reze-engine 0.11.0 → 0.11.1

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 (50) hide show
  1. package/README.md +40 -22
  2. package/dist/engine.d.ts +13 -6
  3. package/dist/engine.d.ts.map +1 -1
  4. package/dist/engine.js +184 -70
  5. package/dist/shaders/body.d.ts +1 -1
  6. package/dist/shaders/body.d.ts.map +1 -1
  7. package/dist/shaders/body.js +44 -21
  8. package/dist/shaders/cloth_rough.d.ts +1 -1
  9. package/dist/shaders/cloth_rough.d.ts.map +1 -1
  10. package/dist/shaders/cloth_rough.js +38 -20
  11. package/dist/shaders/cloth_smooth.d.ts +1 -1
  12. package/dist/shaders/cloth_smooth.d.ts.map +1 -1
  13. package/dist/shaders/cloth_smooth.js +33 -18
  14. package/dist/shaders/default.d.ts +1 -1
  15. package/dist/shaders/default.d.ts.map +1 -1
  16. package/dist/shaders/default.js +29 -12
  17. package/dist/shaders/dfg_lut.d.ts +2 -3
  18. package/dist/shaders/dfg_lut.d.ts.map +1 -1
  19. package/dist/shaders/dfg_lut.js +29 -25
  20. package/dist/shaders/eye.d.ts +1 -1
  21. package/dist/shaders/eye.d.ts.map +1 -1
  22. package/dist/shaders/eye.js +29 -12
  23. package/dist/shaders/face.d.ts +1 -1
  24. package/dist/shaders/face.d.ts.map +1 -1
  25. package/dist/shaders/face.js +47 -23
  26. package/dist/shaders/hair.d.ts +1 -1
  27. package/dist/shaders/hair.d.ts.map +1 -1
  28. package/dist/shaders/hair.js +42 -32
  29. package/dist/shaders/metal.d.ts +1 -1
  30. package/dist/shaders/metal.d.ts.map +1 -1
  31. package/dist/shaders/metal.js +35 -19
  32. package/dist/shaders/nodes.d.ts +1 -1
  33. package/dist/shaders/nodes.d.ts.map +1 -1
  34. package/dist/shaders/nodes.js +79 -37
  35. package/dist/shaders/stockings.d.ts +1 -1
  36. package/dist/shaders/stockings.d.ts.map +1 -1
  37. package/dist/shaders/stockings.js +30 -15
  38. package/package.json +1 -1
  39. package/src/engine.ts +200 -78
  40. package/src/shaders/body.ts +44 -21
  41. package/src/shaders/cloth_rough.ts +38 -20
  42. package/src/shaders/cloth_smooth.ts +33 -18
  43. package/src/shaders/default.ts +29 -12
  44. package/src/shaders/dfg_lut.ts +31 -27
  45. package/src/shaders/eye.ts +29 -12
  46. package/src/shaders/face.ts +47 -23
  47. package/src/shaders/hair.ts +42 -32
  48. package/src/shaders/metal.ts +35 -19
  49. package/src/shaders/nodes.ts +79 -37
  50. package/src/shaders/stockings.ts +30 -15
package/src/engine.ts CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  type AssetReader,
15
15
  } from "./asset-reader"
16
16
  import { DEFAULT_SHADER_WGSL } from "./shaders/default"
17
- import { DFG_LUT_SIZE, DFG_LUT_WGSL } from "./shaders/dfg_lut"
17
+ import { BRDF_LUT_SIZE, BRDF_LUT_BAKE_WGSL } from "./shaders/dfg_lut"
18
18
  import { LTC_MAG_LUT_SIZE, LTC_MAG_LUT_DATA } from "./shaders/ltc_mag_lut"
19
19
  import { FACE_SHADER_WGSL } from "./shaders/face"
20
20
  import { HAIR_SHADER_WGSL } from "./shaders/hair"
@@ -78,7 +78,7 @@ export const DEFAULT_BLOOM_OPTIONS: BloomOptions = {
78
78
  knee: 0.5,
79
79
  radius: 4.0,
80
80
  color: new Vec3(1.0, 0.7247558832168579, 0.6487361788749695),
81
- intensity: 0.03,
81
+ intensity: 0.05,
82
82
  clamp: 0.0,
83
83
  }
84
84
 
@@ -212,6 +212,13 @@ export class Engine {
212
212
  private hdrResolveTexture!: GPUTexture
213
213
  private static readonly MULTISAMPLE_COUNT = 4
214
214
  private static readonly HDR_FORMAT: GPUTextureFormat = "rgba16float"
215
+ /** Single-channel mask written alongside HDR color — 1 = model geometry (contributes
216
+ * to bloom), 0 = ground (never blooms). Sampled by the bloom blit pass to gate the
217
+ * prefilter so ground brightness can't halo the scene. */
218
+ private static readonly BLOOM_MASK_FORMAT: GPUTextureFormat = "r8unorm"
219
+ private multisampleMaskTexture!: GPUTexture
220
+ private maskResolveTexture!: GPUTexture
221
+ private maskResolveView!: GPUTextureView
215
222
  private renderPassDescriptor!: GPURenderPassDescriptor
216
223
  private compositePassDescriptor!: GPURenderPassDescriptor
217
224
  private compositePipeline!: GPURenderPipeline
@@ -256,11 +263,9 @@ export class Engine {
256
263
  private hasGround = false
257
264
  private shadowMapTexture!: GPUTexture
258
265
  private shadowMapDepthView!: GPUTextureView
259
- private dfgLutTexture!: GPUTexture
260
- private dfgLutView!: GPUTextureView
261
- private ltcMagLutTexture!: GPUTexture
262
- private ltcMagLutView!: GPUTextureView
263
- private static readonly SHADOW_MAP_SIZE = 4096
266
+ private brdfLutTexture!: GPUTexture
267
+ private brdfLutView!: GPUTextureView
268
+ private static readonly SHADOW_MAP_SIZE = 2048
264
269
  private shadowDepthPipeline!: GPURenderPipeline
265
270
  private shadowLightVPBuffer!: GPUBuffer
266
271
  private shadowLightVPMatrix = new Float32Array(16)
@@ -287,6 +292,8 @@ export class Engine {
287
292
  private modelInstances = new Map<string, ModelInstance>()
288
293
  private materialSampler!: GPUSampler
289
294
  private textureCache = new Map<string, GPUTexture>()
295
+ private mipBlitPipeline: GPURenderPipeline | null = null
296
+ private mipBlitSampler: GPUSampler | null = null
290
297
  private _nextDefaultModelId = 0
291
298
 
292
299
  // IK and physics enabled at engine level (same for all models)
@@ -473,51 +480,24 @@ export class Engine {
473
480
  Engine.instance = this
474
481
  }
475
482
 
476
- // One-shot bake of EEVEE's BRDF split-sum DFG LUT — ported from
477
- // bsdf_lut_frag.glsl. Runs once per engine init; resulting 64×64 rg16float
478
- // texture is sampled by every material shader via group(0) binding(9).
479
- private bakeDfgLut() {
480
- this.dfgLutTexture = this.device.createTexture({
481
- label: "DFG LUT",
482
- size: [DFG_LUT_SIZE, DFG_LUT_SIZE],
483
- format: "rg16float",
484
- usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
485
- })
486
- this.dfgLutView = this.dfgLutTexture.createView()
487
-
488
- const module = this.device.createShaderModule({ label: "DFG LUT bake", code: DFG_LUT_WGSL })
489
- const pipeline = this.device.createRenderPipeline({
490
- label: "DFG LUT bake pipeline",
491
- layout: "auto",
492
- vertex: { module, entryPoint: "vs" },
493
- fragment: { module, entryPoint: "fs", targets: [{ format: "rg16float" }] },
494
- primitive: { topology: "triangle-list" },
495
- })
496
-
497
- const enc = this.device.createCommandEncoder({ label: "DFG LUT bake encoder" })
498
- const pass = enc.beginRenderPass({
499
- colorAttachments: [
500
- { view: this.dfgLutView, clearValue: { r: 0, g: 0, b: 0, a: 1 }, loadOp: "clear", storeOp: "store" },
501
- ],
502
- })
503
- pass.setPipeline(pipeline)
504
- pass.draw(3, 1, 0, 0)
505
- pass.end()
506
- this.device.queue.submit([enc.finish()])
507
- }
483
+ // One-shot bake of EEVEE's combined BRDF LUT — DFG (bsdf_lut_frag.glsl) packed
484
+ // with ltc_mag_ggx (eevee_lut.c) into a single 64×64 rgba8unorm texture:
485
+ // .rg = split-sum DFG → F_brdf_*_scatter
486
+ // .ba = LTC magnitude → ltc_brdf_scale_from_lut
487
+ // One texture fetch per fragment replaces the previous 2–3 taps. rgba8unorm
488
+ // (vs rgba16float) halves sample bandwidth; DFG/LTC values fit [0,1] cleanly.
489
+ private bakeBrdfLut() {
490
+ if (BRDF_LUT_SIZE !== LTC_MAG_LUT_SIZE) {
491
+ throw new Error("BRDF LUT bake requires DFG size == LTC size (both 64).")
492
+ }
508
493
 
509
- // Upload Blender's static LTC GGX magnitude LUT (eevee_lut.c ltc_mag_ggx[]).
510
- // Pairs with the DFG LUT to form ltc_brdf_scale — closure_eval_glossy_lib.glsl:79-81.
511
- private uploadLtcMagLut() {
512
- this.ltcMagLutTexture = this.device.createTexture({
513
- label: "LTC mag LUT",
494
+ // Temp rg16float LTC source loaded 1:1 by the bake fragment shader, then dropped.
495
+ const ltcTemp = this.device.createTexture({
496
+ label: "LTC mag LUT (bake input)",
514
497
  size: [LTC_MAG_LUT_SIZE, LTC_MAG_LUT_SIZE],
515
498
  format: "rg16float",
516
499
  usage: GPUTextureUsage.COPY_DST | GPUTextureUsage.TEXTURE_BINDING,
517
500
  })
518
- this.ltcMagLutView = this.ltcMagLutTexture.createView()
519
-
520
- // Float32 → float16 bits. rg16float writeTexture expects packed half floats.
521
501
  const n = LTC_MAG_LUT_DATA.length
522
502
  const half = new Uint16Array(n)
523
503
  const f32 = new Float32Array(1)
@@ -527,22 +507,58 @@ export class Engine {
527
507
  const x = u32[0]
528
508
  const sign = (x >>> 16) & 0x8000
529
509
  let exp = ((x >>> 23) & 0xff) - 127 + 15
530
- let mant = x & 0x7fffff
510
+ const mant = x & 0x7fffff
531
511
  if (exp <= 0) {
532
- half[i] = sign // flush tiny values to signed zero (data here is in [0, ~1])
512
+ half[i] = sign
533
513
  } else if (exp >= 31) {
534
- half[i] = sign | 0x7c00 // inf
514
+ half[i] = sign | 0x7c00
535
515
  } else {
536
516
  half[i] = sign | (exp << 10) | (mant >>> 13)
537
517
  }
538
518
  }
539
-
540
519
  this.device.queue.writeTexture(
541
- { texture: this.ltcMagLutTexture },
520
+ { texture: ltcTemp },
542
521
  half,
543
522
  { bytesPerRow: LTC_MAG_LUT_SIZE * 4, rowsPerImage: LTC_MAG_LUT_SIZE },
544
523
  { width: LTC_MAG_LUT_SIZE, height: LTC_MAG_LUT_SIZE, depthOrArrayLayers: 1 }
545
524
  )
525
+
526
+ this.brdfLutTexture = this.device.createTexture({
527
+ label: "BRDF LUT (DFG + LTC packed)",
528
+ size: [BRDF_LUT_SIZE, BRDF_LUT_SIZE],
529
+ format: "rgba8unorm",
530
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
531
+ })
532
+ this.brdfLutView = this.brdfLutTexture.createView()
533
+
534
+ const module = this.device.createShaderModule({ label: "BRDF LUT bake", code: BRDF_LUT_BAKE_WGSL })
535
+ const pipeline = this.device.createRenderPipeline({
536
+ label: "BRDF LUT bake pipeline",
537
+ layout: "auto",
538
+ vertex: { module, entryPoint: "vs" },
539
+ fragment: { module, entryPoint: "fs", targets: [{ format: "rgba8unorm" }] },
540
+ primitive: { topology: "triangle-list" },
541
+ })
542
+
543
+ const bakeBindGroup = this.device.createBindGroup({
544
+ label: "BRDF LUT bake bind group",
545
+ layout: pipeline.getBindGroupLayout(0),
546
+ entries: [{ binding: 0, resource: ltcTemp.createView() }],
547
+ })
548
+
549
+ const enc = this.device.createCommandEncoder({ label: "BRDF LUT bake encoder" })
550
+ const pass = enc.beginRenderPass({
551
+ colorAttachments: [
552
+ { view: this.brdfLutView, clearValue: { r: 0, g: 0, b: 0, a: 1 }, loadOp: "clear", storeOp: "store" },
553
+ ],
554
+ })
555
+ pass.setPipeline(pipeline)
556
+ pass.setBindGroup(0, bakeBindGroup)
557
+ pass.draw(3, 1, 0, 0)
558
+ pass.end()
559
+ this.device.queue.submit([enc.finish()])
560
+
561
+ ltcTemp.destroy()
546
562
  }
547
563
 
548
564
  private createRenderPipeline(config: {
@@ -551,11 +567,13 @@ export class Engine {
551
567
  shaderModule: GPUShaderModule
552
568
  vertexBuffers: GPUVertexBufferLayout[]
553
569
  fragmentTarget?: GPUColorTargetState
570
+ fragmentTargets?: GPUColorTargetState[]
554
571
  fragmentEntryPoint?: string
555
572
  cullMode?: GPUCullMode
556
573
  depthStencil?: GPUDepthStencilState
557
574
  multisample?: GPUMultisampleState
558
575
  }): GPURenderPipeline {
576
+ const targets = config.fragmentTargets ?? (config.fragmentTarget ? [config.fragmentTarget] : undefined)
559
577
  return this.device.createRenderPipeline({
560
578
  label: config.label,
561
579
  layout: config.layout,
@@ -563,11 +581,11 @@ export class Engine {
563
581
  module: config.shaderModule,
564
582
  buffers: config.vertexBuffers,
565
583
  },
566
- fragment: config.fragmentTarget
584
+ fragment: targets
567
585
  ? {
568
586
  module: config.shaderModule,
569
587
  entryPoint: config.fragmentEntryPoint,
570
- targets: [config.fragmentTarget],
588
+ targets,
571
589
  }
572
590
  : undefined,
573
591
  primitive: { cullMode: config.cullMode ?? "none" },
@@ -580,6 +598,7 @@ export class Engine {
580
598
  this.materialSampler = this.device.createSampler({
581
599
  magFilter: "linear",
582
600
  minFilter: "linear",
601
+ mipmapFilter: "linear",
583
602
  addressModeU: "repeat",
584
603
  addressModeV: "repeat",
585
604
  })
@@ -641,6 +660,12 @@ export class Engine {
641
660
  },
642
661
  }
643
662
 
663
+ // Bloom mask target — r8unorm has no alpha channel, so src-alpha blending is invalid.
664
+ // Use replace mode: depth test already rejects occluded fragments, so last-writer-wins
665
+ // on surviving pixels gives the right result (ground writes 0; models/outlines write 1).
666
+ const maskBlend: GPUColorTargetState = { format: Engine.BLOOM_MASK_FORMAT }
667
+ const sceneTargets: GPUColorTargetState[] = [standardBlend, maskBlend]
668
+
644
669
  const shaderModule = this.device.createShaderModule({
645
670
  label: "default model shader",
646
671
  code: DEFAULT_SHADER_WGSL,
@@ -697,7 +722,6 @@ export class Engine {
697
722
  { binding: 4, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "comparison" } },
698
723
  { binding: 5, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
699
724
  { binding: 9, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
700
- { binding: 10, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
701
725
  ],
702
726
  })
703
727
  // group 1: per-instance (skinMats) — bound once per model
@@ -730,7 +754,7 @@ export class Engine {
730
754
  layout: mainPipelineLayout,
731
755
  shaderModule,
732
756
  vertexBuffers: fullVertexBuffers,
733
- fragmentTarget: standardBlend,
757
+ fragmentTargets: sceneTargets,
734
758
  cullMode: "none",
735
759
  depthStencil: {
736
760
  format: "depth24plus-stencil8",
@@ -744,7 +768,7 @@ export class Engine {
744
768
  layout: mainPipelineLayout,
745
769
  shaderModule: faceShaderModule,
746
770
  vertexBuffers: fullVertexBuffers,
747
- fragmentTarget: standardBlend,
771
+ fragmentTargets: sceneTargets,
748
772
  cullMode: "none",
749
773
  depthStencil: {
750
774
  format: "depth24plus-stencil8",
@@ -758,7 +782,7 @@ export class Engine {
758
782
  layout: mainPipelineLayout,
759
783
  shaderModule: hairShaderModule,
760
784
  vertexBuffers: fullVertexBuffers,
761
- fragmentTarget: standardBlend,
785
+ fragmentTargets: sceneTargets,
762
786
  cullMode: "none",
763
787
  depthStencil: {
764
788
  format: "depth24plus-stencil8",
@@ -772,7 +796,7 @@ export class Engine {
772
796
  layout: mainPipelineLayout,
773
797
  shaderModule: clothSmoothShaderModule,
774
798
  vertexBuffers: fullVertexBuffers,
775
- fragmentTarget: standardBlend,
799
+ fragmentTargets: sceneTargets,
776
800
  cullMode: "none",
777
801
  depthStencil: {
778
802
  format: "depth24plus-stencil8",
@@ -786,7 +810,7 @@ export class Engine {
786
810
  layout: mainPipelineLayout,
787
811
  shaderModule: clothRoughShaderModule,
788
812
  vertexBuffers: fullVertexBuffers,
789
- fragmentTarget: standardBlend,
813
+ fragmentTargets: sceneTargets,
790
814
  cullMode: "none",
791
815
  depthStencil: {
792
816
  format: "depth24plus-stencil8",
@@ -800,7 +824,7 @@ export class Engine {
800
824
  layout: mainPipelineLayout,
801
825
  shaderModule: metalShaderModule,
802
826
  vertexBuffers: fullVertexBuffers,
803
- fragmentTarget: standardBlend,
827
+ fragmentTargets: sceneTargets,
804
828
  cullMode: "none",
805
829
  depthStencil: {
806
830
  format: "depth24plus-stencil8",
@@ -814,7 +838,7 @@ export class Engine {
814
838
  layout: mainPipelineLayout,
815
839
  shaderModule: bodyShaderModule,
816
840
  vertexBuffers: fullVertexBuffers,
817
- fragmentTarget: standardBlend,
841
+ fragmentTargets: sceneTargets,
818
842
  cullMode: "none",
819
843
  depthStencil: {
820
844
  format: "depth24plus-stencil8",
@@ -828,7 +852,7 @@ export class Engine {
828
852
  layout: mainPipelineLayout,
829
853
  shaderModule: eyeShaderModule,
830
854
  vertexBuffers: fullVertexBuffers,
831
- fragmentTarget: standardBlend,
855
+ fragmentTargets: sceneTargets,
832
856
  cullMode: "none",
833
857
  depthStencil: {
834
858
  format: "depth24plus-stencil8",
@@ -842,7 +866,7 @@ export class Engine {
842
866
  layout: mainPipelineLayout,
843
867
  shaderModule: stockingsShaderModule,
844
868
  vertexBuffers: fullVertexBuffers,
845
- fragmentTarget: standardBlend,
869
+ fragmentTargets: sceneTargets,
846
870
  cullMode: "none",
847
871
  depthStencil: {
848
872
  format: "depth24plus-stencil8",
@@ -907,10 +931,8 @@ export class Engine {
907
931
  })
908
932
  this.shadowMapDepthView = this.shadowMapTexture.createView()
909
933
 
910
- // One-shot bake of Blender EEVEE's BRDF split-sum DFG LUT (bsdf_lut_frag.glsl).
911
- this.bakeDfgLut()
912
- // Upload static LTC GGX magnitude LUT for direct-specular energy compensation.
913
- this.uploadLtcMagLut()
934
+ // One-shot bake of Blender EEVEE's combined BRDF LUT (DFG + LTC packed rgba8unorm).
935
+ this.bakeBrdfLut()
914
936
 
915
937
  // Now that shadow resources exist, create the main per-frame bind group
916
938
  this.perFrameBindGroup = this.device.createBindGroup({
@@ -923,8 +945,7 @@ export class Engine {
923
945
  { binding: 3, resource: this.shadowMapDepthView },
924
946
  { binding: 4, resource: this.shadowComparisonSampler },
925
947
  { binding: 5, resource: { buffer: this.shadowLightVPBuffer } },
926
- { binding: 9, resource: this.dfgLutView },
927
- { binding: 10, resource: this.ltcMagLutView },
948
+ { binding: 9, resource: this.brdfLutView },
928
949
  ],
929
950
  })
930
951
 
@@ -989,7 +1010,8 @@ export class Engine {
989
1010
  var o: VO; o.worldPos = position; o.normal = normal;
990
1011
  o.position = camera.projection * camera.view * vec4f(position, 1.0); return o;
991
1012
  }
992
- @fragment fn fs(i: VO) -> @location(0) vec4f {
1013
+ struct FSOut { @location(0) color: vec4f, @location(1) mask: f32 };
1014
+ @fragment fn fs(i: VO) -> FSOut {
993
1015
  let n = normalize(i.normal);
994
1016
  let centerDist = length(i.worldPos.xz);
995
1017
  let edgeFade = 1.0 - smoothstep(0.0, 1.0, clamp((centerDist - material.fadeStart) / max(material.fadeEnd - material.fadeStart, 0.001), 0.0, 1.0));
@@ -1027,7 +1049,10 @@ export class Engine {
1027
1049
  var baseColor = material.diffuseColor * sun * (1.0 - dark * 0.65);
1028
1050
  baseColor *= noiseTint;
1029
1051
  let finalColor = mix(baseColor, material.gridLineColor, gridLine * material.gridLineOpacity * edgeFade);
1030
- return vec4f(finalColor * edgeFade, edgeFade);
1052
+ var out: FSOut;
1053
+ out.color = vec4f(finalColor * edgeFade, edgeFade);
1054
+ out.mask = 0.0;
1055
+ return out;
1031
1056
  }
1032
1057
  `,
1033
1058
  })
@@ -1036,7 +1061,7 @@ export class Engine {
1036
1061
  layout: this.device.createPipelineLayout({ bindGroupLayouts: [this.groundShadowBindGroupLayout] }),
1037
1062
  shaderModule: groundShadowShader,
1038
1063
  vertexBuffers: fullVertexBuffers,
1039
- fragmentTarget: standardBlend,
1064
+ fragmentTargets: sceneTargets,
1040
1065
  cullMode: "back",
1041
1066
  depthStencil: { format: "depth24plus-stencil8", depthWriteEnabled: true, depthCompare: "less-equal" },
1042
1067
  })
@@ -1149,8 +1174,12 @@ export class Engine {
1149
1174
  return output;
1150
1175
  }
1151
1176
 
1152
- @fragment fn fs() -> @location(0) vec4f {
1153
- return material.edgeColor;
1177
+ struct FSOut { @location(0) color: vec4f, @location(1) mask: f32 };
1178
+ @fragment fn fs() -> FSOut {
1179
+ var out: FSOut;
1180
+ out.color = material.edgeColor;
1181
+ out.mask = 1.0;
1182
+ return out;
1154
1183
  }
1155
1184
  `,
1156
1185
  })
@@ -1160,7 +1189,7 @@ export class Engine {
1160
1189
  layout: outlinePipelineLayout,
1161
1190
  shaderModule: outlineShaderModule,
1162
1191
  vertexBuffers: outlineVertexBuffers,
1163
- fragmentTarget: standardBlend,
1192
+ fragmentTargets: sceneTargets,
1164
1193
  cullMode: "back",
1165
1194
  depthStencil: {
1166
1195
  format: "depth24plus-stencil8",
@@ -1197,6 +1226,7 @@ export class Engine {
1197
1226
  entries: [
1198
1227
  { binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "unfilterable-float" } },
1199
1228
  { binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
1229
+ { binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "unfilterable-float" } },
1200
1230
  ],
1201
1231
  })
1202
1232
  this.bloomDownsampleBindGroupLayout = this.device.createBindGroupLayout({
@@ -1230,6 +1260,7 @@ export class Engine {
1230
1260
  code: `${bloomFullscreenVs}
1231
1261
  @group(0) @binding(0) var hdrTex: texture_2d<f32>;
1232
1262
  @group(0) @binding(1) var<uniform> prefilter: vec4<f32>; // threshold, knee, clamp, _unused
1263
+ @group(0) @binding(2) var maskTex: texture_2d<f32>;
1233
1264
 
1234
1265
  fn luminance(c: vec3f) -> f32 {
1235
1266
  return dot(max(c, vec3f(0.0)), vec3f(0.2126, 0.7152, 0.0722));
@@ -1240,8 +1271,11 @@ export class Engine {
1240
1271
  let s = textureLoad(hdrTex, cc, 0);
1241
1272
  // Scene pass uses src-alpha blend with clear alpha 0 → premultiplied. Unpremultiply.
1242
1273
  let rgb = max(s.rgb / max(s.a, 1e-6), vec3f(0.0));
1274
+ // Bloom mask: MRT r8unorm written by material shaders (1.0 = bloom, 0.0 = skip).
1275
+ let mask = textureLoad(maskTex, cc, 0).r;
1276
+ let masked = rgb * mask;
1243
1277
  // Blender: clamp each tap BEFORE Karis average (eevee_bloom: color = min(clampIntensity, color)).
1244
- return select(rgb, min(rgb, vec3f(clampV)), clampV > 0.0);
1278
+ return select(masked, min(masked, vec3f(clampV)), clampV > 0.0);
1245
1279
  }
1246
1280
 
1247
1281
  @fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
@@ -1587,6 +1621,23 @@ export class Engine {
1587
1621
  usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1588
1622
  })
1589
1623
 
1624
+ // Bloom-mask MRT attachments — same dims + MSAA as HDR so they share the render pass.
1625
+ // MS buffer gets resolved into maskResolveTexture, which the bloom blit pass samples.
1626
+ this.multisampleMaskTexture = this.device.createTexture({
1627
+ label: "multisample bloom mask",
1628
+ size: [width, height],
1629
+ sampleCount: Engine.MULTISAMPLE_COUNT,
1630
+ format: Engine.BLOOM_MASK_FORMAT,
1631
+ usage: GPUTextureUsage.RENDER_ATTACHMENT,
1632
+ })
1633
+ this.maskResolveTexture = this.device.createTexture({
1634
+ label: "bloom mask resolve",
1635
+ size: [width, height],
1636
+ format: Engine.BLOOM_MASK_FORMAT,
1637
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1638
+ })
1639
+ this.maskResolveView = this.maskResolveTexture.createView()
1640
+
1590
1641
  // Bloom pyramid: mip 0 is half-res, each subsequent mip halves again.
1591
1642
  // Mip count chosen so the coarsest mip is ≥4 px on the short side, capped at BLOOM_MAX_LEVELS.
1592
1643
  const bw = Math.max(1, Math.floor(width / 2))
@@ -1642,9 +1693,17 @@ export class Engine {
1642
1693
  storeOp: "store",
1643
1694
  }
1644
1695
 
1696
+ const maskAttachment: GPURenderPassColorAttachment = {
1697
+ view: this.multisampleMaskTexture.createView(),
1698
+ resolveTarget: this.maskResolveView,
1699
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
1700
+ loadOp: "clear",
1701
+ storeOp: "store",
1702
+ }
1703
+
1645
1704
  this.renderPassDescriptor = {
1646
1705
  label: "renderPass",
1647
- colorAttachments: [colorAttachment],
1706
+ colorAttachments: [colorAttachment, maskAttachment],
1648
1707
  depthStencilAttachment: {
1649
1708
  view: depthTextureView,
1650
1709
  depthClearValue: 1.0,
@@ -1679,6 +1738,7 @@ export class Engine {
1679
1738
  entries: [
1680
1739
  { binding: 0, resource: this.hdrResolveTexture.createView() },
1681
1740
  { binding: 1, resource: { buffer: this.bloomBlitUniformBuffer } },
1741
+ { binding: 2, resource: this.maskResolveView },
1682
1742
  ],
1683
1743
  })
1684
1744
  // Downsample[i] reads bloomDown mip (i-1), writes bloomDown mip i. i ∈ [1..N-1].
@@ -2534,10 +2594,12 @@ export class Engine {
2534
2594
  colorSpaceConversion: "none",
2535
2595
  })
2536
2596
 
2597
+ const mipLevelCount = Math.floor(Math.log2(Math.max(imageBitmap.width, imageBitmap.height))) + 1
2537
2598
  const texture = this.device.createTexture({
2538
2599
  label: `texture: ${cacheKey}`,
2539
2600
  size: [imageBitmap.width, imageBitmap.height],
2540
2601
  format: "rgba8unorm-srgb",
2602
+ mipLevelCount,
2541
2603
  usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
2542
2604
  })
2543
2605
  this.device.queue.copyExternalImageToTexture({ source: imageBitmap }, { texture }, [
@@ -2545,6 +2607,8 @@ export class Engine {
2545
2607
  imageBitmap.height,
2546
2608
  ])
2547
2609
 
2610
+ if (mipLevelCount > 1) this.generateMipmaps(texture, mipLevelCount)
2611
+
2548
2612
  this.textureCache.set(cacheKey, texture)
2549
2613
  inst.textureCacheKeys.push(cacheKey)
2550
2614
  return texture
@@ -2553,6 +2617,64 @@ export class Engine {
2553
2617
  }
2554
2618
  }
2555
2619
 
2620
+ // Bilinear box-filter downsample per level. Reads srgb view (hardware linearizes on sample,
2621
+ // re-encodes on write), so intensities are filtered in linear space — matching EEVEE/Blender.
2622
+ private generateMipmaps(texture: GPUTexture, mipLevelCount: number) {
2623
+ if (!this.mipBlitPipeline || !this.mipBlitSampler) {
2624
+ this.mipBlitSampler = this.device.createSampler({
2625
+ magFilter: "linear",
2626
+ minFilter: "linear",
2627
+ addressModeU: "clamp-to-edge",
2628
+ addressModeV: "clamp-to-edge",
2629
+ })
2630
+ const module = this.device.createShaderModule({
2631
+ label: "mipmap blit",
2632
+ code: /* wgsl */ `
2633
+ @group(0) @binding(0) var src: texture_2d<f32>;
2634
+ @group(0) @binding(1) var samp: sampler;
2635
+ @vertex fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
2636
+ let x = f32((vi & 1u) << 2u) - 1.0;
2637
+ let y = f32((vi & 2u) << 1u) - 1.0;
2638
+ return vec4f(x, y, 0.0, 1.0);
2639
+ }
2640
+ @fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
2641
+ let dstDims = vec2f(textureDimensions(src)) * 0.5;
2642
+ let uv = p.xy / max(dstDims, vec2f(1.0));
2643
+ return textureSampleLevel(src, samp, uv, 0.0);
2644
+ }
2645
+ `,
2646
+ })
2647
+ this.mipBlitPipeline = this.device.createRenderPipeline({
2648
+ label: "mipmap blit pipeline",
2649
+ layout: "auto",
2650
+ vertex: { module, entryPoint: "vs" },
2651
+ fragment: { module, entryPoint: "fs", targets: [{ format: "rgba8unorm-srgb" }] },
2652
+ primitive: { topology: "triangle-list" },
2653
+ })
2654
+ }
2655
+
2656
+ const encoder = this.device.createCommandEncoder({ label: "mipgen" })
2657
+ for (let level = 1; level < mipLevelCount; level++) {
2658
+ const srcView = texture.createView({ baseMipLevel: level - 1, mipLevelCount: 1 })
2659
+ const dstView = texture.createView({ baseMipLevel: level, mipLevelCount: 1 })
2660
+ const bindGroup = this.device.createBindGroup({
2661
+ layout: this.mipBlitPipeline.getBindGroupLayout(0),
2662
+ entries: [
2663
+ { binding: 0, resource: srcView },
2664
+ { binding: 1, resource: this.mipBlitSampler },
2665
+ ],
2666
+ })
2667
+ const pass = encoder.beginRenderPass({
2668
+ colorAttachments: [{ view: dstView, clearValue: { r: 0, g: 0, b: 0, a: 0 }, loadOp: "clear", storeOp: "store" }],
2669
+ })
2670
+ pass.setPipeline(this.mipBlitPipeline)
2671
+ pass.setBindGroup(0, bindGroup)
2672
+ pass.draw(3)
2673
+ pass.end()
2674
+ }
2675
+ this.device.queue.submit([encoder.finish()])
2676
+ }
2677
+
2556
2678
  private renderGround(pass: GPURenderPassEncoder) {
2557
2679
  if (!this.hasGround || !this.groundVertexBuffer || !this.groundIndexBuffer || !this.groundDrawCall) return
2558
2680
  pass.setPipeline(this.groundShadowPipeline)