Merge pull request 'Bend limbs and the torso along a Bézier spine' (#2) from limb-bending into master

Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
2026-09-26 18:10:27 +00:00
18 changed files with 1125 additions and 24 deletions
+33 -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 version with [Stonecutter](https://stonecutter.kikugie.dev/) and published as
`org.saturnclient:emotes-fabric:<version>+<mc>`, with `core` bundled inside the jar. `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>`, - `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 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. version is set in `stonecutter.properties.toml`. The code uses Mojang mappings.
@@ -22,22 +24,48 @@ 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. 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 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 ```java
//? if >=1.21.9 { //? 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 //?} 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 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 ## Commands
```sh ```sh
./gradlew :core:test # core unit tests ./gradlew :core:test # core unit tests
./gradlew :1.21.11:runTestmodClient # launch the game with the tester (run dir: ./run) ./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 :1.21.4:compileTestmodJava # quick compile check for one version
./gradlew buildAll # every version's jar → build/libs/<version>/ ./gradlew buildAll # every version's jar → build/libs/<version>/
GITEA_TOKEN=... ./gradlew publish # publish core + every version to Gitea 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 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 { runs {
// ./gradlew :<mc>:runTestmodClient launches the game with the tester mod loaded. // ./gradlew :<mc>:runTestmodClient launches the game with the tester mod loaded.
register("testmodClient") { register("testmodClient") {
@@ -40,13 +47,15 @@ loom {
name = "Testmod Client" name = "Testmod Client"
source(testmod) source(testmod)
} }
}
runConfigs.all { // ./gradlew :<mc>:runClientGameTest screenshots the test animations, then quits.
preferGradleTask = true register("clientGameTest") {
generateRunConfig = true client()
runDirectory = rootProject.file("run") // Shared by every version name = "Client Game Test"
jvmArguments.add("-Dmixin.debug.export=true") source(testmod)
vmArg("-Dfabric.client.gametest")
runDir = "build/run/clientGameTest"
}
} }
} }
@@ -65,7 +74,12 @@ dependencies {
api(project(":core")) api(project(":core"))
include(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 { 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,251 @@
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.
*
* <p>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. */
private static final int ARC_SAMPLES = 64;
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. */
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) {
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");
}
if (segments < 1) {
throw new IllegalArgumentException("segments must be at least 1");
}
this.start = start;
this.length = end - start;
this.anchoredAtEnd = anchoredAtEnd;
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) {
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;
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;
}
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);
return lerp(ringAngle[ring], ringAngle[ring + 1], along - ring);
}
/** 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,175 @@
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);
}
/** 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() };
}
}
@@ -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() };*/
}
}
@@ -1,8 +1,13 @@
package org.saturnclient.emotes.fabric; package org.saturnclient.emotes.fabric;
import org.joml.Quaternionf;
import org.joml.Vector3f;
import org.saturnclient.emotes.core.Bone; import org.saturnclient.emotes.core.Bone;
import org.saturnclient.emotes.core.BoneTransform; import org.saturnclient.emotes.core.BoneTransform;
import org.saturnclient.emotes.core.Pose; 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; import net.minecraft.client.model.HumanoidModel;
import net.minecraft.client.model.geom.ModelPart; import net.minecraft.client.model.geom.ModelPart;
@@ -12,12 +17,33 @@ import net.minecraft.client.model.geom.ModelPart;
* jacket, pants) are children of the base parts, so they follow automatically. * jacket, pants) are children of the base parts, so they follow automatically.
*/ */
public final class PoseApplier { 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() { private PoseApplier() {
} }
public static void apply(HumanoidModel<?> model, Pose pose) { public static void apply(HumanoidModel<?> model, Pose pose) {
for (var entry : pose.bones().entrySet()) { LimbBend bodyBend = null;
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.
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);
} }
} }
@@ -40,6 +66,57 @@ public final class PoseApplier {
part.xRot = transform.pitch(); part.xRot = transform.pitch();
part.yRot = transform.yaw(); part.yRot = transform.yaw();
part.zRot = transform.roll(); 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. 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 -> 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);
};
}
/**
* 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);
} }
} }
@@ -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; package org.saturnclient.emotes.fabric.mixin;
import org.saturnclient.emotes.core.Pose;
import org.saturnclient.emotes.fabric.PoseApplier; import org.saturnclient.emotes.fabric.PoseApplier;
import org.saturnclient.emotes.fabric.access.EmotePoseHolder; import org.saturnclient.emotes.fabric.access.EmotePoseHolder;
import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Mixin;
@@ -21,9 +20,7 @@ public class HumanoidModelMixin {
method = "setupAnim(Lnet/minecraft/client/renderer/entity/state/HumanoidRenderState;)V", method = "setupAnim(Lnet/minecraft/client/renderer/entity/state/HumanoidRenderState;)V",
at = @At("TAIL")) at = @At("TAIL"))
private void saturnEmotes$applyPose(HumanoidRenderState state, CallbackInfo ci) { private void saturnEmotes$applyPose(HumanoidRenderState state, CallbackInfo ci) {
Pose pose = ((EmotePoseHolder) state).saturnEmotes$getPose(); // Always applied, even for an empty pose, so bends left over from another entity get cleared.
if (!pose.isEmpty()) { PoseApplier.apply((HumanoidModel<?>) (Object) this, ((EmotePoseHolder) state).saturnEmotes$getPose());
PoseApplier.apply((HumanoidModel<?>) (Object) this, pose);
}
} }
} }
@@ -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": [ "client": [
"HumanoidModelMixin", "HumanoidModelMixin",
"LivingEntityRenderStateMixin", "LivingEntityRenderStateMixin",
"LivingEntityRendererMixin" "LivingEntityRendererMixin",
"ModelPartMixin"
], ],
"injectors": { "injectors": {
"defaultRequire": 1 "defaultRequire": 1
@@ -0,0 +1,64 @@
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 set points, from the front, the side and behind.
* 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, 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()) {
singleplayer.getClientWorld().waitForChunksRender();
context.runOnClient(client -> {
client.options.hideGui = true;
client.options.setCameraType(CameraType.THIRD_PERSON_FRONT);
});
context.waitTicks(20);
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()));
}
}
}
/** 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,18 +33,97 @@ final class TestAnimations {
.set(Bone.LEFT_ARM, new BoneTransform(0, drop, 0, 0, 0, 0, 0, 0)) .set(Bone.LEFT_ARM, new BoneTransform(0, drop, 0, 0, 0, 0, 0, 0))
.build(); .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 -> { "bend", of(40, true, t -> {
float bend = HALF_PI * 0.5f * (1 - (float) Math.cos(t / 40f * 2 * Math.PI)); float bend = HALF_PI * 0.5f * (1 - (float) Math.cos(t / 40f * 2 * Math.PI));
return Pose.builder() return Pose.builder()
.set(Bone.RIGHT_ARM, BoneTransform.rotation(-HALF_PI, 0, 0).withBend(bend, 0)) .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)) .set(Bone.LEFT_ARM, BoneTransform.rotation(-HALF_PI, 0, 0).withBend(bend, 0))
.build(); .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();
}),
// 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() { private TestAnimations() {
} }
/**
* 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.
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;
// 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));
// 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, 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))
.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<Float, Pose> sampler) { private static Animation of(float length, boolean loops, Function<Float, Pose> sampler) {
return new Animation() { return new Animation() {
@Override @Override
+2 -1
View File
@@ -7,7 +7,8 @@
"license": "Apache-2.0", "license": "Apache-2.0",
"environment": "client", "environment": "client",
"entrypoints": { "entrypoints": {
"client": ["org.saturnclient.emotes.testmod.EmotesTestmod"] "client": ["org.saturnclient.emotes.testmod.EmotesTestmod"],
"fabric-client-gametest": ["org.saturnclient.emotes.testmod.EmotesGameTest"]
}, },
"depends": { "depends": {
"saturn_emotes": "*", "saturn_emotes": "*",