Skip to content

Animation System

xAstroBoy edited this page Jul 5, 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 — full-length, no frame caps, no joint pruning (official V205 content ships multi-hundred-KB clips; artificial "budgets" were a misdiagnosis and are gone).

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 / scale)

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

  • spin/sway in place → a getTime() Rodrigues rotation shader (axis + ω or amplitude, about the node pivot), or an exact ROTREPLAY (per-frame quaternion replay) when the motion isn't a clean spin — with hemisphere continuity so quaternion sign flips never cause shaking;
  • anything else (paths, scales, combined channels) → a 1-joint RIGID-HZANIM clip carrying the exact per-frame node matrix. Clips keep an inclusive endpoint and wrap-lerp the seam (no "jumpy reset" at the loop), and the bind carries unit scale (clean ACL compression).
  • glTF-sourced envs (.gltf.ovrscene) go through the same rigid port — dropped T+R+S channels are re-attached, normalized-3×3 gates catch non-rigid matrices.

2. Material UV animation (fire / smoke / aurora / waterfalls / spritesheets)

The .sanim and .mat.sanim.opa carry UVTransform channels — a per-frame 2×3 UV affine ([a,b,c,d,e,f]). Two cook targets:

  • Continuous scroll (waterfall/foam: identity 2×2 + a constant per-frame translate) → the smooth uv += rate*time shader, REPEAT-wrapped, seamless. Scroll detection uses the full track window with an oscillation gate — a track that swings 0 → x → 0 (pond color swirls) is NOT a scroll and routes to the exact replay instead of cooking static.
  • Flipbook / complex (sprite-cell atlases, 2×2 scale, dust) → the per-frame matrix replay fragment shader: the verbatim source matrices are baked into the generated SPIR-V and selected frame-snapped by getTime() — the device plays exactly what the desktop plays, at the authored speed (never a generic one).

Spritesheet fidelity (the borders/seams class of bug):

  • the texture's mip chain is capped per cell so coarse mips never average adjacent frames ("borders around the animated card at distance");
  • the replay shader clamps the sampled UV to the current frame's cell (the base-UV rect pushed through the same frame matrix, minus an ASTC-block pad) — edge-touching samples get pinned instead of bleeding the neighbour frame in, and the frame interior stays pixel-exact. (An earlier attempt inset the mesh UVs instead — that rescaled the whole frame and visibly squeezed narrow cells; if you saw a "squished" animated card, update.)
  • MaterialTint RGBA cycles (fireworks flashes, color-changing lights) chain a tint replay onto the same shader — evaluated in the vertex stage (full curve, no compression; fragment-stage tint tables break the Adreno driver).
  • Opacity fade curves layer on top (fog puffs fade out exactly when their node teleports back).

3. Skeletal skinning (banners / stringlights / creatures)

CoSkinnedMesh geometry (.skin.opa) is model-local; world placement lives in the skeleton bind. Linear-blend skinning matches libshell's vertex shader. Ported as HZAN:SKEL + ACL-compressed HZAN:ANIM + a skinned RENDMESH (weights/indices on the device's vertex-stream semantics), with large skins auto-split (~16k verts/piece) sharing one skeleton/clip.

Hard-won correctness notes that are now permanent:

  • each skin binds to the clip that best-matches its own joint set (a last-writer-wins name map collapsed banners to the origin);
  • byte-identical skeletons dedup, but animation groups pair per (skeleton, clip) — not per file;
  • Maya-exported glTF rigs can bake unit conversion into the inverse-bind matrices only — the residual is folded into the rest verts ([HZIBM] in the cook log), else meshes skin ~70× too big;
  • HSR_ACLVERIFY=1 roundtrips every compressed clip and prints the error.

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

An R16G16B16A16_SFLOAT texture, width = vertexCount, height = frameCount, each texel = that vertex's offset for that frame; the device vatunlitbasecolor shader samples by (vertexIndex, time) and offsets the position. Cooked with the full half-float mip chain (the device expects it). Used both for authored VAT vistas and as the port target for complex OPA vertex animation.

Loop behavior

The device fract-wraps a clip only for WrapMode ∈ {0,1,2} — the cook normalizes to a looping mode so nothing freezes on the last frame. Desktop and device share the same phase math (fract(time/loop)), so the timeline scrub (at=<sec> / HSR_ANIMTIME) previews the exact device pose at any time.

How to verify (device-free)

hsr_dump <meshIdx> (MCP) or dump=<idx> (HTTP) prints [COOK-ANIM] — the exact device animation/shader that mesh cooks to: UV-SCROLL · FLIPBOOK-UVMATRIX · HZANIM-SKINNED · RIGID-HZANIM · ROTREPLAY · VAT · STATIC. It reads the same extractors the real cook uses, so it's authoritative. Pin poses with HSR_ANIMTIME=<sec> and compare source vs cooked numerically.

Related: Architecture · File Formats · Roadmap.

Clone this wiki locally