reze-engine 0.2.4 → 0.2.5

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
@@ -9,7 +9,11 @@ export class Engine {
9
9
  this.lightData = new Float32Array(64);
10
10
  this.lightCount = 0;
11
11
  this.resizeObserver = null;
12
- this.sampleCount = 4; // MSAA 4x
12
+ this.sampleCount = 4;
13
+ // Constants
14
+ this.STENCIL_EYE_VALUE = 1;
15
+ this.COMPUTE_WORKGROUP_SIZE = 64;
16
+ this.BLOOM_DOWNSCALE_FACTOR = 2;
13
17
  // Ambient light settings
14
18
  this.ambient = 1.0;
15
19
  // Bloom settings
@@ -22,7 +26,6 @@ export class Engine {
22
26
  this.modelDir = "";
23
27
  this.physics = null;
24
28
  this.textureCache = new Map();
25
- this.textureSizes = new Map();
26
29
  this.lastFpsUpdate = performance.now();
27
30
  this.framesSinceLastUpdate = 0;
28
31
  this.frameTimeSamples = [];
@@ -38,6 +41,7 @@ export class Engine {
38
41
  this.renderLoopCallback = null;
39
42
  this.animationFrames = [];
40
43
  this.animationTimeouts = [];
44
+ this.gpuMemoryMB = 0;
41
45
  this.opaqueNonEyeNonHairDraws = [];
42
46
  this.eyeDraws = [];
43
47
  this.hairDrawsOverEyes = [];
@@ -90,130 +94,130 @@ export class Engine {
90
94
  });
91
95
  const shaderModule = this.device.createShaderModule({
92
96
  label: "model shaders",
93
- code: /* wgsl */ `
94
- struct CameraUniforms {
95
- view: mat4x4f,
96
- projection: mat4x4f,
97
- viewPos: vec3f,
98
- _padding: f32,
99
- };
100
-
101
- struct Light {
102
- direction: vec3f,
103
- _padding1: f32,
104
- color: vec3f,
105
- intensity: f32,
106
- };
107
-
108
- struct LightUniforms {
109
- ambient: f32,
110
- lightCount: f32,
111
- _padding1: f32,
112
- _padding2: f32,
113
- lights: array<Light, 4>,
114
- };
115
-
116
- struct MaterialUniforms {
117
- alpha: f32,
118
- alphaMultiplier: f32,
119
- rimIntensity: f32,
120
- rimPower: f32,
121
- rimColor: vec3f,
122
- isOverEyes: f32, // 1.0 if rendering over eyes, 0.0 otherwise
123
- };
124
-
125
- struct VertexOutput {
126
- @builtin(position) position: vec4f,
127
- @location(0) normal: vec3f,
128
- @location(1) uv: vec2f,
129
- @location(2) worldPos: vec3f,
130
- };
131
-
132
- @group(0) @binding(0) var<uniform> camera: CameraUniforms;
133
- @group(0) @binding(1) var<uniform> light: LightUniforms;
134
- @group(0) @binding(2) var diffuseTexture: texture_2d<f32>;
135
- @group(0) @binding(3) var diffuseSampler: sampler;
136
- @group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
137
- @group(0) @binding(5) var toonTexture: texture_2d<f32>;
138
- @group(0) @binding(6) var toonSampler: sampler;
139
- @group(0) @binding(7) var<uniform> material: MaterialUniforms;
140
-
141
- @vertex fn vs(
142
- @location(0) position: vec3f,
143
- @location(1) normal: vec3f,
144
- @location(2) uv: vec2f,
145
- @location(3) joints0: vec4<u32>,
146
- @location(4) weights0: vec4<f32>
147
- ) -> VertexOutput {
148
- var output: VertexOutput;
149
- let pos4 = vec4f(position, 1.0);
150
-
151
- // Normalize weights to ensure they sum to 1.0 (handles floating-point precision issues)
152
- let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
153
- var normalizedWeights: vec4f;
154
- if (weightSum > 0.0001) {
155
- normalizedWeights = weights0 / weightSum;
156
- } else {
157
- normalizedWeights = vec4f(1.0, 0.0, 0.0, 0.0);
158
- }
159
-
160
- var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
161
- var skinnedNrm = vec3f(0.0, 0.0, 0.0);
162
- for (var i = 0u; i < 4u; i++) {
163
- let j = joints0[i];
164
- let w = normalizedWeights[i];
165
- let m = skinMats[j];
166
- skinnedPos += (m * pos4) * w;
167
- let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
168
- skinnedNrm += (r3 * normal) * w;
169
- }
170
- let worldPos = skinnedPos.xyz;
171
- output.position = camera.projection * camera.view * vec4f(worldPos, 1.0);
172
- output.normal = normalize(skinnedNrm);
173
- output.uv = uv;
174
- output.worldPos = worldPos;
175
- return output;
176
- }
177
-
178
- @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
179
- let n = normalize(input.normal);
180
- let albedo = textureSample(diffuseTexture, diffuseSampler, input.uv).rgb;
181
-
182
- var lightAccum = vec3f(light.ambient);
183
- let numLights = u32(light.lightCount);
184
- for (var i = 0u; i < numLights; i++) {
185
- let l = -light.lights[i].direction;
186
- let nDotL = max(dot(n, l), 0.0);
187
- let toonUV = vec2f(nDotL, 0.5);
188
- let toonFactor = textureSample(toonTexture, toonSampler, toonUV).rgb;
189
- let radiance = light.lights[i].color * light.lights[i].intensity;
190
- lightAccum += toonFactor * radiance * nDotL;
191
- }
192
-
193
- // Rim light calculation
194
- let viewDir = normalize(camera.viewPos - input.worldPos);
195
- var rimFactor = 1.0 - max(dot(n, viewDir), 0.0);
196
- rimFactor = pow(rimFactor, material.rimPower);
197
- let rimLight = material.rimColor * material.rimIntensity * rimFactor;
198
-
199
- let color = albedo * lightAccum + rimLight;
200
-
201
- var finalAlpha = material.alpha * material.alphaMultiplier;
202
- if (material.isOverEyes > 0.5) {
203
- finalAlpha *= 0.5; // Hair over eyes gets 50% alpha
204
- }
205
-
206
- if (finalAlpha < 0.001) {
207
- discard;
208
- }
209
-
210
- return vec4f(clamp(color, vec3f(0.0), vec3f(1.0)), finalAlpha);
211
- }
97
+ code: /* wgsl */ `
98
+ struct CameraUniforms {
99
+ view: mat4x4f,
100
+ projection: mat4x4f,
101
+ viewPos: vec3f,
102
+ _padding: f32,
103
+ };
104
+
105
+ struct Light {
106
+ direction: vec3f,
107
+ _padding1: f32,
108
+ color: vec3f,
109
+ intensity: f32,
110
+ };
111
+
112
+ struct LightUniforms {
113
+ ambient: f32,
114
+ lightCount: f32,
115
+ _padding1: f32,
116
+ _padding2: f32,
117
+ lights: array<Light, 4>,
118
+ };
119
+
120
+ struct MaterialUniforms {
121
+ alpha: f32,
122
+ alphaMultiplier: f32,
123
+ rimIntensity: f32,
124
+ rimPower: f32,
125
+ rimColor: vec3f,
126
+ isOverEyes: f32, // 1.0 if rendering over eyes, 0.0 otherwise
127
+ };
128
+
129
+ struct VertexOutput {
130
+ @builtin(position) position: vec4f,
131
+ @location(0) normal: vec3f,
132
+ @location(1) uv: vec2f,
133
+ @location(2) worldPos: vec3f,
134
+ };
135
+
136
+ @group(0) @binding(0) var<uniform> camera: CameraUniforms;
137
+ @group(0) @binding(1) var<uniform> light: LightUniforms;
138
+ @group(0) @binding(2) var diffuseTexture: texture_2d<f32>;
139
+ @group(0) @binding(3) var diffuseSampler: sampler;
140
+ @group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
141
+ @group(0) @binding(5) var toonTexture: texture_2d<f32>;
142
+ @group(0) @binding(6) var toonSampler: sampler;
143
+ @group(0) @binding(7) var<uniform> material: MaterialUniforms;
144
+
145
+ @vertex fn vs(
146
+ @location(0) position: vec3f,
147
+ @location(1) normal: vec3f,
148
+ @location(2) uv: vec2f,
149
+ @location(3) joints0: vec4<u32>,
150
+ @location(4) weights0: vec4<f32>
151
+ ) -> VertexOutput {
152
+ var output: VertexOutput;
153
+ let pos4 = vec4f(position, 1.0);
154
+
155
+ // Normalize weights to ensure they sum to 1.0 (handles floating-point precision issues)
156
+ let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
157
+ var normalizedWeights: vec4f;
158
+ if (weightSum > 0.0001) {
159
+ normalizedWeights = weights0 / weightSum;
160
+ } else {
161
+ normalizedWeights = vec4f(1.0, 0.0, 0.0, 0.0);
162
+ }
163
+
164
+ var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
165
+ var skinnedNrm = vec3f(0.0, 0.0, 0.0);
166
+ for (var i = 0u; i < 4u; i++) {
167
+ let j = joints0[i];
168
+ let w = normalizedWeights[i];
169
+ let m = skinMats[j];
170
+ skinnedPos += (m * pos4) * w;
171
+ let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
172
+ skinnedNrm += (r3 * normal) * w;
173
+ }
174
+ let worldPos = skinnedPos.xyz;
175
+ output.position = camera.projection * camera.view * vec4f(worldPos, 1.0);
176
+ output.normal = normalize(skinnedNrm);
177
+ output.uv = uv;
178
+ output.worldPos = worldPos;
179
+ return output;
180
+ }
181
+
182
+ @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
183
+ let n = normalize(input.normal);
184
+ let albedo = textureSample(diffuseTexture, diffuseSampler, input.uv).rgb;
185
+
186
+ var lightAccum = vec3f(light.ambient);
187
+ let numLights = u32(light.lightCount);
188
+ for (var i = 0u; i < numLights; i++) {
189
+ let l = -light.lights[i].direction;
190
+ let nDotL = max(dot(n, l), 0.0);
191
+ let toonUV = vec2f(nDotL, 0.5);
192
+ let toonFactor = textureSample(toonTexture, toonSampler, toonUV).rgb;
193
+ let radiance = light.lights[i].color * light.lights[i].intensity;
194
+ lightAccum += toonFactor * radiance * nDotL;
195
+ }
196
+
197
+ // Rim light calculation
198
+ let viewDir = normalize(camera.viewPos - input.worldPos);
199
+ var rimFactor = 1.0 - max(dot(n, viewDir), 0.0);
200
+ rimFactor = pow(rimFactor, material.rimPower);
201
+ let rimLight = material.rimColor * material.rimIntensity * rimFactor;
202
+
203
+ let color = albedo * lightAccum + rimLight;
204
+
205
+ var finalAlpha = material.alpha * material.alphaMultiplier;
206
+ if (material.isOverEyes > 0.5) {
207
+ finalAlpha *= 0.5; // Hair over eyes gets 50% alpha
208
+ }
209
+
210
+ if (finalAlpha < 0.001) {
211
+ discard;
212
+ }
213
+
214
+ return vec4f(clamp(color, vec3f(0.0), vec3f(1.0)), finalAlpha);
215
+ }
212
216
  `,
213
217
  });
214
218
  // Create explicit bind group layout for all pipelines using the main shader
215
- this.hairBindGroupLayout = this.device.createBindGroupLayout({
216
- label: "shared material bind group layout",
219
+ this.mainBindGroupLayout = this.device.createBindGroupLayout({
220
+ label: "main material bind group layout",
217
221
  entries: [
218
222
  { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // camera
219
223
  { binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // light
@@ -225,14 +229,13 @@ export class Engine {
225
229
  { binding: 7, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // material
226
230
  ],
227
231
  });
228
- const sharedPipelineLayout = this.device.createPipelineLayout({
229
- label: "shared pipeline layout",
230
- bindGroupLayouts: [this.hairBindGroupLayout],
232
+ const mainPipelineLayout = this.device.createPipelineLayout({
233
+ label: "main pipeline layout",
234
+ bindGroupLayouts: [this.mainBindGroupLayout],
231
235
  });
232
- // Single pipeline for all materials with alpha blending
233
- this.pipeline = this.device.createRenderPipeline({
236
+ this.modelPipeline = this.device.createRenderPipeline({
234
237
  label: "model pipeline",
235
- layout: sharedPipelineLayout,
238
+ layout: mainPipelineLayout,
236
239
  vertex: {
237
240
  module: shaderModule,
238
241
  buffers: [
@@ -299,77 +302,77 @@ export class Engine {
299
302
  });
300
303
  const outlineShaderModule = this.device.createShaderModule({
301
304
  label: "outline shaders",
302
- code: /* wgsl */ `
303
- struct CameraUniforms {
304
- view: mat4x4f,
305
- projection: mat4x4f,
306
- viewPos: vec3f,
307
- _padding: f32,
308
- };
309
-
310
- struct MaterialUniforms {
311
- edgeColor: vec4f,
312
- edgeSize: f32,
313
- isOverEyes: f32, // 1.0 if rendering over eyes, 0.0 otherwise (for hair outlines)
314
- _padding1: f32,
315
- _padding2: f32,
316
- };
317
-
318
- @group(0) @binding(0) var<uniform> camera: CameraUniforms;
319
- @group(0) @binding(1) var<uniform> material: MaterialUniforms;
320
- @group(0) @binding(2) var<storage, read> skinMats: array<mat4x4f>;
321
-
322
- struct VertexOutput {
323
- @builtin(position) position: vec4f,
324
- };
325
-
326
- @vertex fn vs(
327
- @location(0) position: vec3f,
328
- @location(1) normal: vec3f,
329
- @location(3) joints0: vec4<u32>,
330
- @location(4) weights0: vec4<f32>
331
- ) -> VertexOutput {
332
- var output: VertexOutput;
333
- let pos4 = vec4f(position, 1.0);
334
-
335
- // Normalize weights to ensure they sum to 1.0 (handles floating-point precision issues)
336
- let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
337
- var normalizedWeights: vec4f;
338
- if (weightSum > 0.0001) {
339
- normalizedWeights = weights0 / weightSum;
340
- } else {
341
- normalizedWeights = vec4f(1.0, 0.0, 0.0, 0.0);
342
- }
343
-
344
- var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
345
- var skinnedNrm = vec3f(0.0, 0.0, 0.0);
346
- for (var i = 0u; i < 4u; i++) {
347
- let j = joints0[i];
348
- let w = normalizedWeights[i];
349
- let m = skinMats[j];
350
- skinnedPos += (m * pos4) * w;
351
- let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
352
- skinnedNrm += (r3 * normal) * w;
353
- }
354
- let worldPos = skinnedPos.xyz;
355
- let worldNormal = normalize(skinnedNrm);
356
-
357
- // MMD invert hull: expand vertices outward along normals
358
- let scaleFactor = 0.01;
359
- let expandedPos = worldPos + worldNormal * material.edgeSize * scaleFactor;
360
- output.position = camera.projection * camera.view * vec4f(expandedPos, 1.0);
361
- return output;
362
- }
363
-
364
- @fragment fn fs() -> @location(0) vec4f {
365
- var color = material.edgeColor;
366
-
367
- if (material.isOverEyes > 0.5) {
368
- color.a *= 0.5; // Hair outlines over eyes get 50% alpha
369
- }
370
-
371
- return color;
372
- }
305
+ code: /* wgsl */ `
306
+ struct CameraUniforms {
307
+ view: mat4x4f,
308
+ projection: mat4x4f,
309
+ viewPos: vec3f,
310
+ _padding: f32,
311
+ };
312
+
313
+ struct MaterialUniforms {
314
+ edgeColor: vec4f,
315
+ edgeSize: f32,
316
+ isOverEyes: f32, // 1.0 if rendering over eyes, 0.0 otherwise (for hair outlines)
317
+ _padding1: f32,
318
+ _padding2: f32,
319
+ };
320
+
321
+ @group(0) @binding(0) var<uniform> camera: CameraUniforms;
322
+ @group(0) @binding(1) var<uniform> material: MaterialUniforms;
323
+ @group(0) @binding(2) var<storage, read> skinMats: array<mat4x4f>;
324
+
325
+ struct VertexOutput {
326
+ @builtin(position) position: vec4f,
327
+ };
328
+
329
+ @vertex fn vs(
330
+ @location(0) position: vec3f,
331
+ @location(1) normal: vec3f,
332
+ @location(3) joints0: vec4<u32>,
333
+ @location(4) weights0: vec4<f32>
334
+ ) -> VertexOutput {
335
+ var output: VertexOutput;
336
+ let pos4 = vec4f(position, 1.0);
337
+
338
+ // Normalize weights to ensure they sum to 1.0 (handles floating-point precision issues)
339
+ let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
340
+ var normalizedWeights: vec4f;
341
+ if (weightSum > 0.0001) {
342
+ normalizedWeights = weights0 / weightSum;
343
+ } else {
344
+ normalizedWeights = vec4f(1.0, 0.0, 0.0, 0.0);
345
+ }
346
+
347
+ var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
348
+ var skinnedNrm = vec3f(0.0, 0.0, 0.0);
349
+ for (var i = 0u; i < 4u; i++) {
350
+ let j = joints0[i];
351
+ let w = normalizedWeights[i];
352
+ let m = skinMats[j];
353
+ skinnedPos += (m * pos4) * w;
354
+ let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
355
+ skinnedNrm += (r3 * normal) * w;
356
+ }
357
+ let worldPos = skinnedPos.xyz;
358
+ let worldNormal = normalize(skinnedNrm);
359
+
360
+ // MMD invert hull: expand vertices outward along normals
361
+ let scaleFactor = 0.01;
362
+ let expandedPos = worldPos + worldNormal * material.edgeSize * scaleFactor;
363
+ output.position = camera.projection * camera.view * vec4f(expandedPos, 1.0);
364
+ return output;
365
+ }
366
+
367
+ @fragment fn fs() -> @location(0) vec4f {
368
+ var color = material.edgeColor;
369
+
370
+ if (material.isOverEyes > 0.5) {
371
+ color.a *= 0.5; // Hair outlines over eyes get 50% alpha
372
+ }
373
+
374
+ return color;
375
+ }
373
376
  `,
374
377
  });
375
378
  this.outlinePipeline = this.device.createRenderPipeline({
@@ -435,9 +438,9 @@ export class Engine {
435
438
  count: this.sampleCount,
436
439
  },
437
440
  });
438
- // Unified hair outline pipeline: single pass without stencil testing, uses depth test "less-equal" to draw everywhere hair exists
439
- this.hairUnifiedOutlinePipeline = this.device.createRenderPipeline({
440
- label: "unified hair outline pipeline",
441
+ // Hair outline pipeline
442
+ this.hairOutlinePipeline = this.device.createRenderPipeline({
443
+ label: "hair outline pipeline",
441
444
  layout: outlinePipelineLayout,
442
445
  vertex: {
443
446
  module: outlineShaderModule,
@@ -505,7 +508,7 @@ export class Engine {
505
508
  // Eye overlay pipeline (renders after opaque, writes stencil)
506
509
  this.eyePipeline = this.device.createRenderPipeline({
507
510
  label: "eye overlay pipeline",
508
- layout: sharedPipelineLayout,
511
+ layout: mainPipelineLayout,
509
512
  vertex: {
510
513
  module: shaderModule,
511
514
  buffers: [
@@ -570,55 +573,55 @@ export class Engine {
570
573
  // Depth-only shader for hair pre-pass (reduces overdraw by early depth rejection)
571
574
  const depthOnlyShaderModule = this.device.createShaderModule({
572
575
  label: "depth only shader",
573
- code: /* wgsl */ `
574
- struct CameraUniforms {
575
- view: mat4x4f,
576
- projection: mat4x4f,
577
- viewPos: vec3f,
578
- _padding: f32,
579
- };
580
-
581
- @group(0) @binding(0) var<uniform> camera: CameraUniforms;
582
- @group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
583
-
584
- @vertex fn vs(
585
- @location(0) position: vec3f,
586
- @location(1) normal: vec3f,
587
- @location(3) joints0: vec4<u32>,
588
- @location(4) weights0: vec4<f32>
589
- ) -> @builtin(position) vec4f {
590
- let pos4 = vec4f(position, 1.0);
591
-
592
- // Normalize weights
593
- let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
594
- var normalizedWeights: vec4f;
595
- if (weightSum > 0.0001) {
596
- normalizedWeights = weights0 / weightSum;
597
- } else {
598
- normalizedWeights = vec4f(1.0, 0.0, 0.0, 0.0);
599
- }
600
-
601
- var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
602
- for (var i = 0u; i < 4u; i++) {
603
- let j = joints0[i];
604
- let w = normalizedWeights[i];
605
- let m = skinMats[j];
606
- skinnedPos += (m * pos4) * w;
607
- }
608
- let worldPos = skinnedPos.xyz;
609
- let clipPos = camera.projection * camera.view * vec4f(worldPos, 1.0);
610
- return clipPos;
611
- }
612
-
613
- @fragment fn fs() -> @location(0) vec4f {
614
- return vec4f(0.0, 0.0, 0.0, 0.0); // Transparent - color writes disabled via writeMask
615
- }
576
+ code: /* wgsl */ `
577
+ struct CameraUniforms {
578
+ view: mat4x4f,
579
+ projection: mat4x4f,
580
+ viewPos: vec3f,
581
+ _padding: f32,
582
+ };
583
+
584
+ @group(0) @binding(0) var<uniform> camera: CameraUniforms;
585
+ @group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
586
+
587
+ @vertex fn vs(
588
+ @location(0) position: vec3f,
589
+ @location(1) normal: vec3f,
590
+ @location(3) joints0: vec4<u32>,
591
+ @location(4) weights0: vec4<f32>
592
+ ) -> @builtin(position) vec4f {
593
+ let pos4 = vec4f(position, 1.0);
594
+
595
+ // Normalize weights
596
+ let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
597
+ var normalizedWeights: vec4f;
598
+ if (weightSum > 0.0001) {
599
+ normalizedWeights = weights0 / weightSum;
600
+ } else {
601
+ normalizedWeights = vec4f(1.0, 0.0, 0.0, 0.0);
602
+ }
603
+
604
+ var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
605
+ for (var i = 0u; i < 4u; i++) {
606
+ let j = joints0[i];
607
+ let w = normalizedWeights[i];
608
+ let m = skinMats[j];
609
+ skinnedPos += (m * pos4) * w;
610
+ }
611
+ let worldPos = skinnedPos.xyz;
612
+ let clipPos = camera.projection * camera.view * vec4f(worldPos, 1.0);
613
+ return clipPos;
614
+ }
615
+
616
+ @fragment fn fs() -> @location(0) vec4f {
617
+ return vec4f(0.0, 0.0, 0.0, 0.0); // Transparent - color writes disabled via writeMask
618
+ }
616
619
  `,
617
620
  });
618
621
  // Hair depth pre-pass pipeline: depth-only with color writes disabled to eliminate overdraw
619
622
  this.hairDepthPipeline = this.device.createRenderPipeline({
620
623
  label: "hair depth pre-pass",
621
- layout: sharedPipelineLayout,
624
+ layout: mainPipelineLayout,
622
625
  vertex: {
623
626
  module: depthOnlyShaderModule,
624
627
  buffers: [
@@ -657,10 +660,10 @@ export class Engine {
657
660
  },
658
661
  multisample: { count: this.sampleCount },
659
662
  });
660
- // Unified hair pipeline for over-eyes (stencil == 1): single pass with dynamic branching
661
- this.hairUnifiedPipelineOverEyes = this.device.createRenderPipeline({
662
- label: "unified hair pipeline (over eyes)",
663
- layout: sharedPipelineLayout,
663
+ // Hair pipeline for rendering over eyes (stencil == 1)
664
+ this.hairPipelineOverEyes = this.device.createRenderPipeline({
665
+ label: "hair pipeline (over eyes)",
666
+ layout: mainPipelineLayout,
664
667
  vertex: {
665
668
  module: shaderModule,
666
669
  buffers: [
@@ -722,10 +725,10 @@ export class Engine {
722
725
  },
723
726
  multisample: { count: this.sampleCount },
724
727
  });
725
- // Unified pipeline for hair over non-eyes (stencil != 1)
726
- this.hairUnifiedPipelineOverNonEyes = this.device.createRenderPipeline({
727
- label: "unified hair pipeline (over non-eyes)",
728
- layout: sharedPipelineLayout,
728
+ // Hair pipeline for rendering over non-eyes (stencil != 1)
729
+ this.hairPipelineOverNonEyes = this.device.createRenderPipeline({
730
+ label: "hair pipeline (over non-eyes)",
731
+ layout: mainPipelineLayout,
729
732
  vertex: {
730
733
  module: shaderModule,
731
734
  buffers: [
@@ -792,31 +795,30 @@ export class Engine {
792
795
  createSkinMatrixComputePipeline() {
793
796
  const computeShader = this.device.createShaderModule({
794
797
  label: "skin matrix compute",
795
- code: /* wgsl */ `
796
- struct BoneCountUniform {
797
- count: u32,
798
- _padding1: u32,
799
- _padding2: u32,
800
- _padding3: u32,
801
- _padding4: vec4<u32>,
802
- };
803
-
804
- @group(0) @binding(0) var<uniform> boneCount: BoneCountUniform;
805
- @group(0) @binding(1) var<storage, read> worldMatrices: array<mat4x4f>;
806
- @group(0) @binding(2) var<storage, read> inverseBindMatrices: array<mat4x4f>;
807
- @group(0) @binding(3) var<storage, read_write> skinMatrices: array<mat4x4f>;
808
-
809
- @compute @workgroup_size(64)
810
- fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
811
- let boneIndex = globalId.x;
812
- // Bounds check: we dispatch workgroups (64 threads each), so some threads may be out of range
813
- if (boneIndex >= boneCount.count) {
814
- return;
815
- }
816
- let worldMat = worldMatrices[boneIndex];
817
- let invBindMat = inverseBindMatrices[boneIndex];
818
- skinMatrices[boneIndex] = worldMat * invBindMat;
819
- }
798
+ code: /* wgsl */ `
799
+ struct BoneCountUniform {
800
+ count: u32,
801
+ _padding1: u32,
802
+ _padding2: u32,
803
+ _padding3: u32,
804
+ _padding4: vec4<u32>,
805
+ };
806
+
807
+ @group(0) @binding(0) var<uniform> boneCount: BoneCountUniform;
808
+ @group(0) @binding(1) var<storage, read> worldMatrices: array<mat4x4f>;
809
+ @group(0) @binding(2) var<storage, read> inverseBindMatrices: array<mat4x4f>;
810
+ @group(0) @binding(3) var<storage, read_write> skinMatrices: array<mat4x4f>;
811
+
812
+ @compute @workgroup_size(64) // Must match COMPUTE_WORKGROUP_SIZE
813
+ fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
814
+ let boneIndex = globalId.x;
815
+ if (boneIndex >= boneCount.count) {
816
+ return;
817
+ }
818
+ let worldMat = worldMatrices[boneIndex];
819
+ let invBindMat = inverseBindMatrices[boneIndex];
820
+ skinMatrices[boneIndex] = worldMat * invBindMat;
821
+ }
820
822
  `,
821
823
  });
822
824
  this.skinMatrixComputePipeline = this.device.createComputePipeline({
@@ -870,143 +872,138 @@ export class Engine {
870
872
  // Bloom extraction shader (extracts bright areas)
871
873
  const bloomExtractShader = this.device.createShaderModule({
872
874
  label: "bloom extract",
873
- code: /* wgsl */ `
874
- struct VertexOutput {
875
- @builtin(position) position: vec4f,
876
- @location(0) uv: vec2f,
877
- };
878
-
879
- @vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
880
- var output: VertexOutput;
881
- // Generate fullscreen quad from vertex index
882
- let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
883
- let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
884
- output.position = vec4f(x, y, 0.0, 1.0);
885
- output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
886
- return output;
887
- }
888
-
889
- struct BloomExtractUniforms {
890
- threshold: f32,
891
- _padding1: f32,
892
- _padding2: f32,
893
- _padding3: f32,
894
- _padding4: f32,
895
- _padding5: f32,
896
- _padding6: f32,
897
- _padding7: f32,
898
- };
899
-
900
- @group(0) @binding(0) var inputTexture: texture_2d<f32>;
901
- @group(0) @binding(1) var inputSampler: sampler;
902
- @group(0) @binding(2) var<uniform> extractUniforms: BloomExtractUniforms;
903
-
904
- @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
905
- let color = textureSample(inputTexture, inputSampler, input.uv);
906
- // Extract bright areas above threshold
907
- let threshold = extractUniforms.threshold;
908
- let bloom = max(vec3f(0.0), color.rgb - vec3f(threshold)) / max(0.001, 1.0 - threshold);
909
- return vec4f(bloom, color.a);
910
- }
875
+ code: /* wgsl */ `
876
+ struct VertexOutput {
877
+ @builtin(position) position: vec4f,
878
+ @location(0) uv: vec2f,
879
+ };
880
+
881
+ @vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
882
+ var output: VertexOutput;
883
+ // Generate fullscreen quad from vertex index
884
+ let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
885
+ let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
886
+ output.position = vec4f(x, y, 0.0, 1.0);
887
+ output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
888
+ return output;
889
+ }
890
+
891
+ struct BloomExtractUniforms {
892
+ threshold: f32,
893
+ _padding1: f32,
894
+ _padding2: f32,
895
+ _padding3: f32,
896
+ _padding4: f32,
897
+ _padding5: f32,
898
+ _padding6: f32,
899
+ _padding7: f32,
900
+ };
901
+
902
+ @group(0) @binding(0) var inputTexture: texture_2d<f32>;
903
+ @group(0) @binding(1) var inputSampler: sampler;
904
+ @group(0) @binding(2) var<uniform> extractUniforms: BloomExtractUniforms;
905
+
906
+ @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
907
+ let color = textureSample(inputTexture, inputSampler, input.uv);
908
+ // Extract bright areas above threshold
909
+ let threshold = extractUniforms.threshold;
910
+ let bloom = max(vec3f(0.0), color.rgb - vec3f(threshold)) / max(0.001, 1.0 - threshold);
911
+ return vec4f(bloom, color.a);
912
+ }
911
913
  `,
912
914
  });
913
915
  // Bloom blur shader (gaussian blur - can be used for both horizontal and vertical)
914
916
  const bloomBlurShader = this.device.createShaderModule({
915
917
  label: "bloom blur",
916
- code: /* wgsl */ `
917
- struct VertexOutput {
918
- @builtin(position) position: vec4f,
919
- @location(0) uv: vec2f,
920
- };
921
-
922
- @vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
923
- var output: VertexOutput;
924
- let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
925
- let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
926
- output.position = vec4f(x, y, 0.0, 1.0);
927
- output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
928
- return output;
929
- }
930
-
931
- struct BlurUniforms {
932
- direction: vec2f,
933
- _padding1: f32,
934
- _padding2: f32,
935
- _padding3: f32,
936
- _padding4: f32,
937
- _padding5: f32,
938
- _padding6: f32,
939
- };
940
-
941
- @group(0) @binding(0) var inputTexture: texture_2d<f32>;
942
- @group(0) @binding(1) var inputSampler: sampler;
943
- @group(0) @binding(2) var<uniform> blurUniforms: BlurUniforms;
944
-
945
- // 9-tap gaussian blur
946
- @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
947
- let texelSize = 1.0 / vec2f(textureDimensions(inputTexture));
948
- var result = vec4f(0.0);
949
-
950
- // Gaussian weights for 9-tap filter
951
- let weights = array<f32, 9>(
952
- 0.01621622, 0.05405405, 0.12162162,
953
- 0.19459459, 0.22702703,
954
- 0.19459459, 0.12162162, 0.05405405, 0.01621622
955
- );
956
-
957
- let offsets = array<f32, 9>(-4.0, -3.0, -2.0, -1.0, 0.0, 1.0, 2.0, 3.0, 4.0);
958
-
959
- for (var i = 0u; i < 9u; i++) {
960
- let offset = offsets[i] * texelSize * blurUniforms.direction;
961
- result += textureSample(inputTexture, inputSampler, input.uv + offset) * weights[i];
962
- }
963
-
964
- return result;
965
- }
918
+ code: /* wgsl */ `
919
+ struct VertexOutput {
920
+ @builtin(position) position: vec4f,
921
+ @location(0) uv: vec2f,
922
+ };
923
+
924
+ @vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
925
+ var output: VertexOutput;
926
+ let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
927
+ let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
928
+ output.position = vec4f(x, y, 0.0, 1.0);
929
+ output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
930
+ return output;
931
+ }
932
+
933
+ struct BlurUniforms {
934
+ direction: vec2f,
935
+ _padding1: f32,
936
+ _padding2: f32,
937
+ _padding3: f32,
938
+ _padding4: f32,
939
+ _padding5: f32,
940
+ _padding6: f32,
941
+ };
942
+
943
+ @group(0) @binding(0) var inputTexture: texture_2d<f32>;
944
+ @group(0) @binding(1) var inputSampler: sampler;
945
+ @group(0) @binding(2) var<uniform> blurUniforms: BlurUniforms;
946
+
947
+ // 5-tap gaussian blur
948
+ @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
949
+ let texelSize = 1.0 / vec2f(textureDimensions(inputTexture));
950
+ var result = vec4f(0.0);
951
+
952
+ // Optimized 5-tap Gaussian filter (faster, nearly same quality)
953
+ let weights = array<f32, 5>(0.06136, 0.24477, 0.38774, 0.24477, 0.06136);
954
+ let offsets = array<f32, 5>(-2.0, -1.0, 0.0, 1.0, 2.0);
955
+
956
+ for (var i = 0u; i < 5u; i++) {
957
+ let offset = offsets[i] * texelSize * blurUniforms.direction;
958
+ result += textureSample(inputTexture, inputSampler, input.uv + offset) * weights[i];
959
+ }
960
+
961
+ return result;
962
+ }
966
963
  `,
967
964
  });
968
965
  // Bloom composition shader (combines original scene with bloom)
969
966
  const bloomComposeShader = this.device.createShaderModule({
970
967
  label: "bloom compose",
971
- code: /* wgsl */ `
972
- struct VertexOutput {
973
- @builtin(position) position: vec4f,
974
- @location(0) uv: vec2f,
975
- };
976
-
977
- @vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
978
- var output: VertexOutput;
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 BloomComposeUniforms {
987
- intensity: 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 sceneTexture: texture_2d<f32>;
998
- @group(0) @binding(1) var sceneSampler: sampler;
999
- @group(0) @binding(2) var bloomTexture: texture_2d<f32>;
1000
- @group(0) @binding(3) var bloomSampler: sampler;
1001
- @group(0) @binding(4) var<uniform> composeUniforms: BloomComposeUniforms;
1002
-
1003
- @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
1004
- let scene = textureSample(sceneTexture, sceneSampler, input.uv);
1005
- let bloom = textureSample(bloomTexture, bloomSampler, input.uv);
1006
- // Additive blending with intensity control
1007
- let result = scene.rgb + bloom.rgb * composeUniforms.intensity;
1008
- return vec4f(result, scene.a);
1009
- }
968
+ code: /* wgsl */ `
969
+ struct VertexOutput {
970
+ @builtin(position) position: vec4f,
971
+ @location(0) uv: vec2f,
972
+ };
973
+
974
+ @vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
975
+ var output: VertexOutput;
976
+ let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
977
+ let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
978
+ output.position = vec4f(x, y, 0.0, 1.0);
979
+ output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
980
+ return output;
981
+ }
982
+
983
+ struct BloomComposeUniforms {
984
+ intensity: f32,
985
+ _padding1: f32,
986
+ _padding2: f32,
987
+ _padding3: f32,
988
+ _padding4: f32,
989
+ _padding5: f32,
990
+ _padding6: f32,
991
+ _padding7: f32,
992
+ };
993
+
994
+ @group(0) @binding(0) var sceneTexture: texture_2d<f32>;
995
+ @group(0) @binding(1) var sceneSampler: sampler;
996
+ @group(0) @binding(2) var bloomTexture: texture_2d<f32>;
997
+ @group(0) @binding(3) var bloomSampler: sampler;
998
+ @group(0) @binding(4) var<uniform> composeUniforms: BloomComposeUniforms;
999
+
1000
+ @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
1001
+ let scene = textureSample(sceneTexture, sceneSampler, input.uv);
1002
+ let bloom = textureSample(bloomTexture, bloomSampler, input.uv);
1003
+ // Additive blending with intensity control
1004
+ let result = scene.rgb + bloom.rgb * composeUniforms.intensity;
1005
+ return vec4f(result, scene.a);
1006
+ }
1010
1007
  `,
1011
1008
  });
1012
1009
  // Create uniform buffer for blur direction (minimum 32 bytes for WebGPU)
@@ -1092,11 +1089,9 @@ export class Engine {
1092
1089
  this.bloomThresholdBuffer = bloomThresholdBuffer;
1093
1090
  this.linearSampler = linearSampler;
1094
1091
  }
1095
- // Setup bloom textures and bind groups (called when canvas is resized)
1096
1092
  setupBloom(width, height) {
1097
- // Create bloom textures (half resolution for performance)
1098
- const bloomWidth = Math.floor(width / 2);
1099
- const bloomHeight = Math.floor(height / 2);
1093
+ const bloomWidth = Math.floor(width / this.BLOOM_DOWNSCALE_FACTOR);
1094
+ const bloomHeight = Math.floor(height / this.BLOOM_DOWNSCALE_FACTOR);
1100
1095
  this.bloomExtractTexture = this.device.createTexture({
1101
1096
  label: "bloom extract",
1102
1097
  size: [bloomWidth, bloomHeight],
@@ -1331,7 +1326,9 @@ export class Engine {
1331
1326
  this.physics.reset(worldMats, this.currentModel.getBoneInverseBindMatrices());
1332
1327
  // Upload matrices immediately so next frame shows correct pose
1333
1328
  this.device.queue.writeBuffer(this.worldMatrixBuffer, 0, worldMats.buffer, worldMats.byteOffset, worldMats.byteLength);
1334
- this.computeSkinMatrices();
1329
+ const encoder = this.device.createCommandEncoder();
1330
+ this.computeSkinMatrices(encoder);
1331
+ this.device.queue.submit([encoder.finish()]);
1335
1332
  }
1336
1333
  }
1337
1334
  for (const [_, keyFrames] of boneKeyFramesByBone.entries()) {
@@ -1540,7 +1537,6 @@ export class Engine {
1540
1537
  });
1541
1538
  this.device.queue.writeTexture({ texture: defaultToonTexture }, defaultToonData, { bytesPerRow: 256 * 4 }, [256, 2]);
1542
1539
  this.textureCache.set(defaultToonPath, defaultToonTexture);
1543
- this.textureSizes.set(defaultToonPath, { width: 256, height: 2 });
1544
1540
  return defaultToonTexture;
1545
1541
  };
1546
1542
  this.opaqueNonEyeNonHairDraws = [];
@@ -1552,10 +1548,10 @@ export class Engine {
1552
1548
  this.eyeOutlineDraws = [];
1553
1549
  this.hairOutlineDraws = [];
1554
1550
  this.transparentNonEyeNonHairOutlineDraws = [];
1555
- let runningFirstIndex = 0;
1551
+ let currentIndexOffset = 0;
1556
1552
  for (const mat of materials) {
1557
- const matCount = mat.vertexCount | 0;
1558
- if (matCount === 0)
1553
+ const indexCount = mat.vertexCount;
1554
+ if (indexCount === 0)
1559
1555
  continue;
1560
1556
  const diffuseTexture = await loadTextureByIndex(mat.diffuseTextureIndex);
1561
1557
  if (!diffuseTexture)
@@ -1583,7 +1579,7 @@ export class Engine {
1583
1579
  // Create bind groups using the shared bind group layout - All pipelines (main, eye, hair multiply, hair opaque) use the same shader and layout
1584
1580
  const bindGroup = this.device.createBindGroup({
1585
1581
  label: `material bind group: ${mat.name}`,
1586
- layout: this.hairBindGroupLayout,
1582
+ layout: this.mainBindGroupLayout,
1587
1583
  entries: [
1588
1584
  { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1589
1585
  { binding: 1, resource: { buffer: this.lightUniformBuffer } },
@@ -1598,100 +1594,77 @@ export class Engine {
1598
1594
  // Classify materials into appropriate draw lists
1599
1595
  if (mat.isEye) {
1600
1596
  this.eyeDraws.push({
1601
- count: matCount,
1602
- firstIndex: runningFirstIndex,
1597
+ count: indexCount,
1598
+ firstIndex: currentIndexOffset,
1603
1599
  bindGroup,
1604
1600
  isTransparent,
1605
1601
  });
1606
1602
  }
1607
1603
  else if (mat.isHair) {
1608
- // Hair materials: create bind groups for unified pipeline with dynamic branching
1609
- const materialUniformDataHair = new Float32Array(8);
1610
- materialUniformDataHair[0] = materialAlpha;
1611
- materialUniformDataHair[1] = 1.0; // alphaMultiplier: base value, shader will adjust
1612
- materialUniformDataHair[2] = this.rimLightIntensity;
1613
- materialUniformDataHair[3] = this.rimLightPower;
1614
- materialUniformDataHair[4] = 1.0; // rimColor.r
1615
- materialUniformDataHair[5] = 1.0; // rimColor.g
1616
- materialUniformDataHair[6] = 1.0; // rimColor.b
1617
- materialUniformDataHair[7] = 0.0;
1618
- // Create uniform buffers for both modes
1619
- const materialUniformBufferOverEyes = this.device.createBuffer({
1620
- label: `material uniform (over eyes): ${mat.name}`,
1621
- size: materialUniformDataHair.byteLength,
1622
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1623
- });
1624
- const materialUniformDataOverEyes = new Float32Array(materialUniformDataHair);
1625
- materialUniformDataOverEyes[7] = 1.0;
1626
- this.device.queue.writeBuffer(materialUniformBufferOverEyes, 0, materialUniformDataOverEyes);
1627
- const materialUniformBufferOverNonEyes = this.device.createBuffer({
1628
- label: `material uniform (over non-eyes): ${mat.name}`,
1629
- size: materialUniformDataHair.byteLength,
1630
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1631
- });
1632
- const materialUniformDataOverNonEyes = new Float32Array(materialUniformDataHair);
1633
- materialUniformDataOverNonEyes[7] = 0.0;
1634
- this.device.queue.writeBuffer(materialUniformBufferOverNonEyes, 0, materialUniformDataOverNonEyes);
1635
- // Create bind groups for both modes
1636
- const bindGroupOverEyes = this.device.createBindGroup({
1637
- label: `material bind group (over eyes): ${mat.name}`,
1638
- layout: this.hairBindGroupLayout,
1639
- entries: [
1640
- { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1641
- { binding: 1, resource: { buffer: this.lightUniformBuffer } },
1642
- { binding: 2, resource: diffuseTexture.createView() },
1643
- { binding: 3, resource: this.textureSampler },
1644
- { binding: 4, resource: { buffer: this.skinMatrixBuffer } },
1645
- { binding: 5, resource: toonTexture.createView() },
1646
- { binding: 6, resource: this.textureSampler },
1647
- { binding: 7, resource: { buffer: materialUniformBufferOverEyes } },
1648
- ],
1649
- });
1650
- const bindGroupOverNonEyes = this.device.createBindGroup({
1651
- label: `material bind group (over non-eyes): ${mat.name}`,
1652
- layout: this.hairBindGroupLayout,
1653
- entries: [
1654
- { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1655
- { binding: 1, resource: { buffer: this.lightUniformBuffer } },
1656
- { binding: 2, resource: diffuseTexture.createView() },
1657
- { binding: 3, resource: this.textureSampler },
1658
- { binding: 4, resource: { buffer: this.skinMatrixBuffer } },
1659
- { binding: 5, resource: toonTexture.createView() },
1660
- { binding: 6, resource: this.textureSampler },
1661
- { binding: 7, resource: { buffer: materialUniformBufferOverNonEyes } },
1662
- ],
1663
- });
1664
- // Store both bind groups for unified pipeline
1604
+ // Hair materials: create separate bind groups for over-eyes vs over-non-eyes
1605
+ const createHairBindGroup = (isOverEyes) => {
1606
+ const uniformData = new Float32Array(8);
1607
+ uniformData[0] = materialAlpha;
1608
+ uniformData[1] = 1.0; // alphaMultiplier (shader adjusts based on isOverEyes)
1609
+ uniformData[2] = this.rimLightIntensity;
1610
+ uniformData[3] = this.rimLightPower;
1611
+ uniformData[4] = 1.0; // rimColor.rgb
1612
+ uniformData[5] = 1.0;
1613
+ uniformData[6] = 1.0;
1614
+ uniformData[7] = isOverEyes ? 1.0 : 0.0;
1615
+ const buffer = this.device.createBuffer({
1616
+ label: `material uniform (${isOverEyes ? "over eyes" : "over non-eyes"}): ${mat.name}`,
1617
+ size: uniformData.byteLength,
1618
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1619
+ });
1620
+ this.device.queue.writeBuffer(buffer, 0, uniformData);
1621
+ return this.device.createBindGroup({
1622
+ label: `material bind group (${isOverEyes ? "over eyes" : "over non-eyes"}): ${mat.name}`,
1623
+ layout: this.mainBindGroupLayout,
1624
+ entries: [
1625
+ { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1626
+ { binding: 1, resource: { buffer: this.lightUniformBuffer } },
1627
+ { binding: 2, resource: diffuseTexture.createView() },
1628
+ { binding: 3, resource: this.textureSampler },
1629
+ { binding: 4, resource: { buffer: this.skinMatrixBuffer } },
1630
+ { binding: 5, resource: toonTexture.createView() },
1631
+ { binding: 6, resource: this.textureSampler },
1632
+ { binding: 7, resource: { buffer: buffer } },
1633
+ ],
1634
+ });
1635
+ };
1636
+ const bindGroupOverEyes = createHairBindGroup(true);
1637
+ const bindGroupOverNonEyes = createHairBindGroup(false);
1665
1638
  this.hairDrawsOverEyes.push({
1666
- count: matCount,
1667
- firstIndex: runningFirstIndex,
1639
+ count: indexCount,
1640
+ firstIndex: currentIndexOffset,
1668
1641
  bindGroup: bindGroupOverEyes,
1669
1642
  isTransparent,
1670
1643
  });
1671
1644
  this.hairDrawsOverNonEyes.push({
1672
- count: matCount,
1673
- firstIndex: runningFirstIndex,
1645
+ count: indexCount,
1646
+ firstIndex: currentIndexOffset,
1674
1647
  bindGroup: bindGroupOverNonEyes,
1675
1648
  isTransparent,
1676
1649
  });
1677
1650
  }
1678
1651
  else if (isTransparent) {
1679
1652
  this.transparentNonEyeNonHairDraws.push({
1680
- count: matCount,
1681
- firstIndex: runningFirstIndex,
1653
+ count: indexCount,
1654
+ firstIndex: currentIndexOffset,
1682
1655
  bindGroup,
1683
1656
  isTransparent,
1684
1657
  });
1685
1658
  }
1686
1659
  else {
1687
1660
  this.opaqueNonEyeNonHairDraws.push({
1688
- count: matCount,
1689
- firstIndex: runningFirstIndex,
1661
+ count: indexCount,
1662
+ firstIndex: currentIndexOffset,
1690
1663
  bindGroup,
1691
1664
  isTransparent,
1692
1665
  });
1693
1666
  }
1694
- // Outline for all materials (including transparent) - Edge flag is at bit 4 (0x10) in PMX format, not bit 0 (0x01)
1667
+ // Edge flag is at bit 4 (0x10) in PMX format
1695
1668
  if ((mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
1696
1669
  const materialUniformData = new Float32Array(8);
1697
1670
  materialUniformData[0] = mat.edgeColor[0]; // edgeColor.r
@@ -1699,9 +1672,9 @@ export class Engine {
1699
1672
  materialUniformData[2] = mat.edgeColor[2]; // edgeColor.b
1700
1673
  materialUniformData[3] = mat.edgeColor[3]; // edgeColor.a
1701
1674
  materialUniformData[4] = mat.edgeSize;
1702
- materialUniformData[5] = 0.0; // isOverEyes: 0.0 for all (unified pipeline doesn't use stencil)
1703
- materialUniformData[6] = 0.0; // _padding1
1704
- materialUniformData[7] = 0.0; // _padding2
1675
+ materialUniformData[5] = 0.0; // isOverEyes
1676
+ materialUniformData[6] = 0.0;
1677
+ materialUniformData[7] = 0.0;
1705
1678
  const materialUniformBuffer = this.device.createBuffer({
1706
1679
  label: `outline material uniform: ${mat.name}`,
1707
1680
  size: materialUniformData.byteLength,
@@ -1717,45 +1690,44 @@ export class Engine {
1717
1690
  { binding: 2, resource: { buffer: this.skinMatrixBuffer } },
1718
1691
  ],
1719
1692
  });
1720
- // Classify outlines into appropriate draw lists
1721
1693
  if (mat.isEye) {
1722
1694
  this.eyeOutlineDraws.push({
1723
- count: matCount,
1724
- firstIndex: runningFirstIndex,
1695
+ count: indexCount,
1696
+ firstIndex: currentIndexOffset,
1725
1697
  bindGroup: outlineBindGroup,
1726
1698
  isTransparent,
1727
1699
  });
1728
1700
  }
1729
1701
  else if (mat.isHair) {
1730
1702
  this.hairOutlineDraws.push({
1731
- count: matCount,
1732
- firstIndex: runningFirstIndex,
1703
+ count: indexCount,
1704
+ firstIndex: currentIndexOffset,
1733
1705
  bindGroup: outlineBindGroup,
1734
1706
  isTransparent,
1735
1707
  });
1736
1708
  }
1737
1709
  else if (isTransparent) {
1738
1710
  this.transparentNonEyeNonHairOutlineDraws.push({
1739
- count: matCount,
1740
- firstIndex: runningFirstIndex,
1711
+ count: indexCount,
1712
+ firstIndex: currentIndexOffset,
1741
1713
  bindGroup: outlineBindGroup,
1742
1714
  isTransparent,
1743
1715
  });
1744
1716
  }
1745
1717
  else {
1746
1718
  this.opaqueNonEyeNonHairOutlineDraws.push({
1747
- count: matCount,
1748
- firstIndex: runningFirstIndex,
1719
+ count: indexCount,
1720
+ firstIndex: currentIndexOffset,
1749
1721
  bindGroup: outlineBindGroup,
1750
1722
  isTransparent,
1751
1723
  });
1752
1724
  }
1753
1725
  }
1754
- runningFirstIndex += matCount;
1726
+ currentIndexOffset += indexCount;
1755
1727
  }
1728
+ this.gpuMemoryMB = this.calculateGpuMemory();
1756
1729
  }
1757
- // Helper: Load texture from file path with optional max size limit
1758
- async createTextureFromPath(path, maxSize = 2048) {
1730
+ async createTextureFromPath(path) {
1759
1731
  const cached = this.textureCache.get(path);
1760
1732
  if (cached) {
1761
1733
  return cached;
@@ -1765,41 +1737,28 @@ export class Engine {
1765
1737
  if (!response.ok) {
1766
1738
  throw new Error(`HTTP ${response.status}: ${response.statusText}`);
1767
1739
  }
1768
- let imageBitmap = await createImageBitmap(await response.blob(), {
1740
+ const imageBitmap = await createImageBitmap(await response.blob(), {
1769
1741
  premultiplyAlpha: "none",
1770
1742
  colorSpaceConversion: "none",
1771
1743
  });
1772
- // Downscale if texture is too large
1773
- let finalWidth = imageBitmap.width;
1774
- let finalHeight = imageBitmap.height;
1775
- if (finalWidth > maxSize || finalHeight > maxSize) {
1776
- const scale = Math.min(maxSize / finalWidth, maxSize / finalHeight);
1777
- finalWidth = Math.floor(finalWidth * scale);
1778
- finalHeight = Math.floor(finalHeight * scale);
1779
- // Create canvas to downscale
1780
- const canvas = new OffscreenCanvas(finalWidth, finalHeight);
1781
- const ctx = canvas.getContext("2d");
1782
- if (ctx) {
1783
- ctx.drawImage(imageBitmap, 0, 0, finalWidth, finalHeight);
1784
- imageBitmap = await createImageBitmap(canvas);
1785
- }
1786
- }
1787
1744
  const texture = this.device.createTexture({
1788
1745
  label: `texture: ${path}`,
1789
- size: [finalWidth, finalHeight],
1746
+ size: [imageBitmap.width, imageBitmap.height],
1790
1747
  format: "rgba8unorm",
1791
1748
  usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
1792
1749
  });
1793
- this.device.queue.copyExternalImageToTexture({ source: imageBitmap }, { texture }, [finalWidth, finalHeight]);
1750
+ this.device.queue.copyExternalImageToTexture({ source: imageBitmap }, { texture }, [
1751
+ imageBitmap.width,
1752
+ imageBitmap.height,
1753
+ ]);
1794
1754
  this.textureCache.set(path, texture);
1795
- this.textureSizes.set(path, { width: finalWidth, height: finalHeight });
1796
1755
  return texture;
1797
1756
  }
1798
1757
  catch {
1799
1758
  return null;
1800
1759
  }
1801
1760
  }
1802
- // Step 9: Render one frame
1761
+ // Render strategy: 1) Opaque non-eye/hair 2) Eyes (stencil=1) 3) Hair (depth pre-pass + split by stencil) 4) Transparent 5) Bloom
1803
1762
  render() {
1804
1763
  if (this.multisampleTexture && this.camera && this.device && this.currentModel) {
1805
1764
  const currentTime = performance.now();
@@ -1807,16 +1766,17 @@ export class Engine {
1807
1766
  this.lastFrameTime = currentTime;
1808
1767
  this.updateCameraUniforms();
1809
1768
  this.updateRenderTarget();
1810
- this.updateModelPose(deltaTime);
1769
+ // Use single encoder for both compute and render (reduces sync points)
1811
1770
  const encoder = this.device.createCommandEncoder();
1771
+ this.updateModelPose(deltaTime, encoder);
1812
1772
  const pass = encoder.beginRenderPass(this.renderPassDescriptor);
1813
1773
  pass.setVertexBuffer(0, this.vertexBuffer);
1814
1774
  pass.setVertexBuffer(1, this.jointsBuffer);
1815
1775
  pass.setVertexBuffer(2, this.weightsBuffer);
1816
1776
  pass.setIndexBuffer(this.indexBuffer, "uint32");
1817
1777
  this.drawCallCount = 0;
1818
- // PASS 1: Opaque non-eye, non-hair
1819
- pass.setPipeline(this.pipeline);
1778
+ // Pass 1: Opaque non-eye, non-hair
1779
+ pass.setPipeline(this.modelPipeline);
1820
1780
  for (const draw of this.opaqueNonEyeNonHairDraws) {
1821
1781
  if (draw.count > 0) {
1822
1782
  pass.setBindGroup(0, draw.bindGroup);
@@ -1824,9 +1784,9 @@ export class Engine {
1824
1784
  this.drawCallCount++;
1825
1785
  }
1826
1786
  }
1827
- // PASS 2: Eyes (writes stencil = 1)
1787
+ // Pass 2: Eyes (writes stencil value for hair to test against)
1828
1788
  pass.setPipeline(this.eyePipeline);
1829
- pass.setStencilReference(1); // Set stencil reference value to 1
1789
+ pass.setStencilReference(this.STENCIL_EYE_VALUE);
1830
1790
  for (const draw of this.eyeDraws) {
1831
1791
  if (draw.count > 0) {
1832
1792
  pass.setBindGroup(0, draw.bindGroup);
@@ -1834,9 +1794,9 @@ export class Engine {
1834
1794
  this.drawCallCount++;
1835
1795
  }
1836
1796
  }
1837
- // PASS 3: Hair rendering with depth pre-pass and unified pipeline
1797
+ // Pass 3: Hair rendering (depth pre-pass + shading + outlines)
1838
1798
  this.drawOutlines(pass, false);
1839
- // 3a: Hair depth pre-pass (eliminates overdraw by rejecting fragments early)
1799
+ // 3a: Hair depth pre-pass (reduces overdraw via early depth rejection)
1840
1800
  if (this.hairDrawsOverEyes.length > 0 || this.hairDrawsOverNonEyes.length > 0) {
1841
1801
  pass.setPipeline(this.hairDepthPipeline);
1842
1802
  for (const draw of this.hairDrawsOverEyes) {
@@ -1852,10 +1812,10 @@ export class Engine {
1852
1812
  }
1853
1813
  }
1854
1814
  }
1855
- // 3b: Hair shading pass with unified pipeline and dynamic branching
1815
+ // 3b: Hair shading (split by stencil for transparency over eyes)
1856
1816
  if (this.hairDrawsOverEyes.length > 0) {
1857
- pass.setPipeline(this.hairUnifiedPipelineOverEyes);
1858
- pass.setStencilReference(1);
1817
+ pass.setPipeline(this.hairPipelineOverEyes);
1818
+ pass.setStencilReference(this.STENCIL_EYE_VALUE);
1859
1819
  for (const draw of this.hairDrawsOverEyes) {
1860
1820
  if (draw.count > 0) {
1861
1821
  pass.setBindGroup(0, draw.bindGroup);
@@ -1865,8 +1825,8 @@ export class Engine {
1865
1825
  }
1866
1826
  }
1867
1827
  if (this.hairDrawsOverNonEyes.length > 0) {
1868
- pass.setPipeline(this.hairUnifiedPipelineOverNonEyes);
1869
- pass.setStencilReference(1);
1828
+ pass.setPipeline(this.hairPipelineOverNonEyes);
1829
+ pass.setStencilReference(this.STENCIL_EYE_VALUE);
1870
1830
  for (const draw of this.hairDrawsOverNonEyes) {
1871
1831
  if (draw.count > 0) {
1872
1832
  pass.setBindGroup(0, draw.bindGroup);
@@ -1875,9 +1835,9 @@ export class Engine {
1875
1835
  }
1876
1836
  }
1877
1837
  }
1878
- // 3c: Hair outlines - unified single pass without stencil testing
1838
+ // 3c: Hair outlines
1879
1839
  if (this.hairOutlineDraws.length > 0) {
1880
- pass.setPipeline(this.hairUnifiedOutlinePipeline);
1840
+ pass.setPipeline(this.hairOutlinePipeline);
1881
1841
  for (const draw of this.hairOutlineDraws) {
1882
1842
  if (draw.count > 0) {
1883
1843
  pass.setBindGroup(0, draw.bindGroup);
@@ -1885,8 +1845,8 @@ export class Engine {
1885
1845
  }
1886
1846
  }
1887
1847
  }
1888
- // PASS 4: Transparent non-eye, non-hair
1889
- pass.setPipeline(this.pipeline);
1848
+ // Pass 4: Transparent non-eye, non-hair
1849
+ pass.setPipeline(this.modelPipeline);
1890
1850
  for (const draw of this.transparentNonEyeNonHairDraws) {
1891
1851
  if (draw.count > 0) {
1892
1852
  pass.setBindGroup(0, draw.bindGroup);
@@ -1897,12 +1857,10 @@ export class Engine {
1897
1857
  this.drawOutlines(pass, true);
1898
1858
  pass.end();
1899
1859
  this.device.queue.submit([encoder.finish()]);
1900
- // Apply bloom post-processing
1901
1860
  this.applyBloom();
1902
1861
  this.updateStats(performance.now() - currentTime);
1903
1862
  }
1904
1863
  }
1905
- // Apply bloom post-processing
1906
1864
  applyBloom() {
1907
1865
  if (!this.sceneRenderTexture || !this.bloomExtractTexture) {
1908
1866
  return;
@@ -1917,9 +1875,9 @@ export class Engine {
1917
1875
  const encoder = this.device.createCommandEncoder();
1918
1876
  const width = this.canvas.width;
1919
1877
  const height = this.canvas.height;
1920
- const bloomWidth = Math.floor(width / 2);
1921
- const bloomHeight = Math.floor(height / 2);
1922
- // Pass 1: Extract bright areas (downsample to half resolution)
1878
+ const bloomWidth = Math.floor(width / this.BLOOM_DOWNSCALE_FACTOR);
1879
+ const bloomHeight = Math.floor(height / this.BLOOM_DOWNSCALE_FACTOR);
1880
+ // Extract bright areas
1923
1881
  const extractPass = encoder.beginRenderPass({
1924
1882
  label: "bloom extract",
1925
1883
  colorAttachments: [
@@ -1935,8 +1893,8 @@ export class Engine {
1935
1893
  extractPass.setBindGroup(0, this.bloomExtractBindGroup);
1936
1894
  extractPass.draw(6, 1, 0, 0);
1937
1895
  extractPass.end();
1938
- // Pass 2: Horizontal blur
1939
- const hBlurData = new Float32Array(4); // vec2f + padding = 4 floats
1896
+ // Horizontal blur
1897
+ const hBlurData = new Float32Array(4);
1940
1898
  hBlurData[0] = 1.0;
1941
1899
  hBlurData[1] = 0.0;
1942
1900
  this.device.queue.writeBuffer(this.blurDirectionBuffer, 0, hBlurData);
@@ -1955,8 +1913,8 @@ export class Engine {
1955
1913
  blurHPass.setBindGroup(0, this.bloomBlurHBindGroup);
1956
1914
  blurHPass.draw(6, 1, 0, 0);
1957
1915
  blurHPass.end();
1958
- // Pass 3: Vertical blur
1959
- const vBlurData = new Float32Array(4); // vec2f + padding = 4 floats
1916
+ // Vertical blur
1917
+ const vBlurData = new Float32Array(4);
1960
1918
  vBlurData[0] = 0.0;
1961
1919
  vBlurData[1] = 1.0;
1962
1920
  this.device.queue.writeBuffer(this.blurDirectionBuffer, 0, vBlurData);
@@ -1975,7 +1933,7 @@ export class Engine {
1975
1933
  blurVPass.setBindGroup(0, this.bloomBlurVBindGroup);
1976
1934
  blurVPass.draw(6, 1, 0, 0);
1977
1935
  blurVPass.end();
1978
- // Pass 4: Compose scene + bloom to canvas
1936
+ // Compose to canvas
1979
1937
  const composePass = encoder.beginRenderPass({
1980
1938
  label: "bloom compose",
1981
1939
  colorAttachments: [
@@ -1993,7 +1951,6 @@ export class Engine {
1993
1951
  composePass.end();
1994
1952
  this.device.queue.submit([encoder.finish()]);
1995
1953
  }
1996
- // Update camera uniform buffer each frame
1997
1954
  updateCameraUniforms() {
1998
1955
  const viewMatrix = this.camera.getViewMatrix();
1999
1956
  const projectionMatrix = this.camera.getProjectionMatrix();
@@ -2005,47 +1962,36 @@ export class Engine {
2005
1962
  this.cameraMatrixData[34] = cameraPos.z;
2006
1963
  this.device.queue.writeBuffer(this.cameraUniformBuffer, 0, this.cameraMatrixData);
2007
1964
  }
2008
- // Update render target texture view
2009
1965
  updateRenderTarget() {
2010
1966
  const colorAttachment = this.renderPassDescriptor.colorAttachments[0];
2011
1967
  if (this.sampleCount > 1) {
2012
- // Resolve to scene render texture for post-processing
2013
1968
  colorAttachment.resolveTarget = this.sceneRenderTextureView;
2014
1969
  }
2015
1970
  else {
2016
- // Render directly to scene render texture
2017
1971
  colorAttachment.view = this.sceneRenderTextureView;
2018
1972
  }
2019
1973
  }
2020
- updateModelPose(deltaTime) {
1974
+ updateModelPose(deltaTime, encoder) {
2021
1975
  this.currentModel.evaluatePose();
2022
1976
  const worldMats = this.currentModel.getBoneWorldMatrices();
2023
1977
  if (this.physics) {
2024
1978
  this.physics.step(deltaTime, worldMats, this.currentModel.getBoneInverseBindMatrices());
2025
1979
  }
2026
1980
  this.device.queue.writeBuffer(this.worldMatrixBuffer, 0, worldMats.buffer, worldMats.byteOffset, worldMats.byteLength);
2027
- this.computeSkinMatrices();
1981
+ this.computeSkinMatrices(encoder);
2028
1982
  }
2029
- // Compute skin matrices on GPU
2030
- computeSkinMatrices() {
1983
+ computeSkinMatrices(encoder) {
2031
1984
  const boneCount = this.currentModel.getSkeleton().bones.length;
2032
- const workgroupSize = 64;
2033
- // Dispatch exactly enough threads for all bones (no bounds check needed)
2034
- const workgroupCount = Math.ceil(boneCount / workgroupSize);
2035
- // Bone count is written once in setupModelBuffers() and never changes
2036
- const encoder = this.device.createCommandEncoder();
1985
+ const workgroupCount = Math.ceil(boneCount / this.COMPUTE_WORKGROUP_SIZE);
2037
1986
  const pass = encoder.beginComputePass();
2038
1987
  pass.setPipeline(this.skinMatrixComputePipeline);
2039
1988
  pass.setBindGroup(0, this.skinMatrixComputeBindGroup);
2040
1989
  pass.dispatchWorkgroups(workgroupCount);
2041
1990
  pass.end();
2042
- this.device.queue.submit([encoder.finish()]);
2043
1991
  }
2044
- // Draw outlines (opaque or transparent)
2045
1992
  drawOutlines(pass, transparent) {
2046
1993
  pass.setPipeline(this.outlinePipeline);
2047
1994
  if (transparent) {
2048
- // Draw transparent outlines (if any)
2049
1995
  for (const draw of this.transparentNonEyeNonHairOutlineDraws) {
2050
1996
  if (draw.count > 0) {
2051
1997
  pass.setBindGroup(0, draw.bindGroup);
@@ -2054,7 +2000,6 @@ export class Engine {
2054
2000
  }
2055
2001
  }
2056
2002
  else {
2057
- // Draw opaque outlines before main geometry
2058
2003
  for (const draw of this.opaqueNonEyeNonHairOutlineDraws) {
2059
2004
  if (draw.count > 0) {
2060
2005
  pass.setBindGroup(0, draw.bindGroup);
@@ -2081,12 +2026,12 @@ export class Engine {
2081
2026
  this.framesSinceLastUpdate = 0;
2082
2027
  this.lastFpsUpdate = now;
2083
2028
  }
2084
- // Calculate GPU memory: textures + buffers + render targets
2029
+ this.stats.gpuMemory = this.gpuMemoryMB;
2030
+ }
2031
+ calculateGpuMemory() {
2085
2032
  let textureMemoryBytes = 0;
2086
- for (const [path, size] of this.textureSizes.entries()) {
2087
- if (this.textureCache.has(path)) {
2088
- textureMemoryBytes += size.width * size.height * 4; // RGBA8 = 4 bytes per pixel
2089
- }
2033
+ for (const texture of this.textureCache.values()) {
2034
+ textureMemoryBytes += texture.width * texture.height * 4;
2090
2035
  }
2091
2036
  let bufferMemoryBytes = 0;
2092
2037
  if (this.vertexBuffer) {
@@ -2124,48 +2069,44 @@ export class Engine {
2124
2069
  if (skeleton)
2125
2070
  bufferMemoryBytes += Math.max(256, skeleton.bones.length * 16 * 4);
2126
2071
  }
2127
- bufferMemoryBytes += 40 * 4; // cameraUniformBuffer
2128
- bufferMemoryBytes += 64 * 4; // lightUniformBuffer
2129
- bufferMemoryBytes += 32; // boneCountBuffer
2130
- bufferMemoryBytes += 32; // blurDirectionBuffer
2131
- bufferMemoryBytes += 32; // bloomIntensityBuffer
2132
- bufferMemoryBytes += 32; // bloomThresholdBuffer
2072
+ bufferMemoryBytes += 40 * 4;
2073
+ bufferMemoryBytes += 64 * 4;
2074
+ bufferMemoryBytes += 32;
2075
+ bufferMemoryBytes += 32;
2076
+ bufferMemoryBytes += 32;
2077
+ bufferMemoryBytes += 32;
2133
2078
  if (this.fullscreenQuadBuffer) {
2134
- bufferMemoryBytes += 24 * 4; // fullscreenQuadBuffer (6 vertices * 4 floats)
2079
+ bufferMemoryBytes += 24 * 4;
2135
2080
  }
2136
- // Material uniform buffers: Float32Array(8) = 32 bytes each
2137
2081
  const totalMaterialDraws = this.opaqueNonEyeNonHairDraws.length +
2138
2082
  this.eyeDraws.length +
2139
2083
  this.hairDrawsOverEyes.length +
2140
2084
  this.hairDrawsOverNonEyes.length +
2141
2085
  this.transparentNonEyeNonHairDraws.length;
2142
- bufferMemoryBytes += totalMaterialDraws * 32; // Material uniform buffers (8 floats = 32 bytes)
2143
- // Outline material uniform buffers: Float32Array(8) = 32 bytes each
2086
+ bufferMemoryBytes += totalMaterialDraws * 32;
2144
2087
  const totalOutlineDraws = this.opaqueNonEyeNonHairOutlineDraws.length +
2145
2088
  this.eyeOutlineDraws.length +
2146
2089
  this.hairOutlineDraws.length +
2147
2090
  this.transparentNonEyeNonHairOutlineDraws.length;
2148
- bufferMemoryBytes += totalOutlineDraws * 32; // Outline material uniform buffers
2091
+ bufferMemoryBytes += totalOutlineDraws * 32;
2149
2092
  let renderTargetMemoryBytes = 0;
2150
2093
  if (this.multisampleTexture) {
2151
2094
  const width = this.canvas.width;
2152
2095
  const height = this.canvas.height;
2153
- renderTargetMemoryBytes += width * height * 4 * this.sampleCount; // multisample color
2154
- renderTargetMemoryBytes += width * height * 4; // depth (depth24plus-stencil8 = 4 bytes)
2096
+ renderTargetMemoryBytes += width * height * 4 * this.sampleCount;
2097
+ renderTargetMemoryBytes += width * height * 4;
2155
2098
  }
2156
2099
  if (this.sceneRenderTexture) {
2157
2100
  const width = this.canvas.width;
2158
2101
  const height = this.canvas.height;
2159
- renderTargetMemoryBytes += width * height * 4; // sceneRenderTexture (non-multisampled)
2102
+ renderTargetMemoryBytes += width * height * 4;
2160
2103
  }
2161
2104
  if (this.bloomExtractTexture) {
2162
- const width = Math.floor(this.canvas.width / 2);
2163
- const height = Math.floor(this.canvas.height / 2);
2164
- renderTargetMemoryBytes += width * height * 4; // bloomExtractTexture
2165
- renderTargetMemoryBytes += width * height * 4; // bloomBlurTexture1
2166
- renderTargetMemoryBytes += width * height * 4; // bloomBlurTexture2
2105
+ const width = Math.floor(this.canvas.width / this.BLOOM_DOWNSCALE_FACTOR);
2106
+ const height = Math.floor(this.canvas.height / this.BLOOM_DOWNSCALE_FACTOR);
2107
+ renderTargetMemoryBytes += width * height * 4 * 3;
2167
2108
  }
2168
2109
  const totalGPUMemoryBytes = textureMemoryBytes + bufferMemoryBytes + renderTargetMemoryBytes;
2169
- this.stats.gpuMemory = Math.round((totalGPUMemoryBytes / 1024 / 1024) * 100) / 100;
2110
+ return Math.round((totalGPUMemoryBytes / 1024 / 1024) * 100) / 100;
2170
2111
  }
2171
2112
  }