reze-engine 0.1.7 → 0.1.9

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.
package/dist/engine.js CHANGED
@@ -10,6 +10,9 @@ export class Engine {
10
10
  this.vertexCount = 0;
11
11
  this.resizeObserver = null;
12
12
  this.sampleCount = 4; // MSAA 4x
13
+ // Bloom settings
14
+ this.bloomThreshold = 0.3;
15
+ this.bloomIntensity = 0.13;
13
16
  this.currentModel = null;
14
17
  this.modelDir = "";
15
18
  this.physics = null;
@@ -60,6 +63,8 @@ export class Engine {
60
63
  this.setupCamera();
61
64
  this.setupLighting();
62
65
  this.createPipelines();
66
+ this.createFullscreenQuad();
67
+ this.createBloomPipelines();
63
68
  this.setupResize();
64
69
  }
65
70
  // Step 2: Create shaders and render pipelines
@@ -72,226 +77,225 @@ export class Engine {
72
77
  });
73
78
  const shaderModule = this.device.createShaderModule({
74
79
  label: "model shaders",
75
- code: /* wgsl */ `
76
- struct CameraUniforms {
77
- view: mat4x4f,
78
- projection: mat4x4f,
79
- viewPos: vec3f,
80
- _padding: f32,
81
- };
82
-
83
- struct Light {
84
- direction: vec3f,
85
- _padding1: f32,
86
- color: vec3f,
87
- intensity: f32,
88
- };
89
-
90
- struct LightUniforms {
91
- ambient: f32,
92
- lightCount: f32,
93
- _padding1: f32,
94
- _padding2: f32,
95
- lights: array<Light, 4>,
96
- };
97
-
98
- struct MaterialUniforms {
99
- alpha: f32,
100
- _padding1: f32,
101
- _padding2: f32,
102
- _padding3: f32,
103
- };
104
-
105
- struct VertexOutput {
106
- @builtin(position) position: vec4f,
107
- @location(0) normal: vec3f,
108
- @location(1) uv: vec2f,
109
- @location(2) worldPos: vec3f,
110
- };
111
-
112
- @group(0) @binding(0) var<uniform> camera: CameraUniforms;
113
- @group(0) @binding(1) var<uniform> light: LightUniforms;
114
- @group(0) @binding(2) var diffuseTexture: texture_2d<f32>;
115
- @group(0) @binding(3) var diffuseSampler: sampler;
116
- @group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
117
- @group(0) @binding(5) var toonTexture: texture_2d<f32>;
118
- @group(0) @binding(6) var toonSampler: sampler;
119
- @group(0) @binding(7) var<uniform> material: MaterialUniforms;
120
-
121
- @vertex fn vs(
122
- @location(0) position: vec3f,
123
- @location(1) normal: vec3f,
124
- @location(2) uv: vec2f,
125
- @location(3) joints0: vec4<u32>,
126
- @location(4) weights0: vec4<f32>
127
- ) -> VertexOutput {
128
- var output: VertexOutput;
129
- let pos4 = vec4f(position, 1.0);
130
-
131
- // Normalize weights to ensure they sum to 1.0 (handles floating-point precision issues)
132
- let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
133
- var normalizedWeights: vec4f;
134
- if (weightSum > 0.0001) {
135
- normalizedWeights = weights0 / weightSum;
136
- } else {
137
- normalizedWeights = vec4f(1.0, 0.0, 0.0, 0.0);
138
- }
139
-
140
- var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
141
- var skinnedNrm = vec3f(0.0, 0.0, 0.0);
142
- for (var i = 0u; i < 4u; i++) {
143
- let j = joints0[i];
144
- let w = normalizedWeights[i];
145
- let m = skinMats[j];
146
- skinnedPos += (m * pos4) * w;
147
- let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
148
- skinnedNrm += (r3 * normal) * w;
149
- }
150
- let worldPos = skinnedPos.xyz;
151
- output.position = camera.projection * camera.view * vec4f(worldPos, 1.0);
152
- output.normal = normalize(skinnedNrm);
153
- output.uv = uv;
154
- output.worldPos = worldPos;
155
- return output;
156
- }
157
-
158
- @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
159
- let n = normalize(input.normal);
160
- let albedo = textureSample(diffuseTexture, diffuseSampler, input.uv).rgb;
161
-
162
- var lightAccum = vec3f(light.ambient);
163
- let numLights = u32(light.lightCount);
164
- for (var i = 0u; i < numLights; i++) {
165
- let l = -light.lights[i].direction;
166
- let nDotL = max(dot(n, l), 0.0);
167
- let toonUV = vec2f(nDotL, 0.5);
168
- let toonFactor = textureSample(toonTexture, toonSampler, toonUV).rgb;
169
- let radiance = light.lights[i].color * light.lights[i].intensity;
170
- lightAccum += toonFactor * radiance * nDotL;
171
- }
172
-
173
- let color = albedo * lightAccum;
174
- let finalAlpha = material.alpha;
175
- if (finalAlpha < 0.001) {
176
- discard;
177
- }
178
-
179
- return vec4f(clamp(color, vec3f(0.0), vec3f(1.0)), finalAlpha);
180
- }
80
+ code: /* wgsl */ `
81
+ struct CameraUniforms {
82
+ view: mat4x4f,
83
+ projection: mat4x4f,
84
+ viewPos: vec3f,
85
+ _padding: f32,
86
+ };
87
+
88
+ struct Light {
89
+ direction: vec3f,
90
+ _padding1: f32,
91
+ color: vec3f,
92
+ intensity: f32,
93
+ };
94
+
95
+ struct LightUniforms {
96
+ ambient: f32,
97
+ lightCount: f32,
98
+ _padding1: f32,
99
+ _padding2: f32,
100
+ lights: array<Light, 4>,
101
+ };
102
+
103
+ struct MaterialUniforms {
104
+ alpha: f32,
105
+ _padding1: f32,
106
+ _padding2: f32,
107
+ _padding3: f32,
108
+ };
109
+
110
+ struct VertexOutput {
111
+ @builtin(position) position: vec4f,
112
+ @location(0) normal: vec3f,
113
+ @location(1) uv: vec2f,
114
+ @location(2) worldPos: vec3f,
115
+ };
116
+
117
+ @group(0) @binding(0) var<uniform> camera: CameraUniforms;
118
+ @group(0) @binding(1) var<uniform> light: LightUniforms;
119
+ @group(0) @binding(2) var diffuseTexture: texture_2d<f32>;
120
+ @group(0) @binding(3) var diffuseSampler: sampler;
121
+ @group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
122
+ @group(0) @binding(5) var toonTexture: texture_2d<f32>;
123
+ @group(0) @binding(6) var toonSampler: sampler;
124
+ @group(0) @binding(7) var<uniform> material: MaterialUniforms;
125
+
126
+ @vertex fn vs(
127
+ @location(0) position: vec3f,
128
+ @location(1) normal: vec3f,
129
+ @location(2) uv: vec2f,
130
+ @location(3) joints0: vec4<u32>,
131
+ @location(4) weights0: vec4<f32>
132
+ ) -> VertexOutput {
133
+ var output: VertexOutput;
134
+ let pos4 = vec4f(position, 1.0);
135
+
136
+ // Normalize weights to ensure they sum to 1.0 (handles floating-point precision issues)
137
+ let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
138
+ var normalizedWeights: vec4f;
139
+ if (weightSum > 0.0001) {
140
+ normalizedWeights = weights0 / weightSum;
141
+ } else {
142
+ normalizedWeights = vec4f(1.0, 0.0, 0.0, 0.0);
143
+ }
144
+
145
+ var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
146
+ var skinnedNrm = vec3f(0.0, 0.0, 0.0);
147
+ for (var i = 0u; i < 4u; i++) {
148
+ let j = joints0[i];
149
+ let w = normalizedWeights[i];
150
+ let m = skinMats[j];
151
+ skinnedPos += (m * pos4) * w;
152
+ let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
153
+ skinnedNrm += (r3 * normal) * w;
154
+ }
155
+ let worldPos = skinnedPos.xyz;
156
+ output.position = camera.projection * camera.view * vec4f(worldPos, 1.0);
157
+ output.normal = normalize(skinnedNrm);
158
+ output.uv = uv;
159
+ output.worldPos = worldPos;
160
+ return output;
161
+ }
162
+
163
+ @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
164
+ let n = normalize(input.normal);
165
+ let albedo = textureSample(diffuseTexture, diffuseSampler, input.uv).rgb;
166
+
167
+ var lightAccum = vec3f(light.ambient);
168
+ let numLights = u32(light.lightCount);
169
+ for (var i = 0u; i < numLights; i++) {
170
+ let l = -light.lights[i].direction;
171
+ let nDotL = max(dot(n, l), 0.0);
172
+ let toonUV = vec2f(nDotL, 0.5);
173
+ let toonFactor = textureSample(toonTexture, toonSampler, toonUV).rgb;
174
+ let radiance = light.lights[i].color * light.lights[i].intensity;
175
+ lightAccum += toonFactor * radiance * nDotL;
176
+ }
177
+
178
+ let color = albedo * lightAccum;
179
+ let finalAlpha = material.alpha;
180
+ if (finalAlpha < 0.001) {
181
+ discard;
182
+ }
183
+
184
+ return vec4f(clamp(color, vec3f(0.0), vec3f(1.0)), finalAlpha);
185
+ }
181
186
  `,
182
187
  });
183
188
  // Create a separate shader for hair-over-eyes that outputs pre-multiplied color for darkening effect
184
189
  const hairMultiplyShaderModule = this.device.createShaderModule({
185
190
  label: "hair multiply shaders",
186
- code: /* wgsl */ `
187
- struct CameraUniforms {
188
- view: mat4x4f,
189
- projection: mat4x4f,
190
- viewPos: vec3f,
191
- _padding: f32,
192
- };
193
-
194
- struct Light {
195
- direction: vec3f,
196
- _padding1: f32,
197
- color: vec3f,
198
- intensity: f32,
199
- };
200
-
201
- struct LightUniforms {
202
- ambient: f32,
203
- lightCount: f32,
204
- _padding1: f32,
205
- _padding2: f32,
206
- lights: array<Light, 4>,
207
- };
208
-
209
- struct MaterialUniforms {
210
- alpha: f32,
211
- _padding1: f32,
212
- _padding2: f32,
213
- _padding3: f32,
214
- };
215
-
216
- struct VertexOutput {
217
- @builtin(position) position: vec4f,
218
- @location(0) normal: vec3f,
219
- @location(1) uv: vec2f,
220
- @location(2) worldPos: vec3f,
221
- };
222
-
223
- @group(0) @binding(0) var<uniform> camera: CameraUniforms;
224
- @group(0) @binding(1) var<uniform> light: LightUniforms;
225
- @group(0) @binding(2) var diffuseTexture: texture_2d<f32>;
226
- @group(0) @binding(3) var diffuseSampler: sampler;
227
- @group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
228
- @group(0) @binding(5) var toonTexture: texture_2d<f32>;
229
- @group(0) @binding(6) var toonSampler: sampler;
230
- @group(0) @binding(7) var<uniform> material: MaterialUniforms;
231
-
232
- @vertex fn vs(
233
- @location(0) position: vec3f,
234
- @location(1) normal: vec3f,
235
- @location(2) uv: vec2f,
236
- @location(3) joints0: vec4<u32>,
237
- @location(4) weights0: vec4<f32>
238
- ) -> VertexOutput {
239
- var output: VertexOutput;
240
- let pos4 = vec4f(position, 1.0);
241
-
242
- let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
243
- var normalizedWeights: vec4f;
244
- if (weightSum > 0.0001) {
245
- normalizedWeights = weights0 / weightSum;
246
- } else {
247
- normalizedWeights = vec4f(1.0, 0.0, 0.0, 0.0);
248
- }
249
-
250
- var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
251
- var skinnedNrm = vec3f(0.0, 0.0, 0.0);
252
- for (var i = 0u; i < 4u; i++) {
253
- let j = joints0[i];
254
- let w = normalizedWeights[i];
255
- let m = skinMats[j];
256
- skinnedPos += (m * pos4) * w;
257
- let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
258
- skinnedNrm += (r3 * normal) * w;
259
- }
260
- let worldPos = skinnedPos.xyz;
261
- output.position = camera.projection * camera.view * vec4f(worldPos, 1.0);
262
- output.normal = normalize(skinnedNrm);
263
- output.uv = uv;
264
- output.worldPos = worldPos;
265
- return output;
266
- }
267
-
268
- @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
269
- let n = normalize(input.normal);
270
- let albedo = textureSample(diffuseTexture, diffuseSampler, input.uv).rgb;
271
-
272
- var lightAccum = vec3f(light.ambient);
273
- let numLights = u32(light.lightCount);
274
- for (var i = 0u; i < numLights; i++) {
275
- let l = -light.lights[i].direction;
276
- let nDotL = max(dot(n, l), 0.0);
277
- let toonUV = vec2f(nDotL, 0.5);
278
- let toonFactor = textureSample(toonTexture, toonSampler, toonUV).rgb;
279
- let radiance = light.lights[i].color * light.lights[i].intensity;
280
- lightAccum += toonFactor * radiance * nDotL;
281
- }
282
-
283
- let color = albedo * lightAccum;
284
- let finalAlpha = material.alpha;
285
- if (finalAlpha < 0.001) {
286
- discard;
287
- }
288
-
289
- // For hair-over-eyes effect: simple half-transparent overlay
290
- // Use 50% opacity to create a semi-transparent hair color overlay
291
- let overlayAlpha = finalAlpha * 0.5;
292
-
293
- return vec4f(clamp(color, vec3f(0.0), vec3f(1.0)), overlayAlpha);
294
- }
191
+ code: /* wgsl */ `
192
+ struct CameraUniforms {
193
+ view: mat4x4f,
194
+ projection: mat4x4f,
195
+ viewPos: vec3f,
196
+ _padding: f32,
197
+ };
198
+
199
+ struct Light {
200
+ direction: vec3f,
201
+ _padding1: f32,
202
+ color: vec3f,
203
+ intensity: f32,
204
+ };
205
+
206
+ struct LightUniforms {
207
+ ambient: f32,
208
+ lightCount: f32,
209
+ _padding1: f32,
210
+ _padding2: f32,
211
+ lights: array<Light, 4>,
212
+ };
213
+
214
+ struct MaterialUniforms {
215
+ alpha: f32,
216
+ _padding1: f32,
217
+ _padding2: f32,
218
+ _padding3: f32,
219
+ };
220
+
221
+ struct VertexOutput {
222
+ @builtin(position) position: vec4f,
223
+ @location(0) normal: vec3f,
224
+ @location(1) uv: vec2f,
225
+ @location(2) worldPos: vec3f,
226
+ };
227
+
228
+ @group(0) @binding(0) var<uniform> camera: CameraUniforms;
229
+ @group(0) @binding(1) var<uniform> light: LightUniforms;
230
+ @group(0) @binding(2) var diffuseTexture: texture_2d<f32>;
231
+ @group(0) @binding(3) var diffuseSampler: sampler;
232
+ @group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
233
+ @group(0) @binding(5) var toonTexture: texture_2d<f32>;
234
+ @group(0) @binding(6) var toonSampler: sampler;
235
+ @group(0) @binding(7) var<uniform> material: MaterialUniforms;
236
+
237
+ @vertex fn vs(
238
+ @location(0) position: vec3f,
239
+ @location(1) normal: vec3f,
240
+ @location(2) uv: vec2f,
241
+ @location(3) joints0: vec4<u32>,
242
+ @location(4) weights0: vec4<f32>
243
+ ) -> VertexOutput {
244
+ var output: VertexOutput;
245
+ let pos4 = vec4f(position, 1.0);
246
+
247
+ let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
248
+ var normalizedWeights: vec4f;
249
+ if (weightSum > 0.0001) {
250
+ normalizedWeights = weights0 / weightSum;
251
+ } else {
252
+ normalizedWeights = vec4f(1.0, 0.0, 0.0, 0.0);
253
+ }
254
+
255
+ var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
256
+ var skinnedNrm = vec3f(0.0, 0.0, 0.0);
257
+ for (var i = 0u; i < 4u; i++) {
258
+ let j = joints0[i];
259
+ let w = normalizedWeights[i];
260
+ let m = skinMats[j];
261
+ skinnedPos += (m * pos4) * w;
262
+ let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
263
+ skinnedNrm += (r3 * normal) * w;
264
+ }
265
+ let worldPos = skinnedPos.xyz;
266
+ output.position = camera.projection * camera.view * vec4f(worldPos, 1.0);
267
+ output.normal = normalize(skinnedNrm);
268
+ output.uv = uv;
269
+ output.worldPos = worldPos;
270
+ return output;
271
+ }
272
+
273
+ @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
274
+ let n = normalize(input.normal);
275
+ let albedo = textureSample(diffuseTexture, diffuseSampler, input.uv).rgb;
276
+
277
+ var lightAccum = vec3f(light.ambient);
278
+ let numLights = u32(light.lightCount);
279
+ for (var i = 0u; i < numLights; i++) {
280
+ let l = -light.lights[i].direction;
281
+ let nDotL = max(dot(n, l), 0.0);
282
+ let toonUV = vec2f(nDotL, 0.5);
283
+ let toonFactor = textureSample(toonTexture, toonSampler, toonUV).rgb;
284
+ let radiance = light.lights[i].color * light.lights[i].intensity;
285
+ lightAccum += toonFactor * radiance * nDotL;
286
+ }
287
+
288
+ let color = albedo * lightAccum;
289
+ let finalAlpha = material.alpha;
290
+ if (finalAlpha < 0.001) {
291
+ discard;
292
+ }
293
+
294
+ // For hair-over-eyes effect: simple half-transparent overlay - Use 50% opacity to create a semi-transparent hair color overlay
295
+ let overlayAlpha = finalAlpha * 0.5;
296
+
297
+ return vec4f(clamp(color, vec3f(0.0), vec3f(1.0)), overlayAlpha);
298
+ }
295
299
  `,
296
300
  });
297
301
  // Create explicit bind group layout for all pipelines using the main shader
@@ -383,72 +387,72 @@ export class Engine {
383
387
  });
384
388
  const outlineShaderModule = this.device.createShaderModule({
385
389
  label: "outline shaders",
386
- code: /* wgsl */ `
387
- struct CameraUniforms {
388
- view: mat4x4f,
389
- projection: mat4x4f,
390
- viewPos: vec3f,
391
- _padding: f32,
392
- };
393
-
394
- struct MaterialUniforms {
395
- edgeColor: vec4f,
396
- edgeSize: f32,
397
- _padding1: f32,
398
- _padding2: f32,
399
- _padding3: f32,
400
- };
401
-
402
- @group(0) @binding(0) var<uniform> camera: CameraUniforms;
403
- @group(0) @binding(1) var<uniform> material: MaterialUniforms;
404
- @group(0) @binding(2) var<storage, read> skinMats: array<mat4x4f>;
405
-
406
- struct VertexOutput {
407
- @builtin(position) position: vec4f,
408
- };
409
-
410
- @vertex fn vs(
411
- @location(0) position: vec3f,
412
- @location(1) normal: vec3f,
413
- @location(2) uv: vec2f,
414
- @location(3) joints0: vec4<u32>,
415
- @location(4) weights0: vec4<f32>
416
- ) -> VertexOutput {
417
- var output: VertexOutput;
418
- let pos4 = vec4f(position, 1.0);
419
-
420
- // Normalize weights to ensure they sum to 1.0 (handles floating-point precision issues)
421
- let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
422
- var normalizedWeights: vec4f;
423
- if (weightSum > 0.0001) {
424
- normalizedWeights = weights0 / weightSum;
425
- } else {
426
- normalizedWeights = vec4f(1.0, 0.0, 0.0, 0.0);
427
- }
428
-
429
- var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
430
- var skinnedNrm = vec3f(0.0, 0.0, 0.0);
431
- for (var i = 0u; i < 4u; i++) {
432
- let j = joints0[i];
433
- let w = normalizedWeights[i];
434
- let m = skinMats[j];
435
- skinnedPos += (m * pos4) * w;
436
- let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
437
- skinnedNrm += (r3 * normal) * w;
438
- }
439
- let worldPos = skinnedPos.xyz;
440
- let worldNormal = normalize(skinnedNrm);
441
-
442
- // MMD invert hull: expand vertices outward along normals
443
- let scaleFactor = 0.01;
444
- let expandedPos = worldPos + worldNormal * material.edgeSize * scaleFactor;
445
- output.position = camera.projection * camera.view * vec4f(expandedPos, 1.0);
446
- return output;
447
- }
448
-
449
- @fragment fn fs() -> @location(0) vec4f {
450
- return material.edgeColor;
451
- }
390
+ code: /* wgsl */ `
391
+ struct CameraUniforms {
392
+ view: mat4x4f,
393
+ projection: mat4x4f,
394
+ viewPos: vec3f,
395
+ _padding: f32,
396
+ };
397
+
398
+ struct MaterialUniforms {
399
+ edgeColor: vec4f,
400
+ edgeSize: f32,
401
+ _padding1: f32,
402
+ _padding2: f32,
403
+ _padding3: f32,
404
+ };
405
+
406
+ @group(0) @binding(0) var<uniform> camera: CameraUniforms;
407
+ @group(0) @binding(1) var<uniform> material: MaterialUniforms;
408
+ @group(0) @binding(2) var<storage, read> skinMats: array<mat4x4f>;
409
+
410
+ struct VertexOutput {
411
+ @builtin(position) position: vec4f,
412
+ };
413
+
414
+ @vertex fn vs(
415
+ @location(0) position: vec3f,
416
+ @location(1) normal: vec3f,
417
+ @location(2) uv: vec2f,
418
+ @location(3) joints0: vec4<u32>,
419
+ @location(4) weights0: vec4<f32>
420
+ ) -> VertexOutput {
421
+ var output: VertexOutput;
422
+ let pos4 = vec4f(position, 1.0);
423
+
424
+ // Normalize weights to ensure they sum to 1.0 (handles floating-point precision issues)
425
+ let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
426
+ var normalizedWeights: vec4f;
427
+ if (weightSum > 0.0001) {
428
+ normalizedWeights = weights0 / weightSum;
429
+ } else {
430
+ normalizedWeights = vec4f(1.0, 0.0, 0.0, 0.0);
431
+ }
432
+
433
+ var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
434
+ var skinnedNrm = vec3f(0.0, 0.0, 0.0);
435
+ for (var i = 0u; i < 4u; i++) {
436
+ let j = joints0[i];
437
+ let w = normalizedWeights[i];
438
+ let m = skinMats[j];
439
+ skinnedPos += (m * pos4) * w;
440
+ let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
441
+ skinnedNrm += (r3 * normal) * w;
442
+ }
443
+ let worldPos = skinnedPos.xyz;
444
+ let worldNormal = normalize(skinnedNrm);
445
+
446
+ // MMD invert hull: expand vertices outward along normals
447
+ let scaleFactor = 0.01;
448
+ let expandedPos = worldPos + worldNormal * material.edgeSize * scaleFactor;
449
+ output.position = camera.projection * camera.view * vec4f(expandedPos, 1.0);
450
+ return output;
451
+ }
452
+
453
+ @fragment fn fs() -> @location(0) vec4f {
454
+ return material.edgeColor;
455
+ }
452
456
  `,
453
457
  });
454
458
  this.outlinePipeline = this.device.createRenderPipeline({
@@ -519,8 +523,7 @@ export class Engine {
519
523
  count: this.sampleCount,
520
524
  },
521
525
  });
522
- // Hair outline pipeline: draws hair outlines over non-eyes (stencil != 1)
523
- // Drawn after hair geometry, so depth testing ensures outlines only appear where hair exists
526
+ // Hair outline pipeline: draws hair outlines over non-eyes (stencil != 1) - Drawn after hair geometry, so depth testing ensures outlines only appear where hair exists
524
527
  this.hairOutlinePipeline = this.device.createRenderPipeline({
525
528
  label: "hair outline pipeline",
526
529
  layout: outlinePipelineLayout,
@@ -601,8 +604,7 @@ export class Engine {
601
604
  count: this.sampleCount,
602
605
  },
603
606
  });
604
- // Hair outline pipeline for over eyes: draws where stencil == 1, but only where hair depth exists
605
- // Uses depth compare "equal" with a small bias to only appear where hair geometry exists
607
+ // Hair outline pipeline for over eyes: draws where stencil == 1, but only where hair depth exists - Uses depth compare "equal" with a small bias to only appear where hair geometry exists
606
608
  this.hairOutlineOverEyesPipeline = this.device.createRenderPipeline({
607
609
  label: "hair outline over eyes pipeline",
608
610
  layout: outlinePipelineLayout,
@@ -718,8 +720,7 @@ export class Engine {
718
720
  format: this.presentationFormat,
719
721
  blend: {
720
722
  color: {
721
- // Simple half-transparent overlay effect
722
- // Blend: hairColor * overlayAlpha + eyeColor * (1 - overlayAlpha)
723
+ // Simple half-transparent overlay effect - Blend: hairColor * overlayAlpha + eyeColor * (1 - overlayAlpha)
723
724
  srcFactor: "src-alpha",
724
725
  dstFactor: "one-minus-src-alpha",
725
726
  operation: "add",
@@ -888,31 +889,31 @@ export class Engine {
888
889
  createSkinMatrixComputePipeline() {
889
890
  const computeShader = this.device.createShaderModule({
890
891
  label: "skin matrix compute",
891
- code: /* wgsl */ `
892
- struct BoneCountUniform {
893
- count: u32,
894
- _padding1: u32,
895
- _padding2: u32,
896
- _padding3: u32,
897
- _padding4: vec4<u32>,
898
- };
899
-
900
- @group(0) @binding(0) var<uniform> boneCount: BoneCountUniform;
901
- @group(0) @binding(1) var<storage, read> worldMatrices: array<mat4x4f>;
902
- @group(0) @binding(2) var<storage, read> inverseBindMatrices: array<mat4x4f>;
903
- @group(0) @binding(3) var<storage, read_write> skinMatrices: array<mat4x4f>;
904
-
905
- @compute @workgroup_size(64)
906
- fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
907
- let boneIndex = globalId.x;
908
- // Bounds check: we dispatch workgroups (64 threads each), so some threads may be out of range
909
- if (boneIndex >= boneCount.count) {
910
- return;
911
- }
912
- let worldMat = worldMatrices[boneIndex];
913
- let invBindMat = inverseBindMatrices[boneIndex];
914
- skinMatrices[boneIndex] = worldMat * invBindMat;
915
- }
892
+ code: /* wgsl */ `
893
+ struct BoneCountUniform {
894
+ count: u32,
895
+ _padding1: u32,
896
+ _padding2: u32,
897
+ _padding3: u32,
898
+ _padding4: vec4<u32>,
899
+ };
900
+
901
+ @group(0) @binding(0) var<uniform> boneCount: BoneCountUniform;
902
+ @group(0) @binding(1) var<storage, read> worldMatrices: array<mat4x4f>;
903
+ @group(0) @binding(2) var<storage, read> inverseBindMatrices: array<mat4x4f>;
904
+ @group(0) @binding(3) var<storage, read_write> skinMatrices: array<mat4x4f>;
905
+
906
+ @compute @workgroup_size(64)
907
+ fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
908
+ let boneIndex = globalId.x;
909
+ // Bounds check: we dispatch workgroups (64 threads each), so some threads may be out of range
910
+ if (boneIndex >= boneCount.count) {
911
+ return;
912
+ }
913
+ let worldMat = worldMatrices[boneIndex];
914
+ let invBindMat = inverseBindMatrices[boneIndex];
915
+ skinMatrices[boneIndex] = worldMat * invBindMat;
916
+ }
916
917
  `,
917
918
  });
918
919
  this.skinMatrixComputePipeline = this.device.createComputePipeline({
@@ -923,6 +924,271 @@ export class Engine {
923
924
  },
924
925
  });
925
926
  }
927
+ // Create fullscreen quad for post-processing
928
+ createFullscreenQuad() {
929
+ // Fullscreen quad vertices: two triangles covering the entire screen - Format: position (x, y), uv (u, v)
930
+ const quadVertices = new Float32Array([
931
+ // Triangle 1
932
+ -1.0,
933
+ -1.0,
934
+ 0.0,
935
+ 0.0, // bottom-left
936
+ 1.0,
937
+ -1.0,
938
+ 1.0,
939
+ 0.0, // bottom-right
940
+ -1.0,
941
+ 1.0,
942
+ 0.0,
943
+ 1.0, // top-left
944
+ // Triangle 2
945
+ -1.0,
946
+ 1.0,
947
+ 0.0,
948
+ 1.0, // top-left
949
+ 1.0,
950
+ -1.0,
951
+ 1.0,
952
+ 0.0, // bottom-right
953
+ 1.0,
954
+ 1.0,
955
+ 1.0,
956
+ 1.0, // top-right
957
+ ]);
958
+ this.fullscreenQuadBuffer = this.device.createBuffer({
959
+ label: "fullscreen quad",
960
+ size: quadVertices.byteLength,
961
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
962
+ });
963
+ this.device.queue.writeBuffer(this.fullscreenQuadBuffer, 0, quadVertices);
964
+ }
965
+ // Create bloom post-processing pipelines
966
+ createBloomPipelines() {
967
+ // Bloom extraction shader (extracts bright areas)
968
+ const bloomExtractShader = this.device.createShaderModule({
969
+ label: "bloom extract",
970
+ code: /* wgsl */ `
971
+ struct VertexOutput {
972
+ @builtin(position) position: vec4f,
973
+ @location(0) uv: vec2f,
974
+ };
975
+
976
+ @vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
977
+ var output: VertexOutput;
978
+ // Generate fullscreen quad from vertex index
979
+ let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
980
+ let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
981
+ output.position = vec4f(x, y, 0.0, 1.0);
982
+ output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
983
+ return output;
984
+ }
985
+
986
+ struct BloomExtractUniforms {
987
+ threshold: f32,
988
+ _padding1: f32,
989
+ _padding2: f32,
990
+ _padding3: f32,
991
+ _padding4: f32,
992
+ _padding5: f32,
993
+ _padding6: f32,
994
+ _padding7: f32,
995
+ };
996
+
997
+ @group(0) @binding(0) var inputTexture: texture_2d<f32>;
998
+ @group(0) @binding(1) var inputSampler: sampler;
999
+ @group(0) @binding(2) var<uniform> extractUniforms: BloomExtractUniforms;
1000
+
1001
+ @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
1002
+ let color = textureSample(inputTexture, inputSampler, input.uv);
1003
+ // Extract bright areas above threshold
1004
+ let threshold = extractUniforms.threshold;
1005
+ let bloom = max(vec3f(0.0), color.rgb - vec3f(threshold)) / max(0.001, 1.0 - threshold);
1006
+ return vec4f(bloom, color.a);
1007
+ }
1008
+ `,
1009
+ });
1010
+ // Bloom blur shader (gaussian blur - can be used for both horizontal and vertical)
1011
+ const bloomBlurShader = this.device.createShaderModule({
1012
+ label: "bloom blur",
1013
+ code: /* wgsl */ `
1014
+ struct VertexOutput {
1015
+ @builtin(position) position: vec4f,
1016
+ @location(0) uv: vec2f,
1017
+ };
1018
+
1019
+ @vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
1020
+ var output: VertexOutput;
1021
+ let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
1022
+ let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
1023
+ output.position = vec4f(x, y, 0.0, 1.0);
1024
+ output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
1025
+ return output;
1026
+ }
1027
+
1028
+ struct BlurUniforms {
1029
+ direction: vec2f,
1030
+ _padding1: f32,
1031
+ _padding2: f32,
1032
+ _padding3: f32,
1033
+ _padding4: f32,
1034
+ _padding5: f32,
1035
+ _padding6: f32,
1036
+ };
1037
+
1038
+ @group(0) @binding(0) var inputTexture: texture_2d<f32>;
1039
+ @group(0) @binding(1) var inputSampler: sampler;
1040
+ @group(0) @binding(2) var<uniform> blurUniforms: BlurUniforms;
1041
+
1042
+ // 9-tap gaussian blur
1043
+ @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
1044
+ let texelSize = 1.0 / vec2f(textureDimensions(inputTexture));
1045
+ var result = vec4f(0.0);
1046
+
1047
+ // Gaussian weights for 9-tap filter
1048
+ let weights = array<f32, 9>(
1049
+ 0.01621622, 0.05405405, 0.12162162,
1050
+ 0.19459459, 0.22702703,
1051
+ 0.19459459, 0.12162162, 0.05405405, 0.01621622
1052
+ );
1053
+
1054
+ let offsets = array<f32, 9>(-4.0, -3.0, -2.0, -1.0, 0.0, 1.0, 2.0, 3.0, 4.0);
1055
+
1056
+ for (var i = 0u; i < 9u; i++) {
1057
+ let offset = offsets[i] * texelSize * blurUniforms.direction;
1058
+ result += textureSample(inputTexture, inputSampler, input.uv + offset) * weights[i];
1059
+ }
1060
+
1061
+ return result;
1062
+ }
1063
+ `,
1064
+ });
1065
+ // Bloom composition shader (combines original scene with bloom)
1066
+ const bloomComposeShader = this.device.createShaderModule({
1067
+ label: "bloom compose",
1068
+ code: /* wgsl */ `
1069
+ struct VertexOutput {
1070
+ @builtin(position) position: vec4f,
1071
+ @location(0) uv: vec2f,
1072
+ };
1073
+
1074
+ @vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
1075
+ var output: VertexOutput;
1076
+ let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
1077
+ let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
1078
+ output.position = vec4f(x, y, 0.0, 1.0);
1079
+ output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
1080
+ return output;
1081
+ }
1082
+
1083
+ struct BloomComposeUniforms {
1084
+ intensity: f32,
1085
+ _padding1: f32,
1086
+ _padding2: f32,
1087
+ _padding3: f32,
1088
+ _padding4: f32,
1089
+ _padding5: f32,
1090
+ _padding6: f32,
1091
+ _padding7: f32,
1092
+ };
1093
+
1094
+ @group(0) @binding(0) var sceneTexture: texture_2d<f32>;
1095
+ @group(0) @binding(1) var sceneSampler: sampler;
1096
+ @group(0) @binding(2) var bloomTexture: texture_2d<f32>;
1097
+ @group(0) @binding(3) var bloomSampler: sampler;
1098
+ @group(0) @binding(4) var<uniform> composeUniforms: BloomComposeUniforms;
1099
+
1100
+ @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
1101
+ let scene = textureSample(sceneTexture, sceneSampler, input.uv);
1102
+ let bloom = textureSample(bloomTexture, bloomSampler, input.uv);
1103
+ // Additive blending with intensity control
1104
+ let result = scene.rgb + bloom.rgb * composeUniforms.intensity;
1105
+ return vec4f(result, scene.a);
1106
+ }
1107
+ `,
1108
+ });
1109
+ // Create uniform buffer for blur direction (minimum 32 bytes for WebGPU)
1110
+ const blurDirectionBuffer = this.device.createBuffer({
1111
+ label: "blur direction",
1112
+ size: 32, // Minimum 32 bytes required for uniform buffers in WebGPU
1113
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1114
+ });
1115
+ // Create uniform buffer for bloom intensity (minimum 32 bytes for WebGPU)
1116
+ const bloomIntensityBuffer = this.device.createBuffer({
1117
+ label: "bloom intensity",
1118
+ size: 32, // Minimum 32 bytes required for uniform buffers in WebGPU
1119
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1120
+ });
1121
+ // Create uniform buffer for bloom threshold (minimum 32 bytes for WebGPU)
1122
+ const bloomThresholdBuffer = this.device.createBuffer({
1123
+ label: "bloom threshold",
1124
+ size: 32, // Minimum 32 bytes required for uniform buffers in WebGPU
1125
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1126
+ });
1127
+ // Set default bloom values
1128
+ const intensityData = new Float32Array(8); // f32 + 7 padding floats = 8 floats = 32 bytes
1129
+ intensityData[0] = this.bloomIntensity;
1130
+ this.device.queue.writeBuffer(bloomIntensityBuffer, 0, intensityData);
1131
+ const thresholdData = new Float32Array(8); // f32 + 7 padding floats = 8 floats = 32 bytes
1132
+ thresholdData[0] = this.bloomThreshold;
1133
+ this.device.queue.writeBuffer(bloomThresholdBuffer, 0, thresholdData);
1134
+ // Create linear sampler for post-processing
1135
+ const linearSampler = this.device.createSampler({
1136
+ magFilter: "linear",
1137
+ minFilter: "linear",
1138
+ addressModeU: "clamp-to-edge",
1139
+ addressModeV: "clamp-to-edge",
1140
+ });
1141
+ // Bloom extraction pipeline
1142
+ this.bloomExtractPipeline = this.device.createRenderPipeline({
1143
+ label: "bloom extract",
1144
+ layout: "auto",
1145
+ vertex: {
1146
+ module: bloomExtractShader,
1147
+ entryPoint: "vs",
1148
+ },
1149
+ fragment: {
1150
+ module: bloomExtractShader,
1151
+ entryPoint: "fs",
1152
+ targets: [{ format: this.presentationFormat }],
1153
+ },
1154
+ primitive: { topology: "triangle-list" },
1155
+ });
1156
+ // Bloom blur pipeline
1157
+ this.bloomBlurPipeline = this.device.createRenderPipeline({
1158
+ label: "bloom blur",
1159
+ layout: "auto",
1160
+ vertex: {
1161
+ module: bloomBlurShader,
1162
+ entryPoint: "vs",
1163
+ },
1164
+ fragment: {
1165
+ module: bloomBlurShader,
1166
+ entryPoint: "fs",
1167
+ targets: [{ format: this.presentationFormat }],
1168
+ },
1169
+ primitive: { topology: "triangle-list" },
1170
+ });
1171
+ // Bloom composition pipeline
1172
+ this.bloomComposePipeline = this.device.createRenderPipeline({
1173
+ label: "bloom compose",
1174
+ layout: "auto",
1175
+ vertex: {
1176
+ module: bloomComposeShader,
1177
+ entryPoint: "vs",
1178
+ },
1179
+ fragment: {
1180
+ module: bloomComposeShader,
1181
+ entryPoint: "fs",
1182
+ targets: [{ format: this.presentationFormat }],
1183
+ },
1184
+ primitive: { topology: "triangle-list" },
1185
+ });
1186
+ // Store buffers and sampler for later use
1187
+ this.blurDirectionBuffer = blurDirectionBuffer;
1188
+ this.bloomIntensityBuffer = bloomIntensityBuffer;
1189
+ this.bloomThresholdBuffer = bloomThresholdBuffer;
1190
+ this.linearSampler = linearSampler;
1191
+ }
926
1192
  // Step 3: Setup canvas resize handling
927
1193
  setupResize() {
928
1194
  this.resizeObserver = new ResizeObserver(() => this.handleResize());
@@ -952,17 +1218,47 @@ export class Engine {
952
1218
  format: "depth24plus-stencil8",
953
1219
  usage: GPUTextureUsage.RENDER_ATTACHMENT,
954
1220
  });
1221
+ // Create scene render texture (non-multisampled for post-processing)
1222
+ this.sceneRenderTexture = this.device.createTexture({
1223
+ label: "scene render texture",
1224
+ size: [width, height],
1225
+ format: this.presentationFormat,
1226
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1227
+ });
1228
+ this.sceneRenderTextureView = this.sceneRenderTexture.createView();
1229
+ // Create bloom textures (half resolution for performance)
1230
+ const bloomWidth = Math.floor(width / 2);
1231
+ const bloomHeight = Math.floor(height / 2);
1232
+ this.bloomExtractTexture = this.device.createTexture({
1233
+ label: "bloom extract",
1234
+ size: [bloomWidth, bloomHeight],
1235
+ format: this.presentationFormat,
1236
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1237
+ });
1238
+ this.bloomBlurTexture1 = this.device.createTexture({
1239
+ label: "bloom blur 1",
1240
+ size: [bloomWidth, bloomHeight],
1241
+ format: this.presentationFormat,
1242
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1243
+ });
1244
+ this.bloomBlurTexture2 = this.device.createTexture({
1245
+ label: "bloom blur 2",
1246
+ size: [bloomWidth, bloomHeight],
1247
+ format: this.presentationFormat,
1248
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1249
+ });
955
1250
  const depthTextureView = this.depthTexture.createView();
1251
+ // Render scene to texture instead of directly to canvas
956
1252
  const colorAttachment = this.sampleCount > 1
957
1253
  ? {
958
1254
  view: this.multisampleTexture.createView(),
959
- resolveTarget: this.context.getCurrentTexture().createView(),
1255
+ resolveTarget: this.sceneRenderTextureView,
960
1256
  clearValue: { r: 0, g: 0, b: 0, a: 0 },
961
1257
  loadOp: "clear",
962
1258
  storeOp: "store",
963
1259
  }
964
1260
  : {
965
- view: this.context.getCurrentTexture().createView(),
1261
+ view: this.sceneRenderTextureView,
966
1262
  clearValue: { r: 0, g: 0, b: 0, a: 0 },
967
1263
  loadOp: "clear",
968
1264
  storeOp: "store",
@@ -1217,8 +1513,7 @@ export class Engine {
1217
1513
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1218
1514
  });
1219
1515
  this.device.queue.writeBuffer(materialUniformBuffer, 0, materialUniformData);
1220
- // Create bind groups using the shared bind group layout
1221
- // All pipelines (main, eye, hair multiply, hair opaque) use the same shader and layout
1516
+ // Create bind groups using the shared bind group layout - All pipelines (main, eye, hair multiply, hair opaque) use the same shader and layout
1222
1517
  const bindGroup = this.device.createBindGroup({
1223
1518
  label: `material bind group: ${mat.name}`,
1224
1519
  layout: this.hairBindGroupLayout,
@@ -1266,8 +1561,7 @@ export class Engine {
1266
1561
  isTransparent,
1267
1562
  });
1268
1563
  }
1269
- // Outline for all materials (including transparent)
1270
- // Edge flag is at bit 4 (0x10) in PMX format, not bit 0 (0x01)
1564
+ // Outline for all materials (including transparent) - Edge flag is at bit 4 (0x10) in PMX format, not bit 0 (0x01)
1271
1565
  if ((mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
1272
1566
  const materialUniformData = new Float32Array(8);
1273
1567
  materialUniformData[0] = mat.edgeColor[0];
@@ -1388,7 +1682,7 @@ export class Engine {
1388
1682
  pass.setVertexBuffer(2, this.weightsBuffer);
1389
1683
  pass.setIndexBuffer(this.indexBuffer, "uint32");
1390
1684
  this.drawCallCount = 0;
1391
- // === PASS 1: Opaque non-eye, non-hair (face, body, etc) ===
1685
+ // PASS 1: Opaque non-eye, non-hair (face, body, etc)
1392
1686
  // this.drawOutlines(pass, false) // Opaque outlines
1393
1687
  pass.setPipeline(this.pipeline);
1394
1688
  for (const draw of this.opaqueNonEyeNonHairDraws) {
@@ -1398,7 +1692,7 @@ export class Engine {
1398
1692
  this.drawCallCount++;
1399
1693
  }
1400
1694
  }
1401
- // === PASS 2: Eyes (writes stencil = 1) ===
1695
+ // PASS 2: Eyes (writes stencil = 1)
1402
1696
  pass.setPipeline(this.eyePipeline);
1403
1697
  pass.setStencilReference(1); // Set stencil reference value to 1
1404
1698
  for (const draw of this.eyeDraws) {
@@ -1408,8 +1702,7 @@ export class Engine {
1408
1702
  this.drawCallCount++;
1409
1703
  }
1410
1704
  }
1411
- // === PASS 3a: Hair over eyes (stencil == 1, multiply blend) ===
1412
- // Draw hair geometry first to establish depth
1705
+ // PASS 3a: Hair over eyes (stencil == 1, multiply blend) - Draw hair geometry first to establish depth
1413
1706
  pass.setPipeline(this.hairMultiplyPipeline);
1414
1707
  pass.setStencilReference(1); // Check against stencil value 1
1415
1708
  for (const draw of this.hairDraws) {
@@ -1419,9 +1712,7 @@ export class Engine {
1419
1712
  this.drawCallCount++;
1420
1713
  }
1421
1714
  }
1422
- // === PASS 3a.5: Hair outlines over eyes (stencil == 1, depth test to only draw near hair) ===
1423
- // Use depth compare "less-equal" with the hair depth to only draw outline where hair exists
1424
- // The outline is expanded outward, so we need to ensure it only appears near the hair edge
1715
+ // PASS 3a.5: Hair outlines over eyes (stencil == 1, depth test to only draw near hair)
1425
1716
  pass.setPipeline(this.hairOutlineOverEyesPipeline);
1426
1717
  pass.setStencilReference(1); // Check against stencil value 1 (with equal test)
1427
1718
  for (const draw of this.hairOutlineDraws) {
@@ -1430,7 +1721,7 @@ export class Engine {
1430
1721
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1431
1722
  }
1432
1723
  }
1433
- // === PASS 3b: Hair over non-eyes (stencil != 1, opaque) ===
1724
+ // PASS 3b: Hair over non-eyes (stencil != 1, opaque)
1434
1725
  pass.setPipeline(this.hairOpaquePipeline);
1435
1726
  pass.setStencilReference(1); // Check against stencil value 1 (with not-equal test)
1436
1727
  for (const draw of this.hairDraws) {
@@ -1440,8 +1731,7 @@ export class Engine {
1440
1731
  this.drawCallCount++;
1441
1732
  }
1442
1733
  }
1443
- // === PASS 3b.5: Hair outlines over non-eyes (stencil != 1) ===
1444
- // Draw hair outlines after hair geometry, so they only appear where hair exists
1734
+ // PASS 3b.5: Hair outlines over non-eyes (stencil != 1) - Draw hair outlines after hair geometry, so they only appear where hair exists
1445
1735
  pass.setPipeline(this.hairOutlinePipeline);
1446
1736
  pass.setStencilReference(1); // Check against stencil value 1 (with not-equal test)
1447
1737
  for (const draw of this.hairOutlineDraws) {
@@ -1451,7 +1741,7 @@ export class Engine {
1451
1741
  }
1452
1742
  }
1453
1743
  this.drawOutlines(pass, false); // Opaque outlines
1454
- // === PASS 4: Transparent non-eye, non-hair ===
1744
+ // PASS 4: Transparent non-eye, non-hair
1455
1745
  pass.setPipeline(this.pipeline);
1456
1746
  for (const draw of this.transparentNonEyeNonHairDraws) {
1457
1747
  if (draw.count > 0) {
@@ -1463,9 +1753,136 @@ export class Engine {
1463
1753
  this.drawOutlines(pass, true); // Transparent outlines
1464
1754
  pass.end();
1465
1755
  this.device.queue.submit([encoder.finish()]);
1756
+ // Apply bloom post-processing
1757
+ this.applyBloom();
1466
1758
  this.updateStats(performance.now() - currentTime);
1467
1759
  }
1468
1760
  }
1761
+ // Apply bloom post-processing
1762
+ applyBloom() {
1763
+ if (!this.sceneRenderTexture || !this.bloomExtractTexture) {
1764
+ return;
1765
+ }
1766
+ // Update bloom parameters
1767
+ const thresholdData = new Float32Array(8);
1768
+ thresholdData[0] = this.bloomThreshold;
1769
+ this.device.queue.writeBuffer(this.bloomThresholdBuffer, 0, thresholdData);
1770
+ const intensityData = new Float32Array(8);
1771
+ intensityData[0] = this.bloomIntensity;
1772
+ this.device.queue.writeBuffer(this.bloomIntensityBuffer, 0, intensityData);
1773
+ const encoder = this.device.createCommandEncoder();
1774
+ const width = this.canvas.width;
1775
+ const height = this.canvas.height;
1776
+ const bloomWidth = Math.floor(width / 2);
1777
+ const bloomHeight = Math.floor(height / 2);
1778
+ // Pass 1: Extract bright areas (downsample to half resolution)
1779
+ const extractPass = encoder.beginRenderPass({
1780
+ label: "bloom extract",
1781
+ colorAttachments: [
1782
+ {
1783
+ view: this.bloomExtractTexture.createView(),
1784
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
1785
+ loadOp: "clear",
1786
+ storeOp: "store",
1787
+ },
1788
+ ],
1789
+ });
1790
+ const extractBindGroup = this.device.createBindGroup({
1791
+ layout: this.bloomExtractPipeline.getBindGroupLayout(0),
1792
+ entries: [
1793
+ { binding: 0, resource: this.sceneRenderTexture.createView() },
1794
+ { binding: 1, resource: this.linearSampler },
1795
+ { binding: 2, resource: { buffer: this.bloomThresholdBuffer } },
1796
+ ],
1797
+ });
1798
+ extractPass.setPipeline(this.bloomExtractPipeline);
1799
+ extractPass.setBindGroup(0, extractBindGroup);
1800
+ extractPass.draw(6, 1, 0, 0);
1801
+ extractPass.end();
1802
+ // Pass 2: Horizontal blur
1803
+ const hBlurData = new Float32Array(4); // vec2f + padding = 4 floats
1804
+ hBlurData[0] = 1.0;
1805
+ hBlurData[1] = 0.0;
1806
+ this.device.queue.writeBuffer(this.blurDirectionBuffer, 0, hBlurData);
1807
+ const blurHPass = encoder.beginRenderPass({
1808
+ label: "bloom blur horizontal",
1809
+ colorAttachments: [
1810
+ {
1811
+ view: this.bloomBlurTexture1.createView(),
1812
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
1813
+ loadOp: "clear",
1814
+ storeOp: "store",
1815
+ },
1816
+ ],
1817
+ });
1818
+ const blurHBindGroup = this.device.createBindGroup({
1819
+ layout: this.bloomBlurPipeline.getBindGroupLayout(0),
1820
+ entries: [
1821
+ { binding: 0, resource: this.bloomExtractTexture.createView() },
1822
+ { binding: 1, resource: this.linearSampler },
1823
+ { binding: 2, resource: { buffer: this.blurDirectionBuffer } },
1824
+ ],
1825
+ });
1826
+ blurHPass.setPipeline(this.bloomBlurPipeline);
1827
+ blurHPass.setBindGroup(0, blurHBindGroup);
1828
+ blurHPass.draw(6, 1, 0, 0);
1829
+ blurHPass.end();
1830
+ // Pass 3: Vertical blur
1831
+ const vBlurData = new Float32Array(4); // vec2f + padding = 4 floats
1832
+ vBlurData[0] = 0.0;
1833
+ vBlurData[1] = 1.0;
1834
+ this.device.queue.writeBuffer(this.blurDirectionBuffer, 0, vBlurData);
1835
+ const blurVPass = encoder.beginRenderPass({
1836
+ label: "bloom blur vertical",
1837
+ colorAttachments: [
1838
+ {
1839
+ view: this.bloomBlurTexture2.createView(),
1840
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
1841
+ loadOp: "clear",
1842
+ storeOp: "store",
1843
+ },
1844
+ ],
1845
+ });
1846
+ const blurVBindGroup = this.device.createBindGroup({
1847
+ layout: this.bloomBlurPipeline.getBindGroupLayout(0),
1848
+ entries: [
1849
+ { binding: 0, resource: this.bloomBlurTexture1.createView() },
1850
+ { binding: 1, resource: this.linearSampler },
1851
+ { binding: 2, resource: { buffer: this.blurDirectionBuffer } },
1852
+ ],
1853
+ });
1854
+ blurVPass.setPipeline(this.bloomBlurPipeline);
1855
+ blurVPass.setBindGroup(0, blurVBindGroup);
1856
+ blurVPass.draw(6, 1, 0, 0);
1857
+ blurVPass.end();
1858
+ // Pass 4: Compose scene + bloom to canvas
1859
+ const composePass = encoder.beginRenderPass({
1860
+ label: "bloom compose",
1861
+ colorAttachments: [
1862
+ {
1863
+ view: this.context.getCurrentTexture().createView(),
1864
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
1865
+ loadOp: "clear",
1866
+ storeOp: "store",
1867
+ },
1868
+ ],
1869
+ });
1870
+ const composeBindGroup = this.device.createBindGroup({
1871
+ layout: this.bloomComposePipeline.getBindGroupLayout(0),
1872
+ entries: [
1873
+ { binding: 0, resource: this.sceneRenderTexture.createView() },
1874
+ { binding: 1, resource: this.linearSampler },
1875
+ { binding: 2, resource: this.bloomBlurTexture2.createView() },
1876
+ { binding: 3, resource: this.linearSampler },
1877
+ { binding: 4, resource: { buffer: this.bloomIntensityBuffer } },
1878
+ ],
1879
+ });
1880
+ composePass.setPipeline(this.bloomComposePipeline);
1881
+ composePass.setBindGroup(0, composeBindGroup);
1882
+ composePass.draw(6, 1, 0, 0);
1883
+ composePass.end();
1884
+ this.device.queue.submit([encoder.finish()]);
1885
+ }
1469
1886
  // Update camera uniform buffer each frame
1470
1887
  updateCameraUniforms() {
1471
1888
  const viewMatrix = this.camera.getViewMatrix();
@@ -1482,10 +1899,12 @@ export class Engine {
1482
1899
  updateRenderTarget() {
1483
1900
  const colorAttachment = this.renderPassDescriptor.colorAttachments[0];
1484
1901
  if (this.sampleCount > 1) {
1485
- colorAttachment.resolveTarget = this.context.getCurrentTexture().createView();
1902
+ // Resolve to scene render texture for post-processing
1903
+ colorAttachment.resolveTarget = this.sceneRenderTextureView;
1486
1904
  }
1487
1905
  else {
1488
- colorAttachment.view = this.context.getCurrentTexture().createView();
1906
+ // Render directly to scene render texture
1907
+ colorAttachment.view = this.sceneRenderTextureView;
1489
1908
  }
1490
1909
  }
1491
1910
  // Update model pose and physics