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/src/engine.ts CHANGED
@@ -638,6 +638,35 @@ export const DEFAULT_COLOR_GRADING: ColorGradingOptions = {
638
638
  saturation: 1,
639
639
  }
640
640
 
641
+ /** The cast's shadow on a stage — see Engine.setStageCastShadow. */
642
+ export type StageCastShadow = {
643
+ /** The way TO the light, this engine's axes. */
644
+ direction: { x: number; y: number; z: number }
645
+ /** Linear. */
646
+ color: { x: number; y: number; z: number }
647
+ amount: number
648
+ }
649
+
650
+ /** One layer of scene fog — see Engine.setSceneFog. */
651
+ export type SceneFogLayer = {
652
+ color: { x: number; y: number; z: number }
653
+ amount: number
654
+ /** (slope, offset): f = saturate(depth·slope + offset), 1 = clear. */
655
+ distance: [number, number]
656
+ /** (reference, range): h = clamp((reference − y) / range, −1, 1). */
657
+ height: [number, number]
658
+ }
659
+ export type SceneFog = SceneFogLayer & { dyn?: SceneFogLayer | null }
660
+
661
+ /** A scene's own grade as a cube — see Engine.setStageGrade. */
662
+ export type StageGradeLut = {
663
+ /** Texels along each edge. */
664
+ size: number
665
+ /** size³ texels, 8-bit sRGB-encoded, red fastest then green then blue;
666
+ * RGB (3 bytes a texel) or RGBA (4). */
667
+ data: Uint8Array
668
+ }
669
+
641
670
  export type GizmoDragKind = "rotate" | "translate"
642
671
 
643
672
  export interface GizmoDragEvent {
@@ -1792,7 +1821,16 @@ export class Engine {
1792
1821
  private lightUniformBuffer!: GPUBuffer
1793
1822
  // ambient vec4 (4) + 4 lights x 2 vec4 (32) + 9 irradiance-SH vec4s (36),
1794
1823
  // padded to 80. sh[0].w is the IBL flag: 0 = flat world colour, 1 = the sky.
1795
- private lightData = new Float32Array(80)
1824
+ // …then the scene fog at [72..87] — see setSceneFog — and the cast's
1825
+ // shadow on a stage at [88..107] (colour, amount; its view-projection).
1826
+ private lightData = new Float32Array(112)
1827
+ private castShadow: StageCastShadow | null = null
1828
+ private castShadowTexture!: GPUTexture
1829
+ private castShadowView!: GPUTextureView
1830
+ private castShadowVPBuffer!: GPUBuffer
1831
+ private castShadowBundle: GPURenderBundle | null = null
1832
+ private castShadowCleared = false
1833
+ private readonly castSphereScratch = new Float32Array(4)
1796
1834
  private lightCount = 0
1797
1835
  private resizeObserver: ResizeObserver | null = null
1798
1836
  private resizePending = false
@@ -2457,6 +2495,7 @@ export class Engine {
2457
2495
  private depthOfField: DepthOfFieldOptions = { ...DEFAULT_DEPTH_OF_FIELD_OPTIONS }
2458
2496
  private dofUniformBuffer!: GPUBuffer
2459
2497
  private dofUniformData = new Float32Array(12)
2498
+ private sceneFog: SceneFog | null = null
2460
2499
  private dofFocusScratch = new Vec3(0, 0, 0)
2461
2500
  /** Depth-only view of the scene's MSAA depth buffer, read by the DoF gather. */
2462
2501
  private depthReadView: GPUTextureView | null = null
@@ -2490,6 +2529,9 @@ export class Engine {
2490
2529
  private worldGradientSH: Float32Array | null = null
2491
2530
  /** The installed HDRI's folded irradiance SH (27 floats), or null. */
2492
2531
  private worldSH: Float32Array | null = null
2532
+ /** A diffuse ambient stated outright (setWorldAmbient), outranking the one
2533
+ * fitted to the picture; the picture still answers reflections. */
2534
+ private worldAmbientSH: Float32Array | null = null
2493
2535
  private fallbackEquirectTexture!: GPUTexture
2494
2536
  private fallbackEquirectView!: GPUTextureView
2495
2537
  // The scene's user WGSL effect (setEffect). ONE per scene, mounted under the
@@ -2569,6 +2611,11 @@ export class Engine {
2569
2611
  private trailDue = 0
2570
2612
  private agxLutTexture: GPUTexture | null = null
2571
2613
  private agxFallbackTexture!: GPUTexture
2614
+ /** The scene's own grade (setStageGrade): the cube as handed over, kept so a
2615
+ * call before init() still lands, and its texture once uploaded. */
2616
+ private stageGradeCube: StageGradeLut | null = null
2617
+ private stageGradeTexture: GPUTexture | null = null
2618
+ private stageGradeFallback!: GPUTexture
2572
2619
  /** Bound at composite binding 7 when no effect (or a param-less one) is set. */
2573
2620
  private bgParamsDummyBuffer!: GPUBuffer
2574
2621
  private compositePipelineLayout!: GPUPipelineLayout
@@ -2829,6 +2876,57 @@ export class Engine {
2829
2876
  if (this.device && this.compositeUniformBuffer) this.writeCompositeViewUniforms()
2830
2877
  }
2831
2878
 
2879
+ /**
2880
+ * The grade a scene arrived with, as a cube — a game's own colour grading,
2881
+ * baked by the converter that brought its stage. Applied to the formed frame
2882
+ * before setColorGrading's, which stays the author's to lay on top. Looked up
2883
+ * as the game looks it up: the view transform's output, linear, trilinear
2884
+ * between texel centres. Null takes it off.
2885
+ *
2886
+ * Bytes are the cube as that game stored it: 8-bit sRGB-encoded, red fastest,
2887
+ * then green, then blue; three bytes a texel or four. A cube whose byte count
2888
+ * does not match its size is refused rather than drawn wrong.
2889
+ */
2890
+ setStageGrade(lut: StageGradeLut | null): void {
2891
+ if (lut) {
2892
+ const stride = lut.data.byteLength / lut.size ** 3
2893
+ if (!Number.isInteger(lut.size) || lut.size < 2 || lut.size > 64 || (stride !== 3 && stride !== 4)) {
2894
+ throw new Error(`setStageGrade: ${lut.data.byteLength} bytes is not a ${lut.size}³ cube`)
2895
+ }
2896
+ }
2897
+ this.stageGradeCube = lut
2898
+ if (this.device && this.stageGradeFallback) this.uploadStageGrade()
2899
+ }
2900
+
2901
+ private uploadStageGrade(): void {
2902
+ const lut = this.stageGradeCube
2903
+ this.stageGradeTexture?.destroy()
2904
+ this.stageGradeTexture = null
2905
+ if (lut) {
2906
+ const n = lut.size
2907
+ const texels = n ** 3
2908
+ const stride = lut.data.byteLength / texels
2909
+ const rgba = new Uint8Array(texels * 4)
2910
+ for (let i = 0; i < texels; i++) {
2911
+ rgba[i * 4] = lut.data[i * stride]
2912
+ rgba[i * 4 + 1] = lut.data[i * stride + 1]
2913
+ rgba[i * 4 + 2] = lut.data[i * stride + 2]
2914
+ rgba[i * 4 + 3] = 255
2915
+ }
2916
+ const tex = this.device.createTexture({
2917
+ label: `stage grade ${n}³`,
2918
+ size: [n, n, n],
2919
+ dimension: "3d",
2920
+ format: "rgba8unorm-srgb",
2921
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
2922
+ })
2923
+ this.device.queue.writeTexture({ texture: tex }, rgba, { bytesPerRow: n * 4, rowsPerImage: n }, [n, n, n])
2924
+ this.stageGradeTexture = tex
2925
+ }
2926
+ this.rebuildCompositeBindGroup()
2927
+ if (this.compositeUniformBuffer) this.writeCompositeViewUniforms()
2928
+ }
2929
+
2832
2930
  /** Current grade (for serialization into a scene descriptor). */
2833
2931
  getColorGrading(): ColorGradingOptions {
2834
2932
  const g = this.colorGrading
@@ -2969,7 +3067,8 @@ export class Engine {
2969
3067
  u[32] === 1 && u[33] === 1 && u[34] === 1 &&
2970
3068
  u[36] === 1 && u[37] === 1 && u[38] === 1 &&
2971
3069
  g.contrast === 1 && g.saturation === 1
2972
- u[39] = neutral ? 0 : 1
3070
+ // Bit 0 the CDL above, bit 1 the scene's own cube — see _rzGradeScene.
3071
+ u[39] = (neutral ? 0 : 1) | (this.stageGradeTexture ? 2 : 0)
2973
3072
  this.device.queue.writeBuffer(this.compositeUniformBuffer, 0, u)
2974
3073
  }
2975
3074
 
@@ -3096,9 +3195,10 @@ export class Engine {
3096
3195
  down: [number, number, number]
3097
3196
  } {
3098
3197
  const s = this.world.strength
3099
- if (this.worldSH) {
3198
+ const fitted = this.worldAmbientSH ?? this.worldSH
3199
+ if (fitted) {
3100
3200
  const at = (n: { x: number; y: number; z: number }) =>
3101
- evalIrradianceSH(this.worldSH!, n).map((v) => Math.max(v * s, 0)) as [number, number, number]
3201
+ evalIrradianceSH(fitted, n).map((v) => Math.max(v * s, 0)) as [number, number, number]
3102
3202
  return { source: "hdri", strength: s, up: at({ x: 0, y: 1, z: 0 }), down: at({ x: 0, y: -1, z: 0 }) }
3103
3203
  }
3104
3204
  const c = this.world.color
@@ -3828,6 +3928,7 @@ export class Engine {
3828
3928
  { binding: 8, resource: this.depthReadView },
3829
3929
  { binding: 9, resource: { buffer: this.dofUniformBuffer } },
3830
3930
  { binding: 10, resource: (this.agxLutTexture ?? this.agxFallbackTexture).createView({ dimension: "3d" }) },
3931
+ { binding: 12, resource: (this.stageGradeTexture ?? this.stageGradeFallback).createView({ dimension: "3d" }) },
3831
3932
  { binding: 11, resource: { buffer: this.castBuffer } },
3832
3933
  { binding: 13, resource: { buffer: this.audioBuffer } },
3833
3934
  { binding: 19, resource: { buffer: this.midiBuffer } },
@@ -4266,6 +4367,7 @@ export class Engine {
4266
4367
  { binding: 2, resource: this.bloomSampler },
4267
4368
  { binding: 5, resource: this.filmicLutView },
4268
4369
  { binding: 10, resource: (this.agxLutTexture ?? this.agxFallbackTexture).createView({ dimension: "3d" }) },
4370
+ { binding: 12, resource: (this.stageGradeTexture ?? this.stageGradeFallback).createView({ dimension: "3d" }) },
4269
4371
  { binding: 1, resource: bloom },
4270
4372
  { binding: 4, resource: this.maskResolveView },
4271
4373
  ],
@@ -7251,6 +7353,7 @@ export class Engine {
7251
7353
  { binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
7252
7354
  { binding: 5, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
7253
7355
  { binding: 10, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float", viewDimension: "3d" } },
7356
+ { binding: 12, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float", viewDimension: "3d" } },
7254
7357
  // And the scene's COVERAGE and bloom, without which the tap cannot
7255
7358
  // reconstruct a pixel: the HDR target is premultiplied, so colour alone
7256
7359
  // reads a half-transparent ground as a dark opaque one.
@@ -7419,7 +7522,8 @@ export class Engine {
7419
7522
  label: "main per-frame bind group layout",
7420
7523
  entries: [
7421
7524
  { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
7422
- { binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
7525
+ // The vertex stage reads it too: the scene fog is per vertex (setSceneFog).
7526
+ { binding: 1, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
7423
7527
  { binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
7424
7528
  { binding: 3, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "depth" } },
7425
7529
  { binding: 4, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "comparison" } },
@@ -7433,6 +7537,8 @@ export class Engine {
7433
7537
  { binding: 7, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "depth" } },
7434
7538
  { binding: 8, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
7435
7539
  { binding: 9, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
7540
+ // The cast's shadow on a stage — setStageCastShadow.
7541
+ { binding: 10, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "depth" } },
7436
7542
  ],
7437
7543
  })
7438
7544
  // group 1: per-instance (skinMats) — bound once per model
@@ -7654,6 +7760,22 @@ export class Engine {
7654
7760
  }),
7655
7761
  )
7656
7762
  this.shadowMapDepthViews = this.shadowMapTextures.map((t) => t.createView())
7763
+ // The cast's shadow on a stage (setStageCastShadow): one map, only the cast
7764
+ // in it, from the stage's own direction. Always allocated so every material
7765
+ // bind group and every instance's shadow bind group stays one shape; drawn
7766
+ // only while a stage asks for it.
7767
+ this.castShadowTexture = this.device.createTexture({
7768
+ label: "stage cast shadow map",
7769
+ size: [1024, 1024],
7770
+ format: Engine.SHADOW_DEPTH_FORMAT,
7771
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
7772
+ })
7773
+ this.castShadowView = this.castShadowTexture.createView()
7774
+ this.castShadowVPBuffer = this.device.createBuffer({
7775
+ label: "stage cast shadow view-projection",
7776
+ size: 64,
7777
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
7778
+ })
7657
7779
 
7658
7780
  // One-shot bake of Blender EEVEE's combined BRDF LUT (DFG + LTC packed rgba8unorm).
7659
7781
  this.bakeBrdfLut()
@@ -7666,6 +7788,14 @@ export class Engine {
7666
7788
  usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
7667
7789
  })
7668
7790
  void this.loadAgxLut()
7791
+ this.stageGradeFallback = this.device.createTexture({
7792
+ label: "stage grade fallback",
7793
+ size: [1, 1, 1],
7794
+ dimension: "3d",
7795
+ format: "rgba8unorm-srgb",
7796
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
7797
+ })
7798
+ this.uploadStageGrade()
7669
7799
  this.bakeFilmicLut()
7670
7800
 
7671
7801
  // BEFORE the bind group below, which binds it. Full size from the start:
@@ -8100,6 +8230,9 @@ export class Engine {
8100
8230
  // AgX's 57³ cube. Decompressed and uploaded off the critical path, so a
8101
8231
  // 1×1×1 stand-in keeps the bind group valid until it arrives.
8102
8232
  { binding: 10, visibility: GPUShaderStage.FRAGMENT, texture: { viewDimension: "3d" } },
8233
+ // The scene's own grade cube (setStageGrade) — 1×1×1 stand-in while it
8234
+ // has none, which the flag bit keeps from ever being sampled.
8235
+ { binding: 12, visibility: GPUShaderStage.FRAGMENT, texture: { viewDimension: "3d" } },
8103
8236
  // The cast, for rzSubject/rzAnchor. Always bound so the base shader's
8104
8237
  // layout matches; the base shader simply never reads it.
8105
8238
  { binding: 11, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "read-only-storage" } },
@@ -9876,7 +10009,7 @@ export class Engine {
9876
10009
  private setupLighting() {
9877
10010
  this.lightUniformBuffer = this.device.createBuffer({
9878
10011
  label: "light uniforms",
9879
- size: 80 * 4, // ambient (4) + 4 lights x 2 vec4 (32) + irradiance SH 9 x vec4 (36), padded to 80
10012
+ 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)
9880
10013
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
9881
10014
  })
9882
10015
  this.lightData.fill(0)
@@ -9961,6 +10094,7 @@ export class Engine {
9961
10094
  { binding: 7, resource: this.shadowMapDepthViews[SHADOW_CASCADES.length - 1] },
9962
10095
  { binding: 8, resource: env },
9963
10096
  { binding: 9, resource: this.brdfLutView },
10097
+ { binding: 10, resource: this.castShadowView },
9964
10098
  ],
9965
10099
  })
9966
10100
  this.mirrorPerFrameBindGroup = this.device.createBindGroup({
@@ -9977,6 +10111,7 @@ export class Engine {
9977
10111
  { binding: 7, resource: this.shadowMapDepthViews[SHADOW_CASCADES.length - 1] },
9978
10112
  { binding: 8, resource: env },
9979
10113
  { binding: 9, resource: this.brdfLutView },
10114
+ { binding: 10, resource: this.castShadowView },
9980
10115
  ],
9981
10116
  })
9982
10117
  return true
@@ -10001,7 +10136,7 @@ export class Engine {
10001
10136
  // replaces the colour, strength applies to either).
10002
10137
  // An HDRI outranks a gradient outranks the flat colour, which is the order
10003
10138
  // of how much each one knows about the sky.
10004
- const sh = this.worldSH ?? this.worldGradientSH
10139
+ const sh = this.worldAmbientSH ?? this.worldSH ?? this.worldGradientSH
10005
10140
  for (let i = 0; i < 9; i++) {
10006
10141
  const b = 36 + i * 4
10007
10142
  if (sh) {
@@ -10044,6 +10179,115 @@ export class Engine {
10044
10179
  }
10045
10180
 
10046
10181
  /** Update the world environment (Blender: World Background). Ambient recomputes immediately. */
10182
+ /**
10183
+ * Distance fog on every lit surface, as a game on SimPipeline lays it: per
10184
+ * layer, t = saturate(f − (1 − f)·h) with f = saturate(depth·distance[0] +
10185
+ * distance[1]) and h = clamp((height[0] − y) / height[1], −1, 1), and the
10186
+ * colour pulled toward the layer's by (1 − t)·amount — the haze first, then
10187
+ * `dyn`, which X340 uses to darken toward black. Depth is along the view
10188
+ * axis and y the world height, both in this engine's units; colours linear.
10189
+ *
10190
+ * PER VERTEX, as the game computes it, and that is load-bearing rather than
10191
+ * a saving: a floor built of long triangles carries the fog of its far
10192
+ * corners into the near half, which is most of what X340's checker floor
10193
+ * shows of its haze. Emission-only graphs take none, as the game's effect
10194
+ * shaders take none. Null clears.
10195
+ */
10196
+ setSceneFog(fog: SceneFog | null): void {
10197
+ this.sceneFog = fog
10198
+ const u = this.lightData
10199
+ const layer = (at: number, l: SceneFogLayer | undefined | null) => {
10200
+ u[at] = l?.color.x ?? 0
10201
+ u[at + 1] = l?.color.y ?? 0
10202
+ u[at + 2] = l?.color.z ?? 0
10203
+ u[at + 3] = l ? Math.max(l.amount, 0) : 0
10204
+ u[at + 4] = l?.distance[0] ?? 0
10205
+ u[at + 5] = l?.distance[1] ?? 1
10206
+ u[at + 6] = l?.height[0] ?? 0
10207
+ u[at + 7] = l?.height[1] ?? 1
10208
+ }
10209
+ layer(72, fog)
10210
+ layer(80, fog?.dyn)
10211
+ this.updateLightBuffer()
10212
+ }
10213
+
10214
+ /**
10215
+ * The cast's shadow on a stage, as a game on SimPipeline lays its ground
10216
+ * shadow: the characters drawn into a map of their own from `direction` (the
10217
+ * way TO the light, this engine's axes), and every Unity-mode stage surface
10218
+ * multiplied toward `color` (linear) by amount × how much of the cast stands
10219
+ * in the way. Independent of the sun — X340's is dim moonlight, which left a
10220
+ * kneeling figure no shadow at all. Only stage materials read it; a scene
10221
+ * without a stage is untouched. Null turns it off.
10222
+ */
10223
+ setStageCastShadow(opts: StageCastShadow | null): void {
10224
+ this.castShadow = opts
10225
+ const u = this.lightData
10226
+ u[88] = opts?.color.x ?? 1
10227
+ u[89] = opts?.color.y ?? 1
10228
+ u[90] = opts?.color.z ?? 1
10229
+ u[91] = 0 // lit per frame by updateCastShadowVP, once there is a cast to fit
10230
+ this.updateLightBuffer()
10231
+ }
10232
+
10233
+ /** Fit the cast map to this frame's cast; false when there is nothing to draw. */
10234
+ private updateCastShadowVP(stage: boolean): boolean {
10235
+ const opts = this.castShadow
10236
+ let on = false
10237
+ if (opts && stage) {
10238
+ let minX = Infinity, minY = Infinity, minZ = Infinity, maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity
10239
+ for (const inst of this.modelInstances.values()) {
10240
+ if (inst.isStage || inst.isPlane || inst.isProp || !inst.model.visible) continue
10241
+ const s = this.castSphereScratch
10242
+ this.writeCullSphere(inst, s, 0)
10243
+ if (!(s[3] < 1e6)) continue
10244
+ minX = Math.min(minX, s[0] - s[3]); maxX = Math.max(maxX, s[0] + s[3])
10245
+ minY = Math.min(minY, s[1] - s[3]); maxY = Math.max(maxY, s[1] + s[3])
10246
+ minZ = Math.min(minZ, s[2] - s[3]); maxZ = Math.max(maxZ, s[2] + s[3])
10247
+ }
10248
+ if (minX <= maxX) {
10249
+ const c = new Vec3((minX + maxX) / 2, (minY + maxY) / 2, (minZ + maxZ) / 2)
10250
+ const r = Math.max(maxX - minX, maxY - minY, maxZ - minZ) / 2
10251
+ const d = new Vec3(opts.direction.x, opts.direction.y, opts.direction.z).normalize()
10252
+ // Wide enough for the shadow a low light throws, deep enough to reach
10253
+ // the floor under the figure from well above it.
10254
+ const half = r * 2.5
10255
+ const reach = r * 8
10256
+ const eye = new Vec3(c.x + d.x * reach, c.y + d.y * reach, c.z + d.z * reach)
10257
+ const up = Math.abs(d.y) > 0.99 ? new Vec3(0, 0, 1) : new Vec3(0, 1, 0)
10258
+ const vp = Mat4.orthographicLh(-half, half, -half, half, 1, reach * 2).multiply(Mat4.lookAt(eye, c, up)).values
10259
+ this.device.queue.writeBuffer(this.castShadowVPBuffer, 0, new Float32Array(vp))
10260
+ this.lightData.set(vp, 92)
10261
+ on = true
10262
+ }
10263
+ }
10264
+ const amount = on ? Math.max(opts!.amount, 0) : 0
10265
+ if (this.lightData[91] !== amount || on) {
10266
+ this.lightData[91] = amount
10267
+ this.device.queue.writeBuffer(this.lightUniformBuffer, 88 * 4, this.lightData, 88, 20)
10268
+ }
10269
+ return on
10270
+ }
10271
+
10272
+ /**
10273
+ * The world's DIFFUSE light, stated rather than fitted: nine RGB coefficients
10274
+ * (27 floats) in the form rzWorldAmbient evaluates — c0 + c1·y + c2·z + c3·x +
10275
+ * c4·xy + c5·yz + c6·(3z²−1) + c7·xz + c8·(x²−y²), in this engine's axes, the
10276
+ * value a white surface facing n takes. Null goes back to the SH fitted to
10277
+ * the world's picture or gradient.
10278
+ *
10279
+ * A game lights a room's surfaces from an ambient probe that is not its
10280
+ * reflection probe — X340's is a dim blue trilight while its reflections
10281
+ * carry every lamp the probe baked — and one picture cannot be both. With
10282
+ * this set, the picture answers reflections alone. World strength scales it
10283
+ * as it scales the fitted one.
10284
+ */
10285
+ setWorldAmbient(sh: ArrayLike<number> | null): void {
10286
+ if (sh && sh.length !== 27) throw new Error(`setWorldAmbient: ${sh.length} floats, not 27`)
10287
+ this.worldAmbientSH = sh ? Float32Array.from(sh) : null
10288
+ if (this.device && this.lightUniformBuffer) this.writeWorld()
10289
+ }
10290
+
10047
10291
  setWorld(options: WorldOptions): void {
10048
10292
  if (options.color) this.world.color = options.color
10049
10293
  if (options.strength !== undefined) this.world.strength = options.strength
@@ -10334,6 +10578,8 @@ export class Engine {
10334
10578
  this.canvas.removeEventListener("touchend", this.handleCanvasTouch)
10335
10579
  }
10336
10580
 
10581
+ this.stageGradeTexture?.destroy()
10582
+ this.stageGradeTexture = null
10337
10583
  this.overlayDepthTexture?.destroy()
10338
10584
  this.overlayDepthTexture = null
10339
10585
  this.overlayMsaaTexture?.destroy()
@@ -12953,6 +13199,17 @@ export class Engine {
12953
13199
  this.forEachInstance((inst) => this.drawInstanceShadow(shadow, inst, ci))
12954
13200
  return shadow.finish({ label: `shadow pass, cascade ${ci}` })
12955
13201
  })
13202
+ // The cast alone, for the shadow it lays on a stage.
13203
+ const cast = this.device.createRenderBundleEncoder({
13204
+ label: "stage cast shadow pass",
13205
+ colorFormats: [],
13206
+ depthStencilFormat: Engine.SHADOW_DEPTH_FORMAT,
13207
+ })
13208
+ cast.setPipeline(this.shadowDepthPipeline)
13209
+ this.forEachInstance((inst) => {
13210
+ if (!inst.isStage && !inst.isPlane && !inst.isProp) this.drawInstanceShadow(cast, inst, SHADOW_CASCADES.length)
13211
+ })
13212
+ this.castShadowBundle = cast.finish({ label: "stage cast shadow pass" })
12956
13213
  this.bundleRecords++
12957
13214
  }
12958
13215
 
@@ -13426,6 +13683,18 @@ export class Engine {
13426
13683
  ],
13427
13684
  }),
13428
13685
  )
13686
+ // …and one more past the cascades, for the cast's shadow on a stage.
13687
+ shadowBindGroups.push(
13688
+ this.device.createBindGroup({
13689
+ label: `${name}: shadow bind, stage cast shadow`,
13690
+ layout: this.shadowDepthPipeline.getBindGroupLayout(0),
13691
+ entries: [
13692
+ { binding: 0, resource: { buffer: this.castShadowVPBuffer } },
13693
+ { binding: 1, resource: { buffer: skinMatrixBuffer } },
13694
+ { binding: 2, resource: this.materialSampler },
13695
+ ],
13696
+ }),
13697
+ )
13429
13698
 
13430
13699
  const mainPerInstanceBindGroup = this.perInstanceBindGroup(name, skinMatrixBuffer, null)
13431
13700
 
@@ -15858,6 +16127,18 @@ export class Engine {
15858
16127
  sp.end()
15859
16128
  this.shadowCascadeCleared[ci] = !wanted
15860
16129
  }
16130
+ // The cast's shadow on a stage: drawn while a stage asks for it and has a
16131
+ // cast to shade it with; cleared once when it stops, then left alone.
16132
+ const castOn = this.updateCastShadowVP(stage)
16133
+ if (castOn || !this.castShadowCleared) {
16134
+ const cp = encoder.beginRenderPass({
16135
+ colorAttachments: [],
16136
+ depthStencilAttachment: { view: this.castShadowView, depthClearValue: 1.0, depthLoadOp: "clear", depthStoreOp: "store" },
16137
+ })
16138
+ if (castOn && this.castShadowBundle) cp.executeBundles([this.castShadowBundle])
16139
+ cp.end()
16140
+ this.castShadowCleared = !castOn
16141
+ }
15861
16142
  this.shadowMapPopulated = hasModels
15862
16143
  }
15863
16144
 
@@ -16995,29 +17276,41 @@ export class Engine {
16995
17276
  * each for a reason that would change pixels: EYE front-culls and gates on a
16996
17277
  * bone read, and pre-filled hair depth over the socket would depth-reject
16997
17278
  * the eye before it could write the stencil the see-through-hair pass needs
16998
- * — which is also why HAIR stays out entirely. HASHED alpha (stockings)
16999
- * discards by a position hash this pass does not run, so priming it would
17000
- * punch its cutout into the depth buffer at the wrong texels. They all still
17001
- * BENEFIT: their fragments early-z against the primed depth of whatever
17002
- * plain opaque surface sits in front of them.
17279
+ * — which is also why HAIR stays out entirely. They all still BENEFIT:
17280
+ * their fragments early-z against the primed depth of whatever plain opaque
17281
+ * surface sits in front of them.
17282
+ *
17283
+ * HASHED alpha primes through the SOLID pipeline (cutoff 1.0). Its colour
17284
+ * pass discards by a position hash this pass does not run, but that threshold
17285
+ * is clamped to at most 1, so a texel at alpha exactly 1 survives it always —
17286
+ * those are the only texels the solid prime claims, and it can never punch
17287
+ * the cutout into the wrong ones. A stage's foliage and railings are mostly
17288
+ * such texels, and without the prime every room surface behind them shaded
17289
+ * its full lamp walk and was then painted over.
17003
17290
  */
17004
17291
  private drawOpaqueDepthPrepass(
17005
17292
  pass: GPURenderPassEncoder | GPURenderBundleEncoder,
17006
17293
  inst: ModelInstance,
17007
17294
  view: { perFrame: GPUBindGroup; args: "camera" | "mirror"; outlines: boolean },
17008
17295
  ): void {
17009
- let bound = false
17296
+ let bound: GPURenderPipeline | null = null
17010
17297
  for (const draw of inst.drawCalls) {
17011
17298
  if (draw.type !== "opaque") continue
17299
+ let pipeline = this.depthPrepassPipeline
17012
17300
  if (draw.groupId) {
17013
17301
  const install = inst.styleGroups.get(draw.groupId)
17014
- if (install && !(install.renderClass === "auto" && install.alphaMode === "opaque")) continue
17302
+ if (install && install.renderClass !== "auto") continue
17303
+ if (install?.alphaMode === "hashed") pipeline = this.solidPrepassPipeline
17304
+ else if (install && install.alphaMode !== "opaque") continue
17015
17305
  }
17016
- if (!bound) {
17017
- pass.setPipeline(this.depthPrepassPipeline)
17018
- pass.setBindGroup(0, view.perFrame)
17019
- pass.setBindGroup(1, inst.mainPerInstanceBindGroup)
17020
- bound = true
17306
+ if (bound !== pipeline) {
17307
+ pass.setPipeline(pipeline)
17308
+ // One layout for both prepass pipelines, so groups 0 and 1 carry over.
17309
+ if (!bound) {
17310
+ pass.setBindGroup(0, view.perFrame)
17311
+ pass.setBindGroup(1, inst.mainPerInstanceBindGroup)
17312
+ }
17313
+ bound = pipeline
17021
17314
  }
17022
17315
  pass.setBindGroup(2, draw.bindGroup)
17023
17316
  this.issueDraw(pass, draw, view.args)
@@ -358,6 +358,9 @@ export function compileGraph(graph: ShaderGraph, opts: CompileOptions = {}): Com
358
358
  if (opacity) lines.push(` let final_opacity = saturate(${outputExpr(opacity, "float")}); // @node:${opacity.node}`)
359
359
 
360
360
  const fsBody = lines.join("\n")
361
- const wgsl = assembleModule(opts.renderClass ?? "auto", opts.alphaMode ?? "opaque", fsBody, usesStyle.current, !!opacity, opts.blend ?? "over")
361
+ // A graph takes light when its body reads any: the four shading nodes all
362
+ // pass `sun, amb`, and a hand-written node reaching for the lamps names them.
363
+ const takesLight = /\bsun\b|\bamb\b|rzLight|rzLamp/.test(fsBody)
364
+ const wgsl = assembleModule(opts.renderClass ?? "auto", opts.alphaMode ?? "opaque", fsBody, usesStyle.current, !!opacity, opts.blend ?? "over", takesLight)
362
365
  return { ok: true, wgsl, fsBody, slotMap, diagnostics, prunedNodes }
363
366
  }
@@ -837,6 +837,14 @@ export const NODE_REGISTRY: Record<string, NodeSpec> = {
837
837
  /** Ours, not Blender's: caps firefly speculars from noise-bumped NDF
838
838
  * aliasing, which EEVEE hides behind TAA and we have none. */
839
839
  spec_clamp: F(1e30),
840
+ /** Ours: how roughness picks the reflection's blur. 0 is the engine's
841
+ * sqrt ramp; 1 is Unity's probe curve, p·(1.7−0.7p) mip-steps per
842
+ * eighth, stopping where a cube's last face texel does — what a stage
843
+ * ripped from a Unity game was authored against. See rzWorldSpecularLod. */
844
+ reflection_lod: F(0),
845
+ /** Ours: 1 shades the sun's and the lamps' highlights with URP's
846
+ * direct-light BRDF, as a stage from a Unity game was lit — see bsdf_urp. */
847
+ unity_direct: F(0),
840
848
  },
841
849
  outputs: { color: "color" },
842
850
  emit: (a) => {
@@ -848,11 +856,15 @@ export const NODE_REGISTRY: Record<string, NodeSpec> = {
848
856
  const bsdf =
849
857
  `eval_principled(PrincipledIn(${a.base_color}, ${a.metallic}, ` +
850
858
  `${spec}, ${a.roughness}, ` +
851
- `${a.spec_clamp}, ${a.sheen_weight}, ${a.sheen_tint}), ${a.normal}, l, v, sun, amb, shadow, input.worldPos)`
859
+ `${a.spec_clamp}, ${a.sheen_weight}, ${a.sheen_tint}, ${a.reflection_lod}, ${a.unity_direct}), ${a.normal}, l, v, sun, amb, shadow, input.worldPos)`
852
860
  // v2 defaults Emission Strength to 0, which is the overwhelming case. Emit
853
861
  // the term only when it can do something, so the common shader carries no
854
862
  // dead add and the output stays readable.
855
- return a.emission_strength === "0.0" ? bsdf : `${bsdf} + ${a.emission_color} * ${a.emission_strength}`
863
+ const lit = a.emission_strength === "0.0" ? bsdf : `${bsdf} + ${a.emission_color} * ${a.emission_strength}`
864
+ // Unity mode also ends as SimPipeline's surfaces end: saturate()d, so no
865
+ // lit or glowing surface passes 1.0 before the post — its bloom works on
866
+ // what is left under that. A literal, as every stage graph states it.
867
+ return a.unity_direct === "1.0" ? `min(${lit}, vec3f(1.0))` : lit
856
868
  },
857
869
  },
858
870
  }
@@ -144,6 +144,9 @@ ${gate}
144
144
  let amb = rzWorldAmbient(n) + modelLight.fill.rgb;
145
145
  let shadow = sampleShadow(input.worldPos, n);
146
146
  let tex_color = tex_s.rgb;
147
+ // The normal the lamps' diffuse layer is lit by, for a principled walk to
148
+ // compute it on the way (rzLightsDiffuseOnce). n is final from here on.
149
+ _rzLampN = n;
147
150
 
148
151
  `
149
152
  }
@@ -151,7 +154,7 @@ ${gate}
151
154
  // Tail of fs(): consumes `final_color` + locals, writes FSOut. hashed forces output
152
155
  // alpha to 1 (the discard already did the cutout); hair scales alpha for the over-eyes
153
156
  // pass when IS_OVER_EYES is compiled true.
154
- function epilogue(renderClass: RenderClass, alphaMode: AlphaMode, hasOpacity: boolean, blend: StyleBlend): string {
157
+ function epilogue(renderClass: RenderClass, alphaMode: AlphaMode, hasOpacity: boolean, blend: StyleBlend, takesLight: boolean): string {
155
158
  // A graph that computes its own opacity wins, including over hashed: hashed
156
159
  // writes 1 because its discard already decided the cutout, and a graph asking
157
160
  // for a curve is asking for the opposite of a cutout. Additive writes 1 for a
@@ -178,7 +181,13 @@ function epilogue(renderClass: RenderClass, alphaMode: AlphaMode, hasOpacity: bo
178
181
  // so the fix has an address — when graphs gain an optional albedo output
179
182
  // (material-track era), it lands here and every light is corrected at once,
180
183
  // instead of a hunt through the epilogue's string templates.
181
- const LIT = ` + rzLightsDiffuse(input.worldPos, n) * albedo`
184
+ //
185
+ // ONLY ON A GRAPH THAT TAKES LIGHT. An emission-only graph — Unlit, a stage's
186
+ // painted sheet — is Blender's Emission shader, which no lamp reaches; adding
187
+ // the lamps to it turned X340's floor shadow, a white-RGB picture at a soft
188
+ // alpha, into a glowing disc under the spot above it.
189
+ const LIT = takesLight ? ` + rzLightsDiffuseOnce(input.worldPos, n) * albedo` : ""
190
+
182
191
  const ALBEDO = ` let albedo = tex_color;
183
192
  `
184
193
  // The dissolve's burning edge, ADDED after the graph and after the lights —
@@ -186,19 +195,24 @@ function epilogue(renderClass: RenderClass, alphaMode: AlphaMode, hasOpacity: bo
186
195
  // on every fragment of every material that is not dissolving, which is what
187
196
  // keeps this out of the way of a scene that never uses it.
188
197
  const BURN = ` + RZ_BURN_COLOR * rz_burn`
198
+ // The scene fog, last, as the game lays it over a lit surface; an
199
+ // emission-only graph takes none (the game's effect shaders have no fog).
200
+ const FOG = takesLight
201
+ ? ` 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`
202
+ : ""
189
203
  if (renderClass === "hair") {
190
204
  return `${ALBEDO} var outAlpha = ${alphaBase};
191
205
  if (IS_OVER_EYES) { outAlpha = ${alphaBase} * 0.25; }
192
206
 
193
207
  var out: FSOut;
194
208
  out.color = vec4f(final_color${LIT}${BURN}, outAlpha);
195
- out.mask = vec4f(1.0, 1.0, 0.0, out.color.a);
209
+ ${FOG} out.mask = vec4f(1.0, 1.0, 0.0, out.color.a);
196
210
  ${ID_WRITE} return out;
197
211
  `
198
212
  }
199
213
  return `${ALBEDO} var out: FSOut;
200
214
  out.color = vec4f(final_color${LIT}${BURN}, ${alphaBase});
201
- out.mask = vec4f(1.0, 1.0, 0.0, out.color.a);
215
+ ${FOG} out.mask = vec4f(1.0, 1.0, 0.0, out.color.a);
202
216
  ${ID_WRITE} return out;
203
217
  `
204
218
  }
@@ -215,6 +229,7 @@ export function assembleModule(
215
229
  includeStyleUniforms: boolean,
216
230
  hasOpacity = false,
217
231
  blend: StyleBlend = "over",
232
+ takesLight = true,
218
233
  ): string {
219
234
  return (
220
235
  NODES_WGSL +
@@ -228,7 +243,7 @@ export function assembleModule(
228
243
  prelude(renderClass, alphaMode, blend) +
229
244
  fsBody +
230
245
  "\n" +
231
- epilogue(renderClass, alphaMode, hasOpacity, blend) +
246
+ epilogue(renderClass, alphaMode, hasOpacity, blend, takesLight) +
232
247
  "}\n"
233
248
  )
234
249
  }
package/src/index.ts CHANGED
@@ -5,6 +5,10 @@ export {
5
5
  DEFAULT_VIEW_TRANSFORM,
6
6
  DEFAULT_COLOR_GRADING,
7
7
  type ColorGradingOptions,
8
+ type StageGradeLut,
9
+ type SceneFog,
10
+ type StageCastShadow,
11
+ type SceneFogLayer,
8
12
  type EngineStats,
9
13
  type EngineOptions,
10
14
  type BloomOptions,