reze-engine 0.2.17 → 0.2.18

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
@@ -18,10 +18,10 @@ export class Engine {
18
18
  // Ambient light settings
19
19
  this.ambientColor = new Vec3(1.0, 1.0, 1.0);
20
20
  // Bloom settings
21
- this.bloomThreshold = 0.01;
22
- this.bloomIntensity = 0.12;
21
+ this.bloomThreshold = Engine.DEFAULT_BLOOM_THRESHOLD;
22
+ this.bloomIntensity = Engine.DEFAULT_BLOOM_INTENSITY;
23
23
  // Rim light settings
24
- this.rimLightIntensity = 0.45;
24
+ this.rimLightIntensity = Engine.DEFAULT_RIM_LIGHT_INTENSITY;
25
25
  this.currentModel = null;
26
26
  this.modelDir = "";
27
27
  this.physics = null;
@@ -38,10 +38,9 @@ export class Engine {
38
38
  this.transparentOutlineDraws = [];
39
39
  this.lastFpsUpdate = performance.now();
40
40
  this.framesSinceLastUpdate = 0;
41
- this.frameTimeSamples = [];
42
- this.frameTimeSum = 0;
43
- this.drawCallCount = 0;
44
41
  this.lastFrameTime = performance.now();
42
+ this.frameTimeSum = 0;
43
+ this.frameTimeCount = 0;
45
44
  this.stats = {
46
45
  fps: 0,
47
46
  frameTime: 0,
@@ -57,10 +56,10 @@ export class Engine {
57
56
  this.canvas = canvas;
58
57
  if (options) {
59
58
  this.ambientColor = options.ambientColor ?? new Vec3(1.0, 1.0, 1.0);
60
- this.bloomIntensity = options.bloomIntensity ?? 0.12;
61
- this.rimLightIntensity = options.rimLightIntensity ?? 0.45;
62
- this.cameraDistance = options.cameraDistance ?? 26.6;
63
- this.cameraTarget = options.cameraTarget ?? new Vec3(0, 12.5, 0);
59
+ this.bloomIntensity = options.bloomIntensity ?? Engine.DEFAULT_BLOOM_INTENSITY;
60
+ this.rimLightIntensity = options.rimLightIntensity ?? Engine.DEFAULT_RIM_LIGHT_INTENSITY;
61
+ this.cameraDistance = options.cameraDistance ?? Engine.DEFAULT_CAMERA_DISTANCE;
62
+ this.cameraTarget = options.cameraTarget ?? Engine.DEFAULT_CAMERA_TARGET;
64
63
  }
65
64
  }
66
65
  // Step 1: Get WebGPU device and context
@@ -85,7 +84,6 @@ export class Engine {
85
84
  this.setupCamera();
86
85
  this.setupLighting();
87
86
  this.createPipelines();
88
- this.createFullscreenQuad();
89
87
  this.createBloomPipelines();
90
88
  this.setupResize();
91
89
  }
@@ -98,101 +96,99 @@ export class Engine {
98
96
  });
99
97
  const shaderModule = this.device.createShaderModule({
100
98
  label: "model shaders",
101
- code: /* wgsl */ `
102
- struct CameraUniforms {
103
- view: mat4x4f,
104
- projection: mat4x4f,
105
- viewPos: vec3f,
106
- _padding: f32,
107
- };
108
-
109
- struct LightUniforms {
110
- ambientColor: vec3f,
111
- };
112
-
113
- struct MaterialUniforms {
114
- alpha: f32,
115
- alphaMultiplier: f32,
116
- rimIntensity: f32,
117
- _padding1: f32,
118
- rimColor: vec3f,
119
- isOverEyes: f32, // 1.0 if rendering over eyes, 0.0 otherwise
120
- };
121
-
122
- struct VertexOutput {
123
- @builtin(position) position: vec4f,
124
- @location(0) normal: vec3f,
125
- @location(1) uv: vec2f,
126
- @location(2) worldPos: vec3f,
127
- };
128
-
129
- @group(0) @binding(0) var<uniform> camera: CameraUniforms;
130
- @group(0) @binding(1) var<uniform> light: LightUniforms;
131
- @group(0) @binding(2) var diffuseTexture: texture_2d<f32>;
132
- @group(0) @binding(3) var diffuseSampler: sampler;
133
- @group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
134
- @group(0) @binding(5) var toonTexture: texture_2d<f32>;
135
- @group(0) @binding(6) var toonSampler: sampler;
136
- @group(0) @binding(7) var<uniform> material: MaterialUniforms;
137
-
138
- @vertex fn vs(
139
- @location(0) position: vec3f,
140
- @location(1) normal: vec3f,
141
- @location(2) uv: vec2f,
142
- @location(3) joints0: vec4<u32>,
143
- @location(4) weights0: vec4<f32>
144
- ) -> VertexOutput {
145
- var output: VertexOutput;
146
- let pos4 = vec4f(position, 1.0);
147
-
148
- // Branchless weight normalization (avoids GPU branch divergence)
149
- let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
150
- let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);
151
- let normalizedWeights = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
152
-
153
- var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
154
- var skinnedNrm = vec3f(0.0, 0.0, 0.0);
155
- for (var i = 0u; i < 4u; i++) {
156
- let j = joints0[i];
157
- let w = normalizedWeights[i];
158
- let m = skinMats[j];
159
- skinnedPos += (m * pos4) * w;
160
- let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
161
- skinnedNrm += (r3 * normal) * w;
162
- }
163
- let worldPos = skinnedPos.xyz;
164
- output.position = camera.projection * camera.view * vec4f(worldPos, 1.0);
165
- output.normal = normalize(skinnedNrm);
166
- output.uv = uv;
167
- output.worldPos = worldPos;
168
- return output;
169
- }
170
-
171
- @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
172
- // Early alpha test - discard before expensive calculations
173
- var finalAlpha = material.alpha * material.alphaMultiplier;
174
- if (material.isOverEyes > 0.5) {
175
- finalAlpha *= 0.5; // Hair over eyes gets 50% alpha
176
- }
177
- if (finalAlpha < 0.001) {
178
- discard;
179
- }
180
-
181
- let n = normalize(input.normal);
182
- let albedo = textureSample(diffuseTexture, diffuseSampler, input.uv).rgb;
183
-
184
- let lightAccum = light.ambientColor;
185
-
186
- // Rim light calculation
187
- let viewDir = normalize(camera.viewPos - input.worldPos);
188
- var rimFactor = 1.0 - max(dot(n, viewDir), 0.0);
189
- rimFactor = rimFactor * rimFactor; // Optimized: direct multiply instead of pow(x, 2.0)
190
- let rimLight = material.rimColor * material.rimIntensity * rimFactor;
191
-
192
- let color = albedo * lightAccum + rimLight;
193
-
194
- return vec4f(color, finalAlpha);
195
- }
99
+ code: /* wgsl */ `
100
+ struct CameraUniforms {
101
+ view: mat4x4f,
102
+ projection: mat4x4f,
103
+ viewPos: vec3f,
104
+ _padding: f32,
105
+ };
106
+
107
+ struct LightUniforms {
108
+ ambientColor: vec3f,
109
+ };
110
+
111
+ struct MaterialUniforms {
112
+ alpha: f32,
113
+ alphaMultiplier: f32,
114
+ rimIntensity: f32,
115
+ _padding1: f32,
116
+ rimColor: vec3f,
117
+ isOverEyes: f32, // 1.0 if rendering over eyes, 0.0 otherwise
118
+ };
119
+
120
+ struct VertexOutput {
121
+ @builtin(position) position: vec4f,
122
+ @location(0) normal: vec3f,
123
+ @location(1) uv: vec2f,
124
+ @location(2) worldPos: vec3f,
125
+ };
126
+
127
+ @group(0) @binding(0) var<uniform> camera: CameraUniforms;
128
+ @group(0) @binding(1) var<uniform> light: LightUniforms;
129
+ @group(0) @binding(2) var diffuseTexture: texture_2d<f32>;
130
+ @group(0) @binding(3) var diffuseSampler: sampler;
131
+ @group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
132
+ @group(0) @binding(5) var<uniform> material: MaterialUniforms;
133
+
134
+ @vertex fn vs(
135
+ @location(0) position: vec3f,
136
+ @location(1) normal: vec3f,
137
+ @location(2) uv: vec2f,
138
+ @location(3) joints0: vec4<u32>,
139
+ @location(4) weights0: vec4<f32>
140
+ ) -> VertexOutput {
141
+ var output: VertexOutput;
142
+ let pos4 = vec4f(position, 1.0);
143
+
144
+ // Branchless weight normalization (avoids GPU branch divergence)
145
+ let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
146
+ let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);
147
+ let normalizedWeights = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
148
+
149
+ var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
150
+ var skinnedNrm = vec3f(0.0, 0.0, 0.0);
151
+ for (var i = 0u; i < 4u; i++) {
152
+ let j = joints0[i];
153
+ let w = normalizedWeights[i];
154
+ let m = skinMats[j];
155
+ skinnedPos += (m * pos4) * w;
156
+ let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
157
+ skinnedNrm += (r3 * normal) * w;
158
+ }
159
+ let worldPos = skinnedPos.xyz;
160
+ output.position = camera.projection * camera.view * vec4f(worldPos, 1.0);
161
+ output.normal = normalize(skinnedNrm);
162
+ output.uv = uv;
163
+ output.worldPos = worldPos;
164
+ return output;
165
+ }
166
+
167
+ @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
168
+ // Early alpha test - discard before expensive calculations
169
+ var finalAlpha = material.alpha * material.alphaMultiplier;
170
+ if (material.isOverEyes > 0.5) {
171
+ finalAlpha *= 0.5; // Hair over eyes gets 50% alpha
172
+ }
173
+ if (finalAlpha < 0.001) {
174
+ discard;
175
+ }
176
+
177
+ let n = normalize(input.normal);
178
+ let albedo = textureSample(diffuseTexture, diffuseSampler, input.uv).rgb;
179
+
180
+ let lightAccum = light.ambientColor;
181
+
182
+ // Rim light calculation
183
+ let viewDir = normalize(camera.viewPos - input.worldPos);
184
+ var rimFactor = 1.0 - max(dot(n, viewDir), 0.0);
185
+ rimFactor = rimFactor * rimFactor; // Optimized: direct multiply instead of pow(x, 2.0)
186
+ let rimLight = material.rimColor * material.rimIntensity * rimFactor;
187
+
188
+ let color = albedo * lightAccum + rimLight;
189
+
190
+ return vec4f(color, finalAlpha);
191
+ }
196
192
  `,
197
193
  });
198
194
  // Create explicit bind group layout for all pipelines using the main shader
@@ -204,9 +200,7 @@ export class Engine {
204
200
  { binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: {} }, // diffuseTexture
205
201
  { binding: 3, visibility: GPUShaderStage.FRAGMENT, sampler: {} }, // diffuseSampler
206
202
  { binding: 4, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } }, // skinMats
207
- { binding: 5, visibility: GPUShaderStage.FRAGMENT, texture: {} }, // toonTexture
208
- { binding: 6, visibility: GPUShaderStage.FRAGMENT, sampler: {} }, // toonSampler
209
- { binding: 7, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // material
203
+ { binding: 5, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // material
210
204
  ],
211
205
  });
212
206
  const mainPipelineLayout = this.device.createPipelineLayout({
@@ -282,73 +276,73 @@ export class Engine {
282
276
  });
283
277
  const outlineShaderModule = this.device.createShaderModule({
284
278
  label: "outline shaders",
285
- code: /* wgsl */ `
286
- struct CameraUniforms {
287
- view: mat4x4f,
288
- projection: mat4x4f,
289
- viewPos: vec3f,
290
- _padding: f32,
291
- };
292
-
293
- struct MaterialUniforms {
294
- edgeColor: vec4f,
295
- edgeSize: f32,
296
- isOverEyes: f32, // 1.0 if rendering over eyes, 0.0 otherwise (for hair outlines)
297
- _padding1: f32,
298
- _padding2: f32,
299
- };
300
-
301
- @group(0) @binding(0) var<uniform> camera: CameraUniforms;
302
- @group(0) @binding(1) var<uniform> material: MaterialUniforms;
303
- @group(0) @binding(2) var<storage, read> skinMats: array<mat4x4f>;
304
-
305
- struct VertexOutput {
306
- @builtin(position) position: vec4f,
307
- };
308
-
309
- @vertex fn vs(
310
- @location(0) position: vec3f,
311
- @location(1) normal: vec3f,
312
- @location(3) joints0: vec4<u32>,
313
- @location(4) weights0: vec4<f32>
314
- ) -> VertexOutput {
315
- var output: VertexOutput;
316
- let pos4 = vec4f(position, 1.0);
317
-
318
- // Branchless weight normalization (avoids GPU branch divergence)
319
- let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
320
- let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);
321
- let normalizedWeights = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
322
-
323
- var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
324
- var skinnedNrm = vec3f(0.0, 0.0, 0.0);
325
- for (var i = 0u; i < 4u; i++) {
326
- let j = joints0[i];
327
- let w = normalizedWeights[i];
328
- let m = skinMats[j];
329
- skinnedPos += (m * pos4) * w;
330
- let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
331
- skinnedNrm += (r3 * normal) * w;
332
- }
333
- let worldPos = skinnedPos.xyz;
334
- let worldNormal = normalize(skinnedNrm);
335
-
336
- // MMD invert hull: expand vertices outward along normals
337
- let scaleFactor = 0.01;
338
- let expandedPos = worldPos + worldNormal * material.edgeSize * scaleFactor;
339
- output.position = camera.projection * camera.view * vec4f(expandedPos, 1.0);
340
- return output;
341
- }
342
-
343
- @fragment fn fs() -> @location(0) vec4f {
344
- var color = material.edgeColor;
345
-
346
- if (material.isOverEyes > 0.5) {
347
- color.a *= 0.5; // Hair outlines over eyes get 50% alpha
348
- }
349
-
350
- return color;
351
- }
279
+ code: /* wgsl */ `
280
+ struct CameraUniforms {
281
+ view: mat4x4f,
282
+ projection: mat4x4f,
283
+ viewPos: vec3f,
284
+ _padding: f32,
285
+ };
286
+
287
+ struct MaterialUniforms {
288
+ edgeColor: vec4f,
289
+ edgeSize: f32,
290
+ isOverEyes: f32, // 1.0 if rendering over eyes, 0.0 otherwise (for hair outlines)
291
+ _padding1: f32,
292
+ _padding2: f32,
293
+ };
294
+
295
+ @group(0) @binding(0) var<uniform> camera: CameraUniforms;
296
+ @group(0) @binding(1) var<uniform> material: MaterialUniforms;
297
+ @group(0) @binding(2) var<storage, read> skinMats: array<mat4x4f>;
298
+
299
+ struct VertexOutput {
300
+ @builtin(position) position: vec4f,
301
+ };
302
+
303
+ @vertex fn vs(
304
+ @location(0) position: vec3f,
305
+ @location(1) normal: vec3f,
306
+ @location(3) joints0: vec4<u32>,
307
+ @location(4) weights0: vec4<f32>
308
+ ) -> VertexOutput {
309
+ var output: VertexOutput;
310
+ let pos4 = vec4f(position, 1.0);
311
+
312
+ // Branchless weight normalization (avoids GPU branch divergence)
313
+ let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
314
+ let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);
315
+ let normalizedWeights = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
316
+
317
+ var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
318
+ var skinnedNrm = vec3f(0.0, 0.0, 0.0);
319
+ for (var i = 0u; i < 4u; i++) {
320
+ let j = joints0[i];
321
+ let w = normalizedWeights[i];
322
+ let m = skinMats[j];
323
+ skinnedPos += (m * pos4) * w;
324
+ let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
325
+ skinnedNrm += (r3 * normal) * w;
326
+ }
327
+ let worldPos = skinnedPos.xyz;
328
+ let worldNormal = normalize(skinnedNrm);
329
+
330
+ // MMD invert hull: expand vertices outward along normals
331
+ let scaleFactor = 0.01;
332
+ let expandedPos = worldPos + worldNormal * material.edgeSize * scaleFactor;
333
+ output.position = camera.projection * camera.view * vec4f(expandedPos, 1.0);
334
+ return output;
335
+ }
336
+
337
+ @fragment fn fs() -> @location(0) vec4f {
338
+ var color = material.edgeColor;
339
+
340
+ if (material.isOverEyes > 0.5) {
341
+ color.a *= 0.5; // Hair outlines over eyes get 50% alpha
342
+ }
343
+
344
+ return color;
345
+ }
352
346
  `,
353
347
  });
354
348
  this.outlinePipeline = this.device.createRenderPipeline({
@@ -552,45 +546,45 @@ export class Engine {
552
546
  // Depth-only shader for hair pre-pass (reduces overdraw by early depth rejection)
553
547
  const depthOnlyShaderModule = this.device.createShaderModule({
554
548
  label: "depth only shader",
555
- code: /* wgsl */ `
556
- struct CameraUniforms {
557
- view: mat4x4f,
558
- projection: mat4x4f,
559
- viewPos: vec3f,
560
- _padding: f32,
561
- };
562
-
563
- @group(0) @binding(0) var<uniform> camera: CameraUniforms;
564
- @group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
565
-
566
- @vertex fn vs(
567
- @location(0) position: vec3f,
568
- @location(1) normal: vec3f,
569
- @location(3) joints0: vec4<u32>,
570
- @location(4) weights0: vec4<f32>
571
- ) -> @builtin(position) vec4f {
572
- let pos4 = vec4f(position, 1.0);
573
-
574
- // Branchless weight normalization (avoids GPU branch divergence)
575
- let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
576
- let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);
577
- let normalizedWeights = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
578
-
579
- var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
580
- for (var i = 0u; i < 4u; i++) {
581
- let j = joints0[i];
582
- let w = normalizedWeights[i];
583
- let m = skinMats[j];
584
- skinnedPos += (m * pos4) * w;
585
- }
586
- let worldPos = skinnedPos.xyz;
587
- let clipPos = camera.projection * camera.view * vec4f(worldPos, 1.0);
588
- return clipPos;
589
- }
590
-
591
- @fragment fn fs() -> @location(0) vec4f {
592
- return vec4f(0.0, 0.0, 0.0, 0.0); // Transparent - color writes disabled via writeMask
593
- }
549
+ code: /* wgsl */ `
550
+ struct CameraUniforms {
551
+ view: mat4x4f,
552
+ projection: mat4x4f,
553
+ viewPos: vec3f,
554
+ _padding: f32,
555
+ };
556
+
557
+ @group(0) @binding(0) var<uniform> camera: CameraUniforms;
558
+ @group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
559
+
560
+ @vertex fn vs(
561
+ @location(0) position: vec3f,
562
+ @location(1) normal: vec3f,
563
+ @location(3) joints0: vec4<u32>,
564
+ @location(4) weights0: vec4<f32>
565
+ ) -> @builtin(position) vec4f {
566
+ let pos4 = vec4f(position, 1.0);
567
+
568
+ // Branchless weight normalization (avoids GPU branch divergence)
569
+ let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
570
+ let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);
571
+ let normalizedWeights = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
572
+
573
+ var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
574
+ for (var i = 0u; i < 4u; i++) {
575
+ let j = joints0[i];
576
+ let w = normalizedWeights[i];
577
+ let m = skinMats[j];
578
+ skinnedPos += (m * pos4) * w;
579
+ }
580
+ let worldPos = skinnedPos.xyz;
581
+ let clipPos = camera.projection * camera.view * vec4f(worldPos, 1.0);
582
+ return clipPos;
583
+ }
584
+
585
+ @fragment fn fs() -> @location(0) vec4f {
586
+ return vec4f(0.0, 0.0, 0.0, 0.0); // Transparent - color writes disabled via writeMask
587
+ }
594
588
  `,
595
589
  });
596
590
  // Hair depth pre-pass pipeline: depth-only with color writes disabled to eliminate overdraw
@@ -638,165 +632,104 @@ export class Engine {
638
632
  },
639
633
  multisample: { count: this.sampleCount },
640
634
  });
641
- // Hair pipeline for rendering over eyes (stencil == 1)
642
- this.hairPipelineOverEyes = this.device.createRenderPipeline({
643
- label: "hair pipeline (over eyes)",
644
- layout: mainPipelineLayout,
645
- vertex: {
646
- module: shaderModule,
647
- buffers: [
648
- {
649
- arrayStride: 8 * 4,
650
- attributes: [
651
- { shaderLocation: 0, offset: 0, format: "float32x3" },
652
- { shaderLocation: 1, offset: 3 * 4, format: "float32x3" },
653
- { shaderLocation: 2, offset: 6 * 4, format: "float32x2" },
654
- ],
655
- },
656
- {
657
- arrayStride: 4 * 2,
658
- attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" }],
659
- },
660
- {
661
- arrayStride: 4,
662
- attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" }],
663
- },
664
- ],
665
- },
666
- fragment: {
667
- module: shaderModule,
668
- targets: [
669
- {
670
- format: this.presentationFormat,
671
- blend: {
672
- color: {
673
- srcFactor: "src-alpha",
674
- dstFactor: "one-minus-src-alpha",
675
- operation: "add",
676
- },
677
- alpha: {
678
- srcFactor: "one",
679
- dstFactor: "one-minus-src-alpha",
680
- operation: "add",
681
- },
635
+ // Hair pipelines for rendering over eyes vs non-eyes (only differ in stencil compare mode)
636
+ const createHairPipeline = (isOverEyes) => {
637
+ return this.device.createRenderPipeline({
638
+ label: `hair pipeline (${isOverEyes ? "over eyes" : "over non-eyes"})`,
639
+ layout: mainPipelineLayout,
640
+ vertex: {
641
+ module: shaderModule,
642
+ buffers: [
643
+ {
644
+ arrayStride: 8 * 4,
645
+ attributes: [
646
+ { shaderLocation: 0, offset: 0, format: "float32x3" },
647
+ { shaderLocation: 1, offset: 3 * 4, format: "float32x3" },
648
+ { shaderLocation: 2, offset: 6 * 4, format: "float32x2" },
649
+ ],
682
650
  },
683
- },
684
- ],
685
- },
686
- primitive: { cullMode: "front" },
687
- depthStencil: {
688
- format: "depth24plus-stencil8",
689
- depthWriteEnabled: false, // Don't write depth (already written in pre-pass)
690
- depthCompare: "less-equal", // More lenient than "equal" to avoid precision issues with MSAA
691
- stencilFront: {
692
- compare: "equal", // Only render where stencil == 1 (over eyes)
693
- failOp: "keep",
694
- depthFailOp: "keep",
695
- passOp: "keep",
696
- },
697
- stencilBack: {
698
- compare: "equal",
699
- failOp: "keep",
700
- depthFailOp: "keep",
701
- passOp: "keep",
651
+ {
652
+ arrayStride: 4 * 2,
653
+ attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" }],
654
+ },
655
+ {
656
+ arrayStride: 4,
657
+ attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" }],
658
+ },
659
+ ],
702
660
  },
703
- },
704
- multisample: { count: this.sampleCount },
705
- });
706
- // Hair pipeline for rendering over non-eyes (stencil != 1)
707
- this.hairPipelineOverNonEyes = this.device.createRenderPipeline({
708
- label: "hair pipeline (over non-eyes)",
709
- layout: mainPipelineLayout,
710
- vertex: {
711
- module: shaderModule,
712
- buffers: [
713
- {
714
- arrayStride: 8 * 4,
715
- attributes: [
716
- { shaderLocation: 0, offset: 0, format: "float32x3" },
717
- { shaderLocation: 1, offset: 3 * 4, format: "float32x3" },
718
- { shaderLocation: 2, offset: 6 * 4, format: "float32x2" },
719
- ],
720
- },
721
- {
722
- arrayStride: 4 * 2,
723
- attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" }],
724
- },
725
- {
726
- arrayStride: 4,
727
- attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" }],
728
- },
729
- ],
730
- },
731
- fragment: {
732
- module: shaderModule,
733
- targets: [
734
- {
735
- format: this.presentationFormat,
736
- blend: {
737
- color: {
738
- srcFactor: "src-alpha",
739
- dstFactor: "one-minus-src-alpha",
740
- operation: "add",
741
- },
742
- alpha: {
743
- srcFactor: "one",
744
- dstFactor: "one-minus-src-alpha",
745
- operation: "add",
661
+ fragment: {
662
+ module: shaderModule,
663
+ targets: [
664
+ {
665
+ format: this.presentationFormat,
666
+ blend: {
667
+ color: {
668
+ srcFactor: "src-alpha",
669
+ dstFactor: "one-minus-src-alpha",
670
+ operation: "add",
671
+ },
672
+ alpha: {
673
+ srcFactor: "one",
674
+ dstFactor: "one-minus-src-alpha",
675
+ operation: "add",
676
+ },
746
677
  },
747
678
  },
748
- },
749
- ],
750
- },
751
- primitive: { cullMode: "front" },
752
- depthStencil: {
753
- format: "depth24plus-stencil8",
754
- depthWriteEnabled: false, // Don't write depth (already written in pre-pass)
755
- depthCompare: "less-equal", // More lenient than "equal" to avoid precision issues with MSAA
756
- stencilFront: {
757
- compare: "not-equal", // Only render where stencil != 1 (over non-eyes)
758
- failOp: "keep",
759
- depthFailOp: "keep",
760
- passOp: "keep",
679
+ ],
761
680
  },
762
- stencilBack: {
763
- compare: "not-equal",
764
- failOp: "keep",
765
- depthFailOp: "keep",
766
- passOp: "keep",
681
+ primitive: { cullMode: "front" },
682
+ depthStencil: {
683
+ format: "depth24plus-stencil8",
684
+ depthWriteEnabled: false, // Don't write depth (already written in pre-pass)
685
+ depthCompare: "less-equal", // More lenient than "equal" to avoid precision issues with MSAA
686
+ stencilFront: {
687
+ compare: isOverEyes ? "equal" : "not-equal", // Over eyes: stencil == 1, over non-eyes: stencil != 1
688
+ failOp: "keep",
689
+ depthFailOp: "keep",
690
+ passOp: "keep",
691
+ },
692
+ stencilBack: {
693
+ compare: isOverEyes ? "equal" : "not-equal",
694
+ failOp: "keep",
695
+ depthFailOp: "keep",
696
+ passOp: "keep",
697
+ },
767
698
  },
768
- },
769
- multisample: { count: this.sampleCount },
770
- });
699
+ multisample: { count: this.sampleCount },
700
+ });
701
+ };
702
+ this.hairPipelineOverEyes = createHairPipeline(true);
703
+ this.hairPipelineOverNonEyes = createHairPipeline(false);
771
704
  }
772
705
  // Create compute shader for skin matrix computation
773
706
  createSkinMatrixComputePipeline() {
774
707
  const computeShader = this.device.createShaderModule({
775
708
  label: "skin matrix compute",
776
- code: /* wgsl */ `
777
- struct BoneCountUniform {
778
- count: u32,
779
- _padding1: u32,
780
- _padding2: u32,
781
- _padding3: u32,
782
- _padding4: vec4<u32>,
783
- };
784
-
785
- @group(0) @binding(0) var<uniform> boneCount: BoneCountUniform;
786
- @group(0) @binding(1) var<storage, read> worldMatrices: array<mat4x4f>;
787
- @group(0) @binding(2) var<storage, read> inverseBindMatrices: array<mat4x4f>;
788
- @group(0) @binding(3) var<storage, read_write> skinMatrices: array<mat4x4f>;
789
-
790
- @compute @workgroup_size(64) // Must match COMPUTE_WORKGROUP_SIZE
791
- fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
792
- let boneIndex = globalId.x;
793
- if (boneIndex >= boneCount.count) {
794
- return;
795
- }
796
- let worldMat = worldMatrices[boneIndex];
797
- let invBindMat = inverseBindMatrices[boneIndex];
798
- skinMatrices[boneIndex] = worldMat * invBindMat;
799
- }
709
+ code: /* wgsl */ `
710
+ struct BoneCountUniform {
711
+ count: u32,
712
+ _padding1: u32,
713
+ _padding2: u32,
714
+ _padding3: u32,
715
+ _padding4: vec4<u32>,
716
+ };
717
+
718
+ @group(0) @binding(0) var<uniform> boneCount: BoneCountUniform;
719
+ @group(0) @binding(1) var<storage, read> worldMatrices: array<mat4x4f>;
720
+ @group(0) @binding(2) var<storage, read> inverseBindMatrices: array<mat4x4f>;
721
+ @group(0) @binding(3) var<storage, read_write> skinMatrices: array<mat4x4f>;
722
+
723
+ @compute @workgroup_size(64) // Must match COMPUTE_WORKGROUP_SIZE
724
+ fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
725
+ let boneIndex = globalId.x;
726
+ if (boneIndex >= boneCount.count) {
727
+ return;
728
+ }
729
+ let worldMat = worldMatrices[boneIndex];
730
+ let invBindMat = inverseBindMatrices[boneIndex];
731
+ skinMatrices[boneIndex] = worldMat * invBindMat;
732
+ }
800
733
  `,
801
734
  });
802
735
  this.skinMatrixComputePipeline = this.device.createComputePipeline({
@@ -807,183 +740,145 @@ export class Engine {
807
740
  },
808
741
  });
809
742
  }
810
- // Create fullscreen quad for post-processing
811
- createFullscreenQuad() {
812
- // Fullscreen quad vertices: two triangles covering the entire screen - Format: position (x, y), uv (u, v)
813
- const quadVertices = new Float32Array([
814
- // Triangle 1
815
- -1.0,
816
- -1.0,
817
- 0.0,
818
- 0.0, // bottom-left
819
- 1.0,
820
- -1.0,
821
- 1.0,
822
- 0.0, // bottom-right
823
- -1.0,
824
- 1.0,
825
- 0.0,
826
- 1.0, // top-left
827
- // Triangle 2
828
- -1.0,
829
- 1.0,
830
- 0.0,
831
- 1.0, // top-left
832
- 1.0,
833
- -1.0,
834
- 1.0,
835
- 0.0, // bottom-right
836
- 1.0,
837
- 1.0,
838
- 1.0,
839
- 1.0, // top-right
840
- ]);
841
- this.fullscreenQuadBuffer = this.device.createBuffer({
842
- label: "fullscreen quad",
843
- size: quadVertices.byteLength,
844
- usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
845
- });
846
- this.device.queue.writeBuffer(this.fullscreenQuadBuffer, 0, quadVertices);
847
- }
848
743
  // Create bloom post-processing pipelines
849
744
  createBloomPipelines() {
850
745
  // Bloom extraction shader (extracts bright areas)
851
746
  const bloomExtractShader = this.device.createShaderModule({
852
747
  label: "bloom extract",
853
- code: /* wgsl */ `
854
- struct VertexOutput {
855
- @builtin(position) position: vec4f,
856
- @location(0) uv: vec2f,
857
- };
858
-
859
- @vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
860
- var output: VertexOutput;
861
- // Generate fullscreen quad from vertex index
862
- let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
863
- let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
864
- output.position = vec4f(x, y, 0.0, 1.0);
865
- output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
866
- return output;
867
- }
868
-
869
- struct BloomExtractUniforms {
870
- threshold: f32,
871
- _padding1: f32,
872
- _padding2: f32,
873
- _padding3: f32,
874
- _padding4: f32,
875
- _padding5: f32,
876
- _padding6: f32,
877
- _padding7: f32,
878
- };
879
-
880
- @group(0) @binding(0) var inputTexture: texture_2d<f32>;
881
- @group(0) @binding(1) var inputSampler: sampler;
882
- @group(0) @binding(2) var<uniform> extractUniforms: BloomExtractUniforms;
883
-
884
- @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
885
- let color = textureSample(inputTexture, inputSampler, input.uv);
886
- // Extract bright areas above threshold
887
- let threshold = extractUniforms.threshold;
888
- let bloom = max(vec3f(0.0), color.rgb - vec3f(threshold)) / max(0.001, 1.0 - threshold);
889
- return vec4f(bloom, color.a);
890
- }
748
+ code: /* wgsl */ `
749
+ struct VertexOutput {
750
+ @builtin(position) position: vec4f,
751
+ @location(0) uv: vec2f,
752
+ };
753
+
754
+ @vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
755
+ var output: VertexOutput;
756
+ // Generate fullscreen quad from vertex index
757
+ let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
758
+ let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
759
+ output.position = vec4f(x, y, 0.0, 1.0);
760
+ output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
761
+ return output;
762
+ }
763
+
764
+ struct BloomExtractUniforms {
765
+ threshold: f32,
766
+ _padding1: f32,
767
+ _padding2: f32,
768
+ _padding3: f32,
769
+ _padding4: f32,
770
+ _padding5: f32,
771
+ _padding6: f32,
772
+ _padding7: f32,
773
+ };
774
+
775
+ @group(0) @binding(0) var inputTexture: texture_2d<f32>;
776
+ @group(0) @binding(1) var inputSampler: sampler;
777
+ @group(0) @binding(2) var<uniform> extractUniforms: BloomExtractUniforms;
778
+
779
+ @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
780
+ let color = textureSample(inputTexture, inputSampler, input.uv);
781
+ // Extract bright areas above threshold
782
+ let threshold = extractUniforms.threshold;
783
+ let bloom = max(vec3f(0.0), color.rgb - vec3f(threshold)) / max(0.001, 1.0 - threshold);
784
+ return vec4f(bloom, color.a);
785
+ }
891
786
  `,
892
787
  });
893
788
  // Bloom blur shader (gaussian blur - can be used for both horizontal and vertical)
894
789
  const bloomBlurShader = this.device.createShaderModule({
895
790
  label: "bloom blur",
896
- code: /* wgsl */ `
897
- struct VertexOutput {
898
- @builtin(position) position: vec4f,
899
- @location(0) uv: vec2f,
900
- };
901
-
902
- @vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
903
- var output: VertexOutput;
904
- let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
905
- let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
906
- output.position = vec4f(x, y, 0.0, 1.0);
907
- output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
908
- return output;
909
- }
910
-
911
- struct BlurUniforms {
912
- direction: vec2f,
913
- _padding1: f32,
914
- _padding2: f32,
915
- _padding3: f32,
916
- _padding4: f32,
917
- _padding5: f32,
918
- _padding6: f32,
919
- };
920
-
921
- @group(0) @binding(0) var inputTexture: texture_2d<f32>;
922
- @group(0) @binding(1) var inputSampler: sampler;
923
- @group(0) @binding(2) var<uniform> blurUniforms: BlurUniforms;
924
-
925
- // 3-tap gaussian blur using bilinear filtering trick (40% fewer texture fetches!)
926
- @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
927
- let texelSize = 1.0 / vec2f(textureDimensions(inputTexture));
928
-
929
- // Bilinear optimization: leverage hardware filtering to sample between pixels
930
- // Original 5-tap: weights [0.06136, 0.24477, 0.38774, 0.24477, 0.06136] at offsets [-2, -1, 0, 1, 2]
931
- // Optimized 3-tap: combine adjacent samples using weighted offsets
932
- let weight0 = 0.38774; // Center sample
933
- let weight1 = 0.24477 + 0.06136; // Combined outer samples = 0.30613
934
- let offset1 = (0.24477 * 1.0 + 0.06136 * 2.0) / weight1; // Weighted position = 1.2
935
-
936
- var result = textureSample(inputTexture, inputSampler, input.uv) * weight0;
937
- let offsetVec = offset1 * texelSize * blurUniforms.direction;
938
- result += textureSample(inputTexture, inputSampler, input.uv + offsetVec) * weight1;
939
- result += textureSample(inputTexture, inputSampler, input.uv - offsetVec) * weight1;
940
-
941
- return result;
942
- }
791
+ code: /* wgsl */ `
792
+ struct VertexOutput {
793
+ @builtin(position) position: vec4f,
794
+ @location(0) uv: vec2f,
795
+ };
796
+
797
+ @vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
798
+ var output: VertexOutput;
799
+ let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
800
+ let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
801
+ output.position = vec4f(x, y, 0.0, 1.0);
802
+ output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
803
+ return output;
804
+ }
805
+
806
+ struct BlurUniforms {
807
+ direction: vec2f,
808
+ _padding1: f32,
809
+ _padding2: f32,
810
+ _padding3: f32,
811
+ _padding4: f32,
812
+ _padding5: f32,
813
+ _padding6: f32,
814
+ };
815
+
816
+ @group(0) @binding(0) var inputTexture: texture_2d<f32>;
817
+ @group(0) @binding(1) var inputSampler: sampler;
818
+ @group(0) @binding(2) var<uniform> blurUniforms: BlurUniforms;
819
+
820
+ // 3-tap gaussian blur using bilinear filtering trick (40% fewer texture fetches!)
821
+ @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
822
+ let texelSize = 1.0 / vec2f(textureDimensions(inputTexture));
823
+
824
+ // Bilinear optimization: leverage hardware filtering to sample between pixels
825
+ // Original 5-tap: weights [0.06136, 0.24477, 0.38774, 0.24477, 0.06136] at offsets [-2, -1, 0, 1, 2]
826
+ // Optimized 3-tap: combine adjacent samples using weighted offsets
827
+ let weight0 = 0.38774; // Center sample
828
+ let weight1 = 0.24477 + 0.06136; // Combined outer samples = 0.30613
829
+ let offset1 = (0.24477 * 1.0 + 0.06136 * 2.0) / weight1; // Weighted position = 1.2
830
+
831
+ var result = textureSample(inputTexture, inputSampler, input.uv) * weight0;
832
+ let offsetVec = offset1 * texelSize * blurUniforms.direction;
833
+ result += textureSample(inputTexture, inputSampler, input.uv + offsetVec) * weight1;
834
+ result += textureSample(inputTexture, inputSampler, input.uv - offsetVec) * weight1;
835
+
836
+ return result;
837
+ }
943
838
  `,
944
839
  });
945
840
  // Bloom composition shader (combines original scene with bloom)
946
841
  const bloomComposeShader = this.device.createShaderModule({
947
842
  label: "bloom compose",
948
- code: /* wgsl */ `
949
- struct VertexOutput {
950
- @builtin(position) position: vec4f,
951
- @location(0) uv: vec2f,
952
- };
953
-
954
- @vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
955
- var output: VertexOutput;
956
- let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
957
- let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
958
- output.position = vec4f(x, y, 0.0, 1.0);
959
- output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
960
- return output;
961
- }
962
-
963
- struct BloomComposeUniforms {
964
- intensity: f32,
965
- _padding1: f32,
966
- _padding2: f32,
967
- _padding3: f32,
968
- _padding4: f32,
969
- _padding5: f32,
970
- _padding6: f32,
971
- _padding7: f32,
972
- };
973
-
974
- @group(0) @binding(0) var sceneTexture: texture_2d<f32>;
975
- @group(0) @binding(1) var sceneSampler: sampler;
976
- @group(0) @binding(2) var bloomTexture: texture_2d<f32>;
977
- @group(0) @binding(3) var bloomSampler: sampler;
978
- @group(0) @binding(4) var<uniform> composeUniforms: BloomComposeUniforms;
979
-
980
- @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
981
- let scene = textureSample(sceneTexture, sceneSampler, input.uv);
982
- let bloom = textureSample(bloomTexture, bloomSampler, input.uv);
983
- // Additive blending with intensity control
984
- let result = scene.rgb + bloom.rgb * composeUniforms.intensity;
985
- return vec4f(result, scene.a);
986
- }
843
+ code: /* wgsl */ `
844
+ struct VertexOutput {
845
+ @builtin(position) position: vec4f,
846
+ @location(0) uv: vec2f,
847
+ };
848
+
849
+ @vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
850
+ var output: VertexOutput;
851
+ let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
852
+ let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
853
+ output.position = vec4f(x, y, 0.0, 1.0);
854
+ output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
855
+ return output;
856
+ }
857
+
858
+ struct BloomComposeUniforms {
859
+ intensity: f32,
860
+ _padding1: f32,
861
+ _padding2: f32,
862
+ _padding3: f32,
863
+ _padding4: f32,
864
+ _padding5: f32,
865
+ _padding6: f32,
866
+ _padding7: f32,
867
+ };
868
+
869
+ @group(0) @binding(0) var sceneTexture: texture_2d<f32>;
870
+ @group(0) @binding(1) var sceneSampler: sampler;
871
+ @group(0) @binding(2) var bloomTexture: texture_2d<f32>;
872
+ @group(0) @binding(3) var bloomSampler: sampler;
873
+ @group(0) @binding(4) var<uniform> composeUniforms: BloomComposeUniforms;
874
+
875
+ @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
876
+ let scene = textureSample(sceneTexture, sceneSampler, input.uv);
877
+ let bloom = textureSample(bloomTexture, bloomSampler, input.uv);
878
+ // Additive blending with intensity control
879
+ let result = scene.rgb + bloom.rgb * composeUniforms.intensity;
880
+ return vec4f(result, scene.a);
881
+ }
987
882
  `,
988
883
  });
989
884
  // Create uniform buffer for blur direction (minimum 32 bytes for WebGPU)
@@ -1162,10 +1057,11 @@ export class Engine {
1162
1057
  format: this.presentationFormat,
1163
1058
  usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1164
1059
  });
1165
- this.sceneRenderTextureView = this.sceneRenderTexture.createView();
1166
1060
  // Setup bloom textures and bind groups
1167
1061
  this.setupBloom(width, height);
1168
1062
  const depthTextureView = this.depthTexture.createView();
1063
+ // Cache the scene render texture view (only recreate on resize)
1064
+ this.sceneRenderTextureView = this.sceneRenderTexture.createView();
1169
1065
  // Render scene to texture instead of directly to canvas
1170
1066
  const colorAttachment = this.sampleCount > 1
1171
1067
  ? {
@@ -1562,38 +1458,6 @@ export class Engine {
1562
1458
  const texture = await this.createTextureFromPath(path);
1563
1459
  return texture;
1564
1460
  };
1565
- const loadToonTexture = async (toonTextureIndex) => {
1566
- const texture = await loadTextureByIndex(toonTextureIndex);
1567
- if (texture)
1568
- return texture;
1569
- // Default toon texture fallback - cache it
1570
- const defaultToonPath = "__default_toon__";
1571
- const cached = this.textureCache.get(defaultToonPath);
1572
- if (cached)
1573
- return cached;
1574
- const defaultToonData = new Uint8Array(256 * 2 * 4);
1575
- for (let i = 0; i < 256; i++) {
1576
- const factor = i / 255.0;
1577
- const gray = Math.floor(128 + factor * 127);
1578
- defaultToonData[i * 4] = gray;
1579
- defaultToonData[i * 4 + 1] = gray;
1580
- defaultToonData[i * 4 + 2] = gray;
1581
- defaultToonData[i * 4 + 3] = 255;
1582
- defaultToonData[(256 + i) * 4] = gray;
1583
- defaultToonData[(256 + i) * 4 + 1] = gray;
1584
- defaultToonData[(256 + i) * 4 + 2] = gray;
1585
- defaultToonData[(256 + i) * 4 + 3] = 255;
1586
- }
1587
- const defaultToonTexture = this.device.createTexture({
1588
- label: "default toon texture",
1589
- size: [256, 2],
1590
- format: "rgba8unorm",
1591
- usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
1592
- });
1593
- this.device.queue.writeTexture({ texture: defaultToonTexture }, defaultToonData, { bytesPerRow: 256 * 4 }, [256, 2]);
1594
- this.textureCache.set(defaultToonPath, defaultToonTexture);
1595
- return defaultToonTexture;
1596
- };
1597
1461
  this.opaqueDraws = [];
1598
1462
  this.eyeDraws = [];
1599
1463
  this.hairDrawsOverEyes = [];
@@ -1611,10 +1475,8 @@ export class Engine {
1611
1475
  const diffuseTexture = await loadTextureByIndex(mat.diffuseTextureIndex);
1612
1476
  if (!diffuseTexture)
1613
1477
  throw new Error(`Material "${mat.name}" has no diffuse texture`);
1614
- const toonTexture = await loadToonTexture(mat.toonTextureIndex);
1615
1478
  const materialAlpha = mat.diffuse[3];
1616
- const EPSILON = 0.001;
1617
- const isTransparent = materialAlpha < 1.0 - EPSILON;
1479
+ const isTransparent = materialAlpha < 1.0 - Engine.TRANSPARENCY_EPSILON;
1618
1480
  // Create material uniform data
1619
1481
  const materialUniformData = new Float32Array(8);
1620
1482
  materialUniformData[0] = materialAlpha;
@@ -1641,18 +1503,17 @@ export class Engine {
1641
1503
  { binding: 2, resource: diffuseTexture.createView() },
1642
1504
  { binding: 3, resource: this.materialSampler },
1643
1505
  { binding: 4, resource: { buffer: this.skinMatrixBuffer } },
1644
- { binding: 5, resource: toonTexture.createView() },
1645
- { binding: 6, resource: this.materialSampler },
1646
- { binding: 7, resource: { buffer: materialUniformBuffer } },
1506
+ { binding: 5, resource: { buffer: materialUniformBuffer } },
1647
1507
  ],
1648
1508
  });
1649
1509
  if (mat.isEye) {
1650
- this.eyeDraws.push({
1651
- count: indexCount,
1652
- firstIndex: currentIndexOffset,
1653
- bindGroup,
1654
- isTransparent,
1655
- });
1510
+ if (indexCount > 0) {
1511
+ this.eyeDraws.push({
1512
+ count: indexCount,
1513
+ firstIndex: currentIndexOffset,
1514
+ bindGroup,
1515
+ });
1516
+ }
1656
1517
  }
1657
1518
  else if (mat.isHair) {
1658
1519
  // Hair materials: create separate bind groups for over-eyes vs over-non-eyes
@@ -1681,42 +1542,42 @@ export class Engine {
1681
1542
  { binding: 2, resource: diffuseTexture.createView() },
1682
1543
  { binding: 3, resource: this.materialSampler },
1683
1544
  { binding: 4, resource: { buffer: this.skinMatrixBuffer } },
1684
- { binding: 5, resource: toonTexture.createView() },
1685
- { binding: 6, resource: this.materialSampler },
1686
- { binding: 7, resource: { buffer: buffer } },
1545
+ { binding: 5, resource: { buffer: buffer } },
1687
1546
  ],
1688
1547
  });
1689
1548
  };
1690
1549
  const bindGroupOverEyes = createHairBindGroup(true);
1691
1550
  const bindGroupOverNonEyes = createHairBindGroup(false);
1692
- this.hairDrawsOverEyes.push({
1693
- count: indexCount,
1694
- firstIndex: currentIndexOffset,
1695
- bindGroup: bindGroupOverEyes,
1696
- isTransparent,
1697
- });
1698
- this.hairDrawsOverNonEyes.push({
1699
- count: indexCount,
1700
- firstIndex: currentIndexOffset,
1701
- bindGroup: bindGroupOverNonEyes,
1702
- isTransparent,
1703
- });
1551
+ if (indexCount > 0) {
1552
+ this.hairDrawsOverEyes.push({
1553
+ count: indexCount,
1554
+ firstIndex: currentIndexOffset,
1555
+ bindGroup: bindGroupOverEyes,
1556
+ });
1557
+ this.hairDrawsOverNonEyes.push({
1558
+ count: indexCount,
1559
+ firstIndex: currentIndexOffset,
1560
+ bindGroup: bindGroupOverNonEyes,
1561
+ });
1562
+ }
1704
1563
  }
1705
1564
  else if (isTransparent) {
1706
- this.transparentDraws.push({
1707
- count: indexCount,
1708
- firstIndex: currentIndexOffset,
1709
- bindGroup,
1710
- isTransparent,
1711
- });
1565
+ if (indexCount > 0) {
1566
+ this.transparentDraws.push({
1567
+ count: indexCount,
1568
+ firstIndex: currentIndexOffset,
1569
+ bindGroup,
1570
+ });
1571
+ }
1712
1572
  }
1713
1573
  else {
1714
- this.opaqueDraws.push({
1715
- count: indexCount,
1716
- firstIndex: currentIndexOffset,
1717
- bindGroup,
1718
- isTransparent,
1719
- });
1574
+ if (indexCount > 0) {
1575
+ this.opaqueDraws.push({
1576
+ count: indexCount,
1577
+ firstIndex: currentIndexOffset,
1578
+ bindGroup,
1579
+ });
1580
+ }
1720
1581
  }
1721
1582
  // Edge flag is at bit 4 (0x10) in PMX format
1722
1583
  if ((mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
@@ -1744,37 +1605,35 @@ export class Engine {
1744
1605
  { binding: 2, resource: { buffer: this.skinMatrixBuffer } },
1745
1606
  ],
1746
1607
  });
1747
- if (mat.isEye) {
1748
- this.eyeOutlineDraws.push({
1749
- count: indexCount,
1750
- firstIndex: currentIndexOffset,
1751
- bindGroup: outlineBindGroup,
1752
- isTransparent,
1753
- });
1754
- }
1755
- else if (mat.isHair) {
1756
- this.hairOutlineDraws.push({
1757
- count: indexCount,
1758
- firstIndex: currentIndexOffset,
1759
- bindGroup: outlineBindGroup,
1760
- isTransparent,
1761
- });
1762
- }
1763
- else if (isTransparent) {
1764
- this.transparentOutlineDraws.push({
1765
- count: indexCount,
1766
- firstIndex: currentIndexOffset,
1767
- bindGroup: outlineBindGroup,
1768
- isTransparent,
1769
- });
1770
- }
1771
- else {
1772
- this.opaqueOutlineDraws.push({
1773
- count: indexCount,
1774
- firstIndex: currentIndexOffset,
1775
- bindGroup: outlineBindGroup,
1776
- isTransparent,
1777
- });
1608
+ if (indexCount > 0) {
1609
+ if (mat.isEye) {
1610
+ this.eyeOutlineDraws.push({
1611
+ count: indexCount,
1612
+ firstIndex: currentIndexOffset,
1613
+ bindGroup: outlineBindGroup,
1614
+ });
1615
+ }
1616
+ else if (mat.isHair) {
1617
+ this.hairOutlineDraws.push({
1618
+ count: indexCount,
1619
+ firstIndex: currentIndexOffset,
1620
+ bindGroup: outlineBindGroup,
1621
+ });
1622
+ }
1623
+ else if (isTransparent) {
1624
+ this.transparentOutlineDraws.push({
1625
+ count: indexCount,
1626
+ firstIndex: currentIndexOffset,
1627
+ bindGroup: outlineBindGroup,
1628
+ });
1629
+ }
1630
+ else {
1631
+ this.opaqueOutlineDraws.push({
1632
+ count: indexCount,
1633
+ firstIndex: currentIndexOffset,
1634
+ bindGroup: outlineBindGroup,
1635
+ });
1636
+ }
1778
1637
  }
1779
1638
  }
1780
1639
  currentIndexOffset += indexCount;
@@ -1811,6 +1670,56 @@ export class Engine {
1811
1670
  return null;
1812
1671
  }
1813
1672
  }
1673
+ // Helper: Render eyes with stencil writing (for post-alpha-eye effect)
1674
+ renderEyes(pass) {
1675
+ pass.setPipeline(this.eyePipeline);
1676
+ pass.setStencilReference(this.STENCIL_EYE_VALUE);
1677
+ for (const draw of this.eyeDraws) {
1678
+ pass.setBindGroup(0, draw.bindGroup);
1679
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1680
+ }
1681
+ }
1682
+ // Helper: Render hair with post-alpha-eye effect (depth pre-pass + stencil-based shading + outlines)
1683
+ renderHair(pass) {
1684
+ // Hair depth pre-pass (reduces overdraw via early depth rejection)
1685
+ const hasHair = this.hairDrawsOverEyes.length > 0 || this.hairDrawsOverNonEyes.length > 0;
1686
+ if (hasHair) {
1687
+ pass.setPipeline(this.hairDepthPipeline);
1688
+ for (const draw of this.hairDrawsOverEyes) {
1689
+ pass.setBindGroup(0, draw.bindGroup);
1690
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1691
+ }
1692
+ for (const draw of this.hairDrawsOverNonEyes) {
1693
+ pass.setBindGroup(0, draw.bindGroup);
1694
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1695
+ }
1696
+ }
1697
+ // Hair shading (split by stencil for transparency over eyes)
1698
+ if (this.hairDrawsOverEyes.length > 0) {
1699
+ pass.setPipeline(this.hairPipelineOverEyes);
1700
+ pass.setStencilReference(this.STENCIL_EYE_VALUE);
1701
+ for (const draw of this.hairDrawsOverEyes) {
1702
+ pass.setBindGroup(0, draw.bindGroup);
1703
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1704
+ }
1705
+ }
1706
+ if (this.hairDrawsOverNonEyes.length > 0) {
1707
+ pass.setPipeline(this.hairPipelineOverNonEyes);
1708
+ pass.setStencilReference(this.STENCIL_EYE_VALUE);
1709
+ for (const draw of this.hairDrawsOverNonEyes) {
1710
+ pass.setBindGroup(0, draw.bindGroup);
1711
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1712
+ }
1713
+ }
1714
+ // Hair outlines
1715
+ if (this.hairOutlineDraws.length > 0) {
1716
+ pass.setPipeline(this.hairOutlinePipeline);
1717
+ for (const draw of this.hairOutlineDraws) {
1718
+ pass.setBindGroup(0, draw.bindGroup);
1719
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1720
+ }
1721
+ }
1722
+ }
1814
1723
  // Render strategy: 1) Opaque non-eye/hair 2) Eyes (stencil=1) 3) Hair (depth pre-pass + split by stencil) 4) Transparent 5) Bloom
1815
1724
  render() {
1816
1725
  if (this.multisampleTexture && this.camera && this.device) {
@@ -1830,7 +1739,6 @@ export class Engine {
1830
1739
  return;
1831
1740
  }
1832
1741
  const pass = encoder.beginRenderPass(this.renderPassDescriptor);
1833
- this.drawCallCount = 0;
1834
1742
  if (this.currentModel) {
1835
1743
  pass.setVertexBuffer(0, this.vertexBuffer);
1836
1744
  pass.setVertexBuffer(1, this.jointsBuffer);
@@ -1839,81 +1747,19 @@ export class Engine {
1839
1747
  // Pass 1: Opaque
1840
1748
  pass.setPipeline(this.modelPipeline);
1841
1749
  for (const draw of this.opaqueDraws) {
1842
- if (draw.count > 0) {
1843
- pass.setBindGroup(0, draw.bindGroup);
1844
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1845
- this.drawCallCount++;
1846
- }
1750
+ pass.setBindGroup(0, draw.bindGroup);
1751
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1847
1752
  }
1848
1753
  // Pass 2: Eyes (writes stencil value for hair to test against)
1849
- pass.setPipeline(this.eyePipeline);
1850
- pass.setStencilReference(this.STENCIL_EYE_VALUE);
1851
- for (const draw of this.eyeDraws) {
1852
- if (draw.count > 0) {
1853
- pass.setBindGroup(0, draw.bindGroup);
1854
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1855
- this.drawCallCount++;
1856
- }
1857
- }
1858
- // Pass 3: Hair rendering (depth pre-pass + shading + outlines)
1754
+ this.renderEyes(pass);
1859
1755
  this.drawOutlines(pass, false);
1860
- // 3a: Hair depth pre-pass (reduces overdraw via early depth rejection)
1861
- if (this.hairDrawsOverEyes.length > 0 || this.hairDrawsOverNonEyes.length > 0) {
1862
- pass.setPipeline(this.hairDepthPipeline);
1863
- for (const draw of this.hairDrawsOverEyes) {
1864
- if (draw.count > 0) {
1865
- pass.setBindGroup(0, draw.bindGroup);
1866
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1867
- }
1868
- }
1869
- for (const draw of this.hairDrawsOverNonEyes) {
1870
- if (draw.count > 0) {
1871
- pass.setBindGroup(0, draw.bindGroup);
1872
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1873
- }
1874
- }
1875
- }
1876
- // 3b: Hair shading (split by stencil for transparency over eyes)
1877
- if (this.hairDrawsOverEyes.length > 0) {
1878
- pass.setPipeline(this.hairPipelineOverEyes);
1879
- pass.setStencilReference(this.STENCIL_EYE_VALUE);
1880
- for (const draw of this.hairDrawsOverEyes) {
1881
- if (draw.count > 0) {
1882
- pass.setBindGroup(0, draw.bindGroup);
1883
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1884
- this.drawCallCount++;
1885
- }
1886
- }
1887
- }
1888
- if (this.hairDrawsOverNonEyes.length > 0) {
1889
- pass.setPipeline(this.hairPipelineOverNonEyes);
1890
- pass.setStencilReference(this.STENCIL_EYE_VALUE);
1891
- for (const draw of this.hairDrawsOverNonEyes) {
1892
- if (draw.count > 0) {
1893
- pass.setBindGroup(0, draw.bindGroup);
1894
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1895
- this.drawCallCount++;
1896
- }
1897
- }
1898
- }
1899
- // 3c: Hair outlines
1900
- if (this.hairOutlineDraws.length > 0) {
1901
- pass.setPipeline(this.hairOutlinePipeline);
1902
- for (const draw of this.hairOutlineDraws) {
1903
- if (draw.count > 0) {
1904
- pass.setBindGroup(0, draw.bindGroup);
1905
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1906
- }
1907
- }
1908
- }
1756
+ // Pass 3: Hair rendering (depth pre-pass + shading + outlines)
1757
+ this.renderHair(pass);
1909
1758
  // Pass 4: Transparent
1910
1759
  pass.setPipeline(this.modelPipeline);
1911
1760
  for (const draw of this.transparentDraws) {
1912
- if (draw.count > 0) {
1913
- pass.setBindGroup(0, draw.bindGroup);
1914
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1915
- this.drawCallCount++;
1916
- }
1761
+ pass.setBindGroup(0, draw.bindGroup);
1762
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1917
1763
  }
1918
1764
  this.drawOutlines(pass, true);
1919
1765
  }
@@ -2021,6 +1867,7 @@ export class Engine {
2021
1867
  this.device.queue.writeBuffer(this.cameraUniformBuffer, 0, this.cameraMatrixData);
2022
1868
  }
2023
1869
  updateRenderTarget() {
1870
+ // Use cached view (only recreated on resize in handleResize)
2024
1871
  const colorAttachment = this.renderPassDescriptor.colorAttachments[0];
2025
1872
  if (this.sampleCount > 1) {
2026
1873
  colorAttachment.resolveTarget = this.sceneRenderTextureView;
@@ -2049,40 +1896,44 @@ export class Engine {
2049
1896
  }
2050
1897
  drawOutlines(pass, transparent) {
2051
1898
  pass.setPipeline(this.outlinePipeline);
2052
- if (transparent) {
2053
- for (const draw of this.transparentOutlineDraws) {
2054
- if (draw.count > 0) {
2055
- pass.setBindGroup(0, draw.bindGroup);
2056
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
2057
- }
2058
- }
2059
- }
2060
- else {
2061
- for (const draw of this.opaqueOutlineDraws) {
2062
- if (draw.count > 0) {
2063
- pass.setBindGroup(0, draw.bindGroup);
2064
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
2065
- }
2066
- }
1899
+ const draws = transparent ? this.transparentOutlineDraws : this.opaqueOutlineDraws;
1900
+ for (const draw of draws) {
1901
+ pass.setBindGroup(0, draw.bindGroup);
1902
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
2067
1903
  }
2068
1904
  }
2069
1905
  updateStats(frameTime) {
1906
+ // Simplified frame time tracking - rolling average with fixed window
2070
1907
  const maxSamples = 60;
2071
- this.frameTimeSamples.push(frameTime);
2072
1908
  this.frameTimeSum += frameTime;
2073
- if (this.frameTimeSamples.length > maxSamples) {
2074
- const removed = this.frameTimeSamples.shift();
2075
- this.frameTimeSum -= removed;
1909
+ this.frameTimeCount++;
1910
+ if (this.frameTimeCount > maxSamples) {
1911
+ // Maintain rolling window by subtracting oldest sample estimate
1912
+ const avg = this.frameTimeSum / maxSamples;
1913
+ this.frameTimeSum -= avg;
1914
+ this.frameTimeCount = maxSamples;
2076
1915
  }
2077
- const avgFrameTime = this.frameTimeSum / this.frameTimeSamples.length;
2078
- this.stats.frameTime = Math.round(avgFrameTime * 100) / 100;
1916
+ this.stats.frameTime =
1917
+ Math.round((this.frameTimeSum / this.frameTimeCount) * Engine.STATS_FRAME_TIME_ROUNDING) /
1918
+ Engine.STATS_FRAME_TIME_ROUNDING;
1919
+ // FPS tracking
2079
1920
  const now = performance.now();
2080
1921
  this.framesSinceLastUpdate++;
2081
1922
  const elapsed = now - this.lastFpsUpdate;
2082
- if (elapsed >= 1000) {
2083
- this.stats.fps = Math.round((this.framesSinceLastUpdate / elapsed) * 1000);
1923
+ if (elapsed >= Engine.STATS_FPS_UPDATE_INTERVAL_MS) {
1924
+ this.stats.fps = Math.round((this.framesSinceLastUpdate / elapsed) * Engine.STATS_FPS_UPDATE_INTERVAL_MS);
2084
1925
  this.framesSinceLastUpdate = 0;
2085
1926
  this.lastFpsUpdate = now;
2086
1927
  }
2087
1928
  }
2088
1929
  }
1930
+ // Default values
1931
+ Engine.DEFAULT_BLOOM_THRESHOLD = 0.01;
1932
+ Engine.DEFAULT_BLOOM_INTENSITY = 0.12;
1933
+ Engine.DEFAULT_RIM_LIGHT_INTENSITY = 0.45;
1934
+ Engine.DEFAULT_CAMERA_DISTANCE = 26.6;
1935
+ Engine.DEFAULT_CAMERA_TARGET = new Vec3(0, 12.5, 0);
1936
+ Engine.HAIR_OVER_EYES_ALPHA = 0.5;
1937
+ Engine.TRANSPARENCY_EPSILON = 0.001;
1938
+ Engine.STATS_FPS_UPDATE_INTERVAL_MS = 1000;
1939
+ Engine.STATS_FRAME_TIME_ROUNDING = 100;