reze-engine 0.15.1 → 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.
- package/dist/camera.d.ts +2 -0
- package/dist/camera.d.ts.map +1 -1
- package/dist/camera.js +8 -3
- package/dist/engine.d.ts +19 -4
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +294 -27
- package/dist/ik-solver.d.ts.map +1 -1
- package/dist/ik-solver.js +13 -7
- package/dist/math.d.ts +2 -0
- package/dist/math.d.ts.map +1 -1
- package/dist/math.js +65 -46
- package/dist/model.d.ts +28 -1
- package/dist/model.d.ts.map +1 -1
- package/dist/model.js +259 -75
- package/dist/physics/physics.d.ts +3 -0
- package/dist/physics/physics.d.ts.map +1 -1
- package/dist/physics/physics.js +55 -4
- package/dist/pmx-loader.d.ts.map +1 -1
- package/dist/pmx-loader.js +2 -30
- package/dist/shaders/materials/body.d.ts +1 -1
- package/dist/shaders/materials/body.d.ts.map +1 -1
- package/dist/shaders/materials/body.js +3 -1
- package/dist/shaders/materials/cloth_rough.d.ts +1 -1
- package/dist/shaders/materials/cloth_rough.d.ts.map +1 -1
- package/dist/shaders/materials/cloth_rough.js +3 -1
- package/dist/shaders/materials/cloth_smooth.d.ts +1 -1
- package/dist/shaders/materials/cloth_smooth.d.ts.map +1 -1
- package/dist/shaders/materials/common.d.ts +2 -2
- package/dist/shaders/materials/common.d.ts.map +1 -1
- package/dist/shaders/materials/common.js +8 -0
- package/dist/shaders/materials/default.d.ts +1 -1
- package/dist/shaders/materials/default.d.ts.map +1 -1
- package/dist/shaders/materials/eye.d.ts +1 -1
- package/dist/shaders/materials/eye.d.ts.map +1 -1
- package/dist/shaders/materials/face.d.ts +1 -1
- package/dist/shaders/materials/face.d.ts.map +1 -1
- package/dist/shaders/materials/face.js +4 -1
- package/dist/shaders/materials/hair.d.ts +1 -1
- package/dist/shaders/materials/hair.d.ts.map +1 -1
- package/dist/shaders/materials/metal.d.ts +1 -1
- package/dist/shaders/materials/metal.d.ts.map +1 -1
- package/dist/shaders/materials/stockings.d.ts +1 -1
- package/dist/shaders/materials/stockings.d.ts.map +1 -1
- package/dist/shaders/materials/stockings.js +13 -9
- package/dist/shaders/passes/composite.d.ts +1 -1
- package/dist/shaders/passes/composite.d.ts.map +1 -1
- package/dist/shaders/passes/composite.js +18 -12
- package/dist/shaders/passes/morph.d.ts +2 -0
- package/dist/shaders/passes/morph.d.ts.map +1 -0
- package/dist/shaders/passes/morph.js +49 -0
- package/package.json +1 -1
- package/src/camera.ts +9 -3
- package/src/engine.ts +336 -29
- package/src/ik-solver.ts +14 -7
- package/src/math.ts +41 -51
- package/src/model.ts +288 -91
- package/src/physics/physics.ts +56 -13
- package/src/pmx-loader.ts +2 -35
- package/src/shaders/materials/body.ts +3 -1
- package/src/shaders/materials/cloth_rough.ts +3 -1
- package/src/shaders/materials/common.ts +8 -0
- package/src/shaders/materials/face.ts +4 -1
- package/src/shaders/materials/stockings.ts +13 -9
- package/src/shaders/passes/composite.ts +18 -12
- package/src/shaders/passes/morph.ts +50 -0
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
|
-
|
|
465
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3700
|
-
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
|
|
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.
|
|
3715
|
-
|
|
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
|
-
|
|
3718
|
-
|
|
3719
|
-
|
|
3720
|
-
|
|
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
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
chains.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|