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/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,7 +21,7 @@ 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
27
  export const DEFAULT_VIEW_TRANSFORM = {
@@ -66,6 +66,8 @@ export class Engine {
66
66
  this.pendingPick = null;
67
67
  this.modelInstances = new Map();
68
68
  this.textureCache = new Map();
69
+ this.mipBlitPipeline = null;
70
+ this.mipBlitSampler = null;
69
71
  this._nextDefaultModelId = 0;
70
72
  // IK and physics enabled at engine level (same for all models)
71
73
  this.ikEnabled = true;
@@ -273,47 +275,23 @@ export class Engine {
273
275
  this.setupResize();
274
276
  Engine.instance = this;
275
277
  }
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",
278
+ // One-shot bake of EEVEE's combined BRDF LUT — DFG (bsdf_lut_frag.glsl) packed
279
+ // with ltc_mag_ggx (eevee_lut.c) into a single 64×64 rgba8unorm texture:
280
+ // .rg = split-sum DFG → F_brdf_*_scatter
281
+ // .ba = LTC magnitude → ltc_brdf_scale_from_lut
282
+ // One texture fetch per fragment replaces the previous 2–3 taps. rgba8unorm
283
+ // (vs rgba16float) halves sample bandwidth; DFG/LTC values fit [0,1] cleanly.
284
+ bakeBrdfLut() {
285
+ if (BRDF_LUT_SIZE !== LTC_MAG_LUT_SIZE) {
286
+ throw new Error("BRDF LUT bake requires DFG size == LTC size (both 64).");
287
+ }
288
+ // Temp rg16float LTC source — loaded 1:1 by the bake fragment shader, then dropped.
289
+ const ltcTemp = this.device.createTexture({
290
+ label: "LTC mag LUT (bake input)",
311
291
  size: [LTC_MAG_LUT_SIZE, LTC_MAG_LUT_SIZE],
312
292
  format: "rg16float",
313
293
  usage: GPUTextureUsage.COPY_DST | GPUTextureUsage.TEXTURE_BINDING,
314
294
  });
315
- this.ltcMagLutView = this.ltcMagLutTexture.createView();
316
- // Float32 → float16 bits. rg16float writeTexture expects packed half floats.
317
295
  const n = LTC_MAG_LUT_DATA.length;
318
296
  const half = new Uint16Array(n);
319
297
  const f32 = new Float32Array(1);
@@ -323,20 +301,53 @@ export class Engine {
323
301
  const x = u32[0];
324
302
  const sign = (x >>> 16) & 0x8000;
325
303
  let exp = ((x >>> 23) & 0xff) - 127 + 15;
326
- let mant = x & 0x7fffff;
304
+ const mant = x & 0x7fffff;
327
305
  if (exp <= 0) {
328
- half[i] = sign; // flush tiny values to signed zero (data here is in [0, ~1])
306
+ half[i] = sign;
329
307
  }
330
308
  else if (exp >= 31) {
331
- half[i] = sign | 0x7c00; // inf
309
+ half[i] = sign | 0x7c00;
332
310
  }
333
311
  else {
334
312
  half[i] = sign | (exp << 10) | (mant >>> 13);
335
313
  }
336
314
  }
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 });
315
+ 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 });
316
+ this.brdfLutTexture = this.device.createTexture({
317
+ label: "BRDF LUT (DFG + LTC packed)",
318
+ size: [BRDF_LUT_SIZE, BRDF_LUT_SIZE],
319
+ format: "rgba8unorm",
320
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
321
+ });
322
+ this.brdfLutView = this.brdfLutTexture.createView();
323
+ const module = this.device.createShaderModule({ label: "BRDF LUT bake", code: BRDF_LUT_BAKE_WGSL });
324
+ const pipeline = this.device.createRenderPipeline({
325
+ label: "BRDF LUT bake pipeline",
326
+ layout: "auto",
327
+ vertex: { module, entryPoint: "vs" },
328
+ fragment: { module, entryPoint: "fs", targets: [{ format: "rgba8unorm" }] },
329
+ primitive: { topology: "triangle-list" },
330
+ });
331
+ const bakeBindGroup = this.device.createBindGroup({
332
+ label: "BRDF LUT bake bind group",
333
+ layout: pipeline.getBindGroupLayout(0),
334
+ entries: [{ binding: 0, resource: ltcTemp.createView() }],
335
+ });
336
+ const enc = this.device.createCommandEncoder({ label: "BRDF LUT bake encoder" });
337
+ const pass = enc.beginRenderPass({
338
+ colorAttachments: [
339
+ { view: this.brdfLutView, clearValue: { r: 0, g: 0, b: 0, a: 1 }, loadOp: "clear", storeOp: "store" },
340
+ ],
341
+ });
342
+ pass.setPipeline(pipeline);
343
+ pass.setBindGroup(0, bakeBindGroup);
344
+ pass.draw(3, 1, 0, 0);
345
+ pass.end();
346
+ this.device.queue.submit([enc.finish()]);
347
+ ltcTemp.destroy();
338
348
  }
339
349
  createRenderPipeline(config) {
350
+ const targets = config.fragmentTargets ?? (config.fragmentTarget ? [config.fragmentTarget] : undefined);
340
351
  return this.device.createRenderPipeline({
341
352
  label: config.label,
342
353
  layout: config.layout,
@@ -344,11 +355,11 @@ export class Engine {
344
355
  module: config.shaderModule,
345
356
  buffers: config.vertexBuffers,
346
357
  },
347
- fragment: config.fragmentTarget
358
+ fragment: targets
348
359
  ? {
349
360
  module: config.shaderModule,
350
361
  entryPoint: config.fragmentEntryPoint,
351
- targets: [config.fragmentTarget],
362
+ targets,
352
363
  }
353
364
  : undefined,
354
365
  primitive: { cullMode: config.cullMode ?? "none" },
@@ -360,6 +371,7 @@ export class Engine {
360
371
  this.materialSampler = this.device.createSampler({
361
372
  magFilter: "linear",
362
373
  minFilter: "linear",
374
+ mipmapFilter: "linear",
363
375
  addressModeU: "repeat",
364
376
  addressModeV: "repeat",
365
377
  });
@@ -417,6 +429,11 @@ export class Engine {
417
429
  },
418
430
  },
419
431
  };
432
+ // Bloom mask target — r8unorm has no alpha channel, so src-alpha blending is invalid.
433
+ // Use replace mode: depth test already rejects occluded fragments, so last-writer-wins
434
+ // on surviving pixels gives the right result (ground writes 0; models/outlines write 1).
435
+ const maskBlend = { format: Engine.BLOOM_MASK_FORMAT };
436
+ const sceneTargets = [standardBlend, maskBlend];
420
437
  const shaderModule = this.device.createShaderModule({
421
438
  label: "default model shader",
422
439
  code: DEFAULT_SHADER_WGSL,
@@ -464,7 +481,6 @@ export class Engine {
464
481
  { binding: 4, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "comparison" } },
465
482
  { binding: 5, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
466
483
  { binding: 9, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
467
- { binding: 10, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
468
484
  ],
469
485
  });
470
486
  // group 1: per-instance (skinMats) — bound once per model
@@ -494,7 +510,7 @@ export class Engine {
494
510
  layout: mainPipelineLayout,
495
511
  shaderModule,
496
512
  vertexBuffers: fullVertexBuffers,
497
- fragmentTarget: standardBlend,
513
+ fragmentTargets: sceneTargets,
498
514
  cullMode: "none",
499
515
  depthStencil: {
500
516
  format: "depth24plus-stencil8",
@@ -507,7 +523,7 @@ export class Engine {
507
523
  layout: mainPipelineLayout,
508
524
  shaderModule: faceShaderModule,
509
525
  vertexBuffers: fullVertexBuffers,
510
- fragmentTarget: standardBlend,
526
+ fragmentTargets: sceneTargets,
511
527
  cullMode: "none",
512
528
  depthStencil: {
513
529
  format: "depth24plus-stencil8",
@@ -520,7 +536,7 @@ export class Engine {
520
536
  layout: mainPipelineLayout,
521
537
  shaderModule: hairShaderModule,
522
538
  vertexBuffers: fullVertexBuffers,
523
- fragmentTarget: standardBlend,
539
+ fragmentTargets: sceneTargets,
524
540
  cullMode: "none",
525
541
  depthStencil: {
526
542
  format: "depth24plus-stencil8",
@@ -533,7 +549,7 @@ export class Engine {
533
549
  layout: mainPipelineLayout,
534
550
  shaderModule: clothSmoothShaderModule,
535
551
  vertexBuffers: fullVertexBuffers,
536
- fragmentTarget: standardBlend,
552
+ fragmentTargets: sceneTargets,
537
553
  cullMode: "none",
538
554
  depthStencil: {
539
555
  format: "depth24plus-stencil8",
@@ -546,7 +562,7 @@ export class Engine {
546
562
  layout: mainPipelineLayout,
547
563
  shaderModule: clothRoughShaderModule,
548
564
  vertexBuffers: fullVertexBuffers,
549
- fragmentTarget: standardBlend,
565
+ fragmentTargets: sceneTargets,
550
566
  cullMode: "none",
551
567
  depthStencil: {
552
568
  format: "depth24plus-stencil8",
@@ -559,7 +575,7 @@ export class Engine {
559
575
  layout: mainPipelineLayout,
560
576
  shaderModule: metalShaderModule,
561
577
  vertexBuffers: fullVertexBuffers,
562
- fragmentTarget: standardBlend,
578
+ fragmentTargets: sceneTargets,
563
579
  cullMode: "none",
564
580
  depthStencil: {
565
581
  format: "depth24plus-stencil8",
@@ -572,7 +588,7 @@ export class Engine {
572
588
  layout: mainPipelineLayout,
573
589
  shaderModule: bodyShaderModule,
574
590
  vertexBuffers: fullVertexBuffers,
575
- fragmentTarget: standardBlend,
591
+ fragmentTargets: sceneTargets,
576
592
  cullMode: "none",
577
593
  depthStencil: {
578
594
  format: "depth24plus-stencil8",
@@ -585,7 +601,7 @@ export class Engine {
585
601
  layout: mainPipelineLayout,
586
602
  shaderModule: eyeShaderModule,
587
603
  vertexBuffers: fullVertexBuffers,
588
- fragmentTarget: standardBlend,
604
+ fragmentTargets: sceneTargets,
589
605
  cullMode: "none",
590
606
  depthStencil: {
591
607
  format: "depth24plus-stencil8",
@@ -598,7 +614,7 @@ export class Engine {
598
614
  layout: mainPipelineLayout,
599
615
  shaderModule: stockingsShaderModule,
600
616
  vertexBuffers: fullVertexBuffers,
601
- fragmentTarget: standardBlend,
617
+ fragmentTargets: sceneTargets,
602
618
  cullMode: "none",
603
619
  depthStencil: {
604
620
  format: "depth24plus-stencil8",
@@ -661,10 +677,8 @@ export class Engine {
661
677
  usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
662
678
  });
663
679
  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();
680
+ // One-shot bake of Blender EEVEE's combined BRDF LUT (DFG + LTC packed rgba8unorm).
681
+ this.bakeBrdfLut();
668
682
  // Now that shadow resources exist, create the main per-frame bind group
669
683
  this.perFrameBindGroup = this.device.createBindGroup({
670
684
  label: "main per-frame bind group",
@@ -676,8 +690,7 @@ export class Engine {
676
690
  { binding: 3, resource: this.shadowMapDepthView },
677
691
  { binding: 4, resource: this.shadowComparisonSampler },
678
692
  { binding: 5, resource: { buffer: this.shadowLightVPBuffer } },
679
- { binding: 9, resource: this.dfgLutView },
680
- { binding: 10, resource: this.ltcMagLutView },
693
+ { binding: 9, resource: this.brdfLutView },
681
694
  ],
682
695
  });
683
696
  this.groundShadowBindGroupLayout = this.device.createBindGroupLayout({
@@ -741,7 +754,8 @@ export class Engine {
741
754
  var o: VO; o.worldPos = position; o.normal = normal;
742
755
  o.position = camera.projection * camera.view * vec4f(position, 1.0); return o;
743
756
  }
744
- @fragment fn fs(i: VO) -> @location(0) vec4f {
757
+ struct FSOut { @location(0) color: vec4f, @location(1) mask: f32 };
758
+ @fragment fn fs(i: VO) -> FSOut {
745
759
  let n = normalize(i.normal);
746
760
  let centerDist = length(i.worldPos.xz);
747
761
  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 +793,10 @@ export class Engine {
779
793
  var baseColor = material.diffuseColor * sun * (1.0 - dark * 0.65);
780
794
  baseColor *= noiseTint;
781
795
  let finalColor = mix(baseColor, material.gridLineColor, gridLine * material.gridLineOpacity * edgeFade);
782
- return vec4f(finalColor * edgeFade, edgeFade);
796
+ var out: FSOut;
797
+ out.color = vec4f(finalColor * edgeFade, edgeFade);
798
+ out.mask = 0.0;
799
+ return out;
783
800
  }
784
801
  `,
785
802
  });
@@ -788,7 +805,7 @@ export class Engine {
788
805
  layout: this.device.createPipelineLayout({ bindGroupLayouts: [this.groundShadowBindGroupLayout] }),
789
806
  shaderModule: groundShadowShader,
790
807
  vertexBuffers: fullVertexBuffers,
791
- fragmentTarget: standardBlend,
808
+ fragmentTargets: sceneTargets,
792
809
  cullMode: "back",
793
810
  depthStencil: { format: "depth24plus-stencil8", depthWriteEnabled: true, depthCompare: "less-equal" },
794
811
  });
@@ -897,8 +914,12 @@ export class Engine {
897
914
  return output;
898
915
  }
899
916
 
900
- @fragment fn fs() -> @location(0) vec4f {
901
- return material.edgeColor;
917
+ struct FSOut { @location(0) color: vec4f, @location(1) mask: f32 };
918
+ @fragment fn fs() -> FSOut {
919
+ var out: FSOut;
920
+ out.color = material.edgeColor;
921
+ out.mask = 1.0;
922
+ return out;
902
923
  }
903
924
  `,
904
925
  });
@@ -907,7 +928,7 @@ export class Engine {
907
928
  layout: outlinePipelineLayout,
908
929
  shaderModule: outlineShaderModule,
909
930
  vertexBuffers: outlineVertexBuffers,
910
- fragmentTarget: standardBlend,
931
+ fragmentTargets: sceneTargets,
911
932
  cullMode: "back",
912
933
  depthStencil: {
913
934
  format: "depth24plus-stencil8",
@@ -942,6 +963,7 @@ export class Engine {
942
963
  entries: [
943
964
  { binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "unfilterable-float" } },
944
965
  { binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
966
+ { binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "unfilterable-float" } },
945
967
  ],
946
968
  });
947
969
  this.bloomDownsampleBindGroupLayout = this.device.createBindGroupLayout({
@@ -973,6 +995,7 @@ export class Engine {
973
995
  code: `${bloomFullscreenVs}
974
996
  @group(0) @binding(0) var hdrTex: texture_2d<f32>;
975
997
  @group(0) @binding(1) var<uniform> prefilter: vec4<f32>; // threshold, knee, clamp, _unused
998
+ @group(0) @binding(2) var maskTex: texture_2d<f32>;
976
999
 
977
1000
  fn luminance(c: vec3f) -> f32 {
978
1001
  return dot(max(c, vec3f(0.0)), vec3f(0.2126, 0.7152, 0.0722));
@@ -983,8 +1006,11 @@ export class Engine {
983
1006
  let s = textureLoad(hdrTex, cc, 0);
984
1007
  // Scene pass uses src-alpha blend with clear alpha 0 → premultiplied. Unpremultiply.
985
1008
  let rgb = max(s.rgb / max(s.a, 1e-6), vec3f(0.0));
1009
+ // Bloom mask: MRT r8unorm written by material shaders (1.0 = bloom, 0.0 = skip).
1010
+ let mask = textureLoad(maskTex, cc, 0).r;
1011
+ let masked = rgb * mask;
986
1012
  // Blender: clamp each tap BEFORE Karis average (eevee_bloom: color = min(clampIntensity, color)).
987
- return select(rgb, min(rgb, vec3f(clampV)), clampV > 0.0);
1013
+ return select(masked, min(masked, vec3f(clampV)), clampV > 0.0);
988
1014
  }
989
1015
 
990
1016
  @fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
@@ -1308,6 +1334,22 @@ export class Engine {
1308
1334
  format: Engine.HDR_FORMAT,
1309
1335
  usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1310
1336
  });
1337
+ // Bloom-mask MRT attachments — same dims + MSAA as HDR so they share the render pass.
1338
+ // MS buffer gets resolved into maskResolveTexture, which the bloom blit pass samples.
1339
+ this.multisampleMaskTexture = this.device.createTexture({
1340
+ label: "multisample bloom mask",
1341
+ size: [width, height],
1342
+ sampleCount: Engine.MULTISAMPLE_COUNT,
1343
+ format: Engine.BLOOM_MASK_FORMAT,
1344
+ usage: GPUTextureUsage.RENDER_ATTACHMENT,
1345
+ });
1346
+ this.maskResolveTexture = this.device.createTexture({
1347
+ label: "bloom mask resolve",
1348
+ size: [width, height],
1349
+ format: Engine.BLOOM_MASK_FORMAT,
1350
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1351
+ });
1352
+ this.maskResolveView = this.maskResolveTexture.createView();
1311
1353
  // Bloom pyramid: mip 0 is half-res, each subsequent mip halves again.
1312
1354
  // Mip count chosen so the coarsest mip is ≥4 px on the short side, capped at BLOOM_MAX_LEVELS.
1313
1355
  const bw = Math.max(1, Math.floor(width / 2));
@@ -1352,9 +1394,16 @@ export class Engine {
1352
1394
  loadOp: "clear",
1353
1395
  storeOp: "store",
1354
1396
  };
1397
+ const maskAttachment = {
1398
+ view: this.multisampleMaskTexture.createView(),
1399
+ resolveTarget: this.maskResolveView,
1400
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
1401
+ loadOp: "clear",
1402
+ storeOp: "store",
1403
+ };
1355
1404
  this.renderPassDescriptor = {
1356
1405
  label: "renderPass",
1357
- colorAttachments: [colorAttachment],
1406
+ colorAttachments: [colorAttachment, maskAttachment],
1358
1407
  depthStencilAttachment: {
1359
1408
  view: depthTextureView,
1360
1409
  depthClearValue: 1.0,
@@ -1386,6 +1435,7 @@ export class Engine {
1386
1435
  entries: [
1387
1436
  { binding: 0, resource: this.hdrResolveTexture.createView() },
1388
1437
  { binding: 1, resource: { buffer: this.bloomBlitUniformBuffer } },
1438
+ { binding: 2, resource: this.maskResolveView },
1389
1439
  ],
1390
1440
  });
1391
1441
  // Downsample[i] reads bloomDown mip (i-1), writes bloomDown mip i. i ∈ [1..N-1].
@@ -2116,16 +2166,20 @@ export class Engine {
2116
2166
  premultiplyAlpha: "none",
2117
2167
  colorSpaceConversion: "none",
2118
2168
  });
2169
+ const mipLevelCount = Math.floor(Math.log2(Math.max(imageBitmap.width, imageBitmap.height))) + 1;
2119
2170
  const texture = this.device.createTexture({
2120
2171
  label: `texture: ${cacheKey}`,
2121
2172
  size: [imageBitmap.width, imageBitmap.height],
2122
2173
  format: "rgba8unorm-srgb",
2174
+ mipLevelCount,
2123
2175
  usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
2124
2176
  });
2125
2177
  this.device.queue.copyExternalImageToTexture({ source: imageBitmap }, { texture }, [
2126
2178
  imageBitmap.width,
2127
2179
  imageBitmap.height,
2128
2180
  ]);
2181
+ if (mipLevelCount > 1)
2182
+ this.generateMipmaps(texture, mipLevelCount);
2129
2183
  this.textureCache.set(cacheKey, texture);
2130
2184
  inst.textureCacheKeys.push(cacheKey);
2131
2185
  return texture;
@@ -2134,6 +2188,62 @@ export class Engine {
2134
2188
  return null;
2135
2189
  }
2136
2190
  }
2191
+ // Bilinear box-filter downsample per level. Reads srgb view (hardware linearizes on sample,
2192
+ // re-encodes on write), so intensities are filtered in linear space — matching EEVEE/Blender.
2193
+ generateMipmaps(texture, mipLevelCount) {
2194
+ if (!this.mipBlitPipeline || !this.mipBlitSampler) {
2195
+ this.mipBlitSampler = this.device.createSampler({
2196
+ magFilter: "linear",
2197
+ minFilter: "linear",
2198
+ addressModeU: "clamp-to-edge",
2199
+ addressModeV: "clamp-to-edge",
2200
+ });
2201
+ const module = this.device.createShaderModule({
2202
+ label: "mipmap blit",
2203
+ code: /* wgsl */ `
2204
+ @group(0) @binding(0) var src: texture_2d<f32>;
2205
+ @group(0) @binding(1) var samp: sampler;
2206
+ @vertex fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
2207
+ let x = f32((vi & 1u) << 2u) - 1.0;
2208
+ let y = f32((vi & 2u) << 1u) - 1.0;
2209
+ return vec4f(x, y, 0.0, 1.0);
2210
+ }
2211
+ @fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
2212
+ let dstDims = vec2f(textureDimensions(src)) * 0.5;
2213
+ let uv = p.xy / max(dstDims, vec2f(1.0));
2214
+ return textureSampleLevel(src, samp, uv, 0.0);
2215
+ }
2216
+ `,
2217
+ });
2218
+ this.mipBlitPipeline = this.device.createRenderPipeline({
2219
+ label: "mipmap blit pipeline",
2220
+ layout: "auto",
2221
+ vertex: { module, entryPoint: "vs" },
2222
+ fragment: { module, entryPoint: "fs", targets: [{ format: "rgba8unorm-srgb" }] },
2223
+ primitive: { topology: "triangle-list" },
2224
+ });
2225
+ }
2226
+ const encoder = this.device.createCommandEncoder({ label: "mipgen" });
2227
+ for (let level = 1; level < mipLevelCount; level++) {
2228
+ const srcView = texture.createView({ baseMipLevel: level - 1, mipLevelCount: 1 });
2229
+ const dstView = texture.createView({ baseMipLevel: level, mipLevelCount: 1 });
2230
+ const bindGroup = this.device.createBindGroup({
2231
+ layout: this.mipBlitPipeline.getBindGroupLayout(0),
2232
+ entries: [
2233
+ { binding: 0, resource: srcView },
2234
+ { binding: 1, resource: this.mipBlitSampler },
2235
+ ],
2236
+ });
2237
+ const pass = encoder.beginRenderPass({
2238
+ colorAttachments: [{ view: dstView, clearValue: { r: 0, g: 0, b: 0, a: 0 }, loadOp: "clear", storeOp: "store" }],
2239
+ });
2240
+ pass.setPipeline(this.mipBlitPipeline);
2241
+ pass.setBindGroup(0, bindGroup);
2242
+ pass.draw(3);
2243
+ pass.end();
2244
+ }
2245
+ this.device.queue.submit([encoder.finish()]);
2246
+ }
2137
2247
  renderGround(pass) {
2138
2248
  if (!this.hasGround || !this.groundVertexBuffer || !this.groundIndexBuffer || !this.groundDrawCall)
2139
2249
  return;
@@ -2467,5 +2577,9 @@ export class Engine {
2467
2577
  Engine.instance = null;
2468
2578
  Engine.MULTISAMPLE_COUNT = 4;
2469
2579
  Engine.HDR_FORMAT = "rgba16float";
2580
+ /** Single-channel mask written alongside HDR color — 1 = model geometry (contributes
2581
+ * to bloom), 0 = ground (never blooms). Sampled by the bloom blit pass to gate the
2582
+ * prefilter so ground brightness can't halo the scene. */
2583
+ Engine.BLOOM_MASK_FORMAT = "r8unorm";
2470
2584
  Engine.BLOOM_MAX_LEVELS = 7;
2471
- Engine.SHADOW_MAP_SIZE = 4096;
2585
+ Engine.SHADOW_MAP_SIZE = 2048;