reze-engine 0.25.2 → 0.27.0
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/README.md +6 -4
- package/dist/animation.d.ts +9 -0
- package/dist/animation.d.ts.map +1 -1
- package/dist/animation.js +4 -0
- package/dist/engine.d.ts +36 -1
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +86 -7
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/model.d.ts +12 -1
- package/dist/model.d.ts.map +1 -1
- package/dist/model.js +59 -3
- package/dist/shaders/passes/composite.d.ts.map +1 -1
- package/dist/shaders/passes/composite.js +28 -1
- package/dist/vmd-loader.d.ts +22 -0
- package/dist/vmd-loader.d.ts.map +1 -1
- package/dist/vmd-loader.js +58 -0
- package/dist/vmd-writer.d.ts.map +1 -1
- package/dist/vmd-writer.js +45 -1
- package/package.json +42 -42
- package/src/animation.ts +14 -0
- package/src/engine.ts +105 -8
- package/src/index.ts +4 -1
- package/src/model.ts +54 -3
- package/src/shaders/passes/composite.ts +28 -1
- package/src/vmd-loader.ts +64 -0
- package/src/vmd-writer.ts +43 -1
|
@@ -17,7 +17,7 @@ override APPLY_GAMMA: bool = true;
|
|
|
17
17
|
@group(0) @binding(0) var hdrTex: texture_2d<f32>;
|
|
18
18
|
@group(0) @binding(1) var bloomTex: texture_2d<f32>; // bloomUpTexture mip 0 (full pyramid top)
|
|
19
19
|
@group(0) @binding(2) var bloomSamp: sampler;
|
|
20
|
-
@group(0) @binding(3) var<uniform> viewU: array<vec4<f32>,
|
|
20
|
+
@group(0) @binding(3) var<uniform> viewU: array<vec4<f32>, 10>;
|
|
21
21
|
// Aux mask/alpha texture. .r = bloom mask (unused here; bloom blit uses it).
|
|
22
22
|
// .g = accumulated canvas alpha (what hdr.a carried before the HDR format
|
|
23
23
|
// became rg11b10ufloat). We unpremultiply HDR by this alpha for tonemap, then
|
|
@@ -40,6 +40,8 @@ override APPLY_GAMMA: bool = true;
|
|
|
40
40
|
// viewU[3] = (camera right, tanHalfFov·aspect); viewU[4] = (camera up, tanHalfFov);
|
|
41
41
|
// viewU[5] = (camera forward, _) — refreshed per frame while skybox/effect active.
|
|
42
42
|
// viewU[6] = (time seconds, effect on/off, canvas width, canvas height).
|
|
43
|
+
// viewU[7] = (grade offset.rgb, contrast); viewU[8] = (grade power.rgb, saturation);
|
|
44
|
+
// viewU[9] = (grade slope.rgb, grade on/off) — see grade() below.
|
|
43
45
|
// invGamma = 1/gamma precomputed on CPU — avoids a per-pixel divide.
|
|
44
46
|
@group(0) @binding(6) var bgEquirect: texture_2d<f32>;
|
|
45
47
|
|
|
@@ -59,6 +61,24 @@ fn filmic(x: f32) -> f32 {
|
|
|
59
61
|
|
|
60
62
|
/** Canvas size in pixels — for user background effects (aspect correction). */
|
|
61
63
|
fn bgResolution() -> vec2f { return viewU[6].zw; }
|
|
64
|
+
|
|
65
|
+
/** Color grading, applied to the tonemapped SCENE (not the background — see the
|
|
66
|
+
* call site). The core is ASC CDL, the film-industry interchange standard:
|
|
67
|
+
*
|
|
68
|
+
* out = (in · slope + offset) ^ power then saturation (SOP → SAT)
|
|
69
|
+
*
|
|
70
|
+
* Using the real standard rather than invented controls means a look authored
|
|
71
|
+
* here maps onto any grading tool. slope/offset/power are derived on the CPU
|
|
72
|
+
* from the UI's shadow/midtone/highlight colors (see setColorGrading), so the
|
|
73
|
+
* per-pixel cost is one mul-add, one pow, one lerp. */
|
|
74
|
+
fn grade(c: vec3f) -> vec3f {
|
|
75
|
+
var x = pow(max(c * viewU[9].xyz + viewU[7].xyz, vec3f(0.0)), viewU[8].xyz);
|
|
76
|
+
// Contrast pivots on 0.5 — display-referred midpoint, since we grade post-Filmic.
|
|
77
|
+
x = (x - vec3f(0.5)) * viewU[7].w + vec3f(0.5);
|
|
78
|
+
// Rec.709 luma, matching the ASC SAT node.
|
|
79
|
+
let luma = dot(x, vec3f(0.2126, 0.7152, 0.0722));
|
|
80
|
+
return max(mix(vec3f(luma), x, viewU[8].w), vec3f(0.0));
|
|
81
|
+
}
|
|
62
82
|
`;
|
|
63
83
|
const COMPOSITE_BODY = /* wgsl */ `
|
|
64
84
|
@vertex fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
|
|
@@ -84,6 +104,13 @@ const COMPOSITE_BODY = /* wgsl */ `
|
|
|
84
104
|
let exposed = combined * exp2(viewU[0].x);
|
|
85
105
|
let tm = vec3f(filmic(exposed.r), filmic(exposed.g), filmic(exposed.b));
|
|
86
106
|
var disp = max(tm, vec3f(0.0));
|
|
107
|
+
// Grade the SCENE only, before the display gamma. Deliberately not applied to
|
|
108
|
+
// the background: it keeps a picked background color exactly as picked, and —
|
|
109
|
+
// load-bearing — leaves green-screen mode's key color unshifted so chroma
|
|
110
|
+
// keying still works. Skipped entirely when the grade is neutral.
|
|
111
|
+
if (viewU[9].w > 0.5) {
|
|
112
|
+
disp = grade(disp);
|
|
113
|
+
}
|
|
87
114
|
if (APPLY_GAMMA) {
|
|
88
115
|
disp = pow(disp, vec3f(viewU[0].y));
|
|
89
116
|
}
|
package/dist/vmd-loader.d.ts
CHANGED
|
@@ -27,6 +27,16 @@ export interface CameraKeyframe {
|
|
|
27
27
|
fov: number;
|
|
28
28
|
interpolation: Uint8Array;
|
|
29
29
|
}
|
|
30
|
+
/** A VMD "IK/display" record: one moment at which chains are switched. */
|
|
31
|
+
export interface IkFrame {
|
|
32
|
+
frame: number;
|
|
33
|
+
/** Model visibility rides in the same record. Parsed, unused by playback. */
|
|
34
|
+
visible: boolean;
|
|
35
|
+
states: {
|
|
36
|
+
boneName: string;
|
|
37
|
+
enabled: boolean;
|
|
38
|
+
}[];
|
|
39
|
+
}
|
|
30
40
|
export declare class VMDLoader {
|
|
31
41
|
private view;
|
|
32
42
|
private offset;
|
|
@@ -39,12 +49,24 @@ export declare class VMDLoader {
|
|
|
39
49
|
static loadCamera(url: string): Promise<CameraKeyframe[]>;
|
|
40
50
|
static loadCameraFromBuffer(buffer: ArrayBuffer): CameraKeyframe[];
|
|
41
51
|
private parseCamera;
|
|
52
|
+
static loadIkFromBuffer(buffer: ArrayBuffer): IkFrame[];
|
|
53
|
+
/**
|
|
54
|
+
* The IK/display block, at the very end of the file.
|
|
55
|
+
*
|
|
56
|
+
* MMD writes it after camera, light and self-shadow, and a motion-only VMD
|
|
57
|
+
* often stops before any of them — so every block is bounds-checked and a
|
|
58
|
+
* short file simply reports no IK data rather than throwing. Record sizes:
|
|
59
|
+
* bone 111 B, morph 23 B, camera 61 B, light 28 B, self-shadow 9 B.
|
|
60
|
+
*/
|
|
61
|
+
private parseIk;
|
|
42
62
|
private parse;
|
|
43
63
|
private readBoneFrame;
|
|
44
64
|
private readMorphFrame;
|
|
45
65
|
private getUint8;
|
|
46
66
|
private getUint32;
|
|
47
67
|
private getFloat32;
|
|
68
|
+
/** A fixed-width, NUL-padded Shift-JIS name — the form every VMD name takes. */
|
|
69
|
+
private getShiftJisName;
|
|
48
70
|
private getString;
|
|
49
71
|
private skip;
|
|
50
72
|
}
|
package/dist/vmd-loader.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"vmd-loader.d.ts","sourceRoot":"","sources":["../src/vmd-loader.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAA;AAEnC,MAAM,WAAW,SAAS;IACxB,QAAQ,EAAE,MAAM,CAAA;IAChB,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,EAAE,IAAI,CAAA;IACd,WAAW,EAAE,IAAI,CAAA;IACjB,aAAa,EAAE,UAAU,CAAA;CAC1B;AAED,MAAM,WAAW,UAAU;IACzB,SAAS,EAAE,MAAM,CAAA;IACjB,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;CACf;AAED,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAA;IACZ,UAAU,EAAE,SAAS,EAAE,CAAA;IACvB,WAAW,EAAE,UAAU,EAAE,CAAA;CAC1B;AAED;;qFAEqF;AACrF,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,EAAE,MAAM,CAAA;IAChB,MAAM,EAAE,IAAI,CAAA;IACZ,QAAQ,EAAE,IAAI,CAAA;IACd,GAAG,EAAE,MAAM,CAAA;IACX,aAAa,EAAE,UAAU,CAAA;CAC1B;AAED,qBAAa,SAAS;IACpB,OAAO,CAAC,IAAI,CAAU;IACtB,OAAO,CAAC,MAAM,CAAI;IAClB,OAAO,CAAC,OAAO,CAAa;IAE5B,OAAO;WAWM,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC;IAKtD,MAAM,CAAC,cAAc,CAAC,MAAM,EAAE,WAAW,GAAG,WAAW,EAAE;IAKzD;mGAC+F;WAClF,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,EAAE,CAAC;IAK/D,MAAM,CAAC,oBAAoB,CAAC,MAAM,EAAE,WAAW,GAAG,cAAc,EAAE;IAOlE,OAAO,CAAC,WAAW;IA4BnB,OAAO,CAAC,KAAK;IAgGb,OAAO,CAAC,aAAa;IAuDrB,OAAO,CAAC,cAAc;IAqCtB,OAAO,CAAC,QAAQ;IAShB,OAAO,CAAC,SAAS;IASjB,OAAO,CAAC,UAAU;IASlB,OAAO,CAAC,SAAS;IAMjB,OAAO,CAAC,IAAI;CAMb"}
|
|
1
|
+
{"version":3,"file":"vmd-loader.d.ts","sourceRoot":"","sources":["../src/vmd-loader.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAA;AAEnC,MAAM,WAAW,SAAS;IACxB,QAAQ,EAAE,MAAM,CAAA;IAChB,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,EAAE,IAAI,CAAA;IACd,WAAW,EAAE,IAAI,CAAA;IACjB,aAAa,EAAE,UAAU,CAAA;CAC1B;AAED,MAAM,WAAW,UAAU;IACzB,SAAS,EAAE,MAAM,CAAA;IACjB,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;CACf;AAED,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAA;IACZ,UAAU,EAAE,SAAS,EAAE,CAAA;IACvB,WAAW,EAAE,UAAU,EAAE,CAAA;CAC1B;AAED;;qFAEqF;AACrF,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,EAAE,MAAM,CAAA;IAChB,MAAM,EAAE,IAAI,CAAA;IACZ,QAAQ,EAAE,IAAI,CAAA;IACd,GAAG,EAAE,MAAM,CAAA;IACX,aAAa,EAAE,UAAU,CAAA;CAC1B;AAED,0EAA0E;AAC1E,MAAM,WAAW,OAAO;IACtB,KAAK,EAAE,MAAM,CAAA;IACb,6EAA6E;IAC7E,OAAO,EAAE,OAAO,CAAA;IAChB,MAAM,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,OAAO,CAAA;KAAE,EAAE,CAAA;CACjD;AAED,qBAAa,SAAS;IACpB,OAAO,CAAC,IAAI,CAAU;IACtB,OAAO,CAAC,MAAM,CAAI;IAClB,OAAO,CAAC,OAAO,CAAa;IAE5B,OAAO;WAWM,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC;IAKtD,MAAM,CAAC,cAAc,CAAC,MAAM,EAAE,WAAW,GAAG,WAAW,EAAE;IAKzD;mGAC+F;WAClF,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,EAAE,CAAC;IAK/D,MAAM,CAAC,oBAAoB,CAAC,MAAM,EAAE,WAAW,GAAG,cAAc,EAAE;IAOlE,OAAO,CAAC,WAAW;IA4BnB,MAAM,CAAC,gBAAgB,CAAC,MAAM,EAAE,WAAW,GAAG,OAAO,EAAE;IAIvD;;;;;;;OAOG;IACH,OAAO,CAAC,OAAO;IA8Bf,OAAO,CAAC,KAAK;IAgGb,OAAO,CAAC,aAAa;IAuDrB,OAAO,CAAC,cAAc;IAqCtB,OAAO,CAAC,QAAQ;IAShB,OAAO,CAAC,SAAS;IASjB,OAAO,CAAC,UAAU;IASlB,gFAAgF;IAChF,OAAO,CAAC,eAAe;IAavB,OAAO,CAAC,SAAS;IAMjB,OAAO,CAAC,IAAI;CAMb"}
|
package/dist/vmd-loader.js
CHANGED
|
@@ -61,6 +61,49 @@ export class VMDLoader {
|
|
|
61
61
|
frames.sort((a, b) => a.frame - b.frame);
|
|
62
62
|
return frames;
|
|
63
63
|
}
|
|
64
|
+
static loadIkFromBuffer(buffer) {
|
|
65
|
+
return new VMDLoader(buffer).parseIk();
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The IK/display block, at the very end of the file.
|
|
69
|
+
*
|
|
70
|
+
* MMD writes it after camera, light and self-shadow, and a motion-only VMD
|
|
71
|
+
* often stops before any of them — so every block is bounds-checked and a
|
|
72
|
+
* short file simply reports no IK data rather than throwing. Record sizes:
|
|
73
|
+
* bone 111 B, morph 23 B, camera 61 B, light 28 B, self-shadow 9 B.
|
|
74
|
+
*/
|
|
75
|
+
parseIk() {
|
|
76
|
+
this.offset = 0;
|
|
77
|
+
const header = this.getString(30);
|
|
78
|
+
if (!header.startsWith("Vocaloid Motion Data"))
|
|
79
|
+
throw new Error("Invalid VMD file header");
|
|
80
|
+
this.skip(20);
|
|
81
|
+
const end = this.view.buffer.byteLength;
|
|
82
|
+
const seek = (size) => {
|
|
83
|
+
if (this.offset + 4 > end)
|
|
84
|
+
return false;
|
|
85
|
+
this.skip(this.getUint32() * size);
|
|
86
|
+
return this.offset <= end;
|
|
87
|
+
};
|
|
88
|
+
if (!seek(111) || !seek(23) || !seek(61) || !seek(28) || !seek(9))
|
|
89
|
+
return [];
|
|
90
|
+
if (this.offset + 4 > end)
|
|
91
|
+
return [];
|
|
92
|
+
const count = this.getUint32();
|
|
93
|
+
const frames = [];
|
|
94
|
+
for (let i = 0; i < count && this.offset + 9 <= end; i++) {
|
|
95
|
+
const frame = this.getUint32();
|
|
96
|
+
const visible = this.getUint8() !== 0;
|
|
97
|
+
const ikCount = this.getUint32();
|
|
98
|
+
const states = [];
|
|
99
|
+
for (let j = 0; j < ikCount && this.offset + 21 <= end; j++) {
|
|
100
|
+
states.push({ boneName: this.getShiftJisName(20), enabled: this.getUint8() !== 0 });
|
|
101
|
+
}
|
|
102
|
+
frames.push({ frame, visible, states });
|
|
103
|
+
}
|
|
104
|
+
frames.sort((a, b) => a.frame - b.frame);
|
|
105
|
+
return frames;
|
|
106
|
+
}
|
|
64
107
|
parse() {
|
|
65
108
|
// Read header (30 bytes)
|
|
66
109
|
const header = this.getString(30);
|
|
@@ -244,6 +287,21 @@ export class VMDLoader {
|
|
|
244
287
|
this.offset += 4;
|
|
245
288
|
return v;
|
|
246
289
|
}
|
|
290
|
+
/** A fixed-width, NUL-padded Shift-JIS name — the form every VMD name takes. */
|
|
291
|
+
getShiftJisName(len) {
|
|
292
|
+
const bytes = new Uint8Array(this.view.buffer, this.offset, len);
|
|
293
|
+
this.offset += len;
|
|
294
|
+
let end = bytes.indexOf(0);
|
|
295
|
+
if (end < 0)
|
|
296
|
+
end = len;
|
|
297
|
+
const slice = bytes.slice(0, end);
|
|
298
|
+
try {
|
|
299
|
+
return this.decoder.decode(slice);
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
return String.fromCharCode(...slice);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
247
305
|
getString(len) {
|
|
248
306
|
const bytes = new Uint8Array(this.view.buffer, this.offset, len);
|
|
249
307
|
this.offset += len;
|
package/dist/vmd-writer.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"vmd-writer.d.ts","sourceRoot":"","sources":["../src/vmd-writer.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAqB,MAAM,aAAa,CAAA;
|
|
1
|
+
{"version":3,"file":"vmd-writer.d.ts","sourceRoot":"","sources":["../src/vmd-writer.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAqB,MAAM,aAAa,CAAA;AAiD9D,qBAAa,SAAS;IACpB,KAAK,CAAC,IAAI,EAAE,aAAa,GAAG,WAAW;CAwHxC"}
|
package/dist/vmd-writer.js
CHANGED
|
@@ -5,6 +5,8 @@ const BONE_NAME_SIZE = 15;
|
|
|
5
5
|
const MORPH_NAME_SIZE = 15;
|
|
6
6
|
const BONE_FRAME_SIZE = BONE_NAME_SIZE + 4 + 12 + 16 + 64; // 111 bytes
|
|
7
7
|
const MORPH_FRAME_SIZE = MORPH_NAME_SIZE + 4 + 4; // 23 bytes
|
|
8
|
+
/** IK bone names get 20 bytes in the IK block, not the 15 bones get elsewhere. */
|
|
9
|
+
const IK_NAME_SIZE = 20;
|
|
8
10
|
// Build a Unicode-to-Shift-JIS lookup by inverting the TextDecoder mapping.
|
|
9
11
|
let shiftJISTable = null;
|
|
10
12
|
function getShiftJISTable() {
|
|
@@ -54,10 +56,31 @@ export class VMDWriter {
|
|
|
54
56
|
for (const frames of clip.morphTracks.values()) {
|
|
55
57
|
totalMorphFrames += frames.length;
|
|
56
58
|
}
|
|
59
|
+
// IK state is stored per MOMENT, not per bone: one record lists every chain
|
|
60
|
+
// and its state at that frame. So the tracks are transposed back into the
|
|
61
|
+
// frames they were flattened from.
|
|
62
|
+
const ikByFrame = new Map();
|
|
63
|
+
for (const [boneName, keys] of clip.ikTracks ?? []) {
|
|
64
|
+
for (const key of keys) {
|
|
65
|
+
const at = ikByFrame.get(key.frame);
|
|
66
|
+
if (at)
|
|
67
|
+
at.push({ boneName, enabled: key.enabled });
|
|
68
|
+
else
|
|
69
|
+
ikByFrame.set(key.frame, [{ boneName, enabled: key.enabled }]);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const ikFrames = [...ikByFrame.entries()].sort((a, b) => a[0] - b[0]);
|
|
73
|
+
let ikSize = 0;
|
|
74
|
+
for (const [, states] of ikFrames)
|
|
75
|
+
ikSize += 4 + 1 + 4 + states.length * (IK_NAME_SIZE + 1);
|
|
57
76
|
const size = HEADER_SIZE +
|
|
58
77
|
MODEL_NAME_SIZE +
|
|
59
78
|
4 + totalBoneFrames * BONE_FRAME_SIZE +
|
|
60
|
-
4 + totalMorphFrames * MORPH_FRAME_SIZE
|
|
79
|
+
4 + totalMorphFrames * MORPH_FRAME_SIZE +
|
|
80
|
+
// Camera, light and self-shadow counts, then the IK block. Written only
|
|
81
|
+
// when there is IK state to carry: a plain motion stays byte-identical to
|
|
82
|
+
// what this writer produced before.
|
|
83
|
+
(ikFrames.length > 0 ? 4 * 4 + ikSize : 0);
|
|
61
84
|
const buffer = new ArrayBuffer(size);
|
|
62
85
|
const view = new DataView(buffer);
|
|
63
86
|
let offset = 0;
|
|
@@ -114,6 +137,27 @@ export class VMDWriter {
|
|
|
114
137
|
offset += 4;
|
|
115
138
|
}
|
|
116
139
|
}
|
|
140
|
+
if (ikFrames.length > 0) {
|
|
141
|
+
// Empty camera, light and self-shadow blocks — a reader walking the file
|
|
142
|
+
// in order has to pass through them to reach the IK block.
|
|
143
|
+
for (let i = 0; i < 3; i++, offset += 4)
|
|
144
|
+
view.setUint32(offset, 0, true);
|
|
145
|
+
view.setUint32(offset, ikFrames.length, true);
|
|
146
|
+
offset += 4;
|
|
147
|
+
for (const [frame, states] of ikFrames) {
|
|
148
|
+
view.setUint32(offset, frame, true);
|
|
149
|
+
offset += 4;
|
|
150
|
+
view.setUint8(offset, 1); // model visible
|
|
151
|
+
offset += 1;
|
|
152
|
+
view.setUint32(offset, states.length, true);
|
|
153
|
+
offset += 4;
|
|
154
|
+
for (const state of states) {
|
|
155
|
+
offset = writeFixedShiftJIS(buffer, offset, state.boneName, IK_NAME_SIZE);
|
|
156
|
+
view.setUint8(offset, state.enabled ? 1 : 0);
|
|
157
|
+
offset += 1;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
117
161
|
return buffer;
|
|
118
162
|
}
|
|
119
163
|
}
|
package/package.json
CHANGED
|
@@ -1,44 +1,44 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}
|
|
17
|
-
},
|
|
18
|
-
"files": [
|
|
19
|
-
"dist",
|
|
20
|
-
"src"
|
|
21
|
-
],
|
|
22
|
-
"scripts": {
|
|
23
|
-
"build": "tsc",
|
|
24
|
-
"dev": "tsc --watch",
|
|
25
|
-
"test": "node --import ./tests/register.mjs --test tests/*.test.mjs",
|
|
26
|
-
"prepublishOnly": "node -e \"require('fs').copyFileSync('../README.md','README.md')\" && npm run build"
|
|
27
|
-
},
|
|
28
|
-
"keywords": [
|
|
29
|
-
"webgpu",
|
|
30
|
-
"3d",
|
|
31
|
-
"engine",
|
|
32
|
-
"pmx",
|
|
33
|
-
"physics"
|
|
34
|
-
],
|
|
35
|
-
"author": "",
|
|
36
|
-
"license": "MIT",
|
|
37
|
-
"dependencies": {
|
|
38
|
-
"@webgpu/types": "^0.1.66"
|
|
39
|
-
},
|
|
40
|
-
"devDependencies": {
|
|
41
|
-
"@types/node": "^20",
|
|
42
|
-
"typescript": "^5"
|
|
2
|
+
"name": "reze-engine",
|
|
3
|
+
"version": "0.27.0",
|
|
4
|
+
"description": "A lightweight WebGPU engine for real-time 3D MMD/PMX model rendering",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"types": "./dist/index.d.ts",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/AmyangXYZ/reze-engine"
|
|
11
|
+
},
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"import": "./dist/index.js"
|
|
43
16
|
}
|
|
44
|
-
}
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"src"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsc",
|
|
24
|
+
"dev": "tsc --watch",
|
|
25
|
+
"test": "node --import ./tests/register.mjs --test tests/*.test.mjs",
|
|
26
|
+
"prepublishOnly": "node -e \"require('fs').copyFileSync('../README.md','README.md')\" && npm run build"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"webgpu",
|
|
30
|
+
"3d",
|
|
31
|
+
"engine",
|
|
32
|
+
"pmx",
|
|
33
|
+
"physics"
|
|
34
|
+
],
|
|
35
|
+
"author": "",
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@webgpu/types": "^0.1.66"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/node": "^20",
|
|
42
|
+
"typescript": "^5"
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/animation.ts
CHANGED
|
@@ -26,9 +26,19 @@ export interface MorphKeyframe {
|
|
|
26
26
|
weight: number
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/** One IK chain switching on or off, keyed by its IK bone (左足IK and friends).
|
|
30
|
+
* VMD stores these as steps — a state holds until the next keyframe changes it. */
|
|
31
|
+
export interface IkKeyframe {
|
|
32
|
+
frame: number
|
|
33
|
+
enabled: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
29
36
|
export interface AnimationClip {
|
|
30
37
|
boneTracks: Map<string, BoneKeyframe[]>
|
|
31
38
|
morphTracks: Map<string, MorphKeyframe[]>
|
|
39
|
+
/** Per-chain IK state over time. Absent means "leave IK as the host set it" —
|
|
40
|
+
* which is what every clip built before this existed will do. */
|
|
41
|
+
ikTracks?: Map<string, IkKeyframe[]>
|
|
32
42
|
frameCount: number // last keyframe frame index
|
|
33
43
|
}
|
|
34
44
|
|
|
@@ -70,9 +80,13 @@ export class AnimationState {
|
|
|
70
80
|
private onEnd: ((animationName: string) => void) | null = null
|
|
71
81
|
|
|
72
82
|
loadAnimation(name: string, clip: AnimationClip): void {
|
|
83
|
+
// Copied field by field rather than stored by reference, so the caller's
|
|
84
|
+
// object cannot mutate under playback. Every field has to be listed — an
|
|
85
|
+
// omission here is silent, and drops that part of the clip on the floor.
|
|
73
86
|
this.animations.set(name, {
|
|
74
87
|
boneTracks: clip.boneTracks,
|
|
75
88
|
morphTracks: clip.morphTracks,
|
|
89
|
+
ikTracks: clip.ikTracks,
|
|
76
90
|
frameCount: clip.frameCount,
|
|
77
91
|
})
|
|
78
92
|
}
|
package/src/engine.ts
CHANGED
|
@@ -283,6 +283,34 @@ export const DEFAULT_VIEW_TRANSFORM: ViewTransformOptions = {
|
|
|
283
283
|
look: "medium_high_contrast",
|
|
284
284
|
}
|
|
285
285
|
|
|
286
|
+
/** Color grading applied to the tonemapped scene (ASC CDL — see grade() in
|
|
287
|
+
* composite.ts). The three tonal controls are expressed as COLORS with
|
|
288
|
+
* mid-gray (0.5, 0.5, 0.5) as neutral: the direction from neutral is the hue
|
|
289
|
+
* you push toward, and the distance from neutral is the amount — so no
|
|
290
|
+
* separate strength slider is needed. Display-space sRGB, since grading runs
|
|
291
|
+
* after the view transform. */
|
|
292
|
+
export type ColorGradingOptions = {
|
|
293
|
+
/** Lifts/tints the dark end (CDL offset). */
|
|
294
|
+
shadows: Vec3
|
|
295
|
+
/** Bends the midtones (CDL power) — brighter above neutral, darker below. */
|
|
296
|
+
midtones: Vec3
|
|
297
|
+
/** Scales/tints the bright end (CDL slope). */
|
|
298
|
+
highlights: Vec3
|
|
299
|
+
/** Contrast about the 0.5 display pivot. 1 = neutral. */
|
|
300
|
+
contrast: number
|
|
301
|
+
/** 1 = neutral, 0 = grayscale, >1 = punchier. */
|
|
302
|
+
saturation: number
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const NEUTRAL_GRADE_CHANNEL = 0.5
|
|
306
|
+
export const DEFAULT_COLOR_GRADING: ColorGradingOptions = {
|
|
307
|
+
shadows: new Vec3(NEUTRAL_GRADE_CHANNEL, NEUTRAL_GRADE_CHANNEL, NEUTRAL_GRADE_CHANNEL),
|
|
308
|
+
midtones: new Vec3(NEUTRAL_GRADE_CHANNEL, NEUTRAL_GRADE_CHANNEL, NEUTRAL_GRADE_CHANNEL),
|
|
309
|
+
highlights: new Vec3(NEUTRAL_GRADE_CHANNEL, NEUTRAL_GRADE_CHANNEL, NEUTRAL_GRADE_CHANNEL),
|
|
310
|
+
contrast: 1,
|
|
311
|
+
saturation: 1,
|
|
312
|
+
}
|
|
313
|
+
|
|
286
314
|
export type GizmoDragKind = "rotate" | "translate"
|
|
287
315
|
|
|
288
316
|
export interface GizmoDragEvent {
|
|
@@ -651,7 +679,7 @@ export class Engine {
|
|
|
651
679
|
private compositeBindGroup!: GPUBindGroup
|
|
652
680
|
private compositeUniformBuffer!: GPUBuffer
|
|
653
681
|
// [exposure, invGamma, _, _, bloomTint.x, bloomTint.y, bloomTint.z, bloomIntensity]
|
|
654
|
-
private readonly compositeUniformData = new Float32Array(
|
|
682
|
+
private readonly compositeUniformData = new Float32Array(40)
|
|
655
683
|
/** Composite background (display-space sRGB 0–1) — null = transparent canvas. */
|
|
656
684
|
private backgroundColor: Vec3 | null = null
|
|
657
685
|
// 360 backdrop (equirectangular skybox, sampled by view ray in composite).
|
|
@@ -861,6 +889,42 @@ export class Engine {
|
|
|
861
889
|
return { exposure: v.exposure, gamma: v.gamma, look: v.look }
|
|
862
890
|
}
|
|
863
891
|
|
|
892
|
+
private colorGrading: ColorGradingOptions = {
|
|
893
|
+
shadows: new Vec3(NEUTRAL_GRADE_CHANNEL, NEUTRAL_GRADE_CHANNEL, NEUTRAL_GRADE_CHANNEL),
|
|
894
|
+
midtones: new Vec3(NEUTRAL_GRADE_CHANNEL, NEUTRAL_GRADE_CHANNEL, NEUTRAL_GRADE_CHANNEL),
|
|
895
|
+
highlights: new Vec3(NEUTRAL_GRADE_CHANNEL, NEUTRAL_GRADE_CHANNEL, NEUTRAL_GRADE_CHANNEL),
|
|
896
|
+
contrast: DEFAULT_COLOR_GRADING.contrast,
|
|
897
|
+
saturation: DEFAULT_COLOR_GRADING.saturation,
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* Color-grade the tonemapped scene (ASC CDL slope/offset/power + saturation).
|
|
902
|
+
* The background layer is deliberately left ungraded — see the call site in
|
|
903
|
+
* composite.ts. Uniforms-only: no pipeline rebuild, safe to call per frame
|
|
904
|
+
* (e.g. from a slider drag).
|
|
905
|
+
*/
|
|
906
|
+
setColorGrading(patch: Partial<ColorGradingOptions>): void {
|
|
907
|
+
const g = this.colorGrading
|
|
908
|
+
if (patch.shadows) g.shadows = new Vec3(patch.shadows.x, patch.shadows.y, patch.shadows.z)
|
|
909
|
+
if (patch.midtones) g.midtones = new Vec3(patch.midtones.x, patch.midtones.y, patch.midtones.z)
|
|
910
|
+
if (patch.highlights) g.highlights = new Vec3(patch.highlights.x, patch.highlights.y, patch.highlights.z)
|
|
911
|
+
if (patch.contrast !== undefined) g.contrast = patch.contrast
|
|
912
|
+
if (patch.saturation !== undefined) g.saturation = patch.saturation
|
|
913
|
+
if (this.device && this.compositeUniformBuffer) this.writeCompositeViewUniforms()
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/** Current grade (for serialization into a scene descriptor). */
|
|
917
|
+
getColorGrading(): ColorGradingOptions {
|
|
918
|
+
const g = this.colorGrading
|
|
919
|
+
return {
|
|
920
|
+
shadows: new Vec3(g.shadows.x, g.shadows.y, g.shadows.z),
|
|
921
|
+
midtones: new Vec3(g.midtones.x, g.midtones.y, g.midtones.z),
|
|
922
|
+
highlights: new Vec3(g.highlights.x, g.highlights.y, g.highlights.z),
|
|
923
|
+
contrast: g.contrast,
|
|
924
|
+
saturation: g.saturation,
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
864
928
|
setViewTransformOptions(patch: Partial<ViewTransformOptions>): void {
|
|
865
929
|
const v = this.viewTransform
|
|
866
930
|
if (patch.exposure !== undefined) v.exposure = patch.exposure
|
|
@@ -901,6 +965,32 @@ export class Engine {
|
|
|
901
965
|
u[25] = this.backgroundEffect ? 1 : 0
|
|
902
966
|
u[26] = this.canvas.width
|
|
903
967
|
u[27] = this.canvas.height
|
|
968
|
+
// ── Grade (viewU[7..9]) ── The UI's three tonal COLORS map to ASC CDL here,
|
|
969
|
+
// on the CPU, so the shader only ever sees slope/offset/power. Mid-gray is
|
|
970
|
+
// neutral in all three; the signed distance from it is the amount.
|
|
971
|
+
const g = this.colorGrading
|
|
972
|
+
const off = (c: number) => (c - NEUTRAL_GRADE_CHANNEL) * 0.5 // ±0.25 lift
|
|
973
|
+
// power < 1 brightens, so midtones ABOVE neutral must lower the exponent.
|
|
974
|
+
const pow_ = (c: number) => Math.max(0.05, 1 - (c - NEUTRAL_GRADE_CHANNEL) * 1.5)
|
|
975
|
+
const slope = (c: number) => Math.max(0, 1 + (c - NEUTRAL_GRADE_CHANNEL) * 1.5)
|
|
976
|
+
u[28] = off(g.shadows.x)
|
|
977
|
+
u[29] = off(g.shadows.y)
|
|
978
|
+
u[30] = off(g.shadows.z)
|
|
979
|
+
u[31] = g.contrast
|
|
980
|
+
u[32] = pow_(g.midtones.x)
|
|
981
|
+
u[33] = pow_(g.midtones.y)
|
|
982
|
+
u[34] = pow_(g.midtones.z)
|
|
983
|
+
u[35] = g.saturation
|
|
984
|
+
u[36] = slope(g.highlights.x)
|
|
985
|
+
u[37] = slope(g.highlights.y)
|
|
986
|
+
u[38] = slope(g.highlights.z)
|
|
987
|
+
// Neutral grade → flag off, so the default pipeline pays nothing per pixel.
|
|
988
|
+
const neutral =
|
|
989
|
+
u[28] === 0 && u[29] === 0 && u[30] === 0 &&
|
|
990
|
+
u[32] === 1 && u[33] === 1 && u[34] === 1 &&
|
|
991
|
+
u[36] === 1 && u[37] === 1 && u[38] === 1 &&
|
|
992
|
+
g.contrast === 1 && g.saturation === 1
|
|
993
|
+
u[39] = neutral ? 0 : 1
|
|
904
994
|
this.device.queue.writeBuffer(this.compositeUniformBuffer, 0, u)
|
|
905
995
|
}
|
|
906
996
|
|
|
@@ -2005,10 +2095,11 @@ export class Engine {
|
|
|
2005
2095
|
// mirroring EEVEE where bloom color/intensity are combine-stage params, not prefilter).
|
|
2006
2096
|
this.compositeUniformBuffer = this.device.createBuffer({
|
|
2007
2097
|
label: "composite view uniforms",
|
|
2008
|
-
//
|
|
2098
|
+
// 10 × vec4f: (exposure, invGamma, _, _) · (bloom tint, intensity) ·
|
|
2009
2099
|
// (bg rgb, mode) · camera right/up/forward basis for the 360 skybox ray ·
|
|
2010
|
-
// (time, _, canvas width, canvas height) for user background effects
|
|
2011
|
-
|
|
2100
|
+
// (time, _, canvas width, canvas height) for user background effects ·
|
|
2101
|
+
// three grade vectors (CDL offset+contrast, power+saturation, slope+flag).
|
|
2102
|
+
size: 160,
|
|
2012
2103
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
2013
2104
|
})
|
|
2014
2105
|
this.bgParamsDummyBuffer = this.device.createBuffer({
|
|
@@ -3080,15 +3171,21 @@ export class Engine {
|
|
|
3080
3171
|
return inst ? !inst.hiddenMaterials.has(materialName) : false
|
|
3081
3172
|
}
|
|
3082
3173
|
|
|
3083
|
-
setIKEnabled(enabled: boolean): void {
|
|
3084
|
-
this.ikEnabled = enabled
|
|
3085
|
-
}
|
|
3086
|
-
|
|
3087
3174
|
// Toggle the GPU vertex-morph path. Only affects models loaded afterwards.
|
|
3088
3175
|
setGpuMorphsEnabled(enabled: boolean): void {
|
|
3089
3176
|
this.useGpuMorphs = enabled
|
|
3090
3177
|
}
|
|
3091
3178
|
|
|
3179
|
+
/**
|
|
3180
|
+
* Engine-wide IK switch. Off suppresses every chain regardless of what any
|
|
3181
|
+
* motion says — for hosts that pose the skeleton themselves and want their
|
|
3182
|
+
* own rotations left alone. On (the default) hands the decision to the clip,
|
|
3183
|
+
* which carries per-chain state from the VMD it came from.
|
|
3184
|
+
*/
|
|
3185
|
+
setIKEnabled(enabled: boolean): void {
|
|
3186
|
+
this.ikEnabled = enabled
|
|
3187
|
+
}
|
|
3188
|
+
|
|
3092
3189
|
getIKEnabled(): boolean {
|
|
3093
3190
|
return this.ikEnabled
|
|
3094
3191
|
}
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,8 @@ export {
|
|
|
2
2
|
Engine,
|
|
3
3
|
DEFAULT_BLOOM_OPTIONS,
|
|
4
4
|
DEFAULT_VIEW_TRANSFORM,
|
|
5
|
+
DEFAULT_COLOR_GRADING,
|
|
6
|
+
type ColorGradingOptions,
|
|
5
7
|
type EngineStats,
|
|
6
8
|
type EngineOptions,
|
|
7
9
|
type BloomOptions,
|
|
@@ -57,11 +59,12 @@ export type {
|
|
|
57
59
|
AnimationPlayOptions,
|
|
58
60
|
AnimationProgress,
|
|
59
61
|
BoneKeyframe,
|
|
62
|
+
IkKeyframe,
|
|
60
63
|
MorphKeyframe,
|
|
61
64
|
BoneInterpolation,
|
|
62
65
|
ControlPoint,
|
|
63
66
|
} from "./animation"
|
|
64
67
|
export { FPS } from "./animation"
|
|
65
|
-
export { VMDLoader, type CameraKeyframe } from "./vmd-loader"
|
|
68
|
+
export { VMDLoader, type CameraKeyframe, type IkFrame } from "./vmd-loader"
|
|
66
69
|
export { CameraAnimation, type CameraPose } from "./camera-animation"
|
|
67
70
|
export { RezePhysics } from "./physics"
|
package/src/model.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
AnimationState,
|
|
13
13
|
BoneInterpolation,
|
|
14
14
|
BoneKeyframe,
|
|
15
|
+
IkKeyframe,
|
|
15
16
|
MorphKeyframe,
|
|
16
17
|
interpolateControlPoints,
|
|
17
18
|
rawInterpolationToBoneInterpolation,
|
|
@@ -1222,6 +1223,21 @@ export class Model {
|
|
|
1222
1223
|
return loadBuffer().then((buf) => {
|
|
1223
1224
|
const vmdKeyFrames = VMDLoader.loadFromBuffer(buf)
|
|
1224
1225
|
const clip = this.buildClipFromVmdKeyFrames(vmdKeyFrames)
|
|
1226
|
+
// The IK block lives past every other section, so it is read separately
|
|
1227
|
+
// rather than threaded through the keyframe grouping.
|
|
1228
|
+
const ikFrames = VMDLoader.loadIkFromBuffer(buf)
|
|
1229
|
+
if (ikFrames.length > 0) {
|
|
1230
|
+
const ikTracks = new Map<string, IkKeyframe[]>()
|
|
1231
|
+
for (const record of ikFrames) {
|
|
1232
|
+
for (const state of record.states) {
|
|
1233
|
+
const track = ikTracks.get(state.boneName)
|
|
1234
|
+
if (track) track.push({ frame: record.frame, enabled: state.enabled })
|
|
1235
|
+
else ikTracks.set(state.boneName, [{ frame: record.frame, enabled: state.enabled }])
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
for (const track of ikTracks.values()) track.sort((a, b) => a.frame - b.frame)
|
|
1239
|
+
clip.ikTracks = ikTracks
|
|
1240
|
+
}
|
|
1225
1241
|
this.animationState.loadAnimation(name, clip)
|
|
1226
1242
|
})
|
|
1227
1243
|
}
|
|
@@ -1359,8 +1375,35 @@ export class Model {
|
|
|
1359
1375
|
return idx
|
|
1360
1376
|
}
|
|
1361
1377
|
|
|
1378
|
+
/**
|
|
1379
|
+
* IK bones whose chains are switched OFF at the current frame.
|
|
1380
|
+
*
|
|
1381
|
+
* VMD carries this per chain and over time — a motion legitimately disables
|
|
1382
|
+
* foot IK for a lift and restores it on landing — so it belongs to the clip,
|
|
1383
|
+
* not to a global switch on the engine.
|
|
1384
|
+
*/
|
|
1385
|
+
private ikDisabled = new Set<number>()
|
|
1386
|
+
|
|
1387
|
+
/** Step the IK state to `frame`. VMD IK keys are steps: a state holds until
|
|
1388
|
+
* the next one changes it, so this is a lookup, not an interpolation. */
|
|
1389
|
+
private applyIkFromClip(clip: AnimationClip, frame: number): void {
|
|
1390
|
+
if (!clip.ikTracks || clip.ikTracks.size === 0) {
|
|
1391
|
+
if (this.ikDisabled.size > 0) this.ikDisabled.clear()
|
|
1392
|
+
return
|
|
1393
|
+
}
|
|
1394
|
+
this.ikDisabled.clear()
|
|
1395
|
+
for (const [boneName, keys] of clip.ikTracks) {
|
|
1396
|
+
const index = this.runtimeSkeleton.nameIndex[boneName]
|
|
1397
|
+
if (index === undefined || index < 0 || keys.length === 0) continue
|
|
1398
|
+
let state = keys[0].frame <= frame ? keys[0].enabled : true
|
|
1399
|
+
for (let i = 1; i < keys.length && keys[i].frame <= frame; i++) state = keys[i].enabled
|
|
1400
|
+
if (!state) this.ikDisabled.add(index)
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1362
1404
|
private applyPoseFromClip(clip: AnimationClip | null, frame: number): void {
|
|
1363
1405
|
if (!clip) return
|
|
1406
|
+
this.applyIkFromClip(clip, frame)
|
|
1364
1407
|
if (clip !== this.lastAppliedClip) {
|
|
1365
1408
|
this.boneTrackIndices.clear()
|
|
1366
1409
|
this.morphTrackIndices.clear()
|
|
@@ -1447,8 +1490,12 @@ export class Model {
|
|
|
1447
1490
|
}
|
|
1448
1491
|
}
|
|
1449
1492
|
|
|
1450
|
-
// Returns true when morphs changed (vertex buffer may need upload). ikEnabled
|
|
1451
|
-
|
|
1493
|
+
// Returns true when morphs changed (vertex buffer may need upload). `ikEnabled`
|
|
1494
|
+
// is the host's runtime switch (engine-wide); the clip decides which chains
|
|
1495
|
+
// within that. A host driving bones directly — motion capture writing FK
|
|
1496
|
+
// rotations every frame with no clip playing — turns it off wholesale, because
|
|
1497
|
+
// there is no motion present to carry the per-chain answer.
|
|
1498
|
+
update(deltaTime: number, ikEnabled = true): boolean {
|
|
1452
1499
|
// Update tween time (in milliseconds)
|
|
1453
1500
|
this.tweenTimeMs += deltaTime * 1000
|
|
1454
1501
|
|
|
@@ -1472,7 +1519,8 @@ export class Model {
|
|
|
1472
1519
|
// Compute world matrices (needed for IK solving to read bone positions)
|
|
1473
1520
|
this.computeWorldMatrices()
|
|
1474
1521
|
|
|
1475
|
-
// Solve IK chains (modifies localRotations with final IK rotations)
|
|
1522
|
+
// Solve IK chains (modifies localRotations with final IK rotations). Chains
|
|
1523
|
+
// the clip switched off are skipped inside.
|
|
1476
1524
|
if (ikEnabled) {
|
|
1477
1525
|
this.solveIKChains()
|
|
1478
1526
|
// Recompute world matrices with final IK rotations applied to localRotations
|
|
@@ -1492,6 +1540,9 @@ export class Model {
|
|
|
1492
1540
|
// Solve each IK solver sequentially, ensuring consistent state between solvers
|
|
1493
1541
|
let firstSolver = true
|
|
1494
1542
|
for (const solver of ikSolvers) {
|
|
1543
|
+
// Switched off by the motion for this frame — leave the chain on whatever
|
|
1544
|
+
// the FK track put there.
|
|
1545
|
+
if (this.ikDisabled.has(solver.ikBoneIndex)) continue
|
|
1495
1546
|
// Each solver must see the effects of previous solvers on localRotations, so
|
|
1496
1547
|
// recompute world matrices between solvers. The first solver is skipped: the
|
|
1497
1548
|
// caller (update) already computed them and nothing has changed localRotations yet.
|