Skip to content

Animation System

xAstroBoy edited this page Jul 3, 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.

Fidelity fixes (2026-07-03) — verified source-vs-cooked with pinned poses

All three were found by cooking, loading the cooked APK in the same renderer, and comparing screenshots/data at pinned animation times (HSR_ANIMTIME=<sec>, or live at=<sec> over the bridge — at= now truly pins: it pauses the editor timeline; at=-1 resumes).

Aurora: texture must stay WORLD-STILL

The aurora dome spins while its .sanim UV track counter-scrolls the texture the exact opposite amount — the two cancel, the texture stays world-still, and only the curved geometry bending it animates. The cooked chain (ROTREPLAY vertex + UVSCROLL fragment) had the rotation handedness flipped, so instead of cancelling, the two ADDED: the texture swept half the sky by mid-loop. The flip is removed (cooked t=20 now matches source t=20 exactly); HSR_ROTFLIP=1 re-enables it for A/B testing.

Windmills: one animation group per (skeleton, clip) PAIR

The cook dedups byte-identical skeletons (every 2-joint rigid rig encodes the same), so both polarvillage windmills reference armature7.skel while each mesh's AnimatorPlatformComponent names its own animclipN. The device pairs per component; the desktop HSL preview used to build ONE group per skeleton file with ONE clip — every sharer played the first mesh's clip, so the vista windmill orbited the LODGE windmill's hub 200 units away ("windmill moving everywhere"). The preview now builds one group per (skeleton, clip) pair.

Koi (japan): the glTF inverseBindMatrices carry the unit conversion

Real glTF skinning is Σ w · W_j(t) · IBM_j. The HZANIM port rebuilds the bind pose from the node hierarchy and ships inverse(bindWorld_j) — identical to IBM_j on well-formed rigs, but Maya-exported rigs (japan's kois) bake the unit conversion into the IBMs only: joint nodes have scale 1 while the mesh verts are authored ~70x too big. The cooked fish skinned HUGE. Fix: fold the residual R = bindWorld_j · IBM_j (a global conversion, identical across joints) into the rest vertices — [HZIBM] in the verbose cook log shows it firing. HSR_ACLVERIFY=1 additionally roundtrips per-joint scale through the compressed clip to catch this class of bug.

WYSIWYG tint & lighting

Per-mesh tint edits and the Scene tab's global Lighting sliders now multiply into the cooked COLOR_0 bake — the cooked env matches what the editor shows.

Related: Architecture · File Formats · Roadmap.

Clone this wiki locally