reze-engine 0.1.6 → 0.1.8

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
@@ -45,6 +45,25 @@ export class Engine {
45
45
  private multisampleTexture!: GPUTexture
46
46
  private readonly sampleCount = 4 // MSAA 4x
47
47
  private renderPassDescriptor!: GPURenderPassDescriptor
48
+ // Bloom post-processing textures
49
+ private sceneRenderTexture!: GPUTexture
50
+ private sceneRenderTextureView!: GPUTextureView
51
+ private bloomExtractTexture!: GPUTexture
52
+ private bloomBlurTexture1!: GPUTexture
53
+ private bloomBlurTexture2!: GPUTexture
54
+ // Bloom post-processing pipelines
55
+ private bloomExtractPipeline!: GPURenderPipeline
56
+ private bloomBlurPipeline!: GPURenderPipeline
57
+ private bloomComposePipeline!: GPURenderPipeline
58
+ // Fullscreen quad for post-processing
59
+ private fullscreenQuadBuffer!: GPUBuffer
60
+ private blurDirectionBuffer!: GPUBuffer
61
+ private bloomIntensityBuffer!: GPUBuffer
62
+ private bloomThresholdBuffer!: GPUBuffer
63
+ private linearSampler!: GPUSampler
64
+ // Bloom settings
65
+ public bloomThreshold: number = 0.3
66
+ public bloomIntensity: number = 0.14
48
67
  private currentModel: Model | null = null
49
68
  private modelDir: string = ""
50
69
  private physics: Physics | null = null
@@ -96,6 +115,8 @@ export class Engine {
96
115
  this.setupCamera()
97
116
  this.setupLighting()
98
117
  this.createPipelines()
118
+ this.createFullscreenQuad()
119
+ this.createBloomPipelines()
99
120
  this.setupResize()
100
121
  }
101
122
 
@@ -325,9 +346,8 @@ export class Engine {
325
346
  discard;
326
347
  }
327
348
 
328
- // For hair-over-eyes effect: simple half-transparent overlay
329
- // Use 60% opacity to create a semi-transparent hair color overlay
330
- let overlayAlpha = finalAlpha * 0.6;
349
+ // For hair-over-eyes effect: simple half-transparent overlay - Use 50% opacity to create a semi-transparent hair color overlay
350
+ let overlayAlpha = finalAlpha * 0.5;
331
351
 
332
352
  return vec4f(clamp(color, vec3f(0.0), vec3f(1.0)), overlayAlpha);
333
353
  }
@@ -566,8 +586,7 @@ export class Engine {
566
586
  },
567
587
  })
568
588
 
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
589
+ // Hair outline pipeline: draws hair outlines over non-eyes (stencil != 1) - Drawn after hair geometry, so depth testing ensures outlines only appear where hair exists
571
590
  this.hairOutlinePipeline = this.device.createRenderPipeline({
572
591
  label: "hair outline pipeline",
573
592
  layout: outlinePipelineLayout,
@@ -649,8 +668,7 @@ export class Engine {
649
668
  },
650
669
  })
651
670
 
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
671
+ // Hair outline pipeline for over eyes: draws where stencil == 1, but only where hair depth exists - Uses depth compare "equal" with a small bias to only appear where hair geometry exists
654
672
  this.hairOutlineOverEyesPipeline = this.device.createRenderPipeline({
655
673
  label: "hair outline over eyes pipeline",
656
674
  layout: outlinePipelineLayout,
@@ -768,8 +786,7 @@ export class Engine {
768
786
  format: this.presentationFormat,
769
787
  blend: {
770
788
  color: {
771
- // Simple half-transparent overlay effect
772
- // Blend: hairColor * overlayAlpha + eyeColor * (1 - overlayAlpha)
789
+ // Simple half-transparent overlay effect - Blend: hairColor * overlayAlpha + eyeColor * (1 - overlayAlpha)
773
790
  srcFactor: "src-alpha",
774
791
  dstFactor: "one-minus-src-alpha",
775
792
  operation: "add",
@@ -978,6 +995,286 @@ export class Engine {
978
995
  })
979
996
  }
980
997
 
998
+ // Create fullscreen quad for post-processing
999
+ private createFullscreenQuad() {
1000
+ // Fullscreen quad vertices: two triangles covering the entire screen - Format: position (x, y), uv (u, v)
1001
+ const quadVertices = new Float32Array([
1002
+ // Triangle 1
1003
+ -1.0,
1004
+ -1.0,
1005
+ 0.0,
1006
+ 0.0, // bottom-left
1007
+ 1.0,
1008
+ -1.0,
1009
+ 1.0,
1010
+ 0.0, // bottom-right
1011
+ -1.0,
1012
+ 1.0,
1013
+ 0.0,
1014
+ 1.0, // top-left
1015
+ // Triangle 2
1016
+ -1.0,
1017
+ 1.0,
1018
+ 0.0,
1019
+ 1.0, // top-left
1020
+ 1.0,
1021
+ -1.0,
1022
+ 1.0,
1023
+ 0.0, // bottom-right
1024
+ 1.0,
1025
+ 1.0,
1026
+ 1.0,
1027
+ 1.0, // top-right
1028
+ ])
1029
+
1030
+ this.fullscreenQuadBuffer = this.device.createBuffer({
1031
+ label: "fullscreen quad",
1032
+ size: quadVertices.byteLength,
1033
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
1034
+ })
1035
+ this.device.queue.writeBuffer(this.fullscreenQuadBuffer, 0, quadVertices)
1036
+ }
1037
+
1038
+ // Create bloom post-processing pipelines
1039
+ private createBloomPipelines() {
1040
+ // Bloom extraction shader (extracts bright areas)
1041
+ const bloomExtractShader = this.device.createShaderModule({
1042
+ label: "bloom extract",
1043
+ code: /* wgsl */ `
1044
+ struct VertexOutput {
1045
+ @builtin(position) position: vec4f,
1046
+ @location(0) uv: vec2f,
1047
+ };
1048
+
1049
+ @vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
1050
+ var output: VertexOutput;
1051
+ // Generate fullscreen quad from vertex index
1052
+ let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
1053
+ let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
1054
+ output.position = vec4f(x, y, 0.0, 1.0);
1055
+ output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
1056
+ return output;
1057
+ }
1058
+
1059
+ struct BloomExtractUniforms {
1060
+ threshold: f32,
1061
+ _padding1: f32,
1062
+ _padding2: f32,
1063
+ _padding3: f32,
1064
+ _padding4: f32,
1065
+ _padding5: f32,
1066
+ _padding6: f32,
1067
+ _padding7: f32,
1068
+ };
1069
+
1070
+ @group(0) @binding(0) var inputTexture: texture_2d<f32>;
1071
+ @group(0) @binding(1) var inputSampler: sampler;
1072
+ @group(0) @binding(2) var<uniform> extractUniforms: BloomExtractUniforms;
1073
+
1074
+ @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
1075
+ let color = textureSample(inputTexture, inputSampler, input.uv);
1076
+ // Extract bright areas above threshold
1077
+ let threshold = extractUniforms.threshold;
1078
+ let bloom = max(vec3f(0.0), color.rgb - vec3f(threshold)) / max(0.001, 1.0 - threshold);
1079
+ return vec4f(bloom, color.a);
1080
+ }
1081
+ `,
1082
+ })
1083
+
1084
+ // Bloom blur shader (gaussian blur - can be used for both horizontal and vertical)
1085
+ const bloomBlurShader = this.device.createShaderModule({
1086
+ label: "bloom blur",
1087
+ code: /* wgsl */ `
1088
+ struct VertexOutput {
1089
+ @builtin(position) position: vec4f,
1090
+ @location(0) uv: vec2f,
1091
+ };
1092
+
1093
+ @vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
1094
+ var output: VertexOutput;
1095
+ let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
1096
+ let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
1097
+ output.position = vec4f(x, y, 0.0, 1.0);
1098
+ output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
1099
+ return output;
1100
+ }
1101
+
1102
+ struct BlurUniforms {
1103
+ direction: vec2f,
1104
+ _padding1: f32,
1105
+ _padding2: f32,
1106
+ _padding3: f32,
1107
+ _padding4: f32,
1108
+ _padding5: f32,
1109
+ _padding6: f32,
1110
+ };
1111
+
1112
+ @group(0) @binding(0) var inputTexture: texture_2d<f32>;
1113
+ @group(0) @binding(1) var inputSampler: sampler;
1114
+ @group(0) @binding(2) var<uniform> blurUniforms: BlurUniforms;
1115
+
1116
+ // 9-tap gaussian blur
1117
+ @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
1118
+ let texelSize = 1.0 / vec2f(textureDimensions(inputTexture));
1119
+ var result = vec4f(0.0);
1120
+
1121
+ // Gaussian weights for 9-tap filter
1122
+ let weights = array<f32, 9>(
1123
+ 0.01621622, 0.05405405, 0.12162162,
1124
+ 0.19459459, 0.22702703,
1125
+ 0.19459459, 0.12162162, 0.05405405, 0.01621622
1126
+ );
1127
+
1128
+ let offsets = array<f32, 9>(-4.0, -3.0, -2.0, -1.0, 0.0, 1.0, 2.0, 3.0, 4.0);
1129
+
1130
+ for (var i = 0u; i < 9u; i++) {
1131
+ let offset = offsets[i] * texelSize * blurUniforms.direction;
1132
+ result += textureSample(inputTexture, inputSampler, input.uv + offset) * weights[i];
1133
+ }
1134
+
1135
+ return result;
1136
+ }
1137
+ `,
1138
+ })
1139
+
1140
+ // Bloom composition shader (combines original scene with bloom)
1141
+ const bloomComposeShader = this.device.createShaderModule({
1142
+ label: "bloom compose",
1143
+ code: /* wgsl */ `
1144
+ struct VertexOutput {
1145
+ @builtin(position) position: vec4f,
1146
+ @location(0) uv: vec2f,
1147
+ };
1148
+
1149
+ @vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
1150
+ var output: VertexOutput;
1151
+ let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
1152
+ let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
1153
+ output.position = vec4f(x, y, 0.0, 1.0);
1154
+ output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
1155
+ return output;
1156
+ }
1157
+
1158
+ struct BloomComposeUniforms {
1159
+ intensity: f32,
1160
+ _padding1: f32,
1161
+ _padding2: f32,
1162
+ _padding3: f32,
1163
+ _padding4: f32,
1164
+ _padding5: f32,
1165
+ _padding6: f32,
1166
+ _padding7: f32,
1167
+ };
1168
+
1169
+ @group(0) @binding(0) var sceneTexture: texture_2d<f32>;
1170
+ @group(0) @binding(1) var sceneSampler: sampler;
1171
+ @group(0) @binding(2) var bloomTexture: texture_2d<f32>;
1172
+ @group(0) @binding(3) var bloomSampler: sampler;
1173
+ @group(0) @binding(4) var<uniform> composeUniforms: BloomComposeUniforms;
1174
+
1175
+ @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
1176
+ let scene = textureSample(sceneTexture, sceneSampler, input.uv);
1177
+ let bloom = textureSample(bloomTexture, bloomSampler, input.uv);
1178
+ // Additive blending with intensity control
1179
+ let result = scene.rgb + bloom.rgb * composeUniforms.intensity;
1180
+ return vec4f(result, scene.a);
1181
+ }
1182
+ `,
1183
+ })
1184
+
1185
+ // Create uniform buffer for blur direction (minimum 32 bytes for WebGPU)
1186
+ const blurDirectionBuffer = this.device.createBuffer({
1187
+ label: "blur direction",
1188
+ size: 32, // Minimum 32 bytes required for uniform buffers in WebGPU
1189
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1190
+ })
1191
+
1192
+ // Create uniform buffer for bloom intensity (minimum 32 bytes for WebGPU)
1193
+ const bloomIntensityBuffer = this.device.createBuffer({
1194
+ label: "bloom intensity",
1195
+ size: 32, // Minimum 32 bytes required for uniform buffers in WebGPU
1196
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1197
+ })
1198
+
1199
+ // Create uniform buffer for bloom threshold (minimum 32 bytes for WebGPU)
1200
+ const bloomThresholdBuffer = this.device.createBuffer({
1201
+ label: "bloom threshold",
1202
+ size: 32, // Minimum 32 bytes required for uniform buffers in WebGPU
1203
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1204
+ })
1205
+
1206
+ // Set default bloom values
1207
+ const intensityData = new Float32Array(8) // f32 + 7 padding floats = 8 floats = 32 bytes
1208
+ intensityData[0] = this.bloomIntensity
1209
+ this.device.queue.writeBuffer(bloomIntensityBuffer, 0, intensityData)
1210
+
1211
+ const thresholdData = new Float32Array(8) // f32 + 7 padding floats = 8 floats = 32 bytes
1212
+ thresholdData[0] = this.bloomThreshold
1213
+ this.device.queue.writeBuffer(bloomThresholdBuffer, 0, thresholdData)
1214
+
1215
+ // Create linear sampler for post-processing
1216
+ const linearSampler = this.device.createSampler({
1217
+ magFilter: "linear",
1218
+ minFilter: "linear",
1219
+ addressModeU: "clamp-to-edge",
1220
+ addressModeV: "clamp-to-edge",
1221
+ })
1222
+
1223
+ // Bloom extraction pipeline
1224
+ this.bloomExtractPipeline = this.device.createRenderPipeline({
1225
+ label: "bloom extract",
1226
+ layout: "auto",
1227
+ vertex: {
1228
+ module: bloomExtractShader,
1229
+ entryPoint: "vs",
1230
+ },
1231
+ fragment: {
1232
+ module: bloomExtractShader,
1233
+ entryPoint: "fs",
1234
+ targets: [{ format: this.presentationFormat }],
1235
+ },
1236
+ primitive: { topology: "triangle-list" },
1237
+ })
1238
+
1239
+ // Bloom blur pipeline
1240
+ this.bloomBlurPipeline = this.device.createRenderPipeline({
1241
+ label: "bloom blur",
1242
+ layout: "auto",
1243
+ vertex: {
1244
+ module: bloomBlurShader,
1245
+ entryPoint: "vs",
1246
+ },
1247
+ fragment: {
1248
+ module: bloomBlurShader,
1249
+ entryPoint: "fs",
1250
+ targets: [{ format: this.presentationFormat }],
1251
+ },
1252
+ primitive: { topology: "triangle-list" },
1253
+ })
1254
+
1255
+ // Bloom composition pipeline
1256
+ this.bloomComposePipeline = this.device.createRenderPipeline({
1257
+ label: "bloom compose",
1258
+ layout: "auto",
1259
+ vertex: {
1260
+ module: bloomComposeShader,
1261
+ entryPoint: "vs",
1262
+ },
1263
+ fragment: {
1264
+ module: bloomComposeShader,
1265
+ entryPoint: "fs",
1266
+ targets: [{ format: this.presentationFormat }],
1267
+ },
1268
+ primitive: { topology: "triangle-list" },
1269
+ })
1270
+
1271
+ // Store buffers and sampler for later use
1272
+ this.blurDirectionBuffer = blurDirectionBuffer
1273
+ this.bloomIntensityBuffer = bloomIntensityBuffer
1274
+ this.bloomThresholdBuffer = bloomThresholdBuffer
1275
+ this.linearSampler = linearSampler
1276
+ }
1277
+
981
1278
  // Step 3: Setup canvas resize handling
982
1279
  private setupResize() {
983
1280
  this.resizeObserver = new ResizeObserver(() => this.handleResize())
@@ -1013,19 +1310,51 @@ export class Engine {
1013
1310
  usage: GPUTextureUsage.RENDER_ATTACHMENT,
1014
1311
  })
1015
1312
 
1313
+ // Create scene render texture (non-multisampled for post-processing)
1314
+ this.sceneRenderTexture = this.device.createTexture({
1315
+ label: "scene render texture",
1316
+ size: [width, height],
1317
+ format: this.presentationFormat,
1318
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1319
+ })
1320
+ this.sceneRenderTextureView = this.sceneRenderTexture.createView()
1321
+
1322
+ // Create bloom textures (half resolution for performance)
1323
+ const bloomWidth = Math.floor(width / 2)
1324
+ const bloomHeight = Math.floor(height / 2)
1325
+ this.bloomExtractTexture = this.device.createTexture({
1326
+ label: "bloom extract",
1327
+ size: [bloomWidth, bloomHeight],
1328
+ format: this.presentationFormat,
1329
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1330
+ })
1331
+ this.bloomBlurTexture1 = this.device.createTexture({
1332
+ label: "bloom blur 1",
1333
+ size: [bloomWidth, bloomHeight],
1334
+ format: this.presentationFormat,
1335
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1336
+ })
1337
+ this.bloomBlurTexture2 = this.device.createTexture({
1338
+ label: "bloom blur 2",
1339
+ size: [bloomWidth, bloomHeight],
1340
+ format: this.presentationFormat,
1341
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1342
+ })
1343
+
1016
1344
  const depthTextureView = this.depthTexture.createView()
1017
1345
 
1346
+ // Render scene to texture instead of directly to canvas
1018
1347
  const colorAttachment: GPURenderPassColorAttachment =
1019
1348
  this.sampleCount > 1
1020
1349
  ? {
1021
1350
  view: this.multisampleTexture.createView(),
1022
- resolveTarget: this.context.getCurrentTexture().createView(),
1351
+ resolveTarget: this.sceneRenderTextureView,
1023
1352
  clearValue: { r: 0, g: 0, b: 0, a: 0 },
1024
1353
  loadOp: "clear",
1025
1354
  storeOp: "store",
1026
1355
  }
1027
1356
  : {
1028
- view: this.context.getCurrentTexture().createView(),
1357
+ view: this.sceneRenderTextureView,
1029
1358
  clearValue: { r: 0, g: 0, b: 0, a: 0 },
1030
1359
  loadOp: "clear",
1031
1360
  storeOp: "store",
@@ -1377,8 +1706,7 @@ export class Engine {
1377
1706
  })
1378
1707
  this.device.queue.writeBuffer(materialUniformBuffer, 0, materialUniformData)
1379
1708
 
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
1709
+ // Create bind groups using the shared bind group layout - All pipelines (main, eye, hair multiply, hair opaque) use the same shader and layout
1382
1710
  const bindGroup = this.device.createBindGroup({
1383
1711
  label: `material bind group: ${mat.name}`,
1384
1712
  layout: this.hairBindGroupLayout,
@@ -1425,8 +1753,7 @@ export class Engine {
1425
1753
  })
1426
1754
  }
1427
1755
 
1428
- // Outline for all materials (including transparent)
1429
- // Edge flag is at bit 4 (0x10) in PMX format, not bit 0 (0x01)
1756
+ // Outline for all materials (including transparent) - Edge flag is at bit 4 (0x10) in PMX format, not bit 0 (0x01)
1430
1757
  if ((mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
1431
1758
  const materialUniformData = new Float32Array(8)
1432
1759
  materialUniformData[0] = mat.edgeColor[0]
@@ -1560,8 +1887,9 @@ export class Engine {
1560
1887
 
1561
1888
  this.drawCallCount = 0
1562
1889
 
1563
- // === PASS 1: Opaque non-eye, non-hair (face, body, etc) ===
1564
- this.drawOutlines(pass, false) // Opaque outlines
1890
+ // PASS 1: Opaque non-eye, non-hair (face, body, etc)
1891
+ // this.drawOutlines(pass, false) // Opaque outlines
1892
+
1565
1893
  pass.setPipeline(this.pipeline)
1566
1894
  for (const draw of this.opaqueNonEyeNonHairDraws) {
1567
1895
  if (draw.count > 0) {
@@ -1571,7 +1899,7 @@ export class Engine {
1571
1899
  }
1572
1900
  }
1573
1901
 
1574
- // === PASS 2: Eyes (writes stencil = 1) ===
1902
+ // PASS 2: Eyes (writes stencil = 1)
1575
1903
  pass.setPipeline(this.eyePipeline)
1576
1904
  pass.setStencilReference(1) // Set stencil reference value to 1
1577
1905
  for (const draw of this.eyeDraws) {
@@ -1582,8 +1910,7 @@ export class Engine {
1582
1910
  }
1583
1911
  }
1584
1912
 
1585
- // === PASS 3a: Hair over eyes (stencil == 1, multiply blend) ===
1586
- // Draw hair geometry first to establish depth
1913
+ // PASS 3a: Hair over eyes (stencil == 1, multiply blend) - Draw hair geometry first to establish depth
1587
1914
  pass.setPipeline(this.hairMultiplyPipeline)
1588
1915
  pass.setStencilReference(1) // Check against stencil value 1
1589
1916
  for (const draw of this.hairDraws) {
@@ -1594,9 +1921,7 @@ export class Engine {
1594
1921
  }
1595
1922
  }
1596
1923
 
1597
- // === PASS 3a.5: Hair outlines over eyes (stencil == 1, depth test to only draw near hair) ===
1598
- // Use depth compare "less-equal" with the hair depth to only draw outline where hair exists
1599
- // The outline is expanded outward, so we need to ensure it only appears near the hair edge
1924
+ // PASS 3a.5: Hair outlines over eyes (stencil == 1, depth test to only draw near hair)
1600
1925
  pass.setPipeline(this.hairOutlineOverEyesPipeline)
1601
1926
  pass.setStencilReference(1) // Check against stencil value 1 (with equal test)
1602
1927
  for (const draw of this.hairOutlineDraws) {
@@ -1606,7 +1931,7 @@ export class Engine {
1606
1931
  }
1607
1932
  }
1608
1933
 
1609
- // === PASS 3b: Hair over non-eyes (stencil != 1, opaque) ===
1934
+ // PASS 3b: Hair over non-eyes (stencil != 1, opaque)
1610
1935
  pass.setPipeline(this.hairOpaquePipeline)
1611
1936
  pass.setStencilReference(1) // Check against stencil value 1 (with not-equal test)
1612
1937
  for (const draw of this.hairDraws) {
@@ -1617,8 +1942,7 @@ export class Engine {
1617
1942
  }
1618
1943
  }
1619
1944
 
1620
- // === PASS 3b.5: Hair outlines over non-eyes (stencil != 1) ===
1621
- // Draw hair outlines after hair geometry, so they only appear where hair exists
1945
+ // PASS 3b.5: Hair outlines over non-eyes (stencil != 1) - Draw hair outlines after hair geometry, so they only appear where hair exists
1622
1946
  pass.setPipeline(this.hairOutlinePipeline)
1623
1947
  pass.setStencilReference(1) // Check against stencil value 1 (with not-equal test)
1624
1948
  for (const draw of this.hairOutlineDraws) {
@@ -1628,7 +1952,9 @@ export class Engine {
1628
1952
  }
1629
1953
  }
1630
1954
 
1631
- // === PASS 4: Transparent non-eye, non-hair ===
1955
+ this.drawOutlines(pass, false) // Opaque outlines
1956
+
1957
+ // PASS 4: Transparent non-eye, non-hair
1632
1958
  pass.setPipeline(this.pipeline)
1633
1959
  for (const draw of this.transparentNonEyeNonHairDraws) {
1634
1960
  if (draw.count > 0) {
@@ -1642,10 +1968,156 @@ export class Engine {
1642
1968
 
1643
1969
  pass.end()
1644
1970
  this.device.queue.submit([encoder.finish()])
1971
+
1972
+ // Apply bloom post-processing
1973
+ this.applyBloom()
1974
+
1645
1975
  this.updateStats(performance.now() - currentTime)
1646
1976
  }
1647
1977
  }
1648
1978
 
1979
+ // Apply bloom post-processing
1980
+ private applyBloom() {
1981
+ if (!this.sceneRenderTexture || !this.bloomExtractTexture) {
1982
+ return
1983
+ }
1984
+
1985
+ // Update bloom parameters
1986
+ const thresholdData = new Float32Array(8)
1987
+ thresholdData[0] = this.bloomThreshold
1988
+ this.device.queue.writeBuffer(this.bloomThresholdBuffer, 0, thresholdData)
1989
+
1990
+ const intensityData = new Float32Array(8)
1991
+ intensityData[0] = this.bloomIntensity
1992
+ this.device.queue.writeBuffer(this.bloomIntensityBuffer, 0, intensityData)
1993
+
1994
+ const encoder = this.device.createCommandEncoder()
1995
+ const width = this.canvas.width
1996
+ const height = this.canvas.height
1997
+ const bloomWidth = Math.floor(width / 2)
1998
+ const bloomHeight = Math.floor(height / 2)
1999
+
2000
+ // Pass 1: Extract bright areas (downsample to half resolution)
2001
+ const extractPass = encoder.beginRenderPass({
2002
+ label: "bloom extract",
2003
+ colorAttachments: [
2004
+ {
2005
+ view: this.bloomExtractTexture.createView(),
2006
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
2007
+ loadOp: "clear",
2008
+ storeOp: "store",
2009
+ },
2010
+ ],
2011
+ })
2012
+
2013
+ const extractBindGroup = this.device.createBindGroup({
2014
+ layout: this.bloomExtractPipeline.getBindGroupLayout(0),
2015
+ entries: [
2016
+ { binding: 0, resource: this.sceneRenderTexture.createView() },
2017
+ { binding: 1, resource: this.linearSampler },
2018
+ { binding: 2, resource: { buffer: this.bloomThresholdBuffer } },
2019
+ ],
2020
+ })
2021
+
2022
+ extractPass.setPipeline(this.bloomExtractPipeline)
2023
+ extractPass.setBindGroup(0, extractBindGroup)
2024
+ extractPass.draw(6, 1, 0, 0)
2025
+ extractPass.end()
2026
+
2027
+ // Pass 2: Horizontal blur
2028
+ const hBlurData = new Float32Array(4) // vec2f + padding = 4 floats
2029
+ hBlurData[0] = 1.0
2030
+ hBlurData[1] = 0.0
2031
+ this.device.queue.writeBuffer(this.blurDirectionBuffer, 0, hBlurData)
2032
+ const blurHPass = encoder.beginRenderPass({
2033
+ label: "bloom blur horizontal",
2034
+ colorAttachments: [
2035
+ {
2036
+ view: this.bloomBlurTexture1.createView(),
2037
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
2038
+ loadOp: "clear",
2039
+ storeOp: "store",
2040
+ },
2041
+ ],
2042
+ })
2043
+
2044
+ const blurHBindGroup = this.device.createBindGroup({
2045
+ layout: this.bloomBlurPipeline.getBindGroupLayout(0),
2046
+ entries: [
2047
+ { binding: 0, resource: this.bloomExtractTexture.createView() },
2048
+ { binding: 1, resource: this.linearSampler },
2049
+ { binding: 2, resource: { buffer: this.blurDirectionBuffer } },
2050
+ ],
2051
+ })
2052
+
2053
+ blurHPass.setPipeline(this.bloomBlurPipeline)
2054
+ blurHPass.setBindGroup(0, blurHBindGroup)
2055
+ blurHPass.draw(6, 1, 0, 0)
2056
+ blurHPass.end()
2057
+
2058
+ // Pass 3: Vertical blur
2059
+ const vBlurData = new Float32Array(4) // vec2f + padding = 4 floats
2060
+ vBlurData[0] = 0.0
2061
+ vBlurData[1] = 1.0
2062
+ this.device.queue.writeBuffer(this.blurDirectionBuffer, 0, vBlurData)
2063
+ const blurVPass = encoder.beginRenderPass({
2064
+ label: "bloom blur vertical",
2065
+ colorAttachments: [
2066
+ {
2067
+ view: this.bloomBlurTexture2.createView(),
2068
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
2069
+ loadOp: "clear",
2070
+ storeOp: "store",
2071
+ },
2072
+ ],
2073
+ })
2074
+
2075
+ const blurVBindGroup = this.device.createBindGroup({
2076
+ layout: this.bloomBlurPipeline.getBindGroupLayout(0),
2077
+ entries: [
2078
+ { binding: 0, resource: this.bloomBlurTexture1.createView() },
2079
+ { binding: 1, resource: this.linearSampler },
2080
+ { binding: 2, resource: { buffer: this.blurDirectionBuffer } },
2081
+ ],
2082
+ })
2083
+
2084
+ blurVPass.setPipeline(this.bloomBlurPipeline)
2085
+ blurVPass.setBindGroup(0, blurVBindGroup)
2086
+ blurVPass.draw(6, 1, 0, 0)
2087
+ blurVPass.end()
2088
+
2089
+ // Pass 4: Compose scene + bloom to canvas
2090
+ const composePass = encoder.beginRenderPass({
2091
+ label: "bloom compose",
2092
+ colorAttachments: [
2093
+ {
2094
+ view: this.context.getCurrentTexture().createView(),
2095
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
2096
+ loadOp: "clear",
2097
+ storeOp: "store",
2098
+ },
2099
+ ],
2100
+ })
2101
+
2102
+ const composeBindGroup = this.device.createBindGroup({
2103
+ layout: this.bloomComposePipeline.getBindGroupLayout(0),
2104
+ entries: [
2105
+ { binding: 0, resource: this.sceneRenderTexture.createView() },
2106
+ { binding: 1, resource: this.linearSampler },
2107
+ { binding: 2, resource: this.bloomBlurTexture2.createView() },
2108
+ { binding: 3, resource: this.linearSampler },
2109
+ { binding: 4, resource: { buffer: this.bloomIntensityBuffer } },
2110
+ ],
2111
+ })
2112
+
2113
+ composePass.setPipeline(this.bloomComposePipeline)
2114
+ composePass.setBindGroup(0, composeBindGroup)
2115
+ composePass.draw(6, 1, 0, 0)
2116
+ composePass.end()
2117
+
2118
+ this.device.queue.submit([encoder.finish()])
2119
+ }
2120
+
1649
2121
  // Update camera uniform buffer each frame
1650
2122
  private updateCameraUniforms() {
1651
2123
  const viewMatrix = this.camera.getViewMatrix()
@@ -1663,9 +2135,11 @@ export class Engine {
1663
2135
  private updateRenderTarget() {
1664
2136
  const colorAttachment = (this.renderPassDescriptor.colorAttachments as GPURenderPassColorAttachment[])[0]
1665
2137
  if (this.sampleCount > 1) {
1666
- colorAttachment.resolveTarget = this.context.getCurrentTexture().createView()
2138
+ // Resolve to scene render texture for post-processing
2139
+ colorAttachment.resolveTarget = this.sceneRenderTextureView
1667
2140
  } else {
1668
- colorAttachment.view = this.context.getCurrentTexture().createView()
2141
+ // Render directly to scene render texture
2142
+ colorAttachment.view = this.sceneRenderTextureView
1669
2143
  }
1670
2144
  }
1671
2145