Bend the torso from the hips and add bow and twerk test animations

- LimbBend.anchoredAtEnd keeps the far end in place (the hips on the
  legs) and folds the other half, and angleAt/axis expose how far the
  limb has turned at a point.
- PoseApplier bends the body from the hips and carries the head and arms
  with it: their pivots follow the bent torso and they turn with its ring.
- Test animations: bow (torso only) and twerk. The gametest shoots the
  twerk at the top and bottom of its bounce, including from behind.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-09-26 19:57:50 +02:00
co-authored by claude
parent 8bac5eef64
commit 34aba293b6
6 changed files with 252 additions and 27 deletions
+9 -2
View File
@@ -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
@@ -11,6 +11,9 @@ package org.saturnclient.emotes.core.bend;
* <p>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.
*
* <p>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}. */
@@ -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() };
}
}
@@ -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);
}
}
@@ -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 :<mc>:runClientGameTest}. Screenshots land in
* {@code versions/<mc>/build/run/clientGameTest/screenshots/}.
*/
public final class EmotesGameTest implements FabricClientGameTest {
private static final String MINECRAFT = /*$ minecraft*/ "1.21.11";
/** Animation name and the tick where it's at its most bent. */
private static final Object[][] SHOTS = {
{ "tpose", 10 },
{ "bend", 20 },
{ "flap", 15 },
{ "squat", 20 },
/** Animation name, the tick to screenshot it at, and the angles to look from (0 is the front). */
private static final Shot[] SHOTS = {
new Shot("tpose", 10, 0, 90),
new Shot("bend", 20, 0, 90),
new Shot("flap", 15, 0, 90),
new Shot("squat", 20, 0, 90),
new Shot("bow", 20, 0, 90),
// Top and bottom of the bounce.
new Shot("twerk", 16, 0, 90, 150),
new Shot("twerk", 4, 0, 90, 150),
};
private record Shot(String animation, int tick, float... angles) {
}
@Override
public void runTest(ClientGameTestContext context) {
try (TestSingleplayerContext singleplayer = context.worldBuilder().create()) {
@@ -33,12 +40,13 @@ public final class EmotesGameTest implements FabricClientGameTest {
});
context.waitTicks(20);
for (Object[] shot : SHOTS) {
String name = (String) shot[0];
context.runOnClient(client -> Emotes.play(client.player.getUUID(), TestAnimations.ALL.get(name)));
context.waitTicks((Integer) shot[1]);
screenshot(context, name + "-front", 0);
screenshot(context, name + "-side", 90);
for (Shot shot : SHOTS) {
context.runOnClient(client -> Emotes.play(client.player.getUUID(),
TestAnimations.ALL.get(shot.animation())));
context.waitTicks(shot.tick());
for (float angle : shot.angles()) {
screenshot(context, shot.animation() + "-t" + shot.tick() + "-" + (int) angle, angle);
}
context.runOnClient(client -> Emotes.stop(client.player.getUUID()));
}
}
@@ -58,11 +58,54 @@ final class TestAnimations {
.set(Bone.RIGHT_LEG, leg)
.set(Bone.LEFT_LEG, leg)
.build();
}));
}),
// Chest leaning forward from the hips, head and arms carried with it. Tests the torso.
"bow", of(40, true, t -> {
float lean = HALF_PI * 0.5f * (1 - (float) Math.cos(t / 40f * 2 * Math.PI));
return Pose.builder()
.set(Bone.BODY, BoneTransform.IDENTITY.withBend(lean, 0))
.build();
}),
"twerk", of(8, true, TestAnimations::twerk));
private TestAnimations() {
}
/**
* Squatting with knees out, hips pushed back and the chest leaning forward, bouncing the hips
* about 2.5 times a second. The torso and knees bend with the bounce, so the back arches.
*/
private static Pose twerk(float t) {
// 0 at the bottom of the bounce, 1 at the top.
float up = 0.5f * (1 - (float) Math.cos(t / 8f * 2 * Math.PI));
float thighPitch = -1.1f + 0.15f * up;
float knee = 1.75f - 0.3f * up;
float splay = 0.55f;
float lean = 1.25f - 0.2f * up;
// Lower everything by as much as the bent legs got shorter, and push the hips back so the feet
// stay under where they were (a rigid thigh + shin approximation of the curved leg).
float drop = 12 - 6 * (float) (Math.cos(thighPitch) + Math.cos(thighPitch + knee)) * (float) Math.cos(splay);
float back = -6 * (float) (Math.sin(thighPitch) + Math.sin(thighPitch + knee));
BoneTransform shift = new BoneTransform(0, drop, back, 0, 0, 0, 0, 0);
// The arms ride on the chest, which would swing them back as it leans. Tilt them forward by a
// bit more than the lean so they hang down towards the knees, a little outside them.
float armPitch = -lean * 1.1f;
return Pose.builder()
.set(Bone.BODY, shift.withBend(lean, 0))
.set(Bone.HEAD, new BoneTransform(0, drop, back, -lean * 0.5f, 0, 0, 0, 0))
.set(Bone.RIGHT_ARM, new BoneTransform(0, drop, back, armPitch, 0, 0.3f, 0.5f, 0))
.set(Bone.LEFT_ARM, new BoneTransform(0, drop, back, armPitch, 0, -0.3f, 0.5f, 0))
// Knees fold back and a little inwards, so the thighs splay out and the feet come in.
.set(Bone.RIGHT_LEG, new BoneTransform(0, drop, back, thighPitch, 0, splay, knee,
(float) Math.PI - 0.35f))
.set(Bone.LEFT_LEG, new BoneTransform(0, drop, back, thighPitch, 0, -splay, knee,
(float) Math.PI + 0.35f))
.build();
}
private static Animation of(float length, boolean loops, Function<Float, Pose> sampler) {
return new Animation() {
@Override