diff --git a/README.md b/README.md index 42b8e63..65021e1 100644 --- a/README.md +++ b/README.md @@ -50,8 +50,15 @@ renderer: build a spine, put a ring of shared vertices along it, and draw the fa limb's spine, not their own inflated box, so they stay attached. Axis 0 folds towards the front (-Z) and PI/2 towards +X, in the limb's own space. Knees fold back, -so they use axis PI. The body bends too, but the head and arms don't follow its upper half yet. -Held items still attach to the unbent arm. +so they use axis PI. + +The torso is anchored at the hips instead of its pivot (`LimbBend.anchoredAtEnd`), so the legs stay +attached and the chest leans. The head and arms are siblings of the body in the vanilla model, so +`PoseApplier` carries them: their pivots follow the bent torso, and they turn with its ring there +(`LimbBend.angleAt`). An arm carried by a forward lean swings back like a diver's, so emotes that want +hanging arms tilt them forward by about the lean (see the `twerk` test animation). + +Held items still attach to the unbent arm, and the cape doesn't follow the torso yet. ## Commands diff --git a/core/src/main/java/org/saturnclient/emotes/core/bend/LimbBend.java b/core/src/main/java/org/saturnclient/emotes/core/bend/LimbBend.java index c0f9422..e30bc8e 100644 --- a/core/src/main/java/org/saturnclient/emotes/core/bend/LimbBend.java +++ b/core/src/main/java/org/saturnclient/emotes/core/bend/LimbBend.java @@ -11,6 +11,9 @@ package org.saturnclient.emotes.core.bend; *
The spine is split into {@code segments} rings, spaced evenly by arc length so the limb keeps its * length and each piece covers the same texture rows as before. Each ring moves its cross-section * rigidly, so neighbouring pieces share their vertices and the mesh stays closed however far it bends. + * + *
A limb is normally anchored at {@code start}, its pivot. The torso is anchored at {@code end}
+ * instead: the hips stay on the legs and the upper half leans (see {@link #anchoredAtEnd}).
*/
public final class LimbBend {
/** Samples used to measure the curve's arc length. */
@@ -18,6 +21,8 @@ public final class LimbBend {
private final float start;
private final float length;
+ /** Whether the fixed end is {@code end}. Then everything is mirrored along Y around the middle. */
+ private final boolean anchoredAtEnd;
private final int segments;
private final float angle;
/** Unit bend direction D in the XZ plane, and the rotation axis n = Y × D. */
@@ -37,6 +42,20 @@ public final class LimbBend {
* @param segments number of pieces the limb is divided into
*/
public LimbBend(float start, float end, float angle, float axis, float sharpness, int segments) {
+ this(start, end, angle, axis, sharpness, segments, false);
+ }
+
+ /**
+ * Like {@link #LimbBend(float, float, float, float, float, int)}, but {@code end} stays in place and
+ * the half towards {@code start} folds. Axis 0 still folds towards the front.
+ */
+ public static LimbBend anchoredAtEnd(float start, float end, float angle, float axis, float sharpness,
+ int segments) {
+ return new LimbBend(start, end, angle, axis, sharpness, segments, true);
+ }
+
+ private LimbBend(float start, float end, float angle, float axis, float sharpness, int segments,
+ boolean anchoredAtEnd) {
if (end <= start) {
throw new IllegalArgumentException("end must be below start");
}
@@ -45,6 +64,7 @@ public final class LimbBend {
}
this.start = start;
this.length = end - start;
+ this.anchoredAtEnd = anchoredAtEnd;
this.segments = segments;
this.angle = angle;
this.dirX = (float) Math.sin(axis);
@@ -138,6 +158,43 @@ public final class LimbBend {
* @param out receives the bent position as {x, y, z}
*/
public void bendPoint(float x, float y, float z, float[] out) {
+ if (anchoredAtEnd) {
+ bendPointFromStart(x, mirror(y), z, out);
+ out[1] = mirror(out[1]);
+ } else {
+ bendPointFromStart(x, y, z, out);
+ }
+ }
+
+ /** Rotates a direction (a face normal) the same way {@link #bendPoint} rotates points at {@code atY}. */
+ public void bendDirection(float atY, float nx, float ny, float nz, float[] out) {
+ if (anchoredAtEnd) {
+ rotate(nx, -ny, nz, angleFromStart(mirror(atY)), out);
+ out[1] = -out[1];
+ } else {
+ rotate(nx, ny, nz, angleFromStart(atY), out);
+ }
+ }
+
+ /**
+ * How far the limb has turned at {@code y}, as a rotation around {@link #axis()} by this angle.
+ * Parts attached to the limb at {@code y} (like the head and arms on the torso) turn by this much.
+ */
+ public float angleAt(float y) {
+ // Mirroring along Y turns a rotation around a horizontal axis the other way.
+ return anchoredAtEnd ? -angleFromStart(mirror(y)) : angleFromStart(y);
+ }
+
+ /** The unit axis the limb turns around. It's horizontal, at right angles to the bend direction. */
+ public Vec3 axis() {
+ return new Vec3(axisX, 0, axisZ);
+ }
+
+ private float mirror(float y) {
+ return 2 * start + length - y;
+ }
+
+ private void bendPointFromStart(float x, float y, float z, float[] out) {
float along = (y - start) / length * segments;
int ring;
float f;
@@ -168,11 +225,10 @@ public final class LimbBend {
out[2] += cz;
}
- /** Rotates a direction (a face normal) the same way {@link #bendPoint} rotates points at {@code atY}. */
- public void bendDirection(float atY, float nx, float ny, float nz, float[] out) {
- float along = Math.max(0, Math.min(segments, (atY - start) / length * segments));
+ private float angleFromStart(float y) {
+ float along = Math.max(0, Math.min(segments, (y - start) / length * segments));
int ring = Math.min((int) along, segments - 1);
- rotate(nx, ny, nz, lerp(ringAngle[ring], ringAngle[ring + 1], along - ring), out);
+ return lerp(ringAngle[ring], ringAngle[ring + 1], along - ring);
}
/** Rodrigues' rotation of v around the bend axis n by {@code a}. */
diff --git a/core/src/test/java/org/saturnclient/emotes/core/bend/LimbBendTest.java b/core/src/test/java/org/saturnclient/emotes/core/bend/LimbBendTest.java
index 1be7596..8cee08b 100644
--- a/core/src/test/java/org/saturnclient/emotes/core/bend/LimbBendTest.java
+++ b/core/src/test/java/org/saturnclient/emotes/core/bend/LimbBendTest.java
@@ -110,4 +110,66 @@ class LimbBendTest {
arm(HALF_PI, 0).bendDirection(10, 0, 0, -1, normal);
assertArrayEquals(new float[] { 0, -1, 0 }, normal, 1e-3f);
}
+
+ /** A torso: box from y = 0 (neck) to 12 (hips), anchored at the hips. */
+ private static LimbBend torso(float angle, float axis) {
+ return LimbBend.anchoredAtEnd(0, 12, angle, axis, 0.5f, 12);
+ }
+
+ @Test
+ void torsoKeepsItsHipsInPlace() {
+ assertArrayEquals(new float[] { 3, 12, -1 }, bend(torso(1, 0), 3, 12, -1), 1e-4f);
+ }
+
+ @Test
+ void torsoLeansForward() {
+ // Axis 0 still means forward: the neck moves to the front (-Z) and down (+Y).
+ float[] neck = bend(torso(HALF_PI, 0), 0, 0, 0);
+ assertTrue(neck[2] < -4, "neck should be in front, z = " + neck[2]);
+ assertTrue(neck[1] > 4, "neck should drop, y = " + neck[1]);
+ }
+
+ @Test
+ void torsoKeepsItsLength() {
+ LimbBend bend = torso(HALF_PI, 0);
+ float total = 0;
+ float[] previous = bend(bend, 0, 0, 0);
+ for (int i = 1; i <= 12; i++) {
+ float[] point = bend(bend, 0, i, 0);
+ total += distance(previous, point);
+ previous = point;
+ }
+ assertEquals(12, total, 0.05);
+ }
+
+ @Test
+ void attachedPartsTurnWithTheRing() {
+ // The offset from a ring's center to a point on it turns by angleAt around axis.
+ for (LimbBend bend : new LimbBend[] { arm(1.3f, 0.4f), torso(1.3f, 0.4f) }) {
+ for (float y : new float[] { -2, 0, 4, 10, 12 }) {
+ float[] center = bend(bend, 0, y, 0);
+ float[] point = bend(bend, 1, y, -2);
+ float[] expected = rotate(new float[] { 1, 0, -2 }, bend.axis(), bend.angleAt(y));
+ assertArrayEquals(expected,
+ new float[] { point[0] - center[0], point[1] - center[1], point[2] - center[2] }, 1e-3f);
+ }
+ }
+ }
+
+ @Test
+ void torsoNormalsTurnWithTheRing() {
+ // A forward lean tips the front face (-Z) of the chest towards the ground (+Y).
+ float[] normal = new float[3];
+ torso(HALF_PI, 0).bendDirection(0, 0, 0, -1, normal);
+ assertArrayEquals(new float[] { 0, 1, 0 }, normal, 1e-3f);
+ }
+
+ /** Rodrigues' rotation of v around the unit axis n. */
+ private static float[] rotate(float[] v, Vec3 n, float angle) {
+ Vec3 vec = new Vec3(v[0], v[1], v[2]);
+ float cos = (float) Math.cos(angle);
+ float sin = (float) Math.sin(angle);
+ Vec3 r = vec.scale(cos).add(n.cross(vec).scale(sin)).add(n.scale(n.dot(vec) * (1 - cos)));
+ return new float[] { r.x(), r.y(), r.z() };
+ }
}
diff --git a/src/main/java/org/saturnclient/emotes/fabric/PoseApplier.java b/src/main/java/org/saturnclient/emotes/fabric/PoseApplier.java
index 4c6e35b..ef1e305 100644
--- a/src/main/java/org/saturnclient/emotes/fabric/PoseApplier.java
+++ b/src/main/java/org/saturnclient/emotes/fabric/PoseApplier.java
@@ -1,9 +1,12 @@
package org.saturnclient.emotes.fabric;
+import org.joml.Quaternionf;
+import org.joml.Vector3f;
import org.saturnclient.emotes.core.Bone;
import org.saturnclient.emotes.core.BoneTransform;
import org.saturnclient.emotes.core.Pose;
import org.saturnclient.emotes.core.bend.LimbBend;
+import org.saturnclient.emotes.core.bend.Vec3;
import org.saturnclient.emotes.fabric.access.BendablePart;
import net.minecraft.client.model.HumanoidModel;
@@ -21,6 +24,7 @@ public final class PoseApplier {
}
public static void apply(HumanoidModel> model, Pose pose) {
+ LimbBend bodyBend = null;
for (Bone bone : Bone.values()) {
ModelPart part = part(model, bone);
BoneTransform transform = pose.get(bone).orElse(null);
@@ -28,7 +32,18 @@ public final class PoseApplier {
apply(part, transform);
}
// Models are shared between entities, so bends from the last one drawn must be cleared.
- ((BendablePart) (Object) part).saturnEmotes$setBend(bend(bone, transform));
+ LimbBend bend = bend(bone, transform);
+ ((BendablePart) (Object) part).saturnEmotes$setBend(bend);
+ if (bone == Bone.BODY) {
+ bodyBend = bend;
+ }
+ }
+
+ // The torso bends from the hips, so whatever hangs off its upper half moves with it.
+ if (bodyBend != null && !bodyBend.isStraight()) {
+ carry(model.body, bodyBend, model.head);
+ carry(model.body, bodyBend, model.rightArm);
+ carry(model.body, bodyBend, model.leftArm);
}
}
@@ -58,16 +73,50 @@ public final class PoseApplier {
return null;
}
// Vanilla player limbs, in each part's own space. Armor uses the same ranges so it bends with
- // the limb instead of along its own (inflated) box.
+ // the limb instead of along its own (inflated) box. One segment per texture row, so each piece
+ // keeps a whole row of pixels.
+ float bend = transform.bend();
+ float axis = transform.bendAxis();
return switch (bone) {
case HEAD -> null;
- case RIGHT_ARM, LEFT_ARM -> limb(-2, 10, transform);
- case BODY, RIGHT_LEG, LEFT_LEG -> limb(0, 12, transform);
+ case RIGHT_ARM, LEFT_ARM -> new LimbBend(-2, 10, bend, axis, SHARPNESS, 12);
+ case RIGHT_LEG, LEFT_LEG -> new LimbBend(0, 12, bend, axis, SHARPNESS, 12);
+ // The hips stay on the legs and the chest leans.
+ case BODY -> LimbBend.anchoredAtEnd(0, 12, bend, axis, SHARPNESS, 12);
};
}
- /** One segment per texture row, so each piece keeps a whole row of pixels. */
- private static LimbBend limb(float start, float end, BoneTransform transform) {
- return new LimbBend(start, end, transform.bend(), transform.bendAxis(), SHARPNESS, Math.round(end - start));
+ /**
+ * Moves {@code part} (a sibling of the body in the model) as if it were attached to the bent torso
+ * at its pivot: its pivot follows the torso and it turns with the torso's ring there.
+ */
+ private static void carry(ModelPart body, LimbBend bend, ModelPart part) {
+ Quaternionf bodyRotation = rotation(body);
+
+ // The part's pivot in the torso's own space.
+ Vector3f pivot = bodyRotation.transformInverse(
+ new Vector3f(part.x - body.x, part.y - body.y, part.z - body.z));
+ float[] bent = new float[3];
+ bend.bendPoint(pivot.x, pivot.y, pivot.z, bent);
+ Vector3f moved = bodyRotation.transform(new Vector3f(bent[0], bent[1], bent[2]));
+ part.x = body.x + moved.x;
+ part.y = body.y + moved.y;
+ part.z = body.z + moved.z;
+
+ // The ring's turn happens in the torso's space, so bring it into model space around the part's
+ // own rotation.
+ Vec3 axis = bend.axis();
+ Quaternionf turn = new Quaternionf().rotationAxis(bend.angleAt(pivot.y), axis.x(), axis.y(), axis.z());
+ Quaternionf result = new Quaternionf(bodyRotation).mul(turn).mul(new Quaternionf(bodyRotation).conjugate())
+ .mul(rotation(part));
+ Vector3f euler = result.getEulerAnglesZYX(new Vector3f());
+ part.xRot = euler.x;
+ part.yRot = euler.y;
+ part.zRot = euler.z;
+ }
+
+ /** The rotation ModelPart.translateAndRotate applies. */
+ private static Quaternionf rotation(ModelPart part) {
+ return new Quaternionf().rotationZYX(part.zRot, part.yRot, part.xRot);
}
}
diff --git a/src/testmod/java/org/saturnclient/emotes/testmod/EmotesGameTest.java b/src/testmod/java/org/saturnclient/emotes/testmod/EmotesGameTest.java
index be5794c..d85c047 100644
--- a/src/testmod/java/org/saturnclient/emotes/testmod/EmotesGameTest.java
+++ b/src/testmod/java/org/saturnclient/emotes/testmod/EmotesGameTest.java
@@ -8,21 +8,28 @@ import net.fabricmc.fabric.api.client.gametest.v1.context.TestSingleplayerContex
import net.minecraft.client.CameraType;
/**
- * Screenshots each test animation at its peak, from the front and from the side.
+ * Screenshots each test animation at set points, from the front, the side and behind.
* Run with {@code ./gradlew :