How to create a cross billboard impostor
Written by Jesper Tingvall, Product Expert, Simplygon
Disclaimer: The code in this post is written using version 10.4.232.0 of Simplygon and Blender 4.2.13 LTS. 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
In this blog post, we’ll demonstrate how to use impostor from single view, along with some scene graph manipulation—specifically copying and rotating—to create a cross billboard. A cross billboard consists of two axis-aligned impostors, using the same texture in this example.
Prerequisites
This example will use the Simplygon Blender integration and Python API, but the same concepts can be applied to all other integrations of the Simplygon API.
Problem to solve
We have a symmetrical asset that we want to represent with a distant proxy. Our goal is to make it as low-poly as possible while minimizing texture usage.
One potential solution is to use impostor from single view with a special billboard shader to ensure the resulting quad always faces the camera. Another option is a flipbook impostor, which is ideal for non-symmetrical assets. However, flipbook impostors require a custom shader and a lot of texture space. In this case, we’ll assume we can't use a custom shader due to engine limitations—so both alternatives are off the table.
Solution
We'll use an impostor from a single view to bake our asset to a quad. Then we'll duplicate and rotate the quad by 90 degrees to form a cross billboard impostor viewable from all ground angles.
Impostor from single view
Let’s start by creating an impostor from single view and configuring its mapping image. Two important settings to highlight:
UseTightFitting: When set to
true
, the impostor will tightly fit the asset’s shape, instead of using a conservative square.MaximumLayers: Set this to 10 (the maximum value) for vegetation or assets with many overlapping transparent layers. Low values can cause holes in the resulting texture. More details can be found in Impostor: Billboard cloud for vegetation.
def create_impostor_pipline(sg, texture_width, texture_height):
"""Create impostor pipeline with color material caster"""
# Create aggregation pipeline
pipeline = sg.CreateImpostorFromSingleViewPipeline()
# We set UseTightFitting to get a quad that hugs our imposter closely.
settings = pipeline.GetImpostorFromSingleViewSettings()
settings.SetUseTightFitting(True)
# Set mapping image settings to generate new UV coordinates we can use for material casting.
mapping_image_settings = pipeline.GetMappingImageSettings()
mapping_image_settings.SetGenerateMappingImage(True)
mapping_image_settings.SetApplyNewMaterialIds(True)
mapping_image_settings.SetGenerateTangents(True)
mapping_image_settings.SetUseFullRetexturing(True)
mapping_image_settings.SetTexCoordLevel(0)
# On vegetation assets with many transparent layers we suggest to increase Maximum layers
mapping_image_settings.SetMaximumLayers(10)
# Set texture size
material_settings = mapping_image_settings.GetOutputMaterialSettings(0)
material_settings.SetTextureWidth(texture_width)
material_settings.SetTextureHeight(texture_height)
return pipeline
Material casting
We’ll use material casters to generate our textures. For albedo, we use a color caster. For help configuring settings, see our blog on How to find correct settings for a scripted pipeline.
def create_color_caster(sg, channel_name, output_srgb):
"""Create a color caster using specified settings"""
caster = sg.CreateColorCaster()
caster_settings = caster.GetColorCasterSettings()
caster_settings.SetMaterialChannel(channel_name)
caster_settings.SetOutputSRGB(output_srgb)
# Set casting settings specific per integration. If you are using another integration then Blender you might need to change these.
caster_settings.SetOpacityChannel("Opacity")
caster_settings.SetOpacityChannelComponent(3)
caster_settings.SetBakeOpacityInAlpha(True)
caster_settings.SetFillMode(0)
return caster
For proper lighting, we use a normal caster. Correct settings are essential for lighting accuracy.
def create_normal_caster(sg, channel_name, flip_green, generate_tangent_space_normals, normalize_interpolated_tangent_space, calculate_bitangent_per_fragment):
"""Create a normal caster using specified settings"""
caster = sg.CreateNormalCaster()
caster_settings = caster.GetNormalCasterSettings()
# Set casting settings specific per integration. If you are using another integration then Blender you might need to change these.
caster_settings.SetMaterialChannel(channel_name)
caster_settings.SetGenerateTangentSpaceNormals(generate_tangent_space_normals)
caster_settings.SetFlipGreen(flip_green)
caster_settings.SetCalculateBitangentPerFragment(calculate_bitangent_per_fragment)
caster_settings.SetNormalizeInterpolatedTangentSpace(normalize_interpolated_tangent_space)
return caster
Let us look at the result after our impostor pipeline.
Here is the output textures.
Copy node
To duplicate our impostor, we use a helper function that clones a node. Note that we also copy the geometry data to avoid nodes sharing the same data, which can cause issues when processing.
def clone_node(sg, scene, node_name, clone_name):
"""Create clone of node."""
org = Simplygon.spSceneMesh.SafeCast(scene.GetRootNode().FindNamedChild(node_name))
new = Simplygon.spSceneMesh.SafeCast(org.NewCopy())
new.SetName(clone_name)
new.SetGeometry(org.GetGeometry().NewCopy(True))
scene.GetRootNode().AddChild(new)
return new
Putting it all together
We will now put all of our helper functions together to create our impostor. We start by exporting the asset from Blender into Simplygon.
def process_selection(sg):
"""Remove and bake decals on selected meshes."""
sg.SetGlobalDefaultTangentCalculatorTypeSetting(Simplygon.ETangentSpaceMethod_MikkTSpace)
# Export scene from Blender and import it
file_path = temp_path + file
scene = export_selection(sg, file_path)
After that we create an impostor pipeline, add material casters then run it.
# Create impostor pipeline
pipeline = create_impostor_pipline(sg, texture_width, texture_height)
# Create material casters
basecolor_caster = create_color_caster(sg, "Basecolor", True)
normal_caster = create_normal_caster(sg, "Normals", flip_green = False, generate_tangent_space_normals = True, normalize_interpolated_tangent_space = False, calculate_bitangent_per_fragment = True)
# Add material casters
pipeline.AddMaterialCaster(basecolor_caster, 0)
pipeline.AddMaterialCaster(normal_caster, 0)
# Run pipeline and perform material casting
pipeline.RunScene(scene, Simplygon.EPipelineRunMode_RunInThisProcess)
We will now create a clone of our newly created impostor. Then rotate the clone 90 degrees around Y-axis with SetToRotationTransform.
# Create copy of impostor node
new_impostor = clone_node(sg, scene, "Impostor", "Impostor2")
# Rotate cloned node 90 degrees around Y-axis
new_impostor.GetRelativeTransform().SetToRotationTransform(math.radians(90),0,1,0)
Lastly we aggregate the scene so our original and cloned impostor merges into one model. After this we export back into Blender.
# Aggregate the scene so our old and new impostor are one model
aggregation_pipeline = create_aggregation_pipline(sg)
aggregation_pipeline.RunScene(scene, Simplygon.EPipelineRunMode_RunInThisProcess)
# Import result into Blender
import_results(sg, scene, file_path)
Back in Blender we need to set the material properties on our imported material (Metallic and Roughness) to correct values as well as disable backface culling.
Once we have done that we can look at our resulting impostor.
Result
Let us compare the original asset with our optimized asset. From a stats point of view we have a really good optimization of our asset. A good thing with this optimization is that our output triangle count is disconnected from our original triangle count, all assets will be optimized to 4 triangles. In our case we picked a elongated texture size, a future development could be to pick texture size depending on the asset's dimensions.
Model | Triangle count | Texture | Materials |
---|---|---|---|
Original | 15 k | 2x 2048x2048 | 2 |
PolyClean can | 4 | 2x 128x512 | 1 |
Now let us compare visual quality. We can see some differences between the cross billboard and original asset. One reason for this is that the asset is not perfectly symmetrical, this is most visible in the top. The more symmetrical our asset is the better we can expect this method to work.
Those differences aside, what we should do is compare the asset in our engine at proper switch distance. In Blender and other DCC tools it is easy to have the camera to close when doing reviewing.


Complete script
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT license.
import os
import bpy
import gc
import math
from simplygon10 import simplygon_loader
from simplygon10 import Simplygon
file = "scene.glb"
temp_path = "c:/tmp/"
# Change parameters for quality
texture_height = 512
texture_width = 128
def export_selection(sg, file_path):
"""Export the current selected objects into Simplygon."""
bpy.ops.export_scene.gltf(filepath = file_path, use_selection=True)
sceneImporter = sg.CreateSceneImporter()
sceneImporter.SetImportFilePath(file_path)
sceneImporter.Run()
scene = sceneImporter.GetScene()
return scene
def import_results(sg, scene, file_path):
"""Import the Simplygon scene into Blender."""
scene_exporter = sg.CreateSceneExporter()
scene_exporter.SetExportFilePath(file_path)
scene_exporter.SetScene(scene)
scene_exporter.Run()
bpy.ops.import_scene.gltf(filepath=file_path)
def create_impostor_pipline(sg, texture_width, texture_height):
"""Create impostor pipeline with color material caster"""
# Create aggregation pipeline
pipeline = sg.CreateImpostorFromSingleViewPipeline()
# We set UseTightFitting to get a quad that hugs our imposter closely.
settings = pipeline.GetImpostorFromSingleViewSettings()
settings.SetUseTightFitting(True)
# Set mapping image settings to generate new UV coordinates we can use for material casting.
mapping_image_settings = pipeline.GetMappingImageSettings()
mapping_image_settings.SetGenerateMappingImage(True)
mapping_image_settings.SetApplyNewMaterialIds(True)
mapping_image_settings.SetGenerateTangents(True)
mapping_image_settings.SetUseFullRetexturing(True)
mapping_image_settings.SetTexCoordLevel(0)
# On vegetation assets with many transparent layers we suggest to increase Maximum layers
mapping_image_settings.SetMaximumLayers(10)
# Set texture size
material_settings = mapping_image_settings.GetOutputMaterialSettings(0)
material_settings.SetTextureWidth(texture_width)
material_settings.SetTextureHeight(texture_height)
return pipeline
def create_color_caster(sg, channel_name, output_srgb):
"""Create a color caster using specified settings"""
caster = sg.CreateColorCaster()
caster_settings = caster.GetColorCasterSettings()
caster_settings.SetMaterialChannel(channel_name)
caster_settings.SetOutputSRGB(output_srgb)
# Set casting settings specific per integration. If you are using another integration then Blender you might need to change these.
caster_settings.SetOpacityChannel("Opacity")
caster_settings.SetOpacityChannelComponent(3)
caster_settings.SetBakeOpacityInAlpha(True)
caster_settings.SetFillMode(0)
return caster
def create_normal_caster(sg, channel_name, flip_green, generate_tangent_space_normals, normalize_interpolated_tangent_space, calculate_bitangent_per_fragment):
"""Create a normal caster using specified settings"""
caster = sg.CreateNormalCaster()
caster_settings = caster.GetNormalCasterSettings()
# Set casting settings specific per integration. If you are using another integration then Blender you might need to change these.
caster_settings.SetMaterialChannel(channel_name)
caster_settings.SetGenerateTangentSpaceNormals(generate_tangent_space_normals)
caster_settings.SetFlipGreen(flip_green)
caster_settings.SetCalculateBitangentPerFragment(calculate_bitangent_per_fragment)
caster_settings.SetNormalizeInterpolatedTangentSpace(normalize_interpolated_tangent_space)
return caster
def create_aggregation_pipline(sg):
"""Create aggregation pipeline that merges all geometries in the scene."""
aggregation_pipeline = sg.CreateAggregationPipeline()
aggregator_settings = aggregation_pipeline.GetAggregationSettings()
aggregator_settings.SetMergeGeometries( True )
return aggregation_pipeline
def clone_node(sg, scene, node_name, clone_name):
"""Create clone of node."""
org = Simplygon.spSceneMesh.SafeCast(scene.GetRootNode().FindNamedChild(node_name))
new = Simplygon.spSceneMesh.SafeCast(org.NewCopy())
new.SetName(clone_name)
new.SetGeometry(org.GetGeometry().NewCopy(True))
scene.GetRootNode().AddChild(new)
return new
def process_selection(sg):
"""Remove and bake decals on selected meshes."""
sg.SetGlobalDefaultTangentCalculatorTypeSetting(Simplygon.ETangentSpaceMethod_MikkTSpace)
# Export scene from Blender and import it
file_path = temp_path + file
scene = export_selection(sg, file_path)
# Create impostor pipeline
pipeline = create_impostor_pipline(sg, texture_width, texture_height)
# Create material casters
basecolor_caster = create_color_caster(sg, "Basecolor", True)
normal_caster = create_normal_caster(sg, "Normals", flip_green = False, generate_tangent_space_normals = True, normalize_interpolated_tangent_space = False, calculate_bitangent_per_fragment = True)
# Add material casters
pipeline.AddMaterialCaster(basecolor_caster, 0)
pipeline.AddMaterialCaster(normal_caster, 0)
# Run pipeline and perform material casting
pipeline.RunScene(scene, Simplygon.EPipelineRunMode_RunInThisProcess)
# Create copy of impostor node
new_impostor = clone_node(sg, scene, "Impostor", "Impostor2")
# Rotate cloned node 90 degrees around Y-axis
new_impostor.GetRelativeTransform().SetToRotationTransform(math.radians(90),0,1,0)
# Aggregate the scene so our old and new impostor are one model
aggregation_pipeline = create_aggregation_pipline(sg)
aggregation_pipeline.RunScene(scene, Simplygon.EPipelineRunMode_RunInThisProcess)
# Import result into Blender
import_results(sg, scene, file_path)
def main():
sg = simplygon_loader.init_simplygon()
process_selection(sg)
sg = None
gc.collect()
if __name__== "__main__":
main()