From 8bac5eef64bdf03070a4f41e1e7114d58a9ff913 Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Sat, 26 Sep 2026 19:43:57 +0200 Subject: [PATCH 1/3] =?UTF-8?q?Bend=20limbs=20along=20a=20single=20axis=20?= =?UTF-8?q?with=20a=20B=C3=A9zier=20spine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 31 ++- build.gradle.kts | 28 ++- .../saturnclient/emotes/core/bend/Bezier.java | 51 +++++ .../emotes/core/bend/LimbBend.java | 195 ++++++++++++++++++ .../emotes/core/bend/QuadSplitter.java | 71 +++++++ .../saturnclient/emotes/core/bend/Vec3.java | 39 ++++ .../emotes/core/bend/BezierTest.java | 47 +++++ .../emotes/core/bend/LimbBendTest.java | 113 ++++++++++ .../emotes/core/bend/QuadSplitterTest.java | 67 ++++++ .../emotes/fabric/BentCubeRenderer.java | 66 ++++++ .../emotes/fabric/PoseApplier.java | 34 ++- .../emotes/fabric/access/BendablePart.java | 13 ++ .../fabric/mixin/HumanoidModelMixin.java | 7 +- .../emotes/fabric/mixin/ModelPartMixin.java | 60 ++++++ src/main/resources/saturn_emotes.mixins.json | 3 +- .../emotes/testmod/EmotesGameTest.java | 56 +++++ .../emotes/testmod/TestAnimations.java | 20 +- src/testmod/resources/fabric.mod.json | 3 +- 18 files changed, 881 insertions(+), 23 deletions(-) create mode 100644 core/src/main/java/org/saturnclient/emotes/core/bend/Bezier.java create mode 100644 core/src/main/java/org/saturnclient/emotes/core/bend/LimbBend.java create mode 100644 core/src/main/java/org/saturnclient/emotes/core/bend/QuadSplitter.java create mode 100644 core/src/main/java/org/saturnclient/emotes/core/bend/Vec3.java create mode 100644 core/src/test/java/org/saturnclient/emotes/core/bend/BezierTest.java create mode 100644 core/src/test/java/org/saturnclient/emotes/core/bend/LimbBendTest.java create mode 100644 core/src/test/java/org/saturnclient/emotes/core/bend/QuadSplitterTest.java create mode 100644 src/main/java/org/saturnclient/emotes/fabric/BentCubeRenderer.java create mode 100644 src/main/java/org/saturnclient/emotes/fabric/access/BendablePart.java create mode 100644 src/main/java/org/saturnclient/emotes/fabric/mixin/ModelPartMixin.java create mode 100644 src/testmod/java/org/saturnclient/emotes/testmod/EmotesGameTest.java diff --git a/README.md b/README.md index 233307a..42b8e63 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,9 @@ A player emote library for Saturn Client, with limb bending. version with [Stonecutter](https://stonecutter.kikugie.dev/) and published as `org.saturnclient:emotes-fabric:+`, with `core` bundled inside the jar. - `src/testmod/`: a dev-only tester mod that is never published. It adds `/emotes play `, - `/emotes stop` and `/emotes info`. Press F5 to see yourself. + `/emotes stop` and `/emotes info`. Press F5 to see yourself. It also has a Fabric client gametest + that creates a world and screenshots each animation from the front and side, into + `versions//build/run/clientGameTest/screenshots/`. Supported versions: 1.21.4 to 1.21.11, the same range as Saturn Client. Each version's Fabric API version is set in `stonecutter.properties.toml`. The code uses Mojang mappings. @@ -22,22 +24,41 @@ version is set in `stonecutter.properties.toml`. The code uses Mojang mappings. and the armor layers both go through this method, so armor follows the pose. Both hook points exist unchanged in every supported version. Version-specific code goes behind -Stonecutter comments: +Stonecutter comments, like the vertex accessors in `BentCubeRenderer`: ```java //? if >=1.21.9 { -import net.minecraft.client.renderer.entity.state.AvatarRenderState; +return new float[] { vertex.x(), vertex.y(), vertex.z(), vertex.u(), vertex.v() }; //?} else -/*import net.minecraft.client.renderer.entity.state.PlayerRenderState;*/ +/*return new float[] { vertex.pos().x(), vertex.pos().y(), vertex.pos().z(), vertex.u(), vertex.v() };*/ ``` -Bending (`BoneTransform.bend` / `bendAxis`) is part of the model but isn't rendered yet. +## Bending + +Each limb bends along a single axis (`BoneTransform.bend`, `bendAxis`). It works like the cloak +renderer: build a spine, put a ring of shared vertices along it, and draw the faces between the rings. + +- `LimbBend` (core) runs the spine along a cubic Bézier (`Bezier`). The control points are the rigid + "upper half, joint, lower half" shape, and `sharpness` goes from a round curve (0) to a tight joint + (1). The spine is cut into one ring per texture row (12 per limb), spaced evenly by arc length, so + the limb keeps its length. Each ring moves its cross-section rigidly, so neighbouring pieces share + vertices and the mesh stays closed. +- `QuadSplitter` (core) cuts each vanilla face into strips at the rings and splits its UVs to match. + The texture is unchanged apart from the cuts. +- `ModelPartMixin` swaps in `BentCubeRenderer` for any part with a bend. That renderer deforms + vanilla's own cubes, so slim arms, sleeves, pants and armor all work. Overlays and armor use the + 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. ## Commands ```sh ./gradlew :core:test # core unit tests ./gradlew :1.21.11:runTestmodClient # launch the game with the tester (run dir: ./run) +./gradlew :1.21.11:runClientGameTest # screenshot each test animation, then quit ./gradlew :1.21.4:compileTestmodJava # quick compile check for one version ./gradlew buildAll # every version's jar → build/libs// GITEA_TOKEN=... ./gradlew publish # publish core + every version to Gitea diff --git a/build.gradle.kts b/build.gradle.kts index c5c1bfa..bd816cf 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -33,6 +33,13 @@ loom { options.put("mark-corresponding-synthetics", "1") // Names lambdas, useful for mixins } + runConfigs.all { + preferGradleTask = true + generateRunConfig = true + runDirectory = rootProject.file("run") // Shared by every version + jvmArguments.add("-Dmixin.debug.export=true") + } + runs { // ./gradlew ::runTestmodClient launches the game with the tester mod loaded. register("testmodClient") { @@ -40,13 +47,15 @@ loom { name = "Testmod Client" source(testmod) } - } - runConfigs.all { - preferGradleTask = true - generateRunConfig = true - runDirectory = rootProject.file("run") // Shared by every version - jvmArguments.add("-Dmixin.debug.export=true") + // ./gradlew ::runClientGameTest screenshots the test animations, then quits. + register("clientGameTest") { + client() + name = "Client Game Test" + source(testmod) + vmArg("-Dfabric.client.gametest") + runDir = "build/run/clientGameTest" + } } } @@ -65,7 +74,12 @@ dependencies { api(project(":core")) include(project(":core")) - "modTestmodImplementation"(fabricApi.module("fabric-command-api-v2", sc.properties["deps.fabric_api"])) + for (module in listOf("fabric-command-api-v2", "fabric-client-gametest-api-v1")) { + "modTestmodImplementation"(fabricApi.module(module, sc.properties["deps.fabric_api"])) + } + // Single modules don't bring their Fabric API dependencies (which differ between versions), + // so the tester runs with all of Fabric API. + "modTestmodRuntimeOnly"("net.fabricmc.fabric-api:fabric-api:${sc.properties.get("deps.fabric_api")}") } java { diff --git a/core/src/main/java/org/saturnclient/emotes/core/bend/Bezier.java b/core/src/main/java/org/saturnclient/emotes/core/bend/Bezier.java new file mode 100644 index 0000000..1d15f82 --- /dev/null +++ b/core/src/main/java/org/saturnclient/emotes/core/bend/Bezier.java @@ -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 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 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]; + } +} 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 new file mode 100644 index 0000000..c0f9422 --- /dev/null +++ b/core/src/main/java/org/saturnclient/emotes/core/bend/LimbBend.java @@ -0,0 +1,195 @@ +package org.saturnclient.emotes.core.bend; + +/** + * Bends one limb along a single axis, the way an elbow or knee folds. + * + *

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. + * + *

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; + } +} diff --git a/core/src/main/java/org/saturnclient/emotes/core/bend/QuadSplitter.java b/core/src/main/java/org/saturnclient/emotes/core/bend/QuadSplitter.java new file mode 100644 index 0000000..97cdc3b --- /dev/null +++ b/core/src/main/java/org/saturnclient/emotes/core/bend/QuadSplitter.java @@ -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. + * + *

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 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.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 bounds = new ArrayList<>(); + bounds.add(minY); + for (float cut : cuts) { + if (cut > minY + EPSILON && cut < maxY - EPSILON) { + bounds.add(cut); + } + } + bounds.add(maxY); + + List 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; + } +} diff --git a/core/src/main/java/org/saturnclient/emotes/core/bend/Vec3.java b/core/src/main/java/org/saturnclient/emotes/core/bend/Vec3.java new file mode 100644 index 0000000..319e20f --- /dev/null +++ b/core/src/main/java/org/saturnclient/emotes/core/bend/Vec3.java @@ -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); + } +} diff --git a/core/src/test/java/org/saturnclient/emotes/core/bend/BezierTest.java b/core/src/test/java/org/saturnclient/emotes/core/bend/BezierTest.java new file mode 100644 index 0000000..1806697 --- /dev/null +++ b/core/src/test/java/org/saturnclient/emotes/core/bend/BezierTest.java @@ -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); + } +} 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 new file mode 100644 index 0000000..1be7596 --- /dev/null +++ b/core/src/test/java/org/saturnclient/emotes/core/bend/LimbBendTest.java @@ -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); + } +} diff --git a/core/src/test/java/org/saturnclient/emotes/core/bend/QuadSplitterTest.java b/core/src/test/java/org/saturnclient/emotes/core/bend/QuadSplitterTest.java new file mode 100644 index 0000000..8e55498 --- /dev/null +++ b/core/src/test/java/org/saturnclient/emotes/core/bend/QuadSplitterTest.java @@ -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 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 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 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 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); + } +} diff --git a/src/main/java/org/saturnclient/emotes/fabric/BentCubeRenderer.java b/src/main/java/org/saturnclient/emotes/fabric/BentCubeRenderer.java new file mode 100644 index 0000000..35d07a9 --- /dev/null +++ b/src/main/java/org/saturnclient/emotes/fabric/BentCubeRenderer.java @@ -0,0 +1,66 @@ +package org.saturnclient.emotes.fabric; + +import java.util.List; + +import org.joml.Matrix4f; +import org.joml.Vector3f; +import org.saturnclient.emotes.core.bend.LimbBend; +import org.saturnclient.emotes.core.bend.QuadSplitter; + +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; + +import net.minecraft.client.model.geom.ModelPart; + +/** + * Draws a part's cubes bent. Each vanilla face is cut into strips at the bend's rings (UVs split with + * it), and every vertex is moved onto the bent limb. Vertices that were shared stay shared, so the + * mesh has no gaps, and the texture is the same as vanilla apart from the cuts. + */ +public final class BentCubeRenderer { + private BentCubeRenderer() { + } + + public static void render(List cubes, LimbBend bend, PoseStack.Pose pose, + VertexConsumer consumer, int light, int overlay, int color) { + Matrix4f matrix = pose.pose(); + float[] cuts = bend.cuts(); + float[] bent = new float[3]; + Vector3f position = new Vector3f(); + Vector3f normal = new Vector3f(); + + for (ModelPart.Cube cube : cubes) { + for (ModelPart.Polygon polygon : cube.polygons) { + ModelPart.Vertex[] vertices = polygon.vertices(); + float[][] quad = new float[vertices.length][]; + for (int i = 0; i < vertices.length; i++) { + quad[i] = vertex(vertices[i]); + } + + for (float[][] strip : QuadSplitter.split(quad, cuts)) { + // The strip's normal turns with the ring at its middle. End caps are flat, so theirs + // turns with the end they're at. + float stripY = (strip[0][1] + strip[2][1]) / 2; + bend.bendDirection(stripY, polygon.normal().x(), polygon.normal().y(), polygon.normal().z(), + bent); + pose.transformNormal(normal.set(bent[0], bent[1], bent[2]), normal); + + for (float[] vertex : strip) { + bend.bendPoint(vertex[0], vertex[1], vertex[2], bent); + matrix.transformPosition(bent[0] / 16, bent[1] / 16, bent[2] / 16, position); + consumer.addVertex(position.x(), position.y(), position.z(), color, vertex[3], vertex[4], + overlay, light, normal.x(), normal.y(), normal.z()); + } + } + } + } + } + + /** {x, y, z, u, v} in model pixels. */ + private static float[] vertex(ModelPart.Vertex vertex) { + //? if >=1.21.9 { + return new float[] { vertex.x(), vertex.y(), vertex.z(), vertex.u(), vertex.v() }; + //?} else + /*return new float[] { vertex.pos().x(), vertex.pos().y(), vertex.pos().z(), vertex.u(), vertex.v() };*/ + } +} diff --git a/src/main/java/org/saturnclient/emotes/fabric/PoseApplier.java b/src/main/java/org/saturnclient/emotes/fabric/PoseApplier.java index 150024b..4c6e35b 100644 --- a/src/main/java/org/saturnclient/emotes/fabric/PoseApplier.java +++ b/src/main/java/org/saturnclient/emotes/fabric/PoseApplier.java @@ -3,6 +3,8 @@ package org.saturnclient.emotes.fabric; 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.fabric.access.BendablePart; import net.minecraft.client.model.HumanoidModel; import net.minecraft.client.model.geom.ModelPart; @@ -12,12 +14,21 @@ import net.minecraft.client.model.geom.ModelPart; * jacket, pants) are children of the base parts, so they follow automatically. */ public final class PoseApplier { + /** How tightly joints curve, from 0 (round) to 1 (tight). See {@link LimbBend}. */ + private static final float SHARPNESS = 0.5f; + private PoseApplier() { } public static void apply(HumanoidModel model, Pose pose) { - for (var entry : pose.bones().entrySet()) { - apply(part(model, entry.getKey()), entry.getValue()); + for (Bone bone : Bone.values()) { + ModelPart part = part(model, bone); + BoneTransform transform = pose.get(bone).orElse(null); + if (transform != null) { + 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)); } } @@ -40,6 +51,23 @@ public final class PoseApplier { part.xRot = transform.pitch(); part.yRot = transform.yaw(); part.zRot = transform.roll(); - // TODO: bend (transform.bend(), transform.bendAxis()) needs a custom mesh. + } + + private static LimbBend bend(Bone bone, BoneTransform transform) { + if (transform == null || transform.bend() == 0) { + 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. + 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); + }; + } + + /** 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)); } } diff --git a/src/main/java/org/saturnclient/emotes/fabric/access/BendablePart.java b/src/main/java/org/saturnclient/emotes/fabric/access/BendablePart.java new file mode 100644 index 0000000..a1667bc --- /dev/null +++ b/src/main/java/org/saturnclient/emotes/fabric/access/BendablePart.java @@ -0,0 +1,13 @@ +package org.saturnclient.emotes.fabric.access; + +import org.jetbrains.annotations.Nullable; +import org.saturnclient.emotes.core.bend.LimbBend; + +/** Mixed into ModelPart. A part with a bend draws its cubes bent instead of rigid. */ +public interface BendablePart { + @Nullable + LimbBend saturnEmotes$getBend(); + + /** Sets the bend on this part and all its children (overlay layers like sleeves). */ + void saturnEmotes$setBend(@Nullable LimbBend bend); +} diff --git a/src/main/java/org/saturnclient/emotes/fabric/mixin/HumanoidModelMixin.java b/src/main/java/org/saturnclient/emotes/fabric/mixin/HumanoidModelMixin.java index b46bd77..0b8b28f 100644 --- a/src/main/java/org/saturnclient/emotes/fabric/mixin/HumanoidModelMixin.java +++ b/src/main/java/org/saturnclient/emotes/fabric/mixin/HumanoidModelMixin.java @@ -1,6 +1,5 @@ package org.saturnclient.emotes.fabric.mixin; -import org.saturnclient.emotes.core.Pose; import org.saturnclient.emotes.fabric.PoseApplier; import org.saturnclient.emotes.fabric.access.EmotePoseHolder; import org.spongepowered.asm.mixin.Mixin; @@ -21,9 +20,7 @@ public class HumanoidModelMixin { method = "setupAnim(Lnet/minecraft/client/renderer/entity/state/HumanoidRenderState;)V", at = @At("TAIL")) private void saturnEmotes$applyPose(HumanoidRenderState state, CallbackInfo ci) { - Pose pose = ((EmotePoseHolder) state).saturnEmotes$getPose(); - if (!pose.isEmpty()) { - PoseApplier.apply((HumanoidModel) (Object) this, pose); - } + // Always applied, even for an empty pose, so bends left over from another entity get cleared. + PoseApplier.apply((HumanoidModel) (Object) this, ((EmotePoseHolder) state).saturnEmotes$getPose()); } } diff --git a/src/main/java/org/saturnclient/emotes/fabric/mixin/ModelPartMixin.java b/src/main/java/org/saturnclient/emotes/fabric/mixin/ModelPartMixin.java new file mode 100644 index 0000000..b637c92 --- /dev/null +++ b/src/main/java/org/saturnclient/emotes/fabric/mixin/ModelPartMixin.java @@ -0,0 +1,60 @@ +package org.saturnclient.emotes.fabric.mixin; + +import java.util.List; +import java.util.Map; + +import org.jetbrains.annotations.Nullable; +import org.saturnclient.emotes.core.bend.LimbBend; +import org.saturnclient.emotes.fabric.BentCubeRenderer; +import org.saturnclient.emotes.fabric.access.BendablePart; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; + +import net.minecraft.client.model.geom.ModelPart; + +@Mixin(ModelPart.class) +public class ModelPartMixin implements BendablePart { + @Shadow + @Final + private List cubes; + + @Shadow + @Final + private Map children; + + @Unique + private @Nullable LimbBend saturnEmotes$bend; + + @Override + public @Nullable LimbBend saturnEmotes$getBend() { + return saturnEmotes$bend; + } + + @Override + public void saturnEmotes$setBend(@Nullable LimbBend bend) { + if (bend == null && saturnEmotes$bend == null) { + return; + } + saturnEmotes$bend = bend; + for (ModelPart child : children.values()) { + ((BendablePart) (Object) child).saturnEmotes$setBend(bend); + } + } + + @Inject(method = "compile", at = @At("HEAD"), cancellable = true) + private void saturnEmotes$compileBent(PoseStack.Pose pose, VertexConsumer consumer, int light, int overlay, + int color, CallbackInfo ci) { + if (saturnEmotes$bend != null && !saturnEmotes$bend.isStraight()) { + BentCubeRenderer.render(cubes, saturnEmotes$bend, pose, consumer, light, overlay, color); + ci.cancel(); + } + } +} diff --git a/src/main/resources/saturn_emotes.mixins.json b/src/main/resources/saturn_emotes.mixins.json index 75043d3..d9835c0 100644 --- a/src/main/resources/saturn_emotes.mixins.json +++ b/src/main/resources/saturn_emotes.mixins.json @@ -5,7 +5,8 @@ "client": [ "HumanoidModelMixin", "LivingEntityRenderStateMixin", - "LivingEntityRendererMixin" + "LivingEntityRendererMixin", + "ModelPartMixin" ], "injectors": { "defaultRequire": 1 diff --git a/src/testmod/java/org/saturnclient/emotes/testmod/EmotesGameTest.java b/src/testmod/java/org/saturnclient/emotes/testmod/EmotesGameTest.java new file mode 100644 index 0000000..be5794c --- /dev/null +++ b/src/testmod/java/org/saturnclient/emotes/testmod/EmotesGameTest.java @@ -0,0 +1,56 @@ +package org.saturnclient.emotes.testmod; + +import org.saturnclient.emotes.fabric.Emotes; + +import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest; +import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext; +import net.fabricmc.fabric.api.client.gametest.v1.context.TestSingleplayerContext; +import net.minecraft.client.CameraType; + +/** + * Screenshots each test animation at its peak, from the front and from the side. + * 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 }, + }; + + @Override + public void runTest(ClientGameTestContext context) { + try (TestSingleplayerContext singleplayer = context.worldBuilder().create()) { + singleplayer.getClientWorld().waitForChunksRender(); + context.runOnClient(client -> { + client.options.hideGui = true; + client.options.setCameraType(CameraType.THIRD_PERSON_FRONT); + }); + 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); + context.runOnClient(client -> Emotes.stop(client.player.getUUID())); + } + } + } + + /** Turns the body (not the camera) by {@code turn} degrees, then takes a screenshot. */ + private static void screenshot(ClientGameTestContext context, String name, float turn) { + context.runOnClient(client -> { + float body = client.player.getYRot() + turn; + client.player.setYBodyRot(body); + client.player.yBodyRotO = body; + }); + context.takeScreenshot(MINECRAFT + "-" + name); + } +} diff --git a/src/testmod/java/org/saturnclient/emotes/testmod/TestAnimations.java b/src/testmod/java/org/saturnclient/emotes/testmod/TestAnimations.java index b03464d..2b0a951 100644 --- a/src/testmod/java/org/saturnclient/emotes/testmod/TestAnimations.java +++ b/src/testmod/java/org/saturnclient/emotes/testmod/TestAnimations.java @@ -33,13 +33,31 @@ final class TestAnimations { .set(Bone.LEFT_ARM, new BoneTransform(0, drop, 0, 0, 0, 0, 0, 0)) .build(); }), - // Arms forward with elbows folding in and out. Bend isn't rendered yet. + // Arms forward with elbows folding in and out. Tests bending towards the front. "bend", of(40, true, t -> { float bend = HALF_PI * 0.5f * (1 - (float) Math.cos(t / 40f * 2 * Math.PI)); return Pose.builder() .set(Bone.RIGHT_ARM, BoneTransform.rotation(-HALF_PI, 0, 0).withBend(bend, 0)) .set(Bone.LEFT_ARM, BoneTransform.rotation(-HALF_PI, 0, 0).withBend(bend, 0)) .build(); + }), + // Arms out to the sides with elbows folding down. Tests the bend axis. + "flap", of(30, true, t -> { + float bend = HALF_PI * 0.5f * (1 - (float) Math.cos(t / 30f * 2 * Math.PI)); + return Pose.builder() + .set(Bone.RIGHT_ARM, BoneTransform.rotation(0, 0, HALF_PI).withBend(bend, HALF_PI)) + .set(Bone.LEFT_ARM, BoneTransform.rotation(0, 0, -HALF_PI).withBend(bend, -HALF_PI)) + .build(); + }), + // Thighs forward and knees folding back, past 90°. Tests legs, pants and sharp bends. + "squat", of(40, true, t -> { + float depth = 0.5f * (1 - (float) Math.cos(t / 40f * 2 * Math.PI)); + BoneTransform leg = BoneTransform.rotation(-1.2f * depth, 0, 0) + .withBend(2.2f * depth, (float) Math.PI); + return Pose.builder() + .set(Bone.RIGHT_LEG, leg) + .set(Bone.LEFT_LEG, leg) + .build(); })); private TestAnimations() { diff --git a/src/testmod/resources/fabric.mod.json b/src/testmod/resources/fabric.mod.json index 17f19a8..60a352c 100644 --- a/src/testmod/resources/fabric.mod.json +++ b/src/testmod/resources/fabric.mod.json @@ -7,7 +7,8 @@ "license": "Apache-2.0", "environment": "client", "entrypoints": { - "client": ["org.saturnclient.emotes.testmod.EmotesTestmod"] + "client": ["org.saturnclient.emotes.testmod.EmotesTestmod"], + "fabric-client-gametest": ["org.saturnclient.emotes.testmod.EmotesGameTest"] }, "depends": { "saturn_emotes": "*", -- 2.54.0 From 34aba293b6df656fb01b0d5e756da8d47f7d25a0 Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Sat, 26 Sep 2026 19:57:50 +0200 Subject: [PATCH 2/3] 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 -- 2.54.0 From 09cfd0cc1079df3f640a8fd29916ec46932bcbc1 Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Sat, 26 Sep 2026 20:04:08 +0200 Subject: [PATCH 3/3] Arch the twerk's lower back instead of hunching it The torso tips forward around the hips and bends backwards, so the lower back curves in and the chest comes up. The head and arms get the tilt themselves, since they only follow the torso's bend. Co-Authored-By: Claude Opus 5.5 --- .../emotes/testmod/TestAnimations.java | 40 ++++++++++++++----- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/src/testmod/java/org/saturnclient/emotes/testmod/TestAnimations.java b/src/testmod/java/org/saturnclient/emotes/testmod/TestAnimations.java index ac2023b..3203423 100644 --- a/src/testmod/java/org/saturnclient/emotes/testmod/TestAnimations.java +++ b/src/testmod/java/org/saturnclient/emotes/testmod/TestAnimations.java @@ -72,8 +72,9 @@ final class 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. + * Squatting with knees out and hips pushed back, bouncing the hips about 2.5 times a second. The + * torso tips forward around the hips and bends backwards, so the lower back arches in (not a + * hunched back) and the chest comes up. The arch deepens at the top of each bounce. */ private static Pose twerk(float t) { // 0 at the bottom of the bounce, 1 at the top. @@ -82,22 +83,39 @@ final class TestAnimations { 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; + // How far the torso tips forward at the hips, and how much of that the arch takes back by the + // chest. The chest ends up leaning forward by tilt - arch. + float tilt = 1.4f; + float arch = 0.4f + 0.3f * 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; + // The body pivots at the neck, so tipping it forward swings the hips back. Move the pivot so + // the hips stay on the legs. + float cos = (float) Math.cos(tilt); + float sin = (float) Math.sin(tilt); + float bodyY = drop + 12 - 12 * cos; + float bodyZ = back - 12 * sin; + + // The head and arms only turn with the torso's bend, not its rotation, so they get the tilt + // themselves (tilt plus the bend's turn is the chest's lean) and sit where the tipped torso puts + // them: the neck at the body's pivot, the shoulders 2 pixels down it. + float chest = tilt - arch; + float shoulderY = bodyY + 2 * cos - 2; + float shoulderZ = bodyZ + 2 * sin; + // Arms riding on a leaning chest would swing back, so tilt them forward to hang to the knees. + float armPitch = tilt - chest * 1.1f; + // Follow the chest, then look up by half its lean. + float headPitch = tilt - chest * 0.5f; + 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)) + .set(Bone.BODY, new BoneTransform(0, bodyY, bodyZ, tilt, 0, 0, arch, (float) Math.PI)) + .set(Bone.HEAD, new BoneTransform(0, bodyY, bodyZ, headPitch, 0, 0, 0, 0)) + .set(Bone.RIGHT_ARM, new BoneTransform(0, shoulderY, shoulderZ, armPitch, 0, 0.3f, 0.5f, 0)) + .set(Bone.LEFT_ARM, new BoneTransform(0, shoulderY, shoulderZ, 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)) -- 2.54.0