reze-engine 0.64.0 → 0.65.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 (56) hide show
  1. package/dist/engine.d.ts +146 -54
  2. package/dist/engine.d.ts.map +1 -1
  3. package/dist/engine.js +734 -582
  4. package/dist/graph/slots.d.ts.map +1 -1
  5. package/dist/graph/slots.js +7 -1
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/model.d.ts +10 -1
  9. package/dist/model.d.ts.map +1 -1
  10. package/dist/model.js +15 -1
  11. package/dist/outline-normals.d.ts +12 -0
  12. package/dist/outline-normals.d.ts.map +1 -0
  13. package/dist/outline-normals.js +100 -0
  14. package/dist/pmx-loader.d.ts +3 -0
  15. package/dist/pmx-loader.d.ts.map +1 -1
  16. package/dist/pmx-loader.js +7 -2
  17. package/dist/shaders/materials/common.d.ts.map +1 -1
  18. package/dist/shaders/materials/common.js +4 -2
  19. package/dist/shaders/passes/bloom.d.ts +4 -3
  20. package/dist/shaders/passes/bloom.d.ts.map +1 -1
  21. package/dist/shaders/passes/bloom.js +58 -87
  22. package/dist/shaders/passes/composite.d.ts.map +1 -1
  23. package/dist/shaders/passes/composite.js +118 -72
  24. package/dist/shaders/passes/mirror.d.ts +18 -12
  25. package/dist/shaders/passes/mirror.d.ts.map +1 -1
  26. package/dist/shaders/passes/mirror.js +59 -13
  27. package/dist/shaders/passes/outline.d.ts.map +1 -1
  28. package/dist/shaders/passes/outline.js +71 -14
  29. package/dist/unity/host.d.ts +15 -0
  30. package/dist/unity/host.d.ts.map +1 -1
  31. package/dist/unity/host.js +103 -63
  32. package/dist/unity/looks.d.ts +5 -0
  33. package/dist/unity/looks.d.ts.map +1 -1
  34. package/dist/unity/looks.js +19 -2
  35. package/dist/unity/skin.d.ts.map +1 -1
  36. package/dist/unity/skin.js +5 -2
  37. package/dist/unity/stage.d.ts +7 -0
  38. package/dist/unity/stage.d.ts.map +1 -1
  39. package/dist/unity/stage.js +37 -2
  40. package/package.json +1 -1
  41. package/src/engine.ts +1042 -798
  42. package/src/graph/slots.ts +7 -1
  43. package/src/index.ts +1 -0
  44. package/src/model.ts +18 -1
  45. package/src/outline-normals.ts +110 -0
  46. package/src/pmx-loader.ts +8 -2
  47. package/src/shaders/materials/common.ts +4 -2
  48. package/src/shaders/passes/bloom.ts +59 -87
  49. package/src/shaders/passes/composite.ts +118 -72
  50. package/src/shaders/passes/mirror.ts +60 -13
  51. package/src/shaders/passes/outline.ts +71 -14
  52. package/src/unity/host.ts +57 -14
  53. package/src/unity/looks.ts +21 -2
  54. package/src/unity/skin.ts +5 -2
  55. package/src/unity/stage.ts +38 -2
  56. package/src/shaders/agx-lut.ts +0 -30
@@ -13,7 +13,7 @@ import { castDistanceApi, CAST_FIELD_DIV } from "./cast-distance"
13
13
  import { lyricsApi, lyricsTextApi } from "../lyrics-api"
14
14
  import { midiApi } from "../midi-api"
15
15
  import { gridReadApi } from "./grid"
16
- // Composite: HDR scene + bloom pyramid → Filmic tone map → gamma → swapchain.
16
+ // Composite: HDR scene + bloom → view transform (soft / neutral / aces / none) → gamma → swapchain.
17
17
  // Bloom tint/intensity applied at combine (EEVEE treats them as combine-stage params, not prefilter).
18
18
  //
19
19
  // The shader is a TEMPLATE: buildCompositeShader() emits either the base pass or
@@ -138,14 +138,6 @@ override APPLY_GAMMA: bool = true;
138
138
  // re-premultiply the tonemapped color for output so the premultiplied canvas
139
139
  // alphaMode composites the WebGPU surface over the page background correctly.
140
140
  @group(0) @binding(4) var maskTex: texture_2d<f32>;
141
- // Filmic tone curve baked to a WIDTH×1 r16float LUT (bakeFilmicLut on the CPU side).
142
- // Domain: log2(linear) mapped to [0,13]. Replaces the old array<f32,14> that was indexed
143
- // by a runtime u32 (a Metal-backend smell — dynamic local-array indexing lowers to a
144
- // per-invocation copy/switch) and interpolated piecewise-linearly (C0, so segment slope
145
- // discontinuities showed as Mach bands in smooth skin/shadow gradients). The LUT is a
146
- // monotone-cubic (Fritsch–Carlson) fit through the same 14 anchors — same values, C1
147
- // continuity kills the banding — sampled with hardware linear filtering.
148
- @group(0) @binding(5) var filmicLut: texture_2d<f32>;
149
141
  // viewU[0] = (exposure, invGamma, grain amount, grain seed); viewU[1] = (tint.rgb, intensity)
150
142
  // viewU[2] = (background.rgb, mode) — display-space sRGB, composited UNDER the
151
143
  // scene post-tonemap. BASE-layer mode: 0 transparent (DOM shows),
@@ -177,9 +169,6 @@ override APPLY_GAMMA: bool = true;
177
169
  // track the camera radius. Cleared depth (1.0) inverts to the far
178
170
  // plane, so empty sky reads as maximally defocused background.
179
171
  @group(0) @binding(9) var<uniform> dofU: array<vec4<f32>, 3>;
180
- // Blender's AgX, as the 57³ lookup it ships as rather than a reconstruction of
181
- // it. Sampled in the log-encoded E-Gamut space the cube expects — see agxTransform.
182
- @group(0) @binding(10) var agxLut: texture_3d<f32>;
183
172
  // The scene's own colour grade, as a cube — see _rzStageGrade. sRGB-encoded
184
173
  // storage, so what a sample returns is already linear.
185
174
  @group(0) @binding(12) var stageGradeLut: texture_3d<f32>;
@@ -215,8 +204,6 @@ override APPLY_GAMMA: bool = true;
215
204
  fn rzFieldMerge(top: vec4f, bot: vec4f) -> vec4f {
216
205
  return vec4f(top.rgb + bot.rgb * (1.0 - top.a), top.a + bot.a * (1.0 - top.a));
217
206
  }
218
- // Must match FILMIC_LUT_WIDTH in engine.ts (bakeFilmicLut).
219
- const FILMIC_LUT_W: f32 = 256.0;
220
207
 
221
208
  fn linearDepth(coord: vec2<i32>) -> f32 {
222
209
  let z = textureLoad(depthTex, coord, 0);
@@ -249,79 +236,138 @@ fn sceneSample(coord: vec2<i32>, fullSzI: vec2<i32>, fullSz: vec2f) -> vec4f {
249
236
  return vec4f((sHdr + sBloom) * sAlpha, sAlpha);
250
237
  }
251
238
 
252
- fn filmic(x: f32) -> f32 {
253
- // Reference checkpoints (Blender 3.6 Filmic MHC, sobotka/filmic-blender
254
- // look_medium-high-contrast.spi1d): linear 0.18 → ~0.395, linear 1.0 → ~0.83.
255
- // NOTE: version-pinned to Blender 3.6 — 4.x defaults to AgX, not Filmic.
256
- let t = clamp(log2(max(x, 1e-10)) + 10.0, 0.0, 13.0);
257
- // Map t∈[0,13] to the texel-center of baked sample j = t·(W-1)/13.
258
- let u = (t * (FILMIC_LUT_W - 1.0) / 13.0 + 0.5) / FILMIC_LUT_W;
259
- // textureSampleLevel (explicit LOD, no derivatives) is legal in non-uniform flow.
260
- return textureSampleLevel(filmicLut, bloomSamp, vec2f(u, 0.5), 0.0).r;
261
- }
262
-
263
- /** The sRGB display encoding — Blender's "Standard" view transform, which is what
264
- * NPR work uses: no film curve at all, so the colours a graph computes are the
265
- * colours that land. Not a 2.2 power law; sRGB has a linear toe. */
239
+ /** The sRGB display encoding — "none": no tone curve at all, so the colours a
240
+ * graph computes are the colours that land. Not a 2.2 power law; sRGB has a
241
+ * linear toe. Every transform below ends in it. */
266
242
  fn srgb_encode(x: f32) -> f32 {
267
243
  let c = max(x, 0.0);
268
244
  return select(1.055 * pow(c, 1.0 / 2.4) - 0.055, c * 12.92, c <= 0.0031308);
269
245
  }
270
246
 
271
247
  /**
272
- * AgX, the chain config.ocio states for "AgX Base Rec.1886" on an sRGB display:
273
- *
274
- * scene-linear Rec.709 → 3x3 into FilmLight E-Gamut
275
- * → log2 across 25 stops, [-12.47393, +12.5261] → 0..1
276
- * → the 57³ cube
277
- * → Rec.1886 (pure γ2.4) decoded, then re-encoded as sRGB for the swapchain
278
- *
279
- * The last step is not pedantry: Rec.1886 is a pure power law and sRGB has a
280
- * linear toe, so skipping it lifts the darkest few code values.
281
- *
282
- * Blender interpolates the cube tetrahedrally and a GPU sampler is trilinear.
283
- * The difference is small and confined to saturated gradients; if it ever shows,
284
- * tetrahedral is a dozen lines here.
248
+ * "soft" — Aether Gazer's own curve, from its final pass
249
+ * (Hidden/SimPipeline/Final, decompiled): (1 - e^(-exposure x))^contrast, at the
250
+ * 2.5 and 1.4 its scenes set, into a linear target the swapchain then encodes.
251
+ * The game's ACES branch is off in every scene measured.
285
252
  */
286
- fn agxTransform(c: vec3f) -> vec3f {
287
- let m = mat3x3<f32>(
288
- vec3f(0.55937113, 0.07622071, 0.06552670),
289
- vec3f(0.30478326, 0.78797183, 0.16454675),
290
- vec3f(0.13584556, 0.13580748, 0.76992653),
291
- );
292
- let e = m * max(c, vec3f(0.0));
293
- // The cube's domain: an unlit pixel would take log2 to -inf, so floor it at
294
- // the bottom stop rather than sampling outside the LUT.
295
- let t = clamp((log2(max(e, vec3f(1e-10))) + vec3f(12.47393)) / 25.0, vec3f(0.0), vec3f(1.0));
296
- // Half-texel inset, so the end stops are the cube's own values and not a blend
297
- // with the clamp-to-edge border.
298
- let n = 57.0;
299
- let uvw = t * ((n - 1.0) / n) + (0.5 / n);
300
- let formed = textureSampleLevel(agxLut, bloomSamp, uvw, 0.0).rgb;
301
- let linear = pow(max(formed, vec3f(0.0)), vec3f(2.4));
302
- return vec3f(srgb_encode(linear.r), srgb_encode(linear.g), srgb_encode(linear.b));
253
+ fn softTransform(c: vec3f) -> vec3f {
254
+ let t = pow(max(vec3f(1.0) - exp(-2.5 * max(c, vec3f(0.0))), vec3f(0.0)), vec3f(1.4));
255
+ return vec3f(srgb_encode(t.r), srgb_encode(t.g), srgb_encode(t.b));
256
+ }
257
+
258
+ fn _rzSrgbEncode3(c: vec3f) -> vec3f {
259
+ return vec3f(srgb_encode(c.r), srgb_encode(c.g), srgb_encode(c.b));
303
260
  }
304
261
 
305
262
  /**
306
- * Aether Gazer's own curve, from its final pass (Hidden/SimPipeline/Final,
307
- * decompiled): (1 - e^(-exposure x))^contrast, at the 2.5 and 1.4 its scenes
308
- * set, into a linear target the swapchain then encodes. The game's ACES branch
309
- * is off in every scene measured.
263
+ * "neutral" — Unity URP's Neutral tonemapper as the Khronos PBR Neutral
264
+ * operator, to the published formula: below the start of compression colour
265
+ * passes through (less a small toe offset); above it the peak rolls off toward
266
+ * 1 and desaturates in proportion to how much it lost.
310
267
  */
311
- fn agTransform(c: vec3f) -> vec3f {
312
- let t = pow(max(vec3f(1.0) - exp(-2.5 * max(c, vec3f(0.0))), vec3f(0.0)), vec3f(1.4));
313
- return vec3f(srgb_encode(t.r), srgb_encode(t.g), srgb_encode(t.b));
268
+ fn neutralTransform(color: vec3f) -> vec3f {
269
+ let startCompression = 0.8 - 0.04;
270
+ let desaturation = 0.15;
271
+ var c = max(color, vec3f(0.0));
272
+ let x = min(c.r, min(c.g, c.b));
273
+ let offset = select(0.04, x - 6.25 * x * x, x < 0.08);
274
+ c = c - vec3f(offset);
275
+ let peak = max(c.r, max(c.g, c.b));
276
+ if (peak >= startCompression) {
277
+ let d = 1.0 - startCompression;
278
+ let newPeak = 1.0 - d * d / (peak + d - startCompression);
279
+ c = c * (newPeak / peak);
280
+ let g = 1.0 - 1.0 / (desaturation * (peak - newPeak) + 1.0);
281
+ c = mix(c, vec3f(newPeak), g);
282
+ }
283
+ return _rzSrgbEncode3(clamp(c, vec3f(0.0), vec3f(1.0)));
284
+ }
285
+
286
+ // "aces" — Unity URP's ACES: unity_to_ACES then AcesTonemap, both from the Core
287
+ // RP's Color.hlsl (the branch URP compiles, not full ACES): the RRT's glow and
288
+ // red modifier and global desaturation, the luminance fit of RRT +
289
+ // ODT.Academy.RGBmonitor_100nits_dim, the dim-surround gamma, the ODT
290
+ // desaturation, then AP1 → XYZ → D60→D65 → Rec.709, saturated as URP's
291
+ // ApplyTonemap does. HLSL's row-major matrices are written as rows here.
292
+ fn _rzRows(r0: vec3f, r1: vec3f, r2: vec3f, v: vec3f) -> vec3f {
293
+ return vec3f(dot(r0, v), dot(r1, v), dot(r2, v));
294
+ }
295
+ fn _rzAp1ToXyz(v: vec3f) -> vec3f {
296
+ return _rzRows(vec3f(0.6624541811, 0.1340042065, 0.1561876870),
297
+ vec3f(0.2722287168, 0.6740817658, 0.0536895174),
298
+ vec3f(-0.0055746495, 0.0040607335, 1.0103391003), v);
299
+ }
300
+ fn _rzAp1Luma(v: vec3f) -> f32 {
301
+ return dot(v, vec3f(0.272229, 0.674082, 0.0536895));
302
+ }
303
+ fn acesTransform(color: vec3f) -> vec3f {
304
+ // unity_to_ACES: linear sRGB → AP0.
305
+ var aces = _rzRows(vec3f(0.4397010, 0.3829780, 0.1773350),
306
+ vec3f(0.0897923, 0.8134230, 0.0967616),
307
+ vec3f(0.0175440, 0.1115440, 0.8707040), max(color, vec3f(0.0)));
308
+ // Glow module.
309
+ let mi = min(aces.r, min(aces.g, aces.b));
310
+ let ma = max(aces.r, max(aces.g, aces.b));
311
+ let saturation = (max(ma, 1e-4) - max(mi, 1e-4)) / max(ma, 1e-2);
312
+ let k = max(aces.b * (aces.b - aces.g) + aces.g * (aces.g - aces.r) + aces.r * (aces.r - aces.b), 0.0);
313
+ let ycIn = (aces.b + aces.g + aces.r + 1.75 * sqrt(k)) / 3.0;
314
+ let sx = (saturation - 0.4) / 0.2;
315
+ let st = max(1.0 - abs(sx / 2.0), 0.0);
316
+ let s = (1.0 + select(-1.0, 1.0, sx >= 0.0) * (1.0 - st * st)) / 2.0;
317
+ let glowGain = 0.05 * s;
318
+ let glowMid = 0.08;
319
+ var glow = glowGain * (glowMid / max(ycIn, 1e-6) - 0.5);
320
+ if (ycIn <= 2.0 / 3.0 * glowMid) { glow = glowGain; }
321
+ if (ycIn >= 2.0 * glowMid) { glow = 0.0; }
322
+ aces = aces * (1.0 + glow);
323
+ // Red modifier.
324
+ var hue = 0.0;
325
+ if (!(aces.r == aces.g && aces.g == aces.b)) {
326
+ hue = degrees(atan2(sqrt(3.0) * (aces.g - aces.b), 2.0 * aces.r - aces.g - aces.b));
327
+ }
328
+ if (hue < 0.0) { hue = hue + 360.0; }
329
+ var centered = hue;
330
+ if (centered < -180.0) { centered = centered + 360.0; } else if (centered > 180.0) { centered = centered - 360.0; }
331
+ var hueWeight = smoothstep(0.0, 1.0, 1.0 - abs(2.0 * centered / 135.0));
332
+ hueWeight = hueWeight * hueWeight;
333
+ aces.r = aces.r + hueWeight * saturation * (0.03 - aces.r) * (1.0 - 0.82);
334
+ // AP0 → AP1 (ACEScg), then the RRT's global desaturation.
335
+ var cg = max(_rzRows(vec3f(1.4514393161, -0.2365107469, -0.2149285693),
336
+ vec3f(-0.0765537734, 1.1762296998, -0.0996759264),
337
+ vec3f(0.0083161484, -0.0060324498, 0.9977163014), aces), vec3f(0.0));
338
+ cg = mix(vec3f(_rzAp1Luma(cg)), cg, vec3f(0.96));
339
+ // The luminance fit of RRT + ODT.Academy.RGBmonitor_100nits_dim.
340
+ let post = (cg * (2.785085 * cg + 0.107772)) / (cg * (2.936045 * cg + 0.887122) + 0.806889);
341
+ // Dark → dim surround: Y^0.9811, in xyY.
342
+ var xyz = _rzAp1ToXyz(post);
343
+ let div = max(xyz.x + xyz.y + xyz.z, 1e-4);
344
+ let xyY = vec3f(xyz.x / div, xyz.y / div, pow(clamp(xyz.y, 0.0, 65504.0), 0.9811));
345
+ let m = xyY.z / max(xyY.y, 1e-4);
346
+ xyz = vec3f(xyY.x * m, xyY.z, (1.0 - xyY.x - xyY.y) * m);
347
+ var lin = _rzRows(vec3f(1.6410233797, -0.3248032942, -0.2364246952),
348
+ vec3f(-0.6636628587, 1.6153315917, 0.0167563477),
349
+ vec3f(0.0117218943, -0.0082844420, 0.9883948585), xyz);
350
+ // The ODT's desaturation, then to the display's primaries.
351
+ lin = mix(vec3f(_rzAp1Luma(lin)), lin, vec3f(0.93));
352
+ xyz = _rzRows(vec3f(0.98722400, -0.00611327, 0.0159533),
353
+ vec3f(-0.00759836, 1.00186000, 0.0053302),
354
+ vec3f(0.00307257, -0.00509595, 1.0816800), _rzAp1ToXyz(lin));
355
+ let rec709 = _rzRows(vec3f(3.2409699419, -1.5373831776, -0.4986107603),
356
+ vec3f(-0.9692436363, 1.8759675015, 0.0415550574),
357
+ vec3f(0.0556300797, -0.2039769589, 1.0569715142), xyz);
358
+ return _rzSrgbEncode3(clamp(rec709, vec3f(0.0), vec3f(1.0)));
314
359
  }
315
360
 
316
- /** Which display transform, chosen per frame at viewU[6].y (0 filmic, 1 standard, 2 agx, 3 aether-gazer).
317
- * A uniform branch rather than a pipeline variant: switching is rare, and both
318
- * arms are cheap enough that specialising the pipeline would buy nothing. */
361
+ /** Which display transform, chosen per frame at viewU[6].y (0 soft, 1 none,
362
+ * 2 neutral, 3 aces). A uniform branch rather than a pipeline variant: switching
363
+ * is rare, and every arm is cheap enough that specialising the pipeline would
364
+ * buy nothing. */
319
365
  fn viewTransform(c: vec3f) -> vec3f {
320
366
  let mode = viewU[6].y;
321
- if (mode > 2.5) { return agTransform(c); }
322
- if (mode > 1.5) { return agxTransform(c); }
323
- if (mode > 0.5) { return vec3f(srgb_encode(c.r), srgb_encode(c.g), srgb_encode(c.b)); }
324
- return vec3f(filmic(c.r), filmic(c.g), filmic(c.b));
367
+ if (mode > 2.5) { return acesTransform(c); }
368
+ if (mode > 1.5) { return neutralTransform(c); }
369
+ if (mode > 0.5) { return _rzSrgbEncode3(c); }
370
+ return softTransform(c);
325
371
  }
326
372
 
327
373
  fn _rzSrgbDecode(x: f32) -> f32 {
@@ -218,20 +218,52 @@ ${sceneIdPadWgsl("out")}
218
218
  }
219
219
 
220
220
  /**
221
- * The mirror in the SHADOW pass — the frame throwing shade on the floor.
222
- *
223
- * Its own shader because the mirror has no vertex buffer and no skeleton: the
224
- * quad is six generated vertices and the model matrix is the whole of it, while
225
- * the ordinary shadow shader is built for skinned geometry with an alpha test.
226
- *
227
- * Depth only, no fragment stage. The pane is opaque across its whole rectangle
228
- * — glass included, because this glass is not see-through — so there is nothing
229
- * to alpha-test and nothing to colour.
230
- *
231
- * The cascade index arrives in its own tiny uniform rather than as a dynamic
232
- * offset: the cascade matrices sit 64 bytes apart in one array, and a dynamic
233
- * uniform offset has to be 256-aligned.
221
+ * Downsample for the reflection's COLOUR chain, one pass per mip level: the
222
+ * Jimenez/COD 13-tap dual box — five weighted 2x2 averages, which rejects the
223
+ * nyquist ringing a plain box would leave in a blurred reflection.
234
224
  */
225
+ export const MIRROR_DOWNSAMPLE_WGSL = /* wgsl */ `
226
+ @vertex fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
227
+ let x = f32((vi & 1u) << 2u) - 1.0;
228
+ let y = f32((vi & 2u) << 1u) - 1.0;
229
+ return vec4f(x, y, 0.0, 1.0);
230
+ }
231
+
232
+ @group(0) @binding(0) var srcTex: texture_2d<f32>;
233
+ @group(0) @binding(1) var srcSamp: sampler;
234
+
235
+ fn samp(uv: vec2f, off: vec2f) -> vec3f {
236
+ return textureSampleLevel(srcTex, srcSamp, uv + off, 0.0).rgb;
237
+ }
238
+
239
+ @fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
240
+ let srcDims = vec2f(textureDimensions(srcTex));
241
+ let t = 1.0 / srcDims;
242
+ // fragCoord.xy reports pixel centers (e.g. 0.5,0.5 for first pixel) — divide by dst dims directly.
243
+ let dstDims = srcDims * 0.5;
244
+ let uv = p.xy / max(dstDims, vec2f(1.0));
245
+ let A = samp(uv, t * vec2f(-2.0, -2.0));
246
+ let B = samp(uv, t * vec2f( 0.0, -2.0));
247
+ let C = samp(uv, t * vec2f( 2.0, -2.0));
248
+ let D = samp(uv, t * vec2f(-1.0, -1.0));
249
+ let E = samp(uv, t * vec2f( 1.0, -1.0));
250
+ let F = samp(uv, t * vec2f(-2.0, 0.0));
251
+ let G = samp(uv, t * vec2f( 0.0, 0.0));
252
+ let H = samp(uv, t * vec2f( 2.0, 0.0));
253
+ let I = samp(uv, t * vec2f(-1.0, 1.0));
254
+ let J = samp(uv, t * vec2f( 1.0, 1.0));
255
+ let K = samp(uv, t * vec2f(-2.0, 2.0));
256
+ let L = samp(uv, t * vec2f( 0.0, 2.0));
257
+ let M = samp(uv, t * vec2f( 2.0, 2.0));
258
+ var o = (D + E + I + J) * (0.5 / 4.0);
259
+ o = o + (A + B + G + F) * (0.125 / 4.0);
260
+ o = o + (B + C + H + G) * (0.125 / 4.0);
261
+ o = o + (F + G + L + K) * (0.125 / 4.0);
262
+ o = o + (G + H + M + L) * (0.125 / 4.0);
263
+ return vec4f(o, 1.0);
264
+ }
265
+ `
266
+
235
267
  /**
236
268
  * Downsample for the reflection's COVERAGE chain.
237
269
  *
@@ -263,6 +295,21 @@ struct VSOut { @builtin(position) pos: vec4f, @location(0) uv: vec2f, };
263
295
  }
264
296
  `
265
297
 
298
+ /**
299
+ * The mirror in the SHADOW pass — the frame throwing shade on the floor.
300
+ *
301
+ * Its own shader because the mirror has no vertex buffer and no skeleton: the
302
+ * quad is six generated vertices and the model matrix is the whole of it, while
303
+ * the ordinary shadow shader is built for skinned geometry with an alpha test.
304
+ *
305
+ * Depth only, no fragment stage. The pane is opaque across its whole rectangle
306
+ * — glass included, because this glass is not see-through — so there is nothing
307
+ * to alpha-test and nothing to colour.
308
+ *
309
+ * The cascade index arrives in its own tiny uniform rather than as a dynamic
310
+ * offset: the cascade matrices sit 64 bytes apart in one array, and a dynamic
311
+ * uniform offset has to be 256-aligned.
312
+ */
266
313
  export function mirrorShadowWgsl(cascades: number): string {
267
314
  return /* wgsl */ `
268
315
  struct LightVP { viewProj: array<mat4x4f, ${cascades}>, };
@@ -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
  }
package/src/unity/host.ts CHANGED
@@ -173,6 +173,8 @@ export type NativeDraw = {
173
173
  export class NativeHost {
174
174
  private prepared = new Map<string, Prepared>()
175
175
  private pipelines = new Map<string, { pipeline: GPURenderPipeline; stencilRef: number } | null>()
176
+ /** Pipelines being compiled (see warm). */
177
+ private pending = new Map<string, Promise<void>>()
176
178
  private draws = new Map<string, DrawCache>()
177
179
  private shared = new Map<string, Block>()
178
180
  private frame = 0
@@ -281,9 +283,58 @@ export class NativeHost {
281
283
  : []
282
284
  }
283
285
 
286
+ private pipelineKey(p: Prepared, state: PassState, streams: NativeStream[]): string {
287
+ return `${p.shader.name}|${JSON.stringify(state)}|${streams.map((s) => `${s.format}/${s.stride}`).join(",")}`
288
+ }
289
+
290
+ /**
291
+ * Compile a draw's pipeline ahead of the draw — what a stage or a look calls
292
+ * for every pass it will draw, the moment it knows the shader, the state and
293
+ * the streams. Async: a synchronous build holds the GPU process for the
294
+ * whole compile, which was a one-to-two-second frozen frame at the first
295
+ * draw of each new shader. Resolves when the pipeline is ready or has
296
+ * failed (into `errors`); never rejects.
297
+ */
298
+ warm(shader: string, state: PassState, streams: NativeStream[]): Promise<void> {
299
+ const p = this.prepared.get(shader)
300
+ if (!p) return Promise.resolve()
301
+ const key = this.pipelineKey(p, state, streams)
302
+ if (this.pipelines.has(key)) return Promise.resolve()
303
+ let job = this.pending.get(key)
304
+ if (job) return job
305
+ let built: { desc: GPURenderPipelineDescriptor; stencilRef: number }
306
+ try {
307
+ built = this.describe(p, state, streams)
308
+ } catch (e) {
309
+ this.errors.push(`pipeline ${p.shader.name}: ${(e as Error).message}`)
310
+ this.pipelines.set(key, null)
311
+ return Promise.resolve()
312
+ }
313
+ job = this.device.createRenderPipelineAsync(built.desc).then(
314
+ (pipeline) => {
315
+ this.pipelines.set(key, { pipeline, stencilRef: built.stencilRef })
316
+ this.pending.delete(key)
317
+ },
318
+ (e) => {
319
+ this.errors.push(`pipeline ${p.shader.name}: ${(e as Error).message}`)
320
+ this.pipelines.set(key, null)
321
+ this.pending.delete(key)
322
+ },
323
+ )
324
+ this.pending.set(key, job)
325
+ return job
326
+ }
327
+
328
+ /** The draw's pipeline if it is ready. Never built here: one that is not
329
+ * ready is started and the draw sits this frame out. */
284
330
  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)!
331
+ const ready = this.pipelines.get(this.pipelineKey(p, state, streams))
332
+ if (ready !== undefined) return ready
333
+ void this.warm(p.shader.name, state, streams)
334
+ return null
335
+ }
336
+
337
+ private describe(p: Prepared, state: PassState, streams: NativeStream[]): { desc: GPURenderPipelineDescriptor; stencilRef: number } {
287
338
  const t = this.targets
288
339
  const stencil = stencilState(state)
289
340
  const blend = blendState(state)
@@ -317,10 +368,8 @@ export class NativeHost {
317
368
  ]
318
369
  if (t.colorFormats[2]) targets.push({ format: t.colorFormats[2], writeMask: 0 })
319
370
  const offset = state.Offset
320
- let result: { pipeline: GPURenderPipeline; stencilRef: number } | null
321
- try {
322
- result = {
323
- pipeline: this.device.createRenderPipeline({
371
+ return {
372
+ desc: {
324
373
  label: `${p.shader.name} pipeline`,
325
374
  layout: p.layout,
326
375
  vertex: {
@@ -367,15 +416,9 @@ export class NativeHost {
367
416
  : {}),
368
417
  },
369
418
  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
419
+ },
420
+ stencilRef: stencil?.ref ?? 0,
376
421
  }
377
- this.pipelines.set(key, result)
378
- return result
379
422
  }
380
423
 
381
424
  private lookup(name: string, sources: ValueSource[]): NativeValue | undefined {
@@ -97,6 +97,8 @@ const OVERRIDE_MODES = ["OVERRIDE1", "OVERRIDE2", "OVERRIDE3"]
97
97
  export class NativeLooks {
98
98
  readonly host: NativeHost
99
99
  private installs = new Map<string, Install>()
100
+ /** Per model: its passes' pipelines compiling (see install). */
101
+ private warming = new Map<string, Promise<void>>()
100
102
  private skinPipeline: GPUComputePipeline
101
103
  private zero: GPUBuffer
102
104
  private white: GPUBuffer
@@ -143,6 +145,12 @@ export class NativeLooks {
143
145
  return this.installs.has(model)
144
146
  }
145
147
 
148
+ /** Resolves once every pass of the model's look has its pipeline — until
149
+ * then those draws sit out (NativeHost never compiles at a draw). */
150
+ ready(model: string): Promise<void> {
151
+ return this.warming.get(model) ?? Promise.resolve()
152
+ }
153
+
146
154
  /** Is this model's material drawn by its native look? */
147
155
  dresses(model: string, material: string): boolean {
148
156
  const i = this.installs.get(model)
@@ -205,7 +213,7 @@ export class NativeLooks {
205
213
  const spec = look.materials.find((m) => m.materials.includes(draw.materialName))
206
214
  if (spec) dressed.push({ draw, spec })
207
215
  }
208
- this.installs.set(model.name, {
216
+ const install: Install = {
209
217
  model,
210
218
  look,
211
219
  views,
@@ -218,7 +226,18 @@ export class NativeLooks {
218
226
  skinBind,
219
227
  dressed,
220
228
  block: {},
221
- })
229
+ }
230
+ this.installs.set(model.name, install)
231
+ // Every pass this look will draw, compiled now and side by side — the
232
+ // shader, its state and the streams are all known here.
233
+ const drawn = [...OPAQUE_MODES, ...CHARACTER_MODES, ...OVERRIDE_MODES]
234
+ const jobs: Promise<void>[] = []
235
+ for (const d of dressed)
236
+ for (const p of d.spec.passes) if (drawn.includes(p.lightMode)) jobs.push(this.host.warm(p.shader, p.state, this.streams(install, p.shader)))
237
+ this.warming.set(
238
+ model.name,
239
+ Promise.all(jobs).then(() => {}),
240
+ )
222
241
  }
223
242
 
224
243
  remove(name: string): void {
package/src/unity/skin.ts CHANGED
@@ -78,10 +78,13 @@ export function tangentsFor(verts: Float32Array, indices: Uint32Array | Uint16Ar
78
78
  const p = (v: number, k: number) => verts[v * 8 + k]
79
79
  const e1 = [p(b, 0) - p(a, 0), p(b, 1) - p(a, 1), p(b, 2) - p(a, 2)]
80
80
  const e2 = [p(c, 0) - p(a, 0), p(c, 1) - p(a, 1), p(c, 2) - p(a, 2)]
81
+ // v measured upward, as Unity builds tangent space: a PMX uv runs v down,
82
+ // and taken as it is the bitangent — the handedness in w — comes out
83
+ // reversed, which a game normal map reads as its green channel inverted.
81
84
  const du1 = p(b, 6) - p(a, 6),
82
- dv1 = p(b, 7) - p(a, 7)
85
+ dv1 = p(a, 7) - p(b, 7)
83
86
  const du2 = p(c, 6) - p(a, 6),
84
- dv2 = p(c, 7) - p(a, 7)
87
+ dv2 = p(a, 7) - p(c, 7)
85
88
  const det = du1 * dv2 - du2 * dv1
86
89
  if (Math.abs(det) < 1e-12) continue
87
90
  const r = 1 / det