reze-engine 0.2.17 → 0.2.18

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