reze-engine 0.60.4 → 0.61.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 (95) hide show
  1. package/dist/_dbg-loader.mjs +1100 -0
  2. package/dist/engine.d.ts +121 -5
  3. package/dist/engine.d.ts.map +1 -1
  4. package/dist/engine.js +284 -19
  5. package/dist/graph/compile.d.ts.map +1 -1
  6. package/dist/graph/compile.js +4 -1
  7. package/dist/graph/registry.d.ts.map +1 -1
  8. package/dist/graph/registry.js +14 -2
  9. package/dist/graph/slots.d.ts +1 -1
  10. package/dist/graph/slots.d.ts.map +1 -1
  11. package/dist/graph/slots.js +19 -6
  12. package/dist/index.d.ts +1 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/physics-debug.d.ts +30 -0
  15. package/dist/physics-debug.d.ts.map +1 -0
  16. package/dist/physics-debug.js +526 -0
  17. package/dist/shaders/lights.d.ts.map +1 -1
  18. package/dist/shaders/lights.js +19 -0
  19. package/dist/shaders/materials/body.d.ts +2 -0
  20. package/dist/shaders/materials/body.d.ts.map +1 -0
  21. package/dist/shaders/materials/body.js +95 -0
  22. package/dist/shaders/materials/cloth_rough.d.ts +2 -0
  23. package/dist/shaders/materials/cloth_rough.d.ts.map +1 -0
  24. package/dist/shaders/materials/cloth_rough.js +69 -0
  25. package/dist/shaders/materials/cloth_smooth.d.ts +2 -0
  26. package/dist/shaders/materials/cloth_smooth.d.ts.map +1 -0
  27. package/dist/shaders/materials/cloth_smooth.js +61 -0
  28. package/dist/shaders/materials/common.d.ts.map +1 -1
  29. package/dist/shaders/materials/common.js +47 -0
  30. package/dist/shaders/materials/default.d.ts +2 -0
  31. package/dist/shaders/materials/default.d.ts.map +1 -0
  32. package/dist/shaders/materials/default.js +43 -0
  33. package/dist/shaders/materials/eye.d.ts +2 -0
  34. package/dist/shaders/materials/eye.d.ts.map +1 -0
  35. package/dist/shaders/materials/eye.js +60 -0
  36. package/dist/shaders/materials/face.d.ts +2 -0
  37. package/dist/shaders/materials/face.d.ts.map +1 -0
  38. package/dist/shaders/materials/face.js +95 -0
  39. package/dist/shaders/materials/hair.d.ts +2 -0
  40. package/dist/shaders/materials/hair.d.ts.map +1 -0
  41. package/dist/shaders/materials/hair.js +90 -0
  42. package/dist/shaders/materials/metal.d.ts +2 -0
  43. package/dist/shaders/materials/metal.d.ts.map +1 -0
  44. package/dist/shaders/materials/metal.js +77 -0
  45. package/dist/shaders/materials/mmd_classic.d.ts +2 -0
  46. package/dist/shaders/materials/mmd_classic.d.ts.map +1 -0
  47. package/dist/shaders/materials/mmd_classic.js +66 -0
  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 +110 -23
  51. package/dist/shaders/materials/stockings.d.ts +2 -0
  52. package/dist/shaders/materials/stockings.d.ts.map +1 -0
  53. package/dist/shaders/materials/stockings.js +122 -0
  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 +34 -2
  57. package/dist/shaders/passes/physics-debug.d.ts +2 -0
  58. package/dist/shaders/passes/physics-debug.d.ts.map +1 -0
  59. package/dist/shaders/passes/physics-debug.js +69 -0
  60. package/dist/shaders/scene-tap.js +1 -1
  61. package/package.json +1 -1
  62. package/src/engine.ts +312 -19
  63. package/src/graph/compile.ts +4 -1
  64. package/src/graph/registry.ts +14 -2
  65. package/src/graph/slots.ts +20 -5
  66. package/src/index.ts +4 -0
  67. package/src/shaders/lights.ts +19 -0
  68. package/src/shaders/materials/common.ts +47 -0
  69. package/src/shaders/materials/nodes.ts +110 -23
  70. package/src/shaders/passes/composite.ts +34 -2
  71. package/src/shaders/scene-tap.ts +1 -1
  72. package/dist/graph/presets/pool_floor.d.ts +0 -3
  73. package/dist/graph/presets/pool_floor.d.ts.map +0 -1
  74. package/dist/graph/presets/pool_floor.js +0 -42
  75. package/dist/graph/presets/water.d.ts +0 -3
  76. package/dist/graph/presets/water.d.ts.map +0 -1
  77. package/dist/graph/presets/water.js +0 -31
  78. package/dist/physics/autofit.d.ts +0 -147
  79. package/dist/physics/autofit.d.ts.map +0 -1
  80. package/dist/physics/autofit.js +0 -501
  81. package/dist/shaders/passes/field-blit.d.ts +0 -26
  82. package/dist/shaders/passes/field-blit.d.ts.map +0 -1
  83. package/dist/shaders/passes/field-blit.js +0 -65
  84. package/dist/shaders/passes/ground-noise.d.ts +0 -7
  85. package/dist/shaders/passes/ground-noise.d.ts.map +0 -1
  86. package/dist/shaders/passes/ground-noise.js +0 -88
  87. package/dist/shaders/passes/sim.d.ts +0 -34
  88. package/dist/shaders/passes/sim.d.ts.map +0 -1
  89. package/dist/shaders/passes/sim.js +0 -169
  90. package/dist/shaders/score-api.d.ts +0 -10
  91. package/dist/shaders/score-api.d.ts.map +0 -1
  92. package/dist/shaders/score-api.js +0 -114
  93. package/dist/shaders/shadow-api.d.ts +0 -29
  94. package/dist/shaders/shadow-api.d.ts.map +0 -1
  95. package/dist/shaders/shadow-api.js +0 -112
package/dist/engine.js CHANGED
@@ -731,7 +731,13 @@ export class Engine {
731
731
  this.cameraMatrixData = new Float32Array(40);
732
732
  // ambient vec4 (4) + 4 lights x 2 vec4 (32) + 9 irradiance-SH vec4s (36),
733
733
  // padded to 80. sh[0].w is the IBL flag: 0 = flat world colour, 1 = the sky.
734
- this.lightData = new Float32Array(80);
734
+ // …then the scene fog at [72..87] — see setSceneFog — and the cast's
735
+ // shadow on a stage at [88..107] (colour, amount; its view-projection).
736
+ this.lightData = new Float32Array(112);
737
+ this.castShadow = null;
738
+ this.castShadowBundle = null;
739
+ this.castShadowCleared = false;
740
+ this.castSphereScratch = new Float32Array(4);
735
741
  this.lightCount = 0;
736
742
  this.resizeObserver = null;
737
743
  this.resizePending = false;
@@ -1029,6 +1035,7 @@ export class Engine {
1029
1035
  this.cullReadbackInFlight = false;
1030
1036
  this.depthOfField = { ...DEFAULT_DEPTH_OF_FIELD_OPTIONS };
1031
1037
  this.dofUniformData = new Float32Array(12);
1038
+ this.sceneFog = null;
1032
1039
  this.dofFocusScratch = new Vec3(0, 0, 0);
1033
1040
  /** Depth-only view of the scene's MSAA depth buffer, read by the DoF gather. */
1034
1041
  this.depthReadView = null;
@@ -1061,6 +1068,9 @@ export class Engine {
1061
1068
  this.worldGradientSH = null;
1062
1069
  /** The installed HDRI's folded irradiance SH (27 floats), or null. */
1063
1070
  this.worldSH = null;
1071
+ /** A diffuse ambient stated outright (setWorldAmbient), outranking the one
1072
+ * fitted to the picture; the picture still answers reflections. */
1073
+ this.worldAmbientSH = null;
1064
1074
  // The scene's user WGSL effect (setEffect). ONE per scene, mounted under the
1065
1075
  // scene, over it, or both — whichever of background()/foreground() the code
1066
1076
  // defines. The composite pipelines are REBUILT with the user code injected;
@@ -1121,6 +1131,10 @@ export class Engine {
1121
1131
  * character samples in lockstep and their paths stay comparable. */
1122
1132
  this.trailDue = 0;
1123
1133
  this.agxLutTexture = null;
1134
+ /** The scene's own grade (setStageGrade): the cube as handed over, kept so a
1135
+ * call before init() still lands, and its texture once uploaded. */
1136
+ this.stageGradeCube = null;
1137
+ this.stageGradeTexture = null;
1124
1138
  /** time=0 origin for the active effect — reset each setEffect. */
1125
1139
  /** Scene-clock reading when the current effect was installed. The effect's
1126
1140
  * `time` is measured from here — see where it is written. */
@@ -1650,6 +1664,57 @@ export class Engine {
1650
1664
  if (this.device && this.compositeUniformBuffer)
1651
1665
  this.writeCompositeViewUniforms();
1652
1666
  }
1667
+ /**
1668
+ * The grade a scene arrived with, as a cube — a game's own colour grading,
1669
+ * baked by the converter that brought its stage. Applied to the formed frame
1670
+ * before setColorGrading's, which stays the author's to lay on top. Looked up
1671
+ * as the game looks it up: the view transform's output, linear, trilinear
1672
+ * between texel centres. Null takes it off.
1673
+ *
1674
+ * Bytes are the cube as that game stored it: 8-bit sRGB-encoded, red fastest,
1675
+ * then green, then blue; three bytes a texel or four. A cube whose byte count
1676
+ * does not match its size is refused rather than drawn wrong.
1677
+ */
1678
+ setStageGrade(lut) {
1679
+ if (lut) {
1680
+ const stride = lut.data.byteLength / lut.size ** 3;
1681
+ if (!Number.isInteger(lut.size) || lut.size < 2 || lut.size > 64 || (stride !== 3 && stride !== 4)) {
1682
+ throw new Error(`setStageGrade: ${lut.data.byteLength} bytes is not a ${lut.size}³ cube`);
1683
+ }
1684
+ }
1685
+ this.stageGradeCube = lut;
1686
+ if (this.device && this.stageGradeFallback)
1687
+ this.uploadStageGrade();
1688
+ }
1689
+ uploadStageGrade() {
1690
+ const lut = this.stageGradeCube;
1691
+ this.stageGradeTexture?.destroy();
1692
+ this.stageGradeTexture = null;
1693
+ if (lut) {
1694
+ const n = lut.size;
1695
+ const texels = n ** 3;
1696
+ const stride = lut.data.byteLength / texels;
1697
+ const rgba = new Uint8Array(texels * 4);
1698
+ for (let i = 0; i < texels; i++) {
1699
+ rgba[i * 4] = lut.data[i * stride];
1700
+ rgba[i * 4 + 1] = lut.data[i * stride + 1];
1701
+ rgba[i * 4 + 2] = lut.data[i * stride + 2];
1702
+ rgba[i * 4 + 3] = 255;
1703
+ }
1704
+ const tex = this.device.createTexture({
1705
+ label: `stage grade ${n}³`,
1706
+ size: [n, n, n],
1707
+ dimension: "3d",
1708
+ format: "rgba8unorm-srgb",
1709
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
1710
+ });
1711
+ this.device.queue.writeTexture({ texture: tex }, rgba, { bytesPerRow: n * 4, rowsPerImage: n }, [n, n, n]);
1712
+ this.stageGradeTexture = tex;
1713
+ }
1714
+ this.rebuildCompositeBindGroup();
1715
+ if (this.compositeUniformBuffer)
1716
+ this.writeCompositeViewUniforms();
1717
+ }
1653
1718
  /** Current grade (for serialization into a scene descriptor). */
1654
1719
  getColorGrading() {
1655
1720
  const g = this.colorGrading;
@@ -1786,7 +1851,8 @@ export class Engine {
1786
1851
  u[32] === 1 && u[33] === 1 && u[34] === 1 &&
1787
1852
  u[36] === 1 && u[37] === 1 && u[38] === 1 &&
1788
1853
  g.contrast === 1 && g.saturation === 1;
1789
- u[39] = neutral ? 0 : 1;
1854
+ // Bit 0 the CDL above, bit 1 the scene's own cube — see _rzGradeScene.
1855
+ u[39] = (neutral ? 0 : 1) | (this.stageGradeTexture ? 2 : 0);
1790
1856
  this.device.queue.writeBuffer(this.compositeUniformBuffer, 0, u);
1791
1857
  }
1792
1858
  /**
@@ -1898,8 +1964,9 @@ export class Engine {
1898
1964
  */
1899
1965
  getWorldLighting() {
1900
1966
  const s = this.world.strength;
1901
- if (this.worldSH) {
1902
- const at = (n) => evalIrradianceSH(this.worldSH, n).map((v) => Math.max(v * s, 0));
1967
+ const fitted = this.worldAmbientSH ?? this.worldSH;
1968
+ if (fitted) {
1969
+ const at = (n) => evalIrradianceSH(fitted, n).map((v) => Math.max(v * s, 0));
1903
1970
  return { source: "hdri", strength: s, up: at({ x: 0, y: 1, z: 0 }), down: at({ x: 0, y: -1, z: 0 }) };
1904
1971
  }
1905
1972
  const c = this.world.color;
@@ -2561,6 +2628,7 @@ export class Engine {
2561
2628
  { binding: 8, resource: this.depthReadView },
2562
2629
  { binding: 9, resource: { buffer: this.dofUniformBuffer } },
2563
2630
  { binding: 10, resource: (this.agxLutTexture ?? this.agxFallbackTexture).createView({ dimension: "3d" }) },
2631
+ { binding: 12, resource: (this.stageGradeTexture ?? this.stageGradeFallback).createView({ dimension: "3d" }) },
2564
2632
  { binding: 11, resource: { buffer: this.castBuffer } },
2565
2633
  { binding: 13, resource: { buffer: this.audioBuffer } },
2566
2634
  { binding: 19, resource: { buffer: this.midiBuffer } },
@@ -2985,6 +3053,7 @@ export class Engine {
2985
3053
  { binding: 2, resource: this.bloomSampler },
2986
3054
  { binding: 5, resource: this.filmicLutView },
2987
3055
  { binding: 10, resource: (this.agxLutTexture ?? this.agxFallbackTexture).createView({ dimension: "3d" }) },
3056
+ { binding: 12, resource: (this.stageGradeTexture ?? this.stageGradeFallback).createView({ dimension: "3d" }) },
2988
3057
  { binding: 1, resource: bloom },
2989
3058
  { binding: 4, resource: this.maskResolveView },
2990
3059
  ],
@@ -5849,6 +5918,7 @@ export class Engine {
5849
5918
  { binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
5850
5919
  { binding: 5, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
5851
5920
  { binding: 10, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float", viewDimension: "3d" } },
5921
+ { binding: 12, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float", viewDimension: "3d" } },
5852
5922
  // And the scene's COVERAGE and bloom, without which the tap cannot
5853
5923
  // reconstruct a pixel: the HDR target is premultiplied, so colour alone
5854
5924
  // reads a half-transparent ground as a dark opaque one.
@@ -5999,7 +6069,8 @@ export class Engine {
5999
6069
  label: "main per-frame bind group layout",
6000
6070
  entries: [
6001
6071
  { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
6002
- { binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
6072
+ // The vertex stage reads it too: the scene fog is per vertex (setSceneFog).
6073
+ { binding: 1, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
6003
6074
  { binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
6004
6075
  { binding: 3, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "depth" } },
6005
6076
  { binding: 4, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "comparison" } },
@@ -6013,6 +6084,8 @@ export class Engine {
6013
6084
  { binding: 7, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "depth" } },
6014
6085
  { binding: 8, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
6015
6086
  { binding: 9, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
6087
+ // The cast's shadow on a stage — setStageCastShadow.
6088
+ { binding: 10, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "depth" } },
6016
6089
  ],
6017
6090
  });
6018
6091
  // group 1: per-instance (skinMats) — bound once per model
@@ -6226,6 +6299,22 @@ export class Engine {
6226
6299
  usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
6227
6300
  }));
6228
6301
  this.shadowMapDepthViews = this.shadowMapTextures.map((t) => t.createView());
6302
+ // The cast's shadow on a stage (setStageCastShadow): one map, only the cast
6303
+ // in it, from the stage's own direction. Always allocated so every material
6304
+ // bind group and every instance's shadow bind group stays one shape; drawn
6305
+ // only while a stage asks for it.
6306
+ this.castShadowTexture = this.device.createTexture({
6307
+ label: "stage cast shadow map",
6308
+ size: [1024, 1024],
6309
+ format: Engine.SHADOW_DEPTH_FORMAT,
6310
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
6311
+ });
6312
+ this.castShadowView = this.castShadowTexture.createView();
6313
+ this.castShadowVPBuffer = this.device.createBuffer({
6314
+ label: "stage cast shadow view-projection",
6315
+ size: 64,
6316
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
6317
+ });
6229
6318
  // One-shot bake of Blender EEVEE's combined BRDF LUT (DFG + LTC packed rgba8unorm).
6230
6319
  this.bakeBrdfLut();
6231
6320
  this.bakeGroundNoise();
@@ -6237,6 +6326,14 @@ export class Engine {
6237
6326
  usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
6238
6327
  });
6239
6328
  void this.loadAgxLut();
6329
+ this.stageGradeFallback = this.device.createTexture({
6330
+ label: "stage grade fallback",
6331
+ size: [1, 1, 1],
6332
+ dimension: "3d",
6333
+ format: "rgba8unorm-srgb",
6334
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
6335
+ });
6336
+ this.uploadStageGrade();
6240
6337
  this.bakeFilmicLut();
6241
6338
  // BEFORE the bind group below, which binds it. Full size from the start:
6242
6339
  // every material pipeline binds this, so sizing it to the light count would
@@ -6649,6 +6746,9 @@ export class Engine {
6649
6746
  // AgX's 57³ cube. Decompressed and uploaded off the critical path, so a
6650
6747
  // 1×1×1 stand-in keeps the bind group valid until it arrives.
6651
6748
  { binding: 10, visibility: GPUShaderStage.FRAGMENT, texture: { viewDimension: "3d" } },
6749
+ // The scene's own grade cube (setStageGrade) — 1×1×1 stand-in while it
6750
+ // has none, which the flag bit keeps from ever being sampled.
6751
+ { binding: 12, visibility: GPUShaderStage.FRAGMENT, texture: { viewDimension: "3d" } },
6652
6752
  // The cast, for rzSubject/rzAnchor. Always bound so the base shader's
6653
6753
  // layout matches; the base shader simply never reads it.
6654
6754
  { binding: 11, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "read-only-storage" } },
@@ -8318,7 +8418,7 @@ export class Engine {
8318
8418
  setupLighting() {
8319
8419
  this.lightUniformBuffer = this.device.createBuffer({
8320
8420
  label: "light uniforms",
8321
- size: 80 * 4, // ambient (4) + 4 lights x 2 vec4 (32) + irradiance SH 9 x vec4 (36), padded to 80
8421
+ size: 112 * 4, // ambient (4) + 4 lights x 2 vec4 (32) + irradiance SH 9 x vec4 (36) + fog 4 x vec4 (16) + cast shadow (4 + 16)
8322
8422
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
8323
8423
  });
8324
8424
  this.lightData.fill(0);
@@ -8367,6 +8467,7 @@ export class Engine {
8367
8467
  { binding: 7, resource: this.shadowMapDepthViews[SHADOW_CASCADES.length - 1] },
8368
8468
  { binding: 8, resource: env },
8369
8469
  { binding: 9, resource: this.brdfLutView },
8470
+ { binding: 10, resource: this.castShadowView },
8370
8471
  ],
8371
8472
  });
8372
8473
  this.mirrorPerFrameBindGroup = this.device.createBindGroup({
@@ -8383,6 +8484,7 @@ export class Engine {
8383
8484
  { binding: 7, resource: this.shadowMapDepthViews[SHADOW_CASCADES.length - 1] },
8384
8485
  { binding: 8, resource: env },
8385
8486
  { binding: 9, resource: this.brdfLutView },
8487
+ { binding: 10, resource: this.castShadowView },
8386
8488
  ],
8387
8489
  });
8388
8490
  return true;
@@ -8406,7 +8508,7 @@ export class Engine {
8406
8508
  // replaces the colour, strength applies to either).
8407
8509
  // An HDRI outranks a gradient outranks the flat colour, which is the order
8408
8510
  // of how much each one knows about the sky.
8409
- const sh = this.worldSH ?? this.worldGradientSH;
8511
+ const sh = this.worldAmbientSH ?? this.worldSH ?? this.worldGradientSH;
8410
8512
  for (let i = 0; i < 9; i++) {
8411
8513
  const b = 36 + i * 4;
8412
8514
  if (sh) {
@@ -8450,6 +8552,118 @@ export class Engine {
8450
8552
  this.updateLightBuffer();
8451
8553
  }
8452
8554
  /** Update the world environment (Blender: World Background). Ambient recomputes immediately. */
8555
+ /**
8556
+ * Distance fog on every lit surface, as a game on SimPipeline lays it: per
8557
+ * layer, t = saturate(f − (1 − f)·h) with f = saturate(depth·distance[0] +
8558
+ * distance[1]) and h = clamp((height[0] − y) / height[1], −1, 1), and the
8559
+ * colour pulled toward the layer's by (1 − t)·amount — the haze first, then
8560
+ * `dyn`, which X340 uses to darken toward black. Depth is along the view
8561
+ * axis and y the world height, both in this engine's units; colours linear.
8562
+ *
8563
+ * PER VERTEX, as the game computes it, and that is load-bearing rather than
8564
+ * a saving: a floor built of long triangles carries the fog of its far
8565
+ * corners into the near half, which is most of what X340's checker floor
8566
+ * shows of its haze. Emission-only graphs take none, as the game's effect
8567
+ * shaders take none. Null clears.
8568
+ */
8569
+ setSceneFog(fog) {
8570
+ this.sceneFog = fog;
8571
+ const u = this.lightData;
8572
+ const layer = (at, l) => {
8573
+ u[at] = l?.color.x ?? 0;
8574
+ u[at + 1] = l?.color.y ?? 0;
8575
+ u[at + 2] = l?.color.z ?? 0;
8576
+ u[at + 3] = l ? Math.max(l.amount, 0) : 0;
8577
+ u[at + 4] = l?.distance[0] ?? 0;
8578
+ u[at + 5] = l?.distance[1] ?? 1;
8579
+ u[at + 6] = l?.height[0] ?? 0;
8580
+ u[at + 7] = l?.height[1] ?? 1;
8581
+ };
8582
+ layer(72, fog);
8583
+ layer(80, fog?.dyn);
8584
+ this.updateLightBuffer();
8585
+ }
8586
+ /**
8587
+ * The cast's shadow on a stage, as a game on SimPipeline lays its ground
8588
+ * shadow: the characters drawn into a map of their own from `direction` (the
8589
+ * way TO the light, this engine's axes), and every Unity-mode stage surface
8590
+ * multiplied toward `color` (linear) by amount × how much of the cast stands
8591
+ * in the way. Independent of the sun — X340's is dim moonlight, which left a
8592
+ * kneeling figure no shadow at all. Only stage materials read it; a scene
8593
+ * without a stage is untouched. Null turns it off.
8594
+ */
8595
+ setStageCastShadow(opts) {
8596
+ this.castShadow = opts;
8597
+ const u = this.lightData;
8598
+ u[88] = opts?.color.x ?? 1;
8599
+ u[89] = opts?.color.y ?? 1;
8600
+ u[90] = opts?.color.z ?? 1;
8601
+ u[91] = 0; // lit per frame by updateCastShadowVP, once there is a cast to fit
8602
+ this.updateLightBuffer();
8603
+ }
8604
+ /** Fit the cast map to this frame's cast; false when there is nothing to draw. */
8605
+ updateCastShadowVP(stage) {
8606
+ const opts = this.castShadow;
8607
+ let on = false;
8608
+ if (opts && stage) {
8609
+ let minX = Infinity, minY = Infinity, minZ = Infinity, maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
8610
+ for (const inst of this.modelInstances.values()) {
8611
+ if (inst.isStage || inst.isPlane || inst.isProp || !inst.model.visible)
8612
+ continue;
8613
+ const s = this.castSphereScratch;
8614
+ this.writeCullSphere(inst, s, 0);
8615
+ if (!(s[3] < 1e6))
8616
+ continue;
8617
+ minX = Math.min(minX, s[0] - s[3]);
8618
+ maxX = Math.max(maxX, s[0] + s[3]);
8619
+ minY = Math.min(minY, s[1] - s[3]);
8620
+ maxY = Math.max(maxY, s[1] + s[3]);
8621
+ minZ = Math.min(minZ, s[2] - s[3]);
8622
+ maxZ = Math.max(maxZ, s[2] + s[3]);
8623
+ }
8624
+ if (minX <= maxX) {
8625
+ const c = new Vec3((minX + maxX) / 2, (minY + maxY) / 2, (minZ + maxZ) / 2);
8626
+ const r = Math.max(maxX - minX, maxY - minY, maxZ - minZ) / 2;
8627
+ const d = new Vec3(opts.direction.x, opts.direction.y, opts.direction.z).normalize();
8628
+ // Wide enough for the shadow a low light throws, deep enough to reach
8629
+ // the floor under the figure from well above it.
8630
+ const half = r * 2.5;
8631
+ const reach = r * 8;
8632
+ const eye = new Vec3(c.x + d.x * reach, c.y + d.y * reach, c.z + d.z * reach);
8633
+ const up = Math.abs(d.y) > 0.99 ? new Vec3(0, 0, 1) : new Vec3(0, 1, 0);
8634
+ const vp = Mat4.orthographicLh(-half, half, -half, half, 1, reach * 2).multiply(Mat4.lookAt(eye, c, up)).values;
8635
+ this.device.queue.writeBuffer(this.castShadowVPBuffer, 0, new Float32Array(vp));
8636
+ this.lightData.set(vp, 92);
8637
+ on = true;
8638
+ }
8639
+ }
8640
+ const amount = on ? Math.max(opts.amount, 0) : 0;
8641
+ if (this.lightData[91] !== amount || on) {
8642
+ this.lightData[91] = amount;
8643
+ this.device.queue.writeBuffer(this.lightUniformBuffer, 88 * 4, this.lightData, 88, 20);
8644
+ }
8645
+ return on;
8646
+ }
8647
+ /**
8648
+ * The world's DIFFUSE light, stated rather than fitted: nine RGB coefficients
8649
+ * (27 floats) in the form rzWorldAmbient evaluates — c0 + c1·y + c2·z + c3·x +
8650
+ * c4·xy + c5·yz + c6·(3z²−1) + c7·xz + c8·(x²−y²), in this engine's axes, the
8651
+ * value a white surface facing n takes. Null goes back to the SH fitted to
8652
+ * the world's picture or gradient.
8653
+ *
8654
+ * A game lights a room's surfaces from an ambient probe that is not its
8655
+ * reflection probe — X340's is a dim blue trilight while its reflections
8656
+ * carry every lamp the probe baked — and one picture cannot be both. With
8657
+ * this set, the picture answers reflections alone. World strength scales it
8658
+ * as it scales the fitted one.
8659
+ */
8660
+ setWorldAmbient(sh) {
8661
+ if (sh && sh.length !== 27)
8662
+ throw new Error(`setWorldAmbient: ${sh.length} floats, not 27`);
8663
+ this.worldAmbientSH = sh ? Float32Array.from(sh) : null;
8664
+ if (this.device && this.lightUniformBuffer)
8665
+ this.writeWorld();
8666
+ }
8453
8667
  setWorld(options) {
8454
8668
  if (options.color)
8455
8669
  this.world.color = options.color;
@@ -8680,6 +8894,8 @@ export class Engine {
8680
8894
  this.canvas.removeEventListener("dblclick", this.handleCanvasDoubleClick);
8681
8895
  this.canvas.removeEventListener("touchend", this.handleCanvasTouch);
8682
8896
  }
8897
+ this.stageGradeTexture?.destroy();
8898
+ this.stageGradeTexture = null;
8683
8899
  this.overlayDepthTexture?.destroy();
8684
8900
  this.overlayDepthTexture = null;
8685
8901
  this.overlayMsaaTexture?.destroy();
@@ -11110,6 +11326,18 @@ export class Engine {
11110
11326
  this.forEachInstance((inst) => this.drawInstanceShadow(shadow, inst, ci));
11111
11327
  return shadow.finish({ label: `shadow pass, cascade ${ci}` });
11112
11328
  });
11329
+ // The cast alone, for the shadow it lays on a stage.
11330
+ const cast = this.device.createRenderBundleEncoder({
11331
+ label: "stage cast shadow pass",
11332
+ colorFormats: [],
11333
+ depthStencilFormat: Engine.SHADOW_DEPTH_FORMAT,
11334
+ });
11335
+ cast.setPipeline(this.shadowDepthPipeline);
11336
+ this.forEachInstance((inst) => {
11337
+ if (!inst.isStage && !inst.isPlane && !inst.isProp)
11338
+ this.drawInstanceShadow(cast, inst, SHADOW_CASCADES.length);
11339
+ });
11340
+ this.castShadowBundle = cast.finish({ label: "stage cast shadow pass" });
11113
11341
  this.bundleRecords++;
11114
11342
  }
11115
11343
  /** The two query slots for a pass, or undefined where the device has no
@@ -11543,6 +11771,16 @@ export class Engine {
11543
11771
  { binding: 2, resource: this.materialSampler },
11544
11772
  ],
11545
11773
  }));
11774
+ // …and one more past the cascades, for the cast's shadow on a stage.
11775
+ shadowBindGroups.push(this.device.createBindGroup({
11776
+ label: `${name}: shadow bind, stage cast shadow`,
11777
+ layout: this.shadowDepthPipeline.getBindGroupLayout(0),
11778
+ entries: [
11779
+ { binding: 0, resource: { buffer: this.castShadowVPBuffer } },
11780
+ { binding: 1, resource: { buffer: skinMatrixBuffer } },
11781
+ { binding: 2, resource: this.materialSampler },
11782
+ ],
11783
+ }));
11546
11784
  const mainPerInstanceBindGroup = this.perInstanceBindGroup(name, skinMatrixBuffer, null);
11547
11785
  const pickPerInstanceBindGroup = this.device.createBindGroup({
11548
11786
  label: `${name}: pick per-instance bind group`,
@@ -13576,6 +13814,19 @@ export class Engine {
13576
13814
  sp.end();
13577
13815
  this.shadowCascadeCleared[ci] = !wanted;
13578
13816
  }
13817
+ // The cast's shadow on a stage: drawn while a stage asks for it and has a
13818
+ // cast to shade it with; cleared once when it stops, then left alone.
13819
+ const castOn = this.updateCastShadowVP(stage);
13820
+ if (castOn || !this.castShadowCleared) {
13821
+ const cp = encoder.beginRenderPass({
13822
+ colorAttachments: [],
13823
+ depthStencilAttachment: { view: this.castShadowView, depthClearValue: 1.0, depthLoadOp: "clear", depthStoreOp: "store" },
13824
+ });
13825
+ if (castOn && this.castShadowBundle)
13826
+ cp.executeBundles([this.castShadowBundle]);
13827
+ cp.end();
13828
+ this.castShadowCleared = !castOn;
13829
+ }
13579
13830
  this.shadowMapPopulated = hasModels;
13580
13831
  }
13581
13832
  // Before the particles and before the field pass: both may read the grid,
@@ -14652,27 +14903,41 @@ export class Engine {
14652
14903
  * each for a reason that would change pixels: EYE front-culls and gates on a
14653
14904
  * bone read, and pre-filled hair depth over the socket would depth-reject
14654
14905
  * the eye before it could write the stencil the see-through-hair pass needs
14655
- * — which is also why HAIR stays out entirely. HASHED alpha (stockings)
14656
- * discards by a position hash this pass does not run, so priming it would
14657
- * punch its cutout into the depth buffer at the wrong texels. They all still
14658
- * BENEFIT: their fragments early-z against the primed depth of whatever
14659
- * plain opaque surface sits in front of them.
14906
+ * — which is also why HAIR stays out entirely. They all still BENEFIT:
14907
+ * their fragments early-z against the primed depth of whatever plain opaque
14908
+ * surface sits in front of them.
14909
+ *
14910
+ * HASHED alpha primes through the SOLID pipeline (cutoff 1.0). Its colour
14911
+ * pass discards by a position hash this pass does not run, but that threshold
14912
+ * is clamped to at most 1, so a texel at alpha exactly 1 survives it always —
14913
+ * those are the only texels the solid prime claims, and it can never punch
14914
+ * the cutout into the wrong ones. A stage's foliage and railings are mostly
14915
+ * such texels, and without the prime every room surface behind them shaded
14916
+ * its full lamp walk and was then painted over.
14660
14917
  */
14661
14918
  drawOpaqueDepthPrepass(pass, inst, view) {
14662
- let bound = false;
14919
+ let bound = null;
14663
14920
  for (const draw of inst.drawCalls) {
14664
14921
  if (draw.type !== "opaque")
14665
14922
  continue;
14923
+ let pipeline = this.depthPrepassPipeline;
14666
14924
  if (draw.groupId) {
14667
14925
  const install = inst.styleGroups.get(draw.groupId);
14668
- if (install && !(install.renderClass === "auto" && install.alphaMode === "opaque"))
14926
+ if (install && install.renderClass !== "auto")
14927
+ continue;
14928
+ if (install?.alphaMode === "hashed")
14929
+ pipeline = this.solidPrepassPipeline;
14930
+ else if (install && install.alphaMode !== "opaque")
14669
14931
  continue;
14670
14932
  }
14671
- if (!bound) {
14672
- pass.setPipeline(this.depthPrepassPipeline);
14673
- pass.setBindGroup(0, view.perFrame);
14674
- pass.setBindGroup(1, inst.mainPerInstanceBindGroup);
14675
- bound = true;
14933
+ if (bound !== pipeline) {
14934
+ pass.setPipeline(pipeline);
14935
+ // One layout for both prepass pipelines, so groups 0 and 1 carry over.
14936
+ if (!bound) {
14937
+ pass.setBindGroup(0, view.perFrame);
14938
+ pass.setBindGroup(1, inst.mainPerInstanceBindGroup);
14939
+ }
14940
+ bound = pipeline;
14676
14941
  }
14677
14942
  pass.setBindGroup(2, draw.bindGroup);
14678
14943
  this.issueDraw(pass, draw, view.args);
@@ -1 +1 @@
1
- {"version":3,"file":"compile.d.ts","sourceRoot":"","sources":["../../src/graph/compile.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,UAAU,EAAE,YAAY,EAA0B,WAAW,EAAE,MAAM,UAAU,CAAA;AAI7F,OAAO,KAAK,EAAE,SAAS,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAA;AAExE,MAAM,MAAM,cAAc,GAAG;IAC3B;8EAC0E;IAC1E,YAAY,CAAC,EAAE,OAAO,CAAA;IACtB;6DACyD;IACzD,WAAW,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAA;IAC9C;0EACsE;IACtE,WAAW,CAAC,EAAE,WAAW,CAAA;IACzB,wEAAwE;IACxE,SAAS,CAAC,EAAE,SAAS,CAAA;IACrB,6EAA6E;IAC7E,KAAK,CAAC,EAAE,UAAU,CAAA;CACnB,CAAA;AAED,yFAAyF;AACzF,MAAM,MAAM,SAAS,GAAG;IACtB,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,OAAO,GAAG,OAAO,CAAA;IACvB,SAAS,EAAE,MAAM,CAAA;IACjB,SAAS,CAAC,EAAE,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,CAAA;IACjC,IAAI,EAAE,MAAM,CAAA;CACb,CAAA;AAED,MAAM,MAAM,aAAa,GAAG;IAC1B,EAAE,EAAE,OAAO,CAAA;IACX,gDAAgD;IAChD,IAAI,EAAE,MAAM,CAAA;IACZ,6EAA6E;IAC7E,MAAM,EAAE,MAAM,CAAA;IACd,OAAO,EAAE,SAAS,EAAE,CAAA;IACpB,WAAW,EAAE,UAAU,EAAE,CAAA;IACzB,WAAW,EAAE,MAAM,EAAE,CAAA;CACtB,CAAA;AAcD,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,YAAY,EAAE,GAAG,SAAS,EAAE,CAuBpE;AAID,wBAAgB,aAAa,CAAC,KAAK,EAAE,WAAW,EAAE,IAAI,GAAE,cAAmB,GAAG,UAAU,EAAE,CA4GzF;AAID,wBAAgB,YAAY,CAAC,KAAK,EAAE,WAAW,EAAE,IAAI,GAAE,cAAmB,GAAG,aAAa,CAmKzF"}
1
+ {"version":3,"file":"compile.d.ts","sourceRoot":"","sources":["../../src/graph/compile.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,UAAU,EAAE,YAAY,EAA0B,WAAW,EAAE,MAAM,UAAU,CAAA;AAI7F,OAAO,KAAK,EAAE,SAAS,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAA;AAExE,MAAM,MAAM,cAAc,GAAG;IAC3B;8EAC0E;IAC1E,YAAY,CAAC,EAAE,OAAO,CAAA;IACtB;6DACyD;IACzD,WAAW,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAA;IAC9C;0EACsE;IACtE,WAAW,CAAC,EAAE,WAAW,CAAA;IACzB,wEAAwE;IACxE,SAAS,CAAC,EAAE,SAAS,CAAA;IACrB,6EAA6E;IAC7E,KAAK,CAAC,EAAE,UAAU,CAAA;CACnB,CAAA;AAED,yFAAyF;AACzF,MAAM,MAAM,SAAS,GAAG;IACtB,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,OAAO,GAAG,OAAO,CAAA;IACvB,SAAS,EAAE,MAAM,CAAA;IACjB,SAAS,CAAC,EAAE,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,CAAA;IACjC,IAAI,EAAE,MAAM,CAAA;CACb,CAAA;AAED,MAAM,MAAM,aAAa,GAAG;IAC1B,EAAE,EAAE,OAAO,CAAA;IACX,gDAAgD;IAChD,IAAI,EAAE,MAAM,CAAA;IACZ,6EAA6E;IAC7E,MAAM,EAAE,MAAM,CAAA;IACd,OAAO,EAAE,SAAS,EAAE,CAAA;IACpB,WAAW,EAAE,UAAU,EAAE,CAAA;IACzB,WAAW,EAAE,MAAM,EAAE,CAAA;CACtB,CAAA;AAcD,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,YAAY,EAAE,GAAG,SAAS,EAAE,CAuBpE;AAID,wBAAgB,aAAa,CAAC,KAAK,EAAE,WAAW,EAAE,IAAI,GAAE,cAAmB,GAAG,UAAU,EAAE,CA4GzF;AAID,wBAAgB,YAAY,CAAC,KAAK,EAAE,WAAW,EAAE,IAAI,GAAE,cAAmB,GAAG,aAAa,CAsKzF"}
@@ -309,6 +309,9 @@ export function compileGraph(graph, opts = {}) {
309
309
  if (opacity)
310
310
  lines.push(` let final_opacity = saturate(${outputExpr(opacity, "float")}); // @node:${opacity.node}`);
311
311
  const fsBody = lines.join("\n");
312
- const wgsl = assembleModule(opts.renderClass ?? "auto", opts.alphaMode ?? "opaque", fsBody, usesStyle.current, !!opacity, opts.blend ?? "over");
312
+ // A graph takes light when its body reads any: the four shading nodes all
313
+ // pass `sun, amb`, and a hand-written node reaching for the lamps names them.
314
+ const takesLight = /\bsun\b|\bamb\b|rzLight|rzLamp/.test(fsBody);
315
+ const wgsl = assembleModule(opts.renderClass ?? "auto", opts.alphaMode ?? "opaque", fsBody, usesStyle.current, !!opacity, opts.blend ?? "over", takesLight);
313
316
  return { ok: true, wgsl, fsBody, slotMap, diagnostics, prunedNodes };
314
317
  }
@@ -1 +1 @@
1
- {"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../src/graph/registry.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,UAAU,CAAA;AAE3C,MAAM,MAAM,KAAK,GAAG,OAAO,GAAG,OAAO,GAAG,QAAQ,GAAG,MAAM,CAAA;AAEzD,KAAK,SAAS,GAAG;IACf,IAAI,EAAE,KAAK,CAAA;IACX,OAAO,CAAC,EAAE,WAAW,CAAA;IACrB,4FAA4F;IAC5F,YAAY,CAAC,EAAE,OAAO,CAAA;IACtB,yFAAyF;IACzF,cAAc,CAAC,EAAE,MAAM,CAAA;CACxB,CAAA;AAED,MAAM,MAAM,QAAQ,GAAG;IACrB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,CAAA;IACjC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,CAAA;IAC9B,2FAA2F;IAC3F,IAAI,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KAAK,MAAM,CAAA;IAC/C,qEAAqE;IACrE,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IACvC,gFAAgF;IAChF,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CACtC,CAAA;AAYD,wBAAgB,QAAQ,CAAC,KAAK,EAAE,WAAW,EAAE,IAAI,EAAE,KAAK,GAAG,MAAM,CAoBhE;AAED,wFAAwF;AACxF,wBAAgB,WAAW,CAAC,KAAK,EAAE,WAAW,EAAE,IAAI,EAAE,KAAK,GAAG,OAAO,CAIpE;AAMD,wBAAgB,UAAU,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,EAAE,KAAK,GAAG,OAAO,CAM1D;AAED,wBAAgB,OAAO,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAKpE;AAyCD,eAAO,MAAM,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,QAAQ,CA0tBlD,CAAA"}
1
+ {"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../src/graph/registry.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,UAAU,CAAA;AAE3C,MAAM,MAAM,KAAK,GAAG,OAAO,GAAG,OAAO,GAAG,QAAQ,GAAG,MAAM,CAAA;AAEzD,KAAK,SAAS,GAAG;IACf,IAAI,EAAE,KAAK,CAAA;IACX,OAAO,CAAC,EAAE,WAAW,CAAA;IACrB,4FAA4F;IAC5F,YAAY,CAAC,EAAE,OAAO,CAAA;IACtB,yFAAyF;IACzF,cAAc,CAAC,EAAE,MAAM,CAAA;CACxB,CAAA;AAED,MAAM,MAAM,QAAQ,GAAG;IACrB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,CAAA;IACjC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,CAAA;IAC9B,2FAA2F;IAC3F,IAAI,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KAAK,MAAM,CAAA;IAC/C,qEAAqE;IACrE,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IACvC,gFAAgF;IAChF,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CACtC,CAAA;AAYD,wBAAgB,QAAQ,CAAC,KAAK,EAAE,WAAW,EAAE,IAAI,EAAE,KAAK,GAAG,MAAM,CAoBhE;AAED,wFAAwF;AACxF,wBAAgB,WAAW,CAAC,KAAK,EAAE,WAAW,EAAE,IAAI,EAAE,KAAK,GAAG,OAAO,CAIpE;AAMD,wBAAgB,UAAU,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,EAAE,KAAK,GAAG,OAAO,CAM1D;AAED,wBAAgB,OAAO,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAKpE;AAyCD,eAAO,MAAM,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,QAAQ,CAsuBlD,CAAA"}
@@ -791,6 +791,14 @@ export const NODE_REGISTRY = {
791
791
  /** Ours, not Blender's: caps firefly speculars from noise-bumped NDF
792
792
  * aliasing, which EEVEE hides behind TAA and we have none. */
793
793
  spec_clamp: F(1e30),
794
+ /** Ours: how roughness picks the reflection's blur. 0 is the engine's
795
+ * sqrt ramp; 1 is Unity's probe curve, p·(1.7−0.7p) mip-steps per
796
+ * eighth, stopping where a cube's last face texel does — what a stage
797
+ * ripped from a Unity game was authored against. See rzWorldSpecularLod. */
798
+ reflection_lod: F(0),
799
+ /** Ours: 1 shades the sun's and the lamps' highlights with URP's
800
+ * direct-light BRDF, as a stage from a Unity game was lit — see bsdf_urp. */
801
+ unity_direct: F(0),
794
802
  },
795
803
  outputs: { color: "color" },
796
804
  emit: (a) => {
@@ -801,11 +809,15 @@ export const NODE_REGISTRY = {
801
809
  const spec = foldSpecular(a.ior, a.specular_ior_level);
802
810
  const bsdf = `eval_principled(PrincipledIn(${a.base_color}, ${a.metallic}, ` +
803
811
  `${spec}, ${a.roughness}, ` +
804
- `${a.spec_clamp}, ${a.sheen_weight}, ${a.sheen_tint}), ${a.normal}, l, v, sun, amb, shadow, input.worldPos)`;
812
+ `${a.spec_clamp}, ${a.sheen_weight}, ${a.sheen_tint}, ${a.reflection_lod}, ${a.unity_direct}), ${a.normal}, l, v, sun, amb, shadow, input.worldPos)`;
805
813
  // v2 defaults Emission Strength to 0, which is the overwhelming case. Emit
806
814
  // the term only when it can do something, so the common shader carries no
807
815
  // dead add and the output stays readable.
808
- return a.emission_strength === "0.0" ? bsdf : `${bsdf} + ${a.emission_color} * ${a.emission_strength}`;
816
+ const lit = a.emission_strength === "0.0" ? bsdf : `${bsdf} + ${a.emission_color} * ${a.emission_strength}`;
817
+ // Unity mode also ends as SimPipeline's surfaces end: saturate()d, so no
818
+ // lit or glowing surface passes 1.0 before the post — its bloom works on
819
+ // what is left under that. A literal, as every stage graph states it.
820
+ return a.unity_direct === "1.0" ? `min(${lit}, vec3f(1.0))` : lit;
809
821
  },
810
822
  },
811
823
  };
@@ -4,5 +4,5 @@ import type { AlphaMode, RenderClass, StyleBlend } from "./render-class";
4
4
  * StyleUniforms, render-class/alpha-mode declarations, the composed fs() shell, and the
5
5
  * graph's node body.
6
6
  */
7
- export declare function assembleModule(renderClass: RenderClass, alphaMode: AlphaMode, fsBody: string, includeStyleUniforms: boolean, hasOpacity?: boolean, blend?: StyleBlend): string;
7
+ export declare function assembleModule(renderClass: RenderClass, alphaMode: AlphaMode, fsBody: string, includeStyleUniforms: boolean, hasOpacity?: boolean, blend?: StyleBlend, takesLight?: boolean): string;
8
8
  //# sourceMappingURL=slots.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"slots.d.ts","sourceRoot":"","sources":["../../src/graph/slots.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,SAAS,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAA;AAiMxE;;;;GAIG;AACH,wBAAgB,cAAc,CAC5B,WAAW,EAAE,WAAW,EACxB,SAAS,EAAE,SAAS,EACpB,MAAM,EAAE,MAAM,EACd,oBAAoB,EAAE,OAAO,EAC7B,UAAU,UAAQ,EAClB,KAAK,GAAE,UAAmB,GACzB,MAAM,CAgBR"}
1
+ {"version":3,"file":"slots.d.ts","sourceRoot":"","sources":["../../src/graph/slots.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,SAAS,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAA;AA+MxE;;;;GAIG;AACH,wBAAgB,cAAc,CAC5B,WAAW,EAAE,WAAW,EACxB,SAAS,EAAE,SAAS,EACpB,MAAM,EAAE,MAAM,EACd,oBAAoB,EAAE,OAAO,EAC7B,UAAU,UAAQ,EAClB,KAAK,GAAE,UAAmB,EAC1B,UAAU,UAAO,GAChB,MAAM,CAgBR"}
@@ -132,13 +132,16 @@ ${gate}
132
132
  let amb = rzWorldAmbient(n) + modelLight.fill.rgb;
133
133
  let shadow = sampleShadow(input.worldPos, n);
134
134
  let tex_color = tex_s.rgb;
135
+ // The normal the lamps' diffuse layer is lit by, for a principled walk to
136
+ // compute it on the way (rzLightsDiffuseOnce). n is final from here on.
137
+ _rzLampN = n;
135
138
 
136
139
  `;
137
140
  }
138
141
  // Tail of fs(): consumes `final_color` + locals, writes FSOut. hashed forces output
139
142
  // alpha to 1 (the discard already did the cutout); hair scales alpha for the over-eyes
140
143
  // pass when IS_OVER_EYES is compiled true.
141
- function epilogue(renderClass, alphaMode, hasOpacity, blend) {
144
+ function epilogue(renderClass, alphaMode, hasOpacity, blend, takesLight) {
142
145
  // A graph that computes its own opacity wins, including over hashed: hashed
143
146
  // writes 1 because its discard already decided the cutout, and a graph asking
144
147
  // for a curve is asking for the opposite of a cutout. Additive writes 1 for a
@@ -164,7 +167,12 @@ function epilogue(renderClass, alphaMode, hasOpacity, blend) {
164
167
  // so the fix has an address — when graphs gain an optional albedo output
165
168
  // (material-track era), it lands here and every light is corrected at once,
166
169
  // instead of a hunt through the epilogue's string templates.
167
- const LIT = ` + rzLightsDiffuse(input.worldPos, n) * albedo`;
170
+ //
171
+ // ONLY ON A GRAPH THAT TAKES LIGHT. An emission-only graph — Unlit, a stage's
172
+ // painted sheet — is Blender's Emission shader, which no lamp reaches; adding
173
+ // the lamps to it turned X340's floor shadow, a white-RGB picture at a soft
174
+ // alpha, into a glowing disc under the spot above it.
175
+ const LIT = takesLight ? ` + rzLightsDiffuseOnce(input.worldPos, n) * albedo` : "";
168
176
  const ALBEDO = ` let albedo = tex_color;
169
177
  `;
170
178
  // The dissolve's burning edge, ADDED after the graph and after the lights —
@@ -172,19 +180,24 @@ function epilogue(renderClass, alphaMode, hasOpacity, blend) {
172
180
  // on every fragment of every material that is not dissolving, which is what
173
181
  // keeps this out of the way of a scene that never uses it.
174
182
  const BURN = ` + RZ_BURN_COLOR * rz_burn`;
183
+ // The scene fog, last, as the game lays it over a lit surface; an
184
+ // emission-only graph takes none (the game's effect shaders have no fog).
185
+ const FOG = takesLight
186
+ ? ` out.color = vec4f(mix(mix(out.color.rgb, light.fog[0].rgb, input.fog.x), light.fog[2].rgb, input.fog.y), out.color.a);\n`
187
+ : "";
175
188
  if (renderClass === "hair") {
176
189
  return `${ALBEDO} var outAlpha = ${alphaBase};
177
190
  if (IS_OVER_EYES) { outAlpha = ${alphaBase} * 0.25; }
178
191
 
179
192
  var out: FSOut;
180
193
  out.color = vec4f(final_color${LIT}${BURN}, outAlpha);
181
- out.mask = vec4f(1.0, 1.0, 0.0, out.color.a);
194
+ ${FOG} out.mask = vec4f(1.0, 1.0, 0.0, out.color.a);
182
195
  ${ID_WRITE} return out;
183
196
  `;
184
197
  }
185
198
  return `${ALBEDO} var out: FSOut;
186
199
  out.color = vec4f(final_color${LIT}${BURN}, ${alphaBase});
187
- out.mask = vec4f(1.0, 1.0, 0.0, out.color.a);
200
+ ${FOG} out.mask = vec4f(1.0, 1.0, 0.0, out.color.a);
188
201
  ${ID_WRITE} return out;
189
202
  `;
190
203
  }
@@ -193,7 +206,7 @@ ${ID_WRITE} return out;
193
206
  * StyleUniforms, render-class/alpha-mode declarations, the composed fs() shell, and the
194
207
  * graph's node body.
195
208
  */
196
- export function assembleModule(renderClass, alphaMode, fsBody, includeStyleUniforms, hasOpacity = false, blend = "over") {
209
+ export function assembleModule(renderClass, alphaMode, fsBody, includeStyleUniforms, hasOpacity = false, blend = "over", takesLight = true) {
197
210
  return (NODES_WGSL +
198
211
  COMMON_MATERIAL_PRELUDE_WGSL +
199
212
  // Exactly where it sat inside the prelude constant. It moved out because
@@ -205,6 +218,6 @@ export function assembleModule(renderClass, alphaMode, fsBody, includeStyleUnifo
205
218
  prelude(renderClass, alphaMode, blend) +
206
219
  fsBody +
207
220
  "\n" +
208
- epilogue(renderClass, alphaMode, hasOpacity, blend) +
221
+ epilogue(renderClass, alphaMode, hasOpacity, blend, takesLight) +
209
222
  "}\n");
210
223
  }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { Engine, DEFAULT_BLOOM_OPTIONS, DEFAULT_DEPTH_OF_FIELD_OPTIONS, DEFAULT_VIEW_TRANSFORM, DEFAULT_COLOR_GRADING, type ColorGradingOptions, type EngineStats, type EngineOptions, type BloomOptions, type DepthOfFieldOptions, type ViewTransformOptions, type LoadModelFromFilesOptions, type MaterialPreset, type MaterialPresetMap, type ModelTransform, type SceneLight, type ModelAttachment, type GizmoDragEvent, type GizmoDragCallback, type GizmoDragKind, type DissolveCycle, type EffectParamValue, type EffectResult, type CullDiagnostics, type MidiNote, } from "./engine";
1
+ export { Engine, DEFAULT_BLOOM_OPTIONS, DEFAULT_DEPTH_OF_FIELD_OPTIONS, DEFAULT_VIEW_TRANSFORM, DEFAULT_COLOR_GRADING, type ColorGradingOptions, type StageGradeLut, type SceneFog, type StageCastShadow, type SceneFogLayer, type EngineStats, type EngineOptions, type BloomOptions, type DepthOfFieldOptions, type ViewTransformOptions, type LoadModelFromFilesOptions, type MaterialPreset, type MaterialPresetMap, type ModelTransform, type SceneLight, type ModelAttachment, type GizmoDragEvent, type GizmoDragCallback, type GizmoDragKind, type DissolveCycle, type EffectParamValue, type EffectResult, type CullDiagnostics, type MidiNote, } from "./engine";
2
2
  export { parsePmxFolderInput, pmxFileAtRelativePath, type PmxFolderInputResult } from "./folder-upload";
3
3
  export { parseMidi } from "./midi-loader";
4
4
  export { parseHDR, type HdrImage } from "./hdr";