Cover Box3D world-step semantics
This commit is contained in:
parent
34366604be
commit
0ffad2a4f0
6 changed files with 226 additions and 9 deletions
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue