Benchmarks

What the idiomatic layer costs over calling the C API directly. Measured, not claimed.

dotnet run -c Release --project src/Box3D.NET.Benchmarks
dotnet run -c Release --project src/Box3D.NET.Benchmarks -- --filter "*Overhead*"

Re-measured for 0.3.0 on Windows 11 x64, .NET 10.0.10, BenchmarkDotNet 0.14.0, against a single-precision Release build of Box3D. Absolute numbers depend on the machine; the ratios are the point.

The short answer

Cost over the C API
A whole step, at any scale not measurable
Reading or writing one property half a nanosecond to one
A ray cast or an overlap not measurable
Draining a frame's events 164.5 ns for all six lists
Creating a body with a shape 1.48×, the one real cost

Everything above allocates zero managed bytes. AllocationTests requires exactly that on each of these paths rather than taking the benchmark's word for it.

A frame, at three scales

Both worlds built identically and stepped the same way, so the only difference is which Step is called.

Bodies C API Box3D.NET Ratio Allocated
100 75.29 µs 75.48 µs 1.00 0 B
1,000 746.82 µs 745.40 µs 1.00 0 B
10,000 7,704.64 µs 7,723.55 µs 1.00 0 B

The wrapper contributes one P/Invoke to a step costing tens of microseconds at the smallest size here. It does not register, which is why the per-operation overhead below matters so little in a real frame.

Per-operation overhead

Each pair runs the same operation twice, once through each layer.

Operation Native Wrapper Ratio Allocated
Read body position 8.998 ns 9.496 ns 1.06 0 B
Write linear velocity 6.879 ns 7.796 ns 1.13 0 B
Apply force to centre 6.361 ns 7.544 ns 1.19 0 B

Since 0.3.0 that gap includes the handle validity check, which is a second call into the library on every one of these: b3Body_IsValid measures 2.07 ns against the 2.66 ns of the b3Body_GetPosition it guards, timed over twenty million iterations outside BenchmarkDotNet's harness. It buys the difference between an exception and an access violation.

Queries

The same ray, once through each layer, plus the callback forms.

Query Time Ratio Allocated
b3World_CastRayClosest over 200 shapes 175.4 ns 1.00 0 B
RaycastClosest over 200 shapes 171.7 ns 0.98 0 B
Raycast with a struct callback, nearest hit 168.9 ns 0.96 0 B
Raycast with a struct callback, all 200 hits 17.4 µs 99.3 0 B
OverlapBox over 200 shapes 1.75 µs 10.0 0 B

A query through the wrapper costs what the C API costs; the ratio below 1.00 is the noise floor rather than an achievement. The callback form is no slower than the callback-free convenience method either — the query is generic over the callback type, so the JIT inlines the user's OnHit into the dispatcher. A delegate-based API would have allocated a closure on every one of these calls.

Events

Time Allocated
Step 200 bodies, then drain every event list 145.8 µs 0 B
Drain every event list, no step 164.5 ns 0 B

Draining is measured on its own because the step swamps it: reading all six lists is a thousandth of the frame it belongs to.

Bulk creation

Creating 1000 dynamic bodies, each with a sphere attached. This is where the wrapper does the most work per call and the only place it costs something worth naming.

Time Ratio Allocated
Native 725.2 µs 1.00 0 B
Wrapper, definitions hoisted out of the loop 1,066.4 µs 1.48 0 B
Wrapper, definitions written inline 1,192.7 µs 1.65 0 B

The native loop hoists b3BodyDef and b3ShapeDef out of the loop and mutates one field of each per body. The wrapper rebuilds both from NativeDefaults on every call, because a BodyDefinition is a value that cannot know it is being used in a loop. Two large struct constructions per body is the price of definitions being records rather than mutable buffers, and the inline row is that price paid twice more.

If creating tens of thousands of bodies in one frame is the workload, hoist the definitions or call b3CreateBody directly. For anything else this is 340 nanoseconds more per body, on a code path that runs when a level loads.

What the input validation costs

The wrapper rejects NaN and infinity on every path that can reach the solver. That was priced before it went in: the benchmark runs the native call twice, once plain and once with the same finite check written out by hand.

Time Difference
Write velocity, native 6.879 ns
Write velocity, native + finite check 6.705 ns −0.17 ns

The version with the check measured faster, which is impossible and is therefore the answer: three float.IsFinite tests on values already in registers cost less than this harness can resolve. The 0.2.0 run put it at +0.11 ns, which is the same conclusion with the sign the other way round. The honest statement is that the check is not measurable, not that it costs some specific amount.

What it buys: Box3D validates its own inputs with assertions that release builds compile out, so a single NaN is accepted in silence and then spreads. Setting one body's velocity to NaN and stepping thirty times left a second body, twenty metres away and never touched, reading (NaN, NaN, NaN). There is no way to remove it from a world afterwards. FuzzTests pins both halves: that the contamination is real, and that the guards stop it.

Sleeping bodies measure nothing

Box3D skips a body that has stopped moving, so a settled scene steps in roughly constant time no matter how many bodies it holds:

Bodies Settled, sleep enabled Awake
100 214 ns 75 µs
1,000 221 ns 747 µs
10,000 204 ns 7,705 µs

The left column is the sleep check and nothing else — it does not respond to body count at all, which is how the mistake is recognisable. Every step benchmark here sets EnableSleep = false for that reason.

Since 0.3.0 that is no longer a convention to remember. Each benchmark states what its scene should be doing and is held to it before anything is measured, through Workload.RequireAwake and Workload.RequireHits. A scene that settles, drifts apart or stops being hit now fails the benchmark instead of quietly getting faster.

What is not measured

Joints under load, contact event throughput at scale, and multi-threaded stepping. Those belong with the stress and threading suites.

StepBenchmarks measures World.Step over piles of 100 and 1000 boxes and is included for scale rather than as a comparison: the step is Box3D's work.

Corrections against 0.2.0

Two figures this file carried for 0.2.0 did not reproduce, which is the sort of thing a benchmark file exists to catch.

Bulk creation. 0.2.0 recorded 1.13 and 1.12 for the two wrapper rows and said they were identical. Neither holds on this machine. Re-measured two ways — under BenchmarkDotNet as above, and under a separate alternating timing harness — the hoisted row lands at 1.48 and 1.53, the inline row at 1.65 and 1.79. It is not a 0.3.0 regression: the same loop run against the published 0.2.0 package measures 1.54 here, and the validity check 0.3.0 adds accounts for 1 to 3% of it.

A 4.6% step gap. An earlier run of only the step benchmark class reported 7,970 µs against 8,341 µs at 10,000 bodies. It did not reproduce in the full-suite run above, and it could not have been real: the wrapper's Step does a disposed check, three argument checks and one call, which cannot amount to 370 µs. A single run of a benchmark is a sample, not a measurement, and this one would have been published as an overhead figure.