Bend limbs and the torso along a Bézier spine #2
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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": "*",
|
||||
|
||||
Reference in New Issue
Block a user