diff --git a/.cargo/config.toml b/.cargo/config.toml index 949d1ed002f..cb4793f3221 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -14,6 +14,4 @@ smoketests = "smoketest" # I (@bfops) tried a variety of other link options besides switching linkers, but this # seems to be the only thing that worked. linker = "lld-link" -# Without this, the linker complains that libc functions are undefined - -# it probably signals to rustc and lld-link that libucrt should be included. rustflags = ["-Ctarget-feature=+crt-static"] diff --git a/Cargo.lock b/Cargo.lock index 95fd0e6e1c3..533758b9160 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -482,6 +482,29 @@ dependencies = [ "serde", ] +[[package]] +name = "bindgen" +version = "0.69.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" +dependencies = [ + "bitflags 2.10.0", + "cexpr", + "clang-sys", + "itertools 0.12.1", + "lazy_static", + "lazycell", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex", + "syn 2.0.107", + "which 4.4.2", +] + [[package]] name = "bindgen" version = "0.72.1" @@ -497,7 +520,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "rustc-hash", + "rustc-hash 2.1.1", "shlex", "syn 2.0.107", ] @@ -788,9 +811,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.41" +version = "1.2.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac9fe6cdbb24b6ade63616c0a0688e45bb56732262c158df3c0c4bea4ca47cb7" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" dependencies = [ "find-msvc-tools", "jobserver", @@ -1021,6 +1044,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + [[package]] name = "cobs" version = "0.3.0" @@ -1259,7 +1291,7 @@ dependencies = [ "log", "pulley-interpreter", "regalloc2", - "rustc-hash", + "rustc-hash 2.1.1", "serde", "smallvec", "target-lexicon", @@ -2186,9 +2218,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.4" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "fixedbitset" @@ -2533,6 +2565,18 @@ dependencies = [ "url", ] +[[package]] +name = "glam" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8babf46d4c1c9d92deac9f7be466f76dfc4482b6452fc5024b5e8daf6ffeb3ee" + +[[package]] +name = "glam" +version = "0.30.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19fc433e8437a212d1b6f1e68c7824af3aed907da60afa994e7f542d18d12aa9" + [[package]] name = "glob" version = "0.3.3" @@ -3502,6 +3546,17 @@ dependencies = [ "libc", ] +[[package]] +name = "joltc-sys" +version = "0.3.1+Jolt-5.0.0" +source = "git+https://github.com/pbisogno/jolt-rust?branch=feature%2Fspacetimedb#781042924ea20873b9698c9fe9d0e8616eb44ff3" +dependencies = [ + "anyhow", + "bindgen 0.69.5", + "cmake", + "walkdir", +] + [[package]] name = "js-sys" version = "0.3.81" @@ -3614,6 +3669,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + [[package]] name = "lean_string" version = "0.5.1" @@ -4096,7 +4157,7 @@ version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "496f264d3ead4870d6b366deb9d20597592d64aac2a907f3e7d07c2325ba4663" dependencies = [ - "bindgen", + "bindgen 0.72.1", "bitflags 2.10.0", "byteorder", "netlink-packet-core", @@ -4561,7 +4622,7 @@ checksum = "75b1853bc34cadaa90aa09f95713d8b77ec0c0d3e2d90ccf7a74216f40d20850" dependencies = [ "flate2", "postcard", - "rustc-hash", + "rustc-hash 2.1.1", "serde", "serde_json", "thiserror 2.0.17", @@ -4603,7 +4664,7 @@ dependencies = [ "hashbrown 0.16.1", "oxc_data_structures", "oxc_estree", - "rustc-hash", + "rustc-hash 2.1.1", "serde", ] @@ -4659,7 +4720,7 @@ dependencies = [ "oxc_index", "oxc_syntax", "petgraph 0.8.3", - "rustc-hash", + "rustc-hash 2.1.1", ] [[package]] @@ -4680,7 +4741,7 @@ dependencies = [ "oxc_sourcemap", "oxc_span", "oxc_syntax", - "rustc-hash", + "rustc-hash 2.1.1", ] [[package]] @@ -4692,7 +4753,7 @@ dependencies = [ "cow-utils", "oxc-browserslist", "oxc_syntax", - "rustc-hash", + "rustc-hash 2.1.1", "serde", ] @@ -4768,7 +4829,7 @@ dependencies = [ "oxc_ecmascript", "oxc_span", "oxc_syntax", - "rustc-hash", + "rustc-hash 2.1.1", ] [[package]] @@ -4785,7 +4846,7 @@ dependencies = [ "oxc_semantic", "oxc_span", "oxc_syntax", - "rustc-hash", + "rustc-hash 2.1.1", ] [[package]] @@ -4809,7 +4870,7 @@ dependencies = [ "oxc_span", "oxc_syntax", "oxc_traverse", - "rustc-hash", + "rustc-hash 2.1.1", ] [[package]] @@ -4831,7 +4892,7 @@ dependencies = [ "oxc_regular_expression", "oxc_span", "oxc_syntax", - "rustc-hash", + "rustc-hash 2.1.1", "seq-macro", ] @@ -4847,7 +4908,7 @@ dependencies = [ "oxc_diagnostics", "oxc_span", "phf", - "rustc-hash", + "rustc-hash 2.1.1", "unicode-id-start", ] @@ -4866,7 +4927,7 @@ dependencies = [ "papaya", "parking_lot 0.12.5", "pnp", - "rustc-hash", + "rustc-hash 2.1.1", "rustix 1.1.2", "self_cell", "serde", @@ -4897,7 +4958,7 @@ dependencies = [ "oxc_index", "oxc_span", "oxc_syntax", - "rustc-hash", + "rustc-hash 2.1.1", "self_cell", "smallvec", ] @@ -4910,7 +4971,7 @@ checksum = "36801dbbd025f2fa133367494e38eef75a53d334ae6746ba0c889fc4e76fa3a3" dependencies = [ "base64-simd", "json-escape-simd", - "rustc-hash", + "rustc-hash 2.1.1", "serde", "serde_json", ] @@ -4986,7 +5047,7 @@ dependencies = [ "oxc_span", "oxc_syntax", "oxc_traverse", - "rustc-hash", + "rustc-hash 2.1.1", "serde", "serde_json", "sha1", @@ -5011,7 +5072,7 @@ dependencies = [ "oxc_syntax", "oxc_transformer", "oxc_traverse", - "rustc-hash", + "rustc-hash 2.1.1", ] [[package]] @@ -5029,7 +5090,7 @@ dependencies = [ "oxc_semantic", "oxc_span", "oxc_syntax", - "rustc-hash", + "rustc-hash 2.1.1", ] [[package]] @@ -5394,7 +5455,7 @@ dependencies = [ "nodejs-built-in-modules 1.0.0", "pathdiff", "radix_trie 0.3.0", - "rustc-hash", + "rustc-hash 2.1.1", "serde", "serde_json", "thiserror 2.0.17", @@ -6077,7 +6138,7 @@ dependencies = [ "bumpalo", "hashbrown 0.15.5", "log", - "rustc-hash", + "rustc-hash 2.1.1", "smallvec", ] @@ -6379,7 +6440,7 @@ dependencies = [ "rolldown_std_utils", "rolldown_tracing", "rolldown_utils", - "rustc-hash", + "rustc-hash 2.1.1", "serde", "serde_json", "string_wizard", @@ -6447,7 +6508,7 @@ dependencies = [ "rolldown_sourcemap", "rolldown_std_utils", "rolldown_utils", - "rustc-hash", + "rustc-hash 2.1.1", "serde", "serde_json", "simdutf8", @@ -6474,7 +6535,7 @@ dependencies = [ "blake3", "dashmap 6.1.0", "rolldown_devtools_action", - "rustc-hash", + "rustc-hash 2.1.1", "serde", "serde_json", "tracing", @@ -6528,7 +6589,7 @@ dependencies = [ "oxc_resolver", "rolldown-ariadne", "ropey", - "rustc-hash", + "rustc-hash 2.1.1", "sugar_path", ] @@ -6561,7 +6622,7 @@ dependencies = [ "rolldown_resolver", "rolldown_sourcemap", "rolldown_utils", - "rustc-hash", + "rustc-hash 2.1.1", "serde", "serde_json", "string_wizard", @@ -6580,7 +6641,7 @@ dependencies = [ "rolldown_common", "rolldown_plugin", "rolldown_utils", - "rustc-hash", + "rustc-hash 2.1.1", "serde_json", "xxhash-rust", ] @@ -6659,7 +6720,7 @@ dependencies = [ "oxc", "oxc_sourcemap", "rolldown_utils", - "rustc-hash", + "rustc-hash 2.1.1", ] [[package]] @@ -6709,7 +6770,7 @@ dependencies = [ "regress", "rolldown_error", "rolldown_std_utils", - "rustc-hash", + "rustc-hash 2.1.1", "serde_json", "simdutf8", "sugar_path", @@ -6718,6 +6779,16 @@ dependencies = [ "xxhash-rust", ] +[[package]] +name = "rolt" +version = "0.3.1+Jolt-5.0.0" +source = "git+https://github.com/pbisogno/jolt-rust?branch=feature%2Fspacetimedb#781042924ea20873b9698c9fe9d0e8616eb44ff3" +dependencies = [ + "glam 0.30.10", + "joltc-sys", + "paste", +] + [[package]] name = "ron" version = "0.8.1" @@ -6777,6 +6848,12 @@ version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc-hash" version = "2.1.1" @@ -7463,7 +7540,7 @@ dependencies = [ "data-encoding", "debugid", "if_chain", - "rustc-hash", + "rustc-hash 2.1.1", "serde", "serde_json", "unicode-id-start", @@ -7868,6 +7945,7 @@ dependencies = [ "fs_extra", "futures", "futures-util", + "glam 0.29.3", "hex", "hostname", "http 1.3.1", @@ -7876,6 +7954,7 @@ dependencies = [ "imara-diff", "indexmap 2.12.0", "itertools 0.12.1", + "joltc-sys", "lazy_static", "log", "memchr", @@ -7895,8 +7974,9 @@ dependencies = [ "rayon-core", "regex", "reqwest 0.12.24", + "rolt", "rustc-demangle", - "rustc-hash", + "rustc-hash 2.1.1", "scopeguard", "semver", "serde", @@ -8809,7 +8889,7 @@ dependencies = [ "oxc_index", "oxc_sourcemap", "regex", - "rustc-hash", + "rustc-hash 2.1.1", "serde", ] @@ -10049,7 +10129,7 @@ version = "145.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61d9a107e16bae35a0be2bb0096ac1d2318aac352c82edd796ab2b9cac66d8f0" dependencies = [ - "bindgen", + "bindgen 0.72.1", "bitflags 2.10.0", "fslock", "gzip-header", diff --git a/Cargo.toml b/Cargo.toml index e4066079213..60def7c063e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -350,6 +350,11 @@ tikv-jemalloc-ctl = { version = "0.6.0", features = ["stats"] } jemalloc_pprof = { version = "0.8", features = ["symbolize", "flamegraph"] } zstd-framed = { version = "0.1.1", features = ["tokio"] } +# Physics +rolt = { git = "https://github.com/pbisogno/jolt-rust", branch = "feature/spacetimedb" } +joltc-sys = { git = "https://github.com/pbisogno/jolt-rust", branch = "feature/spacetimedb" } +glam = "0.29" + # Vendor the openssl we rely on, rather than depend on a # potentially very old system version. openssl = { version = "0.10", features = ["vendored"] } @@ -399,3 +404,4 @@ result_large_err = "allow" # This silences a warning in our Nix flake, which otherwise would be upset that our call to `crateNameFromCargoToml` # is running against a file that doesn't have a name in it. crane.name = "spacetimedb" + diff --git a/build_check.ps1 b/build_check.ps1 new file mode 100644 index 00000000000..bebc11a6c56 --- /dev/null +++ b/build_check.ps1 @@ -0,0 +1,19 @@ +# Setup MSVC via vcvars64.bat +cmd /c "`"C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Auxiliary\Build\vcvars64.bat`" >nul 2>&1 && set" | ForEach-Object { + if ($_ -match "^([^=]+)=(.*)$") { + [System.Environment]::SetEnvironmentVariable($matches[1], $matches[2], "Process") + } +} + +# Ensure Strawberry Perl and lld-link are in PATH +$env:PATH = "C:\Strawberry\perl\bin;C:\Strawberry\c\bin;C:\Program Files\LLVM\bin;" + $env:PATH + +Write-Host "=== Perl ===" +& where.exe perl | Select-Object -First 1 +Write-Host "=== link.exe ===" +& where.exe link | Select-Object -First 1 +Write-Host "=== cl.exe ===" +& where.exe cl | Select-Object -First 1 + +Set-Location "C:\Users\Pedro\Documents\SpacetimeDB\SpacetimeDB" +cargo check -p spacetimedb-core 2>&1 diff --git a/crates/bindings-csharp/Runtime/Internal/FFI.cs b/crates/bindings-csharp/Runtime/Internal/FFI.cs index 9cf994e929b..eb9932e30d7 100644 --- a/crates/bindings-csharp/Runtime/Internal/FFI.cs +++ b/crates/bindings-csharp/Runtime/Internal/FFI.cs @@ -397,4 +397,135 @@ public static partial Errno procedure_http_request( uint body_len, out BytesSourcePair out_ ); + + // ==================== Physics (Jolt) ==================== + + const string StdbNamespace10_5 = +#if EXPERIMENTAL_WASM_AOT + "spacetime_10.5" +#else + "bindings" +#endif + ; + + [LibraryImport(StdbNamespace10_5, EntryPoint = "physics_create_world")] + public static partial CheckedStatus physics_create_world( + ReadOnlySpan gravity, // 3 x f32 = 12 bytes + out uint world_id + ); + + [LibraryImport(StdbNamespace10_5, EntryPoint = "physics_destroy_world")] + public static partial CheckedStatus physics_destroy_world(uint world_id); + + [LibraryImport(StdbNamespace10_5, EntryPoint = "physics_create_body")] + public static partial CheckedStatus physics_create_body( + uint world_id, + ReadOnlySpan body_params, // 54 bytes packed struct + out uint body_id + ); + + [LibraryImport(StdbNamespace10_5, EntryPoint = "physics_destroy_body")] + public static partial CheckedStatus physics_destroy_body(uint world_id, uint body_id); + + [LibraryImport(StdbNamespace10_5, EntryPoint = "physics_step")] + public static partial CheckedStatus physics_step( + uint world_id, + uint delta_time_bits, // f32 as bits + uint num_steps + ); + + [LibraryImport(StdbNamespace10_5, EntryPoint = "physics_get_body_transform")] + public static partial CheckedStatus physics_get_body_transform( + uint world_id, + uint body_id, + Span out_ // 7 x f32 = 28 bytes [pos.xyz, rot.xyzw] + ); + + [LibraryImport(StdbNamespace10_5, EntryPoint = "physics_get_body_velocity")] + public static partial CheckedStatus physics_get_body_velocity( + uint world_id, + uint body_id, + Span out_ // 3 x f32 = 12 bytes + ); + + [LibraryImport(StdbNamespace10_5, EntryPoint = "physics_set_body_position")] + public static partial CheckedStatus physics_set_body_position( + uint world_id, + uint body_id, + ReadOnlySpan position // 3 x f32 = 12 bytes + ); + + [LibraryImport(StdbNamespace10_5, EntryPoint = "physics_set_body_velocity")] + public static partial CheckedStatus physics_set_body_velocity( + uint world_id, + uint body_id, + ReadOnlySpan velocity // 3 x f32 = 12 bytes + ); + + [LibraryImport(StdbNamespace10_5, EntryPoint = "physics_set_body_rotation")] + public static partial CheckedStatus physics_set_body_rotation( + uint world_id, + uint body_id, + ReadOnlySpan rotation // 4 x f32 = 16 bytes + ); + + [LibraryImport(StdbNamespace10_5, EntryPoint = "physics_add_force")] + public static partial CheckedStatus physics_add_force( + uint world_id, + uint body_id, + ReadOnlySpan force // 3 x f32 = 12 bytes + ); + + [LibraryImport(StdbNamespace10_5, EntryPoint = "physics_add_impulse")] + public static partial CheckedStatus physics_add_impulse( + uint world_id, + uint body_id, + ReadOnlySpan impulse // 3 x f32 = 12 bytes + ); + + [LibraryImport(StdbNamespace10_5, EntryPoint = "physics_get_all_body_transforms")] + public static partial CheckedStatus physics_get_all_body_transforms( + uint world_id, + out uint bytes_source_id // BytesSource with packed 44-byte records + ); + + // ── Vehicle Physics ──────────────────────────────────────────── + + [LibraryImport(StdbNamespace10_5, EntryPoint = "physics_create_vehicle")] + public static partial CheckedStatus physics_create_vehicle( + uint world_id, + ReadOnlySpan params_ptr, + uint params_len, + ReadOnlySpan pos_ptr, // 3 x f32 = 12 bytes + ReadOnlySpan rot_ptr, // 4 x f32 = 16 bytes + out uint vehicle_id + ); + + [LibraryImport(StdbNamespace10_5, EntryPoint = "physics_set_vehicle_input")] + public static partial CheckedStatus physics_set_vehicle_input( + uint world_id, + uint vehicle_id, + uint forward_bits, + uint right_bits, + uint brake_bits, + uint handbrake_bits + ); + + [LibraryImport(StdbNamespace10_5, EntryPoint = "physics_get_vehicle_state")] + public static partial CheckedStatus physics_get_vehicle_state( + uint world_id, + uint vehicle_id, + out uint bytes_source_id + ); + + [LibraryImport(StdbNamespace10_5, EntryPoint = "physics_destroy_vehicle")] + public static partial CheckedStatus physics_destroy_vehicle( + uint world_id, + uint vehicle_id + ); + + [LibraryImport(StdbNamespace10_5, EntryPoint = "physics_get_vehicle_params_default")] + public static partial CheckedStatus physics_get_vehicle_params_default( + out uint bytes_source_id + ); } diff --git a/crates/bindings-csharp/Runtime/Physics.cs b/crates/bindings-csharp/Runtime/Physics.cs new file mode 100644 index 00000000000..033eb33ff41 --- /dev/null +++ b/crates/bindings-csharp/Runtime/Physics.cs @@ -0,0 +1,422 @@ +namespace SpacetimeDB; + +using System; +using System.Buffers.Binary; +using System.Runtime.InteropServices; +using SpacetimeDB.Internal; + +/// +/// Motion type for physics bodies. +/// +public enum PhysicsMotionType : byte +{ + Static = 0, + Kinematic = 1, + Dynamic = 2, +} + +/// +/// Shape type for physics collision shapes. +/// +public enum PhysicsShapeType : byte +{ + Sphere = 0, + Box = 1, + Capsule = 2, +} + +/// +/// Transform data for a physics body. +/// +public struct PhysicsTransform +{ + public float PosX, PosY, PosZ; + public float RotX, RotY, RotZ, RotW; +} + +/// +/// Bulk body state returned by GetAllBodyTransforms. +/// +public struct PhysicsBodyState +{ + public uint BodyId; + public float PosX, PosY, PosZ; + public float RotX, RotY, RotZ, RotW; + public float VelX, VelY, VelZ; +} + +/// +/// Jolt Physics integration for SpacetimeDB modules. +/// +/// The physics world lives in host memory (native performance) and is accessible +/// from any module language via host functions. State is volatile — modules must +/// persist authoritative data in tables and reconstruct the world on restart. +/// +/// Usage: +/// +/// // In your Init reducer: +/// var worldId = Physics.CreateWorld(0f, -9.81f, 0f); +/// +/// // Create a ground plane (static box) +/// var ground = Physics.CreateBody(worldId, PhysicsShapeType.Box, +/// halfExtents: (50f, 0.5f, 50f), +/// position: (0f, -0.5f, 0f), +/// motionType: PhysicsMotionType.Static); +/// +/// // Create a dynamic sphere +/// var ball = Physics.CreateBody(worldId, PhysicsShapeType.Sphere, +/// radius: 0.5f, +/// position: (0f, 10f, 0f), +/// motionType: PhysicsMotionType.Dynamic, +/// mass: 1f, restitution: 0.8f); +/// +/// // In your PhysicsTick reducer (30Hz): +/// Physics.Step(worldId, 1f / 30f); +/// var states = Physics.GetAllBodyTransforms(worldId); +/// foreach (var s in states) { /* update tables */ } +/// +/// +public static class Physics +{ + /// + /// Create a new physics world with the given gravity. + /// + public static uint CreateWorld(float gravityX, float gravityY, float gravityZ) + { + Span gravity = stackalloc byte[12]; + BinaryPrimitives.WriteSingleLittleEndian(gravity[0..], gravityX); + BinaryPrimitives.WriteSingleLittleEndian(gravity[4..], gravityY); + BinaryPrimitives.WriteSingleLittleEndian(gravity[8..], gravityZ); + FFI.physics_create_world(gravity, out var worldId); + return worldId; + } + + /// + /// Destroy a physics world and all its bodies. + /// + public static void DestroyWorld(uint worldId) + { + FFI.physics_destroy_world(worldId); + } + + /// + /// Create a sphere body. + /// + public static uint CreateBody( + uint worldId, + PhysicsShapeType shapeType, + float radius = 0.5f, + (float x, float y, float z) halfExtents = default, + float halfHeight = 0f, + (float x, float y, float z) position = default, + (float x, float y, float z, float w) rotation = default, + PhysicsMotionType motionType = PhysicsMotionType.Dynamic, + float mass = 1f, + float restitution = 0.3f, + float friction = 0.5f) + { + if (rotation == default) + rotation = (0f, 0f, 0f, 1f); // identity quaternion + + // Pack the 54-byte params struct + Span data = stackalloc byte[54]; + data[0] = (byte)shapeType; + + // shape_params: depends on type + switch (shapeType) + { + case PhysicsShapeType.Sphere: + BinaryPrimitives.WriteSingleLittleEndian(data[1..], radius); + BinaryPrimitives.WriteSingleLittleEndian(data[5..], 0f); + BinaryPrimitives.WriteSingleLittleEndian(data[9..], 0f); + break; + case PhysicsShapeType.Box: + BinaryPrimitives.WriteSingleLittleEndian(data[1..], halfExtents.x); + BinaryPrimitives.WriteSingleLittleEndian(data[5..], halfExtents.y); + BinaryPrimitives.WriteSingleLittleEndian(data[9..], halfExtents.z); + break; + case PhysicsShapeType.Capsule: + BinaryPrimitives.WriteSingleLittleEndian(data[1..], radius); + BinaryPrimitives.WriteSingleLittleEndian(data[5..], halfHeight); + BinaryPrimitives.WriteSingleLittleEndian(data[9..], 0f); + break; + } + + // position + BinaryPrimitives.WriteSingleLittleEndian(data[13..], position.x); + BinaryPrimitives.WriteSingleLittleEndian(data[17..], position.y); + BinaryPrimitives.WriteSingleLittleEndian(data[21..], position.z); + + // rotation (quaternion xyzw) + BinaryPrimitives.WriteSingleLittleEndian(data[25..], rotation.x); + BinaryPrimitives.WriteSingleLittleEndian(data[29..], rotation.y); + BinaryPrimitives.WriteSingleLittleEndian(data[33..], rotation.z); + BinaryPrimitives.WriteSingleLittleEndian(data[37..], rotation.w); + + // motion_type + data[41] = (byte)motionType; + + // mass, restitution, friction + BinaryPrimitives.WriteSingleLittleEndian(data[42..], mass); + BinaryPrimitives.WriteSingleLittleEndian(data[46..], restitution); + BinaryPrimitives.WriteSingleLittleEndian(data[50..], friction); + + FFI.physics_create_body(worldId, data, out var bodyId); + return bodyId; + } + + /// + /// Destroy a body from a physics world. + /// + public static void DestroyBody(uint worldId, uint bodyId) + { + FFI.physics_destroy_body(worldId, bodyId); + } + + /// + /// Step the physics simulation. + /// + /// World to step. + /// Time step in seconds (e.g., 1/30f for 30Hz). + /// Number of collision integration sub-steps (default 1). + public static void Step(uint worldId, float deltaTime, uint numSteps = 1) + { + FFI.physics_step(worldId, BitConverter.SingleToUInt32Bits(deltaTime), numSteps); + } + + /// + /// Get a body's position and rotation. + /// + public static PhysicsTransform GetBodyTransform(uint worldId, uint bodyId) + { + Span buf = stackalloc byte[28]; + FFI.physics_get_body_transform(worldId, bodyId, buf); + return new PhysicsTransform + { + PosX = BinaryPrimitives.ReadSingleLittleEndian(buf[0..]), + PosY = BinaryPrimitives.ReadSingleLittleEndian(buf[4..]), + PosZ = BinaryPrimitives.ReadSingleLittleEndian(buf[8..]), + RotX = BinaryPrimitives.ReadSingleLittleEndian(buf[12..]), + RotY = BinaryPrimitives.ReadSingleLittleEndian(buf[16..]), + RotZ = BinaryPrimitives.ReadSingleLittleEndian(buf[20..]), + RotW = BinaryPrimitives.ReadSingleLittleEndian(buf[24..]), + }; + } + + /// + /// Get a body's linear velocity. + /// + public static (float x, float y, float z) GetBodyVelocity(uint worldId, uint bodyId) + { + Span buf = stackalloc byte[12]; + FFI.physics_get_body_velocity(worldId, bodyId, buf); + return ( + BinaryPrimitives.ReadSingleLittleEndian(buf[0..]), + BinaryPrimitives.ReadSingleLittleEndian(buf[4..]), + BinaryPrimitives.ReadSingleLittleEndian(buf[8..]) + ); + } + + /// + /// Set a body's position. + /// + public static void SetBodyPosition(uint worldId, uint bodyId, float x, float y, float z) + { + Span buf = stackalloc byte[12]; + BinaryPrimitives.WriteSingleLittleEndian(buf[0..], x); + BinaryPrimitives.WriteSingleLittleEndian(buf[4..], y); + BinaryPrimitives.WriteSingleLittleEndian(buf[8..], z); + FFI.physics_set_body_position(worldId, bodyId, buf); + } + + /// + /// Set a body's linear velocity. + /// + public static void SetBodyVelocity(uint worldId, uint bodyId, float x, float y, float z) + { + Span buf = stackalloc byte[12]; + BinaryPrimitives.WriteSingleLittleEndian(buf[0..], x); + BinaryPrimitives.WriteSingleLittleEndian(buf[4..], y); + BinaryPrimitives.WriteSingleLittleEndian(buf[8..], z); + FFI.physics_set_body_velocity(worldId, bodyId, buf); + } + + /// + /// Set a body's rotation (quaternion). + /// + public static void SetBodyRotation(uint worldId, uint bodyId, float x, float y, float z, float w) + { + Span buf = stackalloc byte[16]; + BinaryPrimitives.WriteSingleLittleEndian(buf[0..], x); + BinaryPrimitives.WriteSingleLittleEndian(buf[4..], y); + BinaryPrimitives.WriteSingleLittleEndian(buf[8..], z); + BinaryPrimitives.WriteSingleLittleEndian(buf[12..], w); + FFI.physics_set_body_rotation(worldId, bodyId, buf); + } + + /// + /// Apply a force to a body (applied until next step). + /// + public static void AddForce(uint worldId, uint bodyId, float fx, float fy, float fz) + { + Span buf = stackalloc byte[12]; + BinaryPrimitives.WriteSingleLittleEndian(buf[0..], fx); + BinaryPrimitives.WriteSingleLittleEndian(buf[4..], fy); + BinaryPrimitives.WriteSingleLittleEndian(buf[8..], fz); + FFI.physics_add_force(worldId, bodyId, buf); + } + + /// + /// Apply an impulse to a body (instantaneous velocity change). + /// + public static void AddImpulse(uint worldId, uint bodyId, float ix, float iy, float iz) + { + Span buf = stackalloc byte[12]; + BinaryPrimitives.WriteSingleLittleEndian(buf[0..], ix); + BinaryPrimitives.WriteSingleLittleEndian(buf[4..], iy); + BinaryPrimitives.WriteSingleLittleEndian(buf[8..], iz); + FFI.physics_add_impulse(worldId, bodyId, buf); + } + + // ── Vehicle Physics ───────────────────────────────────────────── + + /// + /// Get default vehicle parameters from the Jolt engine. + /// Returns the raw bytes of JPC_VehicleParams with sensible sedan defaults. + /// + public static byte[] GetVehicleParamsDefault() + { + FFI.physics_get_vehicle_params_default(out var sourceBits); + var source = new BytesSource(sourceBits); + if (source == BytesSource.INVALID) + return Array.Empty(); + + uint remaining = 0; + FFI.bytes_source_remaining_length(source, ref remaining); + var buffer = new byte[remaining]; + uint readLen = remaining; + FFI.bytes_source_read(source, buffer.AsSpan(), ref readLen); + return buffer; + } + + /// + /// Create a 4-wheel vehicle in a physics world. + /// + public static uint CreateVehicle( + uint worldId, + byte[] vehicleParams, + (float x, float y, float z) position = default, + (float x, float y, float z, float w) rotation = default) + { + if (rotation == default) + rotation = (0f, 0f, 0f, 1f); + + Span pos = stackalloc byte[12]; + BinaryPrimitives.WriteSingleLittleEndian(pos[0..], position.x); + BinaryPrimitives.WriteSingleLittleEndian(pos[4..], position.y); + BinaryPrimitives.WriteSingleLittleEndian(pos[8..], position.z); + + Span rot = stackalloc byte[16]; + BinaryPrimitives.WriteSingleLittleEndian(rot[0..], rotation.x); + BinaryPrimitives.WriteSingleLittleEndian(rot[4..], rotation.y); + BinaryPrimitives.WriteSingleLittleEndian(rot[8..], rotation.z); + BinaryPrimitives.WriteSingleLittleEndian(rot[12..], rotation.w); + + FFI.physics_create_vehicle(worldId, vehicleParams, (uint)vehicleParams.Length, pos, rot, out var vehicleId); + return vehicleId; + } + + /// + /// Set driver input for a vehicle. + /// + public static void SetVehicleInput(uint worldId, uint vehicleId, + float forward, float right, float brake, float handbrake) + { + FFI.physics_set_vehicle_input(worldId, vehicleId, + BitConverter.SingleToUInt32Bits(forward), + BitConverter.SingleToUInt32Bits(right), + BitConverter.SingleToUInt32Bits(brake), + BitConverter.SingleToUInt32Bits(handbrake)); + } + + /// + /// Get vehicle state as raw bytes (JPC_VehicleState struct). + /// Returns null if vehicle/world not found. + /// + public static byte[]? GetVehicleStateRaw(uint worldId, uint vehicleId) + { + FFI.physics_get_vehicle_state(worldId, vehicleId, out var sourceBits); + var source = new BytesSource(sourceBits); + if (source == BytesSource.INVALID) + return null; + + uint remaining = 0; + FFI.bytes_source_remaining_length(source, ref remaining); + var buffer = new byte[remaining]; + uint readLen = remaining; + FFI.bytes_source_read(source, buffer.AsSpan(), ref readLen); + return buffer; + } + + /// + /// Destroy a vehicle from a physics world. + /// + public static void DestroyVehicle(uint worldId, uint vehicleId) + { + FFI.physics_destroy_vehicle(worldId, vehicleId); + } + + /// + /// Get transforms and velocities for ALL bodies in a world (bulk query). + /// Efficient for syncing physics state to database tables. + /// + public static PhysicsBodyState[] GetAllBodyTransforms(uint worldId) + { + FFI.physics_get_all_body_transforms(worldId, out var sourceBits); + var source = new BytesSource(sourceBits); + if (source == BytesSource.INVALID) + return Array.Empty(); + + // Read the bytes source + // Each record is 44 bytes: u32 + 3*f32 + 4*f32 + 3*f32 + const int RecordSize = 44; + var buffer = new byte[4096]; // initial buffer + uint totalRead = 0; + + // Read all bytes from the source + uint remaining = 0; + FFI.bytes_source_remaining_length(source, ref remaining); + if (remaining > 0) + buffer = new byte[remaining]; + + // Read in one call + uint readLen = (uint)buffer.Length; + FFI.bytes_source_read(source, buffer.AsSpan(), ref readLen); + totalRead = readLen; + + int count = (int)(totalRead / RecordSize); + var result = new PhysicsBodyState[count]; + + for (int i = 0; i < count; i++) + { + int offset = i * RecordSize; + result[i] = new PhysicsBodyState + { + BodyId = BinaryPrimitives.ReadUInt32LittleEndian(buffer.AsSpan(offset)), + PosX = BinaryPrimitives.ReadSingleLittleEndian(buffer.AsSpan(offset + 4)), + PosY = BinaryPrimitives.ReadSingleLittleEndian(buffer.AsSpan(offset + 8)), + PosZ = BinaryPrimitives.ReadSingleLittleEndian(buffer.AsSpan(offset + 12)), + RotX = BinaryPrimitives.ReadSingleLittleEndian(buffer.AsSpan(offset + 16)), + RotY = BinaryPrimitives.ReadSingleLittleEndian(buffer.AsSpan(offset + 20)), + RotZ = BinaryPrimitives.ReadSingleLittleEndian(buffer.AsSpan(offset + 24)), + RotW = BinaryPrimitives.ReadSingleLittleEndian(buffer.AsSpan(offset + 28)), + VelX = BinaryPrimitives.ReadSingleLittleEndian(buffer.AsSpan(offset + 32)), + VelY = BinaryPrimitives.ReadSingleLittleEndian(buffer.AsSpan(offset + 36)), + VelZ = BinaryPrimitives.ReadSingleLittleEndian(buffer.AsSpan(offset + 40)), + }; + } + + return result; + } + +} diff --git a/crates/bindings-csharp/Runtime/bindings.c b/crates/bindings-csharp/Runtime/bindings.c index 1b55d095713..c2f92f5154b 100644 --- a/crates/bindings-csharp/Runtime/bindings.c +++ b/crates/bindings-csharp/Runtime/bindings.c @@ -129,6 +129,65 @@ IMPORT(uint16_t, procedure_http_request, (request_ptr, request_len, body_ptr, body_len, out)); #undef SPACETIME_MODULE_VERSION +#define SPACETIME_MODULE_VERSION "spacetime_10.5" +IMPORT(Status, physics_create_world, + (const uint8_t* gravity_ptr, uint32_t* world_id), + (gravity_ptr, world_id)); +IMPORT(Status, physics_destroy_world, + (uint32_t world_id), + (world_id)); +IMPORT(Status, physics_create_body, + (uint32_t world_id, const uint8_t* body_params_ptr, uint32_t* body_id), + (world_id, body_params_ptr, body_id)); +IMPORT(Status, physics_destroy_body, + (uint32_t world_id, uint32_t body_id), + (world_id, body_id)); +IMPORT(Status, physics_step, + (uint32_t world_id, uint32_t delta_time_bits, uint32_t num_steps), + (world_id, delta_time_bits, num_steps)); +IMPORT(Status, physics_get_body_transform, + (uint32_t world_id, uint32_t body_id, uint8_t* out_ptr), + (world_id, body_id, out_ptr)); +IMPORT(Status, physics_get_body_velocity, + (uint32_t world_id, uint32_t body_id, uint8_t* out_ptr), + (world_id, body_id, out_ptr)); +IMPORT(Status, physics_set_body_position, + (uint32_t world_id, uint32_t body_id, const uint8_t* position_ptr), + (world_id, body_id, position_ptr)); +IMPORT(Status, physics_set_body_velocity, + (uint32_t world_id, uint32_t body_id, const uint8_t* velocity_ptr), + (world_id, body_id, velocity_ptr)); +IMPORT(Status, physics_set_body_rotation, + (uint32_t world_id, uint32_t body_id, const uint8_t* rotation_ptr), + (world_id, body_id, rotation_ptr)); +IMPORT(Status, physics_add_force, + (uint32_t world_id, uint32_t body_id, const uint8_t* force_ptr), + (world_id, body_id, force_ptr)); +IMPORT(Status, physics_add_impulse, + (uint32_t world_id, uint32_t body_id, const uint8_t* impulse_ptr), + (world_id, body_id, impulse_ptr)); +IMPORT(Status, physics_get_all_body_transforms, + (uint32_t world_id, uint32_t* out), + (world_id, out)); + +// Vehicle physics +IMPORT(Status, physics_create_vehicle, + (uint32_t world_id, const uint8_t* params_ptr, uint32_t params_len, const uint8_t* pos_ptr, const uint8_t* rot_ptr, uint32_t* out), + (world_id, params_ptr, params_len, pos_ptr, rot_ptr, out)); +IMPORT(Status, physics_set_vehicle_input, + (uint32_t world_id, uint32_t vehicle_id, uint32_t forward_bits, uint32_t right_bits, uint32_t brake_bits, uint32_t handbrake_bits), + (world_id, vehicle_id, forward_bits, right_bits, brake_bits, handbrake_bits)); +IMPORT(Status, physics_get_vehicle_state, + (uint32_t world_id, uint32_t vehicle_id, uint32_t* out), + (world_id, vehicle_id, out)); +IMPORT(Status, physics_destroy_vehicle, + (uint32_t world_id, uint32_t vehicle_id), + (world_id, vehicle_id)); +IMPORT(Status, physics_get_vehicle_params_default, + (uint32_t* out), + (out)); +#undef SPACETIME_MODULE_VERSION + #ifndef EXPERIMENTAL_WASM_AOT static MonoClass* ffi_class; diff --git a/crates/bindings-sys/src/lib.rs b/crates/bindings-sys/src/lib.rs index 95dfbc7e600..d832d87ca4a 100644 --- a/crates/bindings-sys/src/lib.rs +++ b/crates/bindings-sys/src/lib.rs @@ -865,6 +865,61 @@ pub mod raw { ) -> u16; } + #[link(wasm_import_module = "spacetime_10.5")] + unsafe extern "C" { + /// Create a new physics world with the given gravity. + /// `gravity_ptr` points to 3 f32s [x, y, z] in WASM memory. + /// The new world ID is written to `out`. + pub fn physics_create_world(gravity_ptr: *const u8, out: *mut u32) -> u16; + + /// Destroy a physics world. + pub fn physics_destroy_world(world_id: u32) -> u16; + + /// Create a body in a physics world. + /// `params_ptr` points to a packed struct (54 bytes): + /// shape_type: u8, shape_params: [f32;3], position: [f32;3], + /// rotation: [f32;4], motion_type: u8, mass: f32, restitution: f32, friction: f32 + /// Body ID is written to `out`. + pub fn physics_create_body(world_id: u32, params_ptr: *const u8, out: *mut u32) -> u16; + + /// Destroy a body from a physics world. + pub fn physics_destroy_body(world_id: u32, body_id: u32) -> u16; + + /// Step the physics simulation. + /// `delta_time_bits` is the f32 delta time as u32 bits. + /// `num_steps` is the number of sub-steps. + pub fn physics_step(world_id: u32, delta_time_bits: u32, num_steps: u32) -> u16; + + /// Get a body's transform (position + rotation). + /// Writes 7 f32s to `out_ptr`: [px, py, pz, rx, ry, rz, rw] + pub fn physics_get_body_transform(world_id: u32, body_id: u32, out_ptr: *mut u8) -> u16; + + /// Get a body's linear velocity. + /// Writes 3 f32s to `out_ptr`: [vx, vy, vz] + pub fn physics_get_body_velocity(world_id: u32, body_id: u32, out_ptr: *mut u8) -> u16; + + /// Set a body's position. Reads 3 f32s from `pos_ptr`. + pub fn physics_set_body_position(world_id: u32, body_id: u32, pos_ptr: *const u8) -> u16; + + /// Set a body's linear velocity. Reads 3 f32s from `vel_ptr`. + pub fn physics_set_body_velocity(world_id: u32, body_id: u32, vel_ptr: *const u8) -> u16; + + /// Set a body's rotation. Reads 4 f32s [x,y,z,w] from `rot_ptr`. + pub fn physics_set_body_rotation(world_id: u32, body_id: u32, rot_ptr: *const u8) -> u16; + + /// Apply a force to a body. Reads 3 f32s from `force_ptr`. + pub fn physics_add_force(world_id: u32, body_id: u32, force_ptr: *const u8) -> u16; + + /// Apply an impulse to a body. Reads 3 f32s from `impulse_ptr`. + pub fn physics_add_impulse(world_id: u32, body_id: u32, impulse_ptr: *const u8) -> u16; + + /// Get transforms for all bodies in a world (bulk). + /// Returns a BytesSource containing packed records (44 bytes each): + /// [body_id: u32, pos: [f32;3], rot: [f32;4], vel: [f32;3]] + /// The BytesSourceId is written to `out`. + pub fn physics_get_all_body_transforms(world_id: u32, out: *mut u32) -> u16; + } + /// What strategy does the database index use? /// /// See also: @@ -1626,3 +1681,126 @@ pub mod procedure { } } } + +// ==================== Physics (Jolt) safe wrappers ==================== + +/// Physics world ID handle. +pub type PhysicsWorldId = u32; + +/// Physics body ID handle. +pub type PhysicsBodyId = u32; + +/// Motion type for physics bodies. +#[repr(u8)] +pub enum PhysicsMotionType { + Static = 0, + Kinematic = 1, + Dynamic = 2, +} + +/// Shape type for physics collision shapes. +#[repr(u8)] +pub enum PhysicsShapeType { + Sphere = 0, + Box = 1, + Capsule = 2, +} + +/// Parameters for creating a physics body. +#[repr(C, packed)] +pub struct PhysicsBodyParams { + pub shape_type: u8, + pub shape_params: [f32; 3], + pub position: [f32; 3], + pub rotation: [f32; 4], + pub motion_type: u8, + pub mass: f32, + pub restitution: f32, + pub friction: f32, +} + +/// Create a new physics world with the given gravity. +pub fn physics_create_world(gravity: [f32; 3]) -> Result { + unsafe { + call(|out| { + raw::physics_create_world(gravity.as_ptr() as *const u8, out) + }) + } +} + +/// Destroy a physics world. +pub fn physics_destroy_world(world_id: PhysicsWorldId) -> Result<()> { + cvt(unsafe { raw::physics_destroy_world(world_id) }) +} + +/// Create a body in a physics world. +pub fn physics_create_body(world_id: PhysicsWorldId, params: &PhysicsBodyParams) -> Result { + unsafe { + call(|out| { + raw::physics_create_body(world_id, params as *const PhysicsBodyParams as *const u8, out) + }) + } +} + +/// Destroy a body from a physics world. +pub fn physics_destroy_body(world_id: PhysicsWorldId, body_id: PhysicsBodyId) -> Result<()> { + cvt(unsafe { raw::physics_destroy_body(world_id, body_id) }) +} + +/// Step the physics simulation. +pub fn physics_step(world_id: PhysicsWorldId, delta_time: f32, num_steps: u32) -> Result<()> { + cvt(unsafe { raw::physics_step(world_id, delta_time.to_bits(), num_steps) }) +} + +/// Get a body's position and rotation. +/// Returns ([px, py, pz], [rx, ry, rz, rw]). +pub fn physics_get_body_transform(world_id: PhysicsWorldId, body_id: PhysicsBodyId) -> Result<([f32; 3], [f32; 4])> { + let mut buf = [0u8; 28]; // 7 * f32 + cvt(unsafe { raw::physics_get_body_transform(world_id, body_id, buf.as_mut_ptr()) })?; + + let read_f32 = |offset: usize| -> f32 { + f32::from_le_bytes(buf[offset..offset + 4].try_into().unwrap()) + }; + + Ok(( + [read_f32(0), read_f32(4), read_f32(8)], + [read_f32(12), read_f32(16), read_f32(20), read_f32(24)], + )) +} + +/// Get a body's linear velocity. +pub fn physics_get_body_velocity(world_id: PhysicsWorldId, body_id: PhysicsBodyId) -> Result<[f32; 3]> { + let mut buf = [0u8; 12]; // 3 * f32 + cvt(unsafe { raw::physics_get_body_velocity(world_id, body_id, buf.as_mut_ptr()) })?; + + Ok([ + f32::from_le_bytes(buf[0..4].try_into().unwrap()), + f32::from_le_bytes(buf[4..8].try_into().unwrap()), + f32::from_le_bytes(buf[8..12].try_into().unwrap()), + ]) +} + +/// Set a body's position. +pub fn physics_set_body_position(world_id: PhysicsWorldId, body_id: PhysicsBodyId, position: [f32; 3]) -> Result<()> { + cvt(unsafe { raw::physics_set_body_position(world_id, body_id, position.as_ptr() as *const u8) }) +} + +/// Set a body's linear velocity. +pub fn physics_set_body_velocity(world_id: PhysicsWorldId, body_id: PhysicsBodyId, velocity: [f32; 3]) -> Result<()> { + cvt(unsafe { raw::physics_set_body_velocity(world_id, body_id, velocity.as_ptr() as *const u8) }) +} + +/// Set a body's rotation (quaternion [x, y, z, w]). +pub fn physics_set_body_rotation(world_id: PhysicsWorldId, body_id: PhysicsBodyId, rotation: [f32; 4]) -> Result<()> { + cvt(unsafe { raw::physics_set_body_rotation(world_id, body_id, rotation.as_ptr() as *const u8) }) +} + +/// Apply a force to a body. +pub fn physics_add_force(world_id: PhysicsWorldId, body_id: PhysicsBodyId, force: [f32; 3]) -> Result<()> { + cvt(unsafe { raw::physics_add_force(world_id, body_id, force.as_ptr() as *const u8) }) +} + +/// Apply an impulse to a body. +pub fn physics_add_impulse(world_id: PhysicsWorldId, body_id: PhysicsBodyId, impulse: [f32; 3]) -> Result<()> { + cvt(unsafe { raw::physics_add_impulse(world_id, body_id, impulse.as_ptr() as *const u8) }) +} diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index e0934d245b8..0283304a473 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -127,6 +127,11 @@ async_cache = "0.3.1" faststr = "0.2.23" core_affinity = "0.8" +# Physics +rolt.workspace = true +joltc-sys.workspace = true +glam.workspace = true + [target.'cfg(not(target_env = "msvc"))'.dependencies] tikv-jemallocator = {workspace = true} tikv-jemalloc-ctl = {workspace = true} diff --git a/crates/core/src/host/instance_env.rs b/crates/core/src/host/instance_env.rs index 0efcd59014c..3e2358c06e6 100644 --- a/crates/core/src/host/instance_env.rs +++ b/crates/core/src/host/instance_env.rs @@ -40,6 +40,7 @@ use std::vec::IntoIter; pub struct InstanceEnv { pub replica_ctx: Arc, pub scheduler: Scheduler, + pub physics: Arc, pub tx: TxSlot, /// The timestamp the current function began running. pub start_time: Timestamp, @@ -224,10 +225,11 @@ impl ChunkedWriter { // Generic 'instance environment' delegated to from various host types. impl InstanceEnv { - pub fn new(replica_ctx: Arc, scheduler: Scheduler) -> Self { + pub fn new(replica_ctx: Arc, scheduler: Scheduler, physics: Arc) -> Self { Self { replica_ctx, scheduler, + physics, tx: TxSlot::default(), start_time: Timestamp::now(), start_instant: Instant::now(), diff --git a/crates/core/src/host/mod.rs b/crates/core/src/host/mod.rs index 0daa9c359bc..65611b79ff2 100644 --- a/crates/core/src/host/mod.rs +++ b/crates/core/src/host/mod.rs @@ -21,6 +21,7 @@ pub mod wasmtime; // Visible for integration testing. pub mod instance_env; pub mod v8; // only pub for testing +pub mod physics; mod wasm_common; pub use disk_storage::DiskStorage; @@ -194,4 +195,26 @@ pub enum AbiCall { ProcedureCommitMutTransaction, ProcedureAbortMutTransaction, ProcedureHttpRequest, + + // Physics (Jolt) + PhysicsCreateWorld, + PhysicsDestroyWorld, + PhysicsCreateBody, + PhysicsDestroyBody, + PhysicsStep, + PhysicsGetBodyTransform, + PhysicsGetBodyVelocity, + PhysicsSetBodyPosition, + PhysicsSetBodyVelocity, + PhysicsSetBodyRotation, + PhysicsAddForce, + PhysicsAddImpulse, + PhysicsGetAllBodyTransforms, + + // Physics (Jolt) - Vehicles + PhysicsCreateVehicle, + PhysicsSetVehicleInput, + PhysicsGetVehicleState, + PhysicsDestroyVehicle, + PhysicsGetVehicleParamsDefault, } diff --git a/crates/core/src/host/module_common.rs b/crates/core/src/host/module_common.rs index bff864759f3..7fbc95ea577 100644 --- a/crates/core/src/host/module_common.rs +++ b/crates/core/src/host/module_common.rs @@ -5,6 +5,7 @@ use crate::{ energy::EnergyMonitor, host::{ module_host::ModuleInfo, + physics::PhysicsState, wasm_common::{module_host_actor::DescribeError, DESCRIBE_MODULE_DUNDER}, Scheduler, }, @@ -19,6 +20,7 @@ use std::sync::Arc; pub fn build_common_module_from_raw( mcc: ModuleCreationContext, raw_def: RawModuleDef, + physics: Arc, ) -> Result { // Perform a bunch of validation on the raw definition. let def: ModuleDef = raw_def.try_into()?; @@ -37,7 +39,7 @@ pub fn build_common_module_from_raw( replica_ctx.subscriptions.clone(), ); - Ok(ModuleCommon::new(replica_ctx, mcc.scheduler, info, mcc.energy_monitor)) + Ok(ModuleCommon::new(replica_ctx, mcc.scheduler, info, mcc.energy_monitor, physics)) } /// Non-runtime-specific parts of a module. @@ -47,6 +49,7 @@ pub(crate) struct ModuleCommon { scheduler: Scheduler, info: Arc, energy_monitor: Arc, + physics: Arc, } impl ModuleCommon { @@ -56,12 +59,14 @@ impl ModuleCommon { scheduler: Scheduler, info: Arc, energy_monitor: Arc, + physics: Arc, ) -> Self { Self { replica_context, scheduler, info, energy_monitor, + physics, } } @@ -89,6 +94,10 @@ impl ModuleCommon { pub fn scheduler(&self) -> &Scheduler { &self.scheduler } + + pub fn physics(&self) -> &Arc { + &self.physics + } } /// Runs the describer of modules in `run` and does some logging around it. diff --git a/crates/core/src/host/physics.rs b/crates/core/src/host/physics.rs new file mode 100644 index 00000000000..1e46b510c38 --- /dev/null +++ b/crates/core/src/host/physics.rs @@ -0,0 +1,665 @@ +//! Jolt Physics integration for SpacetimeDB. +//! +//! Provides a host-side physics world that WASM modules can interact with +//! via host functions. The physics state lives in host memory (not WASM linear memory), +//! giving native performance while being callable from any module language. +//! +//! # Persistence model +//! +//! The Jolt physics world is **not persisted** — it lives only in RAM. +//! Modules must store authoritative state (positions, velocities, shapes) +//! in SpacetimeDB tables and reconstruct the physics world on restart. +//! Between reducer calls, the world stays alive (WASM instances are pooled). + +use joltc_sys::*; +use parking_lot::Mutex; +use rolt::{ + register_default_allocator, factory_init, register_types, + PhysicsSystem, BodyId, ObjectLayer, BroadPhaseLayer, + BroadPhaseLayerInterface, ObjectVsBroadPhaseLayerFilter, ObjectLayerPairFilter, +}; +use std::collections::HashMap; + +/// Identifies a physics world instance. +pub type PhysicsWorldId = u32; + +/// Identifies a body within a physics world. +pub type PhysicsBodyId = u32; + +/// Identifies a vehicle within a physics world. +pub type PhysicsVehicleId = u32; + +/// Motion type for physics bodies. +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MotionType { + Static = 0, + Kinematic = 1, + Dynamic = 2, +} + +impl MotionType { + pub fn from_u8(v: u8) -> Option { + match v { + 0 => Some(Self::Static), + 1 => Some(Self::Kinematic), + 2 => Some(Self::Dynamic), + _ => None, + } + } + + fn to_jolt(self) -> JPC_MotionType { + match self { + Self::Static => JPC_MOTION_TYPE_STATIC, + Self::Kinematic => JPC_MOTION_TYPE_KINEMATIC, + Self::Dynamic => JPC_MOTION_TYPE_DYNAMIC, + } + } +} + +/// Shape type for creating collision shapes. +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ShapeType { + Sphere = 0, + Box = 1, + Capsule = 2, +} + +impl ShapeType { + pub fn from_u8(v: u8) -> Option { + match v { + 0 => Some(Self::Sphere), + 1 => Some(Self::Box), + 2 => Some(Self::Capsule), + _ => None, + } + } +} + +// Simple 2-layer broadphase setup: MOVING and NON_MOVING +const LAYER_NON_MOVING: ObjectLayer = ObjectLayer::new(0); +const LAYER_MOVING: ObjectLayer = ObjectLayer::new(1); +const BP_LAYER_NON_MOVING: BroadPhaseLayer = BroadPhaseLayer::new(0); +const BP_LAYER_MOVING: BroadPhaseLayer = BroadPhaseLayer::new(1); +const NUM_BP_LAYERS: u32 = 2; + +struct SimpleBroadPhaseLayerInterface; +impl BroadPhaseLayerInterface for SimpleBroadPhaseLayerInterface { + fn get_num_broad_phase_layers(&self) -> u32 { + NUM_BP_LAYERS + } + fn get_broad_phase_layer(&self, layer: ObjectLayer) -> BroadPhaseLayer { + if layer == LAYER_NON_MOVING { + BP_LAYER_NON_MOVING + } else { + BP_LAYER_MOVING + } + } +} + +struct SimpleObjectVsBroadPhaseLayerFilter; +impl ObjectVsBroadPhaseLayerFilter for SimpleObjectVsBroadPhaseLayerFilter { + fn should_collide(&self, layer1: ObjectLayer, layer2: BroadPhaseLayer) -> bool { + if layer1 == LAYER_NON_MOVING { + layer2 == BP_LAYER_MOVING + } else { + true + } + } +} + +struct SimpleObjectLayerPairFilter; +impl ObjectLayerPairFilter for SimpleObjectLayerPairFilter { + fn should_collide(&self, layer1: ObjectLayer, layer2: ObjectLayer) -> bool { + // Non-moving objects don't collide with each other + !(layer1 == LAYER_NON_MOVING && layer2 == LAYER_NON_MOVING) + } +} + +/// Helper to create a JPC_Vec3 (which has an unused _w padding field). +fn jpc_vec3(x: f32, y: f32, z: f32) -> JPC_Vec3 { + JPC_Vec3 { x, y, z, _w: 0.0 } +} + +/// Manages all physics worlds for a module instance. +pub struct PhysicsState { + worlds: Mutex>, + next_world_id: Mutex, +} + +/// A single Jolt physics world with its system and resources. +struct PhysicsWorld { + physics_system: PhysicsSystem, + temp_allocator: *mut JPC_TempAllocatorImpl, + job_system: *mut JPC_JobSystemThreadPool, + body_map: HashMap, + next_body_id: PhysicsBodyId, +} + +impl Drop for PhysicsWorld { + fn drop(&mut self) { + unsafe { + if !self.temp_allocator.is_null() { + JPC_TempAllocatorImpl_delete(self.temp_allocator); + } + if !self.job_system.is_null() { + JPC_JobSystemThreadPool_delete(self.job_system); + } + } + } +} + +// Safety: The PhysicsWorld wraps Jolt resources which are thread-safe +// when accessed through the BodyInterface (which Jolt locks internally). +unsafe impl Send for PhysicsWorld {} + +impl PhysicsState { + pub fn new() -> Self { + register_default_allocator(); + factory_init(); + register_types(); + + Self { + worlds: Mutex::new(HashMap::new()), + next_world_id: Mutex::new(1), + } + } + + /// Create a new physics world. Returns the world ID. + /// Note: Custom gravity is not supported by the current joltc_sys bindings. + /// Jolt defaults to (0, -9.81, 0). + pub fn create_world(&self, _gravity: [f32; 3]) -> PhysicsWorldId { + let mut next_id = self.next_world_id.lock(); + let world_id = *next_id; + *next_id += 1; + + let mut physics_system = PhysicsSystem::new(); + + // Initialize with broadphase layer interfaces + physics_system.init( + 1024, // max_bodies + 0, // num_body_mutexes (0 = auto) + 1024, // max_body_pairs + 1024, // max_contact_constraints + SimpleBroadPhaseLayerInterface, + SimpleObjectVsBroadPhaseLayerFilter, + SimpleObjectLayerPairFilter, + ); + + // Create temp allocator and job system for stepping + // JPC_JobSystemThreadPool_new2(maxJobs, maxBarriers) + let temp_allocator = unsafe { JPC_TempAllocatorImpl_new(10 * 1024 * 1024) }; // 10MB + let job_system = unsafe { JPC_JobSystemThreadPool_new2(1024, 8) }; + + let world = PhysicsWorld { + physics_system, + temp_allocator, + job_system, + body_map: HashMap::new(), + next_body_id: 1, + }; + + self.worlds.lock().insert(world_id, world); + world_id + } + + /// Destroy a physics world. + pub fn destroy_world(&self, world_id: PhysicsWorldId) -> bool { + self.worlds.lock().remove(&world_id).is_some() + } + + /// Create a body in the specified world. Returns the body ID. + pub fn create_body( + &self, + world_id: PhysicsWorldId, + shape_type: u8, + shape_params: [f32; 3], + position: [f32; 3], + rotation: [f32; 4], + motion_type: u8, + _mass: f32, + restitution: f32, + friction: f32, + ) -> Option { + let mut worlds = self.worlds.lock(); + let world = worlds.get_mut(&world_id)?; + + let shape_type = ShapeType::from_u8(shape_type)?; + let motion = MotionType::from_u8(motion_type)?; + + let our_body_id = world.next_body_id; + world.next_body_id += 1; + + unsafe { + // Create shape - _default() functions are in-place initializers + let mut out_shape: *mut JPC_Shape = std::ptr::null_mut(); + let success = match shape_type { + ShapeType::Sphere => { + let mut settings: JPC_SphereShapeSettings = std::mem::zeroed(); + JPC_SphereShapeSettings_default(&mut settings); + settings.Radius = shape_params[0]; + JPC_SphereShapeSettings_Create(&settings, &mut out_shape, std::ptr::null_mut()) + } + ShapeType::Box => { + let mut settings: JPC_BoxShapeSettings = std::mem::zeroed(); + JPC_BoxShapeSettings_default(&mut settings); + settings.HalfExtent = jpc_vec3(shape_params[0], shape_params[1], shape_params[2]); + JPC_BoxShapeSettings_Create(&settings, &mut out_shape, std::ptr::null_mut()) + } + ShapeType::Capsule => { + let mut settings: JPC_CapsuleShapeSettings = std::mem::zeroed(); + JPC_CapsuleShapeSettings_default(&mut settings); + settings.Radius = shape_params[0]; + settings.HalfHeightOfCylinder = shape_params[1]; + JPC_CapsuleShapeSettings_Create(&settings, &mut out_shape, std::ptr::null_mut()) + } + }; + + if !success || out_shape.is_null() { + return None; + } + + // Choose object layer based on motion type + let object_layer = if motion == MotionType::Static { + LAYER_NON_MOVING + } else { + LAYER_MOVING + }; + + let activation = if motion == MotionType::Static { + JPC_ACTIVATION_DONT_ACTIVATE + } else { + JPC_ACTIVATION_ACTIVATE + }; + + // Create body creation settings with proper defaults + let mut body_settings: JPC_BodyCreationSettings = std::mem::zeroed(); + JPC_BodyCreationSettings_default(&mut body_settings); + body_settings.Position = jpc_vec3(position[0], position[1], position[2]); + body_settings.Rotation = JPC_Quat { + x: rotation[0], + y: rotation[1], + z: rotation[2], + w: rotation[3], + }; + body_settings.ObjectLayer = object_layer.raw(); + body_settings.MotionType = motion.to_jolt(); + body_settings.Shape = out_shape; + body_settings.Friction = friction; + body_settings.Restitution = restitution; + + // Create body via body interface + let bi = world.physics_system.body_interface(); + let body = bi.create_body(&body_settings); + let jolt_body_id = body?.id(); + + // Add to world + bi.add_body(jolt_body_id, activation); + + world.body_map.insert(our_body_id, jolt_body_id); + Some(our_body_id) + } + } + + /// Remove a body from the world. + pub fn destroy_body(&self, world_id: PhysicsWorldId, body_id: PhysicsBodyId) -> bool { + let mut worlds = self.worlds.lock(); + let world = match worlds.get_mut(&world_id) { + Some(w) => w, + None => return false, + }; + + if let Some(jolt_id) = world.body_map.remove(&body_id) { + let bi = world.physics_system.body_interface(); + bi.remove_body(jolt_id); + bi.destroy_body(jolt_id); + true + } else { + false + } + } + + /// Step the physics simulation. + pub fn step(&self, world_id: PhysicsWorldId, delta_time: f32, num_steps: i32) -> bool { + let mut worlds = self.worlds.lock(); + let world = match worlds.get_mut(&world_id) { + Some(w) => w, + None => return false, + }; + + unsafe { + world.physics_system.update( + delta_time, + num_steps.max(1), + world.temp_allocator, + world.job_system, + ); + } + true + } + + /// Get body position and rotation. + pub fn get_body_transform( + &self, + world_id: PhysicsWorldId, + body_id: PhysicsBodyId, + ) -> Option<([f32; 3], [f32; 4])> { + let worlds = self.worlds.lock(); + let world = worlds.get(&world_id)?; + let jolt_id = world.body_map.get(&body_id)?; + + let bi = world.physics_system.body_interface(); + let pos = bi.center_of_mass_position(*jolt_id); + // Get rotation via raw API + let rot = unsafe { + JPC_BodyInterface_GetRotation(bi.raw(), jolt_id.raw()) + }; + + Some(( + [pos.x as f32, pos.y as f32, pos.z as f32], + [rot.x, rot.y, rot.z, rot.w], + )) + } + + /// Get body linear velocity. + pub fn get_body_velocity( + &self, + world_id: PhysicsWorldId, + body_id: PhysicsBodyId, + ) -> Option<[f32; 3]> { + let worlds = self.worlds.lock(); + let world = worlds.get(&world_id)?; + let jolt_id = world.body_map.get(&body_id)?; + + let bi = world.physics_system.body_interface(); + let vel = bi.linear_velocity(*jolt_id); + Some([vel.x, vel.y, vel.z]) + } + + /// Set body position. + pub fn set_body_position( + &self, + world_id: PhysicsWorldId, + body_id: PhysicsBodyId, + position: [f32; 3], + ) -> bool { + let mut worlds = self.worlds.lock(); + let world = match worlds.get_mut(&world_id) { + Some(w) => w, + None => return false, + }; + + if let Some(jolt_id) = world.body_map.get(&body_id) { + unsafe { + let bi = world.physics_system.body_interface(); + let pos = jpc_vec3(position[0], position[1], position[2]); + JPC_BodyInterface_SetPosition( + bi.raw(), + jolt_id.raw(), + pos, + JPC_ACTIVATION_ACTIVATE, + ); + } + true + } else { + false + } + } + + /// Set body linear velocity. + pub fn set_body_velocity( + &self, + world_id: PhysicsWorldId, + body_id: PhysicsBodyId, + velocity: [f32; 3], + ) -> bool { + let mut worlds = self.worlds.lock(); + let world = match worlds.get_mut(&world_id) { + Some(w) => w, + None => return false, + }; + + if let Some(jolt_id) = world.body_map.get(&body_id) { + unsafe { + let bi = world.physics_system.body_interface(); + let vel = jpc_vec3(velocity[0], velocity[1], velocity[2]); + JPC_BodyInterface_SetLinearVelocity(bi.raw(), jolt_id.raw(), vel); + } + true + } else { + false + } + } + + /// Set body rotation. + pub fn set_body_rotation( + &self, + world_id: PhysicsWorldId, + body_id: PhysicsBodyId, + rotation: [f32; 4], + ) -> bool { + let mut worlds = self.worlds.lock(); + let world = match worlds.get_mut(&world_id) { + Some(w) => w, + None => return false, + }; + + if let Some(jolt_id) = world.body_map.get(&body_id) { + unsafe { + let bi = world.physics_system.body_interface(); + let rot = JPC_Quat { + x: rotation[0], + y: rotation[1], + z: rotation[2], + w: rotation[3], + }; + JPC_BodyInterface_SetRotation( + bi.raw(), + jolt_id.raw(), + rot, + JPC_ACTIVATION_ACTIVATE, + ); + } + true + } else { + false + } + } + + /// Apply a force to a body. + pub fn add_force( + &self, + world_id: PhysicsWorldId, + body_id: PhysicsBodyId, + force: [f32; 3], + ) -> bool { + let mut worlds = self.worlds.lock(); + let world = match worlds.get_mut(&world_id) { + Some(w) => w, + None => return false, + }; + + if let Some(jolt_id) = world.body_map.get(&body_id) { + unsafe { + let bi = world.physics_system.body_interface(); + let f = jpc_vec3(force[0], force[1], force[2]); + JPC_BodyInterface_AddForce(bi.raw(), jolt_id.raw(), f); + } + true + } else { + false + } + } + + /// Apply an impulse to a body. + pub fn add_impulse( + &self, + world_id: PhysicsWorldId, + body_id: PhysicsBodyId, + impulse: [f32; 3], + ) -> bool { + let mut worlds = self.worlds.lock(); + let world = match worlds.get_mut(&world_id) { + Some(w) => w, + None => return false, + }; + + if let Some(jolt_id) = world.body_map.get(&body_id) { + unsafe { + let bi = world.physics_system.body_interface(); + let imp = jpc_vec3(impulse[0], impulse[1], impulse[2]); + JPC_BodyInterface_AddImpulse(bi.raw(), jolt_id.raw(), imp); + } + true + } else { + false + } + } + + /// Get transforms for ALL bodies in bulk. + pub fn get_all_body_transforms( + &self, + world_id: PhysicsWorldId, + ) -> Option> { + let worlds = self.worlds.lock(); + let world = worlds.get(&world_id)?; + + let bi = world.physics_system.body_interface(); + let result: Vec<_> = world + .body_map + .iter() + .map(|(&our_id, &jolt_id)| { + let pos = bi.center_of_mass_position(jolt_id); + let rot = unsafe { JPC_BodyInterface_GetRotation(bi.raw(), jolt_id.raw()) }; + let vel = bi.linear_velocity(jolt_id); + ( + our_id, + [pos.x as f32, pos.y as f32, pos.z as f32], + [rot.x, rot.y, rot.z, rot.w], + [vel.x, vel.y, vel.z], + ) + }) + .collect(); + + Some(result) + } + + // ──────────────────── Vehicle API ──────────────────── + + /// Create a 4-wheel vehicle in the specified world. + /// `params` is a flat byte slice of JPC_VehicleParams (will be reinterpreted). + pub fn create_vehicle( + &self, + world_id: PhysicsWorldId, + params: &[u8], + position: [f32; 3], + rotation: [f32; 4], + ) -> Option { + let worlds = self.worlds.lock(); + let world = worlds.get(&world_id)?; + + // Safety: params must be exactly sizeof(JPC_VehicleParams) + if params.len() < std::mem::size_of::() { + return None; + } + + unsafe { + let vparams = params.as_ptr() as *const JPC_VehicleParams; + let vid = JPC_PhysicsSystem_CreateVehicle( + world.physics_system.raw(), + vparams, + position[0], position[1], position[2], + rotation[0], rotation[1], rotation[2], rotation[3], + ); + if vid == 0 { None } else { Some(vid) } + } + } + + /// Set driver input for a vehicle. + pub fn set_vehicle_input( + &self, + world_id: PhysicsWorldId, + vehicle_id: PhysicsVehicleId, + forward: f32, + right: f32, + brake: f32, + handbrake: f32, + ) -> bool { + let worlds = self.worlds.lock(); + let world = match worlds.get(&world_id) { + Some(w) => w, + None => return false, + }; + + unsafe { + JPC_PhysicsSystem_SetVehicleInput( + world.physics_system.raw(), + vehicle_id, + forward, right, brake, handbrake, + ); + } + true + } + + /// Get vehicle state (body transform, wheels, engine, gear). + /// Returns the raw JPC_VehicleState bytes for serialization to WASM. + pub fn get_vehicle_state( + &self, + world_id: PhysicsWorldId, + vehicle_id: PhysicsVehicleId, + ) -> Option> { + let worlds = self.worlds.lock(); + let world = worlds.get(&world_id)?; + + unsafe { + let mut state: JPC_VehicleState = std::mem::zeroed(); + let ok = JPC_PhysicsSystem_GetVehicleState( + world.physics_system.raw(), + vehicle_id, + &mut state, + ); + if !ok { + return None; + } + + // Serialize as raw bytes + let bytes = std::slice::from_raw_parts( + &state as *const JPC_VehicleState as *const u8, + std::mem::size_of::(), + ); + Some(bytes.to_vec()) + } + } + + /// Destroy a vehicle and remove it from the physics world. + pub fn destroy_vehicle( + &self, + world_id: PhysicsWorldId, + vehicle_id: PhysicsVehicleId, + ) -> bool { + let worlds = self.worlds.lock(); + let world = match worlds.get(&world_id) { + Some(w) => w, + None => return false, + }; + + unsafe { + JPC_PhysicsSystem_DestroyVehicle( + world.physics_system.raw(), + vehicle_id, + ); + } + true + } +} + +impl Default for PhysicsState { + fn default() -> Self { + Self::new() + } +} + +unsafe impl Send for PhysicsState {} +unsafe impl Sync for PhysicsState {} + diff --git a/crates/core/src/host/v8/mod.rs b/crates/core/src/host/v8/mod.rs index b611fcaef26..747361ec79b 100644 --- a/crates/core/src/host/v8/mod.rs +++ b/crates/core/src/host/v8/mod.rs @@ -524,6 +524,7 @@ fn startup_instance_worker<'scope>( scope: &mut PinScope<'scope, '_>, program: Arc, module_or_mcc: Either, + physics: Arc, ) -> anyhow::Result<(HookFunctions<'scope>, ModuleCommon)> { let hook_functions = catch_exception(scope, |scope| { // Start-up the user's module. @@ -542,7 +543,7 @@ fn startup_instance_worker<'scope>( let def = extract_description(scope, &hook_functions, &mcc.replica_ctx)?; // Validate and create a common module from the raw definition. - build_common_module_from_raw(mcc, def)? + build_common_module_from_raw(mcc, def, physics)? } }; @@ -599,11 +600,16 @@ async fn spawn_instance_worker( scope_with_context!(let scope, &mut isolate, Context::new(scope, Default::default())); // Setup the instance environment. - let (replica_ctx, scheduler) = match &module_or_mcc { - Either::Left(module) => (module.replica_ctx(), module.scheduler()), - Either::Right(mcc) => (&mcc.replica_ctx, &mcc.scheduler), + let early_physics; + let (replica_ctx, scheduler, physics) = match &module_or_mcc { + Either::Left(module) => (module.replica_ctx(), module.scheduler(), module.physics()), + Either::Right(mcc) => { + early_physics = Arc::new(crate::host::physics::PhysicsState::new()); + (&mcc.replica_ctx, &mcc.scheduler, &early_physics) + } }; - let instance_env = InstanceEnv::new(replica_ctx.clone(), scheduler.clone()); + let physics_cloned = physics.clone(); + let instance_env = InstanceEnv::new(replica_ctx.clone(), scheduler.clone(), physics.clone()); scope.set_slot(JsInstanceEnv::new(instance_env)); catch_exception(scope, |scope| Ok(builtins::evaluate_builtins(scope)?)) @@ -617,7 +623,7 @@ async fn spawn_instance_worker( log::error!("startup result receiver disconnected"); }) }; - let (hooks, module_common) = match startup_instance_worker(scope, program, module_or_mcc) { + let (hooks, module_common) = match startup_instance_worker(scope, program, module_or_mcc, physics_cloned) { Err(err) => { // There was some error in module setup. // Return the error and terminate the worker. diff --git a/crates/core/src/host/wasm_common.rs b/crates/core/src/host/wasm_common.rs index a5c737d54d6..c01ebb1a225 100644 --- a/crates/core/src/host/wasm_common.rs +++ b/crates/core/src/host/wasm_common.rs @@ -431,6 +431,28 @@ macro_rules! abi_funcs { "spacetime_10.4"::datastore_index_scan_point_bsatn, "spacetime_10.4"::datastore_delete_by_index_scan_point_bsatn, + // Physics (Jolt) host functions + "spacetime_10.5"::physics_create_world, + "spacetime_10.5"::physics_destroy_world, + "spacetime_10.5"::physics_create_body, + "spacetime_10.5"::physics_destroy_body, + "spacetime_10.5"::physics_step, + "spacetime_10.5"::physics_get_body_transform, + "spacetime_10.5"::physics_get_body_velocity, + "spacetime_10.5"::physics_set_body_position, + "spacetime_10.5"::physics_set_body_velocity, + "spacetime_10.5"::physics_set_body_rotation, + "spacetime_10.5"::physics_add_force, + "spacetime_10.5"::physics_add_impulse, + "spacetime_10.5"::physics_get_all_body_transforms, + + // Vehicle physics host functions + "spacetime_10.5"::physics_create_vehicle, + "spacetime_10.5"::physics_set_vehicle_input, + "spacetime_10.5"::physics_get_vehicle_state, + "spacetime_10.5"::physics_destroy_vehicle, + "spacetime_10.5"::physics_get_vehicle_params_default, + } $link_async! { diff --git a/crates/core/src/host/wasm_common/module_host_actor.rs b/crates/core/src/host/wasm_common/module_host_actor.rs index 654c081c4c6..81351939473 100644 --- a/crates/core/src/host/wasm_common/module_host_actor.rs +++ b/crates/core/src/host/wasm_common/module_host_actor.rs @@ -375,13 +375,14 @@ impl WasmModuleHostActor { func_names }; let uninit_instance = module.instantiate_pre()?; - let instance_env = InstanceEnv::new(mcc.replica_ctx.clone(), mcc.scheduler.clone()); + let physics = Arc::new(crate::host::physics::PhysicsState::new()); + let instance_env = InstanceEnv::new(mcc.replica_ctx.clone(), mcc.scheduler.clone(), physics.clone()); let mut instance = uninit_instance.instantiate(instance_env, &func_names)?; let desc = instance.extract_descriptions()?; - // Validate and create a common module rom the raw definition. - let common = build_common_module_from_raw(mcc, desc)?; + // Validate and create a common module from the raw definition. + let common = build_common_module_from_raw(mcc, desc, physics)?; let func_names = Arc::new(func_names); let module = WasmModuleHostActor { @@ -422,7 +423,7 @@ impl WasmModuleHostActor { pub fn create_instance(&self) -> WasmModuleInstance { let common = &self.common; - let env = InstanceEnv::new(common.replica_ctx().clone(), common.scheduler().clone()); + let env = InstanceEnv::new(common.replica_ctx().clone(), common.scheduler().clone(), common.physics().clone()); // this shouldn't fail, since we already called module.create_instance() // before and it didn't error, and ideally they should be deterministic let mut instance = self diff --git a/crates/core/src/host/wasmtime/wasm_instance_env.rs b/crates/core/src/host/wasmtime/wasm_instance_env.rs index 74a57b35e92..2d5dd98c746 100644 --- a/crates/core/src/host/wasmtime/wasm_instance_env.rs +++ b/crates/core/src/host/wasmtime/wasm_instance_env.rs @@ -1943,6 +1943,455 @@ impl WasmInstanceEnv { ) }) } + + // ==================== Physics (Jolt) host functions ==================== + + /// Create a new physics world with the given gravity vector. + /// + /// Writes the new world ID to `out`. + /// Gravity is read as 3 consecutive f32s from WASM memory at `gravity_ptr`. + pub fn physics_create_world( + caller: Caller<'_, Self>, + gravity_ptr: WasmPtr, + out: WasmPtr, + ) -> RtResult { + Self::cvt_ret::(caller, AbiCall::PhysicsCreateWorld, out, |caller| { + let (mem, env) = Self::mem_env(caller); + let gravity_bytes = mem.deref_slice(gravity_ptr, 12)?; // 3 * f32 + let gx = f32::from_le_bytes(gravity_bytes[0..4].try_into().unwrap()); + let gy = f32::from_le_bytes(gravity_bytes[4..8].try_into().unwrap()); + let gz = f32::from_le_bytes(gravity_bytes[8..12].try_into().unwrap()); + + let world_id = env.instance_env.physics.create_world([gx, gy, gz]); + Ok(world_id) + }) + } + + /// Destroy a physics world. + /// + /// Returns errno 0 on success. + pub fn physics_destroy_world( + caller: Caller<'_, Self>, + world_id: u32, + ) -> RtResult { + Self::cvt::(caller, AbiCall::PhysicsDestroyWorld, |caller| { + caller.data().instance_env.physics.destroy_world(world_id); + Ok(()) + }) + } + + /// Create a body in the specified world. + /// + /// Reads body creation params from WASM memory: + /// - `params_ptr`: packed struct of: + /// - shape_type: u8 + /// - shape_params: [f32; 3] (12 bytes) + /// - position: [f32; 3] (12 bytes) + /// - rotation: [f32; 4] (16 bytes) quaternion xyzw + /// - motion_type: u8 + /// - mass: f32 + /// - restitution: f32 + /// - friction: f32 + /// Total: 1 + 12 + 12 + 16 + 1 + 4 + 4 + 4 = 54 bytes + /// + /// Writes body ID to `out`. + pub fn physics_create_body( + caller: Caller<'_, Self>, + world_id: u32, + params_ptr: WasmPtr, + out: WasmPtr, + ) -> RtResult { + Self::cvt_ret::(caller, AbiCall::PhysicsCreateBody, out, |caller| { + let (mem, env) = Self::mem_env(caller); + let data = mem.deref_slice(params_ptr, 54)?; + + let shape_type = data[0]; + + let read_f32 = |offset: usize| -> f32 { + f32::from_le_bytes(data[offset..offset + 4].try_into().unwrap()) + }; + + let shape_params = [read_f32(1), read_f32(5), read_f32(9)]; + let position = [read_f32(13), read_f32(17), read_f32(21)]; + let rotation = [read_f32(25), read_f32(29), read_f32(33), read_f32(37)]; + let motion_type = data[41]; + let mass = read_f32(42); + let restitution = read_f32(46); + let friction = read_f32(50); + + let body_id = env.instance_env.physics + .create_body( + world_id, + shape_type, + shape_params, + position, + rotation, + motion_type, + mass, + restitution, + friction, + ) + .ok_or_else(|| WasmError::Wasm(anyhow!("Failed to create physics body")))?; + + Ok(body_id) + }) + } + + /// Destroy a body from a physics world. + pub fn physics_destroy_body( + caller: Caller<'_, Self>, + world_id: u32, + body_id: u32, + ) -> RtResult { + Self::cvt::(caller, AbiCall::PhysicsDestroyBody, |caller| { + caller.data().instance_env.physics.destroy_body(world_id, body_id); + Ok(()) + }) + } + + /// Step the physics simulation. + /// + /// `delta_time_bits` is the IEEE 754 bit representation of the f32 delta time. + /// `num_steps` is how many sub-steps to run (typically 1). + pub fn physics_step( + caller: Caller<'_, Self>, + world_id: u32, + delta_time_bits: u32, + num_steps: u32, + ) -> RtResult { + Self::cvt::(caller, AbiCall::PhysicsStep, |caller| { + let dt = f32::from_bits(delta_time_bits); + caller.data().instance_env.physics.step(world_id, dt, num_steps as i32); + Ok(()) + }) + } + + /// Get a body's transform (position + rotation). + /// + /// Writes 7 f32s to `out_ptr`: [px, py, pz, rx, ry, rz, rw] + pub fn physics_get_body_transform( + caller: Caller<'_, Self>, + world_id: u32, + body_id: u32, + out_ptr: WasmPtr, + ) -> RtResult { + Self::cvt::(caller, AbiCall::PhysicsGetBodyTransform, |caller| { + let physics = caller.data().instance_env.physics.clone(); + let (pos, rot) = physics + .get_body_transform(world_id, body_id) + .ok_or_else(|| WasmError::Wasm(anyhow!("Body not found")))?; + + let (mem, _env) = Self::mem_env(caller); + let out = mem.deref_slice_mut(out_ptr, 28)?; // 7 * f32 + out[0..4].copy_from_slice(&pos[0].to_le_bytes()); + out[4..8].copy_from_slice(&pos[1].to_le_bytes()); + out[8..12].copy_from_slice(&pos[2].to_le_bytes()); + out[12..16].copy_from_slice(&rot[0].to_le_bytes()); + out[16..20].copy_from_slice(&rot[1].to_le_bytes()); + out[20..24].copy_from_slice(&rot[2].to_le_bytes()); + out[24..28].copy_from_slice(&rot[3].to_le_bytes()); + Ok(()) + }) + } + + /// Get a body's linear velocity. + /// + /// Writes 3 f32s to `out_ptr`: [vx, vy, vz] + pub fn physics_get_body_velocity( + caller: Caller<'_, Self>, + world_id: u32, + body_id: u32, + out_ptr: WasmPtr, + ) -> RtResult { + Self::cvt::(caller, AbiCall::PhysicsGetBodyVelocity, |caller| { + let physics = caller.data().instance_env.physics.clone(); + let vel = physics + .get_body_velocity(world_id, body_id) + .ok_or_else(|| WasmError::Wasm(anyhow!("Body not found")))?; + + let (mem, _env) = Self::mem_env(caller); + let out = mem.deref_slice_mut(out_ptr, 12)?; // 3 * f32 + out[0..4].copy_from_slice(&vel[0].to_le_bytes()); + out[4..8].copy_from_slice(&vel[1].to_le_bytes()); + out[8..12].copy_from_slice(&vel[2].to_le_bytes()); + Ok(()) + }) + } + + /// Set a body's position. + /// + /// Reads 3 f32s from `pos_ptr`: [px, py, pz] + pub fn physics_set_body_position( + caller: Caller<'_, Self>, + world_id: u32, + body_id: u32, + pos_ptr: WasmPtr, + ) -> RtResult { + Self::cvt::(caller, AbiCall::PhysicsSetBodyPosition, |caller| { + let (mem, env) = Self::mem_env(caller); + let data = mem.deref_slice(pos_ptr, 12)?; + let pos = [ + f32::from_le_bytes(data[0..4].try_into().unwrap()), + f32::from_le_bytes(data[4..8].try_into().unwrap()), + f32::from_le_bytes(data[8..12].try_into().unwrap()), + ]; + env.instance_env.physics.set_body_position(world_id, body_id, pos); + Ok(()) + }) + } + + /// Set a body's linear velocity. + /// + /// Reads 3 f32s from `vel_ptr`: [vx, vy, vz] + pub fn physics_set_body_velocity( + caller: Caller<'_, Self>, + world_id: u32, + body_id: u32, + vel_ptr: WasmPtr, + ) -> RtResult { + Self::cvt::(caller, AbiCall::PhysicsSetBodyVelocity, |caller| { + let (mem, env) = Self::mem_env(caller); + let data = mem.deref_slice(vel_ptr, 12)?; + let vel = [ + f32::from_le_bytes(data[0..4].try_into().unwrap()), + f32::from_le_bytes(data[4..8].try_into().unwrap()), + f32::from_le_bytes(data[8..12].try_into().unwrap()), + ]; + env.instance_env.physics.set_body_velocity(world_id, body_id, vel); + Ok(()) + }) + } + + /// Set a body's rotation. + /// + /// Reads 4 f32s from `rot_ptr`: [rx, ry, rz, rw] + pub fn physics_set_body_rotation( + caller: Caller<'_, Self>, + world_id: u32, + body_id: u32, + rot_ptr: WasmPtr, + ) -> RtResult { + Self::cvt::(caller, AbiCall::PhysicsSetBodyRotation, |caller| { + let (mem, env) = Self::mem_env(caller); + let data = mem.deref_slice(rot_ptr, 16)?; + let rot = [ + f32::from_le_bytes(data[0..4].try_into().unwrap()), + f32::from_le_bytes(data[4..8].try_into().unwrap()), + f32::from_le_bytes(data[8..12].try_into().unwrap()), + f32::from_le_bytes(data[12..16].try_into().unwrap()), + ]; + env.instance_env.physics.set_body_rotation(world_id, body_id, rot); + Ok(()) + }) + } + + /// Apply a force to a body. + /// + /// Reads 3 f32s from `force_ptr`: [fx, fy, fz] + pub fn physics_add_force( + caller: Caller<'_, Self>, + world_id: u32, + body_id: u32, + force_ptr: WasmPtr, + ) -> RtResult { + Self::cvt::(caller, AbiCall::PhysicsAddForce, |caller| { + let (mem, env) = Self::mem_env(caller); + let data = mem.deref_slice(force_ptr, 12)?; + let force = [ + f32::from_le_bytes(data[0..4].try_into().unwrap()), + f32::from_le_bytes(data[4..8].try_into().unwrap()), + f32::from_le_bytes(data[8..12].try_into().unwrap()), + ]; + env.instance_env.physics.add_force(world_id, body_id, force); + Ok(()) + }) + } + + /// Apply an impulse to a body. + /// + /// Reads 3 f32s from `impulse_ptr`: [ix, iy, iz] + pub fn physics_add_impulse( + caller: Caller<'_, Self>, + world_id: u32, + body_id: u32, + impulse_ptr: WasmPtr, + ) -> RtResult { + Self::cvt::(caller, AbiCall::PhysicsAddImpulse, |caller| { + let (mem, env) = Self::mem_env(caller); + let data = mem.deref_slice(impulse_ptr, 12)?; + let impulse = [ + f32::from_le_bytes(data[0..4].try_into().unwrap()), + f32::from_le_bytes(data[4..8].try_into().unwrap()), + f32::from_le_bytes(data[8..12].try_into().unwrap()), + ]; + env.instance_env.physics.add_impulse(world_id, body_id, impulse); + Ok(()) + }) + } + + // ── Vehicle Physics ──────────────────────────────────────────────── + + /// Create a vehicle in a physics world. + /// + /// `params_ptr` points to the raw bytes of JPC_VehicleParams. + /// `params_len` is the byte length. + /// `pos_ptr` points to [f32; 3] (position). + /// `rot_ptr` points to [f32; 4] (rotation quaternion). + /// Writes the vehicle ID to `out`. + pub fn physics_create_vehicle( + caller: Caller<'_, Self>, + world_id: u32, + params_ptr: WasmPtr, + params_len: u32, + pos_ptr: WasmPtr, + rot_ptr: WasmPtr, + out: WasmPtr, + ) -> RtResult { + Self::cvt_ret::(caller, AbiCall::PhysicsCreateVehicle, out, |caller| { + let (mem, env) = Self::mem_env(caller); + let params = mem.deref_slice(params_ptr, params_len)?.to_vec(); + let pos_data = mem.deref_slice(pos_ptr, 12)?; + let rot_data = mem.deref_slice(rot_ptr, 16)?; + + let read_f32 = |slice: &[u8], off: usize| -> f32 { + f32::from_le_bytes(slice[off..off + 4].try_into().unwrap()) + }; + + let position = [read_f32(&pos_data, 0), read_f32(&pos_data, 4), read_f32(&pos_data, 8)]; + let rotation = [read_f32(&rot_data, 0), read_f32(&rot_data, 4), read_f32(&rot_data, 8), read_f32(&rot_data, 12)]; + + let vid = env.instance_env.physics + .create_vehicle(world_id, ¶ms, position, rotation) + .ok_or_else(|| WasmError::Wasm(anyhow!("Failed to create vehicle")))?; + + Ok(vid) + }) + } + + /// Set driver input for a vehicle. + /// + /// `forward`, `right`, `brake`, `handbrake` are IEEE 754 f32 bit patterns. + pub fn physics_set_vehicle_input( + caller: Caller<'_, Self>, + world_id: u32, + vehicle_id: u32, + forward_bits: u32, + right_bits: u32, + brake_bits: u32, + handbrake_bits: u32, + ) -> RtResult { + Self::cvt::(caller, AbiCall::PhysicsSetVehicleInput, |caller| { + let forward = f32::from_bits(forward_bits); + let right = f32::from_bits(right_bits); + let brake = f32::from_bits(brake_bits); + let handbrake = f32::from_bits(handbrake_bits); + + if !caller.data().instance_env.physics.set_vehicle_input(world_id, vehicle_id, forward, right, brake, handbrake) { + return Err(WasmError::Wasm(anyhow!("Vehicle or world not found")).into()); + } + Ok(()) + }) + } + + /// Get vehicle state (body transform, wheel states, engine RPM, gear). + /// + /// Returns data as a BytesSource containing the raw JPC_VehicleState bytes. + /// Writes the BytesSourceId to `out`. + pub fn physics_get_vehicle_state( + caller: Caller<'_, Self>, + world_id: u32, + vehicle_id: u32, + out: WasmPtr, + ) -> RtResult { + Self::cvt_ret::(caller, AbiCall::PhysicsGetVehicleState, out, |caller| { + let physics = caller.data().instance_env.physics.clone(); + let state_bytes = physics + .get_vehicle_state(world_id, vehicle_id) + .ok_or_else(|| WasmError::Wasm(anyhow!("Vehicle or world not found")))?; + + let env = caller.data_mut(); + let source_id = env.create_bytes_source(state_bytes.into())?; + Ok(source_id.0) + }) + } + + /// Destroy a vehicle from a physics world. + pub fn physics_destroy_vehicle( + caller: Caller<'_, Self>, + world_id: u32, + vehicle_id: u32, + ) -> RtResult { + Self::cvt::(caller, AbiCall::PhysicsDestroyVehicle, |caller| { + if !caller.data().instance_env.physics.destroy_vehicle(world_id, vehicle_id) { + return Err(WasmError::Wasm(anyhow!("Vehicle or world not found")).into()); + } + Ok(()) + }) + } + + /// Get default vehicle parameters. + /// + /// Returns a BytesSource containing the raw JPC_VehicleParams bytes with defaults. + /// Writes the BytesSourceId to `out`. + pub fn physics_get_vehicle_params_default( + caller: Caller<'_, Self>, + out: WasmPtr, + ) -> RtResult { + Self::cvt_ret::(caller, AbiCall::PhysicsGetVehicleParamsDefault, out, |caller| { + use std::mem; + let default_bytes = unsafe { + let mut params: joltc_sys::JPC_VehicleParams = mem::zeroed(); + joltc_sys::JPC_VehicleParams_default(&mut params); + let bytes = std::slice::from_raw_parts( + ¶ms as *const joltc_sys::JPC_VehicleParams as *const u8, + mem::size_of::(), + ); + bytes.to_vec() + }; + + let env = caller.data_mut(); + let source_id = env.create_bytes_source(default_bytes.into())?; + Ok(source_id.0) + }) + } + + /// Get transforms for all bodies in a world (bulk query). + /// + /// Returns data as a BytesSource containing packed records: + /// For each body: [body_id: u32, pos: [f32;3], rot: [f32;4], vel: [f32;3]] = 44 bytes per body + /// + /// Writes the BytesSourceId to `out`. + pub fn physics_get_all_body_transforms( + caller: Caller<'_, Self>, + world_id: u32, + out: WasmPtr, + ) -> RtResult { + Self::cvt_ret::(caller, AbiCall::PhysicsGetAllBodyTransforms, out, |caller| { + let physics = caller.data().instance_env.physics.clone(); + let transforms = physics + .get_all_body_transforms(world_id) + .ok_or_else(|| WasmError::Wasm(anyhow!("World not found")))?; + + // Pack into bytes: 44 bytes per body + let mut buf = Vec::with_capacity(transforms.len() * 44); + for (body_id, pos, rot, vel) in &transforms { + buf.extend_from_slice(&body_id.to_le_bytes()); + for v in pos { + buf.extend_from_slice(&v.to_le_bytes()); + } + for v in rot { + buf.extend_from_slice(&v.to_le_bytes()); + } + for v in vel { + buf.extend_from_slice(&v.to_le_bytes()); + } + } + + let env = caller.data_mut(); + let source_id = env.create_bytes_source(buf.into())?; + Ok(source_id.0) + }) + } } type Fut<'caller, T> = Box>; diff --git a/crates/core/src/host/wasmtime/wasmtime_module.rs b/crates/core/src/host/wasmtime/wasmtime_module.rs index 48ac0fe80e2..0690120eb16 100644 --- a/crates/core/src/host/wasmtime/wasmtime_module.rs +++ b/crates/core/src/host/wasmtime/wasmtime_module.rs @@ -50,7 +50,7 @@ impl WasmtimeModule { WasmtimeModule { module } } - pub const IMPLEMENTED_ABI: abi::VersionTuple = abi::VersionTuple::new(10, 4); + pub const IMPLEMENTED_ABI: abi::VersionTuple = abi::VersionTuple::new(10, 5); pub(super) fn link_imports(linker: &mut Linker) -> anyhow::Result<()> { const { assert!(WasmtimeModule::IMPLEMENTED_ABI.major == spacetimedb_lib::MODULE_ABI_MAJOR_VERSION) }; diff --git a/modules/physics-test-cs/Lib.cs b/modules/physics-test-cs/Lib.cs new file mode 100644 index 00000000000..c2510023e97 --- /dev/null +++ b/modules/physics-test-cs/Lib.cs @@ -0,0 +1,380 @@ +using SpacetimeDB; + +namespace SpacetimeDB.Modules.PhysicsTest; + +[Table(Accessor = "physics_config", Public = true)] +public partial struct PhysicsConfig +{ + [PrimaryKey] + public uint Id; // always 0 + public uint WorldId; + public uint GroundBodyId; +} + +[Table(Accessor = "physics_body", Public = true)] +public partial struct PhysicsBodyRow +{ + [PrimaryKey] + [AutoInc] + public uint Id; + public uint JoltBodyId; + public string Label; + public float PosX; + public float PosY; + public float PosZ; + public float RotX; + public float RotY; + public float RotZ; + public float RotW; + public float VelX; + public float VelY; + public float VelZ; +} + +[Table(Accessor = "vehicle", Public = true)] +public partial struct VehicleRow +{ + [PrimaryKey] + [AutoInc] + public uint Id; + public uint JoltVehicleId; + public string Label; + // Body transform + public float PosX, PosY, PosZ; + public float RotX, RotY, RotZ, RotW; + public float VelX, VelY, VelZ; + // Vehicle state + public float SpeedKmh; + public float EngineRpm; + public int CurrentGear; + // Wheel states (FL, FR, RL, RR) - positions for client rendering + public float WheelFL_PosX, WheelFL_PosY, WheelFL_PosZ; + public float WheelFL_RotX, WheelFL_RotY, WheelFL_RotZ, WheelFL_RotW; + public float WheelFR_PosX, WheelFR_PosY, WheelFR_PosZ; + public float WheelFR_RotX, WheelFR_RotY, WheelFR_RotZ, WheelFR_RotW; + public float WheelRL_PosX, WheelRL_PosY, WheelRL_PosZ; + public float WheelRL_RotX, WheelRL_RotY, WheelRL_RotZ, WheelRL_RotW; + public float WheelRR_PosX, WheelRR_PosY, WheelRR_PosZ; + public float WheelRR_RotX, WheelRR_RotY, WheelRR_RotZ, WheelRR_RotW; +} + +[Table(Accessor = "physics_tick_schedule", Scheduled = "PhysicsTick", ScheduledAt = nameof(ScheduledAt))] +public partial struct PhysicsTickSchedule +{ + [PrimaryKey] + [AutoInc] + public ulong ScheduledId; + public ScheduleAt ScheduledAt; +} + +public static partial class Module +{ + const float TICK_RATE = 1f / 30f; // 30 Hz + + [Reducer(ReducerKind.Init)] + public static void Init(ReducerContext ctx) + { + Log.Info("PhysicsTest: Initializing..."); + + // Create physics world with gravity + var worldId = Physics.CreateWorld(0f, -9.81f, 0f); + Log.Info($"PhysicsTest: Created world {worldId}"); + + // Create ground plane (static box, 100x1x100) + var groundId = Physics.CreateBody( + worldId, + PhysicsShapeType.Box, + halfExtents: (50f, 0.5f, 50f), + position: (0f, -0.5f, 0f), + motionType: PhysicsMotionType.Static, + friction: 0.5f + ); + Log.Info($"PhysicsTest: Created ground body {groundId}"); + + // Save config + ctx.Db.physics_config.Insert(new PhysicsConfig + { + Id = 0, + WorldId = worldId, + GroundBodyId = groundId, + }); + + // Create a few dynamic bodies to test + SpawnSphere(ctx, worldId, "ball_1", 0f, 10f, 0f, 0.5f, 1f, 0.8f); + SpawnSphere(ctx, worldId, "ball_2", 2f, 15f, 0f, 0.5f, 1f, 0.5f); + SpawnBox(ctx, worldId, "crate_1", -2f, 12f, 0f, 0.5f, 0.5f, 0.5f, 2f); + + // Schedule the physics tick loop + ctx.Db.physics_tick_schedule.Insert(new PhysicsTickSchedule + { + ScheduledAt = new ScheduleAt.Interval(TimeSpan.FromSeconds(TICK_RATE)), + }); + + Log.Info("PhysicsTest: Init complete. Physics tick scheduled at 30Hz."); + } + + static void SpawnSphere(ReducerContext ctx, uint worldId, string label, float x, float y, float z, float radius, float mass, float restitution) + { + var bodyId = Physics.CreateBody( + worldId, + PhysicsShapeType.Sphere, + radius: radius, + position: (x, y, z), + motionType: PhysicsMotionType.Dynamic, + mass: mass, + restitution: restitution, + friction: 0.5f + ); + + ctx.Db.physics_body.Insert(new PhysicsBodyRow + { + JoltBodyId = bodyId, + Label = label, + PosX = x, PosY = y, PosZ = z, + RotX = 0, RotY = 0, RotZ = 0, RotW = 1, + }); + + Log.Info($"PhysicsTest: Spawned sphere '{label}' bodyId={bodyId} at ({x},{y},{z})"); + } + + static void SpawnBox(ReducerContext ctx, uint worldId, string label, float x, float y, float z, float hx, float hy, float hz, float mass) + { + var bodyId = Physics.CreateBody( + worldId, + PhysicsShapeType.Box, + halfExtents: (hx, hy, hz), + position: (x, y, z), + motionType: PhysicsMotionType.Dynamic, + mass: mass, + restitution: 0.3f, + friction: 0.6f + ); + + ctx.Db.physics_body.Insert(new PhysicsBodyRow + { + JoltBodyId = bodyId, + Label = label, + PosX = x, PosY = y, PosZ = z, + RotX = 0, RotY = 0, RotZ = 0, RotW = 1, + }); + + Log.Info($"PhysicsTest: Spawned box '{label}' bodyId={bodyId} at ({x},{y},{z})"); + } + + [Reducer] + public static void PhysicsTick(ReducerContext ctx, PhysicsTickSchedule args) + { + var config = ctx.Db.physics_config.Id.Find(0); + if (config == null) + return; + + var worldId = config.Value.WorldId; + + // Step physics + Physics.Step(worldId, TICK_RATE); + + // Get all body transforms from Jolt + var states = Physics.GetAllBodyTransforms(worldId); + + // Update body table rows + foreach (var state in states) + { + foreach (var row in ctx.Db.physics_body.Iter()) + { + if (row.JoltBodyId == state.BodyId) + { + var updated = row; + updated.PosX = state.PosX; + updated.PosY = state.PosY; + updated.PosZ = state.PosZ; + updated.RotX = state.RotX; + updated.RotY = state.RotY; + updated.RotZ = state.RotZ; + updated.RotW = state.RotW; + updated.VelX = state.VelX; + updated.VelY = state.VelY; + updated.VelZ = state.VelZ; + ctx.Db.physics_body.Id.Update(updated); + break; + } + } + } + + // Sync vehicle states + SyncVehicleStates(ctx, worldId); + } + + [Reducer] + public static void SpawnBall(ReducerContext ctx, float x, float y, float z, float radius, float mass, float restitution) + { + var config = ctx.Db.physics_config.Id.Find(0); + if (config == null) + { + Log.Error("PhysicsTest: No physics world! Call Init first."); + return; + } + + var count = 0; + foreach (var _ in ctx.Db.physics_body.Iter()) count++; + + SpawnSphere(ctx, config.Value.WorldId, $"ball_{count}", x, y, z, radius, mass, restitution); + } + + [Reducer] + public static void ApplyImpulse(ReducerContext ctx, uint bodyTableId, float ix, float iy, float iz) + { + var config = ctx.Db.physics_config.Id.Find(0); + if (config == null) return; + + var body = ctx.Db.physics_body.Id.Find(bodyTableId); + if (body == null) + { + Log.Error($"PhysicsTest: Body {bodyTableId} not found"); + return; + } + + Physics.AddImpulse(config.Value.WorldId, body.Value.JoltBodyId, ix, iy, iz); + Log.Info($"PhysicsTest: Applied impulse ({ix},{iy},{iz}) to '{body.Value.Label}'"); + } + + // ── Vehicle Reducers ─────────────────────────────────────────── + + [Reducer] + public static void SpawnVehicle(ReducerContext ctx, float x, float y, float z, string label) + { + var config = ctx.Db.physics_config.Id.Find(0); + if (config == null) + { + Log.Error("PhysicsTest: No physics world!"); + return; + } + + var worldId = config.Value.WorldId; + + // Get default sedan params from Jolt + var vehicleParams = Physics.GetVehicleParamsDefault(); + if (vehicleParams.Length == 0) + { + Log.Error("PhysicsTest: Failed to get default vehicle params"); + return; + } + + var vehicleId = Physics.CreateVehicle(worldId, vehicleParams, position: (x, y, z)); + if (vehicleId == 0) + { + Log.Error("PhysicsTest: Failed to create vehicle"); + return; + } + + ctx.Db.vehicle.Insert(new VehicleRow + { + JoltVehicleId = vehicleId, + Label = label, + PosX = x, PosY = y, PosZ = z, + RotX = 0, RotY = 0, RotZ = 0, RotW = 1, + }); + + Log.Info($"PhysicsTest: Spawned vehicle '{label}' id={vehicleId} at ({x},{y},{z})"); + } + + [Reducer] + public static void SetVehicleInput(ReducerContext ctx, uint vehicleTableId, float forward, float right, float brake, float handbrake) + { + var config = ctx.Db.physics_config.Id.Find(0); + if (config == null) return; + + var vehicle = ctx.Db.vehicle.Id.Find(vehicleTableId); + if (vehicle == null) + { + Log.Error($"PhysicsTest: Vehicle {vehicleTableId} not found"); + return; + } + + Physics.SetVehicleInput(config.Value.WorldId, vehicle.Value.JoltVehicleId, forward, right, brake, handbrake); + if (forward != 0 || right != 0) + Log.Info($"VehicleInput: joltId={vehicle.Value.JoltVehicleId} fwd={forward} right={right}"); + } + + /// + /// Update vehicle rows from Jolt state. Called each PhysicsTick. + /// + static void SyncVehicleStates(ReducerContext ctx, uint worldId) + { + foreach (var row in ctx.Db.vehicle.Iter()) + { + var stateBytes = Physics.GetVehicleStateRaw(worldId, row.JoltVehicleId); + if (stateBytes == null) continue; + + var d = stateBytes.AsSpan(); + var updated = row; + + // Body transform (same layout as JPC_VehicleState) + int o = 0; + updated.PosX = ReadF(d, o); o += 4; + updated.PosY = ReadF(d, o); o += 4; + updated.PosZ = ReadF(d, o); o += 4; + updated.RotX = ReadF(d, o); o += 4; + updated.RotY = ReadF(d, o); o += 4; + updated.RotZ = ReadF(d, o); o += 4; + updated.RotW = ReadF(d, o); o += 4; + updated.VelX = ReadF(d, o); o += 4; + updated.VelY = ReadF(d, o); o += 4; + updated.VelZ = ReadF(d, o); o += 4; + updated.SpeedKmh = ReadF(d, o); o += 4; + updated.EngineRpm = ReadF(d, o); o += 4; + updated.CurrentGear = ReadI(d, o); o += 4; + + // 4 wheels: each has pos(3f), rot(4f), steer_angle(f), rotation_angle(f), angular_velocity(f), suspension_length(f), has_contact(bool+padding) + // JPC_WheelState = 11 floats + 1 bool (aligned to 4) = 48 bytes + ReadWheel(d, ref o, out updated.WheelFL_PosX, out updated.WheelFL_PosY, out updated.WheelFL_PosZ, + out updated.WheelFL_RotX, out updated.WheelFL_RotY, out updated.WheelFL_RotZ, out updated.WheelFL_RotW); + ReadWheel(d, ref o, out updated.WheelFR_PosX, out updated.WheelFR_PosY, out updated.WheelFR_PosZ, + out updated.WheelFR_RotX, out updated.WheelFR_RotY, out updated.WheelFR_RotZ, out updated.WheelFR_RotW); + ReadWheel(d, ref o, out updated.WheelRL_PosX, out updated.WheelRL_PosY, out updated.WheelRL_PosZ, + out updated.WheelRL_RotX, out updated.WheelRL_RotY, out updated.WheelRL_RotZ, out updated.WheelRL_RotW); + ReadWheel(d, ref o, out updated.WheelRR_PosX, out updated.WheelRR_PosY, out updated.WheelRR_PosZ, + out updated.WheelRR_RotX, out updated.WheelRR_RotY, out updated.WheelRR_RotZ, out updated.WheelRR_RotW); + + ctx.Db.vehicle.Id.Update(updated); + + // Debug: log wheel contact and position every ~1 second (every 30 ticks) + if (row.Id == 1) + { + // Re-read has_contact from the raw bytes for debugging + // Each wheel: 7 floats (pos+rot) + 4 floats (steer/rotation/angular/suspension) + 1 bool + 3 pad = 48 bytes + // Body: 13 floats = 52 bytes + int wheelStart = 52; // offset after body data + bool[] contacts = new bool[4]; + for (int w = 0; w < 4; w++) + { + int contactOffset = wheelStart + w * 48 + 44; // 11 floats = 44 bytes, then bool + contacts[w] = d[contactOffset] != 0; + } + Log.Info($"Vehicle pos=({updated.PosX:F2},{updated.PosY:F2},{updated.PosZ:F2}) speed={updated.SpeedKmh:F1} gear={updated.CurrentGear} rpm={updated.EngineRpm:F0} contacts=[{contacts[0]},{contacts[1]},{contacts[2]},{contacts[3]}]"); + } + } + } + + static float ReadF(ReadOnlySpan d, int offset) => + System.Buffers.Binary.BinaryPrimitives.ReadSingleLittleEndian(d[offset..]); + + static int ReadI(ReadOnlySpan d, int offset) => + System.Buffers.Binary.BinaryPrimitives.ReadInt32LittleEndian(d[offset..]); + + static void ReadWheel(ReadOnlySpan d, ref int o, + out float px, out float py, out float pz, + out float rx, out float ry, out float rz, out float rw) + { + px = ReadF(d, o); o += 4; + py = ReadF(d, o); o += 4; + pz = ReadF(d, o); o += 4; + rx = ReadF(d, o); o += 4; + ry = ReadF(d, o); o += 4; + rz = ReadF(d, o); o += 4; + rw = ReadF(d, o); o += 4; + // Skip: steer_angle, rotation_angle, angular_velocity, suspension_length (4 floats = 16 bytes) + o += 16; + // Skip: has_contact (bool + 3 bytes padding) + o += 4; + } +} diff --git a/modules/physics-test-cs/physics-test-cs.csproj b/modules/physics-test-cs/physics-test-cs.csproj new file mode 100644 index 00000000000..43eb99b6e86 --- /dev/null +++ b/modules/physics-test-cs/physics-test-cs.csproj @@ -0,0 +1,8 @@ + + + + + + + +