@msb235/blits-rapier 0.1.0 → 0.3.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,32 @@ 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.3.0
8
+
9
+ ### Breaking
10
+
11
+ - `@msb235/blits` is a peer at `^0.18.0`, where it was `^0.17.0`. Built and tested against engine
12
+ 0.18.0; nothing here uses what that release changed.
13
+
14
+ ## 0.2.0
15
+
16
+ ### Breaking
17
+
18
+ - `@msb235/blits` is a peer at `^0.17.0`, where it was `^0.16.1`. Built and tested against engine
19
+ 0.17.0; nothing here uses what that release changed.
20
+
21
+ ### Added
22
+
23
+ - `physics` takes `weight`, a number or a signal, for every body's simulated voice. A signal hands
24
+ bodies between physics and authored poses with nothing written from the frame loop. Fading
25
+ `simulated` out ended it for good, so a body could not be handed back.
26
+
27
+ ### Fixed
28
+
29
+ - A body handed back to physics starts where it was shown, at the speed it was shown moving. The
30
+ world went on simulating it while an authored voice held it, so it jumped back to wherever the
31
+ simulation had taken it, such as against a wall that a push had sent it to.
32
+
7
33
  ## 0.1.0
8
34
 
9
35
  ### Added
package/README.md CHANGED
@@ -46,11 +46,14 @@ const { position, rotation } = sim.mix.probe(box);
46
46
  are read before the world steps and the pose after, which is why they are two subjects.
47
47
  - **`body.drive(spec)`** cues a voice on the controls; its weight scales the push.
48
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, at
50
- weight 1, in the role `sim.role`. A voice cued in that role is an alternative to the simulation,
51
- and the two crossfade by weight: fade `simulated` out and an authored pose in, and a body settles
52
- from physics into a scripted place. A voice outside the role adds on top of wherever physics has
53
- it.
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.
54
57
  - **`body.impulse(v)`** applies an impulse once, on the step after the mix's current time.
55
58
  **`body.drop()`** takes the body out of the world then. Both are logged at that time, so a seek
56
59
  back before it undoes them and playing forward through it does them again.
package/dist/physics.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type Handle, type Kit, type Mix, type MixOptions, type VoiceSpec } from '@msb235/blits';
1
+ import { type Handle, 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
4
  /** What `physics` takes: a mix's options, the Rapier module, and the world's gravity. */
@@ -7,13 +7,18 @@ export interface PhysicsOptions<H> extends MixOptions<H> {
7
7
  rapier: RapierModule;
8
8
  /** Default: `[0, -9.81]` or `[0, -9.81, 0]`. */
9
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>;
10
15
  }
11
16
  /** A rigid body: the mix's subject for its pose, with its controls beside it. */
12
17
  export interface Body<O, H> {
13
18
  readonly key: number;
14
19
  /** The subject whose `force` and `torque` push the body, read before the world steps. */
15
20
  readonly controls: object;
16
- /** The voice that shows the body where the world puts it, in `Physics.role`. Weight 1. */
21
+ /** The voice that shows the body where the world puts it, in `Physics.role`, at `PhysicsOptions.weight`. */
17
22
  readonly simulated: Handle<object>;
18
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. */
19
24
  cue(spec: Omit<VoiceSpec<object, O, H>, 'subjects'>): Handle<object>;
package/dist/physics.js CHANGED
@@ -2,9 +2,10 @@ import { mix, patch, } from '@msb235/blits';
2
2
  import { dimsOf } from './rapier.js';
3
3
  import { applyLog, copy, insert, worldOf } from './world.js';
4
4
  const ROLE = 'physics';
5
+ const copyOf = (r) => typeof r === 'number' ? r : [...r];
5
6
  /** Makes a mix that holds a Rapier world, stepped on its `stepMs` clock and replayed by its history. */
6
7
  export function physics(kit, opts) {
7
- const { rapier, gravity, ...rest } = opts;
8
+ const { rapier, gravity, weight, ...rest } = opts;
8
9
  const dims = dimsOf(rapier);
9
10
  const g = gravity ?? (dims.n === 2 ? [0, -9.81] : [0, -9.81, 0]);
10
11
  const m = mix(kit, { ...rest, stepMs: rest.stepMs ?? 1000 / 60 });
@@ -14,7 +15,7 @@ export function physics(kit, opts) {
14
15
  // at or after the step's end, looked up half a step early since replayed times can round apart.
15
16
  const pushLog = [];
16
17
  const keep = rest.history?.ms ?? 0;
17
- const pushesAt = (t) => {
18
+ const frameAt = (t) => {
18
19
  let lo = 0;
19
20
  let hi = pushLog.length;
20
21
  while (lo < hi) {
@@ -24,7 +25,7 @@ export function physics(kit, opts) {
24
25
  else
25
26
  hi = mid;
26
27
  }
27
- return (pushLog[lo] ?? pushLog[pushLog.length - 1])?.pushes ?? {};
28
+ return pushLog[lo] ?? pushLog[pushLog.length - 1];
28
29
  };
29
30
  let seen;
30
31
  let live;
@@ -49,7 +50,15 @@ export function physics(kit, opts) {
49
50
  const w = worldOf(s, rapier);
50
51
  w.timestep = dt / 1000;
51
52
  applyLog(s, w, log, setting.timestamp, dt / 2, dims);
52
- const pushes = pushesAt(setting.timestamp - dt / 2);
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
+ }
53
62
  const pushed = [];
54
63
  for (const k in pushes) {
55
64
  const handle = s.handles.get(+k);
@@ -82,7 +91,40 @@ export function physics(kit, opts) {
82
91
  }, { writes: ['position', 'rotation'] });
83
92
  const now = () => (Number.isNaN(m.timestamp) ? Number.NEGATIVE_INFINITY : m.timestamp);
84
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();
85
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
+ };
86
128
  let next = 0;
87
129
  return {
88
130
  mix: m,
@@ -100,7 +142,12 @@ export function physics(kit, opts) {
100
142
  key,
101
143
  controls,
102
144
  simulated: undefined,
103
- cue: (spec) => own(spec, body),
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
+ },
104
151
  drive: (spec) => {
105
152
  driven.add(body);
106
153
  return own(spec, controls);
@@ -114,6 +161,7 @@ export function physics(kit, opts) {
114
161
  patch: simulated,
115
162
  subjects: [body],
116
163
  role: ROLE,
164
+ ...(weight === undefined ? {} : { weight }),
117
165
  });
118
166
  return body;
119
167
  },
@@ -132,9 +180,17 @@ export function physics(kit, opts) {
132
180
  pushes[body.key] = [...p.force, ...torque];
133
181
  }
134
182
  const at = m.timestamp;
135
- while (pushLog.length > 0 && pushLog[pushLog.length - 1].at >= at)
183
+ let back = false;
184
+ while (pushLog.length > 0 && pushLog[pushLog.length - 1].at >= at) {
136
185
  pushLog.pop();
137
- pushLog.push({ at, pushes });
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 });
138
194
  let old = 0;
139
195
  while (old < pushLog.length - 1 && pushLog[old + 1].at < at - keep)
140
196
  old++;
@@ -142,6 +198,7 @@ export function physics(kit, opts) {
142
198
  pushLog.splice(0, old);
143
199
  m.probe(WORLD);
144
200
  live = seen;
201
+ holding = holdsAt(at);
145
202
  },
146
203
  world: () => (live === undefined ? undefined : worldOf(live, rapier)),
147
204
  };
package/dist/rapier.d.ts CHANGED
@@ -19,6 +19,10 @@ export interface RapierBody {
19
19
  applyImpulse(impulse: RapierVector, wake: boolean): void;
20
20
  resetForces(wake: boolean): void;
21
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;
22
26
  }
23
27
  /** What blits-rapier uses of a Rapier world, in either build. */
24
28
  export interface RapierWorld {
@@ -50,6 +54,13 @@ export interface Dims {
50
54
  rotation(body: RapierBody): number | number[];
51
55
  /** Adds `torque`, a number in 2D and three numbers in 3D. */
52
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[];
53
64
  }
54
65
  /** The 3D build where the module has `Vector3`, else the 2D. */
55
66
  export declare function dimsOf(rapier: RapierModule): Dims;
package/dist/rapier.js CHANGED
@@ -1,3 +1,4 @@
1
+ const speed = (now, before, dt) => now.map((x, i) => (before === undefined || dt <= 0 ? 0 : (x - (before[i] ?? x)) / (dt / 1000)));
1
2
  const two = {
2
3
  n: 2,
3
4
  vector: (v) => ({ x: v[0] ?? 0, y: v[1] ?? 0 }),
@@ -7,6 +8,15 @@ const two = {
7
8
  },
8
9
  rotation: (b) => b.rotation(),
9
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
+ },
10
20
  };
11
21
  const three = {
12
22
  n: 3,
@@ -20,7 +30,33 @@ const three = {
20
30
  return [q.x, q.y, q.z, q.w];
21
31
  },
22
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
+ },
23
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
+ }
24
60
  /** The 3D build where the module has `Vector3`, else the 2D. */
25
61
  export function dimsOf(rapier) {
26
62
  return 'Vector3' in rapier ? three : two;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@msb235/blits-rapier",
3
- "version": "0.1.0",
3
+ "version": "0.3.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.16.1",
41
+ "@msb235/blits": "^0.18.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.16.1"
48
+ "@msb235/blits": "0.18.0"
49
49
  },
50
50
  "peerDependenciesMeta": {
51
51
  "@dimforge/rapier2d-compat": {