reze-engine 0.2.11 → 0.2.13

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