"A complete Blender-headless → s&box pipeline"
You need a repeatable path from authored (or generated) meshes into s&box with correct scale, facing, materials, and a single source of truth for sizes/colliders — without hand-clicking ModelDoc for every prop.
Ad-hoc exports drift: wrong axes, meters vs inches, missing remaps, no catalog of world-space bounds. The fix is a two-stage pipeline — Blender only builds meshes; a second pure-Python stage owns engine wrappers and codegen.
Stage 1 — model generation (Blender headless)
blender --background --python tools/gen_models.pyConventions per model function:
bpy.ops.wm.read_factory_settings(use_empty=True)— clean slate each model.- Build from primitives in real-world meters; chunky low-poly; shared flat-color palette.
- Materials:
use_nodes=True, set Principled Base Color anddiffuse_color(that's what the OBJ exporter writes asKd). - Join everything,
transform_apply, ground at z=0 (obj.location.z -= min_z) — except parts with joint pivots. - Export:
bpy.ops.wm.obj_export(
filepath=path,
export_materials=True,
forward_axis='NEGATIVE_Z',
up_axis='Y',
)Animated parts: export limbs/rotors as separate models with the origin at the joint/axle (ground=False). Assemble in C# as child GameObjects and drive LocalRotation. Map Blender child offsets through:
world = (-bY * s, -bX * s, bZ * s)State-swap models (e.g. picked bush): generate variants with the same random seed so the silhouette matches; swap renderer.Model at runtime.
Stage 2 — asset conversion (pure Python, no Blender)
python tools/gen_assets.pyDoes four things:
- Textures — PNG writer + tileable noise for ground/sky as needed.
- Materials — one
.vmatper palette color using sharedwhite.png+g_vColorTint(constant-colorTextureColordoes not reliably compile). - Models — copy OBJ+MTL into
Assets/models/<project>/, emit a.vmdlper model: KV3 header +MaterialGroupListremaps (bothNameandName.vmat) +RenderMeshFilewithimport_scaleand zero rotation/translation. - C# catalog — e.g.
Models.generated.cs: name → vmdl path + world-spaceSize/Centerviaworld = (-objZ, -objX, objY) * scale. Colliders and placement footprints read from this — regenerates with the art.
Scaling: two approaches
| Approach | When | Rule |
|---|---|---|
| Uniform scale (preferred) | You author in meters | import_scale = 39.37 for everything |
| Per-model target size | Imported kits (Kenney) with unknown native scale | scale = target_units / native_extent from OBJ bounds |
Checklist — new model
- Add generator fn + register in
GENERATORS. - Add its name to
MODELSin the assets script. - Run both tools; check printed world-space size is sane.
- Reference via your catalog (
PathOf("name")), not a hand-typed path.
Sanity before batching
- One angled piece (roof/ramp): confirm rotation sign visually.
- One placed instance: confirm facing (Blender +X → world −Y; yaw +90 → +X).
- Flat ground decals: top surface must sit above the ground plane top.
Splitting mesh authoring from engine packaging keeps Blender ignorant of inches/vmdl/KV3 while the Python stage enforces the remaps, 39.37 scale, and a generated catalog so gameplay code never hardcodes bounds that drift from the mesh. The axis map is applied once in catalog math and once by the OBJ importer — matching conventions keep facing yaw constants valid across the whole set.