native/physics-native/lingua_physics.c wraps Box3D at a specific pinned commit (47d7f7c — Box3D has no 1.0 release yet, and its only tag, v0.1.0, already diverges from this API, confirmed by diffing headers rather than assuming). The wrapper is deliberately narrow: Box3D's own b3WorldDef/ b3BodyDef/b3ShapeDef are large structs with function pointers, and b3BoxHull's own doc comment says it "has data hanging off the end and cannot be directly copied" — none of that crosses the P/Invoke boundary. Every exported Lingua_* function takes and returns only plain int32/float/ bool scalars, and handles are this shim's own array-index handles, not Box3D's id structs. C# binds them with classic DllImport rather than the newer LibraryImport specifically because LibraryImport's generated marshalling needs AllowUnsafeBlocks even for an all-scalar signature like every one of these — DllImport needs none, keeping this plugin inside the kernel's "no unsafe in the v1 hot path" rule with no exception required. engine.physics adds Rigidbody/BoxCollider/SphereCollider components and PhysicsWorld, which diffs Query<Rigidbody>() against its own tracked set every Stage.FixedUpdate (there's no destruction event to hook) to create and destroy native bodies, steps Box3D once per invocation — Engine.Host's accumulator decides how many times that runs per frame, not this plugin — and writes each body's resulting transform back to GameObject.Transform. IPhysicsService exposes ApplyLinearImpulse/Get/SetLinearVelocity for gameplay code. Verified twice: a standalone C smoke test against the native shim alone (a box dropped from y=5 onto a static ground settles at y≈1.0, exactly where the two half-heights sum to), and the full pipeline through Engine. Host — a headless run with a real scene, --dump showing the same box settling at y=0.9999 after physics, scene load, and Stage.FixedUpdate all went through the real kernel. 5 new automated tests in Engine.Physics. Tests cover the same settling behavior for both shapes, a missing-collider warning that fires once and doesn't throw, cleanup after a GameObject is destroyed mid-simulation, and that an applied impulse actually changes velocity — all passed on the first run. Physics-enabled GameObjects must be root-level for now: PhysicsWorld writes Box3D's world-space transform straight into LocalPosition/ LocalRotation, correct only when local and world space are the same thing. A parented rigidbody needs the same parent-WorldMatrix-inverse handling GizmoMath.WorldToLocalPosition already does for the gizmo — real, not-yet-done work, not silently wrong. Full suite: 81 tests. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
148 lines
4.5 KiB
C#
148 lines
4.5 KiB
C#
using System.Numerics;
|
|
using Engine.Kernel.Diagnostics;
|
|
using Engine.Kernel.World;
|
|
using Engine.Physics;
|
|
using Engine.Physics.Contracts;
|
|
|
|
namespace Engine.Physics.Tests;
|
|
|
|
file sealed class RecordingLogger : ILogger
|
|
{
|
|
public List<string> Warnings { get; } = [];
|
|
|
|
public void Info(string message) { }
|
|
public void Warn(string message) => Warnings.Add(message);
|
|
public void Error(string message) { }
|
|
}
|
|
|
|
public class PhysicsWorldTests
|
|
{
|
|
private static GameObject CreateGround(GameWorld world)
|
|
{
|
|
var go = world.CreateGameObject("Ground");
|
|
go.Transform = Transform.Identity;
|
|
go.AddComponent<Rigidbody>().Type = BodyType.Static;
|
|
go.AddComponent<BoxCollider>().HalfExtents = new Vector3(10f, 0.5f, 10f);
|
|
return go;
|
|
}
|
|
|
|
[Fact]
|
|
public void DynamicBox_FallsAndSettlesOnStaticGround()
|
|
{
|
|
var world = new GameWorld();
|
|
var log = new RecordingLogger();
|
|
using var physics = new PhysicsWorld(new Vector3(0, -10, 0), log);
|
|
|
|
CreateGround(world);
|
|
|
|
var box = world.CreateGameObject("FallingBox");
|
|
box.Transform = Transform.Identity;
|
|
box.Transform.LocalPosition = new Vector3(0, 5, 0);
|
|
box.AddComponent<Rigidbody>().Type = BodyType.Dynamic;
|
|
box.AddComponent<BoxCollider>().HalfExtents = new Vector3(0.5f, 0.5f, 0.5f);
|
|
|
|
for (var i = 0; i < 150; i++)
|
|
{
|
|
physics.Sync(world);
|
|
physics.Step(1f / 50f);
|
|
}
|
|
|
|
// Ground half-height 0.5 + box half-height 0.5 = rests at y=1.
|
|
Assert.Equal(1f, box.Transform.LocalPosition.Y, 1);
|
|
Assert.Empty(log.Warnings);
|
|
}
|
|
|
|
[Fact]
|
|
public void DynamicSphere_FallsAndSettlesOnStaticGround()
|
|
{
|
|
var world = new GameWorld();
|
|
var log = new RecordingLogger();
|
|
using var physics = new PhysicsWorld(new Vector3(0, -10, 0), log);
|
|
|
|
CreateGround(world);
|
|
|
|
var sphere = world.CreateGameObject("FallingSphere");
|
|
sphere.Transform = Transform.Identity;
|
|
sphere.Transform.LocalPosition = new Vector3(0, 5, 0);
|
|
sphere.AddComponent<Rigidbody>().Type = BodyType.Dynamic;
|
|
sphere.AddComponent<SphereCollider>().Radius = 0.5f;
|
|
|
|
for (var i = 0; i < 150; i++)
|
|
{
|
|
physics.Sync(world);
|
|
physics.Step(1f / 50f);
|
|
}
|
|
|
|
Assert.Equal(1f, sphere.Transform.LocalPosition.Y, 1);
|
|
}
|
|
|
|
[Fact]
|
|
public void RigidbodyWithoutCollider_WarnsOnceAndDoesNotThrow()
|
|
{
|
|
var world = new GameWorld();
|
|
var log = new RecordingLogger();
|
|
using var physics = new PhysicsWorld(new Vector3(0, -10, 0), log);
|
|
|
|
var go = world.CreateGameObject("NoShape");
|
|
go.Transform = Transform.Identity;
|
|
go.AddComponent<Rigidbody>();
|
|
|
|
for (var i = 0; i < 5; i++)
|
|
{
|
|
physics.Sync(world);
|
|
physics.Step(1f / 50f);
|
|
}
|
|
|
|
Assert.Single(log.Warnings);
|
|
}
|
|
|
|
[Fact]
|
|
public void DestroyedGameObject_BodyCleanedUp_SubsequentStepsStillWork()
|
|
{
|
|
var world = new GameWorld();
|
|
var log = new RecordingLogger();
|
|
using var physics = new PhysicsWorld(new Vector3(0, -10, 0), log);
|
|
|
|
CreateGround(world);
|
|
|
|
var box = world.CreateGameObject("Temp");
|
|
box.Transform = Transform.Identity;
|
|
box.Transform.LocalPosition = new Vector3(0, 5, 0);
|
|
box.AddComponent<Rigidbody>().Type = BodyType.Dynamic;
|
|
box.AddComponent<BoxCollider>();
|
|
|
|
physics.Sync(world);
|
|
physics.Step(1f / 50f);
|
|
|
|
world.Destroy(box);
|
|
|
|
// Should not throw even though the body backing a now-destroyed
|
|
// GameObject still existed in the tracking table until this Sync.
|
|
for (var i = 0; i < 10; i++)
|
|
{
|
|
physics.Sync(world);
|
|
physics.Step(1f / 50f);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void ApplyLinearImpulse_ChangesVelocity()
|
|
{
|
|
var world = new GameWorld();
|
|
var log = new RecordingLogger();
|
|
using var physics = new PhysicsWorld(Vector3.Zero, log); // no gravity, isolate the impulse
|
|
|
|
var box = world.CreateGameObject("Box");
|
|
box.Transform = Transform.Identity;
|
|
box.AddComponent<Rigidbody>().Type = BodyType.Dynamic;
|
|
box.AddComponent<BoxCollider>();
|
|
|
|
physics.Sync(world); // create the body before applying an impulse to it
|
|
|
|
physics.ApplyLinearImpulse(box, new Vector3(5, 0, 0), wake: true);
|
|
|
|
var velocity = physics.GetLinearVelocity(box);
|
|
Assert.True(velocity.X > 0f, $"expected positive X velocity after impulse, got {velocity.X}");
|
|
}
|
|
}
|