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.

Symmetrical looking tree asset.

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.

Tree and billboard of tree

Here is the output textures.

Color texture

Normal texture

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.

Resulting cross billboard and original asset

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.

Original
Cross billboard

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()
⇐ Back to all posts

Request 30-days free evaluation license

*
*
*
*
Industry
*

Request 30-days free evaluation license

*
*
*
*
Industry
*