Use shading network to recreate normal maps

Written by Jesper Tingvall, Product Expert, Simplygon

Disclaimer: The code in this post is written using version 9.1.36100 of Simplygon. If you encounter this post at a later stage, some of the API calls might have changed. However, the core concepts should still remain valid.

Introduction

A common way of saving texture memory is to compress normal maps and just use two channels instead of three. In this example from Anno 1800, by Ubisoft Mainz, the blue channel of the normal map is used to store emissive data. This blog post describes how to create a shading network allowing Simplygon to optimize such assets. In our case we will aggregate two adjacent models to reduce draw calls.

House and advertisement pillar

Prerequisites

This example will use the Python Simplygon integration in 3ds Max, but the same concepts can be applied to all other integrations of the Simplygon API.

Problem to solve

The asset we want to optimize with Simplygon have a normal map where the blue channel contains emissive data. Normal Map / Emissive Map / Compressed normal map with emissive data in blue channel

Solution

The solution is to create a custom shading network which can recreate the entire normal map and create an emissive channel.

Normal map encoding

A normal's component values goes from -1 to 1. However when it is saved to an RGB texture it can not have negative color values. Thus the input range is 0->1 instead of -1->1. Before we do any processing we need to expand it so it covers the entire range. This can be done with the formula normal_map_in_correct_range = input_normal_map * [2, 2, 2, 1] - [1, 1, 1, 0]. The constants can either be ShadingColorNodes or default parameters in the multiply and subtraction nodes.

With ShadingColorNodes the constants are more clear and can be labeled.

# Go from 0 -> 1 to -1 -> 1
def decode_normal(sg, input_node):
    double_xyz = sg.CreateShadingColorNode()
    double_xyz.SetColor(2,2,2,1)
    
    one_xyz = sg.CreateShadingColorNode()
    one_xyz.SetColor(1,1,1,0)
    
    doubled = sg.CreateShadingMultiplyNode()
    doubled.SetInput(0, input_node)
    doubled.SetInput(1, double_xyz)
    
    output = sg.CreateShadingSubtractNode()
    output.SetInput(0, doubled)
    output.SetInput(1, one_xyz)
    return output

With default parameters the code is more dense. This is the style we are going to use in the rest of the script.

# Go from 0 -> 1 to -1 -> 1
def decode_normal(sg, input_node):
    doubled = sg.CreateShadingMultiplyNode()
    doubled.SetInput(0, input_node)
    doubled.SetDefaultParameter(1, 2, 2, 2, 1)
    
    output = sg.CreateShadingSubtractNode()
    output.SetInput(0, doubled)
    output.SetDefaultParameter(1, 1, 1, 1, 0)
    return output

After processing the normal map we need to encode it back from -1->1 to 0->1 range. This can be done with the formula output_normal_map = (processed_normal_map + [1, 1, 1, 0]) * [0.5, 0.5, 0.5, 1].

# Go from -1 -> 1 to 0 -> 1
def encode_normal(sg, input):
    positive = sg.CreateShadingAddNode()
    positive.SetInput(0, input)
    positive.SetDefaultParameter(1, 1, 1, 1, 0)
    
    normal = sg.CreateShadingMultiplyNode()
    normal.SetInput(0, positive)
    positive.SetDefaultParameter(1, 0.5, 0.5, 0.5,1)
    return normal

Recreating blue channel from red and green channels

Since the normal map describes a vector of length one we can recreate the blue value from just knowing red and green value by the formula blue = sqrt(1 - red*red - green*green). This can be realized by the following shading network.

Shading network describing creation of blue channel from red and green channels.

After calculating the blue channel it needs to be merged together with red and green channels to create the full normal map.

Shading network describing merging blue, red and green channels.

Here is the corresponding code to generate both shading networks.

# From R, G recreate B channel in a normal map
def regenerate_blue_normal_channel(sg, input_node):  
    input_rg = sg.CreateShadingMultiplyNode()
    input_rg.SetInput(0, input_node)
    input_rg.SetDefaultParameter(1, 1,1,0,0)
    
    input_rg1 = sg.CreateShadingAddNode()
    input_rg1.SetInput(0, input_rg)
    input_rg1.SetDefaultParameter(1, 0,0,1,0)
    
    invert_rg1 = sg.CreateShadingMultiplyNode()
    invert_rg1.SetInput(0, input_rg1)
    invert_rg1.SetDefaultParameter(1, -1, -1, 1, 1)
    
    dot = sg.CreateShadingDot3Node()
    dot.SetInput(0, input_rg1)
    dot.SetInput(1, invert_rg1)
    
    bbb = sg.CreateShadingSqrtNode()
    bbb.SetInput(0, dot)
    
    b = sg.CreateShadingMultiplyNode()
    b.SetInput(0, bbb)
    b.SetDefaultParameter(1, 0, 0, 1, 0)
    
    input_rga = sg.CreateShadingMultiplyNode()
    input_rga.SetInput(0, input_node)
    input_rga.SetDefaultParameter(1, 1, 1, 0, 1)
    
    output = sg.CreateShadingAddNode()
    output.SetInput(0, b)
    output.SetInput(1, input_rga)    
    return output

Emissive shading network

To create the emissive map we need to take the blue color from the input normal map and create a black and white image from it.

# Mask out B-channel and put it into RGB
def create_emissive_network(sg, input_node):
    mask_b = sg.CreateShadingColorNode()
    mask_b.SetColor(0,0,1,0)
    
    emissive = sg.CreateShadingDot3Node()
    emissive.SetInput(0, input_node)
    emissive.SetInput(1, mask_b)
    return emissive

Normal caster

With the shading network describing how to recreate the normal map we can use a NormalCaster to generate our normal map. Make sure to set the NormalCasterSettings to correspond to your game engine.

# Normal caster
caster = sg.CreateNormalCaster()
caster_settings = caster.GetNormalCasterSettings()
caster_settings.SetGenerateTangentSpaceNormals(True)

# Change these settings to correspond to your engine
#caster_settings.SetFlipGreen(False)
#caster_settings.SetCalculateBitangentPerFragment(False)
#caster_settings.SetNormalizeInterpolatedTangentSpace(False)

caster_settings.SetMaterialChannel("bump") 
pipeline.AddMaterialCaster( caster, 0 )

Emissive caster

A ColorCaster can be used to generate the emissive map.

# Emissive caster
caster = sg.CreateColorCaster()
caster_settings = caster.GetColorCasterSettings()
caster_settings.SetMaterialChannel("emit_color") 
pipeline.AddMaterialCaster( caster, 0 )

Result

The resulting shading network allows Simplygon to optimize assets where the normal map is compressed. It outputs an ordinary normal map and emissive texture. These textures can then be fed back into the texture optimization toolchain.

Albedo / Normal map / Emission map

Resulting aggregated house asset

Complete script

The following script exports all objects from 3ds Max, runs an aggregation and reimports result. It recreates blue channel in a compressed normal map and creates an emissive textures from the blue channel. Depending on your game engine you might need to change tangent space method using SetGlobalDefaultTangentCalculatorTypeSetting.

# Copyright (c) Microsoft Corporation. 
# Licensed under the MIT license. 

from pymxs import runtime as rt
from simplygon import simplygon_loader
from simplygon import Simplygon
import gc

# Go from -1 -> 1 to 0 -> 1
def encode_normal(sg, input):
    positive = sg.CreateShadingAddNode()
    positive.SetInput(0, input)
    positive.SetDefaultParameter(1, 1, 1, 1, 0)
    
    normal = sg.CreateShadingMultiplyNode()
    normal.SetInput(0, positive)
    positive.SetDefaultParameter(1, 0.5, 0.5, 0.5,1)
    return normal

# Go from 0 -> 1 to -1 -> 1
def decode_normal(sg, input_node):
    doubled = sg.CreateShadingMultiplyNode()
    doubled.SetInput(0, input_node)
    doubled.SetDefaultParameter(1, 2, 2, 2, 1)
    
    output = sg.CreateShadingSubtractNode()
    output.SetInput(0, doubled)
    output.SetDefaultParameter(1, 1, 1, 1, 0)
    return output

# From R, G recreate B channel in a normal map
def regenerate_blue_normal_channel(sg, input_node):  
    input_rg = sg.CreateShadingMultiplyNode()
    input_rg.SetInput(0, input_node)
    input_rg.SetDefaultParameter(1, 1,1,0,0)
    
    input_rg1 = sg.CreateShadingAddNode()
    input_rg1.SetInput(0, input_rg)
    input_rg1.SetDefaultParameter(1, 0,0,1,0)
    
    invert_rg1 = sg.CreateShadingMultiplyNode()
    invert_rg1.SetInput(0, input_rg1)
    invert_rg1.SetDefaultParameter(1, -1, -1, 1, 1)
    
    dot = sg.CreateShadingDot3Node()
    dot.SetInput(0, input_rg1)
    dot.SetInput(1, invert_rg1)
    
    bbb = sg.CreateShadingSqrtNode()
    bbb.SetInput(0, dot)
    
    b = sg.CreateShadingMultiplyNode()
    b.SetInput(0, bbb)
    b.SetDefaultParameter(1, 0, 0, 1, 0)
    
    input_rga = sg.CreateShadingMultiplyNode()
    input_rga.SetInput(0, input_node)
    input_rga.SetDefaultParameter(1, 1, 1, 0, 1)
    
    output = sg.CreateShadingAddNode()
    output.SetInput(0, b)
    output.SetInput(1, input_rga)    
    return output

# Mask out B-channel and put it into RGB
def create_emissive_network(sg, input_node):
    emissive = sg.CreateShadingDot3Node()
    emissive.SetInput(0, input_node)
    emissive.SetDefaultParameter(1, 0, 0, 1, 0)
    return emissive

def run_pipeline(sg, import_path, processed_path, textures_path):
    # Set what tangent space method you use
    # https://documentation.simplygon.com/SimplygonSDK_9.1.39000.0/api/reference/enums/etangentspacemethod.html#values
    sg.SetGlobalDefaultTangentCalculatorTypeSetting(Simplygon.ETangentSpaceMethod_Autodesk3dsMax)
    
    # Import model
    sceneImporter = sg.CreateSceneImporter()
    sceneImporter.SetImportFilePath(import_path)
    sceneImporter.RunImport()
    scene = sceneImporter.GetScene()
    
    material_count = scene.GetMaterialTable().GetMaterialsCount()

    # Set up shader networks for all materials
    for i in range(0, material_count):
        print('Creating shading network for material ',i)
        material = scene.GetMaterialTable().GetMaterial(i)
        
        # Normal
        normal_input = material.GetShadingNetwork("bump")
        if normal_input is not None:
            normal = decode_normal(sg, normal_input)
            regenerated_normal = regenerate_blue_normal_channel(sg, normal)
            recoded_normal = encode_normal(sg, regenerated_normal)
            material.SetShadingNetwork("bump", recoded_normal)
            
            # Emissive
            emissiveNode  = create_emissive_network(sg, normal_input)
            material.SetShadingNetwork("emit_color", emissiveNode)

    pipeline = sg.CreateAggregationPipeline()
    pipeline.GetPipelineSettings().SetTextureOutputPath(textures_path)
    mapping_image_settings = pipeline.GetMappingImageSettings()
    mapping_image_settings.SetGenerateMappingImage(True)
    mapping_image_settings.SetTexCoordName("MaterialLOD")
    
    # Reuse UV coordinates
    mapping_image_settings.SetTexCoordGeneratorType( Simplygon.ETexcoordGeneratorType_ChartAggregator )
    sgChartAggregatorSettings = mapping_image_settings.GetChartAggregatorSettings()
    sgChartAggregatorSettings.SetChartAggregatorMode( Simplygon.EChartAggregatorMode_SurfaceArea )
    sgChartAggregatorSettings.SetSeparateOverlappingCharts( False )
    
    material_settings = mapping_image_settings.GetOutputMaterialSettings(0)
    material_settings.SetTextureWidth(2048)
    material_settings.SetTextureHeight(2048)

    # Color caster
    caster = sg.CreateColorCaster()
    caster_settings = caster.GetColorCasterSettings()
    caster_settings.SetMaterialChannel("base_color") 
    pipeline.AddMaterialCaster( caster, 0 )

    # Normal caster
    caster = sg.CreateNormalCaster()
    caster_settings = caster.GetNormalCasterSettings()
    caster_settings.SetGenerateTangentSpaceNormals(True)
    
    # Change these settings to correspond to your engine
    #caster_settings.SetFlipGreen(False)
    #caster_settings.SetCalculateBitangentPerFragment(False)
    #caster_settings.SetNormalizeInterpolatedTangentSpace(False)

    caster_settings.SetMaterialChannel("bump") 
    pipeline.AddMaterialCaster( caster, 0 )

    # Emissive caster
    caster = sg.CreateColorCaster()
    caster_settings = caster.GetColorCasterSettings()
    caster_settings.SetMaterialChannel("emit_color") 
    pipeline.AddMaterialCaster( caster, 0 )

    # Export
    print("Running remeshing process...")
    pipeline.RunScene(scene, Simplygon.EPipelineRunMode_RunInThisProcess)        
    scene_exporter = sg.CreateSceneExporter()
    scene_exporter.SetExportFilePath(processed_path)
    scene_exporter.SetScene(scene)
    scene_exporter.RunExport()
    print('LODS done!')

def cleanup():
    # Clear previous export mapping
    rt.sgsdk_ClearGlobalMapping()
    rt.sgsdk_Reset()
    
def export_scene(path):
    # Select all objects
    rt.select(rt.objects)

    # Export scene to file
    bResult = rt.sgsdk_ExportToFile(path, False)
    
def import_scene(path):
    # Format import string
    print('Importing...')
    rt.sgsdk_SetInitialLODIndex(1)
    rt.sgsdk_SetMeshNameFormat('{MeshName}_LOD{LODIndex}')

    # Import processed file into Max, LOD-index = 1,
    bResult = rt.sgsdk_ImportFromFile(path, True, True, True)
    print('Import result: ', bResult)

def main(argv):
    export_path = 'C:/Temp/ExportedScene.sb'
    processed_path = 'C:/Temp/ProcessedScene.sb'
    textures_path = 'C:/Temp'
    
    cleanup()
    
    export_scene(export_path)

    # Initialize Simplygon
    sg = simplygon_loader.init_simplygon()
    
    # Reduce exported file
    run_pipeline(sg, export_path, processed_path, textures_path)

    import_scene(processed_path)
    
    # De-initialize Simplygon
    sg = None
    gc.collect()
    print('End')


if __name__== "__main__":
    main(sys.argv[1:])
⇐ Back to all posts

Request 30-days free evaluation license

*
*
*
*
Industry
*

Request 30-days free evaluation license

*
*
*
*
Industry
*