reze-engine 0.1.5 → 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
@@ -28,8 +28,14 @@ export class Engine {
28
28
  };
29
29
  this.animationFrameId = null;
30
30
  this.renderLoopCallback = null;
31
- this.materialDraws = [];
32
- 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 = [];
33
39
  this.canvas = canvas;
34
40
  }
35
41
  // Step 1: Get WebGPU device and context
@@ -66,118 +72,251 @@ export class Engine {
66
72
  });
67
73
  const shaderModule = this.device.createShaderModule({
68
74
  label: "model shaders",
69
- code: /* wgsl */ `
70
- struct CameraUniforms {
71
- view: mat4x4f,
72
- projection: mat4x4f,
73
- viewPos: vec3f,
74
- _padding: f32,
75
- };
76
-
77
- struct Light {
78
- direction: vec3f,
79
- _padding1: f32,
80
- color: vec3f,
81
- intensity: f32,
82
- };
83
-
84
- struct LightUniforms {
85
- ambient: f32,
86
- lightCount: f32,
87
- _padding1: f32,
88
- _padding2: f32,
89
- lights: array<Light, 4>,
90
- };
91
-
92
- struct MaterialUniforms {
93
- alpha: f32,
94
- _padding1: f32,
95
- _padding2: f32,
96
- _padding3: f32,
97
- };
98
-
99
- struct VertexOutput {
100
- @builtin(position) position: vec4f,
101
- @location(0) normal: vec3f,
102
- @location(1) uv: vec2f,
103
- @location(2) worldPos: vec3f,
104
- };
105
-
106
- @group(0) @binding(0) var<uniform> camera: CameraUniforms;
107
- @group(0) @binding(1) var<uniform> light: LightUniforms;
108
- @group(0) @binding(2) var diffuseTexture: texture_2d<f32>;
109
- @group(0) @binding(3) var diffuseSampler: sampler;
110
- @group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
111
- @group(0) @binding(5) var toonTexture: texture_2d<f32>;
112
- @group(0) @binding(6) var toonSampler: sampler;
113
- @group(0) @binding(7) var<uniform> material: MaterialUniforms;
114
-
115
- @vertex fn vs(
116
- @location(0) position: vec3f,
117
- @location(1) normal: vec3f,
118
- @location(2) uv: vec2f,
119
- @location(3) joints0: vec4<u32>,
120
- @location(4) weights0: vec4<f32>
121
- ) -> VertexOutput {
122
- var output: VertexOutput;
123
- let pos4 = vec4f(position, 1.0);
124
-
125
- // Normalize weights to ensure they sum to 1.0 (handles floating-point precision issues)
126
- let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
127
- var normalizedWeights: vec4f;
128
- if (weightSum > 0.0001) {
129
- normalizedWeights = weights0 / weightSum;
130
- } else {
131
- normalizedWeights = vec4f(1.0, 0.0, 0.0, 0.0);
132
- }
133
-
134
- var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
135
- var skinnedNrm = vec3f(0.0, 0.0, 0.0);
136
- for (var i = 0u; i < 4u; i++) {
137
- let j = joints0[i];
138
- let w = normalizedWeights[i];
139
- let m = skinMats[j];
140
- skinnedPos += (m * pos4) * w;
141
- let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
142
- skinnedNrm += (r3 * normal) * w;
143
- }
144
- let worldPos = skinnedPos.xyz;
145
- output.position = camera.projection * camera.view * vec4f(worldPos, 1.0);
146
- output.normal = normalize(skinnedNrm);
147
- output.uv = uv;
148
- output.worldPos = worldPos;
149
- return output;
150
- }
151
-
152
- @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
153
- let n = normalize(input.normal);
154
- let albedo = textureSample(diffuseTexture, diffuseSampler, input.uv).rgb;
155
-
156
- var lightAccum = vec3f(light.ambient);
157
- let numLights = u32(light.lightCount);
158
- for (var i = 0u; i < numLights; i++) {
159
- let l = -light.lights[i].direction;
160
- let nDotL = max(dot(n, l), 0.0);
161
- let toonUV = vec2f(nDotL, 0.5);
162
- let toonFactor = textureSample(toonTexture, toonSampler, toonUV).rgb;
163
- let radiance = light.lights[i].color * light.lights[i].intensity;
164
- lightAccum += toonFactor * radiance * nDotL;
165
- }
166
-
167
- let color = albedo * lightAccum;
168
- let finalAlpha = material.alpha;
169
- if (finalAlpha < 0.001) {
170
- discard;
171
- }
172
-
173
- return vec4f(clamp(color, vec3f(0.0), vec3f(1.0)), finalAlpha);
174
- }
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
+ }
181
+ `,
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
+ }
175
295
  `,
176
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
+ });
177
316
  // Single pipeline for all materials with alpha blending
178
317
  this.pipeline = this.device.createRenderPipeline({
179
318
  label: "model pipeline",
180
- layout: "auto",
319
+ layout: sharedPipelineLayout,
181
320
  vertex: {
182
321
  module: shaderModule,
183
322
  buffers: [
@@ -221,7 +360,7 @@ export class Engine {
221
360
  },
222
361
  primitive: { cullMode: "none" },
223
362
  depthStencil: {
224
- format: "depth24plus",
363
+ format: "depth24plus-stencil8",
225
364
  depthWriteEnabled: true,
226
365
  depthCompare: "less",
227
366
  },
@@ -229,79 +368,92 @@ export class Engine {
229
368
  count: this.sampleCount,
230
369
  },
231
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
+ });
232
384
  const outlineShaderModule = this.device.createShaderModule({
233
385
  label: "outline shaders",
234
- code: /* wgsl */ `
235
- struct CameraUniforms {
236
- view: mat4x4f,
237
- projection: mat4x4f,
238
- viewPos: vec3f,
239
- _padding: f32,
240
- };
241
-
242
- struct MaterialUniforms {
243
- edgeColor: vec4f,
244
- edgeSize: f32,
245
- _padding1: f32,
246
- _padding2: f32,
247
- _padding3: f32,
248
- };
249
-
250
- @group(0) @binding(0) var<uniform> camera: CameraUniforms;
251
- @group(0) @binding(1) var<uniform> material: MaterialUniforms;
252
- @group(0) @binding(2) var<storage, read> skinMats: array<mat4x4f>;
253
-
254
- struct VertexOutput {
255
- @builtin(position) position: vec4f,
256
- };
257
-
258
- @vertex fn vs(
259
- @location(0) position: vec3f,
260
- @location(1) normal: vec3f,
261
- @location(2) uv: vec2f,
262
- @location(3) joints0: vec4<u32>,
263
- @location(4) weights0: vec4<f32>
264
- ) -> VertexOutput {
265
- var output: VertexOutput;
266
- let pos4 = vec4f(position, 1.0);
267
-
268
- // Normalize weights to ensure they sum to 1.0 (handles floating-point precision issues)
269
- let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
270
- var normalizedWeights: vec4f;
271
- if (weightSum > 0.0001) {
272
- normalizedWeights = weights0 / weightSum;
273
- } else {
274
- normalizedWeights = vec4f(1.0, 0.0, 0.0, 0.0);
275
- }
276
-
277
- var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
278
- var skinnedNrm = vec3f(0.0, 0.0, 0.0);
279
- for (var i = 0u; i < 4u; i++) {
280
- let j = joints0[i];
281
- let w = normalizedWeights[i];
282
- let m = skinMats[j];
283
- skinnedPos += (m * pos4) * w;
284
- let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
285
- skinnedNrm += (r3 * normal) * w;
286
- }
287
- let worldPos = skinnedPos.xyz;
288
- let worldNormal = normalize(skinnedNrm);
289
-
290
- // MMD invert hull: expand vertices outward along normals
291
- let scaleFactor = 0.01;
292
- let expandedPos = worldPos + worldNormal * material.edgeSize * scaleFactor;
293
- output.position = camera.projection * camera.view * vec4f(expandedPos, 1.0);
294
- return output;
295
- }
296
-
297
- @fragment fn fs() -> @location(0) vec4f {
298
- return material.edgeColor;
299
- }
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
+ }
300
452
  `,
301
453
  });
302
454
  this.outlinePipeline = this.device.createRenderPipeline({
303
455
  label: "outline pipeline",
304
- layout: "auto",
456
+ layout: outlinePipelineLayout,
305
457
  vertex: {
306
458
  module: outlineShaderModule,
307
459
  buffers: [
@@ -359,7 +511,7 @@ export class Engine {
359
511
  cullMode: "back",
360
512
  },
361
513
  depthStencil: {
362
- format: "depth24plus",
514
+ format: "depth24plus-stencil8",
363
515
  depthWriteEnabled: true,
364
516
  depthCompare: "less",
365
517
  },
@@ -367,36 +519,400 @@ export class Engine {
367
519
  count: this.sampleCount,
368
520
  },
369
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
+ });
370
886
  }
371
887
  // Create compute shader for skin matrix computation
372
888
  createSkinMatrixComputePipeline() {
373
889
  const computeShader = this.device.createShaderModule({
374
890
  label: "skin matrix compute",
375
- code: /* wgsl */ `
376
- struct BoneCountUniform {
377
- count: u32,
378
- _padding1: u32,
379
- _padding2: u32,
380
- _padding3: u32,
381
- _padding4: vec4<u32>,
382
- };
383
-
384
- @group(0) @binding(0) var<uniform> boneCount: BoneCountUniform;
385
- @group(0) @binding(1) var<storage, read> worldMatrices: array<mat4x4f>;
386
- @group(0) @binding(2) var<storage, read> inverseBindMatrices: array<mat4x4f>;
387
- @group(0) @binding(3) var<storage, read_write> skinMatrices: array<mat4x4f>;
388
-
389
- @compute @workgroup_size(64)
390
- fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
391
- let boneIndex = globalId.x;
392
- // Bounds check: we dispatch workgroups (64 threads each), so some threads may be out of range
393
- if (boneIndex >= boneCount.count) {
394
- return;
395
- }
396
- let worldMat = worldMatrices[boneIndex];
397
- let invBindMat = inverseBindMatrices[boneIndex];
398
- skinMatrices[boneIndex] = worldMat * invBindMat;
399
- }
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
+ }
400
916
  `,
401
917
  });
402
918
  this.skinMatrixComputePipeline = this.device.createComputePipeline({
@@ -433,7 +949,7 @@ export class Engine {
433
949
  label: "depth texture",
434
950
  size: [width, height],
435
951
  sampleCount: this.sampleCount,
436
- format: "depth24plus",
952
+ format: "depth24plus-stencil8",
437
953
  usage: GPUTextureUsage.RENDER_ATTACHMENT,
438
954
  });
439
955
  const depthTextureView = this.depthTexture.createView();
@@ -459,6 +975,9 @@ export class Engine {
459
975
  depthClearValue: 1.0,
460
976
  depthLoadOp: "clear",
461
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
462
981
  },
463
982
  };
464
983
  this.camera.aspect = width / height;
@@ -667,9 +1186,14 @@ export class Engine {
667
1186
  this.textureSizes.set(defaultToonPath, { width: 256, height: 2 });
668
1187
  return defaultToonTexture;
669
1188
  };
670
- this.materialDraws = [];
671
- this.outlineDraws = [];
672
- 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 = [];
673
1197
  let runningFirstIndex = 0;
674
1198
  for (const mat of materials) {
675
1199
  const matCount = mat.vertexCount | 0;
@@ -693,9 +1217,11 @@ export class Engine {
693
1217
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
694
1218
  });
695
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
696
1222
  const bindGroup = this.device.createBindGroup({
697
1223
  label: `material bind group: ${mat.name}`,
698
- layout: this.pipeline.getBindGroupLayout(0),
1224
+ layout: this.hairBindGroupLayout,
699
1225
  entries: [
700
1226
  { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
701
1227
  { binding: 1, resource: { buffer: this.lightUniformBuffer } },
@@ -707,13 +1233,39 @@ export class Engine {
707
1233
  { binding: 7, resource: { buffer: materialUniformBuffer } },
708
1234
  ],
709
1235
  });
710
- // All materials use the same pipeline
711
- this.materialDraws.push({
712
- count: matCount,
713
- firstIndex: runningFirstIndex,
714
- bindGroup,
715
- isTransparent,
716
- });
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
+ }
717
1269
  // Outline for all materials (including transparent)
718
1270
  // Edge flag is at bit 4 (0x10) in PMX format, not bit 0 (0x01)
719
1271
  if ((mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
@@ -731,20 +1283,46 @@ export class Engine {
731
1283
  this.device.queue.writeBuffer(materialUniformBuffer, 0, materialUniformData);
732
1284
  const outlineBindGroup = this.device.createBindGroup({
733
1285
  label: `outline bind group: ${mat.name}`,
734
- layout: outlineBindGroupLayout,
1286
+ layout: this.outlineBindGroupLayout,
735
1287
  entries: [
736
1288
  { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
737
1289
  { binding: 1, resource: { buffer: materialUniformBuffer } },
738
1290
  { binding: 2, resource: { buffer: this.skinMatrixBuffer } },
739
1291
  ],
740
1292
  });
741
- // All outlines use the same pipeline
742
- this.outlineDraws.push({
743
- count: matCount,
744
- firstIndex: runningFirstIndex,
745
- bindGroup: outlineBindGroup,
746
- isTransparent,
747
- });
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
+ }
748
1326
  }
749
1327
  runningFirstIndex += matCount;
750
1328
  }
@@ -810,10 +1388,78 @@ export class Engine {
810
1388
  pass.setVertexBuffer(2, this.weightsBuffer);
811
1389
  pass.setIndexBuffer(this.indexBuffer, "uint32");
812
1390
  this.drawCallCount = 0;
813
- this.drawOutlines(pass, false);
814
- this.drawModel(pass, false);
815
- this.drawModel(pass, true);
816
- 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
817
1463
  pass.end();
818
1464
  this.device.queue.submit([encoder.finish()]);
819
1465
  this.updateStats(performance.now() - currentTime);
@@ -885,24 +1531,23 @@ export class Engine {
885
1531
  }
886
1532
  // Draw outlines (opaque or transparent)
887
1533
  drawOutlines(pass, transparent) {
888
- if (this.outlineDraws.length === 0)
889
- return;
890
1534
  pass.setPipeline(this.outlinePipeline);
891
- for (const draw of this.outlineDraws) {
892
- if (draw.count > 0 && draw.isTransparent === transparent) {
893
- pass.setBindGroup(0, draw.bindGroup);
894
- 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
+ }
895
1542
  }
896
1543
  }
897
- }
898
- // Draw model materials (opaque or transparent)
899
- drawModel(pass, transparent) {
900
- pass.setPipeline(this.pipeline);
901
- for (const draw of this.materialDraws) {
902
- if (draw.count > 0 && draw.isTransparent === transparent) {
903
- pass.setBindGroup(0, draw.bindGroup);
904
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
905
- 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
+ }
906
1551
  }
907
1552
  }
908
1553
  }
@@ -959,7 +1604,11 @@ export class Engine {
959
1604
  }
960
1605
  bufferMemoryBytes += 40 * 4; // cameraUniformBuffer
961
1606
  bufferMemoryBytes += 64 * 4; // lightUniformBuffer
962
- bufferMemoryBytes += this.materialDraws.length * 4; // Material uniform buffers
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
963
1612
  let renderTargetMemoryBytes = 0;
964
1613
  if (this.multisampleTexture) {
965
1614
  const width = this.canvas.width;