@woosh/meep-engine 2.144.0 → 2.145.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/package.json +1 -1
- package/src/core/bvh2/bvh3/BVH.d.ts.map +1 -1
- package/src/core/bvh2/bvh3/BVH.js +158 -4
- package/src/core/geom/3d/shape/CylinderShape3D.d.ts +56 -0
- package/src/core/geom/3d/shape/CylinderShape3D.d.ts.map +1 -0
- package/src/core/geom/3d/shape/CylinderShape3D.js +223 -0
- package/src/core/geom/3d/shape/json/shape_to_type.d.ts.map +1 -1
- package/src/core/geom/3d/shape/json/shape_to_type.js +3 -0
- package/src/core/geom/3d/shape/json/type_adapters.d.ts +15 -0
- package/src/core/geom/3d/shape/json/type_adapters.d.ts.map +1 -1
- package/src/core/geom/3d/shape/json/type_adapters.js +16 -0
- package/src/engine/control/first-person/DESIGN_COLLISION.md +78 -28
- package/src/engine/control/first-person/FirstPersonPlayerControllerSystem.d.ts +13 -0
- package/src/engine/control/first-person/FirstPersonPlayerControllerSystem.d.ts.map +1 -1
- package/src/engine/control/first-person/FirstPersonPlayerControllerSystem.js +16 -2
- package/src/engine/control/first-person/abilities/WallRun.d.ts.map +1 -1
- package/src/engine/control/first-person/abilities/WallRun.js +18 -35
- package/src/engine/control/first-person/collision/KinematicMover.d.ts +35 -5
- package/src/engine/control/first-person/collision/KinematicMover.d.ts.map +1 -1
- package/src/engine/control/first-person/collision/KinematicMover.js +178 -10
- package/src/engine/physics/PLAN.md +51 -9
package/package.json
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"description": "Pure JavaScript game engine. Fully featured and production ready.",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"author": "Alexander Goldring",
|
|
9
|
-
"version": "2.
|
|
9
|
+
"version": "2.145.0",
|
|
10
10
|
"main": "build/meep.module.js",
|
|
11
11
|
"module": "build/meep.module.js",
|
|
12
12
|
"exports": {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"BVH.d.ts","sourceRoot":"","sources":["../../../../../src/core/bvh2/bvh3/BVH.js"],"names":[],"mappings":"AASA,8BAA+B;AAC/B,+BAAgC;AAChC,+BAAgC;AAChC,8BAA+B;AAE/B;;;;;GAKG;AACH,+BAFU,MAAM,CAE+B;AAE/C;;;GAGG;AACH,wBAFU,MAAM,CAEoB;AAcpC;;;;;GAKG;AACH,iCAFU,MAAM,CAEqB;AAiBrC;;;;;;;;GAQG;AACH;IAEI;;;;OAIG;IACH,sBAAuE;IAYvE;;;;;OAKG;IACH,kCAUC;IA1BD;;;;;OAKG;IACH,+BAEC;IAoBD;;;;OAIG;IACH,uBAAsD;IAUtD;;;;OAIG;IACH,sBAAoD;IAsEpD;;;OAGG;IACH,+BAMC;IAlBD;;;OAGG;IACH,4BAEC;IAjFD;;;OAGG;IACH,iCAEC;IASD;;;;OAIG;IACH,mBAA8B;IAE9B;;;;OAIG;IACH,eAAW;IAEX;;;;OAIG;IACH,eAAY;IAEZ;;;;OAIG;IACH,uBAAmB;IAEnB;;;;OAIG;IACH,eAAmB;IAUnB;;;OAGG;IACH,sBAEC;IAdD;;;OAGG;IACH,mBAEC;IAUD;;;;OAIG;IACH,mBAEC;IAsBD,wBAgBC;IAED;;;;OAIG;IACH,uBA6BC;IAED;;OAEG;IACH,aAIC;IAED;;;;;;;OAOG;IACH,4BAFW,MAAM,QAQhB;IAED;;;OAGG;IACH,iBAFa,MAAM,CAqDlB;IAED;;;;OAIG;IACH,iBAFW,MAAM,QAMhB;IAED;;;;OAIG;IACH,iBAHW,MAAM,GACJ,OAAO,CAOnB;IAED;;;;OAIG;IACH,uBAHW,MAAM,GACJ,MAAM,CAKlB;IAED;;;;OAIG;IACH,uBAHW,MAAM,SACN,MAAM,QAOhB;IAED;;;;OAIG;IACH,oBAHW,MAAM,GACJ,MAAM,CAKlB;IAED;;;;OAIG;IACH,sBAHW,MAAM,UACN,MAAM,QAIhB;IAED;;;;OAIG;IACH,oBAHW,MAAM,GACJ,MAAM,CAKlB;IAED;;;;OAIG;IACH,sBAHW,MAAM,UACN,MAAM,QAIhB;IAED;;;;OAIG;IACH,oBAHW,MAAM,GACJ,MAAM,CAKlB;IAED;;;;OAIG;IACH,sBAHW,MAAM,UACN,MAAM,QAIhB;IAGD;;;;OAIG;IACH,oBAHW,MAAM,GACJ,MAAM,CAKlB;IAED;;;;OAIG;IACH,oBAHW,MAAM,UACN,MAAM,QAKhB;IAED;;;;OAIG;IACH,kBAHW,MAAM,UACN,MAAM,EAAE,GAAC,YAAY,QAe/B;IAED;;;;OAIG;IACH,kBAHW,MAAM,QACN,MAAM,EAAE,GAAC,UAAU,MAAM,CAAC,QAAM,QAsB1C;IAED;;;;OAIG;IACH,mBAHW,MAAM,QACN,MAAM,EAAE,QAWlB;IAED;;;;;;;;;OASG;IACH,4BARW,MAAM,MACN,MAAM,MACN,MAAM,MACN,MAAM,MACN,MAAM,MACN,MAAM,MACN,MAAM,QAmBhB;IAED;;;;OAIG;IACH,0BAHW,MAAM,GACJ,MAAM,CAoBlB;IAED;;;;;OAKG;IACH,wCAJW,MAAM,WACN,MAAM,GACJ,MAAM,CAoClB;IAED;;;;;OAKG;IACH,oCAJW,MAAM,WACN,MAAM,WACN,MAAM,QAgBhB;IAED;;;;OAIG;IACH,kBAHW,MAAM,GACJ,IAAI,CA0GhB;IAED;;;;;OAKG;IACH,wBAqBC;IAED;;;;OAIG;IACH,
|
|
1
|
+
{"version":3,"file":"BVH.d.ts","sourceRoot":"","sources":["../../../../../src/core/bvh2/bvh3/BVH.js"],"names":[],"mappings":"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"}
|
|
@@ -800,7 +800,14 @@ export class BVH {
|
|
|
800
800
|
const uint32 = this.__data_uint32;
|
|
801
801
|
|
|
802
802
|
while (index !== NULL_NODE) {
|
|
803
|
-
|
|
803
|
+
// SAH-reducing rotation (Box2D v3 style) — keeps query cost near the
|
|
804
|
+
// SAH optimum. {@link balance_height} (pure height-AVL) is kept as
|
|
805
|
+
// the alternative. The tree shape this produces differs from the
|
|
806
|
+
// height-balanced one, which (because the contact solver's order
|
|
807
|
+
// follows broadphase traversal order) shifts Gauss-Seidel
|
|
808
|
+
// convergence on near-aligned stacks — see the BVH balance note in
|
|
809
|
+
// engine/physics/PLAN.md.
|
|
810
|
+
index = this.balance_rotate(index);
|
|
804
811
|
|
|
805
812
|
const node_address = index * ELEMENT_WORD_COUNT;
|
|
806
813
|
|
|
@@ -875,13 +882,18 @@ export class BVH {
|
|
|
875
882
|
}
|
|
876
883
|
|
|
877
884
|
/**
|
|
878
|
-
*
|
|
879
|
-
*
|
|
885
|
+
* **Height-balancing** rotation (Box2D v2 `b2DynamicTree::Balance`): if the
|
|
886
|
+
* two subtrees of node A differ in height by more than one, rotate the
|
|
887
|
+
* taller grandchild up. Keeps the tree height ≤ O(log N) but is chosen
|
|
888
|
+
* purely on *height* — it does not consider surface area, so the result is
|
|
889
|
+
* height-balanced yet not SAH-balanced (query cost can stay 1.3–2× above an
|
|
890
|
+
* SAH-optimal tree). See {@link balance_rotate} for the SAH-driven variant.
|
|
891
|
+
* Returns the (possibly new) subtree-root index.
|
|
880
892
|
* @param {number} iA
|
|
881
893
|
* @returns {number}
|
|
882
894
|
* @private
|
|
883
895
|
*/
|
|
884
|
-
|
|
896
|
+
balance_height(iA) {
|
|
885
897
|
assert.notEqual(iA, NULL_NODE, 'input is a null node');
|
|
886
898
|
|
|
887
899
|
//b2TreeNode* A = m_nodes + iA;
|
|
@@ -1079,6 +1091,148 @@ export class BVH {
|
|
|
1079
1091
|
return iA;
|
|
1080
1092
|
}
|
|
1081
1093
|
|
|
1094
|
+
/**
|
|
1095
|
+
* **SAH-reducing** rotation (Kensler 2008 / Box2D v3 `b2RotateNodes`). For
|
|
1096
|
+
* node A with children B, C, consider the four ways to swap a child of A
|
|
1097
|
+
* with a grandchild (a child of the other child); each rotation re-forms
|
|
1098
|
+
* exactly one internal node and changes the tree's total surface-area
|
|
1099
|
+
* (SAH) cost by `area(old child) − area(new child)`. Apply the swap with
|
|
1100
|
+
* the largest positive reduction, or none if no swap helps.
|
|
1101
|
+
*
|
|
1102
|
+
* Unlike {@link balance_height} the subtree root A never moves (so the
|
|
1103
|
+
* return value is always `iA`), and the criterion is *surface area*, not
|
|
1104
|
+
* height — keeping query cost near the SAH optimum. Height is not an
|
|
1105
|
+
* explicit invariant here, but SAH-good trees stay well-shaped for
|
|
1106
|
+
* spatially-distributed data (this is what modern Box2D ships).
|
|
1107
|
+
*
|
|
1108
|
+
* Deterministic: a pure function of the current tree state.
|
|
1109
|
+
*
|
|
1110
|
+
* @param {number} iA
|
|
1111
|
+
* @returns {number} always `iA` (the subtree root is unchanged)
|
|
1112
|
+
* @private
|
|
1113
|
+
*/
|
|
1114
|
+
balance_rotate(iA) {
|
|
1115
|
+
assert.notEqual(iA, NULL_NODE, 'input is a null node');
|
|
1116
|
+
|
|
1117
|
+
const uint32 = this.__data_uint32;
|
|
1118
|
+
const a_addr = iA * ELEMENT_WORD_COUNT;
|
|
1119
|
+
|
|
1120
|
+
if (this.node_is_leaf(iA) || uint32[a_addr + COLUMN_HEIGHT] < 2) {
|
|
1121
|
+
return iA;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
const iB = uint32[a_addr + COLUMN_CHILD_1];
|
|
1125
|
+
const iC = uint32[a_addr + COLUMN_CHILD_2];
|
|
1126
|
+
|
|
1127
|
+
const b_leaf = this.node_is_leaf(iB);
|
|
1128
|
+
const c_leaf = this.node_is_leaf(iC);
|
|
1129
|
+
|
|
1130
|
+
// Largest beneficial SAH reduction found so far. Strictly positive, so a
|
|
1131
|
+
// zero-improvement (or worse) configuration leaves the tree untouched —
|
|
1132
|
+
// no churn, and deterministic.
|
|
1133
|
+
let best_improvement = 0;
|
|
1134
|
+
let best_rotation = 0; // 1..4, 0 = keep as-is
|
|
1135
|
+
|
|
1136
|
+
let iD = NULL_NODE, iE = NULL_NODE, iF = NULL_NODE, iG = NULL_NODE;
|
|
1137
|
+
|
|
1138
|
+
if (!b_leaf) {
|
|
1139
|
+
// B is internal with children D, E. Moving C down into B (and a
|
|
1140
|
+
// grandchild D/E up to A) re-forms B; the only changed area is B's.
|
|
1141
|
+
const area_b = this.node_get_surface_area(iB);
|
|
1142
|
+
iD = uint32[iB * ELEMENT_WORD_COUNT + COLUMN_CHILD_1];
|
|
1143
|
+
iE = uint32[iB * ELEMENT_WORD_COUNT + COLUMN_CHILD_2];
|
|
1144
|
+
|
|
1145
|
+
// Rotation 1 — swap C and D → B = (C, E), A = (B, D).
|
|
1146
|
+
const imp1 = area_b - this.node_get_combined_surface_area(iC, iE);
|
|
1147
|
+
if (imp1 > best_improvement) { best_improvement = imp1; best_rotation = 1; }
|
|
1148
|
+
|
|
1149
|
+
// Rotation 2 — swap C and E → B = (D, C), A = (B, E).
|
|
1150
|
+
const imp2 = area_b - this.node_get_combined_surface_area(iD, iC);
|
|
1151
|
+
if (imp2 > best_improvement) { best_improvement = imp2; best_rotation = 2; }
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
if (!c_leaf) {
|
|
1155
|
+
// C is internal with children F, G. Symmetric: re-forms C.
|
|
1156
|
+
const area_c = this.node_get_surface_area(iC);
|
|
1157
|
+
iF = uint32[iC * ELEMENT_WORD_COUNT + COLUMN_CHILD_1];
|
|
1158
|
+
iG = uint32[iC * ELEMENT_WORD_COUNT + COLUMN_CHILD_2];
|
|
1159
|
+
|
|
1160
|
+
// Rotation 3 — swap B and F → C = (B, G), A = (F, C).
|
|
1161
|
+
const imp3 = area_c - this.node_get_combined_surface_area(iB, iG);
|
|
1162
|
+
if (imp3 > best_improvement) { best_improvement = imp3; best_rotation = 3; }
|
|
1163
|
+
|
|
1164
|
+
// Rotation 4 — swap B and G → C = (F, B), A = (G, C).
|
|
1165
|
+
const imp4 = area_c - this.node_get_combined_surface_area(iF, iB);
|
|
1166
|
+
if (imp4 > best_improvement) { best_improvement = imp4; best_rotation = 4; }
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
if (best_rotation === 0) {
|
|
1170
|
+
return iA; // no beneficial rotation
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
// Apply the chosen rotation: re-wire one child↔grandchild pair, then
|
|
1174
|
+
// refresh the re-formed internal node's AABB + height. A's AABB / height
|
|
1175
|
+
// are recomputed by the caller (bubble_up_update) after we return, but
|
|
1176
|
+
// we also set A's height here so the subtree is self-consistent.
|
|
1177
|
+
switch (best_rotation) {
|
|
1178
|
+
case 1: { // swap C and D: B = (C, E), A = (B, D)
|
|
1179
|
+
uint32[a_addr + COLUMN_CHILD_2] = iD;
|
|
1180
|
+
uint32[iB * ELEMENT_WORD_COUNT + COLUMN_CHILD_1] = iC;
|
|
1181
|
+
uint32[iD * ELEMENT_WORD_COUNT + COLUMN_PARENT] = iA;
|
|
1182
|
+
uint32[iC * ELEMENT_WORD_COUNT + COLUMN_PARENT] = iB;
|
|
1183
|
+
this.node_set_combined_aabb(iB, iC, iE);
|
|
1184
|
+
uint32[iB * ELEMENT_WORD_COUNT + COLUMN_HEIGHT] = 1 + max2(
|
|
1185
|
+
uint32[iC * ELEMENT_WORD_COUNT + COLUMN_HEIGHT],
|
|
1186
|
+
uint32[iE * ELEMENT_WORD_COUNT + COLUMN_HEIGHT]
|
|
1187
|
+
);
|
|
1188
|
+
break;
|
|
1189
|
+
}
|
|
1190
|
+
case 2: { // swap C and E: B = (D, C), A = (B, E)
|
|
1191
|
+
uint32[a_addr + COLUMN_CHILD_2] = iE;
|
|
1192
|
+
uint32[iB * ELEMENT_WORD_COUNT + COLUMN_CHILD_2] = iC;
|
|
1193
|
+
uint32[iE * ELEMENT_WORD_COUNT + COLUMN_PARENT] = iA;
|
|
1194
|
+
uint32[iC * ELEMENT_WORD_COUNT + COLUMN_PARENT] = iB;
|
|
1195
|
+
this.node_set_combined_aabb(iB, iD, iC);
|
|
1196
|
+
uint32[iB * ELEMENT_WORD_COUNT + COLUMN_HEIGHT] = 1 + max2(
|
|
1197
|
+
uint32[iD * ELEMENT_WORD_COUNT + COLUMN_HEIGHT],
|
|
1198
|
+
uint32[iC * ELEMENT_WORD_COUNT + COLUMN_HEIGHT]
|
|
1199
|
+
);
|
|
1200
|
+
break;
|
|
1201
|
+
}
|
|
1202
|
+
case 3: { // swap B and F: C = (B, G), A = (F, C)
|
|
1203
|
+
uint32[a_addr + COLUMN_CHILD_1] = iF;
|
|
1204
|
+
uint32[iC * ELEMENT_WORD_COUNT + COLUMN_CHILD_1] = iB;
|
|
1205
|
+
uint32[iF * ELEMENT_WORD_COUNT + COLUMN_PARENT] = iA;
|
|
1206
|
+
uint32[iB * ELEMENT_WORD_COUNT + COLUMN_PARENT] = iC;
|
|
1207
|
+
this.node_set_combined_aabb(iC, iB, iG);
|
|
1208
|
+
uint32[iC * ELEMENT_WORD_COUNT + COLUMN_HEIGHT] = 1 + max2(
|
|
1209
|
+
uint32[iB * ELEMENT_WORD_COUNT + COLUMN_HEIGHT],
|
|
1210
|
+
uint32[iG * ELEMENT_WORD_COUNT + COLUMN_HEIGHT]
|
|
1211
|
+
);
|
|
1212
|
+
break;
|
|
1213
|
+
}
|
|
1214
|
+
case 4: { // swap B and G: C = (F, B), A = (G, C)
|
|
1215
|
+
uint32[a_addr + COLUMN_CHILD_1] = iG;
|
|
1216
|
+
uint32[iC * ELEMENT_WORD_COUNT + COLUMN_CHILD_2] = iB;
|
|
1217
|
+
uint32[iG * ELEMENT_WORD_COUNT + COLUMN_PARENT] = iA;
|
|
1218
|
+
uint32[iB * ELEMENT_WORD_COUNT + COLUMN_PARENT] = iC;
|
|
1219
|
+
this.node_set_combined_aabb(iC, iF, iB);
|
|
1220
|
+
uint32[iC * ELEMENT_WORD_COUNT + COLUMN_HEIGHT] = 1 + max2(
|
|
1221
|
+
uint32[iF * ELEMENT_WORD_COUNT + COLUMN_HEIGHT],
|
|
1222
|
+
uint32[iB * ELEMENT_WORD_COUNT + COLUMN_HEIGHT]
|
|
1223
|
+
);
|
|
1224
|
+
break;
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
uint32[a_addr + COLUMN_HEIGHT] = 1 + max2(
|
|
1229
|
+
uint32[uint32[a_addr + COLUMN_CHILD_1] * ELEMENT_WORD_COUNT + COLUMN_HEIGHT],
|
|
1230
|
+
uint32[uint32[a_addr + COLUMN_CHILD_2] * ELEMENT_WORD_COUNT + COLUMN_HEIGHT]
|
|
1231
|
+
);
|
|
1232
|
+
|
|
1233
|
+
return iA;
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1082
1236
|
/**
|
|
1083
1237
|
* Utility method for assigning both children at once
|
|
1084
1238
|
* Children must be valid nodes (non-null)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A solid right circular cylinder aligned with the Y axis, centred at the local
|
|
3
|
+
* origin. A disk of {@link radius} extruded along Y for {@link height}, so it
|
|
4
|
+
* spans `y ∈ [-height/2, +height/2]` with **flat** circular caps (the capsule's
|
|
5
|
+
* flat-cap sibling — same radius/height parametrisation, hemispherical caps
|
|
6
|
+
* replaced by disks).
|
|
7
|
+
*
|
|
8
|
+
* Convex, so the physics narrowphase handles it through the GJK + EPA fallback
|
|
9
|
+
* via {@link support} — there is no closed-form cylinder-vs-X contact path yet
|
|
10
|
+
* (a future refinement; the curved side is the usual smooth-support EPA case).
|
|
11
|
+
*
|
|
12
|
+
* @author Alex Goldring
|
|
13
|
+
* @copyright Company Named Limited (c) 2026
|
|
14
|
+
*/
|
|
15
|
+
export class CylinderShape3D extends AbstractShape3D {
|
|
16
|
+
/**
|
|
17
|
+
* @param {number} radius
|
|
18
|
+
* @param {number} height
|
|
19
|
+
* @returns {CylinderShape3D}
|
|
20
|
+
*/
|
|
21
|
+
static from(radius: number, height: number): CylinderShape3D;
|
|
22
|
+
/**
|
|
23
|
+
* Radius of the circular cross-section.
|
|
24
|
+
* @type {number}
|
|
25
|
+
*/
|
|
26
|
+
radius: number;
|
|
27
|
+
/**
|
|
28
|
+
* Full height along the Y axis (total extent — the cylinder spans
|
|
29
|
+
* `[-height/2, +height/2]`).
|
|
30
|
+
* @type {number}
|
|
31
|
+
*/
|
|
32
|
+
height: number;
|
|
33
|
+
compute_bounding_box(result: any): void;
|
|
34
|
+
support(result: any, result_offset: any, direction_x: any, direction_y: any, direction_z: any): void;
|
|
35
|
+
signed_distance_at_point(point: any): number;
|
|
36
|
+
signed_distance_gradient_at_point(result: any, point: any): number;
|
|
37
|
+
contains_point(point: any): boolean;
|
|
38
|
+
nearest_point_on_surface(result: any, reference: any): void;
|
|
39
|
+
sample_random_point_in_volume(result: any, result_offset: any, random: any): void;
|
|
40
|
+
/**
|
|
41
|
+
* @param {CylinderShape3D} other
|
|
42
|
+
* @returns {boolean}
|
|
43
|
+
*/
|
|
44
|
+
equals(other: CylinderShape3D): boolean;
|
|
45
|
+
/**
|
|
46
|
+
* Fast type-check marker, mirroring `isSphereShape3D` / `isBoxShape3D` /
|
|
47
|
+
* `isCapsuleShape3D`. Reserved for a future closed-form cylinder narrowphase
|
|
48
|
+
* dispatch; until then the cylinder routes through GJK + EPA like any other
|
|
49
|
+
* convex shape (no marker check needed for that path).
|
|
50
|
+
* @readonly
|
|
51
|
+
* @type {boolean}
|
|
52
|
+
*/
|
|
53
|
+
readonly isCylinderShape3D: boolean;
|
|
54
|
+
}
|
|
55
|
+
import { AbstractShape3D } from "./AbstractShape3D.js";
|
|
56
|
+
//# sourceMappingURL=CylinderShape3D.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"CylinderShape3D.d.ts","sourceRoot":"","sources":["../../../../../../src/core/geom/3d/shape/CylinderShape3D.js"],"names":[],"mappings":"AAUA;;;;;;;;;;;;;GAaG;AACH;IAkBI;;;;OAIG;IACH,oBAJW,MAAM,UACN,MAAM,GACJ,eAAe,CAa3B;IA9BG;;;OAGG;IACH,QAFU,MAAM,CAEC;IAEjB;;;;OAIG;IACH,QAFU,MAAM,CAED;IAgCnB,wCAUC;IAED,qGAqBC;IAED,6CAoBC;IAED,mEAEC;IAED,oCASC;IAED,4DA0CC;IAED,kFAOC;IAED;;;OAGG;IACH,cAHW,eAAe,GACb,OAAO,CAMnB;IAUL;;;;;;;OAOG;IACH,4BAFU,OAAO,CAE0B;CAV1C;gCA/M+B,sBAAsB"}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { assert } from "../../../assert.js";
|
|
2
|
+
import { clamp } from "../../../math/clamp.js";
|
|
3
|
+
import { sign_not_zero } from "../../../math/sign_not_zero.js";
|
|
4
|
+
import { computeHashFloat } from "../../../primitives/numbers/computeHashFloat.js";
|
|
5
|
+
import { randomPointInCircle } from "../../random/randomPointInCircle.js";
|
|
6
|
+
import { AbstractShape3D } from "./AbstractShape3D.js";
|
|
7
|
+
import { compute_signed_distance_gradient_by_sampling } from "./util/compute_signed_distance_gradient_by_sampling.js";
|
|
8
|
+
|
|
9
|
+
const scratch_v3 = [0, 0, 0];
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A solid right circular cylinder aligned with the Y axis, centred at the local
|
|
13
|
+
* origin. A disk of {@link radius} extruded along Y for {@link height}, so it
|
|
14
|
+
* spans `y ∈ [-height/2, +height/2]` with **flat** circular caps (the capsule's
|
|
15
|
+
* flat-cap sibling — same radius/height parametrisation, hemispherical caps
|
|
16
|
+
* replaced by disks).
|
|
17
|
+
*
|
|
18
|
+
* Convex, so the physics narrowphase handles it through the GJK + EPA fallback
|
|
19
|
+
* via {@link support} — there is no closed-form cylinder-vs-X contact path yet
|
|
20
|
+
* (a future refinement; the curved side is the usual smooth-support EPA case).
|
|
21
|
+
*
|
|
22
|
+
* @author Alex Goldring
|
|
23
|
+
* @copyright Company Named Limited (c) 2026
|
|
24
|
+
*/
|
|
25
|
+
export class CylinderShape3D extends AbstractShape3D {
|
|
26
|
+
constructor() {
|
|
27
|
+
super();
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Radius of the circular cross-section.
|
|
31
|
+
* @type {number}
|
|
32
|
+
*/
|
|
33
|
+
this.radius = 0.5;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Full height along the Y axis (total extent — the cylinder spans
|
|
37
|
+
* `[-height/2, +height/2]`).
|
|
38
|
+
* @type {number}
|
|
39
|
+
*/
|
|
40
|
+
this.height = 1;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {number} radius
|
|
45
|
+
* @param {number} height
|
|
46
|
+
* @returns {CylinderShape3D}
|
|
47
|
+
*/
|
|
48
|
+
static from(radius, height) {
|
|
49
|
+
assert.isNumber(radius, 'radius');
|
|
50
|
+
assert.isNumber(height, 'height');
|
|
51
|
+
assert.greaterThanOrEqual(radius, 0, 'radius');
|
|
52
|
+
assert.greaterThanOrEqual(height, 0, 'height');
|
|
53
|
+
|
|
54
|
+
const r = new CylinderShape3D();
|
|
55
|
+
r.radius = radius;
|
|
56
|
+
r.height = height;
|
|
57
|
+
|
|
58
|
+
return r;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
get volume() {
|
|
62
|
+
const r = this.radius;
|
|
63
|
+
return Math.PI * r * r * this.height;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
get surface_area() {
|
|
67
|
+
const r = this.radius;
|
|
68
|
+
// two circular caps + the lateral (side) surface
|
|
69
|
+
return 2 * Math.PI * r * r + 2 * Math.PI * r * this.height;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
compute_bounding_box(result) {
|
|
73
|
+
const r = this.radius;
|
|
74
|
+
const half_h = this.height / 2;
|
|
75
|
+
|
|
76
|
+
result[0] = -r;
|
|
77
|
+
result[1] = -half_h;
|
|
78
|
+
result[2] = -r;
|
|
79
|
+
result[3] = r;
|
|
80
|
+
result[4] = half_h;
|
|
81
|
+
result[5] = r;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
support(result, result_offset, direction_x, direction_y, direction_z) {
|
|
85
|
+
const r = this.radius;
|
|
86
|
+
const half_h = this.height / 2;
|
|
87
|
+
|
|
88
|
+
// Radial: farthest point on the cross-section disk along (dx, dz). For a
|
|
89
|
+
// purely axial query (rl ≈ 0) any point on the cap face is extremal —
|
|
90
|
+
// the cap centre (0,0) is on the (flat) surface, so it's a valid support.
|
|
91
|
+
const rl = Math.sqrt(direction_x * direction_x + direction_z * direction_z);
|
|
92
|
+
let sx = 0, sz = 0;
|
|
93
|
+
if (rl > 0) {
|
|
94
|
+
const inv = r / rl;
|
|
95
|
+
sx = direction_x * inv;
|
|
96
|
+
sz = direction_z * inv;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Axial: the cap on the +direction.y side.
|
|
100
|
+
const sy = sign_not_zero(direction_y) * half_h;
|
|
101
|
+
|
|
102
|
+
result[result_offset] = sx;
|
|
103
|
+
result[result_offset + 1] = sy;
|
|
104
|
+
result[result_offset + 2] = sz;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
signed_distance_at_point(point) {
|
|
108
|
+
const px = point[0];
|
|
109
|
+
const py = point[1];
|
|
110
|
+
const pz = point[2];
|
|
111
|
+
|
|
112
|
+
const half_h = this.height / 2;
|
|
113
|
+
|
|
114
|
+
// Inigo Quilez capped-cylinder SDF, axis = Y. `d_radial` / `d_axial` are
|
|
115
|
+
// the signed distances outside the infinite cylinder and the slab.
|
|
116
|
+
const d_radial = Math.sqrt(px * px + pz * pz) - this.radius;
|
|
117
|
+
const d_axial = Math.abs(py) - half_h;
|
|
118
|
+
|
|
119
|
+
const ext_r = d_radial > 0 ? d_radial : 0;
|
|
120
|
+
const ext_a = d_axial > 0 ? d_axial : 0;
|
|
121
|
+
const outside = Math.sqrt(ext_r * ext_r + ext_a * ext_a);
|
|
122
|
+
|
|
123
|
+
const m = d_radial > d_axial ? d_radial : d_axial;
|
|
124
|
+
const inside = m < 0 ? m : 0;
|
|
125
|
+
|
|
126
|
+
return outside + inside;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
signed_distance_gradient_at_point(result, point) {
|
|
130
|
+
return compute_signed_distance_gradient_by_sampling(result, this, point);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
contains_point(point) {
|
|
134
|
+
const px = point[0];
|
|
135
|
+
const py = point[1];
|
|
136
|
+
const pz = point[2];
|
|
137
|
+
|
|
138
|
+
const half_h = this.height / 2;
|
|
139
|
+
const r = this.radius;
|
|
140
|
+
|
|
141
|
+
return (px * px + pz * pz) < r * r && (py < half_h) && (py > -half_h);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
nearest_point_on_surface(result, reference) {
|
|
145
|
+
const rx = reference[0];
|
|
146
|
+
const ry = reference[1];
|
|
147
|
+
const rz = reference[2];
|
|
148
|
+
|
|
149
|
+
const r = this.radius;
|
|
150
|
+
const half_h = this.height / 2;
|
|
151
|
+
|
|
152
|
+
const rho = Math.sqrt(rx * rx + rz * rz);
|
|
153
|
+
let dirx, dirz;
|
|
154
|
+
if (rho > 0) {
|
|
155
|
+
dirx = rx / rho;
|
|
156
|
+
dirz = rz / rho;
|
|
157
|
+
} else {
|
|
158
|
+
// On the axis — radial direction is undefined; pick +X.
|
|
159
|
+
dirx = 1;
|
|
160
|
+
dirz = 0;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Three candidate projections; the nearest to the reference is the
|
|
164
|
+
// closest surface point (correct for points inside and outside).
|
|
165
|
+
// 1. side — radial clamped to r, y clamped to the band
|
|
166
|
+
// 2. top cap — y = +half_h, radial clamped to r
|
|
167
|
+
// 3. bot cap — y = -half_h, radial clamped to r
|
|
168
|
+
const cy = clamp(ry, -half_h, half_h);
|
|
169
|
+
const s1x = r * dirx, s1y = cy, s1z = r * dirz;
|
|
170
|
+
|
|
171
|
+
const rc = rho < r ? rho : r;
|
|
172
|
+
const s2x = rc * dirx, s2y = half_h, s2z = rc * dirz;
|
|
173
|
+
const s3x = rc * dirx, s3y = -half_h, s3z = rc * dirz;
|
|
174
|
+
|
|
175
|
+
const d1 = (s1x - rx) * (s1x - rx) + (s1y - ry) * (s1y - ry) + (s1z - rz) * (s1z - rz);
|
|
176
|
+
const d2 = (s2x - rx) * (s2x - rx) + (s2y - ry) * (s2y - ry) + (s2z - rz) * (s2z - rz);
|
|
177
|
+
const d3 = (s3x - rx) * (s3x - rx) + (s3y - ry) * (s3y - ry) + (s3z - rz) * (s3z - rz);
|
|
178
|
+
|
|
179
|
+
if (d1 <= d2 && d1 <= d3) {
|
|
180
|
+
result[0] = s1x; result[1] = s1y; result[2] = s1z;
|
|
181
|
+
} else if (d2 <= d3) {
|
|
182
|
+
result[0] = s2x; result[1] = s2y; result[2] = s2z;
|
|
183
|
+
} else {
|
|
184
|
+
result[0] = s3x; result[1] = s3y; result[2] = s3z;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
sample_random_point_in_volume(result, result_offset, random) {
|
|
189
|
+
// Uniform in the disk (sqrt-weighted radius) × uniform in y.
|
|
190
|
+
randomPointInCircle(random, scratch_v3, 0);
|
|
191
|
+
|
|
192
|
+
result[result_offset] = scratch_v3[0] * this.radius;
|
|
193
|
+
result[result_offset + 1] = (random() - 0.5) * this.height;
|
|
194
|
+
result[result_offset + 2] = scratch_v3[1] * this.radius;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* @param {CylinderShape3D} other
|
|
199
|
+
* @returns {boolean}
|
|
200
|
+
*/
|
|
201
|
+
equals(other) {
|
|
202
|
+
return super.equals(other)
|
|
203
|
+
&& this.radius === other.radius
|
|
204
|
+
&& this.height === other.height;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
hash() {
|
|
208
|
+
const a = computeHashFloat(this.radius);
|
|
209
|
+
const b = computeHashFloat(this.height);
|
|
210
|
+
|
|
211
|
+
return ((a << 5) - a + b) | 0;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Fast type-check marker, mirroring `isSphereShape3D` / `isBoxShape3D` /
|
|
217
|
+
* `isCapsuleShape3D`. Reserved for a future closed-form cylinder narrowphase
|
|
218
|
+
* dispatch; until then the cylinder routes through GJK + EPA like any other
|
|
219
|
+
* convex shape (no marker check needed for that path).
|
|
220
|
+
* @readonly
|
|
221
|
+
* @type {boolean}
|
|
222
|
+
*/
|
|
223
|
+
CylinderShape3D.prototype.isCylinderShape3D = true;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"shape_to_type.d.ts","sourceRoot":"","sources":["../../../../../../../src/core/geom/3d/shape/json/shape_to_type.js"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"shape_to_type.d.ts","sourceRoot":"","sources":["../../../../../../../src/core/geom/3d/shape/json/shape_to_type.js"],"names":[],"mappings":"AAOA;;;;GAIG;AACH,uDAFa,MAAM,CAoBlB"}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CapsuleShape3D } from "../CapsuleShape3D.js";
|
|
2
|
+
import { CylinderShape3D } from "../CylinderShape3D.js";
|
|
2
3
|
import { SphereShape3D } from "../SphereShape3D.js";
|
|
3
4
|
import { TransformedShape3D } from "../TransformedShape3D.js";
|
|
4
5
|
import { UnionShape3D } from "../UnionShape3D.js";
|
|
@@ -22,6 +23,8 @@ export function shape_to_type(shape) {
|
|
|
22
23
|
return 'transform';
|
|
23
24
|
} else if (shape instanceof CapsuleShape3D) {
|
|
24
25
|
return 'capsule';
|
|
26
|
+
} else if (shape instanceof CylinderShape3D) {
|
|
27
|
+
return 'cylinder';
|
|
25
28
|
} else {
|
|
26
29
|
throw new Error('Unsupported shape');
|
|
27
30
|
}
|
|
@@ -30,6 +30,20 @@ export namespace type_adapters {
|
|
|
30
30
|
height: number;
|
|
31
31
|
};
|
|
32
32
|
}
|
|
33
|
+
namespace cylinder {
|
|
34
|
+
function read({ radius, height }: {
|
|
35
|
+
radius: any;
|
|
36
|
+
height: any;
|
|
37
|
+
}): CylinderShape3D;
|
|
38
|
+
/**
|
|
39
|
+
*
|
|
40
|
+
* @param {CylinderShape3D} object
|
|
41
|
+
*/
|
|
42
|
+
function write(object: CylinderShape3D): {
|
|
43
|
+
radius: number;
|
|
44
|
+
height: number;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
33
47
|
namespace transform {
|
|
34
48
|
function read({ transform, subject }: {
|
|
35
49
|
transform: any;
|
|
@@ -60,6 +74,7 @@ export namespace type_adapters {
|
|
|
60
74
|
import { UnitCubeShape3D } from "../UnitCubeShape3D.js";
|
|
61
75
|
import { SphereShape3D } from "../SphereShape3D.js";
|
|
62
76
|
import { CapsuleShape3D } from "../CapsuleShape3D.js";
|
|
77
|
+
import { CylinderShape3D } from "../CylinderShape3D.js";
|
|
63
78
|
import { TransformedShape3D } from "../TransformedShape3D.js";
|
|
64
79
|
import { UnionShape3D } from "../UnionShape3D.js";
|
|
65
80
|
//# sourceMappingURL=type_adapters.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"type_adapters.d.ts","sourceRoot":"","sources":["../../../../../../../src/core/geom/3d/shape/json/type_adapters.js"],"names":[],"mappings":";;
|
|
1
|
+
{"version":3,"file":"type_adapters.d.ts","sourceRoot":"","sources":["../../../../../../../src/core/geom/3d/shape/json/type_adapters.js"],"names":[],"mappings":";;QAYQ,iCAEC;QACD,qBAEC;;;QAGD;;0BAQC;QACD;;WAEG;QACH;;;;UAIC;;;QAGD;;;2BAEC;QACD;;;WAGG;QACH;;;UAKC;;;QAGD;;;4BAEC;QACD;;;WAGG;QACH;;;UAKC;;;QAGD;;;+BAIC;QACD;;;WAGG;QACH;;;UAKC;;;QAGD;;yBAIC;QACD;;;WAGG;QACH;;UAIC;;;gCA9FuB,uBAAuB;8BAHzB,qBAAqB;+BAFpB,sBAAsB;gCACrB,uBAAuB;mCAEpB,0BAA0B;6BAChC,oBAAoB"}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CapsuleShape3D } from "../CapsuleShape3D.js";
|
|
2
|
+
import { CylinderShape3D } from "../CylinderShape3D.js";
|
|
2
3
|
import { SphereShape3D } from "../SphereShape3D.js";
|
|
3
4
|
import { TransformedShape3D } from "../TransformedShape3D.js";
|
|
4
5
|
import { UnionShape3D } from "../UnionShape3D.js";
|
|
@@ -50,6 +51,21 @@ export const type_adapters = {
|
|
|
50
51
|
};
|
|
51
52
|
}
|
|
52
53
|
},
|
|
54
|
+
'cylinder': {
|
|
55
|
+
read({ radius, height }) {
|
|
56
|
+
return CylinderShape3D.from(radius, height);
|
|
57
|
+
},
|
|
58
|
+
/**
|
|
59
|
+
*
|
|
60
|
+
* @param {CylinderShape3D} object
|
|
61
|
+
*/
|
|
62
|
+
write(object) {
|
|
63
|
+
return {
|
|
64
|
+
radius: object.radius,
|
|
65
|
+
height: object.height
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
},
|
|
53
69
|
'transform': {
|
|
54
70
|
read({ transform, subject }) {
|
|
55
71
|
const subject_shape = shape_from_json(subject);
|
|
@@ -195,34 +195,84 @@ lives here.
|
|
|
195
195
|
|
|
196
196
|
### Phase 3 — Stairs ✅ **landed**
|
|
197
197
|
Real stair climbing, gated by `stepHeight` (default 0.3 m, just under
|
|
198
|
-
the capsule radius).
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
198
|
+
the capsule radius). Three cooperating pieces — it took iterating to
|
|
199
|
+
find that all three are needed:
|
|
200
|
+
|
|
201
|
+
1. **Explicit step-up** (`_tryStepUp`, Source/Jolt up-forward-down).
|
|
202
|
+
When a grounded move is blocked, decide step-vs-wall with a **thin
|
|
203
|
+
horizontal ray at the step-height plane** (`sy + stepHeight + skin`),
|
|
204
|
+
then — only if clear — lift by stepHeight, advance the residual
|
|
205
|
+
forward, drop onto the step, commit when it gained ground within
|
|
206
|
+
stepHeight, and **restore the horizontal velocity**. The velocity
|
|
207
|
+
restore is the crux for REALISTIC stairs: the capsule's round bottom
|
|
208
|
+
also rolls up low risers, but only with sustained forward momentum,
|
|
209
|
+
and a controller that reads back the slide-clipped velocity loses
|
|
210
|
+
that momentum at the riser and stalls. The step-up climbs without
|
|
211
|
+
depending on momentum and hands the velocity back so the player keeps
|
|
212
|
+
moving up the flight.
|
|
213
|
+
|
|
214
|
+
The step-vs-wall ray is what stops the round bottom climbing a
|
|
215
|
+
too-tall wall — and the reason it's a THIN ray, not the obvious
|
|
216
|
+
"lift the capsule and sweep it forward" clearance cast: lifted so its
|
|
217
|
+
tip sits at stepHeight, the capsule's rounded bottom narrows through
|
|
218
|
+
the band `(stepHeight, stepHeight+radius)`, so a wall whose top lands
|
|
219
|
+
in that band is never reached by the swept shape — it reads "clear"
|
|
220
|
+
and the player climbs it, *faster speed reaching further into the
|
|
221
|
+
round-off* (the gray-box bug: jitter at a walk, clean climb at a
|
|
222
|
+
sprint). A thin ray has no round-off: it reads the obstacle's true
|
|
223
|
+
height, so a step (top below the plane) is passed over and a wall
|
|
224
|
+
(top above) is struck on its front face, at any approach speed.
|
|
225
|
+
|
|
226
|
+
The ray is cast along the **blocked direction** (the horizontal
|
|
227
|
+
velocity the slide removed = the obstacle's inward normal), from the
|
|
228
|
+
**pre-slide** centre, reaching the swept distance plus a forward
|
|
229
|
+
extent. Both matter for OBLIQUE approaches: at 45° the capsule meets a
|
|
230
|
+
wall with its normal-direction extent, so a ray along *travel* stops
|
|
231
|
+
short of the face and reads a wall as clear (climbing it); and
|
|
232
|
+
rounding a convex corner the slide carries the *post*-slide centre
|
|
233
|
+
just past the corner, so a ray from there shoots past the obstacle —
|
|
234
|
+
the pre-slide centre was still in front of what blocked it. The reach
|
|
235
|
+
tracking the sweep isn't the banned speed coupling: the climb-or-block
|
|
236
|
+
decision is the step-height plane alone; the reach only governs how
|
|
237
|
+
far ahead to look for what the slide already hit.
|
|
238
|
+
|
|
239
|
+
A single ray still can't catch a convex *point*: grazing a corner a
|
|
240
|
+
few degrees off its bisector, the blocked normal runs nearly parallel
|
|
241
|
+
to a face, so the ray slips past the corner just outside the footprint
|
|
242
|
+
and reads clear. The backstop is a post-hoc OVERLAP query — after
|
|
243
|
+
up-forward-down, if the destination overlaps geometry the climb landed
|
|
244
|
+
the round body ON a wall corner, not on a step (a real step top is
|
|
245
|
+
rested on `skin` above, never overlapping), so it's rejected. Probe for
|
|
246
|
+
the common case, overlap-test to catch what a ray threads past.
|
|
247
|
+
|
|
248
|
+
The same round-bottom perch is why the slide keeps every contact's true
|
|
249
|
+
normal (no "flatten steep contacts to vertical" — that would also rob
|
|
250
|
+
a too-steep *slope* of its downhill slide) and why the footprint
|
|
251
|
+
mount in (2) is gated on height above the **centre surface**, not the
|
|
252
|
+
feet (gating on the feet lets a capsule that rode up a hair mount the
|
|
253
|
+
next sliver and ratchet up a wall).
|
|
254
|
+
2. **Two-probe ground categorize** for honest grounded-ness on a step
|
|
255
|
+
edge — *walkability* from a centre raycast (ignores a step's convex
|
|
256
|
+
top edge and a wall's side face, so a steep *slope* is correctly
|
|
257
|
+
not-grounded) and *rest height* from a footprint shapecast (mounts
|
|
258
|
+
a step the leading edge overhangs). A single probe can't tell a
|
|
259
|
+
climbable step edge from a steep slope; the split can.
|
|
260
|
+
3. **Step-DOWN** = the ground-stick reach is `stepHeight`: walking off
|
|
261
|
+
a drop ≤ stepHeight snaps onto the lower surface (stays grounded);
|
|
262
|
+
a larger drop goes airborne.
|
|
263
|
+
|
|
264
|
+
Footgun caught in the prototype: my first stair test used *deep
|
|
265
|
+
overlapping* steps, where the round-bottom roll alone climbs and the
|
|
266
|
+
explicit step-up looked unnecessary. Realistic *thin* treads (shallower
|
|
267
|
+
than the capsule footprint) need the step-up — see the thin-tread tests.
|
|
268
|
+
|
|
269
|
+
**Guard tests** (`collision/Stairs.spec.js` + `KinematicMoverIntegration.spec.js`,
|
|
270
|
+
all green): climb a 5-step staircase; climb a thin-tread staircase
|
|
271
|
+
(treads < footprint) both at the mover level and through the full
|
|
272
|
+
controller; clear a single curb; a 0.5 m riser (> stepHeight) blocks;
|
|
273
|
+
a 0.5 m riser is a **clean wall — no edge ride-up, and clearing is
|
|
274
|
+
speed-independent** (walk and sprint both stop dead, the gray-box guard);
|
|
275
|
+
descend a staircase grounded the whole way.
|
|
226
276
|
|
|
227
277
|
### Phase 4 — Motor seam + delete old code ✅ **landed**
|
|
228
278
|
The mover is now the **only** collision path when a `PhysicsSystem` is
|
|
@@ -182,6 +182,19 @@ export class FirstPersonPlayerControllerSystem extends System<any, any, any, any
|
|
|
182
182
|
* @param {number} dt
|
|
183
183
|
*/
|
|
184
184
|
private _integrateVerticalAndResolveGround;
|
|
185
|
+
/**
|
|
186
|
+
* Resolve the current `runtime.velocity*` against the world — the
|
|
187
|
+
* physics-backed {@link KinematicMover} (recover + sweep-and-slide +
|
|
188
|
+
* ground-categorize) when a PhysicsSystem is present, else the no-physics
|
|
189
|
+
* flat-ground fallback. Gravity is NOT applied here: the standard path
|
|
190
|
+
* applies it in {@link _applyGravity} just before, and abilities with a
|
|
191
|
+
* non-standard vertical model (WallRun's reduced gravity) apply their own
|
|
192
|
+
* and then call this directly — so motion routes through one motor for
|
|
193
|
+
* everyone (sweep-and-slide, anti-tunnel, land/leave events) rather than
|
|
194
|
+
* a bespoke `position._add` + hand-rolled ground-catch.
|
|
195
|
+
* @private
|
|
196
|
+
*/
|
|
197
|
+
private _resolveMotion;
|
|
185
198
|
/**
|
|
186
199
|
* Motor: integrate gravity into `velocityY` with the fall / variable-
|
|
187
200
|
* cut multipliers. The mover never invents motion, so gravity lives
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"FirstPersonPlayerControllerSystem.d.ts","sourceRoot":"","sources":["../../../../../src/engine/control/first-person/FirstPersonPlayerControllerSystem.js"],"names":[],"mappings":"AAgTA;;;;;;;;;;;;;;;;;GAiBG;AACH;IACI,cAkFC;IAzEG,wEAA4D;IAE5D,gKAIC;IAED;;;OAGG;IACH,SAFU,IAAI,MAAM,EAAE,gBAAgB,CAAC,CAEf;IAExB;;;;OAIG;IACH,sBAFU,OAAO,CAEe;IAEhC;;;;OAIG;IACH,SAFU,MAAM,CAEA;IAEhB;;;;;;;;;;;;;;;;OAgBG;IACH,oBAFc,MAAM,KAAI,MAAM,KAAI,MAAM,KAAK,MAAM,GAAC,IAAI,CAE9B;IAE1B;;;;;;;OAOG;IACH,eAFU,aAAa,GAAC,IAAI,CAEH;IAEzB;;;;;;;;OAQG;IACH,eAAkB;IAClB,+DAA+D;IAC/D,uBAAmC;IACnC,yDAAyD;IACzD,oBAA6B;IAC7B,2CAA2C;IAC3C,oBAA4C;IAGhD,2CAMC;IAED;;;;OAIG;IACH,iBAJW,2BAA2B,iBAC3B,SAAS,UACT,MAAM,QAkHhB;IAED;;;;OAIG;IACH,mBAJW,2BAA2B,iBAC3B,SAAS,UACT,MAAM,QAWhB;IAED;;;;;;;;OAQG;IACH,mBAHW,MAAM,GACJ,gBAAgB,GAAC,SAAS,CAItC;IAUG,mBAAoB;IAYpB,yBAA0B;IAI9B;;;;OAIG;IACH,oBA0UC;IAED;;;;;OAKG;IACH;;;;;;;;;;;;;;OAcG;IACH,2BAaC;IAED,2EAkCC;IAED;;;;;;;;;;;;;;;;;;OAkBG;IACH,2BASC;IAED;;;;;;;;;;;OAWG;IACH,wBA0EC;IAED;;;;;;;;;;;;;OAaG;IACH,
|
|
1
|
+
{"version":3,"file":"FirstPersonPlayerControllerSystem.d.ts","sourceRoot":"","sources":["../../../../../src/engine/control/first-person/FirstPersonPlayerControllerSystem.js"],"names":[],"mappings":"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"}
|
|
@@ -1128,14 +1128,28 @@ export class FirstPersonPlayerControllerSystem extends System {
|
|
|
1128
1128
|
*/
|
|
1129
1129
|
_integrateVerticalAndResolveGround(controller, runtime, bodyTransform, dt) {
|
|
1130
1130
|
this._applyGravity(controller, runtime, dt);
|
|
1131
|
+
this._resolveMotion(controller, runtime, bodyTransform, dt);
|
|
1132
|
+
this._detectJumpApex(controller, runtime, bodyTransform);
|
|
1133
|
+
}
|
|
1131
1134
|
|
|
1135
|
+
/**
|
|
1136
|
+
* Resolve the current `runtime.velocity*` against the world — the
|
|
1137
|
+
* physics-backed {@link KinematicMover} (recover + sweep-and-slide +
|
|
1138
|
+
* ground-categorize) when a PhysicsSystem is present, else the no-physics
|
|
1139
|
+
* flat-ground fallback. Gravity is NOT applied here: the standard path
|
|
1140
|
+
* applies it in {@link _applyGravity} just before, and abilities with a
|
|
1141
|
+
* non-standard vertical model (WallRun's reduced gravity) apply their own
|
|
1142
|
+
* and then call this directly — so motion routes through one motor for
|
|
1143
|
+
* everyone (sweep-and-slide, anti-tunnel, land/leave events) rather than
|
|
1144
|
+
* a bespoke `position._add` + hand-rolled ground-catch.
|
|
1145
|
+
* @private
|
|
1146
|
+
*/
|
|
1147
|
+
_resolveMotion(controller, runtime, bodyTransform, dt) {
|
|
1132
1148
|
if (this.physicsSystem !== null) {
|
|
1133
1149
|
this._moveViaMover(controller, runtime, bodyTransform, dt);
|
|
1134
1150
|
} else {
|
|
1135
1151
|
this._moveFlatGround(controller, runtime, bodyTransform, dt);
|
|
1136
1152
|
}
|
|
1137
|
-
|
|
1138
|
-
this._detectJumpApex(controller, runtime, bodyTransform);
|
|
1139
1153
|
}
|
|
1140
1154
|
|
|
1141
1155
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"WallRun.d.ts","sourceRoot":"","sources":["../../../../../../src/engine/control/first-person/abilities/WallRun.js"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH;IAMQ,qDAAqD;IACrD,iBAAiB;IACjB,yDAAyD;IACzD,cAAgB;IAGpB,kEAiBC;IAED,gDAqBC;IAED;;;;OAIG;IACH,wBAEC;IAED,
|
|
1
|
+
{"version":3,"file":"WallRun.d.ts","sourceRoot":"","sources":["../../../../../../src/engine/control/first-person/abilities/WallRun.js"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH;IAMQ,qDAAqD;IACrD,iBAAiB;IACjB,yDAAyD;IACzD,cAAgB;IAGpB,kEAiBC;IAED,gDAqBC;IAED;;;;OAIG;IACH,wBAEC;IAED,uFAgEC;CAKJ;wBAjKuB,cAAc"}
|
|
@@ -131,43 +131,26 @@ export class WallRun extends Ability {
|
|
|
131
131
|
const rollSign = this._side === "L" ? -1 : 1;
|
|
132
132
|
runtime.leanTargetRad = rollSign * cfg.cameraRollDeg * DEG_TO_RAD;
|
|
133
133
|
|
|
134
|
-
// -- Reduced gravity.
|
|
134
|
+
// -- Reduced gravity. This is wall-run's OWN vertical model (a
|
|
135
|
+
// fraction of base gravity, no fall multiplier), applied here rather
|
|
136
|
+
// than via the system's standard gravity step.
|
|
135
137
|
runtime.velocityY -= runtime.gravity * cfg.gravityFactor * dt;
|
|
136
138
|
|
|
137
|
-
// --
|
|
138
|
-
//
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
)
|
|
144
|
-
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
|
|
152
|
-
//
|
|
153
|
-
// Consult the same effective ground the base integrator uses:
|
|
154
|
-
// max(useBuiltInFlatGround baseline, groundResolver). If we've
|
|
155
|
-
// sunk to or past it, snap, zero vy, and release — base will
|
|
156
|
-
// mark grounded on the next tick.
|
|
157
|
-
let groundY = system.useBuiltInFlatGround ? system.groundY : null;
|
|
158
|
-
if (system.groundResolver !== null) {
|
|
159
|
-
const resolved = system.groundResolver(
|
|
160
|
-
bodyTransform.position.x,
|
|
161
|
-
bodyTransform.position.y,
|
|
162
|
-
bodyTransform.position.z,
|
|
163
|
-
);
|
|
164
|
-
if (resolved !== null && (groundY === null || resolved > groundY)) {
|
|
165
|
-
groundY = resolved;
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
if (groundY !== null && bodyTransform.position.y <= groundY) {
|
|
169
|
-
bodyTransform.position.setY(groundY);
|
|
170
|
-
runtime.velocityY = 0;
|
|
139
|
+
// -- Resolve the move through the shared motor. With the reduced-
|
|
140
|
+
// gravity velocity set above, route it through the same sweep-and-
|
|
141
|
+
// slide + ground-categorize the base locomotion uses (the mover when
|
|
142
|
+
// physics is present, else the flat-ground fallback). _resolveMotion
|
|
143
|
+
// does NOT re-apply gravity. This replaces a raw `position._add` plus
|
|
144
|
+
// a hand-rolled groundResolver catch: the wall-runner now (a) can't
|
|
145
|
+
// drift INTO geometry (the sweep clips it) nor sail OFF a ledge the
|
|
146
|
+
// mover would have caught, and (b) gets the motor's anti-tunnel
|
|
147
|
+
// ground catch for free — the same reason base no longer free-falls
|
|
148
|
+
// past a floor. The motor sets `state.grounded` and fires land/leave.
|
|
149
|
+
system._resolveMotion(controller, runtime, bodyTransform, dt);
|
|
150
|
+
|
|
151
|
+
// -- Exit: the motor caught a floor at the wall's foot (we ran out
|
|
152
|
+
// onto the ground). Release; base resumes next tick.
|
|
153
|
+
if (controller.state.grounded) {
|
|
171
154
|
return false;
|
|
172
155
|
}
|
|
173
156
|
|
|
@@ -45,11 +45,12 @@ export class KinematicMover {
|
|
|
45
45
|
* `MIN_WALK_NORMAL` / Source `normal.z ≥ 0.7`.
|
|
46
46
|
* @param {number} [options.stepHeight=0.3] maximum step the player
|
|
47
47
|
* traverses, both up and down:
|
|
48
|
-
* - UP:
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
48
|
+
* - UP: a riser no taller than this is climbed — by the explicit
|
|
49
|
+
* step-up ({@link _tryStepUp}, up-forward-down, which works
|
|
50
|
+
* regardless of forward momentum) and by `_categorizeGround`
|
|
51
|
+
* mounting the player onto a surface within `stepHeight` of the
|
|
52
|
+
* feet. A taller riser blocks (the slide stops the player; the
|
|
53
|
+
* step-up's forward clearance cast detects the wall and aborts).
|
|
53
54
|
* - DOWN: the ground-stick reach — walking off a drop no larger
|
|
54
55
|
* than this snaps the player onto the lower surface (stays
|
|
55
56
|
* grounded); a larger drop goes airborne (a real ledge).
|
|
@@ -76,6 +77,7 @@ export class KinematicMover {
|
|
|
76
77
|
_penDir: Float64Array;
|
|
77
78
|
_planes: Float64Array;
|
|
78
79
|
_cand: Float64Array;
|
|
80
|
+
_support: Float64Array;
|
|
79
81
|
/**
|
|
80
82
|
* Reused result. `grounded` / `groundNormal` are Phase 2 outputs;
|
|
81
83
|
* in Phase 1 they're left at their defaults (the mover doesn't
|
|
@@ -110,6 +112,34 @@ export class KinematicMover {
|
|
|
110
112
|
grounded: boolean;
|
|
111
113
|
groundNormal: Vector3;
|
|
112
114
|
};
|
|
115
|
+
/**
|
|
116
|
+
* Stair step-up. Called after a slide that hit something while the
|
|
117
|
+
* player was trying to move horizontally on the ground. Re-runs the
|
|
118
|
+
* horizontal move from the pre-slide position lifted by `stepHeight`,
|
|
119
|
+
* guarded by a forward CLEARANCE cast: if the path is still blocked at
|
|
120
|
+
* the lifted height the obstacle is taller than a step (a wall) and we
|
|
121
|
+
* abandon the attempt; if clear it's a step, so advance over it and
|
|
122
|
+
* drop. Commits only when it advances farther horizontally than the
|
|
123
|
+
* plain slide and the rise is within `stepHeight`. On success the
|
|
124
|
+
* horizontal velocity is restored (the riser wasn't a wall) so the
|
|
125
|
+
* controller doesn't read back a stalled speed; the vertical is left
|
|
126
|
+
* for `_categorizeGround` to settle on the step.
|
|
127
|
+
*
|
|
128
|
+
* @private
|
|
129
|
+
*/
|
|
130
|
+
private _tryStepUp;
|
|
131
|
+
/**
|
|
132
|
+
* Distance the shape can sweep along a unit axis before contact (less
|
|
133
|
+
* `skin`), or the full `maxDist` if clear. For the step-up lift/drop.
|
|
134
|
+
* @private
|
|
135
|
+
*/
|
|
136
|
+
private _castDistance;
|
|
137
|
+
/**
|
|
138
|
+
* True when a centre raycast at (x,y,z) finds a walkable surface
|
|
139
|
+
* within stick range — gates step-up on "actually standing".
|
|
140
|
+
* @private
|
|
141
|
+
*/
|
|
142
|
+
private _groundedAt;
|
|
113
143
|
/**
|
|
114
144
|
* Push the capsule out of any geometry it currently overlaps. Each
|
|
115
145
|
* pass queries overlaps, finds the single deepest penetration via
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"KinematicMover.d.ts","sourceRoot":"","sources":["../../../../../../src/engine/control/first-person/collision/KinematicMover.js"],"names":[],"mappings":"AAmBA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH;IACI
|
|
1
|
+
{"version":3,"file":"KinematicMover.d.ts","sourceRoot":"","sources":["../../../../../../src/engine/control/first-person/collision/KinematicMover.js"],"names":[],"mappings":"AAmBA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH;IACI;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA8BG;IACH,2BA9BW,OAAO,uCAAuC,EAAE,aAAa,OAC7D,OAAO,wCAAwC,EAAE,sBAAsB;QAKtD,IAAI,GAArB,MAAM;QAEW,kBAAkB,GAAnC,MAAM;QAEW,oBAAoB,GAArC,MAAM;QAEW,aAAa,GAA9B,MAAM;QAIW,UAAU,GAA3B,MAAM;OAuChB;IAxBG,6EAAkC;IAClC,6EAAc;IACd,aAA6D;IAC7D,2BAAmG;IACnG,6BAAyG;IACzG,sBAAsF;IACtF,mBAA6E;IAG7E,WAAsB;IACtB,0BAAqC;IACrC,yBAAsC;IACtC,sBAAkC;IAClC,sBAAoD;IACpD,oBAAgC;IAChC,uBAAmC;IAEnC;;;;;OAKG;IACH,SAFU;QAAC,GAAG,EAAC,OAAO,CAAC;QAAC,QAAQ,EAAC,OAAO,CAAC;QAAC,YAAY,EAAC,OAAO,CAAA;KAAC,CAEmB;IAGtF;;;;;;;;;;;;OAYG;IACH,eATW,OAAO,YACP;QAAC,CAAC,EAAC,MAAM,CAAC;QAAA,CAAC,EAAC,MAAM,CAAC;QAAA,CAAC,EAAC,MAAM,CAAC;QAAA,CAAC,EAAC,MAAM,CAAA;KAAC,SACrC,OAAO,mDAAmD,EAAE,eAAe,YAC3E,OAAO,MACP,MAAM,mBACE,MAAM,YAAW,QAAQ,KAAG,OAAO,GAEzC;QAAC,GAAG,EAAC,OAAO,CAAC;QAAC,QAAQ,EAAC,OAAO,CAAC;QAAC,YAAY,EAAC,OAAO,CAAA;KAAC,CA4CjE;IAED;;;;;;;;;;;;;;OAcG;IACH,mBA0FC;IAED;;;;OAIG;IACH,sBAQC;IAED;;;;OAIG;IACH,oBASC;IAED;;;;;;;;;OASG;IACH,iBAwCC;IAED;;;;;;;;OAQG;IACH,eAmIC;IAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA4BG;IACH,0BAsDC;IAED;;;;;OAKG;IACH,cAKC;CACJ;qBA9kBoB,sCAAsC;oCAIvB,iDAAiD;oBALjE,kCAAkC;yBAG7B,kCAAkC"}
|
|
@@ -64,11 +64,12 @@ export class KinematicMover {
|
|
|
64
64
|
* `MIN_WALK_NORMAL` / Source `normal.z ≥ 0.7`.
|
|
65
65
|
* @param {number} [options.stepHeight=0.3] maximum step the player
|
|
66
66
|
* traverses, both up and down:
|
|
67
|
-
* - UP:
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
67
|
+
* - UP: a riser no taller than this is climbed — by the explicit
|
|
68
|
+
* step-up ({@link _tryStepUp}, up-forward-down, which works
|
|
69
|
+
* regardless of forward momentum) and by `_categorizeGround`
|
|
70
|
+
* mounting the player onto a surface within `stepHeight` of the
|
|
71
|
+
* feet. A taller riser blocks (the slide stops the player; the
|
|
72
|
+
* step-up's forward clearance cast detects the wall and aborts).
|
|
72
73
|
* - DOWN: the ground-stick reach — walking off a drop no larger
|
|
73
74
|
* than this snaps the player onto the lower surface (stays
|
|
74
75
|
* grounded); a larger drop goes airborne (a real ledge).
|
|
@@ -91,6 +92,7 @@ export class KinematicMover {
|
|
|
91
92
|
this._penDir = new Float64Array(3); // compute_penetration out
|
|
92
93
|
this._planes = new Float64Array(MAX_CLIP_PLANES * 3);
|
|
93
94
|
this._cand = new Float64Array(3); // clipped-velocity candidate
|
|
95
|
+
this._support = new Float64Array(3); // shape support point (forward extent)
|
|
94
96
|
|
|
95
97
|
/**
|
|
96
98
|
* Reused result. `grounded` / `groundNormal` are Phase 2 outputs;
|
|
@@ -139,12 +141,163 @@ export class KinematicMover {
|
|
|
139
141
|
// 2. Sweep-and-slide the desired motion.
|
|
140
142
|
this._slide(position, rotation, shape, velocity, dt, filter, result);
|
|
141
143
|
|
|
144
|
+
// 2b. Stairs — if a grounded move was blocked, try to step up and
|
|
145
|
+
// over a low riser. The capsule's round bottom also rolls up
|
|
146
|
+
// low steps via the slide, but only with enough forward
|
|
147
|
+
// momentum; a controller that reads back the slide-clipped
|
|
148
|
+
// velocity loses that momentum at the riser and gets stuck.
|
|
149
|
+
// The explicit step-up climbs without depending on momentum
|
|
150
|
+
// AND restores the horizontal velocity, so the player keeps
|
|
151
|
+
// moving up the flight.
|
|
152
|
+
if (this.stepHeight > 0 && !ascending && result.hit) {
|
|
153
|
+
this._tryStepUp(position, rotation, shape, velocity,
|
|
154
|
+
sx, sy, sz, svx, svy, svz, dt, filter);
|
|
155
|
+
}
|
|
156
|
+
|
|
142
157
|
// 3. Ground categorize + stick + slope velocity clip.
|
|
143
158
|
this._categorizeGround(position, rotation, shape, velocity, filter, result, ascending);
|
|
144
159
|
|
|
145
160
|
return result;
|
|
146
161
|
}
|
|
147
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Stair step-up. Called after a slide that hit something while the
|
|
165
|
+
* player was trying to move horizontally on the ground. Re-runs the
|
|
166
|
+
* horizontal move from the pre-slide position lifted by `stepHeight`,
|
|
167
|
+
* guarded by a forward CLEARANCE cast: if the path is still blocked at
|
|
168
|
+
* the lifted height the obstacle is taller than a step (a wall) and we
|
|
169
|
+
* abandon the attempt; if clear it's a step, so advance over it and
|
|
170
|
+
* drop. Commits only when it advances farther horizontally than the
|
|
171
|
+
* plain slide and the rise is within `stepHeight`. On success the
|
|
172
|
+
* horizontal velocity is restored (the riser wasn't a wall) so the
|
|
173
|
+
* controller doesn't read back a stalled speed; the vertical is left
|
|
174
|
+
* for `_categorizeGround` to settle on the step.
|
|
175
|
+
*
|
|
176
|
+
* @private
|
|
177
|
+
*/
|
|
178
|
+
_tryStepUp(position, rotation, shape, velocity, sx, sy, sz, svx, svy, svz, dt, filter) {
|
|
179
|
+
const hdx = svx * dt, hdz = svz * dt;
|
|
180
|
+
const forwardLen = Math.sqrt(hdx * hdx + hdz * hdz);
|
|
181
|
+
if (forwardLen < MIN_MOVE) return; // no horizontal intent
|
|
182
|
+
// Only step from a grounded start — never mid-air (else a player
|
|
183
|
+
// could climb a wall by jumping into it).
|
|
184
|
+
if (!this._groundedAt(sx, sy, sz, filter)) return;
|
|
185
|
+
|
|
186
|
+
const slideProg = (position.x - sx) * (position.x - sx) + (position.z - sz) * (position.z - sz);
|
|
187
|
+
const px = position.x, py = position.y, pz = position.z;
|
|
188
|
+
const pvx = velocity.x, pvy = velocity.y, pvz = velocity.z;
|
|
189
|
+
const fnx = hdx / forwardLen, fnz = hdz / forwardLen; // travel direction
|
|
190
|
+
|
|
191
|
+
// Direction the slide was BLOCKED in — the inward normal of the
|
|
192
|
+
// obstacle the player walked into = the horizontal velocity the
|
|
193
|
+
// slide removed (pre-slide minus clipped). For a head-on approach
|
|
194
|
+
// this equals the travel direction; for an OBLIQUE one it's the
|
|
195
|
+
// wall's normal, which is what we must probe along: the capsule
|
|
196
|
+
// meets the wall with its normal-direction extent, so a probe cast
|
|
197
|
+
// along TRAVEL would stop short of the face (it only has to reach
|
|
198
|
+
// radius/cos θ along travel, but radius along the normal) and read a
|
|
199
|
+
// wall as clear, climbing it. Deriving it from the velocity change
|
|
200
|
+
// needs no slide-internal normal plumbed out.
|
|
201
|
+
let bdx = svx - velocity.x, bdz = svz - velocity.z;
|
|
202
|
+
const bdLen = Math.sqrt(bdx * bdx + bdz * bdz);
|
|
203
|
+
if (bdLen < MIN_MOVE) return; // nothing opposed the horizontal move — no wall/step ahead
|
|
204
|
+
bdx /= bdLen; bdz /= bdLen;
|
|
205
|
+
|
|
206
|
+
// ── Step-vs-wall decision: a THIN horizontal ray at the step-height
|
|
207
|
+
// plane ──────────────────────────────────────────────────────
|
|
208
|
+
// The obvious "lift the capsule by stepHeight and sweep it forward"
|
|
209
|
+
// clearance test is fooled by the capsule's ROUNDED bottom: lifted
|
|
210
|
+
// so its tip sits at stepHeight, the hemisphere narrows through the
|
|
211
|
+
// band (stepHeight, stepHeight+radius), so a wall whose top lands in
|
|
212
|
+
// that band is never reached by the swept capsule — it reads "clear"
|
|
213
|
+
// and the player climbs a too-tall wall (and does so speed-
|
|
214
|
+
// dependently, since a faster sweep reaches further into the round-
|
|
215
|
+
// off). A thin ray has no such round-off. Cast it INTO the obstacle
|
|
216
|
+
// (along the blocked normal) at exactly the highest climbable height:
|
|
217
|
+
// anything taller than a step has solid material crossing that plane
|
|
218
|
+
// and is struck on its front face; a genuine step (top below the
|
|
219
|
+
// plane) is passed clean over. This reads the obstacle's true height,
|
|
220
|
+
// independent of how the round bottom would perch on its edge — so
|
|
221
|
+
// "clear it or don't" holds at any speed and any approach angle.
|
|
222
|
+
//
|
|
223
|
+
// Origin at the PRE-slide centre, not post-slide: rounding a convex
|
|
224
|
+
// corner, the slide can carry the centre just past the corner (out
|
|
225
|
+
// of the obstacle's footprint) so a probe from there shoots past it
|
|
226
|
+
// and reads clear, climbing the corner. The pre-slide centre was
|
|
227
|
+
// still in front of what blocked it. Reach spans the swept move
|
|
228
|
+
// (`forwardLen`) plus a forward-extent to the leading edge plus a
|
|
229
|
+
// few skins — enough to cross the front face wherever along the
|
|
230
|
+
// sweep contact happened. The reach tracking the sweep is not the
|
|
231
|
+
// banned speed coupling: the climb-or-block DECISION is the
|
|
232
|
+
// step-height plane alone; the reach only governs how far ahead we
|
|
233
|
+
// look for the thing the slide already hit.
|
|
234
|
+
shape.support(this._support, 0, bdx, 0, bdz);
|
|
235
|
+
const lead = this._support[0] * bdx + this._support[2] * bdz; // extent toward the wall (capsule radius)
|
|
236
|
+
const ray = this._ray;
|
|
237
|
+
ray.setOrigin(sx, sy + this.stepHeight + this.skin, sz);
|
|
238
|
+
ray.setDirection(bdx, 0, bdz);
|
|
239
|
+
ray.tMax = forwardLen + lead + 4 * this.skin;
|
|
240
|
+
if (this.physicsSystem.raycast(ray, this._hit, filter)) return; // taller than a step — a wall; keep the plain slide
|
|
241
|
+
|
|
242
|
+
// Confirmed climbable (nothing crosses the step-height plane ahead).
|
|
243
|
+
// Up–forward–down places the capsule onto the step top.
|
|
244
|
+
position.set(sx, sy, sz);
|
|
245
|
+
const up = this._castDistance(position, rotation, shape, 0, 1, 0, this.stepHeight, filter);
|
|
246
|
+
position._add(0, up, 0);
|
|
247
|
+
position._add(fnx * forwardLen, 0, fnz * forwardLen);
|
|
248
|
+
const down = this._castDistance(position, rotation, shape, 0, -1, 0, up + this.skin, filter);
|
|
249
|
+
position._add(0, -down, 0);
|
|
250
|
+
|
|
251
|
+
// Reject a climb that ends INSIDE geometry. The thin step-height
|
|
252
|
+
// probe is a single ray, so at a convex CORNER it can thread past
|
|
253
|
+
// the corner point (the contact normal there is near-parallel to a
|
|
254
|
+
// face, so the ray runs alongside the box just outside its
|
|
255
|
+
// footprint) and read clear — then up-forward-down perches the round
|
|
256
|
+
// body on that corner, overlapping it. A genuine step top is rested
|
|
257
|
+
// on `skin` ABOVE, never overlapping; so an overlap at the
|
|
258
|
+
// destination is the tell that we climbed onto a wall, not a step.
|
|
259
|
+
const stepProg = (position.x - sx) * (position.x - sx) + (position.z - sz) * (position.z - sz);
|
|
260
|
+
const heightGain = position.y - sy;
|
|
261
|
+
const overlaps = this.physicsSystem.overlap(shape, position, rotation, this._overlapBuf, 0, filter) > 0;
|
|
262
|
+
if (stepProg > slideProg + 1e-8 && heightGain <= this.stepHeight + this.skin && !overlaps) {
|
|
263
|
+
velocity.set(svx, svy, svz); // restore horizontal; vertical settled by categorize
|
|
264
|
+
} else {
|
|
265
|
+
position.set(px, py, pz);
|
|
266
|
+
velocity.set(pvx, pvy, pvz);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Distance the shape can sweep along a unit axis before contact (less
|
|
272
|
+
* `skin`), or the full `maxDist` if clear. For the step-up lift/drop.
|
|
273
|
+
* @private
|
|
274
|
+
*/
|
|
275
|
+
_castDistance(position, rotation, shape, dx, dy, dz, maxDist, filter) {
|
|
276
|
+
const ray = this._ray;
|
|
277
|
+
ray.setOrigin(position.x, position.y, position.z);
|
|
278
|
+
ray.setDirection(dx, dy, dz);
|
|
279
|
+
ray.tMax = maxDist;
|
|
280
|
+
if (!this.physicsSystem.shapeCast(ray, shape, rotation, this._hit, filter)) return maxDist;
|
|
281
|
+
const t = this._hit.t - this.skin;
|
|
282
|
+
return t > 0 ? t : 0;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* True when a centre raycast at (x,y,z) finds a walkable surface
|
|
287
|
+
* within stick range — gates step-up on "actually standing".
|
|
288
|
+
* @private
|
|
289
|
+
*/
|
|
290
|
+
_groundedAt(x, y, z, filter) {
|
|
291
|
+
const ray = this._ray;
|
|
292
|
+
const lift = this.stepHeight;
|
|
293
|
+
ray.setOrigin(x, y + lift, z);
|
|
294
|
+
ray.setDirection(0, -1, 0);
|
|
295
|
+
ray.tMax = lift + this.stepHeight + this.skin;
|
|
296
|
+
if (!this.physicsSystem.raycast(ray, this._hit, filter)) return false;
|
|
297
|
+
if (this._hit.normal.y < this.minWalkNormal) return false;
|
|
298
|
+
return (y + lift - this._hit.t) <= y + this.skin;
|
|
299
|
+
}
|
|
300
|
+
|
|
148
301
|
/**
|
|
149
302
|
* Push the capsule out of any geometry it currently overlaps. Each
|
|
150
303
|
* pass queries overlaps, finds the single deepest penetration via
|
|
@@ -272,6 +425,15 @@ export class KinematicMover {
|
|
|
272
425
|
vx = vy = vz = 0;
|
|
273
426
|
break;
|
|
274
427
|
}
|
|
428
|
+
// Store the true contact plane for velocity clipping. The
|
|
429
|
+
// capsule's round bottom does NOT roll up a too-tall obstacle:
|
|
430
|
+
// standing on the floor it meets a wall with its full-radius
|
|
431
|
+
// cylinder side (a horizontal normal — a clean stop), and
|
|
432
|
+
// CLIMBING a step ≤ stepHeight is the explicit step-up's job,
|
|
433
|
+
// gated by a thin step-height probe that reads true obstacle
|
|
434
|
+
// height regardless of approach speed. So the slide keeps every
|
|
435
|
+
// surface's real normal — flattening steep contacts to vertical
|
|
436
|
+
// here would also rob a too-steep SLOPE of its downhill slide.
|
|
275
437
|
const po = numPlanes * 3;
|
|
276
438
|
planes[po] = hit.normal.x;
|
|
277
439
|
planes[po + 1] = hit.normal.y;
|
|
@@ -375,7 +537,8 @@ export class KinematicMover {
|
|
|
375
537
|
const nx = hit.normal.x, ny = hit.normal.y, nz = hit.normal.z;
|
|
376
538
|
result.groundNormal.set(nx, ny, nz);
|
|
377
539
|
if (ny < this.minWalkNormal) return; // steep slope under the feet — slide, not grounded
|
|
378
|
-
|
|
540
|
+
const centreSurfaceY = position.y + lift - hit.t; // the planar ground under the feet
|
|
541
|
+
let surfaceY = centreSurfaceY;
|
|
379
542
|
|
|
380
543
|
// (B) Footprint shapecast — mount a step the leading edge overhangs.
|
|
381
544
|
ray.setOrigin(position.x, position.y + lift, position.z);
|
|
@@ -383,10 +546,15 @@ export class KinematicMover {
|
|
|
383
546
|
ray.tMax = reach;
|
|
384
547
|
if (this.physicsSystem.shapeCast(ray, shape, rotation, hit, filter) && hit.t > this.skin) {
|
|
385
548
|
const stepY = position.y + lift - hit.t;
|
|
386
|
-
//
|
|
387
|
-
//
|
|
388
|
-
//
|
|
389
|
-
|
|
549
|
+
// Mount it only if it's higher than the centre surface and no
|
|
550
|
+
// more than `stepHeight` ABOVE THAT CENTRE SURFACE — not above
|
|
551
|
+
// the feet. Gating on the feet would let the player climb a
|
|
552
|
+
// wall incrementally: ride up its edge a hair, then mount the
|
|
553
|
+
// now-within-reach next sliver, and so on. Gating on the
|
|
554
|
+
// ground under the centre means a wall top is always >
|
|
555
|
+
// stepHeight above the floor the centre sees → never mounted,
|
|
556
|
+
// and a capsule that rode up gets snapped back to the floor.
|
|
557
|
+
if (stepY > surfaceY && stepY - centreSurfaceY <= this.stepHeight + this.skin) {
|
|
390
558
|
surfaceY = stepY;
|
|
391
559
|
}
|
|
392
560
|
}
|
|
@@ -604,13 +604,44 @@ scaffolding is in place.
|
|
|
604
604
|
bodies where speculative margin isn't enough. The bench's falling
|
|
605
605
|
tower (1km drop onto a 1cm floor) is the concrete reproducer —
|
|
606
606
|
180 / 1000 bodies tunnel.
|
|
607
|
-
- [
|
|
608
|
-
(`
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
607
|
+
- [x] **Broadphase BVH balance — SAH rotation.** The dynamic AABB tree
|
|
608
|
+
(`core/bvh2/bvh3/BVH.js`, a Box2D port) used SAH-cost insertion but a
|
|
609
|
+
*height-only* AVL rotation (`balance_height`): height-balanced yet not
|
|
610
|
+
SAH-balanced, so queries walked more nodes than needed. Replaced the
|
|
611
|
+
rotation in `bubble_up_update` with `balance_rotate` — the Box2D-v3 /
|
|
612
|
+
Kensler SAH-reducing rotation (for node A with children B, C, evaluate the
|
|
613
|
+
four child↔grandchild swaps and apply the one that most reduces the
|
|
614
|
+
surface-area cost). Deterministic; identical pair set.
|
|
615
|
+
- Measured (same-session A/B, heavy benches): raycast **−9%**
|
|
616
|
+
(28.2→25.6 µs/ray), falling-tower median **−10%**, settling-grid
|
|
617
|
+
median **−12%**, and the **990/1000-churn stress −27%**
|
|
618
|
+
(63.95→46.68 ms mean over 10k ticks) — biggest where the tree churns
|
|
619
|
+
hardest. Determinism (8-trial bit-identical) holds.
|
|
620
|
+
- **Insertion cost (measured):** `balance_rotate` does 4 surface-area
|
|
621
|
+
evaluations per bubble-up level vs `balance_height`'s single height
|
|
622
|
+
compare, so *pure bulk insertion* is **~1.4–1.5× slower** — the 100k
|
|
623
|
+
synthetic insert bench (`BVH.spec.js`, drift-controlled interleaved
|
|
624
|
+
A/B) drops from **~37k → ~25k inserts/sec** (~27→~40 µs/insert). This
|
|
625
|
+
is the balancer's worst case (insert-only, zero queries/refits to
|
|
626
|
+
amortise against). It does not show up end-to-end: static trees are
|
|
627
|
+
built once then queried forever, dynamic bodies insert once then
|
|
628
|
+
refit/query every frame, and even the 990/1000-swap stress test — the
|
|
629
|
+
maximal insert-churn workload — is net **−27%**. Accepted.
|
|
630
|
+
- **Tradeoff (documented):** the contact solver's Gauss-Seidel order
|
|
631
|
+
follows broadphase traversal order (see `generate_pairs`), so the
|
|
632
|
+
different tree shape shifts convergence on near-aligned stacks — the
|
|
633
|
+
synthetic 128-cube wall now sleeps at ~10 s (was ~6.9 s). It still
|
|
634
|
+
settles, doesn't creep / topple (all bug-guard assertions hold); only
|
|
635
|
+
the sleep *time* moved (that test's budget was bumped 9→11 s with a
|
|
636
|
+
note). Random-shape scenes (falling tower) were faster *and* settled
|
|
637
|
+
fine.
|
|
638
|
+
- **Follow-up:** decouple the solve order from tree shape — sort the
|
|
639
|
+
broadphase pair list by `(idA, idB)` before narrowphase so contact
|
|
640
|
+
order is body-id-deterministic regardless of tree shape. Then no tree
|
|
641
|
+
change can affect convergence (and the stack settles identically under
|
|
642
|
+
either balancer). Has a per-step sort cost + wide test re-baseline, so
|
|
643
|
+
it's its own task. `balance_height` is retained for comparison /
|
|
644
|
+
fallback.
|
|
614
645
|
- [ ] **Per-island parallel solve**: today's island data layout would
|
|
615
646
|
allow worker-based solving once `SharedArrayBuffer` is available.
|
|
616
647
|
Out-of-scope unless / until SAB is universally usable.
|
|
@@ -637,8 +668,19 @@ scaffolding is in place.
|
|
|
637
668
|
- [ ] **Convex hull shape** with eigen-based principal-axes inertia
|
|
638
669
|
derivation. Hooks `matrix_eigenvalues_in_place` from the existing
|
|
639
670
|
linalg layer.
|
|
640
|
-
- [
|
|
641
|
-
|
|
671
|
+
- [~] **Cylinder / cone shapes.**
|
|
672
|
+
- [x] **`CylinderShape3D`** — Y-aligned solid cylinder (radius + full
|
|
673
|
+
height, flat caps; the capsule's flat-cap sibling). Exact `support`,
|
|
674
|
+
capped-cylinder SDF, bounds, `contains` / `nearest_point` /
|
|
675
|
+
volume-sampling, equals/hash, `'cylinder'` JSON tag, `isCylinderShape3D`
|
|
676
|
+
marker. Convex → routes through the narrowphase **GJK + EPA** fallback
|
|
677
|
+
(no marker dispatch needed); spec asserts overlap-detected +
|
|
678
|
+
MTV-separates vs sphere/box. Closed-form cylinder-vs-X contact pairs
|
|
679
|
+
are a future refinement (the curved side is the usual smooth-support
|
|
680
|
+
EPA case — same status as pre-closed-form sphere/capsule).
|
|
681
|
+
- [ ] Closed-form cylinder contact pairs (cylinder × box / sphere / capsule
|
|
682
|
+
/ plane) for multi-point cap manifolds + stable resting.
|
|
683
|
+
- [ ] **Cone shape** (+ closed-form / GJK fallback).
|
|
642
684
|
|
|
643
685
|
### API polish
|
|
644
686
|
- [x] **`overlap(shape, position, rotation, output, output_offset,
|