Bend limbs and the torso along a Bézier spine #2

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