reze-engine 0.1.13 → 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/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
@@ -40,6 +44,7 @@ export class Engine {
40
44
  private worldMatrixBuffer?: GPUBuffer
41
45
  private inverseBindMatrixBuffer?: GPUBuffer
42
46
  private skinMatrixComputePipeline?: GPUComputePipeline
47
+ private skinMatrixComputeBindGroup?: GPUBindGroup
43
48
  private boneCountBuffer?: GPUBuffer
44
49
  private multisampleTexture!: GPUTexture
45
50
  private readonly sampleCount = 4 // MSAA 4x
@@ -60,6 +65,11 @@ export class Engine {
60
65
  private bloomIntensityBuffer!: GPUBuffer
61
66
  private bloomThresholdBuffer!: GPUBuffer
62
67
  private linearSampler!: GPUSampler
68
+ // Bloom bind groups (created once, reused every frame)
69
+ private bloomExtractBindGroup?: GPUBindGroup
70
+ private bloomBlurHBindGroup?: GPUBindGroup
71
+ private bloomBlurVBindGroup?: GPUBindGroup
72
+ private bloomComposeBindGroup?: GPUBindGroup
63
73
  // Bloom settings
64
74
  public bloomThreshold: number = 0.3
65
75
  public bloomIntensity: number = 0.1
@@ -163,7 +173,7 @@ export class Engine {
163
173
  rimIntensity: f32,
164
174
  rimPower: f32,
165
175
  rimColor: vec3f,
166
- _padding1: f32,
176
+ isOverEyes: f32, // 1.0 if rendering over eyes, 0.0 otherwise
167
177
  };
168
178
 
169
179
  struct VertexOutput {
@@ -241,7 +251,14 @@ export class Engine {
241
251
  let rimLight = material.rimColor * material.rimIntensity * rimFactor;
242
252
 
243
253
  let color = albedo * lightAccum + rimLight;
244
- 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
+
245
262
  if (finalAlpha < 0.001) {
246
263
  discard;
247
264
  }
@@ -356,9 +373,9 @@ export class Engine {
356
373
  struct MaterialUniforms {
357
374
  edgeColor: vec4f,
358
375
  edgeSize: f32,
376
+ isOverEyes: f32, // 1.0 if rendering over eyes, 0.0 otherwise (for hair outlines)
359
377
  _padding1: f32,
360
378
  _padding2: f32,
361
- _padding3: f32,
362
379
  };
363
380
 
364
381
  @group(0) @binding(0) var<uniform> camera: CameraUniforms;
@@ -408,7 +425,15 @@ export class Engine {
408
425
  }
409
426
 
410
427
  @fragment fn fs() -> @location(0) vec4f {
411
- 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;
412
437
  }
413
438
  `,
414
439
  })
@@ -635,6 +660,77 @@ export class Engine {
635
660
  },
636
661
  })
637
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
+
638
734
  // Unified hair pipeline - can be used for both over-eyes and over-non-eyes
639
735
  // The difference is controlled by stencil state and alpha multiplier in material uniform
640
736
  this.hairMultiplyPipeline = this.device.createRenderPipeline({
@@ -833,6 +929,240 @@ export class Engine {
833
929
  },
834
930
  multisample: { count: this.sampleCount },
835
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
+ })
836
1166
  }
837
1167
 
838
1168
  // Create compute shader for skin matrix computation
@@ -1156,6 +1486,70 @@ export class Engine {
1156
1486
  this.linearSampler = linearSampler
1157
1487
  }
1158
1488
 
1489
+ // Setup bloom textures and bind groups (called when canvas is resized)
1490
+ private setupBloom(width: number, height: number) {
1491
+ // Create bloom textures (half resolution for performance)
1492
+ const bloomWidth = Math.floor(width / 2)
1493
+ const bloomHeight = Math.floor(height / 2)
1494
+ this.bloomExtractTexture = this.device.createTexture({
1495
+ label: "bloom extract",
1496
+ size: [bloomWidth, bloomHeight],
1497
+ format: this.presentationFormat,
1498
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1499
+ })
1500
+ this.bloomBlurTexture1 = this.device.createTexture({
1501
+ label: "bloom blur 1",
1502
+ size: [bloomWidth, bloomHeight],
1503
+ format: this.presentationFormat,
1504
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1505
+ })
1506
+ this.bloomBlurTexture2 = this.device.createTexture({
1507
+ label: "bloom blur 2",
1508
+ size: [bloomWidth, bloomHeight],
1509
+ format: this.presentationFormat,
1510
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1511
+ })
1512
+
1513
+ // Create bloom bind groups
1514
+ this.bloomExtractBindGroup = this.device.createBindGroup({
1515
+ layout: this.bloomExtractPipeline.getBindGroupLayout(0),
1516
+ entries: [
1517
+ { binding: 0, resource: this.sceneRenderTexture.createView() },
1518
+ { binding: 1, resource: this.linearSampler },
1519
+ { binding: 2, resource: { buffer: this.bloomThresholdBuffer } },
1520
+ ],
1521
+ })
1522
+
1523
+ this.bloomBlurHBindGroup = this.device.createBindGroup({
1524
+ layout: this.bloomBlurPipeline.getBindGroupLayout(0),
1525
+ entries: [
1526
+ { binding: 0, resource: this.bloomExtractTexture.createView() },
1527
+ { binding: 1, resource: this.linearSampler },
1528
+ { binding: 2, resource: { buffer: this.blurDirectionBuffer } },
1529
+ ],
1530
+ })
1531
+
1532
+ this.bloomBlurVBindGroup = this.device.createBindGroup({
1533
+ layout: this.bloomBlurPipeline.getBindGroupLayout(0),
1534
+ entries: [
1535
+ { binding: 0, resource: this.bloomBlurTexture1.createView() },
1536
+ { binding: 1, resource: this.linearSampler },
1537
+ { binding: 2, resource: { buffer: this.blurDirectionBuffer } },
1538
+ ],
1539
+ })
1540
+
1541
+ this.bloomComposeBindGroup = this.device.createBindGroup({
1542
+ layout: this.bloomComposePipeline.getBindGroupLayout(0),
1543
+ entries: [
1544
+ { binding: 0, resource: this.sceneRenderTexture.createView() },
1545
+ { binding: 1, resource: this.linearSampler },
1546
+ { binding: 2, resource: this.bloomBlurTexture2.createView() },
1547
+ { binding: 3, resource: this.linearSampler },
1548
+ { binding: 4, resource: { buffer: this.bloomIntensityBuffer } },
1549
+ ],
1550
+ })
1551
+ }
1552
+
1159
1553
  // Step 3: Setup canvas resize handling
1160
1554
  private setupResize() {
1161
1555
  this.resizeObserver = new ResizeObserver(() => this.handleResize())
@@ -1200,27 +1594,8 @@ export class Engine {
1200
1594
  })
1201
1595
  this.sceneRenderTextureView = this.sceneRenderTexture.createView()
1202
1596
 
1203
- // Create bloom textures (half resolution for performance)
1204
- const bloomWidth = Math.floor(width / 2)
1205
- const bloomHeight = Math.floor(height / 2)
1206
- this.bloomExtractTexture = this.device.createTexture({
1207
- label: "bloom extract",
1208
- size: [bloomWidth, bloomHeight],
1209
- format: this.presentationFormat,
1210
- usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1211
- })
1212
- this.bloomBlurTexture1 = this.device.createTexture({
1213
- label: "bloom blur 1",
1214
- size: [bloomWidth, bloomHeight],
1215
- format: this.presentationFormat,
1216
- usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1217
- })
1218
- this.bloomBlurTexture2 = this.device.createTexture({
1219
- label: "bloom blur 2",
1220
- size: [bloomWidth, bloomHeight],
1221
- format: this.presentationFormat,
1222
- usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1223
- })
1597
+ // Setup bloom textures and bind groups
1598
+ this.setupBloom(width, height)
1224
1599
 
1225
1600
  const depthTextureView = this.depthTexture.createView()
1226
1601
 
@@ -1447,6 +1822,17 @@ export class Engine {
1447
1822
 
1448
1823
  this.createSkinMatrixComputePipeline()
1449
1824
 
1825
+ // Create compute bind group once (reused every frame)
1826
+ this.skinMatrixComputeBindGroup = this.device.createBindGroup({
1827
+ layout: this.skinMatrixComputePipeline!.getBindGroupLayout(0),
1828
+ entries: [
1829
+ { binding: 0, resource: { buffer: this.boneCountBuffer } },
1830
+ { binding: 1, resource: { buffer: this.worldMatrixBuffer } },
1831
+ { binding: 2, resource: { buffer: this.inverseBindMatrixBuffer } },
1832
+ { binding: 3, resource: { buffer: this.skinMatrixBuffer } },
1833
+ ],
1834
+ })
1835
+
1450
1836
  const indices = model.getIndices()
1451
1837
  if (indices) {
1452
1838
  this.indexBuffer = this.device.createBuffer({
@@ -1590,7 +1976,7 @@ export class Engine {
1590
1976
  materialUniformData[4] = this.rimLightColor[0] // rimColor.r
1591
1977
  materialUniformData[5] = this.rimLightColor[1] // rimColor.g
1592
1978
  materialUniformData[6] = this.rimLightColor[2] // rimColor.b
1593
- materialUniformData[7] = 0.0 // _padding1
1979
+ materialUniformData[7] = 0.0 // isOverEyes: 0.0 for non-hair materials
1594
1980
 
1595
1981
  const materialUniformBuffer = this.device.createBuffer({
1596
1982
  label: `material uniform: ${mat.name}`,
@@ -1624,24 +2010,39 @@ export class Engine {
1624
2010
  isTransparent,
1625
2011
  })
1626
2012
  } else if (mat.isHair) {
1627
- // For hair materials, create two bind groups: one for over-eyes (alphaMultiplier = 0.5) and one for over-non-eyes (alphaMultiplier = 1.0)
1628
- const materialUniformDataOverEyes = new Float32Array(8)
1629
- materialUniformDataOverEyes[0] = materialAlpha
1630
- materialUniformDataOverEyes[1] = 0.5 // alphaMultiplier: 0.5 for over-eyes
1631
- materialUniformDataOverEyes[2] = this.rimLightIntensity
1632
- materialUniformDataOverEyes[3] = this.rimLightPower
1633
- materialUniformDataOverEyes[4] = this.rimLightColor[0] // rimColor.r
1634
- materialUniformDataOverEyes[5] = this.rimLightColor[1] // rimColor.g
1635
- materialUniformDataOverEyes[6] = this.rimLightColor[2] // rimColor.b
1636
- materialUniformDataOverEyes[7] = 0.0 // _padding1
1637
-
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)
1638
2027
  const materialUniformBufferOverEyes = this.device.createBuffer({
1639
2028
  label: `material uniform (over eyes): ${mat.name}`,
1640
- size: materialUniformDataOverEyes.byteLength,
2029
+ size: materialUniformDataHair.byteLength,
1641
2030
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1642
2031
  })
2032
+ const materialUniformDataOverEyes = new Float32Array(materialUniformDataHair)
2033
+ materialUniformDataOverEyes[7] = 1.0 // isOverEyes = 1.0
1643
2034
  this.device.queue.writeBuffer(materialUniformBufferOverEyes, 0, materialUniformDataOverEyes)
1644
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)
1645
2046
  const bindGroupOverEyes = this.device.createBindGroup({
1646
2047
  label: `material bind group (over eyes): ${mat.name}`,
1647
2048
  layout: this.hairBindGroupLayout,
@@ -1657,31 +2058,6 @@ export class Engine {
1657
2058
  ],
1658
2059
  })
1659
2060
 
1660
- this.hairDrawsOverEyes.push({
1661
- count: matCount,
1662
- firstIndex: runningFirstIndex,
1663
- bindGroup: bindGroupOverEyes,
1664
- isTransparent,
1665
- })
1666
-
1667
- // Create material uniform for hair over non-eyes (alphaMultiplier = 1.0)
1668
- const materialUniformDataOverNonEyes = new Float32Array(8)
1669
- materialUniformDataOverNonEyes[0] = materialAlpha
1670
- materialUniformDataOverNonEyes[1] = 1.0 // alphaMultiplier: 1.0 for over-non-eyes
1671
- materialUniformDataOverNonEyes[2] = this.rimLightIntensity
1672
- materialUniformDataOverNonEyes[3] = this.rimLightPower
1673
- materialUniformDataOverNonEyes[4] = this.rimLightColor[0] // rimColor.r
1674
- materialUniformDataOverNonEyes[5] = this.rimLightColor[1] // rimColor.g
1675
- materialUniformDataOverNonEyes[6] = this.rimLightColor[2] // rimColor.b
1676
- materialUniformDataOverNonEyes[7] = 0.0 // _padding1
1677
-
1678
- const materialUniformBufferOverNonEyes = this.device.createBuffer({
1679
- label: `material uniform (over non-eyes): ${mat.name}`,
1680
- size: materialUniformDataOverNonEyes.byteLength,
1681
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1682
- })
1683
- this.device.queue.writeBuffer(materialUniformBufferOverNonEyes, 0, materialUniformDataOverNonEyes)
1684
-
1685
2061
  const bindGroupOverNonEyes = this.device.createBindGroup({
1686
2062
  label: `material bind group (over non-eyes): ${mat.name}`,
1687
2063
  layout: this.hairBindGroupLayout,
@@ -1697,6 +2073,14 @@ export class Engine {
1697
2073
  ],
1698
2074
  })
1699
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
+
1700
2084
  this.hairDrawsOverNonEyes.push({
1701
2085
  count: matCount,
1702
2086
  firstIndex: runningFirstIndex,
@@ -1722,11 +2106,14 @@ export class Engine {
1722
2106
  // Outline for all materials (including transparent) - Edge flag is at bit 4 (0x10) in PMX format, not bit 0 (0x01)
1723
2107
  if ((mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
1724
2108
  const materialUniformData = new Float32Array(8)
1725
- materialUniformData[0] = mat.edgeColor[0]
1726
- materialUniformData[1] = mat.edgeColor[1]
1727
- materialUniformData[2] = mat.edgeColor[2]
1728
- 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
1729
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
1730
2117
 
1731
2118
  const materialUniformBuffer = this.device.createBuffer({
1732
2119
  label: `outline material uniform: ${mat.name}`,
@@ -1876,30 +2263,40 @@ export class Engine {
1876
2263
  }
1877
2264
  }
1878
2265
 
1879
- // PASS 3: Hair rendering - optimized single pass approach
1880
- // Since both hair passes use the same shader, we batch them together
1881
- // 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
1882
2269
 
1883
2270
  this.drawOutlines(pass, false) // Opaque outlines
1884
2271
 
1885
- // 3a: Hair over eyes (stencil == 1, alphaMultiplier = 0.5)
1886
- if (this.hairDrawsOverEyes.length > 0) {
1887
- pass.setPipeline(this.hairMultiplyPipeline)
1888
- 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)
1889
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) {
1890
2285
  if (draw.count > 0) {
1891
2286
  pass.setBindGroup(0, draw.bindGroup)
1892
2287
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1893
- this.drawCallCount++
1894
2288
  }
1895
2289
  }
1896
2290
  }
1897
2291
 
1898
- // 3b: Hair over non-eyes (stencil != 1, alphaMultiplier = 1.0)
1899
- if (this.hairDrawsOverNonEyes.length > 0) {
1900
- 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)
1901
2298
  pass.setStencilReference(1)
1902
- for (const draw of this.hairDrawsOverNonEyes) {
2299
+ for (const draw of this.hairDrawsOverEyes) {
1903
2300
  if (draw.count > 0) {
1904
2301
  pass.setBindGroup(0, draw.bindGroup)
1905
2302
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
@@ -1908,21 +2305,24 @@ export class Engine {
1908
2305
  }
1909
2306
  }
1910
2307
 
1911
- // 3c: Hair outlines - batched together, only draw if outlines exist
1912
- if (this.hairOutlineDraws.length > 0) {
1913
- // Over eyes
1914
- pass.setPipeline(this.hairOutlineOverEyesPipeline)
2308
+ if (this.hairDrawsOverNonEyes.length > 0) {
2309
+ pass.setPipeline(this.hairUnifiedPipelineOverNonEyes)
1915
2310
  pass.setStencilReference(1)
1916
- for (const draw of this.hairOutlineDraws) {
2311
+ for (const draw of this.hairDrawsOverNonEyes) {
1917
2312
  if (draw.count > 0) {
1918
2313
  pass.setBindGroup(0, draw.bindGroup)
1919
2314
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
2315
+ this.drawCallCount++
1920
2316
  }
1921
2317
  }
2318
+ }
1922
2319
 
1923
- // Over non-eyes
1924
- pass.setPipeline(this.hairOutlinePipeline)
1925
- 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)
1926
2326
  for (const draw of this.hairOutlineDraws) {
1927
2327
  if (draw.count > 0) {
1928
2328
  pass.setBindGroup(0, draw.bindGroup)
@@ -1987,17 +2387,8 @@ export class Engine {
1987
2387
  ],
1988
2388
  })
1989
2389
 
1990
- const extractBindGroup = this.device.createBindGroup({
1991
- layout: this.bloomExtractPipeline.getBindGroupLayout(0),
1992
- entries: [
1993
- { binding: 0, resource: this.sceneRenderTexture.createView() },
1994
- { binding: 1, resource: this.linearSampler },
1995
- { binding: 2, resource: { buffer: this.bloomThresholdBuffer } },
1996
- ],
1997
- })
1998
-
1999
2390
  extractPass.setPipeline(this.bloomExtractPipeline)
2000
- extractPass.setBindGroup(0, extractBindGroup)
2391
+ extractPass.setBindGroup(0, this.bloomExtractBindGroup!)
2001
2392
  extractPass.draw(6, 1, 0, 0)
2002
2393
  extractPass.end()
2003
2394
 
@@ -2018,17 +2409,8 @@ export class Engine {
2018
2409
  ],
2019
2410
  })
2020
2411
 
2021
- const blurHBindGroup = this.device.createBindGroup({
2022
- layout: this.bloomBlurPipeline.getBindGroupLayout(0),
2023
- entries: [
2024
- { binding: 0, resource: this.bloomExtractTexture.createView() },
2025
- { binding: 1, resource: this.linearSampler },
2026
- { binding: 2, resource: { buffer: this.blurDirectionBuffer } },
2027
- ],
2028
- })
2029
-
2030
2412
  blurHPass.setPipeline(this.bloomBlurPipeline)
2031
- blurHPass.setBindGroup(0, blurHBindGroup)
2413
+ blurHPass.setBindGroup(0, this.bloomBlurHBindGroup!)
2032
2414
  blurHPass.draw(6, 1, 0, 0)
2033
2415
  blurHPass.end()
2034
2416
 
@@ -2049,17 +2431,8 @@ export class Engine {
2049
2431
  ],
2050
2432
  })
2051
2433
 
2052
- const blurVBindGroup = this.device.createBindGroup({
2053
- layout: this.bloomBlurPipeline.getBindGroupLayout(0),
2054
- entries: [
2055
- { binding: 0, resource: this.bloomBlurTexture1.createView() },
2056
- { binding: 1, resource: this.linearSampler },
2057
- { binding: 2, resource: { buffer: this.blurDirectionBuffer } },
2058
- ],
2059
- })
2060
-
2061
2434
  blurVPass.setPipeline(this.bloomBlurPipeline)
2062
- blurVPass.setBindGroup(0, blurVBindGroup)
2435
+ blurVPass.setBindGroup(0, this.bloomBlurVBindGroup!)
2063
2436
  blurVPass.draw(6, 1, 0, 0)
2064
2437
  blurVPass.end()
2065
2438
 
@@ -2076,19 +2449,8 @@ export class Engine {
2076
2449
  ],
2077
2450
  })
2078
2451
 
2079
- const composeBindGroup = this.device.createBindGroup({
2080
- layout: this.bloomComposePipeline.getBindGroupLayout(0),
2081
- entries: [
2082
- { binding: 0, resource: this.sceneRenderTexture.createView() },
2083
- { binding: 1, resource: this.linearSampler },
2084
- { binding: 2, resource: this.bloomBlurTexture2.createView() },
2085
- { binding: 3, resource: this.linearSampler },
2086
- { binding: 4, resource: { buffer: this.bloomIntensityBuffer } },
2087
- ],
2088
- })
2089
-
2090
2452
  composePass.setPipeline(this.bloomComposePipeline)
2091
- composePass.setBindGroup(0, composeBindGroup)
2453
+ composePass.setBindGroup(0, this.bloomComposeBindGroup!)
2092
2454
  composePass.draw(6, 1, 0, 0)
2093
2455
  composePass.end()
2094
2456
 
@@ -2153,26 +2515,12 @@ export class Engine {
2153
2515
  // Dispatch exactly enough threads for all bones (no bounds check needed)
2154
2516
  const workgroupCount = Math.ceil(boneCount / workgroupSize)
2155
2517
 
2156
- // Update bone count uniform
2157
- const boneCountData = new Uint32Array(8) // 32 bytes total
2158
- boneCountData[0] = boneCount
2159
- this.device.queue.writeBuffer(this.boneCountBuffer!, 0, boneCountData)
2160
-
2161
- const bindGroup = this.device.createBindGroup({
2162
- label: "skin matrix compute bind group",
2163
- layout: this.skinMatrixComputePipeline!.getBindGroupLayout(0),
2164
- entries: [
2165
- { binding: 0, resource: { buffer: this.boneCountBuffer! } },
2166
- { binding: 1, resource: { buffer: this.worldMatrixBuffer! } },
2167
- { binding: 2, resource: { buffer: this.inverseBindMatrixBuffer! } },
2168
- { binding: 3, resource: { buffer: this.skinMatrixBuffer! } },
2169
- ],
2170
- })
2518
+ // Bone count is written once in setupModelBuffers() and never changes
2171
2519
 
2172
2520
  const encoder = this.device.createCommandEncoder()
2173
2521
  const pass = encoder.beginComputePass()
2174
2522
  pass.setPipeline(this.skinMatrixComputePipeline!)
2175
- pass.setBindGroup(0, bindGroup)
2523
+ pass.setBindGroup(0, this.skinMatrixComputeBindGroup!)
2176
2524
  pass.dispatchWorkgroups(workgroupCount)
2177
2525
  pass.end()
2178
2526
  this.device.queue.submit([encoder.finish()])