@driftengine/physics 4.8.0 → 4.8.2

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.
@@ -303,6 +303,8 @@ export declare class CharacterController {
303
303
  * would climb twice.
304
304
  */
305
305
  private tryStep;
306
+ /** Put the body back where a step began, and say the step was not taken. */
307
+ private stepBack;
306
308
  /**
307
309
  * Sweep the foot ball down, and fall back to a ray when what it finds is not walkable.
308
310
  *
@@ -36,6 +36,8 @@ const SLIDE_PASSES = 4;
36
36
  * own stop margin already leaves a tenth of a millimetre of clearance, which is what a skin was for.
37
37
  */
38
38
  const SNAP_EPS = 0.001;
39
+ /** How far past a step's edge a step that landed on the edge carries the body's axis. */
40
+ const STEP_OVER = 0.01;
39
41
  export class CharacterController {
40
42
  x = 0;
41
43
  y = 0;
@@ -311,9 +313,10 @@ export class CharacterController {
311
313
  * the same clamp and air control — only the plane is given up, not the feel.
312
314
  */
313
315
  if (input.projectMove === false) {
314
- this.velX = approach(this.velX, wantX, step);
315
- this.velY = approach(this.velY, wantY, step);
316
- this.velZ = approach(this.velZ, wantZ, step);
316
+ const t = approachFraction(wantX - this.velX, wantY - this.velY, wantZ - this.velZ, step);
317
+ this.velX = t === 1 ? wantX : this.velX + (wantX - this.velX) * t;
318
+ this.velY = t === 1 ? wantY : this.velY + (wantY - this.velY) * t;
319
+ this.velZ = t === 1 ? wantZ : this.velZ + (wantZ - this.velZ) * t;
317
320
  }
318
321
  else {
319
322
  const alongUp = this.speedAlongUp;
@@ -324,9 +327,10 @@ export class CharacterController {
324
327
  const wantTangentX = wantX - this.upX * wantIntoUp;
325
328
  const wantTangentY = wantY - this.upY * wantIntoUp;
326
329
  const wantTangentZ = wantZ - this.upZ * wantIntoUp;
327
- tangentX = approach(tangentX, wantTangentX, step);
328
- tangentY = approach(tangentY, wantTangentY, step);
329
- tangentZ = approach(tangentZ, wantTangentZ, step);
330
+ const t = approachFraction(wantTangentX - tangentX, wantTangentY - tangentY, wantTangentZ - tangentZ, step);
331
+ tangentX = t === 1 ? wantTangentX : tangentX + (wantTangentX - tangentX) * t;
332
+ tangentY = t === 1 ? wantTangentY : tangentY + (wantTangentY - tangentY) * t;
333
+ tangentZ = t === 1 ? wantTangentZ : tangentZ + (wantTangentZ - tangentZ) * t;
330
334
  this.velX = tangentX + this.upX * alongUp;
331
335
  this.velY = tangentY + this.upY * alongUp;
332
336
  this.velZ = tangentZ + this.upZ * alongUp;
@@ -452,26 +456,69 @@ export class CharacterController {
452
456
  this.z += liftZ;
453
457
  this.readPose();
454
458
  if (world.shapecast(this.shape, this.pose, flatX, flatY, flatZ, this.stepHit, this.filter)) {
455
- this.x = startX;
456
- this.y = startY;
457
- this.z = startZ;
458
- return false;
459
+ return this.stepBack(startX, startY, startZ);
459
460
  }
460
461
  this.x += flatX;
461
462
  this.y += flatY;
462
463
  this.z += flatZ;
463
464
  this.readPose();
464
- if (world.shapecast(this.shape, this.pose, -liftX, -liftY, -liftZ, this.stepHit, this.filter)) {
465
- const drop = this.stepHeight * this.stepHit.fraction - SNAP_EPS;
466
- this.x -= this.upX * drop;
467
- this.y -= this.upY * drop;
468
- this.z -= this.upZ * drop;
469
- return true;
465
+ if (!world.shapecast(this.shape, this.pose, -liftX, -liftY, -liftZ, this.stepHit, this.filter)) {
466
+ // Nothing beneath the lifted position, so this was a gap rather than a step.
467
+ return this.stepBack(startX, startY, startZ);
470
468
  }
471
- // Nothing beneath the lifted position, so this was a gap rather than a step.
472
- this.x = startX;
473
- this.y = startY;
474
- this.z = startZ;
469
+ let landing = this.stepHit.nx * this.upX + this.stepHit.ny * this.upY + this.stepHit.nz * this.upZ;
470
+ if (landing < this.slopeCos) {
471
+ /*
472
+ * **Landed on the lip, so go on over it.** The forward part of a step is what was left of
473
+ * the tick's move when the body met the step, and at a tick's length that can be millimetres:
474
+ * the foot ball then comes down on the step's edge, a contact far too steep to stand on, and
475
+ * the body was set there airborne, slid off, and never stepped again. Measured with a 0.32 m
476
+ * radius and a 0.35 m step height: a 0.25 m rise climbed and a 0.28 m one did not.
477
+ *
478
+ * The edge's normal points from the edge at the ball's centre, so its part across up is how
479
+ * far the axis stands short of the edge. Moving that far again, plus a centimetre, puts the
480
+ * ball over the step's top, where the drop meets a face. The body moves up to one radius
481
+ * further in that tick than it was going to, which is the price of a step taken in one tick.
482
+ */
483
+ const lipX = this.stepHit.nx - this.upX * landing;
484
+ const lipY = this.stepHit.ny - this.upY * landing;
485
+ const lipZ = this.stepHit.nz - this.upZ * landing;
486
+ const lip = Math.sqrt(lipX * lipX + lipY * lipY + lipZ * lipZ);
487
+ // An edge behind the move, or beside it, is not one this step was climbing.
488
+ if (lip < 1e-6 || lipX * flatX + lipY * flatY + lipZ * flatZ >= 0) {
489
+ return this.stepBack(startX, startY, startZ);
490
+ }
491
+ const reach = (this.radius * lip + STEP_OVER) / lip;
492
+ const overX = -lipX * reach;
493
+ const overY = -lipY * reach;
494
+ const overZ = -lipZ * reach;
495
+ this.readPose();
496
+ if (world.shapecast(this.shape, this.pose, overX, overY, overZ, this.stepHit, this.filter)) {
497
+ return this.stepBack(startX, startY, startZ);
498
+ }
499
+ this.x += overX;
500
+ this.y += overY;
501
+ this.z += overZ;
502
+ this.readPose();
503
+ if (!world.shapecast(this.shape, this.pose, -liftX, -liftY, -liftZ, this.stepHit, this.filter)) {
504
+ return this.stepBack(startX, startY, startZ);
505
+ }
506
+ landing =
507
+ this.stepHit.nx * this.upX + this.stepHit.ny * this.upY + this.stepHit.nz * this.upZ;
508
+ if (landing < this.slopeCos)
509
+ return this.stepBack(startX, startY, startZ);
510
+ }
511
+ const drop = this.stepHeight * this.stepHit.fraction - SNAP_EPS;
512
+ this.x -= this.upX * drop;
513
+ this.y -= this.upY * drop;
514
+ this.z -= this.upZ * drop;
515
+ return true;
516
+ }
517
+ /** Put the body back where a step began, and say the step was not taken. */
518
+ stepBack(x, y, z) {
519
+ this.x = x;
520
+ this.y = y;
521
+ this.z = z;
475
522
  return false;
476
523
  }
477
524
  /**
@@ -630,12 +677,18 @@ export class CharacterController {
630
677
  this.pose.qw = w * inverse;
631
678
  }
632
679
  }
633
- /** Move `from` toward `to` by at most `step`. */
634
- function approach(from, to, step) {
635
- const d = to - from;
636
- if (d > step)
637
- return from + step;
638
- if (d < -step)
639
- return from - step;
640
- return to;
680
+ /**
681
+ * How much of the way to close a gap of `(dx, dy, dz)` this tick: exactly 1 when the gap is no
682
+ * longer than `step`, which the caller reads as "land on the target", and `step` of its length
683
+ * otherwise.
684
+ *
685
+ * **Toward the wanted velocity as a vector, by at most `step` in length.** It was one world axis at
686
+ * a time, each by the whole step, so a diagonal gained and lost speed √2 times as fast as a straight
687
+ * run while the clamp on the wanted vector kept its top speed honest — the faster diagonal lived in
688
+ * the acceleration. What it gives up is that an axis no longer reaches its target on its own
689
+ * schedule: turning from one direction to another now curves rather than squaring the corner.
690
+ */
691
+ function approachFraction(dx, dy, dz, step) {
692
+ const gap = Math.sqrt(dx * dx + dy * dy + dz * dz);
693
+ return gap <= step ? 1 : step / gap;
641
694
  }
package/dist/manifold.js CHANGED
@@ -113,6 +113,68 @@ function roundRound(a, b, margin, out) {
113
113
  return true;
114
114
  }
115
115
  /* ---------- sphere or capsule against a polytope ---------- */
116
+ /** Where along a capsule's segment the search settles: 0.618 to the 30th of 1.2 m is under a micron. */
117
+ const SEGMENT_STEPS = 30;
118
+ const INV_PHI = 0.6180339887498949;
119
+ /** Each end's direction away from the polytope, and its separation, read by the segment search. */
120
+ const END_DIR = new Float64Array(9);
121
+ const END_SEP = new Float64Array(2);
122
+ /** The manifold being filled by `roundPolytope`, kept here so adding a contact allocates nothing. */
123
+ const ROUND = { count: 0, bestSep: Infinity, nx: 0, ny: 1, nz: 0, margin: 0, flipped: false };
124
+ /** Add the contact `CLOSEST` and `END_DIR[slot]` describe, at `separation`, as `feature`. */
125
+ function emitRound(out, slot, separation, feature) {
126
+ if (separation > ROUND.margin || ROUND.count >= MAX_CONTACTS)
127
+ return;
128
+ // Normal points from the round shape toward the polytope, then flipped if A was the polytope.
129
+ const ux = END_DIR[slot * 3] ?? 0;
130
+ const uy = END_DIR[slot * 3 + 1] ?? 0;
131
+ const uz = END_DIR[slot * 3 + 2] ?? 0;
132
+ if (separation < ROUND.bestSep) {
133
+ ROUND.bestSep = separation;
134
+ ROUND.nx = ROUND.flipped ? ux : -ux;
135
+ ROUND.ny = ROUND.flipped ? uy : -uy;
136
+ ROUND.nz = ROUND.flipped ? uz : -uz;
137
+ }
138
+ const at = ROUND.count * 3;
139
+ out.points[at] = CLOSEST[0];
140
+ out.points[at + 1] = CLOSEST[1];
141
+ out.points[at + 2] = CLOSEST[2];
142
+ out.separations[ROUND.count] = separation;
143
+ out.featureIds[ROUND.count] = feature;
144
+ ROUND.count++;
145
+ }
146
+ /**
147
+ * The signed distance from a point to the polytope, with the direction away from it left in
148
+ * `END_DIR[slot]`: positive outside, the deepest face's plane distance inside.
149
+ */
150
+ function signedDistance(poly, polyWorld, faces, px, py, pz, slot) {
151
+ closestOnPolytope(poly, polyWorld, planesB, faces, px, py, pz);
152
+ let ux = px - CLOSEST[0];
153
+ let uy = py - CLOSEST[1];
154
+ let uz = pz - CLOSEST[2];
155
+ let d = Math.sqrt(ux * ux + uy * uy + uz * uz);
156
+ if (CLOSEST[6] < 0) {
157
+ // Inside: the closest face's outward normal is the direction, and depth is its plane distance.
158
+ ux = CLOSEST[3];
159
+ uy = CLOSEST[4];
160
+ uz = CLOSEST[5];
161
+ d = CLOSEST[6];
162
+ }
163
+ else if (d === 0) {
164
+ ux = CLOSEST[3];
165
+ uy = CLOSEST[4];
166
+ uz = CLOSEST[5];
167
+ }
168
+ else {
169
+ ux /= d;
170
+ uy /= d;
171
+ uz /= d;
172
+ }
173
+ END_DIR[slot * 3] = ux;
174
+ END_DIR[slot * 3 + 1] = uy;
175
+ END_DIR[slot * 3 + 2] = uz;
176
+ return d;
177
+ }
116
178
  function roundPolytope(round, poly, polyPose, margin, out, flipped) {
117
179
  // `worldA` holds whichever shape was passed first, so read the round one from the right buffer.
118
180
  const roundWorld = flipped ? worldB : worldA;
@@ -120,67 +182,79 @@ function roundPolytope(round, poly, polyPose, margin, out, flipped) {
120
182
  planesToWorld(poly, polyPose, polyWorld, planesB);
121
183
  const points = round.vertices.length / 3;
122
184
  const faces = faceCount(poly);
123
- let count = 0;
124
- let bestSep = Infinity;
125
- let nx = 0;
126
- let ny = 1;
127
- let nz = 0;
128
- for (let p = 0; p < points; p++) {
129
- const px = roundWorld[p * 3] ?? 0;
130
- const py = roundWorld[p * 3 + 1] ?? 0;
131
- const pz = roundWorld[p * 3 + 2] ?? 0;
132
- closestOnPolytope(poly, polyWorld, planesB, faces, px, py, pz);
133
- let ux = px - CLOSEST[0];
134
- let uy = py - CLOSEST[1];
135
- let uz = pz - CLOSEST[2];
136
- let d = Math.sqrt(ux * ux + uy * uy + uz * uz);
137
- if (CLOSEST[6] < 0) {
138
- // Inside: the closest face's outward normal is the direction, and depth is its plane distance.
139
- ux = CLOSEST[3];
140
- uy = CLOSEST[4];
141
- uz = CLOSEST[5];
142
- d = CLOSEST[6];
143
- }
144
- else if (d === 0) {
145
- ux = CLOSEST[3];
146
- uy = CLOSEST[4];
147
- uz = CLOSEST[5];
148
- }
149
- else {
150
- ux /= d;
151
- uy /= d;
152
- uz /= d;
153
- }
154
- const separation = d - round.radius - poly.radius;
155
- if (separation > margin)
156
- continue;
157
- if (count >= MAX_CONTACTS)
158
- break;
159
- // Normal points from the round shape toward the polytope, then flipped if A was the polytope.
160
- const sx = flipped ? ux : -ux;
161
- const sy = flipped ? uy : -uy;
162
- const sz = flipped ? uz : -uz;
163
- if (separation < bestSep) {
164
- bestSep = separation;
165
- nx = sx;
166
- ny = sy;
167
- nz = sz;
168
- }
169
- const at = count * 3;
170
- out.points[at] = CLOSEST[0];
171
- out.points[at + 1] = CLOSEST[1];
172
- out.points[at + 2] = CLOSEST[2];
173
- out.separations[count] = separation;
185
+ const radii = round.radius + poly.radius;
186
+ ROUND.count = 0;
187
+ ROUND.bestSep = Infinity;
188
+ ROUND.nx = 0;
189
+ ROUND.ny = 1;
190
+ ROUND.nz = 0;
191
+ ROUND.margin = margin;
192
+ ROUND.flipped = flipped;
193
+ for (let p = 0; p < points && p < 2; p++) {
194
+ const separation = signedDistance(poly, polyWorld, faces, roundWorld[p * 3] ?? 0, roundWorld[p * 3 + 1] ?? 0, roundWorld[p * 3 + 2] ?? 0, p) - radii;
195
+ END_SEP[p] = separation;
174
196
  // The endpoint that produced it, which is stable while the capsule keeps its orientation.
175
- out.featureIds[count] = 0x40000000 | p;
176
- count++;
197
+ emitRound(out, p, separation, 0x40000000 | p);
177
198
  }
178
- if (count === 0)
199
+ /*
200
+ * **A capsule is a segment, and its middle can be the part that touches.** This used to ask only
201
+ * its two ends, which is exact for a ball and for a capsule lying on a face, and wrong wherever the
202
+ * segment crosses an edge: a standing capsule walked through a slab across its waist, because
203
+ * neither end ball came within reach of it, and a capsule lying across a rail fell through it.
204
+ *
205
+ * The distance from a point on the segment to a convex polytope is convex along the segment, so
206
+ * its least value is at an end exactly when the slope there points away from the other end. Each
207
+ * end's direction away from the polytope is that slope, so a capsule whose ends are the answer, as
208
+ * one standing on a floor always is, costs nothing more. Otherwise a fixed number of golden-section
209
+ * steps finds the interior point, and it is kept only where it is nearer than both ends.
210
+ */
211
+ if (points === 2) {
212
+ const ax = roundWorld[0] ?? 0;
213
+ const ay = roundWorld[1] ?? 0;
214
+ const az = roundWorld[2] ?? 0;
215
+ const sx = (roundWorld[3] ?? 0) - ax;
216
+ const sy = (roundWorld[4] ?? 0) - ay;
217
+ const sz = (roundWorld[5] ?? 0) - az;
218
+ const leavesFirst = sx * (END_DIR[0] ?? 0) + sy * (END_DIR[1] ?? 0) + sz * (END_DIR[2] ?? 0);
219
+ const leavesSecond = -(sx * (END_DIR[3] ?? 0) +
220
+ sy * (END_DIR[4] ?? 0) +
221
+ sz * (END_DIR[5] ?? 0));
222
+ if (leavesFirst < 0 && leavesSecond < 0) {
223
+ let lo = 0;
224
+ let hi = 1;
225
+ let m1 = hi - (hi - lo) * INV_PHI;
226
+ let m2 = lo + (hi - lo) * INV_PHI;
227
+ let f1 = signedDistance(poly, polyWorld, faces, ax + sx * m1, ay + sy * m1, az + sz * m1, 2);
228
+ let f2 = signedDistance(poly, polyWorld, faces, ax + sx * m2, ay + sy * m2, az + sz * m2, 2);
229
+ for (let step = 0; step < SEGMENT_STEPS; step++) {
230
+ if (f1 <= f2) {
231
+ hi = m2;
232
+ m2 = m1;
233
+ f2 = f1;
234
+ m1 = hi - (hi - lo) * INV_PHI;
235
+ f1 = signedDistance(poly, polyWorld, faces, ax + sx * m1, ay + sy * m1, az + sz * m1, 2);
236
+ }
237
+ else {
238
+ lo = m1;
239
+ m1 = m2;
240
+ f1 = f2;
241
+ m2 = lo + (hi - lo) * INV_PHI;
242
+ f2 = signedDistance(poly, polyWorld, faces, ax + sx * m2, ay + sy * m2, az + sz * m2, 2);
243
+ }
244
+ }
245
+ const at = (lo + hi) / 2;
246
+ const separation = signedDistance(poly, polyWorld, faces, ax + sx * at, ay + sy * at, az + sz * at, 2) - radii;
247
+ if (separation < Math.min(END_SEP[0] ?? Infinity, END_SEP[1] ?? Infinity) - 1e-7) {
248
+ emitRound(out, 2, separation, 0x40000000 | 2);
249
+ }
250
+ }
251
+ }
252
+ if (ROUND.count === 0)
179
253
  return false;
180
- out.nx = nx;
181
- out.ny = ny;
182
- out.nz = nz;
183
- out.count = count;
254
+ out.nx = ROUND.nx;
255
+ out.ny = ROUND.ny;
256
+ out.nz = ROUND.nz;
257
+ out.count = ROUND.count;
184
258
  return true;
185
259
  }
186
260
  /* ---------- polytope against polytope ---------- */
package/dist/query.js CHANGED
@@ -697,7 +697,15 @@ export function shapecastWorld(bodies, tree, shape, pose, dx, dy, dz, out, filte
697
697
  }
698
698
  else if (!collideShapes(shape, poseA, other, poseB, 1e9, manifold))
699
699
  break;
700
- const gap = manifold.separations[0] ?? 0;
700
+ /*
701
+ * **The nearest contact, not the first one written.** A capsule's manifold holds a point per
702
+ * end, and the first is its lower end: reading only that advanced the sweep by the distance
703
+ * from the lower ball while the upper one was the nearer, so a standing capsule swept clean
704
+ * through a beam at head height.
705
+ */
706
+ let gap = Infinity;
707
+ for (let c = 0; c < manifold.count; c++)
708
+ gap = Math.min(gap, manifold.separations[c] ?? gap);
701
709
  if (gap < 1e-4) {
702
710
  /*
703
711
  * A shape already touching a surface it is travelling *along* has not run into it.
@@ -713,7 +721,25 @@ export function shapecastWorld(bodies, tree, shape, pose, dx, dy, dz, out, filte
713
721
  touched = true;
714
722
  break;
715
723
  }
716
- t += gap / travel;
724
+ /*
725
+ * **How far the shapes may close along the contact normal, not along the travel.** The
726
+ * manifold's normal separates the two by `gap`, so they cannot meet before the sweep has
727
+ * closed that much along it. Dividing by the whole travel was always safe and crawled
728
+ * wherever the travel grazes the surface: a ball dropping past the lip of a step closes on it
729
+ * at a sixth of its speed, and twenty-four steps ended before it arrived, so the sweep said
730
+ * nothing was there. A mesh keeps the old step, since one triangle's normal does not separate
731
+ * the whole mesh.
732
+ */
733
+ if (isMesh(shape) || isMesh(other)) {
734
+ t += gap / travel;
735
+ }
736
+ else {
737
+ const closing = dx * manifold.nx + dy * manifold.ny + dz * manifold.nz;
738
+ // Not closing along the normal that separates them, so they never meet on this travel.
739
+ if (closing <= 1e-12)
740
+ break;
741
+ t += gap / closing;
742
+ }
717
743
  if (t > 1)
718
744
  break;
719
745
  }
package/dist/ragdoll.d.ts CHANGED
@@ -88,7 +88,15 @@ export interface Ragdoll {
88
88
  readonly bodyOf: Int32Array;
89
89
  /** How many bones were built. */
90
90
  readonly boneCount: number;
91
- /** Write the ragdoll's current shape into a pose. */
91
+ /**
92
+ * Write the ragdoll's current shape into a pose.
93
+ *
94
+ * Each joint is turned by the bone that **starts** at it, which is the joint a skinned limb
95
+ * follows — the forearm's body turns the elbow — and a joint with no bone below it rides the one
96
+ * it hangs off. Translations are the rig's own, in each parent's frame. A root's rotation is
97
+ * written in the frame the character's node is placed in, at `rootX`, `rootY` and `rootZ`, with
98
+ * no rotation of its own; a joint no bone reaches is left as the pose had it.
99
+ */
92
100
  writePose(out: PoseTarget): void;
93
101
  /** Steer the bodies toward a pose. Weight 1 tracks it, weight 0 goes limp. */
94
102
  drive(world: PhysicsWorld, pose: PoseTarget, weight: number): void;