Replies: 2 comments
|
Thanks for putting this together. I think the proposal makes sense overall, and I agree with the direction of making Jolt an My decision is that I’m open to approving this direction, but I want to narrow the initial scope. For now, I’d like us to I’d like to hold off on the broader render graph and ECS changes for now. C3 and C4 touch areas of the engine that I want to So my preferred next step is to analyze this further and turn it into a smaller implementation plan focused on making the
So as you or your team works on the plugin system, I can prepare the core engine so that it is plugin-system-ready. I appreciate the level of detail in the proposal. It gives us a strong map of the longer-term needs, but I’d like the first In summary, I think having a plugin-system is a really good idea. |
|
@untoldengine Below is the narrowed phase-1 plan you asked for: physics-plugin readiness only, current physics preserved as the default, minimum seams for a Jolt prototype, and no touches to the render-graph or ECS areas you want to review separately. Physics Plugin Readiness — Phase 1 Implementation PlanStatus: Draft for review — no code yet 1. Scope decisionFrom the maintainer's review of the full proposal:
Phase 1 therefore commits to exactly five constraints:
In / out at a glance (numbering from the full proposal)
2. Design summaryThe full rationale lives in the original proposal; this section restates only what phase 1 builds, with the narrowing decisions called out. 2.1
|
| File | Contents |
|---|---|
PhysicsBackend.swift |
protocol, PhysicsCapabilities, PhysicsWorldConfiguration, descriptor/batch/event/query value types |
PhysicsBackendRegistry.swift |
install/uninstall, validation, rollback |
PhysicsCoordinator.swift |
body-set diffing, step orchestration, transform sync, event dispatch |
UntoldDefaultPhysicsBackend.swift |
wraps the existing free functions; reports capabilities = [] |
Edited files:
| File | Change |
|---|---|
ECS/Components.swift |
add ColliderComponent, RigidBodyComponent |
Systems/RegistrationSystem.swift |
cleanup-handler registrations for the two new components (same pattern as the existing 30 handlers) |
Renderer/UntoldEngine.swift |
fixed-step loop calls PhysicsCoordinator.step instead of updatePhysicsSystem (~5 lines) |
Systems/PhysicsSystem.swift |
no behavioural edits; gravity constant reads from PhysicsWorldConfiguration (default (0,-9.8,0)); public API (applyForce, …) unchanged |
Scripting/USCSystem path |
fire OnCollision from the event sink |
docs/Extensions/ |
"Creating a Physics Backend Plugin" guide + license-policy note (MIT/BSD/Zlib/Apache-2.0; no LGPL/GPL) |
Not touched: RenderExtensions.swift, RenderExtensionPlugins.swift, GraphBuilder.swift, Entity.swift, ComponentPool.swift, Scenes.swift, asset formats, Package.swift dependencies.
4. PR plan
Four small PRs, strictly stacked (they build on each other), each individually gated on "existing demos and tests behave identically".
| PR | Branch | Contents | Gate |
|---|---|---|---|
| A | feature/physics_backend_interface |
Protocol + value types + registry + the two components. Nothing calls any of it yet. | Compiles everywhere; registry unit tests (install/rollback/double-install); zero runtime change by construction |
| B | feature/physics_backend_default |
PhysicsCoordinator + default backend wrap + runFrame seam + configurable gravity |
Before/after replay test: recorded scene produces bit-identical transforms vs. current updatePhysicsSystem; all demos unchanged |
| C | feature/physics_events |
Event sink, buffers, PhysicsEvents subscriptions, USC OnCollision wiring |
Dormant with no external backend (proven by test using a mock backend); mock-backend contact test fires USC event |
| D | feature/physics_raycast_facade |
PhysicsQuery.raycast + octree fallback |
Fallback parity test vs. direct octree query; mock-backend routing test |
A mock backend (test target only) stands in for Jolt throughout — it lets every seam be exercised in CI without any native dependency, and it doubles as the reference implementation for the backend-author guide.
The Jolt plugin repo work (xcframework build spike, C shim, sphere-on-plane) can start as soon as PR A merges — it only needs the protocol to compile against; PRs B–D land while the build spike proceeds in parallel.
5. Acceptance criteria for phase 1 as a whole
- An app that installs nothing: identical behaviour, verified by the replay test and demo suite (macOS, iOS, visionOS).
- Existing Arcade plugins (CoolWater/CoolCloth/CoolSaber) compile and run unchanged.
- The mock backend demonstrates: body add/remove lifecycle, kinematic write + transform read-back, contact/trigger events reaching both
PhysicsEventssubscribers and a USConCollisionscript, raycast routing. - Core package still has zero external dependencies and zero C++.
- Documentation exists for a third party to write a backend without reading engine source.
Phase 2 (after the Jolt prototype proves the seams, and on the maintainer's timetable): render interpolation (C1.3, opt-in), shapecast/overlap, mesh colliders + cooked-shape convention (C6), CharacterControllerComponent. C3/C4 remain on the maintainer's separate review path and phase 2 will consume whatever lands there rather than proposing its own versions.
6. Decisions to lock before PR A
Reduced from the original six — the rest either resolved themselves in the narrowing (D3 → §2.3) or belong to the Jolt repo, not the core.
| # | Decision | Recommendation |
|---|---|---|
| D4 | Collision layer width in RigidBodyComponent |
32-bit layer + 32-bit mask (as in the full proposal) |
| D5 | Where the Jolt plugin repo lives | Maintainer's call; affects only the docs' links |
| D7 (new) | Should PhysicsWorldConfiguration be settable per scene or global? |
Global for phase 1 (matches current single-world reality); per-scene is additive later |
D1 (double precision) and D2 (determinism) are Jolt-build flags — they move to the plugin repo's first decision log and don't block any core PR.
Uh oh!
There was an error while loading. Please reload this page.
Core Engine Changes for Plugin Systems
Status: Proposal — no code yet
Scope: UntoldEngine core only. Jolt, particles, fluids and vegetation themselves live in external plugin packages; this document covers only what the core engine must provide so those plugins can exist.
Baseline:
developbranch as of 2026-07-29.1. Purpose
The Engine Systems Implementation Plan (physics / particles / fluids / vegetation) assumes a set of engine facilities that do not exist yet. This proposal enumerates the core-engine changes required to unblock those systems, ordered so that every change is justified by a concrete plugin need, and constrained by the project owner's direction on physics:
Everything below follows from three rules:
Package.swiftkeepsdependencies: [].2. Where the engine stands today
A short, factual inventory — the gaps here drive the change list.
2.1 Physics
Systems/PhysicsSystem.swiftis a set of free functions: per-entity force/moment accumulators (KineticComponent), RK4 integration overPhysicsComponents, hard-coded gravity(0, -9.8, 0), writes directly intoLocalTransformComponent. It runs inside a fixed-timestep accumulator loop inUntoldRenderer.runFrame(Renderer/UntoldEngine.swift:604,fixedStep = 1/60, clamped to 5 substeps), game mode only.There is no collision detection of any kind — no collider component, no broadphase, no contacts, no triggers, no constraints, no physics raycast, no sleeping, no character controller.
USCBuilder.onCollision(tag:)exists in the scripting layer but nothing ever fires it. There is no interpolation of render transforms between fixed steps.2.2 Plugin API
The render-extension system (
Renderer/RenderExtensions.swift,RenderExtensionPlugins.swift) is genuinely self-contained for rendering: shader library / render / compute pipeline / resource / argument-buffer registries, per-framebuildGraph, staged passes, ownership validation with atomic install/rollback. CoolWater, CoolCloth and CoolSaber all live on it.But it is render-only. There is:
RenderExtension— noupdate(dt:), andRenderPassContextcarries neither delta-time nor a frame index;registerCustomSystem(ECS/Scenes.swift:351) runs inside the physics loop but is game-mode-only, has no ordering control and no unregister;RenderExtension;buildGraphand passes run per eye, and every plugin must hand-writeguard context.currentEye == 0dedup;dependencies:overload ofaddPassisinternal);RenderStageenum whose earliest stage isafterOpaqueLighting— nothing before shadows/G-buffer, no pre-simulation stage.2.3 Frame loop & threading
Single-threaded frame loop; no job system, no task graph, no frame/temp allocator. The render graph is rebuilt, topologically sorted and resource-planned every frame (twice per frame in stereo XR) with no caching.
2.4 GPU-driven rendering
No
MTLIndirectCommandBufferusage anywhere. GPU frustum/HZB culling exists (Systems/CullingSystem.swift) but the compacted visible list is read back and draws are CPU-encoded. This blocks the particles and vegetation designs (both are built on indirect draws).2.5 ECS
ComponentMaskis a singleUInt64(MAX_COMPONENTS = 64,Utils/Globals.swift:25) with ~35 component types already registered, and component IDs are assigned lazily in encounter order (not stable across runs). Per-component destruction cleanup handlers exist (ECS/ComponentRegistry.swift) — that part is already plugin-friendly — but there is no entity-creation event and octree deregistration is hard-coded inScene.destroyEntityFinalize.2.6 Spatial queries
pickEntity(CPU octree + GPU acceleration-structure narrow phase),OctreeSystemAABB queries, and analytic plane picking exist. Plugin-owned geometry is invisible to all of them — CoolCloth had to build its own brute-force picking store.2.7 What existing plugins had to invent
Measured across CoolWater / CoolCloth / CoolSaber, each plugin reimplements: (a) an
NSLock-guarded singleton to smuggle per-frame simulation state from the app'sgameUpdateclosure into encode closures; (b) per-eye deduplication; (c) its own picking; (d) lazy first-use geometry initialization detected on the render thread; (e) its own occlusion handling; (f) its own metallib platform-selection boilerplate. Every one of these is a core-engine gap.3. Proposed changes
Changes are grouped C1–C8. Each states which plugin system needs it and the compatibility impact. API sketches are illustrative surface design, not final signatures.
C1. Physics provider interface (the centerpiece)
Needed by: Jolt plugin (all stages), particles (collision promotion, 2.7), vegetation (static trunk colliders, 4.9), fluids (obstacle SDF coupling, 3B.3).
Compatibility: none — the default backend reproduces today's behaviour exactly.
C1.1
PhysicsBackendprotocolA new core protocol that owns the simulation step. The engine talks to a physics backend; which one is a registration decision.
Registration mirrors the render-extension pattern (single active backend, install-before-
UntoldRenderer.create, atomic with rollback):Design points carried over from the implementation plan, because they belong in the interface contract, not in the Jolt plugin:
readTransformsfills one contiguous caller-supplied buffer (UnsafeMutableBufferPointersemantics). The contract forbids per-body calls per frame — this keeps the FFI cost flat regardless of backend.drainEventsis defined to run afterstep()returns, on the simulation thread. Backends that fire callbacks from worker threads (Jolt does, duringUpdate()) must buffer internally into fixed-capacity arrays with an overflow counter. The engine never receives a callback mid-step.PhysicsWorldConfiguration: metres/kg/seconds, Y-up, quaternion orientation — matching both the current system and Jolt's defaults.[](integration only). API calls gated on an absent capability are defined no-ops that log once — so gameplay code can be written against the full interface and degrade gracefully.C1.2 New authoring components
Current
PhysicsComponents/KineticComponentstay untouched (they are the default backend's state). New, backend-agnostic authoring components describe intent:ColliderComponent— shape descriptor:.sphere(r),.box(halfExtents),.capsule(r, h),.cylinder,.convexHull(meshRef),.triangleMesh(meshRef | cookedBlobRef),.heightfield(ref),.compound([...]); plus local offset, friction, restitution,isTrigger.RigidBodyComponent— motion type (static/kinematic/dynamic), mass (or density), collision layer and mask (engine-levelUInt32layer bits; the backend maps them — 32 bits per the plan's "early decisions"), gravity scale, initial velocities, sleep policy.CharacterControllerComponent— stage 4 (see §4); capsule dims, step height, slope limit, up vector.These are data-only and meaningful to any backend. An entity with only the legacy
PhysicsComponents/KineticComponentpair behaves exactly as today under any backend (the Jolt backend would represent it as a gravity-affected body with no collider, or simply leave it to the default integrator — decision D3, §7).The existing per-component cleanup mechanism (
ComponentRegistry.register(componentType:handlerId:priority:cleanup:)) is already sufficient for backends to destroy native bodies on entity destruction — no core change needed there; the proposal just documents it as the supported path.C1.3 Transform authority and interpolation
Today
updatePhysicsSystemwritesLocalTransformComponentdirectly mid-substep. That model can't support interpolation or a backend running off-thread later. Proposed split:dt; engine keeps the previous and current fixed-step transform per dynamic body (a small ring, not per-component storage).lerp/slerp(prev, curr, accumulator/fixedStep)intoLocalTransformComponentand marks it dirty (feedingOctreeSystem.markDirtyas today).syncBodiesFromECSbefore the step.SceneRootTransform(world shift) is applied at the sync boundary in both directions, the same wayScenePickingSystemalready inverse-transforms rays.Interpolation is opt-in per world config (
interpolateRenderTransforms: Bool, defaultfalse) so the default path is bit-identical to today until a project turns it on.C1.4 Event model
New engine-level event types delivered through a physics event sink after each step:
contactBegan / contactPersisted / contactEnded (entityA, entityB, point, normal, impulse),triggerEntered / triggerExited,bodyActivated / bodyDeactivated. Delivery:FrameEvents(UntoldRenderer.onUpdate), e.g.PhysicsEvents.onContact(entity:) -> EventSubscription;USCSystemfinally gets itsOnCollisionwired: the engine forwardscontactBeganinto the already-built (but never-fired)USCBuilder.onCollision(tag:)path. This is the first user-visible payoff of the whole effort and costs the core almost nothing.Fixed-capacity per-frame event buffers with an overflow counter surfaced in the profiler — never allocate mid-step.
C1.5 Unified spatial queries
A thin engine facade,
PhysicsQuery, routes to the active backend when it reports theraycastcapability, else falls back toOctreeSystemAABB queries (best-effort, documented as approximate). Longer term this letsScenePickingSystemoffer aphysicsPreferredbackend option, but picking integration is explicitly out of scope for stage 1.C1.6 Default backend
Systems/PhysicsSystem.swift's free functions get wrapped (not rewritten) asUntoldDefaultPhysicsBackend: PhysicsBackend.runFrame's fixed-step loop callsbackend.step(fixedStep)instead ofupdatePhysicsSystem(fixedStep). All existing public API (applyForce,setVelocity, …) keeps working — the functions become forwarding calls that also remain valid against the legacy components. One hard-coded constant becomes configurable in passing: gravity moves intoPhysicsWorldConfiguration(default(0, -9.8, 0)— behaviour unchanged).This refactor is the stage-0 deliverable: zero behaviour change, proven by the existing demos and a before/after replay test.
C1.7 What stays out of core
The Jolt plugin package (working name
UntoldJoltPhysics) owns: the vendored Jolt source + build script +Jolt.xcframework, theextern "C"shim (CJoltBridge, opaque handles, no exceptions across the boundary, POD-only header), layer-matrix upload as a data blob,JobSystemThreadPool(Jolt's own, per the plan — the engine job system does not block this), cooked-shape loading, and thePhysicsBackendconformance. The engine never links C++.C2. Engine plugin lifecycle — update hooks, teardown, frame identity
Needed by: every plugin. This is the single most-reinvented workaround in the existing Arcade plugins.
Compatibility: additive; all new protocol requirements get default no-op implementations, so existing plugins compile unchanged (API version stays 1 — note the registry's version check is exact-equality,
RenderExtensionPlugins.swift:202, so not bumping it is load-bearing).Additions to
RenderExtension(all defaulted):And to
RenderPassContext(Systems/GraphBuilder.swift:216):deltaTime: Float,frameIndex: UInt64, andisPrimaryEye: Bool(true exactly once per frame) — killing the hand-writtencurrentEye == 0boilerplate and theNSLocksingleton pattern in one move.Two smaller items in the same area:
registerCustomSystemgets a handle-based unregister and a documented ordering position, or is deprecated in favour offixedUpdateabove.UntoldEngineShaderSupport(the#if os(...)+ resource-name dance every plugin currently copies from CoolWater).C3. Render graph — caching, stages, dependencies, GPU-driven draws
Needed by: particles (2.2 indirect args kernel, 2.4 sorting), vegetation (4.3 GPU culling → indirect draws), fluids 3A (volumetric pass placement), and frame-rate health for all of them.
Compatibility: additive; existing stage semantics preserved.
buildGraph+ topo sort + hazard scheduling + resource planning currently run every frame (twice in XR). Introduce a structural hash / dirty flag: rebuild only when the extension set, pass set, resource declarations or viewport change. Pass closures re-execute every frame; the graph doesn't recompile. This is a pure-internal change with a large payoff once particle/vegetation plugins add 5–10 passes each.RenderStageenum (closed enum stays closed — plugins still can't invent stages, which preserves ordering guarantees) with at minimum:.frameStart(compute-only, before shadows/G-buffer — particle spawn/update, vegetation culling, fluid advection all live here) and.beforeShadows(vegetation depth into cascades). Stage list order remains the single source of execution order.internaladdPass(id:dependencies:...)overload to public, restricted to same-owner passes plus a curated set of exported engine pass IDs (depth prepass, HZB build, scene color resolve). Cross-plugin dependencies stay disallowed (ownership validation already enforces this).MTLIndirectCommandBufferas a first-class extension resource type (RenderExtensionBufferDescriptorsibling), plusdispatchThreadgroups(indirectBuffer:)conveniences and ICB usage declarations inRenderGraphResourceUsageso hazard scheduling sees them. The plan's particles design (spawn → update → indirect-args, zero readback) is unimplementable without this.C4. ECS capacity and lifecycle
Needed by: all four systems together will add an estimated 10–15 component types to a mask that has 64 slots with ~35 taken.
Compatibility: memory-layout change, no API change.
ComponentMaskfromUInt64to a fixed 2×UInt64(128 components). TouchesECS/Entity.swift,ComponentPool, and every mask intersection inqueryEntities— mechanical but must land before plugins start claiming slots.onEntityCreated/onEntityDestroyedbroadcast on theFrameEventsbus for systems that need entity-level (not component-level) bookkeeping — the Jolt backend's body table being the first consumer. Also: un-hard-codeOctreeSystem.shared.unregisterEntityinScene.destroyEntityFinalizeby moving it onto the same mechanism.C5. Fixed-timestep loop adjustments
Needed by: physics interpolation (C1.3), consistent plugin simulation.
Compatibility: two bug-level fixes flagged, both opt-in or behaviour-neutral.
physicsAccumulator / fixedStep) to the render side for transform interpolation.StreamingRegionManager.updateandLODSystem.updateare currently fed the constantfixedStepinstead of real delta time (Renderer/UntoldEngine.swift) — they run on a fake 60 Hz clock. Fix to pass measured dt (flagged as a separate, tiny PR since it's a latent bug, not a feature).@MainActorassertions became lock-based no-ops inECS/Scenes.swift:15). ThePhysicsBackendcontract simply says "step and drain are called from the frame thread; backends may parallelize internally" — which is exactly Jolt's model with its own thread pool, and defers an engine job system (see C7).C6. Asset format extensions
Needed by: cooked physics shapes (1.6), particle effect descriptors (2.8), NanoVDB bricks / flipbook atlases (3A), vegetation instance cells and impostors (4.2/4.5).
Compatibility: additive chunk types; file version stays 1.
.untold. Reserve aUntoldChunkTyperange (e.g. ≥ 0x8000) for extension-owned chunks, identified by a(pluginID, chunkKind, chunkVersion)header inside the chunk payload. The container format (AssetFormat/UntoldFormat.swift) gains no new semantics — unknown chunks are already skippable by design; this just formalizes it.Shape::SaveBinaryStateoutput) are stored as plugin chunks or sidecar files keyed by(backendID, backendVersion)— Jolt cooked data is not forward-compatible across Jolt versions, so the key is part of the format, and the baker invalidates on mismatch.untoldengineCLI plugin hooks. Abake-extsubcommand (or a plugin-discovery mechanism in the existingassets/exportflow) that shells out to plugin-provided bakers, so shape cooking / impostor baking / atlas packing don't fork the CLI. Design detail deferred; the reserved chunk range is the only stage-1 requirement.C7. Job scheduler and temp allocator — deliberately deferred
Needed by: eventually everything; blocking nothing now.
Per the implementation plan's own guidance ("ship first with Jolt's
JobSystemThreadPool, swap later — don't block the physics spike"), the engine job system (work-stealing pool, dependencies,n-1workers) and frame allocator are not prerequisites for stages 0–4. They enter the roadmap when particles/vegetation baking or multi-system CPU work demands them (M3+). The only stage-1 commitment is that thePhysicsBackendcontract doesn't preclude it (it doesn't — step/drain threading is already specified in C5.3).C8. Native dependency and licensing policy
Needed by: the Jolt plugin (first), NanoVDB later.
Compatibility: none — policy and documentation, mostly in the plugin repos.
Since backends live outside the engine package, the xcframework machinery (vendored source,
Scripts/build-native.sh, per-platform slices,lipo, SPMbinaryTarget) lives in the plugin repo, following the plan's Phase 0.1 checklist. The core engine's obligations are only:docs/Extensions/):CFooC shim target → Swift wrapper →PhysicsBackendconformance; the "no exceptions across the boundary, POD-only headers, opaque handles" rules; and the metallib-style per-platform selection helper (C2).CONTRIBUTING.mdlicense policy for anything that might ever be upstreamed: MIT / BSD / Zlib / Apache-2.0 only; no LGPL (static-link + App Store is unsatisfiable), no GPL/AGPL. PlusTHIRD_PARTY_LICENSES.mdconventions for plugin repos.binaryTargetworks when the engine is consumed both via SPM and as a framework — transitive-framework embedding is the known trap.4. Staged rollout
Stages map to the owner's "prototype first, then collision events, mesh colliders, character controllers" sequencing. Each stage is independently shippable and gate-checked.
UntoldDefaultPhysicsBackend; existing Arcade plugins compile unchangedOnCollisionwiring), C1.5 (query facade), C5.1 (interpolation on)CharacterControllerComponentfinalizedCharacterVirtualbinding (notCharacter): ground detect, steps, slopes, moving platforms.frameStart/.beforeShadowsstages, public named dependencies, ICB), C4.3, C7 beginsDeferred exactly as the plan says: soft bodies, vehicles, ragdolls, cloth-via-Jolt (CoolCloth's XPBD stays as-is), live fluid solvers.
5. Delivery plan — PR breakdown
The change groups were designed to be independent, so most of the work can proceed as parallel PRs. The split below is by reviewable unit, not one PR per change group: some groups are too large for a single PR (C1), others too small to stand alone (C5.1 folds into interpolation). Each PR gets its own
feature/<name>branch.Land first, alone
Everything else rebases on these two, so they go in before any track starts.
ComponentMask64 → 128feature/ecs_component_mask_128Entity.swift,ComponentPooland every query intersection — the worst PR to rebase late, the easiest to review early. Zero behaviour change.fixedStepinstead of real dtfeature/streaming_lod_real_dtParallel tracks
Tracks are independent of each other and can be worked simultaneously. Within a track PRs are stacked (serial), because they touch the same files — parallelizing inside a track just trades PR count for rebase pain.
PhysicsBackendprotocol +ColliderComponent/RigidBodyComponent(C1.1, C1.2) → PR 4: default backend wrap +runFrameloop swap (C1.6) → PR 5: event sink + USCOnCollisionwiring (C1.4) → PR 6: transform sync + interpolation (C1.3 + C5.1)Renderer/UntoldEngine.swift(runFrame)RenderExtensionupdate/teardown hooks + dt/frameIndex/isPrimaryEye inRenderPassContext(C2)runFrame(small),GraphBuilder.swift(context struct)GraphBuilder.swift,RenderExtensions.swiftC1.5 (query facade) rides with PR 5 or lands as a small follow-up; C4.3 (entity lifecycle broadcast) and C6.3 (CLI bake hooks) are stage-5-adjacent follow-ups with no slot in this first wave. C7 has no PR by design.
Merge-order notes
runFrameis touched by PRs 4, 6 and 7;GraphBuilder.swiftby PRs 7–10. Whichever track merges second rebases — cheap if the tracks stay in flight for days, painful if for weeks. Suggested tie-break: merge PR 7 early (it is small and every other plugin consumer wants it), let the physics and graph tracks rebase over it.6. Compatibility guarantees
applyForce,setVelocity, etc. keep working against the legacy components under any backend.Package.swiftkeeps zero dependencies. Jolt and its toolchain never enter the engine package.7. Decisions to lock before stage 1 (owner input wanted)
JPH_DOUBLE_PRECISION)PhysicsComponents-only entities under the Jolt backendRigidBodyComponent) — cleanest incremental story, allows both to coexist in one sceneuntoldengineorg — affects C8 docs onlyAppendix A — Change ↔ system dependency matrix
.frameStart)All reactions