reze-engine 0.15.0 → 0.16.0

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.
Files changed (93) hide show
  1. package/README.md +3 -3
  2. package/dist/camera.d.ts +2 -0
  3. package/dist/camera.d.ts.map +1 -1
  4. package/dist/camera.js +8 -3
  5. package/dist/engine.d.ts +19 -4
  6. package/dist/engine.d.ts.map +1 -1
  7. package/dist/engine.js +294 -27
  8. package/dist/gpu-profile.d.ts +19 -0
  9. package/dist/gpu-profile.d.ts.map +1 -0
  10. package/dist/gpu-profile.js +120 -0
  11. package/dist/ik-solver.d.ts.map +1 -1
  12. package/dist/ik-solver.js +13 -7
  13. package/dist/math.d.ts +2 -0
  14. package/dist/math.d.ts.map +1 -1
  15. package/dist/math.js +65 -46
  16. package/dist/model.d.ts +28 -1
  17. package/dist/model.d.ts.map +1 -1
  18. package/dist/model.js +259 -75
  19. package/dist/physics/body.d.ts +2 -0
  20. package/dist/physics/body.d.ts.map +1 -1
  21. package/dist/physics/body.js +30 -0
  22. package/dist/physics/constraint.d.ts +13 -0
  23. package/dist/physics/constraint.d.ts.map +1 -1
  24. package/dist/physics/constraint.js +13 -0
  25. package/dist/physics/contact.d.ts +28 -0
  26. package/dist/physics/contact.d.ts.map +1 -1
  27. package/dist/physics/contact.js +27 -32
  28. package/dist/physics/physics.d.ts +3 -0
  29. package/dist/physics/physics.d.ts.map +1 -1
  30. package/dist/physics/physics.js +57 -8
  31. package/dist/physics/profile.d.ts +18 -0
  32. package/dist/physics/profile.d.ts.map +1 -0
  33. package/dist/physics/profile.js +44 -0
  34. package/dist/physics/solver.d.ts.map +1 -1
  35. package/dist/physics/solver.js +436 -293
  36. package/dist/pmx-loader.d.ts.map +1 -1
  37. package/dist/pmx-loader.js +2 -30
  38. package/dist/shaders/materials/body.d.ts +1 -1
  39. package/dist/shaders/materials/body.d.ts.map +1 -1
  40. package/dist/shaders/materials/body.js +3 -1
  41. package/dist/shaders/materials/cloth_rough.d.ts +1 -1
  42. package/dist/shaders/materials/cloth_rough.d.ts.map +1 -1
  43. package/dist/shaders/materials/cloth_rough.js +3 -1
  44. package/dist/shaders/materials/cloth_smooth.d.ts +1 -1
  45. package/dist/shaders/materials/cloth_smooth.d.ts.map +1 -1
  46. package/dist/shaders/materials/common.d.ts +2 -2
  47. package/dist/shaders/materials/common.d.ts.map +1 -1
  48. package/dist/shaders/materials/common.js +8 -0
  49. package/dist/shaders/materials/default.d.ts +1 -1
  50. package/dist/shaders/materials/default.d.ts.map +1 -1
  51. package/dist/shaders/materials/eye.d.ts +1 -1
  52. package/dist/shaders/materials/eye.d.ts.map +1 -1
  53. package/dist/shaders/materials/face.d.ts +1 -1
  54. package/dist/shaders/materials/face.d.ts.map +1 -1
  55. package/dist/shaders/materials/face.js +4 -1
  56. package/dist/shaders/materials/hair.d.ts +1 -1
  57. package/dist/shaders/materials/hair.d.ts.map +1 -1
  58. package/dist/shaders/materials/metal.d.ts +1 -1
  59. package/dist/shaders/materials/metal.d.ts.map +1 -1
  60. package/dist/shaders/materials/stockings.d.ts +1 -1
  61. package/dist/shaders/materials/stockings.d.ts.map +1 -1
  62. package/dist/shaders/materials/stockings.js +13 -9
  63. package/dist/shaders/passes/composite.d.ts +1 -1
  64. package/dist/shaders/passes/composite.d.ts.map +1 -1
  65. package/dist/shaders/passes/composite.js +18 -12
  66. package/dist/shaders/passes/morph.d.ts +2 -0
  67. package/dist/shaders/passes/morph.d.ts.map +1 -0
  68. package/dist/shaders/passes/morph.js +49 -0
  69. package/package.json +1 -1
  70. package/src/camera.ts +9 -3
  71. package/src/engine.ts +336 -29
  72. package/src/ik-solver.ts +14 -7
  73. package/src/math.ts +41 -51
  74. package/src/model.ts +288 -91
  75. package/src/physics/body.ts +29 -0
  76. package/src/physics/constraint.ts +30 -0
  77. package/src/physics/contact.ts +43 -30
  78. package/src/physics/physics.ts +58 -17
  79. package/src/physics/solver.ts +431 -284
  80. package/src/pmx-loader.ts +2 -35
  81. package/src/shaders/materials/body.ts +3 -1
  82. package/src/shaders/materials/cloth_rough.ts +3 -1
  83. package/src/shaders/materials/common.ts +8 -0
  84. package/src/shaders/materials/face.ts +4 -1
  85. package/src/shaders/materials/stockings.ts +13 -9
  86. package/src/shaders/passes/composite.ts +18 -12
  87. package/src/shaders/passes/morph.ts +50 -0
  88. package/dist/physics-debug.d.ts +0 -30
  89. package/dist/physics-debug.d.ts.map +0 -1
  90. package/dist/physics-debug.js +0 -526
  91. package/dist/shaders/passes/physics-debug.d.ts +0 -2
  92. package/dist/shaders/passes/physics-debug.d.ts.map +0 -1
  93. package/dist/shaders/passes/physics-debug.js +0 -69
package/src/engine.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Camera } from "./camera"
2
2
  import { Mat4, Quat, Vec3 } from "./math"
3
3
  import { Model } from "./model"
4
+ import { MORPH_COMPUTE_WGSL } from "./shaders/passes/morph"
4
5
  import { PmxLoader } from "./pmx-loader"
5
6
  import { RezePhysics } from "./physics"
6
7
  import {
@@ -188,8 +189,11 @@ export const DEFAULT_ENGINE_OPTIONS = {
188
189
  }
189
190
 
190
191
  export interface EngineStats {
191
- fps: number
192
- frameTime: number // ms
192
+ fps: number // derived from mean frame interval — bounded by the real refresh rate
193
+ frameTime: number // ms — mean frame interval (vsync-to-vsync), not CPU work time
194
+ frameTimeMax: number // ms — worst frame interval in the window (hitch / stutter indicator)
195
+ fps1PercentLow: number // "1% low" fps = 1000 / 99th-percentile frame interval
196
+ jitter: number // ms — stddev of frame intervals (pacing evenness; high = janky at any mean fps)
193
197
  }
194
198
 
195
199
  type DrawCallType = "opaque" | "transparent" | "ground" | "opaque-outline" | "transparent-outline"
@@ -231,6 +235,16 @@ interface ModelInstance {
231
235
  materialPresets: MaterialPresetMap | undefined
232
236
  physics: RezePhysics | null
233
237
  vertexBufferNeedsUpdate: boolean
238
+ gpuMorph: GpuMorph | null
239
+ }
240
+
241
+ // Per-model GPU vertex-morph compute state. Present only for models with vertex morphs.
242
+ interface GpuMorph {
243
+ bindGroup: GPUBindGroup
244
+ weightsBuffer: GPUBuffer
245
+ weightsData: Float32Array // staging copy uploaded when weights change
246
+ workgroups: number
247
+ dispatchNeeded: boolean
234
248
  }
235
249
 
236
250
  export class Engine {
@@ -374,6 +388,8 @@ export class Engine {
374
388
  // Safari's Metal backend won't fold pow(x, 1) to identity.
375
389
  private compositePipelineIdentity!: GPURenderPipeline
376
390
  private compositePipelineGamma!: GPURenderPipeline
391
+ private morphComputePipeline!: GPUComputePipeline
392
+ private morphComputeBindGroupLayout!: GPUBindGroupLayout
377
393
  private compositeBindGroupLayout!: GPUBindGroupLayout
378
394
  private compositeBindGroup!: GPUBindGroup
379
395
  private compositeUniformBuffer!: GPUBuffer
@@ -417,6 +433,10 @@ export class Engine {
417
433
  private shadowMapDepthView!: GPUTextureView
418
434
  private brdfLutTexture!: GPUTexture
419
435
  private brdfLutView!: GPUTextureView
436
+ private filmicLutTexture!: GPUTexture
437
+ private filmicLutView!: GPUTextureView
438
+ // Width of the baked Filmic tone LUT (composite.ts FILMIC_LUT_W must match).
439
+ private static readonly FILMIC_LUT_WIDTH = 256
420
440
  private static readonly SHADOW_MAP_SIZE = 2048
421
441
  private shadowDepthPipeline!: GPURenderPipeline
422
442
  private shadowLightVPBuffer!: GPUBuffer
@@ -452,20 +472,29 @@ export class Engine {
452
472
  // IK and physics enabled at engine level (same for all models)
453
473
  private ikEnabled = true
454
474
  private physicsEnabled = true
475
+ // GPU vertex-morph path. Set false BEFORE loadModel to fall back to the CPU path (A/B).
476
+ private useGpuMorphs = true
455
477
 
456
478
  // Camera target binding (Babylon/Three style: camera follows model)
457
479
  private cameraTargetModel: Model | null = null
458
480
  private cameraTargetBoneName = "全ての親"
459
481
  private cameraTargetOffset: Vec3 = new Vec3(0, 0, 0)
460
482
 
461
- private lastFpsUpdate = performance.now()
462
- private framesSinceLastUpdate = 0
463
483
  private lastFrameTime = performance.now()
464
- private frameTimeSum = 0
465
- private frameTimeCount = 0
484
+ // Smoothness metrics are computed over a ring buffer of true frame intervals
485
+ // (vsync-to-vsync), recomputed at STATS_REFRESH_MS so the readout doesn't flicker.
486
+ private static readonly STATS_WINDOW = 120
487
+ private static readonly STATS_REFRESH_MS = 500
488
+ private frameIntervals = new Float32Array(Engine.STATS_WINDOW)
489
+ private frameIntervalWrite = 0
490
+ private frameIntervalFilled = 0
491
+ private lastStatsCompute = performance.now()
466
492
  private stats: EngineStats = {
467
493
  fps: 0,
468
494
  frameTime: 0,
495
+ frameTimeMax: 0,
496
+ fps1PercentLow: 0,
497
+ jitter: 0,
469
498
  }
470
499
  private animationFrameId: number | null = null
471
500
  private renderLoopCallback: (() => void) | null = null
@@ -726,6 +755,91 @@ export class Engine {
726
755
  ltcTemp.destroy()
727
756
  }
728
757
 
758
+ // Bake the Blender 3.6 Filmic MHC tone curve into a WIDTH×1 r16float LUT sampled by the
759
+ // composite pass. The 14 anchors are the same as the old inline array; we fit a monotone
760
+ // cubic (Fritsch–Carlson) through them so the curve is C1-continuous (no Mach banding in
761
+ // smooth gradients) while still passing through every anchor (look preserved) and staying
762
+ // monotone (no tonemap overshoot/ringing). Domain is uniform in log2 space: anchor k sits
763
+ // at t=k, k=0..13 (t = log2(linear)+10). See composite.ts::filmic for the sampling map.
764
+ private bakeFilmicLut() {
765
+ const anchors = [
766
+ 0.0028, 0.0068, 0.0151, 0.0313, 0.061, 0.112, 0.192, 0.306, 0.459, 0.631, 0.82, 0.907, 0.962, 0.989,
767
+ ]
768
+ const n = anchors.length
769
+ // Secant slopes (unit spacing, so d_k = y_{k+1} - y_k).
770
+ const d = new Array<number>(n - 1)
771
+ for (let k = 0; k < n - 1; k++) d[k] = anchors[k + 1] - anchors[k]
772
+ // Endpoint + interior tangents.
773
+ const m = new Array<number>(n)
774
+ m[0] = d[0]
775
+ m[n - 1] = d[n - 2]
776
+ for (let k = 1; k < n - 1; k++) m[k] = (d[k - 1] + d[k]) * 0.5
777
+ // Fritsch–Carlson monotonicity clamp.
778
+ for (let k = 0; k < n - 1; k++) {
779
+ if (d[k] === 0) {
780
+ m[k] = 0
781
+ m[k + 1] = 0
782
+ continue
783
+ }
784
+ const a = m[k] / d[k]
785
+ const b = m[k + 1] / d[k]
786
+ const s = a * a + b * b
787
+ if (s > 9) {
788
+ const tau = 3 / Math.sqrt(s)
789
+ m[k] = tau * a * d[k]
790
+ m[k + 1] = tau * b * d[k]
791
+ }
792
+ }
793
+ const W = Engine.FILMIC_LUT_WIDTH
794
+ const values = new Float32Array(W)
795
+ for (let j = 0; j < W; j++) {
796
+ const t = ((n - 1) * j) / (W - 1) // t ∈ [0, n-1]
797
+ const k = Math.min(Math.floor(t), n - 2)
798
+ const s = t - k // local param in [0,1], unit-spaced segment
799
+ const s2 = s * s
800
+ const s3 = s2 * s
801
+ // Hermite basis (h=1).
802
+ const h00 = 2 * s3 - 3 * s2 + 1
803
+ const h10 = s3 - 2 * s2 + s
804
+ const h01 = -2 * s3 + 3 * s2
805
+ const h11 = s3 - s2
806
+ values[j] = h00 * anchors[k] + h10 * m[k] + h01 * anchors[k + 1] + h11 * m[k + 1]
807
+ }
808
+
809
+ // f32 → f16 bits (same conversion as bakeBrdfLut).
810
+ const half = new Uint16Array(W)
811
+ const f32 = new Float32Array(1)
812
+ const u32 = new Uint32Array(f32.buffer)
813
+ for (let j = 0; j < W; j++) {
814
+ f32[0] = values[j]
815
+ const x = u32[0]
816
+ const sign = (x >>> 16) & 0x8000
817
+ const exp = ((x >>> 23) & 0xff) - 127 + 15
818
+ const mant = x & 0x7fffff
819
+ if (exp <= 0) {
820
+ half[j] = sign
821
+ } else if (exp >= 31) {
822
+ half[j] = sign | 0x7c00
823
+ } else {
824
+ half[j] = sign | (exp << 10) | (mant >>> 13)
825
+ }
826
+ }
827
+
828
+ this.filmicLutTexture = this.device.createTexture({
829
+ label: "Filmic tone LUT",
830
+ size: [W, 1],
831
+ format: "r16float",
832
+ usage: GPUTextureUsage.COPY_DST | GPUTextureUsage.TEXTURE_BINDING,
833
+ })
834
+ this.filmicLutView = this.filmicLutTexture.createView()
835
+ this.device.queue.writeTexture(
836
+ { texture: this.filmicLutTexture },
837
+ half,
838
+ { bytesPerRow: W * 2, rowsPerImage: 1 },
839
+ { width: W, height: 1, depthOrArrayLayers: 1 },
840
+ )
841
+ }
842
+
729
843
  private createRenderPipeline(config: {
730
844
  label: string
731
845
  layout: GPUPipelineLayout
@@ -1154,6 +1268,7 @@ export class Engine {
1154
1268
 
1155
1269
  // One-shot bake of Blender EEVEE's combined BRDF LUT (DFG + LTC packed rgba8unorm).
1156
1270
  this.bakeBrdfLut()
1271
+ this.bakeFilmicLut()
1157
1272
 
1158
1273
  // Now that shadow resources exist, create the main per-frame bind group
1159
1274
  this.perFrameBindGroup = this.device.createBindGroup({
@@ -1436,6 +1551,8 @@ export class Engine {
1436
1551
  // Aux mask/alpha texture — composite reads .g to reconstruct the alpha that
1437
1552
  // used to live in the HDR target before the rg11b10ufloat switch.
1438
1553
  { binding: 4, visibility: GPUShaderStage.FRAGMENT, texture: {} },
1554
+ // Filmic tone LUT (r16float, filterable) — sampled with the binding-2 sampler.
1555
+ { binding: 5, visibility: GPUShaderStage.FRAGMENT, texture: {} },
1439
1556
  ],
1440
1557
  })
1441
1558
 
@@ -1463,6 +1580,31 @@ export class Engine {
1463
1580
  this.compositePipelineIdentity = makeCompositePipeline(false, "composite pipeline (gamma=1)")
1464
1581
  this.compositePipelineGamma = makeCompositePipeline(true, "composite pipeline (gamma!=1)")
1465
1582
 
1583
+ // GPU vertex-morph compute pipeline (shared by all models; per-model bind groups).
1584
+ // Bindings: 0-4 read-only storage (base pos, CSR rowStart/colMorph/colOffset, weights),
1585
+ // 5 read-write storage (vertex buffer), 6 uniform (params).
1586
+ const roStorage = { type: "read-only-storage" as const }
1587
+ this.morphComputeBindGroupLayout = this.device.createBindGroupLayout({
1588
+ label: "morph compute bind group layout",
1589
+ entries: [
1590
+ { binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: roStorage },
1591
+ { binding: 1, visibility: GPUShaderStage.COMPUTE, buffer: roStorage },
1592
+ { binding: 2, visibility: GPUShaderStage.COMPUTE, buffer: roStorage },
1593
+ { binding: 3, visibility: GPUShaderStage.COMPUTE, buffer: roStorage },
1594
+ { binding: 4, visibility: GPUShaderStage.COMPUTE, buffer: roStorage },
1595
+ { binding: 5, visibility: GPUShaderStage.COMPUTE, buffer: { type: "storage" } },
1596
+ { binding: 6, visibility: GPUShaderStage.COMPUTE, buffer: { type: "uniform" } },
1597
+ ],
1598
+ })
1599
+ this.morphComputePipeline = this.device.createComputePipeline({
1600
+ label: "morph compute pipeline",
1601
+ layout: this.device.createPipelineLayout({ bindGroupLayouts: [this.morphComputeBindGroupLayout] }),
1602
+ compute: {
1603
+ module: this.device.createShaderModule({ label: "morph compute shader", code: MORPH_COMPUTE_WGSL }),
1604
+ entryPoint: "cs",
1605
+ },
1606
+ })
1607
+
1466
1608
  this.bloomPassDescriptor = {
1467
1609
  label: "bloom pass",
1468
1610
  colorAttachments: [
@@ -1781,6 +1923,7 @@ export class Engine {
1781
1923
  { binding: 2, resource: this.bloomSampler },
1782
1924
  { binding: 3, resource: { buffer: this.compositeUniformBuffer } },
1783
1925
  { binding: 4, resource: this.maskResolveView },
1926
+ { binding: 5, resource: this.filmicLutView },
1784
1927
  ],
1785
1928
  })
1786
1929
  }
@@ -2342,6 +2485,11 @@ export class Engine {
2342
2485
  this.ikEnabled = enabled
2343
2486
  }
2344
2487
 
2488
+ // Toggle the GPU vertex-morph path. Only affects models loaded afterwards.
2489
+ setGpuMorphsEnabled(enabled: boolean): void {
2490
+ this.useGpuMorphs = enabled
2491
+ }
2492
+
2345
2493
  getIKEnabled(): boolean {
2346
2494
  return this.ikEnabled
2347
2495
  }
@@ -2372,7 +2520,23 @@ export class Engine {
2372
2520
  private updateInstances(deltaTime: number): void {
2373
2521
  this.forEachInstance((inst) => {
2374
2522
  const verticesChanged = inst.model.update(deltaTime, this.ikEnabled)
2375
- if (verticesChanged) inst.vertexBufferNeedsUpdate = true
2523
+ if (inst.gpuMorph) {
2524
+ // GPU path: on a weight change, upload effective weights (thresholding tiny values
2525
+ // to 0 to match the CPU skip) and flag the compute dispatch for this frame.
2526
+ if (inst.model.consumeMorphWeightsDirty()) {
2527
+ const eff = inst.model.getEffectiveMorphWeights()
2528
+ const wd = inst.gpuMorph.weightsData
2529
+ const n = Math.min(wd.length, eff.length)
2530
+ for (let i = 0; i < n; i++) {
2531
+ const w = eff[i]
2532
+ wd[i] = w < 0.0001 ? 0 : w
2533
+ }
2534
+ this.device.queue.writeBuffer(inst.gpuMorph.weightsBuffer, 0, wd as ArrayBufferView<ArrayBuffer>)
2535
+ inst.gpuMorph.dispatchNeeded = true
2536
+ }
2537
+ } else if (verticesChanged) {
2538
+ inst.vertexBufferNeedsUpdate = true
2539
+ }
2376
2540
  if (inst.physics && this.physicsEnabled) {
2377
2541
  inst.physics.step(deltaTime, inst.model.getWorldMatrices(), inst.model.getBoneInverseBindMatrices())
2378
2542
  }
@@ -2381,12 +2545,52 @@ export class Engine {
2381
2545
  }
2382
2546
 
2383
2547
  private updateVertexBuffer(inst: ModelInstance): void {
2548
+ // GPU-morph models never CPU-upload the vertex buffer after load — the compute pass
2549
+ // owns the position slots. Ignore any stray dirty flag (e.g. from markVertexBufferDirty).
2550
+ if (inst.gpuMorph) {
2551
+ inst.vertexBufferNeedsUpdate = false
2552
+ return
2553
+ }
2384
2554
  const vertices = inst.model.getVertices()
2385
2555
  if (!vertices?.length) return
2386
- this.device.queue.writeBuffer(inst.vertexBuffer, 0, vertices)
2556
+ // Vertex morphs touch only a subset of verts (typically the face), so upload just the
2557
+ // changed [minVert, maxVert] slice when the model can report one; null = full upload.
2558
+ const range = inst.model.consumeVertexUploadRange()
2559
+ if (range) {
2560
+ const STRIDE = 8 // floats per vertex (pos3 + normal3 + uv2)
2561
+ const firstFloat = range.minVert * STRIDE
2562
+ const floatLen = (range.maxVert - range.minVert + 1) * STRIDE
2563
+ const byteOffset = firstFloat * 4
2564
+ this.device.queue.writeBuffer(
2565
+ inst.vertexBuffer,
2566
+ byteOffset,
2567
+ vertices.buffer,
2568
+ vertices.byteOffset + byteOffset,
2569
+ floatLen * 4,
2570
+ )
2571
+ } else {
2572
+ this.device.queue.writeBuffer(inst.vertexBuffer, 0, vertices)
2573
+ }
2387
2574
  inst.vertexBufferNeedsUpdate = false
2388
2575
  }
2389
2576
 
2577
+ // One compute pass covering every model whose morph weights changed this frame.
2578
+ private dispatchMorphCompute(encoder: GPUCommandEncoder): void {
2579
+ let pass: GPUComputePassEncoder | null = null
2580
+ for (const inst of this.modelInstances.values()) {
2581
+ const gm = inst.gpuMorph
2582
+ if (!gm || !gm.dispatchNeeded) continue
2583
+ if (!pass) {
2584
+ pass = encoder.beginComputePass({ label: "morph compute" })
2585
+ pass.setPipeline(this.morphComputePipeline)
2586
+ }
2587
+ pass.setBindGroup(0, gm.bindGroup)
2588
+ pass.dispatchWorkgroups(gm.workgroups)
2589
+ gm.dispatchNeeded = false
2590
+ }
2591
+ if (pass) pass.end()
2592
+ }
2593
+
2390
2594
  private async setupModelInstance(
2391
2595
  name: string,
2392
2596
  model: Model,
@@ -2402,7 +2606,8 @@ export class Engine {
2402
2606
  const vertexBuffer = this.device.createBuffer({
2403
2607
  label: `${name}: vertex buffer`,
2404
2608
  size: vertices.byteLength,
2405
- usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
2609
+ // STORAGE so the morph compute pass can write morphed positions in place.
2610
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST | GPUBufferUsage.STORAGE,
2406
2611
  })
2407
2612
  this.device.queue.writeBuffer(vertexBuffer, 0, vertices)
2408
2613
 
@@ -2473,6 +2678,8 @@ export class Engine {
2473
2678
 
2474
2679
  const gpuBuffers: GPUBuffer[] = [vertexBuffer, indexBuffer, jointsBuffer, weightsBuffer, skinMatrixBuffer]
2475
2680
 
2681
+ const gpuMorph = this.createGpuMorph(name, model, vertexBuffer, gpuBuffers)
2682
+
2476
2683
  const inst: ModelInstance = {
2477
2684
  name,
2478
2685
  model,
@@ -2495,11 +2702,83 @@ export class Engine {
2495
2702
  materialPresets: undefined,
2496
2703
  physics,
2497
2704
  vertexBufferNeedsUpdate: false,
2705
+ gpuMorph,
2498
2706
  }
2499
2707
  await this.setupMaterialsForInstance(inst)
2500
2708
  this.modelInstances.set(name, inst)
2501
2709
  }
2502
2710
 
2711
+ // Build the per-model GPU vertex-morph state. Returns null (and leaves the model on the
2712
+ // CPU morph path) when the model has no vertex morphs. Created buffers are pushed into
2713
+ // gpuBuffers so they're released with the instance.
2714
+ private createGpuMorph(
2715
+ name: string,
2716
+ model: Model,
2717
+ vertexBuffer: GPUBuffer,
2718
+ gpuBuffers: GPUBuffer[],
2719
+ ): GpuMorph | null {
2720
+ if (!this.useGpuMorphs) return null
2721
+ const data = model.buildMorphComputeData()
2722
+ if (!data) return null
2723
+
2724
+ const RO = GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
2725
+ const mkStorage = (label: string, arr: Float32Array | Uint32Array): GPUBuffer => {
2726
+ const buf = this.device.createBuffer({
2727
+ label: `${name}: morph ${label}`,
2728
+ size: Math.max(arr.byteLength, 4),
2729
+ usage: RO,
2730
+ })
2731
+ this.device.queue.writeBuffer(buf, 0, arr as ArrayBufferView<ArrayBuffer>)
2732
+ gpuBuffers.push(buf)
2733
+ return buf
2734
+ }
2735
+
2736
+ const baseBuf = mkStorage("basePositions", data.basePositions)
2737
+ const rowBuf = mkStorage("rowStart", data.rowStart)
2738
+ const colMorphBuf = mkStorage("colMorph", data.colMorph)
2739
+ const colOffsetBuf = mkStorage("colOffset", data.colOffset)
2740
+
2741
+ // Weights are zero-initialized by WebGPU; the first weight change uploads real values.
2742
+ const weightsBuffer = this.device.createBuffer({
2743
+ label: `${name}: morph weights`,
2744
+ size: Math.max(data.morphCount * 4, 4),
2745
+ usage: RO,
2746
+ })
2747
+ gpuBuffers.push(weightsBuffer)
2748
+
2749
+ const paramsBuffer = this.device.createBuffer({
2750
+ label: `${name}: morph params`,
2751
+ size: 16,
2752
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
2753
+ })
2754
+ this.device.queue.writeBuffer(paramsBuffer, 0, new Uint32Array([data.vertexCount, 0, 0, 0]))
2755
+ gpuBuffers.push(paramsBuffer)
2756
+
2757
+ const bindGroup = this.device.createBindGroup({
2758
+ label: `${name}: morph compute bind group`,
2759
+ layout: this.morphComputeBindGroupLayout,
2760
+ entries: [
2761
+ { binding: 0, resource: { buffer: baseBuf } },
2762
+ { binding: 1, resource: { buffer: rowBuf } },
2763
+ { binding: 2, resource: { buffer: colMorphBuf } },
2764
+ { binding: 3, resource: { buffer: colOffsetBuf } },
2765
+ { binding: 4, resource: { buffer: weightsBuffer } },
2766
+ { binding: 5, resource: { buffer: vertexBuffer } },
2767
+ { binding: 6, resource: { buffer: paramsBuffer } },
2768
+ ],
2769
+ })
2770
+
2771
+ model.enableGpuMorphs()
2772
+
2773
+ return {
2774
+ bindGroup,
2775
+ weightsBuffer,
2776
+ weightsData: new Float32Array(data.morphCount),
2777
+ workgroups: Math.ceil(data.vertexCount / 64),
2778
+ dispatchNeeded: false, // vertex buffer already holds base; dispatch on first weight change
2779
+ }
2780
+ }
2781
+
2503
2782
  private createGroundGeometry(width: number = 100, height: number = 100) {
2504
2783
  const halfWidth = width / 2
2505
2784
  const halfHeight = height / 2
@@ -3467,6 +3746,11 @@ export class Engine {
3467
3746
  this.updateShadowLightVP()
3468
3747
 
3469
3748
  const encoder = this.device.createCommandEncoder()
3749
+
3750
+ // GPU vertex morphs: write morphed positions into vertex buffers before any pass reads
3751
+ // them. WebGPU inserts the storage→vertex barrier between this pass and the render passes.
3752
+ if (hasModels) this.dispatchMorphCompute(encoder)
3753
+
3470
3754
  if (hasModels) {
3471
3755
  const sp = encoder.beginRenderPass({
3472
3756
  colorAttachments: [],
@@ -3554,7 +3838,9 @@ export class Engine {
3554
3838
  this.resolvePickResult(pick.x / dpr, pick.y / dpr)
3555
3839
  }
3556
3840
 
3557
- this.updateStats(performance.now() - currentTime)
3841
+ // Feed the true vsync-to-vsync interval (deltaTime, computed at frame start), not the
3842
+ // CPU time spent in render() — that's what actually reflects perceived smoothness.
3843
+ this.updateStats(deltaTime * 1000)
3558
3844
  }
3559
3845
 
3560
3846
  private drawInstanceShadow(sp: GPURenderPassEncoder, inst: ModelInstance): void {
@@ -3696,28 +3982,49 @@ export class Engine {
3696
3982
  })
3697
3983
  }
3698
3984
 
3699
- private updateStats(frameTime: number) {
3700
- // Simplified frame time tracking - rolling average with fixed window
3701
- const maxSamples = 60
3702
- this.frameTimeSum += frameTime
3703
- this.frameTimeCount++
3704
- if (this.frameTimeCount > maxSamples) {
3705
- // Maintain rolling window by subtracting oldest sample estimate
3706
- const avg = this.frameTimeSum / maxSamples
3707
- this.frameTimeSum -= avg
3708
- this.frameTimeCount = maxSamples
3709
- }
3710
- this.stats.frameTime = Math.round((this.frameTimeSum / this.frameTimeCount) * 100) / 100
3985
+ // frameIntervalMs is the true vsync-to-vsync frame interval (render dt), NOT the CPU
3986
+ // time spent in render() the latter misses GPU cost and pacing, so it can read fast
3987
+ // while the scene stutters. Metrics are derived from a ring buffer of these intervals.
3988
+ private updateStats(frameIntervalMs: number) {
3989
+ const w = Engine.STATS_WINDOW
3990
+ this.frameIntervals[this.frameIntervalWrite] = frameIntervalMs
3991
+ this.frameIntervalWrite = (this.frameIntervalWrite + 1) % w
3992
+ if (this.frameIntervalFilled < w) this.frameIntervalFilled++
3711
3993
 
3712
- // FPS tracking
3713
3994
  const now = performance.now()
3714
- this.framesSinceLastUpdate++
3715
- const elapsed = now - this.lastFpsUpdate
3995
+ if (now - this.lastStatsCompute < Engine.STATS_REFRESH_MS) return
3996
+ this.lastStatsCompute = now
3997
+
3998
+ const n = this.frameIntervalFilled
3999
+ if (n === 0) return
3716
4000
 
3717
- if (elapsed >= 1000) {
3718
- this.stats.fps = Math.round((this.framesSinceLastUpdate / elapsed) * 1000)
3719
- this.framesSinceLastUpdate = 0
3720
- this.lastFpsUpdate = now
4001
+ let sum = 0
4002
+ let max = 0
4003
+ for (let i = 0; i < n; i++) {
4004
+ const v = this.frameIntervals[i]
4005
+ sum += v
4006
+ if (v > max) max = v
3721
4007
  }
4008
+ const mean = sum / n
4009
+
4010
+ let varSum = 0
4011
+ for (let i = 0; i < n; i++) {
4012
+ const d = this.frameIntervals[i] - mean
4013
+ varSum += d * d
4014
+ }
4015
+ const stddev = Math.sqrt(varSum / n)
4016
+
4017
+ // 99th-percentile frame interval → "1% low" fps. TypedArray.sort is numeric.
4018
+ const sorted = this.frameIntervals.slice(0, n).sort()
4019
+ const p99 = sorted[Math.min(n - 1, Math.floor(n * 0.99))]
4020
+
4021
+ // fps from the MEAN interval is inherently bounded by the real refresh (a vsync-locked
4022
+ // interval can't average below the refresh period), so this never reads above the
4023
+ // monitor rate — fixing the old frame-count/window off-by-one (61 on 60Hz, 241 on 240Hz).
4024
+ this.stats.fps = mean > 0 ? Math.round(1000 / mean) : 0
4025
+ this.stats.frameTime = Math.round(mean * 100) / 100
4026
+ this.stats.frameTimeMax = Math.round(max * 100) / 100
4027
+ this.stats.fps1PercentLow = p99 > 0 ? Math.round(1000 / p99) : 0
4028
+ this.stats.jitter = Math.round(stddev * 100) / 100
3722
4029
  }
3723
4030
  }
package/src/ik-solver.ts CHANGED
@@ -86,6 +86,12 @@ const _ikMat: Float32Array[] = [
86
86
  new Float32Array(16), new Float32Array(16), new Float32Array(16), new Float32Array(16),
87
87
  ]
88
88
 
89
+ // An IKChain is derived purely from static link topology (bone index + angle limits +
90
+ // solve axis), which never changes at runtime. Build the chain array once per solver and
91
+ // reuse it — avoids reallocating the array and re-running the per-link limit/axis setup
92
+ // (IKChain ctor) on every solve() every frame.
93
+ const _chainCache = new WeakMap<IKSolver, IKChain[]>()
94
+
89
95
  export class IKSolverSystem {
90
96
  private static readonly EPSILON = 1.0e-8
91
97
  private static readonly THRESHOLD = (88 * Math.PI) / 180
@@ -118,20 +124,21 @@ export class IKSolverSystem {
118
124
  const ikBoneIndex = solver.ikBoneIndex
119
125
  const targetBoneIndex = solver.targetBoneIndex
120
126
 
121
- // Reset IK rotations
127
+ // Reset IK rotations (in place — same object is accumulated into during solve)
122
128
  for (const link of solver.links) {
123
129
  const chainInfo = ikChainInfo[link.boneIndex]
124
130
  if (chainInfo) {
125
- chainInfo.ikRotation = new Quat(0, 0, 0, 1)
131
+ chainInfo.ikRotation.setXYZW(0, 0, 0, 1)
126
132
  }
127
133
  }
128
134
 
129
135
  if (this.getDistance(ikBoneIndex, targetBoneIndex, worldMatrices) < this.EPSILON) return
130
136
 
131
- // Build IK chains
132
- const chains: IKChain[] = []
133
- for (const link of solver.links) {
134
- chains.push(new IKChain(link.boneIndex, link))
137
+ // Build IK chains (cached per solver — static topology, see _chainCache)
138
+ let chains = _chainCache.get(solver)
139
+ if (chains === undefined) {
140
+ chains = solver.links.map((link) => new IKChain(link.boneIndex, link))
141
+ _chainCache.set(solver, chains)
135
142
  }
136
143
 
137
144
  // Update chain bones and target bone world matrices (initial state, no IK yet)
@@ -278,7 +285,7 @@ export class IKSolverSystem {
278
285
 
279
286
  if (chain.minimumAngle && chain.maximumAngle) {
280
287
  const localRot = localRotations[chainBoneIndex]
281
- chainInfo.localRotation = localRot.clone()
288
+ chainInfo.localRotation.set(localRot)
282
289
 
283
290
  // combinedRot = chainInfo.ikRotation * localRot
284
291
  Quat.multiplyInto(chainInfo.ikRotation, localRot, _ikQuat[1])
package/src/math.ts CHANGED
@@ -348,64 +348,54 @@ export class Mat4 {
348
348
 
349
349
  // Perspective matrix for LEFT-HANDED coordinate system (Z+ forward)
350
350
  // For left-handed: Z goes from 0 (near) to 1 (far), +Z is forward
351
- static perspective(fov: number, aspect: number, near: number, far: number): Mat4 {
351
+ // Zero-alloc perspective into an existing column-major array (same values as perspective).
352
+ static perspectiveInto(out: Float32Array, fov: number, aspect: number, near: number, far: number): void {
352
353
  const f = 1.0 / Math.tan(fov / 2)
353
354
  const rangeInv = 1.0 / (far - near) // Positive for left-handed
355
+ out[0] = f / aspect; out[1] = 0; out[2] = 0; out[3] = 0
356
+ out[4] = 0; out[5] = f; out[6] = 0; out[7] = 0
357
+ out[8] = 0; out[9] = 0; out[10] = (far + near) * rangeInv; out[11] = 1 // Z+ forward (LH)
358
+ out[12] = 0; out[13] = 0; out[14] = -near * far * rangeInv * 2; out[15] = 0
359
+ }
354
360
 
355
- return new Mat4(
356
- new Float32Array([
357
- f / aspect,
358
- 0,
359
- 0,
360
- 0,
361
- 0,
362
- f,
363
- 0,
364
- 0,
365
- 0,
366
- 0,
367
- (far + near) * rangeInv,
368
- 1, // Positive for left-handed (Z+ forward)
369
- 0,
370
- 0,
371
- -near * far * rangeInv * 2, // Negated for left-handed
372
- 0,
373
- ])
374
- )
361
+ static perspective(fov: number, aspect: number, near: number, far: number): Mat4 {
362
+ const m = new Mat4(new Float32Array(16))
363
+ Mat4.perspectiveInto(m.values, fov, aspect, near, far)
364
+ return m
365
+ }
366
+
367
+ // Zero-alloc LH lookAt into an existing column-major array (scalar math, no Vec3 temps;
368
+ // identical result to lookAt: forward=norm(target-eye), right=norm(up×forward), up=norm(forward×right)).
369
+ static lookAtInto(
370
+ out: Float32Array,
371
+ ex: number, ey: number, ez: number,
372
+ tx: number, ty: number, tz: number,
373
+ ux: number, uy: number, uz: number
374
+ ): void {
375
+ // sqrt(dot) (not hypot) to match Vec3.length()/normalize() bit-for-bit.
376
+ let fx = tx - ex, fy = ty - ey, fz = tz - ez
377
+ const fl = Math.sqrt(fx * fx + fy * fy + fz * fz); const fi = fl > 0 ? 1 / fl : 0
378
+ fx *= fi; fy *= fi; fz *= fi
379
+ let rx = uy * fz - uz * fy, ry = uz * fx - ux * fz, rz = ux * fy - uy * fx
380
+ const rl = Math.sqrt(rx * rx + ry * ry + rz * rz); const ri = rl > 0 ? 1 / rl : 0
381
+ rx *= ri; ry *= ri; rz *= ri
382
+ let vx = fy * rz - fz * ry, vy = fz * rx - fx * rz, vz = fx * ry - fy * rx
383
+ const vl = Math.sqrt(vx * vx + vy * vy + vz * vz); const vi = vl > 0 ? 1 / vl : 0
384
+ vx *= vi; vy *= vi; vz *= vi
385
+ out[0] = rx; out[1] = vx; out[2] = fx; out[3] = 0
386
+ out[4] = ry; out[5] = vy; out[6] = fy; out[7] = 0
387
+ out[8] = rz; out[9] = vz; out[10] = fz; out[11] = 0
388
+ out[12] = -(rx * ex + ry * ey + rz * ez)
389
+ out[13] = -(vx * ex + vy * ey + vz * ez)
390
+ out[14] = -(fx * ex + fy * ey + fz * ez)
391
+ out[15] = 1
375
392
  }
376
393
 
377
394
  // LookAt matrix for LEFT-HANDED coordinate system (Z+ forward)
378
- // For left-handed: camera looks along +Z direction
379
395
  static lookAt(eye: Vec3, target: Vec3, up: Vec3): Mat4 {
380
- // In left-handed: forward = target - eye (Z+ direction)
381
- // These operations create new Vec3 objects, so normalize() mutates those new objects
382
- const forward = target.subtract(eye)
383
- forward.normalize()
384
- const right = up.cross(forward)
385
- right.normalize() // X+ is right
386
- const upVec = forward.cross(right)
387
- upVec.normalize() // Y+ is up
388
-
389
- return new Mat4(
390
- new Float32Array([
391
- right.x,
392
- upVec.x,
393
- forward.x,
394
- 0,
395
- right.y,
396
- upVec.y,
397
- forward.y,
398
- 0,
399
- right.z,
400
- upVec.z,
401
- forward.z,
402
- 0,
403
- -right.dot(eye),
404
- -upVec.dot(eye),
405
- -forward.dot(eye),
406
- 1,
407
- ])
408
- )
396
+ const m = new Mat4(new Float32Array(16))
397
+ Mat4.lookAtInto(m.values, eye.x, eye.y, eye.z, target.x, target.y, target.z, up.x, up.y, up.z)
398
+ return m
409
399
  }
410
400
 
411
401
  // LH ortho, NDC depth 0=near 1=far