reze-engine 0.42.3 → 0.50.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.
Files changed (140) hide show
  1. package/README.md +40 -410
  2. package/dist/camera.d.ts +3 -0
  3. package/dist/camera.d.ts.map +1 -1
  4. package/dist/camera.js +33 -8
  5. package/dist/engine.d.ts +924 -29
  6. package/dist/engine.d.ts.map +1 -1
  7. package/dist/engine.js +4043 -278
  8. package/dist/graph/registry.d.ts +2 -2
  9. package/dist/graph/registry.d.ts.map +1 -1
  10. package/dist/graph/registry.js +1 -1
  11. package/dist/graph/slots.d.ts +0 -1
  12. package/dist/graph/slots.d.ts.map +1 -1
  13. package/dist/graph/slots.js +37 -9
  14. package/dist/hdr.d.ts +18 -0
  15. package/dist/hdr.d.ts.map +1 -0
  16. package/dist/hdr.js +162 -0
  17. package/dist/ibl.d.ts +19 -0
  18. package/dist/ibl.d.ts.map +1 -0
  19. package/dist/ibl.js +113 -0
  20. package/dist/ik-solver.d.ts +2 -1
  21. package/dist/ik-solver.d.ts.map +1 -1
  22. package/dist/index.d.ts +5 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +10 -0
  25. package/dist/math.d.ts +20 -1
  26. package/dist/math.d.ts.map +1 -1
  27. package/dist/math.js +23 -16
  28. package/dist/midi-loader.d.ts +10 -0
  29. package/dist/midi-loader.d.ts.map +1 -0
  30. package/dist/midi-loader.js +247 -0
  31. package/dist/model.d.ts +2 -13
  32. package/dist/model.d.ts.map +1 -1
  33. package/dist/model.js +29 -0
  34. package/dist/param-track.d.ts +48 -0
  35. package/dist/param-track.d.ts.map +1 -0
  36. package/dist/param-track.js +80 -0
  37. package/dist/physics/types.d.ts.map +1 -1
  38. package/dist/physics/types.js +3 -0
  39. package/dist/reflection.d.ts +27 -0
  40. package/dist/reflection.d.ts.map +1 -0
  41. package/dist/reflection.js +93 -0
  42. package/dist/shaders/anchor-table.d.ts +56 -0
  43. package/dist/shaders/anchor-table.d.ts.map +1 -0
  44. package/dist/shaders/anchor-table.js +128 -0
  45. package/dist/shaders/audio-api.d.ts +3 -0
  46. package/dist/shaders/audio-api.d.ts.map +1 -0
  47. package/dist/shaders/audio-api.js +81 -0
  48. package/dist/shaders/cast-api.d.ts +2 -0
  49. package/dist/shaders/cast-api.d.ts.map +1 -0
  50. package/dist/shaders/cast-api.js +121 -0
  51. package/dist/shaders/cast-layout.d.ts +21 -0
  52. package/dist/shaders/cast-layout.d.ts.map +1 -0
  53. package/dist/shaders/cast-layout.js +20 -0
  54. package/dist/shaders/lights.d.ts +79 -0
  55. package/dist/shaders/lights.d.ts.map +1 -0
  56. package/dist/shaders/lights.js +269 -0
  57. package/dist/shaders/lyrics-api.d.ts +39 -0
  58. package/dist/shaders/lyrics-api.d.ts.map +1 -0
  59. package/dist/shaders/lyrics-api.js +187 -0
  60. package/dist/shaders/materials/common.d.ts +2 -4
  61. package/dist/shaders/materials/common.d.ts.map +1 -1
  62. package/dist/shaders/materials/common.js +87 -35
  63. package/dist/shaders/midi-api.d.ts +10 -0
  64. package/dist/shaders/midi-api.d.ts.map +1 -0
  65. package/dist/shaders/midi-api.js +114 -0
  66. package/dist/shaders/passes/composite.d.ts +31 -15
  67. package/dist/shaders/passes/composite.d.ts.map +1 -1
  68. package/dist/shaders/passes/composite.js +225 -135
  69. package/dist/shaders/passes/cull.d.ts +2 -0
  70. package/dist/shaders/passes/cull.d.ts.map +1 -0
  71. package/dist/shaders/passes/cull.js +138 -0
  72. package/dist/shaders/passes/field-blit.d.ts +26 -0
  73. package/dist/shaders/passes/field-blit.d.ts.map +1 -0
  74. package/dist/shaders/passes/field-blit.js +65 -0
  75. package/dist/shaders/passes/grid.d.ts +31 -0
  76. package/dist/shaders/passes/grid.d.ts.map +1 -0
  77. package/dist/shaders/passes/grid.js +169 -0
  78. package/dist/shaders/passes/ground.d.ts +13 -1
  79. package/dist/shaders/passes/ground.d.ts.map +1 -1
  80. package/dist/shaders/passes/ground.js +170 -25
  81. package/dist/shaders/passes/hosted-api.d.ts +57 -0
  82. package/dist/shaders/passes/hosted-api.d.ts.map +1 -0
  83. package/dist/shaders/passes/hosted-api.js +166 -0
  84. package/dist/shaders/passes/id-debug.d.ts +28 -0
  85. package/dist/shaders/passes/id-debug.d.ts.map +1 -0
  86. package/dist/shaders/passes/id-debug.js +74 -0
  87. package/dist/shaders/passes/particles.d.ts +66 -0
  88. package/dist/shaders/passes/particles.d.ts.map +1 -0
  89. package/dist/shaders/passes/particles.js +279 -0
  90. package/dist/shaders/passes/scene-contract.d.ts +128 -0
  91. package/dist/shaders/passes/scene-contract.d.ts.map +1 -0
  92. package/dist/shaders/passes/scene-contract.js +207 -0
  93. package/dist/shaders/passes/sim.d.ts +34 -0
  94. package/dist/shaders/passes/sim.d.ts.map +1 -0
  95. package/dist/shaders/passes/sim.js +169 -0
  96. package/dist/shaders/passes/trails.d.ts +59 -0
  97. package/dist/shaders/passes/trails.d.ts.map +1 -0
  98. package/dist/shaders/passes/trails.js +340 -0
  99. package/dist/shaders/score-api.d.ts +10 -0
  100. package/dist/shaders/score-api.d.ts.map +1 -0
  101. package/dist/shaders/score-api.js +114 -0
  102. package/dist/shadow-cascades.d.ts +45 -0
  103. package/dist/shadow-cascades.d.ts.map +1 -0
  104. package/dist/shadow-cascades.js +70 -0
  105. package/dist/vmd-loader.d.ts +3 -2
  106. package/dist/vmd-loader.d.ts.map +1 -1
  107. package/package.json +1 -1
  108. package/src/camera.ts +31 -8
  109. package/src/engine.ts +4535 -296
  110. package/src/graph/registry.ts +2 -2
  111. package/src/graph/slots.ts +37 -9
  112. package/src/hdr.ts +156 -0
  113. package/src/ibl.ts +115 -0
  114. package/src/ik-solver.ts +1 -1
  115. package/src/index.ts +12 -0
  116. package/src/math.ts +23 -17
  117. package/src/midi-loader.ts +246 -0
  118. package/src/model.ts +31 -3
  119. package/src/param-track.ts +83 -0
  120. package/src/physics/types.ts +4 -1
  121. package/src/reflection.ts +94 -0
  122. package/src/shaders/anchor-table.ts +147 -0
  123. package/src/shaders/audio-api.ts +82 -0
  124. package/src/shaders/cast-api.ts +123 -0
  125. package/src/shaders/cast-layout.ts +20 -0
  126. package/src/shaders/lights.ts +280 -0
  127. package/src/shaders/lyrics-api.ts +202 -0
  128. package/src/shaders/materials/common.ts +89 -35
  129. package/src/shaders/midi-api.ts +116 -0
  130. package/src/shaders/passes/composite.ts +244 -136
  131. package/src/shaders/passes/cull.ts +139 -0
  132. package/src/shaders/passes/grid.ts +178 -0
  133. package/src/shaders/passes/ground.ts +172 -25
  134. package/src/shaders/passes/hosted-api.ts +171 -0
  135. package/src/shaders/passes/id-debug.ts +75 -0
  136. package/src/shaders/passes/particles.ts +340 -0
  137. package/src/shaders/passes/scene-contract.ts +266 -0
  138. package/src/shaders/passes/trails.ts +390 -0
  139. package/src/shadow-cascades.ts +97 -0
  140. package/src/vmd-loader.ts +2 -2
@@ -8,7 +8,7 @@ import type { SocketValue } from "./schema"
8
8
 
9
9
  export type SockT = "float" | "color" | "vector" | "vec4"
10
10
 
11
- export type InputSpec = {
11
+ type InputSpec = {
12
12
  type: SockT
13
13
  default?: SocketValue
14
14
  /** Socket is meaningless as a literal (e.g. the color being processed) — must be linked. */
@@ -32,7 +32,7 @@ export type NodeSpec = {
32
32
  // Deterministic: same graph JSON → byte-identical WGSL. String(x) is JS shortest
33
33
  // round-trip, so full-precision Blender constants (0.15000000596046448) survive.
34
34
 
35
- export function fmtFloat(x: number): string {
35
+ function fmtFloat(x: number): string {
36
36
  if (!Number.isFinite(x)) throw new Error(`non-finite literal: ${x}`)
37
37
  const s = String(x)
38
38
  return /[.e]/.test(s) ? s : s + ".0"
@@ -8,7 +8,8 @@
8
8
  // See docs/style-groups-spec.md §5, §7.
9
9
 
10
10
  import { NODES_WGSL } from "../shaders/materials/nodes"
11
- import { COMMON_MATERIAL_PRELUDE_WGSL } from "../shaders/materials/common"
11
+ import { COMMON_MATERIAL_PRELUDE_WGSL, commonFsOutWgsl } from "../shaders/materials/common"
12
+ import { sceneIdWriteWgsl } from "../shaders/passes/scene-contract"
12
13
  import type { AlphaMode, RenderClass } from "./render-class"
13
14
 
14
15
  // ── Module-scope declarations ──
@@ -58,7 +59,7 @@ const EYE_REAR_GATE = `
58
59
 
59
60
  // ── Adjust-tier uniforms — fixed 16-vec4f block (256 B) shared by the single material
60
61
  // bind group layout; non-graph draws bind a zero buffer. ──
61
- export const STYLE_UNIFORMS_WGSL = `struct StyleUniforms { p: array<vec4f, 16> };
62
+ const STYLE_UNIFORMS_WGSL = `struct StyleUniforms { p: array<vec4f, 16> };
62
63
  @group(2) @binding(4) var<uniform> style: StyleUniforms;
63
64
 
64
65
  `
@@ -96,7 +97,9 @@ ${discard}
96
97
  ${gate}
97
98
  let l = -light.lights[0].direction.xyz;
98
99
  let sun = light.lights[0].color.xyz * light.lights[0].color.w;
99
- let amb = light.ambientColor.xyz;
100
+ // The world: flat colour, or the HDRI's irradiance at this normal — which
101
+ // is what makes a loaded sky actually light her instead of only backing her.
102
+ let amb = rzWorldAmbient(n);
100
103
  let shadow = sampleShadow(input.worldPos, n);
101
104
  let tex_color = tex_s.rgb;
102
105
 
@@ -108,20 +111,41 @@ ${gate}
108
111
  // pass when IS_OVER_EYES is compiled true.
109
112
  function epilogue(renderClass: RenderClass, alphaMode: AlphaMode): string {
110
113
  const alphaBase = alphaMode === "hashed" ? "1.0" : "alpha"
114
+ // Empty while ids are off, so the epilogue is exactly what it was. The values
115
+ // ride in the per-draw material uniform (see MaterialUniforms), which is what
116
+ // keeps this working through the indirect-draw path.
117
+ const ID_WRITE = sceneIdWriteWgsl("out", "u32(material.materialId)", "u32(material.objectId)")
118
+ // The positional lights, ADDED to whatever the graph decided and tinted by
119
+ // the surface's own texture colour — a red lamp on a blue dress must not wash
120
+ // it toward white. Deliberately outside the graph: this layer does not
121
+ // re-ramp, because two ramped terminators crossing a cheek read as plastic.
122
+ //
123
+ // With no lights rzLightsDiffuse returns exactly zero and its loop never
124
+ // runs, so every scene that declares none renders bit-for-bit as it did
125
+ // before lights existed. That is the property this whole feature is gated on.
126
+ // Modulated by `albedo`, a named local that TODAY is exactly tex_color — the
127
+ // raw texture. That is an approximation with a known failure: a graph that
128
+ // recolours a surface is lit by a colour it no longer shows. The local exists
129
+ // so the fix has an address — when graphs gain an optional albedo output
130
+ // (material-track era), it lands here and every light is corrected at once,
131
+ // instead of a hunt through the epilogue's string templates.
132
+ const LIT = ` + rzLightsDiffuse(input.worldPos, n) * albedo`
133
+ const ALBEDO = ` let albedo = tex_color;
134
+ `
111
135
  if (renderClass === "hair") {
112
- return ` var outAlpha = ${alphaBase};
136
+ return `${ALBEDO} var outAlpha = ${alphaBase};
113
137
  if (IS_OVER_EYES) { outAlpha = ${alphaBase} * 0.25; }
114
138
 
115
139
  var out: FSOut;
116
- out.color = vec4f(final_color, outAlpha);
140
+ out.color = vec4f(final_color${LIT}, outAlpha);
117
141
  out.mask = vec4f(1.0, 1.0, 0.0, out.color.a);
118
- return out;
142
+ ${ID_WRITE} return out;
119
143
  `
120
144
  }
121
- return ` var out: FSOut;
122
- out.color = vec4f(final_color, ${alphaBase});
145
+ return `${ALBEDO} var out: FSOut;
146
+ out.color = vec4f(final_color${LIT}, ${alphaBase});
123
147
  out.mask = vec4f(1.0, 1.0, 0.0, out.color.a);
124
- return out;
148
+ ${ID_WRITE} return out;
125
149
  `
126
150
  }
127
151
 
@@ -139,6 +163,10 @@ export function assembleModule(
139
163
  return (
140
164
  NODES_WGSL +
141
165
  COMMON_MATERIAL_PRELUDE_WGSL +
166
+ // Exactly where it sat inside the prelude constant. It moved out because
167
+ // the id attachment is probed at init and this module is assembled per
168
+ // compile, which is late enough to know the answer.
169
+ commonFsOutWgsl() +
142
170
  (includeStyleUniforms ? STYLE_UNIFORMS_WGSL : "") +
143
171
  decls(renderClass, alphaMode) +
144
172
  prelude(renderClass, alphaMode) +
package/src/hdr.ts ADDED
@@ -0,0 +1,156 @@
1
+ // Radiance .hdr (RGBE) — the HDRI format Blender worlds ship in.
2
+ //
3
+ // Pure and dependency-free like everything else here: the whole format is a
4
+ // text header, a resolution line, and scanlines of shared-exponent RGBE
5
+ // pixels, so a parser is a hundred lines and a WASM decoder would be absurd.
6
+ // The output is scene-linear RGB — the space the view transform expects —
7
+ // which is the entire point: an 8-bit sky is display-space wallpaper, this is
8
+ // radiance the film can actually roll off.
9
+
10
+ export type HdrImage = {
11
+ width: number
12
+ height: number
13
+ /** RGBA, scene-linear, alpha 1 — padded for direct rgba16float upload. */
14
+ data: Float32Array
15
+ }
16
+
17
+ /**
18
+ * Parse a Radiance .hdr file. Throws with a reason on anything malformed —
19
+ * the caller shows it as an upload error, not a blank sky.
20
+ */
21
+ export function parseHDR(buffer: ArrayBuffer): HdrImage {
22
+ const bytes = new Uint8Array(buffer)
23
+ let pos = 0
24
+ const readLine = (): string => {
25
+ let end = pos
26
+ while (end < bytes.length && bytes[end] !== 0x0a) end++
27
+ const line = String.fromCharCode(...bytes.subarray(pos, end))
28
+ pos = end + 1
29
+ return line
30
+ }
31
+
32
+ const magic = readLine()
33
+ if (!magic.startsWith("#?RADIANCE") && !magic.startsWith("#?RGBE")) {
34
+ throw new Error("not a Radiance .hdr file (missing #?RADIANCE header)")
35
+ }
36
+ let formatOk = false
37
+ for (;;) {
38
+ const line = readLine()
39
+ if (line === "") break
40
+ if (line.startsWith("FORMAT=")) {
41
+ if (line !== "FORMAT=32-bit_rle_rgbe") throw new Error(`unsupported .hdr format: ${line.slice(7)}`)
42
+ formatOk = true
43
+ }
44
+ if (pos >= bytes.length) throw new Error("truncated .hdr header")
45
+ }
46
+ if (!formatOk) throw new Error(".hdr header has no FORMAT line")
47
+
48
+ // Only the universal orientation. Rotated/flipped variants exist in theory;
49
+ // no tool in this pipeline writes them, and silently mis-orienting a sky is
50
+ // worse than refusing one.
51
+ const res = readLine()
52
+ const m = /^-Y (\d+) \+X (\d+)$/.exec(res)
53
+ if (!m) throw new Error(`unsupported .hdr orientation: "${res}"`)
54
+ const height = parseInt(m[1], 10)
55
+ const width = parseInt(m[2], 10)
56
+
57
+ const data = new Float32Array(width * height * 4)
58
+ const rgbe = new Uint8Array(width * 4)
59
+
60
+ for (let y = 0; y < height; y++) {
61
+ if (pos + 4 > bytes.length) throw new Error(`truncated at scanline ${y}`)
62
+ const isNewRle = bytes[pos] === 2 && bytes[pos + 1] === 2 && ((bytes[pos + 2] << 8) | bytes[pos + 3]) === width
63
+ if (isNewRle && width >= 8 && width < 32768) {
64
+ pos += 4
65
+ // Four planes, each RLE'd independently: count>128 is a run, else literals.
66
+ for (let c = 0; c < 4; c++) {
67
+ let x = 0
68
+ while (x < width) {
69
+ if (pos >= bytes.length) throw new Error(`truncated RLE at scanline ${y}`)
70
+ let count = bytes[pos++]
71
+ if (count > 128) {
72
+ count -= 128
73
+ const v = bytes[pos++]
74
+ if (x + count > width) throw new Error(`RLE run overflows scanline ${y}`)
75
+ for (let i = 0; i < count; i++) rgbe[(x + i) * 4 + c] = v
76
+ } else {
77
+ if (x + count > width) throw new Error(`RLE literals overflow scanline ${y}`)
78
+ for (let i = 0; i < count; i++) rgbe[(x + i) * 4 + c] = bytes[pos++]
79
+ }
80
+ x += count
81
+ }
82
+ }
83
+ } else {
84
+ // Flat scanline (small widths, or files that never bothered with RLE).
85
+ // The old-style (1,1,1,n) repeat marker is part of this path.
86
+ let x = 0
87
+ while (x < width) {
88
+ if (pos + 4 > bytes.length) throw new Error(`truncated at scanline ${y}`)
89
+ const r = bytes[pos]
90
+ const g = bytes[pos + 1]
91
+ const b = bytes[pos + 2]
92
+ const e = bytes[pos + 3]
93
+ pos += 4
94
+ if (r === 1 && g === 1 && b === 1 && x > 0) {
95
+ // Old-style RLE: repeat the previous pixel e times (shift ignored —
96
+ // scanlines this parser accepts are under 2^15 wide).
97
+ const px = (x - 1) * 4
98
+ for (let i = 0; i < e && x < width; i++, x++) {
99
+ rgbe[x * 4] = rgbe[px]
100
+ rgbe[x * 4 + 1] = rgbe[px + 1]
101
+ rgbe[x * 4 + 2] = rgbe[px + 2]
102
+ rgbe[x * 4 + 3] = rgbe[px + 3]
103
+ }
104
+ } else {
105
+ rgbe[x * 4] = r
106
+ rgbe[x * 4 + 1] = g
107
+ rgbe[x * 4 + 2] = b
108
+ rgbe[x * 4 + 3] = e
109
+ x++
110
+ }
111
+ }
112
+ }
113
+ // RGBE → linear: a shared exponent over three 8-bit mantissas.
114
+ const row = y * width * 4
115
+ for (let x = 0; x < width; x++) {
116
+ const e = rgbe[x * 4 + 3]
117
+ const scale = e === 0 ? 0 : Math.pow(2, e - 136) // 2^(e-128) / 256
118
+ data[row + x * 4] = rgbe[x * 4] * scale
119
+ data[row + x * 4 + 1] = rgbe[x * 4 + 1] * scale
120
+ data[row + x * 4 + 2] = rgbe[x * 4 + 2] * scale
121
+ data[row + x * 4 + 3] = 1
122
+ }
123
+ }
124
+ return { width, height, data }
125
+ }
126
+
127
+ /**
128
+ * Pack float32 to IEEE half, the format rgba16float uploads want. Clamps to
129
+ * the half range rather than producing Inf — a 70000-nit sun texel should
130
+ * read as "the brightest representable", not poison the filter.
131
+ */
132
+ export function packHalf(src: Float32Array): Uint16Array<ArrayBuffer> {
133
+ const out = new Uint16Array(new ArrayBuffer(src.length * 2))
134
+ const f32 = new Float32Array(1)
135
+ const u32 = new Uint32Array(f32.buffer)
136
+ for (let i = 0; i < src.length; i++) {
137
+ f32[0] = src[i]
138
+ const x = u32[0]
139
+ const sign = (x >>> 16) & 0x8000
140
+ let exp = (x >>> 23) & 0xff
141
+ const frac = x & 0x7fffff
142
+ let h: number
143
+ if (exp === 0xff) {
144
+ h = sign | 0x7bff // NaN/Inf → max finite, by the clamp policy above
145
+ } else if (exp > 142) {
146
+ h = sign | 0x7bff // > 65504 → max finite
147
+ } else if (exp < 113) {
148
+ // Subnormal or zero in half precision.
149
+ h = exp < 103 ? sign : sign | ((frac | 0x800000) >> (126 - exp))
150
+ } else {
151
+ h = sign | ((exp - 112) << 10) | (frac >> 13)
152
+ }
153
+ out[i] = h
154
+ }
155
+ return out
156
+ }
package/src/ibl.ts ADDED
@@ -0,0 +1,115 @@
1
+ // Diffuse irradiance from an HDRI, as 9 spherical-harmonic coefficients — the
2
+ // Ramamoorthi–Hanrahan formulation every engine uses, because integrating the
3
+ // whole sky per fragment is absurd and an SH2 fit of irradiance is within 1%
4
+ // for natural skies.
5
+ //
6
+ // Pure and headlessly testable (the shadow-cascades precedent). The output is
7
+ // PRE-FOLDED: all basis and convolution constants are baked into the nine RGB
8
+ // vectors here, so the shader is a plain polynomial in the normal — and the
9
+ // whole thing is normalised so a uniform sky of radiance 1 lights every
10
+ // surface with exactly 1, which is what makes an HDRI world a drop-in for the
11
+ // flat world colour it replaces.
12
+ //
13
+ // Direction convention matches the composite's equirect sampling exactly
14
+ // (LH, +Z forward: u = 0.5 + atan2(x, z)/2π, v = 0.5 − asin(y)/π), because a
15
+ // sky that lights the character from anywhere but where it is drawn would be
16
+ // worse than no IBL at all.
17
+
18
+ import type { HdrImage } from "./hdr"
19
+
20
+ /**
21
+ * Project an equirect HDRI to folded irradiance SH.
22
+ *
23
+ * Returns 9 RGB triplets (27 floats): [A, By, Bz, Bx, Cxy, Cyz, Cz2, Cxz, Cx2y2]
24
+ * for E(n) = A + By·y + Bz·z + Bx·x + Cxy·xy + Cyz·yz + Cz2·(3z²−1) + Cxz·xz + Cx2y2·(x²−y²).
25
+ *
26
+ * `stride` subsamples the image — irradiance is the lowest-frequency thing an
27
+ * HDRI carries, so every 4th texel of a 2K is already overkill.
28
+ */
29
+ export function projectIrradianceSH(img: HdrImage, stride = 4): Float32Array {
30
+ const { width: w, height: h, data } = img
31
+ // Accumulated radiance moments L_lm, RGB each.
32
+ const L = new Float64Array(9 * 3)
33
+ const dPhi = (2 * Math.PI) / w
34
+ const dTheta = Math.PI / h
35
+ for (let y = 0; y < h; y += stride) {
36
+ const v = (y + 0.5) / h
37
+ const ny = Math.cos(Math.PI * v)
38
+ const r = Math.sin(Math.PI * v)
39
+ // Solid angle of one texel row-slice, times the stride² it stands in for.
40
+ const dw = r * dPhi * dTheta * stride * stride
41
+ if (dw <= 0) continue
42
+ for (let x = 0; x < w; x += stride) {
43
+ const u = (x + 0.5) / w
44
+ const phi = (u - 0.5) * 2 * Math.PI
45
+ const nx = r * Math.sin(phi)
46
+ const nz = r * Math.cos(phi)
47
+ const i = (y * w + x) * 4
48
+ // Real SH basis, evaluated once and weighted by the solid angle.
49
+ const b0 = 0.282095 * dw
50
+ const b1 = 0.488603 * ny * dw
51
+ const b2 = 0.488603 * nz * dw
52
+ const b3 = 0.488603 * nx * dw
53
+ const b4 = 1.092548 * nx * ny * dw
54
+ const b5 = 1.092548 * ny * nz * dw
55
+ const b6 = 0.315392 * (3 * nz * nz - 1) * dw
56
+ const b7 = 1.092548 * nx * nz * dw
57
+ const b8 = 0.546274 * (nx * nx - ny * ny) * dw
58
+ for (let c = 0; c < 3; c++) {
59
+ const rad = data[i + c]
60
+ L[0 * 3 + c] += rad * b0
61
+ L[1 * 3 + c] += rad * b1
62
+ L[2 * 3 + c] += rad * b2
63
+ L[3 * 3 + c] += rad * b3
64
+ L[4 * 3 + c] += rad * b4
65
+ L[5 * 3 + c] += rad * b5
66
+ L[6 * 3 + c] += rad * b6
67
+ L[7 * 3 + c] += rad * b7
68
+ L[8 * 3 + c] += rad * b8
69
+ }
70
+ }
71
+ }
72
+ // Irradiance convolution (Ramamoorthi's c1..c5), then /π for the Lambert
73
+ // convention — this is the normalisation that makes uniform-1 → exactly 1.
74
+ const c1 = 0.429043
75
+ const c2 = 0.511664
76
+ const c3 = 0.743125
77
+ const c4 = 0.886227
78
+ const c5 = 0.247708
79
+ const out = new Float32Array(27)
80
+ for (let c = 0; c < 3; c++) {
81
+ const l00 = L[0 * 3 + c]
82
+ const l1m1 = L[1 * 3 + c]
83
+ const l10 = L[2 * 3 + c]
84
+ const l11 = L[3 * 3 + c]
85
+ const l2m2 = L[4 * 3 + c]
86
+ const l2m1 = L[5 * 3 + c]
87
+ const l20 = L[6 * 3 + c]
88
+ const l21 = L[7 * 3 + c]
89
+ const l22 = L[8 * 3 + c]
90
+ // c3·L20·z² is refolded onto the (3z²−1) basis: the z²-free remainder
91
+ // joins the constant term, so the shader's polynomial needs no z² row.
92
+ out[0 * 3 + c] = (c4 * l00 - c5 * l20 + (c3 / 3) * l20) / Math.PI // A
93
+ out[1 * 3 + c] = (2 * c2 * l1m1) / Math.PI // By
94
+ out[2 * 3 + c] = (2 * c2 * l10) / Math.PI // Bz
95
+ out[3 * 3 + c] = (2 * c2 * l11) / Math.PI // Bx
96
+ out[4 * 3 + c] = (2 * c1 * l2m2) / Math.PI // Cxy
97
+ out[5 * 3 + c] = (2 * c1 * l2m1) / Math.PI // Cyz
98
+ out[6 * 3 + c] = ((c3 / 3) * l20) / Math.PI // Cz2 · (3z²−1)
99
+ out[7 * 3 + c] = (2 * c1 * l21) / Math.PI // Cxz
100
+ out[8 * 3 + c] = (2 * c1 * l22) / Math.PI // Cx2y2
101
+ }
102
+ return out
103
+ }
104
+
105
+ /** Evaluate the folded coefficients at a normal — the shader's polynomial, in
106
+ * JS, so tests and any future CPU consumer share one definition. */
107
+ export function evalIrradianceSH(sh: Float32Array, n: { x: number; y: number; z: number }): [number, number, number] {
108
+ const { x, y, z } = n
109
+ const basis = [1, y, z, x, x * y, y * z, 3 * z * z - 1, x * z, x * x - y * y]
110
+ const out: [number, number, number] = [0, 0, 0]
111
+ for (let b = 0; b < 9; b++) {
112
+ for (let c = 0; c < 3; c++) out[c] += sh[b * 3 + c] * basis[b]
113
+ }
114
+ return out
115
+ }
package/src/ik-solver.ts CHANGED
@@ -4,7 +4,7 @@ import { Mat4, Quat, Vec3, type EulerOrder } from "./math"
4
4
  import { Bone, IKLink, IKSolver, IKChainInfo } from "./model"
5
5
 
6
6
  // Callback type for updating world matrix (provided by model to handle append transformations)
7
- export type UpdateWorldMatrixFn = (boneIndex: number, applyIK: boolean) => void
7
+ type UpdateWorldMatrixFn = (boneIndex: number, applyIK: boolean) => void
8
8
 
9
9
  const enum InternalEulerRotationOrder {
10
10
  YXZ = 0,
package/src/index.ts CHANGED
@@ -19,8 +19,20 @@ export {
19
19
  type GizmoDragKind,
20
20
  type EffectParamValue,
21
21
  type EffectResult,
22
+ type CullDiagnostics,
23
+ type MidiNote,
22
24
  } from "./engine"
23
25
  export { parsePmxFolderInput, pmxFileAtRelativePath, type PmxFolderInputResult } from "./folder-upload"
26
+ export { parseMidi } from "./midi-loader"
27
+ // Radiance .hdr, for HDRI worlds — the host fetches the file and hands the
28
+ // parsed image to setBackdropEquirect.
29
+ export { parseHDR, type HdrImage } from "./hdr"
30
+ // Lyrics timing (.lrc) — the host parses the file and hands the lines to setLyrics.
31
+ export { LYRIC_ATLAS_MAX_H, LYRIC_ATLAS_MAX_W, parseLRC, type LyricLine, type LyricRect } from "./shaders/lyrics-api"
32
+ // A material parameter over time. The sampler is exported too: a host that
33
+ // wants to draw a track, or scrub one, should read the same curve the engine
34
+ // plays rather than reimplementing it a second time.
35
+ export { sampleParamTrack, type ParamKey, type ParamValue } from "./param-track"
24
36
  export {
25
37
  compileGraph,
26
38
  validateGraph,
package/src/math.ts CHANGED
@@ -684,16 +684,33 @@ export class Mat4 {
684
684
  return new Mat4(new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]))
685
685
  }
686
686
 
687
- // Perspective matrix for LEFT-HANDED coordinate system (Z+ forward)
688
- // For left-handed: Z goes from 0 (near) to 1 (far), +Z is forward
689
- // Zero-alloc perspective into an existing column-major array (same values as perspective).
687
+ /**
688
+ * Perspective for a LEFT-HANDED system (+Z forward), mapping z ∈ [near, far]
689
+ * onto clip z ∈ [0, w] — WebGPU's range, and D3D's.
690
+ *
691
+ * It used to write the OpenGL matrix, which maps onto [-w, w]. That is not a
692
+ * style difference: WebGPU clips everything with z < 0, so half the mapping was
693
+ * being thrown away and the near plane silently sat at TWICE the requested
694
+ * near. With the camera's own floor of 0.5 that put the real clip at 1.0 units
695
+ * — and since most materials draw with no face culling, a close-up did not fade
696
+ * the front of a head, it cut it open and showed the inside of the skull.
697
+ * Reported from the wild as 穿模 on imported camera work, which is exactly
698
+ * where it would show: a wide-angle shot is composed by moving the camera in.
699
+ *
700
+ * REVERSED-Z is this same function with near and far passed the other way
701
+ * round: it maps near → 1 and far → 0, which is worth doing on a float depth
702
+ * buffer because 1/z crowds precision at the near plane and float crowds it at
703
+ * zero, and the two cancel. On a UNORM buffer it is pointless — the precision
704
+ * curve is identical, only mirrored — so the caller decides, and decides on
705
+ * whether it got a float depth format.
706
+ */
690
707
  static perspectiveInto(out: Float32Array, fov: number, aspect: number, near: number, far: number): void {
691
708
  const f = 1.0 / Math.tan(fov / 2)
692
- const rangeInv = 1.0 / (far - near) // Positive for left-handed
709
+ const rangeInv = 1.0 / (far - near)
693
710
  out[0] = f / aspect; out[1] = 0; out[2] = 0; out[3] = 0
694
711
  out[4] = 0; out[5] = f; out[6] = 0; out[7] = 0
695
- out[8] = 0; out[9] = 0; out[10] = (far + near) * rangeInv; out[11] = 1 // Z+ forward (LH)
696
- out[12] = 0; out[13] = 0; out[14] = -near * far * rangeInv * 2; out[15] = 0
712
+ out[8] = 0; out[9] = 0; out[10] = far * rangeInv; out[11] = 1 // Z+ forward (LH)
713
+ out[12] = 0; out[13] = 0; out[14] = -near * far * rangeInv; out[15] = 0
697
714
  }
698
715
 
699
716
  static perspective(fov: number, aspect: number, near: number, far: number): Mat4 {
@@ -1193,17 +1210,6 @@ export const scratchMat4Values: Float32Array[] = [
1193
1210
  new Float32Array(16),
1194
1211
  ]
1195
1212
 
1196
- export const scratchVec3: Vec3[] = [
1197
- new Vec3(0, 0, 0),
1198
- new Vec3(0, 0, 0),
1199
- new Vec3(0, 0, 0),
1200
- new Vec3(0, 0, 0),
1201
- new Vec3(0, 0, 0),
1202
- new Vec3(0, 0, 0),
1203
- new Vec3(0, 0, 0),
1204
- new Vec3(0, 0, 0),
1205
- ]
1206
-
1207
1213
  export const scratchQuat: Quat[] = [
1208
1214
  new Quat(0, 0, 0, 1),
1209
1215
  new Quat(0, 0, 0, 1),