reze-engine 0.11.0 → 0.11.2

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 +14 -7
  3. package/dist/engine.d.ts.map +1 -1
  4. package/dist/engine.js +206 -77
  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 +58 -47
  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 +45 -42
  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 +30 -26
  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 +47 -43
  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 +2 -2
  39. package/src/engine.ts +227 -97
  40. package/src/shaders/body.ts +58 -47
  41. package/src/shaders/cloth_rough.ts +38 -20
  42. package/src/shaders/cloth_smooth.ts +33 -18
  43. package/src/shaders/default.ts +46 -42
  44. package/src/shaders/dfg_lut.ts +32 -28
  45. package/src/shaders/eye.ts +48 -43
  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,21 +78,23 @@ 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
 
85
85
  /** Blender Color Management / View (rendering.txt: Filmic, exposure, gamma). `look` is reserved for future curve tweaks. */
86
86
  export type ViewTransformOptions = {
87
- /** Stops applied before Filmic: `linear *= 2^exposure` (Blender default often ~−0.3). */
87
+ /** Stops applied before Filmic: `linear *= 2^exposure`. */
88
88
  exposure: number
89
89
  /** After Filmic, display gamma (`pow(rgb, 1/gamma)`). */
90
90
  gamma: number
91
91
  look: "default" | "medium_high_contrast"
92
92
  }
93
93
 
94
+ // Matches the reference Blender project: Filmic view, Medium High Contrast look,
95
+ // exposure 0.3, gamma 1.0, sRGB display, no curves.
94
96
  export const DEFAULT_VIEW_TRANSFORM: ViewTransformOptions = {
95
- exposure: -0.30000001192092896,
97
+ exposure: 0.6,
96
98
  gamma: 1.0,
97
99
  look: "medium_high_contrast",
98
100
  }
@@ -212,6 +214,13 @@ export class Engine {
212
214
  private hdrResolveTexture!: GPUTexture
213
215
  private static readonly MULTISAMPLE_COUNT = 4
214
216
  private static readonly HDR_FORMAT: GPUTextureFormat = "rgba16float"
217
+ /** Single-channel mask written alongside HDR color — 1 = model geometry (contributes
218
+ * to bloom), 0 = ground (never blooms). Sampled by the bloom blit pass to gate the
219
+ * prefilter so ground brightness can't halo the scene. */
220
+ private static readonly BLOOM_MASK_FORMAT: GPUTextureFormat = "r8unorm"
221
+ private multisampleMaskTexture!: GPUTexture
222
+ private maskResolveTexture!: GPUTexture
223
+ private maskResolveView!: GPUTextureView
215
224
  private renderPassDescriptor!: GPURenderPassDescriptor
216
225
  private compositePassDescriptor!: GPURenderPassDescriptor
217
226
  private compositePipeline!: GPURenderPipeline
@@ -256,11 +265,9 @@ export class Engine {
256
265
  private hasGround = false
257
266
  private shadowMapTexture!: GPUTexture
258
267
  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
268
+ private brdfLutTexture!: GPUTexture
269
+ private brdfLutView!: GPUTextureView
270
+ private static readonly SHADOW_MAP_SIZE = 2048
264
271
  private shadowDepthPipeline!: GPURenderPipeline
265
272
  private shadowLightVPBuffer!: GPUBuffer
266
273
  private shadowLightVPMatrix = new Float32Array(16)
@@ -287,6 +294,8 @@ export class Engine {
287
294
  private modelInstances = new Map<string, ModelInstance>()
288
295
  private materialSampler!: GPUSampler
289
296
  private textureCache = new Map<string, GPUTexture>()
297
+ private mipBlitPipeline: GPURenderPipeline | null = null
298
+ private mipBlitSampler: GPUSampler | null = null
290
299
  private _nextDefaultModelId = 0
291
300
 
292
301
  // IK and physics enabled at engine level (same for all models)
@@ -428,9 +437,12 @@ export class Engine {
428
437
  private writeBloomUniforms(): void {
429
438
  const b = this.bloomSettings
430
439
  const bu = this.bloomBlitUniformData
431
- // EEVEE prefilter: threshold, knee, clamp (0 → disabled), _unused
440
+ // EEVEE prefilter: threshold, knee_half, clamp (0 → disabled), _unused
441
+ // Blender halves the knee before passing to the shader (eevee_bloom.c: knee * 0.5f).
442
+ // The blit shader's quadratic soft-knee curve uses knee_half as the offset from threshold,
443
+ // so the soft ramp spans [threshold - knee/2 .. threshold + knee/2] — NOT [threshold - knee .. threshold + knee].
432
444
  bu[0] = b.threshold
433
- bu[1] = b.knee
445
+ bu[1] = b.knee * 0.5
434
446
  bu[2] = b.clamp
435
447
  bu[3] = 0.0
436
448
  this.device.queue.writeBuffer(this.bloomBlitUniformBuffer, 0, bu)
@@ -473,51 +485,24 @@ export class Engine {
473
485
  Engine.instance = this
474
486
  }
475
487
 
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
- }
488
+ // One-shot bake of EEVEE's combined BRDF LUT — DFG (bsdf_lut_frag.glsl) packed
489
+ // with ltc_mag_ggx (eevee_lut.c) into a single 64×64 rgba8unorm texture:
490
+ // .rg = split-sum DFG → F_brdf_*_scatter
491
+ // .ba = LTC magnitude → ltc_brdf_scale_from_lut
492
+ // One texture fetch per fragment replaces the previous 2–3 taps. rgba8unorm
493
+ // (vs rgba16float) halves sample bandwidth; DFG/LTC values fit [0,1] cleanly.
494
+ private bakeBrdfLut() {
495
+ if (BRDF_LUT_SIZE !== LTC_MAG_LUT_SIZE) {
496
+ throw new Error("BRDF LUT bake requires DFG size == LTC size (both 64).")
497
+ }
508
498
 
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",
499
+ // Temp rg16float LTC source loaded 1:1 by the bake fragment shader, then dropped.
500
+ const ltcTemp = this.device.createTexture({
501
+ label: "LTC mag LUT (bake input)",
514
502
  size: [LTC_MAG_LUT_SIZE, LTC_MAG_LUT_SIZE],
515
503
  format: "rg16float",
516
504
  usage: GPUTextureUsage.COPY_DST | GPUTextureUsage.TEXTURE_BINDING,
517
505
  })
518
- this.ltcMagLutView = this.ltcMagLutTexture.createView()
519
-
520
- // Float32 → float16 bits. rg16float writeTexture expects packed half floats.
521
506
  const n = LTC_MAG_LUT_DATA.length
522
507
  const half = new Uint16Array(n)
523
508
  const f32 = new Float32Array(1)
@@ -527,22 +512,58 @@ export class Engine {
527
512
  const x = u32[0]
528
513
  const sign = (x >>> 16) & 0x8000
529
514
  let exp = ((x >>> 23) & 0xff) - 127 + 15
530
- let mant = x & 0x7fffff
515
+ const mant = x & 0x7fffff
531
516
  if (exp <= 0) {
532
- half[i] = sign // flush tiny values to signed zero (data here is in [0, ~1])
517
+ half[i] = sign
533
518
  } else if (exp >= 31) {
534
- half[i] = sign | 0x7c00 // inf
519
+ half[i] = sign | 0x7c00
535
520
  } else {
536
521
  half[i] = sign | (exp << 10) | (mant >>> 13)
537
522
  }
538
523
  }
539
-
540
524
  this.device.queue.writeTexture(
541
- { texture: this.ltcMagLutTexture },
525
+ { texture: ltcTemp },
542
526
  half,
543
527
  { bytesPerRow: LTC_MAG_LUT_SIZE * 4, rowsPerImage: LTC_MAG_LUT_SIZE },
544
- { width: LTC_MAG_LUT_SIZE, height: LTC_MAG_LUT_SIZE, depthOrArrayLayers: 1 }
528
+ { width: LTC_MAG_LUT_SIZE, height: LTC_MAG_LUT_SIZE, depthOrArrayLayers: 1 },
545
529
  )
530
+
531
+ this.brdfLutTexture = this.device.createTexture({
532
+ label: "BRDF LUT (DFG + LTC packed)",
533
+ size: [BRDF_LUT_SIZE, BRDF_LUT_SIZE],
534
+ format: "rgba8unorm",
535
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
536
+ })
537
+ this.brdfLutView = this.brdfLutTexture.createView()
538
+
539
+ const module = this.device.createShaderModule({ label: "BRDF LUT bake", code: BRDF_LUT_BAKE_WGSL })
540
+ const pipeline = this.device.createRenderPipeline({
541
+ label: "BRDF LUT bake pipeline",
542
+ layout: "auto",
543
+ vertex: { module, entryPoint: "vs" },
544
+ fragment: { module, entryPoint: "fs", targets: [{ format: "rgba8unorm" }] },
545
+ primitive: { topology: "triangle-list" },
546
+ })
547
+
548
+ const bakeBindGroup = this.device.createBindGroup({
549
+ label: "BRDF LUT bake bind group",
550
+ layout: pipeline.getBindGroupLayout(0),
551
+ entries: [{ binding: 0, resource: ltcTemp.createView() }],
552
+ })
553
+
554
+ const enc = this.device.createCommandEncoder({ label: "BRDF LUT bake encoder" })
555
+ const pass = enc.beginRenderPass({
556
+ colorAttachments: [
557
+ { view: this.brdfLutView, clearValue: { r: 0, g: 0, b: 0, a: 1 }, loadOp: "clear", storeOp: "store" },
558
+ ],
559
+ })
560
+ pass.setPipeline(pipeline)
561
+ pass.setBindGroup(0, bakeBindGroup)
562
+ pass.draw(3, 1, 0, 0)
563
+ pass.end()
564
+ this.device.queue.submit([enc.finish()])
565
+
566
+ ltcTemp.destroy()
546
567
  }
547
568
 
548
569
  private createRenderPipeline(config: {
@@ -551,11 +572,13 @@ export class Engine {
551
572
  shaderModule: GPUShaderModule
552
573
  vertexBuffers: GPUVertexBufferLayout[]
553
574
  fragmentTarget?: GPUColorTargetState
575
+ fragmentTargets?: GPUColorTargetState[]
554
576
  fragmentEntryPoint?: string
555
577
  cullMode?: GPUCullMode
556
578
  depthStencil?: GPUDepthStencilState
557
579
  multisample?: GPUMultisampleState
558
580
  }): GPURenderPipeline {
581
+ const targets = config.fragmentTargets ?? (config.fragmentTarget ? [config.fragmentTarget] : undefined)
559
582
  return this.device.createRenderPipeline({
560
583
  label: config.label,
561
584
  layout: config.layout,
@@ -563,11 +586,11 @@ export class Engine {
563
586
  module: config.shaderModule,
564
587
  buffers: config.vertexBuffers,
565
588
  },
566
- fragment: config.fragmentTarget
589
+ fragment: targets
567
590
  ? {
568
591
  module: config.shaderModule,
569
592
  entryPoint: config.fragmentEntryPoint,
570
- targets: [config.fragmentTarget],
593
+ targets,
571
594
  }
572
595
  : undefined,
573
596
  primitive: { cullMode: config.cullMode ?? "none" },
@@ -580,6 +603,7 @@ export class Engine {
580
603
  this.materialSampler = this.device.createSampler({
581
604
  magFilter: "linear",
582
605
  minFilter: "linear",
606
+ mipmapFilter: "linear",
583
607
  addressModeU: "repeat",
584
608
  addressModeV: "repeat",
585
609
  })
@@ -641,6 +665,12 @@ export class Engine {
641
665
  },
642
666
  }
643
667
 
668
+ // Bloom mask target — r8unorm has no alpha channel, so src-alpha blending is invalid.
669
+ // Use replace mode: depth test already rejects occluded fragments, so last-writer-wins
670
+ // on surviving pixels gives the right result (ground writes 0; models/outlines write 1).
671
+ const maskBlend: GPUColorTargetState = { format: Engine.BLOOM_MASK_FORMAT }
672
+ const sceneTargets: GPUColorTargetState[] = [standardBlend, maskBlend]
673
+
644
674
  const shaderModule = this.device.createShaderModule({
645
675
  label: "default model shader",
646
676
  code: DEFAULT_SHADER_WGSL,
@@ -697,7 +727,6 @@ export class Engine {
697
727
  { binding: 4, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "comparison" } },
698
728
  { binding: 5, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
699
729
  { binding: 9, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
700
- { binding: 10, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
701
730
  ],
702
731
  })
703
732
  // group 1: per-instance (skinMats) — bound once per model
@@ -730,7 +759,7 @@ export class Engine {
730
759
  layout: mainPipelineLayout,
731
760
  shaderModule,
732
761
  vertexBuffers: fullVertexBuffers,
733
- fragmentTarget: standardBlend,
762
+ fragmentTargets: sceneTargets,
734
763
  cullMode: "none",
735
764
  depthStencil: {
736
765
  format: "depth24plus-stencil8",
@@ -744,7 +773,7 @@ export class Engine {
744
773
  layout: mainPipelineLayout,
745
774
  shaderModule: faceShaderModule,
746
775
  vertexBuffers: fullVertexBuffers,
747
- fragmentTarget: standardBlend,
776
+ fragmentTargets: sceneTargets,
748
777
  cullMode: "none",
749
778
  depthStencil: {
750
779
  format: "depth24plus-stencil8",
@@ -758,7 +787,7 @@ export class Engine {
758
787
  layout: mainPipelineLayout,
759
788
  shaderModule: hairShaderModule,
760
789
  vertexBuffers: fullVertexBuffers,
761
- fragmentTarget: standardBlend,
790
+ fragmentTargets: sceneTargets,
762
791
  cullMode: "none",
763
792
  depthStencil: {
764
793
  format: "depth24plus-stencil8",
@@ -772,7 +801,7 @@ export class Engine {
772
801
  layout: mainPipelineLayout,
773
802
  shaderModule: clothSmoothShaderModule,
774
803
  vertexBuffers: fullVertexBuffers,
775
- fragmentTarget: standardBlend,
804
+ fragmentTargets: sceneTargets,
776
805
  cullMode: "none",
777
806
  depthStencil: {
778
807
  format: "depth24plus-stencil8",
@@ -786,7 +815,7 @@ export class Engine {
786
815
  layout: mainPipelineLayout,
787
816
  shaderModule: clothRoughShaderModule,
788
817
  vertexBuffers: fullVertexBuffers,
789
- fragmentTarget: standardBlend,
818
+ fragmentTargets: sceneTargets,
790
819
  cullMode: "none",
791
820
  depthStencil: {
792
821
  format: "depth24plus-stencil8",
@@ -800,7 +829,7 @@ export class Engine {
800
829
  layout: mainPipelineLayout,
801
830
  shaderModule: metalShaderModule,
802
831
  vertexBuffers: fullVertexBuffers,
803
- fragmentTarget: standardBlend,
832
+ fragmentTargets: sceneTargets,
804
833
  cullMode: "none",
805
834
  depthStencil: {
806
835
  format: "depth24plus-stencil8",
@@ -814,7 +843,7 @@ export class Engine {
814
843
  layout: mainPipelineLayout,
815
844
  shaderModule: bodyShaderModule,
816
845
  vertexBuffers: fullVertexBuffers,
817
- fragmentTarget: standardBlend,
846
+ fragmentTargets: sceneTargets,
818
847
  cullMode: "none",
819
848
  depthStencil: {
820
849
  format: "depth24plus-stencil8",
@@ -828,7 +857,7 @@ export class Engine {
828
857
  layout: mainPipelineLayout,
829
858
  shaderModule: eyeShaderModule,
830
859
  vertexBuffers: fullVertexBuffers,
831
- fragmentTarget: standardBlend,
860
+ fragmentTargets: sceneTargets,
832
861
  cullMode: "none",
833
862
  depthStencil: {
834
863
  format: "depth24plus-stencil8",
@@ -842,7 +871,7 @@ export class Engine {
842
871
  layout: mainPipelineLayout,
843
872
  shaderModule: stockingsShaderModule,
844
873
  vertexBuffers: fullVertexBuffers,
845
- fragmentTarget: standardBlend,
874
+ fragmentTargets: sceneTargets,
846
875
  cullMode: "none",
847
876
  depthStencil: {
848
877
  format: "depth24plus-stencil8",
@@ -907,10 +936,8 @@ export class Engine {
907
936
  })
908
937
  this.shadowMapDepthView = this.shadowMapTexture.createView()
909
938
 
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()
939
+ // One-shot bake of Blender EEVEE's combined BRDF LUT (DFG + LTC packed rgba8unorm).
940
+ this.bakeBrdfLut()
914
941
 
915
942
  // Now that shadow resources exist, create the main per-frame bind group
916
943
  this.perFrameBindGroup = this.device.createBindGroup({
@@ -923,8 +950,7 @@ export class Engine {
923
950
  { binding: 3, resource: this.shadowMapDepthView },
924
951
  { binding: 4, resource: this.shadowComparisonSampler },
925
952
  { binding: 5, resource: { buffer: this.shadowLightVPBuffer } },
926
- { binding: 9, resource: this.dfgLutView },
927
- { binding: 10, resource: this.ltcMagLutView },
953
+ { binding: 9, resource: this.brdfLutView },
928
954
  ],
929
955
  })
930
956
 
@@ -989,7 +1015,8 @@ export class Engine {
989
1015
  var o: VO; o.worldPos = position; o.normal = normal;
990
1016
  o.position = camera.projection * camera.view * vec4f(position, 1.0); return o;
991
1017
  }
992
- @fragment fn fs(i: VO) -> @location(0) vec4f {
1018
+ struct FSOut { @location(0) color: vec4f, @location(1) mask: f32 };
1019
+ @fragment fn fs(i: VO) -> FSOut {
993
1020
  let n = normalize(i.normal);
994
1021
  let centerDist = length(i.worldPos.xz);
995
1022
  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 +1054,10 @@ export class Engine {
1027
1054
  var baseColor = material.diffuseColor * sun * (1.0 - dark * 0.65);
1028
1055
  baseColor *= noiseTint;
1029
1056
  let finalColor = mix(baseColor, material.gridLineColor, gridLine * material.gridLineOpacity * edgeFade);
1030
- return vec4f(finalColor * edgeFade, edgeFade);
1057
+ var out: FSOut;
1058
+ out.color = vec4f(finalColor * edgeFade, edgeFade);
1059
+ out.mask = 0.0;
1060
+ return out;
1031
1061
  }
1032
1062
  `,
1033
1063
  })
@@ -1036,7 +1066,7 @@ export class Engine {
1036
1066
  layout: this.device.createPipelineLayout({ bindGroupLayouts: [this.groundShadowBindGroupLayout] }),
1037
1067
  shaderModule: groundShadowShader,
1038
1068
  vertexBuffers: fullVertexBuffers,
1039
- fragmentTarget: standardBlend,
1069
+ fragmentTargets: sceneTargets,
1040
1070
  cullMode: "back",
1041
1071
  depthStencil: { format: "depth24plus-stencil8", depthWriteEnabled: true, depthCompare: "less-equal" },
1042
1072
  })
@@ -1149,8 +1179,12 @@ export class Engine {
1149
1179
  return output;
1150
1180
  }
1151
1181
 
1152
- @fragment fn fs() -> @location(0) vec4f {
1153
- return material.edgeColor;
1182
+ struct FSOut { @location(0) color: vec4f, @location(1) mask: f32 };
1183
+ @fragment fn fs() -> FSOut {
1184
+ var out: FSOut;
1185
+ out.color = material.edgeColor;
1186
+ out.mask = 1.0;
1187
+ return out;
1154
1188
  }
1155
1189
  `,
1156
1190
  })
@@ -1160,7 +1194,7 @@ export class Engine {
1160
1194
  layout: outlinePipelineLayout,
1161
1195
  shaderModule: outlineShaderModule,
1162
1196
  vertexBuffers: outlineVertexBuffers,
1163
- fragmentTarget: standardBlend,
1197
+ fragmentTargets: sceneTargets,
1164
1198
  cullMode: "back",
1165
1199
  depthStencil: {
1166
1200
  format: "depth24plus-stencil8",
@@ -1197,6 +1231,7 @@ export class Engine {
1197
1231
  entries: [
1198
1232
  { binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "unfilterable-float" } },
1199
1233
  { binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
1234
+ { binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "unfilterable-float" } },
1200
1235
  ],
1201
1236
  })
1202
1237
  this.bloomDownsampleBindGroupLayout = this.device.createBindGroupLayout({
@@ -1230,6 +1265,7 @@ export class Engine {
1230
1265
  code: `${bloomFullscreenVs}
1231
1266
  @group(0) @binding(0) var hdrTex: texture_2d<f32>;
1232
1267
  @group(0) @binding(1) var<uniform> prefilter: vec4<f32>; // threshold, knee, clamp, _unused
1268
+ @group(0) @binding(2) var maskTex: texture_2d<f32>;
1233
1269
 
1234
1270
  fn luminance(c: vec3f) -> f32 {
1235
1271
  return dot(max(c, vec3f(0.0)), vec3f(0.2126, 0.7152, 0.0722));
@@ -1240,8 +1276,11 @@ export class Engine {
1240
1276
  let s = textureLoad(hdrTex, cc, 0);
1241
1277
  // Scene pass uses src-alpha blend with clear alpha 0 → premultiplied. Unpremultiply.
1242
1278
  let rgb = max(s.rgb / max(s.a, 1e-6), vec3f(0.0));
1279
+ // Bloom mask: MRT r8unorm written by material shaders (1.0 = bloom, 0.0 = skip).
1280
+ let mask = textureLoad(maskTex, cc, 0).r;
1281
+ let masked = rgb * mask;
1243
1282
  // Blender: clamp each tap BEFORE Karis average (eevee_bloom: color = min(clampIntensity, color)).
1244
- return select(rgb, min(rgb, vec3f(clampV)), clampV > 0.0);
1283
+ return select(masked, min(masked, vec3f(clampV)), clampV > 0.0);
1245
1284
  }
1246
1285
 
1247
1286
  @fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
@@ -1339,7 +1378,9 @@ export class Engine {
1339
1378
  })
1340
1379
 
1341
1380
  const bloomBlitLayout = this.device.createPipelineLayout({ bindGroupLayouts: [this.bloomBlitBindGroupLayout] })
1342
- const bloomDownLayout = this.device.createPipelineLayout({ bindGroupLayouts: [this.bloomDownsampleBindGroupLayout] })
1381
+ const bloomDownLayout = this.device.createPipelineLayout({
1382
+ bindGroupLayouts: [this.bloomDownsampleBindGroupLayout],
1383
+ })
1343
1384
  const bloomUpLayout = this.device.createPipelineLayout({ bindGroupLayouts: [this.bloomUpsampleBindGroupLayout] })
1344
1385
 
1345
1386
  this.bloomBlitPipeline = this.device.createRenderPipeline({
@@ -1392,9 +1433,14 @@ export class Engine {
1392
1433
  // viewU[0] = (exposure, gamma, _, _); viewU[1] = (tint.rgb, intensity)
1393
1434
 
1394
1435
  fn filmic(x: f32) -> f32 {
1436
+ // Re-fit against Blender 3.6 Filmic MHC anchors (sobotka/filmic-blender
1437
+ // look_medium-high-contrast.spi1d). Previous curve was compressed:
1438
+ // midtones too bright, highlights too dim — flattened contrast, read
1439
+ // as "washed-out" on saturated surfaces (hair especially).
1440
+ // Reference checkpoints: linear 0.18 → ~0.395, linear 1.0 → ~0.83.
1395
1441
  var lut = array<f32, 14>(
1396
- 0.0067, 0.0141, 0.0272, 0.0499, 0.0885, 0.1512, 0.2462,
1397
- 0.3753, 0.5273, 0.6776, 0.8031, 0.8929, 0.9495, 0.9814
1442
+ 0.0028, 0.0068, 0.0151, 0.0313, 0.0610, 0.1120, 0.1920,
1443
+ 0.3060, 0.4590, 0.6310, 0.8200, 0.9070, 0.9620, 0.9890
1398
1444
  );
1399
1445
  let t = clamp(log2(max(x, 1e-10)) + 10.0, 0.0, 13.0);
1400
1446
  let i = u32(t);
@@ -1415,7 +1461,8 @@ export class Engine {
1415
1461
  let fullSz = vec2f(textureDimensions(hdrTex));
1416
1462
  let bloomSz = vec2f(textureDimensions(bloomTex));
1417
1463
  // Bloom is at half-res (pyramid mip 0). Sampler interpolates back to full-res UVs.
1418
- let bloomUv = (fragCoord.xy + vec2f(0.5)) / max(fullSz, vec2f(1.0));
1464
+ // fragCoord.xy is already at pixel center (e.g. 0.5, 0.5 for first pixel).
1465
+ let bloomUv = fragCoord.xy / max(fullSz, vec2f(1.0));
1419
1466
  let tint = viewU[1].xyz;
1420
1467
  let intensity = viewU[1].w;
1421
1468
  let bloom = textureSampleLevel(bloomTex, bloomSamp, bloomUv, 0.0).rgb * tint * intensity;
@@ -1587,15 +1634,29 @@ export class Engine {
1587
1634
  usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1588
1635
  })
1589
1636
 
1637
+ // Bloom-mask MRT attachments — same dims + MSAA as HDR so they share the render pass.
1638
+ // MS buffer gets resolved into maskResolveTexture, which the bloom blit pass samples.
1639
+ this.multisampleMaskTexture = this.device.createTexture({
1640
+ label: "multisample bloom mask",
1641
+ size: [width, height],
1642
+ sampleCount: Engine.MULTISAMPLE_COUNT,
1643
+ format: Engine.BLOOM_MASK_FORMAT,
1644
+ usage: GPUTextureUsage.RENDER_ATTACHMENT,
1645
+ })
1646
+ this.maskResolveTexture = this.device.createTexture({
1647
+ label: "bloom mask resolve",
1648
+ size: [width, height],
1649
+ format: Engine.BLOOM_MASK_FORMAT,
1650
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1651
+ })
1652
+ this.maskResolveView = this.maskResolveTexture.createView()
1653
+
1590
1654
  // Bloom pyramid: mip 0 is half-res, each subsequent mip halves again.
1591
1655
  // Mip count chosen so the coarsest mip is ≥4 px on the short side, capped at BLOOM_MAX_LEVELS.
1592
1656
  const bw = Math.max(1, Math.floor(width / 2))
1593
1657
  const bh = Math.max(1, Math.floor(height / 2))
1594
1658
  const shortSide = Math.max(1, Math.min(bw, bh))
1595
- this.bloomMipCount = Math.max(
1596
- 1,
1597
- Math.min(Engine.BLOOM_MAX_LEVELS, Math.floor(Math.log2(shortSide)) - 1),
1598
- )
1659
+ this.bloomMipCount = Math.max(1, Math.min(Engine.BLOOM_MAX_LEVELS, Math.floor(Math.log2(shortSide)) - 1))
1599
1660
  this.bloomDownTexture = this.device.createTexture({
1600
1661
  label: "bloom down pyramid",
1601
1662
  size: [bw, bh],
@@ -1612,16 +1673,12 @@ export class Engine {
1612
1673
  })
1613
1674
  this.bloomDownMipViews = []
1614
1675
  for (let i = 0; i < this.bloomMipCount; i++) {
1615
- this.bloomDownMipViews.push(
1616
- this.bloomDownTexture.createView({ baseMipLevel: i, mipLevelCount: 1 }),
1617
- )
1676
+ this.bloomDownMipViews.push(this.bloomDownTexture.createView({ baseMipLevel: i, mipLevelCount: 1 }))
1618
1677
  }
1619
1678
  this.bloomUpMipViews = []
1620
1679
  const upLevels = Math.max(1, this.bloomMipCount - 1)
1621
1680
  for (let i = 0; i < upLevels; i++) {
1622
- this.bloomUpMipViews.push(
1623
- this.bloomUpTexture.createView({ baseMipLevel: i, mipLevelCount: 1 }),
1624
- )
1681
+ this.bloomUpMipViews.push(this.bloomUpTexture.createView({ baseMipLevel: i, mipLevelCount: 1 }))
1625
1682
  }
1626
1683
 
1627
1684
  this.depthTexture = this.device.createTexture({
@@ -1642,9 +1699,17 @@ export class Engine {
1642
1699
  storeOp: "store",
1643
1700
  }
1644
1701
 
1702
+ const maskAttachment: GPURenderPassColorAttachment = {
1703
+ view: this.multisampleMaskTexture.createView(),
1704
+ resolveTarget: this.maskResolveView,
1705
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
1706
+ loadOp: "clear",
1707
+ storeOp: "store",
1708
+ }
1709
+
1645
1710
  this.renderPassDescriptor = {
1646
1711
  label: "renderPass",
1647
- colorAttachments: [colorAttachment],
1712
+ colorAttachments: [colorAttachment, maskAttachment],
1648
1713
  depthStencilAttachment: {
1649
1714
  view: depthTextureView,
1650
1715
  depthClearValue: 1.0,
@@ -1679,6 +1744,7 @@ export class Engine {
1679
1744
  entries: [
1680
1745
  { binding: 0, resource: this.hdrResolveTexture.createView() },
1681
1746
  { binding: 1, resource: { buffer: this.bloomBlitUniformBuffer } },
1747
+ { binding: 2, resource: this.maskResolveView },
1682
1748
  ],
1683
1749
  })
1684
1750
  // Downsample[i] reads bloomDown mip (i-1), writes bloomDown mip i. i ∈ [1..N-1].
@@ -2534,10 +2600,12 @@ export class Engine {
2534
2600
  colorSpaceConversion: "none",
2535
2601
  })
2536
2602
 
2603
+ const mipLevelCount = Math.floor(Math.log2(Math.max(imageBitmap.width, imageBitmap.height))) + 1
2537
2604
  const texture = this.device.createTexture({
2538
2605
  label: `texture: ${cacheKey}`,
2539
2606
  size: [imageBitmap.width, imageBitmap.height],
2540
2607
  format: "rgba8unorm-srgb",
2608
+ mipLevelCount,
2541
2609
  usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
2542
2610
  })
2543
2611
  this.device.queue.copyExternalImageToTexture({ source: imageBitmap }, { texture }, [
@@ -2545,6 +2613,8 @@ export class Engine {
2545
2613
  imageBitmap.height,
2546
2614
  ])
2547
2615
 
2616
+ if (mipLevelCount > 1) this.generateMipmaps(texture, mipLevelCount)
2617
+
2548
2618
  this.textureCache.set(cacheKey, texture)
2549
2619
  inst.textureCacheKeys.push(cacheKey)
2550
2620
  return texture
@@ -2553,6 +2623,66 @@ export class Engine {
2553
2623
  }
2554
2624
  }
2555
2625
 
2626
+ // Bilinear box-filter downsample per level. Reads srgb view (hardware linearizes on sample,
2627
+ // re-encodes on write), so intensities are filtered in linear space — matching EEVEE/Blender.
2628
+ private generateMipmaps(texture: GPUTexture, mipLevelCount: number) {
2629
+ if (!this.mipBlitPipeline || !this.mipBlitSampler) {
2630
+ this.mipBlitSampler = this.device.createSampler({
2631
+ magFilter: "linear",
2632
+ minFilter: "linear",
2633
+ addressModeU: "clamp-to-edge",
2634
+ addressModeV: "clamp-to-edge",
2635
+ })
2636
+ const module = this.device.createShaderModule({
2637
+ label: "mipmap blit",
2638
+ code: /* wgsl */ `
2639
+ @group(0) @binding(0) var src: texture_2d<f32>;
2640
+ @group(0) @binding(1) var samp: sampler;
2641
+ @vertex fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
2642
+ let x = f32((vi & 1u) << 2u) - 1.0;
2643
+ let y = f32((vi & 2u) << 1u) - 1.0;
2644
+ return vec4f(x, y, 0.0, 1.0);
2645
+ }
2646
+ @fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
2647
+ let dstDims = vec2f(textureDimensions(src)) * 0.5;
2648
+ let uv = p.xy / max(dstDims, vec2f(1.0));
2649
+ return textureSampleLevel(src, samp, uv, 0.0);
2650
+ }
2651
+ `,
2652
+ })
2653
+ this.mipBlitPipeline = this.device.createRenderPipeline({
2654
+ label: "mipmap blit pipeline",
2655
+ layout: "auto",
2656
+ vertex: { module, entryPoint: "vs" },
2657
+ fragment: { module, entryPoint: "fs", targets: [{ format: "rgba8unorm-srgb" }] },
2658
+ primitive: { topology: "triangle-list" },
2659
+ })
2660
+ }
2661
+
2662
+ const encoder = this.device.createCommandEncoder({ label: "mipgen" })
2663
+ for (let level = 1; level < mipLevelCount; level++) {
2664
+ const srcView = texture.createView({ baseMipLevel: level - 1, mipLevelCount: 1 })
2665
+ const dstView = texture.createView({ baseMipLevel: level, mipLevelCount: 1 })
2666
+ const bindGroup = this.device.createBindGroup({
2667
+ layout: this.mipBlitPipeline.getBindGroupLayout(0),
2668
+ entries: [
2669
+ { binding: 0, resource: srcView },
2670
+ { binding: 1, resource: this.mipBlitSampler },
2671
+ ],
2672
+ })
2673
+ const pass = encoder.beginRenderPass({
2674
+ colorAttachments: [
2675
+ { view: dstView, clearValue: { r: 0, g: 0, b: 0, a: 0 }, loadOp: "clear", storeOp: "store" },
2676
+ ],
2677
+ })
2678
+ pass.setPipeline(this.mipBlitPipeline)
2679
+ pass.setBindGroup(0, bindGroup)
2680
+ pass.draw(3)
2681
+ pass.end()
2682
+ }
2683
+ this.device.queue.submit([encoder.finish()])
2684
+ }
2685
+
2556
2686
  private renderGround(pass: GPURenderPassEncoder) {
2557
2687
  if (!this.hasGround || !this.groundVertexBuffer || !this.groundIndexBuffer || !this.groundDrawCall) return
2558
2688
  pass.setPipeline(this.groundShadowPipeline)