reze-engine 0.1.14 → 0.1.16

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
@@ -81,124 +81,128 @@ export class Engine {
81
81
  });
82
82
  const shaderModule = this.device.createShaderModule({
83
83
  label: "model shaders",
84
- code: /* wgsl */ `
85
- struct CameraUniforms {
86
- view: mat4x4f,
87
- projection: mat4x4f,
88
- viewPos: vec3f,
89
- _padding: f32,
90
- };
91
-
92
- struct Light {
93
- direction: vec3f,
94
- _padding1: f32,
95
- color: vec3f,
96
- intensity: f32,
97
- };
98
-
99
- struct LightUniforms {
100
- ambient: f32,
101
- lightCount: f32,
102
- _padding1: f32,
103
- _padding2: f32,
104
- lights: array<Light, 4>,
105
- };
106
-
107
- struct MaterialUniforms {
108
- alpha: f32,
109
- alphaMultiplier: f32,
110
- rimIntensity: f32,
111
- rimPower: f32,
112
- rimColor: vec3f,
113
- _padding1: f32,
114
- };
115
-
116
- struct VertexOutput {
117
- @builtin(position) position: vec4f,
118
- @location(0) normal: vec3f,
119
- @location(1) uv: vec2f,
120
- @location(2) worldPos: vec3f,
121
- };
122
-
123
- @group(0) @binding(0) var<uniform> camera: CameraUniforms;
124
- @group(0) @binding(1) var<uniform> light: LightUniforms;
125
- @group(0) @binding(2) var diffuseTexture: texture_2d<f32>;
126
- @group(0) @binding(3) var diffuseSampler: sampler;
127
- @group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
128
- @group(0) @binding(5) var toonTexture: texture_2d<f32>;
129
- @group(0) @binding(6) var toonSampler: sampler;
130
- @group(0) @binding(7) var<uniform> material: MaterialUniforms;
131
-
132
- @vertex fn vs(
133
- @location(0) position: vec3f,
134
- @location(1) normal: vec3f,
135
- @location(2) uv: vec2f,
136
- @location(3) joints0: vec4<u32>,
137
- @location(4) weights0: vec4<f32>
138
- ) -> VertexOutput {
139
- var output: VertexOutput;
140
- let pos4 = vec4f(position, 1.0);
141
-
142
- // Normalize weights to ensure they sum to 1.0 (handles floating-point precision issues)
143
- let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
144
- var normalizedWeights: vec4f;
145
- if (weightSum > 0.0001) {
146
- normalizedWeights = weights0 / weightSum;
147
- } else {
148
- normalizedWeights = vec4f(1.0, 0.0, 0.0, 0.0);
149
- }
150
-
151
- var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
152
- var skinnedNrm = vec3f(0.0, 0.0, 0.0);
153
- for (var i = 0u; i < 4u; i++) {
154
- let j = joints0[i];
155
- let w = normalizedWeights[i];
156
- let m = skinMats[j];
157
- skinnedPos += (m * pos4) * w;
158
- let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
159
- skinnedNrm += (r3 * normal) * w;
160
- }
161
- let worldPos = skinnedPos.xyz;
162
- output.position = camera.projection * camera.view * vec4f(worldPos, 1.0);
163
- output.normal = normalize(skinnedNrm);
164
- output.uv = uv;
165
- output.worldPos = worldPos;
166
- return output;
167
- }
168
-
169
- @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
170
- let n = normalize(input.normal);
171
- let albedo = textureSample(diffuseTexture, diffuseSampler, input.uv).rgb;
172
-
173
- var lightAccum = vec3f(light.ambient);
174
- let numLights = u32(light.lightCount);
175
- for (var i = 0u; i < numLights; i++) {
176
- let l = -light.lights[i].direction;
177
- let nDotL = max(dot(n, l), 0.0);
178
- let toonUV = vec2f(nDotL, 0.5);
179
- let toonFactor = textureSample(toonTexture, toonSampler, toonUV).rgb;
180
- let radiance = light.lights[i].color * light.lights[i].intensity;
181
- lightAccum += toonFactor * radiance * nDotL;
182
- }
183
-
184
- // Rim light calculation
185
- let viewDir = normalize(camera.viewPos - input.worldPos);
186
- var rimFactor = 1.0 - max(dot(n, viewDir), 0.0);
187
- rimFactor = pow(rimFactor, material.rimPower);
188
- let rimLight = material.rimColor * material.rimIntensity * rimFactor;
189
-
190
- let color = albedo * lightAccum + rimLight;
191
- let finalAlpha = material.alpha * material.alphaMultiplier;
192
- if (finalAlpha < 0.001) {
193
- discard;
194
- }
195
-
196
- return vec4f(clamp(color, vec3f(0.0), vec3f(1.0)), finalAlpha);
197
- }
84
+ code: /* wgsl */ `
85
+ struct CameraUniforms {
86
+ view: mat4x4f,
87
+ projection: mat4x4f,
88
+ viewPos: vec3f,
89
+ _padding: f32,
90
+ };
91
+
92
+ struct Light {
93
+ direction: vec3f,
94
+ _padding1: f32,
95
+ color: vec3f,
96
+ intensity: f32,
97
+ };
98
+
99
+ struct LightUniforms {
100
+ ambient: f32,
101
+ lightCount: f32,
102
+ _padding1: f32,
103
+ _padding2: f32,
104
+ lights: array<Light, 4>,
105
+ };
106
+
107
+ struct MaterialUniforms {
108
+ alpha: f32,
109
+ alphaMultiplier: f32,
110
+ rimIntensity: f32,
111
+ rimPower: f32,
112
+ rimColor: vec3f,
113
+ isOverEyes: f32, // 1.0 if rendering over eyes, 0.0 otherwise
114
+ };
115
+
116
+ struct VertexOutput {
117
+ @builtin(position) position: vec4f,
118
+ @location(0) normal: vec3f,
119
+ @location(1) uv: vec2f,
120
+ @location(2) worldPos: vec3f,
121
+ };
122
+
123
+ @group(0) @binding(0) var<uniform> camera: CameraUniforms;
124
+ @group(0) @binding(1) var<uniform> light: LightUniforms;
125
+ @group(0) @binding(2) var diffuseTexture: texture_2d<f32>;
126
+ @group(0) @binding(3) var diffuseSampler: sampler;
127
+ @group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
128
+ @group(0) @binding(5) var toonTexture: texture_2d<f32>;
129
+ @group(0) @binding(6) var toonSampler: sampler;
130
+ @group(0) @binding(7) var<uniform> material: MaterialUniforms;
131
+
132
+ @vertex fn vs(
133
+ @location(0) position: vec3f,
134
+ @location(1) normal: vec3f,
135
+ @location(2) uv: vec2f,
136
+ @location(3) joints0: vec4<u32>,
137
+ @location(4) weights0: vec4<f32>
138
+ ) -> VertexOutput {
139
+ var output: VertexOutput;
140
+ let pos4 = vec4f(position, 1.0);
141
+
142
+ // Normalize weights to ensure they sum to 1.0 (handles floating-point precision issues)
143
+ let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
144
+ var normalizedWeights: vec4f;
145
+ if (weightSum > 0.0001) {
146
+ normalizedWeights = weights0 / weightSum;
147
+ } else {
148
+ normalizedWeights = vec4f(1.0, 0.0, 0.0, 0.0);
149
+ }
150
+
151
+ var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
152
+ var skinnedNrm = vec3f(0.0, 0.0, 0.0);
153
+ for (var i = 0u; i < 4u; i++) {
154
+ let j = joints0[i];
155
+ let w = normalizedWeights[i];
156
+ let m = skinMats[j];
157
+ skinnedPos += (m * pos4) * w;
158
+ let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
159
+ skinnedNrm += (r3 * normal) * w;
160
+ }
161
+ let worldPos = skinnedPos.xyz;
162
+ output.position = camera.projection * camera.view * vec4f(worldPos, 1.0);
163
+ output.normal = normalize(skinnedNrm);
164
+ output.uv = uv;
165
+ output.worldPos = worldPos;
166
+ return output;
167
+ }
168
+
169
+ @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
170
+ let n = normalize(input.normal);
171
+ let albedo = textureSample(diffuseTexture, diffuseSampler, input.uv).rgb;
172
+
173
+ var lightAccum = vec3f(light.ambient);
174
+ let numLights = u32(light.lightCount);
175
+ for (var i = 0u; i < numLights; i++) {
176
+ let l = -light.lights[i].direction;
177
+ let nDotL = max(dot(n, l), 0.0);
178
+ let toonUV = vec2f(nDotL, 0.5);
179
+ let toonFactor = textureSample(toonTexture, toonSampler, toonUV).rgb;
180
+ let radiance = light.lights[i].color * light.lights[i].intensity;
181
+ lightAccum += toonFactor * radiance * nDotL;
182
+ }
183
+
184
+ // Rim light calculation
185
+ let viewDir = normalize(camera.viewPos - input.worldPos);
186
+ var rimFactor = 1.0 - max(dot(n, viewDir), 0.0);
187
+ rimFactor = pow(rimFactor, material.rimPower);
188
+ let rimLight = material.rimColor * material.rimIntensity * rimFactor;
189
+
190
+ let color = albedo * lightAccum + rimLight;
191
+
192
+ var finalAlpha = material.alpha * material.alphaMultiplier;
193
+ if (material.isOverEyes > 0.5) {
194
+ finalAlpha *= 0.5; // Hair over eyes gets 50% alpha
195
+ }
196
+
197
+ if (finalAlpha < 0.001) {
198
+ discard;
199
+ }
200
+
201
+ return vec4f(clamp(color, vec3f(0.0), vec3f(1.0)), finalAlpha);
202
+ }
198
203
  `,
199
204
  });
200
205
  // Create explicit bind group layout for all pipelines using the main shader
201
- // This ensures compatibility across all pipelines (main, eye, hair multiply, hair opaque)
202
206
  this.hairBindGroupLayout = this.device.createBindGroupLayout({
203
207
  label: "shared material bind group layout",
204
208
  entries: [
@@ -286,71 +290,77 @@ export class Engine {
286
290
  });
287
291
  const outlineShaderModule = this.device.createShaderModule({
288
292
  label: "outline shaders",
289
- code: /* wgsl */ `
290
- struct CameraUniforms {
291
- view: mat4x4f,
292
- projection: mat4x4f,
293
- viewPos: vec3f,
294
- _padding: f32,
295
- };
296
-
297
- struct MaterialUniforms {
298
- edgeColor: vec4f,
299
- edgeSize: f32,
300
- _padding1: f32,
301
- _padding2: f32,
302
- _padding3: f32,
303
- };
304
-
305
- @group(0) @binding(0) var<uniform> camera: CameraUniforms;
306
- @group(0) @binding(1) var<uniform> material: MaterialUniforms;
307
- @group(0) @binding(2) var<storage, read> skinMats: array<mat4x4f>;
308
-
309
- struct VertexOutput {
310
- @builtin(position) position: vec4f,
311
- };
312
-
313
- @vertex fn vs(
314
- @location(0) position: vec3f,
315
- @location(1) normal: vec3f,
316
- @location(3) joints0: vec4<u32>,
317
- @location(4) weights0: vec4<f32>
318
- ) -> VertexOutput {
319
- var output: VertexOutput;
320
- let pos4 = vec4f(position, 1.0);
321
-
322
- // Normalize weights to ensure they sum to 1.0 (handles floating-point precision issues)
323
- let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
324
- var normalizedWeights: vec4f;
325
- if (weightSum > 0.0001) {
326
- normalizedWeights = weights0 / weightSum;
327
- } else {
328
- normalizedWeights = vec4f(1.0, 0.0, 0.0, 0.0);
329
- }
330
-
331
- var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
332
- var skinnedNrm = vec3f(0.0, 0.0, 0.0);
333
- for (var i = 0u; i < 4u; i++) {
334
- let j = joints0[i];
335
- let w = normalizedWeights[i];
336
- let m = skinMats[j];
337
- skinnedPos += (m * pos4) * w;
338
- let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
339
- skinnedNrm += (r3 * normal) * w;
340
- }
341
- let worldPos = skinnedPos.xyz;
342
- let worldNormal = normalize(skinnedNrm);
343
-
344
- // MMD invert hull: expand vertices outward along normals
345
- let scaleFactor = 0.01;
346
- let expandedPos = worldPos + worldNormal * material.edgeSize * scaleFactor;
347
- output.position = camera.projection * camera.view * vec4f(expandedPos, 1.0);
348
- return output;
349
- }
350
-
351
- @fragment fn fs() -> @location(0) vec4f {
352
- return material.edgeColor;
353
- }
293
+ code: /* wgsl */ `
294
+ struct CameraUniforms {
295
+ view: mat4x4f,
296
+ projection: mat4x4f,
297
+ viewPos: vec3f,
298
+ _padding: f32,
299
+ };
300
+
301
+ struct MaterialUniforms {
302
+ edgeColor: vec4f,
303
+ edgeSize: f32,
304
+ isOverEyes: f32, // 1.0 if rendering over eyes, 0.0 otherwise (for hair outlines)
305
+ _padding1: f32,
306
+ _padding2: f32,
307
+ };
308
+
309
+ @group(0) @binding(0) var<uniform> camera: CameraUniforms;
310
+ @group(0) @binding(1) var<uniform> material: MaterialUniforms;
311
+ @group(0) @binding(2) var<storage, read> skinMats: array<mat4x4f>;
312
+
313
+ struct VertexOutput {
314
+ @builtin(position) position: vec4f,
315
+ };
316
+
317
+ @vertex fn vs(
318
+ @location(0) position: vec3f,
319
+ @location(1) normal: vec3f,
320
+ @location(3) joints0: vec4<u32>,
321
+ @location(4) weights0: vec4<f32>
322
+ ) -> VertexOutput {
323
+ var output: VertexOutput;
324
+ let pos4 = vec4f(position, 1.0);
325
+
326
+ // Normalize weights to ensure they sum to 1.0 (handles floating-point precision issues)
327
+ let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
328
+ var normalizedWeights: vec4f;
329
+ if (weightSum > 0.0001) {
330
+ normalizedWeights = weights0 / weightSum;
331
+ } else {
332
+ normalizedWeights = vec4f(1.0, 0.0, 0.0, 0.0);
333
+ }
334
+
335
+ var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
336
+ var skinnedNrm = vec3f(0.0, 0.0, 0.0);
337
+ for (var i = 0u; i < 4u; i++) {
338
+ let j = joints0[i];
339
+ let w = normalizedWeights[i];
340
+ let m = skinMats[j];
341
+ skinnedPos += (m * pos4) * w;
342
+ let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
343
+ skinnedNrm += (r3 * normal) * w;
344
+ }
345
+ let worldPos = skinnedPos.xyz;
346
+ let worldNormal = normalize(skinnedNrm);
347
+
348
+ // MMD invert hull: expand vertices outward along normals
349
+ let scaleFactor = 0.01;
350
+ let expandedPos = worldPos + worldNormal * material.edgeSize * scaleFactor;
351
+ output.position = camera.projection * camera.view * vec4f(expandedPos, 1.0);
352
+ return output;
353
+ }
354
+
355
+ @fragment fn fs() -> @location(0) vec4f {
356
+ var color = material.edgeColor;
357
+
358
+ if (material.isOverEyes > 0.5) {
359
+ color.a *= 0.5; // Hair outlines over eyes get 50% alpha
360
+ }
361
+
362
+ return color;
363
+ }
354
364
  `,
355
365
  });
356
366
  this.outlinePipeline = this.device.createRenderPipeline({
@@ -416,9 +426,9 @@ export class Engine {
416
426
  count: this.sampleCount,
417
427
  },
418
428
  });
419
- // Hair outline pipeline: draws hair outlines over non-eyes (stencil != 1) - Drawn after hair geometry, so depth testing ensures outlines only appear where hair exists
420
- this.hairOutlinePipeline = this.device.createRenderPipeline({
421
- label: "hair outline pipeline",
429
+ // Unified hair outline pipeline: single pass without stencil testing, uses depth test "less-equal" to draw everywhere hair exists
430
+ this.hairUnifiedOutlinePipeline = this.device.createRenderPipeline({
431
+ label: "unified hair outline pipeline",
422
432
  layout: outlinePipelineLayout,
423
433
  vertex: {
424
434
  module: outlineShaderModule,
@@ -474,44 +484,28 @@ export class Engine {
474
484
  depthStencil: {
475
485
  format: "depth24plus-stencil8",
476
486
  depthWriteEnabled: false, // Don't write depth - let hair geometry control depth
477
- depthCompare: "less-equal", // Only draw where hair depth exists
478
- stencilFront: {
479
- compare: "not-equal", // Only render where stencil != 1 (not over eyes)
480
- failOp: "keep",
481
- depthFailOp: "keep",
482
- passOp: "keep",
483
- },
484
- stencilBack: {
485
- compare: "not-equal",
486
- failOp: "keep",
487
- depthFailOp: "keep",
488
- passOp: "keep",
489
- },
487
+ depthCompare: "less-equal", // Only draw where hair depth exists (no stencil test needed)
488
+ depthBias: -0.0001, // Small negative bias to bring outline slightly closer for depth test
489
+ depthBiasSlopeScale: 0.0,
490
+ depthBiasClamp: 0.0,
490
491
  },
491
492
  multisample: {
492
493
  count: this.sampleCount,
493
494
  },
494
495
  });
495
- // Hair outline pipeline for over eyes: draws where stencil == 1, but only where hair depth exists - Uses depth compare "equal" with a small bias to only appear where hair geometry exists
496
- this.hairOutlineOverEyesPipeline = this.device.createRenderPipeline({
497
- label: "hair outline over eyes pipeline",
498
- layout: outlinePipelineLayout,
496
+ // Eye overlay pipeline (renders after opaque, writes stencil)
497
+ this.eyePipeline = this.device.createRenderPipeline({
498
+ label: "eye overlay pipeline",
499
+ layout: sharedPipelineLayout,
499
500
  vertex: {
500
- module: outlineShaderModule,
501
+ module: shaderModule,
501
502
  buffers: [
502
503
  {
503
504
  arrayStride: 8 * 4,
504
505
  attributes: [
505
- {
506
- shaderLocation: 0,
507
- offset: 0,
508
- format: "float32x3",
509
- },
510
- {
511
- shaderLocation: 1,
512
- offset: 3 * 4,
513
- format: "float32x3",
514
- },
506
+ { shaderLocation: 0, offset: 0, format: "float32x3" },
507
+ { shaderLocation: 1, offset: 3 * 4, format: "float32x3" },
508
+ { shaderLocation: 2, offset: 6 * 4, format: "float32x2" },
515
509
  ],
516
510
  },
517
511
  {
@@ -525,7 +519,7 @@ export class Engine {
525
519
  ],
526
520
  },
527
521
  fragment: {
528
- module: outlineShaderModule,
522
+ module: shaderModule,
529
523
  targets: [
530
524
  {
531
525
  format: this.presentationFormat,
@@ -544,47 +538,86 @@ export class Engine {
544
538
  },
545
539
  ],
546
540
  },
547
- primitive: {
548
- cullMode: "back",
549
- },
541
+ primitive: { cullMode: "none" },
550
542
  depthStencil: {
551
543
  format: "depth24plus-stencil8",
552
544
  depthWriteEnabled: false, // Don't write depth
553
- depthCompare: "less-equal", // Draw where outline depth <= existing depth (hair depth)
554
- depthBias: -0.0001, // Small negative bias to bring outline slightly closer for depth test
555
- depthBiasSlopeScale: 0.0,
556
- depthBiasClamp: 0.0,
545
+ depthCompare: "less", // Respect existing depth
557
546
  stencilFront: {
558
- compare: "equal", // Only render where stencil == 1 (over eyes)
547
+ compare: "always",
559
548
  failOp: "keep",
560
549
  depthFailOp: "keep",
561
- passOp: "keep",
550
+ passOp: "replace", // Write stencil value 1
562
551
  },
563
552
  stencilBack: {
564
- compare: "equal",
553
+ compare: "always",
565
554
  failOp: "keep",
566
555
  depthFailOp: "keep",
567
- passOp: "keep",
556
+ passOp: "replace",
568
557
  },
569
558
  },
570
- multisample: {
571
- count: this.sampleCount,
572
- },
559
+ multisample: { count: this.sampleCount },
573
560
  });
574
- // Unified hair pipeline - can be used for both over-eyes and over-non-eyes
575
- // The difference is controlled by stencil state and alpha multiplier in material uniform
576
- this.hairMultiplyPipeline = this.device.createRenderPipeline({
577
- label: "hair pipeline (over eyes)",
561
+ // Depth-only shader for hair pre-pass (reduces overdraw by early depth rejection)
562
+ const depthOnlyShaderModule = this.device.createShaderModule({
563
+ label: "depth only shader",
564
+ code: /* wgsl */ `
565
+ struct CameraUniforms {
566
+ view: mat4x4f,
567
+ projection: mat4x4f,
568
+ viewPos: vec3f,
569
+ _padding: f32,
570
+ };
571
+
572
+ @group(0) @binding(0) var<uniform> camera: CameraUniforms;
573
+ @group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
574
+
575
+ @vertex fn vs(
576
+ @location(0) position: vec3f,
577
+ @location(1) normal: vec3f,
578
+ @location(3) joints0: vec4<u32>,
579
+ @location(4) weights0: vec4<f32>
580
+ ) -> @builtin(position) vec4f {
581
+ let pos4 = vec4f(position, 1.0);
582
+
583
+ // Normalize weights
584
+ let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
585
+ var normalizedWeights: vec4f;
586
+ if (weightSum > 0.0001) {
587
+ normalizedWeights = weights0 / weightSum;
588
+ } else {
589
+ normalizedWeights = vec4f(1.0, 0.0, 0.0, 0.0);
590
+ }
591
+
592
+ var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
593
+ for (var i = 0u; i < 4u; i++) {
594
+ let j = joints0[i];
595
+ let w = normalizedWeights[i];
596
+ let m = skinMats[j];
597
+ skinnedPos += (m * pos4) * w;
598
+ }
599
+ let worldPos = skinnedPos.xyz;
600
+ let clipPos = camera.projection * camera.view * vec4f(worldPos, 1.0);
601
+ return clipPos;
602
+ }
603
+
604
+ @fragment fn fs() -> @location(0) vec4f {
605
+ return vec4f(0.0, 0.0, 0.0, 0.0); // Transparent - color writes disabled via writeMask
606
+ }
607
+ `,
608
+ });
609
+ // Hair depth pre-pass pipeline: depth-only with color writes disabled to eliminate overdraw
610
+ this.hairDepthPipeline = this.device.createRenderPipeline({
611
+ label: "hair depth pre-pass",
578
612
  layout: sharedPipelineLayout,
579
613
  vertex: {
580
- module: shaderModule,
614
+ module: depthOnlyShaderModule,
581
615
  buffers: [
582
616
  {
583
617
  arrayStride: 8 * 4,
584
618
  attributes: [
585
619
  { shaderLocation: 0, offset: 0, format: "float32x3" },
586
620
  { shaderLocation: 1, offset: 3 * 4, format: "float32x3" },
587
- { shaderLocation: 2, offset: 6 * 4, format: "float32x2" },
588
621
  ],
589
622
  },
590
623
  {
@@ -598,48 +631,26 @@ export class Engine {
598
631
  ],
599
632
  },
600
633
  fragment: {
601
- module: shaderModule,
634
+ module: depthOnlyShaderModule,
635
+ entryPoint: "fs",
602
636
  targets: [
603
637
  {
604
638
  format: this.presentationFormat,
605
- blend: {
606
- color: {
607
- srcFactor: "src-alpha",
608
- dstFactor: "one-minus-src-alpha",
609
- operation: "add",
610
- },
611
- alpha: {
612
- srcFactor: "one",
613
- dstFactor: "one-minus-src-alpha",
614
- operation: "add",
615
- },
616
- },
639
+ writeMask: 0, // Disable all color writes - we only care about depth
617
640
  },
618
641
  ],
619
642
  },
620
643
  primitive: { cullMode: "none" },
621
644
  depthStencil: {
622
645
  format: "depth24plus-stencil8",
623
- depthWriteEnabled: true, // Write depth so outlines can test against it
646
+ depthWriteEnabled: true,
624
647
  depthCompare: "less",
625
- stencilFront: {
626
- compare: "equal", // Only render where stencil == 1
627
- failOp: "keep",
628
- depthFailOp: "keep",
629
- passOp: "keep",
630
- },
631
- stencilBack: {
632
- compare: "equal",
633
- failOp: "keep",
634
- depthFailOp: "keep",
635
- passOp: "keep",
636
- },
637
648
  },
638
649
  multisample: { count: this.sampleCount },
639
650
  });
640
- // Hair pipeline for opaque rendering (hair over non-eyes) - uses same shader, different stencil state
641
- this.hairOpaquePipeline = this.device.createRenderPipeline({
642
- label: "hair pipeline (over non-eyes)",
651
+ // Unified hair pipeline for over-eyes (stencil == 1): single pass with dynamic branching
652
+ this.hairUnifiedPipelineOverEyes = this.device.createRenderPipeline({
653
+ label: "unified hair pipeline (over eyes)",
643
654
  layout: sharedPipelineLayout,
644
655
  vertex: {
645
656
  module: shaderModule,
@@ -685,16 +696,16 @@ export class Engine {
685
696
  primitive: { cullMode: "none" },
686
697
  depthStencil: {
687
698
  format: "depth24plus-stencil8",
688
- depthWriteEnabled: true,
689
- depthCompare: "less",
699
+ depthWriteEnabled: false, // Don't write depth (already written in pre-pass)
700
+ depthCompare: "equal", // Only render where depth matches pre-pass
690
701
  stencilFront: {
691
- compare: "not-equal", // Only render where stencil != 1
702
+ compare: "equal", // Only render where stencil == 1 (over eyes)
692
703
  failOp: "keep",
693
704
  depthFailOp: "keep",
694
705
  passOp: "keep",
695
706
  },
696
707
  stencilBack: {
697
- compare: "not-equal",
708
+ compare: "equal",
698
709
  failOp: "keep",
699
710
  depthFailOp: "keep",
700
711
  passOp: "keep",
@@ -702,9 +713,9 @@ export class Engine {
702
713
  },
703
714
  multisample: { count: this.sampleCount },
704
715
  });
705
- // Eye overlay pipeline (renders after opaque, writes stencil)
706
- this.eyePipeline = this.device.createRenderPipeline({
707
- label: "eye overlay pipeline",
716
+ // Unified pipeline for hair over non-eyes (stencil != 1)
717
+ this.hairUnifiedPipelineOverNonEyes = this.device.createRenderPipeline({
718
+ label: "unified hair pipeline (over non-eyes)",
708
719
  layout: sharedPipelineLayout,
709
720
  vertex: {
710
721
  module: shaderModule,
@@ -750,19 +761,19 @@ export class Engine {
750
761
  primitive: { cullMode: "none" },
751
762
  depthStencil: {
752
763
  format: "depth24plus-stencil8",
753
- depthWriteEnabled: false, // Don't write depth
754
- depthCompare: "less", // Respect existing depth
764
+ depthWriteEnabled: false, // Don't write depth (already written in pre-pass)
765
+ depthCompare: "equal", // Only render where depth matches pre-pass
755
766
  stencilFront: {
756
- compare: "always",
767
+ compare: "not-equal", // Only render where stencil != 1 (over non-eyes)
757
768
  failOp: "keep",
758
769
  depthFailOp: "keep",
759
- passOp: "replace", // Write stencil value 1
770
+ passOp: "keep",
760
771
  },
761
772
  stencilBack: {
762
- compare: "always",
773
+ compare: "not-equal",
763
774
  failOp: "keep",
764
775
  depthFailOp: "keep",
765
- passOp: "replace",
776
+ passOp: "keep",
766
777
  },
767
778
  },
768
779
  multisample: { count: this.sampleCount },
@@ -772,31 +783,31 @@ export class Engine {
772
783
  createSkinMatrixComputePipeline() {
773
784
  const computeShader = this.device.createShaderModule({
774
785
  label: "skin matrix compute",
775
- code: /* wgsl */ `
776
- struct BoneCountUniform {
777
- count: u32,
778
- _padding1: u32,
779
- _padding2: u32,
780
- _padding3: u32,
781
- _padding4: vec4<u32>,
782
- };
783
-
784
- @group(0) @binding(0) var<uniform> boneCount: BoneCountUniform;
785
- @group(0) @binding(1) var<storage, read> worldMatrices: array<mat4x4f>;
786
- @group(0) @binding(2) var<storage, read> inverseBindMatrices: array<mat4x4f>;
787
- @group(0) @binding(3) var<storage, read_write> skinMatrices: array<mat4x4f>;
788
-
789
- @compute @workgroup_size(64)
790
- fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
791
- let boneIndex = globalId.x;
792
- // Bounds check: we dispatch workgroups (64 threads each), so some threads may be out of range
793
- if (boneIndex >= boneCount.count) {
794
- return;
795
- }
796
- let worldMat = worldMatrices[boneIndex];
797
- let invBindMat = inverseBindMatrices[boneIndex];
798
- skinMatrices[boneIndex] = worldMat * invBindMat;
799
- }
786
+ code: /* wgsl */ `
787
+ struct BoneCountUniform {
788
+ count: u32,
789
+ _padding1: u32,
790
+ _padding2: u32,
791
+ _padding3: u32,
792
+ _padding4: vec4<u32>,
793
+ };
794
+
795
+ @group(0) @binding(0) var<uniform> boneCount: BoneCountUniform;
796
+ @group(0) @binding(1) var<storage, read> worldMatrices: array<mat4x4f>;
797
+ @group(0) @binding(2) var<storage, read> inverseBindMatrices: array<mat4x4f>;
798
+ @group(0) @binding(3) var<storage, read_write> skinMatrices: array<mat4x4f>;
799
+
800
+ @compute @workgroup_size(64)
801
+ fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
802
+ let boneIndex = globalId.x;
803
+ // Bounds check: we dispatch workgroups (64 threads each), so some threads may be out of range
804
+ if (boneIndex >= boneCount.count) {
805
+ return;
806
+ }
807
+ let worldMat = worldMatrices[boneIndex];
808
+ let invBindMat = inverseBindMatrices[boneIndex];
809
+ skinMatrices[boneIndex] = worldMat * invBindMat;
810
+ }
800
811
  `,
801
812
  });
802
813
  this.skinMatrixComputePipeline = this.device.createComputePipeline({
@@ -850,143 +861,143 @@ export class Engine {
850
861
  // Bloom extraction shader (extracts bright areas)
851
862
  const bloomExtractShader = this.device.createShaderModule({
852
863
  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
- }
864
+ code: /* wgsl */ `
865
+ struct VertexOutput {
866
+ @builtin(position) position: vec4f,
867
+ @location(0) uv: vec2f,
868
+ };
869
+
870
+ @vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
871
+ var output: VertexOutput;
872
+ // Generate fullscreen quad from vertex index
873
+ let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
874
+ let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
875
+ output.position = vec4f(x, y, 0.0, 1.0);
876
+ output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
877
+ return output;
878
+ }
879
+
880
+ struct BloomExtractUniforms {
881
+ threshold: f32,
882
+ _padding1: f32,
883
+ _padding2: f32,
884
+ _padding3: f32,
885
+ _padding4: f32,
886
+ _padding5: f32,
887
+ _padding6: f32,
888
+ _padding7: f32,
889
+ };
890
+
891
+ @group(0) @binding(0) var inputTexture: texture_2d<f32>;
892
+ @group(0) @binding(1) var inputSampler: sampler;
893
+ @group(0) @binding(2) var<uniform> extractUniforms: BloomExtractUniforms;
894
+
895
+ @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
896
+ let color = textureSample(inputTexture, inputSampler, input.uv);
897
+ // Extract bright areas above threshold
898
+ let threshold = extractUniforms.threshold;
899
+ let bloom = max(vec3f(0.0), color.rgb - vec3f(threshold)) / max(0.001, 1.0 - threshold);
900
+ return vec4f(bloom, color.a);
901
+ }
891
902
  `,
892
903
  });
893
904
  // Bloom blur shader (gaussian blur - can be used for both horizontal and vertical)
894
905
  const bloomBlurShader = this.device.createShaderModule({
895
906
  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
- // 9-tap gaussian blur
926
- @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
927
- let texelSize = 1.0 / vec2f(textureDimensions(inputTexture));
928
- var result = vec4f(0.0);
929
-
930
- // Gaussian weights for 9-tap filter
931
- let weights = array<f32, 9>(
932
- 0.01621622, 0.05405405, 0.12162162,
933
- 0.19459459, 0.22702703,
934
- 0.19459459, 0.12162162, 0.05405405, 0.01621622
935
- );
936
-
937
- let offsets = array<f32, 9>(-4.0, -3.0, -2.0, -1.0, 0.0, 1.0, 2.0, 3.0, 4.0);
938
-
939
- for (var i = 0u; i < 9u; i++) {
940
- let offset = offsets[i] * texelSize * blurUniforms.direction;
941
- result += textureSample(inputTexture, inputSampler, input.uv + offset) * weights[i];
942
- }
943
-
944
- return result;
945
- }
907
+ code: /* wgsl */ `
908
+ struct VertexOutput {
909
+ @builtin(position) position: vec4f,
910
+ @location(0) uv: vec2f,
911
+ };
912
+
913
+ @vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
914
+ var output: VertexOutput;
915
+ let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
916
+ let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
917
+ output.position = vec4f(x, y, 0.0, 1.0);
918
+ output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
919
+ return output;
920
+ }
921
+
922
+ struct BlurUniforms {
923
+ direction: vec2f,
924
+ _padding1: f32,
925
+ _padding2: f32,
926
+ _padding3: f32,
927
+ _padding4: f32,
928
+ _padding5: f32,
929
+ _padding6: f32,
930
+ };
931
+
932
+ @group(0) @binding(0) var inputTexture: texture_2d<f32>;
933
+ @group(0) @binding(1) var inputSampler: sampler;
934
+ @group(0) @binding(2) var<uniform> blurUniforms: BlurUniforms;
935
+
936
+ // 9-tap gaussian blur
937
+ @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
938
+ let texelSize = 1.0 / vec2f(textureDimensions(inputTexture));
939
+ var result = vec4f(0.0);
940
+
941
+ // Gaussian weights for 9-tap filter
942
+ let weights = array<f32, 9>(
943
+ 0.01621622, 0.05405405, 0.12162162,
944
+ 0.19459459, 0.22702703,
945
+ 0.19459459, 0.12162162, 0.05405405, 0.01621622
946
+ );
947
+
948
+ let offsets = array<f32, 9>(-4.0, -3.0, -2.0, -1.0, 0.0, 1.0, 2.0, 3.0, 4.0);
949
+
950
+ for (var i = 0u; i < 9u; i++) {
951
+ let offset = offsets[i] * texelSize * blurUniforms.direction;
952
+ result += textureSample(inputTexture, inputSampler, input.uv + offset) * weights[i];
953
+ }
954
+
955
+ return result;
956
+ }
946
957
  `,
947
958
  });
948
959
  // Bloom composition shader (combines original scene with bloom)
949
960
  const bloomComposeShader = this.device.createShaderModule({
950
961
  label: "bloom compose",
951
- code: /* wgsl */ `
952
- struct VertexOutput {
953
- @builtin(position) position: vec4f,
954
- @location(0) uv: vec2f,
955
- };
956
-
957
- @vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
958
- var output: VertexOutput;
959
- let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
960
- let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
961
- output.position = vec4f(x, y, 0.0, 1.0);
962
- output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
963
- return output;
964
- }
965
-
966
- struct BloomComposeUniforms {
967
- intensity: f32,
968
- _padding1: f32,
969
- _padding2: f32,
970
- _padding3: f32,
971
- _padding4: f32,
972
- _padding5: f32,
973
- _padding6: f32,
974
- _padding7: f32,
975
- };
976
-
977
- @group(0) @binding(0) var sceneTexture: texture_2d<f32>;
978
- @group(0) @binding(1) var sceneSampler: sampler;
979
- @group(0) @binding(2) var bloomTexture: texture_2d<f32>;
980
- @group(0) @binding(3) var bloomSampler: sampler;
981
- @group(0) @binding(4) var<uniform> composeUniforms: BloomComposeUniforms;
982
-
983
- @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
984
- let scene = textureSample(sceneTexture, sceneSampler, input.uv);
985
- let bloom = textureSample(bloomTexture, bloomSampler, input.uv);
986
- // Additive blending with intensity control
987
- let result = scene.rgb + bloom.rgb * composeUniforms.intensity;
988
- return vec4f(result, scene.a);
989
- }
962
+ code: /* wgsl */ `
963
+ struct VertexOutput {
964
+ @builtin(position) position: vec4f,
965
+ @location(0) uv: vec2f,
966
+ };
967
+
968
+ @vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
969
+ var output: VertexOutput;
970
+ let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
971
+ let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
972
+ output.position = vec4f(x, y, 0.0, 1.0);
973
+ output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
974
+ return output;
975
+ }
976
+
977
+ struct BloomComposeUniforms {
978
+ intensity: f32,
979
+ _padding1: f32,
980
+ _padding2: f32,
981
+ _padding3: f32,
982
+ _padding4: f32,
983
+ _padding5: f32,
984
+ _padding6: f32,
985
+ _padding7: f32,
986
+ };
987
+
988
+ @group(0) @binding(0) var sceneTexture: texture_2d<f32>;
989
+ @group(0) @binding(1) var sceneSampler: sampler;
990
+ @group(0) @binding(2) var bloomTexture: texture_2d<f32>;
991
+ @group(0) @binding(3) var bloomSampler: sampler;
992
+ @group(0) @binding(4) var<uniform> composeUniforms: BloomComposeUniforms;
993
+
994
+ @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
995
+ let scene = textureSample(sceneTexture, sceneSampler, input.uv);
996
+ let bloom = textureSample(bloomTexture, bloomSampler, input.uv);
997
+ // Additive blending with intensity control
998
+ let result = scene.rgb + bloom.rgb * composeUniforms.intensity;
999
+ return vec4f(result, scene.a);
1000
+ }
990
1001
  `,
991
1002
  });
992
1003
  // Create uniform buffer for blur direction (minimum 32 bytes for WebGPU)
@@ -1194,9 +1205,9 @@ export class Engine {
1194
1205
  depthClearValue: 1.0,
1195
1206
  depthLoadOp: "clear",
1196
1207
  depthStoreOp: "store",
1197
- stencilClearValue: 0, // New: clear stencil to 0
1198
- stencilLoadOp: "clear", // New: clear stencil each frame
1199
- stencilStoreOp: "store", // New: store stencil
1208
+ stencilClearValue: 0,
1209
+ stencilLoadOp: "clear",
1210
+ stencilStoreOp: "discard", // Discard stencil after frame to save bandwidth (we only use it during rendering)
1200
1211
  },
1201
1212
  };
1202
1213
  this.camera.aspect = width / height;
@@ -1444,7 +1455,7 @@ export class Engine {
1444
1455
  materialUniformData[4] = this.rimLightColor[0]; // rimColor.r
1445
1456
  materialUniformData[5] = this.rimLightColor[1]; // rimColor.g
1446
1457
  materialUniformData[6] = this.rimLightColor[2]; // rimColor.b
1447
- materialUniformData[7] = 0.0; // _padding1
1458
+ materialUniformData[7] = 0.0;
1448
1459
  const materialUniformBuffer = this.device.createBuffer({
1449
1460
  label: `material uniform: ${mat.name}`,
1450
1461
  size: materialUniformData.byteLength,
@@ -1476,22 +1487,34 @@ export class Engine {
1476
1487
  });
1477
1488
  }
1478
1489
  else if (mat.isHair) {
1479
- // For hair materials, create two bind groups: one for over-eyes (alphaMultiplier = 0.5) and one for over-non-eyes (alphaMultiplier = 1.0)
1480
- const materialUniformDataOverEyes = new Float32Array(8);
1481
- materialUniformDataOverEyes[0] = materialAlpha;
1482
- materialUniformDataOverEyes[1] = 0.5; // alphaMultiplier: 0.5 for over-eyes
1483
- materialUniformDataOverEyes[2] = this.rimLightIntensity;
1484
- materialUniformDataOverEyes[3] = this.rimLightPower;
1485
- materialUniformDataOverEyes[4] = this.rimLightColor[0]; // rimColor.r
1486
- materialUniformDataOverEyes[5] = this.rimLightColor[1]; // rimColor.g
1487
- materialUniformDataOverEyes[6] = this.rimLightColor[2]; // rimColor.b
1488
- materialUniformDataOverEyes[7] = 0.0; // _padding1
1490
+ // Hair materials: create bind groups for unified pipeline with dynamic branching
1491
+ const materialUniformDataHair = new Float32Array(8);
1492
+ materialUniformDataHair[0] = materialAlpha;
1493
+ materialUniformDataHair[1] = 1.0; // alphaMultiplier: base value, shader will adjust
1494
+ materialUniformDataHair[2] = this.rimLightIntensity;
1495
+ materialUniformDataHair[3] = this.rimLightPower;
1496
+ materialUniformDataHair[4] = this.rimLightColor[0]; // rimColor.r
1497
+ materialUniformDataHair[5] = this.rimLightColor[1]; // rimColor.g
1498
+ materialUniformDataHair[6] = this.rimLightColor[2]; // rimColor.b
1499
+ materialUniformDataHair[7] = 0.0;
1500
+ // Create uniform buffers for both modes
1489
1501
  const materialUniformBufferOverEyes = this.device.createBuffer({
1490
1502
  label: `material uniform (over eyes): ${mat.name}`,
1491
- size: materialUniformDataOverEyes.byteLength,
1503
+ size: materialUniformDataHair.byteLength,
1492
1504
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1493
1505
  });
1506
+ const materialUniformDataOverEyes = new Float32Array(materialUniformDataHair);
1507
+ materialUniformDataOverEyes[7] = 1.0;
1494
1508
  this.device.queue.writeBuffer(materialUniformBufferOverEyes, 0, materialUniformDataOverEyes);
1509
+ const materialUniformBufferOverNonEyes = this.device.createBuffer({
1510
+ label: `material uniform (over non-eyes): ${mat.name}`,
1511
+ size: materialUniformDataHair.byteLength,
1512
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1513
+ });
1514
+ const materialUniformDataOverNonEyes = new Float32Array(materialUniformDataHair);
1515
+ materialUniformDataOverNonEyes[7] = 0.0;
1516
+ this.device.queue.writeBuffer(materialUniformBufferOverNonEyes, 0, materialUniformDataOverNonEyes);
1517
+ // Create bind groups for both modes
1495
1518
  const bindGroupOverEyes = this.device.createBindGroup({
1496
1519
  label: `material bind group (over eyes): ${mat.name}`,
1497
1520
  layout: this.hairBindGroupLayout,
@@ -1506,28 +1529,6 @@ export class Engine {
1506
1529
  { binding: 7, resource: { buffer: materialUniformBufferOverEyes } },
1507
1530
  ],
1508
1531
  });
1509
- this.hairDrawsOverEyes.push({
1510
- count: matCount,
1511
- firstIndex: runningFirstIndex,
1512
- bindGroup: bindGroupOverEyes,
1513
- isTransparent,
1514
- });
1515
- // Create material uniform for hair over non-eyes (alphaMultiplier = 1.0)
1516
- const materialUniformDataOverNonEyes = new Float32Array(8);
1517
- materialUniformDataOverNonEyes[0] = materialAlpha;
1518
- materialUniformDataOverNonEyes[1] = 1.0; // alphaMultiplier: 1.0 for over-non-eyes
1519
- materialUniformDataOverNonEyes[2] = this.rimLightIntensity;
1520
- materialUniformDataOverNonEyes[3] = this.rimLightPower;
1521
- materialUniformDataOverNonEyes[4] = this.rimLightColor[0]; // rimColor.r
1522
- materialUniformDataOverNonEyes[5] = this.rimLightColor[1]; // rimColor.g
1523
- materialUniformDataOverNonEyes[6] = this.rimLightColor[2]; // rimColor.b
1524
- materialUniformDataOverNonEyes[7] = 0.0; // _padding1
1525
- const materialUniformBufferOverNonEyes = this.device.createBuffer({
1526
- label: `material uniform (over non-eyes): ${mat.name}`,
1527
- size: materialUniformDataOverNonEyes.byteLength,
1528
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1529
- });
1530
- this.device.queue.writeBuffer(materialUniformBufferOverNonEyes, 0, materialUniformDataOverNonEyes);
1531
1532
  const bindGroupOverNonEyes = this.device.createBindGroup({
1532
1533
  label: `material bind group (over non-eyes): ${mat.name}`,
1533
1534
  layout: this.hairBindGroupLayout,
@@ -1542,6 +1543,13 @@ export class Engine {
1542
1543
  { binding: 7, resource: { buffer: materialUniformBufferOverNonEyes } },
1543
1544
  ],
1544
1545
  });
1546
+ // Store both bind groups for unified pipeline
1547
+ this.hairDrawsOverEyes.push({
1548
+ count: matCount,
1549
+ firstIndex: runningFirstIndex,
1550
+ bindGroup: bindGroupOverEyes,
1551
+ isTransparent,
1552
+ });
1545
1553
  this.hairDrawsOverNonEyes.push({
1546
1554
  count: matCount,
1547
1555
  firstIndex: runningFirstIndex,
@@ -1568,11 +1576,14 @@ export class Engine {
1568
1576
  // Outline for all materials (including transparent) - Edge flag is at bit 4 (0x10) in PMX format, not bit 0 (0x01)
1569
1577
  if ((mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
1570
1578
  const materialUniformData = new Float32Array(8);
1571
- materialUniformData[0] = mat.edgeColor[0];
1572
- materialUniformData[1] = mat.edgeColor[1];
1573
- materialUniformData[2] = mat.edgeColor[2];
1574
- materialUniformData[3] = mat.edgeColor[3];
1579
+ materialUniformData[0] = mat.edgeColor[0]; // edgeColor.r
1580
+ materialUniformData[1] = mat.edgeColor[1]; // edgeColor.g
1581
+ materialUniformData[2] = mat.edgeColor[2]; // edgeColor.b
1582
+ materialUniformData[3] = mat.edgeColor[3]; // edgeColor.a
1575
1583
  materialUniformData[4] = mat.edgeSize;
1584
+ materialUniformData[5] = 0.0; // isOverEyes: 0.0 for all (unified pipeline doesn't use stencil)
1585
+ materialUniformData[6] = 0.0; // _padding1
1586
+ materialUniformData[7] = 0.0; // _padding2
1576
1587
  const materialUniformBuffer = this.device.createBuffer({
1577
1588
  label: `outline material uniform: ${mat.name}`,
1578
1589
  size: materialUniformData.byteLength,
@@ -1686,8 +1697,7 @@ export class Engine {
1686
1697
  pass.setVertexBuffer(2, this.weightsBuffer);
1687
1698
  pass.setIndexBuffer(this.indexBuffer, "uint32");
1688
1699
  this.drawCallCount = 0;
1689
- // PASS 1: Opaque non-eye, non-hair (face, body, etc)
1690
- // this.drawOutlines(pass, false) // Opaque outlines
1700
+ // PASS 1: Opaque non-eye, non-hair
1691
1701
  pass.setPipeline(this.pipeline);
1692
1702
  for (const draw of this.opaqueNonEyeNonHairDraws) {
1693
1703
  if (draw.count > 0) {
@@ -1706,27 +1716,29 @@ export class Engine {
1706
1716
  this.drawCallCount++;
1707
1717
  }
1708
1718
  }
1709
- // PASS 3: Hair rendering - optimized single pass approach
1710
- // Since both hair passes use the same shader, we batch them together
1711
- // but still need separate passes due to stencil requirements (equal vs not-equal)
1712
- this.drawOutlines(pass, false); // Opaque outlines
1713
- // 3a: Hair over eyes (stencil == 1, alphaMultiplier = 0.5)
1714
- if (this.hairDrawsOverEyes.length > 0) {
1715
- pass.setPipeline(this.hairMultiplyPipeline);
1716
- pass.setStencilReference(1);
1719
+ // PASS 3: Hair rendering with depth pre-pass and unified pipeline
1720
+ this.drawOutlines(pass, false);
1721
+ // 3a: Hair depth pre-pass (eliminates overdraw by rejecting fragments early)
1722
+ if (this.hairDrawsOverEyes.length > 0 || this.hairDrawsOverNonEyes.length > 0) {
1723
+ pass.setPipeline(this.hairDepthPipeline);
1717
1724
  for (const draw of this.hairDrawsOverEyes) {
1718
1725
  if (draw.count > 0) {
1719
1726
  pass.setBindGroup(0, draw.bindGroup);
1720
1727
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1721
- this.drawCallCount++;
1728
+ }
1729
+ }
1730
+ for (const draw of this.hairDrawsOverNonEyes) {
1731
+ if (draw.count > 0) {
1732
+ pass.setBindGroup(0, draw.bindGroup);
1733
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1722
1734
  }
1723
1735
  }
1724
1736
  }
1725
- // 3b: Hair over non-eyes (stencil != 1, alphaMultiplier = 1.0)
1726
- if (this.hairDrawsOverNonEyes.length > 0) {
1727
- pass.setPipeline(this.hairOpaquePipeline);
1737
+ // 3b: Hair shading pass with unified pipeline and dynamic branching
1738
+ if (this.hairDrawsOverEyes.length > 0) {
1739
+ pass.setPipeline(this.hairUnifiedPipelineOverEyes);
1728
1740
  pass.setStencilReference(1);
1729
- for (const draw of this.hairDrawsOverNonEyes) {
1741
+ for (const draw of this.hairDrawsOverEyes) {
1730
1742
  if (draw.count > 0) {
1731
1743
  pass.setBindGroup(0, draw.bindGroup);
1732
1744
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
@@ -1734,20 +1746,20 @@ export class Engine {
1734
1746
  }
1735
1747
  }
1736
1748
  }
1737
- // 3c: Hair outlines - batched together, only draw if outlines exist
1738
- if (this.hairOutlineDraws.length > 0) {
1739
- // Over eyes
1740
- pass.setPipeline(this.hairOutlineOverEyesPipeline);
1749
+ if (this.hairDrawsOverNonEyes.length > 0) {
1750
+ pass.setPipeline(this.hairUnifiedPipelineOverNonEyes);
1741
1751
  pass.setStencilReference(1);
1742
- for (const draw of this.hairOutlineDraws) {
1752
+ for (const draw of this.hairDrawsOverNonEyes) {
1743
1753
  if (draw.count > 0) {
1744
1754
  pass.setBindGroup(0, draw.bindGroup);
1745
1755
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1756
+ this.drawCallCount++;
1746
1757
  }
1747
1758
  }
1748
- // Over non-eyes
1749
- pass.setPipeline(this.hairOutlinePipeline);
1750
- pass.setStencilReference(1);
1759
+ }
1760
+ // 3c: Hair outlines - unified single pass without stencil testing
1761
+ if (this.hairOutlineDraws.length > 0) {
1762
+ pass.setPipeline(this.hairUnifiedOutlinePipeline);
1751
1763
  for (const draw of this.hairOutlineDraws) {
1752
1764
  if (draw.count > 0) {
1753
1765
  pass.setBindGroup(0, draw.bindGroup);
@@ -1764,7 +1776,7 @@ export class Engine {
1764
1776
  this.drawCallCount++;
1765
1777
  }
1766
1778
  }
1767
- this.drawOutlines(pass, true); // Transparent outlines
1779
+ this.drawOutlines(pass, true);
1768
1780
  pass.end();
1769
1781
  this.device.queue.submit([encoder.finish()]);
1770
1782
  // Apply bloom post-processing