From 8bac5eef64bdf03070a4f41e1e7114d58a9ff913 Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Sat, 26 Sep 2026 19:43:57 +0200 Subject: [PATCH] =?UTF-8?q?Bend=20limbs=20along=20a=20single=20axis=20with?= =?UTF-8?q?=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": "*",