An engine designed for hybrid space sim and immersive sim games. Physics more flexible than Star Citizen, entity system more than Caves o
tertius-engine ARCHITECTURE.md
338 kB · 1393 lines
Markdown
at main

Tertius architecture #

Tertius is an engine for immersive sims and space sims. It combines a Caves of Qud style entity model with nested reference frames, double-precision physics and a deferred Vulkan renderer. This document records the design decisions. Each module must follow it. If a module must break a rule here, the change goes into this document first.

Scope #

These features are out of scope. Do not design for them.

  • Multiplayer of any kind.
  • Time warp. Every frame steps at the same fixed rate.
  • Mods. The design keeps them possible, but no mod loading exists yet.
  • The tidal gravity of a parent. The contents of a frame feel the frame's own field, minus the frame's proper acceleration, and in a turning frame its rotation terms (see "Turning frames" under "Physics"). They do not feel how the parent's field changes across the frame.

Toolchain #

Item Choice
Language C# 15 preview on .NET 11 (LangVersion preview)
Dev shell nix develop (see flake.nix)
Physics Jolt 5.6.0 through JoltPhysicsSharp 2.23.0, double-precision build libjoltc_double
Graphics Vulkan 1.3 through Silk.NET 2.23.0, MoltenVK on macOS
Windowing and input Silk.NET.Windowing and Silk.NET.Input (GLFW)
Shaders GLSL 4.60, compiled to SPIR-V with glslc at build time
Tests xUnit v3 on Microsoft.Testing.Platform, with AwesomeAssertions. Coverage uses Microsoft.Testing.Extensions.CodeCoverage. Every test project also loads the extension in tools/Tertius.TestRecord, which records a passed full run for the pre-commit hook (tools/README.md, "The test record").
Images StbImageWriteSharp writes the PNG captures, in PngCaptureSink. The tests read PNG files with StbImageSharp, and write the approved reference images and the failure files of snapshot tests with StbImageWriteSharp.
Video ffmpeg 9.0.1 (ffmpeg-headless in the dev shell), a separate process that encodes the frames of a headless run with --video. FfmpegVideoSink finds it on the search path. The Host tests read the videos with its ffmpeg and ffprobe.
Benchmarks BenchmarkDotNet 0.15.8
Union generator and analyzer analyzers/Tertius.Unions, a Roslyn incremental source generator and a diagnostic analyzer on Microsoft.CodeAnalysis.CSharp 4.14.0. Every project references it as an analyzer. Its tests run both in process on Microsoft.CodeAnalysis.CSharp 5.9.0, because 4.14.0 does not parse a built-in union.
Style analyzer analyzers/Tertius.Style, a diagnostic analyzer and its code fixes on Microsoft.CodeAnalysis.CSharp and Microsoft.CodeAnalysis.CSharp.Workspaces 4.14.0. Every project but the union generator references it as an analyzer (see "Style analyzer"). Its tests run it in process on 5.9.0, as the union tests do.
Slop metrics tools/Tertius.Slop, a console program that measures the erosion and the verbosity of src from the syntax alone, on Microsoft.CodeAnalysis.CSharp 5.9.0, which parses C# 15. The verbosity also runs jscpd for the clone lines. It prints JSON for the tools that read it (tools/README.md, "Slop metrics"). The git hooks keep the metrics of each commit in git notes under refs/notes/slop, and its history command prints them as CSV. Its tests are in tests/Tertius.Slop.Tests.
Code cleanup tools/cleanup.sh (tools/README.md, "Code cleanup"). dotnet format from the SDK applies the IDE rules of Roslyn and the rules of Roslynator.Analyzers 5.0.1 that .editorconfig turns on. Then CSharpier 1.3.0, from nixpkgs in the dev shell, formats the code at 120 columns.
Platforms The flake supports aarch64-darwin, x86_64-linux and aarch64-linux.

The dev shell builds the double-precision Jolt library with Nix from native/joltc.nix. That file pins the joltc commit and the Jolt tag by hash. The dev shell sets TERTIUS_JOLTC_DIR to the folder of the library. The script native/build-joltc.sh is a fallback. It builds the same sources without a hash check, into native/osx-arm64 or native/linux-x64, and it supports only these two platforms. Both builds apply one patch to joltc, native/joltc-thread-temp-allocator.patch. joltc keeps a temp allocator for system and character updates, a stack allocator that is not thread-safe, and the patch makes it one per calling thread instead of one per process. So several backends in one process, such as parallel tests, step at the same time on different threads. Each backend serializes its own native work and state with its own lock (JoltPhysicsBackend.Gate).

Directory.Build.targets copies the Jolt library next to the output of every project that sets <UsesJolt>true</UsesJolt>. It takes the library from TERTIUS_JOLTC_DIR. When that variable is not set, it takes the library from the folder of the script. It also copies the Vulkan loader from TERTIUS_VULKAN_DIR next to the output of every project that sets <UsesVulkan>true</UsesVulkan>. It defines TERTIUS_CONTRACTS in Debug and Checked builds, so the contract checks compile out of Release builds. Checked has the contracts of Debug and the optimized code of Release, and the test runs use it (tools/test-all.sh runs Checked and Release). Debug stays unoptimized for the debugger. Directory.Build.props turns tiered compilation off in every project whose name ends in .Tests, so a test process compiles each method fully optimized from the start. With tiering on, hot code such as World.Tick stayed unoptimized for most of a Physics run, because the runtime waits for a pause in new compiles that a test run seldom has. In this document, a Debug build means a build with contracts: Debug or Checked.

Projects and dependency rules #

src/Tertius.Core        math, handles, messages, contracts, TripleBuffer, PoseTree, FrameTree with the numeric
                        frame rules, the rails library (Kepler and spin math), the motion intent union,
                        gravity fields and media beside them, the loose-body step, physics and render seams
src/Tertius.Simulation  World, entities, components, rails components, chunked gameplay runs, router, sync point,
                        snapshots, spatial index, render snapshot builder, blueprints
src/Tertius.Physics     Jolt backend: one PhysicsSystem per frame, intents, gravity, hand-off, ghosts
src/Tertius.Rendering   Vulkan deferred renderer: G-buffer, depth ranges, lighting, shadows, impostors, the PNG
                        capture sink
src/Tertius.Host        threads, window, input, tick pacing, scripted and headless runs, state dumps,
                        the video capture sink, wiring of all modules
src/Tertius.Demo        the vertical slice game: its scenes and components (its scripts are in scripts/)
tests/Tertius.*.Tests   one test project per engine module, and Tertius.Host.Tests also tests the Demo;
                        Tertius.Rendering.Tests also holds FLIP and the snapshot test check
tests/Tertius.Testing   shared test helpers: FaultInjectingBackend, WorldTesting, Scenes, test components
benchmarks/             BenchmarkDotNet benchmarks
  • Tertius.Core depends on nothing but the base library.
  • Tertius.Simulation, Tertius.Physics and Tertius.Rendering depend only on Tertius.Core. They never reference each other. The test projects are the exception. Tertius.Testing and the Simulation, Physics and Host test projects reference several modules together. The reason is that every physics test runs the real World on the real Jolt backend.
  • Tertius.Host references all modules and connects them through the seams in Tertius.Core.
  • Tertius.Demo references Tertius.Host.

Threads and the tick #

The simulation runs at a fixed 60 ticks per second. Physics steps once per tick with the same time step.

Thread Work
Main The window and input. macOS requires this.
Gameplay workers Entity runs, in chunks of 64 entities, through Parallel.For. In a large world, also four pieces of the sync point (see below).
Physics One Jolt step for every active frame. Jolt uses its own job threads.
Simulation Runs the ticks and their sync points, and a scripted run's steps. In the window, it paces the ticks by the wall clock.
Render Draws the latest render snapshot and interpolates between the last two.

The Main, Simulation and Render threads exist only in the window. A headless run has none of them. Its calling thread runs the ticks, the steps of the script and the captures (see "Host and scripted runs").

One tick has three parts. First, one thread prepares the tick. Then, during the parallel part, gameplay and physics run at the same time. Both read only state that is fixed for the tick: the front snapshot of tick N-1 and the frame tree. Last, during the sync part, one thread merges the results.

In a large world, the sync thread hands four pieces of the sync part to the gameplay workers, as independent work items. Each one runs on the workers only when WorldOptions.MaxDegreeOfParallelism is not 1 and the piece has at least a threshold of work. One Parallel.For costs about 5 us and 2 KB, so a small world runs every piece on the sync thread and pays nothing. The thresholds of steps 8 and 10 were measured on an Apple M1 Max with all workers (ter-sn28), and the comment on each constant gives its measurement. Step 8 also ends each change that a game makes outside a tick, through the same method, with the same pieces and thresholds.

Step Piece on the workers Threshold Work item
8 Copy the rows that gameplay wrote 8,192 rows that gameplay wrote One chunk of 64 slots
8 Find the new grid cell of each moved entity 65,536 moved entities One of 32 equal ranges of the moved list
10 Join the outboxes, expand the targets, sort the pairs by recipient 6,000 messages A block of chunks, then a slot range
11 Build the render snapshot 8,192 slots 1,024 slots

Each piece ends in the same state, bit for bit, as on the sync thread, in every run. A work item writes only its own chunks, slots or range of a list. What it gives to state that items share waits in a per-item list, and the sync thread joins those lists in chunk, slot or range order, never in the order in which the workers finish. The outboxes of the gameplay chunks join in the same way. Each step below gives its own ordering rule. Tests compare each piece on the workers with its path on one thread. Each subsystem with a piece takes the workers, or none when the world has one thread, and keeps its threshold in an internal property that refuses a value below 1. The tests of all four pieces force the threshold down to 1, so that a small world runs the piece on the workers, and a test of the render snapshot also builds a world of more than 8,192 slots at the default threshold.

The engine's part of a tick follows the rule that hot paths allocate nothing after warm-up, with one accepted exception: the bookkeeping of each Parallel.For call, about 2 KB of short-lived garbage. The gameplay part makes one call in every tick of a world with more than one chunk, when there is more than one worker. In a large world, the sync part adds one call for each piece in the table above, and two for the routing, one for each phase. So a tick of a large world on all workers allocates about 12 KB, under 1 MB per second at 60 ticks per second, which the garbage collector frees in its cheapest generation. A set of worker threads that the world keeps for its whole life would remove this garbage. But it would replace the .NET task scheduler in every parallel part of the tick, so it waits until a measurement shows that the garbage costs time. With one worker, or below its threshold, a piece runs in a plain loop on the sync thread and adds nothing to this garbage. A test checks that a routing on the sync thread allocates nothing after warm-up.

The preparation does these steps in this order:

  • If a change outside a tick made the numeric update due, run it, derive the attachments and swap the snapshot. So a body that a spawn outside a tick made joins its numeric frame before physics creates it.
  • Hand physics the batch of spawns and destroys made outside the tick, before the step that can touch them.
  • Update the frame tree. Fix the accelerations of the falling numeric frames for the step. Give each following frame its step plan and the acceleration that lands it on the plan. The step plan is the step record of the anchor's motion intent in the intents of the step, or without one the record of a kinematic body that holds still (see "Numeric frames"). Physics reads them, and the sync point follows them.
  • Apply the queued changes of the indexes that entity queries start from, so that the workers only read them.
  • Reserve a pool of free slots for each chunk that has live entities, in chunk order. A pool holds the larger of WorldOptions.SpawnPoolSize (8 by default) and twice the spawns of its chunk in the last tick, up to 1,024. When the pools need more slots, the tables grow here. A spawn during the tick takes its slot from the pool of its chunk, so spawned handles are the same in every run.

The sync point does these steps in this order:

  1. Wait for all gameplay chunks and for the physics step. Grow the tables over the slots that spawns took after the pool of their chunk ran dry. Such a spawn takes a slot with an atomic increment. Its slot then depends on thread timing, so the world logs a note that the run is no longer deterministic.
  2. Move the falling numeric frames over the step, with the acceleration that physics used. Then check the physics output before any of it lands. If an output can only come from a bug in the backend, skip the physics update of each entity that it names. "Output that only a bug produces" under "Physics" gives the checks. Then move each handed-off frame under its new parent in the frame tree, and put each following frame on its anchor's step plan when its anchor ended the step at the frame's origin: a hull, whose frame's record says so, or a plain kinematic body, whose front intent did not teleport it. Copy body poses, velocities and hand-offs into the back transform table. A body state of a hull writes no row. Keep whether physics stood each character. Then every frame takes its step record, in one pass of the frame tree (FrameTree.TakeStepRecords, called from FollowOwners, see "Rails components and motion intents"). The pass decides where each frame's record comes from, writes the frame's row and derives its proper acceleration from the record (see "How a frame moves"). It asks the World, through a struct (IFrameOwnerFacts), only what the tree does not know: the record that physics released for a hull, whether the output check skipped the owner's update, and a write of the owner's transform. Before it writes, it copies every frame's proper acceleration, and the rule reads the copy (FieldTheStepFound). So each frame reads its parent's value as the step found it, in any order of the visits. Then the float view of each frame entity, its transform row, is filled from its exact table, its row in the frame tree. The frame entity also moves to the member set of its frame's parent there. So the owner of a frame that a hand-off or the game moved under another parent changes topic and hears one FrameChanged. Each of the two passes is one walk over the frame tree's own storage, in dense order (FrameTree.TakeStepRecords and FrameTree.VisitOwnerViews), with no handle check. In a Debug build, a contract checks after each frame that the World's struct moved no frame, or changed nothing that an owner view reads. A record from physics is relative to the frame's parent after its hand-off. So it must come after the hand-offs, and before step 5 destroys any frame. A frame without a body takes its owner's write of its transform here too, so a write in the tick in which the frame's parent dies converts with the frame. Then each entity without body or frame takes the step record of its motion intent on its transform row (FollowFramelessEntities). This comes before step 5 converts the rows out of dying frames, and before step 7 derives the attached children from their roots. Then each plain body that a hand-off moved changes topic and hears one FrameChanged. Send the contacts that started as Collision events last, and then the contacts that ended as CollisionEnded events. So a contact that starts and ends in one step reaches each entity in that order.
    • 2b. Step every puff over the step that ended (PuffTable.Step, see "Puffs"), by the rule of motion of its frame, and remove the puffs whose age reached their life. Every frame took its step record in step 2, so the frame tree describes the end of the step, and the frame's loose-body step still holds the gravity that physics read. It must come before step 5 converts the contents of dying frames, because a puff that still held its place of the step before would land off by its frame's move over the step. It reads nothing of the physics output, so it also runs when the sync point drops that output, and in a World without a physics backend.
  3. Make sure that no entity with a body wrote its own transform. In every build, such a write throws InvalidOperationException, so none gets through. When WorldOptions.DebugChecks is on, this step checks every invariant of the entity table, this one included. That check costs time in proportion to the slots in use, not to the capacity of the table. A slot that was never used must be clear in every column, and the table checks that when it first takes the slot, because a scan of every unused slot made the check read about 2 MB per call at the default capacity.
  4. The step applied the intents of the last tick. So their collision groups and masks become the back collision rows of the bodies, which step 8 publishes, and their changes of motion become the stored motions of the hulls in the frame tree (FrameTree.HullMotion), also without a physics backend. Both describe the step that ended, as all the state that the next tick reads does. Then each owner that wrote a change of motion in this tick gets its frame planned for the next step, which applies the change: the frame and the intent drop what the new motion refuses (see "Changes of motion"). The entity table lists the owners that wrote a change in each chunk, and the swap hands that list on with the intents, so step 4 visits only them, not every body that wrote a force or a velocity. Then swap the physics intent buffers. The entity table also lists the parts that pushed in each chunk, apart from the intent writers (see "Parts"). The swap hands that list on as the front pushers, as it hands on the owners that wrote a change of motion, and it keeps the parts out of the intent list, because a part's push is no input of physics: step 5 carries it to the part's chain root. The swap clears the old front entries and exchanges the two buffers, so physics gets this tick's forces and motion intents in the next step. With no physics backend, the swap keeps only the changes of motion and the motion intents, and clears the other fields of each intent in place (PhysicsIntent.KeepOnlyMotion). It also clears the push of each part and lists no part, so step 5 carries nothing. The sync point moves an entity without a body by its motion intents, with or without a backend.
  5. Apply the subscribe and unsubscribe requests of the chunks, in chunk order, while every entity that asked is still live. Then the destroy batch grows to the attached children of each dying entity. It also grows to the members of each dying frame that has no surviving ancestor. Then carry the push of each part to the body of its chain root, in one choke-point method (Attachments.CarryPushes, see "Parts"). The flow writes the front intents, so it comes after the swap of step 4. It comes after the growth of the batch, so that a part that dies here gives no push: a part that is not live in the next step does not act in it, as the intent of a body that dies goes with the body. It comes before the batch moves or frees anything, and before step 7b, so that the intents of each part and of its root still name the front frame of the chain, and the sum needs no relation between frames. The sum then converts once, as part of the root's intent, by the rule that converts every force. It comes before step 6c, whose thrust clock reads the force and the impulse of each body's front intent, so the clock counts the pushes of the parts. Then free dead entities. The survivors in a dying frame move into the nearest ancestor frame that survives. Increment the generation of each freed slot and return the slot to the free list. Queue the destroys of their bodies for physics, which gets the batch in step 6c. Before the dying frames go, convert each intent of the next step that names one of them into its entity's new frame. "Ownership of transforms" tells how. Step 7b cannot relate a frame that is gone. The motion intent converts with the rest of the intent, by its case. Then the puffs of each dying frame move into its heir, or die with it when no ancestor survives (PuffTable.MoveOutOfDyingFrames, see "Destroying a frame"). This comes before the frames die, because the frame tree refuses a dead frame.
  6. Make new entities live, in chunk order. Each spawn reserved its slot during the tick, so no table grows here. The world drops a spawn into a frame that step 5 destroyed, and it records an error. Queue the new bodies for physics. An attached entity becomes live in the frame of its parent at this moment, after steps 2 and 5 moved the parent. A spawn's first motion intent goes into the new entity's front intent here, after the swap of step 4 (EntityTable.SetFirstMove). So the next step applies it (see "Creation and destruction"). A new frame stands at the pose of its spawn description. Without a first motion intent, it holds still there for its first step.
    • 6c. Run the numeric update (FrameTree.UpdateNumericFrames, see "Numeric frames"), after the physics output, the destroys and the spawns, so that it clusters every live body where it is at the end of the tick. Every frame took its step record in step 2, so the rows of the bodies and the frames both hold the end of the step, and the update tests the bodies against bubbles of the same moment. (Before the review of ter-2gg0, the frames on rails moved after the update, and the update placed the bubble of a station that orbits at 7.5 km/s 126 m behind the bodies beside it.) A new following frame starts at its anchor's place, with the chord of its anchor's motion over the step that ended, which it derives from the anchor's frame tree row for a hull, and from the anchor's transform row for a plain body (see "Numeric frames"). First the update lets go each following frame whose anchor is no body in it. Then hand this sync point's changes to physics. The update before this one can name a home for the ghost of an anchor. Then physics gets the changes even when there are none, so that it drops the ghosts whose home this update stops naming. The update gathers the bodies in slot order from a bit per slot, which the entity table sets while the slot holds a live entity with a body. It takes each body's bounding radii as the table kept them at the spawn.
  7. The float view of each frame entity is filled from its exact table again, after the numeric update, which can move a frame among numeric frames. A nesting frame that jumps moves no frame and writes no row, but it changes the sum that gives the place of each frame below it in its semantic frame, which can round to another double. This pass runs only when something wrote the frame tree since the pass of step 2. The tree keeps a stamp, FrameTree.OwnerViewStamp, that every write of a frame's local pose, velocities or parent changes, and the world compares it with the stamp at the end of the pass of step 2. A change of a hull's motion at step 4 writes none of these: a hull that leaves its following frame leaves it falling, and a falling frame is still a numeric frame, so no owner view changes. With frames on rails and nothing else, nothing writes the tree between the two passes, and the second pass found every row equal. When WorldOptions.DebugChecks is on, the world checks at the end of every sync point that the float view of every frame entity, its transform row, equals what its exact table gives, bit for bit. So a write of the frame tree that does not change the stamp fails a test. Then every attached entity takes its frame, pose and velocities from the root of its chain.
    • 7b. Each intent that the next step applies and that names another frame than its entity's converts into the entity's frame (see "Ownership of transforms"). This comes after steps 2 and 5, which move entities between frames, and after step 7, so that every entity is in its frame for the next tick. It comes after step 2, so that every frame stands where the next step starts, the moment at which a velocity of the intent applies. The motion intent converts with the rest of the intent. A plain kinematic body can hand off in a step, so its next motion intent converts here. The entity table counts the writes of a back row that move it to another frame (EntityTable.RowFrameChanges). While the count is the one of the last pass of 7b, no entity is in another frame than its intent names, because each intent names the frame of its entity's front row, which step 8 copied after that pass. Then 7b skips its visit of every intent writer, as in a scene of frames on rails that write a motion intent in every tick.
    • 7c. The emitters of the step that ended make their newborns (PuffTable.AddNewborns, see "Puffs"). Then the cap drops the oldest puffs, and the rebuild puts each frame's newborns at the end of its range. Only here does every input of a birth describe the step that ended: the front render row still holds the emitter of that step, which step 8 replaces, and the back transform row holds the emitter's place and velocity at the end of the step, which step 7 derives for an attached jet.
  8. Swap the snapshot. The back tables become the front tables. Copy only dirty slots forward: first the rows that gameplay wrote, in chunk order, then the rows that the sync point wrote. With 8,192 or more rows that gameplay wrote, the workers copy them, one chunk per work item. A chunk's dirty list holds only its own slots, so the items write disjoint rows. Each chunk keeps its moved entities and its changed component flags in its own lists, and the sync thread joins those lists in chunk order after the workers finish. Then it copies the rows that the sync point wrote, and the component index takes the changed flags, on the sync thread, in the order of the copy on one thread. Then the frame tree and the spatial grids catch up with the new front rows (see "Snapshots").
  9. Run the physics queries that components asked for during the tick, the ray casts, shape casts and overlap queries, in entity order. Jolt is at the end of the step, with the destroys and spawns of steps 5 and 6, and the new front snapshot describes the same moment: its component flags, collision groups, poses and frames. Queue each answer as an engine event.
  10. Route the outboxes, and the answers of step 9, into inboxes (see "Outboxes, routing and order"). A routing of 6,000 messages or more runs on the workers.
  11. Build the render snapshot and publish it to the render thread through a triple buffer. A build of 8,192 slots or more runs on the workers. After the instances, the builder places the puffs of the puff table, on the calling thread (see "The render snapshot").
  12. Start the next tick.

Gameplay intents written during tick N reach physics in step N+1. That is one tick of latency, and it is acceptable.

Entities #

An entity is a 64-bit handle: a 32-bit slot index and a 32-bit generation. The generation increments every time the slot is freed. A handle with an old generation is stale, and every lookup must reject it.

Each live entity has a row in three parallel tables, indexed by the slot index:

  • Transform: the frame that the entity is in, a frame-local DPose, and linear and angular velocity. Positions are double, because Jolt gives double positions and a planet frame is millions of meters across.
  • Physics: an optional body description and the double-buffered PhysicsIntent. The cold record of a body also keeps, from the spawn, the bounding radii of its shapes and the center of mass of its shape, which the backend computes (see "Physics"). The lever of a part is measured from that center (see "Parts"). The intent also holds the entity's motion intent for the next step (PhysicsIntent.Move). An entity without a body writes one too (see "Rails components and motion intents"). Every slot holds the field, so an intent is about 380 bytes. With 100,000 entities that each write a motion intent in every tick, step 4 takes 1.8 ms of a sync point of 16 ms, so the field has no sparse store (docs/plans/rails-components.md, "The measured cost").
  • Render: mesh, material, scale, bounding radius, render flags, an optional light (a LightData: a point light, a spot light or none) and an optional puff emitter (a PuffEmitter, see "Puffs"). The emitter adds 44 bytes to the row.

Each entity also has a list of components, which are classes that implement IComponent. The list is sorted by Priority. Components with the same priority keep the order in which they were added.

A frame is an entity that also has a row in the frame tree. FrameHandle holds the entity handle plus the frame index and generation. It converts to Entity implicitly.

Component rules #

  • A component changes only its own state and the tables of its own entity.
  • An entity never changes another entity. It sends a message.
  • A part, an attached entity that pushes, changes only its own intent too. The engine carries its push to the body of its chain's root at the sync point, as physics carries a contact from one body to another. The root's components do not see the push, and no message carries it (see "Parts").
  • An entity reads other entities only through the front snapshot of the previous tick. It never reads another entity's components. The snapshot publishes each entity's component flags, the types of the components that it had, and each body's collision groups, so a component can ask whether another entity has a component, or find entities with an entity query (see docs/plans/queries-and-sensors.md). Snapshot.BodyOf gives the physics facts of another entity's body: its motion, mass, shape kind, bounding radius, center of mass, collision groups and mask, and whether it is a sensor. It gives null for a stale entity and for an entity without a body. A body's description does not change after spawn, so only the collision groups and mask, which an intent can change, have double-buffered rows. The motion of a frame's hull can change too, with the intent self.Body.SetMotion, but the frame tree stores it (FrameTree.HullMotion), and it changes only at step 4 of a sync point, so it is the same for the whole tick. Like the collision groups, the facts show the last finished step, so a change shows from the second tick after the one that writes it. The entity reads the motion of the step that applies its own writes with self.Body.NextStepMotion (see "Changes of motion").
  • A component never stores a ref, a span, an EventList, an Outbox or a Self beyond the call that received it. The compiler enforces this for ref structs.
  • Handle never awaits. For a delay, a component sends a message to itself with a delay.

Ownership of transforms #

If an entity has a physics body, physics owns its transform. Gameplay moves it only through its intent: forces, impulses, velocity changes and teleports through self.Body, and motion intents (see "Rails components and motion intents"). These go into the entity's PhysicsIntent slot. Forces and impulses add up during the tick. Velocity changes and the motion intent keep the last value written. Each kind of body takes its own requests:

  • A dynamic body, hull or plain, takes forces, impulses, velocity changes, teleports and the steer target of self.Body.SteerTo: physics gives the body the velocities that carry it to the target over the step, and contacts and gravity still act on it.
  • A kinematic body, hull or plain, moves only by its motion intents and its teleports. Its motion intents are the end poses of self.MoveKinematic and the full samples of self.MoveOnRails. It holds still in a step without one. SetVelocity and SetAngularVelocity throw an InvalidOperationException for it. (User, 2026-10-18)
  • The owner of a static hull moves it only by a teleport: physics moves the hull at the start of the next step, and the frame takes the teleport's pose at the next sync point.
  • A static body and a character take no motion intent. A character moves by its walk, its jump, forces and impulses.

An attached entity has no body, but it can push the body of its chain's root as a part (see "Parts"). Only the additive intents flow from a part to the root: the force, the torque, the impulse and the angular impulse, which compose by a sum. The exclusive intents stay with the root alone, because two writers of one would need someone to decide: a velocity, a teleport, a motion intent, a character's walk and jump, a change of collision groups and a change of motion. A part's push needs a root with a body: AddForce, AddTorque, AddImpulse and AddAngularImpulse throw an InvalidOperationException for an entity that has no body and is not part of a chain whose root has one, and every other member of self.Body throws for a part as for any entity without a body.

The guards judge each write by the motion of the step that applies it (see "Changes of motion"). The owner of a hull changes the hull's motion with self.Body.SetMotion (see "Changes of motion").

An intent is relative to self.Frame during the tick in which gameplay writes it, and it names that frame (PhysicsIntent.Frame). Physics applies it in the next step, and the sync point between can move the entity to another frame: the step that ran during the same tick hands the body into a bubble or out of one, or the destroy batch moves it out of a frame that dies. The sync point then converts the intent into the entity's new frame, so physics always applies an intent in the entity's frame. An intent that names a dying frame converts at step 5, before the frame dies, and every other intent converts at step 7b (see "Rails components and motion intents"). The conversion uses the motion of the old frame in the new one over the step that applies the intent (FrameTree.MotionRelativeInStep): one relation where that step starts, and one where it ends. Each part of the intent converts by the moment that it describes. The velocities of SetVelocity and SetAngularVelocity apply where the step starts, so they convert by the relation there, with the motion of one frame relative to the other. A teleport converts as a pose by the same relation, and forces, torques, impulses and a character's walk convert as directions by it. The motion of SetMotion, the collision groups and the jump speed do not depend on a frame, so they stay as they are. A motion intent converts by its case (MotionIntent.Converted). An end pose and a steer target are poses at the end of the step, so they convert as poses by the relation where the step ends. A full sample converts its pose, its velocities and its accelerations by the relation where the step ends, and its chord by the frame's poses at both ends of the step (MotionSample.Converted). In the relation where the step ends, a frame whose owner has an end pose or a full sample stands where that motion intent puts it. A frame that holds still stands where the step starts. A numeric frame stands where the numeric update moves it. So a kinematic body that hands off out of a ship that moves on end poses gets its end pose where the ship ends the step, to the rounding of the composed poses. Four kinds of frame on the path make the conversion miss. A falling frame moves with the step acceleration of the step that ended, and the next step fixes its own anew from nearly the same field. So the conversion misses the frame's end position by the change of that acceleration from one step to the next, times the square of the step. A frame with a dynamic hull stands where physics predicts from its row: the row's velocities, and the proper acceleration and the angular acceleration of the last step. So a steady thrust, a steady torque or a resting contact is in the prediction, and the conversion misses by how the step differs from the last step. A change of force, torque or contact misses the frame's end position by about half the change of acceleration times the square of the step. A velocity change, an impulse or a steer target in the step, linear or angular, changes the hull's velocity where the step starts, so it misses the frame's end pose by that change times the step. Physics measures the proper acceleration from the whole change of the hull's linear velocity over the step, so the prediction also repeats a linear velocity change, impulse or steer of the last step as an acceleration over this step. The owner of a frame without a hull can write the frame's transform in the next tick. That write wins over the frame's motion for the step, from a motion intent or from holding still, and the conversion cannot know it. So the conversion misses by how far the write moves the frame from where the relation puts it. A frame that dies at step 5 keeps its relation at the moment of its death at both ends of the step, the relation by which the rehome moves its contents. So a motion intent that converts through it misses by the frame's motion over one step. (Before ter-dhkf, physics applied the intent in the new frame as it was. A kinematic door that gameplay moved in every tick entered a passing pod's bubble, got the target of the station's frame in the pod's frame, jumped out of the bubble, came back in the next tick, and changed frames in every tick.)

If an entity has no body, gameplay owns its transform, unless the entity is attached. It moves the entity in two ways, both relative to self.Frame. self.SetTransform writes the pose, and leaves the entity at rest. An end pose or a full sample moves it over the next step. At step 2 of the sync point after that step, the sync point puts the motion intent's step record into the entity's exact table. That is the frame tree row for the owner of a frame, and the transform row for an entity without a frame. Such an entity holds still in a step without a motion intent. A transform write comes after the motion intent of the same tick, so the write wins for one tick. The next motion intent puts an entity on rails back on its rails. The owner of a root frame takes end poses and full samples too, with or without a body, because its body is only the frame's static interior. The sync point moves its frame as a frame without a hull.

Creation and destruction #

self.Spawn(...) takes a slot at once from the spawn pool of its chunk and returns the new handle. The preparation of the tick reserves the pools in chunk order, so a spawn gets the same handle in every run. If a pool runs dry, the spawn takes a new slot with an atomic increment, and the run is no longer deterministic. The new entity starts to run on the next tick. The tables never grow during the parallel part of a tick. They grow in two places. The preparation of the tick grows them to fill the pools. Step 1 of the sync point grows them over the slots that dry pools gave out. An entity can destroy itself with self.Destroy(). To destroy another entity, send it a Destroy message. Both take effect at the sync point.

No other operation is deferred. There is no general command buffer.

A spawn can attach the new entity to a parent entity with SpawnDesc.AttachTo. The attached entity follows its parent with a fixed offset in the parent's axes. It has no body, it cannot be a frame, and it dies with its parent. It can still push the body of its chain's root with the additive intents, as a part, and its push acts at its place (see "Parts"). Its chain root never changes, because an entity is attached only at its spawn and dies with its parent. A chain of attached entities is always in the frame of its root, the one entity of the chain that is not attached. In step 7 of the sync point, each attached entity takes its frame, pose and velocities from that root. So an attached spawn becomes live in the frame of its parent at that moment, and the frame that its description names does not count. A component that spawns during a tick saw the parent in the front snapshot. In the same tick, physics can hand the parent into another frame, or the parent's frame can die and move the parent to an ancestor frame. The component cannot know that, so it is not a bug: the new entity becomes live in the parent's new frame. A parent that is not live at the sync point, or that owns a root frame and so is in no frame, cannot take the new entity. The world then drops the spawn and records an error in World.Errors. Outside a tick, World.Spawn throws for such a parent instead.

A spawn description can carry the new entity's first motion intent, SpawnDesc.FirstMove: the motion intent that the entity writes for its first step, as if it ran in the spawn's tick, relative to SpawnDesc.Frame. (User, 2026-10-18) Step 6 of the sync point puts it into the new entity's front intent, or the call of World.Spawn does outside a tick. So the step after the spawn applies it, as a motion intent that the entity wrote. Without one, a new frame on rails holds still for its first step, because its components first run in the tick after it goes live. The spawner computes the end of the entity's first step with self.TimeAt(self.Tick + 2) in a tick, and with World.TimeAt(World.TickNumber + 1) outside a tick. World.TimeAt is public for this, the same read as Self.TimeAt. A spawn refuses two kinds of first motion intent, with an ArgumentException. The first is a case that the entity refuses, by the guard of every motion intent (MotionIntent.ProblemFor) with the facts of the description. The second has a number that is not finite, or a zero rotation. A blueprint holds none, because the motion depends on the time of the spawn.

A spawn velocity, SpawnDesc.LinearVelocity or SpawnDesc.AngularVelocity, is the first velocity of a body that physics integrates: a dynamic body, hull or plain, or a character. A kinematic hull takes one too. It keeps it as its frame's velocity until its first sync point. From it, the numeric update gives a fast hull its following frame at spawn. Every other entity moves by something else, so a spawn refuses a velocity on it with an ArgumentException (World.SpawnVelocityProblem). These entities are an attached entity, an entity without a body, the owner of a root frame, a static body, hull or plain, and a kinematic body that owns no frame. The spawner of such an entity gives it a first motion intent instead.

Parts #

A part is an attached entity that pushes the body of its chain's root (docs/plans/parts.md). (User, 2026-10-21) So the shuttle's main engine, each of its jets and the person's back jet can each be a separate entity, attached to the hull or to the person, with one particle emitter each. A part writes only its own intent, with the same members as a body: self.Body.AddForce, AddTorque, AddImpulse and AddAngularImpulse, in the axes of its frame, which is the frame of its chain root. World.WritablePush takes these writes. For an entity with a body it gives the entity's own intent. For an attached entity whose chain root has a body it gives the part's own back intent, and the part's first push of the tick lists it among its chunk's pushers. The guard reads the part's link and the root's body description, which nothing writes during a tick, so the part's worker may read them while the root runs on another worker. self.Body.Pending of a root shows only its own writes, never its parts' pushes, which join at step 5.

At step 5 of the sync point, Attachments.CarryPushes visits the front pushers, chunk by chunk, each chunk in the order of the first push of its parts. It skips a part of the destroy batch and empties its front intent. For each other part, a force at the part's place gives the root's body the same force and the torque of its lever about the body's center of mass, and an impulse gives the same impulse and the angular impulse of its lever. A torque and an angular impulse are free vectors, so they stay as they are. In doubles:

lever   = q.Rotate(placeInBody - centerOfMass)
force  += F
torque += T + Cross(lever, F)
impulse += J
angular += L + Cross(lever, J)

Here placeInBody is the offsets of the chain from the root down to the part, composed with the writes of the tick, which is where step 7 puts the part in the root's axes. centerOfMass is the one that the entity table kept at the root's spawn. q is the rotation of the root's front transform row, the pose from which the part's component wrote its push. So a jet that pushes along its own nozzle gives the push that the root's own component would write for the same jet from the front snapshot. F, T, J and L are the part's four fields, widened from floats. The sum of each root starts from the root's own four fields and adds its parts' contributions in the order of the front pushers. It narrows to floats once, into the root's front intent (EntityTable.AddPush), so two jets whose forces are exact negatives cancel to exactly zero and leave the root's own force exact. A root that wrote nothing in the tick gets an intent that names its front frame, and it joins the end of the intent list in the order of its first part. The pure part of the flow is the value type Push. The flow runs on the sync thread. With 10,000 parts that push in every tick, it takes about 0.36 ms of the sync point on one worker and on all workers, and with no part it reads nothing measurable. It allocates nothing after warm-up.

What physics does with the sum is the rule that it already has for the root's own push. A dynamic body or hull takes it all, and a turning frame turns it as the root's own. A kinematic or static root keeps the sum in its intent, and physics ignores it. A character takes the force and the impulse, and reads no torque. A part whose root has no body cannot push. A part that dies at the sync point gives no push, and neither does a part whose parent or root dies. A part that the game destroys outside a tick, after the sync point that carried its push, still pushes in the next step, because its push is part of the root's intent by then.

The state that parts add, with its invariants, which AssertInvariants of each module checks:

  • EntityTable keeps each chunk's pushers and the front pushers. A live pusher is listed once, has no body, has a written intent, and is no intent writer. A live front pusher is listed once, has no body, is not in the intent list, and holds an empty front intent. A record without a body has a zero center of mass, and a record with a body a finite one.
  • Attachments keeps the chain root of each link, which never changes. The root of every link is the one that a walk up the parents finds, and it is live. Each live pusher and front pusher is attached, and its root has a body. The scratch of the flow is empty outside it.
  • World: no live attached entity is in the intent list, and its front intent is empty. The front row of every live attached entity names the frame of its root's front row, which step 7 keeps and the flow relies on. With a backend, each body's kept center of mass is the one that the backend gives its shape.

A part whose push names another frame than its root's front row comes only from a bug. A build with contracts fails the contract of CarryPushes. Any other build skips the part and records the bug in World.Errors.

Messages #

The event envelope #

public struct Event
{
    public Target Target;         // [Union]: Entity | Topic | Broadcast
    public Entity Sender;
    public int Type;              // EventType<T>.Id of the payload struct
    public PayloadBytes Payload;  // 48 bytes, holds one unmanaged payload struct
    public object? Extra;         // rare managed data
}

A payload is an unmanaged struct that implements IEventPayload and is at most 48 bytes. EventType<T>.Id gives each payload type a dense integer the first time the type is used. The engine supports 512 event types. EventMask is a 512-bit set of event types.

Target is a union with three cases: Entity, Topic and Broadcast, whose members the union generator writes (see "Unions"). Its HasValue and TryGetValue let pattern matching run without boxing. It is 16 bytes: the entity's index and generation and the topic's id and generation share three 4-byte slots, beside the kind byte.

Delivery #

There are three delivery modes:

  • Direct: to one entity handle.
  • Topic: to every subscriber of a topic. Every frame is a topic. An entity subscribes to its frame's topic automatically, and the subscription moves with it on hand-off.
  • Broadcast: to every live entity.

A radius query is not a delivery mode. A component queries the spatial index of the snapshot and then sends direct messages.

Every message between entities arrives on the next tick. A question and its answer take two ticks.

Outboxes, routing and order #

Each chunk of 64 entities owns an outbox for the tick. Outboxes belong to chunks, not to worker threads, because the chunk-to-thread assignment changes from run to run. At the sync point, the router does these steps:

  1. Join the outboxes in chunk order.
  2. Expand each target into recipients: one for direct, the subscribers for a topic, every live entity for broadcast.
  3. If the event type is not in a recipient's entity mask, drop that recipient.
  4. Write (recipient, eventIndex) pairs. The payload is stored once.
  5. Sort the pairs with a stable counting sort by recipient index.

Each inbox then has the same order in every run with the same inputs. Delayed messages wait in a timer queue keyed by tick, and they join the routing on the tick that they are due.

A routing of 6,000 messages or more runs on the workers, in two phases of work items. In the first phase, each block of chunks copies its outboxes into the routed list and expands its messages. A block keeps its pairs in one cell per slot range, which is a run of entity slots. In the second phase, each slot range sorts its cells into the inboxes of its slots. A smaller routing runs the same phases with one block and one slot range on the sync thread. Both give the same inboxes bit for bit, because no order depends on the threads:

  • Each outbox goes to an offset in the routed list that a serial pass computes in chunk order.
  • Every message of a block comes before every message of the next block.
  • A slot range reads its cells in block order, so each inbox lists its messages in routing order, as one stable sort of the whole list does.
  • Each slot range writes its pairs from an offset that a serial pass computes in slot order.

The component pipeline #

public interface IComponent
{
    int Priority { get; }
    EventMask Wants { get; }
    void Handle(Self self, scoped ref EventList events, scoped ref Outbox outbox);
}

When an entity runs, the worker copies the entity's inbox into a scratch EventList. The worker then calls Handle on each component in priority order. Components edit the list in place: change payloads, remove events and add events. An event that a component adds is visible only to the components after it. Messages for other entities go into outbox, not into the list.

If a component's Wants mask has no type in common with the current list, the worker skips that component. Each entity caches the OR of the Wants masks of its components. After a component is added or removed, the entity recomputes the cache. After a component is attached, it must not change Wants.

An entity runs in a tick only in two cases: its inbox is not empty, or its mask contains the engine Tick event. In the second case, the scheduler adds a Tick event to the start of the list.

EventList has a small query API that reads like LINQ but allocates nothing and edits in place:

foreach (ref var damage in events.Of<Damage>())
    damage.Amount = MathF.Max(0, damage.Amount - rating);
events.Of<Damage>().Where(static (ref Damage d) => d.Amount == 0).Remove();

Use normal LINQ outside hot paths.

Containers #

Events do not move to inventory or equipment automatically. A Container component can forward an event on purpose.

Engine events #

The engine defines these payload types in Tertius.Core:

Event Sent on
Tick Every tick, to entities whose mask contains it
Spawned On the first tick of a new entity
Destroy A request to destroy the recipient
FrameChanged After the entity's frame changed: a hand-off moved it to another frame, its frame died and it moved to the nearest ancestor that survives, the game moved the frame that it owns under another parent, or its attachment chain's root changed frame. Once for each change. Not for the frame that a new entity spawns into, and not for a move between numeric frames
Collision Physics reported a contact that started
CollisionEnded Physics reported that a contact which started ended
RayHit The answer to a ray cast that the entity asked for in the previous tick: the entity hit, or a miss
ShapeHit The answer to a shape cast that the entity asked for in the previous tick: the first entity that the shape touched, or a miss
OverlapHit One answer to an overlap query that the entity asked for in the previous tick: one entity that overlaps the shape, or no entity when none does
InputEvent The host forwards input to the entities that subscribe to the input topic, between two ticks (see "Host and scripted runs"). It is a custom C# 15 union of four cases: KeyChanged(Code, IsDown), MouseButtonChanged(Code, IsDown), MouseMoved(Delta) and Scrolled(Delta). A code is the integer value of the Silk.NET Key or MouseButton, because Core does not reference Silk.NET. The default value holds no case, and a component that matches the cases ignores it

Snapshots #

The transform, render, component flag and collision rows are double-buffered. During a tick, everyone reads the front rows. Each entity writes only its own back rows. In step 8 of the sync point, the back rows become the front rows. That step copies forward only the slots that changed. An entity that did not run costs nothing. The physics intents are double-buffered too, but step 4 swaps them instead of copying them forward.

The world also keeps one spatial grid per frame for radius queries. SpatialIndex owns the grids, and Snapshot.QueryRadius and entity queries read them. Each grid is a sparse hash grid of 32 m cubes with 64-bit cell coordinates. The owner of a root frame is in no grid. In step 8, the index moves only the entities whose front transform changed, from their old cell to their new one. It first finds the new cell of each moved entity, and then changes the cells in the order of the moved list. Finding a cell reads only that entity's rows, so with more than one worker and at least 65,536 moved entities, the workers find the cells, each in its own range of the list. The sync thread then applies the ranges in order, and the grids end as they do on one thread.

Reference frames #

The frame tree #

Frames form a tree. Each frame has a double-precision DPose relative to its parent. FrameTree stores frames in a PoseTree<DPose> plus parallel arrays:

Array Meaning
Owner The entity that is the frame
Bounds The ellipsoid radii of the bubble, in frame axes
HullMotion The stored BodyMotion of the frame's hull, the motion that it had in the last finished step, or null for a frame without a hull (see "Changes of motion")
HullMotionInStep The BodyMotion of the frame's hull in the step that applies the given intents, derived from the owner's change of motion there or else from HullMotion (see "Changes of motion")
Gravity The gravity field of the frame, a GravityField union (see "Gravity")
Velocity Linear and angular velocity relative to the parent
ProperAcceleration What an accelerometer at the frame origin read over the last finished step, in frame axes. Step 2 derives it (see "How a frame moves").
FeltGravity Derived from the stored values, with no array of its own: the gravity that a body at rest at a place in the frame felt over the last finished step, in frame axes, with the rotation terms of the frame's turn (see "Gravity")
Followed The follow kind: what made the step record that the frame took at the last sync point (see "Rails components and motion intents")
Turns V1 and V2: the turn of the frame's axes over the step in flight and over the step that ended, each with the frame's inertial angular velocity, in doubles (FrameTurns). BeginStep fixes them before each step. A numeric frame keeps none and reads its semantic frame's (see "Turning frames" under "Physics").
AngularAcceleration V3: the angular acceleration of the frame's last step record relative to its tree parent, in the parent's axes, in doubles. Step 2 writes it, zero for a frame that holds still. BeginStep predicts the turn of a dynamic hull from it.

The bubble of a frame never changes after the frame is created. The simulation alone changes the hull motion, the gravity field and the proper acceleration of a frame: the stored hull motion at step 4 of a sync point, from the intent of its owner that the step that ended applied (see "Changes of motion"), the gravity field through World.SetFrameGravity (see "Gravity"), and the proper acceleration at step 2. FrameTree.Gravity, FrameTree.Bounds and FrameTree.ProperAcceleration return copies, so no other code can change them. The tree keeps no orbit, no spin and no motion sample. The orbit and the spin live on the rails components of the frame's entity, and a motion intent lives in its owner's intent (see "Rails components and motion intents"). After step 2, the tree row keeps three values of the step record: the pose, the instant velocity and the angular velocity. It also keeps the proper acceleration derived from the record, and the follow kind. (User, 2026-10-18) Since spin gravity it keeps the record's angular acceleration too (V3), because the turn of a dynamic hull's frame in the next step is predicted from it. The record's chord and acceleration are not kept.

PoseTree<TPose> is packed. Its arrays have no gaps. Each parent comes before its children, so one linear pass computes all poses. A reparent can break that order. The tree then marks itself dirty and restores the order before the next pass.

The scene graph inside a frame uses the same PoseTree, with the float Pose.

The rotation of every local pose in the frame tree is a unit quaternion: the square of its length is within 1E-12 of 1. FrameTree.AssertInvariants checks it. The world normalizes a rotation that a caller gives it, in a spawn description or in a write to the transform of the owner of a frame without a body, and it does not refuse it. A rotation that is off by 1E-7 in the square of its length would put a parent that is 6.8E6 m away about a meter off, as seen from the frame.

How a frame moves #

A frame follows its owner, the entity that is the frame, at every sync point (see "Rails components and motion intents"):

  • A frame whose owner has a dynamic hull follows the hull, which physics moves. Its pose and velocity come from that body after each step.
  • A frame whose owner has a kinematic hull, or no hull, follows its owner's motion intents: the full samples of a rails component, KeplerMotion or SpinMotion, or the end poses of self.MoveKinematic. The owner of a root frame has no hull.
  • A frame without a step record holds still in its tree parent: a frame with a static hull, and a frame whose owner has no motion intent for the step. It keeps its pose, and it has no velocity there, so it moves with its tree parent. Its owner can still move it: an owner without a body writes its transform, and the owner of a hull that is not dynamic teleports it (see "Ownership of transforms").

Every frame takes its proper acceleration from its step record by one rule (User, 2026-10-18). The proper acceleration is the frame's acceleration over the step minus the gravity at its place where the step started. The tree turns it into the frame's own axes (FrameTree.ProperAccelerationFrom). The gravity at the frame's place is its semantic parent's field there minus the parent's own proper acceleration. In a parent that turns, it also holds the parent's centrifugal and Euler terms at that place and its Coriolis term on the record's chord, from the parent's turns of the step (docs/plans/spin-gravity.md, "The proper acceleration of a frame"). So a frame feels what a body at its origin feels. A root frame feels no gravity. A frame without a record is at rest in its tree parent. Under a numeric tree parent, the rule reads the frame's record in the semantic parent. That record adds the places, velocities and step accelerations of the numeric frames between (FrameTree.HomeAcceleration for the accelerations). For the hull that anchors a following frame, the record in the semantic parent is its step plan (see "Numeric frames"). Physics measures a dynamic hull's value by the same rule: Jolt's whole change of velocity over the step, minus the gravity that physics applied at the hull's centre of mass, turned into the hull's axes. It releases the value beside the hull's record (FrameStepRecord.ProperAcceleration). A value from the record leaves out the velocity that the step's intents set, such as an impulse. It also keeps the float rounding of Jolt's velocities, and the cargo of a ship in orbit then drifts. So:

  • A frame that holds still in a gravity field feels that gravity reversed. So the contents of a parked cabin have weight with no field of the cabin's own, and so do those of a turntable that holds its place on the ground.
  • A frame on a Kepler orbit, in a parent that falls freely and does not turn, reads exactly zero, not zero to rounding. The rule finds the place where the step started from the sample's place and chord, as KeplerOrbit.Sample does. It evaluates the parent's point field there with the expression of KeplerOrbit.Acceleration.
  • A frame at rest in a ship that thrusts reads the ship's value, turned into its axes.
  • A frame that an end pose moves reads the gravity where the step starts, reversed, because an end pose has no acceleration.
  • A ship that parks reads the gravity at its place, reversed: its cargo has weight on the ground, and no thrust stays in free space.
  • A room at rest in a spinning station reads ω² R toward the axis. Its contents feel that reversed, plus the room's own rotation terms, so ω² (R + r) in all. A frame on a Kepler orbit in a turning parent does not read zero, because its orbit turns with the parent: orbits belong in a frame that does not turn.

The rule reads the parent's value as stored before step 2, the value that physics used for the parent's bodies during the step (User, 2026-10-18). So a dynamic child and every other child of the same parent agree in every step. A change moves one level down a chain of frames in each step. A frame that the pass of step 2 skips, after a failed step or for output that the check refused, keeps its value. A new frame reads zero during its first step, and takes the rule's value from the first step 2 after its spawn on (User, 2026-10-18). The rotation terms do not wait for that: they come from the parent's turns of the step, which FrameTree.BeginStep fixed before it, so a room at rest in a spinning station reads ω² R from the step 2 after the station's first turning step. A room spawned in a spinning station reads zero in its first step, as every new frame does, so its contents feel only the room's own field and rotation terms in that step. FrameTree.ProperAcceleration is a value read, and no gameplay writes it.

The rails components are in src/Tertius.Simulation/Components/. Each moves any entity that takes a full sample: the owner of a frame, which carries the frame, a plain kinematic body, or an entity without body or frame. Each holds an exact description of its motion as a function of time, and no integration state, so no error builds up, and a save needs only the description.

  • KeplerMotion: the pose is a function of time, computed in doubles from the orbital elements and the attitude. The attitude Spin turns the frame by its axial tilt and a steady spin about its own +Y, as a planet turns. The attitude FacesParent points the frame's belly (-Y) at its parent and its nose (-Z) along its motion across the horizon, as a station in low orbit flies. It turns with the true anomaly, so it is exact on an oval orbit too. The frame is in free fall, so the rule gives it a proper acceleration of exactly zero in a parent that falls freely. The orbit holds no G·M. The component takes μ from the point gravity of the parent frame in the front snapshot (KeplerOrbit.MuFrom), so a Kepler frame and the loose bodies in its parent fall on the same G·M. (User) G·M lives in one place only, so the two can never differ. The Kepler math is the struct KeplerOrbit, with KeplerAttitude, in the namespace Tertius.Frames.Rails. Each KeplerOrbit member that needs the G·M takes it as a parameter. Two-body math that takes no orbit elements is a static method of KeplerOrbit (User). KeplerOrbit.Chord and KeplerOrbit.Acceleration take a state and a G·M. KeplerOrbit.Propagate(position, velocity, mu, time) gives the state after a time on the two-body path from a state, for any conic and a time of either sign, by the universal variable with Stumpff's functions and Newton's method. The method keeps bounds on the solution and halves them where a step of Newton's method leaves them or gains too little, as on a long hyperbola. It refuses a hyperbola over more than about 1e28 s, where it does not converge. A body on Kepler rails moves on exactly such a path, so the propagation of its measured state equals its rails to the rounding. KeplerOrbit.Lambert(from, to, timeOfFlight, mu, normal) gives the velocities at both ends of the path from one point to another in a time of flight, of less than one revolution, turning about the normal through the angle from 0 to 2π between the points. It is the universal-variable method of Bate, Mueller and White (Curtis, algorithm 5.2), with bisection on its variable z. It refuses a transfer angle within 1 degree of 0 or 180 degrees, where the path has no plane, and a time faster than its search covers: a hyperbola whose anomaly turns by more than 2π, far faster than the escape speed. Both are pure, allocate nothing, and check their arguments with guards that throw. The demo's rendezvous guidance (TransferGuidance) calls both.
  • SpinMotion: the frame stays at its place in its parent and turns in place, as the ground of a planet does. Its FrameSpin (in Tertius.Frames.Rails) gives the rotation as a function of time: a tilt, then a steady turn about the frame's own +Y. The same math gives the Kepler attitude Spin. A sample holds no proper acceleration, and SpinMotion has no such property. A turntable that holds its place on the ground gives its cargo the ground's gravity, reversed, by the rule above. So a landed ship that starts to turn keeps its cargo's weight with no value to copy. The frame's contents feel its rotation terms too (see "Turning frames" under "Physics"). The spin starts where the entity starts its next step (self.PoseAtNextStepStartIn). That is where the motion before it leaves the entity, such as the last sample of a component that it replaced. In a step without a motion intent, a kinematic body holds still in its home, hull or not, so for one in a numeric home the read moves its pose now with the home over the step (FrameTree.HomeMoveOver). For a dynamic body the read is its pose now.

The components check their own rules, and a mod's own component follows the same rules. A property setter refuses an orbit or a spin that is not valid, with an ArgumentException. A valid orbit is closed, with a positive semi-major axis, and each of its elements is finite. A valid spin has finite values. A KeplerMotion whose parent has no point gravity with a G·M above 0 holds its entity still and logs once. A rails component asks the guard of every motion intent first (self.TakesMotionIntent, see "Rails components and motion intents"). If the entity refuses a full sample in the next step, the step that applies it, the component writes nothing and logs the guard's reason once (self.MotionIntentProblem). The component asks in every tick, and the reason is a string that allocates, so it words the reason only for that one log line. These logs go to World.Log, never to World.Errors. If the body is kinematic in the step of its first sample (self.Body.NextStepMotion), a component teleports the body to the rails pose at the start of that step. An entity without a body, and the owner of a root frame, need no teleport.

A Kepler orbit is in the axes of its parent, so in a parent that turns, the orbit turns with it. A planet that turns therefore has two frames, as real spaceflight has an Earth-centered inertial frame and an Earth-fixed one. The planet's frame does not turn: it goes round its star and holds the orbits of ships and moons. A Spin frame at its center turns and holds the ground. The orbit scene of the vertical slice is built this way.

Changes of motion #

A frame has no driver. Every frame follows its owner, and the owner's components move it (see "How a frame moves"). The motion of a frame's hull is its body's BodyMotion. At spawn it comes from BodyDesc.Motion. After spawn, the owner's components change it in a tick with the intent self.Body.SetMotion(BodyMotion), for example to park a ship. (User) The frame tree stores the motion of the hull in one place, FrameTree.HullMotion, and no entity-table row keeps it beside the tree. The stored motion is the motion of the last finished step (see below). It is null for a frame without a hull: a root frame, a frame whose owner has no body, and a numeric frame. In the World, a frame never becomes a root after its spawn: when a root frame dies, everything in it dies too, the frames inside it included (see "Destroying a frame"). The body description keeps the motion of the spawn. Only a hull can change its motion. Until ter-ru5u, a MotionDriver enum in the frame tree chose the motion of the hull. A second value beside the body's motion can disagree with it, so the enum went.

Step k is the physics step that runs beside tick k. A SetMotion written in tick k is in the intents that step k + 1 applies. Physics gives the Jolt body the new motion at the start of step k + 1, after the reconcile and before anything reads the body's motion. At step 4 of the sync point after step k + 1, the tree stores the change, from the intents that the step applied, where the collision rows take their groups from the same intents. So the stored motion lags physics by one step, like the collision rows: the tick beside step k + 1 still reads the old motion in the tree and in the body facts of the snapshot, and the tick after it reads the new one. The state that a tick reads describes the last finished step. (User, 2026-10-18)

The motion of a step that has not finished is derived, never stored. Each question has one read over the same data (the intents written in the tick, the front intents and the stored motion), named by the step that it answers. No reader combines the intents and the store on its own.

Read The step that it answers Its value Readers
FrameTree.HullMotion The last finished step The stored motion The body facts of the snapshot (Snapshot.BodyOf), the World's invariants
FrameTree.HullMotionInStep(frame, stepIntents) The step that applies stepIntents, the step after the last finished step. With the front intents during a tick, and at the sync point until step 4, that is the running step: the step beside the tick, whose output the sync point reads. After step 4 stores the changes and swaps the intents, the front intents are those of the next step The change of motion in the owner's intent there, or else the stored motion Physics at the start of each step (FollowHullMotions), the check of the step's output and the kind of each frame's step record at step 2 (FollowOwners, so the follows of a state dump), and the tree's invariants, with the intents of the step that the numeric frames are planned for
World.NextStepHullMotion(frame), and self.Body.NextStepMotion for a component The next step, which applies the intents written in this tick The change that the owner wrote in this tick so far, or else the motion in the step that applies the front intents: the running step during a tick, and the next step itself after step 4 swaps the intents The guard of every motion intent (World.MotionIntentProblem, which reads World.NextStepMotion) and of SetVelocity and SetAngularVelocity, so the checks of KeplerMotion and SpinMotion and the drops of step 4, the numeric update of step 6c, which plans the next step, and the body that a rehome in step 5 creates for the next step

So the motion that Jolt uses in each step, and every rule that acts on that step, takes the change at once. The first hand-off of a hull that the step made dynamic is no bug, and its frame follows its step record as a move of physics. KeplerMotion finds its hull kinematic in the tick after "Into orbit". Physics follows the derived motion at every step, as the reconcile follows the tree's structure. The tree stores a change also when the step that applied it failed, so a step that fails loses no change: the next step that runs takes it from the stored motion. Without a physics backend the World still counts its steps: the swap keeps the changes of motion in the front intents, and the tree stores each one at the same sync point as with a backend. The last SetMotion in a tick wins, and a change to the motion that the hull has changes nothing, in the tree or in Jolt.

At step 4 of the sync point of the tick that writes a change, before the intents swap, the World keeps the frame and the intent coherent with the motion of the next step (World.ApplyMotionChanges). The frame tree plans the following frame (FrameTree.PlanHullMotion). These drops follow the motion of the next step, whatever the motion of the step that ended. The guards of a tick judge each write by the motion at its moment, and a later change in the same tick can turn that motion:

  • The hull can drop the motion intent that its owner wrote in the tick. If the guard of every motion intent refuses its case under the hull's motion in the next step (World.MotionIntentProblem), the hull drops it, and no step applies it. So a hull that is not kinematic drops an end pose or a full sample, and a hull that is not dynamic drops a steer target. The motion intent that the running step applies stays: the frame follows it at the next sync point.
  • A hull that is kinematic in the next step also drops the velocities of the tick (SetVelocity and SetAngularVelocity). A kinematic hull moves only by its motion intents and its teleports. Its forces and impulses stay, because physics applies them only to a dynamic body.
  • A frame that hangs at the origin of its following frame and is not kinematic in the next step leaves that frame falling. The numeric update of step 6c moves the hull with its frame into the home that its new motion gives it (see "Numeric frames").
  • The change sets no velocity of the frame. So a parked frame keeps its old velocity in the tree for one tick, while its static hull holds still and its pose stays where the step left it. At the next sync point, in step 2, a static hull without a step record holds still, and the frame's velocity becomes zero. Its proper acceleration becomes the gravity at its place, reversed, by the rule of "How a frame moves". So the cargo of a ship that is parked on the ground has weight. A ship that is parked in free space keeps no thrust.

In physics, the hull keeps its body ID and its system through every change. It takes the frame's pose and velocity from the tree. So a hull whose engines come on coasts on with the velocity of its last sample. The interior follows the hull: it is static while the hull is static, and kinematic and at rest while the hull can move (see "Physics"). Every pair of the owner's bodies follows the new motion at once (ter-imi5). Physics makes each ghost of the hull again with the new motion, and it ends each started pair of the hull and of the interior that a body of the new motion cannot make (see "Ghosts"). A pair that both motions allow goes on. So a static sensor beside a ship reports Left when the ship parks, and Entered when the ship moves again. A lander that parks on a static pad hears CollisionEnded from the pad, because a static body touches no static body, and Collision again when it is unparked. A crate that rests on the interior hears nothing, because a dynamic body meets the interior in either motion. When several hulls change their motion in one step, every hull takes its new motion before physics judges any pair, so the order of the owners does not matter: a lander that parks on a platform that starts to move in the same step keeps its touch. A kinematic hull carries the frame hulls that rest on it, for example a small ship on a turntable whose bubble is small. Loose bodies never touch a hull: they enter its frame's bubble first.

So a change of motion is a swap of components plus a change of the body's motion, in one tick:

Change What gameplay does in one tick What happens
Park SetMotion(Static) The hull holds still where the step leaves it, and its frame stops in its semantic parent.
Unpark SetMotion(Dynamic) The hull falls or coasts from rest.
Engines on, Kepler to dynamic Remove KeplerMotion, then SetMotion(Dynamic) The hull coasts on with the velocity of its last sample. Its following frame falls on.
Into orbit, dynamic to Kepler Add KeplerMotion, then SetMotion(Kinematic) The hull holds still in its physics home for one step, as a kinematic hull without a motion intent does. Then the component's first run teleports it onto its orbit.
Kepler to static Remove KeplerMotion, then SetMotion(Static) The frame stops. The numeric update moves the hull out of its following frame into the semantic frame's own system.
A new orbit or spin Replace the rails component The new component's first run teleports the hull onto its rails at the start of the step. So physics never sweeps the hull across the gap. A hull in a following frame jumps with that frame, and the frame's other contents keep their places in the semantic frame.

A swap of rails components has no gap: the old component's last sample covers the step before the new component's first sample, which starts where the last one leaves the entity. Outside a tick, a game adds and removes components with World.AddComponent and World.RemoveComponent.

SetMotion checks its call with always-on guards. It throws an InvalidOperationException for a body that is no hull: an entity without a body, one that owns no frame, and the owner of a root frame. It throws an ArgumentException for a motion that the hull cannot take. BodyDesc.Problem with the role BodyRole.Hull holds these rules, and the world checks the same rules at spawn. A hull is not a character and not a sensor. A dynamic hull has mass. A body whose shape is or holds a mesh is always static, so only its ghosts move. A root frame has no parent, so its owner has no hull. The body of its owner is only the static interior of the frame (BodyRole.OwnsRootFrame). So the rules of motion and mass for a hull do not apply to it.

A motion that the hull cannot take comes only from a bug, because the world checks the motion at spawn and when the owner writes it. Physics then breaks a contract in a Debug build. A Release build keeps the motion of the hull and records the bug in World.Errors, in every step that finds that motion. "Output that only a bug produces" under "Physics" tells how. Physics gives the body of each hull the motion of each step at the start of that step. A body that the World creates between two steps, at a spawn or a rehome, starts with the motion of the next step, which it runs first.

Rails components and motion intents #

In each tick, a component can say how its own entity moves in the next step. That is the step in which the tick's intents act, and it ends at self.TimeAt(self.Tick + 2). The component writes one motion intent into the entity's intent, the field PhysicsIntent.Move, beside the force, the impulse and the velocity. (User, 2026-10-18) MotionIntent (in Tertius) is a generated union, as Target is, with three cases:

Case Writer What it gives Who takes it
EndPose self.MoveKinematic(endPose) Only the pose at the end of the step. The entity moves at a constant velocity from where it stands to that pose. A kinematic body, hull or plain, an entity without a body, and the owner of a root frame
MotionSample, a full sample (in Tertius.Frames.Rails) self.MoveOnRails(sample) The pose at the end of the step, and all the motion that goes with it, in doubles: the chord velocity over the step, the instant velocity at its end, the acceleration over the step, the angular velocity and the angular acceleration. It has no proper acceleration, because the frame tree derives that (see "How a frame moves"). The same as an end pose
SteerTarget self.Body.SteerTo(target) A pose that physics steers a dynamic body toward over the step A dynamic body, hull or plain

The default union holds no case: the entity has no motion intent in the step. Each case is relative to self.Frame, in its axes, and the last write in a tick wins, whatever its case. The rails components KeplerMotion and SpinMotion write full samples. Self.TimeAt gives the same double that World.Time holds in that tick, so a place on an orbit matches the World's time to the last bit. The engine never calls into a component at the sync point: the solve runs in the parallel part of the tick, and the sync point only reads intents. A sample is a pure function of the component's description, the time, the step, the entity's place and the parent's gravity in the front snapshot. So the result does not depend on the order of the chunks. The design is in docs/plans/rails-components.md, "Rails motion for every entity". Before ter-xdt8, a sample table with three buffers kept the samples of frames beside the intents. Only the owner of a frame wrote a sample then.

An end pose and a full sample are the motion itself, for a kinematic body or an entity without a body. A force, an impulse and a steer target are inputs that physics integrates for a dynamic body. One guard keeps the two apart, World.MotionIntentProblem(entity, intent) (self.MotionIntentProblem for a component), which gives the reason of a refusal. World.TakesMotionIntent(entity, intent) (self.TakesMotionIntent) asks the same rules and builds no reason, so it allocates nothing, for a reader that asks in every tick and for the drops of step 4. It reads the motion of the entity's body in the next step, and checks these rules in this order:

  1. An attached entity refuses every case.
  2. A character refuses every case.
  3. An end pose or a full sample needs a body that is kinematic in the next step, or no body. The owner of a root frame passes too, because its body is only the frame's static interior, which physics does not move.
  4. A steer target needs a body that is dynamic in the next step.

So a static body refuses every case. One internal pure function holds the rules, MotionIntent.RefusalFor, which returns the rule that refuses a case (MotionIntentRefusal) and allocates nothing. MotionIntent.ProblemFor words that refusal. The writers, the drops of step 4 and the check of a spawn's first motion intent use them. Physics checks each step with them too. So they never disagree. The three writers throw an ArgumentException for a number that is not finite or a zero rotation, then an InvalidOperationException with the guard's reason. The World makes the rotation unit length. The rails components ask the guard first, with self.TakesMotionIntent, which builds no reason. If it refuses, they log the reason once and write nothing.

The step that applies an end pose or a full sample moves the entity onto it. That move is the entity's step record, a MotionSample: its motion over the step that just ended. One function turns a motion intent into a record, PhysicsIntent.StepRecord. PhysicsIntent.StepRecordOrStill is its total form for a kinematic body, which gives the record of a body that holds still where the step starts when there is no end pose or full sample. A full sample is its own record. An end pose gives the motion at a constant velocity from the pose where the step starts to the end pose, with zero accelerations. If the same intent teleports, the step starts at the teleport's pose. Else it starts where the entity stands. A steer target gives no record: physics measures a dynamic body's motion as for every dynamic body. Each kind of entity takes its record in its own exact table:

Entity Who makes the record, and from which pose Where it lands
Kinematic hull Physics, in the step, from the hull's pose in the frame tree Physics releases it (FrameStepRecord). Step 2 puts it into the frame tree row, and fills the owner's transform row from that row.
Plain kinematic body Physics, in the step, from the body's Jolt pose The body state and the hand-off of the step carry the record's pose and instant velocities. Step 2 writes them into the transform row.
Frame owner without a hull The sync point, at step 2 (FrameTree.TakeStepRecords), by the rule of FrameTree.StepRecordInStep (FrameTree.StepRecordOfIntent), from the frame's tree pose, which still stands where the step started The frame tree row, then the transform row
Entity without body or frame The sync point, at step 2 (FollowFramelessEntities), from the transform row's pose The transform row: the pose, and the velocities narrowed to floats

Every kind that takes an end pose or a full sample holds still in a step without a motion intent. Physics stops a body in the step after its last end pose, full sample or steer target, unless that step gives it a velocity. FrameTree.TakeStepRecords holds a frame without a record still. FollowFramelessEntities gives an entity without body or frame zero velocities after its last record. A transform write of the tick comes after the motion intent, and wins for one tick.

The motion intent has the life of the rest of the intent:

  • Tick k writes it. Step 4 of the sync point of tick k swaps it into the front intents, also without a physics backend. Step k + 1 applies it. A spawn's first motion intent enters the front intents at step 6 instead (see "Creation and destruction"). Step 4 of the next sync point clears it.

  • Physics, in step k + 1: one branch of ApplyBodyIntent serves every body that takes a motion intent, after the teleport of the same intent, so that the teleport applies first. It aims each kinematic body at the pose of its end pose or full sample at the end of the step. A hull at the origin of its following frame aims at that origin. Right after the Jolt update, physics puts each body that it aimed exactly on its target (SnapKinematicBodies). It uses SetRPositionAndRotation, without a change of activation. This snap comes before the contacts, the hand-offs and the ghosts read the end of the step. Jolt reaches a kinematic target only to the float rounding of the body's velocity, about 4 µm per step at 7.6 km/s. With the snap, a place on an orbit stays exact. For a dynamic body, the same branch sets the velocities that carry the centre of mass to the steer target over the step. They also turn the body to the target's rotation. Contacts and gravity still act on it. Physics skips the intent of an entity without a body.

  • Physics predicts every frame at the end of the step from its step record: a ghost moves with the record's chord velocity, converted by the frame's poses at both ends of the step when a frame on the way turns (see "Instant velocities and chords"), and a hand-off carries its instant velocity. A frame whose owner has a kinematic hull or no hull derives its record from its owner's motion intent. It uses FrameTree.StepRecordInStep, whose rule step 2 shares (FrameTree.StepRecordOfIntent), so the two agree to the last bit. A frame without a record predicts its tree pose with no velocity. A plain kinematic body's record stays on its physics record for the step, for its body state and its hand-off.

  • Step 2: one pass of the frame tree, FrameTree.TakeStepRecords, is where every frame takes its step record. It runs in the tree's dense order, whatever the owner of each frame is. Whether a frame has a hull is its stored hull motion. Physics releases the record of every hull that moved. A dynamic hull's record comes from Jolt's state right after the update, with the proper acceleration that physics measured beside it. A hull that handed off in the step has its record relative to its new parent, in the new parent's axes. A kinematic hull's record is the record of its motion intent, with every field exact. A static hull, or a kinematic hull without a motion intent, that teleported has a record at the teleport's pose, at rest in its home. A kinematic hull without a motion intent that anchors a following frame has a record in every step, at the frame's origin and at rest, because physics aims it at its frame's still plan (AimStillAnchors). In the step of a teleport that record stands at the teleport's pose instead. A frame without a hull takes the record of its owner's motion intent. Then it takes its owner's write of its transform in the tick, as a record at rest, which wins for one tick. A frame without a record holds still in its tree parent. Each frame takes the proper acceleration that its record gives (see "How a frame moves"). It also takes its follow kind (FrameTree.Followed), which a state dump reports as follows:

    Follow kind The frame's record came from Dump string
    Rails Its owner's full sample rails
    Kinematic Its owner's end pose kinematic
    Physics Physics, for a hull that was dynamic in the step, with or without a steer target physics
    Static A teleport, a transform write, or no record: the frame held still static

    The tree keeps the record's instant velocity for every frame. Under a numeric home, it keeps it relative to the home's frame velocity, which is the chord of the step. So a read in the semantic parent gives the instant velocity. Physics keeps a velocity relative to the home's instant velocity. One term converts between the two: the home's instant velocity past its frame velocity at the end of the step (FrameTree.HomeInstantVelocityOverChord, zero in a semantic home). Every record that physics releases adds it. An awake dynamic hull takes its home's linear gravity, which holds minus the home's step acceleration, so Jolt's chord plus half a step of its acceleration is relative to the home's instant velocity in Jolt's stepping: the frame velocity after the step plus half a step of the step acceleration. Physics adds the difference of the two excesses to its record first (HomeSteppedVelocityOverPlan, ter-zsku). A frame that holds still in a numeric home keeps it (FrameTree.TakeStepRecords). A velocity that gameplay gives a rigid body or a character loses the same term where the step starts as physics makes the body's chord: half a step of the home's step acceleration (FrameTree.HomeInstantVelocityOverChordAtStepStart, ter-zsku). In a turning frame, the chord rule acts on top of both terms: the turn's part of the chord of a dynamic body is computed on its semantic place and velocity, so it composes with them by addition (see "Instant velocities and chords"). A falling frame's term is half a step of its acceleration. A following frame keeps the plan's instant velocity less the plan's chord as its term, exactly (FrameTree.BeginStep). Half a step of the orbit's pull misses the change of the pull over the step. A hull at the origin of its following frame rests there, so its record is that term alone. Every other frame in the following frame whose hull moves with the frame, a kinematic hull or a dynamic hull that sleeps, reads its velocity in the semantic parent relative to the anchor's. A frame whose hull is awake reads its velocity as Jolt moves the hull in the semantic frame's own system, as without numeric frames. On the fast test orbit of 2 km/s round a point gravity of 800 m/s², that is 3 cm/s off the station's exact velocity for a ship that floats beside it, with numeric frames as without. Then FollowFramelessEntities gives each entity without body or frame the record of its motion intent.

  • Step 4: a hull whose owner wrote a change of motion can drop the written motion intent. If the guard refuses its case under the hull's motion in the next step, the hull drops it (see "Changes of motion"). Then the intent buffers swap.

  • Step 5: before a frame dies, each intent that names it converts into its entity's new frame, its motion intent by its case (MotionIntent.Converted). The conversion uses the motion of the old frame in the new one over the step that applies the intent (FrameTree.MotionRelativeInStep), which gives two relations: Start, where that step starts, and End, where it ends. It composes the motion of every level on the path through the two frames' common ancestor, by the rules of FrameMotion, which hold the rotation terms of every level that turns. At Start each level stands at its tree pose, or at the pose of a teleport in the step's intents, with its row's velocities and no accelerations. At End a level whose owner has an end pose or a full sample takes the record of its motion intent. A level with a dynamic hull takes what physics predicts from its row: its proper acceleration read backward through the accelerometer rule, the rate of its last record plus its angular acceleration times the step, and Jolt's scheme. A numeric level moves as the numeric update moves it. A level that holds still ends where it starts, and a level that dies at this step keeps its relation at the moment of its death at both ends. Each part of the intent converts by the moment that it describes. An end pose and a steer target are poses at the end of the step, so they convert by End's pose and keep their case. A full sample converts its pose, its velocities and its accelerations by End, and its chord by the frame's poses at both ends (MotionSample.Converted, see "Instant velocities and chords"). The velocities of SetVelocity and SetAngularVelocity, the teleport, the forces, the impulses and a character's walk convert by Start. Without a teleport on the path, Start's pose is the tree's relative pose, to the bit, and Start is the tree's relation, so the velocity of SetVelocity converts through the tree level by level (FrameTree.ConvertVelocity), which rounds as before the rotation terms. A relation composed over two levels or more rounds otherwise. The World computes every relation of the destroy batch before it converts any intent, because a surviving frame whose parent dies moves with its owner, whose intent converts here too (docs/plans/spin-gravity.md, "V7, the relative motion of two frames" and "The two conversion rules").

  • Step 7b: the motion intent converts with the rest of the intent into the entity's frame, by the same relations. A hand-off or a rehome of this sync point can move the entity to another frame (see "Ownership of transforms").

A new frame stands at the pose of its spawn description. Without a first motion intent, it holds still there for its first step, as a frame without a motion intent does. The first run of its rails component writes its first sample. It also teleports a kinematic hull to the rails pose at the start of the step that applies the sample. So such a frame stands on its rails from the second sync point after its spawn. A spawn that carries the first motion intent of its rails is on its rails from its first step (see "Creation and destruction"). So a station spawned in orbit gives its contents exactly zero proper acceleration from the start. The Demo's frames on rails carry theirs. A swap of rails components leaves no step without a sample.

Gravity #

Each frame has one gravity field. GravityField is a custom C# 15 union, as Target is, so a field holds the values of its own case and nothing else. (User) The cases are UniformGravity(Acceleration, Region), which is artificial gravity with an optional region box, and PointGravity(Mu), which is a planet. The empty union is no gravity, and it is the default. So a uniform field cannot carry a G·M, and a point field cannot carry an acceleration. The factories GravityField.None, GravityField.Uniform and GravityField.Point make a field. Code reads a field with a switch over the cases or with TryGetValue, and neither boxes. An object feels only the field of the frame that it is in. This is the one-dominant-body rule.

The linear gravity on a body inside a frame is the frame's own field minus the frame's proper acceleration, rotated into frame axes (FrameTree.LinearGravity). As a result, loose objects inside an accelerating ship slide toward the stern. The effective gravity adds the centrifugal term −ω × (ω × r) and the Euler term −α × r of a turning frame at the body's place, from the frame's turns of the step (FrameTree.EffectiveGravity). It takes no velocity, so it holds no Coriolis term. It is the one value for the readers that need one: the form, the standing and the weight of a character, the sleep threshold, and what a sleeping hull rests against. Physics moves a free body by the linear gravity and the two-turn update instead (see "Turning frames" under "Physics"). In a frame that does not turn, and before a frame's first BeginStep, the effective gravity is the linear gravity, to the bit. Both reads return a Double3 and are internal, because they read the turns and the home's acceleration of the step in flight. Game code reads FrameTree.FeltGravity(frame, position) instead, which describes the last finished step, as ProperAcceleration does: the frame's field at the place, minus its proper acceleration, minus ω × (ω × r) and α × r, with ω and α the frame's angular velocity and angular acceleration relative to the virtual root above the roots, from the tree rows and V3, turned into the frame's axes. It returns a Double3, allocates nothing, and in a frame that does not turn it is the field minus the proper acceleration, to the bit. The demo's Clip reads it, so it lets its pen go only once the cabin's turn stops changing. The demo's Passenger reads it too, because its belt pushes against the gravity that physics applies to the person, and physics applies the rotation terms to a walking character as to a dynamic body. A frame that holds still in a gravity field has that gravity, reversed, as its proper acceleration (see "How a frame moves"). So its bodies feel the gravity at its place with no field of the frame's own. A frame whose own field copies its parent's gravity doubles it.

A game gives a frame a new gravity field between ticks with World.SetFrameGravity. The sleeping bodies of the frame wake at the next step. The world refuses a field that is not valid, at this call and at spawn. Such a field has a value that is not finite. A negative G·M is not valid either. FrameTree.SetGravity is internal to the simulation. The KeplerMotion of a child frame reads the G·M of the new field in its next tick. So a new G·M moves the child from its next sample on. A field without a G·M holds the child still.

Media #

A frame can hold a medium: the gas that fills it, at rest in the frame's axes (docs/plans/particles.md, "The frame's medium"). MediumField is a custom C# 15 union, built as GravityField is. (User) Its cases are UniformMedium(Density, Drag, Region), one density in kg/m³ everywhere in the frame or only inside an optional box, such as the air of a cabin, and PlanetMedium(Radius, SurfaceDensity, ScaleHeight, Drag), the air of a planet at the frame's origin, whose density falls by the factor e per scale height above the radius and stays at the surface density below it. The drag is in 1/s. The empty union is no medium, which is vacuum, and it is the default. The factories MediumField.None, MediumField.Uniform and MediumField.Planet make a medium. Three members read one: Fills(place) says whether the medium has gas at a place, DensityAt(place) gives its density there, and Drag gives its drag. Each case matches a case of the gravity field: the uniform medium has the same optional box as the uniform field, and the planet medium is centred where the point mass is.

The medium matches the gravity field point by point. The frame tree keeps one per frame in an array beside the fields, and a numeric frame has none, by the same invariant. The spawn gives it from FrameSpec.Medium. The game changes it between ticks with World.SetFrameMedium, which throws InvalidOperationException during a tick. The World refuses a frame that is gone and a medium that is not valid, at this call and at spawn, with an ArgumentException, and the frame keeps its old medium. A medium that is not valid has a number that is not finite, a negative density, drag or radius, or a scale height that is not above 0. FrameTree.SetMedium is internal to the simulation. Game code reads FrameTree.Medium(frame). No tick changes a medium, so the value stored when a step starts is the value of that step, as for the field, and it needs no derived read for a step in flight. A frame keeps its medium when it hands off or when the game reparents it, so the air of a cabin goes with the ship. When a frame dies, its contents feel the heir's medium from the next step on.

Only puffs read a medium. (User) Physics reads none, and each body keeps its own damping, so a body moves bit for bit the same with and without a medium in its frame (FrameMediumPhysicsTests). A game that wants air drag on a body sets that body's damping itself. The store stays the same when a later ticket lets bodies read the medium.

FrameTree.LastLooseBodyStep(frame) gives a LooseBodyStep: what the last finished step of a semantic frame does to a loose body in it. It holds the frame's field, its proper acceleration as the step found it, its turns, the length of the step of the turns and its medium. It is valid from step 2 of a sync point to the BeginStep of the next tick. Its LinearGravity(place) is the linear gravity that physics read during the step, to the bit, because step 2 writes a new proper acceleration but the loose-body step keeps the one that the step found. Its EffectiveGravity(place, linear) adds the centrifugal and the Euler term and equals the frame tree's effective gravity of the frame, to the bit. Its ChordAhead(place, chord, lift, drag, dt) is the rule of a loose body. The push is the linear gravity less the lift share times the effective gravity. In a turning frame the two-turn update carries the chord and the turn of the step carries the push, and in a frame that does not turn the push adds to the chord. The drag then keeps 1 / (1 + drag · dt) of the chord, the implicit step, which is stable at any drag. With a lift share of 0, which is vacuum, the push is the linear gravity itself, so the rule is the expression of physics for a free body with no numeric home, to the bit. With a lift share of 1, a body at rest in a turning medium stays at rest. The medium lifts by Archimedes' law against the effective gravity, not the linear gravity, because the effective gravity holds the medium at rest in the frame: hot gas in a spinning station rises toward the axis.

Bubbles and nesting #

A frame's bounds are an ellipsoid or a sphere. No other shape is allowed. The bubble decides ownership: which frame an object belongs to. The physical shape of a frame in its parent frame is its real hull. This lets a ship land on a planet with its real hull.

A frame that fits inside a sibling frame enters it, like any other object. A frame fits inside another frame in one case: its largest bubble radius is less than half of the smallest radius of the other bubble. A fighter that flies into a carrier's bubble becomes a child of the carrier. Two frames that do not fit inside each other stay siblings. Their real hulls collide in the parent frame. If their bubbles overlap, objects in the overlap stay in the frame that they were in, and each frame's hull is ghosted into the other.

Docking two frames of similar size merges them explicitly into one frame, and undocking splits them again.

Destroying a frame #

When a frame dies, its contents move to the nearest ancestor frame that survives. Pose and velocity convert through the tree, each moved entity gets FrameChanged, and its body moves to the new frame's physics system. Child frames move up the same way. If no ancestor survives, as with a root frame, the contents die with it. The motion intents of the moved entities convert into their new frames with them. The puffs of the frame move too, in the same destroy path (PuffTable.MoveOutOfDyingFrames). A place converts by the pose of the dying frame in the heir, and a chord as a velocity, with the frame's velocity and its ω × r term, as an entity's row converts. From the next step on, the puffs move by the heir's field, turns and medium. When no ancestor survives, they die with the frame. The KeplerMotion of a child frame orbits the point gravity of the new parent from its next tick on. If the new parent has none, the component has no G·M to orbit, so it holds the frame still and logs once. The game caused it by destroying the parent, so it is not an error.

Hand-off #

An object belongs to the deepest frame whose bubble it is in. These rules control its moves between frames:

  1. The first contact of its bounding sphere with a child's bubble moves it into the child frame. The hull is inside the bubble. So the object is in the child frame before any part of it can reach the child's real hull.
  2. It leaves a frame only after its bounding sphere is fully outside the bubble, plus a margin. The margin is the larger of 2% of the bubble radius and the object's radius. This gap stops the object from moving back and forth every tick.
  3. If it touches two sibling bubbles at once, it enters the one that it penetrates more deeply.
  4. Physics checks after each step, on the physics thread.
  5. The pose converts in double precision. Velocity converts as v' = R⁻¹(v − v_frame − ω × r) on entry, and the reverse on exit. The conversion keeps the ω × r term, and in a turning frame physics keeps a free body on its straight line in inertial space (see "Turning frames" under "Physics"), so an object thrown out of a spinning station leaves on that line. The hand-off carries the instant velocity at the end of the step, which physics reads from Jolt's chord by the turn of that step (see "Instant velocities and chords"). Angular velocity converts as ω' = R⁻¹(ω_body − ω). FrameMotion holds these rules, and steps 5 and 7b of the sync point convert a full sample with the same type. Its fields Acceleration and AngularAcceleration hold the frame's acceleration a_frame and angular acceleration α_frame in the parent, in the parent's axes, and every producer states both: the motion of a frame at the start of a step states zero for each. On entry, the acceleration a of a point converts as a' = R⁻¹(a − a_frame − α_frame × r − ω × (ω × r) − 2 ω × u), with u = v − v_frame − ω × r the point's velocity relative to the frame (AccelerationFromParent). The angular acceleration of a body, the change of its angular velocity over time, converts as α' = R⁻¹(α − α_frame − ω × ω_body) (AngularAccelerationFromParent). The rules on exit are their inverses (docs/plans/spin-gravity.md, "V7, the relative motion of two frames"). The hand-off converts the angular acceleration of a dynamic hull over the step by this rule, with the frame's motion from its step record and the hull's instant angular velocity at the end of the step. In a turning frame the term ω × ω_body is what remains of a free spin's turn in the frame's axes: a cube that keeps its spin in inertial space and leaves a station that turns across its spin takes zero angular acceleration in the parent, where the rule that only turned the vector gave it |ω| |ω_body|.
  6. The entity handle does not change. The body moves from one physics system to the other. The entity moves to the new frame's topic and gets a FrameChanged event.

A numeric frame (see "Numeric frames" below) has no bubble. A body whose system is a numeric frame enters and leaves bubbles as a body in the system of the numeric frame's semantic frame does, at the same places and into the same frames. A crate thrown at 1,000 m/s inside a ship, beyond the merge test's reach of the interior, falls in a numeric frame below the ship. When it is fully outside the ship's bubble plus the margin, it moves into the ship's parent in the tree and gets a FrameChanged event. (ter-kz89)

Physics follows the frame tree, and the tree is the authority. Each hand-off is a proposal that the sync point checks and applies in step 2. A correct backend never proposes a hand-off that the tree refuses, so a refusal comes only from a bug. "Output that only a bug produces" under "Physics" tells what the sync point does then.

At the start of every step, physics reconciles with the tree. First it drops every hand-off of a frame from the last step, whether the tree took it or not. Then it puts the hull of each frame into the physics system of the frame's parent in the tree. A hull that changes systems this way restarts from the frame's pose and velocity in the tree. So after a refused hand-off, the hull loses the motion of the refused step. The same reconcile follows the changes that the game makes to the tree between ticks, such as a reparent that docks a ship. A change of a hull's motion changes no structure of the tree: right after the reconcile, physics gives each hull the motion that it has in the step, from the change in the step's intents or else from the stored motion (see "Changes of motion"). An edit of a frame's pose with FrameTree.SetLocalPose changes no version of the tree, so the reconcile does not see it. So at the start of each step, physics compares each static hull with its frame's pose in the tree, and puts a hull that differs at that pose. This check runs before the intents. So it never moves a hull back from its owner's teleport, which the tree learns only at the next sync point. Physics also reconciles each time that the simulation gives it a batch of structural changes. The numeric update hands physics its last batch at every sync point, also an empty one, so physics follows the tree at the end of every sync point.

If the system of the parent is full, that system grows first. "Physics systems that grow" under "Physics" tells how. So every hull gets its body in the system that the tree names.

Ghosts #

Jolt makes a contact only between two bodies of one system. So while a body can touch the bodies of another frame's system, that system holds a ghost of the body. A ghost is a Jolt body with the real body's shape. At the end of every step, the ghost pass puts it at the real body's pose, with the real body's velocity relative to its system. So Jolt moves it with the real body during the next step. Bodies in the other system collide with the ghost, but the ghost does not push back on the real body. A body has at most one ghost in each system. When the body can no longer touch the bodies of a system, physics removes its ghost there. A ghost stands in for its real body as fully as Jolt allows (ter-imi5, docs/plans/complete-ghosts.md). A contact with a ghost is a contact of the two entities, and so is a contact of two ghosts (see "Contacts").

A ghost is of one of three kinds, by the system that holds it:

  • A child ghost is in the system of a child frame that the body reaches (see below). The child frame is a semantic child of the semantic frame of the body's system.
  • A stick-out ghost is the ghost of a body that can move and that sticks out of its frame's bubble. It is in the frame's tree parent, and in each sibling frame that the body reaches.
  • An anchor ghost is the ghost of the anchor of a following frame, a hull or a plain kinematic body, in a home that the numeric update names in FrameTree.AnchorGhosts (see "Numeric frames").
Body Child ghosts Stick-out ghosts Anchor ghosts What it reaches
Static plain body, the body of an entity that owns no frame Yes Not yet No The content
Kinematic plain body Yes Yes No The content
Dynamic plain body, and a character in either form Yes Yes No The bubble
Static hull Yes No No The content
Kinematic or dynamic hull Yes Yes When the numeric update names a home for it The content
Interior Not yet No No Nothing

A sensor has the ghosts of a body of its motion.

The content of a frame is its bubble, grown until it holds the bounding sphere of every member of the frame's systems that can move. A body of the frame stays in the frame until its center is its radius plus its exit margin outside the bubble. So it can stick out by about twice its radius plus the margin. The excess of the content is how far, in meters, the content reaches beyond the bubble. A first pass of the ghost pass measures the excess of each frame, before any ghost is placed. A static member does not count. The second pass reuses the position that the first pass read of each member that can move. The reach of a body is its radius, and for a hull the larger of its radius and its own bubble's largest radius. Take the signed distance of the body's center from a child's bubble. For a body that reaches the content, the body reaches the child while that distance is at most its reach plus the child's excess. For a body that reaches only the bubble, the distance must be at most its reach. The test of a stick-out ghost for a sibling frame grows by the sibling's excess in the same way. So a crate that sticks out of a ship's bubble stops at a static rock of the parent beyond the bubble's edge. Jolt makes no contact between a kinematic pusher and the kinematic stick-out ghost of a crate that the pusher pushes into a frame. But the pusher has a child ghost in that frame, which pushes the crate on. A dynamic body or a character reaches only the bubble (BodyRecord.GhostsReachContent). (User, 2026-10-18) The reason is the stick-out ghost. A member that sticks out of its frame has a stick-out ghost in the frame's tree parent. That ghost meets a dynamic body or a character there. A second ghost, of that body in the content, makes a mirror pair. A mirror pair is a contact that each of two systems holds between one real body and the other body's kinematic ghost. In a mirror pair, each real body meets the other as an immovable body. Take a ball of 1 kg that flies at 5 m/s at a crate of 10 kg that sticks out of a bubble. The ball stops at the crate's stick-out ghost, and the crate stays where it is. In a mirror pair, the ball's ghost also meets the crate as an immovable body, and shoves it into its bubble at about 5 m/s.

A ghost is only in the system of a frame next to the body's frame, the semantic frame of the frame whose system holds the body: its parent in the tree, a sibling or a child. The siblings and the children are semantic frames, wherever they hang among numeric frames. Numeric frames have no bubbles, so they do not change which bubbles a body, a hull or an anchor touches, and each gets the same ghosts in semantic frames as it gets without numeric frames. So a crate that falls in a numeric frame below a ship and sticks out of the ship's bubble has a ghost in the ship's parent, and in each sibling of the ship whose bubble it touches (ter-kz89). A hull that is not dynamic, such as the hull of a parked station, never enters a bubble. But it has a child ghost in each child frame whose content its own bubble reaches. The home of a group that links the anchor of a following frame, a hull or a plain kinematic body, and an anchor at rest, or an earlier such anchor, keeps an anchor ghost of it (see "Numeric frames" below). The ghost of a body that sticks out of a frame is in the frame's tree parent, the home of the frame's hull. The numeric update links each body that can touch that ghost to the hull, so that body lives in the same home. So no other system needs a ghost of the body that sticks out (ter-4psc). The one exception is a frame whose Kepler hull lives apart from its group. Each home that keeps a ghost of that hull also keeps a ghost of each body that sticks out of the frame. The numeric update can stop naming a home for the ghost of an anchor without a change of the tree. Physics then removes the ghosts that the tree no longer allows there, at the same sync point (JoltPhysicsBackend.DropWithdrawnAnchorGhosts), in the update's last batch, which goes also when it is empty. A frame that leaves the tree takes these homes with it, and so does a following frame that loses its anchor. The ghost of a hull is never in the hull's own frame. When the tree changes, the next reconcile removes each ghost that the new tree does not allow. The ghost pass at the end of the next step adds the ghosts that the new tree needs. A move of a numeric update that takes a frame's hull into another home takes the ghosts of the bodies that stick out of the frame along at once, because the frame's tree parent is the hull's home (review of ter-4psc).

Each ghost has the role, the Jolt motion and the contact motion of its kind. The role is one of the four engine bits of an object layer (see "Physics"). The contact motion decides what the ghost meets (see below).

Ghost Role Jolt motion Contact motion
Child ghost of a static plain body The body's own layer: Static, with the body's groups and mask Static while it holds still in its system, kinematic while it moves Static
Child ghost of a hull The interior's layer: Static, with the owner's groups and mask As a ghost of a static body for a static hull, or else kinematic The hull's motion
Child ghost of a plain body that can move Ghost Kinematic The body's motion
Stick-out ghost in a sibling frame The role of the body's child ghost: the interior's layer for a hull, Ghost for a plain body Kinematic The body's motion
Stick-out ghost in the frame's tree parent, and a ghost beside a Kepler hull that lives apart from its group Ghost Kinematic The body's motion
Anchor ghost FrameHull As a ghost of a static body for a static hull, or else kinematic The hull's motion

A ghost meets what its real body meets in the ghost's system, with one exception. It never meets the hull of a frame that holds its real body, or the body pushes the hull of its own frame. Only a stick-out ghost in the frame's tree parent can be in the system of such a hull, and so can a ghost beside a Kepler hull that lives apart from its group, in a home whose bodies the merge test links to that hull. So these ghosts have the Ghost role, which meets no hull. A child ghost of a static body or of a hull has the Static role, and so does the stick-out ghost of a hull in a sibling frame. The system of a child or of a sibling holds that frame's interior and the hulls of that frame's own children, and none of those frames holds the real body. So a dynamic hull of a frame inside a ship lands on the ghost of the parent's static slab, as it lands on the slab. A pod in a dock stops at the ghost of a shuttle that sticks out of a bay beside the dock into the dock, as it stops at the shuttle's child ghost when the shuttle lives in the dock's parent. One hull has one layer in a frame, wherever the hull lives (NumericBody.GhostsMeetHulls in the numeric update). A child ghost of a hull takes the interior's layer, not the hull's. In another frame, the ghost is the surface of the owner that the bodies of that frame meet. In the owner's own frame, the interior is that surface. An anchor ghost has the hull's own FrameHull layer, so it meets there only what the hull meets: static bodies and other hulls. A ghost keeps its role. If a ghost needs another role, physics makes it again. An example is a frame that enters a sibling frame. A hull in it that sticks out has a ghost in that sibling, in the role of a child ghost. The sibling becomes the frame's tree parent, so that ghost becomes a stick-out ghost in the tree parent, in the Ghost role. No frame gets two ghosts of one body, so a ghost pass wants each ghost once. A second want of one ghost in another role before the next pass comes only from a bug, which physics reports and skips (World.Errors).

A ghost of a static body is static while it holds still in its system, as its real body is. An example is the ghost of the slab under a parked shuttle, in the shuttle's bubble. Another example is the ghost of a parked station's hull in a parked ship beside it. Jolt never simulates a static body, so the bodies that rest on such a ghost sleep. A system with no other awake body does not step. With 100 boxes asleep on the ghost of a station's floor, a tick took 37 µs with a static ghost. With a kinematic ghost, it took 116 µs (MeshGhostBenchmarks). When the ghost starts to move in its system, it turns kinematic, and when it holds still again, static again. So Jolt creates it to allow that, and gives it a mass. The mass is the body's own, or Jolt's mass of the shape for a body without mass. A mesh gets 1 kg, because Jolt cannot compute its mass. Jolt reads the mass only of a dynamic body, but it needs a finite mass to move a kinematic body. (ter-dq92) A ghost of a body that can move is kinematic. Physics sets the pose and velocity of each kinematic ghost in every step. Jolt resets the sleep timer of a body whose pose is set, so such a ghost never falls asleep. Jolt puts an awake kinematic body in the island of each dynamic body that it touches. So a body that rests on such a ghost never falls asleep either. In return, Jolt finds every contact of the ghost in every step. So a held pair with the ghost never ends while the two touch.

The contact motion of a real body is its Jolt motion, and the inner body of a character counts as a character. The contact motion of a ghost is the motion of its real body. The system of the ghost keeps it in a table (FrameWorld.GhostContactMotion). Take two bodies whose layers meet. Jolt's rule is that Jolt makes a contact of them only in two cases: one of them is dynamic, or one is kinematic and the other is a sensor. A character counts as dynamic. The contact filter (FrameWorld.OnContactAdded) applies the same rule to the contact motions of each pair with a ghost. So the moving ghost of a static body, which is kinematic in Jolt, meets only what the static body meets. A kinematic sensor never detects it, as it never detects the static body. So numeric frames, whose anchors have such ghosts, change nothing. The filter acts only on a pair that Jolt adds. Where Jolt calls no filter, physics asks the pair test (FrameWorld.CanTouch). The pair test asks three questions: do the layers meet, does Jolt's rule pass on the Jolt motions, and does it pass on the contact motions? The carry of a contact into a system (see "Contacts") and the check after a change of motion use it. A ghost without a contact motion comes only from a bug. The filter runs on Jolt's worker threads and cannot report it, so it judges the ghost by its Jolt motion, and AssertInvariants finds the missing entry. The pair test runs on the physics thread, so it reports the ghost (World.Errors) and judges it by its Jolt motion too.

A sensor detects the owner of a frame as a body of the hull's motion, in every frame. (User, 2026-10-18) It detects the hull in the frame's tree parent, the interior in the frame itself, and the hull's ghosts in other frames. So a static or a kinematic sensor detects a ship that moves, and only a dynamic sensor detects a parked ship. The interior gets this from its Jolt motion. It is kinematic and at rest while the hull can move, and static while the hull is static. A kinematic interior at rest falls asleep as any body does. A static sensor that appears beside a sleeping interior then waits for the interior to wake, as it waits for any sleeping body. In the frame's tree parent, a sensor that can move never meets the hull's layer. But the hull lies inside the frame's bubble. So the sensor enters the frame before it can reach the hull, and it meets the interior there.

A hull can change its motion. Then every pair of the owner's bodies follows the new motion at the start of the step that applies the change (see "Changes of motion"). Physics removes each ghost of the hull, which loosens its contacts. At once it makes a new ghost at the old ghost's pose and velocity. The new ghost has the Jolt motion and the contact motion of the new motion. Jolt offers every pair of a new body to the filter. The carry at the end of the step keeps each loosened contact that the new ghost can make. The new ghost lags the hull by one step, as every ghost lags an intent that the step applies. The ghost pass at the end of the step puts it where the hull is. The hull and the interior keep their bodies. Physics ends each of their started pairs that the pair test refuses under the new motion (JoltPhysicsBackend.EndPairsThatCannotTouch). Jolt drops a pair that it can no longer make only in a step that simulates one of the two bodies. Without this check, a parked ship and a static sensor beside it keep their touch, because Jolt simulates neither of them. Jolt keeps a pair that the check ends in its cache until an update of the system no longer finds it. That update never comes while the system holds no awake body. So the check puts a pair that Jolt still holds back among the pairs that Jolt found without a touch. When the ship moves again, the pair starts again at its first touch.

These limits stay:

  • Jolt makes no contact between a kinematic body and a static one. So the ghost of a dynamic body never meets a static body. The static body's child ghost carries that contact instead.
  • The ghost of a dynamic sensor detects the child ghost of a static body only while that ghost moves in Jolt.
  • A static body has no stick-out ghost yet, and an interior has no ghost in the frames inside its owner's frame. These wait for a follow-up ticket, with the carry of contacts between an interior and its ghosts.
  • Two dynamic bodies or characters can stick out of two sibling frames and touch where neither one's sphere reaches the other's bubble. Then they pass through each other. Their stick-out ghosts in the parent are both kinematic, and neither body has a ghost in the other's frame.
  • Two dynamic bodies that stick out of two sibling frames, each into the other's frame, meet each other's ghost in both frames: a mirror pair. Two hulls do so too, since a hull's ghost in a sibling meets hulls. Each real body meets the other as an immovable body.

The child ghosts of static plain bodies have a cost. In a system whose semantic frame has a child bubble, the ghost pass reads the position of each static member once per step. It measures the member's distance to each child bubble. A system whose semantic frame has no child bubble skips its static members. Take a station with 2,000 static props and three docked ships, with every body at rest. There, that work costs about 196 µs per step, about 98 ns for each prop, and allocates nothing. A tick takes 344 µs, against 146 µs without the ships (StaticBodyGhostBenchmarks). The 144 µs that the props cost without a ship is mostly the numeric update, which gathers every body.

Numeric frames #

The engine makes and removes frames of its own around groups of bodies, only for precision. These numeric frames keep Jolt's float velocities and float bounds small. Near a ship in orbit, every body moves at about 7.7 km/s in the frame of the planet, and a float velocity there steps by 0.0005 m/s. The design is in docs/plans/numeric-frames.md.

Question Rule
Where a numeric frame lives In the frame tree, with the NumericMode Falling, Resting, Nesting or Following, and a Jolt system of its own. It has no owner, no bubble, no gravity of its own and no rotation. It never turns: it keeps its semantic frame's axes, and its members feel the semantic frame's rotation terms at their semantic places.
What gameplay sees Nothing. Self.Frame, World.Frames, Snapshot, FrameChanged, the topics, QueryRadius, the state dumps and the render snapshot's camera frame show only semantic frames, the frames that content or gameplay made. The public members of FrameTree answer relative to Parent, the semantic parent, and list frames in an order that numeric frames do not change. The members that show the tree as it is stored are internal. The frame outlines of the render snapshot (see "Frame outlines" under "Rendering") show numeric frames too, so that a capture can show them. Gameplay cannot read the render snapshot.
The entity row Frame is the semantic frame. PhysicsHome is the frame whose Jolt system holds the body. Pose is in Frame, HomePosition is the position in PhysicsHome, and LinearVelocity is a float relative to PhysicsHome.
Reads Every read of a position or velocity names its frame. A read in another frame starts from the physics home and composes through the lowest common ancestor.
Writes and directions Relative to self.Frame, in its axes. Physics converts a write when it applies it.
Velocity Jolt's float local velocity plus the double frame velocity of the home, added in doubles.
Who gets one A group of free bodies only gets a falling frame from a common speed of 1,000 m/s, and keeps it down to 400 m/s. Free bodies coast, collide, push each other or fall together. A group with a body that thrusts gets a falling frame from 50 m/s and keeps it down to 20 m/s. Every character thrusts. Any other body thrusts after 0.5 s of ticks whose force and impulse intents ask for at least 1e-3/60 m/s². These intents include the pushes of the body's parts (see "Parts"), so a pair of opposite jets gives no thrust. So a single impulse never counts. Such a body keeps thrusting for 20 s after its last such tick. A slow group 2^23 m (8,389 km) or more from its semantic frame's origin gets a resting frame. It keeps the frame down to 2^22 m (4,194 km). Below the speed and the distance to give up its frame, a group goes back to the semantic frame's own system. (ter-3xs6)
How a falling frame moves It falls freely in the semantic frame's field, integrated in doubles. Each member feels the field at its true position minus the frame's acceleration, so the tidal gravity stays. In a turning semantic frame, FrameTree.BeginStep gives the origin the two-turn update at its semantic place and chord, with the field turned by the step's turn as for a body (see "Turning frames" under "Physics"). So the members feel only the difference of their rotation terms and the origin's, and a falling group keeps small local velocities. A following frame whose anchor has no motion intent in the step does not fall: it lands on the still plan, which holds it at rest in the turning axes where the step starts, and its origin takes no term.
Membership At each sync point, in entity order, in FrameTree.UpdateNumericFrames, the only code that makes, shifts, moves or destroys numeric frames. Two bodies that can touch within 2 s share a frame. Two bodies of one system, a numeric frame's or the semantic frame's own, stay linked 10 m farther. Members split only after 1 s apart, and a body that the semantic frame's own system keeps leaves it for a new resting frame only after 1 s. The group with the most mass in a frame keeps it, and the smaller groups move. The merge test measures two bodies from their places in their homes, through the homes' lowest common ancestor.
Anchors An anchor is a static body, a hull that is not dynamic, the interior of a frame owner, or a kinematic body that lives in a following frame of its own (NumericFrameRules.IsAnchor). Physics keeps the interior at rest in the frame's own system: static while the hull is static, and kinematic otherwise. The numeric update counts the interior as a static body that no update moves (NumericBody.Fixed) in either motion, because the interior and every place that it can meet are fixed in the frame's own system. The merge test links every pair of bodies, an anchor to every group, also to one that falls, and the group takes the anchor's home: an anchor counts as infinitely heavy (ter-dhkf, docs/plans/numeric-frames.md, "Anchors"). A group with a static body never falls, and it gets the mode, the center and the frame of its static bodies alone: their resting frame when they lie far out, or else the semantic frame's own system. A group with a static hull, or with a slow kinematic hull near its semantic frame's origin, or with the interior of a frame owner, stays in the semantic frame's own system. So a fast body within the merge test's reach of its frame's interior runs in the frame's own system. A kinematic body that does not follow is no anchor, but it counts as infinitely heavy too: a group with a kinematic body and no static one has the velocity and the center of its kinematic bodies alone, so a still door keeps a fast crate's group from falling. A kinematic body, a hull or a plain body, that its motion intents move fast or far lives in a following frame of its own, and it is that frame's anchor (see the next row, and ter-zg77). Fast or far means from 1,000 m/s or 2^23 m, the thresholds of free bodies, because a kinematic body never thrusts. Below them a plain kinematic body takes the speeds of its frame's other bodies, as any body does. A group without an anchor at rest takes that following frame. So the anchor is a real body of the group's system, and Jolt sweeps a fast body against it: before, the anchor kept a kinematic ghost in the group's falling frame, which has no linear cast, and a crate at 60 m/s passed through a thin wall (ter-ykqs). A frame that holds a static body of a group, or the following frame of its anchor, has the mass +∞, which only comparisons read, so no sum gives NaN. A group lets go of an anchor when the merge test no longer links them. The split cooldown holds only the members of a numeric frame that are not anchors. Two anchors whose homes are at rest in the semantic frame share the home that the group takes. Two anchors that move differently, the anchor of a following frame and an anchor at rest, such as the place of a ghost fixed in the semantic frame's own system (see the row "Ghosts in the merge test"), or the anchor of another following frame, cannot share a home: the anchor at rest wins, and among following frames the one of the first anchor in entity order. Each other anchor stays in its following frame, and the group's home keeps an anchor ghost of it (FrameTree.AnchorGhosts). The ghost of a hull has the hull's own layer, so it meets only what the hull meets, and the ghost of a plain kinematic body has the body's own layer and collision groups. So a fast plain kinematic body that passes a static body keeps its following frame, and the static body's home keeps its ghost for the encounter. For a hull, that home also keeps a ghost of each body that sticks out of the hull's frame, with the ghost layer. A kinematic body that follows in the following frame of another anchor gets one of its own.
Following frames A following frame (NumericMode.Following, ter-dhkf rule 3, ter-zg77) holds one fast or far kinematic body at rest at its origin, its anchor, and follows the anchor's motion intents. It lies in the anchor's semantic frame and never rotates. The anchor is a hull, whose frame hangs at the origin with the rotation of the hull's motion intent, or a plain kinematic body, a member of the frame's system at its origin. The tree keeps the anchor by entity (FrameTree.Anchors), and nothing of the anchor's own state: a hull's frame has its own row in the tree, and a plain body its transform row and its body in Jolt. Before ter-zg77 the frame was an orbit frame, NumericMode.Orbiting, and only a hull could anchor one. Before each step, FrameTree.BeginStep derives the anchor's step plan: the step record of its motion intent in the intents of the step, from where the anchor stands, the frame's origin, with its frame's rotation for a hull and the rotation of the intent's aim for a plain body, whose rotation the tree does not keep. No reader of a plan reads its angular velocity. The tree keeps the plan for the step, because an end pose needs the place where the step starts, and step 2 moves the frame before it reads the plan. The frame takes the acceleration that lands it on the plan: the plan's chord minus the frame's velocity, divided by the step. An end pose has no acceleration of its own that lands the frame, because its velocity changes at the start of the step. For a full sample, the landing acceleration equals the sample's acceleration to about 3e-10 m/s² in low orbit. In a step the frame falls with that acceleration, as a falling frame falls, and so ends the step on the plan's place. Physics aims the anchor at the frame's origin and snaps it there. At each sync point, in step 2, the frame takes the plan's place, to the last bit, with the plan's chord over the step as its velocity (FrameTree.PlaceFollowingFrame), when the anchor ended the step at the origin. When its anchor has no motion intent in the step, or a steer target, the plan is the record of a kinematic body without a motion intent: it holds still where the step starts, at the place of a teleport in the step or else at the origin, with no velocity (FrameTree.StepPlanOfIntent, ter-zsku). In a turning semantic frame that is rest in its turning axes. The frame lands on that plan by the same rule and holds still in its semantic frame, and physics aims the anchor at the plan as for any plan (JoltPhysicsBackend.AimStillAnchors). The tree and physics take the plan from one total read, PhysicsIntent.StepRecordOrStill: the record of an end pose or a full sample, or else the record of a body that holds still where the step starts. So the frame and its anchor agree to the last bit. So a kinematic body without a motion intent holds still in its semantic frame whatever numeric frame holds it, also far out, and the free members of the frame still feel the full field. Before ter-zsku such a frame fell by its semantic frame's field, the rule of hulls since ter-6r8c. The sleeping members of a following frame wake when its push changes, as the bodies of every frame do (see the sleep rules under "Physics"). The chord is the velocity that Jolt gives a body that moves as the plan says, so the bodies in the frame move as they do in the semantic frame's own system. Jolt and the numeric frames use one scheme: the velocity gains the acceleration, then the place gains the velocity. So the frame's acceleration cancels out of the motion of its contents in the semantic frame, and changes only where the frame lands. It never drains, moves its origin or nests, and only its anchor's group keeps it. The anchor's velocity relative to it is the rest of the plan's instant velocity, kept by the tree row of a hull's frame and by the transform row of a plain body, so that a read in the semantic parent gives the plan's velocity. A new following frame (FrameTree.CreateFollowingFrame, at step 6c) lies at its anchor's place. Its velocity is the chord of its anchor's motion over the step that ended, because the velocity of a numeric frame is a chord. No row keeps a chord, so the frame derives it: the anchor's instant velocity less half a step of its acceleration. For a hull, that acceleration is the gravity at the anchor's place plus the proper acceleration of its frame, the rule of the proper acceleration read backward. In a turning semantic frame the gravity holds the frame's centrifugal, Euler and Coriolis terms, as the rule adds them. The gravity takes the semantic parent's proper acceleration as the step that ended found it, the value that the rule of the anchor's own value read, because step 2 may have given the parent a new value. The tree keeps every frame's value as the step found it from one step 2 to the next for that (FrameTree.FieldTheStepFound). A plain body owns no frame, so the tree keeps no proper acceleration of it, and its frame starts with the chord of a free fall. So the first plan lands the new frame as every later plan does: on a Kepler orbit the start is the orbit's chord, to within the orbit's jerk term, and a hull that moves on end poses at a steady velocity starts with that velocity. A character at rest beside a new fast hull keeps the rigid form in every step. A limit: a spawned hull has no proper acceleration in its first step, so the rule takes it for a free fall there, as for every plain body. An anchor that does not fall, such as one whose motion intents are end poses in a gravity field, takes half the field in its first planned step only. A plain kinematic body takes no spawn velocity, so it gets its following frame at the sync point after its first step. The frame lets its anchor go, and falls on as a falling frame (FrameTree.LeaveFollowingFrame), when the numeric update moves the anchor out, when a hull changes its motion, a hull's frame moves out or dies, or the frame itself goes, and when the update finds that a plain anchor is no body in it any more, because it died or handed off into a bubble in the step (FrameTree.DropLostAnchors). When the anchor teleports, as with the first sample of a new rails component, the frame jumps with it at the next sync point (FrameTree.JumpFollowingFrame): to the plan's place, which after a teleport without a motion intent is the teleport's place. The other bodies and frames in it keep their places in the semantic frame.
Ghosts in the merge test Everything that a group cannot move counts as infinitely heavy, ghosts included (ter-4psc, docs/plans/numeric-frames.md, "Ghosts as infinitely heavy"). The update of a semantic frame P takes the places of the ghosts in P's frames as inputs (GhostPlace). A body of a semantic child C that can stick out of C's bubble before the next update leaves a place in C's tree parent. That tree parent is the home of C's hull. A body of P's semantic parent that can reach P before the next update leaves a place fixed in P's own system. Since ter-imi5, a static body leaves one too. A body of a sibling of P that can stick out of its own bubble and reach P leaves such a place too. A static body, a kinematic body or a hull reaches P's content. That is P's bubble, grown by how far a body of P that can move can reach beyond it before the next update (NumericFrameRules.ContentExcess). A dynamic body or a character reaches only P's bubble (NumericBody.GhostsReachContent), as physics has it (see "Ghosts"). The interior of the parent's owner leaves no place, because it has no ghost in a child frame yet. These tests grow each body by 2 s of its speed, and by at least the split margin of 10 m, so a crate that rests 1 mm inside its bubble gives a place whether it sleeps or not. A first test on distances alone skips each body of P's parent and each whole sibling that cannot reach P's content. The merge test links a body of P to each place that it can touch, as it links two bodies. A body linked to a place in C's tree parent joins the group of C's hull. A body linked to a fixed place keeps its group in P's own system. The fixed place counts as an anchor at rest there: a body linked to it that lives in a following frame of its own stays there, apart from its group, and P's own system keeps an anchor ghost of it, also when every member of the group lives apart (review of ter-zg77). Before, the update dropped the flag of such a body, and a far plain kinematic body passed the fixed ghost of a static sensor without a contact. The ghost lives in the hull's home wherever the update moves the hull, so the body meets the ghost there. Only a body that meets the ghost is linked to its place, as physics has it. A frame hull meets only a place of a ghost of a static body or of a hull in a child frame or a sibling (NumericBody.GhostsMeetHulls, GhostPlace.MeetsHulls), because the mask of a hull names the static role and not the ghost role. A dynamic body or a character always meets the place's ghost. A static or kinematic body meets it only when one of the two is a sensor, and the ghost of a static body meets only what that body meets. A body in the place's system stays linked 10 m farther. Before, a numeric frame of P kept a ghost of each body that sticks out of a child near its members (ter-2gg0). That ghost came at the members fast, with no linear cast. A body that sticks out of a frame whose tree parent is numeric also had a ghost in the semantic parent's own system (ter-wllb). Both are gone.
Drain and recenter A frame takes over its members' common velocity above 1 m/s, and moves its origin to their center of mass beyond 1 km. Both are uniform shifts, so Jolt keeps its contact cache. The ghosts in the frame's system shift too, and wake nothing. A static ghost, of a static body that held still in the frame, moves after a drain. So the drain turns it kinematic, and Jolt moves it in the next step. Each velocity loses the shift in doubles before it narrows to a float.
Moves between systems A whole touching group moves at once, with no FrameChanged and no topic change. A sleeping body stays asleep unless the move changes its velocity. A body that physics has not created yet, such as a spawn, is created in its numeric frame, with its state converted in doubles. A moved body keeps its ghosts in the systems of other frames, because every frame where a ghost can be depends only on the body's semantic frame, which a move does not change. So a fast ship that joins the system of a station's hull while their bubbles overlap keeps its ghost in the station. A moved hull takes along the ghost of each body that sticks out of its frame, which lives in the hull's home, with the ghost's place and velocity in the semantic frame. So a rock that meets a crate's ghost in the step after a ship starts to fall stops at it, as without numeric frames.
Frame owners A hull that joins a numeric frame takes its frame under that numeric frame in the tree. The frame's semantic parent does not change. A frame with a static hull or without a body lies in its semantic parent, and a frame with a kinematic hull lies in its semantic parent or at the origin of its following frame. When the frame hangs in another numeric frame and its owner writes a change of motion, the numeric update of step 6c of that tick's sync point plans the next step with the new motion, and moves the hull with its frame into the home that the new motion gives it. The frame keeps its place, so the merge test sees the hull where it is. The move carries the old home's velocity into the frame. So a parked frame keeps that velocity in the tree while its static hull holds still, and the velocity becomes zero at the next sync point (see "Changes of motion"). A semantic frame that dies takes its numeric frames with it, and the frames in them move to its parent. Every frame that moves up this way goes to the dying frame's semantic parent, whatever the motion of its hull, where the World also moves the frame's hull. The next numeric update moves the hull and its frame into a numeric frame when the group needs one, so only a frame with a hull hangs in a numeric frame. A room without a body in a station on rails that dies in its following frame holds still in the station's semantic parent, and so does a room without a body in a dynamic ship that dies in a falling frame. No numeric update moves such a room. Before the review of ter-4psc, a frame that the engine marked as moved by physics went to the dying frame's tree parent, and such a room fell on in the numeric frame.
Vast distances A numeric frame more than 10^9 m from its tree parent's origin moves under a nesting frame. A nesting frame rests on a grid of 2^20 m in its semantic frame, so its position is exact in a double, and nearby frames share it. It jumps by whole grid steps after its frames, and they shift back by the same exact amount.
Render snapshot It places everything relative to the camera's physics home, which has the axes of the camera's frame.
Thresholds NumericFrameOptions, through WorldOptions.NumericFrames. The precision measurements of ter-kugq set the speeds, the thrust clock and the distances (docs/plans/numeric-frames.md, "Thresholds from measured precision"). Bodies that move together keep their gaps nearly exact up to 1,000 m/s. A body that thrusts drifts from its neighbors by about 2 mm/s per minute of thrust at 50 m/s. Distance added no error up to 9,000 km.

A read in a distant frame, such as the frame of a star, rounds at that distance. The merge test does not: it measures two bodies from their homes, so two bodies a light-year out keep millimeter steps relative to each other.

Physics #

Tertius uses Jolt in double-precision mode. Each frame that contains bodies has its own PhysicsSystem. A frame with no awake bodies is not stepped.

The backend owns each native Jolt object that it creates, and it frees each one in the order that Jolt needs. For example, it frees a character before the system of the character. JoltPhysicsSharp does not mark every native object as owned, so the backend marks each one itself. The C API of Jolt frees the layer filter tables of a system with the system, so each system has its own tables.

Object layers:

Layer Collides with Bodies in the layer
Static Moving, FrameHull Static bodies, the interior of each frame, the child ghosts of static bodies and of hulls, and the stick-out ghosts of hulls in sibling frames
Moving Static, Moving, Ghost Bodies that can move, and characters
FrameHull Static, FrameHull Hulls, and anchor ghosts
Ghost Moving The child ghosts of plain bodies that can move, and stick-out ghosts

A body takes the layer of its description, BodyDesc.Layer, whose default is Moving. A static body on the Moving layer takes the Static layer, and a character always has the Moving layer. The last column gives the layers of bodies with the default. "Ghosts" under "Reference frames" gives the layer of each kind of ghost.

Collision groups refine this table (docs/plans/queries-and-sensors.md, "Collision groups and masks"). Each Jolt object layer holds 16 group bits and 16 mask bits, which Jolt's ObjectLayerPairFilterMask tests both ways. Bits 0 to 11 are content's collision groups, and bits 12 to 15 are the four layers above, which the engine calls roles. A body's groups are its role and its collision groups. Its mask is the any-of set of its collision mask, plus the FrameHull role for a static body. So two bodies that are not hulls collide only when each body's mask matches the other body's groups. A hull is in the FrameHull role alone, with the Static and FrameHull roles as its mask, so it ignores collision groups. The interior of a frame carries the groups and the mask of its entity. So do the inner body and the movement of a character, and each ghost but an anchor ghost. An anchor ghost has the layer of every hull. Every body starts in the group Solid with the mask InAny(Solid), which gives the table above for every pair in which a body can be dynamic. Two bodies that are never dynamic, such as a ghost and a static body, can pass the filter, but Jolt makes no contact between them. A component changes its entity's groups and mask with BodyHandle.SetCollision, and physics applies the change at the start of the next step. Each role has its own broad-phase layer.

A frame entity has two bodies. In the parent system, its hull is a body on the FrameHull layer. In its own system, its interior is a body on the Static layer at the frame's origin, with the hull's shape or BodyDesc.InteriorShape. This includes the exterior surfaces, so that a person on a spacewalk can stand on the hull. Objects inside do not push the interior. The interior is static while the hull is static, and kinematic and at rest while the hull can move (ter-imi5). So a sensor in the frame detects the owner as a body of the hull's motion (see "Ghosts"). The interior of a root frame is always static, because the owner of a root frame has no hull. The World never makes a frame a root after its spawn, but the frame tree does when a frame's root parent dies. Physics then removes the frame's hull, turns the interior static, and ends each started pair of the interior that a static body cannot make, as when a ship parks.

Spawn refuses every body that physics cannot create, before anything changes. BodyDesc.Problem and ShapeDesc.Problem in Tertius.Core hold these rules, and the World and the backend use the same ones. IPhysicsBackend.ShapeProblem adds what only the backend can tell, for example a convex hull whose points coincide. So a change batch holds only bodies that physics can create.

IPhysicsBackend.ShapeCenterOfMass gives the center of mass of a shape, in meters, in the axes of the body that the shape describes, with the body's origin at zero. It is a pure answer: the same description gives the same vector bit for bit. It is thread-safe, so a spawn during a tick can ask. JoltPhysicsBackend reads it from the native shape that it builds and keeps for the description, as ShapeProblem does, and keeps the answer, so a later question costs one lookup. Jolt keeps the center as a float vector, so it widens to doubles exactly. The World asks it at the spawn of each body and keeps it in the body's cold record. The lever of a part is measured from it (see "Parts"), and BodyFacts.CenterOfMass publishes it. A box, a sphere and a capsule have their center at their origin. A compound or a convex hull can have it elsewhere.

A box and a convex hull carry a convex radius in their description, BoxShapeDesc.ConvexRadius and ConvexHullShapeDesc.ConvexRadius. It is the largest rounding, in m, that physics may give the edges of the shape. Jolt shrinks such a shape by its convex radius and rounds it back out, so every edge is a curve. Two rounded shapes side by side in a compound leave a groove at their joint: a ball of 0.25 m radius sinks 4.2 mm into the joint of two hulls with the default radius. So a ring or a torus, a compound of one hull per segment, uses the radius 0, and so does a compound of boxes whose flat faces must line up. The default is ShapeDesc.DefaultConvexRadius, 5 cm. Core holds it in that one constant, and physics keeps no second copy, so a description that names no radius keeps today's shape. Physics can round a shape by less than the radius that its description names. A box never rounds by more than its smallest half extent. Jolt clamps the radius so, and the shape cache does the same clamp to make it explicit. Jolt also never rounds a box contact by more than 5 cm, whatever larger radius the box keeps, so a box radius above 5 cm has the effect of 5 cm. A hull passes the radius to Jolt as the maximum convex radius, and Jolt reduces it when it builds the hull: to half the thinnest width of the hull at most, and so that the rounded hull stays within 5 cm of the true hull at every corner. Where three faces meet at right angles, as at the corners of a slab, that allows 6.83 cm. At the rim of a cylinder hull of many segments, where two faces meet almost flat and the cap crosses them, it allows about 12 cm. Every radius below those limits changes a contact. In the tests, a ball of 0.25 m radius across the joint of two slabs sinks about 0.3 mm for a radius of 1 cm, 4.2 mm for 5 cm, and 7.4 mm for a hull radius of 10 cm, which Jolt reduces to 6.83 cm. ShapeDesc.Problem refuses a radius that is negative or not finite, and it allows zero. The shape cache keys by the reference of the description, so two descriptions that differ only in the radius give two native shapes.

Every Jolt body whose own shape is made of sharp pieces, a ghost and a frame's interior included, sets Jolt's enhanced internal edge removal, and so does every character for its own contact query (ter-fb3w, EdgeRemovalBenchmarks). ShapeDesc.IsMadeOfSharpPieces names such a shape: a mesh, or a compound whose every child is a box or a convex hull of convex radius 0, a mesh, or such a compound. It checks only what the pieces are, not that they meet, because where no pieces meet the flag drops nothing. The flag drops the contact with the edge of a neighbor piece at a joint beyond about 5 mm from a flat joint and 10 mm from a concave crease, so that a ball that lands near a joint is not pushed sideways, a box slides across the joints of a compound without tripping, and a character walks across them without being lifted. In EdgeRemovalBenchmarks, the flag of a floor costs nothing measurable in the scenes whose bodies move alike with and without it, balls on a ring's hulls and balls and boxes on its mesh, and the flag of a character adds about 2 % to the tick of 100 characters that walk across the joints of a ring's hulls. Jolt uses the flag of either body of a pair, but it removes only the internal edges of the pair's second body: the static or kinematic body of a pair with a dynamic one, or of two dynamic bodies the one with the higher Jolt body ID. So a static or kinematic surface removes its edges for every dynamic body that touches it, but a dynamic compound only for a dynamic body of a lower ID: a ball that lands 10 mm from the joint of a dynamic raft of sharp planks spawned before it moves 14.7 mm away from the joint. A compound with a rounded child, a sphere or a capsule does not set the flag, because the groove at its joints is real, and the flag would keep only one of the groove's two contacts. A character's flag still acts on every floor that its query touches, rounded grooves and meshes included, because Jolt's character query reads only its own flag. A character that stands in the middle of the groove between two boxes of the default convex radius keeps one of the two contacts, and it moves 1.1 mm aside and rests 0.15 mm higher, while one that walks across the groove walks as without the flag. On a ring's mesh, which needs no flag, a character that walks at 3 m/s dips up to 1.0 mm into the crease at a joint, against 0.6 mm without it. The engine accepts both, because each is about 1 mm and a character's walk sets its own speed. The rigid form of a character is a body, which follows the floor's flag, so it keeps both contacts of a groove.

Physics turns some values into NaN in its first step, so spawn refuses them too, for every entity. A position or a velocity must be finite. A rotation must be finite and not zero. A mass must be 0 or a finite positive number of at least 1.2E-38, because Jolt divides by it. Friction, restitution and damping must be finite. new DPose { Position = p } has the identity rotation. Only default(DPose) has the zero rotation, and spawn refuses it. Two gaps remain, tracked by ticket ter-d0t6. Spawn tests a rotation in double precision, but physics normalizes it in single precision. So a rotation whose single-precision length rounds to 0 or to infinity still passes. A mass above about 3.5E+37 kg also passes, and its gravity force overflows.

Gravity is a force that physics applies to each awake dynamic body before each step. The Jolt system gravity is zero. The force does not wake a sleeping body, and it does not reset the sleep timer of an awake body.

If three points of a body stay in a small sphere for 0.5 s, Jolt puts the body to sleep. The radius of the sphere is the sleep threshold, a speed, times 0.5 s. Jolt's own threshold is 0.03 m/s.

Take a body that a net acceleration moves from rest. If the body does not cross the sphere in its first 0.5 s, Jolt puts it to sleep. To stay awake, the net acceleration must be more than 8 per second times the threshold. With Jolt's own threshold, that is 0.24 m/s², which is more than all of a weak gravity.

So each physics system has a sleep threshold that follows the weakest effective gravity on its awake dynamic bodies. In a turning frame, that gravity holds the centrifugal and the Euler term (see "Gravity"). At standard gravity (9.81 m/s²) and above, the threshold is Jolt's own 0.03 m/s. Below standard gravity, it falls in proportion to the gravity, down to 3.1 µm/s at 0.001 m/s². It does not fall further. So a body whose net acceleration is more than 2.4 % of the gravity stays awake, in every gravity up to standard gravity.

This keeps a ball on a ramp moving, and a box that tips over a ledge. It also keeps cargo moving that slides on the deck of a thrusting ship, and a body that falls slowly against its damping. A body at rest on something falls asleep, and it costs nothing until something wakes it. A falling body stays awake, and so does a stack of bodies that falls together. Without gravity, a body that drifts keeps drifting until it moves slower than 6 µm/s.

The sleep rule has these limits:

  • A body that a net acceleration below 2.4 % of the gravity moves from rest can fall asleep, and it stays there. Above standard gravity, the limit is 0.24 m/s². An example is a ball on a ramp of 0.5° in 9.81 m/s², whose net acceleration is 0.6 % of the gravity.
  • A body whose motion turns around under a net acceleration below 9.8 % of the gravity can fall asleep at the turning point. An example is a ball that rolls up a ramp of 5°, whose net acceleration is 6.2 % of the gravity.
  • Below 0.001 m/s², the threshold does not fall further. There, a body that a net acceleration below 0.000024 m/s² moves from rest can fall asleep.
  • One system has one threshold, which its weakest gravity sets. An example is one awake body far out in the frame of a planet, where the gravity is weak. It lowers the threshold for every body of that frame, so bodies at rest there take longer to fall asleep. An awake body near the axis of a spinning station, where the centrifugal term is weak, does the same to the station's system.
  • Jolt puts a group of bodies that touch to sleep only all at once. In the benchmark, a heap of 400 boxes that fell and tumbled in 9.81 m/s² took about 25 s to fall asleep. Before round eight it took about 57 s.
  • In weak gravity the threshold is lower, so a heap that tumbles takes longer to fall asleep. The same heap took about 125 s in 1.62 m/s², the gravity of the Moon. Before round eight, it took 76 s there.

If a body that Jolt simulates touches a sleeping body, Jolt wakes the sleeping body. Nothing else wakes it. So before physics removes a body, teleports it, or moves it without a simulated step, physics wakes the bodies around it. Examples of such a move are a static hull that the game moves through the frame tree, and a static hull that its owner teleports. Others are a hull that the reconcile puts in another place, and a ghost.

Physics wakes each body that is near the moved body and can touch it. Near means in the bounds of the moved body, plus 0.05 m. Can touch means that the two layers collide. If Jolt does not simulate the body at its new place either, physics wakes the bodies there too. This is the case for a static body and for a character. So if the table under a sleeping box goes, the box falls, and the bodies farther away sleep on.

If the world destroys a character, or if the character moves to another frame, the character takes its inner body out of its old system. So a box that sleeps on the head of a character that the world destroys falls. A character that walks into another frame leaves nothing behind that a body can hit.

If the gravity field of a frame changes, the sleeping bodies of the frame wake. They also wake when the acceleration that they feel relative to the frame may have changed by more than WakeAccelerationThreshold since their last wake. A sleeping body has no local velocity, so it moves with its frame, and that is right only while what it would feel there stays the same. One cause changes that acceleration, a change of how the frame moves against a free body, and one test bounds it (JoltPhysicsBackend.FeltChangeWakes, docs/plans/spin-gravity.md, "The wake"). The bound adds two parts. The first part is the change of the frame's push since the last wake (FrameWorld.WakePush). The push is what an accelerometer at the frame's origin reads in the step in flight, apart from the frame's own field (FrameTree.PushInStep). The push of a semantic frame is its proper acceleration, so if the thrust of a ship changes, even slowly, its cargo wakes. The push of a numeric frame is its acceleration relative to its semantic frame, less the acceleration of a free body at its origin: zero for a falling frame, the field reversed for a resting frame, and for a following frame the acceleration that lands it on its anchor's step plan, less the free body's. In a turning semantic frame the free body's acceleration holds the rotation terms at the origin, so a falling frame's push stays zero there too. So when the anchor of a following frame speeds up, the frame's sleeping members wake in the first step of the speed-up and keep their motion in the semantic frame (ter-zg77). Before, they moved with the frame. The field of a numeric frame takes in its semantic frame's proper acceleration, so when that proper acceleration moves past the threshold, the sleeping members of a resting frame wake at the step at which the bodies of the semantic frame's own system wake. Before ter-zg77, they slept on and moved with the frame. The second part is the change of the turn of a turning frame, which changes the centrifugal and the Euler acceleration from place to place. Physics bounds it by (|ω| + |ω_wake|) |ω − ω_wake| B + |α − α_wake| B, with ω and α the semantic frame's inertial angular velocity and angular acceleration of the step from its turns, and the same at the system's last wake (FrameWorld.WakeAngularVelocity and WakeAngularAcceleration). B is the reach of the system: the largest distance from the semantic frame's origin to a point of one of its dynamic bodies, each bounded by its bounding sphere, with the home's offset in a numeric home. Physics tests the push's part alone first, as the float square of its change against the float square of the threshold, as before the rotation terms. It measures the reach only when that does not wake the system and the turn changed, so in a frame that does not turn the test is the push test from before the rotation terms, to the bit. So a station that spins up at 0.01 rad/s² wakes its sleepers at once, and a frame that faces its planet on an oval orbit, whose rate changes a little in every step, lets them sleep.

These Jolt settings differ from the Jolt defaults:

Setting Value Reason
Penetration slop 0.005 m Jolt uses 0.02 m, which is visible on small props.
Maximum linear velocity 30 km/s Orbital speeds reach about 8 km/s, and Jolt clamps at 500 m/s.
Motion quality Linear cast Fast bodies must not tunnel through thin walls.
Bodies per frame system 4,096 at first Jolt sizes its buffers up front. A full system grows to twice the size.

The proper acceleration that physics measures for a dynamic hull has a dead band. A component below the float resolution of the hull velocity, divided by the time step, reads as zero. Without it, cargo in an orbiting ship drifts on float noise. Physics releases the value beside the hull's step record (FrameStepRecord.ProperAcceleration). The frame tree derives the value of every other frame from its record (see "How a frame moves").

A dynamic hull in orbit is integrated with float velocities, so its orbit drifts slowly. A frame that coasts for a long time uses a kinematic hull and a KeplerMotion instead.

Jolt keeps rotations as float quaternions, and the square of the length of one is up to 2E-7 away from 1. So the backend normalizes every rotation in double precision where it leaves Jolt, because the hull's rotation becomes the local pose of its frame.

The character controller is a capsule. It uses the frame's effective gravity. Physics stands a character only on walkable ground under gravity. That is, the effective gravity is at least FrameTree.ZeroGravityThreshold, 0.05 m/s², Jolt found ground under the character that is not too steep, and the character does not move away from that ground along the ground's normal. The ground's normal is the normal of the walkable face that holds the character up: of the faces that the character touched in its last update, the one nearest its up, as Jolt chooses the contact that supports a character, and up when that face is steeper than CharacterMaxSlopeDegrees. Jolt's own ground normal is the mean of every face that the character touched, which at the rounded edge of a stair leaned 68 degrees toward the riser. A standing character inherits the velocity of the surface under it, walks with the part of its desired velocity across its up, turned into the ground plane at the same speed, so that on a slope and on a faceted floor it walks along the face (ticket ter-1b21, fork F6 of the spin gravity design), and can jump. Jolt reads the system gravity to press a character's weight onto a dynamic body that it touches. That input is the effective gravity plus the Coriolis term on the character's velocity in the semantic frame, and for a standing character its part along up acts along the ground's normal. So on a flat face in a station a person weighs m (ω² r ± 2 ω v), and the turns of a curved floor's faces add m v² / r. On a static floor the input changes nothing. A character that does not stand is weightless, in any frame and any gravity, because it falls freely or no gravity acts on it. (User) It keeps its momentum, and gravity, forces and impulses change its velocity. In a turning frame the rotation terms change it too, so it moves on its inertial line. Walking and jumping do not move it. The ground holds a standing character against gravity, and in a turning frame against the whole acceleration of its frame, so neither gravity nor the rotation terms change the velocity of a standing character, except in the step in which it jumps, and a character that stands still on a floor has no velocity. Gravity in that velocity would press the character into its ground in every step. Jolt then reported a false velocity of one step of gravity, and it pushed each dynamic body that the character leaned on a little in every step, so a stack that a character stood against woke each time it fell asleep. Forces and impulses change the velocity of every character. The velocity rule goes by what the character's velocity means at the start of its update, a closed set of three cases, the generated union CharacterStartVelocity (ticket ter-1b21, docs/plans/spin-gravity.md, "Characters"). HeldStart is the ground's velocity plus the walk of a character that stands and does not jump, and it takes no gravity. InstantStart is an instant velocity: the ground's velocity plus the walk plus the jump along up, or a velocity that the game gave since the last update. The chord rule turns it into the chord behind it, and it gains a whole step of gravity. A jump is relative to the ground, so to the home's instant velocity. A velocity that the game gave is relative to the home's frame velocity, and the case carries the home's instant velocity past its frame velocity as its excess (InstantStart.Over), so that the float rule takes the excess off in the same subtraction as the half step of gravity, as it did before the rotation terms. ChordStart is Jolt's chord of a character that does not stand, and it gains a whole step of gravity. In a turning frame the two last cases take the two-turn update of a dynamic body, with the field part turned by V1's turn, and the change of an impulse or a force enters as FrameTurn.ChangeBehind of it with V2 (see "Turning frames"). A held character takes the change as it is. In a frame that does not turn, every case keeps the float rule of before, to the bit. Physics keeps the desired velocity of a walk, which applies once the character stands. After each step in which a character in the walking form (see below) did not stand, it loses the part of its velocity that goes into each static or kinematic surface that it hit. So a character that jumps into a low ceiling falls back at once. That part is relative to the surface, so a wall that moves into the character gives it the wall's velocity. Against a dynamic body the walking form keeps its momentum and pushes the body with Jolt's push, which is capped at CharacterMaxStrength and takes no reaction from the character (ticket ter-w7cw). The direction into a surface is the face's own normal if Jolt's contact normal lies within 10 degrees of it, and Jolt's contact normal otherwise, because Jolt's contact normal can lean several degrees on a flat face, while at an edge or a corner the face's normal would cancel motion that nothing blocks. Jolt pushes a character out of a surface that it sank into by moving it, so that push gives the character no velocity.

A character has two forms (ticket ter-1w06). In the walking form it is a Jolt CharacterVirtual, as the paragraph above describes. When the effective gravity at it falls below FrameTree.ZeroGravityThreshold, physics replaces it with the rigid form. The effective gravity holds the centrifugal and the Euler term of a turning frame, so near the axis of a spinning station a person takes the rigid form: in a station whose floor 100 m out gives 9.81 m/s², at 0.3 m from the axis, where ω² r is 0.029 m/s², and not at 2 m, where it is 0.20 m/s² (SpinStationCharacterTests.APersonNearTheHubOfTheStationTakesTheRigidForm). The rigid form is a dynamic Jolt body with the character's material, its mass and no damping, whose rotation is locked. Jolt's solver handles every contact of the rigid form as it does for any rigid body, with friction. So a weightless push conserves momentum and has no strength cap: a weightless person who presses into a crate moves the crate and the person together, and a person and a crate that drift together keep their places. Forces and impulses act on the rigid form. A walk and a jump do not move it, as they do not move any character that does not stand, and a spin does nothing to it. A character takes no motion intent, in either form (see "Rails components and motion intents"). The rigid form keeps the character's up and rotation. It never stands. When the effective gravity reaches twice the threshold, 0.1 m/s², physics turns it back into the walking form. Between the two values a rigid character that touches nothing keeps its form, so a floating character whose gravity hovers near the threshold switches once. The band is wider than the noise of a frame's proper acceleration, which physics measures from float velocities. A rigid character that touches something, as one that lies on a floor does, walks again from the threshold on, because only the walking form stands, and in free flight the two forms move alike. So between the two values a character that touches something has the walking form, whether it came from a weaker or a stronger gravity: a person who lies weightless on a floor when a weak drive starts stands and walks, as one who spawned there does. A ship whose thrust dips below the threshold makes its standing crew rigid for that step. Physics decides the form of every character at the start of each step, from the gravity at it, before it applies intents and gravity, so each step acts on one form. A switch keeps the frame, the pose, the velocity, the up and the contacts: the contacts of the old body carry over to the new one, as in a hand-off, and both forms end a held contact by one contact list (see the contacts of a character below), so no Collision or CollisionEnded comes from a switch. The rigid form's capsule reaches as far as the walking form. It is 0.02 m wider, the gap that a Jolt character keeps from a wall at its side, and its cylinder is as much shorter, because a standing Jolt character rests its capsule on a floor. So a switch moves nothing. The rigid form takes the path of every dynamic body: gravity, hand-offs, moves between numeric frames, shifts, growth, ghosts, collision groups, sensors, contacts, queries and reports. Only the end of a held contact follows the character's contact list, as for the walking form. A character in the air under gravity keeps the walking form, but Jolt's plain update moves it, not the extended update that a standing character gets, which cancels the velocity into each steep surface that the character touched in its last update and sticks the character to the floor when it loses a touch.

Under gravity, the up of a walking character turns against the semantic gravity, with the centrifugal and the Euler term of a turning frame, whether the character touches anything or not (ticket ter-1b21, fork F5 of the spin gravity design). A character that touches anything turns its up at once. That is, Jolt's ground state from the last update of the character is anything but "in the air": walkable ground, steep ground, or a contact that does not support the character. So a character that lands on its side or on its head turns upright, and then it can stand. A character that touches nothing turns its up at most at the air rate, JoltBackendOptions.CharacterAirUpTurnRate, 1 rad/s by default, on the great circle toward the up against the gravity. So a flip of the felt gravity for one step, such as a hull's jolt, turns a floating person's up by less than a degree, and a person who jumps across a spinning station comes down feet first on the far floor. A person who drifts out of a ship in orbit turns its feet toward the planet only where it walks under the planet's field, which is so only without numeric frames. With the default numeric frames its home outside the ship is a falling frame, where the effective gravity is about zero, so it takes the rigid form and keeps its last up (CharacterOrbitHandOffTests). Where the gravity is below FrameTree.ZeroGravityThreshold, the character keeps its last up. Up is the body's +Y, so a teleport sets the up to the +Y of its rotation, in either form, as a spawn does. A teleport to a tilted rotation in the air then turns upright at the air rate, with its rotation along. At a hand-off, a character that stood in its last step takes its up from the gravity of its new frame. Any other character keeps its up and its rotation, turned into the axes of its new frame. The hand-off reports the rotation that the character got in its new frame.

In each step, physics reports for each character whether it stood. A character that a hand-off moved has not stood in its new frame yet. Physics looks up the contacts of that character in its new frame at once, so a character that walks on across the boundary stands again in the next step. Components read the report as self.Body.Standing, and other code as Snapshot.IsStanding. Tick N sees the step that ran during tick N - 1. So the components that steer a character agree with physics on where weightlessness begins.

Turning frames #

A frame turns when the thing that moves it turns it: its owner's motion intent, physics for a dynamic hull, or a teleport. It also turns with every frame above it. A free body moves on a straight line in inertial space. In axes that turn, that line bends, and the rotation terms (Coriolis −2 ω × v, centrifugal −ω × (ω × r) and Euler −α × r) are the shape of the bent line. So physics has one rule for every turning frame: it keeps each free body on its inertial line, as the frame's axes see it (User, 2026-10-16; docs/plans/spin-gravity.md). Before spin gravity, no rotation term acted, and the scope of the engine said so.

The turns. Before each step, in PrepareTick, FrameTree.BeginStep fixes two turns for every semantic frame, in one pass in the tree's dense order, parents first. A turn of a step is how the coordinates of a direction that is fixed in inertial space change in the frame's axes over the step, with the frame's inertial angular velocity at the step's end, in doubles (FrameTurn). V1 is the turn of the step in flight, and V2 is the turn of the step that ended, which the pass copies from the old V1. The pass composes V1 as end⁻¹ · parentTurn · start from the parent's V1 and the frame's own rotation relative to its parent at the two ends of the step. The rotation at the start is the tree rotation, or the rotation of a teleport in the step's intents. The rotation at the end comes from the frame's level, chosen as the follow kind is:

  • An end pose or a full sample of the owner gives the rotation and the angular velocity of its record, exactly.
  • A dynamic hull is predicted from its last record: the rate is the row's angular velocity plus V3 times the step, and the hull turns by that rate as Jolt turns a body. So the contents of a hull that spins up feel the Euler force one step late, the same lag as the hull's proper acceleration. When Jolt puts a hull to sleep in a step, it sets the hull's angular velocity to zero, which is no motion of the step, so the hull's record of that step has no angular acceleration (ComputeProperAccelerations). The tree then predicts no turn, and the frame of a sleeping hull does not turn back by its last rate.
  • Any other frame holds still.

When no level of the chain turns, V1 is exactly the identity. A turning frame is one whose V1 or V2 is not the identity (FrameTurns.Turns), with no list of kinds. A frame whose V2 is not the turn that the chords of its bodies came from takes its new V1 as its V2, so that its step assumes a steady turn: a new frame, a frame that a teleport or a transform write turned, every frame below such a frame, and every frame after a failed step (the frames without a last turn). A numeric frame never turns and reads its semantic frame's turns. The pass allocates nothing and checks no handle.

The update. In a system whose semantic frame turns, ApplyGravity gives every awake dynamic body and every rigid character the two-turn update, x_(n+1) = 2 R_n x_n − R_n R_(n−1) x_(n−1), with R_n V1's turn and R_(n−1) V2's, as the change of the body's chord over the step (FrameTurns.FreeChange). It acts on the body's semantic place and chord, so in a numeric home it adds the home's offset and frame velocity. It holds all three rotation terms exactly, at any step length, and it needs no rate. The force adds the linear gravity, in doubles, and narrows once. A push that is fixed in the frame's axes acts at the start of the step, in the axes then, so the turn of the step carries it into the axes at the end:

  • The field part of the linear gravity, which is the linear gravity plus the home's acceleration, gives R_n g dt². The home's acceleration is motion in the semantic frame's coordinates, which takes no turn.
  • A force of the intent gives R_n F / m · dt. Jolt adds F / m · dt, unturned, so the update adds the difference. The force and the torque of a body's intent include the pushes of its parts (see "Parts"), which the update turns as the body's own.
  • A torque of the intent changes the inertial angular velocity at the start of the step, as an angular impulse does. Jolt adds I⁻¹ τ dt, unturned, so the angular form takes that change as a change at the start of the step and leaves Jolt's own addition out.

Left unturned, each of these pushes a body sideways by ω |a| dt. Inside a room at rest in a spinning station the linear gravity is the room's push reversed, ω² R, so a free crate left its line by 2.9 cm in 1.5 s. A ship that thrust at 4.9 m/s² left the path of its thrust by 0.11 m in 3 s, and a ball under a torque of 1 N m turned 5.2 mrad off in 5 s. With the turns, the three stay within 1e-4 m, 5e-5 m and 7e-6 rad.

The angular velocity follows the angular form: Jolt's angular velocity is set so that exp(Ω_J dt) = R_n · exp(Ω dt), with Ω the body's inertial angular velocity at the start of the step (FrameTurns.AngularChordAhead). So a body keeps its inertial angular velocity, as Jolt keeps it in a frame that does not turn. The rigid form of a character cannot turn, so it gets no angular part.

An impulse changes Jolt's velocity at the start of the step, in the frame's axes then, but the update reads Jolt's velocity as the chord of the step that ended. So ApplyBodyIntent measures the change from Jolt's velocities before and after, the update takes it out of Jolt's velocity, and puts back FrameTurn.ChangeBehind of it with V2, which the update carries by R_n alone. An angular impulse's change goes to the angular form as the change at the start of the step. A dynamic body that a steer target moves takes no update in that step, only the linear gravity, because its steering velocities are already the chord of the step in the frame's coordinates. A kinematic body moves in the frame's coordinates and takes no turn. A static body takes nothing. A walking character takes the update in its own update, by the case of its start velocity (see the character paragraphs above): a character that its ground holds takes none, and one in the air or in the step of its jump takes the two-turn update, with its field and its velocity change turned as a dynamic body's are. Its weight input is the effective gravity plus the Coriolis term on its velocity in the semantic frame. In a frame that does not turn, physics skips all of this, and every result stays as before, to the bit.

The velocity shadow. Jolt keeps float velocities, and their rounding would pile up over many steps where a step turns by much. So each dynamic body in a system whose semantic frame turns keeps a shadow (BodyRecord.Shadow, a generated union, VelocityShadow): the double chord and place that its update gave it, and the float velocity that physics expects Jolt to hold at the end of the step, computed with Jolt's own float operations: the update's force, the intent's force and Jolt's damping factor max(0, 1 − c dt). When the shadow is from the last step and Jolt's velocity is within 1e-6 (|target| + 1 m/s) of the expected one, the next update starts from the shadow's chord and place, and its force also takes back Jolt's rounding of the velocity and of the place. When Jolt's velocity differs by more, a contact changed it. Jolt applied the contact in the frame's axes at the start of the step that ended, while that step's chord is in its axes at its end, so the update starts from Jolt's velocity with the contact's change turned by V2's turn: the contact turn. In every other case the update starts from Jolt's velocity and place. A spawn, a hand-off, a switch of a character's form, a teleport, a SetVelocity, a motion intent, an impulse, a body that falls asleep and a system whose frame stops turning end the shadow. The shifts and moves of numeric frames carry it into the new home. The shadow lives only in turning frames (User, 2026-10-18), and it expects Jolt's damping, so it serves damped bodies too (User, 2026-10-18). An undamped crate at rest in inertial space stays within 0.04 µm of its place over 10 minutes in the spike's station. Without the shadow it moved 24 mm.

Jolt damps a velocity relative to the frame's axes, as air that turns with the station would. So a damped body leaves its inertial line. A damped body that touches nothing in a station drifts outward, and it leaves the bubble of the spike's station within minutes.

The acceleration that the frame gave a body. BodyRecord.FrameAcceleration, an AppliedFrameAcceleration(Field, Turning), holds the acceleration of the force that the frame gave a dynamic body in the last step, in the home's axes. Field is the linear gravity. Turning is the rest of the force's acceleration: the rotation part of the update, the turns of the field and of the intent's force, the move of an impulse's change, the take-back of the shadow and the contact turn. Total is their sum. ComputeProperAccelerations measures a dynamic hull's proper acceleration as its change of velocity over the step minus Total, and InstantVelocity reads Field. The contact turn belongs in Total: a body at rest on a floor gets an update that leans across the floor by ω³ r dt, and the contact turn takes that lean out again, so a total without it reads a push of the floor across itself. A ship on the floor of the spike's station read 0.0507 m/s² across the floor without it, and 4.4e-5 m/s² with it. A sleeping hull takes the linear gravity as Field and the effective gravity minus the linear gravity as Turning, so its Total is the effective gravity, which it rests against. A walking character that its ground holds writes zero for both. One that the update moves writes the linear gravity as Field and the rest of its update beyond the rule without turns as Turning, as a dynamic body does. A frame that does not turn writes Turning as zero, so its total is the field, as before.

What the rule gives, in the spike's station, which spins at 0.31321 rad/s, 9.81 m/s² at its floor of radius 100 m (tests in SpinStation*Tests.cs):

  • A crate dropped from rest relative to the station lands at 0.5607786 s and 0.176458 m antispinward, as its inertial line says.
  • A ship on the floor reads ω² R toward the axis, awake and asleep, and its cargo weighs ω² (R + r). A ship that holds its radius by thrust reads its thrust.
  • Sleepers wake when the station spins up and stay at rest in inertial space. A stack on the floor rests and falls asleep.
  • The same scene gives the same events and the same state, bit for bit, with one thread and with four.

The cost, from SpinStationBenchmarks.cs in benchmarks/Tertius.Benchmarks (Release, on an arm64 Mac):

What Measured
The two-turn update, the angular form and the shadow A tick with 1,000 awake crates took 1,044 µs in the spinning station and 770 µs at a spin rate of 0, about 0.27 µs per awake dynamic body per step.
The reach of the wake test A ship with a dynamic hull that spins at 0.3 rad/s, with 1,000 awake crates in its bubble (DampedSpinningShipBenchmarks): 911 µs per tick when the hull keeps its rate, and 976 µs with an angular damping of 0.05 per second, whose turn changes in every step, so the wake test reads the place of every crate for the reach. Without that read the damped case took 926 µs, so the reach costs about 50 ns per dynamic member per step in a frame whose turn changes. The damped case allocated 195 bytes per tick against 94, and the 101 more went away without the read. Their source is not known: the code of the walk allocates nothing.
BeginStep over 1,000 frames, a root and 333 chains of 3 levels 47 µs when every level turns, 23 µs when none does, with no allocation. ter-sn28 measured 44 µs for a pass over 1,000 frames before.
The character update 100 people who stand on a flat floor took 177 to 180 µs per tick in the spinning station and 162 to 166 µs in a frame with the same gravity that does not turn, over three runs (SpinStationCharacterBenchmarks), so the update of a turning frame costs about 0.15 µs more per standing character per step. Every person stands in both cases, its feet within 0.2 mm of the floor. Before ticket ter-1b21 the people of the station floated 5 cm above the floor, and the two cases did different work.
Memory V1 and V2: 144 bytes per frame (FrameTurns, with the product of the two turns). V3: 24 bytes per frame, and a flag per frame for the frames without a last turn. V4: 24 bytes per body, 12 more than GravityApplied. The shadow: 80 bytes per body. The impulse's step and changes: 56 bytes per body.

These limits stay:

  • The contents of a dynamic hull that spins up feel the Euler force one step late. A crate in a ship spun up at 1 rad/s² misses its place at rest in inertial space by α r dt², 5.6 mm, for that reason.
  • The chord rule cannot follow the jerk of a frame's origin (see "Instant velocities and chords").
  • The step record of a dynamic hull keeps Jolt's angular velocity, the angular chord of the step, as the frame's angular velocity. In a parent that turns across the hull's spin, the chord differs from the instant angular velocity relative to the parent by about dt |Ω × ω| / 2, with Ω the parent's angular velocity and ω the hull's. A body that hands off out of the hull's frame carries ω × r off by that much times r: 2.6e-3 rad/s for a ship that tumbles at 1 rad/s across the axis of the spike's station at 60 Hz, 2.6 cm/s at 10 m from the ship's center.
  • A read of a body's velocity in another frame keeps the instant rule on the row's chord, which is off in direction by up to ω dt |v| / 2 in a turning frame: 8 cm/s at the spike's floor at 31.3 m/s and 60 Hz, the order of the half step by which every velocity that a tick reads lags.
  • A frame on a Kepler orbit in a turning parent turns with the parent, so it reads no zero proper acceleration. Orbits belong in a frame that does not turn.

Instant velocities and chords #

A velocity has one of two meanings (ticket ter-ediu):

Meaning What it is Who uses it
Instant velocity The velocity at one moment Every velocity that gameplay sets: a spawn velocity, SetVelocity, the rest after a teleport, a jump. Also the velocity that a hand-off carries into another frame, and the instant velocity of a frame's step record.
Chord velocity The change of a body's place over the last step, divided by the step Jolt. Every velocity that physics reports for a body is Jolt's, so it is a chord.

Jolt steps a body with semi-implicit Euler: the velocity gains the acceleration times the step, and then the place gains the new velocity times the step. So the velocity that Jolt keeps after a step is the chord of that step. Under a steady acceleration, it is the velocity half a step before the end of the step. If Jolt took an instant velocity as its stored velocity, a body would move as if the velocity had been given half a step earlier: in the spike of the spin gravity epic, a thrown crate landed 2.5 cm off at 60 Hz.

So physics gives Jolt the chord that matches each instant velocity, the chord behind it: the chord of a step that ends at the instant of the velocity, which is the instant velocity minus the body's acceleration times half a step. Jolt then adds a whole step of the acceleration. The chord of the coming step is then the instant velocity plus half a step of the acceleration, and a body under a steady acceleration moves exactly as its instant velocity says. The engine corrects Jolt's convention for every body, not only in turning frames. (User, 2026-10-16)

The acceleration is the one that the step gives the body relative to its home. For a dynamic body it is the home's linear gravity minus Jolt's linear damping times the velocity. A kinematic body has none, because Jolt does not accelerate it. For an angular velocity, the only acceleration is Jolt's angular damping, because Jolt applies no gyroscopic torque. So the angular chord is the instant angular velocity times 1 plus half a step of the angular damping rate.

Where physics turns an instant velocity into a chord (src/Tertius.Physics/JoltPhysicsBackend.Chords.cs):

Place When What
ApplyBodyIntent At the start of a step A SetVelocity, a SetAngularVelocity, and the rest after a teleport (LinearChord, AngularChord)
ConvertSpawnVelocities At the start of the first step after a spawn The spawn velocities of a dynamic rigid body. Physics creates a body between two steps, when it does not know the length of the next step, so the body keeps its instant velocities until the start of that step.
UpdateCharacter In the character's update A character makes its chord in its own update: a jump, and a velocity that the game gave since the last update, lose half a step of the linear gravity. A character that the ground holds and that does not jump gets no gravity in its velocity, so its chord is its instant velocity. In a turning frame the instant velocity also takes the turn's part of the chord rule, with V2 (InstantStart).
HandedOffVelocities At a hand-off, after the Jolt update The chords behind the instant velocities in the new frame, which Jolt's next step carries on. A character that stood keeps its instant velocity as its chord, because the ground holds it there too. One that did not stand loses half a step of the new frame's linear gravity, and in a turning frame it takes the turn's part with the new frame's V1, as a dynamic body does.

Where physics needs the instant velocity of a body at the end of a step, for a hand-off, it adds half a step of the acceleration that the step gave the body to Jolt's chord (InstantVelocity, InstantAngularVelocity). The step record of a dynamic hull's frame does the same for its linear velocity, but it keeps Jolt's angular velocity, the angular chord, as its angular velocity, because the frame tree predicts the hull's turn in the next step from the rate that Jolt turns it by (see the limits under "Turning frames"). That acceleration is the linear gravity that physics applied (the field of BodyRecord.FrameAcceleration) and the damping. A sleeping body got none, and neither did a character that the ground held (BodyRecord.Held). A body that got the gravity also got minus its home's step acceleration, so it moved in the semantic frame as Jolt moves it in the semantic frame's own system, and its instant velocity is relative to the home's instant velocity in Jolt's stepping: the home's frame velocity after the step plus half a step of its step acceleration. A hand-off adds the home's instant velocity of the convention below (InstantHomeVelocity), so physics adds the difference first (HomeSteppedVelocityOverPlan, ter-zsku). The difference is zero for a falling frame. For a following frame it is the plan's jerk on a smooth plan, and half the frame's change of velocity when the plan changes the frame's velocity at the start of the step: 1 km/s when a hull at 2 km/s stops. Before that conversion, a crate that entered the bubble of such a hull in the step in which it stopped entered at 3 km/s. A body that got no gravity moved with its home, so its velocity is relative to the home itself.

In a turning frame (see "Turning frames"), the chord behind is the chord, over the step that ends at the instant, of the inertial line through the body's state, and its exact inverse gives the instant velocity at the end of a step:

ChordBehind(x, v)  = (x − R⁻¹ (x − (v + ω × x) dt)) / dt
InstantAtEnd(x, c) = (x − R (x − c dt)) / dt − ω × x

where x is the body's semantic place at the instant, v its instant velocity and c its chord, R the turn of the step that ends at the instant and ω the frame's inertial angular velocity at the instant (FrameTurn.ChordBehind and InstantAtEnd). The sites at the start of a step, ApplyBodyIntent and ConvertSpawnVelocities, take V2. InstantVelocity, InstantAngularVelocity, HandedOffVelocities and the step record of a dynamic hull's frame (HullRecordOf) take V1 of the step that just ended, in the new frame for a hand-off. The record of an awake hull that stayed in its home takes InstantAtEnd on the chord of the frame's origin, in place of half a step of the rotation terms that the update gave the hull (ter-vja8). The two agree only to first order in dt: half a step of the rotation terms misses the instant velocity by the jerk of the hull's path times dt² / 3, so a free ship at rest in inertial space 100 m from the axis of the spike's station read 2.9e-4 m/s in its frame's velocity, and every hand-off out of the ship carried that. With the exact inverse it reads 3.7e-6 m/s, two float steps at 31 m/s. A hull that handed off in the step (BodyRecord.HandOffStep) has the acceleration of its hand-off, which holds the turn's part already. The angular pair is AngularChordBehind and InstantAngularAtEnd, on the inertial angular velocity. The field acts in the frame's axes at the start of a step, so the chord behind takes the half step of the field part of the linear gravity turned back by the turn, R⁻¹ g dt / 2. The instant read at the end of a step keeps the half step unturned: the field at the end of the step, in the axes then, is the frame's field itself. A crate at rest in inertial space that a room sweeps over kept 8.8e-6 m/s through both hand-offs, where a turned instant read left it at 2.1e-4 m/s. A dynamic body takes the turn, and so does the linear part of a rigid character. A kinematic body takes none, because it moves in the frame's coordinates. A walking character takes the rule in its own update, by what its velocity means at the start of the update (CharacterStartVelocity, see docs/plans/spin-gravity.md, "Characters"). One that its ground did not hold in the step, in the air or in the step of its jump, also takes the inverse in InstantVelocity, and one that did not stand takes the chord behind in HandedOffVelocities, as a dynamic body does. One that stood, also in the step of its jump, keeps its instant velocity as its chord at a hand-off, as in a frame that does not turn. The turn's part acts on the body's semantic place and velocity, so it adds to the terms of a numeric home: the chord at the start of a step converts by FrameTree.HomeInstantVelocityOverChordAtStepStart and then takes the turn's part, and the instant velocity at the end of a step takes both the turn's part and HomeSteppedVelocityOverPlan. In a frame that does not turn, every site keeps its formula, to the bit.

The rule cannot follow the jerk of the origin of a frame that lies off the axis of a turning parent, ω³ R for a room at the distance R. So a crate spawned at rest in such a room drifts at ω³ R dt² / 6: 7.1e-5 m/s at 50 m from the axis of the spike's station. On the ground of Earth the jerk is 2.5e-6 m/s³, which is negligible.

A velocity relative to a numeric home has two forms. The home's frame velocity is a chord too, because the numeric update steps the home the way that Jolt steps a body (see "Numeric frames"). So the home's instant velocity lies further on: half a step of a falling frame's acceleration, or, for a following frame, the plan's instant velocity past its chord (FrameTree.HomeInstantVelocityOverChord). A velocity that gameplay gives is relative to the home's frame velocity, as LocalVelocity gives it. Only the velocity relative to the home's instant velocity is the body's local instant velocity, whose chord Jolt needs. A velocity that a body gets at the start of a step, from a spawn, gameplay or a hand-off, converts by the home's instant velocity past its chord where the step starts: half a step of the step acceleration of each numeric frame up to the semantic frame (FrameTree.HomeInstantVelocityOverChordAtStepStart). Only then does the home's step acceleration cancel out of the body's motion in the semantic frame. It equals the value at the end of the step for a falling frame, and for a following frame on a smooth plan to the plan's jerk, but not when the plan changes the frame's velocity at the start of the step, as for an anchor that stops (ter-zsku). With the value at the end of the step, a body spawned beside a following frame that stops was 3.8 km off its orbit.

Some paths carry a chord on as a chord, so they need no conversion. They are a move between the systems of one semantic frame, a shift of a numeric frame, the growth of a system, and a ghost, which is a kinematic copy that follows the chord of its real body or of its frame's step record. When the frame or its parent turns over the step, a ghost's chord converts by the frame's poses at both ends of the step, c' = (P_end x_end − P_start (x_end − c dt)) / dt, and its angular chord by exp(Ω' dt) = P_end.Rotation · exp(Ω dt) · P_start.Rotation⁻¹ (StepPoses). At the spike's floor, at 31.3 m/s and 60 Hz, the instant rule put a ghost's velocity off by about 8 cm/s in direction. Otherwise a ghost keeps the instant rule, to the bit. In the first two, both homes' velocities are chords, and the body's acceleration in the semantic frame stays the same. A frame's hull that takes the tree's velocity takes the frame's instant velocity of its last record, half a step of the hull's acceleration past the chord. A kinematic hull takes no velocity from the tree. The step record of a frame holds both velocities, its chord and its instant velocity.

The tests are in tests/Tertius.Physics.Tests/InstantVelocityTests.cs: a thrown crate lands where and when its instant velocity says, and bodies and characters that get a velocity at spawn, in flight or by a jump move on the parabola of that velocity. The tests of turning frames are in tests/Tertius.Physics.Tests/SpinStationChordTests.cs: a drop, throws at 60 and 120 Hz, a spawn and a teleport to rest, and a ball that leaves the station with its ghost (docs/plans/spin-gravity.md, "The chord rule and the two conversion rules"). tests/Tertius.Physics.Tests/SpinStationCharacterTests.cs has the people of the spinning station: one that falls onto the floor and stands for 600 of 600 ticks, a jump that lands where the inertial line of its takeoff says, a velocity that the game gives in the air, a person in the air of a room at rest in the station, an impulse in the air, the weight of a person who walks spinward and antispinward on a dynamic plank, a jump across the station that lands upright on the far floor and stands, the rigid form near the hub, a person who leaves the station into space with the velocity of its inertial line, and one who leaves it into a parent that spins up and keeps its line. CharacterStandingTests has the walk along a slope in the ground plane, the press along a dynamic face's normal, the climb of a stair by its height only, and the turn of up in the air at the air rate.

Physics queries #

A physics query that a component asks for becomes a PhysicsRequest in its chunk's list of physics requests, and the sync point runs it. A request holds what every request has: the asker, the frame that the asker was in, the entity query that says what may be hit, and the tag of the answer. It also holds a PhysicsQuestion, which says what the request asks. PhysicsQuestion is a generated union (see "Unions"), and its cases are RayQuestion(Origin, Direction, Length, Kind), ShapeCastQuestion(Shape, Start, Direction, Length) and OverlapQuestion(Shape, Pose). The kind of a ray is RayCastKind.Nearest for CastRay or RayCastKind.All for CastRayAll. The World checks every argument and makes the direction a unit vector before it builds the question, so a request with the default question, which holds no case, comes only from a bug: a Debug build breaks a contract at the sync point, and a Release build gives it no answer and records the state in World.Errors. Every question has the same sweep, which the runner walks through the frames: a ball of radius Reach moves from Origin along Direction for Length meters. A ray reaches 0 sideways, and an overlap moves nowhere (its direction and length are zero) and reaches as far as its shape's bounding radius. FirstHitOnly is true for the nearest hit of a ray and for a shape cast. These sweep properties are switch expressions over the cases with no discard arm, so a new case breaks the build until it answers them. The runner reads the rest of what each case decides with a switch over the cases too: the line along which the walk tests the bubbles (an overlap, which moves nowhere, takes any line through its center), the Jolt call, whether an entity counts once however many of its bodies are hit (only for an overlap), and the answer event.

A shape cast and an overlap query take a QueryShape. It is a custom C# 15 union, as Target is, and its cases are QuerySphere(Radius), QueryBox(HalfExtents) and QueryCapsule(HalfHeight, Radius). The factories QueryShape.Sphere, Box and Capsule build them. A constructor does not check sizes. QueryShape.Problem() says why no query can use a shape, and the World refuses such a query at once, in the call that asks. The default value holds no case (HasValue is false), so Problem() refuses it, and its bounding radius is 0. The backend's QueryShapeCache makes one native Jolt shape for each shape value, with a switch over the cases. A capsule with a half height of 0 becomes a sphere there, because Jolt does not accept a capsule without a cylinder part.

Contacts #

If two bodies start to touch, physics reports the contact once, as a Collision to each of the two entities. A contact starts at a real touch: the first step in which the two bodies are at most 1 mm apart, or approach fast enough to close their gap within the step, so that the solver pushes them apart. Jolt finds a pair earlier, as soon as the bodies are within its speculative contact distance of 0.02 m. Such a pair can touch many steps later or never, so it starts no contact. (User) Each contact that starts ends later with exactly one CollisionEnded to each of the two entities. Physics reports the contact again only after it ended. Jolt finds contacts only for the bodies that it simulates, and it does not simulate a sleeping or a static body. A body can fall asleep on something static or asleep, or a hull can become static on a floor. If the rest of the system keeps stepping then, Jolt drops the contact. After the body wakes or moves, Jolt finds the contact again.

A bounce leaves Jolt's speculative contact distance (User). When a landing step begins within that distance, Jolt gives a bouncing body its rebound at once, from where it is. So a small bounce from a step that began near the surface stayed within the distance, while the same bounce from a step that began near its edge left it: one drop reported one landing or two, by where its landing step began. Physics therefore applies a minimum rebound. A contact does not bounce when the bounce that Jolt would give it rises less than 4 cm, twice the distance. Jolt applies the accumulated forces at the start of a step, so it takes the speed that they added off a rebound: the rebound is the combined restitution (the larger of its two bodies') times the approach speed before the solver runs, less the pull toward the surface along the contact's normal times the step. The pull comes from the forces and inverse masses of the dynamic bodies, and gravity is one of those forces. A numeric frame falls with its semantic frame's gravity, so its bodies get only the gravity that it leaves, and a ghost or kinematic body there comes at them at the frame's acceleration, which Jolt does not see. So in a numeric frame the rule counts each body that is not dynamic as accelerating at the frame's acceleration backward, which gives the pull of the semantic frame, and numeric frames change no decision. Under that pull the bounce rises the rebound squared over twice the pull, so the limit grows with the pull: at 1 g the slowest rebound that remains is about 0.89 m/s, and with no pull toward the surface every bounce remains, because it never comes back. The rule reads what Jolt simulates: Jolt's own velocities, in which every ghost moves as its real body does (ter-dq92), and the contact point that approaches fastest, because Jolt decides restitution at each point and a report sets one restitution for the whole manifold. Jolt computes the settings of each contact again in every step, so both of Jolt's reports of a contact, a new one and one found again, set the combined restitution of such a contact to 0 in every step. Jolt's linear cast of a fast body calls the same reports and uses the settings that they leave. The cast does not take the forces off its rebound, so the rule judges a cast's bounce a little short. A landing whose bounce is just below the minimum in its first step of touch can still bounce in the next step: the speculative contact lets the body reach the surface at almost its full speed, and the gravity of the next step raises the approach above the minimum. That bounce leaves the distance too. The first version of the rule asked for a rebound of 1 m/s without the pull at any gravity, which kept bounces inside the distance at 2 g and more and stopped slow bounces in zero gravity (review of ter-ejx9). The default restitution of a body is 0, as in Jolt, so a body bounces only when its content names a restitution. (ter-4kaa)

Physics holds each contact that Jolt drops. If Jolt finds a held contact again, the contact continues, and physics does not report it. A held contact ends at the first step that simulates one of its two bodies without finding the contact. So sleep and wake end nothing. For example, the game can lift a sleeping box off the floor. Its contact with the floor ends, and the box reports the floor again at its landing. In each step of a system, physics visits every held contact of that system.

A contact between the body of one entity and the ghost of another entity's body is a contact of the two entities, and it can start through the ghost. (User) A ghost stands for its real body, so the impulse of such a contact uses the masses of both real bodies, and it does not depend on which system reports the contact. Jolt moves every ghost with its real body's velocity, and a ghost of a static body is static only while it holds still (see "Ghosts"), so the test of a touch and the impulse read Jolt's velocity of the ghost, which is the real body's. (Before ter-dq92, the ghost of a mesh was always static, and physics put it at its real body's place after each step, so Jolt neither stopped nor bounced a body by the mesh's motion.) A contact of two ghosts is a contact of their entities too (ter-imi5). No ghost is dynamic in Jolt, so Jolt makes such a contact only for a pair with a sensor. An example is a sensor and a body that stick out of two sibling frames. If they overlap only in the parent, outside both bubbles, they touch only through their two stick-out ghosts there. The two entities of a contact can touch in several systems at once. For example, a body that sticks out of a child frame has a ghost in the parent, and a body in the parent touches that ghost, while that body touches the child's bubble and has a ghost in the child, which the first body touches. Physics counts, for each pair of entities, the body pairs that hold their contact as started, in every system. The contact starts when the count goes from 0 to 1, and it ends when the count goes back to 0. So the two entities hear one Collision and one CollisionEnded, however many systems hold the contact in between. When several systems start the contact in one step, the system of the frame with the lowest index gives the point, the normal and the frame of the Collision, so that the order of the systems does not matter. In each step, the body pairs that end count down after the ones that start, so a contact that one system drops while another system starts it goes on. (ter-ejx9)

A contact also ends when one of its bodies is destroyed. The other entity hears it, and the end names the entity that is gone. A body that leaves its system, by a hand-off, a move between numeric frames or a destroy and create at one sync point, keeps each contact in every system that holds a body of each of the two entities, real or ghost, that can touch each other by the pair test (see "Ghosts"). That is the new system when the other entity has its body or a ghost there, and the old system when the body sticks out of its new frame's bubble and leaves a ghost there. Two touching bodies that move together keep their contact, and so does a body that enters a frame ahead of a body that it touches. A ghost that physics removes takes its contacts along in the same way. Physics settles these contacts after the hand-offs and the ghosts of the step, so the new ghost of a body that left can carry them. A move between numeric frames happens at the sync point, and the ghost pass puts the moved body's ghosts at its new state, and makes the ones that its new place wants, only at the end of the next step, so its contacts wait until then. In each system that takes a contact over, the contact is held, and it ends as a held contact does. Every other contact of the body ends, with one exception. A hand-off happens in the step, and the numeric update of the last sync point chose the numeric frames for the bodies' places before the step. So after a hand-off, a contact with a body or a ghost in a numeric frame can be in no system, where without numeric frames one system takes it over. This happens in two ways. A body can enter a frame while it touches a body of the semantic parent. Among the frames of the semantic parent, its ghost is then only in the frame's tree parent, the home of the frame's hull. That tree parent can be a numeric frame. The last update linked the other body to the body that entered, not to the hull. So the other body can live in another frame of the semantic parent: a numeric frame, or the semantic parent's own system while the ghost is in a numeric frame. A body that leaves a frame whose kinematic hull lives apart from its group leaves into the hull's following frame. It drops its ghost in the group's home, where the bodies that the merge test linked to the hull live. The next sync point's numeric update links the touching bodies, or names the group's home for the ghost of the hull. So the rule is: a contact that no system takes over waits with its count until the end of the next step, as the contacts of a move do, when a hand-off of the step moved one of its bodies and one of its bodies, or a ghost of one, is in a numeric frame. A contact that really ended at the hand-off ends one step late. Without numeric frames, no body and no ghost is in a numeric frame, and the system that takes the contact over is the semantic parent's own, so the two entities hear the same events either way. (reviews of ter-ejx9 and ter-2gg0, and ter-4psc) A system takes a contact over only for two bodies that Jolt and the contact filter can make a contact of, which the pair test asks. Their layers meet, so a hull never takes over a contact with a body that moves. Jolt's rule passes on their Jolt motions. When one of them is a ghost, Jolt's rule passes on their contact motions too. A character's own collisions find every body. A held pair that Jolt can never find ends a step late, or never while both its bodies sleep. So no system takes one over. A crate that slides on a static floor into a bubble keeps its contact with the floor: the floor's child ghost in the bubble takes it over. An interior never takes over a contact of its owner's hull: the hull lies inside its frame's bubble, so a body is in the frame before it can touch the hull.

Jolt reports the contact of a pair that it found before in every step, which is a call from Jolt into C# for every contact of every simulated body. Only a pair that Jolt found without a touch needs these reports. So physics hears Jolt's contacts through its own contact listener of the C API, whose callbacks are C# functions that Jolt calls directly, and not through the contact events of JoltPhysicsSharp. Those events look up a C# object for each of the two bodies, in a table behind a lock, which made each tick of a heap of 300 awake boxes about 15 % slower while one pair waited for its touch. The callback for a contact that Jolt found again leaves at once while no pair waits for its touch and the contact's restitution is 0. When a pair waits, it reads the two body IDs and leaves after one set lookup, unless the pair is the one that waits. When the contact can bounce, it also reads the approach speed of each contact point for the minimum rebound, and the bodies' accumulated forces only when a point approaches faster than Jolt's 1 m/s for restitution. The C API keeps one table of contact callbacks for the whole process, so this table replaces the one of JoltPhysicsSharp: its contact events never fire, and the backend replaces the listener that JoltPhysicsSharp gives each new system before the system first steps. (ter-pnco) The impulse of a Collision is an estimate from the approach speed. Jolt's C API cannot read the impulse that the solver applied.

Jolt never simulates the inner body of a character. A character also keeps a gap of 0.02 m from what it touches, which is Jolt's speculative contact distance. So Jolt loses and finds the contact of a character and a box again and again while the character pushes the box. For this reason, the character's own contact list decides the end of a held contact of a character. This list holds each body within 0.1 m of the character, asleep or awake. If the list no longer has the other body, the held contact ends. A character in the rigid form has the same rule. Physics gives it the list that its walking form would have, by Jolt's query for the contacts of a character at its pose, after each update of its system. Without that, a switch to the rigid form would end each contact with a body between 0.02 m and 0.1 m away, which the walking form still held, though nothing moved. A character's contacts that Jolt holds end as those of any body do.

The gap of 0.02 m puts the contact of the inner body at the edge of Jolt's speculative contact distance, so Jolt can miss it, and Jolt never finds a contact of the inner body with a static body. So the character's own collisions start a contact too, also with a ghost. A hit is a real touch. If the character hits a body in its update, and the contact of the pair has not started, physics reports the contact. If Jolt does not hold the pair, physics holds it. The list then ends it, as above. If Jolt finds the contact later, the contact continues. The impulse of such a contact is the speed with which the character moved into the body along the contact's normal, times the reduced mass of the two. So physics reports the contact of a character that walks into a box, pushes it slowly, or lands on a box or on a floor. A body that moves into a character that stands still starts a contact only when Jolt finds it for the inner body.

Physics systems that grow #

Jolt creates each physics system with buffers of a fixed size. So a system holds a fixed number of bodies. Ghosts and the inner bodies of characters count too. Physics never refuses a body for lack of room. It adds a body or a character to a full system in three steps. First, it builds a new system with twice the limits for bodies, body pairs and contact constraints. Then it moves every body of the old system into the new one, and it frees the old system. Last, it adds the new body. A character in the walking form moves as a new native character in the new system, and one in the rigid form moves as a rigid body. A native character that physics frees takes its inner body out of its system. So physics frees the old native character before the old system. This happens in one place, so every path that adds a body gets it: a new body, an interior, a ghost, a hand-off, and a hull that the reconcile moves.

The move keeps the state of each body. A rigid body keeps its ID, its shape, motion type, layer, mass, material and user data, its pose and velocities, and whether it sleeps. A character keeps its form, pose, velocity and up direction, and a walking one finds its ground again. Each pair of bodies that touches stays in contact, so physics does not report the contact again. The new system starts with no contacts, so physics holds each pair, as it holds a contact that Jolt drops (see "Contacts"). This holds for any number of growths. Two things stay behind, because the C API of Jolt cannot save them. The first step in the new system solves its contacts without the impulses of the last step. Also, the sleep timer of each body starts again.

The bodies move in the order of their IDs, and the characters after them. So two runs that grow at the same tick stay the same, bit for bit. A growth costs one pause, in proportion to the number of bodies in the frame, each time that the frame doubles. Physics writes a warning each time. A frame that is known to be large can start larger: raise MaxBodiesPerFrame in the backend options.

Output that only a bug produces #

A state that only a bug can produce breaks a contract in a Debug build. A Release build skips it and records it in World.Errors. A contract that the physics backend breaks is a bug too. It escapes World.Tick on the ticking thread, with the stack of the physics thread, and the world does not record it as a failed step.

The physics backend reports each such state that it finds in the Errors list of the change batch or of the step output. Examples are a motion that the hull cannot take, a motion intent whose case the body's motion in the step refuses, and a change that the backend cannot apply. Another is a started pair that is no contact of two entities, which the check after a change of a hull's motion finds (see "Ghosts"). A Release build drops that pair. The World records each message in World.Errors. Spawn refuses every body that physics cannot create, so a change that fails in the backend comes only from a bug. The backend leaves nothing of that change behind, and it applies the rest of the batch.

For physics output, the facts below define a correct backend. Entities and frames die only in step 5 of the sync point or outside a tick. Physics hears of each death before its next step. An entity keeps its body for its whole life, because a full physics system grows. Each hull starts a step in the system of its frame's parent. So its frame can only enter a sibling or leave for the parent's parent. Physics moves a frame only through its hull: a dynamic hull by the update, a kinematic hull by the motion intent that it applied or its owner's teleport, and a static hull by its owner's teleport. Only a dynamic hull hands off. Physics and the check of its output derive the motion of a hull in a step from the same intents, the ones that the step applied, or else from the stored motion (FrameTree.HullMotionInStep). So a hull is dynamic in a step exactly when that read gives Dynamic for the step, and physics never hands off a hull whose motion in the step is static or kinematic. A root frame has no parent, so its owner has no hull.

So step 2 of the sync point checks each output before any state lands:

  1. A body state names a live entity that has a body, and a live frame.
  2. A hand-off names a live entity that has a body, and a live target frame. It leaves the frame that the entity is in at the start of the step. For the owner of a frame, that is the frame's parent in the tree. The owner of a root frame is in no frame, so it never hands off. If the entity owns a frame, that frame's hull has the motion Dynamic in the step (FrameTree.HullMotionInStep with the intents that the step applied). The target is a different frame. Each entity has at most one hand-off in a step.
  3. The hand-offs of a step, applied in their order, never put a frame under itself or under a frame inside it.
  4. A step record names a live frame with a parent. The owner of that frame has a body, which is the hull. So a step record never names a root frame.
  5. A contact names two different live entities that have a body, and a live frame. An ended contact names two different entities. One of them can be gone or have no body, because destroying a body ends its contacts, and physics hears of the destroy before its next step.
  6. A character state names a live entity whose body is a character. Each entity has at most one character state in a step.

The class PhysicsOutputCheck in Tertius.Core holds these checks. The sync point uses it. The physics tests use it too, in the class that stands in for the sync point. So the tests apply the same rules as the engine.

A broken character state is the one exception to the rule below. It changes only whether its character stands, so a Release build records one error and skips only that state. The character state of an entity whose update the sync point skips is part of that update, so it is skipped too.

If an output fails a check, a Release build skips the whole physics update of each entity that the output names, for that step. For a step record, the entity is the owner of the frame. The whole update of an entity is its body state, its hand-off, the step record of its own frame, and its contacts. The sync point records exactly one error for each output that fails. An output that fails more than one check still records one error. An output whose entities another failed output skipped already still records its own error.

The skip does not change physics. At the next reconcile, a frame hull goes back to where the tree says. A plain body that physics moved into a frame that is gone stays in that system. When physics finds that the frame is gone, it removes the body with a warning.

Physics also checks each motion intent at the start of the step, before it applies it, with the guard of every motion intent (MotionIntent.ProblemFor, see "Rails components and motion intents") and the body's motion in the step. A case that this motion refuses comes only from a bug: an end pose or a full sample on a body that is not kinematic, a steer target on a body that is not dynamic, or any case on a character. The guard of the writers, the drops of step 4 and the check of a spawn give a body only a case that its motion in the step takes. A Debug build breaks a contract. A Release build ignores the motion intent for that step, applies the rest of the intent, and records the bug in World.Errors.

Rendering #

The renderer is a deferred Vulkan renderer. It gets a flat list of instances, each relative to the render origin, and it knows nothing about frames, except that each frame outline of the snapshot names the kind of its frame, for its color (see "Frame outlines" below). The overlay pass draws them (see "The overlay pass" below).

The order of a rendered frame #

The renderer records each rendered frame in this order. FrameGraph.Stage fixes the order of the passes.

  1. Uploads of new meshes, textures and materials. The first frame also uploads the BRDF tables (see "Shading").
  2. Batching on the CPU (SceneBatcher): interpolation by alpha, frustum culling, depth ranges, sorting and indirect commands. The impostor manager takes its impostors out of the batch and chooses the captures of the rendered frame. The batcher also collects the puffs of the snapshot at the render age and sorts them with the transparent instances (see "Puffs in the renderer" below).
  3. The shadow maps of the sun.
  4. Impostor captures.
  5. The G-buffer pass: the far, middle and near ranges. Each range draws the impostor billboards in it after its geometry.
  6. The lighting pass, into the HDR target.
  7. The transparent forward pass: the transparent instances, alpha-blended, and the puffs, added, in one order from back to front.
  8. Tone mapping: an exposure scale, then the ACES curve in Stephen Hill's fit.
  9. The overlay: the frame outlines of the snapshot, blended over the tone-mapped image (see "The overlay pass" below).
  10. The readback of a requested capture. After the GPU finishes the frame, Render hands the pixels to the capture's sink. The readback comes after the overlay, so a capture includes the outlines.

A pass is a RenderPass in one stage of the FrameGraph. Shadows and impostors use three hooks:

  • A RenderPass in a stage.
  • An IRangeDrawer, which draws inside each depth range of the G-buffer pass.
  • An IInstanceProcessor, which gets the culled instances before batching.

A capture is a one-shot request. IRenderer.CaptureNextFrame(sink) takes an ICaptureSink, and any thread can call it. The next Render call that draws a frame waits for that frame on the GPU. Then it calls the sink's Take with a CapturedImage, on the thread that called Render, before it returns. The image holds width × height × 4 bytes, rows from the top, each pixel red, green, blue with sRGB encoding, then a fourth byte with no defined value. The bytes are in the renderer's readback buffer, so they are valid only during the call. A BGRA output, such as a window swapchain, swaps red and blue in place in that buffer first, so every sink gets one pixel order. A window whose swapchain images do not allow transfer reads, or whose surface offers none of the four 8-bit RGBA and BGRA formats, refuses a capture: CaptureNextFrame throws NotSupportedException. A frame that the output skips, such as one of a minimized window, keeps the request for the next frame that draws. The renderer knows nothing about files. PngCaptureSink, in Rendering, copies the pixels, makes every pixel opaque, and writes a PNG file before Take returns. FfmpegVideoSink, in Host, writes the pixels straight into the input of an ffmpeg process as the next frame of a video (see "Host and scripted runs").

GPU timings #

RenderStats.GpuTimings gives the GPU time of each pass of the last frame whose GPU work finished: a GpuTimings with that frame's number and one PassTiming per pass, in recording order. A pass's name is its RenderPass.Name, such as G-buffer, the same name that its debug label carries. A frame skipped by the output still reports the timings that its wait found.

  • FrameGraph.Record is the one place that writes timestamps: one before the first pass, and one after each pass. So a new pass is timed with no work of its own. Each timestamp waits for all commands before it (vkCmdWriteTimestamp2 at all commands), so the passes split the GPU time from the end of the uploads to the end of the last pass, and a pass that overlaps the one before it is charged with what the overlap leaves. In a window, the first pass that writes color can also include the wait for the swapchain image on some drivers, because the output waits for the image at the color attachment output stage, so only a headless run measures the cost of a pass.
  • A pass that records nothing in a frame still reports its time, which is nearly 0. That is the honest cost: a mean over many frames counts the frames in which the pass did nothing.
  • GpuTimer owns one timestamp query pool per frame in flight, of GpuTimer.MaxPasses + 1 queries. A frame graph holds at most 31 passes, and FrameGraph.Add refuses one more. Recording resets the slot's queries first (vkCmdResetQueryPool) and keeps the names of the passes that it records beside the slot's pool. Those names match the stamps in the pool once the frame is submitted. A slot that has not submitted a frame yet is never read, because Vulkan forbids reading a query that was never reset.
  • Render reads the slot's results right after its wait on the slot's fence, before it records again, with vkGetQueryPoolResults and no wait flag, so the read never stalls. So frame n reports frame n − FramesInFlight, and the first FramesInFlight frames report null. A result that is not available is no timing for that frame, never a time of 0: the timings of an earlier frame stay. The last FramesInFlight frames of a run are never read.
  • TimestampClock (on VulkanDevice.Timestamps) is the queue's clock: timestampValidBits of the graphics queue family and the device's timestampPeriod. A queue with 0 valid bits, or a period that is not above 0, cannot time the passes. Then VulkanRenderer.TimesPasses is false, the timer makes no pools and records no queries, and GpuTimings stays null. A difference of two ticks wraps at the valid bits, as the counter does.
  • The timings allocate nothing per frame. The timer reuses one array, so a later frame rewrites the passes of an earlier RenderStats in place, and a reader copies what it keeps.
  • On the development Mac (Apple M1 Max, MoltenVK 1.4.2), the graphics queue has 64 valid bits and a period of 1 ns, and the passes report real times.
  • A headless run writes the summary of the timings to gpu-timings.json in its output folder (see "Host and scripted runs"). docs/plans/rendering-upgrade.md, "Rules for every member", gives the commands to measure a feature's cost at 1080p.

The render snapshot #

The snapshot builder runs at the sync point, in step 11. It computes the pose of every frame relative to the camera's physics home (see "Numeric frames"). It computes in doubles, along the path through the lowest common ancestor. The home has the axes of the camera's frame, and it is near the camera. So positions near the camera stay exact far from the origin of the camera's frame. The builder then writes each instance relative to the render origin, which is the camera position at the current tick. Each instance has a current and a previous transform relative to the same origin, so that the render thread can interpolate.

The builder interpolates only between two consecutive builds of the same camera in the same frame. In any other build, the previous transform is the current one. The previous pose of a frame uses the tree of this build with the local poses of the last build. So a frame that moved between the builds carries its contents along. The builder keeps the last pose of each entity and the frame that the pose was in, in the PoseMemory column of the entity table. So an entity that a hand-off moved to another frame interpolates smoothly.

The snapshot also carries:

  • The camera: its current and previous position and rotation, and its vertical field of view.
  • The sun: its direction from the render origin toward the origin of World.SunFrame, and its angular radius, atan(r / d). Here r is the bounding radius of the owner of the sun's frame, and d is its distance.
  • The ambient light, and one RenderLight for each light that an entity carries: a PointRenderLight or a SpotRenderLight (see "Shading"). A light sits at the pose of its entity composed with the offset of its LightData.
  • OriginInFrame and OriginFrameId: the render origin in the camera's frame, and an id of that frame. The shadow texel grid uses them.
  • RootRotation and PreviousRootRotation, for the star field (see "Shading").
  • Outlines: with World.ShowFrameOutlines on, one FrameOutline for each frame that has a parent (see "Frame outlines" below).
  • Puffs, PuffLooks and PuffAgeStep: the live puffs, their looks and the age that every puff gained since the previous build (see "Puffs in the snapshot" below).

A build of 8,192 or more slots runs on the gameplay workers when the world has more than one, in chunks of 1,024 slots. The chunks join in slot order, so the order of the instances does not depend on threads.

Puffs in the snapshot #

After the instances, the builder places the puffs of the puff table (see "Puffs"), in RenderSnapshotBuilder.BuildPuffs. It reads a PuffView of the table: the live rows, packed from row 0, one range of rows per frame, the look table, and the length of the step that the rows describe. A puff is not an entity, so it has no PoseMemory. The snapshot gets three members, which Clear empties:

Member Type Meaning
Puffs List<RenderPuff> The live puffs, in the order of the rows of the puff table
PuffLooks List<PuffLook> The look table at the build, by index, with its free entries
PuffAgeStep float The age, in s, that every puff gained since the previous build: the step when this build interpolates and the previous build was of the tick before, and 0 otherwise

A RenderPuff is 32 bytes. Position and PreviousPosition are relative to the render origin, in the axes of the camera's frame, in m. Age is the age at the snapshot's tick, in s. Look is the index of its look in PuffLooks. The previous age of a puff is Age − PuffAgeStep, which is below 0 for a newborn.

For each range, the builder reads the pose of the range's frame relative to the camera's physics home once, at this build and at the previous one. These are the poses that place the instances. Then, for each row:

  • Position is the current pose of the frame applied to the row's place, less the render origin.
  • PreviousPosition is the previous pose of the frame applied to the place one chord back, Place − Chord · step, less the render origin. That is the puff's place at the end of the step before, in the same frame. This holds only when the build interpolates and the previous build was of the tick before. Otherwise PreviousPosition is Position: in the first build, after a camera cut, in a second build at the same tick, such as a headless capture, and after a build that skipped ticks. The builder keeps the tick of its last build with a camera for that check.

The rows are doubles, and the poses compose in doubles from the camera's home. So a puff near the camera is exact however far it is from its frame's origin. A snapshot without a camera has no puffs and no looks. A range in a frame that the build did not place adds no puffs. After a sync point every range is in a live frame, so this only guards against a broken table, as for an entity. The placement runs on the calling thread, after the chunks of the instances join, and it keeps the order of the rows. Placing the same view twice in one build gives the same snapshot.

RenderSnapshotBenchmarks.PlacePuffs measures the placement alone (ter-4zt6, Release, one thread). It places 4,096 puffs, the default capacity, in two ranges, after a build that follows the build of the tick before, so each puff also takes its previous place. It takes 7.7 to 8.0 ns per puff in the three scenes, so about 33 µs for 4,096 puffs. The 1-minute load of the machine was 13 when the run began and 6.4 when it ended, so these numbers are an upper bound. The placement allocates nothing.

Frame outlines #

A frame outline shows the region that the rule of its frame covers. World.ShowFrameOutlines turns them on. It is a view setting, as World.Camera is: the builder reads it through RenderView at each build, so the first snapshot that is built after a change shows it. It is off by default, and then the builder does no outline work. A scene can turn it on at its build, and the outlines step of a script changes it between ticks (see "Host and scripted runs"). FrameOutline is a custom union, as RenderLight is, with one case for each kind of frame that has an outline:

Kind of frame Outline Case
Root frame None. Nothing enters or leaves a root frame, so its rule covers no region. None
Semantic frame with a parent Its bubble: the ellipsoid of FrameTree.Bounds, centred on its origin, in its axes. SemanticOutline(Center, Rotation, Radii)
Falling, resting, nesting and following frame Its contents sphere FallingOutline, RestingOutline, NestingOutline and FollowingOutline, each (Center, Radius)

A numeric frame has no bubble. Its members are a group of bodies, not the bodies in a region, so its outline shows what it holds. Its contents sphere is the smallest sphere centred on its origin that holds these:

  • The bounding sphere of each body whose physics home it is: at the body's HomePosition, with the radius that the entity table kept at the spawn.
  • The outline sphere of each frame whose parent in the tree, as the tree stores it, is this frame. The outline sphere of a frame is the smallest sphere centred on its origin that holds its outline: the largest radius of the bubble for a semantic frame, and the contents sphere for a numeric frame.

So the sphere of a falling frame holds the bubble of the ship that falls in it, and the sphere of a following frame holds the bubble of the station whose hull anchors it. The sphere is centred on the frame's origin, as a bubble is, so it also shows how far the members drift from that origin before the frame moves its origin to them.

With the setting on, the builder adds the outlines after the instances and the sun, on the calling thread, after the chunks join. It first makes one pass over the live bodies in slot order. Each numeric physics home keeps the largest value of |HomePosition| + the bounding radius. Then it walks the frames from the last dense index to the first, so that it reaches each child before its parent, and grows each numeric parent to |the child's local position| + the child's outline sphere. A semantic frame's outline sphere is its bubble, so a body or a frame that reaches out of a bubble grows nothing. It computes in doubles and narrows each radius to a float at the end. Then it adds one outline for each frame that has a parent, in the dense order of the tree. The list keeps that order, so it is the same in every run and on any number of threads. The center and the rotation of an outline are its frame's pose relative to the camera's physics home, which also places the instances, less the render origin, in the axes of the camera's frame. A frame of another tree gets an outline too, placed through the shared root, as its instances are. An outline is not interpolated: in a capture, at alpha 1, an outline and the instances of its frame agree exactly. In the window, an outline can lag the objects of its frame by one tick of their motion relative to the camera.

RenderSnapshotBenchmarks measures the build with the outlines off and on (ter-84iw, Release, one thread). With them off, a build takes the time that it took before the outlines, within the noise. With them on, a world of 1,000 frames and 20,000 bodies in 100 resting frames builds in about 1,165 µs instead of 950 µs, and its 1,000 outlines take 48,000 bytes. The orbit scene, with 15 frames, builds in 1.95 µs instead of 1.5 µs. No build allocates.

Puffs #

A puff is a loose body without a shape (docs/plans/particles.md). It is born in the frame of its emitter's entity and lives in that frame for its whole life. It moves as a loose body in that frame moves, and the frame's medium lifts it and drags it (see "Media"). It touches nothing, and gameplay and physics read nothing of it. It dies at the end of its life, or with its frame when no ancestor of the frame survives. The simulation owns the puffs and steps them in every tick, in doubles, so a headless run and the window see the same puffs. (User) The snapshot carries them to the renderer (see "Puffs in the snapshot" and "Puffs in the renderer").

The emitter and the look #

A component writes the emitter of its own entity into its render row, self.Render.Emitter, as it writes its light. PuffEmitter (src/Tertius.Simulation/Entities/PuffEmitter.cs) holds four values:

Field Unit and range Meaning
Rate puffs per s, above 0 How many puffs the emitter makes in each second
Speed m/s, 0 or more A newborn's ejection speed relative to the emitter
Spread rad, 0 to π The half-angle of the cone of ejection directions about the entity's local −Z
Look A PuffLook: what the emitter's puffs share

The emitter has no offset. Puffs leave the origin of the entity along its local −Z, as a spot light points. An entity that needs a nozzle away from its origin attaches a part there. PuffEmitter.CanMakePuffs is true when the rate is finite and above 0, the speed is finite and 0 or more, the spread is from 0 to π, and the look is valid. The default emitter makes no puffs. An emitter that is not valid is left out without an error, as a light is.

PuffLook is a record struct in Core (src/Tertius.Core/Seams/PuffSeam.cs), because the look table and the render snapshot hold it with the same fields. It holds the Life in s, the StartRadius and EndRadius in m, the TemperatureRatio, which is the temperature of the puff's own gas over the medium's, and the Shading. PuffShading is a custom union, as HemisphereUp is, with the cases GlowPuffs(Radiance) and LitPuffs(Albedo). A look is valid when every number is finite, the life and the temperature ratio are above 0, both radii are 0 or more, and the shading holds a case whose color channels are finite and 0 or more.

The store #

PuffTable (src/Tertius.Simulation/Internal/Puffs/PuffTable.cs) is internal to the simulation, and the World owns it. Puffs are not entities: they have no handle, no components, no messages, no render row and no body. Only the sync point and the destroy path write the table, each through one method that the World calls once: Step at step 2b, MoveOutOfDyingFrames in the destroy path, and AddNewborns at step 7c (see "Threads and the tick").

A row, Puff, is 56 bytes:

Field Meaning
Place The puff's place at the end of the last step, in m, in its frame's axes
Chord Its move over the last step divided by the step, in m/s, in its frame's axes
Age Its age at the end of the last step, in s, from 0 up to its life
Look The index of its look in the look table

The chord stands for the place of the step before. The two-turn update needs the last two places, and the place and the chord hold the same information. But far from a frame's origin, the chord keeps the small change that gravity adds in one step, which a second place would round away. In the sun's frame at 1 AU, the two-place form drifts 0.30 m in 10 s, and the chord form drifts 4.2e-5 m.

The rows of one frame lie together in one range, PuffRange(Frame, Start, Count), so no row holds a frame. A range names a semantic frame, never a numeric one, because a puff takes its emitter's row frame. PuffTable.View gives a PuffView of the live rows, the ranges, the look table and the length of the step, for the render snapshot.

The order of the rows is the order of the step, of the snapshot and of the draws, so it is the same in every run:

  • Each frame keeps the place of its first range in the list of ranges. A frame that gets its first puffs takes a new range at the end, in the order of its first newborn.
  • Inside a range, the survivors keep their order, and the newborns come last, in slot order and in each emitter's order of birth. The puffs that a frame's death moves in follow the heir's own puffs when the heir's range came first in the list, and come before them otherwise.
  • A death removes rows, and the rest keep their order. A range that empties leaves the list.

The step only removes rows, so it compacts the survivors toward row 0 in place. Births and a frame's death add rows, so the table rebuilds. It writes the rows into a second array, spare, in the new order, and swaps the two. Then each frame has one range again. The newborns of a step wait in a third array until the rebuild at the end of the same step 7c.

The looks #

PuffLooks (src/Tertius.Simulation/Internal/Puffs/PuffLooks.cs) holds each look that live puffs use once. It keeps a count of the rows that name each entry, a dictionary from each look in use to its index, and a stack of free indices. Emitters with equal looks share one entry. A puff keeps the look of its birth when its emitter changes its look, and the newborns take the entry of the new look. An entry is freed with its last puff, and the next new look takes the index that was freed last. The order of births and deaths alone gives the indices, so they are the same in every run. The dictionary's hash codes change from process to process, but they only find entries. A component that writes a new look in every tick makes one entry per tick while its puffs live.

The rule of motion #

Puff.Stepped moves a puff over the step by its frame's LooseBodyStep (see "Media"), which the step reads once per range:

  • The lift share β is 1 + w (θ − 1) where the frame's medium fills the puff's place, and 0 elsewhere. Here w = 1 − age / life is the share of the puff's own gas, by mass, and θ is the look's temperature ratio. So the puff cools from θ toward the medium's temperature as it mixes. At the end of its life it neither rises nor sinks.
  • The drag is the medium's drag times β.
  • The new chord is LooseBodyStep.ChordAhead(place, chord, β, drag, dt). The place moves by the new chord times the step, and the age grows by the World's float step.
  • A puff whose new age reaches its look's life dies.

In vacuum β is 0, so the rule is the expression of physics for a free body, to the bit. A puff beside a coasting ship stays beside it, and a puff in a spinning station curves as an inertial line does in the turning axes. In air, hot exhaust rises and cold gas sinks, and both come to rest in the frame. The lift share is a temperature ratio, not a density of the puff's own gas. (User) So a puff's lift and drag are the same at every height of a planet medium. The cost is that they stop at once where the frame's medium ends. In the demo, that is at the bubble of earth-surface, at 50 km.

Births #

The emitters of a step are the live entities whose emitter bit is set. The entity table keeps one bit per slot, which is set exactly when the slot is live and its front render row holds an emitter that can make puffs. The copy of a render row at step 8 sets or clears it, and the release of a slot clears it. A spawn writes its front render row itself, so its slot waits in a list until the next copy sets its bit. So an entity that step 6 made live makes no puffs at step 7c of the same sync point, because it was not in the step. An entity that dies in a tick makes no puffs for the last step of its life.

Each entity has a birth phase in [0, 1), a column of the entity table that the release of a slot clears. In a step of length dt, an emitter at rate r with phase p makes n = floor(p + r · dt) puffs, and keeps p + r · dt − n as its new phase. Puff j, from 1 to n, is born at the share s = (j − p) / (r · dt) of the step, so its age at the end of the step is (1 − s) · dt. So the births lie evenly in time at the emitter's rate, across tick boundaries.

A newborn starts from its emitter's place x and velocity v at the end of the step, from the back transform row. Its ejection velocity u has the emitter's speed, along a direction in the cone of the spread. The direction comes from a seed that folds the entity handle, the tick and j through a SplitMix64 finalizer, so it depends on nothing else. With τ the newborn's age and k its drag at its birth, its instant velocity is (v + u) / (1 + k τ). Its place is x − v τ, where the emitter was at the birth, plus τ times the instant velocity. Its chord is that velocity less half a step of its push and its drag. In a turning frame, the frame's turn of the step then makes it a chord (ChordBehind). So in a medium the puffs of a moving emitter lie along its path instead of in beads at each step. A newborn whose age would reach its look's life is not made.

The cap #

When the survivors and the newborns pass the capacity, step 7c drops the oldest puffs first. If the newborns alone reach the capacity, it keeps only the last newborns up to the capacity, in slot order, and drops every survivor. Only the newborns that live at the end of the step count: a look that lives less than a step makes newborns that are already dead, and those never take the place of one that lives. It skips the other newborns without computing them, but every phase still gathers the whole rate. Otherwise it drops as many of the oldest survivors as the excess, and among equal ages the first in the rows. Every step adds the same float step to every age, so the order of the ages is the order of the births. The cap finds the age at the edge by a sort of the ages in a scratch array, only when it binds. WorldOptions.PuffCapacity is the capacity (see "Switches").

Invariants and determinism #

PuffTable.AssertInvariants, which the World's own check calls, holds these:

  • The live puffs number at most the capacity.
  • Every range names a live semantic frame, no two ranges name the same frame, the ranges lie packed from row 0, and no range is empty.
  • Every row's place and chord are finite, its age is 0 or more and below its look's life, and its look index names an entry in use.
  • Each look's count equals the number of rows that name it. The dictionary holds exactly the entries in use, and the free stack holds exactly the other indices.
  • Every look in use is valid, no newborn waits outside step 7c, and every birth phase is in [0, 1).

The entity table's own check holds the emitter bit of each slot to its front render row. A range in a frame that is not live comes only from a bug, because the destroy path moves every puff of a dying frame. A Debug build fails a contract. A Release build drops the range and records the error in World.Errors.

The same run gives the same puffs, bit for bit, in Debug and in Release. The table runs at fixed places in the sync point, in a fixed order. The seeds depend only on the entity, the tick and the index of the birth, and the look indices depend only on the order of births and deaths. The drag is a division and the lift share a multiply and an add, so the step calls no Math.Exp. A test pins a hash of the rows, the ranges and the looks after 300 ticks, and tools/test-all.sh runs it in both builds. A World with puffs off reaches the same state of every entity as a World with puffs on, bit for bit.

Costs #

The sync profile has a lap for each of the two steps, "2 (end) puffs: PuffTable.Step" and "7 (end) puffs: PuffTable.AddNewborns". PuffTableBenchmarks measures the table on one thread (ter-4zt6, Release, on an Apple M1 Max). The 1-minute load of the machine was between 12.7 and 14.6, so these numbers are an upper bound.

Puffs Step in earth Step in earth-surface Step in a spinning station
100 2.0 µs 5.4 µs 4.4 µs
1,024 20.1 µs 55.4 µs 44.3 µs
4,096 80.4 µs 224 µs 178 µs

earth is a root frame with Earth's point field and no medium. earth-surface turns with Earth and holds its planet medium. So the step costs about 20 ns per puff in a frame that does not turn, and 43 to 55 ns in a turning frame. 64 births and the rebuild of 4,096 live puffs add about 8.5 µs in earth: about 45 ns per birth and 1.4 ns per live puff for the rebuild. No step and no birth allocates after warm-up, by test. rows, spare and the newborns' array grow by doubling up to the capacity. At 4,096 live puffs, rows and spare take 448 KiB together, and the newborns' array at most 224 KiB more. The render row's 44 bytes of emitter changed the time of RenderSnapshotBenchmarks by less than 1 percent in every scene.

From the table to the screen #

A puff passes through four stages on its way to the screen. Each stage reads what the one before it wrote, and none writes back:

  1. Step 2b of the sync point steps the old puffs in the table, by the loose-body step of each range's frame. The destroy path of step 5 moves the puffs of each dying frame into its heir. Step 7c adds the newborns of the step that just ended and applies the cap.
  2. Step 11 builds the render snapshot. World.BuildRenderSnapshot passes the table's view, PuffTable.View, to RenderSnapshotBuilder.Build(snapshot, view, puffs). After the instances, the sun and the frame outlines, Build places the puffs with the frame poses of the same build (see "Puffs in the snapshot"). So the snapshot shows the puffs of the same step as its instances. A build without a camera returns before it places anything, so its snapshot has no puffs.
  3. The triple buffer publishes the snapshot to the render thread, as for every other part of it.
  4. The batcher interpolates each puff by the frame's alpha, culls it and writes its GpuPuff, and the transparent pass draws it (see "Puffs in the renderer").

The table holds doubles in the frame of each puff. The snapshot holds floats relative to the render origin, in the axes of the camera's frame. The renderer knows no frames. So a time-lapse film, a skipped snapshot or a snapshot that the window draws twice changes no puff, because the simulation stepped every puff in every step before any of them.

WorldHash.OfPuffs(snapshot) (tests/Tertius.Testing/WorldHash.cs) hashes the puffs of a snapshot: each puff's position, previous position, age and look index in order, the age step, and each field of each look. WorldHash.Of leaves the puffs out, so a test compares a World with puffs and one without through it. PuffSnapshotTests checks that the snapshot of a World shows each row of the table where the frame tree places it relative to the camera, within 0.1 mm, with a jet in a hull's frame and the camera in space. It also pins a hash of the snapshot's puffs after 300 ticks of a busy scene whose cap binds, and tools/test-all.sh runs it in Debug and in Release.

The demo's scene puff-yard (src/Tertius.Demo/DemoScenes.Puffs.cs) and its script scripts/puff-yard.json show glow puffs through the real renderer. In air under gravity, an orange flame rises in a column, a blue jet bends up from its slant, faint green gas that is colder than the air stops about a meter up and spreads into a low dome, and a tossed box leaves an arc of puffs that marks its path. PuffScriptCostMeasurement (tests/Tertius.Host.Tests, explicit) runs that script headless at 1280 × 720 with its four captures, with the default capacity and with a capacity of 0 (ter-4zt6, Release, on an Apple M1 Max, at a 1-minute load of 3.6). The median of 7 runs was 347.3 ms with puffs, about 760 of them alive at once, and 337.3 ms without. So the puffs of that scene cost about 33 µs per tick of 300, from the table to the screen.

The overlay pass #

The overlay pass, OutlinePass, draws the frame outlines of the snapshot. It is the one pass of the stage Overlay of the FrameGraph, after tone mapping and before the readback. The renderer always builds it. With no outline in the snapshot, it writes no record and records no command.

On the CPU, in its Prepare, the pass switches on the case of each FrameOutline, as SceneBatcher does for each RenderLight. It writes one GpuOutline of 48 bytes for the case: the center relative to the interpolated eye, the rotation, the radii, the color of the kind of frame, and a flag for a sphere. A contents sphere has no axes of its own, so its rings lie in the axes of the camera's frame. The pass leaves out an outline whose outline sphere has a radius of 0 or less, or is outside the view frustum. The frustum starts at the near plane, 5 cm ahead, so an outline round the camera stays, unless all of it ahead of the eye is nearer than that. The records keep the order of the snapshot. They go into a storage buffer of the frame set, which grows on demand, as the instance buffer does.

On the GPU, the pass makes one instanced draw of 1,536 vertices for each record. The vertex shader makes four rings of 64 segments from the vertex index:

  • The three ellipses in the principal planes of the frame.
  • For a sphere seen from outside, its rim as seen from the eye: the circle where the lines of view from the eye touch the sphere. A sphere is the contents sphere of a numeric frame, or a semantic bubble whose three radii are equal, by the rule of FrameBounds.IsSphere, such as the bubble of Earth in the demo. The pass sets the sphere flag of the record for both. An ellipsoid, and a sphere round the eye, have no rim, and the 384 vertices of that ring collapse to one point.

Each segment is a quad 2 pixels wide on the screen, made of two triangles. A line list would draw lines 1 pixel wide on MoltenVK, because Metal has no wide lines. The two quads that meet at a corner of a ring compute its two vertices from the same three points of the ring. So they share one edge exactly, mitered across the bend, and no pixel draws twice there. A miter is at most 2 half widths long, so a ring seen almost edge-on ends at its tip without a spike. The vertex shader cuts each segment at the near plane, 5 cm ahead of the eye, as the geometry passes cut a triangle. So a camera inside a bubble sees the parts of its rings in front of it.

The fragment shader compares its distance from the eye with the distance target of the G-buffer. A fragment behind geometry draws at 30 % alpha. A fragment in front of geometry or against the sky draws at full alpha. The pass blends into the output, which it loads. The headless output has an sRGB format, so the hardware applies the sRGB curve, and the pass blends in linear light. On an output that stores linear values, the shader applies the curve itself, as the tone mapper does. There the hardware blends a dimmed ring on encoded values, not in linear light, so a ring behind geometry looks a little different in the window than in a capture.

Each kind of frame has one fixed color, from the frame bubble colors of the editor palette (docs/plans/editor.md). The engine draws no text, so a title card of the video gives the legend.

Case Color
SemanticOutline #7AA7FA, blue
FallingOutline #E58FB4, pink
RestingOutline #4FC1B6, teal
NestingOutline #B58CF0, purple
FollowingOutline #C9B458, gold

RenderStats.Outlines counts the outlines that a frame drew.

OutlineRenderBenchmarks measures the wall time of one headless Render at 1280 × 720 (ter-p51r, Release, validation off). The renderer keeps one frame in flight, so each call waits for the frame before it, and one call takes the CPU and the GPU time of a frame. The scene has a ground and 100 boxes. With no outline, a frame takes 1.26 to 1.41 ms without the pass and 1.28 to 1.43 ms with it, over several runs. So the outlines cost nothing while they are off. With 10, 100 and 1,000 outlines, a frame takes 1.64, 1.82 and 2.50 ms. So the first 10 outlines add about 0.3 ms, and each outline after them adds about 0.9 µs. As soon as the pass has an outline, it begins a render pass that loads and stores the whole output image, which is the probable cause of the step. No frame allocates. The GPU time of the pass alone waits for the GPU timer of ticket ter-yiii.

Instances and draws #

All instances of a rendered frame go into one instance array, shadow casters and impostor billboards included. The array goes into one storage buffer for each frame in flight. Textures live in one bindless array. The batcher sorts the opaque instances by depth range and mesh. It writes one indirect command for each run of instances with the same range and mesh. It sorts the transparent instances and the puffs together by range and then back to front, so a transparent command usually holds one object. At equal keys an instance comes first, and puffs keep the order of the snapshot, so the order is fixed. Each depth range and each shadow cascade issue one vkCmdDrawIndexedIndirect over their part of the commands. The transparent pass draws each range run by run, as the next paragraph says. MoltenVK does not support drawIndirectCount, so the CPU sets the draw count.

The batcher cuts the sorted transparent list of each range into runs. A run is a TransparentRun, a generated union of two cases: MeshCommands(Commands), a stretch of transparent instances, with one indirect command per stretch of equal mesh, and PuffSprites(First, Count), a stretch of puffs. PreparedScene.Transparent holds the ordered list of runs of each range. The transparent pass pushes the matrix of each range once, then draws its runs in order: a MeshCommands run with the transparent pipeline and one vkCmdDrawIndexedIndirect over its commands, and a PuffSprites run with the puff pipeline and one vkCmdDraw over its puffs, with 6 vertices and one instance for each puff. Both pipelines have the scene layout, so the sets and the push constants stay bound between runs. Puffs between two panes of the same mesh split the panes into two commands.

Depth ranges #

There are three depth ranges, drawn from far to near into the same G-buffer. Each range is a slab of view depth that reaches 1% past its nominal boundaries in the table below, so neighbor slabs overlap. The batcher puts an opaque instance in each slab that its bounding sphere overlaps, from near to far. It stops at the first slab that holds the far side of the sphere. So an object that straddles a boundary draws twice, and an object that fits in one slab draws once. A transparent instance or a puff draws once, in the range that holds its center, with depth clamping on.

Range Nominal view depth
Far 2,000 km to infinity (infinite reversed-Z)
Middle 2 km to 2,000 km
Near 5 cm to 2 km

Each range uses an infinite reversed-Z projection. Before a range draws, the depth buffer is cleared to the depth of the range's far boundary, so the GREATER test rejects anything beyond it. A finite far plane is simpler, but on MoltenVK a triangle clipped against both the near and the far plane gets the wrong depth.

The G-buffer stores the distance from the eye in an R32F target, so one lighting pass serves all three ranges.

Shading #

The lighting model is a physically based BRDF with the metallic-roughness parameters of glTF. A BRDF (bidirectional reflectance distribution function) says how much of the light that arrives from one direction a surface sends toward another. The design is docs/plans/pbr-brdf.md, which holds the reasons and the formulas. Lighting is in linear color space, into an HDR target, with tone mapping at the end. Lighting works in world axes centered on the camera. At one surface point, after the maps, the inputs are the base color b, the metallic m, the roughness r₀ and the reflectance ρ. The derived values are:

  • The roughness r = clamp(r₀, 0.045, 1). The floor is applied in one place in each copy of the BRDF: flooredRoughness in brdf.glsl and the constructor of GgxLobe in C#. The G-buffer stores r₀, not r. (User)
  • α = r².
  • The diffuse color c_diff = b · (1 − m). A metal has no diffuse color.
  • The Fresnel F0 = (1 − m) · 0.16 ρ² + m · b. A reflectance of 0.5 gives 0.04. (User)

The BRDF is f(l, v) = c_diff / π + D · Vis · F · c, with these terms:

  • Diffuse: Lambert. (User)
  • D: the GGX distribution of microfacet normals. (User)
  • Vis: the height-correlated Smith visibility term of Heitz 2014, which includes the 1 / (4 (n·l) (n·v)) factor. (User)
  • F: Schlick's Fresnel term. It reaches 1 at a grazing angle for every F0. (User)
  • c: the energy compensation, c = 1 + F0 · (1 / E₁ − 1) per channel, after Filament. E₁ is the lobe albedo of the DFG table below. A white metal then loses no light at any angle or roughness, and c is 1 for a non-metal with F0 = 0. (User)

The diffuse and the specular parts add with no coupling, so there is no factor (1 − F) on the diffuse part. The direct lights and the ambient light share one model. The sun and each point and spot light add f(l, v) · E · (n·l), where E is the light that sunReaches or lightReaches gives. The sun still adds nothing where the geometric normal faces away from it.

The renderer uses two tables, computed by the C# copy of the BRDF (BrdfTables) and uploaded in the first frame. (User)

Table Size Format Across Down Holds
DFG 32 × 32 R16G16_SFLOAT μ = n·v r R the Schlick part S, G the lobe albedo E₁
Lobe-sky 64 × 32 R16_SFLOAT (cos + 1) / 2, with cos = d·up r The share of the GGX lobe that points above the horizon of the up

A table has the texel i hold the value i / (size − 1) on each axis, so the ends are exact. The shaders read a table at the coordinate ((size − 1) · x + 0.5) / size, BrdfTables.TexelCoordinate in C# and tableCoordinate in brdf.glsl, with linear filtering and clamping at the edge. BrdfTables.Compute integrates the GGX lobe with SamplesPerTexel = 16,384 Hammersley samples per texel, in doubles. Each texel sums its samples in the order of the sample index and writes only its own slot, so the tables are the same bits on every run, and the rows run in parallel. BrdfTables.Shared computes them once per process, on first use, because a test process makes many renderers.

The hemisphere ambient light has a specular part:

ambient = c_diff · mix(ground, sky, s_n) + E(μ, r) · c(μ, r) · mix(ground, sky, s_l)

where s_n is the normal's sky share, as before, and E(μ, r) = S + F0 · (E₁ − S) is the specular albedo from the DFG table. s_l is the lobe's sky share, read from the lobe-sky table at d·up, where d is the specular dominant direction of Lagarde and de Rousiers 2014. ambientLight in lighting.glsl computes the sum. (User) Only the G-buffer pass knows the up of the instance, so it computes s_l and stores it in the alpha of the specular target. The transparent pass and the impostor capture compute both shares themselves. With the even mix, both shares are one half. Later screen-space reflections fade their off-screen rays to this specular part.

surfaceRadiance in lighting.glsl returns min(ambient + sun + lights + emission, 65,504) per channel. The lighting pass, the transparent pass and the impostor capture write its result. 65,504 is the largest finite half float. GGX can reflect far more than that at the mirror image of a lamp, and the HDR target would store infinity, which the tone curve turns into NaN. (User)

The G-buffer has these targets:

Target Format Contents
Base color RGBA8 sRGB The base color b. Alpha 0 marks an unlit impostor pixel.
Normals RGBA16 SNORM The shading normal and the geometric normal, both octahedral-encoded, in world axes. Shadow biasing uses the geometric normal.
Specular RGBA8 UNORM R metallic, G roughness r₀, B reflectance, A the lobe's sky share s_l. An impostor billboard writes zero.
Emissive RGBA16F Emitted light. For a lit pixel, alpha is the share of the hemisphere ambient light that comes from the sky. Impostors write their baked color here.
Distance R32F Distance from the eye. Zero is sky.

The material is a MaterialDesc with the fields BaseColor (linear RGBA, alpha is opacity for transparent objects), Roughness (required), Metallic (default 0), Reflectance (default 0.5), Emissive, BaseColorMap, NormalMap and MetallicRoughnessMap. Roughness is required because no roughness is neutral. MaterialDesc.Color(color, roughness) makes a plain material. The default material, which MaterialId.None resolves to, is a white non-metal of roughness 0.5. The metallic-roughness map has glTF's layout: roughness in G, metallic in B, and each channel multiplies its factor. The reflectance has no map. MaterialStore.ToGpu clamps the factors and writes a GpuMaterial of 64 bytes in std430: 52 bytes of fields (baseColor, emissive, metallic, roughness, reflectance and the three map indices) padded to the alignment of 16. A map left at its default resolves to one of the built-in textures of TextureStore: White (slot 0, sRGB), FlatNormal (slot 1) and DefaultMetallicRoughness (slot 2, all white, so both factors stay as they are). A metallic-roughness map must be created with TextureData.Srgb false. The tables go into the frame set (set 0), because the impostor capture binds only sets 0 and 1: binding 8 is the DFG table, binding 9 the lobe-sky table, and binding 10 an immutable sampler with linear filtering, clamping and one level.

Transparent objects use one forward pass after the lighting pass, sorted back to front across ranges. They test occlusion against the distance target, not the depth buffer.

Dynamic point and spot lights are shaded in the deferred lighting pass. The sun is a directional light.

An entity carries its light as RenderData.Light, a LightData. It is a generated union (see "Unions"). Its cases are PointLightData(Offset, Color, Intensity, Range) and SpotLightData(Offset, Color, Intensity, Range, InnerConeAngle, OuterConeAngle). The empty union holds no light, so there is no Enabled flag: an entity whose light is the default carries none, and a component that switches a light off and on keeps the light value and writes the default over it. The light sits at the pose of the entity composed with Offset, and a spot light points along the local -Z axis of that pose.

The snapshot gives each light as a RenderLight, a second custom union. Its cases are PointRenderLight(Position, Color, Intensity, Range) and SpotRenderLight(Position, Direction, Color, Intensity, Range, InnerConeAngle, OuterConeAngle). The two unions have their own case types and do not share them. The snapshot builder switches on the case of the LightData and makes the matching case of the RenderLight, with the position relative to the render origin and, for a spot light, the direction in the axes of the camera's frame. SceneBatcher switches on the case again and writes one GpuLight. It leaves out a light of either case whose range or intensity is zero or less, or whose sphere of reach is outside the frustum. GpuLight keeps its std430 layout of 64 bytes. A point light writes kind 0 and leaves its direction and cone fields zero, because the shader and the impostor lighting read them only for a spot light. A spot light writes kind 1, a unit direction (straight down when the direction is zero), the cosine of the outer angle, and the cosine of the inner angle, which is never less than the outer cosine plus 1e-4, so that the shader's smoothstep has a non-empty interval.

The sky is a procedural star field, fixed to the axes of the root frame of the camera's frame tree. The render snapshot carries the root's axes in the axes of the camera's frame, as RenderSnapshot.RootRotation, and the renderer interpolates them as it interpolates the camera. So the stars do not turn with a ship that the camera rides in, and a switch of the camera to another frame does not move them.

The hemisphere ambient light needs an up, and each instance takes it from the gravity field of its own frame, at its place. (User) Up points against a uniform field, inside the field's region, and away from the frame's origin in a point field. In a frame without gravity, every surface gets the mean of the sky and the ground color. A frame owner, such as a ship's hull, takes its own frame's field at the frame's origin. Only the field counts, not the frame's proper acceleration, so thrust never turns the up. (User) The ambient light of a surface therefore does not depend on the frame that the camera rides in, a planet sees the sky on every side, and an empty frame in space mixes evenly by itself.

The snapshot gives each instance its up as a HemisphereUp. It is a custom C# 15 union, as GravityField is. (User) Its cases are UpAlong(Direction), a direction in the axes of the camera's frame, and UpAwayFrom(Center), a point given relative to the instance, so that a planet's own center moves with it exactly. The empty union is the even mix. It takes 16 bytes, one vector and a tag, because a value holds one case. GpuInstance still packs it into one Vector4 for the shaders: the direction with w 1, the point with w 2, and zero for the even mix. The G-buffer pass stores the sky's share in the alpha of the emissive target. The up is not interpolated between ticks. The impostor manager keeps each capture's up in object space in its record of the light, so an object that turns against its up is captured again.

The up costs about 5 ns per instance in the snapshot build. On one thread, RenderSnapshotBenchmarks builds a snapshot of 20,000 visible entities in 784 µs, against 675 µs without the up. Each GPU instance grows from 64 to 80 bytes.

Cost of the BRDF #

The GPU timers (see "GPU timings") measured the switch in Release, at 1920 × 1080, with one run at a time and the 1-minute load of the machine from 6.6 to 7.4. "Before" is main with Blinn-Phong, "after" is the branch. Each build ran each script twice, and the two runs of one build agree to 0.001 to 0.003 ms on the means. The script demo-exit.json times 3,877 frames:

Pass Mean before Mean after Change of the mean p95 before p95 after
Shadow maps 0.199 ms 0.198 ms 0 0.240 ms 0.239 ms
G-buffer 0.853 ms 0.898 ms +0.045 ms 1.04 ms 1.13 ms
Lighting 1.544 ms 1.797 ms +0.254 ms 1.99 ms 2.30 ms
Transparent 0.311 ms 0.334 ms +0.023 ms 1.22 ms 1.32 ms
Tone map 0.088 ms 0.084 ms within the spread 0.133 ms 0.133 ms
Outlines 0.025 ms 0.023 ms about 0 0.04 ms 0.04 ms
Capture 0.021 ms 0.022 ms 0 0.05 ms 0.05 ms

The BRDF costs about 0.32 ms per frame, summed over the G-buffer, lighting and transparent passes. The lighting pass is the main cost: +16 % on the mean and +15 % on the p95. The new code adds no pass. The script demo-surface.json times only 5 frames per run, so its figures are noisy. Its direction is the same: the lighting pass rises by about 0.15 ms and the transparent pass by about 0.03 ms, both more than the spread between runs, and the G-buffer pass rises by about 0.12 ms, which is only about the size of the spread. The script pbr-spheres.json is new, films 20 ticks and prints no GPU timings, so it has no cost figure. The time of BrdfTables.Compute was not measured in this run. The design doc measured 130 ms with parallel rows once per process, at a load of 8.4.

The memory changes are small. The specular target grows from RG8 to RGBA8, which is 4.1 MB more at 1920 × 1080. The tables take 4 KB each. A material takes 64 bytes, not 48.

Puffs in the renderer #

The renderer draws each puff of the snapshot (see "Puffs in the snapshot") as a camera-facing sprite. It keeps no puff state, so a snapshot that the window draws twice, or one that it skips, changes nothing. For each RenderPuff, with the alpha of the frame and its look L = PuffLooks[Look], SceneBatcher computes:

  • The render age: Age − (1 − alpha) · PuffAgeStep. A puff whose render age is below 0, or at L's life or more, is not drawn. So a newborn appears at the moment of its birth in the step, and a headless capture, at alpha 1, draws each puff at its age.
  • With t the render age over the life, the radius lerp(L.StartRadius, L.EndRadius, t), and the own share 1 − t. The light of a puff falls linearly to nothing at the end of its life.
  • The position: lerp(PreviousPosition, Position, alpha) less the interpolated eye, as for an instance.
  • The color: the own share times the radiance of a GlowPuffs or the albedo of a LitPuffs.

The batcher leaves out a puff whose look index is outside PuffLooks, whose look holds no shading (a free entry of the look table holds none), whose position or radius is not finite, whose radius is 0 or less, or whose sphere is outside the frustum. It writes one GpuPuff of 32 bytes for each puff that it keeps, in the order of the runs: positionRadius, and colorKind, whose w holds the case, 0 for glow and 1 for lit, as GpuInstance packs the case of HemisphereUp. The records go into a storage buffer of the frame set, binding 7, which grows by doubling as the instance buffer does. RenderStats.Puffs counts the drawn puffs.

The puff pipeline is puff.vert and puff.frag, with no vertex input, no face culling, depth clamping on, and BlendMode.Additive. GraphicsPipelineDesc.Blend is an enum, BlendMode, with the cases None, Alpha and Additive, because no case carries a field. Additive adds the source color to the HDR target, with the factors One and One, and keeps the target's alpha. The vertex shader builds a square from the vertex index, centered on the puff, across the line of sight from the eye, with the radius as half its side. The fragment shader adds

color × (1 − ρ²)² × clamp((D − d) / r, 0, 1) × clamp(d / r, 0, 1)

where ρ is the distance of the fragment's point from the puff's center over the radius r, d is the distance of the point from the eye, and D is the G-buffer distance at the pixel. Sky, with D = 0, counts as no surface, so its factor is 1. So a puff fades within one radius of anything opaque, and the eye counts as an opaque point at distance 0. A puff behind a wall draws nothing, a puff that cuts into a wall or a floor shows no hard line, and a puff around the eye fades. The fade is one radius along the line of sight, so on a floor seen at a low angle it is shorter on the screen: a floor seen at 17° fades a puff of 1.2 m over 5 to 8 px at 10 m. Additive blending commutes up to the rounding of the target, so the puffs of one run need no order among themselves, and glass dims only the puffs behind it.

A glow puff emits its color and takes no light. A lit puff is lit once, at its center, in the vertex shader (docs/plans/particles.md, "Lit puffs"). It has no surface and no normal. It scatters the light that reaches its center equally in every direction, which is the mean over every facing of a matte surface lit by the same lights. That surface is the Lambert part of the BRDF, so the formula did not change when the renderer switched to the PBR BRDF. The mean of the cosine over every facing is 1/4, and the mean of the sky's share of the ambient light is one half. So scatteredLight in lighting.glsl gives

L = color × (mean ambient + (E_sun · V_sun + Σ E_light) / (4π))

where color is the own share times the albedo, mean ambient is the mean of the sky and the ground color, E_sun is the sun's radiance, V_sun is the sun's visibility at the center, and E_light is the radiance of a point or spot light times its distance attenuation and its cone at the center. V_sun is the far shadows times the cascades, as for a surface, with the sun's direction as the normal, which needs no normal offset and no receiver-plane slope. The surfaces and the puffs share the two functions that say how much of a light reaches a point: sunReaches for the sun and lightReaches for a point or spot light. The cascades sample with zero gradients, which works in a vertex shader. So the G-buffer set lets the vertex stage read the cascades and their sampler (bindings 5 and 6), and the transparent pass prepares the cascades with the image use SampledVertexFragment, which makes the shadow pass's writes visible to the vertex shader too. Each of the six vertices of a lit puff computes the same light. LitPuffRenderTests checks the model: each lit puff must look exactly as a glow puff whose radiance is the light that the formula gives it, for the ambient light, the sun, a point light, a spot light inside and outside its cone, the shadow of a box in the cascades, a far shadow and a puff halfway through its life. Puffs cast no shadow, never become impostors, and get no bloom: glow puffs saturate through the ACES curve until the bloom ticket (ter-63wr).

PuffRenderBenchmarks measures one headless Render at 1280 × 720, as OutlineRenderBenchmarks does (ter-4zt6, Release, validation off), with the ground and the 100 boxes of that scene. Small puffs have radii from 0.2 m to 1 m, from 5 m to 100 m ahead. Puffs that fill the screen have a radius of 8 m, 3 m to 4 m ahead, so each one covers every pixel. The 1-minute load of the machine was 7.7 when the run began and 6.0 when it ended.

Puffs Small Filling the screen
0 1.28 ms 1.29 ms
100 1.33 ms 3.5 ms
1,000 1.46 ms 20.0 ms
4,096 1.99 ms 79 ms

Before the renderer drew puffs, the same scene took 1.28 to 1.38 ms, at a load of 8.6. So a frame with no puff costs what it cost before. Small puffs cost about 0.17 µs each. A puff that fills the screen costs about 19 µs, the fill of 0.9 million pixels, so the cost of puffs is the pixels that they cover. No frame allocates. The GPU time of the pass alone waits for the GPU timer of ticket ter-yiii.

LitPuffRenderBenchmarks measures the same frame with the small puffs and eight point and spot lights of range 30 m among the near puffs, with lit puffs against glow puffs (ter-4zt6, Release, validation off, 20 iterations). The 1-minute load of the machine was 2.9 when the run began and 7.7 when it ended.

Puffs Glow Lit
0 1.72 ms 1.80 ms
100 1.67 ms 1.74 ms
1,000 1.79 ms 2.07 ms
4,096 2.16 ms 2.30 ms

With no puff, the two cases are the same frame, and they differ by 0.08 ms, about the noise of the run. At 1,000 puffs the lighting adds 0.28 ms, about 0.3 µs per lit puff for its six vertices. At 4,096 the lit run's spread was 0.34 ms, so its difference of 0.15 ms is within the noise. No frame allocates.

Shadows #

Shadow casters are selected by distance from the camera, not by frame, so an object shadows objects in any frame. Every caster has the CastsShadow flag, and the sun has none. The renderer lets an instance cast a shadow without the Visible flag. But the snapshot builder emits only visible instances, so today an entity without Visible casts no shadow. A caster casts two kinds of shadow:

  • Map shadows. Cascaded shadow maps for the sun cover receivers in the first 2 km of view depth, with four cascades and blending at the seams. They draw every caster whose bounding sphere comes within 2 km of the camera, however big it is, for example a ground plane or a planet under the camera. Map shadows have the caster's real shape.
  • Far shadows. Every caster is also a sphere of its bounding radius, tested analytically in the lighting shader against the sun's disc, so the edge is soft. A sphere never shadows a point inside it, so a caster never shadows its own surface, and a planet's ground needs no such shadow: it faces away from the sun at night. A caster that the maps draw counts as a sphere only beyond the maps' reach, faded in over the last cascade's blend, so no receiver gets both shadows from one caster. A long or flat caster shadows as a rough sphere there.

A far shadow counts only while the sun lights the scene. It must also be big enough to notice. A caster beyond the maps must cover a pixel, or a fifth of the sun's angular radius, seen from the camera. A caster in the maps must hide 4% of the sun, seen from where its far receivers start. They start 2 km from the camera. So the distance is 2 km minus the distance of the caster from the camera, and at least the radius of the caster. A 50 m ship 500 m away qualifies, and a crate or a person does not. So the cost per pixel grows only with the shadows that can show. The sun is still the one light with shadows. Making every far light work the same way belongs to the point and spot light shadows epic, which is planned (ticket ter-s9cv, docs/plans/local-light-shadows.md).

The texel grid of each cascade snaps to a world-fixed anchor: the origin of the camera's frame. As a result, shadows do not shimmer as the camera moves. On MoltenVK, a steep caster clipped at the edge of the shadow map gets a flat, wrong depth plane. To avoid it, each cascade box is 16 times deeper than it is wide, with a D32 shadow map. When the device cannot filter D32, the map uses D16, which has less precision to spare. The box is then 4 times deeper than it is wide.

Impostors #

  • The projected height h of an object is the diameter in pixels of the image of its bounding sphere at the center of the screen. Below 200 px, an object becomes an impostor. Above 300 px, it returns to geometry.
  • An instance with the NoImpostor, Celestial or Transparent flag never becomes an impostor. Neither does an instance with a bounding radius of 0, or with the eye closer than 1.05 bounding radii to its center.
  • The impostor is captured on demand, into a 512 px tile of the impostor atlas, which has RendererOptions.ImpostorTiles tiles. A first capture takes a free tile. If none is free, it takes the least recently used tile whose object is out of view or back to geometry. Without such a tile, the first capture waits. Until it has a capture, an impostor draws as geometry.
  • A capture records the view direction, the sun direction, the light that reaches the object and the scale, in object space. It also records r / d, the bounding radius over the distance of the eye. The error of a capture is the largest of these changes since the capture:
    • The angle between the current and the captured view direction.
    • The change of r / d, the parallax between the front and the back of the sphere.
    • The angle between the current and the captured sun direction.
    • The relative change of the light that reaches the object. That light counts the radiance of the sun times its visibility past the far shadows. It also counts both ambient colors and the hemisphere up in object space. It also counts the irradiance of the point and spot lights, with its direction.
    • How far the scale has become non-uniform.
  • If error × h is more than 2, about one pixel, the capture is stale. For the view direction, that is a change of more than 2 / h radians.
  • Each rendered frame makes the 4 captures of highest priority. They come from two binary heaps, one of recaptures and one of first captures, merged by priority. A recapture of an impostor that the last rendered frame drew has the priority error × h. That impostor draws its stale capture until the recapture. Any other capture has the priority π × h, the error of the worst view. Its object draws as geometry until the capture.
  • A capture uses a perspective camera at the real eye position, fitted to the bounding sphere. An orthographic capture looked wrong at the switch distance.
  • A capture bakes the sun, the ambient light with its specular part, far shadows and point and spot lights, but not shadow-map shadows.
  • A billboard writes its per-pixel depth, so impostors intersect the ground like the real object.
  • The renderer keys impostor state on the entity index, the render part, the mesh and the resolved material slot (ImpostorKey). So a part that changes its mesh or its material gets a new key, and it never draws an old capture.

Switches #

RendererOptions holds the switches of the renderer.

Option Default Effect
Validation Off The layer VK_LAYER_KHRONOS_validation, with synchronization checks. It costs CPU time.
FramesInFlight 2 How many frames the CPU records while the GPU works on earlier ones, from 1 to 4.
VSync On FIFO presentation. A headless renderer ignores it.
Width, Height 0 The output size in pixels. A headless renderer needs both. For a window, 0 means the framebuffer size.
ShadowMapSize 2048 The side of each of the four shadow cascades, in texels. The maps take size² × 16 bytes, 64 MB at the default.
Impostors On Draws small objects as impostors.
ImpostorTiles 64 The number of 512 px tiles in the impostor atlas. Each tile takes about 3.3 MB, so the default takes about 213 MB.

The frame outlines are a view setting, World.ShowFrameOutlines, not a renderer switch, because a run turns them on and off between captures, and the snapshot builder cannot see the options of the renderer. The renderer always builds the overlay pass, and with no outline in the snapshot the pass records nothing.

The BRDF has no switch back to the earlier Blinn-Phong model, because the material model changes with it. (User)

The puffs have one switch, WorldOptions.PuffCapacity: the most puffs that live at once, 4,096 by default. A capacity of 0 turns puffs off. No emitter makes a puff, the snapshot holds none, and the renderer draws none. It is a world option, not a renderer switch, because the simulation owns the puffs and steps them in every tick, also in a headless rehearsal without a renderer. A second switch beside it could disagree with it. A negative capacity throws ArgumentOutOfRangeException when the World is made.

A switch that makes rendering deterministic for snapshot tests is planned, not built (ticket ter-rtxu, docs/plans/renderer-snapshot-tests.md).

Snapshot tests #

A snapshot test renders a scene headlessly and compares the image with its approved reference image through FLIP, a perceptual image difference from NVIDIA (Andersson et al. 2020, in its form for display-encoded images). FLIP gives each pixel a value from 0 to 1: how much the pixel seems to change when a viewer flips between the two images. It blurs both images as the eye does at a given viewing distance, measures the color difference of the blurred images in CIELAB, and makes that difference count more where an edge or a point changed. The tests use the paper's default viewer, who sees 67 pixels in each degree of view. Flip in tests/Tertius.Rendering.Tests is a C# port, written from the paper, so the project needs no native build and no package for it. It uses no code from NVIDIA's reference implementation. Tests on hand-made images pin its values, each derived by hand from the paper: black against white, green against blue, two close grays, a checker of one-pixel squares at two viewing distances, a step edge and a line.

ReferenceImages.Verify renders a scene with the headless capture and checks the image. The reference image of a test is tests/Tertius.Rendering.Tests/ReferenceImages/<name>.png.

  • The check fails when any pixel's FLIP value is above the tolerance of the test. The default tolerance is 0.1, and a test can name its own. In a flat area, 0.1 is the value of a CIELAB lightness step of about 2.2, about one just noticeable difference. So a change of one level in every pixel passes, and one changed pixel that anyone can see fails. A rule on the mean FLIP value would let a small object disappear without a failure, because the object hardly moves the mean.
  • When the check fails, it writes the actual image as <name>.actual.png and the FLIP heat map as <name>.flip.png, beside the reference. Git ignores both files, and a passing check removes them. The heat map is black where nothing changed, and it grows brighter, through purple, red and orange to pale yellow, as the change grows.
  • With the environment variable TERTIUS_APPROVE_SNAPSHOTS set to 1, the check makes the image the reference and passes. A snapshot proves only that nothing changed. So the first approval of each reference image is a real review against the description of its scene.
  • The references come from one machine with MoltenVK. On that machine, two headless renders of one scene give the same bytes. Tolerance across GPUs is out of scope.

The first reference image is frame-outlines.png, of the overlay pass (OutlineRenderTests). It shows a turned box in its semantic bubble, a falling frame's sphere partly behind a wall, and one sphere of each other kind of numeric frame, over a gray ground. Four reference images of glow puffs come from PuffRenderTests, and one of lit puffs from LitPuffRenderTests (ter-4zt6). Each test also renders its scene without its puffs and measures what the puffs add.

Image Scene
puffs-glow.png A trail of nine glow puffs that rises from a dark ground into the sky, young and small at one end, old, large and faint at the other. No pixel gets darker.
puffs-glass.png Puffs behind and in front of a pane of alpha 0.2. The puff behind the pane is dimmed, and the one in front of it is not.
puffs-soft.png A puff that cuts into a wall and a floor, and a puff around the eye. No hard line, and the part of a puff behind the floor draws nothing.
puffs-far.png A trail 2.5 km away, in the middle range, at its true size: its first puff of 20 m covers 2.5 px, and nothing shows beyond its radius.
puffs-lit.png Three pale lit puffs over a gray ground, below and beside a roof. The puff in the sun is pale gray, the puff in the roof's shadow takes only the faint ambient light, and the puff under a warm lamp below the roof is warm.

More scenes come with ticket ter-rtxu, which also adds the switch for deterministic rendering.

Blueprints #

A blueprint is a name, a list of component types with optional field values, optional body, render and frame data, and an optional parent. It has no pose. The spawn call gives the frame and the pose (Blueprints.CreateSpawnDesc). Frame data, a FrameSpec, makes the new entity the owner of a frame.

A blueprint with a parent starts from the resolved blueprint of its parent, which must be registered first. A component type that the parent also lists keeps its place in the list of the parent. Its field values merge, and the values of the child win. The other types of the child come after the types of the parent.

Reflection runs only in Blueprints.Register, which C# code calls. For each component type and set of field names, it compiles one factory delegate, which every registry shares. Spawning then costs one delegate call per component. No loader reads blueprint files yet. The file formats and the loader are planned (ticket ter-jwzd, docs/plans/content-formats.md).

Host and scripted runs #

Tertius.Host wires the modules into a program. ScriptCommand.Run reads the command line and starts one of three runs. The design is in docs/plans/host-and-scripted-runs.md.

Run Command line What it does
Headless script --script <file> --headless --out <folder> [--video <file.mkv> [--fps <rate>]] [--size <width>x<height>] No window and no threads of its own. The calling thread runs the steps of the script, with the ticks back to back. It makes a headless renderer only for a script with a capture or film step. So a script with dumps only runs on a machine with no GPU. With --video, every capture becomes the next frame of one video, at --fps frames per second (1 to 240, 30 without the option). The dumps still go into the output folder.
Script in the window --script <file> --out <folder> [--speed <factor>] [--size <width>x<height>] The same script in a window. The simulation thread paces the ticks by the wall clock, times the speed factor.
Free play --scene <name> [--speed <factor>] [--record <file>] The scene in a window, until the window closes. With --record, the host writes the session to the file as a script that replays it.
  • A scene is a named C# function (Scene). It fills a new World and gives scene ids to some of its entities (SceneInstance). The table DemoScenes.All lists the scenes of the Demo. A script names its scene. The scene spin-station (DemoScenes.SpinStation.cs, script scripts/spin-station.json) shows the turning frames: a ring station turns about its axis, and a person stands, walks, jumps and throws a crate on its floor.
  • A script is a JSON file of steps, which run in file order: run, post, dump, capture, camera, film and outlines. Only a run step and a film step advance time. The ticks run in lock-step with the steps, not with the wall clock.
  • The closed sets of the script format are built-in C# 15 unions, such as public union MessageTarget(ToEntity, ToTopic, ToFrame, ToEveryone), because their cases are classes (docs/plans/union-tooling.md, decision 1). ScriptStep has the cases RunStep, PostStep, DumpStep, CaptureStep, CameraStep, FilmStep and OutlinesStep. MessageTarget has the cases ToEntity, ToTopic, ToFrame and ToEveryone. The runner's private ReadyStep has one case for each step after its checks. A case is a sealed record, so a union is a struct around one reference, and Value does not box. Code tests a case with a pattern, such as step is RunStep run, and the empty value with step is null. The compiler writes the constructors and Value. Each union writes its own ToString, which gives the text of the case, and ScriptStep, MessageTarget and CaptureTarget write their equality by the case, because the compiler gives only the equality of ValueType and no ==. The default value of a union holds no case: it is the JSON null. A built-in union lets no constructor refuse a null case (error CS9374), so a null case makes the default value too. The nullable analysis reports a case that may be null at build time, and the checks below refuse the default value at run time. Script.CheckSteps refuses a step that holds no case, and a post step whose target holds no case, with its step number, before the first tick. JSON refuses a null target when it reads it.
  • A JSON converter for each of the two unions (ScriptStepJsonConverter and MessageTargetJsonConverter, in UnionJson.cs) reads and writes the do key of a step and the kind key of a target, as InputEventJsonConverter does for an input event. Reading finds the key in a copy of the reader. Then it gives the whole object to System.Text.Json as the record of that case, with the same strict options. ScriptJson gives the contract of each case record the key as one more member. So reading accepts the key in any place of the object, and writing puts it first. An unknown member, a duplicate member, a missing member and a wrong value are errors, with the words of System.Text.Json. A key that is missing, given twice, not a string or unknown is an error with the words of the converter. The message for a missing or unknown key lists the kinds. A key that is given twice or is not a string gets its own short message. The path in a message about a member is relative to the record of the case, such as $.ticks, because System.Text.Json reads that record in a call of its own. The line and the byte position in the line are those of the whole text. The JSON of scripts, recordings and dumps is the same as before the unions, byte for byte.
  • A dump step writes a JSON state dump: poses, frames, routed events and errors. The routed events of a dump are those since the dump before it. So the runner records the routed events of a tick only while a dump step lies ahead. After the last dump step, or in a script with none, it records nothing, because no dump reads those records. Before the first tick, the runner finds the index of the last dump step, and the arms that tick (run and film) compare their own index with it.
  • A capture step renders the current state at the size of the window of the script, and gives the image to the run's capture target by the name of the step.
  • --size <width>x<height>, such as --size 1920x1080, replaces the size of the script's window in either run of a script, so any script can render at the size of a measurement. Each side is a whole number from 1 to 16,384. The command refuses --size in free play.
  • A headless run whose renderer times its passes (see "GPU timings") collects RenderStats.GpuTimings after each capture, once per finished frame, and writes gpu-timings.json into the output folder after its last step (ScriptCommand.WriteGpuTimings). The file names the device and the size of the frames, and gives for each pass the frames that timed it and the mean, median and 95th percentile (by nearest rank) of its times in milliseconds. The command then writes the file's path and one line for each pass, such as GPU G-buffer: mean 0.859 ms, median 0.861 ms, p95 1.086 ms over 3877 frames. A headless run never turns on the validation layers, so its times are those of the renderer alone.
  • A run has one capture target for its whole life. CaptureTarget is a built-in C# 15 union with two cases, built as ScriptStep is, and the headless playback refuses its default value. PngFolder writes the capture named name into <folder>/<name>.png (PngFolder.PathOf). VideoFile makes each capture the next frame of one video, in the order taken, and drops the names. The playback holds the target, and the runner gives it only the name of each capture (IPlayback.Capture(world, name)). So the runner builds no paths, and RunResult.Captures lists the names, in the order taken. A capture needs a live camera and a playback before the first tick. It needs no check for its folder, because a PngFolder cannot exist without one. The command writes the path of each dump, then the path of each PNG file, or the path of the video.
  • A film step, { "do": "film", "name": "climb", "frames": 18000, "every": 2 }, captures every Nth tick. It is defined as its expansion: frames repetitions of a run step of every ticks followed by a capture. So it runs frames × every ticks, its first capture comes after its first every ticks, and two films in a row with the same every give evenly spaced frames. Frame k, counted from 0, is the capture <name>-<k> with k in six digits (FilmStep.FrameName), such as climb-000000, which fits ffmpeg's %06d pattern. The runner's arm for it calls the same code as the arms of run and capture, so its images are byte-identical to those of its expansion.
  • Script.CheckSteps refuses frames outside 1 to 1,000,000, every below 1, and a name that is not a scene id or that has more than 57 characters, so that every frame name is a scene id of at most 64 characters. The frame names count as capture names. A frame name is its film's name with seven characters more, so two films clash only when they have one name, and a capture clashes with a film when its name is the film's name, a dash and six digits below the film's frames (FilmStep.HasFrameNamed). The check makes no list of frame names, and its message names both steps. A film has the checks of a capture before the first tick: a live camera and a renderer. If the camera dies during a film, the next frame fails with a message that names the step, the frame and the tick, and the frames before it stay on disk.
  • An outlines step, { "do": "outlines", "show": true }, sets the view setting World.ShowFrameOutlines between ticks (see "Frame outlines"). "show": false turns it off again. As after a camera step, the first render snapshot that is built after the step shows the change. A capture builds its snapshot at that moment, in the window as well as headless, so the snapshot of the next capture carries the outlines, or none. The step needs no camera and no renderer.
  • Each capture hands its pixels to a capture sink (see "The order of a rendered frame"). For a PngFolder, a new PngCaptureSink writes the file. A headless capture builds the render snapshot at that moment. It draws the snapshot on the calling thread at interpolation alpha 1, with impostors off, and the sink takes the pixels before the capture returns. In the window, the simulation thread gives the sink to the render thread through CaptureMailbox. Then it waits until the render thread has drawn the capture and the sink has written the file. The window takes only a PngFolder: the command refuses --video without --headless.
  • For a VideoFile, HeadlessPlayback owns one FfmpegVideoSink for the whole run. It starts ffmpeg (ffmpeg-headless in the dev shell) when it is made, before the scene builds, and then makes the folder of the video. ffmpeg opens its output only when the first frame reaches it, so the folder is there in time. The sink writes the raw pixels of each capture straight into ffmpeg's standard input, on the calling thread, with no copy and no allocation: -f rawvideo -pixel_format rgb0, so ffmpeg ignores the fourth byte of each pixel. ffmpeg encodes them losslessly, with libx264rgb -qp 0 -preset veryfast, into a Matroska file, which stays readable when ffmpeg stops early. -y replaces an older file. A thread-pool callback keeps the last 20 lines of ffmpeg's error output, so that ffmpeg never blocks on a full error pipe. A run that reaches its end calls HeadlessPlayback.Finish, which closes ffmpeg's input and waits until ffmpeg has written the end of the file. Every way out of the command disposes the playback, which ends the video too, so after a failure at a step the video plays and holds one frame for each capture before it. A video that holds no frame, after a script that fails before the first tick, is deleted.
  • If ffmpeg cannot start, the command exits with code 5 before the scene builds: no tick runs, and nothing is written, not even the folder of the video. If the folder cannot be made, for example because a file has its name, the sink kills ffmpeg, which has opened nothing yet, and the command exits with code 2, a file problem. If ffmpeg stops during the run (killed, disk full), the next frame's write fails with a broken pipe. The sink waits for ffmpeg to exit and throws VideoStreamException, an IOException with ffmpeg's exit code, the number of frames written and ffmpeg's last error lines. ffmpeg that exits with a code other than 0 at the end is the same exception. The command reports it as a file problem, exit code 2.
  • The measurement of ticket ter-mln3, in a Release build on the development Mac: 600 frames of the orbit scene, 2 ticks apart, take 3.5 ms each into a video at 1280x720 and 5.2 ms at 1920x1080. Into PNG files they take 80 ms and 177 ms, nearly all in the PNG encode. The pipe write takes 1.0 ms and 2.3 ms of each frame, and ffmpeg keeps up: at the end of the 600 frames, it needs 36 ms or less for the frames still in its queue. A writer thread would save at most the time of the write, about 18 s at 720p and 41 s at 1080p over 18,000 frames. So the write stays on the calling thread. docs/plans/blue-danube-demo.md, change 1, "Measured", has the whole report.
  • A post step reads its payload into the fields of the payload type by their camelCase names. InputEvent is the exception, because it is a union: it has its own JSON form, in scripts, in recordings and in dumps. A key or a mouse button is {"kind":"keyDown","code":87}, and a move or a scroll is {"kind":"mouseMove","delta":[3,-4]}. The kinds are keyDown, keyUp, mouseDown, mouseUp, mouseMove and scroll. Reading refuses a kind that is missing or unknown, a member that its kind needs and lacks, and a member that its kind does not have (delta on a key, code on a move). The one exception is that member with its zero value ("delta":[0, 0] on a key, "code":0 on a move), which reading ignores: the recorder wrote it into every input post before InputEvent was a union, so recordings from then still play. A delta reads and writes in the form of every other vector of a script or a dump, because the converter of InputEvent and the vector converters of the host call the same methods (JsonVectors).
  • During a script, live input reaches no entity, so a script runs the same with or without a person at the keyboard.
  • In free play, the main thread decides where each input event goes (InputRouter). Esc closes the window, F1 switches the debug fly camera on and off, and V makes the next view of the scene the camera. The game sees none of these keys. The window host queues every other event. The simulation thread posts the queued events to the input topic before its next tick. So each event joins a known tick, and a recording replays it exactly.
  • The command returns an exit code (ScriptCommand). 0 is success, 1 a wrong command line, 2 a file problem, 3 a script problem and 4 a bug. 5 (ToolError) means that a tool that the run needs cannot start: the renderer, the window or ffmpeg. The command refuses, with code 1, --size without --script or with a side outside 1 to 16,384, --video without --headless, --fps without --video or outside 1 to 240, a --video file whose name does not end in .mkv, and --video for a script with no capture or film step.

Rules for references #

  • A ref into a table never lives past one Handle call.
  • Tables grow only outside the parallel part of a tick, so a ref taken during a tick is always valid for that tick.
  • A component that needs another entity later stores its Entity handle and checks it on use.
  • Mod support will need a registry that drops every reference to mod types before unloading. That work is deferred.

Unions #

A union of struct cases is a partial struct marked [GeneratedUnion(typeof(A), typeof(B), ...)]. The generator in analyzers/Tertius.Unions writes its members: a constructor and an implicit conversion per case, HasValue, Value, a TryGetValue per case, equality and ToString. It stores the cases in private slots that the cases share by type: the k-th value of a type in a case goes into the k-th slot of that type. A positional record struct case gives its parameters as values, and any other struct case is one value. So a union is as large as its slots and a kind byte, and its fields stay plain fields that the JIT can keep in registers. The all-zero value holds no case, unless the attribute names a ZeroCase: then the all-zero value holds that case, the union has no empty value, and its Value is never null. A union of class cases is a built-in union declaration, which does not box them. docs/plans/union-tooling.md has the design. Every union that came before the generator now uses it. Result<T, TProblem> in Core is the one hand-written custom union, because the generator does not support type parameters, so it suppresses TU0001 with that reason. It holds a value or a problem, with a kind byte beside one field of each type, and its extension properties Ok and Problem give each as a nullable value. STYLE.md, "Expected failures", says when a method returns it.

The analyzer in the same project steers each closed set to its form. Its rules, and the refusals of the generator (TU1001 to TU1007), are warnings, errors through TreatWarningsAsErrors, as the style analyzer's are:

Rule Reports Fix
TU0001 A struct that carries [Union] and is not marked [GeneratedUnion]. The generator adds [Union] in its generated part, so a generated union does not count. [GeneratedUnion]
TU0002 A built-in union declaration with a struct case, which the built-in union boxes. Its cases are the parameters of the constructors that the compiler declares for it. [GeneratedUnion]
TU0004 An abstract class or record of the assembly, not closed, that has at least one subtype in the assembly, and only sealed ones. A base that declares an abstract or virtual member is an open set used through virtual dispatch, so the rule skips it. The virtual members that the compiler writes for a record do not count, and neither does an override. It is reported at the end of the compilation, because it needs every subtype. closed record, or a union
TU0005 A struct or class with an instance field or property named Kind of an enum type, beside another instance field. The backing field of a Kind property is not another field. A union of one case per kind

The generator's own refusal TU1002, a class case of a [GeneratedUnion], is the one report of that mistake, so the analyzer has no rule for it. TU0004 and TU0005 can report a set that is open, or a kind that every field shares. Such a type suppresses the rule with [SuppressMessage("Tertius.Unions", "TU000n", Justification = ...)] that says why. Today four types do: the test fixture base CheckedInRun and the test component Flag are open sets that code uses through an interface or asks for by type, never through a switch (TU0004), and in PhysicsOutputFault and RayQuestion every field means something for every kind (TU0005).

Switches over closed sets #

A closed set is an enum, a custom [Union] struct, a built-in union declaration such as ScriptStep, or a closed record hierarchy such as ShapeDesc. A new member of a closed set fails the build at every switch expression that dispatches on it. Three settings give this rule:

  • CS8524 is off (Directory.Build.props). That warning reports an enum switch expression that names every member but not the unnamed values, such as (BodyMotion)9. Without it, an enum switch expression names every member and has no discard arm, and CS8509 reports a missing member. An unnamed value throws SwitchExpressionException. Public inputs keep their Enum.IsDefined guards.
  • A switch expression over a union ends in a null arm for the empty value, never in a discard arm. A guarded arm does not count toward exhaustiveness, so a guarded case also gets an arm without a guard.
  • The build runs IDE0010 as an error. It reports an enum switch statement that does not name every member, and any switch statement without a default case. The other IDE rules that the build runs are warnings: IDE0063, a using block that can be a using declaration, and the rules against unused code, IDE0051, IDE0052 and IDE0060 (see "Style analyzer").

A switch that chooses a value is a switch expression. A switch statement stays only where statements are more natural: arms that run their own loops, return early or change several locals. The compiler does not check a switch statement for a missing case, and IDE0010 checks only an enum, so the style analyzer's TS0015 checks a switch statement over a union or a closed hierarchy. Such a statement names every case in a label without a guard, the cases that do nothing too. A union without a ZeroCase also names null, its empty value. The default case only throws UnreachableException, so a new case fails the build at the statement. A switch expression over a union struct copies the whole union first: C# keeps the input in a hidden local, and the hidden path for no matching arm throws it boxed, so the JIT keeps the copy. For MotionIntent, 184 bytes, a measurement on .NET 11 RC 1 found about 2 to 5 ns per call. Hot members still use a switch, so that a new case fails the build. A member gets a form without a switch only when a profile shows that the copy costs time. (User, 2026-10-24) TS0016 enforces that rule for a switch expression: it reports a discard arm over a union, a closed hierarchy or an enum that the compilation declares, and its code fix deletes the arm _ => throw new UnreachableException(). The cleanup turns a switch statement into a switch expression (IDE0066), and that fix removes the arm that the conversion leaves behind. An enum of another assembly is an open set, because the library adds members. Open sets keep a discard arm: strings, numbers, the keys of Silk.NET, the transitions of a flat state machine, and the private kind tag inside each union struct.

Style analyzer #

The analyzer in analyzers/Tertius.Style checks the rules of STYLE.md that a tool can check. Each rule is a warning, an error through TreatWarningsAsErrors (Directory.Build.props), as a compiler warning is, so a hit fails every build, and each message names the section of STYLE.md that gives the reason. The analyzer never analyzes generated code. .editorconfig turns three rules off in some folders: TS0003 outside src, so the tests, the benchmarks and the analyzers may use !; TS0006 in benchmarks, because BenchmarkDotNet derives a class from each benchmark class and takes only classes that are not sealed; and TS0009 in tests, because a test helper or a fake may keep the shape that the Try methods of .NET have.

Some rules need to know which types the solution declares. A build sees the other projects as metadata, so a type of the solution is a type of the compilation, or of an assembly whose name starts with Tertius (SolutionTypes).

Rule Reports Scope
TS0001 A tuple type with an element that has no name. The rule looks at every tuple type that the code writes, such as a field, a parameter, a return type, a local with a written type, a type argument (Dictionary<(int, int), X>) or an array creation (new (int, double)[n]), and at every declaration whose type the compiler infers: a var local, a foreach variable, a variable that a pattern or an out var declares, and a lambda parameter without a type. A deconstruction such as var (a, b) = ... declares no tuple, and a tuple literal takes the names of the type that receives it, so neither is reported. A name that C# infers from the literal, such as count in (count, total), counts as a name. src and tests
TS0002 A call whose target takes more than 3 parameters, with an argument that binds to a parameter by its position and has no name. A call is a method call, a delegate invocation, new, a target-typed new, a base(...) or this(...) initializer, a primary constructor's base call or an attribute. The params parameter does not count toward the 3, and the arguments that bind to it need no name, because every value of the list has the same role: Log(a, b, c, params rest) needs no names, and Sum(a, b, c, d, params rest) names a to d and leaves the list positional. An attribute's assignments to its properties carry their names already. The receiver of an extension method is not an argument. A call through a function pointer, where C# allows no name, and a dynamic call, which binds at run time, bind to no method symbol, so the rule does not see them. src and tests
TS0003 The null-forgiving operator !. Code tests for null once at the outer shell, in a form that the flow analysis understands: a pattern, a [MemberNotNullWhen] property such as BodyRecord.HasPrimary, or a contract (Contract.Requires and Contract.Assert carry [DoesNotReturnIf(false)], so a contract on a state that only a bug produces narrows the type, as before, without a check in Release). Where ! cannot go, #pragma warning disable TS0003 with a comment says why. Today one place does: the four values of each frame on FrameContext, which the renderer sets before the frame graph runs. src
TS0004 A read of a type of the solution by the position of its fields: a positional pattern with more than one element, and a deconstruction, in an assignment or a foreach, that calls a Deconstruct method, also inside a tuple deconstruction. A tuple and a framework type such as KeyValuePair are not types of the solution: a tuple has no names to bind by, and a framework type never reorders its fields, which is the misbehavior that the rule prevents. A positional pattern with one element, such as EndPose(var pose), has no order to break. src and tests
TS0005 A default method of an interface, without [MethodImpl(MethodImplOptions.AggressiveInlining)], when a type parameter constrained to struct or unmanaged names the interface, or an interface that inherits it, anywhere in the compilation. A default method is an instance method of an interface with a body, other than a private one. The rule needs every constraint, so it reports at the end of the compilation. A constraint in another assembly is out of its sight. src and tests
TS0006 A class or record class that is neither sealed, abstract, static nor closed, and that no class of the compilation derives from. The class that the compiler declares for top-level statements is not reported. It reports at the end of the compilation, because it needs every subtype. A base for another assembly is abstract, so the rule never sees a base whose subtypes are elsewhere. src, tests and analyzers
TS0007 A class more than 2 levels below the root of its hierarchy, counting only the classes of the solution. The first base from outside the solution, such as JsonConverter<T> or Exception, is the root, as object is: the framework asks for that base to plug the class in, and the levels above it are not a hierarchy that the code builds. Each class past the second level is reported, with its chain. src and tests
TS0008 A finalizer. src and tests
TS0009 A method or a local function, whatever its name, that returns bool and has at least one out parameter: the Try shape. The message says to return T?, or Result<T, TProblem> if the caller needs the reason (STYLE.md, "Expected failures"). Three kinds of method are not reported: TryGetValue on a type marked [Union] or [GeneratedUnion], which the compiler calls by name for pattern matching; an override, an explicit implementation and an implicit implementation of an interface member, which take their shape from the member that they implement, so only that member is reported (an interface of the framework is declared outside the solution, so nothing is reported for it); and a method whose comment before the declaration holds the word "benchmark" in any case, the comment that STYLE.md asks for where a benchmark shows that the nullable form costs real time. src
TS0010 A local declared without using whose initializer makes an IDisposable with new, and that the code uses only through its members: x.Member, x?.Member or x[i]. Every other use hands the object on, to a return, a field or a property, an argument, another local or a lambda that captures it, so the rule leaves the object to its new owner. A local that the code never uses is reported too. An explicit Dispose is a member use, so a try and finally around it is reported: a using declaration says the same. src and tests
TS0011 A conditional expression with a branch that is, inside its parentheses, another conditional expression. Each chain is reported once, at its outer conditional. A conditional inside a call, a lambda or the condition of another is not a branch. src and tests
TS0012 A call of an instance method or property on a readonly field, an in parameter or a ref readonly parameter whose type is a type parameter constrained to struct (or unmanaged) and an interface. The call copies the struct first. The rule reports the call, which pays the copy, not the declaration: a payload that the code only copies, such as the in T payload of Outbox.Send, costs nothing more. src and tests
TS0013 A conversion of such a type parameter to an interface type, which boxes the struct. src and tests
TS0014 A static class whose name ends in Helper, Helpers, Util, Utils or Extensions, with no extension method and no extension block. src and tests
TS0015 A switch statement over a closed set that does not name every case, at its switch keyword, with the missing cases in the message. A closed set is a union or a closed hierarchy. A union is a type marked [Union], as a [GeneratedUnion] struct and a built-in union of another assembly are, or a type declared with the union keyword. Its cases are the parameter types of its public constructors with one parameter, as the compiler reads them. A union without a ZeroCase in its [GeneratedUnion] has one more case, null. The cases of a closed class are its direct subtypes. A label names a case when it has no guard and its pattern is the case's type, alone or in an or pattern: a type pattern, a declaration pattern, or a property or positional pattern whose subpatterns match every value. Such a subpattern is a var, a discard, or a type pattern of its input's own type when the input cannot be null, such as int ticks, or string name for a string that is not nullable. The rule also reports, at its default label, a default section that does more than throw a new UnreachableException. Microsoft.CodeAnalysis 4.14 cannot tell that a class of another assembly is closed, so a closed hierarchy of another assembly is out of its sight. src and tests
TS0016 A switch expression over a closed set that has a discard arm, at the pattern of the arm. A closed set is what TS0015 counts, a union or a closed hierarchy, and an enum that the compilation declares. An enum of another assembly, such as Key of Silk.NET or Roslyn's SpecialType, is an open set. A discard arm is an arm without a guard whose pattern is _, var _ or var x. A guarded arm is not one. The message says that a discard arm hides a new case from the compiler. src and tests

The code fix of TS0002 (NameArgumentsFix) names exactly the arguments that the rule asks for, with the names of their parameters, and keeps their order, so each named argument stays in its position, before the positional values of a params list. The name goes before an out, ref or in modifier and takes the comments and the line break in front of the argument. A parameter named by a keyword gets an @. Its fix-all rewrites each document at once, so the calls in the arguments of a call are named too. dotnet format analyzers Tertius.slnx --diagnostics TS0002 --severity warn applies it to the whole solution (tools/README.md).

Four more rules have code fixes, each with a fix-all that rewrites a document at once:

  • PropertyPatternFix (TS0004) turns a positional pattern into a property pattern. Each element gets the name of the property or field that its Deconstruct parameter reads, found by name with case ignored, and the fix offers nothing when a name finds no member. It drops each discard, keeps the type, the designation and the properties that the pattern names already, and writes the pattern on one line. A deconstruction in an assignment or a foreach has no property pattern to become, so it has no fix.
  • InlineDefaultMethodFix (TS0005) puts [MethodImpl(MethodImplOptions.AggressiveInlining)] on its own line after the method's attributes, or first after its doc comment, and adds using System.Runtime.CompilerServices; when the file has none.
  • SealClassFix (TS0006) adds sealed after the access modifiers and new, before every other modifier, such as unsafe or partial.
  • DiscardArmFix (TS0016) deletes a discard arm whose expression is only throw new UnreachableException(...), with or without a message. It offers nothing for any other discard arm, because the value of that arm is behavior, and nothing for the only arm of a switch expression. It keeps the trailing comma style of the arms that stay.

.editorconfig raises IDE0063 to a warning, so the build also fails at a using block that can be a using declaration (STYLE.md, "Disposal and ownership"), and tools/cleanup.sh applies its fix. It raises IDE0033 to a warning too, so the build fails at Item1 on a tuple whose elements have names (STYLE.md, "Name a type only when it earns a name").

Unused code fails the build too. .editorconfig raises four IDE rules to warnings: IDE0051, a private member that nothing uses, IDE0052, a private member that is written but never read, IDE0059, a value that the code assigns and never reads, and IDE0060, a parameter that its method never reads. IDE0060 does not report a parameter that an interface, an override, an event handler or a delegate shape requires. It does not see every delegate shape, so a method whose signature a delegate type fixes names an unused parameter _, as the demo's check panels do. A member that only code in #if TERTIUS_CONTRACTS or in its #else uses sits in the same branch, because Debug and Checked compile one branch and Release the other. The compiler already reports unused locals (CS0168, CS0219). Unused internal and public types and members across projects are the work of the slop tool (tools/Tertius.Slop), not of these rules.