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.
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.
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.
After calculating the blue channel it needs to be merged together with red and green channels to create the full normal map.
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.
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:])