Shaders/SkinnedMesh/TFT_UV_Shift
Authoritative notes on the TFT companion flipbook shader path Shaders/SkinnedMesh/TFT_UV_Shift: how the binary data is shaped, how the extractor resolves it, how GLTF extras are written, and how the web engine consumes it (including the loop grace fix for visibility).
Binary Structure (Skin BIN)
Source: skin.skins[*].material.dynamicMaterial.parameters[] in files like petchibileesin_skin1.bin.json.
- SwitchMaterialDriverElement / mElements
mCondition.mAnimationNames: list of animation identifiers- May be
BinTreeString(plain names) orBinTreeHash(FNV1a of name)
- May be
mValue.graph:json{ "times": [0, 0.25, 0.5, 0.75, 1.0], // normalized 0–1 timeline "values": [0, 1, 2, 3, 4], // flipbook frame indices "mDefaultValue": 7 // idle/default frame }timesare normalized; must be multiplied by animation duration seconds.
Extractor Pipeline (C#)
Files: Skin.cs, StaticMaterial.cs, SkinExtensions.cs, SimpleGltf/Json/GltfAsset.cs.
Parse elements/drivers
ExtractKeyframeFlipbookMappingsFromElements/ExtractKeyframeFlipbookMappingsreadmElements/mDrivers.- Store keyframes into
StaticMaterial.FlipbookKeyframes(hash-keyed) andFlipbookKeyframesByName(name-keyed). - Set
HasFlipbookEvents = truewhen data exists.
Resolve animation identifiers
_clipHashToAnimationName: clip hash → exported animation name (frommClipDataMap)._nameHashToAnimationName: FNV1a32(name) → exported animation name.- During
CollectFlipbookEvents(), hashes are resolved to names; unresolved entries are skipped (noanim_xxxxfallbacks). Result isFlipbookEvents: { animationName -> { frameTime -> frameIndex } }.
Export to GLTF extras
SkinExtensionswritesflipbookEventsintoskeletonRoot.Extras(notuserData).SimpleGltf.Json.GltfAssetserializesflipbookEventsinto GLTFextrasfor Three.js.- Example:json
"flipbookEvents": { "Joke": { "0": 7, "0.25": 5, "0.5": 2 }, "Dance": { "0": 7 } } - Uses animation names only (matches
submeshVisibilityEvents). No hashes reach the frontend.
Web Engine Consumption (packages/web/src/engine/index.ts)
Load
- Reads
skeletonRoot.userData.flipbookEventsintothis.flipbookEvents. - Materials with
hasFlipbookEventsare registered inflipbookEventMaterials.
- Reads
Per-frame update
- Guarded:
updateFlipbookEvents()runs only ifflipbookEventMaterials.length > 0. currentTime = AnimationAction.time(respectsmixer.timeScale).- Event times are normalized; multiply by
animationDuration. - Sorted event times are cached per animation to avoid allocations each frame.
- Frame selection: last event where
currentTime >= normalizedTime * duration; defaults to first event, else material default frame.
- Guarded:
Loop grace (flicker fix)
- Detect loop:
currentFrame < lastVisibilityFrame - 1. - Skip visibility updates for ~2.5 frames after a loop (
loopGraceFrames) to avoid flicker at animation restart. - Reset
lastVisibilityFrame/loopGraceFrameson model load, animation start, and stop.
- Detect loop:
Reset behavior
stopAnimation()and T-pose resets callresetFlipbooksToDefault()and clear loop tracking.
Related Systems
- submeshVisibilityEvents: Same per-animation name keying; flipbook events mirror this (names only).
- Standard flipbooks: Exported as
flipbookKeyframeskeyed by accessory hash; handled separately. - Auto flipbooks: Use
flipbookSpeedfor time-based cycling; unaffected by TFT_UV_Shift events.
Testing Checklist
- Use a TFT companion (e.g., PetChibiLeeSin) and verify:
extras.flipbookEventspresent with animation names.- Mouth/face frames change over full animation duration (normalized times honored).
- No flicker at loop boundaries (grace period working).
- Regression: ensure standard flipbooks (e.g., Milio) and auto flipbooks (e.g., Annie Skin31) still behave; guards prevent overhead when no
hasFlipbookEventsmaterials.
Design Principles Recap
- Name-first export; skip unresolved hashes.
- Write to GLTF
extras, notuserData. - No frontend hashing; all resolution in extractor.
- Performance: guarded calls, cached event times; zero overhead for models without events.
- Default frame: prefer
mDefaultValue(FlipbookDefaultFrame) as idle frame.