reze-engine 0.64.0 → 0.66.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 (173) hide show
  1. package/dist/engine.d.ts +300 -94
  2. package/dist/engine.d.ts.map +1 -1
  3. package/dist/engine.js +1628 -787
  4. package/dist/graph/compile.d.ts.map +1 -1
  5. package/dist/graph/compile.js +5 -0
  6. package/dist/graph/presets/body.d.ts.map +1 -1
  7. package/dist/graph/presets/body.js +26 -24
  8. package/dist/graph/presets/cloth_rough.js +8 -8
  9. package/dist/graph/presets/cloth_smooth.js +8 -8
  10. package/dist/graph/presets/default.js +4 -4
  11. package/dist/graph/presets/eye.js +10 -10
  12. package/dist/graph/presets/face.d.ts.map +1 -1
  13. package/dist/graph/presets/face.js +28 -26
  14. package/dist/graph/presets/hair.js +6 -6
  15. package/dist/graph/presets/metal.js +8 -8
  16. package/dist/graph/presets/stockings.d.ts.map +1 -1
  17. package/dist/graph/presets/stockings.js +4 -11
  18. package/dist/graph/presets/unlit.d.ts.map +1 -1
  19. package/dist/graph/presets/unlit.js +9 -13
  20. package/dist/graph/registry.d.ts.map +1 -1
  21. package/dist/graph/registry.js +128 -157
  22. package/dist/graph/render-class.d.ts +5 -2
  23. package/dist/graph/render-class.d.ts.map +1 -1
  24. package/dist/graph/slots.d.ts.map +1 -1
  25. package/dist/graph/slots.js +12 -2
  26. package/dist/index.d.ts +1 -1
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/model.d.ts +23 -1
  29. package/dist/model.d.ts.map +1 -1
  30. package/dist/model.js +41 -1
  31. package/dist/outline-normals.d.ts +12 -0
  32. package/dist/outline-normals.d.ts.map +1 -0
  33. package/dist/outline-normals.js +100 -0
  34. package/dist/pmx-loader.d.ts +7 -0
  35. package/dist/pmx-loader.d.ts.map +1 -1
  36. package/dist/pmx-loader.js +17 -3
  37. package/dist/shaders/anchor-table.d.ts +11 -0
  38. package/dist/shaders/anchor-table.d.ts.map +1 -1
  39. package/dist/shaders/anchor-table.js +9 -3
  40. package/dist/shaders/directives.d.ts +42 -5
  41. package/dist/shaders/directives.d.ts.map +1 -1
  42. package/dist/shaders/directives.js +51 -4
  43. package/dist/shaders/lights.d.ts +11 -7
  44. package/dist/shaders/lights.d.ts.map +1 -1
  45. package/dist/shaders/lights.js +315 -271
  46. package/dist/shaders/materials/common.d.ts.map +1 -1
  47. package/dist/shaders/materials/common.js +30 -5
  48. package/dist/shaders/materials/nodes.d.ts +1 -1
  49. package/dist/shaders/materials/nodes.d.ts.map +1 -1
  50. package/dist/shaders/materials/nodes.js +191 -271
  51. package/dist/shaders/passes/bloom.d.ts +4 -3
  52. package/dist/shaders/passes/bloom.d.ts.map +1 -1
  53. package/dist/shaders/passes/bloom.js +58 -87
  54. package/dist/shaders/passes/composite.d.ts +1 -1
  55. package/dist/shaders/passes/composite.d.ts.map +1 -1
  56. package/dist/shaders/passes/composite.js +121 -77
  57. package/dist/shaders/passes/ground.js +1 -1
  58. package/dist/shaders/passes/mirror.d.ts +18 -12
  59. package/dist/shaders/passes/mirror.d.ts.map +1 -1
  60. package/dist/shaders/passes/mirror.js +59 -13
  61. package/dist/shaders/passes/outline.d.ts.map +1 -1
  62. package/dist/shaders/passes/outline.js +71 -14
  63. package/dist/shaders/passes/particles.d.ts.map +1 -1
  64. package/dist/shaders/passes/particles.js +9 -3
  65. package/dist/shaders/passes/probe.d.ts +45 -0
  66. package/dist/shaders/passes/probe.d.ts.map +1 -0
  67. package/dist/shaders/passes/probe.js +230 -0
  68. package/dist/shaders/passes/scene-contract.d.ts +13 -0
  69. package/dist/shaders/passes/scene-contract.d.ts.map +1 -1
  70. package/dist/shaders/passes/scene-contract.js +4 -0
  71. package/dist/shaders/scene-light-api.d.ts.map +1 -1
  72. package/dist/shaders/scene-light-api.js +1 -0
  73. package/dist/unity/globals.d.ts +7 -0
  74. package/dist/unity/globals.d.ts.map +1 -1
  75. package/dist/unity/globals.js +4 -3
  76. package/dist/unity/host.d.ts +18 -0
  77. package/dist/unity/host.d.ts.map +1 -1
  78. package/dist/unity/host.js +144 -70
  79. package/dist/unity/looks.d.ts +47 -0
  80. package/dist/unity/looks.d.ts.map +1 -1
  81. package/dist/unity/looks.js +150 -6
  82. package/dist/unity/skin.d.ts.map +1 -1
  83. package/dist/unity/skin.js +5 -2
  84. package/dist/unity/stage.d.ts +24 -0
  85. package/dist/unity/stage.d.ts.map +1 -1
  86. package/dist/unity/stage.js +59 -7
  87. package/package.json +1 -1
  88. package/src/engine.ts +2010 -1033
  89. package/src/graph/compile.ts +4 -0
  90. package/src/graph/presets/body.ts +26 -24
  91. package/src/graph/presets/cloth_rough.ts +8 -8
  92. package/src/graph/presets/cloth_smooth.ts +8 -8
  93. package/src/graph/presets/default.ts +4 -4
  94. package/src/graph/presets/eye.ts +10 -10
  95. package/src/graph/presets/face.ts +28 -26
  96. package/src/graph/presets/hair.ts +6 -6
  97. package/src/graph/presets/metal.ts +8 -8
  98. package/src/graph/presets/stockings.ts +4 -11
  99. package/src/graph/presets/unlit.ts +9 -13
  100. package/src/graph/registry.ts +133 -158
  101. package/src/graph/render-class.ts +5 -2
  102. package/src/graph/slots.ts +12 -2
  103. package/src/index.ts +1 -0
  104. package/src/model.ts +50 -1
  105. package/src/outline-normals.ts +110 -0
  106. package/src/pmx-loader.ts +18 -3
  107. package/src/shaders/anchor-table.ts +15 -3
  108. package/src/shaders/directives.ts +88 -6
  109. package/src/shaders/lights.ts +464 -415
  110. package/src/shaders/materials/common.ts +30 -5
  111. package/src/shaders/materials/nodes.ts +191 -271
  112. package/src/shaders/passes/bloom.ts +59 -87
  113. package/src/shaders/passes/composite.ts +121 -77
  114. package/src/shaders/passes/ground.ts +1 -1
  115. package/src/shaders/passes/mirror.ts +60 -13
  116. package/src/shaders/passes/outline.ts +71 -14
  117. package/src/shaders/passes/particles.ts +9 -3
  118. package/src/shaders/passes/probe.ts +240 -0
  119. package/src/shaders/passes/scene-contract.ts +17 -0
  120. package/src/shaders/scene-light-api.ts +1 -0
  121. package/src/unity/globals.ts +7 -3
  122. package/src/unity/host.ts +93 -20
  123. package/src/unity/looks.ts +182 -9
  124. package/src/unity/skin.ts +5 -2
  125. package/src/unity/stage.ts +66 -7
  126. package/dist/physics-debug.d.ts +0 -30
  127. package/dist/physics-debug.d.ts.map +0 -1
  128. package/dist/physics-debug.js +0 -526
  129. package/dist/shaders/agx-lut.d.ts +0 -11
  130. package/dist/shaders/agx-lut.d.ts.map +0 -1
  131. package/dist/shaders/agx-lut.js +0 -29
  132. package/dist/shaders/dfg_lut.d.ts +0 -3
  133. package/dist/shaders/dfg_lut.d.ts.map +0 -1
  134. package/dist/shaders/dfg_lut.js +0 -129
  135. package/dist/shaders/ltc_mag_lut.d.ts +0 -3
  136. package/dist/shaders/ltc_mag_lut.d.ts.map +0 -1
  137. package/dist/shaders/ltc_mag_lut.js +0 -1033
  138. package/dist/shaders/materials/body.d.ts +0 -2
  139. package/dist/shaders/materials/body.d.ts.map +0 -1
  140. package/dist/shaders/materials/body.js +0 -95
  141. package/dist/shaders/materials/cloth_rough.d.ts +0 -2
  142. package/dist/shaders/materials/cloth_rough.d.ts.map +0 -1
  143. package/dist/shaders/materials/cloth_rough.js +0 -69
  144. package/dist/shaders/materials/cloth_smooth.d.ts +0 -2
  145. package/dist/shaders/materials/cloth_smooth.d.ts.map +0 -1
  146. package/dist/shaders/materials/cloth_smooth.js +0 -61
  147. package/dist/shaders/materials/default.d.ts +0 -2
  148. package/dist/shaders/materials/default.d.ts.map +0 -1
  149. package/dist/shaders/materials/default.js +0 -43
  150. package/dist/shaders/materials/eye.d.ts +0 -2
  151. package/dist/shaders/materials/eye.d.ts.map +0 -1
  152. package/dist/shaders/materials/eye.js +0 -60
  153. package/dist/shaders/materials/face.d.ts +0 -2
  154. package/dist/shaders/materials/face.d.ts.map +0 -1
  155. package/dist/shaders/materials/face.js +0 -95
  156. package/dist/shaders/materials/hair.d.ts +0 -2
  157. package/dist/shaders/materials/hair.d.ts.map +0 -1
  158. package/dist/shaders/materials/hair.js +0 -90
  159. package/dist/shaders/materials/metal.d.ts +0 -2
  160. package/dist/shaders/materials/metal.d.ts.map +0 -1
  161. package/dist/shaders/materials/metal.js +0 -77
  162. package/dist/shaders/materials/mmd_classic.d.ts +0 -2
  163. package/dist/shaders/materials/mmd_classic.d.ts.map +0 -1
  164. package/dist/shaders/materials/mmd_classic.js +0 -66
  165. package/dist/shaders/materials/stockings.d.ts +0 -2
  166. package/dist/shaders/materials/stockings.d.ts.map +0 -1
  167. package/dist/shaders/materials/stockings.js +0 -122
  168. package/dist/shaders/passes/physics-debug.d.ts +0 -2
  169. package/dist/shaders/passes/physics-debug.d.ts.map +0 -1
  170. package/dist/shaders/passes/physics-debug.js +0 -69
  171. package/src/shaders/agx-lut.ts +0 -30
  172. package/src/shaders/dfg_lut.ts +0 -131
  173. package/src/shaders/ltc_mag_lut.ts +0 -1035
@@ -73,14 +73,39 @@ struct VertexOutput {
73
73
  /** The triangle's own threshold, flat — see the material VertexOutput. The
74
74
  * hull traces the body's faces, so it has to lose the same ones. */
75
75
  @location(2) @interpolate(flat) faceT: f32,
76
+ /** How much of a pixel-wide line this one really is (1 = all of it): a line
77
+ * held at the minimum width fades by what it was short. */
78
+ @location(3) coverage: f32,
76
79
  };
77
80
 
81
+ /**
82
+ * Half the frame height (engine units, at the subject) below which a line keeps
83
+ * its constant screen width; wider than that it thins as a world-size line.
84
+ * 12.5 = a frame 25 units tall — a full figure (an MMD model is ~20) with
85
+ * room around her.
86
+ *
87
+ * Not the game's own break (w = 1 game unit = 8 engine units). The game's
88
+ * near width is ~3× MMD's 2·edgeSize px; its world-size line comes down to
89
+ * MMD's width only at a full-figure framing, so that is where the break goes:
90
+ * close-ups and full figures keep today's width, and past that the game's rule
91
+ * holds — NDC offset ∝ 1/w, scaled by the zoom as its projection._m11 scales
92
+ * it. At the game's 8 the demo's own camera (33 units) drew a quarter of
93
+ * today's width, which put every line on the faded minimum.
94
+ */
95
+ const RZ_OUTLINE_FULL_FIGURE = 12.5;
96
+ /** The thinnest a line is drawn, in pixels at 1080p (and never under this many
97
+ * device pixels). A line due to be thinner is drawn this wide and faded by
98
+ * the shortfall, so far lines thin out without aliasing into dashes. */
99
+ const RZ_OUTLINE_MIN_PX = 1.5;
100
+
78
101
  @vertex fn vs(
79
102
  @location(0) position: vec3f,
80
103
  @location(1) normal: vec3f,
81
104
  @location(2) uv: vec2f,
82
105
  @location(3) joints0: vec4<u32>,
83
- @location(4) weights0: vec4<f32>
106
+ @location(4) weights0: vec4<f32>,
107
+ /** xyz: the smoothed rest normal (outline-normals.ts), w: PMX edge scale. */
108
+ @location(5) outlineNormal: vec4f
84
109
  ) -> VertexOutput {
85
110
  var output: VertexOutput;
86
111
  let pos4 = vec4f(position, 1.0);
@@ -97,30 +122,58 @@ struct VertexOutput {
97
122
  let m = skinMats[j];
98
123
  skinnedPos += (m * pos4) * w;
99
124
  let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
100
- skinnedNrm += (r3 * normal) * w;
125
+ skinnedNrm += (r3 * outlineNormal.xyz) * w;
101
126
  }
102
127
  let worldPos = skinnedPos.xyz;
103
128
  let worldNormal = normalize(skinnedNrm);
104
129
 
105
130
  let clipPos = camera.projection * camera.view * vec4f(worldPos, 1.0);
106
131
 
107
- // babylon-mmd: screenNormal = normalize((view * worldNormal).xy)
132
+ // The push direction is the view-space normal's XY, NOT renormalized — the
133
+ // game's: full length at the silhouette, where the normal lies across the
134
+ // view, and shrinking on surfaces turned toward the camera, so creases
135
+ // facing her draw finer than the rim. Skinned exactly as the colour pass
136
+ // skins the normal, from the smoothed rest normal, so every vertex sharing a
137
+ // position pushes the same way and the hull stays closed over hard edges
138
+ // and UV seams.
108
139
  let viewNormal = (camera.view * vec4f(worldNormal, 0.0)).xyz;
109
- let snLen = length(viewNormal.xy);
110
- let screenNormal = select(vec2f(0.0, 0.0), viewNormal.xy / snLen, snLen > 1e-5);
111
140
 
112
141
  // Reference-height normalization (babylon-mmd ships this variant commented
113
- // out as \`renderHeight = 1080\`): thickness is a constant FRACTION of the
114
- // frame — 2·edgeSize px at 1080p — so retina DPR and 4K export don't thin
115
- // the rims to sub-pixel. Width follows the projection aspect.
142
+ // out as \`renderHeight = 1080\`): the near width is a constant FRACTION of
143
+ // the frame — 2·edgeSize px at 1080p — so retina DPR and 4K export don't
144
+ // thin the rims to sub-pixel. Width follows the projection aspect.
116
145
  // projection[1][1]/projection[0][0] = width/height for a symmetric frustum.
117
146
  let aspect = camera.projection[1][1] / camera.projection[0][0];
118
- let viewport = vec2f(1080.0 * aspect, 1080.0);
147
+ let refViewport = vec2f(1080.0 * aspect, 1080.0);
148
+ let deviceViewport = vec2f(camera.viewportHeight * aspect, camera.viewportHeight);
149
+
150
+ // The game's width (Character/Debug.shader, pass "Outline"): the clip offset
151
+ // is the near offset × min(w, break depth). Up close the ×w cancels the
152
+ // perspective divide — constant screen width; past the break it stops
153
+ // growing, so the NDC offset falls as 1/w like a line of fixed world width.
154
+ // The break is the depth at which the frame is a full figure tall, so a
155
+ // zoomed lens moves it out (see RZ_OUTLINE_FULL_FIGURE). Under an
156
+ // orthographic camera w is 1 and the same min() makes the line world-size
157
+ // once the frame is taller than a figure. The PMX per-vertex edge scale
158
+ // multiplies it all, as vertex colour alpha does in the game.
159
+ let w = max(clipPos.w, 1e-4);
160
+ let breakDepth = RZ_OUTLINE_FULL_FIGURE * camera.projection[1][1];
161
+ let nearNdc = viewNormal.xy * (material.edgeSize * outlineNormal.w * 4.0 / refViewport);
162
+ var ndc = nearNdc * (min(w, breakDepth) / w);
119
163
 
120
- // NDC offset = edgeSize · 4/viewport, ×w so the perspective divide cancels:
121
- // constant screen thickness at any distance (babylon-mmd parity).
122
- let offset = screenNormal * (material.edgeSize * 4.0 / viewport) * clipPos.w;
123
- output.position = vec4f(clipPos.xy + offset, clipPos.z, clipPos.w);
164
+ // Antialiased minimum (the game's _OutlineAntialias): held at the minimum
165
+ // width and faded by the shortfall, never thinner. The game fades by
166
+ // sqrt(coverage.x · coverage.y), per axis; for one width that is the ratio
167
+ // itself.
168
+ let px = length(ndc * deviceViewport * 0.5);
169
+ let minPx = RZ_OUTLINE_MIN_PX * max(1.0, camera.viewportHeight / 1080.0);
170
+ var coverage = 1.0;
171
+ if (px < minPx) {
172
+ coverage = px / minPx;
173
+ ndc = select(vec2f(0.0), ndc * (minPx / px), px > 1e-6);
174
+ }
175
+ output.coverage = coverage;
176
+ output.position = vec4f(clipPos.xy + ndc * w, clipPos.z, clipPos.w);
124
177
  output.uv = uv;
125
178
  output.restPos = position;
126
179
  output.faceT = rz_dissolve_threshold(position);
@@ -155,7 +208,11 @@ ${sceneFsOutWgsl({ name: "FSOut", aux: "mask" })}
155
208
  discard;
156
209
  }
157
210
  var out: FSOut;
158
- out.color = vec4f(material.edgeColor.rgb, material.edgeColor.a * texA);
211
+ // Blended, not alpha-to-coverage: this pass already blends (the texture
212
+ // alpha above), it is drawn right after the surface it traces so what lies
213
+ // under a faded line is already there, and 4× MSAA would quantize a fade to
214
+ // four steps.
215
+ out.color = vec4f(material.edgeColor.rgb, material.edgeColor.a * texA * input.coverage);
159
216
  out.mask = vec4f(1.0, 1.0, 0.0, out.color.a);
160
217
  ${sceneIdPadWgsl("out")} return out;
161
218
  }
@@ -388,9 +388,15 @@ ${src.orient ? ` // the author's own plane (particleOrient): a card on a surfac
388
388
  let o = particleOrient(p, ii);
389
389
  right = o[0];
390
390
  up = o[1];
391
- ` : ""} let s = sin(p.rot);
391
+ ` : ""}${/\bfn\s+particlePivot\s*\(/.test(src.wgsl) ? ` // the card's pivot (particlePivot, in half-size units of the quad): the
392
+ // corners are offset by it BEFORE the turn, so the card turns about its
393
+ // pivot rather than its centre — Unity's renderer pivot, a lighthouse beam
394
+ // whose card starts at the lamp instead of straddling it
395
+ let cp = c + particlePivot(p, ii);
396
+ ` : ` let cp = c;
397
+ `} let s = sin(p.rot);
392
398
  let k = cos(p.rot);
393
- var r = vec2f(c.x * k - c.y * s, c.x * s + c.y * k);
399
+ var r = vec2f(cp.x * k - cp.y * s, cp.x * s + cp.y * k);
394
400
  // Stretched along the direction of travel ON SCREEN — which is not the world
395
401
  // direction once the camera is off-axis. Rain falling straight down is nearly
396
402
  // a point when viewed from above and a long streak from the side, and taking
@@ -402,7 +408,7 @@ ${src.orient ? ` // the author's own plane (particleOrient): a card on a surfac
402
408
  let vlen = length(vec2f(vr, vu));
403
409
  if (vlen > 1e-5) {
404
410
  let d = vec2f(vr, vu) / vlen;
405
- r = vec2f(d.y, -d.x) * c.x + d * (c.y * p.stretch);
411
+ r = vec2f(d.y, -d.x) * cp.x + d * (cp.y * p.stretch);
406
412
  }
407
413
  }
408
414
  let world = p.pos + (right * r.x + up * r.y) * p.size;
@@ -0,0 +1,240 @@
1
+ // The reflection probe's GPU passes, and the sky's prefilter.
2
+ //
3
+ // A game does not reflect the sky alone: it bakes a cube of the stage itself
4
+ // from somewhere in the middle of it, prefilters it down its mips with the
5
+ // specular lobe, and every surface reads it box-projected onto the stage's
6
+ // bounds. The engine does the same once a stage has loaded (Engine:
7
+ // captureReflectionProbe): six renders of the stage into a 128² cube, the
8
+ // sky laid in wherever nothing was drawn, a mip chain, then a GGX convolution
9
+ // into the 64² cube the materials sample (rzProbeSpecular in nodes.ts).
10
+ //
11
+ // The world's sky gets the same convolution down its equirect mips, in place
12
+ // of the box average they were built from.
13
+
14
+ import { NODES_WGSL } from "../materials/nodes"
15
+ import { COMMON_MATERIAL_PRELUDE_WGSL, DISSOLVE_WGSL } from "../materials/common"
16
+
17
+ /** Face size of the capture, and of the cube the materials read. The game
18
+ * bakes 64²; the capture is twice that so the base level is antialiased. */
19
+ export const PROBE_CAPTURE_SIZE = 128
20
+ export const PROBE_SIZE = 64
21
+ /** 64² down to 1²: seven levels, the game's own count. */
22
+ export const PROBE_LEVELS = 7
23
+ export const PROBE_CAPTURE_LEVELS = 8
24
+
25
+ /**
26
+ * The perceptual roughness level m of the probe was convolved with, on
27
+ * Unity's probe curve: m = r·(1.7 − 0.7r)·6 (UNITY_SPECCUBE_LOD_STEPS), the
28
+ * convention the game's baked probes follow and its lookup (×8, clamped at 6)
29
+ * reads against. Level 0 is the unblurred capture.
30
+ */
31
+ export function probeLevelRoughness(level: number): number {
32
+ if (level <= 0) return 0
33
+ const m = Math.min(level, 6)
34
+ return Math.min((10.2 - Math.sqrt(Math.max(104.04 - 16.8 * m, 0))) / 8.4, 1)
35
+ }
36
+
37
+ /**
38
+ * The perceptual roughness level m of the sky's equirect chain was convolved
39
+ * with: the engine's own lookup is lod = sqrt(r)·(levels − 1), so level m is
40
+ * r = (m / (levels − 1))².
41
+ */
42
+ export function skyLevelRoughness(level: number, levels: number): number {
43
+ if (level <= 0 || levels <= 1) return 0
44
+ const t = level / (levels - 1)
45
+ return Math.min(t * t, 1)
46
+ }
47
+
48
+ /** A cube face's direction for a uv on it (uv.y down), in the convention the
49
+ * GPU samples a cube with — the same one the capture cameras render. */
50
+ const FACE_DIR_WGSL = /* wgsl */ `
51
+ fn rz_face_dir(face: u32, uv: vec2f) -> vec3f {
52
+ let s = uv.x * 2.0 - 1.0;
53
+ let t = uv.y * 2.0 - 1.0;
54
+ switch face {
55
+ case 0u: { return normalize(vec3f(1.0, -t, -s)); }
56
+ case 1u: { return normalize(vec3f(-1.0, -t, s)); }
57
+ case 2u: { return normalize(vec3f(s, 1.0, t)); }
58
+ case 3u: { return normalize(vec3f(s, -1.0, -t)); }
59
+ case 4u: { return normalize(vec3f(s, -t, 1.0)); }
60
+ default: { return normalize(vec3f(-s, -t, -1.0)); }
61
+ }
62
+ }
63
+
64
+ struct RzFullOut { @builtin(position) pos: vec4f, @location(0) uv: vec2f };
65
+
66
+ @vertex fn vs_full(@builtin(vertex_index) i: u32) -> RzFullOut {
67
+ var o: RzFullOut;
68
+ let x = f32(i32(i / 2u) * 4 - 1);
69
+ let y = f32(i32(i % 2u) * 4 - 1);
70
+ o.pos = vec4f(x, y, 0.0, 1.0);
71
+ o.uv = vec2f(x * 0.5 + 0.5, 0.5 - y * 0.5);
72
+ return o;
73
+ }
74
+ `
75
+
76
+ /** GGX importance sampling, the filtered kind (Křivánek & Colbert): each
77
+ * sample reads the source at the mip whose texel matches its solid angle, so
78
+ * a few dozen taps integrate a wide lobe without the fireflies a point sample
79
+ * of a bright lamp would leave. */
80
+ const GGX_SAMPLING_WGSL = /* wgsl */ `
81
+ fn rz_hammersley(i: u32, n: u32) -> vec2f {
82
+ var b = (i << 16u) | (i >> 16u);
83
+ b = ((b & 0x55555555u) << 1u) | ((b & 0xAAAAAAAAu) >> 1u);
84
+ b = ((b & 0x33333333u) << 2u) | ((b & 0xCCCCCCCCu) >> 2u);
85
+ b = ((b & 0x0F0F0F0Fu) << 4u) | ((b & 0xF0F0F0F0u) >> 4u);
86
+ b = ((b & 0x00FF00FFu) << 8u) | ((b & 0xFF00FF00u) >> 8u);
87
+ return vec2f(f32(i) / f32(n), f32(b) * 2.3283064365386963e-10);
88
+ }
89
+
90
+ /** A half vector about N for the lobe at alpha = a (= perceptual r²). */
91
+ fn rz_ggx_half(xi: vec2f, N: vec3f, a: f32) -> vec3f {
92
+ let phi = 6.283185307 * xi.x;
93
+ let cosT = sqrt((1.0 - xi.y) / (1.0 + (a * a - 1.0) * xi.y));
94
+ let sinT = sqrt(max(1.0 - cosT * cosT, 0.0));
95
+ let h = vec3f(sinT * cos(phi), sinT * sin(phi), cosT);
96
+ let up = select(vec3f(1.0, 0.0, 0.0), vec3f(0.0, 0.0, 1.0), abs(N.z) < 0.999);
97
+ let tx = normalize(cross(up, N));
98
+ let ty = cross(N, tx);
99
+ return normalize(tx * h.x + ty * h.y + N * h.z);
100
+ }
101
+
102
+ fn rz_ggx_d(nh: f32, a: f32) -> f32 {
103
+ let a2 = a * a;
104
+ let d = nh * nh * (a2 - 1.0) + 1.0;
105
+ return a2 / (3.141592653589793 * d * d);
106
+ }
107
+ `
108
+
109
+ /**
110
+ * The compose step: the stage as captured, with the world's sky laid in
111
+ * behind it wherever the capture's coverage says nothing was drawn — an open
112
+ * stage reflects its sky, an interior its walls. Built on the material
113
+ * prelude for rzWorldSpecularLod, which knows every kind of sky the scene may
114
+ * have (an HDRI, a gradient, one colour). Only the bindings it reads end up in
115
+ * its layout ("auto"): the light uniform, the sampler and the sky at group 0,
116
+ * and its own three past the material set's.
117
+ */
118
+ export const PROBE_COMPOSE_WGSL =
119
+ NODES_WGSL +
120
+ COMMON_MATERIAL_PRELUDE_WGSL +
121
+ DISSOLVE_WGSL +
122
+ FACE_DIR_WGSL +
123
+ /* wgsl */ `
124
+ struct RzProbeStep { face: u32, lod: f32, roughness: f32, srcSize: f32 };
125
+ @group(0) @binding(20) var rzCaptureColor: texture_2d_array<f32>;
126
+ @group(0) @binding(21) var rzCaptureAux: texture_2d_array<f32>;
127
+ @group(0) @binding(22) var<uniform> rzStep: RzProbeStep;
128
+
129
+ @fragment fn fs_compose(in: RzFullOut) -> @location(0) vec4f {
130
+ let size = textureDimensions(rzCaptureColor);
131
+ let px = vec2u(min(in.pos.xy, vec2f(size) - vec2f(1.0)));
132
+ let c = textureLoad(rzCaptureColor, px, rzStep.face, 0).rgb;
133
+ let cover = saturate(textureLoad(rzCaptureAux, px, rzStep.face, 0).g);
134
+ let sky = rzWorldSpecularLod(rz_face_dir(rzStep.face, in.uv), 0.0, 0.0);
135
+ return vec4f(c + sky * (1.0 - cover), 1.0);
136
+ }
137
+ `
138
+
139
+ /**
140
+ * The cube's own passes: a mip from the level above (mode 0 — one bilinear
141
+ * tap at the child texel's direction is the 2×2 average), and the GGX
142
+ * convolution into the probe the materials read (mode 1).
143
+ */
144
+ export const PROBE_FILTER_WGSL =
145
+ FACE_DIR_WGSL +
146
+ GGX_SAMPLING_WGSL +
147
+ /* wgsl */ `
148
+ struct RzProbeStep { face: u32, lod: f32, roughness: f32, srcSize: f32, mode: u32 };
149
+ @group(0) @binding(0) var rzSrc: texture_cube<f32>;
150
+ @group(0) @binding(1) var rzSamp: sampler;
151
+ @group(0) @binding(2) var<uniform> rzStep: RzProbeStep;
152
+
153
+ const RZ_PROBE_SAMPLES: u32 = 128u;
154
+
155
+ @fragment fn fs_filter(in: RzFullOut) -> @location(0) vec4f {
156
+ let N = rz_face_dir(rzStep.face, in.uv);
157
+ let r = rzStep.roughness;
158
+ if (rzStep.mode == 0u || r < 0.002) {
159
+ return vec4f(textureSampleLevel(rzSrc, rzSamp, N, rzStep.lod).rgb, 1.0);
160
+ }
161
+ let a = r * r;
162
+ let texelSolid = 4.0 * 3.141592653589793 / (6.0 * rzStep.srcSize * rzStep.srcSize);
163
+ let maxLod = f32(textureNumLevels(rzSrc) - 1u);
164
+ var sum = vec3f(0.0);
165
+ var weight = 0.0;
166
+ for (var i = 0u; i < RZ_PROBE_SAMPLES; i++) {
167
+ let H = rz_ggx_half(rz_hammersley(i, RZ_PROBE_SAMPLES), N, a);
168
+ let L = 2.0 * dot(N, H) * H - N;
169
+ let nl = dot(N, L);
170
+ if (nl <= 0.0) { continue; }
171
+ // N = V: the pdf over L is D·(N·H)/(4·V·H) = D/4.
172
+ let pdf = rz_ggx_d(saturate(dot(N, H)), a) * 0.25;
173
+ let sampleSolid = 1.0 / (f32(RZ_PROBE_SAMPLES) * pdf + 1e-6);
174
+ let lod = clamp(0.5 * log2(sampleSolid / texelSolid) + 1.0, 0.0, maxLod);
175
+ sum += textureSampleLevel(rzSrc, rzSamp, L, lod).rgb * nl;
176
+ weight += nl;
177
+ }
178
+ return vec4f(sum / max(weight, 1e-4), 1.0);
179
+ }
180
+ `
181
+
182
+ /**
183
+ * The sky's mips, convolved: level m of the equirect the materials read
184
+ * (rzWorldSpecularLod) becomes the GGX-filtered sky at the roughness that
185
+ * level stands for, sampled from the box-averaged chain the upload built.
186
+ * Directions follow the composite's dome: u = 0.5 + atan2(x, z)/2π,
187
+ * v = 0.5 − asin(y)/π.
188
+ */
189
+ export const SKY_PREFILTER_WGSL =
190
+ GGX_SAMPLING_WGSL +
191
+ /* wgsl */ `
192
+ struct RzFullOut { @builtin(position) pos: vec4f, @location(0) uv: vec2f };
193
+
194
+ @vertex fn vs_full(@builtin(vertex_index) i: u32) -> RzFullOut {
195
+ var o: RzFullOut;
196
+ let x = f32(i32(i / 2u) * 4 - 1);
197
+ let y = f32(i32(i % 2u) * 4 - 1);
198
+ o.pos = vec4f(x, y, 0.0, 1.0);
199
+ o.uv = vec2f(x * 0.5 + 0.5, 0.5 - y * 0.5);
200
+ return o;
201
+ }
202
+
203
+ struct RzSkyStep { roughness: f32, srcWidth: f32, srcHeight: f32, spare: f32 };
204
+ @group(0) @binding(0) var rzSky: texture_2d<f32>;
205
+ @group(0) @binding(1) var rzSamp: sampler;
206
+ @group(0) @binding(2) var<uniform> rzStep: RzSkyStep;
207
+
208
+ const RZ_SKY_SAMPLES: u32 = 64u;
209
+
210
+ fn rz_sky_dir(uv: vec2f) -> vec3f {
211
+ let phi = (uv.x - 0.5) * 6.283185307;
212
+ let lat = (0.5 - uv.y) * 3.141592653589793;
213
+ return vec3f(cos(lat) * sin(phi), sin(lat), cos(lat) * cos(phi));
214
+ }
215
+
216
+ fn rz_sky_uv(d: vec3f) -> vec2f {
217
+ return vec2f(0.5 + atan2(d.x, d.z) * 0.15915494309, 0.5 - asin(clamp(d.y, -1.0, 1.0)) * 0.31830988618);
218
+ }
219
+
220
+ @fragment fn fs_sky(in: RzFullOut) -> @location(0) vec4f {
221
+ let N = rz_sky_dir(in.uv);
222
+ let a = rzStep.roughness * rzStep.roughness;
223
+ let texelSolid = 4.0 * 3.141592653589793 / (rzStep.srcWidth * rzStep.srcHeight);
224
+ let maxLod = f32(textureNumLevels(rzSky) - 1u);
225
+ var sum = vec3f(0.0);
226
+ var weight = 0.0;
227
+ for (var i = 0u; i < RZ_SKY_SAMPLES; i++) {
228
+ let H = rz_ggx_half(rz_hammersley(i, RZ_SKY_SAMPLES), N, a);
229
+ let L = 2.0 * dot(N, H) * H - N;
230
+ let nl = dot(N, L);
231
+ if (nl <= 0.0) { continue; }
232
+ let pdf = rz_ggx_d(saturate(dot(N, H)), a) * 0.25;
233
+ let sampleSolid = 1.0 / (f32(RZ_SKY_SAMPLES) * pdf + 1e-6);
234
+ let lod = clamp(0.5 * log2(sampleSolid / texelSolid) + 1.0, 0.0, maxLod);
235
+ sum += textureSampleLevel(rzSky, rzSamp, rz_sky_uv(L), lod).rgb * nl;
236
+ weight += nl;
237
+ }
238
+ return vec4f(sum / max(weight, 1e-4), 1.0);
239
+ }
240
+ `
@@ -98,6 +98,12 @@ type SceneRenderClass =
98
98
  * X309's night sky was. Out of WRITES_ID for the mirror's reason — a sheet of
99
99
  * light is not an object anything should be able to pick or outline. */
100
100
  | "material-additive"
101
+ /** A material whose colour arrives ALREADY weighted by its coverage — a
102
+ * game's glass (Blend One OneMinusSrcAlpha): the pane's diffuse fades with
103
+ * its alpha while its reflection does not, which a src-alpha blend cannot
104
+ * express. Out of WRITES_ID for the mirror's reason: a window is not what a
105
+ * click on the figure behind it means. */
106
+ | "material-premultiplied"
101
107
  /** Particles and ribbons in their default, non-additive mode. */
102
108
  | "particle"
103
109
  /** Particles declaring `#blend additive` — LIGHT rather than matter, so
@@ -110,6 +116,13 @@ type SceneRenderClass =
110
116
  * whole, and an ordinary alpha-over aux so a ribbon's mask does not
111
117
  * saturate along every overlap. */
112
118
  | "trail"
119
+ /** Ribbons declaring `#blend over` — MATTER rather than light: laid over
120
+ * what is behind them by their own alpha, the way a game's trail material
121
+ * with Blend One OneMinusSrcAlpha draws. A dark band then darkens, which an
122
+ * additive ribbon can never do; it is how a deep-blue smoke tail reads as a
123
+ * tail at all. The fragment writes straight colour, so the colour blend is
124
+ * src-alpha like any other alpha-over class. */
125
+ | "trail-over"
113
126
  /** The transparent depth prepass: it exists to write DEPTH after the fabric's
114
127
  * colour blended, so an outline drawn later is occluded behind it. It must
115
128
  * therefore write no colour at all — the targets exist only to make the
@@ -183,6 +196,9 @@ const BLENDS: Record<Exclude<SceneRenderClass, "depth-prepass">, [GPUBlendState,
183
196
  // The same pair the additive particles use: light into the colour target, and
184
197
  // coverage that still accumulates so bloom and the composite can see it.
185
198
  "material-additive": [ADD_KEEP_ALPHA, ADD_BOTH],
199
+ // The colour premultiplied by the graph (game_pbr's glass); the aux is
200
+ // written straight, as every material's is.
201
+ "material-premultiplied": [PREMULTIPLIED_OVER, ALPHA_OVER],
186
202
  // PREMULTIPLIED, like the ground and for the same reason: what a mirror
187
203
  // writes is a sample of the HDR target, and that target already holds colour
188
204
  // premultiplied by its own alpha. Handed to the src-alpha blend it would be
@@ -201,6 +217,7 @@ const BLENDS: Record<Exclude<SceneRenderClass, "depth-prepass">, [GPUBlendState,
201
217
  particle: [PREMULTIPLIED_OVER, ALPHA_OVER],
202
218
  "particle-additive": [ADD_KEEP_ALPHA, ADD_BOTH],
203
219
  trail: [ADD_PREMULTIPLIED, ALPHA_OVER],
220
+ "trail-over": [ALPHA_OVER, ALPHA_OVER],
204
221
  }
205
222
 
206
223
  /**
@@ -131,6 +131,7 @@ export function sceneLightApi(on: boolean, group: number, binding: number): stri
131
131
  return /* wgsl */ `
132
132
  fn rzShadow(p: vec3f) -> f32 { return 1.0; }
133
133
  fn rzWorldAmbient(n: vec3f) -> vec3f { return vec3f(0.0); }
134
+ fn rzWorldAmbientAvg() -> vec3f { return vec3f(0.0); }
134
135
  `
135
136
  }
136
137
  const n = SHADOW_CASCADES.length
@@ -71,6 +71,9 @@ export type UnityFrameInput = {
71
71
  /** The direction the main light travels, unit; null for none. */
72
72
  sunDirection: [number, number, number] | null
73
73
  sunColor: [number, number, number]
74
+ /** A game stage's own main light as its pipeline packed it (recorded), which
75
+ * its shaders get in place of sunColor; null for the scene sun's. */
76
+ sunUnity?: { color: number[]; simColor: number[]; simColorNoInt: number[] } | null
74
77
  sunShadow: number
75
78
  lights: UnityLight[]
76
79
  /** Ambient as the engine's folded SH9 (27 floats, ibl.ts), or null for flat. */
@@ -257,12 +260,13 @@ export function unityFrameGlobals(f: UnityFrameInput): Record<string, NativeValu
257
260
  if (f.sunDirection) {
258
261
  const d = gameDir(f.sunDirection)
259
262
  g.SimMainLightDir = [-d[0], -d[1], -d[2], 0]
260
- g.SimMainLightColor = [...f.sunColor, 1]
261
- g.SimMainLightColorNoInt = [...f.sunColor, 1]
263
+ const u = f.sunUnity
264
+ g.SimMainLightColor = u ? u.simColor.slice(0, 4) : [...f.sunColor, 1]
265
+ g.SimMainLightColorNoInt = u ? u.simColorNoInt.slice(0, 4) : [...f.sunColor, 1]
262
266
  // As the game's pipeline leaves it (recorded): the way the light travels.
263
267
  g._MainLightPosition = [d[0], d[1], d[2], 0]
264
268
  g.sim_ShadowLightDirection = [-d[0], -d[1], -d[2], 0]
265
- g._MainLightColor = [...f.sunColor, 1]
269
+ g._MainLightColor = u ? u.color.slice(0, 4) : [...f.sunColor, 1]
266
270
  } else {
267
271
  g.SimMainLightDir = [0, 0, 0, 0]
268
272
  g.SimMainLightColor = [0, 0, 0, 0]
package/src/unity/host.ts CHANGED
@@ -54,6 +54,21 @@ export type NativeValue = number | ArrayLike<number> | ArrayLike<ArrayLike<numbe
54
54
  /** Name -> value; the first source that has a name wins. */
55
55
  export type ValueSource = Record<string, NativeValue | undefined>
56
56
 
57
+ /** Whether two values carry the same numbers (and the same integer-ness). */
58
+ export function sameValue(a: NativeValue, b: NativeValue): boolean {
59
+ if (typeof a === "number" || typeof b === "number") return a === b
60
+ if (a.length !== b.length) return false
61
+ if (a instanceof Uint32Array !== b instanceof Uint32Array) return false
62
+ for (let i = 0; i < a.length; i++) {
63
+ const x = a[i] as number | ArrayLike<number>
64
+ const y = b[i] as number | ArrayLike<number>
65
+ if (typeof x === "number" || typeof y === "number") {
66
+ if (x !== y) return false
67
+ } else if (!sameValue(x, y)) return false
68
+ }
69
+ return true
70
+ }
71
+
57
72
  /** One vertex stream a draw binds, by the semantic a shader input names. */
58
73
  export type NativeStream = {
59
74
  buffer: GPUBuffer
@@ -87,14 +102,25 @@ export function wrapFragment(frag: string, ids: boolean): string {
87
102
  const at = frag.lastIndexOf("@fragment")
88
103
  if (at < 0) throw new Error("native shader: no fragment entry point")
89
104
  const head = frag.slice(at)
90
- const sig = /->\s*@location\(0\)\s*vec4<f32>\s*\{/
91
- if (!sig.test(head)) throw new Error("native shader: the fragment entry does not return one vec4 target")
105
+ // A target narrower than four channels (a float3 SV_Target) leaves the rest
106
+ // unwritten: widened here with zeros and an alpha of one.
107
+ const sig = /->\s*@location\(0\)\s*(vec4<f32>|vec3<f32>|vec2<f32>|f32)\s*\{/
108
+ const kind = sig.exec(head)?.[1]
109
+ if (!kind) throw new Error("native shader: the fragment entry does not return one float target")
92
110
  let body = head.replace(sig, "-> RzNativeOut {")
93
111
  const ret = /return\s+(_e\d+)\s*;\s*\}\s*$/
94
112
  const m = ret.exec(body)
95
113
  if (!m) throw new Error("native shader: the fragment entry's return is not the translator's")
96
- const v = m[1]
97
- body = body.replace(ret, `return RzNativeOut(${v}, vec4f(1.0, 1.0, 0.0, ${v}.a)${ids ? ", vec2u(0u)" : ""});\n}\n`)
114
+ const e = m[1]
115
+ const v =
116
+ kind === "vec4<f32>"
117
+ ? e
118
+ : kind === "vec3<f32>"
119
+ ? `vec4f(${e}, 1.0)`
120
+ : kind === "vec2<f32>"
121
+ ? `vec4f(${e}, 0.0, 1.0)`
122
+ : `vec4f(${e}, 0.0, 0.0, 1.0)`
123
+ body = body.replace(ret, `let rzColor = ${v};\n return RzNativeOut(rzColor, vec4f(1.0, 1.0, 0.0, rzColor.a)${ids ? ", vec2u(0u)" : ""});\n}\n`)
98
124
  const struct = `struct RzNativeOut {\n @location(0) color: vec4f,\n @location(1) mask: vec4f,\n${ids ? " @location(2) id: vec2u,\n" : ""}};\n`
99
125
  // After the module's directives (diagnostic, enable): WGSL wants them first.
100
126
  const pre = frag.slice(0, at)
@@ -173,6 +199,8 @@ export type NativeDraw = {
173
199
  export class NativeHost {
174
200
  private prepared = new Map<string, Prepared>()
175
201
  private pipelines = new Map<string, { pipeline: GPURenderPipeline; stencilRef: number } | null>()
202
+ /** Pipelines being compiled (see warm). */
203
+ private pending = new Map<string, Promise<void>>()
176
204
  private draws = new Map<string, DrawCache>()
177
205
  private shared = new Map<string, Block>()
178
206
  private frame = 0
@@ -188,6 +216,7 @@ export class NativeHost {
188
216
  white: GPUTextureView
189
217
  black: GPUTextureView
190
218
  blackCube: GPUTextureView
219
+ emptyVolume: GPUTextureView
191
220
  depth: GPUTextureView
192
221
  comparison: GPUSampler
193
222
  },
@@ -281,9 +310,58 @@ export class NativeHost {
281
310
  : []
282
311
  }
283
312
 
313
+ private pipelineKey(p: Prepared, state: PassState, streams: NativeStream[]): string {
314
+ return `${p.shader.name}|${JSON.stringify(state)}|${streams.map((s) => `${s.format}/${s.stride}`).join(",")}`
315
+ }
316
+
317
+ /**
318
+ * Compile a draw's pipeline ahead of the draw — what a stage or a look calls
319
+ * for every pass it will draw, the moment it knows the shader, the state and
320
+ * the streams. Async: a synchronous build holds the GPU process for the
321
+ * whole compile, which was a one-to-two-second frozen frame at the first
322
+ * draw of each new shader. Resolves when the pipeline is ready or has
323
+ * failed (into `errors`); never rejects.
324
+ */
325
+ warm(shader: string, state: PassState, streams: NativeStream[]): Promise<void> {
326
+ const p = this.prepared.get(shader)
327
+ if (!p) return Promise.resolve()
328
+ const key = this.pipelineKey(p, state, streams)
329
+ if (this.pipelines.has(key)) return Promise.resolve()
330
+ let job = this.pending.get(key)
331
+ if (job) return job
332
+ let built: { desc: GPURenderPipelineDescriptor; stencilRef: number }
333
+ try {
334
+ built = this.describe(p, state, streams)
335
+ } catch (e) {
336
+ this.errors.push(`pipeline ${p.shader.name}: ${(e as Error).message}`)
337
+ this.pipelines.set(key, null)
338
+ return Promise.resolve()
339
+ }
340
+ job = this.device.createRenderPipelineAsync(built.desc).then(
341
+ (pipeline) => {
342
+ this.pipelines.set(key, { pipeline, stencilRef: built.stencilRef })
343
+ this.pending.delete(key)
344
+ },
345
+ (e) => {
346
+ this.errors.push(`pipeline ${p.shader.name}: ${(e as Error).message}`)
347
+ this.pipelines.set(key, null)
348
+ this.pending.delete(key)
349
+ },
350
+ )
351
+ this.pending.set(key, job)
352
+ return job
353
+ }
354
+
355
+ /** The draw's pipeline if it is ready. Never built here: one that is not
356
+ * ready is started and the draw sits this frame out. */
284
357
  private pipeline(p: Prepared, state: PassState, streams: NativeStream[]) {
285
- const key = `${p.shader.name}|${JSON.stringify(state)}|${streams.map((s) => s.format).join(",")}`
286
- if (this.pipelines.has(key)) return this.pipelines.get(key)!
358
+ const ready = this.pipelines.get(this.pipelineKey(p, state, streams))
359
+ if (ready !== undefined) return ready
360
+ void this.warm(p.shader.name, state, streams)
361
+ return null
362
+ }
363
+
364
+ private describe(p: Prepared, state: PassState, streams: NativeStream[]): { desc: GPURenderPipelineDescriptor; stencilRef: number } {
287
365
  const t = this.targets
288
366
  const stencil = stencilState(state)
289
367
  const blend = blendState(state)
@@ -317,10 +395,8 @@ export class NativeHost {
317
395
  ]
318
396
  if (t.colorFormats[2]) targets.push({ format: t.colorFormats[2], writeMask: 0 })
319
397
  const offset = state.Offset
320
- let result: { pipeline: GPURenderPipeline; stencilRef: number } | null
321
- try {
322
- result = {
323
- pipeline: this.device.createRenderPipeline({
398
+ return {
399
+ desc: {
324
400
  label: `${p.shader.name} pipeline`,
325
401
  layout: p.layout,
326
402
  vertex: {
@@ -367,15 +443,9 @@ export class NativeHost {
367
443
  : {}),
368
444
  },
369
445
  multisample: { count: t.sampleCount },
370
- }),
371
- stencilRef: stencil?.ref ?? 0,
372
- }
373
- } catch (e) {
374
- this.errors.push(`pipeline ${p.shader.name}: ${(e as Error).message}`)
375
- result = null
446
+ },
447
+ stencilRef: stencil?.ref ?? 0,
376
448
  }
377
- this.pipelines.set(key, result)
378
- return result
379
449
  }
380
450
 
381
451
  private lookup(name: string, sources: ValueSource[]): NativeValue | undefined {
@@ -555,14 +625,17 @@ export class NativeHost {
555
625
  if (!samplers) return this.sampler
556
626
  const bare = name.replace(/^sampler_?/, "")
557
627
  const tex = info.bindings[bare] ? bare : info.bindings[`_${bare}`] ? `_${bare}` : bare
558
- return samplers[tex] ?? this.sampler
628
+ return samplers[tex] ?? samplers[tex.replace(/_+$/, "")] ?? this.sampler
559
629
  }
560
630
 
561
631
  private textureView(name: string, type: string, textures: Record<string, GPUTextureView | undefined>): GPUTextureView {
562
- const v = textures[name]
632
+ // naga renames a binding that ends in a digit with a trailing "_", as it
633
+ // does a member (lookupFrom): _AlbedoTex_1 is declared _AlbedoTex_1_
634
+ const v = textures[name] ?? (name.endsWith("_") ? textures[name.replace(/_+$/, "")] : undefined)
563
635
  if (v) return v
564
636
  if (/texture_depth/.test(type)) return this.fallback.depth
565
637
  if (/texture_cube/.test(type)) return this.fallback.blackCube
638
+ if (/texture_3d/.test(type)) return this.fallback.emptyVolume
566
639
  return this.fallback.white
567
640
  }
568
641