@msb235/blits-rapier 0.0.0-stage → 0.2.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 +33 -0
- package/LICENSE +21 -0
- package/README.md +93 -2
- package/dist/channels.d.ts +23 -0
- package/dist/channels.js +15 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/physics.d.ts +48 -0
- package/dist/physics.js +205 -0
- package/dist/rapier.d.ts +66 -0
- package/dist/rapier.js +63 -0
- package/dist/world.d.ts +43 -0
- package/dist/world.js +57 -0
- package/package.json +56 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
This package follows [semver](https://semver.org). Below 1.0.0, a breaking change bumps the minor
|
|
4
|
+
version and everything else the patch. The release workflow refuses a `rapier-v*` tag with no
|
|
5
|
+
section here.
|
|
6
|
+
|
|
7
|
+
## 0.2.0
|
|
8
|
+
|
|
9
|
+
### Breaking
|
|
10
|
+
|
|
11
|
+
- `@msb235/blits` is a peer at `^0.17.0`, where it was `^0.16.1`. Built and tested against engine
|
|
12
|
+
0.17.0; nothing here uses what that release changed.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- `physics` takes `weight`, a number or a signal, for every body's simulated voice. A signal hands
|
|
17
|
+
bodies between physics and authored poses with nothing written from the frame loop. Fading
|
|
18
|
+
`simulated` out ended it for good, so a body could not be handed back.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- A body handed back to physics starts where it was shown, at the speed it was shown moving. The
|
|
23
|
+
world went on simulating it while an authored voice held it, so it jumped back to wherever the
|
|
24
|
+
simulation had taken it, such as against a wall that a push had sent it to.
|
|
25
|
+
|
|
26
|
+
## 0.1.0
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
- `physics`, which holds a Rapier world in a blits mix: bodies as subjects, `force` and `torque`
|
|
31
|
+
voices on each body's controls, a simulated voice in a role that crossfades with authored
|
|
32
|
+
motion, impulses and drops logged at mix time, and exact replay on a seek. 2D and 3D share the
|
|
33
|
+
API through `channels2` and `channels3`.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Michael Baker
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,94 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @msb235/blits-rapier
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Holds a [Rapier](https://rapier.rs) rigid-body world in a [blits](https://github.com/orochi235/blits)
|
|
4
|
+
mix. Voices push bodies around, each body's simulated pose is a voice that blends with authored
|
|
5
|
+
motion, and a seek, a loop or a debugger's scrub replays the world exactly. 2D and 3D share one API;
|
|
6
|
+
the Rapier module you pass decides which.
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
npm install @msb235/blits @msb235/blits-rapier @dimforge/rapier3d-compat # or rapier2d-compat
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
import RAPIER from '@dimforge/rapier3d-compat';
|
|
14
|
+
import { kit, patch } from '@msb235/blits';
|
|
15
|
+
import { channels3, type Pose3, physics } from '@msb235/blits-rapier';
|
|
16
|
+
import { createHistory } from '@weasel-js/history';
|
|
17
|
+
|
|
18
|
+
await RAPIER.init();
|
|
19
|
+
const sim = physics(kit<Pose3>(channels3), {
|
|
20
|
+
rapier: RAPIER,
|
|
21
|
+
history: { ms: 10_000, every: 250, tape: createHistory }, // only to seek
|
|
22
|
+
});
|
|
23
|
+
sim.body(RAPIER.RigidBodyDesc.fixed(), RAPIER.ColliderDesc.cuboid(10, 0.1, 10));
|
|
24
|
+
const box = sim.body(
|
|
25
|
+
RAPIER.RigidBodyDesc.dynamic().setTranslation(0, 4, 0),
|
|
26
|
+
RAPIER.ColliderDesc.cuboid(0.5, 0.5, 0.5),
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const gust = patch<object, Pose3>(1500, (phase) => ({ force: [Math.sin(phase * Math.PI) * 8, 0, 0] }), {
|
|
30
|
+
writes: ['force'],
|
|
31
|
+
});
|
|
32
|
+
box.drive({ patch: gust });
|
|
33
|
+
|
|
34
|
+
// each frame
|
|
35
|
+
sim.sync(now); // in place of sim.mix.sync
|
|
36
|
+
const { position, rotation } = sim.mix.probe(box);
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Bodies
|
|
40
|
+
|
|
41
|
+
- **`sim.body(desc, ...colliders)`** adds a body from Rapier's own descriptions, built with the
|
|
42
|
+
module you passed. Nothing else about Rapier is wrapped. The body joins the world on the step
|
|
43
|
+
after the mix's current time, or on the first frame when the mix has not synced yet.
|
|
44
|
+
- **A body is the mix's subject for its pose**, `position` and `rotation`: `sim.mix.probe(body)`
|
|
45
|
+
reads it. Its **`controls`** is a second subject, whose `force` and `torque` push the body. They
|
|
46
|
+
are read before the world steps and the pose after, which is why they are two subjects.
|
|
47
|
+
- **`body.drive(spec)`** cues a voice on the controls; its weight scales the push.
|
|
48
|
+
**`body.cue(spec)`** cues one on the pose. Neither spec names `subjects`.
|
|
49
|
+
- **`body.simulated`** is the `Handle` of the voice that shows the body where the world puts it, in
|
|
50
|
+
the role `sim.role`, at the weight `physics` was given (default 1). A voice cued in that role is
|
|
51
|
+
an alternative to the simulation, and the two crossfade by weight. To hand bodies to authored
|
|
52
|
+
poses and back, give `physics` a `weight` signal and the authored voices its complement, such as
|
|
53
|
+
`share(k, 0, 2)` and `share(k, 1, 2)` of one `level`. While a body's simulated voice is at weight 0
|
|
54
|
+
and a voice in the role holds it, the world puts the body where it is shown, moving as fast as
|
|
55
|
+
the shown pose moves, so handing it back starts the simulation from there. A voice outside the
|
|
56
|
+
role adds on top of wherever physics has it.
|
|
57
|
+
- **`body.impulse(v)`** applies an impulse once, on the step after the mix's current time.
|
|
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.
|
|
60
|
+
- **`sim.world()`** is the Rapier world as the last `sync` left it, for reading: raycasts, contact
|
|
61
|
+
queries, debugging draws. A change made to it directly is lost at the next seek.
|
|
62
|
+
|
|
63
|
+
## Channels
|
|
64
|
+
|
|
65
|
+
| Channel | 2D (`channels2`) | 3D (`channels3`) | On |
|
|
66
|
+
|---|---|---|---|
|
|
67
|
+
| `position` | `vec(2, sum())` | `vec(3, sum())` | the body |
|
|
68
|
+
| `rotation` | `angle()`, radians | `quat()`, `[x, y, z, w]` | the body |
|
|
69
|
+
| `force` | `vec(2, sum())` | `vec(3, sum())` | its controls |
|
|
70
|
+
| `torque` | `sum()` | `vec(3, sum())` | its controls |
|
|
71
|
+
|
|
72
|
+
Spread them into a kit with your own channels. Forces and torques are in Rapier's units, newtons
|
|
73
|
+
and newton-meters for a body whose mass you set in kilograms.
|
|
74
|
+
|
|
75
|
+
## The clock and seeking
|
|
76
|
+
|
|
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
|
|
80
|
+
every `every` ms and a seek replays from the nearest one. A copy is Rapier's snapshot, kept as
|
|
81
|
+
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.
|
|
84
|
+
|
|
85
|
+
Replay matches the first pass exactly on one machine. To match across machines, pass Rapier's
|
|
86
|
+
deterministic build.
|
|
87
|
+
|
|
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.
|
|
94
|
+
- Soft bodies, cloth and ropes.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type Channel, type Vec } from '@msb235/blits';
|
|
2
|
+
/** The channels a 2D world reads and writes: a body's pose, and the push on its controls. */
|
|
3
|
+
export interface Pose2 {
|
|
4
|
+
position: Vec<2>;
|
|
5
|
+
rotation: number;
|
|
6
|
+
force: Vec<2>;
|
|
7
|
+
torque: number;
|
|
8
|
+
}
|
|
9
|
+
/** The channels a 3D world reads and writes: a body's pose, and the push on its controls. */
|
|
10
|
+
export interface Pose3 {
|
|
11
|
+
position: Vec<3>;
|
|
12
|
+
rotation: Vec<4>;
|
|
13
|
+
force: Vec<3>;
|
|
14
|
+
torque: Vec<3>;
|
|
15
|
+
}
|
|
16
|
+
/** A 2D world's channels, to spread into a kit. */
|
|
17
|
+
export declare const channels2: {
|
|
18
|
+
[K in keyof Pose2]: Channel<Pose2[K]>;
|
|
19
|
+
};
|
|
20
|
+
/** A 3D world's channels, to spread into a kit. */
|
|
21
|
+
export declare const channels3: {
|
|
22
|
+
[K in keyof Pose3]: Channel<Pose3[K]>;
|
|
23
|
+
};
|
package/dist/channels.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { angle, quat, sum, vec } from '@msb235/blits';
|
|
2
|
+
/** A 2D world's channels, to spread into a kit. */
|
|
3
|
+
export const channels2 = {
|
|
4
|
+
position: vec(2, sum()),
|
|
5
|
+
rotation: angle(),
|
|
6
|
+
force: vec(2, sum()),
|
|
7
|
+
torque: sum(),
|
|
8
|
+
};
|
|
9
|
+
/** A 3D world's channels, to spread into a kit. */
|
|
10
|
+
export const channels3 = {
|
|
11
|
+
position: vec(3, sum()),
|
|
12
|
+
rotation: quat(),
|
|
13
|
+
force: vec(3, sum()),
|
|
14
|
+
torque: vec(3, sum()),
|
|
15
|
+
};
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type Handle, type Kit, type Mix, type MixOptions, type Signal, type VoiceSpec } from '@msb235/blits';
|
|
2
|
+
import type { Pose2, Pose3 } from './channels.js';
|
|
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> {
|
|
6
|
+
/** An initialized Rapier module. `@dimforge/rapier3d-compat` makes a 3D world, `rapier2d-compat` a 2D one. */
|
|
7
|
+
rapier: RapierModule;
|
|
8
|
+
/** Default: `[0, -9.81]` or `[0, -9.81, 0]`. */
|
|
9
|
+
gravity?: readonly number[];
|
|
10
|
+
/**
|
|
11
|
+
* The weight of every body's simulated voice, a signal reading each body. A signal hands a body
|
|
12
|
+
* between physics and authored motion with no weight written from the frame loop. Default 1.
|
|
13
|
+
*/
|
|
14
|
+
weight?: number | Signal<object, H>;
|
|
15
|
+
}
|
|
16
|
+
/** A rigid body: the mix's subject for its pose, with its controls beside it. */
|
|
17
|
+
export interface Body<O, H> {
|
|
18
|
+
readonly key: number;
|
|
19
|
+
/** The subject whose `force` and `torque` push the body, read before the world steps. */
|
|
20
|
+
readonly controls: object;
|
|
21
|
+
/** The voice that shows the body where the world puts it, in `Physics.role`, at `PhysicsOptions.weight`. */
|
|
22
|
+
readonly simulated: Handle<object>;
|
|
23
|
+
/** Cues a voice on the body's pose. In `Physics.role` it is an alternative to the simulation; outside it, it adds on top. */
|
|
24
|
+
cue(spec: Omit<VoiceSpec<object, O, H>, 'subjects'>): Handle<object>;
|
|
25
|
+
/** Cues a voice on the body's controls. */
|
|
26
|
+
drive(spec: Omit<VoiceSpec<object, O, H>, 'subjects'>): Handle<object>;
|
|
27
|
+
/** Applies an impulse once, on the step after the mix's current time, and again on any replay through it. */
|
|
28
|
+
impulse(v: readonly number[]): void;
|
|
29
|
+
/** Takes the body out of the world on the step after the mix's current time. A seek back before then brings it back. */
|
|
30
|
+
drop(): void;
|
|
31
|
+
}
|
|
32
|
+
/** A Rapier world held in a blits mix. */
|
|
33
|
+
export interface Physics<O, H> {
|
|
34
|
+
readonly mix: Mix<object, O, H>;
|
|
35
|
+
/** The role every body's simulated voice plays. */
|
|
36
|
+
readonly role: string;
|
|
37
|
+
/**
|
|
38
|
+
* Adds a body made from Rapier's descriptions, on the step after the mix's current time, or on
|
|
39
|
+
* the first step before the mix has synced.
|
|
40
|
+
*/
|
|
41
|
+
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`. */
|
|
43
|
+
sync(now: number): void;
|
|
44
|
+
/** The world as it stands after the last `sync`, for reading. A change made to it is lost at the next seek. */
|
|
45
|
+
world(): RapierWorld | undefined;
|
|
46
|
+
}
|
|
47
|
+
/** Makes a mix that holds a Rapier world, stepped on its `stepMs` clock and replayed by its history. */
|
|
48
|
+
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
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { mix, patch, } from '@msb235/blits';
|
|
2
|
+
import { dimsOf } from './rapier.js';
|
|
3
|
+
import { applyLog, copy, insert, worldOf } from './world.js';
|
|
4
|
+
const ROLE = 'physics';
|
|
5
|
+
const copyOf = (r) => typeof r === 'number' ? r : [...r];
|
|
6
|
+
/** Makes a mix that holds a Rapier world, stepped on its `stepMs` clock and replayed by its history. */
|
|
7
|
+
export function physics(kit, opts) {
|
|
8
|
+
const { rapier, gravity, weight, ...rest } = opts;
|
|
9
|
+
const dims = dimsOf(rapier);
|
|
10
|
+
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 = [];
|
|
13
|
+
// Each frame's pushes at its mix time. A seek replays steps on what the host reads now, so the
|
|
14
|
+
// world reads the pushes of the frame that caused each step from here instead: the first frame
|
|
15
|
+
// at or after the step's end, looked up half a step early since replayed times can round apart.
|
|
16
|
+
const pushLog = [];
|
|
17
|
+
const keep = rest.history?.ms ?? 0;
|
|
18
|
+
const frameAt = (t) => {
|
|
19
|
+
let lo = 0;
|
|
20
|
+
let hi = pushLog.length;
|
|
21
|
+
while (lo < hi) {
|
|
22
|
+
const mid = (lo + hi) >> 1;
|
|
23
|
+
if (pushLog[mid].at < t)
|
|
24
|
+
lo = mid + 1;
|
|
25
|
+
else
|
|
26
|
+
hi = mid;
|
|
27
|
+
}
|
|
28
|
+
return pushLog[lo] ?? pushLog[pushLog.length - 1];
|
|
29
|
+
};
|
|
30
|
+
let seen;
|
|
31
|
+
let live;
|
|
32
|
+
const world = patch(0, (_p, _s, setting) => {
|
|
33
|
+
const s = setting.state;
|
|
34
|
+
// A world first read before it has stepped holds the bodies added before then.
|
|
35
|
+
if (Number.isNaN(s.t)) {
|
|
36
|
+
applyLog(s, worldOf(s, rapier), log, setting.timestamp, 0, dims);
|
|
37
|
+
s.t = setting.timestamp;
|
|
38
|
+
}
|
|
39
|
+
seen = s;
|
|
40
|
+
return {};
|
|
41
|
+
}, {
|
|
42
|
+
writes: [],
|
|
43
|
+
state: () => ({
|
|
44
|
+
bytes: null,
|
|
45
|
+
world: new rapier.World(dims.vector(g)),
|
|
46
|
+
handles: new Map(),
|
|
47
|
+
t: Number.NaN,
|
|
48
|
+
}),
|
|
49
|
+
step: (s, dt, _subject, setting) => {
|
|
50
|
+
const w = worldOf(s, rapier);
|
|
51
|
+
w.timestep = dt / 1000;
|
|
52
|
+
applyLog(s, w, log, setting.timestamp, dt / 2, dims);
|
|
53
|
+
const frame = frameAt(setting.timestamp - dt / 2);
|
|
54
|
+
const pushes = frame?.pushes ?? {};
|
|
55
|
+
const holds = frame?.holds ?? {};
|
|
56
|
+
for (const k in holds) {
|
|
57
|
+
const handle = s.handles.get(+k);
|
|
58
|
+
const h = holds[k];
|
|
59
|
+
if (handle !== undefined && h !== undefined)
|
|
60
|
+
dims.place(w.getRigidBody(handle), h.now, h.before, h.dt);
|
|
61
|
+
}
|
|
62
|
+
const pushed = [];
|
|
63
|
+
for (const k in pushes) {
|
|
64
|
+
const handle = s.handles.get(+k);
|
|
65
|
+
if (handle === undefined)
|
|
66
|
+
continue;
|
|
67
|
+
const v = pushes[k];
|
|
68
|
+
const b = w.getRigidBody(handle);
|
|
69
|
+
b.addForce(dims.vector(v), true);
|
|
70
|
+
dims.torque(b, dims.n === 2 ? (v[2] ?? 0) : v.slice(3));
|
|
71
|
+
pushed.push(handle);
|
|
72
|
+
}
|
|
73
|
+
w.step();
|
|
74
|
+
for (const handle of pushed) {
|
|
75
|
+
const b = w.getRigidBody(handle);
|
|
76
|
+
b.resetForces(false);
|
|
77
|
+
b.resetTorques(false);
|
|
78
|
+
}
|
|
79
|
+
s.t = setting.timestamp;
|
|
80
|
+
},
|
|
81
|
+
clone: copy,
|
|
82
|
+
});
|
|
83
|
+
const WORLD = {};
|
|
84
|
+
let started = false;
|
|
85
|
+
const simulated = patch(0, (_p, subject) => {
|
|
86
|
+
const handle = live?.handles.get(subject.key);
|
|
87
|
+
if (live === undefined || handle === undefined)
|
|
88
|
+
return {};
|
|
89
|
+
const b = worldOf(live, rapier).getRigidBody(handle);
|
|
90
|
+
return { position: dims.position(b), rotation: dims.rotation(b) };
|
|
91
|
+
}, { writes: ['position', 'rotation'] });
|
|
92
|
+
const now = () => (Number.isNaN(m.timestamp) ? Number.NEGATIVE_INFINITY : m.timestamp);
|
|
93
|
+
const driven = new Set();
|
|
94
|
+
// Each body's voices in the simulation's role besides its own, and its pose the last frame they held it.
|
|
95
|
+
const authored = new Map();
|
|
96
|
+
const held = new Map();
|
|
97
|
+
const out = m.pose();
|
|
98
|
+
const shown = m.pose();
|
|
99
|
+
// Read at the end of each frame, for the next frame's steps to apply.
|
|
100
|
+
let holding = {};
|
|
101
|
+
const holdsAt = (at) => {
|
|
102
|
+
const holds = {};
|
|
103
|
+
for (const [body, voices] of authored) {
|
|
104
|
+
const playing = voices.filter((v) => v.state !== 'done');
|
|
105
|
+
if (playing.length === 0) {
|
|
106
|
+
authored.delete(body);
|
|
107
|
+
held.delete(body);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (playing.length < voices.length)
|
|
111
|
+
authored.set(body, playing);
|
|
112
|
+
const p = m.probe(body, shown);
|
|
113
|
+
// Shown with none of the simulation, the body is put where it is shown, so the simulation
|
|
114
|
+
// takes it back from there.
|
|
115
|
+
if (!live?.handles.has(body.key) ||
|
|
116
|
+
body.simulated.weightOf(body) > 0 ||
|
|
117
|
+
!playing.some((v) => v.weightOf(body) > 0)) {
|
|
118
|
+
held.delete(body);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const now = { position: [...p.position], rotation: copyOf(p.rotation) };
|
|
122
|
+
const last = held.get(body);
|
|
123
|
+
holds[body.key] = { now, before: last?.pose, dt: last === undefined ? 0 : at - last.at };
|
|
124
|
+
held.set(body, { pose: now, at });
|
|
125
|
+
}
|
|
126
|
+
return holds;
|
|
127
|
+
};
|
|
128
|
+
let next = 0;
|
|
129
|
+
return {
|
|
130
|
+
mix: m,
|
|
131
|
+
role: ROLE,
|
|
132
|
+
body(desc, ...colliders) {
|
|
133
|
+
const key = next++;
|
|
134
|
+
insert(log, { at: now(), key, kind: 'add', desc, colliders });
|
|
135
|
+
const controls = {};
|
|
136
|
+
const own = (spec, subject) => {
|
|
137
|
+
if ('subjects' in spec)
|
|
138
|
+
throw new Error('blits-rapier: a body cues on itself; its spec names no subjects');
|
|
139
|
+
return m.cue({ ...spec, subjects: [subject] });
|
|
140
|
+
};
|
|
141
|
+
const body = {
|
|
142
|
+
key,
|
|
143
|
+
controls,
|
|
144
|
+
simulated: undefined,
|
|
145
|
+
cue: (spec) => {
|
|
146
|
+
const h = own(spec, body);
|
|
147
|
+
if (spec.role === ROLE)
|
|
148
|
+
authored.set(body, [...(authored.get(body) ?? []), h]);
|
|
149
|
+
return h;
|
|
150
|
+
},
|
|
151
|
+
drive: (spec) => {
|
|
152
|
+
driven.add(body);
|
|
153
|
+
return own(spec, controls);
|
|
154
|
+
},
|
|
155
|
+
impulse: (v) => insert(log, { at: now(), key, kind: 'impulse', v: [...v] }),
|
|
156
|
+
drop: () => {
|
|
157
|
+
insert(log, { at: now(), key, kind: 'drop' });
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
body.simulated = m.cue({
|
|
161
|
+
patch: simulated,
|
|
162
|
+
subjects: [body],
|
|
163
|
+
role: ROLE,
|
|
164
|
+
...(weight === undefined ? {} : { weight }),
|
|
165
|
+
});
|
|
166
|
+
return body;
|
|
167
|
+
},
|
|
168
|
+
sync(t) {
|
|
169
|
+
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);
|
|
202
|
+
},
|
|
203
|
+
world: () => (live === undefined ? undefined : worldOf(live, rapier)),
|
|
204
|
+
};
|
|
205
|
+
}
|
package/dist/rapier.d.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/** A vector as Rapier takes and gives it: `z` in 3D only. */
|
|
2
|
+
export interface RapierVector {
|
|
3
|
+
x: number;
|
|
4
|
+
y: number;
|
|
5
|
+
z?: number;
|
|
6
|
+
}
|
|
7
|
+
/** What blits-rapier uses of a Rapier rigid body, in either build. */
|
|
8
|
+
export interface RapierBody {
|
|
9
|
+
readonly handle: number;
|
|
10
|
+
translation(): RapierVector;
|
|
11
|
+
rotation(): number | {
|
|
12
|
+
x: number;
|
|
13
|
+
y: number;
|
|
14
|
+
z: number;
|
|
15
|
+
w: number;
|
|
16
|
+
};
|
|
17
|
+
addForce(force: RapierVector, wake: boolean): void;
|
|
18
|
+
addTorque(torque: never, wake: boolean): void;
|
|
19
|
+
applyImpulse(impulse: RapierVector, wake: boolean): void;
|
|
20
|
+
resetForces(wake: boolean): void;
|
|
21
|
+
resetTorques(wake: boolean): void;
|
|
22
|
+
setTranslation(v: RapierVector, wake: boolean): void;
|
|
23
|
+
setRotation(r: never, wake: boolean): void;
|
|
24
|
+
setLinvel(v: RapierVector, wake: boolean): void;
|
|
25
|
+
setAngvel(v: never, wake: boolean): void;
|
|
26
|
+
}
|
|
27
|
+
/** What blits-rapier uses of a Rapier world, in either build. */
|
|
28
|
+
export interface RapierWorld {
|
|
29
|
+
timestep: number;
|
|
30
|
+
step(): void;
|
|
31
|
+
takeSnapshot(): Uint8Array;
|
|
32
|
+
createRigidBody(desc: never): RapierBody;
|
|
33
|
+
createCollider(desc: never, parent?: never): unknown;
|
|
34
|
+
getRigidBody(handle: number): RapierBody;
|
|
35
|
+
removeRigidBody(body: never): void;
|
|
36
|
+
free(): void;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* A Rapier module, `@dimforge/rapier2d-compat` or `@dimforge/rapier3d-compat` or a build like
|
|
40
|
+
* them, already initialized. Which one is passed decides the dimensions.
|
|
41
|
+
*/
|
|
42
|
+
export interface RapierModule {
|
|
43
|
+
World: {
|
|
44
|
+
new (gravity: never): RapierWorld;
|
|
45
|
+
restoreSnapshot(data: Uint8Array): RapierWorld;
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/** The arithmetic that differs between the 2D and 3D builds, behind one shape. */
|
|
49
|
+
export interface Dims {
|
|
50
|
+
readonly n: 2 | 3;
|
|
51
|
+
vector(v: readonly number[]): RapierVector;
|
|
52
|
+
position(body: RapierBody): number[];
|
|
53
|
+
/** An angle in 2D, `[x, y, z, w]` in 3D: what the `rotation` channel holds. */
|
|
54
|
+
rotation(body: RapierBody): number | number[];
|
|
55
|
+
/** Adds `torque`, a number in 2D and three numbers in 3D. */
|
|
56
|
+
torque(body: RapierBody, torque: number | readonly number[]): void;
|
|
57
|
+
/** Puts the body at a pose, moving at the speed it took to get there from `before` over `dt` ms. */
|
|
58
|
+
place(body: RapierBody, now: Placed, before: Placed | undefined, dt: number): void;
|
|
59
|
+
}
|
|
60
|
+
/** A body's position and rotation as the channels hold them. */
|
|
61
|
+
export interface Placed {
|
|
62
|
+
position: readonly number[];
|
|
63
|
+
rotation: number | readonly number[];
|
|
64
|
+
}
|
|
65
|
+
/** The 3D build where the module has `Vector3`, else the 2D. */
|
|
66
|
+
export declare function dimsOf(rapier: RapierModule): Dims;
|
package/dist/rapier.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
const speed = (now, before, dt) => now.map((x, i) => (before === undefined || dt <= 0 ? 0 : (x - (before[i] ?? x)) / (dt / 1000)));
|
|
2
|
+
const two = {
|
|
3
|
+
n: 2,
|
|
4
|
+
vector: (v) => ({ x: v[0] ?? 0, y: v[1] ?? 0 }),
|
|
5
|
+
position: (b) => {
|
|
6
|
+
const t = b.translation();
|
|
7
|
+
return [t.x, t.y];
|
|
8
|
+
},
|
|
9
|
+
rotation: (b) => b.rotation(),
|
|
10
|
+
torque: (b, t) => b.addTorque((typeof t === 'number' ? t : (t[0] ?? 0)), true),
|
|
11
|
+
place: (b, now, before, dt) => {
|
|
12
|
+
const r = now.rotation;
|
|
13
|
+
b.setTranslation(two.vector(now.position), true);
|
|
14
|
+
b.setRotation(r, true);
|
|
15
|
+
b.setLinvel(two.vector(speed(now.position, before?.position, dt)), true);
|
|
16
|
+
let turn = before === undefined ? 0 : r - before.rotation;
|
|
17
|
+
turn -= 2 * Math.PI * Math.round(turn / (2 * Math.PI));
|
|
18
|
+
b.setAngvel((dt > 0 ? turn / (dt / 1000) : 0), true);
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
const three = {
|
|
22
|
+
n: 3,
|
|
23
|
+
vector: (v) => ({ x: v[0] ?? 0, y: v[1] ?? 0, z: v[2] ?? 0 }),
|
|
24
|
+
position: (b) => {
|
|
25
|
+
const t = b.translation();
|
|
26
|
+
return [t.x, t.y, t.z ?? 0];
|
|
27
|
+
},
|
|
28
|
+
rotation: (b) => {
|
|
29
|
+
const q = b.rotation();
|
|
30
|
+
return [q.x, q.y, q.z, q.w];
|
|
31
|
+
},
|
|
32
|
+
torque: (b, t) => b.addTorque(three.vector(typeof t === 'number' ? [0, 0, t] : t), true),
|
|
33
|
+
place: (b, now, before, dt) => {
|
|
34
|
+
const [x, y, z, w] = now.rotation;
|
|
35
|
+
b.setTranslation(three.vector(now.position), true);
|
|
36
|
+
b.setRotation({ x, y, z, w }, true);
|
|
37
|
+
b.setLinvel(three.vector(speed(now.position, before?.position, dt)), true);
|
|
38
|
+
b.setAngvel(three.vector(spin(now.rotation, before?.rotation, dt)), true);
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
/** The angular velocity turning `before` into `now` over `dt` ms, both `[x, y, z, w]`. */
|
|
42
|
+
function spin(now, before, dt) {
|
|
43
|
+
if (before === undefined || dt <= 0)
|
|
44
|
+
return [0, 0, 0];
|
|
45
|
+
const [ax, ay, az, aw] = now;
|
|
46
|
+
const [bx, by, bz, bw] = before;
|
|
47
|
+
// now · conj(before), taken the short way round.
|
|
48
|
+
let x = bw * ax - bx * aw - by * az + bz * ay;
|
|
49
|
+
let y = bw * ay - by * aw - bz * ax + bx * az;
|
|
50
|
+
let z = bw * az - bz * aw - bx * ay + by * ax;
|
|
51
|
+
let w = bw * aw + bx * ax + by * ay + bz * az;
|
|
52
|
+
if (w < 0)
|
|
53
|
+
[x, y, z, w] = [-x, -y, -z, -w];
|
|
54
|
+
const s = Math.hypot(x, y, z);
|
|
55
|
+
if (s < 1e-12)
|
|
56
|
+
return [0, 0, 0];
|
|
57
|
+
const k = (2 * Math.atan2(s, w)) / s / (dt / 1000);
|
|
58
|
+
return [x * k, y * k, z * k];
|
|
59
|
+
}
|
|
60
|
+
/** The 3D build where the module has `Vector3`, else the 2D. */
|
|
61
|
+
export function dimsOf(rapier) {
|
|
62
|
+
return 'Vector3' in rapier ? three : two;
|
|
63
|
+
}
|
package/dist/world.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Dims, RapierModule, RapierWorld } from './rapier.js';
|
|
2
|
+
/**
|
|
3
|
+
* A world as the world subject's state. A copy for history holds only `bytes`, Rapier's snapshot,
|
|
4
|
+
* so a copy blits lets go of is plain memory; `worldOf` rebuilds the world on first use.
|
|
5
|
+
*/
|
|
6
|
+
export interface State {
|
|
7
|
+
bytes: Uint8Array | null;
|
|
8
|
+
world: RapierWorld | null;
|
|
9
|
+
/** Each present body's Rapier handle, by its key. */
|
|
10
|
+
handles: Map<number, number>;
|
|
11
|
+
/** The mix time of the last step, NaN before the first. */
|
|
12
|
+
t: number;
|
|
13
|
+
}
|
|
14
|
+
/** Something done to the world at a mix time, applied by whichever step reaches it, on every replay. */
|
|
15
|
+
export type Entry = {
|
|
16
|
+
at: number;
|
|
17
|
+
key: number;
|
|
18
|
+
kind: 'add';
|
|
19
|
+
desc: unknown;
|
|
20
|
+
colliders: readonly unknown[];
|
|
21
|
+
} | {
|
|
22
|
+
at: number;
|
|
23
|
+
key: number;
|
|
24
|
+
kind: 'drop';
|
|
25
|
+
} | {
|
|
26
|
+
at: number;
|
|
27
|
+
key: number;
|
|
28
|
+
kind: 'impulse';
|
|
29
|
+
v: readonly number[];
|
|
30
|
+
};
|
|
31
|
+
/** The state's world, rebuilt from its snapshot where it has none yet. */
|
|
32
|
+
export declare function worldOf(state: State, rapier: RapierModule): RapierWorld;
|
|
33
|
+
/** A copy sharing nothing with `state`, holding the world as a snapshot. */
|
|
34
|
+
export declare function copy(state: State): State;
|
|
35
|
+
/** Puts `entry` into `log` after every entry at or before its time. */
|
|
36
|
+
export declare function insert(log: Entry[], entry: Entry): void;
|
|
37
|
+
/**
|
|
38
|
+
* Applies the entries a step from the state's time to `to` passes: those at or after the state's
|
|
39
|
+
* time and before `to`, or every one before `to` on a state never stepped. An entry logged at the
|
|
40
|
+
* time the world last stepped to applies on the step after. Both ends are taken `slack` early: a
|
|
41
|
+
* replayed step's times can differ from the first pass's by rounding.
|
|
42
|
+
*/
|
|
43
|
+
export declare function applyLog(state: State, world: RapierWorld, log: readonly Entry[], to: number, slack: number, dims: Dims): void;
|
package/dist/world.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/** The state's world, rebuilt from its snapshot where it has none yet. */
|
|
2
|
+
export function worldOf(state, rapier) {
|
|
3
|
+
if (state.world === null) {
|
|
4
|
+
state.world = rapier.World.restoreSnapshot(state.bytes);
|
|
5
|
+
state.bytes = null;
|
|
6
|
+
}
|
|
7
|
+
return state.world;
|
|
8
|
+
}
|
|
9
|
+
/** A copy sharing nothing with `state`, holding the world as a snapshot. */
|
|
10
|
+
export function copy(state) {
|
|
11
|
+
return {
|
|
12
|
+
bytes: state.world === null ? state.bytes : state.world.takeSnapshot(),
|
|
13
|
+
world: null,
|
|
14
|
+
handles: new Map(state.handles),
|
|
15
|
+
t: state.t,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/** Puts `entry` into `log` after every entry at or before its time. */
|
|
19
|
+
export function insert(log, entry) {
|
|
20
|
+
let i = log.length;
|
|
21
|
+
while (i > 0 && log[i - 1].at > entry.at)
|
|
22
|
+
i--;
|
|
23
|
+
log.splice(i, 0, entry);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Applies the entries a step from the state's time to `to` passes: those at or after the state's
|
|
27
|
+
* time and before `to`, or every one before `to` on a state never stepped. An entry logged at the
|
|
28
|
+
* time the world last stepped to applies on the step after. Both ends are taken `slack` early: a
|
|
29
|
+
* replayed step's times can differ from the first pass's by rounding.
|
|
30
|
+
*/
|
|
31
|
+
export function applyLog(state, world, log, to, slack, dims) {
|
|
32
|
+
const from = state.t - slack;
|
|
33
|
+
for (const e of log) {
|
|
34
|
+
if (e.at >= to - slack)
|
|
35
|
+
break;
|
|
36
|
+
if (e.at < from)
|
|
37
|
+
continue;
|
|
38
|
+
const handle = state.handles.get(e.key);
|
|
39
|
+
if (e.kind === 'add') {
|
|
40
|
+
if (handle !== undefined)
|
|
41
|
+
continue;
|
|
42
|
+
const body = world.createRigidBody(e.desc);
|
|
43
|
+
for (const c of e.colliders)
|
|
44
|
+
world.createCollider(c, body);
|
|
45
|
+
state.handles.set(e.key, body.handle);
|
|
46
|
+
}
|
|
47
|
+
else if (handle === undefined) {
|
|
48
|
+
}
|
|
49
|
+
else if (e.kind === 'drop') {
|
|
50
|
+
world.removeRigidBody(world.getRigidBody(handle));
|
|
51
|
+
state.handles.delete(e.key);
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
world.getRigidBody(handle).applyImpulse(dims.vector(e.v), true);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,58 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@msb235/blits-rapier",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
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
|
+
"keywords": [
|
|
6
|
+
"physics",
|
|
7
|
+
"rapier",
|
|
8
|
+
"blits",
|
|
9
|
+
"animation"
|
|
10
|
+
],
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"author": "orochi235",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/orochi235/blits.git",
|
|
16
|
+
"directory": "packages/rapier"
|
|
17
|
+
},
|
|
18
|
+
"homepage": "https://github.com/orochi235/blits/tree/main/packages/rapier#readme",
|
|
19
|
+
"bugs": "https://github.com/orochi235/blits/issues",
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"type": "module",
|
|
24
|
+
"exports": {
|
|
25
|
+
".": {
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"default": "./dist/index.js"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"dist",
|
|
32
|
+
"!dist/.tsbuildinfo",
|
|
33
|
+
"CHANGELOG.md",
|
|
34
|
+
"LICENSE"
|
|
35
|
+
],
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsc -b",
|
|
38
|
+
"prepack": "rm -rf dist && npm run build"
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"@msb235/blits": "^0.17.0",
|
|
42
|
+
"@dimforge/rapier2d-compat": ">=0.21.0 <1.0.0",
|
|
43
|
+
"@dimforge/rapier3d-compat": ">=0.21.0 <1.0.0"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@dimforge/rapier2d-compat": "0.21.0",
|
|
47
|
+
"@dimforge/rapier3d-compat": "0.21.0",
|
|
48
|
+
"@msb235/blits": "0.17.0"
|
|
49
|
+
},
|
|
50
|
+
"peerDependenciesMeta": {
|
|
51
|
+
"@dimforge/rapier2d-compat": {
|
|
52
|
+
"optional": true
|
|
53
|
+
},
|
|
54
|
+
"@dimforge/rapier3d-compat": {
|
|
55
|
+
"optional": true
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|