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:
NotEvil
2026-04-18 17:34:03 +02:00
parent 37da2c1716
commit 11188bc621
63 changed files with 4965 additions and 2226 deletions

View File

@@ -1,9 +1,15 @@
package com.tiedup.remake.client.gltf;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.List;
import org.jetbrains.annotations.Nullable;
import org.joml.Matrix4f;
import org.joml.Quaternionf;
import org.joml.Vector3f;
@@ -27,8 +33,161 @@ public final class GlbParserUtils {
public static final int UNSIGNED_INT = 5125;
public static final int FLOAT = 5126;
/** Maximum allowed GLB file size to prevent OOM from malformed/hostile assets. */
public static final int MAX_GLB_SIZE = 50 * 1024 * 1024; // 50 MB
// GLB binary format constants (shared between parsers and validator).
public static final int GLB_MAGIC = 0x46546C67; // "glTF"
public static final int GLB_VERSION = 2;
public static final int CHUNK_JSON = 0x4E4F534A; // "JSON"
public static final int CHUNK_BIN = 0x004E4942; // "BIN\0"
private GlbParserUtils() {}
/**
* Safely read a GLB stream into a little-endian ByteBuffer positioned past the
* 12-byte header, after validating magic, version, and total length.
*
* <p>Protects downstream parsers from OOM and negative-length crashes on malformed
* or hostile resource packs. Files larger than {@link #MAX_GLB_SIZE} are rejected.</p>
*
* @param input the input stream (will be read)
* @param debugName name included in diagnostic messages
* @return a buffer positioned at the start of the first chunk, with
* remaining bytes exactly equal to {@code totalLength - 12}
* @throws IOException on bad header, size cap exceeded, or truncation
*/
public static ByteBuffer readGlbSafely(InputStream input, String debugName)
throws IOException {
byte[] header = input.readNBytes(12);
if (header.length < 12) {
throw new IOException("GLB truncated in header: " + debugName);
}
ByteBuffer hdr = ByteBuffer.wrap(header).order(ByteOrder.LITTLE_ENDIAN);
int magic = hdr.getInt();
if (magic != GLB_MAGIC) {
throw new IOException("Not a GLB file: " + debugName);
}
int version = hdr.getInt();
if (version != GLB_VERSION) {
throw new IOException(
"Unsupported GLB version " + version + " in " + debugName
);
}
int totalLength = hdr.getInt();
if (totalLength < 12) {
throw new IOException(
"GLB total length " + totalLength + " too small in " + debugName
);
}
if (totalLength > MAX_GLB_SIZE) {
throw new IOException(
"GLB size " +
totalLength +
" exceeds cap " +
MAX_GLB_SIZE +
" in " +
debugName
);
}
int bodyLen = totalLength - 12;
byte[] body = input.readNBytes(bodyLen);
if (body.length < bodyLen) {
throw new IOException(
"GLB truncated: expected " +
bodyLen +
" body bytes, got " +
body.length +
" in " +
debugName
);
}
return ByteBuffer.wrap(body).order(ByteOrder.LITTLE_ENDIAN);
}
/**
* Normalize per-vertex skinning weights so each 4-tuple sums to 1.0.
*
* <p>Blender auto-weights + float quantization often produce sums slightly ≠ 1
* (commonly 0.981.02). LBS without normalization scales the vertex by that
* factor — tiny error per vertex, visible drift over a full mesh.
* Tuples that sum to effectively zero (no influence) are left alone so
* downstream code can treat them as "un-skinned" if needed.</p>
*
* <p>Modifies the array in place. Call once at parse time; zero per-frame cost.</p>
*/
public static void normalizeWeights(float[] weights) {
if (weights == null) return;
if (weights.length % 4 != 0) {
// WEIGHTS_0 is VEC4 per glTF spec; a non-multiple-of-4 array is malformed.
// We still process the well-formed prefix.
org.apache.logging.log4j.LogManager.getLogger(
"GltfPipeline"
).warn(
"[GltfPipeline] WEIGHTS_0 array length {} is not a multiple of 4 (malformed); trailing {} values ignored",
weights.length,
weights.length % 4
);
}
for (int i = 0; i + 3 < weights.length; i += 4) {
float sum =
weights[i] + weights[i + 1] + weights[i + 2] + weights[i + 3];
if (sum > 1.0e-6f && Math.abs(sum - 1.0f) > 1.0e-4f) {
float inv = 1.0f / sum;
weights[i] *= inv;
weights[i + 1] *= inv;
weights[i + 2] *= inv;
weights[i + 3] *= inv;
}
}
}
/**
* Clamp joint indices into the valid range [0, jointCount), remapping
* out-of-range indices to 0 (root). <b>Mutates {@code joints} in place.</b>
* Returns the number of clamps performed so the caller can log a single
* warning when a file is malformed.
*/
public static int clampJointIndices(int[] joints, int jointCount) {
if (joints == null) return 0;
int clamped = 0;
for (int i = 0; i < joints.length; i++) {
if (joints[i] < 0 || joints[i] >= jointCount) {
joints[i] = 0;
clamped++;
}
}
return clamped;
}
/**
* Read a chunk length from the buffer and validate it fits within remaining bytes.
* @throws IOException if length is negative or exceeds remaining
*/
public static int readChunkLength(
ByteBuffer buf,
String chunkName,
String debugName
) throws IOException {
int len = buf.getInt();
if (len < 0) {
throw new IOException(
"Negative " + chunkName + " chunk length in " + debugName
);
}
if (len > buf.remaining() - 4) {
// -4 for the chunk-type field that follows
throw new IOException(
chunkName +
" chunk length " +
len +
" exceeds remaining bytes in " +
debugName
);
}
return len;
}
// ---- Material name parsing ----
/**
@@ -99,16 +258,25 @@ public final class GlbParserUtils {
? bv.get("byteStride").getAsInt()
: 0;
int totalElements = count * components;
float[] result = new float[totalElements];
int componentSize = componentByteSize(componentType);
int stride = byteStride > 0 ? byteStride : components * componentSize;
int totalElements = validateAccessorBounds(
count,
components,
componentSize,
byteOffset,
stride,
binData.capacity()
);
float[] result = new float[totalElements];
// Seek once per element; the sequential reads in readComponentAsFloat
// advance the buffer through the components. Explicit per-component
// seeks are redundant because component c+1 is already at the right
// offset after reading c.
for (int i = 0; i < count; i++) {
int pos = byteOffset + i * stride;
binData.position(byteOffset + i * stride);
for (int c = 0; c < components; c++) {
binData.position(pos + c * componentSize);
result[i * components + c] = readComponentAsFloat(
binData,
componentType
@@ -142,16 +310,22 @@ public final class GlbParserUtils {
? bv.get("byteStride").getAsInt()
: 0;
int totalElements = count * components;
int[] result = new int[totalElements];
int componentSize = componentByteSize(componentType);
int stride = byteStride > 0 ? byteStride : components * componentSize;
int totalElements = validateAccessorBounds(
count,
components,
componentSize,
byteOffset,
stride,
binData.capacity()
);
int[] result = new int[totalElements];
// Seek once per element — see readFloatAccessor comment.
for (int i = 0; i < count; i++) {
int pos = byteOffset + i * stride;
binData.position(byteOffset + i * stride);
for (int c = 0; c < components; c++) {
binData.position(pos + c * componentSize);
result[i * components + c] = readComponentAsInt(
binData,
componentType
@@ -162,6 +336,77 @@ public final class GlbParserUtils {
return result;
}
/**
* Reject malformed/hostile accessors before allocating. Prevents OOM from a
* JSON declaring e.g. {@code count=5e8, type=MAT4} (~8 GB) within a legitimately
* sized GLB. Checks (all must hold):
* <ul>
* <li>{@code count >= 0} and {@code components >= 1}</li>
* <li>{@code count * components} doesn't overflow int</li>
* <li>{@code byteOffset + stride * (count - 1) + components * componentSize <= binCapacity}</li>
* </ul>
*
* @return {@code count * components} (the allocation size)
*/
private static int validateAccessorBounds(
int count,
int components,
int componentSize,
int byteOffset,
int stride,
int binCapacity
) {
if (count < 0) {
throw new IllegalArgumentException(
"Accessor count must be non-negative: " + count
);
}
if (components < 1) {
throw new IllegalArgumentException(
"Accessor components must be >= 1: " + components
);
}
if (byteOffset < 0 || stride < 0 || componentSize < 1) {
throw new IllegalArgumentException(
"Accessor has negative byteOffset/stride or zero componentSize"
);
}
int totalElements;
try {
totalElements = Math.multiplyExact(count, components);
} catch (ArithmeticException overflow) {
throw new IllegalArgumentException(
"Accessor count * components overflows int: " +
count +
" * " +
components
);
}
if (count == 0) {
return 0;
}
// Bytes required: byteOffset + stride*(count-1) + components*componentSize
long lastElementStart =
(long) byteOffset + (long) stride * (long) (count - 1);
long elementBytes = (long) components * (long) componentSize;
long required = lastElementStart + elementBytes;
if (required > binCapacity) {
throw new IllegalArgumentException(
"Accessor would read past BIN chunk: needs " +
required +
" bytes, buffer has " +
binCapacity
);
}
return totalElements;
}
/**
* Read one component as a normalized float in [1, 1] (signed) or [0, 1]
* (unsigned). {@code UNSIGNED_INT} is defensive — glTF 2.0 §3.6.2.3 only
* lists BYTE/UBYTE/SHORT/USHORT as valid normalized types; an out-of-spec
* exporter hitting that branch gets a best-effort divide by 0xFFFFFFFF.
*/
public static float readComponentAsFloat(
ByteBuffer buf,
int componentType
@@ -261,8 +506,543 @@ public final class GlbParserUtils {
);
}
// ---- Naming conventions ----
/**
* True when a mesh name follows the {@code Player*} convention — typically
* a player-armature mesh or seat-armature mesh that the item/furniture
* pipelines must skip. Null-safe.
*
* <p>Historically {@link GlbParser} and {@link
* com.tiedup.remake.client.gltf.diagnostic.GlbValidator GlbValidator}
* used {@code "Player".equals(name)} while
* {@code FurnitureGlbParser} used {@code startsWith("Player")}, so a mesh
* named {@code "Player_foo"} was accepted by the item pipeline but
* rejected by the furniture pipeline. Consolidated on the more defensive
* {@code startsWith} variant — matches the artist guide's
* {@code Player_*} seat armature naming convention.</p>
*/
public static boolean isPlayerMesh(@Nullable String meshName) {
return meshName != null && meshName.startsWith("Player");
}
/**
* Strip a Blender-style armature prefix from a bone or animation name.
* {@code "ArmatureName|bone"} → {@code "bone"}. Keeps everything after
* the last {@code |}. Returns the input unchanged if it has no pipe,
* and returns null if the input is null.
*
* <p>All joint/animation/validator reads must go through this helper —
* a site that stores the raw prefixed name silently breaks artists
* with non-default armature names.</p>
*/
public static String stripArmaturePrefix(@Nullable String name) {
if (name == null) return null;
int pipeIdx = name.lastIndexOf('|');
return pipeIdx >= 0 ? name.substring(pipeIdx + 1) : name;
}
// ---- Node rest pose extraction ----
/**
* Read a node's rest rotation from its glTF JSON representation. Returns
* identity quaternion (0, 0, 0, 1) when no {@code rotation} field is present.
*
* <p>Extracted from 3 call sites in {@link GlbParser} /
* {@link com.tiedup.remake.v2.furniture.client.FurnitureGlbParser
* FurnitureGlbParser} that all had identical logic. Each parser's per-joint
* loop now delegates here to avoid silent drift.</p>
*/
public static Quaternionf readRestRotation(JsonObject node) {
if (node != null && node.has("rotation")) {
JsonArray r = node.getAsJsonArray("rotation");
return new Quaternionf(
r.get(0).getAsFloat(),
r.get(1).getAsFloat(),
r.get(2).getAsFloat(),
r.get(3).getAsFloat()
);
}
return new Quaternionf(); // identity
}
/**
* Read a node's rest translation. Returns the zero vector when no
* {@code translation} field is present. See {@link #readRestRotation}.
*/
public static Vector3f readRestTranslation(JsonObject node) {
if (node != null && node.has("translation")) {
JsonArray t = node.getAsJsonArray("translation");
return new Vector3f(
t.get(0).getAsFloat(),
t.get(1).getAsFloat(),
t.get(2).getAsFloat()
);
}
return new Vector3f();
}
// ---- Bone hierarchy ----
/**
* Build the parent-joint index array by traversing the glTF node children.
*
* @param nodes the top-level {@code nodes} array from the glTF JSON
* @param nodeToJoint mapping: {@code nodeIdx → jointIdx} (use {@code -1}
* for nodes that are not part of the skin)
* @param jointCount the size of the resulting array
* @return array of size {@code jointCount} where index {@code j} holds the
* parent joint index, or {@code -1} for roots
*/
public static int[] buildParentJointIndices(
JsonArray nodes,
int[] nodeToJoint,
int jointCount
) {
int[] parentJointIndices = new int[jointCount];
for (int j = 0; j < jointCount; j++) parentJointIndices[j] = -1;
for (int ni = 0; ni < nodes.size(); ni++) {
JsonObject node = nodes.get(ni).getAsJsonObject();
if (!node.has("children")) continue;
int parentJoint = nodeToJoint[ni];
JsonArray children = node.getAsJsonArray("children");
for (JsonElement child : children) {
int childNodeIdx = child.getAsInt();
// Malformed GLBs may declare a child index outside `nodes`;
// silently skip rather than AIOOBE.
if (childNodeIdx < 0 || childNodeIdx >= nodeToJoint.length) {
continue;
}
int childJoint = nodeToJoint[childNodeIdx];
if (childJoint >= 0 && parentJoint >= 0) {
parentJointIndices[childJoint] = parentJoint;
}
}
}
return parentJointIndices;
}
// ---- Animation parsing ----
/**
* Parse a single {@code glTF animation} JSON object into an
* {@link GltfData.AnimationClip}. Returns {@code null} when the animation
* has no channels that map to the current skin.
*
* <p>Skin-specific filtering is encoded in {@code nodeToJoint}: channels
* targeting a node with a {@code -1} mapping are skipped.</p>
*
* @param animation a single entry from the root {@code animations} array
* @param accessors the root {@code accessors} array
* @param bufferViews the root {@code bufferViews} array
* @param binData the BIN chunk buffer
* @param nodeToJoint mapping from glTF node index to joint index; use
* {@code -1} for nodes not in the skin
* @param jointCount size of the skin
* @return parsed clip, or {@code null} if no channels matched the skin
*/
@Nullable
public static GltfData.AnimationClip parseAnimation(
JsonObject animation,
JsonArray accessors,
JsonArray bufferViews,
ByteBuffer binData,
int[] nodeToJoint,
int jointCount
) {
JsonArray channels = animation.getAsJsonArray("channels");
JsonArray samplers = animation.getAsJsonArray("samplers");
java.util.List<Integer> rotJoints = new java.util.ArrayList<>();
java.util.List<float[]> rotTimestamps = new java.util.ArrayList<>();
java.util.List<Quaternionf[]> rotValues = new java.util.ArrayList<>();
java.util.List<Integer> transJoints = new java.util.ArrayList<>();
java.util.List<float[]> transTimestamps = new java.util.ArrayList<>();
java.util.List<Vector3f[]> transValues = new java.util.ArrayList<>();
for (JsonElement chElem : channels) {
JsonObject channel = chElem.getAsJsonObject();
JsonObject target = channel.getAsJsonObject("target");
String path = target.get("path").getAsString();
int nodeIdx = target.get("node").getAsInt();
if (nodeIdx >= nodeToJoint.length || nodeToJoint[nodeIdx] < 0) continue;
int jointIdx = nodeToJoint[nodeIdx];
int samplerIdx = channel.get("sampler").getAsInt();
JsonObject sampler = samplers.get(samplerIdx).getAsJsonObject();
float[] times = readFloatAccessor(
accessors,
bufferViews,
binData,
sampler.get("input").getAsInt()
);
if ("rotation".equals(path)) {
float[] quats = readFloatAccessor(
accessors,
bufferViews,
binData,
sampler.get("output").getAsInt()
);
Quaternionf[] qArr = new Quaternionf[times.length];
for (int i = 0; i < times.length; i++) {
qArr[i] = new Quaternionf(
quats[i * 4],
quats[i * 4 + 1],
quats[i * 4 + 2],
quats[i * 4 + 3]
);
}
rotJoints.add(jointIdx);
rotTimestamps.add(times);
rotValues.add(qArr);
} else if ("translation".equals(path)) {
float[] vecs = readFloatAccessor(
accessors,
bufferViews,
binData,
sampler.get("output").getAsInt()
);
Vector3f[] tArr = new Vector3f[times.length];
for (int i = 0; i < times.length; i++) {
tArr[i] = new Vector3f(
vecs[i * 3],
vecs[i * 3 + 1],
vecs[i * 3 + 2]
);
}
transJoints.add(jointIdx);
transTimestamps.add(times);
transValues.add(tArr);
}
}
if (rotJoints.isEmpty() && transJoints.isEmpty()) return null;
float[] timestamps = !rotTimestamps.isEmpty()
? rotTimestamps.get(0)
: transTimestamps.get(0);
int frameCount = timestamps.length;
Quaternionf[][] rotations = new Quaternionf[jointCount][];
for (int i = 0; i < rotJoints.size(); i++) {
int jIdx = rotJoints.get(i);
Quaternionf[] vals = rotValues.get(i);
rotations[jIdx] = new Quaternionf[frameCount];
for (int f = 0; f < frameCount; f++) {
rotations[jIdx][f] =
f < vals.length ? vals[f] : vals[vals.length - 1];
}
}
Vector3f[][] translations = new Vector3f[jointCount][];
for (int i = 0; i < transJoints.size(); i++) {
int jIdx = transJoints.get(i);
Vector3f[] vals = transValues.get(i);
translations[jIdx] = new Vector3f[frameCount];
for (int f = 0; f < frameCount; f++) {
translations[jIdx][f] =
f < vals.length
? new Vector3f(vals[f])
: new Vector3f(vals[vals.length - 1]);
}
}
return new GltfData.AnimationClip(
timestamps,
rotations,
translations,
frameCount
);
}
// ---- Primitive mesh parsing ----
/**
* Result of parsing a mesh's primitives: flat per-attribute arrays plus
* per-primitive metadata. All array lengths are in sync with
* {@link #vertexCount}.
*
* <p>When the parsing request had {@code readSkinning = false} (mesh-only
* path used by {@code FurnitureGlbParser.buildMeshOnlyGltfData}), the
* {@link #joints} and {@link #weights} arrays are empty.</p>
*/
public static final class PrimitiveParseResult {
public final float[] positions;
public final float[] normals;
public final float[] texCoords;
public final int[] indices;
public final int[] joints;
public final float[] weights;
public final List<GltfData.Primitive> primitives;
public final int vertexCount;
PrimitiveParseResult(
float[] positions,
float[] normals,
float[] texCoords,
int[] indices,
int[] joints,
float[] weights,
List<GltfData.Primitive> primitives,
int vertexCount
) {
this.positions = positions;
this.normals = normals;
this.texCoords = texCoords;
this.indices = indices;
this.joints = joints;
this.weights = weights;
this.primitives = primitives;
this.vertexCount = vertexCount;
}
}
/**
* Parse every primitive of a mesh, accumulate per-attribute buffers, and
* flatten the result.
*
* <p>Invariants:</p>
* <ul>
* <li>POSITION is required. NORMAL and TEXCOORD_0 are optional and
* default to zero-filled arrays of the correct size.</li>
* <li>Per-primitive indices are offset by the running
* {@code cumulativeVertexCount} so the flat arrays index
* correctly.</li>
* <li>{@code JOINTS_0} is read, out-of-range indices clamped to 0 with
* a WARN log (once per file, via {@link #clampJointIndices}).</li>
* <li>{@code WEIGHTS_0} is read and normalized per-vertex.</li>
* <li>Material tintability: name prefix {@code "tintable_"} → per-channel
* {@link GltfData.Primitive} entry.</li>
* </ul>
*
* @param mesh a single entry from the root {@code meshes} array
* @param accessors root {@code accessors} array
* @param bufferViews root {@code bufferViews} array
* @param binData the BIN chunk buffer
* @param jointCount skin joint count (used to clamp JOINTS_0); pass
* {@code 0} when {@code readSkinning} is false
* @param readSkinning true to read JOINTS_0 / WEIGHTS_0, false for
* mesh-only (furniture placer fallback)
* @param materialNames material name lookup (from
* {@link #parseMaterialNames}); may contain nulls
* @param debugName file/resource name for diagnostics
* @return the consolidated parse result
*/
public static PrimitiveParseResult parsePrimitives(
JsonObject mesh,
JsonArray accessors,
JsonArray bufferViews,
ByteBuffer binData,
int jointCount,
boolean readSkinning,
String[] materialNames,
String debugName
) {
JsonArray primitives = mesh.getAsJsonArray("primitives");
java.util.List<float[]> allPositions = new java.util.ArrayList<>();
java.util.List<float[]> allNormals = new java.util.ArrayList<>();
java.util.List<float[]> allTexCoords = new java.util.ArrayList<>();
java.util.List<int[]> allJoints = new java.util.ArrayList<>();
java.util.List<float[]> allWeights = new java.util.ArrayList<>();
java.util.List<GltfData.Primitive> parsedPrimitives =
new java.util.ArrayList<>();
int cumulativeVertexCount = 0;
for (int pi = 0; pi < primitives.size(); pi++) {
JsonObject primitive = primitives.get(pi).getAsJsonObject();
JsonObject attributes = primitive.getAsJsonObject("attributes");
float[] primPositions = readFloatAccessor(
accessors,
bufferViews,
binData,
attributes.get("POSITION").getAsInt()
);
float[] primNormals = attributes.has("NORMAL")
? readFloatAccessor(
accessors,
bufferViews,
binData,
attributes.get("NORMAL").getAsInt()
)
: new float[primPositions.length];
float[] primTexCoords = attributes.has("TEXCOORD_0")
? readFloatAccessor(
accessors,
bufferViews,
binData,
attributes.get("TEXCOORD_0").getAsInt()
)
: new float[(primPositions.length / 3) * 2];
int primVertexCount = primPositions.length / 3;
int[] primIndices;
if (primitive.has("indices")) {
primIndices = readIntAccessor(
accessors,
bufferViews,
binData,
primitive.get("indices").getAsInt()
);
} else {
primIndices = new int[primVertexCount];
for (int i = 0; i < primVertexCount; i++) primIndices[i] = i;
}
if (cumulativeVertexCount > 0) {
for (int i = 0; i < primIndices.length; i++) {
primIndices[i] += cumulativeVertexCount;
}
}
int[] primJoints = new int[primVertexCount * 4];
float[] primWeights = new float[primVertexCount * 4];
if (readSkinning) {
if (attributes.has("JOINTS_0")) {
primJoints = readIntAccessor(
accessors,
bufferViews,
binData,
attributes.get("JOINTS_0").getAsInt()
);
int clamped = clampJointIndices(primJoints, jointCount);
if (clamped > 0) {
org.apache.logging.log4j.LogManager.getLogger(
"GltfPipeline"
).warn(
"[GltfPipeline] Clamped {} out-of-range joint indices in '{}'",
clamped,
debugName
);
}
}
if (attributes.has("WEIGHTS_0")) {
primWeights = readFloatAccessor(
accessors,
bufferViews,
binData,
attributes.get("WEIGHTS_0").getAsInt()
);
normalizeWeights(primWeights);
}
}
String matName = null;
if (primitive.has("material")) {
int matIdx = primitive.get("material").getAsInt();
if (
matIdx >= 0 &&
materialNames != null &&
matIdx < materialNames.length
) {
matName = materialNames[matIdx];
}
}
boolean isTintable =
matName != null && matName.startsWith("tintable_");
String tintChannel = isTintable ? matName : null;
parsedPrimitives.add(
new GltfData.Primitive(
primIndices,
matName,
isTintable,
tintChannel
)
);
allPositions.add(primPositions);
allNormals.add(primNormals);
allTexCoords.add(primTexCoords);
if (readSkinning) {
allJoints.add(primJoints);
allWeights.add(primWeights);
}
cumulativeVertexCount += primVertexCount;
}
int totalIndices = 0;
for (GltfData.Primitive p : parsedPrimitives)
totalIndices += p.indices().length;
int[] indices = new int[totalIndices];
int offset = 0;
for (GltfData.Primitive p : parsedPrimitives) {
System.arraycopy(
p.indices(),
0,
indices,
offset,
p.indices().length
);
offset += p.indices().length;
}
return new PrimitiveParseResult(
flattenFloats(allPositions),
flattenFloats(allNormals),
flattenFloats(allTexCoords),
indices,
readSkinning ? flattenInts(allJoints) : new int[0],
readSkinning ? flattenFloats(allWeights) : new float[0],
parsedPrimitives,
cumulativeVertexCount
);
}
// ---- Coordinate system conversion ----
/**
* Convert mesh-level spatial data from glTF space to Minecraft model-def
* space. glTF and MC model-def both face -Z; only X (right→left) and Y
* (up→down) differ. Equivalent to a 180° rotation around Z: negate X and Y.
*
* <p>For animation data, see {@link #convertAnimationToMinecraftSpace}.</p>
*
* <ul>
* <li>Vertex positions / normals: (x, y, z) → (-x, -y, z)</li>
* <li>Rest translations: same negation</li>
* <li>Rest rotations (quaternions): negate qx and qy (conjugation by 180° Z)</li>
* <li>Inverse bind matrices: M → C·M·C where C = diag(-1, -1, 1)</li>
* </ul>
*/
public static void convertMeshToMinecraftSpace(
float[] positions,
float[] normals,
Vector3f[] restTranslations,
Quaternionf[] restRotations,
Matrix4f[] inverseBindMatrices
) {
for (int i = 0; i < positions.length; i += 3) {
positions[i] = -positions[i];
positions[i + 1] = -positions[i + 1];
}
for (int i = 0; i < normals.length; i += 3) {
normals[i] = -normals[i];
normals[i + 1] = -normals[i + 1];
}
for (Vector3f t : restTranslations) {
t.x = -t.x;
t.y = -t.y;
}
for (Quaternionf q : restRotations) {
q.x = -q.x;
q.y = -q.y;
}
Matrix4f C = new Matrix4f().scaling(-1, -1, 1);
Matrix4f temp = new Matrix4f();
for (Matrix4f ibm : inverseBindMatrices) {
temp.set(C).mul(ibm).mul(C);
ibm.set(temp);
}
}
/**
* Convert an animation clip's rotations and translations to MC space.
* Negate qx/qy for rotations and negate tx/ty for translations.