Refactor V2 animation, furniture, and GLTF rendering
Broad consolidation of the V2 bondage-item, furniture-entity, and
client-side GLTF pipeline.
Parsing and rendering
- Shared GLB parsing helpers consolidated into GlbParserUtils
(accessor reads, weight normalization, joint-index clamping,
coordinate-space conversion, animation parse, primitive loop).
- Grow-on-demand Matrix4f[] scratch pool in GltfSkinningEngine and
GltfLiveBoneReader — removes per-frame joint-matrix allocation
from the render hot path.
- emitVertex helper dedups three parallel loops in GltfMeshRenderer.
- TintColorResolver.resolve has a zero-alloc path when the item
declares no tint channels.
- itemAnimCache bounded to 256 entries (access-order LRU) with
atomic get-or-compute under the map's monitor.
Animation correctness
- First-in-joint-order wins when body and torso both map to the
same PlayerAnimator slot; duplicate writes log a single WARN.
- Multi-item composites honor the FullX / FullHeadX opt-in that
the single-item path already recognized.
- Seat transforms converted to Minecraft model-def space so
asymmetric furniture renders passengers at the correct offset.
- GlbValidator: IBM count / type / presence, JOINTS_0 presence,
animation channel target validation, multi-skin support.
Furniture correctness and anti-exploit
- Seat assignment synced via SynchedEntityData (server is
authoritative; eliminates client-server divergence on multi-seat).
- Force-mount authorization requires same dimension and a free
seat; cross-dimension distance checks rejected.
- Reconnection on login checks for seat takeover before re-mount
and force-loads the target chunk for cross-dimension cases.
- tiedup_furniture_lockpick_ctx carries a session UUID nonce so
stale context can't misroute a body-item lockpick.
- tiedup_locked_furniture survives death without keepInventory
(Forge 1.20.1 does not auto-copy persistent data on respawn).
Lifecycle and memory
- EntityCleanupHandler fans EntityLeaveLevelEvent out to every
per-entity state map on the client.
- DogPoseRenderHandler re-keyed by UUID (stable across dimension
change; entity int ids are recycled).
- PetBedRenderHandler, PlayerArmHideEventHandler, and
HeldItemHideHandler use receiveCanceled + sentinel sets so
Pre-time mutations are restored even when a downstream handler
cancels the render.
Tests
- JUnit harness with 76+ tests across GlbParserUtils, GltfPoseConverter,
FurnitureSeatGeometry, and FurnitureAuthPredicate.
This commit is contained in:
@@ -108,7 +108,7 @@ PlayerArmature ← armature root object (never keyframe this)
|
||||
| Arms (`*UpperArm`, `*LowerArm`) | Vanilla by default. Your item if it owns ARMS or HANDS. Also available as free bones in `Full` animations. |
|
||||
| Legs (`*UpperLeg`, `*LowerLeg`) | Context layer by default. Your item if it owns LEGS or FEET. Also available as free bones in `Full` animations. |
|
||||
|
||||
**Note:** `torso` and `body` both map to the same internal part. Prefer animating `body` — using `torso` produces the same result but is less intuitive.
|
||||
**Note:** `torso` and `body` both map to the same internal part. Prefer animating `body` — using `torso` produces the same result but is less intuitive. If your GLB contains **both** `body` and `torso` bones, the runtime will use only the first one encountered in the joint array and emit a WARN in the log: `"Bone 'torso' maps to PlayerAnimator part 'body' already written by an earlier bone — ignoring."` To avoid this, rig with one or the other, never both.
|
||||
|
||||
---
|
||||
|
||||
@@ -319,9 +319,32 @@ To animate free bones (body, legs not owned by another item), use the `Full` pre
|
||||
|
||||
**Frame 0 is the base pose** — the minimum every animation must have. The mod always has a valid pose to display from frame 0.
|
||||
|
||||
**Multi-frame animations** (loops, transitions) are supported by the GLB parser — all frames are parsed and stored. However, **multi-frame playback is not yet implemented** in the item animation converter (`GltfPoseConverter`). Currently only frame 0 is read at runtime. Multi-frame playback is a high-priority feature — see `docs/TODO.md`.
|
||||
**Multi-frame animations play fully** — struggle thrashing, walk cycles, breathing idles all animate at runtime. The converter iterates every keyframe and emits them at the correct MC tick.
|
||||
|
||||
**What this means for artists now:** Design your animations with full multi-frame loops (struggle thrashing, walk cycles, breathing idles). Put the most representative pose at frame 0. When multi-frame playback ships, your animations will work immediately without re-exporting.
|
||||
#### Authoring at 20 FPS (strongly recommended)
|
||||
|
||||
Minecraft ticks at **20 Hz**. Authoring at 20 FPS gives you a 1:1 mapping — every source frame becomes one MC tick.
|
||||
|
||||
If you author at a higher rate (24 / 30 / 60 FPS — Blender defaults), the converter quantizes timestamps to MC ticks via rounding. Multiple source frames that round to the same tick are **deduplicated** — only the first is kept, the rest are skipped. Practical impact:
|
||||
|
||||
| Source FPS | Frames kept per second | Lost per second |
|
||||
|---|---|---|
|
||||
| 20 | 20 | 0 |
|
||||
| 24 | ~20 | ~4 |
|
||||
| 30 | 20 | ~10 |
|
||||
| 60 | 20 | ~40 |
|
||||
|
||||
For smooth motion at any rate, set Blender's scene FPS to 20 and author accordingly. If you must author at 24+, put critical keyframes on integer multiples of `1/20s = 50ms` to ensure they land on unique ticks.
|
||||
|
||||
#### Timeline start
|
||||
|
||||
The converter **normalizes the timeline** so the first keyframe plays at tick 0, even if your Blender action's first keyframe is at a non-zero time (NLA strips, trimmed clips). You don't need to pre-shift your timelines.
|
||||
|
||||
#### What the converter reads
|
||||
|
||||
- **Rotations** per joint (full multi-frame). **This is the primary driver.**
|
||||
- **Translations** are parsed but not yet applied to the player animation — use rotations for all motion. (Bone translations are used for the furniture seat skeleton anchor, not the player pose.)
|
||||
- **Ease**: linear interpolation between keyframes. Blender's default F-Curve interpolation (Bezier) is sampled by the exporter at the authored framerate — if you need smooth motion, add keyframes at the sample rate, don't rely on curve-side smoothing.
|
||||
|
||||
### Optional Animations
|
||||
|
||||
@@ -618,6 +641,8 @@ In your JSON definition, separate the mesh from the animations:
|
||||
|
||||
### Export Settings
|
||||
|
||||
**Set your Blender scene FPS to 20** (Scene Properties > Frame Rate > Custom = 20) before authoring animations. Minecraft ticks at 20 Hz; any source frame rate above 20 FPS will have frames silently deduplicated at load. See [Animation Frames](#animation-frames).
|
||||
|
||||
**File > Export > glTF 2.0 (.glb)**
|
||||
|
||||
| Setting | Value | Why |
|
||||
@@ -634,11 +659,12 @@ In your JSON definition, separate the mesh from the animations:
|
||||
|
||||
### Pre-Export Checklist
|
||||
|
||||
- [ ] Scene FPS set to **20** (Blender default is 24 — change it)
|
||||
- [ ] Armature is named `PlayerArmature`
|
||||
- [ ] All 11 bones have correct names (case-sensitive)
|
||||
- [ ] Actions are named `PlayerArmature|Idle`, `PlayerArmature|Struggle`, etc.
|
||||
- [ ] Mesh is weight-painted to skeleton bones only
|
||||
- [ ] Weights are normalized
|
||||
- [ ] Weights are normalized (the mod re-normalizes at load as a safety net, but authoring-normalized weights give the most predictable result)
|
||||
- [ ] Custom bones (if any) are parented to a standard bone in the hierarchy
|
||||
- [ ] Your item mesh is named `Item` in Blender (recommended — ensures the mod picks the correct mesh if your file has multiple objects)
|
||||
- [ ] Materials/textures are applied (the GLB bakes them in)
|
||||
@@ -756,7 +782,6 @@ The `movement_style` changes how the player physically moves — slower speed, d
|
||||
| `display_name` | string | Yes | Name shown in-game |
|
||||
| `model` | string | Yes | ResourceLocation of the GLB mesh |
|
||||
| `slim_model` | string | No | GLB for Alex-model players (3px arms) |
|
||||
| `texture` | string | No | Override texture (if not baked in GLB) |
|
||||
| `animation_source` | string | No | GLB to read animations from (defaults to `model`) |
|
||||
| `regions` | string[] | Yes | Body regions this item occupies |
|
||||
| `blocked_regions` | string[] | No | Regions blocked for other items (defaults to `regions`) |
|
||||
@@ -1039,6 +1064,17 @@ The validation runs automatically on every resource reload (F3+T). Check your ga
|
||||
|
||||
If your GLB contains multiple meshes, name your item mesh `Item` in Blender. The mod prioritizes a mesh named `Item` over other meshes. If no `Item` mesh is found, the last non-`Player` mesh is used (backward compatible, but may pick the wrong one in multi-mesh files).
|
||||
|
||||
### Parse-Time Warnings (watch the log)
|
||||
|
||||
Beyond the toast-based validator, the parser emits WARN-level log lines on load for specific malformations. Grep your `logs/latest.log` for `[GltfPipeline]` / `[FurnitureGltf]` to catch these:
|
||||
|
||||
| WARN message | Meaning | What to do |
|
||||
|---|---|---|
|
||||
| `Clamped N out-of-range joint indices in '<file>'` | The mesh references joint indices ≥ bone count. Clamped to joint 0 (root) to avoid a crash — affected vertices render at the root position, usually visibly wrong. | In Blender, select the mesh, `Weights > Limit Total` (set to 4), then re-normalize and re-export. |
|
||||
| `WEIGHTS_0 array length N is not a multiple of 4` | Malformed skin data (not per-glTF-spec VEC4). Trailing orphan weights are ignored. | Re-export. If it persists, check your mesh for non-mesh attribute overrides in Blender's Object Data properties. |
|
||||
| `GLB size X exceeds cap 52428800` | File too large (>50 MB cap). Parsing is refused; the asset won't render. | Decimate mesh, downsize textures, or split the model. Furniture meshes rarely need to exceed 200 KB. |
|
||||
| `Accessor would read past BIN chunk` / `Accessor count * components overflows int` | Malformed or hostile GLB accessor declaring impossible sizes. Parse refused. | Re-export from Blender (not an authoring mistake — likely a corrupted export). |
|
||||
|
||||
---
|
||||
|
||||
## Common Mistakes
|
||||
@@ -1048,6 +1084,7 @@ If your GLB contains multiple meshes, name your item mesh `Item` in Blender. The
|
||||
| Mistake | Symptom | Fix |
|
||||
|---------|---------|-----|
|
||||
| Bone name typo (`RightUpperArm` instead of `rightUpperArm`) | WARN in log with suggestion: "did you mean 'rightUpperArm'?" — bone treated as custom | Names are **camelCase**, not PascalCase. Check exact spelling. Run `/tiedup validate` to see warnings. |
|
||||
| Both `body` and `torso` bones present | WARN in log: "maps to PlayerAnimator part 'body' already written" — only the first bone in the joint array drives the pose, the other is ignored | Use one or the other. Prefer `body`. Delete the redundant bone from the rig. |
|
||||
| Extra bones in the armature | Custom bones follow their parent in rest pose | Intentional custom bones are fine (chains, decorations). Unintentional ones add file size — delete them. |
|
||||
| Missing `PlayerArmature` root | Mesh renders at wrong position | Rename your armature root to `PlayerArmature` |
|
||||
| Animating `body` bone without TORSO region | Body keyframes used only if `body` is free (no other item owns it) | Declare TORSO/WAIST region if you always want to control body, or use `Full` animations for free-bone effects |
|
||||
@@ -1060,7 +1097,8 @@ If your GLB contains multiple meshes, name your item mesh `Item` in Blender. The
|
||||
| Wrong case (`idle` instead of `Idle`) | Animation not found | Use exact PascalCase: `Idle`, `SitIdle`, `KneelStruggle` |
|
||||
| Variant gap (`.1`, `.2`, `.4` — missing `.3`) | Only .1 and .2 are used | Number sequentially with no gaps |
|
||||
| Animating bones outside your regions | Keyframes silently ignored | Only animate bones in your declared regions |
|
||||
| Multi-frame animations play as static pose | Multi-frame playback not yet implemented — only frame 0 is used | Design full animations now (they'll work when playback ships). Ensure frame 0 is a good base pose. |
|
||||
| Bone rotated a quarter-turn around its vertical axis (yaw ≈ ±90°) | Jitter or sudden flip in pitch/roll at the boundary | Gimbal-lock in the Euler ZYX decomposition used to feed PlayerAnimator. Keep bone yaw within ±85° of forward; if you need a 90° yaw, add a few degrees of pitch or roll. |
|
||||
| Animation looks choppy or loses keyframes | Source FPS > 20 — multiple source frames round to the same MC tick and all but the first are deduplicated | Set Blender's scene FPS to 20 and re-export. See [Animation Frames](#animation-frames) for the mapping table. |
|
||||
|
||||
### Weight Painting Issues
|
||||
|
||||
@@ -1265,21 +1303,29 @@ Furniture_Armature|Shake ← whole frame vibrates
|
||||
#### Player Seat Animations
|
||||
|
||||
Target the `Player_*` armatures. Blender exports them as `Player_main|AnimName`.
|
||||
The mod resolves them as `{seatId}:{AnimName}`.
|
||||
The mod resolves them per seat ID (e.g., `Player_main|Idle` → seat `main`, clip `Idle`).
|
||||
|
||||
| Animation Name | When Played | Required? |
|
||||
|---------------|------------|-----------|
|
||||
| `Idle` | Default seated pose | **Yes** (no fallback) |
|
||||
| `Struggle` | Player struggling to escape | Optional (stays in Idle) |
|
||||
| `Enter` | Mount transition (one-shot, 1 second) | Optional (snaps to Idle if absent) |
|
||||
| `Exit` | Dismount transition (one-shot, 1 second) | Optional (snaps to vanilla if absent) |
|
||||
| `Idle` | Default seated pose (STATE_IDLE) | **Yes** — canonical fallback |
|
||||
| `Occupied` | At least one passenger is seated (STATE_OCCUPIED) | Optional (falls back to Idle) |
|
||||
| `Struggle` | Player struggling to escape (STATE_STRUGGLE) | Optional (falls back to Occupied → Idle) |
|
||||
| `Enter` | Mount transition (STATE_ENTERING, ~20 ticks) | Optional (falls back to Occupied → Idle) |
|
||||
| `Exit` | Dismount transition (STATE_EXITING, ~20 ticks) | Optional (falls back to Occupied → Idle) |
|
||||
| `LockClose` | Seat is being locked (STATE_LOCKING) | Optional (falls back to Occupied → Idle) |
|
||||
| `LockOpen` | Seat is being unlocked (STATE_UNLOCKING) | Optional (falls back to Occupied → Idle) |
|
||||
|
||||
The mod plays the state-specific clip if authored. When a state transitions server-side, the pose updates automatically on all clients — no packet work required.
|
||||
|
||||
**Fallback chain:** state-specific clip → `Occupied` → first authored clip. This means: if you only author `Idle`, the player holds it for every state. Adding `Struggle` and `Enter` gets you polish on those states without breaking anything if you skip the rest.
|
||||
|
||||
Example in Blender's Action Editor:
|
||||
```
|
||||
Player_main|Idle → resolved as "main:Idle" ← arms spread, legs apart
|
||||
Player_main|Struggle → resolved as "main:Struggle" ← pulling against restraints
|
||||
Player_left|Idle → resolved as "left:Idle" ← head and arms through pillory
|
||||
Player_right|Idle → resolved as "right:Idle" ← same pose, other side
|
||||
Player_main|Idle → seat "main" clip "Idle" ← arms spread, legs apart
|
||||
Player_main|Struggle → seat "main" clip "Struggle" ← pulling against restraints
|
||||
Player_main|Enter → seat "main" clip "Enter" ← one-shot mount transition
|
||||
Player_left|Idle → seat "left" clip "Idle" ← head and arms through pillory
|
||||
Player_right|Idle → seat "right" clip "Idle" ← same pose, other side
|
||||
```
|
||||
|
||||
**Key difference from body items:** Furniture player animations control **ALL 11 bones**, not just region-owned bones. The furniture overrides the player's entire pose for the blocked regions, and the remaining regions still show body item effects (gag, blindfold, etc.).
|
||||
|
||||
Reference in New Issue
Block a user