reze-engine 0.2.4 → 0.2.6

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