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.
@@ -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>, 7>;
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
  }
@@ -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
  }
@@ -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"}
@@ -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;
@@ -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;AA+C9D,qBAAa,SAAS;IACpB,KAAK,CAAC,IAAI,EAAE,aAAa,GAAG,WAAW;CAgFxC"}
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"}
@@ -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
- "name": "reze-engine",
3
- "version": "0.25.2",
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"
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(28)
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
- // 7 × vec4f: (exposure, invGamma, _, _) · (bloom tint, intensity) ·
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
- size: 112,
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 is driven by engine (same for all models).
1451
- update(deltaTime: number, ikEnabled: boolean): boolean {
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.