01Setup Assumptions
Everything here is written against SDK 10.4 with the simplygon10 Python package. The pip package is just the bindings - you still need the actual SDK installed and licensed on the machine, or nothing below will import.
- Match your Python version to what your SDK build supports (check the docs for your exact version).
- Before building anything real, open a Python shell and try
from simplygon10 import simplygon_loader, Simplygon. If the import fails, check the installation and supported Python version before continuing. - Once settings become "how the studio does it," get them into source control as JSON. Future-you will want the history.
- Log everything - imports, exports, warnings, the lot. Include the asset name and settings in error messages so a failed job can be reproduced.
python -m pip install -e ".[dev]"
02Initialise The SDK
Initialise once, reuse the instance everywhere, let it go when the process ends. The SDK holds global state, so share one initialised instance within the process and control its lifetime explicitly.
import gc
from simplygon10 import simplygon_loader, Simplygon
def create_simplygon():
sg = simplygon_loader.init_simplygon()
if sg is None:
raise RuntimeError(Simplygon.GetLastInitializationError())
print(f"Simplygon {sg.GetVersion()} initialized")
return sg
def shutdown_simplygon(sg):
sg = None
gc.collect()
In a larger package, wrap initialisation in a small SimplygonInstance class so every command shares one SDK instance and your tests can reset it deliberately.
03Import And Export Scenes
Let Simplygon's own importer and exporter handle files - FBX, OBJ, glTF, USD, whatever your SDK build speaks. Same two helpers for every format, no per-format special-casing to maintain.
from pathlib import Path
from simplygon10 import Simplygon
def load_scene(sg, path):
importer = sg.CreateSceneImporter()
importer.SetImportFilePath(str(path))
result = importer.Run()
if Simplygon.Failed(result):
raise RuntimeError(f"Failed to import scene: {path}")
return importer.GetScene()
def save_scene(sg, scene, path):
Path(path).parent.mkdir(parents=True, exist_ok=True)
exporter = sg.CreateSceneExporter()
exporter.SetScene(scene)
exporter.SetExportFilePath(str(path))
result = exporter.Run()
if Simplygon.Failed(result):
raise RuntimeError(f"Failed to export scene: {path}")
And always read the log after a run. The warnings are where Simplygon tells you the interesting stuff - missing materials, dodgy UVs, geometry it quietly skipped. Ignore them and you'll "discover" those problems in-engine instead.
def check_simplygon_log(sg):
if sg.ErrorOccurred():
errors = sg.CreateStringArray()
sg.GetErrorMessages(errors)
for i in range(errors.GetItemCount()):
print(f"Error: {errors.GetItem(i)}")
sg.ClearErrorMessages()
raise RuntimeError("Simplygon reported errors")
if sg.WarningOccurred():
warnings = sg.CreateStringArray()
sg.GetWarningMessages(warnings)
for i in range(warnings.GetItemCount()):
print(f"Warning: {warnings.GetItem(i)}")
sg.ClearWarningMessages()
04Basic Triangle Reduction
Reduction is the everyday one - fewer triangles, same mesh, nobody's editing the result by hand anyway. The SDK gives you two ways in: the Processor API (explicit, you hold every piece) and the Pipeline API (one object that wraps the whole job and can be saved as JSON). Use pipelines when saved settings and repeatable jobs are the priority; use processors when you need more direct control of the processing steps. Here's both, so you can see the difference:
from simplygon10 import Simplygon
def reduce_with_processor(sg, input_path, output_path, ratio=0.5):
scene = load_scene(sg, input_path)
processor = sg.CreateReductionProcessor()
processor.SetScene(scene)
settings = processor.GetReductionSettings()
settings.SetReductionTargets(
Simplygon.EStopCondition_All,
True, # triangle ratio
False, # triangle count
False, # max deviation
False, # on-screen size
)
settings.SetReductionTargetTriangleRatio(ratio)
processor.RunProcessing()
check_simplygon_log(sg)
save_scene(sg, scene, output_path)
from simplygon10 import Simplygon
def reduce_with_pipeline(sg, input_path, output_path, ratio=0.5):
pipeline = sg.CreateReductionPipeline()
settings = pipeline.GetReductionSettings()
settings.SetReductionTargets(
Simplygon.EStopCondition_All,
True, False, False, False
)
settings.SetReductionTargetTriangleRatio(ratio)
pipeline.RunSceneFromFile(
str(input_path),
str(output_path),
Simplygon.EPipelineRunMode_RunInThisProcess,
)
check_simplygon_log(sg)
Ratio ("give me 50%") is easy to reason about, so it's where everyone starts. But screen size ("make it look right at 300 pixels") is what you actually want for LODs - it lets Simplygon decide how many triangles each asset needs instead of you guessing. The tips page compares the target types.
05Generate An LOD Chain
Notice each LOD gets reduced from a fresh copy of the original, not from the previous LOD - that way errors don't compound down the chain. The classic screen-size ladder is 600 / 300 / 100 pixels, and the _LOD0 naming isn't cosmetic: it's what engines auto-import.
from pathlib import Path
from simplygon10 import Simplygon
def reduce_for_screen_size(sg, original_scene, screen_size):
scene_copy = original_scene.NewCopy()
pipeline = sg.CreateReductionPipeline()
settings = pipeline.GetReductionSettings()
settings.SetReductionTargets(
Simplygon.EStopCondition_Any,
False, # triangle ratio
True, # on-screen size
False, # triangle count
False, # max deviation
)
settings.SetReductionTargetOnScreenSize(screen_size)
pipeline.RunScene(scene_copy, Simplygon.EPipelineRunMode_RunInThisProcess)
check_simplygon_log(sg)
return pipeline.GetProcessedScene()
def create_lod_chain(sg, input_path, output_dir, screen_sizes=(600, 300, 100)):
original_scene = load_scene(sg, input_path)
output_dir = Path(output_dir)
for index, size in enumerate(screen_sizes):
lod_scene = reduce_for_screen_size(sg, original_scene, size)
output_path = output_dir / f"{Path(input_path).stem}_LOD{index}.fbx"
save_scene(sg, lod_scene, output_path)
06Remeshing Proxies
Remeshing creates a new surface instead of preserving the original topology. It can suit proxies, HLODs, and scans where the rendered appearance matters more than editable edge flow. Because the new mesh needs new UVs, material casters bake the source channels onto it.
from simplygon10 import Simplygon
def remesh_scene(sg, scene, screen_size=300, texture_size=1024):
pipeline = sg.CreateRemeshingPipeline()
remesh_settings = pipeline.GetRemeshingSettings()
remesh_settings.SetOnScreenSize(screen_size)
mapping = pipeline.GetMappingImageSettings()
mapping.SetGenerateMappingImage(True)
output_material = mapping.GetOutputMaterialSettings(0)
output_material.SetTextureWidth(texture_size)
output_material.SetTextureHeight(texture_size)
color = sg.CreateColorCaster()
color.GetColorCasterSettings().SetMaterialChannel("Diffuse")
pipeline.AddMaterialCaster(color, 0)
normal = sg.CreateNormalCaster()
normal_settings = normal.GetNormalCasterSettings()
normal_settings.SetMaterialChannel("Normals")
normal_settings.SetGenerateTangentSpaceNormals(True)
pipeline.AddMaterialCaster(normal, 0)
pipeline.RunScene(scene, Simplygon.EPipelineRunMode_RunInThisProcess)
check_simplygon_log(sg)
return pipeline.GetProcessedScene()
One gotcha worth knowing now: channel names follow the source format. FBX speaks Diffuse and Normals; glTF speaks PBR names like Basecolor. When a bake comes out black, the channel name is suspect number one.
07Aggregation And Material Merging
Aggregation is the "leave my triangles alone" option: it merges many objects into fewer meshes, and with casters attached it bakes all those material slots down to one atlas. This can reduce draw calls while keeping the source geometry. Check atlas resolution, material features, and engine behaviour on the merged result.
from simplygon10 import Simplygon
def merge_materials(sg, scene, texture_size=2048):
pipeline = sg.CreateAggregationPipeline()
aggregation = pipeline.GetAggregationSettings()
aggregation.SetMergeGeometries(True)
mapping = pipeline.GetMappingImageSettings()
mapping.SetGenerateMappingImage(True)
output_material = mapping.GetOutputMaterialSettings(0)
output_material.SetTextureWidth(texture_size)
output_material.SetTextureHeight(texture_size)
color = sg.CreateColorCaster()
color.GetColorCasterSettings().SetMaterialChannel("Diffuse")
pipeline.AddMaterialCaster(color, 0)
normal = sg.CreateNormalCaster()
normal.GetNormalCasterSettings().SetMaterialChannel("Normals")
normal.GetNormalCasterSettings().SetGenerateTangentSpaceNormals(True)
pipeline.AddMaterialCaster(normal, 0)
pipeline.RunScene(scene, Simplygon.EPipelineRunMode_RunInThisProcess)
check_simplygon_log(sg)
return pipeline.GetProcessedScene()
If one giant atlas would blow your texture budget - or a hero material needs to keep its fancy shader - split into multiple output materials instead of cramming everything into one 8K sheet.
08Validate Before Processing
Validate the source before processing it. Missing UVs, degenerate triangles, broken material references, and invalid skin weights can all affect the output. Log those problems per asset so a failure can be investigated without rerunning the whole batch.
| Check | Why It Matters |
|---|---|
| Triangles and normals | Bad geometry can create reduction artefacts, holes, or unusable mapping images. |
| UV sets | Material casters need valid UVs for baking and texture output. |
| Materials | Missing material channels become missing baked textures. |
| Skin weights | Character reduction and bone reduction must preserve valid influence data. |
| Scale and transforms | Screen-size targets and camera visibility are only meaningful if input scale is sane. |
def validate_before_processing(scene):
issues = []
# Fill this with studio-specific checks:
# - required UV set exists
# - required material channels exist
# - no empty meshes
# - no unsupported file format edge cases
# - skeletal meshes have valid weights
if issues:
raise RuntimeError("\n".join(issues))
09Batch Processor Shape
Here's where it all comes together: a script that processes a folder. The rules that make it production-grade are boring but non-negotiable - one bad mesh doesn't kill the batch, every failure gets logged with a name, and output paths are predictable enough that the next tool in the chain can find them.
import argparse
from pathlib import Path
def iter_assets(root, extensions=(".fbx", ".obj", ".gltf", ".glb")):
root = Path(root)
for path in root.rglob("*"):
if path.suffix.lower() in extensions:
yield path
def process_folder(input_dir, output_dir, ratio=0.5):
sg = create_simplygon()
try:
for source in iter_assets(input_dir):
relative = source.relative_to(input_dir)
output = Path(output_dir) / relative
try:
print(f"Processing {source}")
reduce_with_pipeline(sg, source, output, ratio=ratio)
except Exception as exc:
print(f"FAILED {source}: {exc}")
finally:
shutdown_simplygon(sg)
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("input_dir")
parser.add_argument("output_dir")
parser.add_argument("--ratio", type=float, default=0.5)
args = parser.parse_args()
process_folder(args.input_dir, args.output_dir, args.ratio)
Once this runs clean, the fun upgrades queue up naturally: a progress observer so long jobs aren't a black box, a JSON summary your producer can read, serialised pipeline files per platform, and - if your licence covers it - distributed processing across the farm. Add the next feature when the team has a clear need for it, and test it on a small batch first.
