Clean repo for open source release

Remove build artifacts, dev tool configs, unused dependencies,
and third-party source dumps. Add proper README, update .gitignore,
clean up Makefile.
This commit is contained in:
NotEvil
2026-04-12 00:51:22 +02:00
parent 2e7a1d403b
commit f6466360b6
1947 changed files with 238025 additions and 1 deletions

View File

@@ -0,0 +1,628 @@
package com.tiedup.remake.client.gltf;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.joml.Matrix4f;
import org.joml.Quaternionf;
import org.joml.Vector3f;
/**
* Parser for binary .glb (glTF 2.0) files.
* Extracts mesh geometry, skinning data, bone hierarchy, and animations.
* Filters out meshes named "Player".
*/
public final class GlbParser {
private static final Logger LOGGER = LogManager.getLogger("GltfPipeline");
private static final int GLB_MAGIC = 0x46546C67; // "glTF"
private static final int GLB_VERSION = 2;
private static final int CHUNK_JSON = 0x4E4F534A; // "JSON"
private static final int CHUNK_BIN = 0x004E4942; // "BIN\0"
private GlbParser() {}
/**
* Parse a .glb file from an InputStream.
*
* @param input the input stream (will be fully read)
* @param debugName name for log messages
* @return parsed GltfData
* @throws IOException if the file is malformed or I/O fails
*/
public static GltfData parse(InputStream input, String debugName) throws IOException {
byte[] allBytes = input.readAllBytes();
ByteBuffer buf = ByteBuffer.wrap(allBytes).order(ByteOrder.LITTLE_ENDIAN);
// -- Header --
int magic = buf.getInt();
if (magic != GLB_MAGIC) {
throw new IOException("Not a GLB file: " + debugName);
}
int version = buf.getInt();
if (version != GLB_VERSION) {
throw new IOException("Unsupported GLB version " + version + " in " + debugName);
}
int totalLength = buf.getInt();
// -- JSON chunk --
int jsonChunkLength = buf.getInt();
int jsonChunkType = buf.getInt();
if (jsonChunkType != CHUNK_JSON) {
throw new IOException("Expected JSON chunk in " + debugName);
}
byte[] jsonBytes = new byte[jsonChunkLength];
buf.get(jsonBytes);
String jsonStr = new String(jsonBytes, StandardCharsets.UTF_8);
JsonObject root = JsonParser.parseString(jsonStr).getAsJsonObject();
// -- BIN chunk --
ByteBuffer binData = null;
if (buf.hasRemaining()) {
int binChunkLength = buf.getInt();
int binChunkType = buf.getInt();
if (binChunkType != CHUNK_BIN) {
throw new IOException("Expected BIN chunk in " + debugName);
}
byte[] binBytes = new byte[binChunkLength];
buf.get(binBytes);
binData = ByteBuffer.wrap(binBytes).order(ByteOrder.LITTLE_ENDIAN);
}
if (binData == null) {
throw new IOException("No BIN chunk in " + debugName);
}
JsonArray accessors = root.getAsJsonArray("accessors");
JsonArray bufferViews = root.getAsJsonArray("bufferViews");
JsonArray nodes = root.getAsJsonArray("nodes");
JsonArray meshes = root.getAsJsonArray("meshes");
// -- Find skin --
JsonArray skins = root.getAsJsonArray("skins");
if (skins == null || skins.size() == 0) {
throw new IOException("No skins found in " + debugName);
}
JsonObject skin = skins.get(0).getAsJsonObject();
JsonArray skinJoints = skin.getAsJsonArray("joints");
// Filter skin joints to only include known deforming bones
List<Integer> filteredJointNodes = new ArrayList<>();
int[] skinJointRemap = new int[skinJoints.size()]; // old skin index -> new filtered index
java.util.Arrays.fill(skinJointRemap, -1);
for (int j = 0; j < skinJoints.size(); j++) {
int nodeIdx = skinJoints.get(j).getAsInt();
JsonObject node = nodes.get(nodeIdx).getAsJsonObject();
String name = node.has("name") ? node.get("name").getAsString() : "joint_" + j;
if (GltfBoneMapper.isKnownBone(name)) {
skinJointRemap[j] = filteredJointNodes.size();
filteredJointNodes.add(nodeIdx);
} else {
LOGGER.debug("[GltfPipeline] Skipping non-deforming bone: '{}' (node {})", name, nodeIdx);
}
}
int jointCount = filteredJointNodes.size();
String[] jointNames = new String[jointCount];
int[] parentJointIndices = new int[jointCount];
Quaternionf[] restRotations = new Quaternionf[jointCount];
Vector3f[] restTranslations = new Vector3f[jointCount];
// Map node index -> joint index (filtered)
int[] nodeToJoint = new int[nodes.size()];
java.util.Arrays.fill(nodeToJoint, -1);
for (int j = 0; j < jointCount; j++) {
int nodeIdx = filteredJointNodes.get(j);
nodeToJoint[nodeIdx] = j;
}
// Read joint names, rest pose, and build parent mapping
java.util.Arrays.fill(parentJointIndices, -1);
for (int j = 0; j < jointCount; j++) {
int nodeIdx = filteredJointNodes.get(j);
JsonObject node = nodes.get(nodeIdx).getAsJsonObject();
jointNames[j] = node.has("name") ? node.get("name").getAsString() : "joint_" + j;
// Rest rotation
if (node.has("rotation")) {
JsonArray r = node.getAsJsonArray("rotation");
restRotations[j] = new Quaternionf(
r.get(0).getAsFloat(), r.get(1).getAsFloat(),
r.get(2).getAsFloat(), r.get(3).getAsFloat()
);
} else {
restRotations[j] = new Quaternionf(); // identity
}
// Rest translation
if (node.has("translation")) {
JsonArray t = node.getAsJsonArray("translation");
restTranslations[j] = new Vector3f(
t.get(0).getAsFloat(), t.get(1).getAsFloat(), t.get(2).getAsFloat()
);
} else {
restTranslations[j] = new Vector3f();
}
}
// Build parent indices by traversing node children
for (int ni = 0; ni < nodes.size(); ni++) {
JsonObject node = nodes.get(ni).getAsJsonObject();
if (node.has("children")) {
int parentJoint = nodeToJoint[ni];
JsonArray children = node.getAsJsonArray("children");
for (JsonElement child : children) {
int childNodeIdx = child.getAsInt();
int childJoint = nodeToJoint[childNodeIdx];
if (childJoint >= 0 && parentJoint >= 0) {
parentJointIndices[childJoint] = parentJoint;
}
}
}
}
// -- Inverse Bind Matrices --
// IBM accessor is indexed by original skin joint order, so we pick the filtered entries
Matrix4f[] inverseBindMatrices = new Matrix4f[jointCount];
if (skin.has("inverseBindMatrices")) {
int ibmAccessor = skin.get("inverseBindMatrices").getAsInt();
float[] ibmData = GlbParserUtils.readFloatAccessor(accessors, bufferViews, binData, ibmAccessor);
for (int origJ = 0; origJ < skinJoints.size(); origJ++) {
int newJ = skinJointRemap[origJ];
if (newJ >= 0) {
inverseBindMatrices[newJ] = new Matrix4f();
inverseBindMatrices[newJ].set(ibmData, origJ * 16);
}
}
} else {
for (int j = 0; j < jointCount; j++) {
inverseBindMatrices[j] = new Matrix4f(); // identity
}
}
// -- Find mesh (ignore "Player" mesh, take LAST non-Player) --
// WORKAROUND: Takes the LAST non-Player mesh because modelers may leave prototype meshes
// in the .glb. Revert to first non-Player mesh once modeler workflow is standardized.
int targetMeshIdx = -1;
if (meshes != null) {
for (int mi = 0; mi < meshes.size(); mi++) {
JsonObject mesh = meshes.get(mi).getAsJsonObject();
String meshName = mesh.has("name") ? mesh.get("name").getAsString() : "";
if (!"Player".equals(meshName)) {
targetMeshIdx = mi;
}
}
}
// -- Parse root material names (for tint channel detection) --
String[] materialNames = GlbParserUtils.parseMaterialNames(root);
// Mesh data: empty arrays if no mesh found (animation-only GLB)
float[] positions;
float[] normals;
float[] texCoords;
int[] indices;
int vertexCount;
int[] meshJoints;
float[] weights;
List<GltfData.Primitive> parsedPrimitives = new ArrayList<>();
if (targetMeshIdx >= 0) {
JsonObject mesh = meshes.get(targetMeshIdx).getAsJsonObject();
JsonArray primitives = mesh.getAsJsonArray("primitives");
// -- Accumulate vertex data from ALL primitives --
List<float[]> allPositions = new ArrayList<>();
List<float[]> allNormals = new ArrayList<>();
List<float[]> allTexCoords = new ArrayList<>();
List<int[]> allJoints = new ArrayList<>();
List<float[]> allWeights = new ArrayList<>();
int cumulativeVertexCount = 0;
for (int pi = 0; pi < primitives.size(); pi++) {
JsonObject primitive = primitives.get(pi).getAsJsonObject();
JsonObject attributes = primitive.getAsJsonObject("attributes");
// -- Read this primitive's vertex data --
float[] primPositions = GlbParserUtils.readFloatAccessor(
accessors, bufferViews, binData,
attributes.get("POSITION").getAsInt()
);
float[] primNormals = attributes.has("NORMAL")
? GlbParserUtils.readFloatAccessor(accessors, bufferViews, binData, attributes.get("NORMAL").getAsInt())
: new float[primPositions.length];
float[] primTexCoords = attributes.has("TEXCOORD_0")
? GlbParserUtils.readFloatAccessor(accessors, bufferViews, binData, attributes.get("TEXCOORD_0").getAsInt())
: new float[primPositions.length / 3 * 2];
int primVertexCount = primPositions.length / 3;
// -- Read this primitive's indices (offset by cumulative vertex count) --
int[] primIndices;
if (primitive.has("indices")) {
primIndices = GlbParserUtils.readIntAccessor(
accessors, bufferViews, binData,
primitive.get("indices").getAsInt()
);
} else {
// Non-indexed: generate sequential indices
primIndices = new int[primVertexCount];
for (int i = 0; i < primVertexCount; i++) primIndices[i] = i;
}
// Offset indices by cumulative vertex count from prior primitives
if (cumulativeVertexCount > 0) {
for (int i = 0; i < primIndices.length; i++) {
primIndices[i] += cumulativeVertexCount;
}
}
// -- Read skinning attributes for this primitive --
int[] primJoints = new int[primVertexCount * 4];
float[] primWeights = new float[primVertexCount * 4];
if (attributes.has("JOINTS_0")) {
primJoints = GlbParserUtils.readIntAccessor(
accessors, bufferViews, binData,
attributes.get("JOINTS_0").getAsInt()
);
// Remap vertex joint indices from original skin order to filtered order
for (int i = 0; i < primJoints.length; i++) {
int origIdx = primJoints[i];
if (origIdx >= 0 && origIdx < skinJointRemap.length) {
primJoints[i] = skinJointRemap[origIdx] >= 0 ? skinJointRemap[origIdx] : 0;
} else {
primJoints[i] = 0;
}
}
}
if (attributes.has("WEIGHTS_0")) {
primWeights = GlbParserUtils.readFloatAccessor(
accessors, bufferViews, binData,
attributes.get("WEIGHTS_0").getAsInt()
);
}
// -- Resolve material name and tint channel --
String matName = null;
if (primitive.has("material")) {
int matIdx = primitive.get("material").getAsInt();
if (matIdx >= 0 && 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);
allJoints.add(primJoints);
allWeights.add(primWeights);
cumulativeVertexCount += primVertexCount;
}
// -- Flatten accumulated data into single arrays --
vertexCount = cumulativeVertexCount;
positions = GlbParserUtils.flattenFloats(allPositions);
normals = GlbParserUtils.flattenFloats(allNormals);
texCoords = GlbParserUtils.flattenFloats(allTexCoords);
meshJoints = GlbParserUtils.flattenInts(allJoints);
weights = GlbParserUtils.flattenFloats(allWeights);
// Build union of all primitive indices (for backward-compat indices() accessor)
int totalIndices = 0;
for (GltfData.Primitive p : parsedPrimitives) totalIndices += p.indices().length;
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;
}
} else {
// Animation-only GLB: no mesh data
LOGGER.info("[GltfPipeline] No mesh found in '{}' (animation-only GLB)", debugName);
positions = new float[0];
normals = new float[0];
texCoords = new float[0];
indices = new int[0];
vertexCount = 0;
meshJoints = new int[0];
weights = new float[0];
}
// -- Read ALL animations --
Map<String, GltfData.AnimationClip> allClips = new LinkedHashMap<>();
JsonArray animations = root.getAsJsonArray("animations");
if (animations != null) {
for (int ai = 0; ai < animations.size(); ai++) {
JsonObject anim = animations.get(ai).getAsJsonObject();
String animName = anim.has("name") ? anim.get("name").getAsString() : "animation_" + ai;
// Strip the "ArmatureName|" prefix if present (Blender convention)
if (animName.contains("|")) {
animName = animName.substring(animName.lastIndexOf('|') + 1);
}
GltfData.AnimationClip clip = parseAnimation(anim, accessors, bufferViews, binData, nodeToJoint, jointCount);
if (clip != null) {
allClips.put(animName, clip);
}
}
}
// Default animation = first clip (for backward compat)
GltfData.AnimationClip animClip = allClips.isEmpty() ? null : allClips.values().iterator().next();
LOGGER.info("[GltfPipeline] Parsed '{}': vertices={}, indices={}, joints={}, animations={}",
debugName, vertexCount, indices.length, jointCount, allClips.size());
for (String name : allClips.keySet()) {
LOGGER.debug("[GltfPipeline] animation: '{}'", name);
}
for (int j = 0; j < jointCount; j++) {
Quaternionf rq = restRotations[j];
Vector3f rt = restTranslations[j];
LOGGER.debug(String.format("[GltfPipeline] joint[%d] = '%s', parent=%d, restQ=(%.3f,%.3f,%.3f,%.3f) restT=(%.3f,%.3f,%.3f)",
j, jointNames[j], parentJointIndices[j],
rq.x, rq.y, rq.z, rq.w, rt.x, rt.y, rt.z));
}
// Log animation translation channels for default clip (BEFORE MC conversion)
if (animClip != null && animClip.translations() != null) {
Vector3f[][] animTrans = animClip.translations();
for (int j = 0; j < jointCount; j++) {
if (j < animTrans.length && animTrans[j] != null) {
Vector3f at = animTrans[j][0]; // first frame
Vector3f rt = restTranslations[j];
LOGGER.debug(String.format(
"[GltfPipeline] joint[%d] '%s' has ANIM TRANSLATION: (%.4f,%.4f,%.4f) vs rest (%.4f,%.4f,%.4f) delta=(%.4f,%.4f,%.4f)",
j, jointNames[j],
at.x, at.y, at.z,
rt.x, rt.y, rt.z,
at.x - rt.x, at.y - rt.y, at.z - rt.z));
}
}
} else {
LOGGER.debug("[GltfPipeline] Default animation has NO translation channels");
}
// Save raw glTF rotations BEFORE coordinate conversion (for pose converter)
// MC model space faces +Z just like glTF, so delta quaternions for ModelPart
// rotation should be computed from raw glTF data, not from the converted data.
Quaternionf[] rawRestRotations = new Quaternionf[jointCount];
for (int j = 0; j < jointCount; j++) {
rawRestRotations[j] = new Quaternionf(restRotations[j]);
}
// Build raw copies of ALL animation clips (before MC conversion)
Map<String, GltfData.AnimationClip> rawAllClips = new LinkedHashMap<>();
for (Map.Entry<String, GltfData.AnimationClip> entry : allClips.entrySet()) {
rawAllClips.put(entry.getKey(), GlbParserUtils.deepCopyClip(entry.getValue()));
}
GltfData.AnimationClip rawAnimClip = rawAllClips.isEmpty() ? null : rawAllClips.values().iterator().next();
// Convert from glTF coordinate system (Y-up, faces +Z) to MC (Y-up, faces -Z)
// This is a 180° rotation around Y: negate X and Z for all spatial data
// Convert ALL animation clips to MC space
for (GltfData.AnimationClip clip : allClips.values()) {
GlbParserUtils.convertAnimationToMinecraftSpace(clip, jointCount);
}
convertToMinecraftSpace(positions, normals, restTranslations, restRotations,
inverseBindMatrices, null, jointCount); // pass null — clips already converted above
LOGGER.debug("[GltfPipeline] Converted all data to Minecraft coordinate space");
return new GltfData(
positions, normals, texCoords,
indices, meshJoints, weights,
jointNames, parentJointIndices,
inverseBindMatrices,
restRotations, restTranslations,
rawRestRotations,
rawAnimClip,
animClip,
allClips, rawAllClips,
parsedPrimitives,
vertexCount, jointCount
);
}
// ---- Animation parsing ----
private 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");
// Collect rotation and translation channels
List<Integer> rotJoints = new ArrayList<>();
List<float[]> rotTimestamps = new ArrayList<>();
List<Quaternionf[]> rotValues = new ArrayList<>();
List<Integer> transJoints = new ArrayList<>();
List<float[]> transTimestamps = new ArrayList<>();
List<Vector3f[]> transValues = new 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 = GlbParserUtils.readFloatAccessor(
accessors, bufferViews, binData,
sampler.get("input").getAsInt()
);
if ("rotation".equals(path)) {
float[] quats = GlbParserUtils.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 = GlbParserUtils.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;
// Use the first available channel's timestamps as reference
float[] timestamps = !rotTimestamps.isEmpty()
? rotTimestamps.get(0)
: transTimestamps.get(0);
int frameCount = timestamps.length;
// Build per-joint rotation arrays (null if no animation for that joint)
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];
}
}
// Build per-joint translation arrays (null if no animation for that joint)
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]);
}
}
// Log translation channels found
if (!transJoints.isEmpty()) {
LOGGER.debug("[GltfPipeline] Animation has {} translation channel(s)",
transJoints.size());
}
return new GltfData.AnimationClip(timestamps, rotations, translations, frameCount);
}
// ---- Coordinate system conversion ----
/**
* Convert all spatial data from glTF space to MC model-def space.
* The Blender-exported character faces -Z in glTF, same as MC model-def.
* Only X (right→left) and Y (up→down) differ between the two spaces.
* Equivalent to a 180° rotation around Z: negate X and Y components.
*
* For positions/normals/translations: (x,y,z) → (-x, -y, z)
* For quaternions: (x,y,z,w) → (-x, -y, z, w) (conjugation by 180° Z)
* For matrices: M → C * M * C where C = diag(-1, -1, 1, 1)
*/
private static void convertToMinecraftSpace(
float[] positions, float[] normals,
Vector3f[] restTranslations, Quaternionf[] restRotations,
Matrix4f[] inverseBindMatrices,
GltfData.AnimationClip animClip, int jointCount
) {
// Vertex positions: negate X and Y
for (int i = 0; i < positions.length; i += 3) {
positions[i] = -positions[i]; // X
positions[i + 1] = -positions[i + 1]; // Y
}
// Vertex normals: negate X and Y
for (int i = 0; i < normals.length; i += 3) {
normals[i] = -normals[i];
normals[i + 1] = -normals[i + 1];
}
// Rest translations: negate X and Y
for (Vector3f t : restTranslations) {
t.x = -t.x;
t.y = -t.y;
}
// Rest rotations: conjugate by 180° Z = negate qx and qy
for (Quaternionf q : restRotations) {
q.x = -q.x;
q.y = -q.y;
}
// Inverse bind matrices: C * M * C where C = diag(-1, -1, 1)
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);
}
// Animation quaternions: same conjugation
if (animClip != null) {
Quaternionf[][] rotations = animClip.rotations();
for (int j = 0; j < jointCount; j++) {
if (j < rotations.length && rotations[j] != null) {
for (Quaternionf q : rotations[j]) {
q.x = -q.x;
q.y = -q.y;
}
}
}
// Animation translations: negate X and Y (same as rest translations)
Vector3f[][] translations = animClip.translations();
if (translations != null) {
for (int j = 0; j < jointCount; j++) {
if (j < translations.length && translations[j] != null) {
for (Vector3f t : translations[j]) {
t.x = -t.x;
t.y = -t.y;
}
}
}
}
}
}
}

View File

@@ -0,0 +1,253 @@
package com.tiedup.remake.client.gltf;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import java.nio.ByteBuffer;
import java.util.List;
import org.joml.Quaternionf;
import org.joml.Vector3f;
/**
* Shared stateless utilities for parsing binary glTF (.glb) files.
*
* <p>These methods are used by both {@link GlbParser} (single-armature bondage meshes)
* and {@link com.tiedup.remake.v2.furniture.client.FurnitureGlbParser FurnitureGlbParser}
* (multi-armature furniture meshes). Extracted to eliminate ~160 lines of verbatim
* duplication between the two parsers.</p>
*
* <p>All methods are pure functions (no state, no side effects).</p>
*/
public final class GlbParserUtils {
// glTF component type constants
public static final int BYTE = 5120;
public static final int UNSIGNED_BYTE = 5121;
public static final int SHORT = 5122;
public static final int UNSIGNED_SHORT = 5123;
public static final int UNSIGNED_INT = 5125;
public static final int FLOAT = 5126;
private GlbParserUtils() {}
// ---- Material name parsing ----
/**
* Parse the root "materials" array and extract each material's "name" field.
* Returns an empty array if no materials are present.
*/
public static String[] parseMaterialNames(JsonObject root) {
if (!root.has("materials") || !root.get("materials").isJsonArray()) {
return new String[0];
}
JsonArray materials = root.getAsJsonArray("materials");
String[] names = new String[materials.size()];
for (int i = 0; i < materials.size(); i++) {
JsonObject mat = materials.get(i).getAsJsonObject();
names[i] = mat.has("name") ? mat.get("name").getAsString() : null;
}
return names;
}
// ---- Array flattening utilities ----
public static float[] flattenFloats(List<float[]> arrays) {
int total = 0;
for (float[] a : arrays) total += a.length;
float[] result = new float[total];
int offset = 0;
for (float[] a : arrays) {
System.arraycopy(a, 0, result, offset, a.length);
offset += a.length;
}
return result;
}
public static int[] flattenInts(List<int[]> arrays) {
int total = 0;
for (int[] a : arrays) total += a.length;
int[] result = new int[total];
int offset = 0;
for (int[] a : arrays) {
System.arraycopy(a, 0, result, offset, a.length);
offset += a.length;
}
return result;
}
// ---- Accessor reading utilities ----
public static float[] readFloatAccessor(
JsonArray accessors, JsonArray bufferViews,
ByteBuffer binData, int accessorIdx
) {
JsonObject accessor = accessors.get(accessorIdx).getAsJsonObject();
int count = accessor.get("count").getAsInt();
int componentType = accessor.get("componentType").getAsInt();
String type = accessor.get("type").getAsString();
int components = typeComponents(type);
int bvIdx = accessor.get("bufferView").getAsInt();
JsonObject bv = bufferViews.get(bvIdx).getAsJsonObject();
int byteOffset = (bv.has("byteOffset") ? bv.get("byteOffset").getAsInt() : 0)
+ (accessor.has("byteOffset") ? accessor.get("byteOffset").getAsInt() : 0);
int byteStride = bv.has("byteStride") ? 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;
for (int i = 0; i < count; i++) {
int pos = byteOffset + i * stride;
for (int c = 0; c < components; c++) {
binData.position(pos + c * componentSize);
result[i * components + c] = readComponentAsFloat(binData, componentType);
}
}
return result;
}
public static int[] readIntAccessor(
JsonArray accessors, JsonArray bufferViews,
ByteBuffer binData, int accessorIdx
) {
JsonObject accessor = accessors.get(accessorIdx).getAsJsonObject();
int count = accessor.get("count").getAsInt();
int componentType = accessor.get("componentType").getAsInt();
String type = accessor.get("type").getAsString();
int components = typeComponents(type);
int bvIdx = accessor.get("bufferView").getAsInt();
JsonObject bv = bufferViews.get(bvIdx).getAsJsonObject();
int byteOffset = (bv.has("byteOffset") ? bv.get("byteOffset").getAsInt() : 0)
+ (accessor.has("byteOffset") ? accessor.get("byteOffset").getAsInt() : 0);
int byteStride = bv.has("byteStride") ? 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;
for (int i = 0; i < count; i++) {
int pos = byteOffset + i * stride;
for (int c = 0; c < components; c++) {
binData.position(pos + c * componentSize);
result[i * components + c] = readComponentAsInt(binData, componentType);
}
}
return result;
}
public static float readComponentAsFloat(ByteBuffer buf, int componentType) {
return switch (componentType) {
case FLOAT -> buf.getFloat();
case BYTE -> buf.get() / 127.0f;
case UNSIGNED_BYTE -> (buf.get() & 0xFF) / 255.0f;
case SHORT -> buf.getShort() / 32767.0f;
case UNSIGNED_SHORT -> (buf.getShort() & 0xFFFF) / 65535.0f;
case UNSIGNED_INT -> (buf.getInt() & 0xFFFFFFFFL) / (float) 0xFFFFFFFFL;
default -> throw new IllegalArgumentException("Unknown component type: " + componentType);
};
}
public static int readComponentAsInt(ByteBuffer buf, int componentType) {
return switch (componentType) {
case BYTE -> buf.get();
case UNSIGNED_BYTE -> buf.get() & 0xFF;
case SHORT -> buf.getShort();
case UNSIGNED_SHORT -> buf.getShort() & 0xFFFF;
case UNSIGNED_INT -> buf.getInt();
case FLOAT -> (int) buf.getFloat();
default -> throw new IllegalArgumentException("Unknown component type: " + componentType);
};
}
public static int typeComponents(String type) {
return switch (type) {
case "SCALAR" -> 1;
case "VEC2" -> 2;
case "VEC3" -> 3;
case "VEC4" -> 4;
case "MAT4" -> 16;
default -> throw new IllegalArgumentException("Unknown accessor type: " + type);
};
}
public static int componentByteSize(int componentType) {
return switch (componentType) {
case BYTE, UNSIGNED_BYTE -> 1;
case SHORT, UNSIGNED_SHORT -> 2;
case UNSIGNED_INT, FLOAT -> 4;
default -> throw new IllegalArgumentException("Unknown component type: " + componentType);
};
}
// ---- Deep-copy utility ----
/**
* Deep-copy an AnimationClip (preserves original data before MC conversion).
*/
public static GltfData.AnimationClip deepCopyClip(GltfData.AnimationClip clip) {
Quaternionf[][] rawRotations = new Quaternionf[clip.rotations().length][];
for (int j = 0; j < clip.rotations().length; j++) {
if (clip.rotations()[j] != null) {
rawRotations[j] = new Quaternionf[clip.rotations()[j].length];
for (int f = 0; f < clip.rotations()[j].length; f++) {
rawRotations[j][f] = new Quaternionf(clip.rotations()[j][f]);
}
}
}
Vector3f[][] rawTranslations = null;
if (clip.translations() != null) {
rawTranslations = new Vector3f[clip.translations().length][];
for (int j = 0; j < clip.translations().length; j++) {
if (clip.translations()[j] != null) {
rawTranslations[j] = new Vector3f[clip.translations()[j].length];
for (int f = 0; f < clip.translations()[j].length; f++) {
rawTranslations[j][f] = new Vector3f(clip.translations()[j][f]);
}
}
}
}
return new GltfData.AnimationClip(
clip.timestamps().clone(), rawRotations, rawTranslations,
clip.frameCount()
);
}
// ---- Coordinate system conversion ----
/**
* Convert an animation clip's rotations and translations to MC space.
* Negate qx/qy for rotations and negate tx/ty for translations.
*/
public static void convertAnimationToMinecraftSpace(GltfData.AnimationClip clip, int jointCount) {
if (clip == null) return;
Quaternionf[][] rotations = clip.rotations();
for (int j = 0; j < jointCount; j++) {
if (j < rotations.length && rotations[j] != null) {
for (Quaternionf q : rotations[j]) {
q.x = -q.x;
q.y = -q.y;
}
}
}
Vector3f[][] translations = clip.translations();
if (translations != null) {
for (int j = 0; j < jointCount; j++) {
if (j < translations.length && translations[j] != null) {
for (Vector3f t : translations[j]) {
t.x = -t.x;
t.y = -t.y;
}
}
}
}
}
}

View File

@@ -0,0 +1,450 @@
package com.tiedup.remake.client.gltf;
import com.tiedup.remake.client.animation.BondageAnimationManager;
import com.tiedup.remake.client.animation.context.AnimationContext;
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.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import org.jetbrains.annotations.Nullable;
import net.minecraft.client.Minecraft;
import net.minecraft.client.player.AbstractClientPlayer;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.entity.LivingEntity;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
/**
* V2 Animation Applier -- manages dual-layer animation for V2 bondage items.
*
* <p>Orchestrates two PlayerAnimator layers simultaneously:
* <ul>
* <li><b>Context layer</b> (priority 40): base body posture (stand/sit/kneel/sneak/walk)
* with item-owned parts disabled, via {@link ContextAnimationFactory}</li>
* <li><b>Item layer</b> (priority 42): per-item GLB animation with only owned bones enabled,
* via {@link GltfPoseConverter#convertSelective}</li>
* </ul>
*
* <p>Each equipped V2 item controls ONLY the bones matching its occupied body regions.
* Bones not owned by any item pass through from the context layer, which provides the
* appropriate base posture animation.
*
* <p>State tracking avoids redundant animation replays: a composite key of
* {@code animSource|context|ownedParts} is compared per-entity to skip no-op updates.
*
* <p>Item animations are cached by {@code animSource#context#ownedParts} since the same
* GLB + context + owned parts always produces the same KeyframeAnimation.
*
* @see ContextAnimationFactory
* @see GlbAnimationResolver
* @see GltfPoseConverter#convertSelective
* @see BondageAnimationManager
*/
@OnlyIn(Dist.CLIENT)
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.
*/
private static final Map<String, KeyframeAnimation> itemAnimCache = new ConcurrentHashMap<>();
/**
* Track which composite state is currently active per entity, to avoid redundant replays.
* Keyed by entity UUID, value is "animSource|context|sortedParts".
*/
private static final Map<UUID, String> activeStateKeys = new ConcurrentHashMap<>();
/** Track cache keys where GLB loading failed, to avoid per-tick retries. */
private static final Set<String> failedLoadKeys = ConcurrentHashMap.newKeySet();
private GltfAnimationApplier() {}
// ========================================
// INIT (legacy)
// ========================================
/**
* Legacy init method -- called by GltfClientSetup.
* No-op: layer registration is handled by {@link BondageAnimationManager#init()}.
*/
public static void init() {
// No-op: animation layers are managed by BondageAnimationManager
}
// ========================================
// V2 DUAL-LAYER API
// ========================================
/**
* Apply the full V2 animation state: context layer + item layer.
*
* <p>Flow:
* <ol>
* <li>Build a composite state key and skip if unchanged</li>
* <li>Create/retrieve a context animation with disabledOnContext parts disabled,
* play on context layer via {@link BondageAnimationManager#playContext}</li>
* <li>Load the GLB (from {@code animationSource} or {@code modelLoc}),
* resolve the named animation via {@link GlbAnimationResolver#resolve},
* convert with selective parts via {@link GltfPoseConverter#convertSelective},
* play on item layer via {@link BondageAnimationManager#playDirect}</li>
* </ol>
*
* <p>The ownership model enables "free bone" animation: if a bone is not claimed
* by any item, the winning item can animate it IF its GLB has keyframes for that bone.
* This allows a straitjacket (ARMS+TORSO) to also animate free legs.</p>
*
* @param entity the entity to animate
* @param modelLoc the item's GLB model (for mesh rendering, and default animation source)
* @param animationSource separate GLB for animations (shared template), or null to use modelLoc
* @param context current animation context (STAND_IDLE, SIT_IDLE, etc.)
* @param ownership bone ownership: which parts this item owns vs other items
* @return true if the item layer animation was applied successfully
*/
public static boolean applyV2Animation(LivingEntity entity, ResourceLocation modelLoc,
@Nullable ResourceLocation animationSource,
AnimationContext context, RegionBoneMapper.BoneOwnership ownership) {
if (entity == null || modelLoc == null) return false;
ResourceLocation animSource = animationSource != null ? animationSource : modelLoc;
// Cache key includes both owned and enabled parts for full disambiguation
String ownedKey = canonicalPartsKey(ownership.thisParts());
String enabledKey = canonicalPartsKey(ownership.enabledParts());
String partsKey = ownedKey + ";" + enabledKey;
// Build composite state key to avoid redundant updates
String stateKey = animSource + "|" + context.name() + "|" + partsKey;
String currentKey = activeStateKeys.get(entity.getUUID());
if (stateKey.equals(currentKey)) {
return true; // Already active, no-op
}
// === Layer 1: Context animation (base body posture) ===
// Parts owned by ANY item (this or others) are disabled on the context layer.
// Only free parts remain enabled on context.
KeyframeAnimation contextAnim = ContextAnimationFactory.create(
context, ownership.disabledOnContext());
if (contextAnim != null) {
BondageAnimationManager.playContext(entity, contextAnim);
}
// === Layer 2: Item animation (GLB pose with selective bones) ===
String itemCacheKey = buildItemCacheKey(animSource, context, partsKey);
// Skip if this GLB already failed to load
if (failedLoadKeys.contains(itemCacheKey)) {
activeStateKeys.put(entity.getUUID(), stateKey);
return false;
}
KeyframeAnimation itemAnim = itemAnimCache.get(itemCacheKey);
if (itemAnim == null) {
GltfData animData = GlbAnimationResolver.resolveAnimationData(modelLoc, animationSource);
if (animData == null) {
LOGGER.warn("[GltfPipeline] Failed to load animation GLB: {}", animSource);
failedLoadKeys.add(itemCacheKey);
activeStateKeys.put(entity.getUUID(), stateKey);
return false;
}
// Resolve which named animation to use (with fallback chain + variant selection)
String glbAnimName = GlbAnimationResolver.resolve(animData, context);
// Pass both owned parts and enabled parts (owned + free) for selective enabling
itemAnim = GltfPoseConverter.convertSelective(
animData, glbAnimName, ownership.thisParts(), ownership.enabledParts());
itemAnimCache.put(itemCacheKey, itemAnim);
}
BondageAnimationManager.playDirect(entity, itemAnim);
activeStateKeys.put(entity.getUUID(), stateKey);
return true;
}
/**
* Apply V2 animation from ALL equipped items simultaneously.
*
* <p>Each item contributes keyframes for only its owned bones into a shared
* {@link KeyframeAnimation.AnimationBuilder}. The first item in the list (highest priority)
* can additionally animate free bones if its GLB has keyframes for them.</p>
*
* @param entity the entity to animate
* @param items resolved V2 items with per-item ownership, sorted by priority desc
* @param context current animation context
* @param allOwnedParts union of all owned parts across all items
* @return true if the composite animation was applied
*/
public static boolean applyMultiItemV2Animation(LivingEntity entity,
List<RegionBoneMapper.V2ItemAnimInfo> items,
AnimationContext context, Set<String> allOwnedParts) {
if (entity == null || items.isEmpty()) return false;
// Build composite state key
StringBuilder keyBuilder = new StringBuilder();
for (RegionBoneMapper.V2ItemAnimInfo item : items) {
ResourceLocation src = item.animSource() != null ? item.animSource() : item.modelLoc();
keyBuilder.append(src).append(':').append(canonicalPartsKey(item.ownedParts())).append(';');
}
keyBuilder.append(context.name());
String stateKey = keyBuilder.toString();
String currentKey = activeStateKeys.get(entity.getUUID());
if (stateKey.equals(currentKey)) {
return true; // Already active
}
// === Layer 1: Context animation ===
KeyframeAnimation contextAnim = ContextAnimationFactory.create(context, allOwnedParts);
if (contextAnim != null) {
BondageAnimationManager.playContext(entity, contextAnim);
}
// === Layer 2: Composite item animation ===
String compositeCacheKey = "multi#" + stateKey;
if (failedLoadKeys.contains(compositeCacheKey)) {
activeStateKeys.put(entity.getUUID(), stateKey);
return false;
}
KeyframeAnimation 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;
for (int i = 0; i < items.size(); i++) {
RegionBoneMapper.V2ItemAnimInfo item = items.get(i);
ResourceLocation animSource = item.animSource() != null ? item.animSource() : item.modelLoc();
GltfData animData = GlbAnimationResolver.resolveAnimationData(item.modelLoc(), item.animSource());
if (animData == null) {
LOGGER.warn("[GltfPipeline] Failed to load GLB for multi-item: {}", animSource);
continue;
}
String glbAnimName = GlbAnimationResolver.resolve(animData, context);
GltfData.AnimationClip rawClip;
if (glbAnimName != null) {
rawClip = animData.getRawAnimation(glbAnimName);
} else {
rawClip = null;
}
if (rawClip == null) {
rawClip = animData.rawGltfAnimation();
}
// Compute effective parts: intersect animation_bones whitelist with ownedParts
// if the item declares per-animation bone filtering.
Set<String> effectiveParts = item.ownedParts();
if (glbAnimName != null && !item.animationBones().isEmpty()) {
Set<String> override = item.animationBones().get(glbAnimName);
if (override != null) {
Set<String> filtered = new HashSet<>(override);
filtered.retainAll(item.ownedParts());
if (!filtered.isEmpty()) {
effectiveParts = filtered;
}
}
}
GltfPoseConverter.addBonesToBuilder(
builder, animData, rawClip, effectiveParts);
anyLoaded = true;
LOGGER.debug("[GltfPipeline] Multi-item: {} -> owned={}, effective={}, anim={}",
animSource, item.ownedParts(), effectiveParts, glbAnimName);
}
if (!anyLoaded) {
failedLoadKeys.add(compositeCacheKey);
activeStateKeys.put(entity.getUUID(), stateKey);
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);
}
}
}
compositeAnim = builder.build();
itemAnimCache.put(compositeCacheKey, compositeAnim);
}
BondageAnimationManager.playDirect(entity, compositeAnim);
activeStateKeys.put(entity.getUUID(), stateKey);
return true;
}
// ========================================
// CLEAR / QUERY
// ========================================
/**
* Clear all V2 animation layers from an entity and remove tracking.
* Stops both the context layer and the item layer.
*
* @param entity the entity to clear animations from
*/
public static void clearV2Animation(LivingEntity entity) {
if (entity == null) return;
activeStateKeys.remove(entity.getUUID());
BondageAnimationManager.stopContext(entity);
BondageAnimationManager.stopAnimation(entity);
}
/**
* Check if an entity has active V2 animation state.
*
* @param entity the entity to check
* @return true if the entity has an active V2 animation state key
*/
public static boolean hasActiveState(LivingEntity entity) {
return entity != null && activeStateKeys.containsKey(entity.getUUID());
}
/**
* Remove tracking for an entity (e.g., on logout/unload).
* Does NOT stop any currently playing animation -- use {@link #clearV2Animation} for that.
*
* @param entityId UUID of the entity to stop tracking
*/
public static void removeTracking(UUID entityId) {
activeStateKeys.remove(entityId);
}
// ========================================
// CACHE MANAGEMENT
// ========================================
/**
* Invalidate all cached item animations and tracking state.
* Call this on resource reload (F3+T) to pick up changed GLB/JSON files.
*
* <p>Does NOT clear ContextAnimationFactory or ContextGlbRegistry here.
* Those are cleared in the reload listener AFTER ContextGlbRegistry.reload()
* to prevent the render thread from caching stale JSON fallbacks during
* the window between clear and repopulate.</p>
*/
public static void invalidateCache() {
itemAnimCache.clear();
activeStateKeys.clear();
failedLoadKeys.clear();
}
/**
* Clear all state (cache + tracking). Called on world unload.
* Clears everything including context caches (no concurrent reload during unload).
*/
public static void clearAll() {
itemAnimCache.clear();
activeStateKeys.clear();
failedLoadKeys.clear();
com.tiedup.remake.client.animation.context.ContextGlbRegistry.clear();
ContextAnimationFactory.clearCache();
}
// ========================================
// LEGACY F9 DEBUG TOGGLE
// ========================================
private static boolean debugEnabled = false;
/**
* Toggle debug mode via F9 key.
* When enabled, applies handcuffs V2 animation (rightArm + leftArm) to the local player
* using STAND_IDLE context. When disabled, clears all V2 animation.
*/
public static void toggle() {
debugEnabled = !debugEnabled;
LOGGER.info("[GltfPipeline] Debug toggle: {}", debugEnabled ? "ON" : "OFF");
AbstractClientPlayer player = Minecraft.getInstance().player;
if (player == null) return;
if (debugEnabled) {
ResourceLocation modelLoc = ResourceLocation.fromNamespaceAndPath(
"tiedup", "models/gltf/v2/handcuffs/cuffs_prototype.glb"
);
Set<String> armParts = Set.of("rightArm", "leftArm");
RegionBoneMapper.BoneOwnership debugOwnership =
new RegionBoneMapper.BoneOwnership(armParts, Set.of());
applyV2Animation(player, modelLoc, null, AnimationContext.STAND_IDLE, debugOwnership);
} else {
clearV2Animation(player);
}
}
/**
* Whether F9 debug mode is currently enabled.
*/
public static boolean isEnabled() {
return debugEnabled;
}
// ========================================
// INTERNAL
// ========================================
/**
* Build cache key for item-layer animations.
* Format: "animSource#contextName#sortedParts"
*/
private static String buildItemCacheKey(ResourceLocation animSource,
AnimationContext context, String partsKey) {
return animSource + "#" + context.name() + "#" + partsKey;
}
/**
* Build a canonical, deterministic string from the owned parts set.
* Sorted alphabetically and joined by comma — guarantees no hash collisions.
*/
private static String canonicalPartsKey(Set<String> ownedParts) {
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;
};
}
}

View File

@@ -0,0 +1,104 @@
package com.tiedup.remake.client.gltf;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import net.minecraft.client.model.HumanoidModel;
import net.minecraft.client.model.geom.ModelPart;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
/**
* Maps glTF bone names to Minecraft HumanoidModel parts.
* Handles upper bones (full rotation) and lower bones (bend only).
*/
@OnlyIn(Dist.CLIENT)
public final class GltfBoneMapper {
/** Maps glTF bone name -> MC model part field name */
private static final Map<String, String> BONE_TO_PART = new HashMap<>();
/** Lower bones that represent bend (elbow/knee) */
private static final Set<String> LOWER_BONES = Set.of(
"leftLowerArm", "rightLowerArm",
"leftLowerLeg", "rightLowerLeg"
);
/** Maps lower bone name -> corresponding upper bone name */
private static final Map<String, String> LOWER_TO_UPPER = Map.of(
"leftLowerArm", "leftUpperArm",
"rightLowerArm", "rightUpperArm",
"leftLowerLeg", "leftUpperLeg",
"rightLowerLeg", "rightUpperLeg"
);
static {
BONE_TO_PART.put("body", "body");
BONE_TO_PART.put("torso", "body");
BONE_TO_PART.put("head", "head");
BONE_TO_PART.put("leftUpperArm", "leftArm");
BONE_TO_PART.put("leftLowerArm", "leftArm");
BONE_TO_PART.put("rightUpperArm", "rightArm");
BONE_TO_PART.put("rightLowerArm", "rightArm");
BONE_TO_PART.put("leftUpperLeg", "leftLeg");
BONE_TO_PART.put("leftLowerLeg", "leftLeg");
BONE_TO_PART.put("rightUpperLeg", "rightLeg");
BONE_TO_PART.put("rightLowerLeg", "rightLeg");
}
private GltfBoneMapper() {}
/**
* Get the ModelPart corresponding to a glTF bone name.
*
* @param model the HumanoidModel
* @param boneName glTF bone name
* @return the ModelPart, or null if not mapped
*/
public static ModelPart getModelPart(HumanoidModel<?> model, String boneName) {
String partName = BONE_TO_PART.get(boneName);
if (partName == null) return null;
return switch (partName) {
case "body" -> model.body;
case "head" -> model.head;
case "leftArm" -> model.leftArm;
case "rightArm" -> model.rightArm;
case "leftLeg" -> model.leftLeg;
case "rightLeg" -> model.rightLeg;
default -> null;
};
}
/**
* Check if this bone represents a lower segment (bend: elbow/knee).
*/
public static boolean isLowerBone(String boneName) {
return LOWER_BONES.contains(boneName);
}
/**
* Get the upper bone name for a given lower bone.
* Returns null if not a lower bone.
*/
public static String getUpperBoneFor(String lowerBoneName) {
return LOWER_TO_UPPER.get(lowerBoneName);
}
/**
* Get the PlayerAnimator part name for a glTF bone.
* Both glTF and PlayerAnimator use "body" for the torso part.
*/
public static String getAnimPartName(String boneName) {
String partName = BONE_TO_PART.get(boneName);
if (partName == null) return null;
return partName;
}
/**
* Check if a bone name is known/mapped.
*/
public static boolean isKnownBone(String boneName) {
return BONE_TO_PART.containsKey(boneName);
}
}

View File

@@ -0,0 +1,67 @@
package com.tiedup.remake.client.gltf;
import java.io.InputStream;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import net.minecraft.client.Minecraft;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.packs.resources.Resource;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
/**
* Lazy-loading cache for parsed glTF data.
* Loads .glb files via Minecraft's ResourceManager on first access.
*/
@OnlyIn(Dist.CLIENT)
public final class GltfCache {
private static final Logger LOGGER = LogManager.getLogger("GltfPipeline");
private static final Map<ResourceLocation, GltfData> CACHE = new ConcurrentHashMap<>();
private GltfCache() {}
/**
* Get parsed glTF data for a resource, loading it on first access.
*
* @param location resource location of the .glb file (e.g. "tiedup:models/gltf/v2/handcuffs/cuffs_prototype.glb")
* @return parsed GltfData, or null if loading failed
*/
public static GltfData get(ResourceLocation location) {
GltfData cached = CACHE.get(location);
if (cached != null) return cached;
try {
Resource resource = Minecraft.getInstance()
.getResourceManager()
.getResource(location)
.orElse(null);
if (resource == null) {
LOGGER.error("[GltfPipeline] Resource not found: {}", location);
return null;
}
try (InputStream is = resource.open()) {
GltfData data = GlbParser.parse(is, location.toString());
CACHE.put(location, data);
return data;
}
} catch (Exception e) {
LOGGER.error("[GltfPipeline] Failed to load GLB: {}", location, e);
return null;
}
}
/** Clear all cached data (call on resource reload). */
public static void clearCache() {
CACHE.clear();
LOGGER.info("[GltfPipeline] Cache cleared");
}
/** Initialize the cache (called during FMLClientSetupEvent). */
public static void init() {
LOGGER.info("[GltfPipeline] GltfCache initialized");
}
}

View File

@@ -0,0 +1,140 @@
package com.tiedup.remake.client.gltf;
import com.mojang.blaze3d.platform.InputConstants;
import com.tiedup.remake.client.animation.context.ContextAnimationFactory;
import com.tiedup.remake.client.animation.context.ContextGlbRegistry;
import com.tiedup.remake.v2.bondage.client.V2BondageRenderLayer;
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemReloadListener;
import net.minecraft.client.KeyMapping;
import net.minecraft.client.renderer.entity.player.PlayerRenderer;
import net.minecraft.server.packs.resources.ResourceManager;
import net.minecraft.util.profiling.ProfilerFiller;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.client.event.EntityRenderersEvent;
import net.minecraftforge.client.event.RegisterClientReloadListenersEvent;
import net.minecraftforge.client.event.RegisterKeyMappingsEvent;
import net.minecraftforge.event.TickEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
import net.minecraftforge.fml.event.lifecycle.FMLClientSetupEvent;
import net.minecraft.server.packs.resources.SimplePreparableReloadListener;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
/**
* Forge event registration for the glTF pipeline.
* Registers keybind (F9), render layers, and animation factory.
*/
public final class GltfClientSetup {
private static final Logger LOGGER = LogManager.getLogger("GltfPipeline");
private static final String KEY_CATEGORY = "key.categories.tiedup";
static final KeyMapping TOGGLE_KEY = new KeyMapping(
"key.tiedup.gltf_toggle",
InputConstants.Type.KEYSYM,
InputConstants.KEY_F9,
KEY_CATEGORY
);
private GltfClientSetup() {}
/**
* MOD bus event subscribers (FMLClientSetupEvent, RegisterKeyMappings, AddLayers).
*/
@Mod.EventBusSubscriber(
modid = "tiedup",
bus = Mod.EventBusSubscriber.Bus.MOD,
value = Dist.CLIENT
)
public static class ModBusEvents {
@SubscribeEvent
public static void onClientSetup(FMLClientSetupEvent event) {
event.enqueueWork(() -> {
GltfCache.init();
GltfAnimationApplier.init();
LOGGER.info("[GltfPipeline] Client setup complete");
});
}
@SubscribeEvent
public static void onRegisterKeybindings(RegisterKeyMappingsEvent event) {
event.register(TOGGLE_KEY);
LOGGER.info("[GltfPipeline] Keybind registered: F9");
}
@SuppressWarnings("unchecked")
@SubscribeEvent
public static void onAddLayers(EntityRenderersEvent.AddLayers event) {
// Add GltfRenderLayer (prototype/debug with F9 toggle) to player renderers
var defaultRenderer = event.getSkin("default");
if (defaultRenderer instanceof PlayerRenderer playerRenderer) {
playerRenderer.addLayer(new GltfRenderLayer(playerRenderer));
playerRenderer.addLayer(new V2BondageRenderLayer<>(playerRenderer));
LOGGER.info("[GltfPipeline] Render layers added to 'default' player renderer");
}
// Add both layers to slim player renderer (Alex)
var slimRenderer = event.getSkin("slim");
if (slimRenderer instanceof PlayerRenderer playerRenderer) {
playerRenderer.addLayer(new GltfRenderLayer(playerRenderer));
playerRenderer.addLayer(new V2BondageRenderLayer<>(playerRenderer));
LOGGER.info("[GltfPipeline] Render layers added to 'slim' player renderer");
}
}
/**
* Register resource reload listener to clear GLB caches on resource pack reload.
* This ensures re-exported GLB models are picked up without restarting the game.
*/
@SubscribeEvent
public static void onRegisterReloadListeners(RegisterClientReloadListenersEvent event) {
event.registerReloadListener(new SimplePreparableReloadListener<Void>() {
@Override
protected Void prepare(ResourceManager resourceManager, ProfilerFiller profiler) {
return null;
}
@Override
protected void apply(Void nothing, ResourceManager resourceManager, ProfilerFiller profiler) {
GltfCache.clearCache();
GltfAnimationApplier.invalidateCache();
GltfMeshRenderer.clearRenderTypeCache();
// Reload context GLB animations from resource packs FIRST,
// then clear the factory cache so it rebuilds against the
// new GLB registry (prevents stale JSON fallback caching).
ContextGlbRegistry.reload(resourceManager);
ContextAnimationFactory.clearCache();
com.tiedup.remake.v2.furniture.client.FurnitureGltfCache.clear();
LOGGER.info("[GltfPipeline] Caches cleared on resource reload");
}
});
LOGGER.info("[GltfPipeline] Resource reload listener registered");
// Data-driven bondage item definitions (tiedup_items/*.json)
event.registerReloadListener(new DataDrivenItemReloadListener());
LOGGER.info("[GltfPipeline] Data-driven item reload listener registered");
}
}
/**
* FORGE bus event subscribers (ClientTickEvent for keybind toggle).
*/
@Mod.EventBusSubscriber(
modid = "tiedup",
bus = Mod.EventBusSubscriber.Bus.FORGE,
value = Dist.CLIENT
)
public static class ForgeBusEvents {
@SubscribeEvent
public static void onClientTick(TickEvent.ClientTickEvent event) {
if (event.phase != TickEvent.Phase.END) return;
while (TOGGLE_KEY.consumeClick()) {
GltfAnimationApplier.toggle();
}
}
}
}

View File

@@ -0,0 +1,194 @@
package com.tiedup.remake.client.gltf;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.jetbrains.annotations.Nullable;
import org.joml.Matrix4f;
import org.joml.Quaternionf;
import org.joml.Vector3f;
/**
* Immutable container for parsed glTF/GLB data.
* Holds mesh geometry, skinning data, bone hierarchy, and optional animations.
* <p>
* Supports multiple named animations per GLB file. The "default" animation
* (first clip) is accessible via {@link #animation()} and {@link #rawGltfAnimation()}
* for backward compatibility. All animations are available via
* {@link #namedAnimations()}.
*/
public final class GltfData {
// -- Mesh geometry (flattened arrays) --
private final float[] positions; // VEC3, length = vertexCount * 3
private final float[] normals; // VEC3, length = vertexCount * 3
private final float[] texCoords; // VEC2, length = vertexCount * 2
private final int[] indices; // triangle indices
// -- Skinning data (per-vertex, 4 influences) --
private final int[] joints; // 4 joint indices per vertex, length = vertexCount * 4
private final float[] weights; // 4 weights per vertex, length = vertexCount * 4
// -- Bone hierarchy (MC-converted for skinning) --
private final String[] jointNames;
private final int[] parentJointIndices; // -1 for root
private final Matrix4f[] inverseBindMatrices;
private final Quaternionf[] restRotations;
private final Vector3f[] restTranslations;
// -- Raw glTF rotations (unconverted, for pose conversion) --
private final Quaternionf[] rawGltfRestRotations;
@Nullable
private final AnimationClip rawGltfAnimation;
// -- Optional animation clip (MC-converted for skinning) --
@Nullable
private final AnimationClip animation;
// -- Multiple named animations --
private final Map<String, AnimationClip> namedAnimations; // MC-converted
private final Map<String, AnimationClip> rawNamedAnimations; // raw glTF space
// -- Per-primitive material/tint info --
private final List<Primitive> primitives;
// -- Counts --
private final int vertexCount;
private final int jointCount;
/**
* Full constructor with multiple named animations and per-primitive data.
*/
public GltfData(
float[] positions, float[] normals, float[] texCoords,
int[] indices, int[] joints, float[] weights,
String[] jointNames, int[] parentJointIndices,
Matrix4f[] inverseBindMatrices,
Quaternionf[] restRotations, Vector3f[] restTranslations,
Quaternionf[] rawGltfRestRotations,
@Nullable AnimationClip rawGltfAnimation,
@Nullable AnimationClip animation,
Map<String, AnimationClip> namedAnimations,
Map<String, AnimationClip> rawNamedAnimations,
List<Primitive> primitives,
int vertexCount, int jointCount
) {
this.positions = positions;
this.normals = normals;
this.texCoords = texCoords;
this.indices = indices;
this.joints = joints;
this.weights = weights;
this.jointNames = jointNames;
this.parentJointIndices = parentJointIndices;
this.inverseBindMatrices = inverseBindMatrices;
this.restRotations = restRotations;
this.restTranslations = restTranslations;
this.rawGltfRestRotations = rawGltfRestRotations;
this.rawGltfAnimation = rawGltfAnimation;
this.animation = animation;
this.namedAnimations = Collections.unmodifiableMap(new LinkedHashMap<>(namedAnimations));
this.rawNamedAnimations = Collections.unmodifiableMap(new LinkedHashMap<>(rawNamedAnimations));
this.primitives = List.copyOf(primitives);
this.vertexCount = vertexCount;
this.jointCount = jointCount;
}
/**
* Legacy constructor for backward compatibility (single animation only).
*/
public GltfData(
float[] positions, float[] normals, float[] texCoords,
int[] indices, int[] joints, float[] weights,
String[] jointNames, int[] parentJointIndices,
Matrix4f[] inverseBindMatrices,
Quaternionf[] restRotations, Vector3f[] restTranslations,
Quaternionf[] rawGltfRestRotations,
@Nullable AnimationClip rawGltfAnimation,
@Nullable AnimationClip animation,
int vertexCount, int jointCount
) {
this(positions, normals, texCoords, indices, joints, weights,
jointNames, parentJointIndices, inverseBindMatrices,
restRotations, restTranslations, rawGltfRestRotations,
rawGltfAnimation, animation,
new LinkedHashMap<>(), new LinkedHashMap<>(),
List.of(new Primitive(indices, null, false, null)),
vertexCount, jointCount);
}
public float[] positions() { return positions; }
public float[] normals() { return normals; }
public float[] texCoords() { return texCoords; }
public int[] indices() { return indices; }
public int[] joints() { return joints; }
public float[] weights() { return weights; }
public String[] jointNames() { return jointNames; }
public int[] parentJointIndices() { return parentJointIndices; }
public Matrix4f[] inverseBindMatrices() { return inverseBindMatrices; }
public Quaternionf[] restRotations() { return restRotations; }
public Vector3f[] restTranslations() { return restTranslations; }
public Quaternionf[] rawGltfRestRotations() { return rawGltfRestRotations; }
@Nullable
public AnimationClip rawGltfAnimation() { return rawGltfAnimation; }
@Nullable
public AnimationClip animation() { return animation; }
public int vertexCount() { return vertexCount; }
public int jointCount() { return jointCount; }
/** Per-primitive material and tint metadata. One entry per glTF primitive in the mesh. */
public List<Primitive> primitives() { return primitives; }
/** All named animations in MC-converted space. Keys are animation names (e.g. "BasicPose", "Struggle"). */
public Map<String, AnimationClip> namedAnimations() { return namedAnimations; }
/** Get a specific named animation in MC-converted space, or null if not found. */
@Nullable
public AnimationClip getAnimation(String name) { return namedAnimations.get(name); }
/** Get a specific named animation in raw glTF space, or null if not found. */
@Nullable
public AnimationClip getRawAnimation(String name) { return rawNamedAnimations.get(name); }
/**
* Animation clip: per-bone timestamps, quaternion rotations, and optional translations.
*/
public static final class AnimationClip {
private final float[] timestamps; // shared timestamps
private final Quaternionf[][] rotations; // [jointIndex][frameIndex], null if no anim
@Nullable
private final Vector3f[][] translations; // [jointIndex][frameIndex], null if no anim
private final int frameCount;
public AnimationClip(float[] timestamps, Quaternionf[][] rotations,
@Nullable Vector3f[][] translations, int frameCount) {
this.timestamps = timestamps;
this.rotations = rotations;
this.translations = translations;
this.frameCount = frameCount;
}
public float[] timestamps() { return timestamps; }
public Quaternionf[][] rotations() { return rotations; }
@Nullable
public Vector3f[][] translations() { return translations; }
public int frameCount() { return frameCount; }
}
/**
* Per-primitive metadata parsed from the glTF mesh.
* Each primitive corresponds to a material assignment in Blender.
*
* @param indices triangle indices for this primitive (already offset to the unified vertex buffer)
* @param materialName the glTF material name, or null if unassigned
* @param tintable true if the material name starts with "tintable_"
* @param tintChannel the tint channel key (e.g. "tintable_0"), or null if not tintable
*/
public record Primitive(
int[] indices,
@Nullable String materialName,
boolean tintable,
@Nullable String tintChannel
) {}
}

View File

@@ -0,0 +1,245 @@
package com.tiedup.remake.client.gltf;
import dev.kosmx.playerAnim.core.util.Pair;
import dev.kosmx.playerAnim.impl.IAnimatedPlayer;
import dev.kosmx.playerAnim.impl.animation.AnimationApplier;
import net.minecraft.client.model.HumanoidModel;
import net.minecraft.client.model.geom.ModelPart;
import net.minecraft.world.entity.LivingEntity;
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.joml.Matrix4f;
import org.joml.Quaternionf;
import org.joml.Vector3f;
/**
* Reads the LIVE skeleton state from HumanoidModel (after PlayerAnimator + bendy-lib
* have applied all rotations for the current frame) and produces joint matrices
* compatible with {@link GltfSkinningEngine#skinVertex}.
* <p>
* KEY INSIGHT: The ModelPart xRot/yRot/zRot values set by PlayerAnimator represent
* DELTA rotations (difference from rest pose) expressed in the MC model-def frame.
* GltfPoseConverter computed them as parent-frame deltas, decomposed to Euler ZYX.
* <p>
* To reconstruct the correct LOCAL rotation for the glTF hierarchy:
* <pre>
* delta = rotationZYX(zRot, yRot, xRot) // MC-frame delta from ModelPart
* localRot = delta * restQ_mc // delta applied on top of local rest
* </pre>
* No de-parenting is needed because both delta and restQ_mc are already in the
* parent's local frame. The MC-to-glTF conjugation (negate qx,qy) is a homomorphism,
* so frame relationships are preserved through the conversion.
* <p>
* For bones WITHOUT a MC ModelPart (root, torso), use the MC-converted rest rotation
* directly from GltfData.
*/
@OnlyIn(Dist.CLIENT)
public final class GltfLiveBoneReader {
private static final Logger LOGGER = LogManager.getLogger("GltfPipeline");
private GltfLiveBoneReader() {}
/**
* Compute joint matrices by reading live skeleton state from the HumanoidModel.
* <p>
* For upper bones: reconstructs the MC-frame delta from ModelPart euler angles,
* then composes with the MC-converted rest rotation to get the local rotation.
* For lower bones: reads bend values from the entity's AnimationApplier and
* composes the bend delta with the local rest rotation.
* For non-animated bones: uses rest rotation from GltfData directly.
* <p>
* The resulting joint matrices should match {@link GltfSkinningEngine#computeJointMatrices}
* when the player is in the rest pose (no animation active).
*
* @param model the HumanoidModel after PlayerAnimator has applied rotations
* @param data parsed glTF data (MC-converted)
* @param entity the living entity being rendered
* @return array of joint matrices ready for skinning, or null on failure
*/
public static Matrix4f[] computeJointMatricesFromModel(
HumanoidModel<?> model, GltfData data, LivingEntity entity
) {
if (model == null || data == null || entity == null) return null;
int jointCount = data.jointCount();
Matrix4f[] jointMatrices = new Matrix4f[jointCount];
Matrix4f[] worldTransforms = new Matrix4f[jointCount];
int[] parents = data.parentJointIndices();
String[] jointNames = data.jointNames();
Quaternionf[] restRotations = data.restRotations();
Vector3f[] restTranslations = data.restTranslations();
// Get the AnimationApplier for bend values (may be null)
AnimationApplier emote = getAnimationApplier(entity);
for (int j = 0; j < jointCount; j++) {
String boneName = jointNames[j];
Quaternionf localRot;
if (GltfBoneMapper.isLowerBone(boneName)) {
// --- Lower bone: reconstruct from bend values ---
localRot = computeLowerBoneLocalRotation(
boneName, j, restRotations, emote
);
} else if (hasUniqueModelPart(boneName)) {
// --- Upper bone with a unique ModelPart ---
ModelPart part = GltfBoneMapper.getModelPart(model, boneName);
if (part != null) {
localRot = computeUpperBoneLocalRotation(
part, j, restRotations
);
} else {
// Fallback: use rest rotation
localRot = new Quaternionf(restRotations[j]);
}
} else {
// --- Non-animated bone (root, torso, etc.): use rest rotation ---
localRot = new Quaternionf(restRotations[j]);
}
// Build local transform: translate(restTranslation) * rotate(localRot)
Matrix4f local = new Matrix4f();
local.translate(restTranslations[j]);
local.rotate(localRot);
// Compose with parent to get world transform
if (parents[j] >= 0 && worldTransforms[parents[j]] != null) {
worldTransforms[j] = new Matrix4f(worldTransforms[parents[j]]).mul(local);
} else {
worldTransforms[j] = new Matrix4f(local);
}
// Final joint matrix = worldTransform * inverseBindMatrix
jointMatrices[j] = new Matrix4f(worldTransforms[j])
.mul(data.inverseBindMatrices()[j]);
}
return jointMatrices;
}
/**
* Compute local rotation for an upper bone that has a unique ModelPart.
* <p>
* ModelPart xRot/yRot/zRot are DELTA rotations (set by PlayerAnimator) expressed
* as ZYX Euler angles in the MC model-def frame. These deltas were originally
* computed by GltfPoseConverter as parent-frame quantities.
* <p>
* The local rotation for the glTF hierarchy is simply:
* <pre>
* delta = rotationZYX(zRot, yRot, xRot)
* localRot = delta * restQ_mc
* </pre>
* No de-parenting is needed: both delta and restQ_mc are already in the parent's
* frame. The MC-to-glTF negate-xy conjugation is a group homomorphism, preserving
* the frame relationship.
*/
private static Quaternionf computeUpperBoneLocalRotation(
ModelPart part, int jointIndex,
Quaternionf[] restRotations
) {
// Reconstruct the MC-frame delta from ModelPart euler angles.
Quaternionf delta = new Quaternionf().rotationZYX(part.zRot, part.yRot, part.xRot);
// Local rotation = delta applied on top of the local rest rotation.
return new Quaternionf(delta).mul(restRotations[jointIndex]);
}
/**
* Compute local rotation for a lower bone (elbow/knee) from bend values.
* <p>
* Bend values are read from the entity's AnimationApplier. The bend delta is
* reconstructed as a quaternion rotation around the bend axis, then composed
* with the local rest rotation:
* <pre>
* bendQuat = axisAngle(cos(bendAxis)*s, 0, sin(bendAxis)*s, cos(halfAngle))
* localRot = bendQuat * restQ_mc
* </pre>
* No de-parenting needed — same reasoning as upper bones.
*/
private static Quaternionf computeLowerBoneLocalRotation(
String boneName, int jointIndex,
Quaternionf[] restRotations,
AnimationApplier emote
) {
if (emote != null) {
// Get the MC part name for the upper bone of this lower bone
String upperBone = GltfBoneMapper.getUpperBoneFor(boneName);
String animPartName = (upperBone != null)
? GltfBoneMapper.getAnimPartName(upperBone)
: null;
if (animPartName != null) {
Pair<Float, Float> bend = emote.getBend(animPartName);
if (bend != null) {
float bendAxis = bend.getLeft();
float bendValue = bend.getRight();
// Reconstruct bend as quaternion (this is the delta)
float ax = (float) Math.cos(bendAxis);
float az = (float) Math.sin(bendAxis);
float halfAngle = bendValue * 0.5f;
float s = (float) Math.sin(halfAngle);
Quaternionf bendQuat = new Quaternionf(
ax * s, 0, az * s, (float) Math.cos(halfAngle)
);
// Local rotation = bend delta applied on top of local rest rotation
return new Quaternionf(bendQuat).mul(restRotations[jointIndex]);
}
}
}
// No bend data or no AnimationApplier — use rest rotation (identity delta)
return new Quaternionf(restRotations[jointIndex]);
}
/**
* Check if a bone name corresponds to a bone that has its OWN unique ModelPart
* (not just a mapping — it must be the PRIMARY bone for that ModelPart).
* <p>
* "torso" maps to model.body but "body" is the primary bone for it.
* Lower bones share a ModelPart with their upper bone.
* Unknown bones (e.g., "PlayerArmature") have no ModelPart at all.
*/
private static boolean hasUniqueModelPart(String boneName) {
// Bones that should read their rotation from the live HumanoidModel.
//
// NOTE: "body" is deliberately EXCLUDED. MC's HumanoidModel is FLAT —
// body, arms, legs, head are all siblings with ABSOLUTE rotations.
// But the GLB skeleton is HIERARCHICAL (body → torso → arms).
// If we read body's live rotation (e.g., attack swing yRot), it propagates
// to arms/head through the hierarchy, but MC's flat model does NOT do this.
// Result: cuffs mesh rotates with body during attack while arms stay put.
//
// Body rotation effects that matter (sneak lean, sitting) are handled by
// LivingEntityRenderer's PoseStack transform, which applies to the entire
// mesh uniformly. No need to read body rotation into joint matrices.
return switch (boneName) {
case "head" -> true;
case "leftUpperArm" -> true;
case "rightUpperArm"-> true;
case "leftUpperLeg" -> true;
case "rightUpperLeg"-> true;
default -> false; // body, torso, lower bones, unknown
};
}
/**
* Get the AnimationApplier from an entity, if available.
* Works for both players (via mixin) and NPCs implementing IAnimatedPlayer.
*/
private static AnimationApplier getAnimationApplier(LivingEntity entity) {
if (entity instanceof IAnimatedPlayer animated) {
try {
return animated.playerAnimator_getAnimation();
} catch (Exception e) {
LOGGER.debug("[GltfPipeline] Could not get AnimationApplier for {}: {}",
entity.getClass().getSimpleName(), e.getMessage());
}
}
return null;
}
}

View File

@@ -0,0 +1,255 @@
package com.tiedup.remake.client.gltf;
import com.mojang.blaze3d.vertex.DefaultVertexFormat;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.blaze3d.vertex.VertexConsumer;
import com.mojang.blaze3d.vertex.VertexFormat;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import net.minecraft.client.renderer.MultiBufferSource;
import net.minecraft.client.renderer.RenderStateShard;
import net.minecraft.client.renderer.RenderType;
import net.minecraft.resources.ResourceLocation;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import org.joml.Matrix3f;
import org.joml.Matrix4f;
import org.joml.Vector4f;
/**
* Submits CPU-skinned glTF mesh vertices to Minecraft's rendering pipeline.
* Uses TRIANGLES mode RenderType (same pattern as ObjModelRenderer).
*/
@OnlyIn(Dist.CLIENT)
public final class GltfMeshRenderer extends RenderStateShard {
private static final ResourceLocation WHITE_TEXTURE =
ResourceLocation.fromNamespaceAndPath("tiedup", "models/obj/shared/white.png");
/** Cached default RenderType (white texture). Created once, reused every frame. */
private static RenderType cachedDefaultRenderType;
/** Cache for texture-specific RenderTypes, keyed by ResourceLocation. */
private static final Map<ResourceLocation, RenderType> RENDER_TYPE_CACHE = new ConcurrentHashMap<>();
private GltfMeshRenderer() {
super("tiedup_gltf_renderer", () -> {}, () -> {});
}
/**
* Get the default TRIANGLES-mode RenderType (white texture), creating it once if needed.
*/
private static RenderType getDefaultRenderType() {
if (cachedDefaultRenderType == null) {
cachedDefaultRenderType = createTriangleRenderType(WHITE_TEXTURE);
}
return cachedDefaultRenderType;
}
/**
* Public accessor for the default RenderType (white texture).
* Used by external renderers that need the same RenderType for tinted rendering.
*/
public static RenderType getRenderTypeForDefaultTexture() {
return getDefaultRenderType();
}
/**
* Get a RenderType for a specific texture, caching it for reuse.
*
* @param texture the texture ResourceLocation
* @return the cached or newly created RenderType
*/
private static RenderType getRenderTypeForTexture(ResourceLocation texture) {
return RENDER_TYPE_CACHE.computeIfAbsent(texture,
GltfMeshRenderer::createTriangleRenderType);
}
/**
* Create a TRIANGLES-mode RenderType for glTF mesh rendering with the given texture.
*/
private static RenderType createTriangleRenderType(ResourceLocation texture) {
RenderType.CompositeState state = RenderType.CompositeState.builder()
.setShaderState(RENDERTYPE_ENTITY_CUTOUT_NO_CULL_SHADER)
.setTextureState(
new RenderStateShard.TextureStateShard(texture, false, false)
)
.setTransparencyState(NO_TRANSPARENCY)
.setCullState(NO_CULL)
.setLightmapState(LIGHTMAP)
.setOverlayState(OVERLAY)
.createCompositeState(true);
return RenderType.create(
"tiedup_gltf_triangles",
DefaultVertexFormat.NEW_ENTITY,
VertexFormat.Mode.TRIANGLES,
256 * 1024,
true,
false,
state
);
}
/**
* Clear cached RenderTypes. Call on resource reload so that re-exported
* textures are picked up without restarting the game.
*/
public static void clearRenderTypeCache() {
cachedDefaultRenderType = null;
RENDER_TYPE_CACHE.clear();
}
/**
* Render a skinned glTF mesh using the default white texture.
*
* @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
*/
public static void renderSkinned(
GltfData data, Matrix4f[] jointMatrices,
PoseStack poseStack, MultiBufferSource buffer,
int packedLight, int packedOverlay
) {
renderSkinnedInternal(data, jointMatrices, poseStack, buffer,
packedLight, packedOverlay, getDefaultRenderType());
}
/**
* Render a skinned glTF mesh using a custom texture.
*
* @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 texture the texture to use for rendering
*/
public static void renderSkinned(
GltfData data, Matrix4f[] jointMatrices,
PoseStack poseStack, MultiBufferSource buffer,
int packedLight, int packedOverlay,
ResourceLocation texture
) {
renderSkinnedInternal(data, jointMatrices, poseStack, buffer,
packedLight, packedOverlay, getRenderTypeForTexture(texture));
}
/**
* Internal rendering implementation shared by both overloads.
*/
private static void renderSkinnedInternal(
GltfData data, Matrix4f[] jointMatrices,
PoseStack poseStack, MultiBufferSource buffer,
int packedLight, int packedOverlay,
RenderType renderType
) {
Matrix4f pose = poseStack.last().pose();
Matrix3f normalMat = poseStack.last().normal();
VertexConsumer vc = buffer.getBuffer(renderType);
int[] indices = data.indices();
float[] texCoords = data.texCoords();
float[] outPos = new float[3];
float[] outNormal = new float[3];
// Pre-allocate scratch vectors outside the loop to avoid per-vertex allocations
Vector4f tmpPos = new Vector4f();
Vector4f tmpNorm = new Vector4f();
for (int idx : indices) {
// Skin this vertex
GltfSkinningEngine.skinVertex(data, idx, jointMatrices, outPos, outNormal, tmpPos, tmpNorm);
// UV coordinates
float u = texCoords[idx * 2];
float v = texCoords[idx * 2 + 1];
vc.vertex(pose, outPos[0], outPos[1], outPos[2])
.color(255, 255, 255, 255)
.uv(u, 1.0f - v)
.overlayCoords(packedOverlay)
.uv2(packedLight)
.normal(normalMat, outNormal[0], outNormal[1], outNormal[2])
.endVertex();
}
}
/**
* Render a skinned glTF mesh with per-primitive tint colors.
*
* <p>Each primitive in the mesh is checked against the tintColors map.
* If a primitive is tintable and its channel is present in the map,
* the corresponding RGB color is applied as vertex color (multiplied
* against the texture by the {@code rendertype_entity_cutout_no_cull} shader).
* Non-tintable primitives render with white (no tint).</p>
*
* <p>This is a single VertexConsumer stream — all primitives share the
* same RenderType and draw call, only the vertex color differs per range.</p>
*
* @param data parsed glTF data (must have primitives)
* @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
* @param tintColors channel name to RGB int (0xRRGGBB); empty map = white everywhere
*/
public static void renderSkinnedTinted(
GltfData data, Matrix4f[] jointMatrices,
PoseStack poseStack, MultiBufferSource buffer,
int packedLight, int packedOverlay,
RenderType renderType,
Map<String, Integer> tintColors
) {
Matrix4f pose = poseStack.last().pose();
Matrix3f normalMat = poseStack.last().normal();
VertexConsumer vc = buffer.getBuffer(renderType);
float[] texCoords = data.texCoords();
float[] outPos = new float[3];
float[] outNormal = new float[3];
Vector4f tmpPos = new Vector4f();
Vector4f tmpNorm = new Vector4f();
List<GltfData.Primitive> primitives = data.primitives();
for (GltfData.Primitive prim : primitives) {
// Determine color for this primitive
int r = 255, g = 255, b = 255;
if (prim.tintable() && prim.tintChannel() != null) {
Integer colorInt = tintColors.get(prim.tintChannel());
if (colorInt != null) {
r = (colorInt >> 16) & 0xFF;
g = (colorInt >> 8) & 0xFF;
b = colorInt & 0xFF;
}
}
for (int idx : prim.indices()) {
GltfSkinningEngine.skinVertex(data, idx, jointMatrices, outPos, outNormal, tmpPos, tmpNorm);
float u = texCoords[idx * 2];
float v = texCoords[idx * 2 + 1];
vc.vertex(pose, outPos[0], outPos[1], outPos[2])
.color(r, g, b, 255)
.uv(u, 1.0f - v)
.overlayCoords(packedOverlay)
.uv2(packedLight)
.normal(normalMat, outNormal[0], outNormal[1], outNormal[2])
.endVertex();
}
}
}
}

View File

@@ -0,0 +1,485 @@
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 org.jetbrains.annotations.Nullable;
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.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 GltfPoseConverter() {}
/**
* 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, GltfData.AnimationClip rawClip,
String animName, Set<String> ownedParts, Set<String> enabledParts) {
KeyframeAnimation.AnimationBuilder builder =
new KeyframeAnimation.AnimationBuilder(AnimationFormat.JSON_EMOTECRAFT);
builder.beginTick = 0;
builder.endTick = 1;
builder.stopTick = 1;
builder.isLooped = true;
builder.returnTick = 0;
builder.name = animName;
String[] jointNames = data.jointNames();
Quaternionf[] rawRestRotations = data.rawGltfRestRotations();
// Track which PlayerAnimator part names received actual animation data
Set<String> partsWithKeyframes = new HashSet<>();
for (int j = 0; j < data.jointCount(); j++) {
String boneName = jointNames[j];
if (!GltfBoneMapper.isKnownBone(boneName)) continue;
// Check if this joint has explicit animation data (not just rest pose fallback).
// A bone counts as explicitly animated if it has rotation OR translation keyframes.
boolean hasExplicitAnim = rawClip != null && (
(j < rawClip.rotations().length && rawClip.rotations()[j] != null)
|| (rawClip.translations() != null
&& j < rawClip.translations().length
&& rawClip.translations()[j] != null)
);
Quaternionf animQ = getRawAnimQuaternion(rawClip, rawRestRotations, j);
Quaternionf restQ = rawRestRotations[j];
// delta_local = inverse(rest_q) * anim_q (in bone-local frame)
Quaternionf deltaLocal = new Quaternionf(restQ).invert().mul(animQ);
// Convert to PARENT frame: delta_parent = rest * delta_local * inv(rest)
Quaternionf deltaParent = new Quaternionf(restQ).mul(deltaLocal)
.mul(new Quaternionf(restQ).invert());
// Convert from glTF parent frame to MC model-def frame.
// 180deg rotation around Z (X and Y differ): negate qx and qy.
Quaternionf deltaQ = new Quaternionf(deltaParent);
deltaQ.x = -deltaQ.x;
deltaQ.y = -deltaQ.y;
if (GltfBoneMapper.isLowerBone(boneName)) {
convertLowerBone(builder, boneName, deltaQ);
} else {
convertUpperBone(builder, boneName, deltaQ);
}
// Record which PlayerAnimator part received data
if (hasExplicitAnim) {
String animPart = GltfBoneMapper.getAnimPartName(boneName);
if (animPart != null) {
partsWithKeyframes.add(animPart);
}
// For lower bones, the keyframe data goes to the upper bone's part
if (GltfBoneMapper.isLowerBone(boneName)) {
String upperBone = GltfBoneMapper.getUpperBoneFor(boneName);
if (upperBone != null) {
String upperPart = GltfBoneMapper.getAnimPartName(upperBone);
if (upperPart != null) {
partsWithKeyframes.add(upperPart);
}
}
}
}
}
// Selective: enable owned parts always, free parts only if they have keyframes
enableSelectiveParts(builder, ownedParts, enabledParts, partsWithKeyframes);
KeyframeAnimation anim = builder.build();
LOGGER.debug("[GltfPipeline] Converted selective animation '{}' (owned: {}, enabled: {}, withKeyframes: {})",
animName, ownedParts, enabledParts, partsWithKeyframes);
return anim;
}
/**
* 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) {
String[] jointNames = data.jointNames();
Quaternionf[] rawRestRotations = data.rawGltfRestRotations();
Set<String> partsWithKeyframes = new HashSet<>();
for (int j = 0; j < data.jointCount(); j++) {
String boneName = jointNames[j];
if (!GltfBoneMapper.isKnownBone(boneName)) continue;
// Only process bones that belong to this item's owned parts
String animPart = GltfBoneMapper.getAnimPartName(boneName);
if (animPart == null || !ownedParts.contains(animPart)) continue;
// For lower bones, check if the UPPER bone's part is owned
// (lower bone keyframes go to the upper bone's StateCollection)
if (GltfBoneMapper.isLowerBone(boneName)) {
String upperBone = GltfBoneMapper.getUpperBoneFor(boneName);
if (upperBone != null) {
String upperPart = GltfBoneMapper.getAnimPartName(upperBone);
if (upperPart == null || !ownedParts.contains(upperPart)) continue;
}
}
boolean hasExplicitAnim = rawClip != null && (
(j < rawClip.rotations().length && rawClip.rotations()[j] != null)
|| (rawClip.translations() != null
&& j < rawClip.translations().length
&& rawClip.translations()[j] != null)
);
Quaternionf animQ = getRawAnimQuaternion(rawClip, rawRestRotations, j);
Quaternionf restQ = rawRestRotations[j];
Quaternionf deltaLocal = new Quaternionf(restQ).invert().mul(animQ);
Quaternionf deltaParent = new Quaternionf(restQ).mul(deltaLocal)
.mul(new Quaternionf(restQ).invert());
Quaternionf deltaQ = new Quaternionf(deltaParent);
deltaQ.x = -deltaQ.x;
deltaQ.y = -deltaQ.y;
if (GltfBoneMapper.isLowerBone(boneName)) {
convertLowerBone(builder, boneName, deltaQ);
} else {
convertUpperBone(builder, boneName, deltaQ);
}
if (hasExplicitAnim) {
partsWithKeyframes.add(animPart);
if (GltfBoneMapper.isLowerBone(boneName)) {
String upperBone = GltfBoneMapper.getUpperBoneFor(boneName);
if (upperBone != null) {
String upperPart = GltfBoneMapper.getAnimPartName(upperBone);
if (upperPart != null) partsWithKeyframes.add(upperPart);
}
}
}
}
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, GltfData.AnimationClip rawClip, String animName) {
KeyframeAnimation.AnimationBuilder builder =
new KeyframeAnimation.AnimationBuilder(AnimationFormat.JSON_EMOTECRAFT);
builder.beginTick = 0;
builder.endTick = 1;
builder.stopTick = 1;
builder.isLooped = true;
builder.returnTick = 0;
builder.name = animName;
String[] jointNames = data.jointNames();
Quaternionf[] rawRestRotations = data.rawGltfRestRotations();
for (int j = 0; j < data.jointCount(); j++) {
String boneName = jointNames[j];
if (!GltfBoneMapper.isKnownBone(boneName)) continue;
Quaternionf animQ = getRawAnimQuaternion(rawClip, rawRestRotations, j);
Quaternionf restQ = rawRestRotations[j];
// delta_local = inverse(rest_q) * anim_q (in bone-local frame)
Quaternionf deltaLocal = new Quaternionf(restQ).invert().mul(animQ);
// Convert to PARENT frame: delta_parent = rest * delta_local * inv(rest)
// Simplifies algebraically to: animQ * inv(restQ)
Quaternionf deltaParent = new Quaternionf(restQ).mul(deltaLocal)
.mul(new Quaternionf(restQ).invert());
// Convert from glTF parent frame to MC model-def frame.
// 180° rotation around Z (X and Y differ): negate qx and qy.
Quaternionf deltaQ = new Quaternionf(deltaParent);
deltaQ.x = -deltaQ.x;
deltaQ.y = -deltaQ.y;
LOGGER.debug(String.format(
"[GltfPipeline] Bone '%s': restQ=(%.3f,%.3f,%.3f,%.3f) animQ=(%.3f,%.3f,%.3f,%.3f) deltaQ=(%.3f,%.3f,%.3f,%.3f)",
boneName,
restQ.x, restQ.y, restQ.z, restQ.w,
animQ.x, animQ.y, animQ.z, animQ.w,
deltaQ.x, deltaQ.y, deltaQ.z, deltaQ.w));
if (GltfBoneMapper.isLowerBone(boneName)) {
convertLowerBone(builder, boneName, deltaQ);
} else {
convertUpperBone(builder, boneName, deltaQ);
}
}
builder.fullyEnableParts();
KeyframeAnimation anim = builder.build();
LOGGER.debug("[GltfPipeline] Converted glTF animation '{}' to KeyframeAnimation", animName);
return anim;
}
/**
* Get the raw animation quaternion for a joint from a specific clip.
* Falls back to rest rotation if the clip is null or has no data for this joint.
*/
private static Quaternionf getRawAnimQuaternion(
GltfData.AnimationClip rawClip, Quaternionf[] rawRestRotations, int jointIndex
) {
if (rawClip != null && jointIndex < rawClip.rotations().length
&& rawClip.rotations()[jointIndex] != null) {
return rawClip.rotations()[jointIndex][0]; // first frame
}
return rawRestRotations[jointIndex]; // fallback to rest
}
private static void convertUpperBone(
KeyframeAnimation.AnimationBuilder builder,
String boneName, Quaternionf deltaQ
) {
// Decompose delta quaternion to Euler ZYX
// JOML's getEulerAnglesZYX stores: euler.x = X rotation, euler.y = Y rotation, euler.z = Z rotation
// (the "ZYX" refers to rotation ORDER, not storage order)
Vector3f euler = new Vector3f();
deltaQ.getEulerAnglesZYX(euler);
float pitch = euler.x; // X rotation (pitch)
float yaw = euler.y; // Y rotation (yaw)
float roll = euler.z; // Z rotation (roll)
LOGGER.debug(String.format(
"[GltfPipeline] Upper bone '%s': pitch=%.1f° yaw=%.1f° roll=%.1f°",
boneName,
Math.toDegrees(pitch),
Math.toDegrees(yaw),
Math.toDegrees(roll)));
// Get the StateCollection for this body part
String animPart = GltfBoneMapper.getAnimPartName(boneName);
if (animPart == null) return;
KeyframeAnimation.StateCollection part = getPartByName(builder, animPart);
if (part == null) return;
part.pitch.addKeyFrame(0, pitch, Ease.CONSTANT);
part.yaw.addKeyFrame(0, yaw, Ease.CONSTANT);
part.roll.addKeyFrame(0, roll, Ease.CONSTANT);
}
private static void convertLowerBone(
KeyframeAnimation.AnimationBuilder builder,
String boneName, Quaternionf deltaQ
) {
// Extract bend angle and axis from the delta quaternion
float angle = 2.0f * (float) Math.acos(
Math.min(1.0, Math.abs(deltaQ.w))
);
// Determine bend direction from axis
float bendDirection = 0.0f;
if (deltaQ.x * deltaQ.x + deltaQ.z * deltaQ.z > 0.001f) {
bendDirection = (float) Math.atan2(deltaQ.z, deltaQ.x);
}
// Sign: if w is negative, the angle wraps
if (deltaQ.w < 0) {
angle = -angle;
}
LOGGER.debug(String.format(
"[GltfPipeline] Lower bone '%s': bendAngle=%.1f° bendDir=%.1f°",
boneName,
Math.toDegrees(angle),
Math.toDegrees(bendDirection)));
// Apply bend to the upper bone's StateCollection
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(0, angle, Ease.CONSTANT);
part.bendDirection.addKeyFrame(0, bendDirection, Ease.CONSTANT);
}
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: enabled (the GLB has animation data for them)</li>
* <li>Free parts WITHOUT keyframes: disabled (no data to animate, pass through to context)</li>
* <li>Other items' parts: disabled (pass through to their own layer)</li>
* </ul>
*
* @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
*/
private static void enableSelectiveParts(
KeyframeAnimation.AnimationBuilder builder,
Set<String> ownedParts, Set<String> enabledParts,
Set<String> partsWithKeyframes) {
String[] allParts = {"head", "body", "rightArm", "leftArm", "rightLeg", "leftLeg"};
for (String partName : allParts) {
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 (enabledParts.contains(partName) && partsWithKeyframes.contains(partName)) {
// Free part WITH keyframes: enable so the GLB animation drives it
part.fullyEnablePart(false);
} else {
// Other item's part, or free part without keyframes: disable.
// Disabled parts pass through to the lower-priority context layer.
part.setEnabled(false);
}
}
}
}
}

View File

@@ -0,0 +1,94 @@
package com.tiedup.remake.client.gltf;
import com.mojang.blaze3d.vertex.PoseStack;
import net.minecraft.client.Minecraft;
import net.minecraft.client.model.PlayerModel;
import net.minecraft.client.player.AbstractClientPlayer;
import net.minecraft.client.renderer.MultiBufferSource;
import net.minecraft.client.renderer.entity.RenderLayerParent;
import net.minecraft.client.renderer.entity.layers.RenderLayer;
import net.minecraft.resources.ResourceLocation;
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.joml.Matrix4f;
/**
* RenderLayer that renders the glTF mesh (handcuffs) on the player.
* Only active when enabled and only renders on the local player.
* <p>
* Uses the live skinning path: reads live skeleton from HumanoidModel
* via {@link GltfLiveBoneReader}, following PlayerAnimator + bendy-lib rotations.
* Falls back to GLB-internal skinning via {@link GltfSkinningEngine} if live reading fails.
*/
@OnlyIn(Dist.CLIENT)
public class GltfRenderLayer
extends RenderLayer<AbstractClientPlayer, PlayerModel<AbstractClientPlayer>> {
private static final Logger LOGGER = LogManager.getLogger("GltfPipeline");
private static final ResourceLocation CUFFS_MODEL =
ResourceLocation.fromNamespaceAndPath(
"tiedup", "models/gltf/v2/handcuffs/cuffs_prototype.glb"
);
public GltfRenderLayer(
RenderLayerParent<AbstractClientPlayer, PlayerModel<AbstractClientPlayer>> renderer
) {
super(renderer);
}
/**
* The Y translate offset to place the glTF mesh in the MC PoseStack.
* <p>
* After LivingEntityRenderer's scale(-1,-1,1) + translate(0,-1.501,0),
* the PoseStack origin is at the model top (1.501 blocks above feet), Y-down.
* The glTF mesh (MC-converted) has feet at Y=0 and head at Y≈-1.5.
* Translating by 1.501 maps glTF feet to PoseStack feet and head to top.
*/
private static final float ALIGNMENT_Y = 1.501f;
@Override
public void render(
PoseStack poseStack,
MultiBufferSource buffer,
int packedLight,
AbstractClientPlayer entity,
float limbSwing,
float limbSwingAmount,
float partialTick,
float ageInTicks,
float netHeadYaw,
float headPitch
) {
if (!GltfAnimationApplier.isEnabled()) return;
if (entity != Minecraft.getInstance().player) return;
GltfData data = GltfCache.get(CUFFS_MODEL);
if (data == null) return;
// Live path: read skeleton from HumanoidModel (after PlayerAnimator)
PlayerModel<AbstractClientPlayer> parentModel = this.getParentModel();
Matrix4f[] joints = GltfLiveBoneReader.computeJointMatricesFromModel(
parentModel, data, entity
);
if (joints == null) {
// Fallback to GLB-internal path if live reading fails
joints = GltfSkinningEngine.computeJointMatrices(data);
}
poseStack.pushPose();
// Align glTF mesh with MC model (feet-to-feet alignment)
poseStack.translate(0, ALIGNMENT_Y, 0);
GltfMeshRenderer.renderSkinned(
data, joints, poseStack, buffer,
packedLight,
net.minecraft.client.renderer.entity.LivingEntityRenderer
.getOverlayCoords(entity, 0.0f)
);
poseStack.popPose();
}
}

View File

@@ -0,0 +1,296 @@
package com.tiedup.remake.client.gltf;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import org.joml.Matrix4f;
import org.joml.Quaternionf;
import org.joml.Vector3f;
import org.joml.Vector4f;
/**
* CPU-based Linear Blend Skinning (LBS) engine.
* Computes joint matrices purely from glTF data (rest translations + animation rotations).
* All data is in MC-converted space (consistent with IBMs and vertex positions).
*/
@OnlyIn(Dist.CLIENT)
public final class GltfSkinningEngine {
private GltfSkinningEngine() {}
/**
* Compute joint matrices from glTF animation/rest data (default animation).
* Each joint matrix = worldTransform * inverseBindMatrix.
* Uses MC-converted glTF data throughout for consistency.
*
* @param data parsed glTF data (MC-converted)
* @return array of joint matrices ready for skinning
*/
public static Matrix4f[] computeJointMatrices(GltfData data) {
return computeJointMatricesFromClip(data, data.animation());
}
/**
* Compute joint matrices with frame interpolation for animated entities.
* Uses SLERP for rotations and LERP for translations between adjacent keyframes.
*
* <p>The {@code time} parameter is in frame-space: 0.0 corresponds to the first
* keyframe and {@code frameCount - 1} to the last. Values between integer frames
* are interpolated. Out-of-range values are clamped.</p>
*
* @param data the parsed glTF data (MC-converted)
* @param clip the animation clip to sample (null = rest pose for all joints)
* @param time time in frame-space (0.0 = first frame, N-1 = last frame)
* @return interpolated joint matrices ready for skinning
*/
public static Matrix4f[] computeJointMatricesAnimated(
GltfData data, GltfData.AnimationClip clip, float time
) {
int jointCount = data.jointCount();
Matrix4f[] jointMatrices = new Matrix4f[jointCount];
Matrix4f[] worldTransforms = new Matrix4f[jointCount];
int[] parents = data.parentJointIndices();
for (int j = 0; j < jointCount; j++) {
// Build local transform: translate(interpT) * rotate(interpQ)
Matrix4f local = new Matrix4f();
local.translate(getInterpolatedTranslation(data, clip, j, time));
local.rotate(getInterpolatedRotation(data, clip, j, time));
// Compose with parent
if (parents[j] >= 0 && worldTransforms[parents[j]] != null) {
worldTransforms[j] = new Matrix4f(worldTransforms[parents[j]]).mul(local);
} else {
worldTransforms[j] = new Matrix4f(local);
}
// Final joint matrix = worldTransform * inverseBindMatrix
jointMatrices[j] = new Matrix4f(worldTransforms[j])
.mul(data.inverseBindMatrices()[j]);
}
return jointMatrices;
}
/**
* Internal: compute joint matrices from a specific animation clip.
*/
private static Matrix4f[] computeJointMatricesFromClip(GltfData data, GltfData.AnimationClip clip) {
int jointCount = data.jointCount();
Matrix4f[] jointMatrices = new Matrix4f[jointCount];
Matrix4f[] worldTransforms = new Matrix4f[jointCount];
int[] parents = data.parentJointIndices();
for (int j = 0; j < jointCount; j++) {
// Build local transform: translate(animT or restT) * rotate(animQ or restQ)
Matrix4f local = new Matrix4f();
local.translate(getAnimTranslation(data, clip, j));
local.rotate(getAnimRotation(data, clip, j));
// Compose with parent
if (parents[j] >= 0 && worldTransforms[parents[j]] != null) {
worldTransforms[j] = new Matrix4f(worldTransforms[parents[j]]).mul(local);
} else {
worldTransforms[j] = new Matrix4f(local);
}
// Final joint matrix = worldTransform * inverseBindMatrix
jointMatrices[j] = new Matrix4f(worldTransforms[j])
.mul(data.inverseBindMatrices()[j]);
}
return jointMatrices;
}
/**
* Get the animation rotation for a joint (MC-converted).
* Falls back to rest rotation if no animation.
*/
private static Quaternionf getAnimRotation(GltfData data, GltfData.AnimationClip clip, int jointIndex) {
if (clip != null && jointIndex < clip.rotations().length
&& clip.rotations()[jointIndex] != null) {
return clip.rotations()[jointIndex][0]; // first frame
}
return data.restRotations()[jointIndex];
}
/**
* Get the animation translation for a joint (MC-converted).
* Falls back to rest translation if no animation translation exists.
*/
private static Vector3f getAnimTranslation(GltfData data, GltfData.AnimationClip clip, int jointIndex) {
if (clip != null && clip.translations() != null
&& jointIndex < clip.translations().length
&& clip.translations()[jointIndex] != null) {
return clip.translations()[jointIndex][0]; // first frame
}
return data.restTranslations()[jointIndex];
}
// ---- Interpolated accessors (for computeJointMatricesAnimated) ----
/**
* Get an interpolated rotation for a joint at a fractional frame time.
* Uses SLERP between the two bounding keyframes.
*
* <p>Falls back to rest rotation when the clip is null or has no rotation
* data for the given joint. A single-frame channel returns that frame directly.</p>
*
* @param data parsed glTF data
* @param clip animation clip (may be null)
* @param jointIndex joint to query
* @param time frame-space time (clamped internally)
* @return new Quaternionf with the interpolated rotation (never mutates source data)
*/
private static Quaternionf getInterpolatedRotation(
GltfData data, GltfData.AnimationClip clip, int jointIndex, float time
) {
if (clip == null || jointIndex >= clip.rotations().length
|| clip.rotations()[jointIndex] == null) {
// No animation data for this joint -- use rest pose (copy to avoid mutation)
Quaternionf rest = data.restRotations()[jointIndex];
return new Quaternionf(rest);
}
Quaternionf[] frames = clip.rotations()[jointIndex];
if (frames.length == 1) {
return new Quaternionf(frames[0]);
}
// Clamp time to valid range [0, frameCount-1]
float clamped = Math.max(0.0f, Math.min(time, frames.length - 1));
int f0 = (int) Math.floor(clamped);
int f1 = Math.min(f0 + 1, frames.length - 1);
float alpha = clamped - f0;
if (alpha < 1e-6f || f0 == f1) {
return new Quaternionf(frames[f0]);
}
// SLERP: create a copy of frame0 and slerp toward frame1
return new Quaternionf(frames[f0]).slerp(frames[f1], alpha);
}
/**
* Get an interpolated translation for a joint at a fractional frame time.
* Uses LERP between the two bounding keyframes.
*
* <p>Falls back to rest translation when the clip is null, the clip has no
* translation data at all, or has no translation data for the given joint.
* A single-frame channel returns that frame directly.</p>
*
* @param data parsed glTF data
* @param clip animation clip (may be null)
* @param jointIndex joint to query
* @param time frame-space time (clamped internally)
* @return new Vector3f with the interpolated translation (never mutates source data)
*/
private static Vector3f getInterpolatedTranslation(
GltfData data, GltfData.AnimationClip clip, int jointIndex, float time
) {
if (clip == null || clip.translations() == null
|| jointIndex >= clip.translations().length
|| clip.translations()[jointIndex] == null) {
// No animation data for this joint -- use rest pose (copy to avoid mutation)
Vector3f rest = data.restTranslations()[jointIndex];
return new Vector3f(rest);
}
Vector3f[] frames = clip.translations()[jointIndex];
if (frames.length == 1) {
return new Vector3f(frames[0]);
}
// Clamp time to valid range [0, frameCount-1]
float clamped = Math.max(0.0f, Math.min(time, frames.length - 1));
int f0 = (int) Math.floor(clamped);
int f1 = Math.min(f0 + 1, frames.length - 1);
float alpha = clamped - f0;
if (alpha < 1e-6f || f0 == f1) {
return new Vector3f(frames[f0]);
}
// LERP: create a copy of frame0 and lerp toward frame1
return new Vector3f(frames[f0]).lerp(frames[f1], alpha);
}
/**
* Skin a single vertex using Linear Blend Skinning.
*
* <p>Callers should pre-allocate {@code tmpPos} and {@code tmpNorm} and reuse
* them across all vertices in a mesh to avoid per-vertex allocations (12k+
* allocations per frame for a typical mesh).</p>
*
* @param data parsed glTF data
* @param vertexIdx index into the vertex arrays
* @param jointMatrices joint matrices from computeJointMatrices
* @param outPos output skinned position (3 floats)
* @param outNormal output skinned normal (3 floats)
* @param tmpPos pre-allocated scratch Vector4f for position transforms
* @param tmpNorm pre-allocated scratch Vector4f for normal transforms
*/
public static void skinVertex(
GltfData data, int vertexIdx, Matrix4f[] jointMatrices,
float[] outPos, float[] outNormal,
Vector4f tmpPos, Vector4f tmpNorm
) {
float[] positions = data.positions();
float[] normals = data.normals();
int[] joints = data.joints();
float[] weights = data.weights();
// Rest position
float vx = positions[vertexIdx * 3];
float vy = positions[vertexIdx * 3 + 1];
float vz = positions[vertexIdx * 3 + 2];
// Rest normal
float nx = normals[vertexIdx * 3];
float ny = normals[vertexIdx * 3 + 1];
float nz = normals[vertexIdx * 3 + 2];
// LBS: v_skinned = Σ(w[i] * jointMatrix[j[i]] * v_rest)
float sx = 0, sy = 0, sz = 0;
float snx = 0, sny = 0, snz = 0;
for (int i = 0; i < 4; i++) {
int ji = joints[vertexIdx * 4 + i];
float w = weights[vertexIdx * 4 + i];
if (w <= 0.0f || ji >= jointMatrices.length) continue;
Matrix4f jm = jointMatrices[ji];
// Transform position
tmpPos.set(vx, vy, vz, 1.0f);
jm.transform(tmpPos);
sx += w * tmpPos.x;
sy += w * tmpPos.y;
sz += w * tmpPos.z;
// Transform normal (ignore translation)
tmpNorm.set(nx, ny, nz, 0.0f);
jm.transform(tmpNorm);
snx += w * tmpNorm.x;
sny += w * tmpNorm.y;
snz += w * tmpNorm.z;
}
outPos[0] = sx;
outPos[1] = sy;
outPos[2] = sz;
// Normalize the normal
float len = (float) Math.sqrt(snx * snx + sny * sny + snz * snz);
if (len > 0.0001f) {
outNormal[0] = snx / len;
outNormal[1] = sny / len;
outNormal[2] = snz / len;
} else {
outNormal[0] = 0;
outNormal[1] = 1;
outNormal[2] = 0;
}
}
}