Bend limbs along a single axis with a Bézier spine

- core: Bezier (any degree, De Casteljau), LimbBend (cubic spine through
  the rigid upper/joint/lower shape, one ring per texture row spaced by
  arc length, rigid rings so pieces share vertices) and QuadSplitter
  (cuts faces at the rings, interpolating UVs).
- fabric: ModelPartMixin draws parts that have a bend with
  BentCubeRenderer, which deforms vanilla's own cubes. Overlays and armor
  use the limb's spine so they stay attached. Bends are reset every
  setupAnim because models are shared between entities.
- testmod: flap and squat animations, and a client gametest that
  screenshots each animation from the front and side
  (runClientGameTest). The tester runs with the full Fabric API because
  single modules don't bring their version-specific dependencies.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-09-26 19:43:57 +02:00
co-authored by claude
parent d5af8d65c2
commit 8bac5eef64
18 changed files with 881 additions and 23 deletions
@@ -0,0 +1,51 @@
package org.saturnclient.emotes.core.bend;
import java.util.List;
/** A Bézier curve of any degree, evaluated with De Casteljau's algorithm. */
public final class Bezier {
private final Vec3[] points;
public Bezier(List<Vec3> controlPoints) {
if (controlPoints.size() < 2) {
throw new IllegalArgumentException("A Bézier curve needs at least two control points");
}
this.points = controlPoints.toArray(Vec3[]::new);
}
public static Bezier of(Vec3... controlPoints) {
return new Bezier(List.of(controlPoints));
}
public int degree() {
return points.length - 1;
}
public List<Vec3> controlPoints() {
return List.of(points);
}
/** The point at {@code t} in [0, 1]. */
public Vec3 point(float t) {
return deCasteljau(points, t);
}
/** The first derivative at {@code t}: the tangent, scaled by the curve's speed. */
public Vec3 derivative(float t) {
Vec3[] hodograph = new Vec3[points.length - 1];
for (int i = 0; i < hodograph.length; i++) {
hodograph[i] = points[i + 1].sub(points[i]).scale(degree());
}
return deCasteljau(hodograph, t);
}
private static Vec3 deCasteljau(Vec3[] controlPoints, float t) {
Vec3[] work = controlPoints.clone();
for (int level = work.length - 1; level > 0; level--) {
for (int i = 0; i < level; i++) {
work[i] = work[i].lerp(work[i + 1], t);
}
}
return work[0];
}
}
@@ -0,0 +1,195 @@
package org.saturnclient.emotes.core.bend;
/**
* Bends one limb along a single axis, the way an elbow or knee folds.
*
* <p>Coordinates are the limb's model space in pixels: the limb hangs along +Y from {@code start}
* to {@code end}, and its front faces -Z. At rest, the spine runs down the limb's center (x = z = 0).
* When bent, the spine follows a cubic Bézier curve whose control polygon is the rigid "upper half,
* joint, lower half" shape, so the upper and lower halves stay nearly straight and the joint curves.
*
* <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.
*/
public final class LimbBend {
/** Samples used to measure the curve's arc length. */
private static final int ARC_SAMPLES = 64;
private final float start;
private final float length;
private final int segments;
private final float angle;
/** Unit bend direction D in the XZ plane, and the rotation axis n = Y × D. */
private final float dirX, dirZ;
private final float axisX, axisZ;
/** Ring centers and their rotation around n, for rings 0..segments. */
private final float[] ringX, ringY, ringZ, ringAngle;
/**
* @param start top of the limb (its pivot end), along Y
* @param end bottom of the limb, along Y
* @param angle how far the lower half folds, in radians
* @param axis direction the limb folds towards, as an angle around the limb: 0 folds to the
* front (-Z) and PI/2 folds to +X
* @param sharpness 0 for a long, round curve (a quadratic Bézier) up to 1 for a tight joint
* @param segments number of pieces the limb is divided into
*/
public LimbBend(float start, float end, float angle, float axis, float sharpness, int segments) {
if (end <= start) {
throw new IllegalArgumentException("end must be below start");
}
if (segments < 1) {
throw new IllegalArgumentException("segments must be at least 1");
}
this.start = start;
this.length = end - start;
this.segments = segments;
this.angle = angle;
this.dirX = (float) Math.sin(axis);
this.dirZ = (float) -Math.cos(axis);
// n = Y × D = (0, 1, 0) × (dirX, 0, dirZ)
this.axisX = dirZ;
this.axisZ = -dirX;
ringX = new float[segments + 1];
ringY = new float[segments + 1];
ringZ = new float[segments + 1];
ringAngle = new float[segments + 1];
if (isStraight()) {
for (int i = 0; i <= segments; i++) {
ringY[i] = start + length * i / segments;
}
return;
}
buildRings(spine(Math.max(0, Math.min(1, sharpness))));
}
/** Whether the limb is (close enough to) unbent. Straight limbs can be drawn as they are. */
public boolean isStraight() {
return Math.abs(angle) < 1e-4f;
}
public int segments() {
return segments;
}
/** Y positions where the limb is cut into pieces, not counting its ends. */
public float[] cuts() {
float[] cuts = new float[segments - 1];
for (int i = 1; i < segments; i++) {
cuts[i - 1] = start + length * i / segments;
}
return cuts;
}
private Bezier spine(float sharpness) {
float half = length / 2;
Vec3 top = new Vec3(0, start, 0);
Vec3 joint = new Vec3(0, start + half, 0);
Vec3 lower = new Vec3(dirX * (float) Math.sin(angle), (float) Math.cos(angle), dirZ * (float) Math.sin(angle));
Vec3 bottom = joint.add(lower.scale(half));
// At sharpness 0 this is the quadratic (top, joint, bottom) raised to a cubic.
float pull = 2f / 3 + sharpness / 3;
return Bezier.of(top, top.lerp(joint, pull), bottom.lerp(joint, pull), bottom);
}
private void buildRings(Bezier curve) {
// Arc length table, so rings can be spaced evenly along the curve.
float[] ts = new float[ARC_SAMPLES + 1];
float[] lengths = new float[ARC_SAMPLES + 1];
Vec3 previous = curve.point(0);
for (int i = 1; i <= ARC_SAMPLES; i++) {
ts[i] = (float) i / ARC_SAMPLES;
Vec3 point = curve.point(ts[i]);
lengths[i] = lengths[i - 1] + point.sub(previous).length();
previous = point;
}
// The curve cuts the corner, so it's shorter than the limb. Scale it about the top to fit.
float scale = length / lengths[ARC_SAMPLES];
Vec3 top = curve.point(0);
Vec3 dir = new Vec3(dirX, 0, dirZ);
int sample = 0;
for (int i = 0; i <= segments; i++) {
float target = lengths[ARC_SAMPLES] * i / segments;
while (sample < ARC_SAMPLES - 1 && lengths[sample + 1] < target) {
sample++;
}
float span = lengths[sample + 1] - lengths[sample];
float f = span > 0 ? (target - lengths[sample]) / span : 0;
float t = ts[sample] + (ts[sample + 1] - ts[sample]) * f;
Vec3 center = top.add(curve.point(t).sub(top).scale(scale));
Vec3 tangent = curve.derivative(t);
ringX[i] = center.x();
ringY[i] = center.y();
ringZ[i] = center.z();
ringAngle[i] = (float) Math.atan2(tangent.dot(dir), tangent.y());
}
}
/**
* Moves a point from the straight limb onto the bent one. Points above or below the limb follow
* the end rings' tangents, so overlay layers and armor that stick out past the limb stay attached.
*
* @param out receives the bent position as {x, y, z}
*/
public void bendPoint(float x, float y, float z, float[] out) {
float along = (y - start) / length * segments;
int ring;
float f;
float extra = 0;
if (along <= 0) {
ring = 0;
f = 0;
extra = y - start;
} else if (along >= segments) {
ring = segments - 1;
f = 1;
extra = y - (start + length);
} else {
ring = Math.min((int) along, segments - 1);
f = along - ring;
}
float cx = lerp(ringX[ring], ringX[ring + 1], f);
float cy = lerp(ringY[ring], ringY[ring + 1], f);
float cz = lerp(ringZ[ring], ringZ[ring + 1], f);
float a = lerp(ringAngle[ring], ringAngle[ring + 1], f);
// The cross-section offset (x, extra, z) rotates with the ring. Past the ends, "extra"
// carries on along the end tangent.
rotate(x, extra, z, a, out);
out[0] += cx;
out[1] += cy;
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));
int ring = Math.min((int) along, segments - 1);
rotate(nx, ny, nz, lerp(ringAngle[ring], ringAngle[ring + 1], along - ring), out);
}
/** Rodrigues' rotation of v around the bend axis n by {@code a}. */
private void rotate(float x, float y, float z, float a, float[] out) {
float cos = (float) Math.cos(a);
float sin = (float) Math.sin(a);
// n = (axisX, 0, axisZ)
float crossX = -axisZ * y;
float crossY = axisZ * x - axisX * z;
float crossZ = axisX * y;
float dot = axisX * x + axisZ * z;
out[0] = x * cos + crossX * sin + axisX * dot * (1 - cos);
out[1] = y * cos + crossY * sin;
out[2] = z * cos + crossZ * sin + axisZ * dot * (1 - cos);
}
private static float lerp(float a, float b, float t) {
return a + (b - a) * t;
}
}
@@ -0,0 +1,71 @@
package org.saturnclient.emotes.core.bend;
import java.util.ArrayList;
import java.util.List;
/**
* Cuts the faces of a box into horizontal strips so they can bend.
*
* <p>A quad is four vertices of {x, y, z, u, v}. Faces that run along Y (the sides of a limb) are cut
* at every Y in {@code cuts}, and each strip's UVs are interpolated along the face, so the strips
* together cover the same texture as the original face. Flat faces (the limb's ends) are kept as they
* are. Strips keep the original winding.
*/
public final class QuadSplitter {
private static final float EPSILON = 1e-4f;
private QuadSplitter() {
}
public static List<float[][]> split(float[][] quad, float[] cuts) {
float minY = Float.MAX_VALUE;
float maxY = -Float.MAX_VALUE;
for (float[] vertex : quad) {
minY = Math.min(minY, vertex[1]);
maxY = Math.max(maxY, vertex[1]);
}
if (maxY - minY < EPSILON) {
return List.<float[][]>of(quad);
}
// Each vertex's partner is its neighbour at the other end of the face's vertical edge.
int[] partner = new int[4];
for (int i = 0; i < 4; i++) {
int next = (i + 1) % 4;
int prev = (i + 3) % 4;
partner[i] = Math.abs(quad[next][1] - quad[i][1]) > EPSILON ? next : prev;
}
List<Float> bounds = new ArrayList<>();
bounds.add(minY);
for (float cut : cuts) {
if (cut > minY + EPSILON && cut < maxY - EPSILON) {
bounds.add(cut);
}
}
bounds.add(maxY);
List<float[][]> strips = new ArrayList<>(bounds.size() - 1);
for (int s = 0; s + 1 < bounds.size(); s++) {
float top = bounds.get(s);
float bottom = bounds.get(s + 1);
float[][] strip = new float[4][];
for (int i = 0; i < 4; i++) {
float[] from = quad[i];
float[] to = quad[partner[i]];
float target = from[1] < to[1] ? top : bottom;
strip[i] = lerp(from, to, (target - from[1]) / (to[1] - from[1]));
}
strips.add(strip);
}
return strips;
}
private static float[] lerp(float[] a, float[] b, float t) {
float[] out = new float[a.length];
for (int i = 0; i < a.length; i++) {
out[i] = a[i] + (b[i] - a[i]) * t;
}
return out;
}
}
@@ -0,0 +1,39 @@
package org.saturnclient.emotes.core.bend;
/** A small immutable 3D vector, in model pixels. */
public record Vec3(float x, float y, float z) {
public static final Vec3 ZERO = new Vec3(0, 0, 0);
public Vec3 add(Vec3 o) {
return new Vec3(x + o.x, y + o.y, z + o.z);
}
public Vec3 sub(Vec3 o) {
return new Vec3(x - o.x, y - o.y, z - o.z);
}
public Vec3 scale(float s) {
return new Vec3(x * s, y * s, z * s);
}
public Vec3 lerp(Vec3 o, float t) {
return new Vec3(x + (o.x - x) * t, y + (o.y - y) * t, z + (o.z - z) * t);
}
public float dot(Vec3 o) {
return x * o.x + y * o.y + z * o.z;
}
public Vec3 cross(Vec3 o) {
return new Vec3(y * o.z - z * o.y, z * o.x - x * o.z, x * o.y - y * o.x);
}
public float length() {
return (float) Math.sqrt(dot(this));
}
public Vec3 normalize() {
float length = length();
return length < 1e-7f ? ZERO : scale(1 / length);
}
}
@@ -0,0 +1,47 @@
package org.saturnclient.emotes.core.bend;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;
class BezierTest {
private static void assertVec(Vec3 expected, Vec3 actual) {
assertEquals(expected.x(), actual.x(), 1e-5);
assertEquals(expected.y(), actual.y(), 1e-5);
assertEquals(expected.z(), actual.z(), 1e-5);
}
@Test
void passesThroughEndpoints() {
Bezier curve = Bezier.of(new Vec3(0, 0, 0), new Vec3(1, 2, 0), new Vec3(3, 2, 1), new Vec3(4, 0, 2));
assertVec(new Vec3(0, 0, 0), curve.point(0));
assertVec(new Vec3(4, 0, 2), curve.point(1));
}
@Test
void quadraticMidpoint() {
Bezier curve = Bezier.of(new Vec3(0, 0, 0), new Vec3(1, 2, 0), new Vec3(2, 0, 0));
// B(0.5) = 0.25 P0 + 0.5 P1 + 0.25 P2
assertVec(new Vec3(1, 1, 0), curve.point(0.5f));
}
@Test
void endTangentsFollowControlPolygon() {
Bezier curve = Bezier.of(new Vec3(0, 0, 0), new Vec3(0, 1, 0), new Vec3(1, 1, 0), new Vec3(2, 1, 0));
// B'(0) = n (P1 - P0), B'(1) = n (P3 - P2)
assertVec(new Vec3(0, 3, 0), curve.derivative(0));
assertVec(new Vec3(3, 0, 0), curve.derivative(1));
}
@Test
void derivativeMatchesFiniteDifference() {
Bezier curve = Bezier.of(new Vec3(0, 0, 0), new Vec3(1, 3, -1), new Vec3(2, -1, 4), new Vec3(5, 2, 0));
float t = 0.37f;
float h = 1e-3f;
Vec3 numeric = curve.point(t + h).sub(curve.point(t - h)).scale(1 / (2 * h));
Vec3 exact = curve.derivative(t);
assertEquals(exact.x(), numeric.x(), 1e-2);
assertEquals(exact.y(), numeric.y(), 1e-2);
assertEquals(exact.z(), numeric.z(), 1e-2);
}
}
@@ -0,0 +1,113 @@
package org.saturnclient.emotes.core.bend;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
class LimbBendTest {
private static final float HALF_PI = (float) (Math.PI / 2);
/** An arm: pivot at the shoulder, box from y = -2 to 10. */
private static LimbBend arm(float angle, float axis) {
return new LimbBend(-2, 10, angle, axis, 0.5f, 12);
}
private static float[] bend(LimbBend bend, float x, float y, float z) {
float[] out = new float[3];
bend.bendPoint(x, y, z, out);
return out;
}
private static float distance(float[] a, float[] b) {
float dx = a[0] - b[0], dy = a[1] - b[1], dz = a[2] - b[2];
return (float) Math.sqrt(dx * dx + dy * dy + dz * dz);
}
@Test
void zeroBendLeavesPointsInPlace() {
LimbBend straight = arm(0, 0);
assertTrue(straight.isStraight());
assertArrayEquals(new float[] { 1.5f, 7, -2 }, bend(straight, 1.5f, 7, -2), 1e-5f);
}
@Test
void cutsDivideTheLimbEvenly() {
float[] cuts = arm(1, 0).cuts();
assertEquals(11, cuts.length);
assertEquals(-1, cuts[0], 1e-5);
assertEquals(9, cuts[10], 1e-5);
}
@Test
void topStaysAttachedToThePivot() {
LimbBend bend = arm(HALF_PI, 0);
assertArrayEquals(new float[] { 2, -2, 2 }, bend(bend, 2, -2, 2), 1e-4f);
}
@Test
void spineKeepsItsLength() {
LimbBend bend = arm(HALF_PI, 0);
float total = 0;
float[] previous = bend(bend, 0, -2, 0);
for (int i = 1; i <= 12; i++) {
float[] point = bend(bend, 0, -2 + i, 0);
total += distance(previous, point);
previous = point;
}
// Rings are chords of the curve, so they come out a hair shorter than its arc.
assertEquals(12, total, 0.05);
}
@Test
void forwardBendMovesTheHandForward() {
// Axis 0 folds towards -Z, the front of the model.
float[] hand = bend(arm(HALF_PI, 0), 0, 10, 0);
assertTrue(hand[2] < -4, "hand should be in front, z = " + hand[2]);
assertEquals(0, hand[0], 1e-4);
}
@Test
void axisTurnsTheBendDirection() {
float[] hand = bend(arm(HALF_PI, HALF_PI), 0, 10, 0);
assertTrue(hand[0] > 4, "hand should be towards +X, x = " + hand[0]);
assertEquals(0, hand[2], 1e-4);
}
@Test
void endPointsFollowTheLowerHalf() {
// Past the bottom of the limb, points carry on along the forearm, which points forward at 90°.
LimbBend bend = arm(HALF_PI, 0);
float[] bottom = bend(bend, 0, 10, 0);
float[] below = bend(bend, 0, 11, 0);
assertEquals(0, below[1] - bottom[1], 0.05);
assertEquals(-1, below[2] - bottom[2], 0.05);
}
@Test
void ringsMoveRigidly() {
// Every point on one ring keeps its distance from the ring's center.
LimbBend bend = arm(1.2f, 0.7f);
float[] center = bend(bend, 0, 4, 0);
float[] corner = bend(bend, 2, 4, -2);
assertEquals(Math.sqrt(8), distance(center, corner), 1e-4);
}
@Test
void neighbouringPiecesShareVertices() {
// A vertex on a cut is reached the same way from the piece above and the piece below.
LimbBend bend = arm(2, 0);
float[] fromAbove = bend(bend, 2, 3.99999f, 2);
float[] fromBelow = bend(bend, 2, 4.00001f, 2);
assertEquals(0, distance(fromAbove, fromBelow), 1e-3);
}
@Test
void normalsRotateWithTheLimb() {
// The front face's normal (-Z) points up (-Y) on the forearm after a 90° forward bend.
float[] normal = new float[3];
arm(HALF_PI, 0).bendDirection(10, 0, 0, -1, normal);
assertArrayEquals(new float[] { 0, -1, 0 }, normal, 1e-3f);
}
}
@@ -0,0 +1,67 @@
package org.saturnclient.emotes.core.bend;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import java.util.List;
import org.junit.jupiter.api.Test;
class QuadSplitterTest {
// Front face of a 4 x 12 limb from y = 0 to 12, texture rows 20 to 32.
private static final float[][] FRONT = {
{ 2, 0, -2, 8, 20 },
{ -2, 0, -2, 4, 20 },
{ -2, 12, -2, 4, 32 },
{ 2, 12, -2, 8, 32 },
};
private static final float[] CUTS = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 };
@Test
void splitsSideFacesIntoOneStripPerSegment() {
List<float[][]> strips = QuadSplitter.split(FRONT, CUTS);
assertEquals(12, strips.size());
assertArrayEquals(new float[] { 2, 5, -2, 8, 25 }, strips.get(5)[0], 1e-5f);
assertArrayEquals(new float[] { -2, 6, -2, 4, 26 }, strips.get(5)[2], 1e-5f);
}
@Test
void stripsCoverTheFaceWithoutGaps() {
List<float[][]> strips = QuadSplitter.split(FRONT, CUTS);
for (int i = 0; i + 1 < strips.size(); i++) {
// The bottom edge of one strip is the top edge of the next.
assertArrayEquals(strips.get(i)[3], strips.get(i + 1)[0], 1e-5f);
assertArrayEquals(strips.get(i)[2], strips.get(i + 1)[1], 1e-5f);
}
}
@Test
void keepsFlatFaces() {
float[][] top = {
{ 2, 0, -2, 4, 16 },
{ -2, 0, -2, 8, 16 },
{ -2, 0, 2, 8, 20 },
{ 2, 0, 2, 4, 20 },
};
List<float[][]> strips = QuadSplitter.split(top, CUTS);
assertEquals(1, strips.size());
assertSame(top, strips.get(0));
}
@Test
void overlaysThatStickOutGetShorterEndStrips() {
// A sleeve inflated by 0.25 runs from -0.25 to 12.25.
float[][] sleeve = {
{ 2.25f, -0.25f, -2.25f, 48, 36 },
{ -2.25f, -0.25f, -2.25f, 44, 36 },
{ -2.25f, 12.25f, -2.25f, 44, 48 },
{ 2.25f, 12.25f, -2.25f, 48, 48 },
};
List<float[][]> strips = QuadSplitter.split(sleeve, CUTS);
assertEquals(12, strips.size());
assertEquals(-0.25f, strips.get(0)[0][1], 1e-5);
assertEquals(1, strips.get(0)[3][1], 1e-5);
}
}