diff --git a/.gitignore b/.gitignore index 983129f..5d71e09 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,10 @@ artifacts/ ## per-machine editing session preference, not project source, same ## category as an IDE's own workspace state. imgui.ini + +## CMake build output and FetchContent's cloned dependencies (Box3D) for +## native/*-native/ — regenerated by cmake -S . -B build, not source; only +## the built liblingua_physics.so (checked in under plugins/engine.physics/ +## Engine.Physics/native/) is what the engine actually ships. +native/*/build/ +native/*/.fetchcontent-cache/ diff --git a/LinguaEngine.sln b/LinguaEngine.sln index e8479fe..be8f89f 100644 --- a/LinguaEngine.sln +++ b/LinguaEngine.sln @@ -57,6 +57,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Editor", "plugins\en EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Editor.Tests", "tests\Engine.Editor.Tests\Engine.Editor.Tests.csproj", "{7F1CF468-6717-4152-BA95-2DA1E011B6CB}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "engine.physics", "engine.physics", "{5BFA9F87-CB7C-360F-A763-812D3AAB4167}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Physics.Contracts", "plugins\engine.physics\Engine.Physics.Contracts\Engine.Physics.Contracts.csproj", "{70BE8EA7-0EF5-4AA1-A6CD-79B7ED4C207D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Physics", "plugins\engine.physics\Engine.Physics\Engine.Physics.csproj", "{A9205D28-762B-49C0-8CFF-8D59BA330786}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Physics.Tests", "tests\Engine.Physics.Tests\Engine.Physics.Tests.csproj", "{70245995-311C-4CBE-A433-F4035303BEDB}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -283,6 +291,42 @@ Global {7F1CF468-6717-4152-BA95-2DA1E011B6CB}.Release|x64.Build.0 = Release|Any CPU {7F1CF468-6717-4152-BA95-2DA1E011B6CB}.Release|x86.ActiveCfg = Release|Any CPU {7F1CF468-6717-4152-BA95-2DA1E011B6CB}.Release|x86.Build.0 = Release|Any CPU + {70BE8EA7-0EF5-4AA1-A6CD-79B7ED4C207D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {70BE8EA7-0EF5-4AA1-A6CD-79B7ED4C207D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {70BE8EA7-0EF5-4AA1-A6CD-79B7ED4C207D}.Debug|x64.ActiveCfg = Debug|Any CPU + {70BE8EA7-0EF5-4AA1-A6CD-79B7ED4C207D}.Debug|x64.Build.0 = Debug|Any CPU + {70BE8EA7-0EF5-4AA1-A6CD-79B7ED4C207D}.Debug|x86.ActiveCfg = Debug|Any CPU + {70BE8EA7-0EF5-4AA1-A6CD-79B7ED4C207D}.Debug|x86.Build.0 = Debug|Any CPU + {70BE8EA7-0EF5-4AA1-A6CD-79B7ED4C207D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {70BE8EA7-0EF5-4AA1-A6CD-79B7ED4C207D}.Release|Any CPU.Build.0 = Release|Any CPU + {70BE8EA7-0EF5-4AA1-A6CD-79B7ED4C207D}.Release|x64.ActiveCfg = Release|Any CPU + {70BE8EA7-0EF5-4AA1-A6CD-79B7ED4C207D}.Release|x64.Build.0 = Release|Any CPU + {70BE8EA7-0EF5-4AA1-A6CD-79B7ED4C207D}.Release|x86.ActiveCfg = Release|Any CPU + {70BE8EA7-0EF5-4AA1-A6CD-79B7ED4C207D}.Release|x86.Build.0 = Release|Any CPU + {A9205D28-762B-49C0-8CFF-8D59BA330786}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A9205D28-762B-49C0-8CFF-8D59BA330786}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A9205D28-762B-49C0-8CFF-8D59BA330786}.Debug|x64.ActiveCfg = Debug|Any CPU + {A9205D28-762B-49C0-8CFF-8D59BA330786}.Debug|x64.Build.0 = Debug|Any CPU + {A9205D28-762B-49C0-8CFF-8D59BA330786}.Debug|x86.ActiveCfg = Debug|Any CPU + {A9205D28-762B-49C0-8CFF-8D59BA330786}.Debug|x86.Build.0 = Debug|Any CPU + {A9205D28-762B-49C0-8CFF-8D59BA330786}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A9205D28-762B-49C0-8CFF-8D59BA330786}.Release|Any CPU.Build.0 = Release|Any CPU + {A9205D28-762B-49C0-8CFF-8D59BA330786}.Release|x64.ActiveCfg = Release|Any CPU + {A9205D28-762B-49C0-8CFF-8D59BA330786}.Release|x64.Build.0 = Release|Any CPU + {A9205D28-762B-49C0-8CFF-8D59BA330786}.Release|x86.ActiveCfg = Release|Any CPU + {A9205D28-762B-49C0-8CFF-8D59BA330786}.Release|x86.Build.0 = Release|Any CPU + {70245995-311C-4CBE-A433-F4035303BEDB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {70245995-311C-4CBE-A433-F4035303BEDB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {70245995-311C-4CBE-A433-F4035303BEDB}.Debug|x64.ActiveCfg = Debug|Any CPU + {70245995-311C-4CBE-A433-F4035303BEDB}.Debug|x64.Build.0 = Debug|Any CPU + {70245995-311C-4CBE-A433-F4035303BEDB}.Debug|x86.ActiveCfg = Debug|Any CPU + {70245995-311C-4CBE-A433-F4035303BEDB}.Debug|x86.Build.0 = Debug|Any CPU + {70245995-311C-4CBE-A433-F4035303BEDB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {70245995-311C-4CBE-A433-F4035303BEDB}.Release|Any CPU.Build.0 = Release|Any CPU + {70245995-311C-4CBE-A433-F4035303BEDB}.Release|x64.ActiveCfg = Release|Any CPU + {70245995-311C-4CBE-A433-F4035303BEDB}.Release|x64.Build.0 = Release|Any CPU + {70245995-311C-4CBE-A433-F4035303BEDB}.Release|x86.ActiveCfg = Release|Any CPU + {70245995-311C-4CBE-A433-F4035303BEDB}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -312,5 +356,9 @@ Global {6A2943F1-1984-470D-BD74-C6E16BB77241} = {8067C66D-9D49-192B-E4C1-1C024401ACE3} {B60A3933-923C-4339-98D1-E4C87A76E80B} = {8067C66D-9D49-192B-E4C1-1C024401ACE3} {7F1CF468-6717-4152-BA95-2DA1E011B6CB} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {5BFA9F87-CB7C-360F-A763-812D3AAB4167} = {07D57EEB-2F50-60C4-C011-FE4FA775C9A8} + {70BE8EA7-0EF5-4AA1-A6CD-79B7ED4C207D} = {5BFA9F87-CB7C-360F-A763-812D3AAB4167} + {A9205D28-762B-49C0-8CFF-8D59BA330786} = {5BFA9F87-CB7C-360F-A763-812D3AAB4167} + {70245995-311C-4CBE-A433-F4035303BEDB} = {0AB3BF05-4346-4AA6-1389-037BE0695223} EndGlobalSection EndGlobal diff --git a/native/physics-native/CMakeLists.txt b/native/physics-native/CMakeLists.txt new file mode 100644 index 0000000..3486ec8 --- /dev/null +++ b/native/physics-native/CMakeLists.txt @@ -0,0 +1,31 @@ +cmake_minimum_required(VERSION 3.16) +project(lingua_physics_native C) + +set(CMAKE_C_STANDARD 11) +set(CMAKE_POSITION_INDEPENDENT_CODE ON) + +# Box3D built static and folded into our own shared library, so the engine +# ships and P/Invokes exactly one native file (lingua_physics.so/.dll), not +# two. Pinned to a specific commit, not a tag or branch: Box3D has no 1.0 +# release yet and its API is still moving (confirmed by diffing its only +# tag, v0.1.0, against this commit — real, not hypothetical, drift), so a +# floating reference would silently start building against a different API +# than the one this shim's code was actually written and verified against. +set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE) +set(BOX3D_SAMPLES OFF CACHE BOOL "" FORCE) +set(BOX3D_UNIT_TESTS OFF CACHE BOOL "" FORCE) +set(BOX3D_BENCHMARKS OFF CACHE BOOL "" FORCE) +set(BOX3D_DOCS OFF CACHE BOOL "" FORCE) + +include(FetchContent) +FetchContent_Declare( + box3d + GIT_REPOSITORY https://github.com/erincatto/box3d.git + GIT_TAG 47d7f7cc7e091142c08d11dc7d2e493c5d34f536 +) +FetchContent_MakeAvailable(box3d) + +add_library(lingua_physics SHARED lingua_physics.c) +target_link_libraries(lingua_physics PRIVATE box3d) + +install(TARGETS lingua_physics RUNTIME DESTINATION . LIBRARY DESTINATION .) diff --git a/native/physics-native/lingua_physics.c b/native/physics-native/lingua_physics.c new file mode 100644 index 0000000..c2b98f5 --- /dev/null +++ b/native/physics-native/lingua_physics.c @@ -0,0 +1,228 @@ +// A deliberately narrow C shim between Box3D's real API and P/Invoke. +// +// Box3D's own API is not safe to bind directly from C#: b3WorldDef, +// b3BodyDef, and b3ShapeDef are large structs with function pointers and +// nested types passed by value, and b3BoxHull is a struct whose own doc +// comment says "has data hanging off the end and cannot be directly +// copied." Replicating any of that layout byte-for-byte in C# is exactly +// the kind of "compiles, passes a smoke test, breaks on someone else's +// machine" risk docs/kernel-contract.md's own risk table warns about for +// native interop specifically. So none of it crosses the P/Invoke +// boundary: every exported function here takes and returns only plain +// int32_t/float scalars, which marshal automatically with no `unsafe` on +// the C# side at all. +// +// Handles are plain array indices into fixed-capacity tables, not Box3D's +// own id structs — one less thing to get the marshaling of exactly right, +// and plenty of headroom for an indie-scale game (Box3D itself caps out at +// 128 worlds; nothing here needs more than one at a time yet). + +#include +#include +#include + +#if defined( _WIN32 ) +#define LINGUA_API __declspec( dllexport ) +#else +#define LINGUA_API __attribute__( ( visibility( "default" ) ) ) +#endif + +#define LINGUA_MAX_WORLDS 8 +#define LINGUA_MAX_BODIES 8192 + +static b3WorldId g_worlds[LINGUA_MAX_WORLDS]; +static bool g_worldUsed[LINGUA_MAX_WORLDS]; + +static b3BodyId g_bodies[LINGUA_MAX_BODIES]; +static bool g_bodyUsed[LINGUA_MAX_BODIES]; + +static bool ValidWorld( int32_t handle ) +{ + return handle >= 0 && handle < LINGUA_MAX_WORLDS && g_worldUsed[handle]; +} + +static bool ValidBody( int32_t handle ) +{ + return handle >= 0 && handle < LINGUA_MAX_BODIES && g_bodyUsed[handle]; +} + +static int32_t AllocBodySlot( void ) +{ + for ( int32_t i = 0; i < LINGUA_MAX_BODIES; i++ ) + { + if ( !g_bodyUsed[i] ) + return i; + } + return -1; +} + +LINGUA_API int32_t Lingua_CreateWorld( float gravityX, float gravityY, float gravityZ ) +{ + for ( int32_t i = 0; i < LINGUA_MAX_WORLDS; i++ ) + { + if ( g_worldUsed[i] ) + continue; + + b3WorldDef def = b3DefaultWorldDef(); + def.gravity = ( b3Vec3 ){ gravityX, gravityY, gravityZ }; + + g_worlds[i] = b3CreateWorld( &def ); + g_worldUsed[i] = true; + return i; + } + + return -1; +} + +LINGUA_API void Lingua_DestroyWorld( int32_t worldHandle ) +{ + if ( !ValidWorld( worldHandle ) ) + return; + + b3DestroyWorld( g_worlds[worldHandle] ); + g_worldUsed[worldHandle] = false; +} + +LINGUA_API void Lingua_WorldStep( int32_t worldHandle, float timeStep, int32_t subStepCount ) +{ + if ( !ValidWorld( worldHandle ) ) + return; + + b3World_Step( g_worlds[worldHandle], timeStep, subStepCount ); +} + +static int32_t CreateBody( + int32_t worldHandle, + float px, float py, float pz, + float qx, float qy, float qz, float qw, + int32_t bodyType ) +{ + if ( !ValidWorld( worldHandle ) ) + return -1; + + int32_t slot = AllocBodySlot(); + if ( slot < 0 ) + return -1; + + b3BodyDef bodyDef = b3DefaultBodyDef(); + bodyDef.type = (b3BodyType)bodyType; + bodyDef.position = ( b3Vec3 ){ px, py, pz }; + bodyDef.rotation = ( b3Quat ){ { qx, qy, qz }, qw }; + + g_bodies[slot] = b3CreateBody( g_worlds[worldHandle], &bodyDef ); + g_bodyUsed[slot] = true; + return slot; +} + +static void ShapeDef( float density, float friction, float restitution, b3ShapeDef* def ) +{ + *def = b3DefaultShapeDef(); + def->density = density; + def->baseMaterial.friction = friction; + def->baseMaterial.restitution = restitution; +} + +LINGUA_API int32_t Lingua_CreateBoxBody( + int32_t worldHandle, + float px, float py, float pz, + float qx, float qy, float qz, float qw, + float halfWidth, float halfHeight, float halfDepth, + int32_t bodyType, float density, float friction, float restitution ) +{ + int32_t handle = CreateBody( worldHandle, px, py, pz, qx, qy, qz, qw, bodyType ); + if ( handle < 0 ) + return -1; + + b3ShapeDef shapeDef; + ShapeDef( density, friction, restitution, &shapeDef ); + + b3BoxHull hull = b3MakeBoxHull( halfWidth, halfHeight, halfDepth ); + b3CreateHullShape( g_bodies[handle], &shapeDef, &hull.base ); + + return handle; +} + +LINGUA_API int32_t Lingua_CreateSphereBody( + int32_t worldHandle, + float px, float py, float pz, + float qx, float qy, float qz, float qw, + float radius, + int32_t bodyType, float density, float friction, float restitution ) +{ + int32_t handle = CreateBody( worldHandle, px, py, pz, qx, qy, qz, qw, bodyType ); + if ( handle < 0 ) + return -1; + + b3ShapeDef shapeDef; + ShapeDef( density, friction, restitution, &shapeDef ); + + b3Sphere sphere = { ( b3Vec3 ){ 0, 0, 0 }, radius }; + b3CreateSphereShape( g_bodies[handle], &shapeDef, &sphere ); + + return handle; +} + +LINGUA_API void Lingua_DestroyBody( int32_t bodyHandle ) +{ + if ( !ValidBody( bodyHandle ) ) + return; + + b3DestroyBody( g_bodies[bodyHandle] ); + g_bodyUsed[bodyHandle] = false; +} + +LINGUA_API void Lingua_GetBodyTransform( + int32_t bodyHandle, + float* outPx, float* outPy, float* outPz, + float* outQx, float* outQy, float* outQz, float* outQw ) +{ + if ( !ValidBody( bodyHandle ) ) + return; + + b3WorldTransform t = b3Body_GetTransform( g_bodies[bodyHandle] ); + *outPx = t.p.x; + *outPy = t.p.y; + *outPz = t.p.z; + *outQx = t.q.v.x; + *outQy = t.q.v.y; + *outQz = t.q.v.z; + *outQw = t.q.s; +} + +LINGUA_API void Lingua_SetBodyTransform( + int32_t bodyHandle, + float px, float py, float pz, + float qx, float qy, float qz, float qw ) +{ + if ( !ValidBody( bodyHandle ) ) + return; + + b3Body_SetTransform( g_bodies[bodyHandle], ( b3Pos ){ px, py, pz }, ( b3Quat ){ { qx, qy, qz }, qw } ); +} + +LINGUA_API void Lingua_ApplyLinearImpulse( int32_t bodyHandle, float ix, float iy, float iz, bool wake ) +{ + if ( !ValidBody( bodyHandle ) ) + return; + + b3Body_ApplyLinearImpulseToCenter( g_bodies[bodyHandle], ( b3Vec3 ){ ix, iy, iz }, wake ); +} + +LINGUA_API void Lingua_GetLinearVelocity( int32_t bodyHandle, float* outVx, float* outVy, float* outVz ) +{ + if ( !ValidBody( bodyHandle ) ) + return; + + b3Vec3 v = b3Body_GetLinearVelocity( g_bodies[bodyHandle] ); + *outVx = v.x; + *outVy = v.y; + *outVz = v.z; +} + +LINGUA_API void Lingua_SetLinearVelocity( int32_t bodyHandle, float vx, float vy, float vz ) +{ + if ( !ValidBody( bodyHandle ) ) + return; + + b3Body_SetLinearVelocity( g_bodies[bodyHandle], ( b3Vec3 ){ vx, vy, vz } ); +} diff --git a/plugins/engine.physics/Engine.Physics.Contracts/BodyType.cs b/plugins/engine.physics/Engine.Physics.Contracts/BodyType.cs new file mode 100644 index 0000000..65070b0 --- /dev/null +++ b/plugins/engine.physics/Engine.Physics.Contracts/BodyType.cs @@ -0,0 +1,13 @@ +namespace Engine.Physics.Contracts; + +/// +/// Values match Box3D's own b3BodyType exactly (Static=0, Kinematic=1, +/// Dynamic=2) so engine.physics can cast this straight into the native +/// shim's bodyType parameter with no translation table to keep in sync. +/// +public enum BodyType +{ + Static = 0, + Kinematic = 1, + Dynamic = 2, +} diff --git a/plugins/engine.physics/Engine.Physics.Contracts/BoxCollider.cs b/plugins/engine.physics/Engine.Physics.Contracts/BoxCollider.cs new file mode 100644 index 0000000..d3d1001 --- /dev/null +++ b/plugins/engine.physics/Engine.Physics.Contracts/BoxCollider.cs @@ -0,0 +1,15 @@ +using System.Numerics; +using Engine.Kernel.World; + +namespace Engine.Physics.Contracts; + +/// +/// A box collision shape, sized in half-extents (distance from center to +/// each face) — Box3D's own convention (b3MakeBoxHull's hx/hy/hz), kept +/// as-is rather than doubled into a "full size" field so there's no /2 +/// conversion to get subtly wrong between here and the native shim. +/// +public sealed class BoxCollider : Component +{ + public Vector3 HalfExtents = new(0.5f, 0.5f, 0.5f); +} diff --git a/plugins/engine.physics/Engine.Physics.Contracts/Engine.Physics.Contracts.csproj b/plugins/engine.physics/Engine.Physics.Contracts/Engine.Physics.Contracts.csproj new file mode 100644 index 0000000..bb93c00 --- /dev/null +++ b/plugins/engine.physics/Engine.Physics.Contracts/Engine.Physics.Contracts.csproj @@ -0,0 +1,5 @@ + + + + + diff --git a/plugins/engine.physics/Engine.Physics.Contracts/IPhysicsService.cs b/plugins/engine.physics/Engine.Physics.Contracts/IPhysicsService.cs new file mode 100644 index 0000000..b765e73 --- /dev/null +++ b/plugins/engine.physics/Engine.Physics.Contracts/IPhysicsService.cs @@ -0,0 +1,21 @@ +using System.Numerics; +using Engine.Kernel.World; + +namespace Engine.Physics.Contracts; + +/// +/// Gameplay-facing physics actions — impulses and velocity — for a +/// GameObject that already has a Rigidbody. Separate from just poking +/// Rigidbody's own fields because these aren't state the Inspector should +/// show as persistent data (a velocity is a live simulation value, not +/// something a scene file should round-trip) and because applying an +/// impulse isn't a field write at all, it's an action Box3D performs. +/// +public interface IPhysicsService +{ + void ApplyLinearImpulse(GameObject go, Vector3 impulse, bool wake = true); + + Vector3 GetLinearVelocity(GameObject go); + + void SetLinearVelocity(GameObject go, Vector3 velocity); +} diff --git a/plugins/engine.physics/Engine.Physics.Contracts/Rigidbody.cs b/plugins/engine.physics/Engine.Physics.Contracts/Rigidbody.cs new file mode 100644 index 0000000..61b7e72 --- /dev/null +++ b/plugins/engine.physics/Engine.Physics.Contracts/Rigidbody.cs @@ -0,0 +1,25 @@ +using Engine.Kernel.World; + +namespace Engine.Physics.Contracts; + +/// +/// Marks a GameObject as physics-simulated. Needs a BoxCollider or +/// SphereCollider on the same GameObject to actually get a body — Rigidbody +/// alone describes how it moves, not what shape it collides as, same split +/// Box3D itself makes between a body and its shapes. +/// +/// Physics-enabled GameObjects must be root-level (no parent) for now: +/// PhysicsWorld writes Box3D's world-space transform straight into +/// Transform.LocalPosition/LocalRotation, which is only correct when local +/// and world space are the same thing. A parented rigidbody would need the +/// same parent-WorldMatrix-inverse handling GizmoMath.WorldToLocalPosition +/// already does for the gizmo — real work, not done here because nothing +/// in M4's demo needs a physics object with a parent yet. +/// +public sealed class Rigidbody : Component +{ + public BodyType Type = BodyType.Dynamic; + public float Density = 1f; + public float Friction = 0.5f; + public float Restitution = 0.1f; +} diff --git a/plugins/engine.physics/Engine.Physics.Contracts/SphereCollider.cs b/plugins/engine.physics/Engine.Physics.Contracts/SphereCollider.cs new file mode 100644 index 0000000..4f7e472 --- /dev/null +++ b/plugins/engine.physics/Engine.Physics.Contracts/SphereCollider.cs @@ -0,0 +1,8 @@ +using Engine.Kernel.World; + +namespace Engine.Physics.Contracts; + +public sealed class SphereCollider : Component +{ + public float Radius = 0.5f; +} diff --git a/plugins/engine.physics/Engine.Physics/AssemblyInfo.cs b/plugins/engine.physics/Engine.Physics/AssemblyInfo.cs new file mode 100644 index 0000000..4b3051c --- /dev/null +++ b/plugins/engine.physics/Engine.Physics/AssemblyInfo.cs @@ -0,0 +1,7 @@ +using System.Runtime.CompilerServices; + +// PhysicsWorld is internal — nothing outside this plugin needs it, real +// physics behavior is tested against it directly rather than only through +// IPlugin/IPhysicsService's much narrower surface. Same pattern as +// Engine.Assets' PngReader. +[assembly: InternalsVisibleTo("Engine.Physics.Tests")] diff --git a/plugins/engine.physics/Engine.Physics/Engine.Physics.csproj b/plugins/engine.physics/Engine.Physics/Engine.Physics.csproj new file mode 100644 index 0000000..bbbe534 --- /dev/null +++ b/plugins/engine.physics/Engine.Physics/Engine.Physics.csproj @@ -0,0 +1,31 @@ + + + + + true + + + + + + liblingua_physics.so + PreserveNewest + + + + + + + + + diff --git a/plugins/engine.physics/Engine.Physics/Native.cs b/plugins/engine.physics/Engine.Physics/Native.cs new file mode 100644 index 0000000..59e4931 --- /dev/null +++ b/plugins/engine.physics/Engine.Physics/Native.cs @@ -0,0 +1,72 @@ +using System.Runtime.InteropServices; + +namespace Engine.Physics; + +/// +/// The entire P/Invoke surface, matching native/physics-native/ +/// lingua_physics.c's exports one-to-one. Every parameter is a plain +/// int/float/bool — no struct crosses this boundary, on purpose, see that +/// file's own doc comment for why. +/// +/// Classic DllImport, not the newer source-generated LibraryImport: +/// LibraryImport's generated marshalling code needs AllowUnsafeBlocks even +/// for an all-blittable signature like every one of these, and there's no +/// reason to open that door — DllImport marshals plain int/float/bool/out +/// float scalars with no unsafe code at all, which is what actually keeps +/// this plugin inside the kernel's "no unsafe in the v1 hot path" rule +/// (Directory.Build.props) instead of needing a narrow exception to it. +/// +internal static class Native +{ + private const string Lib = "lingua_physics"; + + [DllImport(Lib)] + public static extern int Lingua_CreateWorld(float gravityX, float gravityY, float gravityZ); + + [DllImport(Lib)] + public static extern void Lingua_DestroyWorld(int worldHandle); + + [DllImport(Lib)] + public static extern void Lingua_WorldStep(int worldHandle, float timeStep, int subStepCount); + + [DllImport(Lib)] + public static extern int Lingua_CreateBoxBody( + int worldHandle, + float px, float py, float pz, + float qx, float qy, float qz, float qw, + float halfWidth, float halfHeight, float halfDepth, + int bodyType, float density, float friction, float restitution); + + [DllImport(Lib)] + public static extern int Lingua_CreateSphereBody( + int worldHandle, + float px, float py, float pz, + float qx, float qy, float qz, float qw, + float radius, + int bodyType, float density, float friction, float restitution); + + [DllImport(Lib)] + public static extern void Lingua_DestroyBody(int bodyHandle); + + [DllImport(Lib)] + public static extern void Lingua_GetBodyTransform( + int bodyHandle, + out float px, out float py, out float pz, + out float qx, out float qy, out float qz, out float qw); + + [DllImport(Lib)] + public static extern void Lingua_SetBodyTransform( + int bodyHandle, + float px, float py, float pz, + float qx, float qy, float qz, float qw); + + [DllImport(Lib)] + public static extern void Lingua_ApplyLinearImpulse( + int bodyHandle, float ix, float iy, float iz, [MarshalAs(UnmanagedType.U1)] bool wake); + + [DllImport(Lib)] + public static extern void Lingua_GetLinearVelocity(int bodyHandle, out float vx, out float vy, out float vz); + + [DllImport(Lib)] + public static extern void Lingua_SetLinearVelocity(int bodyHandle, float vx, float vy, float vz); +} diff --git a/plugins/engine.physics/Engine.Physics/PhysicsPlugin.cs b/plugins/engine.physics/Engine.Physics/PhysicsPlugin.cs new file mode 100644 index 0000000..27c046d --- /dev/null +++ b/plugins/engine.physics/Engine.Physics/PhysicsPlugin.cs @@ -0,0 +1,54 @@ +using System.Numerics; +using Engine.Kernel.Diagnostics; +using Engine.Kernel.Plugins; +using Engine.Kernel.Scheduling; +using Engine.Kernel.World; +using Engine.Physics.Contracts; + +namespace Engine.Physics; + +/// +/// M4's physics plugin: Box3D, over the shim in native/physics-native/ — +/// see that file's own doc comment for why nothing crosses the P/Invoke +/// boundary but plain scalars. One system, on Stage.FixedUpdate: sync new/ +/// removed Rigidbodies, step once, write results back to Transform. Each +/// FixedUpdate invocation is exactly one physics step at ITime. +/// FixedDeltaTime — Engine.Host's accumulator (Time.ConsumeFixedSteps) +/// decides how many times that runs this frame, not this plugin. +/// +public sealed class PhysicsPlugin : IPlugin +{ + private static readonly Vector3 Gravity = new(0f, -9.81f, 0f); + + private PhysicsWorld? _world; + private ITime? _time; + + public void Configure(IPluginContext ctx) + { + _time = ctx.Time; + _world = new PhysicsWorld(Gravity, ctx.Log); + ctx.Services.Provide(new PhysicsService(_world)); + + ctx.Schedule.Add(Stage.FixedUpdate, Step) + .Reads() + .Reads() + .Reads(); + + ctx.Log.Info("physics world ready (Box3D)"); + } + + public void Shutdown(IPluginContext ctx) + { + ctx.Schedule.RemoveAllFrom("engine.physics"); + ctx.Services.Revoke(); + _world?.Dispose(); + _world = null; + _time = null; + } + + private void Step(IWorld world) + { + _world!.Sync(world); + _world.Step(_time!.FixedDeltaTime); + } +} diff --git a/plugins/engine.physics/Engine.Physics/PhysicsService.cs b/plugins/engine.physics/Engine.Physics/PhysicsService.cs new file mode 100644 index 0000000..63e3387 --- /dev/null +++ b/plugins/engine.physics/Engine.Physics/PhysicsService.cs @@ -0,0 +1,15 @@ +using System.Numerics; +using Engine.Kernel.World; +using Engine.Physics.Contracts; + +namespace Engine.Physics; + +internal sealed class PhysicsService(PhysicsWorld world) : IPhysicsService +{ + public void ApplyLinearImpulse(GameObject go, Vector3 impulse, bool wake = true) => + world.ApplyLinearImpulse(go, impulse, wake); + + public Vector3 GetLinearVelocity(GameObject go) => world.GetLinearVelocity(go); + + public void SetLinearVelocity(GameObject go, Vector3 velocity) => world.SetLinearVelocity(go, velocity); +} diff --git a/plugins/engine.physics/Engine.Physics/PhysicsWorld.cs b/plugins/engine.physics/Engine.Physics/PhysicsWorld.cs new file mode 100644 index 0000000..aeb5427 --- /dev/null +++ b/plugins/engine.physics/Engine.Physics/PhysicsWorld.cs @@ -0,0 +1,130 @@ +using System.Numerics; +using Engine.Kernel.Diagnostics; +using Engine.Kernel.World; +using Engine.Physics.Contracts; + +namespace Engine.Physics; + +/// +/// Owns the one native Box3D world this plugin creates, and the GameObject +/// <-> native body handle mapping. creates bodies for +/// any GameObject that's grown a Rigidbody since last frame and destroys +/// bodies for any that lost one (component removed, or the GameObject +/// itself was destroyed) — there's no destruction event to hook, so this +/// diffs Query<Rigidbody>() against the tracked set every FixedUpdate +/// instead. advances the simulation once and writes +/// every tracked body's new transform back into its GameObject. +/// +internal sealed class PhysicsWorld : IDisposable +{ + private readonly int _handle; + private readonly ILogger _log; + private readonly Dictionary _bodies = []; + private readonly HashSet _warnedMissingCollider = []; + + public PhysicsWorld(Vector3 gravity, ILogger log) + { + _log = log; + _handle = Native.Lingua_CreateWorld(gravity.X, gravity.Y, gravity.Z); + } + + public void Sync(IWorld world) + { + var live = new HashSet(); + + foreach (var go in world.Query()) + { + live.Add(go); + if (!_bodies.ContainsKey(go)) + TryCreateBody(go); + } + + if (_bodies.Count == live.Count) + return; + + foreach (var stale in _bodies.Keys.Where(go => !live.Contains(go)).ToList()) + { + Native.Lingua_DestroyBody(_bodies[stale]); + _bodies.Remove(stale); + _warnedMissingCollider.Remove(stale); + } + } + + public void Step(float timeStep, int subStepCount = 4) + { + Native.Lingua_WorldStep(_handle, timeStep, subStepCount); + + foreach (var (go, handle) in _bodies) + { + Native.Lingua_GetBodyTransform(handle, out var px, out var py, out var pz, out var qx, out var qy, out var qz, out var qw); + + var t = go.Transform; + t.LocalPosition = new Vector3(px, py, pz); + t.LocalRotation = new Quaternion(qx, qy, qz, qw); + go.Transform = t; + } + } + + public void ApplyLinearImpulse(GameObject go, Vector3 impulse, bool wake) + { + if (_bodies.TryGetValue(go, out var handle)) + Native.Lingua_ApplyLinearImpulse(handle, impulse.X, impulse.Y, impulse.Z, wake); + } + + public Vector3 GetLinearVelocity(GameObject go) + { + if (!_bodies.TryGetValue(go, out var handle)) + return Vector3.Zero; + + Native.Lingua_GetLinearVelocity(handle, out var vx, out var vy, out var vz); + return new Vector3(vx, vy, vz); + } + + public void SetLinearVelocity(GameObject go, Vector3 velocity) + { + if (_bodies.TryGetValue(go, out var handle)) + Native.Lingua_SetLinearVelocity(handle, velocity.X, velocity.Y, velocity.Z); + } + + private void TryCreateBody(GameObject go) + { + var rb = go.GetComponent()!; + var pos = go.Transform.LocalPosition; + var rot = go.Transform.LocalRotation; + + var box = go.GetComponent(); + var sphere = go.GetComponent(); + + int handle; + if (box is not null) + { + handle = Native.Lingua_CreateBoxBody( + _handle, pos.X, pos.Y, pos.Z, rot.X, rot.Y, rot.Z, rot.W, + box.HalfExtents.X, box.HalfExtents.Y, box.HalfExtents.Z, + (int)rb.Type, rb.Density, rb.Friction, rb.Restitution); + } + else if (sphere is not null) + { + handle = Native.Lingua_CreateSphereBody( + _handle, pos.X, pos.Y, pos.Z, rot.X, rot.Y, rot.Z, rot.W, + sphere.Radius, (int)rb.Type, rb.Density, rb.Friction, rb.Restitution); + } + else + { + if (_warnedMissingCollider.Add(go)) + _log.Warn($"'{go.Name}' has a Rigidbody but no BoxCollider/SphereCollider — no physics body created."); + return; + } + + _bodies[go] = handle; + } + + public void Dispose() + { + foreach (var handle in _bodies.Values) + Native.Lingua_DestroyBody(handle); + + _bodies.Clear(); + Native.Lingua_DestroyWorld(_handle); + } +} diff --git a/plugins/engine.physics/Engine.Physics/native/liblingua_physics.so b/plugins/engine.physics/Engine.Physics/native/liblingua_physics.so new file mode 100755 index 0000000..de72e27 Binary files /dev/null and b/plugins/engine.physics/Engine.Physics/native/liblingua_physics.so differ diff --git a/plugins/engine.physics/plugin.json b/plugins/engine.physics/plugin.json new file mode 100644 index 0000000..b4b835d --- /dev/null +++ b/plugins/engine.physics/plugin.json @@ -0,0 +1,8 @@ +{ + "id": "engine.physics", + "version": "0.1.0", + "contracts": "Engine.Physics.Contracts.dll", + "assembly": "Engine.Physics.dll", + "dependsOn": {}, + "reloadable": true +} diff --git a/tests/Engine.Physics.Tests/Engine.Physics.Tests.csproj b/tests/Engine.Physics.Tests/Engine.Physics.Tests.csproj new file mode 100644 index 0000000..e707051 --- /dev/null +++ b/tests/Engine.Physics.Tests/Engine.Physics.Tests.csproj @@ -0,0 +1,33 @@ + + + + false + + + + + + + + + + + + + + + + + liblingua_physics.so + PreserveNewest + + + + + + + + diff --git a/tests/Engine.Physics.Tests/PhysicsWorldTests.cs b/tests/Engine.Physics.Tests/PhysicsWorldTests.cs new file mode 100644 index 0000000..7c4a561 --- /dev/null +++ b/tests/Engine.Physics.Tests/PhysicsWorldTests.cs @@ -0,0 +1,147 @@ +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 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().Type = BodyType.Static; + go.AddComponent().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().Type = BodyType.Dynamic; + box.AddComponent().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().Type = BodyType.Dynamic; + sphere.AddComponent().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(); + + 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().Type = BodyType.Dynamic; + box.AddComponent(); + + 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().Type = BodyType.Dynamic; + box.AddComponent(); + + 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}"); + } +}