This file tracks the next feature and robustness targets for Fx2D. Delivered
targets are removed; see git history for what already landed (shapes unified
under vertices[] + skin_radius, capsules/edges/rounded variants, speculative-
contact CCD, broad-phase hardening, the test suite, joint-control examples,
the FxAngleWrap precision fix, contacts/events/sensors, the YAML inertia
ordering fix, the adversarial scene suite, keyboard/mouse input, entity groups
with intra-group collision filtering, the chain collider with its one-sided,
ghost-vertex contact handling, the solver perf pass — per-substep contact
caching plus the measured 14x4 substep/velocity-pass default, ~2.5x per step
combined — and the elasticity default dropping to 0.1).
The numbered items below are the reference detail; this section is the pickup order. The
working practice that produced everything delivered so far: measure before changing (the
benchmark is scripts/bench.cpp, the profiler is FX2D_PROFILE), let the full 15-suite run
judge physics changes — the adversarial suite has rejected wrong configurations more than
once — and reproduce CI (format + Release + Debug/ASan with -Werror) before pushing.
entity_at_point() for
click-dragging and improves every demo.Housekeeping, whenever convenient: the Debug/ASan CI job creeps as suites grow — marking the slingshot suite slow is the lever; and the bucket spawn constants exist in both the example and the adversarial test, which cannot share code, so change them in step.
Chain / polyline colliders — delivered.
FxShapeType::Chain, built with FxShape::make_chain() or the YAML chain: key, is an open
polyline of at least 3 points authored as one entity. It resolves to the deepest contact any
of its segments makes, each handed to the existing edge routines, so it adds no new geometry.
Covered by tests/test_chain.cpp and documented in scene_yml.md.
It inherits the edge limitations as expected: chain-vs-chain and chain-vs-edge produce no contacts, and chains are skipped by speculative-contact CCD, so a fast enough body tunnels. Lifting that is item 3’s time-of-impact work, not a chain problem.
Spatial query APIs — delivered.
Both slices have landed. Slice (a): buffered contacts, begin/end contact events and sensors
(FxScene::contacts(), begin_contact_events(), end_contact_events(),
FxEntity::is_sensor), documented in contacts_and_events.md.
Slice (b): ray casts, overlap and point queries (raycast(), raycast_all(),
overlap_circle/box/point/shape(), entity_at_point()), documented in
queries.md and covered by tests/test_queries.cpp.
Overlap runs the same narrow phase the simulation does, so a query and a contact cannot disagree, with a containment check layered on because the solver reports nothing when one shape lies wholly inside another. Rays are tested against shape boundaries directly, since the narrow phase refuses zero-thickness segments.
Possible follow-ups, none blocking:
step(), so using it would
answer from stale boxes between steps; doing this properly means syncing on demand.overlap_shape is static. A swept version — move this shape along this
vector, report what it would hit and when — is what character controllers want, and shares
the time-of-impact machinery item 3 wants for CCD.Make the collision pipeline faster and continuous. Two halves of the same pipeline: the broad phase decides which pairs get looked at; CCD is what actually prevents fast bodies passing through thin geometry. Division of labour matters — per-substep broad-phase queries never prevented tunneling (they sample AABBs at substep start; a fast body can cross a thin wall within one substep), so hoisting the query out of the substep loop costs no protection.
Broad-phase efficiency. The tree itself is sound (SAH-guided dynamic
AABB tree, fat boxes, dual-tree pair descent), but it is driven wastefully:
get_broad_phase_pairs() runs per substep (src/Scene.cpp), so every
frame pays N tree syncs + N full pair queries.
combine(aabb, aabb + velocity * dt_full)): any pair that can touch
during any substep already overlaps in swept-box space at step start, so
the once-per-step list is a superset of what per-substep queries find.
Narrow phase still runs per substep on that list. The swept-box machinery
already exists for CCD bodies in Registry::get_broad_phase_pairs() —
apply it to all moving bodies with the full-step dt.FxEntity instead of
the m_entity_node_map / m_entity_idx_map lookups per entity/pair.Continuous collision. Speculative contacts are done
(FxEntity::enable_ccd, FxSolver::speculative_contact_check(), YAML
ccd: key). Remaining, to reduce tunneling further for fast movers:
examples/angry_boxes covers mouse input, the draw overlay and impact strength read from
FxScene::contacts(); examples/chain_terrain covers chain colliders and spawning entities
at runtime. Still open:
Extend mixed precision to the remaining float floors.
Both halves of the original finding are fixed, and neither required moving storage off
float32. The pose stays float; only the residual is banked in double, at the two
places that need it.
FxAngleWrap no longer floors tiny rotations (tests/test_angle_precision.cpp).FxEntity::apply_pose_correction(), which adds in
double, stores to float, and keeps what did not fit for the next correction — the same
carry trick __update_pose() already used for integration. A correction below one ulp of
the coordinate (~4.8e-7 at x = 7) used to round away entirely. Measured on an
offset-anchor revolute motor over one second: at dt = 1e-3 it managed 1.97e-6 rad
before, and 2.26 rad after, matching what it achieves at dt = 1e-2. Distance from
the origin no longer decides motor authority either.Remaining, same technique, lower value:
resolve_penetration) still writes the float pose directly.
It shifts pose and prev_pose together so the correction registers no velocity, so it
needs the delta that actually landed — which is exactly what apply_pose_correction()
returns. Penetration corrections are usually far above one ulp, so this matters only for
deep stacks a long way from the origin.Build on the input layer.
Delivered. FxScene::input() exposes keyboard and mouse through FxInput
(include/Fx2D/Input.h), which knows nothing about raylib, so the same gameplay code
compiles windowed and headless. FxRylbRenderer polls once per rendered frame and
yields to ImGui when a panel has focus; a headless scene reports available() == false
until user code injects state through the same producer API, which is the event-trigger
path for scripted demos and RL agents. Documented in input.md and covered by
tests/test_input.cpp. Mouse position is reported in scene units so picking needs no
conversion.
What could follow, none of it blocking:
FxGamepadButton enum plus
axis state, filled from raylib’s gamepad API in the renderer.input.md documents the latch pattern that works around it. If
that proves awkward in a real game, feed input per step instead of per frame.
A worked playable example now exists: examples/angry_boxes is a mouse-driven slingshot
that drags a ball back and topples a tower, with the mechanic itself covered headlessly by
tests/test_slingshot.cpp, which injects mouse state instead of a cursor. The renderer also
gained set_draw_callback() for overlays a game needs but the scene does not own — the
slingshot band, the trajectory preview and the score are all drawn through it.Opt-in multithreading, only where A/B testing shows it wins.
The engine is single-threaded today, deliberately. It previously ran the entity
integration and velocity-derivation loops under std::execution::par, and that was
measured to be slower at every body count tested — 10, 50, 200, 400, 800, 1600
and 3000, against a registry cap of 4096 — while burning up to 32x the CPU:
| bodies | par | seq | speedup | CPU multiplier |
|---|---|---|---|---|
| 10 | 0.71 ms/step | 0.22 | 3.2x | 32x |
| 50 | 4.17 | 1.35 | 3.1x | 23x |
| 200 | 10.18 | 5.96 | 1.7x | 8x |
| 800 | 22.63 | 19.86 | 1.14x | 3x |
| 3000 | 59.27 | 48.61 | 1.22x | 2.4x |
There was no crossover: sequential won across the whole supported range. The cause is
structural — the work is memory-bound over shared_ptr-indirected AoS entities, and it
was dispatched once per substep, so 11 thread hand-offs per frame cost more than the
arithmetic they saved. The parallel policies have been removed (src/Scene.cpp).
So the target is no longer “parallelize the solver”. It is opt-in, user-controllable threading in the few places that can actually pay for it, each justified by an A/B measurement before it lands. Concretely:
FxScene) rather than baked into the step.
A batched RL workload wants each sim single-threaded; a single large interactive
scene may want the opposite. Only the caller knows which.collision_check is independent
pure geometry against const entity state, and unlike the entity loops it does real
compute per item, so it has a plausible shot at beating dispatch overhead. Parallel
over broad_phase_pairs with per-thread contact buffers concatenated in pair
order, never completion order. The shared-state bits currently inside that loop
(wake_if_disturbed, the step-contact buffer insert, warm-start cache lookup) hoist
into a cheap serial pass afterwards. Prove it on a many-contact scene before adopting.shared_ptr.m_contact_cache (write-back stays serial),
concatenation in pair order. A nondeterministic sim would undermine the RL story.Push past the envelope the adversarial scenes established.
The scenes have landed (tests/test_adversarial.cpp): tall stacks, pyramids, mass
ratios, thin slivers, a restitution chain, spinning bodies, a topple test and a
kinematic platform. Thresholds in them are measured rather than aspirational.
The solver passed everything thrown at it. No correctness bug was found. Columns up to 15 boxes and a 5-wide pyramid hold at the default configuration; 20 holds at 22 substeps. The default became 14 substeps x 4 velocity passes after a measured study: fastest configuration passing the full quality suite, 26-33% cheaper per step than the old 11x8. Raising substeps monotonically reduces sink, so the knob users reach for works. 10:1 mass ratios are near-exact. Slivers down to 0.02 thick rest without jitter. Newton’s cradle transfers momentum and leaves the middle balls in place. A box rides a velocity-driven kinematic platform. Mechanical energy never rises on an inelastic floor, at any spin rate tested up to 100 rad/s.
Two behaviours were initially mistaken for bugs, recorded so the mistake is not repeated. A box spinning at 100 rad/s vaults metres into the air — legitimate, since it holds ~838 J of rotational energy and lifting 1 kg by 7 m costs 70 J; measured energy gain is exactly zero. It then leaves the platform sideways and lands on the scene floor, which looked like tunneling but is not: it was at x = 0.015, far off a platform spanning x in [5, 35]. Likewise a 100 kg box dropped on a five-box column scatters it flat, which looked like interpenetration but is a plain topple — pairwise overlap checks confirm every body stays separated. Both lessons are now enforced as tests: assert conserved energy rather than height, and assert overlap rather than final height.
What is genuinely open:
FxNamedRegistry: Item
'base_link_Anchor' already exists., printed by the joint tests) first, since chain
tests depend on joint constraints registering the way the author expects.One open finding from the bucket-fill scene (150-200 balls raining into a floating container): the walls and bucket bottom always hold, but 1-2 balls per 200 end up below the 0.8-thick catch floor, and doubling substeps does not remove it – so it is not the substep-limited penetration mode. Suspected corner or squeeze ejection under pile load; the test pins the measured envelope at <= 3 until the mechanism is found.
The suite is marked slow and skipped when FX2D_SKIP_SLOW_TESTS=1, which CI sets for
its Debug/sanitizer job only. Release runs it on every push.
entity_at_point() for click-dragging; every editor and demo wants it.FxChain is a builder spec, not a
runtime type. A polyline plus a mode. Static mode already exists (the chain collider);
dynamic mode emits an entity group of capsule links joined by revolute joints along the
same polyline. The spec carries the repeatable link properties (shape, mass, inertia
explicit or computed, friction, elasticity — default it to something inert), the joint
parameters (compliance or stiffness, optional angle limits, optional motor), and per-end
anchoring: pinned to the world, attached to a named entity, or free. A chains: YAML
section maps onto the same spec. Naming follows the group scheme: <name>_<i> links,
<name>_j<i> joints, constraints <joint>_<Type>.Prerequisites all landed: groups give membership, bulk delete and intra-group collision filtering; the constraint-naming fix makes many joints on one rig safe; a distance joint (item 9) is the natural first link type before capsules-plus-revolutes.
Expectations to hold the tests to: joints are maximal-coordinate, so a loaded rope will stretch — compliance is the dial, and the tests should pin measured stretch at a chosen compliance rather than assert zero. This is where item 5’s float-precision finding was first stumbled on, so surprises are likely and finding them is the point. Exit: a bridge demo with balls dropped on it, and chains-under-tension in the adversarial suite.