Cover Box3D world-step semantics

This commit is contained in:
Meta Vens 2026-07-14 19:25:02 +01:00
commit 0ffad2a4f0
6 changed files with 226 additions and 9 deletions

View file

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

View file

@ -16,8 +16,9 @@ torque lifetime, and numeric tolerances.
`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, and version operations. This
keeps Box3D's alpha C ABI out of Ymir's public managed contracts.
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
public managed contracts.
`tests/Ymir.Box3D.Parity` builds that facade through CMake and invokes it with
source-generated .NET interop. Run it with:
@ -42,12 +43,16 @@ is retained in the pinned submodule.
includes rounded start and end caps and the same overlap slop.
- Box3D query callback order is not a contract. Ymir sorts projected results by
distance and stable entity id.
- Contact begin facts are emitted after the world step.
- Restitution uses the maximum of the two shape materials.
- Applied torque is transient and cleared by each world step; a continuous
torque must be submitted again for the next step.
## Cut Line
The current managed collision formulas are transitional. They may exist only
while differential tests prove compatibility. The next parity slice covers a
planar sphere-world step, transient forces and torques, restitution mixing,
and begin/end contact events. 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.
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.

View file

@ -1,4 +1,5 @@
#include <box3d/base.h>
#include <box3d/box3d.h>
#include <box3d/collision.h>
#include <box3d/constants.h>
#include <box3d/math_functions.h>
@ -28,11 +29,88 @@ typedef struct ymir_box3d_cast_result
float normal_y;
} ymir_box3d_cast_result;
typedef struct ymir_box3d_body_input
{
float position_x;
float position_y;
float velocity_x;
float velocity_y;
float radius;
float mass;
float restitution;
int is_static;
} ymir_box3d_body_input;
typedef struct ymir_box3d_body_output
{
float position_x;
float position_y;
float velocity_x;
float velocity_y;
float angular_velocity;
} ymir_box3d_body_output;
typedef struct ymir_box3d_pair_step_result
{
ymir_box3d_body_output body_a;
ymir_box3d_body_output body_b;
int begin_contact_count;
int end_contact_count;
int hit_contact_count;
} ymir_box3d_pair_step_result;
typedef struct ymir_box3d_torque_result
{
float angular_velocity_after_applied_step;
float angular_velocity_after_unforced_step;
} ymir_box3d_torque_result;
static b3Vec3 ymir_vec(float x, float y)
{
return (b3Vec3){x, 0.0f, y};
}
static b3BodyId ymir_create_body(b3WorldId world_id, const ymir_box3d_body_input* input)
{
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.linearVelocity = ymir_vec(input->velocity_x, input->velocity_y);
body_def.motionLocks.linearY = true;
body_def.motionLocks.angularX = true;
body_def.motionLocks.angularZ = true;
body_def.enableSleep = false;
b3BodyId body_id = b3CreateBody(world_id, &body_def);
b3ShapeDef shape_def = b3DefaultShapeDef();
if (input->is_static == 0)
{
float volume = 4.0f / 3.0f * B3_PI * input->radius * input->radius * input->radius;
shape_def.density = input->mass / volume;
}
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};
b3CreateSphereShape(body_id, &shape_def, &sphere);
return body_id;
}
static ymir_box3d_body_output ymir_read_body(b3BodyId body_id)
{
b3Pos position = b3Body_GetPosition(body_id);
b3Vec3 velocity = b3Body_GetLinearVelocity(body_id);
b3Vec3 angular_velocity = b3Body_GetAngularVelocity(body_id);
return (ymir_box3d_body_output){
.position_x = position.x,
.position_y = position.z,
.velocity_x = velocity.x,
.velocity_y = velocity.z,
.angular_velocity = angular_velocity.y,
};
}
YMIR_EXPORT int ymir_box3d_get_version(int* major, int* minor, int* revision)
{
b3Version version = b3GetVersion();
@ -132,3 +210,55 @@ YMIR_EXPORT ymir_box3d_cast_result ymir_box3d_cast_sphere(
.normal_y = output.normal.z,
};
}
YMIR_EXPORT void ymir_box3d_step_pair(
const ymir_box3d_body_input* body_a_input,
const ymir_box3d_body_input* body_b_input,
float time_step,
int sub_step_count,
float restitution_threshold,
ymir_box3d_pair_step_result* result)
{
b3WorldDef world_def = b3DefaultWorldDef();
world_def.gravity = b3Vec3_zero;
world_def.enableSleep = false;
world_def.restitutionThreshold = restitution_threshold;
b3WorldId world_id = b3CreateWorld(&world_def);
b3BodyId body_a = ymir_create_body(world_id, body_a_input);
b3BodyId body_b = ymir_create_body(world_id, body_b_input);
b3World_Step(world_id, time_step, sub_step_count);
b3ContactEvents events = b3World_GetContactEvents(world_id);
result->body_a = ymir_read_body(body_a);
result->body_b = ymir_read_body(body_b);
result->begin_contact_count = events.beginCount;
result->end_contact_count = events.endCount;
result->hit_contact_count = events.hitCount;
b3DestroyWorld(world_id);
}
YMIR_EXPORT ymir_box3d_torque_result ymir_box3d_torque_lifetime(float torque, float time_step, int sub_step_count)
{
b3WorldDef world_def = b3DefaultWorldDef();
world_def.gravity = b3Vec3_zero;
world_def.enableSleep = false;
b3WorldId world_id = b3CreateWorld(&world_def);
ymir_box3d_body_input input = {
.radius = 1.0f,
.mass = 1.0f,
.is_static = 0,
};
b3BodyId body_id = ymir_create_body(world_id, &input);
b3Body_ApplyTorque(body_id, (b3Vec3){0.0f, torque, 0.0f}, true);
b3World_Step(world_id, time_step, sub_step_count);
float after_applied_step = b3Body_GetAngularVelocity(body_id).y;
b3World_Step(world_id, time_step, sub_step_count);
float after_unforced_step = b3Body_GetAngularVelocity(body_id).y;
b3DestroyWorld(world_id);
return (ymir_box3d_torque_result){after_applied_step, after_unforced_step};
}

View file

@ -40,6 +40,18 @@ internal static partial class Box3DOracle
float bodyY,
float bodyRadius,
int canEncroach);
[LibraryImport(LibraryName)]
internal static partial void ymir_box3d_step_pair(
in Box3DBodyInput bodyA,
in Box3DBodyInput bodyB,
float timeStep,
int subStepCount,
float restitutionThreshold,
out Box3DPairStepResult result);
[LibraryImport(LibraryName)]
internal static partial Box3DTorqueResult ymir_box3d_torque_lifetime(float torque, float timeStep, int subStepCount);
}
[StructLayout(LayoutKind.Sequential)]
@ -58,3 +70,35 @@ internal readonly record struct Box3DCastResult(
{
public bool IsHit => Hit != 0;
}
[StructLayout(LayoutKind.Sequential)]
internal readonly record struct Box3DBodyInput(
float PositionX,
float PositionY,
float VelocityX,
float VelocityY,
float Radius,
float Mass,
float Restitution,
int IsStatic);
[StructLayout(LayoutKind.Sequential)]
internal readonly record struct Box3DBodyOutput(
float PositionX,
float PositionY,
float VelocityX,
float VelocityY,
float AngularVelocity);
[StructLayout(LayoutKind.Sequential)]
internal readonly record struct Box3DPairStepResult(
Box3DBodyOutput BodyA,
Box3DBodyOutput BodyB,
int BeginContactCount,
int EndContactCount,
int HitContactCount);
[StructLayout(LayoutKind.Sequential)]
internal readonly record struct Box3DTorqueResult(
float AngularVelocityAfterAppliedStep,
float AngularVelocityAfterUnforcedStep);

View file

@ -83,4 +83,41 @@ public sealed class Box3DParityTests
Assert.True(result.IsHit);
}
[Fact]
public void PairStepEmitsBeginContactAndSeparatesOverlappingSpheres()
{
var bodyA = new Box3DBodyInput(-0.9f, 0.0f, 0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0);
var bodyB = new Box3DBodyInput(0.9f, 0.0f, 0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0);
Box3DOracle.ymir_box3d_step_pair(in bodyA, in bodyB, 1.0f / 60.0f, 4, 0.0f, out var result);
Assert.Equal(1, result.BeginContactCount);
Assert.True(result.BodyA.PositionX < -0.9f);
Assert.True(result.BodyB.PositionX > 0.9f);
}
[Fact]
public void PairStepUsesMaximumRestitution()
{
var bodyA = new Box3DBodyInput(-1.0f, 0.0f, 1.0f, 0.0f, 1.0f, 1.0f, 0.1f, 0);
var bodyB = new Box3DBodyInput(1.0f, 0.0f, -1.0f, 0.0f, 1.0f, 1.0f, 0.8f, 0);
Box3DOracle.ymir_box3d_step_pair(in bodyA, in bodyB, 1.0f / 60.0f, 4, 0.0f, out var result);
Assert.True(result.BodyA.VelocityX < -0.7f);
Assert.True(result.BodyB.VelocityX > 0.7f);
}
[Fact]
public void TorqueIsClearedAfterEachWorldStep()
{
var result = Box3DOracle.ymir_box3d_torque_lifetime(0.4f, 0.1f, 4);
Assert.True(result.AngularVelocityAfterAppliedStep > 0.0f);
Assert.Equal(
result.AngularVelocityAfterAppliedStep,
result.AngularVelocityAfterUnforcedStep,
precision: 5);
}
}

View file

@ -8,8 +8,8 @@
<IsPackable>false</IsPackable>
<NativeSourceDir>$(MSBuildThisFileDirectory)..\..\native\Ymir.Box3D</NativeSourceDir>
<NativeBuildDir>$(MSBuildThisFileDirectory)..\..\artifacts\box3d-native\$(Configuration)</NativeBuildDir>
<CMakeExe Condition="'$(OS)' == 'Windows_NT'">C:\Program Files\CMake\bin\cmake.exe</CMakeExe>
<CMakeExe Condition="'$(OS)' != 'Windows_NT'">cmake</CMakeExe>
<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>