reze-engine 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/engine.js CHANGED
@@ -24,20 +24,18 @@ export class Engine {
24
24
  this.stats = {
25
25
  fps: 0,
26
26
  frameTime: 0,
27
- memoryUsed: 0,
28
- vertices: 0,
29
- drawCalls: 0,
30
- triangles: 0,
31
- materials: 0,
32
- textures: 0,
33
- textureMemory: 0,
34
- bufferMemory: 0,
35
27
  gpuMemory: 0,
36
28
  };
37
29
  this.animationFrameId = null;
38
30
  this.renderLoopCallback = null;
39
- this.materialDraws = [];
40
- this.outlineDraws = [];
31
+ this.opaqueNonEyeNonHairDraws = [];
32
+ this.eyeDraws = [];
33
+ this.hairDraws = [];
34
+ this.transparentNonEyeNonHairDraws = [];
35
+ this.opaqueNonEyeNonHairOutlineDraws = [];
36
+ this.eyeOutlineDraws = [];
37
+ this.hairOutlineDraws = [];
38
+ this.transparentNonEyeNonHairOutlineDraws = [];
41
39
  this.canvas = canvas;
42
40
  }
43
41
  // Step 1: Get WebGPU device and context
@@ -74,118 +72,251 @@ export class Engine {
74
72
  });
75
73
  const shaderModule = this.device.createShaderModule({
76
74
  label: "model shaders",
77
- code: /* wgsl */ `
78
- struct CameraUniforms {
79
- view: mat4x4f,
80
- projection: mat4x4f,
81
- viewPos: vec3f,
82
- _padding: f32,
83
- };
84
-
85
- struct Light {
86
- direction: vec3f,
87
- _padding1: f32,
88
- color: vec3f,
89
- intensity: f32,
90
- };
91
-
92
- struct LightUniforms {
93
- ambient: f32,
94
- lightCount: f32,
95
- _padding1: f32,
96
- _padding2: f32,
97
- lights: array<Light, 4>,
98
- };
99
-
100
- struct MaterialUniforms {
101
- alpha: f32,
102
- _padding1: f32,
103
- _padding2: f32,
104
- _padding3: f32,
105
- };
106
-
107
- struct VertexOutput {
108
- @builtin(position) position: vec4f,
109
- @location(0) normal: vec3f,
110
- @location(1) uv: vec2f,
111
- @location(2) worldPos: vec3f,
112
- };
113
-
114
- @group(0) @binding(0) var<uniform> camera: CameraUniforms;
115
- @group(0) @binding(1) var<uniform> light: LightUniforms;
116
- @group(0) @binding(2) var diffuseTexture: texture_2d<f32>;
117
- @group(0) @binding(3) var diffuseSampler: sampler;
118
- @group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
119
- @group(0) @binding(5) var toonTexture: texture_2d<f32>;
120
- @group(0) @binding(6) var toonSampler: sampler;
121
- @group(0) @binding(7) var<uniform> material: MaterialUniforms;
122
-
123
- @vertex fn vs(
124
- @location(0) position: vec3f,
125
- @location(1) normal: vec3f,
126
- @location(2) uv: vec2f,
127
- @location(3) joints0: vec4<u32>,
128
- @location(4) weights0: vec4<f32>
129
- ) -> VertexOutput {
130
- var output: VertexOutput;
131
- let pos4 = vec4f(position, 1.0);
132
-
133
- // Normalize weights to ensure they sum to 1.0 (handles floating-point precision issues)
134
- let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
135
- var normalizedWeights: vec4f;
136
- if (weightSum > 0.0001) {
137
- normalizedWeights = weights0 / weightSum;
138
- } else {
139
- normalizedWeights = vec4f(1.0, 0.0, 0.0, 0.0);
140
- }
141
-
142
- var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
143
- var skinnedNrm = vec3f(0.0, 0.0, 0.0);
144
- for (var i = 0u; i < 4u; i++) {
145
- let j = joints0[i];
146
- let w = normalizedWeights[i];
147
- let m = skinMats[j];
148
- skinnedPos += (m * pos4) * w;
149
- let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
150
- skinnedNrm += (r3 * normal) * w;
151
- }
152
- let worldPos = skinnedPos.xyz;
153
- output.position = camera.projection * camera.view * vec4f(worldPos, 1.0);
154
- output.normal = normalize(skinnedNrm);
155
- output.uv = uv;
156
- output.worldPos = worldPos;
157
- return output;
158
- }
159
-
160
- @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
161
- let n = normalize(input.normal);
162
- let albedo = textureSample(diffuseTexture, diffuseSampler, input.uv).rgb;
163
-
164
- var lightAccum = vec3f(light.ambient);
165
- let numLights = u32(light.lightCount);
166
- for (var i = 0u; i < numLights; i++) {
167
- let l = -light.lights[i].direction;
168
- let nDotL = max(dot(n, l), 0.0);
169
- let toonUV = vec2f(nDotL, 0.5);
170
- let toonFactor = textureSample(toonTexture, toonSampler, toonUV).rgb;
171
- let radiance = light.lights[i].color * light.lights[i].intensity;
172
- lightAccum += toonFactor * radiance * nDotL;
173
- }
174
-
175
- let color = albedo * lightAccum;
176
- let finalAlpha = material.alpha;
177
- if (finalAlpha < 0.001) {
178
- discard;
179
- }
180
-
181
- return vec4f(clamp(color, vec3f(0.0), vec3f(1.0)), finalAlpha);
182
- }
75
+ code: /* wgsl */ `
76
+ struct CameraUniforms {
77
+ view: mat4x4f,
78
+ projection: mat4x4f,
79
+ viewPos: vec3f,
80
+ _padding: f32,
81
+ };
82
+
83
+ struct Light {
84
+ direction: vec3f,
85
+ _padding1: f32,
86
+ color: vec3f,
87
+ intensity: f32,
88
+ };
89
+
90
+ struct LightUniforms {
91
+ ambient: f32,
92
+ lightCount: f32,
93
+ _padding1: f32,
94
+ _padding2: f32,
95
+ lights: array<Light, 4>,
96
+ };
97
+
98
+ struct MaterialUniforms {
99
+ alpha: f32,
100
+ _padding1: f32,
101
+ _padding2: f32,
102
+ _padding3: f32,
103
+ };
104
+
105
+ struct VertexOutput {
106
+ @builtin(position) position: vec4f,
107
+ @location(0) normal: vec3f,
108
+ @location(1) uv: vec2f,
109
+ @location(2) worldPos: vec3f,
110
+ };
111
+
112
+ @group(0) @binding(0) var<uniform> camera: CameraUniforms;
113
+ @group(0) @binding(1) var<uniform> light: LightUniforms;
114
+ @group(0) @binding(2) var diffuseTexture: texture_2d<f32>;
115
+ @group(0) @binding(3) var diffuseSampler: sampler;
116
+ @group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
117
+ @group(0) @binding(5) var toonTexture: texture_2d<f32>;
118
+ @group(0) @binding(6) var toonSampler: sampler;
119
+ @group(0) @binding(7) var<uniform> material: MaterialUniforms;
120
+
121
+ @vertex fn vs(
122
+ @location(0) position: vec3f,
123
+ @location(1) normal: vec3f,
124
+ @location(2) uv: vec2f,
125
+ @location(3) joints0: vec4<u32>,
126
+ @location(4) weights0: vec4<f32>
127
+ ) -> VertexOutput {
128
+ var output: VertexOutput;
129
+ let pos4 = vec4f(position, 1.0);
130
+
131
+ // Normalize weights to ensure they sum to 1.0 (handles floating-point precision issues)
132
+ let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
133
+ var normalizedWeights: vec4f;
134
+ if (weightSum > 0.0001) {
135
+ normalizedWeights = weights0 / weightSum;
136
+ } else {
137
+ normalizedWeights = vec4f(1.0, 0.0, 0.0, 0.0);
138
+ }
139
+
140
+ var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
141
+ var skinnedNrm = vec3f(0.0, 0.0, 0.0);
142
+ for (var i = 0u; i < 4u; i++) {
143
+ let j = joints0[i];
144
+ let w = normalizedWeights[i];
145
+ let m = skinMats[j];
146
+ skinnedPos += (m * pos4) * w;
147
+ let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
148
+ skinnedNrm += (r3 * normal) * w;
149
+ }
150
+ let worldPos = skinnedPos.xyz;
151
+ output.position = camera.projection * camera.view * vec4f(worldPos, 1.0);
152
+ output.normal = normalize(skinnedNrm);
153
+ output.uv = uv;
154
+ output.worldPos = worldPos;
155
+ return output;
156
+ }
157
+
158
+ @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
159
+ let n = normalize(input.normal);
160
+ let albedo = textureSample(diffuseTexture, diffuseSampler, input.uv).rgb;
161
+
162
+ var lightAccum = vec3f(light.ambient);
163
+ let numLights = u32(light.lightCount);
164
+ for (var i = 0u; i < numLights; i++) {
165
+ let l = -light.lights[i].direction;
166
+ let nDotL = max(dot(n, l), 0.0);
167
+ let toonUV = vec2f(nDotL, 0.5);
168
+ let toonFactor = textureSample(toonTexture, toonSampler, toonUV).rgb;
169
+ let radiance = light.lights[i].color * light.lights[i].intensity;
170
+ lightAccum += toonFactor * radiance * nDotL;
171
+ }
172
+
173
+ let color = albedo * lightAccum;
174
+ let finalAlpha = material.alpha;
175
+ if (finalAlpha < 0.001) {
176
+ discard;
177
+ }
178
+
179
+ return vec4f(clamp(color, vec3f(0.0), vec3f(1.0)), finalAlpha);
180
+ }
183
181
  `,
184
182
  });
183
+ // Create a separate shader for hair-over-eyes that outputs pre-multiplied color for darkening effect
184
+ const hairMultiplyShaderModule = this.device.createShaderModule({
185
+ label: "hair multiply shaders",
186
+ code: /* wgsl */ `
187
+ struct CameraUniforms {
188
+ view: mat4x4f,
189
+ projection: mat4x4f,
190
+ viewPos: vec3f,
191
+ _padding: f32,
192
+ };
193
+
194
+ struct Light {
195
+ direction: vec3f,
196
+ _padding1: f32,
197
+ color: vec3f,
198
+ intensity: f32,
199
+ };
200
+
201
+ struct LightUniforms {
202
+ ambient: f32,
203
+ lightCount: f32,
204
+ _padding1: f32,
205
+ _padding2: f32,
206
+ lights: array<Light, 4>,
207
+ };
208
+
209
+ struct MaterialUniforms {
210
+ alpha: f32,
211
+ _padding1: f32,
212
+ _padding2: f32,
213
+ _padding3: f32,
214
+ };
215
+
216
+ struct VertexOutput {
217
+ @builtin(position) position: vec4f,
218
+ @location(0) normal: vec3f,
219
+ @location(1) uv: vec2f,
220
+ @location(2) worldPos: vec3f,
221
+ };
222
+
223
+ @group(0) @binding(0) var<uniform> camera: CameraUniforms;
224
+ @group(0) @binding(1) var<uniform> light: LightUniforms;
225
+ @group(0) @binding(2) var diffuseTexture: texture_2d<f32>;
226
+ @group(0) @binding(3) var diffuseSampler: sampler;
227
+ @group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
228
+ @group(0) @binding(5) var toonTexture: texture_2d<f32>;
229
+ @group(0) @binding(6) var toonSampler: sampler;
230
+ @group(0) @binding(7) var<uniform> material: MaterialUniforms;
231
+
232
+ @vertex fn vs(
233
+ @location(0) position: vec3f,
234
+ @location(1) normal: vec3f,
235
+ @location(2) uv: vec2f,
236
+ @location(3) joints0: vec4<u32>,
237
+ @location(4) weights0: vec4<f32>
238
+ ) -> VertexOutput {
239
+ var output: VertexOutput;
240
+ let pos4 = vec4f(position, 1.0);
241
+
242
+ let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
243
+ var normalizedWeights: vec4f;
244
+ if (weightSum > 0.0001) {
245
+ normalizedWeights = weights0 / weightSum;
246
+ } else {
247
+ normalizedWeights = vec4f(1.0, 0.0, 0.0, 0.0);
248
+ }
249
+
250
+ var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
251
+ var skinnedNrm = vec3f(0.0, 0.0, 0.0);
252
+ for (var i = 0u; i < 4u; i++) {
253
+ let j = joints0[i];
254
+ let w = normalizedWeights[i];
255
+ let m = skinMats[j];
256
+ skinnedPos += (m * pos4) * w;
257
+ let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
258
+ skinnedNrm += (r3 * normal) * w;
259
+ }
260
+ let worldPos = skinnedPos.xyz;
261
+ output.position = camera.projection * camera.view * vec4f(worldPos, 1.0);
262
+ output.normal = normalize(skinnedNrm);
263
+ output.uv = uv;
264
+ output.worldPos = worldPos;
265
+ return output;
266
+ }
267
+
268
+ @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
269
+ let n = normalize(input.normal);
270
+ let albedo = textureSample(diffuseTexture, diffuseSampler, input.uv).rgb;
271
+
272
+ var lightAccum = vec3f(light.ambient);
273
+ let numLights = u32(light.lightCount);
274
+ for (var i = 0u; i < numLights; i++) {
275
+ let l = -light.lights[i].direction;
276
+ let nDotL = max(dot(n, l), 0.0);
277
+ let toonUV = vec2f(nDotL, 0.5);
278
+ let toonFactor = textureSample(toonTexture, toonSampler, toonUV).rgb;
279
+ let radiance = light.lights[i].color * light.lights[i].intensity;
280
+ lightAccum += toonFactor * radiance * nDotL;
281
+ }
282
+
283
+ let color = albedo * lightAccum;
284
+ let finalAlpha = material.alpha;
285
+ if (finalAlpha < 0.001) {
286
+ discard;
287
+ }
288
+
289
+ // For hair-over-eyes effect: simple half-transparent overlay
290
+ // Use 60% opacity to create a semi-transparent hair color overlay
291
+ let overlayAlpha = finalAlpha * 0.6;
292
+
293
+ return vec4f(clamp(color, vec3f(0.0), vec3f(1.0)), overlayAlpha);
294
+ }
295
+ `,
296
+ });
297
+ // Create explicit bind group layout for all pipelines using the main shader
298
+ // This ensures compatibility across all pipelines (main, eye, hair multiply, hair opaque)
299
+ this.hairBindGroupLayout = this.device.createBindGroupLayout({
300
+ label: "shared material bind group layout",
301
+ entries: [
302
+ { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // camera
303
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // light
304
+ { binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: {} }, // diffuseTexture
305
+ { binding: 3, visibility: GPUShaderStage.FRAGMENT, sampler: {} }, // diffuseSampler
306
+ { binding: 4, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } }, // skinMats
307
+ { binding: 5, visibility: GPUShaderStage.FRAGMENT, texture: {} }, // toonTexture
308
+ { binding: 6, visibility: GPUShaderStage.FRAGMENT, sampler: {} }, // toonSampler
309
+ { binding: 7, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // material
310
+ ],
311
+ });
312
+ const sharedPipelineLayout = this.device.createPipelineLayout({
313
+ label: "shared pipeline layout",
314
+ bindGroupLayouts: [this.hairBindGroupLayout],
315
+ });
185
316
  // Single pipeline for all materials with alpha blending
186
317
  this.pipeline = this.device.createRenderPipeline({
187
318
  label: "model pipeline",
188
- layout: "auto",
319
+ layout: sharedPipelineLayout,
189
320
  vertex: {
190
321
  module: shaderModule,
191
322
  buffers: [
@@ -229,7 +360,7 @@ export class Engine {
229
360
  },
230
361
  primitive: { cullMode: "none" },
231
362
  depthStencil: {
232
- format: "depth24plus",
363
+ format: "depth24plus-stencil8",
233
364
  depthWriteEnabled: true,
234
365
  depthCompare: "less",
235
366
  },
@@ -237,79 +368,92 @@ export class Engine {
237
368
  count: this.sampleCount,
238
369
  },
239
370
  });
371
+ // Create bind group layout for outline pipelines
372
+ this.outlineBindGroupLayout = this.device.createBindGroupLayout({
373
+ label: "outline bind group layout",
374
+ entries: [
375
+ { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // camera
376
+ { binding: 1, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // material
377
+ { binding: 2, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } }, // skinMats
378
+ ],
379
+ });
380
+ const outlinePipelineLayout = this.device.createPipelineLayout({
381
+ label: "outline pipeline layout",
382
+ bindGroupLayouts: [this.outlineBindGroupLayout],
383
+ });
240
384
  const outlineShaderModule = this.device.createShaderModule({
241
385
  label: "outline shaders",
242
- code: /* wgsl */ `
243
- struct CameraUniforms {
244
- view: mat4x4f,
245
- projection: mat4x4f,
246
- viewPos: vec3f,
247
- _padding: f32,
248
- };
249
-
250
- struct MaterialUniforms {
251
- edgeColor: vec4f,
252
- edgeSize: f32,
253
- _padding1: f32,
254
- _padding2: f32,
255
- _padding3: f32,
256
- };
257
-
258
- @group(0) @binding(0) var<uniform> camera: CameraUniforms;
259
- @group(0) @binding(1) var<uniform> material: MaterialUniforms;
260
- @group(0) @binding(2) var<storage, read> skinMats: array<mat4x4f>;
261
-
262
- struct VertexOutput {
263
- @builtin(position) position: vec4f,
264
- };
265
-
266
- @vertex fn vs(
267
- @location(0) position: vec3f,
268
- @location(1) normal: vec3f,
269
- @location(2) uv: vec2f,
270
- @location(3) joints0: vec4<u32>,
271
- @location(4) weights0: vec4<f32>
272
- ) -> VertexOutput {
273
- var output: VertexOutput;
274
- let pos4 = vec4f(position, 1.0);
275
-
276
- // Normalize weights to ensure they sum to 1.0 (handles floating-point precision issues)
277
- let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
278
- var normalizedWeights: vec4f;
279
- if (weightSum > 0.0001) {
280
- normalizedWeights = weights0 / weightSum;
281
- } else {
282
- normalizedWeights = vec4f(1.0, 0.0, 0.0, 0.0);
283
- }
284
-
285
- var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
286
- var skinnedNrm = vec3f(0.0, 0.0, 0.0);
287
- for (var i = 0u; i < 4u; i++) {
288
- let j = joints0[i];
289
- let w = normalizedWeights[i];
290
- let m = skinMats[j];
291
- skinnedPos += (m * pos4) * w;
292
- let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
293
- skinnedNrm += (r3 * normal) * w;
294
- }
295
- let worldPos = skinnedPos.xyz;
296
- let worldNormal = normalize(skinnedNrm);
297
-
298
- // MMD invert hull: expand vertices outward along normals
299
- let scaleFactor = 0.01;
300
- let expandedPos = worldPos + worldNormal * material.edgeSize * scaleFactor;
301
- output.position = camera.projection * camera.view * vec4f(expandedPos, 1.0);
302
- return output;
303
- }
304
-
305
- @fragment fn fs() -> @location(0) vec4f {
306
- return material.edgeColor;
307
- }
386
+ code: /* wgsl */ `
387
+ struct CameraUniforms {
388
+ view: mat4x4f,
389
+ projection: mat4x4f,
390
+ viewPos: vec3f,
391
+ _padding: f32,
392
+ };
393
+
394
+ struct MaterialUniforms {
395
+ edgeColor: vec4f,
396
+ edgeSize: f32,
397
+ _padding1: f32,
398
+ _padding2: f32,
399
+ _padding3: f32,
400
+ };
401
+
402
+ @group(0) @binding(0) var<uniform> camera: CameraUniforms;
403
+ @group(0) @binding(1) var<uniform> material: MaterialUniforms;
404
+ @group(0) @binding(2) var<storage, read> skinMats: array<mat4x4f>;
405
+
406
+ struct VertexOutput {
407
+ @builtin(position) position: vec4f,
408
+ };
409
+
410
+ @vertex fn vs(
411
+ @location(0) position: vec3f,
412
+ @location(1) normal: vec3f,
413
+ @location(2) uv: vec2f,
414
+ @location(3) joints0: vec4<u32>,
415
+ @location(4) weights0: vec4<f32>
416
+ ) -> VertexOutput {
417
+ var output: VertexOutput;
418
+ let pos4 = vec4f(position, 1.0);
419
+
420
+ // Normalize weights to ensure they sum to 1.0 (handles floating-point precision issues)
421
+ let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
422
+ var normalizedWeights: vec4f;
423
+ if (weightSum > 0.0001) {
424
+ normalizedWeights = weights0 / weightSum;
425
+ } else {
426
+ normalizedWeights = vec4f(1.0, 0.0, 0.0, 0.0);
427
+ }
428
+
429
+ var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
430
+ var skinnedNrm = vec3f(0.0, 0.0, 0.0);
431
+ for (var i = 0u; i < 4u; i++) {
432
+ let j = joints0[i];
433
+ let w = normalizedWeights[i];
434
+ let m = skinMats[j];
435
+ skinnedPos += (m * pos4) * w;
436
+ let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
437
+ skinnedNrm += (r3 * normal) * w;
438
+ }
439
+ let worldPos = skinnedPos.xyz;
440
+ let worldNormal = normalize(skinnedNrm);
441
+
442
+ // MMD invert hull: expand vertices outward along normals
443
+ let scaleFactor = 0.01;
444
+ let expandedPos = worldPos + worldNormal * material.edgeSize * scaleFactor;
445
+ output.position = camera.projection * camera.view * vec4f(expandedPos, 1.0);
446
+ return output;
447
+ }
448
+
449
+ @fragment fn fs() -> @location(0) vec4f {
450
+ return material.edgeColor;
451
+ }
308
452
  `,
309
453
  });
310
454
  this.outlinePipeline = this.device.createRenderPipeline({
311
455
  label: "outline pipeline",
312
- layout: "auto",
456
+ layout: outlinePipelineLayout,
313
457
  vertex: {
314
458
  module: outlineShaderModule,
315
459
  buffers: [
@@ -367,7 +511,7 @@ export class Engine {
367
511
  cullMode: "back",
368
512
  },
369
513
  depthStencil: {
370
- format: "depth24plus",
514
+ format: "depth24plus-stencil8",
371
515
  depthWriteEnabled: true,
372
516
  depthCompare: "less",
373
517
  },
@@ -375,36 +519,400 @@ export class Engine {
375
519
  count: this.sampleCount,
376
520
  },
377
521
  });
522
+ // Hair outline pipeline: draws hair outlines over non-eyes (stencil != 1)
523
+ // Drawn after hair geometry, so depth testing ensures outlines only appear where hair exists
524
+ this.hairOutlinePipeline = this.device.createRenderPipeline({
525
+ label: "hair outline pipeline",
526
+ layout: outlinePipelineLayout,
527
+ vertex: {
528
+ module: outlineShaderModule,
529
+ buffers: [
530
+ {
531
+ arrayStride: 8 * 4,
532
+ attributes: [
533
+ {
534
+ shaderLocation: 0,
535
+ offset: 0,
536
+ format: "float32x3",
537
+ },
538
+ {
539
+ shaderLocation: 1,
540
+ offset: 3 * 4,
541
+ format: "float32x3",
542
+ },
543
+ {
544
+ shaderLocation: 2,
545
+ offset: 6 * 4,
546
+ format: "float32x2",
547
+ },
548
+ ],
549
+ },
550
+ {
551
+ arrayStride: 4 * 2,
552
+ attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" }],
553
+ },
554
+ {
555
+ arrayStride: 4,
556
+ attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" }],
557
+ },
558
+ ],
559
+ },
560
+ fragment: {
561
+ module: outlineShaderModule,
562
+ targets: [
563
+ {
564
+ format: this.presentationFormat,
565
+ blend: {
566
+ color: {
567
+ srcFactor: "src-alpha",
568
+ dstFactor: "one-minus-src-alpha",
569
+ operation: "add",
570
+ },
571
+ alpha: {
572
+ srcFactor: "one",
573
+ dstFactor: "one-minus-src-alpha",
574
+ operation: "add",
575
+ },
576
+ },
577
+ },
578
+ ],
579
+ },
580
+ primitive: {
581
+ cullMode: "back",
582
+ },
583
+ depthStencil: {
584
+ format: "depth24plus-stencil8",
585
+ depthWriteEnabled: false, // Don't write depth - let hair geometry control depth
586
+ depthCompare: "less-equal", // Only draw where hair depth exists
587
+ stencilFront: {
588
+ compare: "not-equal", // Only render where stencil != 1 (not over eyes)
589
+ failOp: "keep",
590
+ depthFailOp: "keep",
591
+ passOp: "keep",
592
+ },
593
+ stencilBack: {
594
+ compare: "not-equal",
595
+ failOp: "keep",
596
+ depthFailOp: "keep",
597
+ passOp: "keep",
598
+ },
599
+ },
600
+ multisample: {
601
+ count: this.sampleCount,
602
+ },
603
+ });
604
+ // Hair outline pipeline for over eyes: draws where stencil == 1, but only where hair depth exists
605
+ // Uses depth compare "equal" with a small bias to only appear where hair geometry exists
606
+ this.hairOutlineOverEyesPipeline = this.device.createRenderPipeline({
607
+ label: "hair outline over eyes pipeline",
608
+ layout: outlinePipelineLayout,
609
+ vertex: {
610
+ module: outlineShaderModule,
611
+ buffers: [
612
+ {
613
+ arrayStride: 8 * 4,
614
+ attributes: [
615
+ {
616
+ shaderLocation: 0,
617
+ offset: 0,
618
+ format: "float32x3",
619
+ },
620
+ {
621
+ shaderLocation: 1,
622
+ offset: 3 * 4,
623
+ format: "float32x3",
624
+ },
625
+ {
626
+ shaderLocation: 2,
627
+ offset: 6 * 4,
628
+ format: "float32x2",
629
+ },
630
+ ],
631
+ },
632
+ {
633
+ arrayStride: 4 * 2,
634
+ attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" }],
635
+ },
636
+ {
637
+ arrayStride: 4,
638
+ attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" }],
639
+ },
640
+ ],
641
+ },
642
+ fragment: {
643
+ module: outlineShaderModule,
644
+ targets: [
645
+ {
646
+ format: this.presentationFormat,
647
+ blend: {
648
+ color: {
649
+ srcFactor: "src-alpha",
650
+ dstFactor: "one-minus-src-alpha",
651
+ operation: "add",
652
+ },
653
+ alpha: {
654
+ srcFactor: "one",
655
+ dstFactor: "one-minus-src-alpha",
656
+ operation: "add",
657
+ },
658
+ },
659
+ },
660
+ ],
661
+ },
662
+ primitive: {
663
+ cullMode: "back",
664
+ },
665
+ depthStencil: {
666
+ format: "depth24plus-stencil8",
667
+ depthWriteEnabled: false, // Don't write depth
668
+ depthCompare: "less-equal", // Draw where outline depth <= existing depth (hair depth)
669
+ depthBias: -0.0001, // Small negative bias to bring outline slightly closer for depth test
670
+ depthBiasSlopeScale: 0.0,
671
+ depthBiasClamp: 0.0,
672
+ stencilFront: {
673
+ compare: "equal", // Only render where stencil == 1 (over eyes)
674
+ failOp: "keep",
675
+ depthFailOp: "keep",
676
+ passOp: "keep",
677
+ },
678
+ stencilBack: {
679
+ compare: "equal",
680
+ failOp: "keep",
681
+ depthFailOp: "keep",
682
+ passOp: "keep",
683
+ },
684
+ },
685
+ multisample: {
686
+ count: this.sampleCount,
687
+ },
688
+ });
689
+ // Hair pipeline with multiplicative blending (for hair over eyes)
690
+ this.hairMultiplyPipeline = this.device.createRenderPipeline({
691
+ label: "hair multiply pipeline",
692
+ layout: sharedPipelineLayout,
693
+ vertex: {
694
+ module: hairMultiplyShaderModule,
695
+ buffers: [
696
+ {
697
+ arrayStride: 8 * 4,
698
+ attributes: [
699
+ { shaderLocation: 0, offset: 0, format: "float32x3" },
700
+ { shaderLocation: 1, offset: 3 * 4, format: "float32x3" },
701
+ { shaderLocation: 2, offset: 6 * 4, format: "float32x2" },
702
+ ],
703
+ },
704
+ {
705
+ arrayStride: 4 * 2,
706
+ attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" }],
707
+ },
708
+ {
709
+ arrayStride: 4,
710
+ attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" }],
711
+ },
712
+ ],
713
+ },
714
+ fragment: {
715
+ module: hairMultiplyShaderModule,
716
+ targets: [
717
+ {
718
+ format: this.presentationFormat,
719
+ blend: {
720
+ color: {
721
+ // Simple half-transparent overlay effect
722
+ // Blend: hairColor * overlayAlpha + eyeColor * (1 - overlayAlpha)
723
+ srcFactor: "src-alpha",
724
+ dstFactor: "one-minus-src-alpha",
725
+ operation: "add",
726
+ },
727
+ alpha: {
728
+ srcFactor: "one",
729
+ dstFactor: "one-minus-src-alpha",
730
+ operation: "add",
731
+ },
732
+ },
733
+ },
734
+ ],
735
+ },
736
+ primitive: { cullMode: "none" },
737
+ depthStencil: {
738
+ format: "depth24plus-stencil8",
739
+ depthWriteEnabled: true, // Write depth so outlines can test against it
740
+ depthCompare: "less",
741
+ stencilFront: {
742
+ compare: "equal", // Only render where stencil == 1
743
+ failOp: "keep",
744
+ depthFailOp: "keep",
745
+ passOp: "keep",
746
+ },
747
+ stencilBack: {
748
+ compare: "equal",
749
+ failOp: "keep",
750
+ depthFailOp: "keep",
751
+ passOp: "keep",
752
+ },
753
+ },
754
+ multisample: { count: this.sampleCount },
755
+ });
756
+ // Hair pipeline for opaque rendering (hair over non-eyes)
757
+ this.hairOpaquePipeline = this.device.createRenderPipeline({
758
+ label: "hair opaque pipeline",
759
+ layout: sharedPipelineLayout,
760
+ vertex: {
761
+ module: shaderModule,
762
+ buffers: [
763
+ {
764
+ arrayStride: 8 * 4,
765
+ attributes: [
766
+ { shaderLocation: 0, offset: 0, format: "float32x3" },
767
+ { shaderLocation: 1, offset: 3 * 4, format: "float32x3" },
768
+ { shaderLocation: 2, offset: 6 * 4, format: "float32x2" },
769
+ ],
770
+ },
771
+ {
772
+ arrayStride: 4 * 2,
773
+ attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" }],
774
+ },
775
+ {
776
+ arrayStride: 4,
777
+ attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" }],
778
+ },
779
+ ],
780
+ },
781
+ fragment: {
782
+ module: shaderModule,
783
+ targets: [
784
+ {
785
+ format: this.presentationFormat,
786
+ blend: {
787
+ color: {
788
+ srcFactor: "src-alpha",
789
+ dstFactor: "one-minus-src-alpha",
790
+ operation: "add",
791
+ },
792
+ alpha: {
793
+ srcFactor: "one",
794
+ dstFactor: "one-minus-src-alpha",
795
+ operation: "add",
796
+ },
797
+ },
798
+ },
799
+ ],
800
+ },
801
+ primitive: { cullMode: "none" },
802
+ depthStencil: {
803
+ format: "depth24plus-stencil8",
804
+ depthWriteEnabled: true,
805
+ depthCompare: "less",
806
+ stencilFront: {
807
+ compare: "not-equal", // Only render where stencil != 1
808
+ failOp: "keep",
809
+ depthFailOp: "keep",
810
+ passOp: "keep",
811
+ },
812
+ stencilBack: {
813
+ compare: "not-equal",
814
+ failOp: "keep",
815
+ depthFailOp: "keep",
816
+ passOp: "keep",
817
+ },
818
+ },
819
+ multisample: { count: this.sampleCount },
820
+ });
821
+ // Eye overlay pipeline (renders after opaque, writes stencil)
822
+ this.eyePipeline = this.device.createRenderPipeline({
823
+ label: "eye overlay pipeline",
824
+ layout: sharedPipelineLayout,
825
+ vertex: {
826
+ module: shaderModule,
827
+ buffers: [
828
+ {
829
+ arrayStride: 8 * 4,
830
+ attributes: [
831
+ { shaderLocation: 0, offset: 0, format: "float32x3" },
832
+ { shaderLocation: 1, offset: 3 * 4, format: "float32x3" },
833
+ { shaderLocation: 2, offset: 6 * 4, format: "float32x2" },
834
+ ],
835
+ },
836
+ {
837
+ arrayStride: 4 * 2,
838
+ attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" }],
839
+ },
840
+ {
841
+ arrayStride: 4,
842
+ attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" }],
843
+ },
844
+ ],
845
+ },
846
+ fragment: {
847
+ module: shaderModule,
848
+ targets: [
849
+ {
850
+ format: this.presentationFormat,
851
+ blend: {
852
+ color: {
853
+ srcFactor: "src-alpha",
854
+ dstFactor: "one-minus-src-alpha",
855
+ operation: "add",
856
+ },
857
+ alpha: {
858
+ srcFactor: "one",
859
+ dstFactor: "one-minus-src-alpha",
860
+ operation: "add",
861
+ },
862
+ },
863
+ },
864
+ ],
865
+ },
866
+ primitive: { cullMode: "none" },
867
+ depthStencil: {
868
+ format: "depth24plus-stencil8",
869
+ depthWriteEnabled: false, // Don't write depth
870
+ depthCompare: "less", // Respect existing depth
871
+ stencilFront: {
872
+ compare: "always",
873
+ failOp: "keep",
874
+ depthFailOp: "keep",
875
+ passOp: "replace", // Write stencil value 1
876
+ },
877
+ stencilBack: {
878
+ compare: "always",
879
+ failOp: "keep",
880
+ depthFailOp: "keep",
881
+ passOp: "replace",
882
+ },
883
+ },
884
+ multisample: { count: this.sampleCount },
885
+ });
378
886
  }
379
887
  // Create compute shader for skin matrix computation
380
888
  createSkinMatrixComputePipeline() {
381
889
  const computeShader = this.device.createShaderModule({
382
890
  label: "skin matrix compute",
383
- code: /* wgsl */ `
384
- struct BoneCountUniform {
385
- count: u32,
386
- _padding1: u32,
387
- _padding2: u32,
388
- _padding3: u32,
389
- _padding4: vec4<u32>,
390
- };
391
-
392
- @group(0) @binding(0) var<uniform> boneCount: BoneCountUniform;
393
- @group(0) @binding(1) var<storage, read> worldMatrices: array<mat4x4f>;
394
- @group(0) @binding(2) var<storage, read> inverseBindMatrices: array<mat4x4f>;
395
- @group(0) @binding(3) var<storage, read_write> skinMatrices: array<mat4x4f>;
396
-
397
- @compute @workgroup_size(64)
398
- fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
399
- let boneIndex = globalId.x;
400
- // Bounds check: we dispatch workgroups (64 threads each), so some threads may be out of range
401
- if (boneIndex >= boneCount.count) {
402
- return;
403
- }
404
- let worldMat = worldMatrices[boneIndex];
405
- let invBindMat = inverseBindMatrices[boneIndex];
406
- skinMatrices[boneIndex] = worldMat * invBindMat;
407
- }
891
+ code: /* wgsl */ `
892
+ struct BoneCountUniform {
893
+ count: u32,
894
+ _padding1: u32,
895
+ _padding2: u32,
896
+ _padding3: u32,
897
+ _padding4: vec4<u32>,
898
+ };
899
+
900
+ @group(0) @binding(0) var<uniform> boneCount: BoneCountUniform;
901
+ @group(0) @binding(1) var<storage, read> worldMatrices: array<mat4x4f>;
902
+ @group(0) @binding(2) var<storage, read> inverseBindMatrices: array<mat4x4f>;
903
+ @group(0) @binding(3) var<storage, read_write> skinMatrices: array<mat4x4f>;
904
+
905
+ @compute @workgroup_size(64)
906
+ fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
907
+ let boneIndex = globalId.x;
908
+ // Bounds check: we dispatch workgroups (64 threads each), so some threads may be out of range
909
+ if (boneIndex >= boneCount.count) {
910
+ return;
911
+ }
912
+ let worldMat = worldMatrices[boneIndex];
913
+ let invBindMat = inverseBindMatrices[boneIndex];
914
+ skinMatrices[boneIndex] = worldMat * invBindMat;
915
+ }
408
916
  `,
409
917
  });
410
918
  this.skinMatrixComputePipeline = this.device.createComputePipeline({
@@ -441,7 +949,7 @@ export class Engine {
441
949
  label: "depth texture",
442
950
  size: [width, height],
443
951
  sampleCount: this.sampleCount,
444
- format: "depth24plus",
952
+ format: "depth24plus-stencil8",
445
953
  usage: GPUTextureUsage.RENDER_ATTACHMENT,
446
954
  });
447
955
  const depthTextureView = this.depthTexture.createView();
@@ -467,6 +975,9 @@ export class Engine {
467
975
  depthClearValue: 1.0,
468
976
  depthLoadOp: "clear",
469
977
  depthStoreOp: "store",
978
+ stencilClearValue: 0, // New: clear stencil to 0
979
+ stencilLoadOp: "clear", // New: clear stencil each frame
980
+ stencilStoreOp: "store", // New: store stencil
470
981
  },
471
982
  };
472
983
  this.camera.aspect = width / height;
@@ -646,7 +1157,11 @@ export class Engine {
646
1157
  const texture = await loadTextureByIndex(toonTextureIndex);
647
1158
  if (texture)
648
1159
  return texture;
649
- // Default toon texture fallback
1160
+ // Default toon texture fallback - cache it
1161
+ const defaultToonPath = "__default_toon__";
1162
+ const cached = this.textureCache.get(defaultToonPath);
1163
+ if (cached)
1164
+ return cached;
650
1165
  const defaultToonData = new Uint8Array(256 * 2 * 4);
651
1166
  for (let i = 0; i < 256; i++) {
652
1167
  const factor = i / 255.0;
@@ -667,12 +1182,18 @@ export class Engine {
667
1182
  usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
668
1183
  });
669
1184
  this.device.queue.writeTexture({ texture: defaultToonTexture }, defaultToonData, { bytesPerRow: 256 * 4 }, [256, 2]);
670
- this.textureSizes.set("__default_toon__", { width: 256, height: 2 });
1185
+ this.textureCache.set(defaultToonPath, defaultToonTexture);
1186
+ this.textureSizes.set(defaultToonPath, { width: 256, height: 2 });
671
1187
  return defaultToonTexture;
672
1188
  };
673
- this.materialDraws = [];
674
- this.outlineDraws = [];
675
- const outlineBindGroupLayout = this.outlinePipeline.getBindGroupLayout(0);
1189
+ this.opaqueNonEyeNonHairDraws = [];
1190
+ this.eyeDraws = [];
1191
+ this.hairDraws = [];
1192
+ this.transparentNonEyeNonHairDraws = [];
1193
+ this.opaqueNonEyeNonHairOutlineDraws = [];
1194
+ this.eyeOutlineDraws = [];
1195
+ this.hairOutlineDraws = [];
1196
+ this.transparentNonEyeNonHairOutlineDraws = [];
676
1197
  let runningFirstIndex = 0;
677
1198
  for (const mat of materials) {
678
1199
  const matCount = mat.vertexCount | 0;
@@ -696,9 +1217,11 @@ export class Engine {
696
1217
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
697
1218
  });
698
1219
  this.device.queue.writeBuffer(materialUniformBuffer, 0, materialUniformData);
1220
+ // Create bind groups using the shared bind group layout
1221
+ // All pipelines (main, eye, hair multiply, hair opaque) use the same shader and layout
699
1222
  const bindGroup = this.device.createBindGroup({
700
1223
  label: `material bind group: ${mat.name}`,
701
- layout: this.pipeline.getBindGroupLayout(0),
1224
+ layout: this.hairBindGroupLayout,
702
1225
  entries: [
703
1226
  { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
704
1227
  { binding: 1, resource: { buffer: this.lightUniformBuffer } },
@@ -710,13 +1233,39 @@ export class Engine {
710
1233
  { binding: 7, resource: { buffer: materialUniformBuffer } },
711
1234
  ],
712
1235
  });
713
- // All materials use the same pipeline
714
- this.materialDraws.push({
715
- count: matCount,
716
- firstIndex: runningFirstIndex,
717
- bindGroup,
718
- isTransparent,
719
- });
1236
+ // Classify materials into appropriate draw lists
1237
+ if (mat.isEye) {
1238
+ this.eyeDraws.push({
1239
+ count: matCount,
1240
+ firstIndex: runningFirstIndex,
1241
+ bindGroup,
1242
+ isTransparent,
1243
+ });
1244
+ }
1245
+ else if (mat.isHair) {
1246
+ this.hairDraws.push({
1247
+ count: matCount,
1248
+ firstIndex: runningFirstIndex,
1249
+ bindGroup,
1250
+ isTransparent,
1251
+ });
1252
+ }
1253
+ else if (isTransparent) {
1254
+ this.transparentNonEyeNonHairDraws.push({
1255
+ count: matCount,
1256
+ firstIndex: runningFirstIndex,
1257
+ bindGroup,
1258
+ isTransparent,
1259
+ });
1260
+ }
1261
+ else {
1262
+ this.opaqueNonEyeNonHairDraws.push({
1263
+ count: matCount,
1264
+ firstIndex: runningFirstIndex,
1265
+ bindGroup,
1266
+ isTransparent,
1267
+ });
1268
+ }
720
1269
  // Outline for all materials (including transparent)
721
1270
  // Edge flag is at bit 4 (0x10) in PMX format, not bit 0 (0x01)
722
1271
  if ((mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
@@ -734,26 +1283,52 @@ export class Engine {
734
1283
  this.device.queue.writeBuffer(materialUniformBuffer, 0, materialUniformData);
735
1284
  const outlineBindGroup = this.device.createBindGroup({
736
1285
  label: `outline bind group: ${mat.name}`,
737
- layout: outlineBindGroupLayout,
1286
+ layout: this.outlineBindGroupLayout,
738
1287
  entries: [
739
1288
  { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
740
1289
  { binding: 1, resource: { buffer: materialUniformBuffer } },
741
1290
  { binding: 2, resource: { buffer: this.skinMatrixBuffer } },
742
1291
  ],
743
1292
  });
744
- // All outlines use the same pipeline
745
- this.outlineDraws.push({
746
- count: matCount,
747
- firstIndex: runningFirstIndex,
748
- bindGroup: outlineBindGroup,
749
- isTransparent,
750
- });
1293
+ // Classify outlines into appropriate draw lists
1294
+ if (mat.isEye) {
1295
+ this.eyeOutlineDraws.push({
1296
+ count: matCount,
1297
+ firstIndex: runningFirstIndex,
1298
+ bindGroup: outlineBindGroup,
1299
+ isTransparent,
1300
+ });
1301
+ }
1302
+ else if (mat.isHair) {
1303
+ this.hairOutlineDraws.push({
1304
+ count: matCount,
1305
+ firstIndex: runningFirstIndex,
1306
+ bindGroup: outlineBindGroup,
1307
+ isTransparent,
1308
+ });
1309
+ }
1310
+ else if (isTransparent) {
1311
+ this.transparentNonEyeNonHairOutlineDraws.push({
1312
+ count: matCount,
1313
+ firstIndex: runningFirstIndex,
1314
+ bindGroup: outlineBindGroup,
1315
+ isTransparent,
1316
+ });
1317
+ }
1318
+ else {
1319
+ this.opaqueNonEyeNonHairOutlineDraws.push({
1320
+ count: matCount,
1321
+ firstIndex: runningFirstIndex,
1322
+ bindGroup: outlineBindGroup,
1323
+ isTransparent,
1324
+ });
1325
+ }
751
1326
  }
752
1327
  runningFirstIndex += matCount;
753
1328
  }
754
1329
  }
755
- // Helper: Load texture from file path
756
- async createTextureFromPath(path) {
1330
+ // Helper: Load texture from file path with optional max size limit
1331
+ async createTextureFromPath(path, maxSize = 2048) {
757
1332
  const cached = this.textureCache.get(path);
758
1333
  if (cached) {
759
1334
  return cached;
@@ -763,22 +1338,34 @@ export class Engine {
763
1338
  if (!response.ok) {
764
1339
  throw new Error(`HTTP ${response.status}: ${response.statusText}`);
765
1340
  }
766
- const imageBitmap = await createImageBitmap(await response.blob(), {
1341
+ let imageBitmap = await createImageBitmap(await response.blob(), {
767
1342
  premultiplyAlpha: "none",
768
1343
  colorSpaceConversion: "none",
769
1344
  });
1345
+ // Downscale if texture is too large
1346
+ let finalWidth = imageBitmap.width;
1347
+ let finalHeight = imageBitmap.height;
1348
+ if (finalWidth > maxSize || finalHeight > maxSize) {
1349
+ const scale = Math.min(maxSize / finalWidth, maxSize / finalHeight);
1350
+ finalWidth = Math.floor(finalWidth * scale);
1351
+ finalHeight = Math.floor(finalHeight * scale);
1352
+ // Create canvas to downscale
1353
+ const canvas = new OffscreenCanvas(finalWidth, finalHeight);
1354
+ const ctx = canvas.getContext("2d");
1355
+ if (ctx) {
1356
+ ctx.drawImage(imageBitmap, 0, 0, finalWidth, finalHeight);
1357
+ imageBitmap = await createImageBitmap(canvas);
1358
+ }
1359
+ }
770
1360
  const texture = this.device.createTexture({
771
1361
  label: `texture: ${path}`,
772
- size: [imageBitmap.width, imageBitmap.height],
1362
+ size: [finalWidth, finalHeight],
773
1363
  format: "rgba8unorm",
774
1364
  usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
775
1365
  });
776
- this.device.queue.copyExternalImageToTexture({ source: imageBitmap }, { texture }, [
777
- imageBitmap.width,
778
- imageBitmap.height,
779
- ]);
1366
+ this.device.queue.copyExternalImageToTexture({ source: imageBitmap }, { texture }, [finalWidth, finalHeight]);
780
1367
  this.textureCache.set(path, texture);
781
- this.textureSizes.set(path, { width: imageBitmap.width, height: imageBitmap.height });
1368
+ this.textureSizes.set(path, { width: finalWidth, height: finalHeight });
782
1369
  return texture;
783
1370
  }
784
1371
  catch {
@@ -801,10 +1388,78 @@ export class Engine {
801
1388
  pass.setVertexBuffer(2, this.weightsBuffer);
802
1389
  pass.setIndexBuffer(this.indexBuffer, "uint32");
803
1390
  this.drawCallCount = 0;
804
- this.drawOutlines(pass, false);
805
- this.drawModel(pass, false);
806
- this.drawModel(pass, true);
807
- this.drawOutlines(pass, true);
1391
+ // === PASS 1: Opaque non-eye, non-hair (face, body, etc) ===
1392
+ this.drawOutlines(pass, false); // Opaque outlines
1393
+ pass.setPipeline(this.pipeline);
1394
+ for (const draw of this.opaqueNonEyeNonHairDraws) {
1395
+ if (draw.count > 0) {
1396
+ pass.setBindGroup(0, draw.bindGroup);
1397
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1398
+ this.drawCallCount++;
1399
+ }
1400
+ }
1401
+ // === PASS 2: Eyes (writes stencil = 1) ===
1402
+ pass.setPipeline(this.eyePipeline);
1403
+ pass.setStencilReference(1); // Set stencil reference value to 1
1404
+ for (const draw of this.eyeDraws) {
1405
+ if (draw.count > 0) {
1406
+ pass.setBindGroup(0, draw.bindGroup);
1407
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1408
+ this.drawCallCount++;
1409
+ }
1410
+ }
1411
+ // === PASS 3a: Hair over eyes (stencil == 1, multiply blend) ===
1412
+ // Draw hair geometry first to establish depth
1413
+ pass.setPipeline(this.hairMultiplyPipeline);
1414
+ pass.setStencilReference(1); // Check against stencil value 1
1415
+ for (const draw of this.hairDraws) {
1416
+ if (draw.count > 0) {
1417
+ pass.setBindGroup(0, draw.bindGroup);
1418
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1419
+ this.drawCallCount++;
1420
+ }
1421
+ }
1422
+ // === PASS 3a.5: Hair outlines over eyes (stencil == 1, depth test to only draw near hair) ===
1423
+ // Use depth compare "less-equal" with the hair depth to only draw outline where hair exists
1424
+ // The outline is expanded outward, so we need to ensure it only appears near the hair edge
1425
+ pass.setPipeline(this.hairOutlineOverEyesPipeline);
1426
+ pass.setStencilReference(1); // Check against stencil value 1 (with equal test)
1427
+ for (const draw of this.hairOutlineDraws) {
1428
+ if (draw.count > 0) {
1429
+ pass.setBindGroup(0, draw.bindGroup);
1430
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1431
+ }
1432
+ }
1433
+ // === PASS 3b: Hair over non-eyes (stencil != 1, opaque) ===
1434
+ pass.setPipeline(this.hairOpaquePipeline);
1435
+ pass.setStencilReference(1); // Check against stencil value 1 (with not-equal test)
1436
+ for (const draw of this.hairDraws) {
1437
+ if (draw.count > 0) {
1438
+ pass.setBindGroup(0, draw.bindGroup);
1439
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1440
+ this.drawCallCount++;
1441
+ }
1442
+ }
1443
+ // === PASS 3b.5: Hair outlines over non-eyes (stencil != 1) ===
1444
+ // Draw hair outlines after hair geometry, so they only appear where hair exists
1445
+ pass.setPipeline(this.hairOutlinePipeline);
1446
+ pass.setStencilReference(1); // Check against stencil value 1 (with not-equal test)
1447
+ for (const draw of this.hairOutlineDraws) {
1448
+ if (draw.count > 0) {
1449
+ pass.setBindGroup(0, draw.bindGroup);
1450
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1451
+ }
1452
+ }
1453
+ // === PASS 4: Transparent non-eye, non-hair ===
1454
+ pass.setPipeline(this.pipeline);
1455
+ for (const draw of this.transparentNonEyeNonHairDraws) {
1456
+ if (draw.count > 0) {
1457
+ pass.setBindGroup(0, draw.bindGroup);
1458
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1459
+ this.drawCallCount++;
1460
+ }
1461
+ }
1462
+ this.drawOutlines(pass, true); // Transparent outlines
808
1463
  pass.end();
809
1464
  this.device.queue.submit([encoder.finish()]);
810
1465
  this.updateStats(performance.now() - currentTime);
@@ -876,24 +1531,23 @@ export class Engine {
876
1531
  }
877
1532
  // Draw outlines (opaque or transparent)
878
1533
  drawOutlines(pass, transparent) {
879
- if (this.outlineDraws.length === 0)
880
- return;
881
1534
  pass.setPipeline(this.outlinePipeline);
882
- for (const draw of this.outlineDraws) {
883
- if (draw.count > 0 && draw.isTransparent === transparent) {
884
- pass.setBindGroup(0, draw.bindGroup);
885
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1535
+ if (transparent) {
1536
+ // Draw transparent outlines (if any)
1537
+ for (const draw of this.transparentNonEyeNonHairOutlineDraws) {
1538
+ if (draw.count > 0) {
1539
+ pass.setBindGroup(0, draw.bindGroup);
1540
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1541
+ }
886
1542
  }
887
1543
  }
888
- }
889
- // Draw model materials (opaque or transparent)
890
- drawModel(pass, transparent) {
891
- pass.setPipeline(this.pipeline);
892
- for (const draw of this.materialDraws) {
893
- if (draw.count > 0 && draw.isTransparent === transparent) {
894
- pass.setBindGroup(0, draw.bindGroup);
895
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
896
- this.drawCallCount++;
1544
+ else {
1545
+ // Draw opaque outlines before main geometry
1546
+ for (const draw of this.opaqueNonEyeNonHairOutlineDraws) {
1547
+ if (draw.count > 0) {
1548
+ pass.setBindGroup(0, draw.bindGroup);
1549
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1550
+ }
897
1551
  }
898
1552
  }
899
1553
  }
@@ -914,41 +1568,14 @@ export class Engine {
914
1568
  this.stats.fps = Math.round((this.framesSinceLastUpdate / elapsed) * 1000);
915
1569
  this.framesSinceLastUpdate = 0;
916
1570
  this.lastFpsUpdate = now;
917
- const perf = performance;
918
- if (perf.memory) {
919
- this.stats.memoryUsed = Math.round(perf.memory.usedJSHeapSize / 1024 / 1024);
920
- }
921
- }
922
- this.stats.vertices = this.vertexCount;
923
- this.stats.drawCalls = this.drawCallCount;
924
- // Calculate triangles from index buffer
925
- if (this.indexBuffer) {
926
- const indexCount = this.currentModel?.getIndices()?.length || 0;
927
- this.stats.triangles = Math.floor(indexCount / 3);
928
- }
929
- else {
930
- this.stats.triangles = Math.floor(this.vertexCount / 3);
931
1571
  }
932
- // Material count
933
- this.stats.materials = this.materialDraws.length;
934
- // Texture stats
935
- this.stats.textures = this.textureCache.size;
1572
+ // Calculate GPU memory: textures + buffers + render targets
936
1573
  let textureMemoryBytes = 0;
937
1574
  for (const [path, size] of this.textureSizes.entries()) {
938
1575
  if (this.textureCache.has(path)) {
939
- // RGBA8 = 4 bytes per pixel
940
- textureMemoryBytes += size.width * size.height * 4;
1576
+ textureMemoryBytes += size.width * size.height * 4; // RGBA8 = 4 bytes per pixel
941
1577
  }
942
1578
  }
943
- // Add render target textures (multisample + depth)
944
- if (this.multisampleTexture) {
945
- const width = this.canvas.width;
946
- const height = this.canvas.height;
947
- textureMemoryBytes += width * height * 4 * this.sampleCount; // multisample color
948
- textureMemoryBytes += width * height * 4; // depth (depth24plus = 4 bytes)
949
- }
950
- this.stats.textureMemory = Math.round((textureMemoryBytes / 1024 / 1024) * 100) / 100;
951
- // Buffer memory estimate
952
1579
  let bufferMemoryBytes = 0;
953
1580
  if (this.vertexBuffer) {
954
1581
  const vertices = this.currentModel?.getVertices();
@@ -977,10 +1604,19 @@ export class Engine {
977
1604
  }
978
1605
  bufferMemoryBytes += 40 * 4; // cameraUniformBuffer
979
1606
  bufferMemoryBytes += 64 * 4; // lightUniformBuffer
980
- // Material uniform buffers (estimate: 4 bytes per material)
981
- bufferMemoryBytes += this.materialDraws.length * 4;
982
- this.stats.bufferMemory = Math.round((bufferMemoryBytes / 1024 / 1024) * 100) / 100;
983
- // Total GPU memory estimate
984
- this.stats.gpuMemory = Math.round((this.stats.textureMemory + this.stats.bufferMemory) * 100) / 100;
1607
+ const totalMaterialDraws = this.opaqueNonEyeNonHairDraws.length +
1608
+ this.eyeDraws.length +
1609
+ this.hairDraws.length +
1610
+ this.transparentNonEyeNonHairDraws.length;
1611
+ bufferMemoryBytes += totalMaterialDraws * 4; // Material uniform buffers
1612
+ let renderTargetMemoryBytes = 0;
1613
+ if (this.multisampleTexture) {
1614
+ const width = this.canvas.width;
1615
+ const height = this.canvas.height;
1616
+ renderTargetMemoryBytes += width * height * 4 * this.sampleCount; // multisample color
1617
+ renderTargetMemoryBytes += width * height * 4; // depth
1618
+ }
1619
+ const totalGPUMemoryBytes = textureMemoryBytes + bufferMemoryBytes + renderTargetMemoryBytes;
1620
+ this.stats.gpuMemory = Math.round((totalGPUMemoryBytes / 1024 / 1024) * 100) / 100;
985
1621
  }
986
1622
  }