Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
This changelog summarizes meaningful work on Joy Engine, Joy Editor, FORM LAB, PARTICLE LAB and the engine examples. Entries are grouped by day, newest first, using America/Detroit dates. Work before 10-08-2026 happened inside [JoyGames](https://github.com/trentpolack/JoyGames); its [CHANGELOG](https://github.com/trentpolack/JoyGames/blob/develop/CHANGELOG.md) is the historical record for that period.

## 10-16-2026
### SDF Collisions
- Added signed distance field colliders to `physics` as a prototype: `SdfShape` (analytical primitives, or any triangle mesh baked into a voxel distance grid that tolerates open or overlapping procedural meshes) and `PhysicsWorld::create_sdf_body`. Contacts follow Giles and Andrews' caching and importance-sampling method (Pacific Graphics 2026) through a chained narrow-phase dispatcher, so worlds without SDF bodies simulate exactly as before, and SDF simulations stay deterministic natively and in WebAssembly. See [SDF colliders](docs/rust-simulation.md#sdf-colliders).
- Added the SDF COLLISIONS example: FORM props piling up in an SDF bin beside the same props as bounding boxes, with live costs and a `--bench`/`?bench=1` benchmark. Web builds' `THIRD-PARTY-NOTICES.txt` now also carries the reference implementation's MIT license.

### Rust Runtime Default
- Retired the deprecated engine, editor, lab, and example runtime implementation and its renderer dependencies. Rust is the shared native/browser implementation; canonical npm scripts wrap Cargo and browser builds.
- Replaced current runtime and contribution documentation with Rust guidance and generated rustdoc, retaining useful Node tooling, asset schemas, capture orchestration, and recorded compatibility fixtures.
Expand Down
66 changes: 58 additions & 8 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ path = "examples/form-building/main.rs"
name = "form-destruction"
path = "examples/form-destruction/main.rs"

[[example]]
name = "sdf-collisions"
path = "examples/sdf-collisions/main.rs"

[[test]]
name = "engine"
path = "test/rust/main.rs"
Expand All @@ -58,7 +62,10 @@ bytemuck = { version = "1.25.2", features = ["derive"] }
serde = { version = "1.0.229", features = ["derive"] }

# float_roundtrip keeps authored documents byte-stable through a parse/serialize round trip.
serde_json = { version = "1.0.154", features = ["preserve_order", "float_roundtrip"] }
serde_json = { version = "1.0.154", features = [
"preserve_order",
"float_roundtrip",
] }
indexmap = { version = "2.14.2", features = ["serde"] }
thiserror = "2.0.21"
log = "0.4.34"
Expand All @@ -82,14 +89,25 @@ image-webp = "0.2.4"
rapier3d = { version = "0.36.1", features = ["enhanced-determinism"] }

# Rendering, windows, and UI (rendering and platform layers). These match joy-apps' pins.
wgpu = { version = "30.0.1", default-features = false, features = ["std", "wgsl", "webgpu", "webgl", "vulkan", "metal", "dx12", "gles"] }
wgpu = { version = "30.0.1", default-features = false, features = [
"std",
"wgsl",
"webgpu",
"webgl",
"vulkan",
"metal",
"dx12",
"gles",
] }
winit = "0.30.13"
egui = "0.36.2"
egui-wgpu = { version = "0.36.2", default-features = false }

[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
# egui-winit's pinned release does not build for WebAssembly; browsers use `browser::egui_input` instead.
egui-winit = { version = "0.36.2", default-features = false, features = ["clipboard"] }
egui-winit = { version = "0.36.2", default-features = false, features = [
"clipboard",
] }
pollster = "0.4.0"
env_logger = "0.11.11"
image = { version = "0.25.10", default-features = false, features = ["png"] }
Expand All @@ -101,7 +119,9 @@ cpal = "0.18.2"
# Native file dialogs for authoring tools (platform layer, behind development::FileDialogs); the version joy-apps uses.
# The desktop portal backend loads libdbus at run time (falling back to zenity), so Linux builds need no system
# packages; Wayland parent-window support is left out.
rfd = { version = "0.17.2", default-features = false, features = ["xdg-portal"] }
rfd = { version = "0.17.2", default-features = false, features = [
"xdg-portal",
] }

[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]

Expand All @@ -113,10 +133,40 @@ wasm-bindgen = "0.2.106"
wasm-bindgen-futures = "0.4.56"
js-sys = "0.3.83"
web-sys = { version = "0.3.83", features = [
"AudioBuffer", "AudioBufferSourceNode", "AudioContext", "AudioContextOptions", "AudioContextState", "AudioDestinationNode",
"AudioNode", "AudioParam", "AudioScheduledSourceNode", "BaseAudioContext", "Blob", "BlobPropertyBag", "Clipboard", "ClipboardEvent", "CssStyleDeclaration",
"DataTransfer", "Document", "Element", "EventTarget", "File", "FileList", "GainNode", "Gamepad", "GamepadButton", "GamepadMappingType", "HtmlCanvasElement", "HtmlElement", "HtmlInputElement", "Location",
"Navigator", "Storage", "Url", "UrlSearchParams", "Window",
"AudioBuffer",
"AudioBufferSourceNode",
"AudioContext",
"AudioContextOptions",
"AudioContextState",
"AudioDestinationNode",
"AudioNode",
"AudioParam",
"AudioScheduledSourceNode",
"BaseAudioContext",
"Blob",
"BlobPropertyBag",
"Clipboard",
"ClipboardEvent",
"CssStyleDeclaration",
"DataTransfer",
"Document",
"Element",
"EventTarget",
"File",
"FileList",
"GainNode",
"Gamepad",
"GamepadButton",
"GamepadMappingType",
"HtmlCanvasElement",
"HtmlElement",
"HtmlInputElement",
"Location",
"Navigator",
"Storage",
"Url",
"UrlSearchParams",
"Window",
] }
console_error_panic_hook = "0.1.7"
console_log = "1.0.0"
Expand Down
2 changes: 2 additions & 0 deletions docs/gameplay.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ Unknown authored components remain data; only registered runtime behavior runs.
## Physics and Destruction
Rapier provides rigid bodies and configurable raycast vehicles through engine-owned types. Vehicle tires are suspension rays rather than solid rolling colliders. Keep barriers thick enough and speeds appropriate for the discrete solver. See [simulation and physics](rust-simulation.md) for determinism, ownership, and vehicle limits.

Bodies are boxes, spheres, or planes by default. `SdfShape` colliders give non-convex procedural geometry (FORM meshes baked to distance grids, or analytical shapes) its true silhouette in collision; see [SDF colliders](rust-simulation.md#sdf-colliders).

`ChunkStructure` batches damage and support evaluation into a single committed revision; the renderer and physics backend synchronize from that revision. Project-specific collapse, damage, and debris rules stay in the game.

## Authoring
Expand Down
14 changes: 14 additions & 0 deletions docs/rust-simulation.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ The headless simulation layer of Joy Engine's Rust crate (`src/environment`, `sr
| --- | --- |
| `environment` | `EnvironmentOptions` (serde), `EnvironmentSystem`, `EnvironmentFrame` |
| `physics` | `PhysicsWorld`, `BodyOptions`/`BODY_DEFAULTS`, `VehicleOptions`/`WheelOptions`/`VEHICLE_DEFAULTS`/`WHEEL_DEFAULTS`, `BodyHandle`, `VehicleHandle`, `RaycastHit` |
| `physics` (SDF colliders) | `SdfShape`, `SdfPrimitive`, `SdfBakeOptions`/`SDF_BAKE_DEFAULTS`, `SdfCollisionStatistics`, `PhysicsWorld::create_sdf_body` |
| `physics` (backend contract) | `PhysicsBackend` (implemented by `PhysicsWorld`), `PhysicsResidency`, `PhysicsCollisionShape`, `PhysicsMutationBatch`, `PhysicsQueryHit`, `create_removal_mutation_batch` |
| `physics::destruction` | `ChunkStructure`, `DestructibleChunkDefinition`, `RadialDamage`, `ChunkChangeSet`, `ChunkDamageState`, `ChunkRaycastHit`, `ChunkSupportOptions`, `ChunkSupportState`, `create_chunk_mesh_data`, `DestructionError` |
| `gameplay` (documents) | `EntityDefinition`, `EntityTransform`, `LevelData`, `parse_level`/`serialize_level`, `LevelDocument`, `ObjectDocument`, `resolve_level_objects`, `asset_path` |
Expand Down Expand Up @@ -40,6 +41,19 @@ Physics uses rapier3d 0.36 with `enhanced-determinism`; identical worlds stepped
- Rolling/brake friction is divided by grounded-wheel count before clamping. Opposite side impulses are not applied to dynamic ground bodies.
- Wheel ray queries see new/moved colliders after the next solver step. Discrete solver behavior and authored tuning can produce wheelies or tunneling; test driving interactively.

## SDF Colliders
`SdfShape` describes a solid by its signed distance: `SdfShape::primitive` takes an analytical box, sphere, capsule, cylinder, or torus, and `SdfShape::from_triangle_mesh` bakes any triangle mesh (FORM output included) into a voxel grid of distances and gradients, with no convex decomposition. Baking signs the grid by winding number along X, Y, and Z grid lines with a majority vote, so open seams, flipped winding, and overlapping parts still enclose a solid. A shape precomputes its bounds, unit-density mass properties, and high-curvature seed points once; it is an immutable shared handle, so every body built from it reuses the grid. `PhysicsWorld::create_sdf_body` takes the shape plus ordinary `BodyOptions` (mass, pose, materials, and damping apply; the primitive fields are ignored), and the shape's frame is the body frame.

Contacts follow Giles and Andrews, *Real-Time Collision Handling for Signed Distance Fields via Caching and Importance Sampling* (Pacific Graphics 2026), adapted from its MIT-licensed reference implementation (see `src/physics/sdf/LICENSE-sdf-dcd-demo`; web builds include it in `THIRD-PARTY-NOTICES.txt`). For each pair with overlapping bounds, Nelder-Mead minimizes a smooth maximum of both distances, minus a repulsion from points already found, from a few seeds: last step's points (a temporal cache that also carries the solver's warm-start impulses) and a random high-curvature seed. A chained narrow-phase dispatcher handles pairs involving an SDF collider (against SDFs, boxes, spheres, and planes) and passes every other pair to rapier's default dispatcher unchanged, so worlds without SDF bodies simulate exactly as before. Each manifold point becomes its own rapier manifold, which rapier's contact clustering merges by normal. `PhysicsWorld::sdf_collision_statistics` reports the latest fixed step's pairs, contacts, iterations, and distance queries.

The prototype departs from the paper where the engine needed it:
- Normals and depths are read one-sidedly at the optimized point's projections onto both surfaces, keeping the deeper reading, because the paper's "deeper field at the point" rule tilts resting contacts wherever an edge or corner meets a face.
- Cached points live in both bodies' frames and ride their poses, refine to a quarter of the tolerance, keep their position unless the refinement is deeper, and skip optimization when the bodies barely moved relative to each other.
- Pairs with SDF colliders opt out of rapier's contact recycling (through a no-op contact filter hook), which would otherwise freeze the cache's step-by-step refinement while bodies settle.
- Sampling uses fixed per-pair seeded streams and the solver's scalar math, so identical worlds produce identical results, natively and in WebAssembly.

Limits: collision stays discrete, so fast or thin bodies can tunnel; SDF colliders ignore meshes, compounds, sensors, shape casts, and continuous collision detection; and several contact points spread across a small curved-on-flat overlap, which acts as rolling resistance (an SDF sphere launched at 4 m/s on a plane rolls at about 1.85 m/s after three seconds, where rapier's ball keeps 2.57 m/s). Cost is CPU work per overlapping pair. In the [SDF collisions example](../examples/sdf-collisions/README.md) benchmark, 50 non-convex props cost about 4 ms per fixed step on average and 8 ms once the pile is complete natively, and about twice that in WebAssembly, against 0.1 to 0.2 ms for the same props as boxes. Baked grids take about 0.4 to 2.6 MB at 40 to 48 cells per side.

## Destruction
`ChunkStructure` is solver-independent and uses `f64` arithmetic and portable distance helpers, preserving recorded deterministic results. Damage is queued and applied atomically at `commit()`: overlapping hits accumulate, chunks at zero health leave first, then support is evaluated once and unsupported chunks detach in the same revision (the support diagnostics describe that evaluation). Support is a geometric masonry rule: chunks touching the ground plane anchor, stacked contacts and side-face contacts (never corners) carry support, and horizontal offsets spend the cantilever allowance; a shortest-path search with generation-order tie-breaking resolves reach. Revisions increment only when chunks leave and on every `reset()`. `create_chunk_mesh_data` returns closed boxes as plain `MeshAssetData` (positions, indices, per-vertex material tints, and `chunkCount`/`closedInteriorSurfaces` metadata) for the rendering layer to upload.

Expand Down
1 change: 1 addition & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Small feature demonstrations live beside the engine and consume its public `joy-
| [Scene Effects](scene-effects/README.md) | Lighting, shadows, and post-processing inspection | `/examples/scene-effects/` |
| [FORM Building](form-building/README.md) | Editable procedural recipes and baked building assets | `/examples/form-building/` |
| [FORM Destruction](form-destruction/README.md) | Surface-projected impacts and synchronized chunk removal | `/examples/form-destruction/` |
| [SDF Collisions](sdf-collisions/README.md) | Signed distance colliders for FORM props beside bounding boxes, with a native and browser benchmark | Local only (no `gameSite` metadata yet) |

## Run
From the JoyGames root, replace `form-destruction` with any example folder name:
Expand Down
23 changes: 23 additions & 0 deletions examples/sdf-collisions/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# SDF Collisions
A Rust example that compares signed distance field (SDF) colliders with bounding boxes. Two bins fill with the same FORM props, dropped in the same poses on the same schedule: the left bin collides through each prop's SDF, the right through the box the engine could offer before. Chairs hook legs, jacks interlock, and staples nest on the left; on the right the same props stack as crates.

```bash
npm run dev --workspace @joy-engine/example-sdf-collisions
npm run build --workspace @joy-engine/example-sdf-collisions
npm run test --workspace @joy-engine/example-sdf-collisions
npm run bench --workspace @joy-engine/example-sdf-collisions
npm run capture --workspace @joy-engine/example-sdf-collisions -- --built
```

Drag to orbit, scroll to zoom, Space or DROP MORE to add ten props per bin, P to pause, and R to reset. The sidebar shows each bin's CPU physics time per fixed step, the SDF contact work of the latest step, and each collider's build cost and memory.

## Props and Colliders
The chair, jack, staple, and I-beam are FORM scripts in `assets/` built from axis-aligned boxes. Each evaluates once, draws through the engine's PBR path, and bakes into a 40-cell voxel distance grid with `SdfShape::from_triangle_mesh`; every body shares its prop's grid. The ring uses the analytical `SdfPrimitive::Torus`, and its FORM mesh only draws it. The box bin uses each collider's local bounds, and both bins give a prop the same mass (its SDF volume times a pine-like density).

Box-only props keep the baked colliders identical on every target: FORM's spheres and rotated instances use platform trigonometry, which can differ in the last bit between native and WebAssembly builds.

## Benchmark
`--bench` (native) runs a headless scenario, prints one line, and exits; `?bench=1` runs it in browsers before the demo starts, logs it to the console, and shows it in the sidebar. Both bins fill with 50 props over 1,200 fixed steps. The report gives CPU milliseconds per fixed step for the whole run and for the final, settled two seconds, plus a hash of every body's final position. Matching SDF hashes between a native run and a browser run show that SDF contact generation is deterministic across targets.

## Verification
`test/rust/example_sdf_collisions.rs` checks that the props bake into colliders that fit their meshes, and that the benchmark piles repeat bit for bit with no SDF prop leaving its bin. `test/rust/physics.rs` covers SDF resting contact, stacking, raycasts, validation, mesh baking, and determinism in the engine itself. Browser captures add canvas startup and image evidence. CPU timing does not measure GPU execution.
18 changes: 18 additions & 0 deletions examples/sdf-collisions/assets/beam.form
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# I-BEAM: two flanges joined by a web. Flanges nest into each other's channels when beams stack.
project meta(name="I-beam", info="SDF COLLISIONS / PROP")

param length = 1.1 meta(min=0.4, max=2, step=0.05)
param flange = 0.42 meta(min=0.2, max=0.8, step=0.02)
param depth = 0.42 meta(min=0.2, max=0.8, step=0.02)
param plate = 0.07 meta(min=0.03, max=0.15, step=0.01)

# White vertices, so the material's base color shows as authored.
color(1, 1, 1)
let unit = meshBox(1, 1, 1)
let finish = materialPbr(rgba(0.5, 0.64, 0.9, 1), 0.15, 0.45)

repeat 2 as side {
let y = (side*2 - 1)*(depth/2 - plate/2)
instance(unit, vector(0, y, 0), vector(0, 0, 0), vector(length, plate, flange), finish)
}
instance(unit, vector(0, 0, 0), vector(0, 0, 0), vector(length, depth - plate*2, plate), finish)
25 changes: 25 additions & 0 deletions examples/sdf-collisions/assets/chair.form
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# CHAIR: a seat, four legs, and a back rest. Its legs and back hook into neighbors, the classic case for
# convex decomposition and the paper's "pile of chairs". Axis-aligned boxes only, so the baked collider is
# identical on every platform.
project meta(name="Chair", info="SDF COLLISIONS / PROP")

param seat = 0.7 meta(min=0.4, max=1.2, step=0.05)
param leg = 0.5 meta(min=0.2, max=1, step=0.05)
param back = 0.6 meta(min=0.2, max=1, step=0.05)
param thickness = 0.08 meta(min=0.04, max=0.2, step=0.01)

# White vertices, so the material's base color shows as authored.
color(1, 1, 1)
let unit = meshBox(1, 1, 1)
let finish = materialPbr(rgba(0.86, 0.5, 0.26, 1), 0, 0.62)
let inset = seat/2 - thickness/2

instance(unit, vector(0, 0, 0), vector(0, 0, 0), vector(seat, thickness, seat), finish)
repeat 2 as column {
repeat 2 as row {
let x = (column*2 - 1)*inset
let z = (row*2 - 1)*inset
instance(unit, vector(x, -(leg + thickness)/2, z), vector(0, 0, 0), vector(thickness, leg, thickness), finish)
}
}
instance(unit, vector(0, (back + thickness)/2, -inset), vector(0, 0, 0), vector(seat, back, thickness), finish)
22 changes: 22 additions & 0 deletions examples/sdf-collisions/assets/jack.form
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# JACK: three crossed bars with blocky end caps. Every orientation lands on a different set of arms.
project meta(name="Jack", info="SDF COLLISIONS / PROP")

param span = 1 meta(min=0.4, max=1.6, step=0.05)
param bar = 0.14 meta(min=0.06, max=0.3, step=0.01)
param cap = 0.24 meta(min=0.1, max=0.4, step=0.01)

# White vertices, so the material's base color shows as authored.
color(1, 1, 1)
let unit = meshBox(1, 1, 1)
let finish = materialPbr(rgba(0.3, 0.86, 0.82, 1), 0.05, 0.45)
let reach = span/2 - cap/2

instance(unit, vector(0, 0, 0), vector(0, 0, 0), vector(span - cap, bar, bar), finish)
instance(unit, vector(0, 0, 0), vector(0, 0, 0), vector(bar, span - cap, bar), finish)
instance(unit, vector(0, 0, 0), vector(0, 0, 0), vector(bar, bar, span - cap), finish)
repeat 2 as side {
let sign = side*2 - 1
instance(unit, vector(sign*reach, 0, 0), vector(0, 0, 0), vector(cap, cap, cap), finish)
instance(unit, vector(0, sign*reach, 0), vector(0, 0, 0), vector(cap, cap, cap), finish)
instance(unit, vector(0, 0, sign*reach), vector(0, 0, 0), vector(cap, cap, cap), finish)
}
16 changes: 16 additions & 0 deletions examples/sdf-collisions/assets/ring.form
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# RING: the render mesh of the analytical torus collider (major radius 0.36, minor radius 0.1). Its collider is
# an exact distance function, so this mesh never feeds physics.
project meta(name="Ring", info="SDF COLLISIONS / PROP")

param major = 0.36 meta(min=0.2, max=0.8, step=0.01)
param minor = 0.1 meta(min=0.03, max=0.3, step=0.01)
param detail = 40 meta(min=12, max=64, step=4)

let profile = geometry()
repeat 17 as i {
let angle = i/16*6.283185307179586
addPoint(profile, vector(major + cos(angle)*minor, sin(angle)*minor, 0))
}
let ring = revolve(profile, detail)
color(ring, rgba(0.86, 0.3, 0.44, 1))
output("Ring", smoothNormals(ring))
Loading
Loading