Replace managed physics with Box3D runtime boundary
This commit is contained in:
parent
658db6c986
commit
721eee0656
26 changed files with 2167 additions and 200 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -6,4 +6,4 @@ obj/
|
|||
*.user
|
||||
TestResults/
|
||||
BenchmarkDotNet.Artifacts/
|
||||
artifacts/box3d-native/
|
||||
artifacts/
|
||||
|
|
|
|||
49
README.md
49
README.md
|
|
@ -1,8 +1,8 @@
|
|||
# Ymir
|
||||
|
||||
Ymir is the GameCult Box3D wrapper and authoritative physics daemon for
|
||||
Aetheria and future world-bearing projects. The name means **Not Invented
|
||||
Here**. We are taking the hint.
|
||||
Ymir is the GameCult Box3D wrapper and physics-daemon boundary for Aetheria and
|
||||
future world-bearing projects. The name means **Not Invented Here**. We are
|
||||
taking the hint.
|
||||
|
||||
Box3D owns physics algorithms. Ymir owns the service boundary that makes those
|
||||
algorithms usable as durable, typed GameCult world truth. Unity, renderers, and
|
||||
|
|
@ -15,10 +15,13 @@ authorities.
|
|||
behavior, continuous collision, overlap and cast semantics, contact
|
||||
lifecycle, force and torque lifetime, tolerances, and deterministic step
|
||||
behavior for identical recorded inputs.
|
||||
- Ymir owns the pinned Box3D version, stable GameCult entity ids, native ABI
|
||||
isolation, long-lived world sessions, typed commands and facts,
|
||||
deterministic result ordering, checkpoint reconstruction, CultCache state,
|
||||
CultMesh publication, and daemon lifecycle.
|
||||
- Ymir currently owns the pinned Box3D version, stable GameCult entity ids,
|
||||
native ABI isolation, snapshot lowering, deterministic result ordering,
|
||||
versioned CultCache snapshots, and a diagnostic Eve publication.
|
||||
- The Ymir daemon will own long-lived world sessions, typed commands and
|
||||
receipts, contact lifecycle facts, checkpoint reconstruction, CultMesh
|
||||
publication, and process supervision. Those are the cutover contract, not a
|
||||
claim that the current CLI already exposes them.
|
||||
- Aetheria owns gameplay policy and supplies typed physical intent: bodies,
|
||||
fields, tractor targets, forces, projectiles, filters, and the gameplay
|
||||
consequences of Ymir contact facts.
|
||||
|
|
@ -44,11 +47,19 @@ parity suite already witness released Box3D behavior for:
|
|||
- restitution mixing
|
||||
- transient torque lifetime
|
||||
|
||||
The legacy managed Ymir solver remains temporarily live while retained Box3D
|
||||
world sessions, end-contact lifecycle, continuous collision, replay, and
|
||||
checkpoint reconstruction are connected. It is compatibility scaffolding, not
|
||||
the target architecture. It will be deleted, along with Ymir's custom geometry
|
||||
and spatial indexes, once the Box3D-backed path proves the public contract.
|
||||
The production `Ymir.Box3D` boundary owns the native session ABI. The legacy
|
||||
snapshot `YmirSimulator.Step` contract now creates an isolated Box3D session,
|
||||
projects the result, and disposes it. It contains no managed integrator,
|
||||
collision loop, or impulse solver.
|
||||
|
||||
That snapshot facade deliberately does not pretend to own long-lived world
|
||||
identity. The retained native session exists behind an internal managed port;
|
||||
there is not yet a public session registry or command contract. Aetheria will
|
||||
name one Ymir session per run and zone when that contract is exposed. Its old
|
||||
process-wide simulator objects are not session identifiers. Planar heading,
|
||||
angular velocity, and transient torque already pass through Box3D. Typed
|
||||
begin/end/hit lifecycle facts, checkpoint reconstruction, and non-Windows RID
|
||||
artifacts remain cutover work.
|
||||
|
||||
See [the architecture map](docs/architecture.md) and
|
||||
[the executable parity contract](docs/box3d-parity.md).
|
||||
|
|
@ -71,9 +82,17 @@ dotnet test tests\Ymir.Box3D.Parity\Ymir.Box3D.Parity.csproj
|
|||
|
||||
Box3D is MIT licensed. Its license is retained in `extern/box3d`.
|
||||
|
||||
`GameCult.Ymir.Box3D` packages the managed boundary, Box3D license, and native
|
||||
facade under `runtimes/<rid>/native`. A local Windows x64 package has loaded in
|
||||
an isolated empty consumer without CMake or the submodule. A committed release
|
||||
smoke is still required before that is a repeatable packaging guarantee. Linux
|
||||
and macOS packages must be produced and smoked on their own release workers
|
||||
before those RIDs are advertised.
|
||||
|
||||
## Cutover Rule
|
||||
|
||||
A Ymir cutover is complete only when all runtime stepping, collision queries,
|
||||
and contact facts come from a retained Box3D world through one Ymir session
|
||||
primitive. Manual Unity callbacks, Aetheria geometry tests, and the managed
|
||||
Ymir solver must be structurally unable to decide the result.
|
||||
and contact facts come from Box3D through an explicitly owned Ymir session.
|
||||
Manual Unity callbacks and Aetheria geometry tests must be structurally unable
|
||||
to decide the result. Snapshot compatibility calls may use isolated sessions;
|
||||
they are not allowed to infer or share retained-world identity.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/ymir-daemon/ymir-daemon.csproj" />
|
||||
<Project Path="src/Ymir.Box3D/Ymir.Box3D.csproj" />
|
||||
<Project Path="src/Ymir.Core/Ymir.Core.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ Ymir is a wrapper and daemon, not an independent physics-engine research
|
|||
project. Box3D supplies the physics mind. Ymir supplies the memory, nerves,
|
||||
names, and service boundary.
|
||||
|
||||
## Owner
|
||||
## Target Owner
|
||||
|
||||
A long-lived Box3D `b3World` owns the physical decision: given the current
|
||||
world plus commands for a fixed step, what transforms, velocities, overlaps,
|
||||
|
|
@ -27,7 +27,23 @@ Ymir owns the enclosing session and contract:
|
|||
CultMesh;
|
||||
- supervise world lifetime, failure, replay, and observability.
|
||||
|
||||
## Inputs
|
||||
## Current Body
|
||||
|
||||
The runtime solver cut is complete: Box3D is the only integrator, collision
|
||||
engine, broadphase, query-geometry implementation, and contact source in the
|
||||
committed Ymir path. The public `YmirSimulator.Step` compatibility API creates
|
||||
an isolated internal session for one complete snapshot, projects its output,
|
||||
and disposes it. Public circle overlap and cast APIs invoke Box3D geometry over
|
||||
caller-supplied bodies.
|
||||
|
||||
The retained native session and its managed wrapper are internal. The current
|
||||
CLI has no named session registry, public mutation commands, command receipts,
|
||||
retained-world queries, or begin/end/hit lifecycle contract. Its CultCache and
|
||||
Eve output is a regenerated diagnostic projection. The sections marked as
|
||||
target contract describe the cut still to build; they are not advertisements
|
||||
of current daemon capability.
|
||||
|
||||
## Target Inputs
|
||||
|
||||
- stable body and shape definitions
|
||||
- fixed time step and explicit substep count
|
||||
|
|
@ -39,7 +55,7 @@ Ymir owns the enclosing session and contract:
|
|||
Aetheria chooses physical intent and gameplay eligibility. Box3D decides
|
||||
geometry membership and contact. Ymir carries the typed handoff.
|
||||
|
||||
## Outputs
|
||||
## Target Outputs
|
||||
|
||||
- body transforms and linear/angular velocities
|
||||
- begin, end, and hit contact facts with stable Ymir ids
|
||||
|
|
@ -87,12 +103,21 @@ solver.
|
|||
- transport endpoints or dashboards owning world state;
|
||||
- Box3D opaque ids stored as durable GameCult identity.
|
||||
|
||||
## Shared Paths
|
||||
## Target Shared Paths
|
||||
|
||||
Direct player commands, agent commands, programmatic spawns, server ticks,
|
||||
imports, reconnects, replay, and checkpoint recovery all enter one Ymir world
|
||||
session primitive. DTO compatibility methods may lower into that primitive;
|
||||
they may not run an alternate step.
|
||||
imports, reconnects, replay, and checkpoint recovery will enter the same Ymir
|
||||
session primitive for their named world. Today, DTO compatibility methods
|
||||
lower into an isolated Box3D session; they may not run an alternate step or
|
||||
infer retained identity from a body list.
|
||||
|
||||
Session identity must be explicit. A process-wide simulator object, zone index,
|
||||
or repeated stable body id is not enough to join two calls to the same world.
|
||||
Aetheria owns run and zone lifetime and will create, retain, and dispose the
|
||||
corresponding Ymir session after the public port exists. Current projectile
|
||||
sweep and stationary mine proximity integration can use Ymir's static
|
||||
Box3D-backed circle-cast and overlap queries, but those calls do not join a
|
||||
retained world or own contact lifecycle.
|
||||
|
||||
Radial gravity and tractor attraction are typed force-generation inputs. They
|
||||
are applied to Box3D before stepping. Pickup collection remains an Aetheria
|
||||
|
|
@ -115,6 +140,13 @@ The native facade must remain boring:
|
|||
|
||||
## Persistence
|
||||
|
||||
`gamecult.ymir.world_state.v1` appends planar direction and angular velocity to
|
||||
the v0 layout. The v0 reader is an explicit migration path; keys are not
|
||||
reassigned. Torque is transient command state and is intentionally absent.
|
||||
Version 1 is a compatibility world snapshot, not a complete retained-session
|
||||
checkpoint: it does not preserve full shape, material, filter, session tuning,
|
||||
provenance, or warm contact state.
|
||||
|
||||
The canonical checkpoint schema must contain enough typed definitions and
|
||||
state to reconstruct the accepted Box3D world: stable ids, 3D transforms,
|
||||
linear and angular velocity, body/shape/material/filter definitions, session
|
||||
|
|
@ -127,8 +159,10 @@ under a reassigned MessagePack layout.
|
|||
|
||||
## Cut Line
|
||||
|
||||
The managed Ymir solver, `YmirQueryPrimitives`, and `YmirSpatialIndex` are
|
||||
deleted when retained Box3D sessions prove:
|
||||
The managed integrator and circle impulse solver have been deleted from the
|
||||
runtime path. Any uncommitted `YmirQueryPrimitives` or `YmirSpatialIndex` work
|
||||
is obsolete and must not be annexed. The retained-session cut continues until
|
||||
Box3D-backed sessions prove:
|
||||
|
||||
1. fixed-step body state projection;
|
||||
2. begin/end/hit contact lifecycle;
|
||||
|
|
@ -148,7 +182,9 @@ failure fails closed and reports the missing capability.
|
|||
session facts.
|
||||
- Negative source checks prove custom solver and spatial-index authorities are
|
||||
absent after cutover.
|
||||
- Aetheria smokes prove gravity, contact-gated destruction loot, tractor
|
||||
attraction, pickup collection, and fast payload hits.
|
||||
- Aetheria smokes must prove gravity, contact-gated destruction loot, tractor
|
||||
attraction, pickup collection, and fast payload hits through the retained
|
||||
contract. Current gravity, pickup-contact, cast, and overlap witnesses cover
|
||||
only the snapshot/query compatibility path.
|
||||
- Released-client witnesses prove generic Eve clients only observe Ymir and
|
||||
Aetheria provider contracts, never native implementation details.
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
## Authority
|
||||
|
||||
Box3D v0.1.0 is Ymir's physics implementation and executable semantics oracle.
|
||||
The production session cutover is still under construction. The exact upstream source is pinned as the
|
||||
The production native session ABI and snapshot lowering are live; explicit
|
||||
retained-world ownership is still being connected. The exact upstream source is pinned as the
|
||||
`extern/box3d` submodule at commit
|
||||
`8441b4a06d6d09dcfb0b0f704df4d847d1437b92`.
|
||||
|
||||
|
|
@ -15,11 +16,17 @@ torque lifetime, and numeric tolerances.
|
|||
## Body
|
||||
|
||||
`native/Ymir.Box3D` statically links the pinned C17 library into one small
|
||||
shared-library facade. The facade exports only blittable sphere overlap,
|
||||
sphere cast, capsule-versus-sphere membership, planar pair stepping, torque
|
||||
lifetime, and version operations. This keeps Box3D's alpha C ABI out of Ymir's
|
||||
shared-library facade. The facade exports retained sphere-world sessions plus
|
||||
the overlap, cast, pair-step, torque-lifetime, version, and ABI-layout
|
||||
operations used by the harness. This keeps Box3D's alpha C ABI out of Ymir's
|
||||
public managed contracts.
|
||||
|
||||
Production ABI version 2 uses caller-owned blittable buffers, explicit cdecl,
|
||||
fixed-width status/count values, compile-time C layout assertions, and a
|
||||
managed/native layout sentinel before opening a session. Planar direction maps
|
||||
to Box3D rotation about Y; angular velocity and transient torque remain Box3D
|
||||
state and commands rather than managed integration.
|
||||
|
||||
`tests/Ymir.Box3D.Parity` builds that facade through CMake and invokes it with
|
||||
source-generated .NET interop. Run it with:
|
||||
|
||||
|
|
@ -50,9 +57,8 @@ is retained in the pinned submodule.
|
|||
|
||||
## Cut Line
|
||||
|
||||
The current managed collision formulas are transitional. They may exist only
|
||||
while differential tests prove compatibility. The remaining parity slice
|
||||
covers retained world sessions, end-contact lifecycle, continuous collision,
|
||||
and replay. Once those facts are projected through a stable Ymir native port,
|
||||
the managed solver, query geometry, and spatial indexes are deleted rather
|
||||
than retained as fallback authority.
|
||||
The managed integrator and circle collision solver are no longer runtime
|
||||
authorities. The remaining parity slice covers explicit retained-world
|
||||
ownership, end-contact lifecycle, continuous collision, and replay. Custom
|
||||
query geometry and spatial indexes are not fallback authorities; Box3D owns
|
||||
those edge cases.
|
||||
|
|
|
|||
|
|
@ -5,10 +5,17 @@
|
|||
#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
|
||||
|
||||
typedef struct ymir_box3d_overlap_result
|
||||
|
|
@ -65,11 +72,561 @@ typedef struct ymir_box3d_torque_result
|
|||
float angular_velocity_after_unforced_step;
|
||||
} ymir_box3d_torque_result;
|
||||
|
||||
typedef enum ymir_box3d_status
|
||||
{
|
||||
YMIR_BOX3D_OK = 0,
|
||||
YMIR_BOX3D_INVALID_ARGUMENT = 1,
|
||||
YMIR_BOX3D_DUPLICATE_ID = 2,
|
||||
YMIR_BOX3D_BUFFER_TOO_SMALL = 3,
|
||||
YMIR_BOX3D_OUT_OF_MEMORY = 4,
|
||||
YMIR_BOX3D_INTERNAL_ERROR = 5,
|
||||
} ymir_box3d_status;
|
||||
|
||||
typedef struct ymir_box3d_session_body_input
|
||||
{
|
||||
uint64_t stable_id;
|
||||
float position_x;
|
||||
float position_y;
|
||||
float velocity_x;
|
||||
float velocity_y;
|
||||
float direction_x;
|
||||
float direction_y;
|
||||
float angular_velocity;
|
||||
float torque;
|
||||
float radius;
|
||||
float mass;
|
||||
float restitution;
|
||||
uint32_t is_static;
|
||||
} ymir_box3d_session_body_input;
|
||||
|
||||
typedef struct ymir_box3d_radial_field_input
|
||||
{
|
||||
float position_x;
|
||||
float position_y;
|
||||
float strength;
|
||||
float radius;
|
||||
} ymir_box3d_radial_field_input;
|
||||
|
||||
typedef struct ymir_box3d_session_body_output
|
||||
{
|
||||
uint64_t stable_id;
|
||||
float position_x;
|
||||
float position_y;
|
||||
float velocity_x;
|
||||
float velocity_y;
|
||||
float direction_x;
|
||||
float direction_y;
|
||||
float angular_velocity;
|
||||
} ymir_box3d_session_body_output;
|
||||
|
||||
typedef struct ymir_box3d_session_contact_output
|
||||
{
|
||||
uint64_t stable_id_a;
|
||||
uint64_t stable_id_b;
|
||||
float point_x;
|
||||
float point_y;
|
||||
float normal_x;
|
||||
float normal_y;
|
||||
float penetration;
|
||||
float relative_speed;
|
||||
} ymir_box3d_session_contact_output;
|
||||
|
||||
typedef struct ymir_box3d_session_body
|
||||
{
|
||||
uint64_t stable_id;
|
||||
b3BodyId body_id;
|
||||
b3ShapeId shape_id;
|
||||
float last_position_x;
|
||||
float last_position_y;
|
||||
float last_velocity_x;
|
||||
float last_velocity_y;
|
||||
float last_direction_x;
|
||||
float last_direction_y;
|
||||
float last_angular_velocity;
|
||||
float pending_torque;
|
||||
float radius;
|
||||
float mass;
|
||||
float restitution;
|
||||
uint32_t is_static;
|
||||
int seen;
|
||||
} ymir_box3d_session_body;
|
||||
|
||||
typedef struct ymir_box3d_session
|
||||
{
|
||||
b3WorldId world_id;
|
||||
ymir_box3d_session_body* bodies;
|
||||
int body_count;
|
||||
int body_capacity;
|
||||
} ymir_box3d_session;
|
||||
|
||||
_Static_assert(sizeof(ymir_box3d_status) == sizeof(int32_t), "Ymir Box3D status ABI must be 32-bit");
|
||||
_Static_assert(sizeof(ymir_box3d_session_body_input) == 56, "Unexpected Ymir Box3D body input layout");
|
||||
_Static_assert(sizeof(ymir_box3d_radial_field_input) == 16, "Unexpected Ymir Box3D field layout");
|
||||
_Static_assert(sizeof(ymir_box3d_session_body_output) == 40, "Unexpected Ymir Box3D body output layout");
|
||||
_Static_assert(sizeof(ymir_box3d_session_contact_output) == 40, "Unexpected Ymir Box3D contact layout");
|
||||
|
||||
static b3Vec3 ymir_vec(float x, float y)
|
||||
{
|
||||
return (b3Vec3){x, 0.0f, y};
|
||||
}
|
||||
|
||||
static int ymir_is_finite(float value)
|
||||
{
|
||||
return isfinite(value) != 0;
|
||||
}
|
||||
|
||||
static int ymir_session_body_input_is_valid(const ymir_box3d_session_body_input* input)
|
||||
{
|
||||
float direction_length_squared = input != NULL
|
||||
? input->direction_x * input->direction_x + input->direction_y * input->direction_y
|
||||
: 0.0f;
|
||||
return input != NULL && input->stable_id != 0 && input->stable_id <= (uint64_t)UINTPTR_MAX &&
|
||||
ymir_is_finite(input->position_x) && ymir_is_finite(input->position_y) &&
|
||||
ymir_is_finite(input->velocity_x) && ymir_is_finite(input->velocity_y) &&
|
||||
ymir_is_finite(input->direction_x) && ymir_is_finite(input->direction_y) &&
|
||||
direction_length_squared > 1.0e-8f && ymir_is_finite(input->angular_velocity) &&
|
||||
ymir_is_finite(input->torque) &&
|
||||
ymir_is_finite(input->radius) && input->radius > 0.0f && ymir_is_finite(input->mass) &&
|
||||
(input->is_static != 0 || input->mass > 0.0f) && ymir_is_finite(input->restitution) &&
|
||||
input->restitution >= 0.0f;
|
||||
}
|
||||
|
||||
static b3Quat ymir_session_rotation(float direction_x, float direction_y)
|
||||
{
|
||||
float angle = atan2f(direction_x, direction_y);
|
||||
return b3MakeQuatFromAxisAngle((b3Vec3){0.0f, 1.0f, 0.0f}, angle);
|
||||
}
|
||||
|
||||
static b3Vec3 ymir_session_direction(b3BodyId body_id)
|
||||
{
|
||||
return b3RotateVector(b3Body_GetRotation(body_id), (b3Vec3){0.0f, 0.0f, 1.0f});
|
||||
}
|
||||
|
||||
static int ymir_session_find_body(const ymir_box3d_session* session, uint64_t stable_id)
|
||||
{
|
||||
for (int i = 0; i < session->body_count; ++i)
|
||||
{
|
||||
if (session->bodies[i].stable_id == stable_id)
|
||||
{
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
static ymir_box3d_status ymir_session_reserve_bodies(ymir_box3d_session* session, int capacity)
|
||||
{
|
||||
if (capacity <= session->body_capacity)
|
||||
{
|
||||
return YMIR_BOX3D_OK;
|
||||
}
|
||||
|
||||
int new_capacity = session->body_capacity > 0 ? session->body_capacity : 16;
|
||||
while (new_capacity < capacity)
|
||||
{
|
||||
if (new_capacity > INT32_MAX / 2)
|
||||
{
|
||||
new_capacity = capacity;
|
||||
break;
|
||||
}
|
||||
new_capacity *= 2;
|
||||
}
|
||||
|
||||
ymir_box3d_session_body* bodies =
|
||||
(ymir_box3d_session_body*)realloc(session->bodies, (size_t)new_capacity * sizeof(ymir_box3d_session_body));
|
||||
if (bodies == NULL)
|
||||
{
|
||||
return YMIR_BOX3D_OUT_OF_MEMORY;
|
||||
}
|
||||
|
||||
session->bodies = bodies;
|
||||
session->body_capacity = new_capacity;
|
||||
return YMIR_BOX3D_OK;
|
||||
}
|
||||
|
||||
static float ymir_session_density(float mass, float radius)
|
||||
{
|
||||
float volume = 4.0f / 3.0f * B3_PI * radius * radius * radius;
|
||||
return mass / volume;
|
||||
}
|
||||
|
||||
static ymir_box3d_status ymir_session_create_body(
|
||||
ymir_box3d_session* session,
|
||||
const ymir_box3d_session_body_input* input,
|
||||
ymir_box3d_session_body* output)
|
||||
{
|
||||
b3BodyDef body_def = b3DefaultBodyDef();
|
||||
body_def.type = input->is_static != 0 ? b3_staticBody : b3_dynamicBody;
|
||||
body_def.position = ymir_vec(input->position_x, input->position_y);
|
||||
body_def.rotation = ymir_session_rotation(input->direction_x, input->direction_y);
|
||||
body_def.linearVelocity = ymir_vec(input->velocity_x, input->velocity_y);
|
||||
body_def.angularVelocity = (b3Vec3){0.0f, input->angular_velocity, 0.0f};
|
||||
body_def.motionLocks.linearY = true;
|
||||
body_def.motionLocks.angularX = true;
|
||||
body_def.motionLocks.angularZ = true;
|
||||
body_def.enableSleep = false;
|
||||
b3BodyId body_id = b3CreateBody(session->world_id, &body_def);
|
||||
if (B3_IS_NULL(body_id))
|
||||
{
|
||||
return YMIR_BOX3D_INTERNAL_ERROR;
|
||||
}
|
||||
|
||||
b3ShapeDef shape_def = b3DefaultShapeDef();
|
||||
shape_def.userData = (void*)(uintptr_t)input->stable_id;
|
||||
shape_def.density = input->is_static != 0 ? 0.0f : ymir_session_density(input->mass, input->radius);
|
||||
shape_def.baseMaterial.friction = 0.0f;
|
||||
shape_def.baseMaterial.restitution = input->restitution;
|
||||
shape_def.enableContactEvents = true;
|
||||
shape_def.enableHitEvents = true;
|
||||
b3Sphere sphere = {{0.0f, 0.0f, 0.0f}, input->radius};
|
||||
b3ShapeId shape_id = b3CreateSphereShape(body_id, &shape_def, &sphere);
|
||||
if (B3_IS_NULL(shape_id))
|
||||
{
|
||||
b3DestroyBody(body_id);
|
||||
return YMIR_BOX3D_INTERNAL_ERROR;
|
||||
}
|
||||
|
||||
*output = (ymir_box3d_session_body){
|
||||
.stable_id = input->stable_id,
|
||||
.body_id = body_id,
|
||||
.shape_id = shape_id,
|
||||
.last_position_x = input->position_x,
|
||||
.last_position_y = input->position_y,
|
||||
.last_velocity_x = input->velocity_x,
|
||||
.last_velocity_y = input->velocity_y,
|
||||
.last_direction_x = input->direction_x,
|
||||
.last_direction_y = input->direction_y,
|
||||
.last_angular_velocity = input->angular_velocity,
|
||||
.pending_torque = input->torque,
|
||||
.radius = input->radius,
|
||||
.mass = input->mass,
|
||||
.restitution = input->restitution,
|
||||
.is_static = input->is_static != 0,
|
||||
.seen = 1,
|
||||
};
|
||||
return YMIR_BOX3D_OK;
|
||||
}
|
||||
|
||||
static void ymir_session_update_body(
|
||||
ymir_box3d_session_body* body,
|
||||
const ymir_box3d_session_body_input* input)
|
||||
{
|
||||
int type_changed = body->is_static != (input->is_static != 0);
|
||||
int radius_changed = body->radius != input->radius;
|
||||
int mass_changed = body->mass != input->mass;
|
||||
|
||||
if (type_changed)
|
||||
{
|
||||
b3Body_SetType(body->body_id, input->is_static != 0 ? b3_staticBody : b3_dynamicBody);
|
||||
}
|
||||
|
||||
if (radius_changed)
|
||||
{
|
||||
b3Sphere sphere = {{0.0f, 0.0f, 0.0f}, input->radius};
|
||||
b3Shape_SetSphere(body->shape_id, &sphere);
|
||||
}
|
||||
|
||||
if (type_changed || radius_changed || mass_changed)
|
||||
{
|
||||
float density = input->is_static != 0 ? 0.0f : ymir_session_density(input->mass, input->radius);
|
||||
b3Shape_SetDensity(body->shape_id, density, false);
|
||||
b3Body_ApplyMassFromShapes(body->body_id);
|
||||
}
|
||||
|
||||
if (body->restitution != input->restitution)
|
||||
{
|
||||
b3Shape_SetRestitution(body->shape_id, input->restitution);
|
||||
}
|
||||
|
||||
if (body->last_position_x != input->position_x || body->last_position_y != input->position_y ||
|
||||
body->last_direction_x != input->direction_x || body->last_direction_y != input->direction_y)
|
||||
{
|
||||
b3Body_SetTransform(
|
||||
body->body_id,
|
||||
ymir_vec(input->position_x, input->position_y),
|
||||
ymir_session_rotation(input->direction_x, input->direction_y));
|
||||
}
|
||||
|
||||
if (type_changed || body->last_velocity_x != input->velocity_x || body->last_velocity_y != input->velocity_y)
|
||||
{
|
||||
b3Body_SetLinearVelocity(body->body_id, ymir_vec(input->velocity_x, input->velocity_y));
|
||||
}
|
||||
|
||||
if (type_changed || body->last_angular_velocity != input->angular_velocity)
|
||||
{
|
||||
b3Body_SetAngularVelocity(body->body_id, (b3Vec3){0.0f, input->angular_velocity, 0.0f});
|
||||
}
|
||||
|
||||
body->last_position_x = input->position_x;
|
||||
body->last_position_y = input->position_y;
|
||||
body->last_velocity_x = input->velocity_x;
|
||||
body->last_velocity_y = input->velocity_y;
|
||||
body->last_direction_x = input->direction_x;
|
||||
body->last_direction_y = input->direction_y;
|
||||
body->last_angular_velocity = input->angular_velocity;
|
||||
body->pending_torque = input->torque;
|
||||
body->radius = input->radius;
|
||||
body->mass = input->mass;
|
||||
body->restitution = input->restitution;
|
||||
body->is_static = input->is_static != 0;
|
||||
body->seen = 1;
|
||||
}
|
||||
|
||||
static uint64_t ymir_session_shape_stable_id(b3ShapeId shape_id)
|
||||
{
|
||||
return (uint64_t)(uintptr_t)b3Shape_GetUserData(shape_id);
|
||||
}
|
||||
|
||||
typedef struct ymir_box3d_stable_pair
|
||||
{
|
||||
uint64_t first;
|
||||
uint64_t second;
|
||||
} ymir_box3d_stable_pair;
|
||||
|
||||
typedef struct ymir_box3d_stable_pair_set
|
||||
{
|
||||
ymir_box3d_stable_pair* pairs;
|
||||
int count;
|
||||
int capacity;
|
||||
} ymir_box3d_stable_pair_set;
|
||||
|
||||
static ymir_box3d_status ymir_session_add_stable_pair(
|
||||
ymir_box3d_stable_pair_set* set,
|
||||
uint64_t stable_id_a,
|
||||
uint64_t stable_id_b,
|
||||
int* added)
|
||||
{
|
||||
uint64_t first = stable_id_a < stable_id_b ? stable_id_a : stable_id_b;
|
||||
uint64_t second = stable_id_a < stable_id_b ? stable_id_b : stable_id_a;
|
||||
for (int i = 0; i < set->count; ++i)
|
||||
{
|
||||
if (set->pairs[i].first == first && set->pairs[i].second == second)
|
||||
{
|
||||
*added = 0;
|
||||
return YMIR_BOX3D_OK;
|
||||
}
|
||||
}
|
||||
|
||||
if (set->count == set->capacity)
|
||||
{
|
||||
int new_capacity = set->capacity > 0 ? set->capacity * 2 : 16;
|
||||
ymir_box3d_stable_pair* pairs =
|
||||
(ymir_box3d_stable_pair*)realloc(set->pairs, (size_t)new_capacity * sizeof(ymir_box3d_stable_pair));
|
||||
if (pairs == NULL)
|
||||
{
|
||||
return YMIR_BOX3D_OUT_OF_MEMORY;
|
||||
}
|
||||
set->pairs = pairs;
|
||||
set->capacity = new_capacity;
|
||||
}
|
||||
|
||||
set->pairs[set->count++] = (ymir_box3d_stable_pair){first, second};
|
||||
*added = 1;
|
||||
return YMIR_BOX3D_OK;
|
||||
}
|
||||
|
||||
static int ymir_session_contact_from_manifolds(
|
||||
const b3ContactData* contact,
|
||||
ymir_box3d_session_contact_output* result)
|
||||
{
|
||||
const b3Manifold* selected_manifold = NULL;
|
||||
const b3ManifoldPoint* selected_point = NULL;
|
||||
for (int manifold_index = 0; manifold_index < contact->manifoldCount; ++manifold_index)
|
||||
{
|
||||
const b3Manifold* manifold = contact->manifolds + manifold_index;
|
||||
for (int point_index = 0; point_index < manifold->pointCount; ++point_index)
|
||||
{
|
||||
const b3ManifoldPoint* point = manifold->points + point_index;
|
||||
if (selected_point == NULL || point->separation < selected_point->separation)
|
||||
{
|
||||
selected_manifold = manifold;
|
||||
selected_point = point;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (selected_point == NULL)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
uint64_t stable_id_a = ymir_session_shape_stable_id(contact->shapeIdA);
|
||||
uint64_t stable_id_b = ymir_session_shape_stable_id(contact->shapeIdB);
|
||||
if (stable_id_a == 0 || stable_id_b == 0 || stable_id_a == stable_id_b)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
b3BodyId body_a = b3Shape_GetBody(contact->shapeIdA);
|
||||
b3BodyId body_b = b3Shape_GetBody(contact->shapeIdB);
|
||||
b3Pos center_a = b3Body_GetWorldCenterOfMass(body_a);
|
||||
b3Pos center_b = b3Body_GetWorldCenterOfMass(body_b);
|
||||
b3Pos point_a = {
|
||||
center_a.x + selected_point->anchorA.x,
|
||||
center_a.y + selected_point->anchorA.y,
|
||||
center_a.z + selected_point->anchorA.z,
|
||||
};
|
||||
b3Pos point_b = {
|
||||
center_b.x + selected_point->anchorB.x,
|
||||
center_b.y + selected_point->anchorB.y,
|
||||
center_b.z + selected_point->anchorB.z,
|
||||
};
|
||||
*result = (ymir_box3d_session_contact_output){
|
||||
.stable_id_a = stable_id_a,
|
||||
.stable_id_b = stable_id_b,
|
||||
.point_x = 0.5f * (point_a.x + point_b.x),
|
||||
.point_y = 0.5f * (point_a.z + point_b.z),
|
||||
.normal_x = selected_manifold->normal.x,
|
||||
.normal_y = selected_manifold->normal.z,
|
||||
.penetration = selected_point->separation < 0.0f ? -selected_point->separation : 0.0f,
|
||||
.relative_speed = selected_point->normalVelocity,
|
||||
};
|
||||
return 1;
|
||||
}
|
||||
|
||||
static ymir_box3d_status ymir_session_emit_contact(
|
||||
ymir_box3d_stable_pair_set* seen,
|
||||
const ymir_box3d_session_contact_output* contact,
|
||||
ymir_box3d_session_contact_output* output,
|
||||
int capacity,
|
||||
int* total)
|
||||
{
|
||||
int added = 0;
|
||||
ymir_box3d_status status =
|
||||
ymir_session_add_stable_pair(seen, contact->stable_id_a, contact->stable_id_b, &added);
|
||||
if (status != YMIR_BOX3D_OK || !added)
|
||||
{
|
||||
return status;
|
||||
}
|
||||
|
||||
if (output != NULL)
|
||||
{
|
||||
if (*total >= capacity)
|
||||
{
|
||||
return YMIR_BOX3D_BUFFER_TOO_SMALL;
|
||||
}
|
||||
output[*total] = *contact;
|
||||
}
|
||||
*total += 1;
|
||||
return YMIR_BOX3D_OK;
|
||||
}
|
||||
|
||||
static ymir_box3d_status ymir_session_visit_contacts(
|
||||
const ymir_box3d_session* session,
|
||||
ymir_box3d_session_contact_output* output,
|
||||
int capacity,
|
||||
int* count)
|
||||
{
|
||||
int total = 0;
|
||||
ymir_box3d_stable_pair_set seen = {0};
|
||||
for (int body_index = 0; body_index < session->body_count; ++body_index)
|
||||
{
|
||||
b3ShapeId source_shape = session->bodies[body_index].shape_id;
|
||||
int contact_capacity = b3Shape_GetContactCapacity(source_shape);
|
||||
if (contact_capacity <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
b3ContactData* contacts = (b3ContactData*)malloc((size_t)contact_capacity * sizeof(b3ContactData));
|
||||
if (contacts == NULL)
|
||||
{
|
||||
free(seen.pairs);
|
||||
return YMIR_BOX3D_OUT_OF_MEMORY;
|
||||
}
|
||||
|
||||
int contact_count = b3Shape_GetContactData(source_shape, contacts, contact_capacity);
|
||||
for (int contact_index = 0; contact_index < contact_count; ++contact_index)
|
||||
{
|
||||
const b3ContactData* contact = contacts + contact_index;
|
||||
uint64_t stable_id_a = ymir_session_shape_stable_id(contact->shapeIdA);
|
||||
uint64_t stable_id_b = ymir_session_shape_stable_id(contact->shapeIdB);
|
||||
uint64_t source_stable_id = session->bodies[body_index].stable_id;
|
||||
uint64_t first_stable_id = stable_id_a < stable_id_b ? stable_id_a : stable_id_b;
|
||||
if (stable_id_a == 0 || stable_id_b == 0 || source_stable_id != first_stable_id)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
ymir_box3d_session_contact_output candidate;
|
||||
if (ymir_session_contact_from_manifolds(contact, &candidate))
|
||||
{
|
||||
ymir_box3d_status status =
|
||||
ymir_session_emit_contact(&seen, &candidate, output, capacity, &total);
|
||||
if (status != YMIR_BOX3D_OK)
|
||||
{
|
||||
free(contacts);
|
||||
free(seen.pairs);
|
||||
return status;
|
||||
}
|
||||
}
|
||||
}
|
||||
free(contacts);
|
||||
}
|
||||
|
||||
b3ContactEvents events = b3World_GetContactEvents(session->world_id);
|
||||
for (int event_index = 0; event_index < events.hitCount; ++event_index)
|
||||
{
|
||||
const b3ContactHitEvent* event = events.hitEvents + event_index;
|
||||
if (!b3Shape_IsValid(event->shapeIdA) || !b3Shape_IsValid(event->shapeIdB))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
uint64_t stable_id_a = ymir_session_shape_stable_id(event->shapeIdA);
|
||||
uint64_t stable_id_b = ymir_session_shape_stable_id(event->shapeIdB);
|
||||
if (stable_id_a == 0 || stable_id_b == 0 || stable_id_a == stable_id_b)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
ymir_box3d_session_contact_output candidate = {
|
||||
.stable_id_a = stable_id_a,
|
||||
.stable_id_b = stable_id_b,
|
||||
.point_x = event->point.x,
|
||||
.point_y = event->point.z,
|
||||
.normal_x = event->normal.x,
|
||||
.normal_y = event->normal.z,
|
||||
.penetration = 0.0f,
|
||||
.relative_speed = -event->approachSpeed,
|
||||
};
|
||||
ymir_box3d_status status = ymir_session_emit_contact(&seen, &candidate, output, capacity, &total);
|
||||
if (status != YMIR_BOX3D_OK)
|
||||
{
|
||||
free(seen.pairs);
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
for (int event_index = 0; event_index < events.beginCount; ++event_index)
|
||||
{
|
||||
const b3ContactBeginTouchEvent* event = events.beginEvents + event_index;
|
||||
if (!b3Contact_IsValid(event->contactId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
b3ContactData contact = b3Contact_GetData(event->contactId);
|
||||
ymir_box3d_session_contact_output candidate;
|
||||
if (!ymir_session_contact_from_manifolds(&contact, &candidate))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
ymir_box3d_status status = ymir_session_emit_contact(&seen, &candidate, output, capacity, &total);
|
||||
if (status != YMIR_BOX3D_OK)
|
||||
{
|
||||
free(seen.pairs);
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
free(seen.pairs);
|
||||
*count = total;
|
||||
return YMIR_BOX3D_OK;
|
||||
}
|
||||
|
||||
static b3BodyId ymir_create_body(b3WorldId world_id, const ymir_box3d_body_input* input)
|
||||
{
|
||||
b3BodyDef body_def = b3DefaultBodyDef();
|
||||
|
|
@ -111,7 +668,7 @@ static ymir_box3d_body_output ymir_read_body(b3BodyId body_id)
|
|||
};
|
||||
}
|
||||
|
||||
YMIR_EXPORT int ymir_box3d_get_version(int* major, int* minor, int* revision)
|
||||
YMIR_EXPORT int32_t YMIR_CALL ymir_box3d_get_version(int32_t* major, int32_t* minor, int32_t* revision)
|
||||
{
|
||||
b3Version version = b3GetVersion();
|
||||
*major = version.major;
|
||||
|
|
@ -120,7 +677,7 @@ YMIR_EXPORT int ymir_box3d_get_version(int* major, int* minor, int* revision)
|
|||
return b3IsDoublePrecision() ? 1 : 0;
|
||||
}
|
||||
|
||||
YMIR_EXPORT ymir_box3d_overlap_result ymir_box3d_overlap_spheres(
|
||||
YMIR_EXPORT ymir_box3d_overlap_result YMIR_CALL ymir_box3d_overlap_spheres(
|
||||
float query_x,
|
||||
float query_y,
|
||||
float query_radius,
|
||||
|
|
@ -149,7 +706,7 @@ YMIR_EXPORT ymir_box3d_overlap_result ymir_box3d_overlap_spheres(
|
|||
};
|
||||
}
|
||||
|
||||
YMIR_EXPORT ymir_box3d_overlap_result ymir_box3d_overlap_capsule_sphere(
|
||||
YMIR_EXPORT ymir_box3d_overlap_result YMIR_CALL ymir_box3d_overlap_capsule_sphere(
|
||||
float start_x,
|
||||
float start_y,
|
||||
float end_x,
|
||||
|
|
@ -180,7 +737,7 @@ YMIR_EXPORT ymir_box3d_overlap_result ymir_box3d_overlap_capsule_sphere(
|
|||
};
|
||||
}
|
||||
|
||||
YMIR_EXPORT ymir_box3d_cast_result ymir_box3d_cast_sphere(
|
||||
YMIR_EXPORT ymir_box3d_cast_result YMIR_CALL ymir_box3d_cast_sphere(
|
||||
float origin_x,
|
||||
float origin_y,
|
||||
float translation_x,
|
||||
|
|
@ -211,7 +768,7 @@ YMIR_EXPORT ymir_box3d_cast_result ymir_box3d_cast_sphere(
|
|||
};
|
||||
}
|
||||
|
||||
YMIR_EXPORT void ymir_box3d_step_pair(
|
||||
YMIR_EXPORT void YMIR_CALL ymir_box3d_step_pair(
|
||||
const ymir_box3d_body_input* body_a_input,
|
||||
const ymir_box3d_body_input* body_b_input,
|
||||
float time_step,
|
||||
|
|
@ -239,7 +796,7 @@ YMIR_EXPORT void ymir_box3d_step_pair(
|
|||
b3DestroyWorld(world_id);
|
||||
}
|
||||
|
||||
YMIR_EXPORT ymir_box3d_torque_result ymir_box3d_torque_lifetime(float torque, float time_step, int sub_step_count)
|
||||
YMIR_EXPORT ymir_box3d_torque_result YMIR_CALL ymir_box3d_torque_lifetime(float torque, float time_step, int32_t sub_step_count)
|
||||
{
|
||||
b3WorldDef world_def = b3DefaultWorldDef();
|
||||
world_def.gravity = b3Vec3_zero;
|
||||
|
|
@ -262,3 +819,301 @@ YMIR_EXPORT ymir_box3d_torque_result ymir_box3d_torque_lifetime(float torque, fl
|
|||
|
||||
return (ymir_box3d_torque_result){after_applied_step, after_unforced_step};
|
||||
}
|
||||
|
||||
YMIR_EXPORT uint32_t YMIR_CALL ymir_box3d_get_abi_version(void)
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
|
||||
YMIR_EXPORT int32_t YMIR_CALL ymir_box3d_get_abi_layout(
|
||||
uint32_t* body_input_size,
|
||||
uint32_t* field_input_size,
|
||||
uint32_t* body_output_size,
|
||||
uint32_t* contact_output_size)
|
||||
{
|
||||
if (body_input_size == NULL || field_input_size == NULL || body_output_size == NULL || contact_output_size == NULL)
|
||||
{
|
||||
return YMIR_BOX3D_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
*body_input_size = (uint32_t)sizeof(ymir_box3d_session_body_input);
|
||||
*field_input_size = (uint32_t)sizeof(ymir_box3d_radial_field_input);
|
||||
*body_output_size = (uint32_t)sizeof(ymir_box3d_session_body_output);
|
||||
*contact_output_size = (uint32_t)sizeof(ymir_box3d_session_contact_output);
|
||||
return YMIR_BOX3D_OK;
|
||||
}
|
||||
|
||||
YMIR_EXPORT int32_t YMIR_CALL ymir_box3d_session_create(ymir_box3d_session** output)
|
||||
{
|
||||
if (output == NULL)
|
||||
{
|
||||
return YMIR_BOX3D_INVALID_ARGUMENT;
|
||||
}
|
||||
*output = NULL;
|
||||
|
||||
ymir_box3d_session* session = (ymir_box3d_session*)calloc(1, sizeof(ymir_box3d_session));
|
||||
if (session == NULL)
|
||||
{
|
||||
return YMIR_BOX3D_OUT_OF_MEMORY;
|
||||
}
|
||||
|
||||
b3WorldDef world_def = b3DefaultWorldDef();
|
||||
world_def.gravity = b3Vec3_zero;
|
||||
world_def.enableSleep = false;
|
||||
world_def.workerCount = 1;
|
||||
session->world_id = b3CreateWorld(&world_def);
|
||||
if (B3_IS_NULL(session->world_id))
|
||||
{
|
||||
free(session);
|
||||
return YMIR_BOX3D_INTERNAL_ERROR;
|
||||
}
|
||||
|
||||
*output = session;
|
||||
return YMIR_BOX3D_OK;
|
||||
}
|
||||
|
||||
YMIR_EXPORT void YMIR_CALL ymir_box3d_session_destroy(ymir_box3d_session* session)
|
||||
{
|
||||
if (session == NULL)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (B3_IS_NON_NULL(session->world_id) && b3World_IsValid(session->world_id))
|
||||
{
|
||||
b3DestroyWorld(session->world_id);
|
||||
}
|
||||
free(session->bodies);
|
||||
free(session);
|
||||
}
|
||||
|
||||
YMIR_EXPORT int32_t YMIR_CALL ymir_box3d_session_sync_bodies(
|
||||
ymir_box3d_session* session,
|
||||
const ymir_box3d_session_body_input* bodies,
|
||||
int32_t body_count)
|
||||
{
|
||||
if (session == NULL || body_count < 0 || (body_count > 0 && bodies == NULL))
|
||||
{
|
||||
return YMIR_BOX3D_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
for (int i = 0; i < body_count; ++i)
|
||||
{
|
||||
if (!ymir_session_body_input_is_valid(bodies + i))
|
||||
{
|
||||
return YMIR_BOX3D_INVALID_ARGUMENT;
|
||||
}
|
||||
for (int j = 0; j < i; ++j)
|
||||
{
|
||||
if (bodies[i].stable_id == bodies[j].stable_id)
|
||||
{
|
||||
return YMIR_BOX3D_DUPLICATE_ID;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ymir_box3d_status status = ymir_session_reserve_bodies(session, session->body_count + body_count);
|
||||
if (status != YMIR_BOX3D_OK)
|
||||
{
|
||||
return status;
|
||||
}
|
||||
|
||||
for (int i = 0; i < session->body_count; ++i)
|
||||
{
|
||||
session->bodies[i].seen = 0;
|
||||
}
|
||||
|
||||
for (int i = 0; i < body_count; ++i)
|
||||
{
|
||||
int body_index = ymir_session_find_body(session, bodies[i].stable_id);
|
||||
if (body_index >= 0)
|
||||
{
|
||||
ymir_session_update_body(session->bodies + body_index, bodies + i);
|
||||
continue;
|
||||
}
|
||||
|
||||
ymir_box3d_session_body body;
|
||||
status = ymir_session_create_body(session, bodies + i, &body);
|
||||
if (status != YMIR_BOX3D_OK)
|
||||
{
|
||||
return status;
|
||||
}
|
||||
session->bodies[session->body_count++] = body;
|
||||
}
|
||||
|
||||
for (int i = session->body_count - 1; i >= 0; --i)
|
||||
{
|
||||
if (session->bodies[i].seen)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
b3DestroyBody(session->bodies[i].body_id);
|
||||
session->bodies[i] = session->bodies[session->body_count - 1];
|
||||
session->body_count -= 1;
|
||||
}
|
||||
|
||||
return YMIR_BOX3D_OK;
|
||||
}
|
||||
|
||||
YMIR_EXPORT int32_t YMIR_CALL ymir_box3d_session_step(
|
||||
ymir_box3d_session* session,
|
||||
float time_step,
|
||||
int32_t sub_step_count,
|
||||
const ymir_box3d_radial_field_input* fields,
|
||||
int32_t field_count)
|
||||
{
|
||||
if (session == NULL || !ymir_is_finite(time_step) || time_step <= 0.0f || sub_step_count <= 0 ||
|
||||
field_count < 0 || (field_count > 0 && fields == NULL))
|
||||
{
|
||||
return YMIR_BOX3D_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
for (int field_index = 0; field_index < field_count; ++field_index)
|
||||
{
|
||||
const ymir_box3d_radial_field_input* field = fields + field_index;
|
||||
if (!ymir_is_finite(field->position_x) || !ymir_is_finite(field->position_y) ||
|
||||
!ymir_is_finite(field->strength) || !ymir_is_finite(field->radius))
|
||||
{
|
||||
return YMIR_BOX3D_INVALID_ARGUMENT;
|
||||
}
|
||||
}
|
||||
|
||||
for (int body_index = 0; body_index < session->body_count; ++body_index)
|
||||
{
|
||||
ymir_box3d_session_body* body = session->bodies + body_index;
|
||||
if (body->is_static)
|
||||
{
|
||||
body->pending_torque = 0.0f;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (body->pending_torque != 0.0f)
|
||||
{
|
||||
b3Body_ApplyTorque(body->body_id, (b3Vec3){0.0f, body->pending_torque, 0.0f}, true);
|
||||
}
|
||||
body->pending_torque = 0.0f;
|
||||
|
||||
b3Pos position = b3Body_GetPosition(body->body_id);
|
||||
float acceleration_x = 0.0f;
|
||||
float acceleration_y = 0.0f;
|
||||
for (int field_index = 0; field_index < field_count; ++field_index)
|
||||
{
|
||||
const ymir_box3d_radial_field_input* field = fields + field_index;
|
||||
if (field->radius <= 0.0f || field->strength == 0.0f)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
float delta_x = field->position_x - position.x;
|
||||
float delta_y = field->position_y - position.z;
|
||||
float distance_squared = delta_x * delta_x + delta_y * delta_y;
|
||||
if (distance_squared <= 0.0f)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
float distance = sqrtf(distance_squared);
|
||||
if (distance > field->radius)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
float falloff = 1.0f - distance / field->radius;
|
||||
float magnitude = field->strength * falloff / distance;
|
||||
acceleration_x += delta_x * magnitude;
|
||||
acceleration_y += delta_y * magnitude;
|
||||
}
|
||||
|
||||
if (acceleration_x != 0.0f || acceleration_y != 0.0f)
|
||||
{
|
||||
float mass = b3Body_GetMass(body->body_id);
|
||||
b3Body_ApplyForceToCenter(body->body_id, ymir_vec(mass * acceleration_x, mass * acceleration_y), true);
|
||||
}
|
||||
}
|
||||
|
||||
b3World_Step(session->world_id, time_step, sub_step_count);
|
||||
for (int i = 0; i < session->body_count; ++i)
|
||||
{
|
||||
b3Pos position = b3Body_GetPosition(session->bodies[i].body_id);
|
||||
b3Vec3 velocity = b3Body_GetLinearVelocity(session->bodies[i].body_id);
|
||||
b3Vec3 direction = ymir_session_direction(session->bodies[i].body_id);
|
||||
b3Vec3 angular_velocity = b3Body_GetAngularVelocity(session->bodies[i].body_id);
|
||||
session->bodies[i].last_position_x = position.x;
|
||||
session->bodies[i].last_position_y = position.z;
|
||||
session->bodies[i].last_velocity_x = velocity.x;
|
||||
session->bodies[i].last_velocity_y = velocity.z;
|
||||
session->bodies[i].last_direction_x = direction.x;
|
||||
session->bodies[i].last_direction_y = direction.z;
|
||||
session->bodies[i].last_angular_velocity = angular_velocity.y;
|
||||
}
|
||||
|
||||
return YMIR_BOX3D_OK;
|
||||
}
|
||||
|
||||
YMIR_EXPORT int32_t YMIR_CALL ymir_box3d_session_get_body_count(const ymir_box3d_session* session)
|
||||
{
|
||||
return session != NULL ? session->body_count : -YMIR_BOX3D_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
YMIR_EXPORT int32_t YMIR_CALL ymir_box3d_session_copy_bodies(
|
||||
const ymir_box3d_session* session,
|
||||
ymir_box3d_session_body_output* output,
|
||||
int32_t capacity,
|
||||
int32_t* written)
|
||||
{
|
||||
if (session == NULL || written == NULL || capacity < 0 || (capacity > 0 && output == NULL))
|
||||
{
|
||||
return YMIR_BOX3D_INVALID_ARGUMENT;
|
||||
}
|
||||
*written = session->body_count;
|
||||
if (capacity < session->body_count)
|
||||
{
|
||||
return YMIR_BOX3D_BUFFER_TOO_SMALL;
|
||||
}
|
||||
|
||||
for (int i = 0; i < session->body_count; ++i)
|
||||
{
|
||||
b3Pos position = b3Body_GetPosition(session->bodies[i].body_id);
|
||||
b3Vec3 velocity = b3Body_GetLinearVelocity(session->bodies[i].body_id);
|
||||
b3Vec3 direction = ymir_session_direction(session->bodies[i].body_id);
|
||||
b3Vec3 angular_velocity = b3Body_GetAngularVelocity(session->bodies[i].body_id);
|
||||
output[i] = (ymir_box3d_session_body_output){
|
||||
.stable_id = session->bodies[i].stable_id,
|
||||
.position_x = position.x,
|
||||
.position_y = position.z,
|
||||
.velocity_x = velocity.x,
|
||||
.velocity_y = velocity.z,
|
||||
.direction_x = direction.x,
|
||||
.direction_y = direction.z,
|
||||
.angular_velocity = angular_velocity.y,
|
||||
};
|
||||
}
|
||||
return YMIR_BOX3D_OK;
|
||||
}
|
||||
|
||||
YMIR_EXPORT int32_t YMIR_CALL ymir_box3d_session_get_contact_count(const ymir_box3d_session* session)
|
||||
{
|
||||
if (session == NULL)
|
||||
{
|
||||
return -YMIR_BOX3D_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
int count = 0;
|
||||
ymir_box3d_status status = ymir_session_visit_contacts(session, NULL, 0, &count);
|
||||
return status == YMIR_BOX3D_OK ? count : -(int)status;
|
||||
}
|
||||
|
||||
YMIR_EXPORT int32_t YMIR_CALL ymir_box3d_session_copy_contacts(
|
||||
const ymir_box3d_session* session,
|
||||
ymir_box3d_session_contact_output* output,
|
||||
int32_t capacity,
|
||||
int32_t* written)
|
||||
{
|
||||
if (session == NULL || written == NULL || capacity < 0 || (capacity > 0 && output == NULL))
|
||||
{
|
||||
return YMIR_BOX3D_INVALID_ARGUMENT;
|
||||
}
|
||||
*written = 0;
|
||||
return ymir_session_visit_contacts(session, output, capacity, written);
|
||||
}
|
||||
|
|
|
|||
183
src/Ymir.Box3D/Box3DNative.cs
Normal file
183
src/Ymir.Box3D/Box3DNative.cs
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.CompilerServices;
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
|
||||
namespace Ymir.Box3D;
|
||||
|
||||
internal enum Box3DStatus
|
||||
{
|
||||
Ok = 0,
|
||||
InvalidArgument = 1,
|
||||
DuplicateId = 2,
|
||||
BufferTooSmall = 3,
|
||||
OutOfMemory = 4,
|
||||
InternalError = 5
|
||||
}
|
||||
|
||||
internal static partial class Box3DNative
|
||||
{
|
||||
internal const string LibraryName = "ymir_box3d";
|
||||
|
||||
[LibraryImport(LibraryName)]
|
||||
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||
internal static partial uint ymir_box3d_get_abi_version();
|
||||
|
||||
[LibraryImport(LibraryName)]
|
||||
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||
internal static partial Box3DStatus ymir_box3d_get_abi_layout(
|
||||
out uint bodyInputSize,
|
||||
out uint fieldInputSize,
|
||||
out uint bodyOutputSize,
|
||||
out uint contactOutputSize);
|
||||
|
||||
[LibraryImport(LibraryName)]
|
||||
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||
internal static partial int ymir_box3d_get_version(out int major, out int minor, out int revision);
|
||||
|
||||
[LibraryImport(LibraryName)]
|
||||
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||
internal static partial Box3DCastOutput ymir_box3d_cast_sphere(
|
||||
float originX,
|
||||
float originZ,
|
||||
float translationX,
|
||||
float translationZ,
|
||||
float queryRadius,
|
||||
float bodyX,
|
||||
float bodyZ,
|
||||
float bodyRadius,
|
||||
int canEncroach);
|
||||
|
||||
[LibraryImport(LibraryName)]
|
||||
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||
internal static partial Box3DOverlapOutput ymir_box3d_overlap_spheres(
|
||||
float queryX,
|
||||
float queryZ,
|
||||
float queryRadius,
|
||||
float bodyX,
|
||||
float bodyZ,
|
||||
float bodyRadius);
|
||||
|
||||
[LibraryImport(LibraryName)]
|
||||
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||
internal static partial Box3DStatus ymir_box3d_session_create(out nint session);
|
||||
|
||||
[LibraryImport(LibraryName)]
|
||||
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||
internal static partial void ymir_box3d_session_destroy(nint session);
|
||||
|
||||
[LibraryImport(LibraryName)]
|
||||
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||
internal static unsafe partial Box3DStatus ymir_box3d_session_sync_bodies(
|
||||
Box3DSessionHandle session,
|
||||
Box3DBodyInput* bodies,
|
||||
int bodyCount);
|
||||
|
||||
[LibraryImport(LibraryName)]
|
||||
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||
internal static unsafe partial Box3DStatus ymir_box3d_session_step(
|
||||
Box3DSessionHandle session,
|
||||
float deltaTime,
|
||||
int substepCount,
|
||||
Box3DRadialField* fields,
|
||||
int fieldCount);
|
||||
|
||||
[LibraryImport(LibraryName)]
|
||||
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||
internal static partial int ymir_box3d_session_get_body_count(Box3DSessionHandle session);
|
||||
|
||||
[LibraryImport(LibraryName)]
|
||||
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||
internal static unsafe partial Box3DStatus ymir_box3d_session_copy_bodies(
|
||||
Box3DSessionHandle session,
|
||||
Box3DBodyOutput* output,
|
||||
int capacity,
|
||||
out int written);
|
||||
|
||||
[LibraryImport(LibraryName)]
|
||||
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||
internal static partial int ymir_box3d_session_get_contact_count(Box3DSessionHandle session);
|
||||
|
||||
[LibraryImport(LibraryName)]
|
||||
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||
internal static unsafe partial Box3DStatus ymir_box3d_session_copy_contacts(
|
||||
Box3DSessionHandle session,
|
||||
Box3DContactOutput* output,
|
||||
int capacity,
|
||||
out int written);
|
||||
}
|
||||
|
||||
internal sealed class Box3DSessionHandle : SafeHandleZeroOrMinusOneIsInvalid
|
||||
{
|
||||
private Box3DSessionHandle() : base(true)
|
||||
{
|
||||
}
|
||||
|
||||
internal Box3DSessionHandle(nint value) : base(true) => SetHandle(value);
|
||||
|
||||
protected override bool ReleaseHandle()
|
||||
{
|
||||
Box3DNative.ymir_box3d_session_destroy(handle);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal readonly record struct Box3DBodyInput(
|
||||
ulong StableId,
|
||||
float PositionX,
|
||||
float PositionZ,
|
||||
float VelocityX,
|
||||
float VelocityZ,
|
||||
float DirectionX,
|
||||
float DirectionZ,
|
||||
float AngularVelocity,
|
||||
float Torque,
|
||||
float Radius,
|
||||
float Mass,
|
||||
float Restitution,
|
||||
uint IsStatic);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal readonly record struct Box3DRadialField(
|
||||
float PositionX,
|
||||
float PositionZ,
|
||||
float Strength,
|
||||
float Radius);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal readonly record struct Box3DBodyOutput(
|
||||
ulong StableId,
|
||||
float PositionX,
|
||||
float PositionZ,
|
||||
float VelocityX,
|
||||
float VelocityZ,
|
||||
float DirectionX,
|
||||
float DirectionZ,
|
||||
float AngularVelocity);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal readonly record struct Box3DContactOutput(
|
||||
ulong StableIdA,
|
||||
ulong StableIdB,
|
||||
float PointX,
|
||||
float PointZ,
|
||||
float NormalX,
|
||||
float NormalZ,
|
||||
float Penetration,
|
||||
float RelativeSpeed);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal readonly record struct Box3DCastOutput(
|
||||
int Hit,
|
||||
float Fraction,
|
||||
float PointX,
|
||||
float PointZ,
|
||||
float NormalX,
|
||||
float NormalZ);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal readonly record struct Box3DOverlapOutput(
|
||||
int Hit,
|
||||
float Distance,
|
||||
float NormalX,
|
||||
float NormalZ);
|
||||
65
src/Ymir.Box3D/Box3DQueries.cs
Normal file
65
src/Ymir.Box3D/Box3DQueries.cs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
namespace Ymir.Box3D;
|
||||
|
||||
internal readonly record struct Box3DSphereCastHit(
|
||||
float Fraction,
|
||||
float PointX,
|
||||
float PointZ,
|
||||
float NormalX,
|
||||
float NormalZ);
|
||||
|
||||
internal readonly record struct Box3DSphereOverlapHit(float Distance, float NormalX, float NormalZ);
|
||||
|
||||
internal static class Box3DQueries
|
||||
{
|
||||
internal static Box3DSphereOverlapHit? OverlapSpheres(
|
||||
float queryX,
|
||||
float queryZ,
|
||||
float queryRadius,
|
||||
float bodyX,
|
||||
float bodyZ,
|
||||
float bodyRadius)
|
||||
{
|
||||
Box3DSession.EnsureCompatibleNativeLibrary();
|
||||
var output = Box3DNative.ymir_box3d_overlap_spheres(
|
||||
queryX,
|
||||
queryZ,
|
||||
queryRadius,
|
||||
bodyX,
|
||||
bodyZ,
|
||||
bodyRadius);
|
||||
return output.Hit == 0
|
||||
? null
|
||||
: new Box3DSphereOverlapHit(output.Distance, output.NormalX, output.NormalZ);
|
||||
}
|
||||
|
||||
internal static Box3DSphereCastHit? CastSphere(
|
||||
float originX,
|
||||
float originZ,
|
||||
float translationX,
|
||||
float translationZ,
|
||||
float queryRadius,
|
||||
float bodyX,
|
||||
float bodyZ,
|
||||
float bodyRadius)
|
||||
{
|
||||
Box3DSession.EnsureCompatibleNativeLibrary();
|
||||
var output = Box3DNative.ymir_box3d_cast_sphere(
|
||||
originX,
|
||||
originZ,
|
||||
translationX,
|
||||
translationZ,
|
||||
queryRadius,
|
||||
bodyX,
|
||||
bodyZ,
|
||||
bodyRadius,
|
||||
canEncroach: 0);
|
||||
return output.Hit == 0
|
||||
? null
|
||||
: new Box3DSphereCastHit(
|
||||
output.Fraction,
|
||||
output.PointX,
|
||||
output.PointZ,
|
||||
output.NormalX,
|
||||
output.NormalZ);
|
||||
}
|
||||
}
|
||||
240
src/Ymir.Box3D/Box3DSession.cs
Normal file
240
src/Ymir.Box3D/Box3DSession.cs
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Ymir.Box3D;
|
||||
|
||||
internal readonly record struct Box3DBody(
|
||||
ulong StableId,
|
||||
float PositionX,
|
||||
float PositionZ,
|
||||
float VelocityX,
|
||||
float VelocityZ,
|
||||
float DirectionX,
|
||||
float DirectionZ,
|
||||
float AngularVelocity,
|
||||
float Torque,
|
||||
float Radius,
|
||||
float Mass,
|
||||
float Restitution,
|
||||
bool IsStatic);
|
||||
|
||||
internal readonly record struct Box3DField(float PositionX, float PositionZ, float Strength, float Radius);
|
||||
|
||||
internal readonly record struct Box3DBodyState(
|
||||
ulong StableId,
|
||||
float PositionX,
|
||||
float PositionZ,
|
||||
float VelocityX,
|
||||
float VelocityZ,
|
||||
float DirectionX,
|
||||
float DirectionZ,
|
||||
float AngularVelocity);
|
||||
|
||||
internal readonly record struct Box3DContact(
|
||||
ulong StableIdA,
|
||||
ulong StableIdB,
|
||||
float PointX,
|
||||
float PointZ,
|
||||
float NormalX,
|
||||
float NormalZ,
|
||||
float Penetration,
|
||||
float RelativeSpeed);
|
||||
|
||||
internal sealed class Box3DSession : IDisposable
|
||||
{
|
||||
internal const uint AbiVersion = 2;
|
||||
internal const int DefaultSubstepCount = 4;
|
||||
|
||||
private readonly Box3DSessionHandle _handle;
|
||||
private bool _disposed;
|
||||
|
||||
internal Box3DSession()
|
||||
{
|
||||
EnsureCompatibleNativeLibrary();
|
||||
ThrowIfFailed(Box3DNative.ymir_box3d_session_create(out var value), "create session");
|
||||
if (value == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Box3D returned a null Ymir session.");
|
||||
}
|
||||
|
||||
_handle = new Box3DSessionHandle(value);
|
||||
}
|
||||
|
||||
internal unsafe void SyncBodies(ReadOnlySpan<Box3DBody> bodies)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
var native = new Box3DBodyInput[bodies.Length];
|
||||
for (var i = 0; i < bodies.Length; i++)
|
||||
{
|
||||
var body = bodies[i];
|
||||
native[i] = new Box3DBodyInput(
|
||||
body.StableId,
|
||||
body.PositionX,
|
||||
body.PositionZ,
|
||||
body.VelocityX,
|
||||
body.VelocityZ,
|
||||
body.DirectionX,
|
||||
body.DirectionZ,
|
||||
body.AngularVelocity,
|
||||
body.Torque,
|
||||
body.Radius,
|
||||
body.Mass,
|
||||
body.Restitution,
|
||||
body.IsStatic ? 1u : 0u);
|
||||
}
|
||||
|
||||
fixed (Box3DBodyInput* pointer = native)
|
||||
{
|
||||
var status = Box3DNative.ymir_box3d_session_sync_bodies(_handle, pointer, native.Length);
|
||||
if (status is Box3DStatus.OutOfMemory or Box3DStatus.InternalError)
|
||||
{
|
||||
Dispose();
|
||||
}
|
||||
ThrowIfFailed(status, "synchronize bodies");
|
||||
}
|
||||
}
|
||||
|
||||
internal unsafe void Step(float deltaTime, ReadOnlySpan<Box3DField> fields, int substepCount = DefaultSubstepCount)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
if (!float.IsFinite(deltaTime) || deltaTime <= 0.0f)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(deltaTime), "Delta time must be finite and positive.");
|
||||
}
|
||||
|
||||
if (substepCount <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(substepCount), "Substep count must be positive.");
|
||||
}
|
||||
|
||||
var native = new Box3DRadialField[fields.Length];
|
||||
for (var i = 0; i < fields.Length; i++)
|
||||
{
|
||||
native[i] = new Box3DRadialField(fields[i].PositionX, fields[i].PositionZ, fields[i].Strength, fields[i].Radius);
|
||||
}
|
||||
|
||||
fixed (Box3DRadialField* pointer = native)
|
||||
{
|
||||
ThrowIfFailed(
|
||||
Box3DNative.ymir_box3d_session_step(_handle, deltaTime, substepCount, pointer, native.Length),
|
||||
"step session");
|
||||
}
|
||||
}
|
||||
|
||||
internal unsafe Box3DBodyState[] CopyBodies()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
var count = Box3DNative.ymir_box3d_session_get_body_count(_handle);
|
||||
if (count < 0)
|
||||
{
|
||||
throw new InvalidOperationException("Box3D returned an invalid body count.");
|
||||
}
|
||||
|
||||
var native = new Box3DBodyOutput[count];
|
||||
fixed (Box3DBodyOutput* pointer = native)
|
||||
{
|
||||
ThrowIfFailed(Box3DNative.ymir_box3d_session_copy_bodies(_handle, pointer, count, out var written), "copy bodies");
|
||||
if (written != count)
|
||||
{
|
||||
throw new InvalidOperationException($"Box3D body snapshot changed during copy: expected {count}, received {written}.");
|
||||
}
|
||||
}
|
||||
|
||||
return native.Select(body => new Box3DBodyState(
|
||||
body.StableId,
|
||||
body.PositionX,
|
||||
body.PositionZ,
|
||||
body.VelocityX,
|
||||
body.VelocityZ,
|
||||
body.DirectionX,
|
||||
body.DirectionZ,
|
||||
body.AngularVelocity)).ToArray();
|
||||
}
|
||||
|
||||
internal unsafe Box3DContact[] CopyContacts()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
var count = Box3DNative.ymir_box3d_session_get_contact_count(_handle);
|
||||
if (count < 0)
|
||||
{
|
||||
throw new InvalidOperationException("Box3D returned an invalid contact count.");
|
||||
}
|
||||
|
||||
var native = new Box3DContactOutput[count];
|
||||
fixed (Box3DContactOutput* pointer = native)
|
||||
{
|
||||
ThrowIfFailed(Box3DNative.ymir_box3d_session_copy_contacts(_handle, pointer, count, out var written), "copy contacts");
|
||||
if (written != count)
|
||||
{
|
||||
throw new InvalidOperationException($"Box3D contact snapshot changed during copy: expected {count}, received {written}.");
|
||||
}
|
||||
}
|
||||
|
||||
return native.Select(contact => new Box3DContact(
|
||||
contact.StableIdA,
|
||||
contact.StableIdB,
|
||||
contact.PointX,
|
||||
contact.PointZ,
|
||||
contact.NormalX,
|
||||
contact.NormalZ,
|
||||
contact.Penetration,
|
||||
contact.RelativeSpeed)).ToArray();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_handle.Dispose();
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
internal static void EnsureCompatibleNativeLibrary()
|
||||
{
|
||||
var abi = Box3DNative.ymir_box3d_get_abi_version();
|
||||
if (abi != AbiVersion)
|
||||
{
|
||||
throw new InvalidOperationException($"Ymir Box3D ABI mismatch. Managed expects {AbiVersion}, native provides {abi}.");
|
||||
}
|
||||
|
||||
ThrowIfFailed(
|
||||
Box3DNative.ymir_box3d_get_abi_layout(
|
||||
out var bodyInputSize,
|
||||
out var fieldInputSize,
|
||||
out var bodyOutputSize,
|
||||
out var contactOutputSize),
|
||||
"read native ABI layout");
|
||||
EnsureSize<Box3DBodyInput>(bodyInputSize);
|
||||
EnsureSize<Box3DRadialField>(fieldInputSize);
|
||||
EnsureSize<Box3DBodyOutput>(bodyOutputSize);
|
||||
EnsureSize<Box3DContactOutput>(contactOutputSize);
|
||||
|
||||
Box3DNative.ymir_box3d_get_version(out var major, out var minor, out var revision);
|
||||
if (major != 0 || minor != 1 || revision != 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Ymir requires Box3D 0.1.0, but loaded {major}.{minor}.{revision}.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureSize<T>(uint nativeSize) where T : struct
|
||||
{
|
||||
var managedSize = checked((uint)Marshal.SizeOf<T>());
|
||||
if (managedSize != nativeSize)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Ymir Box3D ABI layout mismatch for {typeof(T).Name}. Managed expects {managedSize} bytes, native provides {nativeSize}.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ThrowIfFailed(Box3DStatus status, string operation)
|
||||
{
|
||||
if (status != Box3DStatus.Ok)
|
||||
{
|
||||
throw new InvalidOperationException($"Ymir could not {operation}: native Box3D status {status}.");
|
||||
}
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
}
|
||||
3
src/Ymir.Box3D/Properties/AssemblyInfo.cs
Normal file
3
src/Ymir.Box3D/Properties/AssemblyInfo.cs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("Ymir.Core")]
|
||||
13
src/Ymir.Box3D/README.md
Normal file
13
src/Ymir.Box3D/README.md
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# GameCult.Ymir.Box3D
|
||||
|
||||
This is Ymir's private managed/native boundary for pinned Box3D v0.1.0. It is
|
||||
installed transitively by Ymir; products and clients should reference Ymir's
|
||||
typed session contracts instead of this package.
|
||||
|
||||
The package includes the compiled native facade under `runtimes/<rid>/native`.
|
||||
Package consumers do not need CMake, a C compiler, or the Box3D submodule.
|
||||
Missing or ABI-incompatible native assets fail closed when Ymir opens a physics
|
||||
session.
|
||||
|
||||
Source builds of Ymir use CMake to compile the pinned submodule. Box3D is MIT
|
||||
licensed; its license is included in the package.
|
||||
58
src/Ymir.Box3D/Ymir.Box3D.csproj
Normal file
58
src/Ymir.Box3D/Ymir.Box3D.csproj
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
<RootNamespace>Ymir.Box3D</RootNamespace>
|
||||
<AssemblyName>Ymir.Box3D</AssemblyName>
|
||||
<Description>Native ABI boundary between Ymir sessions and pinned Box3D.</Description>
|
||||
<PackageId>GameCult.Ymir.Box3D</PackageId>
|
||||
<Version>0.1.0</Version>
|
||||
<Authors>GameCult</Authors>
|
||||
<IsPackable>true</IsPackable>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
<NativeSourceDir>$(MSBuildThisFileDirectory)..\..\native\Ymir.Box3D</NativeSourceDir>
|
||||
<NativeBuildDir>$(MSBuildThisFileDirectory)..\..\artifacts\box3d-native\production\$(Configuration)</NativeBuildDir>
|
||||
<CMakeExe Condition="'$(CMakeExe)' == '' and '$(OS)' == 'Windows_NT' and Exists('C:\Program Files\CMake\bin\cmake.exe')">C:\Program Files\CMake\bin\cmake.exe</CMakeExe>
|
||||
<CMakeExe Condition="'$(CMakeExe)' == ''">cmake</CMakeExe>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="$(NativeBuildDir)\bin\$(Configuration)\ymir_box3d.dll"
|
||||
Link="ymir_box3d.dll"
|
||||
CopyToOutputDirectory="PreserveNewest"
|
||||
CopyToPublishDirectory="PreserveNewest"
|
||||
Pack="true"
|
||||
PackagePath="runtimes/win-x64/native/ymir_box3d.dll"
|
||||
Condition="'$(OS)' == 'Windows_NT'" />
|
||||
<None Include="$(NativeBuildDir)\bin\libymir_box3d.so"
|
||||
Link="libymir_box3d.so"
|
||||
CopyToOutputDirectory="PreserveNewest"
|
||||
CopyToPublishDirectory="PreserveNewest"
|
||||
Pack="true"
|
||||
PackagePath="runtimes/linux-x64/native/libymir_box3d.so"
|
||||
Condition="'$(OS)' != 'Windows_NT' and !$([MSBuild]::IsOSPlatform('OSX'))" />
|
||||
<None Include="$(NativeBuildDir)\bin\libymir_box3d.dylib"
|
||||
Link="libymir_box3d.dylib"
|
||||
CopyToOutputDirectory="PreserveNewest"
|
||||
CopyToPublishDirectory="PreserveNewest"
|
||||
Pack="true"
|
||||
PackagePath="runtimes/osx-x64/native/libymir_box3d.dylib"
|
||||
Condition="$([MSBuild]::IsOSPlatform('OSX'))" />
|
||||
<None Include="..\..\extern\box3d\LICENSE"
|
||||
Link="box3d-MIT.txt"
|
||||
Pack="true"
|
||||
PackagePath="licenses" />
|
||||
<None Include="README.md" Pack="true" PackagePath="\" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="BuildYmirBox3DNative"
|
||||
BeforeTargets="PrepareForBuild;GetCopyToOutputDirectoryItems;GetCopyToPublishDirectoryItems"
|
||||
Condition="'$(DesignTimeBuild)' != 'true'">
|
||||
<Exec Command=""$(CMakeExe)" -S "$(NativeSourceDir)" -B "$(NativeBuildDir)" -DBUILD_SHARED_LIBS=OFF" />
|
||||
<Exec Command=""$(CMakeExe)" --build "$(NativeBuildDir)" --config $(Configuration) --target ymir_box3d" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
|
|
@ -18,7 +18,10 @@ public sealed record PhysicsBody(
|
|||
float Radius,
|
||||
float Mass,
|
||||
bool IsStatic = false,
|
||||
float Restitution = 0.2f);
|
||||
float Restitution = 0.2f,
|
||||
Vec2? Direction = null,
|
||||
float AngularVelocity = 0.0f,
|
||||
float Torque = 0.0f);
|
||||
|
||||
public sealed record RadialField(
|
||||
string Id,
|
||||
|
|
@ -46,3 +49,31 @@ public sealed record ContactEvent(
|
|||
public sealed record SimulationStepResult(
|
||||
YmirWorld World,
|
||||
IReadOnlyList<ContactEvent> Contacts);
|
||||
|
||||
public sealed record CircleCastQueryRequest(
|
||||
Vec2 Origin,
|
||||
Vec2 Direction,
|
||||
float Distance,
|
||||
float Radius,
|
||||
IReadOnlyList<PhysicsBody> Bodies);
|
||||
|
||||
public sealed record CircleCastHit(
|
||||
string BodyId,
|
||||
Vec2 Point,
|
||||
Vec2 Normal,
|
||||
float Distance);
|
||||
|
||||
public sealed record CircleCastQueryResult(IReadOnlyList<CircleCastHit> Hits);
|
||||
|
||||
public sealed record CircleOverlapQueryRequest(
|
||||
Vec2 Center,
|
||||
float Radius,
|
||||
IReadOnlyList<PhysicsBody> Bodies);
|
||||
|
||||
public sealed record CircleOverlapHit(
|
||||
string BodyId,
|
||||
Vec2 Point,
|
||||
Vec2 Normal,
|
||||
float Distance);
|
||||
|
||||
public sealed record CircleOverlapQueryResult(IReadOnlyList<CircleOverlapHit> Hits);
|
||||
|
|
|
|||
3
src/Ymir.Core/Properties/AssemblyInfo.cs
Normal file
3
src/Ymir.Core/Properties/AssemblyInfo.cs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("Ymir.Tests")]
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Ymir.Box3D\Ymir.Box3D.csproj" />
|
||||
<ProjectReference Include="$(CultLibRoot)\src\GameCult.Caching\GameCult.Caching.csproj" />
|
||||
<ProjectReference Include="$(CultLibRoot)\src\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj" />
|
||||
<ProjectReference Include="$(EveUnityRoot)\packages\org.gamecult.eve.surface\GameCult.Eve.Surface.csproj" />
|
||||
|
|
@ -18,7 +19,7 @@
|
|||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>Ymir.Core</RootNamespace>
|
||||
<AssemblyName>Ymir.Core</AssemblyName>
|
||||
<Description>GameCult physics/world substrate engine built on CultMath.</Description>
|
||||
<Description>GameCult physics session daemon and contracts backed by pinned Box3D.</Description>
|
||||
<Authors>GameCult</Authors>
|
||||
</PropertyGroup>
|
||||
|
||||
|
|
|
|||
116
src/Ymir.Core/YmirQueries.cs
Normal file
116
src/Ymir.Core/YmirQueries.cs
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
using Ymir.Box3D;
|
||||
|
||||
namespace Ymir.Core;
|
||||
|
||||
public static class YmirQueries
|
||||
{
|
||||
public static CircleOverlapQueryResult OverlapCircle(CircleOverlapQueryRequest request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
if (!float.IsFinite(request.Radius) || request.Radius <= 0.0f)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(request), "Overlap radius must be finite and positive.");
|
||||
}
|
||||
if (!float.IsFinite(request.Center.X) || !float.IsFinite(request.Center.Y))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(request), "Overlap center must be finite.");
|
||||
}
|
||||
|
||||
var hits = new List<CircleOverlapHit>();
|
||||
foreach (var body in request.Bodies)
|
||||
{
|
||||
ValidateQueryBody(body, request);
|
||||
var hit = Box3DQueries.OverlapSpheres(
|
||||
request.Center.X,
|
||||
request.Center.Y,
|
||||
request.Radius,
|
||||
body.Position.X,
|
||||
body.Position.Y,
|
||||
body.Radius);
|
||||
if (hit is not { } value)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
hits.Add(new CircleOverlapHit(
|
||||
body.Id,
|
||||
request.Center,
|
||||
new Vec2(value.NormalX, value.NormalZ),
|
||||
value.Distance));
|
||||
}
|
||||
|
||||
return new CircleOverlapQueryResult(hits
|
||||
.OrderBy(hit => hit.Distance)
|
||||
.ThenBy(hit => hit.BodyId, StringComparer.Ordinal)
|
||||
.ToArray());
|
||||
}
|
||||
|
||||
public static CircleCastQueryResult CastCircle(CircleCastQueryRequest request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
if (!float.IsFinite(request.Distance) || request.Distance < 0.0f)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(request), "Cast distance must be finite and non-negative.");
|
||||
}
|
||||
if (!float.IsFinite(request.Radius) || request.Radius <= 0.0f)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(request), "Cast radius must be finite and positive.");
|
||||
}
|
||||
if (!float.IsFinite(request.Origin.X) || !float.IsFinite(request.Origin.Y))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(request), "Cast origin must be finite.");
|
||||
}
|
||||
|
||||
var directionLengthSquared = request.Direction.X * request.Direction.X + request.Direction.Y * request.Direction.Y;
|
||||
if (!float.IsFinite(directionLengthSquared) || directionLengthSquared <= 1.0e-8f)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(request), "Cast direction must be finite and non-zero.");
|
||||
}
|
||||
|
||||
var inverseLength = 1.0f / MathF.Sqrt(directionLengthSquared);
|
||||
var translationX = request.Direction.X * inverseLength * request.Distance;
|
||||
var translationY = request.Direction.Y * inverseLength * request.Distance;
|
||||
var hits = new List<CircleCastHit>();
|
||||
foreach (var body in request.Bodies)
|
||||
{
|
||||
ValidateQueryBody(body, request);
|
||||
|
||||
var hit = Box3DQueries.CastSphere(
|
||||
request.Origin.X,
|
||||
request.Origin.Y,
|
||||
translationX,
|
||||
translationY,
|
||||
request.Radius,
|
||||
body.Position.X,
|
||||
body.Position.Y,
|
||||
body.Radius);
|
||||
if (hit is not { } value)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
hits.Add(new CircleCastHit(
|
||||
body.Id,
|
||||
new Vec2(value.PointX, value.PointZ),
|
||||
new Vec2(value.NormalX, value.NormalZ),
|
||||
value.Fraction * request.Distance));
|
||||
}
|
||||
|
||||
return new CircleCastQueryResult(hits
|
||||
.OrderBy(hit => hit.Distance)
|
||||
.ThenBy(hit => hit.BodyId, StringComparer.Ordinal)
|
||||
.ToArray());
|
||||
}
|
||||
|
||||
private static void ValidateQueryBody(PhysicsBody body, object request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(body.Id) ||
|
||||
!float.IsFinite(body.Position.X) ||
|
||||
!float.IsFinite(body.Position.Y) ||
|
||||
!float.IsFinite(body.Radius) ||
|
||||
body.Radius <= 0.0f)
|
||||
{
|
||||
throw new ArgumentException("Query bodies require stable ids and finite positive radii.", nameof(request));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -17,7 +17,7 @@ public sealed class YmirProviderAdvertisementDocument
|
|||
public string Title { get; set; } = "Ymir Physics";
|
||||
|
||||
[Key(2)]
|
||||
public string Description { get; set; } = "GameCult authoritative Box3D session wrapper and physics daemon.";
|
||||
public string Description { get; set; } = "GameCult Box3D snapshot wrapper and physics-daemon cutover surface.";
|
||||
|
||||
[Key(3)]
|
||||
public string[] Owns { get; set; } = [];
|
||||
|
|
@ -44,7 +44,7 @@ public sealed class YmirOperatorStateDocument
|
|||
public string Status { get; set; } = "mvp";
|
||||
|
||||
[Key(2)]
|
||||
public string StateOwner { get; set; } = "Ymir world sessions and typed GameCult projections";
|
||||
public string StateOwner { get; set; } = "Isolated Box3D snapshot steps and typed diagnostic projections";
|
||||
|
||||
[Key(3)]
|
||||
public string NumericSubstrate { get; set; } = "Box3D v0.1.0 (C17)";
|
||||
|
|
@ -53,7 +53,7 @@ public sealed class YmirOperatorStateDocument
|
|||
public string BatchKernel { get; set; } = "";
|
||||
|
||||
[Key(5)]
|
||||
public string Persistence { get; set; } = "CultCache gamecult.ymir.world_state.v0 legacy checkpoint during Box3D cutover";
|
||||
public string Persistence { get; set; } = "CultCache gamecult.ymir.world_state.v1; explicit v0 read migration";
|
||||
|
||||
[Key(6)]
|
||||
public string UpdatedAtUtc { get; set; } = "";
|
||||
|
|
@ -81,18 +81,21 @@ public static class YmirServicePublication
|
|||
{
|
||||
ProviderId = "ymir.physics",
|
||||
Title = "Ymir Physics",
|
||||
Description = "GameCult authoritative Box3D session wrapper and physics daemon.",
|
||||
Description = "GameCult Box3D snapshot wrapper and physics-daemon cutover surface.",
|
||||
Owns =
|
||||
[
|
||||
"authoritative physics session lifecycle",
|
||||
"stable GameCult id to Box3D handle projection",
|
||||
"typed physics commands and facts",
|
||||
"Box3D-only isolated snapshot stepping",
|
||||
"stable GameCult id to transient Box3D handle projection",
|
||||
"Box3D-backed circle overlap and cast queries over submitted bodies",
|
||||
"deterministic result ordering",
|
||||
"CultCache checkpoint reconstruction",
|
||||
"CultMesh and Eve service publication"
|
||||
"CultCache world snapshot v1 with explicit v0 read migration",
|
||||
"diagnostic Eve publication"
|
||||
],
|
||||
DoesNotOwn =
|
||||
[
|
||||
"public retained session lifecycle yet",
|
||||
"typed command receipts or begin/end/hit lifecycle facts yet",
|
||||
"complete checkpoint reconstruction yet",
|
||||
"physics algorithm invention",
|
||||
"Box3D solver and collision semantics",
|
||||
"Unity scene truth",
|
||||
|
|
@ -108,10 +111,10 @@ public static class YmirServicePublication
|
|||
{
|
||||
ProviderId = "ymir.physics",
|
||||
Status = "mvp",
|
||||
StateOwner = "Ymir world sessions and typed GameCult projections",
|
||||
StateOwner = "Isolated Box3D snapshot steps and typed diagnostic projections",
|
||||
NumericSubstrate = "Box3D v0.1.0 (C17)",
|
||||
BatchKernel = "Box3D native solver; Ymir does not own physics algorithms",
|
||||
Persistence = "CultCache gamecult.ymir.world_state.v0 legacy checkpoint during Box3D cutover",
|
||||
Persistence = "CultCache gamecult.ymir.world_state.v1; explicit v0 read migration",
|
||||
UpdatedAtUtc = FormatUtc(now)
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,144 +1,20 @@
|
|||
using CultMath;
|
||||
using static CultMath.math;
|
||||
|
||||
namespace Ymir.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Compatibility façade for the snapshot-in/snapshot-out Ymir contract.
|
||||
/// Calls are isolated by design; retained world ownership is not inferred from snapshots.
|
||||
/// </summary>
|
||||
public sealed class YmirSimulator
|
||||
{
|
||||
private const float MinimumMass = 0.0001f;
|
||||
private const float MinimumDistance = 0.0001f;
|
||||
|
||||
public SimulationStepResult Step(SimulationStepRequest request)
|
||||
{
|
||||
var result = Step(new SoASimulationStepRequest(
|
||||
request.DeltaTime,
|
||||
YmirSoAWorld.FromWorld(request.World)));
|
||||
|
||||
return new SimulationStepResult(result.World.ToWorld(), result.Contacts);
|
||||
using var session = new YmirWorldSession();
|
||||
return session.Step(request);
|
||||
}
|
||||
|
||||
public SoASimulationStepResult Step(SoASimulationStepRequest request)
|
||||
{
|
||||
if (request.DeltaTime <= 0.0f)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(request), "DeltaTime must be positive.");
|
||||
}
|
||||
|
||||
var world = request.World.Clone();
|
||||
world.Validate();
|
||||
|
||||
var accelerationX = new float[world.BodyCount];
|
||||
var accelerationY = new float[world.BodyCount];
|
||||
for (var i = 0; i < world.FieldCount; i++)
|
||||
{
|
||||
BatchMath.AddRadialFalloffAcceleration2D(
|
||||
world.PositionX,
|
||||
world.PositionY,
|
||||
world.FieldPositionX[i],
|
||||
world.FieldPositionY[i],
|
||||
world.FieldStrength[i],
|
||||
world.FieldRadius[i],
|
||||
accelerationX,
|
||||
accelerationY);
|
||||
}
|
||||
|
||||
BatchMath.IntegrateSemiImplicitEuler2D(
|
||||
request.DeltaTime,
|
||||
world.DynamicMask,
|
||||
world.PositionX,
|
||||
world.PositionY,
|
||||
world.VelocityX,
|
||||
world.VelocityY,
|
||||
accelerationX,
|
||||
accelerationY);
|
||||
|
||||
var contacts = new List<ContactEvent>();
|
||||
ResolveCircleContacts(world, contacts);
|
||||
|
||||
world.Time += request.DeltaTime;
|
||||
return new SoASimulationStepResult(
|
||||
world,
|
||||
contacts);
|
||||
}
|
||||
|
||||
private static void ResolveCircleContacts(YmirSoAWorld world, List<ContactEvent> contacts)
|
||||
{
|
||||
for (var i = 0; i < world.BodyCount; i++)
|
||||
{
|
||||
for (var j = i + 1; j < world.BodyCount; j++)
|
||||
{
|
||||
var delta = world.PositionAt(j) - world.PositionAt(i);
|
||||
var distance = length(delta);
|
||||
var combinedRadius = world.Radius[i] + world.Radius[j];
|
||||
var penetration = combinedRadius - distance;
|
||||
|
||||
if (penetration <= 0.0f)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var normal = distance <= MinimumDistance ? float2(1.0f, 0.0f) : delta / distance;
|
||||
var point = world.PositionAt(i) + normal * (world.Radius[i] - penetration * 0.5f);
|
||||
var relativeVelocity = world.VelocityAt(j) - world.VelocityAt(i);
|
||||
var relativeSpeed = dot(relativeVelocity, normal);
|
||||
|
||||
contacts.Add(new ContactEvent(
|
||||
world.BodyIds[i],
|
||||
world.BodyIds[j],
|
||||
Vec2.FromCultMath(point),
|
||||
Vec2.FromCultMath(normal),
|
||||
penetration,
|
||||
relativeSpeed));
|
||||
|
||||
ResolveImpulse(world, i, j, normal, penetration, relativeSpeed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ResolveImpulse(
|
||||
YmirSoAWorld world,
|
||||
int left,
|
||||
int right,
|
||||
float2 normal,
|
||||
float penetration,
|
||||
float relativeSpeed)
|
||||
{
|
||||
var leftInvMass = world.DynamicMask[left] <= 0.0f ? 0.0f : 1.0f / max(world.Mass[left], MinimumMass);
|
||||
var rightInvMass = world.DynamicMask[right] <= 0.0f ? 0.0f : 1.0f / max(world.Mass[right], MinimumMass);
|
||||
var invMassSum = leftInvMass + rightInvMass;
|
||||
if (invMassSum <= 0.0f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var correction = normal * (penetration / invMassSum);
|
||||
if (leftInvMass > 0.0f)
|
||||
{
|
||||
world.SetPosition(left, world.PositionAt(left) - correction * leftInvMass);
|
||||
}
|
||||
|
||||
if (rightInvMass > 0.0f)
|
||||
{
|
||||
world.SetPosition(right, world.PositionAt(right) + correction * rightInvMass);
|
||||
}
|
||||
|
||||
if (relativeSpeed >= 0.0f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var restitution = min(world.Restitution[left], world.Restitution[right]);
|
||||
var impulseMagnitude = -(1.0f + restitution) * relativeSpeed / invMassSum;
|
||||
var impulse = normal * impulseMagnitude;
|
||||
|
||||
if (leftInvMass > 0.0f)
|
||||
{
|
||||
world.SetVelocity(left, world.VelocityAt(left) - impulse * leftInvMass);
|
||||
}
|
||||
|
||||
if (rightInvMass > 0.0f)
|
||||
{
|
||||
world.SetVelocity(right, world.VelocityAt(right) + impulse * rightInvMass);
|
||||
}
|
||||
using var session = new YmirWorldSession();
|
||||
return session.Step(request);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,10 @@ public sealed class YmirSoAWorld
|
|||
public float[] PositionY { get; set; } = [];
|
||||
public float[] VelocityX { get; set; } = [];
|
||||
public float[] VelocityY { get; set; } = [];
|
||||
public float[] DirectionX { get; set; } = [];
|
||||
public float[] DirectionY { get; set; } = [];
|
||||
public float[] AngularVelocity { get; set; } = [];
|
||||
public float[] Torque { get; set; } = [];
|
||||
public float[] Radius { get; set; } = [];
|
||||
public float[] Mass { get; set; } = [];
|
||||
public float[] DynamicMask { get; set; } = [];
|
||||
|
|
@ -37,6 +41,10 @@ public sealed class YmirSoAWorld
|
|||
PositionY = bodies.Select(body => body.Position.Y).ToArray(),
|
||||
VelocityX = bodies.Select(body => body.Velocity.X).ToArray(),
|
||||
VelocityY = bodies.Select(body => body.Velocity.Y).ToArray(),
|
||||
DirectionX = bodies.Select(body => DirectionOrDefault(body.Direction).X).ToArray(),
|
||||
DirectionY = bodies.Select(body => DirectionOrDefault(body.Direction).Y).ToArray(),
|
||||
AngularVelocity = bodies.Select(body => body.AngularVelocity).ToArray(),
|
||||
Torque = bodies.Select(body => body.Torque).ToArray(),
|
||||
Radius = bodies.Select(body => body.Radius).ToArray(),
|
||||
Mass = bodies.Select(body => body.Mass).ToArray(),
|
||||
DynamicMask = bodies.Select(body => body.IsStatic ? 0.0f : 1.0f).ToArray(),
|
||||
|
|
@ -59,7 +67,10 @@ public sealed class YmirSoAWorld
|
|||
Radius[i],
|
||||
Mass[i],
|
||||
DynamicMask[i] <= 0.0f,
|
||||
Restitution[i]))
|
||||
Restitution[i],
|
||||
new Vec2(DirectionX[i], DirectionY[i]),
|
||||
AngularVelocity[i],
|
||||
Torque[i]))
|
||||
.ToArray(),
|
||||
Enumerable.Range(0, FieldCount)
|
||||
.Select(i => new RadialField(
|
||||
|
|
@ -77,6 +88,10 @@ public sealed class YmirSoAWorld
|
|||
PositionY = (float[])PositionY.Clone(),
|
||||
VelocityX = (float[])VelocityX.Clone(),
|
||||
VelocityY = (float[])VelocityY.Clone(),
|
||||
DirectionX = (float[])DirectionX.Clone(),
|
||||
DirectionY = (float[])DirectionY.Clone(),
|
||||
AngularVelocity = (float[])AngularVelocity.Clone(),
|
||||
Torque = (float[])Torque.Clone(),
|
||||
Radius = (float[])Radius.Clone(),
|
||||
Mass = (float[])Mass.Clone(),
|
||||
DynamicMask = (float[])DynamicMask.Clone(),
|
||||
|
|
@ -109,6 +124,17 @@ public sealed class YmirSoAWorld
|
|||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(Mass), "Dynamic body mass must be positive.");
|
||||
}
|
||||
|
||||
var directionLengthSquared = DirectionX[i] * DirectionX[i] + DirectionY[i] * DirectionY[i];
|
||||
if (!float.IsFinite(directionLengthSquared) || directionLengthSquared <= 1.0e-8f)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(DirectionX), "Body direction must be finite and non-zero.");
|
||||
}
|
||||
|
||||
if (!float.IsFinite(AngularVelocity[i]) || !float.IsFinite(Torque[i]))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(AngularVelocity), "Body angular state must be finite.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -131,12 +157,17 @@ public sealed class YmirSoAWorld
|
|||
private void ValidateBodyLengths()
|
||||
{
|
||||
var expected = BodyIds.Length;
|
||||
FillMissingAngularState(expected);
|
||||
foreach (var length in new[]
|
||||
{
|
||||
PositionX.Length,
|
||||
PositionY.Length,
|
||||
VelocityX.Length,
|
||||
VelocityY.Length,
|
||||
DirectionX.Length,
|
||||
DirectionY.Length,
|
||||
AngularVelocity.Length,
|
||||
Torque.Length,
|
||||
Radius.Length,
|
||||
Mass.Length,
|
||||
DynamicMask.Length,
|
||||
|
|
@ -150,6 +181,40 @@ public sealed class YmirSoAWorld
|
|||
}
|
||||
}
|
||||
|
||||
private void FillMissingAngularState(int expected)
|
||||
{
|
||||
if (DirectionX.Length == 0 && expected > 0)
|
||||
{
|
||||
DirectionX = new float[expected];
|
||||
}
|
||||
|
||||
if (DirectionY.Length == 0 && expected > 0)
|
||||
{
|
||||
DirectionY = Enumerable.Repeat(1.0f, expected).ToArray();
|
||||
}
|
||||
|
||||
if (AngularVelocity.Length == 0 && expected > 0)
|
||||
{
|
||||
AngularVelocity = new float[expected];
|
||||
}
|
||||
|
||||
if (Torque.Length == 0 && expected > 0)
|
||||
{
|
||||
Torque = new float[expected];
|
||||
}
|
||||
}
|
||||
|
||||
private static Vec2 DirectionOrDefault(Vec2? direction)
|
||||
{
|
||||
if (direction is not { } value ||
|
||||
value.X * value.X + value.Y * value.Y <= 1.0e-8f)
|
||||
{
|
||||
return new Vec2(0.0f, 1.0f);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private void ValidateFieldLengths()
|
||||
{
|
||||
var expected = FieldIds.Length;
|
||||
|
|
|
|||
200
src/Ymir.Core/YmirWorldSession.cs
Normal file
200
src/Ymir.Core/YmirWorldSession.cs
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
using Ymir.Box3D;
|
||||
|
||||
namespace Ymir.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Owns one retained Box3D world and its stable GameCult identity projection.
|
||||
/// Callers own the session lifetime; body snapshots do not imply session identity.
|
||||
/// </summary>
|
||||
internal sealed class YmirWorldSession : IDisposable
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly Box3DSession _session = new();
|
||||
private readonly Dictionary<string, ulong> _keysById = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<ulong, string> _idsByKey = new();
|
||||
private ulong _nextKey = 1;
|
||||
private bool _disposed;
|
||||
|
||||
public SimulationStepResult Step(SimulationStepRequest request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
var result = Step(new SoASimulationStepRequest(
|
||||
request.DeltaTime,
|
||||
YmirSoAWorld.FromWorld(request.World)));
|
||||
|
||||
return new SimulationStepResult(result.World.ToWorld(), result.Contacts);
|
||||
}
|
||||
|
||||
public SoASimulationStepResult Step(SoASimulationStepRequest request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
if (!float.IsFinite(request.DeltaTime) || request.DeltaTime <= 0.0f)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(request), "DeltaTime must be finite and positive.");
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
var world = request.World.Clone();
|
||||
world.Validate();
|
||||
ValidateStableIds(world.BodyIds);
|
||||
|
||||
var indicesByKey = new Dictionary<ulong, int>(world.BodyCount);
|
||||
foreach (var id in world.BodyIds.OrderBy(id => id, StringComparer.Ordinal))
|
||||
{
|
||||
GetOrCreateKey(id);
|
||||
}
|
||||
|
||||
var bodies = new Box3DBody[world.BodyCount];
|
||||
for (var i = 0; i < world.BodyCount; i++)
|
||||
{
|
||||
var key = GetOrCreateKey(world.BodyIds[i]);
|
||||
indicesByKey.Add(key, i);
|
||||
bodies[i] = new Box3DBody(
|
||||
key,
|
||||
world.PositionX[i],
|
||||
world.PositionY[i],
|
||||
world.VelocityX[i],
|
||||
world.VelocityY[i],
|
||||
world.DirectionX[i],
|
||||
world.DirectionY[i],
|
||||
world.AngularVelocity[i],
|
||||
world.Torque[i],
|
||||
world.Radius[i],
|
||||
world.Mass[i],
|
||||
world.Restitution[i],
|
||||
world.DynamicMask[i] <= 0.0f);
|
||||
}
|
||||
|
||||
Array.Sort(bodies, static (left, right) => left.StableId.CompareTo(right.StableId));
|
||||
|
||||
var fields = Enumerable.Range(0, world.FieldCount)
|
||||
.OrderBy(index => world.FieldIds[index], StringComparer.Ordinal)
|
||||
.Select(index => new Box3DField(
|
||||
world.FieldPositionX[index],
|
||||
world.FieldPositionY[index],
|
||||
world.FieldStrength[index],
|
||||
world.FieldRadius[index]))
|
||||
.ToArray();
|
||||
|
||||
_session.SyncBodies(bodies);
|
||||
_session.Step(request.DeltaTime, fields);
|
||||
|
||||
foreach (var body in _session.CopyBodies())
|
||||
{
|
||||
if (!indicesByKey.TryGetValue(body.StableId, out var index))
|
||||
{
|
||||
throw new InvalidOperationException($"Box3D returned unknown stable body key {body.StableId}.");
|
||||
}
|
||||
|
||||
world.PositionX[index] = body.PositionX;
|
||||
world.PositionY[index] = body.PositionZ;
|
||||
world.VelocityX[index] = body.VelocityX;
|
||||
world.VelocityY[index] = body.VelocityZ;
|
||||
world.DirectionX[index] = body.DirectionX;
|
||||
world.DirectionY[index] = body.DirectionZ;
|
||||
world.AngularVelocity[index] = body.AngularVelocity;
|
||||
world.Torque[index] = 0.0f;
|
||||
}
|
||||
|
||||
var contacts = _session.CopyContacts()
|
||||
.Select(ProjectContact)
|
||||
.OrderBy(contact => contact.BodyA, StringComparer.Ordinal)
|
||||
.ThenBy(contact => contact.BodyB, StringComparer.Ordinal)
|
||||
.ThenBy(contact => contact.Point.X)
|
||||
.ThenBy(contact => contact.Point.Y)
|
||||
.ToArray();
|
||||
|
||||
PruneInactiveIds(world.BodyIds);
|
||||
|
||||
world.Time += request.DeltaTime;
|
||||
return new SoASimulationStepResult(world, contacts);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_session.Dispose();
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
private ulong GetOrCreateKey(string id)
|
||||
{
|
||||
if (_keysById.TryGetValue(id, out var existing))
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
if (_nextKey == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Ymir exhausted its stable native body key space.");
|
||||
}
|
||||
|
||||
var key = _nextKey++;
|
||||
_keysById.Add(id, key);
|
||||
_idsByKey.Add(key, id);
|
||||
return key;
|
||||
}
|
||||
|
||||
private ContactEvent ProjectContact(Box3DContact contact)
|
||||
{
|
||||
var bodyA = ResolveId(contact.StableIdA);
|
||||
var bodyB = ResolveId(contact.StableIdB);
|
||||
var normal = new Vec2(contact.NormalX, contact.NormalZ);
|
||||
if (StringComparer.Ordinal.Compare(bodyA, bodyB) > 0)
|
||||
{
|
||||
(bodyA, bodyB) = (bodyB, bodyA);
|
||||
normal = new Vec2(-normal.X, -normal.Y);
|
||||
}
|
||||
|
||||
return new ContactEvent(
|
||||
bodyA,
|
||||
bodyB,
|
||||
new Vec2(contact.PointX, contact.PointZ),
|
||||
normal,
|
||||
contact.Penetration,
|
||||
contact.RelativeSpeed);
|
||||
}
|
||||
|
||||
private string ResolveId(ulong key)
|
||||
{
|
||||
if (!_idsByKey.TryGetValue(key, out var id))
|
||||
{
|
||||
throw new InvalidOperationException($"Box3D returned unknown stable body key {key}.");
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
private void PruneInactiveIds(IReadOnlyList<string> activeIds)
|
||||
{
|
||||
var active = activeIds.ToHashSet(StringComparer.Ordinal);
|
||||
foreach (var id in _keysById.Keys.Where(id => !active.Contains(id)).ToArray())
|
||||
{
|
||||
var key = _keysById[id];
|
||||
_keysById.Remove(id);
|
||||
_idsByKey.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateStableIds(IReadOnlyList<string> bodyIds)
|
||||
{
|
||||
var unique = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var id in bodyIds)
|
||||
{
|
||||
if (!unique.Add(id))
|
||||
{
|
||||
throw new ArgumentException($"Ymir body id '{id}' occurs more than once.", nameof(bodyIds));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ namespace Ymir.Core;
|
|||
|
||||
[CultDocument("gamecult.ymir.world_state", "gamecult.ymir.world_state.v0")]
|
||||
[MessagePackObject]
|
||||
public sealed class YmirWorldStateDocument
|
||||
public sealed class YmirWorldStateDocumentV0
|
||||
{
|
||||
[Key(0)]
|
||||
[CultName]
|
||||
|
|
@ -57,7 +57,7 @@ public sealed class YmirWorldStateDocument
|
|||
[Key(15)]
|
||||
public float[] FieldRadius { get; set; } = [];
|
||||
|
||||
public static YmirWorldStateDocument FromSoA(string worldId, YmirSoAWorld world) => new()
|
||||
public static YmirWorldStateDocumentV0 FromSoA(string worldId, YmirSoAWorld world) => new()
|
||||
{
|
||||
WorldId = worldId,
|
||||
Time = world.Time,
|
||||
|
|
@ -97,6 +97,76 @@ public sealed class YmirWorldStateDocument
|
|||
};
|
||||
}
|
||||
|
||||
[CultDocument("gamecult.ymir.world_state", "gamecult.ymir.world_state.v1")]
|
||||
[MessagePackObject]
|
||||
public sealed class YmirWorldStateDocumentV1
|
||||
{
|
||||
[Key(0), CultName] public string WorldId { get; set; } = "default";
|
||||
[Key(1)] public float Time { get; set; }
|
||||
[Key(2)] public string[] BodyIds { get; set; } = [];
|
||||
[Key(3)] public float[] PositionX { get; set; } = [];
|
||||
[Key(4)] public float[] PositionY { get; set; } = [];
|
||||
[Key(5)] public float[] VelocityX { get; set; } = [];
|
||||
[Key(6)] public float[] VelocityY { get; set; } = [];
|
||||
[Key(7)] public float[] Radius { get; set; } = [];
|
||||
[Key(8)] public float[] Mass { get; set; } = [];
|
||||
[Key(9)] public float[] DynamicMask { get; set; } = [];
|
||||
[Key(10)] public float[] Restitution { get; set; } = [];
|
||||
[Key(11)] public string[] FieldIds { get; set; } = [];
|
||||
[Key(12)] public float[] FieldPositionX { get; set; } = [];
|
||||
[Key(13)] public float[] FieldPositionY { get; set; } = [];
|
||||
[Key(14)] public float[] FieldStrength { get; set; } = [];
|
||||
[Key(15)] public float[] FieldRadius { get; set; } = [];
|
||||
[Key(16)] public float[] DirectionX { get; set; } = [];
|
||||
[Key(17)] public float[] DirectionY { get; set; } = [];
|
||||
[Key(18)] public float[] AngularVelocity { get; set; } = [];
|
||||
|
||||
public static YmirWorldStateDocumentV1 FromSoA(string worldId, YmirSoAWorld world) => new()
|
||||
{
|
||||
WorldId = worldId,
|
||||
Time = world.Time,
|
||||
BodyIds = (string[])world.BodyIds.Clone(),
|
||||
PositionX = (float[])world.PositionX.Clone(),
|
||||
PositionY = (float[])world.PositionY.Clone(),
|
||||
VelocityX = (float[])world.VelocityX.Clone(),
|
||||
VelocityY = (float[])world.VelocityY.Clone(),
|
||||
Radius = (float[])world.Radius.Clone(),
|
||||
Mass = (float[])world.Mass.Clone(),
|
||||
DynamicMask = (float[])world.DynamicMask.Clone(),
|
||||
Restitution = (float[])world.Restitution.Clone(),
|
||||
FieldIds = (string[])world.FieldIds.Clone(),
|
||||
FieldPositionX = (float[])world.FieldPositionX.Clone(),
|
||||
FieldPositionY = (float[])world.FieldPositionY.Clone(),
|
||||
FieldStrength = (float[])world.FieldStrength.Clone(),
|
||||
FieldRadius = (float[])world.FieldRadius.Clone(),
|
||||
DirectionX = (float[])world.DirectionX.Clone(),
|
||||
DirectionY = (float[])world.DirectionY.Clone(),
|
||||
AngularVelocity = (float[])world.AngularVelocity.Clone()
|
||||
};
|
||||
|
||||
public YmirSoAWorld ToSoA() => new()
|
||||
{
|
||||
Time = Time,
|
||||
BodyIds = (string[])BodyIds.Clone(),
|
||||
PositionX = (float[])PositionX.Clone(),
|
||||
PositionY = (float[])PositionY.Clone(),
|
||||
VelocityX = (float[])VelocityX.Clone(),
|
||||
VelocityY = (float[])VelocityY.Clone(),
|
||||
Radius = (float[])Radius.Clone(),
|
||||
Mass = (float[])Mass.Clone(),
|
||||
DynamicMask = (float[])DynamicMask.Clone(),
|
||||
Restitution = (float[])Restitution.Clone(),
|
||||
FieldIds = (string[])FieldIds.Clone(),
|
||||
FieldPositionX = (float[])FieldPositionX.Clone(),
|
||||
FieldPositionY = (float[])FieldPositionY.Clone(),
|
||||
FieldStrength = (float[])FieldStrength.Clone(),
|
||||
FieldRadius = (float[])FieldRadius.Clone(),
|
||||
DirectionX = (float[])DirectionX.Clone(),
|
||||
DirectionY = (float[])DirectionY.Clone(),
|
||||
AngularVelocity = (float[])AngularVelocity.Clone()
|
||||
};
|
||||
}
|
||||
|
||||
public static class YmirCultCacheStore
|
||||
{
|
||||
public static async Task SaveWorldAsync(string path, string worldId, YmirSoAWorld world)
|
||||
|
|
@ -113,8 +183,8 @@ public static class YmirCultCacheStore
|
|||
using (cache)
|
||||
{
|
||||
await cache.UpsertAsync(
|
||||
YmirWorldStateDocument.FromSoA(worldId, world),
|
||||
new CultRecordHandle<YmirWorldStateDocument>(new CultRecordKey($"ymir:world:{worldId}")))
|
||||
YmirWorldStateDocumentV1.FromSoA(worldId, world),
|
||||
new CultRecordHandle<YmirWorldStateDocumentV1>(new CultRecordKey($"ymir:world:{worldId}")))
|
||||
.ConfigureAwait(false);
|
||||
await cache.FlushAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
|
@ -125,6 +195,8 @@ public static class YmirCultCacheStore
|
|||
using var cache = await CultCacheMessagePack.OpenAsync(
|
||||
path,
|
||||
new CultCacheOpenOptions { UseDirectoryStore = true }).ConfigureAwait(false);
|
||||
return cache.Get<YmirWorldStateDocument>(new CultRecordKey($"ymir:world:{worldId}"))?.ToSoA();
|
||||
var key = new CultRecordKey($"ymir:world:{worldId}");
|
||||
return cache.Get<YmirWorldStateDocumentV1>(key)?.ToSoA()
|
||||
?? cache.Get<YmirWorldStateDocumentV0>(key)?.ToSoA();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ internal static class Program
|
|||
ok = true,
|
||||
path,
|
||||
record = "ymir:world:sample",
|
||||
schema = "gamecult.ymir.world_state.v0",
|
||||
schema = "gamecult.ymir.world_state.v1",
|
||||
bodyCount = result.World.BodyCount,
|
||||
fieldCount = result.World.FieldCount
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace Ymir.Box3D.Parity;
|
||||
|
||||
|
|
@ -7,9 +8,11 @@ internal static partial class Box3DOracle
|
|||
private const string LibraryName = "ymir_box3d";
|
||||
|
||||
[LibraryImport(LibraryName)]
|
||||
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||
internal static partial int ymir_box3d_get_version(out int major, out int minor, out int revision);
|
||||
|
||||
[LibraryImport(LibraryName)]
|
||||
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||
internal static partial Box3DOverlapResult ymir_box3d_overlap_spheres(
|
||||
float queryX,
|
||||
float queryY,
|
||||
|
|
@ -19,6 +22,7 @@ internal static partial class Box3DOracle
|
|||
float bodyRadius);
|
||||
|
||||
[LibraryImport(LibraryName)]
|
||||
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||
internal static partial Box3DOverlapResult ymir_box3d_overlap_capsule_sphere(
|
||||
float startX,
|
||||
float startY,
|
||||
|
|
@ -30,6 +34,7 @@ internal static partial class Box3DOracle
|
|||
float bodyRadius);
|
||||
|
||||
[LibraryImport(LibraryName)]
|
||||
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||
internal static partial Box3DCastResult ymir_box3d_cast_sphere(
|
||||
float originX,
|
||||
float originY,
|
||||
|
|
@ -42,6 +47,7 @@ internal static partial class Box3DOracle
|
|||
int canEncroach);
|
||||
|
||||
[LibraryImport(LibraryName)]
|
||||
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||
internal static partial void ymir_box3d_step_pair(
|
||||
in Box3DBodyInput bodyA,
|
||||
in Box3DBodyInput bodyB,
|
||||
|
|
@ -51,6 +57,7 @@ internal static partial class Box3DOracle
|
|||
out Box3DPairStepResult result);
|
||||
|
||||
[LibraryImport(LibraryName)]
|
||||
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||
internal static partial Box3DTorqueResult ymir_box3d_torque_lifetime(float torque, float timeStep, int subStepCount);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
<IsPackable>false</IsPackable>
|
||||
<NativeSourceDir>$(MSBuildThisFileDirectory)..\..\native\Ymir.Box3D</NativeSourceDir>
|
||||
<NativeBuildDir>$(MSBuildThisFileDirectory)..\..\artifacts\box3d-native\$(Configuration)</NativeBuildDir>
|
||||
<NativeBuildDir>$(MSBuildThisFileDirectory)..\..\artifacts\box3d-native\parity\$(Configuration)</NativeBuildDir>
|
||||
<CMakeExe Condition="'$(CMakeExe)' == '' and '$(OS)' == 'Windows_NT' and Exists('C:\Program Files\CMake\bin\cmake.exe')">C:\Program Files\CMake\bin\cmake.exe</CMakeExe>
|
||||
<CMakeExe Condition="'$(CMakeExe)' == ''">cmake</CMakeExe>
|
||||
</PropertyGroup>
|
||||
|
|
|
|||
|
|
@ -16,11 +16,14 @@ public sealed class YmirServicePublicationTests
|
|||
var diagnostics = YmirServicePublication.OperatorState(now);
|
||||
|
||||
Assert.Contains("Box3D", advertisement.Description, StringComparison.Ordinal);
|
||||
Assert.Contains("authoritative physics session lifecycle", advertisement.Owns);
|
||||
Assert.Contains("Box3D-only isolated snapshot stepping", advertisement.Owns);
|
||||
Assert.Contains("public retained session lifecycle yet", advertisement.DoesNotOwn);
|
||||
Assert.Empty(advertisement.CommandLowerings);
|
||||
Assert.Contains("Box3D solver and collision semantics", advertisement.DoesNotOwn);
|
||||
Assert.Equal("Box3D v0.1.0 (C17)", diagnostics.NumericSubstrate);
|
||||
Assert.Contains("does not own physics algorithms", diagnostics.BatchKernel, StringComparison.Ordinal);
|
||||
Assert.Contains("world_state.v0", diagnostics.Persistence, StringComparison.Ordinal);
|
||||
Assert.Contains("world_state.v1", diagnostics.Persistence, StringComparison.Ordinal);
|
||||
Assert.Contains("v0 read migration", diagnostics.Persistence, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
using Ymir.Core;
|
||||
using GameCult.Caching;
|
||||
using GameCult.Caching.MessagePack;
|
||||
|
||||
namespace Ymir.Tests;
|
||||
|
||||
|
|
@ -76,7 +78,78 @@ public sealed class YmirSimulatorTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void SoAStepUsesBufferAsSimulationAuthority()
|
||||
public void ExplicitWorldSessionRetainsBox3DStateAcrossSteps()
|
||||
{
|
||||
using var session = new YmirWorldSession();
|
||||
var first = session.Step(new SimulationStepRequest(
|
||||
0.1f,
|
||||
new YmirWorld(
|
||||
0.0f,
|
||||
[new PhysicsBody("ship", Vec2.Zero, new Vec2(10.0f, 0.0f), 0.5f, 1.0f)],
|
||||
[])));
|
||||
|
||||
var second = session.Step(new SimulationStepRequest(0.1f, first.World));
|
||||
|
||||
Assert.Equal(2.0f, second.World.Bodies[0].Position.X, precision: 5);
|
||||
Assert.Equal(0.2f, second.World.Time, precision: 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Box3DOwnsPlanarOrientationAndConsumesTorqueOnce()
|
||||
{
|
||||
using var session = new YmirWorldSession();
|
||||
var first = session.Step(new SimulationStepRequest(
|
||||
0.1f,
|
||||
new YmirWorld(
|
||||
0.0f,
|
||||
[new PhysicsBody(
|
||||
"ship",
|
||||
Vec2.Zero,
|
||||
Vec2.Zero,
|
||||
0.5f,
|
||||
1.0f,
|
||||
Direction: new Vec2(0.0f, 1.0f),
|
||||
Torque: 1.0f)],
|
||||
[])));
|
||||
|
||||
var firstBody = first.World.Bodies[0];
|
||||
Assert.NotNull(firstBody.Direction);
|
||||
Assert.NotEqual(0.0f, firstBody.AngularVelocity);
|
||||
Assert.Equal(0.0f, firstBody.Torque);
|
||||
|
||||
var second = session.Step(new SimulationStepRequest(0.1f, first.World));
|
||||
Assert.Equal(firstBody.AngularVelocity, second.World.Bodies[0].AngularVelocity, precision: 4);
|
||||
Assert.NotEqual(firstBody.Direction, second.World.Bodies[0].Direction);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CircleCastUsesPinnedBox3DEdgeSemantics()
|
||||
{
|
||||
var result = YmirQueries.CastCircle(new CircleCastQueryRequest(
|
||||
Vec2.Zero,
|
||||
new Vec2(1.0f, 0.0f),
|
||||
10.0f,
|
||||
1.0f,
|
||||
[new PhysicsBody("target", new Vec2(30.0f, 0.0f), Vec2.Zero, 20.0f, 1.0f, IsStatic: true)]));
|
||||
|
||||
var hit = Assert.Single(result.Hits);
|
||||
Assert.Equal("target", hit.BodyId);
|
||||
Assert.InRange(hit.Distance, 8.99f, 9.01f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CircleOverlapUsesPinnedBox3DTangentSemantics()
|
||||
{
|
||||
var result = YmirQueries.OverlapCircle(new CircleOverlapQueryRequest(
|
||||
Vec2.Zero,
|
||||
1.0f,
|
||||
[new PhysicsBody("target", new Vec2(2.0f, 0.0f), Vec2.Zero, 1.0f, 1.0f, IsStatic: true)]));
|
||||
|
||||
Assert.Equal("target", Assert.Single(result.Hits).BodyId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SoAStepProjectsBox3DStateIntoContractBuffers()
|
||||
{
|
||||
var simulator = new YmirSimulator();
|
||||
var world = new YmirSoAWorld
|
||||
|
|
@ -114,7 +187,7 @@ public sealed class YmirSimulatorTests
|
|||
{
|
||||
var world = YmirSoAWorld.FromWorld(new YmirWorld(
|
||||
2.0f,
|
||||
new[] { new PhysicsBody("ship", new Vec2(1.0f, 2.0f), new Vec2(3.0f, 4.0f), 0.5f, 1.0f) },
|
||||
new[] { new PhysicsBody("ship", new Vec2(1.0f, 2.0f), new Vec2(3.0f, 4.0f), 0.5f, 1.0f, Direction: new Vec2(1.0f, 0.0f), AngularVelocity: 2.0f) },
|
||||
new[] { new RadialField("well", new Vec2(5.0f, 6.0f), 7.0f, 8.0f) }));
|
||||
|
||||
await YmirCultCacheStore.SaveWorldAsync(path, "test", world);
|
||||
|
|
@ -124,6 +197,8 @@ public sealed class YmirSimulatorTests
|
|||
Assert.Equal(["ship"], loaded!.BodyIds);
|
||||
Assert.Equal(1.0f, loaded.PositionX[0], precision: 5);
|
||||
Assert.Equal(7.0f, loaded.FieldStrength[0], precision: 5);
|
||||
Assert.Equal(1.0f, loaded.DirectionX[0], precision: 5);
|
||||
Assert.Equal(2.0f, loaded.AngularVelocity[0], precision: 5);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
|
@ -139,6 +214,42 @@ public sealed class YmirSimulatorTests
|
|||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CultCacheStoreReadsV0WithDefaultAngularState()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"ymir-v0-tests-{Guid.NewGuid():N}.cc");
|
||||
var recordsPath = $"{path}.records";
|
||||
try
|
||||
{
|
||||
var world = YmirSoAWorld.FromWorld(new YmirWorld(
|
||||
2.0f,
|
||||
[new PhysicsBody("ship", Vec2.Zero, Vec2.Zero, 0.5f, 1.0f)],
|
||||
[]));
|
||||
using (var cache = await CultCacheMessagePack.OpenAsync(
|
||||
path,
|
||||
new CultCacheOpenOptions { UseDirectoryStore = true, FlushOnDispose = true, StoreFlushOnDispose = true }))
|
||||
{
|
||||
await cache.UpsertAsync(
|
||||
YmirWorldStateDocumentV0.FromSoA("test", world),
|
||||
new CultRecordHandle<YmirWorldStateDocumentV0>(new CultRecordKey("ymir:world:test")));
|
||||
await cache.FlushAsync();
|
||||
}
|
||||
|
||||
var loaded = await YmirCultCacheStore.LoadWorldAsync(path, "test");
|
||||
|
||||
Assert.NotNull(loaded);
|
||||
loaded!.Validate();
|
||||
Assert.Equal(0.0f, loaded.DirectionX[0]);
|
||||
Assert.Equal(1.0f, loaded.DirectionY[0]);
|
||||
Assert.Equal(0.0f, loaded.AngularVelocity[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
if (Directory.Exists(recordsPath)) Directory.Delete(recordsPath, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static SimulationStepResult Step(params PhysicsBody[] bodies)
|
||||
{
|
||||
var simulator = new YmirSimulator();
|
||||
|
|
|
|||
Loading…
Reference in a new issue