Skip to content

Animation System

xAstroBoy edited this page Jul 2, 2026 · 3 revisions

Animation System (V79 → V205)

V79 environments animate in four distinct ways. Each is decoded from the OPA scene and ported to a matching V205 mechanism. This page explains all four and the bugs that were recently fixed.

The V79 scene graph

An official V79 home ships an entity graph (content/scenes/basescene/<fbx>.entity) that wires components onto nodes. The ones that matter for animation:

Component Meaning
CoModel + CoModelAnimation A model whose .sanim drives node transforms and material params.
CoModelAnimation.MaterialTargets The materials the sanim animates (UV / tint).
CoSkeleton + CoSkinnedMesh A skeleton (.skel + .anim) skinning child meshes (cloth, flags, creatures).

1. Node transform animation (spin / sway / translate)

The .sanim stores per-node Translation / Rotation / Scale tracks (windmill blades, trains, snow instancers, floating props). The loader composes each node's animated world matrix by walking the parent chain (evalAnimNodes). The cook ports it as:

  • spin/sway in place → a getTime() Rodrigues rotation shader (axis + ω or amplitude, about the node pivot);
  • translating nodes (cars/trains) → a 1-joint RIGID-HZANIM clip carrying the exact per-frame world matrix (so arbitrary paths port faithfully).

2. Material UV animation (fire / smoke / aurora / waterfalls) ⭐ recently fixed

The .sanim also carries UVTransform channels — a per-frame 2×3 UV affine [a,b,c, d,e,f] (6 floats/frame). This is how flipbook fire, drifting smoke, scrolling aurora and waterfalls animate, authored inside the node sanim (not a separate .mat.sanim.opa).

Bug (fixed): the sanim parser handled only Translation/Rotation/Scale and silently dropped UVTransform, so every such material rendered static. Worse, hasAnimation() gated on animMaxFrames>1 which was computed from node T/R/S only — so a UV-only env (rockquarry) had hasAnimation()==false and the entire animation path was skipped. Fix: parse UVTransform into matUVAnim, and fold UV/tint track lengths into animMaxFrames.

Two cook targets for a UV track:

  • Continuous scroll (waterfall/water/foam: identity 2×2 + a small constant per-frame translate) → the smooth uv += rate*time shader. Continuous, REPEAT-wrapped, seamless.
  • Flipbook / complex (sprite-cell atlas, or a track with 2×2 scale + tint-fade + motion) → the per-frame matrix-replay fragment shader (FLIPBOOK-UVMATRIX).

Bug (fixed): every UV-animated mesh was being sent to matrix-replay, which snaps each frame → choppy, and the hard per-frame offset jump shows a cell border/seam on a scroll. Waterfalls now detect as pure scrolls and take the smooth path (HSR_NOUVSCROLLFAST forces the old replay). Verify with hsr_dump <idx>[COOK-ANIM] => UV-SCROLL(smooth uv+=rate*time).

3. Skeletal skinning (banners / stringlights / creatures) ⭐ recently fixed

CoSkinnedMesh geometry (.skin.opa) is stored in model-local space; the world placement lives in the skeleton's bind pose. Linear blend skinning: vertex_world(f) = Σ wᵢ · clipJointWorld(f)[cjᵢ] · invBind[bᵢ] · localVert (matches libshell's skinning vertex shader).

Bug (fixed): banners/stringlights collapsed to the origin. The loader bound each skin to a clip via a last-writer-wins joint-name map. A full-scene skeleton (rootnode) duplicates the banner/stringlight joint names of the dedicated sub-skeletons (banners_VFX, strings_lights), so it overwrote the binding → skins bound to the wrong (superset) clip → collapsed to (0,0,0). Fix: bind each skin to the clip that best-matches its own joint set (max coverage, fewest extras) — which reproduces the entity graph's CoSkinnedMesh → CoSkeleton pairing. Global for any env with overlapping skeleton joint names. Large skins auto-split (HSR_SKINCAP) sharing one skeleton/clip.

4. VAT — Vertex Animation Texture (fish / coral / non-skeletal deform)

Some vista meshes animate via a VAT: an R16G16B16A16_SFLOAT texture, width=vertexCount × height=frameCount, each texel = that vertex's offset for that frame. The device vatunlitbasecolor shader samples it by (vertexIndex, time) and does pos += offset. Cooked with a full half-float mip chain (the device expects it) — see File Formats.

How to verify (device-free)

hsr_dump <meshIdx> prints [COOK-ANIM] — the exact device animation/shader that mesh cooks to: UV-SCROLL · FLIPBOOK-UVMATRIX · HZANIM-SKINNED · RIGID-HZANIM · SCALE-getTime · POSE-TRANSLATE · STATIC. It reads the same extractors the real cook uses, so it's authoritative.

Related: Architecture · File Formats · Roadmap.

Clone this wiki locally