-
Notifications
You must be signed in to change notification settings - Fork 2
Animation System
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.
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). |
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).
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 smoothuv += rate*timeshader. Continuous, REPEAT-wrapped, seamless. -
Flipbook / complex (sprite-cell atlas, or a track with
2×2scale + 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).
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.
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.
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.
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).
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.
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.
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.
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.