reze-engine 0.10.2 → 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 (62) hide show
  1. package/README.md +90 -13
  2. package/dist/engine.d.ts +177 -34
  3. package/dist/engine.d.ts.map +1 -1
  4. package/dist/engine.js +1204 -318
  5. package/dist/index.d.ts +2 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +1 -1
  8. package/dist/shaders/body.d.ts +2 -0
  9. package/dist/shaders/body.d.ts.map +1 -0
  10. package/dist/shaders/body.js +232 -0
  11. package/dist/shaders/classify.d.ts +4 -0
  12. package/dist/shaders/classify.d.ts.map +1 -0
  13. package/dist/shaders/classify.js +12 -0
  14. package/dist/shaders/cloth_rough.d.ts +2 -0
  15. package/dist/shaders/cloth_rough.d.ts.map +1 -0
  16. package/dist/shaders/cloth_rough.js +190 -0
  17. package/dist/shaders/cloth_smooth.d.ts +2 -0
  18. package/dist/shaders/cloth_smooth.d.ts.map +1 -0
  19. package/dist/shaders/cloth_smooth.js +186 -0
  20. package/dist/shaders/default.d.ts +2 -0
  21. package/dist/shaders/default.d.ts.map +1 -0
  22. package/dist/shaders/default.js +185 -0
  23. package/dist/shaders/dfg_lut.d.ts +3 -0
  24. package/dist/shaders/dfg_lut.d.ts.map +1 -0
  25. package/dist/shaders/dfg_lut.js +129 -0
  26. package/dist/shaders/eye.d.ts +2 -0
  27. package/dist/shaders/eye.d.ts.map +1 -0
  28. package/dist/shaders/eye.js +159 -0
  29. package/dist/shaders/face.d.ts +2 -0
  30. package/dist/shaders/face.d.ts.map +1 -0
  31. package/dist/shaders/face.js +235 -0
  32. package/dist/shaders/hair.d.ts +2 -0
  33. package/dist/shaders/hair.d.ts.map +1 -0
  34. package/dist/shaders/hair.js +196 -0
  35. package/dist/shaders/ltc_mag_lut.d.ts +3 -0
  36. package/dist/shaders/ltc_mag_lut.d.ts.map +1 -0
  37. package/dist/shaders/ltc_mag_lut.js +1033 -0
  38. package/dist/shaders/metal.d.ts +2 -0
  39. package/dist/shaders/metal.d.ts.map +1 -0
  40. package/dist/shaders/metal.js +187 -0
  41. package/dist/shaders/nodes.d.ts +2 -0
  42. package/dist/shaders/nodes.d.ts.map +1 -0
  43. package/dist/shaders/nodes.js +465 -0
  44. package/dist/shaders/stockings.d.ts +2 -0
  45. package/dist/shaders/stockings.d.ts.map +1 -0
  46. package/dist/shaders/stockings.js +244 -0
  47. package/package.json +1 -1
  48. package/src/engine.ts +1412 -385
  49. package/src/index.ts +12 -2
  50. package/src/shaders/body.ts +234 -0
  51. package/src/shaders/classify.ts +25 -0
  52. package/src/shaders/cloth_rough.ts +192 -0
  53. package/src/shaders/cloth_smooth.ts +188 -0
  54. package/src/shaders/default.ts +186 -0
  55. package/src/shaders/dfg_lut.ts +131 -0
  56. package/src/shaders/eye.ts +160 -0
  57. package/src/shaders/face.ts +237 -0
  58. package/src/shaders/hair.ts +198 -0
  59. package/src/shaders/ltc_mag_lut.ts +1035 -0
  60. package/src/shaders/metal.ts +189 -0
  61. package/src/shaders/nodes.ts +466 -0
  62. package/src/shaders/stockings.ts +246 -0
package/dist/engine.js CHANGED
@@ -3,17 +3,38 @@ import { Mat4, Vec3 } from "./math";
3
3
  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
+ import { DEFAULT_SHADER_WGSL } from "./shaders/default";
7
+ import { BRDF_LUT_SIZE, BRDF_LUT_BAKE_WGSL } from "./shaders/dfg_lut";
8
+ import { LTC_MAG_LUT_SIZE, LTC_MAG_LUT_DATA } from "./shaders/ltc_mag_lut";
9
+ import { FACE_SHADER_WGSL } from "./shaders/face";
10
+ import { HAIR_SHADER_WGSL } from "./shaders/hair";
11
+ import { CLOTH_SMOOTH_SHADER_WGSL } from "./shaders/cloth_smooth";
12
+ import { CLOTH_ROUGH_SHADER_WGSL } from "./shaders/cloth_rough";
13
+ import { METAL_SHADER_WGSL } from "./shaders/metal";
14
+ import { BODY_SHADER_WGSL } from "./shaders/body";
15
+ import { EYE_SHADER_WGSL } from "./shaders/eye";
16
+ import { STOCKINGS_SHADER_WGSL } from "./shaders/stockings";
17
+ import { resolvePreset } from "./shaders/classify";
18
+ export const DEFAULT_BLOOM_OPTIONS = {
19
+ enabled: true,
20
+ threshold: 0.5,
21
+ knee: 0.5,
22
+ radius: 4.0,
23
+ color: new Vec3(1.0, 0.7247558832168579, 0.6487361788749695),
24
+ intensity: 0.05,
25
+ clamp: 0.0,
26
+ };
27
+ export const DEFAULT_VIEW_TRANSFORM = {
28
+ exposure: -0.30000001192092896,
29
+ gamma: 1.0,
30
+ look: "medium_high_contrast",
31
+ };
6
32
  export const DEFAULT_ENGINE_OPTIONS = {
7
- ambientColor: new Vec3(0.88, 0.88, 0.88),
8
- directionalLightIntensity: 0.24,
9
- minSpecularIntensity: 0.3,
10
- rimLightIntensity: 0.4,
11
- cameraDistance: 26.6,
12
- cameraTarget: new Vec3(0, 12.5, 0),
13
- cameraFov: Math.PI / 4,
33
+ world: { color: new Vec3(0.4014, 0.4944, 0.647), strength: 0.3 },
34
+ sun: { color: new Vec3(1.0, 1.0, 1.0), strength: 2.0, direction: new Vec3(-0.0873, -0.3844, 0.919) },
35
+ camera: { distance: 26.6, target: new Vec3(0, 12.5, 0), fov: Math.PI / 4 },
14
36
  onRaycast: undefined,
15
37
  physicsOptions: { constraintSolverKeywords: ["胸"] },
16
- shadowLightDirection: new Vec3(0.12, -1, 0.16),
17
38
  };
18
39
  export class Engine {
19
40
  static getInstance() {
@@ -27,16 +48,26 @@ export class Engine {
27
48
  this.lightData = new Float32Array(64);
28
49
  this.lightCount = 0;
29
50
  this.resizeObserver = null;
51
+ // [exposure, gamma, _, _, bloomTint.x, bloomTint.y, bloomTint.z, bloomIntensity]
52
+ this.compositeUniformData = new Float32Array(8);
53
+ this.bloomBlitUniformData = new Float32Array(4);
54
+ this.bloomUpsampleUniformData = new Float32Array(4);
55
+ this.bloomMipCount = 0;
56
+ this.bloomDownMipViews = [];
57
+ this.bloomUpMipViews = [];
58
+ this.bloomDownsampleBindGroups = [];
59
+ this.bloomUpsampleBindGroups = [];
30
60
  this.hasGround = false;
31
61
  this.shadowLightVPMatrix = new Float32Array(16);
32
62
  this.groundDrawCall = null;
33
63
  this.physicsOptions = DEFAULT_ENGINE_OPTIONS.physicsOptions;
34
- this.shadowLightDirection = DEFAULT_ENGINE_OPTIONS.shadowLightDirection;
35
64
  this.lastTouchTime = 0;
36
65
  this.DOUBLE_TAP_DELAY = 300;
37
66
  this.pendingPick = null;
38
67
  this.modelInstances = new Map();
39
68
  this.textureCache = new Map();
69
+ this.mipBlitPipeline = null;
70
+ this.mipBlitSampler = null;
40
71
  this._nextDefaultModelId = 0;
41
72
  // IK and physics enabled at engine level (same for all models)
42
73
  this.ikEnabled = true;
@@ -56,7 +87,7 @@ export class Engine {
56
87
  };
57
88
  this.animationFrameId = null;
58
89
  this.renderLoopCallback = null;
59
- // Shadow uses a fixed orthographic projection, independent of the visible light direction
90
+ // Shadow is cast from the visible sun direction — same vector the shader lights with.
60
91
  this.shadowLightVPDirty = true;
61
92
  this.handleCanvasDoubleClick = (event) => {
62
93
  if (!this.onRaycast || this.modelInstances.size === 0)
@@ -90,20 +121,135 @@ export class Engine {
90
121
  }
91
122
  };
92
123
  this.canvas = canvas;
93
- if (options) {
94
- this.ambientColor = options.ambientColor ?? DEFAULT_ENGINE_OPTIONS.ambientColor;
95
- this.directionalLightIntensity =
96
- options.directionalLightIntensity ?? DEFAULT_ENGINE_OPTIONS.directionalLightIntensity;
97
- this.minSpecularIntensity = options.minSpecularIntensity ?? DEFAULT_ENGINE_OPTIONS.minSpecularIntensity;
98
- this.rimLightIntensity = options.rimLightIntensity ?? DEFAULT_ENGINE_OPTIONS.rimLightIntensity;
99
- this.cameraDistance = options.cameraDistance ?? DEFAULT_ENGINE_OPTIONS.cameraDistance;
100
- this.cameraTarget = options.cameraTarget ?? DEFAULT_ENGINE_OPTIONS.cameraTarget;
101
- this.cameraFov = options.cameraFov ?? DEFAULT_ENGINE_OPTIONS.cameraFov;
102
- this.onRaycast = options.onRaycast;
103
- this.physicsOptions = options.physicsOptions ?? DEFAULT_ENGINE_OPTIONS.physicsOptions;
104
- this.shadowLightDirection = options.shadowLightDirection ?? DEFAULT_ENGINE_OPTIONS.shadowLightDirection;
124
+ const d = DEFAULT_ENGINE_OPTIONS;
125
+ this.world = {
126
+ color: options?.world?.color ?? d.world.color,
127
+ strength: options?.world?.strength ?? d.world.strength,
128
+ };
129
+ this.sun = {
130
+ color: options?.sun?.color ?? d.sun.color,
131
+ strength: options?.sun?.strength ?? d.sun.strength,
132
+ direction: options?.sun?.direction ?? d.sun.direction,
133
+ };
134
+ this.cameraConfig = {
135
+ distance: options?.camera?.distance ?? d.camera.distance,
136
+ target: options?.camera?.target ?? d.camera.target,
137
+ fov: options?.camera?.fov ?? d.camera.fov,
138
+ };
139
+ this.onRaycast = options?.onRaycast;
140
+ this.physicsOptions = options?.physicsOptions ?? d.physicsOptions;
141
+ this.bloomSettings = Engine.mergeBloomDefaults(options?.bloom);
142
+ this.viewTransform = Engine.mergeViewTransformDefaults(options?.view);
143
+ }
144
+ /** Merge partial bloom with EEVEE defaults (same as constructor). */
145
+ static mergeBloomDefaults(partial) {
146
+ const d = DEFAULT_BLOOM_OPTIONS;
147
+ const c = partial?.color;
148
+ return {
149
+ enabled: partial?.enabled ?? d.enabled,
150
+ threshold: partial?.threshold ?? d.threshold,
151
+ knee: partial?.knee ?? d.knee,
152
+ radius: partial?.radius ?? d.radius,
153
+ color: c ? new Vec3(c.x, c.y, c.z) : new Vec3(d.color.x, d.color.y, d.color.z),
154
+ intensity: partial?.intensity ?? d.intensity,
155
+ clamp: partial?.clamp ?? d.clamp,
156
+ };
157
+ }
158
+ static mergeViewTransformDefaults(partial) {
159
+ const d = DEFAULT_VIEW_TRANSFORM;
160
+ return {
161
+ exposure: partial?.exposure ?? d.exposure,
162
+ gamma: partial?.gamma ?? d.gamma,
163
+ look: partial?.look ?? d.look,
164
+ };
165
+ }
166
+ /** Current bloom settings (Blender names; tint is a copied `Vec3`). */
167
+ getBloomOptions() {
168
+ const b = this.bloomSettings;
169
+ return {
170
+ enabled: b.enabled,
171
+ threshold: b.threshold,
172
+ knee: b.knee,
173
+ radius: b.radius,
174
+ color: new Vec3(b.color.x, b.color.y, b.color.z),
175
+ intensity: b.intensity,
176
+ clamp: b.clamp,
177
+ };
178
+ }
179
+ getViewTransformOptions() {
180
+ const v = this.viewTransform;
181
+ return { exposure: v.exposure, gamma: v.gamma, look: v.look };
182
+ }
183
+ setViewTransformOptions(patch) {
184
+ const v = this.viewTransform;
185
+ if (patch.exposure !== undefined)
186
+ v.exposure = patch.exposure;
187
+ if (patch.gamma !== undefined)
188
+ v.gamma = patch.gamma;
189
+ if (patch.look !== undefined)
190
+ v.look = patch.look;
191
+ if (this.device && this.compositeUniformBuffer) {
192
+ this.writeCompositeViewUniforms();
105
193
  }
106
194
  }
195
+ writeCompositeViewUniforms() {
196
+ const v = this.viewTransform;
197
+ const b = this.bloomSettings;
198
+ const effIntensity = b.enabled ? b.intensity : 0.0;
199
+ const u = this.compositeUniformData;
200
+ u[0] = v.exposure;
201
+ u[1] = Math.max(v.gamma, 1e-4);
202
+ u[2] = 0.0;
203
+ u[3] = 0.0;
204
+ u[4] = b.color.x;
205
+ u[5] = b.color.y;
206
+ u[6] = b.color.z;
207
+ u[7] = effIntensity;
208
+ this.device.queue.writeBuffer(this.compositeUniformBuffer, 0, u);
209
+ }
210
+ /** Patch bloom; GPU uniforms update immediately if `init()` has run. */
211
+ setBloomOptions(patch) {
212
+ const b = this.bloomSettings;
213
+ if (patch.enabled !== undefined)
214
+ b.enabled = patch.enabled;
215
+ if (patch.threshold !== undefined)
216
+ b.threshold = patch.threshold;
217
+ if (patch.knee !== undefined)
218
+ b.knee = patch.knee;
219
+ if (patch.radius !== undefined)
220
+ b.radius = patch.radius;
221
+ if (patch.color !== undefined) {
222
+ b.color.x = patch.color.x;
223
+ b.color.y = patch.color.y;
224
+ b.color.z = patch.color.z;
225
+ }
226
+ if (patch.intensity !== undefined)
227
+ b.intensity = patch.intensity;
228
+ if (patch.clamp !== undefined)
229
+ b.clamp = patch.clamp;
230
+ if (this.device && this.bloomBlitUniformBuffer) {
231
+ this.writeBloomUniforms();
232
+ this.writeCompositeViewUniforms();
233
+ }
234
+ }
235
+ // EEVEE prefilter uniforms (blit stage) + upsample sample scale. Intensity/tint live in composite.
236
+ writeBloomUniforms() {
237
+ const b = this.bloomSettings;
238
+ const bu = this.bloomBlitUniformData;
239
+ // EEVEE prefilter: threshold, knee, clamp (0 → disabled), _unused
240
+ bu[0] = b.threshold;
241
+ bu[1] = b.knee;
242
+ bu[2] = b.clamp;
243
+ bu[3] = 0.0;
244
+ this.device.queue.writeBuffer(this.bloomBlitUniformBuffer, 0, bu);
245
+ const us = this.bloomUpsampleUniformData;
246
+ // Blender: bloom.radius directly controls the tent-filter sample scale in texel units.
247
+ us[0] = Math.max(0.5, b.radius);
248
+ us[1] = 0;
249
+ us[2] = 0;
250
+ us[3] = 0;
251
+ this.device.queue.writeBuffer(this.bloomUpsampleUniformBuffer, 0, us);
252
+ }
107
253
  // Step 1: Get WebGPU device and context
108
254
  async init() {
109
255
  const adapter = await navigator.gpu?.requestAdapter();
@@ -129,7 +275,79 @@ export class Engine {
129
275
  this.setupResize();
130
276
  Engine.instance = this;
131
277
  }
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)",
291
+ size: [LTC_MAG_LUT_SIZE, LTC_MAG_LUT_SIZE],
292
+ format: "rg16float",
293
+ usage: GPUTextureUsage.COPY_DST | GPUTextureUsage.TEXTURE_BINDING,
294
+ });
295
+ const n = LTC_MAG_LUT_DATA.length;
296
+ const half = new Uint16Array(n);
297
+ const f32 = new Float32Array(1);
298
+ const u32 = new Uint32Array(f32.buffer);
299
+ for (let i = 0; i < n; i++) {
300
+ f32[0] = LTC_MAG_LUT_DATA[i];
301
+ const x = u32[0];
302
+ const sign = (x >>> 16) & 0x8000;
303
+ let exp = ((x >>> 23) & 0xff) - 127 + 15;
304
+ const mant = x & 0x7fffff;
305
+ if (exp <= 0) {
306
+ half[i] = sign;
307
+ }
308
+ else if (exp >= 31) {
309
+ half[i] = sign | 0x7c00;
310
+ }
311
+ else {
312
+ half[i] = sign | (exp << 10) | (mant >>> 13);
313
+ }
314
+ }
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();
348
+ }
132
349
  createRenderPipeline(config) {
350
+ const targets = config.fragmentTargets ?? (config.fragmentTarget ? [config.fragmentTarget] : undefined);
133
351
  return this.device.createRenderPipeline({
134
352
  label: config.label,
135
353
  layout: config.layout,
@@ -137,11 +355,11 @@ export class Engine {
137
355
  module: config.shaderModule,
138
356
  buffers: config.vertexBuffers,
139
357
  },
140
- fragment: config.fragmentTarget
358
+ fragment: targets
141
359
  ? {
142
360
  module: config.shaderModule,
143
361
  entryPoint: config.fragmentEntryPoint,
144
- targets: [config.fragmentTarget],
362
+ targets,
145
363
  }
146
364
  : undefined,
147
365
  primitive: { cullMode: config.cullMode ?? "none" },
@@ -153,6 +371,7 @@ export class Engine {
153
371
  this.materialSampler = this.device.createSampler({
154
372
  magFilter: "linear",
155
373
  minFilter: "linear",
374
+ mipmapFilter: "linear",
156
375
  addressModeU: "repeat",
157
376
  addressModeV: "repeat",
158
377
  });
@@ -192,8 +411,11 @@ export class Engine {
192
411
  attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" }],
193
412
  },
194
413
  ];
414
+ // Internal scene passes render into the HDR offscreen target; only the final
415
+ // composite pass writes the swapchain. Tonemap moved to composite so bloom
416
+ // (added next) can run on linear HDR.
195
417
  const standardBlend = {
196
- format: this.presentationFormat,
418
+ format: Engine.HDR_FORMAT,
197
419
  blend: {
198
420
  color: {
199
421
  srcFactor: "src-alpha",
@@ -207,146 +429,64 @@ export class Engine {
207
429
  },
208
430
  },
209
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];
210
437
  const shaderModule = this.device.createShaderModule({
211
- label: "model shaders",
212
- code: /* wgsl */ `
213
- struct CameraUniforms {
214
- view: mat4x4f,
215
- projection: mat4x4f,
216
- viewPos: vec3f,
217
- _padding: f32,
218
- };
219
-
220
- struct Light {
221
- direction: vec4f,
222
- color: vec4f,
223
- };
224
-
225
- struct LightUniforms {
226
- ambientColor: vec4f,
227
- lights: array<Light, 4>,
228
- };
229
-
230
- struct MaterialUniforms {
231
- alpha: f32,
232
- rimIntensity: f32,
233
- shininess: f32,
234
- _padding1: f32,
235
- rimColor: vec3f,
236
- _padding2: f32,
237
- diffuseColor: vec3f,
238
- _padding3: f32,
239
- ambientColor: vec3f,
240
- _padding4: f32,
241
- specularColor: vec3f,
242
- _padding5: f32,
243
- };
244
-
245
- struct VertexOutput {
246
- @builtin(position) position: vec4f,
247
- @location(0) normal: vec3f,
248
- @location(1) uv: vec2f,
249
- @location(2) worldPos: vec3f,
250
- };
251
-
252
- // group 0: per-frame (bound once per pass)
253
- @group(0) @binding(0) var<uniform> camera: CameraUniforms;
254
- @group(0) @binding(1) var<uniform> light: LightUniforms;
255
- @group(0) @binding(2) var diffuseSampler: sampler;
256
- // group 1: per-instance (bound once per model)
257
- @group(1) @binding(0) var<storage, read> skinMats: array<mat4x4f>;
258
- // group 2: per-material (bound per draw call)
259
- @group(2) @binding(0) var diffuseTexture: texture_2d<f32>;
260
- @group(2) @binding(1) var<uniform> material: MaterialUniforms;
261
-
262
- @vertex fn vs(
263
- @location(0) position: vec3f,
264
- @location(1) normal: vec3f,
265
- @location(2) uv: vec2f,
266
- @location(3) joints0: vec4<u32>,
267
- @location(4) weights0: vec4<f32>
268
- ) -> VertexOutput {
269
- var output: VertexOutput;
270
- let pos4 = vec4f(position, 1.0);
271
-
272
- let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
273
- let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);
274
- let normalizedWeights = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
275
-
276
- var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
277
- var skinnedNrm = vec3f(0.0, 0.0, 0.0);
278
- for (var i = 0u; i < 4u; i++) {
279
- let j = joints0[i];
280
- let w = normalizedWeights[i];
281
- let m = skinMats[j];
282
- skinnedPos += (m * pos4) * w;
283
- let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
284
- skinnedNrm += (r3 * normal) * w;
285
- }
286
- let worldPos = skinnedPos.xyz;
287
- output.position = camera.projection * camera.view * vec4f(worldPos, 1.0);
288
- output.normal = normalize(skinnedNrm);
289
- output.uv = uv;
290
- output.worldPos = worldPos;
291
- return output;
292
- }
293
-
294
- @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
295
- let finalAlpha = material.alpha;
296
- if (finalAlpha < 0.001) {
297
- discard;
298
- }
299
-
300
- let n = normalize(input.normal);
301
- let textureColor = textureSample(diffuseTexture, diffuseSampler, input.uv).rgb;
302
-
303
- let viewDir = normalize(camera.viewPos - input.worldPos);
304
-
305
- let albedo = textureColor * material.diffuseColor;
306
-
307
- let minSpec = light.ambientColor.w;
308
- let effectiveSpecular = max(material.specularColor, vec3f(minSpec));
309
- let specPower = max(material.shininess, 1.0);
310
-
311
- let l = -light.lights[0].direction.xyz;
312
- let nDotL = max(dot(n, l), 0.0);
313
- let intensity = light.lights[0].color.w;
314
- let radiance = light.lights[0].color.xyz * intensity;
315
-
316
- let lightAccum = light.ambientColor.xyz + radiance * nDotL;
317
-
318
- let h = normalize(l + viewDir);
319
- let nDotH = max(dot(n, h), 0.0);
320
- let specFactor = pow(nDotH, specPower);
321
- let specularAccum = effectiveSpecular * radiance * specFactor * nDotL;
322
-
323
- let litColor = albedo * lightAccum;
324
-
325
- let fresnel = 1.0 - abs(dot(n, viewDir));
326
- let rimFactor = pow(fresnel, 4.0);
327
- let rimLight = material.rimColor * material.rimIntensity * rimFactor;
328
-
329
- let color = litColor + specularAccum + rimLight;
330
-
331
- return vec4f(color, finalAlpha);
332
- }
333
- `,
438
+ label: "default model shader",
439
+ code: DEFAULT_SHADER_WGSL,
440
+ });
441
+ const faceShaderModule = this.device.createShaderModule({
442
+ label: "face NPR shader",
443
+ code: FACE_SHADER_WGSL,
444
+ });
445
+ const hairShaderModule = this.device.createShaderModule({
446
+ label: "hair NPR shader",
447
+ code: HAIR_SHADER_WGSL,
448
+ });
449
+ const clothSmoothShaderModule = this.device.createShaderModule({
450
+ label: "cloth smooth NPR shader",
451
+ code: CLOTH_SMOOTH_SHADER_WGSL,
452
+ });
453
+ const clothRoughShaderModule = this.device.createShaderModule({
454
+ label: "cloth rough NPR shader",
455
+ code: CLOTH_ROUGH_SHADER_WGSL,
334
456
  });
335
- // group 0: per-frame (camera + light + sampler) — bound once per pass
457
+ const metalShaderModule = this.device.createShaderModule({
458
+ label: "metal NPR shader",
459
+ code: METAL_SHADER_WGSL,
460
+ });
461
+ const bodyShaderModule = this.device.createShaderModule({
462
+ label: "body NPR shader",
463
+ code: BODY_SHADER_WGSL,
464
+ });
465
+ const eyeShaderModule = this.device.createShaderModule({
466
+ label: "eye shader",
467
+ code: EYE_SHADER_WGSL,
468
+ });
469
+ const stockingsShaderModule = this.device.createShaderModule({
470
+ label: "stockings NPR shader",
471
+ code: STOCKINGS_SHADER_WGSL,
472
+ });
473
+ // group 0: per-frame (camera + light + sampler + shadow) — bound once per pass
336
474
  this.mainPerFrameBindGroupLayout = this.device.createBindGroupLayout({
337
475
  label: "main per-frame bind group layout",
338
476
  entries: [
339
477
  { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
340
478
  { binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
341
479
  { binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
480
+ { binding: 3, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "depth" } },
481
+ { binding: 4, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "comparison" } },
482
+ { binding: 5, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
483
+ { binding: 9, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
342
484
  ],
343
485
  });
344
486
  // group 1: per-instance (skinMats) — bound once per model
345
487
  this.mainPerInstanceBindGroupLayout = this.device.createBindGroupLayout({
346
488
  label: "main per-instance bind group layout",
347
- entries: [
348
- { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
349
- ],
489
+ entries: [{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } }],
350
490
  });
351
491
  // group 2: per-material (texture + material uniforms) — bound per draw call
352
492
  this.mainPerMaterialBindGroupLayout = this.device.createBindGroupLayout({
@@ -358,23 +498,123 @@ export class Engine {
358
498
  });
359
499
  const mainPipelineLayout = this.device.createPipelineLayout({
360
500
  label: "main pipeline layout",
361
- bindGroupLayouts: [this.mainPerFrameBindGroupLayout, this.mainPerInstanceBindGroupLayout, this.mainPerMaterialBindGroupLayout],
362
- });
363
- this.perFrameBindGroup = this.device.createBindGroup({
364
- label: "main per-frame bind group",
365
- layout: this.mainPerFrameBindGroupLayout,
366
- entries: [
367
- { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
368
- { binding: 1, resource: { buffer: this.lightUniformBuffer } },
369
- { binding: 2, resource: this.materialSampler },
501
+ bindGroupLayouts: [
502
+ this.mainPerFrameBindGroupLayout,
503
+ this.mainPerInstanceBindGroupLayout,
504
+ this.mainPerMaterialBindGroupLayout,
370
505
  ],
371
506
  });
507
+ // perFrameBindGroup is created after shadow resources below
372
508
  this.modelPipeline = this.createRenderPipeline({
373
509
  label: "model pipeline",
374
510
  layout: mainPipelineLayout,
375
511
  shaderModule,
376
512
  vertexBuffers: fullVertexBuffers,
377
- fragmentTarget: standardBlend,
513
+ fragmentTargets: sceneTargets,
514
+ cullMode: "none",
515
+ depthStencil: {
516
+ format: "depth24plus-stencil8",
517
+ depthWriteEnabled: true,
518
+ depthCompare: "less-equal",
519
+ },
520
+ });
521
+ this.facePipeline = this.createRenderPipeline({
522
+ label: "face NPR pipeline",
523
+ layout: mainPipelineLayout,
524
+ shaderModule: faceShaderModule,
525
+ vertexBuffers: fullVertexBuffers,
526
+ fragmentTargets: sceneTargets,
527
+ cullMode: "none",
528
+ depthStencil: {
529
+ format: "depth24plus-stencil8",
530
+ depthWriteEnabled: true,
531
+ depthCompare: "less-equal",
532
+ },
533
+ });
534
+ this.hairPipeline = this.createRenderPipeline({
535
+ label: "hair NPR pipeline",
536
+ layout: mainPipelineLayout,
537
+ shaderModule: hairShaderModule,
538
+ vertexBuffers: fullVertexBuffers,
539
+ fragmentTargets: sceneTargets,
540
+ cullMode: "none",
541
+ depthStencil: {
542
+ format: "depth24plus-stencil8",
543
+ depthWriteEnabled: true,
544
+ depthCompare: "less-equal",
545
+ },
546
+ });
547
+ this.clothSmoothPipeline = this.createRenderPipeline({
548
+ label: "cloth smooth NPR pipeline",
549
+ layout: mainPipelineLayout,
550
+ shaderModule: clothSmoothShaderModule,
551
+ vertexBuffers: fullVertexBuffers,
552
+ fragmentTargets: sceneTargets,
553
+ cullMode: "none",
554
+ depthStencil: {
555
+ format: "depth24plus-stencil8",
556
+ depthWriteEnabled: true,
557
+ depthCompare: "less-equal",
558
+ },
559
+ });
560
+ this.clothRoughPipeline = this.createRenderPipeline({
561
+ label: "cloth rough NPR pipeline",
562
+ layout: mainPipelineLayout,
563
+ shaderModule: clothRoughShaderModule,
564
+ vertexBuffers: fullVertexBuffers,
565
+ fragmentTargets: sceneTargets,
566
+ cullMode: "none",
567
+ depthStencil: {
568
+ format: "depth24plus-stencil8",
569
+ depthWriteEnabled: true,
570
+ depthCompare: "less-equal",
571
+ },
572
+ });
573
+ this.metalPipeline = this.createRenderPipeline({
574
+ label: "metal NPR pipeline",
575
+ layout: mainPipelineLayout,
576
+ shaderModule: metalShaderModule,
577
+ vertexBuffers: fullVertexBuffers,
578
+ fragmentTargets: sceneTargets,
579
+ cullMode: "none",
580
+ depthStencil: {
581
+ format: "depth24plus-stencil8",
582
+ depthWriteEnabled: true,
583
+ depthCompare: "less-equal",
584
+ },
585
+ });
586
+ this.bodyPipeline = this.createRenderPipeline({
587
+ label: "body NPR pipeline",
588
+ layout: mainPipelineLayout,
589
+ shaderModule: bodyShaderModule,
590
+ vertexBuffers: fullVertexBuffers,
591
+ fragmentTargets: sceneTargets,
592
+ cullMode: "none",
593
+ depthStencil: {
594
+ format: "depth24plus-stencil8",
595
+ depthWriteEnabled: true,
596
+ depthCompare: "less-equal",
597
+ },
598
+ });
599
+ this.eyePipeline = this.createRenderPipeline({
600
+ label: "eye pipeline",
601
+ layout: mainPipelineLayout,
602
+ shaderModule: eyeShaderModule,
603
+ vertexBuffers: fullVertexBuffers,
604
+ fragmentTargets: sceneTargets,
605
+ cullMode: "none",
606
+ depthStencil: {
607
+ format: "depth24plus-stencil8",
608
+ depthWriteEnabled: true,
609
+ depthCompare: "less-equal",
610
+ },
611
+ });
612
+ this.stockingsPipeline = this.createRenderPipeline({
613
+ label: "stockings NPR pipeline",
614
+ layout: mainPipelineLayout,
615
+ shaderModule: stockingsShaderModule,
616
+ vertexBuffers: fullVertexBuffers,
617
+ fragmentTargets: sceneTargets,
378
618
  cullMode: "none",
379
619
  depthStencil: {
380
620
  format: "depth24plus-stencil8",
@@ -430,6 +670,29 @@ export class Engine {
430
670
  magFilter: "linear",
431
671
  minFilter: "linear",
432
672
  });
673
+ this.shadowMapTexture = this.device.createTexture({
674
+ label: "shadow map",
675
+ size: [Engine.SHADOW_MAP_SIZE, Engine.SHADOW_MAP_SIZE],
676
+ format: "depth32float",
677
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
678
+ });
679
+ this.shadowMapDepthView = this.shadowMapTexture.createView();
680
+ // One-shot bake of Blender EEVEE's combined BRDF LUT (DFG + LTC packed rgba8unorm).
681
+ this.bakeBrdfLut();
682
+ // Now that shadow resources exist, create the main per-frame bind group
683
+ this.perFrameBindGroup = this.device.createBindGroup({
684
+ label: "main per-frame bind group",
685
+ layout: this.mainPerFrameBindGroupLayout,
686
+ entries: [
687
+ { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
688
+ { binding: 1, resource: { buffer: this.lightUniformBuffer } },
689
+ { binding: 2, resource: this.materialSampler },
690
+ { binding: 3, resource: this.shadowMapDepthView },
691
+ { binding: 4, resource: this.shadowComparisonSampler },
692
+ { binding: 5, resource: { buffer: this.shadowLightVPBuffer } },
693
+ { binding: 9, resource: this.brdfLutView },
694
+ ],
695
+ });
433
696
  this.groundShadowBindGroupLayout = this.device.createBindGroupLayout({
434
697
  label: "ground shadow layout",
435
698
  entries: [
@@ -491,7 +754,8 @@ export class Engine {
491
754
  var o: VO; o.worldPos = position; o.normal = normal;
492
755
  o.position = camera.projection * camera.view * vec4f(position, 1.0); return o;
493
756
  }
494
- @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 {
495
759
  let n = normalize(i.normal);
496
760
  let centerDist = length(i.worldPos.xz);
497
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));
@@ -529,7 +793,10 @@ export class Engine {
529
793
  var baseColor = material.diffuseColor * sun * (1.0 - dark * 0.65);
530
794
  baseColor *= noiseTint;
531
795
  let finalColor = mix(baseColor, material.gridLineColor, gridLine * material.gridLineOpacity * edgeFade);
532
- return vec4f(finalColor * edgeFade, edgeFade);
796
+ var out: FSOut;
797
+ out.color = vec4f(finalColor * edgeFade, edgeFade);
798
+ out.mask = 0.0;
799
+ return out;
533
800
  }
534
801
  `,
535
802
  });
@@ -538,7 +805,7 @@ export class Engine {
538
805
  layout: this.device.createPipelineLayout({ bindGroupLayouts: [this.groundShadowBindGroupLayout] }),
539
806
  shaderModule: groundShadowShader,
540
807
  vertexBuffers: fullVertexBuffers,
541
- fragmentTarget: standardBlend,
808
+ fragmentTargets: sceneTargets,
542
809
  cullMode: "back",
543
810
  depthStencil: { format: "depth24plus-stencil8", depthWriteEnabled: true, depthCompare: "less-equal" },
544
811
  });
@@ -558,14 +825,16 @@ export class Engine {
558
825
  });
559
826
  const outlinePipelineLayout = this.device.createPipelineLayout({
560
827
  label: "outline pipeline layout",
561
- bindGroupLayouts: [this.outlinePerFrameBindGroupLayout, this.mainPerInstanceBindGroupLayout, this.outlinePerMaterialBindGroupLayout],
828
+ bindGroupLayouts: [
829
+ this.outlinePerFrameBindGroupLayout,
830
+ this.mainPerInstanceBindGroupLayout,
831
+ this.outlinePerMaterialBindGroupLayout,
832
+ ],
562
833
  });
563
834
  this.outlinePerFrameBindGroup = this.device.createBindGroup({
564
835
  label: "outline per-frame bind group",
565
836
  layout: this.outlinePerFrameBindGroupLayout,
566
- entries: [
567
- { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
568
- ],
837
+ entries: [{ binding: 0, resource: { buffer: this.cameraUniformBuffer } }],
569
838
  });
570
839
  const outlineShaderModule = this.device.createShaderModule({
571
840
  label: "outline shaders",
@@ -621,17 +890,36 @@ export class Engine {
621
890
  }
622
891
  let worldPos = skinnedPos.xyz;
623
892
  let worldNormal = normalize(skinnedNrm);
624
- // Screen-stable edgeline: extrusion ∝ camera distance (same idea as MMD viewers / babylon-mmd-style scaling)
625
- let camDist = max(length(camera.viewPos - worldPos), 0.25);
626
- let refDist = 30.0;
627
- let edgeScale = 0.025;
628
- let expandedPos = worldPos + worldNormal * material.edgeSize * edgeScale * (camDist / refDist);
629
- output.position = camera.projection * camera.view * vec4f(expandedPos, 1.0);
893
+
894
+ // Screen-space outline extrusion — MMD-style pixel-stable edge line.
895
+ // 1. Project position and normal-as-direction to clip space.
896
+ // 2. Normalize the 2D clip-space normal, aspect-compensated so "one pixel horizontally"
897
+ // matches "one pixel vertically" (otherwise wide viewports squash the outline in X).
898
+ // 3. Offset clip-space xy by (normal * edgeSize * edgeScale), then multiply by w
899
+ // so the perspective divide cancels out → offset stays constant in NDC regardless
900
+ // of depth, matching how MMD / babylon-mmd style outlines look identical when zooming.
901
+ // 4. edgeScale is in NDC-y units per PMX edgeSize. ≈ 0.006 gives ~3px at 1080p; it's
902
+ // tied to viewport HEIGHT so resizing the window keeps pixel thickness stable.
903
+ let viewProj = camera.projection * camera.view;
904
+ let clipPos = viewProj * vec4f(worldPos, 1.0);
905
+ let clipNormal = (viewProj * vec4f(worldNormal, 0.0)).xy;
906
+ // projection is column-major: proj[0][0] = 1/(aspect·tan(fov/2)), proj[1][1] = 1/tan(fov/2).
907
+ // Ratio proj[1][1]/proj[0][0] recovers the viewport aspect (width/height).
908
+ let aspect = camera.projection[1][1] / camera.projection[0][0];
909
+ let pixelDir = normalize(vec2f(clipNormal.x * aspect, clipNormal.y));
910
+ let ndcDir = vec2f(pixelDir.x / aspect, pixelDir.y);
911
+ let edgeScale = 0.0016;
912
+ let offset = ndcDir * material.edgeSize * edgeScale * clipPos.w;
913
+ output.position = vec4f(clipPos.xy + offset, clipPos.z, clipPos.w);
630
914
  return output;
631
915
  }
632
916
 
633
- @fragment fn fs() -> @location(0) vec4f {
634
- 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;
635
923
  }
636
924
  `,
637
925
  });
@@ -640,7 +928,7 @@ export class Engine {
640
928
  layout: outlinePipelineLayout,
641
929
  shaderModule: outlineShaderModule,
642
930
  vertexBuffers: outlineVertexBuffers,
643
- fragmentTarget: standardBlend,
931
+ fragmentTargets: sceneTargets,
644
932
  cullMode: "back",
645
933
  depthStencil: {
646
934
  format: "depth24plus-stencil8",
@@ -649,6 +937,282 @@ export class Engine {
649
937
  depthCompare: "less-equal",
650
938
  },
651
939
  });
940
+ // ─── Bloom (EEVEE 3.6 pyramid): blit(Karis prefilter) → 13-tap downsamples → 9-tap tent upsamples ───
941
+ // Mirrors source/blender/draw/engines/eevee/shaders/effect_bloom_frag.glsl.
942
+ // Firefly suppression lives in the blit (Karis luminance-weighted 4-tap average). A single-pass
943
+ // Gaussian cannot reproduce this — hot pixels dominate and produce the sparkle halo.
944
+ this.bloomSampler = this.device.createSampler({
945
+ label: "bloom sampler",
946
+ magFilter: "linear",
947
+ minFilter: "linear",
948
+ addressModeU: "clamp-to-edge",
949
+ addressModeV: "clamp-to-edge",
950
+ });
951
+ this.bloomBlitUniformBuffer = this.device.createBuffer({
952
+ label: "bloom blit uniforms",
953
+ size: 16,
954
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
955
+ });
956
+ this.bloomUpsampleUniformBuffer = this.device.createBuffer({
957
+ label: "bloom upsample uniforms",
958
+ size: 16,
959
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
960
+ });
961
+ this.bloomBlitBindGroupLayout = this.device.createBindGroupLayout({
962
+ label: "bloom blit layout",
963
+ entries: [
964
+ { binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "unfilterable-float" } },
965
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
966
+ { binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "unfilterable-float" } },
967
+ ],
968
+ });
969
+ this.bloomDownsampleBindGroupLayout = this.device.createBindGroupLayout({
970
+ label: "bloom downsample layout",
971
+ entries: [
972
+ { binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: {} },
973
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
974
+ ],
975
+ });
976
+ this.bloomUpsampleBindGroupLayout = this.device.createBindGroupLayout({
977
+ label: "bloom upsample layout",
978
+ entries: [
979
+ { binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: {} }, // coarser-mip accumulator
980
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: {} }, // matching downsample mip (base add)
981
+ { binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
982
+ { binding: 3, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
983
+ ],
984
+ });
985
+ const bloomFullscreenVs = /* wgsl */ `
986
+ @vertex fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
987
+ let x = f32((vi & 1u) << 2u) - 1.0;
988
+ let y = f32((vi & 2u) << 1u) - 1.0;
989
+ return vec4f(x, y, 0.0, 1.0);
990
+ }
991
+ `;
992
+ // Blit: full-res HDR → half-res. Karis 4-tap firefly average + EEVEE quadratic knee threshold + clamp.
993
+ const bloomBlitShader = this.device.createShaderModule({
994
+ label: "bloom blit (Karis prefilter)",
995
+ code: `${bloomFullscreenVs}
996
+ @group(0) @binding(0) var hdrTex: texture_2d<f32>;
997
+ @group(0) @binding(1) var<uniform> prefilter: vec4<f32>; // threshold, knee, clamp, _unused
998
+ @group(0) @binding(2) var maskTex: texture_2d<f32>;
999
+
1000
+ fn luminance(c: vec3f) -> f32 {
1001
+ return dot(max(c, vec3f(0.0)), vec3f(0.2126, 0.7152, 0.0722));
1002
+ }
1003
+ fn fetch(c: vec2<i32>, clampV: f32) -> vec3f {
1004
+ let d = vec2<i32>(textureDimensions(hdrTex));
1005
+ let cc = clamp(c, vec2<i32>(0), d - vec2<i32>(1));
1006
+ let s = textureLoad(hdrTex, cc, 0);
1007
+ // Scene pass uses src-alpha blend with clear alpha 0 → premultiplied. Unpremultiply.
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;
1012
+ // Blender: clamp each tap BEFORE Karis average (eevee_bloom: color = min(clampIntensity, color)).
1013
+ return select(masked, min(masked, vec3f(clampV)), clampV > 0.0);
1014
+ }
1015
+
1016
+ @fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
1017
+ let dst = vec2<i32>(p.xy - vec2f(0.5));
1018
+ let base = dst * 2;
1019
+ let clampV = prefilter.z;
1020
+ let a = fetch(base + vec2<i32>(0, 0), clampV);
1021
+ let b = fetch(base + vec2<i32>(1, 0), clampV);
1022
+ let c = fetch(base + vec2<i32>(0, 1), clampV);
1023
+ let d = fetch(base + vec2<i32>(1, 1), clampV);
1024
+ // Karis partial average: weight each tap by 1/(1+luma) — suppresses fireflies.
1025
+ let wa = 1.0 / (1.0 + luminance(a));
1026
+ let wb = 1.0 / (1.0 + luminance(b));
1027
+ let wc = 1.0 / (1.0 + luminance(c));
1028
+ let wd = 1.0 / (1.0 + luminance(d));
1029
+ let avg = (a * wa + b * wb + c * wc + d * wd) / max(wa + wb + wc + wd, 1e-6);
1030
+ // EEVEE quadratic threshold (brightness = max-channel, then soft-knee curve).
1031
+ let bright = max(avg.r, max(avg.g, avg.b));
1032
+ let soft = clamp(bright - prefilter.x + prefilter.y, 0.0, 2.0 * prefilter.y);
1033
+ let q = (soft * soft) / (4.0 * max(prefilter.y, 1e-4) + 1e-6);
1034
+ let contrib = max(q, bright - prefilter.x) / max(bright, 1e-4);
1035
+ return vec4f(max(avg * contrib, vec3f(0.0)), 1.0);
1036
+ }
1037
+ `,
1038
+ });
1039
+ // Downsample: Jimenez/COD 13-tap dual-box — 5 weighted 2×2 averages, rejects nyquist ringing.
1040
+ const bloomDownsampleShader = this.device.createShaderModule({
1041
+ label: "bloom downsample 13-tap",
1042
+ code: `${bloomFullscreenVs}
1043
+ @group(0) @binding(0) var srcTex: texture_2d<f32>;
1044
+ @group(0) @binding(1) var srcSamp: sampler;
1045
+
1046
+ fn samp(uv: vec2f, off: vec2f) -> vec3f {
1047
+ return textureSampleLevel(srcTex, srcSamp, uv + off, 0.0).rgb;
1048
+ }
1049
+
1050
+ @fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
1051
+ let srcDims = vec2f(textureDimensions(srcTex));
1052
+ let t = 1.0 / srcDims;
1053
+ // fragCoord.xy reports pixel centers (e.g. 0.5,0.5 for first pixel) — divide by dst dims directly.
1054
+ let dstDims = srcDims * 0.5;
1055
+ let uv = p.xy / max(dstDims, vec2f(1.0));
1056
+ let A = samp(uv, t * vec2f(-2.0, -2.0));
1057
+ let B = samp(uv, t * vec2f( 0.0, -2.0));
1058
+ let C = samp(uv, t * vec2f( 2.0, -2.0));
1059
+ let D = samp(uv, t * vec2f(-1.0, -1.0));
1060
+ let E = samp(uv, t * vec2f( 1.0, -1.0));
1061
+ let F = samp(uv, t * vec2f(-2.0, 0.0));
1062
+ let G = samp(uv, t * vec2f( 0.0, 0.0));
1063
+ let H = samp(uv, t * vec2f( 2.0, 0.0));
1064
+ let I = samp(uv, t * vec2f(-1.0, 1.0));
1065
+ let J = samp(uv, t * vec2f( 1.0, 1.0));
1066
+ let K = samp(uv, t * vec2f(-2.0, 2.0));
1067
+ let L = samp(uv, t * vec2f( 0.0, 2.0));
1068
+ let M = samp(uv, t * vec2f( 2.0, 2.0));
1069
+ var o = (D + E + I + J) * (0.5 / 4.0);
1070
+ o = o + (A + B + G + F) * (0.125 / 4.0);
1071
+ o = o + (B + C + H + G) * (0.125 / 4.0);
1072
+ o = o + (F + G + L + K) * (0.125 / 4.0);
1073
+ o = o + (G + H + M + L) * (0.125 / 4.0);
1074
+ return vec4f(o, 1.0);
1075
+ }
1076
+ `,
1077
+ });
1078
+ // Upsample: 9-tap tent, progressively added to matching downsample mip. Blender radius = sample scale.
1079
+ const bloomUpsampleShader = this.device.createShaderModule({
1080
+ label: "bloom upsample 9-tap tent",
1081
+ code: `${bloomFullscreenVs}
1082
+ @group(0) @binding(0) var srcTex: texture_2d<f32>; // coarser accumulator
1083
+ @group(0) @binding(1) var baseTex: texture_2d<f32>; // matching downsample mip
1084
+ @group(0) @binding(2) var srcSamp: sampler;
1085
+ @group(0) @binding(3) var<uniform> upU: vec4<f32>; // sampleScale, _, _, _
1086
+
1087
+ @fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
1088
+ let srcDims = vec2f(textureDimensions(srcTex));
1089
+ let baseDims = vec2f(textureDimensions(baseTex));
1090
+ let uv = p.xy / max(baseDims, vec2f(1.0));
1091
+ let t = upU.x / srcDims;
1092
+ var o = textureSampleLevel(srcTex, srcSamp, uv + t * vec2f(-1.0, -1.0), 0.0).rgb * 1.0;
1093
+ o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 0.0, -1.0), 0.0).rgb * 2.0;
1094
+ o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 1.0, -1.0), 0.0).rgb * 1.0;
1095
+ o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f(-1.0, 0.0), 0.0).rgb * 2.0;
1096
+ o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 0.0, 0.0), 0.0).rgb * 4.0;
1097
+ o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 1.0, 0.0), 0.0).rgb * 2.0;
1098
+ o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f(-1.0, 1.0), 0.0).rgb * 1.0;
1099
+ o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 0.0, 1.0), 0.0).rgb * 2.0;
1100
+ o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 1.0, 1.0), 0.0).rgb * 1.0;
1101
+ o = o * (1.0 / 16.0);
1102
+ let base = textureSampleLevel(baseTex, srcSamp, uv, 0.0).rgb;
1103
+ return vec4f(o + base, 1.0);
1104
+ }
1105
+ `,
1106
+ });
1107
+ const bloomBlitLayout = this.device.createPipelineLayout({ bindGroupLayouts: [this.bloomBlitBindGroupLayout] });
1108
+ const bloomDownLayout = this.device.createPipelineLayout({ bindGroupLayouts: [this.bloomDownsampleBindGroupLayout] });
1109
+ const bloomUpLayout = this.device.createPipelineLayout({ bindGroupLayouts: [this.bloomUpsampleBindGroupLayout] });
1110
+ this.bloomBlitPipeline = this.device.createRenderPipeline({
1111
+ label: "bloom blit pipeline",
1112
+ layout: bloomBlitLayout,
1113
+ vertex: { module: bloomBlitShader, entryPoint: "vs" },
1114
+ fragment: { module: bloomBlitShader, entryPoint: "fs", targets: [{ format: Engine.HDR_FORMAT }] },
1115
+ primitive: { topology: "triangle-list" },
1116
+ });
1117
+ this.bloomDownsamplePipeline = this.device.createRenderPipeline({
1118
+ label: "bloom downsample pipeline",
1119
+ layout: bloomDownLayout,
1120
+ vertex: { module: bloomDownsampleShader, entryPoint: "vs" },
1121
+ fragment: { module: bloomDownsampleShader, entryPoint: "fs", targets: [{ format: Engine.HDR_FORMAT }] },
1122
+ primitive: { topology: "triangle-list" },
1123
+ });
1124
+ this.bloomUpsamplePipeline = this.device.createRenderPipeline({
1125
+ label: "bloom upsample pipeline",
1126
+ layout: bloomUpLayout,
1127
+ vertex: { module: bloomUpsampleShader, entryPoint: "vs" },
1128
+ fragment: { module: bloomUpsampleShader, entryPoint: "fs", targets: [{ format: Engine.HDR_FORMAT }] },
1129
+ primitive: { topology: "triangle-list" },
1130
+ });
1131
+ // ─── Composite: HDR + bloom → Filmic → swapchain (premultiplied) ───
1132
+ // Bloom color/intensity applied HERE (pyramid is pure energy; tint belongs to the combine step,
1133
+ // mirroring EEVEE where bloom color/intensity are combine-stage params, not prefilter).
1134
+ this.compositeUniformBuffer = this.device.createBuffer({
1135
+ label: "composite view uniforms",
1136
+ size: 32,
1137
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1138
+ });
1139
+ this.compositeBindGroupLayout = this.device.createBindGroupLayout({
1140
+ label: "composite bind group layout",
1141
+ entries: [
1142
+ { binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "unfilterable-float" } },
1143
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: {} },
1144
+ { binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
1145
+ { binding: 3, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
1146
+ ],
1147
+ });
1148
+ const compositeShader = this.device.createShaderModule({
1149
+ label: "composite shader",
1150
+ code: /* wgsl */ `
1151
+ @group(0) @binding(0) var hdrTex: texture_2d<f32>;
1152
+ @group(0) @binding(1) var bloomTex: texture_2d<f32>; // bloomUpTexture mip 0 (full pyramid top)
1153
+ @group(0) @binding(2) var bloomSamp: sampler;
1154
+ @group(0) @binding(3) var<uniform> viewU: array<vec4<f32>, 2>;
1155
+ // viewU[0] = (exposure, gamma, _, _); viewU[1] = (tint.rgb, intensity)
1156
+
1157
+ fn filmic(x: f32) -> f32 {
1158
+ var lut = array<f32, 14>(
1159
+ 0.0067, 0.0141, 0.0272, 0.0499, 0.0885, 0.1512, 0.2462,
1160
+ 0.3753, 0.5273, 0.6776, 0.8031, 0.8929, 0.9495, 0.9814
1161
+ );
1162
+ let t = clamp(log2(max(x, 1e-10)) + 10.0, 0.0, 13.0);
1163
+ let i = u32(t);
1164
+ let j = min(i + 1u, 13u);
1165
+ return mix(lut[i], lut[j], t - f32(i));
1166
+ }
1167
+
1168
+ @vertex fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
1169
+ let x = f32((vi & 1u) << 2u) - 1.0;
1170
+ let y = f32((vi & 2u) << 1u) - 1.0;
1171
+ return vec4f(x, y, 0.0, 1.0);
1172
+ }
1173
+
1174
+ @fragment fn fs(@builtin(position) fragCoord: vec4f) -> @location(0) vec4f {
1175
+ let hdr = textureLoad(hdrTex, vec2<i32>(fragCoord.xy), 0);
1176
+ let a = max(hdr.a, 1e-6);
1177
+ let straight = hdr.rgb / a;
1178
+ let fullSz = vec2f(textureDimensions(hdrTex));
1179
+ let bloomSz = vec2f(textureDimensions(bloomTex));
1180
+ // Bloom is at half-res (pyramid mip 0). Sampler interpolates back to full-res UVs.
1181
+ let bloomUv = (fragCoord.xy + vec2f(0.5)) / max(fullSz, vec2f(1.0));
1182
+ let tint = viewU[1].xyz;
1183
+ let intensity = viewU[1].w;
1184
+ let bloom = textureSampleLevel(bloomTex, bloomSamp, bloomUv, 0.0).rgb * tint * intensity;
1185
+ let combined = straight + bloom;
1186
+ let exposed = combined * exp2(viewU[0].x);
1187
+ let tm = vec3f(filmic(exposed.r), filmic(exposed.g), filmic(exposed.b));
1188
+ let g = max(viewU[0].y, 1e-4);
1189
+ let disp = pow(max(tm, vec3f(0.0)), vec3f(1.0 / g));
1190
+ return vec4f(disp * hdr.a, hdr.a);
1191
+ }
1192
+ `,
1193
+ });
1194
+ this.compositePipeline = this.device.createRenderPipeline({
1195
+ label: "composite pipeline",
1196
+ layout: this.device.createPipelineLayout({ bindGroupLayouts: [this.compositeBindGroupLayout] }),
1197
+ vertex: { module: compositeShader, entryPoint: "vs" },
1198
+ fragment: {
1199
+ module: compositeShader,
1200
+ entryPoint: "fs",
1201
+ targets: [{ format: this.presentationFormat }],
1202
+ },
1203
+ primitive: { topology: "triangle-list" },
1204
+ });
1205
+ this.bloomPassDescriptor = {
1206
+ label: "bloom pass",
1207
+ colorAttachments: [
1208
+ {
1209
+ view: undefined,
1210
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
1211
+ loadOp: "clear",
1212
+ storeOp: "store",
1213
+ },
1214
+ ],
1215
+ };
652
1216
  // GPU picking: encode (modelIndex, materialIndex) as color
653
1217
  const pickShaderModule = this.device.createShaderModule({
654
1218
  label: "pick shader",
@@ -693,32 +1257,28 @@ export class Engine {
693
1257
  });
694
1258
  this.pickPerFrameBindGroupLayout = this.device.createBindGroupLayout({
695
1259
  label: "pick per-frame layout",
696
- entries: [
697
- { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
698
- ],
1260
+ entries: [{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } }],
699
1261
  });
700
1262
  this.pickPerInstanceBindGroupLayout = this.device.createBindGroupLayout({
701
1263
  label: "pick per-instance layout",
702
- entries: [
703
- { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
704
- ],
1264
+ entries: [{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } }],
705
1265
  });
706
1266
  this.pickPerMaterialBindGroupLayout = this.device.createBindGroupLayout({
707
1267
  label: "pick per-material layout",
708
- entries: [
709
- { binding: 0, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
710
- ],
1268
+ entries: [{ binding: 0, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }],
711
1269
  });
712
1270
  const pickPipelineLayout = this.device.createPipelineLayout({
713
1271
  label: "pick pipeline layout",
714
- bindGroupLayouts: [this.pickPerFrameBindGroupLayout, this.pickPerInstanceBindGroupLayout, this.pickPerMaterialBindGroupLayout],
1272
+ bindGroupLayouts: [
1273
+ this.pickPerFrameBindGroupLayout,
1274
+ this.pickPerInstanceBindGroupLayout,
1275
+ this.pickPerMaterialBindGroupLayout,
1276
+ ],
715
1277
  });
716
1278
  this.pickPerFrameBindGroup = this.device.createBindGroup({
717
1279
  label: "pick per-frame bind group",
718
1280
  layout: this.pickPerFrameBindGroupLayout,
719
- entries: [
720
- { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
721
- ],
1281
+ entries: [{ binding: 0, resource: { buffer: this.cameraUniformBuffer } }],
722
1282
  });
723
1283
  this.pickPipeline = this.device.createRenderPipeline({
724
1284
  label: "pick pipeline",
@@ -762,12 +1322,63 @@ export class Engine {
762
1322
  this.canvas.width = width;
763
1323
  this.canvas.height = height;
764
1324
  this.multisampleTexture = this.device.createTexture({
765
- label: "multisample render target",
1325
+ label: "multisample HDR render target",
1326
+ size: [width, height],
1327
+ sampleCount: Engine.MULTISAMPLE_COUNT,
1328
+ format: Engine.HDR_FORMAT,
1329
+ usage: GPUTextureUsage.RENDER_ATTACHMENT,
1330
+ });
1331
+ this.hdrResolveTexture = this.device.createTexture({
1332
+ label: "HDR resolve target",
1333
+ size: [width, height],
1334
+ format: Engine.HDR_FORMAT,
1335
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
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",
766
1341
  size: [width, height],
767
1342
  sampleCount: Engine.MULTISAMPLE_COUNT,
768
- format: this.presentationFormat,
1343
+ format: Engine.BLOOM_MASK_FORMAT,
769
1344
  usage: GPUTextureUsage.RENDER_ATTACHMENT,
770
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();
1353
+ // Bloom pyramid: mip 0 is half-res, each subsequent mip halves again.
1354
+ // Mip count chosen so the coarsest mip is ≥4 px on the short side, capped at BLOOM_MAX_LEVELS.
1355
+ const bw = Math.max(1, Math.floor(width / 2));
1356
+ const bh = Math.max(1, Math.floor(height / 2));
1357
+ const shortSide = Math.max(1, Math.min(bw, bh));
1358
+ this.bloomMipCount = Math.max(1, Math.min(Engine.BLOOM_MAX_LEVELS, Math.floor(Math.log2(shortSide)) - 1));
1359
+ this.bloomDownTexture = this.device.createTexture({
1360
+ label: "bloom down pyramid",
1361
+ size: [bw, bh],
1362
+ mipLevelCount: this.bloomMipCount,
1363
+ format: Engine.HDR_FORMAT,
1364
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1365
+ });
1366
+ this.bloomUpTexture = this.device.createTexture({
1367
+ label: "bloom up pyramid",
1368
+ size: [bw, bh],
1369
+ mipLevelCount: Math.max(1, this.bloomMipCount - 1),
1370
+ format: Engine.HDR_FORMAT,
1371
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1372
+ });
1373
+ this.bloomDownMipViews = [];
1374
+ for (let i = 0; i < this.bloomMipCount; i++) {
1375
+ this.bloomDownMipViews.push(this.bloomDownTexture.createView({ baseMipLevel: i, mipLevelCount: 1 }));
1376
+ }
1377
+ this.bloomUpMipViews = [];
1378
+ const upLevels = Math.max(1, this.bloomMipCount - 1);
1379
+ for (let i = 0; i < upLevels; i++) {
1380
+ this.bloomUpMipViews.push(this.bloomUpTexture.createView({ baseMipLevel: i, mipLevelCount: 1 }));
1381
+ }
771
1382
  this.depthTexture = this.device.createTexture({
772
1383
  label: "depth texture",
773
1384
  size: [width, height],
@@ -778,14 +1389,21 @@ export class Engine {
778
1389
  const depthTextureView = this.depthTexture.createView();
779
1390
  const colorAttachment = {
780
1391
  view: this.multisampleTexture.createView(),
781
- resolveTarget: this.context.getCurrentTexture().createView(),
1392
+ resolveTarget: this.hdrResolveTexture.createView(),
1393
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
1394
+ loadOp: "clear",
1395
+ storeOp: "store",
1396
+ };
1397
+ const maskAttachment = {
1398
+ view: this.multisampleMaskTexture.createView(),
1399
+ resolveTarget: this.maskResolveView,
782
1400
  clearValue: { r: 0, g: 0, b: 0, a: 0 },
783
1401
  loadOp: "clear",
784
1402
  storeOp: "store",
785
1403
  };
786
1404
  this.renderPassDescriptor = {
787
1405
  label: "renderPass",
788
- colorAttachments: [colorAttachment],
1406
+ colorAttachments: [colorAttachment, maskAttachment],
789
1407
  depthStencilAttachment: {
790
1408
  view: depthTextureView,
791
1409
  depthClearValue: 1.0,
@@ -796,6 +1414,73 @@ export class Engine {
796
1414
  stencilStoreOp: "discard",
797
1415
  },
798
1416
  };
1417
+ // Composite pass descriptor (color attachment view patched per-frame to current swapchain).
1418
+ this.compositePassDescriptor = {
1419
+ label: "composite pass",
1420
+ colorAttachments: [
1421
+ {
1422
+ view: undefined,
1423
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
1424
+ loadOp: "clear",
1425
+ storeOp: "store",
1426
+ },
1427
+ ],
1428
+ };
1429
+ this.writeBloomUniforms();
1430
+ if (this.compositeBindGroupLayout && this.bloomBlitBindGroupLayout) {
1431
+ // Blit: reads HDR resolve texture (full-res), writes bloomDown mip 0.
1432
+ this.bloomBlitBindGroup = this.device.createBindGroup({
1433
+ label: "bloom blit bind group",
1434
+ layout: this.bloomBlitBindGroupLayout,
1435
+ entries: [
1436
+ { binding: 0, resource: this.hdrResolveTexture.createView() },
1437
+ { binding: 1, resource: { buffer: this.bloomBlitUniformBuffer } },
1438
+ { binding: 2, resource: this.maskResolveView },
1439
+ ],
1440
+ });
1441
+ // Downsample[i] reads bloomDown mip (i-1), writes bloomDown mip i. i ∈ [1..N-1].
1442
+ this.bloomDownsampleBindGroups = [];
1443
+ for (let i = 1; i < this.bloomMipCount; i++) {
1444
+ this.bloomDownsampleBindGroups.push(this.device.createBindGroup({
1445
+ label: `bloom downsample ${i}`,
1446
+ layout: this.bloomDownsampleBindGroupLayout,
1447
+ entries: [
1448
+ { binding: 0, resource: this.bloomDownMipViews[i - 1] },
1449
+ { binding: 1, resource: this.bloomSampler },
1450
+ ],
1451
+ }));
1452
+ }
1453
+ // Upsample[i] writes bloomUp mip i. Coarsest step reads bloomDown[N-1] (no prior up yet);
1454
+ // subsequent steps read bloomUp[i+1]. Both read bloomDown[i] as the base (additive combine).
1455
+ this.bloomUpsampleBindGroups = [];
1456
+ const topIdx = this.bloomMipCount - 2;
1457
+ for (let i = topIdx; i >= 0; i--) {
1458
+ const srcView = i === topIdx ? this.bloomDownMipViews[this.bloomMipCount - 1] : this.bloomUpMipViews[i + 1];
1459
+ this.bloomUpsampleBindGroups.push(this.device.createBindGroup({
1460
+ label: `bloom upsample ${i}`,
1461
+ layout: this.bloomUpsampleBindGroupLayout,
1462
+ entries: [
1463
+ { binding: 0, resource: srcView },
1464
+ { binding: 1, resource: this.bloomDownMipViews[i] },
1465
+ { binding: 2, resource: this.bloomSampler },
1466
+ { binding: 3, resource: { buffer: this.bloomUpsampleUniformBuffer } },
1467
+ ],
1468
+ }));
1469
+ }
1470
+ // Composite reads bloomUp mip 0 (full pyramid collapsed); fallback to bloomDown mip 0 if no upsample level.
1471
+ const compositeBloomView = this.bloomMipCount > 1 ? this.bloomUpMipViews[0] : this.bloomDownMipViews[0];
1472
+ this.compositeBindGroup = this.device.createBindGroup({
1473
+ label: "composite bind group",
1474
+ layout: this.compositeBindGroupLayout,
1475
+ entries: [
1476
+ { binding: 0, resource: this.hdrResolveTexture.createView() },
1477
+ { binding: 1, resource: compositeBloomView },
1478
+ { binding: 2, resource: this.bloomSampler },
1479
+ { binding: 3, resource: { buffer: this.compositeUniformBuffer } },
1480
+ ],
1481
+ });
1482
+ }
1483
+ this.writeCompositeViewUniforms();
799
1484
  this.camera.aspect = width / height;
800
1485
  if (this.onRaycast) {
801
1486
  this.pickTexture = this.device.createTexture({
@@ -820,7 +1505,7 @@ export class Engine {
820
1505
  size: 40 * 4,
821
1506
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
822
1507
  });
823
- this.camera = new Camera(Math.PI, Math.PI / 2.5, this.cameraDistance, this.cameraTarget, this.cameraFov);
1508
+ this.camera = new Camera(Math.PI, Math.PI / 2.5, this.cameraConfig.distance, this.cameraConfig.target, this.cameraConfig.fov);
824
1509
  this.camera.aspect = this.canvas.width / this.canvas.height;
825
1510
  this.camera.attachControl(this.canvas);
826
1511
  }
@@ -854,58 +1539,100 @@ export class Engine {
854
1539
  this.cameraTargetOffset.y = offset?.y ?? 0;
855
1540
  this.cameraTargetOffset.z = offset?.z ?? 0;
856
1541
  }
857
- getCameraDistance() { return this.camera.radius; }
858
- setCameraDistance(d) { this.camera.radius = d; }
859
- getCameraAlpha() { return this.camera.alpha; }
860
- setCameraAlpha(a) { this.camera.alpha = a; }
861
- getCameraBeta() { return this.camera.beta; }
862
- setCameraBeta(b) { this.camera.beta = b; }
1542
+ getCameraDistance() {
1543
+ return this.camera.radius;
1544
+ }
1545
+ setCameraDistance(d) {
1546
+ this.camera.radius = d;
1547
+ }
1548
+ getCameraAlpha() {
1549
+ return this.camera.alpha;
1550
+ }
1551
+ setCameraAlpha(a) {
1552
+ this.camera.alpha = a;
1553
+ }
1554
+ getCameraBeta() {
1555
+ return this.camera.beta;
1556
+ }
1557
+ setCameraBeta(b) {
1558
+ this.camera.beta = b;
1559
+ }
863
1560
  // Step 5: Create lighting buffers
864
1561
  setupLighting() {
865
1562
  this.lightUniformBuffer = this.device.createBuffer({
866
1563
  label: "light uniforms",
867
- size: 64 * 4, // 64 floats: ambientColor vec4f (4) + 4 lights * 2 vec4f each (32)
1564
+ size: 64 * 4, // ambientColor vec4f (4) + 4 lights * 2 vec4f each (32) = 36 f32 padded to 64
868
1565
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
869
1566
  });
870
- // Initialize light buffer to zeros
871
1567
  this.lightData.fill(0);
872
1568
  this.lightCount = 0;
873
- this.setAmbientColor(this.ambientColor);
874
- this.addLight(new Vec3(0.5, -1, 1).normalize(), new Vec3(1.0, 1.0, 1.0), this.directionalLightIntensity);
875
- }
876
- setAmbientColor(color) {
877
- // Layout: ambientColor (0-3), lights (4-63) - 2 vec4f per light
878
- this.lightData[0] = color.x; // ambientColor.x
879
- this.lightData[1] = color.y; // ambientColor.y
880
- this.lightData[2] = color.z; // ambientColor.z
881
- this.lightData[3] = this.minSpecularIntensity; // ambientColor.w = minSpecularIntensity
1569
+ this.writeWorld();
1570
+ this.writeSun(0);
1571
+ }
1572
+ /**
1573
+ * Write world ambient. For a uniform-radiance world, hemispherical irradiance
1574
+ * is E = π·L and a Lambertian BRDF reflects (albedo/π)·E = albedo·L, so the
1575
+ * shader's ambient uniform is just `world.color × world.strength` no /π.
1576
+ */
1577
+ writeWorld() {
1578
+ const s = this.world.strength;
1579
+ this.lightData[0] = this.world.color.x * s;
1580
+ this.lightData[1] = this.world.color.y * s;
1581
+ this.lightData[2] = this.world.color.z * s;
1582
+ this.lightData[3] = 0;
882
1583
  this.updateLightBuffer();
883
1584
  }
884
- addLight(direction, color, intensity = 1.0) {
885
- if (this.lightCount >= 4)
886
- return false;
887
- const normalized = direction.normalize();
888
- const baseIndex = 4 + this.lightCount * 8; // Start at index 4, 8 floats per light (2 vec4f)
889
- this.lightData[baseIndex] = normalized.x; // direction.x
890
- this.lightData[baseIndex + 1] = normalized.y; // direction.y
891
- this.lightData[baseIndex + 2] = normalized.z; // direction.z
892
- this.lightData[baseIndex + 3] = 0; // direction.w
893
- this.lightData[baseIndex + 4] = color.x; // color.x
894
- this.lightData[baseIndex + 5] = color.y; // color.y
895
- this.lightData[baseIndex + 6] = color.z; // color.z
896
- this.lightData[baseIndex + 7] = intensity; // color.w / intensity
897
- this.lightCount++;
1585
+ /** Write sun lamp into light slot `index` (0..3). Layout mirrors the WGSL struct. */
1586
+ writeSun(index) {
1587
+ if (index < 0 || index >= 4)
1588
+ return;
1589
+ const normalized = this.sun.direction.normalize();
1590
+ const base = 4 + index * 8; // 8 floats per light (direction vec4, color vec4)
1591
+ this.lightData[base] = normalized.x;
1592
+ this.lightData[base + 1] = normalized.y;
1593
+ this.lightData[base + 2] = normalized.z;
1594
+ this.lightData[base + 3] = 0;
1595
+ this.lightData[base + 4] = this.sun.color.x;
1596
+ this.lightData[base + 5] = this.sun.color.y;
1597
+ this.lightData[base + 6] = this.sun.color.z;
1598
+ this.lightData[base + 7] = this.sun.strength;
1599
+ if (index >= this.lightCount)
1600
+ this.lightCount = index + 1;
898
1601
  this.updateLightBuffer();
899
- return true;
1602
+ }
1603
+ /** Update the world environment (Blender: World Background). Ambient recomputes immediately. */
1604
+ setWorld(options) {
1605
+ if (options.color)
1606
+ this.world.color = options.color;
1607
+ if (options.strength !== undefined)
1608
+ this.world.strength = options.strength;
1609
+ this.writeWorld();
1610
+ }
1611
+ /** Update the sun lamp (Blender: Light > Sun). Direction change marks shadow VP dirty. */
1612
+ setSun(options) {
1613
+ if (options.color)
1614
+ this.sun.color = options.color;
1615
+ if (options.strength !== undefined)
1616
+ this.sun.strength = options.strength;
1617
+ if (options.direction) {
1618
+ this.sun.direction = options.direction;
1619
+ this.shadowLightVPDirty = true;
1620
+ }
1621
+ this.writeSun(0);
1622
+ }
1623
+ getWorld() {
1624
+ return this.world;
1625
+ }
1626
+ getSun() {
1627
+ return this.sun;
900
1628
  }
901
1629
  addGround(options) {
902
1630
  const opts = {
903
1631
  width: 160,
904
1632
  height: 160,
905
- diffuseColor: new Vec3(0.8, 0.1, 1.0),
1633
+ diffuseColor: new Vec3(0.9, 0.1, 1.0),
906
1634
  fadeStart: 10.0,
907
1635
  fadeEnd: 80.0,
908
- shadowMapSize: 4096,
909
1636
  shadowStrength: 1.0,
910
1637
  gridSpacing: 4.2,
911
1638
  gridLineWidth: 0.012,
@@ -923,6 +1650,7 @@ export class Engine {
923
1650
  firstIndex: 0,
924
1651
  bindGroup: this.groundShadowBindGroup,
925
1652
  materialName: "Ground",
1653
+ preset: "cloth_rough",
926
1654
  };
927
1655
  }
928
1656
  updateLightBuffer() {
@@ -1039,6 +1767,15 @@ export class Engine {
1039
1767
  }
1040
1768
  }
1041
1769
  }
1770
+ setMaterialPresets(modelName, presets) {
1771
+ const inst = this.modelInstances.get(modelName);
1772
+ if (!inst)
1773
+ return;
1774
+ inst.materialPresets = presets;
1775
+ for (const dc of inst.drawCalls) {
1776
+ dc.preset = resolvePreset(dc.materialName, presets);
1777
+ }
1778
+ }
1042
1779
  setMaterialVisible(modelName, materialName, visible) {
1043
1780
  const inst = this.modelInstances.get(modelName);
1044
1781
  if (!inst)
@@ -1147,24 +1884,14 @@ export class Engine {
1147
1884
  const mainPerInstanceBindGroup = this.device.createBindGroup({
1148
1885
  label: `${name}: main per-instance bind group`,
1149
1886
  layout: this.mainPerInstanceBindGroupLayout,
1150
- entries: [
1151
- { binding: 0, resource: { buffer: skinMatrixBuffer } },
1152
- ],
1887
+ entries: [{ binding: 0, resource: { buffer: skinMatrixBuffer } }],
1153
1888
  });
1154
1889
  const pickPerInstanceBindGroup = this.device.createBindGroup({
1155
1890
  label: `${name}: pick per-instance bind group`,
1156
1891
  layout: this.pickPerInstanceBindGroupLayout,
1157
- entries: [
1158
- { binding: 0, resource: { buffer: skinMatrixBuffer } },
1159
- ],
1892
+ entries: [{ binding: 0, resource: { buffer: skinMatrixBuffer } }],
1160
1893
  });
1161
- const gpuBuffers = [
1162
- vertexBuffer,
1163
- indexBuffer,
1164
- jointsBuffer,
1165
- weightsBuffer,
1166
- skinMatrixBuffer,
1167
- ];
1894
+ const gpuBuffers = [vertexBuffer, indexBuffer, jointsBuffer, weightsBuffer, skinMatrixBuffer];
1168
1895
  const inst = {
1169
1896
  name,
1170
1897
  model,
@@ -1184,6 +1911,7 @@ export class Engine {
1184
1911
  pickPerInstanceBindGroup,
1185
1912
  pickDrawCalls: [],
1186
1913
  hiddenMaterials: new Set(),
1914
+ materialPresets: undefined,
1187
1915
  physics,
1188
1916
  vertexBufferNeedsUpdate: false,
1189
1917
  };
@@ -1255,15 +1983,8 @@ export class Engine {
1255
1983
  this.device.queue.writeBuffer(this.groundIndexBuffer, 0, indices);
1256
1984
  }
1257
1985
  createShadowGroundResources(opts) {
1258
- const { shadowMapSize, diffuseColor, fadeStart, fadeEnd, shadowStrength, gridSpacing, gridLineWidth, gridLineOpacity, gridLineColor, noiseStrength } = opts;
1259
- this.shadowMapTexture = this.device.createTexture({
1260
- label: "shadow map",
1261
- size: [shadowMapSize, shadowMapSize],
1262
- format: "depth32float",
1263
- usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1264
- });
1265
- this.shadowMapDepthView = this.shadowMapTexture.createView();
1266
- // Layout: diffuseColor(3f) fadeStart(1f) | fadeEnd(1f) shadowStrength(1f) pcfTexel(1f) gridSpacing(1f) | gridLineWidth(1f) gridOpacity(1f) noiseStrength(1f) _pad(1f) | gridColor(3f) _pad2(1f)
1986
+ const { diffuseColor, fadeStart, fadeEnd, shadowStrength, gridSpacing, gridLineWidth, gridLineOpacity, gridLineColor, noiseStrength, } = opts;
1987
+ // Shadow map is already created in setupPipelines()
1267
1988
  const gb = new Float32Array(16);
1268
1989
  gb[0] = diffuseColor.x;
1269
1990
  gb[1] = diffuseColor.y;
@@ -1271,7 +1992,7 @@ export class Engine {
1271
1992
  gb[3] = fadeStart;
1272
1993
  gb[4] = fadeEnd;
1273
1994
  gb[5] = shadowStrength;
1274
- gb[6] = 1 / shadowMapSize;
1995
+ gb[6] = 1 / Engine.SHADOW_MAP_SIZE;
1275
1996
  gb[7] = gridSpacing;
1276
1997
  gb[8] = gridLineWidth;
1277
1998
  gb[9] = gridLineOpacity;
@@ -1281,7 +2002,10 @@ export class Engine {
1281
2002
  gb[13] = gridLineColor.y;
1282
2003
  gb[14] = gridLineColor.z;
1283
2004
  gb[15] = 0;
1284
- this.groundShadowMaterialBuffer = this.device.createBuffer({ size: gb.byteLength, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
2005
+ this.groundShadowMaterialBuffer = this.device.createBuffer({
2006
+ size: gb.byteLength,
2007
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
2008
+ });
1285
2009
  this.device.queue.writeBuffer(this.groundShadowMaterialBuffer, 0, gb);
1286
2010
  this.groundShadowBindGroup = this.device.createBindGroup({
1287
2011
  label: "ground shadow bind",
@@ -1300,7 +2024,7 @@ export class Engine {
1300
2024
  if (!this.shadowLightVPDirty)
1301
2025
  return;
1302
2026
  this.shadowLightVPDirty = false;
1303
- const dir = new Vec3(this.shadowLightDirection.x, this.shadowLightDirection.y, this.shadowLightDirection.z);
2027
+ const dir = new Vec3(this.sun.direction.x, this.sun.direction.y, this.sun.direction.z);
1304
2028
  dir.normalize();
1305
2029
  const target = new Vec3(0, 11, 0);
1306
2030
  const eye = new Vec3(target.x - dir.x * 72, target.y - dir.y * 72, target.z - dir.z * 72);
@@ -1338,7 +2062,11 @@ export class Engine {
1338
2062
  throw new Error(`Material "${mat.name}" has no diffuse texture`);
1339
2063
  const materialAlpha = mat.diffuse[3];
1340
2064
  const isTransparent = materialAlpha < 1.0 - 0.001;
1341
- const materialUniformBuffer = this.createMaterialUniformBuffer(prefix + mat.name, materialAlpha, [mat.diffuse[0], mat.diffuse[1], mat.diffuse[2]], mat.ambient, mat.specular, mat.shininess);
2065
+ const materialUniformBuffer = this.createMaterialUniformBuffer(prefix + mat.name, materialAlpha, [
2066
+ mat.diffuse[0],
2067
+ mat.diffuse[1],
2068
+ mat.diffuse[2],
2069
+ ]);
1342
2070
  inst.gpuBuffers.push(materialUniformBuffer);
1343
2071
  const textureView = diffuseTexture.createView();
1344
2072
  const bindGroup = this.device.createBindGroup({
@@ -1350,23 +2078,42 @@ export class Engine {
1350
2078
  ],
1351
2079
  });
1352
2080
  const type = isTransparent ? "transparent" : "opaque";
1353
- inst.drawCalls.push({ type, count: indexCount, firstIndex: currentIndexOffset, bindGroup, materialName: mat.name });
2081
+ const preset = resolvePreset(mat.name, inst.materialPresets);
2082
+ inst.drawCalls.push({
2083
+ type,
2084
+ count: indexCount,
2085
+ firstIndex: currentIndexOffset,
2086
+ bindGroup,
2087
+ materialName: mat.name,
2088
+ preset,
2089
+ });
1354
2090
  if ((mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
1355
2091
  const materialUniformData = new Float32Array([
1356
- mat.edgeColor[0], mat.edgeColor[1], mat.edgeColor[2], mat.edgeColor[3],
1357
- mat.edgeSize, 0, 0, 0,
2092
+ mat.edgeColor[0],
2093
+ mat.edgeColor[1],
2094
+ mat.edgeColor[2],
2095
+ mat.edgeColor[3],
2096
+ mat.edgeSize,
2097
+ 0,
2098
+ 0,
2099
+ 0,
1358
2100
  ]);
1359
2101
  const outlineUniformBuffer = this.createUniformBuffer(`${prefix}outline: ${mat.name}`, materialUniformData);
1360
2102
  inst.gpuBuffers.push(outlineUniformBuffer);
1361
2103
  const outlineBindGroup = this.device.createBindGroup({
1362
2104
  label: `${prefix}outline: ${mat.name}`,
1363
2105
  layout: this.outlinePerMaterialBindGroupLayout,
1364
- entries: [
1365
- { binding: 0, resource: { buffer: outlineUniformBuffer } },
1366
- ],
2106
+ entries: [{ binding: 0, resource: { buffer: outlineUniformBuffer } }],
1367
2107
  });
1368
2108
  const outlineType = isTransparent ? "transparent-outline" : "opaque-outline";
1369
- inst.drawCalls.push({ type: outlineType, count: indexCount, firstIndex: currentIndexOffset, bindGroup: outlineBindGroup, materialName: mat.name });
2109
+ inst.drawCalls.push({
2110
+ type: outlineType,
2111
+ count: indexCount,
2112
+ firstIndex: currentIndexOffset,
2113
+ bindGroup: outlineBindGroup,
2114
+ materialName: mat.name,
2115
+ preset,
2116
+ });
1370
2117
  }
1371
2118
  if (this.onRaycast) {
1372
2119
  const pickIdData = new Float32Array([modelId, materialId, 0, 0]);
@@ -1386,18 +2133,13 @@ export class Engine {
1386
2133
  inst.shadowDrawCalls.push(d);
1387
2134
  }
1388
2135
  }
1389
- createMaterialUniformBuffer(label, alpha, diffuseColor, ambientColor, specularColor, shininess) {
1390
- const data = new Float32Array(20);
1391
- data.set([
1392
- alpha,
1393
- this.rimLightIntensity,
1394
- shininess,
1395
- 0.0,
1396
- 1.0, 1.0, 1.0, 0.0, // rimColor (vec3), _padding2
1397
- diffuseColor[0], diffuseColor[1], diffuseColor[2], 0.0,
1398
- ambientColor[0], ambientColor[1], ambientColor[2], 0.0,
1399
- specularColor[0], specularColor[1], specularColor[2], 0.0,
1400
- ]);
2136
+ createMaterialUniformBuffer(label, alpha, diffuseColor) {
2137
+ // Matches WGSL `struct MaterialUniforms { diffuseColor: vec3f, alpha: f32 }` — 16 bytes.
2138
+ const data = new Float32Array(4);
2139
+ data[0] = diffuseColor[0];
2140
+ data[1] = diffuseColor[1];
2141
+ data[2] = diffuseColor[2];
2142
+ data[3] = alpha;
1401
2143
  return this.createUniformBuffer(`material uniform: ${label}`, data);
1402
2144
  }
1403
2145
  createUniformBuffer(label, data) {
@@ -1424,16 +2166,20 @@ export class Engine {
1424
2166
  premultiplyAlpha: "none",
1425
2167
  colorSpaceConversion: "none",
1426
2168
  });
2169
+ const mipLevelCount = Math.floor(Math.log2(Math.max(imageBitmap.width, imageBitmap.height))) + 1;
1427
2170
  const texture = this.device.createTexture({
1428
2171
  label: `texture: ${cacheKey}`,
1429
2172
  size: [imageBitmap.width, imageBitmap.height],
1430
- format: "rgba8unorm",
2173
+ format: "rgba8unorm-srgb",
2174
+ mipLevelCount,
1431
2175
  usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
1432
2176
  });
1433
2177
  this.device.queue.copyExternalImageToTexture({ source: imageBitmap }, { texture }, [
1434
2178
  imageBitmap.width,
1435
2179
  imageBitmap.height,
1436
2180
  ]);
2181
+ if (mipLevelCount > 1)
2182
+ this.generateMipmaps(texture, mipLevelCount);
1437
2183
  this.textureCache.set(cacheKey, texture);
1438
2184
  inst.textureCacheKeys.push(cacheKey);
1439
2185
  return texture;
@@ -1442,6 +2188,62 @@ export class Engine {
1442
2188
  return null;
1443
2189
  }
1444
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
+ }
1445
2247
  renderGround(pass) {
1446
2248
  if (!this.hasGround || !this.groundVertexBuffer || !this.groundIndexBuffer || !this.groundDrawCall)
1447
2249
  return;
@@ -1463,12 +2265,14 @@ export class Engine {
1463
2265
  if (!this.pendingPick || !this.pickTexture || !this.pickDepthTexture)
1464
2266
  return;
1465
2267
  const pass = encoder.beginRenderPass({
1466
- colorAttachments: [{
2268
+ colorAttachments: [
2269
+ {
1467
2270
  view: this.pickTexture.createView(),
1468
2271
  clearValue: { r: 0, g: 0, b: 0, a: 0 },
1469
2272
  loadOp: "clear",
1470
2273
  storeOp: "store",
1471
- }],
2274
+ },
2275
+ ],
1472
2276
  depthStencilAttachment: {
1473
2277
  view: this.pickDepthTexture.createView(),
1474
2278
  depthClearValue: 1.0,
@@ -1543,7 +2347,6 @@ export class Engine {
1543
2347
  const currentTime = performance.now();
1544
2348
  const deltaTime = this.lastFrameTime > 0 ? (currentTime - this.lastFrameTime) / 1000 : 0.016;
1545
2349
  this.lastFrameTime = currentTime;
1546
- this.updateRenderTarget();
1547
2350
  const hasModels = this.modelInstances.size > 0;
1548
2351
  if (hasModels) {
1549
2352
  this.updateInstances(deltaTime);
@@ -1560,10 +2363,9 @@ export class Engine {
1560
2363
  }
1561
2364
  }
1562
2365
  this.updateCameraUniforms();
1563
- if (this.hasGround)
1564
- this.updateShadowLightVP();
2366
+ this.updateShadowLightVP();
1565
2367
  const encoder = this.device.createCommandEncoder();
1566
- if (hasModels && this.hasGround && this.shadowMapDepthView) {
2368
+ if (hasModels) {
1567
2369
  const sp = encoder.beginRenderPass({
1568
2370
  colorAttachments: [],
1569
2371
  depthStencilAttachment: {
@@ -1583,6 +2385,51 @@ export class Engine {
1583
2385
  if (this.hasGround)
1584
2386
  this.renderGround(pass);
1585
2387
  pass.end();
2388
+ // Bloom pyramid (EEVEE 3.6):
2389
+ // 1. Blit: HDR → bloomDown[0] (Karis prefilter, half-res)
2390
+ // 2. Downsample: bloomDown[0] → bloomDown[1] → … → bloomDown[N-1] (13-tap)
2391
+ // 3. Upsample (top-down): bloomUp[N-2] = tent(bloomDown[N-1]) + bloomDown[N-2],
2392
+ // then bloomUp[i] = tent(bloomUp[i+1]) + bloomDown[i] until i=0 (9-tap tent)
2393
+ // Composite reads bloomUp[0] and adds tint * intensity * bloom before Filmic.
2394
+ if (this.bloomBlitBindGroup && this.compositeBindGroup && this.bloomMipCount > 0) {
2395
+ const bloomAtt = this.bloomPassDescriptor.colorAttachments;
2396
+ // 1. Blit
2397
+ bloomAtt[0].view = this.bloomDownMipViews[0];
2398
+ const pBlit = encoder.beginRenderPass(this.bloomPassDescriptor);
2399
+ pBlit.setPipeline(this.bloomBlitPipeline);
2400
+ pBlit.setBindGroup(0, this.bloomBlitBindGroup);
2401
+ pBlit.draw(3);
2402
+ pBlit.end();
2403
+ // 2. Downsample chain
2404
+ for (let i = 1; i < this.bloomMipCount; i++) {
2405
+ bloomAtt[0].view = this.bloomDownMipViews[i];
2406
+ const p = encoder.beginRenderPass(this.bloomPassDescriptor);
2407
+ p.setPipeline(this.bloomDownsamplePipeline);
2408
+ p.setBindGroup(0, this.bloomDownsampleBindGroups[i - 1]);
2409
+ p.draw(3);
2410
+ p.end();
2411
+ }
2412
+ // 3. Upsample chain (coarsest to finest; bindGroups[0] is the coarsest step)
2413
+ const upSteps = this.bloomUpsampleBindGroups.length;
2414
+ const topIdx = this.bloomMipCount - 2;
2415
+ for (let k = 0; k < upSteps; k++) {
2416
+ const levelIdx = topIdx - k; // writes bloomUp[levelIdx]
2417
+ bloomAtt[0].view = this.bloomUpMipViews[levelIdx];
2418
+ const p = encoder.beginRenderPass(this.bloomPassDescriptor);
2419
+ p.setPipeline(this.bloomUpsamplePipeline);
2420
+ p.setBindGroup(0, this.bloomUpsampleBindGroups[k]);
2421
+ p.draw(3);
2422
+ p.end();
2423
+ }
2424
+ }
2425
+ // Composite: HDR + bloom → Filmic tonemap → swapchain.
2426
+ const compositeAttachment = this.compositePassDescriptor.colorAttachments[0];
2427
+ compositeAttachment.view = this.context.getCurrentTexture().createView();
2428
+ const cpass = encoder.beginRenderPass(this.compositePassDescriptor);
2429
+ cpass.setPipeline(this.compositePipeline);
2430
+ cpass.setBindGroup(0, this.compositeBindGroup);
2431
+ cpass.draw(3);
2432
+ cpass.end();
1586
2433
  const pick = this.pendingPick;
1587
2434
  if (pick && hasModels)
1588
2435
  this.renderPickPass(encoder);
@@ -1594,10 +2441,6 @@ export class Engine {
1594
2441
  }
1595
2442
  this.updateStats(performance.now() - currentTime);
1596
2443
  }
1597
- updateRenderTarget() {
1598
- const colorAttachment = this.renderPassDescriptor.colorAttachments[0];
1599
- colorAttachment.resolveTarget = this.context.getCurrentTexture().createView();
1600
- }
1601
2444
  drawInstanceShadow(sp, inst) {
1602
2445
  sp.setBindGroup(0, inst.shadowBindGroup);
1603
2446
  sp.setVertexBuffer(0, inst.vertexBuffer);
@@ -1609,39 +2452,87 @@ export class Engine {
1609
2452
  sp.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1610
2453
  }
1611
2454
  }
1612
- drawOpaque(pass, inst, pipeline) {
1613
- pass.setPipeline(pipeline);
2455
+ pipelineForPreset(preset) {
2456
+ if (preset === "face")
2457
+ return this.facePipeline;
2458
+ if (preset === "hair")
2459
+ return this.hairPipeline;
2460
+ if (preset === "cloth_smooth")
2461
+ return this.clothSmoothPipeline;
2462
+ if (preset === "cloth_rough")
2463
+ return this.clothRoughPipeline;
2464
+ if (preset === "metal")
2465
+ return this.metalPipeline;
2466
+ if (preset === "body")
2467
+ return this.bodyPipeline;
2468
+ if (preset === "eye")
2469
+ return this.eyePipeline;
2470
+ if (preset === "stockings")
2471
+ return this.stockingsPipeline;
2472
+ return this.modelPipeline;
2473
+ }
2474
+ /**
2475
+ * Draw every material of a given type (`opaque` or `transparent`) using the main
2476
+ * pipeline(s). Binds the per-frame and per-instance groups once at the top of the
2477
+ * batch, then issues one draw per material. Early-outs if nothing to draw so we
2478
+ * don't waste bindings when a model has no transparents, etc.
2479
+ */
2480
+ drawMaterials(pass, inst, type) {
2481
+ let currentPipeline = null;
2482
+ let bound = false;
1614
2483
  for (const draw of inst.drawCalls) {
1615
- if (draw.type === "opaque" && this.shouldRenderDrawCall(inst, draw)) {
1616
- pass.setBindGroup(2, draw.bindGroup);
1617
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
2484
+ if (draw.type !== type || !this.shouldRenderDrawCall(inst, draw))
2485
+ continue;
2486
+ if (!bound) {
2487
+ pass.setBindGroup(0, this.perFrameBindGroup);
2488
+ pass.setBindGroup(1, inst.mainPerInstanceBindGroup);
2489
+ bound = true;
1618
2490
  }
2491
+ const pipeline = this.pipelineForPreset(draw.preset);
2492
+ if (pipeline !== currentPipeline) {
2493
+ pass.setPipeline(pipeline);
2494
+ currentPipeline = pipeline;
2495
+ }
2496
+ pass.setBindGroup(2, draw.bindGroup);
2497
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1619
2498
  }
1620
2499
  }
1621
- drawTransparent(pass, inst, pipeline) {
1622
- pass.setPipeline(pipeline);
2500
+ /**
2501
+ * Draw every outline of a given type (`opaque-outline` or `transparent-outline`).
2502
+ * Uses its own pipeline layout (group 0 = camera-only, group 2 = edge uniforms), so
2503
+ * every batch binds its own groups from scratch — the next drawMaterials call will
2504
+ * rebind group 0/1 correctly if needed.
2505
+ */
2506
+ drawOutlines(pass, inst, type) {
2507
+ let bound = false;
1623
2508
  for (const draw of inst.drawCalls) {
1624
- if (draw.type === "transparent" && this.shouldRenderDrawCall(inst, draw)) {
1625
- pass.setBindGroup(2, draw.bindGroup);
1626
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
2509
+ if (draw.type !== type || !this.shouldRenderDrawCall(inst, draw))
2510
+ continue;
2511
+ if (!bound) {
2512
+ pass.setPipeline(this.outlinePipeline);
2513
+ pass.setBindGroup(0, this.outlinePerFrameBindGroup);
2514
+ pass.setBindGroup(1, inst.mainPerInstanceBindGroup);
2515
+ bound = true;
1627
2516
  }
2517
+ pass.setBindGroup(2, draw.bindGroup);
2518
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1628
2519
  }
1629
2520
  }
1630
- bindMainGroups(pass, inst) {
1631
- pass.setBindGroup(0, this.perFrameBindGroup);
1632
- pass.setBindGroup(1, inst.mainPerInstanceBindGroup);
1633
- }
2521
+ /**
2522
+ * Main-pass render sequence for one model instance:
2523
+ * 1) opaque bodies → 2) opaque outlines → 3) transparents → 4) transparent outlines.
2524
+ * Each batch binds the groups it needs, so switching between main and outline
2525
+ * pipelines is self-contained (no cross-batch dependencies).
2526
+ */
1634
2527
  renderOneModel(pass, inst) {
1635
2528
  pass.setVertexBuffer(0, inst.vertexBuffer);
1636
2529
  pass.setVertexBuffer(1, inst.jointsBuffer);
1637
2530
  pass.setVertexBuffer(2, inst.weightsBuffer);
1638
2531
  pass.setIndexBuffer(inst.indexBuffer, "uint32");
1639
- this.bindMainGroups(pass, inst);
1640
- this.drawOpaque(pass, inst, this.modelPipeline);
1641
- this.drawOutlines(pass, inst, false);
1642
- this.bindMainGroups(pass, inst);
1643
- this.drawTransparent(pass, inst, this.modelPipeline);
1644
- this.drawOutlines(pass, inst, true);
2532
+ this.drawMaterials(pass, inst, "opaque");
2533
+ this.drawOutlines(pass, inst, "opaque-outline");
2534
+ this.drawMaterials(pass, inst, "transparent");
2535
+ this.drawOutlines(pass, inst, "transparent-outline");
1645
2536
  }
1646
2537
  updateCameraUniforms() {
1647
2538
  const viewMatrix = this.camera.getViewMatrix();
@@ -1660,18 +2551,6 @@ export class Engine {
1660
2551
  this.device.queue.writeBuffer(inst.skinMatrixBuffer, 0, skinMatrices.buffer, skinMatrices.byteOffset, skinMatrices.byteLength);
1661
2552
  });
1662
2553
  }
1663
- drawOutlines(pass, inst, transparent) {
1664
- pass.setPipeline(this.outlinePipeline);
1665
- pass.setBindGroup(0, this.outlinePerFrameBindGroup);
1666
- pass.setBindGroup(1, inst.mainPerInstanceBindGroup);
1667
- const outlineType = transparent ? "transparent-outline" : "opaque-outline";
1668
- for (const draw of inst.drawCalls) {
1669
- if (draw.type === outlineType && this.shouldRenderDrawCall(inst, draw)) {
1670
- pass.setBindGroup(2, draw.bindGroup);
1671
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1672
- }
1673
- }
1674
- }
1675
2554
  updateStats(frameTime) {
1676
2555
  // Simplified frame time tracking - rolling average with fixed window
1677
2556
  const maxSamples = 60;
@@ -1697,3 +2576,10 @@ export class Engine {
1697
2576
  }
1698
2577
  Engine.instance = null;
1699
2578
  Engine.MULTISAMPLE_COUNT = 4;
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";
2584
+ Engine.BLOOM_MAX_LEVELS = 7;
2585
+ Engine.SHADOW_MAP_SIZE = 2048;