reze-engine 0.1.14 → 0.1.15

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.d.ts CHANGED
@@ -24,8 +24,12 @@ export declare class Engine {
24
24
  private outlinePipeline;
25
25
  private hairOutlinePipeline;
26
26
  private hairOutlineOverEyesPipeline;
27
+ private hairUnifiedOutlinePipeline;
27
28
  private hairMultiplyPipeline;
28
29
  private hairOpaquePipeline;
30
+ private hairUnifiedPipelineOverEyes;
31
+ private hairUnifiedPipelineOverNonEyes;
32
+ private hairDepthPipeline;
29
33
  private eyePipeline;
30
34
  private hairBindGroupLayout;
31
35
  private outlineBindGroupLayout;
@@ -1 +1 @@
1
- {"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,UAAU,CAAA;AACjC,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAA;AAKnC,MAAM,WAAW,WAAW;IAC1B,GAAG,EAAE,MAAM,CAAA;IACX,SAAS,EAAE,MAAM,CAAA;IACjB,SAAS,EAAE,MAAM,CAAA;CAClB;AAED,qBAAa,MAAM;IACjB,OAAO,CAAC,MAAM,CAAmB;IACjC,OAAO,CAAC,MAAM,CAAY;IAC1B,OAAO,CAAC,OAAO,CAAmB;IAClC,OAAO,CAAC,kBAAkB,CAAmB;IACtC,MAAM,EAAG,MAAM,CAAA;IACtB,OAAO,CAAC,mBAAmB,CAAY;IACvC,OAAO,CAAC,gBAAgB,CAAuB;IAC/C,OAAO,CAAC,kBAAkB,CAAY;IACtC,OAAO,CAAC,SAAS,CAAuB;IACxC,OAAO,CAAC,UAAU,CAAI;IACtB,OAAO,CAAC,YAAY,CAAY;IAChC,OAAO,CAAC,WAAW,CAAC,CAAW;IAC/B,OAAO,CAAC,cAAc,CAA8B;IACpD,OAAO,CAAC,YAAY,CAAa;IACjC,OAAO,CAAC,QAAQ,CAAoB;IACpC,OAAO,CAAC,eAAe,CAAoB;IAC3C,OAAO,CAAC,mBAAmB,CAAoB;IAC/C,OAAO,CAAC,2BAA2B,CAAoB;IACvD,OAAO,CAAC,oBAAoB,CAAoB;IAChD,OAAO,CAAC,kBAAkB,CAAoB;IAC9C,OAAO,CAAC,WAAW,CAAoB;IACvC,OAAO,CAAC,mBAAmB,CAAqB;IAChD,OAAO,CAAC,sBAAsB,CAAqB;IACnD,OAAO,CAAC,YAAY,CAAY;IAChC,OAAO,CAAC,aAAa,CAAY;IACjC,OAAO,CAAC,gBAAgB,CAAC,CAAW;IACpC,OAAO,CAAC,iBAAiB,CAAC,CAAW;IACrC,OAAO,CAAC,uBAAuB,CAAC,CAAW;IAC3C,OAAO,CAAC,yBAAyB,CAAC,CAAoB;IACtD,OAAO,CAAC,0BAA0B,CAAC,CAAc;IACjD,OAAO,CAAC,eAAe,CAAC,CAAW;IACnC,OAAO,CAAC,kBAAkB,CAAa;IACvC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAI;IAChC,OAAO,CAAC,oBAAoB,CAA0B;IAEtD,OAAO,CAAC,kBAAkB,CAAa;IACvC,OAAO,CAAC,sBAAsB,CAAiB;IAC/C,OAAO,CAAC,mBAAmB,CAAa;IACxC,OAAO,CAAC,iBAAiB,CAAa;IACtC,OAAO,CAAC,iBAAiB,CAAa;IAEtC,OAAO,CAAC,oBAAoB,CAAoB;IAChD,OAAO,CAAC,iBAAiB,CAAoB;IAC7C,OAAO,CAAC,oBAAoB,CAAoB;IAEhD,OAAO,CAAC,oBAAoB,CAAY;IACxC,OAAO,CAAC,mBAAmB,CAAY;IACvC,OAAO,CAAC,oBAAoB,CAAY;IACxC,OAAO,CAAC,oBAAoB,CAAY;IACxC,OAAO,CAAC,aAAa,CAAa;IAElC,OAAO,CAAC,qBAAqB,CAAC,CAAc;IAC5C,OAAO,CAAC,mBAAmB,CAAC,CAAc;IAC1C,OAAO,CAAC,mBAAmB,CAAC,CAAc;IAC1C,OAAO,CAAC,qBAAqB,CAAC,CAAc;IAErC,cAAc,EAAE,MAAM,CAAM;IAC5B,cAAc,EAAE,MAAM,CAAM;IAEnC,OAAO,CAAC,iBAAiB,CAAe;IACxC,OAAO,CAAC,aAAa,CAAc;IACnC,OAAO,CAAC,aAAa,CAA4C;IACjE,OAAO,CAAC,YAAY,CAAqB;IACzC,OAAO,CAAC,QAAQ,CAAa;IAC7B,OAAO,CAAC,OAAO,CAAuB;IACtC,OAAO,CAAC,cAAc,CAAa;IACnC,OAAO,CAAC,YAAY,CAAgC;IACpD,OAAO,CAAC,YAAY,CAAuD;IAE3E,OAAO,CAAC,aAAa,CAAoB;IACzC,OAAO,CAAC,qBAAqB,CAAI;IACjC,OAAO,CAAC,gBAAgB,CAAe;IACvC,OAAO,CAAC,YAAY,CAAY;IAChC,OAAO,CAAC,aAAa,CAAY;IACjC,OAAO,CAAC,aAAa,CAAoB;IACzC,OAAO,CAAC,KAAK,CAIZ;IACD,OAAO,CAAC,gBAAgB,CAAsB;IAC9C,OAAO,CAAC,kBAAkB,CAA4B;gBAE1C,MAAM,EAAE,iBAAiB;IAKxB,IAAI;IA+BjB,OAAO,CAAC,eAAe;IAwsBvB,OAAO,CAAC,+BAA+B;IAyCvC,OAAO,CAAC,oBAAoB;IAwC5B,OAAO,CAAC,oBAAoB;IAgP5B,OAAO,CAAC,UAAU;IAgElB,OAAO,CAAC,WAAW;IAMnB,OAAO,CAAC,YAAY;IA8EpB,OAAO,CAAC,WAAW;IAcnB,OAAO,CAAC,aAAa;IAgBd,QAAQ,CAAC,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,SAAS,GAAE,MAAY,GAAG,OAAO;IAmBxE,UAAU,CAAC,SAAS,EAAE,MAAM;IAI5B,QAAQ,IAAI,WAAW;IAIvB,aAAa,CAAC,QAAQ,CAAC,EAAE,MAAM,IAAI;IAgBnC,cAAc;IAQd,OAAO;IAUD,SAAS,CAAC,IAAI,EAAE,MAAM;IAW5B,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,EAAE,UAAU,CAAC,EAAE,MAAM;YAK5D,iBAAiB;IA0G/B,OAAO,CAAC,wBAAwB,CAKxB;IACR,OAAO,CAAC,QAAQ,CAA+F;IAC/G,OAAO,CAAC,iBAAiB,CACrB;IACJ,OAAO,CAAC,oBAAoB,CAKpB;IACR,OAAO,CAAC,6BAA6B,CAK7B;IACR,OAAO,CAAC,+BAA+B,CAK/B;IACR,OAAO,CAAC,eAAe,CAA+F;IACtH,OAAO,CAAC,gBAAgB,CACpB;IACJ,OAAO,CAAC,oCAAoC,CAKpC;YAGM,cAAc;YA0Rd,qBAAqB;IAkD5B,MAAM;IA0Hb,OAAO,CAAC,UAAU;IAwGlB,OAAO,CAAC,oBAAoB;IAa5B,OAAO,CAAC,kBAAkB;IAY1B,OAAO,CAAC,eAAe;IA0BvB,OAAO,CAAC,mBAAmB;IAkB3B,OAAO,CAAC,YAAY;IAqBpB,OAAO,CAAC,WAAW;CA4GpB"}
1
+ {"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"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"}
package/dist/engine.js CHANGED
@@ -110,7 +110,7 @@ export class Engine {
110
110
  rimIntensity: f32,
111
111
  rimPower: f32,
112
112
  rimColor: vec3f,
113
- _padding1: f32,
113
+ isOverEyes: f32, // 1.0 if rendering over eyes, 0.0 otherwise
114
114
  };
115
115
 
116
116
  struct VertexOutput {
@@ -188,7 +188,14 @@ export class Engine {
188
188
  let rimLight = material.rimColor * material.rimIntensity * rimFactor;
189
189
 
190
190
  let color = albedo * lightAccum + rimLight;
191
- let finalAlpha = material.alpha * material.alphaMultiplier;
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
+
192
199
  if (finalAlpha < 0.001) {
193
200
  discard;
194
201
  }
@@ -297,9 +304,9 @@ export class Engine {
297
304
  struct MaterialUniforms {
298
305
  edgeColor: vec4f,
299
306
  edgeSize: f32,
307
+ isOverEyes: f32, // 1.0 if rendering over eyes, 0.0 otherwise (for hair outlines)
300
308
  _padding1: f32,
301
309
  _padding2: f32,
302
- _padding3: f32,
303
310
  };
304
311
 
305
312
  @group(0) @binding(0) var<uniform> camera: CameraUniforms;
@@ -349,7 +356,15 @@ export class Engine {
349
356
  }
350
357
 
351
358
  @fragment fn fs() -> @location(0) vec4f {
352
- return material.edgeColor;
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;
353
368
  }
354
369
  `,
355
370
  });
@@ -571,6 +586,76 @@ export class Engine {
571
586
  count: this.sampleCount,
572
587
  },
573
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
593
+ this.hairUnifiedOutlinePipeline = this.device.createRenderPipeline({
594
+ label: "unified hair outline pipeline",
595
+ layout: outlinePipelineLayout,
596
+ vertex: {
597
+ module: outlineShaderModule,
598
+ buffers: [
599
+ {
600
+ arrayStride: 8 * 4,
601
+ attributes: [
602
+ {
603
+ shaderLocation: 0,
604
+ offset: 0,
605
+ format: "float32x3",
606
+ },
607
+ {
608
+ shaderLocation: 1,
609
+ offset: 3 * 4,
610
+ format: "float32x3",
611
+ },
612
+ ],
613
+ },
614
+ {
615
+ arrayStride: 4 * 2,
616
+ attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" }],
617
+ },
618
+ {
619
+ arrayStride: 4,
620
+ attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" }],
621
+ },
622
+ ],
623
+ },
624
+ fragment: {
625
+ module: outlineShaderModule,
626
+ targets: [
627
+ {
628
+ format: this.presentationFormat,
629
+ blend: {
630
+ color: {
631
+ srcFactor: "src-alpha",
632
+ dstFactor: "one-minus-src-alpha",
633
+ operation: "add",
634
+ },
635
+ alpha: {
636
+ srcFactor: "one",
637
+ dstFactor: "one-minus-src-alpha",
638
+ operation: "add",
639
+ },
640
+ },
641
+ },
642
+ ],
643
+ },
644
+ primitive: {
645
+ cullMode: "back",
646
+ },
647
+ depthStencil: {
648
+ format: "depth24plus-stencil8",
649
+ depthWriteEnabled: false, // Don't write depth - let hair geometry control depth
650
+ depthCompare: "less-equal", // Only draw where hair depth exists (no stencil test needed)
651
+ depthBias: -0.0001, // Small negative bias to bring outline slightly closer for depth test
652
+ depthBiasSlopeScale: 0.0,
653
+ depthBiasClamp: 0.0,
654
+ },
655
+ multisample: {
656
+ count: this.sampleCount,
657
+ },
658
+ });
574
659
  // Unified hair pipeline - can be used for both over-eyes and over-non-eyes
575
660
  // The difference is controlled by stencil state and alpha multiplier in material uniform
576
661
  this.hairMultiplyPipeline = this.device.createRenderPipeline({
@@ -767,6 +852,235 @@ export class Engine {
767
852
  },
768
853
  multisample: { count: this.sampleCount },
769
854
  });
855
+ // Depth-only shader for hair pre-pass (reduces overdraw by early depth rejection)
856
+ const depthOnlyShaderModule = this.device.createShaderModule({
857
+ 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
+ }
904
+ `,
905
+ });
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
909
+ this.hairDepthPipeline = this.device.createRenderPipeline({
910
+ label: "hair depth pre-pass",
911
+ layout: sharedPipelineLayout,
912
+ vertex: {
913
+ module: depthOnlyShaderModule,
914
+ buffers: [
915
+ {
916
+ arrayStride: 8 * 4,
917
+ attributes: [
918
+ { shaderLocation: 0, offset: 0, format: "float32x3" },
919
+ { shaderLocation: 1, offset: 3 * 4, format: "float32x3" },
920
+ ],
921
+ },
922
+ {
923
+ arrayStride: 4 * 2,
924
+ attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" }],
925
+ },
926
+ {
927
+ arrayStride: 4,
928
+ attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" }],
929
+ },
930
+ ],
931
+ },
932
+ fragment: {
933
+ module: depthOnlyShaderModule,
934
+ entryPoint: "fs",
935
+ targets: [
936
+ {
937
+ format: this.presentationFormat,
938
+ writeMask: 0, // Disable all color writes - we only care about depth
939
+ },
940
+ ],
941
+ },
942
+ primitive: { cullMode: "none" },
943
+ depthStencil: {
944
+ format: "depth24plus-stencil8",
945
+ depthWriteEnabled: true,
946
+ depthCompare: "less",
947
+ },
948
+ multisample: { count: this.sampleCount },
949
+ });
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)
955
+ this.hairUnifiedPipelineOverEyes = this.device.createRenderPipeline({
956
+ label: "unified hair pipeline (over eyes)",
957
+ layout: sharedPipelineLayout,
958
+ vertex: {
959
+ module: shaderModule,
960
+ buffers: [
961
+ {
962
+ arrayStride: 8 * 4,
963
+ attributes: [
964
+ { shaderLocation: 0, offset: 0, format: "float32x3" },
965
+ { shaderLocation: 1, offset: 3 * 4, format: "float32x3" },
966
+ { shaderLocation: 2, offset: 6 * 4, format: "float32x2" },
967
+ ],
968
+ },
969
+ {
970
+ arrayStride: 4 * 2,
971
+ attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" }],
972
+ },
973
+ {
974
+ arrayStride: 4,
975
+ attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" }],
976
+ },
977
+ ],
978
+ },
979
+ fragment: {
980
+ module: shaderModule,
981
+ targets: [
982
+ {
983
+ format: this.presentationFormat,
984
+ blend: {
985
+ color: {
986
+ srcFactor: "src-alpha",
987
+ dstFactor: "one-minus-src-alpha",
988
+ operation: "add",
989
+ },
990
+ alpha: {
991
+ srcFactor: "one",
992
+ dstFactor: "one-minus-src-alpha",
993
+ operation: "add",
994
+ },
995
+ },
996
+ },
997
+ ],
998
+ },
999
+ primitive: { cullMode: "none" },
1000
+ depthStencil: {
1001
+ format: "depth24plus-stencil8",
1002
+ depthWriteEnabled: false, // Don't write depth (already written in pre-pass)
1003
+ depthCompare: "equal", // Only render where depth matches pre-pass
1004
+ stencilFront: {
1005
+ compare: "equal", // Only render where stencil == 1 (over eyes)
1006
+ failOp: "keep",
1007
+ depthFailOp: "keep",
1008
+ passOp: "keep",
1009
+ },
1010
+ stencilBack: {
1011
+ compare: "equal",
1012
+ failOp: "keep",
1013
+ depthFailOp: "keep",
1014
+ passOp: "keep",
1015
+ },
1016
+ },
1017
+ multisample: { count: this.sampleCount },
1018
+ });
1019
+ // Unified pipeline for hair over non-eyes (stencil != 1)
1020
+ this.hairUnifiedPipelineOverNonEyes = this.device.createRenderPipeline({
1021
+ label: "unified hair pipeline (over non-eyes)",
1022
+ layout: sharedPipelineLayout,
1023
+ vertex: {
1024
+ module: shaderModule,
1025
+ buffers: [
1026
+ {
1027
+ arrayStride: 8 * 4,
1028
+ attributes: [
1029
+ { shaderLocation: 0, offset: 0, format: "float32x3" },
1030
+ { shaderLocation: 1, offset: 3 * 4, format: "float32x3" },
1031
+ { shaderLocation: 2, offset: 6 * 4, format: "float32x2" },
1032
+ ],
1033
+ },
1034
+ {
1035
+ arrayStride: 4 * 2,
1036
+ attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" }],
1037
+ },
1038
+ {
1039
+ arrayStride: 4,
1040
+ attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" }],
1041
+ },
1042
+ ],
1043
+ },
1044
+ fragment: {
1045
+ module: shaderModule,
1046
+ targets: [
1047
+ {
1048
+ format: this.presentationFormat,
1049
+ blend: {
1050
+ color: {
1051
+ srcFactor: "src-alpha",
1052
+ dstFactor: "one-minus-src-alpha",
1053
+ operation: "add",
1054
+ },
1055
+ alpha: {
1056
+ srcFactor: "one",
1057
+ dstFactor: "one-minus-src-alpha",
1058
+ operation: "add",
1059
+ },
1060
+ },
1061
+ },
1062
+ ],
1063
+ },
1064
+ primitive: { cullMode: "none" },
1065
+ depthStencil: {
1066
+ format: "depth24plus-stencil8",
1067
+ depthWriteEnabled: false, // Don't write depth (already written in pre-pass)
1068
+ depthCompare: "equal", // Only render where depth matches pre-pass
1069
+ stencilFront: {
1070
+ compare: "not-equal", // Only render where stencil != 1 (over non-eyes)
1071
+ failOp: "keep",
1072
+ depthFailOp: "keep",
1073
+ passOp: "keep",
1074
+ },
1075
+ stencilBack: {
1076
+ compare: "not-equal",
1077
+ failOp: "keep",
1078
+ depthFailOp: "keep",
1079
+ passOp: "keep",
1080
+ },
1081
+ },
1082
+ multisample: { count: this.sampleCount },
1083
+ });
770
1084
  }
771
1085
  // Create compute shader for skin matrix computation
772
1086
  createSkinMatrixComputePipeline() {
@@ -1444,7 +1758,7 @@ export class Engine {
1444
1758
  materialUniformData[4] = this.rimLightColor[0]; // rimColor.r
1445
1759
  materialUniformData[5] = this.rimLightColor[1]; // rimColor.g
1446
1760
  materialUniformData[6] = this.rimLightColor[2]; // rimColor.b
1447
- materialUniformData[7] = 0.0; // _padding1
1761
+ materialUniformData[7] = 0.0; // isOverEyes: 0.0 for non-hair materials
1448
1762
  const materialUniformBuffer = this.device.createBuffer({
1449
1763
  label: `material uniform: ${mat.name}`,
1450
1764
  size: materialUniformData.byteLength,
@@ -1476,22 +1790,36 @@ export class Engine {
1476
1790
  });
1477
1791
  }
1478
1792
  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
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
1796
+ const materialUniformDataHair = new Float32Array(8);
1797
+ materialUniformDataHair[0] = materialAlpha;
1798
+ materialUniformDataHair[1] = 1.0; // alphaMultiplier: base value, shader will adjust
1799
+ materialUniformDataHair[2] = this.rimLightIntensity;
1800
+ materialUniformDataHair[3] = this.rimLightPower;
1801
+ materialUniformDataHair[4] = this.rimLightColor[0]; // rimColor.r
1802
+ materialUniformDataHair[5] = this.rimLightColor[1]; // rimColor.g
1803
+ 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)
1489
1806
  const materialUniformBufferOverEyes = this.device.createBuffer({
1490
1807
  label: `material uniform (over eyes): ${mat.name}`,
1491
- size: materialUniformDataOverEyes.byteLength,
1808
+ size: materialUniformDataHair.byteLength,
1492
1809
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1493
1810
  });
1811
+ const materialUniformDataOverEyes = new Float32Array(materialUniformDataHair);
1812
+ materialUniformDataOverEyes[7] = 1.0; // isOverEyes = 1.0
1494
1813
  this.device.queue.writeBuffer(materialUniformBufferOverEyes, 0, materialUniformDataOverEyes);
1814
+ const materialUniformBufferOverNonEyes = this.device.createBuffer({
1815
+ label: `material uniform (over non-eyes): ${mat.name}`,
1816
+ size: materialUniformDataHair.byteLength,
1817
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1818
+ });
1819
+ const materialUniformDataOverNonEyes = new Float32Array(materialUniformDataHair);
1820
+ materialUniformDataOverNonEyes[7] = 0.0; // isOverEyes = 0.0
1821
+ this.device.queue.writeBuffer(materialUniformBufferOverNonEyes, 0, materialUniformDataOverNonEyes);
1822
+ // Create bind groups for both modes (they share everything except the uniform buffer)
1495
1823
  const bindGroupOverEyes = this.device.createBindGroup({
1496
1824
  label: `material bind group (over eyes): ${mat.name}`,
1497
1825
  layout: this.hairBindGroupLayout,
@@ -1506,28 +1834,6 @@ export class Engine {
1506
1834
  { binding: 7, resource: { buffer: materialUniformBufferOverEyes } },
1507
1835
  ],
1508
1836
  });
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
1837
  const bindGroupOverNonEyes = this.device.createBindGroup({
1532
1838
  label: `material bind group (over non-eyes): ${mat.name}`,
1533
1839
  layout: this.hairBindGroupLayout,
@@ -1542,6 +1848,13 @@ export class Engine {
1542
1848
  { binding: 7, resource: { buffer: materialUniformBufferOverNonEyes } },
1543
1849
  ],
1544
1850
  });
1851
+ // Store both bind groups - we'll use them with the unified pipeline
1852
+ this.hairDrawsOverEyes.push({
1853
+ count: matCount,
1854
+ firstIndex: runningFirstIndex,
1855
+ bindGroup: bindGroupOverEyes,
1856
+ isTransparent,
1857
+ });
1545
1858
  this.hairDrawsOverNonEyes.push({
1546
1859
  count: matCount,
1547
1860
  firstIndex: runningFirstIndex,
@@ -1568,11 +1881,14 @@ export class Engine {
1568
1881
  // Outline for all materials (including transparent) - Edge flag is at bit 4 (0x10) in PMX format, not bit 0 (0x01)
1569
1882
  if ((mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
1570
1883
  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];
1884
+ materialUniformData[0] = mat.edgeColor[0]; // edgeColor.r
1885
+ materialUniformData[1] = mat.edgeColor[1]; // edgeColor.g
1886
+ materialUniformData[2] = mat.edgeColor[2]; // edgeColor.b
1887
+ materialUniformData[3] = mat.edgeColor[3]; // edgeColor.a
1575
1888
  materialUniformData[4] = mat.edgeSize;
1889
+ materialUniformData[5] = mat.isHair ? 0.0 : 0.0; // isOverEyes: 0.0 for all (unified pipeline doesn't use stencil)
1890
+ materialUniformData[6] = 0.0; // _padding1
1891
+ materialUniformData[7] = 0.0; // _padding2
1576
1892
  const materialUniformBuffer = this.device.createBuffer({
1577
1893
  label: `outline material uniform: ${mat.name}`,
1578
1894
  size: materialUniformData.byteLength,
@@ -1706,27 +2022,37 @@ export class Engine {
1706
2022
  this.drawCallCount++;
1707
2023
  }
1708
2024
  }
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)
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
1712
2028
  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);
2029
+ // 3a: Hair depth pre-pass (depth-only, no color writes)
2030
+ // This eliminates most overdraw by rejecting fragments early before expensive shading
2031
+ if (this.hairDrawsOverEyes.length > 0 || this.hairDrawsOverNonEyes.length > 0) {
2032
+ pass.setPipeline(this.hairDepthPipeline);
2033
+ // Render all hair materials for depth (no stencil test needed for depth pass)
1717
2034
  for (const draw of this.hairDrawsOverEyes) {
2035
+ if (draw.count > 0) {
2036
+ // Use the same bind group structure (camera, skin matrices) for depth pass
2037
+ pass.setBindGroup(0, draw.bindGroup);
2038
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
2039
+ }
2040
+ }
2041
+ for (const draw of this.hairDrawsOverNonEyes) {
1718
2042
  if (draw.count > 0) {
1719
2043
  pass.setBindGroup(0, draw.bindGroup);
1720
2044
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1721
- this.drawCallCount++;
1722
2045
  }
1723
2046
  }
1724
2047
  }
1725
- // 3b: Hair over non-eyes (stencil != 1, alphaMultiplier = 1.0)
1726
- if (this.hairDrawsOverNonEyes.length > 0) {
1727
- pass.setPipeline(this.hairOpaquePipeline);
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
2052
+ if (this.hairDrawsOverEyes.length > 0) {
2053
+ pass.setPipeline(this.hairUnifiedPipelineOverEyes);
1728
2054
  pass.setStencilReference(1);
1729
- for (const draw of this.hairDrawsOverNonEyes) {
2055
+ for (const draw of this.hairDrawsOverEyes) {
1730
2056
  if (draw.count > 0) {
1731
2057
  pass.setBindGroup(0, draw.bindGroup);
1732
2058
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
@@ -1734,20 +2060,23 @@ export class Engine {
1734
2060
  }
1735
2061
  }
1736
2062
  }
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);
2063
+ if (this.hairDrawsOverNonEyes.length > 0) {
2064
+ pass.setPipeline(this.hairUnifiedPipelineOverNonEyes);
1741
2065
  pass.setStencilReference(1);
1742
- for (const draw of this.hairOutlineDraws) {
2066
+ for (const draw of this.hairDrawsOverNonEyes) {
1743
2067
  if (draw.count > 0) {
1744
2068
  pass.setBindGroup(0, draw.bindGroup);
1745
2069
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
2070
+ this.drawCallCount++;
1746
2071
  }
1747
2072
  }
1748
- // Over non-eyes
1749
- pass.setPipeline(this.hairOutlinePipeline);
1750
- pass.setStencilReference(1);
2073
+ }
2074
+ // 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
+ if (this.hairOutlineDraws.length > 0) {
2079
+ pass.setPipeline(this.hairUnifiedOutlinePipeline);
1751
2080
  for (const draw of this.hairOutlineDraws) {
1752
2081
  if (draw.count > 0) {
1753
2082
  pass.setBindGroup(0, draw.bindGroup);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reze-engine",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
4
4
  "description": "A WebGPU-based MMD model renderer",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
package/src/engine.ts CHANGED
@@ -29,8 +29,12 @@ export class Engine {
29
29
  private outlinePipeline!: GPURenderPipeline
30
30
  private hairOutlinePipeline!: GPURenderPipeline
31
31
  private hairOutlineOverEyesPipeline!: GPURenderPipeline
32
+ private hairUnifiedOutlinePipeline!: GPURenderPipeline // Unified hair outline pipeline without stencil testing
32
33
  private hairMultiplyPipeline!: GPURenderPipeline
33
34
  private hairOpaquePipeline!: GPURenderPipeline
35
+ private hairUnifiedPipelineOverEyes!: GPURenderPipeline // Unified hair pipeline for over-eyes (stencil == 1)
36
+ private hairUnifiedPipelineOverNonEyes!: GPURenderPipeline // Unified hair pipeline for over-non-eyes (stencil != 1)
37
+ private hairDepthPipeline!: GPURenderPipeline // Depth-only pipeline for hair pre-pass
34
38
  private eyePipeline!: GPURenderPipeline
35
39
  private hairBindGroupLayout!: GPUBindGroupLayout
36
40
  private outlineBindGroupLayout!: GPUBindGroupLayout
@@ -169,7 +173,7 @@ export class Engine {
169
173
  rimIntensity: f32,
170
174
  rimPower: f32,
171
175
  rimColor: vec3f,
172
- _padding1: f32,
176
+ isOverEyes: f32, // 1.0 if rendering over eyes, 0.0 otherwise
173
177
  };
174
178
 
175
179
  struct VertexOutput {
@@ -247,7 +251,14 @@ export class Engine {
247
251
  let rimLight = material.rimColor * material.rimIntensity * rimFactor;
248
252
 
249
253
  let color = albedo * lightAccum + rimLight;
250
- let finalAlpha = material.alpha * material.alphaMultiplier;
254
+
255
+ // Dynamic branching: adjust alpha based on whether we're over eyes
256
+ // This allows single-pass hair rendering instead of two separate passes
257
+ var finalAlpha = material.alpha * material.alphaMultiplier;
258
+ if (material.isOverEyes > 0.5) {
259
+ finalAlpha *= 0.5; // Hair over eyes gets 50% alpha
260
+ }
261
+
251
262
  if (finalAlpha < 0.001) {
252
263
  discard;
253
264
  }
@@ -362,9 +373,9 @@ export class Engine {
362
373
  struct MaterialUniforms {
363
374
  edgeColor: vec4f,
364
375
  edgeSize: f32,
376
+ isOverEyes: f32, // 1.0 if rendering over eyes, 0.0 otherwise (for hair outlines)
365
377
  _padding1: f32,
366
378
  _padding2: f32,
367
- _padding3: f32,
368
379
  };
369
380
 
370
381
  @group(0) @binding(0) var<uniform> camera: CameraUniforms;
@@ -414,7 +425,15 @@ export class Engine {
414
425
  }
415
426
 
416
427
  @fragment fn fs() -> @location(0) vec4f {
417
- return material.edgeColor;
428
+ var color = material.edgeColor;
429
+
430
+ // Dynamic branching: adjust alpha for hair outlines over eyes
431
+ // This allows single-pass outline rendering instead of two separate passes
432
+ if (material.isOverEyes > 0.5) {
433
+ color.a *= 0.5; // Hair outlines over eyes get 50% alpha
434
+ }
435
+
436
+ return color;
418
437
  }
419
438
  `,
420
439
  })
@@ -641,6 +660,77 @@ export class Engine {
641
660
  },
642
661
  })
643
662
 
663
+ // Unified hair outline pipeline: single pass without stencil testing
664
+ // Uses depth test "less-equal" to draw everywhere hair exists
665
+ // Shader branches on isOverEyes uniform to adjust alpha dynamically
666
+ // This eliminates the need for two separate outline passes
667
+ this.hairUnifiedOutlinePipeline = this.device.createRenderPipeline({
668
+ label: "unified hair outline pipeline",
669
+ layout: outlinePipelineLayout,
670
+ vertex: {
671
+ module: outlineShaderModule,
672
+ buffers: [
673
+ {
674
+ arrayStride: 8 * 4,
675
+ attributes: [
676
+ {
677
+ shaderLocation: 0,
678
+ offset: 0,
679
+ format: "float32x3" as GPUVertexFormat,
680
+ },
681
+ {
682
+ shaderLocation: 1,
683
+ offset: 3 * 4,
684
+ format: "float32x3" as GPUVertexFormat,
685
+ },
686
+ ],
687
+ },
688
+ {
689
+ arrayStride: 4 * 2,
690
+ attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" as GPUVertexFormat }],
691
+ },
692
+ {
693
+ arrayStride: 4,
694
+ attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" as GPUVertexFormat }],
695
+ },
696
+ ],
697
+ },
698
+ fragment: {
699
+ module: outlineShaderModule,
700
+ targets: [
701
+ {
702
+ format: this.presentationFormat,
703
+ blend: {
704
+ color: {
705
+ srcFactor: "src-alpha",
706
+ dstFactor: "one-minus-src-alpha",
707
+ operation: "add",
708
+ },
709
+ alpha: {
710
+ srcFactor: "one",
711
+ dstFactor: "one-minus-src-alpha",
712
+ operation: "add",
713
+ },
714
+ },
715
+ },
716
+ ],
717
+ },
718
+ primitive: {
719
+ cullMode: "back",
720
+ },
721
+ depthStencil: {
722
+ format: "depth24plus-stencil8",
723
+ depthWriteEnabled: false, // Don't write depth - let hair geometry control depth
724
+ depthCompare: "less-equal", // Only draw where hair depth exists (no stencil test needed)
725
+ depthBias: -0.0001, // Small negative bias to bring outline slightly closer for depth test
726
+ depthBiasSlopeScale: 0.0,
727
+ depthBiasClamp: 0.0,
728
+ },
729
+ multisample: {
730
+ count: this.sampleCount,
731
+ },
732
+ })
733
+
644
734
  // Unified hair pipeline - can be used for both over-eyes and over-non-eyes
645
735
  // The difference is controlled by stencil state and alpha multiplier in material uniform
646
736
  this.hairMultiplyPipeline = this.device.createRenderPipeline({
@@ -839,6 +929,240 @@ export class Engine {
839
929
  },
840
930
  multisample: { count: this.sampleCount },
841
931
  })
932
+
933
+ // Depth-only shader for hair pre-pass (reduces overdraw by early depth rejection)
934
+ const depthOnlyShaderModule = this.device.createShaderModule({
935
+ label: "depth only shader",
936
+ code: /* wgsl */ `
937
+ struct CameraUniforms {
938
+ view: mat4x4f,
939
+ projection: mat4x4f,
940
+ viewPos: vec3f,
941
+ _padding: f32,
942
+ };
943
+
944
+ @group(0) @binding(0) var<uniform> camera: CameraUniforms;
945
+ @group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
946
+
947
+ @vertex fn vs(
948
+ @location(0) position: vec3f,
949
+ @location(1) normal: vec3f,
950
+ @location(3) joints0: vec4<u32>,
951
+ @location(4) weights0: vec4<f32>
952
+ ) -> @builtin(position) vec4f {
953
+ let pos4 = vec4f(position, 1.0);
954
+
955
+ // Normalize weights
956
+ let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
957
+ var normalizedWeights: vec4f;
958
+ if (weightSum > 0.0001) {
959
+ normalizedWeights = weights0 / weightSum;
960
+ } else {
961
+ normalizedWeights = vec4f(1.0, 0.0, 0.0, 0.0);
962
+ }
963
+
964
+ var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
965
+ for (var i = 0u; i < 4u; i++) {
966
+ let j = joints0[i];
967
+ let w = normalizedWeights[i];
968
+ let m = skinMats[j];
969
+ skinnedPos += (m * pos4) * w;
970
+ }
971
+ let worldPos = skinnedPos.xyz;
972
+ let clipPos = camera.projection * camera.view * vec4f(worldPos, 1.0);
973
+ return clipPos;
974
+ }
975
+
976
+ // Minimal fragment shader - returns transparent, no color writes (writeMask: 0)
977
+ // Required because render pass has color attachments
978
+ // Depth is still written even though we don't write color
979
+ @fragment fn fs() -> @location(0) vec4f {
980
+ return vec4f(0.0, 0.0, 0.0, 0.0); // Transparent - color writes disabled via writeMask
981
+ }
982
+ `,
983
+ })
984
+
985
+ // Hair depth pre-pass pipeline (depth-only, no color writes)
986
+ // This eliminates most overdraw by rejecting fragments early before expensive shading
987
+ // Note: Must have a color target to match render pass, but we disable all color writes
988
+ this.hairDepthPipeline = this.device.createRenderPipeline({
989
+ label: "hair depth pre-pass",
990
+ layout: sharedPipelineLayout,
991
+ vertex: {
992
+ module: depthOnlyShaderModule,
993
+ buffers: [
994
+ {
995
+ arrayStride: 8 * 4,
996
+ attributes: [
997
+ { shaderLocation: 0, offset: 0, format: "float32x3" as GPUVertexFormat },
998
+ { shaderLocation: 1, offset: 3 * 4, format: "float32x3" as GPUVertexFormat },
999
+ ],
1000
+ },
1001
+ {
1002
+ arrayStride: 4 * 2,
1003
+ attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" as GPUVertexFormat }],
1004
+ },
1005
+ {
1006
+ arrayStride: 4,
1007
+ attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" as GPUVertexFormat }],
1008
+ },
1009
+ ],
1010
+ },
1011
+ fragment: {
1012
+ module: depthOnlyShaderModule,
1013
+ entryPoint: "fs",
1014
+ targets: [
1015
+ {
1016
+ format: this.presentationFormat,
1017
+ writeMask: 0, // Disable all color writes - we only care about depth
1018
+ },
1019
+ ],
1020
+ },
1021
+ primitive: { cullMode: "none" },
1022
+ depthStencil: {
1023
+ format: "depth24plus-stencil8",
1024
+ depthWriteEnabled: true,
1025
+ depthCompare: "less",
1026
+ },
1027
+ multisample: { count: this.sampleCount },
1028
+ })
1029
+
1030
+ // Unified hair pipeline: single pass with dynamic branching in shader
1031
+ // Uses stencil testing to filter fragments, then shader branches on isOverEyes uniform
1032
+ // This eliminates the need for separate pipelines - same shader, different stencil states
1033
+ // We create two variants: one for over-eyes (stencil == 1) and one for over-non-eyes (stencil != 1)
1034
+
1035
+ // Unified pipeline for hair over eyes (stencil == 1)
1036
+ this.hairUnifiedPipelineOverEyes = this.device.createRenderPipeline({
1037
+ label: "unified hair pipeline (over eyes)",
1038
+ layout: sharedPipelineLayout,
1039
+ vertex: {
1040
+ module: shaderModule,
1041
+ buffers: [
1042
+ {
1043
+ arrayStride: 8 * 4,
1044
+ attributes: [
1045
+ { shaderLocation: 0, offset: 0, format: "float32x3" as GPUVertexFormat },
1046
+ { shaderLocation: 1, offset: 3 * 4, format: "float32x3" as GPUVertexFormat },
1047
+ { shaderLocation: 2, offset: 6 * 4, format: "float32x2" as GPUVertexFormat },
1048
+ ],
1049
+ },
1050
+ {
1051
+ arrayStride: 4 * 2,
1052
+ attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" as GPUVertexFormat }],
1053
+ },
1054
+ {
1055
+ arrayStride: 4,
1056
+ attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" as GPUVertexFormat }],
1057
+ },
1058
+ ],
1059
+ },
1060
+ fragment: {
1061
+ module: shaderModule,
1062
+ targets: [
1063
+ {
1064
+ format: this.presentationFormat,
1065
+ blend: {
1066
+ color: {
1067
+ srcFactor: "src-alpha",
1068
+ dstFactor: "one-minus-src-alpha",
1069
+ operation: "add",
1070
+ },
1071
+ alpha: {
1072
+ srcFactor: "one",
1073
+ dstFactor: "one-minus-src-alpha",
1074
+ operation: "add",
1075
+ },
1076
+ },
1077
+ },
1078
+ ],
1079
+ },
1080
+ primitive: { cullMode: "none" },
1081
+ depthStencil: {
1082
+ format: "depth24plus-stencil8",
1083
+ depthWriteEnabled: false, // Don't write depth (already written in pre-pass)
1084
+ depthCompare: "equal", // Only render where depth matches pre-pass
1085
+ stencilFront: {
1086
+ compare: "equal", // Only render where stencil == 1 (over eyes)
1087
+ failOp: "keep",
1088
+ depthFailOp: "keep",
1089
+ passOp: "keep",
1090
+ },
1091
+ stencilBack: {
1092
+ compare: "equal",
1093
+ failOp: "keep",
1094
+ depthFailOp: "keep",
1095
+ passOp: "keep",
1096
+ },
1097
+ },
1098
+ multisample: { count: this.sampleCount },
1099
+ })
1100
+
1101
+ // Unified pipeline for hair over non-eyes (stencil != 1)
1102
+ this.hairUnifiedPipelineOverNonEyes = this.device.createRenderPipeline({
1103
+ label: "unified hair pipeline (over non-eyes)",
1104
+ layout: sharedPipelineLayout,
1105
+ vertex: {
1106
+ module: shaderModule,
1107
+ buffers: [
1108
+ {
1109
+ arrayStride: 8 * 4,
1110
+ attributes: [
1111
+ { shaderLocation: 0, offset: 0, format: "float32x3" as GPUVertexFormat },
1112
+ { shaderLocation: 1, offset: 3 * 4, format: "float32x3" as GPUVertexFormat },
1113
+ { shaderLocation: 2, offset: 6 * 4, format: "float32x2" as GPUVertexFormat },
1114
+ ],
1115
+ },
1116
+ {
1117
+ arrayStride: 4 * 2,
1118
+ attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" as GPUVertexFormat }],
1119
+ },
1120
+ {
1121
+ arrayStride: 4,
1122
+ attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" as GPUVertexFormat }],
1123
+ },
1124
+ ],
1125
+ },
1126
+ fragment: {
1127
+ module: shaderModule,
1128
+ targets: [
1129
+ {
1130
+ format: this.presentationFormat,
1131
+ blend: {
1132
+ color: {
1133
+ srcFactor: "src-alpha",
1134
+ dstFactor: "one-minus-src-alpha",
1135
+ operation: "add",
1136
+ },
1137
+ alpha: {
1138
+ srcFactor: "one",
1139
+ dstFactor: "one-minus-src-alpha",
1140
+ operation: "add",
1141
+ },
1142
+ },
1143
+ },
1144
+ ],
1145
+ },
1146
+ primitive: { cullMode: "none" },
1147
+ depthStencil: {
1148
+ format: "depth24plus-stencil8",
1149
+ depthWriteEnabled: false, // Don't write depth (already written in pre-pass)
1150
+ depthCompare: "equal", // Only render where depth matches pre-pass
1151
+ stencilFront: {
1152
+ compare: "not-equal", // Only render where stencil != 1 (over non-eyes)
1153
+ failOp: "keep",
1154
+ depthFailOp: "keep",
1155
+ passOp: "keep",
1156
+ },
1157
+ stencilBack: {
1158
+ compare: "not-equal",
1159
+ failOp: "keep",
1160
+ depthFailOp: "keep",
1161
+ passOp: "keep",
1162
+ },
1163
+ },
1164
+ multisample: { count: this.sampleCount },
1165
+ })
842
1166
  }
843
1167
 
844
1168
  // Create compute shader for skin matrix computation
@@ -1652,7 +1976,7 @@ export class Engine {
1652
1976
  materialUniformData[4] = this.rimLightColor[0] // rimColor.r
1653
1977
  materialUniformData[5] = this.rimLightColor[1] // rimColor.g
1654
1978
  materialUniformData[6] = this.rimLightColor[2] // rimColor.b
1655
- materialUniformData[7] = 0.0 // _padding1
1979
+ materialUniformData[7] = 0.0 // isOverEyes: 0.0 for non-hair materials
1656
1980
 
1657
1981
  const materialUniformBuffer = this.device.createBuffer({
1658
1982
  label: `material uniform: ${mat.name}`,
@@ -1686,24 +2010,39 @@ export class Engine {
1686
2010
  isTransparent,
1687
2011
  })
1688
2012
  } else if (mat.isHair) {
1689
- // For hair materials, create two bind groups: one for over-eyes (alphaMultiplier = 0.5) and one for over-non-eyes (alphaMultiplier = 1.0)
1690
- const materialUniformDataOverEyes = new Float32Array(8)
1691
- materialUniformDataOverEyes[0] = materialAlpha
1692
- materialUniformDataOverEyes[1] = 0.5 // alphaMultiplier: 0.5 for over-eyes
1693
- materialUniformDataOverEyes[2] = this.rimLightIntensity
1694
- materialUniformDataOverEyes[3] = this.rimLightPower
1695
- materialUniformDataOverEyes[4] = this.rimLightColor[0] // rimColor.r
1696
- materialUniformDataOverEyes[5] = this.rimLightColor[1] // rimColor.g
1697
- materialUniformDataOverEyes[6] = this.rimLightColor[2] // rimColor.b
1698
- materialUniformDataOverEyes[7] = 0.0 // _padding1
1699
-
2013
+ // For hair materials, create a single bind group that will be used with the unified pipeline
2014
+ // The shader will dynamically branch based on isOverEyes uniform
2015
+ // We still need two uniform buffers (one for each render mode) but can reuse the same bind group structure
2016
+ const materialUniformDataHair = new Float32Array(8)
2017
+ materialUniformDataHair[0] = materialAlpha
2018
+ materialUniformDataHair[1] = 1.0 // alphaMultiplier: base value, shader will adjust
2019
+ materialUniformDataHair[2] = this.rimLightIntensity
2020
+ materialUniformDataHair[3] = this.rimLightPower
2021
+ materialUniformDataHair[4] = this.rimLightColor[0] // rimColor.r
2022
+ materialUniformDataHair[5] = this.rimLightColor[1] // rimColor.g
2023
+ materialUniformDataHair[6] = this.rimLightColor[2] // rimColor.b
2024
+ materialUniformDataHair[7] = 0.0 // isOverEyes: will be set per draw call
2025
+
2026
+ // Create uniform buffers for both modes (we'll update them per frame)
1700
2027
  const materialUniformBufferOverEyes = this.device.createBuffer({
1701
2028
  label: `material uniform (over eyes): ${mat.name}`,
1702
- size: materialUniformDataOverEyes.byteLength,
2029
+ size: materialUniformDataHair.byteLength,
1703
2030
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1704
2031
  })
2032
+ const materialUniformDataOverEyes = new Float32Array(materialUniformDataHair)
2033
+ materialUniformDataOverEyes[7] = 1.0 // isOverEyes = 1.0
1705
2034
  this.device.queue.writeBuffer(materialUniformBufferOverEyes, 0, materialUniformDataOverEyes)
1706
2035
 
2036
+ const materialUniformBufferOverNonEyes = this.device.createBuffer({
2037
+ label: `material uniform (over non-eyes): ${mat.name}`,
2038
+ size: materialUniformDataHair.byteLength,
2039
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
2040
+ })
2041
+ const materialUniformDataOverNonEyes = new Float32Array(materialUniformDataHair)
2042
+ materialUniformDataOverNonEyes[7] = 0.0 // isOverEyes = 0.0
2043
+ this.device.queue.writeBuffer(materialUniformBufferOverNonEyes, 0, materialUniformDataOverNonEyes)
2044
+
2045
+ // Create bind groups for both modes (they share everything except the uniform buffer)
1707
2046
  const bindGroupOverEyes = this.device.createBindGroup({
1708
2047
  label: `material bind group (over eyes): ${mat.name}`,
1709
2048
  layout: this.hairBindGroupLayout,
@@ -1719,31 +2058,6 @@ export class Engine {
1719
2058
  ],
1720
2059
  })
1721
2060
 
1722
- this.hairDrawsOverEyes.push({
1723
- count: matCount,
1724
- firstIndex: runningFirstIndex,
1725
- bindGroup: bindGroupOverEyes,
1726
- isTransparent,
1727
- })
1728
-
1729
- // Create material uniform for hair over non-eyes (alphaMultiplier = 1.0)
1730
- const materialUniformDataOverNonEyes = new Float32Array(8)
1731
- materialUniformDataOverNonEyes[0] = materialAlpha
1732
- materialUniformDataOverNonEyes[1] = 1.0 // alphaMultiplier: 1.0 for over-non-eyes
1733
- materialUniformDataOverNonEyes[2] = this.rimLightIntensity
1734
- materialUniformDataOverNonEyes[3] = this.rimLightPower
1735
- materialUniformDataOverNonEyes[4] = this.rimLightColor[0] // rimColor.r
1736
- materialUniformDataOverNonEyes[5] = this.rimLightColor[1] // rimColor.g
1737
- materialUniformDataOverNonEyes[6] = this.rimLightColor[2] // rimColor.b
1738
- materialUniformDataOverNonEyes[7] = 0.0 // _padding1
1739
-
1740
- const materialUniformBufferOverNonEyes = this.device.createBuffer({
1741
- label: `material uniform (over non-eyes): ${mat.name}`,
1742
- size: materialUniformDataOverNonEyes.byteLength,
1743
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1744
- })
1745
- this.device.queue.writeBuffer(materialUniformBufferOverNonEyes, 0, materialUniformDataOverNonEyes)
1746
-
1747
2061
  const bindGroupOverNonEyes = this.device.createBindGroup({
1748
2062
  label: `material bind group (over non-eyes): ${mat.name}`,
1749
2063
  layout: this.hairBindGroupLayout,
@@ -1759,6 +2073,14 @@ export class Engine {
1759
2073
  ],
1760
2074
  })
1761
2075
 
2076
+ // Store both bind groups - we'll use them with the unified pipeline
2077
+ this.hairDrawsOverEyes.push({
2078
+ count: matCount,
2079
+ firstIndex: runningFirstIndex,
2080
+ bindGroup: bindGroupOverEyes,
2081
+ isTransparent,
2082
+ })
2083
+
1762
2084
  this.hairDrawsOverNonEyes.push({
1763
2085
  count: matCount,
1764
2086
  firstIndex: runningFirstIndex,
@@ -1784,11 +2106,14 @@ export class Engine {
1784
2106
  // Outline for all materials (including transparent) - Edge flag is at bit 4 (0x10) in PMX format, not bit 0 (0x01)
1785
2107
  if ((mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
1786
2108
  const materialUniformData = new Float32Array(8)
1787
- materialUniformData[0] = mat.edgeColor[0]
1788
- materialUniformData[1] = mat.edgeColor[1]
1789
- materialUniformData[2] = mat.edgeColor[2]
1790
- materialUniformData[3] = mat.edgeColor[3]
2109
+ materialUniformData[0] = mat.edgeColor[0] // edgeColor.r
2110
+ materialUniformData[1] = mat.edgeColor[1] // edgeColor.g
2111
+ materialUniformData[2] = mat.edgeColor[2] // edgeColor.b
2112
+ materialUniformData[3] = mat.edgeColor[3] // edgeColor.a
1791
2113
  materialUniformData[4] = mat.edgeSize
2114
+ materialUniformData[5] = mat.isHair ? 0.0 : 0.0 // isOverEyes: 0.0 for all (unified pipeline doesn't use stencil)
2115
+ materialUniformData[6] = 0.0 // _padding1
2116
+ materialUniformData[7] = 0.0 // _padding2
1792
2117
 
1793
2118
  const materialUniformBuffer = this.device.createBuffer({
1794
2119
  label: `outline material uniform: ${mat.name}`,
@@ -1938,30 +2263,40 @@ export class Engine {
1938
2263
  }
1939
2264
  }
1940
2265
 
1941
- // PASS 3: Hair rendering - optimized single pass approach
1942
- // Since both hair passes use the same shader, we batch them together
1943
- // but still need separate passes due to stencil requirements (equal vs not-equal)
2266
+ // PASS 3: Hair rendering - optimized with depth pre-pass and unified pipeline
2267
+ // Depth pre-pass: render hair depth-only to eliminate overdraw early
2268
+ // Then render shaded hair once with depth test "equal" to only shade visible fragments
1944
2269
 
1945
2270
  this.drawOutlines(pass, false) // Opaque outlines
1946
2271
 
1947
- // 3a: Hair over eyes (stencil == 1, alphaMultiplier = 0.5)
1948
- if (this.hairDrawsOverEyes.length > 0) {
1949
- pass.setPipeline(this.hairMultiplyPipeline)
1950
- pass.setStencilReference(1)
2272
+ // 3a: Hair depth pre-pass (depth-only, no color writes)
2273
+ // This eliminates most overdraw by rejecting fragments early before expensive shading
2274
+ if (this.hairDrawsOverEyes.length > 0 || this.hairDrawsOverNonEyes.length > 0) {
2275
+ pass.setPipeline(this.hairDepthPipeline)
2276
+ // Render all hair materials for depth (no stencil test needed for depth pass)
1951
2277
  for (const draw of this.hairDrawsOverEyes) {
2278
+ if (draw.count > 0) {
2279
+ // Use the same bind group structure (camera, skin matrices) for depth pass
2280
+ pass.setBindGroup(0, draw.bindGroup)
2281
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
2282
+ }
2283
+ }
2284
+ for (const draw of this.hairDrawsOverNonEyes) {
1952
2285
  if (draw.count > 0) {
1953
2286
  pass.setBindGroup(0, draw.bindGroup)
1954
2287
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1955
- this.drawCallCount++
1956
2288
  }
1957
2289
  }
1958
2290
  }
1959
2291
 
1960
- // 3b: Hair over non-eyes (stencil != 1, alphaMultiplier = 1.0)
1961
- if (this.hairDrawsOverNonEyes.length > 0) {
1962
- pass.setPipeline(this.hairOpaquePipeline)
2292
+ // 3b: Hair shading pass - unified pipeline with dynamic branching
2293
+ // Uses depth test "equal" to only render where depth was written in pre-pass
2294
+ // Shader branches on isOverEyes uniform to adjust alpha dynamically
2295
+ // This eliminates one full geometry pass compared to the old approach
2296
+ if (this.hairDrawsOverEyes.length > 0) {
2297
+ pass.setPipeline(this.hairUnifiedPipelineOverEyes)
1963
2298
  pass.setStencilReference(1)
1964
- for (const draw of this.hairDrawsOverNonEyes) {
2299
+ for (const draw of this.hairDrawsOverEyes) {
1965
2300
  if (draw.count > 0) {
1966
2301
  pass.setBindGroup(0, draw.bindGroup)
1967
2302
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
@@ -1970,21 +2305,24 @@ export class Engine {
1970
2305
  }
1971
2306
  }
1972
2307
 
1973
- // 3c: Hair outlines - batched together, only draw if outlines exist
1974
- if (this.hairOutlineDraws.length > 0) {
1975
- // Over eyes
1976
- pass.setPipeline(this.hairOutlineOverEyesPipeline)
2308
+ if (this.hairDrawsOverNonEyes.length > 0) {
2309
+ pass.setPipeline(this.hairUnifiedPipelineOverNonEyes)
1977
2310
  pass.setStencilReference(1)
1978
- for (const draw of this.hairOutlineDraws) {
2311
+ for (const draw of this.hairDrawsOverNonEyes) {
1979
2312
  if (draw.count > 0) {
1980
2313
  pass.setBindGroup(0, draw.bindGroup)
1981
2314
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
2315
+ this.drawCallCount++
1982
2316
  }
1983
2317
  }
2318
+ }
1984
2319
 
1985
- // Over non-eyes
1986
- pass.setPipeline(this.hairOutlinePipeline)
1987
- pass.setStencilReference(1)
2320
+ // 3c: Hair outlines - unified single pass without stencil testing
2321
+ // Uses depth test "less-equal" to draw everywhere hair exists
2322
+ // Shader branches on isOverEyes uniform to adjust alpha dynamically (currently always 0.0)
2323
+ // This eliminates the need for two separate outline passes
2324
+ if (this.hairOutlineDraws.length > 0) {
2325
+ pass.setPipeline(this.hairUnifiedOutlinePipeline)
1988
2326
  for (const draw of this.hairOutlineDraws) {
1989
2327
  if (draw.count > 0) {
1990
2328
  pass.setBindGroup(0, draw.bindGroup)