@driftengine/physics 4.7.4 → 4.8.1
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/dist/controller.d.ts +2 -0
- package/dist/controller.js +59 -14
- package/dist/manifold.js +132 -58
- package/dist/query.js +28 -2
- package/package.json +1 -1
- package/src/controller.ts +64 -14
- package/src/manifold.ts +154 -55
- package/src/query.ts +25 -2
package/dist/controller.d.ts
CHANGED
|
@@ -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
|
*
|
package/dist/controller.js
CHANGED
|
@@ -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;
|
|
@@ -452,26 +454,69 @@ export class CharacterController {
|
|
|
452
454
|
this.z += liftZ;
|
|
453
455
|
this.readPose();
|
|
454
456
|
if (world.shapecast(this.shape, this.pose, flatX, flatY, flatZ, this.stepHit, this.filter)) {
|
|
455
|
-
this.
|
|
456
|
-
this.y = startY;
|
|
457
|
-
this.z = startZ;
|
|
458
|
-
return false;
|
|
457
|
+
return this.stepBack(startX, startY, startZ);
|
|
459
458
|
}
|
|
460
459
|
this.x += flatX;
|
|
461
460
|
this.y += flatY;
|
|
462
461
|
this.z += flatZ;
|
|
463
462
|
this.readPose();
|
|
464
|
-
if (world.shapecast(this.shape, this.pose, -liftX, -liftY, -liftZ, this.stepHit, this.filter)) {
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
this.y -= this.upY * drop;
|
|
468
|
-
this.z -= this.upZ * drop;
|
|
469
|
-
return true;
|
|
463
|
+
if (!world.shapecast(this.shape, this.pose, -liftX, -liftY, -liftZ, this.stepHit, this.filter)) {
|
|
464
|
+
// Nothing beneath the lifted position, so this was a gap rather than a step.
|
|
465
|
+
return this.stepBack(startX, startY, startZ);
|
|
470
466
|
}
|
|
471
|
-
|
|
472
|
-
this.
|
|
473
|
-
|
|
474
|
-
|
|
467
|
+
let landing = this.stepHit.nx * this.upX + this.stepHit.ny * this.upY + this.stepHit.nz * this.upZ;
|
|
468
|
+
if (landing < this.slopeCos) {
|
|
469
|
+
/*
|
|
470
|
+
* **Landed on the lip, so go on over it.** The forward part of a step is what was left of
|
|
471
|
+
* the tick's move when the body met the step, and at a tick's length that can be millimetres:
|
|
472
|
+
* the foot ball then comes down on the step's edge, a contact far too steep to stand on, and
|
|
473
|
+
* the body was set there airborne, slid off, and never stepped again. Measured with a 0.32 m
|
|
474
|
+
* radius and a 0.35 m step height: a 0.25 m rise climbed and a 0.28 m one did not.
|
|
475
|
+
*
|
|
476
|
+
* The edge's normal points from the edge at the ball's centre, so its part across up is how
|
|
477
|
+
* far the axis stands short of the edge. Moving that far again, plus a centimetre, puts the
|
|
478
|
+
* ball over the step's top, where the drop meets a face. The body moves up to one radius
|
|
479
|
+
* further in that tick than it was going to, which is the price of a step taken in one tick.
|
|
480
|
+
*/
|
|
481
|
+
const lipX = this.stepHit.nx - this.upX * landing;
|
|
482
|
+
const lipY = this.stepHit.ny - this.upY * landing;
|
|
483
|
+
const lipZ = this.stepHit.nz - this.upZ * landing;
|
|
484
|
+
const lip = Math.sqrt(lipX * lipX + lipY * lipY + lipZ * lipZ);
|
|
485
|
+
// An edge behind the move, or beside it, is not one this step was climbing.
|
|
486
|
+
if (lip < 1e-6 || lipX * flatX + lipY * flatY + lipZ * flatZ >= 0) {
|
|
487
|
+
return this.stepBack(startX, startY, startZ);
|
|
488
|
+
}
|
|
489
|
+
const reach = (this.radius * lip + STEP_OVER) / lip;
|
|
490
|
+
const overX = -lipX * reach;
|
|
491
|
+
const overY = -lipY * reach;
|
|
492
|
+
const overZ = -lipZ * reach;
|
|
493
|
+
this.readPose();
|
|
494
|
+
if (world.shapecast(this.shape, this.pose, overX, overY, overZ, this.stepHit, this.filter)) {
|
|
495
|
+
return this.stepBack(startX, startY, startZ);
|
|
496
|
+
}
|
|
497
|
+
this.x += overX;
|
|
498
|
+
this.y += overY;
|
|
499
|
+
this.z += overZ;
|
|
500
|
+
this.readPose();
|
|
501
|
+
if (!world.shapecast(this.shape, this.pose, -liftX, -liftY, -liftZ, this.stepHit, this.filter)) {
|
|
502
|
+
return this.stepBack(startX, startY, startZ);
|
|
503
|
+
}
|
|
504
|
+
landing =
|
|
505
|
+
this.stepHit.nx * this.upX + this.stepHit.ny * this.upY + this.stepHit.nz * this.upZ;
|
|
506
|
+
if (landing < this.slopeCos)
|
|
507
|
+
return this.stepBack(startX, startY, startZ);
|
|
508
|
+
}
|
|
509
|
+
const drop = this.stepHeight * this.stepHit.fraction - SNAP_EPS;
|
|
510
|
+
this.x -= this.upX * drop;
|
|
511
|
+
this.y -= this.upY * drop;
|
|
512
|
+
this.z -= this.upZ * drop;
|
|
513
|
+
return true;
|
|
514
|
+
}
|
|
515
|
+
/** Put the body back where a step began, and say the step was not taken. */
|
|
516
|
+
stepBack(x, y, z) {
|
|
517
|
+
this.x = x;
|
|
518
|
+
this.y = y;
|
|
519
|
+
this.z = z;
|
|
475
520
|
return false;
|
|
476
521
|
}
|
|
477
522
|
/**
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
176
|
-
count++;
|
|
197
|
+
emitRound(out, p, separation, 0x40000000 | p);
|
|
177
198
|
}
|
|
178
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/package.json
CHANGED
package/src/controller.ts
CHANGED
|
@@ -170,6 +170,8 @@ const SLIDE_PASSES = 4;
|
|
|
170
170
|
* own stop margin already leaves a tenth of a millimetre of clearance, which is what a skin was for.
|
|
171
171
|
*/
|
|
172
172
|
const SNAP_EPS = 0.001;
|
|
173
|
+
/** How far past a step's edge a step that landed on the edge carries the body's axis. */
|
|
174
|
+
const STEP_OVER = 0.01;
|
|
173
175
|
|
|
174
176
|
export class CharacterController {
|
|
175
177
|
x = 0;
|
|
@@ -603,26 +605,74 @@ export class CharacterController {
|
|
|
603
605
|
this.z += liftZ;
|
|
604
606
|
this.readPose();
|
|
605
607
|
if (world.shapecast(this.shape, this.pose, flatX, flatY, flatZ, this.stepHit, this.filter)) {
|
|
606
|
-
this.
|
|
607
|
-
this.y = startY;
|
|
608
|
-
this.z = startZ;
|
|
609
|
-
return false;
|
|
608
|
+
return this.stepBack(startX, startY, startZ);
|
|
610
609
|
}
|
|
611
610
|
this.x += flatX;
|
|
612
611
|
this.y += flatY;
|
|
613
612
|
this.z += flatZ;
|
|
614
613
|
this.readPose();
|
|
615
|
-
if (
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
614
|
+
if (
|
|
615
|
+
!world.shapecast(this.shape, this.pose, -liftX, -liftY, -liftZ, this.stepHit, this.filter)
|
|
616
|
+
) {
|
|
617
|
+
// Nothing beneath the lifted position, so this was a gap rather than a step.
|
|
618
|
+
return this.stepBack(startX, startY, startZ);
|
|
619
|
+
}
|
|
620
|
+
let landing =
|
|
621
|
+
this.stepHit.nx * this.upX + this.stepHit.ny * this.upY + this.stepHit.nz * this.upZ;
|
|
622
|
+
if (landing < this.slopeCos) {
|
|
623
|
+
/*
|
|
624
|
+
* **Landed on the lip, so go on over it.** The forward part of a step is what was left of
|
|
625
|
+
* the tick's move when the body met the step, and at a tick's length that can be millimetres:
|
|
626
|
+
* the foot ball then comes down on the step's edge, a contact far too steep to stand on, and
|
|
627
|
+
* the body was set there airborne, slid off, and never stepped again. Measured with a 0.32 m
|
|
628
|
+
* radius and a 0.35 m step height: a 0.25 m rise climbed and a 0.28 m one did not.
|
|
629
|
+
*
|
|
630
|
+
* The edge's normal points from the edge at the ball's centre, so its part across up is how
|
|
631
|
+
* far the axis stands short of the edge. Moving that far again, plus a centimetre, puts the
|
|
632
|
+
* ball over the step's top, where the drop meets a face. The body moves up to one radius
|
|
633
|
+
* further in that tick than it was going to, which is the price of a step taken in one tick.
|
|
634
|
+
*/
|
|
635
|
+
const lipX = this.stepHit.nx - this.upX * landing;
|
|
636
|
+
const lipY = this.stepHit.ny - this.upY * landing;
|
|
637
|
+
const lipZ = this.stepHit.nz - this.upZ * landing;
|
|
638
|
+
const lip = Math.sqrt(lipX * lipX + lipY * lipY + lipZ * lipZ);
|
|
639
|
+
// An edge behind the move, or beside it, is not one this step was climbing.
|
|
640
|
+
if (lip < 1e-6 || lipX * flatX + lipY * flatY + lipZ * flatZ >= 0) {
|
|
641
|
+
return this.stepBack(startX, startY, startZ);
|
|
642
|
+
}
|
|
643
|
+
const reach = (this.radius * lip + STEP_OVER) / lip;
|
|
644
|
+
const overX = -lipX * reach;
|
|
645
|
+
const overY = -lipY * reach;
|
|
646
|
+
const overZ = -lipZ * reach;
|
|
647
|
+
this.readPose();
|
|
648
|
+
if (world.shapecast(this.shape, this.pose, overX, overY, overZ, this.stepHit, this.filter)) {
|
|
649
|
+
return this.stepBack(startX, startY, startZ);
|
|
650
|
+
}
|
|
651
|
+
this.x += overX;
|
|
652
|
+
this.y += overY;
|
|
653
|
+
this.z += overZ;
|
|
654
|
+
this.readPose();
|
|
655
|
+
if (
|
|
656
|
+
!world.shapecast(this.shape, this.pose, -liftX, -liftY, -liftZ, this.stepHit, this.filter)
|
|
657
|
+
) {
|
|
658
|
+
return this.stepBack(startX, startY, startZ);
|
|
659
|
+
}
|
|
660
|
+
landing =
|
|
661
|
+
this.stepHit.nx * this.upX + this.stepHit.ny * this.upY + this.stepHit.nz * this.upZ;
|
|
662
|
+
if (landing < this.slopeCos) return this.stepBack(startX, startY, startZ);
|
|
621
663
|
}
|
|
622
|
-
|
|
623
|
-
this.x
|
|
624
|
-
this.y
|
|
625
|
-
this.z
|
|
664
|
+
const drop = this.stepHeight * this.stepHit.fraction - SNAP_EPS;
|
|
665
|
+
this.x -= this.upX * drop;
|
|
666
|
+
this.y -= this.upY * drop;
|
|
667
|
+
this.z -= this.upZ * drop;
|
|
668
|
+
return true;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/** Put the body back where a step began, and say the step was not taken. */
|
|
672
|
+
private stepBack(x: number, y: number, z: number): false {
|
|
673
|
+
this.x = x;
|
|
674
|
+
this.y = y;
|
|
675
|
+
this.z = z;
|
|
626
676
|
return false;
|
|
627
677
|
}
|
|
628
678
|
|
package/src/manifold.ts
CHANGED
|
@@ -207,6 +207,76 @@ function roundRound(a: ConvexShape, b: ConvexShape, margin: number, out: Manifol
|
|
|
207
207
|
|
|
208
208
|
/* ---------- sphere or capsule against a polytope ---------- */
|
|
209
209
|
|
|
210
|
+
/** Where along a capsule's segment the search settles: 0.618 to the 30th of 1.2 m is under a micron. */
|
|
211
|
+
const SEGMENT_STEPS = 30;
|
|
212
|
+
const INV_PHI = 0.6180339887498949;
|
|
213
|
+
/** Each end's direction away from the polytope, and its separation, read by the segment search. */
|
|
214
|
+
const END_DIR = new Float64Array(9);
|
|
215
|
+
const END_SEP = new Float64Array(2);
|
|
216
|
+
/** The manifold being filled by `roundPolytope`, kept here so adding a contact allocates nothing. */
|
|
217
|
+
const ROUND = { count: 0, bestSep: Infinity, nx: 0, ny: 1, nz: 0, margin: 0, flipped: false };
|
|
218
|
+
|
|
219
|
+
/** Add the contact `CLOSEST` and `END_DIR[slot]` describe, at `separation`, as `feature`. */
|
|
220
|
+
function emitRound(out: Manifold, slot: number, separation: number, feature: number): void {
|
|
221
|
+
if (separation > ROUND.margin || ROUND.count >= MAX_CONTACTS) return;
|
|
222
|
+
// Normal points from the round shape toward the polytope, then flipped if A was the polytope.
|
|
223
|
+
const ux = END_DIR[slot * 3] ?? 0;
|
|
224
|
+
const uy = END_DIR[slot * 3 + 1] ?? 0;
|
|
225
|
+
const uz = END_DIR[slot * 3 + 2] ?? 0;
|
|
226
|
+
if (separation < ROUND.bestSep) {
|
|
227
|
+
ROUND.bestSep = separation;
|
|
228
|
+
ROUND.nx = ROUND.flipped ? ux : -ux;
|
|
229
|
+
ROUND.ny = ROUND.flipped ? uy : -uy;
|
|
230
|
+
ROUND.nz = ROUND.flipped ? uz : -uz;
|
|
231
|
+
}
|
|
232
|
+
const at = ROUND.count * 3;
|
|
233
|
+
out.points[at] = CLOSEST[0];
|
|
234
|
+
out.points[at + 1] = CLOSEST[1];
|
|
235
|
+
out.points[at + 2] = CLOSEST[2];
|
|
236
|
+
out.separations[ROUND.count] = separation;
|
|
237
|
+
out.featureIds[ROUND.count] = feature;
|
|
238
|
+
ROUND.count++;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The signed distance from a point to the polytope, with the direction away from it left in
|
|
243
|
+
* `END_DIR[slot]`: positive outside, the deepest face's plane distance inside.
|
|
244
|
+
*/
|
|
245
|
+
function signedDistance(
|
|
246
|
+
poly: ConvexShape,
|
|
247
|
+
polyWorld: Float32Array,
|
|
248
|
+
faces: number,
|
|
249
|
+
px: number,
|
|
250
|
+
py: number,
|
|
251
|
+
pz: number,
|
|
252
|
+
slot: number,
|
|
253
|
+
): number {
|
|
254
|
+
closestOnPolytope(poly, polyWorld, planesB, faces, px, py, pz);
|
|
255
|
+
let ux = px - CLOSEST[0];
|
|
256
|
+
let uy = py - CLOSEST[1];
|
|
257
|
+
let uz = pz - CLOSEST[2];
|
|
258
|
+
let d = Math.sqrt(ux * ux + uy * uy + uz * uz);
|
|
259
|
+
if (CLOSEST[6] < 0) {
|
|
260
|
+
// Inside: the closest face's outward normal is the direction, and depth is its plane distance.
|
|
261
|
+
ux = CLOSEST[3];
|
|
262
|
+
uy = CLOSEST[4];
|
|
263
|
+
uz = CLOSEST[5];
|
|
264
|
+
d = CLOSEST[6];
|
|
265
|
+
} else if (d === 0) {
|
|
266
|
+
ux = CLOSEST[3];
|
|
267
|
+
uy = CLOSEST[4];
|
|
268
|
+
uz = CLOSEST[5];
|
|
269
|
+
} else {
|
|
270
|
+
ux /= d;
|
|
271
|
+
uy /= d;
|
|
272
|
+
uz /= d;
|
|
273
|
+
}
|
|
274
|
+
END_DIR[slot * 3] = ux;
|
|
275
|
+
END_DIR[slot * 3 + 1] = uy;
|
|
276
|
+
END_DIR[slot * 3 + 2] = uz;
|
|
277
|
+
return d;
|
|
278
|
+
}
|
|
279
|
+
|
|
210
280
|
function roundPolytope(
|
|
211
281
|
round: ConvexShape,
|
|
212
282
|
poly: ConvexShape,
|
|
@@ -222,63 +292,92 @@ function roundPolytope(
|
|
|
222
292
|
|
|
223
293
|
const points = round.vertices.length / 3;
|
|
224
294
|
const faces = faceCount(poly);
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
} else if (d === 0) {
|
|
247
|
-
ux = CLOSEST[3];
|
|
248
|
-
uy = CLOSEST[4];
|
|
249
|
-
uz = CLOSEST[5];
|
|
250
|
-
} else {
|
|
251
|
-
ux /= d;
|
|
252
|
-
uy /= d;
|
|
253
|
-
uz /= d;
|
|
254
|
-
}
|
|
255
|
-
const separation = d - round.radius - poly.radius;
|
|
256
|
-
if (separation > margin) continue;
|
|
257
|
-
if (count >= MAX_CONTACTS) break;
|
|
258
|
-
// Normal points from the round shape toward the polytope, then flipped if A was the polytope.
|
|
259
|
-
const sx = flipped ? ux : -ux;
|
|
260
|
-
const sy = flipped ? uy : -uy;
|
|
261
|
-
const sz = flipped ? uz : -uz;
|
|
262
|
-
if (separation < bestSep) {
|
|
263
|
-
bestSep = separation;
|
|
264
|
-
nx = sx;
|
|
265
|
-
ny = sy;
|
|
266
|
-
nz = sz;
|
|
267
|
-
}
|
|
268
|
-
const at = count * 3;
|
|
269
|
-
out.points[at] = CLOSEST[0];
|
|
270
|
-
out.points[at + 1] = CLOSEST[1];
|
|
271
|
-
out.points[at + 2] = CLOSEST[2];
|
|
272
|
-
out.separations[count] = separation;
|
|
295
|
+
const radii = round.radius + poly.radius;
|
|
296
|
+
ROUND.count = 0;
|
|
297
|
+
ROUND.bestSep = Infinity;
|
|
298
|
+
ROUND.nx = 0;
|
|
299
|
+
ROUND.ny = 1;
|
|
300
|
+
ROUND.nz = 0;
|
|
301
|
+
ROUND.margin = margin;
|
|
302
|
+
ROUND.flipped = flipped;
|
|
303
|
+
|
|
304
|
+
for (let p = 0; p < points && p < 2; p++) {
|
|
305
|
+
const separation =
|
|
306
|
+
signedDistance(
|
|
307
|
+
poly,
|
|
308
|
+
polyWorld,
|
|
309
|
+
faces,
|
|
310
|
+
roundWorld[p * 3] ?? 0,
|
|
311
|
+
roundWorld[p * 3 + 1] ?? 0,
|
|
312
|
+
roundWorld[p * 3 + 2] ?? 0,
|
|
313
|
+
p,
|
|
314
|
+
) - radii;
|
|
315
|
+
END_SEP[p] = separation;
|
|
273
316
|
// The endpoint that produced it, which is stable while the capsule keeps its orientation.
|
|
274
|
-
out
|
|
275
|
-
count++;
|
|
317
|
+
emitRound(out, p, separation, 0x40000000 | p);
|
|
276
318
|
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
319
|
+
|
|
320
|
+
/*
|
|
321
|
+
* **A capsule is a segment, and its middle can be the part that touches.** This used to ask only
|
|
322
|
+
* its two ends, which is exact for a ball and for a capsule lying on a face, and wrong wherever the
|
|
323
|
+
* segment crosses an edge: a standing capsule walked through a slab across its waist, because
|
|
324
|
+
* neither end ball came within reach of it, and a capsule lying across a rail fell through it.
|
|
325
|
+
*
|
|
326
|
+
* The distance from a point on the segment to a convex polytope is convex along the segment, so
|
|
327
|
+
* its least value is at an end exactly when the slope there points away from the other end. Each
|
|
328
|
+
* end's direction away from the polytope is that slope, so a capsule whose ends are the answer, as
|
|
329
|
+
* one standing on a floor always is, costs nothing more. Otherwise a fixed number of golden-section
|
|
330
|
+
* steps finds the interior point, and it is kept only where it is nearer than both ends.
|
|
331
|
+
*/
|
|
332
|
+
if (points === 2) {
|
|
333
|
+
const ax = roundWorld[0] ?? 0;
|
|
334
|
+
const ay = roundWorld[1] ?? 0;
|
|
335
|
+
const az = roundWorld[2] ?? 0;
|
|
336
|
+
const sx = (roundWorld[3] ?? 0) - ax;
|
|
337
|
+
const sy = (roundWorld[4] ?? 0) - ay;
|
|
338
|
+
const sz = (roundWorld[5] ?? 0) - az;
|
|
339
|
+
const leavesFirst = sx * (END_DIR[0] ?? 0) + sy * (END_DIR[1] ?? 0) + sz * (END_DIR[2] ?? 0);
|
|
340
|
+
const leavesSecond = -(
|
|
341
|
+
sx * (END_DIR[3] ?? 0) +
|
|
342
|
+
sy * (END_DIR[4] ?? 0) +
|
|
343
|
+
sz * (END_DIR[5] ?? 0)
|
|
344
|
+
);
|
|
345
|
+
if (leavesFirst < 0 && leavesSecond < 0) {
|
|
346
|
+
let lo = 0;
|
|
347
|
+
let hi = 1;
|
|
348
|
+
let m1 = hi - (hi - lo) * INV_PHI;
|
|
349
|
+
let m2 = lo + (hi - lo) * INV_PHI;
|
|
350
|
+
let f1 = signedDistance(poly, polyWorld, faces, ax + sx * m1, ay + sy * m1, az + sz * m1, 2);
|
|
351
|
+
let f2 = signedDistance(poly, polyWorld, faces, ax + sx * m2, ay + sy * m2, az + sz * m2, 2);
|
|
352
|
+
for (let step = 0; step < SEGMENT_STEPS; step++) {
|
|
353
|
+
if (f1 <= f2) {
|
|
354
|
+
hi = m2;
|
|
355
|
+
m2 = m1;
|
|
356
|
+
f2 = f1;
|
|
357
|
+
m1 = hi - (hi - lo) * INV_PHI;
|
|
358
|
+
f1 = signedDistance(poly, polyWorld, faces, ax + sx * m1, ay + sy * m1, az + sz * m1, 2);
|
|
359
|
+
} else {
|
|
360
|
+
lo = m1;
|
|
361
|
+
m1 = m2;
|
|
362
|
+
f1 = f2;
|
|
363
|
+
m2 = lo + (hi - lo) * INV_PHI;
|
|
364
|
+
f2 = signedDistance(poly, polyWorld, faces, ax + sx * m2, ay + sy * m2, az + sz * m2, 2);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
const at = (lo + hi) / 2;
|
|
368
|
+
const separation =
|
|
369
|
+
signedDistance(poly, polyWorld, faces, ax + sx * at, ay + sy * at, az + sz * at, 2) - radii;
|
|
370
|
+
if (separation < Math.min(END_SEP[0] ?? Infinity, END_SEP[1] ?? Infinity) - 1e-7) {
|
|
371
|
+
emitRound(out, 2, separation, 0x40000000 | 2);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (ROUND.count === 0) return false;
|
|
377
|
+
out.nx = ROUND.nx;
|
|
378
|
+
out.ny = ROUND.ny;
|
|
379
|
+
out.nz = ROUND.nz;
|
|
380
|
+
out.count = ROUND.count;
|
|
282
381
|
return true;
|
|
283
382
|
}
|
|
284
383
|
|
package/src/query.ts
CHANGED
|
@@ -858,7 +858,14 @@ export function shapecastWorld(
|
|
|
858
858
|
if (chosen === undefined) break;
|
|
859
859
|
copyManifold(chosen, manifold);
|
|
860
860
|
} else if (!collideShapes(shape, poseA, other, poseB, 1e9, manifold)) break;
|
|
861
|
-
|
|
861
|
+
/*
|
|
862
|
+
* **The nearest contact, not the first one written.** A capsule's manifold holds a point per
|
|
863
|
+
* end, and the first is its lower end: reading only that advanced the sweep by the distance
|
|
864
|
+
* from the lower ball while the upper one was the nearer, so a standing capsule swept clean
|
|
865
|
+
* through a beam at head height.
|
|
866
|
+
*/
|
|
867
|
+
let gap = Infinity;
|
|
868
|
+
for (let c = 0; c < manifold.count; c++) gap = Math.min(gap, manifold.separations[c] ?? gap);
|
|
862
869
|
if (gap < 1e-4) {
|
|
863
870
|
/*
|
|
864
871
|
* A shape already touching a surface it is travelling *along* has not run into it.
|
|
@@ -874,7 +881,23 @@ export function shapecastWorld(
|
|
|
874
881
|
touched = true;
|
|
875
882
|
break;
|
|
876
883
|
}
|
|
877
|
-
|
|
884
|
+
/*
|
|
885
|
+
* **How far the shapes may close along the contact normal, not along the travel.** The
|
|
886
|
+
* manifold's normal separates the two by `gap`, so they cannot meet before the sweep has
|
|
887
|
+
* closed that much along it. Dividing by the whole travel was always safe and crawled
|
|
888
|
+
* wherever the travel grazes the surface: a ball dropping past the lip of a step closes on it
|
|
889
|
+
* at a sixth of its speed, and twenty-four steps ended before it arrived, so the sweep said
|
|
890
|
+
* nothing was there. A mesh keeps the old step, since one triangle's normal does not separate
|
|
891
|
+
* the whole mesh.
|
|
892
|
+
*/
|
|
893
|
+
if (isMesh(shape) || isMesh(other)) {
|
|
894
|
+
t += gap / travel;
|
|
895
|
+
} else {
|
|
896
|
+
const closing = dx * manifold.nx + dy * manifold.ny + dz * manifold.nz;
|
|
897
|
+
// Not closing along the normal that separates them, so they never meet on this travel.
|
|
898
|
+
if (closing <= 1e-12) break;
|
|
899
|
+
t += gap / closing;
|
|
900
|
+
}
|
|
878
901
|
if (t > 1) break;
|
|
879
902
|
}
|
|
880
903
|
if (brushing || !touched || t > 1) continue;
|