reze-engine 0.3.5 → 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,9 +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
- this.animationStartTime = 0; // Track when animation first started (for A-pose prevention)
54
39
  this.canvas = canvas;
55
40
  if (options) {
56
41
  this.ambientColor = options.ambientColor ?? new Vec3(1.0, 1.0, 1.0);
@@ -85,6 +70,26 @@ export class Engine {
85
70
  this.createBloomPipelines();
86
71
  this.setupResize();
87
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
+ }
88
93
  createPipelines() {
89
94
  this.materialSampler = this.device.createSampler({
90
95
  magFilter: "linear",
@@ -92,6 +97,74 @@ export class Engine {
92
97
  addressModeU: "repeat",
93
98
  addressModeV: "repeat",
94
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
+ };
95
168
  const shaderModule = this.device.createShaderModule({
96
169
  label: "model shaders",
97
170
  code: /* wgsl */ `
@@ -205,59 +278,18 @@ export class Engine {
205
278
  label: "main pipeline layout",
206
279
  bindGroupLayouts: [this.mainBindGroupLayout],
207
280
  });
208
- this.modelPipeline = this.device.createRenderPipeline({
281
+ this.modelPipeline = this.createRenderPipeline({
209
282
  label: "model pipeline",
210
283
  layout: mainPipelineLayout,
211
- vertex: {
212
- module: shaderModule,
213
- buffers: [
214
- {
215
- arrayStride: 8 * 4,
216
- attributes: [
217
- { shaderLocation: 0, offset: 0, format: "float32x3" },
218
- { shaderLocation: 1, offset: 3 * 4, format: "float32x3" },
219
- { shaderLocation: 2, offset: 6 * 4, format: "float32x2" },
220
- ],
221
- },
222
- {
223
- arrayStride: 4 * 2,
224
- attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" }],
225
- },
226
- {
227
- arrayStride: 4,
228
- attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" }],
229
- },
230
- ],
231
- },
232
- fragment: {
233
- module: shaderModule,
234
- targets: [
235
- {
236
- format: this.presentationFormat,
237
- blend: {
238
- color: {
239
- srcFactor: "src-alpha",
240
- dstFactor: "one-minus-src-alpha",
241
- operation: "add",
242
- },
243
- alpha: {
244
- srcFactor: "one",
245
- dstFactor: "one-minus-src-alpha",
246
- operation: "add",
247
- },
248
- },
249
- },
250
- ],
251
- },
252
- primitive: { cullMode: "none" },
284
+ shaderModule,
285
+ vertexBuffers: fullVertexBuffers,
286
+ fragmentTarget: standardBlend,
287
+ cullMode: "none",
253
288
  depthStencil: {
254
289
  format: "depth24plus-stencil8",
255
290
  depthWriteEnabled: true,
256
291
  depthCompare: "less-equal",
257
292
  },
258
- multisample: {
259
- count: this.sampleCount,
260
- },
261
293
  });
262
294
  // Create bind group layout for outline pipelines
263
295
  this.outlineBindGroupLayout = this.device.createBindGroupLayout({
@@ -343,194 +375,56 @@ export class Engine {
343
375
  }
344
376
  `,
345
377
  });
346
- this.outlinePipeline = this.device.createRenderPipeline({
378
+ this.outlinePipeline = this.createRenderPipeline({
347
379
  label: "outline pipeline",
348
380
  layout: outlinePipelineLayout,
349
- vertex: {
350
- module: outlineShaderModule,
351
- buffers: [
352
- {
353
- arrayStride: 8 * 4,
354
- attributes: [
355
- {
356
- shaderLocation: 0,
357
- offset: 0,
358
- format: "float32x3",
359
- },
360
- {
361
- shaderLocation: 1,
362
- offset: 3 * 4,
363
- format: "float32x3",
364
- },
365
- ],
366
- },
367
- {
368
- arrayStride: 4 * 2,
369
- attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" }],
370
- },
371
- {
372
- arrayStride: 4,
373
- attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" }],
374
- },
375
- ],
376
- },
377
- fragment: {
378
- module: outlineShaderModule,
379
- targets: [
380
- {
381
- format: this.presentationFormat,
382
- blend: {
383
- color: {
384
- srcFactor: "src-alpha",
385
- dstFactor: "one-minus-src-alpha",
386
- operation: "add",
387
- },
388
- alpha: {
389
- srcFactor: "one",
390
- dstFactor: "one-minus-src-alpha",
391
- operation: "add",
392
- },
393
- },
394
- },
395
- ],
396
- },
397
- primitive: {
398
- cullMode: "back",
399
- },
381
+ shaderModule: outlineShaderModule,
382
+ vertexBuffers: outlineVertexBuffers,
383
+ fragmentTarget: standardBlend,
384
+ cullMode: "back",
400
385
  depthStencil: {
401
386
  format: "depth24plus-stencil8",
402
387
  depthWriteEnabled: true,
403
388
  depthCompare: "less-equal",
404
389
  },
405
- multisample: {
406
- count: this.sampleCount,
407
- },
408
390
  });
409
391
  // Hair outline pipeline
410
- this.hairOutlinePipeline = this.device.createRenderPipeline({
392
+ this.hairOutlinePipeline = this.createRenderPipeline({
411
393
  label: "hair outline pipeline",
412
394
  layout: outlinePipelineLayout,
413
- vertex: {
414
- module: outlineShaderModule,
415
- buffers: [
416
- {
417
- arrayStride: 8 * 4,
418
- attributes: [
419
- {
420
- shaderLocation: 0,
421
- offset: 0,
422
- format: "float32x3",
423
- },
424
- {
425
- shaderLocation: 1,
426
- offset: 3 * 4,
427
- format: "float32x3",
428
- },
429
- ],
430
- },
431
- {
432
- arrayStride: 4 * 2,
433
- attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" }],
434
- },
435
- {
436
- arrayStride: 4,
437
- attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" }],
438
- },
439
- ],
440
- },
441
- fragment: {
442
- module: outlineShaderModule,
443
- targets: [
444
- {
445
- format: this.presentationFormat,
446
- blend: {
447
- color: {
448
- srcFactor: "src-alpha",
449
- dstFactor: "one-minus-src-alpha",
450
- operation: "add",
451
- },
452
- alpha: {
453
- srcFactor: "one",
454
- dstFactor: "one-minus-src-alpha",
455
- operation: "add",
456
- },
457
- },
458
- },
459
- ],
460
- },
461
- primitive: {
462
- cullMode: "back",
463
- },
395
+ shaderModule: outlineShaderModule,
396
+ vertexBuffers: outlineVertexBuffers,
397
+ fragmentTarget: standardBlend,
398
+ cullMode: "back",
464
399
  depthStencil: {
465
400
  format: "depth24plus-stencil8",
466
- depthWriteEnabled: false, // Don't write depth - let hair geometry control depth
467
- depthCompare: "less-equal", // Only draw where hair depth exists (no stencil test needed)
468
- 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,
469
404
  depthBiasSlopeScale: 0.0,
470
405
  depthBiasClamp: 0.0,
471
406
  },
472
- multisample: {
473
- count: this.sampleCount,
474
- },
475
407
  });
476
408
  // Eye overlay pipeline (renders after opaque, writes stencil)
477
- this.eyePipeline = this.device.createRenderPipeline({
409
+ this.eyePipeline = this.createRenderPipeline({
478
410
  label: "eye overlay pipeline",
479
411
  layout: mainPipelineLayout,
480
- vertex: {
481
- module: shaderModule,
482
- buffers: [
483
- {
484
- arrayStride: 8 * 4,
485
- attributes: [
486
- { shaderLocation: 0, offset: 0, format: "float32x3" },
487
- { shaderLocation: 1, offset: 3 * 4, format: "float32x3" },
488
- { shaderLocation: 2, offset: 6 * 4, format: "float32x2" },
489
- ],
490
- },
491
- {
492
- arrayStride: 4 * 2,
493
- attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" }],
494
- },
495
- {
496
- arrayStride: 4,
497
- attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" }],
498
- },
499
- ],
500
- },
501
- fragment: {
502
- module: shaderModule,
503
- targets: [
504
- {
505
- format: this.presentationFormat,
506
- blend: {
507
- color: {
508
- srcFactor: "src-alpha",
509
- dstFactor: "one-minus-src-alpha",
510
- operation: "add",
511
- },
512
- alpha: {
513
- srcFactor: "one",
514
- dstFactor: "one-minus-src-alpha",
515
- operation: "add",
516
- },
517
- },
518
- },
519
- ],
520
- },
521
- primitive: { cullMode: "front" },
412
+ shaderModule,
413
+ vertexBuffers: fullVertexBuffers,
414
+ fragmentTarget: standardBlend,
415
+ cullMode: "front",
522
416
  depthStencil: {
523
417
  format: "depth24plus-stencil8",
524
- depthWriteEnabled: true, // Write depth to occlude back of head
525
- depthCompare: "less-equal", // More lenient to reduce precision conflicts
526
- depthBias: -0.00005, // Reduced bias to minimize conflicts while still occluding back face
418
+ depthWriteEnabled: true,
419
+ depthCompare: "less-equal",
420
+ depthBias: -0.00005,
527
421
  depthBiasSlopeScale: 0.0,
528
422
  depthBiasClamp: 0.0,
529
423
  stencilFront: {
530
424
  compare: "always",
531
425
  failOp: "keep",
532
426
  depthFailOp: "keep",
533
- passOp: "replace", // Write stencil value 1
427
+ passOp: "replace",
534
428
  },
535
429
  stencilBack: {
536
430
  compare: "always",
@@ -539,7 +433,6 @@ export class Engine {
539
433
  passOp: "replace",
540
434
  },
541
435
  },
542
- multisample: { count: this.sampleCount },
543
436
  });
544
437
  // Depth-only shader for hair pre-pass (reduces overdraw by early depth rejection)
545
438
  const depthOnlyShaderModule = this.device.createShaderModule({
@@ -586,103 +479,41 @@ export class Engine {
586
479
  `,
587
480
  });
588
481
  // Hair depth pre-pass pipeline: depth-only with color writes disabled to eliminate overdraw
589
- this.hairDepthPipeline = this.device.createRenderPipeline({
482
+ this.hairDepthPipeline = this.createRenderPipeline({
590
483
  label: "hair depth pre-pass",
591
484
  layout: mainPipelineLayout,
592
- vertex: {
593
- module: depthOnlyShaderModule,
594
- buffers: [
595
- {
596
- arrayStride: 8 * 4,
597
- attributes: [
598
- { shaderLocation: 0, offset: 0, format: "float32x3" },
599
- { shaderLocation: 1, offset: 3 * 4, format: "float32x3" },
600
- ],
601
- },
602
- {
603
- arrayStride: 4 * 2,
604
- attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" }],
605
- },
606
- {
607
- arrayStride: 4,
608
- attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" }],
609
- },
610
- ],
611
- },
612
- fragment: {
613
- module: depthOnlyShaderModule,
614
- entryPoint: "fs",
615
- targets: [
616
- {
617
- format: this.presentationFormat,
618
- writeMask: 0, // Disable all color writes - we only care about depth
619
- },
620
- ],
485
+ shaderModule: depthOnlyShaderModule,
486
+ vertexBuffers: depthOnlyVertexBuffers,
487
+ fragmentTarget: {
488
+ format: this.presentationFormat,
489
+ writeMask: 0,
621
490
  },
622
- primitive: { cullMode: "front" },
491
+ fragmentEntryPoint: "fs",
492
+ cullMode: "front",
623
493
  depthStencil: {
624
494
  format: "depth24plus-stencil8",
625
495
  depthWriteEnabled: true,
626
- depthCompare: "less-equal", // Match the color pass compare mode for consistency
496
+ depthCompare: "less-equal",
627
497
  depthBias: 0.0,
628
498
  depthBiasSlopeScale: 0.0,
629
499
  depthBiasClamp: 0.0,
630
500
  },
631
- multisample: { count: this.sampleCount },
632
501
  });
633
502
  // Hair pipelines for rendering over eyes vs non-eyes (only differ in stencil compare mode)
634
503
  const createHairPipeline = (isOverEyes) => {
635
- return this.device.createRenderPipeline({
504
+ return this.createRenderPipeline({
636
505
  label: `hair pipeline (${isOverEyes ? "over eyes" : "over non-eyes"})`,
637
506
  layout: mainPipelineLayout,
638
- vertex: {
639
- module: shaderModule,
640
- buffers: [
641
- {
642
- arrayStride: 8 * 4,
643
- attributes: [
644
- { shaderLocation: 0, offset: 0, format: "float32x3" },
645
- { shaderLocation: 1, offset: 3 * 4, format: "float32x3" },
646
- { shaderLocation: 2, offset: 6 * 4, format: "float32x2" },
647
- ],
648
- },
649
- {
650
- arrayStride: 4 * 2,
651
- attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" }],
652
- },
653
- {
654
- arrayStride: 4,
655
- attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" }],
656
- },
657
- ],
658
- },
659
- fragment: {
660
- module: shaderModule,
661
- targets: [
662
- {
663
- format: this.presentationFormat,
664
- blend: {
665
- color: {
666
- srcFactor: "src-alpha",
667
- dstFactor: "one-minus-src-alpha",
668
- operation: "add",
669
- },
670
- alpha: {
671
- srcFactor: "one",
672
- dstFactor: "one-minus-src-alpha",
673
- operation: "add",
674
- },
675
- },
676
- },
677
- ],
678
- },
679
- primitive: { cullMode: "front" },
507
+ shaderModule,
508
+ vertexBuffers: fullVertexBuffers,
509
+ fragmentTarget: standardBlend,
510
+ cullMode: "front",
680
511
  depthStencil: {
681
512
  format: "depth24plus-stencil8",
682
- depthWriteEnabled: false, // Don't write depth (already written in pre-pass)
683
- depthCompare: "less-equal", // More lenient than "equal" to avoid precision issues with MSAA
513
+ depthWriteEnabled: false,
514
+ depthCompare: "less-equal",
684
515
  stencilFront: {
685
- compare: isOverEyes ? "equal" : "not-equal", // Over eyes: stencil == 1, over non-eyes: stencil != 1
516
+ compare: isOverEyes ? "equal" : "not-equal",
686
517
  failOp: "keep",
687
518
  depthFailOp: "keep",
688
519
  passOp: "keep",
@@ -694,50 +525,11 @@ export class Engine {
694
525
  passOp: "keep",
695
526
  },
696
527
  },
697
- multisample: { count: this.sampleCount },
698
528
  });
699
529
  };
700
530
  this.hairPipelineOverEyes = createHairPipeline(true);
701
531
  this.hairPipelineOverNonEyes = createHairPipeline(false);
702
532
  }
703
- // Create compute shader for skin matrix computation
704
- createSkinMatrixComputePipeline() {
705
- const computeShader = this.device.createShaderModule({
706
- label: "skin matrix compute",
707
- code: /* wgsl */ `
708
- struct BoneCountUniform {
709
- count: u32,
710
- _padding1: u32,
711
- _padding2: u32,
712
- _padding3: u32,
713
- _padding4: vec4<u32>,
714
- };
715
-
716
- @group(0) @binding(0) var<uniform> boneCount: BoneCountUniform;
717
- @group(0) @binding(1) var<storage, read> worldMatrices: array<mat4x4f>;
718
- @group(0) @binding(2) var<storage, read> inverseBindMatrices: array<mat4x4f>;
719
- @group(0) @binding(3) var<storage, read_write> skinMatrices: array<mat4x4f>;
720
-
721
- @compute @workgroup_size(64) // Must match COMPUTE_WORKGROUP_SIZE
722
- fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
723
- let boneIndex = globalId.x;
724
- if (boneIndex >= boneCount.count) {
725
- return;
726
- }
727
- let worldMat = worldMatrices[boneIndex];
728
- let invBindMat = inverseBindMatrices[boneIndex];
729
- skinMatrices[boneIndex] = worldMat * invBindMat;
730
- }
731
- `,
732
- });
733
- this.skinMatrixComputePipeline = this.device.createComputePipeline({
734
- label: "skin matrix compute pipeline",
735
- layout: "auto",
736
- compute: {
737
- module: computeShader,
738
- },
739
- });
740
- }
741
533
  // Create bloom post-processing pipelines
742
534
  createBloomPipelines() {
743
535
  // Bloom extraction shader (extracts bright areas)
@@ -1120,131 +912,24 @@ export class Engine {
1120
912
  this.lightData[3] = 0.0; // Padding for vec3f alignment
1121
913
  }
1122
914
  async loadAnimation(url) {
1123
- await this.player.loadVmd(url);
1124
- this.hasAnimation = true;
1125
- // Show first frame (time 0) immediately
1126
- if (this.currentModel) {
1127
- const initialPose = this.player.getPoseAtTime(0);
1128
- this.applyPose(initialPose);
1129
- // Reset bones without time 0 keyframes
1130
- const skeleton = this.currentModel.getSkeleton();
1131
- const bonesWithPose = new Set(initialPose.boneRotations.keys());
1132
- const bonesToReset = [];
1133
- for (const bone of skeleton.bones) {
1134
- if (!bonesWithPose.has(bone.name)) {
1135
- bonesToReset.push(bone.name);
1136
- }
1137
- }
1138
- if (bonesToReset.length > 0) {
1139
- const identityQuat = new Quat(0, 0, 0, 1);
1140
- const identityQuats = new Array(bonesToReset.length).fill(identityQuat);
1141
- this.rotateBones(bonesToReset, identityQuats, 0);
1142
- }
1143
- // Update model pose and physics
1144
- this.currentModel.evaluatePose();
1145
- if (this.physics) {
1146
- const worldMats = this.currentModel.getBoneWorldMatrices();
1147
- this.physics.reset(worldMats, this.currentModel.getBoneInverseBindMatrices());
1148
- // Upload matrices immediately
1149
- this.device.queue.writeBuffer(this.worldMatrixBuffer, 0, worldMats.buffer, worldMats.byteOffset, worldMats.byteLength);
1150
- const encoder = this.device.createCommandEncoder();
1151
- this.computeSkinMatrices(encoder);
1152
- this.device.queue.submit([encoder.finish()]);
1153
- }
1154
- }
915
+ if (!this.currentModel)
916
+ return;
917
+ await this.currentModel.loadVmd(url);
1155
918
  }
1156
919
  playAnimation() {
1157
- if (!this.hasAnimation || !this.currentModel)
1158
- return;
1159
- const wasPaused = this.player.isPausedState();
1160
- const wasPlaying = this.player.isPlayingState();
1161
- // Only reset pose and physics if starting from beginning (not resuming)
1162
- if (!wasPlaying && !wasPaused) {
1163
- // Get initial pose at time 0
1164
- const initialPose = this.player.getPoseAtTime(0);
1165
- this.applyPose(initialPose);
1166
- // Reset bones without time 0 keyframes
1167
- const skeleton = this.currentModel.getSkeleton();
1168
- const bonesWithPose = new Set(initialPose.boneRotations.keys());
1169
- const bonesToReset = [];
1170
- for (const bone of skeleton.bones) {
1171
- if (!bonesWithPose.has(bone.name)) {
1172
- bonesToReset.push(bone.name);
1173
- }
1174
- }
1175
- if (bonesToReset.length > 0) {
1176
- const identityQuat = new Quat(0, 0, 0, 1);
1177
- const identityQuats = new Array(bonesToReset.length).fill(identityQuat);
1178
- this.rotateBones(bonesToReset, identityQuats, 0);
1179
- }
1180
- // Reset physics immediately and upload matrices to prevent A-pose flash
1181
- if (this.physics) {
1182
- this.currentModel.evaluatePose();
1183
- const worldMats = this.currentModel.getBoneWorldMatrices();
1184
- this.physics.reset(worldMats, this.currentModel.getBoneInverseBindMatrices());
1185
- // Upload matrices immediately so next frame shows correct pose
1186
- this.device.queue.writeBuffer(this.worldMatrixBuffer, 0, worldMats.buffer, worldMats.byteOffset, worldMats.byteLength);
1187
- const encoder = this.device.createCommandEncoder();
1188
- this.computeSkinMatrices(encoder);
1189
- this.device.queue.submit([encoder.finish()]);
1190
- }
1191
- }
1192
- // Start playback (or resume if paused)
1193
- this.player.play();
1194
- if (this.animationStartTime === 0) {
1195
- this.animationStartTime = performance.now();
1196
- }
920
+ this.currentModel?.playAnimation();
1197
921
  }
1198
922
  stopAnimation() {
1199
- this.player.stop();
923
+ this.currentModel?.stopAnimation();
1200
924
  }
1201
925
  pauseAnimation() {
1202
- this.player.pause();
926
+ this.currentModel?.pauseAnimation();
1203
927
  }
1204
928
  seekAnimation(time) {
1205
- if (!this.currentModel || !this.hasAnimation)
1206
- return;
1207
- this.player.seek(time);
1208
- // Immediately apply pose at seeked time
1209
- const pose = this.player.getPoseAtTime(time);
1210
- this.applyPose(pose);
1211
- // Update model pose and physics
1212
- this.currentModel.evaluatePose();
1213
- if (this.physics) {
1214
- const worldMats = this.currentModel.getBoneWorldMatrices();
1215
- this.physics.reset(worldMats, this.currentModel.getBoneInverseBindMatrices());
1216
- // Upload matrices immediately
1217
- this.device.queue.writeBuffer(this.worldMatrixBuffer, 0, worldMats.buffer, worldMats.byteOffset, worldMats.byteLength);
1218
- const encoder = this.device.createCommandEncoder();
1219
- this.computeSkinMatrices(encoder);
1220
- this.device.queue.submit([encoder.finish()]);
1221
- }
929
+ this.currentModel?.seekAnimation(time);
1222
930
  }
1223
931
  getAnimationProgress() {
1224
- return this.player.getProgress();
1225
- }
1226
- /**
1227
- * Apply animation pose to model
1228
- */
1229
- applyPose(pose) {
1230
- if (!this.currentModel)
1231
- return;
1232
- // Apply bone rotations
1233
- if (pose.boneRotations.size > 0) {
1234
- const boneNames = Array.from(pose.boneRotations.keys());
1235
- const rotations = Array.from(pose.boneRotations.values());
1236
- this.rotateBones(boneNames, rotations, 0);
1237
- }
1238
- // Apply bone translations
1239
- if (pose.boneTranslations.size > 0) {
1240
- const boneNames = Array.from(pose.boneTranslations.keys());
1241
- const translations = Array.from(pose.boneTranslations.values());
1242
- this.moveBones(boneNames, translations, 0);
1243
- }
1244
- // Apply morph weights
1245
- for (const [morphName, weight] of pose.morphWeights.entries()) {
1246
- this.setMorphWeight(morphName, weight, 0);
1247
- }
932
+ return this.currentModel?.getAnimationProgress() ?? { current: 0, duration: 0, percentage: 0 };
1248
933
  }
1249
934
  getStats() {
1250
935
  return { ...this.stats };
@@ -1284,7 +969,6 @@ export class Engine {
1284
969
  const dir = pathParts.join("/") + "/";
1285
970
  this.modelDir = dir;
1286
971
  const model = await PmxLoader.load(path);
1287
- this.physics = new Physics(model.getRigidbodies(), model.getJoints());
1288
972
  await this.setupModelBuffers(model);
1289
973
  }
1290
974
  rotateBones(bones, rotations, durationMs) {
@@ -1339,12 +1023,7 @@ export class Engine {
1339
1023
  this.skinMatrixBuffer = this.device.createBuffer({
1340
1024
  label: "skin matrices",
1341
1025
  size: Math.max(256, matrixSize),
1342
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.VERTEX,
1343
- });
1344
- this.worldMatrixBuffer = this.device.createBuffer({
1345
- label: "world matrices",
1346
- size: Math.max(256, matrixSize),
1347
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
1026
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
1348
1027
  });
1349
1028
  this.inverseBindMatrixBuffer = this.device.createBuffer({
1350
1029
  label: "inverse bind matrices",
@@ -1353,25 +1032,6 @@ export class Engine {
1353
1032
  });
1354
1033
  const invBindMatrices = skeleton.inverseBindMatrices;
1355
1034
  this.device.queue.writeBuffer(this.inverseBindMatrixBuffer, 0, invBindMatrices.buffer, invBindMatrices.byteOffset, invBindMatrices.byteLength);
1356
- this.boneCountBuffer = this.device.createBuffer({
1357
- label: "bone count uniform",
1358
- size: 32, // Minimum uniform buffer size is 32 bytes
1359
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1360
- });
1361
- const boneCountData = new Uint32Array(8); // 32 bytes total
1362
- boneCountData[0] = boneCount;
1363
- this.device.queue.writeBuffer(this.boneCountBuffer, 0, boneCountData);
1364
- this.createSkinMatrixComputePipeline();
1365
- // Create compute bind group once (reused every frame)
1366
- this.skinMatrixComputeBindGroup = this.device.createBindGroup({
1367
- layout: this.skinMatrixComputePipeline.getBindGroupLayout(0),
1368
- entries: [
1369
- { binding: 0, resource: { buffer: this.boneCountBuffer } },
1370
- { binding: 1, resource: { buffer: this.worldMatrixBuffer } },
1371
- { binding: 2, resource: { buffer: this.inverseBindMatrixBuffer } },
1372
- { binding: 3, resource: { buffer: this.skinMatrixBuffer } },
1373
- ],
1374
- });
1375
1035
  const indices = model.getIndices();
1376
1036
  if (indices) {
1377
1037
  this.indexBuffer = this.device.createBuffer({
@@ -1400,15 +1060,7 @@ export class Engine {
1400
1060
  const texture = await this.createTextureFromPath(path);
1401
1061
  return texture;
1402
1062
  };
1403
- this.opaqueDraws = [];
1404
- this.eyeDraws = [];
1405
- this.hairDrawsOverEyes = [];
1406
- this.hairDrawsOverNonEyes = [];
1407
- this.transparentDraws = [];
1408
- this.opaqueOutlineDraws = [];
1409
- this.eyeOutlineDraws = [];
1410
- this.hairOutlineDraws = [];
1411
- this.transparentOutlineDraws = [];
1063
+ this.drawCalls = [];
1412
1064
  let currentIndexOffset = 0;
1413
1065
  for (const mat of materials) {
1414
1066
  const indexCount = mat.vertexCount;
@@ -1419,22 +1071,7 @@ export class Engine {
1419
1071
  throw new Error(`Material "${mat.name}" has no diffuse texture`);
1420
1072
  const materialAlpha = mat.diffuse[3];
1421
1073
  const isTransparent = materialAlpha < 1.0 - Engine.TRANSPARENCY_EPSILON;
1422
- // Create material uniform data
1423
- const materialUniformData = new Float32Array(8);
1424
- materialUniformData[0] = materialAlpha;
1425
- materialUniformData[1] = 1.0; // alphaMultiplier: 1.0 for non-hair materials
1426
- materialUniformData[2] = this.rimLightIntensity;
1427
- materialUniformData[3] = 0.0; // _padding1
1428
- materialUniformData[4] = 1.0; // rimColor.r
1429
- materialUniformData[5] = 1.0; // rimColor.g
1430
- materialUniformData[6] = 1.0; // rimColor.b
1431
- materialUniformData[7] = 0.0; // isOverEyes
1432
- const materialUniformBuffer = this.device.createBuffer({
1433
- label: `material uniform: ${mat.name}`,
1434
- size: materialUniformData.byteLength,
1435
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1436
- });
1437
- this.device.queue.writeBuffer(materialUniformBuffer, 0, materialUniformData);
1074
+ const materialUniformBuffer = this.createMaterialUniformBuffer(mat.name, materialAlpha, 0.0);
1438
1075
  // Create bind groups using the shared bind group layout - All pipelines (main, eye, hair multiply, hair opaque) use the same shader and layout
1439
1076
  const bindGroup = this.device.createBindGroup({
1440
1077
  label: `material bind group: ${mat.name}`,
@@ -1448,96 +1085,62 @@ export class Engine {
1448
1085
  { binding: 5, resource: { buffer: materialUniformBuffer } },
1449
1086
  ],
1450
1087
  });
1451
- if (mat.isEye) {
1452
- if (indexCount > 0) {
1453
- this.eyeDraws.push({
1454
- count: indexCount,
1455
- firstIndex: currentIndexOffset,
1456
- bindGroup,
1457
- });
1088
+ if (indexCount > 0) {
1089
+ if (mat.isEye) {
1090
+ this.drawCalls.push({ type: "eye", count: indexCount, firstIndex: currentIndexOffset, bindGroup });
1458
1091
  }
1459
- }
1460
- else if (mat.isHair) {
1461
- // Hair materials: create separate bind groups for over-eyes vs over-non-eyes
1462
- const createHairBindGroup = (isOverEyes) => {
1463
- const uniformData = new Float32Array(8);
1464
- uniformData[0] = materialAlpha;
1465
- uniformData[1] = 1.0; // alphaMultiplier (shader adjusts based on isOverEyes)
1466
- uniformData[2] = this.rimLightIntensity;
1467
- uniformData[3] = 0.0; // _padding1
1468
- uniformData[4] = 1.0; // rimColor.rgb
1469
- uniformData[5] = 1.0;
1470
- uniformData[6] = 1.0;
1471
- uniformData[7] = isOverEyes ? 1.0 : 0.0; // isOverEyes
1472
- const buffer = this.device.createBuffer({
1473
- label: `material uniform (${isOverEyes ? "over eyes" : "over non-eyes"}): ${mat.name}`,
1474
- size: uniformData.byteLength,
1475
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1476
- });
1477
- this.device.queue.writeBuffer(buffer, 0, uniformData);
1478
- return this.device.createBindGroup({
1479
- label: `material bind group (${isOverEyes ? "over eyes" : "over non-eyes"}): ${mat.name}`,
1480
- layout: this.mainBindGroupLayout,
1481
- entries: [
1482
- { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1483
- { binding: 1, resource: { buffer: this.lightUniformBuffer } },
1484
- { binding: 2, resource: diffuseTexture.createView() },
1485
- { binding: 3, resource: this.materialSampler },
1486
- { binding: 4, resource: { buffer: this.skinMatrixBuffer } },
1487
- { binding: 5, resource: { buffer: buffer } },
1488
- ],
1489
- });
1490
- };
1491
- const bindGroupOverEyes = createHairBindGroup(true);
1492
- const bindGroupOverNonEyes = createHairBindGroup(false);
1493
- if (indexCount > 0) {
1494
- 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",
1495
1113
  count: indexCount,
1496
1114
  firstIndex: currentIndexOffset,
1497
1115
  bindGroup: bindGroupOverEyes,
1498
1116
  });
1499
- this.hairDrawsOverNonEyes.push({
1117
+ this.drawCalls.push({
1118
+ type: "hair-over-non-eyes",
1500
1119
  count: indexCount,
1501
1120
  firstIndex: currentIndexOffset,
1502
1121
  bindGroup: bindGroupOverNonEyes,
1503
1122
  });
1504
1123
  }
1505
- }
1506
- else if (isTransparent) {
1507
- if (indexCount > 0) {
1508
- this.transparentDraws.push({
1509
- count: indexCount,
1510
- firstIndex: currentIndexOffset,
1511
- bindGroup,
1512
- });
1124
+ else if (isTransparent) {
1125
+ this.drawCalls.push({ type: "transparent", count: indexCount, firstIndex: currentIndexOffset, bindGroup });
1513
1126
  }
1514
- }
1515
- else {
1516
- if (indexCount > 0) {
1517
- this.opaqueDraws.push({
1518
- count: indexCount,
1519
- firstIndex: currentIndexOffset,
1520
- bindGroup,
1521
- });
1127
+ else {
1128
+ this.drawCalls.push({ type: "opaque", count: indexCount, firstIndex: currentIndexOffset, bindGroup });
1522
1129
  }
1523
1130
  }
1524
1131
  // Edge flag is at bit 4 (0x10) in PMX format
1525
1132
  if ((mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
1526
- const materialUniformData = new Float32Array(8);
1527
- materialUniformData[0] = mat.edgeColor[0]; // edgeColor.r
1528
- materialUniformData[1] = mat.edgeColor[1]; // edgeColor.g
1529
- materialUniformData[2] = mat.edgeColor[2]; // edgeColor.b
1530
- materialUniformData[3] = mat.edgeColor[3]; // edgeColor.a
1531
- materialUniformData[4] = mat.edgeSize;
1532
- materialUniformData[5] = 0.0; // isOverEyes
1533
- materialUniformData[6] = 0.0;
1534
- materialUniformData[7] = 0.0;
1535
- const materialUniformBuffer = this.device.createBuffer({
1536
- label: `outline material uniform: ${mat.name}`,
1537
- size: materialUniformData.byteLength,
1538
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1539
- });
1540
- this.device.queue.writeBuffer(materialUniformBuffer, 0, materialUniformData);
1133
+ const materialUniformData = new Float32Array([
1134
+ mat.edgeColor[0],
1135
+ mat.edgeColor[1],
1136
+ mat.edgeColor[2],
1137
+ mat.edgeColor[3],
1138
+ mat.edgeSize,
1139
+ 0,
1140
+ 0,
1141
+ 0,
1142
+ ]);
1143
+ const materialUniformBuffer = this.createUniformBuffer(`outline material uniform: ${mat.name}`, materialUniformData);
1541
1144
  const outlineBindGroup = this.device.createBindGroup({
1542
1145
  label: `outline bind group: ${mat.name}`,
1543
1146
  layout: this.outlineBindGroupLayout,
@@ -1548,39 +1151,38 @@ export class Engine {
1548
1151
  ],
1549
1152
  });
1550
1153
  if (indexCount > 0) {
1551
- if (mat.isEye) {
1552
- this.eyeOutlineDraws.push({
1553
- count: indexCount,
1554
- firstIndex: currentIndexOffset,
1555
- bindGroup: outlineBindGroup,
1556
- });
1557
- }
1558
- else if (mat.isHair) {
1559
- this.hairOutlineDraws.push({
1560
- count: indexCount,
1561
- firstIndex: currentIndexOffset,
1562
- bindGroup: outlineBindGroup,
1563
- });
1564
- }
1565
- else if (isTransparent) {
1566
- this.transparentOutlineDraws.push({
1567
- count: indexCount,
1568
- firstIndex: currentIndexOffset,
1569
- bindGroup: outlineBindGroup,
1570
- });
1571
- }
1572
- else {
1573
- this.opaqueOutlineDraws.push({
1574
- count: indexCount,
1575
- firstIndex: currentIndexOffset,
1576
- bindGroup: outlineBindGroup,
1577
- });
1578
- }
1154
+ const outlineType = mat.isEye
1155
+ ? "eye-outline"
1156
+ : mat.isHair
1157
+ ? "hair-outline"
1158
+ : isTransparent
1159
+ ? "transparent-outline"
1160
+ : "opaque-outline";
1161
+ this.drawCalls.push({
1162
+ type: outlineType,
1163
+ count: indexCount,
1164
+ firstIndex: currentIndexOffset,
1165
+ bindGroup: outlineBindGroup,
1166
+ });
1579
1167
  }
1580
1168
  }
1581
1169
  currentIndexOffset += indexCount;
1582
1170
  }
1583
1171
  }
1172
+ createMaterialUniformBuffer(label, alpha, isOverEyes) {
1173
+ const data = new Float32Array(8);
1174
+ data.set([alpha, 1.0, this.rimLightIntensity, 0.0, 1.0, 1.0, 1.0, isOverEyes]);
1175
+ return this.createUniformBuffer(`material uniform: ${label}`, data);
1176
+ }
1177
+ createUniformBuffer(label, data) {
1178
+ const buffer = this.device.createBuffer({
1179
+ label,
1180
+ size: data.byteLength,
1181
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1182
+ });
1183
+ this.device.queue.writeBuffer(buffer, 0, data);
1184
+ return buffer;
1185
+ }
1584
1186
  async createTextureFromPath(path) {
1585
1187
  const cached = this.textureCache.get(path);
1586
1188
  if (cached) {
@@ -1616,47 +1218,50 @@ export class Engine {
1616
1218
  renderEyes(pass) {
1617
1219
  pass.setPipeline(this.eyePipeline);
1618
1220
  pass.setStencilReference(this.STENCIL_EYE_VALUE);
1619
- for (const draw of this.eyeDraws) {
1620
- pass.setBindGroup(0, draw.bindGroup);
1621
- 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
+ }
1622
1226
  }
1623
1227
  }
1624
1228
  // Helper: Render hair with post-alpha-eye effect (depth pre-pass + stencil-based shading + outlines)
1625
1229
  renderHair(pass) {
1626
1230
  // Hair depth pre-pass (reduces overdraw via early depth rejection)
1627
- 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");
1628
1232
  if (hasHair) {
1629
1233
  pass.setPipeline(this.hairDepthPipeline);
1630
- for (const draw of this.hairDrawsOverEyes) {
1631
- pass.setBindGroup(0, draw.bindGroup);
1632
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1633
- }
1634
- for (const draw of this.hairDrawsOverNonEyes) {
1635
- pass.setBindGroup(0, draw.bindGroup);
1636
- 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
+ }
1637
1239
  }
1638
1240
  }
1639
1241
  // Hair shading (split by stencil for transparency over eyes)
1640
- if (this.hairDrawsOverEyes.length > 0) {
1242
+ const hairOverEyes = this.drawCalls.filter((d) => d.type === "hair-over-eyes");
1243
+ if (hairOverEyes.length > 0) {
1641
1244
  pass.setPipeline(this.hairPipelineOverEyes);
1642
1245
  pass.setStencilReference(this.STENCIL_EYE_VALUE);
1643
- for (const draw of this.hairDrawsOverEyes) {
1246
+ for (const draw of hairOverEyes) {
1644
1247
  pass.setBindGroup(0, draw.bindGroup);
1645
1248
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1646
1249
  }
1647
1250
  }
1648
- if (this.hairDrawsOverNonEyes.length > 0) {
1251
+ const hairOverNonEyes = this.drawCalls.filter((d) => d.type === "hair-over-non-eyes");
1252
+ if (hairOverNonEyes.length > 0) {
1649
1253
  pass.setPipeline(this.hairPipelineOverNonEyes);
1650
1254
  pass.setStencilReference(this.STENCIL_EYE_VALUE);
1651
- for (const draw of this.hairDrawsOverNonEyes) {
1255
+ for (const draw of hairOverNonEyes) {
1652
1256
  pass.setBindGroup(0, draw.bindGroup);
1653
1257
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1654
1258
  }
1655
1259
  }
1656
1260
  // Hair outlines
1657
- if (this.hairOutlineDraws.length > 0) {
1261
+ const hairOutlines = this.drawCalls.filter((d) => d.type === "hair-outline");
1262
+ if (hairOutlines.length > 0) {
1658
1263
  pass.setPipeline(this.hairOutlinePipeline);
1659
- for (const draw of this.hairOutlineDraws) {
1264
+ for (const draw of hairOutlines) {
1660
1265
  pass.setBindGroup(0, draw.bindGroup);
1661
1266
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1662
1267
  }
@@ -1670,18 +1275,10 @@ export class Engine {
1670
1275
  this.lastFrameTime = currentTime;
1671
1276
  this.updateCameraUniforms();
1672
1277
  this.updateRenderTarget();
1673
- // Animate VMD animation if playing
1674
- if (this.hasAnimation && this.currentModel) {
1675
- const pose = this.player.update(currentTime);
1676
- if (pose) {
1677
- this.applyPose(pose);
1678
- }
1679
- }
1680
- // Update model pose first (this may update morph weights via tweens)
1681
- // 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)
1682
1279
  if (this.currentModel) {
1683
- const hasActiveMorphTweens = this.currentModel.evaluatePose();
1684
- if (hasActiveMorphTweens) {
1280
+ const verticesChanged = this.currentModel.update(deltaTime);
1281
+ if (verticesChanged) {
1685
1282
  this.vertexBufferNeedsUpdate = true;
1686
1283
  }
1687
1284
  }
@@ -1690,9 +1287,10 @@ export class Engine {
1690
1287
  this.updateVertexBuffer();
1691
1288
  this.vertexBufferNeedsUpdate = false;
1692
1289
  }
1693
- // 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
1694
1293
  const encoder = this.device.createCommandEncoder();
1695
- this.updateModelPose(deltaTime, encoder);
1696
1294
  const pass = encoder.beginRenderPass(this.renderPassDescriptor);
1697
1295
  if (this.currentModel) {
1698
1296
  pass.setVertexBuffer(0, this.vertexBuffer);
@@ -1701,9 +1299,11 @@ export class Engine {
1701
1299
  pass.setIndexBuffer(this.indexBuffer, "uint32");
1702
1300
  // Pass 1: Opaque
1703
1301
  pass.setPipeline(this.modelPipeline);
1704
- for (const draw of this.opaqueDraws) {
1705
- pass.setBindGroup(0, draw.bindGroup);
1706
- 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
+ }
1707
1307
  }
1708
1308
  // Pass 2: Eyes (writes stencil value for hair to test against)
1709
1309
  this.renderEyes(pass);
@@ -1712,9 +1312,11 @@ export class Engine {
1712
1312
  this.renderHair(pass);
1713
1313
  // Pass 4: Transparent
1714
1314
  pass.setPipeline(this.modelPipeline);
1715
- for (const draw of this.transparentDraws) {
1716
- pass.setBindGroup(0, draw.bindGroup);
1717
- 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
+ }
1718
1320
  }
1719
1321
  this.drawOutlines(pass, true);
1720
1322
  }
@@ -1831,31 +1433,20 @@ export class Engine {
1831
1433
  colorAttachment.view = this.sceneRenderTextureView;
1832
1434
  }
1833
1435
  }
1834
- updateModelPose(deltaTime, encoder) {
1835
- // Note: evaluatePose is called earlier in render() to update vertex buffer before encoder creation
1836
- // Here we just get the matrices and update physics/compute
1837
- const worldMats = this.currentModel.getBoneWorldMatrices();
1838
- if (this.physics) {
1839
- this.physics.step(deltaTime, worldMats, this.currentModel.getBoneInverseBindMatrices());
1840
- }
1841
- this.device.queue.writeBuffer(this.worldMatrixBuffer, 0, worldMats.buffer, worldMats.byteOffset, worldMats.byteLength);
1842
- this.computeSkinMatrices(encoder);
1843
- }
1844
- computeSkinMatrices(encoder) {
1845
- const boneCount = this.currentModel.getSkeleton().bones.length;
1846
- const workgroupCount = Math.ceil(boneCount / this.COMPUTE_WORKGROUP_SIZE);
1847
- const pass = encoder.beginComputePass();
1848
- pass.setPipeline(this.skinMatrixComputePipeline);
1849
- pass.setBindGroup(0, this.skinMatrixComputeBindGroup);
1850
- pass.dispatchWorkgroups(workgroupCount);
1851
- 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);
1852
1441
  }
1853
1442
  drawOutlines(pass, transparent) {
1854
1443
  pass.setPipeline(this.outlinePipeline);
1855
- const draws = transparent ? this.transparentOutlineDraws : this.opaqueOutlineDraws;
1856
- for (const draw of draws) {
1857
- pass.setBindGroup(0, draw.bindGroup);
1858
- 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
+ }
1859
1450
  }
1860
1451
  }
1861
1452
  updateStats(frameTime) {