reze-engine 0.1.5 → 0.1.7

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/src/engine.ts CHANGED
@@ -28,6 +28,13 @@ export class Engine {
28
28
  private depthTexture!: GPUTexture
29
29
  private pipeline!: GPURenderPipeline
30
30
  private outlinePipeline!: GPURenderPipeline
31
+ private hairOutlinePipeline!: GPURenderPipeline
32
+ private hairOutlineOverEyesPipeline!: GPURenderPipeline
33
+ private hairMultiplyPipeline!: GPURenderPipeline
34
+ private hairOpaquePipeline!: GPURenderPipeline
35
+ private eyePipeline!: GPURenderPipeline
36
+ private hairBindGroupLayout!: GPUBindGroupLayout
37
+ private outlineBindGroupLayout!: GPUBindGroupLayout
31
38
  private jointsBuffer!: GPUBuffer
32
39
  private weightsBuffer!: GPUBuffer
33
40
  private skinMatrixBuffer?: GPUBuffer
@@ -212,10 +219,146 @@ export class Engine {
212
219
  `,
213
220
  })
214
221
 
222
+ // Create a separate shader for hair-over-eyes that outputs pre-multiplied color for darkening effect
223
+ const hairMultiplyShaderModule = this.device.createShaderModule({
224
+ label: "hair multiply shaders",
225
+ code: /* wgsl */ `
226
+ struct CameraUniforms {
227
+ view: mat4x4f,
228
+ projection: mat4x4f,
229
+ viewPos: vec3f,
230
+ _padding: f32,
231
+ };
232
+
233
+ struct Light {
234
+ direction: vec3f,
235
+ _padding1: f32,
236
+ color: vec3f,
237
+ intensity: f32,
238
+ };
239
+
240
+ struct LightUniforms {
241
+ ambient: f32,
242
+ lightCount: f32,
243
+ _padding1: f32,
244
+ _padding2: f32,
245
+ lights: array<Light, 4>,
246
+ };
247
+
248
+ struct MaterialUniforms {
249
+ alpha: f32,
250
+ _padding1: f32,
251
+ _padding2: f32,
252
+ _padding3: f32,
253
+ };
254
+
255
+ struct VertexOutput {
256
+ @builtin(position) position: vec4f,
257
+ @location(0) normal: vec3f,
258
+ @location(1) uv: vec2f,
259
+ @location(2) worldPos: vec3f,
260
+ };
261
+
262
+ @group(0) @binding(0) var<uniform> camera: CameraUniforms;
263
+ @group(0) @binding(1) var<uniform> light: LightUniforms;
264
+ @group(0) @binding(2) var diffuseTexture: texture_2d<f32>;
265
+ @group(0) @binding(3) var diffuseSampler: sampler;
266
+ @group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
267
+ @group(0) @binding(5) var toonTexture: texture_2d<f32>;
268
+ @group(0) @binding(6) var toonSampler: sampler;
269
+ @group(0) @binding(7) var<uniform> material: MaterialUniforms;
270
+
271
+ @vertex fn vs(
272
+ @location(0) position: vec3f,
273
+ @location(1) normal: vec3f,
274
+ @location(2) uv: vec2f,
275
+ @location(3) joints0: vec4<u32>,
276
+ @location(4) weights0: vec4<f32>
277
+ ) -> VertexOutput {
278
+ var output: VertexOutput;
279
+ let pos4 = vec4f(position, 1.0);
280
+
281
+ let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
282
+ var normalizedWeights: vec4f;
283
+ if (weightSum > 0.0001) {
284
+ normalizedWeights = weights0 / weightSum;
285
+ } else {
286
+ normalizedWeights = vec4f(1.0, 0.0, 0.0, 0.0);
287
+ }
288
+
289
+ var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
290
+ var skinnedNrm = vec3f(0.0, 0.0, 0.0);
291
+ for (var i = 0u; i < 4u; i++) {
292
+ let j = joints0[i];
293
+ let w = normalizedWeights[i];
294
+ let m = skinMats[j];
295
+ skinnedPos += (m * pos4) * w;
296
+ let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
297
+ skinnedNrm += (r3 * normal) * w;
298
+ }
299
+ let worldPos = skinnedPos.xyz;
300
+ output.position = camera.projection * camera.view * vec4f(worldPos, 1.0);
301
+ output.normal = normalize(skinnedNrm);
302
+ output.uv = uv;
303
+ output.worldPos = worldPos;
304
+ return output;
305
+ }
306
+
307
+ @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
308
+ let n = normalize(input.normal);
309
+ let albedo = textureSample(diffuseTexture, diffuseSampler, input.uv).rgb;
310
+
311
+ var lightAccum = vec3f(light.ambient);
312
+ let numLights = u32(light.lightCount);
313
+ for (var i = 0u; i < numLights; i++) {
314
+ let l = -light.lights[i].direction;
315
+ let nDotL = max(dot(n, l), 0.0);
316
+ let toonUV = vec2f(nDotL, 0.5);
317
+ let toonFactor = textureSample(toonTexture, toonSampler, toonUV).rgb;
318
+ let radiance = light.lights[i].color * light.lights[i].intensity;
319
+ lightAccum += toonFactor * radiance * nDotL;
320
+ }
321
+
322
+ let color = albedo * lightAccum;
323
+ let finalAlpha = material.alpha;
324
+ if (finalAlpha < 0.001) {
325
+ discard;
326
+ }
327
+
328
+ // For hair-over-eyes effect: simple half-transparent overlay
329
+ // Use 50% opacity to create a semi-transparent hair color overlay
330
+ let overlayAlpha = finalAlpha * 0.5;
331
+
332
+ return vec4f(clamp(color, vec3f(0.0), vec3f(1.0)), overlayAlpha);
333
+ }
334
+ `,
335
+ })
336
+
337
+ // Create explicit bind group layout for all pipelines using the main shader
338
+ // This ensures compatibility across all pipelines (main, eye, hair multiply, hair opaque)
339
+ this.hairBindGroupLayout = this.device.createBindGroupLayout({
340
+ label: "shared material bind group layout",
341
+ entries: [
342
+ { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // camera
343
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // light
344
+ { binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: {} }, // diffuseTexture
345
+ { binding: 3, visibility: GPUShaderStage.FRAGMENT, sampler: {} }, // diffuseSampler
346
+ { binding: 4, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } }, // skinMats
347
+ { binding: 5, visibility: GPUShaderStage.FRAGMENT, texture: {} }, // toonTexture
348
+ { binding: 6, visibility: GPUShaderStage.FRAGMENT, sampler: {} }, // toonSampler
349
+ { binding: 7, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // material
350
+ ],
351
+ })
352
+
353
+ const sharedPipelineLayout = this.device.createPipelineLayout({
354
+ label: "shared pipeline layout",
355
+ bindGroupLayouts: [this.hairBindGroupLayout],
356
+ })
357
+
215
358
  // Single pipeline for all materials with alpha blending
216
359
  this.pipeline = this.device.createRenderPipeline({
217
360
  label: "model pipeline",
218
- layout: "auto",
361
+ layout: sharedPipelineLayout,
219
362
  vertex: {
220
363
  module: shaderModule,
221
364
  buffers: [
@@ -259,7 +402,7 @@ export class Engine {
259
402
  },
260
403
  primitive: { cullMode: "none" },
261
404
  depthStencil: {
262
- format: "depth24plus",
405
+ format: "depth24plus-stencil8",
263
406
  depthWriteEnabled: true,
264
407
  depthCompare: "less",
265
408
  },
@@ -268,6 +411,21 @@ export class Engine {
268
411
  },
269
412
  })
270
413
 
414
+ // Create bind group layout for outline pipelines
415
+ this.outlineBindGroupLayout = this.device.createBindGroupLayout({
416
+ label: "outline bind group layout",
417
+ entries: [
418
+ { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // camera
419
+ { binding: 1, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // material
420
+ { binding: 2, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } }, // skinMats
421
+ ],
422
+ })
423
+
424
+ const outlinePipelineLayout = this.device.createPipelineLayout({
425
+ label: "outline pipeline layout",
426
+ bindGroupLayouts: [this.outlineBindGroupLayout],
427
+ })
428
+
271
429
  const outlineShaderModule = this.device.createShaderModule({
272
430
  label: "outline shaders",
273
431
  code: /* wgsl */ `
@@ -341,7 +499,7 @@ export class Engine {
341
499
 
342
500
  this.outlinePipeline = this.device.createRenderPipeline({
343
501
  label: "outline pipeline",
344
- layout: "auto",
502
+ layout: outlinePipelineLayout,
345
503
  vertex: {
346
504
  module: outlineShaderModule,
347
505
  buffers: [
@@ -399,7 +557,7 @@ export class Engine {
399
557
  cullMode: "back",
400
558
  },
401
559
  depthStencil: {
402
- format: "depth24plus",
560
+ format: "depth24plus-stencil8",
403
561
  depthWriteEnabled: true,
404
562
  depthCompare: "less",
405
563
  },
@@ -407,6 +565,376 @@ export class Engine {
407
565
  count: this.sampleCount,
408
566
  },
409
567
  })
568
+
569
+ // Hair outline pipeline: draws hair outlines over non-eyes (stencil != 1)
570
+ // Drawn after hair geometry, so depth testing ensures outlines only appear where hair exists
571
+ this.hairOutlinePipeline = this.device.createRenderPipeline({
572
+ label: "hair outline pipeline",
573
+ layout: outlinePipelineLayout,
574
+ vertex: {
575
+ module: outlineShaderModule,
576
+ buffers: [
577
+ {
578
+ arrayStride: 8 * 4,
579
+ attributes: [
580
+ {
581
+ shaderLocation: 0,
582
+ offset: 0,
583
+ format: "float32x3" as GPUVertexFormat,
584
+ },
585
+ {
586
+ shaderLocation: 1,
587
+ offset: 3 * 4,
588
+ format: "float32x3" as GPUVertexFormat,
589
+ },
590
+ {
591
+ shaderLocation: 2,
592
+ offset: 6 * 4,
593
+ format: "float32x2" as GPUVertexFormat,
594
+ },
595
+ ],
596
+ },
597
+ {
598
+ arrayStride: 4 * 2,
599
+ attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" as GPUVertexFormat }],
600
+ },
601
+ {
602
+ arrayStride: 4,
603
+ attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" as GPUVertexFormat }],
604
+ },
605
+ ],
606
+ },
607
+ fragment: {
608
+ module: outlineShaderModule,
609
+ targets: [
610
+ {
611
+ format: this.presentationFormat,
612
+ blend: {
613
+ color: {
614
+ srcFactor: "src-alpha",
615
+ dstFactor: "one-minus-src-alpha",
616
+ operation: "add",
617
+ },
618
+ alpha: {
619
+ srcFactor: "one",
620
+ dstFactor: "one-minus-src-alpha",
621
+ operation: "add",
622
+ },
623
+ },
624
+ },
625
+ ],
626
+ },
627
+ primitive: {
628
+ cullMode: "back",
629
+ },
630
+ depthStencil: {
631
+ format: "depth24plus-stencil8",
632
+ depthWriteEnabled: false, // Don't write depth - let hair geometry control depth
633
+ depthCompare: "less-equal", // Only draw where hair depth exists
634
+ stencilFront: {
635
+ compare: "not-equal", // Only render where stencil != 1 (not over eyes)
636
+ failOp: "keep",
637
+ depthFailOp: "keep",
638
+ passOp: "keep",
639
+ },
640
+ stencilBack: {
641
+ compare: "not-equal",
642
+ failOp: "keep",
643
+ depthFailOp: "keep",
644
+ passOp: "keep",
645
+ },
646
+ },
647
+ multisample: {
648
+ count: this.sampleCount,
649
+ },
650
+ })
651
+
652
+ // Hair outline pipeline for over eyes: draws where stencil == 1, but only where hair depth exists
653
+ // Uses depth compare "equal" with a small bias to only appear where hair geometry exists
654
+ this.hairOutlineOverEyesPipeline = this.device.createRenderPipeline({
655
+ label: "hair outline over eyes pipeline",
656
+ layout: outlinePipelineLayout,
657
+ vertex: {
658
+ module: outlineShaderModule,
659
+ buffers: [
660
+ {
661
+ arrayStride: 8 * 4,
662
+ attributes: [
663
+ {
664
+ shaderLocation: 0,
665
+ offset: 0,
666
+ format: "float32x3" as GPUVertexFormat,
667
+ },
668
+ {
669
+ shaderLocation: 1,
670
+ offset: 3 * 4,
671
+ format: "float32x3" as GPUVertexFormat,
672
+ },
673
+ {
674
+ shaderLocation: 2,
675
+ offset: 6 * 4,
676
+ format: "float32x2" as GPUVertexFormat,
677
+ },
678
+ ],
679
+ },
680
+ {
681
+ arrayStride: 4 * 2,
682
+ attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" as GPUVertexFormat }],
683
+ },
684
+ {
685
+ arrayStride: 4,
686
+ attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" as GPUVertexFormat }],
687
+ },
688
+ ],
689
+ },
690
+ fragment: {
691
+ module: outlineShaderModule,
692
+ targets: [
693
+ {
694
+ format: this.presentationFormat,
695
+ blend: {
696
+ color: {
697
+ srcFactor: "src-alpha",
698
+ dstFactor: "one-minus-src-alpha",
699
+ operation: "add",
700
+ },
701
+ alpha: {
702
+ srcFactor: "one",
703
+ dstFactor: "one-minus-src-alpha",
704
+ operation: "add",
705
+ },
706
+ },
707
+ },
708
+ ],
709
+ },
710
+ primitive: {
711
+ cullMode: "back",
712
+ },
713
+ depthStencil: {
714
+ format: "depth24plus-stencil8",
715
+ depthWriteEnabled: false, // Don't write depth
716
+
717
+ depthCompare: "less-equal", // Draw where outline depth <= existing depth (hair depth)
718
+ depthBias: -0.0001, // Small negative bias to bring outline slightly closer for depth test
719
+ depthBiasSlopeScale: 0.0,
720
+ depthBiasClamp: 0.0,
721
+ stencilFront: {
722
+ compare: "equal", // Only render where stencil == 1 (over eyes)
723
+ failOp: "keep",
724
+ depthFailOp: "keep",
725
+ passOp: "keep",
726
+ },
727
+ stencilBack: {
728
+ compare: "equal",
729
+ failOp: "keep",
730
+ depthFailOp: "keep",
731
+ passOp: "keep",
732
+ },
733
+ },
734
+ multisample: {
735
+ count: this.sampleCount,
736
+ },
737
+ })
738
+
739
+ // Hair pipeline with multiplicative blending (for hair over eyes)
740
+ this.hairMultiplyPipeline = this.device.createRenderPipeline({
741
+ label: "hair multiply pipeline",
742
+ layout: sharedPipelineLayout,
743
+ vertex: {
744
+ module: hairMultiplyShaderModule,
745
+ buffers: [
746
+ {
747
+ arrayStride: 8 * 4,
748
+ attributes: [
749
+ { shaderLocation: 0, offset: 0, format: "float32x3" as GPUVertexFormat },
750
+ { shaderLocation: 1, offset: 3 * 4, format: "float32x3" as GPUVertexFormat },
751
+ { shaderLocation: 2, offset: 6 * 4, format: "float32x2" as GPUVertexFormat },
752
+ ],
753
+ },
754
+ {
755
+ arrayStride: 4 * 2,
756
+ attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" as GPUVertexFormat }],
757
+ },
758
+ {
759
+ arrayStride: 4,
760
+ attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" as GPUVertexFormat }],
761
+ },
762
+ ],
763
+ },
764
+ fragment: {
765
+ module: hairMultiplyShaderModule,
766
+ targets: [
767
+ {
768
+ format: this.presentationFormat,
769
+ blend: {
770
+ color: {
771
+ // Simple half-transparent overlay effect
772
+ // Blend: hairColor * overlayAlpha + eyeColor * (1 - overlayAlpha)
773
+ srcFactor: "src-alpha",
774
+ dstFactor: "one-minus-src-alpha",
775
+ operation: "add",
776
+ },
777
+ alpha: {
778
+ srcFactor: "one",
779
+ dstFactor: "one-minus-src-alpha",
780
+ operation: "add",
781
+ },
782
+ },
783
+ },
784
+ ],
785
+ },
786
+ primitive: { cullMode: "none" },
787
+ depthStencil: {
788
+ format: "depth24plus-stencil8",
789
+ depthWriteEnabled: true, // Write depth so outlines can test against it
790
+ depthCompare: "less",
791
+ stencilFront: {
792
+ compare: "equal", // Only render where stencil == 1
793
+ failOp: "keep",
794
+ depthFailOp: "keep",
795
+ passOp: "keep",
796
+ },
797
+ stencilBack: {
798
+ compare: "equal",
799
+ failOp: "keep",
800
+ depthFailOp: "keep",
801
+ passOp: "keep",
802
+ },
803
+ },
804
+ multisample: { count: this.sampleCount },
805
+ })
806
+
807
+ // Hair pipeline for opaque rendering (hair over non-eyes)
808
+ this.hairOpaquePipeline = this.device.createRenderPipeline({
809
+ label: "hair opaque pipeline",
810
+ layout: sharedPipelineLayout,
811
+ vertex: {
812
+ module: shaderModule,
813
+ buffers: [
814
+ {
815
+ arrayStride: 8 * 4,
816
+ attributes: [
817
+ { shaderLocation: 0, offset: 0, format: "float32x3" as GPUVertexFormat },
818
+ { shaderLocation: 1, offset: 3 * 4, format: "float32x3" as GPUVertexFormat },
819
+ { shaderLocation: 2, offset: 6 * 4, format: "float32x2" as GPUVertexFormat },
820
+ ],
821
+ },
822
+ {
823
+ arrayStride: 4 * 2,
824
+ attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" as GPUVertexFormat }],
825
+ },
826
+ {
827
+ arrayStride: 4,
828
+ attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" as GPUVertexFormat }],
829
+ },
830
+ ],
831
+ },
832
+ fragment: {
833
+ module: shaderModule,
834
+ targets: [
835
+ {
836
+ format: this.presentationFormat,
837
+ blend: {
838
+ color: {
839
+ srcFactor: "src-alpha",
840
+ dstFactor: "one-minus-src-alpha",
841
+ operation: "add",
842
+ },
843
+ alpha: {
844
+ srcFactor: "one",
845
+ dstFactor: "one-minus-src-alpha",
846
+ operation: "add",
847
+ },
848
+ },
849
+ },
850
+ ],
851
+ },
852
+ primitive: { cullMode: "none" },
853
+ depthStencil: {
854
+ format: "depth24plus-stencil8",
855
+ depthWriteEnabled: true,
856
+ depthCompare: "less",
857
+ stencilFront: {
858
+ compare: "not-equal", // Only render where stencil != 1
859
+ failOp: "keep",
860
+ depthFailOp: "keep",
861
+ passOp: "keep",
862
+ },
863
+ stencilBack: {
864
+ compare: "not-equal",
865
+ failOp: "keep",
866
+ depthFailOp: "keep",
867
+ passOp: "keep",
868
+ },
869
+ },
870
+ multisample: { count: this.sampleCount },
871
+ })
872
+
873
+ // Eye overlay pipeline (renders after opaque, writes stencil)
874
+ this.eyePipeline = this.device.createRenderPipeline({
875
+ label: "eye overlay pipeline",
876
+ layout: sharedPipelineLayout,
877
+ vertex: {
878
+ module: shaderModule,
879
+ buffers: [
880
+ {
881
+ arrayStride: 8 * 4,
882
+ attributes: [
883
+ { shaderLocation: 0, offset: 0, format: "float32x3" as GPUVertexFormat },
884
+ { shaderLocation: 1, offset: 3 * 4, format: "float32x3" as GPUVertexFormat },
885
+ { shaderLocation: 2, offset: 6 * 4, format: "float32x2" as GPUVertexFormat },
886
+ ],
887
+ },
888
+ {
889
+ arrayStride: 4 * 2,
890
+ attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" as GPUVertexFormat }],
891
+ },
892
+ {
893
+ arrayStride: 4,
894
+ attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" as GPUVertexFormat }],
895
+ },
896
+ ],
897
+ },
898
+ fragment: {
899
+ module: shaderModule,
900
+ targets: [
901
+ {
902
+ format: this.presentationFormat,
903
+ blend: {
904
+ color: {
905
+ srcFactor: "src-alpha",
906
+ dstFactor: "one-minus-src-alpha",
907
+ operation: "add",
908
+ },
909
+ alpha: {
910
+ srcFactor: "one",
911
+ dstFactor: "one-minus-src-alpha",
912
+ operation: "add",
913
+ },
914
+ },
915
+ },
916
+ ],
917
+ },
918
+ primitive: { cullMode: "none" },
919
+ depthStencil: {
920
+ format: "depth24plus-stencil8",
921
+ depthWriteEnabled: false, // Don't write depth
922
+ depthCompare: "less", // Respect existing depth
923
+ stencilFront: {
924
+ compare: "always",
925
+ failOp: "keep",
926
+ depthFailOp: "keep",
927
+ passOp: "replace", // Write stencil value 1
928
+ },
929
+ stencilBack: {
930
+ compare: "always",
931
+ failOp: "keep",
932
+ depthFailOp: "keep",
933
+ passOp: "replace",
934
+ },
935
+ },
936
+ multisample: { count: this.sampleCount },
937
+ })
410
938
  }
411
939
 
412
940
  // Create compute shader for skin matrix computation
@@ -481,7 +1009,7 @@ export class Engine {
481
1009
  label: "depth texture",
482
1010
  size: [width, height],
483
1011
  sampleCount: this.sampleCount,
484
- format: "depth24plus",
1012
+ format: "depth24plus-stencil8",
485
1013
  usage: GPUTextureUsage.RENDER_ATTACHMENT,
486
1014
  })
487
1015
 
@@ -511,6 +1039,9 @@ export class Engine {
511
1039
  depthClearValue: 1.0,
512
1040
  depthLoadOp: "clear",
513
1041
  depthStoreOp: "store",
1042
+ stencilClearValue: 0, // New: clear stencil to 0
1043
+ stencilLoadOp: "clear", // New: clear stencil each frame
1044
+ stencilStoreOp: "store", // New: store stencil
514
1045
  },
515
1046
  }
516
1047
 
@@ -722,8 +1253,35 @@ export class Engine {
722
1253
  await this.setupMaterials(model)
723
1254
  }
724
1255
 
725
- private materialDraws: { count: number; firstIndex: number; bindGroup: GPUBindGroup; isTransparent: boolean }[] = []
726
- private outlineDraws: { count: number; firstIndex: number; bindGroup: GPUBindGroup; isTransparent: boolean }[] = []
1256
+ private opaqueNonEyeNonHairDraws: {
1257
+ count: number
1258
+ firstIndex: number
1259
+ bindGroup: GPUBindGroup
1260
+ isTransparent: boolean
1261
+ }[] = []
1262
+ private eyeDraws: { count: number; firstIndex: number; bindGroup: GPUBindGroup; isTransparent: boolean }[] = []
1263
+ private hairDraws: { count: number; firstIndex: number; bindGroup: GPUBindGroup; isTransparent: boolean }[] = []
1264
+ private transparentNonEyeNonHairDraws: {
1265
+ count: number
1266
+ firstIndex: number
1267
+ bindGroup: GPUBindGroup
1268
+ isTransparent: boolean
1269
+ }[] = []
1270
+ private opaqueNonEyeNonHairOutlineDraws: {
1271
+ count: number
1272
+ firstIndex: number
1273
+ bindGroup: GPUBindGroup
1274
+ isTransparent: boolean
1275
+ }[] = []
1276
+ private eyeOutlineDraws: { count: number; firstIndex: number; bindGroup: GPUBindGroup; isTransparent: boolean }[] = []
1277
+ private hairOutlineDraws: { count: number; firstIndex: number; bindGroup: GPUBindGroup; isTransparent: boolean }[] =
1278
+ []
1279
+ private transparentNonEyeNonHairOutlineDraws: {
1280
+ count: number
1281
+ firstIndex: number
1282
+ bindGroup: GPUBindGroup
1283
+ isTransparent: boolean
1284
+ }[] = []
727
1285
 
728
1286
  // Step 8: Load textures and create material bind groups
729
1287
  private async setupMaterials(model: Model) {
@@ -783,9 +1341,14 @@ export class Engine {
783
1341
  return defaultToonTexture
784
1342
  }
785
1343
 
786
- this.materialDraws = []
787
- this.outlineDraws = []
788
- const outlineBindGroupLayout = this.outlinePipeline.getBindGroupLayout(0)
1344
+ this.opaqueNonEyeNonHairDraws = []
1345
+ this.eyeDraws = []
1346
+ this.hairDraws = []
1347
+ this.transparentNonEyeNonHairDraws = []
1348
+ this.opaqueNonEyeNonHairOutlineDraws = []
1349
+ this.eyeOutlineDraws = []
1350
+ this.hairOutlineDraws = []
1351
+ this.transparentNonEyeNonHairOutlineDraws = []
789
1352
  let runningFirstIndex = 0
790
1353
 
791
1354
  for (const mat of materials) {
@@ -814,9 +1377,11 @@ export class Engine {
814
1377
  })
815
1378
  this.device.queue.writeBuffer(materialUniformBuffer, 0, materialUniformData)
816
1379
 
1380
+ // Create bind groups using the shared bind group layout
1381
+ // All pipelines (main, eye, hair multiply, hair opaque) use the same shader and layout
817
1382
  const bindGroup = this.device.createBindGroup({
818
1383
  label: `material bind group: ${mat.name}`,
819
- layout: this.pipeline.getBindGroupLayout(0),
1384
+ layout: this.hairBindGroupLayout,
820
1385
  entries: [
821
1386
  { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
822
1387
  { binding: 1, resource: { buffer: this.lightUniformBuffer } },
@@ -829,13 +1394,36 @@ export class Engine {
829
1394
  ],
830
1395
  })
831
1396
 
832
- // All materials use the same pipeline
833
- this.materialDraws.push({
834
- count: matCount,
835
- firstIndex: runningFirstIndex,
836
- bindGroup,
837
- isTransparent,
838
- })
1397
+ // Classify materials into appropriate draw lists
1398
+ if (mat.isEye) {
1399
+ this.eyeDraws.push({
1400
+ count: matCount,
1401
+ firstIndex: runningFirstIndex,
1402
+ bindGroup,
1403
+ isTransparent,
1404
+ })
1405
+ } else if (mat.isHair) {
1406
+ this.hairDraws.push({
1407
+ count: matCount,
1408
+ firstIndex: runningFirstIndex,
1409
+ bindGroup,
1410
+ isTransparent,
1411
+ })
1412
+ } else if (isTransparent) {
1413
+ this.transparentNonEyeNonHairDraws.push({
1414
+ count: matCount,
1415
+ firstIndex: runningFirstIndex,
1416
+ bindGroup,
1417
+ isTransparent,
1418
+ })
1419
+ } else {
1420
+ this.opaqueNonEyeNonHairDraws.push({
1421
+ count: matCount,
1422
+ firstIndex: runningFirstIndex,
1423
+ bindGroup,
1424
+ isTransparent,
1425
+ })
1426
+ }
839
1427
 
840
1428
  // Outline for all materials (including transparent)
841
1429
  // Edge flag is at bit 4 (0x10) in PMX format, not bit 0 (0x01)
@@ -856,7 +1444,7 @@ export class Engine {
856
1444
 
857
1445
  const outlineBindGroup = this.device.createBindGroup({
858
1446
  label: `outline bind group: ${mat.name}`,
859
- layout: outlineBindGroupLayout,
1447
+ layout: this.outlineBindGroupLayout,
860
1448
  entries: [
861
1449
  { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
862
1450
  { binding: 1, resource: { buffer: materialUniformBuffer } },
@@ -864,13 +1452,36 @@ export class Engine {
864
1452
  ],
865
1453
  })
866
1454
 
867
- // All outlines use the same pipeline
868
- this.outlineDraws.push({
869
- count: matCount,
870
- firstIndex: runningFirstIndex,
871
- bindGroup: outlineBindGroup,
872
- isTransparent,
873
- })
1455
+ // Classify outlines into appropriate draw lists
1456
+ if (mat.isEye) {
1457
+ this.eyeOutlineDraws.push({
1458
+ count: matCount,
1459
+ firstIndex: runningFirstIndex,
1460
+ bindGroup: outlineBindGroup,
1461
+ isTransparent,
1462
+ })
1463
+ } else if (mat.isHair) {
1464
+ this.hairOutlineDraws.push({
1465
+ count: matCount,
1466
+ firstIndex: runningFirstIndex,
1467
+ bindGroup: outlineBindGroup,
1468
+ isTransparent,
1469
+ })
1470
+ } else if (isTransparent) {
1471
+ this.transparentNonEyeNonHairOutlineDraws.push({
1472
+ count: matCount,
1473
+ firstIndex: runningFirstIndex,
1474
+ bindGroup: outlineBindGroup,
1475
+ isTransparent,
1476
+ })
1477
+ } else {
1478
+ this.opaqueNonEyeNonHairOutlineDraws.push({
1479
+ count: matCount,
1480
+ firstIndex: runningFirstIndex,
1481
+ bindGroup: outlineBindGroup,
1482
+ isTransparent,
1483
+ })
1484
+ }
874
1485
  }
875
1486
 
876
1487
  runningFirstIndex += matCount
@@ -948,10 +1559,89 @@ export class Engine {
948
1559
  pass.setIndexBuffer(this.indexBuffer!, "uint32")
949
1560
 
950
1561
  this.drawCallCount = 0
951
- this.drawOutlines(pass, false)
952
- this.drawModel(pass, false)
953
- this.drawModel(pass, true)
954
- this.drawOutlines(pass, true)
1562
+
1563
+ // === PASS 1: Opaque non-eye, non-hair (face, body, etc) ===
1564
+ // this.drawOutlines(pass, false) // Opaque outlines
1565
+
1566
+ pass.setPipeline(this.pipeline)
1567
+ for (const draw of this.opaqueNonEyeNonHairDraws) {
1568
+ if (draw.count > 0) {
1569
+ pass.setBindGroup(0, draw.bindGroup)
1570
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1571
+ this.drawCallCount++
1572
+ }
1573
+ }
1574
+
1575
+ // === PASS 2: Eyes (writes stencil = 1) ===
1576
+ pass.setPipeline(this.eyePipeline)
1577
+ pass.setStencilReference(1) // Set stencil reference value to 1
1578
+ for (const draw of this.eyeDraws) {
1579
+ if (draw.count > 0) {
1580
+ pass.setBindGroup(0, draw.bindGroup)
1581
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1582
+ this.drawCallCount++
1583
+ }
1584
+ }
1585
+
1586
+ // === PASS 3a: Hair over eyes (stencil == 1, multiply blend) ===
1587
+ // Draw hair geometry first to establish depth
1588
+ pass.setPipeline(this.hairMultiplyPipeline)
1589
+ pass.setStencilReference(1) // Check against stencil value 1
1590
+ for (const draw of this.hairDraws) {
1591
+ if (draw.count > 0) {
1592
+ pass.setBindGroup(0, draw.bindGroup)
1593
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1594
+ this.drawCallCount++
1595
+ }
1596
+ }
1597
+
1598
+ // === PASS 3a.5: Hair outlines over eyes (stencil == 1, depth test to only draw near hair) ===
1599
+ // Use depth compare "less-equal" with the hair depth to only draw outline where hair exists
1600
+ // The outline is expanded outward, so we need to ensure it only appears near the hair edge
1601
+ pass.setPipeline(this.hairOutlineOverEyesPipeline)
1602
+ pass.setStencilReference(1) // Check against stencil value 1 (with equal test)
1603
+ for (const draw of this.hairOutlineDraws) {
1604
+ if (draw.count > 0) {
1605
+ pass.setBindGroup(0, draw.bindGroup)
1606
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1607
+ }
1608
+ }
1609
+
1610
+ // === PASS 3b: Hair over non-eyes (stencil != 1, opaque) ===
1611
+ pass.setPipeline(this.hairOpaquePipeline)
1612
+ pass.setStencilReference(1) // Check against stencil value 1 (with not-equal test)
1613
+ for (const draw of this.hairDraws) {
1614
+ if (draw.count > 0) {
1615
+ pass.setBindGroup(0, draw.bindGroup)
1616
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1617
+ this.drawCallCount++
1618
+ }
1619
+ }
1620
+
1621
+ // === PASS 3b.5: Hair outlines over non-eyes (stencil != 1) ===
1622
+ // Draw hair outlines after hair geometry, so they only appear where hair exists
1623
+ pass.setPipeline(this.hairOutlinePipeline)
1624
+ pass.setStencilReference(1) // Check against stencil value 1 (with not-equal test)
1625
+ for (const draw of this.hairOutlineDraws) {
1626
+ if (draw.count > 0) {
1627
+ pass.setBindGroup(0, draw.bindGroup)
1628
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1629
+ }
1630
+ }
1631
+
1632
+ this.drawOutlines(pass, false) // Opaque outlines
1633
+
1634
+ // === PASS 4: Transparent non-eye, non-hair ===
1635
+ pass.setPipeline(this.pipeline)
1636
+ for (const draw of this.transparentNonEyeNonHairDraws) {
1637
+ if (draw.count > 0) {
1638
+ pass.setBindGroup(0, draw.bindGroup)
1639
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1640
+ this.drawCallCount++
1641
+ }
1642
+ }
1643
+
1644
+ this.drawOutlines(pass, true) // Transparent outlines
955
1645
 
956
1646
  pass.end()
957
1647
  this.device.queue.submit([encoder.finish()])
@@ -1046,24 +1736,22 @@ export class Engine {
1046
1736
 
1047
1737
  // Draw outlines (opaque or transparent)
1048
1738
  private drawOutlines(pass: GPURenderPassEncoder, transparent: boolean) {
1049
- if (this.outlineDraws.length === 0) return
1050
1739
  pass.setPipeline(this.outlinePipeline)
1051
- for (const draw of this.outlineDraws) {
1052
- if (draw.count > 0 && draw.isTransparent === transparent) {
1053
- pass.setBindGroup(0, draw.bindGroup)
1054
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1740
+ if (transparent) {
1741
+ // Draw transparent outlines (if any)
1742
+ for (const draw of this.transparentNonEyeNonHairOutlineDraws) {
1743
+ if (draw.count > 0) {
1744
+ pass.setBindGroup(0, draw.bindGroup)
1745
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1746
+ }
1055
1747
  }
1056
- }
1057
- }
1058
-
1059
- // Draw model materials (opaque or transparent)
1060
- private drawModel(pass: GPURenderPassEncoder, transparent: boolean) {
1061
- pass.setPipeline(this.pipeline)
1062
- for (const draw of this.materialDraws) {
1063
- if (draw.count > 0 && draw.isTransparent === transparent) {
1064
- pass.setBindGroup(0, draw.bindGroup)
1065
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1066
- this.drawCallCount++
1748
+ } else {
1749
+ // Draw opaque outlines before main geometry
1750
+ for (const draw of this.opaqueNonEyeNonHairOutlineDraws) {
1751
+ if (draw.count > 0) {
1752
+ pass.setBindGroup(0, draw.bindGroup)
1753
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1754
+ }
1067
1755
  }
1068
1756
  }
1069
1757
  }
@@ -1120,7 +1808,12 @@ export class Engine {
1120
1808
  }
1121
1809
  bufferMemoryBytes += 40 * 4 // cameraUniformBuffer
1122
1810
  bufferMemoryBytes += 64 * 4 // lightUniformBuffer
1123
- bufferMemoryBytes += this.materialDraws.length * 4 // Material uniform buffers
1811
+ const totalMaterialDraws =
1812
+ this.opaqueNonEyeNonHairDraws.length +
1813
+ this.eyeDraws.length +
1814
+ this.hairDraws.length +
1815
+ this.transparentNonEyeNonHairDraws.length
1816
+ bufferMemoryBytes += totalMaterialDraws * 4 // Material uniform buffers
1124
1817
 
1125
1818
  let renderTargetMemoryBytes = 0
1126
1819
  if (this.multisampleTexture) {