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/dist/engine.js CHANGED
@@ -4,7 +4,7 @@ import { PmxLoader } from "./pmx-loader";
4
4
  import { Physics } from "./physics";
5
5
  import { createFetchAssetReader, createFileMapAssetReader, deriveBasePathFromPmxPath, fileListToMap, findFirstPmxFileInList, joinAssetPath, normalizeAssetPath, } from "./asset-reader";
6
6
  import { DEFAULT_SHADER_WGSL } from "./shaders/default";
7
- import { DFG_LUT_SIZE, DFG_LUT_WGSL } from "./shaders/dfg_lut";
7
+ import { BRDF_LUT_SIZE, BRDF_LUT_BAKE_WGSL } from "./shaders/dfg_lut";
8
8
  import { LTC_MAG_LUT_SIZE, LTC_MAG_LUT_DATA } from "./shaders/ltc_mag_lut";
9
9
  import { FACE_SHADER_WGSL } from "./shaders/face";
10
10
  import { HAIR_SHADER_WGSL } from "./shaders/hair";
@@ -21,11 +21,13 @@ export const DEFAULT_BLOOM_OPTIONS = {
21
21
  knee: 0.5,
22
22
  radius: 4.0,
23
23
  color: new Vec3(1.0, 0.7247558832168579, 0.6487361788749695),
24
- intensity: 0.03,
24
+ intensity: 0.05,
25
25
  clamp: 0.0,
26
26
  };
27
+ // Matches the reference Blender project: Filmic view, Medium High Contrast look,
28
+ // exposure 0.3, gamma 1.0, sRGB display, no curves.
27
29
  export const DEFAULT_VIEW_TRANSFORM = {
28
- exposure: -0.30000001192092896,
30
+ exposure: 0.6,
29
31
  gamma: 1.0,
30
32
  look: "medium_high_contrast",
31
33
  };
@@ -66,6 +68,8 @@ export class Engine {
66
68
  this.pendingPick = null;
67
69
  this.modelInstances = new Map();
68
70
  this.textureCache = new Map();
71
+ this.mipBlitPipeline = null;
72
+ this.mipBlitSampler = null;
69
73
  this._nextDefaultModelId = 0;
70
74
  // IK and physics enabled at engine level (same for all models)
71
75
  this.ikEnabled = true;
@@ -234,9 +238,12 @@ export class Engine {
234
238
  writeBloomUniforms() {
235
239
  const b = this.bloomSettings;
236
240
  const bu = this.bloomBlitUniformData;
237
- // EEVEE prefilter: threshold, knee, clamp (0 → disabled), _unused
241
+ // EEVEE prefilter: threshold, knee_half, clamp (0 → disabled), _unused
242
+ // Blender halves the knee before passing to the shader (eevee_bloom.c: knee * 0.5f).
243
+ // The blit shader's quadratic soft-knee curve uses knee_half as the offset from threshold,
244
+ // so the soft ramp spans [threshold - knee/2 .. threshold + knee/2] — NOT [threshold - knee .. threshold + knee].
238
245
  bu[0] = b.threshold;
239
- bu[1] = b.knee;
246
+ bu[1] = b.knee * 0.5;
240
247
  bu[2] = b.clamp;
241
248
  bu[3] = 0.0;
242
249
  this.device.queue.writeBuffer(this.bloomBlitUniformBuffer, 0, bu);
@@ -273,47 +280,23 @@ export class Engine {
273
280
  this.setupResize();
274
281
  Engine.instance = this;
275
282
  }
276
- // One-shot bake of EEVEE's BRDF split-sum DFG LUT — ported from
277
- // bsdf_lut_frag.glsl. Runs once per engine init; resulting 64×64 rg16float
278
- // texture is sampled by every material shader via group(0) binding(9).
279
- bakeDfgLut() {
280
- this.dfgLutTexture = this.device.createTexture({
281
- label: "DFG LUT",
282
- size: [DFG_LUT_SIZE, DFG_LUT_SIZE],
283
- format: "rg16float",
284
- usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
285
- });
286
- this.dfgLutView = this.dfgLutTexture.createView();
287
- const module = this.device.createShaderModule({ label: "DFG LUT bake", code: DFG_LUT_WGSL });
288
- const pipeline = this.device.createRenderPipeline({
289
- label: "DFG LUT bake pipeline",
290
- layout: "auto",
291
- vertex: { module, entryPoint: "vs" },
292
- fragment: { module, entryPoint: "fs", targets: [{ format: "rg16float" }] },
293
- primitive: { topology: "triangle-list" },
294
- });
295
- const enc = this.device.createCommandEncoder({ label: "DFG LUT bake encoder" });
296
- const pass = enc.beginRenderPass({
297
- colorAttachments: [
298
- { view: this.dfgLutView, clearValue: { r: 0, g: 0, b: 0, a: 1 }, loadOp: "clear", storeOp: "store" },
299
- ],
300
- });
301
- pass.setPipeline(pipeline);
302
- pass.draw(3, 1, 0, 0);
303
- pass.end();
304
- this.device.queue.submit([enc.finish()]);
305
- }
306
- // Upload Blender's static LTC GGX magnitude LUT (eevee_lut.c ltc_mag_ggx[]).
307
- // Pairs with the DFG LUT to form ltc_brdf_scale — closure_eval_glossy_lib.glsl:79-81.
308
- uploadLtcMagLut() {
309
- this.ltcMagLutTexture = this.device.createTexture({
310
- label: "LTC mag LUT",
283
+ // One-shot bake of EEVEE's combined BRDF LUT — DFG (bsdf_lut_frag.glsl) packed
284
+ // with ltc_mag_ggx (eevee_lut.c) into a single 64×64 rgba8unorm texture:
285
+ // .rg = split-sum DFG → F_brdf_*_scatter
286
+ // .ba = LTC magnitude → ltc_brdf_scale_from_lut
287
+ // One texture fetch per fragment replaces the previous 2–3 taps. rgba8unorm
288
+ // (vs rgba16float) halves sample bandwidth; DFG/LTC values fit [0,1] cleanly.
289
+ bakeBrdfLut() {
290
+ if (BRDF_LUT_SIZE !== LTC_MAG_LUT_SIZE) {
291
+ throw new Error("BRDF LUT bake requires DFG size == LTC size (both 64).");
292
+ }
293
+ // Temp rg16float LTC source — loaded 1:1 by the bake fragment shader, then dropped.
294
+ const ltcTemp = this.device.createTexture({
295
+ label: "LTC mag LUT (bake input)",
311
296
  size: [LTC_MAG_LUT_SIZE, LTC_MAG_LUT_SIZE],
312
297
  format: "rg16float",
313
298
  usage: GPUTextureUsage.COPY_DST | GPUTextureUsage.TEXTURE_BINDING,
314
299
  });
315
- this.ltcMagLutView = this.ltcMagLutTexture.createView();
316
- // Float32 → float16 bits. rg16float writeTexture expects packed half floats.
317
300
  const n = LTC_MAG_LUT_DATA.length;
318
301
  const half = new Uint16Array(n);
319
302
  const f32 = new Float32Array(1);
@@ -323,20 +306,53 @@ export class Engine {
323
306
  const x = u32[0];
324
307
  const sign = (x >>> 16) & 0x8000;
325
308
  let exp = ((x >>> 23) & 0xff) - 127 + 15;
326
- let mant = x & 0x7fffff;
309
+ const mant = x & 0x7fffff;
327
310
  if (exp <= 0) {
328
- half[i] = sign; // flush tiny values to signed zero (data here is in [0, ~1])
311
+ half[i] = sign;
329
312
  }
330
313
  else if (exp >= 31) {
331
- half[i] = sign | 0x7c00; // inf
314
+ half[i] = sign | 0x7c00;
332
315
  }
333
316
  else {
334
317
  half[i] = sign | (exp << 10) | (mant >>> 13);
335
318
  }
336
319
  }
337
- this.device.queue.writeTexture({ texture: this.ltcMagLutTexture }, half, { bytesPerRow: LTC_MAG_LUT_SIZE * 4, rowsPerImage: LTC_MAG_LUT_SIZE }, { width: LTC_MAG_LUT_SIZE, height: LTC_MAG_LUT_SIZE, depthOrArrayLayers: 1 });
320
+ this.device.queue.writeTexture({ texture: ltcTemp }, half, { bytesPerRow: LTC_MAG_LUT_SIZE * 4, rowsPerImage: LTC_MAG_LUT_SIZE }, { width: LTC_MAG_LUT_SIZE, height: LTC_MAG_LUT_SIZE, depthOrArrayLayers: 1 });
321
+ this.brdfLutTexture = this.device.createTexture({
322
+ label: "BRDF LUT (DFG + LTC packed)",
323
+ size: [BRDF_LUT_SIZE, BRDF_LUT_SIZE],
324
+ format: "rgba8unorm",
325
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
326
+ });
327
+ this.brdfLutView = this.brdfLutTexture.createView();
328
+ const module = this.device.createShaderModule({ label: "BRDF LUT bake", code: BRDF_LUT_BAKE_WGSL });
329
+ const pipeline = this.device.createRenderPipeline({
330
+ label: "BRDF LUT bake pipeline",
331
+ layout: "auto",
332
+ vertex: { module, entryPoint: "vs" },
333
+ fragment: { module, entryPoint: "fs", targets: [{ format: "rgba8unorm" }] },
334
+ primitive: { topology: "triangle-list" },
335
+ });
336
+ const bakeBindGroup = this.device.createBindGroup({
337
+ label: "BRDF LUT bake bind group",
338
+ layout: pipeline.getBindGroupLayout(0),
339
+ entries: [{ binding: 0, resource: ltcTemp.createView() }],
340
+ });
341
+ const enc = this.device.createCommandEncoder({ label: "BRDF LUT bake encoder" });
342
+ const pass = enc.beginRenderPass({
343
+ colorAttachments: [
344
+ { view: this.brdfLutView, clearValue: { r: 0, g: 0, b: 0, a: 1 }, loadOp: "clear", storeOp: "store" },
345
+ ],
346
+ });
347
+ pass.setPipeline(pipeline);
348
+ pass.setBindGroup(0, bakeBindGroup);
349
+ pass.draw(3, 1, 0, 0);
350
+ pass.end();
351
+ this.device.queue.submit([enc.finish()]);
352
+ ltcTemp.destroy();
338
353
  }
339
354
  createRenderPipeline(config) {
355
+ const targets = config.fragmentTargets ?? (config.fragmentTarget ? [config.fragmentTarget] : undefined);
340
356
  return this.device.createRenderPipeline({
341
357
  label: config.label,
342
358
  layout: config.layout,
@@ -344,11 +360,11 @@ export class Engine {
344
360
  module: config.shaderModule,
345
361
  buffers: config.vertexBuffers,
346
362
  },
347
- fragment: config.fragmentTarget
363
+ fragment: targets
348
364
  ? {
349
365
  module: config.shaderModule,
350
366
  entryPoint: config.fragmentEntryPoint,
351
- targets: [config.fragmentTarget],
367
+ targets,
352
368
  }
353
369
  : undefined,
354
370
  primitive: { cullMode: config.cullMode ?? "none" },
@@ -360,6 +376,7 @@ export class Engine {
360
376
  this.materialSampler = this.device.createSampler({
361
377
  magFilter: "linear",
362
378
  minFilter: "linear",
379
+ mipmapFilter: "linear",
363
380
  addressModeU: "repeat",
364
381
  addressModeV: "repeat",
365
382
  });
@@ -417,6 +434,11 @@ export class Engine {
417
434
  },
418
435
  },
419
436
  };
437
+ // Bloom mask target — r8unorm has no alpha channel, so src-alpha blending is invalid.
438
+ // Use replace mode: depth test already rejects occluded fragments, so last-writer-wins
439
+ // on surviving pixels gives the right result (ground writes 0; models/outlines write 1).
440
+ const maskBlend = { format: Engine.BLOOM_MASK_FORMAT };
441
+ const sceneTargets = [standardBlend, maskBlend];
420
442
  const shaderModule = this.device.createShaderModule({
421
443
  label: "default model shader",
422
444
  code: DEFAULT_SHADER_WGSL,
@@ -464,7 +486,6 @@ export class Engine {
464
486
  { binding: 4, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "comparison" } },
465
487
  { binding: 5, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
466
488
  { binding: 9, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
467
- { binding: 10, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
468
489
  ],
469
490
  });
470
491
  // group 1: per-instance (skinMats) — bound once per model
@@ -494,7 +515,7 @@ export class Engine {
494
515
  layout: mainPipelineLayout,
495
516
  shaderModule,
496
517
  vertexBuffers: fullVertexBuffers,
497
- fragmentTarget: standardBlend,
518
+ fragmentTargets: sceneTargets,
498
519
  cullMode: "none",
499
520
  depthStencil: {
500
521
  format: "depth24plus-stencil8",
@@ -507,7 +528,7 @@ export class Engine {
507
528
  layout: mainPipelineLayout,
508
529
  shaderModule: faceShaderModule,
509
530
  vertexBuffers: fullVertexBuffers,
510
- fragmentTarget: standardBlend,
531
+ fragmentTargets: sceneTargets,
511
532
  cullMode: "none",
512
533
  depthStencil: {
513
534
  format: "depth24plus-stencil8",
@@ -520,7 +541,7 @@ export class Engine {
520
541
  layout: mainPipelineLayout,
521
542
  shaderModule: hairShaderModule,
522
543
  vertexBuffers: fullVertexBuffers,
523
- fragmentTarget: standardBlend,
544
+ fragmentTargets: sceneTargets,
524
545
  cullMode: "none",
525
546
  depthStencil: {
526
547
  format: "depth24plus-stencil8",
@@ -533,7 +554,7 @@ export class Engine {
533
554
  layout: mainPipelineLayout,
534
555
  shaderModule: clothSmoothShaderModule,
535
556
  vertexBuffers: fullVertexBuffers,
536
- fragmentTarget: standardBlend,
557
+ fragmentTargets: sceneTargets,
537
558
  cullMode: "none",
538
559
  depthStencil: {
539
560
  format: "depth24plus-stencil8",
@@ -546,7 +567,7 @@ export class Engine {
546
567
  layout: mainPipelineLayout,
547
568
  shaderModule: clothRoughShaderModule,
548
569
  vertexBuffers: fullVertexBuffers,
549
- fragmentTarget: standardBlend,
570
+ fragmentTargets: sceneTargets,
550
571
  cullMode: "none",
551
572
  depthStencil: {
552
573
  format: "depth24plus-stencil8",
@@ -559,7 +580,7 @@ export class Engine {
559
580
  layout: mainPipelineLayout,
560
581
  shaderModule: metalShaderModule,
561
582
  vertexBuffers: fullVertexBuffers,
562
- fragmentTarget: standardBlend,
583
+ fragmentTargets: sceneTargets,
563
584
  cullMode: "none",
564
585
  depthStencil: {
565
586
  format: "depth24plus-stencil8",
@@ -572,7 +593,7 @@ export class Engine {
572
593
  layout: mainPipelineLayout,
573
594
  shaderModule: bodyShaderModule,
574
595
  vertexBuffers: fullVertexBuffers,
575
- fragmentTarget: standardBlend,
596
+ fragmentTargets: sceneTargets,
576
597
  cullMode: "none",
577
598
  depthStencil: {
578
599
  format: "depth24plus-stencil8",
@@ -585,7 +606,7 @@ export class Engine {
585
606
  layout: mainPipelineLayout,
586
607
  shaderModule: eyeShaderModule,
587
608
  vertexBuffers: fullVertexBuffers,
588
- fragmentTarget: standardBlend,
609
+ fragmentTargets: sceneTargets,
589
610
  cullMode: "none",
590
611
  depthStencil: {
591
612
  format: "depth24plus-stencil8",
@@ -598,7 +619,7 @@ export class Engine {
598
619
  layout: mainPipelineLayout,
599
620
  shaderModule: stockingsShaderModule,
600
621
  vertexBuffers: fullVertexBuffers,
601
- fragmentTarget: standardBlend,
622
+ fragmentTargets: sceneTargets,
602
623
  cullMode: "none",
603
624
  depthStencil: {
604
625
  format: "depth24plus-stencil8",
@@ -661,10 +682,8 @@ export class Engine {
661
682
  usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
662
683
  });
663
684
  this.shadowMapDepthView = this.shadowMapTexture.createView();
664
- // One-shot bake of Blender EEVEE's BRDF split-sum DFG LUT (bsdf_lut_frag.glsl).
665
- this.bakeDfgLut();
666
- // Upload static LTC GGX magnitude LUT for direct-specular energy compensation.
667
- this.uploadLtcMagLut();
685
+ // One-shot bake of Blender EEVEE's combined BRDF LUT (DFG + LTC packed rgba8unorm).
686
+ this.bakeBrdfLut();
668
687
  // Now that shadow resources exist, create the main per-frame bind group
669
688
  this.perFrameBindGroup = this.device.createBindGroup({
670
689
  label: "main per-frame bind group",
@@ -676,8 +695,7 @@ export class Engine {
676
695
  { binding: 3, resource: this.shadowMapDepthView },
677
696
  { binding: 4, resource: this.shadowComparisonSampler },
678
697
  { binding: 5, resource: { buffer: this.shadowLightVPBuffer } },
679
- { binding: 9, resource: this.dfgLutView },
680
- { binding: 10, resource: this.ltcMagLutView },
698
+ { binding: 9, resource: this.brdfLutView },
681
699
  ],
682
700
  });
683
701
  this.groundShadowBindGroupLayout = this.device.createBindGroupLayout({
@@ -741,7 +759,8 @@ export class Engine {
741
759
  var o: VO; o.worldPos = position; o.normal = normal;
742
760
  o.position = camera.projection * camera.view * vec4f(position, 1.0); return o;
743
761
  }
744
- @fragment fn fs(i: VO) -> @location(0) vec4f {
762
+ struct FSOut { @location(0) color: vec4f, @location(1) mask: f32 };
763
+ @fragment fn fs(i: VO) -> FSOut {
745
764
  let n = normalize(i.normal);
746
765
  let centerDist = length(i.worldPos.xz);
747
766
  let edgeFade = 1.0 - smoothstep(0.0, 1.0, clamp((centerDist - material.fadeStart) / max(material.fadeEnd - material.fadeStart, 0.001), 0.0, 1.0));
@@ -779,7 +798,10 @@ export class Engine {
779
798
  var baseColor = material.diffuseColor * sun * (1.0 - dark * 0.65);
780
799
  baseColor *= noiseTint;
781
800
  let finalColor = mix(baseColor, material.gridLineColor, gridLine * material.gridLineOpacity * edgeFade);
782
- return vec4f(finalColor * edgeFade, edgeFade);
801
+ var out: FSOut;
802
+ out.color = vec4f(finalColor * edgeFade, edgeFade);
803
+ out.mask = 0.0;
804
+ return out;
783
805
  }
784
806
  `,
785
807
  });
@@ -788,7 +810,7 @@ export class Engine {
788
810
  layout: this.device.createPipelineLayout({ bindGroupLayouts: [this.groundShadowBindGroupLayout] }),
789
811
  shaderModule: groundShadowShader,
790
812
  vertexBuffers: fullVertexBuffers,
791
- fragmentTarget: standardBlend,
813
+ fragmentTargets: sceneTargets,
792
814
  cullMode: "back",
793
815
  depthStencil: { format: "depth24plus-stencil8", depthWriteEnabled: true, depthCompare: "less-equal" },
794
816
  });
@@ -897,8 +919,12 @@ export class Engine {
897
919
  return output;
898
920
  }
899
921
 
900
- @fragment fn fs() -> @location(0) vec4f {
901
- return material.edgeColor;
922
+ struct FSOut { @location(0) color: vec4f, @location(1) mask: f32 };
923
+ @fragment fn fs() -> FSOut {
924
+ var out: FSOut;
925
+ out.color = material.edgeColor;
926
+ out.mask = 1.0;
927
+ return out;
902
928
  }
903
929
  `,
904
930
  });
@@ -907,7 +933,7 @@ export class Engine {
907
933
  layout: outlinePipelineLayout,
908
934
  shaderModule: outlineShaderModule,
909
935
  vertexBuffers: outlineVertexBuffers,
910
- fragmentTarget: standardBlend,
936
+ fragmentTargets: sceneTargets,
911
937
  cullMode: "back",
912
938
  depthStencil: {
913
939
  format: "depth24plus-stencil8",
@@ -942,6 +968,7 @@ export class Engine {
942
968
  entries: [
943
969
  { binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "unfilterable-float" } },
944
970
  { binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
971
+ { binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "unfilterable-float" } },
945
972
  ],
946
973
  });
947
974
  this.bloomDownsampleBindGroupLayout = this.device.createBindGroupLayout({
@@ -973,6 +1000,7 @@ export class Engine {
973
1000
  code: `${bloomFullscreenVs}
974
1001
  @group(0) @binding(0) var hdrTex: texture_2d<f32>;
975
1002
  @group(0) @binding(1) var<uniform> prefilter: vec4<f32>; // threshold, knee, clamp, _unused
1003
+ @group(0) @binding(2) var maskTex: texture_2d<f32>;
976
1004
 
977
1005
  fn luminance(c: vec3f) -> f32 {
978
1006
  return dot(max(c, vec3f(0.0)), vec3f(0.2126, 0.7152, 0.0722));
@@ -983,8 +1011,11 @@ export class Engine {
983
1011
  let s = textureLoad(hdrTex, cc, 0);
984
1012
  // Scene pass uses src-alpha blend with clear alpha 0 → premultiplied. Unpremultiply.
985
1013
  let rgb = max(s.rgb / max(s.a, 1e-6), vec3f(0.0));
1014
+ // Bloom mask: MRT r8unorm written by material shaders (1.0 = bloom, 0.0 = skip).
1015
+ let mask = textureLoad(maskTex, cc, 0).r;
1016
+ let masked = rgb * mask;
986
1017
  // Blender: clamp each tap BEFORE Karis average (eevee_bloom: color = min(clampIntensity, color)).
987
- return select(rgb, min(rgb, vec3f(clampV)), clampV > 0.0);
1018
+ return select(masked, min(masked, vec3f(clampV)), clampV > 0.0);
988
1019
  }
989
1020
 
990
1021
  @fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
@@ -1079,7 +1110,9 @@ export class Engine {
1079
1110
  `,
1080
1111
  });
1081
1112
  const bloomBlitLayout = this.device.createPipelineLayout({ bindGroupLayouts: [this.bloomBlitBindGroupLayout] });
1082
- const bloomDownLayout = this.device.createPipelineLayout({ bindGroupLayouts: [this.bloomDownsampleBindGroupLayout] });
1113
+ const bloomDownLayout = this.device.createPipelineLayout({
1114
+ bindGroupLayouts: [this.bloomDownsampleBindGroupLayout],
1115
+ });
1083
1116
  const bloomUpLayout = this.device.createPipelineLayout({ bindGroupLayouts: [this.bloomUpsampleBindGroupLayout] });
1084
1117
  this.bloomBlitPipeline = this.device.createRenderPipeline({
1085
1118
  label: "bloom blit pipeline",
@@ -1129,9 +1162,14 @@ export class Engine {
1129
1162
  // viewU[0] = (exposure, gamma, _, _); viewU[1] = (tint.rgb, intensity)
1130
1163
 
1131
1164
  fn filmic(x: f32) -> f32 {
1165
+ // Re-fit against Blender 3.6 Filmic MHC anchors (sobotka/filmic-blender
1166
+ // look_medium-high-contrast.spi1d). Previous curve was compressed:
1167
+ // midtones too bright, highlights too dim — flattened contrast, read
1168
+ // as "washed-out" on saturated surfaces (hair especially).
1169
+ // Reference checkpoints: linear 0.18 → ~0.395, linear 1.0 → ~0.83.
1132
1170
  var lut = array<f32, 14>(
1133
- 0.0067, 0.0141, 0.0272, 0.0499, 0.0885, 0.1512, 0.2462,
1134
- 0.3753, 0.5273, 0.6776, 0.8031, 0.8929, 0.9495, 0.9814
1171
+ 0.0028, 0.0068, 0.0151, 0.0313, 0.0610, 0.1120, 0.1920,
1172
+ 0.3060, 0.4590, 0.6310, 0.8200, 0.9070, 0.9620, 0.9890
1135
1173
  );
1136
1174
  let t = clamp(log2(max(x, 1e-10)) + 10.0, 0.0, 13.0);
1137
1175
  let i = u32(t);
@@ -1152,7 +1190,8 @@ export class Engine {
1152
1190
  let fullSz = vec2f(textureDimensions(hdrTex));
1153
1191
  let bloomSz = vec2f(textureDimensions(bloomTex));
1154
1192
  // Bloom is at half-res (pyramid mip 0). Sampler interpolates back to full-res UVs.
1155
- let bloomUv = (fragCoord.xy + vec2f(0.5)) / max(fullSz, vec2f(1.0));
1193
+ // fragCoord.xy is already at pixel center (e.g. 0.5, 0.5 for first pixel).
1194
+ let bloomUv = fragCoord.xy / max(fullSz, vec2f(1.0));
1156
1195
  let tint = viewU[1].xyz;
1157
1196
  let intensity = viewU[1].w;
1158
1197
  let bloom = textureSampleLevel(bloomTex, bloomSamp, bloomUv, 0.0).rgb * tint * intensity;
@@ -1308,6 +1347,22 @@ export class Engine {
1308
1347
  format: Engine.HDR_FORMAT,
1309
1348
  usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1310
1349
  });
1350
+ // Bloom-mask MRT attachments — same dims + MSAA as HDR so they share the render pass.
1351
+ // MS buffer gets resolved into maskResolveTexture, which the bloom blit pass samples.
1352
+ this.multisampleMaskTexture = this.device.createTexture({
1353
+ label: "multisample bloom mask",
1354
+ size: [width, height],
1355
+ sampleCount: Engine.MULTISAMPLE_COUNT,
1356
+ format: Engine.BLOOM_MASK_FORMAT,
1357
+ usage: GPUTextureUsage.RENDER_ATTACHMENT,
1358
+ });
1359
+ this.maskResolveTexture = this.device.createTexture({
1360
+ label: "bloom mask resolve",
1361
+ size: [width, height],
1362
+ format: Engine.BLOOM_MASK_FORMAT,
1363
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1364
+ });
1365
+ this.maskResolveView = this.maskResolveTexture.createView();
1311
1366
  // Bloom pyramid: mip 0 is half-res, each subsequent mip halves again.
1312
1367
  // Mip count chosen so the coarsest mip is ≥4 px on the short side, capped at BLOOM_MAX_LEVELS.
1313
1368
  const bw = Math.max(1, Math.floor(width / 2));
@@ -1352,9 +1407,16 @@ export class Engine {
1352
1407
  loadOp: "clear",
1353
1408
  storeOp: "store",
1354
1409
  };
1410
+ const maskAttachment = {
1411
+ view: this.multisampleMaskTexture.createView(),
1412
+ resolveTarget: this.maskResolveView,
1413
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
1414
+ loadOp: "clear",
1415
+ storeOp: "store",
1416
+ };
1355
1417
  this.renderPassDescriptor = {
1356
1418
  label: "renderPass",
1357
- colorAttachments: [colorAttachment],
1419
+ colorAttachments: [colorAttachment, maskAttachment],
1358
1420
  depthStencilAttachment: {
1359
1421
  view: depthTextureView,
1360
1422
  depthClearValue: 1.0,
@@ -1386,6 +1448,7 @@ export class Engine {
1386
1448
  entries: [
1387
1449
  { binding: 0, resource: this.hdrResolveTexture.createView() },
1388
1450
  { binding: 1, resource: { buffer: this.bloomBlitUniformBuffer } },
1451
+ { binding: 2, resource: this.maskResolveView },
1389
1452
  ],
1390
1453
  });
1391
1454
  // Downsample[i] reads bloomDown mip (i-1), writes bloomDown mip i. i ∈ [1..N-1].
@@ -2116,16 +2179,20 @@ export class Engine {
2116
2179
  premultiplyAlpha: "none",
2117
2180
  colorSpaceConversion: "none",
2118
2181
  });
2182
+ const mipLevelCount = Math.floor(Math.log2(Math.max(imageBitmap.width, imageBitmap.height))) + 1;
2119
2183
  const texture = this.device.createTexture({
2120
2184
  label: `texture: ${cacheKey}`,
2121
2185
  size: [imageBitmap.width, imageBitmap.height],
2122
2186
  format: "rgba8unorm-srgb",
2187
+ mipLevelCount,
2123
2188
  usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
2124
2189
  });
2125
2190
  this.device.queue.copyExternalImageToTexture({ source: imageBitmap }, { texture }, [
2126
2191
  imageBitmap.width,
2127
2192
  imageBitmap.height,
2128
2193
  ]);
2194
+ if (mipLevelCount > 1)
2195
+ this.generateMipmaps(texture, mipLevelCount);
2129
2196
  this.textureCache.set(cacheKey, texture);
2130
2197
  inst.textureCacheKeys.push(cacheKey);
2131
2198
  return texture;
@@ -2134,6 +2201,64 @@ export class Engine {
2134
2201
  return null;
2135
2202
  }
2136
2203
  }
2204
+ // Bilinear box-filter downsample per level. Reads srgb view (hardware linearizes on sample,
2205
+ // re-encodes on write), so intensities are filtered in linear space — matching EEVEE/Blender.
2206
+ generateMipmaps(texture, mipLevelCount) {
2207
+ if (!this.mipBlitPipeline || !this.mipBlitSampler) {
2208
+ this.mipBlitSampler = this.device.createSampler({
2209
+ magFilter: "linear",
2210
+ minFilter: "linear",
2211
+ addressModeU: "clamp-to-edge",
2212
+ addressModeV: "clamp-to-edge",
2213
+ });
2214
+ const module = this.device.createShaderModule({
2215
+ label: "mipmap blit",
2216
+ code: /* wgsl */ `
2217
+ @group(0) @binding(0) var src: texture_2d<f32>;
2218
+ @group(0) @binding(1) var samp: sampler;
2219
+ @vertex fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
2220
+ let x = f32((vi & 1u) << 2u) - 1.0;
2221
+ let y = f32((vi & 2u) << 1u) - 1.0;
2222
+ return vec4f(x, y, 0.0, 1.0);
2223
+ }
2224
+ @fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
2225
+ let dstDims = vec2f(textureDimensions(src)) * 0.5;
2226
+ let uv = p.xy / max(dstDims, vec2f(1.0));
2227
+ return textureSampleLevel(src, samp, uv, 0.0);
2228
+ }
2229
+ `,
2230
+ });
2231
+ this.mipBlitPipeline = this.device.createRenderPipeline({
2232
+ label: "mipmap blit pipeline",
2233
+ layout: "auto",
2234
+ vertex: { module, entryPoint: "vs" },
2235
+ fragment: { module, entryPoint: "fs", targets: [{ format: "rgba8unorm-srgb" }] },
2236
+ primitive: { topology: "triangle-list" },
2237
+ });
2238
+ }
2239
+ const encoder = this.device.createCommandEncoder({ label: "mipgen" });
2240
+ for (let level = 1; level < mipLevelCount; level++) {
2241
+ const srcView = texture.createView({ baseMipLevel: level - 1, mipLevelCount: 1 });
2242
+ const dstView = texture.createView({ baseMipLevel: level, mipLevelCount: 1 });
2243
+ const bindGroup = this.device.createBindGroup({
2244
+ layout: this.mipBlitPipeline.getBindGroupLayout(0),
2245
+ entries: [
2246
+ { binding: 0, resource: srcView },
2247
+ { binding: 1, resource: this.mipBlitSampler },
2248
+ ],
2249
+ });
2250
+ const pass = encoder.beginRenderPass({
2251
+ colorAttachments: [
2252
+ { view: dstView, clearValue: { r: 0, g: 0, b: 0, a: 0 }, loadOp: "clear", storeOp: "store" },
2253
+ ],
2254
+ });
2255
+ pass.setPipeline(this.mipBlitPipeline);
2256
+ pass.setBindGroup(0, bindGroup);
2257
+ pass.draw(3);
2258
+ pass.end();
2259
+ }
2260
+ this.device.queue.submit([encoder.finish()]);
2261
+ }
2137
2262
  renderGround(pass) {
2138
2263
  if (!this.hasGround || !this.groundVertexBuffer || !this.groundIndexBuffer || !this.groundDrawCall)
2139
2264
  return;
@@ -2467,5 +2592,9 @@ export class Engine {
2467
2592
  Engine.instance = null;
2468
2593
  Engine.MULTISAMPLE_COUNT = 4;
2469
2594
  Engine.HDR_FORMAT = "rgba16float";
2595
+ /** Single-channel mask written alongside HDR color — 1 = model geometry (contributes
2596
+ * to bloom), 0 = ground (never blooms). Sampled by the bloom blit pass to gate the
2597
+ * prefilter so ground brightness can't halo the scene. */
2598
+ Engine.BLOOM_MASK_FORMAT = "r8unorm";
2470
2599
  Engine.BLOOM_MAX_LEVELS = 7;
2471
- Engine.SHADOW_MAP_SIZE = 4096;
2600
+ Engine.SHADOW_MAP_SIZE = 2048;