From 17269f51f894a512599ef606bb2f6bcb48c1bbfc Mon Sep 17 00:00:00 2001 From: NotEvil Date: Fri, 17 Apr 2026 01:45:02 +0200 Subject: [PATCH] =?UTF-8?q?perf(gltf):=20add=20skinning=20cache=20?= =?UTF-8?q?=E2=80=94=20skip=20re-skinning=20when=20pose=20is=20unchanged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../remake/client/gltf/GltfClientSetup.java | 21 +++ .../remake/client/gltf/GltfMeshRenderer.java | 95 ++++++++++++++ .../remake/client/gltf/GltfSkinCache.java | 121 ++++++++++++++++++ 3 files changed, 237 insertions(+) create mode 100644 src/main/java/com/tiedup/remake/client/gltf/GltfSkinCache.java diff --git a/src/main/java/com/tiedup/remake/client/gltf/GltfClientSetup.java b/src/main/java/com/tiedup/remake/client/gltf/GltfClientSetup.java index 7c2b204..8868887 100644 --- a/src/main/java/com/tiedup/remake/client/gltf/GltfClientSetup.java +++ b/src/main/java/com/tiedup/remake/client/gltf/GltfClientSetup.java @@ -96,6 +96,7 @@ public final class GltfClientSetup { ProfilerFiller profiler ) { GltfCache.clearCache(); + GltfSkinCache.clearAll(); GltfAnimationApplier.invalidateCache(); GltfMeshRenderer.clearRenderTypeCache(); // Reload context GLB animations from resource packs FIRST, @@ -120,4 +121,24 @@ public final class GltfClientSetup { } } + /** + * FORGE bus event subscribers for entity lifecycle cleanup. + * Removes skin cache entries when entities leave the level, preventing memory leaks. + */ + @Mod.EventBusSubscriber( + modid = "tiedup", + bus = Mod.EventBusSubscriber.Bus.FORGE, + value = Dist.CLIENT + ) + public static class ForgeBusEvents { + + @SubscribeEvent + public static void onEntityLeaveLevel( + net.minecraftforge.event.entity.EntityLeaveLevelEvent event + ) { + if (event.getLevel().isClientSide()) { + GltfSkinCache.removeEntity(event.getEntity().getId()); + } + } + } } diff --git a/src/main/java/com/tiedup/remake/client/gltf/GltfMeshRenderer.java b/src/main/java/com/tiedup/remake/client/gltf/GltfMeshRenderer.java index e7a9c5b..d25e706 100644 --- a/src/main/java/com/tiedup/remake/client/gltf/GltfMeshRenderer.java +++ b/src/main/java/com/tiedup/remake/client/gltf/GltfMeshRenderer.java @@ -225,6 +225,101 @@ public final class GltfMeshRenderer extends RenderStateShard { } } + /** + * Two-pass skinned renderer with cache support. + * + *

Pass 1 (skippable): if {@code cachedPositions} is null, skin every + * unique vertex into flat {@code float[]} arrays (positions and normals). + * If cached arrays are provided, Pass 1 is skipped entirely.

+ * + *

Pass 2 (always): iterate the index buffer, read skinned data from + * the arrays, and emit to the {@link VertexConsumer}.

+ * + * @param data parsed glTF data + * @param jointMatrices computed joint matrices from skinning engine + * @param poseStack current pose stack + * @param buffer multi-buffer source + * @param packedLight packed light value + * @param packedOverlay packed overlay value + * @param renderType the RenderType to use for rendering + * @param cachedPositions previously cached skinned positions, or null to compute fresh + * @param cachedNormals previously cached skinned normals, or null to compute fresh + * @return {@code new float[][] { positions, normals }} for the caller to cache + */ + public static float[][] renderSkinnedWithCache( + GltfData data, + Matrix4f[] jointMatrices, + PoseStack poseStack, + MultiBufferSource buffer, + int packedLight, + int packedOverlay, + RenderType renderType, + float[] cachedPositions, + float[] cachedNormals + ) { + int vertexCount = data.vertexCount(); + float[] positions; + float[] normals; + + // -- Pass 1: Skin all unique vertices (skipped when cache hit) -- + if (cachedPositions != null && cachedNormals != null) { + positions = cachedPositions; + normals = cachedNormals; + } else { + positions = new float[vertexCount * 3]; + normals = new float[vertexCount * 3]; + + float[] outPos = new float[3]; + float[] outNormal = new float[3]; + Vector4f tmpPos = new Vector4f(); + Vector4f tmpNorm = new Vector4f(); + + for (int v = 0; v < vertexCount; v++) { + GltfSkinningEngine.skinVertex( + data, v, jointMatrices, outPos, outNormal, tmpPos, tmpNorm + ); + positions[v * 3] = outPos[0]; + positions[v * 3 + 1] = outPos[1]; + positions[v * 3 + 2] = outPos[2]; + normals[v * 3] = outNormal[0]; + normals[v * 3 + 1] = outNormal[1]; + normals[v * 3 + 2] = outNormal[2]; + } + } + + // -- Pass 2: Emit vertices from arrays to VertexConsumer -- + Matrix4f pose = poseStack.last().pose(); + Matrix3f normalMat = poseStack.last().normal(); + VertexConsumer vc = buffer.getBuffer(renderType); + + int[] indices = data.indices(); + float[] texCoords = data.texCoords(); + + for (int idx : indices) { + float px = positions[idx * 3]; + float py = positions[idx * 3 + 1]; + float pz = positions[idx * 3 + 2]; + + float nx = normals[idx * 3]; + float ny = normals[idx * 3 + 1]; + float nz = normals[idx * 3 + 2]; + + float u = texCoords[idx * 2]; + float v = texCoords[idx * 2 + 1]; + + vc + .vertex(pose, px, py, pz) + .color(255, 255, 255, 255) + .uv(u, 1.0f - v) + .overlayCoords(packedOverlay) + .uv2(packedLight) + .normal(normalMat, nx, ny, nz) + .endVertex(); + } + + return new float[][] { positions, normals }; + } + /** * Render a skinned glTF mesh with per-primitive tint colors. * diff --git a/src/main/java/com/tiedup/remake/client/gltf/GltfSkinCache.java b/src/main/java/com/tiedup/remake/client/gltf/GltfSkinCache.java new file mode 100644 index 0000000..5360600 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/gltf/GltfSkinCache.java @@ -0,0 +1,121 @@ +package com.tiedup.remake.client.gltf; + +import java.util.HashMap; +import java.util.Map; +import net.minecraft.resources.ResourceLocation; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Skinning result cache that avoids re-skinning when a player's pose hasn't changed. + * + *

Uses a dirty-flag approach: each cache entry stores the raw int bits of + * every float input (joint euler angles) that drove the last skinning pass. + * On the next frame, if all bits match exactly, the cached skinned positions + * and normals are reused (skipping the expensive LBS loop). + * + *

Bit comparison via {@link Float#floatToRawIntBits(float)} avoids epsilon + * drift: a pose is "unchanged" only when the inputs are identical down to the + * bit (idle, AFK, paused animation frame). + */ +@OnlyIn(Dist.CLIENT) +public final class GltfSkinCache { + + private record CacheKey(int entityId, ResourceLocation modelLoc) {} + + private static final class Entry { + int[] lastInputBits; + float[] skinnedPositions; + float[] skinnedNormals; + } + + private static final Map cache = new HashMap<>(); + + private GltfSkinCache() {} + + /** + * Check whether the pose inputs are bit-identical to the last cached skinning pass. + * + * @param entityId the entity's numeric ID + * @param modelLoc the model ResourceLocation (distinguishes multiple items on one entity) + * @param currentInputs flat array of float inputs that drove joint matrix computation + * @return true if every input bit matches the cached entry (safe to reuse cached data) + */ + public static boolean isPoseUnchanged( + int entityId, + ResourceLocation modelLoc, + float[] currentInputs + ) { + CacheKey key = new CacheKey(entityId, modelLoc); + Entry entry = cache.get(key); + if (entry == null || entry.lastInputBits == null) return false; + if (entry.lastInputBits.length != currentInputs.length) return false; + for (int i = 0; i < currentInputs.length; i++) { + if ( + entry.lastInputBits[i] + != Float.floatToRawIntBits(currentInputs[i]) + ) { + return false; + } + } + return true; + } + + /** + * Store a skinning result in the cache, replacing any previous entry for the same key. + * + * @param entityId the entity's numeric ID + * @param modelLoc the model ResourceLocation + * @param poseInputs the float inputs that produced these results (will be bit-snapshotted) + * @param positions skinned vertex positions (will be cloned) + * @param normals skinned vertex normals (will be cloned) + */ + public static void store( + int entityId, + ResourceLocation modelLoc, + float[] poseInputs, + float[] positions, + float[] normals + ) { + CacheKey key = new CacheKey(entityId, modelLoc); + Entry entry = cache.computeIfAbsent(key, k -> new Entry()); + entry.lastInputBits = new int[poseInputs.length]; + for (int i = 0; i < poseInputs.length; i++) { + entry.lastInputBits[i] = Float.floatToRawIntBits(poseInputs[i]); + } + entry.skinnedPositions = positions.clone(); + entry.skinnedNormals = normals.clone(); + } + + /** + * Retrieve cached skinned positions, or null if no cache entry exists. + */ + public static float[] getCachedPositions( + int entityId, + ResourceLocation modelLoc + ) { + Entry entry = cache.get(new CacheKey(entityId, modelLoc)); + return entry != null ? entry.skinnedPositions : null; + } + + /** + * Retrieve cached skinned normals, or null if no cache entry exists. + */ + public static float[] getCachedNormals( + int entityId, + ResourceLocation modelLoc + ) { + Entry entry = cache.get(new CacheKey(entityId, modelLoc)); + return entry != null ? entry.skinnedNormals : null; + } + + /** Clear the entire cache (called on resource reload). */ + public static void clearAll() { + cache.clear(); + } + + /** Remove all cache entries for a specific entity (called on entity leave). */ + public static void removeEntity(int entityId) { + cache.entrySet().removeIf(e -> e.getKey().entityId == entityId); + } +}