reze-engine 0.10.1 → 0.11.0

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 (78) hide show
  1. package/README.md +113 -20
  2. package/dist/asset-reader.d.ts +16 -0
  3. package/dist/asset-reader.d.ts.map +1 -0
  4. package/dist/asset-reader.js +74 -0
  5. package/dist/engine.d.ts +179 -36
  6. package/dist/engine.d.ts.map +1 -1
  7. package/dist/engine.js +1133 -321
  8. package/dist/folder-upload.d.ts +24 -0
  9. package/dist/folder-upload.d.ts.map +1 -0
  10. package/dist/folder-upload.js +50 -0
  11. package/dist/index.d.ts +3 -2
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +2 -2
  14. package/dist/model.d.ts +6 -1
  15. package/dist/model.d.ts.map +1 -1
  16. package/dist/model.js +34 -2
  17. package/dist/pmx-loader.d.ts +3 -0
  18. package/dist/pmx-loader.d.ts.map +1 -1
  19. package/dist/pmx-loader.js +9 -2
  20. package/dist/shaders/body.d.ts +2 -0
  21. package/dist/shaders/body.d.ts.map +1 -0
  22. package/dist/shaders/body.js +209 -0
  23. package/dist/shaders/classify.d.ts +4 -0
  24. package/dist/shaders/classify.d.ts.map +1 -0
  25. package/dist/shaders/classify.js +12 -0
  26. package/dist/shaders/cloth_rough.d.ts +2 -0
  27. package/dist/shaders/cloth_rough.d.ts.map +1 -0
  28. package/dist/shaders/cloth_rough.js +172 -0
  29. package/dist/shaders/cloth_smooth.d.ts +2 -0
  30. package/dist/shaders/cloth_smooth.d.ts.map +1 -0
  31. package/dist/shaders/cloth_smooth.js +171 -0
  32. package/dist/shaders/default.d.ts +2 -0
  33. package/dist/shaders/default.d.ts.map +1 -0
  34. package/dist/shaders/default.js +168 -0
  35. package/dist/shaders/dfg_lut.d.ts +4 -0
  36. package/dist/shaders/dfg_lut.d.ts.map +1 -0
  37. package/dist/shaders/dfg_lut.js +125 -0
  38. package/dist/shaders/eye.d.ts +2 -0
  39. package/dist/shaders/eye.d.ts.map +1 -0
  40. package/dist/shaders/eye.js +142 -0
  41. package/dist/shaders/face.d.ts +2 -0
  42. package/dist/shaders/face.d.ts.map +1 -0
  43. package/dist/shaders/face.js +211 -0
  44. package/dist/shaders/hair.d.ts +2 -0
  45. package/dist/shaders/hair.d.ts.map +1 -0
  46. package/dist/shaders/hair.js +186 -0
  47. package/dist/shaders/ltc_mag_lut.d.ts +3 -0
  48. package/dist/shaders/ltc_mag_lut.d.ts.map +1 -0
  49. package/dist/shaders/ltc_mag_lut.js +1033 -0
  50. package/dist/shaders/metal.d.ts +2 -0
  51. package/dist/shaders/metal.d.ts.map +1 -0
  52. package/dist/shaders/metal.js +171 -0
  53. package/dist/shaders/nodes.d.ts +2 -0
  54. package/dist/shaders/nodes.d.ts.map +1 -0
  55. package/dist/shaders/nodes.js +423 -0
  56. package/dist/shaders/stockings.d.ts +2 -0
  57. package/dist/shaders/stockings.d.ts.map +1 -0
  58. package/dist/shaders/stockings.js +229 -0
  59. package/package.json +1 -1
  60. package/src/asset-reader.ts +79 -0
  61. package/src/engine.ts +1352 -383
  62. package/src/folder-upload.ts +59 -0
  63. package/src/index.ts +12 -2
  64. package/src/model.ts +34 -2
  65. package/src/pmx-loader.ts +11 -2
  66. package/src/shaders/body.ts +211 -0
  67. package/src/shaders/classify.ts +25 -0
  68. package/src/shaders/cloth_rough.ts +174 -0
  69. package/src/shaders/cloth_smooth.ts +173 -0
  70. package/src/shaders/default.ts +169 -0
  71. package/src/shaders/dfg_lut.ts +127 -0
  72. package/src/shaders/eye.ts +143 -0
  73. package/src/shaders/face.ts +213 -0
  74. package/src/shaders/hair.ts +188 -0
  75. package/src/shaders/ltc_mag_lut.ts +1035 -0
  76. package/src/shaders/metal.ts +173 -0
  77. package/src/shaders/nodes.ts +424 -0
  78. package/src/shaders/stockings.ts +231 -0
package/dist/engine.js CHANGED
@@ -2,17 +2,39 @@ import { Camera } from "./camera";
2
2
  import { Mat4, Vec3 } from "./math";
3
3
  import { PmxLoader } from "./pmx-loader";
4
4
  import { Physics } from "./physics";
5
+ import { createFetchAssetReader, createFileMapAssetReader, deriveBasePathFromPmxPath, fileListToMap, findFirstPmxFileInList, joinAssetPath, normalizeAssetPath, } from "./asset-reader";
6
+ import { DEFAULT_SHADER_WGSL } from "./shaders/default";
7
+ import { DFG_LUT_SIZE, DFG_LUT_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.03,
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
+ };
5
32
  export const DEFAULT_ENGINE_OPTIONS = {
6
- ambientColor: new Vec3(0.88, 0.88, 0.88),
7
- directionalLightIntensity: 0.24,
8
- minSpecularIntensity: 0.3,
9
- rimLightIntensity: 0.4,
10
- cameraDistance: 26.6,
11
- cameraTarget: new Vec3(0, 12.5, 0),
12
- 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 },
13
36
  onRaycast: undefined,
14
37
  physicsOptions: { constraintSolverKeywords: ["胸"] },
15
- shadowLightDirection: new Vec3(0.12, -1, 0.16),
16
38
  };
17
39
  export class Engine {
18
40
  static getInstance() {
@@ -26,11 +48,19 @@ export class Engine {
26
48
  this.lightData = new Float32Array(64);
27
49
  this.lightCount = 0;
28
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 = [];
29
60
  this.hasGround = false;
30
61
  this.shadowLightVPMatrix = new Float32Array(16);
31
62
  this.groundDrawCall = null;
32
63
  this.physicsOptions = DEFAULT_ENGINE_OPTIONS.physicsOptions;
33
- this.shadowLightDirection = DEFAULT_ENGINE_OPTIONS.shadowLightDirection;
34
64
  this.lastTouchTime = 0;
35
65
  this.DOUBLE_TAP_DELAY = 300;
36
66
  this.pendingPick = null;
@@ -55,7 +85,7 @@ export class Engine {
55
85
  };
56
86
  this.animationFrameId = null;
57
87
  this.renderLoopCallback = null;
58
- // Shadow uses a fixed orthographic projection, independent of the visible light direction
88
+ // Shadow is cast from the visible sun direction — same vector the shader lights with.
59
89
  this.shadowLightVPDirty = true;
60
90
  this.handleCanvasDoubleClick = (event) => {
61
91
  if (!this.onRaycast || this.modelInstances.size === 0)
@@ -89,20 +119,135 @@ export class Engine {
89
119
  }
90
120
  };
91
121
  this.canvas = canvas;
92
- if (options) {
93
- this.ambientColor = options.ambientColor ?? DEFAULT_ENGINE_OPTIONS.ambientColor;
94
- this.directionalLightIntensity =
95
- options.directionalLightIntensity ?? DEFAULT_ENGINE_OPTIONS.directionalLightIntensity;
96
- this.minSpecularIntensity = options.minSpecularIntensity ?? DEFAULT_ENGINE_OPTIONS.minSpecularIntensity;
97
- this.rimLightIntensity = options.rimLightIntensity ?? DEFAULT_ENGINE_OPTIONS.rimLightIntensity;
98
- this.cameraDistance = options.cameraDistance ?? DEFAULT_ENGINE_OPTIONS.cameraDistance;
99
- this.cameraTarget = options.cameraTarget ?? DEFAULT_ENGINE_OPTIONS.cameraTarget;
100
- this.cameraFov = options.cameraFov ?? DEFAULT_ENGINE_OPTIONS.cameraFov;
101
- this.onRaycast = options.onRaycast;
102
- this.physicsOptions = options.physicsOptions ?? DEFAULT_ENGINE_OPTIONS.physicsOptions;
103
- this.shadowLightDirection = options.shadowLightDirection ?? DEFAULT_ENGINE_OPTIONS.shadowLightDirection;
122
+ const d = DEFAULT_ENGINE_OPTIONS;
123
+ this.world = {
124
+ color: options?.world?.color ?? d.world.color,
125
+ strength: options?.world?.strength ?? d.world.strength,
126
+ };
127
+ this.sun = {
128
+ color: options?.sun?.color ?? d.sun.color,
129
+ strength: options?.sun?.strength ?? d.sun.strength,
130
+ direction: options?.sun?.direction ?? d.sun.direction,
131
+ };
132
+ this.cameraConfig = {
133
+ distance: options?.camera?.distance ?? d.camera.distance,
134
+ target: options?.camera?.target ?? d.camera.target,
135
+ fov: options?.camera?.fov ?? d.camera.fov,
136
+ };
137
+ this.onRaycast = options?.onRaycast;
138
+ this.physicsOptions = options?.physicsOptions ?? d.physicsOptions;
139
+ this.bloomSettings = Engine.mergeBloomDefaults(options?.bloom);
140
+ this.viewTransform = Engine.mergeViewTransformDefaults(options?.view);
141
+ }
142
+ /** Merge partial bloom with EEVEE defaults (same as constructor). */
143
+ static mergeBloomDefaults(partial) {
144
+ const d = DEFAULT_BLOOM_OPTIONS;
145
+ const c = partial?.color;
146
+ return {
147
+ enabled: partial?.enabled ?? d.enabled,
148
+ threshold: partial?.threshold ?? d.threshold,
149
+ knee: partial?.knee ?? d.knee,
150
+ radius: partial?.radius ?? d.radius,
151
+ color: c ? new Vec3(c.x, c.y, c.z) : new Vec3(d.color.x, d.color.y, d.color.z),
152
+ intensity: partial?.intensity ?? d.intensity,
153
+ clamp: partial?.clamp ?? d.clamp,
154
+ };
155
+ }
156
+ static mergeViewTransformDefaults(partial) {
157
+ const d = DEFAULT_VIEW_TRANSFORM;
158
+ return {
159
+ exposure: partial?.exposure ?? d.exposure,
160
+ gamma: partial?.gamma ?? d.gamma,
161
+ look: partial?.look ?? d.look,
162
+ };
163
+ }
164
+ /** Current bloom settings (Blender names; tint is a copied `Vec3`). */
165
+ getBloomOptions() {
166
+ const b = this.bloomSettings;
167
+ return {
168
+ enabled: b.enabled,
169
+ threshold: b.threshold,
170
+ knee: b.knee,
171
+ radius: b.radius,
172
+ color: new Vec3(b.color.x, b.color.y, b.color.z),
173
+ intensity: b.intensity,
174
+ clamp: b.clamp,
175
+ };
176
+ }
177
+ getViewTransformOptions() {
178
+ const v = this.viewTransform;
179
+ return { exposure: v.exposure, gamma: v.gamma, look: v.look };
180
+ }
181
+ setViewTransformOptions(patch) {
182
+ const v = this.viewTransform;
183
+ if (patch.exposure !== undefined)
184
+ v.exposure = patch.exposure;
185
+ if (patch.gamma !== undefined)
186
+ v.gamma = patch.gamma;
187
+ if (patch.look !== undefined)
188
+ v.look = patch.look;
189
+ if (this.device && this.compositeUniformBuffer) {
190
+ this.writeCompositeViewUniforms();
191
+ }
192
+ }
193
+ writeCompositeViewUniforms() {
194
+ const v = this.viewTransform;
195
+ const b = this.bloomSettings;
196
+ const effIntensity = b.enabled ? b.intensity : 0.0;
197
+ const u = this.compositeUniformData;
198
+ u[0] = v.exposure;
199
+ u[1] = Math.max(v.gamma, 1e-4);
200
+ u[2] = 0.0;
201
+ u[3] = 0.0;
202
+ u[4] = b.color.x;
203
+ u[5] = b.color.y;
204
+ u[6] = b.color.z;
205
+ u[7] = effIntensity;
206
+ this.device.queue.writeBuffer(this.compositeUniformBuffer, 0, u);
207
+ }
208
+ /** Patch bloom; GPU uniforms update immediately if `init()` has run. */
209
+ setBloomOptions(patch) {
210
+ const b = this.bloomSettings;
211
+ if (patch.enabled !== undefined)
212
+ b.enabled = patch.enabled;
213
+ if (patch.threshold !== undefined)
214
+ b.threshold = patch.threshold;
215
+ if (patch.knee !== undefined)
216
+ b.knee = patch.knee;
217
+ if (patch.radius !== undefined)
218
+ b.radius = patch.radius;
219
+ if (patch.color !== undefined) {
220
+ b.color.x = patch.color.x;
221
+ b.color.y = patch.color.y;
222
+ b.color.z = patch.color.z;
223
+ }
224
+ if (patch.intensity !== undefined)
225
+ b.intensity = patch.intensity;
226
+ if (patch.clamp !== undefined)
227
+ b.clamp = patch.clamp;
228
+ if (this.device && this.bloomBlitUniformBuffer) {
229
+ this.writeBloomUniforms();
230
+ this.writeCompositeViewUniforms();
104
231
  }
105
232
  }
233
+ // EEVEE prefilter uniforms (blit stage) + upsample sample scale. Intensity/tint live in composite.
234
+ writeBloomUniforms() {
235
+ const b = this.bloomSettings;
236
+ const bu = this.bloomBlitUniformData;
237
+ // EEVEE prefilter: threshold, knee, clamp (0 → disabled), _unused
238
+ bu[0] = b.threshold;
239
+ bu[1] = b.knee;
240
+ bu[2] = b.clamp;
241
+ bu[3] = 0.0;
242
+ this.device.queue.writeBuffer(this.bloomBlitUniformBuffer, 0, bu);
243
+ const us = this.bloomUpsampleUniformData;
244
+ // Blender: bloom.radius directly controls the tent-filter sample scale in texel units.
245
+ us[0] = Math.max(0.5, b.radius);
246
+ us[1] = 0;
247
+ us[2] = 0;
248
+ us[3] = 0;
249
+ this.device.queue.writeBuffer(this.bloomUpsampleUniformBuffer, 0, us);
250
+ }
106
251
  // Step 1: Get WebGPU device and context
107
252
  async init() {
108
253
  const adapter = await navigator.gpu?.requestAdapter();
@@ -128,6 +273,69 @@ export class Engine {
128
273
  this.setupResize();
129
274
  Engine.instance = this;
130
275
  }
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",
311
+ size: [LTC_MAG_LUT_SIZE, LTC_MAG_LUT_SIZE],
312
+ format: "rg16float",
313
+ usage: GPUTextureUsage.COPY_DST | GPUTextureUsage.TEXTURE_BINDING,
314
+ });
315
+ this.ltcMagLutView = this.ltcMagLutTexture.createView();
316
+ // Float32 → float16 bits. rg16float writeTexture expects packed half floats.
317
+ const n = LTC_MAG_LUT_DATA.length;
318
+ const half = new Uint16Array(n);
319
+ const f32 = new Float32Array(1);
320
+ const u32 = new Uint32Array(f32.buffer);
321
+ for (let i = 0; i < n; i++) {
322
+ f32[0] = LTC_MAG_LUT_DATA[i];
323
+ const x = u32[0];
324
+ const sign = (x >>> 16) & 0x8000;
325
+ let exp = ((x >>> 23) & 0xff) - 127 + 15;
326
+ let mant = x & 0x7fffff;
327
+ if (exp <= 0) {
328
+ half[i] = sign; // flush tiny values to signed zero (data here is in [0, ~1])
329
+ }
330
+ else if (exp >= 31) {
331
+ half[i] = sign | 0x7c00; // inf
332
+ }
333
+ else {
334
+ half[i] = sign | (exp << 10) | (mant >>> 13);
335
+ }
336
+ }
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 });
338
+ }
131
339
  createRenderPipeline(config) {
132
340
  return this.device.createRenderPipeline({
133
341
  label: config.label,
@@ -191,8 +399,11 @@ export class Engine {
191
399
  attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" }],
192
400
  },
193
401
  ];
402
+ // Internal scene passes render into the HDR offscreen target; only the final
403
+ // composite pass writes the swapchain. Tonemap moved to composite so bloom
404
+ // (added next) can run on linear HDR.
194
405
  const standardBlend = {
195
- format: this.presentationFormat,
406
+ format: Engine.HDR_FORMAT,
196
407
  blend: {
197
408
  color: {
198
409
  srcFactor: "src-alpha",
@@ -207,145 +418,59 @@ export class Engine {
207
418
  },
208
419
  };
209
420
  const shaderModule = this.device.createShaderModule({
210
- label: "model shaders",
211
- code: /* wgsl */ `
212
- struct CameraUniforms {
213
- view: mat4x4f,
214
- projection: mat4x4f,
215
- viewPos: vec3f,
216
- _padding: f32,
217
- };
218
-
219
- struct Light {
220
- direction: vec4f,
221
- color: vec4f,
222
- };
223
-
224
- struct LightUniforms {
225
- ambientColor: vec4f,
226
- lights: array<Light, 4>,
227
- };
228
-
229
- struct MaterialUniforms {
230
- alpha: f32,
231
- rimIntensity: f32,
232
- shininess: f32,
233
- _padding1: f32,
234
- rimColor: vec3f,
235
- _padding2: f32,
236
- diffuseColor: vec3f,
237
- _padding3: f32,
238
- ambientColor: vec3f,
239
- _padding4: f32,
240
- specularColor: vec3f,
241
- _padding5: f32,
242
- };
243
-
244
- struct VertexOutput {
245
- @builtin(position) position: vec4f,
246
- @location(0) normal: vec3f,
247
- @location(1) uv: vec2f,
248
- @location(2) worldPos: vec3f,
249
- };
250
-
251
- // group 0: per-frame (bound once per pass)
252
- @group(0) @binding(0) var<uniform> camera: CameraUniforms;
253
- @group(0) @binding(1) var<uniform> light: LightUniforms;
254
- @group(0) @binding(2) var diffuseSampler: sampler;
255
- // group 1: per-instance (bound once per model)
256
- @group(1) @binding(0) var<storage, read> skinMats: array<mat4x4f>;
257
- // group 2: per-material (bound per draw call)
258
- @group(2) @binding(0) var diffuseTexture: texture_2d<f32>;
259
- @group(2) @binding(1) var<uniform> material: MaterialUniforms;
260
-
261
- @vertex fn vs(
262
- @location(0) position: vec3f,
263
- @location(1) normal: vec3f,
264
- @location(2) uv: vec2f,
265
- @location(3) joints0: vec4<u32>,
266
- @location(4) weights0: vec4<f32>
267
- ) -> VertexOutput {
268
- var output: VertexOutput;
269
- let pos4 = vec4f(position, 1.0);
270
-
271
- let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
272
- let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);
273
- let normalizedWeights = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
274
-
275
- var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
276
- var skinnedNrm = vec3f(0.0, 0.0, 0.0);
277
- for (var i = 0u; i < 4u; i++) {
278
- let j = joints0[i];
279
- let w = normalizedWeights[i];
280
- let m = skinMats[j];
281
- skinnedPos += (m * pos4) * w;
282
- let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
283
- skinnedNrm += (r3 * normal) * w;
284
- }
285
- let worldPos = skinnedPos.xyz;
286
- output.position = camera.projection * camera.view * vec4f(worldPos, 1.0);
287
- output.normal = normalize(skinnedNrm);
288
- output.uv = uv;
289
- output.worldPos = worldPos;
290
- return output;
291
- }
292
-
293
- @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
294
- let finalAlpha = material.alpha;
295
- if (finalAlpha < 0.001) {
296
- discard;
297
- }
298
-
299
- let n = normalize(input.normal);
300
- let textureColor = textureSample(diffuseTexture, diffuseSampler, input.uv).rgb;
301
-
302
- let viewDir = normalize(camera.viewPos - input.worldPos);
303
-
304
- let albedo = textureColor * material.diffuseColor;
305
-
306
- let minSpec = light.ambientColor.w;
307
- let effectiveSpecular = max(material.specularColor, vec3f(minSpec));
308
- let specPower = max(material.shininess, 1.0);
309
-
310
- let l = -light.lights[0].direction.xyz;
311
- let nDotL = max(dot(n, l), 0.0);
312
- let intensity = light.lights[0].color.w;
313
- let radiance = light.lights[0].color.xyz * intensity;
314
-
315
- let lightAccum = light.ambientColor.xyz + radiance * nDotL;
316
-
317
- let h = normalize(l + viewDir);
318
- let nDotH = max(dot(n, h), 0.0);
319
- let specFactor = pow(nDotH, specPower);
320
- let specularAccum = effectiveSpecular * radiance * specFactor * nDotL;
321
-
322
- let litColor = albedo * lightAccum;
323
-
324
- let fresnel = 1.0 - abs(dot(n, viewDir));
325
- let rimFactor = pow(fresnel, 4.0);
326
- let rimLight = material.rimColor * material.rimIntensity * rimFactor;
327
-
328
- let color = litColor + specularAccum + rimLight;
329
-
330
- return vec4f(color, finalAlpha);
331
- }
332
- `,
421
+ label: "default model shader",
422
+ code: DEFAULT_SHADER_WGSL,
423
+ });
424
+ const faceShaderModule = this.device.createShaderModule({
425
+ label: "face NPR shader",
426
+ code: FACE_SHADER_WGSL,
427
+ });
428
+ const hairShaderModule = this.device.createShaderModule({
429
+ label: "hair NPR shader",
430
+ code: HAIR_SHADER_WGSL,
431
+ });
432
+ const clothSmoothShaderModule = this.device.createShaderModule({
433
+ label: "cloth smooth NPR shader",
434
+ code: CLOTH_SMOOTH_SHADER_WGSL,
435
+ });
436
+ const clothRoughShaderModule = this.device.createShaderModule({
437
+ label: "cloth rough NPR shader",
438
+ code: CLOTH_ROUGH_SHADER_WGSL,
439
+ });
440
+ const metalShaderModule = this.device.createShaderModule({
441
+ label: "metal NPR shader",
442
+ code: METAL_SHADER_WGSL,
443
+ });
444
+ const bodyShaderModule = this.device.createShaderModule({
445
+ label: "body NPR shader",
446
+ code: BODY_SHADER_WGSL,
333
447
  });
334
- // group 0: per-frame (camera + light + sampler) — bound once per pass
448
+ const eyeShaderModule = this.device.createShaderModule({
449
+ label: "eye shader",
450
+ code: EYE_SHADER_WGSL,
451
+ });
452
+ const stockingsShaderModule = this.device.createShaderModule({
453
+ label: "stockings NPR shader",
454
+ code: STOCKINGS_SHADER_WGSL,
455
+ });
456
+ // group 0: per-frame (camera + light + sampler + shadow) — bound once per pass
335
457
  this.mainPerFrameBindGroupLayout = this.device.createBindGroupLayout({
336
458
  label: "main per-frame bind group layout",
337
459
  entries: [
338
460
  { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
339
461
  { binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
340
462
  { binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
463
+ { binding: 3, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "depth" } },
464
+ { binding: 4, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "comparison" } },
465
+ { binding: 5, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
466
+ { binding: 9, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
467
+ { binding: 10, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
341
468
  ],
342
469
  });
343
470
  // group 1: per-instance (skinMats) — bound once per model
344
471
  this.mainPerInstanceBindGroupLayout = this.device.createBindGroupLayout({
345
472
  label: "main per-instance bind group layout",
346
- entries: [
347
- { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
348
- ],
473
+ entries: [{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } }],
349
474
  });
350
475
  // group 2: per-material (texture + material uniforms) — bound per draw call
351
476
  this.mainPerMaterialBindGroupLayout = this.device.createBindGroupLayout({
@@ -357,17 +482,13 @@ export class Engine {
357
482
  });
358
483
  const mainPipelineLayout = this.device.createPipelineLayout({
359
484
  label: "main pipeline layout",
360
- bindGroupLayouts: [this.mainPerFrameBindGroupLayout, this.mainPerInstanceBindGroupLayout, this.mainPerMaterialBindGroupLayout],
361
- });
362
- this.perFrameBindGroup = this.device.createBindGroup({
363
- label: "main per-frame bind group",
364
- layout: this.mainPerFrameBindGroupLayout,
365
- entries: [
366
- { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
367
- { binding: 1, resource: { buffer: this.lightUniformBuffer } },
368
- { binding: 2, resource: this.materialSampler },
485
+ bindGroupLayouts: [
486
+ this.mainPerFrameBindGroupLayout,
487
+ this.mainPerInstanceBindGroupLayout,
488
+ this.mainPerMaterialBindGroupLayout,
369
489
  ],
370
490
  });
491
+ // perFrameBindGroup is created after shadow resources below
371
492
  this.modelPipeline = this.createRenderPipeline({
372
493
  label: "model pipeline",
373
494
  layout: mainPipelineLayout,
@@ -381,6 +502,110 @@ export class Engine {
381
502
  depthCompare: "less-equal",
382
503
  },
383
504
  });
505
+ this.facePipeline = this.createRenderPipeline({
506
+ label: "face NPR pipeline",
507
+ layout: mainPipelineLayout,
508
+ shaderModule: faceShaderModule,
509
+ vertexBuffers: fullVertexBuffers,
510
+ fragmentTarget: standardBlend,
511
+ cullMode: "none",
512
+ depthStencil: {
513
+ format: "depth24plus-stencil8",
514
+ depthWriteEnabled: true,
515
+ depthCompare: "less-equal",
516
+ },
517
+ });
518
+ this.hairPipeline = this.createRenderPipeline({
519
+ label: "hair NPR pipeline",
520
+ layout: mainPipelineLayout,
521
+ shaderModule: hairShaderModule,
522
+ vertexBuffers: fullVertexBuffers,
523
+ fragmentTarget: standardBlend,
524
+ cullMode: "none",
525
+ depthStencil: {
526
+ format: "depth24plus-stencil8",
527
+ depthWriteEnabled: true,
528
+ depthCompare: "less-equal",
529
+ },
530
+ });
531
+ this.clothSmoothPipeline = this.createRenderPipeline({
532
+ label: "cloth smooth NPR pipeline",
533
+ layout: mainPipelineLayout,
534
+ shaderModule: clothSmoothShaderModule,
535
+ vertexBuffers: fullVertexBuffers,
536
+ fragmentTarget: standardBlend,
537
+ cullMode: "none",
538
+ depthStencil: {
539
+ format: "depth24plus-stencil8",
540
+ depthWriteEnabled: true,
541
+ depthCompare: "less-equal",
542
+ },
543
+ });
544
+ this.clothRoughPipeline = this.createRenderPipeline({
545
+ label: "cloth rough NPR pipeline",
546
+ layout: mainPipelineLayout,
547
+ shaderModule: clothRoughShaderModule,
548
+ vertexBuffers: fullVertexBuffers,
549
+ fragmentTarget: standardBlend,
550
+ cullMode: "none",
551
+ depthStencil: {
552
+ format: "depth24plus-stencil8",
553
+ depthWriteEnabled: true,
554
+ depthCompare: "less-equal",
555
+ },
556
+ });
557
+ this.metalPipeline = this.createRenderPipeline({
558
+ label: "metal NPR pipeline",
559
+ layout: mainPipelineLayout,
560
+ shaderModule: metalShaderModule,
561
+ vertexBuffers: fullVertexBuffers,
562
+ fragmentTarget: standardBlend,
563
+ cullMode: "none",
564
+ depthStencil: {
565
+ format: "depth24plus-stencil8",
566
+ depthWriteEnabled: true,
567
+ depthCompare: "less-equal",
568
+ },
569
+ });
570
+ this.bodyPipeline = this.createRenderPipeline({
571
+ label: "body NPR pipeline",
572
+ layout: mainPipelineLayout,
573
+ shaderModule: bodyShaderModule,
574
+ vertexBuffers: fullVertexBuffers,
575
+ fragmentTarget: standardBlend,
576
+ cullMode: "none",
577
+ depthStencil: {
578
+ format: "depth24plus-stencil8",
579
+ depthWriteEnabled: true,
580
+ depthCompare: "less-equal",
581
+ },
582
+ });
583
+ this.eyePipeline = this.createRenderPipeline({
584
+ label: "eye pipeline",
585
+ layout: mainPipelineLayout,
586
+ shaderModule: eyeShaderModule,
587
+ vertexBuffers: fullVertexBuffers,
588
+ fragmentTarget: standardBlend,
589
+ cullMode: "none",
590
+ depthStencil: {
591
+ format: "depth24plus-stencil8",
592
+ depthWriteEnabled: true,
593
+ depthCompare: "less-equal",
594
+ },
595
+ });
596
+ this.stockingsPipeline = this.createRenderPipeline({
597
+ label: "stockings NPR pipeline",
598
+ layout: mainPipelineLayout,
599
+ shaderModule: stockingsShaderModule,
600
+ vertexBuffers: fullVertexBuffers,
601
+ fragmentTarget: standardBlend,
602
+ cullMode: "none",
603
+ depthStencil: {
604
+ format: "depth24plus-stencil8",
605
+ depthWriteEnabled: true,
606
+ depthCompare: "less-equal",
607
+ },
608
+ });
384
609
  this.shadowLightVPBuffer = this.device.createBuffer({
385
610
  size: 64,
386
611
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
@@ -429,6 +654,32 @@ export class Engine {
429
654
  magFilter: "linear",
430
655
  minFilter: "linear",
431
656
  });
657
+ this.shadowMapTexture = this.device.createTexture({
658
+ label: "shadow map",
659
+ size: [Engine.SHADOW_MAP_SIZE, Engine.SHADOW_MAP_SIZE],
660
+ format: "depth32float",
661
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
662
+ });
663
+ 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();
668
+ // Now that shadow resources exist, create the main per-frame bind group
669
+ this.perFrameBindGroup = this.device.createBindGroup({
670
+ label: "main per-frame bind group",
671
+ layout: this.mainPerFrameBindGroupLayout,
672
+ entries: [
673
+ { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
674
+ { binding: 1, resource: { buffer: this.lightUniformBuffer } },
675
+ { binding: 2, resource: this.materialSampler },
676
+ { binding: 3, resource: this.shadowMapDepthView },
677
+ { binding: 4, resource: this.shadowComparisonSampler },
678
+ { binding: 5, resource: { buffer: this.shadowLightVPBuffer } },
679
+ { binding: 9, resource: this.dfgLutView },
680
+ { binding: 10, resource: this.ltcMagLutView },
681
+ ],
682
+ });
432
683
  this.groundShadowBindGroupLayout = this.device.createBindGroupLayout({
433
684
  label: "ground shadow layout",
434
685
  entries: [
@@ -557,14 +808,16 @@ export class Engine {
557
808
  });
558
809
  const outlinePipelineLayout = this.device.createPipelineLayout({
559
810
  label: "outline pipeline layout",
560
- bindGroupLayouts: [this.outlinePerFrameBindGroupLayout, this.mainPerInstanceBindGroupLayout, this.outlinePerMaterialBindGroupLayout],
811
+ bindGroupLayouts: [
812
+ this.outlinePerFrameBindGroupLayout,
813
+ this.mainPerInstanceBindGroupLayout,
814
+ this.outlinePerMaterialBindGroupLayout,
815
+ ],
561
816
  });
562
817
  this.outlinePerFrameBindGroup = this.device.createBindGroup({
563
818
  label: "outline per-frame bind group",
564
819
  layout: this.outlinePerFrameBindGroupLayout,
565
- entries: [
566
- { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
567
- ],
820
+ entries: [{ binding: 0, resource: { buffer: this.cameraUniformBuffer } }],
568
821
  });
569
822
  const outlineShaderModule = this.device.createShaderModule({
570
823
  label: "outline shaders",
@@ -620,12 +873,27 @@ export class Engine {
620
873
  }
621
874
  let worldPos = skinnedPos.xyz;
622
875
  let worldNormal = normalize(skinnedNrm);
623
- // Screen-stable edgeline: extrusion ∝ camera distance (same idea as MMD viewers / babylon-mmd-style scaling)
624
- let camDist = max(length(camera.viewPos - worldPos), 0.25);
625
- let refDist = 30.0;
626
- let edgeScale = 0.025;
627
- let expandedPos = worldPos + worldNormal * material.edgeSize * edgeScale * (camDist / refDist);
628
- output.position = camera.projection * camera.view * vec4f(expandedPos, 1.0);
876
+
877
+ // Screen-space outline extrusion — MMD-style pixel-stable edge line.
878
+ // 1. Project position and normal-as-direction to clip space.
879
+ // 2. Normalize the 2D clip-space normal, aspect-compensated so "one pixel horizontally"
880
+ // matches "one pixel vertically" (otherwise wide viewports squash the outline in X).
881
+ // 3. Offset clip-space xy by (normal * edgeSize * edgeScale), then multiply by w
882
+ // so the perspective divide cancels out → offset stays constant in NDC regardless
883
+ // of depth, matching how MMD / babylon-mmd style outlines look identical when zooming.
884
+ // 4. edgeScale is in NDC-y units per PMX edgeSize. ≈ 0.006 gives ~3px at 1080p; it's
885
+ // tied to viewport HEIGHT so resizing the window keeps pixel thickness stable.
886
+ let viewProj = camera.projection * camera.view;
887
+ let clipPos = viewProj * vec4f(worldPos, 1.0);
888
+ let clipNormal = (viewProj * vec4f(worldNormal, 0.0)).xy;
889
+ // projection is column-major: proj[0][0] = 1/(aspect·tan(fov/2)), proj[1][1] = 1/tan(fov/2).
890
+ // Ratio proj[1][1]/proj[0][0] recovers the viewport aspect (width/height).
891
+ let aspect = camera.projection[1][1] / camera.projection[0][0];
892
+ let pixelDir = normalize(vec2f(clipNormal.x * aspect, clipNormal.y));
893
+ let ndcDir = vec2f(pixelDir.x / aspect, pixelDir.y);
894
+ let edgeScale = 0.0016;
895
+ let offset = ndcDir * material.edgeSize * edgeScale * clipPos.w;
896
+ output.position = vec4f(clipPos.xy + offset, clipPos.z, clipPos.w);
629
897
  return output;
630
898
  }
631
899
 
@@ -648,6 +916,277 @@ export class Engine {
648
916
  depthCompare: "less-equal",
649
917
  },
650
918
  });
919
+ // ─── Bloom (EEVEE 3.6 pyramid): blit(Karis prefilter) → 13-tap downsamples → 9-tap tent upsamples ───
920
+ // Mirrors source/blender/draw/engines/eevee/shaders/effect_bloom_frag.glsl.
921
+ // Firefly suppression lives in the blit (Karis luminance-weighted 4-tap average). A single-pass
922
+ // Gaussian cannot reproduce this — hot pixels dominate and produce the sparkle halo.
923
+ this.bloomSampler = this.device.createSampler({
924
+ label: "bloom sampler",
925
+ magFilter: "linear",
926
+ minFilter: "linear",
927
+ addressModeU: "clamp-to-edge",
928
+ addressModeV: "clamp-to-edge",
929
+ });
930
+ this.bloomBlitUniformBuffer = this.device.createBuffer({
931
+ label: "bloom blit uniforms",
932
+ size: 16,
933
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
934
+ });
935
+ this.bloomUpsampleUniformBuffer = this.device.createBuffer({
936
+ label: "bloom upsample uniforms",
937
+ size: 16,
938
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
939
+ });
940
+ this.bloomBlitBindGroupLayout = this.device.createBindGroupLayout({
941
+ label: "bloom blit layout",
942
+ entries: [
943
+ { binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "unfilterable-float" } },
944
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
945
+ ],
946
+ });
947
+ this.bloomDownsampleBindGroupLayout = this.device.createBindGroupLayout({
948
+ label: "bloom downsample layout",
949
+ entries: [
950
+ { binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: {} },
951
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
952
+ ],
953
+ });
954
+ this.bloomUpsampleBindGroupLayout = this.device.createBindGroupLayout({
955
+ label: "bloom upsample layout",
956
+ entries: [
957
+ { binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: {} }, // coarser-mip accumulator
958
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: {} }, // matching downsample mip (base add)
959
+ { binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
960
+ { binding: 3, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
961
+ ],
962
+ });
963
+ const bloomFullscreenVs = /* wgsl */ `
964
+ @vertex fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
965
+ let x = f32((vi & 1u) << 2u) - 1.0;
966
+ let y = f32((vi & 2u) << 1u) - 1.0;
967
+ return vec4f(x, y, 0.0, 1.0);
968
+ }
969
+ `;
970
+ // Blit: full-res HDR → half-res. Karis 4-tap firefly average + EEVEE quadratic knee threshold + clamp.
971
+ const bloomBlitShader = this.device.createShaderModule({
972
+ label: "bloom blit (Karis prefilter)",
973
+ code: `${bloomFullscreenVs}
974
+ @group(0) @binding(0) var hdrTex: texture_2d<f32>;
975
+ @group(0) @binding(1) var<uniform> prefilter: vec4<f32>; // threshold, knee, clamp, _unused
976
+
977
+ fn luminance(c: vec3f) -> f32 {
978
+ return dot(max(c, vec3f(0.0)), vec3f(0.2126, 0.7152, 0.0722));
979
+ }
980
+ fn fetch(c: vec2<i32>, clampV: f32) -> vec3f {
981
+ let d = vec2<i32>(textureDimensions(hdrTex));
982
+ let cc = clamp(c, vec2<i32>(0), d - vec2<i32>(1));
983
+ let s = textureLoad(hdrTex, cc, 0);
984
+ // Scene pass uses src-alpha blend with clear alpha 0 → premultiplied. Unpremultiply.
985
+ let rgb = max(s.rgb / max(s.a, 1e-6), vec3f(0.0));
986
+ // Blender: clamp each tap BEFORE Karis average (eevee_bloom: color = min(clampIntensity, color)).
987
+ return select(rgb, min(rgb, vec3f(clampV)), clampV > 0.0);
988
+ }
989
+
990
+ @fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
991
+ let dst = vec2<i32>(p.xy - vec2f(0.5));
992
+ let base = dst * 2;
993
+ let clampV = prefilter.z;
994
+ let a = fetch(base + vec2<i32>(0, 0), clampV);
995
+ let b = fetch(base + vec2<i32>(1, 0), clampV);
996
+ let c = fetch(base + vec2<i32>(0, 1), clampV);
997
+ let d = fetch(base + vec2<i32>(1, 1), clampV);
998
+ // Karis partial average: weight each tap by 1/(1+luma) — suppresses fireflies.
999
+ let wa = 1.0 / (1.0 + luminance(a));
1000
+ let wb = 1.0 / (1.0 + luminance(b));
1001
+ let wc = 1.0 / (1.0 + luminance(c));
1002
+ let wd = 1.0 / (1.0 + luminance(d));
1003
+ let avg = (a * wa + b * wb + c * wc + d * wd) / max(wa + wb + wc + wd, 1e-6);
1004
+ // EEVEE quadratic threshold (brightness = max-channel, then soft-knee curve).
1005
+ let bright = max(avg.r, max(avg.g, avg.b));
1006
+ let soft = clamp(bright - prefilter.x + prefilter.y, 0.0, 2.0 * prefilter.y);
1007
+ let q = (soft * soft) / (4.0 * max(prefilter.y, 1e-4) + 1e-6);
1008
+ let contrib = max(q, bright - prefilter.x) / max(bright, 1e-4);
1009
+ return vec4f(max(avg * contrib, vec3f(0.0)), 1.0);
1010
+ }
1011
+ `,
1012
+ });
1013
+ // Downsample: Jimenez/COD 13-tap dual-box — 5 weighted 2×2 averages, rejects nyquist ringing.
1014
+ const bloomDownsampleShader = this.device.createShaderModule({
1015
+ label: "bloom downsample 13-tap",
1016
+ code: `${bloomFullscreenVs}
1017
+ @group(0) @binding(0) var srcTex: texture_2d<f32>;
1018
+ @group(0) @binding(1) var srcSamp: sampler;
1019
+
1020
+ fn samp(uv: vec2f, off: vec2f) -> vec3f {
1021
+ return textureSampleLevel(srcTex, srcSamp, uv + off, 0.0).rgb;
1022
+ }
1023
+
1024
+ @fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
1025
+ let srcDims = vec2f(textureDimensions(srcTex));
1026
+ let t = 1.0 / srcDims;
1027
+ // fragCoord.xy reports pixel centers (e.g. 0.5,0.5 for first pixel) — divide by dst dims directly.
1028
+ let dstDims = srcDims * 0.5;
1029
+ let uv = p.xy / max(dstDims, vec2f(1.0));
1030
+ let A = samp(uv, t * vec2f(-2.0, -2.0));
1031
+ let B = samp(uv, t * vec2f( 0.0, -2.0));
1032
+ let C = samp(uv, t * vec2f( 2.0, -2.0));
1033
+ let D = samp(uv, t * vec2f(-1.0, -1.0));
1034
+ let E = samp(uv, t * vec2f( 1.0, -1.0));
1035
+ let F = samp(uv, t * vec2f(-2.0, 0.0));
1036
+ let G = samp(uv, t * vec2f( 0.0, 0.0));
1037
+ let H = samp(uv, t * vec2f( 2.0, 0.0));
1038
+ let I = samp(uv, t * vec2f(-1.0, 1.0));
1039
+ let J = samp(uv, t * vec2f( 1.0, 1.0));
1040
+ let K = samp(uv, t * vec2f(-2.0, 2.0));
1041
+ let L = samp(uv, t * vec2f( 0.0, 2.0));
1042
+ let M = samp(uv, t * vec2f( 2.0, 2.0));
1043
+ var o = (D + E + I + J) * (0.5 / 4.0);
1044
+ o = o + (A + B + G + F) * (0.125 / 4.0);
1045
+ o = o + (B + C + H + G) * (0.125 / 4.0);
1046
+ o = o + (F + G + L + K) * (0.125 / 4.0);
1047
+ o = o + (G + H + M + L) * (0.125 / 4.0);
1048
+ return vec4f(o, 1.0);
1049
+ }
1050
+ `,
1051
+ });
1052
+ // Upsample: 9-tap tent, progressively added to matching downsample mip. Blender radius = sample scale.
1053
+ const bloomUpsampleShader = this.device.createShaderModule({
1054
+ label: "bloom upsample 9-tap tent",
1055
+ code: `${bloomFullscreenVs}
1056
+ @group(0) @binding(0) var srcTex: texture_2d<f32>; // coarser accumulator
1057
+ @group(0) @binding(1) var baseTex: texture_2d<f32>; // matching downsample mip
1058
+ @group(0) @binding(2) var srcSamp: sampler;
1059
+ @group(0) @binding(3) var<uniform> upU: vec4<f32>; // sampleScale, _, _, _
1060
+
1061
+ @fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
1062
+ let srcDims = vec2f(textureDimensions(srcTex));
1063
+ let baseDims = vec2f(textureDimensions(baseTex));
1064
+ let uv = p.xy / max(baseDims, vec2f(1.0));
1065
+ let t = upU.x / srcDims;
1066
+ var o = textureSampleLevel(srcTex, srcSamp, uv + t * vec2f(-1.0, -1.0), 0.0).rgb * 1.0;
1067
+ o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 0.0, -1.0), 0.0).rgb * 2.0;
1068
+ o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 1.0, -1.0), 0.0).rgb * 1.0;
1069
+ o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f(-1.0, 0.0), 0.0).rgb * 2.0;
1070
+ o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 0.0, 0.0), 0.0).rgb * 4.0;
1071
+ o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 1.0, 0.0), 0.0).rgb * 2.0;
1072
+ o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f(-1.0, 1.0), 0.0).rgb * 1.0;
1073
+ o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 0.0, 1.0), 0.0).rgb * 2.0;
1074
+ o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 1.0, 1.0), 0.0).rgb * 1.0;
1075
+ o = o * (1.0 / 16.0);
1076
+ let base = textureSampleLevel(baseTex, srcSamp, uv, 0.0).rgb;
1077
+ return vec4f(o + base, 1.0);
1078
+ }
1079
+ `,
1080
+ });
1081
+ const bloomBlitLayout = this.device.createPipelineLayout({ bindGroupLayouts: [this.bloomBlitBindGroupLayout] });
1082
+ const bloomDownLayout = this.device.createPipelineLayout({ bindGroupLayouts: [this.bloomDownsampleBindGroupLayout] });
1083
+ const bloomUpLayout = this.device.createPipelineLayout({ bindGroupLayouts: [this.bloomUpsampleBindGroupLayout] });
1084
+ this.bloomBlitPipeline = this.device.createRenderPipeline({
1085
+ label: "bloom blit pipeline",
1086
+ layout: bloomBlitLayout,
1087
+ vertex: { module: bloomBlitShader, entryPoint: "vs" },
1088
+ fragment: { module: bloomBlitShader, entryPoint: "fs", targets: [{ format: Engine.HDR_FORMAT }] },
1089
+ primitive: { topology: "triangle-list" },
1090
+ });
1091
+ this.bloomDownsamplePipeline = this.device.createRenderPipeline({
1092
+ label: "bloom downsample pipeline",
1093
+ layout: bloomDownLayout,
1094
+ vertex: { module: bloomDownsampleShader, entryPoint: "vs" },
1095
+ fragment: { module: bloomDownsampleShader, entryPoint: "fs", targets: [{ format: Engine.HDR_FORMAT }] },
1096
+ primitive: { topology: "triangle-list" },
1097
+ });
1098
+ this.bloomUpsamplePipeline = this.device.createRenderPipeline({
1099
+ label: "bloom upsample pipeline",
1100
+ layout: bloomUpLayout,
1101
+ vertex: { module: bloomUpsampleShader, entryPoint: "vs" },
1102
+ fragment: { module: bloomUpsampleShader, entryPoint: "fs", targets: [{ format: Engine.HDR_FORMAT }] },
1103
+ primitive: { topology: "triangle-list" },
1104
+ });
1105
+ // ─── Composite: HDR + bloom → Filmic → swapchain (premultiplied) ───
1106
+ // Bloom color/intensity applied HERE (pyramid is pure energy; tint belongs to the combine step,
1107
+ // mirroring EEVEE where bloom color/intensity are combine-stage params, not prefilter).
1108
+ this.compositeUniformBuffer = this.device.createBuffer({
1109
+ label: "composite view uniforms",
1110
+ size: 32,
1111
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1112
+ });
1113
+ this.compositeBindGroupLayout = this.device.createBindGroupLayout({
1114
+ label: "composite bind group layout",
1115
+ entries: [
1116
+ { binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "unfilterable-float" } },
1117
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: {} },
1118
+ { binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
1119
+ { binding: 3, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
1120
+ ],
1121
+ });
1122
+ const compositeShader = this.device.createShaderModule({
1123
+ label: "composite shader",
1124
+ code: /* wgsl */ `
1125
+ @group(0) @binding(0) var hdrTex: texture_2d<f32>;
1126
+ @group(0) @binding(1) var bloomTex: texture_2d<f32>; // bloomUpTexture mip 0 (full pyramid top)
1127
+ @group(0) @binding(2) var bloomSamp: sampler;
1128
+ @group(0) @binding(3) var<uniform> viewU: array<vec4<f32>, 2>;
1129
+ // viewU[0] = (exposure, gamma, _, _); viewU[1] = (tint.rgb, intensity)
1130
+
1131
+ fn filmic(x: f32) -> f32 {
1132
+ var lut = array<f32, 14>(
1133
+ 0.0067, 0.0141, 0.0272, 0.0499, 0.0885, 0.1512, 0.2462,
1134
+ 0.3753, 0.5273, 0.6776, 0.8031, 0.8929, 0.9495, 0.9814
1135
+ );
1136
+ let t = clamp(log2(max(x, 1e-10)) + 10.0, 0.0, 13.0);
1137
+ let i = u32(t);
1138
+ let j = min(i + 1u, 13u);
1139
+ return mix(lut[i], lut[j], t - f32(i));
1140
+ }
1141
+
1142
+ @vertex fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
1143
+ let x = f32((vi & 1u) << 2u) - 1.0;
1144
+ let y = f32((vi & 2u) << 1u) - 1.0;
1145
+ return vec4f(x, y, 0.0, 1.0);
1146
+ }
1147
+
1148
+ @fragment fn fs(@builtin(position) fragCoord: vec4f) -> @location(0) vec4f {
1149
+ let hdr = textureLoad(hdrTex, vec2<i32>(fragCoord.xy), 0);
1150
+ let a = max(hdr.a, 1e-6);
1151
+ let straight = hdr.rgb / a;
1152
+ let fullSz = vec2f(textureDimensions(hdrTex));
1153
+ let bloomSz = vec2f(textureDimensions(bloomTex));
1154
+ // Bloom is at half-res (pyramid mip 0). Sampler interpolates back to full-res UVs.
1155
+ let bloomUv = (fragCoord.xy + vec2f(0.5)) / max(fullSz, vec2f(1.0));
1156
+ let tint = viewU[1].xyz;
1157
+ let intensity = viewU[1].w;
1158
+ let bloom = textureSampleLevel(bloomTex, bloomSamp, bloomUv, 0.0).rgb * tint * intensity;
1159
+ let combined = straight + bloom;
1160
+ let exposed = combined * exp2(viewU[0].x);
1161
+ let tm = vec3f(filmic(exposed.r), filmic(exposed.g), filmic(exposed.b));
1162
+ let g = max(viewU[0].y, 1e-4);
1163
+ let disp = pow(max(tm, vec3f(0.0)), vec3f(1.0 / g));
1164
+ return vec4f(disp * hdr.a, hdr.a);
1165
+ }
1166
+ `,
1167
+ });
1168
+ this.compositePipeline = this.device.createRenderPipeline({
1169
+ label: "composite pipeline",
1170
+ layout: this.device.createPipelineLayout({ bindGroupLayouts: [this.compositeBindGroupLayout] }),
1171
+ vertex: { module: compositeShader, entryPoint: "vs" },
1172
+ fragment: {
1173
+ module: compositeShader,
1174
+ entryPoint: "fs",
1175
+ targets: [{ format: this.presentationFormat }],
1176
+ },
1177
+ primitive: { topology: "triangle-list" },
1178
+ });
1179
+ this.bloomPassDescriptor = {
1180
+ label: "bloom pass",
1181
+ colorAttachments: [
1182
+ {
1183
+ view: undefined,
1184
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
1185
+ loadOp: "clear",
1186
+ storeOp: "store",
1187
+ },
1188
+ ],
1189
+ };
651
1190
  // GPU picking: encode (modelIndex, materialIndex) as color
652
1191
  const pickShaderModule = this.device.createShaderModule({
653
1192
  label: "pick shader",
@@ -692,32 +1231,28 @@ export class Engine {
692
1231
  });
693
1232
  this.pickPerFrameBindGroupLayout = this.device.createBindGroupLayout({
694
1233
  label: "pick per-frame layout",
695
- entries: [
696
- { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
697
- ],
1234
+ entries: [{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } }],
698
1235
  });
699
1236
  this.pickPerInstanceBindGroupLayout = this.device.createBindGroupLayout({
700
1237
  label: "pick per-instance layout",
701
- entries: [
702
- { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
703
- ],
1238
+ entries: [{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } }],
704
1239
  });
705
1240
  this.pickPerMaterialBindGroupLayout = this.device.createBindGroupLayout({
706
1241
  label: "pick per-material layout",
707
- entries: [
708
- { binding: 0, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
709
- ],
1242
+ entries: [{ binding: 0, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }],
710
1243
  });
711
1244
  const pickPipelineLayout = this.device.createPipelineLayout({
712
1245
  label: "pick pipeline layout",
713
- bindGroupLayouts: [this.pickPerFrameBindGroupLayout, this.pickPerInstanceBindGroupLayout, this.pickPerMaterialBindGroupLayout],
1246
+ bindGroupLayouts: [
1247
+ this.pickPerFrameBindGroupLayout,
1248
+ this.pickPerInstanceBindGroupLayout,
1249
+ this.pickPerMaterialBindGroupLayout,
1250
+ ],
714
1251
  });
715
1252
  this.pickPerFrameBindGroup = this.device.createBindGroup({
716
1253
  label: "pick per-frame bind group",
717
1254
  layout: this.pickPerFrameBindGroupLayout,
718
- entries: [
719
- { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
720
- ],
1255
+ entries: [{ binding: 0, resource: { buffer: this.cameraUniformBuffer } }],
721
1256
  });
722
1257
  this.pickPipeline = this.device.createRenderPipeline({
723
1258
  label: "pick pipeline",
@@ -761,12 +1296,47 @@ export class Engine {
761
1296
  this.canvas.width = width;
762
1297
  this.canvas.height = height;
763
1298
  this.multisampleTexture = this.device.createTexture({
764
- label: "multisample render target",
1299
+ label: "multisample HDR render target",
765
1300
  size: [width, height],
766
1301
  sampleCount: Engine.MULTISAMPLE_COUNT,
767
- format: this.presentationFormat,
1302
+ format: Engine.HDR_FORMAT,
768
1303
  usage: GPUTextureUsage.RENDER_ATTACHMENT,
769
1304
  });
1305
+ this.hdrResolveTexture = this.device.createTexture({
1306
+ label: "HDR resolve target",
1307
+ size: [width, height],
1308
+ format: Engine.HDR_FORMAT,
1309
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1310
+ });
1311
+ // Bloom pyramid: mip 0 is half-res, each subsequent mip halves again.
1312
+ // Mip count chosen so the coarsest mip is ≥4 px on the short side, capped at BLOOM_MAX_LEVELS.
1313
+ const bw = Math.max(1, Math.floor(width / 2));
1314
+ const bh = Math.max(1, Math.floor(height / 2));
1315
+ const shortSide = Math.max(1, Math.min(bw, bh));
1316
+ this.bloomMipCount = Math.max(1, Math.min(Engine.BLOOM_MAX_LEVELS, Math.floor(Math.log2(shortSide)) - 1));
1317
+ this.bloomDownTexture = this.device.createTexture({
1318
+ label: "bloom down pyramid",
1319
+ size: [bw, bh],
1320
+ mipLevelCount: this.bloomMipCount,
1321
+ format: Engine.HDR_FORMAT,
1322
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1323
+ });
1324
+ this.bloomUpTexture = this.device.createTexture({
1325
+ label: "bloom up pyramid",
1326
+ size: [bw, bh],
1327
+ mipLevelCount: Math.max(1, this.bloomMipCount - 1),
1328
+ format: Engine.HDR_FORMAT,
1329
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1330
+ });
1331
+ this.bloomDownMipViews = [];
1332
+ for (let i = 0; i < this.bloomMipCount; i++) {
1333
+ this.bloomDownMipViews.push(this.bloomDownTexture.createView({ baseMipLevel: i, mipLevelCount: 1 }));
1334
+ }
1335
+ this.bloomUpMipViews = [];
1336
+ const upLevels = Math.max(1, this.bloomMipCount - 1);
1337
+ for (let i = 0; i < upLevels; i++) {
1338
+ this.bloomUpMipViews.push(this.bloomUpTexture.createView({ baseMipLevel: i, mipLevelCount: 1 }));
1339
+ }
770
1340
  this.depthTexture = this.device.createTexture({
771
1341
  label: "depth texture",
772
1342
  size: [width, height],
@@ -777,7 +1347,7 @@ export class Engine {
777
1347
  const depthTextureView = this.depthTexture.createView();
778
1348
  const colorAttachment = {
779
1349
  view: this.multisampleTexture.createView(),
780
- resolveTarget: this.context.getCurrentTexture().createView(),
1350
+ resolveTarget: this.hdrResolveTexture.createView(),
781
1351
  clearValue: { r: 0, g: 0, b: 0, a: 0 },
782
1352
  loadOp: "clear",
783
1353
  storeOp: "store",
@@ -795,6 +1365,72 @@ export class Engine {
795
1365
  stencilStoreOp: "discard",
796
1366
  },
797
1367
  };
1368
+ // Composite pass descriptor (color attachment view patched per-frame to current swapchain).
1369
+ this.compositePassDescriptor = {
1370
+ label: "composite pass",
1371
+ colorAttachments: [
1372
+ {
1373
+ view: undefined,
1374
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
1375
+ loadOp: "clear",
1376
+ storeOp: "store",
1377
+ },
1378
+ ],
1379
+ };
1380
+ this.writeBloomUniforms();
1381
+ if (this.compositeBindGroupLayout && this.bloomBlitBindGroupLayout) {
1382
+ // Blit: reads HDR resolve texture (full-res), writes bloomDown mip 0.
1383
+ this.bloomBlitBindGroup = this.device.createBindGroup({
1384
+ label: "bloom blit bind group",
1385
+ layout: this.bloomBlitBindGroupLayout,
1386
+ entries: [
1387
+ { binding: 0, resource: this.hdrResolveTexture.createView() },
1388
+ { binding: 1, resource: { buffer: this.bloomBlitUniformBuffer } },
1389
+ ],
1390
+ });
1391
+ // Downsample[i] reads bloomDown mip (i-1), writes bloomDown mip i. i ∈ [1..N-1].
1392
+ this.bloomDownsampleBindGroups = [];
1393
+ for (let i = 1; i < this.bloomMipCount; i++) {
1394
+ this.bloomDownsampleBindGroups.push(this.device.createBindGroup({
1395
+ label: `bloom downsample ${i}`,
1396
+ layout: this.bloomDownsampleBindGroupLayout,
1397
+ entries: [
1398
+ { binding: 0, resource: this.bloomDownMipViews[i - 1] },
1399
+ { binding: 1, resource: this.bloomSampler },
1400
+ ],
1401
+ }));
1402
+ }
1403
+ // Upsample[i] writes bloomUp mip i. Coarsest step reads bloomDown[N-1] (no prior up yet);
1404
+ // subsequent steps read bloomUp[i+1]. Both read bloomDown[i] as the base (additive combine).
1405
+ this.bloomUpsampleBindGroups = [];
1406
+ const topIdx = this.bloomMipCount - 2;
1407
+ for (let i = topIdx; i >= 0; i--) {
1408
+ const srcView = i === topIdx ? this.bloomDownMipViews[this.bloomMipCount - 1] : this.bloomUpMipViews[i + 1];
1409
+ this.bloomUpsampleBindGroups.push(this.device.createBindGroup({
1410
+ label: `bloom upsample ${i}`,
1411
+ layout: this.bloomUpsampleBindGroupLayout,
1412
+ entries: [
1413
+ { binding: 0, resource: srcView },
1414
+ { binding: 1, resource: this.bloomDownMipViews[i] },
1415
+ { binding: 2, resource: this.bloomSampler },
1416
+ { binding: 3, resource: { buffer: this.bloomUpsampleUniformBuffer } },
1417
+ ],
1418
+ }));
1419
+ }
1420
+ // Composite reads bloomUp mip 0 (full pyramid collapsed); fallback to bloomDown mip 0 if no upsample level.
1421
+ const compositeBloomView = this.bloomMipCount > 1 ? this.bloomUpMipViews[0] : this.bloomDownMipViews[0];
1422
+ this.compositeBindGroup = this.device.createBindGroup({
1423
+ label: "composite bind group",
1424
+ layout: this.compositeBindGroupLayout,
1425
+ entries: [
1426
+ { binding: 0, resource: this.hdrResolveTexture.createView() },
1427
+ { binding: 1, resource: compositeBloomView },
1428
+ { binding: 2, resource: this.bloomSampler },
1429
+ { binding: 3, resource: { buffer: this.compositeUniformBuffer } },
1430
+ ],
1431
+ });
1432
+ }
1433
+ this.writeCompositeViewUniforms();
798
1434
  this.camera.aspect = width / height;
799
1435
  if (this.onRaycast) {
800
1436
  this.pickTexture = this.device.createTexture({
@@ -819,7 +1455,7 @@ export class Engine {
819
1455
  size: 40 * 4,
820
1456
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
821
1457
  });
822
- this.camera = new Camera(Math.PI, Math.PI / 2.5, this.cameraDistance, this.cameraTarget, this.cameraFov);
1458
+ this.camera = new Camera(Math.PI, Math.PI / 2.5, this.cameraConfig.distance, this.cameraConfig.target, this.cameraConfig.fov);
823
1459
  this.camera.aspect = this.canvas.width / this.canvas.height;
824
1460
  this.camera.attachControl(this.canvas);
825
1461
  }
@@ -853,58 +1489,100 @@ export class Engine {
853
1489
  this.cameraTargetOffset.y = offset?.y ?? 0;
854
1490
  this.cameraTargetOffset.z = offset?.z ?? 0;
855
1491
  }
856
- getCameraDistance() { return this.camera.radius; }
857
- setCameraDistance(d) { this.camera.radius = d; }
858
- getCameraAlpha() { return this.camera.alpha; }
859
- setCameraAlpha(a) { this.camera.alpha = a; }
860
- getCameraBeta() { return this.camera.beta; }
861
- setCameraBeta(b) { this.camera.beta = b; }
1492
+ getCameraDistance() {
1493
+ return this.camera.radius;
1494
+ }
1495
+ setCameraDistance(d) {
1496
+ this.camera.radius = d;
1497
+ }
1498
+ getCameraAlpha() {
1499
+ return this.camera.alpha;
1500
+ }
1501
+ setCameraAlpha(a) {
1502
+ this.camera.alpha = a;
1503
+ }
1504
+ getCameraBeta() {
1505
+ return this.camera.beta;
1506
+ }
1507
+ setCameraBeta(b) {
1508
+ this.camera.beta = b;
1509
+ }
862
1510
  // Step 5: Create lighting buffers
863
1511
  setupLighting() {
864
1512
  this.lightUniformBuffer = this.device.createBuffer({
865
1513
  label: "light uniforms",
866
- size: 64 * 4, // 64 floats: ambientColor vec4f (4) + 4 lights * 2 vec4f each (32)
1514
+ size: 64 * 4, // ambientColor vec4f (4) + 4 lights * 2 vec4f each (32) = 36 f32 padded to 64
867
1515
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
868
1516
  });
869
- // Initialize light buffer to zeros
870
1517
  this.lightData.fill(0);
871
1518
  this.lightCount = 0;
872
- this.setAmbientColor(this.ambientColor);
873
- this.addLight(new Vec3(0.5, -1, 1).normalize(), new Vec3(1.0, 1.0, 1.0), this.directionalLightIntensity);
874
- }
875
- setAmbientColor(color) {
876
- // Layout: ambientColor (0-3), lights (4-63) - 2 vec4f per light
877
- this.lightData[0] = color.x; // ambientColor.x
878
- this.lightData[1] = color.y; // ambientColor.y
879
- this.lightData[2] = color.z; // ambientColor.z
880
- this.lightData[3] = this.minSpecularIntensity; // ambientColor.w = minSpecularIntensity
1519
+ this.writeWorld();
1520
+ this.writeSun(0);
1521
+ }
1522
+ /**
1523
+ * Write world ambient. For a uniform-radiance world, hemispherical irradiance
1524
+ * is E = π·L and a Lambertian BRDF reflects (albedo/π)·E = albedo·L, so the
1525
+ * shader's ambient uniform is just `world.color × world.strength` no /π.
1526
+ */
1527
+ writeWorld() {
1528
+ const s = this.world.strength;
1529
+ this.lightData[0] = this.world.color.x * s;
1530
+ this.lightData[1] = this.world.color.y * s;
1531
+ this.lightData[2] = this.world.color.z * s;
1532
+ this.lightData[3] = 0;
881
1533
  this.updateLightBuffer();
882
1534
  }
883
- addLight(direction, color, intensity = 1.0) {
884
- if (this.lightCount >= 4)
885
- return false;
886
- const normalized = direction.normalize();
887
- const baseIndex = 4 + this.lightCount * 8; // Start at index 4, 8 floats per light (2 vec4f)
888
- this.lightData[baseIndex] = normalized.x; // direction.x
889
- this.lightData[baseIndex + 1] = normalized.y; // direction.y
890
- this.lightData[baseIndex + 2] = normalized.z; // direction.z
891
- this.lightData[baseIndex + 3] = 0; // direction.w
892
- this.lightData[baseIndex + 4] = color.x; // color.x
893
- this.lightData[baseIndex + 5] = color.y; // color.y
894
- this.lightData[baseIndex + 6] = color.z; // color.z
895
- this.lightData[baseIndex + 7] = intensity; // color.w / intensity
896
- this.lightCount++;
1535
+ /** Write sun lamp into light slot `index` (0..3). Layout mirrors the WGSL struct. */
1536
+ writeSun(index) {
1537
+ if (index < 0 || index >= 4)
1538
+ return;
1539
+ const normalized = this.sun.direction.normalize();
1540
+ const base = 4 + index * 8; // 8 floats per light (direction vec4, color vec4)
1541
+ this.lightData[base] = normalized.x;
1542
+ this.lightData[base + 1] = normalized.y;
1543
+ this.lightData[base + 2] = normalized.z;
1544
+ this.lightData[base + 3] = 0;
1545
+ this.lightData[base + 4] = this.sun.color.x;
1546
+ this.lightData[base + 5] = this.sun.color.y;
1547
+ this.lightData[base + 6] = this.sun.color.z;
1548
+ this.lightData[base + 7] = this.sun.strength;
1549
+ if (index >= this.lightCount)
1550
+ this.lightCount = index + 1;
897
1551
  this.updateLightBuffer();
898
- return true;
1552
+ }
1553
+ /** Update the world environment (Blender: World Background). Ambient recomputes immediately. */
1554
+ setWorld(options) {
1555
+ if (options.color)
1556
+ this.world.color = options.color;
1557
+ if (options.strength !== undefined)
1558
+ this.world.strength = options.strength;
1559
+ this.writeWorld();
1560
+ }
1561
+ /** Update the sun lamp (Blender: Light > Sun). Direction change marks shadow VP dirty. */
1562
+ setSun(options) {
1563
+ if (options.color)
1564
+ this.sun.color = options.color;
1565
+ if (options.strength !== undefined)
1566
+ this.sun.strength = options.strength;
1567
+ if (options.direction) {
1568
+ this.sun.direction = options.direction;
1569
+ this.shadowLightVPDirty = true;
1570
+ }
1571
+ this.writeSun(0);
1572
+ }
1573
+ getWorld() {
1574
+ return this.world;
1575
+ }
1576
+ getSun() {
1577
+ return this.sun;
899
1578
  }
900
1579
  addGround(options) {
901
1580
  const opts = {
902
1581
  width: 160,
903
1582
  height: 160,
904
- diffuseColor: new Vec3(0.8, 0.1, 1.0),
1583
+ diffuseColor: new Vec3(0.9, 0.1, 1.0),
905
1584
  fadeStart: 10.0,
906
1585
  fadeEnd: 80.0,
907
- shadowMapSize: 4096,
908
1586
  shadowStrength: 1.0,
909
1587
  gridSpacing: 4.2,
910
1588
  gridLineWidth: 0.012,
@@ -922,6 +1600,7 @@ export class Engine {
922
1600
  firstIndex: 0,
923
1601
  bindGroup: this.groundShadowBindGroup,
924
1602
  materialName: "Ground",
1603
+ preset: "cloth_rough",
925
1604
  };
926
1605
  }
927
1606
  updateLightBuffer() {
@@ -965,28 +1644,55 @@ export class Engine {
965
1644
  this.resizeObserver = null;
966
1645
  }
967
1646
  }
968
- async loadModel(nameOrPath, path) {
969
- const pmxPath = path === undefined ? nameOrPath : path;
970
- const name = path === undefined ? "model_" + (this._nextDefaultModelId++) : nameOrPath;
1647
+ async loadModel(nameOrPath, pathOrOptions) {
1648
+ if (pathOrOptions !== undefined && typeof pathOrOptions === "object" && "files" in pathOrOptions) {
1649
+ const name = nameOrPath;
1650
+ const pmxFile = pathOrOptions.pmxFile ?? findFirstPmxFileInList(pathOrOptions.files);
1651
+ if (!pmxFile)
1652
+ throw new Error("No .pmx file found in the selected folder");
1653
+ const map = fileListToMap(pathOrOptions.files);
1654
+ const pmxKey = normalizeAssetPath(pmxFile.webkitRelativePath ?? pmxFile.name);
1655
+ const reader = createFileMapAssetReader(map);
1656
+ const model = await PmxLoader.loadFromReader(reader, pmxKey);
1657
+ model.setName(name);
1658
+ await this.addModel(model, pmxKey, name, reader);
1659
+ return model;
1660
+ }
1661
+ const pmxPath = pathOrOptions === undefined ? nameOrPath : pathOrOptions;
1662
+ const name = pathOrOptions === undefined ? "model_" + this._nextDefaultModelId++ : nameOrPath;
971
1663
  const model = await PmxLoader.load(pmxPath);
972
1664
  model.setName(name);
973
1665
  await this.addModel(model, pmxPath, name);
974
1666
  return model;
975
1667
  }
976
- async addModel(model, pmxPath, name) {
1668
+ async addModel(model, pmxPath, name, assetReader) {
977
1669
  const requested = name ?? model.name;
978
1670
  let key = requested;
979
1671
  let n = 1;
980
1672
  while (this.modelInstances.has(key)) {
981
1673
  key = `${requested}_${n++}`;
982
1674
  }
983
- const pathParts = pmxPath.split("/");
984
- pathParts.pop();
985
- const basePath = pathParts.join("/") + "/";
986
- await this.setupModelInstance(key, model, basePath);
1675
+ const reader = assetReader ?? createFetchAssetReader();
1676
+ const basePath = deriveBasePathFromPmxPath(pmxPath);
1677
+ model.setAssetContext(reader, basePath);
1678
+ await this.setupModelInstance(key, model, basePath, reader);
987
1679
  return key;
988
1680
  }
989
1681
  removeModel(name) {
1682
+ const inst = this.modelInstances.get(name);
1683
+ if (!inst)
1684
+ return;
1685
+ inst.model.stopAnimation();
1686
+ for (const path of inst.textureCacheKeys) {
1687
+ const tex = this.textureCache.get(path);
1688
+ if (tex) {
1689
+ tex.destroy();
1690
+ this.textureCache.delete(path);
1691
+ }
1692
+ }
1693
+ for (const buf of inst.gpuBuffers) {
1694
+ buf.destroy();
1695
+ }
990
1696
  this.modelInstances.delete(name);
991
1697
  }
992
1698
  getModelNames() {
@@ -1011,6 +1717,15 @@ export class Engine {
1011
1717
  }
1012
1718
  }
1013
1719
  }
1720
+ setMaterialPresets(modelName, presets) {
1721
+ const inst = this.modelInstances.get(modelName);
1722
+ if (!inst)
1723
+ return;
1724
+ inst.materialPresets = presets;
1725
+ for (const dc of inst.drawCalls) {
1726
+ dc.preset = resolvePreset(dc.materialName, presets);
1727
+ }
1728
+ }
1014
1729
  setMaterialVisible(modelName, materialName, visible) {
1015
1730
  const inst = this.modelInstances.get(modelName);
1016
1731
  if (!inst)
@@ -1068,7 +1783,7 @@ export class Engine {
1068
1783
  this.device.queue.writeBuffer(inst.vertexBuffer, 0, vertices);
1069
1784
  inst.vertexBufferNeedsUpdate = false;
1070
1785
  }
1071
- async setupModelInstance(name, model, basePath) {
1786
+ async setupModelInstance(name, model, basePath, assetReader) {
1072
1787
  const vertices = model.getVertices();
1073
1788
  const skinning = model.getSkinning();
1074
1789
  const skeleton = model.getSkeleton();
@@ -1119,21 +1834,21 @@ export class Engine {
1119
1834
  const mainPerInstanceBindGroup = this.device.createBindGroup({
1120
1835
  label: `${name}: main per-instance bind group`,
1121
1836
  layout: this.mainPerInstanceBindGroupLayout,
1122
- entries: [
1123
- { binding: 0, resource: { buffer: skinMatrixBuffer } },
1124
- ],
1837
+ entries: [{ binding: 0, resource: { buffer: skinMatrixBuffer } }],
1125
1838
  });
1126
1839
  const pickPerInstanceBindGroup = this.device.createBindGroup({
1127
1840
  label: `${name}: pick per-instance bind group`,
1128
1841
  layout: this.pickPerInstanceBindGroupLayout,
1129
- entries: [
1130
- { binding: 0, resource: { buffer: skinMatrixBuffer } },
1131
- ],
1842
+ entries: [{ binding: 0, resource: { buffer: skinMatrixBuffer } }],
1132
1843
  });
1844
+ const gpuBuffers = [vertexBuffer, indexBuffer, jointsBuffer, weightsBuffer, skinMatrixBuffer];
1133
1845
  const inst = {
1134
1846
  name,
1135
1847
  model,
1136
1848
  basePath,
1849
+ assetReader,
1850
+ gpuBuffers,
1851
+ textureCacheKeys: [],
1137
1852
  vertexBuffer,
1138
1853
  indexBuffer,
1139
1854
  jointsBuffer,
@@ -1146,6 +1861,7 @@ export class Engine {
1146
1861
  pickPerInstanceBindGroup,
1147
1862
  pickDrawCalls: [],
1148
1863
  hiddenMaterials: new Set(),
1864
+ materialPresets: undefined,
1149
1865
  physics,
1150
1866
  vertexBufferNeedsUpdate: false,
1151
1867
  };
@@ -1217,15 +1933,8 @@ export class Engine {
1217
1933
  this.device.queue.writeBuffer(this.groundIndexBuffer, 0, indices);
1218
1934
  }
1219
1935
  createShadowGroundResources(opts) {
1220
- const { shadowMapSize, diffuseColor, fadeStart, fadeEnd, shadowStrength, gridSpacing, gridLineWidth, gridLineOpacity, gridLineColor, noiseStrength } = opts;
1221
- this.shadowMapTexture = this.device.createTexture({
1222
- label: "shadow map",
1223
- size: [shadowMapSize, shadowMapSize],
1224
- format: "depth32float",
1225
- usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1226
- });
1227
- this.shadowMapDepthView = this.shadowMapTexture.createView();
1228
- // 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)
1936
+ const { diffuseColor, fadeStart, fadeEnd, shadowStrength, gridSpacing, gridLineWidth, gridLineOpacity, gridLineColor, noiseStrength, } = opts;
1937
+ // Shadow map is already created in setupPipelines()
1229
1938
  const gb = new Float32Array(16);
1230
1939
  gb[0] = diffuseColor.x;
1231
1940
  gb[1] = diffuseColor.y;
@@ -1233,7 +1942,7 @@ export class Engine {
1233
1942
  gb[3] = fadeStart;
1234
1943
  gb[4] = fadeEnd;
1235
1944
  gb[5] = shadowStrength;
1236
- gb[6] = 1 / shadowMapSize;
1945
+ gb[6] = 1 / Engine.SHADOW_MAP_SIZE;
1237
1946
  gb[7] = gridSpacing;
1238
1947
  gb[8] = gridLineWidth;
1239
1948
  gb[9] = gridLineOpacity;
@@ -1243,7 +1952,10 @@ export class Engine {
1243
1952
  gb[13] = gridLineColor.y;
1244
1953
  gb[14] = gridLineColor.z;
1245
1954
  gb[15] = 0;
1246
- this.groundShadowMaterialBuffer = this.device.createBuffer({ size: gb.byteLength, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
1955
+ this.groundShadowMaterialBuffer = this.device.createBuffer({
1956
+ size: gb.byteLength,
1957
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1958
+ });
1247
1959
  this.device.queue.writeBuffer(this.groundShadowMaterialBuffer, 0, gb);
1248
1960
  this.groundShadowBindGroup = this.device.createBindGroup({
1249
1961
  label: "ground shadow bind",
@@ -1262,7 +1974,7 @@ export class Engine {
1262
1974
  if (!this.shadowLightVPDirty)
1263
1975
  return;
1264
1976
  this.shadowLightVPDirty = false;
1265
- const dir = new Vec3(this.shadowLightDirection.x, this.shadowLightDirection.y, this.shadowLightDirection.z);
1977
+ const dir = new Vec3(this.sun.direction.x, this.sun.direction.y, this.sun.direction.z);
1266
1978
  dir.normalize();
1267
1979
  const target = new Vec3(0, 11, 0);
1268
1980
  const eye = new Vec3(target.x - dir.x * 72, target.y - dir.y * 72, target.z - dir.z * 72);
@@ -1285,8 +1997,8 @@ export class Engine {
1285
1997
  const loadTextureByIndex = async (texIndex) => {
1286
1998
  if (texIndex < 0 || texIndex >= textures.length)
1287
1999
  return null;
1288
- const path = inst.basePath + textures[texIndex].path;
1289
- return this.createTextureFromPath(path);
2000
+ const logicalPath = joinAssetPath(inst.basePath, normalizeAssetPath(textures[texIndex].path));
2001
+ return this.createTextureFromLogicalPath(inst, logicalPath);
1290
2002
  };
1291
2003
  let currentIndexOffset = 0;
1292
2004
  let materialId = 0;
@@ -1300,7 +2012,12 @@ export class Engine {
1300
2012
  throw new Error(`Material "${mat.name}" has no diffuse texture`);
1301
2013
  const materialAlpha = mat.diffuse[3];
1302
2014
  const isTransparent = materialAlpha < 1.0 - 0.001;
1303
- const materialUniformBuffer = this.createMaterialUniformBuffer(prefix + mat.name, materialAlpha, [mat.diffuse[0], mat.diffuse[1], mat.diffuse[2]], mat.ambient, mat.specular, mat.shininess);
2015
+ const materialUniformBuffer = this.createMaterialUniformBuffer(prefix + mat.name, materialAlpha, [
2016
+ mat.diffuse[0],
2017
+ mat.diffuse[1],
2018
+ mat.diffuse[2],
2019
+ ]);
2020
+ inst.gpuBuffers.push(materialUniformBuffer);
1304
2021
  const textureView = diffuseTexture.createView();
1305
2022
  const bindGroup = this.device.createBindGroup({
1306
2023
  label: `${prefix}material: ${mat.name}`,
@@ -1311,26 +2028,47 @@ export class Engine {
1311
2028
  ],
1312
2029
  });
1313
2030
  const type = isTransparent ? "transparent" : "opaque";
1314
- inst.drawCalls.push({ type, count: indexCount, firstIndex: currentIndexOffset, bindGroup, materialName: mat.name });
2031
+ const preset = resolvePreset(mat.name, inst.materialPresets);
2032
+ inst.drawCalls.push({
2033
+ type,
2034
+ count: indexCount,
2035
+ firstIndex: currentIndexOffset,
2036
+ bindGroup,
2037
+ materialName: mat.name,
2038
+ preset,
2039
+ });
1315
2040
  if ((mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
1316
2041
  const materialUniformData = new Float32Array([
1317
- mat.edgeColor[0], mat.edgeColor[1], mat.edgeColor[2], mat.edgeColor[3],
1318
- mat.edgeSize, 0, 0, 0,
2042
+ mat.edgeColor[0],
2043
+ mat.edgeColor[1],
2044
+ mat.edgeColor[2],
2045
+ mat.edgeColor[3],
2046
+ mat.edgeSize,
2047
+ 0,
2048
+ 0,
2049
+ 0,
1319
2050
  ]);
1320
2051
  const outlineUniformBuffer = this.createUniformBuffer(`${prefix}outline: ${mat.name}`, materialUniformData);
2052
+ inst.gpuBuffers.push(outlineUniformBuffer);
1321
2053
  const outlineBindGroup = this.device.createBindGroup({
1322
2054
  label: `${prefix}outline: ${mat.name}`,
1323
2055
  layout: this.outlinePerMaterialBindGroupLayout,
1324
- entries: [
1325
- { binding: 0, resource: { buffer: outlineUniformBuffer } },
1326
- ],
2056
+ entries: [{ binding: 0, resource: { buffer: outlineUniformBuffer } }],
1327
2057
  });
1328
2058
  const outlineType = isTransparent ? "transparent-outline" : "opaque-outline";
1329
- inst.drawCalls.push({ type: outlineType, count: indexCount, firstIndex: currentIndexOffset, bindGroup: outlineBindGroup, materialName: mat.name });
2059
+ inst.drawCalls.push({
2060
+ type: outlineType,
2061
+ count: indexCount,
2062
+ firstIndex: currentIndexOffset,
2063
+ bindGroup: outlineBindGroup,
2064
+ materialName: mat.name,
2065
+ preset,
2066
+ });
1330
2067
  }
1331
2068
  if (this.onRaycast) {
1332
2069
  const pickIdData = new Float32Array([modelId, materialId, 0, 0]);
1333
2070
  const pickIdBuffer = this.createUniformBuffer(`${prefix}pick: ${mat.name}`, pickIdData);
2071
+ inst.gpuBuffers.push(pickIdBuffer);
1334
2072
  const pickBindGroup = this.device.createBindGroup({
1335
2073
  label: `${prefix}pick: ${mat.name}`,
1336
2074
  layout: this.pickPerMaterialBindGroupLayout,
@@ -1345,18 +2083,13 @@ export class Engine {
1345
2083
  inst.shadowDrawCalls.push(d);
1346
2084
  }
1347
2085
  }
1348
- createMaterialUniformBuffer(label, alpha, diffuseColor, ambientColor, specularColor, shininess) {
1349
- const data = new Float32Array(20);
1350
- data.set([
1351
- alpha,
1352
- this.rimLightIntensity,
1353
- shininess,
1354
- 0.0,
1355
- 1.0, 1.0, 1.0, 0.0, // rimColor (vec3), _padding2
1356
- diffuseColor[0], diffuseColor[1], diffuseColor[2], 0.0,
1357
- ambientColor[0], ambientColor[1], ambientColor[2], 0.0,
1358
- specularColor[0], specularColor[1], specularColor[2], 0.0,
1359
- ]);
2086
+ createMaterialUniformBuffer(label, alpha, diffuseColor) {
2087
+ // Matches WGSL `struct MaterialUniforms { diffuseColor: vec3f, alpha: f32 }` — 16 bytes.
2088
+ const data = new Float32Array(4);
2089
+ data[0] = diffuseColor[0];
2090
+ data[1] = diffuseColor[1];
2091
+ data[2] = diffuseColor[2];
2092
+ data[3] = alpha;
1360
2093
  return this.createUniformBuffer(`material uniform: ${label}`, data);
1361
2094
  }
1362
2095
  createUniformBuffer(label, data) {
@@ -1371,31 +2104,30 @@ export class Engine {
1371
2104
  shouldRenderDrawCall(inst, drawCall) {
1372
2105
  return !inst.hiddenMaterials.has(drawCall.materialName);
1373
2106
  }
1374
- async createTextureFromPath(path) {
1375
- const cached = this.textureCache.get(path);
2107
+ async createTextureFromLogicalPath(inst, logicalPath) {
2108
+ const cacheKey = logicalPath;
2109
+ const cached = this.textureCache.get(cacheKey);
1376
2110
  if (cached) {
1377
2111
  return cached;
1378
2112
  }
1379
2113
  try {
1380
- const response = await fetch(path);
1381
- if (!response.ok) {
1382
- throw new Error(`HTTP ${response.status}: ${response.statusText}`);
1383
- }
1384
- const imageBitmap = await createImageBitmap(await response.blob(), {
2114
+ const buffer = await inst.assetReader.readBinary(logicalPath);
2115
+ const imageBitmap = await createImageBitmap(new Blob([buffer]), {
1385
2116
  premultiplyAlpha: "none",
1386
2117
  colorSpaceConversion: "none",
1387
2118
  });
1388
2119
  const texture = this.device.createTexture({
1389
- label: `texture: ${path}`,
2120
+ label: `texture: ${cacheKey}`,
1390
2121
  size: [imageBitmap.width, imageBitmap.height],
1391
- format: "rgba8unorm",
2122
+ format: "rgba8unorm-srgb",
1392
2123
  usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
1393
2124
  });
1394
2125
  this.device.queue.copyExternalImageToTexture({ source: imageBitmap }, { texture }, [
1395
2126
  imageBitmap.width,
1396
2127
  imageBitmap.height,
1397
2128
  ]);
1398
- this.textureCache.set(path, texture);
2129
+ this.textureCache.set(cacheKey, texture);
2130
+ inst.textureCacheKeys.push(cacheKey);
1399
2131
  return texture;
1400
2132
  }
1401
2133
  catch {
@@ -1423,12 +2155,14 @@ export class Engine {
1423
2155
  if (!this.pendingPick || !this.pickTexture || !this.pickDepthTexture)
1424
2156
  return;
1425
2157
  const pass = encoder.beginRenderPass({
1426
- colorAttachments: [{
2158
+ colorAttachments: [
2159
+ {
1427
2160
  view: this.pickTexture.createView(),
1428
2161
  clearValue: { r: 0, g: 0, b: 0, a: 0 },
1429
2162
  loadOp: "clear",
1430
2163
  storeOp: "store",
1431
- }],
2164
+ },
2165
+ ],
1432
2166
  depthStencilAttachment: {
1433
2167
  view: this.pickDepthTexture.createView(),
1434
2168
  depthClearValue: 1.0,
@@ -1503,7 +2237,6 @@ export class Engine {
1503
2237
  const currentTime = performance.now();
1504
2238
  const deltaTime = this.lastFrameTime > 0 ? (currentTime - this.lastFrameTime) / 1000 : 0.016;
1505
2239
  this.lastFrameTime = currentTime;
1506
- this.updateRenderTarget();
1507
2240
  const hasModels = this.modelInstances.size > 0;
1508
2241
  if (hasModels) {
1509
2242
  this.updateInstances(deltaTime);
@@ -1520,10 +2253,9 @@ export class Engine {
1520
2253
  }
1521
2254
  }
1522
2255
  this.updateCameraUniforms();
1523
- if (this.hasGround)
1524
- this.updateShadowLightVP();
2256
+ this.updateShadowLightVP();
1525
2257
  const encoder = this.device.createCommandEncoder();
1526
- if (hasModels && this.hasGround && this.shadowMapDepthView) {
2258
+ if (hasModels) {
1527
2259
  const sp = encoder.beginRenderPass({
1528
2260
  colorAttachments: [],
1529
2261
  depthStencilAttachment: {
@@ -1543,6 +2275,51 @@ export class Engine {
1543
2275
  if (this.hasGround)
1544
2276
  this.renderGround(pass);
1545
2277
  pass.end();
2278
+ // Bloom pyramid (EEVEE 3.6):
2279
+ // 1. Blit: HDR → bloomDown[0] (Karis prefilter, half-res)
2280
+ // 2. Downsample: bloomDown[0] → bloomDown[1] → … → bloomDown[N-1] (13-tap)
2281
+ // 3. Upsample (top-down): bloomUp[N-2] = tent(bloomDown[N-1]) + bloomDown[N-2],
2282
+ // then bloomUp[i] = tent(bloomUp[i+1]) + bloomDown[i] until i=0 (9-tap tent)
2283
+ // Composite reads bloomUp[0] and adds tint * intensity * bloom before Filmic.
2284
+ if (this.bloomBlitBindGroup && this.compositeBindGroup && this.bloomMipCount > 0) {
2285
+ const bloomAtt = this.bloomPassDescriptor.colorAttachments;
2286
+ // 1. Blit
2287
+ bloomAtt[0].view = this.bloomDownMipViews[0];
2288
+ const pBlit = encoder.beginRenderPass(this.bloomPassDescriptor);
2289
+ pBlit.setPipeline(this.bloomBlitPipeline);
2290
+ pBlit.setBindGroup(0, this.bloomBlitBindGroup);
2291
+ pBlit.draw(3);
2292
+ pBlit.end();
2293
+ // 2. Downsample chain
2294
+ for (let i = 1; i < this.bloomMipCount; i++) {
2295
+ bloomAtt[0].view = this.bloomDownMipViews[i];
2296
+ const p = encoder.beginRenderPass(this.bloomPassDescriptor);
2297
+ p.setPipeline(this.bloomDownsamplePipeline);
2298
+ p.setBindGroup(0, this.bloomDownsampleBindGroups[i - 1]);
2299
+ p.draw(3);
2300
+ p.end();
2301
+ }
2302
+ // 3. Upsample chain (coarsest to finest; bindGroups[0] is the coarsest step)
2303
+ const upSteps = this.bloomUpsampleBindGroups.length;
2304
+ const topIdx = this.bloomMipCount - 2;
2305
+ for (let k = 0; k < upSteps; k++) {
2306
+ const levelIdx = topIdx - k; // writes bloomUp[levelIdx]
2307
+ bloomAtt[0].view = this.bloomUpMipViews[levelIdx];
2308
+ const p = encoder.beginRenderPass(this.bloomPassDescriptor);
2309
+ p.setPipeline(this.bloomUpsamplePipeline);
2310
+ p.setBindGroup(0, this.bloomUpsampleBindGroups[k]);
2311
+ p.draw(3);
2312
+ p.end();
2313
+ }
2314
+ }
2315
+ // Composite: HDR + bloom → Filmic tonemap → swapchain.
2316
+ const compositeAttachment = this.compositePassDescriptor.colorAttachments[0];
2317
+ compositeAttachment.view = this.context.getCurrentTexture().createView();
2318
+ const cpass = encoder.beginRenderPass(this.compositePassDescriptor);
2319
+ cpass.setPipeline(this.compositePipeline);
2320
+ cpass.setBindGroup(0, this.compositeBindGroup);
2321
+ cpass.draw(3);
2322
+ cpass.end();
1546
2323
  const pick = this.pendingPick;
1547
2324
  if (pick && hasModels)
1548
2325
  this.renderPickPass(encoder);
@@ -1554,10 +2331,6 @@ export class Engine {
1554
2331
  }
1555
2332
  this.updateStats(performance.now() - currentTime);
1556
2333
  }
1557
- updateRenderTarget() {
1558
- const colorAttachment = this.renderPassDescriptor.colorAttachments[0];
1559
- colorAttachment.resolveTarget = this.context.getCurrentTexture().createView();
1560
- }
1561
2334
  drawInstanceShadow(sp, inst) {
1562
2335
  sp.setBindGroup(0, inst.shadowBindGroup);
1563
2336
  sp.setVertexBuffer(0, inst.vertexBuffer);
@@ -1569,39 +2342,87 @@ export class Engine {
1569
2342
  sp.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1570
2343
  }
1571
2344
  }
1572
- drawOpaque(pass, inst, pipeline) {
1573
- pass.setPipeline(pipeline);
2345
+ pipelineForPreset(preset) {
2346
+ if (preset === "face")
2347
+ return this.facePipeline;
2348
+ if (preset === "hair")
2349
+ return this.hairPipeline;
2350
+ if (preset === "cloth_smooth")
2351
+ return this.clothSmoothPipeline;
2352
+ if (preset === "cloth_rough")
2353
+ return this.clothRoughPipeline;
2354
+ if (preset === "metal")
2355
+ return this.metalPipeline;
2356
+ if (preset === "body")
2357
+ return this.bodyPipeline;
2358
+ if (preset === "eye")
2359
+ return this.eyePipeline;
2360
+ if (preset === "stockings")
2361
+ return this.stockingsPipeline;
2362
+ return this.modelPipeline;
2363
+ }
2364
+ /**
2365
+ * Draw every material of a given type (`opaque` or `transparent`) using the main
2366
+ * pipeline(s). Binds the per-frame and per-instance groups once at the top of the
2367
+ * batch, then issues one draw per material. Early-outs if nothing to draw so we
2368
+ * don't waste bindings when a model has no transparents, etc.
2369
+ */
2370
+ drawMaterials(pass, inst, type) {
2371
+ let currentPipeline = null;
2372
+ let bound = false;
1574
2373
  for (const draw of inst.drawCalls) {
1575
- if (draw.type === "opaque" && this.shouldRenderDrawCall(inst, draw)) {
1576
- pass.setBindGroup(2, draw.bindGroup);
1577
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
2374
+ if (draw.type !== type || !this.shouldRenderDrawCall(inst, draw))
2375
+ continue;
2376
+ if (!bound) {
2377
+ pass.setBindGroup(0, this.perFrameBindGroup);
2378
+ pass.setBindGroup(1, inst.mainPerInstanceBindGroup);
2379
+ bound = true;
2380
+ }
2381
+ const pipeline = this.pipelineForPreset(draw.preset);
2382
+ if (pipeline !== currentPipeline) {
2383
+ pass.setPipeline(pipeline);
2384
+ currentPipeline = pipeline;
1578
2385
  }
2386
+ pass.setBindGroup(2, draw.bindGroup);
2387
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1579
2388
  }
1580
2389
  }
1581
- drawTransparent(pass, inst, pipeline) {
1582
- pass.setPipeline(pipeline);
2390
+ /**
2391
+ * Draw every outline of a given type (`opaque-outline` or `transparent-outline`).
2392
+ * Uses its own pipeline layout (group 0 = camera-only, group 2 = edge uniforms), so
2393
+ * every batch binds its own groups from scratch — the next drawMaterials call will
2394
+ * rebind group 0/1 correctly if needed.
2395
+ */
2396
+ drawOutlines(pass, inst, type) {
2397
+ let bound = false;
1583
2398
  for (const draw of inst.drawCalls) {
1584
- if (draw.type === "transparent" && this.shouldRenderDrawCall(inst, draw)) {
1585
- pass.setBindGroup(2, draw.bindGroup);
1586
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
2399
+ if (draw.type !== type || !this.shouldRenderDrawCall(inst, draw))
2400
+ continue;
2401
+ if (!bound) {
2402
+ pass.setPipeline(this.outlinePipeline);
2403
+ pass.setBindGroup(0, this.outlinePerFrameBindGroup);
2404
+ pass.setBindGroup(1, inst.mainPerInstanceBindGroup);
2405
+ bound = true;
1587
2406
  }
2407
+ pass.setBindGroup(2, draw.bindGroup);
2408
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1588
2409
  }
1589
2410
  }
1590
- bindMainGroups(pass, inst) {
1591
- pass.setBindGroup(0, this.perFrameBindGroup);
1592
- pass.setBindGroup(1, inst.mainPerInstanceBindGroup);
1593
- }
2411
+ /**
2412
+ * Main-pass render sequence for one model instance:
2413
+ * 1) opaque bodies → 2) opaque outlines → 3) transparents → 4) transparent outlines.
2414
+ * Each batch binds the groups it needs, so switching between main and outline
2415
+ * pipelines is self-contained (no cross-batch dependencies).
2416
+ */
1594
2417
  renderOneModel(pass, inst) {
1595
2418
  pass.setVertexBuffer(0, inst.vertexBuffer);
1596
2419
  pass.setVertexBuffer(1, inst.jointsBuffer);
1597
2420
  pass.setVertexBuffer(2, inst.weightsBuffer);
1598
2421
  pass.setIndexBuffer(inst.indexBuffer, "uint32");
1599
- this.bindMainGroups(pass, inst);
1600
- this.drawOpaque(pass, inst, this.modelPipeline);
1601
- this.drawOutlines(pass, inst, false);
1602
- this.bindMainGroups(pass, inst);
1603
- this.drawTransparent(pass, inst, this.modelPipeline);
1604
- this.drawOutlines(pass, inst, true);
2422
+ this.drawMaterials(pass, inst, "opaque");
2423
+ this.drawOutlines(pass, inst, "opaque-outline");
2424
+ this.drawMaterials(pass, inst, "transparent");
2425
+ this.drawOutlines(pass, inst, "transparent-outline");
1605
2426
  }
1606
2427
  updateCameraUniforms() {
1607
2428
  const viewMatrix = this.camera.getViewMatrix();
@@ -1620,18 +2441,6 @@ export class Engine {
1620
2441
  this.device.queue.writeBuffer(inst.skinMatrixBuffer, 0, skinMatrices.buffer, skinMatrices.byteOffset, skinMatrices.byteLength);
1621
2442
  });
1622
2443
  }
1623
- drawOutlines(pass, inst, transparent) {
1624
- pass.setPipeline(this.outlinePipeline);
1625
- pass.setBindGroup(0, this.outlinePerFrameBindGroup);
1626
- pass.setBindGroup(1, inst.mainPerInstanceBindGroup);
1627
- const outlineType = transparent ? "transparent-outline" : "opaque-outline";
1628
- for (const draw of inst.drawCalls) {
1629
- if (draw.type === outlineType && this.shouldRenderDrawCall(inst, draw)) {
1630
- pass.setBindGroup(2, draw.bindGroup);
1631
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1632
- }
1633
- }
1634
- }
1635
2444
  updateStats(frameTime) {
1636
2445
  // Simplified frame time tracking - rolling average with fixed window
1637
2446
  const maxSamples = 60;
@@ -1657,3 +2466,6 @@ export class Engine {
1657
2466
  }
1658
2467
  Engine.instance = null;
1659
2468
  Engine.MULTISAMPLE_COUNT = 4;
2469
+ Engine.HDR_FORMAT = "rgba16float";
2470
+ Engine.BLOOM_MAX_LEVELS = 7;
2471
+ Engine.SHADOW_MAP_SIZE = 4096;