"A complete Blender-headless → s&box pipeline"
▸ SYMPTOM
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.
▸ CAUSE
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.
▸ FIX
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.
▸ WHY IT WORKS
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.