Headless 3D SoA world over Box3D, state read in place #1

Merged
nibu merged 1 commit from nibu/soa-world into main 2026-11-10 17:13:58 +00:00
9 changed files with 768 additions and 1 deletions

View file

@ -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`.

View file

@ -6,6 +6,7 @@
</Folder>
<Folder Name="/tests/">
<Project Path="tests/Ymir.Box3D.Parity/Ymir.Box3D.Parity.csproj" />
<Project Path="tests/Ymir.Box3D.Soa.Tests/Ymir.Box3D.Soa.Tests.csproj" />
<Project Path="tests/Ymir.Tests/Ymir.Tests.csproj" />
</Folder>
<Project Path="../CultLib/packages/cultmath/src/CultMath/CultMath.csproj" />

38
docs/soa-world.md Normal file
View file

@ -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<T>` 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.

View file

@ -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)

View file

@ -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 <box3d/base.h>
#include <box3d/box3d.h>
#include <box3d/collision.h>
#include <box3d/math_functions.h>
#include <box3d/types.h>
#include <math.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#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;
}

View file

@ -0,0 +1,203 @@
using System.Runtime.InteropServices;
namespace Ymir.Box3D;
internal enum Box3DSoaKind : uint
{
Sphere = 0,
StaticBox = 1
}
/// <summary>Native layout of <c>ymir_box3d_soa_view</c>: counts, then one pointer per lane.</summary>
[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);
}
/// <summary>
/// The lanes of a <see cref="Box3DSoaWorld"/>: 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 <see cref="Box3DSoaWorld.Step"/>, and every span is
/// invalid once the world is disposed.
/// </summary>
internal readonly unsafe struct Box3DSoaView
{
private readonly Box3DSoaViewNative _v;
internal Box3DSoaView(Box3DSoaViewNative v) => _v = v;
/// <summary>Bodies at the moment this view was taken: take it after the last Add. Only the lane contents are live.</summary>
public int Count => _v.Count;
public int Capacity => _v.Capacity;
public ReadOnlySpan<ulong> StableId => new(_v.StableId, Count);
public ReadOnlySpan<uint> Kind => new(_v.Kind, Count);
public ReadOnlySpan<float> PositionX => new(_v.PositionX, Count);
public ReadOnlySpan<float> PositionY => new(_v.PositionY, Count);
public ReadOnlySpan<float> PositionZ => new(_v.PositionZ, Count);
/// <summary>Unit quaternion, x y z w.</summary>
public ReadOnlySpan<float> RotationX => new(_v.RotationX, Count);
public ReadOnlySpan<float> RotationY => new(_v.RotationY, Count);
public ReadOnlySpan<float> RotationZ => new(_v.RotationZ, Count);
public ReadOnlySpan<float> RotationW => new(_v.RotationW, Count);
public ReadOnlySpan<float> VelocityX => new(_v.VelocityX, Count);
public ReadOnlySpan<float> VelocityY => new(_v.VelocityY, Count);
public ReadOnlySpan<float> VelocityZ => new(_v.VelocityZ, Count);
public ReadOnlySpan<float> AngularVelocityX => new(_v.AngularVelocityX, Count);
public ReadOnlySpan<float> AngularVelocityY => new(_v.AngularVelocityY, Count);
public ReadOnlySpan<float> AngularVelocityZ => new(_v.AngularVelocityZ, Count);
/// <summary>Address of a lane, for tests and interop that need to prove nothing was copied.</summary>
public nint PositionYAddress => (nint)_v.PositionY;
}
/// <summary>
/// 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.
/// </summary>
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<Box3DSoaViewNative>())
{
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));
}
/// <summary>The live lanes. Count and step count are read from native memory each call.</summary>
public Box3DSoaView View
{
get
{
ObjectDisposedException.ThrowIf(_handle == 0, this);
Check(Box3DSoaNative.ymir_box3d_soa_get_view(_handle, out var v));
return new Box3DSoaView(v);
}
}
/// <summary>Steps taken so far.</summary>
public ulong StepCount
{
get
{
ObjectDisposedException.ThrowIf(_handle == 0, this);
Check(Box3DSoaNative.ymir_box3d_soa_get_view(_handle, out var v));
return v.StepCount;
}
}
/// <summary>Adds a dynamic sphere; returns its lane index.</summary>
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;
}
/// <summary>Adds a static axis-aligned box centred at <paramref name="position"/>.</summary>
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}.");
}
}
}

View file

@ -1,3 +1,4 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Ymir.Core")]
[assembly: InternalsVisibleTo("Ymir.Box3D.Soa.Tests")]

View file

@ -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;
/// <summary>A ground slab whose top face is y = 0 and a 5 x 5 grid of unit spheres dropped from varied heights.</summary>
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<byte[]>();
void Add(ReadOnlySpan<float> 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<InvalidOperationException>(() => world.AddStaticBox(1, (0f, 0f, 0f), (1f, 1f, 1f))); // duplicate id
world.AddSphere(2, (0f, 5f, 0f), 0.5f, 1f);
Assert.Throws<InvalidOperationException>(() => world.AddSphere(3, (0f, 5f, 0f), 0.5f, 1f)); // capacity
Assert.Throws<InvalidOperationException>(() => world.Step(0f));
Assert.Throws<InvalidOperationException>(() => world.Step(float.NaN));
using var other = new Box3DSoaWorld(capacity: 2);
Assert.Throws<InvalidOperationException>(() => other.AddSphere(1, (float.NaN, 0f, 0f), 0.5f, 1f));
Assert.Throws<InvalidOperationException>(() => other.AddSphere(1, (0f, 0f, 0f), 0f, 1f));
Assert.Throws<InvalidOperationException>(() => new Box3DSoaWorld(capacity: 0));
}
[Fact]
public void A_disposed_world_refuses_use()
{
var world = Build();
world.Dispose();
world.Dispose();
Assert.Throws<ObjectDisposedException>(() => world.Step(Dt));
Assert.Throws<ObjectDisposedException>(() => world.View);
}
}

View file

@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Ymir.Box3D\Ymir.Box3D.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>