From 34aba293b6df656fb01b0d5e756da8d47f7d25a0 Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Sat, 26 Sep 2026 19:57:50 +0200 Subject: [PATCH] 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 --- README.md | 11 +++- .../emotes/core/bend/LimbBend.java | 64 +++++++++++++++++-- .../emotes/core/bend/LimbBendTest.java | 62 ++++++++++++++++++ .../emotes/fabric/PoseApplier.java | 63 ++++++++++++++++-- .../emotes/testmod/EmotesGameTest.java | 34 ++++++---- .../emotes/testmod/TestAnimations.java | 45 ++++++++++++- 6 files changed, 252 insertions(+), 27 deletions(-) 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 ::runClientGameTest}. Screenshots land in * {@code versions//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())); } } diff --git a/src/testmod/java/org/saturnclient/emotes/testmod/TestAnimations.java b/src/testmod/java/org/saturnclient/emotes/testmod/TestAnimations.java index 2b0a951..ac2023b 100644 --- a/src/testmod/java/org/saturnclient/emotes/testmod/TestAnimations.java +++ b/src/testmod/java/org/saturnclient/emotes/testmod/TestAnimations.java @@ -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 sampler) { return new Animation() { @Override