reze-engine 0.2.0 → 0.2.2

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