reze-engine 0.2.4 → 0.2.5

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