reze-engine 0.2.4 → 0.2.6

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
@@ -17,12 +21,20 @@ export class Engine {
17
21
  this.bloomIntensity = 0.12;
18
22
  // Rim light settings
19
23
  this.rimLightIntensity = 0.45;
20
- this.rimLightPower = 2.0;
21
24
  this.currentModel = null;
22
25
  this.modelDir = "";
23
26
  this.physics = null;
24
27
  this.textureCache = new Map();
25
- this.textureSizes = new Map();
28
+ // Draw lists
29
+ this.opaqueDraws = [];
30
+ this.eyeDraws = [];
31
+ this.hairDrawsOverEyes = [];
32
+ this.hairDrawsOverNonEyes = [];
33
+ this.transparentDraws = [];
34
+ this.opaqueOutlineDraws = [];
35
+ this.eyeOutlineDraws = [];
36
+ this.hairOutlineDraws = [];
37
+ this.transparentOutlineDraws = [];
26
38
  this.lastFpsUpdate = performance.now();
27
39
  this.framesSinceLastUpdate = 0;
28
40
  this.frameTimeSamples = [];
@@ -38,15 +50,7 @@ export class Engine {
38
50
  this.renderLoopCallback = null;
39
51
  this.animationFrames = [];
40
52
  this.animationTimeouts = [];
41
- this.opaqueNonEyeNonHairDraws = [];
42
- this.eyeDraws = [];
43
- this.hairDrawsOverEyes = [];
44
- this.hairDrawsOverNonEyes = [];
45
- this.transparentNonEyeNonHairDraws = [];
46
- this.opaqueNonEyeNonHairOutlineDraws = [];
47
- this.eyeOutlineDraws = [];
48
- this.hairOutlineDraws = [];
49
- this.transparentNonEyeNonHairOutlineDraws = [];
53
+ this.gpuMemoryMB = 0;
50
54
  this.canvas = canvas;
51
55
  if (options) {
52
56
  this.ambient = options.ambient ?? 1.0;
@@ -80,9 +84,8 @@ export class Engine {
80
84
  this.createBloomPipelines();
81
85
  this.setupResize();
82
86
  }
83
- // Step 2: Create shaders and render pipelines
84
87
  createPipelines() {
85
- this.textureSampler = this.device.createSampler({
88
+ this.materialSampler = this.device.createSampler({
86
89
  magFilter: "linear",
87
90
  minFilter: "linear",
88
91
  addressModeU: "repeat",
@@ -90,130 +93,126 @@ export class Engine {
90
93
  });
91
94
  const shaderModule = this.device.createShaderModule({
92
95
  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
- }
96
+ code: /* wgsl */ `
97
+ struct CameraUniforms {
98
+ view: mat4x4f,
99
+ projection: mat4x4f,
100
+ viewPos: vec3f,
101
+ _padding: f32,
102
+ };
103
+
104
+ struct Light {
105
+ direction: vec3f,
106
+ _padding1: f32,
107
+ color: vec3f,
108
+ intensity: f32,
109
+ };
110
+
111
+ struct LightUniforms {
112
+ ambient: f32,
113
+ lightCount: f32,
114
+ _padding1: f32,
115
+ _padding2: f32,
116
+ lights: array<Light, 4>,
117
+ };
118
+
119
+ struct MaterialUniforms {
120
+ alpha: f32,
121
+ alphaMultiplier: f32,
122
+ rimIntensity: f32,
123
+ _padding1: f32,
124
+ rimColor: vec3f,
125
+ isOverEyes: f32, // 1.0 if rendering over eyes, 0.0 otherwise
126
+ };
127
+
128
+ struct VertexOutput {
129
+ @builtin(position) position: vec4f,
130
+ @location(0) normal: vec3f,
131
+ @location(1) uv: vec2f,
132
+ @location(2) worldPos: vec3f,
133
+ };
134
+
135
+ @group(0) @binding(0) var<uniform> camera: CameraUniforms;
136
+ @group(0) @binding(1) var<uniform> light: LightUniforms;
137
+ @group(0) @binding(2) var diffuseTexture: texture_2d<f32>;
138
+ @group(0) @binding(3) var diffuseSampler: sampler;
139
+ @group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
140
+ @group(0) @binding(5) var toonTexture: texture_2d<f32>;
141
+ @group(0) @binding(6) var toonSampler: sampler;
142
+ @group(0) @binding(7) var<uniform> material: MaterialUniforms;
143
+
144
+ @vertex fn vs(
145
+ @location(0) position: vec3f,
146
+ @location(1) normal: vec3f,
147
+ @location(2) uv: vec2f,
148
+ @location(3) joints0: vec4<u32>,
149
+ @location(4) weights0: vec4<f32>
150
+ ) -> VertexOutput {
151
+ var output: VertexOutput;
152
+ let pos4 = vec4f(position, 1.0);
153
+
154
+ // Branchless weight normalization (avoids GPU branch divergence)
155
+ let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
156
+ let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);
157
+ let normalizedWeights = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
158
+
159
+ var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
160
+ var skinnedNrm = vec3f(0.0, 0.0, 0.0);
161
+ for (var i = 0u; i < 4u; i++) {
162
+ let j = joints0[i];
163
+ let w = normalizedWeights[i];
164
+ let m = skinMats[j];
165
+ skinnedPos += (m * pos4) * w;
166
+ let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
167
+ skinnedNrm += (r3 * normal) * w;
168
+ }
169
+ let worldPos = skinnedPos.xyz;
170
+ output.position = camera.projection * camera.view * vec4f(worldPos, 1.0);
171
+ output.normal = normalize(skinnedNrm);
172
+ output.uv = uv;
173
+ output.worldPos = worldPos;
174
+ return output;
175
+ }
176
+
177
+ @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
178
+ // Early alpha test - discard before expensive calculations
179
+ var finalAlpha = material.alpha * material.alphaMultiplier;
180
+ if (material.isOverEyes > 0.5) {
181
+ finalAlpha *= 0.5; // Hair over eyes gets 50% alpha
182
+ }
183
+ if (finalAlpha < 0.001) {
184
+ discard;
185
+ }
186
+
187
+ let n = normalize(input.normal);
188
+ let albedo = textureSample(diffuseTexture, diffuseSampler, input.uv).rgb;
189
+
190
+ var lightAccum = vec3f(light.ambient);
191
+ let numLights = u32(light.lightCount);
192
+ for (var i = 0u; i < numLights; i++) {
193
+ let l = -light.lights[i].direction;
194
+ let nDotL = max(dot(n, l), 0.0);
195
+ let toonUV = vec2f(nDotL, 0.5);
196
+ let toonFactor = textureSample(toonTexture, toonSampler, toonUV).rgb;
197
+ let radiance = light.lights[i].color * light.lights[i].intensity;
198
+ lightAccum += toonFactor * radiance * nDotL;
199
+ }
200
+
201
+ // Rim light calculation
202
+ let viewDir = normalize(camera.viewPos - input.worldPos);
203
+ var rimFactor = 1.0 - max(dot(n, viewDir), 0.0);
204
+ rimFactor = rimFactor * rimFactor; // Optimized: direct multiply instead of pow(x, 2.0)
205
+ let rimLight = material.rimColor * material.rimIntensity * rimFactor;
206
+
207
+ let color = albedo * lightAccum + rimLight;
208
+
209
+ return vec4f(color, finalAlpha);
210
+ }
212
211
  `,
213
212
  });
214
213
  // 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",
214
+ this.mainBindGroupLayout = this.device.createBindGroupLayout({
215
+ label: "main material bind group layout",
217
216
  entries: [
218
217
  { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // camera
219
218
  { binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // light
@@ -225,14 +224,13 @@ export class Engine {
225
224
  { binding: 7, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // material
226
225
  ],
227
226
  });
228
- const sharedPipelineLayout = this.device.createPipelineLayout({
229
- label: "shared pipeline layout",
230
- bindGroupLayouts: [this.hairBindGroupLayout],
227
+ const mainPipelineLayout = this.device.createPipelineLayout({
228
+ label: "main pipeline layout",
229
+ bindGroupLayouts: [this.mainBindGroupLayout],
231
230
  });
232
- // Single pipeline for all materials with alpha blending
233
- this.pipeline = this.device.createRenderPipeline({
231
+ this.modelPipeline = this.device.createRenderPipeline({
234
232
  label: "model pipeline",
235
- layout: sharedPipelineLayout,
233
+ layout: mainPipelineLayout,
236
234
  vertex: {
237
235
  module: shaderModule,
238
236
  buffers: [
@@ -299,77 +297,73 @@ export class Engine {
299
297
  });
300
298
  const outlineShaderModule = this.device.createShaderModule({
301
299
  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
- }
300
+ code: /* wgsl */ `
301
+ struct CameraUniforms {
302
+ view: mat4x4f,
303
+ projection: mat4x4f,
304
+ viewPos: vec3f,
305
+ _padding: f32,
306
+ };
307
+
308
+ struct MaterialUniforms {
309
+ edgeColor: vec4f,
310
+ edgeSize: f32,
311
+ isOverEyes: f32, // 1.0 if rendering over eyes, 0.0 otherwise (for hair outlines)
312
+ _padding1: f32,
313
+ _padding2: f32,
314
+ };
315
+
316
+ @group(0) @binding(0) var<uniform> camera: CameraUniforms;
317
+ @group(0) @binding(1) var<uniform> material: MaterialUniforms;
318
+ @group(0) @binding(2) var<storage, read> skinMats: array<mat4x4f>;
319
+
320
+ struct VertexOutput {
321
+ @builtin(position) position: vec4f,
322
+ };
323
+
324
+ @vertex fn vs(
325
+ @location(0) position: vec3f,
326
+ @location(1) normal: vec3f,
327
+ @location(3) joints0: vec4<u32>,
328
+ @location(4) weights0: vec4<f32>
329
+ ) -> VertexOutput {
330
+ var output: VertexOutput;
331
+ let pos4 = vec4f(position, 1.0);
332
+
333
+ // Branchless weight normalization (avoids GPU branch divergence)
334
+ let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
335
+ let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);
336
+ let normalizedWeights = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
337
+
338
+ var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
339
+ var skinnedNrm = vec3f(0.0, 0.0, 0.0);
340
+ for (var i = 0u; i < 4u; i++) {
341
+ let j = joints0[i];
342
+ let w = normalizedWeights[i];
343
+ let m = skinMats[j];
344
+ skinnedPos += (m * pos4) * w;
345
+ let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
346
+ skinnedNrm += (r3 * normal) * w;
347
+ }
348
+ let worldPos = skinnedPos.xyz;
349
+ let worldNormal = normalize(skinnedNrm);
350
+
351
+ // MMD invert hull: expand vertices outward along normals
352
+ let scaleFactor = 0.01;
353
+ let expandedPos = worldPos + worldNormal * material.edgeSize * scaleFactor;
354
+ output.position = camera.projection * camera.view * vec4f(expandedPos, 1.0);
355
+ return output;
356
+ }
357
+
358
+ @fragment fn fs() -> @location(0) vec4f {
359
+ var color = material.edgeColor;
360
+
361
+ if (material.isOverEyes > 0.5) {
362
+ color.a *= 0.5; // Hair outlines over eyes get 50% alpha
363
+ }
364
+
365
+ return color;
366
+ }
373
367
  `,
374
368
  });
375
369
  this.outlinePipeline = this.device.createRenderPipeline({
@@ -435,9 +429,9 @@ export class Engine {
435
429
  count: this.sampleCount,
436
430
  },
437
431
  });
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",
432
+ // Hair outline pipeline
433
+ this.hairOutlinePipeline = this.device.createRenderPipeline({
434
+ label: "hair outline pipeline",
441
435
  layout: outlinePipelineLayout,
442
436
  vertex: {
443
437
  module: outlineShaderModule,
@@ -505,7 +499,7 @@ export class Engine {
505
499
  // Eye overlay pipeline (renders after opaque, writes stencil)
506
500
  this.eyePipeline = this.device.createRenderPipeline({
507
501
  label: "eye overlay pipeline",
508
- layout: sharedPipelineLayout,
502
+ layout: mainPipelineLayout,
509
503
  vertex: {
510
504
  module: shaderModule,
511
505
  buffers: [
@@ -570,55 +564,51 @@ export class Engine {
570
564
  // Depth-only shader for hair pre-pass (reduces overdraw by early depth rejection)
571
565
  const depthOnlyShaderModule = this.device.createShaderModule({
572
566
  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
- }
567
+ code: /* wgsl */ `
568
+ struct CameraUniforms {
569
+ view: mat4x4f,
570
+ projection: mat4x4f,
571
+ viewPos: vec3f,
572
+ _padding: f32,
573
+ };
574
+
575
+ @group(0) @binding(0) var<uniform> camera: CameraUniforms;
576
+ @group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
577
+
578
+ @vertex fn vs(
579
+ @location(0) position: vec3f,
580
+ @location(1) normal: vec3f,
581
+ @location(3) joints0: vec4<u32>,
582
+ @location(4) weights0: vec4<f32>
583
+ ) -> @builtin(position) vec4f {
584
+ let pos4 = vec4f(position, 1.0);
585
+
586
+ // Branchless weight normalization (avoids GPU branch divergence)
587
+ let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
588
+ let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);
589
+ let normalizedWeights = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
590
+
591
+ var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
592
+ for (var i = 0u; i < 4u; i++) {
593
+ let j = joints0[i];
594
+ let w = normalizedWeights[i];
595
+ let m = skinMats[j];
596
+ skinnedPos += (m * pos4) * w;
597
+ }
598
+ let worldPos = skinnedPos.xyz;
599
+ let clipPos = camera.projection * camera.view * vec4f(worldPos, 1.0);
600
+ return clipPos;
601
+ }
602
+
603
+ @fragment fn fs() -> @location(0) vec4f {
604
+ return vec4f(0.0, 0.0, 0.0, 0.0); // Transparent - color writes disabled via writeMask
605
+ }
616
606
  `,
617
607
  });
618
608
  // Hair depth pre-pass pipeline: depth-only with color writes disabled to eliminate overdraw
619
609
  this.hairDepthPipeline = this.device.createRenderPipeline({
620
610
  label: "hair depth pre-pass",
621
- layout: sharedPipelineLayout,
611
+ layout: mainPipelineLayout,
622
612
  vertex: {
623
613
  module: depthOnlyShaderModule,
624
614
  buffers: [
@@ -657,10 +647,10 @@ export class Engine {
657
647
  },
658
648
  multisample: { count: this.sampleCount },
659
649
  });
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,
650
+ // Hair pipeline for rendering over eyes (stencil == 1)
651
+ this.hairPipelineOverEyes = this.device.createRenderPipeline({
652
+ label: "hair pipeline (over eyes)",
653
+ layout: mainPipelineLayout,
664
654
  vertex: {
665
655
  module: shaderModule,
666
656
  buffers: [
@@ -722,10 +712,10 @@ export class Engine {
722
712
  },
723
713
  multisample: { count: this.sampleCount },
724
714
  });
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,
715
+ // Hair pipeline for rendering over non-eyes (stencil != 1)
716
+ this.hairPipelineOverNonEyes = this.device.createRenderPipeline({
717
+ label: "hair pipeline (over non-eyes)",
718
+ layout: mainPipelineLayout,
729
719
  vertex: {
730
720
  module: shaderModule,
731
721
  buffers: [
@@ -792,31 +782,30 @@ export class Engine {
792
782
  createSkinMatrixComputePipeline() {
793
783
  const computeShader = this.device.createShaderModule({
794
784
  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
- }
785
+ code: /* wgsl */ `
786
+ struct BoneCountUniform {
787
+ count: u32,
788
+ _padding1: u32,
789
+ _padding2: u32,
790
+ _padding3: u32,
791
+ _padding4: vec4<u32>,
792
+ };
793
+
794
+ @group(0) @binding(0) var<uniform> boneCount: BoneCountUniform;
795
+ @group(0) @binding(1) var<storage, read> worldMatrices: array<mat4x4f>;
796
+ @group(0) @binding(2) var<storage, read> inverseBindMatrices: array<mat4x4f>;
797
+ @group(0) @binding(3) var<storage, read_write> skinMatrices: array<mat4x4f>;
798
+
799
+ @compute @workgroup_size(64) // Must match COMPUTE_WORKGROUP_SIZE
800
+ fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
801
+ let boneIndex = globalId.x;
802
+ if (boneIndex >= boneCount.count) {
803
+ return;
804
+ }
805
+ let worldMat = worldMatrices[boneIndex];
806
+ let invBindMat = inverseBindMatrices[boneIndex];
807
+ skinMatrices[boneIndex] = worldMat * invBindMat;
808
+ }
820
809
  `,
821
810
  });
822
811
  this.skinMatrixComputePipeline = this.device.createComputePipeline({
@@ -870,143 +859,140 @@ export class Engine {
870
859
  // Bloom extraction shader (extracts bright areas)
871
860
  const bloomExtractShader = this.device.createShaderModule({
872
861
  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
- }
862
+ code: /* wgsl */ `
863
+ struct VertexOutput {
864
+ @builtin(position) position: vec4f,
865
+ @location(0) uv: vec2f,
866
+ };
867
+
868
+ @vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
869
+ var output: VertexOutput;
870
+ // Generate fullscreen quad from vertex index
871
+ let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
872
+ let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
873
+ output.position = vec4f(x, y, 0.0, 1.0);
874
+ output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
875
+ return output;
876
+ }
877
+
878
+ struct BloomExtractUniforms {
879
+ threshold: f32,
880
+ _padding1: f32,
881
+ _padding2: f32,
882
+ _padding3: f32,
883
+ _padding4: f32,
884
+ _padding5: f32,
885
+ _padding6: f32,
886
+ _padding7: f32,
887
+ };
888
+
889
+ @group(0) @binding(0) var inputTexture: texture_2d<f32>;
890
+ @group(0) @binding(1) var inputSampler: sampler;
891
+ @group(0) @binding(2) var<uniform> extractUniforms: BloomExtractUniforms;
892
+
893
+ @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
894
+ let color = textureSample(inputTexture, inputSampler, input.uv);
895
+ // Extract bright areas above threshold
896
+ let threshold = extractUniforms.threshold;
897
+ let bloom = max(vec3f(0.0), color.rgb - vec3f(threshold)) / max(0.001, 1.0 - threshold);
898
+ return vec4f(bloom, color.a);
899
+ }
911
900
  `,
912
901
  });
913
902
  // Bloom blur shader (gaussian blur - can be used for both horizontal and vertical)
914
903
  const bloomBlurShader = this.device.createShaderModule({
915
904
  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
- }
905
+ code: /* wgsl */ `
906
+ struct VertexOutput {
907
+ @builtin(position) position: vec4f,
908
+ @location(0) uv: vec2f,
909
+ };
910
+
911
+ @vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
912
+ var output: VertexOutput;
913
+ let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
914
+ let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
915
+ output.position = vec4f(x, y, 0.0, 1.0);
916
+ output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
917
+ return output;
918
+ }
919
+
920
+ struct BlurUniforms {
921
+ direction: vec2f,
922
+ _padding1: f32,
923
+ _padding2: f32,
924
+ _padding3: f32,
925
+ _padding4: f32,
926
+ _padding5: f32,
927
+ _padding6: f32,
928
+ };
929
+
930
+ @group(0) @binding(0) var inputTexture: texture_2d<f32>;
931
+ @group(0) @binding(1) var inputSampler: sampler;
932
+ @group(0) @binding(2) var<uniform> blurUniforms: BlurUniforms;
933
+
934
+ // 3-tap gaussian blur using bilinear filtering trick (40% fewer texture fetches!)
935
+ @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
936
+ let texelSize = 1.0 / vec2f(textureDimensions(inputTexture));
937
+
938
+ // Bilinear optimization: leverage hardware filtering to sample between pixels
939
+ // Original 5-tap: weights [0.06136, 0.24477, 0.38774, 0.24477, 0.06136] at offsets [-2, -1, 0, 1, 2]
940
+ // Optimized 3-tap: combine adjacent samples using weighted offsets
941
+ let weight0 = 0.38774; // Center sample
942
+ let weight1 = 0.24477 + 0.06136; // Combined outer samples = 0.30613
943
+ let offset1 = (0.24477 * 1.0 + 0.06136 * 2.0) / weight1; // Weighted position = 1.2
944
+
945
+ var result = textureSample(inputTexture, inputSampler, input.uv) * weight0;
946
+ let offsetVec = offset1 * texelSize * blurUniforms.direction;
947
+ result += textureSample(inputTexture, inputSampler, input.uv + offsetVec) * weight1;
948
+ result += textureSample(inputTexture, inputSampler, input.uv - offsetVec) * weight1;
949
+
950
+ return result;
951
+ }
966
952
  `,
967
953
  });
968
954
  // Bloom composition shader (combines original scene with bloom)
969
955
  const bloomComposeShader = this.device.createShaderModule({
970
956
  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
- }
957
+ code: /* wgsl */ `
958
+ struct VertexOutput {
959
+ @builtin(position) position: vec4f,
960
+ @location(0) uv: vec2f,
961
+ };
962
+
963
+ @vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
964
+ var output: VertexOutput;
965
+ let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
966
+ let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
967
+ output.position = vec4f(x, y, 0.0, 1.0);
968
+ output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
969
+ return output;
970
+ }
971
+
972
+ struct BloomComposeUniforms {
973
+ intensity: f32,
974
+ _padding1: f32,
975
+ _padding2: f32,
976
+ _padding3: f32,
977
+ _padding4: f32,
978
+ _padding5: f32,
979
+ _padding6: f32,
980
+ _padding7: f32,
981
+ };
982
+
983
+ @group(0) @binding(0) var sceneTexture: texture_2d<f32>;
984
+ @group(0) @binding(1) var sceneSampler: sampler;
985
+ @group(0) @binding(2) var bloomTexture: texture_2d<f32>;
986
+ @group(0) @binding(3) var bloomSampler: sampler;
987
+ @group(0) @binding(4) var<uniform> composeUniforms: BloomComposeUniforms;
988
+
989
+ @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
990
+ let scene = textureSample(sceneTexture, sceneSampler, input.uv);
991
+ let bloom = textureSample(bloomTexture, bloomSampler, input.uv);
992
+ // Additive blending with intensity control
993
+ let result = scene.rgb + bloom.rgb * composeUniforms.intensity;
994
+ return vec4f(result, scene.a);
995
+ }
1010
996
  `,
1011
997
  });
1012
998
  // Create uniform buffer for blur direction (minimum 32 bytes for WebGPU)
@@ -1092,11 +1078,9 @@ export class Engine {
1092
1078
  this.bloomThresholdBuffer = bloomThresholdBuffer;
1093
1079
  this.linearSampler = linearSampler;
1094
1080
  }
1095
- // Setup bloom textures and bind groups (called when canvas is resized)
1096
1081
  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);
1082
+ const bloomWidth = Math.floor(width / this.BLOOM_DOWNSCALE_FACTOR);
1083
+ const bloomHeight = Math.floor(height / this.BLOOM_DOWNSCALE_FACTOR);
1100
1084
  this.bloomExtractTexture = this.device.createTexture({
1101
1085
  label: "bloom extract",
1102
1086
  size: [bloomWidth, bloomHeight],
@@ -1331,7 +1315,9 @@ export class Engine {
1331
1315
  this.physics.reset(worldMats, this.currentModel.getBoneInverseBindMatrices());
1332
1316
  // Upload matrices immediately so next frame shows correct pose
1333
1317
  this.device.queue.writeBuffer(this.worldMatrixBuffer, 0, worldMats.buffer, worldMats.byteOffset, worldMats.byteLength);
1334
- this.computeSkinMatrices();
1318
+ const encoder = this.device.createCommandEncoder();
1319
+ this.computeSkinMatrices(encoder);
1320
+ this.device.queue.submit([encoder.finish()]);
1335
1321
  }
1336
1322
  }
1337
1323
  for (const [_, keyFrames] of boneKeyFramesByBone.entries()) {
@@ -1495,7 +1481,6 @@ export class Engine {
1495
1481
  }
1496
1482
  await this.setupMaterials(model);
1497
1483
  }
1498
- // Step 8: Load textures and create material bind groups
1499
1484
  async setupMaterials(model) {
1500
1485
  const materials = model.getMaterials();
1501
1486
  if (materials.length === 0) {
@@ -1540,22 +1525,21 @@ export class Engine {
1540
1525
  });
1541
1526
  this.device.queue.writeTexture({ texture: defaultToonTexture }, defaultToonData, { bytesPerRow: 256 * 4 }, [256, 2]);
1542
1527
  this.textureCache.set(defaultToonPath, defaultToonTexture);
1543
- this.textureSizes.set(defaultToonPath, { width: 256, height: 2 });
1544
1528
  return defaultToonTexture;
1545
1529
  };
1546
- this.opaqueNonEyeNonHairDraws = [];
1530
+ this.opaqueDraws = [];
1547
1531
  this.eyeDraws = [];
1548
1532
  this.hairDrawsOverEyes = [];
1549
1533
  this.hairDrawsOverNonEyes = [];
1550
- this.transparentNonEyeNonHairDraws = [];
1551
- this.opaqueNonEyeNonHairOutlineDraws = [];
1534
+ this.transparentDraws = [];
1535
+ this.opaqueOutlineDraws = [];
1552
1536
  this.eyeOutlineDraws = [];
1553
1537
  this.hairOutlineDraws = [];
1554
- this.transparentNonEyeNonHairOutlineDraws = [];
1555
- let runningFirstIndex = 0;
1538
+ this.transparentOutlineDraws = [];
1539
+ let currentIndexOffset = 0;
1556
1540
  for (const mat of materials) {
1557
- const matCount = mat.vertexCount | 0;
1558
- if (matCount === 0)
1541
+ const indexCount = mat.vertexCount;
1542
+ if (indexCount === 0)
1559
1543
  continue;
1560
1544
  const diffuseTexture = await loadTextureByIndex(mat.diffuseTextureIndex);
1561
1545
  if (!diffuseTexture)
@@ -1569,11 +1553,11 @@ export class Engine {
1569
1553
  materialUniformData[0] = materialAlpha;
1570
1554
  materialUniformData[1] = 1.0; // alphaMultiplier: 1.0 for non-hair materials
1571
1555
  materialUniformData[2] = this.rimLightIntensity;
1572
- materialUniformData[3] = this.rimLightPower;
1556
+ materialUniformData[3] = 0.0; // _padding1
1573
1557
  materialUniformData[4] = 1.0; // rimColor.r
1574
1558
  materialUniformData[5] = 1.0; // rimColor.g
1575
1559
  materialUniformData[6] = 1.0; // rimColor.b
1576
- materialUniformData[7] = 0.0;
1560
+ materialUniformData[7] = 0.0; // isOverEyes
1577
1561
  const materialUniformBuffer = this.device.createBuffer({
1578
1562
  label: `material uniform: ${mat.name}`,
1579
1563
  size: materialUniformData.byteLength,
@@ -1583,115 +1567,91 @@ export class Engine {
1583
1567
  // Create bind groups using the shared bind group layout - All pipelines (main, eye, hair multiply, hair opaque) use the same shader and layout
1584
1568
  const bindGroup = this.device.createBindGroup({
1585
1569
  label: `material bind group: ${mat.name}`,
1586
- layout: this.hairBindGroupLayout,
1570
+ layout: this.mainBindGroupLayout,
1587
1571
  entries: [
1588
1572
  { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1589
1573
  { binding: 1, resource: { buffer: this.lightUniformBuffer } },
1590
1574
  { binding: 2, resource: diffuseTexture.createView() },
1591
- { binding: 3, resource: this.textureSampler },
1575
+ { binding: 3, resource: this.materialSampler },
1592
1576
  { binding: 4, resource: { buffer: this.skinMatrixBuffer } },
1593
1577
  { binding: 5, resource: toonTexture.createView() },
1594
- { binding: 6, resource: this.textureSampler },
1578
+ { binding: 6, resource: this.materialSampler },
1595
1579
  { binding: 7, resource: { buffer: materialUniformBuffer } },
1596
1580
  ],
1597
1581
  });
1598
- // Classify materials into appropriate draw lists
1599
1582
  if (mat.isEye) {
1600
1583
  this.eyeDraws.push({
1601
- count: matCount,
1602
- firstIndex: runningFirstIndex,
1584
+ count: indexCount,
1585
+ firstIndex: currentIndexOffset,
1603
1586
  bindGroup,
1604
1587
  isTransparent,
1605
1588
  });
1606
1589
  }
1607
1590
  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
1591
+ // Hair materials: create separate bind groups for over-eyes vs over-non-eyes
1592
+ const createHairBindGroup = (isOverEyes) => {
1593
+ const uniformData = new Float32Array(8);
1594
+ uniformData[0] = materialAlpha;
1595
+ uniformData[1] = 1.0; // alphaMultiplier (shader adjusts based on isOverEyes)
1596
+ uniformData[2] = this.rimLightIntensity;
1597
+ uniformData[3] = 0.0; // _padding1
1598
+ uniformData[4] = 1.0; // rimColor.rgb
1599
+ uniformData[5] = 1.0;
1600
+ uniformData[6] = 1.0;
1601
+ uniformData[7] = isOverEyes ? 1.0 : 0.0; // isOverEyes
1602
+ const buffer = this.device.createBuffer({
1603
+ label: `material uniform (${isOverEyes ? "over eyes" : "over non-eyes"}): ${mat.name}`,
1604
+ size: uniformData.byteLength,
1605
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1606
+ });
1607
+ this.device.queue.writeBuffer(buffer, 0, uniformData);
1608
+ return this.device.createBindGroup({
1609
+ label: `material bind group (${isOverEyes ? "over eyes" : "over non-eyes"}): ${mat.name}`,
1610
+ layout: this.mainBindGroupLayout,
1611
+ entries: [
1612
+ { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1613
+ { binding: 1, resource: { buffer: this.lightUniformBuffer } },
1614
+ { binding: 2, resource: diffuseTexture.createView() },
1615
+ { binding: 3, resource: this.materialSampler },
1616
+ { binding: 4, resource: { buffer: this.skinMatrixBuffer } },
1617
+ { binding: 5, resource: toonTexture.createView() },
1618
+ { binding: 6, resource: this.materialSampler },
1619
+ { binding: 7, resource: { buffer: buffer } },
1620
+ ],
1621
+ });
1622
+ };
1623
+ const bindGroupOverEyes = createHairBindGroup(true);
1624
+ const bindGroupOverNonEyes = createHairBindGroup(false);
1665
1625
  this.hairDrawsOverEyes.push({
1666
- count: matCount,
1667
- firstIndex: runningFirstIndex,
1626
+ count: indexCount,
1627
+ firstIndex: currentIndexOffset,
1668
1628
  bindGroup: bindGroupOverEyes,
1669
1629
  isTransparent,
1670
1630
  });
1671
1631
  this.hairDrawsOverNonEyes.push({
1672
- count: matCount,
1673
- firstIndex: runningFirstIndex,
1632
+ count: indexCount,
1633
+ firstIndex: currentIndexOffset,
1674
1634
  bindGroup: bindGroupOverNonEyes,
1675
1635
  isTransparent,
1676
1636
  });
1677
1637
  }
1678
1638
  else if (isTransparent) {
1679
- this.transparentNonEyeNonHairDraws.push({
1680
- count: matCount,
1681
- firstIndex: runningFirstIndex,
1639
+ this.transparentDraws.push({
1640
+ count: indexCount,
1641
+ firstIndex: currentIndexOffset,
1682
1642
  bindGroup,
1683
1643
  isTransparent,
1684
1644
  });
1685
1645
  }
1686
1646
  else {
1687
- this.opaqueNonEyeNonHairDraws.push({
1688
- count: matCount,
1689
- firstIndex: runningFirstIndex,
1647
+ this.opaqueDraws.push({
1648
+ count: indexCount,
1649
+ firstIndex: currentIndexOffset,
1690
1650
  bindGroup,
1691
1651
  isTransparent,
1692
1652
  });
1693
1653
  }
1694
- // Outline for all materials (including transparent) - Edge flag is at bit 4 (0x10) in PMX format, not bit 0 (0x01)
1654
+ // Edge flag is at bit 4 (0x10) in PMX format
1695
1655
  if ((mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
1696
1656
  const materialUniformData = new Float32Array(8);
1697
1657
  materialUniformData[0] = mat.edgeColor[0]; // edgeColor.r
@@ -1699,9 +1659,9 @@ export class Engine {
1699
1659
  materialUniformData[2] = mat.edgeColor[2]; // edgeColor.b
1700
1660
  materialUniformData[3] = mat.edgeColor[3]; // edgeColor.a
1701
1661
  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
1662
+ materialUniformData[5] = 0.0; // isOverEyes
1663
+ materialUniformData[6] = 0.0;
1664
+ materialUniformData[7] = 0.0;
1705
1665
  const materialUniformBuffer = this.device.createBuffer({
1706
1666
  label: `outline material uniform: ${mat.name}`,
1707
1667
  size: materialUniformData.byteLength,
@@ -1717,45 +1677,44 @@ export class Engine {
1717
1677
  { binding: 2, resource: { buffer: this.skinMatrixBuffer } },
1718
1678
  ],
1719
1679
  });
1720
- // Classify outlines into appropriate draw lists
1721
1680
  if (mat.isEye) {
1722
1681
  this.eyeOutlineDraws.push({
1723
- count: matCount,
1724
- firstIndex: runningFirstIndex,
1682
+ count: indexCount,
1683
+ firstIndex: currentIndexOffset,
1725
1684
  bindGroup: outlineBindGroup,
1726
1685
  isTransparent,
1727
1686
  });
1728
1687
  }
1729
1688
  else if (mat.isHair) {
1730
1689
  this.hairOutlineDraws.push({
1731
- count: matCount,
1732
- firstIndex: runningFirstIndex,
1690
+ count: indexCount,
1691
+ firstIndex: currentIndexOffset,
1733
1692
  bindGroup: outlineBindGroup,
1734
1693
  isTransparent,
1735
1694
  });
1736
1695
  }
1737
1696
  else if (isTransparent) {
1738
- this.transparentNonEyeNonHairOutlineDraws.push({
1739
- count: matCount,
1740
- firstIndex: runningFirstIndex,
1697
+ this.transparentOutlineDraws.push({
1698
+ count: indexCount,
1699
+ firstIndex: currentIndexOffset,
1741
1700
  bindGroup: outlineBindGroup,
1742
1701
  isTransparent,
1743
1702
  });
1744
1703
  }
1745
1704
  else {
1746
- this.opaqueNonEyeNonHairOutlineDraws.push({
1747
- count: matCount,
1748
- firstIndex: runningFirstIndex,
1705
+ this.opaqueOutlineDraws.push({
1706
+ count: indexCount,
1707
+ firstIndex: currentIndexOffset,
1749
1708
  bindGroup: outlineBindGroup,
1750
1709
  isTransparent,
1751
1710
  });
1752
1711
  }
1753
1712
  }
1754
- runningFirstIndex += matCount;
1713
+ currentIndexOffset += indexCount;
1755
1714
  }
1715
+ this.gpuMemoryMB = this.calculateGpuMemory();
1756
1716
  }
1757
- // Helper: Load texture from file path with optional max size limit
1758
- async createTextureFromPath(path, maxSize = 2048) {
1717
+ async createTextureFromPath(path) {
1759
1718
  const cached = this.textureCache.get(path);
1760
1719
  if (cached) {
1761
1720
  return cached;
@@ -1765,41 +1724,28 @@ export class Engine {
1765
1724
  if (!response.ok) {
1766
1725
  throw new Error(`HTTP ${response.status}: ${response.statusText}`);
1767
1726
  }
1768
- let imageBitmap = await createImageBitmap(await response.blob(), {
1727
+ const imageBitmap = await createImageBitmap(await response.blob(), {
1769
1728
  premultiplyAlpha: "none",
1770
1729
  colorSpaceConversion: "none",
1771
1730
  });
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
1731
  const texture = this.device.createTexture({
1788
1732
  label: `texture: ${path}`,
1789
- size: [finalWidth, finalHeight],
1733
+ size: [imageBitmap.width, imageBitmap.height],
1790
1734
  format: "rgba8unorm",
1791
1735
  usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
1792
1736
  });
1793
- this.device.queue.copyExternalImageToTexture({ source: imageBitmap }, { texture }, [finalWidth, finalHeight]);
1737
+ this.device.queue.copyExternalImageToTexture({ source: imageBitmap }, { texture }, [
1738
+ imageBitmap.width,
1739
+ imageBitmap.height,
1740
+ ]);
1794
1741
  this.textureCache.set(path, texture);
1795
- this.textureSizes.set(path, { width: finalWidth, height: finalHeight });
1796
1742
  return texture;
1797
1743
  }
1798
1744
  catch {
1799
1745
  return null;
1800
1746
  }
1801
1747
  }
1802
- // Step 9: Render one frame
1748
+ // Render strategy: 1) Opaque non-eye/hair 2) Eyes (stencil=1) 3) Hair (depth pre-pass + split by stencil) 4) Transparent 5) Bloom
1803
1749
  render() {
1804
1750
  if (this.multisampleTexture && this.camera && this.device && this.currentModel) {
1805
1751
  const currentTime = performance.now();
@@ -1807,26 +1753,27 @@ export class Engine {
1807
1753
  this.lastFrameTime = currentTime;
1808
1754
  this.updateCameraUniforms();
1809
1755
  this.updateRenderTarget();
1810
- this.updateModelPose(deltaTime);
1756
+ // Use single encoder for both compute and render (reduces sync points)
1811
1757
  const encoder = this.device.createCommandEncoder();
1758
+ this.updateModelPose(deltaTime, encoder);
1812
1759
  const pass = encoder.beginRenderPass(this.renderPassDescriptor);
1813
1760
  pass.setVertexBuffer(0, this.vertexBuffer);
1814
1761
  pass.setVertexBuffer(1, this.jointsBuffer);
1815
1762
  pass.setVertexBuffer(2, this.weightsBuffer);
1816
1763
  pass.setIndexBuffer(this.indexBuffer, "uint32");
1817
1764
  this.drawCallCount = 0;
1818
- // PASS 1: Opaque non-eye, non-hair
1819
- pass.setPipeline(this.pipeline);
1820
- for (const draw of this.opaqueNonEyeNonHairDraws) {
1765
+ // Pass 1: Opaque
1766
+ pass.setPipeline(this.modelPipeline);
1767
+ for (const draw of this.opaqueDraws) {
1821
1768
  if (draw.count > 0) {
1822
1769
  pass.setBindGroup(0, draw.bindGroup);
1823
1770
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1824
1771
  this.drawCallCount++;
1825
1772
  }
1826
1773
  }
1827
- // PASS 2: Eyes (writes stencil = 1)
1774
+ // Pass 2: Eyes (writes stencil value for hair to test against)
1828
1775
  pass.setPipeline(this.eyePipeline);
1829
- pass.setStencilReference(1); // Set stencil reference value to 1
1776
+ pass.setStencilReference(this.STENCIL_EYE_VALUE);
1830
1777
  for (const draw of this.eyeDraws) {
1831
1778
  if (draw.count > 0) {
1832
1779
  pass.setBindGroup(0, draw.bindGroup);
@@ -1834,9 +1781,9 @@ export class Engine {
1834
1781
  this.drawCallCount++;
1835
1782
  }
1836
1783
  }
1837
- // PASS 3: Hair rendering with depth pre-pass and unified pipeline
1784
+ // Pass 3: Hair rendering (depth pre-pass + shading + outlines)
1838
1785
  this.drawOutlines(pass, false);
1839
- // 3a: Hair depth pre-pass (eliminates overdraw by rejecting fragments early)
1786
+ // 3a: Hair depth pre-pass (reduces overdraw via early depth rejection)
1840
1787
  if (this.hairDrawsOverEyes.length > 0 || this.hairDrawsOverNonEyes.length > 0) {
1841
1788
  pass.setPipeline(this.hairDepthPipeline);
1842
1789
  for (const draw of this.hairDrawsOverEyes) {
@@ -1852,10 +1799,10 @@ export class Engine {
1852
1799
  }
1853
1800
  }
1854
1801
  }
1855
- // 3b: Hair shading pass with unified pipeline and dynamic branching
1802
+ // 3b: Hair shading (split by stencil for transparency over eyes)
1856
1803
  if (this.hairDrawsOverEyes.length > 0) {
1857
- pass.setPipeline(this.hairUnifiedPipelineOverEyes);
1858
- pass.setStencilReference(1);
1804
+ pass.setPipeline(this.hairPipelineOverEyes);
1805
+ pass.setStencilReference(this.STENCIL_EYE_VALUE);
1859
1806
  for (const draw of this.hairDrawsOverEyes) {
1860
1807
  if (draw.count > 0) {
1861
1808
  pass.setBindGroup(0, draw.bindGroup);
@@ -1865,8 +1812,8 @@ export class Engine {
1865
1812
  }
1866
1813
  }
1867
1814
  if (this.hairDrawsOverNonEyes.length > 0) {
1868
- pass.setPipeline(this.hairUnifiedPipelineOverNonEyes);
1869
- pass.setStencilReference(1);
1815
+ pass.setPipeline(this.hairPipelineOverNonEyes);
1816
+ pass.setStencilReference(this.STENCIL_EYE_VALUE);
1870
1817
  for (const draw of this.hairDrawsOverNonEyes) {
1871
1818
  if (draw.count > 0) {
1872
1819
  pass.setBindGroup(0, draw.bindGroup);
@@ -1875,9 +1822,9 @@ export class Engine {
1875
1822
  }
1876
1823
  }
1877
1824
  }
1878
- // 3c: Hair outlines - unified single pass without stencil testing
1825
+ // 3c: Hair outlines
1879
1826
  if (this.hairOutlineDraws.length > 0) {
1880
- pass.setPipeline(this.hairUnifiedOutlinePipeline);
1827
+ pass.setPipeline(this.hairOutlinePipeline);
1881
1828
  for (const draw of this.hairOutlineDraws) {
1882
1829
  if (draw.count > 0) {
1883
1830
  pass.setBindGroup(0, draw.bindGroup);
@@ -1885,9 +1832,9 @@ export class Engine {
1885
1832
  }
1886
1833
  }
1887
1834
  }
1888
- // PASS 4: Transparent non-eye, non-hair
1889
- pass.setPipeline(this.pipeline);
1890
- for (const draw of this.transparentNonEyeNonHairDraws) {
1835
+ // Pass 4: Transparent
1836
+ pass.setPipeline(this.modelPipeline);
1837
+ for (const draw of this.transparentDraws) {
1891
1838
  if (draw.count > 0) {
1892
1839
  pass.setBindGroup(0, draw.bindGroup);
1893
1840
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
@@ -1897,12 +1844,10 @@ export class Engine {
1897
1844
  this.drawOutlines(pass, true);
1898
1845
  pass.end();
1899
1846
  this.device.queue.submit([encoder.finish()]);
1900
- // Apply bloom post-processing
1901
1847
  this.applyBloom();
1902
1848
  this.updateStats(performance.now() - currentTime);
1903
1849
  }
1904
1850
  }
1905
- // Apply bloom post-processing
1906
1851
  applyBloom() {
1907
1852
  if (!this.sceneRenderTexture || !this.bloomExtractTexture) {
1908
1853
  return;
@@ -1917,9 +1862,9 @@ export class Engine {
1917
1862
  const encoder = this.device.createCommandEncoder();
1918
1863
  const width = this.canvas.width;
1919
1864
  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)
1865
+ const bloomWidth = Math.floor(width / this.BLOOM_DOWNSCALE_FACTOR);
1866
+ const bloomHeight = Math.floor(height / this.BLOOM_DOWNSCALE_FACTOR);
1867
+ // Extract bright areas
1923
1868
  const extractPass = encoder.beginRenderPass({
1924
1869
  label: "bloom extract",
1925
1870
  colorAttachments: [
@@ -1935,8 +1880,8 @@ export class Engine {
1935
1880
  extractPass.setBindGroup(0, this.bloomExtractBindGroup);
1936
1881
  extractPass.draw(6, 1, 0, 0);
1937
1882
  extractPass.end();
1938
- // Pass 2: Horizontal blur
1939
- const hBlurData = new Float32Array(4); // vec2f + padding = 4 floats
1883
+ // Horizontal blur
1884
+ const hBlurData = new Float32Array(4);
1940
1885
  hBlurData[0] = 1.0;
1941
1886
  hBlurData[1] = 0.0;
1942
1887
  this.device.queue.writeBuffer(this.blurDirectionBuffer, 0, hBlurData);
@@ -1955,8 +1900,8 @@ export class Engine {
1955
1900
  blurHPass.setBindGroup(0, this.bloomBlurHBindGroup);
1956
1901
  blurHPass.draw(6, 1, 0, 0);
1957
1902
  blurHPass.end();
1958
- // Pass 3: Vertical blur
1959
- const vBlurData = new Float32Array(4); // vec2f + padding = 4 floats
1903
+ // Vertical blur
1904
+ const vBlurData = new Float32Array(4);
1960
1905
  vBlurData[0] = 0.0;
1961
1906
  vBlurData[1] = 1.0;
1962
1907
  this.device.queue.writeBuffer(this.blurDirectionBuffer, 0, vBlurData);
@@ -1975,7 +1920,7 @@ export class Engine {
1975
1920
  blurVPass.setBindGroup(0, this.bloomBlurVBindGroup);
1976
1921
  blurVPass.draw(6, 1, 0, 0);
1977
1922
  blurVPass.end();
1978
- // Pass 4: Compose scene + bloom to canvas
1923
+ // Compose to canvas
1979
1924
  const composePass = encoder.beginRenderPass({
1980
1925
  label: "bloom compose",
1981
1926
  colorAttachments: [
@@ -1993,7 +1938,6 @@ export class Engine {
1993
1938
  composePass.end();
1994
1939
  this.device.queue.submit([encoder.finish()]);
1995
1940
  }
1996
- // Update camera uniform buffer each frame
1997
1941
  updateCameraUniforms() {
1998
1942
  const viewMatrix = this.camera.getViewMatrix();
1999
1943
  const projectionMatrix = this.camera.getProjectionMatrix();
@@ -2005,48 +1949,37 @@ export class Engine {
2005
1949
  this.cameraMatrixData[34] = cameraPos.z;
2006
1950
  this.device.queue.writeBuffer(this.cameraUniformBuffer, 0, this.cameraMatrixData);
2007
1951
  }
2008
- // Update render target texture view
2009
1952
  updateRenderTarget() {
2010
1953
  const colorAttachment = this.renderPassDescriptor.colorAttachments[0];
2011
1954
  if (this.sampleCount > 1) {
2012
- // Resolve to scene render texture for post-processing
2013
1955
  colorAttachment.resolveTarget = this.sceneRenderTextureView;
2014
1956
  }
2015
1957
  else {
2016
- // Render directly to scene render texture
2017
1958
  colorAttachment.view = this.sceneRenderTextureView;
2018
1959
  }
2019
1960
  }
2020
- updateModelPose(deltaTime) {
1961
+ updateModelPose(deltaTime, encoder) {
2021
1962
  this.currentModel.evaluatePose();
2022
1963
  const worldMats = this.currentModel.getBoneWorldMatrices();
2023
1964
  if (this.physics) {
2024
1965
  this.physics.step(deltaTime, worldMats, this.currentModel.getBoneInverseBindMatrices());
2025
1966
  }
2026
1967
  this.device.queue.writeBuffer(this.worldMatrixBuffer, 0, worldMats.buffer, worldMats.byteOffset, worldMats.byteLength);
2027
- this.computeSkinMatrices();
1968
+ this.computeSkinMatrices(encoder);
2028
1969
  }
2029
- // Compute skin matrices on GPU
2030
- computeSkinMatrices() {
1970
+ computeSkinMatrices(encoder) {
2031
1971
  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();
1972
+ const workgroupCount = Math.ceil(boneCount / this.COMPUTE_WORKGROUP_SIZE);
2037
1973
  const pass = encoder.beginComputePass();
2038
1974
  pass.setPipeline(this.skinMatrixComputePipeline);
2039
1975
  pass.setBindGroup(0, this.skinMatrixComputeBindGroup);
2040
1976
  pass.dispatchWorkgroups(workgroupCount);
2041
1977
  pass.end();
2042
- this.device.queue.submit([encoder.finish()]);
2043
1978
  }
2044
- // Draw outlines (opaque or transparent)
2045
1979
  drawOutlines(pass, transparent) {
2046
1980
  pass.setPipeline(this.outlinePipeline);
2047
1981
  if (transparent) {
2048
- // Draw transparent outlines (if any)
2049
- for (const draw of this.transparentNonEyeNonHairOutlineDraws) {
1982
+ for (const draw of this.transparentOutlineDraws) {
2050
1983
  if (draw.count > 0) {
2051
1984
  pass.setBindGroup(0, draw.bindGroup);
2052
1985
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
@@ -2054,8 +1987,7 @@ export class Engine {
2054
1987
  }
2055
1988
  }
2056
1989
  else {
2057
- // Draw opaque outlines before main geometry
2058
- for (const draw of this.opaqueNonEyeNonHairOutlineDraws) {
1990
+ for (const draw of this.opaqueOutlineDraws) {
2059
1991
  if (draw.count > 0) {
2060
1992
  pass.setBindGroup(0, draw.bindGroup);
2061
1993
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
@@ -2081,12 +2013,12 @@ export class Engine {
2081
2013
  this.framesSinceLastUpdate = 0;
2082
2014
  this.lastFpsUpdate = now;
2083
2015
  }
2084
- // Calculate GPU memory: textures + buffers + render targets
2016
+ this.stats.gpuMemory = this.gpuMemoryMB;
2017
+ }
2018
+ calculateGpuMemory() {
2085
2019
  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
- }
2020
+ for (const texture of this.textureCache.values()) {
2021
+ textureMemoryBytes += texture.width * texture.height * 4;
2090
2022
  }
2091
2023
  let bufferMemoryBytes = 0;
2092
2024
  if (this.vertexBuffer) {
@@ -2124,48 +2056,44 @@ export class Engine {
2124
2056
  if (skeleton)
2125
2057
  bufferMemoryBytes += Math.max(256, skeleton.bones.length * 16 * 4);
2126
2058
  }
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
2059
+ bufferMemoryBytes += 40 * 4;
2060
+ bufferMemoryBytes += 64 * 4;
2061
+ bufferMemoryBytes += 32;
2062
+ bufferMemoryBytes += 32;
2063
+ bufferMemoryBytes += 32;
2064
+ bufferMemoryBytes += 32;
2133
2065
  if (this.fullscreenQuadBuffer) {
2134
- bufferMemoryBytes += 24 * 4; // fullscreenQuadBuffer (6 vertices * 4 floats)
2066
+ bufferMemoryBytes += 24 * 4;
2135
2067
  }
2136
- // Material uniform buffers: Float32Array(8) = 32 bytes each
2137
- const totalMaterialDraws = this.opaqueNonEyeNonHairDraws.length +
2068
+ const totalMaterialDraws = this.opaqueDraws.length +
2138
2069
  this.eyeDraws.length +
2139
2070
  this.hairDrawsOverEyes.length +
2140
2071
  this.hairDrawsOverNonEyes.length +
2141
- this.transparentNonEyeNonHairDraws.length;
2142
- bufferMemoryBytes += totalMaterialDraws * 32; // Material uniform buffers (8 floats = 32 bytes)
2143
- // Outline material uniform buffers: Float32Array(8) = 32 bytes each
2144
- const totalOutlineDraws = this.opaqueNonEyeNonHairOutlineDraws.length +
2072
+ this.transparentDraws.length;
2073
+ bufferMemoryBytes += totalMaterialDraws * 32;
2074
+ const totalOutlineDraws = this.opaqueOutlineDraws.length +
2145
2075
  this.eyeOutlineDraws.length +
2146
2076
  this.hairOutlineDraws.length +
2147
- this.transparentNonEyeNonHairOutlineDraws.length;
2148
- bufferMemoryBytes += totalOutlineDraws * 32; // Outline material uniform buffers
2077
+ this.transparentOutlineDraws.length;
2078
+ bufferMemoryBytes += totalOutlineDraws * 32;
2149
2079
  let renderTargetMemoryBytes = 0;
2150
2080
  if (this.multisampleTexture) {
2151
2081
  const width = this.canvas.width;
2152
2082
  const height = this.canvas.height;
2153
- renderTargetMemoryBytes += width * height * 4 * this.sampleCount; // multisample color
2154
- renderTargetMemoryBytes += width * height * 4; // depth (depth24plus-stencil8 = 4 bytes)
2083
+ renderTargetMemoryBytes += width * height * 4 * this.sampleCount;
2084
+ renderTargetMemoryBytes += width * height * 4;
2155
2085
  }
2156
2086
  if (this.sceneRenderTexture) {
2157
2087
  const width = this.canvas.width;
2158
2088
  const height = this.canvas.height;
2159
- renderTargetMemoryBytes += width * height * 4; // sceneRenderTexture (non-multisampled)
2089
+ renderTargetMemoryBytes += width * height * 4;
2160
2090
  }
2161
2091
  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
2092
+ const width = Math.floor(this.canvas.width / this.BLOOM_DOWNSCALE_FACTOR);
2093
+ const height = Math.floor(this.canvas.height / this.BLOOM_DOWNSCALE_FACTOR);
2094
+ renderTargetMemoryBytes += width * height * 4 * 3;
2167
2095
  }
2168
2096
  const totalGPUMemoryBytes = textureMemoryBytes + bufferMemoryBytes + renderTargetMemoryBytes;
2169
- this.stats.gpuMemory = Math.round((totalGPUMemoryBytes / 1024 / 1024) * 100) / 100;
2097
+ return Math.round((totalGPUMemoryBytes / 1024 / 1024) * 100) / 100;
2170
2098
  }
2171
2099
  }