Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
2805c2c
docs(briefs): add M1.1.11 plane half-space brief
guysenpai Jul 27, 2026
f23cb9d
feat(forge): add shape class taxonomy and plane payload
guysenpai Jul 27, 2026
8c598b9
feat(forge): make a half-space body static-only
guysenpai Jul 27, 2026
66f792d
feat(forge): move the typed shape refusal to the query entries
guysenpai Jul 27, 2026
ca471aa
feat(forge): add the analytic half-space kernels and their adapters
guysenpai Jul 27, 2026
bebbec9
feat(forge): put unbounded shapes outside the broadphase trees
guysenpai Jul 27, 2026
5bd0d25
feat(forge): add the half-space contact path
guysenpai Jul 27, 2026
c314f2a
chore(forge): close M1.1.11 measurements and align on the amended spec
guysenpai Jul 27, 2026
34ac5eb
fix(forge): return minus-direction at a half-space initial overlap
guysenpai Jul 28, 2026
00dbd0c
fix(forge): assert a finite plane distance at both domain gates
guysenpai Jul 28, 2026
ca00eef
fix(forge): reuse retired unbounded broadphase slots
guysenpai Jul 28, 2026
63fc7d5
docs(briefs): record the four additional E7 items
guysenpai Jul 28, 2026
acfb870
fix(forge): reserve the unbounded slot list only when it will grow
guysenpai Jul 28, 2026
80aaa2a
docs(forge): state the plane descriptor domain where the caller reads it
guysenpai Jul 28, 2026
3033b10
docs(forge): state that unbounded iteration follows the slot index
guysenpai Jul 30, 2026
bbc2b5a
docs: refresh current state and record two tooling safeguards
guysenpai Jul 30, 2026
9b7e672
docs: add the M1.1.11 tag row
guysenpai Jul 30, 2026
a18acad
docs(forge): align the public plane domain clause on the spec wording
guysenpai Jul 30, 2026
68a0cc3
docs(briefs): close M1.1.11
guysenpai Jul 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 35 additions & 4 deletions CLAUDE.md

Large diffs are not rendered by default.

97 changes: 89 additions & 8 deletions bench/forge_3d_raycast.zig
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,12 @@ const Scene = struct {
/// A 22 × 22 × 21 grid (10 164 cells, truncated to `n_bodies`) of alternating
/// spheres / boxes / capsules, spaced 3 m apart — all STATIC, which is the scene a
/// query cares about: the broadphase tree is built once and never moved.
fn buildScene(gpa: std.mem.Allocator) !Scene {
/// Build the bench scene. With `with_plane`, one static half-space `{ y <= 0 }` joins the
/// SAME scene — the M1.1.11 delta measurement. The grid starts at y = 0, so the plane is
/// genuinely in contact with its lowest layer; and an unbounded list has no box to prune
/// on, which is precisely why it is not in a tree, so EVERY ray is offered to it and the
/// exact kernel runs on every one. That is the worst case, and the honest one to report.
fn buildScene(gpa: std.mem.Allocator, with_plane: bool) !Scene {
var scene = Scene{ .bp = Broadphase.init(.{}) };
const sphere = try scene.store.createShape(gpa, .{ .sphere = .{ .radius = 0.6 } });
const box = try scene.store.createShape(gpa, .{ .box = .{ .half_extents = av3(0.5, 0.5, 0.5) } });
Expand Down Expand Up @@ -128,9 +133,63 @@ fn buildScene(gpa: std.mem.Allocator) !Scene {
}
}
}
if (with_plane) {
const plane = try scene.store.createShape(gpa, .{ .plane = .{ .normal = av3(0, 1, 0), .distance = 0 } });
const id = try scene.bm.addBody(gpa, &scene.store, .{
.shape = plane,
.body_type = .static,
.entity = .{ .index = n_bodies, .generation = 0 },
});
// The body is at the DEFAULT pose — identity rotation, origin position — so the
// world half-space IS the local one and no transport is needed. Stated rather than
// silently relied on: a posed plane needs
// `shape.halfSpace(...).transformed(rotation, position)`, which is what the test
// harness does.
_ = try scene.bp.insertUnbounded(gpa, .static, .{ .normal = Vec3r.unit_y, .distance = 0 }, id);
}
return scene;
}

/// One ray SELECTION MODE, timed on one scene — the M1.1.11 delta harness.
///
/// One function rather than three copied loops, and both scenes measured through it in the
/// same process, back to back: a delta between two separate runs would carry the machine's
/// thermal drift and a differently compiled code path, which is not the quantity asked
/// for. The only difference between the two scenes is the unbounded list.
const RayMode = enum { closest, any, all };

fn timeMode(
mode: RayMode,
scene: *Scene,
origins: []const Vec3r,
directions: []const Vec3r,
checksum: *f64,
) i64 {
var buf: [32]query.RayHit = undefined;
var best_ns: i64 = std.math.maxInt(i64);
for (0..n_reps) |_| {
const t0 = nowNs();
for (origins, directions) |o, d| {
const q = query.RayQuery{ .origin = o, .direction = d, .max_distance = 200 };
switch (mode) {
.closest => if (query.raycast(&scene.bp, &scene.bm, &scene.store, q)) |hit| {
checksum.* += @floatCast(hit.distance);
},
.any => if (query.raycastAny(&scene.bp, &scene.bm, &scene.store, q)) {
checksum.* += 1;
},
.all => {
const n = query.raycastAll(&scene.bp, &scene.bm, &scene.store, q, &buf);
if (n > 0) checksum.* += @floatCast(buf[0].distance);
},
}
}
const dt = nowNs() - t0;
if (dt < best_ns) best_ns = dt;
}
return best_ns;
}

const Measure = struct {
name: []const u8,
ns_per_ray: f64,
Expand Down Expand Up @@ -173,7 +232,7 @@ pub fn main(init: std.process.Init) !void {
std.debug.print("warning: build mode is {s}; absolute ns are only meaningful in ReleaseFast\n", .{@tagName(builtin.mode)});
}

var scene = try buildScene(gpa);
var scene = try buildScene(gpa, false);
defer scene.deinit(gpa);
std.debug.assert(scene.bm.count() == n_bodies);

Expand Down Expand Up @@ -211,7 +270,7 @@ pub fn main(init: std.process.Init) !void {
const t0 = nowNs();
for (origins, directions) |o, d| {
const q = query.RayQuery{ .origin = o, .direction = d, .max_distance = 200 };
if (try query.raycast(&scene.bp, &scene.bm, &scene.store, q)) |hit| {
if (query.raycast(&scene.bp, &scene.bm, &scene.store, q)) |hit| {
hits += 1;
checksum += @floatCast(hit.distance);
}
Expand All @@ -230,7 +289,7 @@ pub fn main(init: std.process.Init) !void {
const t0 = nowNs();
for (origins, directions) |o, d| {
const q = query.RayQuery{ .origin = o, .direction = d, .max_distance = 200 };
if (try query.raycastAny(&scene.bp, &scene.bm, &scene.store, q)) {
if (query.raycastAny(&scene.bp, &scene.bm, &scene.store, q)) {
hits += 1;
checksum += 1;
}
Expand All @@ -250,7 +309,7 @@ pub fn main(init: std.process.Init) !void {
const t0 = nowNs();
for (origins, directions) |o, d| {
const q = query.RayQuery{ .origin = o, .direction = d, .max_distance = 200 };
const n = try query.raycastAll(&scene.bp, &scene.bm, &scene.store, q, &buf);
const n = query.raycastAll(&scene.bp, &scene.bm, &scene.store, q, &buf);
if (n > 0) {
hits += 1;
checksum += @floatCast(buf[0].distance);
Expand Down Expand Up @@ -280,7 +339,7 @@ pub fn main(init: std.process.Init) !void {
const t0 = nowNs();
for (inside, directions) |o, d| {
const q = query.RayQuery{ .origin = o, .direction = d, .max_distance = 5 };
if (try query.raycast(&scene.bp, &scene.bm, &scene.store, q)) |hit| {
if (query.raycast(&scene.bp, &scene.bm, &scene.store, q)) |hit| {
hits += 1;
checksum += @floatCast(hit.distance);
}
Expand Down Expand Up @@ -339,7 +398,7 @@ pub fn main(init: std.process.Init) !void {
const t0 = nowNs();
for (swept_origins, swept_dirs) |o, d| {
const q = query.RayQuery{ .origin = o, .direction = d, .max_distance = 200 };
if (try query.raycast(&scene.bp, &scene.bm, &scene.store, q)) |hit| {
if (query.raycast(&scene.bp, &scene.bm, &scene.store, q)) |hit| {
hits += 1;
checksum += @floatCast(hit.distance);
}
Expand All @@ -358,7 +417,7 @@ pub fn main(init: std.process.Init) !void {
const t0 = nowNs();
for (order) |ix| {
const q = query.RayQuery{ .origin = swept_origins[ix], .direction = swept_dirs[ix], .max_distance = 200 };
if (try query.raycast(&scene.bp, &scene.bm, &scene.store, q)) |hit| {
if (query.raycast(&scene.bp, &scene.bm, &scene.store, q)) |hit| {
hits += 1;
checksum += @floatCast(hit.distance);
}
Expand All @@ -370,6 +429,28 @@ pub fn main(init: std.process.Init) !void {
}
}

// --- M1.1.11: the cost of one half-space in the scene, REPORTED, never gated ---
//
// The same 10 000 rays, the same code, the same process — once against the grid alone
// and once against the grid plus one static half-space in the layer's unbounded list.
// No envelope is pre-registered: it is a measurement, and what it measures is that an
// unbounded list has no box to prune on, so the exact kernel runs on EVERY ray.
{
var scene_plane = try buildScene(gpa, true);
defer scene_plane.deinit(gpa);
std.debug.assert(scene_plane.bm.count() == n_bodies + 1);
std.debug.print("\n half-space delta (10k rays, same process, best of {d}):\n", .{n_reps});
for ([_]RayMode{ .closest, .any, .all }) |mode| {
const without = timeMode(mode, &scene, origins, directions, &checksum);
const with = timeMode(mode, &scene_plane, origins, directions, &checksum);
const ns_without = @as(f64, @floatFromInt(without)) / @as(f64, n_rays);
const ns_with = @as(f64, @floatFromInt(with)) / @as(f64, n_rays);
std.debug.print(" {s: <18} {d: >9.1} ns -> {d: >9.1} ns delta {d: >8.1} ns ({d: >5.2}x)\n", .{
@tagName(mode), ns_without, ns_with, ns_with - ns_without, ns_with / ns_without,
});
}
}

const frame_ns: f64 = @as(f64, std.time.ns_per_s) / 60.0;
std.debug.print("\nforge_3d raycast bench ({s}, {d} static bodies, {d} rays x {d} reps, best rep)\n", .{ @tagName(builtin.mode), n_bodies, n_rays, n_reps });
std.debug.print(" {s:<22} {s:>12} {s:>14} {s:>16} {s:>9}\n", .{ "mode", "ns/ray", "rays/s", "rays/frame @60Hz", "hit rate" });
Expand Down
106 changes: 100 additions & 6 deletions bench/forge_3d_shapecast.zig
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,10 @@ const Scene = struct {
/// 22 × 22 × 21 grid truncated to `n_bodies`, alternating spheres / boxes / capsules
/// 3 m apart, all STATIC — which is the scene a query cares about, the tree being
/// built once and never moved.
fn buildScene(gpa: std.mem.Allocator) !Scene {
/// With `with_plane`, one static half-space `{ y <= 0 }` joins the SAME scene — the
/// M1.1.11 delta measurement (see `bench/forge_3d_raycast.zig` for the reasoning: an
/// unbounded list has no box to prune on, so every query is offered it).
fn buildScene(gpa: std.mem.Allocator, with_plane: bool) !Scene {
var scene = Scene{ .bp = Broadphase.init(.{}) };
const sphere = try scene.store.createShape(gpa, .{ .sphere = .{ .radius = 0.6 } });
const box = try scene.store.createShape(gpa, .{ .box = .{ .half_extents = av3(0.5, 0.5, 0.5) } });
Expand Down Expand Up @@ -126,6 +129,16 @@ fn buildScene(gpa: std.mem.Allocator) !Scene {
}
}
}
if (with_plane) {
const plane = try scene.store.createShape(gpa, .{ .plane = .{ .normal = av3(0, 1, 0), .distance = 0 } });
const id = try scene.bm.addBody(gpa, &scene.store, .{
.shape = plane,
.body_type = .static,
.entity = .{ .index = n_bodies, .generation = 0 },
});
// Default pose ⇒ the world half-space is the local one, no transport needed.
_ = try scene.bp.insertUnbounded(gpa, .static, .{ .normal = Vec3r.unit_y, .distance = 0 }, id);
}
return scene;
}

Expand Down Expand Up @@ -172,7 +185,7 @@ pub fn main(init: std.process.Init) !void {
std.debug.print("warning: build mode is {s}; absolute ns are only meaningful in ReleaseFast\n", .{@tagName(builtin.mode)});
}

var scene = try buildScene(gpa);
var scene = try buildScene(gpa, false);
defer scene.deinit(gpa);
std.debug.assert(scene.bm.count() == n_bodies);

Expand Down Expand Up @@ -230,7 +243,7 @@ pub fn main(init: std.process.Init) !void {
.direction = d,
.max_distance = 200,
};
if (query.shapeCast(&scene.bp, &scene.bm, &scene.store, q)) |hit| {
if (try query.shapeCast(&scene.bp, &scene.bm, &scene.store, q)) |hit| {
hits += 1;
checksum += @floatCast(hit.distance);
}
Expand All @@ -249,7 +262,7 @@ pub fn main(init: std.process.Init) !void {
for (0..n_reps) |_| {
const t0 = nowNs();
for (centres) |c| {
const n = query.overlapShape(&scene.bp, &scene.bm, &scene.store, .{
const n = try query.overlapShape(&scene.bp, &scene.bm, &scene.store, .{
.shape = cast_sphere,
.position = c,
}, &out);
Expand Down Expand Up @@ -281,7 +294,7 @@ pub fn main(init: std.process.Init) !void {
.direction = d,
.max_distance = 200,
};
if (query.shapeCast(&scene.bp, &scene.bm, &scene.store, q)) |hit| {
if (try query.shapeCast(&scene.bp, &scene.bm, &scene.store, q)) |hit| {
hits += 1;
checksum += @floatCast(hit.distance);
}
Expand All @@ -298,7 +311,7 @@ pub fn main(init: std.process.Init) !void {
const t0 = nowNs();
for (origins, directions) |o, d| {
const q = query.RayQuery{ .origin = o, .direction = d, .max_distance = 200 };
if (try query.raycast(&scene.bp, &scene.bm, &scene.store, q)) |hit| {
if (query.raycast(&scene.bp, &scene.bm, &scene.store, q)) |hit| {
hits += 1;
checksum += @floatCast(hit.distance);
}
Expand All @@ -309,6 +322,87 @@ pub fn main(init: std.process.Init) !void {
measures[5] = report("raycast (same rays)", best_ns, n_queries, hits / n_reps);
}

// --- M1.1.11: the cost of one half-space in the scene, REPORTED, never gated ---
//
// The same queries, the same code, the same process — the grid alone against the grid
// plus one static half-space in the layer's unbounded list. Both measured here rather
// than across two runs, so the delta carries neither thermal drift nor a differently
// compiled path. Three entries, one per structure the list touches: a shape CAST (the
// swept traversal), a shape OVERLAP (the AABB traversal), and a POINT QUERY (the
// cheapest entry, where a fixed per-query cost shows up most clearly).
{
var scene_plane = try buildScene(gpa, true);
defer scene_plane.deinit(gpa);
std.debug.assert(scene_plane.bm.count() == n_bodies + 1);
std.debug.print("\n half-space delta ({d} queries, same process, best of {d}):\n", .{ n_queries, n_reps });

// A shape handle is PER STORE: the probe must be created in the store it is used
// against. Passing `scene`'s handle to `scene_plane`'s store resolved the same slot
// index to a DIFFERENT shape — the plane — and the entry answered
// `error.UnsupportedShape`, which is the E3 channel doing exactly its job on a
// caller mistake. Found by running it, not by reading it.
const plane_probe = try scene_plane.store.createShape(gpa, .{ .sphere = .{ .radius = 0.5 } });
var out_ns: [2]f64 = .{ 0, 0 };
// (a) sphere cast
for ([_]bool{ false, true }, 0..) |with, slot| {
const target = if (with) &scene_plane else &scene;
const probe = if (with) plane_probe else cast_sphere;
var best_ns: i64 = std.math.maxInt(i64);
for (0..n_reps) |_| {
const t0 = nowNs();
for (origins, directions) |o, d| {
const q = query.CastQuery{ .shape = probe, .origin = o, .direction = d, .max_distance = 200 };
if (try query.shapeCast(&target.bp, &target.bm, &target.store, q)) |hit| checksum += @floatCast(hit.distance);
}
const dt = nowNs() - t0;
if (dt < best_ns) best_ns = dt;
}
out_ns[slot] = @as(f64, @floatFromInt(best_ns)) / @as(f64, n_queries);
}
std.debug.print(" {s: <18} {d: >9.1} ns -> {d: >9.1} ns delta {d: >8.1} ns ({d: >5.2}x)\n", .{
"sphere cast", out_ns[0], out_ns[1], out_ns[1] - out_ns[0], out_ns[1] / out_ns[0],
});

// (b) point query — the cheapest entry, so a fixed per-query cost is most visible.
var ids: [32]api.BodyId = undefined;
for ([_]bool{ false, true }, 0..) |with, slot| {
const target = if (with) &scene_plane else &scene;
var best_ns: i64 = std.math.maxInt(i64);
for (0..n_reps) |_| {
const t0 = nowNs();
for (origins) |o| {
checksum += @floatFromInt(query.pointQuery(&target.bp, &target.bm, &target.store, o, .{}, &ids));
}
const dt = nowNs() - t0;
if (dt < best_ns) best_ns = dt;
}
out_ns[slot] = @as(f64, @floatFromInt(best_ns)) / @as(f64, n_queries);
}
std.debug.print(" {s: <18} {d: >9.1} ns -> {d: >9.1} ns delta {d: >8.1} ns ({d: >5.2}x)\n", .{
"point query", out_ns[0], out_ns[1], out_ns[1] - out_ns[0], out_ns[1] / out_ns[0],
});

// (c) world-AABB overlap — the entry whose exact kernel the half-space arm replaces.
for ([_]bool{ false, true }, 0..) |with, slot| {
const target = if (with) &scene_plane else &scene;
var best_ns: i64 = std.math.maxInt(i64);
for (0..n_reps) |_| {
const t0 = nowNs();
for (origins) |o| {
const lo = o.sub(Vec3r.splat(1));
const hi = o.add(Vec3r.splat(1));
checksum += @floatFromInt(query.overlapAabb(&target.bp, &target.bm, &target.store, lo, hi, .{}, &ids));
}
const dt = nowNs() - t0;
if (dt < best_ns) best_ns = dt;
}
out_ns[slot] = @as(f64, @floatFromInt(best_ns)) / @as(f64, n_queries);
}
std.debug.print(" {s: <18} {d: >9.1} ns -> {d: >9.1} ns delta {d: >8.1} ns ({d: >5.2}x)\n", .{
"overlapAabb", out_ns[0], out_ns[1], out_ns[1] - out_ns[0], out_ns[1] / out_ns[0],
});
}

const frame_ns: f64 = @as(f64, std.time.ns_per_s) / 60.0;
std.debug.print("\nforge_3d shapecast/overlap bench ({s}, {d} static bodies, {d} queries x {d} reps, best rep)\n", .{ @tagName(builtin.mode), n_bodies, n_queries, n_reps });
std.debug.print(" {s:<26} {s:>12} {s:>14} {s:>18} {s:>9}\n", .{ "mode", "ns/query", "queries/s", "queries/frame @60Hz", "hit rate" });
Expand Down
14 changes: 7 additions & 7 deletions bench/results/forge_3d_raycast.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,16 @@
- Build mode: ReleaseFast
- Scene: 10000 STATIC bodies (spheres / boxes / capsules on a 3 m grid)
- Rays: 10000 per rep, 10 reps, best rep reported
- Anti-DCE checksum: 10635397.257
- Anti-DCE checksum: 24596700.872

| mode | ns/ray | rays/s | rays per 16.67 ms frame | hit rate |
|---|---|---|---|---|
| closest | 746.3 | 1339944 | 22332 | 0.88 |
| any | 546.0 | 1831502 | 30525 | 0.88 |
| all (buffer 32) | 1532.4 | 652571 | 10876 | 0.88 |
| closest (5 m bound) | 386.4 | 2587992 | 43133 | 0.19 |
| closest (swept order) | 185.7 | 5385030 | 89750 | 1.00 |
| closest (swept, permuted) | 253.7 | 3941663 | 65694 | 1.00 |
| closest | 830.1 | 1204674 | 20078 | 0.88 |
| any | 575.7 | 1737016 | 28950 | 0.88 |
| all (buffer 32) | 1555.1 | 643045 | 10717 | 0.88 |
| closest (5 m bound) | 374.9 | 2667378 | 44456 | 0.19 |
| closest (swept order) | 184.7 | 5414185 | 90236 | 1.00 |
| closest (swept, permuted) | 239.0 | 4184100 | 69735 | 1.00 |

**Reported, not gated.** No envelope is pre-registered: this is the first
measurement of this path, and registering a bound before measuring its
Expand Down
14 changes: 7 additions & 7 deletions bench/results/forge_3d_shapecast.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,16 @@
- Scene: 10000 STATIC bodies (spheres / boxes / capsules on a 3 m grid) — the
raycast bench's scene, so the two tables are comparable
- Queries: 10000 per rep, 10 reps, best rep reported
- Anti-DCE checksum: 14262579.523
- Anti-DCE checksum: 19343205.471

| mode | ns/query | queries/s | queries per 16.67 ms frame | hit rate |
|---|---|---|---|---|
| sphere cast | 1292.9 | 773455 | 12891 | 0.98 |
| box cast | 1343.7 | 744214 | 12404 | 0.98 |
| capsule cast | 1276.1 | 783638 | 13061 | 0.98 |
| shape overlap (buffer 32) | 292.3 | 3421143 | 57019 | 0.19 |
| point cast (radius 0) | 1452.3 | 688563 | 11476 | 0.89 |
| raycast (same rays) | 926.1 | 1079797 | 17997 | 0.89 |
| sphere cast | 1216.0 | 822368 | 13706 | 0.98 |
| box cast | 1253.3 | 797894 | 13298 | 0.98 |
| capsule cast | 1222.1 | 818264 | 13638 | 0.98 |
| shape overlap (buffer 32) | 233.1 | 4290004 | 71500 | 0.19 |
| point cast (radius 0) | 1249.2 | 800512 | 13342 | 0.89 |
| raycast (same rays) | 771.5 | 1296176 | 21603 | 0.89 |

**Reported, not gated.** No envelope is pre-registered: this is the first
measurement of this path, and registering a bound before measuring its
Expand Down
Loading