Phase 1.1-1.3 : bridge GLB → SkinnedMesh

Premier jalon Phase 1 : conversion d'un GltfData (format legacy 11-joints
PlayerAnimator) vers SkinnedMesh Epic Fight (biped ~20 joints).

Files :
- rig/bridge/GlbJointAliasTable.java : table mapping statique PlayerAnimator
  → biped EF (body/torso→Chest, leftUpperArm→Arm_L, leftLowerArm→Elbow_L, etc).
  Fallback Root pour inconnus. Bypass si nom déjà biped (Root/Torso/Chest/Head
  ou suffixe _R/_L).
- rig/bridge/GltfToSkinnedMesh.java : convert(GltfData, AssetAccessor<Armature>)
  → SkinnedMesh. Pré-calcule jointIdMap, boucle vertices (pos/normal/uv + drop 4th
  joint à plus faible poids + renormalise 3 restants), groupe indices par
  primitive (material) en VanillaMeshPartDefinition.

Note : animations GLB ignorées (scope Phase 4 JSON EF authored).

Compile BUILD SUCCESSFUL maintenu.
This commit is contained in:
notevil
2026-04-22 14:28:37 +02:00
parent 4a615368df
commit 94fcece05a
2 changed files with 357 additions and 0 deletions

View File

@@ -0,0 +1,119 @@
/*
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.bridge;
import java.util.Map;
import javax.annotation.Nullable;
import com.google.common.collect.ImmutableMap;
/**
* Table d'alias runtime pour mapper les noms de joints des GLB legacy TiedUp
* (riggés via PlayerAnimator / bendy-lib avec noms type {@code leftUpperArm})
* vers le skeleton biped Epic Fight utilisé par RIG ({@code Arm_L}, etc.).
*
* <p>Voir {@code docs/plans/rig/ARCHITECTURE.md §6.3} pour la source de vérité
* du mapping. Tout joint inconnu après lookup doit être loggé WARN par le
* caller et fallback sur {@code Root}.</p>
*
* <p><b>Cas spécial "body/torso"</b> — le GLB legacy a souvent un unique joint
* couvrant l'ensemble du torse. On le mappe sur {@code Chest} par défaut
* (meilleur fit pour les items bondage majoritairement attachés au haut du
* corps : harnais, menottes de poitrine, collier). Si un item a besoin
* d'attachement à {@code Torso} (ceinture), le modeler devra renommer son
* joint en {@code waist} explicitement.</p>
*/
public final class GlbJointAliasTable {
/**
* Mapping direct PlayerAnimator → biped EF. Les clés sont la forme
* lowercase EXACTE des noms exportés par les GLB legacy.
*/
private static final Map<String, String> ALIAS = ImmutableMap.<String, String>builder()
// Torso region
.put("body", "Chest")
.put("torso", "Chest")
.put("chest", "Chest")
.put("waist", "Torso")
.put("hip", "Torso")
// Head
.put("head", "Head")
// Arms left
.put("leftshoulder", "Shoulder_L")
.put("leftupperarm", "Arm_L")
.put("leftarm", "Arm_L")
.put("leftlowerarm", "Elbow_L")
.put("leftforearm", "Elbow_L")
.put("leftelbow", "Elbow_L")
.put("lefthand", "Hand_L")
// Arms right
.put("rightshoulder", "Shoulder_R")
.put("rightupperarm", "Arm_R")
.put("rightarm", "Arm_R")
.put("rightlowerarm", "Elbow_R")
.put("rightforearm", "Elbow_R")
.put("rightelbow", "Elbow_R")
.put("righthand", "Hand_R")
// Legs left
.put("leftupperleg", "Thigh_L")
.put("leftleg", "Thigh_L")
.put("leftlowerleg", "Knee_L")
.put("leftknee", "Knee_L")
.put("leftfoot", "Leg_L")
// Legs right
.put("rightupperleg", "Thigh_R")
.put("rightleg", "Thigh_R")
.put("rightlowerleg", "Knee_R")
.put("rightknee", "Knee_R")
.put("rightfoot", "Leg_R")
// Root fallback (déjà nommé Root dans GLB modernes)
.put("root", "Root")
.put("armature", "Root")
.build();
private GlbJointAliasTable() {}
/**
* Traduit un nom de joint GLB legacy vers le nom biped EF équivalent.
* Case-insensitive. Les noms déjà au format biped EF (ex: {@code Arm_L}) sont
* retournés tels quels après vérification.
*
* @param gltfJointName nom tel qu'exporté dans le GLB (jointNames[])
* @return nom biped EF (ex: {@code Arm_L}), ou null si inconnu
*/
@Nullable
public static String mapGltfJointName(String gltfJointName) {
if (gltfJointName == null || gltfJointName.isEmpty()) {
return null;
}
// Direct hit sur le biped EF (GLB moderne déjà bien rigged).
if (isBipedJointName(gltfJointName)) {
return gltfJointName;
}
return ALIAS.get(gltfJointName.toLowerCase());
}
/**
* Vérifie si un nom est déjà au format biped EF. Utilisé pour court-circuiter
* l'alias lookup sur les GLB modernes.
*/
public static boolean isBipedJointName(String name) {
// Heuristique : les noms biped EF sont en PascalCase avec suffixe _R/_L,
// ou parmi {Root, Torso, Chest, Head}.
return switch (name) {
case "Root", "Torso", "Chest", "Head" -> true;
default -> name.endsWith("_R") || name.endsWith("_L");
};
}
}

View File

@@ -0,0 +1,238 @@
/*
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.bridge;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import it.unimi.dsi.fastutil.ints.IntArrayList;
import it.unimi.dsi.fastutil.ints.IntList;
import com.tiedup.remake.client.gltf.GltfData;
import com.tiedup.remake.client.gltf.GltfData.Primitive;
import com.tiedup.remake.rig.TiedUpRigConstants;
import com.tiedup.remake.rig.armature.Armature;
import com.tiedup.remake.rig.armature.Joint;
import com.tiedup.remake.rig.asset.AssetAccessor;
import com.tiedup.remake.rig.math.Vec2f;
import com.tiedup.remake.rig.math.Vec3f;
import com.tiedup.remake.rig.mesh.MeshPartDefinition;
import com.tiedup.remake.rig.mesh.SingleGroupVertexBuilder;
import com.tiedup.remake.rig.mesh.SkinnedMesh;
import com.tiedup.remake.rig.mesh.transformer.VanillaModelTransformer.VanillaMeshPartDefinition;
/**
* Pont Phase 1 : convertit un {@link GltfData} (format GLB legacy TiedUp
* riggé 11-joints PlayerAnimator) en {@link SkinnedMesh} Epic Fight
* (biped ~20 joints).
*
* <p>Algorithme (voir {@code docs/plans/rig/MIGRATION.md §1.2.1}) :</p>
* <ol>
* <li>Pré-calculer le mapping {@code gltfJointIdx → bipedJointId} via
* {@link GlbJointAliasTable} + {@link Armature#searchJointByName}.</li>
* <li>Pour chaque vertex :
* <ul>
* <li>Position / normal / UV depuis {@link GltfData}</li>
* <li>Retenir les 3 joints de plus fort poids parmi les 4 glTF</li>
* <li>Renormaliser les poids retenus pour sommer à 1.0</li>
* <li>Construire le {@link SingleGroupVertexBuilder}</li>
* </ul>
* </li>
* <li>Grouper les indices par {@link Primitive} en autant de
* {@link MeshPartDefinition}.</li>
* <li>{@link SingleGroupVertexBuilder#loadVertexInformation(List, Map)}
* construit le {@link SkinnedMesh}.</li>
* </ol>
*
* <p>Les animations éventuellement embarquées dans le GLB sont <b>ignorées</b> —
* les animations passent par JSON EF natif via {@code JsonAssetLoader}.</p>
*/
public final class GltfToSkinnedMesh {
private static final float WEIGHT_EPSILON = 1.0e-4F;
private GltfToSkinnedMesh() {}
/**
* Convertit un GLB parsé en {@link SkinnedMesh} utilisable par le pipeline
* de rendu RIG.
*
* @param data données GLB parsées par {@code GlbParser.parse(...)}
* @param armature armature biped EF cible (doit être déjà chargée)
* @return SkinnedMesh prêt à être rendu
* @throws IllegalStateException si {@code armature} est null
*/
public static SkinnedMesh convert(GltfData data, AssetAccessor<? extends Armature> armature) {
if (armature == null || armature.get() == null) {
throw new IllegalStateException(
"Armature not loaded — GltfToSkinnedMesh.convert() called before resource reload completed"
);
}
Armature arm = armature.get();
int[] jointIdMap = buildJointIdMap(data.jointNames(), arm);
int vertexCount = data.vertexCount();
float[] positions = data.positions();
float[] normals = data.normals();
float[] texCoords = data.texCoords();
int[] joints = data.joints();
float[] weights = data.weights();
List<SingleGroupVertexBuilder> vertices = new ArrayList<>(vertexCount);
for (int i = 0; i < vertexCount; i++) {
vertices.add(buildVertex(i, positions, normals, texCoords, joints, weights, jointIdMap));
}
Map<MeshPartDefinition, IntList> partIndices = buildPartIndices(data.primitives());
return SingleGroupVertexBuilder.loadVertexInformation(vertices, partIndices);
}
/**
* Construit le mapping {@code gltfJointIdx → bipedJointId} une seule fois
* avant la boucle vertex. Les noms inconnus retombent sur la racine
* {@code Root} (id 0) avec un log WARN.
*/
private static int[] buildJointIdMap(String[] gltfJointNames, Armature arm) {
int[] map = new int[gltfJointNames.length];
int unknownCount = 0;
int aliasedCount = 0;
int rootId = arm.rootJoint != null ? arm.rootJoint.getId() : 0;
for (int i = 0; i < gltfJointNames.length; i++) {
String gltfName = gltfJointNames[i];
String bipedName = GlbJointAliasTable.mapGltfJointName(gltfName);
if (bipedName == null) {
TiedUpRigConstants.LOGGER.warn(
"GltfToSkinnedMesh: unknown joint '{}' — fallback to Root",
gltfName
);
map[i] = rootId;
unknownCount++;
continue;
}
Joint joint = arm.searchJointByName(bipedName);
if (joint == null) {
TiedUpRigConstants.LOGGER.warn(
"GltfToSkinnedMesh: biped joint '{}' (aliased from '{}') not found in armature — fallback to Root",
bipedName, gltfName
);
map[i] = rootId;
unknownCount++;
continue;
}
map[i] = joint.getId();
if (!gltfName.equals(bipedName)) {
aliasedCount++;
}
}
TiedUpRigConstants.LOGGER.info(
"GltfToSkinnedMesh: {} joints mapped ({} via alias, {} unknown→Root)",
gltfJointNames.length, aliasedCount, unknownCount
);
return map;
}
/**
* Construit un vertex individuel : position/normal/UV depuis les arrays
* flattened, puis sélection des 3 plus forts poids (drop du 4e) + renormalisation.
*/
private static SingleGroupVertexBuilder buildVertex(
int i,
float[] positions, float[] normals, float[] texCoords,
int[] joints, float[] weights,
int[] jointIdMap) {
SingleGroupVertexBuilder vb = new SingleGroupVertexBuilder();
vb.setPosition(new Vec3f(positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2]));
vb.setNormal(new Vec3f(normals[i * 3], normals[i * 3 + 1], normals[i * 3 + 2]));
vb.setTextureCoordinate(new Vec2f(texCoords[i * 2], texCoords[i * 2 + 1]));
// Récupère les 4 joints/poids glTF, sélectionne les 3 plus forts.
int[] rawJoints = new int[4];
float[] rawWeights = new float[4];
for (int k = 0; k < 4; k++) {
rawJoints[k] = joints[i * 4 + k];
rawWeights[k] = weights[i * 4 + k];
}
// Trouve l'index du plus faible poids (à drop).
int minIdx = 0;
for (int k = 1; k < 4; k++) {
if (rawWeights[k] < rawWeights[minIdx]) {
minIdx = k;
}
}
// Build les 3 retenus + compte effective.
float w0 = 0, w1 = 0, w2 = 0;
int id0 = 0, id1 = 0, id2 = 0;
int effectiveCount = 0;
int slot = 0;
for (int k = 0; k < 4; k++) {
if (k == minIdx) continue;
float w = rawWeights[k];
int id = jointIdMap[rawJoints[k]];
switch (slot) {
case 0 -> { w0 = w; id0 = id; }
case 1 -> { w1 = w; id1 = id; }
case 2 -> { w2 = w; id2 = id; }
}
if (w > WEIGHT_EPSILON) effectiveCount++;
slot++;
}
// Renormalise les 3 poids pour qu'ils somment à 1.0.
float sum = w0 + w1 + w2;
if (sum > WEIGHT_EPSILON) {
float inv = 1.0F / sum;
w0 *= inv; w1 *= inv; w2 *= inv;
} else {
// Vertex sans skinning (tout-zéro ou bugué) — attache au Root avec poids 1.
w0 = 1.0F; w1 = 0; w2 = 0;
id0 = 0; id1 = 0; id2 = 0;
effectiveCount = 1;
}
vb.setEffectiveJointIDs(new Vec3f(id0, id1, id2));
vb.setEffectiveJointWeights(new Vec3f(w0, w1, w2));
vb.setEffectiveJointNumber(Math.max(1, effectiveCount));
return vb;
}
/**
* Groupe les indices par primitive (= material dans Blender) → une
* {@link VanillaMeshPartDefinition} par primitive. Le partName est pris sur
* le {@code materialName} si défini, sinon un nom synthétique
* {@code "part_N"}.
*/
private static Map<MeshPartDefinition, IntList> buildPartIndices(List<Primitive> primitives) {
Map<MeshPartDefinition, IntList> partIndices = new HashMap<>();
int fallbackCounter = 0;
for (Primitive prim : primitives) {
String partName = prim.materialName();
if (partName == null || partName.isEmpty()) {
partName = "part_" + fallbackCounter++;
}
MeshPartDefinition partDef = VanillaMeshPartDefinition.of(partName);
IntList indexList = new IntArrayList(prim.indices().length);
for (int idx : prim.indices()) {
indexList.add(idx);
}
partIndices.put(partDef, indexList);
}
return partIndices;
}
}