reze-engine 0.3.6 → 0.3.7

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/dist/engine.js CHANGED
@@ -1,8 +1,6 @@
1
1
  import { Camera } from "./camera";
2
- import { Quat, Vec3 } from "./math";
2
+ import { Vec3 } from "./math";
3
3
  import { PmxLoader } from "./pmx-loader";
4
- import { Physics } from "./physics";
5
- import { Player } from "./player";
6
4
  export class Engine {
7
5
  constructor(canvas, options) {
8
6
  this.cameraMatrixData = new Float32Array(36);
@@ -13,7 +11,6 @@ export class Engine {
13
11
  this.sampleCount = 4;
14
12
  // Constants
15
13
  this.STENCIL_EYE_VALUE = 1;
16
- this.COMPUTE_WORKGROUP_SIZE = 64;
17
14
  this.BLOOM_DOWNSCALE_FACTOR = 2;
18
15
  // Ambient light settings
19
16
  this.ambientColor = new Vec3(1.0, 1.0, 1.0);
@@ -24,19 +21,10 @@ export class Engine {
24
21
  this.rimLightIntensity = Engine.DEFAULT_RIM_LIGHT_INTENSITY;
25
22
  this.currentModel = null;
26
23
  this.modelDir = "";
27
- this.physics = null;
28
24
  this.textureCache = new Map();
29
25
  this.vertexBufferNeedsUpdate = false;
30
- // Draw lists
31
- this.opaqueDraws = [];
32
- this.eyeDraws = [];
33
- this.hairDrawsOverEyes = [];
34
- this.hairDrawsOverNonEyes = [];
35
- this.transparentDraws = [];
36
- this.opaqueOutlineDraws = [];
37
- this.eyeOutlineDraws = [];
38
- this.hairOutlineDraws = [];
39
- this.transparentOutlineDraws = [];
26
+ // Unified draw call list
27
+ this.drawCalls = [];
40
28
  this.lastFpsUpdate = performance.now();
41
29
  this.framesSinceLastUpdate = 0;
42
30
  this.lastFrameTime = performance.now();
@@ -48,8 +36,6 @@ export class Engine {
48
36
  };
49
37
  this.animationFrameId = null;
50
38
  this.renderLoopCallback = null;
51
- this.player = new Player();
52
- this.hasAnimation = false; // Set to true when loadAnimation is called
53
39
  this.canvas = canvas;
54
40
  if (options) {
55
41
  this.ambientColor = options.ambientColor ?? new Vec3(1.0, 1.0, 1.0);
@@ -84,6 +70,26 @@ export class Engine {
84
70
  this.createBloomPipelines();
85
71
  this.setupResize();
86
72
  }
73
+ createRenderPipeline(config) {
74
+ return this.device.createRenderPipeline({
75
+ label: config.label,
76
+ layout: config.layout,
77
+ vertex: {
78
+ module: config.shaderModule,
79
+ buffers: config.vertexBuffers,
80
+ },
81
+ fragment: config.fragmentTarget
82
+ ? {
83
+ module: config.shaderModule,
84
+ entryPoint: config.fragmentEntryPoint,
85
+ targets: [config.fragmentTarget],
86
+ }
87
+ : undefined,
88
+ primitive: { cullMode: config.cullMode ?? "none" },
89
+ depthStencil: config.depthStencil,
90
+ multisample: config.multisample ?? { count: this.sampleCount },
91
+ });
92
+ }
87
93
  createPipelines() {
88
94
  this.materialSampler = this.device.createSampler({
89
95
  magFilter: "linear",
@@ -91,6 +97,74 @@ export class Engine {
91
97
  addressModeU: "repeat",
92
98
  addressModeV: "repeat",
93
99
  });
100
+ // Shared vertex buffer layouts
101
+ const fullVertexBuffers = [
102
+ {
103
+ arrayStride: 8 * 4,
104
+ attributes: [
105
+ { shaderLocation: 0, offset: 0, format: "float32x3" },
106
+ { shaderLocation: 1, offset: 3 * 4, format: "float32x3" },
107
+ { shaderLocation: 2, offset: 6 * 4, format: "float32x2" },
108
+ ],
109
+ },
110
+ {
111
+ arrayStride: 4 * 2,
112
+ attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" }],
113
+ },
114
+ {
115
+ arrayStride: 4,
116
+ attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" }],
117
+ },
118
+ ];
119
+ const outlineVertexBuffers = [
120
+ {
121
+ arrayStride: 8 * 4,
122
+ attributes: [
123
+ { shaderLocation: 0, offset: 0, format: "float32x3" },
124
+ { shaderLocation: 1, offset: 3 * 4, format: "float32x3" },
125
+ ],
126
+ },
127
+ {
128
+ arrayStride: 4 * 2,
129
+ attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" }],
130
+ },
131
+ {
132
+ arrayStride: 4,
133
+ attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" }],
134
+ },
135
+ ];
136
+ const depthOnlyVertexBuffers = [
137
+ {
138
+ arrayStride: 8 * 4,
139
+ attributes: [
140
+ { shaderLocation: 0, offset: 0, format: "float32x3" },
141
+ { shaderLocation: 1, offset: 3 * 4, format: "float32x3" },
142
+ ],
143
+ },
144
+ {
145
+ arrayStride: 4 * 2,
146
+ attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" }],
147
+ },
148
+ {
149
+ arrayStride: 4,
150
+ attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" }],
151
+ },
152
+ ];
153
+ const standardBlend = {
154
+ format: this.presentationFormat,
155
+ blend: {
156
+ color: {
157
+ srcFactor: "src-alpha",
158
+ dstFactor: "one-minus-src-alpha",
159
+ operation: "add",
160
+ },
161
+ alpha: {
162
+ srcFactor: "one",
163
+ dstFactor: "one-minus-src-alpha",
164
+ operation: "add",
165
+ },
166
+ },
167
+ };
94
168
  const shaderModule = this.device.createShaderModule({
95
169
  label: "model shaders",
96
170
  code: /* wgsl */ `
@@ -204,59 +278,18 @@ export class Engine {
204
278
  label: "main pipeline layout",
205
279
  bindGroupLayouts: [this.mainBindGroupLayout],
206
280
  });
207
- this.modelPipeline = this.device.createRenderPipeline({
281
+ this.modelPipeline = this.createRenderPipeline({
208
282
  label: "model pipeline",
209
283
  layout: mainPipelineLayout,
210
- vertex: {
211
- module: shaderModule,
212
- buffers: [
213
- {
214
- arrayStride: 8 * 4,
215
- attributes: [
216
- { shaderLocation: 0, offset: 0, format: "float32x3" },
217
- { shaderLocation: 1, offset: 3 * 4, format: "float32x3" },
218
- { shaderLocation: 2, offset: 6 * 4, format: "float32x2" },
219
- ],
220
- },
221
- {
222
- arrayStride: 4 * 2,
223
- attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" }],
224
- },
225
- {
226
- arrayStride: 4,
227
- attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" }],
228
- },
229
- ],
230
- },
231
- fragment: {
232
- module: shaderModule,
233
- targets: [
234
- {
235
- format: this.presentationFormat,
236
- blend: {
237
- color: {
238
- srcFactor: "src-alpha",
239
- dstFactor: "one-minus-src-alpha",
240
- operation: "add",
241
- },
242
- alpha: {
243
- srcFactor: "one",
244
- dstFactor: "one-minus-src-alpha",
245
- operation: "add",
246
- },
247
- },
248
- },
249
- ],
250
- },
251
- primitive: { cullMode: "none" },
284
+ shaderModule,
285
+ vertexBuffers: fullVertexBuffers,
286
+ fragmentTarget: standardBlend,
287
+ cullMode: "none",
252
288
  depthStencil: {
253
289
  format: "depth24plus-stencil8",
254
290
  depthWriteEnabled: true,
255
291
  depthCompare: "less-equal",
256
292
  },
257
- multisample: {
258
- count: this.sampleCount,
259
- },
260
293
  });
261
294
  // Create bind group layout for outline pipelines
262
295
  this.outlineBindGroupLayout = this.device.createBindGroupLayout({
@@ -342,194 +375,56 @@ export class Engine {
342
375
  }
343
376
  `,
344
377
  });
345
- this.outlinePipeline = this.device.createRenderPipeline({
378
+ this.outlinePipeline = this.createRenderPipeline({
346
379
  label: "outline pipeline",
347
380
  layout: outlinePipelineLayout,
348
- vertex: {
349
- module: outlineShaderModule,
350
- buffers: [
351
- {
352
- arrayStride: 8 * 4,
353
- attributes: [
354
- {
355
- shaderLocation: 0,
356
- offset: 0,
357
- format: "float32x3",
358
- },
359
- {
360
- shaderLocation: 1,
361
- offset: 3 * 4,
362
- format: "float32x3",
363
- },
364
- ],
365
- },
366
- {
367
- arrayStride: 4 * 2,
368
- attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" }],
369
- },
370
- {
371
- arrayStride: 4,
372
- attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" }],
373
- },
374
- ],
375
- },
376
- fragment: {
377
- module: outlineShaderModule,
378
- targets: [
379
- {
380
- format: this.presentationFormat,
381
- blend: {
382
- color: {
383
- srcFactor: "src-alpha",
384
- dstFactor: "one-minus-src-alpha",
385
- operation: "add",
386
- },
387
- alpha: {
388
- srcFactor: "one",
389
- dstFactor: "one-minus-src-alpha",
390
- operation: "add",
391
- },
392
- },
393
- },
394
- ],
395
- },
396
- primitive: {
397
- cullMode: "back",
398
- },
381
+ shaderModule: outlineShaderModule,
382
+ vertexBuffers: outlineVertexBuffers,
383
+ fragmentTarget: standardBlend,
384
+ cullMode: "back",
399
385
  depthStencil: {
400
386
  format: "depth24plus-stencil8",
401
387
  depthWriteEnabled: true,
402
388
  depthCompare: "less-equal",
403
389
  },
404
- multisample: {
405
- count: this.sampleCount,
406
- },
407
390
  });
408
391
  // Hair outline pipeline
409
- this.hairOutlinePipeline = this.device.createRenderPipeline({
392
+ this.hairOutlinePipeline = this.createRenderPipeline({
410
393
  label: "hair outline pipeline",
411
394
  layout: outlinePipelineLayout,
412
- vertex: {
413
- module: outlineShaderModule,
414
- buffers: [
415
- {
416
- arrayStride: 8 * 4,
417
- attributes: [
418
- {
419
- shaderLocation: 0,
420
- offset: 0,
421
- format: "float32x3",
422
- },
423
- {
424
- shaderLocation: 1,
425
- offset: 3 * 4,
426
- format: "float32x3",
427
- },
428
- ],
429
- },
430
- {
431
- arrayStride: 4 * 2,
432
- attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" }],
433
- },
434
- {
435
- arrayStride: 4,
436
- attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" }],
437
- },
438
- ],
439
- },
440
- fragment: {
441
- module: outlineShaderModule,
442
- targets: [
443
- {
444
- format: this.presentationFormat,
445
- blend: {
446
- color: {
447
- srcFactor: "src-alpha",
448
- dstFactor: "one-minus-src-alpha",
449
- operation: "add",
450
- },
451
- alpha: {
452
- srcFactor: "one",
453
- dstFactor: "one-minus-src-alpha",
454
- operation: "add",
455
- },
456
- },
457
- },
458
- ],
459
- },
460
- primitive: {
461
- cullMode: "back",
462
- },
395
+ shaderModule: outlineShaderModule,
396
+ vertexBuffers: outlineVertexBuffers,
397
+ fragmentTarget: standardBlend,
398
+ cullMode: "back",
463
399
  depthStencil: {
464
400
  format: "depth24plus-stencil8",
465
- depthWriteEnabled: false, // Don't write depth - let hair geometry control depth
466
- depthCompare: "less-equal", // Only draw where hair depth exists (no stencil test needed)
467
- depthBias: -0.0001, // Small negative bias to bring outline slightly closer for depth test
401
+ depthWriteEnabled: false,
402
+ depthCompare: "less-equal",
403
+ depthBias: -0.0001,
468
404
  depthBiasSlopeScale: 0.0,
469
405
  depthBiasClamp: 0.0,
470
406
  },
471
- multisample: {
472
- count: this.sampleCount,
473
- },
474
407
  });
475
408
  // Eye overlay pipeline (renders after opaque, writes stencil)
476
- this.eyePipeline = this.device.createRenderPipeline({
409
+ this.eyePipeline = this.createRenderPipeline({
477
410
  label: "eye overlay pipeline",
478
411
  layout: mainPipelineLayout,
479
- vertex: {
480
- module: shaderModule,
481
- buffers: [
482
- {
483
- arrayStride: 8 * 4,
484
- attributes: [
485
- { shaderLocation: 0, offset: 0, format: "float32x3" },
486
- { shaderLocation: 1, offset: 3 * 4, format: "float32x3" },
487
- { shaderLocation: 2, offset: 6 * 4, format: "float32x2" },
488
- ],
489
- },
490
- {
491
- arrayStride: 4 * 2,
492
- attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" }],
493
- },
494
- {
495
- arrayStride: 4,
496
- attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" }],
497
- },
498
- ],
499
- },
500
- fragment: {
501
- module: shaderModule,
502
- targets: [
503
- {
504
- format: this.presentationFormat,
505
- blend: {
506
- color: {
507
- srcFactor: "src-alpha",
508
- dstFactor: "one-minus-src-alpha",
509
- operation: "add",
510
- },
511
- alpha: {
512
- srcFactor: "one",
513
- dstFactor: "one-minus-src-alpha",
514
- operation: "add",
515
- },
516
- },
517
- },
518
- ],
519
- },
520
- primitive: { cullMode: "front" },
412
+ shaderModule,
413
+ vertexBuffers: fullVertexBuffers,
414
+ fragmentTarget: standardBlend,
415
+ cullMode: "front",
521
416
  depthStencil: {
522
417
  format: "depth24plus-stencil8",
523
- depthWriteEnabled: true, // Write depth to occlude back of head
524
- depthCompare: "less-equal", // More lenient to reduce precision conflicts
525
- depthBias: -0.00005, // Reduced bias to minimize conflicts while still occluding back face
418
+ depthWriteEnabled: true,
419
+ depthCompare: "less-equal",
420
+ depthBias: -0.00005,
526
421
  depthBiasSlopeScale: 0.0,
527
422
  depthBiasClamp: 0.0,
528
423
  stencilFront: {
529
424
  compare: "always",
530
425
  failOp: "keep",
531
426
  depthFailOp: "keep",
532
- passOp: "replace", // Write stencil value 1
427
+ passOp: "replace",
533
428
  },
534
429
  stencilBack: {
535
430
  compare: "always",
@@ -538,7 +433,6 @@ export class Engine {
538
433
  passOp: "replace",
539
434
  },
540
435
  },
541
- multisample: { count: this.sampleCount },
542
436
  });
543
437
  // Depth-only shader for hair pre-pass (reduces overdraw by early depth rejection)
544
438
  const depthOnlyShaderModule = this.device.createShaderModule({
@@ -585,103 +479,41 @@ export class Engine {
585
479
  `,
586
480
  });
587
481
  // Hair depth pre-pass pipeline: depth-only with color writes disabled to eliminate overdraw
588
- this.hairDepthPipeline = this.device.createRenderPipeline({
482
+ this.hairDepthPipeline = this.createRenderPipeline({
589
483
  label: "hair depth pre-pass",
590
484
  layout: mainPipelineLayout,
591
- vertex: {
592
- module: depthOnlyShaderModule,
593
- buffers: [
594
- {
595
- arrayStride: 8 * 4,
596
- attributes: [
597
- { shaderLocation: 0, offset: 0, format: "float32x3" },
598
- { shaderLocation: 1, offset: 3 * 4, format: "float32x3" },
599
- ],
600
- },
601
- {
602
- arrayStride: 4 * 2,
603
- attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" }],
604
- },
605
- {
606
- arrayStride: 4,
607
- attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" }],
608
- },
609
- ],
610
- },
611
- fragment: {
612
- module: depthOnlyShaderModule,
613
- entryPoint: "fs",
614
- targets: [
615
- {
616
- format: this.presentationFormat,
617
- writeMask: 0, // Disable all color writes - we only care about depth
618
- },
619
- ],
485
+ shaderModule: depthOnlyShaderModule,
486
+ vertexBuffers: depthOnlyVertexBuffers,
487
+ fragmentTarget: {
488
+ format: this.presentationFormat,
489
+ writeMask: 0,
620
490
  },
621
- primitive: { cullMode: "front" },
491
+ fragmentEntryPoint: "fs",
492
+ cullMode: "front",
622
493
  depthStencil: {
623
494
  format: "depth24plus-stencil8",
624
495
  depthWriteEnabled: true,
625
- depthCompare: "less-equal", // Match the color pass compare mode for consistency
496
+ depthCompare: "less-equal",
626
497
  depthBias: 0.0,
627
498
  depthBiasSlopeScale: 0.0,
628
499
  depthBiasClamp: 0.0,
629
500
  },
630
- multisample: { count: this.sampleCount },
631
501
  });
632
502
  // Hair pipelines for rendering over eyes vs non-eyes (only differ in stencil compare mode)
633
503
  const createHairPipeline = (isOverEyes) => {
634
- return this.device.createRenderPipeline({
504
+ return this.createRenderPipeline({
635
505
  label: `hair pipeline (${isOverEyes ? "over eyes" : "over non-eyes"})`,
636
506
  layout: mainPipelineLayout,
637
- vertex: {
638
- module: shaderModule,
639
- buffers: [
640
- {
641
- arrayStride: 8 * 4,
642
- attributes: [
643
- { shaderLocation: 0, offset: 0, format: "float32x3" },
644
- { shaderLocation: 1, offset: 3 * 4, format: "float32x3" },
645
- { shaderLocation: 2, offset: 6 * 4, format: "float32x2" },
646
- ],
647
- },
648
- {
649
- arrayStride: 4 * 2,
650
- attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" }],
651
- },
652
- {
653
- arrayStride: 4,
654
- attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" }],
655
- },
656
- ],
657
- },
658
- fragment: {
659
- module: shaderModule,
660
- targets: [
661
- {
662
- format: this.presentationFormat,
663
- blend: {
664
- color: {
665
- srcFactor: "src-alpha",
666
- dstFactor: "one-minus-src-alpha",
667
- operation: "add",
668
- },
669
- alpha: {
670
- srcFactor: "one",
671
- dstFactor: "one-minus-src-alpha",
672
- operation: "add",
673
- },
674
- },
675
- },
676
- ],
677
- },
678
- primitive: { cullMode: "front" },
507
+ shaderModule,
508
+ vertexBuffers: fullVertexBuffers,
509
+ fragmentTarget: standardBlend,
510
+ cullMode: "front",
679
511
  depthStencil: {
680
512
  format: "depth24plus-stencil8",
681
- depthWriteEnabled: false, // Don't write depth (already written in pre-pass)
682
- depthCompare: "less-equal", // More lenient than "equal" to avoid precision issues with MSAA
513
+ depthWriteEnabled: false,
514
+ depthCompare: "less-equal",
683
515
  stencilFront: {
684
- compare: isOverEyes ? "equal" : "not-equal", // Over eyes: stencil == 1, over non-eyes: stencil != 1
516
+ compare: isOverEyes ? "equal" : "not-equal",
685
517
  failOp: "keep",
686
518
  depthFailOp: "keep",
687
519
  passOp: "keep",
@@ -693,50 +525,11 @@ export class Engine {
693
525
  passOp: "keep",
694
526
  },
695
527
  },
696
- multisample: { count: this.sampleCount },
697
528
  });
698
529
  };
699
530
  this.hairPipelineOverEyes = createHairPipeline(true);
700
531
  this.hairPipelineOverNonEyes = createHairPipeline(false);
701
532
  }
702
- // Create compute shader for skin matrix computation
703
- createSkinMatrixComputePipeline() {
704
- const computeShader = this.device.createShaderModule({
705
- label: "skin matrix compute",
706
- code: /* wgsl */ `
707
- struct BoneCountUniform {
708
- count: u32,
709
- _padding1: u32,
710
- _padding2: u32,
711
- _padding3: u32,
712
- _padding4: vec4<u32>,
713
- };
714
-
715
- @group(0) @binding(0) var<uniform> boneCount: BoneCountUniform;
716
- @group(0) @binding(1) var<storage, read> worldMatrices: array<mat4x4f>;
717
- @group(0) @binding(2) var<storage, read> inverseBindMatrices: array<mat4x4f>;
718
- @group(0) @binding(3) var<storage, read_write> skinMatrices: array<mat4x4f>;
719
-
720
- @compute @workgroup_size(64) // Must match COMPUTE_WORKGROUP_SIZE
721
- fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
722
- let boneIndex = globalId.x;
723
- if (boneIndex >= boneCount.count) {
724
- return;
725
- }
726
- let worldMat = worldMatrices[boneIndex];
727
- let invBindMat = inverseBindMatrices[boneIndex];
728
- skinMatrices[boneIndex] = worldMat * invBindMat;
729
- }
730
- `,
731
- });
732
- this.skinMatrixComputePipeline = this.device.createComputePipeline({
733
- label: "skin matrix compute pipeline",
734
- layout: "auto",
735
- compute: {
736
- module: computeShader,
737
- },
738
- });
739
- }
740
533
  // Create bloom post-processing pipelines
741
534
  createBloomPipelines() {
742
535
  // Bloom extraction shader (extracts bright areas)
@@ -1119,128 +912,24 @@ export class Engine {
1119
912
  this.lightData[3] = 0.0; // Padding for vec3f alignment
1120
913
  }
1121
914
  async loadAnimation(url) {
1122
- await this.player.loadVmd(url);
1123
- this.hasAnimation = true;
1124
- // Show first frame (time 0) immediately
1125
- if (this.currentModel) {
1126
- const initialPose = this.player.getPoseAtTime(0);
1127
- this.applyPose(initialPose);
1128
- // Reset bones without time 0 keyframes
1129
- const skeleton = this.currentModel.getSkeleton();
1130
- const bonesWithPose = new Set(initialPose.boneRotations.keys());
1131
- const bonesToReset = [];
1132
- for (const bone of skeleton.bones) {
1133
- if (!bonesWithPose.has(bone.name)) {
1134
- bonesToReset.push(bone.name);
1135
- }
1136
- }
1137
- if (bonesToReset.length > 0) {
1138
- const identityQuat = new Quat(0, 0, 0, 1);
1139
- const identityQuats = new Array(bonesToReset.length).fill(identityQuat);
1140
- this.rotateBones(bonesToReset, identityQuats, 0);
1141
- }
1142
- // Update model pose and physics
1143
- this.currentModel.evaluatePose();
1144
- if (this.physics) {
1145
- const worldMats = this.currentModel.getBoneWorldMatrices();
1146
- this.physics.reset(worldMats, this.currentModel.getBoneInverseBindMatrices());
1147
- // Upload matrices immediately
1148
- this.device.queue.writeBuffer(this.worldMatrixBuffer, 0, worldMats.buffer, worldMats.byteOffset, worldMats.byteLength);
1149
- const encoder = this.device.createCommandEncoder();
1150
- this.computeSkinMatrices(encoder);
1151
- this.device.queue.submit([encoder.finish()]);
1152
- }
1153
- }
915
+ if (!this.currentModel)
916
+ return;
917
+ await this.currentModel.loadVmd(url);
1154
918
  }
1155
919
  playAnimation() {
1156
- if (!this.hasAnimation || !this.currentModel)
1157
- return;
1158
- const wasPaused = this.player.isPausedState;
1159
- const wasPlaying = this.player.isPlayingState;
1160
- // Only reset pose and physics if starting from beginning (not resuming)
1161
- if (!wasPlaying && !wasPaused) {
1162
- // Get initial pose at time 0
1163
- const initialPose = this.player.getPoseAtTime(0);
1164
- this.applyPose(initialPose);
1165
- // Reset bones without time 0 keyframes
1166
- const skeleton = this.currentModel.getSkeleton();
1167
- const bonesWithPose = new Set(initialPose.boneRotations.keys());
1168
- const bonesToReset = [];
1169
- for (const bone of skeleton.bones) {
1170
- if (!bonesWithPose.has(bone.name)) {
1171
- bonesToReset.push(bone.name);
1172
- }
1173
- }
1174
- if (bonesToReset.length > 0) {
1175
- const identityQuat = new Quat(0, 0, 0, 1);
1176
- const identityQuats = new Array(bonesToReset.length).fill(identityQuat);
1177
- this.rotateBones(bonesToReset, identityQuats, 0);
1178
- }
1179
- // Reset physics immediately and upload matrices to prevent A-pose flash
1180
- if (this.physics) {
1181
- this.currentModel.evaluatePose();
1182
- const worldMats = this.currentModel.getBoneWorldMatrices();
1183
- this.physics.reset(worldMats, this.currentModel.getBoneInverseBindMatrices());
1184
- // Upload matrices immediately so next frame shows correct pose
1185
- this.device.queue.writeBuffer(this.worldMatrixBuffer, 0, worldMats.buffer, worldMats.byteOffset, worldMats.byteLength);
1186
- const encoder = this.device.createCommandEncoder();
1187
- this.computeSkinMatrices(encoder);
1188
- this.device.queue.submit([encoder.finish()]);
1189
- }
1190
- }
1191
- // Start playback (or resume if paused)
1192
- this.player.play();
920
+ this.currentModel?.playAnimation();
1193
921
  }
1194
922
  stopAnimation() {
1195
- this.player.stop();
923
+ this.currentModel?.stopAnimation();
1196
924
  }
1197
925
  pauseAnimation() {
1198
- this.player.pause();
926
+ this.currentModel?.pauseAnimation();
1199
927
  }
1200
928
  seekAnimation(time) {
1201
- if (!this.currentModel || !this.hasAnimation)
1202
- return;
1203
- this.player.seek(time);
1204
- // Immediately apply pose at seeked time
1205
- const pose = this.player.getPoseAtTime(time);
1206
- this.applyPose(pose);
1207
- // Update model pose and physics
1208
- this.currentModel.evaluatePose();
1209
- if (this.physics) {
1210
- const worldMats = this.currentModel.getBoneWorldMatrices();
1211
- this.physics.reset(worldMats, this.currentModel.getBoneInverseBindMatrices());
1212
- // Upload matrices immediately
1213
- this.device.queue.writeBuffer(this.worldMatrixBuffer, 0, worldMats.buffer, worldMats.byteOffset, worldMats.byteLength);
1214
- const encoder = this.device.createCommandEncoder();
1215
- this.computeSkinMatrices(encoder);
1216
- this.device.queue.submit([encoder.finish()]);
1217
- }
929
+ this.currentModel?.seekAnimation(time);
1218
930
  }
1219
931
  getAnimationProgress() {
1220
- return this.player.getProgress();
1221
- }
1222
- /**
1223
- * Apply animation pose to model
1224
- */
1225
- applyPose(pose) {
1226
- if (!this.currentModel)
1227
- return;
1228
- // Apply bone rotations
1229
- if (pose.boneRotations.size > 0) {
1230
- const boneNames = Array.from(pose.boneRotations.keys());
1231
- const rotations = Array.from(pose.boneRotations.values());
1232
- this.rotateBones(boneNames, rotations, 0);
1233
- }
1234
- // Apply bone translations
1235
- if (pose.boneTranslations.size > 0) {
1236
- const boneNames = Array.from(pose.boneTranslations.keys());
1237
- const translations = Array.from(pose.boneTranslations.values());
1238
- this.moveBones(boneNames, translations, 0);
1239
- }
1240
- // Apply morph weights
1241
- for (const [morphName, weight] of pose.morphWeights.entries()) {
1242
- this.setMorphWeight(morphName, weight, 0);
1243
- }
932
+ return this.currentModel?.getAnimationProgress() ?? { current: 0, duration: 0, percentage: 0 };
1244
933
  }
1245
934
  getStats() {
1246
935
  return { ...this.stats };
@@ -1280,7 +969,6 @@ export class Engine {
1280
969
  const dir = pathParts.join("/") + "/";
1281
970
  this.modelDir = dir;
1282
971
  const model = await PmxLoader.load(path);
1283
- this.physics = new Physics(model.getRigidbodies(), model.getJoints());
1284
972
  await this.setupModelBuffers(model);
1285
973
  }
1286
974
  rotateBones(bones, rotations, durationMs) {
@@ -1335,12 +1023,7 @@ export class Engine {
1335
1023
  this.skinMatrixBuffer = this.device.createBuffer({
1336
1024
  label: "skin matrices",
1337
1025
  size: Math.max(256, matrixSize),
1338
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.VERTEX,
1339
- });
1340
- this.worldMatrixBuffer = this.device.createBuffer({
1341
- label: "world matrices",
1342
- size: Math.max(256, matrixSize),
1343
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
1026
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
1344
1027
  });
1345
1028
  this.inverseBindMatrixBuffer = this.device.createBuffer({
1346
1029
  label: "inverse bind matrices",
@@ -1349,25 +1032,6 @@ export class Engine {
1349
1032
  });
1350
1033
  const invBindMatrices = skeleton.inverseBindMatrices;
1351
1034
  this.device.queue.writeBuffer(this.inverseBindMatrixBuffer, 0, invBindMatrices.buffer, invBindMatrices.byteOffset, invBindMatrices.byteLength);
1352
- this.boneCountBuffer = this.device.createBuffer({
1353
- label: "bone count uniform",
1354
- size: 32, // Minimum uniform buffer size is 32 bytes
1355
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1356
- });
1357
- const boneCountData = new Uint32Array(8); // 32 bytes total
1358
- boneCountData[0] = boneCount;
1359
- this.device.queue.writeBuffer(this.boneCountBuffer, 0, boneCountData);
1360
- this.createSkinMatrixComputePipeline();
1361
- // Create compute bind group once (reused every frame)
1362
- this.skinMatrixComputeBindGroup = this.device.createBindGroup({
1363
- layout: this.skinMatrixComputePipeline.getBindGroupLayout(0),
1364
- entries: [
1365
- { binding: 0, resource: { buffer: this.boneCountBuffer } },
1366
- { binding: 1, resource: { buffer: this.worldMatrixBuffer } },
1367
- { binding: 2, resource: { buffer: this.inverseBindMatrixBuffer } },
1368
- { binding: 3, resource: { buffer: this.skinMatrixBuffer } },
1369
- ],
1370
- });
1371
1035
  const indices = model.getIndices();
1372
1036
  if (indices) {
1373
1037
  this.indexBuffer = this.device.createBuffer({
@@ -1396,15 +1060,7 @@ export class Engine {
1396
1060
  const texture = await this.createTextureFromPath(path);
1397
1061
  return texture;
1398
1062
  };
1399
- this.opaqueDraws = [];
1400
- this.eyeDraws = [];
1401
- this.hairDrawsOverEyes = [];
1402
- this.hairDrawsOverNonEyes = [];
1403
- this.transparentDraws = [];
1404
- this.opaqueOutlineDraws = [];
1405
- this.eyeOutlineDraws = [];
1406
- this.hairOutlineDraws = [];
1407
- this.transparentOutlineDraws = [];
1063
+ this.drawCalls = [];
1408
1064
  let currentIndexOffset = 0;
1409
1065
  for (const mat of materials) {
1410
1066
  const indexCount = mat.vertexCount;
@@ -1429,51 +1085,48 @@ export class Engine {
1429
1085
  { binding: 5, resource: { buffer: materialUniformBuffer } },
1430
1086
  ],
1431
1087
  });
1432
- const addDrawCall = (draws) => {
1433
- if (indexCount > 0) {
1434
- draws.push({ count: indexCount, firstIndex: currentIndexOffset, bindGroup });
1088
+ if (indexCount > 0) {
1089
+ if (mat.isEye) {
1090
+ this.drawCalls.push({ type: "eye", count: indexCount, firstIndex: currentIndexOffset, bindGroup });
1435
1091
  }
1436
- };
1437
- if (mat.isEye) {
1438
- addDrawCall(this.eyeDraws);
1439
- }
1440
- else if (mat.isHair) {
1441
- // Hair materials: create separate bind groups for over-eyes vs over-non-eyes
1442
- const createHairBindGroup = (isOverEyes) => {
1443
- const buffer = this.createMaterialUniformBuffer(`${mat.name} (${isOverEyes ? "over eyes" : "over non-eyes"})`, materialAlpha, isOverEyes ? 1.0 : 0.0);
1444
- return this.device.createBindGroup({
1445
- label: `material bind group (${isOverEyes ? "over eyes" : "over non-eyes"}): ${mat.name}`,
1446
- layout: this.mainBindGroupLayout,
1447
- entries: [
1448
- { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1449
- { binding: 1, resource: { buffer: this.lightUniformBuffer } },
1450
- { binding: 2, resource: diffuseTexture.createView() },
1451
- { binding: 3, resource: this.materialSampler },
1452
- { binding: 4, resource: { buffer: this.skinMatrixBuffer } },
1453
- { binding: 5, resource: { buffer: buffer } },
1454
- ],
1455
- });
1456
- };
1457
- const bindGroupOverEyes = createHairBindGroup(true);
1458
- const bindGroupOverNonEyes = createHairBindGroup(false);
1459
- if (indexCount > 0) {
1460
- this.hairDrawsOverEyes.push({
1092
+ else if (mat.isHair) {
1093
+ // Hair materials: create separate bind groups for over-eyes vs over-non-eyes
1094
+ const createHairBindGroup = (isOverEyes) => {
1095
+ const buffer = this.createMaterialUniformBuffer(`${mat.name} (${isOverEyes ? "over eyes" : "over non-eyes"})`, materialAlpha, isOverEyes ? 1.0 : 0.0);
1096
+ return this.device.createBindGroup({
1097
+ label: `material bind group (${isOverEyes ? "over eyes" : "over non-eyes"}): ${mat.name}`,
1098
+ layout: this.mainBindGroupLayout,
1099
+ entries: [
1100
+ { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1101
+ { binding: 1, resource: { buffer: this.lightUniformBuffer } },
1102
+ { binding: 2, resource: diffuseTexture.createView() },
1103
+ { binding: 3, resource: this.materialSampler },
1104
+ { binding: 4, resource: { buffer: this.skinMatrixBuffer } },
1105
+ { binding: 5, resource: { buffer: buffer } },
1106
+ ],
1107
+ });
1108
+ };
1109
+ const bindGroupOverEyes = createHairBindGroup(true);
1110
+ const bindGroupOverNonEyes = createHairBindGroup(false);
1111
+ this.drawCalls.push({
1112
+ type: "hair-over-eyes",
1461
1113
  count: indexCount,
1462
1114
  firstIndex: currentIndexOffset,
1463
1115
  bindGroup: bindGroupOverEyes,
1464
1116
  });
1465
- this.hairDrawsOverNonEyes.push({
1117
+ this.drawCalls.push({
1118
+ type: "hair-over-non-eyes",
1466
1119
  count: indexCount,
1467
1120
  firstIndex: currentIndexOffset,
1468
1121
  bindGroup: bindGroupOverNonEyes,
1469
1122
  });
1470
1123
  }
1471
- }
1472
- else if (isTransparent) {
1473
- addDrawCall(this.transparentDraws);
1474
- }
1475
- else {
1476
- addDrawCall(this.opaqueDraws);
1124
+ else if (isTransparent) {
1125
+ this.drawCalls.push({ type: "transparent", count: indexCount, firstIndex: currentIndexOffset, bindGroup });
1126
+ }
1127
+ else {
1128
+ this.drawCalls.push({ type: "opaque", count: indexCount, firstIndex: currentIndexOffset, bindGroup });
1129
+ }
1477
1130
  }
1478
1131
  // Edge flag is at bit 4 (0x10) in PMX format
1479
1132
  if ((mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
@@ -1498,14 +1151,19 @@ export class Engine {
1498
1151
  ],
1499
1152
  });
1500
1153
  if (indexCount > 0) {
1501
- const outlineDraws = mat.isEye
1502
- ? this.eyeOutlineDraws
1154
+ const outlineType = mat.isEye
1155
+ ? "eye-outline"
1503
1156
  : mat.isHair
1504
- ? this.hairOutlineDraws
1157
+ ? "hair-outline"
1505
1158
  : isTransparent
1506
- ? this.transparentOutlineDraws
1507
- : this.opaqueOutlineDraws;
1508
- outlineDraws.push({ count: indexCount, firstIndex: currentIndexOffset, bindGroup: outlineBindGroup });
1159
+ ? "transparent-outline"
1160
+ : "opaque-outline";
1161
+ this.drawCalls.push({
1162
+ type: outlineType,
1163
+ count: indexCount,
1164
+ firstIndex: currentIndexOffset,
1165
+ bindGroup: outlineBindGroup,
1166
+ });
1509
1167
  }
1510
1168
  }
1511
1169
  currentIndexOffset += indexCount;
@@ -1560,47 +1218,50 @@ export class Engine {
1560
1218
  renderEyes(pass) {
1561
1219
  pass.setPipeline(this.eyePipeline);
1562
1220
  pass.setStencilReference(this.STENCIL_EYE_VALUE);
1563
- for (const draw of this.eyeDraws) {
1564
- pass.setBindGroup(0, draw.bindGroup);
1565
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1221
+ for (const draw of this.drawCalls) {
1222
+ if (draw.type === "eye") {
1223
+ pass.setBindGroup(0, draw.bindGroup);
1224
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1225
+ }
1566
1226
  }
1567
1227
  }
1568
1228
  // Helper: Render hair with post-alpha-eye effect (depth pre-pass + stencil-based shading + outlines)
1569
1229
  renderHair(pass) {
1570
1230
  // Hair depth pre-pass (reduces overdraw via early depth rejection)
1571
- const hasHair = this.hairDrawsOverEyes.length > 0 || this.hairDrawsOverNonEyes.length > 0;
1231
+ const hasHair = this.drawCalls.some((d) => d.type === "hair-over-eyes" || d.type === "hair-over-non-eyes");
1572
1232
  if (hasHair) {
1573
1233
  pass.setPipeline(this.hairDepthPipeline);
1574
- for (const draw of this.hairDrawsOverEyes) {
1575
- pass.setBindGroup(0, draw.bindGroup);
1576
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1577
- }
1578
- for (const draw of this.hairDrawsOverNonEyes) {
1579
- pass.setBindGroup(0, draw.bindGroup);
1580
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1234
+ for (const draw of this.drawCalls) {
1235
+ if (draw.type === "hair-over-eyes" || draw.type === "hair-over-non-eyes") {
1236
+ pass.setBindGroup(0, draw.bindGroup);
1237
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1238
+ }
1581
1239
  }
1582
1240
  }
1583
1241
  // Hair shading (split by stencil for transparency over eyes)
1584
- if (this.hairDrawsOverEyes.length > 0) {
1242
+ const hairOverEyes = this.drawCalls.filter((d) => d.type === "hair-over-eyes");
1243
+ if (hairOverEyes.length > 0) {
1585
1244
  pass.setPipeline(this.hairPipelineOverEyes);
1586
1245
  pass.setStencilReference(this.STENCIL_EYE_VALUE);
1587
- for (const draw of this.hairDrawsOverEyes) {
1246
+ for (const draw of hairOverEyes) {
1588
1247
  pass.setBindGroup(0, draw.bindGroup);
1589
1248
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1590
1249
  }
1591
1250
  }
1592
- if (this.hairDrawsOverNonEyes.length > 0) {
1251
+ const hairOverNonEyes = this.drawCalls.filter((d) => d.type === "hair-over-non-eyes");
1252
+ if (hairOverNonEyes.length > 0) {
1593
1253
  pass.setPipeline(this.hairPipelineOverNonEyes);
1594
1254
  pass.setStencilReference(this.STENCIL_EYE_VALUE);
1595
- for (const draw of this.hairDrawsOverNonEyes) {
1255
+ for (const draw of hairOverNonEyes) {
1596
1256
  pass.setBindGroup(0, draw.bindGroup);
1597
1257
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1598
1258
  }
1599
1259
  }
1600
1260
  // Hair outlines
1601
- if (this.hairOutlineDraws.length > 0) {
1261
+ const hairOutlines = this.drawCalls.filter((d) => d.type === "hair-outline");
1262
+ if (hairOutlines.length > 0) {
1602
1263
  pass.setPipeline(this.hairOutlinePipeline);
1603
- for (const draw of this.hairOutlineDraws) {
1264
+ for (const draw of hairOutlines) {
1604
1265
  pass.setBindGroup(0, draw.bindGroup);
1605
1266
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1606
1267
  }
@@ -1614,18 +1275,10 @@ export class Engine {
1614
1275
  this.lastFrameTime = currentTime;
1615
1276
  this.updateCameraUniforms();
1616
1277
  this.updateRenderTarget();
1617
- // Animate VMD animation if playing
1618
- if (this.hasAnimation && this.currentModel) {
1619
- const pose = this.player.update(currentTime);
1620
- if (pose) {
1621
- this.applyPose(pose);
1622
- }
1623
- }
1624
- // Update model pose first (this may update morph weights via tweens)
1625
- // We need to do this before creating the encoder to ensure vertex buffer is ready
1278
+ // Update model (handles tweens, animation, physics, IK, and skin matrices)
1626
1279
  if (this.currentModel) {
1627
- const hasActiveMorphTweens = this.currentModel.evaluatePose();
1628
- if (hasActiveMorphTweens) {
1280
+ const verticesChanged = this.currentModel.update(deltaTime);
1281
+ if (verticesChanged) {
1629
1282
  this.vertexBufferNeedsUpdate = true;
1630
1283
  }
1631
1284
  }
@@ -1634,9 +1287,10 @@ export class Engine {
1634
1287
  this.updateVertexBuffer();
1635
1288
  this.vertexBufferNeedsUpdate = false;
1636
1289
  }
1637
- // Use single encoder for both compute and render (reduces sync points)
1290
+ // Update skin matrices buffer
1291
+ this.updateSkinMatrices();
1292
+ // Use single encoder for render
1638
1293
  const encoder = this.device.createCommandEncoder();
1639
- this.updateModelPose(deltaTime, encoder);
1640
1294
  const pass = encoder.beginRenderPass(this.renderPassDescriptor);
1641
1295
  if (this.currentModel) {
1642
1296
  pass.setVertexBuffer(0, this.vertexBuffer);
@@ -1645,9 +1299,11 @@ export class Engine {
1645
1299
  pass.setIndexBuffer(this.indexBuffer, "uint32");
1646
1300
  // Pass 1: Opaque
1647
1301
  pass.setPipeline(this.modelPipeline);
1648
- for (const draw of this.opaqueDraws) {
1649
- pass.setBindGroup(0, draw.bindGroup);
1650
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1302
+ for (const draw of this.drawCalls) {
1303
+ if (draw.type === "opaque") {
1304
+ pass.setBindGroup(0, draw.bindGroup);
1305
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1306
+ }
1651
1307
  }
1652
1308
  // Pass 2: Eyes (writes stencil value for hair to test against)
1653
1309
  this.renderEyes(pass);
@@ -1656,9 +1312,11 @@ export class Engine {
1656
1312
  this.renderHair(pass);
1657
1313
  // Pass 4: Transparent
1658
1314
  pass.setPipeline(this.modelPipeline);
1659
- for (const draw of this.transparentDraws) {
1660
- pass.setBindGroup(0, draw.bindGroup);
1661
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1315
+ for (const draw of this.drawCalls) {
1316
+ if (draw.type === "transparent") {
1317
+ pass.setBindGroup(0, draw.bindGroup);
1318
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1319
+ }
1662
1320
  }
1663
1321
  this.drawOutlines(pass, true);
1664
1322
  }
@@ -1775,31 +1433,20 @@ export class Engine {
1775
1433
  colorAttachment.view = this.sceneRenderTextureView;
1776
1434
  }
1777
1435
  }
1778
- updateModelPose(deltaTime, encoder) {
1779
- // Note: evaluatePose is called earlier in render() to update vertex buffer before encoder creation
1780
- // Here we just get the matrices and update physics/compute
1781
- const worldMats = this.currentModel.getBoneWorldMatrices();
1782
- if (this.physics) {
1783
- this.physics.step(deltaTime, worldMats, this.currentModel.getBoneInverseBindMatrices());
1784
- }
1785
- this.device.queue.writeBuffer(this.worldMatrixBuffer, 0, worldMats.buffer, worldMats.byteOffset, worldMats.byteLength);
1786
- this.computeSkinMatrices(encoder);
1787
- }
1788
- computeSkinMatrices(encoder) {
1789
- const boneCount = this.currentModel.getSkeleton().bones.length;
1790
- const workgroupCount = Math.ceil(boneCount / this.COMPUTE_WORKGROUP_SIZE);
1791
- const pass = encoder.beginComputePass();
1792
- pass.setPipeline(this.skinMatrixComputePipeline);
1793
- pass.setBindGroup(0, this.skinMatrixComputeBindGroup);
1794
- pass.dispatchWorkgroups(workgroupCount);
1795
- pass.end();
1436
+ updateSkinMatrices() {
1437
+ if (!this.currentModel || !this.skinMatrixBuffer)
1438
+ return;
1439
+ const skinMatrices = this.currentModel.getSkinMatrices();
1440
+ this.device.queue.writeBuffer(this.skinMatrixBuffer, 0, skinMatrices.buffer, skinMatrices.byteOffset, skinMatrices.byteLength);
1796
1441
  }
1797
1442
  drawOutlines(pass, transparent) {
1798
1443
  pass.setPipeline(this.outlinePipeline);
1799
- const draws = transparent ? this.transparentOutlineDraws : this.opaqueOutlineDraws;
1800
- for (const draw of draws) {
1801
- pass.setBindGroup(0, draw.bindGroup);
1802
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1444
+ const outlineType = transparent ? "transparent-outline" : "opaque-outline";
1445
+ for (const draw of this.drawCalls) {
1446
+ if (draw.type === outlineType) {
1447
+ pass.setBindGroup(0, draw.bindGroup);
1448
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1449
+ }
1803
1450
  }
1804
1451
  }
1805
1452
  updateStats(frameTime) {