reze-engine 0.61.4 → 0.61.5

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 (85) hide show
  1. package/dist/engine.d.ts +49 -2
  2. package/dist/engine.d.ts.map +1 -1
  3. package/dist/engine.js +232 -25
  4. package/dist/graph/presets/pool_floor.d.ts +3 -0
  5. package/dist/graph/presets/pool_floor.d.ts.map +1 -0
  6. package/dist/graph/presets/pool_floor.js +42 -0
  7. package/dist/graph/presets/water.d.ts +3 -0
  8. package/dist/graph/presets/water.d.ts.map +1 -0
  9. package/dist/graph/presets/water.js +31 -0
  10. package/dist/graph/registry.d.ts.map +1 -1
  11. package/dist/graph/registry.js +11 -0
  12. package/dist/graph/slots.js +2 -2
  13. package/dist/physics/autofit.d.ts +147 -0
  14. package/dist/physics/autofit.d.ts.map +1 -0
  15. package/dist/physics/autofit.js +501 -0
  16. package/dist/shaders/materials/common.js +5 -5
  17. package/dist/shaders/materials/nodes.d.ts +1 -1
  18. package/dist/shaders/materials/nodes.d.ts.map +1 -1
  19. package/dist/shaders/materials/nodes.js +12 -0
  20. package/dist/shaders/passes/field-blit.d.ts +26 -0
  21. package/dist/shaders/passes/field-blit.d.ts.map +1 -0
  22. package/dist/shaders/passes/field-blit.js +65 -0
  23. package/dist/shaders/passes/ground-noise.d.ts +7 -0
  24. package/dist/shaders/passes/ground-noise.d.ts.map +1 -0
  25. package/dist/shaders/passes/ground-noise.js +88 -0
  26. package/dist/shaders/passes/particles.js +6 -6
  27. package/dist/shaders/passes/scene-contract.js +1 -1
  28. package/dist/shaders/passes/sim.d.ts +34 -0
  29. package/dist/shaders/passes/sim.d.ts.map +1 -0
  30. package/dist/shaders/passes/sim.js +169 -0
  31. package/dist/shaders/passes/subsurface.d.ts +34 -0
  32. package/dist/shaders/passes/subsurface.d.ts.map +1 -0
  33. package/dist/shaders/passes/subsurface.js +134 -0
  34. package/dist/shaders/score-api.d.ts +10 -0
  35. package/dist/shaders/score-api.d.ts.map +1 -0
  36. package/dist/shaders/score-api.js +114 -0
  37. package/dist/shaders/shadow-api.d.ts +29 -0
  38. package/dist/shaders/shadow-api.d.ts.map +1 -0
  39. package/dist/shaders/shadow-api.js +112 -0
  40. package/package.json +1 -1
  41. package/src/engine.ts +242 -24
  42. package/src/graph/registry.ts +11 -0
  43. package/src/graph/slots.ts +2 -2
  44. package/src/shaders/materials/common.ts +5 -5
  45. package/src/shaders/materials/nodes.ts +12 -0
  46. package/src/shaders/passes/particles.ts +6 -6
  47. package/src/shaders/passes/scene-contract.ts +1 -1
  48. package/src/shaders/passes/subsurface.ts +136 -0
  49. package/dist/_dbg-loader.mjs +0 -1100
  50. package/dist/physics-debug.d.ts +0 -30
  51. package/dist/physics-debug.d.ts.map +0 -1
  52. package/dist/physics-debug.js +0 -526
  53. package/dist/shaders/materials/body.d.ts +0 -2
  54. package/dist/shaders/materials/body.d.ts.map +0 -1
  55. package/dist/shaders/materials/body.js +0 -95
  56. package/dist/shaders/materials/cloth_rough.d.ts +0 -2
  57. package/dist/shaders/materials/cloth_rough.d.ts.map +0 -1
  58. package/dist/shaders/materials/cloth_rough.js +0 -69
  59. package/dist/shaders/materials/cloth_smooth.d.ts +0 -2
  60. package/dist/shaders/materials/cloth_smooth.d.ts.map +0 -1
  61. package/dist/shaders/materials/cloth_smooth.js +0 -61
  62. package/dist/shaders/materials/default.d.ts +0 -2
  63. package/dist/shaders/materials/default.d.ts.map +0 -1
  64. package/dist/shaders/materials/default.js +0 -43
  65. package/dist/shaders/materials/eye.d.ts +0 -2
  66. package/dist/shaders/materials/eye.d.ts.map +0 -1
  67. package/dist/shaders/materials/eye.js +0 -60
  68. package/dist/shaders/materials/face.d.ts +0 -2
  69. package/dist/shaders/materials/face.d.ts.map +0 -1
  70. package/dist/shaders/materials/face.js +0 -95
  71. package/dist/shaders/materials/hair.d.ts +0 -2
  72. package/dist/shaders/materials/hair.d.ts.map +0 -1
  73. package/dist/shaders/materials/hair.js +0 -90
  74. package/dist/shaders/materials/metal.d.ts +0 -2
  75. package/dist/shaders/materials/metal.d.ts.map +0 -1
  76. package/dist/shaders/materials/metal.js +0 -77
  77. package/dist/shaders/materials/mmd_classic.d.ts +0 -2
  78. package/dist/shaders/materials/mmd_classic.d.ts.map +0 -1
  79. package/dist/shaders/materials/mmd_classic.js +0 -66
  80. package/dist/shaders/materials/stockings.d.ts +0 -2
  81. package/dist/shaders/materials/stockings.d.ts.map +0 -1
  82. package/dist/shaders/materials/stockings.js +0 -122
  83. package/dist/shaders/passes/physics-debug.d.ts +0 -2
  84. package/dist/shaders/passes/physics-debug.d.ts.map +0 -1
  85. package/dist/shaders/passes/physics-debug.js +0 -69
package/src/engine.ts CHANGED
@@ -112,6 +112,7 @@ import {
112
112
  EFFECT_TRAIL_BASE,
113
113
  EFFECT_TRAIL_SAMPLES,
114
114
  } from "./shaders/passes/composite"
115
+ import { SUBSURFACE_WGSL, SSS_SPIKE } from "./shaders/passes/subsurface"
115
116
  import {
116
117
  buildParticleComputeShader,
117
118
  buildParticleRenderShader,
@@ -761,6 +762,12 @@ interface DrawCall {
761
762
  /** Material draws only: false = excluded from the shadow map (PMX cast-shadow
762
763
  * flag off). Sheer texels are cut per fragment by the shadow pass's alpha test. */
763
764
  castsShadow?: boolean
765
+ /** The PMX author's double-sided flag (bit 0x01). Off means MMD draws the
766
+ * material's front faces only — an inner lining authored as a flipped copy
767
+ * of the outer layer is invisible from outside, which is what the author
768
+ * built it around. Media planes stay double-sided: they build their own
769
+ * index list and say so there. */
770
+ doubleSided: boolean
764
771
  /** Edge-flagged materials: interleaved inverted-hull outline drawn right after
765
772
  * this material with the outline pipeline. Shares this call's index range;
766
773
  * own bind group (edge uniforms + diffuse texture for the alpha test). */
@@ -2082,15 +2089,18 @@ export class Engine {
2082
2089
  /** Stencil value stamped by eye draws so hair can stencil-test against it and
2083
2090
  * alpha-blend a second pass over eye silhouette pixels (see-through-hair effect). */
2084
2091
  private static readonly STENCIL_EYE_VALUE = 1
2085
- /** Aux MRT alongside HDR color. Two channels:
2092
+ /** Aux MRT alongside HDR color. Three channels:
2086
2093
  * .r — bloom mask (1 = model geometry, 0 = ground; sampled by bloom blit to gate prefilter).
2087
2094
  * .g — accumulated alpha (the channel that used to live in hdr.a before the HDR format
2088
2095
  * switched to rg11b10ufloat, which has no alpha). Sampled by composite/bloom to
2089
2096
  * un-premultiply color for tonemap and to produce the canvas-drawable alpha used by
2090
2097
  * the premultiplied alphaMode compositor (so the page background still shows through
2091
2098
  * cleared / edge-faded regions like before).
2092
- * rg8unorm at 4× MSAA is 8 bytes/texel — still fits Apple TBDR tile memory comfortably. */
2093
- private static readonly BLOOM_MASK_FORMAT: GPUTextureFormat = "rg8unorm"
2099
+ * .b — subsurface strength, set by a graph's `subsurface` node and 0 everywhere
2100
+ * else; the scattering pass reads it to find skin (passes/subsurface.ts).
2101
+ * rgba8unorm at 4× MSAA is 16 bytes/texel; it never leaves tile memory (storeOp
2102
+ * discard), only its single-sample resolve does. */
2103
+ private static readonly BLOOM_MASK_FORMAT: GPUTextureFormat = "rgba8unorm"
2094
2104
  /**
2095
2105
  * The master switch for the id attachment. OFF — and off having been proven
2096
2106
  * to work, not off because it was never finished.
@@ -2633,6 +2643,22 @@ export class Engine {
2633
2643
  // composite adds bloomUp mip 0 × (color × intensity) to HDR before Filmic.
2634
2644
  // Matches EEVEE energy: tint/intensity applied at composite, not prefilter.
2635
2645
  private bloomSampler!: GPUSampler
2646
+ // Screen-space subsurface scattering (passes/subsurface.ts). The scratch target
2647
+ // is the X pass's output and lives with the canvas size; the bind groups name
2648
+ // it and the HDR resolve, so a resize drops them and the next frame rebuilds.
2649
+ private sssPipelineX: GPURenderPipeline | null = null
2650
+ private sssPipelineY: GPURenderPipeline | null = null
2651
+ private sssScratch: GPUTexture | null = null
2652
+ private sssBindGroupX: GPUBindGroup | null = null
2653
+ private sssBindGroupY: GPUBindGroup | null = null
2654
+ private sssUniformX: GPUBuffer | null = null
2655
+ private sssUniformY: GPUBuffer | null = null
2656
+ private readonly sssUniformData = new Float32Array(8)
2657
+ // Model pipelines' descriptors, and the single-sided twins built from them —
2658
+ // one per pipeline per view (see sidedPipeline).
2659
+ private readonly pipelineDescs = new WeakMap<GPURenderPipeline, GPURenderPipelineDescriptor>()
2660
+ private readonly singleSided = new WeakMap<GPURenderPipeline, GPURenderPipeline>()
2661
+ private readonly singleSidedMirror = new WeakMap<GPURenderPipeline, GPURenderPipeline>()
2636
2662
  private bloomBlitUniformBuffer!: GPUBuffer
2637
2663
  private bloomUpsampleUniformBuffer!: GPUBuffer
2638
2664
  private readonly bloomBlitUniformData = new Float32Array(4)
@@ -6143,6 +6169,134 @@ export class Engine {
6143
6169
  /** The user's field mounts, drawn at half resolution for the composite to
6144
6170
  * upsample. Runs the whole quad — uniform control flow, so effects may use
6145
6171
  * derivatives freely, which the old inline path had to forbid. */
6172
+ /** Whether any installed style group routes through a `subsurface` node —
6173
+ * the scattering pass, and the depth it reads, cost nothing otherwise. */
6174
+ private subsurfaceInUse(): boolean {
6175
+ for (const inst of this.modelInstances.values()) {
6176
+ for (const g of inst.styleGroups.values()) {
6177
+ if (g.group.materials.length && g.group.graph.nodes.some((n) => n.type === "subsurface")) return true
6178
+ }
6179
+ }
6180
+ return false
6181
+ }
6182
+
6183
+ /**
6184
+ * ray-mmd's skin scattering: a horizontal then a vertical depth-aware blur of
6185
+ * the pixels the scene pass marked as skin (aux .b), the second written back
6186
+ * into the HDR resolve through a blend that keeps ray-mmd's per-channel share
6187
+ * of the unblurred picture. See passes/subsurface.ts.
6188
+ */
6189
+ private renderSubsurface(encoder: GPUCommandEncoder): void {
6190
+ if (!this.device || !this.subsurfaceInUse()) return
6191
+ if (!this.hdrResolveTexture || !this.maskResolveView || !this.depthReadView || !this.dofUniformBuffer) return
6192
+ const w = this.hdrResolveTexture.width
6193
+ const h = this.hdrResolveTexture.height
6194
+
6195
+ if (!this.sssPipelineX || !this.sssPipelineY) {
6196
+ const module = this.device.createShaderModule({ label: "subsurface scattering", code: SUBSURFACE_WGSL })
6197
+ this.sssPipelineX = this.device.createRenderPipeline({
6198
+ label: "subsurface blur x",
6199
+ layout: "auto",
6200
+ vertex: { module, entryPoint: "vs" },
6201
+ fragment: { module, entryPoint: "fsX", targets: [{ format: this.hdrFormat }] },
6202
+ primitive: { topology: "triangle-list" },
6203
+ })
6204
+ // result = blurred·(1 − spike) + original·spike, per channel: the
6205
+ // destination is the original, and the blend constant holds the spike.
6206
+ this.sssPipelineY = this.device.createRenderPipeline({
6207
+ label: "subsurface blur y",
6208
+ layout: "auto",
6209
+ vertex: { module, entryPoint: "vs" },
6210
+ fragment: {
6211
+ module,
6212
+ entryPoint: "fsY",
6213
+ targets: [
6214
+ {
6215
+ format: this.hdrFormat,
6216
+ blend: {
6217
+ color: { srcFactor: "one-minus-constant", dstFactor: "constant", operation: "add" },
6218
+ alpha: { srcFactor: "zero", dstFactor: "one", operation: "add" },
6219
+ },
6220
+ },
6221
+ ],
6222
+ },
6223
+ primitive: { topology: "triangle-list" },
6224
+ })
6225
+ this.sssBindGroupX = null
6226
+ this.sssBindGroupY = null
6227
+ }
6228
+ if (!this.sssUniformX || !this.sssUniformY) {
6229
+ const make = (label: string) =>
6230
+ this.device.createBuffer({ label, size: 32, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST })
6231
+ this.sssUniformX = make("subsurface uniforms x")
6232
+ this.sssUniformY = make("subsurface uniforms y")
6233
+ }
6234
+ if (!this.sssScratch || this.sssScratch.width !== w || this.sssScratch.height !== h) {
6235
+ this.sssScratch?.destroy()
6236
+ this.sssScratch = this.device.createTexture({
6237
+ label: "subsurface scratch",
6238
+ size: [w, h],
6239
+ format: this.hdrFormat,
6240
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
6241
+ })
6242
+ this.sssBindGroupX = null
6243
+ this.sssBindGroupY = null
6244
+ }
6245
+ if (!this.sssBindGroupX || !this.sssBindGroupY) {
6246
+ const mask = this.maskResolveView
6247
+ const depth = this.depthReadView
6248
+ const dof = this.dofUniformBuffer
6249
+ const group = (pipeline: GPURenderPipeline, src: GPUTexture, uniforms: GPUBuffer, label: string) =>
6250
+ this.device.createBindGroup({
6251
+ label,
6252
+ layout: pipeline.getBindGroupLayout(0),
6253
+ entries: [
6254
+ { binding: 0, resource: src.createView() },
6255
+ { binding: 1, resource: mask },
6256
+ { binding: 2, resource: depth },
6257
+ { binding: 3, resource: { buffer: dof } },
6258
+ { binding: 4, resource: { buffer: uniforms } },
6259
+ ],
6260
+ })
6261
+ this.sssBindGroupX = group(this.sssPipelineX, this.hdrResolveTexture, this.sssUniformX, "subsurface x")
6262
+ this.sssBindGroupY = group(this.sssPipelineY, this.sssScratch, this.sssUniformY, "subsurface y")
6263
+ }
6264
+
6265
+ const tanY = Math.tan(this.camera.fov * 0.5)
6266
+ const u = this.sssUniformData
6267
+ u[2] = 0
6268
+ u[3] = 0
6269
+ u[4] = tanY * this.camera.aspect
6270
+ u[5] = tanY
6271
+ u[6] = w
6272
+ u[7] = h
6273
+ u[0] = 1
6274
+ u[1] = 0
6275
+ this.device.queue.writeBuffer(this.sssUniformX, 0, u)
6276
+ u[0] = 0
6277
+ u[1] = 1
6278
+ this.device.queue.writeBuffer(this.sssUniformY, 0, u)
6279
+
6280
+ const px = encoder.beginRenderPass({
6281
+ label: "subsurface blur x",
6282
+ colorAttachments: [{ view: this.sssScratch.createView(), loadOp: "clear", clearValue: [0, 0, 0, 0], storeOp: "store" }],
6283
+ })
6284
+ px.setPipeline(this.sssPipelineX)
6285
+ px.setBindGroup(0, this.sssBindGroupX)
6286
+ px.draw(3)
6287
+ px.end()
6288
+
6289
+ const py = encoder.beginRenderPass({
6290
+ label: "subsurface blur y",
6291
+ colorAttachments: [{ view: this.hdrResolveTexture.createView(), loadOp: "load", storeOp: "store" }],
6292
+ })
6293
+ py.setPipeline(this.sssPipelineY)
6294
+ py.setBindGroup(0, this.sssBindGroupY)
6295
+ py.setBlendConstant({ r: SSS_SPIKE[0], g: SSS_SPIKE[1], b: SSS_SPIKE[2], a: 0 })
6296
+ py.draw(3)
6297
+ py.end()
6298
+ }
6299
+
6146
6300
  private renderFieldPass(encoder: GPUCommandEncoder): void {
6147
6301
  // TWO PREDICATES, deliberately, and they are not interchangeable.
6148
6302
  //
@@ -7210,7 +7364,7 @@ export class Engine {
7210
7364
  multisample?: GPUMultisampleState
7211
7365
  }): GPURenderPipeline {
7212
7366
  const targets = config.fragmentTargets ?? (config.fragmentTarget ? [config.fragmentTarget] : undefined)
7213
- return this.device.createRenderPipeline({
7367
+ return this.createModelPipeline({
7214
7368
  label: config.label,
7215
7369
  layout: config.layout,
7216
7370
  vertex: {
@@ -7653,7 +7807,7 @@ export class Engine {
7653
7807
  depthCompare: this.depthAhead,
7654
7808
  },
7655
7809
  }
7656
- this.depthPrepassPipeline = this.device.createRenderPipeline({
7810
+ this.depthPrepassPipeline = this.createModelPipeline({
7657
7811
  label: "opaque depth prepass",
7658
7812
  ...prepassDesc,
7659
7813
  fragment: {
@@ -7665,7 +7819,7 @@ export class Engine {
7665
7819
  // The SOLID prime: same module, cutoff forced to exactly 1.0. Only texels
7666
7820
  // whose blend ignores the destination may pre-claim depth in the
7667
7821
  // transparent phase — see the override's note in depth-prepass.ts.
7668
- this.solidPrepassPipeline = this.device.createRenderPipeline({
7822
+ this.solidPrepassPipeline = this.createModelPipeline({
7669
7823
  label: "transparent solid prepass",
7670
7824
  ...prepassDesc,
7671
7825
  fragment: {
@@ -7681,7 +7835,7 @@ export class Engine {
7681
7835
  // hair depth from ever claiming the pixels the see-through-hair pass needs
7682
7836
  // the eye to survive on. (Bundle draws use the PASS's stencil reference;
7683
7837
  // only pipeline/bind/vertex state resets across executeBundles.)
7684
- this.hairPrimePipeline = this.device.createRenderPipeline({
7838
+ this.hairPrimePipeline = this.createModelPipeline({
7685
7839
  label: "hair depth prime",
7686
7840
  ...prepassDesc,
7687
7841
  depthStencil: {
@@ -8553,6 +8707,10 @@ export class Engine {
8553
8707
  usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
8554
8708
  })
8555
8709
  this.maskResolveView = this.maskResolveTexture.createView()
8710
+ this.sssScratch?.destroy()
8711
+ this.sssScratch = null
8712
+ this.sssBindGroupX = null
8713
+ this.sssBindGroupY = null
8556
8714
 
8557
8715
  // The floor mirror's targets — FULL resolution, the same attachment
8558
8716
  // contract and sample count as the scene pass, which is what lets the
@@ -10403,6 +10561,8 @@ export class Engine {
10403
10561
  bindGroup: this.groundShadowBindGroup!,
10404
10562
  materialName: "Ground",
10405
10563
  groupId: null,
10564
+ // The ground's own pipeline decides its culling.
10565
+ doubleSided: true,
10406
10566
  // The ground belongs to no model instance, so it is not in the cull list —
10407
10567
  // cullIndex -1 leaves renderGround unconditional. Its box is filled in
10408
10568
  // anyway rather than left a lie for whoever reads this next.
@@ -14479,6 +14639,7 @@ export class Engine {
14479
14639
  groupId: null,
14480
14640
  baseBindGroupEntries,
14481
14641
  castsShadow,
14642
+ doubleSided: inst.isPlane || (mat.edgeFlag & 0x01) !== 0,
14482
14643
  outline,
14483
14644
  bounds,
14484
14645
  cullIndex: -1,
@@ -16063,7 +16224,10 @@ export class Engine {
16063
16224
  // The two real readers are both in the composite and both have their own
16064
16225
  // flag above: linearDepth() feeds the DoF gather and the depth handed to a
16065
16226
  // foreground mount. Nothing else binds depthTex at all.
16066
- const depthRead = dofOn || this.effects.some((e) => e.hasForeground)
16227
+ // The scattering pass is the third reader: it rejects taps across a depth
16228
+ // step and scales its width by distance, so a scene wearing a subsurface
16229
+ // look stores depth for as long as it does.
16230
+ const depthRead = dofOn || this.effects.some((e) => e.hasForeground) || this.subsurfaceInUse()
16067
16231
  this.renderPassDescriptor.depthStencilAttachment!.depthStoreOp = depthRead ? "store" : "discard"
16068
16232
  if (depthRead) this.writeDepthOfFieldUniforms()
16069
16233
 
@@ -16261,6 +16425,9 @@ export class Engine {
16261
16425
  // kept ribbons out of bloom.
16262
16426
  this.drawTrails(pass, "camera")
16263
16427
  pass.end()
16428
+ // Skin scattering, on the resolved scene and before anything reads it:
16429
+ // bloom should bleed the scattered skin, not the raw one.
16430
+ this.renderSubsurface(encoder)
16264
16431
  // The field mounts, likewise: after the scene so foregrounds can read its
16265
16432
  // depth, before the composite that samples both layers.
16266
16433
  this.renderFieldPass(encoder)
@@ -17081,13 +17248,55 @@ export class Engine {
17081
17248
  stencilWriteMask: 0xff,
17082
17249
  }
17083
17250
  }
17084
- return this.device.createRenderPipelineAsync({
17251
+ const desc: GPURenderPipelineDescriptor = {
17085
17252
  ...base,
17086
17253
  fragment: { module, constants, targets: blend === "additive" ? this.sceneTargetsAdditive : this.sceneTargets },
17087
17254
  depthStencil,
17255
+ }
17256
+ return this.device.createRenderPipelineAsync(desc).then((pipeline) => {
17257
+ this.pipelineDescs.set(pipeline, desc)
17258
+ return pipeline
17088
17259
  })
17089
17260
  }
17090
17261
 
17262
+ /** A pipeline whose descriptor is kept, so a single-sided twin can be built
17263
+ * from it on demand (see sidedPipeline). */
17264
+ private createModelPipeline(desc: GPURenderPipelineDescriptor): GPURenderPipeline {
17265
+ const pipeline = this.device.createRenderPipeline(desc)
17266
+ this.pipelineDescs.set(pipeline, desc)
17267
+ return pipeline
17268
+ }
17269
+
17270
+ /**
17271
+ * The pipeline a draw should use once its double-sided flag is honoured.
17272
+ *
17273
+ * A single-sided material culls its back faces, as MMD does. PMX winds a front
17274
+ * face clockwise, which this engine sees as a BACK face — the eye's front cull
17275
+ * and the outline hull's back cull are the same fact — so the cull is "front"
17276
+ * for the camera and "back" in the mirror, whose reflection flips winding.
17277
+ *
17278
+ * Every pipeline that draws a material's surface goes through here, depth
17279
+ * primes included: a prime that kept a culled face would write depth for a
17280
+ * surface nobody sees and hide what is behind it. A pipeline that already
17281
+ * culls (the eye) is returned as it is.
17282
+ */
17283
+ private sidedPipeline(pipeline: GPURenderPipeline, draw: DrawCall, mirrored: boolean): GPURenderPipeline {
17284
+ if (draw.doubleSided) return pipeline
17285
+ const desc = this.pipelineDescs.get(pipeline)
17286
+ if (!desc || (desc.primitive?.cullMode ?? "none") !== "none") return pipeline
17287
+ const cache = mirrored ? this.singleSidedMirror : this.singleSided
17288
+ let twin = cache.get(pipeline)
17289
+ if (!twin) {
17290
+ twin = this.device.createRenderPipeline({
17291
+ ...desc,
17292
+ label: `${desc.label ?? "pipeline"} (single-sided${mirrored ? ", mirror" : ""})`,
17293
+ primitive: { ...desc.primitive, cullMode: mirrored ? "back" : "front" },
17294
+ })
17295
+ cache.set(pipeline, twin)
17296
+ }
17297
+ return twin
17298
+ }
17299
+
17091
17300
  // Pipeline for a material draw call: its group's compiled pipeline when grouped, else
17092
17301
  // the neutral base (ungrouped materials render the default graph). Transparent-bucket
17093
17302
  // draws on the CAST use the SAME depth-write-on pipeline — babylon-mmd's
@@ -17145,7 +17354,8 @@ export class Engine {
17145
17354
  pass.setBindGroup(1, inst.mainPerInstanceBindGroup)
17146
17355
  bound = true
17147
17356
  }
17148
- const pipeline = this.pipelineForDrawCall(inst, draw, view.args === "mirror")
17357
+ const mirrored = view.args === "mirror"
17358
+ const pipeline = this.sidedPipeline(this.pipelineForDrawCall(inst, draw, mirrored), draw, mirrored)
17149
17359
  if (pipeline !== currentPipeline) {
17150
17360
  pass.setPipeline(pipeline)
17151
17361
  currentPipeline = pipeline
@@ -17268,14 +17478,17 @@ export class Engine {
17268
17478
  inst: ModelInstance,
17269
17479
  view: { perFrame: GPUBindGroup; args: "camera" | "mirror"; outlines: boolean },
17270
17480
  ): void {
17271
- let bound = false
17481
+ let bound: GPURenderPipeline | null = null
17272
17482
  for (const draw of inst.drawCalls) {
17273
17483
  if (draw.type !== "opaque" || !this.isHairDraw(inst, draw)) continue
17274
- if (!bound) {
17275
- pass.setPipeline(this.hairPrimePipeline)
17276
- pass.setBindGroup(0, view.perFrame)
17277
- pass.setBindGroup(1, inst.mainPerInstanceBindGroup)
17278
- bound = true
17484
+ const pipeline = this.sidedPipeline(this.hairPrimePipeline, draw, view.args === "mirror")
17485
+ if (bound !== pipeline) {
17486
+ pass.setPipeline(pipeline)
17487
+ if (!bound) {
17488
+ pass.setBindGroup(0, view.perFrame)
17489
+ pass.setBindGroup(1, inst.mainPerInstanceBindGroup)
17490
+ }
17491
+ bound = pipeline
17279
17492
  }
17280
17493
  pass.setBindGroup(2, draw.bindGroup)
17281
17494
  this.issueDraw(pass, draw, view.args)
@@ -17338,6 +17551,7 @@ export class Engine {
17338
17551
  if (install?.alphaMode === "hashed") pipeline = this.solidPrepassPipeline
17339
17552
  else if (install && install.alphaMode !== "opaque") continue
17340
17553
  }
17554
+ pipeline = this.sidedPipeline(pipeline, draw, view.args === "mirror")
17341
17555
  if (bound !== pipeline) {
17342
17556
  pass.setPipeline(pipeline)
17343
17557
  // One layout for both prepass pipelines, so groups 0 and 1 carry over.
@@ -17375,14 +17589,17 @@ export class Engine {
17375
17589
  view: { perFrame: GPUBindGroup; args: "camera" | "mirror"; outlines: boolean },
17376
17590
  ): void {
17377
17591
  if (inst.isStage) return
17378
- let bound = false
17592
+ let bound: GPURenderPipeline | null = null
17379
17593
  for (const draw of inst.drawCalls) {
17380
17594
  if (draw.type !== "transparent") continue
17381
- if (!bound) {
17382
- pass.setPipeline(this.solidPrepassPipeline)
17383
- pass.setBindGroup(0, view.perFrame)
17384
- pass.setBindGroup(1, inst.mainPerInstanceBindGroup)
17385
- bound = true
17595
+ const pipeline = this.sidedPipeline(this.solidPrepassPipeline, draw, view.args === "mirror")
17596
+ if (bound !== pipeline) {
17597
+ pass.setPipeline(pipeline)
17598
+ if (!bound) {
17599
+ pass.setBindGroup(0, view.perFrame)
17600
+ pass.setBindGroup(1, inst.mainPerInstanceBindGroup)
17601
+ }
17602
+ bound = pipeline
17386
17603
  }
17387
17604
  pass.setBindGroup(2, draw.bindGroup)
17388
17605
  this.issueDraw(pass, draw, view.args)
@@ -17442,8 +17659,9 @@ export class Engine {
17442
17659
  let currentPipeline: GPURenderPipeline | null = null
17443
17660
  for (const draw of inst.drawCalls) {
17444
17661
  if (draw.type !== "opaque") continue
17445
- const overEyes = this.overEyesPipelineFor(inst, draw)
17446
- if (!overEyes) continue
17662
+ const hairOverEyes = this.overEyesPipelineFor(inst, draw)
17663
+ if (!hairOverEyes) continue
17664
+ const overEyes = this.sidedPipeline(hairOverEyes, draw, view.args === "mirror")
17447
17665
  if (!bound) {
17448
17666
  pass.setBindGroup(0, view.perFrame)
17449
17667
  pass.setBindGroup(1, inst.mainPerInstanceBindGroup)
@@ -724,6 +724,17 @@ export const NODE_REGISTRY: Record<string, NodeSpec> = {
724
724
  outputs: { value: "float" },
725
725
  emit: () => `shader_to_rgb_diffuse(n, l, sun, amb, shadow)`,
726
726
  },
727
+ /**
728
+ * Hands this surface to the screen-space scattering pass: the colour passes
729
+ * through, and the pixel is blurred afterwards the way ray-mmd blurs skin —
730
+ * red furthest, never across a depth step. Strength scales the width; 1 is
731
+ * ray-mmd's skin, up to 4. It has to lie on the path to the output to take effect.
732
+ */
733
+ subsurface: {
734
+ inputs: { color: C([1, 1, 1], true), strength: F(1) },
735
+ outputs: { color: "color" },
736
+ emit: (a) => `rz_subsurface(${a.strength}, ${a.color})`,
737
+ },
727
738
 
728
739
  /**
729
740
  * The same closure as a COLOUR, which is what Blender's Shader to RGB
@@ -220,13 +220,13 @@ function epilogue(renderClass: RenderClass, alphaMode: AlphaMode, hasOpacity: bo
220
220
 
221
221
  var out: FSOut;
222
222
  out.color = vec4f(final_color${LIT}${BURN}, outAlpha);
223
- ${FOG} out.mask = vec4f(1.0, 1.0, 0.0, out.color.a);
223
+ ${FOG} out.mask = vec4f(1.0, 1.0, _rzSubsurface, out.color.a);
224
224
  ${ID_WRITE} return out;
225
225
  `
226
226
  }
227
227
  return `${ALBEDO} var out: FSOut;
228
228
  out.color = vec4f(final_color${LIT}${BURN}, ${alphaBase});
229
- ${FOG} out.mask = vec4f(1.0, 1.0, 0.0, out.color.a);
229
+ ${FOG} out.mask = vec4f(1.0, 1.0, _rzSubsurface, out.color.a);
230
230
  ${ID_WRITE} return out;
231
231
  `
232
232
  }
@@ -330,17 +330,17 @@ const COMMON_VS_WGSL = /* wgsl */ `
330
330
  // Location 0: final radiance+alpha (blended into rg11b10ufloat; the HDR target
331
331
  // has no alpha channel, but the blend equation still uses the .a you write here
332
332
  // as the src-alpha factor that premultiplies rgb into the HDR target).
333
- // Location 1: auxiliary rg8unorm carrying
333
+ // Location 1: auxiliary rgba8unorm carrying
334
334
  // .r = bloom mask (1 = contributes to bloom, 0 = skip — e.g. ground).
335
335
  // .g = accumulated canvas alpha — the channel that used to live in hdr.a
336
336
  // before the switch to rg11b10ufloat. Sampled by composite to
337
337
  // un-premultiply color for tonemap and to set the final drawable alpha
338
338
  // (needed for the `premultiplied` canvas alphaMode that blends the
339
339
  // WebGPU surface over the page background).
340
- // FS output at location 1 must be vec4f — the blend state references src.a, and
341
- // WebGPU requires the fragment output to provide an alpha component even though
342
- // the rg8unorm target only stores .r and .g (extra components are discarded).
343
- // Materials write mask = vec4f(1.0, 1.0, 0.0, color.a); ground writes
340
+ // .b = subsurface strength ÷ 4 — what a graph's `subsurface` node set, 0 for
341
+ // everything else. Read by the scattering pass (passes/subsurface.ts).
342
+ // FS output at location 1 must be vec4f — the blend state references src.a.
343
+ // Materials write mask = vec4f(1.0, 1.0, _rzSubsurface, color.a); ground writes
344
344
  // vec4f(0.0, 1.0, 0.0, edgeFade). With src.a coming from the 4th component and
345
345
  // src-alpha blending enabled:
346
346
  // out.r = mask_r · src.a + dst.r · (1-src.a) (bloom mask, weighted by alpha)
@@ -654,6 +654,18 @@ fn shader_to_rgb_diffuse(n: vec3f, l: vec3f, sun_rgb: vec3f, ambient_rgb: vec3f,
654
654
  return luminance_rec709_linear(shader_to_rgb_lit(n, l, sun_rgb, ambient_rgb, shadow));
655
655
  }
656
656
 
657
+ // ─── SUBSURFACE node ────────────────────────────────────────────────
658
+ // Marks this fragment for the screen-space scattering pass (passes/subsurface.ts)
659
+ // and passes its colour through. The strength lands in the aux target's .b via
660
+ // the epilogue, stored as a quarter so an 8-bit channel carries 0–4: 1 is
661
+ // ray-mmd's skin width, 0 leaves the pixel out of the pass.
662
+ var<private> _rzSubsurface: f32 = 0.0;
663
+
664
+ fn rz_subsurface(strength: f32, color: vec3f) -> vec3f {
665
+ _rzSubsurface = clamp(strength, 0.0, 4.0) * 0.25;
666
+ return color;
667
+ }
668
+
657
669
  // ─── BUMP node ──────────────────────────────────────────────────────
658
670
  // Screen-space bump from a scalar height field. Needs dFdx/dFdy which
659
671
  // WGSL provides as dpdx/dpdy.
@@ -391,13 +391,13 @@ struct FSOut {
391
391
  // writes it; a blended one writes all four, which is what not declaring it
392
392
  // would have meant.
393
393
  @builtin(sample_mask) samples: u32,
394
- // The scene's aux target: (bloom mask, coverage). Materials write it, so a
395
- // particle that skipped it would punch a hole in the mask of whatever it drew
396
- // over.
394
+ // The scene's aux target: (bloom mask, coverage, subsurface). Materials write
395
+ // it, so a particle that skipped it would punch a hole in the mask of whatever
396
+ // it drew over. A particle is never skin, so its .b is 0.
397
397
  //
398
- // vec4f even though the target is rg8unorm and only .rg land: that target's
399
- // blend factors reference SrcAlpha, and a fragment with no alpha channel is
400
- // rejected outright — "reading alpha but it is missing from fragment output".
398
+ // vec4f because that target's blend factors reference SrcAlpha, and a fragment
399
+ // with no alpha channel is rejected outright — "reading alpha but it is
400
+ // missing from fragment output".
401
401
  // The material shaders declare vec4f here for the same reason.
402
402
  @location(1) mask: vec4f,
403
403
  // Declared whenever the pass carries the attachment, and padded rather than
@@ -127,7 +127,7 @@ const ADD_KEEP_ALPHA: GPUBlendState = {
127
127
  alpha: { srcFactor: "zero", dstFactor: "one", operation: "add" },
128
128
  }
129
129
 
130
- /** Both channels sum. rg8unorm clamps at 1, which is the saturation alpha-over
130
+ /** Both channels sum. rgba8unorm clamps at 1, which is the saturation alpha-over
131
131
  * would have reached anyway. */
132
132
  const ADD_BOTH: GPUBlendState = {
133
133
  color: { srcFactor: "one", dstFactor: "one", operation: "add" },
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Screen-space subsurface scattering — ray-mmd's skin pass (PostProcessScattering.fxsub,
3
+ * SSSGaussBlurPS), ported constant for constant.
4
+ *
5
+ * A skin surface is not shaded by the light at its own pixel alone: light that
6
+ * enters a little way off comes back out here, red furthest. ray-mmd models that
7
+ * as a separable blur of the lit skin in screen space, one axis per pass, with a
8
+ * per-channel Gaussian (red widest) and a depth reject so light never crosses from
9
+ * a forearm onto the body behind it. The width is a length ON THE SURFACE, so it
10
+ * is divided by depth, and it narrows where the surface turns away from the view.
11
+ * Then a share of the unblurred picture is laid back over it — the "spike" that
12
+ * keeps pores, painted lines and the terminator from dissolving.
13
+ *
14
+ * What marks a pixel as skin is the scene pass's aux .b, written by the graph's
15
+ * `subsurface` node (0 everywhere else), as a quarter of its strength. The strength
16
+ * scales the radius, as ray-mmd's sssStrength does; 1 is ray-mmd's skin, 4 the most.
17
+ *
18
+ * One departure, and it is a routing one: ray-mmd blurs the lit colour BEFORE the
19
+ * albedo multiply and adds specular after. This engine resolves one HDR colour per
20
+ * pixel, so the blur runs over the finished colour. At this radius — a few pixels,
21
+ * with the spike keeping 14–20% of the original — the texture is not visibly
22
+ * softened; the lighting is.
23
+ *
24
+ * Two entry points share the module: X reads the HDR resolve and writes the
25
+ * scratch target, Y reads the scratch and writes back INTO the HDR resolve. The
26
+ * spike mix (lerp toward the original) is done by the Y pipeline's blend state,
27
+ * with the blend constant holding ray-mmd's per-channel spike: the destination IS
28
+ * the original, so no third copy of the frame is needed.
29
+ */
30
+
31
+ /** ray-mmd, skin profile (index 1): profileSpikeRadArr[1].xyz × (1 − sssAmount),
32
+ * with sssAmount = customA = 0.6 in Materials/Skin/material_skin.fx. */
33
+ export const SSS_SPIKE: [number, number, number] = [0.35 * 0.4, 0.4 * 0.4, 0.5 * 0.4]
34
+
35
+ export const SUBSURFACE_WGSL = /* wgsl */ `
36
+ @group(0) @binding(0) var srcTex: texture_2d<f32>;
37
+ @group(0) @binding(1) var maskTex: texture_2d<f32>;
38
+ @group(0) @binding(2) var depthTex: texture_depth_multisampled_2d;
39
+ // dofU[2] = (projA, projB): the same z-buffer inversion the composite uses.
40
+ @group(0) @binding(3) var<uniform> dofU: array<vec4f, 3>;
41
+ // sssU[0] = (direction.xy, radius, _); sssU[1] = (tanHalfFov·aspect, tanHalfFov, width, height).
42
+ @group(0) @binding(4) var<uniform> sssU: array<vec4f, 2>;
43
+
44
+ struct VsOut { @builtin(position) pos: vec4f };
45
+
46
+ @vertex fn vs(@builtin(vertex_index) i: u32) -> VsOut {
47
+ let p = vec2f(f32((i << 1u) & 2u), f32(i & 2u));
48
+ var o: VsOut;
49
+ o.pos = vec4f(p * 2.0 - 1.0, 0.0, 1.0);
50
+ return o;
51
+ }
52
+
53
+ fn size() -> vec2i { return vec2i(sssU[1].zw); }
54
+
55
+ fn texel(uv: vec2f) -> vec2i {
56
+ return clamp(vec2i(floor(uv * sssU[1].zw)), vec2i(0), size() - 1);
57
+ }
58
+
59
+ fn linearDepth(c: vec2i) -> f32 {
60
+ let z = textureLoad(depthTex, c, 0);
61
+ return clamp(dofU[2].y / (z - dofU[2].x), 0.05, 100000.0);
62
+ }
63
+
64
+ // View-space position of a texel centre — ray-mmd's GetViewPosition, with y up.
65
+ fn viewPos(c: vec2i, d: f32) -> vec3f {
66
+ let uv = (vec2f(c) + 0.5) / sssU[1].zw;
67
+ let ndc = vec2f(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0);
68
+ return vec3f(ndc * sssU[1].xy * d, d);
69
+ }
70
+
71
+ fn skin(c: vec2i) -> f32 { return textureLoad(maskTex, c, 0).b; }
72
+
73
+ fn blur(fragPos: vec4f) -> vec4f {
74
+ let c = vec2i(fragPos.xy);
75
+ let amount = skin(c) * 4.0;
76
+ let dir = sssU[0].xy;
77
+ let uv = (vec2f(c) + 0.5) / sssU[1].zw;
78
+ let centre = textureLoad(srcTex, c, 0);
79
+
80
+ // ray-mmd, verbatim: six offsets, and the skin variances per channel.
81
+ // A var, not a const: a const array cannot be indexed by the loop counter.
82
+ var offsets = array<f32, 6>(0.352, 0.719, 1.117, 1.579, 2.177, 3.213);
83
+ const profileVar = vec3f(9.6, 2.8, 2.2);
84
+ // mSSSSScale at the controller's default; it sets both the width and how hard
85
+ // a depth step rejects.
86
+ const SSSS_SCALE: f32 = 0.04;
87
+
88
+ let d = linearDepth(c);
89
+
90
+ // The surface's normal, rebuilt from depth (ray-mmd reads it from its G-buffer;
91
+ // this engine keeps none). Only its angle to the view matters: the blur narrows
92
+ // along an axis the surface is turning away on.
93
+ let s = size();
94
+ let cx = min(c + vec2i(1, 0), s - 1);
95
+ let cy = min(c + vec2i(0, 1), s - 1);
96
+ let P = viewPos(c, d);
97
+ let N = cross(viewPos(cx, linearDepth(cx)) - P, viewPos(cy, linearDepth(cy)) - P);
98
+ let nxz = select(vec2f(0.0, 1.0), normalize(N.xz), dot(N.xz, N.xz) > 1e-12);
99
+ let nyz = select(vec2f(0.0, 1.0), normalize(N.yz), dot(N.yz, N.yz) > 1e-12);
100
+ let scaleX = abs(dot(nxz, normalize(P.xz)));
101
+ let scaleY = abs(dot(nyz, normalize(P.yz)));
102
+ let perspectiveScale = max(dot(dir, vec2f(scaleX, scaleY)), 0.3);
103
+
104
+ let radius = 0.0055 * amount * perspectiveScale / SSSS_SCALE;
105
+ let step = dir * radius / d;
106
+
107
+ var weightSum = vec3f(1.0);
108
+ var colour = centre.rgb;
109
+ for (var i = 0; i < 6; i++) {
110
+ let o = offsets[i] / 5.5 * step;
111
+ for (var side = 0; side < 2; side++) {
112
+ let tc = texel(select(uv - o, uv + o, side == 0));
113
+ // Skin only: ray-mmd zeroes a non-skin pixel's depth so the depth term
114
+ // rejects it; the mask says the same thing directly.
115
+ let isSkin = select(0.0, 1.0, skin(tc) > 0.004);
116
+ let dd = (linearDepth(tc) - d) * 1000.0 * SSSS_SCALE;
117
+ let w = exp(-(offsets[i] * offsets[i] + dd * dd) / profileVar) * isSkin;
118
+ weightSum += w;
119
+ colour += w * textureLoad(srcTex, tc, 0).rgb;
120
+ }
121
+ }
122
+ return vec4f(colour / weightSum, centre.a);
123
+ }
124
+
125
+ @fragment fn fsX(@builtin(position) pos: vec4f) -> @location(0) vec4f {
126
+ // Nothing reads the scratch target off skin: the Y pass rejects those taps.
127
+ if (skin(vec2i(pos.xy)) <= 0.004) { return vec4f(0.0); }
128
+ return blur(pos);
129
+ }
130
+
131
+ @fragment fn fsY(@builtin(position) pos: vec4f) -> @location(0) vec4f {
132
+ // Off skin the HDR resolve must stay exactly as the scene left it.
133
+ if (skin(vec2i(pos.xy)) <= 0.004) { discard; }
134
+ return blur(pos);
135
+ }
136
+ `