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.
295 lines
12 KiB
Java
295 lines
12 KiB
Java
package com.tiedup.remake.client.gltf;
|
|
|
|
import com.mojang.blaze3d.systems.RenderSystem;
|
|
import dev.kosmx.playerAnim.core.util.Pair;
|
|
import dev.kosmx.playerAnim.impl.IAnimatedPlayer;
|
|
import dev.kosmx.playerAnim.impl.animation.AnimationApplier;
|
|
import net.minecraft.client.model.HumanoidModel;
|
|
import net.minecraft.client.model.geom.ModelPart;
|
|
import net.minecraft.world.entity.LivingEntity;
|
|
import net.minecraftforge.api.distmarker.Dist;
|
|
import net.minecraftforge.api.distmarker.OnlyIn;
|
|
import org.apache.logging.log4j.LogManager;
|
|
import org.apache.logging.log4j.Logger;
|
|
import org.joml.Matrix4f;
|
|
import org.joml.Quaternionf;
|
|
import org.joml.Vector3f;
|
|
|
|
/**
|
|
* Reads the LIVE skeleton state from HumanoidModel (after PlayerAnimator + bendy-lib
|
|
* have applied all rotations for the current frame) and produces joint matrices
|
|
* compatible with {@link GltfSkinningEngine#skinVertex}.
|
|
* <p>
|
|
* KEY INSIGHT: The ModelPart xRot/yRot/zRot values set by PlayerAnimator represent
|
|
* DELTA rotations (difference from rest pose) expressed in the MC model-def frame.
|
|
* GltfPoseConverter computed them as parent-frame deltas, decomposed to Euler ZYX.
|
|
* <p>
|
|
* To reconstruct the correct LOCAL rotation for the glTF hierarchy:
|
|
* <pre>
|
|
* delta = rotationZYX(zRot, yRot, xRot) // MC-frame delta from ModelPart
|
|
* localRot = delta * restQ_mc // delta applied on top of local rest
|
|
* </pre>
|
|
* No de-parenting is needed because both delta and restQ_mc are already in the
|
|
* parent's local frame. The MC-to-glTF conjugation (negate qx,qy) is a homomorphism,
|
|
* so frame relationships are preserved through the conversion.
|
|
* <p>
|
|
* For bones WITHOUT a MC ModelPart (root, torso), use the MC-converted rest rotation
|
|
* directly from GltfData.
|
|
*/
|
|
@OnlyIn(Dist.CLIENT)
|
|
public final class GltfLiveBoneReader {
|
|
|
|
private static final Logger LOGGER = LogManager.getLogger("GltfPipeline");
|
|
|
|
private GltfLiveBoneReader() {}
|
|
|
|
// Scratch pools for joint-matrix computation. Render-thread-only
|
|
// (asserted below). Pre-populated Matrix4f slots are reused via
|
|
// set() / identity() / mul(). See GltfSkinningEngine for the twin pool.
|
|
private static Matrix4f[] scratchJointMatrices = new Matrix4f[0];
|
|
private static Matrix4f[] scratchWorldTransforms = new Matrix4f[0];
|
|
private static final Matrix4f scratchLocal = new Matrix4f();
|
|
|
|
private static Matrix4f[] ensureScratch(Matrix4f[] current, int needed) {
|
|
if (current.length >= needed) return current;
|
|
Matrix4f[] next = new Matrix4f[needed];
|
|
int i = 0;
|
|
for (; i < current.length; i++) next[i] = current[i];
|
|
for (; i < needed; i++) next[i] = new Matrix4f();
|
|
return next;
|
|
}
|
|
|
|
/**
|
|
* Compute joint matrices by reading live skeleton state from the HumanoidModel.
|
|
* <p>
|
|
* For upper bones: reconstructs the MC-frame delta from ModelPart euler angles,
|
|
* then composes with the MC-converted rest rotation to get the local rotation.
|
|
* For lower bones: reads bend values from the entity's AnimationApplier and
|
|
* composes the bend delta with the local rest rotation.
|
|
* For non-animated bones: uses rest rotation from GltfData directly.
|
|
* <p>
|
|
* The resulting joint matrices should match {@link GltfSkinningEngine#computeJointMatrices}
|
|
* when the player is in the rest pose (no animation active).
|
|
*
|
|
* @param model the HumanoidModel after PlayerAnimator has applied rotations
|
|
* @param data parsed glTF data (MC-converted)
|
|
* @param entity the living entity being rendered
|
|
* @return live reference to an internal scratch buffer (or null on failure).
|
|
* Caller MUST consume before the next call to any {@code compute*}
|
|
* method on this class; do not store.
|
|
*/
|
|
public static Matrix4f[] computeJointMatricesFromModel(
|
|
HumanoidModel<?> model,
|
|
GltfData data,
|
|
LivingEntity entity
|
|
) {
|
|
if (model == null || data == null || entity == null) return null;
|
|
assert RenderSystem.isOnRenderThread()
|
|
: "GltfLiveBoneReader.computeJointMatricesFromModel must run on the render thread (scratch buffers are not thread-safe)";
|
|
|
|
int jointCount = data.jointCount();
|
|
scratchJointMatrices = ensureScratch(scratchJointMatrices, jointCount);
|
|
scratchWorldTransforms = ensureScratch(
|
|
scratchWorldTransforms,
|
|
jointCount
|
|
);
|
|
Matrix4f[] jointMatrices = scratchJointMatrices;
|
|
Matrix4f[] worldTransforms = scratchWorldTransforms;
|
|
|
|
int[] parents = data.parentJointIndices();
|
|
String[] jointNames = data.jointNames();
|
|
Quaternionf[] restRotations = data.restRotations();
|
|
Vector3f[] restTranslations = data.restTranslations();
|
|
|
|
// Get the AnimationApplier for bend values (may be null)
|
|
AnimationApplier emote = getAnimationApplier(entity);
|
|
|
|
for (int j = 0; j < jointCount; j++) {
|
|
String boneName = jointNames[j];
|
|
Quaternionf localRot;
|
|
|
|
if (GltfBoneMapper.isLowerBone(boneName)) {
|
|
// --- Lower bone: reconstruct from bend values ---
|
|
localRot = computeLowerBoneLocalRotation(
|
|
boneName,
|
|
j,
|
|
restRotations,
|
|
emote
|
|
);
|
|
} else if (hasUniqueModelPart(boneName)) {
|
|
// --- Upper bone with a unique ModelPart ---
|
|
ModelPart part = GltfBoneMapper.getModelPart(model, boneName);
|
|
if (part != null) {
|
|
localRot = computeUpperBoneLocalRotation(
|
|
part,
|
|
j,
|
|
restRotations
|
|
);
|
|
} else {
|
|
// Fallback: use rest rotation
|
|
localRot = new Quaternionf(restRotations[j]);
|
|
}
|
|
} else {
|
|
// --- Non-animated bone (root, torso, etc.): use rest rotation ---
|
|
localRot = new Quaternionf(restRotations[j]);
|
|
}
|
|
|
|
// Build local transform: translate(restTranslation) * rotate(localRot)
|
|
scratchLocal.identity();
|
|
scratchLocal.translate(restTranslations[j]);
|
|
scratchLocal.rotate(localRot);
|
|
|
|
// Compose with parent to get world transform.
|
|
// Same semantics as pre-refactor: treat as root when parent hasn't
|
|
// been processed yet (parents[j] >= j was a null in the old array).
|
|
Matrix4f world = worldTransforms[j];
|
|
if (parents[j] >= 0 && parents[j] < j) {
|
|
world.set(worldTransforms[parents[j]]).mul(scratchLocal);
|
|
} else {
|
|
world.set(scratchLocal);
|
|
}
|
|
|
|
// Final joint matrix = worldTransform * inverseBindMatrix
|
|
jointMatrices[j].set(world).mul(data.inverseBindMatrices()[j]);
|
|
}
|
|
|
|
return jointMatrices;
|
|
}
|
|
|
|
/**
|
|
* Compute local rotation for an upper bone that has a unique ModelPart.
|
|
* <p>
|
|
* ModelPart xRot/yRot/zRot are DELTA rotations (set by PlayerAnimator) expressed
|
|
* as ZYX Euler angles in the MC model-def frame. These deltas were originally
|
|
* computed by GltfPoseConverter as parent-frame quantities.
|
|
* <p>
|
|
* The local rotation for the glTF hierarchy is simply:
|
|
* <pre>
|
|
* delta = rotationZYX(zRot, yRot, xRot)
|
|
* localRot = delta * restQ_mc
|
|
* </pre>
|
|
* No de-parenting is needed: both delta and restQ_mc are already in the parent's
|
|
* frame. The MC-to-glTF negate-xy conjugation is a group homomorphism, preserving
|
|
* the frame relationship.
|
|
*/
|
|
private static Quaternionf computeUpperBoneLocalRotation(
|
|
ModelPart part,
|
|
int jointIndex,
|
|
Quaternionf[] restRotations
|
|
) {
|
|
// Reconstruct the MC-frame delta from ModelPart euler angles.
|
|
Quaternionf delta = new Quaternionf().rotationZYX(
|
|
part.zRot,
|
|
part.yRot,
|
|
part.xRot
|
|
);
|
|
// Local rotation = delta applied on top of the local rest rotation.
|
|
return new Quaternionf(delta).mul(restRotations[jointIndex]);
|
|
}
|
|
|
|
/**
|
|
* Compute local rotation for a lower bone (elbow/knee) from bend values.
|
|
* <p>
|
|
* Bend values are read from the entity's AnimationApplier. The bend delta is
|
|
* reconstructed as a quaternion rotation around the bend axis, then composed
|
|
* with the local rest rotation:
|
|
* <pre>
|
|
* bendQuat = axisAngle(cos(bendAxis)*s, 0, sin(bendAxis)*s, cos(halfAngle))
|
|
* localRot = bendQuat * restQ_mc
|
|
* </pre>
|
|
* No de-parenting needed — same reasoning as upper bones.
|
|
*/
|
|
private static Quaternionf computeLowerBoneLocalRotation(
|
|
String boneName,
|
|
int jointIndex,
|
|
Quaternionf[] restRotations,
|
|
AnimationApplier emote
|
|
) {
|
|
if (emote != null) {
|
|
// Get the MC part name for the upper bone of this lower bone
|
|
String upperBone = GltfBoneMapper.getUpperBoneFor(boneName);
|
|
String animPartName = (upperBone != null)
|
|
? GltfBoneMapper.getAnimPartName(upperBone)
|
|
: null;
|
|
|
|
if (animPartName != null) {
|
|
Pair<Float, Float> bend = emote.getBend(animPartName);
|
|
if (bend != null) {
|
|
float bendAxis = bend.getLeft();
|
|
float bendValue = bend.getRight();
|
|
|
|
// Reconstruct bend as quaternion (this is the delta)
|
|
float ax = (float) Math.cos(bendAxis);
|
|
float az = (float) Math.sin(bendAxis);
|
|
float halfAngle = bendValue * 0.5f;
|
|
float s = (float) Math.sin(halfAngle);
|
|
Quaternionf bendQuat = new Quaternionf(
|
|
ax * s,
|
|
0,
|
|
az * s,
|
|
(float) Math.cos(halfAngle)
|
|
);
|
|
|
|
// Local rotation = bend delta applied on top of local rest rotation
|
|
return new Quaternionf(bendQuat).mul(
|
|
restRotations[jointIndex]
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// No bend data or no AnimationApplier — use rest rotation (identity delta)
|
|
return new Quaternionf(restRotations[jointIndex]);
|
|
}
|
|
|
|
/**
|
|
* Check if a bone name corresponds to a bone that has its OWN unique ModelPart
|
|
* (not just a mapping — it must be the PRIMARY bone for that ModelPart).
|
|
* <p>
|
|
* "torso" maps to model.body but "body" is the primary bone for it.
|
|
* Lower bones share a ModelPart with their upper bone.
|
|
* Unknown bones (e.g., "PlayerArmature") have no ModelPart at all.
|
|
*/
|
|
private static boolean hasUniqueModelPart(String boneName) {
|
|
// Bones that should read their rotation from the live HumanoidModel.
|
|
//
|
|
// NOTE: "body" is deliberately EXCLUDED. MC's HumanoidModel is FLAT —
|
|
// body, arms, legs, head are all siblings with ABSOLUTE rotations.
|
|
// But the GLB skeleton is HIERARCHICAL (body → torso → arms).
|
|
// If we read body's live rotation (e.g., attack swing yRot), it propagates
|
|
// to arms/head through the hierarchy, but MC's flat model does NOT do this.
|
|
// Result: cuffs mesh rotates with body during attack while arms stay put.
|
|
//
|
|
// Body rotation effects that matter (sneak lean, sitting) are handled by
|
|
// LivingEntityRenderer's PoseStack transform, which applies to the entire
|
|
// mesh uniformly. No need to read body rotation into joint matrices.
|
|
return switch (boneName) {
|
|
case "head" -> true;
|
|
case "leftUpperArm" -> true;
|
|
case "rightUpperArm" -> true;
|
|
case "leftUpperLeg" -> true;
|
|
case "rightUpperLeg" -> true;
|
|
default -> false; // body, torso, lower bones, unknown
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get the AnimationApplier from an entity, if available.
|
|
* Works for both players (via mixin) and NPCs implementing IAnimatedPlayer.
|
|
*/
|
|
private static AnimationApplier getAnimationApplier(LivingEntity entity) {
|
|
if (entity instanceof IAnimatedPlayer animated) {
|
|
try {
|
|
return animated.playerAnimator_getAnimation();
|
|
} catch (Exception e) {
|
|
LOGGER.debug(
|
|
"[GltfPipeline] Could not get AnimationApplier for {}: {}",
|
|
entity.getClass().getSimpleName(),
|
|
e.getMessage()
|
|
);
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
}
|