reze-engine 0.1.11 → 0.1.13

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