From 741d9615a37e5fb7d80cdce5d609e4226aeb800c Mon Sep 17 00:00:00 2001 From: Nicolas Maman Date: Sun, 4 Oct 2026 13:30:14 -0300 Subject: [PATCH] physics_world: mover queries hand their visitors planes and segments of their own collide_mover passed its visitor the per-worker mover-plane scratch, and body_collide_mover wrote into the same block; cast_mover's proxy pointed at the per-worker segment scratch, which body_time_of_impact_mover and any nested cast_mover rewrote. A visitor or filter that queried the mover again -- a character checking a moving platform mid-query -- found the outer query's planes (or the capsule it was sweeping) silently replaced. The reference keeps them on each call's stack. Now: - collide_mover gives its visitor a planes block of its own, allocated for the query and freed after it; - cast_mover keeps the segment in its context and points the proxy there; - body_collide_mover writes each plane straight into the caller's result. The shared plane scratch is gone (test_physics_world's permanent allocations drop by one). test_mover_world's test_nested_mover_queries: a visitor asking a platform body for its plane mid-query keeps its own plane (on main it reads the platform's). Found by an audit of the mover and the height field against Box3D e77352c, which found them otherwise faithful. --- aephysics/physics_world/module.ae | 40 +++++++++++----------- tests/test_mover_world.ae | 55 +++++++++++++++++++++++++++++++ tests/test_physics_world.ae | 4 +-- 3 files changed, 77 insertions(+), 22 deletions(-) diff --git a/aephysics/physics_world/module.ae b/aephysics/physics_world/module.ae index f1c96b3..b312271 100644 --- a/aephysics/physics_world/module.ae +++ b/aephysics/physics_world/module.ae @@ -475,24 +475,17 @@ struct WorldMoverContext { mover: Capsule origin: Vec3 user_context: ptr + planes: ptr // PlaneResult[MOVER_PLANE_CAPACITY], this query's own } const MOVER_PLANE_CAPACITY = 64 -var g_mover_planes: ptr = null // PlaneResult[MOVER_PLANE_CAPACITY] -mover_plane_block() -> ptr { return native.scratch(g_mover_planes, MOVER_PLANE_CAPACITY * sizeof(PlaneResult)) } - -mover_planes() -> PlaneResult[] { - if g_mover_planes == null { g_mover_planes = basics.alloc(native.MAX_WORKERS * (MOVER_PLANE_CAPACITY * sizeof(PlaneResult))) } - return mover_plane_block() as PlaneResult[] -} - mover_collide_visitor(proxy_id: int, user_data: long, context: ptr) -> bool { mc = context as *WorldMoverContext w = mc.w s = dynamics.world_shape_at(w, user_data as int) if shape.should_query_collide(s.filter, mc.filter) == false { return true } t = relative_transform(w, s.body_id, mc.origin) - planes = mover_planes() + planes = mc.planes as PlaneResult[] count = shape.collide_mover(planes, MOVER_PLANE_CAPACITY, &s.geometry, t, mc.mover) if count > 0 { visitor = mc.visitor @@ -509,12 +502,17 @@ collide_mover(id: WorldId, origin: Vec3, mover: Capsule, filter: QueryFilter, vi if w == null { return } r = math.vec3(mover.radius, mover.radius, mover.radius) box = math.offset_aabb(AABB { lower: math.sub(math.min_vec3(mover.center1, mover.center2), r), upper: math.add(math.max_vec3(mover.center1, mover.center2), r) }, origin) - mc = WorldMoverContext { w: w, visitor: visitor, filter: filter, mover: mover, origin: origin, user_context: context } + // The planes a visitor reads are this query's own block, as the + // reference's are on its stack: a visitor that queries the mover again + // (a character checking a platform) does not overwrite them. + planes_block = basics.alloc(MOVER_PLANE_CAPACITY * sizeof(PlaneResult)) + mc = WorldMoverContext { w: w, visitor: visitor, filter: filter, mover: mover, origin: origin, user_context: context, planes: planes_block } i = 0 while i < BODY_KIND_COUNT { dynamic_tree.tree_query(broad_phase.broad_phase_tree(&w.broad_phase, i), box, filter.mask_bits, false, mover_collide_visitor, (&mc) as ptr) i = i + 1 } + basics.free_bytes(planes_block, MOVER_PLANE_CAPACITY * sizeof(PlaneResult)) } struct WorldCastContext { @@ -657,6 +655,8 @@ struct WorldMoverCastContext { translation: Vec3 can_encroach: bool user_context: ptr + point1: Vec3 // the mover's segment, which proxy points at: this cast's own + point2: Vec3 } mover_cast_visitor(input: *BoxCastInput, proxy_id: int, user_data: long, context: ptr) -> float { @@ -684,14 +684,14 @@ mover_points() -> ptr { return native.scratch(g_mover_points, 2 * sizeof(Vec3)) cast_mover(id: WorldId, origin: Vec3, mover: Capsule, translation: Vec3, filter: QueryFilter, has_filter: bool, filter_fn: fn(ShapeId, ptr) -> bool, context: ptr) -> float { w = world_of(id) if w == null { return 1.0 } - if g_mover_points == null { g_mover_points = basics.alloc(native.MAX_WORKERS * (2 * sizeof(Vec3))) } - points = mover_points() as Vec3[] - points[0] = mover.center1 - points[1] = mover.center2 + // The segment lives in this cast's context, as the reference's on its + // stack: a filter that casts again (or asks a body's time of impact) does + // not move it under this cast. mc = WorldMoverCastContext { w: w, filter_fn: filter_fn, has_filter: has_filter, filter: filter, fraction: 1.0, origin: origin, - proxy: distance.shape_proxy(mover_points(), 2, mover.radius), translation: translation, - can_encroach: mover.radius > 0.0, user_context: context } - input = BoxCastInput { box: math.offset_aabb(math.make_aabb(mover_points() as float[], 2, mover.radius), origin), translation: translation, max_fraction: 1.0 } + proxy: distance.shape_proxy(null, 2, mover.radius), translation: translation, + can_encroach: mover.radius > 0.0, user_context: context, point1: mover.center1, point2: mover.center2 } + mc.proxy.points = (&mc.point1) as ptr + input = BoxCastInput { box: math.offset_aabb(math.make_aabb(mc.proxy.points as float[], 2, mover.radius), origin), translation: translation, max_fraction: 1.0 } i = 0 while i < BODY_KIND_COUNT { dynamic_tree.tree_box_cast(broad_phase.broad_phase_tree(&w.broad_phase, i), &input, filter.mask_bits, false, mover_cast_visitor, (&mc) as ptr) @@ -797,15 +797,15 @@ body_collide_mover(id: BodyId, planes: BodyPlaneResult[], capacity: int, origin: body = dynamics.get_body_full_id(w, id) t = Transform { p: math.sub(body_transform.p, origin), q: body_transform.q } count = 0 - one = mover_planes() shape_id = body.head_shape_id while shape_id != NULL_INDEX { s = dynamics.world_shape_at(w, shape_id) shape_id = s.next_shape_id if shape.should_query_collide(s.filter, filter) == false { continue } if shape.is_convex(s.geometry.kind) == false { continue } - if shape.collide_mover(one, 1, &s.geometry, t, mover) > 0 { - planes[count] = BodyPlaneResult { shape_id: dynamics.make_shape_id(w, s), result: one[0] } + // Straight into the caller's result, no shared scratch to overwrite. + if shape.collide_mover((&planes[count].result) as ptr as PlaneResult[], 1, &s.geometry, t, mover) > 0 { + planes[count].shape_id = dynamics.make_shape_id(w, s) count = count + 1 if count == capacity { return count } } diff --git a/tests/test_mover_world.ae b/tests/test_mover_world.ae index 763ddcf..6495417 100644 --- a/tests/test_mover_world.ae +++ b/tests/test_mover_world.ae @@ -96,6 +96,60 @@ capture_planes(shape_id: ShapeId, planes: PlaneResult[], count: int, context: pt return true } +// A visitor that asks a platform body for its plane mid-query, as a +// character controller does, and checks its own planes did not move. +struct NestedQuery { + platform: BodyId + before_normal: Vec3 + before_separation: float + after_normal: Vec3 + after_separation: float + platform_planes: int + visits: int +} + +nested_visitor(shape_id: ShapeId, planes: PlaneResult[], count: int, context: ptr) -> bool { + nq = context as *NestedQuery + nq.visits = nq.visits + 1 + nq.before_normal = planes[0].plane.normal + nq.before_separation = planes[0].plane.offset + found_block = calloc(4, sizeof(BodyPlaneResult)) + found = found_block as BodyPlaneResult[] + nq.platform_planes = physics_world.body_collide_mover(nq.platform, found, 4, math.vec3_zero(), capsule_at(5.0, 1.15, 1.35, 0.2), + shape.default_query_filter(), dynamics.body_get_transform(nq.platform)) + free(found_block) + nq.after_normal = planes[0].plane.normal + nq.after_separation = planes[0].plane.offset + return false +} + +test_nested_mover_queries() { + wdef = dynamics.default_world_def() + world_id = dynamics.create_world(&wdef) + gdef = dynamics.default_body_def() + gdef.position = math.vec3(0.0, 0.0 - 0.5, 0.0) + ground = dynamics.create_body(world_id, &gdef) + slab = hull.make_box_hull(4.0, 0.5, 4.0) + sdef = dynamics.default_shape_def() + dynamics.create_hull_shape(ground, &sdef, slab) + pdef = dynamics.default_body_def() + pdef.position = math.vec3(5.0, 1.0, 0.0) + pdef.rotation = math.make_quat_from_axis_angle(math.vec3(0.0, 0.0, 1.0), 0.4) + platform = dynamics.create_body(world_id, &pdef) + plank = hull.make_box_hull(1.0, 0.1, 1.0) + dynamics.create_hull_shape(platform, &sdef, plank) + physics_world.step(world_id, 1.0 / 60.0, 1) + nq = NestedQuery { platform: platform, before_normal: math.vec3_zero(), before_separation: 0.0, after_normal: math.vec3_zero(), + after_separation: 0.0, platform_planes: 0, visits: 0 } + physics_world.collide_mover(world_id, math.vec3_zero(), capsule_at(0.0, 0.15, 0.35, 0.2), shape.default_query_filter(), nested_visitor, (&nq) as ptr) + ensure("nested: the ground visited, the platform found (${nq.visits}, ${nq.platform_planes})", nq.visits == 1 && nq.platform_planes == 1) + ensure("nested: the ground's plane unchanged by the platform query", + nq.after_normal.x == nq.before_normal.x && nq.after_normal.y == nq.before_normal.y && nq.after_separation == nq.before_separation) + dynamics.destroy_world(world_id) + hull.destroy_hull(slab) + hull.destroy_hull(plank) +} + capsule_at(x: float, y0: float, y1: float, radius: float) -> Capsule { return Capsule { center1: math.vec3(x, y0, 0.0), center2: math.vec3(x, y1, 0.0), radius: radius } } @@ -315,6 +369,7 @@ main() { test_body_material_indices() test_body_skips_mesh_and_compound() test_world_mesh_material_clamp() + test_nested_mover_queries() println("mover_world: ${checks} checks") if failures == 0 { println("mover_world: all checks passed") diff --git a/tests/test_physics_world.ae b/tests/test_physics_world.ae index 8b149f6..e9ca22c 100644 --- a/tests/test_physics_world.ae +++ b/tests/test_physics_world.ae @@ -904,8 +904,8 @@ main() { test_body_queries() test_world_queries() test_determinism() - // The scratch stays allocated: the worlds, the masses, the sweep and query buffers, the solvers' dummies (the modules' scratch is reserved before the count). - ensure("every other counted allocation was freed (${basics.alloc_count() - before})", basics.alloc_count() == before + 9) + // The scratch stays allocated: the worlds, the masses, the sweep and query buffers, the solvers' dummies (the modules' scratch is reserved before the count). The mover's planes are each query's own now, freed with it. + ensure("every other counted allocation was freed (${basics.alloc_count() - before})", basics.alloc_count() == before + 8) println("physics_world: ${checks} checks") if failures == 0 { println("physics_world: all checks passed")