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:
NotEvil
2026-04-18 17:34:03 +02:00
parent 37da2c1716
commit 11188bc621
63 changed files with 4965 additions and 2226 deletions

View File

@@ -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.).