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 that line works, you're in business. If not, fix that first - nothing else matters yet. - 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. A batch job that fails silently at 2am is a special kind of misery.
python -m pip install -e ".[dev]"
02Initialize The SDK
Initialize once, reuse the instance everywhere, let it go when the process ends. The SDK holds global state, so spinning up multiple instances is asking for weirdness - that's why most people wrap it in a singleton and move on with their lives.
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 initialization 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). Rule of thumb: pipelines for anything real, processors when you need to poke at the scene mid-process. 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 has the full rant.
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 throws the original topology in the bin and builds a fresh shell around whatever's visible. Sounds drastic. It is. That's why it's perfect for proxies, HLODs, and 3D scans where the topology was garbage anyway. The catch: new mesh means new UVs, so the original textures no longer fit. The casters below handle that by re-baking color, normals, and friends onto the new surface.
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. Draw calls plummet, silhouettes stay pixel-identical. Kitbash props and modular buildings love this.
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
Batch jobs live or die on this: check the asset before you spend two minutes processing it. Degenerate triangles, missing UVs, broken material refs, bad bone weights - catch them at the door and your overnight run finishes instead of face-planting on asset 12 of 400.
| Check | Why It Matters |
|---|---|
| Triangles and normals | Bad geometry can create reduction artifacts, 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 eats a folder. The useful bits are boring but non-negotiable: one bad mesh should not kill the batch, every failure needs a clear log entry, and output paths need to be predictable enough for the next tool to 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, serialized pipeline files per platform, and - if your license covers it - distributed processing across the farm. Each one is an afternoon, not a project.
The Technical Artist