diff --git a/README.md b/README.md index cb83d6e..c75485d 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,9 @@ path. [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) **[Getting started](docs/getting-started.md)** · +**[Guides](docs/guides/bodies.md)** · +**[Examples](docs/examples.md)** · **[Gallery](docs/gallery.md)** · -**[Architecture](docs/architecture.md)** · **[Benchmarks](docs/benchmarks.md)** · **[API reference](https://Miguel249.github.io/Box3D.NET/)** @@ -462,10 +463,13 @@ All nine, animated, are in the **[gallery](docs/gallery.md)**. | | | | --- | --- | -| [Getting started](docs/getting-started.md) | From nothing to a simulation, and the handful of things that will otherwise trip you up. | -| [Gallery](docs/gallery.md) | Nine scenes, animated, and how the renderer that drew them hangs off the public interface. | -| [Architecture](docs/architecture.md) | Layers, ownership and the frame loop, with diagrams. | +| [Getting started](docs/getting-started.md) | Install, first simulation, the loop. | +| [Guides](docs/guides/bodies.md) | Bodies, shapes, filtering, queries, events, joints, terrain, characters, debug draw. | +| [Concepts](docs/concepts/step.md) | The step, memory and ownership, handle validity, threading, the native layer. | +| [Examples](docs/examples.md) | Sixteen runnable samples, and what each one teaches. | +| [Gallery](docs/gallery.md) | Nine scenes, animated, drawn through the public debug draw interface. | | [Benchmarks](docs/benchmarks.md) | What the wrapper costs, measured. | +| [Architecture](docs/architecture.md) | How the binding is generated and held to the C API. | | [API coverage](docs/api-coverage.md) | Every function Box3D exports, how it is bound, and whether the idiomatic layer reaches it. | | [API reference](https://Miguel249.github.io/Box3D.NET/) | Every public type, generated from the XML documentation. | diff --git a/docs/api/index.md b/docs/api/index.md index fbe70f8..d26e7c2 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -1,24 +1,36 @@ # API reference -Two namespaces, and you almost certainly want the first. +Three namespaces, and you almost certainly want the first. -## Box3D +| Namespace | What it is | +| --- | --- | +| `Box3D` | The idiomatic surface. Start at `PhysicsWorld`, then `Body` and `Shape`. | +| `Box3D.Native` | A literal mirror of the Box3D C API: the same names, the same signatures, no abstraction. | +| `Box3D.Interop` | The one sanctioned bridge between the two, as extension methods. | -The idiomatic surface. Start at `PhysicsWorld`, then `Body` and `Shape`. - -Everything here validates its input, manages nothing you did not ask it to +Everything in `Box3D` validates its input, manages nothing you did not ask it to manage, and allocates nothing on the simulation path. -## Box3D.Native - -A literal mirror of the Box3D C API: the same names, the same signatures, no -abstraction. Reach for it when you need one of the roughly 580 exported -functions the idiomatic surface does not cover yet. - -Nothing here validates anything or manages a lifetime. Passing an invalid -identifier crashes the process rather than raising an exception. - -## Box3D.Interop - -The one sanctioned bridge between the two, as extension methods. Importing this -namespace is what makes reaching for the C layer visible in your own source. \ No newline at end of file +Nothing in `Box3D.Native` validates anything or manages a lifetime. Passing an +invalid identifier crashes the process rather than raising an exception. Reach +for it when you need one of the roughly 580 exported functions the idiomatic +surface does not cover yet — [the native layer](../concepts/native-layer.md) +explains the boundary, and [API coverage](../api-coverage.md) lists what is on +each side of it. + +Importing `Box3D.Interop` is what makes reaching for the C layer visible in your +own source. + +## Where to start + +| Looking for | Type | +| --- | --- | +| Creating and stepping a world | [`PhysicsWorld`](Box3D.PhysicsWorld.yml) | +| A physical object | [`Body`](Box3D.Body.yml), [`BodyDefinition`](Box3D.BodyDefinition.yml) | +| Collision geometry | [`Shape`](Box3D.Shape.yml), [`ShapeDefinition`](Box3D.ShapeDefinition.yml) | +| Ray casts and overlaps | [`RaycastHit`](Box3D.RaycastHit.yml), [`IRaycastCallback`](Box3D.IRaycastCallback.yml) | +| What happened last step | [`WorldEvents`](Box3D.WorldEvents.yml) | +| Constraints | [`Joint`](Box3D.Joint.yml) and the nine specific handles | +| Terrain | [`HeightField`](Box3D.HeightField.yml), [`CollisionMesh`](Box3D.CollisionMesh.yml) | +| Character movement | [`CharacterMover`](Box3D.CharacterMover.yml) | +| Drawing the simulation | [`IDebugDrawer`](Box3D.IDebugDrawer.yml) | diff --git a/docs/architecture.md b/docs/architecture.md index 412ddcc..f3a86eb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,188 +1,97 @@ # Architecture -How the pieces fit, and why they are arranged this way. +How the binding is produced and how it is held to the C API. This page is for +contributors and for anyone deciding whether to trust the layer between their +game and Box3D. -## The layers +Using the library needs none of it: [the native layer](concepts/native-layer.md) +covers the packages and where the boundary is, and +[Memory and ownership](concepts/ownership.md) covers what owns what. -```mermaid -flowchart TB - app["Your game or application"] - high["Box3D.NET
PhysicsWorld · Body · Shape · Joint
idiomatic, validated, allocation-free"] - interop["Box3D.Interop
ToNativeId · ToBody
the marked door between layers"] - native["Box3D.NET.Native
579 P/Invokes · blittable structs
a literal mirror of the C API"] - c["Box3D (C)
consumed unmodified as a submodule"] - - app -->|"the normal path"| high - app -.->|"when you need
something not wrapped"| interop - high --> native - interop --> native - native -->|"P/Invoke, no marshalling"| c - - style high fill:#512BD4,color:#fff,stroke:#3a1f9e - style native fill:#b09ef5,color:#1a1a1a,stroke:#7d68c9 - style interop fill:#fff,color:#1a1a1a,stroke:#512BD4,stroke-dasharray: 4 3 - style c fill:#d6cdfa,color:#1a1a1a,stroke:#7d68c9 - style app fill:#f4f4f5,color:#1a1a1a,stroke:#a1a1aa -``` - -The rule is that `Box3D.NET` never names a `Box3D.NET.Native` type in public -API. If it did, every consumer touching a handle would take a compile-time -dependency on the C ABI and the two packages could no longer version -independently. `LayeringTests` enforces it by reflection over the built -assembly, because a rule like this decays quietly. - -Dropping to the native layer stays possible, because a thin wrapper should not -be a ceiling — Box3D exports around 580 functions and the idiomatic surface does -not cover all of them. But it goes through `Box3D.Interop`, so the coupling -appears as a `using` in your own source rather than happening by accident. - -## What owns what - -The single most important diagram, because it is the one thing you can get -wrong in a way that crashes rather than throws. +## The build ```mermaid flowchart LR - subgraph disposable["Owns unmanaged memory — IDisposable"] - world["PhysicsWorld"] - mesh["CollisionMesh"] - field["HeightField"] - compound["CompoundGeometry"] - hull["ConvexHull"] - end - - subgraph handles["Handles — copy freely, dispose nothing"] - body["Body"] - shape["Shape"] - joint["Joint"] - end - - world -->|"creates and owns"| body - body -->|"creates and owns"| shape - world -->|"creates and owns"| joint - - hull -.->|"copied on attach"| shape - mesh -->|"borrowed — must outlive"| shape - field -->|"borrowed — must outlive"| shape - compound -->|"borrowed — must outlive"| shape - - style world fill:#512BD4,color:#fff - style mesh fill:#dc2626,color:#fff - style field fill:#dc2626,color:#fff - style compound fill:#dc2626,color:#fff - style hull fill:#16a34a,color:#fff - style body fill:#f4f4f5,color:#1a1a1a - style shape fill:#f4f4f5,color:#1a1a1a - style joint fill:#f4f4f5,color:#1a1a1a -``` + sub["external/box3d
submodule, pinned, never modified"] + script["tools/build-native.ps1
CMake · shared library"] + runtimes["runtimes/<rid>/native/"] + gen["tools/generate-bindings.ps1"] + generated["Generated/*.g.cs
543 declarations"] + pkg["NuGet packages"] -Read the arrow colours: + sub --> script --> runtimes --> pkg + sub -->|"headers"| gen --> generated --> pkg -- **Green** — a hull is interned into the world when attached, so it may be - disposed the moment the shape exists. -- **Red** — a mesh, height field or baked compound is *borrowed*. The shape holds - a pointer into it. Disposing it while a shape is alive is a use-after-free - inside the solver. **Dispose the world first.** -- **Grey** — bodies, shapes and joints own nothing. They die with their world. + style sub fill:#d6cdfa,color:#1a1a1a + style pkg fill:#512BD4,color:#fff +``` -```csharp -using var terrain = HeightField.FromHeights(heights, 256, 256, scale); +Box3D is a submodule pinned to a commit and never modified. Both the binding and +the binary are derived from it, which is what makes an upgrade a matter of +moving the submodule, re-running two scripts and reading the diff: -using (var world = new PhysicsWorld()) -{ - world.CreateStaticBody().AddHeightField(terrain); - Simulate(world); -} -// World disposed here, terrain after. Never the other way round. +```sh +git -C external/box3d checkout +pwsh tools/generate-bindings.ps1 # re-emit the P/Invokes and record the commit +pwsh tools/dump-abi.ps1 # re-record the struct layouts +dotnet test -c Release ``` -None of the disposable types has a finalizer. A finalizer runs on the GC thread -at a time of the runtime's choosing, and freeing a world mid-step, or a mesh a -live shape still points at, corrupts rather than leaks. Forgetting to dispose -leaks until the process exits, which is a bug you can see; freeing early is one -you cannot. +CI fails if the checked-in generated sources differ from what the scripts +produce, which is the point of them. -## A frame +## The bindings are generated -```mermaid -sequenceDiagram - participant App as Your game - participant World as PhysicsWorld - participant Box3D as Box3D (C) - - App->>World: body.LinearVelocity = v - Note over World: finite check, 0.11 ns - World->>Box3D: b3Body_SetLinearVelocity - - App->>World: Step(1/60) - World->>Box3D: b3World_Step - Note over Box3D: collide · solve · integrate
buffers events internally - - App->>World: Events.BodyMoves - World->>Box3D: b3World_GetBodyEvents - Box3D-->>World: pointer + count - Note over World: ref struct view,
no copy, no allocation - World-->>App: only the bodies that moved - - App->>World: RaycastClosest(...) - World->>Box3D: b3World_CastRayClosest - Box3D-->>App: RaycastHit -``` +`tools/generate-bindings.ps1` produces the 543 P/Invoke declarations from the +Box3D headers, converting the Doxygen comments into XML documentation along the +way. A mistyped parameter in a hand-written binding does not fail to compile; it +corrupts the stack at run time. Generating removes that class of bug. -Events are buffered by Box3D during the step and handed back afterwards rather -than raised as callbacks, because the solver is multithreaded and because -applications usually want to change the world in response — which is unsafe -mid-step. `WorldEvents` exposes them as `ref struct` views over engine memory, -so reading a frame's worth allocates nothing. They are valid only until the next -step. +A C type the script has not been taught is a hard error rather than something +passed through, and `BindingSource.Commit` records which Box3D revision the +declarations came from, so an assembly can be traced back to its headers. -## Why query callbacks are structs +Thirty-six functions are still bound by hand, and one deliberately is not — +[API coverage](api-coverage.md) lists all of them. -```mermaid -flowchart LR - q["world.Raycast<TCallback>"] --> ctx["context on the stack:
pointer to your struct
+ managed function pointer"] - ctx --> thunk["static thunk
[UnmanagedCallersOnly]"] - thunk --> gen["InvokeRaycast<TCallback>
generic, so it can be specialised"] - gen --> your["your OnHit — inlined"] - - style q fill:#512BD4,color:#fff - style your fill:#16a34a,color:#fff - style thunk fill:#b09ef5,color:#1a1a1a -``` +## The struct layouts are checked against a C compiler -The query is generic over the callback type, so the JIT specialises it and -devirtualises the call: your `OnHit` is inlined into the dispatcher with no -delegate allocation and nothing to keep alive across the transition. +The declarations are generated, but the structs they pass are hand-written +mirrors, and nothing about C# forces a mirror to match. A field of the wrong +width, or two fields swapped, compiles and runs: the call succeeds and reads the +wrong bytes, so a body ends up with its restitution in the friction slot. There +is no crash to investigate. -The one indirection exists because `[UnmanagedCallersOnly]` cannot be applied to -a generic method. The context carries a *managed* function pointer to a generic -helper, which may be generic precisely because it is not the -`UnmanagedCallersOnly` one, and the non-generic native thunk calls through it. -That is one indirect call per hit, against an allocation and a GC handle per -query for the delegate design. +`tools/dump-abi.ps1` compiles a program against the real Box3D headers that +prints `sizeof`, `_Alignof` and `offsetof` for every field, and records the +answers in `abi/native-layout.json`. The test suite holds all 92 structs to that +file — size, every field offset, blittability, and whether a mirror exists at +all — and CI regenerates it, so a submodule bump that moves a field fails the +build instead of shipping. -Measured: a ray cast with a struct callback runs at 163 ns against 167 ns for -the callback-free convenience method, and allocates nothing. +## The layering rule is enforced, not documented -## The build +`Box3D.NET` never names a `Box3D.NET.Native` type in public API. `LayeringTests` +checks that by reflection over the built assembly, because a rule like this +decays quietly — one convenient property and nothing fails. -```mermaid -flowchart LR - sub["external/box3d
submodule, pinned, never modified"] - script["tools/build-native.ps1
CMake · shared library"] - runtimes["runtimes/<rid>/native/"] - gen["tools/generate-bindings.ps1"] - generated["Generated/*.g.cs
543 declarations"] - pkg["NuGet packages"] +The sanctioned way down is `Box3D.Interop`, which is a `using` in the consumer's +own source rather than an accident. - sub --> script --> runtimes --> pkg - sub -->|"headers"| gen --> generated --> pkg +## What CI verifies - style sub fill:#d6cdfa,color:#1a1a1a - style pkg fill:#512BD4,color:#fff -``` +| | | +| --- | --- | +| Every test, on every supported platform | including determinism, threading, leaks and allocation | +| The packed `.nupkg`, installed into a project that has never heard of this repository | which is the only check that exercises NuGet asset resolution rather than `bin/` | +| The samples, published with NativeAOT | which proves nothing on those paths needs the JIT | +| Generated sources and the ABI dump against the headers | so a submodule bump cannot land silently | +| The public API against the last published package | a break is allowed before 1.0, but it belongs in the changelog | -Box3D is a submodule pinned to a commit and never modified. Both the binding and -the binary are derived from it, which is what makes an upgrade a matter of -moving the submodule, re-running two scripts and reading the diff. CI fails if -the checked-in generated sources differ from what the script produces. +`AllocationTests` is the one worth knowing about before writing code here: it +measures the documented hot paths with `GC.GetAllocatedBytesForCurrentThread` +and requires exactly zero bytes, so a captured closure or a boxed enumerator +fails the build. + +The repository README has the commands, the platform matrix and the full list of +test suites. diff --git a/docs/benchmarks.md b/docs/benchmarks.md index 36ed9cc..956f7f9 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -1,45 +1,35 @@ # Benchmarks -What the idiomatic layer costs over calling the C API directly. - -Reproduce with: +What the idiomatic layer costs over calling the C API directly. Measured, not +claimed. ```sh dotnet run -c Release --project src/Box3D.NET.Benchmarks dotnet run -c Release --project src/Box3D.NET.Benchmarks -- --filter "*Overhead*" ``` -Figures below were 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. - -Where 0.3.0's numbers differ from the ones this file carried for 0.2.0, the -difference is called out rather than quietly overwritten. Two of them did not -reproduce at all, which is the sort of thing a benchmark file exists to catch. +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. -## Per-operation overhead +## The short answer -Each pair runs the same operation twice, once through each layer, so the -difference is the wrapper and nothing else. - -| 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 | +| | 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 | -Half a nanosecond to a nanosecond over the bare call. 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 directly over twenty million iterations -outside BenchmarkDotNet's harness. It buys the difference between an exception -and an access violation — see the handle section of the README. +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 -The per-operation figures above measure a single call. This measures a whole -step, which is what a game actually pays, with both worlds built identically and -stepped the same way so that the only difference is which `Step` is called. +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 | | ---: | ---: | ---: | ---: | ---: | @@ -47,66 +37,28 @@ stepped the same way so that the only difference is which `Step` is called. | 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's overhead on a step is not measurable. Against a frame costing tens -of microseconds at the smallest size here, one P/Invoke does not register. - -An earlier run of only this benchmark class reported 7,970 µs against 8,341 µs -at 10,000 bodies — a 4.6% gap. 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. It is -recorded here because a single run of a benchmark is a sample, not a -measurement, and this one would have been published as an overhead figure. - -### Sleeping bodies measure nothing - -Worth stating because it invalidated an earlier version of these benchmarks. -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 the input validation costs +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. -The wrapper rejects NaN and infinity on every path that can reach the solver. -This is not free, so it 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. +## Per-operation overhead -| | Time | Difference | -| --- | ---: | ---: | -| Write velocity, native | 6.879 ns | — | -| Write velocity, native + finite check | 6.705 ns | **−0.17 ns** | +Each pair runs the same operation twice, once through each layer. -The version with the check measured *faster* than the version without it, 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. Either way it is below the noise floor, so the honest statement is that -the check is not measurable rather than that it costs some specific amount. +| 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 | -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. Measured directly — -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 of this: that -the contamination is real, and that the guards stop it. +Since 0.3.0 that gap includes the [handle validity +check](concepts/handles.md), 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. -## Spatial queries +## Queries The same ray, once through each layer, plus the callback forms. @@ -120,10 +72,10 @@ The same ray, once through each layer, plus the callback forms. 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 specializes it and inlines the user's `OnHit` into the -dispatcher. A delegate-based API would have allocated a closure on every one of -these calls. +the callback-free convenience method either — [the query is generic over the +callback type](concepts/native-layer.md#why-callbacks-are-structs), 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 @@ -132,10 +84,8 @@ these calls. | 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 as well because the step swamps it: reading all -six lists is a thousandth of the frame it belongs to. It is legitimate to read -them without stepping — the buffers belong to the world and stay valid until the -next `Step`, which is the documented lifetime. +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 @@ -149,37 +99,88 @@ naming. | 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 | -**This table replaces figures that did not reproduce.** For 0.2.0 it recorded -1.13 and 1.12, and said the two wrapper rows 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 and -the inline row at 1.65 and 1.79. - -That is not a 0.3.0 regression, and it was checked rather than assumed: the same -loop run against the **published 0.2.0 package** measures 1.54 here. The -validity check 0.3.0 adds accounts for 1 to 3% of it, measured by adding one -`b3Body_IsValid` per body to the native loop, which moved it from 1.00 to 1.01. - -What the remaining half is: 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, for the `BodyDefinition.Dynamic` -and `ShapeDefinition.Default` that the hoisted row builds once. +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 reach through `Box3D.Interop` and call `b3CreateBody` directly. -For anything else this is 340 nanoseconds per body against 725, on a code path -that runs when a level loads. +definitions or [call `b3CreateBody` +directly](concepts/native-layer.md#going-down-a-level). 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 -## What is measured, and what is not +Two figures this file carried for 0.2.0 did not reproduce, which is the sort of +thing a benchmark file exists to catch. -`StepBenchmarks` measures `World.Step` over piles of 100 and 1000 boxes. It is -included for scale rather than as a comparison: the step is Box3D's work, the -wrapper contributes one P/Invoke to it, and at hundreds of microseconds per step -that call is unmeasurable. It is the reason the overhead above matters so -little in a real frame. +**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. -Not yet measured: joints under load, contact event throughput at scale, and -multi-threaded stepping. Those belong with the stress and threading suites. +**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. diff --git a/docs/concepts/handles.md b/docs/concepts/handles.md new file mode 100644 index 0000000..797abc5 --- /dev/null +++ b/docs/concepts/handles.md @@ -0,0 +1,87 @@ +# Handle validity + +`Body`, `Shape`, `Joint` and the nine specific joint handles are small value +types holding an index and a generation counter — not pointers, not +`SafeHandle`s. Copy them, store them, compare them, put them in a dictionary. + +The question this page answers is what happens when you use one after the thing +it refers to is gone. + +## What the library does + +Every member that dereferences a handle asks the engine whether it is still live +first, and throws `InvalidOperationException` if it is not: + +```csharp +Body body = world.CreateDynamicBody(spawn); +body.Destroy(); + +Vector3 position = body.Position; // InvalidOperationException +``` + +Without that check the same line reads freed memory. Measured on `win-x64` +against the shipped release build, before the checks went in: + +``` +default(Body).Position access violation, 0xC0000005 +body.Position after body.Destroy() access violation +body.Position after world.Dispose() access violation +body.Destroy() twice access violation +handle whose index had been reused returned the replacement body's + position, in silence +``` + +The last one is the reason a generation counter is not enough on its own: no +crash, no exception, just another body's state reported as yours. + +Box3D validates handles with assertions, and assertions are compiled out of the +release binary this package ships — which is why the check has to live here. + +## Asking is always safe + +`IsValid`, handle conversions such as `hinge.AsJoint`, equality and `ToString` +never throw. That makes them usable in exactly the place you need them: reading +events, where a handle may refer to something that was destroyed during the +step. + +```csharp +foreach (ContactEndEvent touch in world.Events.ContactEnds) +{ + // An end-touch event is often raised *because* a shape was destroyed. + if (touch.ShapeA.IsValid) + { + Handle(touch.ShapeA); + } +} +``` + +## What it costs + +`b3Body_IsValid` measures 2.07 ns against the 2.66 ns of the +`b3Body_GetPosition` it guards, timed over twenty million iterations. Reading a +body position through the wrapper costs 9.50 ns against the C API's 9.00 ns, and +that check is most of the difference. + +That is the price of an exception with a stack trace instead of an access +violation. If you are reading thousands of properties per frame and have already +proved the handles are live, [the native layer](native-layer.md) does no +checking at all, by design. + +## The one case that remains + +A `b3BodyId` records which world *slot* it came from, but not that world's +generation. A handle held past `world.Dispose()` is therefore indistinguishable +from a handle into whatever world next occupies the slot, and nothing in the id +can separate them. + +That belongs to Box3D rather than to this binding. `HandleSafetyTests` pins the +behaviour so it is a known boundary rather than a surprise, and the rule that +avoids it is simple: + +> Handles do not outlive their world. + +## Related + +- [Memory and ownership](ownership.md) — who owns what, and the disposal order. +- [Events](../guides/events.md#begin-and-end-touch) — where invalid handles + actually turn up. diff --git a/docs/concepts/native-layer.md b/docs/concepts/native-layer.md new file mode 100644 index 0000000..ec70285 --- /dev/null +++ b/docs/concepts/native-layer.md @@ -0,0 +1,127 @@ +# The native layer + +**Most users should use `Box3D.NET` and never read this page.** +`Box3D.NET.Native` is the raw P/Invoke layer: a one-to-one mirror of the Box3D C +API, with the same names, the same signatures and no abstraction. Reach for it +when you need one of the roughly 580 exported functions the idiomatic surface +does not cover yet. + +```mermaid +flowchart TB + app["Your game or application"] + high["Box3D.NET
PhysicsWorld · Body · Shape · Joint
idiomatic, validated, allocation-free"] + interop["Box3D.Interop
ToNativeId · ToBody
the marked door between layers"] + native["Box3D.NET.Native
579 P/Invokes · blittable structs
a literal mirror of the C API"] + c["Box3D (C)
consumed unmodified as a submodule"] + + app -->|"the normal path"| high + app -.->|"when you need
something not wrapped"| interop + high --> native + interop --> native + native -->|"P/Invoke, no marshalling"| c + + style high fill:#512BD4,color:#fff,stroke:#3a1f9e + style native fill:#b09ef5,color:#1a1a1a,stroke:#7d68c9 + style interop fill:#fff,color:#1a1a1a,stroke:#512BD4,stroke-dasharray: 4 3 + style c fill:#d6cdfa,color:#1a1a1a,stroke:#7d68c9 + style app fill:#f4f4f5,color:#1a1a1a,stroke:#a1a1aa +``` + +## The safety rules, once + +Everything in `Box3D.NET.Native` and `Box3D.Interop` follows the C API's +contract, which assumes the caller knows what it is doing: + +| | `Box3D.NET` | `Box3D.NET.Native` | +| --- | --- | --- | +| Validates handles | yes, throws | **no, undefined behaviour** | +| Rejects NaN and infinity | yes, throws | **no** | +| Manages lifetimes | the world owns its objects | **you do** | +| Guards world creation | yes, process-wide mutex | **no** | + +Passing an invalid identifier crashes the process rather than raising an +exception. Passing a NaN contaminates the world silently. Those two sentences +are the whole difference, and they are not repeated on the individual functions. + +## Going down a level + +`Box3D.NET` never names a `Box3D.NET.Native` type in public API. If it did, +every consumer touching a handle would take a compile-time dependency on the C +ABI, and the two packages could no longer version independently. +`LayeringTests` enforces that by reflection over the built assembly, because a +rule like this decays quietly — one convenient property and nothing fails. + +Dropping down is still supported, because a thin wrapper should not be a +ceiling. It goes through `Box3D.Interop`: + +```csharp +using Box3D.Interop; +using Box3D.Native; + +b3BodyId raw = body.ToNativeId(); +B3.b3Body_SetName(raw, name); + +Body back = raw.ToBody(); +``` + +Importing that namespace is the point: the coupling shows up as a `using` in +your own source rather than being the path of least resistance. + +[API coverage](../api-coverage.md) lists every exported function and whether the +idiomatic layer reaches it. `NATIVE_ONLY` is not a to-do list — most of it is +machinery an idiomatic API should not surface: individual accessors a single +property reads several of at once, recording and replay, tree internals, +profiling counters. + +## Why callbacks are structs + +```mermaid +flowchart LR + q["world.Raycast<TCallback>"] --> ctx["context on the stack:
pointer to your struct
+ managed function pointer"] + ctx --> thunk["static thunk
[UnmanagedCallersOnly]"] + thunk --> gen["InvokeRaycast<TCallback>
generic, so it can be specialised"] + gen --> your["your OnHit — inlined"] + + style q fill:#512BD4,color:#fff + style your fill:#16a34a,color:#fff + style thunk fill:#b09ef5,color:#1a1a1a +``` + +Queries are generic over the callback type, so the JIT specialises them and +devirtualises the call: your `OnHit` is inlined into the dispatcher with no +delegate allocation and nothing to keep alive across the transition. + +The one indirection exists because `[UnmanagedCallersOnly]` cannot be applied to +a generic method. The context carries a *managed* function pointer to a generic +helper — which may be generic precisely because it is not the +`UnmanagedCallersOnly` one — and the non-generic native thunk calls through it. +That is one indirect call per hit, against an allocation and a GC handle per +query for the delegate design. + +Measured: a ray cast with a struct callback runs at 168.9 ns against 171.7 ns +for the callback-free convenience method, and allocates nothing. + +## Marshalling, or the absence of it + +The native assembly is compiled with `DisableRuntimeMarshalling`. Every P/Invoke +is a direct call with arguments passed as they already sit in memory, and any +accidentally non-blittable type is a compile error instead of a silent +field-by-field copy. Booleans cross the boundary as `NativeBool`, a one-byte +value type matching C's `_Bool`. + +Vectors need no conversion either: `b3Vec3` and `System.Numerics.Vector3` have +the same layout, as do `b3Quat` and `Quaternion`. `LayoutTests` asserts that +rather than trusting it. + +## Single precision only + +Box3D's `BOX3D_DOUBLE_PRECISION` "large world" mode changes the ABI rather than +being a runtime switch. This binding targets the default single-precision build +and asserts at test time that the loaded library agrees. Large-world support, if +it lands, will be a separate package. + +## Where the bindings come from + +They are generated from the Box3D headers rather than written by hand, and the +struct layouts are checked against what a C compiler reports for the same +declarations. [Architecture](../architecture.md) covers that pipeline. diff --git a/docs/concepts/ownership.md b/docs/concepts/ownership.md new file mode 100644 index 0000000..712a64c --- /dev/null +++ b/docs/concepts/ownership.md @@ -0,0 +1,118 @@ +# Memory and ownership + +This is the one thing in the library you can get wrong in a way that crashes +rather than throws. + +```mermaid +flowchart LR + subgraph disposable["Owns unmanaged memory — IDisposable"] + world["PhysicsWorld"] + mesh["CollisionMesh"] + field["HeightField"] + compound["CompoundGeometry"] + hull["ConvexHull"] + end + + subgraph handles["Handles — copy freely, dispose nothing"] + body["Body"] + shape["Shape"] + joint["Joint"] + end + + world -->|"creates and owns"| body + body -->|"creates and owns"| shape + world -->|"creates and owns"| joint + + hull -.->|"copied on attach"| shape + mesh -->|"borrowed — must outlive"| shape + field -->|"borrowed — must outlive"| shape + compound -->|"borrowed — must outlive"| shape + + style world fill:#512BD4,color:#fff + style mesh fill:#dc2626,color:#fff + style field fill:#dc2626,color:#fff + style compound fill:#dc2626,color:#fff + style hull fill:#16a34a,color:#fff + style body fill:#f4f4f5,color:#1a1a1a + style shape fill:#f4f4f5,color:#1a1a1a + style joint fill:#f4f4f5,color:#1a1a1a +``` + +## The rules + +| | Copied on attach | Dispose | +| --- | --- | --- | +| `Sphere`, `Capsule`, `Box` | yes, by value | nothing to dispose | +| `ConvexHull` | yes, interned in the world | any time, even before the world | +| `CollisionMesh` | **no, borrowed** | **after the world** | +| `HeightField` | **no, borrowed** | **after the world** | +| `CompoundGeometry` | **no, borrowed** | **after the world** | +| `Body`, `Shape`, `Joint` | — | never; they die with the world | + +A borrowed geometry is one the shape holds a pointer into. Disposing it while a +shape is alive is a use-after-free inside the solver: not an exception, not a +`NullReferenceException`, a crash or worse. + +```csharp +using var terrain = HeightField.FromHeights(heights, 256, 256, scale); + +using (var world = new PhysicsWorld()) +{ + world.CreateStaticBody().AddHeightField(terrain); + Simulate(world); +} +// World disposed here, terrain after. Never the other way round. +``` + +Declaring the geometry first and the world second, as above, gets the order +right by construction: `using` disposes in reverse. + +## Only the world owns the simulation + +`PhysicsWorld` is the only type that owns the simulation's memory. `Body`, +`Shape` and `Joint` are handles into it — small value types you can copy, store +and pass between threads freely. Making them `IDisposable` would imply an +ownership they do not have. + +Destroying a world destroys everything in it. There is nothing else to release, +and nothing to release in any particular order. + +## There are no finalizers + +Not on `PhysicsWorld`, and not on any of the disposable geometry types. This is +a deliberate departure from the usual guidance. + +A finalizer runs on the GC thread at a time of the runtime's choosing. Freeing a +world while another thread is inside `Step`, or freeing a mesh a live shape +still points at, corrupts the simulation rather than merely leaking. The +alternative failure — forgetting to dispose — leaks until the process exits, +which is a bug you can see: + +```csharp +Console.WriteLine(PhysicsWorld.Count); // climbing, and capped at MaxCount +``` + +Visible and diagnosable beats a use-after-free that only shows up under load. + +## Checking for leaks + +Every disposable geometry exposes `ByteCount`, and the native layer can report +the process-wide total: + +```csharp +using Box3D.Native; + +int before = B3.b3GetByteCount(); +// ... create and destroy worlds, meshes, hulls ... +int after = B3.b3GetByteCount(); // should be back where it started +``` + +That is exactly what the test suite does after every create-and-destroy cycle, +which is why the ownership rules above are asserted rather than assumed. + +## Related + +- [Handle validity](handles.md) — what happens when you use a handle after its + world or body is gone. +- [Terrain and meshes](../guides/terrain.md) — the three borrowed kinds, and + what each is for. diff --git a/docs/concepts/step.md b/docs/concepts/step.md new file mode 100644 index 0000000..13cfa66 --- /dev/null +++ b/docs/concepts/step.md @@ -0,0 +1,130 @@ +# The simulation step + +`world.Step(timeStep, subStepCount)` is where collision detection, constraint +solving and integration happen. Nothing else moves the simulation. + +```mermaid +sequenceDiagram + participant App as Your game + participant World as PhysicsWorld + participant Box3D as Box3D (C) + + App->>World: body.LinearVelocity = v + Note over World: finite check, 0.11 ns + World->>Box3D: b3Body_SetLinearVelocity + + App->>World: Step(1/60) + World->>Box3D: b3World_Step + Note over Box3D: collide · solve · integrate
buffers events internally + + App->>World: Events.BodyMoves + World->>Box3D: b3World_GetBodyEvents + Box3D-->>World: pointer + count + Note over World: ref struct view,
no copy, no allocation + World-->>App: only the bodies that moved + + App->>World: RaycastClosest(...) + World->>Box3D: b3World_CastRayClosest + Box3D-->>App: RaycastHit +``` + +## Keep the step fixed + +A varying step makes the simulation irreproducible and hurts stability. Decouple +it from the frame rate with an accumulator, and clamp the input so that one long +frame does not spiral into a hundred catch-up steps: + +```csharp +const float FixedStep = 1.0f / 60.0f; + +accumulator += MathF.Min(deltaTime, 0.25f); + +while (accumulator >= FixedStep) +{ + world.Step(FixedStep); + accumulator -= FixedStep; +} +``` + +Interpolate between the last two physics poses if you need to render at a +higher rate than you simulate. Do not step by the frame time to get there. + +## Sub-steps + +The second argument is how many solver sub-steps to take within the step. More +is more accurate and more expensive; four is the usual choice and the default. +Raise it for stacks that sag or joints that stretch, not for tunnelling — +that is what continuous collision is for. + +Sub-step count is part of the input to the simulation, so changing it changes +the result. Keep it fixed for the same reason the time step is fixed. + +## Events are buffered, not raised + +Box3D collects what happened during the step and hands it back afterwards +instead of calling back mid-step. Two reasons: the solver is multithreaded, and +applications usually want to change the world in response, which is unsafe while +it is being solved. + +The consequence for you is that [events](../guides/events.md) live between one +step and the next, and creating or destroying bodies while reading them is +fine. + +## Sleeping + +A body that stops moving falls asleep and stops being simulated until something +touches it. This is on by default and is the reason a settled scene of ten +thousand bodies costs roughly what an empty one does. + +```csharp +world.SleepEnabled = false; // whole world; rarely what you want +body.CanSleep = false; // this body only +world.AwakeBodyCount // zero means everything has settled +``` + +Sleeping is a feature, not a compromise, but it does mean a benchmark over a +settled scene measures the sleep check and nothing else. See +[Benchmarks](../benchmarks.md#sleeping-bodies-measure-nothing). + +## Continuous collision + +A body moving fast enough to pass through a wall within one step is a tunnelling +problem, and the answer is not a smaller step. + +```csharp +// On by default. Leave it on; turning it off saves very little. +using var world = new PhysicsWorld(WorldSettings.Default with { EnableContinuous = true }); + +// Sweep this one against dynamic and kinematic bodies too. +Body shell = world.CreateBody(BodyDefinition.Dynamic(muzzle) with { IsBullet = true }); +``` + +Use bullets sparingly. They are swept after everything else has moved, so they +do not guarantee correct collision when *both* bodies move fast. For a +projectile that must not miss, [cast a ray](../guides/queries.md) along its path +and place the hit yourself. + +Sensors have no continuous collision at all. + +## Tuning a world + +Most simulations only ever set `Gravity` and `WorkerCount`. The rest of +[`WorldSettings`](../api/Box3D.WorldSettings.yml) exists for when something specific +is wrong: + +| Setting | Reach for it when | +| --- | --- | +| `RestitutionThreshold` | Slow contacts bounce when they should settle. Setting it very low prevents sleeping | +| `HitEventThreshold` | Too many, or too few, [hit events](../guides/events.md#impacts) | +| `MaximumLinearSpeed` | Something reaches an absurd speed and never comes back | +| `ContactSpeed` | Deep overlap is pushed apart explosively | +| `ContactHertz`, `ContactDampingRatio` | Bodies visibly sink into each other, or jitter. Advanced | + +Settings are fixed when the world is created, except `Gravity`, `SleepEnabled` +and `ContinuousEnabled`, which are properties on the world itself. + +## What a step costs + +The wrapper adds one P/Invoke to a step that takes tens of microseconds at the +smallest useful scale, so its overhead is not measurable. The numbers are in +[Benchmarks](../benchmarks.md). diff --git a/docs/concepts/threading.md b/docs/concepts/threading.md new file mode 100644 index 0000000..f2aab00 --- /dev/null +++ b/docs/concepts/threading.md @@ -0,0 +1,78 @@ +# Threading and determinism + +The short version: **one world, one thread.** + +## What is safe + +Each row is what the threading tests actually exercise, rather than what would +be convenient to promise. + +| Operation | Safe concurrently | +| --- | :---: | +| `Step` on **different** worlds | yes | +| Reads and queries on **different** worlds | yes | +| `new PhysicsWorld(...)` and `Dispose` from several threads | yes, serialised by this library | +| Two threads reading one world, nothing stepping | yes | +| Passing a `Body`, `Shape` or `Joint` between threads | yes, the handle is a value | +| Anything on **one** world while that world is stepping | **no** | +| Two threads mutating one world | **no** | +| Reading `Events` while that world is stepping | **no** | + +A `PhysicsWorld` is not internally synchronised, and deliberately so: a lock +around `Step` would cost every user in order to protect a pattern the engine +does not support anyway. Give each world an owner, and hand results to other +threads afterwards. + +## Two things worth knowing + +**World creation and destruction are guarded here, not by Box3D.** The engine +keeps its worlds in one global table and picks a slot by scanning for a free +entry, then marks it in use some thirty lines later; nothing synchronises the +two. Two threads creating a world at the same time can select the same slot, and +the corrupted world then spins forever inside `Step` rather than failing +outright. Box3D's own documentation says to hold a mutex around those calls, so +`PhysicsWorld` holds one. It covers only the two native calls, so `Step`, +queries and body edits never touch it. Code calling `b3CreateWorld` through +[the native layer](native-layer.md) directly is outside that guard and on its +own. + +**A world may already be using several threads.** `WorkerCount` above one lets +Box3D start and own worker threads for the solver: + +```csharp +using var world = new PhysicsWorld(WorldSettings.Default with { WorkerCount = 4 }); +``` + +That is internal parallelism inside one `Step` on one calling thread. It does +not make the world safe to touch from your threads, and it is not additive with +running several worlds at once — the cores have to come from somewhere. Box3D +performs best on performance cores sharing one L2 cache; efficiency cores and +hyper-threading add little and can cost. + +## Determinism + +Box3D is written for cross-platform determinism, which is what makes lockstep +networking and replay possible. This binding's job is not to undermine it. +`DeterminismTests` hashes the exact bits of every body's position and velocity +after a fixed number of steps and requires equality, not closeness. + +Verified on every CI platform: + +- the same scene run twice, and run many times, gives identical bits; +- other worlds existing alongside it change nothing; +- interleaving two worlds step by step changes neither; +- a query between steps changes nothing; +- reading events changes nothing; +- a multithreaded world is reproducible against itself. + +**Not** verified, and therefore not claimed: that two *different* platforms or +architectures produce identical bits for the same scene. That is a property of +Box3D and of the compiler it was built with, not of this binding, and proving it +would mean comparing hashes across the CI matrix rather than within each runner. +Until that exists, treat cross-platform determinism as Box3D's claim rather than +as this package's. + +Determinism also depends on things you control: a fixed time step, a fixed +sub-step count, and the same sequence of operations. A variable time step makes +a simulation non-reproducible no matter what either layer does. See +[the simulation step](step.md). diff --git a/docs/concepts/toc.yml b/docs/concepts/toc.yml new file mode 100644 index 0000000..396d582 --- /dev/null +++ b/docs/concepts/toc.yml @@ -0,0 +1,10 @@ +- name: The simulation step + href: step.md +- name: Memory and ownership + href: ownership.md +- name: Handle validity + href: handles.md +- name: Threading and determinism + href: threading.md +- name: The native layer + href: native-layer.md diff --git a/docs/docfx.json b/docs/docfx.json index 44405ee..4ffbfc3 100644 --- a/docs/docfx.json +++ b/docs/docfx.json @@ -33,6 +33,10 @@ { "files": [ "*.md", + "guides/*.md", + "guides/toc.yml", + "concepts/*.md", + "concepts/toc.yml", "toc.yml" ], "exclude": [ diff --git a/docs/examples.md b/docs/examples.md new file mode 100644 index 0000000..579de34 --- /dev/null +++ b/docs/examples.md @@ -0,0 +1,64 @@ +# Examples + +Sixteen runnable samples in `src/Box3D.NET.Samples`, each headless, +self-checking and small enough to read in one sitting. They assert on their own +results rather than only printing, so CI runs them — published with NativeAOT — +and a regression fails the build instead of producing plausible output nobody +reads. + +```sh +dotnet run --project src/Box3D.NET.Samples -- --list # what there is +dotnet run --project src/Box3D.NET.Samples -- raycast # run one +dotnet run --project src/Box3D.NET.Samples # run all of them +``` + +## Start here + +| Sample | Teaches | Guide | +| --- | --- | --- | +| `basic-world` | A world, a body, a shape, a step | [Getting started](getting-started.md) | +| `dynamic-body` | Gravity acting on a falling body | [Bodies](guides/bodies.md) | +| `collision` | A falling box landing on static ground | [Bodies](guides/bodies.md) | + +## Reacting to the simulation + +| Sample | Teaches | Guide | +| --- | --- | --- | +| `raycast` | Closest-hit and callback ray casts | [Queries](guides/queries.md) | +| `contact-events` | Reading contacts after a step | [Events](guides/events.md) | +| `sensor` | A trigger volume that reports overlaps without colliding | [Events](guides/events.md#sensors) | +| `entities` | Associating game objects with bodies through user data | [Getting started](getting-started.md#get-the-results-back) | + +## Geometry + +| Sample | Teaches | Guide | +| --- | --- | --- | +| `compound` | Several shapes on one body, and many baked into one | [Shapes](guides/shapes.md) | +| `mesh` | Collision against a triangle mesh | [Terrain and meshes](guides/terrain.md) | +| `height-field` | Terrain from a height map | [Terrain and meshes](guides/terrain.md) | +| `continuous` | A fast body that would otherwise tunnel through a wall | [The simulation step](concepts/step.md#continuous-collision) | + +## Joints + +| Sample | Teaches | Guide | +| --- | --- | --- | +| `hinged-door` | A revolute joint with limits | [Joints](guides/joints.md) | +| `chain` | A hanging chain of revolute joints | [Joints](guides/joints.md) | +| `vehicle` | A wheeled vehicle built from wheel joints | [Joints](guides/joints.md) | + +## The rest + +| Sample | Teaches | Guide | +| --- | --- | --- | +| `character` | A kinematic character walking, sliding and climbing | [Characters](guides/characters.md) | +| `debug-draw` | Feeding the world's debug geometry to a renderer | [Debug draw](guides/debug-draw.md) | + +`character` is the one to copy. It builds a complete controller — gravity, +jumping, ground detection, slope limits, wall sliding — on the three mover +primitives in about eighty lines. + +## Seeing them run + +The [gallery](gallery.md) renders nine scenes to animated GIFs through the +public debug draw interface. Same library, same API, a renderer with no +privileged access. diff --git a/docs/gallery.md b/docs/gallery.md index a240a55..c5203e3 100644 --- a/docs/gallery.md +++ b/docs/gallery.md @@ -1,7 +1,7 @@ # Gallery -Nine scenes, each one a world built through the public API and photographed by -a renderer that knows nothing about Box3D beyond `IDebugDrawer` and +Nine scenes, each one a world built through the public API and drawn by a +renderer that knows nothing about Box3D beyond `IDebugDrawer` and `IDebugShapeFactory`. ```sh @@ -32,37 +32,33 @@ boxes stack. ![Contact points, normals and broad-phase bounds](../assets/renders/contacts.gif) -The same simulation with the engine's own diagnostics turned on: -`DrawContacts`, `DrawContactNormals` and `DrawBounds`. Every dot is a point the -solver is working with, and every yellow box is a broad-phase bound. +The same simulation with the engine's own diagnostics turned on: `DrawContacts`, +`DrawContactNormals` and `DrawBounds`. Every dot is a point the solver is +working with, and every yellow box is a broad-phase bound. -Two draw calls rather than one, because the options apply to the whole call. The -first draws every shape; the second draws only the annotations, restricted with -`CategoryMask` to the dynamic bodies — the floor's bounding box is eighty metres -across and would be the only thing in the picture. +Two draw calls rather than one, because [options apply to the whole +call](guides/debug-draw.md#drawing-part-of-a-world): the first draws every +shape, the second draws only the annotations, restricted with `CategoryMask` to +the dynamic bodies. The floor's bounding box is eighty metres across and would +otherwise be the only thing in the picture. -The numbers come out of the engine through `DrawString`, and are the reason the -renderer carries a font at all: the large one over each body is its mass, from -`DrawMass`, and the small one at each contact is that point's separation in -centimetres, from `DrawContactNormals`. `--font-card` renders a proof sheet of -all ninety-five glyphs. - -The arrangement is chosen around those labels. Every contact point is annotated, -so a box resting squarely on another box costs four numbers where a capsule lying -on the same box costs two — which is why the thing on top of the crate is a -capsule, and why there is one crate rather than a pile of them. +The numbers come out of the engine through `DrawString` — the large one over +each body is its mass, the small one at each contact is that point's separation +in centimetres. The arrangement is chosen around those labels: a box resting +squarely on another box costs four numbers where a capsule lying on the same box +costs two, which is why the thing on top of the crate is a capsule. ## chain ![A chain of revolute joints swinging](../assets/renders/chain.gif) Nine capsules and a weight, hinged end to end and set swinging about the anchor. -The small markers along it are the joint frames, drawn by the engine with -`DrawJoints`. +The small markers along it are the joint frames, drawn with `DrawJoints`. The chain starts hanging straight and is given angular velocity rather than -being built at an angle. A chain assembled already displaced has every joint -violated on the first step and snaps. +being built at an angle. [A chain assembled already +displaced](guides/joints.md#use-the-factory-methods) has every joint violated on +the first step and snaps. ## vehicle @@ -70,7 +66,7 @@ violated on the first step and snaps. A chassis, two wheel joints with suspension and a motor on the rear one. The bumps are static boxes; the take-off ramp at the end is a triangle mesh, which is -borrowed by the shape built from it rather than copied — so it is released after +[borrowed rather than copied](concepts/ownership.md) — so it is released after the world, not before. ## raycast @@ -88,35 +84,27 @@ back. ![A kinematic character climbing a ramp](../assets/renders/character.gif) -The character controller from the samples, unchanged: gather the planes the -capsule is touching, solve them, clip the velocity. It turns orange on the -ground and yellow in the air. +The [character controller](guides/characters.md) from the samples, unchanged: +gather the planes the capsule is touching, solve them, clip the velocity. It +turns orange on the ground and yellow in the air. The red marker under its feet is a plane the mover is solving against, drawn through the capsule on purpose — an annotation hidden by the body it describes -shows nothing at all. Note that a contact point comes back *relative to the query -origin*, not in world space. +shows nothing at all. Note that a contact point comes back *relative to the +query origin*, not in world space. ## compound ![A colonnade baked into one shape, with balls bouncing off it](../assets/renders/compound.gif) Thirty-seven children — a mesh plinth, twelve hull columns, twelve capsule -lintels and twelve sphere capitals — baked into a single `CompoundGeometry` and -attached with one call to `AddCompound`. The gold box is the broad-phase bound, -and there is exactly one of it: that is what a baked compound buys, and why Box3D -restricts it to static bodies. - -The trade shows in the picture too. One shape means one filter, one set of events -and one colour; the children cannot be told apart from outside. For several -shapes that *can* be told apart, or on a body that moves, attach them to the body -one at a time instead — that is a run-time compound, and it works on any body -type. - -This is also the only shape a renderer cannot draw unaided. Hulls, meshes and -height fields all have a `b3Shape_Get…` accessor; a compound has none, so the -scene hands the baked geometry to the shape factory itself. Without that the -columns would be missing and the balls would appear to bounce off nothing. +lintels and twelve sphere capitals — [baked into a single +`CompoundGeometry`](guides/terrain.md#baked-compounds) and attached with one call +to `AddCompound`. The gold box is the broad-phase bound, and there is exactly one +of it. + +The trade shows in the picture too: one shape means one filter, one set of events +and one colour, and the children cannot be told apart from outside. ## terrain @@ -125,60 +113,21 @@ columns would be missing and the balls would appear to bounce off nothing. A 41 by 41 height field with twelve balls dropped around the rim. The terrain mesh is read back out of the engine's own compressed grid rather than from the array it was built from, so the picture shows what the simulation is actually -colliding against — the quantization steps included, which are the faint terraces -visible across the slope. +colliding against — the [quantization +steps](guides/terrain.md#height-fields) included, which are the faint terraces +across the slope. ## How the pictures are made `src/Box3D.NET.Visualizer` is a console application with no dependencies beyond -the base class library. It contains a software rasterizer, a PNG writer and a -GIF writer, and it consumes `Box3D.NET` exactly as an application would. - -That arrangement is the point. The library is deliberately renderer-agnostic — -it knows nothing about OpenGL, Vulkan, Unity or Godot — so the way to prove the -drawing interface is usable is to write a renderer against it and no other -privileged access. - -There are two halves to that interface, and both are exercised here: - -- **`IDebugShapeFactory`** is asked once per shape to build a drawable and hands - back an opaque handle. The visualizer tessellates spheres and capsules from the - handful of floats the engine passes, and reads hulls, meshes and height fields - out of the engine through `Box3D.Interop` — the marked door down to the C API. - Baked compounds are the one exception: there is no accessor for them, so the - scene that baked one hands it over. A drawable is built once and reused for - every frame after, which is the difference between rendering a five-second - animation in seconds and in minutes. -- **`IDebugDrawer`** receives everything else each frame: the shape handles with - their transforms, and the segments, points and boxes the diagnostics produce. - It is a `struct` passed by `ref`, so the engine's side of the call allocates - nothing. - -Anything the engine cannot know about — a ray the scene cast, a character being -moved by the game rather than simulated — the scene draws itself, through the -same renderer. - -### What the renderer does - -Triangles, a depth buffer, one directional light with a hemisphere ambient, and -shadows projected onto a plane. It renders at three times the output size and -box-filters down, which is the whole anti-aliasing strategy. - -Text is a 5x7 bitmap table, blitted in screen space with a one-pixel shadow so a -label stays readable on a white body and on the backdrop alike. It covers the -ninety-five printable ASCII characters, which is everything Box3D emits; -anything else is drawn as a question mark rather than dropped. - -The output is written directly: PNG through `ZLibStream` with a Paeth filter per -scanline, and GIF with a median-cut palette shared across the animation, an -ordered dither, and only the rectangle that changed stored per frame. Each -animation is capped at seventy frames, with the sampling stride and the frame -delay derived together so that a longer scene samples itself more coarsely rather -than producing a heavier file. - -### What it is not - -It is not a renderer to build a game on. There is no texturing, no transparency, -no font beyond the bitmap table, and no glyph outside ASCII. A shape it cannot -tessellate — a baked compound the scene never handed over — is drawn as nothing -rather than drawn wrong, and the run reports how many of those it met. +the base class library: a software rasterizer, a PNG writer and a GIF writer, +consuming `Box3D.NET` exactly as an application would. + +That arrangement is the point. The library is deliberately renderer-agnostic, so +the way to prove the drawing interface is usable is to write a renderer against +it and no other privileged access. One exception exists and is visible here: +hulls, meshes and height fields all have a `b3Shape_Get…` accessor, but a baked +compound has none, so the scene that baked one hands it to the shape factory +itself. + +[Debug draw](guides/debug-draw.md) covers the interface it is written against. diff --git a/docs/getting-started.md b/docs/getting-started.md index d726e42..3abd5db 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,6 +1,7 @@ # Getting started -Five minutes from nothing to a simulation. +Install the package, run a simulation, get the results back into your game. +Five minutes. ## Install @@ -8,9 +9,10 @@ Five minutes from nothing to a simulation. dotnet add package Box3D.NET ``` -The native Box3D binary for your platform comes with it. Nothing else to install. +The native Box3D binary for your platform comes with it. Nothing else to +install. .NET 8 or later, on Windows, Linux and macOS, x64 and arm64. -## The smallest program that simulates something +## Your first simulation ```csharp using System.Numerics; @@ -32,16 +34,19 @@ for (int frame = 0; frame < 120; frame++) Console.WriteLine(ball.Position); // resting on the ground ``` -Four ideas, and that is the whole model: +That is the whole model: | | | | --- | --- | -| **World** | Contains everything and is the thing you step. Dispose it when finished. | -| **Body** | Has a position, an orientation and a velocity. No shape of its own. | -| **Shape** | Collision geometry attached to a body. A body can carry several. | -| **Step** | Advances time. Everything happens here. | +| [`PhysicsWorld`](api/Box3D.PhysicsWorld.yml) | Contains everything and is the thing you step. Dispose it when finished. | +| [`Body`](api/Box3D.Body.yml) | Position, orientation, velocity. No shape of its own. | +| [`Shape`](api/Box3D.Shape.yml) | Collision geometry attached to a body. A body can carry several. | +| `Step` | Advances time. Everything happens here. | -## Body types +A body with no shapes has no mass and no geometry, so create the body and then +attach to it. + +## Three kinds of body ```csharp world.CreateStaticBody(position); // never moves. Level geometry. @@ -50,11 +55,9 @@ world.CreateKinematicBody(position); // you move it; it pushes others aside. ``` Static bodies are effectively free, so use them for everything that does not -move. A kinematic body is what a moving platform or a character controller wants: -drive it with `LinearVelocity` or `MoveTowards`, not `SetTransform`, so that -contacts push other bodies correctly. +move. See [Bodies](guides/bodies.md). -## Use a fixed time step +## Step at a fixed rate ```csharp world.Step(1.0f / 60.0f); // yes @@ -62,7 +65,7 @@ world.Step(deltaTime); // no ``` A varying step makes the simulation irreproducible and hurts stability. Decouple -it from your frame rate with an accumulator: +physics from your frame rate with an accumulator: ```csharp const float FixedStep = 1.0f / 60.0f; @@ -81,22 +84,19 @@ void Update(float deltaTime) } ``` -## Connect physics to your game +[The simulation step](concepts/step.md) covers sub-steps, sleeping and +continuous collision. + +## Get the results back Every body, shape and joint carries a `ulong` that Box3D stores and never reads. -Put an entity id or an array index in it: +Put an entity id or an array index in it, then read results back through it: ```csharp Body body = world.CreateDynamicBody(spawn); body.AddBox(Box.Cube(0.5f)); body.UserData = entityId; -``` -Then read results back through it. This is the efficient way — one contiguous -array holding only what actually moved, rather than asking every body every -frame: - -```csharp world.Step(FixedStep); foreach (BodyMoveEvent moved in world.Events.BodyMoves) @@ -107,202 +107,31 @@ foreach (BodyMoveEvent moved in world.Events.BodyMoves) } ``` -Shapes carry their own identifier, separate from the body's, which is what lets -a hit be attributed to a part rather than to the whole object: - -```csharp -head.UserData = (ulong)HitZone.Head; -torso.UserData = (ulong)HitZone.Torso; -``` - -## Shoot something - -```csharp -RaycastHit hit = world.RaycastClosest(muzzle, aim * 100.0f); - -if (hit.Hit) -{ - ulong entity = hit.Shape.Body.UserData; - var zone = (HitZone)hit.Shape.UserData; - - Damage(entity, zone == HitZone.Head ? 100 : 25); - SpawnDecal(hit.Point, hit.Normal); -} -``` - -`Fraction` is how far along the ray the hit was, from zero to one. Multiply by -the length of the vector you passed to get a distance. - -For anything more selective — ignoring the shooter, collecting every hit — -implement `IRaycastCallback` on a **struct**: - -```csharp -struct IgnoreSelf : IRaycastCallback -{ - public Body Self; - public RaycastHit Nearest; - - public RaycastAction OnHit(in RaycastHit hit) - { - if (hit.Shape.Body == Self) - { - return RaycastAction.Ignore; - } - - Nearest = hit; - return RaycastAction.ClipTo(hit.Fraction); // keep only closer hits - } -} - -var callback = new IgnoreSelf { Self = player }; -world.Raycast(muzzle, aim * 100.0f, ref callback); -``` - -A struct rather than a delegate so the query allocates nothing. See -[architecture](architecture.md#why-query-callbacks-are-structs). - -## React to collisions - -Events are opt-in per shape, because collecting them is not free: - -```csharp -var reporting = ShapeDefinition.Default with -{ - EnableContactEvents = true, - EnableHitEvents = true, -}; - -body.AddBox(Box.Cube(0.5f), reporting); -``` - -Then, after stepping: - -```csharp -foreach (ContactHitEvent hit in world.Events.ContactHits) -{ - PlayImpactSound(hit.Point, volume: hit.ApproachSpeed / 20.0f); -} -``` - -Events are valid only until the next step, so read what you need before stepping -again. It is safe to create and destroy bodies while walking them — that is why -Box3D buffers events instead of calling back mid-step. +`BodyMoves` is one contiguous list of what actually moved, which beats asking +every body every frame. See [Events](guides/events.md). -## Trigger volumes - -A sensor reports overlaps and never pushes anything: - -```csharp -Body trigger = world.CreateStaticBody(doorway); -trigger.AddBox(new Box(new Vector3(1.0f, 1.0f, 0.2f)), ShapeDefinition.Default with -{ - IsSensor = true, - EnableSensorEvents = true, - Density = 0.0f, // a sensor still weighs something otherwise -}); -``` - -The visitor needs `EnableSensorEvents` too. - -## Filter what collides with what - -```csharp -[Flags] -enum Layers : ulong -{ - World = 1 << 0, - Player = 1 << 1, - Enemy = 1 << 2, - Debris = 1 << 3, -} - -var playerFilter = new CollisionFilter -{ - Categories = (ulong)Layers.Player, - CollidesWith = (ulong)(Layers.World | Layers.Enemy), -}; - -body.AddCapsule(capsule, ShapeDefinition.Default with { Filter = playerFilter }); -``` - -For a one-off pair that must not collide — a projectile and the turret that -fired it — a filter joint is cheaper than spending a category bit: - -```csharp -world.CreateFilterJoint(FilterJointDefinition.Between(turret, shell)); -``` - -## Join things together - -```csharp -// A door that opens ninety degrees and swings shut. -RevoluteJoint hinge = world.CreateRevoluteJoint( - RevoluteJointDefinition.Hinge(frame, door, hingePoint, Vector3.UnitY) with - { - LimitsEnabled = true, - LowerAngle = 0.0f, - UpperAngle = MathF.PI * 0.5f, - MotorEnabled = true, - MotorSpeed = -1.0f, - MaxMotorTorque = 50.0f, - }); -``` - -The factory methods matter more than they look. A joint needs a *pair* of local -frames describing the same world pose from each body's point of view; get that -wrong and the joint starts out violated and snaps on the first step. `Hinge`, -`Slider`, `Between`, `BallAndSocket`, `Weld` and `Suspension` derive that pair -from a world-space anchor and axis. - -## Terrain - -For anything large and static, a height field stores far less than the -equivalent mesh — 20 KB against 289 KB for a 64 by 64 grid: - -```csharp -using var terrain = HeightField.FromHeights( - heights, - columnCount: 256, - rowCount: 256, - scale: new Vector3(1.0f, 100.0f, 1.0f)); - -Body ground = world.CreateStaticBody(); -ground.AddHeightField(terrain); -``` - -**Order matters here.** A height field and a mesh are *borrowed* by the shapes -built from them, not copied. Dispose the world before disposing them. See -[architecture](architecture.md#what-owns-what). - -## Things that will trip you up +## Where next -**Bad numbers throw.** Passing NaN or infinity raises an `ArgumentException` -rather than being accepted: +| | | +| --- | --- | +| [Bodies](guides/bodies.md) · [Shapes](guides/shapes.md) | The two types you will use most | +| [Queries](guides/queries.md) | Ray casts, overlaps, shape casts | +| [Events](guides/events.md) | Contacts, sensors, what moved | +| [Collision filtering](guides/collision-filtering.md) | What collides with what | +| [Joints](guides/joints.md) | Hinges, sliders, wheels, and six more | +| [Memory and ownership](concepts/ownership.md) | What to dispose, and in which order | +| [Examples](examples.md) | Sixteen runnable samples | -```csharp -body.LinearVelocity = new Vector3(float.NaN, 0, 0); // ArgumentException -``` +## Three things that catch people out -This is deliberate. Box3D validates with assertions that release builds compile -out, so without the check a single NaN spreads until every body in the world -reads NaN, and nothing can remove it. +**Non-finite values throw.** `body.LinearVelocity = new Vector3(float.NaN, 0, 0)` +raises an `ArgumentException` instead of being accepted. Box3D validates with +assertions that release builds compile out, so without the check one NaN spreads +until every body in the world reads NaN. **Create bodies where they belong.** Creating at the origin and moving afterwards costs nearly twice as much, and more once shapes are attached. -**A body with no shapes has no mass and no geometry.** Create the body, then -attach shapes. - **Meshes, height fields and baked compounds are static only.** Box3D only -generates their contacts against static bodies. Use a convex hull or several -primitives for something that moves — several shapes on one body is a run-time -compound, and it works on any body type. - -**Sleeping is a feature.** A settled scene costs almost nothing because bodies -fall asleep. `world.AwakeBodyCount` reaching zero means everything has settled. - -## Where next - -- [Architecture](architecture.md) — layers, ownership, and why the API is shaped this way -- [Benchmarks](benchmarks.md) — what the wrapper costs, measured -- `src/Box3D.NET.Samples` — sixteen runnable samples, each demonstrating one feature +generates their contacts against static bodies. See +[Terrain and meshes](guides/terrain.md). diff --git a/docs/guides/bodies.md b/docs/guides/bodies.md new file mode 100644 index 0000000..69c063c --- /dev/null +++ b/docs/guides/bodies.md @@ -0,0 +1,149 @@ +# Bodies + +A [`Body`](../api/Box3D.Body.yml) has a position, an orientation, a velocity and a +mass. It has no shape of its own: collision geometry is +[attached to it](shapes.md), and the mass comes from what you attach. + +## Body types + +| Type | Moved by | Mass | Pushed by others | Typical use | +| --- | --- | --- | --- | --- | +| `Static` | nothing | infinite | no | Level geometry, terrain | +| `Kinematic` | you | infinite | no | Platforms, lifts, characters | +| `Dynamic` | the solver | from its shapes | yes | Anything that falls over | + +Static bodies are effectively free, so use them for everything that does not +move; they do not collide with each other at all. A kinematic body pushes +dynamic bodies out of the way without being pushed back. + +```csharp +Body ground = world.CreateStaticBody(new Vector3(0.0f, -0.5f, 0.0f)); +Body crate = world.CreateDynamicBody(new Vector3(0.0f, 5.0f, 0.0f)); +Body lift = world.CreateKinematicBody(shaftBase); +``` + +For anything the three convenience methods do not cover, build a +[`BodyDefinition`](../api/Box3D.BodyDefinition.yml) and pass it to `CreateBody`: + +```csharp +Body projectile = world.CreateBody(BodyDefinition.Dynamic(muzzle) with +{ + LinearVelocity = aim * 80.0f, + IsBullet = true, + GravityScale = 0.5f, + Name = "shell", +}); +``` + +Create bodies where they belong. Creating at the origin and moving afterwards +costs nearly twice as much, and more once shapes are attached. + +## Moving a dynamic body + +Forces and impulses are what the solver expects. A force accumulates over the +step; an impulse changes velocity immediately. + +```csharp +crate.ApplyForceToCenter(new Vector3(0.0f, 200.0f, 0.0f)); // thruster +crate.ApplyImpulseToCenter(jump); // kick +crate.ApplyForce(wind, atPoint); // off-centre: also spins it +crate.ApplyTorque(new Vector3(0.0f, 5.0f, 0.0f)); +crate.ApplyAngularImpulse(spin); +``` + +Each takes a `wake` argument, defaulting to `true`. Applying a force to a +sleeping body without waking it does nothing. + +Setting `LinearVelocity` directly works and is sometimes what you want, but it +overrides the solver rather than cooperating with it: a body driven that way +walks through a stack instead of pushing it. + +## Moving a kinematic body + +Drive it with `LinearVelocity` or `MoveTowards`, not `SetTransform`: + +```csharp +lift.LinearVelocity = new Vector3(0.0f, 2.0f, 0.0f); +lift.MoveTowards(nextPosition, nextRotation, FixedStep); +``` + +`SetTransform` is a teleport: it does not sweep, so the body can pass through +geometry, and it is expensive. `MoveTowards` sets the velocity that arrives at +the target pose after one step, so the body keeps a real velocity and its +contacts push other bodies correctly. That is what makes a platform carry what +stands on it. + +## Mass + +Mass, centre of mass and rotational inertia are computed from the shapes on the +body and their [density](shapes.md#density-and-material). A body with no shapes +has neither mass nor geometry, so attach before you simulate. + +```csharp +Body body = world.CreateDynamicBody(spawn); +body.AddBox(Box.Cube(0.5f), ShapeDefinition.Default with { Density = 500.0f }); + +float kilograms = body.Mass; +Vector3 centre = body.CenterOfMass; // world space +Vector3 local = body.LocalCenterOfMass; // body space +``` + +Attaching or destroying a shape recomputes the mass by default. When several +shapes change at once, suppress it with `UpdateBodyMass = false` and call +`RecomputeMass` once at the end. + +## Sleeping + +A body that stops moving falls asleep and stops costing anything until something +touches it. This is on by default and is a large win: a settled scene of ten +thousand bodies steps in roughly the time an empty one does. + +```csharp +world.AwakeBodyCount // zero means the scene has settled +body.IsAwake = true; // wake it yourself +body.CanSleep = false; // this body is stepped every frame, always +``` + +Turn sleeping off only when something depends on a body being stepped every +frame. `SleepThreshold` on the definition sets the speed below which a body is +considered still. + +Sleeping is also the classic way to write a benchmark that measures nothing — +see [Benchmarks](../benchmarks.md#sleeping-bodies-measure-nothing). + +## Restricting motion + +```csharp +Body character = world.CreateDynamicBody(spawn); +character.MotionLocks = MotionLocks.NoRotation; // stays upright + +crate.MotionLocks = new MotionLocks { LinearZ = true, AngularX = true }; +``` + +[`MotionLocks`](../api/Box3D.MotionLocks.yml) removes degrees of freedom without +changing mass, which is how you get a body that slides but never tips. + +## Body space + +```csharp +Vector3 local = body.ToLocalPoint(worldPoint); +Vector3 world = body.ToWorldPoint(localPoint); +Vector3 direction = body.ToWorldVector(localDirection); // rotation only + +Vector3 velocity = body.GetVelocityAt(contactPoint); // includes spin +``` + +`GetVelocityAt` is the one to use for impact sounds and damage: a point on a +spinning body moves even when the body's centre does not. + +## Enabling, disabling, destroying + +```csharp +body.Disable(); // out of the simulation entirely; costs almost nothing +body.Enable(); +body.Destroy(); // gone, and every handle to it is now invalid +``` + +Disabling is the cheap way to park something you will need again. Destroying +invalidates every `Body` and `Shape` handle referring to it — see +[Handle validity](../concepts/handles.md). diff --git a/docs/guides/characters.md b/docs/guides/characters.md new file mode 100644 index 0000000..b0d19bd --- /dev/null +++ b/docs/guides/characters.md @@ -0,0 +1,122 @@ +# Characters + +Box3D.NET gives you the three engine-side primitives a kinematic character +controller needs, and stops there: + +```csharp +// 1. What is the capsule touching? +var gather = new GatherPlanes { Planes = buffer }; +world.CollideCapsule(capsule, position, ref gather); + +Span planes = buffer.AsSpan(0, gather.Count); + +// 2. Where can it actually go? +PlaneSolverResult result = CharacterMover.SolvePlanes(velocity * dt, planes); +position += result.Translation; + +// 3. Do not accumulate speed into a wall. +velocity = CharacterMover.ClipVelocity(velocity, planes); +``` + +Walk speed, jump height, what counts as ground, whether a slope is climbable — +that is game design, and every game answers it differently. Wrapping an opinion +about it here would be inventing policy Box3D deliberately left to the caller. + +`CharacterControllerSample` builds a complete controller on these three calls in +about eighty lines, with gravity, jumping, ground detection, slope limits and +wall sliding. Copy it and change the parts that are yours. + +## Gathering contacts + +[`CollideCapsule`](../api/Box3D.PhysicsWorld.yml) reports every surface the capsule is +touching, through a struct callback so that gathering them every frame allocates +nothing: + +```csharp +struct GatherPlanes : ICharacterCollisionCallback +{ + public CollisionPlane[] Planes; + public int Count; + + public bool OnContact(in CharacterContact contact) + { + if (Count < Planes.Length) + { + Planes[Count++] = CollisionPlane.From(contact); + } + + return true; // false stops gathering + } +} +``` + +The capsule is given relative to `origin`, which is the character's world +position. A [`CharacterContact`](../api/Box3D.CharacterContact.yml) carries the shape, +the plane normal, the offset and the contact point — **relative to the query +origin, not in world space**, which is the detail that makes a debug marker +appear in the wrong place the first time. + +## Solving + +[`SolvePlanes`](../api/Box3D.CharacterMover.yml) finds the translation closest to the +one requested that satisfies every plane. That is what makes a character slide +along a wall instead of stopping dead against it, and what keeps it out of the +corner where two walls meet. + +The solver writes back into the planes it was given: each one's `Push` reports +how far it had to move along that plane. `ClipVelocity` then projects the +velocity onto the planes that resisted, skipping the ones with no push. Without +that step a character walking into a wall keeps building velocity into it and +shoots sideways the moment the wall ends. + +## Tuning what a plane means + +[`CollisionPlane`](../api/Box3D.CollisionPlane.yml) is where policy goes: + +```csharp +CollisionPlane plane = CollisionPlane.From(contact); + +if (plane.Normal.Y < SlopeLimit) +{ + plane = plane with { ClipsVelocity = false }; // steep: do not slide down it +} + +plane = plane with { PushLimit = maxStepHeight }; // do not be shoved further than this +``` + +`PushLimit` bounds how far the solver may move the character along that plane, +and `ClipsVelocity` decides whether the plane takes part in velocity clipping at +all. Between them they cover step height, slope limits and one-way surfaces. + +## Casting instead of colliding + +```csharp +float fraction = world.CastCapsule(capsule, position, move); +``` + +A specialised shape cast that slides along surfaces instead of catching on them. +Good for a quick "can the character get there", poor as a source of information +about what it is touching — use `CollideCapsule` for that. + +## Choosing a body + +A character is usually a **kinematic** body: you own its position, and it pushes +dynamic bodies without being pushed back. Move it with `MoveTowards` so that its +contacts carry what stands on it, never with `SetTransform`. + +If the character is a dynamic body instead, lock its rotation so it cannot tip +over: + +```csharp +character.MotionLocks = MotionLocks.NoRotation; +``` + +Either way, turn off contact recycling on the character's body: + +```csharp +var def = BodyDefinition.Kinematic(spawn) with { EnableContactRecycling = false }; +``` + +Recycling reuses contact manifolds across small movements and is a performance +win, but it can produce ghost collisions. On a character a snagged step is more +noticeable than the cost. diff --git a/docs/guides/collision-filtering.md b/docs/guides/collision-filtering.md new file mode 100644 index 0000000..bf4ae3b --- /dev/null +++ b/docs/guides/collision-filtering.md @@ -0,0 +1,83 @@ +# Collision filtering + +Two shapes collide when each one's `Categories` appears in the other's +`CollidesWith`. The test is symmetric, so making one side ignore the other is +enough to disable the pair. + +```csharp +[Flags] +enum Layers : ulong +{ + World = 1 << 0, + Player = 1 << 1, + Enemy = 1 << 2, + Debris = 1 << 3, +} + +var playerFilter = new CollisionFilter +{ + Categories = (ulong)Layers.Player, + CollidesWith = (ulong)(Layers.World | Layers.Enemy), +}; + +body.AddCapsule(capsule, ShapeDefinition.Default with { Filter = playerFilter }); +``` + +A shape usually belongs to one category and collides with several. +[`CollisionFilter.Default`](../api/Box3D.CollisionFilter.yml) belongs to every +category and collides with everything, which is what a shape gets if you say +nothing. + +The same filter applies to [queries](queries.md), not only to shape-versus-shape +collision, so debris that ignores the player is also invisible to a ray cast +filtered to the player's categories. + +## Groups, for pairs and ragdolls + +A non-zero `Group` overrides the masks entirely: + +| Group | Effect | +| ---: | --- | +| negative | Shapes sharing it **never** collide | +| positive | Shapes sharing it **always** collide | +| zero | No effect; the masks decide | + +```csharp +// Every limb of one ragdoll ignores every other limb of the same ragdoll. +var filter = CollisionFilter.Default with { Group = -ragdollIndex }; +``` + +This is the cheap way to say "these objects never collide with each other" +without spending a category bit on them, and it scales: each ragdoll gets its +own negative number. + +## One pair, without spending anything + +For a single pair that must not collide — a projectile and the turret that fired +it — a filter joint is more direct than either mechanism: + +```csharp +world.CreateFilterJoint(FilterJointDefinition.Between(turret, shell)); +``` + +As a side effect the two bodies stay in the same simulation island, so they +sleep and wake together. + +## Changing a filter later + +```csharp +shape.SetFilter(newFilter); // recomputes contacts +shape.SetFilter(newFilter, recomputeContacts: false); +``` + +Recomputing is what makes a change take effect on contacts that already exist. +Skip it only when you are about to move the shape anyway. + +## Choosing between the three + +| You want | Use | +| --- | --- | +| Layers: players, enemies, world, debris | `Categories` and `CollidesWith` | +| A set of objects that ignore each other | Negative `Group` | +| Exactly two bodies that ignore each other | [`FilterJoint`](joints.md) | +| A shape that detects but never pushes | [Sensor](shapes.md#sensors) | diff --git a/docs/guides/debug-draw.md b/docs/guides/debug-draw.md new file mode 100644 index 0000000..e7b9809 --- /dev/null +++ b/docs/guides/debug-draw.md @@ -0,0 +1,119 @@ +# Debug draw + +Box3D can draw what it is simulating — shapes, contacts, joints, bounds, islands +— into whatever renderer you already have. It knows nothing about OpenGL, +Vulkan, Unity or Godot: it hands over points, segments and boxes in world space +and you decide what a line looks like. + +Two interfaces, and a drawn frame allocates nothing. + +## The two halves + +| | Called | Job | +| --- | --- | --- | +| [`IDebugShapeFactory`](../api/Box3D.IDebugShapeFactory.yml) | once per shape | Turn a shape into a drawable and return an opaque handle | +| [`IDebugDrawer`](../api/Box3D.IDebugDrawer.yml) | every frame | Receive the handles with their transforms, plus every other primitive | + +The split is what makes debug draw usable rather than a slideshow: a real +renderer uploads a mesh once in the factory and issues a draw call per frame in +the drawer. + +The factory has to be supplied when the world is built, because Box3D needs +those callbacks at construction time: + +```csharp +var factory = new MyShapeFactory(); +using var world = new PhysicsWorld(WorldSettings.Default, factory); +``` + +`DestroyShape` is called when a shape is modified or destroyed, and for +everything still alive when the world is disposed. Neither factory method may +touch the world. + +## Drawing a frame + +```csharp +var drawer = new MyDrawer(renderer); + +world.Draw(ref drawer, DebugDrawOptions.Default with +{ + DrawShapes = true, + DrawJoints = true, +}); +``` + +Implement the drawer on a **struct** passed by `ref`. The calls arrive through +function pointers with no delegate, no closure and no boxing, so the JIT can +inline them and the frame allocates nothing — provided your implementation does +not allocate either. + +Every method is called synchronously, on the thread that called `Draw`, and none +of them may touch the world being drawn. Reading or writing the simulation from +inside a draw callback is the same race as doing it mid-step. + +A drawer that has no shape factory can leave `DrawShape` empty and still get +every segment, point and box. + +## What can be drawn + +`DebugDrawOptions.Default` has everything off. Turn on what you want to see: + +| Option | Shows | +| --- | --- | +| `DrawShapes` | The geometry itself, through the factory | +| `DrawBounds` | Broad-phase bounding boxes | +| `DrawMass` | Centres of mass, with the mass as text | +| `DrawSleep` | Which bodies are asleep | +| `DrawJoints`, `DrawJointExtras`, `DrawAnchorA` | Joint frames, and how hard they are working | +| `DrawContacts`, `DrawContactNormals`, `DrawContactFeatures`, `DrawContactForces` | What the solver is working with | +| `DrawIslands`, `DrawGraphColors` | How the solver has partitioned the scene | +| `DrawBodyNames` | The `Name` from each body's definition | + +`ForceScale` and `JointScale` set how long the force and joint markers are; +`Bounds` clips drawing to a region, which is how you draw only what the camera +can see. + +## Drawing part of a world + +`CategoryMask` restricts a call to some [collision +categories](collision-filtering.md), and the options apply to the whole call, so +seeing annotations on some bodies and not others means two calls: + +```csharp +// Everything, plainly. +world.Draw(ref drawer, DebugDrawOptions.Default with { DrawShapes = true }); + +// Contacts and bounds, but only for the dynamic bodies. +world.Draw(ref drawer, DebugDrawOptions.Default with +{ + DrawContacts = true, + DrawContactNormals = true, + DrawBounds = true, + CategoryMask = (ulong)Layers.Dynamic, +}); +``` + +Without the mask, the floor's bounding box — eighty metres across — is the only +thing in the picture. + +## Text and colour + +Box3D emits labels through `DrawString`: the mass over a body, the separation at +a contact point. It uses the ninety-five printable ASCII characters and nothing +else, so a bitmap font is enough. + +[`DebugColor`](../api/Box3D.DebugColor.yml) is the suggested colour, packed as +`0xMMRRGGBB` — the top byte is a +[`DebugMaterial`](../api/Box3D.DebugMaterial.yml) hint, and `ToUnitRgb` gives the +three floats a shader wants. Suggested is the operative word: a drawer is free +to ignore it. + +## A worked implementation + +`src/Box3D.NET.Visualizer` is a complete drawer: a software rasterizer with its +own PNG and GIF writers, no dependencies beyond the base class library, using +`Box3D.NET` through these two interfaces and no privileged access. Every picture +in the [gallery](../gallery.md) comes out of it. + +The smaller `debug-draw` [sample](../examples.md) is the shorter read if all you +want is the shape of the code. diff --git a/docs/guides/events.md b/docs/guides/events.md new file mode 100644 index 0000000..30fe809 --- /dev/null +++ b/docs/guides/events.md @@ -0,0 +1,135 @@ +# Events + +Box3D buffers what happened during a step and hands it back afterwards. +[`world.Events`](../api/Box3D.WorldEvents.yml) exposes six lists as views over engine +memory, so reading a frame's worth allocates nothing. + +```csharp +world.Step(FixedStep); + +foreach (ContactHitEvent hit in world.Events.ContactHits) +{ + PlayImpactSound(hit.Point, volume: hit.ApproachSpeed / 20.0f); +} +``` + +| List | Raised when | Needs | +| --- | --- | --- | +| `ContactBegins` | two shapes start touching | `EnableContactEvents` | +| `ContactEnds` | two shapes stop touching | `EnableContactEvents` | +| `ContactHits` | an impact above the world's hit threshold | `EnableHitEvents` | +| `SensorBegins` | a shape enters a sensor | `EnableSensorEvents` on both | +| `SensorEnds` | a shape leaves a sensor | `EnableSensorEvents` on both | +| `BodyMoves` | a body moved during the step | nothing | + +## Events are opt-in + +Collecting them is not free, so every list except `BodyMoves` is off until a +shape asks for it: + +```csharp +var reporting = ShapeDefinition.Default with +{ + EnableContactEvents = true, + EnableHitEvents = true, +}; + +body.AddBox(Box.Cube(0.5f), reporting); +``` + +Turn on what you read, and nothing else. A world where every shape reports every +contact spends real time filling lists nobody walks. + +## What moved + +`BodyMoves` is the list to drive rendering from. It contains only the bodies +that actually moved, so a settled scene produces nothing: + +```csharp +foreach (BodyMoveEvent moved in world.Events.BodyMoves) +{ + ref Transform transform = ref transforms[moved.Body.UserData]; + transform.Position = moved.Position; + transform.Rotation = moved.Rotation; + + if (moved.FellAsleep) + { + StopAnimating(moved.Body.UserData); + } +} +``` + +`FellAsleep` is the last report you get about that body until something wakes +it, which makes it the natural place to release whatever you were spending on +it. + +## Impacts + +A hit event is what you want for impact sounds and damage. It carries the point, +the normal and the closing speed, plus each surface's +[`UserMaterialId`](shapes.md#density-and-material): + +```csharp +foreach (ContactHitEvent hit in world.Events.ContactHits) +{ + Material surface = Materials[hit.UserMaterialIdA]; + PlayImpactSound(surface, hit.Point, hit.ApproachSpeed); +} +``` + +Only impacts above `WorldSettings.HitEventThreshold` are reported, which is what +keeps a settling stack from firing a hundred of them. + +## Begin and end touch + +`ContactBegins` and `ContactEnds` are for state: a foot on the ground, a card in +a slot, a fuse burning while two things touch. + +An end-touch event is often raised *because* a shape was destroyed, so check +before using the handle: + +```csharp +foreach (ContactEndEvent touch in world.Events.ContactEnds) +{ + if (touch.ShapeA.IsValid && touch.ShapeB.IsValid) + { + Separate(touch.ShapeA, touch.ShapeB); + } +} +``` + +`IsValid` never throws, so asking is always safe. See +[Handle validity](../concepts/handles.md). + +## Sensors + +A sensor reports overlaps without pushing anything. Both the sensor and the +visitor need `EnableSensorEvents`: + +```csharp +Body trigger = world.CreateStaticBody(doorway); +trigger.AddBox(volume, ShapeDefinition.Default with +{ + IsSensor = true, + EnableSensorEvents = true, + Density = 0.0f, +}); + +foreach (SensorBeginEvent entered in world.Events.SensorBegins) +{ + OpenDoor(entered.Sensor.UserData, by: entered.Visitor.Body.UserData); +} +``` + +Sensors have no continuous collision, so something fast enough to cross the +volume within one step passes through unreported. Use a +[shape cast](queries.md) for a trigger that has to catch a bullet. + +## Lifetime + +Events are valid only until the next `Step`. Read what you need before stepping +again; copy anything you want to keep. + +It is safe to create and destroy bodies while walking the lists — that is the +whole reason Box3D buffers events instead of calling back mid-step. Be aware +that doing so can invalidate handles carried by events you have not read yet. diff --git a/docs/guides/joints.md b/docs/guides/joints.md new file mode 100644 index 0000000..4a738e0 --- /dev/null +++ b/docs/guides/joints.md @@ -0,0 +1,149 @@ +# Joints + +A joint constrains how two bodies may move relative to each other. There are +nine, each with its own handle type and its own definition. + +```csharp +// A door that opens ninety degrees and swings shut behind you. +RevoluteJoint hinge = world.CreateRevoluteJoint( + RevoluteJointDefinition.Hinge(frame, door, hingePoint, Vector3.UnitY) with + { + LimitsEnabled = true, + LowerAngle = 0.0f, + UpperAngle = MathF.PI * 0.5f, + MotorEnabled = true, + MotorSpeed = -1.0f, + MaxMotorTorque = 50.0f, + }); +``` + +## The nine + +| Joint | Leaves free | Built with | For | +| --- | --- | --- | --- | +| `Revolute` | one rotation axis | `Hinge(a, b, anchor, axis)` | Doors, wheels, chains, ragdoll elbows | +| `Prismatic` | one translation axis | `Slider(a, b, anchor, axis)` | Lifts, pistons, drawers | +| `Distance` | everything, at a fixed range | `Between(a, b, anchorA, anchorB)` | Ropes, springs, struts | +| `Spherical` | all three rotations | `BallAndSocket(a, b, anchor, axis)` | Shoulders, hips, pendulums | +| `Weld` | nothing | `Weld(a, b, anchor)` | Rigid assemblies, breakable joins | +| `Wheel` | spin plus suspension travel | `Suspension(chassis, wheel, anchor, axis)` | Vehicles | +| `Motor` | everything, while driving a target pose | `MotorJointDefinition` | Followers, mouse dragging, active props | +| `Parallel` | everything but the frame's z axis | `ParallelJointDefinition` | Keeping something upright without locking it | +| `Filter` | everything | `Between(a, b)` | Two bodies that must not collide | + +Each `Create…Joint` returns the specific handle, not a generic one, so +`hinge.MotorSpeed` compiles and `hinge.MinLength` does not. The shared members +are one hop away through `hinge.AsJoint`. + +## Use the factory methods + +A joint needs a *pair* of local frames describing the same world pose from each +body's point of view. Get that wrong and the joint starts out violated and snaps +on the first step. + +`Hinge`, `Slider`, `Between`, `BallAndSocket`, `Weld` and `Suspension` derive +that pair from a world-space anchor and axis, which is how you would describe +the joint out loud. For the joints without a factory, or for a frame you want to +build yourself, [`Joint.FramesFromWorldAnchor`](../api/Box3D.Joint.yml) does the same +calculation. + +Build the assembly in its rest pose. A chain assembled already displaced has +every joint violated on the first step and snaps — give it angular velocity +instead. + +## Limits and motors + +Most joints take limits, a motor, or both. The pattern is the same everywhere: +an `…Enabled` flag, the range, and a maximum force or torque the motor may +spend. + +```csharp +// A lift that travels four metres straight up. +PrismaticJoint lift = world.CreatePrismaticJoint( + PrismaticJointDefinition.Slider(shaft, platform, basePoint, Vector3.UnitY) with + { + LimitsEnabled = true, + LowerTranslation = 0.0f, + UpperTranslation = 4.0f, + MotorEnabled = true, + MotorSpeed = 1.0f, + MaxMotorForce = 5000.0f, + }); +``` + +| Joint | Limits | Motor | +| --- | --- | --- | +| `Revolute` | `LowerAngle`, `UpperAngle` | `MotorSpeed`, `MaxMotorTorque` | +| `Prismatic` | `LowerTranslation`, `UpperTranslation` | `MotorSpeed`, `MaxMotorForce` | +| `Distance` | `MinLength`, `MaxLength` | `MotorSpeed`, `MaxMotorForce` | +| `Spherical` | `ConeAngle`, `LowerTwistAngle`, `UpperTwistAngle` | `MotorVelocity`, `MaxMotorTorque` | +| `Wheel` | suspension and steering, each with its own pair | `SpinSpeed`/`MaxSpinTorque`, `TargetSteeringAngle`/`MaxSteeringTorque` | + +A motor with an unlimited maximum will hold anything, including things it should +not. The maximum is what makes a door closer stop when you push against it. + +## Springs + +Several joints can be soft rather than rigid, described in hertz and a damping +ratio rather than in stiffness: + +```csharp +var rope = DistanceJointDefinition.Between(anchor, load, top, hook) with +{ + SpringEnabled = true, + Hertz = 4.0f, // how fast it oscillates + DampingRatio = 0.5f, // 1.0 is critically damped + LimitsEnabled = true, + MinLength = 0.1f, + MaxLength = 3.0f, +}; +``` + +A wheel is the same idea twice under different names: `SuspensionHertz` and +`SuspensionDampingRatio` for the travel, `SteeringHertz` and +`SteeringDampingRatio` for how sharply it turns to a target angle. `Parallel` +uses a spring to keep two frames' z axes aligned, which is how you keep a body +upright without locking its rotation outright. + +## Reading a joint back + +```csharp +Vector3 force = hinge.AsJoint.ConstraintForce; +float drift = hinge.AsJoint.LinearSeparation; + +if (force.Length() > BreakingForce) +{ + hinge.AsJoint.Destroy(); +} +``` + +`ForceThreshold` and `TorqueThreshold` on the base definition make the engine +report when a joint is overloaded, which is the ingredient for something that +breaks under load. + +## Bodies that should not collide + +Connected bodies do not collide by default. Set `CollideConnected` on the base +definition when they should — a wheel that must still hit the ground it sits on. + +For two bodies that are not jointed at all but must not collide, use a filter +joint rather than spending a [category bit](collision-filtering.md): + +```csharp +world.CreateFilterJoint(FilterJointDefinition.Between(turret, shell)); +``` + +## Tuning + +Every joint carries `ConstraintHertz` and `ConstraintDampingRatio` on its base +definition, which control how hard the solver works to hold it together. Raise +the hertz for an assembly that visibly stretches under load; lower it for one +that jitters. + +```csharp +joint.SetConstraintTuning(hertz: 60.0f, dampingRatio: 2.0f); +``` + +`DrawScale` decides how large the joint's markers are when +[drawn](debug-draw.md). Joints are one of the things worth seeing before you +believe them. diff --git a/docs/guides/queries.md b/docs/guides/queries.md new file mode 100644 index 0000000..9ccb929 --- /dev/null +++ b/docs/guides/queries.md @@ -0,0 +1,148 @@ +# Queries + +Asking the world what is there, without stepping it. Every query on this page +allocates nothing, including the callback forms. + +| Question | Call | +| --- | --- | +| What does this ray hit first? | `world.RaycastClosest` | +| What does this ray pass through? | `world.Raycast` with a callback | +| What is inside this box? | `world.OverlapBox`, `world.OverlapBounds` | +| Does this ray hit *that* shape? | `shape.Raycast` | +| How far can this capsule move? | `world.CastCapsule` | +| What is this capsule touching? | `world.CollideCapsule` — see [Characters](characters.md) | + +## The closest hit + +```csharp +RaycastHit hit = world.RaycastClosest(muzzle, aim * 100.0f); + +if (hit.Hit) +{ + ulong entity = hit.Shape.Body.UserData; + var zone = (HitZone)hit.Shape.UserData; + + Damage(entity, zone == HitZone.Head ? 100 : 25); + SpawnDecal(hit.Point, hit.Normal); +} +``` + +The second argument is a vector, not a direction: its length is the range. +`Fraction` is how far along it the hit was, from zero to one, so multiply by that +vector's length to get a distance. + +[`RaycastHit`](../api/Box3D.RaycastHit.yml) also carries `TriangleIndex` and +`ChildIndex` for hits against meshes and compounds, and `UserMaterialId` for +whatever you tagged the surface with. + +## Ray casts that need a decision per hit + +Ignoring the shooter, collecting every hit, stopping early — implement +[`IRaycastCallback`](../api/Box3D.IRaycastCallback.yml) on a **struct**: + +```csharp +struct IgnoreSelf : IRaycastCallback +{ + public Body Self; + public RaycastHit Nearest; + + public RaycastAction OnHit(in RaycastHit hit) + { + if (hit.Shape.Body == Self) + { + return RaycastAction.Ignore; + } + + Nearest = hit; + return RaycastAction.ClipTo(hit.Fraction); // keep only closer hits + } +} + +var callback = new IgnoreSelf { Self = player }; +world.Raycast(muzzle, aim * 100.0f, ref callback); +``` + +Shapes arrive in no particular order. What you return decides what happens next: + +| [`RaycastAction`](../api/Box3D.RaycastAction.yml) | Effect | +| --- | --- | +| `Ignore` | Pretend the shape is not there and carry on | +| `ClipTo(hit.Fraction)` | Shorten the ray to end here; only closer shapes are reported afterwards | +| `Continue` | Record it and carry on, near and far | +| `Stop` | End the cast now, keeping what was gathered | + +Returning `ClipTo` from every hit is a closest-hit search, which is what +`RaycastClosest` does for you. + +A struct rather than a delegate, because the query is generic over the callback +type: the JIT specialises it and inlines your `OnHit` into the dispatcher, so +nothing is allocated and nothing has to be kept alive across the native +transition. See +[the native layer](../concepts/native-layer.md#why-callbacks-are-structs). + +The world is locked while a query runs. Collect what you need and create or +destroy bodies after it returns. + +## Overlaps + +```csharp +struct CountBodies : IOverlapCallback +{ + public int Count; + + public bool OnOverlap(Shape shape) + { + Count++; + return true; // false stops the search + } +} + +var callback = new CountBodies(); +world.OverlapBox(explosionCentre, new Vector3(5.0f), ref callback); +``` + +This is a broad-phase query: it tests bounding boxes, not geometry, so it can +report a shape whose box overlaps and whose geometry does not. Narrow it +yourself when you need exactness — a distance test against +`shape.ClosestPointTo` is usually enough. + +`OverlapBounds` takes a [`BoundingBox`](../api/Box3D.BoundingBox.yml) directly, which +is the form to use when you already have one from `body.Bounds` or +`shape.Bounds`. + +## Filtering a query + +Every query takes an optional [`QueryFilter`](../api/Box3D.QueryFilter.yml), which +works exactly like a [collision filter](collision-filtering.md): the query sees a +shape when the categories agree in both directions. + +```csharp +var visibleToBullets = new QueryFilter( + categories: (ulong)Layers.Bullet, + collidesWith: (ulong)(Layers.World | Layers.Enemy)); + +world.RaycastClosest(muzzle, aim * 100.0f, visibleToBullets); +``` + +Passing `null`, which is the default, tests against everything. + +## Casting against one shape + +```csharp +RaycastHit hit = shape.Raycast(origin, direction * 10.0f); +``` + +No broad phase, no filter — just this shape. Useful when you already know what +you are testing against, such as re-checking the shape a previous query +returned. + +## Explosions + +Not a query, but it belongs with them: `Explode` applies a radial impulse to +everything within reach, scaled by how much of each shape faces the blast. + +```csharp +world.Explode(centre, radius: 5.0f, impulsePerArea: 300.0f, falloff: 2.0f); +``` + +Only spheres, capsules and hulls respond. A negative `impulsePerArea` implodes. diff --git a/docs/guides/shapes.md b/docs/guides/shapes.md new file mode 100644 index 0000000..58330f0 --- /dev/null +++ b/docs/guides/shapes.md @@ -0,0 +1,162 @@ +# Shapes + +A [`Shape`](../api/Box3D.Shape.yml) is collision geometry attached to a body. A body +can carry as many as it needs, and each one has its own material, filter and +events. + +```csharp +Body body = world.CreateDynamicBody(spawn); + +body.AddSphere(new Sphere(0.5f)); +body.AddBox(Box.Cube(0.5f)); +body.AddCapsule(Capsule.Upright(height: 1.8f, radius: 0.3f)); +``` + +## The geometry kinds + +| Geometry | Built with | Copied on attach | Body types | Dispose | +| --- | --- | --- | --- | --- | +| [`Sphere`](../api/Box3D.Sphere.yml) | value | yes | any | — | +| [`Capsule`](../api/Box3D.Capsule.yml) | value | yes | any | — | +| [`Box`](../api/Box3D.Box.yml) | value | yes | any | — | +| [`ConvexHull`](../api/Box3D.ConvexHull.yml) | `FromPoints`, `Cylinder`, `Cone`, `Rock` | yes, interned in the world | any | any time | +| [`CollisionMesh`](../api/Box3D.CollisionMesh.yml) | `FromTriangles`, `Grid`, `Wave` | **no, borrowed** | static only | **after the world** | +| [`HeightField`](../api/Box3D.HeightField.yml) | `FromHeights`, `Grid`, `Wave` | **no, borrowed** | static only | **after the world** | +| [`CompoundGeometry`](../api/Box3D.CompoundGeometry.yml) | `CompoundBuilder` | **no, borrowed** | static only | **after the world** | + +The first four are the ones to reach for on anything that moves. The last three +are covered in [Terrain and meshes](terrain.md), and their lifetime rules in +[Memory and ownership](../concepts/ownership.md). + +## Primitives + +The three value types have constructors and a few factories for the shapes +people actually build: + +```csharp +new Sphere(0.5f); // centred on the body origin +new Sphere(offset, 0.5f); + +Box.Cube(0.5f); // half-extent, so a 1m cube +Box.FromSize(new Vector3(2.0f, 0.1f, 2.0f)); // full size instead +new Box(halfExtents, centre); + +Capsule.Upright(height: 1.8f, radius: 0.3f); // a character +new Capsule(start, end, radius); // any orientation +``` + +`Box` is described by half-extents, which is a common source of a level that +comes out twice the intended size. `FromSize` takes the measurement you would +read off a model. + +## Convex hulls + +A hull is how a moving body gets a shape that is not a primitive: meshes and +height fields are static only, so a rock, a wheel or a crate with a chamfered +edge is a hull. + +```csharp +using ConvexHull rock = ConvexHull.Rock(0.4f); +using ConvexHull wheel = ConvexHull.Cylinder(height: 0.2f, radius: 0.35f); +using ConvexHull custom = ConvexHull.FromPoints(vertices, maxVertexCount: 32); + +body.AddHull(rock); +``` + +A hull is interned into the world when it is attached, so it may be disposed as +soon as the shape exists. That is the one disposable geometry with no ordering +rule attached to it. + +## Density and material + +Density gives the body its mass; the material decides how the surface behaves. + +```csharp +body.AddBox(Box.Cube(0.5f), ShapeDefinition.Default with +{ + Density = 500.0f, + Material = PhysicsMaterial.Default with + { + Friction = 0.9f, + Restitution = 0.4f, + }, +}); +``` + +[`PhysicsMaterial`](../api/Box3D.PhysicsMaterial.yml) carries five things: + +| | | +| --- | --- | +| `Friction` | Resistance to sliding | +| `Restitution` | Bounciness | +| `RollingResistance` | Resistance to rolling, which is what stops a ball rolling forever | +| `SurfaceVelocity` | A surface that moves without the body moving — a conveyor | +| `UserMaterialId` | Your own identifier, handed back by ray casts and hit events | + +`UserMaterialId` is the way to answer "what did this hit sound like": tag the ice +and the gravel when you build them, then read +[`RaycastHit.UserMaterialId`](../api/Box3D.RaycastHit.yml) or the `UserMaterialIdA` +and `UserMaterialIdB` on a [hit event](events.md#impacts). + +Density can be changed after the fact, and a shape can be told not to recompute +the body's mass while several change at once: + +```csharp +shape.SetDensity(200.0f, updateBodyMass: false); +// ... more changes ... +body.RecomputeMass(); +``` + +## Several shapes on one body + +Attaching more than one shape is a run-time compound, and it works on any body +type. This is how you build an L-shaped piece, a character with a separate head +volume, or a vehicle chassis with a bumper. + +```csharp +Body character = world.CreateDynamicBody(spawn); + +Shape torso = character.AddCapsule(Capsule.Upright(1.4f, 0.3f)); +Shape head = character.AddSphere(new Sphere(new Vector3(0.0f, 1.5f, 0.0f), 0.2f)); + +torso.UserData = (ulong)HitZone.Torso; +head.UserData = (ulong)HitZone.Head; +``` + +Each shape keeps its own identifier, filter, material and events, which is what +lets a hit be attributed to a part rather than to the whole object. The cost is +one broad-phase proxy per shape. + +When the geometry is large, static and never needs to be told apart — a +colonnade, a rock field — bake it into a single +[`CompoundGeometry`](terrain.md#baked-compounds) instead and pay for one proxy. + +## Sensors + +A sensor reports overlaps and never pushes anything: + +```csharp +Body trigger = world.CreateStaticBody(doorway); + +trigger.AddBox(new Box(new Vector3(1.0f, 1.0f, 0.2f)), ShapeDefinition.Default with +{ + IsSensor = true, + EnableSensorEvents = true, + Density = 0.0f, // a sensor still weighs something otherwise +}); +``` + +The visitor needs `EnableSensorEvents` too. See [Events](events.md#sensors). + +## Changing a shape later + +```csharp +shape.Friction = 0.2f; +shape.Restitution = 0.6f; +shape.SetFilter(newFilter); // recomputes contacts by default +shape.SetDensity(300.0f); +shape.Destroy(); // updates the body's mass by default +``` + +`ApplyWind` is there too, for shapes that should be pushed by air rather than by +contacts. diff --git a/docs/guides/terrain.md b/docs/guides/terrain.md new file mode 100644 index 0000000..d0475f1 --- /dev/null +++ b/docs/guides/terrain.md @@ -0,0 +1,131 @@ +# Terrain and meshes + +Three kinds of geometry exist for large static scenery: height fields, triangle +meshes and baked compounds. All three are **static only** — Box3D generates +their contacts against static bodies — and all three are **borrowed** rather +than copied. + +> [!IMPORTANT] +> A shape holds a pointer into the geometry it was built from. Dispose the world +> first, then the geometry. Doing it the other way round is a use-after-free +> inside the solver, not an exception. See +> [Memory and ownership](../concepts/ownership.md). + +```csharp +using var terrain = HeightField.FromHeights(heights, 256, 256, scale); + +using (var world = new PhysicsWorld()) +{ + world.CreateStaticBody().AddHeightField(terrain); + Simulate(world); +} +// World disposed here, terrain after. Never the other way round. +``` + +## Choosing between them + +| | Describes | Cost | Use for | +| --- | --- | --- | --- | +| [`HeightField`](../api/Box3D.HeightField.yml) | one height per grid point | smallest, queries fastest | Outdoor terrain | +| [`CollisionMesh`](../api/Box3D.CollisionMesh.yml) | arbitrary triangles | one entry per triangle | Buildings, ramps, anything with an underside | +| [`CompoundGeometry`](../api/Box3D.CompoundGeometry.yml) | many primitives baked into one | one broad-phase proxy for the lot | Rock fields, colonnades, clutter | + +A height field cannot describe a cave or an overhang. That is the whole +trade-off: it is a height map, and in exchange it stores one number per grid +point instead of triangles. + +## Height fields + +```csharp +using var terrain = HeightField.FromHeights( + heights, + columnCount: 256, // grid lines along x + rowCount: 256, // grid lines along z + scale: new Vector3(1.0f, 100.0f, 1.0f)); // 1 m cells, 100 m of relief + +Body ground = world.CreateStaticBody(); +ground.AddHeightField(terrain); +``` + +Three things about height fields surprise people: + +**They start at the body's origin** and extend into positive x and z rather than +being centred. A 64 by 64 grid of two-metre cells covers 0 to 128 on both axes. +Position the body to place it, or read `Bounds` to find out where it ended up. + +**Heights are quantized** against a minimum and maximum. Two fields that must +line up along an edge have to be built with the same `minimumHeight` and +`maximumHeight`, or their steps differ and a seam appears. + +**Holes are a material.** Passing `HeightField.HoleMaterial` for a cell removes +it, which is how you cut a cave mouth or a shaft into otherwise solid terrain. + +`HeightField.Grid` and `HeightField.Wave` build test terrain without a height +map, which is what most of the [samples](../examples.md) use. + +## Triangle meshes + +```csharp +using var level = CollisionMesh.FromTriangles(vertices, indices); + +Body geometry = world.CreateStaticBody(); +geometry.AddMesh(level); +geometry.AddMesh(level, scale: new Vector3(-1.0f, 1.0f, 1.0f)); // mirrored +``` + +Indices are three per triangle, wound counter-clockwise seen from the side the +surface faces. **Winding decides which side is solid**, so a mesh built the wrong +way round lets bodies fall through from above while stopping them from below. +The inputs are copied, so the arrays can be released as soon as the call +returns. + +[`MeshOptions`](../api/Box3D.MeshOptions.yml) controls how the mesh is prepared: + +| Option | Effect | +| --- | --- | +| `WeldVertices`, `WeldTolerance` | Merge vertices that coincide, so shared edges are recognised | +| `IdentifyEdges` | Mark internal edges, which stops bodies catching on triangle boundaries | +| `UseMedianSplit` | A faster build with a slightly worse tree | + +`MeshOptions.Fast` is the preset for content built at run time, where build time +matters more than query time. `DegenerateTriangleCount` reports how many +triangles the build had to discard — a non-zero count on content you exported +means the exporter did something you did not intend. + +The `Grid`, `Wave`, `HollowBox` and `BoxMesh` factories build meshes directly, +which saves writing vertex arrays by hand in tests and samples. + +## Baked compounds + +A compound bakes many primitives into a single shape with one broad-phase proxy: + +```csharp +using CompoundGeometry colonnade = new CompoundBuilder() + .AddMesh(plinth, Vector3.Zero) + .AddHull(column, new Vector3(0.0f, 0.5f, 0.0f)) + .AddCapsule(lintel) + .AddSphere(capital) + .Build(); + +Body scenery = world.CreateStaticBody(); +scenery.AddCompound(colonnade); +``` + +The whole compound arrives as one shape: one filter, one set of events, one +user data, one colour when drawn. Per-child materials are fixed when it is +baked, and the children cannot be told apart from outside. + +That is the trade. For geometry that has to be told apart, or on a body that +moves, [attach shapes one at a time](shapes.md#several-shapes-on-one-body) +instead. + +## Measuring what they cost + +All three expose `ByteCount`, which reports what the engine actually allocated: + +```csharp +Console.WriteLine($"{terrain.ByteCount / 1024} KB"); +``` + +That is the honest way to decide between a height field and the equivalent mesh +for your own content, rather than taking a rule of thumb on trust. diff --git a/docs/guides/toc.yml b/docs/guides/toc.yml new file mode 100644 index 0000000..8284a7d --- /dev/null +++ b/docs/guides/toc.yml @@ -0,0 +1,18 @@ +- name: Bodies + href: bodies.md +- name: Shapes + href: shapes.md +- name: Collision filtering + href: collision-filtering.md +- name: Queries + href: queries.md +- name: Events + href: events.md +- name: Joints + href: joints.md +- name: Terrain and meshes + href: terrain.md +- name: Characters + href: characters.md +- name: Debug draw + href: debug-draw.md diff --git a/docs/index.md b/docs/index.md index 81c6baa..e04ffcd 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,8 +4,13 @@ _layout: landing # Box3D.NET -An idiomatic, allocation-free C# binding for [Box3D](https://github.com/erincatto/box3d), -the 3D physics engine by Erin Catto. +An idiomatic C# binding for [Box3D](https://github.com/erincatto/box3d), the 3D +physics engine by Erin Catto, with no managed allocations on the simulation hot +path. + +```sh +dotnet add package Box3D.NET +``` ```csharp using var world = new PhysicsWorld(); @@ -20,28 +25,64 @@ for (int frame = 0; frame < 120; frame++) { world.Step(1.0f / 60.0f); } + +Console.WriteLine(ball.Position); // resting on the ground ``` -## Start here +That is the whole API for a first simulation: a world, some bodies, shapes on +them, and a step. Everything else is opt-in. + +[**Getting started**](getting-started.md) · +[Guides](guides/bodies.md) · +[Examples](examples.md) · +[API reference](api/index.md) -- **[Getting started](getting-started.md)** — from nothing to a simulation, and the - handful of things that will otherwise trip you up. -- **[Gallery](gallery.md)** — eight scenes, animated, drawn through the public - debug draw interface by a renderer with no privileged access. -- **[Architecture](architecture.md)** — the layers, what owns what, and why the API - is shaped the way it is. -- **[Benchmarks](benchmarks.md)** — what the wrapper costs against calling the C API - directly. Measured, not claimed. -- **[API reference](api/index.md)** — every public type. +[![A pyramid of crates struck by a heavy ball](../assets/renders/stack.png)](gallery.md) -## What it is +## What you get + +- Worlds, bodies, shapes, queries, events, all nine joint types, meshes, height + fields, baked compounds, the character mover and debug draw. +- Zero managed allocations on the simulation path, including query callbacks, + event enumeration and a drawn debug frame. Held to it by tests, not by + assertion. +- `System.Numerics` types at the boundary, so no conversion between physics and + your renderer. +- .NET 8 or later, Windows, Linux and macOS, x64 and arm64, NativeAOT and + trimming. +- No dependencies beyond the base class library. The native binary ships in the + package. + +## Two packages | Package | What it is | | --- | --- | | `Box3D.NET` | The idiomatic surface. This is what you want. | | `Box3D.NET.Native` | The raw P/Invoke layer, a one-to-one mirror of the C API. | -Reading a body position through the wrapper costs what calling -`b3Body_GetPosition` costs. Ray casts allocate nothing, including the callback -forms. .NET 8 or later, Windows, Linux and macOS, x64 and arm64, NativeAOT and -trimming. \ No newline at end of file +`Box3D.NET` validates its input and never names a native type in public API. +[The native layer](concepts/native-layer.md) is the escape hatch for the +functions it does not cover yet, reached deliberately through a `using`. + +## What it costs + +Reading a body position through the wrapper costs 9.50 ns against the C API's +9.00 ns. A whole step is indistinguishable from the C API at 100, 1,000 and +10,000 bodies. Queries allocate nothing, including the callback forms. + +[Benchmarks](benchmarks.md) has the method and the conditions. + +## Where to go + +| | | +| --- | --- | +| [Getting started](getting-started.md) | Install, first simulation, the loop | +| [Guides](guides/bodies.md) | Bodies, shapes, queries, events, joints, terrain, characters | +| [Concepts](concepts/step.md) | The step, ownership, handles, threading, the native layer | +| [Examples](examples.md) | Sixteen runnable samples | +| [Gallery](gallery.md) | Nine scenes, animated, drawn through the public interface | +| [API reference](api/index.md) | Every public type | + +Status: 0.x. The API may still change between minor versions; every break is +recorded in the changelog, and packages are validated against the previous +release so none happens by accident. diff --git a/docs/toc.yml b/docs/toc.yml index ff2bb73..f4467e6 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -1,12 +1,26 @@ - name: Getting started href: getting-started.md + +- name: Guides + href: guides/ + +- name: Concepts + href: concepts/ + +- name: Examples + href: examples.md + - name: Gallery href: gallery.md -- name: Architecture - href: architecture.md + - name: Benchmarks href: benchmarks.md -- name: API coverage - href: api-coverage.md -- name: API reference - href: api/index.md \ No newline at end of file + +- name: Reference + items: + - name: API reference + href: api/ + - name: API coverage + href: api-coverage.md + - name: Architecture + href: architecture.md