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:
@@ -6,8 +6,10 @@ import com.tiedup.remake.client.animation.context.ContextAnimationFactory;
|
||||
import com.tiedup.remake.client.animation.context.GlbAnimationResolver;
|
||||
import com.tiedup.remake.client.animation.context.RegionBoneMapper;
|
||||
import dev.kosmx.playerAnim.core.data.KeyframeAnimation;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
@@ -54,12 +56,36 @@ public final class GltfAnimationApplier {
|
||||
private static final Logger LOGGER = LogManager.getLogger("GltfPipeline");
|
||||
|
||||
/**
|
||||
* Cache of converted item-layer KeyframeAnimations.
|
||||
* Keyed by "animSource#context#ownedPartsHash".
|
||||
* Same GLB + same context + same owned parts = same KeyframeAnimation.
|
||||
* Cache of converted item-layer KeyframeAnimations, keyed by
|
||||
* {@code animSource#context#ownedPartsHash}. LRU-bounded via
|
||||
* access-ordered {@link LinkedHashMap}: size capped at
|
||||
* {@link #ITEM_ANIM_CACHE_MAX}, head (least-recently-used) evicted on
|
||||
* overflow. Wrapped in {@link Collections#synchronizedMap} because
|
||||
* {@code LinkedHashMap.get} mutates the iteration order. External
|
||||
* iteration is not supported; use only {@code get}/{@code put}/{@code clear}.
|
||||
*/
|
||||
private static final int ITEM_ANIM_CACHE_MAX = 256;
|
||||
|
||||
// Initial capacity (int)(cap / loadFactor) + 1 so the cap is reached
|
||||
// without a rehash.
|
||||
private static final int ITEM_ANIM_CACHE_INITIAL_CAPACITY =
|
||||
(int) (ITEM_ANIM_CACHE_MAX / 0.75f) + 1;
|
||||
|
||||
private static final Map<String, KeyframeAnimation> itemAnimCache =
|
||||
new ConcurrentHashMap<>();
|
||||
Collections.synchronizedMap(
|
||||
new LinkedHashMap<String, KeyframeAnimation>(
|
||||
ITEM_ANIM_CACHE_INITIAL_CAPACITY,
|
||||
0.75f,
|
||||
true // access-order
|
||||
) {
|
||||
@Override
|
||||
protected boolean removeEldestEntry(
|
||||
Map.Entry<String, KeyframeAnimation> eldest
|
||||
) {
|
||||
return size() > ITEM_ANIM_CACHE_MAX;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Track which composite state is currently active per entity, to avoid redundant replays.
|
||||
@@ -184,16 +210,22 @@ public final class GltfAnimationApplier {
|
||||
// (Struggle.1 vs Struggle.2) get separate cache entries.
|
||||
String variantCacheKey = itemCacheKey + "#" + (glbAnimName != null ? glbAnimName : "default");
|
||||
|
||||
KeyframeAnimation itemAnim = itemAnimCache.get(variantCacheKey);
|
||||
if (itemAnim == null) {
|
||||
// Pass both owned parts and enabled parts (owned + free) for selective enabling
|
||||
itemAnim = GltfPoseConverter.convertSelective(
|
||||
animData,
|
||||
glbAnimName,
|
||||
ownership.thisParts(),
|
||||
ownership.enabledParts()
|
||||
);
|
||||
itemAnimCache.put(variantCacheKey, itemAnim);
|
||||
// Atomic get-or-compute under the map's monitor. Collections
|
||||
// .synchronizedMap only synchronizes individual get/put calls, so a
|
||||
// naive check-then-put races between concurrent converters and can
|
||||
// both double-convert and trip removeEldestEntry with a stale size.
|
||||
KeyframeAnimation itemAnim;
|
||||
synchronized (itemAnimCache) {
|
||||
itemAnim = itemAnimCache.get(variantCacheKey);
|
||||
if (itemAnim == null) {
|
||||
itemAnim = GltfPoseConverter.convertSelective(
|
||||
animData,
|
||||
glbAnimName,
|
||||
ownership.thisParts(),
|
||||
ownership.enabledParts()
|
||||
);
|
||||
itemAnimCache.put(variantCacheKey, itemAnim);
|
||||
}
|
||||
}
|
||||
|
||||
BondageAnimationManager.playDirect(entity, itemAnim);
|
||||
@@ -292,20 +324,29 @@ public final class GltfAnimationApplier {
|
||||
return false;
|
||||
}
|
||||
|
||||
KeyframeAnimation compositeAnim = itemAnimCache.get(compositeCacheKey);
|
||||
// Atomic get-or-compute under the map's monitor (see
|
||||
// applyV2Animation). All current callers are render-thread so no
|
||||
// contention in practice, but the synchronized wrap closes the
|
||||
// window where two converters could race and clobber each other.
|
||||
KeyframeAnimation compositeAnim;
|
||||
synchronized (itemAnimCache) {
|
||||
compositeAnim = itemAnimCache.get(compositeCacheKey);
|
||||
}
|
||||
if (compositeAnim == null) {
|
||||
KeyframeAnimation.AnimationBuilder builder =
|
||||
new KeyframeAnimation.AnimationBuilder(
|
||||
dev.kosmx.playerAnim.core.data.AnimationFormat.JSON_EMOTECRAFT
|
||||
);
|
||||
builder.beginTick = 0;
|
||||
builder.endTick = 1;
|
||||
builder.stopTick = 1;
|
||||
builder.isLooped = true;
|
||||
builder.returnTick = 0;
|
||||
builder.name = "gltf_composite";
|
||||
|
||||
boolean anyLoaded = false;
|
||||
int maxEndTick = 1;
|
||||
Set<String> unionKeyframeParts = new HashSet<>();
|
||||
boolean anyFullBody = false;
|
||||
boolean anyFullHead = false;
|
||||
|
||||
for (ResolvedItem resolved : resolvedItems) {
|
||||
RegionBoneMapper.V2ItemAnimInfo item = resolved.info();
|
||||
@@ -338,9 +379,28 @@ public final class GltfAnimationApplier {
|
||||
}
|
||||
}
|
||||
|
||||
GltfPoseConverter.addBonesToBuilder(
|
||||
builder, animData, rawClip, effectiveParts
|
||||
Set<String> itemKeyframeParts =
|
||||
GltfPoseConverter.addBonesToBuilder(
|
||||
builder, animData, rawClip, effectiveParts
|
||||
);
|
||||
unionKeyframeParts.addAll(itemKeyframeParts);
|
||||
maxEndTick = Math.max(
|
||||
maxEndTick,
|
||||
GltfPoseConverter.computeEndTick(rawClip)
|
||||
);
|
||||
// FullX / FullHeadX opt-in: ANY item requesting it lifts the
|
||||
// restriction for the composite. The animation name passed to
|
||||
// the core helper uses the same "gltf_" prefix convention as
|
||||
// the single-item path.
|
||||
String prefixed = glbAnimName != null
|
||||
? "gltf_" + glbAnimName
|
||||
: null;
|
||||
if (GltfPoseConverter.isFullBodyAnimName(prefixed)) {
|
||||
anyFullBody = true;
|
||||
}
|
||||
if (GltfPoseConverter.isFullHeadAnimName(prefixed)) {
|
||||
anyFullHead = true;
|
||||
}
|
||||
anyLoaded = true;
|
||||
|
||||
LOGGER.debug(
|
||||
@@ -355,33 +415,32 @@ public final class GltfAnimationApplier {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Enable only owned parts on the item layer.
|
||||
// Free parts (head, body, etc. not owned by any item) are disabled here
|
||||
// so they pass through to the context layer / vanilla animation.
|
||||
String[] allPartNames = {
|
||||
"head",
|
||||
"body",
|
||||
"rightArm",
|
||||
"leftArm",
|
||||
"rightLeg",
|
||||
"leftLeg",
|
||||
};
|
||||
for (String partName : allPartNames) {
|
||||
KeyframeAnimation.StateCollection part = getPartByName(
|
||||
builder,
|
||||
partName
|
||||
);
|
||||
if (part != null) {
|
||||
if (allOwnedParts.contains(partName)) {
|
||||
part.fullyEnablePart(false);
|
||||
} else {
|
||||
part.setEnabled(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
builder.endTick = maxEndTick;
|
||||
builder.stopTick = maxEndTick;
|
||||
|
||||
// Selective-part enabling for the composite. Owned parts always on;
|
||||
// free parts (including head) opt-in only if ANY item declares a
|
||||
// FullX / FullHeadX animation AND has keyframes for that part.
|
||||
GltfPoseConverter.enableSelectivePartsComposite(
|
||||
builder,
|
||||
allOwnedParts,
|
||||
unionKeyframeParts,
|
||||
anyFullBody,
|
||||
anyFullHead
|
||||
);
|
||||
|
||||
compositeAnim = builder.build();
|
||||
itemAnimCache.put(compositeCacheKey, compositeAnim);
|
||||
synchronized (itemAnimCache) {
|
||||
// Another thread may have computed the same key while we were
|
||||
// building. Prefer its result to keep one instance per key,
|
||||
// matching removeEldestEntry's accounting.
|
||||
KeyframeAnimation winner = itemAnimCache.get(compositeCacheKey);
|
||||
if (winner != null) {
|
||||
compositeAnim = winner;
|
||||
} else {
|
||||
itemAnimCache.put(compositeCacheKey, compositeAnim);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BondageAnimationManager.playDirect(entity, compositeAnim);
|
||||
@@ -475,21 +534,4 @@ public final class GltfAnimationApplier {
|
||||
return String.join(",", new TreeSet<>(ownedParts));
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up an {@link KeyframeAnimation.StateCollection} by part name on a builder.
|
||||
*/
|
||||
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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user