reze-engine 0.2.17 → 0.2.19

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,14 +18,15 @@ 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;
28
28
  this.textureCache = new Map();
29
+ this.vertexBufferNeedsUpdate = false;
29
30
  // Draw lists
30
31
  this.opaqueDraws = [];
31
32
  this.eyeDraws = [];
@@ -38,10 +39,9 @@ export class Engine {
38
39
  this.transparentOutlineDraws = [];
39
40
  this.lastFpsUpdate = performance.now();
40
41
  this.framesSinceLastUpdate = 0;
41
- this.frameTimeSamples = [];
42
- this.frameTimeSum = 0;
43
- this.drawCallCount = 0;
44
42
  this.lastFrameTime = performance.now();
43
+ this.frameTimeSum = 0;
44
+ this.frameTimeCount = 0;
45
45
  this.stats = {
46
46
  fps: 0,
47
47
  frameTime: 0,
@@ -57,10 +57,10 @@ export class Engine {
57
57
  this.canvas = canvas;
58
58
  if (options) {
59
59
  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);
60
+ this.bloomIntensity = options.bloomIntensity ?? Engine.DEFAULT_BLOOM_INTENSITY;
61
+ this.rimLightIntensity = options.rimLightIntensity ?? Engine.DEFAULT_RIM_LIGHT_INTENSITY;
62
+ this.cameraDistance = options.cameraDistance ?? Engine.DEFAULT_CAMERA_DISTANCE;
63
+ this.cameraTarget = options.cameraTarget ?? Engine.DEFAULT_CAMERA_TARGET;
64
64
  }
65
65
  }
66
66
  // Step 1: Get WebGPU device and context
@@ -85,7 +85,6 @@ export class Engine {
85
85
  this.setupCamera();
86
86
  this.setupLighting();
87
87
  this.createPipelines();
88
- this.createFullscreenQuad();
89
88
  this.createBloomPipelines();
90
89
  this.setupResize();
91
90
  }
@@ -98,101 +97,99 @@ export class Engine {
98
97
  });
99
98
  const shaderModule = this.device.createShaderModule({
100
99
  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
- }
100
+ code: /* wgsl */ `
101
+ struct CameraUniforms {
102
+ view: mat4x4f,
103
+ projection: mat4x4f,
104
+ viewPos: vec3f,
105
+ _padding: f32,
106
+ };
107
+
108
+ struct LightUniforms {
109
+ ambientColor: vec3f,
110
+ };
111
+
112
+ struct MaterialUniforms {
113
+ alpha: f32,
114
+ alphaMultiplier: f32,
115
+ rimIntensity: f32,
116
+ _padding1: f32,
117
+ rimColor: vec3f,
118
+ isOverEyes: f32, // 1.0 if rendering over eyes, 0.0 otherwise
119
+ };
120
+
121
+ struct VertexOutput {
122
+ @builtin(position) position: vec4f,
123
+ @location(0) normal: vec3f,
124
+ @location(1) uv: vec2f,
125
+ @location(2) worldPos: vec3f,
126
+ };
127
+
128
+ @group(0) @binding(0) var<uniform> camera: CameraUniforms;
129
+ @group(0) @binding(1) var<uniform> light: LightUniforms;
130
+ @group(0) @binding(2) var diffuseTexture: texture_2d<f32>;
131
+ @group(0) @binding(3) var diffuseSampler: sampler;
132
+ @group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
133
+ @group(0) @binding(5) var<uniform> material: MaterialUniforms;
134
+
135
+ @vertex fn vs(
136
+ @location(0) position: vec3f,
137
+ @location(1) normal: vec3f,
138
+ @location(2) uv: vec2f,
139
+ @location(3) joints0: vec4<u32>,
140
+ @location(4) weights0: vec4<f32>
141
+ ) -> VertexOutput {
142
+ var output: VertexOutput;
143
+ let pos4 = vec4f(position, 1.0);
144
+
145
+ // Branchless weight normalization (avoids GPU branch divergence)
146
+ let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
147
+ let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);
148
+ let normalizedWeights = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
149
+
150
+ var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
151
+ var skinnedNrm = vec3f(0.0, 0.0, 0.0);
152
+ for (var i = 0u; i < 4u; i++) {
153
+ let j = joints0[i];
154
+ let w = normalizedWeights[i];
155
+ let m = skinMats[j];
156
+ skinnedPos += (m * pos4) * w;
157
+ let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
158
+ skinnedNrm += (r3 * normal) * w;
159
+ }
160
+ let worldPos = skinnedPos.xyz;
161
+ output.position = camera.projection * camera.view * vec4f(worldPos, 1.0);
162
+ output.normal = normalize(skinnedNrm);
163
+ output.uv = uv;
164
+ output.worldPos = worldPos;
165
+ return output;
166
+ }
167
+
168
+ @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
169
+ // Early alpha test - discard before expensive calculations
170
+ var finalAlpha = material.alpha * material.alphaMultiplier;
171
+ if (material.isOverEyes > 0.5) {
172
+ finalAlpha *= 0.5; // Hair over eyes gets 50% alpha
173
+ }
174
+ if (finalAlpha < 0.001) {
175
+ discard;
176
+ }
177
+
178
+ let n = normalize(input.normal);
179
+ let albedo = textureSample(diffuseTexture, diffuseSampler, input.uv).rgb;
180
+
181
+ let lightAccum = light.ambientColor;
182
+
183
+ // Rim light calculation
184
+ let viewDir = normalize(camera.viewPos - input.worldPos);
185
+ var rimFactor = 1.0 - max(dot(n, viewDir), 0.0);
186
+ rimFactor = rimFactor * rimFactor; // Optimized: direct multiply instead of pow(x, 2.0)
187
+ let rimLight = material.rimColor * material.rimIntensity * rimFactor;
188
+
189
+ let color = albedo * lightAccum + rimLight;
190
+
191
+ return vec4f(color, finalAlpha);
192
+ }
196
193
  `,
197
194
  });
198
195
  // Create explicit bind group layout for all pipelines using the main shader
@@ -204,9 +201,7 @@ export class Engine {
204
201
  { binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: {} }, // diffuseTexture
205
202
  { binding: 3, visibility: GPUShaderStage.FRAGMENT, sampler: {} }, // diffuseSampler
206
203
  { 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
204
+ { binding: 5, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // material
210
205
  ],
211
206
  });
212
207
  const mainPipelineLayout = this.device.createPipelineLayout({
@@ -282,73 +277,73 @@ export class Engine {
282
277
  });
283
278
  const outlineShaderModule = this.device.createShaderModule({
284
279
  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
- }
280
+ code: /* wgsl */ `
281
+ struct CameraUniforms {
282
+ view: mat4x4f,
283
+ projection: mat4x4f,
284
+ viewPos: vec3f,
285
+ _padding: f32,
286
+ };
287
+
288
+ struct MaterialUniforms {
289
+ edgeColor: vec4f,
290
+ edgeSize: f32,
291
+ isOverEyes: f32, // 1.0 if rendering over eyes, 0.0 otherwise (for hair outlines)
292
+ _padding1: f32,
293
+ _padding2: f32,
294
+ };
295
+
296
+ @group(0) @binding(0) var<uniform> camera: CameraUniforms;
297
+ @group(0) @binding(1) var<uniform> material: MaterialUniforms;
298
+ @group(0) @binding(2) var<storage, read> skinMats: array<mat4x4f>;
299
+
300
+ struct VertexOutput {
301
+ @builtin(position) position: vec4f,
302
+ };
303
+
304
+ @vertex fn vs(
305
+ @location(0) position: vec3f,
306
+ @location(1) normal: vec3f,
307
+ @location(3) joints0: vec4<u32>,
308
+ @location(4) weights0: vec4<f32>
309
+ ) -> VertexOutput {
310
+ var output: VertexOutput;
311
+ let pos4 = vec4f(position, 1.0);
312
+
313
+ // Branchless weight normalization (avoids GPU branch divergence)
314
+ let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
315
+ let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);
316
+ let normalizedWeights = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
317
+
318
+ var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
319
+ var skinnedNrm = vec3f(0.0, 0.0, 0.0);
320
+ for (var i = 0u; i < 4u; i++) {
321
+ let j = joints0[i];
322
+ let w = normalizedWeights[i];
323
+ let m = skinMats[j];
324
+ skinnedPos += (m * pos4) * w;
325
+ let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
326
+ skinnedNrm += (r3 * normal) * w;
327
+ }
328
+ let worldPos = skinnedPos.xyz;
329
+ let worldNormal = normalize(skinnedNrm);
330
+
331
+ // MMD invert hull: expand vertices outward along normals
332
+ let scaleFactor = 0.01;
333
+ let expandedPos = worldPos + worldNormal * material.edgeSize * scaleFactor;
334
+ output.position = camera.projection * camera.view * vec4f(expandedPos, 1.0);
335
+ return output;
336
+ }
337
+
338
+ @fragment fn fs() -> @location(0) vec4f {
339
+ var color = material.edgeColor;
340
+
341
+ if (material.isOverEyes > 0.5) {
342
+ color.a *= 0.5; // Hair outlines over eyes get 50% alpha
343
+ }
344
+
345
+ return color;
346
+ }
352
347
  `,
353
348
  });
354
349
  this.outlinePipeline = this.device.createRenderPipeline({
@@ -552,45 +547,45 @@ export class Engine {
552
547
  // Depth-only shader for hair pre-pass (reduces overdraw by early depth rejection)
553
548
  const depthOnlyShaderModule = this.device.createShaderModule({
554
549
  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
- }
550
+ code: /* wgsl */ `
551
+ struct CameraUniforms {
552
+ view: mat4x4f,
553
+ projection: mat4x4f,
554
+ viewPos: vec3f,
555
+ _padding: f32,
556
+ };
557
+
558
+ @group(0) @binding(0) var<uniform> camera: CameraUniforms;
559
+ @group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
560
+
561
+ @vertex fn vs(
562
+ @location(0) position: vec3f,
563
+ @location(1) normal: vec3f,
564
+ @location(3) joints0: vec4<u32>,
565
+ @location(4) weights0: vec4<f32>
566
+ ) -> @builtin(position) vec4f {
567
+ let pos4 = vec4f(position, 1.0);
568
+
569
+ // Branchless weight normalization (avoids GPU branch divergence)
570
+ let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
571
+ let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);
572
+ let normalizedWeights = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
573
+
574
+ var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
575
+ for (var i = 0u; i < 4u; i++) {
576
+ let j = joints0[i];
577
+ let w = normalizedWeights[i];
578
+ let m = skinMats[j];
579
+ skinnedPos += (m * pos4) * w;
580
+ }
581
+ let worldPos = skinnedPos.xyz;
582
+ let clipPos = camera.projection * camera.view * vec4f(worldPos, 1.0);
583
+ return clipPos;
584
+ }
585
+
586
+ @fragment fn fs() -> @location(0) vec4f {
587
+ return vec4f(0.0, 0.0, 0.0, 0.0); // Transparent - color writes disabled via writeMask
588
+ }
594
589
  `,
595
590
  });
596
591
  // Hair depth pre-pass pipeline: depth-only with color writes disabled to eliminate overdraw
@@ -638,165 +633,104 @@ export class Engine {
638
633
  },
639
634
  multisample: { count: this.sampleCount },
640
635
  });
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
- },
636
+ // Hair pipelines for rendering over eyes vs non-eyes (only differ in stencil compare mode)
637
+ const createHairPipeline = (isOverEyes) => {
638
+ return this.device.createRenderPipeline({
639
+ label: `hair pipeline (${isOverEyes ? "over eyes" : "over non-eyes"})`,
640
+ layout: mainPipelineLayout,
641
+ vertex: {
642
+ module: shaderModule,
643
+ buffers: [
644
+ {
645
+ arrayStride: 8 * 4,
646
+ attributes: [
647
+ { shaderLocation: 0, offset: 0, format: "float32x3" },
648
+ { shaderLocation: 1, offset: 3 * 4, format: "float32x3" },
649
+ { shaderLocation: 2, offset: 6 * 4, format: "float32x2" },
650
+ ],
682
651
  },
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",
652
+ {
653
+ arrayStride: 4 * 2,
654
+ attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" }],
655
+ },
656
+ {
657
+ arrayStride: 4,
658
+ attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" }],
659
+ },
660
+ ],
702
661
  },
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",
662
+ fragment: {
663
+ module: shaderModule,
664
+ targets: [
665
+ {
666
+ format: this.presentationFormat,
667
+ blend: {
668
+ color: {
669
+ srcFactor: "src-alpha",
670
+ dstFactor: "one-minus-src-alpha",
671
+ operation: "add",
672
+ },
673
+ alpha: {
674
+ srcFactor: "one",
675
+ dstFactor: "one-minus-src-alpha",
676
+ operation: "add",
677
+ },
746
678
  },
747
679
  },
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",
680
+ ],
761
681
  },
762
- stencilBack: {
763
- compare: "not-equal",
764
- failOp: "keep",
765
- depthFailOp: "keep",
766
- passOp: "keep",
682
+ primitive: { cullMode: "front" },
683
+ depthStencil: {
684
+ format: "depth24plus-stencil8",
685
+ depthWriteEnabled: false, // Don't write depth (already written in pre-pass)
686
+ depthCompare: "less-equal", // More lenient than "equal" to avoid precision issues with MSAA
687
+ stencilFront: {
688
+ compare: isOverEyes ? "equal" : "not-equal", // Over eyes: stencil == 1, over non-eyes: stencil != 1
689
+ failOp: "keep",
690
+ depthFailOp: "keep",
691
+ passOp: "keep",
692
+ },
693
+ stencilBack: {
694
+ compare: isOverEyes ? "equal" : "not-equal",
695
+ failOp: "keep",
696
+ depthFailOp: "keep",
697
+ passOp: "keep",
698
+ },
767
699
  },
768
- },
769
- multisample: { count: this.sampleCount },
770
- });
700
+ multisample: { count: this.sampleCount },
701
+ });
702
+ };
703
+ this.hairPipelineOverEyes = createHairPipeline(true);
704
+ this.hairPipelineOverNonEyes = createHairPipeline(false);
771
705
  }
772
706
  // Create compute shader for skin matrix computation
773
707
  createSkinMatrixComputePipeline() {
774
708
  const computeShader = this.device.createShaderModule({
775
709
  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
- }
710
+ code: /* wgsl */ `
711
+ struct BoneCountUniform {
712
+ count: u32,
713
+ _padding1: u32,
714
+ _padding2: u32,
715
+ _padding3: u32,
716
+ _padding4: vec4<u32>,
717
+ };
718
+
719
+ @group(0) @binding(0) var<uniform> boneCount: BoneCountUniform;
720
+ @group(0) @binding(1) var<storage, read> worldMatrices: array<mat4x4f>;
721
+ @group(0) @binding(2) var<storage, read> inverseBindMatrices: array<mat4x4f>;
722
+ @group(0) @binding(3) var<storage, read_write> skinMatrices: array<mat4x4f>;
723
+
724
+ @compute @workgroup_size(64) // Must match COMPUTE_WORKGROUP_SIZE
725
+ fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
726
+ let boneIndex = globalId.x;
727
+ if (boneIndex >= boneCount.count) {
728
+ return;
729
+ }
730
+ let worldMat = worldMatrices[boneIndex];
731
+ let invBindMat = inverseBindMatrices[boneIndex];
732
+ skinMatrices[boneIndex] = worldMat * invBindMat;
733
+ }
800
734
  `,
801
735
  });
802
736
  this.skinMatrixComputePipeline = this.device.createComputePipeline({
@@ -807,183 +741,145 @@ export class Engine {
807
741
  },
808
742
  });
809
743
  }
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
744
  // Create bloom post-processing pipelines
849
745
  createBloomPipelines() {
850
746
  // Bloom extraction shader (extracts bright areas)
851
747
  const bloomExtractShader = this.device.createShaderModule({
852
748
  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
- }
749
+ code: /* wgsl */ `
750
+ struct VertexOutput {
751
+ @builtin(position) position: vec4f,
752
+ @location(0) uv: vec2f,
753
+ };
754
+
755
+ @vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
756
+ var output: VertexOutput;
757
+ // Generate fullscreen quad from vertex index
758
+ let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
759
+ let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
760
+ output.position = vec4f(x, y, 0.0, 1.0);
761
+ output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
762
+ return output;
763
+ }
764
+
765
+ struct BloomExtractUniforms {
766
+ threshold: f32,
767
+ _padding1: f32,
768
+ _padding2: f32,
769
+ _padding3: f32,
770
+ _padding4: f32,
771
+ _padding5: f32,
772
+ _padding6: f32,
773
+ _padding7: f32,
774
+ };
775
+
776
+ @group(0) @binding(0) var inputTexture: texture_2d<f32>;
777
+ @group(0) @binding(1) var inputSampler: sampler;
778
+ @group(0) @binding(2) var<uniform> extractUniforms: BloomExtractUniforms;
779
+
780
+ @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
781
+ let color = textureSample(inputTexture, inputSampler, input.uv);
782
+ // Extract bright areas above threshold
783
+ let threshold = extractUniforms.threshold;
784
+ let bloom = max(vec3f(0.0), color.rgb - vec3f(threshold)) / max(0.001, 1.0 - threshold);
785
+ return vec4f(bloom, color.a);
786
+ }
891
787
  `,
892
788
  });
893
789
  // Bloom blur shader (gaussian blur - can be used for both horizontal and vertical)
894
790
  const bloomBlurShader = this.device.createShaderModule({
895
791
  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
- }
792
+ code: /* wgsl */ `
793
+ struct VertexOutput {
794
+ @builtin(position) position: vec4f,
795
+ @location(0) uv: vec2f,
796
+ };
797
+
798
+ @vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
799
+ var output: VertexOutput;
800
+ let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
801
+ let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
802
+ output.position = vec4f(x, y, 0.0, 1.0);
803
+ output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
804
+ return output;
805
+ }
806
+
807
+ struct BlurUniforms {
808
+ direction: vec2f,
809
+ _padding1: f32,
810
+ _padding2: f32,
811
+ _padding3: f32,
812
+ _padding4: f32,
813
+ _padding5: f32,
814
+ _padding6: f32,
815
+ };
816
+
817
+ @group(0) @binding(0) var inputTexture: texture_2d<f32>;
818
+ @group(0) @binding(1) var inputSampler: sampler;
819
+ @group(0) @binding(2) var<uniform> blurUniforms: BlurUniforms;
820
+
821
+ // 3-tap gaussian blur using bilinear filtering trick (40% fewer texture fetches!)
822
+ @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
823
+ let texelSize = 1.0 / vec2f(textureDimensions(inputTexture));
824
+
825
+ // Bilinear optimization: leverage hardware filtering to sample between pixels
826
+ // Original 5-tap: weights [0.06136, 0.24477, 0.38774, 0.24477, 0.06136] at offsets [-2, -1, 0, 1, 2]
827
+ // Optimized 3-tap: combine adjacent samples using weighted offsets
828
+ let weight0 = 0.38774; // Center sample
829
+ let weight1 = 0.24477 + 0.06136; // Combined outer samples = 0.30613
830
+ let offset1 = (0.24477 * 1.0 + 0.06136 * 2.0) / weight1; // Weighted position = 1.2
831
+
832
+ var result = textureSample(inputTexture, inputSampler, input.uv) * weight0;
833
+ let offsetVec = offset1 * texelSize * blurUniforms.direction;
834
+ result += textureSample(inputTexture, inputSampler, input.uv + offsetVec) * weight1;
835
+ result += textureSample(inputTexture, inputSampler, input.uv - offsetVec) * weight1;
836
+
837
+ return result;
838
+ }
943
839
  `,
944
840
  });
945
841
  // Bloom composition shader (combines original scene with bloom)
946
842
  const bloomComposeShader = this.device.createShaderModule({
947
843
  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
- }
844
+ code: /* wgsl */ `
845
+ struct VertexOutput {
846
+ @builtin(position) position: vec4f,
847
+ @location(0) uv: vec2f,
848
+ };
849
+
850
+ @vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
851
+ var output: VertexOutput;
852
+ let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
853
+ let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
854
+ output.position = vec4f(x, y, 0.0, 1.0);
855
+ output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
856
+ return output;
857
+ }
858
+
859
+ struct BloomComposeUniforms {
860
+ intensity: f32,
861
+ _padding1: f32,
862
+ _padding2: f32,
863
+ _padding3: f32,
864
+ _padding4: f32,
865
+ _padding5: f32,
866
+ _padding6: f32,
867
+ _padding7: f32,
868
+ };
869
+
870
+ @group(0) @binding(0) var sceneTexture: texture_2d<f32>;
871
+ @group(0) @binding(1) var sceneSampler: sampler;
872
+ @group(0) @binding(2) var bloomTexture: texture_2d<f32>;
873
+ @group(0) @binding(3) var bloomSampler: sampler;
874
+ @group(0) @binding(4) var<uniform> composeUniforms: BloomComposeUniforms;
875
+
876
+ @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
877
+ let scene = textureSample(sceneTexture, sceneSampler, input.uv);
878
+ let bloom = textureSample(bloomTexture, bloomSampler, input.uv);
879
+ // Additive blending with intensity control
880
+ let result = scene.rgb + bloom.rgb * composeUniforms.intensity;
881
+ return vec4f(result, scene.a);
882
+ }
987
883
  `,
988
884
  });
989
885
  // Create uniform buffer for blur direction (minimum 32 bytes for WebGPU)
@@ -1162,10 +1058,11 @@ export class Engine {
1162
1058
  format: this.presentationFormat,
1163
1059
  usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1164
1060
  });
1165
- this.sceneRenderTextureView = this.sceneRenderTexture.createView();
1166
1061
  // Setup bloom textures and bind groups
1167
1062
  this.setupBloom(width, height);
1168
1063
  const depthTextureView = this.depthTexture.createView();
1064
+ // Cache the scene render texture view (only recreate on resize)
1065
+ this.sceneRenderTextureView = this.sceneRenderTexture.createView();
1169
1066
  // Render scene to texture instead of directly to canvas
1170
1067
  const colorAttachment = this.sampleCount > 1
1171
1068
  ? {
@@ -1472,6 +1369,22 @@ export class Engine {
1472
1369
  rotateBones(bones, rotations, durationMs) {
1473
1370
  this.currentModel?.rotateBones(bones, rotations, durationMs);
1474
1371
  }
1372
+ setMorphWeight(name, weight, durationMs) {
1373
+ if (!this.currentModel)
1374
+ return;
1375
+ this.currentModel.setMorphWeight(name, weight, durationMs);
1376
+ if (!durationMs || durationMs === 0) {
1377
+ this.vertexBufferNeedsUpdate = true;
1378
+ }
1379
+ }
1380
+ updateVertexBuffer() {
1381
+ if (!this.currentModel || !this.vertexBuffer)
1382
+ return;
1383
+ const vertices = this.currentModel.getVertices();
1384
+ if (!vertices || vertices.length === 0)
1385
+ return;
1386
+ this.device.queue.writeBuffer(this.vertexBuffer, 0, vertices);
1387
+ }
1475
1388
  // Step 7: Create vertex, index, and joint buffers
1476
1389
  async setupModelBuffers(model) {
1477
1390
  this.currentModel = model;
@@ -1562,38 +1475,6 @@ export class Engine {
1562
1475
  const texture = await this.createTextureFromPath(path);
1563
1476
  return texture;
1564
1477
  };
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
1478
  this.opaqueDraws = [];
1598
1479
  this.eyeDraws = [];
1599
1480
  this.hairDrawsOverEyes = [];
@@ -1611,10 +1492,8 @@ export class Engine {
1611
1492
  const diffuseTexture = await loadTextureByIndex(mat.diffuseTextureIndex);
1612
1493
  if (!diffuseTexture)
1613
1494
  throw new Error(`Material "${mat.name}" has no diffuse texture`);
1614
- const toonTexture = await loadToonTexture(mat.toonTextureIndex);
1615
1495
  const materialAlpha = mat.diffuse[3];
1616
- const EPSILON = 0.001;
1617
- const isTransparent = materialAlpha < 1.0 - EPSILON;
1496
+ const isTransparent = materialAlpha < 1.0 - Engine.TRANSPARENCY_EPSILON;
1618
1497
  // Create material uniform data
1619
1498
  const materialUniformData = new Float32Array(8);
1620
1499
  materialUniformData[0] = materialAlpha;
@@ -1641,18 +1520,17 @@ export class Engine {
1641
1520
  { binding: 2, resource: diffuseTexture.createView() },
1642
1521
  { binding: 3, resource: this.materialSampler },
1643
1522
  { binding: 4, resource: { buffer: this.skinMatrixBuffer } },
1644
- { binding: 5, resource: toonTexture.createView() },
1645
- { binding: 6, resource: this.materialSampler },
1646
- { binding: 7, resource: { buffer: materialUniformBuffer } },
1523
+ { binding: 5, resource: { buffer: materialUniformBuffer } },
1647
1524
  ],
1648
1525
  });
1649
1526
  if (mat.isEye) {
1650
- this.eyeDraws.push({
1651
- count: indexCount,
1652
- firstIndex: currentIndexOffset,
1653
- bindGroup,
1654
- isTransparent,
1655
- });
1527
+ if (indexCount > 0) {
1528
+ this.eyeDraws.push({
1529
+ count: indexCount,
1530
+ firstIndex: currentIndexOffset,
1531
+ bindGroup,
1532
+ });
1533
+ }
1656
1534
  }
1657
1535
  else if (mat.isHair) {
1658
1536
  // Hair materials: create separate bind groups for over-eyes vs over-non-eyes
@@ -1681,42 +1559,42 @@ export class Engine {
1681
1559
  { binding: 2, resource: diffuseTexture.createView() },
1682
1560
  { binding: 3, resource: this.materialSampler },
1683
1561
  { binding: 4, resource: { buffer: this.skinMatrixBuffer } },
1684
- { binding: 5, resource: toonTexture.createView() },
1685
- { binding: 6, resource: this.materialSampler },
1686
- { binding: 7, resource: { buffer: buffer } },
1562
+ { binding: 5, resource: { buffer: buffer } },
1687
1563
  ],
1688
1564
  });
1689
1565
  };
1690
1566
  const bindGroupOverEyes = createHairBindGroup(true);
1691
1567
  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
- });
1568
+ if (indexCount > 0) {
1569
+ this.hairDrawsOverEyes.push({
1570
+ count: indexCount,
1571
+ firstIndex: currentIndexOffset,
1572
+ bindGroup: bindGroupOverEyes,
1573
+ });
1574
+ this.hairDrawsOverNonEyes.push({
1575
+ count: indexCount,
1576
+ firstIndex: currentIndexOffset,
1577
+ bindGroup: bindGroupOverNonEyes,
1578
+ });
1579
+ }
1704
1580
  }
1705
1581
  else if (isTransparent) {
1706
- this.transparentDraws.push({
1707
- count: indexCount,
1708
- firstIndex: currentIndexOffset,
1709
- bindGroup,
1710
- isTransparent,
1711
- });
1582
+ if (indexCount > 0) {
1583
+ this.transparentDraws.push({
1584
+ count: indexCount,
1585
+ firstIndex: currentIndexOffset,
1586
+ bindGroup,
1587
+ });
1588
+ }
1712
1589
  }
1713
1590
  else {
1714
- this.opaqueDraws.push({
1715
- count: indexCount,
1716
- firstIndex: currentIndexOffset,
1717
- bindGroup,
1718
- isTransparent,
1719
- });
1591
+ if (indexCount > 0) {
1592
+ this.opaqueDraws.push({
1593
+ count: indexCount,
1594
+ firstIndex: currentIndexOffset,
1595
+ bindGroup,
1596
+ });
1597
+ }
1720
1598
  }
1721
1599
  // Edge flag is at bit 4 (0x10) in PMX format
1722
1600
  if ((mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
@@ -1744,37 +1622,35 @@ export class Engine {
1744
1622
  { binding: 2, resource: { buffer: this.skinMatrixBuffer } },
1745
1623
  ],
1746
1624
  });
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
- });
1625
+ if (indexCount > 0) {
1626
+ if (mat.isEye) {
1627
+ this.eyeOutlineDraws.push({
1628
+ count: indexCount,
1629
+ firstIndex: currentIndexOffset,
1630
+ bindGroup: outlineBindGroup,
1631
+ });
1632
+ }
1633
+ else if (mat.isHair) {
1634
+ this.hairOutlineDraws.push({
1635
+ count: indexCount,
1636
+ firstIndex: currentIndexOffset,
1637
+ bindGroup: outlineBindGroup,
1638
+ });
1639
+ }
1640
+ else if (isTransparent) {
1641
+ this.transparentOutlineDraws.push({
1642
+ count: indexCount,
1643
+ firstIndex: currentIndexOffset,
1644
+ bindGroup: outlineBindGroup,
1645
+ });
1646
+ }
1647
+ else {
1648
+ this.opaqueOutlineDraws.push({
1649
+ count: indexCount,
1650
+ firstIndex: currentIndexOffset,
1651
+ bindGroup: outlineBindGroup,
1652
+ });
1653
+ }
1778
1654
  }
1779
1655
  }
1780
1656
  currentIndexOffset += indexCount;
@@ -1811,6 +1687,56 @@ export class Engine {
1811
1687
  return null;
1812
1688
  }
1813
1689
  }
1690
+ // Helper: Render eyes with stencil writing (for post-alpha-eye effect)
1691
+ renderEyes(pass) {
1692
+ pass.setPipeline(this.eyePipeline);
1693
+ pass.setStencilReference(this.STENCIL_EYE_VALUE);
1694
+ for (const draw of this.eyeDraws) {
1695
+ pass.setBindGroup(0, draw.bindGroup);
1696
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1697
+ }
1698
+ }
1699
+ // Helper: Render hair with post-alpha-eye effect (depth pre-pass + stencil-based shading + outlines)
1700
+ renderHair(pass) {
1701
+ // Hair depth pre-pass (reduces overdraw via early depth rejection)
1702
+ const hasHair = this.hairDrawsOverEyes.length > 0 || this.hairDrawsOverNonEyes.length > 0;
1703
+ if (hasHair) {
1704
+ pass.setPipeline(this.hairDepthPipeline);
1705
+ for (const draw of this.hairDrawsOverEyes) {
1706
+ pass.setBindGroup(0, draw.bindGroup);
1707
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1708
+ }
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 shading (split by stencil for transparency over eyes)
1715
+ if (this.hairDrawsOverEyes.length > 0) {
1716
+ pass.setPipeline(this.hairPipelineOverEyes);
1717
+ pass.setStencilReference(this.STENCIL_EYE_VALUE);
1718
+ for (const draw of this.hairDrawsOverEyes) {
1719
+ pass.setBindGroup(0, draw.bindGroup);
1720
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1721
+ }
1722
+ }
1723
+ if (this.hairDrawsOverNonEyes.length > 0) {
1724
+ pass.setPipeline(this.hairPipelineOverNonEyes);
1725
+ pass.setStencilReference(this.STENCIL_EYE_VALUE);
1726
+ for (const draw of this.hairDrawsOverNonEyes) {
1727
+ pass.setBindGroup(0, draw.bindGroup);
1728
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1729
+ }
1730
+ }
1731
+ // Hair outlines
1732
+ if (this.hairOutlineDraws.length > 0) {
1733
+ pass.setPipeline(this.hairOutlinePipeline);
1734
+ for (const draw of this.hairOutlineDraws) {
1735
+ pass.setBindGroup(0, draw.bindGroup);
1736
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1737
+ }
1738
+ }
1739
+ }
1814
1740
  // Render strategy: 1) Opaque non-eye/hair 2) Eyes (stencil=1) 3) Hair (depth pre-pass + split by stencil) 4) Transparent 5) Bloom
1815
1741
  render() {
1816
1742
  if (this.multisampleTexture && this.camera && this.device) {
@@ -1819,6 +1745,19 @@ export class Engine {
1819
1745
  this.lastFrameTime = currentTime;
1820
1746
  this.updateCameraUniforms();
1821
1747
  this.updateRenderTarget();
1748
+ // Update model pose first (this may update morph weights via tweens)
1749
+ // We need to do this before creating the encoder to ensure vertex buffer is ready
1750
+ if (this.currentModel) {
1751
+ const hasActiveMorphTweens = this.currentModel.evaluatePose();
1752
+ if (hasActiveMorphTweens) {
1753
+ this.vertexBufferNeedsUpdate = true;
1754
+ }
1755
+ }
1756
+ // Update vertex buffer if morphs changed
1757
+ if (this.vertexBufferNeedsUpdate) {
1758
+ this.updateVertexBuffer();
1759
+ this.vertexBufferNeedsUpdate = false;
1760
+ }
1822
1761
  // Use single encoder for both compute and render (reduces sync points)
1823
1762
  const encoder = this.device.createCommandEncoder();
1824
1763
  this.updateModelPose(deltaTime, encoder);
@@ -1830,7 +1769,6 @@ export class Engine {
1830
1769
  return;
1831
1770
  }
1832
1771
  const pass = encoder.beginRenderPass(this.renderPassDescriptor);
1833
- this.drawCallCount = 0;
1834
1772
  if (this.currentModel) {
1835
1773
  pass.setVertexBuffer(0, this.vertexBuffer);
1836
1774
  pass.setVertexBuffer(1, this.jointsBuffer);
@@ -1839,81 +1777,19 @@ export class Engine {
1839
1777
  // Pass 1: Opaque
1840
1778
  pass.setPipeline(this.modelPipeline);
1841
1779
  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
- }
1780
+ pass.setBindGroup(0, draw.bindGroup);
1781
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1847
1782
  }
1848
1783
  // 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)
1784
+ this.renderEyes(pass);
1859
1785
  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
- }
1786
+ // Pass 3: Hair rendering (depth pre-pass + shading + outlines)
1787
+ this.renderHair(pass);
1909
1788
  // Pass 4: Transparent
1910
1789
  pass.setPipeline(this.modelPipeline);
1911
1790
  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
- }
1791
+ pass.setBindGroup(0, draw.bindGroup);
1792
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1917
1793
  }
1918
1794
  this.drawOutlines(pass, true);
1919
1795
  }
@@ -2021,6 +1897,7 @@ export class Engine {
2021
1897
  this.device.queue.writeBuffer(this.cameraUniformBuffer, 0, this.cameraMatrixData);
2022
1898
  }
2023
1899
  updateRenderTarget() {
1900
+ // Use cached view (only recreated on resize in handleResize)
2024
1901
  const colorAttachment = this.renderPassDescriptor.colorAttachments[0];
2025
1902
  if (this.sampleCount > 1) {
2026
1903
  colorAttachment.resolveTarget = this.sceneRenderTextureView;
@@ -2030,7 +1907,8 @@ export class Engine {
2030
1907
  }
2031
1908
  }
2032
1909
  updateModelPose(deltaTime, encoder) {
2033
- this.currentModel.evaluatePose();
1910
+ // Note: evaluatePose is called earlier in render() to update vertex buffer before encoder creation
1911
+ // Here we just get the matrices and update physics/compute
2034
1912
  const worldMats = this.currentModel.getBoneWorldMatrices();
2035
1913
  if (this.physics) {
2036
1914
  this.physics.step(deltaTime, worldMats, this.currentModel.getBoneInverseBindMatrices());
@@ -2049,40 +1927,44 @@ export class Engine {
2049
1927
  }
2050
1928
  drawOutlines(pass, transparent) {
2051
1929
  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
- }
1930
+ const draws = transparent ? this.transparentOutlineDraws : this.opaqueOutlineDraws;
1931
+ for (const draw of draws) {
1932
+ pass.setBindGroup(0, draw.bindGroup);
1933
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
2067
1934
  }
2068
1935
  }
2069
1936
  updateStats(frameTime) {
1937
+ // Simplified frame time tracking - rolling average with fixed window
2070
1938
  const maxSamples = 60;
2071
- this.frameTimeSamples.push(frameTime);
2072
1939
  this.frameTimeSum += frameTime;
2073
- if (this.frameTimeSamples.length > maxSamples) {
2074
- const removed = this.frameTimeSamples.shift();
2075
- this.frameTimeSum -= removed;
1940
+ this.frameTimeCount++;
1941
+ if (this.frameTimeCount > maxSamples) {
1942
+ // Maintain rolling window by subtracting oldest sample estimate
1943
+ const avg = this.frameTimeSum / maxSamples;
1944
+ this.frameTimeSum -= avg;
1945
+ this.frameTimeCount = maxSamples;
2076
1946
  }
2077
- const avgFrameTime = this.frameTimeSum / this.frameTimeSamples.length;
2078
- this.stats.frameTime = Math.round(avgFrameTime * 100) / 100;
1947
+ this.stats.frameTime =
1948
+ Math.round((this.frameTimeSum / this.frameTimeCount) * Engine.STATS_FRAME_TIME_ROUNDING) /
1949
+ Engine.STATS_FRAME_TIME_ROUNDING;
1950
+ // FPS tracking
2079
1951
  const now = performance.now();
2080
1952
  this.framesSinceLastUpdate++;
2081
1953
  const elapsed = now - this.lastFpsUpdate;
2082
- if (elapsed >= 1000) {
2083
- this.stats.fps = Math.round((this.framesSinceLastUpdate / elapsed) * 1000);
1954
+ if (elapsed >= Engine.STATS_FPS_UPDATE_INTERVAL_MS) {
1955
+ this.stats.fps = Math.round((this.framesSinceLastUpdate / elapsed) * Engine.STATS_FPS_UPDATE_INTERVAL_MS);
2084
1956
  this.framesSinceLastUpdate = 0;
2085
1957
  this.lastFpsUpdate = now;
2086
1958
  }
2087
1959
  }
2088
1960
  }
1961
+ // Default values
1962
+ Engine.DEFAULT_BLOOM_THRESHOLD = 0.01;
1963
+ Engine.DEFAULT_BLOOM_INTENSITY = 0.12;
1964
+ Engine.DEFAULT_RIM_LIGHT_INTENSITY = 0.45;
1965
+ Engine.DEFAULT_CAMERA_DISTANCE = 26.6;
1966
+ Engine.DEFAULT_CAMERA_TARGET = new Vec3(0, 12.5, 0);
1967
+ Engine.HAIR_OVER_EYES_ALPHA = 0.5;
1968
+ Engine.TRANSPARENCY_EPSILON = 0.001;
1969
+ Engine.STATS_FPS_UPDATE_INTERVAL_MS = 1000;
1970
+ Engine.STATS_FRAME_TIME_ROUNDING = 100;