@daneren2005/shared-memory-physics 1.2.1 → 1.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/README.md CHANGED
@@ -18,9 +18,9 @@ npm install @daneren2005/shared-memory-physics @daneren2005/shared-memory-ecs @d
18
18
 
19
19
  | Component | Config | Serialization | Block |
20
20
  | --------------- | -------------------------------------------- | -------------------------- | ---------------------------------------------- |
21
- | `transform` | `width`, `height`, `angle?` / `radius` | `x`, `y` | `Float32Array` – where, how big, facing |
21
+ | `transform` | `width`, `height`, `angle?` / `radius` | `x`, `y`, `angle` | `Float32Array` – where, how big, facing |
22
22
  | `velocity` | – | `velocityX?`, `velocityY?` | `Float32Array` – world units per second |
23
- | `body` | `shape?`, `collideCategory?`, `collideMask?` | – | `Uint32Array` – what shape, what collides with |
23
+ | `body` | `shape?`, `collideCategory?`, `collideMask?`, `sensor?`, `continuousCollisionDetection?` | – | `Uint32Array` – what shape, what collides with |
24
24
  | `interpolation` | `interpolate` | – | `Float32Array` – where to *draw* it |
25
25
 
26
26
  Entity configs are flat and shared across every component, so velocity is keyed as `velocityX` / `velocityY`
@@ -30,12 +30,13 @@ load one, and the missing one defaults to `0`.
30
30
  The transform is split in two. **Config** is what your entity template says - `width` and `height` are
31
31
  required, `angle` defaults to `0` - and a size is what loads the component at all: a config with neither a
32
32
  width nor a height does not get a transform. **Serialization** is the live state that changes as the world
33
- runs, so `save()` returns only `x` / `y` and a reloaded entity gets its size and starting facing back from
34
- your own template rather than the save.
33
+ runs, so `save()` returns `x` / `y` and `angle`, while a reloaded entity gets its size back from your own
34
+ template rather than the save.
35
35
 
36
36
  `x` / `y` are the **centre** of the box, not a corner: the box is rotated about that point and collision
37
37
  projects out from it in both directions. `angle` is in **radians** counter-clockwise from the +x axis, and
38
- nothing here writes it - it is yours to set, usually from the heading, and collision reads it. `width` /
38
+ the physics never writes it - it is yours to set, usually from the heading, and collision reads it - but it
39
+ round-trips through `save` so a turned entity comes back facing the same way. `width` /
39
40
  `height` are the unrotated size; a box with no area never overlaps anything, not even another box sharing its
40
41
  exact position.
41
42
 
@@ -85,10 +86,17 @@ exactly still counts as **not** overlapping, whichever pair it is, and a shape w
85
86
  anything. Which of the two sizes decides that depends on the shape: a circle with no height is a perfectly
86
87
  good circle, while a rectangle with no height is nothing at all.
87
88
 
89
+ `contactNormal` answers the other half of a contact: the unit direction one shape is pushed off the other,
90
+ written into a `{ x, y }` you pass in, and `false` when the two are exactly on top of each other and there is no
91
+ such direction. It splits the same three ways off the same geometry, so the direction is the face that was
92
+ actually hit at the angle the pair is turned to - not the nearest world axis. This is what the native bounce
93
+ reflects around; a game reaching for its own response can use it the same way.
94
+
88
95
  `shapesOverlap` is exported, along with `shapeHalfWidth` / `shapeHalfHeight` (the axis-aligned box a shape is
89
96
  indexed under), `shapeRadius`, `shapeIsEmpty`, `capsuleHalfLength`, and the distance primitives underneath -
90
- `segmentSegmentDistanceSquared`, `segmentBoxDistanceSquared` and `pointSegmentDistanceSquared`.
91
- `orientedBoxesOverlap`, `boundsHalfWidth` and `boundsHalfHeight` are still there and still rectangle-only.
97
+ `segmentSegmentDistanceSquared`, `segmentBoxDistanceSquared` and `pointSegmentDistanceSquared`, each of which
98
+ takes an optional `out` vector and fills it with the gap it measured. `orientedBoxesOverlap`, `boundsHalfWidth`
99
+ and `boundsHalfHeight` are still there and still rectangle-only.
92
100
 
93
101
  ## Registering
94
102
 
@@ -301,8 +309,9 @@ happened rather than the one after, which is one frame of latency and nothing el
301
309
 
302
310
  - **Pause and `timeScale`** are free. `BaseWorld#runUpdate` skips every system while paused and scales the
303
311
  elapsed time it hands them, so the pacing stops and slows with the simulation on its own.
304
- - **Spawns** are free. The block is seeded from the entity's config, so something added between steps is drawn
305
- standing where it was put rather than sliding in from the origin.
312
+ - **Spawns at rest** are free. The block is seeded from the entity's config, so something added between steps is
313
+ drawn standing where it was put rather than sliding in from the origin. A spawn *already moving* is the one case
314
+ that needs a hand - see [`startInterpolation`](#a-moving-spawn-startinterpolation) below.
306
315
  - **Velocity changes, collisions and coming to rest** are all just steps the simulation took, and are drawn as
307
316
  they happened.
308
317
 
@@ -318,6 +327,41 @@ entity.components.transform!.x = 500;
318
327
  snapEntity(entity); // drawn at 500 on the very next frame
319
328
  ```
320
329
 
330
+ ### A moving spawn: `startInterpolation`
331
+
332
+ Blending has only one position for a brand-new entity - where it spawned - so it holds there until the first
333
+ physics step gives it a second, up to a whole step later. For anything at rest that is exactly right (the bullet
334
+ above stands where it was put). For something spawned *already moving* - a bullet leaving a barrel - that step of
335
+ stillness reads as the shot hanging in the air before it goes.
336
+
337
+ `startInterpolation` fixes it by **opening the render at the spawn point** and moving it out along the entity's own
338
+ velocity at its true speed, **jumping the transform forward** to where that motion reaches by the time the first
339
+ real step lands. So the entity is drawn leaving the spawn in its true direction from the very next frame, and the
340
+ seeded segment hands straight over to the first step with no seam. How far it jumps - and how long the seeded
341
+ segment lasts - is whatever is left of the current step, since that is when the first run lands:
342
+
343
+ ```ts
344
+ const bullet = world.loadEntity({
345
+ x: 0, y: 0, velocityX: 400, velocityY: 0, radius: 3, interpolate: true,
346
+ });
347
+ physics.startInterpolation(bullet); // drawn from the spawn point and moving out, not parked there until the first step
348
+ ```
349
+
350
+ It reads the step and the accumulator off the system, so there is nothing to pass.
351
+
352
+ **The jump skips a collision sweep.** It is a real move of the transform, applied without sweeping the span it
353
+ covers, so the entity skips forward up to a step: it can **tunnel** through anything within that span of the spawn,
354
+ and it runs that far ahead of where its velocity alone would have put it from then on. This is a deliberate trade
355
+ for **spawned projectiles**, where leaving the muzzle instantly matters more than that first sweep - a shot fired
356
+ *into* a target still lands inside it and is caught and killed by the next run's overlap test; only something thin
357
+ enough to sit entirely within that jumped span is passed through uncaught. Reach for it on bullets and the like,
358
+ not on something that must never skip a step of collision. Because the transform is jumped to the segment's end,
359
+ the render is still never drawn ahead of the simulation.
360
+
361
+ A no-op for an entity with no velocity or no interpolation, or while the system steps every frame. Driving physics
362
+ by hand rather than through `PhysicsSystem`? Call the underlying `startSpawnInterpolation(entity, { stepMs,
363
+ stepFraction, tick })` directly.
364
+
321
365
  ### The cost
322
366
 
323
367
  32 bytes per interpolated entity, three extra writes per entity per physics step, and a per-frame pass of one
@@ -401,11 +445,13 @@ world.addSystem(new PhysicsSystem<Components, GameUpdateComponents>(world, {
401
445
  `createPhysicsUpdate` stamps what it needs onto the function it returns, so `optional` and the collidable
402
446
  query come across with `updateFunction` and never have to be repeated on the system.
403
447
 
404
- **Per entity, not per pair.** The callback fires for the entity that *moved*, once for each thing it ended up
405
- on top of. `self` is that entity, with every block the system asked for; `other` is what it hit, where only
406
- `transform` is guaranteed. Two moving entities that run into each other therefore get one call each with the
407
- roles swapped - which is what lets a callback act on itself and leave the other side to its own call. An
408
- entity with no velocity is never `self`, because the system never moves it, but it is still found as `other`.
448
+ **Once per pair, per run.** Both sides of a contact find it - one by sweeping into it, the other by overlapping
449
+ it on its own turn - and the callback fires once, for whichever got there first. `self` is that entity, with
450
+ every block the system asked for; `other` is the one it hit, where only `transform` is guaranteed. **The other
451
+ side gets no call of its own**, so a callback that only touches `self` leaves it untouched: apply damage, tag a
452
+ kill, or raise an event for *both* entities in the one call. Which of the two is `self` follows update order
453
+ and is not something to depend on. An entity with no velocity is never `self`, because the system never moves
454
+ it, but it is still found as `other`.
409
455
 
410
456
  Writes go straight into shared memory, so a callback can bounce an entity by flipping its velocity, or push a
411
457
  value another thread also touches with the atomics from `@daneren2005/shared-memory-objects`. To reach past
@@ -460,6 +506,17 @@ thing in the way is thick, so at any normal frame rate it does not arise - a shi
460
506
  moves 1.6 of them per frame at 60fps. A fixed `deltaBetweenRuns` puts a ceiling on it if your game has anything
461
507
  fast enough to care.
462
508
 
509
+ **Continuous collision detection** lifts that ceiling for the bodies that need it, without slowing down the
510
+ ones that do not. Set `continuousCollisionDetection: true` on a body and its own move is tested along the whole
511
+ path it swept this run rather than only where the step ends - so a bullet that steps clean past a target still
512
+ comes to rest against it (or, if it is a sensor, still reports the pass through it). It is one extra swept-shape
513
+ test per candidate and only for that body: every other body stays on the single end-of-move test. Each shape
514
+ sweeps to a single covering shape - a rectangle to an oriented box spanning start to end, a circle or capsule to
515
+ a capsule down its path - chosen to be a superset of the ground actually covered, so a contact is reported early
516
+ at worst, never missed. A continuous body comes straight to rest at its first contact and does not corner-slide,
517
+ which is the behaviour a fast thing wants anyway. Reach for it on the small, fast things that tunnel - bullets,
518
+ thrown weapons - and leave it off everything else.
519
+
463
520
  ### Collision filtering
464
521
 
465
522
  Every body collides **as** a `collideCategory` and **with** a `collideMask`, both plain 32-bit fields. The
@@ -515,6 +572,26 @@ than off both. The pair is never missed, just attributed to the side that moved
515
572
  the entity that moves first gets the clear road: it takes its whole move, and the second one is the one that
516
573
  comes to rest against it.
517
574
 
575
+ ### Scoping a system to a group
576
+
577
+ Categories decide which *kinds* of body collide; `scope` decides which *world* they collide in. A game that
578
+ runs several independent groups at once - solar systems around the same origin, floors of a dungeon - gives
579
+ each group its own `PhysicsSystem` with a `scope`, and each one then only ever sees, moves, or collides against
580
+ the entities that pass it:
581
+
582
+ ```ts
583
+ world.addSystem(new PhysicsSystem<Components>(world, {
584
+ updateFunction: gamePhysicsUpdate,
585
+ scope: entity => entity.components.solarSystem?.solarSystemId === solId,
586
+ }));
587
+ ```
588
+
589
+ Unlike a category rule - which every body in the world is still weighed against - a scoped-out body never
590
+ enters this system's broadphase at all, so two groups whose boxes overlap around a shared origin cannot collide
591
+ across the boundary, and the tree each run builds is only as big as its own group. `scope` is separate from the
592
+ sharding `filter` (which narrows only *which* movers a worker steps, still sweeping the whole world so one
593
+ simulation can spread across cores); an entity handed both has to pass each to be moved.
594
+
518
595
  ## Using physicsUpdate in your own system
519
596
 
520
597
  `physicsUpdate` is a plain `EntityUpdateFunction`, so a game that already has its own movement system can call
@@ -548,6 +625,11 @@ transform[TRANSFORM_Y_INDEX] += moveY * fraction;
548
625
  `blocking` is what the entity came to rest *against*, which `forEachOverlapping` will not report from that
549
626
  resting place: the two are touching rather than through each other.
550
627
 
628
+ Both honour a body's `continuousCollisionDetection` flag. `sweep` picks it up on its own - a continuous body's
629
+ move is swept along its whole path. `forEachOverlapping` needs the move handed to it, since it runs after the
630
+ entity has already been put down: `forEachOverlapping(self, handle, moveX, moveY)` tests the path a continuous
631
+ body just swept, while the two-argument form (and every non-continuous body) tests only where it now stands.
632
+
551
633
  ## Spatial queries
552
634
 
553
635
  `SpatialIndex` answers the other kind of question about where things are - who is in this area, and what is
@@ -600,8 +682,8 @@ npm start # http://127.0.0.1:8080
600
682
  ```
601
683
 
602
684
  - **Bouncing circles** - circles with a random position and heading, reflected off each other and off the
603
- walls by an `onCollision` that mirrors the velocity about the contact normal.
604
- - **Bouncing rectangles** - the same, on boxes of random width and height, where the normal is the axis the
685
+ walls by the native bounce, which mirrors the velocity about the contact normal.
686
+ - **Bouncing rectangles** - the same, on boxes of random width and height, where the normal is the face the
605
687
  two are least through each other on rather than the line between their centres.
606
688
  - **Walking into a wall** - one unit between two boxes, turned around on a timer so it presses into each of
607
689
  them in turn. It starts on a deliberately long 100ms physics step, so turning **interpolate rendering** off is
@@ -5,6 +5,7 @@ export interface BodyComponent {
5
5
  collideCategory: number;
6
6
  collideMask: number;
7
7
  sensor: boolean;
8
+ continuousCollisionDetection: boolean;
8
9
  }
9
10
  export declare const SHAPE_RECTANGLE = 1;
10
11
  export declare const SHAPE_CIRCLE = 2;
@@ -20,12 +21,17 @@ export interface BodyConfig {
20
21
  collideCategory?: number;
21
22
  collideMask?: number;
22
23
  sensor?: boolean;
24
+ continuousCollisionDetection?: boolean;
23
25
  }
24
- export declare const BODY_SHAPE_INDEX = 0;
26
+ export declare const BODY_FLAGS_INDEX = 0;
25
27
  export declare const BODY_CATEGORY_INDEX = 1;
26
28
  export declare const BODY_MASK_INDEX = 2;
27
- export declare const BODY_SENSOR_INDEX = 3;
28
- export declare const BODY_SIZE = 4;
29
+ export declare const BODY_SIZE = 3;
30
+ export declare const BODY_SHAPE_MASK = 3;
31
+ export declare const BODY_SENSOR_FLAG = 4;
32
+ export declare const BODY_CCD_FLAG = 8;
33
+ export declare function bodyShape(body: Uint32Array): number;
29
34
  export declare function isSensor(body: Uint32Array): boolean;
35
+ export declare function isContinuous(body: Uint32Array): boolean;
30
36
  export declare const bodyDefinition: ComponentDefinition<BodyComponent, Uint32Array, BodyConfig>;
31
37
  export default bodyDefinition;
@@ -1,11 +1,16 @@
1
1
  import type { ComponentDefinition } from '@daneren2005/shared-memory-ecs';
2
2
  import type { TransformComponent } from './transform-component';
3
+ import type { VelocityComponent } from './velocity-component';
3
4
  export interface InterpolationComponent {
4
5
  index: number;
5
6
  x: number;
6
7
  y: number;
7
8
  prevX: number;
8
9
  prevY: number;
10
+ progress: number;
11
+ syncedTick: number;
12
+ duration: number;
13
+ tick: number;
9
14
  }
10
15
  export interface InterpolationConfig {
11
16
  interpolate?: boolean;
@@ -29,4 +34,17 @@ export interface SnappableEntity {
29
34
  };
30
35
  }
31
36
  export declare function snapEntity(entity: SnappableEntity): void;
37
+ export interface SpawnableEntity {
38
+ components: {
39
+ transform?: TransformComponent;
40
+ velocity?: VelocityComponent;
41
+ interpolation?: InterpolationComponent;
42
+ };
43
+ }
44
+ export interface SpawnInterpolationOptions {
45
+ stepMs: number;
46
+ stepFraction: number;
47
+ tick: number;
48
+ }
49
+ export declare function startSpawnInterpolation(entity: SpawnableEntity, options: SpawnInterpolationOptions): void;
32
50
  export default interpolationDefinition;
@@ -27,6 +27,7 @@ export type PhysicsUpdateComponents = {
27
27
  body?: Uint32Array;
28
28
  bounciness?: Float32Array;
29
29
  interpolation?: Float32Array;
30
+ entity?: Uint32Array;
30
31
  };
31
32
  export type InterpolationUpdateComponents = {
32
33
  transform: Float32Array;
@@ -16,6 +16,7 @@ export interface TransformConfig {
16
16
  export interface TransformSerialization {
17
17
  x: number;
18
18
  y: number;
19
+ angle: number;
19
20
  }
20
21
  export declare const TRANSFORM_X_INDEX = 0;
21
22
  export declare const TRANSFORM_Y_INDEX = 1;
package/dist/index.d.ts CHANGED
@@ -4,18 +4,19 @@ export type { TransformComponent, TransformConfig, TransformSerialization, } fro
4
4
  export { default as velocityDefinition } from './components/velocity-component';
5
5
  export { VELOCITY_X_INDEX, VELOCITY_Y_INDEX, VELOCITY_SIZE, } from './components/velocity-component';
6
6
  export type { VelocityComponent, VelocityConfig, VelocitySerialization, } from './components/velocity-component';
7
- export { default as bodyDefinition, canCollide, isSensor } from './components/body-component';
8
- export { SHAPE_RECTANGLE, SHAPE_CIRCLE, SHAPE_CAPSULE, DEFAULT_COLLIDE_CATEGORY, DEFAULT_COLLIDE_MASK, BODY_SHAPE_INDEX, BODY_CATEGORY_INDEX, BODY_MASK_INDEX, BODY_SENSOR_INDEX, BODY_SIZE, } from './components/body-component';
7
+ export { default as bodyDefinition, canCollide, isSensor, isContinuous, bodyShape } from './components/body-component';
8
+ export { SHAPE_RECTANGLE, SHAPE_CIRCLE, SHAPE_CAPSULE, DEFAULT_COLLIDE_CATEGORY, DEFAULT_COLLIDE_MASK, BODY_FLAGS_INDEX, BODY_SHAPE_MASK, BODY_SENSOR_FLAG, BODY_CCD_FLAG, BODY_CATEGORY_INDEX, BODY_MASK_INDEX, BODY_SIZE, } from './components/body-component';
9
9
  export type { BodyComponent, BodyConfig, } from './components/body-component';
10
10
  export { default as bouncinessDefinition } from './components/bounciness-component';
11
11
  export { BOUNCINESS_INDEX, BOUNCINESS_SIZE, } from './components/bounciness-component';
12
12
  export type { BouncinessComponent, BouncinessConfig, } from './components/bounciness-component';
13
- export { default as interpolationDefinition, snapEntity } from './components/interpolation-component';
13
+ export { default as interpolationDefinition, snapEntity, startSpawnInterpolation } from './components/interpolation-component';
14
14
  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';
15
- export type { InterpolationComponent, InterpolationConfig, SnappableEntity, } from './components/interpolation-component';
15
+ export type { InterpolationComponent, InterpolationConfig, SnappableEntity, SpawnableEntity, SpawnInterpolationOptions, } from './components/interpolation-component';
16
16
  export { physicsRegistry } from './components/registry';
17
17
  export type { PhysicsComponents, PhysicsUpdateComponents, InterpolationComponents, InterpolationUpdateComponents, } from './components/registry';
18
- export { shapesOverlap, shapeHalfWidth, shapeHalfHeight, shapeIsEmpty, shapeRadius, capsuleHalfLength, boundsHalfWidth, boundsHalfHeight, orientedBoxesOverlap, segmentSegmentDistanceSquared, segmentBoxDistanceSquared, pointSegmentDistanceSquared, } from './math/shapes';
18
+ export { shapesOverlap, contactNormal, shapeHalfWidth, shapeHalfHeight, shapeIsEmpty, shapeRadius, capsuleHalfLength, boundsHalfWidth, boundsHalfHeight, orientedBoxesOverlap, segmentSegmentDistanceSquared, segmentBoxDistanceSquared, pointSegmentDistanceSquared, } from './math/shapes';
19
+ export type { Vector } from './math/shapes';
19
20
  export { default as CollisionBroadphase, COLLIDABLE_QUERY } from './systems/collision';
20
21
  export type { CollisionComponents, CollisionEntity, CollisionFunction, MoveResult, MovingEntity, SweepResult } from './systems/collision';
21
22
  export { default as SpatialIndex } from './systems/spatial-index';