From 03c13734ebf2568e62a53e217ada754a8dcdfbdb Mon Sep 17 00:00:00 2001 From: Wind-Explorer Date: Wed, 9 Sep 2026 10:34:11 +0800 Subject: [PATCH] update puppet model --- .../components/renderer/puppet/animation.ts | 30 ++++++++-- .../components/renderer/puppet/visual.ts | 58 +++++++++++++------ tests/scene-animation.test.ts | 28 +++++++++ 3 files changed, 94 insertions(+), 22 deletions(-) diff --git a/src/routes/scene/components/renderer/puppet/animation.ts b/src/routes/scene/components/renderer/puppet/animation.ts index a513b4d..eafd040 100644 --- a/src/routes/scene/components/renderer/puppet/animation.ts +++ b/src/routes/scene/components/renderer/puppet/animation.ts @@ -11,10 +11,12 @@ export class PuppetAnimation { private readonly movementDelta = new THREE.Vector3(); private readonly idleRotationY: number; private idleTargetRotationY: number; + private readonly legRestY: number; private hasSmoothedPosition = false; private wasMoving = false; constructor(private readonly rig: PuppetRig) { + this.legRestY = rig.leftLeg.position.y; this.idleRotationY = rig.root.rotation.y; this.idleTargetRotationY = this.idleRotationY; } @@ -77,6 +79,7 @@ export class PuppetAnimation { if (Math.abs(limb.rotation.x) <= ANGLE_EPSILON) limb.rotation.x = 0; else active = true; } + this.placeFeet(); return active; } @@ -119,10 +122,27 @@ export class PuppetAnimation { } private animateWalkCycle(elapsedSeconds: number) { - const swing = Math.sin(elapsedSeconds * 8) * 0.5; - this.rig.leftLeg.rotation.x = swing * 2; - this.rig.rightLeg.rotation.x = -swing * 2; - this.rig.leftArm.rotation.x = -swing * 2; - this.rig.rightArm.rotation.x = swing * 2; + const swing = Math.sin(elapsedSeconds * 10); + this.rig.leftLeg.rotation.x = swing * 0.45; + this.rig.rightLeg.rotation.x = -swing * 0.45; + this.rig.leftArm.rotation.x = -swing * 0.35; + this.rig.rightArm.rotation.x = swing * 0.35; + this.placeFeet(); + } + + // Lift each short leg enough to keep its broad foot from clipping the ground. + // Recompute while settling too, so stopping restores the exact standing pose. + private placeFeet() { + for (const [pivot, mesh] of [ + [this.rig.leftLeg, this.rig.leftLegMesh], + [this.rig.rightLeg, this.rig.rightLegMesh], + ] as const) { + const angle = pivot.rotation.x; + const { height, depth } = mesh.geometry.parameters; + pivot.position.y = this.legRestY + Math.max( + 0, + height * (Math.cos(angle) - 1) + depth / 2 * Math.abs(Math.sin(angle)), + ); + } } } diff --git a/src/routes/scene/components/renderer/puppet/visual.ts b/src/routes/scene/components/renderer/puppet/visual.ts index c367fbe..8ac623a 100644 --- a/src/routes/scene/components/renderer/puppet/visual.ts +++ b/src/routes/scene/components/renderer/puppet/visual.ts @@ -110,45 +110,48 @@ function createRig(): PuppetRig { root.rotation.y = CAMERA_FACING_ROTATION_Y; const material = new THREE.MeshStandardMaterial(); - const body = new THREE.Mesh(new THREE.BoxGeometry(16, 24, 8), material); - body.position.y = 36; + const body = new THREE.Mesh(createTaperedBox(18, 20, 12, 0.8), material); + body.position.y = 26; - const head = new THREE.Mesh(new THREE.BoxGeometry(16, 16, 16), material); - head.position.y = 56; + const head = new THREE.Mesh(createTaperedBox(30, 28, 24, 0.92), material); + head.position.y = 50; const { pivot: leftArm, mesh: leftArmMesh } = createLimbPivot( -12, - 48, - 8, - 24, + 34, + 7, + 16, 8, material, ); const { pivot: rightArm, mesh: rightArmMesh } = createLimbPivot( 12, - 48, - 8, - 24, + 34, + 7, + 16, 8, material, ); const { pivot: leftLeg, mesh: leftLegMesh } = createLimbPivot( -4, - 24, - 8, - 24, + 16, 8, + 16, + 10, material, ); const { pivot: rightLeg, mesh: rightLegMesh } = createLimbPivot( 4, - 24, - 8, - 24, + 16, 8, + 16, + 10, material, ); + // Splayed shoulders keep the short hands clear of the wider torso. + leftArm.rotation.z = -0.16; + rightArm.rotation.z = 0.16; root.add(body, head, leftArm, rightArm, leftLeg, rightLeg); return { @@ -178,7 +181,7 @@ function createLimbPivot( pivot.position.set(x, y, 0); const limb = new THREE.Mesh( - new THREE.BoxGeometry(width, height, depth), + createTaperedBox(width, height, depth, 0.85), material, ); limb.position.y = -height / 2; @@ -186,3 +189,24 @@ function createLimbPivot( return { pivot, mesh: limb }; } + +/** Keep BoxGeometry's six four-vertex faces so the existing skin atlas still fits. + * The narrower upper end gives the torso shoulders and the limbs chunky ends. + */ +function createTaperedBox( + width: number, + height: number, + depth: number, + topScale: number, +) { + const geometry = new THREE.BoxGeometry(width, height, depth); + const positions = geometry.attributes.position; + for (let i = 0; i < positions.count; i++) { + if (positions.getY(i) > 0) { + positions.setX(i, positions.getX(i) * topScale); + positions.setZ(i, positions.getZ(i) * topScale); + } + } + geometry.computeVertexNormals(); + return geometry; +} diff --git a/tests/scene-animation.test.ts b/tests/scene-animation.test.ts index e93bd28..cecafba 100644 --- a/tests/scene-animation.test.ts +++ b/tests/scene-animation.test.ts @@ -97,3 +97,31 @@ test('limb relaxation has the same speed at 30 and 120 FPS and finishes', () => fast.dispose(); } }); + +test('broad feet stay above ground throughout a stride and return to rest', () => { + const visual = new PuppetVisual('feet'); + const point = new THREE.Vector3(); + const restPositions = visual.root.children.map((part) => part.position.clone()); + try { + for (let frame = 0; frame <= 120; frame++) { + visual.walkInPlace(0, frame / 120 * Math.PI * 2 / 10); + visual.root.updateMatrixWorld(true); + visual.root.traverse((part) => { + if (!(part instanceof THREE.Mesh)) return; + const vertices = part.geometry.attributes.position; + for (let i = 0; i < vertices.count; i++) { + point.fromBufferAttribute(vertices, i).applyMatrix4(part.matrixWorld); + assert.ok(point.y >= -1e-6, `vertex below ground: ${point.y}`); + } + }); + } + visual.walkInPlace(0, 0.1); + for (let frame = 0; frame < 120; frame++) visual.pause(1 / 60); + assert.equal(visual.pause(0), false); + visual.root.children.forEach((part, index) => { + assert.deepEqual(part.position, restPositions[index]); + }); + } finally { + visual.dispose(); + } +});