"""Collide-and-slide movement policy for kinematic character bodies.
This is the ONE thing that distinguishes a character body from any other
:attr:`~simvx.core.physics.world.BodyMode.KINEMATIC` body. It is a pair of free
functions, not a backend method, written purely against five world primitives:
:meth:`~simvx.core.physics.world.PhysicsWorld.body_transform`,
:meth:`~simvx.core.physics.world.PhysicsWorld.set_body_transform` and
:meth:`~simvx.core.physics.world.PhysicsWorld.sweep_body` for the move, plus
:meth:`~simvx.core.physics.world.PhysicsWorld.body_mass` and
:meth:`~simvx.core.physics.world.PhysicsWorld.apply_impulse` for the push. Being
a free function means no backend can override it, every backend gets bit-identical
policy, and it is unit-testable against a stub world with no physics backend at all.
The policy reads the start pose once, tracks it in plain Python floats, passes
every sweep an explicit ``from_transform``, and writes the pose back exactly once
at the end. Nothing is written to the backend during the loop, so an abandoned
step-up leg simply discards a tracked pose: there is no revert to perform.
The final :meth:`set_body_transform` is UNCONDITIONAL and load-bearing. Some
backends only search colliding pairs over bodies they consider active, and
re-posing a kinematic body is what keeps it active and therefore visible to
areas and sensors. A "pose unchanged, skip the write" guard would look like a
free saving (one fewer FFI crossing per idle character on the web backend) and
would silently make every stationary character invisible to triggers. Do not add
one.
The policy never integrates gravity (the caller supplies the velocity) and never
writes the body's simulated velocity: like every kinematic body driven by
``move_and_collide``, a character is position-driven and its stored linear
velocity stays zero. A character never CARRIES a body resting on it, but it does
SHOVE what it walks into, by default: ``push_factor`` is ``1.0``, and ``0.0`` is
the opt-out that restores the pure block and costs one float comparison per
blocking contact. Unreal is the only other engine shipping a built-in push and it
too is on by default. The blocking contacts the move resolved are handed back in
:attr:`MoveResult.collisions` whatever the factor, so game code that wants
different physics (carrying, one-way pushes, damage on impact) still has
everything it needs to do its own thing.
The push is one impulse per blocking contact the character did not land ON (see
the floor exemption below), along the contact normal, of magnitude
``push_factor * (M * m / (M + m)) * approach_speed`` -- the character's mass
``M``, the struck body's mass ``m``, and the component of the character's
velocity into that surface at the moment it was blocked. Scaling by the approach
speed rather than using a flat impulse is what stops a walking character and a
sprinting one shoving a crate identically; Unreal scales its own push force the
same way, under a flag it turns on by default (``bScalePushForceToVelocity``).
``M * m / (M + m)`` is the reduced mass of the pair, and multiplied by the
approach speed it is exactly the impulse of a perfectly inelastic collision: the
one that leaves both bodies moving together. That is what makes ``1.0`` a
meaningful default rather than a hazard. ONE contact changes the struck body's
velocity by ``impulse / m``, which comes out as
``push_factor * M / (M + m) * approach``, so at ``1.0`` a single contact adds at
most the character's own approach speed and never more -- measured on both 3D
backends, a 70 kg character walking at 4 m/s hands a 1 kg crate 3.9437 m/s, an
equal 70 kg crate 2.0000 m/s and a 1000 kg crate 0.2617 m/s, each matching the
formula to four decimals. A character that keeps walking catches a light body up
and hits it again, and those kicks do stack: this is a bound per contact, not a
speed limit on the body. The unbounded alternative (``M`` alone, no ``m``) would
have handed that 1 kg crate 280 m/s from one contact,
which is why the knob could not previously ship on. Above ``1.0`` the impulse
stops being physical and becomes a shove-harder dial; below it, a character that
leans on things gently.
The impulse goes through the CENTRE OF MASS, not through the contact point, so a
shove never tumbles what it hits. That is a deliberate departure from the more
physical alternative, and the reason is cross-backend: the angular response to an
off-centre impulse is the one part of the seam a backend is allowed to
approximate, and the built-in tiers do (they use ``inverse_mass`` as an inverse-
inertia stand-in). Measured in one scene -- a 70 kg character walking into a
settled 1 kg crate for two seconds at ``push_factor`` 1 -- applying at the contact
point tilted the crate 43 degrees on builtin against 90 on Jolt and sent it 6.98 m
against 10.14; through the centre of mass there is no tilt at all and the two
travel 6.98 m and 7.27 m. A game wanting the crate to topple has the contact point
in :attr:`MoveResult.collisions` and can add the torque itself.
The one thing the push reads about the body it hit is that body's effective mass,
through :meth:`~simvx.core.physics.world.PhysicsWorld.body_mass`. An immovable
body answers ``inf``: it has no reduced mass to share, and the impulse would have
been inert on it anyway, so the branch skips it outright and a wall or a parked
platform absorbs the walk exactly as before.
The push also reads the contact's own classification, and a contact this move
called FLOOR is exempt. A character standing on something is pressing into it
every step at whatever speed gravity has just given it, so pushing floor contacts
would turn a character's weight into a downward impulse on whatever it stands on:
measured with the exemption removed and the character falling at 18 m/s^2, a 70 kg
character dropped 8 m onto a 1 kg crate at ``push_factor`` 1 drove it 0.2286 m into
the static floor on the built-in solver -- a crate whose resting centre is 0.35 m
up, so two thirds of the way down to it -- and left it 0.0589 m low once everything
settled, against 0.0005 m on Jolt. With the exemption both leave the crate exactly
where it was resting. It is the same exemption Unity's stock controller recipe
makes by filtering downward hits, and it leaves the standing case to the solver,
which is what actually holds a character up. Unreal instead applies a separate,
explicitly scaled downward force for it (``StandingDownwardForceScale``); there
is no such knob here, so standing imparts nothing. Walls, ceilings and slopes too
steep to walk on are all pushed.
The push is applied while the velocity still has its component into that surface,
not afterwards over the collected hits: the slide deflects the velocity out of
each normal as it goes, so a pass over :attr:`MoveResult.collisions` at the end
would measure every primary contact as approaching at zero.
Internals are deliberately scalar (plain floats plus ``math.sqrt`` / ``math.hypot``)
rather than numpy: the vector types are constructed only at the ``sweep_body`` and
``set_body_transform`` boundaries, which measures materially cheaper per character
per step than the same policy expressed in ``Vec3`` arithmetic.
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from typing import TYPE_CHECKING
from ..math import Quat, Vec2, Vec3
from .world import SweepHit
from .world2d import SweepHit2D
if TYPE_CHECKING:
from .world import BodyHandle, PhysicsWorld
from .world2d import Physics2DWorld
# Degenerate-motion guard: a sweep shorter than this is not worth issuing.
_SLIDE_EPS = 1e-9
#: How far ahead of a candidate landing the policy looks for walkable ground when
#: the landing's own normal is not walkable, as a multiple of ``step_height``.
#: A rounded character mounts a ledge by perching on its top EDGE, so its landing
#: normal is the edge's and never the flat top's; asking what is AHEAD is what
#: separates that perch from a foothold up a sphere's flank, which has nothing
#: ahead of it. The window this value has to sit in is derived in
#: :func:`_step_up_3d`.
_STEP_FORWARD_TEST = 1.0
#: Minimum extra depth given to a probe that must PENETRATE a surface rather than
#: touch it. A substepped sweep reports nothing at a flush touch, so a probe aimed
#: exactly at a surface has to overshoot it to see it at all. The same slack the
#: ground probe uses, named because two probes now depend on it.
_GROUND_SLACK = 1e-3
__all__ = ["MoveResult", "MoveResult2D", "move_and_slide", "move_and_slide_2d"]
[docs]
@dataclass(slots=True, frozen=True)
class MoveResult:
"""Outcome of one 3D collide-and-slide move.
Attributes:
velocity: Post-slide velocity (deflected out of every contact normal);
the caller writes this back as its new velocity.
on_floor: True if a contact this move classified as floor, or if the
ground probe found walkable ground under the feet.
on_wall: True if a contact this move classified as wall.
on_ceiling: True if a contact this move classified as ceiling.
floor_normal: Normal of the floor contact this move (unit ``Vec3``), or
``+up`` when there was no floor contact.
position: The final pose position the policy wrote, so the caller needs no
read-back call after the move.
collisions: The blocking :class:`~simvx.core.physics.world.SweepHit`\\ s
the policy resolved, in slide order. Handed back whether or not
``push_factor`` did anything with them, so game code can apply physics
of its own, e.g. ``world.apply_impulse(c.body, impulse, at=c.point)``
for each entry. Empty when the move was unobstructed.
"""
velocity: Vec3
on_floor: bool
on_wall: bool
on_ceiling: bool
floor_normal: Vec3
position: Vec3
collisions: tuple[SweepHit, ...]
[docs]
@dataclass(slots=True, frozen=True)
class MoveResult2D:
"""Outcome of one 2D collide-and-slide move.
2D sibling of :class:`MoveResult`; see it for the field semantics, including
the :attr:`collisions` apply-your-own-impulse contract.
"""
velocity: Vec2
on_floor: bool
on_wall: bool
on_ceiling: bool
floor_normal: Vec2
position: Vec2
collisions: tuple[SweepHit2D, ...]
def _classify(dot_up: float, cos_slope: float) -> str:
"""Classify a contact normal against ``up`` as floor / wall / ceiling.
The single cross-dimension classifier: a normal within ``slope_limit`` of
``+up`` is walkable floor, one within ``slope_limit`` of ``-up`` is ceiling,
and everything between is wall. Both thresholds derive from the SAME
``cos(slope_limit)``, so raising the walkable slope narrows the wall band
symmetrically from both sides.
"""
if dot_up > cos_slope:
return "floor"
if dot_up < -cos_slope:
return "ceiling"
return "wall"
def _unit3(v: object, fx: float, fy: float, fz: float) -> tuple[float, float, float]:
"""Normalise a 3-component sequence, falling back to ``(fx, fy, fz)``."""
x, y, z = float(v[0]), float(v[1]), float(v[2]) # type: ignore[index]
n = math.sqrt(x * x + y * y + z * z)
if n < 1e-12:
return fx, fy, fz
return x / n, y / n, z / n
def _unit2(v: object, fx: float, fy: float) -> tuple[float, float]:
"""Normalise a 2-component sequence, falling back to ``(fx, fy)``."""
x, y = float(v[0]), float(v[1]) # type: ignore[index]
n = math.hypot(x, y)
if n < 1e-12:
return fx, fy
return x / n, y / n
def _ground_normal_3d(
world: PhysicsWorld,
handle: BodyHandle,
pos: tuple[float, float, float],
rot: Quat,
up: tuple[float, float, float],
cos_slope: float,
skin_width: float,
) -> tuple[float, float, float] | None:
"""Walkable-floor normal under ``pos``, or ``None``. Never moves the body.
A hit counts as ground purely on ``dot(normal, up) > cos(slope_limit)``. There
is deliberately NO distance gate: a substepped backend reports the last substep
it proved clear, which for a body resting on a floor is the start of the cast,
so ``distance`` is exactly 0.0 at every resting height and every probe length.
Gating on distance would report airborne forever, so gravity would accumulate
and a jump would never re-arm.
``lift`` is what gives an EXACT-sweep backend a non-degenerate contact normal:
starting the cast flush with the surface leaves its narrowphase with no
separating axis. It changes nothing on a substepped backend, which reports the
same hit and the same 0.0 either way, so it must never be tuned away on the
strength of a builtin-only measurement.
"""
px, py, pz = pos
ux, uy, uz = up
lift = max(skin_width, 1e-3)
reach = lift + max(skin_width * 4.0, 1e-3)
hit = world.sweep_body(
handle,
Vec3(-ux * reach, -uy * reach, -uz * reach),
from_transform=(Vec3(px + ux * lift, py + uy * lift, pz + uz * lift), rot),
skin=skin_width,
)
if hit is None:
return None
n = hit.normal
gx, gy, gz = float(n[0]), float(n[1]), float(n[2])
if gx * ux + gy * uy + gz * uz > cos_slope:
return gx, gy, gz
return None
def _ground_normal_2d(
world: Physics2DWorld,
handle: BodyHandle,
pos: tuple[float, float],
rot: float,
up: tuple[float, float],
cos_slope: float,
skin_width: float,
) -> tuple[float, float] | None:
"""2D sibling of :func:`_ground_normal_3d`; see it for the rule and the lift."""
px, py = pos
ux, uy = up
lift = max(skin_width, 1e-3)
reach = lift + max(skin_width * 4.0, 1e-3)
hit = world.sweep_body(
handle,
Vec2(-ux * reach, -uy * reach),
from_transform=(Vec2(px + ux * lift, py + uy * lift), rot),
skin=skin_width,
)
if hit is None:
return None
n = hit.normal
gx, gy = float(n[0]), float(n[1])
if gx * ux + gy * uy > cos_slope:
return gx, gy
return None
def _walkable_ahead_3d(
world: PhysicsWorld,
handle: BodyHandle,
lifted: tuple[float, float, float],
rot: Quat,
horizontal: tuple[float, float, float],
up: tuple[float, float, float],
step_height: float,
landing_drop: float,
skin_width: float,
cos_slope: float,
) -> bool:
"""Does the surface the step landed on CONTINUE ahead, at the height it landed?
Probes from the LIFTED, advanced pose the drop leg cast from, never from the
candidate landing pose. A rounded character's landing pose is by construction
partly BELOW the surface it perched on the edge of and horizontally outside
it, so a probe placed there would be adjudicating a penetrating pose, and what
a penetrating pose reports is a minimum-translation direction: exactly the
backend-dependent quantity this rule exists to stop reading. The lifted pose is
free space, because the forward leg proved it.
Two legs, both cast from that free-space pose.
Leg A measures how far the lifted pose can actually advance, up to
``_STEP_FORWARD_TEST * step_height``, so a wall just beyond a narrow ledge
clamps the advance instead of poisoning the verdict.
Leg B drops from the advanced pose by ``landing_drop`` and classifies what it
finds by the same rule the first drop is classified by. The drop is the depth
the primary landing was found at and NOT the whole ``step_height``, which is
the whole of the test: a ledge continues at the height the character perched
on its edge, so a probe of that depth finds it, while a ball's flank has
nothing but the floor the character is already standing on below it, and the
floor is further down than the foothold was. Casting the full ``step_height``
would find that floor and call every foothold a step.
``_GROUND_SLACK`` is added so a surface that continues EXACTLY level with the
landing is penetrated rather than touched: a substepped sweep reports nothing
at a flush touch, and a flat ledge top is precisely that case.
Leg B reporting nothing REFUSES, where the primary drop treats the same answer
as ambiguous and pays a ground probe to settle it. The asymmetry is deliberate:
the primary drop's ``None`` means "either a clear drop or a flush landing", two
cases that need separating, while leg B's means "whatever is ahead is lower
than what the character just landed on", which is the refusal this helper
exists to return.
"""
ux, uy, uz = up
px, py, pz = lifted
hx, hy, hz = horizontal
hlen = math.sqrt(hx * hx + hy * hy + hz * hz)
if hlen < _SLIDE_EPS:
return False
reach = step_height * _STEP_FORWARD_TEST
if reach < _SLIDE_EPS:
return False
fx, fy, fz = hx / hlen * reach, hy / hlen * reach, hz / hlen * reach
ahead = world.sweep_body(handle, Vec3(fx, fy, fz), from_transform=(Vec3(px, py, pz), rot), skin=skin_width)
clear = reach if ahead is None else min(reach, max(0.0, float(ahead.distance)))
s = clear / reach
ax, ay, az = px + fx * s, py + fy * s, pz + fz * s
drop = landing_drop + max(skin_width * 4.0, _GROUND_SLACK)
dx, dy, dz = -ux * drop, -uy * drop, -uz * drop
hit = world.sweep_body(handle, Vec3(dx, dy, dz), from_transform=(Vec3(ax, ay, az), rot), skin=skin_width)
if hit is None:
return False
n = hit.normal
return _classify(float(n[0]) * ux + float(n[1]) * uy + float(n[2]) * uz, cos_slope) == "floor"
def _walkable_ahead_2d(
world: Physics2DWorld,
handle: BodyHandle,
lifted: tuple[float, float],
rot: float,
horizontal: tuple[float, float],
up: tuple[float, float],
step_height: float,
landing_drop: float,
skin_width: float,
cos_slope: float,
) -> bool:
"""2D sibling of :func:`_walkable_ahead_3d`; see it for the rule and the legs."""
ux, uy = up
px, py = lifted
hx, hy = horizontal
hlen = math.hypot(hx, hy)
if hlen < _SLIDE_EPS:
return False
reach = step_height * _STEP_FORWARD_TEST
if reach < _SLIDE_EPS:
return False
fx, fy = hx / hlen * reach, hy / hlen * reach
ahead = world.sweep_body(handle, Vec2(fx, fy), from_transform=(Vec2(px, py), rot), skin=skin_width)
clear = reach if ahead is None else min(reach, max(0.0, float(ahead.distance)))
s = clear / reach
ax, ay = px + fx * s, py + fy * s
drop = landing_drop + max(skin_width * 4.0, _GROUND_SLACK)
dx, dy = -ux * drop, -uy * drop
hit = world.sweep_body(handle, Vec2(dx, dy), from_transform=(Vec2(ax, ay), rot), skin=skin_width)
if hit is None:
return False
n = hit.normal
return _classify(float(n[0]) * ux + float(n[1]) * uy, cos_slope) == "floor"
[docs]
def move_and_slide(
world: PhysicsWorld,
handle: BodyHandle,
velocity: Vec3,
dt: float,
*,
up: Vec3,
slope_limit: float,
step_height: float,
skin_width: float,
max_slides: int,
push_factor: float = 1.0,
mass: float = 1.0,
) -> MoveResult:
"""Collide-and-slide a kinematic body by ``velocity * dt`` against the world.
Sweeps along the remaining motion; on each blocking contact it advances to
exactly the reported clear distance, classifies the normal against ``up``,
deflects both the remaining motion and the velocity out of the surface, and
repeats up to ``max_slides`` times. A wall hit with ``step_height > 0`` gets
one up / forward / drop step probe, which classifies what it lands on against
the same ``slope_limit``. A landing whose own normal is not walkable is
accepted only when walkable ground continues ahead of it, so a ledge a rounded
character can only perch on the edge of is a step and a foothold up the flank
of a ball is not; :func:`_step_up_3d` states the rule and its window. A final
downward probe establishes floor state without moving the body.
Args:
world: Any :class:`~simvx.core.physics.world.PhysicsWorld`.
handle: Handle of the KINEMATIC body to move.
velocity: Desired world-space velocity (``Vec3``), units/s.
dt: Timestep in seconds.
up: World up vector (``Vec3``); normalised here.
slope_limit: Maximum walkable slope, in RADIANS.
step_height: Maximum step-up height in world units (``0`` disables).
skin_width: Contact clearance requested from each sweep; also scales the
ground probe's lift and reach.
max_slides: Maximum collide-and-slide iterations.
push_factor: Dimensionless multiplier on how hard to shove what the
character walks into. Every blocking contact EXCEPT one classified as
floor, and except an immovable body, receives an impulse of
``push_factor * (mass * struck_mass / (mass + struck_mass)) *
approach_speed`` N*s along the contact normal, where the approach
speed is the component of the character's velocity INTO that surface;
standing on a body imparts nothing to it. ``1.0`` is the arrest
impulse of a perfectly inelastic collision, so the struck body leaves
at no more than the character's own approach speed; ``0.0`` imparts
nothing at all and the branch body never runs. It defaults to ``1.0``
here and at the node-level knob,
:attr:`~simvx.core.physics.nodes.CharacterBody3D.push_factor`, alike.
mass: The character's mass in kg, the dimensioned half of the impulse.
The default here is ``1.0`` rather than the 70 kg of
:attr:`~simvx.core.physics.nodes.CharacterBody3D.mass`: this function
is the raw policy, called with a stub world in tests and with explicit
numbers by anything driving it directly, so its default is the
neutral one and the node supplies a human-shaped mass instead.
Returns:
A :class:`MoveResult`, including the final pose and the blocking contacts.
"""
ux, uy, uz = _unit3(up, 0.0, 1.0, 0.0)
vx, vy, vz = float(velocity[0]), float(velocity[1]), float(velocity[2])
step = float(dt)
mx, my, mz = vx * step, vy * step, vz * step
cos_slope = math.cos(slope_limit)
pos, rot = world.body_transform(handle)
px, py, pz = float(pos[0]), float(pos[1]), float(pos[2])
on_floor = on_wall = on_ceiling = False
fnx, fny, fnz = ux, uy, uz
collisions: list[SweepHit] = []
for _ in range(max_slides):
mlen = math.sqrt(mx * mx + my * my + mz * mz)
if mlen < _SLIDE_EPS:
break
hit = world.sweep_body(handle, Vec3(mx, my, mz), from_transform=(Vec3(px, py, pz), rot), skin=skin_width)
if hit is None:
px += mx
py += my
pz += mz
break
collisions.append(hit)
frac = min(1.0, max(0.0, float(hit.distance) / mlen))
px += mx * frac
py += my * frac
pz += mz * frac
n = hit.normal
nx, ny, nz = float(n[0]), float(n[1]), float(n[2])
kind = _classify(nx * ux + ny * uy + nz * uz, cos_slope)
if push_factor > 0.0 and kind != "floor":
# Shove what was hit, before the velocity is deflected out of this
# normal and the approach speed reads zero. Floor contacts are exempt
# and an infinite-mass body takes nothing; see the module docstring.
into = -(vx * nx + vy * ny + vz * nz)
if into > 0.0:
struck = world.body_mass(hit.body)
if struck != math.inf:
j = push_factor * (mass * struck / (mass + struck)) * into
world.apply_impulse(hit.body, Vec3(-nx * j, -ny * j, -nz * j))
if kind == "floor":
on_floor = True
fnx, fny, fnz = nx, ny, nz
elif kind == "ceiling":
on_ceiling = True
else:
on_wall = True
if step_height > 0.0:
rest = 1.0 - frac
stepped = _step_up_3d(
world,
handle,
(px, py, pz),
rot,
(mx * rest, my * rest, mz * rest),
(ux, uy, uz),
step_height,
skin_width,
cos_slope,
)
if stepped is not None:
px, py, pz = stepped
break
rest = 1.0 - frac
rx, ry, rz = mx * rest, my * rest, mz * rest
dn = rx * nx + ry * ny + rz * nz
if dn < 0.0: # only remove a component INTO the surface
rx -= nx * dn
ry -= ny * dn
rz -= nz * dn
dv = vx * nx + vy * ny + vz * nz
if dv < 0.0:
vx -= nx * dv
vy -= ny * dv
vz -= nz * dv
mx, my, mz = rx, ry, rz
# Ground probe: DETECTION ONLY, it never snaps the body. The slide sweeps
# above deliberately ignore the non-opposing floor a walking character rests
# on, so floor state is established here with a short downward cast.
ground = _ground_normal_3d(world, handle, (px, py, pz), rot, (ux, uy, uz), cos_slope, skin_width)
if ground is not None:
on_floor = True
fnx, fny, fnz = ground
final = Vec3(px, py, pz)
world.set_body_transform(handle, (final, rot))
return MoveResult(
velocity=Vec3(vx, vy, vz),
on_floor=on_floor,
on_wall=on_wall,
on_ceiling=on_ceiling,
floor_normal=Vec3(fnx, fny, fnz),
position=final,
collisions=tuple(collisions),
)
def _step_up_3d(
world: PhysicsWorld,
handle: BodyHandle,
pos: tuple[float, float, float],
rot: Quat,
horizontal: tuple[float, float, float],
up: tuple[float, float, float],
step_height: float,
skin_width: float,
cos_slope: float,
) -> tuple[float, float, float] | None:
"""One up / forward / drop step probe; ``None`` when the step is not viable.
Runs its legs as :meth:`sweep_body` calls from tracked origins and returns the
pose to commit, so abandoning a leg costs nothing: the caller keeps the pose it
already had and falls back to wall-slide.
BOTH landing branches are adjudicated against ``slope_limit``, the same rule
the character applies to the ground it stands on. Without that a curved prop is
climbable by any character at all: a ball's flank presents a continuously
varying normal, so somewhere up its face the drop leg finds a foothold and the
character ratchets up something it should never grip. Body mode plays no part
in the verdict, because a low flat-topped crate is legitimately steppable
whether it is STATIC or DYNAMIC, and freezing one to STATIC for gameplay must
not make it climbable.
The two branches reach the rule differently, and deliberately:
- A drop that reports NOTHING has no normal to classify, so a ground probe at
the fully-dropped pose supplies one. That probe is also what tells a flush
landing from a gap, and it is the only cost this policy pays for the rule.
- A drop that REPORTS a contact is classified on the normal it reported,
exactly as the slide loop classifies its own contacts. Probing the reported
landing pose instead would be wrong on a substepped backend, whose drop
distance is a lower bound: measured, a landing 0.1 units below the lifted
feet reports distance 0.0, so that pose is nowhere near the surface and a
short probe under it finds nothing at all. The reported NORMAL has no such
problem; a substepped and an exact backend agree on it.
An UNWALKABLE landing is adjudicated by :func:`_walkable_ahead_3d`, a forward
validity probe, and not by how far the drop leg happened to travel. A character
with a FLAT base lands squarely on a flat top and takes the walkable fast path
above. A ROUNDED base never does: it mounts a ledge by perching on the top
EDGE, and an edge contact reports the edge's normal rather than the flat top's,
so refusing every unwalkable landing outright freezes a capsule against any
ledge it cannot clear in one frame of forward motion, which at ordinary walking
speeds is every ledge. Asking whether walkable ground CONTINUES ahead of the
landing is what separates that perch from a foothold up a sphere's flank: a
ledge continues under the character and a flank falls away. It is the same
construction Jolt's ``CharacterVirtual::WalkStairs`` uses
(``mWalkStairsStepForwardTest``).
The distance the drop reported is used only to place the committed pose, never
to decide the verdict. That matters because ``SweepHit.distance`` is a
backend-dependent lower bound, and the seam's own promise is that no
gameplay-visible discrete decision may be derived from one.
``_STEP_FORWARD_TEST`` is the reach, as a multiple of ``step_height``, and it
has a derived window rather than a tuned value.
LOWER BOUND. Take a capsule of radius ``r`` whose lower cap centre sits a
horizontal distance ``d`` outside a horizontal top edge, at the height where it
touches. The contact normal points from the edge to the cap centre, so
``dot(normal, up) = sqrt(1 - (d/r)^2)``, which classifies as floor exactly when
``d < r * sin(slope_limit)``. The gap before the probe runs is at most ``r``
(the blocking slide contact was against the vertical face, which puts the cap
centre exactly ``r`` outside it), so leg B's contact is walkable as soon as
``_STEP_FORWARD_TEST > (r / step_height) * (1 - sin(slope_limit))``. At a
45-degree limit that is 0.234 for a 0.4-radius character over a 0.5 step and
0.073 for a 10-unit-radius character over a 40-unit step.
UPPER BOUND. The reach must not be so long that the probe clears a prop
entirely and finds the floor beyond it, which would accept the flank foothold
it exists to refuse. Leg A's clamp is the primary defence: at the first foothold
the lifted capsule is still inside the prop's vertical band, so leg A blocks
short and leg B re-finds the flank.
Measured over ``{builtin, jolt} x {box, capsule} x {ball, low crate, ledge}``
in 15-degree approach steps, at both the metre-scale and the pixel-scale
scene: every multiple from 0.25 to 4.0 clears all three prop classes (no gain
on any ball, the crate mounted, the ledge mounted and walked), and 0.1 fails
the crate on Jolt and the ledge in 2D. 1.0 is taken as the value furthest
inside that window.
A character whose collider radius exceeds
``step_height * _STEP_FORWARD_TEST / (1 - sin(slope_limit))`` may fail to mount
a ledge. The engine cannot check that for itself: the seam can SET a body's
shape but not read it, so the radius is not a quantity this policy has.
"""
hx, hy, hz = horizontal
if math.sqrt(hx * hx + hy * hy + hz * hz) < _SLIDE_EPS:
return None
px, py, pz = pos
ux, uy, uz = up
lift = (ux * step_height, uy * step_height, uz * step_height)
if world.sweep_body(handle, Vec3(*lift), from_transform=(Vec3(px, py, pz), rot), skin=skin_width) is not None:
return None # not enough headroom to step up
px, py, pz = px + lift[0], py + lift[1], pz + lift[2]
if world.sweep_body(handle, Vec3(hx, hy, hz), from_transform=(Vec3(px, py, pz), rot), skin=skin_width) is not None:
return None # forward leg still blocked at step height
px, py, pz = px + hx, py + hy, pz + hz
dx, dy, dz = -ux * step_height, -uy * step_height, -uz * step_height
landed = world.sweep_body(handle, Vec3(dx, dy, dz), from_transform=(Vec3(px, py, pz), rot), skin=skin_width)
if landed is None:
# No blocking contact means the whole drop is clear, which is ALSO what a
# landing exactly `step_height` below looks like: the surface is touched,
# not penetrated, so a sweep reports nothing. Committing the full drop is
# therefore safe, but only a ground probe can tell a flush landing from a
# genuine gap, and stepping out over a gap is not a step.
below = (px + dx, py + dy, pz + dz)
if _ground_normal_3d(world, handle, below, rot, up, cos_slope, skin_width) is None:
return None
return below
n = landed.normal
frac = min(1.0, max(0.0, float(landed.distance) / step_height))
if _classify(float(n[0]) * ux + float(n[1]) * uy + float(n[2]) * uz, cos_slope) == "floor":
return px + dx * frac, py + dy * frac, pz + dz * frac # fast path: no extra sweep
if _walkable_ahead_3d(
world, handle, (px, py, pz), rot, horizontal, up, step_height, step_height * frac, skin_width, cos_slope
):
return px + dx * frac, py + dy * frac, pz + dz * frac
return None # an unwalkable landing with nothing walkable beyond it: not a step
[docs]
def move_and_slide_2d(
world: Physics2DWorld,
handle: BodyHandle,
velocity: Vec2,
dt: float,
*,
up: Vec2,
slope_limit: float,
step_height: float,
skin_width: float,
max_slides: int,
push_factor: float = 1.0,
mass: float = 1.0,
) -> MoveResult2D:
"""Collide-and-slide a kinematic 2D body by ``velocity * dt``.
2D sibling of :func:`move_and_slide`; see it for the policy, the ground-probe
rule and the argument semantics, including what ``push_factor`` means.
Rotation is a scalar in radians and is carried through unchanged.
"""
ux, uy = _unit2(up, 0.0, 1.0)
vx, vy = float(velocity[0]), float(velocity[1])
step = float(dt)
mx, my = vx * step, vy * step
cos_slope = math.cos(slope_limit)
pos, rot = world.body_transform(handle)
px, py = float(pos[0]), float(pos[1])
on_floor = on_wall = on_ceiling = False
fnx, fny = ux, uy
collisions: list[SweepHit2D] = []
for _ in range(max_slides):
mlen = math.hypot(mx, my)
if mlen < _SLIDE_EPS:
break
hit = world.sweep_body(handle, Vec2(mx, my), from_transform=(Vec2(px, py), rot), skin=skin_width)
if hit is None:
px += mx
py += my
break
collisions.append(hit)
frac = min(1.0, max(0.0, float(hit.distance) / mlen))
px += mx * frac
py += my * frac
n = hit.normal
nx, ny = float(n[0]), float(n[1])
kind = _classify(nx * ux + ny * uy, cos_slope)
if push_factor > 0.0 and kind != "floor":
# See move_and_slide: applied here rather than over the collected
# hits, to everything except the floor, and skipped outright for an
# infinite-mass body, which has no reduced mass to share.
into = -(vx * nx + vy * ny)
if into > 0.0:
struck = world.body_mass(hit.body)
if struck != math.inf:
j = push_factor * (mass * struck / (mass + struck)) * into
world.apply_impulse(hit.body, Vec2(-nx * j, -ny * j))
if kind == "floor":
on_floor = True
fnx, fny = nx, ny
elif kind == "ceiling":
on_ceiling = True
else:
on_wall = True
if step_height > 0.0:
rest = 1.0 - frac
stepped = _step_up_2d(
world, handle, (px, py), rot, (mx * rest, my * rest), (ux, uy), step_height, skin_width, cos_slope
)
if stepped is not None:
px, py = stepped
break
rest = 1.0 - frac
rx, ry = mx * rest, my * rest
dn = rx * nx + ry * ny
if dn < 0.0: # only remove a component INTO the surface
rx -= nx * dn
ry -= ny * dn
dv = vx * nx + vy * ny
if dv < 0.0:
vx -= nx * dv
vy -= ny * dv
mx, my = rx, ry
# Detection-only ground probe; see _ground_normal_3d for why the
# classification is on the normal alone and why the lift must not be tuned away.
ground = _ground_normal_2d(world, handle, (px, py), rot, (ux, uy), cos_slope, skin_width)
if ground is not None:
on_floor = True
fnx, fny = ground
final = Vec2(px, py)
world.set_body_transform(handle, (final, rot))
return MoveResult2D(
velocity=Vec2(vx, vy),
on_floor=on_floor,
on_wall=on_wall,
on_ceiling=on_ceiling,
floor_normal=Vec2(fnx, fny),
position=final,
collisions=tuple(collisions),
)
def _step_up_2d(
world: Physics2DWorld,
handle: BodyHandle,
pos: tuple[float, float],
rot: float,
horizontal: tuple[float, float],
up: tuple[float, float],
step_height: float,
skin_width: float,
cos_slope: float,
) -> tuple[float, float] | None:
"""2D sibling of :func:`_step_up_3d`."""
hx, hy = horizontal
if math.hypot(hx, hy) < _SLIDE_EPS:
return None
px, py = pos
ux, uy = up
lift = (ux * step_height, uy * step_height)
if world.sweep_body(handle, Vec2(*lift), from_transform=(Vec2(px, py), rot), skin=skin_width) is not None:
return None # not enough headroom to step up
px, py = px + lift[0], py + lift[1]
if world.sweep_body(handle, Vec2(hx, hy), from_transform=(Vec2(px, py), rot), skin=skin_width) is not None:
return None # forward leg still blocked at step height
px, py = px + hx, py + hy
dx, dy = -ux * step_height, -uy * step_height
landed = world.sweep_body(handle, Vec2(dx, dy), from_transform=(Vec2(px, py), rot), skin=skin_width)
if landed is None:
# See _step_up_3d: an unreported drop is either a fully clear one or a
# landing exactly step_height below, and only a ground probe separates a
# flush landing from a gap.
below = (px + dx, py + dy)
if _ground_normal_2d(world, handle, below, rot, up, cos_slope, skin_width) is None:
return None
return below
n = landed.normal
frac = min(1.0, max(0.0, float(landed.distance) / step_height))
if _classify(float(n[0]) * ux + float(n[1]) * uy, cos_slope) == "floor":
return px + dx * frac, py + dy * frac # fast path: no extra sweep
if _walkable_ahead_2d(
world, handle, (px, py), rot, horizontal, up, step_height, step_height * frac, skin_width, cos_slope
):
return px + dx * frac, py + dy * frac
return None # an unwalkable landing with nothing walkable beyond it: not a step