reze-engine 0.10.2 → 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 (62) hide show
  1. package/README.md +72 -13
  2. package/dist/engine.d.ts +170 -34
  3. package/dist/engine.d.ts.map +1 -1
  4. package/dist/engine.js +1080 -308
  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 +209 -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 +172 -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 +171 -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 +168 -0
  23. package/dist/shaders/dfg_lut.d.ts +4 -0
  24. package/dist/shaders/dfg_lut.d.ts.map +1 -0
  25. package/dist/shaders/dfg_lut.js +125 -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 +142 -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 +211 -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 +186 -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 +171 -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 +423 -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 +229 -0
  47. package/package.json +1 -1
  48. package/src/engine.ts +1281 -376
  49. package/src/index.ts +12 -2
  50. package/src/shaders/body.ts +211 -0
  51. package/src/shaders/classify.ts +25 -0
  52. package/src/shaders/cloth_rough.ts +174 -0
  53. package/src/shaders/cloth_smooth.ts +173 -0
  54. package/src/shaders/default.ts +169 -0
  55. package/src/shaders/dfg_lut.ts +127 -0
  56. package/src/shaders/eye.ts +143 -0
  57. package/src/shaders/face.ts +213 -0
  58. package/src/shaders/hair.ts +188 -0
  59. package/src/shaders/ltc_mag_lut.ts +1035 -0
  60. package/src/shaders/metal.ts +173 -0
  61. package/src/shaders/nodes.ts +424 -0
  62. package/src/shaders/stockings.ts +231 -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 { 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
+ };
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,11 +48,19 @@ 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;
@@ -56,7 +85,7 @@ export class Engine {
56
85
  };
57
86
  this.animationFrameId = null;
58
87
  this.renderLoopCallback = null;
59
- // 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.
60
89
  this.shadowLightVPDirty = true;
61
90
  this.handleCanvasDoubleClick = (event) => {
62
91
  if (!this.onRaycast || this.modelInstances.size === 0)
@@ -90,20 +119,135 @@ export class Engine {
90
119
  }
91
120
  };
92
121
  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;
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();
105
231
  }
106
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
+ }
107
251
  // Step 1: Get WebGPU device and context
108
252
  async init() {
109
253
  const adapter = await navigator.gpu?.requestAdapter();
@@ -129,6 +273,69 @@ export class Engine {
129
273
  this.setupResize();
130
274
  Engine.instance = this;
131
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
+ }
132
339
  createRenderPipeline(config) {
133
340
  return this.device.createRenderPipeline({
134
341
  label: config.label,
@@ -192,8 +399,11 @@ export class Engine {
192
399
  attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" }],
193
400
  },
194
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.
195
405
  const standardBlend = {
196
- format: this.presentationFormat,
406
+ format: Engine.HDR_FORMAT,
197
407
  blend: {
198
408
  color: {
199
409
  srcFactor: "src-alpha",
@@ -208,145 +418,59 @@ export class Engine {
208
418
  },
209
419
  };
210
420
  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
- `,
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,
334
447
  });
335
- // 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
336
457
  this.mainPerFrameBindGroupLayout = this.device.createBindGroupLayout({
337
458
  label: "main per-frame bind group layout",
338
459
  entries: [
339
460
  { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
340
461
  { binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
341
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" } },
342
468
  ],
343
469
  });
344
470
  // group 1: per-instance (skinMats) — bound once per model
345
471
  this.mainPerInstanceBindGroupLayout = this.device.createBindGroupLayout({
346
472
  label: "main per-instance bind group layout",
347
- entries: [
348
- { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
349
- ],
473
+ entries: [{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } }],
350
474
  });
351
475
  // group 2: per-material (texture + material uniforms) — bound per draw call
352
476
  this.mainPerMaterialBindGroupLayout = this.device.createBindGroupLayout({
@@ -358,17 +482,13 @@ export class Engine {
358
482
  });
359
483
  const mainPipelineLayout = this.device.createPipelineLayout({
360
484
  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 },
485
+ bindGroupLayouts: [
486
+ this.mainPerFrameBindGroupLayout,
487
+ this.mainPerInstanceBindGroupLayout,
488
+ this.mainPerMaterialBindGroupLayout,
370
489
  ],
371
490
  });
491
+ // perFrameBindGroup is created after shadow resources below
372
492
  this.modelPipeline = this.createRenderPipeline({
373
493
  label: "model pipeline",
374
494
  layout: mainPipelineLayout,
@@ -382,6 +502,110 @@ export class Engine {
382
502
  depthCompare: "less-equal",
383
503
  },
384
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
+ });
385
609
  this.shadowLightVPBuffer = this.device.createBuffer({
386
610
  size: 64,
387
611
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
@@ -430,6 +654,32 @@ export class Engine {
430
654
  magFilter: "linear",
431
655
  minFilter: "linear",
432
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
+ });
433
683
  this.groundShadowBindGroupLayout = this.device.createBindGroupLayout({
434
684
  label: "ground shadow layout",
435
685
  entries: [
@@ -558,14 +808,16 @@ export class Engine {
558
808
  });
559
809
  const outlinePipelineLayout = this.device.createPipelineLayout({
560
810
  label: "outline pipeline layout",
561
- bindGroupLayouts: [this.outlinePerFrameBindGroupLayout, this.mainPerInstanceBindGroupLayout, this.outlinePerMaterialBindGroupLayout],
811
+ bindGroupLayouts: [
812
+ this.outlinePerFrameBindGroupLayout,
813
+ this.mainPerInstanceBindGroupLayout,
814
+ this.outlinePerMaterialBindGroupLayout,
815
+ ],
562
816
  });
563
817
  this.outlinePerFrameBindGroup = this.device.createBindGroup({
564
818
  label: "outline per-frame bind group",
565
819
  layout: this.outlinePerFrameBindGroupLayout,
566
- entries: [
567
- { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
568
- ],
820
+ entries: [{ binding: 0, resource: { buffer: this.cameraUniformBuffer } }],
569
821
  });
570
822
  const outlineShaderModule = this.device.createShaderModule({
571
823
  label: "outline shaders",
@@ -621,12 +873,27 @@ export class Engine {
621
873
  }
622
874
  let worldPos = skinnedPos.xyz;
623
875
  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);
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);
630
897
  return output;
631
898
  }
632
899
 
@@ -649,6 +916,277 @@ export class Engine {
649
916
  depthCompare: "less-equal",
650
917
  },
651
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
+ };
652
1190
  // GPU picking: encode (modelIndex, materialIndex) as color
653
1191
  const pickShaderModule = this.device.createShaderModule({
654
1192
  label: "pick shader",
@@ -693,32 +1231,28 @@ export class Engine {
693
1231
  });
694
1232
  this.pickPerFrameBindGroupLayout = this.device.createBindGroupLayout({
695
1233
  label: "pick per-frame layout",
696
- entries: [
697
- { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
698
- ],
1234
+ entries: [{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } }],
699
1235
  });
700
1236
  this.pickPerInstanceBindGroupLayout = this.device.createBindGroupLayout({
701
1237
  label: "pick per-instance layout",
702
- entries: [
703
- { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
704
- ],
1238
+ entries: [{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } }],
705
1239
  });
706
1240
  this.pickPerMaterialBindGroupLayout = this.device.createBindGroupLayout({
707
1241
  label: "pick per-material layout",
708
- entries: [
709
- { binding: 0, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
710
- ],
1242
+ entries: [{ binding: 0, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }],
711
1243
  });
712
1244
  const pickPipelineLayout = this.device.createPipelineLayout({
713
1245
  label: "pick pipeline layout",
714
- bindGroupLayouts: [this.pickPerFrameBindGroupLayout, this.pickPerInstanceBindGroupLayout, this.pickPerMaterialBindGroupLayout],
1246
+ bindGroupLayouts: [
1247
+ this.pickPerFrameBindGroupLayout,
1248
+ this.pickPerInstanceBindGroupLayout,
1249
+ this.pickPerMaterialBindGroupLayout,
1250
+ ],
715
1251
  });
716
1252
  this.pickPerFrameBindGroup = this.device.createBindGroup({
717
1253
  label: "pick per-frame bind group",
718
1254
  layout: this.pickPerFrameBindGroupLayout,
719
- entries: [
720
- { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
721
- ],
1255
+ entries: [{ binding: 0, resource: { buffer: this.cameraUniformBuffer } }],
722
1256
  });
723
1257
  this.pickPipeline = this.device.createRenderPipeline({
724
1258
  label: "pick pipeline",
@@ -762,12 +1296,47 @@ export class Engine {
762
1296
  this.canvas.width = width;
763
1297
  this.canvas.height = height;
764
1298
  this.multisampleTexture = this.device.createTexture({
765
- label: "multisample render target",
1299
+ label: "multisample HDR render target",
766
1300
  size: [width, height],
767
1301
  sampleCount: Engine.MULTISAMPLE_COUNT,
768
- format: this.presentationFormat,
1302
+ format: Engine.HDR_FORMAT,
769
1303
  usage: GPUTextureUsage.RENDER_ATTACHMENT,
770
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
+ }
771
1340
  this.depthTexture = this.device.createTexture({
772
1341
  label: "depth texture",
773
1342
  size: [width, height],
@@ -778,7 +1347,7 @@ export class Engine {
778
1347
  const depthTextureView = this.depthTexture.createView();
779
1348
  const colorAttachment = {
780
1349
  view: this.multisampleTexture.createView(),
781
- resolveTarget: this.context.getCurrentTexture().createView(),
1350
+ resolveTarget: this.hdrResolveTexture.createView(),
782
1351
  clearValue: { r: 0, g: 0, b: 0, a: 0 },
783
1352
  loadOp: "clear",
784
1353
  storeOp: "store",
@@ -796,6 +1365,72 @@ export class Engine {
796
1365
  stencilStoreOp: "discard",
797
1366
  },
798
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();
799
1434
  this.camera.aspect = width / height;
800
1435
  if (this.onRaycast) {
801
1436
  this.pickTexture = this.device.createTexture({
@@ -820,7 +1455,7 @@ export class Engine {
820
1455
  size: 40 * 4,
821
1456
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
822
1457
  });
823
- 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);
824
1459
  this.camera.aspect = this.canvas.width / this.canvas.height;
825
1460
  this.camera.attachControl(this.canvas);
826
1461
  }
@@ -854,58 +1489,100 @@ export class Engine {
854
1489
  this.cameraTargetOffset.y = offset?.y ?? 0;
855
1490
  this.cameraTargetOffset.z = offset?.z ?? 0;
856
1491
  }
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; }
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
+ }
863
1510
  // Step 5: Create lighting buffers
864
1511
  setupLighting() {
865
1512
  this.lightUniformBuffer = this.device.createBuffer({
866
1513
  label: "light uniforms",
867
- 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
868
1515
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
869
1516
  });
870
- // Initialize light buffer to zeros
871
1517
  this.lightData.fill(0);
872
1518
  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
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;
882
1533
  this.updateLightBuffer();
883
1534
  }
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++;
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;
898
1551
  this.updateLightBuffer();
899
- 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;
900
1578
  }
901
1579
  addGround(options) {
902
1580
  const opts = {
903
1581
  width: 160,
904
1582
  height: 160,
905
- diffuseColor: new Vec3(0.8, 0.1, 1.0),
1583
+ diffuseColor: new Vec3(0.9, 0.1, 1.0),
906
1584
  fadeStart: 10.0,
907
1585
  fadeEnd: 80.0,
908
- shadowMapSize: 4096,
909
1586
  shadowStrength: 1.0,
910
1587
  gridSpacing: 4.2,
911
1588
  gridLineWidth: 0.012,
@@ -923,6 +1600,7 @@ export class Engine {
923
1600
  firstIndex: 0,
924
1601
  bindGroup: this.groundShadowBindGroup,
925
1602
  materialName: "Ground",
1603
+ preset: "cloth_rough",
926
1604
  };
927
1605
  }
928
1606
  updateLightBuffer() {
@@ -1039,6 +1717,15 @@ export class Engine {
1039
1717
  }
1040
1718
  }
1041
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
+ }
1042
1729
  setMaterialVisible(modelName, materialName, visible) {
1043
1730
  const inst = this.modelInstances.get(modelName);
1044
1731
  if (!inst)
@@ -1147,24 +1834,14 @@ export class Engine {
1147
1834
  const mainPerInstanceBindGroup = this.device.createBindGroup({
1148
1835
  label: `${name}: main per-instance bind group`,
1149
1836
  layout: this.mainPerInstanceBindGroupLayout,
1150
- entries: [
1151
- { binding: 0, resource: { buffer: skinMatrixBuffer } },
1152
- ],
1837
+ entries: [{ binding: 0, resource: { buffer: skinMatrixBuffer } }],
1153
1838
  });
1154
1839
  const pickPerInstanceBindGroup = this.device.createBindGroup({
1155
1840
  label: `${name}: pick per-instance bind group`,
1156
1841
  layout: this.pickPerInstanceBindGroupLayout,
1157
- entries: [
1158
- { binding: 0, resource: { buffer: skinMatrixBuffer } },
1159
- ],
1842
+ entries: [{ binding: 0, resource: { buffer: skinMatrixBuffer } }],
1160
1843
  });
1161
- const gpuBuffers = [
1162
- vertexBuffer,
1163
- indexBuffer,
1164
- jointsBuffer,
1165
- weightsBuffer,
1166
- skinMatrixBuffer,
1167
- ];
1844
+ const gpuBuffers = [vertexBuffer, indexBuffer, jointsBuffer, weightsBuffer, skinMatrixBuffer];
1168
1845
  const inst = {
1169
1846
  name,
1170
1847
  model,
@@ -1184,6 +1861,7 @@ export class Engine {
1184
1861
  pickPerInstanceBindGroup,
1185
1862
  pickDrawCalls: [],
1186
1863
  hiddenMaterials: new Set(),
1864
+ materialPresets: undefined,
1187
1865
  physics,
1188
1866
  vertexBufferNeedsUpdate: false,
1189
1867
  };
@@ -1255,15 +1933,8 @@ export class Engine {
1255
1933
  this.device.queue.writeBuffer(this.groundIndexBuffer, 0, indices);
1256
1934
  }
1257
1935
  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)
1936
+ const { diffuseColor, fadeStart, fadeEnd, shadowStrength, gridSpacing, gridLineWidth, gridLineOpacity, gridLineColor, noiseStrength, } = opts;
1937
+ // Shadow map is already created in setupPipelines()
1267
1938
  const gb = new Float32Array(16);
1268
1939
  gb[0] = diffuseColor.x;
1269
1940
  gb[1] = diffuseColor.y;
@@ -1271,7 +1942,7 @@ export class Engine {
1271
1942
  gb[3] = fadeStart;
1272
1943
  gb[4] = fadeEnd;
1273
1944
  gb[5] = shadowStrength;
1274
- gb[6] = 1 / shadowMapSize;
1945
+ gb[6] = 1 / Engine.SHADOW_MAP_SIZE;
1275
1946
  gb[7] = gridSpacing;
1276
1947
  gb[8] = gridLineWidth;
1277
1948
  gb[9] = gridLineOpacity;
@@ -1281,7 +1952,10 @@ export class Engine {
1281
1952
  gb[13] = gridLineColor.y;
1282
1953
  gb[14] = gridLineColor.z;
1283
1954
  gb[15] = 0;
1284
- 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
+ });
1285
1959
  this.device.queue.writeBuffer(this.groundShadowMaterialBuffer, 0, gb);
1286
1960
  this.groundShadowBindGroup = this.device.createBindGroup({
1287
1961
  label: "ground shadow bind",
@@ -1300,7 +1974,7 @@ export class Engine {
1300
1974
  if (!this.shadowLightVPDirty)
1301
1975
  return;
1302
1976
  this.shadowLightVPDirty = false;
1303
- 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);
1304
1978
  dir.normalize();
1305
1979
  const target = new Vec3(0, 11, 0);
1306
1980
  const eye = new Vec3(target.x - dir.x * 72, target.y - dir.y * 72, target.z - dir.z * 72);
@@ -1338,7 +2012,11 @@ export class Engine {
1338
2012
  throw new Error(`Material "${mat.name}" has no diffuse texture`);
1339
2013
  const materialAlpha = mat.diffuse[3];
1340
2014
  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);
2015
+ const materialUniformBuffer = this.createMaterialUniformBuffer(prefix + mat.name, materialAlpha, [
2016
+ mat.diffuse[0],
2017
+ mat.diffuse[1],
2018
+ mat.diffuse[2],
2019
+ ]);
1342
2020
  inst.gpuBuffers.push(materialUniformBuffer);
1343
2021
  const textureView = diffuseTexture.createView();
1344
2022
  const bindGroup = this.device.createBindGroup({
@@ -1350,23 +2028,42 @@ export class Engine {
1350
2028
  ],
1351
2029
  });
1352
2030
  const type = isTransparent ? "transparent" : "opaque";
1353
- 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
+ });
1354
2040
  if ((mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
1355
2041
  const materialUniformData = new Float32Array([
1356
- mat.edgeColor[0], mat.edgeColor[1], mat.edgeColor[2], mat.edgeColor[3],
1357
- 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,
1358
2050
  ]);
1359
2051
  const outlineUniformBuffer = this.createUniformBuffer(`${prefix}outline: ${mat.name}`, materialUniformData);
1360
2052
  inst.gpuBuffers.push(outlineUniformBuffer);
1361
2053
  const outlineBindGroup = this.device.createBindGroup({
1362
2054
  label: `${prefix}outline: ${mat.name}`,
1363
2055
  layout: this.outlinePerMaterialBindGroupLayout,
1364
- entries: [
1365
- { binding: 0, resource: { buffer: outlineUniformBuffer } },
1366
- ],
2056
+ entries: [{ binding: 0, resource: { buffer: outlineUniformBuffer } }],
1367
2057
  });
1368
2058
  const outlineType = isTransparent ? "transparent-outline" : "opaque-outline";
1369
- 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
+ });
1370
2067
  }
1371
2068
  if (this.onRaycast) {
1372
2069
  const pickIdData = new Float32Array([modelId, materialId, 0, 0]);
@@ -1386,18 +2083,13 @@ export class Engine {
1386
2083
  inst.shadowDrawCalls.push(d);
1387
2084
  }
1388
2085
  }
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
- ]);
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;
1401
2093
  return this.createUniformBuffer(`material uniform: ${label}`, data);
1402
2094
  }
1403
2095
  createUniformBuffer(label, data) {
@@ -1427,7 +2119,7 @@ export class Engine {
1427
2119
  const texture = this.device.createTexture({
1428
2120
  label: `texture: ${cacheKey}`,
1429
2121
  size: [imageBitmap.width, imageBitmap.height],
1430
- format: "rgba8unorm",
2122
+ format: "rgba8unorm-srgb",
1431
2123
  usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
1432
2124
  });
1433
2125
  this.device.queue.copyExternalImageToTexture({ source: imageBitmap }, { texture }, [
@@ -1463,12 +2155,14 @@ export class Engine {
1463
2155
  if (!this.pendingPick || !this.pickTexture || !this.pickDepthTexture)
1464
2156
  return;
1465
2157
  const pass = encoder.beginRenderPass({
1466
- colorAttachments: [{
2158
+ colorAttachments: [
2159
+ {
1467
2160
  view: this.pickTexture.createView(),
1468
2161
  clearValue: { r: 0, g: 0, b: 0, a: 0 },
1469
2162
  loadOp: "clear",
1470
2163
  storeOp: "store",
1471
- }],
2164
+ },
2165
+ ],
1472
2166
  depthStencilAttachment: {
1473
2167
  view: this.pickDepthTexture.createView(),
1474
2168
  depthClearValue: 1.0,
@@ -1543,7 +2237,6 @@ export class Engine {
1543
2237
  const currentTime = performance.now();
1544
2238
  const deltaTime = this.lastFrameTime > 0 ? (currentTime - this.lastFrameTime) / 1000 : 0.016;
1545
2239
  this.lastFrameTime = currentTime;
1546
- this.updateRenderTarget();
1547
2240
  const hasModels = this.modelInstances.size > 0;
1548
2241
  if (hasModels) {
1549
2242
  this.updateInstances(deltaTime);
@@ -1560,10 +2253,9 @@ export class Engine {
1560
2253
  }
1561
2254
  }
1562
2255
  this.updateCameraUniforms();
1563
- if (this.hasGround)
1564
- this.updateShadowLightVP();
2256
+ this.updateShadowLightVP();
1565
2257
  const encoder = this.device.createCommandEncoder();
1566
- if (hasModels && this.hasGround && this.shadowMapDepthView) {
2258
+ if (hasModels) {
1567
2259
  const sp = encoder.beginRenderPass({
1568
2260
  colorAttachments: [],
1569
2261
  depthStencilAttachment: {
@@ -1583,6 +2275,51 @@ export class Engine {
1583
2275
  if (this.hasGround)
1584
2276
  this.renderGround(pass);
1585
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();
1586
2323
  const pick = this.pendingPick;
1587
2324
  if (pick && hasModels)
1588
2325
  this.renderPickPass(encoder);
@@ -1594,10 +2331,6 @@ export class Engine {
1594
2331
  }
1595
2332
  this.updateStats(performance.now() - currentTime);
1596
2333
  }
1597
- updateRenderTarget() {
1598
- const colorAttachment = this.renderPassDescriptor.colorAttachments[0];
1599
- colorAttachment.resolveTarget = this.context.getCurrentTexture().createView();
1600
- }
1601
2334
  drawInstanceShadow(sp, inst) {
1602
2335
  sp.setBindGroup(0, inst.shadowBindGroup);
1603
2336
  sp.setVertexBuffer(0, inst.vertexBuffer);
@@ -1609,39 +2342,87 @@ export class Engine {
1609
2342
  sp.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1610
2343
  }
1611
2344
  }
1612
- drawOpaque(pass, inst, pipeline) {
1613
- 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;
1614
2373
  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);
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;
1618
2385
  }
2386
+ pass.setBindGroup(2, draw.bindGroup);
2387
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1619
2388
  }
1620
2389
  }
1621
- drawTransparent(pass, inst, pipeline) {
1622
- 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;
1623
2398
  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);
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;
1627
2406
  }
2407
+ pass.setBindGroup(2, draw.bindGroup);
2408
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1628
2409
  }
1629
2410
  }
1630
- bindMainGroups(pass, inst) {
1631
- pass.setBindGroup(0, this.perFrameBindGroup);
1632
- pass.setBindGroup(1, inst.mainPerInstanceBindGroup);
1633
- }
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
+ */
1634
2417
  renderOneModel(pass, inst) {
1635
2418
  pass.setVertexBuffer(0, inst.vertexBuffer);
1636
2419
  pass.setVertexBuffer(1, inst.jointsBuffer);
1637
2420
  pass.setVertexBuffer(2, inst.weightsBuffer);
1638
2421
  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);
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");
1645
2426
  }
1646
2427
  updateCameraUniforms() {
1647
2428
  const viewMatrix = this.camera.getViewMatrix();
@@ -1660,18 +2441,6 @@ export class Engine {
1660
2441
  this.device.queue.writeBuffer(inst.skinMatrixBuffer, 0, skinMatrices.buffer, skinMatrices.byteOffset, skinMatrices.byteLength);
1661
2442
  });
1662
2443
  }
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
2444
  updateStats(frameTime) {
1676
2445
  // Simplified frame time tracking - rolling average with fixed window
1677
2446
  const maxSamples = 60;
@@ -1697,3 +2466,6 @@ export class Engine {
1697
2466
  }
1698
2467
  Engine.instance = null;
1699
2468
  Engine.MULTISAMPLE_COUNT = 4;
2469
+ Engine.HDR_FORMAT = "rgba16float";
2470
+ Engine.BLOOM_MAX_LEVELS = 7;
2471
+ Engine.SHADOW_MAP_SIZE = 4096;