@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 +57 -0
- package/README.md +71 -13
- package/dist/channels.d.ts +22 -0
- package/dist/channels.js +13 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/physics.d.ts +18 -4
- package/dist/physics.js +206 -45
- package/dist/rapier.d.ts +9 -0
- package/dist/rapier.js +16 -0
- package/dist/world.d.ts +11 -6
- package/dist/world.js +22 -0
- package/package.json +3 -3
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,
|
|
78
|
-
per interval, so it runs at the same rate
|
|
79
|
-
|
|
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
|
|
83
|
-
|
|
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
|
|
89
|
-
|
|
90
|
-
-
|
|
91
|
-
`
|
|
92
|
-
|
|
93
|
-
|
|
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.
|
package/dist/channels.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
5
|
-
|
|
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`,
|
|
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
|
|
12
|
-
const
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
/**
|
|
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
|
+
"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.
|
|
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.
|
|
48
|
+
"@msb235/blits": "0.20.0"
|
|
49
49
|
},
|
|
50
50
|
"peerDependenciesMeta": {
|
|
51
51
|
"@dimforge/rapier2d-compat": {
|