physics: Jolt's floor sized for what moves - pairs 4096, contacts 2048 and a 4 MB temp heap by default (phys_open_sized for more), and a body past max_bodies or a step past the pairs or contacts is Mem.over, never a quiet -1; world.ludic split from shapes.ludic

An empty world at the game's size held 51839 KB of Jolt's heap (200000 bodies, 65536 pairs, 20480
contacts, a 32 MB temp heap), the jolt= floor under every walk. Pairs and contacts come from what
moves - drops, boats, a few walkers - and a valley's still things make none. Now 11743 KB at 200000
bodies and 8461 KB at the 98304 the game opens for (jolt_floor_test holds it under 16 MB). The temp heap
still falls back to malloc for a step that wants more.

lib/macos-arm64 rebuilt; lib/windows-x64 needs native/build.sh on the PC (jph_world_new's new sizes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-29 16:18:42 +03:00
parent 031fcad641
commit b4af12025d
11 changed files with 95 additions and 52 deletions

View file

@ -16,7 +16,10 @@ export property PhysPose {
function ph_add(physics_st: mut PhysicsState, shape: int, x: float, y: float, z: float, yaw: float, motion: int, layer: int, mass: float) -> int {
let s = ph_shape(physics_st, shape)
if physics_st.ph_world == null or s == null { return -1 }
return jph_body_add(physics_st.ph_world, s, x, y, z, yaw, motion, layer, mass, 0.6, 0.1)
let id = jph_body_add(physics_st.ph_world, s, x, y, z, yaw, motion, layer, mass, 0.6, 0.1)
# 25.5a: a world full to the bodies it was opened for is a failure, never a thing quietly not there
if id < 0 and jph_world_bodies(physics_st.ph_world) >= physics_st.ph_max { Mem.over("a physics body past phys_open's max_bodies") }
return id
}
# the ground: a heightfield or a mesh at the world's origin

View file

@ -8,6 +8,7 @@ import "state.ludic"
import "fact_pool.ludic"
import "shape_ids.ludic"
import "owned.ludic"
import "world.ludic"
import "shapes.ludic"
import "bodies.ludic"
import "queries.ludic"

Binary file not shown.

View file

@ -1,6 +1,6 @@
# native.ludic - the shim's symbols (native/shim/jph_shim.cpp). A world and a shape are handles
# the package keeps; a body is an int id. None of this is exported.
extern function jph_world_new(max_bodies: int, threads: int) -> pointer = "jph_world_new"
extern function jph_world_new(max_bodies: int, threads: int, max_pairs: int, max_contacts: int, temp_kb: int) -> pointer = "jph_world_new"
extern function jph_world_free(w: pointer) -> void = "jph_world_free"
extern function jph_world_gravity(w: pointer, y: float) -> void = "jph_world_gravity"
extern function jph_world_step(w: pointer, dt: float, sub: int) -> int = "jph_world_step"

View file

@ -93,11 +93,11 @@ public:
struct World {
// pairs and contacts are for what MOVES: a valley of sixty thousand still trunks makes none
// between them, so these are Jolt's own sizes, not the body count's; the temporary memory falls
// back to the heap rather than aborting a step
World(int max_bodies, int threads)
: temp(32 * 1024 * 1024), jobs(cMaxPhysicsJobs, cMaxPhysicsBarriers, threads) {
sys.Init(max_bodies, 0, 65536, 20480, bpl, ovb, olp);
// between them, so they are sized for the moving things, not the body count; the temporary memory
// falls back to the heap rather than aborting a step. All four are the package's (phys_open)
World(int max_bodies, int threads, int max_pairs, int max_contacts, int temp_kb)
: temp(size_t(temp_kb) * 1024), jobs(cMaxPhysicsJobs, cMaxPhysicsBarriers, threads) {
sys.Init(max_bodies, 0, max_pairs, max_contacts, bpl, ovb, olp);
sys.SetContactListener(&contacts);
}
TempAllocatorImplWithMallocFallback temp;

View file

@ -1,13 +1,14 @@
// jph_world.inl - a physics world: made, stepped, emptied of its contacts, freed
// a world for up to max_bodies; threads 0 means the machine's cores less one, at most four
JPH_SHIM void *jph_world_new(int max_bodies, int threads) {
// a world for up to max_bodies, max_pairs touching pairs and max_contacts contact constraints, with a
// temp heap of temp_kb; threads 0 means the machine's cores less one, at most four
JPH_SHIM void *jph_world_new(int max_bodies, int threads, int max_pairs, int max_contacts, int temp_kb) {
if (jph_init() != 0) return nullptr;
if (threads <= 0) {
int hw = int(std::thread::hardware_concurrency()) - 1;
threads = hw < 1 ? 1 : (hw > 4 ? 4 : hw);
}
World *w = new World(max_bodies, threads);
World *w = new World(max_bodies, threads, max_pairs, max_contacts, temp_kb);
w->sys.SetGravity(Vec3(0.0f, -9.81f, 0.0f));
return w;
}

View file

@ -1,43 +1,5 @@
# shapes.ludic - the world opened and closed, and shapes: made in C, kept here, named by an int.
# A shape may be used by any number of bodies; phys_close frees them all.
# a world for up to max_bodies (threads 0: the cores less one, at most four); false if C refused
export function phys_open(physics_st: mut PhysicsState, max_bodies: int, threads: int) -> bool {
phys_close(physics_st)
ph_buffers()
physics_st.ph_world = jph_world_new(max_bodies, threads)
ph_own_reset(physics_st, max_bodies)
physics_st.ph_gen += 1
physics_st.ph_acc = 0.0
physics_st.ph_steps = 0
return physics_st.ph_world != null
}
export function phys_is_open(physics_st: PhysicsState) -> bool { return physics_st.ph_world != null }
# which world this is: a body, shape or walker id kept from an older one means nothing now
export function phys_generation(physics_st: PhysicsState) -> int { return physics_st.ph_gen }
# everything let go: the world, every body in it and every shape (a world swap, a quit); the lists
# that held them are emptied and kept, since nothing is given back
export function phys_close(physics_st: mut PhysicsState) -> void {
if physics_st.ph_walkers != null {
for i in 0 .. len(physics_st.ph_walkers) {
if physics_st.ph_walkers[i] != null { jph_char_free(physics_st.ph_walkers[i]) }
}
}
if physics_st.ph_walkers != null { List.clear(physics_st.ph_walkers) }
if physics_st.ph_world != null { jph_world_free(physics_st.ph_world) }
physics_st.ph_world = null
if physics_st.ph_shapes != null {
for i in 0 .. len(physics_st.ph_shapes) {
if physics_st.ph_shapes[i] != null { jph_shape_free(physics_st.ph_shapes[i]) }
}
}
if physics_st.ph_shapes != null { List.clear(physics_st.ph_shapes) }
List.clear(physics_st.ph_spare)
physics_st.ph_nf = 0
if physics_st.ph_facts != null { q_clear(physics_st.ph_facts) }
}
# shapes.ludic - shapes: made in C, kept here, named by an int. A shape may be used by any number of
# bodies; phys_close (world.ludic) frees them all.
# a crate: half extents in metres
@creates(PhysShape)

View file

@ -42,6 +42,7 @@ export state PhysicsState {
ph_step: float = 0.016666668 # seconds a step
ph_sub: int = 1 # collision steps in each
ph_max_steps: int = 4 # a slow frame catches up this far and no further
ph_max: int = 0 # the bodies the world was opened for: one more is a failure, not a -1
ph_acc: float = 0.0
ph_steps: int = 0
ph_gen: int = 0 # how many worlds have been opened: an id from an older one is stale
@ -75,3 +76,9 @@ export function phys_config(physics_st: mut PhysicsState, step: float, sub: int,
physics_st.ph_hard = hard
}
function ph_facts__new() -> Queue<PhysFact> { return queue_new("physics.facts") }
# what moves, sized: touching pairs, contact constraints and the step's temp heap (KB). A valley's still
# things make no pairs between them; drops, boats and the walkers do. Past a size a step fails loudly
export const PH_PAIRS: int = 4096
export const PH_CONTACTS: int = 2048
export const PH_TEMP_KB: int = 4096

View file

@ -23,7 +23,8 @@ export function phys_step(physics_st: mut PhysicsState) -> void {
if physics_st.ph_world == null { return }
ph_buffers()
ph_buoy_all(physics_st)
jph_world_step(physics_st.ph_world, physics_st.ph_step, physics_st.ph_sub)
# 25.5a: Jolt's pairs or contacts full (PH_PAIRS, PH_CONTACTS) is a failure: past it things pass through
if jph_world_step(physics_st.ph_world, physics_st.ph_step, physics_st.ph_sub) != 0 { Mem.over("a physics step past phys_open's pairs or contacts") }
physics_st.ph_steps += 1
ph_contacts(physics_st)
}

View file

@ -0,0 +1,25 @@
# jolt_floor_test.ludic - what an empty world costs in Jolt's own heap at the game's size (plan 25):
# the floor under every body, from the body table, the broad phase, the contact caches and the temp heap
import "ludic.physics"
import "ludic.base"
program JoltFloorTest {
numbers float
function floor_at(physics_st: mut PhysicsState, bodies: int) -> long {
expect(phys_open(physics_st, 64, 1))
phys_close(physics_st)
let before = phys_heap_bytes()
expect(phys_open(physics_st, bodies, 0))
let f = phys_heap_bytes() - before
phys_close(physics_st)
return f
}
# it was 51839 KB at 200000 bodies with 65536 pairs, 20480 contacts and a 32 MB temp heap
test "an empty world at the game's size costs under 16 MB" (physics_st: mut PhysicsState) {
let a = floor_at(physics_st, 200000)
let b = floor_at(physics_st, 98304)
print(`jolt floor: {a / long(1024)} KB at 200000 bodies, {b / long(1024)} KB at 98304`)
expect(b < long(16 * 1024 * 1024))
expect(a > b)
}
}

View file

@ -0,0 +1,43 @@
# world.ludic - the world opened and closed: its sizes, and everything in it let go at once
# a world for up to max_bodies (threads 0: the cores less one, at most four); false if C refused.
# Pairs, contacts and the temp heap are sized for the things that MOVE (PH_PAIRS, ...)
export function phys_open(physics_st: mut PhysicsState, max_bodies: int, threads: int) -> bool { return phys_open_sized(physics_st, max_bodies, threads, PH_PAIRS, PH_CONTACTS, PH_TEMP_KB) }
# the same with Jolt's other three sizes given: touching pairs, contact constraints, the temp heap in KB
export function phys_open_sized(physics_st: mut PhysicsState, max_bodies: int, threads: int, max_pairs: int, max_contacts: int, temp_kb: int) -> bool {
phys_close(physics_st)
ph_buffers()
physics_st.ph_world = jph_world_new(max_bodies, threads, max_pairs, max_contacts, temp_kb)
physics_st.ph_max = max_bodies
ph_own_reset(physics_st, max_bodies)
physics_st.ph_gen += 1
physics_st.ph_acc = 0.0
physics_st.ph_steps = 0
return physics_st.ph_world != null
}
export function phys_is_open(physics_st: PhysicsState) -> bool { return physics_st.ph_world != null }
# which world this is: a body, shape or walker id kept from an older one means nothing now
export function phys_generation(physics_st: PhysicsState) -> int { return physics_st.ph_gen }
# everything let go: the world, every body in it and every shape (a world swap, a quit); the lists
# that held them are emptied and kept, since nothing is given back
export function phys_close(physics_st: mut PhysicsState) -> void {
if physics_st.ph_walkers != null {
for i in 0 .. len(physics_st.ph_walkers) {
if physics_st.ph_walkers[i] != null { jph_char_free(physics_st.ph_walkers[i]) }
}
}
if physics_st.ph_walkers != null { List.clear(physics_st.ph_walkers) }
if physics_st.ph_world != null { jph_world_free(physics_st.ph_world) }
physics_st.ph_world = null
if physics_st.ph_shapes != null {
for i in 0 .. len(physics_st.ph_shapes) {
if physics_st.ph_shapes[i] != null { jph_shape_free(physics_st.ph_shapes[i]) }
}
}
if physics_st.ph_shapes != null { List.clear(physics_st.ph_shapes) }
List.clear(physics_st.ph_spare)
physics_st.ph_nf = 0
if physics_st.ph_facts != null { q_clear(physics_st.ph_facts) }
}