reze-engine 0.1.15 → 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,131 +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
- 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
- // Dynamic branching: adjust alpha based on whether we're over eyes
193
- // This allows single-pass hair rendering instead of two separate passes
194
- var finalAlpha = material.alpha * material.alphaMultiplier;
195
- if (material.isOverEyes > 0.5) {
196
- finalAlpha *= 0.5; // Hair over eyes gets 50% alpha
197
- }
198
-
199
- if (finalAlpha < 0.001) {
200
- discard;
201
- }
202
-
203
- return vec4f(clamp(color, vec3f(0.0), vec3f(1.0)), finalAlpha);
204
- }
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
+ }
205
203
  `,
206
204
  });
207
205
  // Create explicit bind group layout for all pipelines using the main shader
208
- // This ensures compatibility across all pipelines (main, eye, hair multiply, hair opaque)
209
206
  this.hairBindGroupLayout = this.device.createBindGroupLayout({
210
207
  label: "shared material bind group layout",
211
208
  entries: [
@@ -293,79 +290,77 @@ export class Engine {
293
290
  });
294
291
  const outlineShaderModule = this.device.createShaderModule({
295
292
  label: "outline shaders",
296
- code: /* wgsl */ `
297
- struct CameraUniforms {
298
- view: mat4x4f,
299
- projection: mat4x4f,
300
- viewPos: vec3f,
301
- _padding: f32,
302
- };
303
-
304
- struct MaterialUniforms {
305
- edgeColor: vec4f,
306
- edgeSize: f32,
307
- isOverEyes: f32, // 1.0 if rendering over eyes, 0.0 otherwise (for hair outlines)
308
- _padding1: f32,
309
- _padding2: f32,
310
- };
311
-
312
- @group(0) @binding(0) var<uniform> camera: CameraUniforms;
313
- @group(0) @binding(1) var<uniform> material: MaterialUniforms;
314
- @group(0) @binding(2) var<storage, read> skinMats: array<mat4x4f>;
315
-
316
- struct VertexOutput {
317
- @builtin(position) position: vec4f,
318
- };
319
-
320
- @vertex fn vs(
321
- @location(0) position: vec3f,
322
- @location(1) normal: vec3f,
323
- @location(3) joints0: vec4<u32>,
324
- @location(4) weights0: vec4<f32>
325
- ) -> VertexOutput {
326
- var output: VertexOutput;
327
- let pos4 = vec4f(position, 1.0);
328
-
329
- // Normalize weights to ensure they sum to 1.0 (handles floating-point precision issues)
330
- let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
331
- var normalizedWeights: vec4f;
332
- if (weightSum > 0.0001) {
333
- normalizedWeights = weights0 / weightSum;
334
- } else {
335
- normalizedWeights = vec4f(1.0, 0.0, 0.0, 0.0);
336
- }
337
-
338
- var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
339
- var skinnedNrm = vec3f(0.0, 0.0, 0.0);
340
- for (var i = 0u; i < 4u; i++) {
341
- let j = joints0[i];
342
- let w = normalizedWeights[i];
343
- let m = skinMats[j];
344
- skinnedPos += (m * pos4) * w;
345
- let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
346
- skinnedNrm += (r3 * normal) * w;
347
- }
348
- let worldPos = skinnedPos.xyz;
349
- let worldNormal = normalize(skinnedNrm);
350
-
351
- // MMD invert hull: expand vertices outward along normals
352
- let scaleFactor = 0.01;
353
- let expandedPos = worldPos + worldNormal * material.edgeSize * scaleFactor;
354
- output.position = camera.projection * camera.view * vec4f(expandedPos, 1.0);
355
- return output;
356
- }
357
-
358
- @fragment fn fs() -> @location(0) vec4f {
359
- var color = material.edgeColor;
360
-
361
- // Dynamic branching: adjust alpha for hair outlines over eyes
362
- // This allows single-pass outline rendering instead of two separate passes
363
- if (material.isOverEyes > 0.5) {
364
- color.a *= 0.5; // Hair outlines over eyes get 50% alpha
365
- }
366
-
367
- return color;
368
- }
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
+ }
369
364
  `,
370
365
  });
371
366
  this.outlinePipeline = this.device.createRenderPipeline({
@@ -431,165 +426,7 @@ export class Engine {
431
426
  count: this.sampleCount,
432
427
  },
433
428
  });
434
- // 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
435
- this.hairOutlinePipeline = this.device.createRenderPipeline({
436
- label: "hair outline pipeline",
437
- layout: outlinePipelineLayout,
438
- vertex: {
439
- module: outlineShaderModule,
440
- buffers: [
441
- {
442
- arrayStride: 8 * 4,
443
- attributes: [
444
- {
445
- shaderLocation: 0,
446
- offset: 0,
447
- format: "float32x3",
448
- },
449
- {
450
- shaderLocation: 1,
451
- offset: 3 * 4,
452
- format: "float32x3",
453
- },
454
- ],
455
- },
456
- {
457
- arrayStride: 4 * 2,
458
- attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" }],
459
- },
460
- {
461
- arrayStride: 4,
462
- attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" }],
463
- },
464
- ],
465
- },
466
- fragment: {
467
- module: outlineShaderModule,
468
- targets: [
469
- {
470
- format: this.presentationFormat,
471
- blend: {
472
- color: {
473
- srcFactor: "src-alpha",
474
- dstFactor: "one-minus-src-alpha",
475
- operation: "add",
476
- },
477
- alpha: {
478
- srcFactor: "one",
479
- dstFactor: "one-minus-src-alpha",
480
- operation: "add",
481
- },
482
- },
483
- },
484
- ],
485
- },
486
- primitive: {
487
- cullMode: "back",
488
- },
489
- depthStencil: {
490
- format: "depth24plus-stencil8",
491
- depthWriteEnabled: false, // Don't write depth - let hair geometry control depth
492
- depthCompare: "less-equal", // Only draw where hair depth exists
493
- stencilFront: {
494
- compare: "not-equal", // Only render where stencil != 1 (not over eyes)
495
- failOp: "keep",
496
- depthFailOp: "keep",
497
- passOp: "keep",
498
- },
499
- stencilBack: {
500
- compare: "not-equal",
501
- failOp: "keep",
502
- depthFailOp: "keep",
503
- passOp: "keep",
504
- },
505
- },
506
- multisample: {
507
- count: this.sampleCount,
508
- },
509
- });
510
- // 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
511
- this.hairOutlineOverEyesPipeline = this.device.createRenderPipeline({
512
- label: "hair outline over eyes pipeline",
513
- layout: outlinePipelineLayout,
514
- vertex: {
515
- module: outlineShaderModule,
516
- buffers: [
517
- {
518
- arrayStride: 8 * 4,
519
- attributes: [
520
- {
521
- shaderLocation: 0,
522
- offset: 0,
523
- format: "float32x3",
524
- },
525
- {
526
- shaderLocation: 1,
527
- offset: 3 * 4,
528
- format: "float32x3",
529
- },
530
- ],
531
- },
532
- {
533
- arrayStride: 4 * 2,
534
- attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" }],
535
- },
536
- {
537
- arrayStride: 4,
538
- attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" }],
539
- },
540
- ],
541
- },
542
- fragment: {
543
- module: outlineShaderModule,
544
- targets: [
545
- {
546
- format: this.presentationFormat,
547
- blend: {
548
- color: {
549
- srcFactor: "src-alpha",
550
- dstFactor: "one-minus-src-alpha",
551
- operation: "add",
552
- },
553
- alpha: {
554
- srcFactor: "one",
555
- dstFactor: "one-minus-src-alpha",
556
- operation: "add",
557
- },
558
- },
559
- },
560
- ],
561
- },
562
- primitive: {
563
- cullMode: "back",
564
- },
565
- depthStencil: {
566
- format: "depth24plus-stencil8",
567
- depthWriteEnabled: false, // Don't write depth
568
- depthCompare: "less-equal", // Draw where outline depth <= existing depth (hair depth)
569
- depthBias: -0.0001, // Small negative bias to bring outline slightly closer for depth test
570
- depthBiasSlopeScale: 0.0,
571
- depthBiasClamp: 0.0,
572
- stencilFront: {
573
- compare: "equal", // Only render where stencil == 1 (over eyes)
574
- failOp: "keep",
575
- depthFailOp: "keep",
576
- passOp: "keep",
577
- },
578
- stencilBack: {
579
- compare: "equal",
580
- failOp: "keep",
581
- depthFailOp: "keep",
582
- passOp: "keep",
583
- },
584
- },
585
- multisample: {
586
- count: this.sampleCount,
587
- },
588
- });
589
- // Unified hair outline pipeline: single pass without stencil testing
590
- // Uses depth test "less-equal" to draw everywhere hair exists
591
- // Shader branches on isOverEyes uniform to adjust alpha dynamically
592
- // This eliminates the need for two separate outline passes
429
+ // Unified hair outline pipeline: single pass without stencil testing, uses depth test "less-equal" to draw everywhere hair exists
593
430
  this.hairUnifiedOutlinePipeline = this.device.createRenderPipeline({
594
431
  label: "unified hair outline pipeline",
595
432
  layout: outlinePipelineLayout,
@@ -656,137 +493,6 @@ export class Engine {
656
493
  count: this.sampleCount,
657
494
  },
658
495
  });
659
- // Unified hair pipeline - can be used for both over-eyes and over-non-eyes
660
- // The difference is controlled by stencil state and alpha multiplier in material uniform
661
- this.hairMultiplyPipeline = this.device.createRenderPipeline({
662
- label: "hair pipeline (over eyes)",
663
- layout: sharedPipelineLayout,
664
- vertex: {
665
- module: shaderModule,
666
- buffers: [
667
- {
668
- arrayStride: 8 * 4,
669
- attributes: [
670
- { shaderLocation: 0, offset: 0, format: "float32x3" },
671
- { shaderLocation: 1, offset: 3 * 4, format: "float32x3" },
672
- { shaderLocation: 2, offset: 6 * 4, format: "float32x2" },
673
- ],
674
- },
675
- {
676
- arrayStride: 4 * 2,
677
- attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" }],
678
- },
679
- {
680
- arrayStride: 4,
681
- attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" }],
682
- },
683
- ],
684
- },
685
- fragment: {
686
- module: shaderModule,
687
- targets: [
688
- {
689
- format: this.presentationFormat,
690
- blend: {
691
- color: {
692
- srcFactor: "src-alpha",
693
- dstFactor: "one-minus-src-alpha",
694
- operation: "add",
695
- },
696
- alpha: {
697
- srcFactor: "one",
698
- dstFactor: "one-minus-src-alpha",
699
- operation: "add",
700
- },
701
- },
702
- },
703
- ],
704
- },
705
- primitive: { cullMode: "none" },
706
- depthStencil: {
707
- format: "depth24plus-stencil8",
708
- depthWriteEnabled: true, // Write depth so outlines can test against it
709
- depthCompare: "less",
710
- stencilFront: {
711
- compare: "equal", // Only render where stencil == 1
712
- failOp: "keep",
713
- depthFailOp: "keep",
714
- passOp: "keep",
715
- },
716
- stencilBack: {
717
- compare: "equal",
718
- failOp: "keep",
719
- depthFailOp: "keep",
720
- passOp: "keep",
721
- },
722
- },
723
- multisample: { count: this.sampleCount },
724
- });
725
- // Hair pipeline for opaque rendering (hair over non-eyes) - uses same shader, different stencil state
726
- this.hairOpaquePipeline = this.device.createRenderPipeline({
727
- label: "hair pipeline (over non-eyes)",
728
- layout: sharedPipelineLayout,
729
- vertex: {
730
- module: shaderModule,
731
- buffers: [
732
- {
733
- arrayStride: 8 * 4,
734
- attributes: [
735
- { shaderLocation: 0, offset: 0, format: "float32x3" },
736
- { shaderLocation: 1, offset: 3 * 4, format: "float32x3" },
737
- { shaderLocation: 2, offset: 6 * 4, format: "float32x2" },
738
- ],
739
- },
740
- {
741
- arrayStride: 4 * 2,
742
- attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" }],
743
- },
744
- {
745
- arrayStride: 4,
746
- attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" }],
747
- },
748
- ],
749
- },
750
- fragment: {
751
- module: shaderModule,
752
- targets: [
753
- {
754
- format: this.presentationFormat,
755
- blend: {
756
- color: {
757
- srcFactor: "src-alpha",
758
- dstFactor: "one-minus-src-alpha",
759
- operation: "add",
760
- },
761
- alpha: {
762
- srcFactor: "one",
763
- dstFactor: "one-minus-src-alpha",
764
- operation: "add",
765
- },
766
- },
767
- },
768
- ],
769
- },
770
- primitive: { cullMode: "none" },
771
- depthStencil: {
772
- format: "depth24plus-stencil8",
773
- depthWriteEnabled: true,
774
- depthCompare: "less",
775
- stencilFront: {
776
- compare: "not-equal", // Only render where stencil != 1
777
- failOp: "keep",
778
- depthFailOp: "keep",
779
- passOp: "keep",
780
- },
781
- stencilBack: {
782
- compare: "not-equal",
783
- failOp: "keep",
784
- depthFailOp: "keep",
785
- passOp: "keep",
786
- },
787
- },
788
- multisample: { count: this.sampleCount },
789
- });
790
496
  // Eye overlay pipeline (renders after opaque, writes stencil)
791
497
  this.eyePipeline = this.device.createRenderPipeline({
792
498
  label: "eye overlay pipeline",
@@ -855,57 +561,52 @@ export class Engine {
855
561
  // Depth-only shader for hair pre-pass (reduces overdraw by early depth rejection)
856
562
  const depthOnlyShaderModule = this.device.createShaderModule({
857
563
  label: "depth only shader",
858
- code: /* wgsl */ `
859
- struct CameraUniforms {
860
- view: mat4x4f,
861
- projection: mat4x4f,
862
- viewPos: vec3f,
863
- _padding: f32,
864
- };
865
-
866
- @group(0) @binding(0) var<uniform> camera: CameraUniforms;
867
- @group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
868
-
869
- @vertex fn vs(
870
- @location(0) position: vec3f,
871
- @location(1) normal: vec3f,
872
- @location(3) joints0: vec4<u32>,
873
- @location(4) weights0: vec4<f32>
874
- ) -> @builtin(position) vec4f {
875
- let pos4 = vec4f(position, 1.0);
876
-
877
- // Normalize weights
878
- let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
879
- var normalizedWeights: vec4f;
880
- if (weightSum > 0.0001) {
881
- normalizedWeights = weights0 / weightSum;
882
- } else {
883
- normalizedWeights = vec4f(1.0, 0.0, 0.0, 0.0);
884
- }
885
-
886
- var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
887
- for (var i = 0u; i < 4u; i++) {
888
- let j = joints0[i];
889
- let w = normalizedWeights[i];
890
- let m = skinMats[j];
891
- skinnedPos += (m * pos4) * w;
892
- }
893
- let worldPos = skinnedPos.xyz;
894
- let clipPos = camera.projection * camera.view * vec4f(worldPos, 1.0);
895
- return clipPos;
896
- }
897
-
898
- // Minimal fragment shader - returns transparent, no color writes (writeMask: 0)
899
- // Required because render pass has color attachments
900
- // Depth is still written even though we don't write color
901
- @fragment fn fs() -> @location(0) vec4f {
902
- return vec4f(0.0, 0.0, 0.0, 0.0); // Transparent - color writes disabled via writeMask
903
- }
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
+ }
904
607
  `,
905
608
  });
906
- // Hair depth pre-pass pipeline (depth-only, no color writes)
907
- // This eliminates most overdraw by rejecting fragments early before expensive shading
908
- // Note: Must have a color target to match render pass, but we disable all color writes
609
+ // Hair depth pre-pass pipeline: depth-only with color writes disabled to eliminate overdraw
909
610
  this.hairDepthPipeline = this.device.createRenderPipeline({
910
611
  label: "hair depth pre-pass",
911
612
  layout: sharedPipelineLayout,
@@ -947,11 +648,7 @@ export class Engine {
947
648
  },
948
649
  multisample: { count: this.sampleCount },
949
650
  });
950
- // Unified hair pipeline: single pass with dynamic branching in shader
951
- // Uses stencil testing to filter fragments, then shader branches on isOverEyes uniform
952
- // This eliminates the need for separate pipelines - same shader, different stencil states
953
- // We create two variants: one for over-eyes (stencil == 1) and one for over-non-eyes (stencil != 1)
954
- // Unified pipeline for hair over eyes (stencil == 1)
651
+ // Unified hair pipeline for over-eyes (stencil == 1): single pass with dynamic branching
955
652
  this.hairUnifiedPipelineOverEyes = this.device.createRenderPipeline({
956
653
  label: "unified hair pipeline (over eyes)",
957
654
  layout: sharedPipelineLayout,
@@ -1086,31 +783,31 @@ export class Engine {
1086
783
  createSkinMatrixComputePipeline() {
1087
784
  const computeShader = this.device.createShaderModule({
1088
785
  label: "skin matrix compute",
1089
- code: /* wgsl */ `
1090
- struct BoneCountUniform {
1091
- count: u32,
1092
- _padding1: u32,
1093
- _padding2: u32,
1094
- _padding3: u32,
1095
- _padding4: vec4<u32>,
1096
- };
1097
-
1098
- @group(0) @binding(0) var<uniform> boneCount: BoneCountUniform;
1099
- @group(0) @binding(1) var<storage, read> worldMatrices: array<mat4x4f>;
1100
- @group(0) @binding(2) var<storage, read> inverseBindMatrices: array<mat4x4f>;
1101
- @group(0) @binding(3) var<storage, read_write> skinMatrices: array<mat4x4f>;
1102
-
1103
- @compute @workgroup_size(64)
1104
- fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
1105
- let boneIndex = globalId.x;
1106
- // Bounds check: we dispatch workgroups (64 threads each), so some threads may be out of range
1107
- if (boneIndex >= boneCount.count) {
1108
- return;
1109
- }
1110
- let worldMat = worldMatrices[boneIndex];
1111
- let invBindMat = inverseBindMatrices[boneIndex];
1112
- skinMatrices[boneIndex] = worldMat * invBindMat;
1113
- }
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
+ }
1114
811
  `,
1115
812
  });
1116
813
  this.skinMatrixComputePipeline = this.device.createComputePipeline({
@@ -1164,143 +861,143 @@ export class Engine {
1164
861
  // Bloom extraction shader (extracts bright areas)
1165
862
  const bloomExtractShader = this.device.createShaderModule({
1166
863
  label: "bloom extract",
1167
- code: /* wgsl */ `
1168
- struct VertexOutput {
1169
- @builtin(position) position: vec4f,
1170
- @location(0) uv: vec2f,
1171
- };
1172
-
1173
- @vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
1174
- var output: VertexOutput;
1175
- // Generate fullscreen quad from vertex index
1176
- let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
1177
- let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
1178
- output.position = vec4f(x, y, 0.0, 1.0);
1179
- output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
1180
- return output;
1181
- }
1182
-
1183
- struct BloomExtractUniforms {
1184
- threshold: f32,
1185
- _padding1: f32,
1186
- _padding2: f32,
1187
- _padding3: f32,
1188
- _padding4: f32,
1189
- _padding5: f32,
1190
- _padding6: f32,
1191
- _padding7: f32,
1192
- };
1193
-
1194
- @group(0) @binding(0) var inputTexture: texture_2d<f32>;
1195
- @group(0) @binding(1) var inputSampler: sampler;
1196
- @group(0) @binding(2) var<uniform> extractUniforms: BloomExtractUniforms;
1197
-
1198
- @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
1199
- let color = textureSample(inputTexture, inputSampler, input.uv);
1200
- // Extract bright areas above threshold
1201
- let threshold = extractUniforms.threshold;
1202
- let bloom = max(vec3f(0.0), color.rgb - vec3f(threshold)) / max(0.001, 1.0 - threshold);
1203
- return vec4f(bloom, color.a);
1204
- }
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
+ }
1205
902
  `,
1206
903
  });
1207
904
  // Bloom blur shader (gaussian blur - can be used for both horizontal and vertical)
1208
905
  const bloomBlurShader = this.device.createShaderModule({
1209
906
  label: "bloom blur",
1210
- code: /* wgsl */ `
1211
- struct VertexOutput {
1212
- @builtin(position) position: vec4f,
1213
- @location(0) uv: vec2f,
1214
- };
1215
-
1216
- @vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
1217
- var output: VertexOutput;
1218
- let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
1219
- let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
1220
- output.position = vec4f(x, y, 0.0, 1.0);
1221
- output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
1222
- return output;
1223
- }
1224
-
1225
- struct BlurUniforms {
1226
- direction: vec2f,
1227
- _padding1: f32,
1228
- _padding2: f32,
1229
- _padding3: f32,
1230
- _padding4: f32,
1231
- _padding5: f32,
1232
- _padding6: f32,
1233
- };
1234
-
1235
- @group(0) @binding(0) var inputTexture: texture_2d<f32>;
1236
- @group(0) @binding(1) var inputSampler: sampler;
1237
- @group(0) @binding(2) var<uniform> blurUniforms: BlurUniforms;
1238
-
1239
- // 9-tap gaussian blur
1240
- @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
1241
- let texelSize = 1.0 / vec2f(textureDimensions(inputTexture));
1242
- var result = vec4f(0.0);
1243
-
1244
- // Gaussian weights for 9-tap filter
1245
- let weights = array<f32, 9>(
1246
- 0.01621622, 0.05405405, 0.12162162,
1247
- 0.19459459, 0.22702703,
1248
- 0.19459459, 0.12162162, 0.05405405, 0.01621622
1249
- );
1250
-
1251
- let offsets = array<f32, 9>(-4.0, -3.0, -2.0, -1.0, 0.0, 1.0, 2.0, 3.0, 4.0);
1252
-
1253
- for (var i = 0u; i < 9u; i++) {
1254
- let offset = offsets[i] * texelSize * blurUniforms.direction;
1255
- result += textureSample(inputTexture, inputSampler, input.uv + offset) * weights[i];
1256
- }
1257
-
1258
- return result;
1259
- }
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
+ }
1260
957
  `,
1261
958
  });
1262
959
  // Bloom composition shader (combines original scene with bloom)
1263
960
  const bloomComposeShader = this.device.createShaderModule({
1264
961
  label: "bloom compose",
1265
- code: /* wgsl */ `
1266
- struct VertexOutput {
1267
- @builtin(position) position: vec4f,
1268
- @location(0) uv: vec2f,
1269
- };
1270
-
1271
- @vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
1272
- var output: VertexOutput;
1273
- let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
1274
- let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
1275
- output.position = vec4f(x, y, 0.0, 1.0);
1276
- output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
1277
- return output;
1278
- }
1279
-
1280
- struct BloomComposeUniforms {
1281
- intensity: f32,
1282
- _padding1: f32,
1283
- _padding2: f32,
1284
- _padding3: f32,
1285
- _padding4: f32,
1286
- _padding5: f32,
1287
- _padding6: f32,
1288
- _padding7: f32,
1289
- };
1290
-
1291
- @group(0) @binding(0) var sceneTexture: texture_2d<f32>;
1292
- @group(0) @binding(1) var sceneSampler: sampler;
1293
- @group(0) @binding(2) var bloomTexture: texture_2d<f32>;
1294
- @group(0) @binding(3) var bloomSampler: sampler;
1295
- @group(0) @binding(4) var<uniform> composeUniforms: BloomComposeUniforms;
1296
-
1297
- @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
1298
- let scene = textureSample(sceneTexture, sceneSampler, input.uv);
1299
- let bloom = textureSample(bloomTexture, bloomSampler, input.uv);
1300
- // Additive blending with intensity control
1301
- let result = scene.rgb + bloom.rgb * composeUniforms.intensity;
1302
- return vec4f(result, scene.a);
1303
- }
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
+ }
1304
1001
  `,
1305
1002
  });
1306
1003
  // Create uniform buffer for blur direction (minimum 32 bytes for WebGPU)
@@ -1508,9 +1205,9 @@ export class Engine {
1508
1205
  depthClearValue: 1.0,
1509
1206
  depthLoadOp: "clear",
1510
1207
  depthStoreOp: "store",
1511
- stencilClearValue: 0, // New: clear stencil to 0
1512
- stencilLoadOp: "clear", // New: clear stencil each frame
1513
- 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)
1514
1211
  },
1515
1212
  };
1516
1213
  this.camera.aspect = width / height;
@@ -1758,7 +1455,7 @@ export class Engine {
1758
1455
  materialUniformData[4] = this.rimLightColor[0]; // rimColor.r
1759
1456
  materialUniformData[5] = this.rimLightColor[1]; // rimColor.g
1760
1457
  materialUniformData[6] = this.rimLightColor[2]; // rimColor.b
1761
- materialUniformData[7] = 0.0; // isOverEyes: 0.0 for non-hair materials
1458
+ materialUniformData[7] = 0.0;
1762
1459
  const materialUniformBuffer = this.device.createBuffer({
1763
1460
  label: `material uniform: ${mat.name}`,
1764
1461
  size: materialUniformData.byteLength,
@@ -1790,9 +1487,7 @@ export class Engine {
1790
1487
  });
1791
1488
  }
1792
1489
  else if (mat.isHair) {
1793
- // For hair materials, create a single bind group that will be used with the unified pipeline
1794
- // The shader will dynamically branch based on isOverEyes uniform
1795
- // We still need two uniform buffers (one for each render mode) but can reuse the same bind group structure
1490
+ // Hair materials: create bind groups for unified pipeline with dynamic branching
1796
1491
  const materialUniformDataHair = new Float32Array(8);
1797
1492
  materialUniformDataHair[0] = materialAlpha;
1798
1493
  materialUniformDataHair[1] = 1.0; // alphaMultiplier: base value, shader will adjust
@@ -1801,15 +1496,15 @@ export class Engine {
1801
1496
  materialUniformDataHair[4] = this.rimLightColor[0]; // rimColor.r
1802
1497
  materialUniformDataHair[5] = this.rimLightColor[1]; // rimColor.g
1803
1498
  materialUniformDataHair[6] = this.rimLightColor[2]; // rimColor.b
1804
- materialUniformDataHair[7] = 0.0; // isOverEyes: will be set per draw call
1805
- // Create uniform buffers for both modes (we'll update them per frame)
1499
+ materialUniformDataHair[7] = 0.0;
1500
+ // Create uniform buffers for both modes
1806
1501
  const materialUniformBufferOverEyes = this.device.createBuffer({
1807
1502
  label: `material uniform (over eyes): ${mat.name}`,
1808
1503
  size: materialUniformDataHair.byteLength,
1809
1504
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1810
1505
  });
1811
1506
  const materialUniformDataOverEyes = new Float32Array(materialUniformDataHair);
1812
- materialUniformDataOverEyes[7] = 1.0; // isOverEyes = 1.0
1507
+ materialUniformDataOverEyes[7] = 1.0;
1813
1508
  this.device.queue.writeBuffer(materialUniformBufferOverEyes, 0, materialUniformDataOverEyes);
1814
1509
  const materialUniformBufferOverNonEyes = this.device.createBuffer({
1815
1510
  label: `material uniform (over non-eyes): ${mat.name}`,
@@ -1817,9 +1512,9 @@ export class Engine {
1817
1512
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1818
1513
  });
1819
1514
  const materialUniformDataOverNonEyes = new Float32Array(materialUniformDataHair);
1820
- materialUniformDataOverNonEyes[7] = 0.0; // isOverEyes = 0.0
1515
+ materialUniformDataOverNonEyes[7] = 0.0;
1821
1516
  this.device.queue.writeBuffer(materialUniformBufferOverNonEyes, 0, materialUniformDataOverNonEyes);
1822
- // Create bind groups for both modes (they share everything except the uniform buffer)
1517
+ // Create bind groups for both modes
1823
1518
  const bindGroupOverEyes = this.device.createBindGroup({
1824
1519
  label: `material bind group (over eyes): ${mat.name}`,
1825
1520
  layout: this.hairBindGroupLayout,
@@ -1848,7 +1543,7 @@ export class Engine {
1848
1543
  { binding: 7, resource: { buffer: materialUniformBufferOverNonEyes } },
1849
1544
  ],
1850
1545
  });
1851
- // Store both bind groups - we'll use them with the unified pipeline
1546
+ // Store both bind groups for unified pipeline
1852
1547
  this.hairDrawsOverEyes.push({
1853
1548
  count: matCount,
1854
1549
  firstIndex: runningFirstIndex,
@@ -1886,7 +1581,7 @@ export class Engine {
1886
1581
  materialUniformData[2] = mat.edgeColor[2]; // edgeColor.b
1887
1582
  materialUniformData[3] = mat.edgeColor[3]; // edgeColor.a
1888
1583
  materialUniformData[4] = mat.edgeSize;
1889
- materialUniformData[5] = mat.isHair ? 0.0 : 0.0; // isOverEyes: 0.0 for all (unified pipeline doesn't use stencil)
1584
+ materialUniformData[5] = 0.0; // isOverEyes: 0.0 for all (unified pipeline doesn't use stencil)
1890
1585
  materialUniformData[6] = 0.0; // _padding1
1891
1586
  materialUniformData[7] = 0.0; // _padding2
1892
1587
  const materialUniformBuffer = this.device.createBuffer({
@@ -2002,8 +1697,7 @@ export class Engine {
2002
1697
  pass.setVertexBuffer(2, this.weightsBuffer);
2003
1698
  pass.setIndexBuffer(this.indexBuffer, "uint32");
2004
1699
  this.drawCallCount = 0;
2005
- // PASS 1: Opaque non-eye, non-hair (face, body, etc)
2006
- // this.drawOutlines(pass, false) // Opaque outlines
1700
+ // PASS 1: Opaque non-eye, non-hair
2007
1701
  pass.setPipeline(this.pipeline);
2008
1702
  for (const draw of this.opaqueNonEyeNonHairDraws) {
2009
1703
  if (draw.count > 0) {
@@ -2022,18 +1716,13 @@ export class Engine {
2022
1716
  this.drawCallCount++;
2023
1717
  }
2024
1718
  }
2025
- // PASS 3: Hair rendering - optimized with depth pre-pass and unified pipeline
2026
- // Depth pre-pass: render hair depth-only to eliminate overdraw early
2027
- // Then render shaded hair once with depth test "equal" to only shade visible fragments
2028
- this.drawOutlines(pass, false); // Opaque outlines
2029
- // 3a: Hair depth pre-pass (depth-only, no color writes)
2030
- // This eliminates most overdraw by rejecting fragments early before expensive shading
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)
2031
1722
  if (this.hairDrawsOverEyes.length > 0 || this.hairDrawsOverNonEyes.length > 0) {
2032
1723
  pass.setPipeline(this.hairDepthPipeline);
2033
- // Render all hair materials for depth (no stencil test needed for depth pass)
2034
1724
  for (const draw of this.hairDrawsOverEyes) {
2035
1725
  if (draw.count > 0) {
2036
- // Use the same bind group structure (camera, skin matrices) for depth pass
2037
1726
  pass.setBindGroup(0, draw.bindGroup);
2038
1727
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
2039
1728
  }
@@ -2045,10 +1734,7 @@ export class Engine {
2045
1734
  }
2046
1735
  }
2047
1736
  }
2048
- // 3b: Hair shading pass - unified pipeline with dynamic branching
2049
- // Uses depth test "equal" to only render where depth was written in pre-pass
2050
- // Shader branches on isOverEyes uniform to adjust alpha dynamically
2051
- // This eliminates one full geometry pass compared to the old approach
1737
+ // 3b: Hair shading pass with unified pipeline and dynamic branching
2052
1738
  if (this.hairDrawsOverEyes.length > 0) {
2053
1739
  pass.setPipeline(this.hairUnifiedPipelineOverEyes);
2054
1740
  pass.setStencilReference(1);
@@ -2072,9 +1758,6 @@ export class Engine {
2072
1758
  }
2073
1759
  }
2074
1760
  // 3c: Hair outlines - unified single pass without stencil testing
2075
- // Uses depth test "less-equal" to draw everywhere hair exists
2076
- // Shader branches on isOverEyes uniform to adjust alpha dynamically (currently always 0.0)
2077
- // This eliminates the need for two separate outline passes
2078
1761
  if (this.hairOutlineDraws.length > 0) {
2079
1762
  pass.setPipeline(this.hairUnifiedOutlinePipeline);
2080
1763
  for (const draw of this.hairOutlineDraws) {
@@ -2093,7 +1776,7 @@ export class Engine {
2093
1776
  this.drawCallCount++;
2094
1777
  }
2095
1778
  }
2096
- this.drawOutlines(pass, true); // Transparent outlines
1779
+ this.drawOutlines(pass, true);
2097
1780
  pass.end();
2098
1781
  this.device.queue.submit([encoder.finish()]);
2099
1782
  // Apply bloom post-processing