reze-engine 0.1.12 → 0.1.14

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