Source code for simvx.core.physics.slide

"""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