reze-engine 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/engine.ts CHANGED
@@ -1,1170 +1,1158 @@
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
- memoryUsed: number // MB (JS heap)
11
- vertices: number
12
- drawCalls: number
13
- triangles: number
14
- materials: number
15
- textures: number
16
- textureMemory: number // MB
17
- bufferMemory: number // MB
18
- gpuMemory: number // MB (estimated total GPU memory)
19
- }
20
-
21
- export class Engine {
22
- private canvas: HTMLCanvasElement
23
- private device!: GPUDevice
24
- private context!: GPUCanvasContext
25
- private presentationFormat!: GPUTextureFormat
26
- public camera!: Camera
27
- private cameraUniformBuffer!: GPUBuffer
28
- private cameraMatrixData = new Float32Array(36)
29
- private lightUniformBuffer!: GPUBuffer
30
- private lightData = new Float32Array(64)
31
- private lightCount = 0
32
- private vertexBuffer!: GPUBuffer
33
- private vertexCount: number = 0
34
- private indexBuffer?: GPUBuffer
35
- private resizeObserver: ResizeObserver | null = null
36
- private depthTexture!: GPUTexture
37
- private pipeline!: GPURenderPipeline
38
- private outlinePipeline!: GPURenderPipeline
39
- private jointsBuffer!: GPUBuffer
40
- private weightsBuffer!: GPUBuffer
41
- private skinMatrixBuffer?: GPUBuffer
42
- private worldMatrixBuffer?: GPUBuffer
43
- private inverseBindMatrixBuffer?: GPUBuffer
44
- private skinMatrixComputePipeline?: GPUComputePipeline
45
- private boneCountBuffer?: GPUBuffer
46
- private multisampleTexture!: GPUTexture
47
- private readonly sampleCount = 4 // MSAA 4x
48
- private renderPassDescriptor!: GPURenderPassDescriptor
49
- private currentModel: Model | null = null
50
- private modelDir: string = ""
51
- private physics: Physics | null = null
52
- private textureSampler!: GPUSampler
53
- private textureCache = new Map<string, GPUTexture>()
54
- private textureSizes = new Map<string, { width: number; height: number }>()
55
-
56
- private lastFpsUpdate = performance.now()
57
- private framesSinceLastUpdate = 0
58
- private frameTimeSamples: number[] = []
59
- private frameTimeSum: number = 0
60
- private drawCallCount: number = 0
61
- private lastFrameTime = performance.now()
62
- private stats: EngineStats = {
63
- fps: 0,
64
- frameTime: 0,
65
- memoryUsed: 0,
66
- vertices: 0,
67
- drawCalls: 0,
68
- triangles: 0,
69
- materials: 0,
70
- textures: 0,
71
- textureMemory: 0,
72
- bufferMemory: 0,
73
- gpuMemory: 0,
74
- }
75
- private animationFrameId: number | null = null
76
- private renderLoopCallback: (() => void) | null = null
77
-
78
- constructor(canvas: HTMLCanvasElement) {
79
- this.canvas = canvas
80
- }
81
-
82
- // Step 1: Get WebGPU device and context
83
- public async init() {
84
- const adapter = await navigator.gpu?.requestAdapter()
85
- const device = await adapter?.requestDevice()
86
- if (!device) {
87
- throw new Error("WebGPU is not supported in this browser.")
88
- }
89
- this.device = device
90
-
91
- const context = this.canvas.getContext("webgpu")
92
- if (!context) {
93
- throw new Error("Failed to get WebGPU context.")
94
- }
95
- this.context = context
96
-
97
- this.presentationFormat = navigator.gpu.getPreferredCanvasFormat()
98
-
99
- this.context.configure({
100
- device: this.device,
101
- format: this.presentationFormat,
102
- alphaMode: "premultiplied",
103
- })
104
-
105
- this.setupCamera()
106
- this.setupLighting()
107
- this.createPipelines()
108
- this.setupResize()
109
- }
110
-
111
- // Step 2: Create shaders and render pipelines
112
- private createPipelines() {
113
- this.textureSampler = this.device.createSampler({
114
- magFilter: "linear",
115
- minFilter: "linear",
116
- addressModeU: "repeat",
117
- addressModeV: "repeat",
118
- })
119
-
120
- const shaderModule = this.device.createShaderModule({
121
- label: "model shaders",
122
- code: /* wgsl */ `
123
- struct CameraUniforms {
124
- view: mat4x4f,
125
- projection: mat4x4f,
126
- viewPos: vec3f,
127
- _padding: f32,
128
- };
129
-
130
- struct Light {
131
- direction: vec3f,
132
- _padding1: f32,
133
- color: vec3f,
134
- intensity: f32,
135
- };
136
-
137
- struct LightUniforms {
138
- ambient: f32,
139
- lightCount: f32,
140
- _padding1: f32,
141
- _padding2: f32,
142
- lights: array<Light, 4>,
143
- };
144
-
145
- struct MaterialUniforms {
146
- alpha: f32,
147
- _padding1: f32,
148
- _padding2: f32,
149
- _padding3: f32,
150
- };
151
-
152
- struct VertexOutput {
153
- @builtin(position) position: vec4f,
154
- @location(0) normal: vec3f,
155
- @location(1) uv: vec2f,
156
- @location(2) worldPos: vec3f,
157
- };
158
-
159
- @group(0) @binding(0) var<uniform> camera: CameraUniforms;
160
- @group(0) @binding(1) var<uniform> light: LightUniforms;
161
- @group(0) @binding(2) var diffuseTexture: texture_2d<f32>;
162
- @group(0) @binding(3) var diffuseSampler: sampler;
163
- @group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
164
- @group(0) @binding(5) var toonTexture: texture_2d<f32>;
165
- @group(0) @binding(6) var toonSampler: sampler;
166
- @group(0) @binding(7) var<uniform> material: MaterialUniforms;
167
-
168
- @vertex fn vs(
169
- @location(0) position: vec3f,
170
- @location(1) normal: vec3f,
171
- @location(2) uv: vec2f,
172
- @location(3) joints0: vec4<u32>,
173
- @location(4) weights0: vec4<f32>
174
- ) -> VertexOutput {
175
- var output: VertexOutput;
176
- let pos4 = vec4f(position, 1.0);
177
-
178
- // Normalize weights to ensure they sum to 1.0 (handles floating-point precision issues)
179
- let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
180
- var normalizedWeights: vec4f;
181
- if (weightSum > 0.0001) {
182
- normalizedWeights = weights0 / weightSum;
183
- } else {
184
- normalizedWeights = vec4f(1.0, 0.0, 0.0, 0.0);
185
- }
186
-
187
- var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
188
- var skinnedNrm = vec3f(0.0, 0.0, 0.0);
189
- for (var i = 0u; i < 4u; i++) {
190
- let j = joints0[i];
191
- let w = normalizedWeights[i];
192
- let m = skinMats[j];
193
- skinnedPos += (m * pos4) * w;
194
- let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
195
- skinnedNrm += (r3 * normal) * w;
196
- }
197
- let worldPos = skinnedPos.xyz;
198
- output.position = camera.projection * camera.view * vec4f(worldPos, 1.0);
199
- output.normal = normalize(skinnedNrm);
200
- output.uv = uv;
201
- output.worldPos = worldPos;
202
- return output;
203
- }
204
-
205
- @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
206
- let n = normalize(input.normal);
207
- let albedo = textureSample(diffuseTexture, diffuseSampler, input.uv).rgb;
208
-
209
- var lightAccum = vec3f(light.ambient);
210
- let numLights = u32(light.lightCount);
211
- for (var i = 0u; i < numLights; i++) {
212
- let l = -light.lights[i].direction;
213
- let nDotL = max(dot(n, l), 0.0);
214
- let toonUV = vec2f(nDotL, 0.5);
215
- let toonFactor = textureSample(toonTexture, toonSampler, toonUV).rgb;
216
- let radiance = light.lights[i].color * light.lights[i].intensity;
217
- lightAccum += toonFactor * radiance * nDotL;
218
- }
219
-
220
- let color = albedo * lightAccum;
221
- let finalAlpha = material.alpha;
222
- if (finalAlpha < 0.001) {
223
- discard;
224
- }
225
-
226
- return vec4f(clamp(color, vec3f(0.0), vec3f(1.0)), finalAlpha);
227
- }
228
- `,
229
- })
230
-
231
- // Single pipeline for all materials with alpha blending
232
- this.pipeline = this.device.createRenderPipeline({
233
- label: "model pipeline",
234
- layout: "auto",
235
- vertex: {
236
- module: shaderModule,
237
- buffers: [
238
- {
239
- arrayStride: 8 * 4,
240
- attributes: [
241
- { shaderLocation: 0, offset: 0, format: "float32x3" as GPUVertexFormat },
242
- { shaderLocation: 1, offset: 3 * 4, format: "float32x3" as GPUVertexFormat },
243
- { shaderLocation: 2, offset: 6 * 4, format: "float32x2" as GPUVertexFormat },
244
- ],
245
- },
246
- {
247
- arrayStride: 4 * 2,
248
- attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" as GPUVertexFormat }],
249
- },
250
- {
251
- arrayStride: 4,
252
- attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" as GPUVertexFormat }],
253
- },
254
- ],
255
- },
256
- fragment: {
257
- module: shaderModule,
258
- targets: [
259
- {
260
- format: this.presentationFormat,
261
- blend: {
262
- color: {
263
- srcFactor: "src-alpha",
264
- dstFactor: "one-minus-src-alpha",
265
- operation: "add",
266
- },
267
- alpha: {
268
- srcFactor: "one",
269
- dstFactor: "one-minus-src-alpha",
270
- operation: "add",
271
- },
272
- },
273
- },
274
- ],
275
- },
276
- primitive: { cullMode: "none" },
277
- depthStencil: {
278
- format: "depth24plus",
279
- depthWriteEnabled: true,
280
- depthCompare: "less",
281
- },
282
- multisample: {
283
- count: this.sampleCount,
284
- },
285
- })
286
-
287
- const outlineShaderModule = this.device.createShaderModule({
288
- label: "outline shaders",
289
- code: /* wgsl */ `
290
- struct CameraUniforms {
291
- view: mat4x4f,
292
- projection: mat4x4f,
293
- viewPos: vec3f,
294
- _padding: f32,
295
- };
296
-
297
- struct MaterialUniforms {
298
- edgeColor: vec4f,
299
- edgeSize: f32,
300
- _padding1: f32,
301
- _padding2: f32,
302
- _padding3: f32,
303
- };
304
-
305
- @group(0) @binding(0) var<uniform> camera: CameraUniforms;
306
- @group(0) @binding(1) var<uniform> material: MaterialUniforms;
307
- @group(0) @binding(2) var<storage, read> skinMats: array<mat4x4f>;
308
-
309
- struct VertexOutput {
310
- @builtin(position) position: vec4f,
311
- };
312
-
313
- @vertex fn vs(
314
- @location(0) position: vec3f,
315
- @location(1) normal: vec3f,
316
- @location(2) uv: vec2f,
317
- @location(3) joints0: vec4<u32>,
318
- @location(4) weights0: vec4<f32>
319
- ) -> VertexOutput {
320
- var output: VertexOutput;
321
- let pos4 = vec4f(position, 1.0);
322
-
323
- // Normalize weights to ensure they sum to 1.0 (handles floating-point precision issues)
324
- let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
325
- var normalizedWeights: vec4f;
326
- if (weightSum > 0.0001) {
327
- normalizedWeights = weights0 / weightSum;
328
- } else {
329
- normalizedWeights = vec4f(1.0, 0.0, 0.0, 0.0);
330
- }
331
-
332
- var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
333
- var skinnedNrm = vec3f(0.0, 0.0, 0.0);
334
- for (var i = 0u; i < 4u; i++) {
335
- let j = joints0[i];
336
- let w = normalizedWeights[i];
337
- let m = skinMats[j];
338
- skinnedPos += (m * pos4) * w;
339
- let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
340
- skinnedNrm += (r3 * normal) * w;
341
- }
342
- let worldPos = skinnedPos.xyz;
343
- let worldNormal = normalize(skinnedNrm);
344
-
345
- // MMD invert hull: expand vertices outward along normals
346
- let scaleFactor = 0.01;
347
- let expandedPos = worldPos + worldNormal * material.edgeSize * scaleFactor;
348
- output.position = camera.projection * camera.view * vec4f(expandedPos, 1.0);
349
- return output;
350
- }
351
-
352
- @fragment fn fs() -> @location(0) vec4f {
353
- return material.edgeColor;
354
- }
355
- `,
356
- })
357
-
358
- this.outlinePipeline = this.device.createRenderPipeline({
359
- label: "outline pipeline",
360
- layout: "auto",
361
- vertex: {
362
- module: outlineShaderModule,
363
- buffers: [
364
- {
365
- arrayStride: 8 * 4,
366
- attributes: [
367
- {
368
- shaderLocation: 0,
369
- offset: 0,
370
- format: "float32x3" as GPUVertexFormat,
371
- },
372
- {
373
- shaderLocation: 1,
374
- offset: 3 * 4,
375
- format: "float32x3" as GPUVertexFormat,
376
- },
377
- {
378
- shaderLocation: 2,
379
- offset: 6 * 4,
380
- format: "float32x2" as GPUVertexFormat,
381
- },
382
- ],
383
- },
384
- {
385
- arrayStride: 4 * 2,
386
- attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" as GPUVertexFormat }],
387
- },
388
- {
389
- arrayStride: 4,
390
- attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" as GPUVertexFormat }],
391
- },
392
- ],
393
- },
394
- fragment: {
395
- module: outlineShaderModule,
396
- targets: [
397
- {
398
- format: this.presentationFormat,
399
- blend: {
400
- color: {
401
- srcFactor: "src-alpha",
402
- dstFactor: "one-minus-src-alpha",
403
- operation: "add",
404
- },
405
- alpha: {
406
- srcFactor: "one",
407
- dstFactor: "one-minus-src-alpha",
408
- operation: "add",
409
- },
410
- },
411
- },
412
- ],
413
- },
414
- primitive: {
415
- cullMode: "back",
416
- },
417
- depthStencil: {
418
- format: "depth24plus",
419
- depthWriteEnabled: true,
420
- depthCompare: "less",
421
- },
422
- multisample: {
423
- count: this.sampleCount,
424
- },
425
- })
426
- }
427
-
428
- // Create compute shader for skin matrix computation
429
- private createSkinMatrixComputePipeline() {
430
- const computeShader = this.device.createShaderModule({
431
- label: "skin matrix compute",
432
- code: /* wgsl */ `
433
- struct BoneCountUniform {
434
- count: u32,
435
- _padding1: u32,
436
- _padding2: u32,
437
- _padding3: u32,
438
- _padding4: vec4<u32>,
439
- };
440
-
441
- @group(0) @binding(0) var<uniform> boneCount: BoneCountUniform;
442
- @group(0) @binding(1) var<storage, read> worldMatrices: array<mat4x4f>;
443
- @group(0) @binding(2) var<storage, read> inverseBindMatrices: array<mat4x4f>;
444
- @group(0) @binding(3) var<storage, read_write> skinMatrices: array<mat4x4f>;
445
-
446
- @compute @workgroup_size(64)
447
- fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
448
- let boneIndex = globalId.x;
449
- // Bounds check: we dispatch workgroups (64 threads each), so some threads may be out of range
450
- if (boneIndex >= boneCount.count) {
451
- return;
452
- }
453
- let worldMat = worldMatrices[boneIndex];
454
- let invBindMat = inverseBindMatrices[boneIndex];
455
- skinMatrices[boneIndex] = worldMat * invBindMat;
456
- }
457
- `,
458
- })
459
-
460
- this.skinMatrixComputePipeline = this.device.createComputePipeline({
461
- label: "skin matrix compute pipeline",
462
- layout: "auto",
463
- compute: {
464
- module: computeShader,
465
- },
466
- })
467
- }
468
-
469
- // Step 3: Setup canvas resize handling
470
- private setupResize() {
471
- this.resizeObserver = new ResizeObserver(() => this.handleResize())
472
- this.resizeObserver.observe(this.canvas)
473
- this.handleResize()
474
- }
475
-
476
- private handleResize() {
477
- const displayWidth = this.canvas.clientWidth
478
- const displayHeight = this.canvas.clientHeight
479
-
480
- const dpr = window.devicePixelRatio || 1
481
- const width = Math.floor(displayWidth * dpr)
482
- const height = Math.floor(displayHeight * dpr)
483
-
484
- if (!this.multisampleTexture || this.canvas.width !== width || this.canvas.height !== height) {
485
- this.canvas.width = width
486
- this.canvas.height = height
487
-
488
- this.multisampleTexture = this.device.createTexture({
489
- label: "multisample render target",
490
- size: [width, height],
491
- sampleCount: this.sampleCount,
492
- format: this.presentationFormat,
493
- usage: GPUTextureUsage.RENDER_ATTACHMENT,
494
- })
495
-
496
- this.depthTexture = this.device.createTexture({
497
- label: "depth texture",
498
- size: [width, height],
499
- sampleCount: this.sampleCount,
500
- format: "depth24plus",
501
- usage: GPUTextureUsage.RENDER_ATTACHMENT,
502
- })
503
-
504
- const depthTextureView = this.depthTexture.createView()
505
-
506
- const colorAttachment: GPURenderPassColorAttachment =
507
- this.sampleCount > 1
508
- ? {
509
- view: this.multisampleTexture.createView(),
510
- resolveTarget: this.context.getCurrentTexture().createView(),
511
- clearValue: { r: 0, g: 0, b: 0, a: 0 },
512
- loadOp: "clear",
513
- storeOp: "store",
514
- }
515
- : {
516
- view: this.context.getCurrentTexture().createView(),
517
- clearValue: { r: 0, g: 0, b: 0, a: 0 },
518
- loadOp: "clear",
519
- storeOp: "store",
520
- }
521
-
522
- this.renderPassDescriptor = {
523
- label: "renderPass",
524
- colorAttachments: [colorAttachment],
525
- depthStencilAttachment: {
526
- view: depthTextureView,
527
- depthClearValue: 1.0,
528
- depthLoadOp: "clear",
529
- depthStoreOp: "store",
530
- },
531
- }
532
-
533
- this.camera.aspect = width / height
534
- }
535
- }
536
-
537
- // Step 4: Create camera and uniform buffer
538
- private setupCamera() {
539
- this.cameraUniformBuffer = this.device.createBuffer({
540
- label: "camera uniforms",
541
- size: 40 * 4,
542
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
543
- })
544
-
545
- this.camera = new Camera(Math.PI, Math.PI / 2.5, 26.6, new Vec3(0, 12.5, 0))
546
-
547
- this.camera.aspect = this.canvas.width / this.canvas.height
548
- this.camera.attachControl(this.canvas)
549
- }
550
-
551
- // Step 5: Create lighting buffers
552
- private setupLighting() {
553
- this.lightUniformBuffer = this.device.createBuffer({
554
- label: "light uniforms",
555
- size: 64 * 4,
556
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
557
- })
558
-
559
- this.lightCount = 0
560
-
561
- this.setAmbient(0.96)
562
- this.addLight(new Vec3(-0.5, -0.8, 0.5).normalize(), new Vec3(1.0, 0.95, 0.9), 0.12)
563
- this.addLight(new Vec3(0.7, -0.5, 0.3).normalize(), new Vec3(0.8, 0.85, 1.0), 0.1)
564
- this.addLight(new Vec3(0.3, -0.5, -1.0).normalize(), new Vec3(0.9, 0.9, 1.0), 0.08)
565
- this.device.queue.writeBuffer(this.lightUniformBuffer, 0, this.lightData)
566
- }
567
-
568
- public addLight(direction: Vec3, color: Vec3, intensity: number = 1.0): boolean {
569
- if (this.lightCount >= 4) return false
570
-
571
- const normalized = direction.normalize()
572
- const baseIndex = 4 + this.lightCount * 8
573
- this.lightData[baseIndex] = normalized.x
574
- this.lightData[baseIndex + 1] = normalized.y
575
- this.lightData[baseIndex + 2] = normalized.z
576
- this.lightData[baseIndex + 3] = 0
577
- this.lightData[baseIndex + 4] = color.x
578
- this.lightData[baseIndex + 5] = color.y
579
- this.lightData[baseIndex + 6] = color.z
580
- this.lightData[baseIndex + 7] = intensity
581
-
582
- this.lightCount++
583
- this.lightData[1] = this.lightCount
584
- return true
585
- }
586
-
587
- public setAmbient(intensity: number) {
588
- this.lightData[0] = intensity
589
- }
590
-
591
- public getStats(): EngineStats {
592
- return { ...this.stats }
593
- }
594
-
595
- public runRenderLoop(callback?: () => void) {
596
- this.renderLoopCallback = callback || null
597
-
598
- const loop = () => {
599
- this.render()
600
-
601
- if (this.renderLoopCallback) {
602
- this.renderLoopCallback()
603
- }
604
-
605
- this.animationFrameId = requestAnimationFrame(loop)
606
- }
607
-
608
- this.animationFrameId = requestAnimationFrame(loop)
609
- }
610
-
611
- public stopRenderLoop() {
612
- if (this.animationFrameId !== null) {
613
- cancelAnimationFrame(this.animationFrameId)
614
- this.animationFrameId = null
615
- }
616
- this.renderLoopCallback = null
617
- }
618
-
619
- public dispose() {
620
- this.stopRenderLoop()
621
- if (this.camera) this.camera.detachControl()
622
- if (this.resizeObserver) {
623
- this.resizeObserver.disconnect()
624
- this.resizeObserver = null
625
- }
626
- }
627
-
628
- // Step 6: Load PMX model file
629
- public async loadModel(path: string) {
630
- const pathParts = path.split("/")
631
- pathParts.pop()
632
- const dir = pathParts.join("/") + "/"
633
- this.modelDir = dir
634
-
635
- const model = await PmxLoader.load(path)
636
- this.physics = new Physics(model.getRigidbodies(), model.getJoints())
637
- await this.setupModelBuffers(model)
638
-
639
- model.rotateBones(
640
- ["腰", "首", "右腕", "左腕", "右ひざ"],
641
- [
642
- new Quat(-0.4, -0.3, 0, 1),
643
- new Quat(0.3, -0.3, -0.3, 1),
644
- new Quat(0.3, 0.3, 0.3, 1),
645
- new Quat(-0.3, 0.3, -0.3, 1),
646
- new Quat(-1.0, -0.3, 0.0, 1),
647
- ],
648
- 1000
649
- )
650
- }
651
-
652
- public rotateBones(bones: string[], rotations: Quat[], duration: number) {
653
- this.currentModel?.rotateBones(bones, rotations, duration)
654
- }
655
-
656
- // Step 7: Create vertex, index, and joint buffers
657
- private async setupModelBuffers(model: Model) {
658
- this.currentModel = model
659
- const vertices = model.getVertices()
660
- const skinning = model.getSkinning()
661
- const skeleton = model.getSkeleton()
662
-
663
- this.vertexBuffer = this.device.createBuffer({
664
- label: "model vertex buffer",
665
- size: vertices.byteLength,
666
- usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
667
- })
668
- this.device.queue.writeBuffer(this.vertexBuffer, 0, vertices)
669
- this.vertexCount = model.getVertexCount()
670
-
671
- this.jointsBuffer = this.device.createBuffer({
672
- label: "joints buffer",
673
- size: skinning.joints.byteLength,
674
- usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
675
- })
676
- this.device.queue.writeBuffer(
677
- this.jointsBuffer,
678
- 0,
679
- skinning.joints.buffer,
680
- skinning.joints.byteOffset,
681
- skinning.joints.byteLength
682
- )
683
-
684
- this.weightsBuffer = this.device.createBuffer({
685
- label: "weights buffer",
686
- size: skinning.weights.byteLength,
687
- usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
688
- })
689
- this.device.queue.writeBuffer(
690
- this.weightsBuffer,
691
- 0,
692
- skinning.weights.buffer,
693
- skinning.weights.byteOffset,
694
- skinning.weights.byteLength
695
- )
696
-
697
- const boneCount = skeleton.bones.length
698
- const matrixSize = boneCount * 16 * 4
699
-
700
- this.skinMatrixBuffer = this.device.createBuffer({
701
- label: "skin matrices",
702
- size: Math.max(256, matrixSize),
703
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.VERTEX,
704
- })
705
-
706
- this.worldMatrixBuffer = this.device.createBuffer({
707
- label: "world matrices",
708
- size: Math.max(256, matrixSize),
709
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
710
- })
711
-
712
- this.inverseBindMatrixBuffer = this.device.createBuffer({
713
- label: "inverse bind matrices",
714
- size: Math.max(256, matrixSize),
715
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
716
- })
717
-
718
- const invBindMatrices = skeleton.inverseBindMatrices
719
- this.device.queue.writeBuffer(
720
- this.inverseBindMatrixBuffer,
721
- 0,
722
- invBindMatrices.buffer,
723
- invBindMatrices.byteOffset,
724
- invBindMatrices.byteLength
725
- )
726
-
727
- this.boneCountBuffer = this.device.createBuffer({
728
- label: "bone count uniform",
729
- size: 32, // Minimum uniform buffer size is 32 bytes
730
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
731
- })
732
- const boneCountData = new Uint32Array(8) // 32 bytes total
733
- boneCountData[0] = boneCount
734
- this.device.queue.writeBuffer(this.boneCountBuffer, 0, boneCountData)
735
-
736
- this.createSkinMatrixComputePipeline()
737
-
738
- const indices = model.getIndices()
739
- if (indices) {
740
- this.indexBuffer = this.device.createBuffer({
741
- label: "model index buffer",
742
- size: indices.byteLength,
743
- usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
744
- })
745
- this.device.queue.writeBuffer(this.indexBuffer, 0, indices)
746
- } else {
747
- throw new Error("Model has no index buffer")
748
- }
749
-
750
- await this.setupMaterials(model)
751
- }
752
-
753
- private materialDraws: { count: number; firstIndex: number; bindGroup: GPUBindGroup; isTransparent: boolean }[] = []
754
- private outlineDraws: { count: number; firstIndex: number; bindGroup: GPUBindGroup; isTransparent: boolean }[] = []
755
-
756
- // Step 8: Load textures and create material bind groups
757
- private async setupMaterials(model: Model) {
758
- const materials = model.getMaterials()
759
- if (materials.length === 0) {
760
- throw new Error("Model has no materials")
761
- }
762
-
763
- const textures = model.getTextures()
764
-
765
- const loadTextureByIndex = async (texIndex: number): Promise<GPUTexture | null> => {
766
- if (texIndex < 0 || texIndex >= textures.length) {
767
- return null
768
- }
769
-
770
- const path = this.modelDir + textures[texIndex].path
771
- const texture = await this.createTextureFromPath(path)
772
- return texture
773
- }
774
-
775
- const loadToonTexture = async (toonTextureIndex: number): Promise<GPUTexture> => {
776
- const texture = await loadTextureByIndex(toonTextureIndex)
777
- if (texture) return texture
778
-
779
- // Default toon texture fallback
780
- const defaultToonData = new Uint8Array(256 * 2 * 4)
781
- for (let i = 0; i < 256; i++) {
782
- const factor = i / 255.0
783
- const gray = Math.floor(128 + factor * 127)
784
- defaultToonData[i * 4] = gray
785
- defaultToonData[i * 4 + 1] = gray
786
- defaultToonData[i * 4 + 2] = gray
787
- defaultToonData[i * 4 + 3] = 255
788
- defaultToonData[(256 + i) * 4] = gray
789
- defaultToonData[(256 + i) * 4 + 1] = gray
790
- defaultToonData[(256 + i) * 4 + 2] = gray
791
- defaultToonData[(256 + i) * 4 + 3] = 255
792
- }
793
- const defaultToonTexture = this.device.createTexture({
794
- label: "default toon texture",
795
- size: [256, 2],
796
- format: "rgba8unorm",
797
- usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
798
- })
799
- this.device.queue.writeTexture(
800
- { texture: defaultToonTexture },
801
- defaultToonData,
802
- { bytesPerRow: 256 * 4 },
803
- [256, 2]
804
- )
805
- this.textureSizes.set("__default_toon__", { width: 256, height: 2 })
806
- return defaultToonTexture
807
- }
808
-
809
- this.materialDraws = []
810
- this.outlineDraws = []
811
- const outlineBindGroupLayout = this.outlinePipeline.getBindGroupLayout(0)
812
- let runningFirstIndex = 0
813
-
814
- for (const mat of materials) {
815
- const matCount = mat.vertexCount | 0
816
- if (matCount === 0) continue
817
-
818
- const diffuseTexture = await loadTextureByIndex(mat.diffuseTextureIndex)
819
- if (!diffuseTexture) throw new Error(`Material "${mat.name}" has no diffuse texture`)
820
-
821
- const toonTexture = await loadToonTexture(mat.toonTextureIndex)
822
-
823
- const materialAlpha = mat.diffuse[3]
824
- const EPSILON = 0.001
825
- const isTransparent = materialAlpha < 1.0 - EPSILON
826
-
827
- const materialUniformData = new Float32Array(4)
828
- materialUniformData[0] = materialAlpha
829
- materialUniformData[1] = 0.0
830
- materialUniformData[2] = 0.0
831
- materialUniformData[3] = 0.0
832
-
833
- const materialUniformBuffer = this.device.createBuffer({
834
- label: `material uniform: ${mat.name}`,
835
- size: materialUniformData.byteLength,
836
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
837
- })
838
- this.device.queue.writeBuffer(materialUniformBuffer, 0, materialUniformData)
839
-
840
- const bindGroup = this.device.createBindGroup({
841
- label: `material bind group: ${mat.name}`,
842
- layout: this.pipeline.getBindGroupLayout(0),
843
- entries: [
844
- { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
845
- { binding: 1, resource: { buffer: this.lightUniformBuffer } },
846
- { binding: 2, resource: diffuseTexture.createView() },
847
- { binding: 3, resource: this.textureSampler },
848
- { binding: 4, resource: { buffer: this.skinMatrixBuffer! } },
849
- { binding: 5, resource: toonTexture.createView() },
850
- { binding: 6, resource: this.textureSampler },
851
- { binding: 7, resource: { buffer: materialUniformBuffer } },
852
- ],
853
- })
854
-
855
- // All materials use the same pipeline
856
- this.materialDraws.push({
857
- count: matCount,
858
- firstIndex: runningFirstIndex,
859
- bindGroup,
860
- isTransparent,
861
- })
862
-
863
- // Outline for all materials (including transparent)
864
- // Edge flag is at bit 4 (0x10) in PMX format, not bit 0 (0x01)
865
- if ((mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
866
- const materialUniformData = new Float32Array(8)
867
- materialUniformData[0] = mat.edgeColor[0]
868
- materialUniformData[1] = mat.edgeColor[1]
869
- materialUniformData[2] = mat.edgeColor[2]
870
- materialUniformData[3] = mat.edgeColor[3]
871
- materialUniformData[4] = mat.edgeSize
872
-
873
- const materialUniformBuffer = this.device.createBuffer({
874
- label: `outline material uniform: ${mat.name}`,
875
- size: materialUniformData.byteLength,
876
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
877
- })
878
- this.device.queue.writeBuffer(materialUniformBuffer, 0, materialUniformData)
879
-
880
- const outlineBindGroup = this.device.createBindGroup({
881
- label: `outline bind group: ${mat.name}`,
882
- layout: outlineBindGroupLayout,
883
- entries: [
884
- { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
885
- { binding: 1, resource: { buffer: materialUniformBuffer } },
886
- { binding: 2, resource: { buffer: this.skinMatrixBuffer! } },
887
- ],
888
- })
889
-
890
- // All outlines use the same pipeline
891
- this.outlineDraws.push({
892
- count: matCount,
893
- firstIndex: runningFirstIndex,
894
- bindGroup: outlineBindGroup,
895
- isTransparent,
896
- })
897
- }
898
-
899
- runningFirstIndex += matCount
900
- }
901
- }
902
-
903
- // Helper: Load texture from file path
904
- private async createTextureFromPath(path: string): Promise<GPUTexture | null> {
905
- const cached = this.textureCache.get(path)
906
- if (cached) {
907
- return cached
908
- }
909
-
910
- try {
911
- const response = await fetch(path)
912
- if (!response.ok) {
913
- throw new Error(`HTTP ${response.status}: ${response.statusText}`)
914
- }
915
- const imageBitmap = await createImageBitmap(await response.blob(), {
916
- premultiplyAlpha: "none",
917
- colorSpaceConversion: "none",
918
- })
919
- const texture = this.device.createTexture({
920
- label: `texture: ${path}`,
921
- size: [imageBitmap.width, imageBitmap.height],
922
- format: "rgba8unorm",
923
- usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
924
- })
925
- this.device.queue.copyExternalImageToTexture({ source: imageBitmap }, { texture }, [
926
- imageBitmap.width,
927
- imageBitmap.height,
928
- ])
929
-
930
- this.textureCache.set(path, texture)
931
- this.textureSizes.set(path, { width: imageBitmap.width, height: imageBitmap.height })
932
- return texture
933
- } catch {
934
- return null
935
- }
936
- }
937
-
938
- // Step 9: Render one frame
939
- public render() {
940
- if (this.multisampleTexture && this.camera && this.device && this.currentModel) {
941
- const currentTime = performance.now()
942
- const deltaTime = this.lastFrameTime > 0 ? (currentTime - this.lastFrameTime) / 1000 : 0.016
943
- this.lastFrameTime = currentTime
944
-
945
- this.updateCameraUniforms()
946
- this.updateRenderTarget()
947
-
948
- this.updateModelPose(deltaTime)
949
-
950
- const encoder = this.device.createCommandEncoder()
951
- const pass = encoder.beginRenderPass(this.renderPassDescriptor)
952
-
953
- pass.setVertexBuffer(0, this.vertexBuffer)
954
- pass.setVertexBuffer(1, this.jointsBuffer)
955
- pass.setVertexBuffer(2, this.weightsBuffer)
956
- pass.setIndexBuffer(this.indexBuffer!, "uint32")
957
-
958
- this.drawCallCount = 0
959
- this.drawOutlines(pass, false)
960
- this.drawModel(pass, false)
961
- this.drawModel(pass, true)
962
- this.drawOutlines(pass, true)
963
-
964
- pass.end()
965
- this.device.queue.submit([encoder.finish()])
966
- this.updateStats(performance.now() - currentTime)
967
- }
968
- }
969
-
970
- // Update camera uniform buffer each frame
971
- private updateCameraUniforms() {
972
- const viewMatrix = this.camera.getViewMatrix()
973
- const projectionMatrix = this.camera.getProjectionMatrix()
974
- const cameraPos = this.camera.getPosition()
975
- this.cameraMatrixData.set(viewMatrix.values, 0)
976
- this.cameraMatrixData.set(projectionMatrix.values, 16)
977
- this.cameraMatrixData[32] = cameraPos.x
978
- this.cameraMatrixData[33] = cameraPos.y
979
- this.cameraMatrixData[34] = cameraPos.z
980
- this.device.queue.writeBuffer(this.cameraUniformBuffer, 0, this.cameraMatrixData)
981
- }
982
-
983
- // Update render target texture view
984
- private updateRenderTarget() {
985
- const colorAttachment = (this.renderPassDescriptor.colorAttachments as GPURenderPassColorAttachment[])[0]
986
- if (this.sampleCount > 1) {
987
- colorAttachment.resolveTarget = this.context.getCurrentTexture().createView()
988
- } else {
989
- colorAttachment.view = this.context.getCurrentTexture().createView()
990
- }
991
- }
992
-
993
- // Update model pose and physics
994
- private updateModelPose(deltaTime: number) {
995
- this.currentModel!.evaluatePose()
996
-
997
- // Upload world matrices to GPU
998
- const worldMats = this.currentModel!.getBoneWorldMatrices()
999
- this.device.queue.writeBuffer(
1000
- this.worldMatrixBuffer!,
1001
- 0,
1002
- worldMats.buffer,
1003
- worldMats.byteOffset,
1004
- worldMats.byteLength
1005
- )
1006
-
1007
- if (this.physics) {
1008
- this.physics.step(deltaTime, worldMats, this.currentModel!.getBoneInverseBindMatrices())
1009
- // Re-upload world matrices after physics (physics may have updated bones)
1010
- this.device.queue.writeBuffer(
1011
- this.worldMatrixBuffer!,
1012
- 0,
1013
- worldMats.buffer,
1014
- worldMats.byteOffset,
1015
- worldMats.byteLength
1016
- )
1017
- }
1018
-
1019
- // Compute skin matrices on GPU
1020
- this.computeSkinMatrices()
1021
- }
1022
-
1023
- // Compute skin matrices on GPU
1024
- private computeSkinMatrices() {
1025
- const boneCount = this.currentModel!.getSkeleton().bones.length
1026
- const workgroupSize = 64
1027
- // Dispatch exactly enough threads for all bones (no bounds check needed)
1028
- const workgroupCount = Math.ceil(boneCount / workgroupSize)
1029
-
1030
- // Update bone count uniform
1031
- const boneCountData = new Uint32Array(8) // 32 bytes total
1032
- boneCountData[0] = boneCount
1033
- this.device.queue.writeBuffer(this.boneCountBuffer!, 0, boneCountData)
1034
-
1035
- const bindGroup = this.device.createBindGroup({
1036
- label: "skin matrix compute bind group",
1037
- layout: this.skinMatrixComputePipeline!.getBindGroupLayout(0),
1038
- entries: [
1039
- { binding: 0, resource: { buffer: this.boneCountBuffer! } },
1040
- { binding: 1, resource: { buffer: this.worldMatrixBuffer! } },
1041
- { binding: 2, resource: { buffer: this.inverseBindMatrixBuffer! } },
1042
- { binding: 3, resource: { buffer: this.skinMatrixBuffer! } },
1043
- ],
1044
- })
1045
-
1046
- const encoder = this.device.createCommandEncoder()
1047
- const pass = encoder.beginComputePass()
1048
- pass.setPipeline(this.skinMatrixComputePipeline!)
1049
- pass.setBindGroup(0, bindGroup)
1050
- pass.dispatchWorkgroups(workgroupCount)
1051
- pass.end()
1052
- this.device.queue.submit([encoder.finish()])
1053
- }
1054
-
1055
- // Draw outlines (opaque or transparent)
1056
- private drawOutlines(pass: GPURenderPassEncoder, transparent: boolean) {
1057
- if (this.outlineDraws.length === 0) return
1058
- pass.setPipeline(this.outlinePipeline)
1059
- for (const draw of this.outlineDraws) {
1060
- if (draw.count > 0 && draw.isTransparent === transparent) {
1061
- pass.setBindGroup(0, draw.bindGroup)
1062
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1063
- }
1064
- }
1065
- }
1066
-
1067
- // Draw model materials (opaque or transparent)
1068
- private drawModel(pass: GPURenderPassEncoder, transparent: boolean) {
1069
- pass.setPipeline(this.pipeline)
1070
- for (const draw of this.materialDraws) {
1071
- if (draw.count > 0 && draw.isTransparent === transparent) {
1072
- pass.setBindGroup(0, draw.bindGroup)
1073
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1074
- this.drawCallCount++
1075
- }
1076
- }
1077
- }
1078
-
1079
- private updateStats(frameTime: number) {
1080
- const maxSamples = 60
1081
- this.frameTimeSamples.push(frameTime)
1082
- this.frameTimeSum += frameTime
1083
- if (this.frameTimeSamples.length > maxSamples) {
1084
- const removed = this.frameTimeSamples.shift()!
1085
- this.frameTimeSum -= removed
1086
- }
1087
- const avgFrameTime = this.frameTimeSum / this.frameTimeSamples.length
1088
- this.stats.frameTime = Math.round(avgFrameTime * 100) / 100
1089
-
1090
- const now = performance.now()
1091
- this.framesSinceLastUpdate++
1092
- const elapsed = now - this.lastFpsUpdate
1093
-
1094
- if (elapsed >= 1000) {
1095
- this.stats.fps = Math.round((this.framesSinceLastUpdate / elapsed) * 1000)
1096
- this.framesSinceLastUpdate = 0
1097
- this.lastFpsUpdate = now
1098
-
1099
- const perf = performance as Performance & {
1100
- memory?: { usedJSHeapSize: number; totalJSHeapSize: number }
1101
- }
1102
- if (perf.memory) {
1103
- this.stats.memoryUsed = Math.round(perf.memory.usedJSHeapSize / 1024 / 1024)
1104
- }
1105
- }
1106
-
1107
- this.stats.vertices = this.vertexCount
1108
- this.stats.drawCalls = this.drawCallCount
1109
-
1110
- // Calculate triangles from index buffer
1111
- if (this.indexBuffer) {
1112
- const indexCount = this.currentModel?.getIndices()?.length || 0
1113
- this.stats.triangles = Math.floor(indexCount / 3)
1114
- } else {
1115
- this.stats.triangles = Math.floor(this.vertexCount / 3)
1116
- }
1117
-
1118
- // Material count
1119
- this.stats.materials = this.materialDraws.length
1120
-
1121
- // Texture stats
1122
- this.stats.textures = this.textureCache.size
1123
- let textureMemoryBytes = 0
1124
- for (const [path, size] of this.textureSizes.entries()) {
1125
- if (this.textureCache.has(path)) {
1126
- // RGBA8 = 4 bytes per pixel
1127
- textureMemoryBytes += size.width * size.height * 4
1128
- }
1129
- }
1130
- // Add render target textures (multisample + depth)
1131
- if (this.multisampleTexture) {
1132
- const width = this.canvas.width
1133
- const height = this.canvas.height
1134
- textureMemoryBytes += width * height * 4 * this.sampleCount // multisample color
1135
- textureMemoryBytes += width * height * 4 // depth (depth24plus = 4 bytes)
1136
- }
1137
- this.stats.textureMemory = Math.round((textureMemoryBytes / 1024 / 1024) * 100) / 100
1138
-
1139
- // Buffer memory estimate
1140
- let bufferMemoryBytes = 0
1141
- if (this.vertexBuffer) {
1142
- const vertices = this.currentModel?.getVertices()
1143
- if (vertices) bufferMemoryBytes += vertices.byteLength
1144
- }
1145
- if (this.indexBuffer) {
1146
- const indices = this.currentModel?.getIndices()
1147
- if (indices) bufferMemoryBytes += indices.byteLength
1148
- }
1149
- if (this.jointsBuffer) {
1150
- const skinning = this.currentModel?.getSkinning()
1151
- if (skinning) bufferMemoryBytes += skinning.joints.byteLength
1152
- }
1153
- if (this.weightsBuffer) {
1154
- const skinning = this.currentModel?.getSkinning()
1155
- if (skinning) bufferMemoryBytes += skinning.weights.byteLength
1156
- }
1157
- if (this.skinMatrixBuffer) {
1158
- const skeleton = this.currentModel?.getSkeleton()
1159
- if (skeleton) bufferMemoryBytes += Math.max(256, skeleton.bones.length * 16 * 4)
1160
- }
1161
- bufferMemoryBytes += 40 * 4 // cameraUniformBuffer
1162
- bufferMemoryBytes += 64 * 4 // lightUniformBuffer
1163
- // Material uniform buffers (estimate: 4 bytes per material)
1164
- bufferMemoryBytes += this.materialDraws.length * 4
1165
- this.stats.bufferMemory = Math.round((bufferMemoryBytes / 1024 / 1024) * 100) / 100
1166
-
1167
- // Total GPU memory estimate
1168
- this.stats.gpuMemory = Math.round((this.stats.textureMemory + this.stats.bufferMemory) * 100) / 100
1169
- }
1170
- }
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
+ memoryUsed: number // MB (JS heap)
11
+ vertices: number
12
+ drawCalls: number
13
+ triangles: number
14
+ materials: number
15
+ textures: number
16
+ textureMemory: number // MB
17
+ bufferMemory: number // MB
18
+ gpuMemory: number // MB (estimated total GPU memory)
19
+ }
20
+
21
+ export class Engine {
22
+ private canvas: HTMLCanvasElement
23
+ private device!: GPUDevice
24
+ private context!: GPUCanvasContext
25
+ private presentationFormat!: GPUTextureFormat
26
+ public camera!: Camera
27
+ private cameraUniformBuffer!: GPUBuffer
28
+ private cameraMatrixData = new Float32Array(36)
29
+ private lightUniformBuffer!: GPUBuffer
30
+ private lightData = new Float32Array(64)
31
+ private lightCount = 0
32
+ private vertexBuffer!: GPUBuffer
33
+ private vertexCount: number = 0
34
+ private indexBuffer?: GPUBuffer
35
+ private resizeObserver: ResizeObserver | null = null
36
+ private depthTexture!: GPUTexture
37
+ private pipeline!: GPURenderPipeline
38
+ private outlinePipeline!: GPURenderPipeline
39
+ private jointsBuffer!: GPUBuffer
40
+ private weightsBuffer!: GPUBuffer
41
+ private skinMatrixBuffer?: GPUBuffer
42
+ private worldMatrixBuffer?: GPUBuffer
43
+ private inverseBindMatrixBuffer?: GPUBuffer
44
+ private skinMatrixComputePipeline?: GPUComputePipeline
45
+ private boneCountBuffer?: GPUBuffer
46
+ private multisampleTexture!: GPUTexture
47
+ private readonly sampleCount = 4 // MSAA 4x
48
+ private renderPassDescriptor!: GPURenderPassDescriptor
49
+ private currentModel: Model | null = null
50
+ private modelDir: string = ""
51
+ private physics: Physics | null = null
52
+ private textureSampler!: GPUSampler
53
+ private textureCache = new Map<string, GPUTexture>()
54
+ private textureSizes = new Map<string, { width: number; height: number }>()
55
+
56
+ private lastFpsUpdate = performance.now()
57
+ private framesSinceLastUpdate = 0
58
+ private frameTimeSamples: number[] = []
59
+ private frameTimeSum: number = 0
60
+ private drawCallCount: number = 0
61
+ private lastFrameTime = performance.now()
62
+ private stats: EngineStats = {
63
+ fps: 0,
64
+ frameTime: 0,
65
+ memoryUsed: 0,
66
+ vertices: 0,
67
+ drawCalls: 0,
68
+ triangles: 0,
69
+ materials: 0,
70
+ textures: 0,
71
+ textureMemory: 0,
72
+ bufferMemory: 0,
73
+ gpuMemory: 0,
74
+ }
75
+ private animationFrameId: number | null = null
76
+ private renderLoopCallback: (() => void) | null = null
77
+
78
+ constructor(canvas: HTMLCanvasElement) {
79
+ this.canvas = canvas
80
+ }
81
+
82
+ // Step 1: Get WebGPU device and context
83
+ public async init() {
84
+ const adapter = await navigator.gpu?.requestAdapter()
85
+ const device = await adapter?.requestDevice()
86
+ if (!device) {
87
+ throw new Error("WebGPU is not supported in this browser.")
88
+ }
89
+ this.device = device
90
+
91
+ const context = this.canvas.getContext("webgpu")
92
+ if (!context) {
93
+ throw new Error("Failed to get WebGPU context.")
94
+ }
95
+ this.context = context
96
+
97
+ this.presentationFormat = navigator.gpu.getPreferredCanvasFormat()
98
+
99
+ this.context.configure({
100
+ device: this.device,
101
+ format: this.presentationFormat,
102
+ alphaMode: "premultiplied",
103
+ })
104
+
105
+ this.setupCamera()
106
+ this.setupLighting()
107
+ this.createPipelines()
108
+ this.setupResize()
109
+ }
110
+
111
+ // Step 2: Create shaders and render pipelines
112
+ private createPipelines() {
113
+ this.textureSampler = this.device.createSampler({
114
+ magFilter: "linear",
115
+ minFilter: "linear",
116
+ addressModeU: "repeat",
117
+ addressModeV: "repeat",
118
+ })
119
+
120
+ const shaderModule = this.device.createShaderModule({
121
+ label: "model shaders",
122
+ code: /* wgsl */ `
123
+ struct CameraUniforms {
124
+ view: mat4x4f,
125
+ projection: mat4x4f,
126
+ viewPos: vec3f,
127
+ _padding: f32,
128
+ };
129
+
130
+ struct Light {
131
+ direction: vec3f,
132
+ _padding1: f32,
133
+ color: vec3f,
134
+ intensity: f32,
135
+ };
136
+
137
+ struct LightUniforms {
138
+ ambient: f32,
139
+ lightCount: f32,
140
+ _padding1: f32,
141
+ _padding2: f32,
142
+ lights: array<Light, 4>,
143
+ };
144
+
145
+ struct MaterialUniforms {
146
+ alpha: f32,
147
+ _padding1: f32,
148
+ _padding2: f32,
149
+ _padding3: f32,
150
+ };
151
+
152
+ struct VertexOutput {
153
+ @builtin(position) position: vec4f,
154
+ @location(0) normal: vec3f,
155
+ @location(1) uv: vec2f,
156
+ @location(2) worldPos: vec3f,
157
+ };
158
+
159
+ @group(0) @binding(0) var<uniform> camera: CameraUniforms;
160
+ @group(0) @binding(1) var<uniform> light: LightUniforms;
161
+ @group(0) @binding(2) var diffuseTexture: texture_2d<f32>;
162
+ @group(0) @binding(3) var diffuseSampler: sampler;
163
+ @group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
164
+ @group(0) @binding(5) var toonTexture: texture_2d<f32>;
165
+ @group(0) @binding(6) var toonSampler: sampler;
166
+ @group(0) @binding(7) var<uniform> material: MaterialUniforms;
167
+
168
+ @vertex fn vs(
169
+ @location(0) position: vec3f,
170
+ @location(1) normal: vec3f,
171
+ @location(2) uv: vec2f,
172
+ @location(3) joints0: vec4<u32>,
173
+ @location(4) weights0: vec4<f32>
174
+ ) -> VertexOutput {
175
+ var output: VertexOutput;
176
+ let pos4 = vec4f(position, 1.0);
177
+
178
+ // Normalize weights to ensure they sum to 1.0 (handles floating-point precision issues)
179
+ let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
180
+ var normalizedWeights: vec4f;
181
+ if (weightSum > 0.0001) {
182
+ normalizedWeights = weights0 / weightSum;
183
+ } else {
184
+ normalizedWeights = vec4f(1.0, 0.0, 0.0, 0.0);
185
+ }
186
+
187
+ var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
188
+ var skinnedNrm = vec3f(0.0, 0.0, 0.0);
189
+ for (var i = 0u; i < 4u; i++) {
190
+ let j = joints0[i];
191
+ let w = normalizedWeights[i];
192
+ let m = skinMats[j];
193
+ skinnedPos += (m * pos4) * w;
194
+ let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
195
+ skinnedNrm += (r3 * normal) * w;
196
+ }
197
+ let worldPos = skinnedPos.xyz;
198
+ output.position = camera.projection * camera.view * vec4f(worldPos, 1.0);
199
+ output.normal = normalize(skinnedNrm);
200
+ output.uv = uv;
201
+ output.worldPos = worldPos;
202
+ return output;
203
+ }
204
+
205
+ @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
206
+ let n = normalize(input.normal);
207
+ let albedo = textureSample(diffuseTexture, diffuseSampler, input.uv).rgb;
208
+
209
+ var lightAccum = vec3f(light.ambient);
210
+ let numLights = u32(light.lightCount);
211
+ for (var i = 0u; i < numLights; i++) {
212
+ let l = -light.lights[i].direction;
213
+ let nDotL = max(dot(n, l), 0.0);
214
+ let toonUV = vec2f(nDotL, 0.5);
215
+ let toonFactor = textureSample(toonTexture, toonSampler, toonUV).rgb;
216
+ let radiance = light.lights[i].color * light.lights[i].intensity;
217
+ lightAccum += toonFactor * radiance * nDotL;
218
+ }
219
+
220
+ let color = albedo * lightAccum;
221
+ let finalAlpha = material.alpha;
222
+ if (finalAlpha < 0.001) {
223
+ discard;
224
+ }
225
+
226
+ return vec4f(clamp(color, vec3f(0.0), vec3f(1.0)), finalAlpha);
227
+ }
228
+ `,
229
+ })
230
+
231
+ // Single pipeline for all materials with alpha blending
232
+ this.pipeline = this.device.createRenderPipeline({
233
+ label: "model pipeline",
234
+ layout: "auto",
235
+ vertex: {
236
+ module: shaderModule,
237
+ buffers: [
238
+ {
239
+ arrayStride: 8 * 4,
240
+ attributes: [
241
+ { shaderLocation: 0, offset: 0, format: "float32x3" as GPUVertexFormat },
242
+ { shaderLocation: 1, offset: 3 * 4, format: "float32x3" as GPUVertexFormat },
243
+ { shaderLocation: 2, offset: 6 * 4, format: "float32x2" as GPUVertexFormat },
244
+ ],
245
+ },
246
+ {
247
+ arrayStride: 4 * 2,
248
+ attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" as GPUVertexFormat }],
249
+ },
250
+ {
251
+ arrayStride: 4,
252
+ attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" as GPUVertexFormat }],
253
+ },
254
+ ],
255
+ },
256
+ fragment: {
257
+ module: shaderModule,
258
+ targets: [
259
+ {
260
+ format: this.presentationFormat,
261
+ blend: {
262
+ color: {
263
+ srcFactor: "src-alpha",
264
+ dstFactor: "one-minus-src-alpha",
265
+ operation: "add",
266
+ },
267
+ alpha: {
268
+ srcFactor: "one",
269
+ dstFactor: "one-minus-src-alpha",
270
+ operation: "add",
271
+ },
272
+ },
273
+ },
274
+ ],
275
+ },
276
+ primitive: { cullMode: "none" },
277
+ depthStencil: {
278
+ format: "depth24plus",
279
+ depthWriteEnabled: true,
280
+ depthCompare: "less",
281
+ },
282
+ multisample: {
283
+ count: this.sampleCount,
284
+ },
285
+ })
286
+
287
+ const outlineShaderModule = this.device.createShaderModule({
288
+ label: "outline shaders",
289
+ code: /* wgsl */ `
290
+ struct CameraUniforms {
291
+ view: mat4x4f,
292
+ projection: mat4x4f,
293
+ viewPos: vec3f,
294
+ _padding: f32,
295
+ };
296
+
297
+ struct MaterialUniforms {
298
+ edgeColor: vec4f,
299
+ edgeSize: f32,
300
+ _padding1: f32,
301
+ _padding2: f32,
302
+ _padding3: f32,
303
+ };
304
+
305
+ @group(0) @binding(0) var<uniform> camera: CameraUniforms;
306
+ @group(0) @binding(1) var<uniform> material: MaterialUniforms;
307
+ @group(0) @binding(2) var<storage, read> skinMats: array<mat4x4f>;
308
+
309
+ struct VertexOutput {
310
+ @builtin(position) position: vec4f,
311
+ };
312
+
313
+ @vertex fn vs(
314
+ @location(0) position: vec3f,
315
+ @location(1) normal: vec3f,
316
+ @location(2) uv: vec2f,
317
+ @location(3) joints0: vec4<u32>,
318
+ @location(4) weights0: vec4<f32>
319
+ ) -> VertexOutput {
320
+ var output: VertexOutput;
321
+ let pos4 = vec4f(position, 1.0);
322
+
323
+ // Normalize weights to ensure they sum to 1.0 (handles floating-point precision issues)
324
+ let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
325
+ var normalizedWeights: vec4f;
326
+ if (weightSum > 0.0001) {
327
+ normalizedWeights = weights0 / weightSum;
328
+ } else {
329
+ normalizedWeights = vec4f(1.0, 0.0, 0.0, 0.0);
330
+ }
331
+
332
+ var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
333
+ var skinnedNrm = vec3f(0.0, 0.0, 0.0);
334
+ for (var i = 0u; i < 4u; i++) {
335
+ let j = joints0[i];
336
+ let w = normalizedWeights[i];
337
+ let m = skinMats[j];
338
+ skinnedPos += (m * pos4) * w;
339
+ let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
340
+ skinnedNrm += (r3 * normal) * w;
341
+ }
342
+ let worldPos = skinnedPos.xyz;
343
+ let worldNormal = normalize(skinnedNrm);
344
+
345
+ // MMD invert hull: expand vertices outward along normals
346
+ let scaleFactor = 0.01;
347
+ let expandedPos = worldPos + worldNormal * material.edgeSize * scaleFactor;
348
+ output.position = camera.projection * camera.view * vec4f(expandedPos, 1.0);
349
+ return output;
350
+ }
351
+
352
+ @fragment fn fs() -> @location(0) vec4f {
353
+ return material.edgeColor;
354
+ }
355
+ `,
356
+ })
357
+
358
+ this.outlinePipeline = this.device.createRenderPipeline({
359
+ label: "outline pipeline",
360
+ layout: "auto",
361
+ vertex: {
362
+ module: outlineShaderModule,
363
+ buffers: [
364
+ {
365
+ arrayStride: 8 * 4,
366
+ attributes: [
367
+ {
368
+ shaderLocation: 0,
369
+ offset: 0,
370
+ format: "float32x3" as GPUVertexFormat,
371
+ },
372
+ {
373
+ shaderLocation: 1,
374
+ offset: 3 * 4,
375
+ format: "float32x3" as GPUVertexFormat,
376
+ },
377
+ {
378
+ shaderLocation: 2,
379
+ offset: 6 * 4,
380
+ format: "float32x2" as GPUVertexFormat,
381
+ },
382
+ ],
383
+ },
384
+ {
385
+ arrayStride: 4 * 2,
386
+ attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" as GPUVertexFormat }],
387
+ },
388
+ {
389
+ arrayStride: 4,
390
+ attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" as GPUVertexFormat }],
391
+ },
392
+ ],
393
+ },
394
+ fragment: {
395
+ module: outlineShaderModule,
396
+ targets: [
397
+ {
398
+ format: this.presentationFormat,
399
+ blend: {
400
+ color: {
401
+ srcFactor: "src-alpha",
402
+ dstFactor: "one-minus-src-alpha",
403
+ operation: "add",
404
+ },
405
+ alpha: {
406
+ srcFactor: "one",
407
+ dstFactor: "one-minus-src-alpha",
408
+ operation: "add",
409
+ },
410
+ },
411
+ },
412
+ ],
413
+ },
414
+ primitive: {
415
+ cullMode: "back",
416
+ },
417
+ depthStencil: {
418
+ format: "depth24plus",
419
+ depthWriteEnabled: true,
420
+ depthCompare: "less",
421
+ },
422
+ multisample: {
423
+ count: this.sampleCount,
424
+ },
425
+ })
426
+ }
427
+
428
+ // Create compute shader for skin matrix computation
429
+ private createSkinMatrixComputePipeline() {
430
+ const computeShader = this.device.createShaderModule({
431
+ label: "skin matrix compute",
432
+ code: /* wgsl */ `
433
+ struct BoneCountUniform {
434
+ count: u32,
435
+ _padding1: u32,
436
+ _padding2: u32,
437
+ _padding3: u32,
438
+ _padding4: vec4<u32>,
439
+ };
440
+
441
+ @group(0) @binding(0) var<uniform> boneCount: BoneCountUniform;
442
+ @group(0) @binding(1) var<storage, read> worldMatrices: array<mat4x4f>;
443
+ @group(0) @binding(2) var<storage, read> inverseBindMatrices: array<mat4x4f>;
444
+ @group(0) @binding(3) var<storage, read_write> skinMatrices: array<mat4x4f>;
445
+
446
+ @compute @workgroup_size(64)
447
+ fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
448
+ let boneIndex = globalId.x;
449
+ // Bounds check: we dispatch workgroups (64 threads each), so some threads may be out of range
450
+ if (boneIndex >= boneCount.count) {
451
+ return;
452
+ }
453
+ let worldMat = worldMatrices[boneIndex];
454
+ let invBindMat = inverseBindMatrices[boneIndex];
455
+ skinMatrices[boneIndex] = worldMat * invBindMat;
456
+ }
457
+ `,
458
+ })
459
+
460
+ this.skinMatrixComputePipeline = this.device.createComputePipeline({
461
+ label: "skin matrix compute pipeline",
462
+ layout: "auto",
463
+ compute: {
464
+ module: computeShader,
465
+ },
466
+ })
467
+ }
468
+
469
+ // Step 3: Setup canvas resize handling
470
+ private setupResize() {
471
+ this.resizeObserver = new ResizeObserver(() => this.handleResize())
472
+ this.resizeObserver.observe(this.canvas)
473
+ this.handleResize()
474
+ }
475
+
476
+ private handleResize() {
477
+ const displayWidth = this.canvas.clientWidth
478
+ const displayHeight = this.canvas.clientHeight
479
+
480
+ const dpr = window.devicePixelRatio || 1
481
+ const width = Math.floor(displayWidth * dpr)
482
+ const height = Math.floor(displayHeight * dpr)
483
+
484
+ if (!this.multisampleTexture || this.canvas.width !== width || this.canvas.height !== height) {
485
+ this.canvas.width = width
486
+ this.canvas.height = height
487
+
488
+ this.multisampleTexture = this.device.createTexture({
489
+ label: "multisample render target",
490
+ size: [width, height],
491
+ sampleCount: this.sampleCount,
492
+ format: this.presentationFormat,
493
+ usage: GPUTextureUsage.RENDER_ATTACHMENT,
494
+ })
495
+
496
+ this.depthTexture = this.device.createTexture({
497
+ label: "depth texture",
498
+ size: [width, height],
499
+ sampleCount: this.sampleCount,
500
+ format: "depth24plus",
501
+ usage: GPUTextureUsage.RENDER_ATTACHMENT,
502
+ })
503
+
504
+ const depthTextureView = this.depthTexture.createView()
505
+
506
+ const colorAttachment: GPURenderPassColorAttachment =
507
+ this.sampleCount > 1
508
+ ? {
509
+ view: this.multisampleTexture.createView(),
510
+ resolveTarget: this.context.getCurrentTexture().createView(),
511
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
512
+ loadOp: "clear",
513
+ storeOp: "store",
514
+ }
515
+ : {
516
+ view: this.context.getCurrentTexture().createView(),
517
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
518
+ loadOp: "clear",
519
+ storeOp: "store",
520
+ }
521
+
522
+ this.renderPassDescriptor = {
523
+ label: "renderPass",
524
+ colorAttachments: [colorAttachment],
525
+ depthStencilAttachment: {
526
+ view: depthTextureView,
527
+ depthClearValue: 1.0,
528
+ depthLoadOp: "clear",
529
+ depthStoreOp: "store",
530
+ },
531
+ }
532
+
533
+ this.camera.aspect = width / height
534
+ }
535
+ }
536
+
537
+ // Step 4: Create camera and uniform buffer
538
+ private setupCamera() {
539
+ this.cameraUniformBuffer = this.device.createBuffer({
540
+ label: "camera uniforms",
541
+ size: 40 * 4,
542
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
543
+ })
544
+
545
+ this.camera = new Camera(Math.PI, Math.PI / 2.5, 26.6, new Vec3(0, 12.5, 0))
546
+
547
+ this.camera.aspect = this.canvas.width / this.canvas.height
548
+ this.camera.attachControl(this.canvas)
549
+ }
550
+
551
+ // Step 5: Create lighting buffers
552
+ private setupLighting() {
553
+ this.lightUniformBuffer = this.device.createBuffer({
554
+ label: "light uniforms",
555
+ size: 64 * 4,
556
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
557
+ })
558
+
559
+ this.lightCount = 0
560
+
561
+ this.setAmbient(0.96)
562
+ this.addLight(new Vec3(-0.5, -0.8, 0.5).normalize(), new Vec3(1.0, 0.95, 0.9), 0.12)
563
+ this.addLight(new Vec3(0.7, -0.5, 0.3).normalize(), new Vec3(0.8, 0.85, 1.0), 0.1)
564
+ this.addLight(new Vec3(0.3, -0.5, -1.0).normalize(), new Vec3(0.9, 0.9, 1.0), 0.08)
565
+ this.device.queue.writeBuffer(this.lightUniformBuffer, 0, this.lightData)
566
+ }
567
+
568
+ public addLight(direction: Vec3, color: Vec3, intensity: number = 1.0): boolean {
569
+ if (this.lightCount >= 4) return false
570
+
571
+ const normalized = direction.normalize()
572
+ const baseIndex = 4 + this.lightCount * 8
573
+ this.lightData[baseIndex] = normalized.x
574
+ this.lightData[baseIndex + 1] = normalized.y
575
+ this.lightData[baseIndex + 2] = normalized.z
576
+ this.lightData[baseIndex + 3] = 0
577
+ this.lightData[baseIndex + 4] = color.x
578
+ this.lightData[baseIndex + 5] = color.y
579
+ this.lightData[baseIndex + 6] = color.z
580
+ this.lightData[baseIndex + 7] = intensity
581
+
582
+ this.lightCount++
583
+ this.lightData[1] = this.lightCount
584
+ return true
585
+ }
586
+
587
+ public setAmbient(intensity: number) {
588
+ this.lightData[0] = intensity
589
+ }
590
+
591
+ public getStats(): EngineStats {
592
+ return { ...this.stats }
593
+ }
594
+
595
+ public runRenderLoop(callback?: () => void) {
596
+ this.renderLoopCallback = callback || null
597
+
598
+ const loop = () => {
599
+ this.render()
600
+
601
+ if (this.renderLoopCallback) {
602
+ this.renderLoopCallback()
603
+ }
604
+
605
+ this.animationFrameId = requestAnimationFrame(loop)
606
+ }
607
+
608
+ this.animationFrameId = requestAnimationFrame(loop)
609
+ }
610
+
611
+ public stopRenderLoop() {
612
+ if (this.animationFrameId !== null) {
613
+ cancelAnimationFrame(this.animationFrameId)
614
+ this.animationFrameId = null
615
+ }
616
+ this.renderLoopCallback = null
617
+ }
618
+
619
+ public dispose() {
620
+ this.stopRenderLoop()
621
+ if (this.camera) this.camera.detachControl()
622
+ if (this.resizeObserver) {
623
+ this.resizeObserver.disconnect()
624
+ this.resizeObserver = null
625
+ }
626
+ }
627
+
628
+ // Step 6: Load PMX model file
629
+ public async loadModel(path: string) {
630
+ const pathParts = path.split("/")
631
+ pathParts.pop()
632
+ const dir = pathParts.join("/") + "/"
633
+ this.modelDir = dir
634
+
635
+ const model = await PmxLoader.load(path)
636
+ this.physics = new Physics(model.getRigidbodies(), model.getJoints())
637
+ await this.setupModelBuffers(model)
638
+ }
639
+
640
+ public rotateBones(bones: string[], rotations: Quat[], durationMs?: number) {
641
+ this.currentModel?.rotateBones(bones, rotations, durationMs)
642
+ }
643
+
644
+ // Step 7: Create vertex, index, and joint buffers
645
+ private async setupModelBuffers(model: Model) {
646
+ this.currentModel = model
647
+ const vertices = model.getVertices()
648
+ const skinning = model.getSkinning()
649
+ const skeleton = model.getSkeleton()
650
+
651
+ this.vertexBuffer = this.device.createBuffer({
652
+ label: "model vertex buffer",
653
+ size: vertices.byteLength,
654
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
655
+ })
656
+ this.device.queue.writeBuffer(this.vertexBuffer, 0, vertices)
657
+ this.vertexCount = model.getVertexCount()
658
+
659
+ this.jointsBuffer = this.device.createBuffer({
660
+ label: "joints buffer",
661
+ size: skinning.joints.byteLength,
662
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
663
+ })
664
+ this.device.queue.writeBuffer(
665
+ this.jointsBuffer,
666
+ 0,
667
+ skinning.joints.buffer,
668
+ skinning.joints.byteOffset,
669
+ skinning.joints.byteLength
670
+ )
671
+
672
+ this.weightsBuffer = this.device.createBuffer({
673
+ label: "weights buffer",
674
+ size: skinning.weights.byteLength,
675
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
676
+ })
677
+ this.device.queue.writeBuffer(
678
+ this.weightsBuffer,
679
+ 0,
680
+ skinning.weights.buffer,
681
+ skinning.weights.byteOffset,
682
+ skinning.weights.byteLength
683
+ )
684
+
685
+ const boneCount = skeleton.bones.length
686
+ const matrixSize = boneCount * 16 * 4
687
+
688
+ this.skinMatrixBuffer = this.device.createBuffer({
689
+ label: "skin matrices",
690
+ size: Math.max(256, matrixSize),
691
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.VERTEX,
692
+ })
693
+
694
+ this.worldMatrixBuffer = this.device.createBuffer({
695
+ label: "world matrices",
696
+ size: Math.max(256, matrixSize),
697
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
698
+ })
699
+
700
+ this.inverseBindMatrixBuffer = this.device.createBuffer({
701
+ label: "inverse bind matrices",
702
+ size: Math.max(256, matrixSize),
703
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
704
+ })
705
+
706
+ const invBindMatrices = skeleton.inverseBindMatrices
707
+ this.device.queue.writeBuffer(
708
+ this.inverseBindMatrixBuffer,
709
+ 0,
710
+ invBindMatrices.buffer,
711
+ invBindMatrices.byteOffset,
712
+ invBindMatrices.byteLength
713
+ )
714
+
715
+ this.boneCountBuffer = this.device.createBuffer({
716
+ label: "bone count uniform",
717
+ size: 32, // Minimum uniform buffer size is 32 bytes
718
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
719
+ })
720
+ const boneCountData = new Uint32Array(8) // 32 bytes total
721
+ boneCountData[0] = boneCount
722
+ this.device.queue.writeBuffer(this.boneCountBuffer, 0, boneCountData)
723
+
724
+ this.createSkinMatrixComputePipeline()
725
+
726
+ const indices = model.getIndices()
727
+ if (indices) {
728
+ this.indexBuffer = this.device.createBuffer({
729
+ label: "model index buffer",
730
+ size: indices.byteLength,
731
+ usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
732
+ })
733
+ this.device.queue.writeBuffer(this.indexBuffer, 0, indices)
734
+ } else {
735
+ throw new Error("Model has no index buffer")
736
+ }
737
+
738
+ await this.setupMaterials(model)
739
+ }
740
+
741
+ private materialDraws: { count: number; firstIndex: number; bindGroup: GPUBindGroup; isTransparent: boolean }[] = []
742
+ private outlineDraws: { count: number; firstIndex: number; bindGroup: GPUBindGroup; isTransparent: boolean }[] = []
743
+
744
+ // Step 8: Load textures and create material bind groups
745
+ private async setupMaterials(model: Model) {
746
+ const materials = model.getMaterials()
747
+ if (materials.length === 0) {
748
+ throw new Error("Model has no materials")
749
+ }
750
+
751
+ const textures = model.getTextures()
752
+
753
+ const loadTextureByIndex = async (texIndex: number): Promise<GPUTexture | null> => {
754
+ if (texIndex < 0 || texIndex >= textures.length) {
755
+ return null
756
+ }
757
+
758
+ const path = this.modelDir + textures[texIndex].path
759
+ const texture = await this.createTextureFromPath(path)
760
+ return texture
761
+ }
762
+
763
+ const loadToonTexture = async (toonTextureIndex: number): Promise<GPUTexture> => {
764
+ const texture = await loadTextureByIndex(toonTextureIndex)
765
+ if (texture) return texture
766
+
767
+ // Default toon texture fallback
768
+ const defaultToonData = new Uint8Array(256 * 2 * 4)
769
+ for (let i = 0; i < 256; i++) {
770
+ const factor = i / 255.0
771
+ const gray = Math.floor(128 + factor * 127)
772
+ defaultToonData[i * 4] = gray
773
+ defaultToonData[i * 4 + 1] = gray
774
+ defaultToonData[i * 4 + 2] = gray
775
+ defaultToonData[i * 4 + 3] = 255
776
+ defaultToonData[(256 + i) * 4] = gray
777
+ defaultToonData[(256 + i) * 4 + 1] = gray
778
+ defaultToonData[(256 + i) * 4 + 2] = gray
779
+ defaultToonData[(256 + i) * 4 + 3] = 255
780
+ }
781
+ const defaultToonTexture = this.device.createTexture({
782
+ label: "default toon texture",
783
+ size: [256, 2],
784
+ format: "rgba8unorm",
785
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
786
+ })
787
+ this.device.queue.writeTexture(
788
+ { texture: defaultToonTexture },
789
+ defaultToonData,
790
+ { bytesPerRow: 256 * 4 },
791
+ [256, 2]
792
+ )
793
+ this.textureSizes.set("__default_toon__", { width: 256, height: 2 })
794
+ return defaultToonTexture
795
+ }
796
+
797
+ this.materialDraws = []
798
+ this.outlineDraws = []
799
+ const outlineBindGroupLayout = this.outlinePipeline.getBindGroupLayout(0)
800
+ let runningFirstIndex = 0
801
+
802
+ for (const mat of materials) {
803
+ const matCount = mat.vertexCount | 0
804
+ if (matCount === 0) continue
805
+
806
+ const diffuseTexture = await loadTextureByIndex(mat.diffuseTextureIndex)
807
+ if (!diffuseTexture) throw new Error(`Material "${mat.name}" has no diffuse texture`)
808
+
809
+ const toonTexture = await loadToonTexture(mat.toonTextureIndex)
810
+
811
+ const materialAlpha = mat.diffuse[3]
812
+ const EPSILON = 0.001
813
+ const isTransparent = materialAlpha < 1.0 - EPSILON
814
+
815
+ const materialUniformData = new Float32Array(4)
816
+ materialUniformData[0] = materialAlpha
817
+ materialUniformData[1] = 0.0
818
+ materialUniformData[2] = 0.0
819
+ materialUniformData[3] = 0.0
820
+
821
+ const materialUniformBuffer = this.device.createBuffer({
822
+ label: `material uniform: ${mat.name}`,
823
+ size: materialUniformData.byteLength,
824
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
825
+ })
826
+ this.device.queue.writeBuffer(materialUniformBuffer, 0, materialUniformData)
827
+
828
+ const bindGroup = this.device.createBindGroup({
829
+ label: `material bind group: ${mat.name}`,
830
+ layout: this.pipeline.getBindGroupLayout(0),
831
+ entries: [
832
+ { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
833
+ { binding: 1, resource: { buffer: this.lightUniformBuffer } },
834
+ { binding: 2, resource: diffuseTexture.createView() },
835
+ { binding: 3, resource: this.textureSampler },
836
+ { binding: 4, resource: { buffer: this.skinMatrixBuffer! } },
837
+ { binding: 5, resource: toonTexture.createView() },
838
+ { binding: 6, resource: this.textureSampler },
839
+ { binding: 7, resource: { buffer: materialUniformBuffer } },
840
+ ],
841
+ })
842
+
843
+ // All materials use the same pipeline
844
+ this.materialDraws.push({
845
+ count: matCount,
846
+ firstIndex: runningFirstIndex,
847
+ bindGroup,
848
+ isTransparent,
849
+ })
850
+
851
+ // Outline for all materials (including transparent)
852
+ // Edge flag is at bit 4 (0x10) in PMX format, not bit 0 (0x01)
853
+ if ((mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
854
+ const materialUniformData = new Float32Array(8)
855
+ materialUniformData[0] = mat.edgeColor[0]
856
+ materialUniformData[1] = mat.edgeColor[1]
857
+ materialUniformData[2] = mat.edgeColor[2]
858
+ materialUniformData[3] = mat.edgeColor[3]
859
+ materialUniformData[4] = mat.edgeSize
860
+
861
+ const materialUniformBuffer = this.device.createBuffer({
862
+ label: `outline material uniform: ${mat.name}`,
863
+ size: materialUniformData.byteLength,
864
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
865
+ })
866
+ this.device.queue.writeBuffer(materialUniformBuffer, 0, materialUniformData)
867
+
868
+ const outlineBindGroup = this.device.createBindGroup({
869
+ label: `outline bind group: ${mat.name}`,
870
+ layout: outlineBindGroupLayout,
871
+ entries: [
872
+ { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
873
+ { binding: 1, resource: { buffer: materialUniformBuffer } },
874
+ { binding: 2, resource: { buffer: this.skinMatrixBuffer! } },
875
+ ],
876
+ })
877
+
878
+ // All outlines use the same pipeline
879
+ this.outlineDraws.push({
880
+ count: matCount,
881
+ firstIndex: runningFirstIndex,
882
+ bindGroup: outlineBindGroup,
883
+ isTransparent,
884
+ })
885
+ }
886
+
887
+ runningFirstIndex += matCount
888
+ }
889
+ }
890
+
891
+ // Helper: Load texture from file path
892
+ private async createTextureFromPath(path: string): Promise<GPUTexture | null> {
893
+ const cached = this.textureCache.get(path)
894
+ if (cached) {
895
+ return cached
896
+ }
897
+
898
+ try {
899
+ const response = await fetch(path)
900
+ if (!response.ok) {
901
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`)
902
+ }
903
+ const imageBitmap = await createImageBitmap(await response.blob(), {
904
+ premultiplyAlpha: "none",
905
+ colorSpaceConversion: "none",
906
+ })
907
+ const texture = this.device.createTexture({
908
+ label: `texture: ${path}`,
909
+ size: [imageBitmap.width, imageBitmap.height],
910
+ format: "rgba8unorm",
911
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
912
+ })
913
+ this.device.queue.copyExternalImageToTexture({ source: imageBitmap }, { texture }, [
914
+ imageBitmap.width,
915
+ imageBitmap.height,
916
+ ])
917
+
918
+ this.textureCache.set(path, texture)
919
+ this.textureSizes.set(path, { width: imageBitmap.width, height: imageBitmap.height })
920
+ return texture
921
+ } catch {
922
+ return null
923
+ }
924
+ }
925
+
926
+ // Step 9: Render one frame
927
+ public render() {
928
+ if (this.multisampleTexture && this.camera && this.device && this.currentModel) {
929
+ const currentTime = performance.now()
930
+ const deltaTime = this.lastFrameTime > 0 ? (currentTime - this.lastFrameTime) / 1000 : 0.016
931
+ this.lastFrameTime = currentTime
932
+
933
+ this.updateCameraUniforms()
934
+ this.updateRenderTarget()
935
+
936
+ this.updateModelPose(deltaTime)
937
+
938
+ const encoder = this.device.createCommandEncoder()
939
+ const pass = encoder.beginRenderPass(this.renderPassDescriptor)
940
+
941
+ pass.setVertexBuffer(0, this.vertexBuffer)
942
+ pass.setVertexBuffer(1, this.jointsBuffer)
943
+ pass.setVertexBuffer(2, this.weightsBuffer)
944
+ pass.setIndexBuffer(this.indexBuffer!, "uint32")
945
+
946
+ this.drawCallCount = 0
947
+ this.drawOutlines(pass, false)
948
+ this.drawModel(pass, false)
949
+ this.drawModel(pass, true)
950
+ this.drawOutlines(pass, true)
951
+
952
+ pass.end()
953
+ this.device.queue.submit([encoder.finish()])
954
+ this.updateStats(performance.now() - currentTime)
955
+ }
956
+ }
957
+
958
+ // Update camera uniform buffer each frame
959
+ private updateCameraUniforms() {
960
+ const viewMatrix = this.camera.getViewMatrix()
961
+ const projectionMatrix = this.camera.getProjectionMatrix()
962
+ const cameraPos = this.camera.getPosition()
963
+ this.cameraMatrixData.set(viewMatrix.values, 0)
964
+ this.cameraMatrixData.set(projectionMatrix.values, 16)
965
+ this.cameraMatrixData[32] = cameraPos.x
966
+ this.cameraMatrixData[33] = cameraPos.y
967
+ this.cameraMatrixData[34] = cameraPos.z
968
+ this.device.queue.writeBuffer(this.cameraUniformBuffer, 0, this.cameraMatrixData)
969
+ }
970
+
971
+ // Update render target texture view
972
+ private updateRenderTarget() {
973
+ const colorAttachment = (this.renderPassDescriptor.colorAttachments as GPURenderPassColorAttachment[])[0]
974
+ if (this.sampleCount > 1) {
975
+ colorAttachment.resolveTarget = this.context.getCurrentTexture().createView()
976
+ } else {
977
+ colorAttachment.view = this.context.getCurrentTexture().createView()
978
+ }
979
+ }
980
+
981
+ // Update model pose and physics
982
+ private updateModelPose(deltaTime: number) {
983
+ this.currentModel!.evaluatePose()
984
+
985
+ // Upload world matrices to GPU
986
+ const worldMats = this.currentModel!.getBoneWorldMatrices()
987
+ this.device.queue.writeBuffer(
988
+ this.worldMatrixBuffer!,
989
+ 0,
990
+ worldMats.buffer,
991
+ worldMats.byteOffset,
992
+ worldMats.byteLength
993
+ )
994
+
995
+ if (this.physics) {
996
+ this.physics.step(deltaTime, worldMats, this.currentModel!.getBoneInverseBindMatrices())
997
+ // Re-upload world matrices after physics (physics may have updated bones)
998
+ this.device.queue.writeBuffer(
999
+ this.worldMatrixBuffer!,
1000
+ 0,
1001
+ worldMats.buffer,
1002
+ worldMats.byteOffset,
1003
+ worldMats.byteLength
1004
+ )
1005
+ }
1006
+
1007
+ // Compute skin matrices on GPU
1008
+ this.computeSkinMatrices()
1009
+ }
1010
+
1011
+ // Compute skin matrices on GPU
1012
+ private computeSkinMatrices() {
1013
+ const boneCount = this.currentModel!.getSkeleton().bones.length
1014
+ const workgroupSize = 64
1015
+ // Dispatch exactly enough threads for all bones (no bounds check needed)
1016
+ const workgroupCount = Math.ceil(boneCount / workgroupSize)
1017
+
1018
+ // Update bone count uniform
1019
+ const boneCountData = new Uint32Array(8) // 32 bytes total
1020
+ boneCountData[0] = boneCount
1021
+ this.device.queue.writeBuffer(this.boneCountBuffer!, 0, boneCountData)
1022
+
1023
+ const bindGroup = this.device.createBindGroup({
1024
+ label: "skin matrix compute bind group",
1025
+ layout: this.skinMatrixComputePipeline!.getBindGroupLayout(0),
1026
+ entries: [
1027
+ { binding: 0, resource: { buffer: this.boneCountBuffer! } },
1028
+ { binding: 1, resource: { buffer: this.worldMatrixBuffer! } },
1029
+ { binding: 2, resource: { buffer: this.inverseBindMatrixBuffer! } },
1030
+ { binding: 3, resource: { buffer: this.skinMatrixBuffer! } },
1031
+ ],
1032
+ })
1033
+
1034
+ const encoder = this.device.createCommandEncoder()
1035
+ const pass = encoder.beginComputePass()
1036
+ pass.setPipeline(this.skinMatrixComputePipeline!)
1037
+ pass.setBindGroup(0, bindGroup)
1038
+ pass.dispatchWorkgroups(workgroupCount)
1039
+ pass.end()
1040
+ this.device.queue.submit([encoder.finish()])
1041
+ }
1042
+
1043
+ // Draw outlines (opaque or transparent)
1044
+ private drawOutlines(pass: GPURenderPassEncoder, transparent: boolean) {
1045
+ if (this.outlineDraws.length === 0) return
1046
+ pass.setPipeline(this.outlinePipeline)
1047
+ for (const draw of this.outlineDraws) {
1048
+ if (draw.count > 0 && draw.isTransparent === transparent) {
1049
+ pass.setBindGroup(0, draw.bindGroup)
1050
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1051
+ }
1052
+ }
1053
+ }
1054
+
1055
+ // Draw model materials (opaque or transparent)
1056
+ private drawModel(pass: GPURenderPassEncoder, transparent: boolean) {
1057
+ pass.setPipeline(this.pipeline)
1058
+ for (const draw of this.materialDraws) {
1059
+ if (draw.count > 0 && draw.isTransparent === transparent) {
1060
+ pass.setBindGroup(0, draw.bindGroup)
1061
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1062
+ this.drawCallCount++
1063
+ }
1064
+ }
1065
+ }
1066
+
1067
+ private updateStats(frameTime: number) {
1068
+ const maxSamples = 60
1069
+ this.frameTimeSamples.push(frameTime)
1070
+ this.frameTimeSum += frameTime
1071
+ if (this.frameTimeSamples.length > maxSamples) {
1072
+ const removed = this.frameTimeSamples.shift()!
1073
+ this.frameTimeSum -= removed
1074
+ }
1075
+ const avgFrameTime = this.frameTimeSum / this.frameTimeSamples.length
1076
+ this.stats.frameTime = Math.round(avgFrameTime * 100) / 100
1077
+
1078
+ const now = performance.now()
1079
+ this.framesSinceLastUpdate++
1080
+ const elapsed = now - this.lastFpsUpdate
1081
+
1082
+ if (elapsed >= 1000) {
1083
+ this.stats.fps = Math.round((this.framesSinceLastUpdate / elapsed) * 1000)
1084
+ this.framesSinceLastUpdate = 0
1085
+ this.lastFpsUpdate = now
1086
+
1087
+ const perf = performance as Performance & {
1088
+ memory?: { usedJSHeapSize: number; totalJSHeapSize: number }
1089
+ }
1090
+ if (perf.memory) {
1091
+ this.stats.memoryUsed = Math.round(perf.memory.usedJSHeapSize / 1024 / 1024)
1092
+ }
1093
+ }
1094
+
1095
+ this.stats.vertices = this.vertexCount
1096
+ this.stats.drawCalls = this.drawCallCount
1097
+
1098
+ // Calculate triangles from index buffer
1099
+ if (this.indexBuffer) {
1100
+ const indexCount = this.currentModel?.getIndices()?.length || 0
1101
+ this.stats.triangles = Math.floor(indexCount / 3)
1102
+ } else {
1103
+ this.stats.triangles = Math.floor(this.vertexCount / 3)
1104
+ }
1105
+
1106
+ // Material count
1107
+ this.stats.materials = this.materialDraws.length
1108
+
1109
+ // Texture stats
1110
+ this.stats.textures = this.textureCache.size
1111
+ let textureMemoryBytes = 0
1112
+ for (const [path, size] of this.textureSizes.entries()) {
1113
+ if (this.textureCache.has(path)) {
1114
+ // RGBA8 = 4 bytes per pixel
1115
+ textureMemoryBytes += size.width * size.height * 4
1116
+ }
1117
+ }
1118
+ // Add render target textures (multisample + depth)
1119
+ if (this.multisampleTexture) {
1120
+ const width = this.canvas.width
1121
+ const height = this.canvas.height
1122
+ textureMemoryBytes += width * height * 4 * this.sampleCount // multisample color
1123
+ textureMemoryBytes += width * height * 4 // depth (depth24plus = 4 bytes)
1124
+ }
1125
+ this.stats.textureMemory = Math.round((textureMemoryBytes / 1024 / 1024) * 100) / 100
1126
+
1127
+ // Buffer memory estimate
1128
+ let bufferMemoryBytes = 0
1129
+ if (this.vertexBuffer) {
1130
+ const vertices = this.currentModel?.getVertices()
1131
+ if (vertices) bufferMemoryBytes += vertices.byteLength
1132
+ }
1133
+ if (this.indexBuffer) {
1134
+ const indices = this.currentModel?.getIndices()
1135
+ if (indices) bufferMemoryBytes += indices.byteLength
1136
+ }
1137
+ if (this.jointsBuffer) {
1138
+ const skinning = this.currentModel?.getSkinning()
1139
+ if (skinning) bufferMemoryBytes += skinning.joints.byteLength
1140
+ }
1141
+ if (this.weightsBuffer) {
1142
+ const skinning = this.currentModel?.getSkinning()
1143
+ if (skinning) bufferMemoryBytes += skinning.weights.byteLength
1144
+ }
1145
+ if (this.skinMatrixBuffer) {
1146
+ const skeleton = this.currentModel?.getSkeleton()
1147
+ if (skeleton) bufferMemoryBytes += Math.max(256, skeleton.bones.length * 16 * 4)
1148
+ }
1149
+ bufferMemoryBytes += 40 * 4 // cameraUniformBuffer
1150
+ bufferMemoryBytes += 64 * 4 // lightUniformBuffer
1151
+ // Material uniform buffers (estimate: 4 bytes per material)
1152
+ bufferMemoryBytes += this.materialDraws.length * 4
1153
+ this.stats.bufferMemory = Math.round((bufferMemoryBytes / 1024 / 1024) * 100) / 100
1154
+
1155
+ // Total GPU memory estimate
1156
+ this.stats.gpuMemory = Math.round((this.stats.textureMemory + this.stats.bufferMemory) * 100) / 100
1157
+ }
1158
+ }