reze-engine 0.1.14 → 0.1.16

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
@@ -1,2321 +1,2330 @@
1
- import { Camera } from "./camera"
2
- import { Quat, Vec3 } from "./math"
3
- import { Model } from "./model"
4
- import { PmxLoader } from "./pmx-loader"
5
- import { Physics } from "./physics"
6
-
7
- export interface EngineStats {
8
- fps: number
9
- frameTime: number // ms
10
- gpuMemory: number // MB (estimated total GPU memory)
11
- }
12
-
13
- export class Engine {
14
- private canvas: HTMLCanvasElement
15
- private device!: GPUDevice
16
- private context!: GPUCanvasContext
17
- private presentationFormat!: GPUTextureFormat
18
- public camera!: Camera
19
- private cameraUniformBuffer!: GPUBuffer
20
- private cameraMatrixData = new Float32Array(36)
21
- private lightUniformBuffer!: GPUBuffer
22
- private lightData = new Float32Array(64)
23
- private lightCount = 0
24
- private vertexBuffer!: GPUBuffer
25
- private indexBuffer?: GPUBuffer
26
- private resizeObserver: ResizeObserver | null = null
27
- private depthTexture!: GPUTexture
28
- private pipeline!: GPURenderPipeline
29
- private outlinePipeline!: GPURenderPipeline
30
- private hairOutlinePipeline!: GPURenderPipeline
31
- private hairOutlineOverEyesPipeline!: GPURenderPipeline
32
- private hairMultiplyPipeline!: GPURenderPipeline
33
- private hairOpaquePipeline!: GPURenderPipeline
34
- private eyePipeline!: GPURenderPipeline
35
- private hairBindGroupLayout!: GPUBindGroupLayout
36
- private outlineBindGroupLayout!: GPUBindGroupLayout
37
- private jointsBuffer!: GPUBuffer
38
- private weightsBuffer!: GPUBuffer
39
- private skinMatrixBuffer?: GPUBuffer
40
- private worldMatrixBuffer?: GPUBuffer
41
- private inverseBindMatrixBuffer?: GPUBuffer
42
- private skinMatrixComputePipeline?: GPUComputePipeline
43
- private skinMatrixComputeBindGroup?: GPUBindGroup
44
- private boneCountBuffer?: GPUBuffer
45
- private multisampleTexture!: GPUTexture
46
- private readonly sampleCount = 4 // MSAA 4x
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 bind groups (created once, reused every frame)
65
- private bloomExtractBindGroup?: GPUBindGroup
66
- private bloomBlurHBindGroup?: GPUBindGroup
67
- private bloomBlurVBindGroup?: GPUBindGroup
68
- private bloomComposeBindGroup?: GPUBindGroup
69
- // Bloom settings
70
- public bloomThreshold: number = 0.3
71
- public bloomIntensity: number = 0.1
72
- // Rim light settings
73
- private rimLightIntensity: number = 0.35
74
- private rimLightPower: number = 2.0
75
- private rimLightColor: [number, number, number] = [1.0, 1.0, 1.0]
76
- private currentModel: Model | null = null
77
- private modelDir: string = ""
78
- private physics: Physics | null = null
79
- private textureSampler!: GPUSampler
80
- private textureCache = new Map<string, GPUTexture>()
81
- private textureSizes = new Map<string, { width: number; height: number }>()
82
-
83
- private lastFpsUpdate = performance.now()
84
- private framesSinceLastUpdate = 0
85
- private frameTimeSamples: number[] = []
86
- private frameTimeSum: number = 0
87
- private drawCallCount: number = 0
88
- private lastFrameTime = performance.now()
89
- private stats: EngineStats = {
90
- fps: 0,
91
- frameTime: 0,
92
- gpuMemory: 0,
93
- }
94
- private animationFrameId: number | null = null
95
- private renderLoopCallback: (() => void) | null = null
96
-
97
- constructor(canvas: HTMLCanvasElement) {
98
- this.canvas = canvas
99
- }
100
-
101
- // Step 1: Get WebGPU device and context
102
- public async init() {
103
- const adapter = await navigator.gpu?.requestAdapter()
104
- const device = await adapter?.requestDevice()
105
- if (!device) {
106
- throw new Error("WebGPU is not supported in this browser.")
107
- }
108
- this.device = device
109
-
110
- const context = this.canvas.getContext("webgpu")
111
- if (!context) {
112
- throw new Error("Failed to get WebGPU context.")
113
- }
114
- this.context = context
115
-
116
- this.presentationFormat = navigator.gpu.getPreferredCanvasFormat()
117
-
118
- this.context.configure({
119
- device: this.device,
120
- format: this.presentationFormat,
121
- alphaMode: "premultiplied",
122
- })
123
-
124
- this.setupCamera()
125
- this.setupLighting()
126
- this.createPipelines()
127
- this.createFullscreenQuad()
128
- this.createBloomPipelines()
129
- this.setupResize()
130
- }
131
-
132
- // Step 2: Create shaders and render pipelines
133
- private createPipelines() {
134
- this.textureSampler = this.device.createSampler({
135
- magFilter: "linear",
136
- minFilter: "linear",
137
- addressModeU: "repeat",
138
- addressModeV: "repeat",
139
- })
140
-
141
- const shaderModule = this.device.createShaderModule({
142
- label: "model shaders",
143
- code: /* wgsl */ `
144
- struct CameraUniforms {
145
- view: mat4x4f,
146
- projection: mat4x4f,
147
- viewPos: vec3f,
148
- _padding: f32,
149
- };
150
-
151
- struct Light {
152
- direction: vec3f,
153
- _padding1: f32,
154
- color: vec3f,
155
- intensity: f32,
156
- };
157
-
158
- struct LightUniforms {
159
- ambient: f32,
160
- lightCount: f32,
161
- _padding1: f32,
162
- _padding2: f32,
163
- lights: array<Light, 4>,
164
- };
165
-
166
- struct MaterialUniforms {
167
- alpha: f32,
168
- alphaMultiplier: f32,
169
- rimIntensity: f32,
170
- rimPower: f32,
171
- rimColor: vec3f,
172
- _padding1: f32,
173
- };
174
-
175
- struct VertexOutput {
176
- @builtin(position) position: vec4f,
177
- @location(0) normal: vec3f,
178
- @location(1) uv: vec2f,
179
- @location(2) worldPos: vec3f,
180
- };
181
-
182
- @group(0) @binding(0) var<uniform> camera: CameraUniforms;
183
- @group(0) @binding(1) var<uniform> light: LightUniforms;
184
- @group(0) @binding(2) var diffuseTexture: texture_2d<f32>;
185
- @group(0) @binding(3) var diffuseSampler: sampler;
186
- @group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
187
- @group(0) @binding(5) var toonTexture: texture_2d<f32>;
188
- @group(0) @binding(6) var toonSampler: sampler;
189
- @group(0) @binding(7) var<uniform> material: MaterialUniforms;
190
-
191
- @vertex fn vs(
192
- @location(0) position: vec3f,
193
- @location(1) normal: vec3f,
194
- @location(2) uv: vec2f,
195
- @location(3) joints0: vec4<u32>,
196
- @location(4) weights0: vec4<f32>
197
- ) -> VertexOutput {
198
- var output: VertexOutput;
199
- let pos4 = vec4f(position, 1.0);
200
-
201
- // Normalize weights to ensure they sum to 1.0 (handles floating-point precision issues)
202
- let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
203
- var normalizedWeights: vec4f;
204
- if (weightSum > 0.0001) {
205
- normalizedWeights = weights0 / weightSum;
206
- } else {
207
- normalizedWeights = vec4f(1.0, 0.0, 0.0, 0.0);
208
- }
209
-
210
- var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
211
- var skinnedNrm = vec3f(0.0, 0.0, 0.0);
212
- for (var i = 0u; i < 4u; i++) {
213
- let j = joints0[i];
214
- let w = normalizedWeights[i];
215
- let m = skinMats[j];
216
- skinnedPos += (m * pos4) * w;
217
- let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
218
- skinnedNrm += (r3 * normal) * w;
219
- }
220
- let worldPos = skinnedPos.xyz;
221
- output.position = camera.projection * camera.view * vec4f(worldPos, 1.0);
222
- output.normal = normalize(skinnedNrm);
223
- output.uv = uv;
224
- output.worldPos = worldPos;
225
- return output;
226
- }
227
-
228
- @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
229
- let n = normalize(input.normal);
230
- let albedo = textureSample(diffuseTexture, diffuseSampler, input.uv).rgb;
231
-
232
- var lightAccum = vec3f(light.ambient);
233
- let numLights = u32(light.lightCount);
234
- for (var i = 0u; i < numLights; i++) {
235
- let l = -light.lights[i].direction;
236
- let nDotL = max(dot(n, l), 0.0);
237
- let toonUV = vec2f(nDotL, 0.5);
238
- let toonFactor = textureSample(toonTexture, toonSampler, toonUV).rgb;
239
- let radiance = light.lights[i].color * light.lights[i].intensity;
240
- lightAccum += toonFactor * radiance * nDotL;
241
- }
242
-
243
- // Rim light calculation
244
- let viewDir = normalize(camera.viewPos - input.worldPos);
245
- var rimFactor = 1.0 - max(dot(n, viewDir), 0.0);
246
- rimFactor = pow(rimFactor, material.rimPower);
247
- let rimLight = material.rimColor * material.rimIntensity * rimFactor;
248
-
249
- let color = albedo * lightAccum + rimLight;
250
- let finalAlpha = material.alpha * material.alphaMultiplier;
251
- if (finalAlpha < 0.001) {
252
- discard;
253
- }
254
-
255
- return vec4f(clamp(color, vec3f(0.0), vec3f(1.0)), finalAlpha);
256
- }
257
- `,
258
- })
259
-
260
- // Create explicit bind group layout for all pipelines using the main shader
261
- // This ensures compatibility across all pipelines (main, eye, hair multiply, hair opaque)
262
- this.hairBindGroupLayout = this.device.createBindGroupLayout({
263
- label: "shared material bind group layout",
264
- entries: [
265
- { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // camera
266
- { binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // light
267
- { binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: {} }, // diffuseTexture
268
- { binding: 3, visibility: GPUShaderStage.FRAGMENT, sampler: {} }, // diffuseSampler
269
- { binding: 4, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } }, // skinMats
270
- { binding: 5, visibility: GPUShaderStage.FRAGMENT, texture: {} }, // toonTexture
271
- { binding: 6, visibility: GPUShaderStage.FRAGMENT, sampler: {} }, // toonSampler
272
- { binding: 7, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // material
273
- ],
274
- })
275
-
276
- const sharedPipelineLayout = this.device.createPipelineLayout({
277
- label: "shared pipeline layout",
278
- bindGroupLayouts: [this.hairBindGroupLayout],
279
- })
280
-
281
- // Single pipeline for all materials with alpha blending
282
- this.pipeline = this.device.createRenderPipeline({
283
- label: "model pipeline",
284
- layout: sharedPipelineLayout,
285
- vertex: {
286
- module: shaderModule,
287
- buffers: [
288
- {
289
- arrayStride: 8 * 4,
290
- attributes: [
291
- { shaderLocation: 0, offset: 0, format: "float32x3" as GPUVertexFormat },
292
- { shaderLocation: 1, offset: 3 * 4, format: "float32x3" as GPUVertexFormat },
293
- { shaderLocation: 2, offset: 6 * 4, format: "float32x2" as GPUVertexFormat },
294
- ],
295
- },
296
- {
297
- arrayStride: 4 * 2,
298
- attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" as GPUVertexFormat }],
299
- },
300
- {
301
- arrayStride: 4,
302
- attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" as GPUVertexFormat }],
303
- },
304
- ],
305
- },
306
- fragment: {
307
- module: shaderModule,
308
- targets: [
309
- {
310
- format: this.presentationFormat,
311
- blend: {
312
- color: {
313
- srcFactor: "src-alpha",
314
- dstFactor: "one-minus-src-alpha",
315
- operation: "add",
316
- },
317
- alpha: {
318
- srcFactor: "one",
319
- dstFactor: "one-minus-src-alpha",
320
- operation: "add",
321
- },
322
- },
323
- },
324
- ],
325
- },
326
- primitive: { cullMode: "none" },
327
- depthStencil: {
328
- format: "depth24plus-stencil8",
329
- depthWriteEnabled: true,
330
- depthCompare: "less",
331
- },
332
- multisample: {
333
- count: this.sampleCount,
334
- },
335
- })
336
-
337
- // Create bind group layout for outline pipelines
338
- this.outlineBindGroupLayout = this.device.createBindGroupLayout({
339
- label: "outline bind group layout",
340
- entries: [
341
- { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // camera
342
- { binding: 1, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // material
343
- { binding: 2, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } }, // skinMats
344
- ],
345
- })
346
-
347
- const outlinePipelineLayout = this.device.createPipelineLayout({
348
- label: "outline pipeline layout",
349
- bindGroupLayouts: [this.outlineBindGroupLayout],
350
- })
351
-
352
- const outlineShaderModule = this.device.createShaderModule({
353
- label: "outline shaders",
354
- code: /* wgsl */ `
355
- struct CameraUniforms {
356
- view: mat4x4f,
357
- projection: mat4x4f,
358
- viewPos: vec3f,
359
- _padding: f32,
360
- };
361
-
362
- struct MaterialUniforms {
363
- edgeColor: vec4f,
364
- edgeSize: f32,
365
- _padding1: f32,
366
- _padding2: f32,
367
- _padding3: f32,
368
- };
369
-
370
- @group(0) @binding(0) var<uniform> camera: CameraUniforms;
371
- @group(0) @binding(1) var<uniform> material: MaterialUniforms;
372
- @group(0) @binding(2) var<storage, read> skinMats: array<mat4x4f>;
373
-
374
- struct VertexOutput {
375
- @builtin(position) position: vec4f,
376
- };
377
-
378
- @vertex fn vs(
379
- @location(0) position: vec3f,
380
- @location(1) normal: vec3f,
381
- @location(3) joints0: vec4<u32>,
382
- @location(4) weights0: vec4<f32>
383
- ) -> VertexOutput {
384
- var output: VertexOutput;
385
- let pos4 = vec4f(position, 1.0);
386
-
387
- // Normalize weights to ensure they sum to 1.0 (handles floating-point precision issues)
388
- let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
389
- var normalizedWeights: vec4f;
390
- if (weightSum > 0.0001) {
391
- normalizedWeights = weights0 / weightSum;
392
- } else {
393
- normalizedWeights = vec4f(1.0, 0.0, 0.0, 0.0);
394
- }
395
-
396
- var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
397
- var skinnedNrm = vec3f(0.0, 0.0, 0.0);
398
- for (var i = 0u; i < 4u; i++) {
399
- let j = joints0[i];
400
- let w = normalizedWeights[i];
401
- let m = skinMats[j];
402
- skinnedPos += (m * pos4) * w;
403
- let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
404
- skinnedNrm += (r3 * normal) * w;
405
- }
406
- let worldPos = skinnedPos.xyz;
407
- let worldNormal = normalize(skinnedNrm);
408
-
409
- // MMD invert hull: expand vertices outward along normals
410
- let scaleFactor = 0.01;
411
- let expandedPos = worldPos + worldNormal * material.edgeSize * scaleFactor;
412
- output.position = camera.projection * camera.view * vec4f(expandedPos, 1.0);
413
- return output;
414
- }
415
-
416
- @fragment fn fs() -> @location(0) vec4f {
417
- return material.edgeColor;
418
- }
419
- `,
420
- })
421
-
422
- this.outlinePipeline = this.device.createRenderPipeline({
423
- label: "outline pipeline",
424
- layout: outlinePipelineLayout,
425
- vertex: {
426
- module: outlineShaderModule,
427
- buffers: [
428
- {
429
- arrayStride: 8 * 4,
430
- attributes: [
431
- {
432
- shaderLocation: 0,
433
- offset: 0,
434
- format: "float32x3" as GPUVertexFormat,
435
- },
436
- {
437
- shaderLocation: 1,
438
- offset: 3 * 4,
439
- format: "float32x3" as GPUVertexFormat,
440
- },
441
- ],
442
- },
443
- {
444
- arrayStride: 4 * 2,
445
- attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" as GPUVertexFormat }],
446
- },
447
- {
448
- arrayStride: 4,
449
- attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" as GPUVertexFormat }],
450
- },
451
- ],
452
- },
453
- fragment: {
454
- module: outlineShaderModule,
455
- targets: [
456
- {
457
- format: this.presentationFormat,
458
- blend: {
459
- color: {
460
- srcFactor: "src-alpha",
461
- dstFactor: "one-minus-src-alpha",
462
- operation: "add",
463
- },
464
- alpha: {
465
- srcFactor: "one",
466
- dstFactor: "one-minus-src-alpha",
467
- operation: "add",
468
- },
469
- },
470
- },
471
- ],
472
- },
473
- primitive: {
474
- cullMode: "back",
475
- },
476
- depthStencil: {
477
- format: "depth24plus-stencil8",
478
- depthWriteEnabled: true,
479
- depthCompare: "less",
480
- },
481
- multisample: {
482
- count: this.sampleCount,
483
- },
484
- })
485
-
486
- // 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
487
- this.hairOutlinePipeline = this.device.createRenderPipeline({
488
- label: "hair outline pipeline",
489
- layout: outlinePipelineLayout,
490
- vertex: {
491
- module: outlineShaderModule,
492
- buffers: [
493
- {
494
- arrayStride: 8 * 4,
495
- attributes: [
496
- {
497
- shaderLocation: 0,
498
- offset: 0,
499
- format: "float32x3" as GPUVertexFormat,
500
- },
501
- {
502
- shaderLocation: 1,
503
- offset: 3 * 4,
504
- format: "float32x3" as GPUVertexFormat,
505
- },
506
- ],
507
- },
508
- {
509
- arrayStride: 4 * 2,
510
- attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" as GPUVertexFormat }],
511
- },
512
- {
513
- arrayStride: 4,
514
- attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" as GPUVertexFormat }],
515
- },
516
- ],
517
- },
518
- fragment: {
519
- module: outlineShaderModule,
520
- targets: [
521
- {
522
- format: this.presentationFormat,
523
- blend: {
524
- color: {
525
- srcFactor: "src-alpha",
526
- dstFactor: "one-minus-src-alpha",
527
- operation: "add",
528
- },
529
- alpha: {
530
- srcFactor: "one",
531
- dstFactor: "one-minus-src-alpha",
532
- operation: "add",
533
- },
534
- },
535
- },
536
- ],
537
- },
538
- primitive: {
539
- cullMode: "back",
540
- },
541
- depthStencil: {
542
- format: "depth24plus-stencil8",
543
- depthWriteEnabled: false, // Don't write depth - let hair geometry control depth
544
- depthCompare: "less-equal", // Only draw where hair depth exists
545
- stencilFront: {
546
- compare: "not-equal", // Only render where stencil != 1 (not over eyes)
547
- failOp: "keep",
548
- depthFailOp: "keep",
549
- passOp: "keep",
550
- },
551
- stencilBack: {
552
- compare: "not-equal",
553
- failOp: "keep",
554
- depthFailOp: "keep",
555
- passOp: "keep",
556
- },
557
- },
558
- multisample: {
559
- count: this.sampleCount,
560
- },
561
- })
562
-
563
- // 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
564
- this.hairOutlineOverEyesPipeline = this.device.createRenderPipeline({
565
- label: "hair outline over eyes pipeline",
566
- layout: outlinePipelineLayout,
567
- vertex: {
568
- module: outlineShaderModule,
569
- buffers: [
570
- {
571
- arrayStride: 8 * 4,
572
- attributes: [
573
- {
574
- shaderLocation: 0,
575
- offset: 0,
576
- format: "float32x3" as GPUVertexFormat,
577
- },
578
- {
579
- shaderLocation: 1,
580
- offset: 3 * 4,
581
- format: "float32x3" as GPUVertexFormat,
582
- },
583
- ],
584
- },
585
- {
586
- arrayStride: 4 * 2,
587
- attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" as GPUVertexFormat }],
588
- },
589
- {
590
- arrayStride: 4,
591
- attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" as GPUVertexFormat }],
592
- },
593
- ],
594
- },
595
- fragment: {
596
- module: outlineShaderModule,
597
- targets: [
598
- {
599
- format: this.presentationFormat,
600
- blend: {
601
- color: {
602
- srcFactor: "src-alpha",
603
- dstFactor: "one-minus-src-alpha",
604
- operation: "add",
605
- },
606
- alpha: {
607
- srcFactor: "one",
608
- dstFactor: "one-minus-src-alpha",
609
- operation: "add",
610
- },
611
- },
612
- },
613
- ],
614
- },
615
- primitive: {
616
- cullMode: "back",
617
- },
618
- depthStencil: {
619
- format: "depth24plus-stencil8",
620
- depthWriteEnabled: false, // Don't write depth
621
-
622
- depthCompare: "less-equal", // Draw where outline depth <= existing depth (hair depth)
623
- depthBias: -0.0001, // Small negative bias to bring outline slightly closer for depth test
624
- depthBiasSlopeScale: 0.0,
625
- depthBiasClamp: 0.0,
626
- stencilFront: {
627
- compare: "equal", // Only render where stencil == 1 (over eyes)
628
- failOp: "keep",
629
- depthFailOp: "keep",
630
- passOp: "keep",
631
- },
632
- stencilBack: {
633
- compare: "equal",
634
- failOp: "keep",
635
- depthFailOp: "keep",
636
- passOp: "keep",
637
- },
638
- },
639
- multisample: {
640
- count: this.sampleCount,
641
- },
642
- })
643
-
644
- // Unified hair pipeline - can be used for both over-eyes and over-non-eyes
645
- // The difference is controlled by stencil state and alpha multiplier in material uniform
646
- this.hairMultiplyPipeline = this.device.createRenderPipeline({
647
- label: "hair pipeline (over eyes)",
648
- layout: sharedPipelineLayout,
649
- vertex: {
650
- module: shaderModule,
651
- buffers: [
652
- {
653
- arrayStride: 8 * 4,
654
- attributes: [
655
- { shaderLocation: 0, offset: 0, format: "float32x3" as GPUVertexFormat },
656
- { shaderLocation: 1, offset: 3 * 4, format: "float32x3" as GPUVertexFormat },
657
- { shaderLocation: 2, offset: 6 * 4, format: "float32x2" as GPUVertexFormat },
658
- ],
659
- },
660
- {
661
- arrayStride: 4 * 2,
662
- attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" as GPUVertexFormat }],
663
- },
664
- {
665
- arrayStride: 4,
666
- attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" as GPUVertexFormat }],
667
- },
668
- ],
669
- },
670
- fragment: {
671
- module: shaderModule,
672
- targets: [
673
- {
674
- format: this.presentationFormat,
675
- blend: {
676
- color: {
677
- srcFactor: "src-alpha",
678
- dstFactor: "one-minus-src-alpha",
679
- operation: "add",
680
- },
681
- alpha: {
682
- srcFactor: "one",
683
- dstFactor: "one-minus-src-alpha",
684
- operation: "add",
685
- },
686
- },
687
- },
688
- ],
689
- },
690
- primitive: { cullMode: "none" },
691
- depthStencil: {
692
- format: "depth24plus-stencil8",
693
- depthWriteEnabled: true, // Write depth so outlines can test against it
694
- depthCompare: "less",
695
- stencilFront: {
696
- compare: "equal", // Only render where stencil == 1
697
- failOp: "keep",
698
- depthFailOp: "keep",
699
- passOp: "keep",
700
- },
701
- stencilBack: {
702
- compare: "equal",
703
- failOp: "keep",
704
- depthFailOp: "keep",
705
- passOp: "keep",
706
- },
707
- },
708
- multisample: { count: this.sampleCount },
709
- })
710
-
711
- // Hair pipeline for opaque rendering (hair over non-eyes) - uses same shader, different stencil state
712
- this.hairOpaquePipeline = this.device.createRenderPipeline({
713
- label: "hair pipeline (over non-eyes)",
714
- layout: sharedPipelineLayout,
715
- vertex: {
716
- module: shaderModule,
717
- buffers: [
718
- {
719
- arrayStride: 8 * 4,
720
- attributes: [
721
- { shaderLocation: 0, offset: 0, format: "float32x3" as GPUVertexFormat },
722
- { shaderLocation: 1, offset: 3 * 4, format: "float32x3" as GPUVertexFormat },
723
- { shaderLocation: 2, offset: 6 * 4, format: "float32x2" as GPUVertexFormat },
724
- ],
725
- },
726
- {
727
- arrayStride: 4 * 2,
728
- attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" as GPUVertexFormat }],
729
- },
730
- {
731
- arrayStride: 4,
732
- attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" as GPUVertexFormat }],
733
- },
734
- ],
735
- },
736
- fragment: {
737
- module: shaderModule,
738
- targets: [
739
- {
740
- format: this.presentationFormat,
741
- blend: {
742
- color: {
743
- srcFactor: "src-alpha",
744
- dstFactor: "one-minus-src-alpha",
745
- operation: "add",
746
- },
747
- alpha: {
748
- srcFactor: "one",
749
- dstFactor: "one-minus-src-alpha",
750
- operation: "add",
751
- },
752
- },
753
- },
754
- ],
755
- },
756
- primitive: { cullMode: "none" },
757
- depthStencil: {
758
- format: "depth24plus-stencil8",
759
- depthWriteEnabled: true,
760
- depthCompare: "less",
761
- stencilFront: {
762
- compare: "not-equal", // Only render where stencil != 1
763
- failOp: "keep",
764
- depthFailOp: "keep",
765
- passOp: "keep",
766
- },
767
- stencilBack: {
768
- compare: "not-equal",
769
- failOp: "keep",
770
- depthFailOp: "keep",
771
- passOp: "keep",
772
- },
773
- },
774
- multisample: { count: this.sampleCount },
775
- })
776
-
777
- // Eye overlay pipeline (renders after opaque, writes stencil)
778
- this.eyePipeline = this.device.createRenderPipeline({
779
- label: "eye overlay pipeline",
780
- layout: sharedPipelineLayout,
781
- vertex: {
782
- module: shaderModule,
783
- buffers: [
784
- {
785
- arrayStride: 8 * 4,
786
- attributes: [
787
- { shaderLocation: 0, offset: 0, format: "float32x3" as GPUVertexFormat },
788
- { shaderLocation: 1, offset: 3 * 4, format: "float32x3" as GPUVertexFormat },
789
- { shaderLocation: 2, offset: 6 * 4, format: "float32x2" as GPUVertexFormat },
790
- ],
791
- },
792
- {
793
- arrayStride: 4 * 2,
794
- attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" as GPUVertexFormat }],
795
- },
796
- {
797
- arrayStride: 4,
798
- attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" as GPUVertexFormat }],
799
- },
800
- ],
801
- },
802
- fragment: {
803
- module: shaderModule,
804
- targets: [
805
- {
806
- format: this.presentationFormat,
807
- blend: {
808
- color: {
809
- srcFactor: "src-alpha",
810
- dstFactor: "one-minus-src-alpha",
811
- operation: "add",
812
- },
813
- alpha: {
814
- srcFactor: "one",
815
- dstFactor: "one-minus-src-alpha",
816
- operation: "add",
817
- },
818
- },
819
- },
820
- ],
821
- },
822
- primitive: { cullMode: "none" },
823
- depthStencil: {
824
- format: "depth24plus-stencil8",
825
- depthWriteEnabled: false, // Don't write depth
826
- depthCompare: "less", // Respect existing depth
827
- stencilFront: {
828
- compare: "always",
829
- failOp: "keep",
830
- depthFailOp: "keep",
831
- passOp: "replace", // Write stencil value 1
832
- },
833
- stencilBack: {
834
- compare: "always",
835
- failOp: "keep",
836
- depthFailOp: "keep",
837
- passOp: "replace",
838
- },
839
- },
840
- multisample: { count: this.sampleCount },
841
- })
842
- }
843
-
844
- // Create compute shader for skin matrix computation
845
- private createSkinMatrixComputePipeline() {
846
- const computeShader = this.device.createShaderModule({
847
- label: "skin matrix compute",
848
- code: /* wgsl */ `
849
- struct BoneCountUniform {
850
- count: u32,
851
- _padding1: u32,
852
- _padding2: u32,
853
- _padding3: u32,
854
- _padding4: vec4<u32>,
855
- };
856
-
857
- @group(0) @binding(0) var<uniform> boneCount: BoneCountUniform;
858
- @group(0) @binding(1) var<storage, read> worldMatrices: array<mat4x4f>;
859
- @group(0) @binding(2) var<storage, read> inverseBindMatrices: array<mat4x4f>;
860
- @group(0) @binding(3) var<storage, read_write> skinMatrices: array<mat4x4f>;
861
-
862
- @compute @workgroup_size(64)
863
- fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
864
- let boneIndex = globalId.x;
865
- // Bounds check: we dispatch workgroups (64 threads each), so some threads may be out of range
866
- if (boneIndex >= boneCount.count) {
867
- return;
868
- }
869
- let worldMat = worldMatrices[boneIndex];
870
- let invBindMat = inverseBindMatrices[boneIndex];
871
- skinMatrices[boneIndex] = worldMat * invBindMat;
872
- }
873
- `,
874
- })
875
-
876
- this.skinMatrixComputePipeline = this.device.createComputePipeline({
877
- label: "skin matrix compute pipeline",
878
- layout: "auto",
879
- compute: {
880
- module: computeShader,
881
- },
882
- })
883
- }
884
-
885
- // Create fullscreen quad for post-processing
886
- private createFullscreenQuad() {
887
- // Fullscreen quad vertices: two triangles covering the entire screen - Format: position (x, y), uv (u, v)
888
- const quadVertices = new Float32Array([
889
- // Triangle 1
890
- -1.0,
891
- -1.0,
892
- 0.0,
893
- 0.0, // bottom-left
894
- 1.0,
895
- -1.0,
896
- 1.0,
897
- 0.0, // bottom-right
898
- -1.0,
899
- 1.0,
900
- 0.0,
901
- 1.0, // top-left
902
- // Triangle 2
903
- -1.0,
904
- 1.0,
905
- 0.0,
906
- 1.0, // top-left
907
- 1.0,
908
- -1.0,
909
- 1.0,
910
- 0.0, // bottom-right
911
- 1.0,
912
- 1.0,
913
- 1.0,
914
- 1.0, // top-right
915
- ])
916
-
917
- this.fullscreenQuadBuffer = this.device.createBuffer({
918
- label: "fullscreen quad",
919
- size: quadVertices.byteLength,
920
- usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
921
- })
922
- this.device.queue.writeBuffer(this.fullscreenQuadBuffer, 0, quadVertices)
923
- }
924
-
925
- // Create bloom post-processing pipelines
926
- private createBloomPipelines() {
927
- // Bloom extraction shader (extracts bright areas)
928
- const bloomExtractShader = this.device.createShaderModule({
929
- label: "bloom extract",
930
- code: /* wgsl */ `
931
- struct VertexOutput {
932
- @builtin(position) position: vec4f,
933
- @location(0) uv: vec2f,
934
- };
935
-
936
- @vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
937
- var output: VertexOutput;
938
- // Generate fullscreen quad from vertex index
939
- let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
940
- let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
941
- output.position = vec4f(x, y, 0.0, 1.0);
942
- output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
943
- return output;
944
- }
945
-
946
- struct BloomExtractUniforms {
947
- threshold: f32,
948
- _padding1: f32,
949
- _padding2: f32,
950
- _padding3: f32,
951
- _padding4: f32,
952
- _padding5: f32,
953
- _padding6: f32,
954
- _padding7: f32,
955
- };
956
-
957
- @group(0) @binding(0) var inputTexture: texture_2d<f32>;
958
- @group(0) @binding(1) var inputSampler: sampler;
959
- @group(0) @binding(2) var<uniform> extractUniforms: BloomExtractUniforms;
960
-
961
- @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
962
- let color = textureSample(inputTexture, inputSampler, input.uv);
963
- // Extract bright areas above threshold
964
- let threshold = extractUniforms.threshold;
965
- let bloom = max(vec3f(0.0), color.rgb - vec3f(threshold)) / max(0.001, 1.0 - threshold);
966
- return vec4f(bloom, color.a);
967
- }
968
- `,
969
- })
970
-
971
- // Bloom blur shader (gaussian blur - can be used for both horizontal and vertical)
972
- const bloomBlurShader = this.device.createShaderModule({
973
- label: "bloom blur",
974
- code: /* wgsl */ `
975
- struct VertexOutput {
976
- @builtin(position) position: vec4f,
977
- @location(0) uv: vec2f,
978
- };
979
-
980
- @vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
981
- var output: VertexOutput;
982
- let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
983
- let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
984
- output.position = vec4f(x, y, 0.0, 1.0);
985
- output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
986
- return output;
987
- }
988
-
989
- struct BlurUniforms {
990
- direction: vec2f,
991
- _padding1: f32,
992
- _padding2: f32,
993
- _padding3: f32,
994
- _padding4: f32,
995
- _padding5: f32,
996
- _padding6: f32,
997
- };
998
-
999
- @group(0) @binding(0) var inputTexture: texture_2d<f32>;
1000
- @group(0) @binding(1) var inputSampler: sampler;
1001
- @group(0) @binding(2) var<uniform> blurUniforms: BlurUniforms;
1002
-
1003
- // 9-tap gaussian blur
1004
- @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
1005
- let texelSize = 1.0 / vec2f(textureDimensions(inputTexture));
1006
- var result = vec4f(0.0);
1007
-
1008
- // Gaussian weights for 9-tap filter
1009
- let weights = array<f32, 9>(
1010
- 0.01621622, 0.05405405, 0.12162162,
1011
- 0.19459459, 0.22702703,
1012
- 0.19459459, 0.12162162, 0.05405405, 0.01621622
1013
- );
1014
-
1015
- let offsets = array<f32, 9>(-4.0, -3.0, -2.0, -1.0, 0.0, 1.0, 2.0, 3.0, 4.0);
1016
-
1017
- for (var i = 0u; i < 9u; i++) {
1018
- let offset = offsets[i] * texelSize * blurUniforms.direction;
1019
- result += textureSample(inputTexture, inputSampler, input.uv + offset) * weights[i];
1020
- }
1021
-
1022
- return result;
1023
- }
1024
- `,
1025
- })
1026
-
1027
- // Bloom composition shader (combines original scene with bloom)
1028
- const bloomComposeShader = this.device.createShaderModule({
1029
- label: "bloom compose",
1030
- code: /* wgsl */ `
1031
- struct VertexOutput {
1032
- @builtin(position) position: vec4f,
1033
- @location(0) uv: vec2f,
1034
- };
1035
-
1036
- @vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
1037
- var output: VertexOutput;
1038
- let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
1039
- let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
1040
- output.position = vec4f(x, y, 0.0, 1.0);
1041
- output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
1042
- return output;
1043
- }
1044
-
1045
- struct BloomComposeUniforms {
1046
- intensity: f32,
1047
- _padding1: f32,
1048
- _padding2: f32,
1049
- _padding3: f32,
1050
- _padding4: f32,
1051
- _padding5: f32,
1052
- _padding6: f32,
1053
- _padding7: f32,
1054
- };
1055
-
1056
- @group(0) @binding(0) var sceneTexture: texture_2d<f32>;
1057
- @group(0) @binding(1) var sceneSampler: sampler;
1058
- @group(0) @binding(2) var bloomTexture: texture_2d<f32>;
1059
- @group(0) @binding(3) var bloomSampler: sampler;
1060
- @group(0) @binding(4) var<uniform> composeUniforms: BloomComposeUniforms;
1061
-
1062
- @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
1063
- let scene = textureSample(sceneTexture, sceneSampler, input.uv);
1064
- let bloom = textureSample(bloomTexture, bloomSampler, input.uv);
1065
- // Additive blending with intensity control
1066
- let result = scene.rgb + bloom.rgb * composeUniforms.intensity;
1067
- return vec4f(result, scene.a);
1068
- }
1069
- `,
1070
- })
1071
-
1072
- // Create uniform buffer for blur direction (minimum 32 bytes for WebGPU)
1073
- const blurDirectionBuffer = this.device.createBuffer({
1074
- label: "blur direction",
1075
- size: 32, // Minimum 32 bytes required for uniform buffers in WebGPU
1076
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1077
- })
1078
-
1079
- // Create uniform buffer for bloom intensity (minimum 32 bytes for WebGPU)
1080
- const bloomIntensityBuffer = this.device.createBuffer({
1081
- label: "bloom intensity",
1082
- size: 32, // Minimum 32 bytes required for uniform buffers in WebGPU
1083
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1084
- })
1085
-
1086
- // Create uniform buffer for bloom threshold (minimum 32 bytes for WebGPU)
1087
- const bloomThresholdBuffer = this.device.createBuffer({
1088
- label: "bloom threshold",
1089
- size: 32, // Minimum 32 bytes required for uniform buffers in WebGPU
1090
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1091
- })
1092
-
1093
- // Set default bloom values
1094
- const intensityData = new Float32Array(8) // f32 + 7 padding floats = 8 floats = 32 bytes
1095
- intensityData[0] = this.bloomIntensity
1096
- this.device.queue.writeBuffer(bloomIntensityBuffer, 0, intensityData)
1097
-
1098
- const thresholdData = new Float32Array(8) // f32 + 7 padding floats = 8 floats = 32 bytes
1099
- thresholdData[0] = this.bloomThreshold
1100
- this.device.queue.writeBuffer(bloomThresholdBuffer, 0, thresholdData)
1101
-
1102
- // Create linear sampler for post-processing
1103
- const linearSampler = this.device.createSampler({
1104
- magFilter: "linear",
1105
- minFilter: "linear",
1106
- addressModeU: "clamp-to-edge",
1107
- addressModeV: "clamp-to-edge",
1108
- })
1109
-
1110
- // Bloom extraction pipeline
1111
- this.bloomExtractPipeline = this.device.createRenderPipeline({
1112
- label: "bloom extract",
1113
- layout: "auto",
1114
- vertex: {
1115
- module: bloomExtractShader,
1116
- entryPoint: "vs",
1117
- },
1118
- fragment: {
1119
- module: bloomExtractShader,
1120
- entryPoint: "fs",
1121
- targets: [{ format: this.presentationFormat }],
1122
- },
1123
- primitive: { topology: "triangle-list" },
1124
- })
1125
-
1126
- // Bloom blur pipeline
1127
- this.bloomBlurPipeline = this.device.createRenderPipeline({
1128
- label: "bloom blur",
1129
- layout: "auto",
1130
- vertex: {
1131
- module: bloomBlurShader,
1132
- entryPoint: "vs",
1133
- },
1134
- fragment: {
1135
- module: bloomBlurShader,
1136
- entryPoint: "fs",
1137
- targets: [{ format: this.presentationFormat }],
1138
- },
1139
- primitive: { topology: "triangle-list" },
1140
- })
1141
-
1142
- // Bloom composition pipeline
1143
- this.bloomComposePipeline = this.device.createRenderPipeline({
1144
- label: "bloom compose",
1145
- layout: "auto",
1146
- vertex: {
1147
- module: bloomComposeShader,
1148
- entryPoint: "vs",
1149
- },
1150
- fragment: {
1151
- module: bloomComposeShader,
1152
- entryPoint: "fs",
1153
- targets: [{ format: this.presentationFormat }],
1154
- },
1155
- primitive: { topology: "triangle-list" },
1156
- })
1157
-
1158
- // Store buffers and sampler for later use
1159
- this.blurDirectionBuffer = blurDirectionBuffer
1160
- this.bloomIntensityBuffer = bloomIntensityBuffer
1161
- this.bloomThresholdBuffer = bloomThresholdBuffer
1162
- this.linearSampler = linearSampler
1163
- }
1164
-
1165
- // Setup bloom textures and bind groups (called when canvas is resized)
1166
- private setupBloom(width: number, height: number) {
1167
- // Create bloom textures (half resolution for performance)
1168
- const bloomWidth = Math.floor(width / 2)
1169
- const bloomHeight = Math.floor(height / 2)
1170
- this.bloomExtractTexture = this.device.createTexture({
1171
- label: "bloom extract",
1172
- size: [bloomWidth, bloomHeight],
1173
- format: this.presentationFormat,
1174
- usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1175
- })
1176
- this.bloomBlurTexture1 = this.device.createTexture({
1177
- label: "bloom blur 1",
1178
- size: [bloomWidth, bloomHeight],
1179
- format: this.presentationFormat,
1180
- usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1181
- })
1182
- this.bloomBlurTexture2 = this.device.createTexture({
1183
- label: "bloom blur 2",
1184
- size: [bloomWidth, bloomHeight],
1185
- format: this.presentationFormat,
1186
- usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1187
- })
1188
-
1189
- // Create bloom bind groups
1190
- this.bloomExtractBindGroup = this.device.createBindGroup({
1191
- layout: this.bloomExtractPipeline.getBindGroupLayout(0),
1192
- entries: [
1193
- { binding: 0, resource: this.sceneRenderTexture.createView() },
1194
- { binding: 1, resource: this.linearSampler },
1195
- { binding: 2, resource: { buffer: this.bloomThresholdBuffer } },
1196
- ],
1197
- })
1198
-
1199
- this.bloomBlurHBindGroup = this.device.createBindGroup({
1200
- layout: this.bloomBlurPipeline.getBindGroupLayout(0),
1201
- entries: [
1202
- { binding: 0, resource: this.bloomExtractTexture.createView() },
1203
- { binding: 1, resource: this.linearSampler },
1204
- { binding: 2, resource: { buffer: this.blurDirectionBuffer } },
1205
- ],
1206
- })
1207
-
1208
- this.bloomBlurVBindGroup = this.device.createBindGroup({
1209
- layout: this.bloomBlurPipeline.getBindGroupLayout(0),
1210
- entries: [
1211
- { binding: 0, resource: this.bloomBlurTexture1.createView() },
1212
- { binding: 1, resource: this.linearSampler },
1213
- { binding: 2, resource: { buffer: this.blurDirectionBuffer } },
1214
- ],
1215
- })
1216
-
1217
- this.bloomComposeBindGroup = this.device.createBindGroup({
1218
- layout: this.bloomComposePipeline.getBindGroupLayout(0),
1219
- entries: [
1220
- { binding: 0, resource: this.sceneRenderTexture.createView() },
1221
- { binding: 1, resource: this.linearSampler },
1222
- { binding: 2, resource: this.bloomBlurTexture2.createView() },
1223
- { binding: 3, resource: this.linearSampler },
1224
- { binding: 4, resource: { buffer: this.bloomIntensityBuffer } },
1225
- ],
1226
- })
1227
- }
1228
-
1229
- // Step 3: Setup canvas resize handling
1230
- private setupResize() {
1231
- this.resizeObserver = new ResizeObserver(() => this.handleResize())
1232
- this.resizeObserver.observe(this.canvas)
1233
- this.handleResize()
1234
- }
1235
-
1236
- private handleResize() {
1237
- const displayWidth = this.canvas.clientWidth
1238
- const displayHeight = this.canvas.clientHeight
1239
-
1240
- const dpr = window.devicePixelRatio || 1
1241
- const width = Math.floor(displayWidth * dpr)
1242
- const height = Math.floor(displayHeight * dpr)
1243
-
1244
- if (!this.multisampleTexture || this.canvas.width !== width || this.canvas.height !== height) {
1245
- this.canvas.width = width
1246
- this.canvas.height = height
1247
-
1248
- this.multisampleTexture = this.device.createTexture({
1249
- label: "multisample render target",
1250
- size: [width, height],
1251
- sampleCount: this.sampleCount,
1252
- format: this.presentationFormat,
1253
- usage: GPUTextureUsage.RENDER_ATTACHMENT,
1254
- })
1255
-
1256
- this.depthTexture = this.device.createTexture({
1257
- label: "depth texture",
1258
- size: [width, height],
1259
- sampleCount: this.sampleCount,
1260
- format: "depth24plus-stencil8",
1261
- usage: GPUTextureUsage.RENDER_ATTACHMENT,
1262
- })
1263
-
1264
- // Create scene render texture (non-multisampled for post-processing)
1265
- this.sceneRenderTexture = this.device.createTexture({
1266
- label: "scene render texture",
1267
- size: [width, height],
1268
- format: this.presentationFormat,
1269
- usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1270
- })
1271
- this.sceneRenderTextureView = this.sceneRenderTexture.createView()
1272
-
1273
- // Setup bloom textures and bind groups
1274
- this.setupBloom(width, height)
1275
-
1276
- const depthTextureView = this.depthTexture.createView()
1277
-
1278
- // Render scene to texture instead of directly to canvas
1279
- const colorAttachment: GPURenderPassColorAttachment =
1280
- this.sampleCount > 1
1281
- ? {
1282
- view: this.multisampleTexture.createView(),
1283
- resolveTarget: this.sceneRenderTextureView,
1284
- clearValue: { r: 0, g: 0, b: 0, a: 0 },
1285
- loadOp: "clear",
1286
- storeOp: "store",
1287
- }
1288
- : {
1289
- view: this.sceneRenderTextureView,
1290
- clearValue: { r: 0, g: 0, b: 0, a: 0 },
1291
- loadOp: "clear",
1292
- storeOp: "store",
1293
- }
1294
-
1295
- this.renderPassDescriptor = {
1296
- label: "renderPass",
1297
- colorAttachments: [colorAttachment],
1298
- depthStencilAttachment: {
1299
- view: depthTextureView,
1300
- depthClearValue: 1.0,
1301
- depthLoadOp: "clear",
1302
- depthStoreOp: "store",
1303
- stencilClearValue: 0, // New: clear stencil to 0
1304
- stencilLoadOp: "clear", // New: clear stencil each frame
1305
- stencilStoreOp: "store", // New: store stencil
1306
- },
1307
- }
1308
-
1309
- this.camera.aspect = width / height
1310
- }
1311
- }
1312
-
1313
- // Step 4: Create camera and uniform buffer
1314
- private setupCamera() {
1315
- this.cameraUniformBuffer = this.device.createBuffer({
1316
- label: "camera uniforms",
1317
- size: 40 * 4,
1318
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1319
- })
1320
-
1321
- this.camera = new Camera(Math.PI, Math.PI / 2.5, 26.6, new Vec3(0, 12.5, 0))
1322
-
1323
- this.camera.aspect = this.canvas.width / this.canvas.height
1324
- this.camera.attachControl(this.canvas)
1325
- }
1326
-
1327
- // Step 5: Create lighting buffers
1328
- private setupLighting() {
1329
- this.lightUniformBuffer = this.device.createBuffer({
1330
- label: "light uniforms",
1331
- size: 64 * 4,
1332
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1333
- })
1334
-
1335
- this.lightCount = 0
1336
-
1337
- this.setAmbient(0.96)
1338
- this.addLight(new Vec3(-0.5, -0.8, 0.5).normalize(), new Vec3(1.0, 0.95, 0.9), 0.12)
1339
- this.addLight(new Vec3(0.7, -0.5, 0.3).normalize(), new Vec3(0.8, 0.85, 1.0), 0.1)
1340
- this.addLight(new Vec3(0.3, -0.5, -1.0).normalize(), new Vec3(0.9, 0.9, 1.0), 0.08)
1341
- this.device.queue.writeBuffer(this.lightUniformBuffer, 0, this.lightData)
1342
- }
1343
-
1344
- public addLight(direction: Vec3, color: Vec3, intensity: number = 1.0): boolean {
1345
- if (this.lightCount >= 4) return false
1346
-
1347
- const normalized = direction.normalize()
1348
- const baseIndex = 4 + this.lightCount * 8
1349
- this.lightData[baseIndex] = normalized.x
1350
- this.lightData[baseIndex + 1] = normalized.y
1351
- this.lightData[baseIndex + 2] = normalized.z
1352
- this.lightData[baseIndex + 3] = 0
1353
- this.lightData[baseIndex + 4] = color.x
1354
- this.lightData[baseIndex + 5] = color.y
1355
- this.lightData[baseIndex + 6] = color.z
1356
- this.lightData[baseIndex + 7] = intensity
1357
-
1358
- this.lightCount++
1359
- this.lightData[1] = this.lightCount
1360
- return true
1361
- }
1362
-
1363
- public setAmbient(intensity: number) {
1364
- this.lightData[0] = intensity
1365
- }
1366
-
1367
- public getStats(): EngineStats {
1368
- return { ...this.stats }
1369
- }
1370
-
1371
- public runRenderLoop(callback?: () => void) {
1372
- this.renderLoopCallback = callback || null
1373
-
1374
- const loop = () => {
1375
- this.render()
1376
-
1377
- if (this.renderLoopCallback) {
1378
- this.renderLoopCallback()
1379
- }
1380
-
1381
- this.animationFrameId = requestAnimationFrame(loop)
1382
- }
1383
-
1384
- this.animationFrameId = requestAnimationFrame(loop)
1385
- }
1386
-
1387
- public stopRenderLoop() {
1388
- if (this.animationFrameId !== null) {
1389
- cancelAnimationFrame(this.animationFrameId)
1390
- this.animationFrameId = null
1391
- }
1392
- this.renderLoopCallback = null
1393
- }
1394
-
1395
- public dispose() {
1396
- this.stopRenderLoop()
1397
- if (this.camera) this.camera.detachControl()
1398
- if (this.resizeObserver) {
1399
- this.resizeObserver.disconnect()
1400
- this.resizeObserver = null
1401
- }
1402
- }
1403
-
1404
- // Step 6: Load PMX model file
1405
- public async loadModel(path: string) {
1406
- const pathParts = path.split("/")
1407
- pathParts.pop()
1408
- const dir = pathParts.join("/") + "/"
1409
- this.modelDir = dir
1410
-
1411
- const model = await PmxLoader.load(path)
1412
- this.physics = new Physics(model.getRigidbodies(), model.getJoints())
1413
- await this.setupModelBuffers(model)
1414
- }
1415
-
1416
- public rotateBones(bones: string[], rotations: Quat[], durationMs?: number) {
1417
- this.currentModel?.rotateBones(bones, rotations, durationMs)
1418
- }
1419
-
1420
- // Step 7: Create vertex, index, and joint buffers
1421
- private async setupModelBuffers(model: Model) {
1422
- this.currentModel = model
1423
- const vertices = model.getVertices()
1424
- const skinning = model.getSkinning()
1425
- const skeleton = model.getSkeleton()
1426
-
1427
- this.vertexBuffer = this.device.createBuffer({
1428
- label: "model vertex buffer",
1429
- size: vertices.byteLength,
1430
- usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
1431
- })
1432
- this.device.queue.writeBuffer(this.vertexBuffer, 0, vertices)
1433
-
1434
- this.jointsBuffer = this.device.createBuffer({
1435
- label: "joints buffer",
1436
- size: skinning.joints.byteLength,
1437
- usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
1438
- })
1439
- this.device.queue.writeBuffer(
1440
- this.jointsBuffer,
1441
- 0,
1442
- skinning.joints.buffer,
1443
- skinning.joints.byteOffset,
1444
- skinning.joints.byteLength
1445
- )
1446
-
1447
- this.weightsBuffer = this.device.createBuffer({
1448
- label: "weights buffer",
1449
- size: skinning.weights.byteLength,
1450
- usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
1451
- })
1452
- this.device.queue.writeBuffer(
1453
- this.weightsBuffer,
1454
- 0,
1455
- skinning.weights.buffer,
1456
- skinning.weights.byteOffset,
1457
- skinning.weights.byteLength
1458
- )
1459
-
1460
- const boneCount = skeleton.bones.length
1461
- const matrixSize = boneCount * 16 * 4
1462
-
1463
- this.skinMatrixBuffer = this.device.createBuffer({
1464
- label: "skin matrices",
1465
- size: Math.max(256, matrixSize),
1466
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.VERTEX,
1467
- })
1468
-
1469
- this.worldMatrixBuffer = this.device.createBuffer({
1470
- label: "world matrices",
1471
- size: Math.max(256, matrixSize),
1472
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
1473
- })
1474
-
1475
- this.inverseBindMatrixBuffer = this.device.createBuffer({
1476
- label: "inverse bind matrices",
1477
- size: Math.max(256, matrixSize),
1478
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
1479
- })
1480
-
1481
- const invBindMatrices = skeleton.inverseBindMatrices
1482
- this.device.queue.writeBuffer(
1483
- this.inverseBindMatrixBuffer,
1484
- 0,
1485
- invBindMatrices.buffer,
1486
- invBindMatrices.byteOffset,
1487
- invBindMatrices.byteLength
1488
- )
1489
-
1490
- this.boneCountBuffer = this.device.createBuffer({
1491
- label: "bone count uniform",
1492
- size: 32, // Minimum uniform buffer size is 32 bytes
1493
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1494
- })
1495
- const boneCountData = new Uint32Array(8) // 32 bytes total
1496
- boneCountData[0] = boneCount
1497
- this.device.queue.writeBuffer(this.boneCountBuffer, 0, boneCountData)
1498
-
1499
- this.createSkinMatrixComputePipeline()
1500
-
1501
- // Create compute bind group once (reused every frame)
1502
- this.skinMatrixComputeBindGroup = this.device.createBindGroup({
1503
- layout: this.skinMatrixComputePipeline!.getBindGroupLayout(0),
1504
- entries: [
1505
- { binding: 0, resource: { buffer: this.boneCountBuffer } },
1506
- { binding: 1, resource: { buffer: this.worldMatrixBuffer } },
1507
- { binding: 2, resource: { buffer: this.inverseBindMatrixBuffer } },
1508
- { binding: 3, resource: { buffer: this.skinMatrixBuffer } },
1509
- ],
1510
- })
1511
-
1512
- const indices = model.getIndices()
1513
- if (indices) {
1514
- this.indexBuffer = this.device.createBuffer({
1515
- label: "model index buffer",
1516
- size: indices.byteLength,
1517
- usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
1518
- })
1519
- this.device.queue.writeBuffer(this.indexBuffer, 0, indices)
1520
- } else {
1521
- throw new Error("Model has no index buffer")
1522
- }
1523
-
1524
- await this.setupMaterials(model)
1525
- }
1526
-
1527
- private opaqueNonEyeNonHairDraws: {
1528
- count: number
1529
- firstIndex: number
1530
- bindGroup: GPUBindGroup
1531
- isTransparent: boolean
1532
- }[] = []
1533
- private eyeDraws: { count: number; firstIndex: number; bindGroup: GPUBindGroup; isTransparent: boolean }[] = []
1534
- private hairDrawsOverEyes: { count: number; firstIndex: number; bindGroup: GPUBindGroup; isTransparent: boolean }[] =
1535
- []
1536
- private hairDrawsOverNonEyes: {
1537
- count: number
1538
- firstIndex: number
1539
- bindGroup: GPUBindGroup
1540
- isTransparent: boolean
1541
- }[] = []
1542
- private transparentNonEyeNonHairDraws: {
1543
- count: number
1544
- firstIndex: number
1545
- bindGroup: GPUBindGroup
1546
- isTransparent: boolean
1547
- }[] = []
1548
- private opaqueNonEyeNonHairOutlineDraws: {
1549
- count: number
1550
- firstIndex: number
1551
- bindGroup: GPUBindGroup
1552
- isTransparent: boolean
1553
- }[] = []
1554
- private eyeOutlineDraws: { count: number; firstIndex: number; bindGroup: GPUBindGroup; isTransparent: boolean }[] = []
1555
- private hairOutlineDraws: { count: number; firstIndex: number; bindGroup: GPUBindGroup; isTransparent: boolean }[] =
1556
- []
1557
- private transparentNonEyeNonHairOutlineDraws: {
1558
- count: number
1559
- firstIndex: number
1560
- bindGroup: GPUBindGroup
1561
- isTransparent: boolean
1562
- }[] = []
1563
-
1564
- // Step 8: Load textures and create material bind groups
1565
- private async setupMaterials(model: Model) {
1566
- const materials = model.getMaterials()
1567
- if (materials.length === 0) {
1568
- throw new Error("Model has no materials")
1569
- }
1570
-
1571
- const textures = model.getTextures()
1572
-
1573
- const loadTextureByIndex = async (texIndex: number): Promise<GPUTexture | null> => {
1574
- if (texIndex < 0 || texIndex >= textures.length) {
1575
- return null
1576
- }
1577
-
1578
- const path = this.modelDir + textures[texIndex].path
1579
- const texture = await this.createTextureFromPath(path)
1580
- return texture
1581
- }
1582
-
1583
- const loadToonTexture = async (toonTextureIndex: number): Promise<GPUTexture> => {
1584
- const texture = await loadTextureByIndex(toonTextureIndex)
1585
- if (texture) return texture
1586
-
1587
- // Default toon texture fallback - cache it
1588
- const defaultToonPath = "__default_toon__"
1589
- const cached = this.textureCache.get(defaultToonPath)
1590
- if (cached) return cached
1591
-
1592
- const defaultToonData = new Uint8Array(256 * 2 * 4)
1593
- for (let i = 0; i < 256; i++) {
1594
- const factor = i / 255.0
1595
- const gray = Math.floor(128 + factor * 127)
1596
- defaultToonData[i * 4] = gray
1597
- defaultToonData[i * 4 + 1] = gray
1598
- defaultToonData[i * 4 + 2] = gray
1599
- defaultToonData[i * 4 + 3] = 255
1600
- defaultToonData[(256 + i) * 4] = gray
1601
- defaultToonData[(256 + i) * 4 + 1] = gray
1602
- defaultToonData[(256 + i) * 4 + 2] = gray
1603
- defaultToonData[(256 + i) * 4 + 3] = 255
1604
- }
1605
- const defaultToonTexture = this.device.createTexture({
1606
- label: "default toon texture",
1607
- size: [256, 2],
1608
- format: "rgba8unorm",
1609
- usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
1610
- })
1611
- this.device.queue.writeTexture(
1612
- { texture: defaultToonTexture },
1613
- defaultToonData,
1614
- { bytesPerRow: 256 * 4 },
1615
- [256, 2]
1616
- )
1617
- this.textureCache.set(defaultToonPath, defaultToonTexture)
1618
- this.textureSizes.set(defaultToonPath, { width: 256, height: 2 })
1619
- return defaultToonTexture
1620
- }
1621
-
1622
- this.opaqueNonEyeNonHairDraws = []
1623
- this.eyeDraws = []
1624
- this.hairDrawsOverEyes = []
1625
- this.hairDrawsOverNonEyes = []
1626
- this.transparentNonEyeNonHairDraws = []
1627
- this.opaqueNonEyeNonHairOutlineDraws = []
1628
- this.eyeOutlineDraws = []
1629
- this.hairOutlineDraws = []
1630
- this.transparentNonEyeNonHairOutlineDraws = []
1631
- let runningFirstIndex = 0
1632
-
1633
- for (const mat of materials) {
1634
- const matCount = mat.vertexCount | 0
1635
- if (matCount === 0) continue
1636
-
1637
- const diffuseTexture = await loadTextureByIndex(mat.diffuseTextureIndex)
1638
- if (!diffuseTexture) throw new Error(`Material "${mat.name}" has no diffuse texture`)
1639
-
1640
- const toonTexture = await loadToonTexture(mat.toonTextureIndex)
1641
-
1642
- const materialAlpha = mat.diffuse[3]
1643
- const EPSILON = 0.001
1644
- const isTransparent = materialAlpha < 1.0 - EPSILON
1645
-
1646
- // Create material uniform data
1647
- const materialUniformData = new Float32Array(8)
1648
- materialUniformData[0] = materialAlpha
1649
- materialUniformData[1] = 1.0 // alphaMultiplier: 1.0 for non-hair materials
1650
- materialUniformData[2] = this.rimLightIntensity
1651
- materialUniformData[3] = this.rimLightPower
1652
- materialUniformData[4] = this.rimLightColor[0] // rimColor.r
1653
- materialUniformData[5] = this.rimLightColor[1] // rimColor.g
1654
- materialUniformData[6] = this.rimLightColor[2] // rimColor.b
1655
- materialUniformData[7] = 0.0 // _padding1
1656
-
1657
- const materialUniformBuffer = this.device.createBuffer({
1658
- label: `material uniform: ${mat.name}`,
1659
- size: materialUniformData.byteLength,
1660
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1661
- })
1662
- this.device.queue.writeBuffer(materialUniformBuffer, 0, materialUniformData)
1663
-
1664
- // Create bind groups using the shared bind group layout - All pipelines (main, eye, hair multiply, hair opaque) use the same shader and layout
1665
- const bindGroup = this.device.createBindGroup({
1666
- label: `material bind group: ${mat.name}`,
1667
- layout: this.hairBindGroupLayout,
1668
- entries: [
1669
- { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1670
- { binding: 1, resource: { buffer: this.lightUniformBuffer } },
1671
- { binding: 2, resource: diffuseTexture.createView() },
1672
- { binding: 3, resource: this.textureSampler },
1673
- { binding: 4, resource: { buffer: this.skinMatrixBuffer! } },
1674
- { binding: 5, resource: toonTexture.createView() },
1675
- { binding: 6, resource: this.textureSampler },
1676
- { binding: 7, resource: { buffer: materialUniformBuffer } },
1677
- ],
1678
- })
1679
-
1680
- // Classify materials into appropriate draw lists
1681
- if (mat.isEye) {
1682
- this.eyeDraws.push({
1683
- count: matCount,
1684
- firstIndex: runningFirstIndex,
1685
- bindGroup,
1686
- isTransparent,
1687
- })
1688
- } else if (mat.isHair) {
1689
- // For hair materials, create two bind groups: one for over-eyes (alphaMultiplier = 0.5) and one for over-non-eyes (alphaMultiplier = 1.0)
1690
- const materialUniformDataOverEyes = new Float32Array(8)
1691
- materialUniformDataOverEyes[0] = materialAlpha
1692
- materialUniformDataOverEyes[1] = 0.5 // alphaMultiplier: 0.5 for over-eyes
1693
- materialUniformDataOverEyes[2] = this.rimLightIntensity
1694
- materialUniformDataOverEyes[3] = this.rimLightPower
1695
- materialUniformDataOverEyes[4] = this.rimLightColor[0] // rimColor.r
1696
- materialUniformDataOverEyes[5] = this.rimLightColor[1] // rimColor.g
1697
- materialUniformDataOverEyes[6] = this.rimLightColor[2] // rimColor.b
1698
- materialUniformDataOverEyes[7] = 0.0 // _padding1
1699
-
1700
- const materialUniformBufferOverEyes = this.device.createBuffer({
1701
- label: `material uniform (over eyes): ${mat.name}`,
1702
- size: materialUniformDataOverEyes.byteLength,
1703
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1704
- })
1705
- this.device.queue.writeBuffer(materialUniformBufferOverEyes, 0, materialUniformDataOverEyes)
1706
-
1707
- const bindGroupOverEyes = this.device.createBindGroup({
1708
- label: `material bind group (over eyes): ${mat.name}`,
1709
- layout: this.hairBindGroupLayout,
1710
- entries: [
1711
- { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1712
- { binding: 1, resource: { buffer: this.lightUniformBuffer } },
1713
- { binding: 2, resource: diffuseTexture.createView() },
1714
- { binding: 3, resource: this.textureSampler },
1715
- { binding: 4, resource: { buffer: this.skinMatrixBuffer! } },
1716
- { binding: 5, resource: toonTexture.createView() },
1717
- { binding: 6, resource: this.textureSampler },
1718
- { binding: 7, resource: { buffer: materialUniformBufferOverEyes } },
1719
- ],
1720
- })
1721
-
1722
- this.hairDrawsOverEyes.push({
1723
- count: matCount,
1724
- firstIndex: runningFirstIndex,
1725
- bindGroup: bindGroupOverEyes,
1726
- isTransparent,
1727
- })
1728
-
1729
- // Create material uniform for hair over non-eyes (alphaMultiplier = 1.0)
1730
- const materialUniformDataOverNonEyes = new Float32Array(8)
1731
- materialUniformDataOverNonEyes[0] = materialAlpha
1732
- materialUniformDataOverNonEyes[1] = 1.0 // alphaMultiplier: 1.0 for over-non-eyes
1733
- materialUniformDataOverNonEyes[2] = this.rimLightIntensity
1734
- materialUniformDataOverNonEyes[3] = this.rimLightPower
1735
- materialUniformDataOverNonEyes[4] = this.rimLightColor[0] // rimColor.r
1736
- materialUniformDataOverNonEyes[5] = this.rimLightColor[1] // rimColor.g
1737
- materialUniformDataOverNonEyes[6] = this.rimLightColor[2] // rimColor.b
1738
- materialUniformDataOverNonEyes[7] = 0.0 // _padding1
1739
-
1740
- const materialUniformBufferOverNonEyes = this.device.createBuffer({
1741
- label: `material uniform (over non-eyes): ${mat.name}`,
1742
- size: materialUniformDataOverNonEyes.byteLength,
1743
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1744
- })
1745
- this.device.queue.writeBuffer(materialUniformBufferOverNonEyes, 0, materialUniformDataOverNonEyes)
1746
-
1747
- const bindGroupOverNonEyes = this.device.createBindGroup({
1748
- label: `material bind group (over non-eyes): ${mat.name}`,
1749
- layout: this.hairBindGroupLayout,
1750
- entries: [
1751
- { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1752
- { binding: 1, resource: { buffer: this.lightUniformBuffer } },
1753
- { binding: 2, resource: diffuseTexture.createView() },
1754
- { binding: 3, resource: this.textureSampler },
1755
- { binding: 4, resource: { buffer: this.skinMatrixBuffer! } },
1756
- { binding: 5, resource: toonTexture.createView() },
1757
- { binding: 6, resource: this.textureSampler },
1758
- { binding: 7, resource: { buffer: materialUniformBufferOverNonEyes } },
1759
- ],
1760
- })
1761
-
1762
- this.hairDrawsOverNonEyes.push({
1763
- count: matCount,
1764
- firstIndex: runningFirstIndex,
1765
- bindGroup: bindGroupOverNonEyes,
1766
- isTransparent,
1767
- })
1768
- } else if (isTransparent) {
1769
- this.transparentNonEyeNonHairDraws.push({
1770
- count: matCount,
1771
- firstIndex: runningFirstIndex,
1772
- bindGroup,
1773
- isTransparent,
1774
- })
1775
- } else {
1776
- this.opaqueNonEyeNonHairDraws.push({
1777
- count: matCount,
1778
- firstIndex: runningFirstIndex,
1779
- bindGroup,
1780
- isTransparent,
1781
- })
1782
- }
1783
-
1784
- // Outline for all materials (including transparent) - Edge flag is at bit 4 (0x10) in PMX format, not bit 0 (0x01)
1785
- if ((mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
1786
- const materialUniformData = new Float32Array(8)
1787
- materialUniformData[0] = mat.edgeColor[0]
1788
- materialUniformData[1] = mat.edgeColor[1]
1789
- materialUniformData[2] = mat.edgeColor[2]
1790
- materialUniformData[3] = mat.edgeColor[3]
1791
- materialUniformData[4] = mat.edgeSize
1792
-
1793
- const materialUniformBuffer = this.device.createBuffer({
1794
- label: `outline material uniform: ${mat.name}`,
1795
- size: materialUniformData.byteLength,
1796
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1797
- })
1798
- this.device.queue.writeBuffer(materialUniformBuffer, 0, materialUniformData)
1799
-
1800
- const outlineBindGroup = this.device.createBindGroup({
1801
- label: `outline bind group: ${mat.name}`,
1802
- layout: this.outlineBindGroupLayout,
1803
- entries: [
1804
- { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1805
- { binding: 1, resource: { buffer: materialUniformBuffer } },
1806
- { binding: 2, resource: { buffer: this.skinMatrixBuffer! } },
1807
- ],
1808
- })
1809
-
1810
- // Classify outlines into appropriate draw lists
1811
- if (mat.isEye) {
1812
- this.eyeOutlineDraws.push({
1813
- count: matCount,
1814
- firstIndex: runningFirstIndex,
1815
- bindGroup: outlineBindGroup,
1816
- isTransparent,
1817
- })
1818
- } else if (mat.isHair) {
1819
- this.hairOutlineDraws.push({
1820
- count: matCount,
1821
- firstIndex: runningFirstIndex,
1822
- bindGroup: outlineBindGroup,
1823
- isTransparent,
1824
- })
1825
- } else if (isTransparent) {
1826
- this.transparentNonEyeNonHairOutlineDraws.push({
1827
- count: matCount,
1828
- firstIndex: runningFirstIndex,
1829
- bindGroup: outlineBindGroup,
1830
- isTransparent,
1831
- })
1832
- } else {
1833
- this.opaqueNonEyeNonHairOutlineDraws.push({
1834
- count: matCount,
1835
- firstIndex: runningFirstIndex,
1836
- bindGroup: outlineBindGroup,
1837
- isTransparent,
1838
- })
1839
- }
1840
- }
1841
-
1842
- runningFirstIndex += matCount
1843
- }
1844
- }
1845
-
1846
- // Helper: Load texture from file path with optional max size limit
1847
- private async createTextureFromPath(path: string, maxSize: number = 2048): Promise<GPUTexture | null> {
1848
- const cached = this.textureCache.get(path)
1849
- if (cached) {
1850
- return cached
1851
- }
1852
-
1853
- try {
1854
- const response = await fetch(path)
1855
- if (!response.ok) {
1856
- throw new Error(`HTTP ${response.status}: ${response.statusText}`)
1857
- }
1858
- let imageBitmap = await createImageBitmap(await response.blob(), {
1859
- premultiplyAlpha: "none",
1860
- colorSpaceConversion: "none",
1861
- })
1862
-
1863
- // Downscale if texture is too large
1864
- let finalWidth = imageBitmap.width
1865
- let finalHeight = imageBitmap.height
1866
- if (finalWidth > maxSize || finalHeight > maxSize) {
1867
- const scale = Math.min(maxSize / finalWidth, maxSize / finalHeight)
1868
- finalWidth = Math.floor(finalWidth * scale)
1869
- finalHeight = Math.floor(finalHeight * scale)
1870
-
1871
- // Create canvas to downscale
1872
- const canvas = new OffscreenCanvas(finalWidth, finalHeight)
1873
- const ctx = canvas.getContext("2d")
1874
- if (ctx) {
1875
- ctx.drawImage(imageBitmap, 0, 0, finalWidth, finalHeight)
1876
- imageBitmap = await createImageBitmap(canvas)
1877
- }
1878
- }
1879
-
1880
- const texture = this.device.createTexture({
1881
- label: `texture: ${path}`,
1882
- size: [finalWidth, finalHeight],
1883
- format: "rgba8unorm",
1884
- usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
1885
- })
1886
- this.device.queue.copyExternalImageToTexture({ source: imageBitmap }, { texture }, [finalWidth, finalHeight])
1887
-
1888
- this.textureCache.set(path, texture)
1889
- this.textureSizes.set(path, { width: finalWidth, height: finalHeight })
1890
- return texture
1891
- } catch {
1892
- return null
1893
- }
1894
- }
1895
-
1896
- // Step 9: Render one frame
1897
- public render() {
1898
- if (this.multisampleTexture && this.camera && this.device && this.currentModel) {
1899
- const currentTime = performance.now()
1900
- const deltaTime = this.lastFrameTime > 0 ? (currentTime - this.lastFrameTime) / 1000 : 0.016
1901
- this.lastFrameTime = currentTime
1902
-
1903
- this.updateCameraUniforms()
1904
- this.updateRenderTarget()
1905
-
1906
- this.updateModelPose(deltaTime)
1907
-
1908
- const encoder = this.device.createCommandEncoder()
1909
- const pass = encoder.beginRenderPass(this.renderPassDescriptor)
1910
-
1911
- pass.setVertexBuffer(0, this.vertexBuffer)
1912
- pass.setVertexBuffer(1, this.jointsBuffer)
1913
- pass.setVertexBuffer(2, this.weightsBuffer)
1914
- pass.setIndexBuffer(this.indexBuffer!, "uint32")
1915
-
1916
- this.drawCallCount = 0
1917
-
1918
- // PASS 1: Opaque non-eye, non-hair (face, body, etc)
1919
- // this.drawOutlines(pass, false) // Opaque outlines
1920
-
1921
- pass.setPipeline(this.pipeline)
1922
- for (const draw of this.opaqueNonEyeNonHairDraws) {
1923
- if (draw.count > 0) {
1924
- pass.setBindGroup(0, draw.bindGroup)
1925
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1926
- this.drawCallCount++
1927
- }
1928
- }
1929
-
1930
- // PASS 2: Eyes (writes stencil = 1)
1931
- pass.setPipeline(this.eyePipeline)
1932
- pass.setStencilReference(1) // Set stencil reference value to 1
1933
- for (const draw of this.eyeDraws) {
1934
- if (draw.count > 0) {
1935
- pass.setBindGroup(0, draw.bindGroup)
1936
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1937
- this.drawCallCount++
1938
- }
1939
- }
1940
-
1941
- // PASS 3: Hair rendering - optimized single pass approach
1942
- // Since both hair passes use the same shader, we batch them together
1943
- // but still need separate passes due to stencil requirements (equal vs not-equal)
1944
-
1945
- this.drawOutlines(pass, false) // Opaque outlines
1946
-
1947
- // 3a: Hair over eyes (stencil == 1, alphaMultiplier = 0.5)
1948
- if (this.hairDrawsOverEyes.length > 0) {
1949
- pass.setPipeline(this.hairMultiplyPipeline)
1950
- pass.setStencilReference(1)
1951
- for (const draw of this.hairDrawsOverEyes) {
1952
- if (draw.count > 0) {
1953
- pass.setBindGroup(0, draw.bindGroup)
1954
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1955
- this.drawCallCount++
1956
- }
1957
- }
1958
- }
1959
-
1960
- // 3b: Hair over non-eyes (stencil != 1, alphaMultiplier = 1.0)
1961
- if (this.hairDrawsOverNonEyes.length > 0) {
1962
- pass.setPipeline(this.hairOpaquePipeline)
1963
- pass.setStencilReference(1)
1964
- for (const draw of this.hairDrawsOverNonEyes) {
1965
- if (draw.count > 0) {
1966
- pass.setBindGroup(0, draw.bindGroup)
1967
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1968
- this.drawCallCount++
1969
- }
1970
- }
1971
- }
1972
-
1973
- // 3c: Hair outlines - batched together, only draw if outlines exist
1974
- if (this.hairOutlineDraws.length > 0) {
1975
- // Over eyes
1976
- pass.setPipeline(this.hairOutlineOverEyesPipeline)
1977
- pass.setStencilReference(1)
1978
- for (const draw of this.hairOutlineDraws) {
1979
- if (draw.count > 0) {
1980
- pass.setBindGroup(0, draw.bindGroup)
1981
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1982
- }
1983
- }
1984
-
1985
- // Over non-eyes
1986
- pass.setPipeline(this.hairOutlinePipeline)
1987
- pass.setStencilReference(1)
1988
- for (const draw of this.hairOutlineDraws) {
1989
- if (draw.count > 0) {
1990
- pass.setBindGroup(0, draw.bindGroup)
1991
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1992
- }
1993
- }
1994
- }
1995
-
1996
- // PASS 4: Transparent non-eye, non-hair
1997
- pass.setPipeline(this.pipeline)
1998
- for (const draw of this.transparentNonEyeNonHairDraws) {
1999
- if (draw.count > 0) {
2000
- pass.setBindGroup(0, draw.bindGroup)
2001
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
2002
- this.drawCallCount++
2003
- }
2004
- }
2005
-
2006
- this.drawOutlines(pass, true) // Transparent outlines
2007
-
2008
- pass.end()
2009
- this.device.queue.submit([encoder.finish()])
2010
-
2011
- // Apply bloom post-processing
2012
- this.applyBloom()
2013
-
2014
- this.updateStats(performance.now() - currentTime)
2015
- }
2016
- }
2017
-
2018
- // Apply bloom post-processing
2019
- private applyBloom() {
2020
- if (!this.sceneRenderTexture || !this.bloomExtractTexture) {
2021
- return
2022
- }
2023
-
2024
- // Update bloom parameters
2025
- const thresholdData = new Float32Array(8)
2026
- thresholdData[0] = this.bloomThreshold
2027
- this.device.queue.writeBuffer(this.bloomThresholdBuffer, 0, thresholdData)
2028
-
2029
- const intensityData = new Float32Array(8)
2030
- intensityData[0] = this.bloomIntensity
2031
- this.device.queue.writeBuffer(this.bloomIntensityBuffer, 0, intensityData)
2032
-
2033
- const encoder = this.device.createCommandEncoder()
2034
- const width = this.canvas.width
2035
- const height = this.canvas.height
2036
- const bloomWidth = Math.floor(width / 2)
2037
- const bloomHeight = Math.floor(height / 2)
2038
-
2039
- // Pass 1: Extract bright areas (downsample to half resolution)
2040
- const extractPass = encoder.beginRenderPass({
2041
- label: "bloom extract",
2042
- colorAttachments: [
2043
- {
2044
- view: this.bloomExtractTexture.createView(),
2045
- clearValue: { r: 0, g: 0, b: 0, a: 0 },
2046
- loadOp: "clear",
2047
- storeOp: "store",
2048
- },
2049
- ],
2050
- })
2051
-
2052
- extractPass.setPipeline(this.bloomExtractPipeline)
2053
- extractPass.setBindGroup(0, this.bloomExtractBindGroup!)
2054
- extractPass.draw(6, 1, 0, 0)
2055
- extractPass.end()
2056
-
2057
- // Pass 2: Horizontal blur
2058
- const hBlurData = new Float32Array(4) // vec2f + padding = 4 floats
2059
- hBlurData[0] = 1.0
2060
- hBlurData[1] = 0.0
2061
- this.device.queue.writeBuffer(this.blurDirectionBuffer, 0, hBlurData)
2062
- const blurHPass = encoder.beginRenderPass({
2063
- label: "bloom blur horizontal",
2064
- colorAttachments: [
2065
- {
2066
- view: this.bloomBlurTexture1.createView(),
2067
- clearValue: { r: 0, g: 0, b: 0, a: 0 },
2068
- loadOp: "clear",
2069
- storeOp: "store",
2070
- },
2071
- ],
2072
- })
2073
-
2074
- blurHPass.setPipeline(this.bloomBlurPipeline)
2075
- blurHPass.setBindGroup(0, this.bloomBlurHBindGroup!)
2076
- blurHPass.draw(6, 1, 0, 0)
2077
- blurHPass.end()
2078
-
2079
- // Pass 3: Vertical blur
2080
- const vBlurData = new Float32Array(4) // vec2f + padding = 4 floats
2081
- vBlurData[0] = 0.0
2082
- vBlurData[1] = 1.0
2083
- this.device.queue.writeBuffer(this.blurDirectionBuffer, 0, vBlurData)
2084
- const blurVPass = encoder.beginRenderPass({
2085
- label: "bloom blur vertical",
2086
- colorAttachments: [
2087
- {
2088
- view: this.bloomBlurTexture2.createView(),
2089
- clearValue: { r: 0, g: 0, b: 0, a: 0 },
2090
- loadOp: "clear",
2091
- storeOp: "store",
2092
- },
2093
- ],
2094
- })
2095
-
2096
- blurVPass.setPipeline(this.bloomBlurPipeline)
2097
- blurVPass.setBindGroup(0, this.bloomBlurVBindGroup!)
2098
- blurVPass.draw(6, 1, 0, 0)
2099
- blurVPass.end()
2100
-
2101
- // Pass 4: Compose scene + bloom to canvas
2102
- const composePass = encoder.beginRenderPass({
2103
- label: "bloom compose",
2104
- colorAttachments: [
2105
- {
2106
- view: this.context.getCurrentTexture().createView(),
2107
- clearValue: { r: 0, g: 0, b: 0, a: 0 },
2108
- loadOp: "clear",
2109
- storeOp: "store",
2110
- },
2111
- ],
2112
- })
2113
-
2114
- composePass.setPipeline(this.bloomComposePipeline)
2115
- composePass.setBindGroup(0, this.bloomComposeBindGroup!)
2116
- composePass.draw(6, 1, 0, 0)
2117
- composePass.end()
2118
-
2119
- this.device.queue.submit([encoder.finish()])
2120
- }
2121
-
2122
- // Update camera uniform buffer each frame
2123
- private updateCameraUniforms() {
2124
- const viewMatrix = this.camera.getViewMatrix()
2125
- const projectionMatrix = this.camera.getProjectionMatrix()
2126
- const cameraPos = this.camera.getPosition()
2127
- this.cameraMatrixData.set(viewMatrix.values, 0)
2128
- this.cameraMatrixData.set(projectionMatrix.values, 16)
2129
- this.cameraMatrixData[32] = cameraPos.x
2130
- this.cameraMatrixData[33] = cameraPos.y
2131
- this.cameraMatrixData[34] = cameraPos.z
2132
- this.device.queue.writeBuffer(this.cameraUniformBuffer, 0, this.cameraMatrixData)
2133
- }
2134
-
2135
- // Update render target texture view
2136
- private updateRenderTarget() {
2137
- const colorAttachment = (this.renderPassDescriptor.colorAttachments as GPURenderPassColorAttachment[])[0]
2138
- if (this.sampleCount > 1) {
2139
- // Resolve to scene render texture for post-processing
2140
- colorAttachment.resolveTarget = this.sceneRenderTextureView
2141
- } else {
2142
- // Render directly to scene render texture
2143
- colorAttachment.view = this.sceneRenderTextureView
2144
- }
2145
- }
2146
-
2147
- // Update model pose and physics
2148
- private updateModelPose(deltaTime: number) {
2149
- // Step 1: Animation evaluation (computes matrices to CPU memory, no upload yet)
2150
- this.currentModel!.evaluatePose()
2151
-
2152
- // Step 2: Get world matrices (still in CPU memory)
2153
- const worldMats = this.currentModel!.getBoneWorldMatrices()
2154
-
2155
- // Step 3: Physics modifies matrices in-place
2156
- if (this.physics) {
2157
- this.physics.step(deltaTime, worldMats, this.currentModel!.getBoneInverseBindMatrices())
2158
- }
2159
-
2160
- // Step 4: Upload ONCE with final result (animation + physics)
2161
- this.device.queue.writeBuffer(
2162
- this.worldMatrixBuffer!,
2163
- 0,
2164
- worldMats.buffer,
2165
- worldMats.byteOffset,
2166
- worldMats.byteLength
2167
- )
2168
-
2169
- // Step 5: GPU skinning
2170
- this.computeSkinMatrices()
2171
- }
2172
-
2173
- // Compute skin matrices on GPU
2174
- private computeSkinMatrices() {
2175
- const boneCount = this.currentModel!.getSkeleton().bones.length
2176
- const workgroupSize = 64
2177
- // Dispatch exactly enough threads for all bones (no bounds check needed)
2178
- const workgroupCount = Math.ceil(boneCount / workgroupSize)
2179
-
2180
- // Bone count is written once in setupModelBuffers() and never changes
2181
-
2182
- const encoder = this.device.createCommandEncoder()
2183
- const pass = encoder.beginComputePass()
2184
- pass.setPipeline(this.skinMatrixComputePipeline!)
2185
- pass.setBindGroup(0, this.skinMatrixComputeBindGroup!)
2186
- pass.dispatchWorkgroups(workgroupCount)
2187
- pass.end()
2188
- this.device.queue.submit([encoder.finish()])
2189
- }
2190
-
2191
- // Draw outlines (opaque or transparent)
2192
- private drawOutlines(pass: GPURenderPassEncoder, transparent: boolean) {
2193
- pass.setPipeline(this.outlinePipeline)
2194
- if (transparent) {
2195
- // Draw transparent outlines (if any)
2196
- for (const draw of this.transparentNonEyeNonHairOutlineDraws) {
2197
- if (draw.count > 0) {
2198
- pass.setBindGroup(0, draw.bindGroup)
2199
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
2200
- }
2201
- }
2202
- } else {
2203
- // Draw opaque outlines before main geometry
2204
- for (const draw of this.opaqueNonEyeNonHairOutlineDraws) {
2205
- if (draw.count > 0) {
2206
- pass.setBindGroup(0, draw.bindGroup)
2207
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
2208
- }
2209
- }
2210
- }
2211
- }
2212
-
2213
- private updateStats(frameTime: number) {
2214
- const maxSamples = 60
2215
- this.frameTimeSamples.push(frameTime)
2216
- this.frameTimeSum += frameTime
2217
- if (this.frameTimeSamples.length > maxSamples) {
2218
- const removed = this.frameTimeSamples.shift()!
2219
- this.frameTimeSum -= removed
2220
- }
2221
- const avgFrameTime = this.frameTimeSum / this.frameTimeSamples.length
2222
- this.stats.frameTime = Math.round(avgFrameTime * 100) / 100
2223
-
2224
- const now = performance.now()
2225
- this.framesSinceLastUpdate++
2226
- const elapsed = now - this.lastFpsUpdate
2227
-
2228
- if (elapsed >= 1000) {
2229
- this.stats.fps = Math.round((this.framesSinceLastUpdate / elapsed) * 1000)
2230
- this.framesSinceLastUpdate = 0
2231
- this.lastFpsUpdate = now
2232
- }
2233
-
2234
- // Calculate GPU memory: textures + buffers + render targets
2235
- let textureMemoryBytes = 0
2236
- for (const [path, size] of this.textureSizes.entries()) {
2237
- if (this.textureCache.has(path)) {
2238
- textureMemoryBytes += size.width * size.height * 4 // RGBA8 = 4 bytes per pixel
2239
- }
2240
- }
2241
-
2242
- let bufferMemoryBytes = 0
2243
- if (this.vertexBuffer) {
2244
- const vertices = this.currentModel?.getVertices()
2245
- if (vertices) bufferMemoryBytes += vertices.byteLength
2246
- }
2247
- if (this.indexBuffer) {
2248
- const indices = this.currentModel?.getIndices()
2249
- if (indices) bufferMemoryBytes += indices.byteLength
2250
- }
2251
- if (this.jointsBuffer) {
2252
- const skinning = this.currentModel?.getSkinning()
2253
- if (skinning) bufferMemoryBytes += skinning.joints.byteLength
2254
- }
2255
- if (this.weightsBuffer) {
2256
- const skinning = this.currentModel?.getSkinning()
2257
- if (skinning) bufferMemoryBytes += skinning.weights.byteLength
2258
- }
2259
- if (this.skinMatrixBuffer) {
2260
- const skeleton = this.currentModel?.getSkeleton()
2261
- if (skeleton) bufferMemoryBytes += Math.max(256, skeleton.bones.length * 16 * 4)
2262
- }
2263
- if (this.worldMatrixBuffer) {
2264
- const skeleton = this.currentModel?.getSkeleton()
2265
- if (skeleton) bufferMemoryBytes += Math.max(256, skeleton.bones.length * 16 * 4)
2266
- }
2267
- if (this.inverseBindMatrixBuffer) {
2268
- const skeleton = this.currentModel?.getSkeleton()
2269
- if (skeleton) bufferMemoryBytes += Math.max(256, skeleton.bones.length * 16 * 4)
2270
- }
2271
- bufferMemoryBytes += 40 * 4 // cameraUniformBuffer
2272
- bufferMemoryBytes += 64 * 4 // lightUniformBuffer
2273
- bufferMemoryBytes += 32 // boneCountBuffer
2274
- bufferMemoryBytes += 32 // blurDirectionBuffer
2275
- bufferMemoryBytes += 32 // bloomIntensityBuffer
2276
- bufferMemoryBytes += 32 // bloomThresholdBuffer
2277
- if (this.fullscreenQuadBuffer) {
2278
- bufferMemoryBytes += 24 * 4 // fullscreenQuadBuffer (6 vertices * 4 floats)
2279
- }
2280
-
2281
- // Material uniform buffers: Float32Array(8) = 32 bytes each
2282
- const totalMaterialDraws =
2283
- this.opaqueNonEyeNonHairDraws.length +
2284
- this.eyeDraws.length +
2285
- this.hairDrawsOverEyes.length +
2286
- this.hairDrawsOverNonEyes.length +
2287
- this.transparentNonEyeNonHairDraws.length
2288
- bufferMemoryBytes += totalMaterialDraws * 32 // Material uniform buffers (8 floats = 32 bytes)
2289
-
2290
- // Outline material uniform buffers: Float32Array(8) = 32 bytes each
2291
- const totalOutlineDraws =
2292
- this.opaqueNonEyeNonHairOutlineDraws.length +
2293
- this.eyeOutlineDraws.length +
2294
- this.hairOutlineDraws.length +
2295
- this.transparentNonEyeNonHairOutlineDraws.length
2296
- bufferMemoryBytes += totalOutlineDraws * 32 // Outline material uniform buffers
2297
-
2298
- let renderTargetMemoryBytes = 0
2299
- if (this.multisampleTexture) {
2300
- const width = this.canvas.width
2301
- const height = this.canvas.height
2302
- renderTargetMemoryBytes += width * height * 4 * this.sampleCount // multisample color
2303
- renderTargetMemoryBytes += width * height * 4 // depth (depth24plus-stencil8 = 4 bytes)
2304
- }
2305
- if (this.sceneRenderTexture) {
2306
- const width = this.canvas.width
2307
- const height = this.canvas.height
2308
- renderTargetMemoryBytes += width * height * 4 // sceneRenderTexture (non-multisampled)
2309
- }
2310
- if (this.bloomExtractTexture) {
2311
- const width = Math.floor(this.canvas.width / 2)
2312
- const height = Math.floor(this.canvas.height / 2)
2313
- renderTargetMemoryBytes += width * height * 4 // bloomExtractTexture
2314
- renderTargetMemoryBytes += width * height * 4 // bloomBlurTexture1
2315
- renderTargetMemoryBytes += width * height * 4 // bloomBlurTexture2
2316
- }
2317
-
2318
- const totalGPUMemoryBytes = textureMemoryBytes + bufferMemoryBytes + renderTargetMemoryBytes
2319
- this.stats.gpuMemory = Math.round((totalGPUMemoryBytes / 1024 / 1024) * 100) / 100
2320
- }
2321
- }
1
+ import { Camera } from "./camera"
2
+ import { Quat, Vec3 } from "./math"
3
+ import { Model } from "./model"
4
+ import { PmxLoader } from "./pmx-loader"
5
+ import { Physics } from "./physics"
6
+
7
+ export interface EngineStats {
8
+ fps: number
9
+ frameTime: number // ms
10
+ gpuMemory: number // MB (estimated total GPU memory)
11
+ }
12
+
13
+ export class Engine {
14
+ private canvas: HTMLCanvasElement
15
+ private device!: GPUDevice
16
+ private context!: GPUCanvasContext
17
+ private presentationFormat!: GPUTextureFormat
18
+ public camera!: Camera
19
+ private cameraUniformBuffer!: GPUBuffer
20
+ private cameraMatrixData = new Float32Array(36)
21
+ private lightUniformBuffer!: GPUBuffer
22
+ private lightData = new Float32Array(64)
23
+ private lightCount = 0
24
+ private vertexBuffer!: GPUBuffer
25
+ private indexBuffer?: GPUBuffer
26
+ private resizeObserver: ResizeObserver | null = null
27
+ private depthTexture!: GPUTexture
28
+ private pipeline!: GPURenderPipeline
29
+ private outlinePipeline!: GPURenderPipeline
30
+ private hairUnifiedOutlinePipeline!: GPURenderPipeline
31
+ private hairUnifiedPipelineOverEyes!: GPURenderPipeline
32
+ private hairUnifiedPipelineOverNonEyes!: GPURenderPipeline
33
+ private hairDepthPipeline!: GPURenderPipeline
34
+ private eyePipeline!: GPURenderPipeline
35
+ private hairBindGroupLayout!: GPUBindGroupLayout
36
+ private outlineBindGroupLayout!: GPUBindGroupLayout
37
+ private jointsBuffer!: GPUBuffer
38
+ private weightsBuffer!: GPUBuffer
39
+ private skinMatrixBuffer?: GPUBuffer
40
+ private worldMatrixBuffer?: GPUBuffer
41
+ private inverseBindMatrixBuffer?: GPUBuffer
42
+ private skinMatrixComputePipeline?: GPUComputePipeline
43
+ private skinMatrixComputeBindGroup?: GPUBindGroup
44
+ private boneCountBuffer?: GPUBuffer
45
+ private multisampleTexture!: GPUTexture
46
+ private readonly sampleCount = 4 // MSAA 4x
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 bind groups (created once, reused every frame)
65
+ private bloomExtractBindGroup?: GPUBindGroup
66
+ private bloomBlurHBindGroup?: GPUBindGroup
67
+ private bloomBlurVBindGroup?: GPUBindGroup
68
+ private bloomComposeBindGroup?: GPUBindGroup
69
+ // Bloom settings
70
+ public bloomThreshold: number = 0.3
71
+ public bloomIntensity: number = 0.1
72
+ // Rim light settings
73
+ private rimLightIntensity: number = 0.35
74
+ private rimLightPower: number = 2.0
75
+ private rimLightColor: [number, number, number] = [1.0, 1.0, 1.0]
76
+ private currentModel: Model | null = null
77
+ private modelDir: string = ""
78
+ private physics: Physics | null = null
79
+ private textureSampler!: GPUSampler
80
+ private textureCache = new Map<string, GPUTexture>()
81
+ private textureSizes = new Map<string, { width: number; height: number }>()
82
+
83
+ private lastFpsUpdate = performance.now()
84
+ private framesSinceLastUpdate = 0
85
+ private frameTimeSamples: number[] = []
86
+ private frameTimeSum: number = 0
87
+ private drawCallCount: number = 0
88
+ private lastFrameTime = performance.now()
89
+ private stats: EngineStats = {
90
+ fps: 0,
91
+ frameTime: 0,
92
+ gpuMemory: 0,
93
+ }
94
+ private animationFrameId: number | null = null
95
+ private renderLoopCallback: (() => void) | null = null
96
+
97
+ constructor(canvas: HTMLCanvasElement) {
98
+ this.canvas = canvas
99
+ }
100
+
101
+ // Step 1: Get WebGPU device and context
102
+ public async init() {
103
+ const adapter = await navigator.gpu?.requestAdapter()
104
+ const device = await adapter?.requestDevice()
105
+ if (!device) {
106
+ throw new Error("WebGPU is not supported in this browser.")
107
+ }
108
+ this.device = device
109
+
110
+ const context = this.canvas.getContext("webgpu")
111
+ if (!context) {
112
+ throw new Error("Failed to get WebGPU context.")
113
+ }
114
+ this.context = context
115
+
116
+ this.presentationFormat = navigator.gpu.getPreferredCanvasFormat()
117
+
118
+ this.context.configure({
119
+ device: this.device,
120
+ format: this.presentationFormat,
121
+ alphaMode: "premultiplied",
122
+ })
123
+
124
+ this.setupCamera()
125
+ this.setupLighting()
126
+ this.createPipelines()
127
+ this.createFullscreenQuad()
128
+ this.createBloomPipelines()
129
+ this.setupResize()
130
+ }
131
+
132
+ // Step 2: Create shaders and render pipelines
133
+ private createPipelines() {
134
+ this.textureSampler = this.device.createSampler({
135
+ magFilter: "linear",
136
+ minFilter: "linear",
137
+ addressModeU: "repeat",
138
+ addressModeV: "repeat",
139
+ })
140
+
141
+ const shaderModule = this.device.createShaderModule({
142
+ label: "model shaders",
143
+ code: /* wgsl */ `
144
+ struct CameraUniforms {
145
+ view: mat4x4f,
146
+ projection: mat4x4f,
147
+ viewPos: vec3f,
148
+ _padding: f32,
149
+ };
150
+
151
+ struct Light {
152
+ direction: vec3f,
153
+ _padding1: f32,
154
+ color: vec3f,
155
+ intensity: f32,
156
+ };
157
+
158
+ struct LightUniforms {
159
+ ambient: f32,
160
+ lightCount: f32,
161
+ _padding1: f32,
162
+ _padding2: f32,
163
+ lights: array<Light, 4>,
164
+ };
165
+
166
+ struct MaterialUniforms {
167
+ alpha: f32,
168
+ alphaMultiplier: f32,
169
+ rimIntensity: f32,
170
+ rimPower: f32,
171
+ rimColor: vec3f,
172
+ isOverEyes: f32, // 1.0 if rendering over eyes, 0.0 otherwise
173
+ };
174
+
175
+ struct VertexOutput {
176
+ @builtin(position) position: vec4f,
177
+ @location(0) normal: vec3f,
178
+ @location(1) uv: vec2f,
179
+ @location(2) worldPos: vec3f,
180
+ };
181
+
182
+ @group(0) @binding(0) var<uniform> camera: CameraUniforms;
183
+ @group(0) @binding(1) var<uniform> light: LightUniforms;
184
+ @group(0) @binding(2) var diffuseTexture: texture_2d<f32>;
185
+ @group(0) @binding(3) var diffuseSampler: sampler;
186
+ @group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
187
+ @group(0) @binding(5) var toonTexture: texture_2d<f32>;
188
+ @group(0) @binding(6) var toonSampler: sampler;
189
+ @group(0) @binding(7) var<uniform> material: MaterialUniforms;
190
+
191
+ @vertex fn vs(
192
+ @location(0) position: vec3f,
193
+ @location(1) normal: vec3f,
194
+ @location(2) uv: vec2f,
195
+ @location(3) joints0: vec4<u32>,
196
+ @location(4) weights0: vec4<f32>
197
+ ) -> VertexOutput {
198
+ var output: VertexOutput;
199
+ let pos4 = vec4f(position, 1.0);
200
+
201
+ // Normalize weights to ensure they sum to 1.0 (handles floating-point precision issues)
202
+ let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
203
+ var normalizedWeights: vec4f;
204
+ if (weightSum > 0.0001) {
205
+ normalizedWeights = weights0 / weightSum;
206
+ } else {
207
+ normalizedWeights = vec4f(1.0, 0.0, 0.0, 0.0);
208
+ }
209
+
210
+ var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
211
+ var skinnedNrm = vec3f(0.0, 0.0, 0.0);
212
+ for (var i = 0u; i < 4u; i++) {
213
+ let j = joints0[i];
214
+ let w = normalizedWeights[i];
215
+ let m = skinMats[j];
216
+ skinnedPos += (m * pos4) * w;
217
+ let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
218
+ skinnedNrm += (r3 * normal) * w;
219
+ }
220
+ let worldPos = skinnedPos.xyz;
221
+ output.position = camera.projection * camera.view * vec4f(worldPos, 1.0);
222
+ output.normal = normalize(skinnedNrm);
223
+ output.uv = uv;
224
+ output.worldPos = worldPos;
225
+ return output;
226
+ }
227
+
228
+ @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
229
+ let n = normalize(input.normal);
230
+ let albedo = textureSample(diffuseTexture, diffuseSampler, input.uv).rgb;
231
+
232
+ var lightAccum = vec3f(light.ambient);
233
+ let numLights = u32(light.lightCount);
234
+ for (var i = 0u; i < numLights; i++) {
235
+ let l = -light.lights[i].direction;
236
+ let nDotL = max(dot(n, l), 0.0);
237
+ let toonUV = vec2f(nDotL, 0.5);
238
+ let toonFactor = textureSample(toonTexture, toonSampler, toonUV).rgb;
239
+ let radiance = light.lights[i].color * light.lights[i].intensity;
240
+ lightAccum += toonFactor * radiance * nDotL;
241
+ }
242
+
243
+ // Rim light calculation
244
+ let viewDir = normalize(camera.viewPos - input.worldPos);
245
+ var rimFactor = 1.0 - max(dot(n, viewDir), 0.0);
246
+ rimFactor = pow(rimFactor, material.rimPower);
247
+ let rimLight = material.rimColor * material.rimIntensity * rimFactor;
248
+
249
+ let color = albedo * lightAccum + rimLight;
250
+
251
+ var finalAlpha = material.alpha * material.alphaMultiplier;
252
+ if (material.isOverEyes > 0.5) {
253
+ finalAlpha *= 0.5; // Hair over eyes gets 50% alpha
254
+ }
255
+
256
+ if (finalAlpha < 0.001) {
257
+ discard;
258
+ }
259
+
260
+ return vec4f(clamp(color, vec3f(0.0), vec3f(1.0)), finalAlpha);
261
+ }
262
+ `,
263
+ })
264
+
265
+ // Create explicit bind group layout for all pipelines using the main shader
266
+ this.hairBindGroupLayout = this.device.createBindGroupLayout({
267
+ label: "shared material bind group layout",
268
+ entries: [
269
+ { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // camera
270
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // light
271
+ { binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: {} }, // diffuseTexture
272
+ { binding: 3, visibility: GPUShaderStage.FRAGMENT, sampler: {} }, // diffuseSampler
273
+ { binding: 4, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } }, // skinMats
274
+ { binding: 5, visibility: GPUShaderStage.FRAGMENT, texture: {} }, // toonTexture
275
+ { binding: 6, visibility: GPUShaderStage.FRAGMENT, sampler: {} }, // toonSampler
276
+ { binding: 7, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // material
277
+ ],
278
+ })
279
+
280
+ const sharedPipelineLayout = this.device.createPipelineLayout({
281
+ label: "shared pipeline layout",
282
+ bindGroupLayouts: [this.hairBindGroupLayout],
283
+ })
284
+
285
+ // Single pipeline for all materials with alpha blending
286
+ this.pipeline = this.device.createRenderPipeline({
287
+ label: "model pipeline",
288
+ layout: sharedPipelineLayout,
289
+ vertex: {
290
+ module: shaderModule,
291
+ buffers: [
292
+ {
293
+ arrayStride: 8 * 4,
294
+ attributes: [
295
+ { shaderLocation: 0, offset: 0, format: "float32x3" as GPUVertexFormat },
296
+ { shaderLocation: 1, offset: 3 * 4, format: "float32x3" as GPUVertexFormat },
297
+ { shaderLocation: 2, offset: 6 * 4, format: "float32x2" as GPUVertexFormat },
298
+ ],
299
+ },
300
+ {
301
+ arrayStride: 4 * 2,
302
+ attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" as GPUVertexFormat }],
303
+ },
304
+ {
305
+ arrayStride: 4,
306
+ attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" as GPUVertexFormat }],
307
+ },
308
+ ],
309
+ },
310
+ fragment: {
311
+ module: shaderModule,
312
+ targets: [
313
+ {
314
+ format: this.presentationFormat,
315
+ blend: {
316
+ color: {
317
+ srcFactor: "src-alpha",
318
+ dstFactor: "one-minus-src-alpha",
319
+ operation: "add",
320
+ },
321
+ alpha: {
322
+ srcFactor: "one",
323
+ dstFactor: "one-minus-src-alpha",
324
+ operation: "add",
325
+ },
326
+ },
327
+ },
328
+ ],
329
+ },
330
+ primitive: { cullMode: "none" },
331
+ depthStencil: {
332
+ format: "depth24plus-stencil8",
333
+ depthWriteEnabled: true,
334
+ depthCompare: "less",
335
+ },
336
+ multisample: {
337
+ count: this.sampleCount,
338
+ },
339
+ })
340
+
341
+ // Create bind group layout for outline pipelines
342
+ this.outlineBindGroupLayout = this.device.createBindGroupLayout({
343
+ label: "outline bind group layout",
344
+ entries: [
345
+ { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // camera
346
+ { binding: 1, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // material
347
+ { binding: 2, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } }, // skinMats
348
+ ],
349
+ })
350
+
351
+ const outlinePipelineLayout = this.device.createPipelineLayout({
352
+ label: "outline pipeline layout",
353
+ bindGroupLayouts: [this.outlineBindGroupLayout],
354
+ })
355
+
356
+ const outlineShaderModule = this.device.createShaderModule({
357
+ label: "outline shaders",
358
+ code: /* wgsl */ `
359
+ struct CameraUniforms {
360
+ view: mat4x4f,
361
+ projection: mat4x4f,
362
+ viewPos: vec3f,
363
+ _padding: f32,
364
+ };
365
+
366
+ struct MaterialUniforms {
367
+ edgeColor: vec4f,
368
+ edgeSize: f32,
369
+ isOverEyes: f32, // 1.0 if rendering over eyes, 0.0 otherwise (for hair outlines)
370
+ _padding1: f32,
371
+ _padding2: f32,
372
+ };
373
+
374
+ @group(0) @binding(0) var<uniform> camera: CameraUniforms;
375
+ @group(0) @binding(1) var<uniform> material: MaterialUniforms;
376
+ @group(0) @binding(2) var<storage, read> skinMats: array<mat4x4f>;
377
+
378
+ struct VertexOutput {
379
+ @builtin(position) position: vec4f,
380
+ };
381
+
382
+ @vertex fn vs(
383
+ @location(0) position: vec3f,
384
+ @location(1) normal: vec3f,
385
+ @location(3) joints0: vec4<u32>,
386
+ @location(4) weights0: vec4<f32>
387
+ ) -> VertexOutput {
388
+ var output: VertexOutput;
389
+ let pos4 = vec4f(position, 1.0);
390
+
391
+ // Normalize weights to ensure they sum to 1.0 (handles floating-point precision issues)
392
+ let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
393
+ var normalizedWeights: vec4f;
394
+ if (weightSum > 0.0001) {
395
+ normalizedWeights = weights0 / weightSum;
396
+ } else {
397
+ normalizedWeights = vec4f(1.0, 0.0, 0.0, 0.0);
398
+ }
399
+
400
+ var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
401
+ var skinnedNrm = vec3f(0.0, 0.0, 0.0);
402
+ for (var i = 0u; i < 4u; i++) {
403
+ let j = joints0[i];
404
+ let w = normalizedWeights[i];
405
+ let m = skinMats[j];
406
+ skinnedPos += (m * pos4) * w;
407
+ let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
408
+ skinnedNrm += (r3 * normal) * w;
409
+ }
410
+ let worldPos = skinnedPos.xyz;
411
+ let worldNormal = normalize(skinnedNrm);
412
+
413
+ // MMD invert hull: expand vertices outward along normals
414
+ let scaleFactor = 0.01;
415
+ let expandedPos = worldPos + worldNormal * material.edgeSize * scaleFactor;
416
+ output.position = camera.projection * camera.view * vec4f(expandedPos, 1.0);
417
+ return output;
418
+ }
419
+
420
+ @fragment fn fs() -> @location(0) vec4f {
421
+ var color = material.edgeColor;
422
+
423
+ if (material.isOverEyes > 0.5) {
424
+ color.a *= 0.5; // Hair outlines over eyes get 50% alpha
425
+ }
426
+
427
+ return color;
428
+ }
429
+ `,
430
+ })
431
+
432
+ this.outlinePipeline = this.device.createRenderPipeline({
433
+ label: "outline pipeline",
434
+ layout: outlinePipelineLayout,
435
+ vertex: {
436
+ module: outlineShaderModule,
437
+ buffers: [
438
+ {
439
+ arrayStride: 8 * 4,
440
+ attributes: [
441
+ {
442
+ shaderLocation: 0,
443
+ offset: 0,
444
+ format: "float32x3" as GPUVertexFormat,
445
+ },
446
+ {
447
+ shaderLocation: 1,
448
+ offset: 3 * 4,
449
+ format: "float32x3" as GPUVertexFormat,
450
+ },
451
+ ],
452
+ },
453
+ {
454
+ arrayStride: 4 * 2,
455
+ attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" as GPUVertexFormat }],
456
+ },
457
+ {
458
+ arrayStride: 4,
459
+ attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" as GPUVertexFormat }],
460
+ },
461
+ ],
462
+ },
463
+ fragment: {
464
+ module: outlineShaderModule,
465
+ targets: [
466
+ {
467
+ format: this.presentationFormat,
468
+ blend: {
469
+ color: {
470
+ srcFactor: "src-alpha",
471
+ dstFactor: "one-minus-src-alpha",
472
+ operation: "add",
473
+ },
474
+ alpha: {
475
+ srcFactor: "one",
476
+ dstFactor: "one-minus-src-alpha",
477
+ operation: "add",
478
+ },
479
+ },
480
+ },
481
+ ],
482
+ },
483
+ primitive: {
484
+ cullMode: "back",
485
+ },
486
+ depthStencil: {
487
+ format: "depth24plus-stencil8",
488
+ depthWriteEnabled: true,
489
+ depthCompare: "less",
490
+ },
491
+ multisample: {
492
+ count: this.sampleCount,
493
+ },
494
+ })
495
+
496
+ // Unified hair outline pipeline: single pass without stencil testing, uses depth test "less-equal" to draw everywhere hair exists
497
+ this.hairUnifiedOutlinePipeline = this.device.createRenderPipeline({
498
+ label: "unified hair outline pipeline",
499
+ layout: outlinePipelineLayout,
500
+ vertex: {
501
+ module: outlineShaderModule,
502
+ buffers: [
503
+ {
504
+ arrayStride: 8 * 4,
505
+ attributes: [
506
+ {
507
+ shaderLocation: 0,
508
+ offset: 0,
509
+ format: "float32x3" as GPUVertexFormat,
510
+ },
511
+ {
512
+ shaderLocation: 1,
513
+ offset: 3 * 4,
514
+ format: "float32x3" as GPUVertexFormat,
515
+ },
516
+ ],
517
+ },
518
+ {
519
+ arrayStride: 4 * 2,
520
+ attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" as GPUVertexFormat }],
521
+ },
522
+ {
523
+ arrayStride: 4,
524
+ attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" as GPUVertexFormat }],
525
+ },
526
+ ],
527
+ },
528
+ fragment: {
529
+ module: outlineShaderModule,
530
+ targets: [
531
+ {
532
+ format: this.presentationFormat,
533
+ blend: {
534
+ color: {
535
+ srcFactor: "src-alpha",
536
+ dstFactor: "one-minus-src-alpha",
537
+ operation: "add",
538
+ },
539
+ alpha: {
540
+ srcFactor: "one",
541
+ dstFactor: "one-minus-src-alpha",
542
+ operation: "add",
543
+ },
544
+ },
545
+ },
546
+ ],
547
+ },
548
+ primitive: {
549
+ cullMode: "back",
550
+ },
551
+ depthStencil: {
552
+ format: "depth24plus-stencil8",
553
+ depthWriteEnabled: false, // Don't write depth - let hair geometry control depth
554
+ depthCompare: "less-equal", // Only draw where hair depth exists (no stencil test needed)
555
+ depthBias: -0.0001, // Small negative bias to bring outline slightly closer for depth test
556
+ depthBiasSlopeScale: 0.0,
557
+ depthBiasClamp: 0.0,
558
+ },
559
+ multisample: {
560
+ count: this.sampleCount,
561
+ },
562
+ })
563
+
564
+ // Eye overlay pipeline (renders after opaque, writes stencil)
565
+ this.eyePipeline = this.device.createRenderPipeline({
566
+ label: "eye overlay pipeline",
567
+ layout: sharedPipelineLayout,
568
+ vertex: {
569
+ module: shaderModule,
570
+ buffers: [
571
+ {
572
+ arrayStride: 8 * 4,
573
+ attributes: [
574
+ { shaderLocation: 0, offset: 0, format: "float32x3" as GPUVertexFormat },
575
+ { shaderLocation: 1, offset: 3 * 4, format: "float32x3" as GPUVertexFormat },
576
+ { shaderLocation: 2, offset: 6 * 4, format: "float32x2" as GPUVertexFormat },
577
+ ],
578
+ },
579
+ {
580
+ arrayStride: 4 * 2,
581
+ attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" as GPUVertexFormat }],
582
+ },
583
+ {
584
+ arrayStride: 4,
585
+ attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" as GPUVertexFormat }],
586
+ },
587
+ ],
588
+ },
589
+ fragment: {
590
+ module: shaderModule,
591
+ targets: [
592
+ {
593
+ format: this.presentationFormat,
594
+ blend: {
595
+ color: {
596
+ srcFactor: "src-alpha",
597
+ dstFactor: "one-minus-src-alpha",
598
+ operation: "add",
599
+ },
600
+ alpha: {
601
+ srcFactor: "one",
602
+ dstFactor: "one-minus-src-alpha",
603
+ operation: "add",
604
+ },
605
+ },
606
+ },
607
+ ],
608
+ },
609
+ primitive: { cullMode: "none" },
610
+ depthStencil: {
611
+ format: "depth24plus-stencil8",
612
+ depthWriteEnabled: false, // Don't write depth
613
+ depthCompare: "less", // Respect existing depth
614
+ stencilFront: {
615
+ compare: "always",
616
+ failOp: "keep",
617
+ depthFailOp: "keep",
618
+ passOp: "replace", // Write stencil value 1
619
+ },
620
+ stencilBack: {
621
+ compare: "always",
622
+ failOp: "keep",
623
+ depthFailOp: "keep",
624
+ passOp: "replace",
625
+ },
626
+ },
627
+ multisample: { count: this.sampleCount },
628
+ })
629
+
630
+ // Depth-only shader for hair pre-pass (reduces overdraw by early depth rejection)
631
+ const depthOnlyShaderModule = this.device.createShaderModule({
632
+ label: "depth only shader",
633
+ code: /* wgsl */ `
634
+ struct CameraUniforms {
635
+ view: mat4x4f,
636
+ projection: mat4x4f,
637
+ viewPos: vec3f,
638
+ _padding: f32,
639
+ };
640
+
641
+ @group(0) @binding(0) var<uniform> camera: CameraUniforms;
642
+ @group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
643
+
644
+ @vertex fn vs(
645
+ @location(0) position: vec3f,
646
+ @location(1) normal: vec3f,
647
+ @location(3) joints0: vec4<u32>,
648
+ @location(4) weights0: vec4<f32>
649
+ ) -> @builtin(position) vec4f {
650
+ let pos4 = vec4f(position, 1.0);
651
+
652
+ // Normalize weights
653
+ let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
654
+ var normalizedWeights: vec4f;
655
+ if (weightSum > 0.0001) {
656
+ normalizedWeights = weights0 / weightSum;
657
+ } else {
658
+ normalizedWeights = vec4f(1.0, 0.0, 0.0, 0.0);
659
+ }
660
+
661
+ var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
662
+ for (var i = 0u; i < 4u; i++) {
663
+ let j = joints0[i];
664
+ let w = normalizedWeights[i];
665
+ let m = skinMats[j];
666
+ skinnedPos += (m * pos4) * w;
667
+ }
668
+ let worldPos = skinnedPos.xyz;
669
+ let clipPos = camera.projection * camera.view * vec4f(worldPos, 1.0);
670
+ return clipPos;
671
+ }
672
+
673
+ @fragment fn fs() -> @location(0) vec4f {
674
+ return vec4f(0.0, 0.0, 0.0, 0.0); // Transparent - color writes disabled via writeMask
675
+ }
676
+ `,
677
+ })
678
+
679
+ // Hair depth pre-pass pipeline: depth-only with color writes disabled to eliminate overdraw
680
+ this.hairDepthPipeline = this.device.createRenderPipeline({
681
+ label: "hair depth pre-pass",
682
+ layout: sharedPipelineLayout,
683
+ vertex: {
684
+ module: depthOnlyShaderModule,
685
+ buffers: [
686
+ {
687
+ arrayStride: 8 * 4,
688
+ attributes: [
689
+ { shaderLocation: 0, offset: 0, format: "float32x3" as GPUVertexFormat },
690
+ { shaderLocation: 1, offset: 3 * 4, format: "float32x3" as GPUVertexFormat },
691
+ ],
692
+ },
693
+ {
694
+ arrayStride: 4 * 2,
695
+ attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" as GPUVertexFormat }],
696
+ },
697
+ {
698
+ arrayStride: 4,
699
+ attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" as GPUVertexFormat }],
700
+ },
701
+ ],
702
+ },
703
+ fragment: {
704
+ module: depthOnlyShaderModule,
705
+ entryPoint: "fs",
706
+ targets: [
707
+ {
708
+ format: this.presentationFormat,
709
+ writeMask: 0, // Disable all color writes - we only care about depth
710
+ },
711
+ ],
712
+ },
713
+ primitive: { cullMode: "none" },
714
+ depthStencil: {
715
+ format: "depth24plus-stencil8",
716
+ depthWriteEnabled: true,
717
+ depthCompare: "less",
718
+ },
719
+ multisample: { count: this.sampleCount },
720
+ })
721
+
722
+ // Unified hair pipeline for over-eyes (stencil == 1): single pass with dynamic branching
723
+ this.hairUnifiedPipelineOverEyes = this.device.createRenderPipeline({
724
+ label: "unified hair pipeline (over eyes)",
725
+ layout: sharedPipelineLayout,
726
+ vertex: {
727
+ module: shaderModule,
728
+ buffers: [
729
+ {
730
+ arrayStride: 8 * 4,
731
+ attributes: [
732
+ { shaderLocation: 0, offset: 0, format: "float32x3" as GPUVertexFormat },
733
+ { shaderLocation: 1, offset: 3 * 4, format: "float32x3" as GPUVertexFormat },
734
+ { shaderLocation: 2, offset: 6 * 4, format: "float32x2" as GPUVertexFormat },
735
+ ],
736
+ },
737
+ {
738
+ arrayStride: 4 * 2,
739
+ attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" as GPUVertexFormat }],
740
+ },
741
+ {
742
+ arrayStride: 4,
743
+ attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" as GPUVertexFormat }],
744
+ },
745
+ ],
746
+ },
747
+ fragment: {
748
+ module: shaderModule,
749
+ targets: [
750
+ {
751
+ format: this.presentationFormat,
752
+ blend: {
753
+ color: {
754
+ srcFactor: "src-alpha",
755
+ dstFactor: "one-minus-src-alpha",
756
+ operation: "add",
757
+ },
758
+ alpha: {
759
+ srcFactor: "one",
760
+ dstFactor: "one-minus-src-alpha",
761
+ operation: "add",
762
+ },
763
+ },
764
+ },
765
+ ],
766
+ },
767
+ primitive: { cullMode: "none" },
768
+ depthStencil: {
769
+ format: "depth24plus-stencil8",
770
+ depthWriteEnabled: false, // Don't write depth (already written in pre-pass)
771
+ depthCompare: "equal", // Only render where depth matches pre-pass
772
+ stencilFront: {
773
+ compare: "equal", // Only render where stencil == 1 (over eyes)
774
+ failOp: "keep",
775
+ depthFailOp: "keep",
776
+ passOp: "keep",
777
+ },
778
+ stencilBack: {
779
+ compare: "equal",
780
+ failOp: "keep",
781
+ depthFailOp: "keep",
782
+ passOp: "keep",
783
+ },
784
+ },
785
+ multisample: { count: this.sampleCount },
786
+ })
787
+
788
+ // Unified pipeline for hair over non-eyes (stencil != 1)
789
+ this.hairUnifiedPipelineOverNonEyes = this.device.createRenderPipeline({
790
+ label: "unified hair pipeline (over non-eyes)",
791
+ layout: sharedPipelineLayout,
792
+ vertex: {
793
+ module: shaderModule,
794
+ buffers: [
795
+ {
796
+ arrayStride: 8 * 4,
797
+ attributes: [
798
+ { shaderLocation: 0, offset: 0, format: "float32x3" as GPUVertexFormat },
799
+ { shaderLocation: 1, offset: 3 * 4, format: "float32x3" as GPUVertexFormat },
800
+ { shaderLocation: 2, offset: 6 * 4, format: "float32x2" as GPUVertexFormat },
801
+ ],
802
+ },
803
+ {
804
+ arrayStride: 4 * 2,
805
+ attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" as GPUVertexFormat }],
806
+ },
807
+ {
808
+ arrayStride: 4,
809
+ attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" as GPUVertexFormat }],
810
+ },
811
+ ],
812
+ },
813
+ fragment: {
814
+ module: shaderModule,
815
+ targets: [
816
+ {
817
+ format: this.presentationFormat,
818
+ blend: {
819
+ color: {
820
+ srcFactor: "src-alpha",
821
+ dstFactor: "one-minus-src-alpha",
822
+ operation: "add",
823
+ },
824
+ alpha: {
825
+ srcFactor: "one",
826
+ dstFactor: "one-minus-src-alpha",
827
+ operation: "add",
828
+ },
829
+ },
830
+ },
831
+ ],
832
+ },
833
+ primitive: { cullMode: "none" },
834
+ depthStencil: {
835
+ format: "depth24plus-stencil8",
836
+ depthWriteEnabled: false, // Don't write depth (already written in pre-pass)
837
+ depthCompare: "equal", // Only render where depth matches pre-pass
838
+ stencilFront: {
839
+ compare: "not-equal", // Only render where stencil != 1 (over non-eyes)
840
+ failOp: "keep",
841
+ depthFailOp: "keep",
842
+ passOp: "keep",
843
+ },
844
+ stencilBack: {
845
+ compare: "not-equal",
846
+ failOp: "keep",
847
+ depthFailOp: "keep",
848
+ passOp: "keep",
849
+ },
850
+ },
851
+ multisample: { count: this.sampleCount },
852
+ })
853
+ }
854
+
855
+ // Create compute shader for skin matrix computation
856
+ private createSkinMatrixComputePipeline() {
857
+ const computeShader = this.device.createShaderModule({
858
+ label: "skin matrix compute",
859
+ code: /* wgsl */ `
860
+ struct BoneCountUniform {
861
+ count: u32,
862
+ _padding1: u32,
863
+ _padding2: u32,
864
+ _padding3: u32,
865
+ _padding4: vec4<u32>,
866
+ };
867
+
868
+ @group(0) @binding(0) var<uniform> boneCount: BoneCountUniform;
869
+ @group(0) @binding(1) var<storage, read> worldMatrices: array<mat4x4f>;
870
+ @group(0) @binding(2) var<storage, read> inverseBindMatrices: array<mat4x4f>;
871
+ @group(0) @binding(3) var<storage, read_write> skinMatrices: array<mat4x4f>;
872
+
873
+ @compute @workgroup_size(64)
874
+ fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
875
+ let boneIndex = globalId.x;
876
+ // Bounds check: we dispatch workgroups (64 threads each), so some threads may be out of range
877
+ if (boneIndex >= boneCount.count) {
878
+ return;
879
+ }
880
+ let worldMat = worldMatrices[boneIndex];
881
+ let invBindMat = inverseBindMatrices[boneIndex];
882
+ skinMatrices[boneIndex] = worldMat * invBindMat;
883
+ }
884
+ `,
885
+ })
886
+
887
+ this.skinMatrixComputePipeline = this.device.createComputePipeline({
888
+ label: "skin matrix compute pipeline",
889
+ layout: "auto",
890
+ compute: {
891
+ module: computeShader,
892
+ },
893
+ })
894
+ }
895
+
896
+ // Create fullscreen quad for post-processing
897
+ private createFullscreenQuad() {
898
+ // Fullscreen quad vertices: two triangles covering the entire screen - Format: position (x, y), uv (u, v)
899
+ const quadVertices = new Float32Array([
900
+ // Triangle 1
901
+ -1.0,
902
+ -1.0,
903
+ 0.0,
904
+ 0.0, // bottom-left
905
+ 1.0,
906
+ -1.0,
907
+ 1.0,
908
+ 0.0, // bottom-right
909
+ -1.0,
910
+ 1.0,
911
+ 0.0,
912
+ 1.0, // top-left
913
+ // Triangle 2
914
+ -1.0,
915
+ 1.0,
916
+ 0.0,
917
+ 1.0, // top-left
918
+ 1.0,
919
+ -1.0,
920
+ 1.0,
921
+ 0.0, // bottom-right
922
+ 1.0,
923
+ 1.0,
924
+ 1.0,
925
+ 1.0, // top-right
926
+ ])
927
+
928
+ this.fullscreenQuadBuffer = this.device.createBuffer({
929
+ label: "fullscreen quad",
930
+ size: quadVertices.byteLength,
931
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
932
+ })
933
+ this.device.queue.writeBuffer(this.fullscreenQuadBuffer, 0, quadVertices)
934
+ }
935
+
936
+ // Create bloom post-processing pipelines
937
+ private createBloomPipelines() {
938
+ // Bloom extraction shader (extracts bright areas)
939
+ const bloomExtractShader = this.device.createShaderModule({
940
+ label: "bloom extract",
941
+ code: /* wgsl */ `
942
+ struct VertexOutput {
943
+ @builtin(position) position: vec4f,
944
+ @location(0) uv: vec2f,
945
+ };
946
+
947
+ @vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
948
+ var output: VertexOutput;
949
+ // Generate fullscreen quad from vertex index
950
+ let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
951
+ let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
952
+ output.position = vec4f(x, y, 0.0, 1.0);
953
+ output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
954
+ return output;
955
+ }
956
+
957
+ struct BloomExtractUniforms {
958
+ threshold: f32,
959
+ _padding1: f32,
960
+ _padding2: f32,
961
+ _padding3: f32,
962
+ _padding4: f32,
963
+ _padding5: f32,
964
+ _padding6: f32,
965
+ _padding7: f32,
966
+ };
967
+
968
+ @group(0) @binding(0) var inputTexture: texture_2d<f32>;
969
+ @group(0) @binding(1) var inputSampler: sampler;
970
+ @group(0) @binding(2) var<uniform> extractUniforms: BloomExtractUniforms;
971
+
972
+ @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
973
+ let color = textureSample(inputTexture, inputSampler, input.uv);
974
+ // Extract bright areas above threshold
975
+ let threshold = extractUniforms.threshold;
976
+ let bloom = max(vec3f(0.0), color.rgb - vec3f(threshold)) / max(0.001, 1.0 - threshold);
977
+ return vec4f(bloom, color.a);
978
+ }
979
+ `,
980
+ })
981
+
982
+ // Bloom blur shader (gaussian blur - can be used for both horizontal and vertical)
983
+ const bloomBlurShader = this.device.createShaderModule({
984
+ label: "bloom blur",
985
+ code: /* wgsl */ `
986
+ struct VertexOutput {
987
+ @builtin(position) position: vec4f,
988
+ @location(0) uv: vec2f,
989
+ };
990
+
991
+ @vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
992
+ var output: VertexOutput;
993
+ let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
994
+ let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
995
+ output.position = vec4f(x, y, 0.0, 1.0);
996
+ output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
997
+ return output;
998
+ }
999
+
1000
+ struct BlurUniforms {
1001
+ direction: vec2f,
1002
+ _padding1: f32,
1003
+ _padding2: f32,
1004
+ _padding3: f32,
1005
+ _padding4: f32,
1006
+ _padding5: f32,
1007
+ _padding6: f32,
1008
+ };
1009
+
1010
+ @group(0) @binding(0) var inputTexture: texture_2d<f32>;
1011
+ @group(0) @binding(1) var inputSampler: sampler;
1012
+ @group(0) @binding(2) var<uniform> blurUniforms: BlurUniforms;
1013
+
1014
+ // 9-tap gaussian blur
1015
+ @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
1016
+ let texelSize = 1.0 / vec2f(textureDimensions(inputTexture));
1017
+ var result = vec4f(0.0);
1018
+
1019
+ // Gaussian weights for 9-tap filter
1020
+ let weights = array<f32, 9>(
1021
+ 0.01621622, 0.05405405, 0.12162162,
1022
+ 0.19459459, 0.22702703,
1023
+ 0.19459459, 0.12162162, 0.05405405, 0.01621622
1024
+ );
1025
+
1026
+ let offsets = array<f32, 9>(-4.0, -3.0, -2.0, -1.0, 0.0, 1.0, 2.0, 3.0, 4.0);
1027
+
1028
+ for (var i = 0u; i < 9u; i++) {
1029
+ let offset = offsets[i] * texelSize * blurUniforms.direction;
1030
+ result += textureSample(inputTexture, inputSampler, input.uv + offset) * weights[i];
1031
+ }
1032
+
1033
+ return result;
1034
+ }
1035
+ `,
1036
+ })
1037
+
1038
+ // Bloom composition shader (combines original scene with bloom)
1039
+ const bloomComposeShader = this.device.createShaderModule({
1040
+ label: "bloom compose",
1041
+ code: /* wgsl */ `
1042
+ struct VertexOutput {
1043
+ @builtin(position) position: vec4f,
1044
+ @location(0) uv: vec2f,
1045
+ };
1046
+
1047
+ @vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
1048
+ var output: VertexOutput;
1049
+ let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
1050
+ let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
1051
+ output.position = vec4f(x, y, 0.0, 1.0);
1052
+ output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
1053
+ return output;
1054
+ }
1055
+
1056
+ struct BloomComposeUniforms {
1057
+ intensity: f32,
1058
+ _padding1: f32,
1059
+ _padding2: f32,
1060
+ _padding3: f32,
1061
+ _padding4: f32,
1062
+ _padding5: f32,
1063
+ _padding6: f32,
1064
+ _padding7: f32,
1065
+ };
1066
+
1067
+ @group(0) @binding(0) var sceneTexture: texture_2d<f32>;
1068
+ @group(0) @binding(1) var sceneSampler: sampler;
1069
+ @group(0) @binding(2) var bloomTexture: texture_2d<f32>;
1070
+ @group(0) @binding(3) var bloomSampler: sampler;
1071
+ @group(0) @binding(4) var<uniform> composeUniforms: BloomComposeUniforms;
1072
+
1073
+ @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
1074
+ let scene = textureSample(sceneTexture, sceneSampler, input.uv);
1075
+ let bloom = textureSample(bloomTexture, bloomSampler, input.uv);
1076
+ // Additive blending with intensity control
1077
+ let result = scene.rgb + bloom.rgb * composeUniforms.intensity;
1078
+ return vec4f(result, scene.a);
1079
+ }
1080
+ `,
1081
+ })
1082
+
1083
+ // Create uniform buffer for blur direction (minimum 32 bytes for WebGPU)
1084
+ const blurDirectionBuffer = this.device.createBuffer({
1085
+ label: "blur direction",
1086
+ size: 32, // Minimum 32 bytes required for uniform buffers in WebGPU
1087
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1088
+ })
1089
+
1090
+ // Create uniform buffer for bloom intensity (minimum 32 bytes for WebGPU)
1091
+ const bloomIntensityBuffer = this.device.createBuffer({
1092
+ label: "bloom intensity",
1093
+ size: 32, // Minimum 32 bytes required for uniform buffers in WebGPU
1094
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1095
+ })
1096
+
1097
+ // Create uniform buffer for bloom threshold (minimum 32 bytes for WebGPU)
1098
+ const bloomThresholdBuffer = this.device.createBuffer({
1099
+ label: "bloom threshold",
1100
+ size: 32, // Minimum 32 bytes required for uniform buffers in WebGPU
1101
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1102
+ })
1103
+
1104
+ // Set default bloom values
1105
+ const intensityData = new Float32Array(8) // f32 + 7 padding floats = 8 floats = 32 bytes
1106
+ intensityData[0] = this.bloomIntensity
1107
+ this.device.queue.writeBuffer(bloomIntensityBuffer, 0, intensityData)
1108
+
1109
+ const thresholdData = new Float32Array(8) // f32 + 7 padding floats = 8 floats = 32 bytes
1110
+ thresholdData[0] = this.bloomThreshold
1111
+ this.device.queue.writeBuffer(bloomThresholdBuffer, 0, thresholdData)
1112
+
1113
+ // Create linear sampler for post-processing
1114
+ const linearSampler = this.device.createSampler({
1115
+ magFilter: "linear",
1116
+ minFilter: "linear",
1117
+ addressModeU: "clamp-to-edge",
1118
+ addressModeV: "clamp-to-edge",
1119
+ })
1120
+
1121
+ // Bloom extraction pipeline
1122
+ this.bloomExtractPipeline = this.device.createRenderPipeline({
1123
+ label: "bloom extract",
1124
+ layout: "auto",
1125
+ vertex: {
1126
+ module: bloomExtractShader,
1127
+ entryPoint: "vs",
1128
+ },
1129
+ fragment: {
1130
+ module: bloomExtractShader,
1131
+ entryPoint: "fs",
1132
+ targets: [{ format: this.presentationFormat }],
1133
+ },
1134
+ primitive: { topology: "triangle-list" },
1135
+ })
1136
+
1137
+ // Bloom blur pipeline
1138
+ this.bloomBlurPipeline = this.device.createRenderPipeline({
1139
+ label: "bloom blur",
1140
+ layout: "auto",
1141
+ vertex: {
1142
+ module: bloomBlurShader,
1143
+ entryPoint: "vs",
1144
+ },
1145
+ fragment: {
1146
+ module: bloomBlurShader,
1147
+ entryPoint: "fs",
1148
+ targets: [{ format: this.presentationFormat }],
1149
+ },
1150
+ primitive: { topology: "triangle-list" },
1151
+ })
1152
+
1153
+ // Bloom composition pipeline
1154
+ this.bloomComposePipeline = this.device.createRenderPipeline({
1155
+ label: "bloom compose",
1156
+ layout: "auto",
1157
+ vertex: {
1158
+ module: bloomComposeShader,
1159
+ entryPoint: "vs",
1160
+ },
1161
+ fragment: {
1162
+ module: bloomComposeShader,
1163
+ entryPoint: "fs",
1164
+ targets: [{ format: this.presentationFormat }],
1165
+ },
1166
+ primitive: { topology: "triangle-list" },
1167
+ })
1168
+
1169
+ // Store buffers and sampler for later use
1170
+ this.blurDirectionBuffer = blurDirectionBuffer
1171
+ this.bloomIntensityBuffer = bloomIntensityBuffer
1172
+ this.bloomThresholdBuffer = bloomThresholdBuffer
1173
+ this.linearSampler = linearSampler
1174
+ }
1175
+
1176
+ // Setup bloom textures and bind groups (called when canvas is resized)
1177
+ private setupBloom(width: number, height: number) {
1178
+ // Create bloom textures (half resolution for performance)
1179
+ const bloomWidth = Math.floor(width / 2)
1180
+ const bloomHeight = Math.floor(height / 2)
1181
+ this.bloomExtractTexture = this.device.createTexture({
1182
+ label: "bloom extract",
1183
+ size: [bloomWidth, bloomHeight],
1184
+ format: this.presentationFormat,
1185
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1186
+ })
1187
+ this.bloomBlurTexture1 = this.device.createTexture({
1188
+ label: "bloom blur 1",
1189
+ size: [bloomWidth, bloomHeight],
1190
+ format: this.presentationFormat,
1191
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1192
+ })
1193
+ this.bloomBlurTexture2 = this.device.createTexture({
1194
+ label: "bloom blur 2",
1195
+ size: [bloomWidth, bloomHeight],
1196
+ format: this.presentationFormat,
1197
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1198
+ })
1199
+
1200
+ // Create bloom bind groups
1201
+ this.bloomExtractBindGroup = this.device.createBindGroup({
1202
+ layout: this.bloomExtractPipeline.getBindGroupLayout(0),
1203
+ entries: [
1204
+ { binding: 0, resource: this.sceneRenderTexture.createView() },
1205
+ { binding: 1, resource: this.linearSampler },
1206
+ { binding: 2, resource: { buffer: this.bloomThresholdBuffer } },
1207
+ ],
1208
+ })
1209
+
1210
+ this.bloomBlurHBindGroup = this.device.createBindGroup({
1211
+ layout: this.bloomBlurPipeline.getBindGroupLayout(0),
1212
+ entries: [
1213
+ { binding: 0, resource: this.bloomExtractTexture.createView() },
1214
+ { binding: 1, resource: this.linearSampler },
1215
+ { binding: 2, resource: { buffer: this.blurDirectionBuffer } },
1216
+ ],
1217
+ })
1218
+
1219
+ this.bloomBlurVBindGroup = this.device.createBindGroup({
1220
+ layout: this.bloomBlurPipeline.getBindGroupLayout(0),
1221
+ entries: [
1222
+ { binding: 0, resource: this.bloomBlurTexture1.createView() },
1223
+ { binding: 1, resource: this.linearSampler },
1224
+ { binding: 2, resource: { buffer: this.blurDirectionBuffer } },
1225
+ ],
1226
+ })
1227
+
1228
+ this.bloomComposeBindGroup = this.device.createBindGroup({
1229
+ layout: this.bloomComposePipeline.getBindGroupLayout(0),
1230
+ entries: [
1231
+ { binding: 0, resource: this.sceneRenderTexture.createView() },
1232
+ { binding: 1, resource: this.linearSampler },
1233
+ { binding: 2, resource: this.bloomBlurTexture2.createView() },
1234
+ { binding: 3, resource: this.linearSampler },
1235
+ { binding: 4, resource: { buffer: this.bloomIntensityBuffer } },
1236
+ ],
1237
+ })
1238
+ }
1239
+
1240
+ // Step 3: Setup canvas resize handling
1241
+ private setupResize() {
1242
+ this.resizeObserver = new ResizeObserver(() => this.handleResize())
1243
+ this.resizeObserver.observe(this.canvas)
1244
+ this.handleResize()
1245
+ }
1246
+
1247
+ private handleResize() {
1248
+ const displayWidth = this.canvas.clientWidth
1249
+ const displayHeight = this.canvas.clientHeight
1250
+
1251
+ const dpr = window.devicePixelRatio || 1
1252
+ const width = Math.floor(displayWidth * dpr)
1253
+ const height = Math.floor(displayHeight * dpr)
1254
+
1255
+ if (!this.multisampleTexture || this.canvas.width !== width || this.canvas.height !== height) {
1256
+ this.canvas.width = width
1257
+ this.canvas.height = height
1258
+
1259
+ this.multisampleTexture = this.device.createTexture({
1260
+ label: "multisample render target",
1261
+ size: [width, height],
1262
+ sampleCount: this.sampleCount,
1263
+ format: this.presentationFormat,
1264
+ usage: GPUTextureUsage.RENDER_ATTACHMENT,
1265
+ })
1266
+
1267
+ this.depthTexture = this.device.createTexture({
1268
+ label: "depth texture",
1269
+ size: [width, height],
1270
+ sampleCount: this.sampleCount,
1271
+ format: "depth24plus-stencil8",
1272
+ usage: GPUTextureUsage.RENDER_ATTACHMENT,
1273
+ })
1274
+
1275
+ // Create scene render texture (non-multisampled for post-processing)
1276
+ this.sceneRenderTexture = this.device.createTexture({
1277
+ label: "scene render texture",
1278
+ size: [width, height],
1279
+ format: this.presentationFormat,
1280
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1281
+ })
1282
+ this.sceneRenderTextureView = this.sceneRenderTexture.createView()
1283
+
1284
+ // Setup bloom textures and bind groups
1285
+ this.setupBloom(width, height)
1286
+
1287
+ const depthTextureView = this.depthTexture.createView()
1288
+
1289
+ // Render scene to texture instead of directly to canvas
1290
+ const colorAttachment: GPURenderPassColorAttachment =
1291
+ this.sampleCount > 1
1292
+ ? {
1293
+ view: this.multisampleTexture.createView(),
1294
+ resolveTarget: this.sceneRenderTextureView,
1295
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
1296
+ loadOp: "clear",
1297
+ storeOp: "store",
1298
+ }
1299
+ : {
1300
+ view: this.sceneRenderTextureView,
1301
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
1302
+ loadOp: "clear",
1303
+ storeOp: "store",
1304
+ }
1305
+
1306
+ this.renderPassDescriptor = {
1307
+ label: "renderPass",
1308
+ colorAttachments: [colorAttachment],
1309
+ depthStencilAttachment: {
1310
+ view: depthTextureView,
1311
+ depthClearValue: 1.0,
1312
+ depthLoadOp: "clear",
1313
+ depthStoreOp: "store",
1314
+ stencilClearValue: 0,
1315
+ stencilLoadOp: "clear",
1316
+ stencilStoreOp: "discard", // Discard stencil after frame to save bandwidth (we only use it during rendering)
1317
+ },
1318
+ }
1319
+
1320
+ this.camera.aspect = width / height
1321
+ }
1322
+ }
1323
+
1324
+ // Step 4: Create camera and uniform buffer
1325
+ private setupCamera() {
1326
+ this.cameraUniformBuffer = this.device.createBuffer({
1327
+ label: "camera uniforms",
1328
+ size: 40 * 4,
1329
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1330
+ })
1331
+
1332
+ this.camera = new Camera(Math.PI, Math.PI / 2.5, 26.6, new Vec3(0, 12.5, 0))
1333
+
1334
+ this.camera.aspect = this.canvas.width / this.canvas.height
1335
+ this.camera.attachControl(this.canvas)
1336
+ }
1337
+
1338
+ // Step 5: Create lighting buffers
1339
+ private setupLighting() {
1340
+ this.lightUniformBuffer = this.device.createBuffer({
1341
+ label: "light uniforms",
1342
+ size: 64 * 4,
1343
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1344
+ })
1345
+
1346
+ this.lightCount = 0
1347
+
1348
+ this.setAmbient(0.96)
1349
+ this.addLight(new Vec3(-0.5, -0.8, 0.5).normalize(), new Vec3(1.0, 0.95, 0.9), 0.12)
1350
+ this.addLight(new Vec3(0.7, -0.5, 0.3).normalize(), new Vec3(0.8, 0.85, 1.0), 0.1)
1351
+ this.addLight(new Vec3(0.3, -0.5, -1.0).normalize(), new Vec3(0.9, 0.9, 1.0), 0.08)
1352
+ this.device.queue.writeBuffer(this.lightUniformBuffer, 0, this.lightData)
1353
+ }
1354
+
1355
+ public addLight(direction: Vec3, color: Vec3, intensity: number = 1.0): boolean {
1356
+ if (this.lightCount >= 4) return false
1357
+
1358
+ const normalized = direction.normalize()
1359
+ const baseIndex = 4 + this.lightCount * 8
1360
+ this.lightData[baseIndex] = normalized.x
1361
+ this.lightData[baseIndex + 1] = normalized.y
1362
+ this.lightData[baseIndex + 2] = normalized.z
1363
+ this.lightData[baseIndex + 3] = 0
1364
+ this.lightData[baseIndex + 4] = color.x
1365
+ this.lightData[baseIndex + 5] = color.y
1366
+ this.lightData[baseIndex + 6] = color.z
1367
+ this.lightData[baseIndex + 7] = intensity
1368
+
1369
+ this.lightCount++
1370
+ this.lightData[1] = this.lightCount
1371
+ return true
1372
+ }
1373
+
1374
+ public setAmbient(intensity: number) {
1375
+ this.lightData[0] = intensity
1376
+ }
1377
+
1378
+ public getStats(): EngineStats {
1379
+ return { ...this.stats }
1380
+ }
1381
+
1382
+ public runRenderLoop(callback?: () => void) {
1383
+ this.renderLoopCallback = callback || null
1384
+
1385
+ const loop = () => {
1386
+ this.render()
1387
+
1388
+ if (this.renderLoopCallback) {
1389
+ this.renderLoopCallback()
1390
+ }
1391
+
1392
+ this.animationFrameId = requestAnimationFrame(loop)
1393
+ }
1394
+
1395
+ this.animationFrameId = requestAnimationFrame(loop)
1396
+ }
1397
+
1398
+ public stopRenderLoop() {
1399
+ if (this.animationFrameId !== null) {
1400
+ cancelAnimationFrame(this.animationFrameId)
1401
+ this.animationFrameId = null
1402
+ }
1403
+ this.renderLoopCallback = null
1404
+ }
1405
+
1406
+ public dispose() {
1407
+ this.stopRenderLoop()
1408
+ if (this.camera) this.camera.detachControl()
1409
+ if (this.resizeObserver) {
1410
+ this.resizeObserver.disconnect()
1411
+ this.resizeObserver = null
1412
+ }
1413
+ }
1414
+
1415
+ // Step 6: Load PMX model file
1416
+ public async loadModel(path: string) {
1417
+ const pathParts = path.split("/")
1418
+ pathParts.pop()
1419
+ const dir = pathParts.join("/") + "/"
1420
+ this.modelDir = dir
1421
+
1422
+ const model = await PmxLoader.load(path)
1423
+ this.physics = new Physics(model.getRigidbodies(), model.getJoints())
1424
+ await this.setupModelBuffers(model)
1425
+ }
1426
+
1427
+ public rotateBones(bones: string[], rotations: Quat[], durationMs?: number) {
1428
+ this.currentModel?.rotateBones(bones, rotations, durationMs)
1429
+ }
1430
+
1431
+ // Step 7: Create vertex, index, and joint buffers
1432
+ private async setupModelBuffers(model: Model) {
1433
+ this.currentModel = model
1434
+ const vertices = model.getVertices()
1435
+ const skinning = model.getSkinning()
1436
+ const skeleton = model.getSkeleton()
1437
+
1438
+ this.vertexBuffer = this.device.createBuffer({
1439
+ label: "model vertex buffer",
1440
+ size: vertices.byteLength,
1441
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
1442
+ })
1443
+ this.device.queue.writeBuffer(this.vertexBuffer, 0, vertices)
1444
+
1445
+ this.jointsBuffer = this.device.createBuffer({
1446
+ label: "joints buffer",
1447
+ size: skinning.joints.byteLength,
1448
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
1449
+ })
1450
+ this.device.queue.writeBuffer(
1451
+ this.jointsBuffer,
1452
+ 0,
1453
+ skinning.joints.buffer,
1454
+ skinning.joints.byteOffset,
1455
+ skinning.joints.byteLength
1456
+ )
1457
+
1458
+ this.weightsBuffer = this.device.createBuffer({
1459
+ label: "weights buffer",
1460
+ size: skinning.weights.byteLength,
1461
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
1462
+ })
1463
+ this.device.queue.writeBuffer(
1464
+ this.weightsBuffer,
1465
+ 0,
1466
+ skinning.weights.buffer,
1467
+ skinning.weights.byteOffset,
1468
+ skinning.weights.byteLength
1469
+ )
1470
+
1471
+ const boneCount = skeleton.bones.length
1472
+ const matrixSize = boneCount * 16 * 4
1473
+
1474
+ this.skinMatrixBuffer = this.device.createBuffer({
1475
+ label: "skin matrices",
1476
+ size: Math.max(256, matrixSize),
1477
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.VERTEX,
1478
+ })
1479
+
1480
+ this.worldMatrixBuffer = this.device.createBuffer({
1481
+ label: "world matrices",
1482
+ size: Math.max(256, matrixSize),
1483
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
1484
+ })
1485
+
1486
+ this.inverseBindMatrixBuffer = this.device.createBuffer({
1487
+ label: "inverse bind matrices",
1488
+ size: Math.max(256, matrixSize),
1489
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
1490
+ })
1491
+
1492
+ const invBindMatrices = skeleton.inverseBindMatrices
1493
+ this.device.queue.writeBuffer(
1494
+ this.inverseBindMatrixBuffer,
1495
+ 0,
1496
+ invBindMatrices.buffer,
1497
+ invBindMatrices.byteOffset,
1498
+ invBindMatrices.byteLength
1499
+ )
1500
+
1501
+ this.boneCountBuffer = this.device.createBuffer({
1502
+ label: "bone count uniform",
1503
+ size: 32, // Minimum uniform buffer size is 32 bytes
1504
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1505
+ })
1506
+ const boneCountData = new Uint32Array(8) // 32 bytes total
1507
+ boneCountData[0] = boneCount
1508
+ this.device.queue.writeBuffer(this.boneCountBuffer, 0, boneCountData)
1509
+
1510
+ this.createSkinMatrixComputePipeline()
1511
+
1512
+ // Create compute bind group once (reused every frame)
1513
+ this.skinMatrixComputeBindGroup = this.device.createBindGroup({
1514
+ layout: this.skinMatrixComputePipeline!.getBindGroupLayout(0),
1515
+ entries: [
1516
+ { binding: 0, resource: { buffer: this.boneCountBuffer } },
1517
+ { binding: 1, resource: { buffer: this.worldMatrixBuffer } },
1518
+ { binding: 2, resource: { buffer: this.inverseBindMatrixBuffer } },
1519
+ { binding: 3, resource: { buffer: this.skinMatrixBuffer } },
1520
+ ],
1521
+ })
1522
+
1523
+ const indices = model.getIndices()
1524
+ if (indices) {
1525
+ this.indexBuffer = this.device.createBuffer({
1526
+ label: "model index buffer",
1527
+ size: indices.byteLength,
1528
+ usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
1529
+ })
1530
+ this.device.queue.writeBuffer(this.indexBuffer, 0, indices)
1531
+ } else {
1532
+ throw new Error("Model has no index buffer")
1533
+ }
1534
+
1535
+ await this.setupMaterials(model)
1536
+ }
1537
+
1538
+ private opaqueNonEyeNonHairDraws: {
1539
+ count: number
1540
+ firstIndex: number
1541
+ bindGroup: GPUBindGroup
1542
+ isTransparent: boolean
1543
+ }[] = []
1544
+ private eyeDraws: { count: number; firstIndex: number; bindGroup: GPUBindGroup; isTransparent: boolean }[] = []
1545
+ private hairDrawsOverEyes: { count: number; firstIndex: number; bindGroup: GPUBindGroup; isTransparent: boolean }[] =
1546
+ []
1547
+ private hairDrawsOverNonEyes: {
1548
+ count: number
1549
+ firstIndex: number
1550
+ bindGroup: GPUBindGroup
1551
+ isTransparent: boolean
1552
+ }[] = []
1553
+ private transparentNonEyeNonHairDraws: {
1554
+ count: number
1555
+ firstIndex: number
1556
+ bindGroup: GPUBindGroup
1557
+ isTransparent: boolean
1558
+ }[] = []
1559
+ private opaqueNonEyeNonHairOutlineDraws: {
1560
+ count: number
1561
+ firstIndex: number
1562
+ bindGroup: GPUBindGroup
1563
+ isTransparent: boolean
1564
+ }[] = []
1565
+ private eyeOutlineDraws: { count: number; firstIndex: number; bindGroup: GPUBindGroup; isTransparent: boolean }[] = []
1566
+ private hairOutlineDraws: { count: number; firstIndex: number; bindGroup: GPUBindGroup; isTransparent: boolean }[] =
1567
+ []
1568
+ private transparentNonEyeNonHairOutlineDraws: {
1569
+ count: number
1570
+ firstIndex: number
1571
+ bindGroup: GPUBindGroup
1572
+ isTransparent: boolean
1573
+ }[] = []
1574
+
1575
+ // Step 8: Load textures and create material bind groups
1576
+ private async setupMaterials(model: Model) {
1577
+ const materials = model.getMaterials()
1578
+ if (materials.length === 0) {
1579
+ throw new Error("Model has no materials")
1580
+ }
1581
+
1582
+ const textures = model.getTextures()
1583
+
1584
+ const loadTextureByIndex = async (texIndex: number): Promise<GPUTexture | null> => {
1585
+ if (texIndex < 0 || texIndex >= textures.length) {
1586
+ return null
1587
+ }
1588
+
1589
+ const path = this.modelDir + textures[texIndex].path
1590
+ const texture = await this.createTextureFromPath(path)
1591
+ return texture
1592
+ }
1593
+
1594
+ const loadToonTexture = async (toonTextureIndex: number): Promise<GPUTexture> => {
1595
+ const texture = await loadTextureByIndex(toonTextureIndex)
1596
+ if (texture) return texture
1597
+
1598
+ // Default toon texture fallback - cache it
1599
+ const defaultToonPath = "__default_toon__"
1600
+ const cached = this.textureCache.get(defaultToonPath)
1601
+ if (cached) return cached
1602
+
1603
+ const defaultToonData = new Uint8Array(256 * 2 * 4)
1604
+ for (let i = 0; i < 256; i++) {
1605
+ const factor = i / 255.0
1606
+ const gray = Math.floor(128 + factor * 127)
1607
+ defaultToonData[i * 4] = gray
1608
+ defaultToonData[i * 4 + 1] = gray
1609
+ defaultToonData[i * 4 + 2] = gray
1610
+ defaultToonData[i * 4 + 3] = 255
1611
+ defaultToonData[(256 + i) * 4] = gray
1612
+ defaultToonData[(256 + i) * 4 + 1] = gray
1613
+ defaultToonData[(256 + i) * 4 + 2] = gray
1614
+ defaultToonData[(256 + i) * 4 + 3] = 255
1615
+ }
1616
+ const defaultToonTexture = this.device.createTexture({
1617
+ label: "default toon texture",
1618
+ size: [256, 2],
1619
+ format: "rgba8unorm",
1620
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
1621
+ })
1622
+ this.device.queue.writeTexture(
1623
+ { texture: defaultToonTexture },
1624
+ defaultToonData,
1625
+ { bytesPerRow: 256 * 4 },
1626
+ [256, 2]
1627
+ )
1628
+ this.textureCache.set(defaultToonPath, defaultToonTexture)
1629
+ this.textureSizes.set(defaultToonPath, { width: 256, height: 2 })
1630
+ return defaultToonTexture
1631
+ }
1632
+
1633
+ this.opaqueNonEyeNonHairDraws = []
1634
+ this.eyeDraws = []
1635
+ this.hairDrawsOverEyes = []
1636
+ this.hairDrawsOverNonEyes = []
1637
+ this.transparentNonEyeNonHairDraws = []
1638
+ this.opaqueNonEyeNonHairOutlineDraws = []
1639
+ this.eyeOutlineDraws = []
1640
+ this.hairOutlineDraws = []
1641
+ this.transparentNonEyeNonHairOutlineDraws = []
1642
+ let runningFirstIndex = 0
1643
+
1644
+ for (const mat of materials) {
1645
+ const matCount = mat.vertexCount | 0
1646
+ if (matCount === 0) continue
1647
+
1648
+ const diffuseTexture = await loadTextureByIndex(mat.diffuseTextureIndex)
1649
+ if (!diffuseTexture) throw new Error(`Material "${mat.name}" has no diffuse texture`)
1650
+
1651
+ const toonTexture = await loadToonTexture(mat.toonTextureIndex)
1652
+
1653
+ const materialAlpha = mat.diffuse[3]
1654
+ const EPSILON = 0.001
1655
+ const isTransparent = materialAlpha < 1.0 - EPSILON
1656
+
1657
+ // Create material uniform data
1658
+ const materialUniformData = new Float32Array(8)
1659
+ materialUniformData[0] = materialAlpha
1660
+ materialUniformData[1] = 1.0 // alphaMultiplier: 1.0 for non-hair materials
1661
+ materialUniformData[2] = this.rimLightIntensity
1662
+ materialUniformData[3] = this.rimLightPower
1663
+ materialUniformData[4] = this.rimLightColor[0] // rimColor.r
1664
+ materialUniformData[5] = this.rimLightColor[1] // rimColor.g
1665
+ materialUniformData[6] = this.rimLightColor[2] // rimColor.b
1666
+ materialUniformData[7] = 0.0
1667
+
1668
+ const materialUniformBuffer = this.device.createBuffer({
1669
+ label: `material uniform: ${mat.name}`,
1670
+ size: materialUniformData.byteLength,
1671
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1672
+ })
1673
+ this.device.queue.writeBuffer(materialUniformBuffer, 0, materialUniformData)
1674
+
1675
+ // Create bind groups using the shared bind group layout - All pipelines (main, eye, hair multiply, hair opaque) use the same shader and layout
1676
+ const bindGroup = this.device.createBindGroup({
1677
+ label: `material bind group: ${mat.name}`,
1678
+ layout: this.hairBindGroupLayout,
1679
+ entries: [
1680
+ { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1681
+ { binding: 1, resource: { buffer: this.lightUniformBuffer } },
1682
+ { binding: 2, resource: diffuseTexture.createView() },
1683
+ { binding: 3, resource: this.textureSampler },
1684
+ { binding: 4, resource: { buffer: this.skinMatrixBuffer! } },
1685
+ { binding: 5, resource: toonTexture.createView() },
1686
+ { binding: 6, resource: this.textureSampler },
1687
+ { binding: 7, resource: { buffer: materialUniformBuffer } },
1688
+ ],
1689
+ })
1690
+
1691
+ // Classify materials into appropriate draw lists
1692
+ if (mat.isEye) {
1693
+ this.eyeDraws.push({
1694
+ count: matCount,
1695
+ firstIndex: runningFirstIndex,
1696
+ bindGroup,
1697
+ isTransparent,
1698
+ })
1699
+ } else if (mat.isHair) {
1700
+ // Hair materials: create bind groups for unified pipeline with dynamic branching
1701
+ const materialUniformDataHair = new Float32Array(8)
1702
+ materialUniformDataHair[0] = materialAlpha
1703
+ materialUniformDataHair[1] = 1.0 // alphaMultiplier: base value, shader will adjust
1704
+ materialUniformDataHair[2] = this.rimLightIntensity
1705
+ materialUniformDataHair[3] = this.rimLightPower
1706
+ materialUniformDataHair[4] = this.rimLightColor[0] // rimColor.r
1707
+ materialUniformDataHair[5] = this.rimLightColor[1] // rimColor.g
1708
+ materialUniformDataHair[6] = this.rimLightColor[2] // rimColor.b
1709
+ materialUniformDataHair[7] = 0.0
1710
+
1711
+ // Create uniform buffers for both modes
1712
+ const materialUniformBufferOverEyes = this.device.createBuffer({
1713
+ label: `material uniform (over eyes): ${mat.name}`,
1714
+ size: materialUniformDataHair.byteLength,
1715
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1716
+ })
1717
+ const materialUniformDataOverEyes = new Float32Array(materialUniformDataHair)
1718
+ materialUniformDataOverEyes[7] = 1.0
1719
+ this.device.queue.writeBuffer(materialUniformBufferOverEyes, 0, materialUniformDataOverEyes)
1720
+
1721
+ const materialUniformBufferOverNonEyes = this.device.createBuffer({
1722
+ label: `material uniform (over non-eyes): ${mat.name}`,
1723
+ size: materialUniformDataHair.byteLength,
1724
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1725
+ })
1726
+ const materialUniformDataOverNonEyes = new Float32Array(materialUniformDataHair)
1727
+ materialUniformDataOverNonEyes[7] = 0.0
1728
+ this.device.queue.writeBuffer(materialUniformBufferOverNonEyes, 0, materialUniformDataOverNonEyes)
1729
+
1730
+ // Create bind groups for both modes
1731
+ const bindGroupOverEyes = this.device.createBindGroup({
1732
+ label: `material bind group (over eyes): ${mat.name}`,
1733
+ layout: this.hairBindGroupLayout,
1734
+ entries: [
1735
+ { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1736
+ { binding: 1, resource: { buffer: this.lightUniformBuffer } },
1737
+ { binding: 2, resource: diffuseTexture.createView() },
1738
+ { binding: 3, resource: this.textureSampler },
1739
+ { binding: 4, resource: { buffer: this.skinMatrixBuffer! } },
1740
+ { binding: 5, resource: toonTexture.createView() },
1741
+ { binding: 6, resource: this.textureSampler },
1742
+ { binding: 7, resource: { buffer: materialUniformBufferOverEyes } },
1743
+ ],
1744
+ })
1745
+
1746
+ const bindGroupOverNonEyes = this.device.createBindGroup({
1747
+ label: `material bind group (over non-eyes): ${mat.name}`,
1748
+ layout: this.hairBindGroupLayout,
1749
+ entries: [
1750
+ { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1751
+ { binding: 1, resource: { buffer: this.lightUniformBuffer } },
1752
+ { binding: 2, resource: diffuseTexture.createView() },
1753
+ { binding: 3, resource: this.textureSampler },
1754
+ { binding: 4, resource: { buffer: this.skinMatrixBuffer! } },
1755
+ { binding: 5, resource: toonTexture.createView() },
1756
+ { binding: 6, resource: this.textureSampler },
1757
+ { binding: 7, resource: { buffer: materialUniformBufferOverNonEyes } },
1758
+ ],
1759
+ })
1760
+
1761
+ // Store both bind groups for unified pipeline
1762
+ this.hairDrawsOverEyes.push({
1763
+ count: matCount,
1764
+ firstIndex: runningFirstIndex,
1765
+ bindGroup: bindGroupOverEyes,
1766
+ isTransparent,
1767
+ })
1768
+
1769
+ this.hairDrawsOverNonEyes.push({
1770
+ count: matCount,
1771
+ firstIndex: runningFirstIndex,
1772
+ bindGroup: bindGroupOverNonEyes,
1773
+ isTransparent,
1774
+ })
1775
+ } else if (isTransparent) {
1776
+ this.transparentNonEyeNonHairDraws.push({
1777
+ count: matCount,
1778
+ firstIndex: runningFirstIndex,
1779
+ bindGroup,
1780
+ isTransparent,
1781
+ })
1782
+ } else {
1783
+ this.opaqueNonEyeNonHairDraws.push({
1784
+ count: matCount,
1785
+ firstIndex: runningFirstIndex,
1786
+ bindGroup,
1787
+ isTransparent,
1788
+ })
1789
+ }
1790
+
1791
+ // Outline for all materials (including transparent) - Edge flag is at bit 4 (0x10) in PMX format, not bit 0 (0x01)
1792
+ if ((mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
1793
+ const materialUniformData = new Float32Array(8)
1794
+ materialUniformData[0] = mat.edgeColor[0] // edgeColor.r
1795
+ materialUniformData[1] = mat.edgeColor[1] // edgeColor.g
1796
+ materialUniformData[2] = mat.edgeColor[2] // edgeColor.b
1797
+ materialUniformData[3] = mat.edgeColor[3] // edgeColor.a
1798
+ materialUniformData[4] = mat.edgeSize
1799
+ materialUniformData[5] = 0.0 // isOverEyes: 0.0 for all (unified pipeline doesn't use stencil)
1800
+ materialUniformData[6] = 0.0 // _padding1
1801
+ materialUniformData[7] = 0.0 // _padding2
1802
+
1803
+ const materialUniformBuffer = this.device.createBuffer({
1804
+ label: `outline material uniform: ${mat.name}`,
1805
+ size: materialUniformData.byteLength,
1806
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1807
+ })
1808
+ this.device.queue.writeBuffer(materialUniformBuffer, 0, materialUniformData)
1809
+
1810
+ const outlineBindGroup = this.device.createBindGroup({
1811
+ label: `outline bind group: ${mat.name}`,
1812
+ layout: this.outlineBindGroupLayout,
1813
+ entries: [
1814
+ { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1815
+ { binding: 1, resource: { buffer: materialUniformBuffer } },
1816
+ { binding: 2, resource: { buffer: this.skinMatrixBuffer! } },
1817
+ ],
1818
+ })
1819
+
1820
+ // Classify outlines into appropriate draw lists
1821
+ if (mat.isEye) {
1822
+ this.eyeOutlineDraws.push({
1823
+ count: matCount,
1824
+ firstIndex: runningFirstIndex,
1825
+ bindGroup: outlineBindGroup,
1826
+ isTransparent,
1827
+ })
1828
+ } else if (mat.isHair) {
1829
+ this.hairOutlineDraws.push({
1830
+ count: matCount,
1831
+ firstIndex: runningFirstIndex,
1832
+ bindGroup: outlineBindGroup,
1833
+ isTransparent,
1834
+ })
1835
+ } else if (isTransparent) {
1836
+ this.transparentNonEyeNonHairOutlineDraws.push({
1837
+ count: matCount,
1838
+ firstIndex: runningFirstIndex,
1839
+ bindGroup: outlineBindGroup,
1840
+ isTransparent,
1841
+ })
1842
+ } else {
1843
+ this.opaqueNonEyeNonHairOutlineDraws.push({
1844
+ count: matCount,
1845
+ firstIndex: runningFirstIndex,
1846
+ bindGroup: outlineBindGroup,
1847
+ isTransparent,
1848
+ })
1849
+ }
1850
+ }
1851
+
1852
+ runningFirstIndex += matCount
1853
+ }
1854
+ }
1855
+
1856
+ // Helper: Load texture from file path with optional max size limit
1857
+ private async createTextureFromPath(path: string, maxSize: number = 2048): Promise<GPUTexture | null> {
1858
+ const cached = this.textureCache.get(path)
1859
+ if (cached) {
1860
+ return cached
1861
+ }
1862
+
1863
+ try {
1864
+ const response = await fetch(path)
1865
+ if (!response.ok) {
1866
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`)
1867
+ }
1868
+ let imageBitmap = await createImageBitmap(await response.blob(), {
1869
+ premultiplyAlpha: "none",
1870
+ colorSpaceConversion: "none",
1871
+ })
1872
+
1873
+ // Downscale if texture is too large
1874
+ let finalWidth = imageBitmap.width
1875
+ let finalHeight = imageBitmap.height
1876
+ if (finalWidth > maxSize || finalHeight > maxSize) {
1877
+ const scale = Math.min(maxSize / finalWidth, maxSize / finalHeight)
1878
+ finalWidth = Math.floor(finalWidth * scale)
1879
+ finalHeight = Math.floor(finalHeight * scale)
1880
+
1881
+ // Create canvas to downscale
1882
+ const canvas = new OffscreenCanvas(finalWidth, finalHeight)
1883
+ const ctx = canvas.getContext("2d")
1884
+ if (ctx) {
1885
+ ctx.drawImage(imageBitmap, 0, 0, finalWidth, finalHeight)
1886
+ imageBitmap = await createImageBitmap(canvas)
1887
+ }
1888
+ }
1889
+
1890
+ const texture = this.device.createTexture({
1891
+ label: `texture: ${path}`,
1892
+ size: [finalWidth, finalHeight],
1893
+ format: "rgba8unorm",
1894
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
1895
+ })
1896
+ this.device.queue.copyExternalImageToTexture({ source: imageBitmap }, { texture }, [finalWidth, finalHeight])
1897
+
1898
+ this.textureCache.set(path, texture)
1899
+ this.textureSizes.set(path, { width: finalWidth, height: finalHeight })
1900
+ return texture
1901
+ } catch {
1902
+ return null
1903
+ }
1904
+ }
1905
+
1906
+ // Step 9: Render one frame
1907
+ public render() {
1908
+ if (this.multisampleTexture && this.camera && this.device && this.currentModel) {
1909
+ const currentTime = performance.now()
1910
+ const deltaTime = this.lastFrameTime > 0 ? (currentTime - this.lastFrameTime) / 1000 : 0.016
1911
+ this.lastFrameTime = currentTime
1912
+
1913
+ this.updateCameraUniforms()
1914
+ this.updateRenderTarget()
1915
+
1916
+ this.updateModelPose(deltaTime)
1917
+
1918
+ const encoder = this.device.createCommandEncoder()
1919
+ const pass = encoder.beginRenderPass(this.renderPassDescriptor)
1920
+
1921
+ pass.setVertexBuffer(0, this.vertexBuffer)
1922
+ pass.setVertexBuffer(1, this.jointsBuffer)
1923
+ pass.setVertexBuffer(2, this.weightsBuffer)
1924
+ pass.setIndexBuffer(this.indexBuffer!, "uint32")
1925
+
1926
+ this.drawCallCount = 0
1927
+
1928
+ // PASS 1: Opaque non-eye, non-hair
1929
+ pass.setPipeline(this.pipeline)
1930
+ for (const draw of this.opaqueNonEyeNonHairDraws) {
1931
+ if (draw.count > 0) {
1932
+ pass.setBindGroup(0, draw.bindGroup)
1933
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1934
+ this.drawCallCount++
1935
+ }
1936
+ }
1937
+
1938
+ // PASS 2: Eyes (writes stencil = 1)
1939
+ pass.setPipeline(this.eyePipeline)
1940
+ pass.setStencilReference(1) // Set stencil reference value to 1
1941
+ for (const draw of this.eyeDraws) {
1942
+ if (draw.count > 0) {
1943
+ pass.setBindGroup(0, draw.bindGroup)
1944
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1945
+ this.drawCallCount++
1946
+ }
1947
+ }
1948
+
1949
+ // PASS 3: Hair rendering with depth pre-pass and unified pipeline
1950
+ this.drawOutlines(pass, false)
1951
+
1952
+ // 3a: Hair depth pre-pass (eliminates overdraw by rejecting fragments early)
1953
+ if (this.hairDrawsOverEyes.length > 0 || this.hairDrawsOverNonEyes.length > 0) {
1954
+ pass.setPipeline(this.hairDepthPipeline)
1955
+ for (const draw of this.hairDrawsOverEyes) {
1956
+ if (draw.count > 0) {
1957
+ pass.setBindGroup(0, draw.bindGroup)
1958
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1959
+ }
1960
+ }
1961
+ for (const draw of this.hairDrawsOverNonEyes) {
1962
+ if (draw.count > 0) {
1963
+ pass.setBindGroup(0, draw.bindGroup)
1964
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1965
+ }
1966
+ }
1967
+ }
1968
+
1969
+ // 3b: Hair shading pass with unified pipeline and dynamic branching
1970
+ if (this.hairDrawsOverEyes.length > 0) {
1971
+ pass.setPipeline(this.hairUnifiedPipelineOverEyes)
1972
+ pass.setStencilReference(1)
1973
+ for (const draw of this.hairDrawsOverEyes) {
1974
+ if (draw.count > 0) {
1975
+ pass.setBindGroup(0, draw.bindGroup)
1976
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1977
+ this.drawCallCount++
1978
+ }
1979
+ }
1980
+ }
1981
+
1982
+ if (this.hairDrawsOverNonEyes.length > 0) {
1983
+ pass.setPipeline(this.hairUnifiedPipelineOverNonEyes)
1984
+ pass.setStencilReference(1)
1985
+ for (const draw of this.hairDrawsOverNonEyes) {
1986
+ if (draw.count > 0) {
1987
+ pass.setBindGroup(0, draw.bindGroup)
1988
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1989
+ this.drawCallCount++
1990
+ }
1991
+ }
1992
+ }
1993
+
1994
+ // 3c: Hair outlines - unified single pass without stencil testing
1995
+ if (this.hairOutlineDraws.length > 0) {
1996
+ pass.setPipeline(this.hairUnifiedOutlinePipeline)
1997
+ for (const draw of this.hairOutlineDraws) {
1998
+ if (draw.count > 0) {
1999
+ pass.setBindGroup(0, draw.bindGroup)
2000
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
2001
+ }
2002
+ }
2003
+ }
2004
+
2005
+ // PASS 4: Transparent non-eye, non-hair
2006
+ pass.setPipeline(this.pipeline)
2007
+ for (const draw of this.transparentNonEyeNonHairDraws) {
2008
+ if (draw.count > 0) {
2009
+ pass.setBindGroup(0, draw.bindGroup)
2010
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
2011
+ this.drawCallCount++
2012
+ }
2013
+ }
2014
+
2015
+ this.drawOutlines(pass, true)
2016
+
2017
+ pass.end()
2018
+ this.device.queue.submit([encoder.finish()])
2019
+
2020
+ // Apply bloom post-processing
2021
+ this.applyBloom()
2022
+
2023
+ this.updateStats(performance.now() - currentTime)
2024
+ }
2025
+ }
2026
+
2027
+ // Apply bloom post-processing
2028
+ private applyBloom() {
2029
+ if (!this.sceneRenderTexture || !this.bloomExtractTexture) {
2030
+ return
2031
+ }
2032
+
2033
+ // Update bloom parameters
2034
+ const thresholdData = new Float32Array(8)
2035
+ thresholdData[0] = this.bloomThreshold
2036
+ this.device.queue.writeBuffer(this.bloomThresholdBuffer, 0, thresholdData)
2037
+
2038
+ const intensityData = new Float32Array(8)
2039
+ intensityData[0] = this.bloomIntensity
2040
+ this.device.queue.writeBuffer(this.bloomIntensityBuffer, 0, intensityData)
2041
+
2042
+ const encoder = this.device.createCommandEncoder()
2043
+ const width = this.canvas.width
2044
+ const height = this.canvas.height
2045
+ const bloomWidth = Math.floor(width / 2)
2046
+ const bloomHeight = Math.floor(height / 2)
2047
+
2048
+ // Pass 1: Extract bright areas (downsample to half resolution)
2049
+ const extractPass = encoder.beginRenderPass({
2050
+ label: "bloom extract",
2051
+ colorAttachments: [
2052
+ {
2053
+ view: this.bloomExtractTexture.createView(),
2054
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
2055
+ loadOp: "clear",
2056
+ storeOp: "store",
2057
+ },
2058
+ ],
2059
+ })
2060
+
2061
+ extractPass.setPipeline(this.bloomExtractPipeline)
2062
+ extractPass.setBindGroup(0, this.bloomExtractBindGroup!)
2063
+ extractPass.draw(6, 1, 0, 0)
2064
+ extractPass.end()
2065
+
2066
+ // Pass 2: Horizontal blur
2067
+ const hBlurData = new Float32Array(4) // vec2f + padding = 4 floats
2068
+ hBlurData[0] = 1.0
2069
+ hBlurData[1] = 0.0
2070
+ this.device.queue.writeBuffer(this.blurDirectionBuffer, 0, hBlurData)
2071
+ const blurHPass = encoder.beginRenderPass({
2072
+ label: "bloom blur horizontal",
2073
+ colorAttachments: [
2074
+ {
2075
+ view: this.bloomBlurTexture1.createView(),
2076
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
2077
+ loadOp: "clear",
2078
+ storeOp: "store",
2079
+ },
2080
+ ],
2081
+ })
2082
+
2083
+ blurHPass.setPipeline(this.bloomBlurPipeline)
2084
+ blurHPass.setBindGroup(0, this.bloomBlurHBindGroup!)
2085
+ blurHPass.draw(6, 1, 0, 0)
2086
+ blurHPass.end()
2087
+
2088
+ // Pass 3: Vertical blur
2089
+ const vBlurData = new Float32Array(4) // vec2f + padding = 4 floats
2090
+ vBlurData[0] = 0.0
2091
+ vBlurData[1] = 1.0
2092
+ this.device.queue.writeBuffer(this.blurDirectionBuffer, 0, vBlurData)
2093
+ const blurVPass = encoder.beginRenderPass({
2094
+ label: "bloom blur vertical",
2095
+ colorAttachments: [
2096
+ {
2097
+ view: this.bloomBlurTexture2.createView(),
2098
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
2099
+ loadOp: "clear",
2100
+ storeOp: "store",
2101
+ },
2102
+ ],
2103
+ })
2104
+
2105
+ blurVPass.setPipeline(this.bloomBlurPipeline)
2106
+ blurVPass.setBindGroup(0, this.bloomBlurVBindGroup!)
2107
+ blurVPass.draw(6, 1, 0, 0)
2108
+ blurVPass.end()
2109
+
2110
+ // Pass 4: Compose scene + bloom to canvas
2111
+ const composePass = encoder.beginRenderPass({
2112
+ label: "bloom compose",
2113
+ colorAttachments: [
2114
+ {
2115
+ view: this.context.getCurrentTexture().createView(),
2116
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
2117
+ loadOp: "clear",
2118
+ storeOp: "store",
2119
+ },
2120
+ ],
2121
+ })
2122
+
2123
+ composePass.setPipeline(this.bloomComposePipeline)
2124
+ composePass.setBindGroup(0, this.bloomComposeBindGroup!)
2125
+ composePass.draw(6, 1, 0, 0)
2126
+ composePass.end()
2127
+
2128
+ this.device.queue.submit([encoder.finish()])
2129
+ }
2130
+
2131
+ // Update camera uniform buffer each frame
2132
+ private updateCameraUniforms() {
2133
+ const viewMatrix = this.camera.getViewMatrix()
2134
+ const projectionMatrix = this.camera.getProjectionMatrix()
2135
+ const cameraPos = this.camera.getPosition()
2136
+ this.cameraMatrixData.set(viewMatrix.values, 0)
2137
+ this.cameraMatrixData.set(projectionMatrix.values, 16)
2138
+ this.cameraMatrixData[32] = cameraPos.x
2139
+ this.cameraMatrixData[33] = cameraPos.y
2140
+ this.cameraMatrixData[34] = cameraPos.z
2141
+ this.device.queue.writeBuffer(this.cameraUniformBuffer, 0, this.cameraMatrixData)
2142
+ }
2143
+
2144
+ // Update render target texture view
2145
+ private updateRenderTarget() {
2146
+ const colorAttachment = (this.renderPassDescriptor.colorAttachments as GPURenderPassColorAttachment[])[0]
2147
+ if (this.sampleCount > 1) {
2148
+ // Resolve to scene render texture for post-processing
2149
+ colorAttachment.resolveTarget = this.sceneRenderTextureView
2150
+ } else {
2151
+ // Render directly to scene render texture
2152
+ colorAttachment.view = this.sceneRenderTextureView
2153
+ }
2154
+ }
2155
+
2156
+ // Update model pose and physics
2157
+ private updateModelPose(deltaTime: number) {
2158
+ // Step 1: Animation evaluation (computes matrices to CPU memory, no upload yet)
2159
+ this.currentModel!.evaluatePose()
2160
+
2161
+ // Step 2: Get world matrices (still in CPU memory)
2162
+ const worldMats = this.currentModel!.getBoneWorldMatrices()
2163
+
2164
+ // Step 3: Physics modifies matrices in-place
2165
+ if (this.physics) {
2166
+ this.physics.step(deltaTime, worldMats, this.currentModel!.getBoneInverseBindMatrices())
2167
+ }
2168
+
2169
+ // Step 4: Upload ONCE with final result (animation + physics)
2170
+ this.device.queue.writeBuffer(
2171
+ this.worldMatrixBuffer!,
2172
+ 0,
2173
+ worldMats.buffer,
2174
+ worldMats.byteOffset,
2175
+ worldMats.byteLength
2176
+ )
2177
+
2178
+ // Step 5: GPU skinning
2179
+ this.computeSkinMatrices()
2180
+ }
2181
+
2182
+ // Compute skin matrices on GPU
2183
+ private computeSkinMatrices() {
2184
+ const boneCount = this.currentModel!.getSkeleton().bones.length
2185
+ const workgroupSize = 64
2186
+ // Dispatch exactly enough threads for all bones (no bounds check needed)
2187
+ const workgroupCount = Math.ceil(boneCount / workgroupSize)
2188
+
2189
+ // Bone count is written once in setupModelBuffers() and never changes
2190
+
2191
+ const encoder = this.device.createCommandEncoder()
2192
+ const pass = encoder.beginComputePass()
2193
+ pass.setPipeline(this.skinMatrixComputePipeline!)
2194
+ pass.setBindGroup(0, this.skinMatrixComputeBindGroup!)
2195
+ pass.dispatchWorkgroups(workgroupCount)
2196
+ pass.end()
2197
+ this.device.queue.submit([encoder.finish()])
2198
+ }
2199
+
2200
+ // Draw outlines (opaque or transparent)
2201
+ private drawOutlines(pass: GPURenderPassEncoder, transparent: boolean) {
2202
+ pass.setPipeline(this.outlinePipeline)
2203
+ if (transparent) {
2204
+ // Draw transparent outlines (if any)
2205
+ for (const draw of this.transparentNonEyeNonHairOutlineDraws) {
2206
+ if (draw.count > 0) {
2207
+ pass.setBindGroup(0, draw.bindGroup)
2208
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
2209
+ }
2210
+ }
2211
+ } else {
2212
+ // Draw opaque outlines before main geometry
2213
+ for (const draw of this.opaqueNonEyeNonHairOutlineDraws) {
2214
+ if (draw.count > 0) {
2215
+ pass.setBindGroup(0, draw.bindGroup)
2216
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
2217
+ }
2218
+ }
2219
+ }
2220
+ }
2221
+
2222
+ private updateStats(frameTime: number) {
2223
+ const maxSamples = 60
2224
+ this.frameTimeSamples.push(frameTime)
2225
+ this.frameTimeSum += frameTime
2226
+ if (this.frameTimeSamples.length > maxSamples) {
2227
+ const removed = this.frameTimeSamples.shift()!
2228
+ this.frameTimeSum -= removed
2229
+ }
2230
+ const avgFrameTime = this.frameTimeSum / this.frameTimeSamples.length
2231
+ this.stats.frameTime = Math.round(avgFrameTime * 100) / 100
2232
+
2233
+ const now = performance.now()
2234
+ this.framesSinceLastUpdate++
2235
+ const elapsed = now - this.lastFpsUpdate
2236
+
2237
+ if (elapsed >= 1000) {
2238
+ this.stats.fps = Math.round((this.framesSinceLastUpdate / elapsed) * 1000)
2239
+ this.framesSinceLastUpdate = 0
2240
+ this.lastFpsUpdate = now
2241
+ }
2242
+
2243
+ // Calculate GPU memory: textures + buffers + render targets
2244
+ let textureMemoryBytes = 0
2245
+ for (const [path, size] of this.textureSizes.entries()) {
2246
+ if (this.textureCache.has(path)) {
2247
+ textureMemoryBytes += size.width * size.height * 4 // RGBA8 = 4 bytes per pixel
2248
+ }
2249
+ }
2250
+
2251
+ let bufferMemoryBytes = 0
2252
+ if (this.vertexBuffer) {
2253
+ const vertices = this.currentModel?.getVertices()
2254
+ if (vertices) bufferMemoryBytes += vertices.byteLength
2255
+ }
2256
+ if (this.indexBuffer) {
2257
+ const indices = this.currentModel?.getIndices()
2258
+ if (indices) bufferMemoryBytes += indices.byteLength
2259
+ }
2260
+ if (this.jointsBuffer) {
2261
+ const skinning = this.currentModel?.getSkinning()
2262
+ if (skinning) bufferMemoryBytes += skinning.joints.byteLength
2263
+ }
2264
+ if (this.weightsBuffer) {
2265
+ const skinning = this.currentModel?.getSkinning()
2266
+ if (skinning) bufferMemoryBytes += skinning.weights.byteLength
2267
+ }
2268
+ if (this.skinMatrixBuffer) {
2269
+ const skeleton = this.currentModel?.getSkeleton()
2270
+ if (skeleton) bufferMemoryBytes += Math.max(256, skeleton.bones.length * 16 * 4)
2271
+ }
2272
+ if (this.worldMatrixBuffer) {
2273
+ const skeleton = this.currentModel?.getSkeleton()
2274
+ if (skeleton) bufferMemoryBytes += Math.max(256, skeleton.bones.length * 16 * 4)
2275
+ }
2276
+ if (this.inverseBindMatrixBuffer) {
2277
+ const skeleton = this.currentModel?.getSkeleton()
2278
+ if (skeleton) bufferMemoryBytes += Math.max(256, skeleton.bones.length * 16 * 4)
2279
+ }
2280
+ bufferMemoryBytes += 40 * 4 // cameraUniformBuffer
2281
+ bufferMemoryBytes += 64 * 4 // lightUniformBuffer
2282
+ bufferMemoryBytes += 32 // boneCountBuffer
2283
+ bufferMemoryBytes += 32 // blurDirectionBuffer
2284
+ bufferMemoryBytes += 32 // bloomIntensityBuffer
2285
+ bufferMemoryBytes += 32 // bloomThresholdBuffer
2286
+ if (this.fullscreenQuadBuffer) {
2287
+ bufferMemoryBytes += 24 * 4 // fullscreenQuadBuffer (6 vertices * 4 floats)
2288
+ }
2289
+
2290
+ // Material uniform buffers: Float32Array(8) = 32 bytes each
2291
+ const totalMaterialDraws =
2292
+ this.opaqueNonEyeNonHairDraws.length +
2293
+ this.eyeDraws.length +
2294
+ this.hairDrawsOverEyes.length +
2295
+ this.hairDrawsOverNonEyes.length +
2296
+ this.transparentNonEyeNonHairDraws.length
2297
+ bufferMemoryBytes += totalMaterialDraws * 32 // Material uniform buffers (8 floats = 32 bytes)
2298
+
2299
+ // Outline material uniform buffers: Float32Array(8) = 32 bytes each
2300
+ const totalOutlineDraws =
2301
+ this.opaqueNonEyeNonHairOutlineDraws.length +
2302
+ this.eyeOutlineDraws.length +
2303
+ this.hairOutlineDraws.length +
2304
+ this.transparentNonEyeNonHairOutlineDraws.length
2305
+ bufferMemoryBytes += totalOutlineDraws * 32 // Outline material uniform buffers
2306
+
2307
+ let renderTargetMemoryBytes = 0
2308
+ if (this.multisampleTexture) {
2309
+ const width = this.canvas.width
2310
+ const height = this.canvas.height
2311
+ renderTargetMemoryBytes += width * height * 4 * this.sampleCount // multisample color
2312
+ renderTargetMemoryBytes += width * height * 4 // depth (depth24plus-stencil8 = 4 bytes)
2313
+ }
2314
+ if (this.sceneRenderTexture) {
2315
+ const width = this.canvas.width
2316
+ const height = this.canvas.height
2317
+ renderTargetMemoryBytes += width * height * 4 // sceneRenderTexture (non-multisampled)
2318
+ }
2319
+ if (this.bloomExtractTexture) {
2320
+ const width = Math.floor(this.canvas.width / 2)
2321
+ const height = Math.floor(this.canvas.height / 2)
2322
+ renderTargetMemoryBytes += width * height * 4 // bloomExtractTexture
2323
+ renderTargetMemoryBytes += width * height * 4 // bloomBlurTexture1
2324
+ renderTargetMemoryBytes += width * height * 4 // bloomBlurTexture2
2325
+ }
2326
+
2327
+ const totalGPUMemoryBytes = textureMemoryBytes + bufferMemoryBytes + renderTargetMemoryBytes
2328
+ this.stats.gpuMemory = Math.round((totalGPUMemoryBytes / 1024 / 1024) * 100) / 100
2329
+ }
2330
+ }