reze-engine 0.41.4 → 0.42.1
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/README.md +26 -1
- package/dist/engine.d.ts +48 -1
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +254 -7
- package/dist/model.d.ts +9 -0
- package/dist/model.d.ts.map +1 -1
- package/dist/model.js +18 -0
- package/dist/physics/physics.d.ts +22 -2
- package/dist/physics/physics.d.ts.map +1 -1
- package/dist/physics/physics.js +24 -3
- package/dist/shaders/passes/composite.d.ts +46 -1
- package/dist/shaders/passes/composite.d.ts.map +1 -1
- package/dist/shaders/passes/composite.js +243 -13
- package/package.json +1 -1
- package/src/engine.ts +272 -7
- package/src/model.ts +17 -0
- package/src/physics/physics.ts +27 -3
- package/src/shaders/passes/composite.ts +221 -14
package/src/engine.ts
CHANGED
|
@@ -34,7 +34,14 @@ import {
|
|
|
34
34
|
BLOOM_UPSAMPLE_SHADER_WGSL,
|
|
35
35
|
} from "./shaders/passes/bloom"
|
|
36
36
|
import { AGX_LUT_GZ, AGX_LUT_SIZE } from "./shaders/agx-lut"
|
|
37
|
-
import {
|
|
37
|
+
import {
|
|
38
|
+
buildCompositeShader,
|
|
39
|
+
parseEffectAnchors,
|
|
40
|
+
EFFECT_ANCHORS,
|
|
41
|
+
EFFECT_SUBJECTS,
|
|
42
|
+
EFFECT_TRAIL_BASE,
|
|
43
|
+
EFFECT_TRAIL_SAMPLES,
|
|
44
|
+
} from "./shaders/passes/composite"
|
|
38
45
|
import { PICK_SHADER_WGSL } from "./shaders/passes/pick"
|
|
39
46
|
import { MIPMAP_BLIT_SHADER_WGSL } from "./shaders/passes/mipmap"
|
|
40
47
|
import { compileGraph, type CompileOptions, type StyleSlot } from "./graph/compile"
|
|
@@ -234,14 +241,43 @@ export type WorldOptions = {
|
|
|
234
241
|
|
|
235
242
|
/** A model's scene placement — root offset baked into skinning + visibility. Serializable
|
|
236
243
|
* into a scene descriptor via getModelTransform. */
|
|
237
|
-
/** How many character positions an effect can read (viewU[11..14]).
|
|
238
|
-
|
|
244
|
+
/** How many character positions an effect can read (viewU[11..14]). Defined
|
|
245
|
+
* beside the shader that reads them — the layout arithmetic has to agree. */
|
|
246
|
+
const MAX_EFFECT_SUBJECTS = EFFECT_SUBJECTS
|
|
239
247
|
/** Where a character IS, for an effect that follows them. センター carries a
|
|
240
248
|
* motion's root movement — walking, jumping — where the model transform only
|
|
241
249
|
* carries where the model was placed; 全ての親 is the fallback for a model that
|
|
242
250
|
* animates the true root instead. */
|
|
243
251
|
const SUBJECT_BONES = ["センター", "全ての親"]
|
|
244
252
|
|
|
253
|
+
/** How many bones one effect may name. Eight is already a lot for one file, and
|
|
254
|
+
* this is a MINIMUM: raising it breaks nothing, because effects read through
|
|
255
|
+
* rzAnchor() rather than indexing the buffer. Lowering it would. */
|
|
256
|
+
const MAX_EFFECT_ANCHORS = EFFECT_ANCHORS
|
|
257
|
+
|
|
258
|
+
/** Only for the bounding sphere's height. */
|
|
259
|
+
const HEAD_BONE = "頭"
|
|
260
|
+
|
|
261
|
+
/** Path samples kept per trailed anchor. ~2.1s at the sampling rate below, which
|
|
262
|
+
* is a long ribbon — a dancer's arm draws most of a circle in that time.
|
|
263
|
+
*
|
|
264
|
+
* A MINIMUM, like every cap here, and raising it is why that matters: effects
|
|
265
|
+
* read through rzTrail and loop to rzTrailCount, so this went 64 → 128 without
|
|
266
|
+
* touching a single published effect. Lowering it is the direction that breaks. */
|
|
267
|
+
const TRAIL_SAMPLES = EFFECT_TRAIL_SAMPLES
|
|
268
|
+
/** Sampled on the SCENE clock at a fixed rate, so a path is identical in the
|
|
269
|
+
* editor, in an export and in a re-export, and its spacing does not change with
|
|
270
|
+
* the display's refresh. */
|
|
271
|
+
const TRAIL_HZ = 60
|
|
272
|
+
const TRAIL_DT = 1 / TRAIL_HZ
|
|
273
|
+
|
|
274
|
+
/** vec4 slots: four subjects × 3, then anchors × four subjects × 3, then the
|
|
275
|
+
* trails — slot-major, four subjects each, TRAIL_SAMPLES apiece. */
|
|
276
|
+
const CAST_SUBJECT_VEC4S = MAX_EFFECT_SUBJECTS * 3
|
|
277
|
+
const CAST_ANCHOR_VEC4S = MAX_EFFECT_ANCHORS * MAX_EFFECT_SUBJECTS * 3
|
|
278
|
+
const CAST_TRAIL_BASE = EFFECT_TRAIL_BASE
|
|
279
|
+
const CAST_VEC4S = CAST_TRAIL_BASE + MAX_EFFECT_ANCHORS * MAX_EFFECT_SUBJECTS * TRAIL_SAMPLES
|
|
280
|
+
|
|
245
281
|
export type ModelTransform = {
|
|
246
282
|
position: Vec3
|
|
247
283
|
rotation: Quat
|
|
@@ -840,14 +876,38 @@ export class Engine {
|
|
|
840
876
|
/** Mounted over the finished frame — and the reason the scene pass has to
|
|
841
877
|
* STORE its depth, which it otherwise discards into tile memory. */
|
|
842
878
|
hasForeground: boolean
|
|
879
|
+
/** Bones the source asked for, in declaration order — rzAnchor's slots. Only
|
|
880
|
+
* these are resolved and uploaded, so a file that names none costs nothing. */
|
|
881
|
+
anchors: { bone: string; trail: boolean }[]
|
|
843
882
|
} | null = null
|
|
883
|
+
/** The cast, as the effect API sees it. Written per frame while an effect is
|
|
884
|
+
* installed, and only up to what that effect actually declared. */
|
|
885
|
+
private castBuffer!: GPUBuffer
|
|
886
|
+
private castData!: Float32Array<ArrayBuffer>
|
|
887
|
+
/** Last frame's anchor world positions, for velocity. Keyed model id → slot. */
|
|
888
|
+
private anchorPrev = new Map<string, Float32Array>()
|
|
889
|
+
private castLastMs = 0
|
|
890
|
+
/** Recent path per trailed anchor, keyed "model\0slot". Newest first, so the
|
|
891
|
+
* shader's index 0 is now — written by unshifting rather than by tracking a
|
|
892
|
+
* head, because 64 is short and the alternative is an index the GPU side
|
|
893
|
+
* would also have to know about. */
|
|
894
|
+
private anchorTrail = new Map<string, { pos: number[]; t: number[] }>()
|
|
895
|
+
/** Scene seconds, advanced by the frame delta — NOT wall time, so an offline
|
|
896
|
+
* export samples the same path the editor showed. */
|
|
897
|
+
private sceneClock = 0
|
|
898
|
+
private trailAccum = 0
|
|
899
|
+
/** Trail samples owed this frame, computed once so every trail on every
|
|
900
|
+
* character samples in lockstep and their paths stay comparable. */
|
|
901
|
+
private trailDue = 0
|
|
844
902
|
private agxLutTexture: GPUTexture | null = null
|
|
845
903
|
private agxFallbackTexture!: GPUTexture
|
|
846
904
|
/** Bound at composite binding 7 when no effect (or a param-less one) is set. */
|
|
847
905
|
private bgParamsDummyBuffer!: GPUBuffer
|
|
848
906
|
private compositePipelineLayout!: GPUPipelineLayout
|
|
849
907
|
/** time=0 origin for the active effect — reset each setEffect. */
|
|
850
|
-
|
|
908
|
+
/** Scene-clock reading when the current effect was installed. The effect's
|
|
909
|
+
* `time` is measured from here — see where it is written. */
|
|
910
|
+
private effectEpochScene = 0
|
|
851
911
|
private compositeBloomView: GPUTextureView | null = null
|
|
852
912
|
|
|
853
913
|
// EEVEE-style bloom pyramid (mirrors Blender 3.6 effect_bloom_frag.glsl):
|
|
@@ -1191,6 +1251,7 @@ export class Engine {
|
|
|
1191
1251
|
|
|
1192
1252
|
private rebuildCompositeBindGroup(): void {
|
|
1193
1253
|
if (!this.device || !this.hdrResolveTexture || !this.compositeBloomView || !this.depthReadView) return
|
|
1254
|
+
if (!this.castBuffer) return
|
|
1194
1255
|
this.compositeBindGroup = this.device.createBindGroup({
|
|
1195
1256
|
label: "composite bind group",
|
|
1196
1257
|
layout: this.compositeBindGroupLayout,
|
|
@@ -1206,6 +1267,7 @@ export class Engine {
|
|
|
1206
1267
|
{ binding: 8, resource: this.depthReadView },
|
|
1207
1268
|
{ binding: 9, resource: { buffer: this.dofUniformBuffer } },
|
|
1208
1269
|
{ binding: 10, resource: (this.agxLutTexture ?? this.agxFallbackTexture).createView({ dimension: "3d" }) },
|
|
1270
|
+
{ binding: 11, resource: { buffer: this.castBuffer } },
|
|
1209
1271
|
],
|
|
1210
1272
|
})
|
|
1211
1273
|
}
|
|
@@ -1337,6 +1399,14 @@ export class Engine {
|
|
|
1337
1399
|
}
|
|
1338
1400
|
const mounts = { background: hasBackground, foreground: hasForeground }
|
|
1339
1401
|
|
|
1402
|
+
// ── Which bones did the author ask for? Same idea as the mounts above: a
|
|
1403
|
+
// declaration in the source, not a setting somewhere else. Only what is
|
|
1404
|
+
// named here gets resolved and uploaded, so naming none costs nothing and
|
|
1405
|
+
// naming eight costs eight — rather than every rig's 500 bones costing
|
|
1406
|
+
// everybody. Past the cap the extras are dropped rather than silently
|
|
1407
|
+
// shifting every slot after them.
|
|
1408
|
+
const anchors = parseEffectAnchors(wgsl, MAX_EFFECT_ANCHORS)
|
|
1409
|
+
|
|
1340
1410
|
// ── Params: codegen a WGSL struct and mirror its uniform layout on the CPU.
|
|
1341
1411
|
// Fields are emitted in declaration order; offsets follow WGSL's natural
|
|
1342
1412
|
// uniform rules (f32 align 4, vec3f align 16 size 12), computed identically
|
|
@@ -1418,10 +1488,16 @@ export class Engine {
|
|
|
1418
1488
|
})
|
|
1419
1489
|
this.device.queue.writeBuffer(paramsBuffer, 0, paramsData)
|
|
1420
1490
|
}
|
|
1421
|
-
this.effect = { wgsl, paramLayout: layout, paramsBuffer, paramsData, hasBackground, hasForeground }
|
|
1491
|
+
this.effect = { wgsl, paramLayout: layout, paramsBuffer, paramsData, hasBackground, hasForeground, anchors }
|
|
1492
|
+
// Velocities and paths restart from rest rather than continuing from
|
|
1493
|
+
// whatever the last effect's slot 0 happened to be — the slots mean
|
|
1494
|
+
// something different now, and a trail would otherwise draw a line from the
|
|
1495
|
+
// old bone to the new one across the whole scene.
|
|
1496
|
+
this.anchorPrev.clear()
|
|
1497
|
+
this.anchorTrail.clear()
|
|
1422
1498
|
this.compositePipelineIdentity = identity
|
|
1423
1499
|
this.compositePipelineGamma = gamma
|
|
1424
|
-
this.
|
|
1500
|
+
this.effectEpochScene = this.sceneClock
|
|
1425
1501
|
this.rebuildCompositeBindGroup()
|
|
1426
1502
|
this.writeCompositeViewUniforms()
|
|
1427
1503
|
return { ok: true, diagnostics: [], mounts }
|
|
@@ -2457,6 +2533,15 @@ export class Engine {
|
|
|
2457
2533
|
size: 16,
|
|
2458
2534
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
2459
2535
|
})
|
|
2536
|
+
// Allocated at full size once rather than grown: it is ~7KB, the bind group
|
|
2537
|
+
// would otherwise be rebuilt whenever an effect declared a different number
|
|
2538
|
+
// of bones, and only the declared prefix is ever written.
|
|
2539
|
+
this.castData = new Float32Array(CAST_VEC4S * 4)
|
|
2540
|
+
this.castBuffer = this.device.createBuffer({
|
|
2541
|
+
label: "effect cast data",
|
|
2542
|
+
size: this.castData.byteLength,
|
|
2543
|
+
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
|
2544
|
+
})
|
|
2460
2545
|
this.compositeBindGroupLayout = this.device.createBindGroupLayout({
|
|
2461
2546
|
label: "composite bind group layout",
|
|
2462
2547
|
entries: [
|
|
@@ -2486,6 +2571,9 @@ export class Engine {
|
|
|
2486
2571
|
// AgX's 57³ cube. Decompressed and uploaded off the critical path, so a
|
|
2487
2572
|
// 1×1×1 stand-in keeps the bind group valid until it arrives.
|
|
2488
2573
|
{ binding: 10, visibility: GPUShaderStage.FRAGMENT, texture: { viewDimension: "3d" } },
|
|
2574
|
+
// The cast, for rzSubject/rzAnchor. Always bound so the base shader's
|
|
2575
|
+
// layout matches; the base shader simply never reads it.
|
|
2576
|
+
{ binding: 11, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "read-only-storage" } },
|
|
2489
2577
|
],
|
|
2490
2578
|
})
|
|
2491
2579
|
this.fallbackEquirectTexture = this.device.createTexture({
|
|
@@ -5393,6 +5481,13 @@ export class Engine {
|
|
|
5393
5481
|
}
|
|
5394
5482
|
|
|
5395
5483
|
private renderWithDelta(deltaTime: number) {
|
|
5484
|
+
// The scene clock, and the only clock a trail may sample on: renderFrame()
|
|
5485
|
+
// drives offline export with an exact per-frame delta, so a path recorded
|
|
5486
|
+
// against this is reproducible where one recorded against wall time is not.
|
|
5487
|
+
this.sceneClock += deltaTime
|
|
5488
|
+
this.trailAccum += deltaTime
|
|
5489
|
+
this.trailDue = Math.floor(this.trailAccum / TRAIL_DT)
|
|
5490
|
+
this.trailAccum -= this.trailDue * TRAIL_DT
|
|
5396
5491
|
const tFrame = performance.now()
|
|
5397
5492
|
this.frameAnimMsRaw = 0
|
|
5398
5493
|
this.framePhysicsMsRaw = 0
|
|
@@ -6178,7 +6273,16 @@ export class Engine {
|
|
|
6178
6273
|
u[22] = v[10]
|
|
6179
6274
|
u[23] = 0
|
|
6180
6275
|
// Effect clock + canvas size (viewU[6]) — written on the same refresh.
|
|
6181
|
-
|
|
6276
|
+
// The effect clock, on the SCENE's time rather than the wall's.
|
|
6277
|
+
//
|
|
6278
|
+
// renderFrame() drives offline export as fast as the encoder will take
|
|
6279
|
+
// frames, so wall time races ahead of the video's own time — a rain effect
|
|
6280
|
+
// fell at the wrong rate in the export, a twinkle blinked at the wrong
|
|
6281
|
+
// speed, and none of it matched what the editor had shown. Measured
|
|
6282
|
+
// against the accumulated frame delta, an effect animates identically in
|
|
6283
|
+
// the editor, in an export, and in a re-export, which is the same rule the
|
|
6284
|
+
// trails already followed.
|
|
6285
|
+
u[24] = this.sceneClock - this.effectEpochScene
|
|
6182
6286
|
u[26] = this.canvas.width
|
|
6183
6287
|
u[27] = this.canvas.height
|
|
6184
6288
|
// Camera world position (viewU[10]) — the other half of bgWorldPos. It
|
|
@@ -6218,11 +6322,172 @@ export class Engine {
|
|
|
6218
6322
|
u[44 + n * 4] = px
|
|
6219
6323
|
u[45 + n * 4] = py
|
|
6220
6324
|
u[46 + n * 4] = pz
|
|
6325
|
+
this.writeCastEntry(inst, n, px, py, pz)
|
|
6221
6326
|
n++
|
|
6222
6327
|
})
|
|
6223
6328
|
u[43] = n
|
|
6224
6329
|
this.device.queue.writeBuffer(this.compositeUniformBuffer, 0, u)
|
|
6330
|
+
// Only what an effect declared, and only while one is installed. A scene
|
|
6331
|
+
// with no effect writes nothing here at all.
|
|
6332
|
+
if (this.effect) {
|
|
6333
|
+
// Up to the last trailed slot, not the whole buffer: an effect with no
|
|
6334
|
+
// trails never uploads the 32KB it would otherwise pay for every frame.
|
|
6335
|
+
let lastTrail = -1
|
|
6336
|
+
for (let i = 0; i < this.effect.anchors.length; i++) if (this.effect.anchors[i].trail) lastTrail = i
|
|
6337
|
+
const used =
|
|
6338
|
+
lastTrail >= 0
|
|
6339
|
+
? CAST_TRAIL_BASE + (lastTrail * MAX_EFFECT_SUBJECTS + MAX_EFFECT_SUBJECTS) * TRAIL_SAMPLES
|
|
6340
|
+
: CAST_SUBJECT_VEC4S + this.effect.anchors.length * MAX_EFFECT_SUBJECTS * 3
|
|
6341
|
+
this.device.queue.writeBuffer(this.castBuffer, 0, this.castData, 0, used * 4)
|
|
6342
|
+
this.castLastMs = performance.now()
|
|
6343
|
+
}
|
|
6344
|
+
}
|
|
6345
|
+
}
|
|
6346
|
+
|
|
6347
|
+
/**
|
|
6348
|
+
* One character's slice of the effect API's view of the cast.
|
|
6349
|
+
*
|
|
6350
|
+
* `px/py/pz` is the hip point the caller just composed — passed in rather than
|
|
6351
|
+
* recomputed, since it is the same two bone lookups.
|
|
6352
|
+
*
|
|
6353
|
+
* Bone positions are model-space, so each is scaled, rotated and translated by
|
|
6354
|
+
* the model transform exactly as the hip point above was. Getting that wrong
|
|
6355
|
+
* does not look wrong on a model standing at the origin, which is precisely
|
|
6356
|
+
* how it would ship.
|
|
6357
|
+
*/
|
|
6358
|
+
private writeCastEntry(inst: ModelInstance, n: number, px: number, py: number, pz: number): void {
|
|
6359
|
+
const effect = this.effect
|
|
6360
|
+
if (!effect) return
|
|
6361
|
+
const m = inst.model
|
|
6362
|
+
const cd = this.castData
|
|
6363
|
+
const toWorld = (v: Vec3): Vec3 => {
|
|
6364
|
+
v.setXYZ(v.x * m.scale, v.y * m.scale, v.z * m.scale)
|
|
6365
|
+
Quat.rotateVecInto(m.rotation, v, v)
|
|
6366
|
+
v.setXYZ(v.x + m.position.x, v.y + m.position.y, v.z + m.position.z)
|
|
6367
|
+
return v
|
|
6368
|
+
}
|
|
6369
|
+
|
|
6370
|
+
// The floor under this character: where the model was PLACED.
|
|
6371
|
+
//
|
|
6372
|
+
// A foot bone was the obvious answer and the wrong one. 足IK sits at the
|
|
6373
|
+
// ANKLE, not on the sole — an ankle above the ground even standing still,
|
|
6374
|
+
// and further still in heels — so a floor derived from it lands a hand's
|
|
6375
|
+
// width up the leg, which is exactly where the first version of Footfalls
|
|
6376
|
+
// drew its marks. A PMX's origin is between the feet on the floor by
|
|
6377
|
+
// convention, and placing a character on a stage moves that origin with
|
|
6378
|
+
// them, so the placement already answers "what is the ground here".
|
|
6379
|
+
//
|
|
6380
|
+
// Deliberately NOT the animated height: a jump lifts the character, not the
|
|
6381
|
+
// floor, and a floor that follows a jump is not a floor.
|
|
6382
|
+
const floorY = m.position.y
|
|
6383
|
+
// Generous on purpose: this is for culling, and a sphere that is too small
|
|
6384
|
+
// clips the effect it was meant to bound. Height is hip-to-head doubled;
|
|
6385
|
+
// arm span is about height on a human, so half of it is the radius, and the
|
|
6386
|
+
// rest is margin for a motion that reaches.
|
|
6387
|
+
const head = m.getBoneWorldPosition(HEAD_BONE)
|
|
6388
|
+
const height = head ? Math.max(0.01, toWorld(head).y - floorY) : Math.max(0.01, (py - floorY) * 2)
|
|
6389
|
+
const b = n * 12
|
|
6390
|
+
cd[b] = px
|
|
6391
|
+
cd[b + 1] = floorY
|
|
6392
|
+
cd[b + 2] = pz
|
|
6393
|
+
cd[b + 3] = 1
|
|
6394
|
+
cd[b + 4] = px
|
|
6395
|
+
cd[b + 5] = py
|
|
6396
|
+
cd[b + 6] = pz
|
|
6397
|
+
cd[b + 8] = px
|
|
6398
|
+
cd[b + 9] = floorY + height * 0.5
|
|
6399
|
+
cd[b + 10] = pz
|
|
6400
|
+
cd[b + 11] = height * 0.75
|
|
6401
|
+
|
|
6402
|
+
// Declared bones. Velocity is per model AND per slot, so two characters
|
|
6403
|
+
// wearing the same effect never inherit each other's motion.
|
|
6404
|
+
const anchors = effect.anchors
|
|
6405
|
+
if (anchors.length === 0) return
|
|
6406
|
+
let prev = this.anchorPrev.get(inst.name)
|
|
6407
|
+
const dtMs = Math.max(1, performance.now() - this.castLastMs)
|
|
6408
|
+
const invDt = 1000 / dtMs
|
|
6409
|
+
if (!prev || prev.length !== anchors.length * 3) {
|
|
6410
|
+
prev = new Float32Array(anchors.length * 3).fill(NaN)
|
|
6411
|
+
this.anchorPrev.set(inst.name, prev)
|
|
6412
|
+
}
|
|
6413
|
+
for (let s = 0; s < anchors.length; s++) {
|
|
6414
|
+
const a = CAST_SUBJECT_VEC4S * 4 + (s * MAX_EFFECT_SUBJECTS + n) * 12
|
|
6415
|
+
const pos = m.getBoneWorldPosition(anchors[s].bone)
|
|
6416
|
+
if (!pos) {
|
|
6417
|
+
cd[a + 3] = 0
|
|
6418
|
+
continue
|
|
6419
|
+
}
|
|
6420
|
+
toWorld(pos)
|
|
6421
|
+
const p = s * 3
|
|
6422
|
+
// NaN on the first frame a slot exists — a velocity out of nothing would
|
|
6423
|
+
// be a spike, and a trail or a spark reading it would fire on load.
|
|
6424
|
+
const vx = Number.isNaN(prev[p]) ? 0 : (pos.x - prev[p]) * invDt
|
|
6425
|
+
const vy = Number.isNaN(prev[p]) ? 0 : (pos.y - prev[p + 1]) * invDt
|
|
6426
|
+
const vz = Number.isNaN(prev[p]) ? 0 : (pos.z - prev[p + 2]) * invDt
|
|
6427
|
+
prev[p] = pos.x
|
|
6428
|
+
prev[p + 1] = pos.y
|
|
6429
|
+
prev[p + 2] = pos.z
|
|
6430
|
+
cd[a] = pos.x
|
|
6431
|
+
cd[a + 1] = pos.y
|
|
6432
|
+
cd[a + 2] = pos.z
|
|
6433
|
+
cd[a + 3] = 1
|
|
6434
|
+
cd[a + 4] = vx
|
|
6435
|
+
cd[a + 5] = vy
|
|
6436
|
+
cd[a + 6] = vz
|
|
6437
|
+
if (anchors[s].trail) this.writeTrail(inst.name, s, n, pos, cd, a)
|
|
6438
|
+
const fwd = m.getBoneWorldForward(anchors[s].bone)
|
|
6439
|
+
if (fwd) {
|
|
6440
|
+
Quat.rotateVecInto(m.rotation, fwd, fwd)
|
|
6441
|
+
cd[a + 8] = fwd.x
|
|
6442
|
+
cd[a + 9] = fwd.y
|
|
6443
|
+
cd[a + 10] = fwd.z
|
|
6444
|
+
}
|
|
6445
|
+
}
|
|
6446
|
+
}
|
|
6447
|
+
|
|
6448
|
+
/**
|
|
6449
|
+
* One trailed anchor's recent path, sampled on the scene clock and written
|
|
6450
|
+
* newest-first.
|
|
6451
|
+
*
|
|
6452
|
+
* Newest-first is what lets a ribbon be drawn by walking the index upward and
|
|
6453
|
+
* fading on age, and it means the shader never needs to know where the ring's
|
|
6454
|
+
* head is. Sixty-four entries is short enough that unshifting beats the
|
|
6455
|
+
* bookkeeping an actual ring buffer would push onto the GPU side too.
|
|
6456
|
+
*
|
|
6457
|
+
* Sampling is gated on TRAIL_DT of SCENE time, so a 120Hz display and a 30fps
|
|
6458
|
+
* export record the same path at the same spacing. A frame that covers several
|
|
6459
|
+
* intervals emits several samples rather than one, or a fast hand would tear.
|
|
6460
|
+
*/
|
|
6461
|
+
private writeTrail(model: string, slot: number, n: number, pos: Vec3, cd: Float32Array, anchorBase: number): void {
|
|
6462
|
+
const key = `${model}\u0000${slot}`
|
|
6463
|
+
let ring = this.anchorTrail.get(key)
|
|
6464
|
+
if (!ring) {
|
|
6465
|
+
ring = { pos: [], t: [] }
|
|
6466
|
+
this.anchorTrail.set(key, ring)
|
|
6467
|
+
}
|
|
6468
|
+
if (this.trailDue > 0 || ring.pos.length === 0) {
|
|
6469
|
+
const steps = Math.min(this.trailDue, 4)
|
|
6470
|
+
for (let k = 0; k < Math.max(1, steps); k++) {
|
|
6471
|
+
ring.pos.unshift(pos.x, pos.y, pos.z)
|
|
6472
|
+
ring.t.unshift(this.sceneClock)
|
|
6473
|
+
if (ring.t.length > TRAIL_SAMPLES) {
|
|
6474
|
+
ring.t.length = TRAIL_SAMPLES
|
|
6475
|
+
ring.pos.length = TRAIL_SAMPLES * 3
|
|
6476
|
+
}
|
|
6477
|
+
}
|
|
6225
6478
|
}
|
|
6479
|
+
const count = ring.t.length
|
|
6480
|
+
// Age rather than a timestamp: the shader would otherwise need the scene
|
|
6481
|
+
// clock too, and there is only one place that has to know what time it is.
|
|
6482
|
+
const base = (CAST_TRAIL_BASE + (slot * MAX_EFFECT_SUBJECTS + n) * TRAIL_SAMPLES) * 4
|
|
6483
|
+
for (let i = 0; i < count; i++) {
|
|
6484
|
+
cd[base + i * 4] = ring.pos[i * 3]
|
|
6485
|
+
cd[base + i * 4 + 1] = ring.pos[i * 3 + 1]
|
|
6486
|
+
cd[base + i * 4 + 2] = ring.pos[i * 3 + 2]
|
|
6487
|
+
cd[base + i * 4 + 3] = this.sceneClock - ring.t[i]
|
|
6488
|
+
}
|
|
6489
|
+
// The count rides in the anchor's spare lane, so rzTrailCount is one read.
|
|
6490
|
+
cd[anchorBase + 11] = count
|
|
6226
6491
|
}
|
|
6227
6492
|
|
|
6228
6493
|
private updateSkinMatrices() {
|
package/src/model.ts
CHANGED
|
@@ -968,6 +968,23 @@ export class Model {
|
|
|
968
968
|
return this.runtimeSkeleton.worldMatrices[idx].getPosition()
|
|
969
969
|
}
|
|
970
970
|
|
|
971
|
+
/**
|
|
972
|
+
* A bone's forward axis, normalised — which way a foot points, where a head
|
|
973
|
+
* looks. Model space, like getBoneWorldPosition: the caller composes the model
|
|
974
|
+
* transform on top if it wants world space.
|
|
975
|
+
*
|
|
976
|
+
* Column 2 of the world matrix. Null for a name this rig does not have, which
|
|
977
|
+
* is the ordinary case across rigs that spell bones differently.
|
|
978
|
+
*/
|
|
979
|
+
getBoneWorldForward(boneName: string): Vec3 | null {
|
|
980
|
+
const idx = this.runtimeSkeleton.nameIndex[boneName]
|
|
981
|
+
if (idx === undefined || idx < 0) return null
|
|
982
|
+
const m = this.runtimeSkeleton.worldMatrices[idx].values
|
|
983
|
+
const len = Math.hypot(m[8], m[9], m[10])
|
|
984
|
+
if (len < 1e-8) return null
|
|
985
|
+
return new Vec3(m[8] / len, m[9] / len, m[10] / len)
|
|
986
|
+
}
|
|
987
|
+
|
|
971
988
|
getSkinning(): Skinning {
|
|
972
989
|
return this.skinning
|
|
973
990
|
}
|
package/src/physics/physics.ts
CHANGED
|
@@ -28,8 +28,28 @@ export class RezePhysics {
|
|
|
28
28
|
private readonly maxSubSteps = 6
|
|
29
29
|
/** EMA of one world.step's CPU cost; drives catch-up load shedding. */
|
|
30
30
|
private stepCostEmaMs = 0.5
|
|
31
|
-
/**
|
|
32
|
-
|
|
31
|
+
/**
|
|
32
|
+
* Share of the frame a single frame may spend catching physics up before it
|
|
33
|
+
* sheds the backlog instead.
|
|
34
|
+
*
|
|
35
|
+
* A FRACTION, not a fixed millisecond count, and that distinction is the whole
|
|
36
|
+
* point. It used to be a flat 8ms — about half a frame at 60Hz, which is what
|
|
37
|
+
* it was tuned against. The moment anything else made frames longer (a heavy
|
|
38
|
+
* effect, a weak GPU) the frame grew and the budget did not, so at 30Hz
|
|
39
|
+
* physics was allowed a QUARTER of the frame while needing twice the
|
|
40
|
+
* substeps. It shed work it could easily afford, and shedding discards time —
|
|
41
|
+
* so physics ran fractionally slow-motion against a character moving at full
|
|
42
|
+
* speed, which is exactly what hair and skirts lagging behind the body looks
|
|
43
|
+
* like.
|
|
44
|
+
*
|
|
45
|
+
* At 60Hz this reproduces the old 8ms almost exactly, so nothing that was
|
|
46
|
+
* tuned against it moves.
|
|
47
|
+
*/
|
|
48
|
+
private readonly stepBudgetFraction = 0.5
|
|
49
|
+
/** Floor and ceiling on that share: a very short frame still gets enough to
|
|
50
|
+
* make progress, and a very long one must not spend all of itself here. */
|
|
51
|
+
private readonly stepBudgetMinMs = 8
|
|
52
|
+
private readonly stepBudgetMaxMs = 24
|
|
33
53
|
|
|
34
54
|
// Fixed-timestep render interpolation ("Fix Your Timestep"): the dynamic body pose is
|
|
35
55
|
// rendered as lerp(prev, curr, alpha) between the last two completed substeps, where
|
|
@@ -260,7 +280,11 @@ export class RezePhysics {
|
|
|
260
280
|
// state (6× step cost every frame; iOS "starts smooth, then stays slow").
|
|
261
281
|
// Catching up must never cost more than a frame-budget slice: shed the backlog
|
|
262
282
|
// instead (physics runs fractionally slow-motion for a beat; framerate holds).
|
|
263
|
-
const
|
|
283
|
+
const budgetMs = Math.min(
|
|
284
|
+
this.stepBudgetMaxMs,
|
|
285
|
+
Math.max(this.stepBudgetMinMs, dt * 1000 * this.stepBudgetFraction),
|
|
286
|
+
)
|
|
287
|
+
const affordable = Math.max(1, Math.floor(budgetMs / Math.max(0.05, this.stepCostEmaMs)))
|
|
264
288
|
if (nSub > affordable) nSub = affordable
|
|
265
289
|
for (let k = 0; k < nSub; k++) {
|
|
266
290
|
this.savePrevState()
|