diff --git a/README.md b/README.md index 0616a5d..06bebdb 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ installing a second solver. - `src/Ymir.Core/YmirSession.cs`: the public in-process retained session (commands, receipts, stepping, queries, `CapturePersistence`). `YmirSimulator.cs` is the snapshot facade that wraps an isolated session. - `src/Ymir.Box3D/` and `native/Ymir.Box3D/`: the native session ABI and the Ymir-owned Box3D shim (`GameCult.Ymir.Box3D` package). - `tests/Ymir.Box3D.Parity/`: the executable parity contract against pinned Box3D v0.1.0 (`docs/box3d-parity.md`). + - `src/Ymir.Box3D/Box3DSoaWorld.cs` and `native/Ymir.Box3D/ymir_box3d_soa.c`: a headless 3D world (gravity, free rotation) whose state is a structure-of-arrays view read in place (`docs/soa-world.md`). Early slice for Lodur and Bad Day Simulator; spheres and static boxes only. - `docs/architecture.md`, `docs/retained-session-contract.md`, `docs/aetheria-cutover.md`. - Build and test: `git submodule update --init --recursive`, then `dotnet test Ymir.slnx` (needs CMake and a C17 compiler). - **Live:** retained sessions, replay-backed checkpoint reconstruction, parity for overlap and cast queries, tractor membership, contact-begin facts, restitution, torque lifetime, collision filtering and bullet CCD. Aetheria embeds one session per run and zone and stores persistence in a daemon-private CultCache. Package version `0.3.0`. diff --git a/Ymir.slnx b/Ymir.slnx index a793caa..180e6d3 100644 --- a/Ymir.slnx +++ b/Ymir.slnx @@ -6,6 +6,7 @@ + diff --git a/docs/soa-world.md b/docs/soa-world.md new file mode 100644 index 0000000..4258840 --- /dev/null +++ b/docs/soa-world.md @@ -0,0 +1,38 @@ +# Headless SoA world + +A headless 3D Box3D world with a structure-of-arrays state view, for Lodur and Bad Day Simulator. +Box3D decides every physical outcome; Ymir creates bodies, steps the world and exposes state. + +## Why it is not the retained session + +`ymir_box3d_session_*` (`ymir_box3d.c`) is the planar Aetheria session: gravity is zero, Y is locked, +only yaw rotates (`motionLocks.linearY/angularX/angularZ`), and state leaves by `copy_bodies` into an +array of structs. A body cannot fall or tumble there, and every read is a copy. The SoA world is a +separate native object (`ymir_box3d_soa.c`, ABI 1) so the Aetheria session's contract is untouched. + +## Contract + +- Native: `ymir_box3d_soa_create(capacity, gravity) / add_sphere / add_static_box / step / get_view / destroy`. + Capacity is fixed at creation, so lane pointers never move. +- Lanes (`ymir_box3d_soa_view`): `stable_id`, `kind`, position xyz, rotation quaternion xyzw, linear + velocity xyz, angular velocity xyz. One contiguous array per component, indexed by body. +- Lane contents change only inside `step`. Between steps a caller may read any lane in place. +- Managed (`src/Ymir.Box3D/Box3DSoaWorld.cs`, internal): `Box3DSoaView` exposes each lane as a + `ReadOnlySpan` over native memory. Spans are invalid after `Dispose`. `Count` is as of when the + view was taken, so take it after the last add. +- Determinism: one Box3D worker, bodies stepped in insertion order, same inputs give identical lane + bytes on the same build. It is not claimed across platforms or CPUs. + +## Tests + +`tests/Ymir.Box3D.Soa.Tests`: gravity and rest read through one held view, address stability across +steps, two runs byte-equal at six checkpoints (25 spheres colliding on a slab), a one-ulp input +difference diverging (so the equality check can fail), argument refusal, dispose. + +## Needed next for Lodur + +Recorded as a mind follow-up; summary: joints (Box3D has revolute, spherical, weld, motor), a body +layout beyond spheres and static boxes (capsules, boxes, compound, per-shape density), a torque/force +write path (per-body lane written before the step, as the planar session's `apply_torque`), joint +state lanes, contact facts for the SoA world, and a public surface in `Ymir.Core` rather than an +internal wrapper. diff --git a/native/Ymir.Box3D/CMakeLists.txt b/native/Ymir.Box3D/CMakeLists.txt index 29a706c..eabb2b5 100644 --- a/native/Ymir.Box3D/CMakeLists.txt +++ b/native/Ymir.Box3D/CMakeLists.txt @@ -11,7 +11,7 @@ set(BOX3D_VALIDATE ON CACHE BOOL "" FORCE) add_subdirectory("${CMAKE_CURRENT_SOURCE_DIR}/../../extern/box3d" "${CMAKE_CURRENT_BINARY_DIR}/box3d") -add_library(ymir_box3d SHARED ymir_box3d.c) +add_library(ymir_box3d SHARED ymir_box3d.c ymir_box3d_soa.c) target_link_libraries(ymir_box3d PRIVATE box3d::box3d) target_include_directories(ymir_box3d PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/../../extern/box3d/include") target_compile_features(ymir_box3d PRIVATE c_std_17) diff --git a/native/Ymir.Box3D/ymir_box3d_soa.c b/native/Ymir.Box3D/ymir_box3d_soa.c new file mode 100644 index 0000000..cc4c29a --- /dev/null +++ b/native/Ymir.Box3D/ymir_box3d_soa.c @@ -0,0 +1,348 @@ +// Headless 3D Box3D world with a structure-of-arrays state view. Box3D owns every physical +// decision (integration, collision, contact); this file only creates bodies, steps the world and +// copies each body's state into contiguous lanes once per step. A caller reads the lanes in place +// through `ymir_box3d_soa_view`: the pointers are stable for the world's whole life (capacity is +// fixed at creation) and the lane contents change only inside `ymir_box3d_soa_step`. +// +// This world is not the retained planar session in ymir_box3d.c: that one locks Y, zeroes gravity +// and hands out an array of structs by copy. See docs/soa-world.md. + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#if defined(_WIN32) +#define YMIR_EXPORT __declspec(dllexport) +#define YMIR_CALL __cdecl +#else +#define YMIR_EXPORT __attribute__((visibility("default"))) +#define YMIR_CALL +#endif + +enum +{ + YMIR_SOA_OK = 0, + YMIR_SOA_INVALID_ARGUMENT = 1, + YMIR_SOA_DUPLICATE_ID = 2, + YMIR_SOA_FULL = 3, + YMIR_SOA_OUT_OF_MEMORY = 4, + YMIR_SOA_INTERNAL_ERROR = 5 +}; + +enum +{ + YMIR_SOA_KIND_SPHERE = 0, + YMIR_SOA_KIND_STATIC_BOX = 1 +}; + +#define YMIR_SOA_ABI_VERSION 1u + +// Pointers first and in lane order, so the layout is the same on every 64-bit target. +typedef struct ymir_box3d_soa_view +{ + uint32_t abi_version; + int32_t count; + int32_t capacity; + uint32_t reserved; + uint64_t step_count; + const uint64_t* stable_id; + const uint32_t* kind; + const float* position_x; + const float* position_y; + const float* position_z; + const float* rotation_x; + const float* rotation_y; + const float* rotation_z; + const float* rotation_w; + const float* velocity_x; + const float* velocity_y; + const float* velocity_z; + const float* angular_velocity_x; + const float* angular_velocity_y; + const float* angular_velocity_z; +} ymir_box3d_soa_view; + +#define YMIR_SOA_FLOAT_LANES 13 + +typedef struct ymir_box3d_soa_world +{ + b3WorldId world_id; + int32_t count; + int32_t capacity; + uint64_t step_count; + b3BodyId* bodies; + uint64_t* stable_id; + uint32_t* kind; + float* lanes; // YMIR_SOA_FLOAT_LANES arrays of `capacity` floats, lane-major +} ymir_box3d_soa_world; + +static float* ymir_soa_lane(const ymir_box3d_soa_world* world, int lane) +{ + return world->lanes + (size_t)lane * (size_t)world->capacity; +} + +static int ymir_soa_finite(float v) +{ + return isfinite(v); +} + +static void ymir_soa_refresh(ymir_box3d_soa_world* world, int index) +{ + b3BodyId body = world->bodies[index]; + b3Pos p = b3Body_GetPosition(body); + b3Quat q = b3Body_GetRotation(body); + b3Vec3 v = b3Body_GetLinearVelocity(body); + b3Vec3 w = b3Body_GetAngularVelocity(body); + const float values[YMIR_SOA_FLOAT_LANES] = { + p.x, p.y, p.z, q.v.x, q.v.y, q.v.z, q.s, v.x, v.y, v.z, w.x, w.y, w.z}; + for (int lane = 0; lane < YMIR_SOA_FLOAT_LANES; ++lane) + { + ymir_soa_lane(world, lane)[index] = values[lane]; + } +} + +YMIR_EXPORT uint32_t YMIR_CALL ymir_box3d_soa_get_abi_version(void) +{ + return YMIR_SOA_ABI_VERSION; +} + +YMIR_EXPORT uint32_t YMIR_CALL ymir_box3d_soa_get_view_size(void) +{ + return (uint32_t)sizeof(ymir_box3d_soa_view); +} + +YMIR_EXPORT int32_t YMIR_CALL ymir_box3d_soa_create( + int32_t capacity, float gravity_x, float gravity_y, float gravity_z, ymir_box3d_soa_world** output) +{ + if (output == NULL) + { + return YMIR_SOA_INVALID_ARGUMENT; + } + *output = NULL; + if (capacity <= 0 || !ymir_soa_finite(gravity_x) || !ymir_soa_finite(gravity_y) || !ymir_soa_finite(gravity_z)) + { + return YMIR_SOA_INVALID_ARGUMENT; + } + + ymir_box3d_soa_world* world = (ymir_box3d_soa_world*)calloc(1, sizeof(ymir_box3d_soa_world)); + if (world == NULL) + { + return YMIR_SOA_OUT_OF_MEMORY; + } + world->capacity = capacity; + world->bodies = (b3BodyId*)calloc((size_t)capacity, sizeof(b3BodyId)); + world->stable_id = (uint64_t*)calloc((size_t)capacity, sizeof(uint64_t)); + world->kind = (uint32_t*)calloc((size_t)capacity, sizeof(uint32_t)); + world->lanes = (float*)calloc((size_t)capacity * YMIR_SOA_FLOAT_LANES, sizeof(float)); + if (world->bodies == NULL || world->stable_id == NULL || world->kind == NULL || world->lanes == NULL) + { + free(world->bodies); + free(world->stable_id); + free(world->kind); + free(world->lanes); + free(world); + return YMIR_SOA_OUT_OF_MEMORY; + } + + b3WorldDef def = b3DefaultWorldDef(); + def.gravity = (b3Vec3){gravity_x, gravity_y, gravity_z}; + def.enableSleep = false; + def.workerCount = 1; // one worker: the step order is fixed, which determinism rests on + world->world_id = b3CreateWorld(&def); + if (B3_IS_NULL(world->world_id)) + { + free(world->bodies); + free(world->stable_id); + free(world->kind); + free(world->lanes); + free(world); + return YMIR_SOA_INTERNAL_ERROR; + } + + *output = world; + return YMIR_SOA_OK; +} + +YMIR_EXPORT void YMIR_CALL ymir_box3d_soa_destroy(ymir_box3d_soa_world* world) +{ + if (world == NULL) + { + return; + } + if (B3_IS_NON_NULL(world->world_id) && b3World_IsValid(world->world_id)) + { + b3DestroyWorld(world->world_id); + } + free(world->bodies); + free(world->stable_id); + free(world->kind); + free(world->lanes); + free(world); +} + +static int32_t ymir_soa_reserve(ymir_box3d_soa_world* world, uint64_t stable_id) +{ + if (world->count >= world->capacity) + { + return YMIR_SOA_FULL; + } + for (int i = 0; i < world->count; ++i) + { + if (world->stable_id[i] == stable_id) + { + return YMIR_SOA_DUPLICATE_ID; + } + } + return YMIR_SOA_OK; +} + +static int32_t ymir_soa_commit( + ymir_box3d_soa_world* world, uint64_t stable_id, uint32_t kind, b3BodyId body, int32_t* index) +{ + int i = world->count++; + world->bodies[i] = body; + world->stable_id[i] = stable_id; + world->kind[i] = kind; + ymir_soa_refresh(world, i); + if (index != NULL) + { + *index = i; + } + return YMIR_SOA_OK; +} + +// A dynamic sphere. `density` is Box3D's shape density; mass follows from it. +YMIR_EXPORT int32_t YMIR_CALL ymir_box3d_soa_add_sphere( + ymir_box3d_soa_world* world, uint64_t stable_id, float position_x, float position_y, float position_z, + float velocity_x, float velocity_y, float velocity_z, float radius, float density, float friction, + float restitution, int32_t* index) +{ + if (world == NULL || radius <= 0.0f || density <= 0.0f || !ymir_soa_finite(position_x) || + !ymir_soa_finite(position_y) || !ymir_soa_finite(position_z) || !ymir_soa_finite(velocity_x) || + !ymir_soa_finite(velocity_y) || !ymir_soa_finite(velocity_z) || !ymir_soa_finite(radius) || + !ymir_soa_finite(density) || !ymir_soa_finite(friction) || !ymir_soa_finite(restitution)) + { + return YMIR_SOA_INVALID_ARGUMENT; + } + int32_t status = ymir_soa_reserve(world, stable_id); + if (status != YMIR_SOA_OK) + { + return status; + } + + b3BodyDef body_def = b3DefaultBodyDef(); + body_def.type = b3_dynamicBody; + body_def.position = (b3Vec3){position_x, position_y, position_z}; + body_def.linearVelocity = (b3Vec3){velocity_x, velocity_y, velocity_z}; + body_def.enableSleep = false; + b3BodyId body = b3CreateBody(world->world_id, &body_def); + if (B3_IS_NULL(body)) + { + return YMIR_SOA_INTERNAL_ERROR; + } + b3ShapeDef shape_def = b3DefaultShapeDef(); + shape_def.userData = (void*)(uintptr_t)stable_id; + shape_def.density = density; + shape_def.baseMaterial.friction = friction; + shape_def.baseMaterial.restitution = restitution; + b3Sphere sphere = {{0.0f, 0.0f, 0.0f}, radius}; + if (B3_IS_NULL(b3CreateSphereShape(body, &shape_def, &sphere))) + { + b3DestroyBody(body); + return YMIR_SOA_INTERNAL_ERROR; + } + return ymir_soa_commit(world, stable_id, YMIR_SOA_KIND_SPHERE, body, index); +} + +// A static box (ground, walls) centred at the position, half extents along the world axes. +YMIR_EXPORT int32_t YMIR_CALL ymir_box3d_soa_add_static_box( + ymir_box3d_soa_world* world, uint64_t stable_id, float position_x, float position_y, float position_z, + float half_x, float half_y, float half_z, float friction, float restitution, int32_t* index) +{ + if (world == NULL || half_x <= 0.0f || half_y <= 0.0f || half_z <= 0.0f || !ymir_soa_finite(position_x) || + !ymir_soa_finite(position_y) || !ymir_soa_finite(position_z) || !ymir_soa_finite(half_x) || + !ymir_soa_finite(half_y) || !ymir_soa_finite(half_z) || !ymir_soa_finite(friction) || + !ymir_soa_finite(restitution)) + { + return YMIR_SOA_INVALID_ARGUMENT; + } + int32_t status = ymir_soa_reserve(world, stable_id); + if (status != YMIR_SOA_OK) + { + return status; + } + + b3BodyDef body_def = b3DefaultBodyDef(); + body_def.type = b3_staticBody; + body_def.position = (b3Vec3){position_x, position_y, position_z}; + b3BodyId body = b3CreateBody(world->world_id, &body_def); + if (B3_IS_NULL(body)) + { + return YMIR_SOA_INTERNAL_ERROR; + } + b3ShapeDef shape_def = b3DefaultShapeDef(); + shape_def.userData = (void*)(uintptr_t)stable_id; + shape_def.baseMaterial.friction = friction; + shape_def.baseMaterial.restitution = restitution; + b3BoxHull box = b3MakeBoxHull(half_x, half_y, half_z); + if (B3_IS_NULL(b3CreateHullShape(body, &shape_def, &box.base))) + { + b3DestroyBody(body); + return YMIR_SOA_INTERNAL_ERROR; + } + return ymir_soa_commit(world, stable_id, YMIR_SOA_KIND_STATIC_BOX, body, index); +} + +YMIR_EXPORT int32_t YMIR_CALL ymir_box3d_soa_step(ymir_box3d_soa_world* world, float time_step, int32_t sub_step_count) +{ + if (world == NULL || !ymir_soa_finite(time_step) || time_step <= 0.0f || sub_step_count <= 0) + { + return YMIR_SOA_INVALID_ARGUMENT; + } + b3World_Step(world->world_id, time_step, sub_step_count); + for (int i = 0; i < world->count; ++i) + { + ymir_soa_refresh(world, i); + } + world->step_count += 1; + return YMIR_SOA_OK; +} + +YMIR_EXPORT int32_t YMIR_CALL ymir_box3d_soa_get_view(const ymir_box3d_soa_world* world, ymir_box3d_soa_view* view) +{ + if (world == NULL || view == NULL) + { + return YMIR_SOA_INVALID_ARGUMENT; + } + const float* base = world->lanes; + size_t n = (size_t)world->capacity; + *view = (ymir_box3d_soa_view){ + .abi_version = YMIR_SOA_ABI_VERSION, + .count = world->count, + .capacity = world->capacity, + .step_count = world->step_count, + .stable_id = world->stable_id, + .kind = world->kind, + .position_x = base + 0 * n, + .position_y = base + 1 * n, + .position_z = base + 2 * n, + .rotation_x = base + 3 * n, + .rotation_y = base + 4 * n, + .rotation_z = base + 5 * n, + .rotation_w = base + 6 * n, + .velocity_x = base + 7 * n, + .velocity_y = base + 8 * n, + .velocity_z = base + 9 * n, + .angular_velocity_x = base + 10 * n, + .angular_velocity_y = base + 11 * n, + .angular_velocity_z = base + 12 * n, + }; + return YMIR_SOA_OK; +} diff --git a/src/Ymir.Box3D/Box3DSoaWorld.cs b/src/Ymir.Box3D/Box3DSoaWorld.cs new file mode 100644 index 0000000..2af40d3 --- /dev/null +++ b/src/Ymir.Box3D/Box3DSoaWorld.cs @@ -0,0 +1,203 @@ +using System.Runtime.InteropServices; + +namespace Ymir.Box3D; + +internal enum Box3DSoaKind : uint +{ + Sphere = 0, + StaticBox = 1 +} + +/// Native layout of ymir_box3d_soa_view: counts, then one pointer per lane. +[StructLayout(LayoutKind.Sequential)] +internal unsafe struct Box3DSoaViewNative +{ + public uint AbiVersion; + public int Count; + public int Capacity; + public uint Reserved; + public ulong StepCount; + public ulong* StableId; + public uint* Kind; + public float* PositionX; + public float* PositionY; + public float* PositionZ; + public float* RotationX; + public float* RotationY; + public float* RotationZ; + public float* RotationW; + public float* VelocityX; + public float* VelocityY; + public float* VelocityZ; + public float* AngularVelocityX; + public float* AngularVelocityY; + public float* AngularVelocityZ; +} + +internal static unsafe partial class Box3DSoaNative +{ + private const string Library = Box3DNative.LibraryName; + + [LibraryImport(Library)] + [UnmanagedCallConv(CallConvs = [typeof(System.Runtime.CompilerServices.CallConvCdecl)])] + internal static partial uint ymir_box3d_soa_get_abi_version(); + + [LibraryImport(Library)] + [UnmanagedCallConv(CallConvs = [typeof(System.Runtime.CompilerServices.CallConvCdecl)])] + internal static partial uint ymir_box3d_soa_get_view_size(); + + [LibraryImport(Library)] + [UnmanagedCallConv(CallConvs = [typeof(System.Runtime.CompilerServices.CallConvCdecl)])] + internal static partial int ymir_box3d_soa_create(int capacity, float gx, float gy, float gz, out nint world); + + [LibraryImport(Library)] + [UnmanagedCallConv(CallConvs = [typeof(System.Runtime.CompilerServices.CallConvCdecl)])] + internal static partial void ymir_box3d_soa_destroy(nint world); + + [LibraryImport(Library)] + [UnmanagedCallConv(CallConvs = [typeof(System.Runtime.CompilerServices.CallConvCdecl)])] + internal static partial int ymir_box3d_soa_add_sphere( + nint world, ulong stableId, float px, float py, float pz, float vx, float vy, float vz, + float radius, float density, float friction, float restitution, out int index); + + [LibraryImport(Library)] + [UnmanagedCallConv(CallConvs = [typeof(System.Runtime.CompilerServices.CallConvCdecl)])] + internal static partial int ymir_box3d_soa_add_static_box( + nint world, ulong stableId, float px, float py, float pz, float hx, float hy, float hz, + float friction, float restitution, out int index); + + [LibraryImport(Library)] + [UnmanagedCallConv(CallConvs = [typeof(System.Runtime.CompilerServices.CallConvCdecl)])] + internal static partial int ymir_box3d_soa_step(nint world, float timeStep, int subStepCount); + + [LibraryImport(Library)] + [UnmanagedCallConv(CallConvs = [typeof(System.Runtime.CompilerServices.CallConvCdecl)])] + internal static partial int ymir_box3d_soa_get_view(nint world, out Box3DSoaViewNative view); +} + +/// +/// The lanes of a : contiguous native arrays, one per component, indexed +/// by body. Each accessor is a span over native memory, not a copy. The pointers are fixed for the +/// world's life, the contents change only inside , and every span is +/// invalid once the world is disposed. +/// +internal readonly unsafe struct Box3DSoaView +{ + private readonly Box3DSoaViewNative _v; + + internal Box3DSoaView(Box3DSoaViewNative v) => _v = v; + + /// Bodies at the moment this view was taken: take it after the last Add. Only the lane contents are live. + public int Count => _v.Count; + public int Capacity => _v.Capacity; + + public ReadOnlySpan StableId => new(_v.StableId, Count); + public ReadOnlySpan Kind => new(_v.Kind, Count); + public ReadOnlySpan PositionX => new(_v.PositionX, Count); + public ReadOnlySpan PositionY => new(_v.PositionY, Count); + public ReadOnlySpan PositionZ => new(_v.PositionZ, Count); + /// Unit quaternion, x y z w. + public ReadOnlySpan RotationX => new(_v.RotationX, Count); + public ReadOnlySpan RotationY => new(_v.RotationY, Count); + public ReadOnlySpan RotationZ => new(_v.RotationZ, Count); + public ReadOnlySpan RotationW => new(_v.RotationW, Count); + public ReadOnlySpan VelocityX => new(_v.VelocityX, Count); + public ReadOnlySpan VelocityY => new(_v.VelocityY, Count); + public ReadOnlySpan VelocityZ => new(_v.VelocityZ, Count); + public ReadOnlySpan AngularVelocityX => new(_v.AngularVelocityX, Count); + public ReadOnlySpan AngularVelocityY => new(_v.AngularVelocityY, Count); + public ReadOnlySpan AngularVelocityZ => new(_v.AngularVelocityZ, Count); + + /// Address of a lane, for tests and interop that need to prove nothing was copied. + public nint PositionYAddress => (nint)_v.PositionY; +} + +/// +/// A headless 3D Box3D world: N bodies stepped deterministically (one worker, fixed order) with a +/// structure-of-arrays state view read in place. Ymir writes no solver; Box3D decides everything. +/// +internal sealed class Box3DSoaWorld : IDisposable +{ + private const uint ExpectedAbi = 1; + private nint _handle; + + public Box3DSoaWorld(int capacity, float gravityX = 0f, float gravityY = -10f, float gravityZ = 0f) + { + if (Box3DSoaNative.ymir_box3d_soa_get_abi_version() != ExpectedAbi || + Box3DSoaNative.ymir_box3d_soa_get_view_size() != (uint)System.Runtime.CompilerServices.Unsafe.SizeOf()) + { + throw new InvalidOperationException("The native Ymir Box3D SoA ABI does not match this build."); + } + + Check(Box3DSoaNative.ymir_box3d_soa_create(capacity, gravityX, gravityY, gravityZ, out _handle)); + } + + /// The live lanes. Count and step count are read from native memory each call. + public Box3DSoaView View + { + get + { + ObjectDisposedException.ThrowIf(_handle == 0, this); + Check(Box3DSoaNative.ymir_box3d_soa_get_view(_handle, out var v)); + return new Box3DSoaView(v); + } + } + + /// Steps taken so far. + public ulong StepCount + { + get + { + ObjectDisposedException.ThrowIf(_handle == 0, this); + Check(Box3DSoaNative.ymir_box3d_soa_get_view(_handle, out var v)); + return v.StepCount; + } + } + + /// Adds a dynamic sphere; returns its lane index. + public int AddSphere( + ulong stableId, (float X, float Y, float Z) position, float radius, float density, + (float X, float Y, float Z) velocity = default, float friction = 0.6f, float restitution = 0f) + { + ObjectDisposedException.ThrowIf(_handle == 0, this); + Check(Box3DSoaNative.ymir_box3d_soa_add_sphere( + _handle, stableId, position.X, position.Y, position.Z, velocity.X, velocity.Y, velocity.Z, + radius, density, friction, restitution, out var index)); + return index; + } + + /// Adds a static axis-aligned box centred at . + public int AddStaticBox( + ulong stableId, (float X, float Y, float Z) position, (float X, float Y, float Z) halfExtents, + float friction = 0.6f, float restitution = 0f) + { + ObjectDisposedException.ThrowIf(_handle == 0, this); + Check(Box3DSoaNative.ymir_box3d_soa_add_static_box( + _handle, stableId, position.X, position.Y, position.Z, halfExtents.X, halfExtents.Y, halfExtents.Z, + friction, restitution, out var index)); + return index; + } + + public void Step(float timeStep, int subStepCount = 4) + { + ObjectDisposedException.ThrowIf(_handle == 0, this); + Check(Box3DSoaNative.ymir_box3d_soa_step(_handle, timeStep, subStepCount)); + } + + public void Dispose() + { + var handle = Interlocked.Exchange(ref _handle, 0); + if (handle != 0) + { + Box3DSoaNative.ymir_box3d_soa_destroy(handle); + } + } + + private static void Check(int status) + { + if (status != 0) + { + throw new InvalidOperationException($"Ymir Box3D SoA call failed with status {status}."); + } + } +} diff --git a/src/Ymir.Box3D/Properties/AssemblyInfo.cs b/src/Ymir.Box3D/Properties/AssemblyInfo.cs index 65a30aa..93c6e82 100644 --- a/src/Ymir.Box3D/Properties/AssemblyInfo.cs +++ b/src/Ymir.Box3D/Properties/AssemblyInfo.cs @@ -1,3 +1,4 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("Ymir.Core")] +[assembly: InternalsVisibleTo("Ymir.Box3D.Soa.Tests")] diff --git a/tests/Ymir.Box3D.Soa.Tests/Box3DSoaWorldTests.cs b/tests/Ymir.Box3D.Soa.Tests/Box3DSoaWorldTests.cs new file mode 100644 index 0000000..462d2e8 --- /dev/null +++ b/tests/Ymir.Box3D.Soa.Tests/Box3DSoaWorldTests.cs @@ -0,0 +1,150 @@ +using System.Runtime.InteropServices; +using Ymir.Box3D; + +namespace Ymir.Box3D.Soa.Tests; + +public sealed unsafe class Box3DSoaWorldTests +{ + private const float Dt = 1f / 60f; + private const ulong GroundId = 1; + + /// A ground slab whose top face is y = 0 and a 5 x 5 grid of unit spheres dropped from varied heights. + private static Box3DSoaWorld Build(float firstDropHeight = 3f) + { + var world = new Box3DSoaWorld(capacity: 64); + world.AddStaticBox(GroundId, (0f, -0.5f, 0f), (50f, 0.5f, 50f)); + ulong id = 100; + for (var x = 0; x < 5; x++) + { + for (var z = 0; z < 5; z++) + { + var h = x == 0 && z == 0 ? firstDropHeight : 3f + 0.37f * ((x * 5 + z) % 7); + world.AddSphere(id++, (x * 1.5f - 3f, h, z * 1.5f - 3f), radius: 0.5f, density: 1f, + velocity: (0.1f * z, 0f, -0.05f * x)); + } + } + return world; + } + + private static void Run(Box3DSoaWorld world, int steps) + { + for (var i = 0; i < steps; i++) world.Step(Dt); + } + + private static byte[] Lanes(Box3DSoaView v) + { + // Every lane, byte for byte, in lane order. Spans are read in place; only this + // comparison buffer copies. + var parts = new List(); + void Add(ReadOnlySpan lane) => parts.Add(MemoryMarshal.AsBytes(lane).ToArray()); + parts.Add(MemoryMarshal.AsBytes(v.StableId).ToArray()); + parts.Add(MemoryMarshal.AsBytes(v.Kind).ToArray()); + Add(v.PositionX); Add(v.PositionY); Add(v.PositionZ); + Add(v.RotationX); Add(v.RotationY); Add(v.RotationZ); Add(v.RotationW); + Add(v.VelocityX); Add(v.VelocityY); Add(v.VelocityZ); + Add(v.AngularVelocityX); Add(v.AngularVelocityY); Add(v.AngularVelocityZ); + return parts.SelectMany(p => p).ToArray(); + } + + [Fact] + public void A_body_falls_under_gravity_and_rests_on_the_ground_read_in_place() + { + using var world = Build(); + var view = world.View; // taken once: every read below goes through this same view + const int sphere = 1; // lane 0 is the ground slab, lane 1 the first sphere, dropped from y = 3 + Assert.Equal((uint)Box3DSoaKind.StaticBox, view.Kind[0]); + Assert.Equal((uint)Box3DSoaKind.Sphere, view.Kind[sphere]); + Assert.Equal(26, view.Count); + Assert.Equal(3f, view.PositionY[sphere]); + + Run(world, 10); + Assert.True(view.PositionY[sphere] < 3f, "falls under gravity"); + Assert.True(view.VelocityY[sphere] < -1f, "accelerates downward"); + Assert.Equal(10ul, world.StepCount); + + Run(world, 350); + // Rests on the slab top (y = 0): centre one radius above, essentially still. + Assert.InRange(view.PositionY[sphere], 0.5f - 0.02f, 0.5f + 0.02f); + Assert.True(MathF.Abs(view.VelocityY[sphere]) < 0.05f); + for (var i = 1; i < view.Count; i++) + { + Assert.InRange(view.PositionY[i], 0.45f, 0.55f + 0.02f); + } + + // The ground did not move and its quaternion is still identity. + Assert.Equal(-0.5f, view.PositionY[0]); + Assert.Equal(1f, view.RotationW[0]); + } + + [Fact] + public void The_view_is_read_in_place_with_no_copy() + { + using var world = Build(); + var before = world.View; + fixed (float* y0 = before.PositionY) + { + Assert.Equal(before.PositionYAddress, (nint)y0); // the span starts at the native lane + } + var heldFirst = before.PositionY[1]; + + Run(world, 30); + + var after = world.View; + Assert.Equal(before.PositionYAddress, after.PositionYAddress); // same memory after stepping + // The view taken before the step shows the stepped state: it is not a snapshot. + Assert.NotEqual(heldFirst, before.PositionY[1]); + Assert.Equal(after.PositionY[1], before.PositionY[1]); + Assert.Equal(30ul, world.StepCount); + } + + [Fact] + public void Two_runs_with_the_same_inputs_match_bit_for_bit_at_every_checkpoint() + { + using var a = Build(); + using var b = Build(); + for (var checkpoint = 0; checkpoint < 6; checkpoint++) + { + Run(a, 50); + Run(b, 50); + Assert.True(Lanes(a.View).AsSpan().SequenceEqual(Lanes(b.View)), $"diverged by step {50 * (checkpoint + 1)}"); + } + // The spheres collided with each other and the ground on the way: the lanes are not trivially equal zeros. + Assert.Contains(a.View.VelocityX.ToArray(), v => v != 0f); + } + + [Fact] + public void The_bit_comparison_can_fail_one_input_one_ulp_apart_diverges() + { + using var a = Build(); + using var b = Build(firstDropHeight: BitConverter.UInt32BitsToSingle(BitConverter.SingleToUInt32Bits(3f) + 1)); + Run(a, 300); + Run(b, 300); + Assert.False(Lanes(a.View).AsSpan().SequenceEqual(Lanes(b.View))); + } + + [Fact] + public void Refuses_bad_input_and_a_full_or_duplicate_world() + { + using var world = new Box3DSoaWorld(capacity: 2); + world.AddStaticBox(1, (0f, 0f, 0f), (1f, 1f, 1f)); + Assert.Throws(() => world.AddStaticBox(1, (0f, 0f, 0f), (1f, 1f, 1f))); // duplicate id + world.AddSphere(2, (0f, 5f, 0f), 0.5f, 1f); + Assert.Throws(() => world.AddSphere(3, (0f, 5f, 0f), 0.5f, 1f)); // capacity + Assert.Throws(() => world.Step(0f)); + Assert.Throws(() => world.Step(float.NaN)); + using var other = new Box3DSoaWorld(capacity: 2); + Assert.Throws(() => other.AddSphere(1, (float.NaN, 0f, 0f), 0.5f, 1f)); + Assert.Throws(() => other.AddSphere(1, (0f, 0f, 0f), 0f, 1f)); + Assert.Throws(() => new Box3DSoaWorld(capacity: 0)); + } + + [Fact] + public void A_disposed_world_refuses_use() + { + var world = Build(); + world.Dispose(); + world.Dispose(); + Assert.Throws(() => world.Step(Dt)); + Assert.Throws(() => world.View); + } +} diff --git a/tests/Ymir.Box3D.Soa.Tests/Ymir.Box3D.Soa.Tests.csproj b/tests/Ymir.Box3D.Soa.Tests/Ymir.Box3D.Soa.Tests.csproj new file mode 100644 index 0000000..30ef337 --- /dev/null +++ b/tests/Ymir.Box3D.Soa.Tests/Ymir.Box3D.Soa.Tests.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + true + false + + + + + + + + + + + + + + + + +