reze-engine 0.61.3 → 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 (105) hide show
  1. package/dist/engine.d.ts +74 -9
  2. package/dist/engine.d.ts.map +1 -1
  3. package/dist/engine.js +260 -34
  4. package/dist/graph/presets/body.js +1 -1
  5. package/dist/graph/presets/cloth_rough.js +1 -1
  6. package/dist/graph/presets/cloth_smooth.js +1 -1
  7. package/dist/graph/presets/eye.d.ts.map +1 -1
  8. package/dist/graph/presets/eye.js +19 -0
  9. package/dist/graph/presets/face.js +1 -1
  10. package/dist/graph/presets/hair.js +1 -1
  11. package/dist/graph/presets/metal.js +1 -1
  12. package/dist/graph/presets/pool_floor.d.ts +3 -0
  13. package/dist/graph/presets/pool_floor.d.ts.map +1 -0
  14. package/dist/graph/presets/pool_floor.js +42 -0
  15. package/dist/graph/presets/water.d.ts +3 -0
  16. package/dist/graph/presets/water.d.ts.map +1 -0
  17. package/dist/graph/presets/water.js +31 -0
  18. package/dist/graph/registry.d.ts.map +1 -1
  19. package/dist/graph/registry.js +11 -0
  20. package/dist/graph/slots.d.ts.map +1 -1
  21. package/dist/graph/slots.js +19 -5
  22. package/dist/physics/autofit.d.ts +147 -0
  23. package/dist/physics/autofit.d.ts.map +1 -0
  24. package/dist/physics/autofit.js +501 -0
  25. package/dist/shaders/materials/common.d.ts.map +1 -1
  26. package/dist/shaders/materials/common.js +10 -9
  27. package/dist/shaders/materials/nodes.d.ts +1 -1
  28. package/dist/shaders/materials/nodes.d.ts.map +1 -1
  29. package/dist/shaders/materials/nodes.js +12 -0
  30. package/dist/shaders/passes/field-blit.d.ts +26 -0
  31. package/dist/shaders/passes/field-blit.d.ts.map +1 -0
  32. package/dist/shaders/passes/field-blit.js +65 -0
  33. package/dist/shaders/passes/ground-noise.d.ts +7 -0
  34. package/dist/shaders/passes/ground-noise.d.ts.map +1 -0
  35. package/dist/shaders/passes/ground-noise.js +88 -0
  36. package/dist/shaders/passes/particles.js +6 -6
  37. package/dist/shaders/passes/scene-contract.js +1 -1
  38. package/dist/shaders/passes/sim.d.ts +34 -0
  39. package/dist/shaders/passes/sim.d.ts.map +1 -0
  40. package/dist/shaders/passes/sim.js +169 -0
  41. package/dist/shaders/passes/subsurface.d.ts +34 -0
  42. package/dist/shaders/passes/subsurface.d.ts.map +1 -0
  43. package/dist/shaders/passes/subsurface.js +134 -0
  44. package/dist/shaders/scene-light-api.d.ts.map +1 -1
  45. package/dist/shaders/scene-light-api.js +11 -0
  46. package/dist/shaders/score-api.d.ts +10 -0
  47. package/dist/shaders/score-api.d.ts.map +1 -0
  48. package/dist/shaders/score-api.js +114 -0
  49. package/dist/shaders/shadow-api.d.ts +29 -0
  50. package/dist/shaders/shadow-api.d.ts.map +1 -0
  51. package/dist/shaders/shadow-api.js +112 -0
  52. package/package.json +1 -1
  53. package/src/engine.ts +275 -36
  54. package/src/graph/presets/body.ts +1 -1
  55. package/src/graph/presets/cloth_rough.ts +1 -1
  56. package/src/graph/presets/cloth_smooth.ts +1 -1
  57. package/src/graph/presets/eye.ts +19 -0
  58. package/src/graph/presets/face.ts +1 -1
  59. package/src/graph/presets/hair.ts +1 -1
  60. package/src/graph/presets/metal.ts +1 -1
  61. package/src/graph/registry.ts +11 -0
  62. package/src/graph/slots.ts +19 -5
  63. package/src/shaders/materials/common.ts +10 -9
  64. package/src/shaders/materials/nodes.ts +12 -0
  65. package/src/shaders/passes/particles.ts +6 -6
  66. package/src/shaders/passes/scene-contract.ts +1 -1
  67. package/src/shaders/passes/subsurface.ts +136 -0
  68. package/src/shaders/scene-light-api.ts +11 -0
  69. package/dist/_dbg-loader.mjs +0 -1100
  70. package/dist/physics-debug.d.ts +0 -30
  71. package/dist/physics-debug.d.ts.map +0 -1
  72. package/dist/physics-debug.js +0 -526
  73. package/dist/shaders/materials/body.d.ts +0 -2
  74. package/dist/shaders/materials/body.d.ts.map +0 -1
  75. package/dist/shaders/materials/body.js +0 -95
  76. package/dist/shaders/materials/cloth_rough.d.ts +0 -2
  77. package/dist/shaders/materials/cloth_rough.d.ts.map +0 -1
  78. package/dist/shaders/materials/cloth_rough.js +0 -69
  79. package/dist/shaders/materials/cloth_smooth.d.ts +0 -2
  80. package/dist/shaders/materials/cloth_smooth.d.ts.map +0 -1
  81. package/dist/shaders/materials/cloth_smooth.js +0 -61
  82. package/dist/shaders/materials/default.d.ts +0 -2
  83. package/dist/shaders/materials/default.d.ts.map +0 -1
  84. package/dist/shaders/materials/default.js +0 -43
  85. package/dist/shaders/materials/eye.d.ts +0 -2
  86. package/dist/shaders/materials/eye.d.ts.map +0 -1
  87. package/dist/shaders/materials/eye.js +0 -60
  88. package/dist/shaders/materials/face.d.ts +0 -2
  89. package/dist/shaders/materials/face.d.ts.map +0 -1
  90. package/dist/shaders/materials/face.js +0 -95
  91. package/dist/shaders/materials/hair.d.ts +0 -2
  92. package/dist/shaders/materials/hair.d.ts.map +0 -1
  93. package/dist/shaders/materials/hair.js +0 -90
  94. package/dist/shaders/materials/metal.d.ts +0 -2
  95. package/dist/shaders/materials/metal.d.ts.map +0 -1
  96. package/dist/shaders/materials/metal.js +0 -77
  97. package/dist/shaders/materials/mmd_classic.d.ts +0 -2
  98. package/dist/shaders/materials/mmd_classic.d.ts.map +0 -1
  99. package/dist/shaders/materials/mmd_classic.js +0 -66
  100. package/dist/shaders/materials/stockings.d.ts +0 -2
  101. package/dist/shaders/materials/stockings.d.ts.map +0 -1
  102. package/dist/shaders/materials/stockings.js +0 -122
  103. package/dist/shaders/passes/physics-debug.d.ts +0 -2
  104. package/dist/shaders/passes/physics-debug.d.ts.map +0 -1
  105. 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). */
@@ -896,8 +903,9 @@ interface ModelInstance {
896
903
  shadowDrawCalls: DrawCall[]
897
904
  shadowBindGroups: GPUBindGroup[]
898
905
  mainPerInstanceBindGroup: GPUBindGroup
899
- /** Its own light — fill and sun — when setModelFill or setModelSun gave it
900
- * one: a 32-byte ModelLight, and the CPU copy it is written from. */
906
+ /** Its own light — fill, sun and options — when setModelFill, setModelSun or
907
+ * setModelFlatSky gave it one: a 48-byte ModelLight, and the CPU copy it is
908
+ * written from. */
901
909
  lightBuffer: GPUBuffer | null
902
910
  modelLight: Float32Array | null
903
911
  pickPerInstanceBindGroup: GPUBindGroup
@@ -2081,15 +2089,18 @@ export class Engine {
2081
2089
  /** Stencil value stamped by eye draws so hair can stencil-test against it and
2082
2090
  * alpha-blend a second pass over eye silhouette pixels (see-through-hair effect). */
2083
2091
  private static readonly STENCIL_EYE_VALUE = 1
2084
- /** Aux MRT alongside HDR color. Two channels:
2092
+ /** Aux MRT alongside HDR color. Three channels:
2085
2093
  * .r — bloom mask (1 = model geometry, 0 = ground; sampled by bloom blit to gate prefilter).
2086
2094
  * .g — accumulated alpha (the channel that used to live in hdr.a before the HDR format
2087
2095
  * switched to rg11b10ufloat, which has no alpha). Sampled by composite/bloom to
2088
2096
  * un-premultiply color for tonemap and to produce the canvas-drawable alpha used by
2089
2097
  * the premultiplied alphaMode compositor (so the page background still shows through
2090
2098
  * cleared / edge-faded regions like before).
2091
- * rg8unorm at 4× MSAA is 8 bytes/texel — still fits Apple TBDR tile memory comfortably. */
2092
- 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"
2093
2104
  /**
2094
2105
  * The master switch for the id attachment. OFF — and off having been proven
2095
2106
  * to work, not off because it was never finished.
@@ -2632,6 +2643,22 @@ export class Engine {
2632
2643
  // composite adds bloomUp mip 0 × (color × intensity) to HDR before Filmic.
2633
2644
  // Matches EEVEE energy: tint/intensity applied at composite, not prefilter.
2634
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>()
2635
2662
  private bloomBlitUniformBuffer!: GPUBuffer
2636
2663
  private bloomUpsampleUniformBuffer!: GPUBuffer
2637
2664
  private readonly bloomBlitUniformData = new Float32Array(4)
@@ -6142,6 +6169,134 @@ export class Engine {
6142
6169
  /** The user's field mounts, drawn at half resolution for the composite to
6143
6170
  * upsample. Runs the whole quad — uniform control flow, so effects may use
6144
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
+
6145
6300
  private renderFieldPass(encoder: GPUCommandEncoder): void {
6146
6301
  // TWO PREDICATES, deliberately, and they are not interchangeable.
6147
6302
  //
@@ -7209,7 +7364,7 @@ export class Engine {
7209
7364
  multisample?: GPUMultisampleState
7210
7365
  }): GPURenderPipeline {
7211
7366
  const targets = config.fragmentTargets ?? (config.fragmentTarget ? [config.fragmentTarget] : undefined)
7212
- return this.device.createRenderPipeline({
7367
+ return this.createModelPipeline({
7213
7368
  label: config.label,
7214
7369
  layout: config.layout,
7215
7370
  vertex: {
@@ -7433,7 +7588,7 @@ export class Engine {
7433
7588
 
7434
7589
  this.noFillBuffer = this.device.createBuffer({
7435
7590
  label: "model light (none)",
7436
- size: 32,
7591
+ size: 48,
7437
7592
  usage: GPUBufferUsage.UNIFORM,
7438
7593
  })
7439
7594
 
@@ -7652,7 +7807,7 @@ export class Engine {
7652
7807
  depthCompare: this.depthAhead,
7653
7808
  },
7654
7809
  }
7655
- this.depthPrepassPipeline = this.device.createRenderPipeline({
7810
+ this.depthPrepassPipeline = this.createModelPipeline({
7656
7811
  label: "opaque depth prepass",
7657
7812
  ...prepassDesc,
7658
7813
  fragment: {
@@ -7664,7 +7819,7 @@ export class Engine {
7664
7819
  // The SOLID prime: same module, cutoff forced to exactly 1.0. Only texels
7665
7820
  // whose blend ignores the destination may pre-claim depth in the
7666
7821
  // transparent phase — see the override's note in depth-prepass.ts.
7667
- this.solidPrepassPipeline = this.device.createRenderPipeline({
7822
+ this.solidPrepassPipeline = this.createModelPipeline({
7668
7823
  label: "transparent solid prepass",
7669
7824
  ...prepassDesc,
7670
7825
  fragment: {
@@ -7680,7 +7835,7 @@ export class Engine {
7680
7835
  // hair depth from ever claiming the pixels the see-through-hair pass needs
7681
7836
  // the eye to survive on. (Bundle draws use the PASS's stencil reference;
7682
7837
  // only pipeline/bind/vertex state resets across executeBundles.)
7683
- this.hairPrimePipeline = this.device.createRenderPipeline({
7838
+ this.hairPrimePipeline = this.createModelPipeline({
7684
7839
  label: "hair depth prime",
7685
7840
  ...prepassDesc,
7686
7841
  depthStencil: {
@@ -8552,6 +8707,10 @@ export class Engine {
8552
8707
  usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
8553
8708
  })
8554
8709
  this.maskResolveView = this.maskResolveTexture.createView()
8710
+ this.sssScratch?.destroy()
8711
+ this.sssScratch = null
8712
+ this.sssBindGroupX = null
8713
+ this.sssBindGroupY = null
8555
8714
 
8556
8715
  // The floor mirror's targets — FULL resolution, the same attachment
8557
8716
  // contract and sample count as the scene pass, which is what lets the
@@ -10402,6 +10561,8 @@ export class Engine {
10402
10561
  bindGroup: this.groundShadowBindGroup!,
10403
10562
  materialName: "Ground",
10404
10563
  groupId: null,
10564
+ // The ground's own pipeline decides its culling.
10565
+ doubleSided: true,
10405
10566
  // The ground belongs to no model instance, so it is not in the cull list —
10406
10567
  // cullIndex -1 leaves renderGround unconditional. Its box is filled in
10407
10568
  // anyway rather than left a lie for whoever reads this next.
@@ -11020,8 +11181,12 @@ export class Engine {
11020
11181
  }
11021
11182
 
11022
11183
  /**
11023
- * Light one model with more than the world gives it: a FILL, linear RGB added
11024
- * to its ambient everywhere on it. Null takes it away again.
11184
+ * Light one model with more than the world gives it: a FILL, linear RGB times
11185
+ * its surface colour, added after its material graph. Null takes it away again.
11186
+ *
11187
+ * After the graph, not in its ambient: an NPR ramp read an ambient fill as
11188
+ * light, so sliding it moved her shadows and flipped whole regions across a
11189
+ * hard step. Added after, it brightens her evenly and every shadow stays put.
11025
11190
  *
11026
11191
  * A game lights its stage and its characters apart. Aether Gazer's rooms are
11027
11192
  * lit by their lamps and a near-black ambient, while its characters take a
@@ -11048,15 +11213,31 @@ export class Engine {
11048
11213
  return this.writeModelLight(name, 4, sun)
11049
11214
  }
11050
11215
 
11051
- /** One half of a model's light, at `at` (0 the fill, 4 the sun) in its
11052
- * ModelLight; w says whether that half is set. Both null releases the
11053
- * buffer and the model takes the shared zero stand-in again. */
11054
- private writeModelLight(name: string, at: 0 | 4, value: Vec3 | null): boolean {
11216
+ /**
11217
+ * Light one model by the sky's AVERAGE rather than its shape: the world's
11218
+ * colour and brightness from every direction alike. False gives it the
11219
+ * directional sky back.
11220
+ *
11221
+ * For a cast under an anime look. An HDRI's sky is brighter and warmer one
11222
+ * way than another, and taken at each normal it shades a face with soft
11223
+ * realistic gradients that no toon ramp drew — PBR on an anime face. Aether
11224
+ * Gazer gives its characters one flat base light instead. World strength and
11225
+ * colour still reach her; only the shape is gone. A flat World is its own
11226
+ * average, so without an HDRI this changes nothing.
11227
+ */
11228
+ setModelFlatSky(name: string, flat: boolean): boolean {
11229
+ return this.writeModelLight(name, 8, flat ? new Vec3(1, 0, 0) : null)
11230
+ }
11231
+
11232
+ /** One part of a model's light, at `at` (0 the fill, 4 the sun, 8 its
11233
+ * options) in its ModelLight; w says whether that part is set. All unset
11234
+ * releases the buffer and the model takes the shared zero stand-in again. */
11235
+ private writeModelLight(name: string, at: 0 | 4 | 8, value: Vec3 | null): boolean {
11055
11236
  const inst = this.modelInstances.get(name)
11056
11237
  if (!inst || !this.device) return false
11057
- const light = inst.modelLight ?? new Float32Array(8)
11238
+ const light = inst.modelLight ?? new Float32Array(12)
11058
11239
  light.set(value ? [value.x, value.y, value.z, 1] : [0, 0, 0, 0], at)
11059
- const any = light[3] > 0 || light[7] > 0
11240
+ const any = light[3] > 0 || light[7] > 0 || light[11] > 0
11060
11241
  if (!any) {
11061
11242
  if (!inst.lightBuffer) return true
11062
11243
  const retired = inst.lightBuffer
@@ -11072,7 +11253,7 @@ export class Engine {
11072
11253
  if (!inst.lightBuffer) {
11073
11254
  inst.lightBuffer = this.device.createBuffer({
11074
11255
  label: `${name}: light`,
11075
- size: 32,
11256
+ size: 48,
11076
11257
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
11077
11258
  })
11078
11259
  inst.mainPerInstanceBindGroup = this.perInstanceBindGroup(name, inst.skinMatrixBuffer, inst.lightBuffer)
@@ -14458,6 +14639,7 @@ export class Engine {
14458
14639
  groupId: null,
14459
14640
  baseBindGroupEntries,
14460
14641
  castsShadow,
14642
+ doubleSided: inst.isPlane || (mat.edgeFlag & 0x01) !== 0,
14461
14643
  outline,
14462
14644
  bounds,
14463
14645
  cullIndex: -1,
@@ -16042,7 +16224,10 @@ export class Engine {
16042
16224
  // The two real readers are both in the composite and both have their own
16043
16225
  // flag above: linearDepth() feeds the DoF gather and the depth handed to a
16044
16226
  // foreground mount. Nothing else binds depthTex at all.
16045
- 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()
16046
16231
  this.renderPassDescriptor.depthStencilAttachment!.depthStoreOp = depthRead ? "store" : "discard"
16047
16232
  if (depthRead) this.writeDepthOfFieldUniforms()
16048
16233
 
@@ -16240,6 +16425,9 @@ export class Engine {
16240
16425
  // kept ribbons out of bloom.
16241
16426
  this.drawTrails(pass, "camera")
16242
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)
16243
16431
  // The field mounts, likewise: after the scene so foregrounds can read its
16244
16432
  // depth, before the composite that samples both layers.
16245
16433
  this.renderFieldPass(encoder)
@@ -17060,13 +17248,55 @@ export class Engine {
17060
17248
  stencilWriteMask: 0xff,
17061
17249
  }
17062
17250
  }
17063
- return this.device.createRenderPipelineAsync({
17251
+ const desc: GPURenderPipelineDescriptor = {
17064
17252
  ...base,
17065
17253
  fragment: { module, constants, targets: blend === "additive" ? this.sceneTargetsAdditive : this.sceneTargets },
17066
17254
  depthStencil,
17255
+ }
17256
+ return this.device.createRenderPipelineAsync(desc).then((pipeline) => {
17257
+ this.pipelineDescs.set(pipeline, desc)
17258
+ return pipeline
17067
17259
  })
17068
17260
  }
17069
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
+
17070
17300
  // Pipeline for a material draw call: its group's compiled pipeline when grouped, else
17071
17301
  // the neutral base (ungrouped materials render the default graph). Transparent-bucket
17072
17302
  // draws on the CAST use the SAME depth-write-on pipeline — babylon-mmd's
@@ -17124,7 +17354,8 @@ export class Engine {
17124
17354
  pass.setBindGroup(1, inst.mainPerInstanceBindGroup)
17125
17355
  bound = true
17126
17356
  }
17127
- 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)
17128
17359
  if (pipeline !== currentPipeline) {
17129
17360
  pass.setPipeline(pipeline)
17130
17361
  currentPipeline = pipeline
@@ -17247,14 +17478,17 @@ export class Engine {
17247
17478
  inst: ModelInstance,
17248
17479
  view: { perFrame: GPUBindGroup; args: "camera" | "mirror"; outlines: boolean },
17249
17480
  ): void {
17250
- let bound = false
17481
+ let bound: GPURenderPipeline | null = null
17251
17482
  for (const draw of inst.drawCalls) {
17252
17483
  if (draw.type !== "opaque" || !this.isHairDraw(inst, draw)) continue
17253
- if (!bound) {
17254
- pass.setPipeline(this.hairPrimePipeline)
17255
- pass.setBindGroup(0, view.perFrame)
17256
- pass.setBindGroup(1, inst.mainPerInstanceBindGroup)
17257
- 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
17258
17492
  }
17259
17493
  pass.setBindGroup(2, draw.bindGroup)
17260
17494
  this.issueDraw(pass, draw, view.args)
@@ -17317,6 +17551,7 @@ export class Engine {
17317
17551
  if (install?.alphaMode === "hashed") pipeline = this.solidPrepassPipeline
17318
17552
  else if (install && install.alphaMode !== "opaque") continue
17319
17553
  }
17554
+ pipeline = this.sidedPipeline(pipeline, draw, view.args === "mirror")
17320
17555
  if (bound !== pipeline) {
17321
17556
  pass.setPipeline(pipeline)
17322
17557
  // One layout for both prepass pipelines, so groups 0 and 1 carry over.
@@ -17354,14 +17589,17 @@ export class Engine {
17354
17589
  view: { perFrame: GPUBindGroup; args: "camera" | "mirror"; outlines: boolean },
17355
17590
  ): void {
17356
17591
  if (inst.isStage) return
17357
- let bound = false
17592
+ let bound: GPURenderPipeline | null = null
17358
17593
  for (const draw of inst.drawCalls) {
17359
17594
  if (draw.type !== "transparent") continue
17360
- if (!bound) {
17361
- pass.setPipeline(this.solidPrepassPipeline)
17362
- pass.setBindGroup(0, view.perFrame)
17363
- pass.setBindGroup(1, inst.mainPerInstanceBindGroup)
17364
- 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
17365
17603
  }
17366
17604
  pass.setBindGroup(2, draw.bindGroup)
17367
17605
  this.issueDraw(pass, draw, view.args)
@@ -17421,8 +17659,9 @@ export class Engine {
17421
17659
  let currentPipeline: GPURenderPipeline | null = null
17422
17660
  for (const draw of inst.drawCalls) {
17423
17661
  if (draw.type !== "opaque") continue
17424
- const overEyes = this.overEyesPipelineFor(inst, draw)
17425
- if (!overEyes) continue
17662
+ const hairOverEyes = this.overEyesPipelineFor(inst, draw)
17663
+ if (!hairOverEyes) continue
17664
+ const overEyes = this.sidedPipeline(hairOverEyes, draw, view.args === "mirror")
17426
17665
  if (!bound) {
17427
17666
  pass.setBindGroup(0, view.perFrame)
17428
17667
  pass.setBindGroup(1, inst.mainPerInstanceBindGroup)
@@ -19,7 +19,7 @@ export const BODY_GRAPH: ShaderGraph = {
19
19
  { id: "tex_base", type: "mix/multiply", inputs: { fac: 1.0 } },
20
20
  { id: "geo", type: "geometry" },
21
21
  { id: "str", type: "shader_to_rgb_diffuse" },
22
- { id: "toon", type: "ramp_constant", inputs: { pos0: 0.0, pos1: 0.2966 } },
22
+ { id: "toon", type: "ramp_cardinal", inputs: { pos0: 0.2466, pos1: 0.3466 } },
23
23
  { id: "shadow_tint", type: "hue_sat", inputs: { hue: 0.5, saturation: 2.0, value: 0.3499999940395355, fac: 1.0 } },
24
24
  { id: "lit_tint", type: "hue_sat", inputs: { hue: 0.5, saturation: 1.5, value: 1.0, fac: 1.0 } },
25
25
  { id: "toon_color", type: "mix/blend" },
@@ -19,7 +19,7 @@ export const CLOTH_ROUGH_GRAPH: ShaderGraph = {
19
19
  { id: "tex_base", type: "mix/multiply", inputs: { fac: 1.0 } },
20
20
  { id: "geo", type: "geometry" },
21
21
  { id: "str", type: "shader_to_rgb_diffuse" },
22
- { id: "ramp_008", type: "ramp_constant_aa", inputs: { edge: 0.2966 } },
22
+ { id: "ramp_008", type: "ramp_cardinal", inputs: { pos0: 0.2466, pos1: 0.3466 } },
23
23
  { id: "mix04_fac", type: "math/multiply", inputs: { b: 0.5 } },
24
24
  { id: "dark_tex", type: "hue_sat", inputs: { hue: 0.5, saturation: 1.0, value: 0.19999998807907104, fac: 1.0 } },
25
25
  { id: "mix_004", type: "mix/blend" },
@@ -19,7 +19,7 @@ export const CLOTH_SMOOTH_GRAPH: ShaderGraph = {
19
19
  { id: "tex_base", type: "mix/multiply", inputs: { fac: 1.0 } },
20
20
  { id: "geo", type: "geometry" },
21
21
  { id: "str", type: "shader_to_rgb_diffuse" },
22
- { id: "ramp_008", type: "ramp_constant_aa", inputs: { edge: 0.2966 } },
22
+ { id: "ramp_008", type: "ramp_cardinal", inputs: { pos0: 0.2466, pos1: 0.3466 } },
23
23
  { id: "mix04_fac", type: "math/multiply", inputs: { b: 0.5 } },
24
24
  { id: "dark_tex", type: "hue_sat", inputs: { hue: 0.5, saturation: 1.0, value: 0.19999998807907104, fac: 1.0 } },
25
25
  { id: "mix_004", type: "mix/blend" },
@@ -28,6 +28,17 @@ export const EYE_GRAPH: ShaderGraph = {
28
28
  },
29
29
  { id: "emission", type: "emission", inputs: { strength: 1.5 } },
30
30
  { id: "add", type: "add_shader" },
31
+ // The iris glow follows the key round her head: full (1.5) lit from the
32
+ // front, easing to 0.2 of it lit from behind - a fixed 1.5 left the eyes
33
+ // bright white in a backlit face. head_basis.forward · L, half-Lambert,
34
+ // times the sun's shadow at the eye, so an eye in a shadowed face dims too.
35
+ { id: "eye_hb", type: "head_basis" },
36
+ { id: "eye_lt", type: "light" },
37
+ { id: "eye_fl", type: "vector_math/dot" },
38
+ { id: "eye_half", type: "math/multiply_add", inputs: { b: 0.5, c: 0.5 } },
39
+ { id: "eye_lvl", type: "map_range", inputs: { from_min: 0.0, from_max: 1.0, to_min: 0.2, to_max: 1.0 } },
40
+ { id: "eye_sh", type: "math/multiply" },
41
+ { id: "eye_str", type: "math/multiply", inputs: { b: 1.5 } },
31
42
  ],
32
43
  links: [
33
44
  { from: { node: "tex", socket: "color" }, to: { node: "tex_base", socket: "a" } },
@@ -36,6 +47,14 @@ export const EYE_GRAPH: ShaderGraph = {
36
47
  { from: { node: "tex_base", socket: "color" }, to: { node: "emission", socket: "color" } },
37
48
  { from: { node: "principled", socket: "color" }, to: { node: "add", socket: "a" } },
38
49
  { from: { node: "emission", socket: "color" }, to: { node: "add", socket: "b" } },
50
+ { from: { node: "eye_hb", socket: "forward" }, to: { node: "eye_fl", socket: "a" } },
51
+ { from: { node: "eye_lt", socket: "direction" }, to: { node: "eye_fl", socket: "b" } },
52
+ { from: { node: "eye_fl", socket: "value" }, to: { node: "eye_half", socket: "a" } },
53
+ { from: { node: "eye_half", socket: "value" }, to: { node: "eye_sh", socket: "a" } },
54
+ { from: { node: "eye_lt", socket: "shadow" }, to: { node: "eye_sh", socket: "b" } },
55
+ { from: { node: "eye_sh", socket: "value" }, to: { node: "eye_lvl", socket: "value" } },
56
+ { from: { node: "eye_lvl", socket: "value" }, to: { node: "eye_str", socket: "a" } },
57
+ { from: { node: "eye_str", socket: "value" }, to: { node: "emission", socket: "strength" } },
39
58
  ],
40
59
  output: { node: "add", socket: "color" },
41
60
  }
@@ -24,7 +24,7 @@ export const FACE_GRAPH: ShaderGraph = {
24
24
 
25
25
  // ── toon shading ──
26
26
  { id: "str", type: "shader_to_rgb_diffuse" },
27
- { id: "toon", type: "ramp_constant_aa", inputs: { edge: 0.2966 } },
27
+ { id: "toon", type: "ramp_cardinal", inputs: { pos0: 0.2466, pos1: 0.3466 } },
28
28
  { id: "shadow_tint", type: "hue_sat", inputs: { hue: 0.46000000834465027, saturation: 2.0, value: 0.3499999940395355, fac: 1.0 } },
29
29
  { id: "lit_tint", type: "hue_sat", inputs: { hue: 0.46000000834465027, saturation: 1.600000023841858, value: 1.5, fac: 1.0 } },
30
30
  { id: "toon_color", type: "mix/blend" },
@@ -29,7 +29,7 @@ export const HAIR_GRAPH: ShaderGraph = {
29
29
  { id: "hs_002", type: "hue_sat", inputs: { hue: 0.48, saturation: 1.2, value: 0.7, fac: 1.0 } },
30
30
  { id: "hs_001", type: "hue_sat", inputs: { hue: 0.5, saturation: 1.5, value: 1.0, fac: 1.0 } },
31
31
  { id: "str", type: "shader_to_rgb_diffuse" },
32
- { id: "ramp_008", type: "ramp_constant", inputs: { pos0: 0.0, color0: [0, 0, 0, 1], pos1: 0.2966, color1: [1, 1, 1, 1] } },
32
+ { id: "ramp_008", type: "ramp_cardinal", inputs: { pos0: 0.2466, color0: [0, 0, 0, 1], pos1: 0.3466, color1: [1, 1, 1, 1] } },
33
33
  { id: "mix_004", type: "mix/blend" },
34
34
  { id: "bc", type: "bright_contrast", inputs: { bright: 0.1, contrast: 0.2 } },
35
35
  { id: "sep_n", type: "separate_xyz" },
@@ -21,7 +21,7 @@ export const METAL_GRAPH: ShaderGraph = {
21
21
  { id: "geo", type: "geometry" },
22
22
  { id: "tex_tint", type: "hue_sat", inputs: { hue: 0.5, saturation: 1.0, value: 0.800000011920929, fac: 1.0 } },
23
23
  { id: "str", type: "shader_to_rgb_diffuse" },
24
- { id: "ramp_008", type: "ramp_constant_aa", inputs: { edge: 0.2966 } },
24
+ { id: "ramp_008", type: "ramp_cardinal", inputs: { pos0: 0.2466, pos1: 0.3466 } },
25
25
  { id: "mix04_fac", type: "math/multiply", inputs: { b: 0.5 } },
26
26
  { id: "dark_tex", type: "hue_sat", inputs: { hue: 0.5, saturation: 1.0, value: 0.19999998807907104, fac: 1.0 } },
27
27
  { id: "mix_004", type: "mix/blend" },
@@ -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