reze-engine 0.16.1 → 0.16.2

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.
@@ -19,12 +19,30 @@ import type { Contact, ContactPool } from "./contact"
19
19
 
20
20
  const BOUNCE_THRESHOLD = 2.0
21
21
 
22
+ // Ceilings on limit-correction velocity. In normal operation limit errors are
23
+ // tiny; a large error only appears after a discontinuity (teleport, stall,
24
+ // deep penetration), and feeding err·ERP/dt to the solver unclamped then
25
+ // injects explosion-scale impulses into the chain.
26
+ const MAX_LINEAR_CORRECTION_VEL = 120 // units/s
27
+ const MAX_ANGULAR_CORRECTION_VEL = 30 // rad/s
28
+
29
+ // Bullet's limit-motor softness defaults (0.7 translational, 0.5 rotational):
30
+ // scale each iteration's limit impulse so the stop engages progressively
31
+ // instead of as a hard velocity snap.
32
+ const LIMIT_SOFTNESS_LINEAR = 0.7
33
+ const LIMIT_SOFTNESS_ANGULAR = 0.5
34
+ // Angular limit violations below this switch to per-axis euler rows; above
35
+ // it, the single geodesic row takes over (see setupConstraint).
36
+ const GEODESIC_THRESHOLD = 0.5 // rad
37
+
22
38
  // Module-level scratch (no per-iter allocations).
23
39
  const _TA = new Float32Array(16)
24
40
  const _TB = new Float32Array(16)
25
41
  const _bodyMatA = new Float32Array(16)
26
42
  const _bodyMatB = new Float32Array(16)
27
43
  const _angDiffScratch = new Float32Array(3)
44
+ const _quatScratchA = new Float32Array(4)
45
+ const _quatScratchB = new Float32Array(4)
28
46
 
29
47
  export function solveConstraints(
30
48
  store: RigidBodyStore,
@@ -82,21 +100,23 @@ function setupConstraint(
82
100
  Mat4.multiplyArrays(_bodyMatA, 0, con.frameA, 0, _TA, 0)
83
101
  Mat4.multiplyArrays(_bodyMatB, 0, con.frameB, 0, _TB, 0)
84
102
 
85
- // Mass-weighted shared anchor (Bullet 2.75 m_AnchorPos).
103
+ // Per-body pivots at each body's own joint-frame origin (Spring2-style).
104
+ // Bullet 2.7x's shared mass-weighted anchor (m_AnchorPos) degenerates when
105
+ // the joint is violated by a large distance: the midpoint sits far from
106
+ // both bodies, the lever arms grow with the separation, the Jacobian
107
+ // denominator blows up as err²·invInertia, and the row applies torque
108
+ // instead of closing velocity — the joint "breaks" and the error runs
109
+ // away. Per-body pivots keep the levers bounded by the frame offsets, so
110
+ // the row stays effective no matter how large the violation is.
86
111
  const pos = store.positions
87
112
  const ai = a * 3
88
113
  const bi = b * 3
89
- const weightA = imB === 0 ? 1 : imA / (imA + imB)
90
- const weightB = 1 - weightA
91
- const anchorX = _TA[12] * weightA + _TB[12] * weightB
92
- const anchorY = _TA[13] * weightA + _TB[13] * weightB
93
- const anchorZ = _TA[14] * weightA + _TB[14] * weightB
94
- const rAx = anchorX - pos[ai + 0]
95
- const rAy = anchorY - pos[ai + 1]
96
- const rAz = anchorZ - pos[ai + 2]
97
- const rBx = anchorX - pos[bi + 0]
98
- const rBy = anchorY - pos[bi + 1]
99
- const rBz = anchorZ - pos[bi + 2]
114
+ const rAx = _TA[12] - pos[ai + 0]
115
+ const rAy = _TA[13] - pos[ai + 1]
116
+ const rAz = _TA[14] - pos[ai + 2]
117
+ const rBx = _TB[12] - pos[bi + 0]
118
+ const rBy = _TB[13] - pos[bi + 1]
119
+ const rBz = _TB[14] - pos[bi + 2]
100
120
  const lA = con.cacheLeverA
101
121
  const lB = con.cacheLeverB
102
122
  lA[0] = rAx; lA[1] = rAy; lA[2] = rAz
@@ -143,23 +163,37 @@ function setupConstraint(
143
163
  const lo = con.linearMin[i]
144
164
  const hi = con.linearMax[i]
145
165
  const curr = i === 0 ? linDiff0 : i === 1 ? linDiff1 : linDiff2
166
+ // active: 1 = bilateral equality (locked axis — a joint, always on),
167
+ // 2 = unilateral stop (ranged axis in violation).
146
168
  let target = 0
147
169
  let active = 0
148
170
  if (lo <= hi) {
149
171
  let err = 0
150
172
  if (curr < lo) err = curr - lo
151
173
  else if (curr > hi) err = curr - hi
174
+ if (lo === hi) active = 1
175
+ else if (err !== 0) active = 2
152
176
  if (err !== 0) {
153
177
  target = -err * STOP_ERP * invDt
154
- active = 1
178
+ if (target > MAX_LINEAR_CORRECTION_VEL) target = MAX_LINEAR_CORRECTION_VEL
179
+ else if (target < -MAX_LINEAR_CORRECTION_VEL) target = -MAX_LINEAR_CORRECTION_VEL
155
180
  }
156
181
  }
157
- if (con.springEnabled[i]) {
158
- target += -con.springStiffness[i] * (curr - con.equilibriumPoint[i]) * dt
159
- active = 1
160
- }
161
182
  tgt[i] = target
162
183
  act[i] = denom > 0 ? active : 0
184
+ con.cacheLinLimitImp[i] = 0
185
+ if (con.springEnabled[i] && denom > 0) {
186
+ // Clamp k to the deadbeat limit: an explicit spring with k·dt² > 1
187
+ // overshoots equilibrium every step and pumps energy — the classic
188
+ // pre-Spring2 Bullet 6dof instability this port inherited. (¼ margin
189
+ // for Gauss-Seidel coupling in chains.)
190
+ const k = Math.min(con.springStiffness[i], 0.25 * invDt * invDt)
191
+ const serr = curr - con.equilibriumPoint[i]
192
+ con.cacheLinSpringTarget[i] = -k * serr * dt
193
+ con.cacheLinSpringActive[i] = 1
194
+ } else {
195
+ con.cacheLinSpringActive[i] = 0
196
+ }
163
197
  }
164
198
 
165
199
  // Angular: TA^T · TB → Euler XYZ; axes from TA.col2 × TB.col0.
@@ -198,32 +232,91 @@ function setupConstraint(
198
232
  const angDenom = iiA + iiB
199
233
  con.cacheAngJacInv = angDenom > 0 ? 1 / angDenom : 0
200
234
 
235
+ // Per-axis rows carry only the springs. Sign flip vs linear:
236
+ // d(angDiff)/dt = −(ω_B − ω_A)·ax.
201
237
  const angTgt = con.cacheAngTargetVel
202
238
  const angAct = con.cacheAngActive
203
239
  for (let i = 0; i < 3; i++) {
204
240
  const idx = i + 3
205
- const lo = con.angularMin[i]
206
- const hi = con.angularMax[i]
207
- const curr = _angDiffScratch[i]
208
- let target = 0
209
- let active = 0
210
- if (lo <= hi) {
211
- let err = 0
212
- if (curr < lo) err = curr - lo
213
- else if (curr > hi) err = curr - hi
214
- // Sign flip vs linear: d(angDiff)/dt = −(ω_B − ω_A)·ax.
215
- if (err !== 0) {
216
- target = err * STOP_ERP * invDt
217
- active = 1
218
- }
241
+ if (con.springEnabled[idx] && angDenom > 0) {
242
+ // Same deadbeat clamp as the linear springs.
243
+ const k = Math.min(con.springStiffness[idx], 0.25 * invDt * invDt)
244
+ const serr = _angDiffScratch[i] - con.equilibriumPoint[idx]
245
+ angTgt[i] = k * serr * dt
246
+ angAct[i] = 1
247
+ } else {
248
+ angTgt[i] = 0
249
+ angAct[i] = 0
219
250
  }
220
- if (con.springEnabled[idx]) {
221
- target += con.springStiffness[idx] * (curr - con.equilibriumPoint[idx]) * dt
222
- active = 1
251
+ }
252
+
253
+ // Angular limit handling is hybrid. Small violations (the resting-cloth
254
+ // regime) use per-axis euler rows — they converge cleanly and keep resting
255
+ // cloth dead still. Large violations switch to a single geodesic row toward
256
+ // the euler-clamped target: per-axis euler rows (the Bullet-2.7x approach
257
+ // this port used) become geometrically inconsistent for large errors — near
258
+ // the asin singularity they chase phantom errors and pump angular velocity
259
+ // into the chain instead of converging.
260
+ con.cacheAngLimActive = 0
261
+ if (angDenom > 0) {
262
+ const ex = _angDiffScratch[0], ey = _angDiffScratch[1], ez = _angDiffScratch[2]
263
+ // Free axes (min > max) follow the current angle, i.e. no correction.
264
+ let tx = ex, ty = ey, tz = ez
265
+ if (con.angularMin[0] <= con.angularMax[0]) tx = ex < con.angularMin[0] ? con.angularMin[0] : ex > con.angularMax[0] ? con.angularMax[0] : ex
266
+ if (con.angularMin[1] <= con.angularMax[1]) ty = ey < con.angularMin[1] ? con.angularMin[1] : ey > con.angularMax[1] ? con.angularMax[1] : ey
267
+ if (con.angularMin[2] <= con.angularMax[2]) tz = ez < con.angularMin[2] ? con.angularMin[2] : ez > con.angularMax[2] ? con.angularMax[2] : ez
268
+ const errX = ex - tx, errY = ey - ty, errZ = ez - tz
269
+ const maxErr = Math.max(Math.abs(errX), Math.abs(errY), Math.abs(errZ))
270
+ if (maxErr > 0 && maxErr < GEODESIC_THRESHOLD) {
271
+ // Per-axis euler limit rows, folded into the per-axis row set (the
272
+ // spring impulse clamp must not bound a limit row — lift it).
273
+ for (let i = 0; i < 3; i++) {
274
+ const err = i === 0 ? errX : i === 1 ? errY : errZ
275
+ if (err === 0) continue
276
+ let target = err * STOP_ERP * invDt
277
+ if (target > MAX_ANGULAR_CORRECTION_VEL) target = MAX_ANGULAR_CORRECTION_VEL
278
+ else if (target < -MAX_ANGULAR_CORRECTION_VEL) target = -MAX_ANGULAR_CORRECTION_VEL
279
+ angTgt[i] += target
280
+ angAct[i] = 1
281
+ }
282
+ } else if (maxErr > 0) {
283
+ // Bilateral (equality) if any violated axis is locked — a locked axis
284
+ // is a joint, not a stop. Unilateral otherwise.
285
+ const bilateral =
286
+ (tx !== ex && con.angularMin[0] === con.angularMax[0]) ||
287
+ (ty !== ey && con.angularMin[1] === con.angularMax[1]) ||
288
+ (tz !== ez && con.angularMin[2] === con.angularMax[2])
289
+ // The decomposition above satisfies R_rel^T = Rx(x)·Ry(y)·Rz(z), so
290
+ // u = qx·qy·qz is conj(q_rel) and the error rotation (current →
291
+ // clamped target, expressed in TA's frame) is q_E = conj(u_t) ⊗ u.
292
+ eulerXYZQuatInto(ex, ey, ez, _quatScratchA)
293
+ eulerXYZQuatInto(tx, ty, tz, _quatScratchB)
294
+ const ux = _quatScratchA[0], uy = _quatScratchA[1], uz = _quatScratchA[2], uw = _quatScratchA[3]
295
+ const vx = _quatScratchB[0], vy = _quatScratchB[1], vz = _quatScratchB[2], vw = _quatScratchB[3]
296
+ // q_E = conj(v) ⊗ u
297
+ let qex = vw * ux - vx * uw - vy * uz + vz * uy
298
+ let qey = vw * uy + vx * uz - vy * uw - vz * ux
299
+ let qez = vw * uz - vx * uy + vy * ux - vz * uw
300
+ let qew = vw * uw + vx * ux + vy * uy + vz * uz
301
+ if (qew < 0) { qex = -qex; qey = -qey; qez = -qez; qew = -qew }
302
+ const sinHalf = Math.sqrt(qex * qex + qey * qey + qez * qez)
303
+ if (sinHalf > 1e-6) {
304
+ const angle = 2 * Math.atan2(sinHalf, qew)
305
+ const invS = 1 / sinHalf
306
+ const axx = qex * invS, axy = qey * invS, axz = qez * invS
307
+ // Axis lives in TA's frame; TA's basis columns map it to world.
308
+ const lim = con.cacheAngLimAxis
309
+ lim[0] = _TA[0] * axx + _TA[4] * axy + _TA[8] * axz
310
+ lim[1] = _TA[1] * axx + _TA[5] * axy + _TA[9] * axz
311
+ lim[2] = _TA[2] * axx + _TA[6] * axy + _TA[10] * axz
312
+ let target = angle * STOP_ERP * invDt
313
+ if (target > MAX_ANGULAR_CORRECTION_VEL) target = MAX_ANGULAR_CORRECTION_VEL
314
+ con.cacheAngLimTarget = target
315
+ con.cacheAngLimActive = bilateral ? 1 : 2
316
+ }
223
317
  }
224
- angTgt[i] = target
225
- angAct[i] = angDenom > 0 ? active : 0
226
318
  }
319
+ con.cacheAngLimImp = 0
227
320
  }
228
321
 
229
322
  // ITER: read cache, compute relVel from current lv/av, apply impulse.
@@ -266,12 +359,40 @@ function iterateConstraint(
266
359
  const dvy = vBy - vAy
267
360
  const dvz = vBz - vAz
268
361
 
362
+ const sprAct = con.cacheLinSpringActive
363
+ const sprTgt = con.cacheLinSpringTarget
364
+ const limImp = con.cacheLinLimitImp
269
365
  for (let i = 0; i < 3; i++) {
270
- if (!act[i]) continue
366
+ if (!act[i] && !sprAct[i]) continue
271
367
  const o = i * 3
272
368
  const axx = axes[o + 0], axy = axes[o + 1], axz = axes[o + 2]
273
369
  const relVel = dvx * axx + dvy * axy + dvz * axz
274
- const j = (tgt[i] - relVel) * jac[i]
370
+ let j = 0
371
+
372
+ // Limit row. Locked axes (act 1) are bilateral equality joints; ranged
373
+ // axes in violation (act 2) are unilateral stops — accumulated impulse
374
+ // clamped to the corrective sign, so the stop pushes back into range but
375
+ // never pulls deeper or brakes natural recovery (a bilateral stop acts
376
+ // as a motor and pumps energy into swinging cloth).
377
+ if (act[i]) {
378
+ const target = tgt[i]
379
+ let dImp = LIMIT_SOFTNESS_LINEAR * (target - relVel) * jac[i]
380
+ if (act[i] === 2) {
381
+ const old = limImp[i]
382
+ let next = old + dImp
383
+ if (target > 0 ? next < 0 : next > 0) next = 0
384
+ dImp = next - old
385
+ limImp[i] = next
386
+ }
387
+ j += dImp
388
+ }
389
+
390
+ // Spring row: velocity-target drive; the deadbeat k-clamp at setup
391
+ // bounds its aggression.
392
+ if (sprAct[i]) {
393
+ j += (sprTgt[i] - relVel) * jac[i]
394
+ }
395
+
275
396
  if (j === 0) continue
276
397
  if (imA > 0) {
277
398
  lv[ai + 0] -= j * imA * axx
@@ -305,6 +426,8 @@ function iterateConstraint(
305
426
  const o = i * 3
306
427
  const axx = angAxes[o + 0], axy = angAxes[o + 1], axz = angAxes[o + 2]
307
428
  const relAv = dax * axx + day * axy + daz * axz
429
+ // Spring drive (deadbeat-clamped at setup) plus, for small violations,
430
+ // the folded per-axis limit correction.
308
431
  const j = (angTgt[i] - relAv) * angJacInv
309
432
  if (j === 0) continue
310
433
  if (iiA > 0) {
@@ -318,6 +441,39 @@ function iterateConstraint(
318
441
  av[bi + 2] += j * iiB * axz
319
442
  }
320
443
  }
444
+
445
+ // Geodesic limit row: drive (ω_B − ω_A)·axis toward the correction target.
446
+ // Unilateral — the accumulated impulse can only push toward the legal
447
+ // region (target is always ≥ 0 along the corrective axis).
448
+ if (con.cacheAngLimActive) {
449
+ const lim = con.cacheAngLimAxis
450
+ const nx = lim[0], ny = lim[1], nz = lim[2]
451
+ // Re-read relAv — the spring rows above may have changed av.
452
+ const relAv =
453
+ (av[bi + 0] - av[ai + 0]) * nx +
454
+ (av[bi + 1] - av[ai + 1]) * ny +
455
+ (av[bi + 2] - av[ai + 2]) * nz
456
+ let j = LIMIT_SOFTNESS_ANGULAR * (con.cacheAngLimTarget - relAv) * angJacInv
457
+ if (con.cacheAngLimActive === 2) {
458
+ const old = con.cacheAngLimImp
459
+ let next = old + j
460
+ if (next < 0) next = 0
461
+ j = next - old
462
+ con.cacheAngLimImp = next
463
+ }
464
+ if (j !== 0) {
465
+ if (iiA > 0) {
466
+ av[ai + 0] -= j * iiA * nx
467
+ av[ai + 1] -= j * iiA * ny
468
+ av[ai + 2] -= j * iiA * nz
469
+ }
470
+ if (iiB > 0) {
471
+ av[bi + 0] += j * iiB * nx
472
+ av[bi + 1] += j * iiB * ny
473
+ av[bi + 2] += j * iiB * nz
474
+ }
475
+ }
476
+ }
321
477
  }
322
478
 
323
479
  // SETUP: pre-compute Jacobians, friction basis, and the bounce reference
@@ -552,6 +708,17 @@ function buildBodyMat(store: RigidBodyStore, i: number, out: Float32Array): void
552
708
  )
553
709
  }
554
710
 
711
+ // Quaternion of qx(x) ⊗ qy(y) ⊗ qz(z) (three.js 'XYZ' order).
712
+ function eulerXYZQuatInto(x: number, y: number, z: number, out: Float32Array): void {
713
+ const sx = Math.sin(x * 0.5), cx = Math.cos(x * 0.5)
714
+ const sy = Math.sin(y * 0.5), cy = Math.cos(y * 0.5)
715
+ const sz = Math.sin(z * 0.5), cz = Math.cos(z * 0.5)
716
+ out[0] = sx * cy * cz + cx * sy * sz
717
+ out[1] = cx * sy * cz - sx * cy * sz
718
+ out[2] = cx * cy * sz + sx * sy * cz
719
+ out[3] = cx * cy * cz - sx * sy * sz
720
+ }
721
+
555
722
  // Euler XYZ from a 3×3 rotation matrix (row-major elements).
556
723
  function matrixToEulerXYZ(
557
724
  r00: number, r01: number,
@@ -101,13 +101,25 @@ export class World {
101
101
 
102
102
  // 5. Integrate. Cap angular velocity at π/2 per step — a high-impulse
103
103
  // contact spike on a low-inertia body would otherwise spin past π
104
- // in one step and trash the quaternion integration.
104
+ // in one step and trash the quaternion integration. Linear velocity
105
+ // is capped at 5 units per step for the same reason: an explosion
106
+ // guard far above what legit cloth motion ever reaches.
105
107
  const MAX_ANGVEL_DT = Math.PI * 0.5
108
+ const MAX_LINVEL_DT = 5
106
109
  for (let i = 0; i < N; i++) {
107
110
  if (types[i] !== RigidbodyType.Dynamic || invMass[i] <= 0) continue
108
111
  const i3 = i * 3
109
112
  const i4 = i * 4
110
113
 
114
+ const vx = lv[i3 + 0], vy = lv[i3 + 1], vz = lv[i3 + 2]
115
+ const vmag = Math.sqrt(vx * vx + vy * vy + vz * vz)
116
+ if (vmag * dt > MAX_LINVEL_DT) {
117
+ const scale = MAX_LINVEL_DT / (vmag * dt)
118
+ lv[i3 + 0] = vx * scale
119
+ lv[i3 + 1] = vy * scale
120
+ lv[i3 + 2] = vz * scale
121
+ }
122
+
111
123
  pos[i3 + 0] += lv[i3 + 0] * dt
112
124
  pos[i3 + 1] += lv[i3 + 1] * dt
113
125
  pos[i3 + 2] += lv[i3 + 2] * dt
@@ -1,19 +0,0 @@
1
- export declare class GpuTimestamp {
2
- enabled: boolean;
3
- readonly supported: boolean;
4
- private device;
5
- private querySet;
6
- private resolveBuffer;
7
- private readbackBuffer;
8
- private nextSlot;
9
- private frameLabels;
10
- private inFlight;
11
- private pendingLabels;
12
- constructor(device: GPUDevice);
13
- private ensureCreated;
14
- beginFrame(): void;
15
- wrap(desc: GPURenderPassDescriptor, label: string): GPURenderPassDescriptor;
16
- endFrame(encoder: GPUCommandEncoder): void;
17
- afterSubmit(): void;
18
- }
19
- //# sourceMappingURL=gpu-profile.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"gpu-profile.d.ts","sourceRoot":"","sources":["../src/gpu-profile.ts"],"names":[],"mappings":"AAoBA,qBAAa,YAAY;IACvB,OAAO,UAAQ;IACf,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAA;IAC3B,OAAO,CAAC,MAAM,CAAW;IACzB,OAAO,CAAC,QAAQ,CAA2B;IAC3C,OAAO,CAAC,aAAa,CAAyB;IAC9C,OAAO,CAAC,cAAc,CAAyB;IAC/C,OAAO,CAAC,QAAQ,CAAI;IACpB,OAAO,CAAC,WAAW,CAAe;IAClC,OAAO,CAAC,QAAQ,CAAQ;IAKxB,OAAO,CAAC,aAAa,CAAwB;gBAEjC,MAAM,EAAE,SAAS;IAK7B,OAAO,CAAC,aAAa;IAiBrB,UAAU,IAAI,IAAI;IAWlB,IAAI,CAAC,IAAI,EAAE,uBAAuB,EAAE,KAAK,EAAE,MAAM,GAAG,uBAAuB;IAqB3E,QAAQ,CAAC,OAAO,EAAE,iBAAiB,GAAG,IAAI;IAW1C,WAAW,IAAI,IAAI;CAwBpB"}
@@ -1,120 +0,0 @@
1
- // WebGPU timestamp-query profiling. Records pass durations into the same
2
- // profiler module as CPU phases (so the snapshot table mixes both with a
3
- // "gpu." prefix). Read-only — no effect on rendering output.
4
- //
5
- // Pattern (per spec):
6
- // 1. Each render pass declares timestampWrites { begin, end } slot indices.
7
- // 2. After all passes, encoder.resolveQuerySet writes uint64 ns timestamps
8
- // into a QUERY_RESOLVE buffer.
9
- // 3. We copy the resolve buffer into a MAP_READ readback buffer.
10
- // 4. Submit. After the GPU finishes, mapAsync resolves; we read durations
11
- // and record them.
12
- //
13
- // One readback buffer with an in-flight gate: if mapAsync hasn't resolved
14
- // from a previous frame, we skip the copy this frame (no contention, just
15
- // one frame of dropped sampling).
16
- import { profiler } from "./physics/profile";
17
- const MAX_PASSES_PER_FRAME = 64;
18
- export class GpuTimestamp {
19
- constructor(device) {
20
- this.enabled = false;
21
- this.querySet = null;
22
- this.resolveBuffer = null;
23
- this.readbackBuffer = null;
24
- this.nextSlot = 0;
25
- this.frameLabels = [];
26
- this.inFlight = false;
27
- // Set inside endFrame() when a copy was queued this frame; consumed by
28
- // afterSubmit() to kick mapAsync. mapAsync must run AFTER queue.submit —
29
- // calling it before submit puts the buffer into "mapping pending" state,
30
- // which makes the copy in the encoder illegal.
31
- this.pendingLabels = null;
32
- this.device = device;
33
- this.supported = device.features.has("timestamp-query");
34
- }
35
- ensureCreated() {
36
- if (this.querySet || !this.supported)
37
- return;
38
- const slots = MAX_PASSES_PER_FRAME * 2;
39
- this.querySet = this.device.createQuerySet({ type: "timestamp", count: slots });
40
- this.resolveBuffer = this.device.createBuffer({
41
- label: "gpu-timestamp-resolve",
42
- size: slots * 8,
43
- usage: GPUBufferUsage.QUERY_RESOLVE | GPUBufferUsage.COPY_SRC,
44
- });
45
- this.readbackBuffer = this.device.createBuffer({
46
- label: "gpu-timestamp-readback",
47
- size: slots * 8,
48
- usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
49
- });
50
- }
51
- // Called once at the top of each render() before any pass wrap.
52
- beginFrame() {
53
- if (!this.enabled || !this.supported)
54
- return;
55
- this.ensureCreated();
56
- this.nextSlot = 0;
57
- this.frameLabels = [];
58
- }
59
- // Wrap a render-pass descriptor with timestampWrites for `label`. If
60
- // profiling is off / unsupported / out of slots, returns desc unchanged.
61
- // The same label called multiple times (e.g. each bloom mip) is fine —
62
- // the profiler accumulates by label.
63
- wrap(desc, label) {
64
- if (!this.enabled || !this.querySet)
65
- return desc;
66
- if (this.nextSlot + 2 > MAX_PASSES_PER_FRAME * 2)
67
- return desc;
68
- const begin = this.nextSlot;
69
- const end = this.nextSlot + 1;
70
- this.nextSlot += 2;
71
- this.frameLabels.push(label);
72
- return {
73
- ...desc,
74
- timestampWrites: {
75
- querySet: this.querySet,
76
- beginningOfPassWriteIndex: begin,
77
- endOfPassWriteIndex: end,
78
- },
79
- };
80
- }
81
- // Call on the encoder after all passes, BEFORE encoder.finish(). Adds
82
- // resolveQuerySet + copyBufferToBuffer commands. mapAsync is deferred to
83
- // afterSubmit() — calling it before submit transitions the readback buffer
84
- // into "mapping pending" state, which makes the queued copy illegal.
85
- endFrame(encoder) {
86
- if (!this.enabled || !this.querySet || this.frameLabels.length === 0)
87
- return;
88
- const count = this.frameLabels.length * 2;
89
- encoder.resolveQuerySet(this.querySet, 0, count, this.resolveBuffer, 0);
90
- if (this.inFlight)
91
- return;
92
- encoder.copyBufferToBuffer(this.resolveBuffer, 0, this.readbackBuffer, 0, count * 8);
93
- this.pendingLabels = this.frameLabels.slice();
94
- }
95
- // Call AFTER device.queue.submit(). Kicks mapAsync if a copy was queued
96
- // this frame; the .then() reads durations and unmaps.
97
- afterSubmit() {
98
- const labels = this.pendingLabels;
99
- if (!labels)
100
- return;
101
- this.pendingLabels = null;
102
- this.inFlight = true;
103
- const readback = this.readbackBuffer;
104
- void readback.mapAsync(GPUMapMode.READ).then(() => {
105
- const arr = new BigInt64Array(readback.getMappedRange());
106
- for (let i = 0; i < labels.length; i++) {
107
- const start = arr[i * 2];
108
- const end = arr[i * 2 + 1];
109
- // Per spec, timestamps are nanoseconds.
110
- const ms = Number(end - start) / 1e6;
111
- if (ms >= 0 && ms < 1000)
112
- profiler.record("gpu." + labels[i], ms);
113
- }
114
- readback.unmap();
115
- this.inFlight = false;
116
- }, () => {
117
- this.inFlight = false;
118
- });
119
- }
120
- }
@@ -1,18 +0,0 @@
1
- declare class PhysicsProfiler {
2
- enabled: boolean;
3
- private sums;
4
- private counts;
5
- begin(): number;
6
- end(label: string, t0: number): void;
7
- record(label: string, ms: number): void;
8
- snapshot(): Record<string, {
9
- avgMs: number;
10
- calls: number;
11
- totalMs: number;
12
- }>;
13
- reset(): void;
14
- }
15
- export declare const profiler: PhysicsProfiler;
16
- export type ProfileSnapshot = ReturnType<PhysicsProfiler["snapshot"]>;
17
- export {};
18
- //# sourceMappingURL=profile.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"profile.d.ts","sourceRoot":"","sources":["../../src/physics/profile.ts"],"names":[],"mappings":"AAIA,cAAM,eAAe;IACnB,OAAO,UAAQ;IACf,OAAO,CAAC,IAAI,CAA8C;IAC1D,OAAO,CAAC,MAAM,CAA8C;IAE5D,KAAK,IAAI,MAAM;IAIf,GAAG,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,IAAI;IASpC,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,IAAI;IAQvC,QAAQ,IAAI,MAAM,CAAC,MAAM,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;IAU7E,KAAK,IAAI,IAAI;CAId;AAED,eAAO,MAAM,QAAQ,iBAAwB,CAAA;AAC7C,MAAM,MAAM,eAAe,GAAG,UAAU,CAAC,eAAe,CAAC,UAAU,CAAC,CAAC,CAAA"}
@@ -1,44 +0,0 @@
1
- // Per-phase wall-clock accumulator. Off by default — when `enabled` is false,
2
- // begin()/end() short-circuit so production has zero overhead. Toggle via
3
- // `RezePhysics.setProfileEnabled(true)` and read with `getProfile()`.
4
- class PhysicsProfiler {
5
- constructor() {
6
- this.enabled = false;
7
- this.sums = Object.create(null);
8
- this.counts = Object.create(null);
9
- }
10
- begin() {
11
- return this.enabled ? performance.now() : 0;
12
- }
13
- end(label, t0) {
14
- if (!this.enabled)
15
- return;
16
- const dt = performance.now() - t0;
17
- this.sums[label] = (this.sums[label] ?? 0) + dt;
18
- this.counts[label] = (this.counts[label] ?? 0) + 1;
19
- }
20
- // Direct accumulator for durations not measured via begin()/end() — used by
21
- // GPU timestamp readback, which arrives async after the frame has ended.
22
- record(label, ms) {
23
- if (!this.enabled)
24
- return;
25
- this.sums[label] = (this.sums[label] ?? 0) + ms;
26
- this.counts[label] = (this.counts[label] ?? 0) + 1;
27
- }
28
- // Snapshot since last reset(). avgMs is per call; totalMs is the cumulative
29
- // window cost — divide by elapsed wall time to get a "% of frame budget".
30
- snapshot() {
31
- const out = {};
32
- for (const k in this.sums) {
33
- const total = this.sums[k];
34
- const calls = this.counts[k];
35
- out[k] = { avgMs: calls > 0 ? total / calls : 0, calls, totalMs: total };
36
- }
37
- return out;
38
- }
39
- reset() {
40
- this.sums = Object.create(null);
41
- this.counts = Object.create(null);
42
- }
43
- }
44
- export const profiler = new PhysicsProfiler();