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.
@@ -39,7 +39,7 @@ override APPLY_GAMMA: bool = true;
39
39
  @group(0) @binding(0) var hdrTex: texture_2d<f32>;
40
40
  @group(0) @binding(1) var bloomTex: texture_2d<f32>; // bloomUpTexture mip 0 (full pyramid top)
41
41
  @group(0) @binding(2) var bloomSamp: sampler;
42
- @group(0) @binding(3) var<uniform> viewU: array<vec4<f32>, 7>;
42
+ @group(0) @binding(3) var<uniform> viewU: array<vec4<f32>, 10>;
43
43
  // Aux mask/alpha texture. .r = bloom mask (unused here; bloom blit uses it).
44
44
  // .g = accumulated canvas alpha (what hdr.a carried before the HDR format
45
45
  // became rg11b10ufloat). We unpremultiply HDR by this alpha for tonemap, then
@@ -62,6 +62,8 @@ override APPLY_GAMMA: bool = true;
62
62
  // viewU[3] = (camera right, tanHalfFov·aspect); viewU[4] = (camera up, tanHalfFov);
63
63
  // viewU[5] = (camera forward, _) — refreshed per frame while skybox/effect active.
64
64
  // viewU[6] = (time seconds, effect on/off, canvas width, canvas height).
65
+ // viewU[7] = (grade offset.rgb, contrast); viewU[8] = (grade power.rgb, saturation);
66
+ // viewU[9] = (grade slope.rgb, grade on/off) — see grade() below.
65
67
  // invGamma = 1/gamma precomputed on CPU — avoids a per-pixel divide.
66
68
  @group(0) @binding(6) var bgEquirect: texture_2d<f32>;
67
69
 
@@ -81,6 +83,24 @@ fn filmic(x: f32) -> f32 {
81
83
 
82
84
  /** Canvas size in pixels — for user background effects (aspect correction). */
83
85
  fn bgResolution() -> vec2f { return viewU[6].zw; }
86
+
87
+ /** Color grading, applied to the tonemapped SCENE (not the background — see the
88
+ * call site). The core is ASC CDL, the film-industry interchange standard:
89
+ *
90
+ * out = (in · slope + offset) ^ power then saturation (SOP → SAT)
91
+ *
92
+ * Using the real standard rather than invented controls means a look authored
93
+ * here maps onto any grading tool. slope/offset/power are derived on the CPU
94
+ * from the UI's shadow/midtone/highlight colors (see setColorGrading), so the
95
+ * per-pixel cost is one mul-add, one pow, one lerp. */
96
+ fn grade(c: vec3f) -> vec3f {
97
+ var x = pow(max(c * viewU[9].xyz + viewU[7].xyz, vec3f(0.0)), viewU[8].xyz);
98
+ // Contrast pivots on 0.5 — display-referred midpoint, since we grade post-Filmic.
99
+ x = (x - vec3f(0.5)) * viewU[7].w + vec3f(0.5);
100
+ // Rec.709 luma, matching the ASC SAT node.
101
+ let luma = dot(x, vec3f(0.2126, 0.7152, 0.0722));
102
+ return max(mix(vec3f(luma), x, viewU[8].w), vec3f(0.0));
103
+ }
84
104
  `
85
105
 
86
106
  const COMPOSITE_BODY = /* wgsl */ `
@@ -107,6 +127,13 @@ const COMPOSITE_BODY = /* wgsl */ `
107
127
  let exposed = combined * exp2(viewU[0].x);
108
128
  let tm = vec3f(filmic(exposed.r), filmic(exposed.g), filmic(exposed.b));
109
129
  var disp = max(tm, vec3f(0.0));
130
+ // Grade the SCENE only, before the display gamma. Deliberately not applied to
131
+ // the background: it keeps a picked background color exactly as picked, and —
132
+ // load-bearing — leaves green-screen mode's key color unshifted so chroma
133
+ // keying still works. Skipped entirely when the grade is neutral.
134
+ if (viewU[9].w > 0.5) {
135
+ disp = grade(disp);
136
+ }
110
137
  if (APPLY_GAMMA) {
111
138
  disp = pow(disp, vec3f(viewU[0].y));
112
139
  }
package/src/vmd-loader.ts CHANGED
@@ -32,6 +32,14 @@ export interface CameraKeyframe {
32
32
  interpolation: Uint8Array // 24 bytes
33
33
  }
34
34
 
35
+ /** A VMD "IK/display" record: one moment at which chains are switched. */
36
+ export interface IkFrame {
37
+ frame: number
38
+ /** Model visibility rides in the same record. Parsed, unused by playback. */
39
+ visible: boolean
40
+ states: { boneName: string; enabled: boolean }[]
41
+ }
42
+
35
43
  export class VMDLoader {
36
44
  private view: DataView
37
45
  private offset = 0
@@ -100,6 +108,48 @@ export class VMDLoader {
100
108
  return frames
101
109
  }
102
110
 
111
+ static loadIkFromBuffer(buffer: ArrayBuffer): IkFrame[] {
112
+ return new VMDLoader(buffer).parseIk()
113
+ }
114
+
115
+ /**
116
+ * The IK/display block, at the very end of the file.
117
+ *
118
+ * MMD writes it after camera, light and self-shadow, and a motion-only VMD
119
+ * often stops before any of them — so every block is bounds-checked and a
120
+ * short file simply reports no IK data rather than throwing. Record sizes:
121
+ * bone 111 B, morph 23 B, camera 61 B, light 28 B, self-shadow 9 B.
122
+ */
123
+ private parseIk(): IkFrame[] {
124
+ this.offset = 0
125
+ const header = this.getString(30)
126
+ if (!header.startsWith("Vocaloid Motion Data")) throw new Error("Invalid VMD file header")
127
+ this.skip(20)
128
+ const end = this.view.buffer.byteLength
129
+ const seek = (size: number): boolean => {
130
+ if (this.offset + 4 > end) return false
131
+ this.skip(this.getUint32() * size)
132
+ return this.offset <= end
133
+ }
134
+ if (!seek(111) || !seek(23) || !seek(61) || !seek(28) || !seek(9)) return []
135
+ if (this.offset + 4 > end) return []
136
+
137
+ const count = this.getUint32()
138
+ const frames: IkFrame[] = []
139
+ for (let i = 0; i < count && this.offset + 9 <= end; i++) {
140
+ const frame = this.getUint32()
141
+ const visible = this.getUint8() !== 0
142
+ const ikCount = this.getUint32()
143
+ const states: { boneName: string; enabled: boolean }[] = []
144
+ for (let j = 0; j < ikCount && this.offset + 21 <= end; j++) {
145
+ states.push({ boneName: this.getShiftJisName(20), enabled: this.getUint8() !== 0 })
146
+ }
147
+ frames.push({ frame, visible, states })
148
+ }
149
+ frames.sort((a, b) => a.frame - b.frame)
150
+ return frames
151
+ }
152
+
103
153
  private parse(): VMDKeyFrame[] {
104
154
  // Read header (30 bytes)
105
155
  const header = this.getString(30)
@@ -315,6 +365,20 @@ export class VMDLoader {
315
365
  return v
316
366
  }
317
367
 
368
+ /** A fixed-width, NUL-padded Shift-JIS name — the form every VMD name takes. */
369
+ private getShiftJisName(len: number): string {
370
+ const bytes = new Uint8Array(this.view.buffer, this.offset, len)
371
+ this.offset += len
372
+ let end = bytes.indexOf(0)
373
+ if (end < 0) end = len
374
+ const slice = bytes.slice(0, end)
375
+ try {
376
+ return this.decoder.decode(slice)
377
+ } catch {
378
+ return String.fromCharCode(...slice)
379
+ }
380
+ }
381
+
318
382
  private getString(len: number): string {
319
383
  const bytes = new Uint8Array(this.view.buffer, this.offset, len)
320
384
  this.offset += len
package/src/vmd-writer.ts CHANGED
@@ -7,6 +7,8 @@ const BONE_NAME_SIZE = 15
7
7
  const MORPH_NAME_SIZE = 15
8
8
  const BONE_FRAME_SIZE = BONE_NAME_SIZE + 4 + 12 + 16 + 64 // 111 bytes
9
9
  const MORPH_FRAME_SIZE = MORPH_NAME_SIZE + 4 + 4 // 23 bytes
10
+ /** IK bone names get 20 bytes in the IK block, not the 15 bones get elsewhere. */
11
+ const IK_NAME_SIZE = 20
10
12
 
11
13
  // Build a Unicode-to-Shift-JIS lookup by inverting the TextDecoder mapping.
12
14
  let shiftJISTable: Map<string, number[]> | null = null
@@ -56,11 +58,30 @@ export class VMDWriter {
56
58
  totalMorphFrames += frames.length
57
59
  }
58
60
 
61
+ // IK state is stored per MOMENT, not per bone: one record lists every chain
62
+ // and its state at that frame. So the tracks are transposed back into the
63
+ // frames they were flattened from.
64
+ const ikByFrame = new Map<number, { boneName: string; enabled: boolean }[]>()
65
+ for (const [boneName, keys] of clip.ikTracks ?? []) {
66
+ for (const key of keys) {
67
+ const at = ikByFrame.get(key.frame)
68
+ if (at) at.push({ boneName, enabled: key.enabled })
69
+ else 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) ikSize += 4 + 1 + 4 + states.length * (IK_NAME_SIZE + 1)
75
+
59
76
  const size =
60
77
  HEADER_SIZE +
61
78
  MODEL_NAME_SIZE +
62
79
  4 + totalBoneFrames * BONE_FRAME_SIZE +
63
- 4 + totalMorphFrames * MORPH_FRAME_SIZE
80
+ 4 + totalMorphFrames * MORPH_FRAME_SIZE +
81
+ // Camera, light and self-shadow counts, then the IK block. Written only
82
+ // when there is IK state to carry: a plain motion stays byte-identical to
83
+ // what this writer produced before.
84
+ (ikFrames.length > 0 ? 4 * 4 + ikSize : 0)
64
85
 
65
86
  const buffer = new ArrayBuffer(size)
66
87
  const view = new DataView(buffer)
@@ -124,6 +145,27 @@ export class VMDWriter {
124
145
  }
125
146
  }
126
147
 
148
+ if (ikFrames.length > 0) {
149
+ // Empty camera, light and self-shadow blocks — a reader walking the file
150
+ // in order has to pass through them to reach the IK block.
151
+ for (let i = 0; i < 3; i++, offset += 4) view.setUint32(offset, 0, true)
152
+ view.setUint32(offset, ikFrames.length, true)
153
+ offset += 4
154
+ for (const [frame, states] of ikFrames) {
155
+ view.setUint32(offset, frame, true)
156
+ offset += 4
157
+ view.setUint8(offset, 1) // model visible
158
+ offset += 1
159
+ view.setUint32(offset, states.length, true)
160
+ offset += 4
161
+ for (const state of states) {
162
+ offset = writeFixedShiftJIS(buffer, offset, state.boneName, IK_NAME_SIZE)
163
+ view.setUint8(offset, state.enabled ? 1 : 0)
164
+ offset += 1
165
+ }
166
+ }
167
+ }
168
+
127
169
  return buffer
128
170
  }
129
171
  }