@woosh/meep-engine 2.142.0 → 2.144.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.
Files changed (64) hide show
  1. package/package.json +1 -1
  2. package/src/core/geom/3d/shape/CapsuleShape3D.d.ts +1 -1
  3. package/src/core/geom/3d/shape/CapsuleShape3D.js +1 -1
  4. package/src/core/geom/3d/shape/PointShape3D.d.ts +1 -0
  5. package/src/core/geom/3d/shape/PointShape3D.d.ts.map +1 -1
  6. package/src/core/geom/3d/shape/PointShape3D.js +11 -0
  7. package/src/core/geom/3d/shape/SphereShape3D.d.ts +48 -0
  8. package/src/core/geom/3d/shape/SphereShape3D.d.ts.map +1 -0
  9. package/src/core/geom/3d/shape/SphereShape3D.js +131 -0
  10. package/src/core/geom/3d/shape/UnitSphereShape3D.d.ts +30 -18
  11. package/src/core/geom/3d/shape/UnitSphereShape3D.d.ts.map +1 -1
  12. package/src/core/geom/3d/shape/UnitSphereShape3D.js +44 -92
  13. package/src/core/geom/3d/shape/json/shape_to_type.d.ts.map +1 -1
  14. package/src/core/geom/3d/shape/json/shape_to_type.js +4 -2
  15. package/src/core/geom/3d/shape/json/type_adapters.d.ts +12 -3
  16. package/src/core/geom/3d/shape/json/type_adapters.d.ts.map +1 -1
  17. package/src/core/geom/3d/shape/json/type_adapters.js +16 -4
  18. package/src/core/geom/3d/shape/util/shape_to_visual_entity.js +2 -2
  19. package/src/engine/control/first-person/DESIGN_COLLISION.md +302 -0
  20. package/src/engine/control/first-person/FirstPersonPlayerControllerSystem.d.ts +91 -58
  21. package/src/engine/control/first-person/FirstPersonPlayerControllerSystem.d.ts.map +1 -1
  22. package/src/engine/control/first-person/FirstPersonPlayerControllerSystem.js +1814 -1789
  23. package/src/engine/control/first-person/TODO.md +17 -32
  24. package/src/engine/control/first-person/collision/KinematicMover.d.ts +176 -0
  25. package/src/engine/control/first-person/collision/KinematicMover.d.ts.map +1 -0
  26. package/src/engine/control/first-person/collision/KinematicMover.js +424 -0
  27. package/src/engine/control/first-person/prototype_first_person_controller.js +65 -0
  28. package/src/engine/graphics/render/buffer/simple-fx/ao/AmbientOcclusionPostProcessEffect.d.ts.map +1 -1
  29. package/src/engine/graphics/render/buffer/simple-fx/ao/AmbientOcclusionPostProcessEffect.js +3 -1
  30. package/src/engine/graphics/render/buffer/simple-fx/ao/SAOShader.js +30 -16
  31. package/src/engine/physics/PLAN.md +94 -32
  32. package/src/engine/physics/contact/ManifoldStore.d.ts +28 -2
  33. package/src/engine/physics/contact/ManifoldStore.d.ts.map +1 -1
  34. package/src/engine/physics/contact/ManifoldStore.js +37 -3
  35. package/src/engine/physics/contact/combine_material.d.ts +30 -0
  36. package/src/engine/physics/contact/combine_material.d.ts.map +1 -0
  37. package/src/engine/physics/contact/combine_material.js +35 -0
  38. package/src/engine/physics/ecs/Collider.d.ts +15 -0
  39. package/src/engine/physics/ecs/Collider.d.ts.map +1 -1
  40. package/src/engine/physics/ecs/Collider.js +34 -0
  41. package/src/engine/physics/ecs/Joint.d.ts +18 -0
  42. package/src/engine/physics/ecs/Joint.d.ts.map +1 -1
  43. package/src/engine/physics/ecs/Joint.js +70 -0
  44. package/src/engine/physics/ecs/JointSerializationAdapter.d.ts +29 -0
  45. package/src/engine/physics/ecs/JointSerializationAdapter.d.ts.map +1 -0
  46. package/src/engine/physics/ecs/JointSerializationAdapter.js +72 -0
  47. package/src/engine/physics/ecs/PhysicsSystem.d.ts +9 -4
  48. package/src/engine/physics/ecs/PhysicsSystem.d.ts.map +1 -1
  49. package/src/engine/physics/ecs/PhysicsSystem.js +9 -4
  50. package/src/engine/physics/ecs/RigidBody.d.ts +15 -0
  51. package/src/engine/physics/ecs/RigidBody.d.ts.map +1 -1
  52. package/src/engine/physics/ecs/RigidBody.js +46 -0
  53. package/src/engine/physics/narrowphase/compute_penetration.d.ts +41 -41
  54. package/src/engine/physics/narrowphase/compute_penetration.d.ts.map +1 -1
  55. package/src/engine/physics/narrowphase/compute_penetration.js +96 -169
  56. package/src/engine/physics/narrowphase/narrowphase_step.d.ts +52 -0
  57. package/src/engine/physics/narrowphase/narrowphase_step.d.ts.map +1 -1
  58. package/src/engine/physics/narrowphase/narrowphase_step.js +150 -16
  59. package/src/engine/physics/narrowphase/refine_ray_hit.js +2 -2
  60. package/src/engine/physics/narrowphase/sphere_sphere_contact.d.ts +8 -7
  61. package/src/engine/physics/narrowphase/sphere_sphere_contact.d.ts.map +1 -1
  62. package/src/engine/physics/narrowphase/sphere_sphere_contact.js +8 -7
  63. package/src/engine/physics/solver/solve_contacts.d.ts.map +1 -1
  64. package/src/engine/physics/solver/solve_contacts.js +10 -21
@@ -88,38 +88,23 @@ evaluator then scales. Two-step change.
88
88
 
89
89
  Estimated effort: 1 day (more wiring than the others).
90
90
 
91
- ## Move-and-slide follow-ups
92
-
93
- ### Slide along non-axis-aligned walls
94
- **Landed.** `PhysicsSystem.shapeCast` now writes the true contact
95
- normal (narrowphase-refined via GJK + EPA at TOI), and `_moveAndSlide`
96
- uses the canonical iterative slide: stop at first contact, project the
97
- residual onto the contact tangent, sweep again, repeat up to 4 times.
98
- Multi-contact corners stop cleanly; diagonal approaches slide along
99
- both axis-aligned and oblique surfaces.
100
-
101
- ### Vertical sweep (anti-tunnel for floors and ceilings)
102
- `_moveAndSlide` is currently called for horizontal motion only. The
103
- vertical phase still does a direct position add. For fast falls past
104
- the ground-resolver's 0.1 m raycast lift, the player can sink through
105
- the floor in one tick; for vertical wall-jumps into a ceiling, no
106
- contact is detected.
107
-
108
- Wiring a vertical sweep into `_integrateVerticalAndResolveGround`
109
- needs to be coordinated with the SKIN clearance — landing at
110
- `floor + SKIN` would re-flag the player as airborne by the resolver,
111
- which would re-apply gravity and bounce. Either drop SKIN on
112
- floor-normal contacts, or have the resolver use a tolerance instead
113
- of strict `position.y <= testY`.
114
-
115
- ### Ability motion routing
116
- Slide and WallRun integrate position manually (slide friction in the
117
- ability tick; wall-run reduced-gravity integration). Once the slide-
118
- along-walls work above lands, threading those through `_moveAndSlide`
119
- would prevent wall-runners from drifting into the wall and sliders
120
- from penetrating obstacles in their path. The controller-side change
121
- is a few lines per ability; the gating issue is having a usable
122
- surface normal.
91
+ ## Collision (KinematicMover) follow-ups
92
+
93
+ The legacy `_moveAndSlide` + scalar-resolver regime was replaced by the
94
+ `collision/KinematicMover` rebuild (Phases 1–4; see DESIGN_COLLISION.md).
95
+ Slide-along-walls (axis + oblique), vertical anti-tunnel (floors and
96
+ ceilings), depenetration, slopes, stick-to-ground, and stairs all landed
97
+ there. Remaining:
98
+
99
+ ### Route WallRun through the mover
100
+ Slide already routes its motion through the mover (it calls
101
+ `_integrateVerticalAndResolveGround` `_moveViaMover`). **WallRun still
102
+ self-integrates** its reduced-gravity model and so doesn't get
103
+ sweep-and-slide a wall-runner can still drift into / off geometry the
104
+ mover would have resolved. Thread WallRun's per-tick motion through
105
+ `mover.move()` (keeping its reduced-gravity as the vertical input). The
106
+ gating issue (a usable surface normal) is solved now — `result.groundNormal`
107
+ and the narrowphase contact normals are available.
123
108
 
124
109
  ## Other open notes
125
110
 
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Kinematic character collision solver — Phase 1 (recover +
3
+ * unified sweep-and-slide). See DESIGN_COLLISION.md.
4
+ *
5
+ * The mover is controller-agnostic: it knows about a capsule pose, a
6
+ * desired velocity, and the physics world. Its job is to take the
7
+ * velocity the control layer wants and return the position it actually
8
+ * reaches plus the velocity corrected for whatever it hit. It invents
9
+ * no motion — gravity / jump impulses live in the control layer and
10
+ * arrive folded into `velocity`.
11
+ *
12
+ * One move is a sequence (Phase 1 implements steps 1-2; ground
13
+ * categorize / stairs / settle land in later phases):
14
+ *
15
+ * 1. RECOVER — depenetration via `overlap()` + `compute_penetration`,
16
+ * run unconditionally so the move starts clear even from a
17
+ * start-solid state a pure sweep can't escape.
18
+ * 2. SLIDE — unified 3D collide-and-slide via `shapeCast`, clipping
19
+ * velocity onto true narrowphase contact normals. Crease-aware
20
+ * (Quake `SV_FlyMove`): a second plane re-violating the first is
21
+ * handled by sliding along their seam; a third (or a velocity
22
+ * reversal) dead-stops. Floors / ceilings / walls are all just
23
+ * contact planes, so vertical anti-tunnelling is automatic.
24
+ *
25
+ * @author Alex Goldring
26
+ * @copyright Company Named Limited (c) 2026
27
+ */
28
+ export class KinematicMover {
29
+ /**
30
+ * @param {import("../../../physics/ecs/PhysicsSystem.js").PhysicsSystem} physicsSystem
31
+ * @param {import("../../../ecs/EntityComponentDataset.js").EntityComponentDataset} ecd
32
+ * used to resolve an overlapping `body_id` (via
33
+ * `physicsSystem.entityOf`) back to its {@link Transform} +
34
+ * {@link Collider} for `compute_penetration`.
35
+ * @param {object} [options]
36
+ * @param {number} [options.skin=0.005] clearance left after each
37
+ * sweep stop / push-out so the next query doesn't start in contact.
38
+ * @param {number} [options.maxSlideIterations=4] slide "bumps" per
39
+ * move (Quake `numbumps`).
40
+ * @param {number} [options.maxRecoverIterations=4] depenetration
41
+ * passes before giving up (deepest body pushed out per pass).
42
+ * @param {number} [options.minWalkNormal=0.7] minimum ground-normal Y
43
+ * to count as standable (~45.6°). Below it the surface is "too
44
+ * steep" — the player slides instead of grounding. Matches Quake3
45
+ * `MIN_WALK_NORMAL` / Source `normal.z ≥ 0.7`.
46
+ * @param {number} [options.stepHeight=0.3] maximum step the player
47
+ * traverses, both up and down:
48
+ * - UP: ground-categorize mounts the player onto a surface up to
49
+ * `stepHeight` above the feet (a step the capsule's leading edge
50
+ * reaches); a taller riser isn't mounted and the slide blocks it.
51
+ * The capsule's round bottom does the actual climbing motion;
52
+ * `stepHeight` is the gate on how high categorize will stick.
53
+ * - DOWN: the ground-stick reach — walking off a drop no larger
54
+ * than this snaps the player onto the lower surface (stays
55
+ * grounded); a larger drop goes airborne (a real ledge).
56
+ * Source `sv_stepsize` is ~0.34 m; the default is just under the
57
+ * capsule radius so low ledges don't feel "magnetic".
58
+ */
59
+ constructor(physicsSystem: import("../../../physics/ecs/PhysicsSystem.js").PhysicsSystem, ecd: import("../../../ecs/EntityComponentDataset.js").EntityComponentDataset, options?: {
60
+ skin?: number;
61
+ maxSlideIterations?: number;
62
+ maxRecoverIterations?: number;
63
+ minWalkNormal?: number;
64
+ stepHeight?: number;
65
+ });
66
+ physicsSystem: import("../../../physics/ecs/PhysicsSystem.js").PhysicsSystem;
67
+ ecd: import("../../../ecs/EntityComponentDataset.js").EntityComponentDataset;
68
+ skin: number;
69
+ maxSlideIterations: number;
70
+ maxRecoverIterations: number;
71
+ minWalkNormal: number;
72
+ stepHeight: number;
73
+ _ray: Ray3;
74
+ _hit: PhysicsSurfacePoint;
75
+ _overlapBuf: Uint32Array;
76
+ _penDir: Float64Array;
77
+ _planes: Float64Array;
78
+ _cand: Float64Array;
79
+ /**
80
+ * Reused result. `grounded` / `groundNormal` are Phase 2 outputs;
81
+ * in Phase 1 they're left at their defaults (the mover doesn't
82
+ * categorize ground yet).
83
+ * @type {{hit:boolean, grounded:boolean, groundNormal:Vector3}}
84
+ */
85
+ _result: {
86
+ hit: boolean;
87
+ grounded: boolean;
88
+ groundNormal: Vector3;
89
+ };
90
+ /**
91
+ * Resolve one move. Mutates `position` to the resolved location and
92
+ * `velocity` to the corrected value.
93
+ *
94
+ * @param {Vector3} position in/out — current pose, written to the resolved pose
95
+ * @param {{x:number,y:number,z:number,w:number}} rotation capsule orientation (read)
96
+ * @param {import("../../../../core/geom/3d/shape/AbstractShape3D.js").AbstractShape3D} shape convex capsule
97
+ * @param {Vector3} velocity in/out — desired velocity, written to the corrected velocity
98
+ * @param {number} dt
99
+ * @param {(entity:number, collider:Collider)=>boolean} filter excludes
100
+ * the player's own body in integration; accept-all in isolation
101
+ * @returns {{hit:boolean, grounded:boolean, groundNormal:Vector3}} reused result
102
+ */
103
+ move(position: Vector3, rotation: {
104
+ x: number;
105
+ y: number;
106
+ z: number;
107
+ w: number;
108
+ }, shape: import("../../../../core/geom/3d/shape/AbstractShape3D.js").AbstractShape3D, velocity: Vector3, dt: number, filter: (entity: number, collider: Collider) => boolean): {
109
+ hit: boolean;
110
+ grounded: boolean;
111
+ groundNormal: Vector3;
112
+ };
113
+ /**
114
+ * Push the capsule out of any geometry it currently overlaps. Each
115
+ * pass queries overlaps, finds the single deepest penetration via
116
+ * {@link compute_penetration}, and pushes out along its separation
117
+ * axis by `depth + skin`; re-queries until clear or the iteration
118
+ * cap is hit. Deepest-first converges multi-body resting contact
119
+ * (each pass removes the worst offender) without solving a system.
120
+ *
121
+ * @private
122
+ */
123
+ private _recover;
124
+ /**
125
+ * Unified 3D collide-and-slide. Faithful to Quake's `SV_FlyMove`:
126
+ * sweep the velocity for the remaining time, stop at contact, clip
127
+ * the (original) velocity against every plane hit so far, slide
128
+ * along the seam of a two-plane crease, dead-stop on a third plane
129
+ * or a velocity reversal.
130
+ *
131
+ * @private
132
+ */
133
+ private _slide;
134
+ /**
135
+ * Ground categorization + stick-to-ground + slope velocity clip.
136
+ * Probes below the feet to decide grounded-ness, the surface height
137
+ * to rest on, and the surface normal for the velocity clip.
138
+ *
139
+ * - `ascending` (jumped this tick) → never grounded; skip entirely
140
+ * so the snap can't cancel the jump.
141
+ * - no surface within `stepHeight` below the feet → airborne.
142
+ *
143
+ * Two probes, with a deliberate division of labour that's what makes
144
+ * stairs AND slopes both work (they're the same steep-normal contact
145
+ * to a single probe, so one probe can't tell them apart):
146
+ *
147
+ * (A) WALKABILITY + base height — a centre-point RAYCAST. It sees
148
+ * the actual planar surface under the feet and ignores both a
149
+ * step's convex top EDGE (whose normal is misleadingly steep)
150
+ * and a wall's vertical SIDE face. A steep normal here means a
151
+ * genuine steep SLOPE → not grounded (slide).
152
+ * (B) STEP height — a footprint capsule SHAPECAST. It raises the
153
+ * rest height onto a step the leading edge overhangs (which the
154
+ * centre ray, aimed behind the riser, misses). Used only when
155
+ * it genuinely swept (`hit.t > skin`, not start-solid in a wall)
156
+ * and the step is within `stepHeight`.
157
+ *
158
+ * Reference point is the capsule bottom (`position.y`), matching the
159
+ * feet-at-origin player capsule.
160
+ *
161
+ * @private
162
+ */
163
+ private _categorizeGround;
164
+ /**
165
+ * `out = v - n·(v·n)` — project `v` onto the plane with unit normal
166
+ * `n` (overbounce 1.0; clearance handled by `skin`, so no extra
167
+ * nudge — equivalent in practice to Quake3's `OVERCLIP 1.001`).
168
+ * @private
169
+ */
170
+ private _clip;
171
+ }
172
+ import { Ray3 } from "../../../../core/geom/3d/ray/Ray3.js";
173
+ import { PhysicsSurfacePoint } from "../../../physics/queries/PhysicsSurfacePoint.js";
174
+ import Vector3 from "../../../../core/geom/Vector3.js";
175
+ import { Collider } from "../../../physics/ecs/Collider.js";
176
+ //# sourceMappingURL=KinematicMover.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"KinematicMover.d.ts","sourceRoot":"","sources":["../../../../../../src/engine/control/first-person/collision/KinematicMover.js"],"names":[],"mappings":"AAmBA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH;IACI;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA6BG;IACH,2BA7BW,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;OAqChB;IAvBG,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;IAEhC;;;;;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,CA+BjE;IAED;;;;;;;;;OASG;IACH,iBAwCC;IAED;;;;;;;;OAQG;IACH,eA0HC;IAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA4BG;IACH,0BAgDC;IAED;;;;;OAKG;IACH,cAKC;CACJ;qBAtaoB,sCAAsC;oCAIvB,iDAAiD;oBALjE,kCAAkC;yBAG7B,kCAAkC"}
@@ -0,0 +1,424 @@
1
+ import Vector3 from "../../../../core/geom/Vector3.js";
2
+ import { Ray3 } from "../../../../core/geom/3d/ray/Ray3.js";
3
+ import { Transform } from "../../../ecs/transform/Transform.js";
4
+ import { Collider } from "../../../physics/ecs/Collider.js";
5
+ import { compute_penetration } from "../../../physics/narrowphase/compute_penetration.js";
6
+ import { PhysicsSurfacePoint } from "../../../physics/queries/PhysicsSurfacePoint.js";
7
+
8
+ /**
9
+ * Maximum simultaneous clip planes tracked within a single slide,
10
+ * matching Quake's `MAX_CLIP_PLANES`. A move that contacts more than
11
+ * this many distinct planes in one tick is in pathological geometry
12
+ * (a cone of inward-pointing walls); we dead-stop rather than thrash.
13
+ * @type {number}
14
+ */
15
+ const MAX_CLIP_PLANES = 5;
16
+
17
+ /** Below this speed/length we treat the remaining motion as spent. */
18
+ const MIN_MOVE = 1e-6;
19
+
20
+ /**
21
+ * Kinematic character collision solver — Phase 1 (recover +
22
+ * unified sweep-and-slide). See DESIGN_COLLISION.md.
23
+ *
24
+ * The mover is controller-agnostic: it knows about a capsule pose, a
25
+ * desired velocity, and the physics world. Its job is to take the
26
+ * velocity the control layer wants and return the position it actually
27
+ * reaches plus the velocity corrected for whatever it hit. It invents
28
+ * no motion — gravity / jump impulses live in the control layer and
29
+ * arrive folded into `velocity`.
30
+ *
31
+ * One move is a sequence (Phase 1 implements steps 1-2; ground
32
+ * categorize / stairs / settle land in later phases):
33
+ *
34
+ * 1. RECOVER — depenetration via `overlap()` + `compute_penetration`,
35
+ * run unconditionally so the move starts clear even from a
36
+ * start-solid state a pure sweep can't escape.
37
+ * 2. SLIDE — unified 3D collide-and-slide via `shapeCast`, clipping
38
+ * velocity onto true narrowphase contact normals. Crease-aware
39
+ * (Quake `SV_FlyMove`): a second plane re-violating the first is
40
+ * handled by sliding along their seam; a third (or a velocity
41
+ * reversal) dead-stops. Floors / ceilings / walls are all just
42
+ * contact planes, so vertical anti-tunnelling is automatic.
43
+ *
44
+ * @author Alex Goldring
45
+ * @copyright Company Named Limited (c) 2026
46
+ */
47
+ export class KinematicMover {
48
+ /**
49
+ * @param {import("../../../physics/ecs/PhysicsSystem.js").PhysicsSystem} physicsSystem
50
+ * @param {import("../../../ecs/EntityComponentDataset.js").EntityComponentDataset} ecd
51
+ * used to resolve an overlapping `body_id` (via
52
+ * `physicsSystem.entityOf`) back to its {@link Transform} +
53
+ * {@link Collider} for `compute_penetration`.
54
+ * @param {object} [options]
55
+ * @param {number} [options.skin=0.005] clearance left after each
56
+ * sweep stop / push-out so the next query doesn't start in contact.
57
+ * @param {number} [options.maxSlideIterations=4] slide "bumps" per
58
+ * move (Quake `numbumps`).
59
+ * @param {number} [options.maxRecoverIterations=4] depenetration
60
+ * passes before giving up (deepest body pushed out per pass).
61
+ * @param {number} [options.minWalkNormal=0.7] minimum ground-normal Y
62
+ * to count as standable (~45.6°). Below it the surface is "too
63
+ * steep" — the player slides instead of grounding. Matches Quake3
64
+ * `MIN_WALK_NORMAL` / Source `normal.z ≥ 0.7`.
65
+ * @param {number} [options.stepHeight=0.3] maximum step the player
66
+ * traverses, both up and down:
67
+ * - UP: ground-categorize mounts the player onto a surface up to
68
+ * `stepHeight` above the feet (a step the capsule's leading edge
69
+ * reaches); a taller riser isn't mounted and the slide blocks it.
70
+ * The capsule's round bottom does the actual climbing motion;
71
+ * `stepHeight` is the gate on how high categorize will stick.
72
+ * - DOWN: the ground-stick reach — walking off a drop no larger
73
+ * than this snaps the player onto the lower surface (stays
74
+ * grounded); a larger drop goes airborne (a real ledge).
75
+ * Source `sv_stepsize` is ~0.34 m; the default is just under the
76
+ * capsule radius so low ledges don't feel "magnetic".
77
+ */
78
+ constructor(physicsSystem, ecd, options = {}) {
79
+ this.physicsSystem = physicsSystem;
80
+ this.ecd = ecd;
81
+ this.skin = options.skin !== undefined ? options.skin : 0.005;
82
+ this.maxSlideIterations = options.maxSlideIterations !== undefined ? options.maxSlideIterations : 4;
83
+ this.maxRecoverIterations = options.maxRecoverIterations !== undefined ? options.maxRecoverIterations : 4;
84
+ this.minWalkNormal = options.minWalkNormal !== undefined ? options.minWalkNormal : 0.7;
85
+ this.stepHeight = options.stepHeight !== undefined ? options.stepHeight : 0.3;
86
+
87
+ // ── Scratch — reused per move, no per-call allocation ──────────
88
+ this._ray = new Ray3();
89
+ this._hit = new PhysicsSurfacePoint();
90
+ this._overlapBuf = new Uint32Array(16);
91
+ this._penDir = new Float64Array(3); // compute_penetration out
92
+ this._planes = new Float64Array(MAX_CLIP_PLANES * 3);
93
+ this._cand = new Float64Array(3); // clipped-velocity candidate
94
+
95
+ /**
96
+ * Reused result. `grounded` / `groundNormal` are Phase 2 outputs;
97
+ * in Phase 1 they're left at their defaults (the mover doesn't
98
+ * categorize ground yet).
99
+ * @type {{hit:boolean, grounded:boolean, groundNormal:Vector3}}
100
+ */
101
+ this._result = { hit: false, grounded: false, groundNormal: new Vector3(0, 1, 0) };
102
+ }
103
+
104
+ /**
105
+ * Resolve one move. Mutates `position` to the resolved location and
106
+ * `velocity` to the corrected value.
107
+ *
108
+ * @param {Vector3} position in/out — current pose, written to the resolved pose
109
+ * @param {{x:number,y:number,z:number,w:number}} rotation capsule orientation (read)
110
+ * @param {import("../../../../core/geom/3d/shape/AbstractShape3D.js").AbstractShape3D} shape convex capsule
111
+ * @param {Vector3} velocity in/out — desired velocity, written to the corrected velocity
112
+ * @param {number} dt
113
+ * @param {(entity:number, collider:Collider)=>boolean} filter excludes
114
+ * the player's own body in integration; accept-all in isolation
115
+ * @returns {{hit:boolean, grounded:boolean, groundNormal:Vector3}} reused result
116
+ */
117
+ move(position, rotation, shape, velocity, dt, filter) {
118
+ const result = this._result;
119
+ result.hit = false;
120
+ result.grounded = false;
121
+ result.groundNormal.set(0, 1, 0);
122
+
123
+ // Capture intent-to-leave BEFORE the slide. A positive input
124
+ // vertical velocity is a jump / launch (the only thing that sets
125
+ // one — gravity is negative); on those ticks we must NOT
126
+ // stick-to-ground or the snap cancels the jump on its first
127
+ // frame. A ramp's upward velocity is produced INSIDE the slide
128
+ // (the ramp is a contact plane), so it doesn't show up here —
129
+ // which is exactly why we gate on the pre-slide value.
130
+ const ascending = velocity.y > MIN_MOVE;
131
+
132
+ // 1. Recover — start the move penetration-free.
133
+ this._recover(position, rotation, shape, filter);
134
+
135
+ // Snapshot pre-slide state for a possible stair step-up retry.
136
+ const sx = position.x, sy = position.y, sz = position.z;
137
+ const svx = velocity.x, svy = velocity.y, svz = velocity.z;
138
+
139
+ // 2. Sweep-and-slide the desired motion.
140
+ this._slide(position, rotation, shape, velocity, dt, filter, result);
141
+
142
+ // 3. Ground categorize + stick + slope velocity clip.
143
+ this._categorizeGround(position, rotation, shape, velocity, filter, result, ascending);
144
+
145
+ return result;
146
+ }
147
+
148
+ /**
149
+ * Push the capsule out of any geometry it currently overlaps. Each
150
+ * pass queries overlaps, finds the single deepest penetration via
151
+ * {@link compute_penetration}, and pushes out along its separation
152
+ * axis by `depth + skin`; re-queries until clear or the iteration
153
+ * cap is hit. Deepest-first converges multi-body resting contact
154
+ * (each pass removes the worst offender) without solving a system.
155
+ *
156
+ * @private
157
+ */
158
+ _recover(position, rotation, shape, filter) {
159
+ const physics = this.physicsSystem;
160
+ const ecd = this.ecd;
161
+ const buf = this._overlapBuf;
162
+ const dir = this._penDir;
163
+
164
+ for (let iter = 0; iter < this.maxRecoverIterations; iter++) {
165
+ const n = physics.overlap(shape, position, rotation, buf, 0, filter);
166
+ if (n === 0) return;
167
+
168
+ let bestDepth = 0;
169
+ let bestX = 0, bestY = 0, bestZ = 0;
170
+
171
+ for (let i = 0; i < n; i++) {
172
+ const bodyId = buf[i];
173
+ const entity = physics.entityOf(bodyId);
174
+ if (entity < 0) continue;
175
+ const otherT = ecd.getComponent(entity, Transform);
176
+ const otherC = ecd.getComponent(entity, Collider);
177
+ if (otherT === undefined || otherC === undefined) continue;
178
+
179
+ // depth + unit B→A separation (A = our capsule, B = other).
180
+ const depth = compute_penetration(
181
+ dir,
182
+ shape, position, rotation,
183
+ otherC.shape, otherT.position, otherT.rotation,
184
+ );
185
+ if (depth > bestDepth) {
186
+ bestDepth = depth;
187
+ bestX = dir[0]; bestY = dir[1]; bestZ = dir[2];
188
+ }
189
+ }
190
+
191
+ // Overlap reported but no actionable depth (touching boundary,
192
+ // or MPR/overlap disagreement at the kiss) — treat as clear.
193
+ if (bestDepth <= 0) return;
194
+
195
+ const push = bestDepth + this.skin;
196
+ position._add(bestX * push, bestY * push, bestZ * push);
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Unified 3D collide-and-slide. Faithful to Quake's `SV_FlyMove`:
202
+ * sweep the velocity for the remaining time, stop at contact, clip
203
+ * the (original) velocity against every plane hit so far, slide
204
+ * along the seam of a two-plane crease, dead-stop on a third plane
205
+ * or a velocity reversal.
206
+ *
207
+ * @private
208
+ */
209
+ _slide(position, rotation, shape, velocity, dt, filter, result) {
210
+ const physics = this.physicsSystem;
211
+ const ray = this._ray;
212
+ const hit = this._hit;
213
+ const planes = this._planes;
214
+ const cand = this._cand;
215
+ const skin = this.skin;
216
+
217
+ // Original desired velocity — clipping is always done against
218
+ // this, not the running value (Quake's invariant; prevents
219
+ // accumulated rounding from spiralling the direction).
220
+ const ovx = velocity.x, ovy = velocity.y, ovz = velocity.z;
221
+ let vx = ovx, vy = ovy, vz = ovz;
222
+ let timeLeft = dt;
223
+ let numPlanes = 0;
224
+
225
+ for (let bump = 0; bump < this.maxSlideIterations; bump++) {
226
+ const speed = Math.sqrt(vx * vx + vy * vy + vz * vz);
227
+ if (speed < MIN_MOVE) break;
228
+ const len = speed * timeLeft;
229
+ if (len < MIN_MOVE) break;
230
+
231
+ const inv = 1 / speed;
232
+ const ndx = vx * inv, ndy = vy * inv, ndz = vz * inv;
233
+
234
+ ray.setOrigin(position.x, position.y, position.z);
235
+ ray.setDirection(ndx, ndy, ndz);
236
+ ray.tMax = len;
237
+
238
+ const didHit = physics.shapeCast(ray, shape, rotation, hit, filter);
239
+
240
+ if (!didHit) {
241
+ // Clear path — consume the whole remaining move.
242
+ position._add(ndx * len, ndy * len, ndz * len);
243
+ break;
244
+ }
245
+
246
+ // Separating contact: `shapeCast` reports a start-in-contact
247
+ // hit (t≈0) even when we're moving AWAY from the touched
248
+ // surface — e.g. jumping off a floor we're resting on, where
249
+ // the down-normal floor is behind the upward motion. If the
250
+ // sweep direction points along the outward contact normal
251
+ // (dir·n ≥ 0) the surface isn't in our way; take the full
252
+ // remaining move rather than clipping the velocity into it
253
+ // (which would cancel the jump). A genuine blocker is only
254
+ // reachable by moving INTO it (dir·n < 0).
255
+ const dirDotN = ndx * hit.normal.x + ndy * hit.normal.y + ndz * hit.normal.z;
256
+ if (dirDotN >= 0) {
257
+ position._add(ndx * len, ndy * len, ndz * len);
258
+ break;
259
+ }
260
+
261
+ result.hit = true;
262
+
263
+ // Advance up to the contact (less skin), and consume the
264
+ // corresponding fraction of the remaining time.
265
+ const advance = hit.t - skin > 0 ? hit.t - skin : 0;
266
+ if (advance > 0) position._add(ndx * advance, ndy * advance, ndz * advance);
267
+ const fraction = hit.t / len; // time uses the true TOI, not the skinned advance
268
+ timeLeft -= timeLeft * (fraction > 1 ? 1 : fraction);
269
+
270
+ if (numPlanes >= MAX_CLIP_PLANES) {
271
+ // Too many planes — wedged. Stop.
272
+ vx = vy = vz = 0;
273
+ break;
274
+ }
275
+ const po = numPlanes * 3;
276
+ planes[po] = hit.normal.x;
277
+ planes[po + 1] = hit.normal.y;
278
+ planes[po + 2] = hit.normal.z;
279
+ numPlanes++;
280
+
281
+ // Re-derive velocity: find a plane the ORIGINAL velocity can
282
+ // slide along without violating any other plane.
283
+ let resolved = false;
284
+ for (let i = 0; i < numPlanes; i++) {
285
+ const ix = planes[i * 3], iy = planes[i * 3 + 1], iz = planes[i * 3 + 2];
286
+ this._clip(ovx, ovy, ovz, ix, iy, iz, cand);
287
+ let ok = true;
288
+ for (let j = 0; j < numPlanes; j++) {
289
+ if (j === i) continue;
290
+ const jx = planes[j * 3], jy = planes[j * 3 + 1], jz = planes[j * 3 + 2];
291
+ if (cand[0] * jx + cand[1] * jy + cand[2] * jz < 0) { ok = false; break; }
292
+ }
293
+ if (ok) {
294
+ vx = cand[0]; vy = cand[1]; vz = cand[2];
295
+ resolved = true;
296
+ break;
297
+ }
298
+ }
299
+
300
+ if (!resolved) {
301
+ // No single plane works — slide along the crease of two.
302
+ // (More than two simultaneously-violated planes ⇒ corner;
303
+ // dead-stop.)
304
+ if (numPlanes !== 2) {
305
+ vx = vy = vz = 0;
306
+ break;
307
+ }
308
+ const ax = planes[0], ay = planes[1], az = planes[2];
309
+ const bx = planes[3], by = planes[4], bz = planes[5];
310
+ // seam = a × b
311
+ let cx = ay * bz - az * by;
312
+ let cy = az * bx - ax * bz;
313
+ let cz = ax * by - ay * bx;
314
+ const clen = Math.sqrt(cx * cx + cy * cy + cz * cz);
315
+ if (clen < MIN_MOVE) { vx = vy = vz = 0; break; }
316
+ const cinv = 1 / clen;
317
+ cx *= cinv; cy *= cinv; cz *= cinv;
318
+ const d = cx * ovx + cy * ovy + cz * ovz;
319
+ vx = cx * d; vy = cy * d; vz = cz * d;
320
+ }
321
+
322
+ // Anti-oscillation: if the new velocity opposes the original
323
+ // intent, we'd bounce back into the geometry — stop instead.
324
+ if (vx * ovx + vy * ovy + vz * ovz <= 0) {
325
+ vx = vy = vz = 0;
326
+ break;
327
+ }
328
+ }
329
+
330
+ velocity.set(vx, vy, vz);
331
+ }
332
+
333
+ /**
334
+ * Ground categorization + stick-to-ground + slope velocity clip.
335
+ * Probes below the feet to decide grounded-ness, the surface height
336
+ * to rest on, and the surface normal for the velocity clip.
337
+ *
338
+ * - `ascending` (jumped this tick) → never grounded; skip entirely
339
+ * so the snap can't cancel the jump.
340
+ * - no surface within `stepHeight` below the feet → airborne.
341
+ *
342
+ * Two probes, with a deliberate division of labour that's what makes
343
+ * stairs AND slopes both work (they're the same steep-normal contact
344
+ * to a single probe, so one probe can't tell them apart):
345
+ *
346
+ * (A) WALKABILITY + base height — a centre-point RAYCAST. It sees
347
+ * the actual planar surface under the feet and ignores both a
348
+ * step's convex top EDGE (whose normal is misleadingly steep)
349
+ * and a wall's vertical SIDE face. A steep normal here means a
350
+ * genuine steep SLOPE → not grounded (slide).
351
+ * (B) STEP height — a footprint capsule SHAPECAST. It raises the
352
+ * rest height onto a step the leading edge overhangs (which the
353
+ * centre ray, aimed behind the riser, misses). Used only when
354
+ * it genuinely swept (`hit.t > skin`, not start-solid in a wall)
355
+ * and the step is within `stepHeight`.
356
+ *
357
+ * Reference point is the capsule bottom (`position.y`), matching the
358
+ * feet-at-origin player capsule.
359
+ *
360
+ * @private
361
+ */
362
+ _categorizeGround(position, rotation, shape, velocity, filter, result, ascending) {
363
+ if (ascending) return; // jump / launch — stay airborne this tick
364
+
365
+ const ray = this._ray;
366
+ const hit = this._hit;
367
+ const lift = this.stepHeight; // probe starts this high above the feet
368
+ const reach = lift + this.stepHeight + this.skin; // …down to stepHeight below the feet
369
+
370
+ // (A) Centre raycast — walkability + base surface height.
371
+ ray.setOrigin(position.x, position.y + lift, position.z);
372
+ ray.setDirection(0, -1, 0);
373
+ ray.tMax = reach;
374
+ if (!this.physicsSystem.raycast(ray, hit, filter)) return; // airborne
375
+ const nx = hit.normal.x, ny = hit.normal.y, nz = hit.normal.z;
376
+ result.groundNormal.set(nx, ny, nz);
377
+ if (ny < this.minWalkNormal) return; // steep slope under the feet — slide, not grounded
378
+ let surfaceY = position.y + lift - hit.t;
379
+
380
+ // (B) Footprint shapecast — mount a step the leading edge overhangs.
381
+ ray.setOrigin(position.x, position.y + lift, position.z);
382
+ ray.setDirection(0, -1, 0);
383
+ ray.tMax = reach;
384
+ if (this.physicsSystem.shapeCast(ray, shape, rotation, hit, filter) && hit.t > this.skin) {
385
+ const stepY = position.y + lift - hit.t;
386
+ // Raise onto it only if it's higher than the centre surface
387
+ // and within a climbable step (a taller one is a wall the
388
+ // slide blocks — leave the player on the centre surface).
389
+ if (stepY > surfaceY && stepY - position.y <= this.stepHeight + this.skin) {
390
+ surfaceY = stepY;
391
+ }
392
+ }
393
+
394
+ result.grounded = true;
395
+
396
+ // Stick: rest the feet `skin` above the surface (snaps down for a
397
+ // descent, up onto a step / out of a grazing contact).
398
+ position.y = surfaceY + this.skin;
399
+
400
+ // Clip the into-ground velocity component (gravity on flat ground;
401
+ // the into-slope part on a ramp), leaving tangential motion.
402
+ const vdot = velocity.x * nx + velocity.y * ny + velocity.z * nz;
403
+ if (vdot < 0) {
404
+ velocity.set(
405
+ velocity.x - vdot * nx,
406
+ velocity.y - vdot * ny,
407
+ velocity.z - vdot * nz,
408
+ );
409
+ }
410
+ }
411
+
412
+ /**
413
+ * `out = v - n·(v·n)` — project `v` onto the plane with unit normal
414
+ * `n` (overbounce 1.0; clearance handled by `skin`, so no extra
415
+ * nudge — equivalent in practice to Quake3's `OVERCLIP 1.001`).
416
+ * @private
417
+ */
418
+ _clip(vx, vy, vz, nx, ny, nz, out) {
419
+ const backoff = vx * nx + vy * ny + vz * nz;
420
+ out[0] = vx - nx * backoff;
421
+ out[1] = vy - ny * backoff;
422
+ out[2] = vz - nz * backoff;
423
+ }
424
+ }