reze-engine 0.2.14 → 0.2.16

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