reze-engine 0.61.4 → 0.61.6

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 (92) hide show
  1. package/dist/engine.d.ts +69 -2
  2. package/dist/engine.d.ts.map +1 -1
  3. package/dist/engine.js +310 -36
  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/directives.d.ts +9 -0
  17. package/dist/shaders/directives.d.ts.map +1 -1
  18. package/dist/shaders/directives.js +5 -0
  19. package/dist/shaders/materials/common.js +5 -5
  20. package/dist/shaders/materials/nodes.d.ts +1 -1
  21. package/dist/shaders/materials/nodes.d.ts.map +1 -1
  22. package/dist/shaders/materials/nodes.js +12 -0
  23. package/dist/shaders/passes/field-blit.d.ts +26 -0
  24. package/dist/shaders/passes/field-blit.d.ts.map +1 -0
  25. package/dist/shaders/passes/field-blit.js +65 -0
  26. package/dist/shaders/passes/ghosts.d.ts +22 -0
  27. package/dist/shaders/passes/ghosts.d.ts.map +1 -0
  28. package/dist/shaders/passes/ghosts.js +75 -0
  29. package/dist/shaders/passes/ground-noise.d.ts +7 -0
  30. package/dist/shaders/passes/ground-noise.d.ts.map +1 -0
  31. package/dist/shaders/passes/ground-noise.js +88 -0
  32. package/dist/shaders/passes/particles.js +6 -6
  33. package/dist/shaders/passes/scene-contract.js +1 -1
  34. package/dist/shaders/passes/sim.d.ts +34 -0
  35. package/dist/shaders/passes/sim.d.ts.map +1 -0
  36. package/dist/shaders/passes/sim.js +169 -0
  37. package/dist/shaders/passes/subsurface.d.ts +34 -0
  38. package/dist/shaders/passes/subsurface.d.ts.map +1 -0
  39. package/dist/shaders/passes/subsurface.js +134 -0
  40. package/dist/shaders/score-api.d.ts +10 -0
  41. package/dist/shaders/score-api.d.ts.map +1 -0
  42. package/dist/shaders/score-api.js +114 -0
  43. package/dist/shaders/shadow-api.d.ts +29 -0
  44. package/dist/shaders/shadow-api.d.ts.map +1 -0
  45. package/dist/shaders/shadow-api.js +112 -0
  46. package/package.json +1 -1
  47. package/src/engine.ts +309 -26
  48. package/src/graph/registry.ts +11 -0
  49. package/src/graph/slots.ts +2 -2
  50. package/src/shaders/directives.ts +14 -0
  51. package/src/shaders/materials/common.ts +5 -5
  52. package/src/shaders/materials/nodes.ts +12 -0
  53. package/src/shaders/passes/particles.ts +6 -6
  54. package/src/shaders/passes/scene-contract.ts +1 -1
  55. package/src/shaders/passes/subsurface.ts +136 -0
  56. package/dist/_dbg-loader.mjs +0 -1100
  57. package/dist/physics-debug.d.ts +0 -30
  58. package/dist/physics-debug.d.ts.map +0 -1
  59. package/dist/physics-debug.js +0 -526
  60. package/dist/shaders/materials/body.d.ts +0 -2
  61. package/dist/shaders/materials/body.d.ts.map +0 -1
  62. package/dist/shaders/materials/body.js +0 -95
  63. package/dist/shaders/materials/cloth_rough.d.ts +0 -2
  64. package/dist/shaders/materials/cloth_rough.d.ts.map +0 -1
  65. package/dist/shaders/materials/cloth_rough.js +0 -69
  66. package/dist/shaders/materials/cloth_smooth.d.ts +0 -2
  67. package/dist/shaders/materials/cloth_smooth.d.ts.map +0 -1
  68. package/dist/shaders/materials/cloth_smooth.js +0 -61
  69. package/dist/shaders/materials/default.d.ts +0 -2
  70. package/dist/shaders/materials/default.d.ts.map +0 -1
  71. package/dist/shaders/materials/default.js +0 -43
  72. package/dist/shaders/materials/eye.d.ts +0 -2
  73. package/dist/shaders/materials/eye.d.ts.map +0 -1
  74. package/dist/shaders/materials/eye.js +0 -60
  75. package/dist/shaders/materials/face.d.ts +0 -2
  76. package/dist/shaders/materials/face.d.ts.map +0 -1
  77. package/dist/shaders/materials/face.js +0 -95
  78. package/dist/shaders/materials/hair.d.ts +0 -2
  79. package/dist/shaders/materials/hair.d.ts.map +0 -1
  80. package/dist/shaders/materials/hair.js +0 -90
  81. package/dist/shaders/materials/metal.d.ts +0 -2
  82. package/dist/shaders/materials/metal.d.ts.map +0 -1
  83. package/dist/shaders/materials/metal.js +0 -77
  84. package/dist/shaders/materials/mmd_classic.d.ts +0 -2
  85. package/dist/shaders/materials/mmd_classic.d.ts.map +0 -1
  86. package/dist/shaders/materials/mmd_classic.js +0 -66
  87. package/dist/shaders/materials/stockings.d.ts +0 -2
  88. package/dist/shaders/materials/stockings.d.ts.map +0 -1
  89. package/dist/shaders/materials/stockings.js +0 -122
  90. package/dist/shaders/passes/physics-debug.d.ts +0 -2
  91. package/dist/shaders/passes/physics-debug.d.ts.map +0 -1
  92. 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). */
@@ -1650,6 +1657,8 @@ interface EffectInstance {
1650
1657
  /** This effect IS a mirror — see the `#mirror` directive. Draws no shader;
1651
1658
  * the engine reads its dials and folds the scene through the plane. */
1652
1659
  hasMirror: boolean
1660
+ /** `#stepped` — the cast it is aimed at moves on twos. */
1661
+ stepped: boolean
1653
1662
  /** Mounted under the scene. */
1654
1663
  hasBackground: boolean
1655
1664
  /** Mounted over the finished frame — and the reason the scene pass has to
@@ -2082,15 +2091,18 @@ export class Engine {
2082
2091
  /** Stencil value stamped by eye draws so hair can stencil-test against it and
2083
2092
  * alpha-blend a second pass over eye silhouette pixels (see-through-hair effect). */
2084
2093
  private static readonly STENCIL_EYE_VALUE = 1
2085
- /** Aux MRT alongside HDR color. Two channels:
2094
+ /** Aux MRT alongside HDR color. Three channels:
2086
2095
  * .r — bloom mask (1 = model geometry, 0 = ground; sampled by bloom blit to gate prefilter).
2087
2096
  * .g — accumulated alpha (the channel that used to live in hdr.a before the HDR format
2088
2097
  * switched to rg11b10ufloat, which has no alpha). Sampled by composite/bloom to
2089
2098
  * un-premultiply color for tonemap and to produce the canvas-drawable alpha used by
2090
2099
  * the premultiplied alphaMode compositor (so the page background still shows through
2091
2100
  * 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"
2101
+ * .b — subsurface strength, set by a graph's `subsurface` node and 0 everywhere
2102
+ * else; the scattering pass reads it to find skin (passes/subsurface.ts).
2103
+ * rgba8unorm at 4× MSAA is 16 bytes/texel; it never leaves tile memory (storeOp
2104
+ * discard), only its single-sample resolve does. */
2105
+ private static readonly BLOOM_MASK_FORMAT: GPUTextureFormat = "rgba8unorm"
2094
2106
  /**
2095
2107
  * The master switch for the id attachment. OFF — and off having been proven
2096
2108
  * to work, not off because it was never finished.
@@ -2633,6 +2645,28 @@ export class Engine {
2633
2645
  // composite adds bloomUp mip 0 × (color × intensity) to HDR before Filmic.
2634
2646
  // Matches EEVEE energy: tint/intensity applied at composite, not prefilter.
2635
2647
  private bloomSampler!: GPUSampler
2648
+ // Screen-space subsurface scattering (passes/subsurface.ts). The scratch target
2649
+ // is the X pass's output and lives with the canvas size; the bind groups name
2650
+ // it and the HDR resolve, so a resize drops them and the next frame rebuilds.
2651
+ private sssPipelineX: GPURenderPipeline | null = null
2652
+ private sssPipelineY: GPURenderPipeline | null = null
2653
+ private sssScratch: GPUTexture | null = null
2654
+ private sssBindGroupX: GPUBindGroup | null = null
2655
+ private sssBindGroupY: GPUBindGroup | null = null
2656
+ private sssUniformX: GPUBuffer | null = null
2657
+ private sssUniformY: GPUBuffer | null = null
2658
+ private readonly sssUniformData = new Float32Array(8)
2659
+ // Model pipelines' descriptors, and the single-sided twins built from them —
2660
+ // one per pipeline per view (see sidedPipeline).
2661
+ private readonly pipelineDescs = new WeakMap<GPURenderPipeline, GPURenderPipelineDescriptor>()
2662
+ private readonly singleSided = new WeakMap<GPURenderPipeline, GPURenderPipeline>()
2663
+ private readonly singleSidedMirror = new WeakMap<GPURenderPipeline, GPURenderPipeline>()
2664
+ // Stepped motion (#stepped). The models whose pose is being HELD this frame —
2665
+ // their skinning and morph uploads wait for the stepped clock's next tick —
2666
+ // and the tick each was last shown at. Empty while no effect declares it.
2667
+ private readonly steppedHeld = new Set<string>()
2668
+ private readonly steppedTick = new Map<string, number>()
2669
+ private steppedLastClock = -1
2636
2670
  private bloomBlitUniformBuffer!: GPUBuffer
2637
2671
  private bloomUpsampleUniformBuffer!: GPUBuffer
2638
2672
  private readonly bloomBlitUniformData = new Float32Array(4)
@@ -3410,6 +3444,55 @@ export class Engine {
3410
3444
  * Gated on `weight`, so a scheduled mirror appears and leaves with its strip
3411
3445
  * and a faded-out one costs no reflection pass at all.
3412
3446
  */
3447
+ /** The dials a stepped effect is read by, by name — MIRROR_DIALS' pattern. */
3448
+ private static readonly STEPPED_DIALS = {
3449
+ /** Poses per second of scene time: 12 is 24fps animation on twos, 6 on fours. */
3450
+ fps: "FPS",
3451
+ } as const
3452
+
3453
+ /**
3454
+ * Decide which models hold their pose this frame.
3455
+ *
3456
+ * Nothing underneath is slowed: animation and physics run every frame as they
3457
+ * always do, so the pose that lands on each tick is the true one for that
3458
+ * moment and a hold never drifts behind the music. What steps is only when it
3459
+ * reaches the GPU — the skinning upload and the morph dispatch wait for the
3460
+ * next tick of a clock running at FPS on the scene clock, which is what an
3461
+ * export steps.
3462
+ *
3463
+ * FIRST match wins, and only the models it is aimed at hold: a stage, a prop
3464
+ * and every effect keep moving on ones, which is what makes the cast read as
3465
+ * animated rather than the playback as broken.
3466
+ */
3467
+ private syncSteppedFromEffects(): void {
3468
+ this.steppedHeld.clear()
3469
+ let fx: EffectInstance | null = null
3470
+ for (const e of this.effects) {
3471
+ if (e.stepped && e.weight > 0) {
3472
+ fx = e
3473
+ break
3474
+ }
3475
+ }
3476
+ if (!fx) {
3477
+ this.steppedTick.clear()
3478
+ return
3479
+ }
3480
+ // Paused, nothing holds: time is not moving, so there is nothing to step,
3481
+ // and a pose edited on a stopped timeline has to show as it is edited.
3482
+ const paused = this.sceneClock === this.steppedLastClock
3483
+ this.steppedLastClock = this.sceneClock
3484
+ if (paused) return
3485
+ const D = Engine.STEPPED_DIALS
3486
+ const fps = Math.max(this.effectDial(fx, D.fps, 6), 1)
3487
+ const tick = Math.floor(this.sceneClock * fps + 1e-6)
3488
+ this.forEachInstance((inst) => {
3489
+ const slot = this.castSlotOf.get(inst.name)
3490
+ if (slot === undefined || !(fx!.subjectMask & (1 << slot))) return
3491
+ if (this.steppedTick.get(inst.name) === tick) this.steppedHeld.add(inst.name)
3492
+ else this.steppedTick.set(inst.name, tick)
3493
+ })
3494
+ }
3495
+
3413
3496
  private syncMirrorFromEffects(): void {
3414
3497
  let fx: EffectInstance | null = null
3415
3498
  for (const e of this.effects) {
@@ -4761,14 +4844,15 @@ export class Engine {
4761
4844
  // #mirror is a mount on the same footing, and for the same reason: a
4762
4845
  // reflection re-renders the scene from a folded camera, which is a pass and
4763
4846
  // not a shader, so the effect declares it and draws nothing itself.
4764
- if (!hasBackground && !hasForeground && !wantsParticles && !wantsTrails && !hasLightEmit(wgsl) && !d.mirror) {
4847
+ // #stepped too: it decides when a pose reaches the screen, not what is drawn.
4848
+ if (!hasBackground && !hasForeground && !wantsParticles && !wantsTrails && !hasLightEmit(wgsl) && !d.mirror && !d.stepped) {
4765
4849
  return { ok: false, diagnostics: [
4766
4850
  "an effect must define fn background(ray: vec3f, uv: vec2f, time: f32) -> vec4f, " +
4767
4851
  "fn foreground(ray: vec3f, uv: vec2f, time: f32, depth: f32) -> vec4f, " +
4768
4852
  "the particle trio (particleInit/particleStep/particleShade), " +
4769
4853
  "the ribbon pair (trailWidth/trailShade), " +
4770
4854
  "fn lightEmit(i: u32) -> RzLight with #lights <n>, " +
4771
- "or #mirror",
4855
+ "#mirror, or #stepped",
4772
4856
  ], mounts: noMounts, params: [], duration: 0, readsCast: false }
4773
4857
  }
4774
4858
  const mounts = { background: hasBackground, foreground: hasForeground }
@@ -5080,6 +5164,7 @@ export class Engine {
5080
5164
  paramsBuffer,
5081
5165
  paramsData,
5082
5166
  hasMirror: d.mirror,
5167
+ stepped: d.stepped,
5083
5168
  hasBackground,
5084
5169
  hasForeground,
5085
5170
  // The author's OWN source, not the assembled module: the assembled one
@@ -6143,6 +6228,134 @@ export class Engine {
6143
6228
  /** The user's field mounts, drawn at half resolution for the composite to
6144
6229
  * upsample. Runs the whole quad — uniform control flow, so effects may use
6145
6230
  * derivatives freely, which the old inline path had to forbid. */
6231
+ /** Whether any installed style group routes through a `subsurface` node —
6232
+ * the scattering pass, and the depth it reads, cost nothing otherwise. */
6233
+ private subsurfaceInUse(): boolean {
6234
+ for (const inst of this.modelInstances.values()) {
6235
+ for (const g of inst.styleGroups.values()) {
6236
+ if (g.group.materials.length && g.group.graph.nodes.some((n) => n.type === "subsurface")) return true
6237
+ }
6238
+ }
6239
+ return false
6240
+ }
6241
+
6242
+ /**
6243
+ * ray-mmd's skin scattering: a horizontal then a vertical depth-aware blur of
6244
+ * the pixels the scene pass marked as skin (aux .b), the second written back
6245
+ * into the HDR resolve through a blend that keeps ray-mmd's per-channel share
6246
+ * of the unblurred picture. See passes/subsurface.ts.
6247
+ */
6248
+ private renderSubsurface(encoder: GPUCommandEncoder): void {
6249
+ if (!this.device || !this.subsurfaceInUse()) return
6250
+ if (!this.hdrResolveTexture || !this.maskResolveView || !this.depthReadView || !this.dofUniformBuffer) return
6251
+ const w = this.hdrResolveTexture.width
6252
+ const h = this.hdrResolveTexture.height
6253
+
6254
+ if (!this.sssPipelineX || !this.sssPipelineY) {
6255
+ const module = this.device.createShaderModule({ label: "subsurface scattering", code: SUBSURFACE_WGSL })
6256
+ this.sssPipelineX = this.device.createRenderPipeline({
6257
+ label: "subsurface blur x",
6258
+ layout: "auto",
6259
+ vertex: { module, entryPoint: "vs" },
6260
+ fragment: { module, entryPoint: "fsX", targets: [{ format: this.hdrFormat }] },
6261
+ primitive: { topology: "triangle-list" },
6262
+ })
6263
+ // result = blurred·(1 − spike) + original·spike, per channel: the
6264
+ // destination is the original, and the blend constant holds the spike.
6265
+ this.sssPipelineY = this.device.createRenderPipeline({
6266
+ label: "subsurface blur y",
6267
+ layout: "auto",
6268
+ vertex: { module, entryPoint: "vs" },
6269
+ fragment: {
6270
+ module,
6271
+ entryPoint: "fsY",
6272
+ targets: [
6273
+ {
6274
+ format: this.hdrFormat,
6275
+ blend: {
6276
+ color: { srcFactor: "one-minus-constant", dstFactor: "constant", operation: "add" },
6277
+ alpha: { srcFactor: "zero", dstFactor: "one", operation: "add" },
6278
+ },
6279
+ },
6280
+ ],
6281
+ },
6282
+ primitive: { topology: "triangle-list" },
6283
+ })
6284
+ this.sssBindGroupX = null
6285
+ this.sssBindGroupY = null
6286
+ }
6287
+ if (!this.sssUniformX || !this.sssUniformY) {
6288
+ const make = (label: string) =>
6289
+ this.device.createBuffer({ label, size: 32, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST })
6290
+ this.sssUniformX = make("subsurface uniforms x")
6291
+ this.sssUniformY = make("subsurface uniforms y")
6292
+ }
6293
+ if (!this.sssScratch || this.sssScratch.width !== w || this.sssScratch.height !== h) {
6294
+ this.sssScratch?.destroy()
6295
+ this.sssScratch = this.device.createTexture({
6296
+ label: "subsurface scratch",
6297
+ size: [w, h],
6298
+ format: this.hdrFormat,
6299
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
6300
+ })
6301
+ this.sssBindGroupX = null
6302
+ this.sssBindGroupY = null
6303
+ }
6304
+ if (!this.sssBindGroupX || !this.sssBindGroupY) {
6305
+ const mask = this.maskResolveView
6306
+ const depth = this.depthReadView
6307
+ const dof = this.dofUniformBuffer
6308
+ const group = (pipeline: GPURenderPipeline, src: GPUTexture, uniforms: GPUBuffer, label: string) =>
6309
+ this.device.createBindGroup({
6310
+ label,
6311
+ layout: pipeline.getBindGroupLayout(0),
6312
+ entries: [
6313
+ { binding: 0, resource: src.createView() },
6314
+ { binding: 1, resource: mask },
6315
+ { binding: 2, resource: depth },
6316
+ { binding: 3, resource: { buffer: dof } },
6317
+ { binding: 4, resource: { buffer: uniforms } },
6318
+ ],
6319
+ })
6320
+ this.sssBindGroupX = group(this.sssPipelineX, this.hdrResolveTexture, this.sssUniformX, "subsurface x")
6321
+ this.sssBindGroupY = group(this.sssPipelineY, this.sssScratch, this.sssUniformY, "subsurface y")
6322
+ }
6323
+
6324
+ const tanY = Math.tan(this.camera.fov * 0.5)
6325
+ const u = this.sssUniformData
6326
+ u[2] = 0
6327
+ u[3] = 0
6328
+ u[4] = tanY * this.camera.aspect
6329
+ u[5] = tanY
6330
+ u[6] = w
6331
+ u[7] = h
6332
+ u[0] = 1
6333
+ u[1] = 0
6334
+ this.device.queue.writeBuffer(this.sssUniformX, 0, u)
6335
+ u[0] = 0
6336
+ u[1] = 1
6337
+ this.device.queue.writeBuffer(this.sssUniformY, 0, u)
6338
+
6339
+ const px = encoder.beginRenderPass({
6340
+ label: "subsurface blur x",
6341
+ colorAttachments: [{ view: this.sssScratch.createView(), loadOp: "clear", clearValue: [0, 0, 0, 0], storeOp: "store" }],
6342
+ })
6343
+ px.setPipeline(this.sssPipelineX)
6344
+ px.setBindGroup(0, this.sssBindGroupX)
6345
+ px.draw(3)
6346
+ px.end()
6347
+
6348
+ const py = encoder.beginRenderPass({
6349
+ label: "subsurface blur y",
6350
+ colorAttachments: [{ view: this.hdrResolveTexture.createView(), loadOp: "load", storeOp: "store" }],
6351
+ })
6352
+ py.setPipeline(this.sssPipelineY)
6353
+ py.setBindGroup(0, this.sssBindGroupY)
6354
+ py.setBlendConstant({ r: SSS_SPIKE[0], g: SSS_SPIKE[1], b: SSS_SPIKE[2], a: 0 })
6355
+ py.draw(3)
6356
+ py.end()
6357
+ }
6358
+
6146
6359
  private renderFieldPass(encoder: GPUCommandEncoder): void {
6147
6360
  // TWO PREDICATES, deliberately, and they are not interchangeable.
6148
6361
  //
@@ -7210,7 +7423,7 @@ export class Engine {
7210
7423
  multisample?: GPUMultisampleState
7211
7424
  }): GPURenderPipeline {
7212
7425
  const targets = config.fragmentTargets ?? (config.fragmentTarget ? [config.fragmentTarget] : undefined)
7213
- return this.device.createRenderPipeline({
7426
+ return this.createModelPipeline({
7214
7427
  label: config.label,
7215
7428
  layout: config.layout,
7216
7429
  vertex: {
@@ -7653,7 +7866,7 @@ export class Engine {
7653
7866
  depthCompare: this.depthAhead,
7654
7867
  },
7655
7868
  }
7656
- this.depthPrepassPipeline = this.device.createRenderPipeline({
7869
+ this.depthPrepassPipeline = this.createModelPipeline({
7657
7870
  label: "opaque depth prepass",
7658
7871
  ...prepassDesc,
7659
7872
  fragment: {
@@ -7665,7 +7878,7 @@ export class Engine {
7665
7878
  // The SOLID prime: same module, cutoff forced to exactly 1.0. Only texels
7666
7879
  // whose blend ignores the destination may pre-claim depth in the
7667
7880
  // transparent phase — see the override's note in depth-prepass.ts.
7668
- this.solidPrepassPipeline = this.device.createRenderPipeline({
7881
+ this.solidPrepassPipeline = this.createModelPipeline({
7669
7882
  label: "transparent solid prepass",
7670
7883
  ...prepassDesc,
7671
7884
  fragment: {
@@ -7681,7 +7894,7 @@ export class Engine {
7681
7894
  // hair depth from ever claiming the pixels the see-through-hair pass needs
7682
7895
  // the eye to survive on. (Bundle draws use the PASS's stencil reference;
7683
7896
  // only pipeline/bind/vertex state resets across executeBundles.)
7684
- this.hairPrimePipeline = this.device.createRenderPipeline({
7897
+ this.hairPrimePipeline = this.createModelPipeline({
7685
7898
  label: "hair depth prime",
7686
7899
  ...prepassDesc,
7687
7900
  depthStencil: {
@@ -8553,6 +8766,10 @@ export class Engine {
8553
8766
  usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
8554
8767
  })
8555
8768
  this.maskResolveView = this.maskResolveTexture.createView()
8769
+ this.sssScratch?.destroy()
8770
+ this.sssScratch = null
8771
+ this.sssBindGroupX = null
8772
+ this.sssBindGroupY = null
8556
8773
 
8557
8774
  // The floor mirror's targets — FULL resolution, the same attachment
8558
8775
  // contract and sample count as the scene pass, which is what lets the
@@ -10403,6 +10620,8 @@ export class Engine {
10403
10620
  bindGroup: this.groundShadowBindGroup!,
10404
10621
  materialName: "Ground",
10405
10622
  groupId: null,
10623
+ // The ground's own pipeline decides its culling.
10624
+ doubleSided: true,
10406
10625
  // The ground belongs to no model instance, so it is not in the cull list —
10407
10626
  // cullIndex -1 leaves renderGround unconditional. Its box is filled in
10408
10627
  // anyway rather than left a lie for whoever reads this next.
@@ -12549,6 +12768,8 @@ export class Engine {
12549
12768
  for (const inst of this.modelInstances.values()) {
12550
12769
  const gm = inst.gpuMorph
12551
12770
  if (!gm || !gm.dispatchNeeded) continue
12771
+ // The face holds with the body (see syncSteppedFromEffects).
12772
+ if (this.steppedHeld.has(inst.name)) continue
12552
12773
  if (!pass) {
12553
12774
  pass = encoder.beginComputePass({ label: "morph compute", timestampWrites: this.stamps("morph") })
12554
12775
  pass.setPipeline(this.morphComputePipeline)
@@ -14479,6 +14700,7 @@ export class Engine {
14479
14700
  groupId: null,
14480
14701
  baseBindGroupEntries,
14481
14702
  castsShadow,
14703
+ doubleSided: inst.isPlane || (mat.edgeFlag & 0x01) !== 0,
14482
14704
  outline,
14483
14705
  bounds,
14484
14706
  cullIndex: -1,
@@ -15966,6 +16188,7 @@ export class Engine {
15966
16188
  }
15967
16189
 
15968
16190
  const hasModels = this.modelInstances.size > 0
16191
+ this.syncSteppedFromEffects()
15969
16192
  if (hasModels) {
15970
16193
  this.updateInstances(deltaTime)
15971
16194
  this.updateSkinMatrices()
@@ -16063,7 +16286,10 @@ export class Engine {
16063
16286
  // The two real readers are both in the composite and both have their own
16064
16287
  // flag above: linearDepth() feeds the DoF gather and the depth handed to a
16065
16288
  // foreground mount. Nothing else binds depthTex at all.
16066
- const depthRead = dofOn || this.effects.some((e) => e.hasForeground)
16289
+ // The scattering pass is the third reader: it rejects taps across a depth
16290
+ // step and scales its width by distance, so a scene wearing a subsurface
16291
+ // look stores depth for as long as it does.
16292
+ const depthRead = dofOn || this.effects.some((e) => e.hasForeground) || this.subsurfaceInUse()
16067
16293
  this.renderPassDescriptor.depthStencilAttachment!.depthStoreOp = depthRead ? "store" : "discard"
16068
16294
  if (depthRead) this.writeDepthOfFieldUniforms()
16069
16295
 
@@ -16261,6 +16487,9 @@ export class Engine {
16261
16487
  // kept ribbons out of bloom.
16262
16488
  this.drawTrails(pass, "camera")
16263
16489
  pass.end()
16490
+ // Skin scattering, on the resolved scene and before anything reads it:
16491
+ // bloom should bleed the scattered skin, not the raw one.
16492
+ this.renderSubsurface(encoder)
16264
16493
  // The field mounts, likewise: after the scene so foregrounds can read its
16265
16494
  // depth, before the composite that samples both layers.
16266
16495
  this.renderFieldPass(encoder)
@@ -17081,13 +17310,55 @@ export class Engine {
17081
17310
  stencilWriteMask: 0xff,
17082
17311
  }
17083
17312
  }
17084
- return this.device.createRenderPipelineAsync({
17313
+ const desc: GPURenderPipelineDescriptor = {
17085
17314
  ...base,
17086
17315
  fragment: { module, constants, targets: blend === "additive" ? this.sceneTargetsAdditive : this.sceneTargets },
17087
17316
  depthStencil,
17317
+ }
17318
+ return this.device.createRenderPipelineAsync(desc).then((pipeline) => {
17319
+ this.pipelineDescs.set(pipeline, desc)
17320
+ return pipeline
17088
17321
  })
17089
17322
  }
17090
17323
 
17324
+ /** A pipeline whose descriptor is kept, so a single-sided twin can be built
17325
+ * from it on demand (see sidedPipeline). */
17326
+ private createModelPipeline(desc: GPURenderPipelineDescriptor): GPURenderPipeline {
17327
+ const pipeline = this.device.createRenderPipeline(desc)
17328
+ this.pipelineDescs.set(pipeline, desc)
17329
+ return pipeline
17330
+ }
17331
+
17332
+ /**
17333
+ * The pipeline a draw should use once its double-sided flag is honoured.
17334
+ *
17335
+ * A single-sided material culls its back faces, as MMD does. PMX winds a front
17336
+ * face clockwise, which this engine sees as a BACK face — the eye's front cull
17337
+ * and the outline hull's back cull are the same fact — so the cull is "front"
17338
+ * for the camera and "back" in the mirror, whose reflection flips winding.
17339
+ *
17340
+ * Every pipeline that draws a material's surface goes through here, depth
17341
+ * primes included: a prime that kept a culled face would write depth for a
17342
+ * surface nobody sees and hide what is behind it. A pipeline that already
17343
+ * culls (the eye) is returned as it is.
17344
+ */
17345
+ private sidedPipeline(pipeline: GPURenderPipeline, draw: DrawCall, mirrored: boolean): GPURenderPipeline {
17346
+ if (draw.doubleSided) return pipeline
17347
+ const desc = this.pipelineDescs.get(pipeline)
17348
+ if (!desc || (desc.primitive?.cullMode ?? "none") !== "none") return pipeline
17349
+ const cache = mirrored ? this.singleSidedMirror : this.singleSided
17350
+ let twin = cache.get(pipeline)
17351
+ if (!twin) {
17352
+ twin = this.device.createRenderPipeline({
17353
+ ...desc,
17354
+ label: `${desc.label ?? "pipeline"} (single-sided${mirrored ? ", mirror" : ""})`,
17355
+ primitive: { ...desc.primitive, cullMode: mirrored ? "back" : "front" },
17356
+ })
17357
+ cache.set(pipeline, twin)
17358
+ }
17359
+ return twin
17360
+ }
17361
+
17091
17362
  // Pipeline for a material draw call: its group's compiled pipeline when grouped, else
17092
17363
  // the neutral base (ungrouped materials render the default graph). Transparent-bucket
17093
17364
  // draws on the CAST use the SAME depth-write-on pipeline — babylon-mmd's
@@ -17145,7 +17416,8 @@ export class Engine {
17145
17416
  pass.setBindGroup(1, inst.mainPerInstanceBindGroup)
17146
17417
  bound = true
17147
17418
  }
17148
- const pipeline = this.pipelineForDrawCall(inst, draw, view.args === "mirror")
17419
+ const mirrored = view.args === "mirror"
17420
+ const pipeline = this.sidedPipeline(this.pipelineForDrawCall(inst, draw, mirrored), draw, mirrored)
17149
17421
  if (pipeline !== currentPipeline) {
17150
17422
  pass.setPipeline(pipeline)
17151
17423
  currentPipeline = pipeline
@@ -17268,14 +17540,17 @@ export class Engine {
17268
17540
  inst: ModelInstance,
17269
17541
  view: { perFrame: GPUBindGroup; args: "camera" | "mirror"; outlines: boolean },
17270
17542
  ): void {
17271
- let bound = false
17543
+ let bound: GPURenderPipeline | null = null
17272
17544
  for (const draw of inst.drawCalls) {
17273
17545
  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
17546
+ const pipeline = this.sidedPipeline(this.hairPrimePipeline, draw, view.args === "mirror")
17547
+ if (bound !== pipeline) {
17548
+ pass.setPipeline(pipeline)
17549
+ if (!bound) {
17550
+ pass.setBindGroup(0, view.perFrame)
17551
+ pass.setBindGroup(1, inst.mainPerInstanceBindGroup)
17552
+ }
17553
+ bound = pipeline
17279
17554
  }
17280
17555
  pass.setBindGroup(2, draw.bindGroup)
17281
17556
  this.issueDraw(pass, draw, view.args)
@@ -17338,6 +17613,7 @@ export class Engine {
17338
17613
  if (install?.alphaMode === "hashed") pipeline = this.solidPrepassPipeline
17339
17614
  else if (install && install.alphaMode !== "opaque") continue
17340
17615
  }
17616
+ pipeline = this.sidedPipeline(pipeline, draw, view.args === "mirror")
17341
17617
  if (bound !== pipeline) {
17342
17618
  pass.setPipeline(pipeline)
17343
17619
  // One layout for both prepass pipelines, so groups 0 and 1 carry over.
@@ -17375,14 +17651,17 @@ export class Engine {
17375
17651
  view: { perFrame: GPUBindGroup; args: "camera" | "mirror"; outlines: boolean },
17376
17652
  ): void {
17377
17653
  if (inst.isStage) return
17378
- let bound = false
17654
+ let bound: GPURenderPipeline | null = null
17379
17655
  for (const draw of inst.drawCalls) {
17380
17656
  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
17657
+ const pipeline = this.sidedPipeline(this.solidPrepassPipeline, draw, view.args === "mirror")
17658
+ if (bound !== pipeline) {
17659
+ pass.setPipeline(pipeline)
17660
+ if (!bound) {
17661
+ pass.setBindGroup(0, view.perFrame)
17662
+ pass.setBindGroup(1, inst.mainPerInstanceBindGroup)
17663
+ }
17664
+ bound = pipeline
17386
17665
  }
17387
17666
  pass.setBindGroup(2, draw.bindGroup)
17388
17667
  this.issueDraw(pass, draw, view.args)
@@ -17442,8 +17721,9 @@ export class Engine {
17442
17721
  let currentPipeline: GPURenderPipeline | null = null
17443
17722
  for (const draw of inst.drawCalls) {
17444
17723
  if (draw.type !== "opaque") continue
17445
- const overEyes = this.overEyesPipelineFor(inst, draw)
17446
- if (!overEyes) continue
17724
+ const hairOverEyes = this.overEyesPipelineFor(inst, draw)
17725
+ if (!hairOverEyes) continue
17726
+ const overEyes = this.sidedPipeline(hairOverEyes, draw, view.args === "mirror")
17447
17727
  if (!bound) {
17448
17728
  pass.setBindGroup(0, view.perFrame)
17449
17729
  pass.setBindGroup(1, inst.mainPerInstanceBindGroup)
@@ -17895,6 +18175,9 @@ export class Engine {
17895
18175
  // re-uploading bones×64 bytes for scenery that never moves is the one
17896
18176
  // per-frame cost a stage would otherwise still pay in full.
17897
18177
  if (!inst.skinMatricesDirty) return
18178
+ // Held on a stepped effect's tick: stays dirty, so the pose that is current
18179
+ // when the next tick comes is the one uploaded.
18180
+ if (this.steppedHeld.has(inst.name)) return
17898
18181
  const skinMatrices = inst.model.getSkinMatrices()
17899
18182
  this.device.queue.writeBuffer(
17900
18183
  inst.skinMatrixBuffer,
@@ -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
  }
@@ -87,6 +87,15 @@ export type EffectDirectives = {
87
87
  * it publishes, forks, schedules and fades like every other one.
88
88
  */
89
89
  mirror: boolean
90
+ /**
91
+ * `#stepped`: the cast this effect is aimed at moves ON TWOS — its pose, and
92
+ * its face, held for a few frames and then snapped to where it is now, the way
93
+ * limited animation and stop-motion move. A mount with no shader, on
94
+ * #mirror's footing: it changes WHEN a pose reaches the screen, which no
95
+ * shader can. The rate comes from the effect's own FPS dial by name — see
96
+ * the engine's STEPPED_DIALS.
97
+ */
98
+ stepped: boolean
90
99
  /**
91
100
  * How long ONE firing of this effect lasts, in seconds. 0 = undeclared.
92
101
  *
@@ -118,6 +127,7 @@ const SPEC = {
118
127
  bloom: 0,
119
128
  dissolve: 0,
120
129
  mirror: 0,
130
+ stepped: 0,
121
131
  duration: 1,
122
132
  ground: "rest",
123
133
  } as const
@@ -177,6 +187,7 @@ export function parseDirectives(wgsl: string): DirectiveResult {
177
187
  dissolve: false,
178
188
  ground: null,
179
189
  mirror: false,
190
+ stepped: false,
180
191
  duration: 0,
181
192
  }
182
193
  const errors: string[] = []
@@ -296,6 +307,9 @@ export function parseDirectives(wgsl: string): DirectiveResult {
296
307
  case "mirror":
297
308
  d.mirror = true
298
309
  return
310
+ case "stepped":
311
+ d.stepped = true
312
+ return
299
313
  case "duration": {
300
314
  // SECONDS, like every other time a directive states (see #dissolve).
301
315
  // The document above works in frames and converts once; an author
@@ -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.