reze-engine 0.2.3 → 0.2.4

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