reze-engine 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/engine.js CHANGED
@@ -9,7 +9,11 @@ export class Engine {
9
9
  this.lightData = new Float32Array(64);
10
10
  this.lightCount = 0;
11
11
  this.resizeObserver = null;
12
- this.sampleCount = 4; // MSAA 4x
12
+ this.sampleCount = 4;
13
+ // Constants
14
+ this.STENCIL_EYE_VALUE = 1;
15
+ this.COMPUTE_WORKGROUP_SIZE = 64;
16
+ this.BLOOM_DOWNSCALE_FACTOR = 2;
13
17
  // Ambient light settings
14
18
  this.ambient = 1.0;
15
19
  // Bloom settings
@@ -22,7 +26,6 @@ export class Engine {
22
26
  this.modelDir = "";
23
27
  this.physics = null;
24
28
  this.textureCache = new Map();
25
- this.textureSizes = new Map();
26
29
  this.lastFpsUpdate = performance.now();
27
30
  this.framesSinceLastUpdate = 0;
28
31
  this.frameTimeSamples = [];
@@ -38,6 +41,7 @@ export class Engine {
38
41
  this.renderLoopCallback = null;
39
42
  this.animationFrames = [];
40
43
  this.animationTimeouts = [];
44
+ this.gpuMemoryMB = 0;
41
45
  this.opaqueNonEyeNonHairDraws = [];
42
46
  this.eyeDraws = [];
43
47
  this.hairDrawsOverEyes = [];
@@ -50,10 +54,8 @@ export class Engine {
50
54
  this.canvas = canvas;
51
55
  if (options) {
52
56
  this.ambient = options.ambient ?? 1.0;
53
- this.bloomThreshold = options.bloomThreshold ?? 0.3;
54
57
  this.bloomIntensity = options.bloomIntensity ?? 0.12;
55
58
  this.rimLightIntensity = options.rimLightIntensity ?? 0.45;
56
- this.rimLightPower = options.rimLightPower ?? 2.0;
57
59
  }
58
60
  }
59
61
  // Step 1: Get WebGPU device and context
@@ -214,8 +216,8 @@ export class Engine {
214
216
  `,
215
217
  });
216
218
  // Create explicit bind group layout for all pipelines using the main shader
217
- this.hairBindGroupLayout = this.device.createBindGroupLayout({
218
- label: "shared material bind group layout",
219
+ this.mainBindGroupLayout = this.device.createBindGroupLayout({
220
+ label: "main material bind group layout",
219
221
  entries: [
220
222
  { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // camera
221
223
  { binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // light
@@ -227,14 +229,13 @@ export class Engine {
227
229
  { binding: 7, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // material
228
230
  ],
229
231
  });
230
- const sharedPipelineLayout = this.device.createPipelineLayout({
231
- label: "shared pipeline layout",
232
- bindGroupLayouts: [this.hairBindGroupLayout],
232
+ const mainPipelineLayout = this.device.createPipelineLayout({
233
+ label: "main pipeline layout",
234
+ bindGroupLayouts: [this.mainBindGroupLayout],
233
235
  });
234
- // Single pipeline for all materials with alpha blending
235
- this.pipeline = this.device.createRenderPipeline({
236
+ this.modelPipeline = this.device.createRenderPipeline({
236
237
  label: "model pipeline",
237
- layout: sharedPipelineLayout,
238
+ layout: mainPipelineLayout,
238
239
  vertex: {
239
240
  module: shaderModule,
240
241
  buffers: [
@@ -437,9 +438,9 @@ export class Engine {
437
438
  count: this.sampleCount,
438
439
  },
439
440
  });
440
- // Unified hair outline pipeline: single pass without stencil testing, uses depth test "less-equal" to draw everywhere hair exists
441
- this.hairUnifiedOutlinePipeline = this.device.createRenderPipeline({
442
- label: "unified hair outline pipeline",
441
+ // Hair outline pipeline
442
+ this.hairOutlinePipeline = this.device.createRenderPipeline({
443
+ label: "hair outline pipeline",
443
444
  layout: outlinePipelineLayout,
444
445
  vertex: {
445
446
  module: outlineShaderModule,
@@ -507,7 +508,7 @@ export class Engine {
507
508
  // Eye overlay pipeline (renders after opaque, writes stencil)
508
509
  this.eyePipeline = this.device.createRenderPipeline({
509
510
  label: "eye overlay pipeline",
510
- layout: sharedPipelineLayout,
511
+ layout: mainPipelineLayout,
511
512
  vertex: {
512
513
  module: shaderModule,
513
514
  buffers: [
@@ -620,7 +621,7 @@ export class Engine {
620
621
  // Hair depth pre-pass pipeline: depth-only with color writes disabled to eliminate overdraw
621
622
  this.hairDepthPipeline = this.device.createRenderPipeline({
622
623
  label: "hair depth pre-pass",
623
- layout: sharedPipelineLayout,
624
+ layout: mainPipelineLayout,
624
625
  vertex: {
625
626
  module: depthOnlyShaderModule,
626
627
  buffers: [
@@ -659,10 +660,10 @@ export class Engine {
659
660
  },
660
661
  multisample: { count: this.sampleCount },
661
662
  });
662
- // Unified hair pipeline for over-eyes (stencil == 1): single pass with dynamic branching
663
- this.hairUnifiedPipelineOverEyes = this.device.createRenderPipeline({
664
- label: "unified hair pipeline (over eyes)",
665
- layout: sharedPipelineLayout,
663
+ // Hair pipeline for rendering over eyes (stencil == 1)
664
+ this.hairPipelineOverEyes = this.device.createRenderPipeline({
665
+ label: "hair pipeline (over eyes)",
666
+ layout: mainPipelineLayout,
666
667
  vertex: {
667
668
  module: shaderModule,
668
669
  buffers: [
@@ -724,10 +725,10 @@ export class Engine {
724
725
  },
725
726
  multisample: { count: this.sampleCount },
726
727
  });
727
- // Unified pipeline for hair over non-eyes (stencil != 1)
728
- this.hairUnifiedPipelineOverNonEyes = this.device.createRenderPipeline({
729
- label: "unified hair pipeline (over non-eyes)",
730
- layout: sharedPipelineLayout,
728
+ // Hair pipeline for rendering over non-eyes (stencil != 1)
729
+ this.hairPipelineOverNonEyes = this.device.createRenderPipeline({
730
+ label: "hair pipeline (over non-eyes)",
731
+ layout: mainPipelineLayout,
731
732
  vertex: {
732
733
  module: shaderModule,
733
734
  buffers: [
@@ -808,10 +809,9 @@ export class Engine {
808
809
  @group(0) @binding(2) var<storage, read> inverseBindMatrices: array<mat4x4f>;
809
810
  @group(0) @binding(3) var<storage, read_write> skinMatrices: array<mat4x4f>;
810
811
 
811
- @compute @workgroup_size(64)
812
+ @compute @workgroup_size(64) // Must match COMPUTE_WORKGROUP_SIZE
812
813
  fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
813
814
  let boneIndex = globalId.x;
814
- // Bounds check: we dispatch workgroups (64 threads each), so some threads may be out of range
815
815
  if (boneIndex >= boneCount.count) {
816
816
  return;
817
817
  }
@@ -944,21 +944,16 @@ export class Engine {
944
944
  @group(0) @binding(1) var inputSampler: sampler;
945
945
  @group(0) @binding(2) var<uniform> blurUniforms: BlurUniforms;
946
946
 
947
- // 9-tap gaussian blur
947
+ // 5-tap gaussian blur
948
948
  @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
949
949
  let texelSize = 1.0 / vec2f(textureDimensions(inputTexture));
950
950
  var result = vec4f(0.0);
951
951
 
952
- // Gaussian weights for 9-tap filter
953
- let weights = array<f32, 9>(
954
- 0.01621622, 0.05405405, 0.12162162,
955
- 0.19459459, 0.22702703,
956
- 0.19459459, 0.12162162, 0.05405405, 0.01621622
957
- );
952
+ // Optimized 5-tap Gaussian filter (faster, nearly same quality)
953
+ let weights = array<f32, 5>(0.06136, 0.24477, 0.38774, 0.24477, 0.06136);
954
+ let offsets = array<f32, 5>(-2.0, -1.0, 0.0, 1.0, 2.0);
958
955
 
959
- let offsets = array<f32, 9>(-4.0, -3.0, -2.0, -1.0, 0.0, 1.0, 2.0, 3.0, 4.0);
960
-
961
- for (var i = 0u; i < 9u; i++) {
956
+ for (var i = 0u; i < 5u; i++) {
962
957
  let offset = offsets[i] * texelSize * blurUniforms.direction;
963
958
  result += textureSample(inputTexture, inputSampler, input.uv + offset) * weights[i];
964
959
  }
@@ -1094,11 +1089,9 @@ export class Engine {
1094
1089
  this.bloomThresholdBuffer = bloomThresholdBuffer;
1095
1090
  this.linearSampler = linearSampler;
1096
1091
  }
1097
- // Setup bloom textures and bind groups (called when canvas is resized)
1098
1092
  setupBloom(width, height) {
1099
- // Create bloom textures (half resolution for performance)
1100
- const bloomWidth = Math.floor(width / 2);
1101
- const bloomHeight = Math.floor(height / 2);
1093
+ const bloomWidth = Math.floor(width / this.BLOOM_DOWNSCALE_FACTOR);
1094
+ const bloomHeight = Math.floor(height / this.BLOOM_DOWNSCALE_FACTOR);
1102
1095
  this.bloomExtractTexture = this.device.createTexture({
1103
1096
  label: "bloom extract",
1104
1097
  size: [bloomWidth, bloomHeight],
@@ -1272,7 +1265,6 @@ export class Engine {
1272
1265
  async loadAnimation(url) {
1273
1266
  const frames = await VMDLoader.load(url);
1274
1267
  this.animationFrames = frames;
1275
- console.log(this.animationFrames);
1276
1268
  }
1277
1269
  playAnimation() {
1278
1270
  if (this.animationFrames.length === 0)
@@ -1334,7 +1326,9 @@ export class Engine {
1334
1326
  this.physics.reset(worldMats, this.currentModel.getBoneInverseBindMatrices());
1335
1327
  // Upload matrices immediately so next frame shows correct pose
1336
1328
  this.device.queue.writeBuffer(this.worldMatrixBuffer, 0, worldMats.buffer, worldMats.byteOffset, worldMats.byteLength);
1337
- this.computeSkinMatrices();
1329
+ const encoder = this.device.createCommandEncoder();
1330
+ this.computeSkinMatrices(encoder);
1331
+ this.device.queue.submit([encoder.finish()]);
1338
1332
  }
1339
1333
  }
1340
1334
  for (const [_, keyFrames] of boneKeyFramesByBone.entries()) {
@@ -1408,6 +1402,14 @@ export class Engine {
1408
1402
  const dir = pathParts.join("/") + "/";
1409
1403
  this.modelDir = dir;
1410
1404
  const model = await PmxLoader.load(path);
1405
+ // console.log({
1406
+ // vertices: Array.from(model.getVertices()),
1407
+ // indices: Array.from(model.getIndices()),
1408
+ // materials: model.getMaterials(),
1409
+ // textures: model.getTextures(),
1410
+ // bones: model.getSkeleton().bones,
1411
+ // skinning: { joints: Array.from(model.getSkinning().joints), weights: Array.from(model.getSkinning().weights) },
1412
+ // })
1411
1413
  this.physics = new Physics(model.getRigidbodies(), model.getJoints());
1412
1414
  await this.setupModelBuffers(model);
1413
1415
  }
@@ -1535,7 +1537,6 @@ export class Engine {
1535
1537
  });
1536
1538
  this.device.queue.writeTexture({ texture: defaultToonTexture }, defaultToonData, { bytesPerRow: 256 * 4 }, [256, 2]);
1537
1539
  this.textureCache.set(defaultToonPath, defaultToonTexture);
1538
- this.textureSizes.set(defaultToonPath, { width: 256, height: 2 });
1539
1540
  return defaultToonTexture;
1540
1541
  };
1541
1542
  this.opaqueNonEyeNonHairDraws = [];
@@ -1547,10 +1548,10 @@ export class Engine {
1547
1548
  this.eyeOutlineDraws = [];
1548
1549
  this.hairOutlineDraws = [];
1549
1550
  this.transparentNonEyeNonHairOutlineDraws = [];
1550
- let runningFirstIndex = 0;
1551
+ let currentIndexOffset = 0;
1551
1552
  for (const mat of materials) {
1552
- const matCount = mat.vertexCount | 0;
1553
- if (matCount === 0)
1553
+ const indexCount = mat.vertexCount;
1554
+ if (indexCount === 0)
1554
1555
  continue;
1555
1556
  const diffuseTexture = await loadTextureByIndex(mat.diffuseTextureIndex);
1556
1557
  if (!diffuseTexture)
@@ -1578,7 +1579,7 @@ export class Engine {
1578
1579
  // Create bind groups using the shared bind group layout - All pipelines (main, eye, hair multiply, hair opaque) use the same shader and layout
1579
1580
  const bindGroup = this.device.createBindGroup({
1580
1581
  label: `material bind group: ${mat.name}`,
1581
- layout: this.hairBindGroupLayout,
1582
+ layout: this.mainBindGroupLayout,
1582
1583
  entries: [
1583
1584
  { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1584
1585
  { binding: 1, resource: { buffer: this.lightUniformBuffer } },
@@ -1593,100 +1594,77 @@ export class Engine {
1593
1594
  // Classify materials into appropriate draw lists
1594
1595
  if (mat.isEye) {
1595
1596
  this.eyeDraws.push({
1596
- count: matCount,
1597
- firstIndex: runningFirstIndex,
1597
+ count: indexCount,
1598
+ firstIndex: currentIndexOffset,
1598
1599
  bindGroup,
1599
1600
  isTransparent,
1600
1601
  });
1601
1602
  }
1602
1603
  else if (mat.isHair) {
1603
- // Hair materials: create bind groups for unified pipeline with dynamic branching
1604
- const materialUniformDataHair = new Float32Array(8);
1605
- materialUniformDataHair[0] = materialAlpha;
1606
- materialUniformDataHair[1] = 1.0; // alphaMultiplier: base value, shader will adjust
1607
- materialUniformDataHair[2] = this.rimLightIntensity;
1608
- materialUniformDataHair[3] = this.rimLightPower;
1609
- materialUniformDataHair[4] = 1.0; // rimColor.r
1610
- materialUniformDataHair[5] = 1.0; // rimColor.g
1611
- materialUniformDataHair[6] = 1.0; // rimColor.b
1612
- materialUniformDataHair[7] = 0.0;
1613
- // Create uniform buffers for both modes
1614
- const materialUniformBufferOverEyes = this.device.createBuffer({
1615
- label: `material uniform (over eyes): ${mat.name}`,
1616
- size: materialUniformDataHair.byteLength,
1617
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1618
- });
1619
- const materialUniformDataOverEyes = new Float32Array(materialUniformDataHair);
1620
- materialUniformDataOverEyes[7] = 1.0;
1621
- this.device.queue.writeBuffer(materialUniformBufferOverEyes, 0, materialUniformDataOverEyes);
1622
- const materialUniformBufferOverNonEyes = this.device.createBuffer({
1623
- label: `material uniform (over non-eyes): ${mat.name}`,
1624
- size: materialUniformDataHair.byteLength,
1625
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1626
- });
1627
- const materialUniformDataOverNonEyes = new Float32Array(materialUniformDataHair);
1628
- materialUniformDataOverNonEyes[7] = 0.0;
1629
- this.device.queue.writeBuffer(materialUniformBufferOverNonEyes, 0, materialUniformDataOverNonEyes);
1630
- // Create bind groups for both modes
1631
- const bindGroupOverEyes = this.device.createBindGroup({
1632
- label: `material bind group (over eyes): ${mat.name}`,
1633
- layout: this.hairBindGroupLayout,
1634
- entries: [
1635
- { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1636
- { binding: 1, resource: { buffer: this.lightUniformBuffer } },
1637
- { binding: 2, resource: diffuseTexture.createView() },
1638
- { binding: 3, resource: this.textureSampler },
1639
- { binding: 4, resource: { buffer: this.skinMatrixBuffer } },
1640
- { binding: 5, resource: toonTexture.createView() },
1641
- { binding: 6, resource: this.textureSampler },
1642
- { binding: 7, resource: { buffer: materialUniformBufferOverEyes } },
1643
- ],
1644
- });
1645
- const bindGroupOverNonEyes = this.device.createBindGroup({
1646
- label: `material bind group (over non-eyes): ${mat.name}`,
1647
- layout: this.hairBindGroupLayout,
1648
- entries: [
1649
- { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1650
- { binding: 1, resource: { buffer: this.lightUniformBuffer } },
1651
- { binding: 2, resource: diffuseTexture.createView() },
1652
- { binding: 3, resource: this.textureSampler },
1653
- { binding: 4, resource: { buffer: this.skinMatrixBuffer } },
1654
- { binding: 5, resource: toonTexture.createView() },
1655
- { binding: 6, resource: this.textureSampler },
1656
- { binding: 7, resource: { buffer: materialUniformBufferOverNonEyes } },
1657
- ],
1658
- });
1659
- // Store both bind groups for unified pipeline
1604
+ // Hair materials: create separate bind groups for over-eyes vs over-non-eyes
1605
+ const createHairBindGroup = (isOverEyes) => {
1606
+ const uniformData = new Float32Array(8);
1607
+ uniformData[0] = materialAlpha;
1608
+ uniformData[1] = 1.0; // alphaMultiplier (shader adjusts based on isOverEyes)
1609
+ uniformData[2] = this.rimLightIntensity;
1610
+ uniformData[3] = this.rimLightPower;
1611
+ uniformData[4] = 1.0; // rimColor.rgb
1612
+ uniformData[5] = 1.0;
1613
+ uniformData[6] = 1.0;
1614
+ uniformData[7] = isOverEyes ? 1.0 : 0.0;
1615
+ const buffer = this.device.createBuffer({
1616
+ label: `material uniform (${isOverEyes ? "over eyes" : "over non-eyes"}): ${mat.name}`,
1617
+ size: uniformData.byteLength,
1618
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1619
+ });
1620
+ this.device.queue.writeBuffer(buffer, 0, uniformData);
1621
+ return this.device.createBindGroup({
1622
+ label: `material bind group (${isOverEyes ? "over eyes" : "over non-eyes"}): ${mat.name}`,
1623
+ layout: this.mainBindGroupLayout,
1624
+ entries: [
1625
+ { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1626
+ { binding: 1, resource: { buffer: this.lightUniformBuffer } },
1627
+ { binding: 2, resource: diffuseTexture.createView() },
1628
+ { binding: 3, resource: this.textureSampler },
1629
+ { binding: 4, resource: { buffer: this.skinMatrixBuffer } },
1630
+ { binding: 5, resource: toonTexture.createView() },
1631
+ { binding: 6, resource: this.textureSampler },
1632
+ { binding: 7, resource: { buffer: buffer } },
1633
+ ],
1634
+ });
1635
+ };
1636
+ const bindGroupOverEyes = createHairBindGroup(true);
1637
+ const bindGroupOverNonEyes = createHairBindGroup(false);
1660
1638
  this.hairDrawsOverEyes.push({
1661
- count: matCount,
1662
- firstIndex: runningFirstIndex,
1639
+ count: indexCount,
1640
+ firstIndex: currentIndexOffset,
1663
1641
  bindGroup: bindGroupOverEyes,
1664
1642
  isTransparent,
1665
1643
  });
1666
1644
  this.hairDrawsOverNonEyes.push({
1667
- count: matCount,
1668
- firstIndex: runningFirstIndex,
1645
+ count: indexCount,
1646
+ firstIndex: currentIndexOffset,
1669
1647
  bindGroup: bindGroupOverNonEyes,
1670
1648
  isTransparent,
1671
1649
  });
1672
1650
  }
1673
1651
  else if (isTransparent) {
1674
1652
  this.transparentNonEyeNonHairDraws.push({
1675
- count: matCount,
1676
- firstIndex: runningFirstIndex,
1653
+ count: indexCount,
1654
+ firstIndex: currentIndexOffset,
1677
1655
  bindGroup,
1678
1656
  isTransparent,
1679
1657
  });
1680
1658
  }
1681
1659
  else {
1682
1660
  this.opaqueNonEyeNonHairDraws.push({
1683
- count: matCount,
1684
- firstIndex: runningFirstIndex,
1661
+ count: indexCount,
1662
+ firstIndex: currentIndexOffset,
1685
1663
  bindGroup,
1686
1664
  isTransparent,
1687
1665
  });
1688
1666
  }
1689
- // Outline for all materials (including transparent) - Edge flag is at bit 4 (0x10) in PMX format, not bit 0 (0x01)
1667
+ // Edge flag is at bit 4 (0x10) in PMX format
1690
1668
  if ((mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
1691
1669
  const materialUniformData = new Float32Array(8);
1692
1670
  materialUniformData[0] = mat.edgeColor[0]; // edgeColor.r
@@ -1694,9 +1672,9 @@ export class Engine {
1694
1672
  materialUniformData[2] = mat.edgeColor[2]; // edgeColor.b
1695
1673
  materialUniformData[3] = mat.edgeColor[3]; // edgeColor.a
1696
1674
  materialUniformData[4] = mat.edgeSize;
1697
- materialUniformData[5] = 0.0; // isOverEyes: 0.0 for all (unified pipeline doesn't use stencil)
1698
- materialUniformData[6] = 0.0; // _padding1
1699
- materialUniformData[7] = 0.0; // _padding2
1675
+ materialUniformData[5] = 0.0; // isOverEyes
1676
+ materialUniformData[6] = 0.0;
1677
+ materialUniformData[7] = 0.0;
1700
1678
  const materialUniformBuffer = this.device.createBuffer({
1701
1679
  label: `outline material uniform: ${mat.name}`,
1702
1680
  size: materialUniformData.byteLength,
@@ -1712,45 +1690,44 @@ export class Engine {
1712
1690
  { binding: 2, resource: { buffer: this.skinMatrixBuffer } },
1713
1691
  ],
1714
1692
  });
1715
- // Classify outlines into appropriate draw lists
1716
1693
  if (mat.isEye) {
1717
1694
  this.eyeOutlineDraws.push({
1718
- count: matCount,
1719
- firstIndex: runningFirstIndex,
1695
+ count: indexCount,
1696
+ firstIndex: currentIndexOffset,
1720
1697
  bindGroup: outlineBindGroup,
1721
1698
  isTransparent,
1722
1699
  });
1723
1700
  }
1724
1701
  else if (mat.isHair) {
1725
1702
  this.hairOutlineDraws.push({
1726
- count: matCount,
1727
- firstIndex: runningFirstIndex,
1703
+ count: indexCount,
1704
+ firstIndex: currentIndexOffset,
1728
1705
  bindGroup: outlineBindGroup,
1729
1706
  isTransparent,
1730
1707
  });
1731
1708
  }
1732
1709
  else if (isTransparent) {
1733
1710
  this.transparentNonEyeNonHairOutlineDraws.push({
1734
- count: matCount,
1735
- firstIndex: runningFirstIndex,
1711
+ count: indexCount,
1712
+ firstIndex: currentIndexOffset,
1736
1713
  bindGroup: outlineBindGroup,
1737
1714
  isTransparent,
1738
1715
  });
1739
1716
  }
1740
1717
  else {
1741
1718
  this.opaqueNonEyeNonHairOutlineDraws.push({
1742
- count: matCount,
1743
- firstIndex: runningFirstIndex,
1719
+ count: indexCount,
1720
+ firstIndex: currentIndexOffset,
1744
1721
  bindGroup: outlineBindGroup,
1745
1722
  isTransparent,
1746
1723
  });
1747
1724
  }
1748
1725
  }
1749
- runningFirstIndex += matCount;
1726
+ currentIndexOffset += indexCount;
1750
1727
  }
1728
+ this.gpuMemoryMB = this.calculateGpuMemory();
1751
1729
  }
1752
- // Helper: Load texture from file path with optional max size limit
1753
- async createTextureFromPath(path, maxSize = 2048) {
1730
+ async createTextureFromPath(path) {
1754
1731
  const cached = this.textureCache.get(path);
1755
1732
  if (cached) {
1756
1733
  return cached;
@@ -1760,41 +1737,28 @@ export class Engine {
1760
1737
  if (!response.ok) {
1761
1738
  throw new Error(`HTTP ${response.status}: ${response.statusText}`);
1762
1739
  }
1763
- let imageBitmap = await createImageBitmap(await response.blob(), {
1740
+ const imageBitmap = await createImageBitmap(await response.blob(), {
1764
1741
  premultiplyAlpha: "none",
1765
1742
  colorSpaceConversion: "none",
1766
1743
  });
1767
- // Downscale if texture is too large
1768
- let finalWidth = imageBitmap.width;
1769
- let finalHeight = imageBitmap.height;
1770
- if (finalWidth > maxSize || finalHeight > maxSize) {
1771
- const scale = Math.min(maxSize / finalWidth, maxSize / finalHeight);
1772
- finalWidth = Math.floor(finalWidth * scale);
1773
- finalHeight = Math.floor(finalHeight * scale);
1774
- // Create canvas to downscale
1775
- const canvas = new OffscreenCanvas(finalWidth, finalHeight);
1776
- const ctx = canvas.getContext("2d");
1777
- if (ctx) {
1778
- ctx.drawImage(imageBitmap, 0, 0, finalWidth, finalHeight);
1779
- imageBitmap = await createImageBitmap(canvas);
1780
- }
1781
- }
1782
1744
  const texture = this.device.createTexture({
1783
1745
  label: `texture: ${path}`,
1784
- size: [finalWidth, finalHeight],
1746
+ size: [imageBitmap.width, imageBitmap.height],
1785
1747
  format: "rgba8unorm",
1786
1748
  usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
1787
1749
  });
1788
- this.device.queue.copyExternalImageToTexture({ source: imageBitmap }, { texture }, [finalWidth, finalHeight]);
1750
+ this.device.queue.copyExternalImageToTexture({ source: imageBitmap }, { texture }, [
1751
+ imageBitmap.width,
1752
+ imageBitmap.height,
1753
+ ]);
1789
1754
  this.textureCache.set(path, texture);
1790
- this.textureSizes.set(path, { width: finalWidth, height: finalHeight });
1791
1755
  return texture;
1792
1756
  }
1793
1757
  catch {
1794
1758
  return null;
1795
1759
  }
1796
1760
  }
1797
- // Step 9: Render one frame
1761
+ // Render strategy: 1) Opaque non-eye/hair 2) Eyes (stencil=1) 3) Hair (depth pre-pass + split by stencil) 4) Transparent 5) Bloom
1798
1762
  render() {
1799
1763
  if (this.multisampleTexture && this.camera && this.device && this.currentModel) {
1800
1764
  const currentTime = performance.now();
@@ -1802,16 +1766,17 @@ export class Engine {
1802
1766
  this.lastFrameTime = currentTime;
1803
1767
  this.updateCameraUniforms();
1804
1768
  this.updateRenderTarget();
1805
- this.updateModelPose(deltaTime);
1769
+ // Use single encoder for both compute and render (reduces sync points)
1806
1770
  const encoder = this.device.createCommandEncoder();
1771
+ this.updateModelPose(deltaTime, encoder);
1807
1772
  const pass = encoder.beginRenderPass(this.renderPassDescriptor);
1808
1773
  pass.setVertexBuffer(0, this.vertexBuffer);
1809
1774
  pass.setVertexBuffer(1, this.jointsBuffer);
1810
1775
  pass.setVertexBuffer(2, this.weightsBuffer);
1811
1776
  pass.setIndexBuffer(this.indexBuffer, "uint32");
1812
1777
  this.drawCallCount = 0;
1813
- // PASS 1: Opaque non-eye, non-hair
1814
- pass.setPipeline(this.pipeline);
1778
+ // Pass 1: Opaque non-eye, non-hair
1779
+ pass.setPipeline(this.modelPipeline);
1815
1780
  for (const draw of this.opaqueNonEyeNonHairDraws) {
1816
1781
  if (draw.count > 0) {
1817
1782
  pass.setBindGroup(0, draw.bindGroup);
@@ -1819,9 +1784,9 @@ export class Engine {
1819
1784
  this.drawCallCount++;
1820
1785
  }
1821
1786
  }
1822
- // PASS 2: Eyes (writes stencil = 1)
1787
+ // Pass 2: Eyes (writes stencil value for hair to test against)
1823
1788
  pass.setPipeline(this.eyePipeline);
1824
- pass.setStencilReference(1); // Set stencil reference value to 1
1789
+ pass.setStencilReference(this.STENCIL_EYE_VALUE);
1825
1790
  for (const draw of this.eyeDraws) {
1826
1791
  if (draw.count > 0) {
1827
1792
  pass.setBindGroup(0, draw.bindGroup);
@@ -1829,9 +1794,9 @@ export class Engine {
1829
1794
  this.drawCallCount++;
1830
1795
  }
1831
1796
  }
1832
- // PASS 3: Hair rendering with depth pre-pass and unified pipeline
1797
+ // Pass 3: Hair rendering (depth pre-pass + shading + outlines)
1833
1798
  this.drawOutlines(pass, false);
1834
- // 3a: Hair depth pre-pass (eliminates overdraw by rejecting fragments early)
1799
+ // 3a: Hair depth pre-pass (reduces overdraw via early depth rejection)
1835
1800
  if (this.hairDrawsOverEyes.length > 0 || this.hairDrawsOverNonEyes.length > 0) {
1836
1801
  pass.setPipeline(this.hairDepthPipeline);
1837
1802
  for (const draw of this.hairDrawsOverEyes) {
@@ -1847,10 +1812,10 @@ export class Engine {
1847
1812
  }
1848
1813
  }
1849
1814
  }
1850
- // 3b: Hair shading pass with unified pipeline and dynamic branching
1815
+ // 3b: Hair shading (split by stencil for transparency over eyes)
1851
1816
  if (this.hairDrawsOverEyes.length > 0) {
1852
- pass.setPipeline(this.hairUnifiedPipelineOverEyes);
1853
- pass.setStencilReference(1);
1817
+ pass.setPipeline(this.hairPipelineOverEyes);
1818
+ pass.setStencilReference(this.STENCIL_EYE_VALUE);
1854
1819
  for (const draw of this.hairDrawsOverEyes) {
1855
1820
  if (draw.count > 0) {
1856
1821
  pass.setBindGroup(0, draw.bindGroup);
@@ -1860,8 +1825,8 @@ export class Engine {
1860
1825
  }
1861
1826
  }
1862
1827
  if (this.hairDrawsOverNonEyes.length > 0) {
1863
- pass.setPipeline(this.hairUnifiedPipelineOverNonEyes);
1864
- pass.setStencilReference(1);
1828
+ pass.setPipeline(this.hairPipelineOverNonEyes);
1829
+ pass.setStencilReference(this.STENCIL_EYE_VALUE);
1865
1830
  for (const draw of this.hairDrawsOverNonEyes) {
1866
1831
  if (draw.count > 0) {
1867
1832
  pass.setBindGroup(0, draw.bindGroup);
@@ -1870,9 +1835,9 @@ export class Engine {
1870
1835
  }
1871
1836
  }
1872
1837
  }
1873
- // 3c: Hair outlines - unified single pass without stencil testing
1838
+ // 3c: Hair outlines
1874
1839
  if (this.hairOutlineDraws.length > 0) {
1875
- pass.setPipeline(this.hairUnifiedOutlinePipeline);
1840
+ pass.setPipeline(this.hairOutlinePipeline);
1876
1841
  for (const draw of this.hairOutlineDraws) {
1877
1842
  if (draw.count > 0) {
1878
1843
  pass.setBindGroup(0, draw.bindGroup);
@@ -1880,8 +1845,8 @@ export class Engine {
1880
1845
  }
1881
1846
  }
1882
1847
  }
1883
- // PASS 4: Transparent non-eye, non-hair
1884
- pass.setPipeline(this.pipeline);
1848
+ // Pass 4: Transparent non-eye, non-hair
1849
+ pass.setPipeline(this.modelPipeline);
1885
1850
  for (const draw of this.transparentNonEyeNonHairDraws) {
1886
1851
  if (draw.count > 0) {
1887
1852
  pass.setBindGroup(0, draw.bindGroup);
@@ -1892,12 +1857,10 @@ export class Engine {
1892
1857
  this.drawOutlines(pass, true);
1893
1858
  pass.end();
1894
1859
  this.device.queue.submit([encoder.finish()]);
1895
- // Apply bloom post-processing
1896
1860
  this.applyBloom();
1897
1861
  this.updateStats(performance.now() - currentTime);
1898
1862
  }
1899
1863
  }
1900
- // Apply bloom post-processing
1901
1864
  applyBloom() {
1902
1865
  if (!this.sceneRenderTexture || !this.bloomExtractTexture) {
1903
1866
  return;
@@ -1912,9 +1875,9 @@ export class Engine {
1912
1875
  const encoder = this.device.createCommandEncoder();
1913
1876
  const width = this.canvas.width;
1914
1877
  const height = this.canvas.height;
1915
- const bloomWidth = Math.floor(width / 2);
1916
- const bloomHeight = Math.floor(height / 2);
1917
- // Pass 1: Extract bright areas (downsample to half resolution)
1878
+ const bloomWidth = Math.floor(width / this.BLOOM_DOWNSCALE_FACTOR);
1879
+ const bloomHeight = Math.floor(height / this.BLOOM_DOWNSCALE_FACTOR);
1880
+ // Extract bright areas
1918
1881
  const extractPass = encoder.beginRenderPass({
1919
1882
  label: "bloom extract",
1920
1883
  colorAttachments: [
@@ -1930,8 +1893,8 @@ export class Engine {
1930
1893
  extractPass.setBindGroup(0, this.bloomExtractBindGroup);
1931
1894
  extractPass.draw(6, 1, 0, 0);
1932
1895
  extractPass.end();
1933
- // Pass 2: Horizontal blur
1934
- const hBlurData = new Float32Array(4); // vec2f + padding = 4 floats
1896
+ // Horizontal blur
1897
+ const hBlurData = new Float32Array(4);
1935
1898
  hBlurData[0] = 1.0;
1936
1899
  hBlurData[1] = 0.0;
1937
1900
  this.device.queue.writeBuffer(this.blurDirectionBuffer, 0, hBlurData);
@@ -1950,8 +1913,8 @@ export class Engine {
1950
1913
  blurHPass.setBindGroup(0, this.bloomBlurHBindGroup);
1951
1914
  blurHPass.draw(6, 1, 0, 0);
1952
1915
  blurHPass.end();
1953
- // Pass 3: Vertical blur
1954
- const vBlurData = new Float32Array(4); // vec2f + padding = 4 floats
1916
+ // Vertical blur
1917
+ const vBlurData = new Float32Array(4);
1955
1918
  vBlurData[0] = 0.0;
1956
1919
  vBlurData[1] = 1.0;
1957
1920
  this.device.queue.writeBuffer(this.blurDirectionBuffer, 0, vBlurData);
@@ -1970,7 +1933,7 @@ export class Engine {
1970
1933
  blurVPass.setBindGroup(0, this.bloomBlurVBindGroup);
1971
1934
  blurVPass.draw(6, 1, 0, 0);
1972
1935
  blurVPass.end();
1973
- // Pass 4: Compose scene + bloom to canvas
1936
+ // Compose to canvas
1974
1937
  const composePass = encoder.beginRenderPass({
1975
1938
  label: "bloom compose",
1976
1939
  colorAttachments: [
@@ -1988,7 +1951,6 @@ export class Engine {
1988
1951
  composePass.end();
1989
1952
  this.device.queue.submit([encoder.finish()]);
1990
1953
  }
1991
- // Update camera uniform buffer each frame
1992
1954
  updateCameraUniforms() {
1993
1955
  const viewMatrix = this.camera.getViewMatrix();
1994
1956
  const projectionMatrix = this.camera.getProjectionMatrix();
@@ -2000,47 +1962,36 @@ export class Engine {
2000
1962
  this.cameraMatrixData[34] = cameraPos.z;
2001
1963
  this.device.queue.writeBuffer(this.cameraUniformBuffer, 0, this.cameraMatrixData);
2002
1964
  }
2003
- // Update render target texture view
2004
1965
  updateRenderTarget() {
2005
1966
  const colorAttachment = this.renderPassDescriptor.colorAttachments[0];
2006
1967
  if (this.sampleCount > 1) {
2007
- // Resolve to scene render texture for post-processing
2008
1968
  colorAttachment.resolveTarget = this.sceneRenderTextureView;
2009
1969
  }
2010
1970
  else {
2011
- // Render directly to scene render texture
2012
1971
  colorAttachment.view = this.sceneRenderTextureView;
2013
1972
  }
2014
1973
  }
2015
- updateModelPose(deltaTime) {
1974
+ updateModelPose(deltaTime, encoder) {
2016
1975
  this.currentModel.evaluatePose();
2017
1976
  const worldMats = this.currentModel.getBoneWorldMatrices();
2018
1977
  if (this.physics) {
2019
1978
  this.physics.step(deltaTime, worldMats, this.currentModel.getBoneInverseBindMatrices());
2020
1979
  }
2021
1980
  this.device.queue.writeBuffer(this.worldMatrixBuffer, 0, worldMats.buffer, worldMats.byteOffset, worldMats.byteLength);
2022
- this.computeSkinMatrices();
1981
+ this.computeSkinMatrices(encoder);
2023
1982
  }
2024
- // Compute skin matrices on GPU
2025
- computeSkinMatrices() {
1983
+ computeSkinMatrices(encoder) {
2026
1984
  const boneCount = this.currentModel.getSkeleton().bones.length;
2027
- const workgroupSize = 64;
2028
- // Dispatch exactly enough threads for all bones (no bounds check needed)
2029
- const workgroupCount = Math.ceil(boneCount / workgroupSize);
2030
- // Bone count is written once in setupModelBuffers() and never changes
2031
- const encoder = this.device.createCommandEncoder();
1985
+ const workgroupCount = Math.ceil(boneCount / this.COMPUTE_WORKGROUP_SIZE);
2032
1986
  const pass = encoder.beginComputePass();
2033
1987
  pass.setPipeline(this.skinMatrixComputePipeline);
2034
1988
  pass.setBindGroup(0, this.skinMatrixComputeBindGroup);
2035
1989
  pass.dispatchWorkgroups(workgroupCount);
2036
1990
  pass.end();
2037
- this.device.queue.submit([encoder.finish()]);
2038
1991
  }
2039
- // Draw outlines (opaque or transparent)
2040
1992
  drawOutlines(pass, transparent) {
2041
1993
  pass.setPipeline(this.outlinePipeline);
2042
1994
  if (transparent) {
2043
- // Draw transparent outlines (if any)
2044
1995
  for (const draw of this.transparentNonEyeNonHairOutlineDraws) {
2045
1996
  if (draw.count > 0) {
2046
1997
  pass.setBindGroup(0, draw.bindGroup);
@@ -2049,7 +2000,6 @@ export class Engine {
2049
2000
  }
2050
2001
  }
2051
2002
  else {
2052
- // Draw opaque outlines before main geometry
2053
2003
  for (const draw of this.opaqueNonEyeNonHairOutlineDraws) {
2054
2004
  if (draw.count > 0) {
2055
2005
  pass.setBindGroup(0, draw.bindGroup);
@@ -2076,12 +2026,12 @@ export class Engine {
2076
2026
  this.framesSinceLastUpdate = 0;
2077
2027
  this.lastFpsUpdate = now;
2078
2028
  }
2079
- // Calculate GPU memory: textures + buffers + render targets
2029
+ this.stats.gpuMemory = this.gpuMemoryMB;
2030
+ }
2031
+ calculateGpuMemory() {
2080
2032
  let textureMemoryBytes = 0;
2081
- for (const [path, size] of this.textureSizes.entries()) {
2082
- if (this.textureCache.has(path)) {
2083
- textureMemoryBytes += size.width * size.height * 4; // RGBA8 = 4 bytes per pixel
2084
- }
2033
+ for (const texture of this.textureCache.values()) {
2034
+ textureMemoryBytes += texture.width * texture.height * 4;
2085
2035
  }
2086
2036
  let bufferMemoryBytes = 0;
2087
2037
  if (this.vertexBuffer) {
@@ -2119,48 +2069,44 @@ export class Engine {
2119
2069
  if (skeleton)
2120
2070
  bufferMemoryBytes += Math.max(256, skeleton.bones.length * 16 * 4);
2121
2071
  }
2122
- bufferMemoryBytes += 40 * 4; // cameraUniformBuffer
2123
- bufferMemoryBytes += 64 * 4; // lightUniformBuffer
2124
- bufferMemoryBytes += 32; // boneCountBuffer
2125
- bufferMemoryBytes += 32; // blurDirectionBuffer
2126
- bufferMemoryBytes += 32; // bloomIntensityBuffer
2127
- bufferMemoryBytes += 32; // bloomThresholdBuffer
2072
+ bufferMemoryBytes += 40 * 4;
2073
+ bufferMemoryBytes += 64 * 4;
2074
+ bufferMemoryBytes += 32;
2075
+ bufferMemoryBytes += 32;
2076
+ bufferMemoryBytes += 32;
2077
+ bufferMemoryBytes += 32;
2128
2078
  if (this.fullscreenQuadBuffer) {
2129
- bufferMemoryBytes += 24 * 4; // fullscreenQuadBuffer (6 vertices * 4 floats)
2079
+ bufferMemoryBytes += 24 * 4;
2130
2080
  }
2131
- // Material uniform buffers: Float32Array(8) = 32 bytes each
2132
2081
  const totalMaterialDraws = this.opaqueNonEyeNonHairDraws.length +
2133
2082
  this.eyeDraws.length +
2134
2083
  this.hairDrawsOverEyes.length +
2135
2084
  this.hairDrawsOverNonEyes.length +
2136
2085
  this.transparentNonEyeNonHairDraws.length;
2137
- bufferMemoryBytes += totalMaterialDraws * 32; // Material uniform buffers (8 floats = 32 bytes)
2138
- // Outline material uniform buffers: Float32Array(8) = 32 bytes each
2086
+ bufferMemoryBytes += totalMaterialDraws * 32;
2139
2087
  const totalOutlineDraws = this.opaqueNonEyeNonHairOutlineDraws.length +
2140
2088
  this.eyeOutlineDraws.length +
2141
2089
  this.hairOutlineDraws.length +
2142
2090
  this.transparentNonEyeNonHairOutlineDraws.length;
2143
- bufferMemoryBytes += totalOutlineDraws * 32; // Outline material uniform buffers
2091
+ bufferMemoryBytes += totalOutlineDraws * 32;
2144
2092
  let renderTargetMemoryBytes = 0;
2145
2093
  if (this.multisampleTexture) {
2146
2094
  const width = this.canvas.width;
2147
2095
  const height = this.canvas.height;
2148
- renderTargetMemoryBytes += width * height * 4 * this.sampleCount; // multisample color
2149
- renderTargetMemoryBytes += width * height * 4; // depth (depth24plus-stencil8 = 4 bytes)
2096
+ renderTargetMemoryBytes += width * height * 4 * this.sampleCount;
2097
+ renderTargetMemoryBytes += width * height * 4;
2150
2098
  }
2151
2099
  if (this.sceneRenderTexture) {
2152
2100
  const width = this.canvas.width;
2153
2101
  const height = this.canvas.height;
2154
- renderTargetMemoryBytes += width * height * 4; // sceneRenderTexture (non-multisampled)
2102
+ renderTargetMemoryBytes += width * height * 4;
2155
2103
  }
2156
2104
  if (this.bloomExtractTexture) {
2157
- const width = Math.floor(this.canvas.width / 2);
2158
- const height = Math.floor(this.canvas.height / 2);
2159
- renderTargetMemoryBytes += width * height * 4; // bloomExtractTexture
2160
- renderTargetMemoryBytes += width * height * 4; // bloomBlurTexture1
2161
- renderTargetMemoryBytes += width * height * 4; // bloomBlurTexture2
2105
+ const width = Math.floor(this.canvas.width / this.BLOOM_DOWNSCALE_FACTOR);
2106
+ const height = Math.floor(this.canvas.height / this.BLOOM_DOWNSCALE_FACTOR);
2107
+ renderTargetMemoryBytes += width * height * 4 * 3;
2162
2108
  }
2163
2109
  const totalGPUMemoryBytes = textureMemoryBytes + bufferMemoryBytes + renderTargetMemoryBytes;
2164
- this.stats.gpuMemory = Math.round((totalGPUMemoryBytes / 1024 / 1024) * 100) / 100;
2110
+ return Math.round((totalGPUMemoryBytes / 1024 / 1024) * 100) / 100;
2165
2111
  }
2166
2112
  }