diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ff98db..0488358 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,153 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +Box3D moves from `3fc20f5` to `9e5a4cd`, the six upstream commits between them. +The idiomatic `Box3D.NET` only gains members: nothing in it is removed or +changes signature, and package validation against the last release reports no +difference. `Box3D.NET.Native` is a mirror of the C API, so where Box3D changed +a declaration the mirror changed with it; every such break is listed under +Changed and recorded in `src/Box3D.NET.Native/CompatibilitySuppressions.xml`. + +### Added + +- **Clockwise meshes.** `MeshOptions.ClockwiseWinding` builds a mesh from + indices wound clockwise, for content exported with that convention, instead + of reordering them by hand. The mesh faces the same way either way. + +- **Meshes from interleaved vertex buffers.** + `CollisionMesh.FromTriangles(vertices, positionOffset, indices, ...)` + reads each position out of a larger vertex struct, so a render vertex buffer + can be used as it is. The stride is the size of `TVertex`. A vertex type + Box3D cannot read — a size that is not a multiple of four, or outside 12 to + 4096 bytes — is rejected with an `ArgumentException`, and a position offset + that is misaligned or does not fit with an `ArgumentOutOfRangeException`, + rather than surfacing as a failed build. + +- **What a character is touching.** `CharacterContact.TriangleIndex`, + `ChildIndex` and `MaterialIndex` name the mesh or height field triangle, the + compound child and the material behind each plane. All three are zero for + shapes they do not apply to, which is how Box3D reports them — not minus one + as on `RaycastHit`. The material index is clamped to the shape's own + materials, so on a mesh or height field attached through `Box3D.NET`, which + carries one material, it is always zero; on a compound it tells the children's + materials apart. + +- **A character struck by a moving body.** `CharacterMover.TimeOfImpact` sweeps + a body between two poses against a capsule's own movement over the same + interval and returns a `CharacterImpact`: whether it hit, the shape, the + point, the normal from the body towards the character, and the fraction. + Only the sphere, capsule and hull shapes on the body are swept, and a shape + overlapping the capsule at the start is ignored — both as Box3D defines it. + `CharacterControllerSample` now ends with a crate dropped on a standing + character. + +- **`Joint.IsAwake`**, true exactly when at least one of the joint's bodies is + awake. + +- **Continuous collision tuning.** `BodyDefinition.SafetyFactor` and + `Body.SafetyFactor` decide when a body counts as fast enough to need + continuous collision. Smaller is safer but can make the body hitch; the + engine default of 0.5 is what every body had before. A non-finite or + negative value is rejected. + +- In `Box3D.NET.Native`: `b3Body_TimeOfImpactMover` and `b3BodyTOIResult`, + `b3Joint_IsAwake`, `b3Body_SetSafetyFactor` and `b3Body_GetSafetyFactor`, + `b3Body_GetMinExtent`, `b3Body_GetMaxExtent` and `b3Body_GetMaxExtentOrigin`, + `b3GetMaxManifoldPoints`, `b3Hull2D`, `b3SimplifyHull2D` and `b3Point2D`, + `b3TreeProxy`, and `Constants.B3_MAX_MESH_CONTACT_TRIANGLES`. The new struct + fields are `b3MeshDef.stride` and `clockWiseWinding`, + `b3PlaneResult.triangleIndex`, `childIndex` and `materialIndex`, + `b3BodyDef.safetyFactor` and `b3CompoundData.proxyOffset`. + +### Changed + +- **Box3D updated to `9e5a4cd`** (`v0.1.0-24`), from `3fc20f5`. + +- **Mesh and height field triangles are one-sided for shape casts.** + `PhysicsWorld.CastCapsule`, and `b3World_CastMover`, `b3World_CastShape` and + `b3Body_CastShape` in the native layer, now pass through the back of a + triangle, as ray casts always have. A capsule cast upwards from under a floor + used to stop on its underside; it now goes through. The tests that pin this + fail against the previous Box3D. `CollideCapsule` already reported only the + front face, and still does. + +- **The serialized geometry formats changed.** Box3D moved hulls, meshes and + height fields to 64-bit content hashes and rewrote the dynamic tree, so + `B3_HULL_VERSION`, `B3_MESH_VERSION`, `B3_HEIGHT_FIELD_VERSION`, + `B3_DYNAMIC_TREE_VERSION` and the `B3_COMPOUND_VERSION` derived from them all + have new values, and recordings are now format 6.7. Nothing `Box3D.NET` builds + at run time is affected. Geometry baked and saved by an earlier Box3D — through + `b3LoadHeightField`, `b3DynamicTree_Load` or raw copies of the structures in + `Box3D.NET.Native` — does not match the new layout and has to be rebuilt. + +- **Restitution is applied once, at the end of the step.** Box3D dropped + `B3_RESTITUTION_ITERATIONS`, so `Constants.B3_RESTITUTION_ITERATIONS` is now + `[Obsolete]` and affects nothing. `b3ManifoldPoint.normalVelocity` is only + computed when restitution or hit events need it, and is zero otherwise; + `ContactHitEvent.ApproachSpeed` is unaffected, since hit events are what turn + it on. + +- A NaN smuggled into a body through `Box3D.NET.Native` no longer reaches bodies + it is not touching: Box3D's new broad phase contained that. It still spreads + through contact, so the validation in `Box3D.NET` is unchanged, and the + README, `docs/benchmarks.md` and the test that measures it now describe what + actually happens. + +- **Breaking, `Box3D.NET.Native` only.** Each of these follows a change to the C + declaration it mirrors, and none can be preserved without the mirror lying + about the layout the native library uses: + - `b3GetByteCount` returns `long`, as `int64_t` in C. Change the variable + receiving it. + - `b3SetAllocator` takes `delegate* unmanaged[Cdecl]` and + `delegate* unmanaged[Cdecl]`: the size is a `size_t`, + and the free callback is now told the size of the block. + - `b3Counters.byteCount` is a `long` and is now the first field. + - `b3Profile.applyRestitution` is gone. + - The dynamic tree was rewritten. `b3TreeNode` is now 32 bytes of box, + `flagIndex` and a `height`/`shapeIndex` union, with `IsLeaf`, `IsMoved` and + `Index` to read the packed word; the user data and category bits moved to + the new `b3TreeProxy`. `b3DynamicTree` holds `parents`, `proxies`, + `nodeEnd`, `pairFreeList` and the rest of the new layout in place of `root`, + `nodeCount` and `freeList`. `b3TreeNodeChildren`, `b3TreeNodeFlags` and + `b3TreeNodeFlagsStorage` have no C counterpart any more and are removed, + and `b3DynamicTree_ValidateNoEnlarged` became + `b3DynamicTree_ValidateNoMoved`. `B3.b3DynamicTree_GetUserData` and + `b3DynamicTree_GetAABB` read through the proxies, as the C versions do. + - `b3HullData.hash`, `b3MeshData.hash` and `b3HeightFieldData.hash` are + `ulong`, and the fields after them moved. `b3HullData.padding` is gone, + `b3MeshData.padding` is new, `b3HeightFieldData.clockwise` is a `byte` + followed by seven bytes of padding, and `b3BoxHull.padding` shrank to two. + - `b3RecPlayer_Create` and `b3RecPlayer_Destroy` were renamed by Box3D to + `b3CreatePlayer` and `b3DestroyPlayer`. This one is a pure rename, so the + old names remain as `[Obsolete]` forwarders and existing code still + compiles. + +- `B3Math.b3GetLengthAndNormalize` follows the new inline in the header: a + vector counts as zero when its squared length is at most `1000 * FLT_MIN`, + rather than when its length is below `FLT_EPSILON`, and its length is then + reported as zero. + +- `tools/generate-bindings.ps1` maps `b3AllocFcn` and `b3FreeFcn` to their new + `size_t` signatures. + +### Fixed + +Inherited from Box3D: + +- Triangle collision fixes against meshes and height fields (upstream #126). +- Mover collision is smoother, and no longer catches on the back of mesh + triangles (upstream #141). +- Creating many hulls with regularly spaced coordinates, as voxel games do, was + slow because of collisions in the hull database's hash. Box3D now hashes with + rapidhash; the first 64-bit hash it tried had a bad optimization, fixed later + in the same range of commits (upstream #120, #129). +- Contacts are invalidated when a body's centre of mass shifts, instead of + being recycled with stale anchors (upstream #155). +- A broad-phase validation failure in scenes with contact events (upstream + #149). It only fired in builds of Box3D with validation enabled, which the + shipped binaries are not. + + ## [0.4.0] - 2026-08-10 A reach release. The library itself is unchanged — no type, member or behaviour diff --git a/README.md b/README.md index e69453c..79a896e 100644 --- a/README.md +++ b/README.md @@ -333,6 +333,12 @@ the caller. about eighty lines, with gravity, jumping, ground detection, slope limits and wall sliding. Copy it and change the parts that are yours. +Two more calls cover what those three cannot see. Each contact names the mesh +triangle, compound child and material it came from, so a controller can tell ice +from stone; and `CharacterMover.TimeOfImpact` sweeps a moving body against the +character, which is how something that is not simulated finds out that a falling +crate has hit it. + ### Bad numbers are rejected at the boundary Box3D validates its inputs with assertions, and assertions are compiled out of @@ -340,9 +346,9 @@ the release builds this package ships. So a NaN is accepted in silence — and i does not stay where you put it. Measured: 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)`. -The solver couples bodies through islands and the broad phase, so one bad number -reaches everything, and there is no way to remove it from a world afterwards. +second body, merely resting on it, reading `(NaN, NaN, NaN)`. The solver couples +bodies through contact, so one bad number reaches everything it touches, and +there is no way to remove it from a world afterwards. So the library rejects non-finite values at the call that produced them: @@ -407,7 +413,7 @@ is no crash to investigate. `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`](abi/native-layout.json). The test suite -holds all 92 structs to that file — size, every field offset, blittability, and +holds all 94 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. @@ -547,7 +553,7 @@ run time. | Suite | What it protects | | --- | --- | -| `AbiTests` | All 92 structs against what the C compiler reports for the same declarations: size, every field offset, blittability, and whether a mirror exists at all. | +| `AbiTests` | All 94 structs against what the C compiler reports for the same declarations: size, every field offset, blittability, and whether a mirror exists at all. | | `LayoutTests` | A core set of sizes against values derived by hand from the C declarations. Narrower than `AbiTests` and kept because it needs neither a native binary nor a C toolchain. | | `DebugDrawTests` | That debug draw reaches a managed drawer with usable values, that a shape factory is asked once per shape rather than once per frame, that disposal releases every drawable, and that a drawn frame allocates nothing. | | `MathTests` | The math ported from the `B3_INLINE` functions, by algebraic identity and by agreement with `System.Numerics`. | @@ -556,7 +562,7 @@ run time. | `LayeringTests` | That no `Box3D.NET.Native` type reaches the public surface, checked by reflection over the built assembly. | | `UserDataTests` | Identifiers survive the round trip through the native `void*`, including the top bit, and come back from events and queries. | | `GeometryTests` | Hull, mesh and height field behaviour, and the ownership rules for each. | -| `CharacterMoverTests` | Contact gathering, the plane solver, velocity clipping, and a character sliding along a wall. | +| `CharacterMoverTests` | Contact gathering and what it reports touching, the plane solver, velocity clipping, one-sided mesh and height field triangles, a moving body striking the character, and a character sliding along a wall. | | `FuzzTests` | Non-finite input, extreme magnitudes, degenerate geometry, and seeded random operation sequences. | | `DeterminismTests` | That the same scene run twice hashes identically, bit for bit, including alongside other worlds and interleaved queries. | | `ThreadingTests` | That independent worlds step in parallel and reach exactly the state they would have reached alone. | diff --git a/abi/native-layout.json b/abi/native-layout.json index e1e2c91..22f2615 100644 --- a/abi/native-layout.json +++ b/abi/native-layout.json @@ -3,12 +3,12 @@ "b3Capacity": { "size": 20, "align": 4, "fields": {"staticShapeCount": 0, "dynamicShapeCount": 4, "staticBodyCount": 8, "dynamicBodyCount": 12, "contactCount": 16} }, "b3WorldDef": { "size": 144, "align": 8, "fields": {"gravity": 0, "restitutionThreshold": 12, "hitEventThreshold": 16, "contactHertz": 20, "contactDampingRatio": 24, "contactSpeed": 28, "maximumLinearSpeed": 32, "frictionCallback": 40, "restitutionCallback": 48, "enableSleep": 56, "enableContinuous": 57, "workerCount": 60, "enqueueTask": 64, "finishTask": 72, "userTaskContext": 80, "userData": 88, "createDebugShape": 96, "destroyDebugShape": 104, "userDebugShapeContext": 112, "capacity": 120, "internalValue": 140} }, "b3MotionLocks": { "size": 6, "align": 1, "fields": {"linearX": 0, "linearY": 1, "linearZ": 2, "angularX": 3, "angularY": 4, "angularZ": 5} }, - "b3BodyDef": { "size": 104, "align": 8, "fields": {"type": 0, "position": 4, "rotation": 16, "linearVelocity": 32, "angularVelocity": 44, "linearDamping": 56, "angularDamping": 60, "gravityScale": 64, "sleepThreshold": 68, "name": 72, "userData": 80, "motionLocks": 88, "enableSleep": 94, "isAwake": 95, "isBullet": 96, "isEnabled": 97, "allowFastRotation": 98, "enableContactRecycling": 99, "internalValue": 100} }, + "b3BodyDef": { "size": 112, "align": 8, "fields": {"type": 0, "position": 4, "rotation": 16, "linearVelocity": 32, "angularVelocity": 44, "linearDamping": 56, "angularDamping": 60, "gravityScale": 64, "sleepThreshold": 68, "safetyFactor": 72, "name": 80, "userData": 88, "motionLocks": 96, "enableSleep": 102, "isAwake": 103, "isBullet": 104, "isEnabled": 105, "allowFastRotation": 106, "enableContactRecycling": 107, "internalValue": 108} }, "b3Filter": { "size": 24, "align": 8, "fields": {"categoryBits": 0, "maskBits": 8, "groupIndex": 16} }, "b3SurfaceMaterial": { "size": 40, "align": 8, "fields": {"friction": 0, "restitution": 4, "rollingResistance": 8, "tangentVelocity": 12, "userMaterialId": 24, "customColor": 32, "padding": 36} }, "b3ShapeDef": { "size": 120, "align": 8, "fields": {"name": 0, "userData": 8, "materials": 16, "materialCount": 24, "baseMaterial": 32, "density": 72, "explosionScale": 76, "filter": 80, "enableCustomFiltering": 104, "isSensor": 105, "enableSensorEvents": 106, "enableContactEvents": 107, "enableHitEvents": 108, "enablePreSolveEvents": 109, "invokeContactCreation": 110, "updateBodyMass": 111, "enableSpeculativeContact": 112, "internalValue": 116} }, - "b3Profile": { "size": 92, "align": 4, "fields": {"step": 0, "pairs": 4, "collide": 8, "solve": 12, "solverSetup": 16, "constraints": 20, "prepareConstraints": 24, "integrateVelocities": 28, "warmStart": 32, "solveImpulses": 36, "integratePositions": 40, "relaxImpulses": 44, "applyRestitution": 48, "storeImpulses": 52, "splitIslands": 56, "transforms": 60, "sensorHits": 64, "jointEvents": 68, "hitEvents": 72, "refit": 76, "bullets": 80, "sleepIslands": 84, "sensors": 88} }, - "b3Counters": { "size": 200, "align": 4, "fields": {"bodyCount": 0, "shapeCount": 4, "contactCount": 8, "jointCount": 12, "islandCount": 16, "stackUsed": 20, "arenaCapacity": 24, "staticTreeHeight": 28, "treeHeight": 32, "satCallCount": 36, "satCacheHitCount": 40, "byteCount": 44, "taskCount": 48, "colorCounts": 52, "manifoldCounts": 148, "awakeContactCount": 180, "recycledContactCount": 184, "distanceIterations": 188, "pushBackIterations": 192, "rootIterations": 196} }, + "b3Profile": { "size": 88, "align": 4, "fields": {"step": 0, "pairs": 4, "collide": 8, "solve": 12, "solverSetup": 16, "constraints": 20, "prepareConstraints": 24, "integrateVelocities": 28, "warmStart": 32, "solveImpulses": 36, "integratePositions": 40, "relaxImpulses": 44, "storeImpulses": 48, "splitIslands": 52, "transforms": 56, "sensorHits": 60, "jointEvents": 64, "hitEvents": 68, "refit": 72, "bullets": 76, "sleepIslands": 80, "sensors": 84} }, + "b3Counters": { "size": 208, "align": 8, "fields": {"byteCount": 0, "bodyCount": 8, "shapeCount": 12, "contactCount": 16, "jointCount": 20, "islandCount": 24, "stackUsed": 28, "arenaCapacity": 32, "staticTreeHeight": 36, "treeHeight": 40, "satCallCount": 44, "satCacheHitCount": 48, "taskCount": 52, "colorCounts": 56, "manifoldCounts": 152, "awakeContactCount": 184, "recycledContactCount": 188, "distanceIterations": 192, "pushBackIterations": 196, "rootIterations": 200} }, "b3JointDef": { "size": 112, "align": 8, "fields": {"userData": 0, "bodyIdA": 8, "bodyIdB": 16, "localFrameA": 24, "localFrameB": 52, "forceThreshold": 80, "torqueThreshold": 84, "constraintHertz": 88, "constraintDampingRatio": 92, "drawScale": 96, "collideConnected": 100, "internalValue": 104} }, "b3DistanceJointDef": { "size": 160, "align": 8, "fields": {"base": 0, "length": 112, "enableSpring": 116, "lowerSpringForce": 120, "upperSpringForce": 124, "hertz": 128, "dampingRatio": 132, "enableLimit": 136, "minLength": 140, "maxLength": 144, "enableMotor": 148, "maxMotorForce": 152, "motorSpeed": 156} }, "b3MotorJointDef": { "size": 168, "align": 8, "fields": {"base": 0, "linearVelocity": 112, "maxVelocityForce": 124, "angularVelocity": 128, "maxVelocityTorque": 140, "linearHertz": 144, "linearDampingRatio": 148, "maxSpringForce": 152, "angularHertz": 156, "angularDampingRatio": 160, "maxSpringTorque": 164} }, @@ -49,34 +49,35 @@ "b3Sweep": { "size": 68, "align": 4, "fields": {"localCenter": 0, "c1": 12, "c2": 24, "q1": 36, "q2": 52} }, "b3TOIInput": { "size": 176, "align": 8, "fields": {"proxyA": 0, "proxyB": 16, "sweepA": 32, "sweepB": 100, "maxFraction": 168} }, "b3TOIOutput": { "size": 52, "align": 4, "fields": {"state": 0, "point": 4, "normal": 16, "fraction": 28, "distance": 32, "distanceIterations": 36, "pushBackIterations": 40, "rootIterations": 44, "usedFallback": 48} }, - "b3TreeNodeChildren": { "size": 8, "align": 4, "fields": {"child1": 0, "child2": 4} }, - "b3TreeNode": { "size": 48, "align": 8, "fields": {"aabb": 0, "categoryBits": 24, "children": 32, "userData": 32, "parent": 40, "next": 40, "height": 44, "flags": 46} }, - "b3DynamicTree": { "size": 80, "align": 8, "fields": {"version": 0, "nodes": 8, "root": 16, "nodeCount": 20, "nodeCapacity": 24, "proxyCount": 28, "freeList": 32, "leafIndices": 40, "leafBoxes": 48, "leafCenters": 56, "binIndices": 64, "rebuildCapacity": 72} }, + "b3TreeNode": { "size": 32, "align": 4, "fields": {"aabb": 0, "flagIndex": 24, "height": 28, "shapeIndex": 28} }, + "b3TreeProxy": { "size": 24, "align": 8, "fields": {"userData": 0, "categoryBits": 8, "node": 16, "next": 20} }, + "b3DynamicTree": { "size": 112, "align": 8, "fields": {"version": 0, "nodes": 8, "parents": 16, "proxies": 24, "nodeEnd": 32, "nodeCapacity": 36, "pairFreeList": 40, "proxyCount": 44, "proxyCapacity": 48, "proxyFreeList": 52, "swapNodes": 56, "leafIndices": 64, "leafNodes": 72, "leafBoxes": 80, "leafCenters": 88, "binIndices": 96, "rebuildCapacity": 104, "dfsOrdered": 108} }, "b3TreeStats": { "size": 8, "align": 4, "fields": {"nodeVisits": 0, "leafVisits": 4} }, - "b3PlaneResult": { "size": 28, "align": 4, "fields": {"plane": 0, "point": 16} }, + "b3PlaneResult": { "size": 40, "align": 4, "fields": {"plane": 0, "point": 16, "triangleIndex": 28, "childIndex": 32, "materialIndex": 36} }, "b3CollisionPlane": { "size": 28, "align": 4, "fields": {"plane": 0, "pushLimit": 16, "push": 20, "clipVelocity": 24} }, "b3PlaneSolverResult": { "size": 16, "align": 4, "fields": {"delta": 0, "iterationCount": 12} }, - "b3BodyPlaneResult": { "size": 36, "align": 4, "fields": {"shapeId": 0, "result": 8} }, + "b3BodyPlaneResult": { "size": 48, "align": 4, "fields": {"shapeId": 0, "result": 8} }, + "b3BodyTOIResult": { "size": 36, "align": 4, "fields": {"point": 0, "normal": 12, "fraction": 24, "shapeId": 28} }, "b3MassData": { "size": 52, "align": 4, "fields": {"mass": 0, "center": 4, "inertia": 16} }, "b3Sphere": { "size": 16, "align": 4, "fields": {"center": 0, "radius": 12} }, "b3Capsule": { "size": 28, "align": 4, "fields": {"center1": 0, "center2": 12, "radius": 24} }, "b3HullVertex": { "size": 1, "align": 1, "fields": {"edge": 0} }, "b3HullHalfEdge": { "size": 4, "align": 1, "fields": {"next": 0, "twin": 1, "origin": 2, "face": 3} }, "b3HullFace": { "size": 1, "align": 1, "fields": {"edge": 0} }, - "b3HullData": { "size": 144, "align": 8, "fields": {"version": 0, "byteCount": 8, "hash": 12, "aabb": 16, "surfaceArea": 40, "volume": 44, "innerRadius": 48, "center": 52, "centralInertia": 64, "vertexCount": 100, "vertexOffset": 104, "pointOffset": 108, "edgeCount": 112, "edgeOffset": 116, "faceCount": 120, "planeOffset": 124, "faceOffset": 128, "soaVertexOffset": 132, "soaNormalOffset": 136, "padding": 140} }, - "b3BoxHull": { "size": 648, "align": 8, "fields": {"base": 0, "boxVertices": 144, "boxPoints": 152, "boxEdges": 248, "boxPlanes": 344, "boxFaces": 440, "padding": 446, "vx": 456, "vy": 488, "vz": 520, "nx": 552, "ny": 584, "nz": 616} }, - "b3MeshDef": { "size": 40, "align": 8, "fields": {"vertices": 0, "indices": 8, "materialIndices": 16, "weldTolerance": 24, "vertexCount": 28, "triangleCount": 32, "weldVertices": 36, "useMedianSplit": 37, "identifyEdges": 38} }, + "b3HullData": { "size": 144, "align": 8, "fields": {"version": 0, "hash": 8, "aabb": 16, "surfaceArea": 40, "volume": 44, "innerRadius": 48, "center": 52, "centralInertia": 64, "vertexCount": 100, "vertexOffset": 104, "pointOffset": 108, "edgeCount": 112, "edgeOffset": 116, "faceCount": 120, "planeOffset": 124, "faceOffset": 128, "soaVertexOffset": 132, "soaNormalOffset": 136, "byteCount": 140} }, + "b3BoxHull": { "size": 640, "align": 8, "fields": {"base": 0, "boxVertices": 144, "boxPoints": 152, "boxEdges": 248, "boxPlanes": 344, "boxFaces": 440, "padding": 446, "vx": 448, "vy": 480, "vz": 512, "nx": 544, "ny": 576, "nz": 608} }, + "b3MeshDef": { "size": 48, "align": 8, "fields": {"vertices": 0, "stride": 8, "indices": 16, "materialIndices": 24, "weldTolerance": 32, "vertexCount": 36, "triangleCount": 40, "weldVertices": 44, "useMedianSplit": 45, "identifyEdges": 46, "clockWiseWinding": 47} }, "b3MeshTriangle": { "size": 12, "align": 4, "fields": {"index1": 0, "index2": 4, "index3": 8} }, - "b3MeshData": { "size": 88, "align": 8, "fields": {"version": 0, "byteCount": 8, "hash": 12, "bounds": 16, "surfaceArea": 40, "treeHeight": 44, "degenerateCount": 48, "nodeOffset": 52, "nodeCount": 56, "vertexOffset": 60, "vertexCount": 64, "triangleOffset": 68, "triangleCount": 72, "materialOffset": 76, "materialCount": 80, "flagsOffset": 84} }, + "b3MeshData": { "size": 96, "align": 8, "fields": {"version": 0, "hash": 8, "byteCount": 16, "bounds": 20, "surfaceArea": 44, "treeHeight": 48, "degenerateCount": 52, "nodeOffset": 56, "nodeCount": 60, "vertexOffset": 64, "vertexCount": 68, "triangleOffset": 72, "triangleCount": 76, "materialOffset": 80, "materialCount": 84, "flagsOffset": 88, "padding": 92} }, "b3Mesh": { "size": 24, "align": 8, "fields": {"data": 0, "scale": 8} }, "b3HeightFieldDef": { "size": 48, "align": 8, "fields": {"heights": 0, "materialIndices": 8, "scale": 16, "countX": 28, "countZ": 32, "globalMinimumHeight": 36, "globalMaximumHeight": 40, "clockwiseWinding": 44} }, - "b3HeightFieldData": { "size": 88, "align": 8, "fields": {"version": 0, "byteCount": 8, "hash": 12, "aabb": 16, "minHeight": 40, "maxHeight": 44, "heightScale": 48, "scale": 52, "columnCount": 64, "rowCount": 68, "heightsOffset": 72, "materialOffset": 76, "flagsOffset": 80, "clockwise": 84, "padding": 85} }, + "b3HeightFieldData": { "size": 96, "align": 8, "fields": {"version": 0, "hash": 8, "byteCount": 16, "aabb": 20, "minHeight": 44, "maxHeight": 48, "heightScale": 52, "scale": 56, "columnCount": 68, "rowCount": 72, "heightsOffset": 76, "materialOffset": 80, "flagsOffset": 84, "clockwise": 88, "padding": 89} }, "b3CompoundCapsuleDef": { "size": 72, "align": 8, "fields": {"capsule": 0, "material": 32} }, "b3CompoundHullDef": { "size": 80, "align": 8, "fields": {"hull": 0, "transform": 8, "material": 40} }, "b3CompoundMeshDef": { "size": 64, "align": 8, "fields": {"meshData": 0, "transform": 8, "scale": 36, "materials": 48, "materialCount": 56} }, "b3CompoundSphereDef": { "size": 56, "align": 8, "fields": {"sphere": 0, "material": 16} }, "b3CompoundDef": { "size": 64, "align": 8, "fields": {"capsules": 0, "capsuleCount": 8, "hulls": 16, "hullCount": 24, "meshes": 32, "meshCount": 40, "spheres": 48, "sphereCount": 56} }, - "b3CompoundData": { "size": 144, "align": 8, "fields": {"version": 0, "byteCount": 8, "nodeOffset": 12, "tree": 16, "materialOffset": 96, "materialCount": 100, "capsuleOffset": 104, "capsuleCount": 108, "hullOffset": 112, "hullCount": 116, "sharedHullCount": 120, "meshOffset": 124, "meshCount": 128, "sharedMeshCount": 132, "sphereOffset": 136, "sphereCount": 140} }, + "b3CompoundData": { "size": 184, "align": 8, "fields": {"version": 0, "byteCount": 8, "nodeOffset": 12, "proxyOffset": 16, "tree": 24, "materialOffset": 136, "materialCount": 140, "capsuleOffset": 144, "capsuleCount": 148, "hullOffset": 152, "hullCount": 156, "sharedHullCount": 160, "meshOffset": 164, "meshCount": 168, "sharedMeshCount": 172, "sphereOffset": 176, "sphereCount": 180} }, "b3CompoundCapsule": { "size": 32, "align": 4, "fields": {"capsule": 0, "materialIndex": 28} }, "b3CompoundHull": { "size": 40, "align": 8, "fields": {"hull": 0, "transform": 8, "materialIndex": 36} }, "b3CompoundMesh": { "size": 64, "align": 8, "fields": {"meshData": 0, "transform": 8, "scale": 36, "materialIndices": 48} }, @@ -89,6 +90,7 @@ "b3LocalManifold": { "size": 64, "align": 8, "fields": {"normal": 0, "triangleNormal": 12, "points": 24, "pointCount": 32, "triangleIndex": 36, "i1": 40, "i2": 44, "i3": 48, "squaredDistance": 52, "feature": 56, "triangleFlags": 60} }, "b3DebugShape": { "size": 24, "align": 8, "fields": {"shapeId": 0, "type": 8, "capsule": 16, "compound": 16, "heightField": 16, "hull": 16, "mesh": 16, "sphere": 16} }, "b3DebugDraw": { "size": 128, "align": 8, "fields": {"DrawShapeFcn": 0, "DrawSegmentFcn": 8, "DrawTransformFcn": 16, "DrawPointFcn": 24, "DrawSphereFcn": 32, "DrawCapsuleFcn": 40, "DrawBoundsFcn": 48, "DrawBoxFcn": 56, "DrawStringFcn": 64, "drawingBounds": 72, "forceScale": 96, "jointScale": 100, "drawShapes": 104, "drawJoints": 105, "drawJointExtras": 106, "drawBounds": 107, "drawMass": 108, "drawSleep": 109, "drawBodyNames": 110, "drawContacts": 111, "drawAnchorA": 112, "drawGraphColors": 113, "drawContactFeatures": 114, "drawContactNormals": 115, "drawContactForces": 116, "drawIslands": 117, "context": 120} }, + "b3Point2D": { "size": 16, "align": 4, "fields": {"p": 0, "separation": 8, "originalIndex": 12} }, "b3RecPlayerInfo": { "size": 44, "align": 4, "fields": {"frameCount": 0, "workerCount": 4, "timeStep": 8, "subStepCount": 12, "lengthScale": 16, "bounds": 20} }, "b3RecQueryInfo": { "size": 120, "align": 8, "fields": {"type": 0, "filter": 8, "aabb": 40, "origin": 64, "translation": 76, "hitCount": 88, "key": 96, "id": 104, "name": 112} }, "b3RecQueryHit": { "size": 36, "align": 4, "fields": {"shape": 0, "point": 8, "normal": 20, "fraction": 32} } diff --git a/docs/api-coverage.md b/docs/api-coverage.md index 7c3a060..5873478 100644 --- a/docs/api-coverage.md +++ b/docs/api-coverage.md @@ -12,12 +12,12 @@ this file and the headers disagree. | | Count | | --- | ---: | -| Functions declared `B3_API` in the Box3D headers | 580 | -| Bound by the generator | 543 | -| Bound by hand | 36 | +| Functions declared `B3_API` in the Box3D headers | 590 | +| Bound by the generator | 552 | +| Bound by hand | 37 | | Deliberately not bound | 1 | -| Reachable through `Box3D.NET` (HIGH_LEVEL) | 338 | -| Reachable only through `Box3D.NET.Native` (NATIVE_ONLY) | 241 | +| Reachable through `Box3D.NET` (HIGH_LEVEL) | 342 | +| Reachable only through `Box3D.NET.Native` (NATIVE_ONLY) | 247 | `NATIVE_ONLY` is not a to-do list. Most of it is machinery an idiomatic API should not surface: individual accessors that a single property reads several @@ -38,6 +38,7 @@ one of them is an inconvenience rather than a wall. | Function | Binding | Reach | | --- | --- | --- | | `b3GetByteCount` | hand-written | NATIVE_ONLY | +| `b3GetMaxManifoldPoints` | hand-written | NATIVE_ONLY | | `b3GetMilliseconds` | hand-written | NATIVE_ONLY | | `b3GetMillisecondsAndReset` | hand-written | NATIVE_ONLY | | `b3GetTicks` | hand-written | NATIVE_ONLY | @@ -90,10 +91,14 @@ one of them is an inconvenience rather than a wall. | `b3Body_GetLocalVector` | generated | HIGH_LEVEL | | `b3Body_GetMass` | generated | HIGH_LEVEL | | `b3Body_GetMassData` | generated | NATIVE_ONLY | +| `b3Body_GetMaxExtent` | generated | NATIVE_ONLY | +| `b3Body_GetMaxExtentOrigin` | generated | NATIVE_ONLY | +| `b3Body_GetMinExtent` | generated | NATIVE_ONLY | | `b3Body_GetMotionLocks` | generated | HIGH_LEVEL | | `b3Body_GetName` | generated | NATIVE_ONLY | | `b3Body_GetPosition` | generated | HIGH_LEVEL | | `b3Body_GetRotation` | generated | HIGH_LEVEL | +| `b3Body_GetSafetyFactor` | generated | HIGH_LEVEL | | `b3Body_GetShapeCount` | generated | HIGH_LEVEL | | `b3Body_GetShapes` | generated | HIGH_LEVEL | | `b3Body_GetSleepThreshold` | generated | NATIVE_ONLY | @@ -124,11 +129,13 @@ one of them is an inconvenience rather than a wall. | `b3Body_SetMassData` | generated | NATIVE_ONLY | | `b3Body_SetMotionLocks` | generated | HIGH_LEVEL | | `b3Body_SetName` | generated | HIGH_LEVEL | +| `b3Body_SetSafetyFactor` | generated | HIGH_LEVEL | | `b3Body_SetSleepThreshold` | generated | NATIVE_ONLY | | `b3Body_SetTargetTransform` | generated | HIGH_LEVEL | | `b3Body_SetTransform` | generated | HIGH_LEVEL | | `b3Body_SetType` | generated | HIGH_LEVEL | | `b3Body_SetUserData` | generated | HIGH_LEVEL | +| `b3Body_TimeOfImpactMover` | generated | HIGH_LEVEL | | `b3Contact_GetData` | generated | NATIVE_ONLY | | `b3Contact_IsValid` | generated | NATIVE_ONLY | | `b3CreateBakedCompoundShape` | generated | HIGH_LEVEL | @@ -141,6 +148,7 @@ one of them is an inconvenience rather than a wall. | `b3CreateMeshShape` | generated | HIGH_LEVEL | | `b3CreateMotorJoint` | generated | HIGH_LEVEL | | `b3CreateParallelJoint` | generated | HIGH_LEVEL | +| `b3CreatePlayer` | generated | NATIVE_ONLY | | `b3CreatePrismaticJoint` | generated | HIGH_LEVEL | | `b3CreateRecording` | generated | NATIVE_ONLY | | `b3CreateRevoluteJoint` | generated | HIGH_LEVEL | @@ -152,6 +160,7 @@ one of them is an inconvenience rather than a wall. | `b3CreateWorld` | generated | HIGH_LEVEL | | `b3DestroyBody` | generated | HIGH_LEVEL | | `b3DestroyJoint` | generated | HIGH_LEVEL | +| `b3DestroyPlayer` | generated | NATIVE_ONLY | | `b3DestroyRecording` | generated | NATIVE_ONLY | | `b3DestroyShape` | generated | HIGH_LEVEL | | `b3DestroyWorld` | generated | HIGH_LEVEL | @@ -195,6 +204,7 @@ one of them is an inconvenience rather than a wall. | `b3Joint_GetType` | generated | HIGH_LEVEL | | `b3Joint_GetUserData` | generated | HIGH_LEVEL | | `b3Joint_GetWorld` | generated | HIGH_LEVEL | +| `b3Joint_IsAwake` | generated | HIGH_LEVEL | | `b3Joint_IsValid` | generated | HIGH_LEVEL | | `b3Joint_SetCollideConnected` | generated | HIGH_LEVEL | | `b3Joint_SetConstraintTuning` | generated | HIGH_LEVEL | @@ -255,8 +265,6 @@ one of them is an inconvenience rather than a wall. | `b3PrismaticJoint_SetTargetTranslation` | generated | HIGH_LEVEL | | `b3Recording_GetData` | generated | NATIVE_ONLY | | `b3Recording_GetSize` | generated | NATIVE_ONLY | -| `b3RecPlayer_Create` | generated | NATIVE_ONLY | -| `b3RecPlayer_Destroy` | generated | NATIVE_ONLY | | `b3RecPlayer_DrawFrameQueries` | generated | NATIVE_ONLY | | `b3RecPlayer_GetBodyCount` | generated | NATIVE_ONLY | | `b3RecPlayer_GetBodyId` | generated | NATIVE_ONLY | @@ -543,7 +551,7 @@ one of them is an inconvenience rather than a wall. | `b3DynamicTree_Save` | generated | NATIVE_ONLY | | `b3DynamicTree_SetCategoryBits` | generated | NATIVE_ONLY | | `b3DynamicTree_Validate` | generated | NATIVE_ONLY | -| `b3DynamicTree_ValidateNoEnlarged` | generated | NATIVE_ONLY | +| `b3DynamicTree_ValidateNoMoved` | generated | NATIVE_ONLY | | `b3GetCompoundCapsule` | generated | NATIVE_ONLY | | `b3GetCompoundChild` | generated | NATIVE_ONLY | | `b3GetCompoundHull` | generated | NATIVE_ONLY | @@ -552,6 +560,7 @@ one of them is an inconvenience rather than a wall. | `b3GetCompoundSphere` | generated | NATIVE_ONLY | | `b3GetHeight` | generated | NATIVE_ONLY | | `b3GetSweepTransform` | generated | NATIVE_ONLY | +| `b3Hull2D` | generated | NATIVE_ONLY | | `b3IsValidRay` | generated | NATIVE_ONLY | | `b3LoadHeightField` | generated | NATIVE_ONLY | | `b3MakeBoxHull` | generated | HIGH_LEVEL | @@ -584,6 +593,7 @@ one of them is an inconvenience rather than a wall. | `b3ShapeCastMesh` | generated | NATIVE_ONLY | | `b3ShapeCastSphere` | generated | NATIVE_ONLY | | `b3ShapeDistance` | generated | NATIVE_ONLY | +| `b3SimplifyHull2D` | generated | NATIVE_ONLY | | `b3SolvePlanes` | generated | HIGH_LEVEL | | `b3TimeOfImpact` | generated | NATIVE_ONLY | diff --git a/docs/architecture.md b/docs/architecture.md index 8cdf439..d2c25d8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -76,7 +76,7 @@ is no crash to investigate. `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 +answers in `abi/native-layout.json`. The test suite holds all 94 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. diff --git a/docs/benchmarks.md b/docs/benchmarks.md index 956f7f9..f821de0 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -131,8 +131,8 @@ 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 +one body's velocity to NaN and stepping thirty times left a second body, merely +resting on it, 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. diff --git a/docs/concepts/ownership.md b/docs/concepts/ownership.md index 712a64c..32959d6 100644 --- a/docs/concepts/ownership.md +++ b/docs/concepts/ownership.md @@ -102,9 +102,9 @@ the process-wide total: ```csharp using Box3D.Native; -int before = B3.b3GetByteCount(); +long before = B3.b3GetByteCount(); // ... create and destroy worlds, meshes, hulls ... -int after = B3.b3GetByteCount(); // should be back where it started +long after = B3.b3GetByteCount(); // should be back where it started ``` That is exactly what the test suite does after every create-and-destroy cycle, diff --git a/docs/guides/characters.md b/docs/guides/characters.md index b0d19bd..1e26a37 100644 --- a/docs/guides/characters.md +++ b/docs/guides/characters.md @@ -56,6 +56,13 @@ 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. +It also says what was touched. `TriangleIndex` names the mesh or height field +triangle, `ChildIndex` the child of a compound, and `MaterialIndex` the material +of the shape at that point — the way to tell ice from stone on a compound built +from both. All three are zero for shapes they do not apply to, rather than minus +one as on a ray hit. Mesh and height field triangles are one-sided here: a +capsule touching one from behind reports nothing. + ## Solving [`SolvePlanes`](../api/Box3D.CharacterMover.yml) finds the translation closest to the @@ -98,6 +105,28 @@ 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. +## Being struck + +A kinematic character is not simulated, so nothing tells it when a falling crate +or a swinging door hits it. [`TimeOfImpact`](../api/Box3D.CharacterMover.yml) sweeps a +body between two poses against the capsule's own movement over the same +interval: + +```csharp +Vector3 crateStart = crate.Position; +Quaternion crateStartRotation = crate.Rotation; + +world.Step(dt); + +CharacterImpact impact = CharacterMover.TimeOfImpact( + crate, capsule, position, velocity * dt, + crateStart, crateStartRotation, crate.Position, crate.Rotation); +``` + +Sweep before solving the character's own movement for the frame. Once the +character has been pushed clear of the body the two only touch, and a touch is +not an impact. Only the sphere, capsule and hull shapes on the body take part. + ## Choosing a body A character is usually a **kinematic** body: you own its position, and it pushes diff --git a/docs/guides/joints.md b/docs/guides/joints.md index 4a738e0..1bcff95 100644 --- a/docs/guides/joints.md +++ b/docs/guides/joints.md @@ -121,6 +121,10 @@ if (force.Length() > BreakingForce) report when a joint is overloaded, which is the ingredient for something that breaks under load. +`AsJoint.IsAwake` says whether the joint is being simulated, which is exactly +when at least one of its bodies is awake, and is cheaper than asking both. +`AsJoint.WakeBodies()` wakes it. + ## Bodies that should not collide Connected bodies do not collide by default. Set `CollideConnected` on the base diff --git a/docs/guides/terrain.md b/docs/guides/terrain.md index d0475f1..fb78de0 100644 --- a/docs/guides/terrain.md +++ b/docs/guides/terrain.md @@ -76,9 +76,27 @@ 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. +Content exported with the opposite convention does not need its indices +reordered: set `ClockwiseWinding` and the mesh is built facing the same way. The inputs are copied, so the arrays can be released as soon as the call returns. +Ray casts, shape casts and the character mover all treat mesh and height field +triangles as one-sided: they see the front face and pass through the back. + +Positions can be read straight out of an interleaved render vertex buffer. The +vertex type's size is the stride, and the position is found at a byte offset +inside it: + +```csharp +int offset = (int)Marshal.OffsetOf(nameof(RenderVertex.Position)); +using var level = CollisionMesh.FromTriangles(vertexBuffer, offset, indices); +``` + +Box3D accepts strides that are a multiple of four bytes between 12 and 4096; a +vertex type outside that range is rejected with an `ArgumentException` rather +than failing inside the build. + [`MeshOptions`](../api/Box3D.MeshOptions.yml) controls how the mesh is prepared: | Option | Effect | @@ -86,6 +104,7 @@ returns. | `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 | +| `ClockwiseWinding` | The indices are wound clockwise rather than counter-clockwise | `MeshOptions.Fast` is the preset for content built at run time, where build time matters more than query time. `DegenerateTriangleCount` reports how many diff --git a/external/box3d b/external/box3d index 3fc20f5..9e5a4cd 160000 --- a/external/box3d +++ b/external/box3d @@ -1 +1 @@ -Subproject commit 3fc20f5b453ba9e14cdf54ecafa87a2a4bcdf53c +Subproject commit 9e5a4cde862fba95ff19f096b79567f3ea6c01fd diff --git a/src/Box3D.NET.Native/B3.Accessors.cs b/src/Box3D.NET.Native/B3.Accessors.cs index 7f759cb..d1a1305 100644 --- a/src/Box3D.NET.Native/B3.Accessors.cs +++ b/src/Box3D.NET.Native/B3.Accessors.cs @@ -32,7 +32,7 @@ public static unsafe partial class B3 /// The user data. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static ulong b3DynamicTree_GetUserData(b3DynamicTree* tree, int proxyId) => - tree->nodes[proxyId].userData; + tree->proxies[proxyId].userData; /// Gets the bounding box of a tree proxy. /// The tree. @@ -40,7 +40,7 @@ public static ulong b3DynamicTree_GetUserData(b3DynamicTree* tree, int proxyId) /// The bounding box. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static b3AABB b3DynamicTree_GetAABB(b3DynamicTree* tree, int proxyId) => - tree->nodes[proxyId].aabb; + tree->nodes[tree->proxies[proxyId].node].aabb; // -------------------------------------------------------------- hull data diff --git a/src/Box3D.NET.Native/B3.Base.cs b/src/Box3D.NET.Native/B3.Base.cs index 50e42c1..ea020f5 100644 --- a/src/Box3D.NET.Native/B3.Base.cs +++ b/src/Box3D.NET.Native/B3.Base.cs @@ -38,23 +38,24 @@ public static unsafe partial class B3 /// Overrides the allocation functions. Must be called during application startup. /// /// - /// The allocation callback, with signature void* (int size, int alignment). + /// The allocation callback, with signature void* (size_t size, int alignment). /// The alignment is guaranteed to be a power of two. /// /// - /// The deallocation callback, with signature void (void* mem). + /// The deallocation callback, with signature void (void* mem, size_t size). + /// The size is the one the block was allocated with. /// [LibraryImport(Box3DLibrary.Name)] [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] public static partial void b3SetAllocator( - delegate* unmanaged[Cdecl] allocFcn, - delegate* unmanaged[Cdecl] freeFcn); + delegate* unmanaged[Cdecl] allocFcn, + delegate* unmanaged[Cdecl] freeFcn); /// Gets the total number of bytes currently allocated by Box3D. /// The byte count. [LibraryImport(Box3DLibrary.Name)] [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] - public static partial int b3GetByteCount(); + public static partial long b3GetByteCount(); /// Overrides the assertion callback. /// @@ -93,6 +94,19 @@ public static partial void b3SetAssertFcn( [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] public static partial NativeBool b3IsDoublePrecision(); + /// + /// Gets the value of B3_MAX_MANIFOLD_POINTS the library was compiled with. + /// + /// The maximum number of contact points per manifold. + /// + /// That value sizes the point array inside . If it + /// differs from , every structure + /// holding a manifold has a different layout from its mirror here. + /// + [LibraryImport(Box3DLibrary.Name)] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial int b3GetMaxManifoldPoints(); + /// Gets the absolute number of system ticks. The unit is platform specific. /// The tick count. [LibraryImport(Box3DLibrary.Name)] diff --git a/src/Box3D.NET.Native/B3.Obsolete.cs b/src/Box3D.NET.Native/B3.Obsolete.cs new file mode 100644 index 0000000..69ecd3b --- /dev/null +++ b/src/Box3D.NET.Native/B3.Obsolete.cs @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MIT +// Names Box3D has since renamed, kept so that code written against an earlier +// version of this binding still compiles. + +using System; +using System.ComponentModel; + +namespace Box3D.Native; + +/* + * Only pure renames belong here: a function whose C declaration is unchanged + * apart from its name, so forwarding to the new name is exactly the old call. + * A function whose behaviour or signature changed is not shimmed, because a + * shim would claim a compatibility the native library no longer offers. + */ + +public static unsafe partial class B3 +{ + /// Creates an incremental replay player. Renamed by Box3D to . + /// The recording bytes. + /// The number of recording bytes. + /// The number of workers the replay world steps with. + /// A new player, or null on a bad header or a deserialization failure. + [Obsolete("Box3D renamed this function to b3CreatePlayer. This forwarding name will be removed in a future release.")] + [EditorBrowsable(EditorBrowsableState.Never)] + public static b3RecPlayer* b3RecPlayer_Create(void* data, int size, int workerCount) => + b3CreatePlayer(data, size, workerCount); + + /// Destroys a replay player. Renamed by Box3D to . + /// The player to destroy. + [Obsolete("Box3D renamed this function to b3DestroyPlayer. This forwarding name will be removed in a future release.")] + [EditorBrowsable(EditorBrowsableState.Never)] + public static void b3RecPlayer_Destroy(b3RecPlayer* player) => b3DestroyPlayer(player); +} diff --git a/src/Box3D.NET.Native/B3Math.cs b/src/Box3D.NET.Native/B3Math.cs index bd4f80a..0d263b7 100644 --- a/src/Box3D.NET.Native/B3Math.cs +++ b/src/Box3D.NET.Native/B3Math.cs @@ -211,19 +211,21 @@ public static Vector3 b3Normalize(Vector3 a) } /// Normalizes a vector and reports its original length. - /// Receives the original length. + /// Receives the original length, or zero if the input is very small. /// The vector. /// The unit vector, or the zero vector if the input is very small. public static Vector3 b3GetLengthAndNormalize(out float length, Vector3 a) { - length = b3Length(a); - if (length < FltEpsilon) + float lengthSquared = (a.X * a.X) + (a.Y * a.Y) + (a.Z * a.Z); + if (lengthSquared > 1000.0f * FltMin) { - return Vector3.Zero; + length = MathF.Sqrt(lengthSquared); + float s = 1.0f / length; + return new Vector3(s * a.X, s * a.Y, s * a.Z); } - float invLength = 1.0f / length; - return new Vector3(invLength * a.X, invLength * a.Y, invLength * a.Z); + length = 0.0f; + return Vector3.Zero; } /// Computes a unit vector perpendicular to the given vector. diff --git a/src/Box3D.NET.Native/CompatibilitySuppressions.xml b/src/Box3D.NET.Native/CompatibilitySuppressions.xml new file mode 100644 index 0000000..c65ffc3 --- /dev/null +++ b/src/Box3D.NET.Native/CompatibilitySuppressions.xml @@ -0,0 +1,165 @@ + + + + + CP0001 + T:Box3D.Native.b3TreeNodeChildren + lib/net8.0/Box3D.NET.Native.dll + lib/net8.0/Box3D.NET.Native.dll + true + + + CP0001 + T:Box3D.Native.b3TreeNodeFlags + lib/net8.0/Box3D.NET.Native.dll + lib/net8.0/Box3D.NET.Native.dll + true + + + CP0001 + T:Box3D.Native.b3TreeNodeFlagsStorage + lib/net8.0/Box3D.NET.Native.dll + lib/net8.0/Box3D.NET.Native.dll + true + + + CP0002 + F:Box3D.Native.b3Counters.byteCount + lib/net8.0/Box3D.NET.Native.dll + lib/net8.0/Box3D.NET.Native.dll + true + + + CP0002 + F:Box3D.Native.b3DynamicTree.freeList + lib/net8.0/Box3D.NET.Native.dll + lib/net8.0/Box3D.NET.Native.dll + true + + + CP0002 + F:Box3D.Native.b3DynamicTree.nodeCount + lib/net8.0/Box3D.NET.Native.dll + lib/net8.0/Box3D.NET.Native.dll + true + + + CP0002 + F:Box3D.Native.b3DynamicTree.root + lib/net8.0/Box3D.NET.Native.dll + lib/net8.0/Box3D.NET.Native.dll + true + + + CP0002 + F:Box3D.Native.b3HeightFieldData.clockwise + lib/net8.0/Box3D.NET.Native.dll + lib/net8.0/Box3D.NET.Native.dll + true + + + CP0002 + F:Box3D.Native.b3HeightFieldData.hash + lib/net8.0/Box3D.NET.Native.dll + lib/net8.0/Box3D.NET.Native.dll + true + + + CP0002 + F:Box3D.Native.b3HullData.hash + lib/net8.0/Box3D.NET.Native.dll + lib/net8.0/Box3D.NET.Native.dll + true + + + CP0002 + F:Box3D.Native.b3HullData.padding + lib/net8.0/Box3D.NET.Native.dll + lib/net8.0/Box3D.NET.Native.dll + true + + + CP0002 + F:Box3D.Native.b3MeshData.hash + lib/net8.0/Box3D.NET.Native.dll + lib/net8.0/Box3D.NET.Native.dll + true + + + CP0002 + F:Box3D.Native.b3Profile.applyRestitution + lib/net8.0/Box3D.NET.Native.dll + lib/net8.0/Box3D.NET.Native.dll + true + + + CP0002 + F:Box3D.Native.b3TreeNode.categoryBits + lib/net8.0/Box3D.NET.Native.dll + lib/net8.0/Box3D.NET.Native.dll + true + + + CP0002 + F:Box3D.Native.b3TreeNode.children + lib/net8.0/Box3D.NET.Native.dll + lib/net8.0/Box3D.NET.Native.dll + true + + + CP0002 + F:Box3D.Native.b3TreeNode.flags + lib/net8.0/Box3D.NET.Native.dll + lib/net8.0/Box3D.NET.Native.dll + true + + + CP0002 + F:Box3D.Native.b3TreeNode.height + lib/net8.0/Box3D.NET.Native.dll + lib/net8.0/Box3D.NET.Native.dll + true + + + CP0002 + F:Box3D.Native.b3TreeNode.next + lib/net8.0/Box3D.NET.Native.dll + lib/net8.0/Box3D.NET.Native.dll + true + + + CP0002 + F:Box3D.Native.b3TreeNode.parent + lib/net8.0/Box3D.NET.Native.dll + lib/net8.0/Box3D.NET.Native.dll + true + + + CP0002 + F:Box3D.Native.b3TreeNode.userData + lib/net8.0/Box3D.NET.Native.dll + lib/net8.0/Box3D.NET.Native.dll + true + + + CP0002 + M:Box3D.Native.B3.b3DynamicTree_ValidateNoEnlarged(Box3D.Native.b3DynamicTree*) + lib/net8.0/Box3D.NET.Native.dll + lib/net8.0/Box3D.NET.Native.dll + true + + + CP0002 + M:Box3D.Native.B3.b3GetByteCount + lib/net8.0/Box3D.NET.Native.dll + lib/net8.0/Box3D.NET.Native.dll + true + + + CP0002 + M:Box3D.Native.B3.b3SetAllocator(,) + lib/net8.0/Box3D.NET.Native.dll + lib/net8.0/Box3D.NET.Native.dll + true + + \ No newline at end of file diff --git a/src/Box3D.NET.Native/Constants.cs b/src/Box3D.NET.Native/Constants.cs index 5411e5a..afc74b4 100644 --- a/src/Box3D.NET.Native/Constants.cs +++ b/src/Box3D.NET.Native/Constants.cs @@ -17,8 +17,8 @@ namespace Box3D.Native; /// /// The values assume a default build of Box3D. Constants guarded by /// #ifndef in the header (B3_MAX_WORLDS, B3_GYROSCOPIC_ITERATIONS, -/// B3_RESTITUTION_ITERATIONS) can be overridden when compiling the native -/// library, in which case these no longer match. +/// B3_MAX_MANIFOLD_POINTS, B3_MAX_MESH_CONTACT_TRIANGLES) can be +/// overridden when compiling the native library, in which case these no longer match. /// /// public static class Constants @@ -61,14 +61,35 @@ public static class Constants public const float B3_TIME_TO_SLEEP = 0.5f; /// The maximum number of contact points between two touching shapes. + /// + /// Sizes the point array inline in , so a native library + /// built with a different value has a different ABI. + /// reports the value the loaded library was compiled with. + /// public const int B3_MAX_MANIFOLD_POINTS = 4; /// The number of iterations used for gyroscopic torques. May be overridden at compile time. public const int B3_GYROSCOPIC_ITERATIONS = 1; - /// The number of restitution iterations. May be overridden at compile time. + /// The number of restitution iterations. + /// + /// Box3D no longer defines this macro: restitution is now applied once, deferred + /// to the end of the step. The constant is kept so that code referring to it + /// still compiles, and will be removed in a future release. + /// + [System.Obsolete("Box3D no longer has restitution iterations; restitution is applied once per step. This constant no longer affects anything.")] public const int B3_RESTITUTION_ITERATIONS = 1; + /// + /// The maximum number of mesh or height field triangles a single convex shape + /// can collide with. May be overridden at compile time. + /// + /// + /// Raising it costs stack space. Simplifying the collision geometry is usually + /// the better fix; a render mesh used for collision is the common culprit. + /// + public const int B3_MAX_MESH_CONTACT_TRIANGLES = 256; + /// The maximum number of convex hull vertices. Fixed for performance. public const int B3_MAX_HULL_VERTICES = 128; @@ -121,16 +142,16 @@ public static class Constants public const float B3_MIN_SCALE = 0.01f; /// The dynamic tree version, for validating serialized data. - public const ulong B3_DYNAMIC_TREE_VERSION = 0x93EDAF889FD30B4AUL; + public const ulong B3_DYNAMIC_TREE_VERSION = 0x1D6F4C2A73B80E91UL; /// The convex hull version, for validating serialized data. - public const ulong B3_HULL_VERSION = 0xDA5150191B994C01UL; + public const ulong B3_HULL_VERSION = 0x4A4C9587DE57485CUL; /// The triangle mesh version, for validating serialized data. - public const ulong B3_MESH_VERSION = 0xABD11AB62A6E886DUL; + public const ulong B3_MESH_VERSION = 0xAAAB9A00F1A8AAF7UL; /// The height field version, for validating serialized data. - public const ulong B3_HEIGHT_FIELD_VERSION = 0x8B18CBD138A6BC84UL; + public const ulong B3_HEIGHT_FIELD_VERSION = 0x8E41E5FB084848F8UL; /// The baked compound version, derived from the tree, mesh and hull versions. public const ulong B3_COMPOUND_VERSION = diff --git a/src/Box3D.NET.Native/Enums.cs b/src/Box3D.NET.Native/Enums.cs index 2c7dd1f..68aafa7 100644 --- a/src/Box3D.NET.Native/Enums.cs +++ b/src/Box3D.NET.Native/Enums.cs @@ -106,26 +106,6 @@ public enum b3TOIState b3_toiStateSeparated = 4, } -/// -/// Flags on a dynamic tree node. Mirror of b3TreeNodeFlags. -/// -/// Internal to the tree implementation; exposed because b3TreeNode is public. -[Flags] -public enum b3TreeNodeFlags -{ - /// No flags. - None = 0, - - /// The node is allocated rather than on the free list. - b3_allocatedNode = 0x0001, - - /// The node bounding box was enlarged and needs a refit. - b3_enlargedNode = 0x0002, - - /// The node is a leaf and carries user data instead of children. - b3_leafNode = 0x0004, -} - /// /// Adjacency flags on a mesh triangle. Mirror of b3MeshEdgeFlags. /// diff --git a/src/Box3D.NET.Native/Generated/B3.Box3D.g.cs b/src/Box3D.NET.Native/Generated/B3.Box3D.g.cs index 0d4f271..5715d00 100644 --- a/src/Box3D.NET.Native/Generated/B3.Box3D.g.cs +++ b/src/Box3D.NET.Native/Generated/B3.Box3D.g.cs @@ -170,7 +170,8 @@ public static partial b3TreeStats b3World_OverlapShape( /// /// Cast a ray into the world to collect shapes in the path of the ray. Your callback - /// function controls whether you get the closest point, any point, or n-points. + /// function controls whether you get the closest point, any point, or n-points. Ignores + /// back-side collision on meshes and height-fields. /// /// The world to cast the ray against /// The start point of the ray @@ -196,7 +197,7 @@ public static partial b3TreeStats b3World_CastRay( /// /// Cast a ray into the world to collect the closest hit. This is a convenience function. /// Ignores initial overlap. This is less general than b3World_CastRay() and does not allow - /// for custom filtering. + /// for custom filtering. Ignores back-side collision on meshes and height-fields. /// /// See the Box3D documentation. /// See the Box3D documentation. @@ -214,7 +215,8 @@ public static partial b3RayResult b3World_CastRayClosest( /// /// Cast a shape through the world. Similar to a cast ray except that a shape is cast /// instead of a point. The proxy points are relative to the origin and the hit points come - /// back as world positions, so the cast stays precise far from the world origin. + /// back as world positions, so the cast stays precise far from the world origin. Ignores + /// back-side collision on meshes and height-fields. /// /// See the Box3D documentation. /// See the Box3D documentation. @@ -242,7 +244,7 @@ public static partial b3TreeStats b3World_CastShape( /// Cast a capsule mover through the world. This is a special shape cast that handles /// sliding along other shapes while reducing clipping. This is not a good source of /// information about what the mover is touching. Instead use the planes returned by - /// b3World_CollideMover. + /// b3World_CollideMover. Ignores back-side collision on meshes and height-fields. /// /// World to cast the mover against /// World position the mover capsule is relative to @@ -266,7 +268,8 @@ public static partial float b3World_CastMover( /// /// Collide a capsule mover with the world, gathering collision planes that can be fed to /// b3SolvePlanes. Useful for kinematic character movement. The mover and the returned - /// planes are relative to the origin. + /// planes are relative to the origin. Ignores back-side collision on meshes and + /// height-fields. /// /// See the Box3D documentation. /// See the Box3D documentation. @@ -733,7 +736,7 @@ public static partial void b3World_SetRestitutionCallback( /// a new player, or NULL on bad header or deserialization failure [LibraryImport(Box3DLibrary.Name)] [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] - public static partial b3RecPlayer* b3RecPlayer_Create(void* data, int size, int workerCount); + public static partial b3RecPlayer* b3CreatePlayer(void* data, int size, int workerCount); /// /// Destroy the player and free all memory. Restores the previous global length scale. @@ -741,7 +744,7 @@ public static partial void b3World_SetRestitutionCallback( /// See the Box3D documentation. [LibraryImport(Box3DLibrary.Name)] [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] - public static partial void b3RecPlayer_Destroy(b3RecPlayer* player); + public static partial void b3DestroyPlayer(b3RecPlayer* player); /// /// Advance one frame. dispatch ops until the next Step completes. @@ -1538,6 +1541,25 @@ public static partial void b3Body_ApplyLinearImpulse( [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] public static partial float b3Body_GetSleepThreshold(b3BodyId bodyId); + /// + /// Set the continuous collision safety factor. Smaller is safer but can lead to hitching. + /// Recommended range [0.01, 0.5]. Non-dimensional. + /// + /// See the Box3D documentation. + /// See the Box3D documentation. + [LibraryImport(Box3DLibrary.Name)] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial void b3Body_SetSafetyFactor(b3BodyId bodyId, float safetyFactor); + + /// + /// Get the continuous collision safety factor. Non-dimensional. + /// + /// See the Box3D documentation. + /// See the Box3D documentation. + [LibraryImport(Box3DLibrary.Name)] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial float b3Body_GetSafetyFactor(b3BodyId bodyId); + /// /// Returns true if this body is enabled /// @@ -1737,6 +1759,34 @@ public static partial void b3Body_ApplyLinearImpulse( [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] public static partial b3AABB b3Body_ComputeAABB(b3BodyId bodyId); + /// + /// The minimum distance from any shape to the shape centroid. + /// + /// See the Box3D documentation. + /// See the Box3D documentation. + [LibraryImport(Box3DLibrary.Name)] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial float b3Body_GetMinExtent(b3BodyId bodyId); + + /// + /// The maximum extent vector from any point on the body shapes to the center of mass. + /// + /// See the Box3D documentation. + /// See the Box3D documentation. + [LibraryImport(Box3DLibrary.Name)] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial Vector3 b3Body_GetMaxExtent(b3BodyId bodyId); + + /// + /// The maximum extent vector from any point on the body shapes to the body origin. + /// Conservative. + /// + /// See the Box3D documentation. + /// See the Box3D documentation. + [LibraryImport(Box3DLibrary.Name)] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial Vector3 b3Body_GetMaxExtentOrigin(b3BodyId bodyId); + /// /// Get the closest point on a body to a world target. /// @@ -1749,7 +1799,8 @@ public static partial void b3Body_ApplyLinearImpulse( public static partial float b3Body_GetClosestPoint(b3BodyId bodyId, Vector3* result, Vector3 target); /// - /// Cast a ray at a specific body using a specified body transform. + /// Cast a ray at a specific body using a specified body transform. Ignores back-side + /// collision on meshes and height-fields. /// /// See the Box3D documentation. /// See the Box3D documentation. @@ -1769,7 +1820,8 @@ public static partial b3BodyCastResult b3Body_CastRay( b3Transform bodyTransform); /// - /// Cast a shape at a specific body using a specified body transform. + /// Cast a shape at a specific body using a specified body transform. Ignores back-side + /// collision on meshes and height-fields. /// /// See the Box3D documentation. /// See the Box3D documentation. @@ -1811,7 +1863,8 @@ public static partial NativeBool b3Body_OverlapShape( b3Transform bodyTransform); /// - /// Collide a character mover with a specific body using a specified body transform. + /// Collide a character mover with a specific body using a specified body transform. Only + /// considers convex shapes on the body. /// /// See the Box3D documentation. /// See the Box3D documentation. @@ -1832,6 +1885,30 @@ public static partial int b3Body_CollideMover( b3QueryFilter filter, b3Transform bodyTransform); + /// + /// Perform a time of impact between a character mover and a body using specified sweep + /// transforms. Initial overlap of any shape on the body is ignored. A non-overlapped shape + /// can still be hit. Only considers convex shapes on the body. + /// + /// See the Box3D documentation. + /// See the Box3D documentation. + /// See the Box3D documentation. + /// See the Box3D documentation. + /// See the Box3D documentation. + /// See the Box3D documentation. + /// See the Box3D documentation. + /// See the Box3D documentation. + [LibraryImport(Box3DLibrary.Name)] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial b3BodyTOIResult b3Body_TimeOfImpactMover( + b3BodyId bodyId, + Vector3 origin, + b3Capsule* mover, + Vector3 moverTranslation, + b3QueryFilter filter, + b3Transform bodyTransform1, + b3Transform bodyTransform2); + /// /// Create a circle shape and attach it to a body. The shape definition and geometry are /// fully cloned. Contacts are not created until the next time step. @@ -2611,6 +2688,15 @@ public static partial void b3Shape_ApplyWind( [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] public static partial void b3Joint_WakeBodies(b3JointId jointId); + /// + /// Is the joint awake? If true then an attached body is awake. + /// + /// See the Box3D documentation. + /// See the Box3D documentation. + [LibraryImport(Box3DLibrary.Name)] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial NativeBool b3Joint_IsAwake(b3JointId jointId); + /// /// Get the current constraint force for this joint /// @@ -3838,7 +3924,9 @@ public static partial void b3SphericalJoint_SetTwistLimits( public static partial NativeBool b3SphericalJoint_IsMotorEnabled(b3JointId jointId); /// - /// Set the spherical joint motor velocity in radians per second + /// Set the spherical joint motor velocity in radians per second. This is the relative + /// angular velocity between the two bodies in world space. motorVelocity = angularVelocityB + /// - angularVelocityA /// /// See the Box3D documentation. /// See the Box3D documentation. diff --git a/src/Box3D.NET.Native/Generated/B3.Collision.g.cs b/src/Box3D.NET.Native/Generated/B3.Collision.g.cs index 17eced1..160af62 100644 --- a/src/Box3D.NET.Native/Generated/B3.Collision.g.cs +++ b/src/Box3D.NET.Native/Generated/B3.Collision.g.cs @@ -122,8 +122,8 @@ public static partial b3TreeStats b3DynamicTree_Query( /// /// the dynamic tree to query /// the query point - /// nodes are skipped if the bit-wise AND with the node category bits is zero - /// nodes are skipped if the bit-wise AND with the node category bits does not equal the maskBits + /// proxies are skipped if the bit-wise AND with the proxy category bits is zero + /// proxies are skipped if the bit-wise AND with the proxy category bits does not equal the maskBits /// a user provided instance of b3TreeQueryClosestCallbackFcn /// a user context object that is provided to the callback /// the initial and final minimum squared distance. Provide a small initial to restrict the search and improve performance. If the value is large this query has performance that scales linearly with the number of proxies and would be slower than a brute force search. @@ -149,8 +149,8 @@ public static partial b3TreeStats b3DynamicTree_QueryClosest( /// /// the dynamic tree to ray cast /// the ray cast input data. The ray extends from p1 to p1 + maxFraction * (p2 - p1) - /// bit mask test: `bool accept = (maskBits & node->categoryBits) != 0;` - /// modifies bit mask test: `bool accept = (maskBits & node->categoryBits) == maskBits;` + /// bit mask test: `bool accept = (maskBits & proxy->categoryBits) != 0;` + /// modifies bit mask test: `bool accept = (maskBits & proxy->categoryBits) == maskBits;` /// a callback function that is called for each proxy that is hit by the ray /// user context that is passed to the callback /// performance data @@ -252,12 +252,12 @@ public static partial b3TreeStats b3DynamicTree_BoxCast( public static partial void b3DynamicTree_Validate(b3DynamicTree* tree); /// - /// Validate this tree has no enlarged AABBs. For testing. + /// Validate this tree has no moved nodes. For testing. /// /// See the Box3D documentation. [LibraryImport(Box3DLibrary.Name)] [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] - public static partial void b3DynamicTree_ValidateNoEnlarged(b3DynamicTree* tree); + public static partial void b3DynamicTree_ValidateNoMoved(b3DynamicTree* tree); /// /// Save this tree to a file for debugging @@ -312,7 +312,8 @@ public static partial b3TreeStats b3DynamicTree_BoxCast( public static partial b3HullData* b3CreateRock(float radius); /// - /// Create a generic convex hull. + /// Create a generic convex hull. This can fail if B3_MAX_HULL_VERTICES, B3_MAX_HULL_FACES, + /// or B3_MAX_HULL_EDGES is exceeded. /// /// See the Box3D documentation. /// See the Box3D documentation. @@ -1297,6 +1298,30 @@ public static partial void b3CollideTriangleAndSphere( Vector3* triangleA, b3Sphere* sphereB); + /// + /// Compute a 2D hull. Used internally for contact manifold simplification. Here for + /// testing. + /// + /// See the Box3D documentation. + /// See the Box3D documentation. + /// See the Box3D documentation. + /// See the Box3D documentation. + [LibraryImport(Box3DLibrary.Name)] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial int b3Hull2D(b3Point2D* pts, int count, b3Point2D* hull); + + /// + /// Simplify a 2D hull. Used internally for contact manifold simplification. Here for + /// testing. + /// + /// See the Box3D documentation. + /// See the Box3D documentation. + /// See the Box3D documentation. + /// See the Box3D documentation. + [LibraryImport(Box3DLibrary.Name)] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial int b3SimplifyHull2D(b3Point2D* hull, int count, int target); + /// /// Solves the position of a mover that satisfies the given collision planes. /// diff --git a/src/Box3D.NET.Native/Generated/BindingSource.g.cs b/src/Box3D.NET.Native/Generated/BindingSource.g.cs index 01f8c39..bfce41a 100644 --- a/src/Box3D.NET.Native/Generated/BindingSource.g.cs +++ b/src/Box3D.NET.Native/Generated/BindingSource.g.cs @@ -20,8 +20,8 @@ namespace Box3D.Native; public static class BindingSource { /// The Box3D commit the declarations were generated from. - public const string Commit = "3fc20f5b453ba9e14cdf54ecafa87a2a4bcdf53c"; + public const string Commit = "9e5a4cde862fba95ff19f096b79567f3ea6c01fd"; /// The same revision as git describe renders it. - public const string Description = "3fc20f5"; + public const string Description = "9e5a4cd"; } \ No newline at end of file diff --git a/src/Box3D.NET.Native/OpaqueTypes.cs b/src/Box3D.NET.Native/OpaqueTypes.cs index b824651..0caf04b 100644 --- a/src/Box3D.NET.Native/OpaqueTypes.cs +++ b/src/Box3D.NET.Native/OpaqueTypes.cs @@ -31,8 +31,8 @@ public struct b3Recording /// /// /// Only ever used as b3RecPlayer*. Create one with -/// and release it with -/// . The player owns a private copy of the +/// and release it with +/// . The player owns a private copy of the /// recording bytes and drives its own replay world. /// public struct b3RecPlayer diff --git a/src/Box3D.NET.Native/Structs.Body.cs b/src/Box3D.NET.Native/Structs.Body.cs index 7570459..9662066 100644 --- a/src/Box3D.NET.Native/Structs.Body.cs +++ b/src/Box3D.NET.Native/Structs.Body.cs @@ -90,6 +90,17 @@ public unsafe struct b3BodyDef /// The sleep speed threshold, usually in metres per second. public float sleepThreshold; + /// + /// The continuous collision safety factor. Non-dimensional. + /// + /// + /// A body is only treated as fast, and given continuous collision, when it + /// moves more than this fraction of the distance that risks tunneling in one + /// step. Smaller is safer but can cause hitching. The recommended range is + /// 0.01 to 0.5, and the default of 0.5 favours performance. + /// + public float safetyFactor; + /// /// An optional name for debugging, as a null-terminated UTF-8 string. /// diff --git a/src/Box3D.NET.Native/Structs.Character.cs b/src/Box3D.NET.Native/Structs.Character.cs index 9555d3c..3307b37 100644 --- a/src/Box3D.NET.Native/Structs.Character.cs +++ b/src/Box3D.NET.Native/Structs.Character.cs @@ -31,6 +31,15 @@ public struct b3PlaneResult /// The closest point on the shape. Not necessarily unique. public Vector3 point; + + /// The index of the mesh or height field triangle hit. Zero for other shapes. + public int triangleIndex; + + /// The index of the compound child shape. Zero for other shapes. + public int childIndex; + + /// The material index, clamped to the materials of the shape. + public int materialIndex; } /// @@ -89,3 +98,23 @@ public struct b3BodyPlaneResult /// The plane result. public b3PlaneResult result; } + +/// +/// The time of impact between a character mover and a body. Mirror of b3BodyTOIResult. +/// +/// Returned by b3Body_TimeOfImpactMover. +[StructLayout(LayoutKind.Sequential)] +public struct b3BodyTOIResult +{ + /// The hit point in world space. + public Vector3 point; + + /// The hit normal, pointing from the body towards the mover. + public Vector3 normal; + + /// The fraction of the sweep at which the hit occurs. One when nothing is hit. + public float fraction; + + /// The shape that was hit. Null when nothing is hit. + public b3ShapeId shapeId; +} diff --git a/src/Box3D.NET.Native/Structs.Collision.cs b/src/Box3D.NET.Native/Structs.Collision.cs index 82e118e..83175ff 100644 --- a/src/Box3D.NET.Native/Structs.Collision.cs +++ b/src/Box3D.NET.Native/Structs.Collision.cs @@ -214,3 +214,24 @@ public unsafe struct b3LocalManifold /// The triangle adjacency flags, as . public int triangleFlags; } + +/// +/// A point of a two-dimensional hull used to simplify contact manifolds. +/// Mirror of b3Point2D. +/// +/// +/// Internal to Box3D, and in the public header only so that b3Hull2D and +/// b3SimplifyHull2D can be tested. +/// +[StructLayout(LayoutKind.Sequential)] +public struct b3Point2D +{ + /// The point in the plane of the manifold. + public b3Vec2 p; + + /// The separation of the associated contact point. + public float separation; + + /// The index of the associated contact point before simplification. + public int originalIndex; +} diff --git a/src/Box3D.NET.Native/Structs.Compound.cs b/src/Box3D.NET.Native/Structs.Compound.cs index 00d3f8e..f8f159b 100644 --- a/src/Box3D.NET.Native/Structs.Compound.cs +++ b/src/Box3D.NET.Native/Structs.Compound.cs @@ -129,10 +129,17 @@ public struct b3CompoundData /// The byte offset of the tree node array from the start of this structure. public int nodeOffset; + /// The byte offset of the tree proxy array from the start of this structure. + public int proxyOffset; + /// /// The immutable dynamic tree over the children. /// - /// Its node pointer must be fixed up using after deserialization. + /// + /// Its node and proxy pointers must be fixed up using + /// and after deserialization. A baked tree is never + /// inserted into, so its parent array stays null. + /// public b3DynamicTree tree; /// The byte offset of the material array from the start of this structure. diff --git a/src/Box3D.NET.Native/Structs.Geometry.cs b/src/Box3D.NET.Native/Structs.Geometry.cs index c51a526..a6d759e 100644 --- a/src/Box3D.NET.Native/Structs.Geometry.cs +++ b/src/Box3D.NET.Native/Structs.Geometry.cs @@ -103,11 +103,8 @@ public struct b3HullData /// The format version. Must equal . public ulong version; - /// The total size of this hull in bytes, including the trailing arrays. - public int byteCount; - - /// A content hash of this hull, computed with this field set to zero. - public uint hash; + /// A 64-bit content hash of this hull, computed with this field set to zero. + public ulong hash; /// The local-space bounding box. public b3AABB aabb; @@ -157,8 +154,8 @@ public struct b3HullData /// The byte offset of the structure-of-arrays normal data. public int soaNormalOffset; - /// Explicit padding. Hull identity is a hash over raw bytes, so no implicit padding is allowed. - public int padding; + /// The total size of this hull in bytes, including the trailing arrays. + public int byteCount; } /// @@ -191,7 +188,7 @@ public unsafe struct b3BoxHull public fixed byte boxFaces[6 * 1]; /// Explicit padding. - public fixed byte padding[10]; + public fixed byte padding[2]; /// Vertex x coordinates, structure-of-arrays form. public fixed float vx[8]; @@ -221,7 +218,21 @@ public unsafe struct b3MeshDef /// The vertex positions. public Vector3* vertices; - /// Three vertex indices per triangle, wound counter-clockwise. + /// + /// The distance in bytes from one vertex to the next. Zero means the vertices + /// are contiguous. + /// + /// + /// Lets the positions be read straight out of an interleaved vertex buffer. + /// A non-zero stride must be a multiple of four, at least the size of a + /// vector and at most 4096, or b3CreateMesh returns null. + /// + public nuint stride; + + /// + /// Three vertex indices per triangle, wound counter-clockwise unless + /// is set. + /// public int* indices; /// @@ -254,6 +265,9 @@ public unsafe struct b3MeshDef /// Whether to compute triangle adjacency from shared edges. /// Required for the ghost-collision suppression driven by . public NativeBool identifyEdges; + + /// Whether the input indices are wound clockwise rather than counter-clockwise. + public NativeBool clockWiseWinding; } /// A triangle in a collision mesh. Mirror of b3MeshTriangle. @@ -322,12 +336,12 @@ public struct b3MeshData /// The format version. Must equal . public ulong version; + /// A 64-bit content hash of this mesh, computed with this field set to zero. + public ulong hash; + /// The total size of this mesh in bytes, including the trailing arrays. public int byteCount; - /// A content hash of this mesh, computed with this field set to zero. - public uint hash; - /// The local-space bounding box. public b3AABB bounds; @@ -366,6 +380,9 @@ public struct b3MeshData /// The byte offset of the triangle flag array from the start of this structure. public int flagsOffset; + + /// Explicit padding. Identity is a hash over raw bytes, so no implicit padding is allowed. + public int padding; } /// @@ -439,12 +456,12 @@ public unsafe struct b3HeightFieldData /// The format version. Must equal . public ulong version; + /// A 64-bit content hash, computed with this field set to zero. + public ulong hash; + /// The total size of this height field in bytes, including the trailing arrays. public int byteCount; - /// A content hash, computed with this field set to zero. - public uint hash; - /// The local-space bounding box. public b3AABB aabb; @@ -475,9 +492,10 @@ public unsafe struct b3HeightFieldData /// The byte offset of the flag array, one per triangle. public int flagsOffset; - /// Whether the triangles use clockwise winding. - public NativeBool clockwise; + /// Whether the triangles use clockwise winding. Non-zero for clockwise. + /// A uint8_t in C rather than a bool, so that every byte of the struct is explicit. + public byte clockwise; /// Explicit padding. Identity is a hash over raw bytes, so no implicit padding is allowed. - public fixed byte padding[3]; + public fixed byte padding[7]; } diff --git a/src/Box3D.NET.Native/Structs.Tree.cs b/src/Box3D.NET.Native/Structs.Tree.cs index 411261d..a49d2bd 100644 --- a/src/Box3D.NET.Native/Structs.Tree.cs +++ b/src/Box3D.NET.Native/Structs.Tree.cs @@ -6,27 +6,21 @@ namespace Box3D.Native; -/// -/// The child indices of an internal tree node. Mirror of b3TreeNodeChildren. -/// -[StructLayout(LayoutKind.Sequential)] -public struct b3TreeNodeChildren -{ - /// The index of the first child node. - public int child1; - - /// The index of the second child node. - public int child2; -} - /// /// A node in the dynamic tree. Mirror of b3TreeNode. /// /// -/// This is internal data, exposed for performance. It contains two unions: the -/// children of an internal node overlap the user data of a leaf, and the parent -/// index of an allocated node overlaps the free-list link of a free node. -/// Which member is live follows from . +/// +/// This is internal data, exposed for performance. Siblings are stored as a +/// pair at an even index so that two nodes share a 64-byte cache line; the root +/// is at index zero and index one is always empty. +/// +/// +/// packs a leaf bit, a moved bit and an index: the +/// first node of the child pair for an internal node, or the proxy id for a +/// leaf. The last word is a union of and +/// , and says which is live. +/// /// [StructLayout(LayoutKind.Explicit)] public struct b3TreeNode @@ -35,50 +29,52 @@ public struct b3TreeNode [FieldOffset(0)] public b3AABB aabb; - /// The category bits used for collision filtering. + /// + /// Bit 31 is set for a leaf, bit 30 for a moved node, and bits 0 to 29 hold + /// the index of the child pair or, for a leaf, the proxy id. + /// [FieldOffset(24)] - public ulong categoryBits; + public uint flagIndex; - /// The child indices. Valid when the node is not a leaf. - [FieldOffset(32)] - public b3TreeNodeChildren children; + /// The height of an internal node. A leaf has height zero. + [FieldOffset(28)] + public int height; - /// The user data. Valid when the node is a leaf. - [FieldOffset(32)] - public ulong userData; - - /// The parent node index. Valid when the node is allocated. - [FieldOffset(40)] - public int parent; + /// The shape index of a leaf, truncated from the proxy user data. + [FieldOffset(28)] + public int shapeIndex; - /// The next free node index. Valid when the node is on the free list. - [FieldOffset(40)] - public int next; + /// Gets a value indicating whether this node is a leaf. + public readonly bool IsLeaf => (flagIndex & 0x80000000u) != 0; - /// The height of the node. Leaves have height zero. - [FieldOffset(44)] - public ushort height; + /// Gets a value indicating whether this node is flagged as moved. + public readonly bool IsMoved => (flagIndex & 0x40000000u) != 0; - /// The node flags. - [FieldOffset(46)] - public b3TreeNodeFlagsStorage flags; + /// + /// Gets the index held in the low 30 bits: the first node of the child pair, + /// or the proxy id when is true. + /// + public readonly int Index => (int)(flagIndex & 0x3FFFFFFFu); } /// -/// The storage form of inside a , -/// which the C header declares as a uint16_t. +/// The per-proxy data of a dynamic tree, kept apart from the nodes as cold data. +/// Mirror of b3TreeProxy. /// -[StructLayout(LayoutKind.Sequential, Size = 2)] -public struct b3TreeNodeFlagsStorage +[StructLayout(LayoutKind.Sequential)] +public struct b3TreeProxy { - private ushort _value; - - /// Gets or sets the flags. - public b3TreeNodeFlags Value - { - readonly get => (b3TreeNodeFlags)_value; - set => _value = (ushort)value; - } + /// The user data. An integer rather than a pointer, because Box3D uses it as a shape index. + public ulong userData; + + /// The category bits used for collision filtering. + public ulong categoryBits; + + /// The leaf node of this proxy, or for a free proxy. + public int node; + + /// The next free proxy. + public int next; } /// @@ -105,27 +101,45 @@ public unsafe struct b3DynamicTree /// public ulong version; - /// The node pool. + /// + /// The nodes. The root is at index zero and index one is empty; otherwise + /// siblings are paired at even indices, with holes for free pairs. + /// public b3TreeNode* nodes; - /// The index of the root node. - public int root; + /// The parent index of each node. The free list is interleaved with it. + public int* parents; + + /// The proxies, indexed by proxy id. + public b3TreeProxy* proxies; - /// The number of nodes in use. - public int nodeCount; + /// One past the highest allocated node index. + public int nodeEnd; /// The number of nodes allocated. public int nodeCapacity; + /// The head of the list of free node pairs below . + public int pairFreeList; + /// The number of proxies created. public int proxyCount; - /// The head of the node free list. - public int freeList; + /// The number of proxies allocated. + public int proxyCapacity; + + /// The head of the proxy free list. + public int proxyFreeList; + + /// Scratch storage: the node array swapped in during a rebuild. + public b3TreeNode* swapNodes; /// Scratch storage: leaf indices used during a rebuild. public int* leafIndices; + /// Scratch storage: the leaves of a rebuild, each a proxy or a retained subtree. + public b3TreeNode* leafNodes; + /// Scratch storage: leaf bounding boxes used during a rebuild. public b3AABB* leafBoxes; @@ -137,6 +151,12 @@ public unsafe struct b3DynamicTree /// The capacity of the rebuild scratch storage. public int rebuildCapacity; + + /// + /// Whether the nodes are in depth-first order, children after their parent. + /// + /// Set by a rebuild and disturbed by creating proxies. + public NativeBool dfsOrdered; } /// diff --git a/src/Box3D.NET.Native/Structs.World.cs b/src/Box3D.NET.Native/Structs.World.cs index 267965c..776745c 100644 --- a/src/Box3D.NET.Native/Structs.World.cs +++ b/src/Box3D.NET.Native/Structs.World.cs @@ -206,9 +206,6 @@ public struct b3Profile /// Time spent relaxing impulses. public float relaxImpulses; - /// Time spent applying restitution. - public float applyRestitution; - /// Time spent storing impulses. public float storeImpulses; @@ -246,6 +243,9 @@ public struct b3Profile [StructLayout(LayoutKind.Sequential)] public unsafe struct b3Counters { + /// The total bytes allocated. + public long byteCount; + /// The number of bodies. public int bodyCount; @@ -279,9 +279,6 @@ public unsafe struct b3Counters /// The number of separating axis cache hits. public int satCacheHitCount; - /// The total bytes allocated. - public int byteCount; - /// The number of tasks enqueued during the step. public int taskCount; diff --git a/src/Box3D.NET.Samples/CharacterControllerSample.cs b/src/Box3D.NET.Samples/CharacterControllerSample.cs index d41ef8e..841d52a 100644 --- a/src/Box3D.NET.Samples/CharacterControllerSample.cs +++ b/src/Box3D.NET.Samples/CharacterControllerSample.cs @@ -12,7 +12,8 @@ namespace Box3D.Samples; /// /// This is the sample to copy for player movement. It is a complete controller /// in about eighty lines: gravity, walking, jumping, ground detection, slope -/// limiting and sliding along walls. +/// limiting and sliding along walls. The end of it shows how a character that +/// is not simulated can still tell that a falling body has struck it. /// /// /// None of that logic lives in Box3D.NET, on purpose. What counts as ground, @@ -28,6 +29,8 @@ internal static class CharacterControllerSample // standing on it. Forty-five degrees. private static readonly float MinimumGroundNormalY = MathF.Cos(MathF.PI / 4.0f); + private static readonly Capsule CharacterCapsule = Capsule.Upright(height: 1.8f, radius: 0.3f); + private const int MaxPlanes = 16; /// Gathers the surfaces the capsule is touching, without allocating. @@ -59,7 +62,7 @@ private sealed class Character { private readonly PhysicsWorld _world; private readonly CollisionPlane[] _planes = new CollisionPlane[MaxPlanes]; - private readonly Capsule _capsule = Capsule.Upright(height: 1.8f, radius: 0.3f); + private readonly Capsule _capsule = CharacterCapsule; public Character(PhysicsWorld world, Vector3 start) { @@ -208,5 +211,42 @@ public static void Run() Console.WriteLine($" climbed : {climbed:F2} m up a {RampAngle * 180.0f / MathF.PI:F0} degree ramp"); SampleRunner.Expect(climbed > 0.3f, "the character walked up the slope"); + + // Drop a crate on a character standing still. The character is not in + // the simulation, so the crate falls straight through it and nothing + // reports the blow. Sweeping the crate from where it was before each step + // to where it is after is how the character finds out. + var bystander = new Character(world, new Vector3(-3.0f, 1.0f, -10.0f)); + for (int i = 0; i < 30; i++) + { + bystander.Update(Vector3.Zero, 0.0f, TimeStep); + } + + Body crate = world.CreateDynamicBody(new Vector3(-3.0f, 6.0f, -10.0f)); + crate.AddBox(Box.Cube(0.4f)); + + CharacterImpact impact = default; + for (int i = 0; i < 120 && !impact.Hit; i++) + { + Vector3 crateStart = crate.Position; + Quaternion crateStartRotation = crate.Rotation; + + world.Step(TimeStep); + + // Sweep before the character is resolved against the world. Once it + // has been pushed clear of the crate the two only touch, and a touch + // is not an impact. The bystander means to stand still, so its own + // translation for the step is zero. + impact = CharacterMover.TimeOfImpact( + crate, CharacterCapsule, bystander.Position, Vector3.Zero, + crateStart, crateStartRotation, crate.Position, crate.Rotation); + + bystander.Update(Vector3.Zero, 0.0f, TimeStep); + } + + Console.WriteLine($" crate struck : {impact.Hit} at {impact.Point}, from {impact.Normal}"); + + SampleRunner.Expect(impact.Hit, "the falling crate was seen to strike the character"); + SampleRunner.Expect(impact.Normal.Y < -0.9f, "and it came from above"); } } diff --git a/src/Box3D.NET.Tests/CharacterMoverTests.cs b/src/Box3D.NET.Tests/CharacterMoverTests.cs index da0c4cf..351710a 100644 --- a/src/Box3D.NET.Tests/CharacterMoverTests.cs +++ b/src/Box3D.NET.Tests/CharacterMoverTests.cs @@ -342,4 +342,359 @@ public void A_character_walks_along_a_wall_instead_of_through_it() // plane solver rather than a simple stop. Assert.True(position.Z > 1.0f, $"the character stopped dead instead of sliding, z = {position.Z}"); } + + // ------------------------------------------------------ what was touched + + private struct GatherContacts : ICharacterCollisionCallback + { + public CharacterContact[] Contacts; + public int Count; + + public bool OnContact(in CharacterContact contact) + { + if (Count < Contacts.Length) + { + Contacts[Count++] = contact; + } + + return true; + } + } + + // The contact from the surface directly beneath the capsule. + private CharacterContact GroundContactAt(Vector3 position) + { + Capsule capsule = Capsule.Upright(1.8f, 0.3f); + var gather = new GatherContacts { Contacts = new CharacterContact[MaxPlanes] }; + _world.CollideCapsule(capsule, position, ref gather); + + for (int i = 0; i < gather.Count; i++) + { + if (Vector3.Dot(gather.Contacts[i].Normal, Vector3.UnitY) > 0.9f) + { + return gather.Contacts[i]; + } + } + + Assert.Fail($"no ground contact at {position}; {gather.Count} contacts in all"); + return default; + } + + [NativeFact] + public void A_contact_with_a_mesh_names_the_same_triangle_a_ray_does() + { + // Eight by eight cells of one metre centred on the origin, two triangles + // each, so the capsule stands on a different triangle depending on where + // it is. + using CollisionMesh grid = CollisionMesh.Grid(8, 8, 1.0f); + _world.CreateStaticBody().AddMesh(grid); + _world.Step(1.0f / 60.0f); + + var seen = new System.Collections.Generic.HashSet(); + + foreach (Vector3 foot in new[] { new Vector3(-2.7f, 0.0f, -2.8f), new Vector3(1.6f, 0.0f, 2.3f), new Vector3(-0.8f, 0.0f, 2.7f) }) + { + RaycastHit ray = _world.RaycastClosest(foot + new Vector3(0.0f, 5.0f, 0.0f), new Vector3(0.0f, -10.0f, 0.0f)); + Assert.True(ray.Hit, $"the ray missed the grid at {foot}"); + + CharacterContact contact = GroundContactAt(foot + new Vector3(0.0f, 0.9f, 0.0f)); + + Assert.Equal(ray.Shape, contact.Shape); + Assert.Equal(ray.TriangleIndex, contact.TriangleIndex); + Assert.InRange(contact.TriangleIndex, 0, grid.TriangleCount - 1); + Assert.Equal(0, contact.ChildIndex); + + // A mesh attached here carries a single material, and Box3D clamps + // the index to the shape's material count. + Assert.Equal(0, contact.MaterialIndex); + + seen.Add(contact.TriangleIndex); + } + + Assert.Equal(3, seen.Count); + } + + [NativeFact] + public void A_contact_with_a_height_field_names_the_same_triangle_a_ray_does() + { + using HeightField terrain = HeightField.Grid(9, 9, new Vector3(1.0f, 1.0f, 1.0f)); + _world.CreateStaticBody().AddHeightField(terrain); + _world.Step(1.0f / 60.0f); + + Vector3 foot = new(2.3f, 0.0f, 4.6f); + RaycastHit ray = _world.RaycastClosest(foot + new Vector3(0.0f, 5.0f, 0.0f), new Vector3(0.0f, -10.0f, 0.0f)); + Assert.True(ray.Hit, "the ray missed the terrain"); + + CharacterContact contact = GroundContactAt(new Vector3(foot.X, ray.Point.Y + 0.9f, foot.Z)); + + Assert.Equal(ray.TriangleIndex, contact.TriangleIndex); + Assert.True(contact.TriangleIndex >= 0, $"triangle index {contact.TriangleIndex}"); + } + + [NativeFact] + public void A_contact_with_a_compound_names_the_child_and_its_material() + { + var stone = PhysicsMaterial.Default with { Friction = 0.9f, UserMaterialId = 1 }; + var ice = PhysicsMaterial.Default with { Friction = 0.02f, UserMaterialId = 2 }; + + // Two flat-topped boxes side by side, one of stone and one of ice. + using ConvexHull slab = ConvexHull.FromPoints( + [ + new Vector3(-1.0f, -0.5f, -1.0f), new Vector3(1.0f, -0.5f, -1.0f), + new Vector3(1.0f, -0.5f, 1.0f), new Vector3(-1.0f, -0.5f, 1.0f), + new Vector3(-1.0f, 0.5f, -1.0f), new Vector3(1.0f, 0.5f, -1.0f), + new Vector3(1.0f, 0.5f, 1.0f), new Vector3(-1.0f, 0.5f, 1.0f), + ]); + + using CompoundGeometry compound = new CompoundBuilder() + .AddHull(slab, new Vector3(-2.0f, -0.5f, 0.0f), material: stone) + .AddHull(slab, new Vector3(2.0f, -0.5f, 0.0f), material: ice) + .Build(); + + _world.CreateStaticBody().AddCompound(compound); + _world.Step(1.0f / 60.0f); + + CharacterContact onStone = GroundContactAt(new Vector3(-2.0f, 0.9f, 0.0f)); + CharacterContact onIce = GroundContactAt(new Vector3(2.0f, 0.9f, 0.0f)); + + RaycastHit stoneRay = _world.RaycastClosest(new Vector3(-2.0f, 5.0f, 0.0f), new Vector3(0.0f, -10.0f, 0.0f)); + RaycastHit iceRay = _world.RaycastClosest(new Vector3(2.0f, 5.0f, 0.0f), new Vector3(0.0f, -10.0f, 0.0f)); + + // The child index agrees with the one ray casts already report. + Assert.Equal(stoneRay.ChildIndex, onStone.ChildIndex); + Assert.Equal(iceRay.ChildIndex, onIce.ChildIndex); + Assert.NotEqual(onStone.ChildIndex, onIce.ChildIndex); + + // And the two surfaces are told apart by material, which is what lets a + // controller make the ice slippery. + Assert.Equal(1UL, stoneRay.UserMaterialId); + Assert.Equal(2UL, iceRay.UserMaterialId); + Assert.NotEqual(onStone.MaterialIndex, onIce.MaterialIndex); + Assert.InRange(onStone.MaterialIndex, 0, 1); + Assert.InRange(onIce.MaterialIndex, 0, 1); + } + + [NativeFact] + public void A_contact_with_a_convex_shape_reports_zero_indices() + { + AddFloor(); + _world.Step(1.0f / 60.0f); + + CharacterContact contact = GroundContactAt(new Vector3(0.0f, 0.9f, 0.0f)); + + Assert.Equal(0, contact.TriangleIndex); + Assert.Equal(0, contact.ChildIndex); + Assert.Equal(0, contact.MaterialIndex); + } + + // ---------------------------------------------------- one-sided triangles + + [NativeFact] + public void A_capsule_cast_stops_on_the_front_of_a_mesh() + { + using CollisionMesh grid = CollisionMesh.Grid(8, 8, 1.0f); + _world.CreateStaticBody().AddMesh(grid); + _world.Step(1.0f / 60.0f); + + Capsule capsule = Capsule.Upright(1.8f, 0.3f); + + float fraction = _world.CastCapsule(capsule, new Vector3(0.3f, 5.0f, 0.4f), new Vector3(0.0f, -10.0f, 0.0f)); + + // The capsule bottom starts 4.1 metres up, so it lands about 41% of the way. + Assert.True(fraction is > 0.3f and < 0.5f, $"the cast from above stopped at {fraction}"); + } + + [NativeFact] + public void A_capsule_cast_passes_through_the_back_of_a_mesh() + { + using CollisionMesh grid = CollisionMesh.Grid(8, 8, 1.0f); + _world.CreateStaticBody().AddMesh(grid); + _world.Step(1.0f / 60.0f); + + Capsule capsule = Capsule.Upright(1.8f, 0.3f); + + float fraction = _world.CastCapsule(capsule, new Vector3(0.3f, -5.0f, 0.4f), new Vector3(0.0f, 10.0f, 0.0f)); + + Assert.Equal(1.0f, fraction); + } + + [NativeFact] + public void A_capsule_cast_passes_through_the_back_of_a_height_field() + { + using HeightField terrain = HeightField.Grid(9, 9, new Vector3(1.0f, 1.0f, 1.0f)); + _world.CreateStaticBody().AddHeightField(terrain); + _world.Step(1.0f / 60.0f); + + Capsule capsule = Capsule.Upright(1.8f, 0.3f); + + float fromAbove = _world.CastCapsule(capsule, new Vector3(4.0f, 5.0f, 4.0f), new Vector3(0.0f, -10.0f, 0.0f)); + float fromBelow = _world.CastCapsule(capsule, new Vector3(4.0f, -5.0f, 4.0f), new Vector3(0.0f, 10.0f, 0.0f)); + + Assert.True(fromAbove < 1.0f, "the cast from above should land on the terrain"); + Assert.Equal(1.0f, fromBelow); + } + + [NativeFact] + public void A_capsule_under_a_mesh_touches_nothing() + { + using CollisionMesh grid = CollisionMesh.Grid(8, 8, 1.0f); + _world.CreateStaticBody().AddMesh(grid); + _world.Step(1.0f / 60.0f); + + // Its top cap pokes through the underside of the floor. + var callback = new CountContacts(); + _world.CollideCapsule(Capsule.Upright(1.8f, 0.3f), new Vector3(0.3f, -1.0f, 0.4f), ref callback); + + Assert.Equal(0, callback.Count); + } + + // --------------------------------------------------------- time of impact + + private static readonly Capsule Character = Capsule.Upright(1.8f, 0.3f); + + // A crate one metre to the side of a character standing at the origin. + private Body CreateCrate(Vector3 position, BodyType type = BodyType.Kinematic) + { + Body crate = _world.CreateBody(BodyDefinition.Default with { Type = type, Position = position }); + crate.AddBox(Box.Cube(0.5f)); + return crate; + } + + [NativeFact] + public void A_body_swept_into_a_standing_character_reports_the_impact() + { + Body crate = CreateCrate(new Vector3(3.0f, 0.0f, 0.0f)); + + // The crate slides from x = 3 to x = -1 while the character stands still. + CharacterImpact impact = CharacterMover.TimeOfImpact( + crate, Character, Vector3.Zero, Vector3.Zero, + new Vector3(3.0f, 0.0f, 0.0f), Quaternion.Identity, + new Vector3(-1.0f, 0.0f, 0.0f), Quaternion.Identity); + + Assert.True(impact.Hit); + Assert.Equal(crate, impact.Shape.Body); + + // The crate face at x - 0.5 meets the capsule surface at x = 0.3, so the + // crate centre reaches x = 0.8: 2.2 of its 4 metres. + Assert.True(impact.Fraction is > 0.5f and < 0.6f, $"impact at fraction {impact.Fraction}"); + + // The normal points from the body to the character. + Assert.True(impact.Normal.X < -0.9f, $"normal {impact.Normal}"); + Assert.True(MathF.Abs(impact.Point.X - 0.3f) < 0.05f, $"point {impact.Point}"); + } + + [NativeFact] + public void A_character_walking_into_a_still_body_reports_the_impact() + { + Body crate = CreateCrate(new Vector3(3.0f, 0.0f, 0.0f)); + Vector3 pose = crate.Position; + + CharacterImpact impact = CharacterMover.TimeOfImpact( + crate, Character, Vector3.Zero, new Vector3(4.0f, 0.0f, 0.0f), + pose, Quaternion.Identity, pose, Quaternion.Identity); + + Assert.True(impact.Hit); + + // The capsule surface reaches the crate face at x = 2.5 after 2.2 metres. + Assert.True(impact.Fraction is > 0.5f and < 0.6f, $"impact at fraction {impact.Fraction}"); + } + + [NativeFact] + public void A_body_moving_away_misses_the_character() + { + Body crate = CreateCrate(new Vector3(3.0f, 0.0f, 0.0f)); + + CharacterImpact impact = CharacterMover.TimeOfImpact( + crate, Character, Vector3.Zero, Vector3.Zero, + new Vector3(3.0f, 0.0f, 0.0f), Quaternion.Identity, + new Vector3(6.0f, 0.0f, 0.0f), Quaternion.Identity); + + Assert.False(impact.Hit); + Assert.Equal(1.0f, impact.Fraction); + Assert.False(impact.Shape.IsValid); + } + + [NativeFact] + public void A_body_already_overlapping_the_character_is_ignored() + { + Body crate = CreateCrate(Vector3.Zero); + + CharacterImpact impact = CharacterMover.TimeOfImpact( + crate, Character, Vector3.Zero, Vector3.Zero, + new Vector3(0.2f, 0.0f, 0.0f), Quaternion.Identity, + new Vector3(-0.2f, 0.0f, 0.0f), Quaternion.Identity); + + Assert.False(impact.Hit); + } + + [NativeFact] + public void The_filter_decides_which_shapes_on_the_body_can_strike() + { + Body crate = _world.CreateBody(BodyDefinition.Kinematic(new Vector3(3.0f, 0.0f, 0.0f))); + crate.AddBox(Box.Cube(0.5f), ShapeDefinition.Default with + { + Filter = CollisionFilter.Default with { Categories = 0x2 }, + }); + + CharacterImpact Sweep(QueryFilter filter) => CharacterMover.TimeOfImpact( + crate, Character, Vector3.Zero, Vector3.Zero, + new Vector3(3.0f, 0.0f, 0.0f), Quaternion.Identity, + new Vector3(-1.0f, 0.0f, 0.0f), Quaternion.Identity, + filter); + + Assert.True(Sweep(QueryFilter.Default).Hit); + Assert.False(Sweep(QueryFilter.Default with { CollidesWith = 0x4 }).Hit); + } + + [NativeFact] + public void A_falling_body_strikes_the_character_during_a_step() + { + // The way the sweep is meant to be used: record the pose, step, then + // sweep from the old pose to the new one. + Body crate = CreateCrate(new Vector3(0.0f, 2.0f, 0.0f), BodyType.Dynamic); + crate.LinearVelocity = new Vector3(0.0f, -40.0f, 0.0f); + + Vector3 start = crate.Position; + Quaternion startRotation = crate.Rotation; + + _world.Step(1.0f / 60.0f); + + CharacterImpact impact = CharacterMover.TimeOfImpact( + crate, Character, Vector3.Zero, Vector3.Zero, + start, startRotation, crate.Position, crate.Rotation); + + Assert.True(impact.Hit, $"the crate fell from {start} to {crate.Position} without striking"); + Assert.True(impact.Normal.Y < -0.9f, $"a falling crate strikes from above, normal {impact.Normal}"); + Assert.True(impact.Fraction is > 0.0f and < 1.0f); + } + + [NativeFact] + public void Only_convex_shapes_on_a_body_are_swept() + { + // Box3D sweeps spheres, capsules and hulls. A compound on its own body + // is skipped, so the same geometry reports nothing. + using CompoundGeometry compound = new CompoundBuilder() + .AddSphere(new Sphere(0.5f)) + .Build(); + + Body body = _world.CreateStaticBody(new Vector3(3.0f, 0.0f, 0.0f)); + body.AddCompound(compound); + + CharacterImpact impact = CharacterMover.TimeOfImpact( + body, Character, Vector3.Zero, new Vector3(4.0f, 0.0f, 0.0f), + body.Position, Quaternion.Identity, body.Position, Quaternion.Identity); + + Assert.False(impact.Hit); + } + + [NativeFact] + public void A_stale_body_cannot_be_swept() + { + Body crate = CreateCrate(new Vector3(3.0f, 0.0f, 0.0f)); + crate.Destroy(); + + Assert.Throws(() => CharacterMover.TimeOfImpact( + crate, Character, Vector3.Zero, Vector3.Zero, + Vector3.Zero, Quaternion.Identity, Vector3.Zero, Quaternion.Identity)); + } } diff --git a/src/Box3D.NET.Tests/FuzzTests.cs b/src/Box3D.NET.Tests/FuzzTests.cs index 5aa3e15..7a397ad 100644 --- a/src/Box3D.NET.Tests/FuzzTests.cs +++ b/src/Box3D.NET.Tests/FuzzTests.cs @@ -48,20 +48,24 @@ public void Dispose() // ----------------------------------------------------------- the reason [NativeFact] - public void One_bad_velocity_would_otherwise_poison_unrelated_bodies() + public void One_bad_velocity_would_otherwise_poison_the_bodies_it_touches() { // This is the measurement that justifies validating at all. Setting one - // body's velocity to NaN and stepping leaves a second body, twenty metres - // away and never touched, reading NaN. The solver couples bodies through - // islands and the broad phase, so there is no containment and no way to - // remove it afterwards. + // body's velocity to NaN and stepping leaves a second body, one it is + // merely resting against, reading NaN. The solver couples bodies through + // contact, so the value spreads through whatever the bad body touches and + // there is no way to remove it afterwards. + // + // Before Box3D 9e5a4cd the spread was wider still: a body twenty metres + // away and never touched was poisoned too. The broad-phase rewrite + // upstream contained that, but not contamination through contact. // // Going through the native layer directly reproduces what the library // would do without the check. Body poisoned = _world.CreateDynamicBody(new Vector3(0.0f, 10.0f, 0.0f)); poisoned.AddSphere(new Sphere(0.5f)); - Body innocent = _world.CreateDynamicBody(new Vector3(20.0f, 10.0f, 0.0f)); + Body innocent = _world.CreateDynamicBody(new Vector3(0.0f, 11.0f, 0.0f)); innocent.AddSphere(new Sphere(0.5f)); Box3D.Native.B3.b3Body_SetLinearVelocity( @@ -249,6 +253,42 @@ public void A_zero_density_shape_gives_the_body_no_mass() Assert.Equal(0.0f, body.Mass); } + [NativeTheory] + [MemberData(nameof(NonFiniteValues))] + public void A_non_finite_safety_factor_is_rejected(float bad) + { + Body body = _world.CreateDynamicBody(); + body.AddSphere(new Sphere(0.5f)); + + Assert.Throws(() => body.SafetyFactor = bad); + Assert.Throws(() => + _world.CreateBody(BodyDefinition.Dynamic() with { SafetyFactor = bad })); + } + + [NativeFact] + public void A_negative_safety_factor_is_rejected() + { + Body body = _world.CreateDynamicBody(); + + Assert.Throws(() => body.SafetyFactor = -0.1f); + Assert.Throws(() => + _world.CreateBody(BodyDefinition.Dynamic() with { SafetyFactor = -0.1f })); + } + + [NativeFact] + public void The_safety_factor_reaches_the_body_and_can_be_changed() + { + // The engine default comes through the definition untouched. + Assert.Equal(0.5f, BodyDefinition.Default.SafetyFactor); + Assert.Equal(0.5f, _world.CreateDynamicBody().SafetyFactor); + + Body careful = _world.CreateBody(BodyDefinition.Dynamic() with { SafetyFactor = 0.05f }); + Assert.Equal(0.05f, careful.SafetyFactor); + + careful.SafetyFactor = 0.25f; + Assert.Equal(0.25f, careful.SafetyFactor); + } + [NativeFact] public void Extreme_but_finite_tuning_does_not_break_the_world() { diff --git a/src/Box3D.NET.Tests/GeometryOwnershipTests.cs b/src/Box3D.NET.Tests/GeometryOwnershipTests.cs index a2f30eb..55eb9c4 100644 --- a/src/Box3D.NET.Tests/GeometryOwnershipTests.cs +++ b/src/Box3D.NET.Tests/GeometryOwnershipTests.cs @@ -44,9 +44,9 @@ private static void AssertNoLeak(string what, Action operation) // measuring from cold would report that as a leak. operation(); - int before = B3.b3GetByteCount(); + long before = B3.b3GetByteCount(); operation(); - int after = B3.b3GetByteCount(); + long after = B3.b3GetByteCount(); Assert.True( before == after, diff --git a/src/Box3D.NET.Tests/GeometryTests.cs b/src/Box3D.NET.Tests/GeometryTests.cs index c2b9597..069a60a 100644 --- a/src/Box3D.NET.Tests/GeometryTests.cs +++ b/src/Box3D.NET.Tests/GeometryTests.cs @@ -2,6 +2,7 @@ using System; using System.Numerics; +using System.Runtime.InteropServices; using Box3D.Native; using Xunit; @@ -141,7 +142,7 @@ public void Disposing_a_hull_twice_is_harmless() [NativeFact] public void A_hull_gives_its_memory_back() { - int before = B3.b3GetByteCount(); + long before = B3.b3GetByteCount(); ConvexHull hull = ConvexHull.Cylinder(2.0f, 1.0f, sides: 32); Assert.True(B3.b3GetByteCount() > before, "building a hull should allocate"); @@ -190,6 +191,180 @@ public void A_mesh_rejects_a_material_index_count_that_is_not_per_triangle() CollisionMesh.FromTriangles(FloorVertices, FloorIndices, materialIndices: [0, 0, 0])); } + // The same floor wound the other way, as a tool with the opposite convention + // would export it. + private static readonly int[] ClockwiseFloorIndices = [0, 1, 2, 0, 2, 3]; + + // A render vertex with the position deliberately not first, and a normal + // that would put the mesh somewhere else entirely if it were read instead. + [StructLayout(LayoutKind.Sequential)] + private struct RenderVertex + { + public Vector3 Normal; + public Vector3 Position; + public Vector2 Uv; + } + + // Thirteen bytes, which no stride Box3D accepts can describe. + [StructLayout(LayoutKind.Sequential, Pack = 1)] + private struct PackedVertex + { + public Vector3 Position; + public byte Flags; + } + + private static RenderVertex[] InterleavedFloor() + { + var vertices = new RenderVertex[FloorVertices.Length]; + for (int i = 0; i < vertices.Length; i++) + { + vertices[i] = new RenderVertex + { + Normal = new Vector3(100.0f, 100.0f, 100.0f), + Position = FloorVertices[i], + Uv = new Vector2(i, -i), + }; + } + + return vertices; + } + + private static readonly int PositionOffset = (int)Marshal.OffsetOf(nameof(RenderVertex.Position)); + + // Ray casts only see the front of a triangle, so the side a ray hits from is + // the side the mesh faces. + private static (bool FromAbove, bool FromBelow) SidesThatBlockARay(CollisionMesh mesh) + { + using var world = new PhysicsWorld(); + world.CreateStaticBody().AddMesh(mesh); + + RaycastHit down = world.RaycastClosest(new Vector3(1.0f, 10.0f, 1.0f), new Vector3(0.0f, -20.0f, 0.0f)); + RaycastHit up = world.RaycastClosest(new Vector3(1.0f, -10.0f, 1.0f), new Vector3(0.0f, 20.0f, 0.0f)); + + return (down.Hit, up.Hit); + } + + [NativeFact] + public void A_mesh_read_with_the_plain_vector_stride_matches_the_contiguous_build() + { + using CollisionMesh contiguous = CollisionMesh.FromTriangles(FloorVertices, FloorIndices); + using CollisionMesh strided = CollisionMesh.FromTriangles(FloorVertices, 0, FloorIndices); + + Assert.Equal(contiguous.TriangleCount, strided.TriangleCount); + Assert.Equal(contiguous.VertexCount, strided.VertexCount); + Assert.Equal(contiguous.Bounds, strided.Bounds); + } + + [NativeFact] + public void A_mesh_reads_its_positions_out_of_an_interleaved_vertex_buffer() + { + Assert.Equal(12, PositionOffset); + + using CollisionMesh mesh = CollisionMesh.FromTriangles( + InterleavedFloor(), PositionOffset, FloorIndices); + + // Reading the normal or the texture coordinates by mistake would move the + // box to y = 100 or pull it off the floor's footprint. + Assert.Equal(2, mesh.TriangleCount); + Assert.Equal(-5.0f, mesh.Bounds.Min.X, 3); + Assert.Equal(5.0f, mesh.Bounds.Max.X, 3); + Assert.Equal(-5.0f, mesh.Bounds.Min.Z, 3); + Assert.Equal(5.0f, mesh.Bounds.Max.Z, 3); + Assert.Equal(0.0f, mesh.Bounds.Min.Y, 3); + Assert.Equal(0.0f, mesh.Bounds.Max.Y, 3); + + Assert.Equal((true, false), SidesThatBlockARay(mesh)); + } + + [NativeFact] + public void An_interleaved_mesh_can_be_welded() + { + // Welding reads the source vertices through the stride too. + using CollisionMesh mesh = CollisionMesh.FromTriangles( + InterleavedFloor(), PositionOffset, FloorIndices, MeshOptions.Default with { WeldVertices = true }); + + Assert.Equal(4, mesh.VertexCount); + Assert.Equal(0.0f, mesh.Bounds.Max.Y, 3); + } + + [NativeFact] + public void A_vertex_smaller_than_a_position_is_rejected() + { + Vector2[] tooSmall = [Vector2.Zero, Vector2.UnitX, Vector2.UnitY]; + + Assert.Throws(() => CollisionMesh.FromTriangles(tooSmall, 0, [0, 1, 2])); + } + + [NativeFact] + public void A_vertex_whose_size_is_not_a_multiple_of_four_is_rejected() + { + Assert.Equal(13, Marshal.SizeOf()); + + var vertices = new PackedVertex[3]; + + Assert.Throws(() => CollisionMesh.FromTriangles(vertices, 0, [0, 1, 2])); + } + + [NativeTheory] + [InlineData(-4)] + [InlineData(2)] + [InlineData(24)] + public void A_position_that_does_not_fit_the_vertex_is_rejected(int positionOffset) + { + // RenderVertex is 32 bytes: 24 would run the position past its end, and 2 + // would read it off a four-byte boundary. + Assert.Throws(() => + CollisionMesh.FromTriangles(InterleavedFloor(), positionOffset, FloorIndices)); + } + + [NativeFact] + public void A_counter_clockwise_mesh_faces_the_side_it_is_wound_towards() + { + using CollisionMesh mesh = CollisionMesh.FromTriangles(FloorVertices, FloorIndices); + + Assert.Equal((true, false), SidesThatBlockARay(mesh)); + } + + [NativeFact] + public void A_clockwise_mesh_declared_as_clockwise_faces_the_same_way() + { + using CollisionMesh mesh = CollisionMesh.FromTriangles( + FloorVertices, ClockwiseFloorIndices, MeshOptions.Default with { ClockwiseWinding = true }); + + Assert.Equal((true, false), SidesThatBlockARay(mesh)); + } + + [NativeFact] + public void A_clockwise_mesh_left_undeclared_faces_the_other_way() + { + // This is the mistake the option exists to prevent: a mesh exported with + // the other convention is solid only from underneath. + using CollisionMesh mesh = CollisionMesh.FromTriangles(FloorVertices, ClockwiseFloorIndices); + + Assert.Equal((false, true), SidesThatBlockARay(mesh)); + } + + [NativeFact] + public void Declaring_a_counter_clockwise_mesh_clockwise_flips_it() + { + using CollisionMesh mesh = CollisionMesh.FromTriangles( + FloorVertices, FloorIndices, MeshOptions.Default with { ClockwiseWinding = true }); + + Assert.Equal((false, true), SidesThatBlockARay(mesh)); + } + + [NativeFact] + public void An_interleaved_clockwise_mesh_faces_the_right_way() + { + using CollisionMesh mesh = CollisionMesh.FromTriangles( + InterleavedFloor(), + PositionOffset, + ClockwiseFloorIndices, + MeshOptions.Fast with { ClockwiseWinding = true }); + + Assert.Equal((true, false), SidesThatBlockARay(mesh)); + } + [NativeFact] public void A_body_rests_on_a_mesh_floor() { @@ -253,7 +428,7 @@ public void A_mesh_may_only_go_on_a_static_body() [NativeFact] public void A_mesh_gives_its_memory_back() { - int before = B3.b3GetByteCount(); + long before = B3.b3GetByteCount(); CollisionMesh mesh = CollisionMesh.Grid(16, 16, 1.0f); Assert.True(B3.b3GetByteCount() > before, "building a mesh should allocate"); @@ -390,7 +565,7 @@ public void A_height_field_may_only_go_on_a_static_body() [NativeFact] public void A_height_field_gives_its_memory_back() { - int before = B3.b3GetByteCount(); + long before = B3.b3GetByteCount(); HeightField field = HeightField.Grid(32, 32, Vector3.One); Assert.True(B3.b3GetByteCount() > before, "building a height field should allocate"); @@ -580,7 +755,7 @@ public void A_compound_gives_its_memory_back() { using ConvexHull tetrahedron = ConvexHull.FromPoints(TetrahedronPoints); - int before = B3.b3GetByteCount(); + long before = B3.b3GetByteCount(); CompoundGeometry compound = new CompoundBuilder() .AddHull(tetrahedron, Vector3.Zero) diff --git a/src/Box3D.NET.Tests/JointTests.cs b/src/Box3D.NET.Tests/JointTests.cs index 00f2f6f..4b9fb33 100644 --- a/src/Box3D.NET.Tests/JointTests.cs +++ b/src/Box3D.NET.Tests/JointTests.cs @@ -408,4 +408,49 @@ public void Joint_frames_reject_a_zero_axis() Assert.Throws(() => Joint.FramesFromWorldAnchor(a, b, Vector3.Zero, Vector3.Zero)); } + + // ---------------------------------------------------------------- sleep + + [NativeFact] + public void A_joint_is_awake_while_its_body_is_and_sleeps_with_it() + { + Body anchor = CreateAnchor(new Vector3(0.0f, 10.0f, 0.0f)); + Body welded = _world.CreateBody(BodyDefinition.Dynamic(new Vector3(1.0f, 10.0f, 0.0f))); + welded.AddBox(Box.Cube(0.25f)); + + Joint joint = _world.CreateWeldJoint( + WeldJointDefinition.Weld(anchor, welded, new Vector3(0.5f, 10.0f, 0.0f))).AsJoint; + + Assert.True(joint.IsAwake, "a new joint on an awake body should be awake"); + + // Held still by a static anchor, the body settles and falls asleep, and + // the joint goes with it. + Simulate(240); + + Assert.False(welded.IsAwake, "the welded body should have settled and slept"); + Assert.False(joint.IsAwake, "a joint whose only dynamic body sleeps should sleep too"); + + welded.IsAwake = true; + + Assert.True(joint.IsAwake, "waking the body should wake its joint"); + } + + [NativeFact] + public void Waking_the_bodies_through_the_joint_wakes_the_joint() + { + Body anchor = CreateAnchor(new Vector3(0.0f, 10.0f, 0.0f)); + Body welded = _world.CreateBody(BodyDefinition.Dynamic(new Vector3(1.0f, 10.0f, 0.0f))); + welded.AddBox(Box.Cube(0.25f)); + + Joint joint = _world.CreateWeldJoint( + WeldJointDefinition.Weld(anchor, welded, new Vector3(0.5f, 10.0f, 0.0f))).AsJoint; + + Simulate(240); + Assert.False(joint.IsAwake); + + joint.WakeBodies(); + + Assert.True(joint.IsAwake); + Assert.True(welded.IsAwake); + } } diff --git a/src/Box3D.NET.Tests/LayoutTests.cs b/src/Box3D.NET.Tests/LayoutTests.cs index 6b1ddfb..efee3ac 100644 --- a/src/Box3D.NET.Tests/LayoutTests.cs +++ b/src/Box3D.NET.Tests/LayoutTests.cs @@ -98,8 +98,21 @@ public void Id_types_have_the_C_size(Type type, int expected) [InlineData(typeof(b3Manifold), 268)] [InlineData(typeof(b3FeaturePair), 4)] [InlineData(typeof(b3SATCache), 8)] - [InlineData(typeof(b3TreeNode), 48)] + [InlineData(typeof(b3TreeNode), 32)] + [InlineData(typeof(b3TreeProxy), 24)] + [InlineData(typeof(b3DynamicTree), 112)] [InlineData(typeof(b3ChildShape), 80)] + [InlineData(typeof(b3PlaneResult), 40)] + [InlineData(typeof(b3BodyPlaneResult), 48)] + [InlineData(typeof(b3BodyTOIResult), 36)] + [InlineData(typeof(b3Point2D), 16)] + [InlineData(typeof(b3MeshDef), 48)] + [InlineData(typeof(b3MeshData), 96)] + [InlineData(typeof(b3HullData), 144)] + [InlineData(typeof(b3BoxHull), 640)] + [InlineData(typeof(b3HeightFieldData), 96)] + [InlineData(typeof(b3Profile), 88)] + [InlineData(typeof(b3Counters), 208)] public void Public_structs_have_the_C_size(Type type, int expected) { Assert.Equal(expected, SizeOf(type)); @@ -164,6 +177,82 @@ public void MotionLocks_packs_one_byte_per_axis() Assert.Equal(0, raw[5]); // angularZ } + /* + * b3MeshDef gained a size_t stride after the vertex pointer and a trailing + * bool. The stride is pointer-sized, so everything after it moved by eight + * bytes, and the new bool fills what used to be the struct's last padding + * byte: 8 + 8 + 8 + 8 + 4 + 4 + 4 + four bools = 48. + */ + + [Fact] + public void MeshDef_puts_the_stride_after_the_vertices_and_the_winding_last() + { + Assert.Equal(8, (int)Marshal.OffsetOf(nameof(b3MeshDef.stride))); + Assert.Equal(16, (int)Marshal.OffsetOf(nameof(b3MeshDef.indices))); + Assert.Equal(24, (int)Marshal.OffsetOf(nameof(b3MeshDef.materialIndices))); + Assert.Equal(32, (int)Marshal.OffsetOf(nameof(b3MeshDef.weldTolerance))); + Assert.Equal(44, (int)Marshal.OffsetOf(nameof(b3MeshDef.weldVertices))); + Assert.Equal(47, (int)Marshal.OffsetOf(nameof(b3MeshDef.clockWiseWinding))); + Assert.Equal(sizeof(ulong), Unsafe.SizeOf()); + } + + /* + * b3PlaneResult appended three ints after the 28 bytes of plane and point, + * and b3BodyPlaneResult embeds it after an 8-byte shape id. + */ + + [Fact] + public void PlaneResult_appends_the_triangle_child_and_material_indices() + { + Assert.Equal(28, (int)Marshal.OffsetOf(nameof(b3PlaneResult.triangleIndex))); + Assert.Equal(32, (int)Marshal.OffsetOf(nameof(b3PlaneResult.childIndex))); + Assert.Equal(36, (int)Marshal.OffsetOf(nameof(b3PlaneResult.materialIndex))); + Assert.Equal(8, (int)Marshal.OffsetOf(nameof(b3BodyPlaneResult.result))); + } + + [Fact] + public void BodyTOIResult_is_point_normal_fraction_then_shape() + { + Assert.Equal(0, (int)Marshal.OffsetOf(nameof(b3BodyTOIResult.point))); + Assert.Equal(12, (int)Marshal.OffsetOf(nameof(b3BodyTOIResult.normal))); + Assert.Equal(24, (int)Marshal.OffsetOf(nameof(b3BodyTOIResult.fraction))); + Assert.Equal(28, (int)Marshal.OffsetOf(nameof(b3BodyTOIResult.shapeId))); + } + + /* + * The geometry headers moved to 64-bit hashes. The hash now follows the + * version directly, where the old byte count and 32-bit hash used to share + * one eight-byte slot, so every field after it moved. + */ + + [Fact] + public void Baked_geometry_keeps_a_64_bit_hash_right_after_the_version() + { + Assert.Equal(8, (int)Marshal.OffsetOf(nameof(b3HullData.hash))); + Assert.Equal(140, (int)Marshal.OffsetOf(nameof(b3HullData.byteCount))); + + Assert.Equal(8, (int)Marshal.OffsetOf(nameof(b3MeshData.hash))); + Assert.Equal(16, (int)Marshal.OffsetOf(nameof(b3MeshData.byteCount))); + + Assert.Equal(8, (int)Marshal.OffsetOf(nameof(b3HeightFieldData.hash))); + Assert.Equal(16, (int)Marshal.OffsetOf(nameof(b3HeightFieldData.byteCount))); + Assert.Equal(88, (int)Marshal.OffsetOf(nameof(b3HeightFieldData.clockwise))); + } + + [Fact] + public void TreeNode_union_members_share_the_last_word() + { + b3TreeNode node = default; + node.height = 7; + + Assert.Equal(7, node.shapeIndex); + + node.flagIndex = 0x80000000u | 0x40000000u | 42u; + Assert.True(node.IsLeaf); + Assert.True(node.IsMoved); + Assert.Equal(42, node.Index); + } + /* * The inline arrays must occupy exactly the space of the C arrays they * replace, with no header of their own. diff --git a/src/Box3D.NET.Tests/NativeInteropTests.cs b/src/Box3D.NET.Tests/NativeInteropTests.cs index fee116e..ba6341b 100644 --- a/src/Box3D.NET.Tests/NativeInteropTests.cs +++ b/src/Box3D.NET.Tests/NativeInteropTests.cs @@ -98,6 +98,7 @@ public void DefaultBodyDef_comes_back_with_sane_values() Assert.True(def.isAwake); Assert.True(def.isEnabled); Assert.False(def.isBullet); + Assert.Equal(0.5f, def.safetyFactor); } [NativeFact] @@ -327,7 +328,7 @@ public void Creating_and_destroying_worlds_does_not_leak() b3WorldId first = B3.b3CreateWorld(&warmup); B3.b3DestroyWorld(first); - int before = B3.b3GetByteCount(); + long before = B3.b3GetByteCount(); for (int i = 0; i < 16; ++i) { @@ -347,7 +348,7 @@ public void Creating_and_destroying_worlds_does_not_leak() B3.b3DestroyWorld(world); } - int after = B3.b3GetByteCount(); + long after = B3.b3GetByteCount(); Assert.Equal(before, after); } @@ -355,7 +356,7 @@ public void Creating_and_destroying_worlds_does_not_leak() [NativeFact] public void A_created_hull_is_freed_by_the_caller() { - int before = B3.b3GetByteCount(); + long before = B3.b3GetByteCount(); b3HullData* hull = B3.b3CreateCylinder(2.0f, 1.0f, 0.0f, 16); try @@ -394,7 +395,7 @@ public void A_box_hull_is_self_contained_and_must_not_be_freed() { // b3MakeBoxHull returns by value with its arrays embedded, so it does // not allocate and must never reach b3DestroyHull. - int before = B3.b3GetByteCount(); + long before = B3.b3GetByteCount(); b3BoxHull box = B3.b3MakeBoxHull(1.0f, 2.0f, 3.0f); @@ -490,4 +491,112 @@ public void Length_units_are_reported_as_the_default() Assert.Equal(1.0f, B3.b3GetLengthUnitsPerMeter(), 6); Assert.Equal(0.005f, ScaledConstants.B3_LINEAR_SLOP, 6); } + + // ----------------------------------------------------------- build facts + + [NativeFact] + public void The_library_was_built_with_the_manifold_size_the_mirrors_assume() + { + // b3Manifold carries B3_MAX_MANIFOLD_POINTS points inline, and every + // structure holding one is laid out around that number. Box3D added this + // function precisely so that a binding can check it. + Assert.Equal(Constants.B3_MAX_MANIFOLD_POINTS, B3.b3GetMaxManifoldPoints()); + } + + [NativeFact] + public void Baked_geometry_carries_the_format_versions_the_constants_declare() + { + // The versions changed with the switch to 64-bit hashes. Data baked by an + // older Box3D is rejected on this field, so the constants have to agree + // with the library that does the rejecting. + b3MeshData* mesh = B3.b3CreateGridMesh(2, 2, 1.0f, 1, true); + b3HeightFieldData* field = B3.b3CreateGrid(3, 3, Vector3.One, false); + try + { + Assert.Equal(Constants.B3_MESH_VERSION, mesh->version); + Assert.Equal(Constants.B3_HEIGHT_FIELD_VERSION, field->version); + Assert.NotEqual(0UL, mesh->hash); + Assert.NotEqual(0UL, field->hash); + } + finally + { + B3.b3DestroyMesh(mesh); + B3.b3DestroyHeightField(field); + } + } + + [NativeFact] + public void A_mesh_def_with_an_unusable_stride_is_refused() + { + Vector3* vertices = stackalloc Vector3[3] { Vector3.Zero, Vector3.UnitX, Vector3.UnitZ }; + int* indices = stackalloc int[3] { 0, 2, 1 }; + + b3MeshDef def = new() + { + vertices = vertices, + indices = indices, + vertexCount = 3, + triangleCount = 1, + stride = 14, + }; + + // Box3D reads the stride as a byte count, so one that is not a multiple + // of four, or is smaller than a vector, can only be a mistake. + Assert.True(B3.b3CreateMesh(&def, null, 0) is null); + + def.stride = 8; + Assert.True(B3.b3CreateMesh(&def, null, 0) is null); + + def.stride = (nuint)sizeof(Vector3); + b3MeshData* mesh = B3.b3CreateMesh(&def, null, 0); + Assert.True(mesh is not null); + B3.b3DestroyMesh(mesh); + } + + // ------------------------------------------------- one-sided shape casts + + [NativeFact] + public void A_shape_cast_hits_the_front_of_a_mesh_and_passes_through_the_back() + { + b3WorldDef worldDef = B3.b3DefaultWorldDef(); + b3WorldId world = B3.b3CreateWorld(&worldDef); + b3MeshData* grid = B3.b3CreateGridMesh(4, 4, 1.0f, 1, true); + try + { + b3BodyDef bodyDef = B3.b3DefaultBodyDef(); + b3BodyId body = B3.b3CreateBody(world, &bodyDef); + b3ShapeDef shapeDef = B3.b3DefaultShapeDef(); + B3.b3CreateMeshShape(body, &shapeDef, grid, Vector3.One); + B3.b3World_Step(world, 1.0f / 60.0f, 4); + + // A sphere of radius 0.25, as a one-point proxy. + Vector3 point = Vector3.Zero; + b3ShapeProxy proxy = new() { points = &point, count = 1, radius = 0.25f }; + b3Transform pose = B3.b3Body_GetTransform(body); + // The grid is four metres across, centred on the origin. + Vector3 above = new(0.3f, 2.0f, 0.4f); + Vector3 below = new(0.3f, -2.0f, 0.4f); + + b3BodyCastResult down = B3.b3Body_CastShape( + body, above, &proxy, new Vector3(0.0f, -4.0f, 0.0f), B3.b3DefaultQueryFilter(), 1.0f, false, pose); + + b3BodyCastResult up = B3.b3Body_CastShape( + body, below, &proxy, new Vector3(0.0f, 4.0f, 0.0f), B3.b3DefaultQueryFilter(), 1.0f, false, pose); + + // The grid lies at y = 0 facing up, so from above the sphere lands + // after 1.75 of its 4 metres. + Assert.False(down.shapeId.IsNull, "the cast from above should hit the front of the mesh"); + Assert.Equal(1.75f / 4.0f, down.fraction, 2); + Assert.True(down.normal.Y > 0.9f, $"normal {down.normal}"); + + // From below it meets only the back of each triangle, which casts + // ignore, as ray casts always have. + Assert.True(up.shapeId.IsNull, $"the cast from below hit the back of the mesh at fraction {up.fraction}"); + } + finally + { + B3.b3DestroyWorld(world); + B3.b3DestroyMesh(grid); + } + } } diff --git a/src/Box3D.NET.Tests/StressTests.cs b/src/Box3D.NET.Tests/StressTests.cs index 27164a5..fe1b1eb 100644 --- a/src/Box3D.NET.Tests/StressTests.cs +++ b/src/Box3D.NET.Tests/StressTests.cs @@ -38,9 +38,9 @@ private static void AssertNoLeak(string what, Action operation) // so measuring from cold would report that as a leak. operation(); - int before = B3.b3GetByteCount(); + long before = B3.b3GetByteCount(); operation(); - int after = B3.b3GetByteCount(); + long after = B3.b3GetByteCount(); Assert.True( before == after, @@ -268,9 +268,9 @@ public void Creating_and_destroying_bodies_in_a_live_world_does_not_leak() Churn(world); } - int before = B3.b3GetByteCount(); + long before = B3.b3GetByteCount(); Churn(world); - int after = B3.b3GetByteCount(); + long after = B3.b3GetByteCount(); // Box3D's arrays grow and keep their capacity, so this checks that // repeated churn does not grow without bound rather than that it returns diff --git a/src/Box3D.NET/Body.cs b/src/Box3D.NET/Body.cs index ac4b671..2bcb85e 100644 --- a/src/Box3D.NET/Body.cs +++ b/src/Box3D.NET/Body.cs @@ -166,6 +166,27 @@ public bool IsBullet set => B3.b3Body_SetBullet(Id, value); } + /// + /// Gets or sets the continuous collision safety factor. Non-dimensional. + /// + /// The value is not finite. + /// The value is negative. + /// + /// Smaller is safer against tunneling but can make the body hitch. The + /// recommended range is 0.01 to 0.5. See . + /// + public float SafetyFactor + { + get => B3.b3Body_GetSafetyFactor(Id); + + set + { + Validate.Finite(value); + ArgumentOutOfRangeException.ThrowIfNegative(value); + B3.b3Body_SetSafetyFactor(Id, value); + } + } + /// Gets a value indicating whether the body takes part in the simulation. /// Use and to change it. public bool IsEnabled => B3.b3Body_IsEnabled(Id); diff --git a/src/Box3D.NET/BodyDefinition.cs b/src/Box3D.NET/BodyDefinition.cs index 64273b9..f09b0f6 100644 --- a/src/Box3D.NET/BodyDefinition.cs +++ b/src/Box3D.NET/BodyDefinition.cs @@ -159,6 +159,19 @@ public readonly record struct BodyDefinition /// Gets the speed below which the body is considered still enough to sleep. public float SleepThreshold { get; init; } + /// + /// Gets the continuous collision safety factor, which decides when the body + /// counts as fast enough to need continuous collision. Non-dimensional. + /// + /// + /// Box3D only pays for continuous collision when a body risks tunneling: one + /// that could tunnel after moving some distance is treated as fast once it + /// moves more than this fraction of that distance in a step. Smaller is safer + /// but can make the body hitch. The recommended range is 0.01 to 0.5, and the + /// engine default of 0.5 favours performance. + /// + public float SafetyFactor { get; init; } + /// Gets an optional name, used when debugging and drawing. /// Copied by the engine, so the string need not be kept alive. public string? Name { get; init; } @@ -266,6 +279,7 @@ internal unsafe b3BodyDef ToNative(byte* name) def.angularDamping = AngularDamping; def.gravityScale = GravityScale; def.sleepThreshold = SleepThreshold; + def.safetyFactor = SafetyFactor; def.name = name; def.motionLocks = MotionLocks.ToNative(); def.enableSleep = CanSleep; @@ -289,6 +303,7 @@ internal unsafe b3BodyDef ToNative(byte* name) AngularDamping = def.angularDamping, GravityScale = def.gravityScale, SleepThreshold = def.sleepThreshold, + SafetyFactor = def.safetyFactor, Name = null, MotionLocks = MotionLocks.FromNative(def.motionLocks), CanSleep = def.enableSleep, diff --git a/src/Box3D.NET/CharacterMover.cs b/src/Box3D.NET/CharacterMover.cs index 2889505..1249a49 100644 --- a/src/Box3D.NET/CharacterMover.cs +++ b/src/Box3D.NET/CharacterMover.cs @@ -63,6 +63,29 @@ public readonly record struct CharacterContact /// Gets the closest point on the touched shape, relative to the query origin. public Vector3 Point { get; init; } + + /// Gets the index of the mesh or height field triangle touched. + /// + /// Zero for a shape that is not a mesh or a height field, so read it only when + /// is one. Unlike , + /// Box3D does not report minus one here. + /// + public int TriangleIndex { get; init; } + + /// Gets the index of the compound child touched. + /// Zero for a shape that is not a compound. + public int ChildIndex { get; init; } + + /// + /// Gets the index of the material touched, into the materials the shape was + /// created with. + /// + /// + /// This is how a character tells which surface it is on when one mesh or + /// height field carries several materials: ice, mud, a ladder. Box3D clamps + /// it to the shape's material count, so it is always a valid index. + /// + public int MaterialIndex { get; init; } } /// @@ -162,6 +185,44 @@ public static CollisionPlane From( }; } +/// +/// Where a moving body first struck a character capsule during a sweep. +/// +/// Reported by . +public readonly record struct CharacterImpact +{ + /// Gets a value indicating whether the body struck the capsule. + /// When this is false every other member is meaningless, and is one. + public bool Hit { get; init; } + + /// Gets the shape on the body that struck the capsule. + public Shape Shape { get; init; } + + /// Gets the world-space point of impact. + public Vector3 Point { get; init; } + + /// Gets the impact normal, pointing from the body towards the capsule. + public Vector3 Normal { get; init; } + + /// + /// Gets how far through the sweep the impact occurred, from zero at the + /// start poses to one at the end poses. + /// + /// + /// Interpolate the body pose by this to recover where it was at first touch. + /// + public float Fraction { get; init; } + + internal static CharacterImpact FromNative(in b3BodyTOIResult result) => new() + { + Hit = !result.shapeId.IsNull, + Shape = new Shape(result.shapeId), + Point = result.point, + Normal = result.normal, + Fraction = result.fraction, + }; +} + /// The outcome of solving a set of character collision planes. public readonly record struct PlaneSolverResult { @@ -314,4 +375,85 @@ public static unsafe Vector3 ClipVelocity(Vector3 velocity, ReadOnlySpan + /// Finds when a moving body first strikes a character capsule, with both + /// moving over the same interval. + /// + /// The body that may strike the capsule. + /// The character capsule, relative to . + /// The world position the capsule starts from. + /// How far the capsule moves over the interval. + /// The world position of the body at the start of the interval. + /// The world rotation of the body at the start of the interval. + /// The world position of the body at the end of the interval. + /// The world rotation of the body at the end of the interval. + /// Which of the body's shapes are considered, or null for all of them. + /// The earliest impact, or one whose is false. + /// The body handle is stale or default. + /// + /// + /// A kinematic character is not simulated, so nothing tells it that a falling + /// crate or a swinging door has hit it. Record the body's pose before + /// , then sweep the body from that pose to its + /// current one against the capsule's own movement for the step. + /// + /// + /// Pass the movement the character intends for the interval, and + /// sweep before resolving it against the world with + /// . A character already pushed clear of the body + /// ends the interval merely touching it, and a touch is not reported as an + /// impact. + /// + /// + /// Only the sphere, capsule and hull shapes on the body are swept; meshes, + /// height fields and compounds are skipped. A shape already overlapping the + /// capsule at the start is ignored, though another shape on the same body can + /// still hit. + /// + /// + /// + /// + /// Vector3 crateStart = crate.Position; + /// Quaternion crateStartRotation = crate.Rotation; + /// + /// world.Step(dt); + /// + /// CharacterImpact impact = CharacterMover.TimeOfImpact( + /// crate, capsule, position, velocity * dt, + /// crateStart, crateStartRotation, crate.Position, crate.Rotation); + /// + /// if (impact.Hit) + /// { + /// TakeDamage(impact.Point, impact.Normal); + /// } + /// + /// // Only now gather planes and solve the character's own movement. + /// + /// + public static unsafe CharacterImpact TimeOfImpact( + Body body, + Capsule capsule, + Vector3 origin, + Vector3 translation, + Vector3 bodyStartPosition, + Quaternion bodyStartRotation, + Vector3 bodyEndPosition, + Quaternion bodyEndRotation, + QueryFilter? filter = null) + { + b3BodyId id = Validate.Handle(body.NativeId); + b3Capsule native = capsule.ToNative(); + + b3BodyTOIResult result = B3.b3Body_TimeOfImpactMover( + id, + origin, + &native, + translation, + (filter ?? QueryFilter.Default).ToNative(), + new b3Transform { p = bodyStartPosition, q = bodyStartRotation }, + new b3Transform { p = bodyEndPosition, q = bodyEndRotation }); + + return CharacterImpact.FromNative(result); + } } diff --git a/src/Box3D.NET/CollisionMesh.cs b/src/Box3D.NET/CollisionMesh.cs index 1694103..6d2647a 100644 --- a/src/Box3D.NET/CollisionMesh.cs +++ b/src/Box3D.NET/CollisionMesh.cs @@ -50,6 +50,17 @@ public readonly record struct MeshOptions /// public bool UseMedianSplit { get; init; } + /// + /// Gets a value indicating whether the triangle indices are wound clockwise + /// when seen from the side the surface faces. + /// + /// + /// Box3D's own convention is counter-clockwise. Set this for geometry exported + /// with the opposite convention, rather than reordering the indices by hand; + /// the mesh is built facing the same way either way. + /// + public bool ClockwiseWinding { get; init; } + /// Gets options suited to level geometry a character walks on. public static MeshOptions Default => new() { @@ -57,6 +68,7 @@ public readonly record struct MeshOptions WeldTolerance = 1e-4f, IdentifyEdges = true, UseMedianSplit = false, + ClockwiseWinding = false, }; /// Gets options that build as fast as possible, skipping the repair passes. @@ -67,6 +79,7 @@ public readonly record struct MeshOptions WeldTolerance = 0.0f, IdentifyEdges = false, UseMedianSplit = true, + ClockwiseWinding = false, }; } @@ -194,7 +207,7 @@ internal b3MeshData* NativeMesh /// The vertex positions, in mesh-local space. /// /// Three indices per triangle, wound counter-clockwise when seen from the - /// side the surface faces. + /// side the surface faces unless is set. /// /// The build options, or null for . /// @@ -218,10 +231,124 @@ public static CollisionMesh FromTriangles( MeshOptions? options = null, ReadOnlySpan materialIndices = default) { - if (vertices.Length < 3) + fixed (Vector3* vertexPtr = vertices) + { + // A stride of zero is Box3D's spelling of "contiguous". + return Build(vertexPtr, 0, vertices.Length, indices, options, materialIndices); + } + } + + /// + /// Builds a mesh straight from an interleaved vertex buffer, reading each + /// position out of a larger vertex structure. + /// + /// + /// The vertex type, such as a struct holding a position, a normal and texture + /// coordinates. Its size is the vertex stride. + /// + /// The vertices, in mesh-local space. + /// + /// The byte offset of the position inside + /// , typically from Marshal.OffsetOf. + /// + /// + /// Three indices per triangle, wound counter-clockwise when seen from the side + /// the surface faces unless is set. + /// + /// The build options, or null for . + /// + /// One material index per triangle, selecting into the per-triangle materials + /// of the shape definition. Leave empty for a single material. + /// + /// The mesh. + /// + /// The vertex type has a size Box3D cannot use as a stride, or any of the + /// conditions of the + /// + /// overload. + /// + /// + /// is not on a four-byte boundary, or the + /// position does not fit inside the vertex. + /// + /// + /// + /// This saves copying the positions out of a render vertex buffer before + /// building. Box3D reads the positions through the stride and copies them, so + /// the buffer may be released as soon as this returns. + /// + /// + /// Box3D accepts strides that are a multiple of four bytes, from twelve up to + /// 4096, and the position must start on a four-byte boundary. Vertex structs + /// made of floats satisfy both. + /// + /// + /// + /// + /// [StructLayout(LayoutKind.Sequential)] + /// struct RenderVertex + /// { + /// public Vector3 Position; + /// public Vector3 Normal; + /// public Vector2 Uv; + /// } + /// + /// int offset = (int)Marshal.OffsetOf<RenderVertex>(nameof(RenderVertex.Position)); + /// using var level = CollisionMesh.FromTriangles<RenderVertex>(vertexBuffer, offset, indexBuffer); + /// + /// + public static CollisionMesh FromTriangles( + ReadOnlySpan vertices, + int positionOffset, + ReadOnlySpan indices, + MeshOptions? options = null, + ReadOnlySpan materialIndices = default) + where TVertex : unmanaged + { + int stride = sizeof(TVertex); + + // Box3D rejects these by returning null, which would surface as the + // unhelpful "could not build a mesh". Saying which rule was broken is + // cheap here and impossible afterwards. + if (stride < sizeof(Vector3) || stride > MaxVertexStride || stride % 4 != 0) { throw new ArgumentException( - $"A mesh needs at least three vertices, got {vertices.Length}.", + $"A vertex of {stride} bytes cannot be read by Box3D, which needs a stride that is a multiple " + + $"of four between {sizeof(Vector3)} and {MaxVertexStride} bytes.", + nameof(vertices)); + } + + if (positionOffset < 0 || positionOffset > stride - sizeof(Vector3) || positionOffset % 4 != 0) + { + throw new ArgumentOutOfRangeException( + nameof(positionOffset), + positionOffset, + $"The position must start on a four-byte boundary and fit inside the {stride}-byte vertex."); + } + + fixed (TVertex* vertexPtr = vertices) + { + Vector3* positions = (Vector3*)((byte*)vertexPtr + positionOffset); + return Build(positions, (nuint)stride, vertices.Length, indices, options, materialIndices); + } + } + + // The largest stride Box3D accepts; anything above it is taken to be an + // uninitialized field rather than a real vertex layout. + private const int MaxVertexStride = 4096; + + private static CollisionMesh Build( + Vector3* vertices, + nuint stride, + int vertexCount, + ReadOnlySpan indices, + MeshOptions? options, + ReadOnlySpan materialIndices) + { + if (vertexCount < 3) + { + throw new ArgumentException( + $"A mesh needs at least three vertices, got {vertexCount}.", nameof(vertices)); } @@ -239,10 +366,10 @@ public static CollisionMesh FromTriangles( // while building the hierarchy. Checking here is cheap next to the build. for (int i = 0; i < indices.Length; i++) { - if ((uint)indices[i] >= (uint)vertices.Length) + if ((uint)indices[i] >= (uint)vertexCount) { throw new ArgumentException( - $"Index {i} refers to vertex {indices[i]}, but there are only {vertices.Length} vertices.", + $"Index {i} refers to vertex {indices[i]}, but there are only {vertexCount} vertices.", nameof(indices)); } } @@ -256,21 +383,22 @@ public static CollisionMesh FromTriangles( MeshOptions settings = options ?? MeshOptions.Default; - fixed (Vector3* vertexPtr = vertices) fixed (int* indexPtr = indices) fixed (byte* materialPtr = materialIndices) { b3MeshDef def = new() { - vertices = vertexPtr, + vertices = vertices, + stride = stride, indices = indexPtr, materialIndices = materialIndices.IsEmpty ? null : materialPtr, - vertexCount = vertices.Length, + vertexCount = vertexCount, triangleCount = triangleCount, weldVertices = settings.WeldVertices, weldTolerance = settings.WeldTolerance, identifyEdges = settings.IdentifyEdges, useMedianSplit = settings.UseMedianSplit, + clockWiseWinding = settings.ClockwiseWinding, }; b3MeshData* mesh = B3.b3CreateMesh(&def, null, 0); diff --git a/src/Box3D.NET/Joint.cs b/src/Box3D.NET/Joint.cs index 3b36d63..b09eb92 100644 --- a/src/Box3D.NET/Joint.cs +++ b/src/Box3D.NET/Joint.cs @@ -325,6 +325,14 @@ public float TorqueThreshold set => B3.b3Joint_SetTorqueThreshold(Id, value); } + /// Gets a value indicating whether the joint is awake. + /// + /// A joint is awake when it is in the awake set, which is exactly when at + /// least one body it connects is awake. Cheaper than asking both bodies. Use + /// to wake it. + /// + public bool IsAwake => B3.b3Joint_IsAwake(Id); + /// Wakes both attached bodies. public void WakeBodies() => B3.b3Joint_WakeBodies(Id); diff --git a/src/Box3D.NET/PhysicsWorld.Character.cs b/src/Box3D.NET/PhysicsWorld.Character.cs index acc5d3d..a6b6c51 100644 --- a/src/Box3D.NET/PhysicsWorld.Character.cs +++ b/src/Box3D.NET/PhysicsWorld.Character.cs @@ -50,6 +50,9 @@ private static NativeBool CharacterThunk( Normal = plane.plane.normal, Offset = plane.plane.offset, Point = plane.point, + TriangleIndex = plane.triangleIndex, + ChildIndex = plane.childIndex, + MaterialIndex = plane.materialIndex, }; if (!ctx.Invoke(ctx.Callback, in contact)) @@ -83,6 +86,12 @@ private static NativeBool CharacterThunk( /// character is touching: a cast tells you where it stops, these planes tell /// you what is in the way and in which direction. /// + /// + /// Mesh and height field triangles are one-sided: only their front face + /// produces a plane. and + /// say which triangle and which + /// surface material the capsule is touching. + /// /// /// /// @@ -133,10 +142,16 @@ public void CollideCapsule( /// /// The world has been disposed. /// + /// /// A specialised shape cast that slides along surfaces instead of catching on /// them. Useful for a quick "can the character get there" test, but a poor /// source of information about what it is touching; use /// for that. + /// + /// + /// Mesh and height field triangles are one-sided here, as they are for ray + /// casts: a capsule moving into the back of a triangle passes through it. + /// /// public float CastCapsule( Capsule capsule, diff --git a/src/Box3D.NET/PhysicsWorld.cs b/src/Box3D.NET/PhysicsWorld.cs index 2560a27..837593a 100644 --- a/src/Box3D.NET/PhysicsWorld.cs +++ b/src/Box3D.NET/PhysicsWorld.cs @@ -369,6 +369,7 @@ public void Step(float timeStep, int subStepCount = 4) /// The body's initial state. /// The new body, with no shapes attached yet. /// The world has been disposed. + /// The definition's is negative. /// /// /// Body crate = world.CreateBody(BodyDefinition.Dynamic(new Vector3(0, 5, 0))); @@ -383,6 +384,8 @@ public Body CreateBody(BodyDefinition definition) Validate.Finite(definition.Rotation); Validate.Finite(definition.LinearVelocity); Validate.Finite(definition.AngularVelocity); + Validate.Finite(definition.SafetyFactor); + ArgumentOutOfRangeException.ThrowIfNegative(definition.SafetyFactor); // Naming a body is a debugging aid that almost nothing uses, and stack // space is zeroed on entry, so reserving the name buffer unconditionally diff --git a/src/Box3D.NET/Validate.cs b/src/Box3D.NET/Validate.cs index 6ba6ed9..2f0622c 100644 --- a/src/Box3D.NET/Validate.cs +++ b/src/Box3D.NET/Validate.cs @@ -15,9 +15,9 @@ namespace Box3D; * silence, and it does not stay where it was put. * * Measured: setting one body's velocity to NaN and stepping thirty times left a - * second body, twenty metres away and never touched, with a position of - * (NaN, NaN, NaN). The solver couples bodies through islands and the broad - * phase, so one bad number reaches everything. The simulation does not fail, it + * second body, merely resting on it, with a position of (NaN, NaN, NaN). The + * solver couples bodies through contact, so one bad number reaches everything + * it touches. The simulation does not fail, it * silently becomes garbage, and the first sign of it is usually a renderer * drawing nothing. * diff --git a/tools/generate-bindings.ps1 b/tools/generate-bindings.ps1 index 17bdf6f..e70c1cd 100644 --- a/tools/generate-bindings.ps1 +++ b/tools/generate-bindings.ps1 @@ -84,8 +84,8 @@ $CallbackMap = @{ 'b3MoverFilterFcn' = 'delegate* unmanaged[Cdecl]' 'b3CompoundQueryFcn' = 'delegate* unmanaged[Cdecl]' 'b3MeshQueryFcn' = 'delegate* unmanaged[Cdecl]' - 'b3AllocFcn' = 'delegate* unmanaged[Cdecl]' - 'b3FreeFcn' = 'delegate* unmanaged[Cdecl]' + 'b3AllocFcn' = 'delegate* unmanaged[Cdecl]' + 'b3FreeFcn' = 'delegate* unmanaged[Cdecl]' 'b3AssertFcn' = 'delegate* unmanaged[Cdecl]' 'b3LogFcn' = 'delegate* unmanaged[Cdecl]' }