The offscreen wgpu pipeline

A renderer-agnostic wgpu pipeline: input is a Scene of RenderMeshes (world-space transform per mesh), a look-at camera, a directional+ambient light; output is RGBA8 pixels → PNG. There is no window or surface — it renders into a texture and reads it back, so it works over SSH and in CI wherever a GPU adapter (Vulkan/GL/Metal/DX) exists. render_to_rgba returns Err when no adapter is available, and the crate's smoke test skips.

  • One merged vertex buffer (transforms baked CPU-side), one uniform; the index buffer is grouped into per-texture batches — one draw per distinct texture.
  • A Scene carries a texture pool; untextured batches bind a 1×1 white texel so the shader path is uniform. UVs are V-flipped (FBX/glTF are bottom-left origin, wgpu samples top-left), and an alpha cutout at 0.5 keeps foliage/hair/decal cards from rendering as opaque squares.
  • 4× MSAA + depth buffer; two-sided Lambert + ambient, because imported meshes have inconsistent triangle winding.
This is where glam enters the CLI dependency graph for the first time — confined to the renderer and the CLI's render_scene/world modules, per the workspace invariant.

Rendering an avatar

avatar render --avatar model.fbx -o out.png

The avatar loader imports the mesh(es) and renders the rest/bind pose from the raw control points — it deliberately does not apply the FBX skin-bind matrices. Ripped and converted avatars (notably MMD→FBX) ship inconsistent per-cluster Transform matrices, so linear-blend skinning through them blends opposing rotations and the mesh collapses into spikes; the control points are always a clean, undeformed bind.

The model is auto-uprighted by aligning its hips→head axis to +Y, measured from cluster centroids in control-point space (weights + control points are reliable; the bind matrices are not) — a model authored lying down or sideways comes out standing. Non-humanoid rigs fall back to the file's declared UpAxis. The avatar is textured from its FBX-embedded materials (per material slot, embedded blobs or files resolved relative to the FBX), and the camera auto-frames the bounds (--yaw/--pitch orbit).

Pose-adjacent previews

  • --stretch HINGE:FACTOR (repeatable, * wildcards, FBX only) previews the chain-length edit avatar physbone stretch makes: bones below the hinge have their parent offsets scaled and the mesh is CPU-skinned through the resulting pose. The one place the renderer poses — it uses the pose delta only, identity everywhere the pose is untouched, never the untrusted per-cluster binds.
  • --material-texture NAME=IMAGE draws a material with a different image — preview a texture edit, or draw a hair material with its emission map to see where a glow lands.
  • --pose PREFAB (FBX only) takes every bone's local transform from the prefab's same-named GameObject — the render shows what Unity will show for that prefab (stretched and re-angled chains, hand-posed bones), undoing Unity's import mirroring and import scale. Verified: posing from an untouched migration prefab (152 bones) reproduces the rest render to the pixel.

Rendering a world

avatar render --world scene.unity -o out.png avatar render --world path/to/project -o out.png

The world loader parses a Unity .unity scene and emulates enough of Unity's FBX import pipeline to place geometry correctly: Transform/MeshFilter/MeshRenderer/PrefabInstance objects are read with a lossy YAML parse, world matrices composed up the m_Father chain, each FBX mesh placed by its Model node's transform up the FBX parent chain, and Unity's import scale applied — fixing cm-unit props that would otherwise render 100× too big. Prefab-instanced models (whose meshes are never serialized into the scene) are re-instantiated from m_SourcePrefab; materials resolve through scene .mats or the model importer's material remap, falling back to FBX-embedded materials and then neutral grey. Unity's left-handed Y-up space is converted to the renderer's right-handed space.

Validated against the Cozy Cabin world (PC export): the cabin assembles at correct scale (~6 m), props at real-world sizes, everything textured — alpha-cut foliage as proper pine.

Dropping an avatar into a world

avatar render --avatar model.fbx --world scene.unity -o out.png

With both, the avatar is placed where VRChat would materialise a player: the scene's spawn is resolved from the VRC_SceneDescriptor's first spawns[] transform (falling back to the VRCWorld GameObject), the avatar is normalised to human height (1.6 m — ripped/MMD FBX units vary wildly), and its feet stand on the spawn point. The camera defaults to framing on the avatar with the map around it; --frame world frames the whole scene. Validated: the SDK2 avatar stands at the Cozy Cabin's real spawn point, on the porch, at correct scale.

Interactive viewer — avatar view

avatar view --avatar X --world Y opens a native window onto the same assembled scene instead of writing a PNG: drag to orbit, wheel to zoom, WASD (+Space/Shift) to walk the focus point, R to reset, Esc to quit. It reuses the offscreen geometry/shader pipeline but draws to a live swapchain, opening at the same framing the PNG would produce. The CLI's viewer feature is on by default (winit builds headlessly and only needs a display at runtime); --no-default-features gives a pure-offscreen binary.

Limits

A static preview, not a pixel-accurate Unity render
Shading is flat-lit: one base-colour texture × tint under a single Lambert light — no normal/metallic/emission maps, no transparency blending (only the 0.5 alpha cutout), no lightmaps or custom shaders. Texture decode is limited to what image handles (PNG/JPEG/TGA/BMP/GIF — no DDS/PSD/EXR); undecodable textures fall back to the material's flat colour.
  • FBX transform fidelity — only Lcl translation/rotation/scaling is composed: no per-node RotationOrder, pre/post-rotation, pivots, geometric transforms, or scale-inheritance modes. Rare on static world props; edge cases can be mis-oriented.
  • No prefab nesting or per-platform import overrides; only the first material per renderer is read for colour.
  • The standalone avatar render is the rest pose — full posed skinning would need the bind matrices this class of asset can't be trusted to provide (the --stretch/--pose delta trick sidesteps that).