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/src/engine.ts CHANGED
@@ -2,8 +2,6 @@ import { Camera } from "./camera"
2
2
  import { Quat, Vec3 } from "./math"
3
3
  import { Model } from "./model"
4
4
  import { PmxLoader } from "./pmx-loader"
5
- import { Physics } from "./physics"
6
- import { AnimationPose, Player } from "./player"
7
5
 
8
6
  export type EngineOptions = {
9
7
  ambientColor?: Vec3
@@ -18,7 +16,19 @@ export interface EngineStats {
18
16
  frameTime: number // ms
19
17
  }
20
18
 
19
+ type DrawCallType =
20
+ | "opaque"
21
+ | "eye"
22
+ | "hair-over-eyes"
23
+ | "hair-over-non-eyes"
24
+ | "transparent"
25
+ | "opaque-outline"
26
+ | "eye-outline"
27
+ | "hair-outline"
28
+ | "transparent-outline"
29
+
21
30
  interface DrawCall {
31
+ type: DrawCallType
22
32
  count: number
23
33
  firstIndex: number
24
34
  bindGroup: GPUBindGroup
@@ -54,17 +64,12 @@ export class Engine {
54
64
  private jointsBuffer!: GPUBuffer
55
65
  private weightsBuffer!: GPUBuffer
56
66
  private skinMatrixBuffer?: GPUBuffer
57
- private worldMatrixBuffer?: GPUBuffer
58
67
  private inverseBindMatrixBuffer?: GPUBuffer
59
- private skinMatrixComputePipeline?: GPUComputePipeline
60
- private skinMatrixComputeBindGroup?: GPUBindGroup
61
- private boneCountBuffer?: GPUBuffer
62
68
  private multisampleTexture!: GPUTexture
63
69
  private readonly sampleCount = 4
64
70
  private renderPassDescriptor!: GPURenderPassDescriptor
65
71
  // Constants
66
72
  private readonly STENCIL_EYE_VALUE = 1
67
- private readonly COMPUTE_WORKGROUP_SIZE = 64
68
73
  private readonly BLOOM_DOWNSCALE_FACTOR = 2
69
74
 
70
75
  // Default values
@@ -106,20 +111,11 @@ export class Engine {
106
111
 
107
112
  private currentModel: Model | null = null
108
113
  private modelDir: string = ""
109
- private physics: Physics | null = null
110
114
  private materialSampler!: GPUSampler
111
115
  private textureCache = new Map<string, GPUTexture>()
112
116
  private vertexBufferNeedsUpdate = false
113
- // Draw lists
114
- private opaqueDraws: DrawCall[] = []
115
- private eyeDraws: DrawCall[] = []
116
- private hairDrawsOverEyes: DrawCall[] = []
117
- private hairDrawsOverNonEyes: DrawCall[] = []
118
- private transparentDraws: DrawCall[] = []
119
- private opaqueOutlineDraws: DrawCall[] = []
120
- private eyeOutlineDraws: DrawCall[] = []
121
- private hairOutlineDraws: DrawCall[] = []
122
- private transparentOutlineDraws: DrawCall[] = []
117
+ // Unified draw call list
118
+ private drawCalls: DrawCall[] = []
123
119
 
124
120
  private lastFpsUpdate = performance.now()
125
121
  private framesSinceLastUpdate = 0
@@ -133,10 +129,6 @@ export class Engine {
133
129
  private animationFrameId: number | null = null
134
130
  private renderLoopCallback: (() => void) | null = null
135
131
 
136
- private player: Player = new Player()
137
- private hasAnimation = false // Set to true when loadAnimation is called
138
- private animationStartTime: number = 0 // Track when animation first started (for A-pose prevention)
139
-
140
132
  constructor(canvas: HTMLCanvasElement, options?: EngineOptions) {
141
133
  this.canvas = canvas
142
134
  if (options) {
@@ -178,6 +170,37 @@ export class Engine {
178
170
  this.setupResize()
179
171
  }
180
172
 
173
+ private createRenderPipeline(config: {
174
+ label: string
175
+ layout: GPUPipelineLayout
176
+ shaderModule: GPUShaderModule
177
+ vertexBuffers: GPUVertexBufferLayout[]
178
+ fragmentTarget?: GPUColorTargetState
179
+ fragmentEntryPoint?: string
180
+ cullMode?: GPUCullMode
181
+ depthStencil?: GPUDepthStencilState
182
+ multisample?: GPUMultisampleState
183
+ }): GPURenderPipeline {
184
+ return this.device.createRenderPipeline({
185
+ label: config.label,
186
+ layout: config.layout,
187
+ vertex: {
188
+ module: config.shaderModule,
189
+ buffers: config.vertexBuffers,
190
+ },
191
+ fragment: config.fragmentTarget
192
+ ? {
193
+ module: config.shaderModule,
194
+ entryPoint: config.fragmentEntryPoint,
195
+ targets: [config.fragmentTarget],
196
+ }
197
+ : undefined,
198
+ primitive: { cullMode: config.cullMode ?? "none" },
199
+ depthStencil: config.depthStencil,
200
+ multisample: config.multisample ?? { count: this.sampleCount },
201
+ })
202
+ }
203
+
181
204
  private createPipelines() {
182
205
  this.materialSampler = this.device.createSampler({
183
206
  magFilter: "linear",
@@ -186,6 +209,78 @@ export class Engine {
186
209
  addressModeV: "repeat",
187
210
  })
188
211
 
212
+ // Shared vertex buffer layouts
213
+ const fullVertexBuffers: GPUVertexBufferLayout[] = [
214
+ {
215
+ arrayStride: 8 * 4,
216
+ attributes: [
217
+ { shaderLocation: 0, offset: 0, format: "float32x3" as GPUVertexFormat },
218
+ { shaderLocation: 1, offset: 3 * 4, format: "float32x3" as GPUVertexFormat },
219
+ { shaderLocation: 2, offset: 6 * 4, format: "float32x2" as GPUVertexFormat },
220
+ ],
221
+ },
222
+ {
223
+ arrayStride: 4 * 2,
224
+ attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" as GPUVertexFormat }],
225
+ },
226
+ {
227
+ arrayStride: 4,
228
+ attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" as GPUVertexFormat }],
229
+ },
230
+ ]
231
+
232
+ const outlineVertexBuffers: GPUVertexBufferLayout[] = [
233
+ {
234
+ arrayStride: 8 * 4,
235
+ attributes: [
236
+ { shaderLocation: 0, offset: 0, format: "float32x3" as GPUVertexFormat },
237
+ { shaderLocation: 1, offset: 3 * 4, format: "float32x3" as GPUVertexFormat },
238
+ ],
239
+ },
240
+ {
241
+ arrayStride: 4 * 2,
242
+ attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" as GPUVertexFormat }],
243
+ },
244
+ {
245
+ arrayStride: 4,
246
+ attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" as GPUVertexFormat }],
247
+ },
248
+ ]
249
+
250
+ const depthOnlyVertexBuffers: GPUVertexBufferLayout[] = [
251
+ {
252
+ arrayStride: 8 * 4,
253
+ attributes: [
254
+ { shaderLocation: 0, offset: 0, format: "float32x3" as GPUVertexFormat },
255
+ { shaderLocation: 1, offset: 3 * 4, format: "float32x3" as GPUVertexFormat },
256
+ ],
257
+ },
258
+ {
259
+ arrayStride: 4 * 2,
260
+ attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" as GPUVertexFormat }],
261
+ },
262
+ {
263
+ arrayStride: 4,
264
+ attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" as GPUVertexFormat }],
265
+ },
266
+ ]
267
+
268
+ const standardBlend: GPUColorTargetState = {
269
+ format: this.presentationFormat,
270
+ blend: {
271
+ color: {
272
+ srcFactor: "src-alpha",
273
+ dstFactor: "one-minus-src-alpha",
274
+ operation: "add",
275
+ },
276
+ alpha: {
277
+ srcFactor: "one",
278
+ dstFactor: "one-minus-src-alpha",
279
+ operation: "add",
280
+ },
281
+ },
282
+ }
283
+
189
284
  const shaderModule = this.device.createShaderModule({
190
285
  label: "model shaders",
191
286
  code: /* wgsl */ `
@@ -302,59 +397,18 @@ export class Engine {
302
397
  bindGroupLayouts: [this.mainBindGroupLayout],
303
398
  })
304
399
 
305
- this.modelPipeline = this.device.createRenderPipeline({
400
+ this.modelPipeline = this.createRenderPipeline({
306
401
  label: "model pipeline",
307
402
  layout: mainPipelineLayout,
308
- vertex: {
309
- module: shaderModule,
310
- buffers: [
311
- {
312
- arrayStride: 8 * 4,
313
- attributes: [
314
- { shaderLocation: 0, offset: 0, format: "float32x3" as GPUVertexFormat },
315
- { shaderLocation: 1, offset: 3 * 4, format: "float32x3" as GPUVertexFormat },
316
- { shaderLocation: 2, offset: 6 * 4, format: "float32x2" as GPUVertexFormat },
317
- ],
318
- },
319
- {
320
- arrayStride: 4 * 2,
321
- attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" as GPUVertexFormat }],
322
- },
323
- {
324
- arrayStride: 4,
325
- attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" as GPUVertexFormat }],
326
- },
327
- ],
328
- },
329
- fragment: {
330
- module: shaderModule,
331
- targets: [
332
- {
333
- format: this.presentationFormat,
334
- blend: {
335
- color: {
336
- srcFactor: "src-alpha",
337
- dstFactor: "one-minus-src-alpha",
338
- operation: "add",
339
- },
340
- alpha: {
341
- srcFactor: "one",
342
- dstFactor: "one-minus-src-alpha",
343
- operation: "add",
344
- },
345
- },
346
- },
347
- ],
348
- },
349
- primitive: { cullMode: "none" },
403
+ shaderModule,
404
+ vertexBuffers: fullVertexBuffers,
405
+ fragmentTarget: standardBlend,
406
+ cullMode: "none",
350
407
  depthStencil: {
351
408
  format: "depth24plus-stencil8",
352
409
  depthWriteEnabled: true,
353
410
  depthCompare: "less-equal",
354
411
  },
355
- multisample: {
356
- count: this.sampleCount,
357
- },
358
412
  })
359
413
 
360
414
  // Create bind group layout for outline pipelines
@@ -444,196 +498,58 @@ export class Engine {
444
498
  `,
445
499
  })
446
500
 
447
- this.outlinePipeline = this.device.createRenderPipeline({
501
+ this.outlinePipeline = this.createRenderPipeline({
448
502
  label: "outline pipeline",
449
503
  layout: outlinePipelineLayout,
450
- vertex: {
451
- module: outlineShaderModule,
452
- buffers: [
453
- {
454
- arrayStride: 8 * 4,
455
- attributes: [
456
- {
457
- shaderLocation: 0,
458
- offset: 0,
459
- format: "float32x3" as GPUVertexFormat,
460
- },
461
- {
462
- shaderLocation: 1,
463
- offset: 3 * 4,
464
- format: "float32x3" as GPUVertexFormat,
465
- },
466
- ],
467
- },
468
- {
469
- arrayStride: 4 * 2,
470
- attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" as GPUVertexFormat }],
471
- },
472
- {
473
- arrayStride: 4,
474
- attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" as GPUVertexFormat }],
475
- },
476
- ],
477
- },
478
- fragment: {
479
- module: outlineShaderModule,
480
- targets: [
481
- {
482
- format: this.presentationFormat,
483
- blend: {
484
- color: {
485
- srcFactor: "src-alpha",
486
- dstFactor: "one-minus-src-alpha",
487
- operation: "add",
488
- },
489
- alpha: {
490
- srcFactor: "one",
491
- dstFactor: "one-minus-src-alpha",
492
- operation: "add",
493
- },
494
- },
495
- },
496
- ],
497
- },
498
- primitive: {
499
- cullMode: "back",
500
- },
504
+ shaderModule: outlineShaderModule,
505
+ vertexBuffers: outlineVertexBuffers,
506
+ fragmentTarget: standardBlend,
507
+ cullMode: "back",
501
508
  depthStencil: {
502
509
  format: "depth24plus-stencil8",
503
510
  depthWriteEnabled: true,
504
511
  depthCompare: "less-equal",
505
512
  },
506
- multisample: {
507
- count: this.sampleCount,
508
- },
509
513
  })
510
514
 
511
515
  // Hair outline pipeline
512
- this.hairOutlinePipeline = this.device.createRenderPipeline({
516
+ this.hairOutlinePipeline = this.createRenderPipeline({
513
517
  label: "hair outline pipeline",
514
518
  layout: outlinePipelineLayout,
515
- vertex: {
516
- module: outlineShaderModule,
517
- buffers: [
518
- {
519
- arrayStride: 8 * 4,
520
- attributes: [
521
- {
522
- shaderLocation: 0,
523
- offset: 0,
524
- format: "float32x3" as GPUVertexFormat,
525
- },
526
- {
527
- shaderLocation: 1,
528
- offset: 3 * 4,
529
- format: "float32x3" as GPUVertexFormat,
530
- },
531
- ],
532
- },
533
- {
534
- arrayStride: 4 * 2,
535
- attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" as GPUVertexFormat }],
536
- },
537
- {
538
- arrayStride: 4,
539
- attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" as GPUVertexFormat }],
540
- },
541
- ],
542
- },
543
- fragment: {
544
- module: outlineShaderModule,
545
- targets: [
546
- {
547
- format: this.presentationFormat,
548
- blend: {
549
- color: {
550
- srcFactor: "src-alpha",
551
- dstFactor: "one-minus-src-alpha",
552
- operation: "add",
553
- },
554
- alpha: {
555
- srcFactor: "one",
556
- dstFactor: "one-minus-src-alpha",
557
- operation: "add",
558
- },
559
- },
560
- },
561
- ],
562
- },
563
- primitive: {
564
- cullMode: "back",
565
- },
519
+ shaderModule: outlineShaderModule,
520
+ vertexBuffers: outlineVertexBuffers,
521
+ fragmentTarget: standardBlend,
522
+ cullMode: "back",
566
523
  depthStencil: {
567
524
  format: "depth24plus-stencil8",
568
- depthWriteEnabled: false, // Don't write depth - let hair geometry control depth
569
- depthCompare: "less-equal", // Only draw where hair depth exists (no stencil test needed)
570
- depthBias: -0.0001, // Small negative bias to bring outline slightly closer for depth test
525
+ depthWriteEnabled: false,
526
+ depthCompare: "less-equal",
527
+ depthBias: -0.0001,
571
528
  depthBiasSlopeScale: 0.0,
572
529
  depthBiasClamp: 0.0,
573
530
  },
574
- multisample: {
575
- count: this.sampleCount,
576
- },
577
531
  })
578
532
 
579
533
  // Eye overlay pipeline (renders after opaque, writes stencil)
580
- this.eyePipeline = this.device.createRenderPipeline({
534
+ this.eyePipeline = this.createRenderPipeline({
581
535
  label: "eye overlay pipeline",
582
536
  layout: mainPipelineLayout,
583
- vertex: {
584
- module: shaderModule,
585
- buffers: [
586
- {
587
- arrayStride: 8 * 4,
588
- attributes: [
589
- { shaderLocation: 0, offset: 0, format: "float32x3" as GPUVertexFormat },
590
- { shaderLocation: 1, offset: 3 * 4, format: "float32x3" as GPUVertexFormat },
591
- { shaderLocation: 2, offset: 6 * 4, format: "float32x2" as GPUVertexFormat },
592
- ],
593
- },
594
- {
595
- arrayStride: 4 * 2,
596
- attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" as GPUVertexFormat }],
597
- },
598
- {
599
- arrayStride: 4,
600
- attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" as GPUVertexFormat }],
601
- },
602
- ],
603
- },
604
- fragment: {
605
- module: shaderModule,
606
- targets: [
607
- {
608
- format: this.presentationFormat,
609
- blend: {
610
- color: {
611
- srcFactor: "src-alpha",
612
- dstFactor: "one-minus-src-alpha",
613
- operation: "add",
614
- },
615
- alpha: {
616
- srcFactor: "one",
617
- dstFactor: "one-minus-src-alpha",
618
- operation: "add",
619
- },
620
- },
621
- },
622
- ],
623
- },
624
- primitive: { cullMode: "front" },
537
+ shaderModule,
538
+ vertexBuffers: fullVertexBuffers,
539
+ fragmentTarget: standardBlend,
540
+ cullMode: "front",
625
541
  depthStencil: {
626
542
  format: "depth24plus-stencil8",
627
- depthWriteEnabled: true, // Write depth to occlude back of head
628
- depthCompare: "less-equal", // More lenient to reduce precision conflicts
629
- depthBias: -0.00005, // Reduced bias to minimize conflicts while still occluding back face
543
+ depthWriteEnabled: true,
544
+ depthCompare: "less-equal",
545
+ depthBias: -0.00005,
630
546
  depthBiasSlopeScale: 0.0,
631
547
  depthBiasClamp: 0.0,
632
548
  stencilFront: {
633
549
  compare: "always",
634
550
  failOp: "keep",
635
551
  depthFailOp: "keep",
636
- passOp: "replace", // Write stencil value 1
552
+ passOp: "replace",
637
553
  },
638
554
  stencilBack: {
639
555
  compare: "always",
@@ -642,7 +558,6 @@ export class Engine {
642
558
  passOp: "replace",
643
559
  },
644
560
  },
645
- multisample: { count: this.sampleCount },
646
561
  })
647
562
 
648
563
  // Depth-only shader for hair pre-pass (reduces overdraw by early depth rejection)
@@ -691,104 +606,42 @@ export class Engine {
691
606
  })
692
607
 
693
608
  // Hair depth pre-pass pipeline: depth-only with color writes disabled to eliminate overdraw
694
- this.hairDepthPipeline = this.device.createRenderPipeline({
609
+ this.hairDepthPipeline = this.createRenderPipeline({
695
610
  label: "hair depth pre-pass",
696
611
  layout: mainPipelineLayout,
697
- vertex: {
698
- module: depthOnlyShaderModule,
699
- buffers: [
700
- {
701
- arrayStride: 8 * 4,
702
- attributes: [
703
- { shaderLocation: 0, offset: 0, format: "float32x3" as GPUVertexFormat },
704
- { shaderLocation: 1, offset: 3 * 4, format: "float32x3" as GPUVertexFormat },
705
- ],
706
- },
707
- {
708
- arrayStride: 4 * 2,
709
- attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" as GPUVertexFormat }],
710
- },
711
- {
712
- arrayStride: 4,
713
- attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" as GPUVertexFormat }],
714
- },
715
- ],
716
- },
717
- fragment: {
718
- module: depthOnlyShaderModule,
719
- entryPoint: "fs",
720
- targets: [
721
- {
722
- format: this.presentationFormat,
723
- writeMask: 0, // Disable all color writes - we only care about depth
724
- },
725
- ],
612
+ shaderModule: depthOnlyShaderModule,
613
+ vertexBuffers: depthOnlyVertexBuffers,
614
+ fragmentTarget: {
615
+ format: this.presentationFormat,
616
+ writeMask: 0,
726
617
  },
727
- primitive: { cullMode: "front" },
618
+ fragmentEntryPoint: "fs",
619
+ cullMode: "front",
728
620
  depthStencil: {
729
621
  format: "depth24plus-stencil8",
730
622
  depthWriteEnabled: true,
731
- depthCompare: "less-equal", // Match the color pass compare mode for consistency
623
+ depthCompare: "less-equal",
732
624
  depthBias: 0.0,
733
625
  depthBiasSlopeScale: 0.0,
734
626
  depthBiasClamp: 0.0,
735
627
  },
736
- multisample: { count: this.sampleCount },
737
628
  })
738
629
 
739
630
  // Hair pipelines for rendering over eyes vs non-eyes (only differ in stencil compare mode)
740
631
  const createHairPipeline = (isOverEyes: boolean): GPURenderPipeline => {
741
- return this.device.createRenderPipeline({
632
+ return this.createRenderPipeline({
742
633
  label: `hair pipeline (${isOverEyes ? "over eyes" : "over non-eyes"})`,
743
634
  layout: mainPipelineLayout,
744
- vertex: {
745
- module: shaderModule,
746
- buffers: [
747
- {
748
- arrayStride: 8 * 4,
749
- attributes: [
750
- { shaderLocation: 0, offset: 0, format: "float32x3" as GPUVertexFormat },
751
- { shaderLocation: 1, offset: 3 * 4, format: "float32x3" as GPUVertexFormat },
752
- { shaderLocation: 2, offset: 6 * 4, format: "float32x2" as GPUVertexFormat },
753
- ],
754
- },
755
- {
756
- arrayStride: 4 * 2,
757
- attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" as GPUVertexFormat }],
758
- },
759
- {
760
- arrayStride: 4,
761
- attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" as GPUVertexFormat }],
762
- },
763
- ],
764
- },
765
- fragment: {
766
- module: shaderModule,
767
- targets: [
768
- {
769
- format: this.presentationFormat,
770
- blend: {
771
- color: {
772
- srcFactor: "src-alpha",
773
- dstFactor: "one-minus-src-alpha",
774
- operation: "add",
775
- },
776
- alpha: {
777
- srcFactor: "one",
778
- dstFactor: "one-minus-src-alpha",
779
- operation: "add",
780
- },
781
- },
782
- },
783
- ],
784
- },
785
- primitive: { cullMode: "front" },
635
+ shaderModule,
636
+ vertexBuffers: fullVertexBuffers,
637
+ fragmentTarget: standardBlend,
638
+ cullMode: "front",
786
639
  depthStencil: {
787
640
  format: "depth24plus-stencil8",
788
- depthWriteEnabled: false, // Don't write depth (already written in pre-pass)
789
- depthCompare: "less-equal", // More lenient than "equal" to avoid precision issues with MSAA
641
+ depthWriteEnabled: false,
642
+ depthCompare: "less-equal",
790
643
  stencilFront: {
791
- compare: isOverEyes ? "equal" : "not-equal", // Over eyes: stencil == 1, over non-eyes: stencil != 1
644
+ compare: isOverEyes ? "equal" : "not-equal",
792
645
  failOp: "keep",
793
646
  depthFailOp: "keep",
794
647
  passOp: "keep",
@@ -800,7 +653,6 @@ export class Engine {
800
653
  passOp: "keep",
801
654
  },
802
655
  },
803
- multisample: { count: this.sampleCount },
804
656
  })
805
657
  }
806
658
 
@@ -808,46 +660,6 @@ export class Engine {
808
660
  this.hairPipelineOverNonEyes = createHairPipeline(false)
809
661
  }
810
662
 
811
- // Create compute shader for skin matrix computation
812
- private createSkinMatrixComputePipeline() {
813
- const computeShader = this.device.createShaderModule({
814
- label: "skin matrix compute",
815
- code: /* wgsl */ `
816
- struct BoneCountUniform {
817
- count: u32,
818
- _padding1: u32,
819
- _padding2: u32,
820
- _padding3: u32,
821
- _padding4: vec4<u32>,
822
- };
823
-
824
- @group(0) @binding(0) var<uniform> boneCount: BoneCountUniform;
825
- @group(0) @binding(1) var<storage, read> worldMatrices: array<mat4x4f>;
826
- @group(0) @binding(2) var<storage, read> inverseBindMatrices: array<mat4x4f>;
827
- @group(0) @binding(3) var<storage, read_write> skinMatrices: array<mat4x4f>;
828
-
829
- @compute @workgroup_size(64) // Must match COMPUTE_WORKGROUP_SIZE
830
- fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
831
- let boneIndex = globalId.x;
832
- if (boneIndex >= boneCount.count) {
833
- return;
834
- }
835
- let worldMat = worldMatrices[boneIndex];
836
- let invBindMat = inverseBindMatrices[boneIndex];
837
- skinMatrices[boneIndex] = worldMat * invBindMat;
838
- }
839
- `,
840
- })
841
-
842
- this.skinMatrixComputePipeline = this.device.createComputePipeline({
843
- label: "skin matrix compute pipeline",
844
- layout: "auto",
845
- compute: {
846
- module: computeShader,
847
- },
848
- })
849
- }
850
-
851
663
  // Create bloom post-processing pipelines
852
664
  private createBloomPipelines() {
853
665
  // Bloom extraction shader (extracts bright areas)
@@ -1268,174 +1080,28 @@ export class Engine {
1268
1080
  }
1269
1081
 
1270
1082
  public async loadAnimation(url: string) {
1271
- await this.player.loadVmd(url)
1272
- this.hasAnimation = true
1273
-
1274
- // Show first frame (time 0) immediately
1275
- if (this.currentModel) {
1276
- const initialPose = this.player.getPoseAtTime(0)
1277
- this.applyPose(initialPose)
1278
-
1279
- // Reset bones without time 0 keyframes
1280
- const skeleton = this.currentModel.getSkeleton()
1281
- const bonesWithPose = new Set(initialPose.boneRotations.keys())
1282
- const bonesToReset: string[] = []
1283
- for (const bone of skeleton.bones) {
1284
- if (!bonesWithPose.has(bone.name)) {
1285
- bonesToReset.push(bone.name)
1286
- }
1287
- }
1288
-
1289
- if (bonesToReset.length > 0) {
1290
- const identityQuat = new Quat(0, 0, 0, 1)
1291
- const identityQuats = new Array(bonesToReset.length).fill(identityQuat)
1292
- this.rotateBones(bonesToReset, identityQuats, 0)
1293
- }
1294
-
1295
- // Update model pose and physics
1296
- this.currentModel.evaluatePose()
1297
-
1298
- if (this.physics) {
1299
- const worldMats = this.currentModel.getBoneWorldMatrices()
1300
- this.physics.reset(worldMats, this.currentModel.getBoneInverseBindMatrices())
1301
-
1302
- // Upload matrices immediately
1303
- this.device.queue.writeBuffer(
1304
- this.worldMatrixBuffer!,
1305
- 0,
1306
- worldMats.buffer,
1307
- worldMats.byteOffset,
1308
- worldMats.byteLength
1309
- )
1310
- const encoder = this.device.createCommandEncoder()
1311
- this.computeSkinMatrices(encoder)
1312
- this.device.queue.submit([encoder.finish()])
1313
- }
1314
- }
1083
+ if (!this.currentModel) return
1084
+ await this.currentModel.loadVmd(url)
1315
1085
  }
1316
1086
 
1317
1087
  public playAnimation() {
1318
- if (!this.hasAnimation || !this.currentModel) return
1319
-
1320
- const wasPaused = this.player.isPausedState()
1321
- const wasPlaying = this.player.isPlayingState()
1322
-
1323
- // Only reset pose and physics if starting from beginning (not resuming)
1324
- if (!wasPlaying && !wasPaused) {
1325
- // Get initial pose at time 0
1326
- const initialPose = this.player.getPoseAtTime(0)
1327
- this.applyPose(initialPose)
1328
-
1329
- // Reset bones without time 0 keyframes
1330
- const skeleton = this.currentModel.getSkeleton()
1331
- const bonesWithPose = new Set(initialPose.boneRotations.keys())
1332
- const bonesToReset: string[] = []
1333
- for (const bone of skeleton.bones) {
1334
- if (!bonesWithPose.has(bone.name)) {
1335
- bonesToReset.push(bone.name)
1336
- }
1337
- }
1338
-
1339
- if (bonesToReset.length > 0) {
1340
- const identityQuat = new Quat(0, 0, 0, 1)
1341
- const identityQuats = new Array(bonesToReset.length).fill(identityQuat)
1342
- this.rotateBones(bonesToReset, identityQuats, 0)
1343
- }
1344
-
1345
- // Reset physics immediately and upload matrices to prevent A-pose flash
1346
- if (this.physics) {
1347
- this.currentModel.evaluatePose()
1348
-
1349
- const worldMats = this.currentModel.getBoneWorldMatrices()
1350
- this.physics.reset(worldMats, this.currentModel.getBoneInverseBindMatrices())
1351
-
1352
- // Upload matrices immediately so next frame shows correct pose
1353
- this.device.queue.writeBuffer(
1354
- this.worldMatrixBuffer!,
1355
- 0,
1356
- worldMats.buffer,
1357
- worldMats.byteOffset,
1358
- worldMats.byteLength
1359
- )
1360
- const encoder = this.device.createCommandEncoder()
1361
- this.computeSkinMatrices(encoder)
1362
- this.device.queue.submit([encoder.finish()])
1363
- }
1364
- }
1365
-
1366
- // Start playback (or resume if paused)
1367
- this.player.play()
1368
- if (this.animationStartTime === 0) {
1369
- this.animationStartTime = performance.now()
1370
- }
1088
+ this.currentModel?.playAnimation()
1371
1089
  }
1372
1090
 
1373
1091
  public stopAnimation() {
1374
- this.player.stop()
1092
+ this.currentModel?.stopAnimation()
1375
1093
  }
1376
1094
 
1377
1095
  public pauseAnimation() {
1378
- this.player.pause()
1096
+ this.currentModel?.pauseAnimation()
1379
1097
  }
1380
1098
 
1381
1099
  public seekAnimation(time: number) {
1382
- if (!this.currentModel || !this.hasAnimation) return
1383
-
1384
- this.player.seek(time)
1385
-
1386
- // Immediately apply pose at seeked time
1387
- const pose = this.player.getPoseAtTime(time)
1388
- this.applyPose(pose)
1389
-
1390
- // Update model pose and physics
1391
- this.currentModel.evaluatePose()
1392
-
1393
- if (this.physics) {
1394
- const worldMats = this.currentModel.getBoneWorldMatrices()
1395
- this.physics.reset(worldMats, this.currentModel.getBoneInverseBindMatrices())
1396
-
1397
- // Upload matrices immediately
1398
- this.device.queue.writeBuffer(
1399
- this.worldMatrixBuffer!,
1400
- 0,
1401
- worldMats.buffer,
1402
- worldMats.byteOffset,
1403
- worldMats.byteLength
1404
- )
1405
- const encoder = this.device.createCommandEncoder()
1406
- this.computeSkinMatrices(encoder)
1407
- this.device.queue.submit([encoder.finish()])
1408
- }
1100
+ this.currentModel?.seekAnimation(time)
1409
1101
  }
1410
1102
 
1411
1103
  public getAnimationProgress() {
1412
- return this.player.getProgress()
1413
- }
1414
-
1415
- /**
1416
- * Apply animation pose to model
1417
- */
1418
- private applyPose(pose: AnimationPose): void {
1419
- if (!this.currentModel) return
1420
-
1421
- // Apply bone rotations
1422
- if (pose.boneRotations.size > 0) {
1423
- const boneNames = Array.from(pose.boneRotations.keys())
1424
- const rotations = Array.from(pose.boneRotations.values())
1425
- this.rotateBones(boneNames, rotations, 0)
1426
- }
1427
-
1428
- // Apply bone translations
1429
- if (pose.boneTranslations.size > 0) {
1430
- const boneNames = Array.from(pose.boneTranslations.keys())
1431
- const translations = Array.from(pose.boneTranslations.values())
1432
- this.moveBones(boneNames, translations, 0)
1433
- }
1434
-
1435
- // Apply morph weights
1436
- for (const [morphName, weight] of pose.morphWeights.entries()) {
1437
- this.setMorphWeight(morphName, weight, 0)
1438
- }
1104
+ return this.currentModel?.getAnimationProgress() ?? { current: 0, duration: 0, percentage: 0 }
1439
1105
  }
1440
1106
 
1441
1107
  public getStats(): EngineStats {
@@ -1484,8 +1150,6 @@ export class Engine {
1484
1150
  this.modelDir = dir
1485
1151
 
1486
1152
  const model = await PmxLoader.load(path)
1487
-
1488
- this.physics = new Physics(model.getRigidbodies(), model.getJoints())
1489
1153
  await this.setupModelBuffers(model)
1490
1154
  }
1491
1155
 
@@ -1559,13 +1223,7 @@ export class Engine {
1559
1223
  this.skinMatrixBuffer = this.device.createBuffer({
1560
1224
  label: "skin matrices",
1561
1225
  size: Math.max(256, matrixSize),
1562
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.VERTEX,
1563
- })
1564
-
1565
- this.worldMatrixBuffer = this.device.createBuffer({
1566
- label: "world matrices",
1567
- size: Math.max(256, matrixSize),
1568
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
1226
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
1569
1227
  })
1570
1228
 
1571
1229
  this.inverseBindMatrixBuffer = this.device.createBuffer({
@@ -1583,28 +1241,6 @@ export class Engine {
1583
1241
  invBindMatrices.byteLength
1584
1242
  )
1585
1243
 
1586
- this.boneCountBuffer = this.device.createBuffer({
1587
- label: "bone count uniform",
1588
- size: 32, // Minimum uniform buffer size is 32 bytes
1589
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1590
- })
1591
- const boneCountData = new Uint32Array(8) // 32 bytes total
1592
- boneCountData[0] = boneCount
1593
- this.device.queue.writeBuffer(this.boneCountBuffer, 0, boneCountData)
1594
-
1595
- this.createSkinMatrixComputePipeline()
1596
-
1597
- // Create compute bind group once (reused every frame)
1598
- this.skinMatrixComputeBindGroup = this.device.createBindGroup({
1599
- layout: this.skinMatrixComputePipeline!.getBindGroupLayout(0),
1600
- entries: [
1601
- { binding: 0, resource: { buffer: this.boneCountBuffer } },
1602
- { binding: 1, resource: { buffer: this.worldMatrixBuffer } },
1603
- { binding: 2, resource: { buffer: this.inverseBindMatrixBuffer } },
1604
- { binding: 3, resource: { buffer: this.skinMatrixBuffer } },
1605
- ],
1606
- })
1607
-
1608
1244
  const indices = model.getIndices()
1609
1245
  if (indices) {
1610
1246
  this.indexBuffer = this.device.createBuffer({
@@ -1638,15 +1274,7 @@ export class Engine {
1638
1274
  return texture
1639
1275
  }
1640
1276
 
1641
- this.opaqueDraws = []
1642
- this.eyeDraws = []
1643
- this.hairDrawsOverEyes = []
1644
- this.hairDrawsOverNonEyes = []
1645
- this.transparentDraws = []
1646
- this.opaqueOutlineDraws = []
1647
- this.eyeOutlineDraws = []
1648
- this.hairOutlineDraws = []
1649
- this.transparentOutlineDraws = []
1277
+ this.drawCalls = []
1650
1278
  let currentIndexOffset = 0
1651
1279
 
1652
1280
  for (const mat of materials) {
@@ -1659,23 +1287,7 @@ export class Engine {
1659
1287
  const materialAlpha = mat.diffuse[3]
1660
1288
  const isTransparent = materialAlpha < 1.0 - Engine.TRANSPARENCY_EPSILON
1661
1289
 
1662
- // Create material uniform data
1663
- const materialUniformData = new Float32Array(8)
1664
- materialUniformData[0] = materialAlpha
1665
- materialUniformData[1] = 1.0 // alphaMultiplier: 1.0 for non-hair materials
1666
- materialUniformData[2] = this.rimLightIntensity
1667
- materialUniformData[3] = 0.0 // _padding1
1668
- materialUniformData[4] = 1.0 // rimColor.r
1669
- materialUniformData[5] = 1.0 // rimColor.g
1670
- materialUniformData[6] = 1.0 // rimColor.b
1671
- materialUniformData[7] = 0.0 // isOverEyes
1672
-
1673
- const materialUniformBuffer = this.device.createBuffer({
1674
- label: `material uniform: ${mat.name}`,
1675
- size: materialUniformData.byteLength,
1676
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1677
- })
1678
- this.device.queue.writeBuffer(materialUniformBuffer, 0, materialUniformData)
1290
+ const materialUniformBuffer = this.createMaterialUniformBuffer(mat.name, materialAlpha, 0.0)
1679
1291
 
1680
1292
  // Create bind groups using the shared bind group layout - All pipelines (main, eye, hair multiply, hair opaque) use the same shader and layout
1681
1293
  const bindGroup = this.device.createBindGroup({
@@ -1691,100 +1303,70 @@ export class Engine {
1691
1303
  ],
1692
1304
  })
1693
1305
 
1694
- if (mat.isEye) {
1695
- if (indexCount > 0) {
1696
- this.eyeDraws.push({
1697
- count: indexCount,
1698
- firstIndex: currentIndexOffset,
1699
- bindGroup,
1700
- })
1701
- }
1702
- } else if (mat.isHair) {
1703
- // Hair materials: create separate bind groups for over-eyes vs over-non-eyes
1704
- const createHairBindGroup = (isOverEyes: boolean) => {
1705
- const uniformData = new Float32Array(8)
1706
- uniformData[0] = materialAlpha
1707
- uniformData[1] = 1.0 // alphaMultiplier (shader adjusts based on isOverEyes)
1708
- uniformData[2] = this.rimLightIntensity
1709
- uniformData[3] = 0.0 // _padding1
1710
- uniformData[4] = 1.0 // rimColor.rgb
1711
- uniformData[5] = 1.0
1712
- uniformData[6] = 1.0
1713
- uniformData[7] = isOverEyes ? 1.0 : 0.0 // isOverEyes
1714
-
1715
- const buffer = this.device.createBuffer({
1716
- label: `material uniform (${isOverEyes ? "over eyes" : "over non-eyes"}): ${mat.name}`,
1717
- size: uniformData.byteLength,
1718
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1719
- })
1720
- this.device.queue.writeBuffer(buffer, 0, uniformData)
1721
-
1722
- return this.device.createBindGroup({
1723
- label: `material bind group (${isOverEyes ? "over eyes" : "over non-eyes"}): ${mat.name}`,
1724
- layout: this.mainBindGroupLayout,
1725
- entries: [
1726
- { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1727
- { binding: 1, resource: { buffer: this.lightUniformBuffer } },
1728
- { binding: 2, resource: diffuseTexture.createView() },
1729
- { binding: 3, resource: this.materialSampler },
1730
- { binding: 4, resource: { buffer: this.skinMatrixBuffer! } },
1731
- { binding: 5, resource: { buffer: buffer } },
1732
- ],
1733
- })
1734
- }
1306
+ if (indexCount > 0) {
1307
+ if (mat.isEye) {
1308
+ this.drawCalls.push({ type: "eye", count: indexCount, firstIndex: currentIndexOffset, bindGroup })
1309
+ } else if (mat.isHair) {
1310
+ // Hair materials: create separate bind groups for over-eyes vs over-non-eyes
1311
+ const createHairBindGroup = (isOverEyes: boolean) => {
1312
+ const buffer = this.createMaterialUniformBuffer(
1313
+ `${mat.name} (${isOverEyes ? "over eyes" : "over non-eyes"})`,
1314
+ materialAlpha,
1315
+ isOverEyes ? 1.0 : 0.0
1316
+ )
1317
+
1318
+ return this.device.createBindGroup({
1319
+ label: `material bind group (${isOverEyes ? "over eyes" : "over non-eyes"}): ${mat.name}`,
1320
+ layout: this.mainBindGroupLayout,
1321
+ entries: [
1322
+ { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1323
+ { binding: 1, resource: { buffer: this.lightUniformBuffer } },
1324
+ { binding: 2, resource: diffuseTexture.createView() },
1325
+ { binding: 3, resource: this.materialSampler },
1326
+ { binding: 4, resource: { buffer: this.skinMatrixBuffer! } },
1327
+ { binding: 5, resource: { buffer: buffer } },
1328
+ ],
1329
+ })
1330
+ }
1735
1331
 
1736
- const bindGroupOverEyes = createHairBindGroup(true)
1737
- const bindGroupOverNonEyes = createHairBindGroup(false)
1332
+ const bindGroupOverEyes = createHairBindGroup(true)
1333
+ const bindGroupOverNonEyes = createHairBindGroup(false)
1738
1334
 
1739
- if (indexCount > 0) {
1740
- this.hairDrawsOverEyes.push({
1335
+ this.drawCalls.push({
1336
+ type: "hair-over-eyes",
1741
1337
  count: indexCount,
1742
1338
  firstIndex: currentIndexOffset,
1743
1339
  bindGroup: bindGroupOverEyes,
1744
1340
  })
1745
-
1746
- this.hairDrawsOverNonEyes.push({
1341
+ this.drawCalls.push({
1342
+ type: "hair-over-non-eyes",
1747
1343
  count: indexCount,
1748
1344
  firstIndex: currentIndexOffset,
1749
1345
  bindGroup: bindGroupOverNonEyes,
1750
1346
  })
1751
- }
1752
- } else if (isTransparent) {
1753
- if (indexCount > 0) {
1754
- this.transparentDraws.push({
1755
- count: indexCount,
1756
- firstIndex: currentIndexOffset,
1757
- bindGroup,
1758
- })
1759
- }
1760
- } else {
1761
- if (indexCount > 0) {
1762
- this.opaqueDraws.push({
1763
- count: indexCount,
1764
- firstIndex: currentIndexOffset,
1765
- bindGroup,
1766
- })
1347
+ } else if (isTransparent) {
1348
+ this.drawCalls.push({ type: "transparent", count: indexCount, firstIndex: currentIndexOffset, bindGroup })
1349
+ } else {
1350
+ this.drawCalls.push({ type: "opaque", count: indexCount, firstIndex: currentIndexOffset, bindGroup })
1767
1351
  }
1768
1352
  }
1769
1353
 
1770
1354
  // Edge flag is at bit 4 (0x10) in PMX format
1771
1355
  if ((mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
1772
- const materialUniformData = new Float32Array(8)
1773
- materialUniformData[0] = mat.edgeColor[0] // edgeColor.r
1774
- materialUniformData[1] = mat.edgeColor[1] // edgeColor.g
1775
- materialUniformData[2] = mat.edgeColor[2] // edgeColor.b
1776
- materialUniformData[3] = mat.edgeColor[3] // edgeColor.a
1777
- materialUniformData[4] = mat.edgeSize
1778
- materialUniformData[5] = 0.0 // isOverEyes
1779
- materialUniformData[6] = 0.0
1780
- materialUniformData[7] = 0.0
1781
-
1782
- const materialUniformBuffer = this.device.createBuffer({
1783
- label: `outline material uniform: ${mat.name}`,
1784
- size: materialUniformData.byteLength,
1785
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1786
- })
1787
- this.device.queue.writeBuffer(materialUniformBuffer, 0, materialUniformData)
1356
+ const materialUniformData = new Float32Array([
1357
+ mat.edgeColor[0],
1358
+ mat.edgeColor[1],
1359
+ mat.edgeColor[2],
1360
+ mat.edgeColor[3],
1361
+ mat.edgeSize,
1362
+ 0,
1363
+ 0,
1364
+ 0,
1365
+ ])
1366
+ const materialUniformBuffer = this.createUniformBuffer(
1367
+ `outline material uniform: ${mat.name}`,
1368
+ materialUniformData
1369
+ )
1788
1370
 
1789
1371
  const outlineBindGroup = this.device.createBindGroup({
1790
1372
  label: `outline bind group: ${mat.name}`,
@@ -1797,31 +1379,19 @@ export class Engine {
1797
1379
  })
1798
1380
 
1799
1381
  if (indexCount > 0) {
1800
- if (mat.isEye) {
1801
- this.eyeOutlineDraws.push({
1802
- count: indexCount,
1803
- firstIndex: currentIndexOffset,
1804
- bindGroup: outlineBindGroup,
1805
- })
1806
- } else if (mat.isHair) {
1807
- this.hairOutlineDraws.push({
1808
- count: indexCount,
1809
- firstIndex: currentIndexOffset,
1810
- bindGroup: outlineBindGroup,
1811
- })
1812
- } else if (isTransparent) {
1813
- this.transparentOutlineDraws.push({
1814
- count: indexCount,
1815
- firstIndex: currentIndexOffset,
1816
- bindGroup: outlineBindGroup,
1817
- })
1818
- } else {
1819
- this.opaqueOutlineDraws.push({
1820
- count: indexCount,
1821
- firstIndex: currentIndexOffset,
1822
- bindGroup: outlineBindGroup,
1823
- })
1824
- }
1382
+ const outlineType: DrawCallType = mat.isEye
1383
+ ? "eye-outline"
1384
+ : mat.isHair
1385
+ ? "hair-outline"
1386
+ : isTransparent
1387
+ ? "transparent-outline"
1388
+ : "opaque-outline"
1389
+ this.drawCalls.push({
1390
+ type: outlineType,
1391
+ count: indexCount,
1392
+ firstIndex: currentIndexOffset,
1393
+ bindGroup: outlineBindGroup,
1394
+ })
1825
1395
  }
1826
1396
  }
1827
1397
 
@@ -1829,6 +1399,22 @@ export class Engine {
1829
1399
  }
1830
1400
  }
1831
1401
 
1402
+ private createMaterialUniformBuffer(label: string, alpha: number, isOverEyes: number): GPUBuffer {
1403
+ const data = new Float32Array(8)
1404
+ data.set([alpha, 1.0, this.rimLightIntensity, 0.0, 1.0, 1.0, 1.0, isOverEyes])
1405
+ return this.createUniformBuffer(`material uniform: ${label}`, data)
1406
+ }
1407
+
1408
+ private createUniformBuffer(label: string, data: Float32Array | Uint32Array): GPUBuffer {
1409
+ const buffer = this.device.createBuffer({
1410
+ label,
1411
+ size: data.byteLength,
1412
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1413
+ })
1414
+ this.device.queue.writeBuffer(buffer, 0, data as ArrayBufferView<ArrayBuffer>)
1415
+ return buffer
1416
+ }
1417
+
1832
1418
  private async createTextureFromPath(path: string): Promise<GPUTexture | null> {
1833
1419
  const cached = this.textureCache.get(path)
1834
1420
  if (cached) {
@@ -1867,51 +1453,54 @@ export class Engine {
1867
1453
  private renderEyes(pass: GPURenderPassEncoder) {
1868
1454
  pass.setPipeline(this.eyePipeline)
1869
1455
  pass.setStencilReference(this.STENCIL_EYE_VALUE)
1870
- for (const draw of this.eyeDraws) {
1871
- pass.setBindGroup(0, draw.bindGroup)
1872
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1456
+ for (const draw of this.drawCalls) {
1457
+ if (draw.type === "eye") {
1458
+ pass.setBindGroup(0, draw.bindGroup)
1459
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1460
+ }
1873
1461
  }
1874
1462
  }
1875
1463
 
1876
1464
  // Helper: Render hair with post-alpha-eye effect (depth pre-pass + stencil-based shading + outlines)
1877
1465
  private renderHair(pass: GPURenderPassEncoder) {
1878
1466
  // Hair depth pre-pass (reduces overdraw via early depth rejection)
1879
- const hasHair = this.hairDrawsOverEyes.length > 0 || this.hairDrawsOverNonEyes.length > 0
1467
+ const hasHair = this.drawCalls.some((d) => d.type === "hair-over-eyes" || d.type === "hair-over-non-eyes")
1880
1468
  if (hasHair) {
1881
1469
  pass.setPipeline(this.hairDepthPipeline)
1882
- for (const draw of this.hairDrawsOverEyes) {
1883
- pass.setBindGroup(0, draw.bindGroup)
1884
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1885
- }
1886
- for (const draw of this.hairDrawsOverNonEyes) {
1887
- pass.setBindGroup(0, draw.bindGroup)
1888
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1470
+ for (const draw of this.drawCalls) {
1471
+ if (draw.type === "hair-over-eyes" || draw.type === "hair-over-non-eyes") {
1472
+ pass.setBindGroup(0, draw.bindGroup)
1473
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1474
+ }
1889
1475
  }
1890
1476
  }
1891
1477
 
1892
1478
  // Hair shading (split by stencil for transparency over eyes)
1893
- if (this.hairDrawsOverEyes.length > 0) {
1479
+ const hairOverEyes = this.drawCalls.filter((d) => d.type === "hair-over-eyes")
1480
+ if (hairOverEyes.length > 0) {
1894
1481
  pass.setPipeline(this.hairPipelineOverEyes)
1895
1482
  pass.setStencilReference(this.STENCIL_EYE_VALUE)
1896
- for (const draw of this.hairDrawsOverEyes) {
1483
+ for (const draw of hairOverEyes) {
1897
1484
  pass.setBindGroup(0, draw.bindGroup)
1898
1485
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1899
1486
  }
1900
1487
  }
1901
1488
 
1902
- if (this.hairDrawsOverNonEyes.length > 0) {
1489
+ const hairOverNonEyes = this.drawCalls.filter((d) => d.type === "hair-over-non-eyes")
1490
+ if (hairOverNonEyes.length > 0) {
1903
1491
  pass.setPipeline(this.hairPipelineOverNonEyes)
1904
1492
  pass.setStencilReference(this.STENCIL_EYE_VALUE)
1905
- for (const draw of this.hairDrawsOverNonEyes) {
1493
+ for (const draw of hairOverNonEyes) {
1906
1494
  pass.setBindGroup(0, draw.bindGroup)
1907
1495
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1908
1496
  }
1909
1497
  }
1910
1498
 
1911
1499
  // Hair outlines
1912
- if (this.hairOutlineDraws.length > 0) {
1500
+ const hairOutlines = this.drawCalls.filter((d) => d.type === "hair-outline")
1501
+ if (hairOutlines.length > 0) {
1913
1502
  pass.setPipeline(this.hairOutlinePipeline)
1914
- for (const draw of this.hairOutlineDraws) {
1503
+ for (const draw of hairOutlines) {
1915
1504
  pass.setBindGroup(0, draw.bindGroup)
1916
1505
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1917
1506
  }
@@ -1928,19 +1517,10 @@ export class Engine {
1928
1517
  this.updateCameraUniforms()
1929
1518
  this.updateRenderTarget()
1930
1519
 
1931
- // Animate VMD animation if playing
1932
- if (this.hasAnimation && this.currentModel) {
1933
- const pose = this.player.update(currentTime)
1934
- if (pose) {
1935
- this.applyPose(pose)
1936
- }
1937
- }
1938
-
1939
- // Update model pose first (this may update morph weights via tweens)
1940
- // We need to do this before creating the encoder to ensure vertex buffer is ready
1520
+ // Update model (handles tweens, animation, physics, IK, and skin matrices)
1941
1521
  if (this.currentModel) {
1942
- const hasActiveMorphTweens = this.currentModel.evaluatePose()
1943
- if (hasActiveMorphTweens) {
1522
+ const verticesChanged = this.currentModel.update(deltaTime)
1523
+ if (verticesChanged) {
1944
1524
  this.vertexBufferNeedsUpdate = true
1945
1525
  }
1946
1526
  }
@@ -1951,10 +1531,11 @@ export class Engine {
1951
1531
  this.vertexBufferNeedsUpdate = false
1952
1532
  }
1953
1533
 
1954
- // Use single encoder for both compute and render (reduces sync points)
1955
- const encoder = this.device.createCommandEncoder()
1534
+ // Update skin matrices buffer
1535
+ this.updateSkinMatrices()
1956
1536
 
1957
- this.updateModelPose(deltaTime, encoder)
1537
+ // Use single encoder for render
1538
+ const encoder = this.device.createCommandEncoder()
1958
1539
 
1959
1540
  const pass = encoder.beginRenderPass(this.renderPassDescriptor)
1960
1541
 
@@ -1966,9 +1547,11 @@ export class Engine {
1966
1547
 
1967
1548
  // Pass 1: Opaque
1968
1549
  pass.setPipeline(this.modelPipeline)
1969
- for (const draw of this.opaqueDraws) {
1970
- pass.setBindGroup(0, draw.bindGroup)
1971
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1550
+ for (const draw of this.drawCalls) {
1551
+ if (draw.type === "opaque") {
1552
+ pass.setBindGroup(0, draw.bindGroup)
1553
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1554
+ }
1972
1555
  }
1973
1556
 
1974
1557
  // Pass 2: Eyes (writes stencil value for hair to test against)
@@ -1981,9 +1564,11 @@ export class Engine {
1981
1564
 
1982
1565
  // Pass 4: Transparent
1983
1566
  pass.setPipeline(this.modelPipeline)
1984
- for (const draw of this.transparentDraws) {
1985
- pass.setBindGroup(0, draw.bindGroup)
1986
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1567
+ for (const draw of this.drawCalls) {
1568
+ if (draw.type === "transparent") {
1569
+ pass.setBindGroup(0, draw.bindGroup)
1570
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1571
+ }
1987
1572
  }
1988
1573
 
1989
1574
  this.drawOutlines(pass, true)
@@ -2119,42 +1704,27 @@ export class Engine {
2119
1704
  }
2120
1705
  }
2121
1706
 
2122
- private updateModelPose(deltaTime: number, encoder: GPUCommandEncoder) {
2123
- // Note: evaluatePose is called earlier in render() to update vertex buffer before encoder creation
2124
- // Here we just get the matrices and update physics/compute
2125
- const worldMats = this.currentModel!.getBoneWorldMatrices()
2126
-
2127
- if (this.physics) {
2128
- this.physics.step(deltaTime, worldMats, this.currentModel!.getBoneInverseBindMatrices())
2129
- }
1707
+ private updateSkinMatrices() {
1708
+ if (!this.currentModel || !this.skinMatrixBuffer) return
2130
1709
 
1710
+ const skinMatrices = this.currentModel.getSkinMatrices()
2131
1711
  this.device.queue.writeBuffer(
2132
- this.worldMatrixBuffer!,
1712
+ this.skinMatrixBuffer,
2133
1713
  0,
2134
- worldMats.buffer,
2135
- worldMats.byteOffset,
2136
- worldMats.byteLength
1714
+ skinMatrices.buffer,
1715
+ skinMatrices.byteOffset,
1716
+ skinMatrices.byteLength
2137
1717
  )
2138
- this.computeSkinMatrices(encoder)
2139
- }
2140
-
2141
- private computeSkinMatrices(encoder: GPUCommandEncoder) {
2142
- const boneCount = this.currentModel!.getSkeleton().bones.length
2143
- const workgroupCount = Math.ceil(boneCount / this.COMPUTE_WORKGROUP_SIZE)
2144
-
2145
- const pass = encoder.beginComputePass()
2146
- pass.setPipeline(this.skinMatrixComputePipeline!)
2147
- pass.setBindGroup(0, this.skinMatrixComputeBindGroup!)
2148
- pass.dispatchWorkgroups(workgroupCount)
2149
- pass.end()
2150
1718
  }
2151
1719
 
2152
1720
  private drawOutlines(pass: GPURenderPassEncoder, transparent: boolean) {
2153
1721
  pass.setPipeline(this.outlinePipeline)
2154
- const draws = transparent ? this.transparentOutlineDraws : this.opaqueOutlineDraws
2155
- for (const draw of draws) {
2156
- pass.setBindGroup(0, draw.bindGroup)
2157
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1722
+ const outlineType: DrawCallType = transparent ? "transparent-outline" : "opaque-outline"
1723
+ for (const draw of this.drawCalls) {
1724
+ if (draw.type === outlineType) {
1725
+ pass.setBindGroup(0, draw.bindGroup)
1726
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1727
+ }
2158
1728
  }
2159
1729
  }
2160
1730