@msb235/blits-rapier 0.3.0 → 0.5.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/CHANGELOG.md CHANGED
@@ -4,6 +4,63 @@ This package follows [semver](https://semver.org). Below 1.0.0, a breaking chang
4
4
  version and everything else the patch. The release workflow refuses a `rapier-v*` tag with no
5
5
  section here.
6
6
 
7
+ ## 0.5.0
8
+
9
+ ### Breaking
10
+
11
+ - `@msb235/blits` is a peer at `^0.20.0`, where it was `^0.19.0`. Built and tested against engine
12
+ 0.20.0; a physics mix on a transport reads its `Transport.history`.
13
+
14
+ ### Added
15
+
16
+ - A physics mix plays on a shared `transport`: the host syncs the transport and calls the new
17
+ `sim.step()`, and the world seeks with the transport's other mixes. `physicsStore(store)` wraps
18
+ the transport's history store so the world's log of pushes pages through it. `sim.sync` on a
19
+ transport throws, as does `physics` on a transport whose store is not wrapped.
20
+ - The mix is named `'physics'` when `physics` is given no `name`, so the debug panel lists it.
21
+
22
+ ### Fixed
23
+
24
+ - A seek forward after playing on from a seek back replays the world with the pushes logged the
25
+ first time. Playing on dropped every frame logged ahead, so the world replayed that stretch with
26
+ one frame's push.
27
+ - An `impulse`, `drop` or `body` call made after a seek lets go of the calls logged ahead of it
28
+ before the seek. A host making its calls again on the way did each twice, and a call it did not
29
+ make again still happened.
30
+ - A body shows the time sought as soon as a seek lands, and `sim.world()` returns that world. It
31
+ showed the world from before the seek until the next frame, and on that frame too if no time
32
+ had passed.
33
+ - A mix given no `history` keeps the one a debugger's `watched().history` gives it. The world
34
+ logged only one frame of pushes, so the debugger's seek replayed every step with that frame's.
35
+ - Without a history `store`, the log of `impulse`, `drop` and `body` calls lets go of what history
36
+ can no longer reach. It grew for the session, holding every body's Rapier description.
37
+
38
+ ## 0.4.0
39
+
40
+ ### Breaking
41
+
42
+ - `@msb235/blits` is a peer at `^0.19.0`, where it was `^0.18.0`. Built and tested against engine
43
+ 0.19.0; nothing here uses what that release changed.
44
+ - `PhysicsOptions` no longer takes `keyOf`: every subject in the mix is the driver's, and it keys
45
+ them itself.
46
+
47
+ ### Added
48
+
49
+ - Kinematic bodies moved by a voice: `kinematic2` and `kinematic3` add `goal` and `facing` channels
50
+ to a body's controls, which move a kinematic body over the next step. Neither has a rest, so a
51
+ body no voice writes stays where it is.
52
+ - History pages to a `store`. The world's state packs to plain data, its Rapier snapshot as base64,
53
+ and the log of each frame's pushes pages out beside it, so memory stays bounded.
54
+
55
+ ### Fixed
56
+
57
+ - Pushes, impulses, drops and added bodies are logged at the mix's time (`mix.now`), not the host's
58
+ timestamp, so a seek, `rebase` or rate other than 1 no longer files them at the wrong step.
59
+ - The log of each frame's pushes reaches back to the world copy a replay at the edge of memory
60
+ starts from, not just `history.ms`.
61
+ - `channels2`'s `rotation` takes a turn as `2 * Math.PI`. It was `angle()`, whose turn is 360, so
62
+ two radian angles either side of ±π blended the long way round, through 0.
63
+
7
64
  ## 0.3.0
8
65
 
9
66
  ### Breaking
package/README.md CHANGED
@@ -56,7 +56,10 @@ const { position, rotation } = sim.mix.probe(box);
56
56
  role adds on top of wherever physics has it.
57
57
  - **`body.impulse(v)`** applies an impulse once, on the step after the mix's current time.
58
58
  **`body.drop()`** takes the body out of the world then. Both are logged at that time, so a seek
59
- back before it undoes them and playing forward through it does them again.
59
+ back before it undoes them and playing forward through it does them again. One of these calls,
60
+ or `sim.body`, made after a seek lets go of the calls logged ahead of it before the seek, as a
61
+ call on the mix's tape starts a new future: a host that makes its calls again on the way does
62
+ each once.
60
63
  - **`sim.world()`** is the Rapier world as the last `sync` left it, for reading: raycasts, contact
61
64
  queries, debugging draws. A change made to it directly is lost at the next seek.
62
65
 
@@ -65,30 +68,85 @@ const { position, rotation } = sim.mix.probe(box);
65
68
  | Channel | 2D (`channels2`) | 3D (`channels3`) | On |
66
69
  |---|---|---|---|
67
70
  | `position` | `vec(2, sum())` | `vec(3, sum())` | the body |
68
- | `rotation` | `angle()`, radians | `quat()`, `[x, y, z, w]` | the body |
71
+ | `rotation` | `angle({ turn: 2 * Math.PI })`, radians | `quat()`, `[x, y, z, w]` | the body |
69
72
  | `force` | `vec(2, sum())` | `vec(3, sum())` | its controls |
70
73
  | `torque` | `sum()` | `vec(3, sum())` | its controls |
71
74
 
72
75
  Spread them into a kit with your own channels. Forces and torques are in Rapier's units, newtons
73
76
  and newton-meters for a body whose mass you set in kilograms.
74
77
 
78
+ ## Kinematic bodies
79
+
80
+ A body made with `RigidBodyDesc.kinematicPositionBased()` goes where a voice on its controls says,
81
+ through two more channels, `kinematic2` or `kinematic3`, spread into the kit beside the others:
82
+
83
+ | Channel | 2D (`kinematic2`) | 3D (`kinematic3`) |
84
+ |---|---|---|
85
+ | `goal` | `vec(2, last())`, a position | `vec(3, last())`, a position |
86
+ | `facing` | `last()`, an angle in radians | `last()`, an `[x, y, z, w]` quaternion |
87
+
88
+ ```ts
89
+ const sim = physics(kit<Pose3 & Kinematic3>({ ...channels3, ...kinematic3 }), { rapier: RAPIER });
90
+ const door = sim.body(RAPIER.RigidBodyDesc.kinematicPositionBased(), RAPIER.ColliderDesc.cuboid(1, 2, 0.1));
91
+ door.drive({ patch: keys(800, [{ at: 0, delta: { goal: [0, 2, 0] } }, { at: 1, delta: { goal: [2, 2, 0] } }]) });
92
+ ```
93
+
94
+ Neither has a rest: a body no voice writes a goal for stays where it is, and one whose voice has
95
+ ended stays where it left it. The world moves the body there over the next step, at the speed that
96
+ takes, so it pushes what it meets. Since the channels are `last`, a voice's weight only switches its
97
+ goal on or off, and of the voices writing one, the last cued wins; keyframes between two goals
98
+ interpolate, the facing the short way round.
99
+
75
100
  ## The clock and seeking
76
101
 
77
- `physics` makes the mix, with `stepMs` 1000 / 60 unless you set it; the world takes one Rapier step
78
- per interval, so it runs at the same rate whatever the frame rate. Pass `history` and a `tape` to
79
- seek. The world lives in the mix as the state of a stateful patch, so blits' history keeps a copy
102
+ `physics` makes the mix, named `'physics'` unless you name it, with `stepMs` 1000 / 60 unless you
103
+ set it; the world takes one Rapier step per interval of mix time, so it runs at the same rate
104
+ whatever the frame rate or the mix's `rate`. Each frame reads every body's controls once, and the
105
+ steps that frame takes all push by what it read, so a force that changes faster than the frame
106
+ rate is sampled at it. A long frame, such as the first after a tab was hidden, catches up every
107
+ step it owes, unless the mix's `maxDt` caps how far a frame steps: two seconds take 120 steps
108
+ without it and 6 under `maxDt: 100`, the rest dropped rather than owed. Call `mix.rebase()` when a
109
+ tab comes back to leave the hidden time out instead. Pass `history` and a `tape` to
110
+ seek, or leave `history` out and a debugger's `watched().history` is kept as the mix's. The world lives in the mix as the state of a stateful patch, so blits' history keeps a copy
80
111
  every `every` ms and a seek replays from the nearest one. A copy is Rapier's snapshot, kept as
81
112
  bytes and rebuilt into a world only when a replay needs it, so history costs memory in proportion
82
- to the world's size and `ms / every`. Each frame's pushes are logged beside it for the replay and
83
- pruned with history.
113
+ to the world's size and `ms / every`. Each frame's pushes are logged beside it for the replay, back
114
+ to the copy a replay at the edge of memory would start from.
115
+
116
+ `history.store` works: the world pages out as plain data, its snapshot in base64. Give the mix a
117
+ `name`, as every mix on a transport with a store needs; `physics` keys its own subjects, so it takes
118
+ no `keyOf`. The log of each frame's pushes pages out with it, one `host` record a frame under the
119
+ subject `'blits-rapier frames'`, and `prepare` loads it back for a seek past memory.
120
+
121
+ On a shared `transport`, the world plays on the transport's clock and history beside its other
122
+ mixes: sync the transport, then call `sim.step()` each frame, where a mix alone calls
123
+ `sim.sync(now)`. If the transport keeps a history `store`, make it with `physicsStore(store)`, so
124
+ the log of pushes pages through it; `physics` throws for a transport whose store is not wrapped.
125
+
126
+ ```ts
127
+ const t = transport({ history: { ms: 10_000, tape, store: physicsStore(store) } });
128
+ const sim = physics(kit<Pose3>(channels3), { rapier: RAPIER, transport: t, name: 'world' });
129
+ // each frame
130
+ t.sync(now);
131
+ sim.step();
132
+ ```
84
133
 
85
134
  Replay matches the first pass exactly on one machine. To match across machines, pass Rapier's
86
135
  deterministic build.
87
136
 
88
- ## Not yet
89
-
90
- - Kinematic bodies moved by a voice. They need a channel with no rest, which blits' `vec` of
91
- `sum()` is not.
92
- - Paging history to a `store`: a Rapier description is not plain data, so a body's add cannot be
93
- written out.
137
+ ## Not handled
138
+
139
+ - **Reads at another time.** `mix.project(t)` and `mix.trace` show a body where the world was at
140
+ the last `sync`, not at `t`, since the world is a voice on a subject of its own. A seek is
141
+ shown at once, before the next `sync`.
142
+ - **Picking another future.** The tape's `switchBranch` brings back the mix's own calls, not
143
+ `impulse`, `drop` or `body` calls a later one let go of. A call on the mix itself, such as a
144
+ `cue`, made after a seek does not let go of those calls logged ahead.
145
+ - **A seek forward past memory after playing on from a seek back**, with a `store`: the seek back
146
+ cuts the pushes paged after it from the store, so the world replays that stretch with the push
147
+ of the first frame it still holds. Within memory it replays exactly.
148
+ - **Growth with a store.** Each `impulse`, `drop` and `body` call is kept for the session, since
149
+ a seek past memory replays it and a body's description is a Rapier object, not data a store
150
+ takes. Without a store they go once history can no longer reach them. Every body made keeps its
151
+ voices in the mix for the session, dropped or not.
94
152
  - Soft bodies, cloth and ropes.
@@ -21,3 +21,25 @@ export declare const channels2: {
21
21
  export declare const channels3: {
22
22
  [K in keyof Pose3]: Channel<Pose3[K]>;
23
23
  };
24
+ /**
25
+ * Where a voice on a kinematic 2D body's controls moves it: a position and an angle in radians. Each
26
+ * has no rest, so a body with no voice writing it stays where it is. A voice's weight only switches
27
+ * its goal on or off, and of the voices writing one, the last cued wins.
28
+ */
29
+ export interface Kinematic2 {
30
+ goal?: Vec<2>;
31
+ facing?: number;
32
+ }
33
+ /** Where a voice on a kinematic 3D body's controls moves it: a position and an `[x, y, z, w]` quaternion. See `Kinematic2`. */
34
+ export interface Kinematic3 {
35
+ goal?: Vec<3>;
36
+ facing?: Vec<4>;
37
+ }
38
+ /** A kinematic 2D body's channels, to spread into a kit beside `channels2`. */
39
+ export declare const kinematic2: {
40
+ [K in keyof Kinematic2]-?: Channel<NonNullable<Kinematic2[K]>>;
41
+ };
42
+ /** A kinematic 3D body's channels, to spread into a kit beside `channels3`. */
43
+ export declare const kinematic3: {
44
+ [K in keyof Kinematic3]-?: Channel<NonNullable<Kinematic3[K]>>;
45
+ };
package/dist/channels.js CHANGED
@@ -1,8 +1,8 @@
1
- import { angle, quat, sum, vec } from '@msb235/blits';
1
+ import { angle, last, quat, sum, vec } from '@msb235/blits';
2
2
  /** A 2D world's channels, to spread into a kit. */
3
3
  export const channels2 = {
4
4
  position: vec(2, sum()),
5
- rotation: angle(),
5
+ rotation: angle({ turn: 2 * Math.PI }),
6
6
  force: vec(2, sum()),
7
7
  torque: sum(),
8
8
  };
@@ -13,3 +13,14 @@ export const channels3 = {
13
13
  force: vec(3, sum()),
14
14
  torque: vec(3, sum()),
15
15
  };
16
+ const along = (a, b, u) => a + (b - a) * u;
17
+ /** A kinematic 2D body's channels, to spread into a kit beside `channels2`. */
18
+ export const kinematic2 = {
19
+ goal: vec(2, last({ lerp: along })),
20
+ facing: last({ lerp: angle({ turn: 2 * Math.PI }).lerp }),
21
+ };
22
+ /** A kinematic 3D body's channels, to spread into a kit beside `channels3`. */
23
+ export const kinematic3 = {
24
+ goal: vec(3, last({ lerp: along })),
25
+ facing: last({ lerp: quat().lerp }),
26
+ };
package/dist/index.d.ts CHANGED
@@ -1,3 +1,3 @@
1
- export { channels2, channels3, type Pose2, type Pose3 } from './channels.js';
2
- export { type Body, type Physics, type PhysicsOptions, physics } from './physics.js';
1
+ export { channels2, channels3, type Kinematic2, type Kinematic3, kinematic2, kinematic3, type Pose2, type Pose3, } from './channels.js';
2
+ export { type Body, type Physics, type PhysicsOptions, physics, physicsStore } from './physics.js';
3
3
  export type { RapierBody, RapierModule, RapierVector, RapierWorld } from './rapier.js';
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
- export { channels2, channels3 } from './channels.js';
2
- export { physics } from './physics.js';
1
+ export { channels2, channels3, kinematic2, kinematic3, } from './channels.js';
2
+ export { physics, physicsStore } from './physics.js';
package/dist/physics.d.ts CHANGED
@@ -1,8 +1,11 @@
1
- import { type Handle, type Kit, type Mix, type MixOptions, type Signal, type VoiceSpec } from '@msb235/blits';
1
+ import { type Handle, type HistoryStore, type Kit, type Mix, type MixOptions, type Signal, type VoiceSpec } from '@msb235/blits';
2
2
  import type { Pose2, Pose3 } from './channels.js';
3
3
  import { type RapierModule, type RapierWorld } from './rapier.js';
4
- /** What `physics` takes: a mix's options, the Rapier module, and the world's gravity. */
5
- export interface PhysicsOptions<H> extends MixOptions<H> {
4
+ /**
5
+ * What `physics` takes: a mix's options, the Rapier module, and the world's gravity. There is no
6
+ * `keyOf`: every subject is the driver's own, and it keys them itself for a history store.
7
+ */
8
+ export interface PhysicsOptions<H> extends Omit<MixOptions<H>, 'keyOf'> {
6
9
  /** An initialized Rapier module. `@dimforge/rapier3d-compat` makes a 3D world, `rapier2d-compat` a 2D one. */
7
10
  rapier: RapierModule;
8
11
  /** Default: `[0, -9.81]` or `[0, -9.81, 0]`. */
@@ -39,10 +42,21 @@ export interface Physics<O, H> {
39
42
  * the first step before the mix has synced.
40
43
  */
41
44
  body(desc: unknown, ...colliders: unknown[]): Body<O, H>;
42
- /** Syncs the mix to `now`, reads every body's controls, and steps the world. Call it in place of `mix.sync`. */
45
+ /** Syncs the mix to `now`, then calls `step`. Call it in place of `mix.sync`; on a transport, call `step`. */
43
46
  sync(now: number): void;
47
+ /**
48
+ * Reads every body's controls and steps the world to the mix's time. On a transport, call it each
49
+ * frame after the transport's `sync`.
50
+ */
51
+ step(): void;
44
52
  /** The world as it stands after the last `sync`, for reading. A change made to it is lost at the next seek. */
45
53
  world(): RapierWorld | undefined;
46
54
  }
55
+ /**
56
+ * Wraps a history store so the worlds of physics mixes on a transport page their log of each
57
+ * frame's pushes through it, and get it back on `prepare`. Make the transport with
58
+ * `history: { ..., store: physicsStore(store) }`; a mix made alone wraps its own store.
59
+ */
60
+ export declare function physicsStore(store: HistoryStore): HistoryStore;
47
61
  /** Makes a mix that holds a Rapier world, stepped on its `stepMs` clock and replayed by its history. */
48
62
  export declare function physics<O extends Pose2 | Pose3, H extends object = Record<string, never>>(kit: Kit<O>, opts: PhysicsOptions<H>): Physics<O, H>;
package/dist/physics.js CHANGED
@@ -1,20 +1,89 @@
1
- import { mix, patch, } from '@msb235/blits';
1
+ import { mix, patch, watched, } from '@msb235/blits';
2
2
  import { dimsOf } from './rapier.js';
3
- import { applyLog, copy, insert, worldOf } from './world.js';
3
+ import { applyLog, copy, insert, pack, unpack, worldOf } from './world.js';
4
4
  const ROLE = 'physics';
5
5
  const copyOf = (r) => typeof r === 'number' ? r : [...r];
6
+ /** The subject key the world's frame records go under in a history store, on the `host` stream. */
7
+ const FRAMES = 'blits-rapier frames';
8
+ const paging = new WeakMap();
9
+ /**
10
+ * Wraps a history store so the worlds of physics mixes on a transport page their log of each
11
+ * frame's pushes through it, and get it back on `prepare`. Make the transport with
12
+ * `history: { ..., store: physicsStore(store) }`; a mix made alone wraps its own store.
13
+ */
14
+ export function physicsStore(store) {
15
+ const worlds = new Set();
16
+ const wrapped = {
17
+ page: (out) => store.page(out),
18
+ cut: (seq) => store.cut(seq),
19
+ async load(t) {
20
+ const all = await store.load(t);
21
+ for (const w of worlds) {
22
+ // A replay to t starts from the world's latest copy at or before it, which needs the frames from there.
23
+ let from = t;
24
+ for (const r of all)
25
+ if (r.mix === w.name && r.stream === 'snap' && r.subject === 'world' && r.at < from)
26
+ from = r.at;
27
+ w.restore(from < t ? await store.load(from) : all, from);
28
+ }
29
+ return all.filter((r) => !(r.stream === 'host' && r.subject === FRAMES));
30
+ },
31
+ };
32
+ paging.set(wrapped, worlds);
33
+ return wrapped;
34
+ }
6
35
  /** Makes a mix that holds a Rapier world, stepped on its `stepMs` clock and replayed by its history. */
7
36
  export function physics(kit, opts) {
8
37
  const { rapier, gravity, weight, ...rest } = opts;
9
38
  const dims = dimsOf(rapier);
10
39
  const g = gravity ?? (dims.n === 2 ? [0, -9.81] : [0, -9.81, 0]);
11
- const m = mix(kit, { ...rest, stepMs: rest.stepMs ?? 1000 / 60 });
12
- const log = [];
40
+ const stepMs = rest.stepMs ?? 1000 / 60;
41
+ const name = rest.name ?? 'physics';
42
+ const transport = rest.transport;
43
+ // A mix naming no history keeps the one a debugger gives mixes made from now on.
44
+ const history = transport === undefined ? (rest.history ?? watched().history) : transport.history;
45
+ const outer = history?.store;
46
+ if (outer !== undefined && transport !== undefined && !paging.has(outer))
47
+ throw new Error("blits-rapier: a physics mix on a transport with a history store needs the transport's store made with physicsStore(store), so the world's frames page with it");
48
+ const store = outer === undefined ? undefined : transport === undefined ? physicsStore(outer) : outer;
13
49
  // Each frame's pushes at its mix time. A seek replays steps on what the host reads now, so the
14
50
  // world reads the pushes of the frame that caused each step from here instead: the first frame
15
51
  // at or after the step's end, looked up half a step early since replayed times can round apart.
52
+ // It reaches back past memory to the world's copy a replay starts from; older frames go to the
53
+ // store as host records under `FRAMES`, and come back when `prepare` loads.
16
54
  const pushLog = [];
17
- const keep = rest.history?.ms ?? 0;
55
+ const keep = history === undefined ? 0 : history.ms + (history.every ?? 200) + 2 * stepMs;
56
+ const ours = (r) => r.mix === name && r.stream === 'host' && r.subject === FRAMES;
57
+ const restore = (records, from) => {
58
+ let start = Number.NEGATIVE_INFINITY;
59
+ for (const r of records)
60
+ if (ours(r) && r.at <= from && r.at > start)
61
+ start = r.at;
62
+ const byAt = new Map();
63
+ for (const r of records)
64
+ if (ours(r) && r.at >= start && (byAt.get(r.at)?.seq ?? -1) < r.seq)
65
+ byAt.set(r.at, r);
66
+ const first = pushLog[0]?.at ?? Number.POSITIVE_INFINITY;
67
+ const older = [...byAt.values()]
68
+ .filter((r) => r.at < first)
69
+ .sort((a, b) => a.at - b.at)
70
+ .map((r) => ({ ...r.data, at: r.at, seq: r.seq }));
71
+ pushLog.unshift(...older);
72
+ };
73
+ if (store !== undefined)
74
+ paging.get(store)?.add({ ours, restore, name });
75
+ // Every subject is the driver's own: the world, each body and each body's controls.
76
+ const keys = new WeakMap();
77
+ const m = mix(kit, {
78
+ ...rest,
79
+ ...(store !== undefined && transport === undefined
80
+ ? { history: { ...history, store } }
81
+ : {}),
82
+ stepMs,
83
+ name,
84
+ keyOf: (subject) => keys.get(subject),
85
+ });
86
+ const log = [];
18
87
  const frameAt = (t) => {
19
88
  let lo = 0;
20
89
  let hi = pushLog.length;
@@ -59,6 +128,13 @@ export function physics(kit, opts) {
59
128
  if (handle !== undefined && h !== undefined)
60
129
  dims.place(w.getRigidBody(handle), h.now, h.before, h.dt);
61
130
  }
131
+ const goals = frame?.goals ?? {};
132
+ for (const k in goals) {
133
+ const handle = s.handles.get(+k);
134
+ const g = goals[k];
135
+ if (handle !== undefined && g !== undefined)
136
+ dims.aim(w.getRigidBody(handle), g);
137
+ }
62
138
  const pushed = [];
63
139
  for (const k in pushes) {
64
140
  const handle = s.handles.get(+k);
@@ -79,17 +155,23 @@ export function physics(kit, opts) {
79
155
  s.t = setting.timestamp;
80
156
  },
81
157
  clone: copy,
158
+ pack,
159
+ unpack,
82
160
  });
83
161
  const WORLD = {};
162
+ keys.set(WORLD, 'world');
84
163
  let started = false;
85
- const simulated = patch(0, (_p, subject) => {
164
+ const simulated = patch(0, (_p, subject, setting) => {
165
+ // A read at another time sees the world as it stands; see the README.
166
+ if (setting.timestamp === m.now)
167
+ fresh();
86
168
  const handle = live?.handles.get(subject.key);
87
169
  if (live === undefined || handle === undefined)
88
170
  return {};
89
171
  const b = worldOf(live, rapier).getRigidBody(handle);
90
172
  return { position: dims.position(b), rotation: dims.rotation(b) };
91
173
  }, { writes: ['position', 'rotation'] });
92
- const now = () => (Number.isNaN(m.timestamp) ? Number.NEGATIVE_INFINITY : m.timestamp);
174
+ const now = () => (Number.isNaN(m.now) ? Number.NEGATIVE_INFINITY : m.now);
93
175
  const driven = new Set();
94
176
  // Each body's voices in the simulation's role besides its own, and its pose the last frame they held it.
95
177
  const authored = new Map();
@@ -126,13 +208,118 @@ export function physics(kit, opts) {
126
208
  return holds;
127
209
  };
128
210
  let next = 0;
211
+ // The frame, mix time and host timestamp of the last `step`, the first frame after a seek it
212
+ // noticed, and the frame `live` was read in. A seek comes and goes without the driver hearing.
213
+ let lastSeq;
214
+ let lastAt = Number.NEGATIVE_INFINITY;
215
+ let lastTs = Number.NaN;
216
+ let soughtSeq = Number.NEGATIVE_INFINITY;
217
+ let liveSeq = Number.NaN;
218
+ let startAt = Number.NaN;
219
+ const sought = () => lastSeq !== undefined && history?.tape !== undefined && (m.seq > lastSeq + 1 || m.now < lastAt);
220
+ /** Reads the world at the time sought after seeks since the last step, with no sync since. */
221
+ const fresh = () => {
222
+ if (lastSeq === undefined ||
223
+ history?.tape === undefined ||
224
+ liveSeq === m.seq ||
225
+ m.timestamp !== lastTs)
226
+ return;
227
+ m.probe(WORLD);
228
+ live = seen;
229
+ liveSeq = m.seq;
230
+ };
231
+ /** Logs a call, letting go of the calls logged ahead of it before a seek, as the mix's tape does. */
232
+ const call = (entry) => {
233
+ if (sought())
234
+ soughtSeq = lastSeq + 1;
235
+ const from = entry.at - stepMs / 2;
236
+ for (let i = log.length - 1; i >= 0 && log[i].at >= from; i--)
237
+ if (log[i].seq < soughtSeq)
238
+ log.splice(i, 1);
239
+ insert(log, entry);
240
+ };
241
+ const step = () => {
242
+ // Cued before the mix has a time, the world would catch up from mix time 0 on its first step.
243
+ if (!started) {
244
+ started = true;
245
+ m.cue({ patch: world, subjects: [WORLD] });
246
+ }
247
+ const pushes = {};
248
+ const goals = {};
249
+ for (const body of driven) {
250
+ const p = m.probe(body.controls, out);
251
+ const torque = typeof p.torque === 'number' ? [p.torque] : p.torque;
252
+ if (p.force.some((x) => x !== 0) || torque.some((x) => x !== 0))
253
+ pushes[body.key] = [...p.force, ...torque];
254
+ if (p.goal !== undefined || p.facing !== undefined) {
255
+ const g = {};
256
+ if (p.goal !== undefined)
257
+ g.position = [...p.goal];
258
+ if (p.facing !== undefined)
259
+ g.rotation = copyOf(p.facing);
260
+ goals[body.key] = g;
261
+ }
262
+ }
263
+ const at = m.now;
264
+ const seq = m.seq;
265
+ if (Number.isNaN(startAt))
266
+ startAt = at;
267
+ // This frame stands for the frames logged after where the mix stood before it, up to its own
268
+ // time. Those ahead stay for a seek forward, which replays them as the mix's tape does its calls.
269
+ let before = lastAt;
270
+ if (sought()) {
271
+ before = m.project({ frame: seq - 1 }).timestamp;
272
+ soughtSeq = lastSeq + 1;
273
+ holding = {};
274
+ held.clear();
275
+ }
276
+ let end = pushLog.length;
277
+ while (end > 0 && pushLog[end - 1].at > at)
278
+ end--;
279
+ let start = end;
280
+ while (start > 0 &&
281
+ (pushLog[start - 1].at > before || pushLog[start - 1].at === at))
282
+ start--;
283
+ pushLog.splice(start, end - start, { at, seq, pushes, holds: holding, goals });
284
+ lastSeq = seq;
285
+ lastAt = at;
286
+ lastTs = m.timestamp;
287
+ let old = 0;
288
+ while (old < pushLog.length - 1 && pushLog[old + 1].at < at - keep)
289
+ old++;
290
+ const gone = old > 0 ? pushLog.splice(0, old) : undefined;
291
+ if (gone !== undefined && store !== undefined)
292
+ store.page(gone.map(({ at, seq, pushes, holds, goals }) => ({
293
+ mix: name,
294
+ stream: 'host',
295
+ subject: FRAMES,
296
+ at,
297
+ seq,
298
+ data: { pushes, holds, goals },
299
+ })));
300
+ m.probe(WORLD);
301
+ live = seen;
302
+ liveSeq = seq;
303
+ // Without a store, calls older than any copy a replay starts from are in every copy already,
304
+ // once history no longer reaches the world's first step, which a fresh world replays from.
305
+ const reach = Math.min(at, live?.t ?? at) - keep - stepMs;
306
+ if (store === undefined && reach > startAt) {
307
+ let n = 0;
308
+ while (n < log.length && log[n].at < reach)
309
+ n++;
310
+ if (n > 0)
311
+ log.splice(0, n);
312
+ }
313
+ holding = holdsAt(at);
314
+ };
129
315
  return {
130
316
  mix: m,
131
317
  role: ROLE,
132
318
  body(desc, ...colliders) {
133
319
  const key = next++;
134
- insert(log, { at: now(), key, kind: 'add', desc, colliders });
320
+ call({ at: now(), seq: m.seq, key, kind: 'add', desc, colliders });
135
321
  const controls = {};
322
+ keys.set(controls, `controls ${key}`);
136
323
  const own = (spec, subject) => {
137
324
  if ('subjects' in spec)
138
325
  throw new Error('blits-rapier: a body cues on itself; its spec names no subjects');
@@ -152,11 +339,10 @@ export function physics(kit, opts) {
152
339
  driven.add(body);
153
340
  return own(spec, controls);
154
341
  },
155
- impulse: (v) => insert(log, { at: now(), key, kind: 'impulse', v: [...v] }),
156
- drop: () => {
157
- insert(log, { at: now(), key, kind: 'drop' });
158
- },
342
+ impulse: (v) => call({ at: now(), seq: m.seq, key, kind: 'impulse', v: [...v] }),
343
+ drop: () => call({ at: now(), seq: m.seq, key, kind: 'drop' }),
159
344
  };
345
+ keys.set(body, `body ${key}`);
160
346
  body.simulated = m.cue({
161
347
  patch: simulated,
162
348
  subjects: [body],
@@ -166,40 +352,15 @@ export function physics(kit, opts) {
166
352
  return body;
167
353
  },
168
354
  sync(t) {
355
+ if (transport !== undefined)
356
+ throw new Error('blits-rapier: this physics plays on a transport: sync the transport, then call step()');
169
357
  m.sync(t);
170
- // Cued before the mix has a time, the world would catch up from mix time 0 on its first step.
171
- if (!started) {
172
- started = true;
173
- m.cue({ patch: world, subjects: [WORLD] });
174
- }
175
- const pushes = {};
176
- for (const body of driven) {
177
- const p = m.probe(body.controls, out);
178
- const torque = typeof p.torque === 'number' ? [p.torque] : p.torque;
179
- if (p.force.some((x) => x !== 0) || torque.some((x) => x !== 0))
180
- pushes[body.key] = [...p.force, ...torque];
181
- }
182
- const at = m.timestamp;
183
- let back = false;
184
- while (pushLog.length > 0 && pushLog[pushLog.length - 1].at >= at) {
185
- pushLog.pop();
186
- back = true;
187
- }
188
- // A seek back leaves the holds read before it, and their speeds, behind.
189
- if (back) {
190
- holding = {};
191
- held.clear();
192
- }
193
- pushLog.push({ at, pushes, holds: holding });
194
- let old = 0;
195
- while (old < pushLog.length - 1 && pushLog[old + 1].at < at - keep)
196
- old++;
197
- if (old > 0)
198
- pushLog.splice(0, old);
199
- m.probe(WORLD);
200
- live = seen;
201
- holding = holdsAt(at);
358
+ step();
359
+ },
360
+ step,
361
+ world: () => {
362
+ fresh();
363
+ return live === undefined ? undefined : worldOf(live, rapier);
202
364
  },
203
- world: () => (live === undefined ? undefined : worldOf(live, rapier)),
204
365
  };
205
366
  }
package/dist/rapier.d.ts CHANGED
@@ -23,6 +23,8 @@ export interface RapierBody {
23
23
  setRotation(r: never, wake: boolean): void;
24
24
  setLinvel(v: RapierVector, wake: boolean): void;
25
25
  setAngvel(v: never, wake: boolean): void;
26
+ setNextKinematicTranslation(v: RapierVector): void;
27
+ setNextKinematicRotation(r: never): void;
26
28
  }
27
29
  /** What blits-rapier uses of a Rapier world, in either build. */
28
30
  export interface RapierWorld {
@@ -56,6 +58,13 @@ export interface Dims {
56
58
  torque(body: RapierBody, torque: number | readonly number[]): void;
57
59
  /** Puts the body at a pose, moving at the speed it took to get there from `before` over `dt` ms. */
58
60
  place(body: RapierBody, now: Placed, before: Placed | undefined, dt: number): void;
61
+ /** Moves a kinematic body to whichever of a position and rotation is given, over the next step. */
62
+ aim(body: RapierBody, goal: Goal): void;
63
+ }
64
+ /** Where a kinematic body's controls ask it to be: either part may be absent. */
65
+ export interface Goal {
66
+ position?: readonly number[];
67
+ rotation?: number | readonly number[];
59
68
  }
60
69
  /** A body's position and rotation as the channels hold them. */
61
70
  export interface Placed {
package/dist/rapier.js CHANGED
@@ -17,6 +17,12 @@ const two = {
17
17
  turn -= 2 * Math.PI * Math.round(turn / (2 * Math.PI));
18
18
  b.setAngvel((dt > 0 ? turn / (dt / 1000) : 0), true);
19
19
  },
20
+ aim: (b, goal) => {
21
+ if (goal.position !== undefined)
22
+ b.setNextKinematicTranslation(two.vector(goal.position));
23
+ if (goal.rotation !== undefined)
24
+ b.setNextKinematicRotation(goal.rotation);
25
+ },
20
26
  };
21
27
  const three = {
22
28
  n: 3,
@@ -37,6 +43,16 @@ const three = {
37
43
  b.setLinvel(three.vector(speed(now.position, before?.position, dt)), true);
38
44
  b.setAngvel(three.vector(spin(now.rotation, before?.rotation, dt)), true);
39
45
  },
46
+ aim: (b, goal) => {
47
+ if (goal.position !== undefined)
48
+ b.setNextKinematicTranslation(three.vector(goal.position));
49
+ if (goal.rotation === undefined)
50
+ return;
51
+ const [x, y, z, w] = goal.rotation;
52
+ // A keyed facing need not be unit length, and Rapier takes it as given.
53
+ const n = Math.hypot(x, y, z, w) || 1;
54
+ b.setNextKinematicRotation({ x: x / n, y: y / n, z: z / n, w: w / n });
55
+ },
40
56
  };
41
57
  /** The angular velocity turning `before` into `now` over `dt` ms, both `[x, y, z, w]`. */
42
58
  function spin(now, before, dt) {
package/dist/world.d.ts CHANGED
@@ -11,27 +11,32 @@ export interface State {
11
11
  /** The mix time of the last step, NaN before the first. */
12
12
  t: number;
13
13
  }
14
- /** Something done to the world at a mix time, applied by whichever step reaches it, on every replay. */
14
+ /**
15
+ * Something done to the world at a mix time, applied by whichever step reaches it, on every
16
+ * replay. `seq` is the frame the host made the call in.
17
+ */
15
18
  export type Entry = {
16
19
  at: number;
20
+ seq: number;
17
21
  key: number;
22
+ } & ({
18
23
  kind: 'add';
19
24
  desc: unknown;
20
25
  colliders: readonly unknown[];
21
26
  } | {
22
- at: number;
23
- key: number;
24
27
  kind: 'drop';
25
28
  } | {
26
- at: number;
27
- key: number;
28
29
  kind: 'impulse';
29
30
  v: readonly number[];
30
- };
31
+ });
31
32
  /** The state's world, rebuilt from its snapshot where it has none yet. */
32
33
  export declare function worldOf(state: State, rapier: RapierModule): RapierWorld;
33
34
  /** A copy sharing nothing with `state`, holding the world as a snapshot. */
34
35
  export declare function copy(state: State): State;
36
+ /** The state as plain data, for a history store: the snapshot as base64 and the handles as pairs. */
37
+ export declare function pack(state: State): unknown;
38
+ /** A state from what `pack` made, its world left to `worldOf` to rebuild. */
39
+ export declare function unpack(data: unknown): State;
35
40
  /** Puts `entry` into `log` after every entry at or before its time. */
36
41
  export declare function insert(log: Entry[], entry: Entry): void;
37
42
  /**
package/dist/world.js CHANGED
@@ -15,6 +15,28 @@ export function copy(state) {
15
15
  t: state.t,
16
16
  };
17
17
  }
18
+ /** The state as plain data, for a history store: the snapshot as base64 and the handles as pairs. */
19
+ export function pack(state) {
20
+ const bytes = state.world === null ? state.bytes : state.world.takeSnapshot();
21
+ let text = '';
22
+ // In chunks: a spread of a large snapshot overflows the call stack.
23
+ for (let i = 0; i < (bytes?.length ?? 0); i += 0x8000)
24
+ text += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
25
+ return {
26
+ bytes: btoa(text),
27
+ handles: [...state.handles],
28
+ t: Number.isNaN(state.t) ? null : state.t,
29
+ };
30
+ }
31
+ /** A state from what `pack` made, its world left to `worldOf` to rebuild. */
32
+ export function unpack(data) {
33
+ const d = data;
34
+ const text = atob(d.bytes);
35
+ const bytes = new Uint8Array(text.length);
36
+ for (let i = 0; i < text.length; i++)
37
+ bytes[i] = text.charCodeAt(i);
38
+ return { bytes, world: null, handles: new Map(d.handles), t: d.t ?? Number.NaN };
39
+ }
18
40
  /** Puts `entry` into `log` after every entry at or before its time. */
19
41
  export function insert(log, entry) {
20
42
  let i = log.length;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@msb235/blits-rapier",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Pairs Rapier rigid-body physics with a blits mix: voices push bodies, and each body's simulated pose blends with authored motion",
5
5
  "keywords": [
6
6
  "physics",
@@ -38,14 +38,14 @@
38
38
  "prepack": "rm -rf dist && npm run build"
39
39
  },
40
40
  "peerDependencies": {
41
- "@msb235/blits": "^0.18.0",
41
+ "@msb235/blits": "^0.20.0",
42
42
  "@dimforge/rapier2d-compat": ">=0.21.0 <1.0.0",
43
43
  "@dimforge/rapier3d-compat": ">=0.21.0 <1.0.0"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@dimforge/rapier2d-compat": "0.21.0",
47
47
  "@dimforge/rapier3d-compat": "0.21.0",
48
- "@msb235/blits": "0.18.0"
48
+ "@msb235/blits": "0.20.0"
49
49
  },
50
50
  "peerDependenciesMeta": {
51
51
  "@dimforge/rapier2d-compat": {