reze-engine 0.2.10 → 0.2.12

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