reze-engine 0.2.17 → 0.2.19

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