Add pinned Box3D parity oracle
This commit is contained in:
parent
f191767471
commit
6d13a2cb97
9 changed files with 395 additions and 0 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -5,3 +5,5 @@ obj/
|
|||
.ymir/
|
||||
*.user
|
||||
TestResults/
|
||||
BenchmarkDotNet.Artifacts/
|
||||
artifacts/box3d-native/
|
||||
|
|
|
|||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
[submodule "extern/box3d"]
|
||||
path = extern/box3d
|
||||
url = https://github.com/erincatto/box3d.git
|
||||
53
docs/box3d-parity.md
Normal file
53
docs/box3d-parity.md
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
# Box3D Parity Harness
|
||||
|
||||
## Authority
|
||||
|
||||
Box3D v0.1.0 is Ymir's collision-semantics oracle while the native cutover is
|
||||
under construction. The exact upstream source is pinned as the
|
||||
`extern/box3d` submodule at commit
|
||||
`8441b4a06d6d09dcfb0b0f704df4d847d1437b92`.
|
||||
|
||||
The oracle does not own GameCult entity ids, gameplay policy, persistence, or
|
||||
result ordering. Ymir projects Box3D facts into those contracts. Box3D owns
|
||||
shape overlap, shape cast, solver, broadphase, contact lifecycle, force and
|
||||
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, 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:
|
||||
|
||||
```powershell
|
||||
dotnet test tests\Ymir.Box3D.Parity\Ymir.Box3D.Parity.csproj
|
||||
```
|
||||
|
||||
The build requires CMake and a C17 compiler. Box3D is MIT licensed; its license
|
||||
is retained in the pinned submodule.
|
||||
|
||||
## Released v0.1.0 Law
|
||||
|
||||
- Sphere overlap accepts separation strictly below the `0.0005 m` overlap
|
||||
slop. Exact tangency therefore overlaps.
|
||||
- Sphere shape casts use Box3D's `0.005 m` linear slop. The hit fraction is not
|
||||
the zero-tolerance analytic sphere intersection.
|
||||
- The low-level v0.1.0 sphere shape cast reports initially touching and
|
||||
initially overlapping shapes as hits. The harness records the released
|
||||
binary's behavior rather than relying on prose or recollection.
|
||||
- A two-point shape proxy plus radius is the tractor capsule. Membership
|
||||
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.
|
||||
|
||||
## 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.
|
||||
1
extern/box3d
vendored
Submodule
1
extern/box3d
vendored
Submodule
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 8441b4a06d6d09dcfb0b0f704df4d847d1437b92
|
||||
23
native/Ymir.Box3D/CMakeLists.txt
Normal file
23
native/Ymir.Box3D/CMakeLists.txt
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
cmake_minimum_required(VERSION 3.22)
|
||||
|
||||
project(ymir_box3d VERSION 0.1.0 LANGUAGES C)
|
||||
|
||||
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
|
||||
set(BOX3D_SAMPLES OFF CACHE BOOL "" FORCE)
|
||||
set(BOX3D_BENCHMARKS OFF CACHE BOOL "" FORCE)
|
||||
set(BOX3D_DOCS OFF CACHE BOOL "" FORCE)
|
||||
set(BOX3D_UNIT_TESTS OFF CACHE BOOL "" FORCE)
|
||||
set(BOX3D_VALIDATE ON CACHE BOOL "" FORCE)
|
||||
|
||||
add_subdirectory("${CMAKE_CURRENT_SOURCE_DIR}/../../extern/box3d" "${CMAKE_CURRENT_BINARY_DIR}/box3d")
|
||||
|
||||
add_library(ymir_box3d SHARED ymir_box3d.c)
|
||||
target_link_libraries(ymir_box3d PRIVATE box3d::box3d)
|
||||
target_include_directories(ymir_box3d PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/../../extern/box3d/include")
|
||||
target_compile_features(ymir_box3d PRIVATE c_std_17)
|
||||
|
||||
set_target_properties(ymir_box3d PROPERTIES
|
||||
C_VISIBILITY_PRESET hidden
|
||||
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin"
|
||||
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin"
|
||||
ARCHIVE_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib")
|
||||
134
native/Ymir.Box3D/ymir_box3d.c
Normal file
134
native/Ymir.Box3D/ymir_box3d.c
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
#include <box3d/base.h>
|
||||
#include <box3d/collision.h>
|
||||
#include <box3d/constants.h>
|
||||
#include <box3d/math_functions.h>
|
||||
#include <box3d/types.h>
|
||||
|
||||
#if defined(_WIN32)
|
||||
#define YMIR_EXPORT __declspec(dllexport)
|
||||
#else
|
||||
#define YMIR_EXPORT __attribute__((visibility("default")))
|
||||
#endif
|
||||
|
||||
typedef struct ymir_box3d_overlap_result
|
||||
{
|
||||
int hit;
|
||||
float distance;
|
||||
float normal_x;
|
||||
float normal_y;
|
||||
} ymir_box3d_overlap_result;
|
||||
|
||||
typedef struct ymir_box3d_cast_result
|
||||
{
|
||||
int hit;
|
||||
float fraction;
|
||||
float point_x;
|
||||
float point_y;
|
||||
float normal_x;
|
||||
float normal_y;
|
||||
} ymir_box3d_cast_result;
|
||||
|
||||
static b3Vec3 ymir_vec(float x, float y)
|
||||
{
|
||||
return (b3Vec3){x, 0.0f, y};
|
||||
}
|
||||
|
||||
YMIR_EXPORT int ymir_box3d_get_version(int* major, int* minor, int* revision)
|
||||
{
|
||||
b3Version version = b3GetVersion();
|
||||
*major = version.major;
|
||||
*minor = version.minor;
|
||||
*revision = version.revision;
|
||||
return b3IsDoublePrecision() ? 1 : 0;
|
||||
}
|
||||
|
||||
YMIR_EXPORT ymir_box3d_overlap_result ymir_box3d_overlap_spheres(
|
||||
float query_x,
|
||||
float query_y,
|
||||
float query_radius,
|
||||
float body_x,
|
||||
float body_y,
|
||||
float body_radius)
|
||||
{
|
||||
b3Sphere body = {ymir_vec(body_x, body_y), body_radius};
|
||||
b3Vec3 query_point = ymir_vec(query_x, query_y);
|
||||
b3ShapeProxy query = {&query_point, 1, query_radius};
|
||||
|
||||
b3DistanceInput input = {
|
||||
.proxyA = (b3ShapeProxy){&body.center, 1, body.radius},
|
||||
.proxyB = query,
|
||||
.transform = b3Transform_identity,
|
||||
.useRadii = true,
|
||||
};
|
||||
b3SimplexCache cache = {0};
|
||||
b3DistanceOutput distance = b3ShapeDistance(&input, &cache, NULL, 0);
|
||||
|
||||
return (ymir_box3d_overlap_result){
|
||||
.hit = b3OverlapSphere(&body, b3Transform_identity, &query) ? 1 : 0,
|
||||
.distance = distance.distance,
|
||||
.normal_x = distance.normal.x,
|
||||
.normal_y = distance.normal.z,
|
||||
};
|
||||
}
|
||||
|
||||
YMIR_EXPORT ymir_box3d_overlap_result ymir_box3d_overlap_capsule_sphere(
|
||||
float start_x,
|
||||
float start_y,
|
||||
float end_x,
|
||||
float end_y,
|
||||
float capsule_radius,
|
||||
float body_x,
|
||||
float body_y,
|
||||
float body_radius)
|
||||
{
|
||||
b3Sphere body = {ymir_vec(body_x, body_y), body_radius};
|
||||
b3Vec3 capsule_points[2] = {ymir_vec(start_x, start_y), ymir_vec(end_x, end_y)};
|
||||
b3ShapeProxy capsule = {capsule_points, 2, capsule_radius};
|
||||
|
||||
b3DistanceInput input = {
|
||||
.proxyA = (b3ShapeProxy){&body.center, 1, body.radius},
|
||||
.proxyB = capsule,
|
||||
.transform = b3Transform_identity,
|
||||
.useRadii = true,
|
||||
};
|
||||
b3SimplexCache cache = {0};
|
||||
b3DistanceOutput distance = b3ShapeDistance(&input, &cache, NULL, 0);
|
||||
|
||||
return (ymir_box3d_overlap_result){
|
||||
.hit = b3OverlapSphere(&body, b3Transform_identity, &capsule) ? 1 : 0,
|
||||
.distance = distance.distance,
|
||||
.normal_x = distance.normal.x,
|
||||
.normal_y = distance.normal.z,
|
||||
};
|
||||
}
|
||||
|
||||
YMIR_EXPORT ymir_box3d_cast_result ymir_box3d_cast_sphere(
|
||||
float origin_x,
|
||||
float origin_y,
|
||||
float translation_x,
|
||||
float translation_y,
|
||||
float query_radius,
|
||||
float body_x,
|
||||
float body_y,
|
||||
float body_radius,
|
||||
int can_encroach)
|
||||
{
|
||||
b3Sphere body = {ymir_vec(body_x, body_y), body_radius};
|
||||
b3Vec3 query_point = ymir_vec(origin_x, origin_y);
|
||||
b3ShapeCastInput input = {
|
||||
.proxy = (b3ShapeProxy){&query_point, 1, query_radius},
|
||||
.translation = ymir_vec(translation_x, translation_y),
|
||||
.maxFraction = 1.0f,
|
||||
.canEncroach = can_encroach != 0,
|
||||
};
|
||||
b3CastOutput output = b3ShapeCastSphere(&body, &input);
|
||||
|
||||
return (ymir_box3d_cast_result){
|
||||
.hit = output.hit ? 1 : 0,
|
||||
.fraction = output.fraction,
|
||||
.point_x = output.point.x,
|
||||
.point_y = output.point.z,
|
||||
.normal_x = output.normal.x,
|
||||
.normal_y = output.normal.z,
|
||||
};
|
||||
}
|
||||
60
tests/Ymir.Box3D.Parity/Box3DOracle.cs
Normal file
60
tests/Ymir.Box3D.Parity/Box3DOracle.cs
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Ymir.Box3D.Parity;
|
||||
|
||||
internal static partial class Box3DOracle
|
||||
{
|
||||
private const string LibraryName = "ymir_box3d";
|
||||
|
||||
[LibraryImport(LibraryName)]
|
||||
internal static partial int ymir_box3d_get_version(out int major, out int minor, out int revision);
|
||||
|
||||
[LibraryImport(LibraryName)]
|
||||
internal static partial Box3DOverlapResult ymir_box3d_overlap_spheres(
|
||||
float queryX,
|
||||
float queryY,
|
||||
float queryRadius,
|
||||
float bodyX,
|
||||
float bodyY,
|
||||
float bodyRadius);
|
||||
|
||||
[LibraryImport(LibraryName)]
|
||||
internal static partial Box3DOverlapResult ymir_box3d_overlap_capsule_sphere(
|
||||
float startX,
|
||||
float startY,
|
||||
float endX,
|
||||
float endY,
|
||||
float capsuleRadius,
|
||||
float bodyX,
|
||||
float bodyY,
|
||||
float bodyRadius);
|
||||
|
||||
[LibraryImport(LibraryName)]
|
||||
internal static partial Box3DCastResult ymir_box3d_cast_sphere(
|
||||
float originX,
|
||||
float originY,
|
||||
float translationX,
|
||||
float translationY,
|
||||
float queryRadius,
|
||||
float bodyX,
|
||||
float bodyY,
|
||||
float bodyRadius,
|
||||
int canEncroach);
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal readonly record struct Box3DOverlapResult(int Hit, float Distance, float NormalX, float NormalY)
|
||||
{
|
||||
public bool IsHit => Hit != 0;
|
||||
}
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal readonly record struct Box3DCastResult(
|
||||
int Hit,
|
||||
float Fraction,
|
||||
float PointX,
|
||||
float PointY,
|
||||
float NormalX,
|
||||
float NormalY)
|
||||
{
|
||||
public bool IsHit => Hit != 0;
|
||||
}
|
||||
86
tests/Ymir.Box3D.Parity/Box3DParityTests.cs
Normal file
86
tests/Ymir.Box3D.Parity/Box3DParityTests.cs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
namespace Ymir.Box3D.Parity;
|
||||
|
||||
public sealed class Box3DParityTests
|
||||
{
|
||||
[Fact]
|
||||
public void OracleIsPinnedBox3DRelease()
|
||||
{
|
||||
var doublePrecision = Box3DOracle.ymir_box3d_get_version(out var major, out var minor, out var revision);
|
||||
|
||||
Assert.Equal((0, 1, 0), (major, minor, revision));
|
||||
Assert.Equal(0, doublePrecision);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1.9f, true)]
|
||||
[InlineData(2.0f, true)]
|
||||
[InlineData(2.00049f, true)]
|
||||
[InlineData(2.00051f, false)]
|
||||
public void SphereOverlapClassificationMatchesBox3DSlop(float centerDistance, bool expected)
|
||||
{
|
||||
var result = Box3DOracle.ymir_box3d_overlap_spheres(0.0f, 0.0f, 1.0f, centerDistance, 0.0f, 1.0f);
|
||||
|
||||
Assert.Equal(expected, result.IsHit);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-1.00049f, true)]
|
||||
[InlineData(-1.00051f, false)]
|
||||
[InlineData(5.0f, true)]
|
||||
[InlineData(11.00049f, true)]
|
||||
[InlineData(11.00051f, false)]
|
||||
public void TractorCapsuleMembershipUsesBox3DRoundedCapsAndSlop(float pickupX, bool expected)
|
||||
{
|
||||
var result = Box3DOracle.ymir_box3d_overlap_capsule_sphere(
|
||||
0.0f,
|
||||
0.0f,
|
||||
10.0f,
|
||||
0.0f,
|
||||
0.5f,
|
||||
pickupX,
|
||||
0.0f,
|
||||
0.5f);
|
||||
|
||||
Assert.Equal(expected, result.IsHit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SphereCastReturnsBox3DFractionAndNormal()
|
||||
{
|
||||
var result = Box3DOracle.ymir_box3d_cast_sphere(
|
||||
-5.0f,
|
||||
0.0f,
|
||||
10.0f,
|
||||
0.0f,
|
||||
0.5f,
|
||||
0.0f,
|
||||
0.0f,
|
||||
1.0f,
|
||||
0);
|
||||
|
||||
Assert.True(result.IsHit);
|
||||
Assert.Equal(0.3505f, result.Fraction, precision: 4);
|
||||
Assert.Equal(-1.0f, result.NormalX, precision: 4);
|
||||
Assert.Equal(0.0f, result.NormalY, precision: 4);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-1.5f)]
|
||||
[InlineData(-1.4f)]
|
||||
[InlineData(0.0f)]
|
||||
public void SphereCastInitiallyTouchingOrOverlappingIsAHit(float originX)
|
||||
{
|
||||
var result = Box3DOracle.ymir_box3d_cast_sphere(
|
||||
originX,
|
||||
0.0f,
|
||||
5.0f,
|
||||
0.0f,
|
||||
0.5f,
|
||||
0.0f,
|
||||
0.0f,
|
||||
1.0f,
|
||||
0);
|
||||
|
||||
Assert.True(result.IsHit);
|
||||
}
|
||||
}
|
||||
33
tests/Ymir.Box3D.Parity/Ymir.Box3D.Parity.csproj
Normal file
33
tests/Ymir.Box3D.Parity/Ymir.Box3D.Parity.csproj
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
<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>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="BuildBox3DParityOracle" BeforeTargets="Build" Condition="'$(DesignTimeBuild)' != 'true'">
|
||||
<Exec Command=""$(CMakeExe)" -S "$(NativeSourceDir)" -B "$(NativeBuildDir)" -DBUILD_SHARED_LIBS=OFF" />
|
||||
<Exec Command=""$(CMakeExe)" --build "$(NativeBuildDir)" --config $(Configuration) --target ymir_box3d" />
|
||||
<Copy SourceFiles="$(NativeBuildDir)\bin\$(Configuration)\ymir_box3d.dll" DestinationFolder="$(OutDir)" Condition="'$(OS)' == 'Windows_NT'" />
|
||||
<Copy SourceFiles="$(NativeBuildDir)\bin\libymir_box3d.so" DestinationFolder="$(OutDir)" Condition="'$(OS)' != 'Windows_NT' and !$([MSBuild]::IsOSPlatform('OSX'))" />
|
||||
<Copy SourceFiles="$(NativeBuildDir)\bin\libymir_box3d.dylib" DestinationFolder="$(OutDir)" Condition="$([MSBuild]::IsOSPlatform('OSX'))" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
Loading…
Reference in a new issue