Headless 3D SoA world over Box3D, state read in place #1
9 changed files with 768 additions and 1 deletions
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
38
docs/soa-world.md
Normal 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.
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
348
native/Ymir.Box3D/ymir_box3d_soa.c
Normal file
348
native/Ymir.Box3D/ymir_box3d_soa.c
Normal 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;
|
||||
}
|
||||
203
src/Ymir.Box3D/Box3DSoaWorld.cs
Normal file
203
src/Ymir.Box3D/Box3DSoaWorld.cs
Normal 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}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("Ymir.Core")]
|
||||
[assembly: InternalsVisibleTo("Ymir.Box3D.Soa.Tests")]
|
||||
|
|
|
|||
150
tests/Ymir.Box3D.Soa.Tests/Box3DSoaWorldTests.cs
Normal file
150
tests/Ymir.Box3D.Soa.Tests/Box3DSoaWorldTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
25
tests/Ymir.Box3D.Soa.Tests/Ymir.Box3D.Soa.Tests.csproj
Normal file
25
tests/Ymir.Box3D.Soa.Tests/Ymir.Box3D.Soa.Tests.csproj
Normal 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>
|
||||
Loading…
Reference in a new issue