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/src/engine.ts CHANGED
@@ -8,9 +8,7 @@ import { VMDKeyFrame, VMDLoader } from "./vmd-loader"
8
8
  export type EngineOptions = {
9
9
  ambient?: number
10
10
  bloomIntensity?: number
11
- bloomThreshold?: number
12
11
  rimLightIntensity?: number
13
- rimLightPower?: number
14
12
  }
15
13
 
16
14
  export interface EngineStats {
@@ -31,7 +29,7 @@ export class Engine {
31
29
  private device!: GPUDevice
32
30
  private context!: GPUCanvasContext
33
31
  private presentationFormat!: GPUTextureFormat
34
- public camera!: Camera
32
+ private camera!: Camera
35
33
  private cameraUniformBuffer!: GPUBuffer
36
34
  private cameraMatrixData = new Float32Array(36)
37
35
  private lightUniformBuffer!: GPUBuffer
@@ -41,14 +39,14 @@ export class Engine {
41
39
  private indexBuffer?: GPUBuffer
42
40
  private resizeObserver: ResizeObserver | null = null
43
41
  private depthTexture!: GPUTexture
44
- private pipeline!: GPURenderPipeline
42
+ private modelPipeline!: GPURenderPipeline
45
43
  private outlinePipeline!: GPURenderPipeline
46
- private hairUnifiedOutlinePipeline!: GPURenderPipeline
47
- private hairUnifiedPipelineOverEyes!: GPURenderPipeline
48
- private hairUnifiedPipelineOverNonEyes!: GPURenderPipeline
44
+ private hairOutlinePipeline!: GPURenderPipeline
45
+ private hairPipelineOverEyes!: GPURenderPipeline
46
+ private hairPipelineOverNonEyes!: GPURenderPipeline
49
47
  private hairDepthPipeline!: GPURenderPipeline
50
48
  private eyePipeline!: GPURenderPipeline
51
- private hairBindGroupLayout!: GPUBindGroupLayout
49
+ private mainBindGroupLayout!: GPUBindGroupLayout
52
50
  private outlineBindGroupLayout!: GPUBindGroupLayout
53
51
  private jointsBuffer!: GPUBuffer
54
52
  private weightsBuffer!: GPUBuffer
@@ -59,8 +57,12 @@ export class Engine {
59
57
  private skinMatrixComputeBindGroup?: GPUBindGroup
60
58
  private boneCountBuffer?: GPUBuffer
61
59
  private multisampleTexture!: GPUTexture
62
- private readonly sampleCount = 4 // MSAA 4x
60
+ private readonly sampleCount = 4
63
61
  private renderPassDescriptor!: GPURenderPassDescriptor
62
+ // Constants
63
+ private readonly STENCIL_EYE_VALUE = 1
64
+ private readonly COMPUTE_WORKGROUP_SIZE = 64
65
+ private readonly BLOOM_DOWNSCALE_FACTOR = 2
64
66
  // Ambient light settings
65
67
  private ambient: number = 1.0
66
68
  // Bloom post-processing textures
@@ -96,7 +98,6 @@ export class Engine {
96
98
  private physics: Physics | null = null
97
99
  private textureSampler!: GPUSampler
98
100
  private textureCache = new Map<string, GPUTexture>()
99
- private textureSizes = new Map<string, { width: number; height: number }>()
100
101
 
101
102
  private lastFpsUpdate = performance.now()
102
103
  private framesSinceLastUpdate = 0
@@ -114,15 +115,14 @@ export class Engine {
114
115
 
115
116
  private animationFrames: VMDKeyFrame[] = []
116
117
  private animationTimeouts: number[] = []
118
+ private gpuMemoryMB: number = 0
117
119
 
118
120
  constructor(canvas: HTMLCanvasElement, options?: EngineOptions) {
119
121
  this.canvas = canvas
120
122
  if (options) {
121
123
  this.ambient = options.ambient ?? 1.0
122
- this.bloomThreshold = options.bloomThreshold ?? 0.3
123
124
  this.bloomIntensity = options.bloomIntensity ?? 0.12
124
125
  this.rimLightIntensity = options.rimLightIntensity ?? 0.45
125
- this.rimLightPower = options.rimLightPower ?? 2.0
126
126
  }
127
127
  }
128
128
 
@@ -291,8 +291,8 @@ export class Engine {
291
291
  })
292
292
 
293
293
  // Create explicit bind group layout for all pipelines using the main shader
294
- this.hairBindGroupLayout = this.device.createBindGroupLayout({
295
- label: "shared material bind group layout",
294
+ this.mainBindGroupLayout = this.device.createBindGroupLayout({
295
+ label: "main material bind group layout",
296
296
  entries: [
297
297
  { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // camera
298
298
  { binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // light
@@ -305,15 +305,14 @@ export class Engine {
305
305
  ],
306
306
  })
307
307
 
308
- const sharedPipelineLayout = this.device.createPipelineLayout({
309
- label: "shared pipeline layout",
310
- bindGroupLayouts: [this.hairBindGroupLayout],
308
+ const mainPipelineLayout = this.device.createPipelineLayout({
309
+ label: "main pipeline layout",
310
+ bindGroupLayouts: [this.mainBindGroupLayout],
311
311
  })
312
312
 
313
- // Single pipeline for all materials with alpha blending
314
- this.pipeline = this.device.createRenderPipeline({
313
+ this.modelPipeline = this.device.createRenderPipeline({
315
314
  label: "model pipeline",
316
- layout: sharedPipelineLayout,
315
+ layout: mainPipelineLayout,
317
316
  vertex: {
318
317
  module: shaderModule,
319
318
  buffers: [
@@ -521,9 +520,9 @@ export class Engine {
521
520
  },
522
521
  })
523
522
 
524
- // Unified hair outline pipeline: single pass without stencil testing, uses depth test "less-equal" to draw everywhere hair exists
525
- this.hairUnifiedOutlinePipeline = this.device.createRenderPipeline({
526
- label: "unified hair outline pipeline",
523
+ // Hair outline pipeline
524
+ this.hairOutlinePipeline = this.device.createRenderPipeline({
525
+ label: "hair outline pipeline",
527
526
  layout: outlinePipelineLayout,
528
527
  vertex: {
529
528
  module: outlineShaderModule,
@@ -592,7 +591,7 @@ export class Engine {
592
591
  // Eye overlay pipeline (renders after opaque, writes stencil)
593
592
  this.eyePipeline = this.device.createRenderPipeline({
594
593
  label: "eye overlay pipeline",
595
- layout: sharedPipelineLayout,
594
+ layout: mainPipelineLayout,
596
595
  vertex: {
597
596
  module: shaderModule,
598
597
  buffers: [
@@ -707,7 +706,7 @@ export class Engine {
707
706
  // Hair depth pre-pass pipeline: depth-only with color writes disabled to eliminate overdraw
708
707
  this.hairDepthPipeline = this.device.createRenderPipeline({
709
708
  label: "hair depth pre-pass",
710
- layout: sharedPipelineLayout,
709
+ layout: mainPipelineLayout,
711
710
  vertex: {
712
711
  module: depthOnlyShaderModule,
713
712
  buffers: [
@@ -747,10 +746,10 @@ export class Engine {
747
746
  multisample: { count: this.sampleCount },
748
747
  })
749
748
 
750
- // Unified hair pipeline for over-eyes (stencil == 1): single pass with dynamic branching
751
- this.hairUnifiedPipelineOverEyes = this.device.createRenderPipeline({
752
- label: "unified hair pipeline (over eyes)",
753
- layout: sharedPipelineLayout,
749
+ // Hair pipeline for rendering over eyes (stencil == 1)
750
+ this.hairPipelineOverEyes = this.device.createRenderPipeline({
751
+ label: "hair pipeline (over eyes)",
752
+ layout: mainPipelineLayout,
754
753
  vertex: {
755
754
  module: shaderModule,
756
755
  buffers: [
@@ -813,10 +812,10 @@ export class Engine {
813
812
  multisample: { count: this.sampleCount },
814
813
  })
815
814
 
816
- // Unified pipeline for hair over non-eyes (stencil != 1)
817
- this.hairUnifiedPipelineOverNonEyes = this.device.createRenderPipeline({
818
- label: "unified hair pipeline (over non-eyes)",
819
- layout: sharedPipelineLayout,
815
+ // Hair pipeline for rendering over non-eyes (stencil != 1)
816
+ this.hairPipelineOverNonEyes = this.device.createRenderPipeline({
817
+ label: "hair pipeline (over non-eyes)",
818
+ layout: mainPipelineLayout,
820
819
  vertex: {
821
820
  module: shaderModule,
822
821
  buffers: [
@@ -898,10 +897,9 @@ export class Engine {
898
897
  @group(0) @binding(2) var<storage, read> inverseBindMatrices: array<mat4x4f>;
899
898
  @group(0) @binding(3) var<storage, read_write> skinMatrices: array<mat4x4f>;
900
899
 
901
- @compute @workgroup_size(64)
900
+ @compute @workgroup_size(64) // Must match COMPUTE_WORKGROUP_SIZE
902
901
  fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
903
902
  let boneIndex = globalId.x;
904
- // Bounds check: we dispatch workgroups (64 threads each), so some threads may be out of range
905
903
  if (boneIndex >= boneCount.count) {
906
904
  return;
907
905
  }
@@ -1039,21 +1037,16 @@ export class Engine {
1039
1037
  @group(0) @binding(1) var inputSampler: sampler;
1040
1038
  @group(0) @binding(2) var<uniform> blurUniforms: BlurUniforms;
1041
1039
 
1042
- // 9-tap gaussian blur
1040
+ // 5-tap gaussian blur
1043
1041
  @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
1044
1042
  let texelSize = 1.0 / vec2f(textureDimensions(inputTexture));
1045
1043
  var result = vec4f(0.0);
1046
1044
 
1047
- // Gaussian weights for 9-tap filter
1048
- let weights = array<f32, 9>(
1049
- 0.01621622, 0.05405405, 0.12162162,
1050
- 0.19459459, 0.22702703,
1051
- 0.19459459, 0.12162162, 0.05405405, 0.01621622
1052
- );
1045
+ // Optimized 5-tap Gaussian filter (faster, nearly same quality)
1046
+ let weights = array<f32, 5>(0.06136, 0.24477, 0.38774, 0.24477, 0.06136);
1047
+ let offsets = array<f32, 5>(-2.0, -1.0, 0.0, 1.0, 2.0);
1053
1048
 
1054
- let offsets = array<f32, 9>(-4.0, -3.0, -2.0, -1.0, 0.0, 1.0, 2.0, 3.0, 4.0);
1055
-
1056
- for (var i = 0u; i < 9u; i++) {
1049
+ for (var i = 0u; i < 5u; i++) {
1057
1050
  let offset = offsets[i] * texelSize * blurUniforms.direction;
1058
1051
  result += textureSample(inputTexture, inputSampler, input.uv + offset) * weights[i];
1059
1052
  }
@@ -1201,11 +1194,9 @@ export class Engine {
1201
1194
  this.linearSampler = linearSampler
1202
1195
  }
1203
1196
 
1204
- // Setup bloom textures and bind groups (called when canvas is resized)
1205
1197
  private setupBloom(width: number, height: number) {
1206
- // Create bloom textures (half resolution for performance)
1207
- const bloomWidth = Math.floor(width / 2)
1208
- const bloomHeight = Math.floor(height / 2)
1198
+ const bloomWidth = Math.floor(width / this.BLOOM_DOWNSCALE_FACTOR)
1199
+ const bloomHeight = Math.floor(height / this.BLOOM_DOWNSCALE_FACTOR)
1209
1200
  this.bloomExtractTexture = this.device.createTexture({
1210
1201
  label: "bloom extract",
1211
1202
  size: [bloomWidth, bloomHeight],
@@ -1406,7 +1397,6 @@ export class Engine {
1406
1397
  public async loadAnimation(url: string) {
1407
1398
  const frames = await VMDLoader.load(url)
1408
1399
  this.animationFrames = frames
1409
- console.log(this.animationFrames)
1410
1400
  }
1411
1401
 
1412
1402
  public playAnimation() {
@@ -1485,7 +1475,9 @@ export class Engine {
1485
1475
  worldMats.byteOffset,
1486
1476
  worldMats.byteLength
1487
1477
  )
1488
- this.computeSkinMatrices()
1478
+ const encoder = this.device.createCommandEncoder()
1479
+ this.computeSkinMatrices(encoder)
1480
+ this.device.queue.submit([encoder.finish()])
1489
1481
  }
1490
1482
  }
1491
1483
  for (const [_, keyFrames] of boneKeyFramesByBone.entries()) {
@@ -1570,6 +1562,14 @@ export class Engine {
1570
1562
  this.modelDir = dir
1571
1563
 
1572
1564
  const model = await PmxLoader.load(path)
1565
+ // console.log({
1566
+ // vertices: Array.from(model.getVertices()),
1567
+ // indices: Array.from(model.getIndices()),
1568
+ // materials: model.getMaterials(),
1569
+ // textures: model.getTextures(),
1570
+ // bones: model.getSkeleton().bones,
1571
+ // skinning: { joints: Array.from(model.getSkinning().joints), weights: Array.from(model.getSkinning().weights) },
1572
+ // })
1573
1573
  this.physics = new Physics(model.getRigidbodies(), model.getJoints())
1574
1574
  await this.setupModelBuffers(model)
1575
1575
  }
@@ -1776,7 +1776,6 @@ export class Engine {
1776
1776
  [256, 2]
1777
1777
  )
1778
1778
  this.textureCache.set(defaultToonPath, defaultToonTexture)
1779
- this.textureSizes.set(defaultToonPath, { width: 256, height: 2 })
1780
1779
  return defaultToonTexture
1781
1780
  }
1782
1781
 
@@ -1789,11 +1788,11 @@ export class Engine {
1789
1788
  this.eyeOutlineDraws = []
1790
1789
  this.hairOutlineDraws = []
1791
1790
  this.transparentNonEyeNonHairOutlineDraws = []
1792
- let runningFirstIndex = 0
1791
+ let currentIndexOffset = 0
1793
1792
 
1794
1793
  for (const mat of materials) {
1795
- const matCount = mat.vertexCount | 0
1796
- if (matCount === 0) continue
1794
+ const indexCount = mat.vertexCount
1795
+ if (indexCount === 0) continue
1797
1796
 
1798
1797
  const diffuseTexture = await loadTextureByIndex(mat.diffuseTextureIndex)
1799
1798
  if (!diffuseTexture) throw new Error(`Material "${mat.name}" has no diffuse texture`)
@@ -1825,7 +1824,7 @@ export class Engine {
1825
1824
  // Create bind groups using the shared bind group layout - All pipelines (main, eye, hair multiply, hair opaque) use the same shader and layout
1826
1825
  const bindGroup = this.device.createBindGroup({
1827
1826
  label: `material bind group: ${mat.name}`,
1828
- layout: this.hairBindGroupLayout,
1827
+ layout: this.mainBindGroupLayout,
1829
1828
  entries: [
1830
1829
  { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1831
1830
  { binding: 1, resource: { buffer: this.lightUniformBuffer } },
@@ -1841,104 +1840,80 @@ export class Engine {
1841
1840
  // Classify materials into appropriate draw lists
1842
1841
  if (mat.isEye) {
1843
1842
  this.eyeDraws.push({
1844
- count: matCount,
1845
- firstIndex: runningFirstIndex,
1843
+ count: indexCount,
1844
+ firstIndex: currentIndexOffset,
1846
1845
  bindGroup,
1847
1846
  isTransparent,
1848
1847
  })
1849
1848
  } else if (mat.isHair) {
1850
- // Hair materials: create bind groups for unified pipeline with dynamic branching
1851
- const materialUniformDataHair = new Float32Array(8)
1852
- materialUniformDataHair[0] = materialAlpha
1853
- materialUniformDataHair[1] = 1.0 // alphaMultiplier: base value, shader will adjust
1854
- materialUniformDataHair[2] = this.rimLightIntensity
1855
- materialUniformDataHair[3] = this.rimLightPower
1856
- materialUniformDataHair[4] = 1.0 // rimColor.r
1857
- materialUniformDataHair[5] = 1.0 // rimColor.g
1858
- materialUniformDataHair[6] = 1.0 // rimColor.b
1859
- materialUniformDataHair[7] = 0.0
1860
-
1861
- // Create uniform buffers for both modes
1862
- const materialUniformBufferOverEyes = this.device.createBuffer({
1863
- label: `material uniform (over eyes): ${mat.name}`,
1864
- size: materialUniformDataHair.byteLength,
1865
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1866
- })
1867
- const materialUniformDataOverEyes = new Float32Array(materialUniformDataHair)
1868
- materialUniformDataOverEyes[7] = 1.0
1869
- this.device.queue.writeBuffer(materialUniformBufferOverEyes, 0, materialUniformDataOverEyes)
1870
-
1871
- const materialUniformBufferOverNonEyes = this.device.createBuffer({
1872
- label: `material uniform (over non-eyes): ${mat.name}`,
1873
- size: materialUniformDataHair.byteLength,
1874
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1875
- })
1876
- const materialUniformDataOverNonEyes = new Float32Array(materialUniformDataHair)
1877
- materialUniformDataOverNonEyes[7] = 0.0
1878
- this.device.queue.writeBuffer(materialUniformBufferOverNonEyes, 0, materialUniformDataOverNonEyes)
1879
-
1880
- // Create bind groups for both modes
1881
- const bindGroupOverEyes = this.device.createBindGroup({
1882
- label: `material bind group (over eyes): ${mat.name}`,
1883
- layout: this.hairBindGroupLayout,
1884
- entries: [
1885
- { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1886
- { binding: 1, resource: { buffer: this.lightUniformBuffer } },
1887
- { binding: 2, resource: diffuseTexture.createView() },
1888
- { binding: 3, resource: this.textureSampler },
1889
- { binding: 4, resource: { buffer: this.skinMatrixBuffer! } },
1890
- { binding: 5, resource: toonTexture.createView() },
1891
- { binding: 6, resource: this.textureSampler },
1892
- { binding: 7, resource: { buffer: materialUniformBufferOverEyes } },
1893
- ],
1894
- })
1849
+ // Hair materials: create separate bind groups for over-eyes vs over-non-eyes
1850
+ const createHairBindGroup = (isOverEyes: boolean) => {
1851
+ const uniformData = new Float32Array(8)
1852
+ uniformData[0] = materialAlpha
1853
+ uniformData[1] = 1.0 // alphaMultiplier (shader adjusts based on isOverEyes)
1854
+ uniformData[2] = this.rimLightIntensity
1855
+ uniformData[3] = this.rimLightPower
1856
+ uniformData[4] = 1.0 // rimColor.rgb
1857
+ uniformData[5] = 1.0
1858
+ uniformData[6] = 1.0
1859
+ uniformData[7] = isOverEyes ? 1.0 : 0.0
1860
+
1861
+ const buffer = this.device.createBuffer({
1862
+ label: `material uniform (${isOverEyes ? "over eyes" : "over non-eyes"}): ${mat.name}`,
1863
+ size: uniformData.byteLength,
1864
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1865
+ })
1866
+ this.device.queue.writeBuffer(buffer, 0, uniformData)
1867
+
1868
+ return this.device.createBindGroup({
1869
+ label: `material bind group (${isOverEyes ? "over eyes" : "over non-eyes"}): ${mat.name}`,
1870
+ layout: this.mainBindGroupLayout,
1871
+ entries: [
1872
+ { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1873
+ { binding: 1, resource: { buffer: this.lightUniformBuffer } },
1874
+ { binding: 2, resource: diffuseTexture.createView() },
1875
+ { binding: 3, resource: this.textureSampler },
1876
+ { binding: 4, resource: { buffer: this.skinMatrixBuffer! } },
1877
+ { binding: 5, resource: toonTexture.createView() },
1878
+ { binding: 6, resource: this.textureSampler },
1879
+ { binding: 7, resource: { buffer: buffer } },
1880
+ ],
1881
+ })
1882
+ }
1895
1883
 
1896
- const bindGroupOverNonEyes = this.device.createBindGroup({
1897
- label: `material bind group (over non-eyes): ${mat.name}`,
1898
- layout: this.hairBindGroupLayout,
1899
- entries: [
1900
- { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1901
- { binding: 1, resource: { buffer: this.lightUniformBuffer } },
1902
- { binding: 2, resource: diffuseTexture.createView() },
1903
- { binding: 3, resource: this.textureSampler },
1904
- { binding: 4, resource: { buffer: this.skinMatrixBuffer! } },
1905
- { binding: 5, resource: toonTexture.createView() },
1906
- { binding: 6, resource: this.textureSampler },
1907
- { binding: 7, resource: { buffer: materialUniformBufferOverNonEyes } },
1908
- ],
1909
- })
1884
+ const bindGroupOverEyes = createHairBindGroup(true)
1885
+ const bindGroupOverNonEyes = createHairBindGroup(false)
1910
1886
 
1911
- // Store both bind groups for unified pipeline
1912
1887
  this.hairDrawsOverEyes.push({
1913
- count: matCount,
1914
- firstIndex: runningFirstIndex,
1888
+ count: indexCount,
1889
+ firstIndex: currentIndexOffset,
1915
1890
  bindGroup: bindGroupOverEyes,
1916
1891
  isTransparent,
1917
1892
  })
1918
1893
 
1919
1894
  this.hairDrawsOverNonEyes.push({
1920
- count: matCount,
1921
- firstIndex: runningFirstIndex,
1895
+ count: indexCount,
1896
+ firstIndex: currentIndexOffset,
1922
1897
  bindGroup: bindGroupOverNonEyes,
1923
1898
  isTransparent,
1924
1899
  })
1925
1900
  } else if (isTransparent) {
1926
1901
  this.transparentNonEyeNonHairDraws.push({
1927
- count: matCount,
1928
- firstIndex: runningFirstIndex,
1902
+ count: indexCount,
1903
+ firstIndex: currentIndexOffset,
1929
1904
  bindGroup,
1930
1905
  isTransparent,
1931
1906
  })
1932
1907
  } else {
1933
1908
  this.opaqueNonEyeNonHairDraws.push({
1934
- count: matCount,
1935
- firstIndex: runningFirstIndex,
1909
+ count: indexCount,
1910
+ firstIndex: currentIndexOffset,
1936
1911
  bindGroup,
1937
1912
  isTransparent,
1938
1913
  })
1939
1914
  }
1940
1915
 
1941
- // Outline for all materials (including transparent) - Edge flag is at bit 4 (0x10) in PMX format, not bit 0 (0x01)
1916
+ // Edge flag is at bit 4 (0x10) in PMX format
1942
1917
  if ((mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
1943
1918
  const materialUniformData = new Float32Array(8)
1944
1919
  materialUniformData[0] = mat.edgeColor[0] // edgeColor.r
@@ -1946,9 +1921,9 @@ export class Engine {
1946
1921
  materialUniformData[2] = mat.edgeColor[2] // edgeColor.b
1947
1922
  materialUniformData[3] = mat.edgeColor[3] // edgeColor.a
1948
1923
  materialUniformData[4] = mat.edgeSize
1949
- materialUniformData[5] = 0.0 // isOverEyes: 0.0 for all (unified pipeline doesn't use stencil)
1950
- materialUniformData[6] = 0.0 // _padding1
1951
- materialUniformData[7] = 0.0 // _padding2
1924
+ materialUniformData[5] = 0.0 // isOverEyes
1925
+ materialUniformData[6] = 0.0
1926
+ materialUniformData[7] = 0.0
1952
1927
 
1953
1928
  const materialUniformBuffer = this.device.createBuffer({
1954
1929
  label: `outline material uniform: ${mat.name}`,
@@ -1967,44 +1942,44 @@ export class Engine {
1967
1942
  ],
1968
1943
  })
1969
1944
 
1970
- // Classify outlines into appropriate draw lists
1971
1945
  if (mat.isEye) {
1972
1946
  this.eyeOutlineDraws.push({
1973
- count: matCount,
1974
- firstIndex: runningFirstIndex,
1947
+ count: indexCount,
1948
+ firstIndex: currentIndexOffset,
1975
1949
  bindGroup: outlineBindGroup,
1976
1950
  isTransparent,
1977
1951
  })
1978
1952
  } else if (mat.isHair) {
1979
1953
  this.hairOutlineDraws.push({
1980
- count: matCount,
1981
- firstIndex: runningFirstIndex,
1954
+ count: indexCount,
1955
+ firstIndex: currentIndexOffset,
1982
1956
  bindGroup: outlineBindGroup,
1983
1957
  isTransparent,
1984
1958
  })
1985
1959
  } else if (isTransparent) {
1986
1960
  this.transparentNonEyeNonHairOutlineDraws.push({
1987
- count: matCount,
1988
- firstIndex: runningFirstIndex,
1961
+ count: indexCount,
1962
+ firstIndex: currentIndexOffset,
1989
1963
  bindGroup: outlineBindGroup,
1990
1964
  isTransparent,
1991
1965
  })
1992
1966
  } else {
1993
1967
  this.opaqueNonEyeNonHairOutlineDraws.push({
1994
- count: matCount,
1995
- firstIndex: runningFirstIndex,
1968
+ count: indexCount,
1969
+ firstIndex: currentIndexOffset,
1996
1970
  bindGroup: outlineBindGroup,
1997
1971
  isTransparent,
1998
1972
  })
1999
1973
  }
2000
1974
  }
2001
1975
 
2002
- runningFirstIndex += matCount
1976
+ currentIndexOffset += indexCount
2003
1977
  }
1978
+
1979
+ this.gpuMemoryMB = this.calculateGpuMemory()
2004
1980
  }
2005
1981
 
2006
- // Helper: Load texture from file path with optional max size limit
2007
- private async createTextureFromPath(path: string, maxSize: number = 2048): Promise<GPUTexture | null> {
1982
+ private async createTextureFromPath(path: string): Promise<GPUTexture | null> {
2008
1983
  const cached = this.textureCache.get(path)
2009
1984
  if (cached) {
2010
1985
  return cached
@@ -2015,45 +1990,30 @@ export class Engine {
2015
1990
  if (!response.ok) {
2016
1991
  throw new Error(`HTTP ${response.status}: ${response.statusText}`)
2017
1992
  }
2018
- let imageBitmap = await createImageBitmap(await response.blob(), {
1993
+ const imageBitmap = await createImageBitmap(await response.blob(), {
2019
1994
  premultiplyAlpha: "none",
2020
1995
  colorSpaceConversion: "none",
2021
1996
  })
2022
1997
 
2023
- // Downscale if texture is too large
2024
- let finalWidth = imageBitmap.width
2025
- let finalHeight = imageBitmap.height
2026
- if (finalWidth > maxSize || finalHeight > maxSize) {
2027
- const scale = Math.min(maxSize / finalWidth, maxSize / finalHeight)
2028
- finalWidth = Math.floor(finalWidth * scale)
2029
- finalHeight = Math.floor(finalHeight * scale)
2030
-
2031
- // Create canvas to downscale
2032
- const canvas = new OffscreenCanvas(finalWidth, finalHeight)
2033
- const ctx = canvas.getContext("2d")
2034
- if (ctx) {
2035
- ctx.drawImage(imageBitmap, 0, 0, finalWidth, finalHeight)
2036
- imageBitmap = await createImageBitmap(canvas)
2037
- }
2038
- }
2039
-
2040
1998
  const texture = this.device.createTexture({
2041
1999
  label: `texture: ${path}`,
2042
- size: [finalWidth, finalHeight],
2000
+ size: [imageBitmap.width, imageBitmap.height],
2043
2001
  format: "rgba8unorm",
2044
2002
  usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
2045
2003
  })
2046
- this.device.queue.copyExternalImageToTexture({ source: imageBitmap }, { texture }, [finalWidth, finalHeight])
2004
+ this.device.queue.copyExternalImageToTexture({ source: imageBitmap }, { texture }, [
2005
+ imageBitmap.width,
2006
+ imageBitmap.height,
2007
+ ])
2047
2008
 
2048
2009
  this.textureCache.set(path, texture)
2049
- this.textureSizes.set(path, { width: finalWidth, height: finalHeight })
2050
2010
  return texture
2051
2011
  } catch {
2052
2012
  return null
2053
2013
  }
2054
2014
  }
2055
2015
 
2056
- // Step 9: Render one frame
2016
+ // Render strategy: 1) Opaque non-eye/hair 2) Eyes (stencil=1) 3) Hair (depth pre-pass + split by stencil) 4) Transparent 5) Bloom
2057
2017
  public render() {
2058
2018
  if (this.multisampleTexture && this.camera && this.device && this.currentModel) {
2059
2019
  const currentTime = performance.now()
@@ -2063,9 +2023,11 @@ export class Engine {
2063
2023
  this.updateCameraUniforms()
2064
2024
  this.updateRenderTarget()
2065
2025
 
2066
- this.updateModelPose(deltaTime)
2067
-
2026
+ // Use single encoder for both compute and render (reduces sync points)
2068
2027
  const encoder = this.device.createCommandEncoder()
2028
+
2029
+ this.updateModelPose(deltaTime, encoder)
2030
+
2069
2031
  const pass = encoder.beginRenderPass(this.renderPassDescriptor)
2070
2032
 
2071
2033
  pass.setVertexBuffer(0, this.vertexBuffer)
@@ -2075,8 +2037,8 @@ export class Engine {
2075
2037
 
2076
2038
  this.drawCallCount = 0
2077
2039
 
2078
- // PASS 1: Opaque non-eye, non-hair
2079
- pass.setPipeline(this.pipeline)
2040
+ // Pass 1: Opaque non-eye, non-hair
2041
+ pass.setPipeline(this.modelPipeline)
2080
2042
  for (const draw of this.opaqueNonEyeNonHairDraws) {
2081
2043
  if (draw.count > 0) {
2082
2044
  pass.setBindGroup(0, draw.bindGroup)
@@ -2085,9 +2047,9 @@ export class Engine {
2085
2047
  }
2086
2048
  }
2087
2049
 
2088
- // PASS 2: Eyes (writes stencil = 1)
2050
+ // Pass 2: Eyes (writes stencil value for hair to test against)
2089
2051
  pass.setPipeline(this.eyePipeline)
2090
- pass.setStencilReference(1) // Set stencil reference value to 1
2052
+ pass.setStencilReference(this.STENCIL_EYE_VALUE)
2091
2053
  for (const draw of this.eyeDraws) {
2092
2054
  if (draw.count > 0) {
2093
2055
  pass.setBindGroup(0, draw.bindGroup)
@@ -2096,10 +2058,10 @@ export class Engine {
2096
2058
  }
2097
2059
  }
2098
2060
 
2099
- // PASS 3: Hair rendering with depth pre-pass and unified pipeline
2061
+ // Pass 3: Hair rendering (depth pre-pass + shading + outlines)
2100
2062
  this.drawOutlines(pass, false)
2101
2063
 
2102
- // 3a: Hair depth pre-pass (eliminates overdraw by rejecting fragments early)
2064
+ // 3a: Hair depth pre-pass (reduces overdraw via early depth rejection)
2103
2065
  if (this.hairDrawsOverEyes.length > 0 || this.hairDrawsOverNonEyes.length > 0) {
2104
2066
  pass.setPipeline(this.hairDepthPipeline)
2105
2067
  for (const draw of this.hairDrawsOverEyes) {
@@ -2116,10 +2078,10 @@ export class Engine {
2116
2078
  }
2117
2079
  }
2118
2080
 
2119
- // 3b: Hair shading pass with unified pipeline and dynamic branching
2081
+ // 3b: Hair shading (split by stencil for transparency over eyes)
2120
2082
  if (this.hairDrawsOverEyes.length > 0) {
2121
- pass.setPipeline(this.hairUnifiedPipelineOverEyes)
2122
- pass.setStencilReference(1)
2083
+ pass.setPipeline(this.hairPipelineOverEyes)
2084
+ pass.setStencilReference(this.STENCIL_EYE_VALUE)
2123
2085
  for (const draw of this.hairDrawsOverEyes) {
2124
2086
  if (draw.count > 0) {
2125
2087
  pass.setBindGroup(0, draw.bindGroup)
@@ -2130,8 +2092,8 @@ export class Engine {
2130
2092
  }
2131
2093
 
2132
2094
  if (this.hairDrawsOverNonEyes.length > 0) {
2133
- pass.setPipeline(this.hairUnifiedPipelineOverNonEyes)
2134
- pass.setStencilReference(1)
2095
+ pass.setPipeline(this.hairPipelineOverNonEyes)
2096
+ pass.setStencilReference(this.STENCIL_EYE_VALUE)
2135
2097
  for (const draw of this.hairDrawsOverNonEyes) {
2136
2098
  if (draw.count > 0) {
2137
2099
  pass.setBindGroup(0, draw.bindGroup)
@@ -2141,9 +2103,9 @@ export class Engine {
2141
2103
  }
2142
2104
  }
2143
2105
 
2144
- // 3c: Hair outlines - unified single pass without stencil testing
2106
+ // 3c: Hair outlines
2145
2107
  if (this.hairOutlineDraws.length > 0) {
2146
- pass.setPipeline(this.hairUnifiedOutlinePipeline)
2108
+ pass.setPipeline(this.hairOutlinePipeline)
2147
2109
  for (const draw of this.hairOutlineDraws) {
2148
2110
  if (draw.count > 0) {
2149
2111
  pass.setBindGroup(0, draw.bindGroup)
@@ -2152,8 +2114,8 @@ export class Engine {
2152
2114
  }
2153
2115
  }
2154
2116
 
2155
- // PASS 4: Transparent non-eye, non-hair
2156
- pass.setPipeline(this.pipeline)
2117
+ // Pass 4: Transparent non-eye, non-hair
2118
+ pass.setPipeline(this.modelPipeline)
2157
2119
  for (const draw of this.transparentNonEyeNonHairDraws) {
2158
2120
  if (draw.count > 0) {
2159
2121
  pass.setBindGroup(0, draw.bindGroup)
@@ -2167,14 +2129,12 @@ export class Engine {
2167
2129
  pass.end()
2168
2130
  this.device.queue.submit([encoder.finish()])
2169
2131
 
2170
- // Apply bloom post-processing
2171
2132
  this.applyBloom()
2172
2133
 
2173
2134
  this.updateStats(performance.now() - currentTime)
2174
2135
  }
2175
2136
  }
2176
2137
 
2177
- // Apply bloom post-processing
2178
2138
  private applyBloom() {
2179
2139
  if (!this.sceneRenderTexture || !this.bloomExtractTexture) {
2180
2140
  return
@@ -2192,10 +2152,10 @@ export class Engine {
2192
2152
  const encoder = this.device.createCommandEncoder()
2193
2153
  const width = this.canvas.width
2194
2154
  const height = this.canvas.height
2195
- const bloomWidth = Math.floor(width / 2)
2196
- const bloomHeight = Math.floor(height / 2)
2155
+ const bloomWidth = Math.floor(width / this.BLOOM_DOWNSCALE_FACTOR)
2156
+ const bloomHeight = Math.floor(height / this.BLOOM_DOWNSCALE_FACTOR)
2197
2157
 
2198
- // Pass 1: Extract bright areas (downsample to half resolution)
2158
+ // Extract bright areas
2199
2159
  const extractPass = encoder.beginRenderPass({
2200
2160
  label: "bloom extract",
2201
2161
  colorAttachments: [
@@ -2213,8 +2173,8 @@ export class Engine {
2213
2173
  extractPass.draw(6, 1, 0, 0)
2214
2174
  extractPass.end()
2215
2175
 
2216
- // Pass 2: Horizontal blur
2217
- const hBlurData = new Float32Array(4) // vec2f + padding = 4 floats
2176
+ // Horizontal blur
2177
+ const hBlurData = new Float32Array(4)
2218
2178
  hBlurData[0] = 1.0
2219
2179
  hBlurData[1] = 0.0
2220
2180
  this.device.queue.writeBuffer(this.blurDirectionBuffer, 0, hBlurData)
@@ -2235,8 +2195,8 @@ export class Engine {
2235
2195
  blurHPass.draw(6, 1, 0, 0)
2236
2196
  blurHPass.end()
2237
2197
 
2238
- // Pass 3: Vertical blur
2239
- const vBlurData = new Float32Array(4) // vec2f + padding = 4 floats
2198
+ // Vertical blur
2199
+ const vBlurData = new Float32Array(4)
2240
2200
  vBlurData[0] = 0.0
2241
2201
  vBlurData[1] = 1.0
2242
2202
  this.device.queue.writeBuffer(this.blurDirectionBuffer, 0, vBlurData)
@@ -2257,7 +2217,7 @@ export class Engine {
2257
2217
  blurVPass.draw(6, 1, 0, 0)
2258
2218
  blurVPass.end()
2259
2219
 
2260
- // Pass 4: Compose scene + bloom to canvas
2220
+ // Compose to canvas
2261
2221
  const composePass = encoder.beginRenderPass({
2262
2222
  label: "bloom compose",
2263
2223
  colorAttachments: [
@@ -2278,7 +2238,6 @@ export class Engine {
2278
2238
  this.device.queue.submit([encoder.finish()])
2279
2239
  }
2280
2240
 
2281
- // Update camera uniform buffer each frame
2282
2241
  private updateCameraUniforms() {
2283
2242
  const viewMatrix = this.camera.getViewMatrix()
2284
2243
  const projectionMatrix = this.camera.getProjectionMatrix()
@@ -2291,19 +2250,16 @@ export class Engine {
2291
2250
  this.device.queue.writeBuffer(this.cameraUniformBuffer, 0, this.cameraMatrixData)
2292
2251
  }
2293
2252
 
2294
- // Update render target texture view
2295
2253
  private updateRenderTarget() {
2296
2254
  const colorAttachment = (this.renderPassDescriptor.colorAttachments as GPURenderPassColorAttachment[])[0]
2297
2255
  if (this.sampleCount > 1) {
2298
- // Resolve to scene render texture for post-processing
2299
2256
  colorAttachment.resolveTarget = this.sceneRenderTextureView
2300
2257
  } else {
2301
- // Render directly to scene render texture
2302
2258
  colorAttachment.view = this.sceneRenderTextureView
2303
2259
  }
2304
2260
  }
2305
2261
 
2306
- private updateModelPose(deltaTime: number) {
2262
+ private updateModelPose(deltaTime: number, encoder: GPUCommandEncoder) {
2307
2263
  this.currentModel!.evaluatePose()
2308
2264
  const worldMats = this.currentModel!.getBoneWorldMatrices()
2309
2265
 
@@ -2318,32 +2274,23 @@ export class Engine {
2318
2274
  worldMats.byteOffset,
2319
2275
  worldMats.byteLength
2320
2276
  )
2321
- this.computeSkinMatrices()
2277
+ this.computeSkinMatrices(encoder)
2322
2278
  }
2323
2279
 
2324
- // Compute skin matrices on GPU
2325
- private computeSkinMatrices() {
2280
+ private computeSkinMatrices(encoder: GPUCommandEncoder) {
2326
2281
  const boneCount = this.currentModel!.getSkeleton().bones.length
2327
- const workgroupSize = 64
2328
- // Dispatch exactly enough threads for all bones (no bounds check needed)
2329
- const workgroupCount = Math.ceil(boneCount / workgroupSize)
2330
-
2331
- // Bone count is written once in setupModelBuffers() and never changes
2282
+ const workgroupCount = Math.ceil(boneCount / this.COMPUTE_WORKGROUP_SIZE)
2332
2283
 
2333
- const encoder = this.device.createCommandEncoder()
2334
2284
  const pass = encoder.beginComputePass()
2335
2285
  pass.setPipeline(this.skinMatrixComputePipeline!)
2336
2286
  pass.setBindGroup(0, this.skinMatrixComputeBindGroup!)
2337
2287
  pass.dispatchWorkgroups(workgroupCount)
2338
2288
  pass.end()
2339
- this.device.queue.submit([encoder.finish()])
2340
2289
  }
2341
2290
 
2342
- // Draw outlines (opaque or transparent)
2343
2291
  private drawOutlines(pass: GPURenderPassEncoder, transparent: boolean) {
2344
2292
  pass.setPipeline(this.outlinePipeline)
2345
2293
  if (transparent) {
2346
- // Draw transparent outlines (if any)
2347
2294
  for (const draw of this.transparentNonEyeNonHairOutlineDraws) {
2348
2295
  if (draw.count > 0) {
2349
2296
  pass.setBindGroup(0, draw.bindGroup)
@@ -2351,7 +2298,6 @@ export class Engine {
2351
2298
  }
2352
2299
  }
2353
2300
  } else {
2354
- // Draw opaque outlines before main geometry
2355
2301
  for (const draw of this.opaqueNonEyeNonHairOutlineDraws) {
2356
2302
  if (draw.count > 0) {
2357
2303
  pass.setBindGroup(0, draw.bindGroup)
@@ -2382,12 +2328,13 @@ export class Engine {
2382
2328
  this.lastFpsUpdate = now
2383
2329
  }
2384
2330
 
2385
- // Calculate GPU memory: textures + buffers + render targets
2331
+ this.stats.gpuMemory = this.gpuMemoryMB
2332
+ }
2333
+
2334
+ private calculateGpuMemory(): number {
2386
2335
  let textureMemoryBytes = 0
2387
- for (const [path, size] of this.textureSizes.entries()) {
2388
- if (this.textureCache.has(path)) {
2389
- textureMemoryBytes += size.width * size.height * 4 // RGBA8 = 4 bytes per pixel
2390
- }
2336
+ for (const texture of this.textureCache.values()) {
2337
+ textureMemoryBytes += texture.width * texture.height * 4
2391
2338
  }
2392
2339
 
2393
2340
  let bufferMemoryBytes = 0
@@ -2419,54 +2366,49 @@ export class Engine {
2419
2366
  const skeleton = this.currentModel?.getSkeleton()
2420
2367
  if (skeleton) bufferMemoryBytes += Math.max(256, skeleton.bones.length * 16 * 4)
2421
2368
  }
2422
- bufferMemoryBytes += 40 * 4 // cameraUniformBuffer
2423
- bufferMemoryBytes += 64 * 4 // lightUniformBuffer
2424
- bufferMemoryBytes += 32 // boneCountBuffer
2425
- bufferMemoryBytes += 32 // blurDirectionBuffer
2426
- bufferMemoryBytes += 32 // bloomIntensityBuffer
2427
- bufferMemoryBytes += 32 // bloomThresholdBuffer
2369
+ bufferMemoryBytes += 40 * 4
2370
+ bufferMemoryBytes += 64 * 4
2371
+ bufferMemoryBytes += 32
2372
+ bufferMemoryBytes += 32
2373
+ bufferMemoryBytes += 32
2374
+ bufferMemoryBytes += 32
2428
2375
  if (this.fullscreenQuadBuffer) {
2429
- bufferMemoryBytes += 24 * 4 // fullscreenQuadBuffer (6 vertices * 4 floats)
2376
+ bufferMemoryBytes += 24 * 4
2430
2377
  }
2431
-
2432
- // Material uniform buffers: Float32Array(8) = 32 bytes each
2433
2378
  const totalMaterialDraws =
2434
2379
  this.opaqueNonEyeNonHairDraws.length +
2435
2380
  this.eyeDraws.length +
2436
2381
  this.hairDrawsOverEyes.length +
2437
2382
  this.hairDrawsOverNonEyes.length +
2438
2383
  this.transparentNonEyeNonHairDraws.length
2439
- bufferMemoryBytes += totalMaterialDraws * 32 // Material uniform buffers (8 floats = 32 bytes)
2384
+ bufferMemoryBytes += totalMaterialDraws * 32
2440
2385
 
2441
- // Outline material uniform buffers: Float32Array(8) = 32 bytes each
2442
2386
  const totalOutlineDraws =
2443
2387
  this.opaqueNonEyeNonHairOutlineDraws.length +
2444
2388
  this.eyeOutlineDraws.length +
2445
2389
  this.hairOutlineDraws.length +
2446
2390
  this.transparentNonEyeNonHairOutlineDraws.length
2447
- bufferMemoryBytes += totalOutlineDraws * 32 // Outline material uniform buffers
2391
+ bufferMemoryBytes += totalOutlineDraws * 32
2448
2392
 
2449
2393
  let renderTargetMemoryBytes = 0
2450
2394
  if (this.multisampleTexture) {
2451
2395
  const width = this.canvas.width
2452
2396
  const height = this.canvas.height
2453
- renderTargetMemoryBytes += width * height * 4 * this.sampleCount // multisample color
2454
- renderTargetMemoryBytes += width * height * 4 // depth (depth24plus-stencil8 = 4 bytes)
2397
+ renderTargetMemoryBytes += width * height * 4 * this.sampleCount
2398
+ renderTargetMemoryBytes += width * height * 4
2455
2399
  }
2456
2400
  if (this.sceneRenderTexture) {
2457
2401
  const width = this.canvas.width
2458
2402
  const height = this.canvas.height
2459
- renderTargetMemoryBytes += width * height * 4 // sceneRenderTexture (non-multisampled)
2403
+ renderTargetMemoryBytes += width * height * 4
2460
2404
  }
2461
2405
  if (this.bloomExtractTexture) {
2462
- const width = Math.floor(this.canvas.width / 2)
2463
- const height = Math.floor(this.canvas.height / 2)
2464
- renderTargetMemoryBytes += width * height * 4 // bloomExtractTexture
2465
- renderTargetMemoryBytes += width * height * 4 // bloomBlurTexture1
2466
- renderTargetMemoryBytes += width * height * 4 // bloomBlurTexture2
2406
+ const width = Math.floor(this.canvas.width / this.BLOOM_DOWNSCALE_FACTOR)
2407
+ const height = Math.floor(this.canvas.height / this.BLOOM_DOWNSCALE_FACTOR)
2408
+ renderTargetMemoryBytes += width * height * 4 * 3
2467
2409
  }
2468
2410
 
2469
2411
  const totalGPUMemoryBytes = textureMemoryBytes + bufferMemoryBytes + renderTargetMemoryBytes
2470
- this.stats.gpuMemory = Math.round((totalGPUMemoryBytes / 1024 / 1024) * 100) / 100
2412
+ return Math.round((totalGPUMemoryBytes / 1024 / 1024) * 100) / 100
2471
2413
  }
2472
2414
  }