feature/gltf-pipeline-v2 #18

Merged
NotEvil merged 19 commits from feature/gltf-pipeline-v2 into develop 2026-04-17 02:07:45 +00:00
3 changed files with 237 additions and 0 deletions
Showing only changes of commit 17269f51f8 - Show all commits

View File

@@ -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());
}
}
}
}

View File

@@ -225,6 +225,101 @@ public final class GltfMeshRenderer extends RenderStateShard {
}
}
/**
* Two-pass skinned renderer with cache support.
*
* <p><b>Pass 1</b> (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.</p>
*
* <p><b>Pass 2</b> (always): iterate the index buffer, read skinned data from
* the arrays, and emit to the {@link VertexConsumer}.</p>
*
* @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.
*

View File

@@ -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.
*
* <p>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).
*
* <p>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<CacheKey, Entry> 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);
}
}