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