avatar-render (lib avatar_render, CLI avatar render) is the renderer layer of the toolchain:
an offscreen GPU pipeline that turns an avatar — and a Unity world scene — into a PNG, headless.
It builds on the runtime rig layer (FBX/glTF import → RawMesh) and the Unity-YAML reader.
A renderer-agnostic wgpu pipeline. Input: a Scene of RenderMeshes (world-space transform per
mesh), a look-at Camera, a directional+ambient Light, a clear colour. Output: RGBA8 pixels →
PNG. No window/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.
- One merged vertex buffer (mesh transforms baked CPU-side) + a single uniform (view-projection + light). The index buffer is grouped into per-texture batches (one draw per distinct texture), so a textured scene still uploads one vertex buffer and binds each texture once.
- Textures. A [
Scene] carries atexturespool; each [RenderMesh] has UVs and an optional index into it. Textured batches bind atexture_2d+ sampler (group 1); untextured ones bind a 1×1 white texel so the shader path is uniform (base = texture × vertex tint). UVs are V-flipped (FBX/glTF are bottom-left origin, wgpu samples top-left). Alpha cutout at 0.5 discards transparent texels so foliage / hair / decal cards (transparent PNGs) don't render as opaque squares; opaque textures are unaffected. - 4× MSAA + depth buffer; right-handed camera with
0..1clip depth (glam::Mat4::perspective_rh). - Two-sided Lambert + ambient: imported meshes have inconsistent triangle winding, so both faces are lit rather than going black.
render_to_rgbareturnsErrwhen no GPU adapter is available; the CLI surfaces that and the crate's smoke test skips.
glam enters the CLI dependency graph for the first time here (the renderer is built on it),
confined to the CLI's render_scene/world modules.
The avatar loader (crates/cli/src/render_scene.rs):
- Imports the mesh(es) via
FbxDocument::meshes()/GltfDocument::meshes(). - Uses the raw control points as the bind geometry. It deliberately does not apply the FBX
skin-bind matrices: ripped/converted avatars (notably MMD→FBX) ship inconsistent per-cluster
Transformmatrices, so linear-blend skinning blends opposing rotations and the mesh collapses into spikes. The control points are always a clean, undeformed bind. - Auto-uprights the model by aligning its hips→head axis to +Y. That axis is measured from the
cluster centroids in control-point space (weights + control points are reliable; the bind
matrices are not), so a model authored lying down, sideways, or upside down comes out standing.
Non-humanoid rigs fall back to the file's declared
UpAxis. - Textures the avatar from its FBX-embedded materials: each mesh is split per material slot, and
each slot's diffuse texture (an embedded
Contentblob, or a file resolved relative to the FBX) and diffuse colour are applied. Meshes without materials fall back to a per-submesh palette colour so parts stay visually distinct. - Auto-frames the camera (
--yaw/--pitchorbit) to the geometry's bounds.
This renders the avatar in its rest/bind pose. (Full posed skinning would need the bind matrices, which this class of asset can't be trusted to provide; see the note above.)
--stretch HINGE:FACTOR (repeatable, * wildcards in HINGE, FBX only) previews a
chain-length change — the edit avatar physbone stretch makes to a prefab — on the
FBX: every bone below the bones matching HINGE has its offset from its parent scaled by
FACTOR, and each skinned mesh is CPU-skinned through the resulting pose. This is the one place
the renderer poses: it uses the pose delta only — per bone G⁻¹ · world(posed) · world(rest)⁻¹ · G, with G the mesh node's global transform (the space its raw control points
live in) — so it is identity everywhere the pose is untouched and never touches the untrusted
per-cluster bind Transforms. avatar render --avatar m.fbx --stretch 'Skirt_0_*:1.5' shows a
50 %-longer skirt; a bone name that matches nothing is an error.
--material-texture NAME=IMAGE (repeatable) draws an FBX material with that image instead
of its own diffuse texture — preview a texture edit, or draw a hair material with its emission
map to see where the glow lands on the mesh (which is how the mikunpc crown patch was found and
the fbx reslot
fix verified).
--pose PREFAB (FBX only) goes further: every bone's local transform is taken from the
prefab's GameObject of the same name (ambiguous names skipped), so the render shows what Unity
will show for that prefab — stretched and re-angled chains, hand-posed bones. Unity's import
mirrors the FBX hierarchy (X negated), so a prefab local (p, q) maps back to
((−p.x, p.y, p.z), (q.x, −q.y, −q.z, q.w)), positions divided by the import scale
(UnitScaleFactor/100); the same delta-only skinning applies. Verified on the real avatar: posing
from its untouched migration prefab (152 bones) reproduces the rest render to the pixel.
The world loader (crates/cli/src/world.rs) parses a Unity .unity scene and emulates enough of
Unity's FBX import pipeline to place geometry at the right scale and assemble multi-mesh /
prefab-instanced models:
- Reads the
Transform(4),MeshFilter(33),MeshRenderer(23) andPrefabInstance(1001) objects with the Unity-YAML reader's lossy parse (scenes contain MonoBehaviours whose serialized scalarsyaml-rust2rejects; only those object types are needed, so the rest is skipped). - Composes each scene transform's world matrix up the
m_Fatherchain (memoized, cycle-guarded). - FBX node-world transforms.
avatar_fbx::meshes()returns each mesh in its own geometry space; a multi-mesh FBX only assembles once each mesh is placed by itsModelnode's transform, composed up the FBXOOparent chain (EulerXYZ, degrees). This is what turns the cabin's 115 meshes from a pile at the origin into a building. - Import scale. Unity bakes the model import scale (
useFileScale/globalScale×UnitScaleFactor/100, read from the FBX + its.meta) into the imported mesh, so we apply it to the raw FBX geometry — fixing props from cm-unit FBX files that would otherwise render 100× too big. - Prefab instances. An instanced model's visible meshes are not serialized into the scene —
only a stripped placeholder plus the instance's root override (
m_Modification). We resolvem_SourcePrefab→ FBX and re-instantiate every mesh atworld(m_TransformParent) · root_local · import_scale · node_world(the root scale is the import scale, a prefab default that never appears in the scene). This is how the cabin shell renders. - Directly-placed MeshFilters keep using the raw mesh geometry (× import scale) at their GameObject's transform — what Unity does when a shared sub-mesh is assigned to a plain GameObject.
- Materials + textures. Each renderer's
m_Materialsslots are resolved (per material GUID) to their.mat's base colour (_Color) and base texture (_MainTex→ asset → decoded pixels), and the mesh is split per slot so each slot draws with its own texture/tint. Prefab-instanced models (the cabin) have no scene-side material, so their materials are resolved instead through the model importer's material remap (World.fbx.meta'sexternalObjects: FBX material name → project.mat), falling back to the FBX-embedded material's texture/colour. Unresolved materials fall back to neutral grey. - Converts Unity's left-handed Y-up space to the renderer's right-handed space (negating Z).
Validated against the Cozy Cabin world (PC export): the cabin assembles at correct scale (~6 m)
with its surrounding low-poly trees, props (clocks, iPad, pens) at correct real-world sizes inside
it, and the trees / cabin / props render textured (the alpha-cut foliage as proper pine, the
cabin's remapped materials, the props' _MainTex).
Remaining limitations (still a static preview, not a pixel-accurate Unity render):
- Shading is flat-lit, not shader-accurate. A single base-colour texture × tint under one
Lambert light — no normal/metallic/emission maps, transparency blending (only a 0.5 alpha cutout),
lightmaps, or custom shaders. Texture formats are limited to what
imagedecodes (PNG/JPEG/TGA/ BMP/GIF — no DDS/PSD/EXR); undecodable textures fall back to the material's flat colour. - FBX transform fidelity. We compose
LclTranslation/Rotation/Scaling only — no per-nodeRotationOrder, pre/post-rotation, rotation/scale pivots, geometric transforms, orInheritTypescale-inheritance modes. Uncommon on static world props; rare cases can be mis-oriented/scaled. - No prefab nesting or per-platform import overrides, and only the first material per renderer is read for colour.
With both an avatar and a world, the avatar is placed where VRChat would materialise a player:
the world loader resolves the scene's player spawn (the VRC_SceneDescriptor's first spawns[]
transform, falling back to the VRCWorld GameObject's transform) and returns it in renderer space
(WorldLoad::spawn). render_scene::load_avatar_in_world then normalises the avatar to human
height (1.6 m — regardless of its authored units, since ripped/MMD FBXs vary wildly) and stands its
feet on the spawn point. Without spawn placement the avatar would sit at the world origin at its own
scale and be lost beside the map.
The camera defaults to framing on the avatar (its bounds grown ~2.4× so the map shows around it);
--frame world frames the whole scene instead, --frame avatar forces avatar-framing. A standalone
avatar (no world) is framed tightly as before. Validated: the SDK2 avatar stands at the Cozy Cabin's
real spawn point, on the porch, at correct scale.
avatar view opens a native window onto the same assembled scene (built by the shared
assemble_scene) instead of writing a PNG: drag to orbit, wheel to zoom, WASD
(+Space/Shift) to walk the focus point through the cabin, R to reset, Esc to quit. It reuses
the offscreen geometry/shader pipeline (crates/render/src/viewer.rs, feature viewer) but draws to
a live swapchain surface re-rendered every frame from an orbit camera, opening at the same framing
the PNG would produce. The CLI's viewer feature is on by default (winit builds headlessly; it only
needs a display at runtime); --no-default-features drops it for a pure-offscreen binary.
The output is a PNG you can open. The avatar path renders the real SDK2 avatar upright, undistorted,
and textured from its embedded materials. The world path assembles real Unity scenes at correct
scale and texture (validated against the Cozy Cabin world) — geometrically faithful, shaded with
base-colour textures (flat-lit, not shader-accurate) per the limitations above. Combined
--avatar --world drops the avatar at the world's player-spawn point at human scale and frames on
it; avatar view shows the same scene in an interactive orbit/walk window.