Skip to content

Offscreen Rendering

The engine can draw a map straight to disk. --render opens a map, shoots every camera entity it authored, and writes each one to a PNG — no window, no swapchain, no server, no simulation. It is one of the five launch modes of the single engine executable, alongside the default windowed singleplayer, the --headless dedicated server, the --connect remote client, and the --benchmark offscreen timing run.

DigitalHeaven.Engine.Host --render <dir> [--map <barcode|name>] [--camera <name>]
[--width <px>] [--height <px>] [--renderUi] [--renderMotion]
[--set <key>=<value>]...
FlagArgumentDefault
--renderOutput directory. Required — it is what selects this mode.
--mapA map barcode, or a bare name if it is unambiguous.The bundled map
--cameraThe name of one camera entity to shoot.Every camera the map authors
--widthOutput width in pixels.1600
--heightOutput height in pixels.900
--renderUiNone. Composites the Halcyon player UI over each camera and writes its whole transition as a sequence.Off
--renderMotionNone. Photographs camera motion blur as a labeled sequence. Cannot be combined with --renderUi.Off
--set<key>=<value>. Overrides one preference for this render only. Repeatable.

Each shot is written as <mapFileStem>_<cameraName>.png — the map’s file stem, not its display name — and its path is echoed as it lands.

The mode is exclusive by construction, and the contradictions are rejected at parse time rather than half-honored:

  • --render with --headless — “different modes: one draws a map offscreen, the other runs a server.”
  • --render with --connect--render draws a local map; a connected client takes its map from the server.
  • --map or --camera without --render — those two select what to render, so they need something to render into.
  • --renderUi without --render — there is no offscreen frame to composite a UI over.
  • --renderMotion without --render — same reason, and there is nowhere to put a sequence.
  • --renderMotion with --renderUi — each writes its own sequence from the same camera; run them separately.
  • --set without --render — a live session already has a console; the flag exists only because a render has no way to type into one.

A map that authors no camera entity cannot be rendered; the error says so and tells you to add one. So does naming a camera the map does not have — it lists the ones it does.

A render resolves the map’s look exactly as the map author authored it. The mode builds a virgin preference store — freshly constructed, with no profile ever loaded into it — and no world context, so every world.* value falls through to what the map’s lighting and render blocks seeded, and every viewer-owned comfort setting takes its engine default.

That is the point, not a limitation. No saved console edit, no tuning session, and no profile on the machine that happens to be running the render can leak into the image. The same map renders the same way on a developer’s machine, on a fresh clone, and on a build agent that has never had the game open.

--set is the one deliberate way in, and it is deliberately explicit: every override is spelled out on the command line, so the image is still reproducible from the command that made it. It is applied to the virgin store before anything reads from it, and an unknown key or an unparsable value is an error rather than a silent no-op — which is what makes it usable for A/B comparisons of a single effect.

DigitalHeaven.Engine.Host --render out/ --set client.render.aoHalfResolution=false --set client.render.aoDenoise=0

Logic entities draw in rest pose: a door closed, a button unpressed, and a light lit if and only if it was authored startOn. Nothing is simulated, so there is no tick count, no elapsed time and no interaction state to agree on.

By default a render contains no UI at all — it is a picture of the world. --renderUi composites the Halcyon layer over the tonemapped frame in exactly the position the live client puts it: after the resolve, color-only, loading rather than clearing what is underneath.

The developer tooling takes part too. The debug HUD, the gizmos and the DigitalHeaven overlay are plain draw commands rather than an immediate-mode context that only exists next to a window, so the offscreen path can composite them like anything else. There is one hand-off per band — PaintTooling for the gizmos, the readouts and the frame-stats block that sit under the player’s screens, PaintCrosshair for the reticle above them and still under the screens, PaintHud for the speedometer over the screens and PaintOverlay for the overlay’s own chrome above that — and each takes a callback rather than handing out its list, because the ordering is the contract: after the step, which clears the list, and before the shot, which records it. Which band a surface goes into is read off the surface’s own Band constant (PerformanceOverlay, EffectsReadoutOverlay, SpeedometerOverlay, CrosshairOverlay), so a capture cannot photograph a stack the live client does not draw.

A UI that animates cannot be represented by one still, so --renderUi writes a sequence instead: the panel mounts hidden, is shown, and the transition is sampled at both ends and at every quarter between, then once well after it settles, then the same again on the way out. Each frame is named <mapFileStem>_<cameraName>_<phase><milliseconds>ms.png:

night-city_skyline_enter0000ms.png just mounted — the recipe starts at zero opacity, so this frame is the world alone
night-city_skyline_enter0037ms.png partway in: offset by part of the travel, partly opaque
night-city_skyline_enter0075ms.png the arrive point — placed and fully opaque, still visibly soft
night-city_skyline_enter0112ms.png nearly resolved; only a trace of blur left
night-city_skyline_enter0150ms.png the transition's end — sharp, and no layer at all
night-city_skyline_settled1000ms.png a second later, to prove nothing keeps moving
night-city_skyline_exit0037ms.png …and back out, sampled the same way

Each camera walks a run of sequences back to back, so one pass photographs every surface the engine draws: the demo panel (enter/settled/exit above), the settings screen (settingsBrowse, settingsSearch, settingsSwitches, and settingsMenu — a dropdown held open, which is the one frame that needs a synthesized press, since whether a menu is open is the control’s own state and there is no preference to write instead), the pause menu (pauseMenuEnter…, pauseMenuSettled, pauseMenuSettings — settings as it is actually reached, from the menu — and pauseMenuExit…), the main menu (mainMenu…), which is the same screen with the no-session link set, and the console in each of its three placements (consoleSheet, consoleCard, consoleDock). Every sequence labels its frames separately so none can overwrite another’s images.

Two of them photograph the stack contract rather than a surface. The window run (windows…) drags the options window, swaps which of the two windows is in front, and then spends one Escape: windowsEscapeClearedBoth is the frame after it, bare, because leaving the pause layer takes the console with it, and windowsRepausedRestored is the re-pause, which brings back only the window that belongs to the layer. The in-game console run (inGameConsoleOpen, inGameConsoleClosed, pausedConsoleDimmed, menuAfterConsole) photographs the headline claim of the console contract: inGameConsoleOpen is the console with no menu and no dim behind it — the world is still running — and pausedConsoleDimmed is the same console over the pause layer, so the two images differ in exactly the thing being claimed. A still frame cannot show a clock, so what the pair actually proves is that the surface is reachable without the layer; the behavior itself is pinned in ClientUiStateTests and ClientUiStackTests.

One more is the text specimen run (textSubpixel, textGrayscale, textFade…), which A/Bs the two antialiasing paths on frames that differ in nothing else.

Another is the chat run, and like the in-game console pair its evidence is the relation between two images. chatFeed is the passive feed as it reads during play — no plate, no field, no keyboard — and chatPromptOpen is the same feed with the prompt over it. Every line visible in the first is at exactly the same pixels in the second: the closed and open states are one tree, the input row is mounted and reserving its height even while chat is closed, and the surface is anchored by its bottom edge, so opening adds a field, a plate and more history without moving anything the player was already reading. Then chatClose… samples the close across the transition, because the claim there is that the prompt retraces its entrance instead of blinking out. A render has no session, so the lines are seeded by the command — a join, a leave, a server line and several player lines, all stamped at the capture’s single instant so the fade can never make which lines are legible depend on how many frames the run took to get there.

Another is the appearance run (appearanceSeedDefault, appearanceSeedAlternate), which searches the settings screen for that section and then photographs it twice with the clock held still, changing only client.ui.themeColor. Two frames that differ in nothing but the seed are the only honest evidence that the tinted surfaces are derived from it: any card, chip or track that fails to move between them is a hardcoded color, and the image says so at a glance in a way no unit test does.

The last is the developer run, and it is built to be read as a diff. It walks client.debug.hud one level at a time with the overlay down — hudStats (the frame-stats block and the position readout), hudGizmos (+ the axis, entity and scene-pivot gizmos and the sound cues), hudSignal (+ the rush figures) — so consecutive images differ by exactly one level and each one says what that level draws. Three images taken at the top level would say nothing. Then it raises the DigitalHeaven overlay, steps past its fade, and shoots it settled: overlayBar with nothing open, one frame per window archetype, four frames with the pointer parked on something (overlayBarHover, overlayBarPress, overlayControlHover, overlayControlPress — the only pictures there are of the chrome’s hover and pressed paints), and overlayWindows with all three windows cascaded. The run ends with the overlay put away, so nothing concatenated after it inherits a surface it did not ask for.

Beside it is the HUD-layering run, which holds the frame-stats block still at one level and moves the surfaces around it: hudOverPauseMenu (the block dimmed under the menu’s scrim, along with the gizmos), hudUnderConsole (the same menu with the console raised over both), and hudOverMainMenu (the same block under the main menu, which takes no scrim and no vignette). The names predate the band split and are kept because the frames are the same frames; what they photograph now is the block in Tooling. Three images with one constant and one variable are what makes the layering claim visible; a capture is a still, so it photographs the two ends of the session ramp rather than any point along it. Like the developer run it ends driven back out, leaving nothing raised.

The speedometer run is the player-facing counterpart, and it makes the same claim from the other side of the band. Four zero-length steps photograph the readout at a stand, at the engine’s own declared walk and sprint speeds and at a fall — speedometer0 through speedometer3, differing in the digits and nothing else, which is how the fixed-width number field is visible at all — then speedometerOverMenu raises the pause menu under it and speedometerUnderConsole raises the console over it. Both, because a boundary photographed only on its true side is not a picture of a boundary — though the second is a weaker picture than it sounds: at the default sheet geometry the console does not reach the bottom band, so the two share a screen rather than one hiding the other. That is what a player sees; the order itself is asserted against SpeedometerOverlay.Band rather than photographed, because no console placement covers the bottom center. A capture has no pawn, so the one number the card exists to print is requested as data; it has no interaction state either, so what those two frames show is the layering under the gate a live client would apply.

The crosshair run is the newest, and it exists because the band it photographs had no hand-off at all. crosshairInPlay is the reticle during play with client.debug.hud at its top level, so the scene-pivot gizmo is projecting into the middle of the frame and the mark has to be on top of it — the half the band was added for. crosshairUnderMenu and crosshairUnderOverlay are the halves that could not be taken before: the reticle is requested with a player screen up, which is the gate held open on purpose, because what needs photographing there is the stack doing the covering rather than ClientUiState.CrosshairVisible declining to draw anything. A live client never reaches that state, and that is the point — a capture has no interaction state, so the reticle is a step field rather than a predicate the harness evaluates. The gate itself is untouched.

The console’s frames are driven, not staged. The half-typed command line is fed through the model’s own controllers, so the completion panel is ranked by the real ranker and windowed by the real cursor. The sheet sequence adds three extra frames the other two placements do not need, because they photograph the shared console body rather than a placement: consoleSheetCycled (one Tab into the candidate list), consoleSheetFiltered (the header filter narrowing the log with a category chip muted), and consoleSheetSelected (a multi-line text selection over the last few lines of the scrollback).

The UI clock is stepped by fixed amounts supplied by the harness, never by a wall clock, and the pointer is delivered as unavailable so nothing can hover. Two surfaces read a clock the harness cannot step, and both are pinned rather than stepped: the caret’s blink is held solid with CaretBlinkSeconds: 0, because a blink would make the caret’s presence in a PNG a coin flip, and the overlay’s toast arrival ramp is held at zero with client.overlay.toastFadeIn, because a Notification stamps DateTime.UtcNow and is asked its own age — which made overlayBar differ between two runs by however far into a 0.3 s fade the machine had got. Pinned, every toast is photographed settled, which is the state those frames were always described as showing. One frame is the exception and says so: the open-dropdown shot presses the anchor’s own rectangle before it is photographed, because whether a menu is open belongs to the control rather than to any model, and it puts the pointer back out of reach immediately after — so the menu appears open with nothing hovered. The client.ui.* preferences resolve against the same virgin store the world settings do, so the transition’s duration, travel and blur are the engine’s declared defaults — the demo panel itself is requested by the flag rather than by client.ui.demo, which a virgin store always reports as off.

Camera motion blur is the first effect whose output depends on the previous frame, and that breaks a still outright: every ordinary capture rewinds the renderer’s frame-to-frame history first (see Determinism below), so the filter is handed a camera with no predecessor, correctly calls that a discontinuity, and leaves the frame sharp. --render with --set client.render.motionBlur=true therefore produces exactly the same image as without it. That is not a bug in either half; it is why --renderMotion exists.

--renderMotion walks a fixed timeline of phases. Each rewinds once, drives one unphotographed frame to establish a previous camera, and then shoots the frame with the motion between the two in force. Each image is named <mapFileStem>_<cameraName>_<phase>.png:

PhaseWhat it isWhat it should look like
stillStatic camera, filter onPerfectly sharp — and byte-identical to stillBlurOff
walk4 m/sSharp: under the onset speed
onsetExactly the onset speedStill sharp: the ramp is zero at its start
sprint18 m/sBlurred
sprintZeroAmount / sprintZeroShutterThe same sprint with the strength and the shutter each switched offBoth byte-identical to sprintBlurOff
fall24 m/s straight downThe strongest blur the effect produces
flickA mouse flick with the feet plantedPerfectly sharp, however large the screen velocity is
flickMovingThe same flick while sprintingBlurred, with rotation contributing its capped share
teleportA 40 m jump in one frame, at full gainSharp — the discontinuity cancels it
hitchA 200 ms frame while sprintingSharp — past the hitch gate
lowFpsThe same sprint at 40 fpsVisibly weaker than sprint, and the same streak length

Every phase above that claims sharpness also writes a <phase>BlurOff twin — stillBlurOff, walkBlurOff, onsetBlurOff, sprintBlurOff, fallBlurOff, flickBlurOff, teleportBlurOff, hitchBlurOff — at the identical camera pose, yaw, gain and frame duration, varying only the master toggle. That pairing is the point: a walk, a flick, a teleport and a hitch all move or turn the camera, so none of them can be the same bytes as the static frame, and comparing any of them to stillBlurOff would be a check that can only ever fail — and whose failure would say nothing about the blur.

The whole timeline is a pure function of the onset and saturation speeds, so it is reproducible run to run and a --set on either moves the phases with it. Everything the timeline does not override still comes from the preference store, so --set client.render.motionBlurShutter=4 A/Bs the entire sequence at the maximum exposure.

Aim it at a camera looking at detailed geometry with real depth complexity. A flat wall would hide the exact artifact the sequence exists to catch — Jimenez’s own note on the technique is that it “works great for constant or low-frequency backgrounds, but it creates artifacts on detailed ones”, which is why the resolve this engine uses is not the one from those slides.

Two runs of the same scene produce byte-identical PNGs. That is the whole reason the feature exists — render diffing and regression images only mean anything if a pixel that moved moved because the content changed.

Getting there means pinning every input that would otherwise drift between runs:

PinnedWhy it would otherwise drift
The view is built directly as a render view, never through ClientCameraClientCamera carries the speed-FOV spring, the roll spring and a sub-tick interpolation alpha — all of which are functions of when you looked, not where from
The frame-in-flight slot is reset before every captureThe windowed path rotates through its in-flight slots, so the same shot would land in a different slot depending on how many frames preceded it
Every frame-to-frame history is dropped: the temporal accumulation and the motion filter’s previous cameraBoth would otherwise make a shot depend on whatever frame happened to precede it. For motion blur this is also what makes a still unblurrable — see above
The scene extent is re-baselined rather than grownThe renderer’s scene target only ever grows in normal play; without the reset, a wide earlier shot in the same run would leave a later narrow one rendering into a larger target
Background mip generation is drained before any captureMip chains are built on a worker thread; a capture that raced it would sample different mips depending on thread timing
Logic props are walked in author orderLight ranking breaks ties by submission order, so an unordered walk would reorder the light set
Panini is offWith the warp off the scene extent is exactly the output extent, always — and the shot is plain rectilinear, which is what a reference image should be
The preference store is virginSee above

The offscreen path is not a separate renderer. The swapchain and the offscreen target sit behind one output seam, so shadow cascades, the scene pass, bloom and the HDR resolve are the same passes running in the same order — only the last hop differs, presenting a frame versus reading one back. A headless graphics device simply requests no surface or swapchain support. The readback format matches the swapchain’s, so no gamma math happens on the way out, and the PNG is written by a small hand-rolled encoder rather than an image library.

See CONTRIBUTING.md → Offscreen Rendering for the full architecture — the output seam, the device setup and the encoder.