@call-me-sensei/toonlab 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ATTRIBUTION.md +40 -0
- package/LICENSE +21 -0
- package/README.md +125 -0
- package/package.json +97 -0
- package/src/character/characterRig.js +353 -0
- package/src/character/freestyleSwimClip.js +541 -0
- package/src/character/index.js +4 -0
- package/src/character/modelLoader.js +433 -0
- package/src/core/materialRoles.js +438 -0
- package/src/core/presetDocuments.js +110 -0
- package/src/core/shaderBackend.js +24 -0
- package/src/debrisgen/debrisFields.js +544 -0
- package/src/debrisgen/debrisGenerator.js +1929 -0
- package/src/debrisgen/debrisPalettes.js +71 -0
- package/src/debrisgen/debrisPhysics.js +198 -0
- package/src/debrisgen/debrisPresets.js +155 -0
- package/src/debrisgen/debrisSettings.js +333 -0
- package/src/debrisgen/debrisTextures.js +380 -0
- package/src/debrisgen/index.js +5 -0
- package/src/debug/fieldValues.js +95 -0
- package/src/debug/index.js +7 -0
- package/src/debug/settingsPanel.js +170 -0
- package/src/environment/environmentAmbientProbe.js +160 -0
- package/src/environment/environmentMaterialAdapter.js +336 -0
- package/src/environment/environmentMaterialClassifier.js +178 -0
- package/src/environment/environmentPlanarReflection.js +201 -0
- package/src/environment/environmentPresets.js +443 -0
- package/src/environment/environmentRigs.js +684 -0
- package/src/environment/environmentSettings.js +504 -0
- package/src/environment/environmentShaderMaterials.js +318 -0
- package/src/environment/environmentSunShadowPass.js +268 -0
- package/src/environment/environmentTextureResolver.js +161 -0
- package/src/environment/environmentTimeOfDay.js +167 -0
- package/src/environment/environmentVertexAo.js +188 -0
- package/src/environment/index.js +8 -0
- package/src/environment/scanAssetStylize.js +103 -0
- package/src/index.js +21 -0
- package/src/loaders/index.js +1 -0
- package/src/post/index.js +2 -0
- package/src/post/postProcessing.js +1087 -0
- package/src/rockgen/export/glbExport.js +177 -0
- package/src/rockgen/heightfield/heightfieldErosion.js +7 -0
- package/src/rockgen/heightfield/heightfieldPatch.js +183 -0
- package/src/rockgen/heightfield/stylizedErosionSim.js +356 -0
- package/src/rockgen/index.js +14 -0
- package/src/rockgen/mesh/meshAttributes.js +374 -0
- package/src/rockgen/mesh/meshDocument.js +158 -0
- package/src/rockgen/mesh/surfaceNets.js +329 -0
- package/src/rockgen/noise/cellularNoise3.js +92 -0
- package/src/rockgen/noise/prng.js +62 -0
- package/src/rockgen/noise/simplexNoise3.js +89 -0
- package/src/rockgen/noise/valueNoise3.js +87 -0
- package/src/rockgen/rockDocument.js +255 -0
- package/src/rockgen/rockHelpers.js +8 -0
- package/src/rockgen/rockgenPresets.js +559 -0
- package/src/rockgen/rockgenSettings.js +756 -0
- package/src/rockgen/sdf/fieldCompiler.js +544 -0
- package/src/rockgen/sdf/sculptEdits.js +66 -0
- package/src/rockgen/sdf/sdfModifiers.js +45 -0
- package/src/rockgen/sdf/sdfOps.js +37 -0
- package/src/rockgen/sdf/sdfPrimitives.js +88 -0
- package/src/shaders-tsl/anime.js +963 -0
- package/src/shaders-tsl/chunks/character-color.js +64 -0
- package/src/shaders-tsl/chunks/character-highlights.js +174 -0
- package/src/shaders-tsl/chunks/character-lighting.js +309 -0
- package/src/shaders-tsl/chunks/character-material-maps.js +146 -0
- package/src/shaders-tsl/chunks/character-roles.js +52 -0
- package/src/shaders-tsl/chunks/character-scene-lights.js +270 -0
- package/src/shaders-tsl/chunks/character-shadow-color.js +61 -0
- package/src/shaders-tsl/chunks/character-skinning.js +140 -0
- package/src/shaders-tsl/chunks/environment-color.js +47 -0
- package/src/shaders-tsl/chunks/environment-debug.js +79 -0
- package/src/shaders-tsl/chunks/environment-lighting.js +260 -0
- package/src/shaders-tsl/chunks/environment-sun-shadow.js +87 -0
- package/src/shaders-tsl/chunks/foliage-fog.js +60 -0
- package/src/shaders-tsl/chunks/pass-depth-color.js +89 -0
- package/src/shaders-tsl/chunks/stylized-cloud-shadow.js +70 -0
- package/src/shaders-tsl/chunks/water-color.js +190 -0
- package/src/shaders-tsl/chunks/water-common.js +124 -0
- package/src/shaders-tsl/chunks/water-foam.js +93 -0
- package/src/shaders-tsl/chunks/water-lighting.js +116 -0
- package/src/shaders-tsl/chunks/water-ripple.js +64 -0
- package/src/shaders-tsl/chunks/water-waves.js +100 -0
- package/src/shaders-tsl/environment-ao-overlay.js +60 -0
- package/src/shaders-tsl/environment.js +680 -0
- package/src/shaders-tsl/flower.js +267 -0
- package/src/shaders-tsl/grass.js +193 -0
- package/src/shaders-tsl/post-composite.js +461 -0
- package/src/shaders-tsl/sky.js +175 -0
- package/src/shaders-tsl/tree-leaf.js +264 -0
- package/src/shaders-tsl/water-breaker.js +423 -0
- package/src/shaders-tsl/water-kelp.js +171 -0
- package/src/shaders-tsl/water-rain.js +107 -0
- package/src/shaders-tsl/water-simulation.js +140 -0
- package/src/shaders-tsl/water-splash.js +298 -0
- package/src/shaders-tsl/water.js +509 -0
- package/src/sky/index.js +2 -0
- package/src/sky/stylizedSky.js +341 -0
- package/src/toon/characterRenderPasses.js +646 -0
- package/src/toon/index.js +4 -0
- package/src/toon/settings/alphaSettings.js +170 -0
- package/src/toon/settings/averageShadowSettings.js +181 -0
- package/src/toon/settings/baseTextureSettings.js +133 -0
- package/src/toon/settings/celShadeSettings.js +96 -0
- package/src/toon/settings/contactShadowSettings.js +114 -0
- package/src/toon/settings/eyeHighlightSettings.js +187 -0
- package/src/toon/settings/faceLightingSettings.js +135 -0
- package/src/toon/settings/furSettings.js +112 -0
- package/src/toon/settings/glitterSettings.js +102 -0
- package/src/toon/settings/hairHighlightSettings.js +325 -0
- package/src/toon/settings/indirectLightSettings.js +244 -0
- package/src/toon/settings/localLightSettings.js +176 -0
- package/src/toon/settings/materialMapSettings.js +299 -0
- package/src/toon/settings/outlineSettings.js +263 -0
- package/src/toon/settings/perspectiveRemovalSettings.js +60 -0
- package/src/toon/settings/rimLightSettings.js +268 -0
- package/src/toon/settings/sceneShadowSettings.js +147 -0
- package/src/toon/settings/selfShadowSettings.js +221 -0
- package/src/toon/settings/shadowColorSettings.js +238 -0
- package/src/toon/settings/skinToneSettings.js +150 -0
- package/src/toon/settings/specularSettings.js +314 -0
- package/src/toon/settings/stickerSettings.js +102 -0
- package/src/toon/toonMaterialAdapter.js +2235 -0
- package/src/toon/toonSettings.js +860 -0
- package/src/vegetation/flowerSpecies.js +260 -0
- package/src/vegetation/index.js +10 -0
- package/src/vegetation/stylizedBush.js +136 -0
- package/src/vegetation/stylizedFlower.js +213 -0
- package/src/vegetation/stylizedFlowers.js +297 -0
- package/src/vegetation/stylizedGrass.js +491 -0
- package/src/vegetation/stylizedTree.js +2449 -0
- package/src/vegetation/stylizedTreeFoliage.js +691 -0
- package/src/vegetation/treeExport.js +300 -0
- package/src/vegetation/treeRecipe.js +647 -0
- package/src/water/index.js +2 -0
- package/src/water/water.js +20 -0
- package/src/water/waterBreakerSystem.js +591 -0
- package/src/water/waterInteraction.js +193 -0
- package/src/water/waterMaterial.js +263 -0
- package/src/water/waterRain.js +136 -0
- package/src/water/waterRippleSimulation.js +227 -0
- package/src/water/waterScenePasses.js +415 -0
- package/src/water/waterSettings.js +1138 -0
- package/src/water/waterSplashSystem.js +251 -0
- package/src/water/waterSurface.js +475 -0
- package/src/water/waterVegetation.js +83 -0
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
import { targetBoneNameForRole, targetBoneNamesForRole } from './characterRig.js';
|
|
3
|
+
|
|
4
|
+
// Procedurally generated freestyle (front crawl) swim clip — no FBX source.
|
|
5
|
+
// Motion is authored in the character's rest frame (standing): arms windmill
|
|
6
|
+
// in the sagittal plane with a catch-up glide (hands meet at full front
|
|
7
|
+
// extension), legs flutter-kick, the torso rolls around its long axis, and a
|
|
8
|
+
// prone pitch on the torso roots lays the whole body flat in the water. Each
|
|
9
|
+
// bone's offset is a world-space rotation delta accumulated down the
|
|
10
|
+
// hierarchy (D(bone) = D(parent) * offset(bone), world = D * restWorld), so
|
|
11
|
+
// the result is independent of the bones' local axes — the same world-space
|
|
12
|
+
// solve the Mixamo retargeter relies on. Which bone plays which humanoid role
|
|
13
|
+
// comes from the resolved `rig` (characterRig.js), so the clip bakes onto any
|
|
14
|
+
// supported skeleton (MMD, VRM, Rigify, Mixamo-named).
|
|
15
|
+
//
|
|
16
|
+
// The caller must put the skeleton in its bind pose before calling; the clip
|
|
17
|
+
// is baked against the bone world rotations found at call time.
|
|
18
|
+
|
|
19
|
+
const FPS = 30;
|
|
20
|
+
const STROKE_DURATION = 0.8; // seconds per single arm stroke
|
|
21
|
+
const STROKES_PER_CLIP = 4; // one breath per clip => breathe every 4th stroke
|
|
22
|
+
const DURATION = STROKE_DURATION * STROKES_PER_CLIP;
|
|
23
|
+
|
|
24
|
+
const PRONE_PITCH = 1.28; // torso pitch to horizontal (slightly head-up)
|
|
25
|
+
const BODY_ROLL = 0.3; // roll around the spine axis, synced to arms
|
|
26
|
+
const HANDS_MEET_BIAS = 0.06; // extra convergence past the computed centerline
|
|
27
|
+
// angle so the hands visibly land at the center
|
|
28
|
+
const RECOVERY_OUT = 0.35; // elbow swings wide during the over-air recovery
|
|
29
|
+
const ARM_GLIDE = 0.85; // dwell at full front extension (catch-up feel)
|
|
30
|
+
const ELBOW_PULL_BEND = 0.45; // elbow flex during the underwater pull
|
|
31
|
+
const ELBOW_RECOVERY_BEND = 1.35; // high-elbow flex keeps the hand low over water
|
|
32
|
+
const ENTRY_ELBOW_BEND = 0.15; // slight relaxed bend while extended out front
|
|
33
|
+
const PALM_DOWN_TWIST = Math.PI / 2; // forearm pronation: rigs whose bind palms
|
|
34
|
+
// face the body need a quarter twist to
|
|
35
|
+
// land them palm-down at the water entry
|
|
36
|
+
const FINGER_CURL = 0.28; // gentle joint curl cups the hand into a paddle
|
|
37
|
+
const THUMB_CURL = 0.22; // thumb tucks toward the palm edge
|
|
38
|
+
const KICK_BEATS = 3; // flutter kicks per windmill (6-beat kick)
|
|
39
|
+
const KICK_SWING = 0.26; // thigh swing amplitude
|
|
40
|
+
const KNEE_BEND = 0.5; // knee whip on the downbeat
|
|
41
|
+
const ANKLE_POINT = 0.5; // pointed toes
|
|
42
|
+
const HEAD_TUCK = 0.1; // extra neck+head pitch: face looks straight down
|
|
43
|
+
const BREATH_TURN = 0.55; // head roll to the side for the breath
|
|
44
|
+
const BREATH_LIFT = 0.35; // face pitches out of the water while breathing
|
|
45
|
+
const BREATH_ROLL = 0.18; // extra body roll supporting the breath
|
|
46
|
+
const CENTER_BOB = 0.02; // vertical hip bob, in world meters
|
|
47
|
+
|
|
48
|
+
const SIDES = Object.freeze({
|
|
49
|
+
right: Object.freeze({ key: 'right', mmd: '右', sign: -1 }),
|
|
50
|
+
left: Object.freeze({ key: 'left', mmd: '左', sign: 1 }),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const FINGERS = ['Index', 'Middle', 'Ring', 'Little'];
|
|
54
|
+
const FINGER_JOINTS = ['Proximal', 'Intermediate', 'Distal'];
|
|
55
|
+
const THUMB_JOINTS = ['ThumbMetacarpal', 'ThumbProximal', 'ThumbDistal'];
|
|
56
|
+
|
|
57
|
+
function findBone(targetMesh, name) {
|
|
58
|
+
if (!name) return null;
|
|
59
|
+
return targetMesh.skeleton.bones.find((bone) => bone.name === name) || null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isDescendantOf(bone, ancestor) {
|
|
63
|
+
for (let node = bone.parent; node; node = node.parent) {
|
|
64
|
+
if (node === ancestor) return true;
|
|
65
|
+
}
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function createFreestyleSwimClip(targetMesh, rig, {
|
|
70
|
+
clipName = 'FreestyleSwim',
|
|
71
|
+
// 'skeleton' emits ".bones[name].prop" tracks for mixers rooted at the
|
|
72
|
+
// skinned mesh (the retarget path); 'node' emits "name.prop" tracks for
|
|
73
|
+
// mixers rooted at the model scene (the native-clip path).
|
|
74
|
+
trackNameStyle = 'skeleton',
|
|
75
|
+
} = {}) {
|
|
76
|
+
targetMesh.updateMatrixWorld(true);
|
|
77
|
+
|
|
78
|
+
const roleBoneName = (role) => targetBoneNameForRole(rig, role);
|
|
79
|
+
const roleBone = (role) => findBone(targetMesh, roleBoneName(role));
|
|
80
|
+
const trackName = (boneName, property) => (trackNameStyle === 'node'
|
|
81
|
+
? `${boneName}.${property}`
|
|
82
|
+
: `.bones[${boneName}].${property}`);
|
|
83
|
+
|
|
84
|
+
// Character frame at rest: lateral axis from the shoulder line, up from the
|
|
85
|
+
// world, forward completing the basis. The rig's bind pose faces the
|
|
86
|
+
// opposite of the direction ecctrl drives it (the Mixamo world-baked clips
|
|
87
|
+
// set that runtime convention), so the shoulder line is mirrored — without
|
|
88
|
+
// the flip the swimmer travels feet-first on her back.
|
|
89
|
+
const up = new THREE.Vector3(0, 1, 0);
|
|
90
|
+
const leftArm = roleBone('leftUpperArm');
|
|
91
|
+
const rightArm = roleBone('rightUpperArm');
|
|
92
|
+
const right = new THREE.Vector3(1, 0, 0);
|
|
93
|
+
if (leftArm && rightArm) {
|
|
94
|
+
right.subVectors(
|
|
95
|
+
leftArm.getWorldPosition(new THREE.Vector3()),
|
|
96
|
+
rightArm.getWorldPosition(new THREE.Vector3()),
|
|
97
|
+
);
|
|
98
|
+
right.y = 0;
|
|
99
|
+
if (right.lengthSq() < 1e-8) right.set(1, 0, 0);
|
|
100
|
+
right.normalize();
|
|
101
|
+
}
|
|
102
|
+
const forward = new THREE.Vector3().crossVectors(up, right).normalize();
|
|
103
|
+
|
|
104
|
+
const rotX = (angle) => new THREE.Quaternion().setFromAxisAngle(right, angle);
|
|
105
|
+
const rotY = (angle) => new THREE.Quaternion().setFromAxisAngle(up, angle);
|
|
106
|
+
const rotZ = (angle) => new THREE.Quaternion().setFromAxisAngle(forward, angle);
|
|
107
|
+
const compose = (...quats) => quats.reduce((result, q) => result.multiply(q), new THREE.Quaternion());
|
|
108
|
+
|
|
109
|
+
// Many binds are A-poses: the arms hang splayed outward, and a windmill
|
|
110
|
+
// baked on top of that splay traces a wide cone (arms flap out sideways).
|
|
111
|
+
// Align each upper arm to point straight down first so the stroke stays in
|
|
112
|
+
// a plane, then steer that plane per phase (inward at entry, out on recovery).
|
|
113
|
+
// The entry convergence is computed from the rig's own proportions: the hand
|
|
114
|
+
// lands on the centerline when the arm angles in by atan(shoulder offset /
|
|
115
|
+
// arm reach), so it adapts to anime-scale shoulders and arm lengths.
|
|
116
|
+
const armGeometry = (side) => {
|
|
117
|
+
const shoulder = roleBone(`${side.key}UpperArm`);
|
|
118
|
+
const elbow = roleBone(`${side.key}LowerArm`);
|
|
119
|
+
const wrist = roleBone(`${side.key}Hand`);
|
|
120
|
+
const geometry = {
|
|
121
|
+
align: new THREE.Quaternion(),
|
|
122
|
+
inward: 0.3 + HANDS_MEET_BIAS,
|
|
123
|
+
// Bind-frame forearm axis: the pronation twist rotates about it, and the
|
|
124
|
+
// accumulated chain delta carries it to the current forearm direction at
|
|
125
|
+
// every phase, so the twist always stays a twist about the forearm.
|
|
126
|
+
forearmAxis: new THREE.Vector3(0, -1, 0),
|
|
127
|
+
twistCarrier: wrist?.name ?? null,
|
|
128
|
+
};
|
|
129
|
+
if (!shoulder || !elbow) return geometry;
|
|
130
|
+
const shoulderPos = shoulder.getWorldPosition(new THREE.Vector3());
|
|
131
|
+
const elbowPos = elbow.getWorldPosition(new THREE.Vector3());
|
|
132
|
+
const dir = elbowPos.clone().sub(shoulderPos);
|
|
133
|
+
if (dir.lengthSq() < 1e-10) return geometry;
|
|
134
|
+
geometry.align.setFromUnitVectors(dir.clone().normalize(), new THREE.Vector3(0, -1, 0));
|
|
135
|
+
const upperLength = dir.length();
|
|
136
|
+
// Hand adds roughly a third of a forearm to the reach.
|
|
137
|
+
const forearmLength = wrist ? wrist.getWorldPosition(new THREE.Vector3()).distanceTo(elbowPos) : upperLength;
|
|
138
|
+
const reach = upperLength + forearmLength * 1.35;
|
|
139
|
+
const halfShoulderWidth = Math.abs(shoulderPos.dot(right)
|
|
140
|
+
- (leftArm && rightArm
|
|
141
|
+
? leftArm.getWorldPosition(new THREE.Vector3()).add(rightArm.getWorldPosition(new THREE.Vector3())).multiplyScalar(0.5).dot(right)
|
|
142
|
+
: shoulderPos.dot(right)));
|
|
143
|
+
if (reach > 1e-6 && halfShoulderWidth > 1e-6) {
|
|
144
|
+
geometry.inward = Math.atan2(halfShoulderWidth, reach) + HANDS_MEET_BIAS;
|
|
145
|
+
}
|
|
146
|
+
if (wrist) {
|
|
147
|
+
const forearmDir = wrist.getWorldPosition(new THREE.Vector3()).sub(elbowPos);
|
|
148
|
+
if (forearmDir.lengthSq() > 1e-10) geometry.forearmAxis.copy(forearmDir.normalize());
|
|
149
|
+
// Standard MMD rigs put a dedicated twist bone (手捩) on the forearm with
|
|
150
|
+
// gradient skin weights — palm rotation belongs there when it drives the
|
|
151
|
+
// hand; otherwise twist the wrist bone directly.
|
|
152
|
+
if (rig?.type === 'mmd') {
|
|
153
|
+
const twistBone = findBone(targetMesh, `${side.mmd}手捩`);
|
|
154
|
+
if (twistBone && isDescendantOf(wrist, twistBone)) geometry.twistCarrier = twistBone.name;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return geometry;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
// Bind-pose palm normal. MMD rigs bind in an A-pose with the palms facing
|
|
161
|
+
// the body, so the palm normal is the horizontal medial direction — that
|
|
162
|
+
// path is kept verbatim (tuned against the reference rig). Other rigs bind in
|
|
163
|
+
// arbitrary conventions (Rigify/Mixamo T-poses palm-down), so the normal is
|
|
164
|
+
// estimated from the hand's own geometry: fingers × knuckle line.
|
|
165
|
+
const medialPalmNormal = (side) => {
|
|
166
|
+
const wrist = roleBone(`${side.key}Hand`);
|
|
167
|
+
const otherWrist = roleBone(`${side.key === 'right' ? 'left' : 'right'}Hand`);
|
|
168
|
+
if (!wrist || !otherWrist) return null;
|
|
169
|
+
const normal = otherWrist.getWorldPosition(new THREE.Vector3())
|
|
170
|
+
.sub(wrist.getWorldPosition(new THREE.Vector3()));
|
|
171
|
+
normal.y = 0;
|
|
172
|
+
return normal.lengthSq() > 1e-10 ? normal.normalize() : null;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const geometricPalmNormal = (side) => {
|
|
176
|
+
const wrist = roleBone(`${side.key}Hand`);
|
|
177
|
+
const middle = roleBone(`${side.key}MiddleProximal`);
|
|
178
|
+
const index = roleBone(`${side.key}IndexProximal`);
|
|
179
|
+
const little = roleBone(`${side.key}LittleProximal`);
|
|
180
|
+
if (!wrist || !middle || !index || !little) return null;
|
|
181
|
+
const fingerDir = middle.getWorldPosition(new THREE.Vector3())
|
|
182
|
+
.sub(wrist.getWorldPosition(new THREE.Vector3()));
|
|
183
|
+
const knuckleLine = little.getWorldPosition(new THREE.Vector3())
|
|
184
|
+
.sub(index.getWorldPosition(new THREE.Vector3()));
|
|
185
|
+
// Right hand flat on a table, fingers away: index→little runs right,
|
|
186
|
+
// fingers run forward, (index→little) × fingers points down = palm side.
|
|
187
|
+
// The left hand mirrors.
|
|
188
|
+
const normal = new THREE.Vector3().crossVectors(knuckleLine, fingerDir);
|
|
189
|
+
if (normal.lengthSq() < 1e-10) return null;
|
|
190
|
+
normal.normalize();
|
|
191
|
+
if (side.key === 'left') normal.negate();
|
|
192
|
+
return normal;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const palmNormalFor = (side) => (rig?.type === 'mmd'
|
|
196
|
+
? medialPalmNormal(side)
|
|
197
|
+
: geometricPalmNormal(side) || medialPalmNormal(side));
|
|
198
|
+
|
|
199
|
+
// Shape the hand into a swimmer's paddle: fingers pressed together (each
|
|
200
|
+
// finger steered parallel to the middle finger within the palm plane) and
|
|
201
|
+
// every joint curled gently toward the palm so the hand cups the water.
|
|
202
|
+
// Static offsets — the shape holds through the whole stroke and the loop
|
|
203
|
+
// stays seamless.
|
|
204
|
+
const handFor = (side) => {
|
|
205
|
+
const wrist = roleBone(`${side.key}Hand`);
|
|
206
|
+
if (!wrist) return [];
|
|
207
|
+
const palmNormal = palmNormalFor(side);
|
|
208
|
+
if (!palmNormal) return [];
|
|
209
|
+
|
|
210
|
+
const jointDir = (bone) => {
|
|
211
|
+
const child = bone.children.find((node) => node.isBone);
|
|
212
|
+
const pos = bone.getWorldPosition(new THREE.Vector3());
|
|
213
|
+
return child
|
|
214
|
+
? child.getWorldPosition(new THREE.Vector3()).sub(pos)
|
|
215
|
+
: pos.sub(bone.parent.getWorldPosition(new THREE.Vector3()));
|
|
216
|
+
};
|
|
217
|
+
const inPalmPlane = (dir) => {
|
|
218
|
+
const projected = dir.clone().addScaledVector(palmNormal, -dir.dot(palmNormal));
|
|
219
|
+
return projected.lengthSq() > 1e-10 ? projected.normalize() : null;
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const middleBase = roleBone(`${side.key}MiddleProximal`);
|
|
223
|
+
const middleDir = middleBase ? inPalmPlane(jointDir(middleBase)) : null;
|
|
224
|
+
|
|
225
|
+
const specs = [];
|
|
226
|
+
const addJoint = (bone, curl, adductTo) => {
|
|
227
|
+
if (!bone) return;
|
|
228
|
+
const dir = jointDir(bone);
|
|
229
|
+
if (dir.lengthSq() < 1e-10) return;
|
|
230
|
+
dir.normalize();
|
|
231
|
+
// Rotating about dir × palmNormal moves the fingertip toward the palm.
|
|
232
|
+
const curlAxis = dir.clone().cross(palmNormal);
|
|
233
|
+
if (curlAxis.lengthSq() < 1e-10) return;
|
|
234
|
+
const shape = new THREE.Quaternion().setFromAxisAngle(curlAxis.normalize(), curl);
|
|
235
|
+
const flatDir = adductTo ? inPalmPlane(dir) : null;
|
|
236
|
+
if (flatDir && adductTo) {
|
|
237
|
+
shape.multiply(new THREE.Quaternion().setFromUnitVectors(flatDir, adductTo));
|
|
238
|
+
}
|
|
239
|
+
specs.push({ name: bone.name, offset: () => shape.clone() });
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
for (const finger of FINGERS) {
|
|
243
|
+
for (const joint of FINGER_JOINTS) {
|
|
244
|
+
addJoint(
|
|
245
|
+
roleBone(`${side.key}${finger}${joint}`),
|
|
246
|
+
FINGER_CURL,
|
|
247
|
+
joint === 'Proximal' && finger !== 'Middle' ? middleDir : null,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
// Tuck the thumb against the index finger: swing the thumb base to run
|
|
252
|
+
// parallel with the index finger's bind direction, add the same base curl
|
|
253
|
+
// the index gets so it hugs the curled finger, then curl the outer joints.
|
|
254
|
+
const indexBase = roleBone(`${side.key}IndexProximal`);
|
|
255
|
+
const indexDir = indexBase ? jointDir(indexBase) : null;
|
|
256
|
+
let isThumbBase = true;
|
|
257
|
+
for (const joint of THUMB_JOINTS) {
|
|
258
|
+
const bone = roleBone(`${side.key}${joint}`);
|
|
259
|
+
if (!bone) continue;
|
|
260
|
+
if (isThumbBase && indexDir && indexDir.lengthSq() > 1e-10) {
|
|
261
|
+
isThumbBase = false;
|
|
262
|
+
const dir = jointDir(bone);
|
|
263
|
+
if (dir.lengthSq() > 1e-10) {
|
|
264
|
+
const shape = new THREE.Quaternion()
|
|
265
|
+
.setFromUnitVectors(dir.normalize(), indexDir.clone().normalize());
|
|
266
|
+
const curlAxis = indexDir.clone().normalize().cross(palmNormal);
|
|
267
|
+
if (curlAxis.lengthSq() > 1e-10) {
|
|
268
|
+
shape.premultiply(new THREE.Quaternion().setFromAxisAngle(curlAxis.normalize(), FINGER_CURL));
|
|
269
|
+
}
|
|
270
|
+
specs.push({ name: bone.name, offset: () => shape.clone() });
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
isThumbBase = false;
|
|
275
|
+
addJoint(bone, THUMB_CURL, null);
|
|
276
|
+
}
|
|
277
|
+
return specs;
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
// armPhase 0 = hand entry (arm extended past the head). (0, PI) is the
|
|
281
|
+
// underwater pull, (PI, 2*PI) the over-air recovery. The rest pose arm
|
|
282
|
+
// hangs down, so the swing starts PI away from it. ARM_GLIDE slows the
|
|
283
|
+
// swing near entry: with the arms half a cycle apart, both dwell extended
|
|
284
|
+
// in front at the same time — the catch-up moment where the hands meet.
|
|
285
|
+
const armSwing = (armPhase) => Math.PI + armPhase - ARM_GLIDE * Math.sin(armPhase);
|
|
286
|
+
const elbowBend = (armPhase) => {
|
|
287
|
+
const s = Math.sin(armPhase);
|
|
288
|
+
const entry = Math.max(0, Math.cos(armPhase));
|
|
289
|
+
// A touch of relaxed elbow while the arm is extended out front.
|
|
290
|
+
return (s >= 0 ? ELBOW_PULL_BEND * s : -ELBOW_RECOVERY_BEND * s) + ENTRY_ELBOW_BEND * entry * entry;
|
|
291
|
+
};
|
|
292
|
+
// Lateral steering of the stroke plane: negative = toward the centerline.
|
|
293
|
+
const armLateral = (armPhase, inward) => {
|
|
294
|
+
const entry = Math.max(0, Math.cos(armPhase));
|
|
295
|
+
const recovery = Math.max(0, -Math.sin(armPhase));
|
|
296
|
+
return -inward * entry * entry + RECOVERY_OUT * recovery;
|
|
297
|
+
};
|
|
298
|
+
// One breath per clip, timed to the right arm's second recovery. Zero at
|
|
299
|
+
// the clip boundaries so the loop stays seamless.
|
|
300
|
+
const breathPulse = (phase) => {
|
|
301
|
+
const s = Math.max(0, Math.sin(2 * phase - 3 * Math.PI));
|
|
302
|
+
return s * s;
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
// The clip spans STROKES_PER_CLIP single-arm strokes: the windmill phase
|
|
306
|
+
// runs twice as fast as the clip phase. One entry per animated bone;
|
|
307
|
+
// offset(phase) returns the world-frame delta for the clip phase [0, 2*PI).
|
|
308
|
+
const windmill = (phase) => 2 * phase;
|
|
309
|
+
|
|
310
|
+
// Pronation: the palm should land flat on the water at entry and stay
|
|
311
|
+
// facing backward through the pull — palming the water, not slicing it.
|
|
312
|
+
// MMD keeps the tuned quarter turn (bind palms face the body); other rigs
|
|
313
|
+
// compute the twist that maps their actual bind palm normal to straight
|
|
314
|
+
// down at the entry pose, absorbing whatever the bind convention is (and
|
|
315
|
+
// the twist ambiguity of the upper-arm align). Held constant so the loop
|
|
316
|
+
// stays seamless.
|
|
317
|
+
const pronationFor = (side, { align, inward, forearmAxis }) => {
|
|
318
|
+
const fallback = new THREE.Quaternion().setFromAxisAngle(forearmAxis, side.sign * PALM_DOWN_TWIST);
|
|
319
|
+
if (rig?.type === 'mmd') return fallback;
|
|
320
|
+
const bindPalm = palmNormalFor(side);
|
|
321
|
+
if (!bindPalm) return fallback;
|
|
322
|
+
// Full world delta carrying the hand at entry (armPhase 0): the torso's
|
|
323
|
+
// prone pitch and chest counter-pitch (their phase terms are zero at
|
|
324
|
+
// entry), the arm swing/steer/align, and the entry elbow bend. Pull the
|
|
325
|
+
// world-down target back into the bind frame the offsets compose in.
|
|
326
|
+
const entryDelta = compose(
|
|
327
|
+
rotX(PRONE_PITCH),
|
|
328
|
+
rotX(-0.1),
|
|
329
|
+
rotZ(side.sign * armLateral(0, inward)),
|
|
330
|
+
rotX(armSwing(0)),
|
|
331
|
+
align.clone(),
|
|
332
|
+
rotX(elbowBend(0)),
|
|
333
|
+
);
|
|
334
|
+
const targetBind = new THREE.Vector3(0, -1, 0).applyQuaternion(entryDelta.clone().invert());
|
|
335
|
+
const project = (v) => v.clone().addScaledVector(forearmAxis, -v.dot(forearmAxis));
|
|
336
|
+
const from = project(bindPalm);
|
|
337
|
+
const to = project(targetBind);
|
|
338
|
+
if (from.lengthSq() < 1e-8 || to.lengthSq() < 1e-8) return fallback;
|
|
339
|
+
from.normalize();
|
|
340
|
+
to.normalize();
|
|
341
|
+
let angle = Math.acos(THREE.MathUtils.clamp(from.dot(to), -1, 1));
|
|
342
|
+
if (new THREE.Vector3().crossVectors(from, to).dot(forearmAxis) < 0) angle = -angle;
|
|
343
|
+
return new THREE.Quaternion().setFromAxisAngle(forearmAxis, angle);
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
const armFor = (side, phaseShift) => {
|
|
347
|
+
// Sign of the lateral steer. The steer rotates about the forward axis and
|
|
348
|
+
// acts while the arm points up/forward (entry and recovery) — a Z-rotation
|
|
349
|
+
// moves an up-pointing arm the opposite way from a hanging one, so the
|
|
350
|
+
// right arm converges with a positive rotation, not a negative one.
|
|
351
|
+
const { sign } = side;
|
|
352
|
+
const geometry = armGeometry(side);
|
|
353
|
+
const { align, inward, twistCarrier } = geometry;
|
|
354
|
+
const arm = (p) => windmill(p) + phaseShift;
|
|
355
|
+
const wristName = roleBoneName(`${side.key}Hand`);
|
|
356
|
+
const palmDown = pronationFor(side, geometry);
|
|
357
|
+
const wristFlap = (p) => rotX(0.15 * Math.sin(arm(p)));
|
|
358
|
+
return [
|
|
359
|
+
{ name: roleBoneName(`${side.key}Shoulder`), offset: (p) => rotX(0.18 * Math.sin(arm(p))) },
|
|
360
|
+
{
|
|
361
|
+
name: roleBoneName(`${side.key}UpperArm`),
|
|
362
|
+
offset: (p) => compose(rotZ(sign * armLateral(arm(p), inward)), rotX(armSwing(arm(p))), align.clone()),
|
|
363
|
+
},
|
|
364
|
+
{ name: roleBoneName(`${side.key}LowerArm`), offset: (p) => rotX(elbowBend(arm(p))) },
|
|
365
|
+
...(twistCarrier === wristName
|
|
366
|
+
? [{ name: wristName, offset: (p) => compose(palmDown.clone(), wristFlap(p)) }]
|
|
367
|
+
: [
|
|
368
|
+
{ name: twistCarrier, offset: () => palmDown.clone() },
|
|
369
|
+
{ name: wristName, offset: (p) => wristFlap(p) },
|
|
370
|
+
]),
|
|
371
|
+
];
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
// Roles that expand to several bones (MMD FK/D leg pairs) share a motion;
|
|
375
|
+
// the D bones are siblings of the FK bones on standard rigs and both drive
|
|
376
|
+
// skin weights depending on the model.
|
|
377
|
+
const legFor = (side, phaseShift) => {
|
|
378
|
+
const kick = (p) => windmill(p) * KICK_BEATS + phaseShift;
|
|
379
|
+
const role = (suffix) => `${side.key}${suffix}`;
|
|
380
|
+
const expand = (roleName, offset) => targetBoneNamesForRole(rig, roleName)
|
|
381
|
+
.map((name) => ({ name, offset, role: roleName }));
|
|
382
|
+
return [
|
|
383
|
+
...expand(role('UpperLeg'), (p) => rotX(KICK_SWING * Math.sin(kick(p)) + 0.06)),
|
|
384
|
+
...expand(role('LowerLeg'), (p) => rotX(KNEE_BEND * Math.max(0, Math.cos(kick(p))))),
|
|
385
|
+
...expand(role('Foot'), (p) => rotX(ANKLE_POINT + 0.1 * Math.sin(kick(p)))),
|
|
386
|
+
];
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
const specs = [
|
|
390
|
+
{ name: roleBoneName('hips'), offset: (p) => compose(rotX(PRONE_PITCH + 0.12), rotY(0.15 * Math.sin(windmill(p)))) },
|
|
391
|
+
{
|
|
392
|
+
name: roleBoneName('spine'),
|
|
393
|
+
offset: (p) => compose(
|
|
394
|
+
rotX(PRONE_PITCH),
|
|
395
|
+
rotY(BODY_ROLL * Math.sin(windmill(p)) - BREATH_ROLL * breathPulse(p)),
|
|
396
|
+
),
|
|
397
|
+
},
|
|
398
|
+
{ name: roleBoneName('upperChest'), offset: (p) => compose(rotX(-0.1), rotY(0.4 * BODY_ROLL * Math.sin(windmill(p)))) },
|
|
399
|
+
{ name: roleBoneName('chest'), offset: (p) => rotY(0.2 * BODY_ROLL * Math.sin(windmill(p))) },
|
|
400
|
+
{ name: roleBoneName('neck'), offset: (p) => rotX(HEAD_TUCK - 0.3 * BREATH_LIFT * breathPulse(p)) },
|
|
401
|
+
{
|
|
402
|
+
name: roleBoneName('head'),
|
|
403
|
+
offset: (p) => compose(
|
|
404
|
+
rotX(HEAD_TUCK - 0.7 * BREATH_LIFT * breathPulse(p)),
|
|
405
|
+
rotY(-BREATH_TURN * breathPulse(p)),
|
|
406
|
+
),
|
|
407
|
+
},
|
|
408
|
+
...armFor(SIDES.right, 0),
|
|
409
|
+
...armFor(SIDES.left, Math.PI),
|
|
410
|
+
...handFor(SIDES.right),
|
|
411
|
+
...handFor(SIDES.left),
|
|
412
|
+
...legFor(SIDES.right, 0),
|
|
413
|
+
...legFor(SIDES.left, Math.PI),
|
|
414
|
+
];
|
|
415
|
+
|
|
416
|
+
const animated = [];
|
|
417
|
+
const offsetsByBone = new Map();
|
|
418
|
+
for (const spec of specs) {
|
|
419
|
+
const bone = findBone(targetMesh, spec.name);
|
|
420
|
+
if (!bone || offsetsByBone.has(bone)) continue;
|
|
421
|
+
animated.push({
|
|
422
|
+
bone,
|
|
423
|
+
role: spec.role ?? null,
|
|
424
|
+
offset: spec.offset,
|
|
425
|
+
restWorldQuat: bone.getWorldQuaternion(new THREE.Quaternion()),
|
|
426
|
+
parentRestWorldQuat: bone.parent
|
|
427
|
+
? bone.parent.getWorldQuaternion(new THREE.Quaternion())
|
|
428
|
+
: new THREE.Quaternion(),
|
|
429
|
+
});
|
|
430
|
+
offsetsByBone.set(bone, spec.offset);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Guard against non-standard rigs where one of a role's bones is a child of
|
|
434
|
+
// another (e.g. an MMD D bone under its FK counterpart) — the inherited
|
|
435
|
+
// delta already carries the motion there.
|
|
436
|
+
for (let i = animated.length - 1; i >= 0; i -= 1) {
|
|
437
|
+
const { bone, role } = animated[i];
|
|
438
|
+
if (!role) continue;
|
|
439
|
+
const roleAncestor = animated.find((other) => other !== animated[i]
|
|
440
|
+
&& other.role === role
|
|
441
|
+
&& isDescendantOf(bone, other.bone));
|
|
442
|
+
if (roleAncestor) {
|
|
443
|
+
offsetsByBone.delete(bone);
|
|
444
|
+
animated.splice(i, 1);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (animated.length === 0) {
|
|
449
|
+
throw new Error('Freestyle swim clip: no animatable humanoid bones were found.');
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// The specs author world-space intents per branch assuming the MMD layout,
|
|
453
|
+
// where hips (下半身) and spine (上半身) are SIBLINGS under センター. Standard
|
|
454
|
+
// humanoid rigs (Rigify/VRM/Mixamo) parent the spine under the hips, so the
|
|
455
|
+
// accumulated delta would stack the hips' prone pitch on top of the spine's
|
|
456
|
+
// own — folding the body in half and driving the head underwater. Rebase
|
|
457
|
+
// the spine offset by the inverse of the hips offset so its accumulated
|
|
458
|
+
// world delta matches the authored intent on either hierarchy.
|
|
459
|
+
const hipsEntry = animated.find((node) => node.bone.name === roleBoneName('hips'));
|
|
460
|
+
const spineEntry = animated.find((node) => node.bone.name === roleBoneName('spine'));
|
|
461
|
+
if (hipsEntry && spineEntry && isDescendantOf(spineEntry.bone, hipsEntry.bone)) {
|
|
462
|
+
const hipsOffset = hipsEntry.offset;
|
|
463
|
+
const spineOffset = spineEntry.offset;
|
|
464
|
+
const rebasedOffset = (p) => hipsOffset(p).invert().multiply(spineOffset(p));
|
|
465
|
+
spineEntry.offset = rebasedOffset;
|
|
466
|
+
offsetsByBone.set(spineEntry.bone, rebasedOffset);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const frameCount = Math.round(FPS * DURATION) + 1;
|
|
470
|
+
const times = new Float32Array(frameCount);
|
|
471
|
+
for (const node of animated) node.values = new Float32Array(frameCount * 4);
|
|
472
|
+
|
|
473
|
+
const centerBone = findBone(targetMesh, rig?.hipCarrierName);
|
|
474
|
+
const centerState = centerBone ? {
|
|
475
|
+
values: new Float32Array(frameCount * 3),
|
|
476
|
+
restLocalPos: centerBone.position.clone(),
|
|
477
|
+
parentWorldQuatInverse: centerBone.parent
|
|
478
|
+
? centerBone.parent.getWorldQuaternion(new THREE.Quaternion()).invert()
|
|
479
|
+
: new THREE.Quaternion(),
|
|
480
|
+
parentWorldScale: centerBone.parent
|
|
481
|
+
? centerBone.parent.getWorldScale(new THREE.Vector3())
|
|
482
|
+
: new THREE.Vector3(1, 1, 1),
|
|
483
|
+
} : null;
|
|
484
|
+
|
|
485
|
+
const identity = new THREE.Quaternion();
|
|
486
|
+
const deltaCache = new Map();
|
|
487
|
+
const getDelta = (node) => {
|
|
488
|
+
if (!node || node === targetMesh || !node.isBone) return identity;
|
|
489
|
+
let delta = deltaCache.get(node);
|
|
490
|
+
if (!delta) {
|
|
491
|
+
delta = getDelta(node.parent).clone();
|
|
492
|
+
const offset = offsetsByBone.get(node);
|
|
493
|
+
if (offset) delta.multiply(offset.__current);
|
|
494
|
+
deltaCache.set(node, delta);
|
|
495
|
+
}
|
|
496
|
+
return delta;
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
const worldQuat = new THREE.Quaternion();
|
|
500
|
+
const parentWorldQuat = new THREE.Quaternion();
|
|
501
|
+
const bobVector = new THREE.Vector3();
|
|
502
|
+
|
|
503
|
+
for (let frame = 0; frame < frameCount; frame += 1) {
|
|
504
|
+
const phase = (2 * Math.PI * frame) / (frameCount - 1);
|
|
505
|
+
times[frame] = frame / FPS;
|
|
506
|
+
|
|
507
|
+
deltaCache.clear();
|
|
508
|
+
for (const node of animated) node.offset.__current = node.offset(phase);
|
|
509
|
+
|
|
510
|
+
for (const node of animated) {
|
|
511
|
+
const parentDelta = getDelta(node.bone.parent);
|
|
512
|
+
worldQuat.copy(parentDelta).multiply(node.offset.__current).multiply(node.restWorldQuat);
|
|
513
|
+
parentWorldQuat.copy(parentDelta).multiply(node.parentRestWorldQuat);
|
|
514
|
+
parentWorldQuat.invert().multiply(worldQuat).normalize();
|
|
515
|
+
parentWorldQuat.toArray(node.values, frame * 4);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
if (centerState) {
|
|
519
|
+
bobVector.set(0, CENTER_BOB * Math.sin(2 * windmill(phase) - 0.5), 0)
|
|
520
|
+
.applyQuaternion(centerState.parentWorldQuatInverse)
|
|
521
|
+
.divide(centerState.parentWorldScale)
|
|
522
|
+
.add(centerState.restLocalPos);
|
|
523
|
+
bobVector.toArray(centerState.values, frame * 3);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const tracks = animated.map((node) => new THREE.QuaternionKeyframeTrack(
|
|
528
|
+
trackName(node.bone.name, 'quaternion'),
|
|
529
|
+
times.slice(),
|
|
530
|
+
node.values,
|
|
531
|
+
));
|
|
532
|
+
if (centerState) {
|
|
533
|
+
tracks.push(new THREE.VectorKeyframeTrack(
|
|
534
|
+
trackName(centerBone.name, 'position'),
|
|
535
|
+
times.slice(),
|
|
536
|
+
centerState.values,
|
|
537
|
+
));
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
return new THREE.AnimationClip(clipName, DURATION, tracks);
|
|
541
|
+
}
|