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.
754 lines
29 KiB
Java
754 lines
29 KiB
Java
package com.tiedup.remake.client.gltf;
|
|
|
|
import dev.kosmx.playerAnim.core.data.AnimationFormat;
|
|
import dev.kosmx.playerAnim.core.data.KeyframeAnimation;
|
|
import dev.kosmx.playerAnim.core.util.Ease;
|
|
import java.util.HashSet;
|
|
import java.util.Set;
|
|
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.jetbrains.annotations.Nullable;
|
|
import org.joml.Quaternionf;
|
|
import org.joml.Vector3f;
|
|
|
|
/**
|
|
* Converts glTF rest pose + animation quaternions into a PlayerAnimator KeyframeAnimation.
|
|
* <p>
|
|
* Data is expected to be already in MC coordinate space (converted by GlbParser).
|
|
* For upper bones: computes delta quaternion, decomposes to Euler ZYX (pitch/yaw/roll).
|
|
* For lower bones: extracts bend angle from delta quaternion.
|
|
* <p>
|
|
* The GLB model's arm pivots are expected to match MC's exactly (world Y=1.376),
|
|
* so no angle scaling is needed. If the pivots don't match, fix the Blender model.
|
|
* <p>
|
|
* Produces a static looping pose (beginTick=0, endTick=1, looped).
|
|
*/
|
|
@OnlyIn(Dist.CLIENT)
|
|
public final class GltfPoseConverter {
|
|
|
|
private static final Logger LOGGER = LogManager.getLogger("GltfPipeline");
|
|
|
|
private static final int TICKS_PER_SECOND = 20;
|
|
private static final Ease DEFAULT_EASE = Ease.LINEAR;
|
|
|
|
private GltfPoseConverter() {}
|
|
|
|
/**
|
|
* Compute the end tick (inclusive) of a clip's keyframe timeline, relative
|
|
* to the clip's first timestamp. Returns 1 for null or empty clips (minimum
|
|
* valid builder endTick). glTF timestamps are in seconds; MC ticks are 20 Hz.
|
|
*
|
|
* <p>The baseline subtraction ensures clips authored with an NLA onset
|
|
* ({@code timestamps[0] > 0}) don't leave tick range {@code [0, firstTick)}
|
|
* undefined on each loop — the clip is always timeline-normalized to start
|
|
* at tick 0.</p>
|
|
*/
|
|
public static int computeEndTick(@Nullable GltfData.AnimationClip clip) {
|
|
if (
|
|
clip == null ||
|
|
clip.frameCount() == 0 ||
|
|
clip.timestamps().length == 0
|
|
) {
|
|
return 1;
|
|
}
|
|
float[] times = clip.timestamps();
|
|
int lastIdx = Math.min(times.length - 1, clip.frameCount() - 1);
|
|
float baseline = times[0];
|
|
return Math.max(
|
|
1,
|
|
Math.round((times[lastIdx] - baseline) * TICKS_PER_SECOND)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Convert a frame index to an MC tick based on the clip's timestamps,
|
|
* relative to {@code baselineSeconds} (typically {@code timestamps[0]}).
|
|
*/
|
|
private static int frameToTick(
|
|
@Nullable GltfData.AnimationClip clip,
|
|
int frameIndex,
|
|
float baselineSeconds
|
|
) {
|
|
if (clip == null) return 0;
|
|
float[] times = clip.timestamps();
|
|
if (frameIndex >= times.length) return 0;
|
|
return Math.round((times[frameIndex] - baselineSeconds) * TICKS_PER_SECOND);
|
|
}
|
|
|
|
/** Return the timestamp baseline for the clip, or 0 if absent. */
|
|
private static float timelineBaseline(@Nullable GltfData.AnimationClip clip) {
|
|
if (clip == null || clip.timestamps().length == 0) return 0f;
|
|
return clip.timestamps()[0];
|
|
}
|
|
|
|
/**
|
|
* Convert a GltfData's rest pose (or first animation frame) to a KeyframeAnimation.
|
|
* Uses the default (first) animation clip.
|
|
* GltfData must already be in MC coordinate space.
|
|
*
|
|
* @param data parsed glTF data (in MC space)
|
|
* @return a static looping KeyframeAnimation suitable for PlayerAnimator
|
|
*/
|
|
public static KeyframeAnimation convert(GltfData data) {
|
|
return convertClip(data, data.rawGltfAnimation(), "gltf_pose");
|
|
}
|
|
|
|
/**
|
|
* Convert a specific named animation from GltfData to a KeyframeAnimation.
|
|
* Falls back to the default animation if the name is not found.
|
|
*
|
|
* @param data parsed glTF data (in MC space)
|
|
* @param animationName the name of the animation to convert (e.g. "Struggle", "Idle")
|
|
* @return a static looping KeyframeAnimation suitable for PlayerAnimator
|
|
*/
|
|
public static KeyframeAnimation convert(
|
|
GltfData data,
|
|
String animationName
|
|
) {
|
|
GltfData.AnimationClip rawClip = data.getRawAnimation(animationName);
|
|
if (rawClip == null) {
|
|
LOGGER.warn(
|
|
"[GltfPipeline] Animation '{}' not found, falling back to default",
|
|
animationName
|
|
);
|
|
return convert(data);
|
|
}
|
|
return convertClip(data, rawClip, "gltf_" + animationName);
|
|
}
|
|
|
|
/**
|
|
* Convert a GLB animation with selective part enabling and free-bone support.
|
|
*
|
|
* <p>Owned parts are always enabled in the output animation. Free parts (in
|
|
* {@code enabledParts} but not in {@code ownedParts}) are only enabled if the
|
|
* GLB contains actual keyframe data for them. Parts not in {@code enabledParts}
|
|
* at all are always disabled (pass through to lower layers).</p>
|
|
*
|
|
* @param data parsed glTF data (in MC space)
|
|
* @param animationName animation name in GLB, or null for default
|
|
* @param ownedParts parts the item explicitly owns (always enabled)
|
|
* @param enabledParts parts the item may animate (owned + free); free parts
|
|
* are only enabled if the GLB has keyframes for them
|
|
* @return KeyframeAnimation with selective parts active
|
|
*/
|
|
public static KeyframeAnimation convertSelective(
|
|
GltfData data,
|
|
@Nullable String animationName,
|
|
Set<String> ownedParts,
|
|
Set<String> enabledParts
|
|
) {
|
|
GltfData.AnimationClip rawClip;
|
|
String animName;
|
|
if (animationName != null) {
|
|
rawClip = data.getRawAnimation(animationName);
|
|
animName = "gltf_" + animationName;
|
|
} else {
|
|
rawClip = null;
|
|
animName = "gltf_pose";
|
|
}
|
|
if (rawClip == null) {
|
|
rawClip = data.rawGltfAnimation();
|
|
}
|
|
return convertClipSelective(
|
|
data,
|
|
rawClip,
|
|
animName,
|
|
ownedParts,
|
|
enabledParts
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Internal: convert a specific raw animation clip with selective part enabling
|
|
* and free-bone support.
|
|
*
|
|
* <p>Tracks which PlayerAnimator parts received actual keyframe data from the GLB.
|
|
* A bone has keyframes if {@code rawClip.rotations()[jointIndex] != null}.
|
|
* This information is used by {@link #enableSelectiveParts} to decide whether
|
|
* free parts should be enabled.</p>
|
|
*
|
|
* @param ownedParts parts the item explicitly owns (always enabled)
|
|
* @param enabledParts parts the item may animate (owned + free)
|
|
*/
|
|
private static KeyframeAnimation convertClipSelective(
|
|
GltfData data,
|
|
@Nullable GltfData.AnimationClip rawClip,
|
|
String animName,
|
|
Set<String> ownedParts,
|
|
Set<String> enabledParts
|
|
) {
|
|
KeyframeAnimation.AnimationBuilder builder =
|
|
new KeyframeAnimation.AnimationBuilder(
|
|
AnimationFormat.JSON_EMOTECRAFT
|
|
);
|
|
|
|
int endTick = computeEndTick(rawClip);
|
|
int frameCount = rawClip != null ? Math.max(1, rawClip.frameCount()) : 1;
|
|
|
|
builder.beginTick = 0;
|
|
builder.endTick = endTick;
|
|
builder.stopTick = endTick;
|
|
builder.isLooped = true;
|
|
builder.returnTick = 0;
|
|
builder.name = animName;
|
|
|
|
// Track which PlayerAnimator part names received actual animation data.
|
|
// Joint-level; not frame-dependent — we detect once on frame 0.
|
|
Set<String> partsWithKeyframes = new HashSet<>();
|
|
|
|
// Tick deduplication: MC runs at 20 Hz. Source clips authored at higher
|
|
// rates (24/30/60 FPS Blender) produce multiple frames that round to the
|
|
// same tick; emit once per unique tick (keep the first) so artists see
|
|
// deterministic behavior rather than relying on PlayerAnimator's "last
|
|
// inserted wins" semantic. ARTIST_GUIDE: author at 20 FPS for 1:1.
|
|
float baseline = timelineBaseline(rawClip);
|
|
int lastTick = Integer.MIN_VALUE;
|
|
|
|
for (int f = 0; f < frameCount; f++) {
|
|
int tick = frameToTick(rawClip, f, baseline);
|
|
if (tick == lastTick) continue;
|
|
applyFrameToBuilder(
|
|
builder,
|
|
data,
|
|
rawClip,
|
|
f,
|
|
tick,
|
|
DEFAULT_EASE,
|
|
/* ownedFilter */null,
|
|
/* keyframeCollector */f == 0 ? partsWithKeyframes : null
|
|
);
|
|
lastTick = tick;
|
|
}
|
|
|
|
// Selective: enable owned parts always, free parts only for "Full" animations
|
|
// that explicitly opt into full-body control.
|
|
enableSelectiveParts(
|
|
builder,
|
|
ownedParts,
|
|
enabledParts,
|
|
partsWithKeyframes,
|
|
animName
|
|
);
|
|
|
|
KeyframeAnimation anim = builder.build();
|
|
LOGGER.debug(
|
|
"[GltfPipeline] Converted selective animation '{}' ({} frames, endTick={}, owned={}, enabled={}, withKeyframes={})",
|
|
animName,
|
|
frameCount,
|
|
endTick,
|
|
ownedParts,
|
|
enabledParts,
|
|
partsWithKeyframes
|
|
);
|
|
return anim;
|
|
}
|
|
|
|
/**
|
|
* Apply a single frame's delta rotations for every known bone to the builder,
|
|
* writing one keyframe per bone at {@code tick}.
|
|
*
|
|
* @param ownedFilter if non-null, only bones whose animPart is in this
|
|
* set are written (shared-builder multi-item path)
|
|
* @param keyframeCollector if non-null, parts that have explicit rotation or
|
|
* translation channels are added to this set
|
|
*/
|
|
private static void applyFrameToBuilder(
|
|
KeyframeAnimation.AnimationBuilder builder,
|
|
GltfData data,
|
|
@Nullable GltfData.AnimationClip rawClip,
|
|
int frameIndex,
|
|
int tick,
|
|
Ease ease,
|
|
@Nullable Set<String> ownedFilter,
|
|
@Nullable Set<String> keyframeCollector
|
|
) {
|
|
String[] jointNames = data.jointNames();
|
|
Quaternionf[] rawRestRotations = data.rawGltfRestRotations();
|
|
|
|
// Two known bones can map to the same PlayerAnimator part (e.g.
|
|
// `body` + `torso` → "body"). Both would write to the same
|
|
// StateCollection and the second write silently wins; instead,
|
|
// first-in-array-order wins and subsequent collisions are skipped.
|
|
// Lower bones don't conflict with upper bones (separate axis).
|
|
Set<String> claimedUpperParts = new java.util.HashSet<>();
|
|
|
|
for (int j = 0; j < data.jointCount(); j++) {
|
|
String boneName = jointNames[j];
|
|
if (!GltfBoneMapper.isKnownBone(boneName)) continue;
|
|
|
|
String animPart = GltfBoneMapper.getAnimPartName(boneName);
|
|
if (animPart == null) continue;
|
|
|
|
boolean isLower = GltfBoneMapper.isLowerBone(boneName);
|
|
|
|
// Apply ownedFilter BEFORE claiming the slot: a bone that this item
|
|
// doesn't own must not reserve the upper-part slot, otherwise a
|
|
// later owned bone mapping to the same slot gets spuriously
|
|
// rejected by the collision check below.
|
|
if (ownedFilter != null) {
|
|
if (!ownedFilter.contains(animPart)) continue;
|
|
// For lower bones, also require the upper bone's part to be owned.
|
|
if (isLower) {
|
|
String upper = GltfBoneMapper.getUpperBoneFor(boneName);
|
|
if (upper != null) {
|
|
String upperPart = GltfBoneMapper.getAnimPartName(upper);
|
|
if (
|
|
upperPart == null ||
|
|
!ownedFilter.contains(upperPart)
|
|
) continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!isLower && !claimedUpperParts.add(animPart)) {
|
|
// Another upper bone already claimed this PlayerAnimator part.
|
|
// Skip the duplicate write so HashMap iteration order can't
|
|
// silently flip which bone drives the pose.
|
|
if (frameIndex == 0) {
|
|
LOGGER.warn(
|
|
"[GltfPipeline] Bone '{}' maps to PlayerAnimator part '{}' already written by an earlier bone — ignoring. Use only one of them in the GLB.",
|
|
boneName,
|
|
animPart
|
|
);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
Quaternionf animQ = getRawAnimQuaternion(
|
|
rawClip,
|
|
rawRestRotations,
|
|
j,
|
|
frameIndex
|
|
);
|
|
Quaternionf restQ = rawRestRotations[j];
|
|
|
|
// delta_local = inverse(rest_q) * anim_q (bone-local frame)
|
|
Quaternionf deltaLocal = new Quaternionf(restQ).invert().mul(animQ);
|
|
// delta_parent = rest * delta_local * inv(rest)
|
|
Quaternionf deltaParent = new Quaternionf(restQ)
|
|
.mul(deltaLocal)
|
|
.mul(new Quaternionf(restQ).invert());
|
|
// glTF parent frame → MC model-def frame: 180° around Z (negate qx, qy).
|
|
Quaternionf deltaQ = new Quaternionf(deltaParent);
|
|
deltaQ.x = -deltaQ.x;
|
|
deltaQ.y = -deltaQ.y;
|
|
|
|
if (isLower) {
|
|
convertLowerBone(builder, boneName, deltaQ, tick, ease);
|
|
} else {
|
|
convertUpperBone(builder, boneName, deltaQ, tick, ease);
|
|
}
|
|
|
|
if (keyframeCollector != null) {
|
|
// Translation-only channels count as "explicit": a pure-
|
|
// translation animation (e.g. a rigid-body bounce) still
|
|
// feeds keyframes to PlayerAnimator, so its part must be
|
|
// claimed for composite merging.
|
|
boolean hasExplicitAnim =
|
|
rawClip != null &&
|
|
((j < rawClip.rotations().length &&
|
|
rawClip.rotations()[j] != null) ||
|
|
(rawClip.translations() != null &&
|
|
j < rawClip.translations().length &&
|
|
rawClip.translations()[j] != null));
|
|
if (hasExplicitAnim) {
|
|
keyframeCollector.add(animPart);
|
|
if (isLower) {
|
|
String upper = GltfBoneMapper.getUpperBoneFor(boneName);
|
|
if (upper != null) {
|
|
String upperPart = GltfBoneMapper.getAnimPartName(
|
|
upper
|
|
);
|
|
if (upperPart != null) keyframeCollector.add(
|
|
upperPart
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add keyframes for specific owned parts from a GLB animation clip to an existing builder.
|
|
*
|
|
* <p>Only writes keyframes for bones that map to a part in {@code ownedParts}.
|
|
* Other bones are skipped entirely. This allows multiple items to contribute
|
|
* to the same animation builder without overwriting each other's keyframes.</p>
|
|
*
|
|
* @param builder the shared animation builder to add keyframes to
|
|
* @param data parsed glTF data
|
|
* @param rawClip the raw animation clip, or null for rest pose
|
|
* @param ownedParts parts this item exclusively owns (only these get keyframes)
|
|
* @return set of part names that received actual keyframe data from the GLB
|
|
*/
|
|
public static Set<String> addBonesToBuilder(
|
|
KeyframeAnimation.AnimationBuilder builder,
|
|
GltfData data,
|
|
@Nullable GltfData.AnimationClip rawClip,
|
|
Set<String> ownedParts
|
|
) {
|
|
Set<String> partsWithKeyframes = new HashSet<>();
|
|
int frameCount = rawClip != null ? Math.max(1, rawClip.frameCount()) : 1;
|
|
float baseline = timelineBaseline(rawClip);
|
|
int lastTick = Integer.MIN_VALUE;
|
|
|
|
for (int f = 0; f < frameCount; f++) {
|
|
int tick = frameToTick(rawClip, f, baseline);
|
|
if (tick == lastTick) continue;
|
|
applyFrameToBuilder(
|
|
builder,
|
|
data,
|
|
rawClip,
|
|
f,
|
|
tick,
|
|
DEFAULT_EASE,
|
|
ownedParts,
|
|
f == 0 ? partsWithKeyframes : null
|
|
);
|
|
lastTick = tick;
|
|
}
|
|
|
|
return partsWithKeyframes;
|
|
}
|
|
|
|
/**
|
|
* Convert an animation clip using skeleton data from a separate source.
|
|
*
|
|
* <p>This is useful when the animation clip is stored separately from the
|
|
* skeleton (e.g., furniture seat animations where the Player_* armature's
|
|
* clips are parsed into a separate map from the skeleton GltfData).</p>
|
|
*
|
|
* <p>The resulting animation has all parts fully enabled. Callers should
|
|
* create a mutable copy and selectively disable parts as needed.</p>
|
|
*
|
|
* @param skeleton the GltfData providing rest pose, joint names, and joint count
|
|
* @param clip the raw animation clip (in glTF space) to convert
|
|
* @param animName debug name for the resulting animation
|
|
* @return a static looping KeyframeAnimation with all parts enabled
|
|
*/
|
|
public static KeyframeAnimation convertWithSkeleton(
|
|
GltfData skeleton,
|
|
GltfData.AnimationClip clip,
|
|
String animName
|
|
) {
|
|
return convertClip(skeleton, clip, animName);
|
|
}
|
|
|
|
/**
|
|
* Internal: convert a specific raw animation clip to a KeyframeAnimation.
|
|
*/
|
|
private static KeyframeAnimation convertClip(
|
|
GltfData data,
|
|
@Nullable GltfData.AnimationClip rawClip,
|
|
String animName
|
|
) {
|
|
KeyframeAnimation.AnimationBuilder builder =
|
|
new KeyframeAnimation.AnimationBuilder(
|
|
AnimationFormat.JSON_EMOTECRAFT
|
|
);
|
|
|
|
int endTick = computeEndTick(rawClip);
|
|
int frameCount = rawClip != null ? Math.max(1, rawClip.frameCount()) : 1;
|
|
float baseline = timelineBaseline(rawClip);
|
|
int lastTick = Integer.MIN_VALUE;
|
|
|
|
builder.beginTick = 0;
|
|
builder.endTick = endTick;
|
|
builder.stopTick = endTick;
|
|
builder.isLooped = true;
|
|
builder.returnTick = 0;
|
|
builder.name = animName;
|
|
|
|
for (int f = 0; f < frameCount; f++) {
|
|
int tick = frameToTick(rawClip, f, baseline);
|
|
if (tick == lastTick) continue;
|
|
applyFrameToBuilder(
|
|
builder,
|
|
data,
|
|
rawClip,
|
|
f,
|
|
tick,
|
|
DEFAULT_EASE,
|
|
/* ownedFilter */null,
|
|
/* keyframeCollector */null
|
|
);
|
|
lastTick = tick;
|
|
}
|
|
|
|
builder.fullyEnableParts();
|
|
|
|
KeyframeAnimation anim = builder.build();
|
|
LOGGER.debug(
|
|
"[GltfPipeline] Converted glTF animation '{}' ({} frames, endTick={})",
|
|
animName,
|
|
frameCount,
|
|
endTick
|
|
);
|
|
return anim;
|
|
}
|
|
|
|
/**
|
|
* Get the raw animation quaternion for a joint at a specific frame.
|
|
* Falls back to rest rotation if the clip is null, has no data for this joint,
|
|
* or has an empty channel. Clamps frameIndex to the last available frame if
|
|
* the joint's channel is shorter than the shared timestamps array.
|
|
*/
|
|
private static Quaternionf getRawAnimQuaternion(
|
|
@Nullable GltfData.AnimationClip rawClip,
|
|
Quaternionf[] rawRestRotations,
|
|
int jointIndex,
|
|
int frameIndex
|
|
) {
|
|
if (
|
|
rawClip != null &&
|
|
jointIndex < rawClip.rotations().length &&
|
|
rawClip.rotations()[jointIndex] != null
|
|
) {
|
|
Quaternionf[] channel = rawClip.rotations()[jointIndex];
|
|
if (channel.length > 0) {
|
|
int safeFrame = Math.min(frameIndex, channel.length - 1);
|
|
return channel[safeFrame];
|
|
}
|
|
}
|
|
// Defensive: under a well-formed GLB, jointCount == restRotations.length
|
|
// (guaranteed by the parser). This guard keeps us from AIOOBE-ing if
|
|
// that invariant is ever broken by a future parser path.
|
|
if (jointIndex >= rawRestRotations.length) {
|
|
return new Quaternionf();
|
|
}
|
|
return rawRestRotations[jointIndex];
|
|
}
|
|
|
|
private static void convertUpperBone(
|
|
KeyframeAnimation.AnimationBuilder builder,
|
|
String boneName,
|
|
Quaternionf deltaQ,
|
|
int tick,
|
|
Ease ease
|
|
) {
|
|
// "ZYX" is rotation order, not storage: euler.{x,y,z} hold the X/Y/Z
|
|
// Euler angles for a R = Rz·Ry·Rx decomposition. Gimbal lock at the
|
|
// middle axis (euler.y = ±90°); see ARTIST_GUIDE.md Common Mistakes.
|
|
Vector3f euler = new Vector3f();
|
|
deltaQ.getEulerAnglesZYX(euler);
|
|
float pitch = euler.x;
|
|
float yaw = euler.y;
|
|
float roll = euler.z;
|
|
|
|
String animPart = GltfBoneMapper.getAnimPartName(boneName);
|
|
if (animPart == null) return;
|
|
|
|
KeyframeAnimation.StateCollection part = getPartByName(
|
|
builder,
|
|
animPart
|
|
);
|
|
if (part == null) return;
|
|
|
|
part.pitch.addKeyFrame(tick, pitch, ease);
|
|
part.yaw.addKeyFrame(tick, yaw, ease);
|
|
part.roll.addKeyFrame(tick, roll, ease);
|
|
}
|
|
|
|
private static void convertLowerBone(
|
|
KeyframeAnimation.AnimationBuilder builder,
|
|
String boneName,
|
|
Quaternionf deltaQ,
|
|
int tick,
|
|
Ease ease
|
|
) {
|
|
// Canonicalize q: q and -q represent the same rotation. Always pick the
|
|
// hemisphere with w >= 0 so consecutive frames don't pop across the
|
|
// double-cover boundary when interpolating.
|
|
float qx = deltaQ.x;
|
|
float qy = deltaQ.y;
|
|
float qz = deltaQ.z;
|
|
float qw = deltaQ.w;
|
|
if (qw < 0) {
|
|
qx = -qx;
|
|
qy = -qy;
|
|
qz = -qz;
|
|
qw = -qw;
|
|
}
|
|
|
|
// Now qw is in [0, 1]. Rotation angle = 2 * acos(qw), in [0, π].
|
|
float angle = 2.0f * (float) Math.acos(Math.min(1.0f, qw));
|
|
|
|
float bendDirection = 0.0f;
|
|
if (qx * qx + qz * qz > 0.001f) {
|
|
bendDirection = (float) Math.atan2(qz, qx);
|
|
}
|
|
|
|
String upperBone = GltfBoneMapper.getUpperBoneFor(boneName);
|
|
if (upperBone == null) return;
|
|
|
|
String animPart = GltfBoneMapper.getAnimPartName(upperBone);
|
|
if (animPart == null) return;
|
|
|
|
KeyframeAnimation.StateCollection part = getPartByName(
|
|
builder,
|
|
animPart
|
|
);
|
|
if (part == null || !part.isBendable) return;
|
|
|
|
part.bend.addKeyFrame(tick, angle, ease);
|
|
part.bendDirection.addKeyFrame(tick, bendDirection, ease);
|
|
}
|
|
|
|
private static KeyframeAnimation.StateCollection getPartByName(
|
|
KeyframeAnimation.AnimationBuilder builder,
|
|
String name
|
|
) {
|
|
return switch (name) {
|
|
case "head" -> builder.head;
|
|
case "body" -> builder.body;
|
|
case "rightArm" -> builder.rightArm;
|
|
case "leftArm" -> builder.leftArm;
|
|
case "rightLeg" -> builder.rightLeg;
|
|
case "leftLeg" -> builder.leftLeg;
|
|
default -> null;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Enable parts selectively based on ownership and keyframe presence.
|
|
*
|
|
* <ul>
|
|
* <li>Owned parts: always enabled (the item controls these bones)</li>
|
|
* <li>Free parts WITH keyframes AND "Full" animation: enabled (explicit opt-in to full-body)</li>
|
|
* <li>Free parts without "Full" prefix: disabled (prevents accidental bone hijacking)</li>
|
|
* <li>Other items' parts: disabled (pass through to their own layer)</li>
|
|
* </ul>
|
|
*
|
|
* <p>The "Full" prefix convention (FullIdle, FullStruggle, FullWalk) is the artist's
|
|
* explicit declaration that this animation is designed to control the entire body,
|
|
* not just the item's owned regions. Without this prefix, free bones are never enabled,
|
|
* even if the GLB contains keyframes for them. This prevents accidental bone hijacking
|
|
* when an artist keyframes all bones in Blender by default.</p>
|
|
*
|
|
* @param builder the animation builder with keyframes already added
|
|
* @param ownedParts parts the item explicitly owns (always enabled)
|
|
* @param enabledParts parts the item may animate (owned + free)
|
|
* @param partsWithKeyframes parts that received actual animation data from the GLB
|
|
* @param animName resolved animation name (checked for "Full" prefix)
|
|
*/
|
|
private static void enableSelectiveParts(
|
|
KeyframeAnimation.AnimationBuilder builder,
|
|
Set<String> ownedParts,
|
|
Set<String> enabledParts,
|
|
Set<String> partsWithKeyframes,
|
|
String animName
|
|
) {
|
|
boolean isFullBodyAnimation = isFullBodyAnimName(animName);
|
|
boolean allowFreeHead = isFullHeadAnimName(animName);
|
|
enableSelectivePartsCore(
|
|
builder,
|
|
ownedParts,
|
|
enabledParts,
|
|
partsWithKeyframes,
|
|
isFullBodyAnimation,
|
|
allowFreeHead
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Check whether a resolved-and-prefixed animation name (e.g. {@code "gltf_FullStruggle"})
|
|
* declares opt-in to full-body free-bone animation. See the "Full" prefix
|
|
* convention in {@link #enableSelectiveParts}.
|
|
*/
|
|
public static boolean isFullBodyAnimName(@Nullable String animName) {
|
|
return animName != null && animName.startsWith("gltf_Full");
|
|
}
|
|
|
|
/**
|
|
* Check whether a resolved-and-prefixed animation name opts in to head
|
|
* animation as a free bone (e.g. {@code "gltf_FullHeadStruggle"}). Head is
|
|
* protected by default to preserve vanilla head-tracking on bondage items
|
|
* that don't specifically want to animate it.
|
|
*/
|
|
public static boolean isFullHeadAnimName(@Nullable String animName) {
|
|
return isFullBodyAnimName(animName) &&
|
|
animName.startsWith("gltf_FullHead");
|
|
}
|
|
|
|
/**
|
|
* Composite variant of {@link #enableSelectiveParts} used by the multi-item
|
|
* path. Callers (e.g. {@code GltfAnimationApplier.applyMultiItemV2Animation})
|
|
* compute the three aggregates themselves: {@code allOwnedParts} is the
|
|
* union of owned regions across all items, {@code partsWithKeyframes} is
|
|
* the union of keyframe parts returned by each {@link #addBonesToBuilder}
|
|
* call, and the two Full/FullHead flags should be true if ANY item in the
|
|
* composite resolved to a {@code FullX}/{@code FullHeadX} animation name.
|
|
*/
|
|
public static void enableSelectivePartsComposite(
|
|
KeyframeAnimation.AnimationBuilder builder,
|
|
Set<String> allOwnedParts,
|
|
Set<String> partsWithKeyframes,
|
|
boolean isFullBodyAnimation,
|
|
boolean allowFreeHead
|
|
) {
|
|
// In the composite path every animation part is implicitly in
|
|
// enabledParts — if a FullX animation has keyframes for it, we want it
|
|
// enabled. Pass ALL_PARTS as the enabled set so the single-item
|
|
// opt-out path is a no-op.
|
|
enableSelectivePartsCore(
|
|
builder,
|
|
allOwnedParts,
|
|
ALL_PARTS_SET,
|
|
partsWithKeyframes,
|
|
isFullBodyAnimation,
|
|
allowFreeHead
|
|
);
|
|
}
|
|
|
|
private static final Set<String> ALL_PARTS_SET = Set.of(
|
|
"head",
|
|
"body",
|
|
"rightArm",
|
|
"leftArm",
|
|
"rightLeg",
|
|
"leftLeg"
|
|
);
|
|
|
|
private static void enableSelectivePartsCore(
|
|
KeyframeAnimation.AnimationBuilder builder,
|
|
Set<String> ownedParts,
|
|
Set<String> enabledParts,
|
|
Set<String> partsWithKeyframes,
|
|
boolean isFullBodyAnimation,
|
|
boolean allowFreeHead
|
|
) {
|
|
for (String partName : ALL_PARTS_SET) {
|
|
KeyframeAnimation.StateCollection part = getPartByName(
|
|
builder,
|
|
partName
|
|
);
|
|
if (part != null) {
|
|
if (ownedParts.contains(partName)) {
|
|
// Always enable owned parts — the item controls these bones
|
|
part.fullyEnablePart(false);
|
|
} else if (
|
|
isFullBodyAnimation &&
|
|
enabledParts.contains(partName) &&
|
|
partsWithKeyframes.contains(partName) &&
|
|
(!"head".equals(partName) || allowFreeHead)
|
|
) {
|
|
// Full-body animation: free part WITH keyframes — enable.
|
|
// The "Full" prefix is the artist's explicit opt-in to animate
|
|
// bones outside their declared regions.
|
|
// Head is protected by default (preserves vanilla head tracking).
|
|
// Use "Head" in the animation name (e.g., FullHeadStruggle) to
|
|
// explicitly opt-in to head control for that animation.
|
|
part.fullyEnablePart(false);
|
|
} else {
|
|
// Non-Full animation, other item's part, or free part without keyframes.
|
|
// Disabled parts pass through to the lower-priority context layer.
|
|
part.setEnabled(false);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|