reze-engine 0.1.15 → 0.1.16

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