@volter/editor-blender 0.1.136 → 0.1.138
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/contributions/blender-palette.source.mjs +20 -0
- package/contributions/blender-play-skin.ts +260 -93
- package/contributions/blender-pose.ts +621 -0
- package/contributions/blender-runtime-skin.ts +209 -187
- package/contributions/blender-runtime.document.tsx +4 -1
- package/contributions/blender.palette.json +4 -1
- package/contributions/blender.style.ts +7 -0
- package/package.json +6 -6
- package/contributions/blender-play-mixer.ts +0 -256
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BLENDER'S POSE, EVALUATED ON THE PRESENTER'S BONES. One evaluator, used by the Timeline
|
|
3
|
+
* (`blender-runtime-skin.ts`) and by a game (`blender-play-skin.ts`), so the two can never disagree
|
|
4
|
+
* with each other, and both follow what Blender itself does:
|
|
5
|
+
*
|
|
6
|
+
* - THE STACK is `BKE_animsys_evaluate_animdata`'s: the NLA tracks bottom to top (mute and solo as
|
|
7
|
+
* Blender reads them), then the active action over them. A strip's time is
|
|
8
|
+
* `nlastrip_get_frame_actionclip`'s (scale, repeat, reverse), its influence its blend in and out
|
|
9
|
+
* or its own `influence`, and outside it the track holds as its extrapolation says.
|
|
10
|
+
* - EACH CHANNEL blends as `nla_blend_value` / `nla_combine_*` blend it: per component, Replace,
|
|
11
|
+
* Combine, Add, Subtract or Multiply at the layer's influence. A component no layer animates
|
|
12
|
+
* keeps the bone's own value; one that is animated starts from the property's default. So an
|
|
13
|
+
* action that keys only the upper body layers over one that keys the legs, with no masks.
|
|
14
|
+
* - THE MATRIX is `BKE_pchan_to_mat4` then `BKE_armature_mat_bone_to_pose`: rotation by the
|
|
15
|
+
* bone's own mode, and `parent @ rest @ basis` down the hierarchy.
|
|
16
|
+
* - CONSTRAINTS run per bone after its parent, in stack order, blended by their influence as
|
|
17
|
+
* `BKE_constraints_solve` blends them. Damped Track is evaluated (`damptrack_do_transform`).
|
|
18
|
+
* Any other type is not, and is named (`unsupported`): its effect is Blender's alone.
|
|
19
|
+
*
|
|
20
|
+
* A clip holds an action's F-Curves whole (`session.py`'s `rna_action_clip`): keys, handles,
|
|
21
|
+
* interpolation, extrapolation and Cycles modifiers, evaluated as `fcurve_eval_keyframes` and
|
|
22
|
+
* `fcm_cycles_time` evaluate them, so a pose is Blender's at any frame, whole or fractional.
|
|
23
|
+
*/
|
|
24
|
+
import type { BlenderActionClip, BlenderClipCurve } from '@volter/blender-engine/browser/rna';
|
|
25
|
+
import type {
|
|
26
|
+
BlenderArmature,
|
|
27
|
+
BlenderArmatureAnimation,
|
|
28
|
+
BlenderArmatureBone,
|
|
29
|
+
BlenderNlaStrip,
|
|
30
|
+
} from '@volter/blender-engine/browser/three/blender-runtime-armature';
|
|
31
|
+
import type { ArmatureRig } from '@volter/blender-engine/browser/three/blender-runtime-skeleton';
|
|
32
|
+
import * as THREE from 'three';
|
|
33
|
+
|
|
34
|
+
type Channel = 'location' | 'rotation_quaternion' | 'rotation_euler' | 'rotation_axis_angle' | 'scale';
|
|
35
|
+
const CHANNELS: readonly Channel[] = ['location', 'rotation_quaternion', 'rotation_euler', 'rotation_axis_angle', 'scale'];
|
|
36
|
+
const DEFAULTS: Record<Channel, readonly number[]> = {
|
|
37
|
+
location: [0, 0, 0],
|
|
38
|
+
rotation_quaternion: [1, 0, 0, 0],
|
|
39
|
+
rotation_euler: [0, 0, 0],
|
|
40
|
+
// Blender's NLA default for an axis-angle channel is all zeros (`anim_sys.cc`, zero_v4).
|
|
41
|
+
rotation_axis_angle: [0, 0, 0, 0],
|
|
42
|
+
scale: [1, 1, 1],
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function float32Of(base64: string): Float32Array {
|
|
46
|
+
const binary = atob(base64);
|
|
47
|
+
const bytes = new Uint8Array(binary.length);
|
|
48
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
49
|
+
return new Float32Array(bytes.buffer, bytes.byteOffset, bytes.byteLength >> 2);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** One F-Curve, as Blender evaluates it. */
|
|
53
|
+
interface PoseCurve {
|
|
54
|
+
readonly extrapolation: string;
|
|
55
|
+
readonly interpolation: readonly number[];
|
|
56
|
+
/** Six per key: `co`, `handle_left`, `handle_right`. */
|
|
57
|
+
readonly keys: Float32Array;
|
|
58
|
+
readonly cycles: readonly [number, number, number, number] | null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** One action read for one armature: its F-Curves, by bone and channel, one per component. */
|
|
62
|
+
export interface PoseClip {
|
|
63
|
+
readonly action: string;
|
|
64
|
+
readonly start: number;
|
|
65
|
+
readonly end: number;
|
|
66
|
+
/** Where Blender holds the active action over NLA strips; infinite where Cycles repeats it. */
|
|
67
|
+
readonly keysStart: number;
|
|
68
|
+
readonly keysEnd: number;
|
|
69
|
+
readonly cyclic: boolean;
|
|
70
|
+
readonly channels: readonly {
|
|
71
|
+
readonly bone: string;
|
|
72
|
+
readonly channel: Channel;
|
|
73
|
+
readonly mask: number;
|
|
74
|
+
readonly stride: number;
|
|
75
|
+
readonly curves: readonly (PoseCurve | null)[];
|
|
76
|
+
}[];
|
|
77
|
+
/** What its curves do in Blender that is not played. */
|
|
78
|
+
readonly unsupported: readonly string[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The clip door's curves as a clip, or null when it holds nothing a pose can take. */
|
|
82
|
+
export function poseClip(clip: BlenderActionClip): PoseClip | null {
|
|
83
|
+
if (!clip.action || clip.clipStart === undefined || clip.clipEnd === undefined || !clip.tracks.length) return null;
|
|
84
|
+
const channels = new Map<string, { bone: string; channel: Channel; mask: number; stride: number; curves: (PoseCurve | null)[] }>();
|
|
85
|
+
const unsupported = new Set<string>();
|
|
86
|
+
for (const track of clip.tracks) {
|
|
87
|
+
const key = `${track.bone}\u0000${track.property}`;
|
|
88
|
+
const stride = DEFAULTS[track.property].length;
|
|
89
|
+
let entry = channels.get(key);
|
|
90
|
+
if (!entry) channels.set(key, entry = { bone: track.bone, channel: track.property, mask: 0, stride, curves: Array.from({ length: stride }, () => null) });
|
|
91
|
+
if (track.index < 0 || track.index >= stride) continue;
|
|
92
|
+
entry.mask |= 1 << track.index;
|
|
93
|
+
entry.curves[track.index] = poseCurve(track);
|
|
94
|
+
for (const thing of track.unsupported ?? []) unsupported.add(`${clip.action}'s ${thing}`);
|
|
95
|
+
}
|
|
96
|
+
for (const thing of clip.unsupported ?? []) unsupported.add(`${clip.action}'s ${thing}`);
|
|
97
|
+
return {
|
|
98
|
+
action: clip.action, start: clip.clipStart, end: clip.clipEnd,
|
|
99
|
+
keysStart: clip.keysStart === null ? -Infinity : clip.keysStart ?? clip.clipStart,
|
|
100
|
+
keysEnd: clip.keysEnd === null ? Infinity : clip.keysEnd ?? clip.clipEnd,
|
|
101
|
+
cyclic: clip.cyclic ?? false, channels: [...channels.values()], unsupported: [...unsupported] };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function poseCurve(curve: Pick<BlenderClipCurve, 'extrapolation' | 'interpolation' | 'keysBase64'> & { readonly cycles?: BlenderClipCurve['cycles'] | undefined }): PoseCurve {
|
|
105
|
+
return { extrapolation: curve.extrapolation, interpolation: curve.interpolation, keys: float32Of(curve.keysBase64), cycles: curve.cycles ?? null };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** `BKE_fcurve_correct_bezpart`: shorten handles that overlap in time, so the curve is a function. */
|
|
109
|
+
function correctBezpart(v1: number[], v2: number[], v3: number[], v4: number[]): void {
|
|
110
|
+
const h1 = [v1[0]! - v2[0]!, v1[1]! - v2[1]!];
|
|
111
|
+
const h2 = [v4[0]! - v3[0]!, v4[1]! - v3[1]!];
|
|
112
|
+
const length = v4[0]! - v1[0]!;
|
|
113
|
+
const len1 = Math.abs(h1[0]!);
|
|
114
|
+
const len2 = Math.abs(h2[0]!);
|
|
115
|
+
if (len1 + len2 === 0) return;
|
|
116
|
+
if (len1 + len2 > length) {
|
|
117
|
+
const fac = length / (len1 + len2);
|
|
118
|
+
v2[0] = v1[0]! - fac * h1[0]!;
|
|
119
|
+
v2[1] = v1[1]! - fac * h1[1]!;
|
|
120
|
+
v3[0] = v4[0]! - fac * h2[0]!;
|
|
121
|
+
v3[1] = v4[1]! - fac * h2[1]!;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The Bezier segment's value at `x` (`findzero` then `berekeny`): x(t) is monotonic once
|
|
126
|
+
* corrected, so its one root in [0, 1] is found by Newton steps kept inside a bisection. */
|
|
127
|
+
function bezierAt(x: number, v1: readonly number[], v2: readonly number[], v3: readonly number[], v4: readonly number[]): number {
|
|
128
|
+
const cubic = (a: number, b: number, c: number, d: number, t: number): number => {
|
|
129
|
+
const u = 1 - t;
|
|
130
|
+
return u * u * u * a + 3 * u * u * t * b + 3 * u * t * t * c + t * t * t * d;
|
|
131
|
+
};
|
|
132
|
+
let lo = 0;
|
|
133
|
+
let hi = 1;
|
|
134
|
+
let t = (x - v1[0]!) / ((v4[0]! - v1[0]!) || 1);
|
|
135
|
+
for (let i = 0; i < 60; i++) {
|
|
136
|
+
const at = cubic(v1[0]!, v2[0]!, v3[0]!, v4[0]!, t) - x;
|
|
137
|
+
if (Math.abs(at) < 1e-9) break;
|
|
138
|
+
if (at < 0) lo = t;
|
|
139
|
+
else hi = t;
|
|
140
|
+
const u = 1 - t;
|
|
141
|
+
const slope = 3 * u * u * (v2[0]! - v1[0]!) + 6 * u * t * (v3[0]! - v2[0]!) + 3 * t * t * (v4[0]! - v3[0]!);
|
|
142
|
+
const next = slope !== 0 ? t - at / slope : (lo + hi) / 2;
|
|
143
|
+
t = next > lo && next < hi ? next : (lo + hi) / 2;
|
|
144
|
+
}
|
|
145
|
+
return cubic(v1[1]!, v2[1]!, v3[1]!, v4[1]!, t);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** `fcurve_eval_keyframes`: the curve's own value at a frame, extrapolated past its keys. */
|
|
149
|
+
function keyframesAt(curve: PoseCurve, frame: number): number {
|
|
150
|
+
const k = curve.keys;
|
|
151
|
+
const count = k.length / 6;
|
|
152
|
+
if (count === 0) return 0;
|
|
153
|
+
const x = (i: number): number => k[i * 6]!;
|
|
154
|
+
const y = (i: number): number => k[i * 6 + 1]!;
|
|
155
|
+
if (count === 1) return y(0);
|
|
156
|
+
const last = count - 1;
|
|
157
|
+
// PAST AN END, a Linear curve leaves along the end key's own interpolation: its handle when it is
|
|
158
|
+
// a Bezier key, the segment beside it when Linear, and not at all when Constant.
|
|
159
|
+
if (frame <= x(0)) {
|
|
160
|
+
if (curve.extrapolation !== 'LINEAR' || frame === x(0) || curve.interpolation[0] === 0) return y(0);
|
|
161
|
+
const dx = x(0) - frame;
|
|
162
|
+
if (curve.interpolation[0] === 2) {
|
|
163
|
+
const fac = x(0) - k[2]!;
|
|
164
|
+
return fac !== 0 ? y(0) - ((y(0) - k[3]!) / fac) * dx : y(0);
|
|
165
|
+
}
|
|
166
|
+
const fac = x(1) - x(0);
|
|
167
|
+
return fac !== 0 ? y(0) - ((y(1) - y(0)) / fac) * dx : y(0);
|
|
168
|
+
}
|
|
169
|
+
if (frame >= x(last)) {
|
|
170
|
+
if (curve.extrapolation !== 'LINEAR' || frame === x(last) || curve.interpolation[last] === 0) return y(last);
|
|
171
|
+
const dx = frame - x(last);
|
|
172
|
+
if (curve.interpolation[last] === 2) {
|
|
173
|
+
const fac = k[last * 6 + 4]! - x(last);
|
|
174
|
+
return fac !== 0 ? y(last) + ((k[last * 6 + 5]! - y(last)) / fac) * dx : y(last);
|
|
175
|
+
}
|
|
176
|
+
const fac = x(last) - x(last - 1);
|
|
177
|
+
return fac !== 0 ? y(last) + ((y(last) - y(last - 1)) / fac) * dx : y(last);
|
|
178
|
+
}
|
|
179
|
+
let lo = 0;
|
|
180
|
+
let hi = last;
|
|
181
|
+
while (hi - lo > 1) {
|
|
182
|
+
const mid = (lo + hi) >> 1;
|
|
183
|
+
if (x(mid) <= frame) lo = mid;
|
|
184
|
+
else hi = mid;
|
|
185
|
+
}
|
|
186
|
+
if (frame === x(lo)) return y(lo);
|
|
187
|
+
const mode = curve.interpolation[lo] ?? 1;
|
|
188
|
+
if (mode === 0) return y(lo);
|
|
189
|
+
if (mode === 1) return y(lo) + ((y(hi) - y(lo)) * (frame - x(lo))) / (x(hi) - x(lo));
|
|
190
|
+
const v1 = [x(lo), y(lo)];
|
|
191
|
+
const v2 = [k[lo * 6 + 4]!, k[lo * 6 + 5]!];
|
|
192
|
+
const v3 = [k[hi * 6 + 2]!, k[hi * 6 + 3]!];
|
|
193
|
+
const v4 = [x(hi), y(hi)];
|
|
194
|
+
const flat = 1.1920929e-7;
|
|
195
|
+
if (Math.abs(v1[1]! - v4[1]!) < flat && Math.abs(v2[1]! - v3[1]!) < flat && Math.abs(v3[1]! - v4[1]!) < flat) return v1[1]!;
|
|
196
|
+
correctBezpart(v1, v2, v3, v4);
|
|
197
|
+
return bezierAt(frame, v1, v2, v3, v4);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** The curve at a frame, through its Cycles modifier as `fcm_cycles_time` maps it. */
|
|
201
|
+
export function curveAt(curve: PoseCurve, frame: number): number {
|
|
202
|
+
const cycles = curve.cycles;
|
|
203
|
+
const k = curve.keys;
|
|
204
|
+
const count = k.length / 6;
|
|
205
|
+
if (!cycles || count < 2) return keyframesAt(curve, frame);
|
|
206
|
+
const firstX = k[0]!;
|
|
207
|
+
const firstY = k[1]!;
|
|
208
|
+
const lastX = k[(count - 1) * 6]!;
|
|
209
|
+
const lastY = k[(count - 1) * 6 + 1]!;
|
|
210
|
+
let side = 0;
|
|
211
|
+
let mode = 0;
|
|
212
|
+
let limit = 0;
|
|
213
|
+
let ofs = 0;
|
|
214
|
+
if (frame < firstX && cycles[0]) { side = -1; mode = cycles[0]; limit = cycles[1]; ofs = firstX; }
|
|
215
|
+
else if (frame > lastX && cycles[2]) { side = 1; mode = cycles[2]; limit = cycles[3]; ofs = lastX; }
|
|
216
|
+
if (side === 0 || mode === 0) return keyframesAt(curve, frame);
|
|
217
|
+
const cycdx = lastX - firstX;
|
|
218
|
+
const cycdy = lastY - firstY;
|
|
219
|
+
if (cycdx === 0) return keyframesAt(curve, frame);
|
|
220
|
+
const cycle = (side * (frame - ofs)) / cycdx;
|
|
221
|
+
if (limit && cycle >= limit) {
|
|
222
|
+
// Past its count the curve holds where its last cycle ends.
|
|
223
|
+
const odd = mode === 3 && limit % 2 === 1;
|
|
224
|
+
const time = side === 1 ? (odd ? firstX : lastX) : (odd ? lastX : firstX);
|
|
225
|
+
return keyframesAt(curve, time) + (mode === 2 ? cycdy * limit * side : 0);
|
|
226
|
+
}
|
|
227
|
+
const cyct = (frame - ofs) % cycdx;
|
|
228
|
+
const offset = mode === 2 ? (side < 0 ? Math.floor((frame - ofs) / cycdx) : Math.ceil((frame - ofs) / cycdx)) * cycdy : 0;
|
|
229
|
+
let time: number;
|
|
230
|
+
if (cyct === 0) {
|
|
231
|
+
time = side === 1 ? lastX : firstX;
|
|
232
|
+
if (mode === 3 && Math.trunc(cycle) % 2) time = side === 1 ? firstX : lastX;
|
|
233
|
+
} else if (mode === 3 && Math.trunc(cycle + 1) % 2) {
|
|
234
|
+
time = side < 0 ? firstX - cyct : lastX - cyct;
|
|
235
|
+
} else {
|
|
236
|
+
time = (side < 0 ? lastX : firstX) + cyct;
|
|
237
|
+
}
|
|
238
|
+
if (time < firstX) time += cycdx;
|
|
239
|
+
return keyframesAt(curve, time) + offset;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* THE ACTIVE ACTION'S LAYER at a scene frame, placed as `animsys_calculate_nla` places it: a soloed
|
|
245
|
+
* track leaves it out; when no enabled track has strips (or the NLA is off) it is evaluated alone,
|
|
246
|
+
* whole, at full influence; otherwise it is one more strip spanning its own range, at its influence
|
|
247
|
+
* and blend type, holding or not past that range as its extrapolation says.
|
|
248
|
+
*/
|
|
249
|
+
export function actionLayer(animation: BlenderArmatureAnimation | undefined, clip: PoseClip, frame: number,
|
|
250
|
+
stripsEvaluated: boolean): PoseLayer | null {
|
|
251
|
+
const nla = animation?.useNla ?? true;
|
|
252
|
+
if (nla && animation!.tracks.some((track) => track.solo)) return null;
|
|
253
|
+
if (!nla || !stripsEvaluated) return { clip, frame, influence: 1, blend: 'REPLACE' };
|
|
254
|
+
const extrapolation = animation!.extrapolation;
|
|
255
|
+
let at = frame;
|
|
256
|
+
if (frame < clip.keysStart) {
|
|
257
|
+
if (extrapolation !== 'HOLD') return null;
|
|
258
|
+
at = clip.keysStart;
|
|
259
|
+
} else if (frame > clip.keysEnd) {
|
|
260
|
+
if (extrapolation === 'NOTHING') return null;
|
|
261
|
+
at = clip.keysEnd;
|
|
262
|
+
}
|
|
263
|
+
return { clip, frame: at, influence: animation!.influence, blend: animation!.blendType };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** One layer of the stack: an action at a frame of its own, an influence, a blend type. */
|
|
267
|
+
export interface PoseLayer {
|
|
268
|
+
readonly clip: PoseClip;
|
|
269
|
+
readonly frame: number;
|
|
270
|
+
readonly influence: number;
|
|
271
|
+
readonly blend: string;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** A clip by action name: the clip, `undefined` while it is being baked, null when it has none. */
|
|
275
|
+
export type ClipSource = (action: string) => PoseClip | null | undefined;
|
|
276
|
+
|
|
277
|
+
/** The strip a track plays at `frame`, and the time it plays it at (`nlastrips_ctime_get_strip`). */
|
|
278
|
+
function stripAt(strips: readonly BlenderNlaStrip[], frame: number): { strip: BlenderNlaStrip; time: number } | null {
|
|
279
|
+
const live = strips.filter((strip) => !strip.mute).sort((a, b) => a.start - b.start);
|
|
280
|
+
for (const strip of live) if (frame >= strip.start && frame <= strip.end) return { strip, time: frame };
|
|
281
|
+
const first = live[0];
|
|
282
|
+
if (!first) return null;
|
|
283
|
+
if (frame < first.start) return first.extrapolation === 'HOLD' ? { strip: first, time: first.start } : null;
|
|
284
|
+
let before: BlenderNlaStrip | null = null;
|
|
285
|
+
for (const strip of live) if (strip.end < frame) before = strip;
|
|
286
|
+
if (before && (before.extrapolation === 'HOLD' || before.extrapolation === 'HOLD_FORWARD')) return { strip: before, time: before.end };
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** The action frame a strip shows at `time` (`nlastrip_get_frame_actionclip`, evaluation). */
|
|
291
|
+
function stripFrame(strip: BlenderNlaStrip, time: number): number {
|
|
292
|
+
const length = strip.actionEnd - strip.actionStart || 1;
|
|
293
|
+
const scale = Math.abs(strip.scale) || 1;
|
|
294
|
+
const atWholeEnd = Math.abs(strip.repeat % 1) < 1e-6 && Math.abs(time - strip.end) < 1e-6;
|
|
295
|
+
const into = ((time - strip.start) % (length * scale)) / scale;
|
|
296
|
+
if (strip.reversed) return atWholeEnd ? strip.actionStart : strip.actionEnd - into;
|
|
297
|
+
return atWholeEnd ? strip.actionEnd : strip.actionStart + into;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const influenceCurves = new WeakMap<object, PoseCurve>();
|
|
301
|
+
|
|
302
|
+
/** A strip's influence at `time`: its keyed curve when it is animated (`nlastrip_evaluate_controls`),
|
|
303
|
+
* else from its blend in and out (`nlastrip_get_influence`). */
|
|
304
|
+
function stripInfluence(strip: BlenderNlaStrip, time: number): number {
|
|
305
|
+
if (strip.animatedInfluence) {
|
|
306
|
+
const keyed = strip.influenceCurve;
|
|
307
|
+
if (!keyed) return strip.influence;
|
|
308
|
+
let curve = influenceCurves.get(keyed);
|
|
309
|
+
if (!curve) influenceCurves.set(keyed, curve = poseCurve(keyed));
|
|
310
|
+
return Math.max(0, Math.min(1, curveAt(curve, time)));
|
|
311
|
+
}
|
|
312
|
+
if (strip.blendIn > 0 && time >= strip.start && time <= strip.start + strip.blendIn) return (time - strip.start) / strip.blendIn;
|
|
313
|
+
if (strip.blendOut > 0 && time >= strip.end - strip.blendOut && time <= strip.end) return (strip.end - time) / strip.blendOut;
|
|
314
|
+
return 1;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* The NLA's layers at a scene frame, bottom to top, as Blender evaluates them. `waiting` says a
|
|
319
|
+
* clip it needs is still being baked; `skipped` names what plays in Blender and not here;
|
|
320
|
+
* `evaluated` says an enabled track has strips (what places the active action over the NLA).
|
|
321
|
+
*/
|
|
322
|
+
export function nlaLayers(animation: BlenderArmatureAnimation | undefined, frame: number, clips: ClipSource,
|
|
323
|
+
tracks?: (name: string) => boolean): { layers: PoseLayer[]; waiting: boolean; skipped: string[]; evaluated: boolean } {
|
|
324
|
+
const layers: PoseLayer[] = [];
|
|
325
|
+
const skipped: string[] = [];
|
|
326
|
+
let waiting = false;
|
|
327
|
+
let evaluated = false;
|
|
328
|
+
if (!animation || !animation.useNla) return { layers, waiting, skipped, evaluated };
|
|
329
|
+
const solo = animation.tracks.some((track) => track.solo);
|
|
330
|
+
for (const track of animation.tracks) {
|
|
331
|
+
// ENABLED as `BKE_nlatrack_is_enabled` says: under a solo only the soloed track, muted or not.
|
|
332
|
+
if ((solo ? !track.solo : track.mute) || (tracks && !tracks(track.name))) continue;
|
|
333
|
+
// AN ENABLED TRACK WITH STRIPS places the active action over the NLA, whether or not the frame
|
|
334
|
+
// falls on one of them (`animsys_evaluate_nla_for_flush`'s `has_strips`; measured in 5.2).
|
|
335
|
+
if (track.strips.length) evaluated = true;
|
|
336
|
+
const hit = stripAt(track.strips, frame);
|
|
337
|
+
if (!hit) continue;
|
|
338
|
+
const influence = stripInfluence(hit.strip, hit.time);
|
|
339
|
+
if (hit.strip.type !== 'CLIP' || !hit.strip.action) {
|
|
340
|
+
skipped.push(`the ${hit.strip.type.toLowerCase()} strip "${hit.strip.name}" on track "${track.name}"`);
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
const clip = clips(hit.strip.action);
|
|
344
|
+
if (clip === undefined) waiting = true;
|
|
345
|
+
if (!clip) continue;
|
|
346
|
+
layers.push({ clip, frame: stripFrame(hit.strip, hit.time), influence, blend: hit.strip.blendType });
|
|
347
|
+
}
|
|
348
|
+
return { layers, waiting, skipped, evaluated };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
type Values = Record<Channel, number[]>;
|
|
352
|
+
|
|
353
|
+
const quatMul = (a: readonly number[], b: readonly number[]): number[] => [
|
|
354
|
+
a[0]! * b[0]! - a[1]! * b[1]! - a[2]! * b[2]! - a[3]! * b[3]!,
|
|
355
|
+
a[0]! * b[1]! + a[1]! * b[0]! + a[2]! * b[3]! - a[3]! * b[2]!,
|
|
356
|
+
a[0]! * b[2]! + a[2]! * b[0]! + a[3]! * b[1]! - a[1]! * b[3]!,
|
|
357
|
+
a[0]! * b[3]! + a[3]! * b[0]! + a[1]! * b[2]! - a[2]! * b[1]!,
|
|
358
|
+
];
|
|
359
|
+
const quatNormal = (q: readonly number[]): number[] => {
|
|
360
|
+
const length = Math.hypot(q[0]!, q[1]!, q[2]!, q[3]!);
|
|
361
|
+
return length > 0 ? q.map((v) => v / length) : [1, 0, 0, 0];
|
|
362
|
+
};
|
|
363
|
+
/** `pow_qt_fl_normalized`. */
|
|
364
|
+
const quatPow = (q: readonly number[], k: number): number[] => {
|
|
365
|
+
const angle = k * Math.acos(Math.max(-1, Math.min(1, q[0]!)));
|
|
366
|
+
const length = Math.hypot(q[1]!, q[2]!, q[3]!);
|
|
367
|
+
const s = Math.sin(angle);
|
|
368
|
+
return length > 0 ? [Math.cos(angle), (q[1]! / length) * s, (q[2]! / length) * s, (q[3]! / length) * s] : [Math.cos(angle), 0, 0, 0];
|
|
369
|
+
};
|
|
370
|
+
/** One layer blended into the values (`nla_blend_value`, `nla_combine_value`, `nla_combine_quaternion`). */
|
|
371
|
+
function blendLayer(values: Map<string, Values>, touched: Map<string, Record<Channel, number>>, layer: PoseLayer): void {
|
|
372
|
+
const k = Math.max(0, Math.min(1, layer.influence));
|
|
373
|
+
if (k === 0) return;
|
|
374
|
+
const frame = layer.frame;
|
|
375
|
+
for (const channel of layer.clip.channels) {
|
|
376
|
+
const bone = values.get(channel.bone);
|
|
377
|
+
if (!bone) continue;
|
|
378
|
+
const marks = touched.get(channel.bone)!;
|
|
379
|
+
const out = bone[channel.channel];
|
|
380
|
+
// A COMPONENT AN ACTION ANIMATES starts from the property's default, as Blender's NLA does.
|
|
381
|
+
for (let i = 0; i < channel.stride; i++) if (channel.mask & (1 << i) && !(marks[channel.channel] & (1 << i))) out[i] = DEFAULTS[channel.channel][i]!;
|
|
382
|
+
marks[channel.channel] |= channel.mask;
|
|
383
|
+
const sample = channel.curves.map((curve) => (curve ? curveAt(curve, frame) : 0));
|
|
384
|
+
if (layer.blend === 'COMBINE' && channel.channel === 'rotation_quaternion') {
|
|
385
|
+
const result = quatMul(quatNormal(out), quatPow(quatNormal(sample), k));
|
|
386
|
+
for (let i = 0; i < channel.stride; i++) if (channel.mask & (1 << i)) out[i] = result[i]!;
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
for (let i = 0; i < channel.stride; i++) {
|
|
390
|
+
if (!(channel.mask & (1 << i))) continue;
|
|
391
|
+
const lower = out[i]!;
|
|
392
|
+
const upper = sample[i]!;
|
|
393
|
+
switch (layer.blend) {
|
|
394
|
+
case 'ADD': out[i] = lower + upper * k; break;
|
|
395
|
+
case 'SUBTRACT': out[i] = lower - upper * k; break;
|
|
396
|
+
case 'MULTIPLY': out[i] = k * (lower * upper) + (1 - k) * lower; break;
|
|
397
|
+
case 'COMBINE':
|
|
398
|
+
out[i] = channel.channel === 'scale' ? lower * Math.pow(upper, k) : lower + (upper - DEFAULTS[channel.channel][i]!) * k;
|
|
399
|
+
break;
|
|
400
|
+
default: out[i] = lower * (1 - k) + upper * k;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** `BKE_pchan_to_mat4`: the channels as the bone's basis matrix, rotation by its own mode. */
|
|
407
|
+
function basisMatrix(mode: string, values: Values): THREE.Matrix4 {
|
|
408
|
+
const rotation = new THREE.Quaternion();
|
|
409
|
+
if (mode === 'QUATERNION') {
|
|
410
|
+
const q = quatNormal(values.rotation_quaternion);
|
|
411
|
+
rotation.set(q[1]!, q[2]!, q[3]!, q[0]!);
|
|
412
|
+
} else if (mode === 'AXIS_ANGLE') {
|
|
413
|
+
const v = values.rotation_axis_angle;
|
|
414
|
+
rotation.setFromAxisAngle(new THREE.Vector3(v[1], v[2], v[3]).normalize(), v[0]!);
|
|
415
|
+
} else {
|
|
416
|
+
// Blender's `XYZ` applies X first; three's Euler order names the matrix product, so it reads
|
|
417
|
+
// the same rotation reversed.
|
|
418
|
+
const e = values.rotation_euler;
|
|
419
|
+
rotation.setFromEuler(new THREE.Euler(e[0], e[1], e[2], mode.split('').reverse().join('') as THREE.EulerOrder));
|
|
420
|
+
}
|
|
421
|
+
const [x, y, z] = values.location;
|
|
422
|
+
const [sx, sy, sz] = values.scale;
|
|
423
|
+
return new THREE.Matrix4().compose(new THREE.Vector3(x, y, z), rotation, new THREE.Vector3(sx, sy, sz));
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const TRACK_AXES = ['TRACK_X', 'TRACK_Y', 'TRACK_Z', 'TRACK_NEGATIVE_X', 'TRACK_NEGATIVE_Y', 'TRACK_NEGATIVE_Z'];
|
|
427
|
+
const TRACK_VECTORS = [[1, 0, 0], [0, 1, 0], [0, 0, 1], [-1, 0, 0], [0, -1, 0], [0, 0, -1]] as const;
|
|
428
|
+
|
|
429
|
+
/** `damptrack_do_transform`: the smallest rotation that points the owner's axis at the target. */
|
|
430
|
+
function dampedTrack(owner: THREE.Matrix4, target: THREE.Vector3, axis: string): THREE.Matrix4 {
|
|
431
|
+
const flag = Math.max(0, TRACK_AXES.indexOf(axis));
|
|
432
|
+
const basis = new THREE.Matrix3().setFromMatrix4(owner);
|
|
433
|
+
const obvec = new THREE.Vector3(...TRACK_VECTORS[flag]!).applyMatrix3(basis);
|
|
434
|
+
if (obvec.lengthSq() === 0) return owner.clone();
|
|
435
|
+
obvec.normalize();
|
|
436
|
+
const obloc = new THREE.Vector3().setFromMatrixPosition(owner);
|
|
437
|
+
const tarvec = target.clone().sub(obloc);
|
|
438
|
+
if (tarvec.lengthSq() === 0) tarvec.copy(obvec);
|
|
439
|
+
else tarvec.normalize();
|
|
440
|
+
const raxis = new THREE.Vector3().crossVectors(obvec, tarvec);
|
|
441
|
+
let rangle = Math.acos(Math.max(-1, Math.min(1, obvec.dot(tarvec))));
|
|
442
|
+
const norm = raxis.length();
|
|
443
|
+
if (norm < 1.1920929e-7) {
|
|
444
|
+
if (Math.abs(rangle) < Math.PI - 0.01) return owner.clone();
|
|
445
|
+
rangle = Math.PI;
|
|
446
|
+
const other = new THREE.Vector3(...TRACK_VECTORS[(flag + 1) % 6]!).applyMatrix3(basis);
|
|
447
|
+
raxis.crossVectors(obvec, other);
|
|
448
|
+
if (raxis.lengthSq() === 0) return owner.clone();
|
|
449
|
+
} else if (norm < 0.1) {
|
|
450
|
+
rangle = rangle > Math.PI / 2 ? Math.PI - Math.asin(norm) : Math.asin(norm);
|
|
451
|
+
}
|
|
452
|
+
const turned = new THREE.Matrix4().makeRotationAxis(raxis.normalize(), rangle).multiply(owner);
|
|
453
|
+
return turned.setPosition(obloc);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** `interp_m4_m4m4`: a constraint's result taken by its influence. */
|
|
457
|
+
function blendMatrix(before: THREE.Matrix4, after: THREE.Matrix4, k: number): THREE.Matrix4 {
|
|
458
|
+
if (k >= 1) return after;
|
|
459
|
+
const [p0, q0, s0] = [new THREE.Vector3(), new THREE.Quaternion(), new THREE.Vector3()];
|
|
460
|
+
const [p1, q1, s1] = [new THREE.Vector3(), new THREE.Quaternion(), new THREE.Vector3()];
|
|
461
|
+
before.decompose(p0, q0, s0);
|
|
462
|
+
after.decompose(p1, q1, s1);
|
|
463
|
+
return new THREE.Matrix4().compose(p0.lerp(p1, k), q0.slerp(q1, k), s0.lerp(s1, k));
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** What a game changes on a constraint while it runs. */
|
|
467
|
+
export interface ConstraintOverride {
|
|
468
|
+
readonly influence?: number;
|
|
469
|
+
/** The object it aims at instead of the file's target. */
|
|
470
|
+
readonly target?: THREE.Object3D | null;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** Where a constraint's targets are, in the scene the bones are in. */
|
|
474
|
+
export interface ConstraintWorld {
|
|
475
|
+
/** An object by its Blender name (its presented copy). */
|
|
476
|
+
object(name: string): THREE.Object3D | null;
|
|
477
|
+
/** A game's change to one constraint of one bone, if it made one. */
|
|
478
|
+
override?(bone: string, constraint: string): ConstraintOverride | undefined;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** One armature's bones, posed by the stack: Blender's evaluation, on the presenter's skeleton. */
|
|
482
|
+
export class ArmaturePose {
|
|
483
|
+
#bones: readonly BlenderArmatureBone[] = [];
|
|
484
|
+
#restRelative = new Map<string, THREE.Matrix4>();
|
|
485
|
+
#armature: BlenderArmature | null = null;
|
|
486
|
+
|
|
487
|
+
constructor(readonly rig: ArmatureRig) {}
|
|
488
|
+
|
|
489
|
+
/** Take the frame's facts for this armature (its bones' rest, channels and constraints). */
|
|
490
|
+
facts(armature: BlenderArmature): void {
|
|
491
|
+
if (armature === this.#armature) return;
|
|
492
|
+
this.#armature = armature;
|
|
493
|
+
this.#bones = armature.bones;
|
|
494
|
+
const rest = new Map(armature.bones.map((bone) => [bone.name, matrixOf(bone.rest ?? bone.matrix)]));
|
|
495
|
+
this.#restRelative = new Map(armature.bones.map((bone) => {
|
|
496
|
+
const own = rest.get(bone.name)!;
|
|
497
|
+
const parent = bone.parent === null ? undefined : rest.get(bone.parent);
|
|
498
|
+
return [bone.name, parent ? parent.clone().invert().multiply(own) : own.clone()];
|
|
499
|
+
}));
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
get armature(): BlenderArmature | null {
|
|
503
|
+
return this.#armature;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** What this armature does in Blender that the evaluator does not, besides its clips' own
|
|
507
|
+
* (`PoseClip.unsupported`): each named once. */
|
|
508
|
+
unsupported(): string[] {
|
|
509
|
+
const out: string[] = [];
|
|
510
|
+
for (const bone of this.#bones) {
|
|
511
|
+
for (const constraint of bone.constraints ?? []) {
|
|
512
|
+
if (constraint.enabled && constraint.type !== 'DAMPED_TRACK') out.push(`the ${constraintLabel(constraint.type)} constraint "${constraint.name}" on bone "${bone.name}"`);
|
|
513
|
+
}
|
|
514
|
+
if (bone.inherit?.length) out.push(`bone "${bone.name}"'s parenting (${bone.inherit.join(', ')})`);
|
|
515
|
+
}
|
|
516
|
+
for (const bone of this.#armature?.animation?.drivers ?? []) out.push(`the drivers on bone "${bone}"`);
|
|
517
|
+
if (this.#armature?.animation?.tweak) out.push('the NLA in tweak mode (leave it with Tab in the NLA editor)');
|
|
518
|
+
for (const track of this.#armature?.animation?.tracks ?? [])
|
|
519
|
+
for (const strip of track.strips)
|
|
520
|
+
if (!strip.mute && strip.animatedTime) out.push(`the animated strip time of "${strip.name}"`);
|
|
521
|
+
return out;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** Pose the bones from these layers and constraints; answers each bone's armature-space pose.
|
|
525
|
+
* `write: false` only answers it, leaving the bones as they are (the fidelity check). */
|
|
526
|
+
apply(layers: readonly PoseLayer[], world: ConstraintWorld, write = true): Map<string, THREE.Matrix4> {
|
|
527
|
+
const values = new Map<string, Values>();
|
|
528
|
+
const touched = new Map<string, Record<Channel, number>>();
|
|
529
|
+
for (const bone of this.#bones) {
|
|
530
|
+
const own = bone.channels;
|
|
531
|
+
values.set(bone.name, Object.fromEntries(CHANNELS.map((channel) => [channel, [...(own?.[channel] ?? DEFAULTS[channel])]])) as Values);
|
|
532
|
+
touched.set(bone.name, { location: 0, rotation_quaternion: 0, rotation_euler: 0, rotation_axis_angle: 0, scale: 0 });
|
|
533
|
+
}
|
|
534
|
+
for (const layer of layers) blendLayer(values, touched, layer);
|
|
535
|
+
|
|
536
|
+
this.rig.object.updateWorldMatrix(true, false);
|
|
537
|
+
const armatureWorld = this.rig.object.matrixWorld;
|
|
538
|
+
const toArmature = armatureWorld.clone().invert();
|
|
539
|
+
const poses = new Map<string, THREE.Matrix4>();
|
|
540
|
+
for (const bone of this.#bones) {
|
|
541
|
+
const parent = bone.parent === null ? null : poses.get(bone.parent) ?? null;
|
|
542
|
+
const basis = basisMatrix(bone.channels?.mode ?? 'QUATERNION', values.get(bone.name)!);
|
|
543
|
+
let pose = (parent ? parent.clone() : new THREE.Matrix4()).multiply(this.#restRelative.get(bone.name)!).multiply(basis);
|
|
544
|
+
for (const constraint of bone.constraints ?? []) {
|
|
545
|
+
if (!constraint.enabled || constraint.type !== 'DAMPED_TRACK') continue;
|
|
546
|
+
const change = world.override?.(bone.name, constraint.name);
|
|
547
|
+
const influence = change?.influence ?? constraint.influence;
|
|
548
|
+
if (influence <= 0) continue;
|
|
549
|
+
const target = this.#target(constraint, change, poses, world);
|
|
550
|
+
if (!target) continue;
|
|
551
|
+
const owner = armatureWorld.clone().multiply(pose);
|
|
552
|
+
const solved = dampedTrack(owner, target, constraint.trackAxis ?? 'TRACK_Y');
|
|
553
|
+
pose = toArmature.clone().multiply(blendMatrix(owner, solved, influence));
|
|
554
|
+
}
|
|
555
|
+
poses.set(bone.name, pose);
|
|
556
|
+
}
|
|
557
|
+
if (!write) return poses;
|
|
558
|
+
for (const bone of this.#bones) {
|
|
559
|
+
const own = poses.get(bone.name)!;
|
|
560
|
+
const parent = bone.parent === null ? undefined : poses.get(bone.parent);
|
|
561
|
+
const local = parent ? parent.clone().invert().multiply(own) : own;
|
|
562
|
+
const presented = this.rig.bones.get(bone.name);
|
|
563
|
+
if (presented) local.decompose(presented.position, presented.quaternion, presented.scale);
|
|
564
|
+
}
|
|
565
|
+
this.rig.object.updateMatrixWorld(true);
|
|
566
|
+
return poses;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** A constraint's target point in world space: an object's origin, or a point along a bone. */
|
|
570
|
+
#target(constraint: NonNullable<BlenderArmatureBone['constraints']>[number], change: ConstraintOverride | undefined,
|
|
571
|
+
poses: ReadonlyMap<string, THREE.Matrix4>, world: ConstraintWorld): THREE.Vector3 | null {
|
|
572
|
+
if (change?.target) {
|
|
573
|
+
change.target.updateWorldMatrix(true, false);
|
|
574
|
+
return new THREE.Vector3().setFromMatrixPosition(change.target.matrixWorld);
|
|
575
|
+
}
|
|
576
|
+
if (change?.target === null || !constraint.target) return null;
|
|
577
|
+
const object = world.object(constraint.target);
|
|
578
|
+
if (!object) return null;
|
|
579
|
+
object.updateWorldMatrix(true, false);
|
|
580
|
+
if (!constraint.subtarget) return new THREE.Vector3().setFromMatrixPosition(object.matrixWorld);
|
|
581
|
+
const own = constraint.target === this.rig.armature;
|
|
582
|
+
const pose = own ? poses.get(constraint.subtarget) : undefined;
|
|
583
|
+
const bone = this.#bones.find((one) => one.name === constraint.subtarget);
|
|
584
|
+
if (own && pose && bone) {
|
|
585
|
+
const along = new THREE.Vector3(0, bone.length * (constraint.headTail ?? 0), 0).applyMatrix4(pose);
|
|
586
|
+
return along.applyMatrix4(this.rig.object.matrixWorld);
|
|
587
|
+
}
|
|
588
|
+
const presented = object.getObjectByName(constraint.subtarget);
|
|
589
|
+
return presented ? new THREE.Vector3().setFromMatrixPosition(presented.matrixWorld) : null;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const matrixOf = (rows: readonly (readonly number[])[]): THREE.Matrix4 =>
|
|
594
|
+
new THREE.Matrix4().set(...(rows.flat() as Parameters<THREE.Matrix4['set']>));
|
|
595
|
+
|
|
596
|
+
const constraintLabel = (type: string): string =>
|
|
597
|
+
type.toLowerCase().split('_').map((word) => (word === 'ik' ? 'IK' : word[0]!.toUpperCase() + word.slice(1))).join(' ');
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* THE FIDELITY CHECK: how far an evaluated pose is from Blender's own at the same frame (the
|
|
601
|
+
* frame's `PoseBone.matrix`). Answers the bone that is furthest off, or null when every bone is
|
|
602
|
+
* within a tenth of a degree and a thousandth of its length.
|
|
603
|
+
*/
|
|
604
|
+
export function poseDivergence(evaluated: ReadonlyMap<string, THREE.Matrix4>, armature: BlenderArmature):
|
|
605
|
+
{ bone: string; degrees: number; offset: number } | null {
|
|
606
|
+
let worst: { bone: string; degrees: number; offset: number; score: number } | null = null;
|
|
607
|
+
const [p0, q0, s0, p1, q1, s1] = [new THREE.Vector3(), new THREE.Quaternion(), new THREE.Vector3(), new THREE.Vector3(), new THREE.Quaternion(), new THREE.Vector3()];
|
|
608
|
+
for (const bone of armature.bones) {
|
|
609
|
+
const ours = evaluated.get(bone.name);
|
|
610
|
+
if (!ours) continue;
|
|
611
|
+
ours.decompose(p0, q0, s0);
|
|
612
|
+
matrixOf(bone.matrix).decompose(p1, q1, s1);
|
|
613
|
+
const degrees = THREE.MathUtils.radToDeg(q0.angleTo(q1));
|
|
614
|
+
// Relative to the bone, but never to less than a tenth of a metre: a fingertip bone a
|
|
615
|
+
// centimetre long is not off by a tenth of a millimetre's float noise.
|
|
616
|
+
const offset = p0.distanceTo(p1) / Math.max(bone.length, 0.1);
|
|
617
|
+
const score = Math.max(degrees / 0.1, offset / 1e-3);
|
|
618
|
+
if (score > 1 && (!worst || score > worst.score)) worst = { bone: bone.name, degrees, offset, score };
|
|
619
|
+
}
|
|
620
|
+
return worst && { bone: worst.bone, degrees: worst.degrees, offset: worst.offset };
|
|
621
|
+
}
|