reze-engine 0.1.7 → 0.1.8

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