@daneren2005/shared-memory-physics 0.0.1 → 1.1.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/README.md +297 -27
- package/dist/components/interpolation-component.d.ts +32 -0
- package/dist/components/registry.d.ts +12 -0
- package/dist/index.d.ts +13 -5
- package/dist/index.js +230 -117
- package/dist/index.js.map +1 -1
- package/dist/math/shapes.d.ts +0 -1
- package/dist/systems/interpolation-system.d.ts +10 -0
- package/dist/systems/interpolation-update.d.ts +4 -0
- package/dist/systems/physics-system.d.ts +9 -4
- package/dist/systems/physics-update.d.ts +9 -4
- package/dist/systems/spatial-index.d.ts +23 -0
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -14,11 +14,12 @@ npm install @daneren2005/shared-memory-physics @daneren2005/shared-memory-ecs @d
|
|
|
14
14
|
|
|
15
15
|
## Components
|
|
16
16
|
|
|
17
|
-
| Component
|
|
18
|
-
|
|
|
19
|
-
| `transform`
|
|
20
|
-
| `velocity`
|
|
21
|
-
| `body`
|
|
17
|
+
| Component | Config | Serialization | Block |
|
|
18
|
+
| --------------- | -------------------------------------------- | -------------------------- | ---------------------------------------------- |
|
|
19
|
+
| `transform` | `width`, `height`, `angle?` / `radius` | `x`, `y` | `Float32Array` – where, how big, facing |
|
|
20
|
+
| `velocity` | – | `velocityX?`, `velocityY?` | `Float32Array` – world units per second |
|
|
21
|
+
| `body` | `shape?`, `collideCategory?`, `collideMask?` | – | `Uint32Array` – what shape, what collides with |
|
|
22
|
+
| `interpolation` | `interpolate` | – | `Float32Array` – where to *draw* it |
|
|
22
23
|
|
|
23
24
|
Entity configs are flat and shared across every component, so velocity is keyed as `velocityX` / `velocityY`
|
|
24
25
|
rather than `x` / `y` (which already belong to the transform). Either velocity axis on its own is enough to
|
|
@@ -36,6 +37,10 @@ nothing here writes it - it is yours to set, usually from the heading, and colli
|
|
|
36
37
|
`height` are the unrotated size; a box with no area never overlaps anything, not even another box sharing its
|
|
37
38
|
exact position.
|
|
38
39
|
|
|
40
|
+
`interpolation` is where to **draw** an entity, which stops being the same thing as where it is the moment
|
|
41
|
+
physics runs less often than the screen refreshes. It is opt-in per entity (`interpolate: true`) and filled in
|
|
42
|
+
by [`InterpolationSystem`](#interpolation) - see that section for what it costs and what it buys.
|
|
43
|
+
|
|
39
44
|
The `body` is what makes an entity collidable, and a size loads one just as it loads a transform - so
|
|
40
45
|
everything with a place in the world collides with everything else until you say otherwise. All of it is
|
|
41
46
|
defining config out of your own template, which is why `save` returns nothing for it: a reloaded entity gets
|
|
@@ -121,17 +126,77 @@ world.update(1000); // one second: the entity above is now at x = 10
|
|
|
121
126
|
`elapsedTime` is in milliseconds (what `BaseWorld#update` is driven with) and velocity is in world units per
|
|
122
127
|
**second**, so a run covering 16ms moves an entity 16/1000ths of its velocity.
|
|
123
128
|
|
|
124
|
-
|
|
125
|
-
|
|
129
|
+
### The step
|
|
130
|
+
|
|
131
|
+
`PhysicsSystem` runs on a fixed step of **50ms** unless you say otherwise (`DEFAULT_PHYSICS_STEP_MS`). Collision
|
|
132
|
+
is by a distance the whole of it, so running it 20 times a second rather than 60 is two thirds less of the frame
|
|
133
|
+
spent on it for a simulation that is no less correct - only coarser. What that costs is smoothness, which is
|
|
134
|
+
what [`InterpolationSystem`](#interpolation) is for.
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
new PhysicsSystem(world, { deltaBetweenRuns: 0 }); // back to every frame
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
The step has one real ceiling: a move is swept where it *ends*, so **a longer step is a longer move, and a move
|
|
141
|
+
longer than the thing in its way is thick goes clean through it**. At 50ms an entity crossing 300 units a second
|
|
142
|
+
covers 15 of them per step, so anything it must be stopped by has to be thicker than that. A game with genuinely
|
|
143
|
+
fast entities either keeps the step short or makes its walls thick.
|
|
144
|
+
|
|
145
|
+
The system accumulates frame time and runs once it has a step's worth, so a 16ms frame runs physics on roughly
|
|
146
|
+
every third one and hands it the whole 50ms. Nothing is lost and nothing is run twice.
|
|
147
|
+
|
|
148
|
+
`PhysicsSystem` also stamps each run with a `tick`, which is what the interpolation block is published under.
|
|
149
|
+
A game that subclasses the system to attach per-run data of its own **must call `super.addDataToWorld(world)`**:
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
class GamePhysicsSystem extends PhysicsSystem<Components, GameComponents, GameWorld> {
|
|
153
|
+
addDataToWorld(world: GameWorld): void {
|
|
154
|
+
super.addDataToWorld(world); // without this, nothing gets a tick
|
|
155
|
+
world.bounds = this.bounds;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
`GameWorld` extends `PhysicsWorld` (which is `ComponentSystemWorld` plus that `tick`) rather than
|
|
161
|
+
`ComponentSystemWorld` directly.
|
|
162
|
+
|
|
163
|
+
A run's moves are reported back to the main thread as a single `position-updated` event **on the system**,
|
|
164
|
+
carrying the ids of everything that moved, so anything you keep keyed off position can follow the moves
|
|
165
|
+
rather than having to poll every block:
|
|
126
166
|
|
|
127
167
|
```ts
|
|
128
|
-
|
|
129
|
-
|
|
168
|
+
import { POSITION_UPDATED_EVENT } from '@daneren2005/shared-memory-physics';
|
|
169
|
+
|
|
170
|
+
const physics = world.addSystem(new PhysicsSystem(world));
|
|
171
|
+
|
|
172
|
+
physics.on(POSITION_UPDATED_EVENT, (entityIds: Array<number>) => {
|
|
173
|
+
for(const eid of entityIds) {
|
|
174
|
+
const transform = world.getEntityByEid(eid)?.components.transform;
|
|
175
|
+
// transform.x / transform.y are where it ended up
|
|
176
|
+
}
|
|
130
177
|
});
|
|
131
178
|
```
|
|
132
179
|
|
|
133
|
-
|
|
134
|
-
|
|
180
|
+
Nearly every entity moves on nearly every run, so this is the one thing a physics system must not report an
|
|
181
|
+
entity at a time: an event object apiece in the worker, cloned across the boundary, then a lookup and an emit
|
|
182
|
+
apiece on the main thread costs more, at a few thousand entities, than moving them did. One array of ids
|
|
183
|
+
costs one listener call for the whole run.
|
|
184
|
+
|
|
185
|
+
No position travels with the id, because there is no need: the transform is a `SharedArrayBuffer` block, so
|
|
186
|
+
the position the worker wrote is already on the main thread by the time the event lands - read it off the
|
|
187
|
+
entity (or straight off the block). The move is not reported as a property change either, so a listener on
|
|
188
|
+
`component-property-updated` never hears about positions.
|
|
189
|
+
|
|
190
|
+
An entity that did not move is not in the list, so a world of still entities stays quiet - and so does one
|
|
191
|
+
held against something it cannot move past.
|
|
192
|
+
|
|
193
|
+
**A run only reports at all when something is listening.** The bill for this event is paid in the worker - an id
|
|
194
|
+
per moved entity pushed into the run's event array, and that array structured-cloned back across the boundary -
|
|
195
|
+
so `PhysicsSystem` checks per run whether it has a listener and tells the update not to bother when it does not.
|
|
196
|
+
That matters most for a game rendering from [`interpolation`](#interpolation), which wants nothing to do with an
|
|
197
|
+
event that fires at the physics rate: it is the rate that game is drawing *around*. Nothing has to be
|
|
198
|
+
configured for it, but `reportMoves: true` forces it on for a listener attached somewhere the system cannot see
|
|
199
|
+
it, and `reportMoves: false` turns it off regardless.
|
|
135
200
|
|
|
136
201
|
The move is written with `addAtomicFloat32` rather than a plain `+=`, so a game system on another thread can
|
|
137
202
|
add to the same position in the same instant without either move being lost.
|
|
@@ -160,13 +225,130 @@ world.addSystem(new PhysicsSystem(world, {
|
|
|
160
225
|
Without a `getWorker` (or where Web Workers / `SharedArrayBuffer` are unavailable) the exact same
|
|
161
226
|
`physicsUpdate` runs in-process on the main thread instead.
|
|
162
227
|
|
|
228
|
+
## Interpolation
|
|
229
|
+
|
|
230
|
+
A 50ms physics step against a 16ms frame means the transform only changes on one frame in three, and an entity
|
|
231
|
+
drawn straight off it visibly stutters. `InterpolationSystem` fills in a **render position** that changes every
|
|
232
|
+
frame instead, by blending between the two positions physics published either side of its last step:
|
|
233
|
+
|
|
234
|
+
```
|
|
235
|
+
render = prev + (current - prev) * alpha
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Every position it writes is therefore one the simulation actually produced - somewhere on the segment between
|
|
239
|
+
two real ones, never a guess past the end of one. There is nothing to tune and no case where it draws something
|
|
240
|
+
that did not happen: an entity that stopped against a wall is drawn easing into the wall and stopping, and one
|
|
241
|
+
that turned a corner is drawn turning the corner rather than carrying on into its old heading for a frame and
|
|
242
|
+
being snapped back. The cost is **one step of latency, always** - what is on screen is where the world was a
|
|
243
|
+
step ago.
|
|
244
|
+
|
|
245
|
+
```ts
|
|
246
|
+
import { InterpolationSystem, PhysicsSystem } from '@daneren2005/shared-memory-physics';
|
|
247
|
+
|
|
248
|
+
world.addSystem(new PhysicsSystem<Components>(world, { getWorker }));
|
|
249
|
+
world.addSystem(new InterpolationSystem<Components>(world));
|
|
250
|
+
|
|
251
|
+
world.loadEntity({ x: 0, y: 0, width: 10, height: 10, velocityX: 100, interpolate: true });
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
It takes no configuration and is not wired to the physics system. Everything it needs - the two positions, how
|
|
255
|
+
much simulated time lies between them, and whether that pair is new - is published into the block by whichever
|
|
256
|
+
run wrote it, so a game that retunes `deltaBetweenRuns` mid-flight is followed with nothing told to this system.
|
|
257
|
+
|
|
258
|
+
Then draw from `interpolation` instead of `transform`, keeping the size and facing where they have always been:
|
|
259
|
+
|
|
260
|
+
```ts
|
|
261
|
+
const { x, y } = entity.components.interpolation ?? entity.components.transform!;
|
|
262
|
+
const { width, height, angle } = entity.components.transform!;
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Draw from it **every frame**, not from `position-updated`. That event fires once per physics step, which is
|
|
266
|
+
exactly the cadence interpolation exists to hide - a sprite moved by it would be as choppy as one reading the
|
|
267
|
+
transform. Once nothing is listening to it, `PhysicsSystem` stops reporting moves at all and the whole event
|
|
268
|
+
costs nothing; see [the note above](#the-step).
|
|
269
|
+
|
|
270
|
+
**Only rendering should read it.** Range checks, targeting, AI steering, "can I build here" and anything you
|
|
271
|
+
persist all want `transform` - the real position. The render position is a function of local frame timing, so
|
|
272
|
+
two machines drawing the same simulation at 144Hz and 30Hz hold *different* numbers in it at the same tick;
|
|
273
|
+
anything that branches on it stops being deterministic. Nothing in this library ever reads it back, which is
|
|
274
|
+
what keeps the simulation itself unaffected.
|
|
275
|
+
|
|
276
|
+
### A slow physics run costs latency and nothing else
|
|
277
|
+
|
|
278
|
+
This is the property the whole thing is for, and it is worth being explicit about because it is easy to build
|
|
279
|
+
something that looks right and does not have it. **If a physics run takes longer than a frame, nothing about the
|
|
280
|
+
motion changes** - it is drawn a little further behind, at exactly the same speed. A worker that consistently
|
|
281
|
+
takes 30ms to come back costs 30ms of latency, which is not something an eye can see; only *variance* in how
|
|
282
|
+
late it is can be seen at all.
|
|
283
|
+
|
|
284
|
+
That works because the pacing is driven by the steps that have **landed** in the block, not by the physics
|
|
285
|
+
system's own accumulator. The accumulator resets when a run is *posted*, and on a worker thread that is not when
|
|
286
|
+
its results arrive - a run posted with 14ms of leftover and taking 30ms to come back leaves a whole frame where
|
|
287
|
+
the accumulator says "0.3 of the way into the new step" while the block still holds the one before it, so the
|
|
288
|
+
entity is drawn 0.3 along a segment it was drawn 0.96 along last frame. Backwards, then a lurch forward, on
|
|
289
|
+
every step.
|
|
290
|
+
|
|
291
|
+
For the same reason each run publishes **how much simulated time it covered** rather than that being assumed to
|
|
292
|
+
be the step: a run that comes back late leaves more than a step's worth banked, and the next one covers two at
|
|
293
|
+
once. Dividing that segment by the step would draw it at double speed.
|
|
294
|
+
|
|
295
|
+
Ordering against the physics system is only a preference - after it means a step is picked up on the frame it
|
|
296
|
+
happened rather than the one after, which is one frame of latency and nothing else.
|
|
297
|
+
|
|
298
|
+
### What it does not need
|
|
299
|
+
|
|
300
|
+
- **Pause and `timeScale`** are free. `BaseWorld#runUpdate` skips every system while paused and scales the
|
|
301
|
+
elapsed time it hands them, so the pacing stops and slows with the simulation on its own.
|
|
302
|
+
- **Spawns** are free. The block is seeded from the entity's config, so something added between steps is drawn
|
|
303
|
+
standing where it was put rather than sliding in from the origin.
|
|
304
|
+
- **Velocity changes, collisions and coming to rest** are all just steps the simulation took, and are drawn as
|
|
305
|
+
they happened.
|
|
306
|
+
|
|
307
|
+
### What it does need: `snapEntity`
|
|
308
|
+
|
|
309
|
+
A teleport and a long move are the same two numbers, so this is the one thing blending cannot work out for
|
|
310
|
+
itself. Write a transform directly and the entity is drawn sliding the whole way there over the next step:
|
|
311
|
+
|
|
312
|
+
```ts
|
|
313
|
+
import { snapEntity } from '@daneren2005/shared-memory-physics';
|
|
314
|
+
|
|
315
|
+
entity.components.transform!.x = 500;
|
|
316
|
+
snapEntity(entity); // drawn at 500 on the very next frame
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
### The cost
|
|
320
|
+
|
|
321
|
+
32 bytes per interpolated entity, three extra writes per entity per physics step, and a per-frame pass of one
|
|
322
|
+
lerp per entity on the main thread - order 0.2ms at 10,000 entities. An entity without the component pays
|
|
323
|
+
nothing at all. `forceMainThread: false` plus a `getWorker` moves the pass to a worker for worlds large enough
|
|
324
|
+
that it shows up in a profile, at the price of the render position being one frame stale; because consumers only
|
|
325
|
+
ever read `interpolation.x`, that is a constructor flag rather than a migration.
|
|
326
|
+
|
|
327
|
+
Physics writes `prev` and then the transform on the worker thread while the main thread reads both, so the block
|
|
328
|
+
carries a tick stamp that is written **last**, with a release store. The update reads it on either side of its
|
|
329
|
+
own reads and drops the frame if it changed, rather than blending a `prev` from one step against a transform
|
|
330
|
+
from another - which is the one mismatch that would draw an entity moving backwards.
|
|
331
|
+
|
|
163
332
|
## Collisions
|
|
164
333
|
|
|
165
|
-
`createPhysicsUpdate` builds an update
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
334
|
+
`createPhysicsUpdate` builds an update whose entities notice each other: every move is swept against whatever
|
|
335
|
+
is in front of it, so nothing is ever moved into anything else, and a callback of yours is told what each
|
|
336
|
+
entity ran into. The callback runs wherever the update runs - on the worker thread, in the same pass as the
|
|
337
|
+
movement - so it cannot be handed to `PhysicsSystem` at construction time: functions do not survive
|
|
338
|
+
`postMessage`. It reaches the worker by being baked into a module that both the worker file and the main
|
|
339
|
+
thread import.
|
|
340
|
+
|
|
341
|
+
The callback is optional and the [sweep](#stopping-at-the-edge) is not. `createPhysicsUpdate()` with nothing
|
|
342
|
+
asked of it at all is movement that comes to rest against walls, terrain and other units and says nothing
|
|
343
|
+
about any of it:
|
|
344
|
+
|
|
345
|
+
```ts
|
|
346
|
+
// Stopped by everything, responds to nothing.
|
|
347
|
+
export const gamePhysicsUpdate = createPhysicsUpdate<Components>();
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
Movement that walks straight through the world is the bare [`physicsUpdate`](#using-physicsupdate-in-your-own-system)
|
|
351
|
+
instead, which is also the only one that costs nothing per entity for the collidable query.
|
|
170
352
|
|
|
171
353
|
```ts
|
|
172
354
|
// game-physics-update.ts - imported by *both* the worker file and the system below
|
|
@@ -227,7 +409,8 @@ Writes go straight into shared memory, so a callback can bounce an entity by fli
|
|
|
227
409
|
value another thread also touches with the atomics from `@daneren2005/shared-memory-objects`. To reach past
|
|
228
410
|
the two entities in front of it - to credit a third for a kill, say - a callback can walk
|
|
229
411
|
`queries[COLLIDABLE_QUERY]`, the full collidable list. Anything that has to happen on the main thread goes
|
|
230
|
-
through `callbacks`: `entityDied`, `entityComponentChanged`, `createEntity
|
|
412
|
+
through `callbacks`: `entityDied`, `entityComponentChanged`, `createEntity`, and `emitEntityEvent` for an
|
|
413
|
+
event of your own - the same one the move above reports itself through.
|
|
231
414
|
|
|
232
415
|
**What collides.** Everything with a transform and a `body`, not only the entities the system moves, so a
|
|
233
416
|
ship can run into a station that has no velocity of its own. Shapes that only just touch do not count as
|
|
@@ -239,14 +422,17 @@ An entity is never moved into another one. Before anything is written to the tra
|
|
|
239
422
|
against everything along its path, and the entity is put down on the edge of whatever is in the way - then
|
|
240
423
|
`onCollision` runs for it, exactly as it would if the two had ended up overlapping.
|
|
241
424
|
|
|
425
|
+
This is every update `createPhysicsUpdate` builds, with or without a callback: where an entity is allowed to
|
|
426
|
+
end up is physics' decision, and what the game does about the contact is a separate one.
|
|
427
|
+
|
|
242
428
|
```ts
|
|
243
|
-
// A ship at x = 0 and a station at x = 30, both 10 wide, with a velocity that asks for
|
|
429
|
+
// A ship at x = 0 and a station at x = 30, both 10 wide, with a velocity that asks for 25 in this run.
|
|
244
430
|
world.update(1000);
|
|
245
|
-
console.log(ship.components.transform!.x); // ~20, their edges touching - not
|
|
431
|
+
console.log(ship.components.transform!.x); // ~20, their edges touching - not 25, and never inside it
|
|
246
432
|
```
|
|
247
433
|
|
|
248
|
-
The
|
|
249
|
-
|
|
434
|
+
The move is one decision over everything it lands on rather than one per entity, so a mover ending up on top of
|
|
435
|
+
two units comes to rest against the *near* one however the two happened to come up, and only the near one is
|
|
250
436
|
reported as a collision - the far one was never reached. An entity wedged between two at the same moment gets a
|
|
251
437
|
call for each.
|
|
252
438
|
|
|
@@ -259,13 +445,18 @@ Two details worth knowing:
|
|
|
259
445
|
forwards each run, so it reports no position change while it pushes - but it does keep reporting the
|
|
260
446
|
collision for as long as it keeps pushing.
|
|
261
447
|
|
|
262
|
-
|
|
448
|
+
**What it costs.** A move is checked where it *ends*, so a clear one - which is nearly every move - is a single
|
|
449
|
+
shape test against each thing near where it lands, and nothing more. Only a move that is genuinely stopping
|
|
450
|
+
somewhere pays for working out where: contact is then halved in on rather than solved, since three shapes at any
|
|
263
451
|
rotation to one another have no single formula for when a pair first touches, while the overlap test the
|
|
264
452
|
narrowphase already has answers it for any pair anywhere. The position an entity is put down at is always one
|
|
265
|
-
that tested clear, so it is genuinely outside everything - within about `1e-4`
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
453
|
+
that tested clear, so it is genuinely outside everything - within about `1e-4` world units of touching.
|
|
454
|
+
|
|
455
|
+
The trade for that is **a move long enough to carry an entity clean past something is not stopped by it**: by
|
|
456
|
+
the time the move is out there is nothing left to land on. That takes a single run covering more ground than the
|
|
457
|
+
thing in the way is thick, so at any normal frame rate it does not arise - a ship crossing 100 units a second
|
|
458
|
+
moves 1.6 of them per frame at 60fps. A fixed `deltaBetweenRuns` puts a ceiling on it if your game has anything
|
|
459
|
+
fast enough to care.
|
|
269
460
|
|
|
270
461
|
### Collision filtering
|
|
271
462
|
|
|
@@ -355,11 +546,90 @@ transform[TRANSFORM_Y_INDEX] += moveY * fraction;
|
|
|
355
546
|
`blocking` is what the entity came to rest *against*, which `forEachOverlapping` will not report from that
|
|
356
547
|
resting place: the two are touching rather than through each other.
|
|
357
548
|
|
|
549
|
+
## Spatial queries
|
|
550
|
+
|
|
551
|
+
`SpatialIndex` answers the other kind of question about where things are - who is in this area, and what is
|
|
552
|
+
the closest thing to this point - for the systems that are not about collisions at all: targeting, aggro
|
|
553
|
+
range, spawning somewhere clear, area effects. It is the same R-tree the broadphase is built on, without the
|
|
554
|
+
collide categories or the shape tests.
|
|
555
|
+
|
|
556
|
+
Build one from anything shaped like a query result and it reads the transform (and the body, to know which
|
|
557
|
+
outline that transform describes) off each entity. Only the transform is needed, so it indexes anything with
|
|
558
|
+
a place in the world rather than only what collides:
|
|
559
|
+
|
|
560
|
+
```ts
|
|
561
|
+
import { SpatialIndex } from '@daneren2005/shared-memory-physics';
|
|
562
|
+
|
|
563
|
+
const index = new SpatialIndex(queries.collidable);
|
|
564
|
+
```
|
|
565
|
+
|
|
566
|
+
Nothing reads the blocks after that, so the index is a **snapshot** of where everything was when it was
|
|
567
|
+
built - build one per run, in `preRun`, and let every entity in that run search it.
|
|
568
|
+
|
|
569
|
+
| Query | Answers |
|
|
570
|
+
| -------------------------------------------------- | ---------------------------------------------------- |
|
|
571
|
+
| `search(minX, minY, maxX, maxY, filter?)` | everything whose box reaches into that one |
|
|
572
|
+
| `searchAround(x, y, reachX, reachY, filter?)` | the same, written from the middle out |
|
|
573
|
+
| `findNearest(x, y, maxDistance?, filter?)` | the closest one, or `undefined` |
|
|
574
|
+
| `findNearby(x, y, maxResults, maxDistance?, filter?)` | the closest `maxResults`, nearest first |
|
|
575
|
+
|
|
576
|
+
Every one of them takes a `filter`, run per candidate the tree turns up, and hands it the whole entity -
|
|
577
|
+
`{ entityId, components }` - so it can read back any block that came along with the query. That is where the
|
|
578
|
+
things the index cannot know go: the searcher's own id, which side something is on, whether it is worth
|
|
579
|
+
shooting at.
|
|
580
|
+
|
|
581
|
+
```ts
|
|
582
|
+
const target = index.findNearest(x, y, 150, other => other.entityId !== entityId && isEnemy(other));
|
|
583
|
+
```
|
|
584
|
+
|
|
585
|
+
`findNearest` walks the tree in distance order rather than searching a box and sorting what comes back, so
|
|
586
|
+
ask it directly rather than building the sort yourself - it settles as soon as the nearest box is reached, and
|
|
587
|
+
`maxDistance` stops it walking the rest of the world when the answer is that there is nobody. Distance is
|
|
588
|
+
measured to an entity's **box**, not to its centre, so a large target is as near as its nearest edge and
|
|
589
|
+
`maxDistance` reads as a range past the hull.
|
|
590
|
+
|
|
591
|
+
## Examples
|
|
592
|
+
|
|
593
|
+
Three runnable examples live in [`examples/`](./examples), rendered with [Phaser](https://phaser.io) (a dev
|
|
594
|
+
dependency - it is not part of the library) and driven by plain HTML controls beside the canvas:
|
|
595
|
+
|
|
596
|
+
```sh
|
|
597
|
+
npm start # http://127.0.0.1:8080
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
- **Bouncing circles** - circles with a random position and heading, reflected off each other and off the
|
|
601
|
+
walls by an `onCollision` that mirrors the velocity about the contact normal.
|
|
602
|
+
- **Bouncing rectangles** - the same, on boxes of random width and height, where the normal is the axis the
|
|
603
|
+
two are least through each other on rather than the line between their centres.
|
|
604
|
+
- **Walking into a wall** - one unit between two boxes, turned around on a timer so it presses into each of
|
|
605
|
+
them in turn. It starts on a deliberately long 100ms physics step, so turning **interpolate rendering** off is
|
|
606
|
+
a direct before-and-after on the same running scene: the same simulation, drawn six frames at a time instead
|
|
607
|
+
of one.
|
|
608
|
+
|
|
609
|
+
Every page has a **physics step** slider (starting at the library's own 50ms) and an **interpolate rendering**
|
|
610
|
+
toggle, so the trade the [interpolation section](#interpolation) describes can be looked at rather than
|
|
611
|
+
reasoned about.
|
|
612
|
+
|
|
613
|
+
The examples import the library as `@daneren2005/shared-memory-physics`, aliased at the source next door, so
|
|
614
|
+
the code reads the way a game's would and editing a system hot-reloads the running page. `npm start` also
|
|
615
|
+
serves the two [cross-origin isolation](https://developer.mozilla.org/en-US/docs/Web/API/crossOriginIsolated)
|
|
616
|
+
headers `SharedArrayBuffer` needs, so physics genuinely runs on a worker there; where a browser will not hand
|
|
617
|
+
one out, the ECS runs the same update in-process instead and the **Run physics in a worker** toggle says so.
|
|
618
|
+
|
|
619
|
+
The walls the examples bounce off are ordinary entities - a transform and a body, no velocity - so they are
|
|
620
|
+
stopped against by the same sweep that stops anything against anything.
|
|
621
|
+
|
|
622
|
+
Every push to `dev` or `production` publishes them to GitHub Pages, both to the same place - so the site shows
|
|
623
|
+
whichever branch pushed last, which is what you want while the examples are changing faster than releases are
|
|
624
|
+
cut. See [`.github/workflows/pages.yml`](./.github/workflows/pages.yml) for the one-time repository setting it
|
|
625
|
+
needs, and for the one line to drop when it should go back to publishing releases only.
|
|
626
|
+
|
|
358
627
|
## Building
|
|
359
628
|
|
|
360
629
|
```sh
|
|
361
630
|
npm install
|
|
362
|
-
npm run build
|
|
631
|
+
npm run build # emits dist/ (js + d.ts)
|
|
632
|
+
npm run build:examples # emits examples/dist/
|
|
363
633
|
npm run type-check
|
|
364
634
|
npm test
|
|
365
635
|
```
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ComponentDefinition } from '@daneren2005/shared-memory-ecs';
|
|
2
|
+
import type { TransformComponent } from './transform-component';
|
|
3
|
+
export interface InterpolationComponent {
|
|
4
|
+
index: number;
|
|
5
|
+
x: number;
|
|
6
|
+
y: number;
|
|
7
|
+
prevX: number;
|
|
8
|
+
prevY: number;
|
|
9
|
+
}
|
|
10
|
+
export interface InterpolationConfig {
|
|
11
|
+
interpolate?: boolean;
|
|
12
|
+
x?: number;
|
|
13
|
+
y?: number;
|
|
14
|
+
}
|
|
15
|
+
export declare const INTERPOLATION_X_INDEX = 0;
|
|
16
|
+
export declare const INTERPOLATION_Y_INDEX = 1;
|
|
17
|
+
export declare const INTERPOLATION_PREV_X_INDEX = 2;
|
|
18
|
+
export declare const INTERPOLATION_PREV_Y_INDEX = 3;
|
|
19
|
+
export declare const INTERPOLATION_PROGRESS_INDEX = 4;
|
|
20
|
+
export declare const INTERPOLATION_SYNCED_TICK_INDEX = 5;
|
|
21
|
+
export declare const INTERPOLATION_DURATION_INDEX = 6;
|
|
22
|
+
export declare const INTERPOLATION_TICK_INDEX = 7;
|
|
23
|
+
export declare const INTERPOLATION_SIZE = 8;
|
|
24
|
+
export declare const interpolationDefinition: ComponentDefinition<InterpolationComponent, Float32Array, InterpolationConfig>;
|
|
25
|
+
export interface SnappableEntity {
|
|
26
|
+
components: {
|
|
27
|
+
transform?: TransformComponent;
|
|
28
|
+
interpolation?: InterpolationComponent;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export declare function snapEntity(entity: SnappableEntity): void;
|
|
32
|
+
export default interpolationDefinition;
|
|
@@ -1,18 +1,30 @@
|
|
|
1
1
|
import { type BodyComponent } from './body-component';
|
|
2
|
+
import { type InterpolationComponent } from './interpolation-component';
|
|
2
3
|
import { type TransformComponent } from './transform-component';
|
|
3
4
|
import { type VelocityComponent } from './velocity-component';
|
|
4
5
|
export declare const physicsRegistry: {
|
|
5
6
|
transform: import("@daneren2005/shared-memory-ecs").ComponentDefinition<TransformComponent, Float32Array<ArrayBufferLike>, import("./transform-component").TransformConfig, import("./transform-component").TransformSerialization>;
|
|
6
7
|
velocity: import("@daneren2005/shared-memory-ecs").ComponentDefinition<VelocityComponent, Float32Array<ArrayBufferLike>, import("./velocity-component").VelocityConfig, import("./velocity-component").VelocityConfig>;
|
|
7
8
|
body: import("@daneren2005/shared-memory-ecs").ComponentDefinition<BodyComponent, Uint32Array<ArrayBufferLike>, import("./body-component").BodyConfig, object>;
|
|
9
|
+
interpolation: import("@daneren2005/shared-memory-ecs").ComponentDefinition<InterpolationComponent, Float32Array<ArrayBufferLike>, import("./interpolation-component").InterpolationConfig, object>;
|
|
8
10
|
};
|
|
9
11
|
export type PhysicsComponents = {
|
|
10
12
|
transform: TransformComponent;
|
|
11
13
|
velocity: VelocityComponent;
|
|
12
14
|
body: BodyComponent;
|
|
15
|
+
interpolation: InterpolationComponent;
|
|
16
|
+
};
|
|
17
|
+
export type InterpolationComponents = {
|
|
18
|
+
transform: TransformComponent;
|
|
19
|
+
interpolation: InterpolationComponent;
|
|
13
20
|
};
|
|
14
21
|
export type PhysicsUpdateComponents = {
|
|
15
22
|
transform: Float32Array;
|
|
16
23
|
velocity: Float32Array;
|
|
17
24
|
body?: Uint32Array;
|
|
25
|
+
interpolation?: Float32Array;
|
|
26
|
+
};
|
|
27
|
+
export type InterpolationUpdateComponents = {
|
|
28
|
+
transform: Float32Array;
|
|
29
|
+
interpolation: Float32Array;
|
|
18
30
|
};
|
package/dist/index.d.ts
CHANGED
|
@@ -7,12 +7,20 @@ export type { VelocityComponent, VelocityConfig, VelocitySerialization, } from '
|
|
|
7
7
|
export { default as bodyDefinition, canCollide } from './components/body-component';
|
|
8
8
|
export { SHAPE_RECTANGLE, SHAPE_CIRCLE, SHAPE_CAPSULE, DEFAULT_COLLIDE_CATEGORY, DEFAULT_COLLIDE_MASK, BODY_SHAPE_INDEX, BODY_CATEGORY_INDEX, BODY_MASK_INDEX, BODY_SIZE, } from './components/body-component';
|
|
9
9
|
export type { BodyComponent, BodyConfig, } from './components/body-component';
|
|
10
|
+
export { default as interpolationDefinition, snapEntity } from './components/interpolation-component';
|
|
11
|
+
export { INTERPOLATION_X_INDEX, INTERPOLATION_Y_INDEX, INTERPOLATION_PREV_X_INDEX, INTERPOLATION_PREV_Y_INDEX, INTERPOLATION_PROGRESS_INDEX, INTERPOLATION_SYNCED_TICK_INDEX, INTERPOLATION_DURATION_INDEX, INTERPOLATION_TICK_INDEX, INTERPOLATION_SIZE, } from './components/interpolation-component';
|
|
12
|
+
export type { InterpolationComponent, InterpolationConfig, SnappableEntity, } from './components/interpolation-component';
|
|
10
13
|
export { physicsRegistry } from './components/registry';
|
|
11
|
-
export type { PhysicsComponents, PhysicsUpdateComponents } from './components/registry';
|
|
12
|
-
export { shapesOverlap, shapeHalfWidth, shapeHalfHeight, shapeIsEmpty,
|
|
14
|
+
export type { PhysicsComponents, PhysicsUpdateComponents, InterpolationComponents, InterpolationUpdateComponents, } from './components/registry';
|
|
15
|
+
export { shapesOverlap, shapeHalfWidth, shapeHalfHeight, shapeIsEmpty, shapeRadius, capsuleHalfLength, boundsHalfWidth, boundsHalfHeight, orientedBoxesOverlap, segmentSegmentDistanceSquared, segmentBoxDistanceSquared, pointSegmentDistanceSquared, } from './math/shapes';
|
|
13
16
|
export { default as CollisionBroadphase, COLLIDABLE_QUERY } from './systems/collision';
|
|
14
17
|
export type { CollisionComponents, CollisionEntity, CollisionFunction, MovingEntity, SweepResult } from './systems/collision';
|
|
15
|
-
export { default as
|
|
16
|
-
export type {
|
|
17
|
-
export { default as
|
|
18
|
+
export { default as SpatialIndex } from './systems/spatial-index';
|
|
19
|
+
export type { SpatialComponents, SpatialEntity, SpatialFilter } from './systems/spatial-index';
|
|
20
|
+
export { default as physicsUpdate, createPhysicsUpdate, POSITION_UPDATED_EVENT } from './systems/physics-update';
|
|
21
|
+
export type { PhysicsUpdateFunction, PhysicsUpdateMetadata, PhysicsUpdateOptions, PhysicsWorld } from './systems/physics-update';
|
|
22
|
+
export { default as PhysicsSystem, DEFAULT_PHYSICS_STEP_MS } from './systems/physics-system';
|
|
18
23
|
export type { PhysicsSystemConfig } from './systems/physics-system';
|
|
24
|
+
export { default as interpolationUpdate } from './systems/interpolation-update';
|
|
25
|
+
export { default as InterpolationSystem } from './systems/interpolation-system';
|
|
26
|
+
export type { InterpolationSystemConfig } from './systems/interpolation-system';
|