Bend limbs and the torso along a Bézier spine #2
@@ -50,8 +50,15 @@ renderer: build a spine, put a ring of shared vertices along it, and draw the fa
|
||||
limb's spine, not their own inflated box, so they stay attached.
|
||||
|
||||
Axis 0 folds towards the front (-Z) and PI/2 towards +X, in the limb's own space. Knees fold back,
|
||||
so they use axis PI. The body bends too, but the head and arms don't follow its upper half yet.
|
||||
Held items still attach to the unbent arm.
|
||||
so they use axis PI.
|
||||
|
||||
The torso is anchored at the hips instead of its pivot (`LimbBend.anchoredAtEnd`), so the legs stay
|
||||
attached and the chest leans. The head and arms are siblings of the body in the vanilla model, so
|
||||
`PoseApplier` carries them: their pivots follow the bent torso, and they turn with its ring there
|
||||
(`LimbBend.angleAt`). An arm carried by a forward lean swings back like a diver's, so emotes that want
|
||||
hanging arms tilt them forward by about the lean (see the `twerk` test animation).
|
||||
|
||||
Held items still attach to the unbent arm, and the cape doesn't follow the torso yet.
|
||||
|
||||
## Commands
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@ package org.saturnclient.emotes.core.bend;
|
||||
* <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. */
|
||||
@@ -18,6 +21,8 @@ public final class LimbBend {
|
||||
|
||||
private final float start;
|
||||
private final float length;
|
||||
/** Whether the fixed end is {@code end}. Then everything is mirrored along Y around the middle. */
|
||||
private final boolean anchoredAtEnd;
|
||||
private final int segments;
|
||||
private final float angle;
|
||||
/** Unit bend direction D in the XZ plane, and the rotation axis n = Y × D. */
|
||||
@@ -37,6 +42,20 @@ public final class LimbBend {
|
||||
* @param segments number of pieces the limb is divided into
|
||||
*/
|
||||
public LimbBend(float start, float end, float angle, float axis, float sharpness, int segments) {
|
||||
this(start, end, angle, axis, sharpness, segments, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Like {@link #LimbBend(float, float, float, float, float, int)}, but {@code end} stays in place and
|
||||
* the half towards {@code start} folds. Axis 0 still folds towards the front.
|
||||
*/
|
||||
public static LimbBend anchoredAtEnd(float start, float end, float angle, float axis, float sharpness,
|
||||
int segments) {
|
||||
return new LimbBend(start, end, angle, axis, sharpness, segments, true);
|
||||
}
|
||||
|
||||
private LimbBend(float start, float end, float angle, float axis, float sharpness, int segments,
|
||||
boolean anchoredAtEnd) {
|
||||
if (end <= start) {
|
||||
throw new IllegalArgumentException("end must be below start");
|
||||
}
|
||||
@@ -45,6 +64,7 @@ public final class LimbBend {
|
||||
}
|
||||
this.start = start;
|
||||
this.length = end - start;
|
||||
this.anchoredAtEnd = anchoredAtEnd;
|
||||
this.segments = segments;
|
||||
this.angle = angle;
|
||||
this.dirX = (float) Math.sin(axis);
|
||||
@@ -138,6 +158,43 @@ public final class LimbBend {
|
||||
* @param out receives the bent position as {x, y, z}
|
||||
*/
|
||||
public void bendPoint(float x, float y, float z, float[] out) {
|
||||
if (anchoredAtEnd) {
|
||||
bendPointFromStart(x, mirror(y), z, out);
|
||||
out[1] = mirror(out[1]);
|
||||
} else {
|
||||
bendPointFromStart(x, y, z, out);
|
||||
}
|
||||
}
|
||||
|
||||
/** Rotates a direction (a face normal) the same way {@link #bendPoint} rotates points at {@code atY}. */
|
||||
public void bendDirection(float atY, float nx, float ny, float nz, float[] out) {
|
||||
if (anchoredAtEnd) {
|
||||
rotate(nx, -ny, nz, angleFromStart(mirror(atY)), out);
|
||||
out[1] = -out[1];
|
||||
} else {
|
||||
rotate(nx, ny, nz, angleFromStart(atY), out);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How far the limb has turned at {@code y}, as a rotation around {@link #axis()} by this angle.
|
||||
* Parts attached to the limb at {@code y} (like the head and arms on the torso) turn by this much.
|
||||
*/
|
||||
public float angleAt(float y) {
|
||||
// Mirroring along Y turns a rotation around a horizontal axis the other way.
|
||||
return anchoredAtEnd ? -angleFromStart(mirror(y)) : angleFromStart(y);
|
||||
}
|
||||
|
||||
/** The unit axis the limb turns around. It's horizontal, at right angles to the bend direction. */
|
||||
public Vec3 axis() {
|
||||
return new Vec3(axisX, 0, axisZ);
|
||||
}
|
||||
|
||||
private float mirror(float y) {
|
||||
return 2 * start + length - y;
|
||||
}
|
||||
|
||||
private void bendPointFromStart(float x, float y, float z, float[] out) {
|
||||
float along = (y - start) / length * segments;
|
||||
int ring;
|
||||
float f;
|
||||
@@ -168,11 +225,10 @@ public final class LimbBend {
|
||||
out[2] += cz;
|
||||
}
|
||||
|
||||
/** Rotates a direction (a face normal) the same way {@link #bendPoint} rotates points at {@code atY}. */
|
||||
public void bendDirection(float atY, float nx, float ny, float nz, float[] out) {
|
||||
float along = Math.max(0, Math.min(segments, (atY - start) / length * segments));
|
||||
private float angleFromStart(float y) {
|
||||
float along = Math.max(0, Math.min(segments, (y - start) / length * segments));
|
||||
int ring = Math.min((int) along, segments - 1);
|
||||
rotate(nx, ny, nz, lerp(ringAngle[ring], ringAngle[ring + 1], along - ring), out);
|
||||
return lerp(ringAngle[ring], ringAngle[ring + 1], along - ring);
|
||||
}
|
||||
|
||||
/** Rodrigues' rotation of v around the bend axis n by {@code a}. */
|
||||
|
||||
@@ -110,4 +110,66 @@ class LimbBendTest {
|
||||
arm(HALF_PI, 0).bendDirection(10, 0, 0, -1, normal);
|
||||
assertArrayEquals(new float[] { 0, -1, 0 }, normal, 1e-3f);
|
||||
}
|
||||
|
||||
/** A torso: box from y = 0 (neck) to 12 (hips), anchored at the hips. */
|
||||
private static LimbBend torso(float angle, float axis) {
|
||||
return LimbBend.anchoredAtEnd(0, 12, angle, axis, 0.5f, 12);
|
||||
}
|
||||
|
||||
@Test
|
||||
void torsoKeepsItsHipsInPlace() {
|
||||
assertArrayEquals(new float[] { 3, 12, -1 }, bend(torso(1, 0), 3, 12, -1), 1e-4f);
|
||||
}
|
||||
|
||||
@Test
|
||||
void torsoLeansForward() {
|
||||
// Axis 0 still means forward: the neck moves to the front (-Z) and down (+Y).
|
||||
float[] neck = bend(torso(HALF_PI, 0), 0, 0, 0);
|
||||
assertTrue(neck[2] < -4, "neck should be in front, z = " + neck[2]);
|
||||
assertTrue(neck[1] > 4, "neck should drop, y = " + neck[1]);
|
||||
}
|
||||
|
||||
@Test
|
||||
void torsoKeepsItsLength() {
|
||||
LimbBend bend = torso(HALF_PI, 0);
|
||||
float total = 0;
|
||||
float[] previous = bend(bend, 0, 0, 0);
|
||||
for (int i = 1; i <= 12; i++) {
|
||||
float[] point = bend(bend, 0, i, 0);
|
||||
total += distance(previous, point);
|
||||
previous = point;
|
||||
}
|
||||
assertEquals(12, total, 0.05);
|
||||
}
|
||||
|
||||
@Test
|
||||
void attachedPartsTurnWithTheRing() {
|
||||
// The offset from a ring's center to a point on it turns by angleAt around axis.
|
||||
for (LimbBend bend : new LimbBend[] { arm(1.3f, 0.4f), torso(1.3f, 0.4f) }) {
|
||||
for (float y : new float[] { -2, 0, 4, 10, 12 }) {
|
||||
float[] center = bend(bend, 0, y, 0);
|
||||
float[] point = bend(bend, 1, y, -2);
|
||||
float[] expected = rotate(new float[] { 1, 0, -2 }, bend.axis(), bend.angleAt(y));
|
||||
assertArrayEquals(expected,
|
||||
new float[] { point[0] - center[0], point[1] - center[1], point[2] - center[2] }, 1e-3f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void torsoNormalsTurnWithTheRing() {
|
||||
// A forward lean tips the front face (-Z) of the chest towards the ground (+Y).
|
||||
float[] normal = new float[3];
|
||||
torso(HALF_PI, 0).bendDirection(0, 0, 0, -1, normal);
|
||||
assertArrayEquals(new float[] { 0, 1, 0 }, normal, 1e-3f);
|
||||
}
|
||||
|
||||
/** Rodrigues' rotation of v around the unit axis n. */
|
||||
private static float[] rotate(float[] v, Vec3 n, float angle) {
|
||||
Vec3 vec = new Vec3(v[0], v[1], v[2]);
|
||||
float cos = (float) Math.cos(angle);
|
||||
float sin = (float) Math.sin(angle);
|
||||
Vec3 r = vec.scale(cos).add(n.cross(vec).scale(sin)).add(n.scale(n.dot(vec) * (1 - cos)));
|
||||
return new float[] { r.x(), r.y(), r.z() };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package org.saturnclient.emotes.fabric;
|
||||
|
||||
import org.joml.Quaternionf;
|
||||
import org.joml.Vector3f;
|
||||
import org.saturnclient.emotes.core.Bone;
|
||||
import org.saturnclient.emotes.core.BoneTransform;
|
||||
import org.saturnclient.emotes.core.Pose;
|
||||
import org.saturnclient.emotes.core.bend.LimbBend;
|
||||
import org.saturnclient.emotes.core.bend.Vec3;
|
||||
import org.saturnclient.emotes.fabric.access.BendablePart;
|
||||
|
||||
import net.minecraft.client.model.HumanoidModel;
|
||||
@@ -21,6 +24,7 @@ public final class PoseApplier {
|
||||
}
|
||||
|
||||
public static void apply(HumanoidModel<?> model, Pose pose) {
|
||||
LimbBend bodyBend = null;
|
||||
for (Bone bone : Bone.values()) {
|
||||
ModelPart part = part(model, bone);
|
||||
BoneTransform transform = pose.get(bone).orElse(null);
|
||||
@@ -28,7 +32,18 @@ public final class PoseApplier {
|
||||
apply(part, transform);
|
||||
}
|
||||
// Models are shared between entities, so bends from the last one drawn must be cleared.
|
||||
((BendablePart) (Object) part).saturnEmotes$setBend(bend(bone, transform));
|
||||
LimbBend bend = bend(bone, transform);
|
||||
((BendablePart) (Object) part).saturnEmotes$setBend(bend);
|
||||
if (bone == Bone.BODY) {
|
||||
bodyBend = bend;
|
||||
}
|
||||
}
|
||||
|
||||
// The torso bends from the hips, so whatever hangs off its upper half moves with it.
|
||||
if (bodyBend != null && !bodyBend.isStraight()) {
|
||||
carry(model.body, bodyBend, model.head);
|
||||
carry(model.body, bodyBend, model.rightArm);
|
||||
carry(model.body, bodyBend, model.leftArm);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,16 +73,50 @@ public final class PoseApplier {
|
||||
return null;
|
||||
}
|
||||
// Vanilla player limbs, in each part's own space. Armor uses the same ranges so it bends with
|
||||
// the limb instead of along its own (inflated) box.
|
||||
// the limb instead of along its own (inflated) box. One segment per texture row, so each piece
|
||||
// keeps a whole row of pixels.
|
||||
float bend = transform.bend();
|
||||
float axis = transform.bendAxis();
|
||||
return switch (bone) {
|
||||
case HEAD -> null;
|
||||
case RIGHT_ARM, LEFT_ARM -> limb(-2, 10, transform);
|
||||
case BODY, RIGHT_LEG, LEFT_LEG -> limb(0, 12, transform);
|
||||
case RIGHT_ARM, LEFT_ARM -> new LimbBend(-2, 10, bend, axis, SHARPNESS, 12);
|
||||
case RIGHT_LEG, LEFT_LEG -> new LimbBend(0, 12, bend, axis, SHARPNESS, 12);
|
||||
// The hips stay on the legs and the chest leans.
|
||||
case BODY -> LimbBend.anchoredAtEnd(0, 12, bend, axis, SHARPNESS, 12);
|
||||
};
|
||||
}
|
||||
|
||||
/** One segment per texture row, so each piece keeps a whole row of pixels. */
|
||||
private static LimbBend limb(float start, float end, BoneTransform transform) {
|
||||
return new LimbBend(start, end, transform.bend(), transform.bendAxis(), SHARPNESS, Math.round(end - start));
|
||||
/**
|
||||
* Moves {@code part} (a sibling of the body in the model) as if it were attached to the bent torso
|
||||
* at its pivot: its pivot follows the torso and it turns with the torso's ring there.
|
||||
*/
|
||||
private static void carry(ModelPart body, LimbBend bend, ModelPart part) {
|
||||
Quaternionf bodyRotation = rotation(body);
|
||||
|
||||
// The part's pivot in the torso's own space.
|
||||
Vector3f pivot = bodyRotation.transformInverse(
|
||||
new Vector3f(part.x - body.x, part.y - body.y, part.z - body.z));
|
||||
float[] bent = new float[3];
|
||||
bend.bendPoint(pivot.x, pivot.y, pivot.z, bent);
|
||||
Vector3f moved = bodyRotation.transform(new Vector3f(bent[0], bent[1], bent[2]));
|
||||
part.x = body.x + moved.x;
|
||||
part.y = body.y + moved.y;
|
||||
part.z = body.z + moved.z;
|
||||
|
||||
// The ring's turn happens in the torso's space, so bring it into model space around the part's
|
||||
// own rotation.
|
||||
Vec3 axis = bend.axis();
|
||||
Quaternionf turn = new Quaternionf().rotationAxis(bend.angleAt(pivot.y), axis.x(), axis.y(), axis.z());
|
||||
Quaternionf result = new Quaternionf(bodyRotation).mul(turn).mul(new Quaternionf(bodyRotation).conjugate())
|
||||
.mul(rotation(part));
|
||||
Vector3f euler = result.getEulerAnglesZYX(new Vector3f());
|
||||
part.xRot = euler.x;
|
||||
part.yRot = euler.y;
|
||||
part.zRot = euler.z;
|
||||
}
|
||||
|
||||
/** The rotation ModelPart.translateAndRotate applies. */
|
||||
private static Quaternionf rotation(ModelPart part) {
|
||||
return new Quaternionf().rotationZYX(part.zRot, part.yRot, part.xRot);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,21 +8,28 @@ import net.fabricmc.fabric.api.client.gametest.v1.context.TestSingleplayerContex
|
||||
import net.minecraft.client.CameraType;
|
||||
|
||||
/**
|
||||
* Screenshots each test animation at its peak, from the front and from the side.
|
||||
* Screenshots each test animation at set points, from the front, the side and behind.
|
||||
* Run with {@code ./gradlew :<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 },
|
||||
/** Animation name, the tick to screenshot it at, and the angles to look from (0 is the front). */
|
||||
private static final Shot[] SHOTS = {
|
||||
new Shot("tpose", 10, 0, 90),
|
||||
new Shot("bend", 20, 0, 90),
|
||||
new Shot("flap", 15, 0, 90),
|
||||
new Shot("squat", 20, 0, 90),
|
||||
new Shot("bow", 20, 0, 90),
|
||||
// Top and bottom of the bounce.
|
||||
new Shot("twerk", 16, 0, 90, 150),
|
||||
new Shot("twerk", 4, 0, 90, 150),
|
||||
};
|
||||
|
||||
private record Shot(String animation, int tick, float... angles) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void runTest(ClientGameTestContext context) {
|
||||
try (TestSingleplayerContext singleplayer = context.worldBuilder().create()) {
|
||||
@@ -33,12 +40,13 @@ public final class EmotesGameTest implements FabricClientGameTest {
|
||||
});
|
||||
context.waitTicks(20);
|
||||
|
||||
for (Object[] shot : SHOTS) {
|
||||
String name = (String) shot[0];
|
||||
context.runOnClient(client -> Emotes.play(client.player.getUUID(), TestAnimations.ALL.get(name)));
|
||||
context.waitTicks((Integer) shot[1]);
|
||||
screenshot(context, name + "-front", 0);
|
||||
screenshot(context, name + "-side", 90);
|
||||
for (Shot shot : SHOTS) {
|
||||
context.runOnClient(client -> Emotes.play(client.player.getUUID(),
|
||||
TestAnimations.ALL.get(shot.animation())));
|
||||
context.waitTicks(shot.tick());
|
||||
for (float angle : shot.angles()) {
|
||||
screenshot(context, shot.animation() + "-t" + shot.tick() + "-" + (int) angle, angle);
|
||||
}
|
||||
context.runOnClient(client -> Emotes.stop(client.player.getUUID()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,11 +58,54 @@ final class TestAnimations {
|
||||
.set(Bone.RIGHT_LEG, leg)
|
||||
.set(Bone.LEFT_LEG, leg)
|
||||
.build();
|
||||
}));
|
||||
}),
|
||||
// Chest leaning forward from the hips, head and arms carried with it. Tests the torso.
|
||||
"bow", of(40, true, t -> {
|
||||
float lean = HALF_PI * 0.5f * (1 - (float) Math.cos(t / 40f * 2 * Math.PI));
|
||||
return Pose.builder()
|
||||
.set(Bone.BODY, BoneTransform.IDENTITY.withBend(lean, 0))
|
||||
.build();
|
||||
}),
|
||||
"twerk", of(8, true, TestAnimations::twerk));
|
||||
|
||||
private TestAnimations() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Squatting with knees out, hips pushed back and the chest leaning forward, bouncing the hips
|
||||
* about 2.5 times a second. The torso and knees bend with the bounce, so the back arches.
|
||||
*/
|
||||
private static Pose twerk(float t) {
|
||||
// 0 at the bottom of the bounce, 1 at the top.
|
||||
float up = 0.5f * (1 - (float) Math.cos(t / 8f * 2 * Math.PI));
|
||||
|
||||
float thighPitch = -1.1f + 0.15f * up;
|
||||
float knee = 1.75f - 0.3f * up;
|
||||
float splay = 0.55f;
|
||||
float lean = 1.25f - 0.2f * up;
|
||||
|
||||
// Lower everything by as much as the bent legs got shorter, and push the hips back so the feet
|
||||
// stay under where they were (a rigid thigh + shin approximation of the curved leg).
|
||||
float drop = 12 - 6 * (float) (Math.cos(thighPitch) + Math.cos(thighPitch + knee)) * (float) Math.cos(splay);
|
||||
float back = -6 * (float) (Math.sin(thighPitch) + Math.sin(thighPitch + knee));
|
||||
|
||||
BoneTransform shift = new BoneTransform(0, drop, back, 0, 0, 0, 0, 0);
|
||||
// The arms ride on the chest, which would swing them back as it leans. Tilt them forward by a
|
||||
// bit more than the lean so they hang down towards the knees, a little outside them.
|
||||
float armPitch = -lean * 1.1f;
|
||||
return Pose.builder()
|
||||
.set(Bone.BODY, shift.withBend(lean, 0))
|
||||
.set(Bone.HEAD, new BoneTransform(0, drop, back, -lean * 0.5f, 0, 0, 0, 0))
|
||||
.set(Bone.RIGHT_ARM, new BoneTransform(0, drop, back, armPitch, 0, 0.3f, 0.5f, 0))
|
||||
.set(Bone.LEFT_ARM, new BoneTransform(0, drop, back, armPitch, 0, -0.3f, 0.5f, 0))
|
||||
// Knees fold back and a little inwards, so the thighs splay out and the feet come in.
|
||||
.set(Bone.RIGHT_LEG, new BoneTransform(0, drop, back, thighPitch, 0, splay, knee,
|
||||
(float) Math.PI - 0.35f))
|
||||
.set(Bone.LEFT_LEG, new BoneTransform(0, drop, back, thighPitch, 0, -splay, knee,
|
||||
(float) Math.PI + 0.35f))
|
||||
.build();
|
||||
}
|
||||
|
||||
private static Animation of(float length, boolean loops, Function<Float, Pose> sampler) {
|
||||
return new Animation() {
|
||||
@Override
|
||||
|
||||
Reference in New Issue
Block a user