Render
This file covers how render work reaches the GPU: the renderer’s dependency-inversion boundary
and the frame graph behind it. What the passes then compute — materials and image-based
lighting, the temporal core, shadows, and the lighting and sky — is
docs/architecture/PRESENTATION_RENDER_SHADING.md.
1. The render seam
Rendering does not belong inside the runtime — the runtime knows no graphics, just as it knows no
math. The renderer is a separate compiled library (engine/presentation/render/), a greenfield
Vulkan 1.4 backend behind a dependency-inversion boundary so a D3D12/Metal backend can follow
without touching a consumer. The layering, from abstract to concrete:
-
RHI device (
engine/presentation/render/include/SushiEngine/render/rhi/device.hpp):IRenderDevice/create_render_device()carry no Vulkan types.DeviceInformationexposes the physical device’s UUID — the key a later milestone matches against SushiRuntime’s SYCL device for zero-copy interop.RenderDeviceDescriptioncarries aSurfaceFactoryhook and required instance extensions so a windowed host supplies its presentation surface without the renderer ever calling a windowing library;native_handles()is the single, explicit escape hatch a native-API adapter (the editor’s ImGui Vulkan backend) uses. -
Presentation facade (
engine/presentation/render/include/SushiEngine/render/window_renderer.hpp):IWindowRenderer/create_window_renderer()own the device and swapchain and drive the acquire → clear → submit → present cycle; a host opens a frame, records into the returned command buffer, and closes it. Swapchain rebuild on resize is internal. The Vulkan implementation isengine/presentation/render/source/rhi/vulkan/vulkan_window_renderer.*.present_scene_view(view, slot, width, height)(PLATFORM0 S4) is the other way a host ends a frame, and it now does one of two things depending on whether frame generation is running. With it off, the call is what it always was: a blit of anISceneViewslot’s resolve image straight onto the swapchain image — outside any dynamic-rendering scope, since the blit cannot record inside one — for a host with no other UI to draw over it (sushiengine_player, unlike the editor, which instead samples the scene view’s texture through ImGui). With it on, the call instead hands the composited real frame and its generated siblings toVulkan::FramePresenter(engine/presentation/render/source/rhi/vulkan/frame_presenter.hpp), which queues them and presents each in turn from its own thread, spaced byFrame::FramePacer’s deadline for that frame, so two presents that would otherwise land in the same refresh interval do not.A
WindowRendererDescriptionwith nosurface_factory(PLATFORM0 S6) builds no swapchain at all:begin_frame/present_scene_view/end_framebecome well-defined no-ops, and a host still gets a working device, asset library, andcreate_scene_view()— the offscreen-render, no-window shapese player --headlessuses for CI.A queue mutex, forced by the presenter thread.
VkQueueis externally synchronized — the Vulkan specification leaves concurrent access to one queue as the caller’s problem — and this renderer submitted and presented from a single thread until the paced presenter gave it a second one contending for the same graphics queue.VulkanDevice::queue_mutex()(engine/presentation/render/source/rhi/vulkan/vulkan_device.hpp) is the lock that followed: every submit and present against the graphics queue takes it, across eleven call sites spanning the atmosphere nest, the texture and cloud-noise libraries, the IBL pass, the mesh and prefab thumbnail renderers, the offscreen path, the scene view, and the window renderer itself. This was not part of the frame generation design; it is a precondition the presenter thread forced, and it now binds every future submit site in this module — a new one that reaches the graphics queue without takingqueue_mutex()is racing the presenter thread, whether or not frame generation happens to be on when it is written.The presented images are allocated whether or not generation is on. Each of
ViewResources’SLOTSframe slots (engine/presentation/render/source/rhi/vulkan/view_resources.hpp) ownsPRESENTED_PER_SLOTpresentable images beyond its resolve — the composited real frame plusMAX_GENERATED_FRAMESgenerated ones — and all of them exist from startup, unconditionally: atSLOTS = 3andMAX_GENERATED_FRAMES = 3, that is 12 extraRESOLVE_FORMAT(VK_FORMAT_R8G8B8A8_UNORM) images, roughly 100 MB at 1920×1080, held whether the setting is off, on, or generating fewer than three frames. Sizing this to the livegenerated_framessetting was rejected: shrinking or growing it on a setting change would mean destroying and recreating images the host has already registered ImGui views over, from inside a running frame — the same class of invalidation hazard that cost this project a lost device once. The fixed ceiling is what buys the editor’s viewport panel never having to learn that generation exists;MAX_GENERATED_FRAMESis the lever if the standing cost is ever judged too high for what it buys. -
Headless target (
engine/presentation/render/source/rhi/vulkan/vulkan_offscreen.*): the same device path without a window, used bysushiengine_render_probeto validate the pipeline in CI. A second, independent headless path isIWindowRendereritself with no surface (above) — the onese player --headlessuses, since it needs the samecreate_scene_view()/assets()surface a windowed player gets, not the offscreen probe’s narrower one. -
Scene view (
engine/presentation/render/include/SushiEngine/render/scene_view.hpp:ISceneView, created byIWindowRenderer::create_scene_view()): an offscreen camera view of aMeshInstanceset (each tagged with aMeshKind— Box, Sphere, or Cylinder — plus per-kind shape params, or an imported mesh instead of a primitive when itsmeshfield is set,docs/archive/design/STATIC_MESH_AUTHORING.md) plus a ground grid, drawn from aCameraView. The Vulkan implementation (engine/presentation/render/source/rhi/vulkan/vulkan_scene_view.*) rotates throughViewResources::SLOTS(three) frame slots — the frame being sampled by the UI is never the frame being drawn — and leaves its colour image shader-readable, with an alpha of one, so the editor samples it withImGui::Image. It exposes only the sampler/view handles a UI backend needs, never a full descriptor set.Alongside the shaded image it renders a second
R32_UINTid target carrying each instance’s picking id, copied to a host buffer each frame sopick(x, y)resolves a click to the entity under the cursor (GPU id-buffer picking);renderalso takes the selected id, which the mesh shader highlights, and the grid mode the Scene view draws it against. -
Lighting, materials, and the sky (see the shading chapter).
renderalso takes aconst Render::Environment&and the camera’s world position, and draws the frame in three HDR passes rather than one, giving PBR meshes and a WGS84 planet with a physical atmosphere.
The editor composes these behind its own windowing seam
(engine/foundation/platform/include/SushiEngine/platform/platform_window.hpp
IPlatformWindow, SDL implementation engine/foundation/platform/source/sdl_window.cpp) and a
Dear ImGui ↔ Vulkan adapter (applications/editor/source/ui/imgui_backend.*) — the one
editor component that speaks Vulkan, kept apart from the app loop and panels so the rest of the
editor names no graphics API.
A single ViewportPanel (applications/editor/source/ui/viewport_panel.*) owns an offscreen
scene view and renders it from an injected camera — the ISceneCamera seam
(applications/editor/source/camera/scene_camera.hpp). Two implementations back the two Unity
viewports: a navigable FlyCameraSource (the Scene view) driving a fly camera
(applications/editor/source/camera/fly_camera.hpp) through a stateless controller
(applications/editor/source/camera/camera_controller.hpp) that reads a library-neutral
InputState (applications/editor/source/input/input_state.hpp) the panel fills from ImGui, and
a WorldCameraSource (the Game view) posed each frame from the simulation’s camera.
FlyCamera and CameraController store and compute in Scalar (see
the value-type seam), so the camera pipeline runs at the
same precision as the rest of the engine — InputState fields stay float (ImGui pixel deltas)
and are static_cast-ed to Scalar at the computation boundary. So the same panel serves both
viewports, the controller depends on no input source and stays unit-testable, and a new camera
kind is a new implementation rather than a new panel.
Interaction closes the loop: a left-click picks via the id target and the Scene view draws the
transform gizmo at the selection (applications/editor/source/gizmo/gizmo_controller.*, ImGui
draw list, projecting through the camera), so an entity is created from the Hierarchy, selected
in any viewport, moved with the gizmo, edited in the Inspector, and destroyed — all against the
one live world. GizmoController offers translate/rotate/scale (Unity’s W/E/R) and a
GizmoSpace (Local/World) the toolbar toggles; Scale always drags local axes to avoid shearing a
rotated object. Rotate drags are computed by intersecting the mouse ray with the axis’s own plane
through the pivot each frame and measuring the signed world-space angle swept between grab-time
and current plane vectors — a screen-space angle would invert once the camera crosses to the far
side of the axis, which is why translate/scale axes and the ray/plane math live in world space
rather than screen space throughout.
Editor and project settings sit behind a preferences seam
(engine/world/authoring/include/SushiEngine/authoring/preferences.hpp IPreferencesStore, JSON
implementation writing a per-user preferences.json). The Preferences window edits a plain
Preferences aggregate; the loop persists changes and applies the live-effective ones (theme,
camera speed). The precision setting selects the physics-solve precision of
the value-type seam (a live runtime choice that rebuilds
the running simulation from a scene snapshot); the boundary Scalar itself is always double.
The Project panel (applications/editor/source/project/project_panel.cpp) is a two-pane file
browser over the on-disk project: a recursive folder tree and a searchable icon-grid of the
current folder, supporting create/rename/delete, “Show in Explorer”, and double-click open (text
extensions open in the built-in text editor; anything else opens via the OS default application,
ShellExecuteW on Windows). The project root defaults to <user profile>/sushiengine/project —
outside the engine’s own source tree — and is persisted as Preferences::last_project_root once
resolved, so authored project files never mix with engine source.
Scene persistence (engine/world/serialization/source/scene_serializer.cpp,
ISceneSerializer-free — it is two free functions, save_scene/load_scene, since there is only
one format) writes/reads a .scene JSON file purely through IWorldEditor’s existing
query/mutate surface, so it adds no engine-side type. Parent links are stored as indices into the
saved entity array rather than raw EntityIds (ids are not guaranteed stable across a
destroy-and-reload); loading destroys every existing entity, recreates the file’s in order, and
resolves parent indices in a second pass once all entities exist, so a child listed before its
parent in the file still resolves correctly. File ▸ Save Scene/Save Scene As.../New Scene
and the Project panel’s double-click/Open on a .scene file are the entry points;
capture_scene/apply_scene are the same functions save_scene/load_scene wrap around file
I/O, and are reused directly by undo/redo below.
Undo/redo (engine/world/authoring/include/SushiEngine/authoring/command_history.hpp and
engine/world/authoring/source/command_history.cpp, CommandHistory) is whole-world
snapshot-based rather than a per-field command hierarchy: every step is a full
capture_scene/apply_scene round-trip, which is simple and correct at this entity count at the
cost of coarser granularity. Two recording modes cover the panels: record() snapshots
immediately before a discrete, single-frame mutation (create, delete, rename, reparent, a
checkbox toggle); begin_change()/end_change() bracket a continuous edit spanning several
frames (an Inspector slider held down, a gizmo drag) so it costs one undo step regardless of how
many frames it runs.
Panels call begin_change on the widget’s activation edge (ImGui::IsItemActivated()) and
end_change on its deactivation edge (IsItemDeactivatedAfterEdit()); the gizmo does the same
off GizmoController::dragging()’s grab/release edge, tracked in the main loop since the gizmo
lives inside ViewportPanel::draw(). Edit ▸ Undo/Redo (Ctrl+Z/Ctrl+Y, ignored while
ImGuiIO::WantTextInput is set so a rename field’s own text editing is not hijacked) drive it.
Because undo/redo swaps the whole world, entity ids are not preserved across the step, so both
clear the current selection rather than risk it aliasing an unrelated new entity.
CommandHistory::revision() is a counter bumped by every record(), committed end_change(),
undo(), and redo() — a cheap “has the world changed” signal a host can compare against a
stashed value without diffing snapshots. EditorContext stashes it as saved_scene_revision on
every successful New/Open/Save; scene_is_dirty()
(applications/editor/source/core/editor_context.hpp) is just the inequality of the two, and
backs both the status bar’s * on the scene name and the close-confirm prompt below.
Ctrl+S and File ▸ Save Scene both go through save_current_scene()
(applications/editor/source/scene/scene_commands.*), which saves straight to scene_path if
set or opens the existing Save-As prompt if the scene has never been saved, so all three save
entry points (menu, shortcut, close-confirm) agree on when the scene becomes clean. Closing the
window (the title bar’s X or File ▸ Exit) sets EditorContext::close_requested instead of
exiting directly; the main loop’s draw_exit_confirm_modal lets the frame close immediately if
the scene is clean, otherwise prompts Save/Don’t Save/Cancel, deferring to the Save-As modal
(tracked by EditorContext::exit_after_save) when Save has no path yet.
Live simulation state reaches the renderer through the simulation seam
(engine/world/simulation/include/SushiEngine/simulation/simulation.hpp): ISimulation /
create_simulation(), plain C++ that names no runtime, SYCL, or ECS type — only the value types
from the value-type seam. The concrete world lives in one
compiled library, sushiengine_simulation (engine/world/simulation/), the single place device
code exists outside an example: it owns a SushiRuntime::API::Runtime, the Execution::Context
built over it, an ECS World, and a Schedule, and starts with no entities — every archetype is
pre-reserved up front so the editor’s own creates never trigger a mid-run chunk allocation, but
nothing is seeded into them.
Two systems over disjoint components (spin writes orientation, orbit writes position) are
registered for entities that carry SpinStep/OrbitState, which today only exist if authored
directly against World (no editor path attaches them); the dependency tracker still runs them
in parallel whenever such entities are present. Every value a kernel reads is precomputed on the
host into a component so the kernels are pure arithmetic that capture no host state — the
discipline that keeps them legal device code (see
the ECS and the system graph).
This is dependency inversion at the largest seam in the engine: the editor links
sushiengine_simulation and depends only on ISimulation, so the runtime, SYCL, and ECS never
enter the editor’s translation units, and a different world backend (or a headless stub) can
replace it without the editor changing. Because the editor links a SYCL library, its final link
is SYCL-aware and it ships the runtime DLL — the plain-toolchain lane is held by sandbox and
sushiengine_render_probe, not the editor.
Each tick() runs the schedule and an extract pass reads the world’s shared-USM columns back
on the host (via World::get) into a read-only RenderScene (RenderInstance — an EntityId +
transform + colour — and the resolved cameras) the editor draws. Cameras are ECS entities too (a
CameraParameters column: lens plus a display_index/priority/active routing), posed by their
transform; the extract picks, per display, the active camera with the highest priority into
RenderScene::display_cameras, and the Game view chooses which display it shows so two cameras
never conflict. RenderScene::has_camera reports whether any camera resolved at all;
create_simulation() seeds no demo entities — the live world starts empty, and
default_camera() exists only to give RenderScene::camera a well-formed value when
has_camera is false, not as something the Game view renders through.
GameViewRenderPolicy::should_render
(applications/editor/source/core/game_view_render_policy.hpp) is the one place that gates a
render pass on has_active_camera && has_display; when it says no, the Game window still opens
(it no longer skips ImGui::Begin and disappear) and shows a centered “No cameras rendering”
placeholder plus its toolbar instead of a render, via ViewportPanel::draw_no_camera — a method
on the same panel object as the render path, so the fullscreen state machine
(apply_fullscreen_transition) is one implementation instead of a member copy and a
function-static copy that could disagree about the dock slot to restore.
That toolbar — an aspect/resolution preset, a Landscape/Portrait orientation combo, and a
Fullscreen checkbox, held in EditorContext::game_view_settings (GameViewSettings,
engine/world/authoring/include/SushiEngine/authoring/game_view_settings.hpp) — is shared
between the no-camera placeholder and the normal render path so the row never drifts into two
implementations; when a preset constrains the aspect, ViewportPanel::draw
letterboxes/pillarboxes the rendered image within the panel instead of stretching it to the
panel’s shape, independently of Fullscreen — which instead undocks the panel and expands it to
cover the whole editor viewport (Unity’s “Maximize on Play”), restoring its dock slot when
unchecked.
The Scene view authors the world (pick, gizmo) and is the only place a selection is drawn
highlighted; the Game view is played, not authored, so it neither picks nor receives the Scene
selection. The editor ticks only while the toolbar is Playing (or on a one-shot step_requested,
set by the toolbar’s Step button and cleared every frame), binding the existing PlayState.
Pressing Play captures the scene into EditorContext::play_mode_snapshot via capture_scene;
pressing Stop re-applies it via apply_scene and clears the snapshot, so play-mode mutations
(spawns, destroys, transform/physics edits) never leak into the edited scene, mirroring Unity’s
edit/play-mode separation — this reuses the same capture_scene/apply_scene round-trip
CommandHistory already relies on for undo/redo, rather than a second snapshot mechanism. The
extract is a host copy today. A later interop milestone promotes it to a device-shared sink
pinned to a render thread, so the scheduler can overlap the next step’s simulation with the
current step’s draw and skip the round-trip.
Eight ECS components — Transform, Orientation, SpinStep, OrbitState, SurfaceHeading,
Tint, Room and Portal — are declared together in
engine/world/simulation/include/SushiEngine/simulation/components.hpp rather than inline in
engine/world/simulation/source/runtime_simulation.cpp, so a consumer includes a header instead
of reaching into a translation unit. That header is not the whole component set: the components
added since carry their own header in the same directory, among them
engine/world/simulation/include/SushiEngine/simulation/mesh_filter.hpp,
engine/world/simulation/include/SushiEngine/simulation/mesh_renderer.hpp,
engine/world/simulation/include/SushiEngine/simulation/material_slot.hpp and
engine/world/simulation/include/SushiEngine/simulation/collider.hpp.
Transform + Orientation are mandatory on every entity; Tint (the Renderer component) and Camera
are independently pluggable per entity — Unity-style add/remove — through
IWorldEditor::set_has_component on MeshRendererStorage and on CameraParameters
(distinct from the create_camera recipe, which spawns a fresh camera entity). A toggle is one
World::add_component or World::remove_component, moving the entity to the archetype matching
its new component set
and copies every column the two signatures share (RuntimeSimulation::migrate_components). The
entity handle survives, and so does every component the toggle does not name. Seeded, animated
demo cubes (SpinStep/OrbitState) are exempt — their component set is fixed for the demo.
The world is the single source of truth for entities — there is no separate editor-side scene
model. The editor reads and writes it through IWorldEditor, split from ISimulation so a panel
that only inspects or edits depends on the narrow surface (interface segregation): entities are
addressed by a stable EntityId, queried (entities, name, transform, color, visible,
has_renderer, is_camera) and mutated (create, destroy, set_name, set_transform,
set_color, set_visible, set_has_renderer, set_is_camera). Transform, colour, and the
Camera lens are real ECS components the surface writes through; names, visibility, and parenting
are host-side editor metadata the simulation keeps beside each entity’s handle
(parent/set_parent). Editor-created entities carry no motion components, so the spin/orbit
systems never match them and they stay authorable while the world plays — only the seeded demo
cubes are system-driven.
The Hierarchy renders these entities as a tree (drag-and-drop reparents; dropping on empty space
unparents to root), guarded against cycles by walking the candidate parent’s own ancestor chain
before accepting a drop, and the Inspector edits the selection — including, for Camera and
Renderer, an “x” on the header to detach the component and an “Add Component” menu offering
whichever is missing; the editor GUI goes through Dear ImGui. EditorContext splits selection in
two: selected_entity is the single “primary” target the Inspector, viewport gizmo, and
Align/Move-to-View act on, while selected_entities is the Hierarchy’s full multi-selection
(Ctrl+click toggles membership; Shift+click ranges from selection_anchor — the last plain or
Ctrl click — over the tree’s depth-first display order, or the filtered order when a search
filter narrows the list). A plain click collapses both back to one entity
(select_only/toggle_selected/is_selected in
applications/editor/source/core/editor_context.hpp); Delete acts on the whole vector.
Because parenting is host metadata rather than an ECS Parent component, both the extract pass
and a reparent walk the parent chain on the host (RuntimeSimulation::world_transform, bounded
by the live entity count against a corrupt chain) rather than in a kernel — the same
host-copy-first posture as extract itself, revisited only if parenting needs to affect systems
running on the device. World pose is composed as a shear-free hierarchical TRS chain rather than
a general Matrix4 product (world_scale = parent_scale * local_scale,
world_rotation = parent_rotation * local_rotation,
world_position = parent_position + parent_rotation ∘ (parent_scale * local_position), matching
Unity’s model) precisely because that form is invertible: set_parent uses the inverse to
recompute the child’s local transform at the moment of reparenting, so its resolved world-space
pose is unchanged by the move rather than being reinterpreted (and visibly jumping) in the new
parent’s space.
1.1. The render graph
ISceneView’s Vulkan implementation is not a sequence of hand-recorded passes but a frame
graph. Each frame the scene view builds a Frame::FrameContext — camera, extent, quality tier,
draw list, and the handles of this frame’s targets — and asks each pass to register itself; the
graph then derives everything that used to be written by hand.
The quality tier does not reach the passes raw. Once per frame the scene view runs
resolve_quality (engine/presentation/render/source/frame/quality.cpp, public type
QualityParameters), which turns RenderQuality into the concrete parameters passes actually
read — soft-shadow tap counts, contact-march length, cloud budget, the coarsest variable-rate
tile, the shadow atlas size and cascade count, and which advanced BRDF lobes are evaluated. The
policy lives in that one file so the tier cannot mean one thing in the shadow pass and another in
the cloud pass; a pass reads resolved parameters, never the enum. The authored settings are the
High baseline, so High resolves to the request verbatim and a lower tier scales the expensive
half down from it.
-
engine/presentation/render/source/graph/—RenderGraph,RenderPassBuilder,PassContext,TextureHandle/BufferHandle, and the access vocabulary. A pass declares what it touches (read/write/color_attachment/depth_stencil_attachment) and the graph derives how:resource_state.*maps each declared access to exactly one (stage, access mask, layout) triple, so every barrier between passes and everyvkCmdBeginRenderingscope — viewport and scissor included — is generated, never authored. §1.3 names the images that still barrier by hand.compile()culls passes whose outputs nothing reads, then walks the schedule assigning physical resources: a transient is returned to its pool the moment its last reader is scheduled, so two disjoint lifetimes land on one allocation. That is the graph’s memory aliasing.pass_capture.*is the graph’s debug instrument, absent unless something attaches it: it copies out every texture a pass wrote and hashes it on the host, so a golden mismatch can name the pass rather than only the frame. It lives inside the graph because only the graph tracks image layouts across passes — a capture outside it would have to restore what it changed, while one inside simply records it. -
engine/presentation/render/source/resources/—TexturePool/BufferPool(the physical backing, one set per frame slot so a pool never hands this frame a resource the previous frame’s submit is still reading),DescriptorAllocator(per-slot pools reset wholesale, so a resize rebuilds no descriptor set),DescriptorHeap(the bindless update-after-bind array bound as set 1),PipelineCache(aVkPipelineCachepersisted to disk) andGraphicsPipelineFactory(four independently cachedVK_EXT_graphics_pipeline_libraryhalves, with monolithic creation as the fallback when the extension is absent),SamplerCache, andShaderLibrary.A pipeline never makes a pass wait on its own best version:
GraphicsPipelineFactoryhands out aPipelineHandlepointing at the fast-linked (GPL) pipeline the instant it exists, while a background thread rebuilds the same pipeline monolithically and swaps the handle’s atomic pointer (release/acquire) once it is ready; the superseded pipeline retires after a delay sized past every view sharing the factory’s clock, the same reasoningTextureLibrary’s streaming retirement below uses.DescriptorWriterandbind_descriptor_set()are the matching write/bind seam every pass, the compute/RT passes, and the scene layout route through, so the announcedVK_EXT_descriptor_heaplands as a swap behind these two functions rather than a sweep of every pass. -
engine/presentation/render/source/passes/— the passes, one class per file, each implementing the sameIRenderPass::register_pass(graph, frame)contract and owning only its own pipelines; the seam itself (engine/presentation/render/source/passes/render_pass.hpp) and four shared helpers sit beside them. The frame-order table names 71 scene passes, and a lit frame walks a subset of them: the environment capture, the opaque geometry pass, the shading-rate mask, the sky pass, the star field, the cloudscape field/light-volume/shadow-map bakes, the amortized half-resolution cloud march, the cloud composite, the temporal resolve, the post-processing stack (depth of field, motion blur, auto-exposure metering, and bloom), the display transform, the spatial anti-aliasing filter, and the picking readback. Adding an effect is adding one of these with its descriptor, one line in a family composer and one row in the frame-order table (§1.2); no neighbouring pass changes. A pass that this frame’s settings do not call for registers nothing, so the chain reconfigures without any pass learning what the others do. -
The post-processing stack runs after the temporal resolve:
DOFPassandMotionBlurPass(gather-based, tier-gated) each hand their output to the next stage;AutoExposurePassbuilds a luminance histogram the scene view reads back to adapt the exposure;BloomPassbuilds a Karis-averaged mip pyramid into a half-resolution target; andTonemapPassis the single display transform that applies the resolved exposure, a colour grade, one of three tone curves (AgX / ACES / Khronos Neutral), the lens effects, and a gamma 2.2 encode (engine/presentation/render/shaders/tonemap.frag,pow(1/2.2), not the sRGB curve) with a blue-noise dither. All of them read onePostProcessUniformsblock (scene-set binding 31) the scene view fills fromRenderSettings::post, which the editor’s Post Process window authors — the passes never name the editor.The exposure the transform actually applied leaves the renderer on
RenderFrameStatistics, asapplied_exposurebesidemetered_luminanceandexposure_metering_floored. It is reported rather than only spent because anything calibrated against a display level — the star field is the standing case — is calibrated against that number, and the authored setting is not it. The floored flag is the one that decides an argument: on the histogram’s internal 1e-4 luminance floor the exposure is the pass’s ceiling rather than a measurement, so no auto-exposure setting will brighten the frame further.The star field goes further than reading this number:
FrameContext::applied_exposurepublishes it to every pass, not only the Scene view, andStarPassis the one consumer that spends it — it divides it back out of the star field’s written radiance so the field reaches its calibrated display brightness at whatever exposure the frame actually lands on, rather than only at the one exposure its anchors happened to be stated at. A star field left to assume a typical exposure was invisible at the low end of what auto-exposure legitimately reaches and nine times overexposed at the ceiling; cancelling the real value is what removed the assumption instead of retuning it. -
The GPU-driven geometry path (Phase 10) replaces the CPU’s one-draw-per-instance loop with two device buffers and a cull dispatch, so the CPU cost is flat in the number of distinct meshes rather than the number of instances.
InstanceSystem(engine/presentation/render/source/scene/) packs every opaque mesh instance into a per-frameGPUInstancestorage-buffer record — camera-relative transform, bounding sphere, and the material/motion/pick indices the classic draw used to push — and groups them by mesh into per-mesh buckets, one host-mapped buffer per frame slot in the exact shapeMaterialSystemandMotionSystemalready use.CullPassthen runs before the depth prepass: one thread per instance tests the bounding sphere against the view frustum, its own on-screen diameter (a screen-coverage LOD gate that drops instances too small to matter), and the occlusion pyramid, then compacts the survivors per bucket and writes oneVkDrawIndexedIndirectCommandper bucket whose instance count it decides — no CPU readback in the loop (a survivor counter is read back one frame late only for the editor’s cull statistics).OcclusionPassowns that pyramid: a persistent max-Z (farthest-depth) mip chain built after the depth prepass, the conservative twin of theHiZPassnearest-depth pyramid the SSR trace marches (nearest is right for reflections and wrong for culling). It lives outside the render graph and is read at the start of the next frame by the cull — reprojected with the previous view-projection and the eye delta — so an instance is tested against the depth the last frame actually rendered; a freshly (re)created image clears to “far” so nothing occludes until real depth lands, which self-corrects with no popping and no readback.The draw itself runs through
engine/presentation/render/shaders/mesh_gpu.vert, the twin ofengine/presentation/render/shaders/mesh.vert: it reads the model matrix, material index, and picking id from the instance record (an indirect draw carries no push constant), indexing the cull pass’s compacted survivor list from the one value still pushed per bucket — the bucket’s base into that list. Its instance and compacted buffers ride a set-2 descriptor set (SceneLayout::INSTANCE_SET), so the bindless heap keeps set 1 and both vertex shaders feed the sameengine/presentation/render/shaders/pbr.frag.The whole path is two-path: the scene view takes the GPU-driven route when the tier permits it (
QualityParameters::gpu_driven— off on Low, on for Medium/High/Ultra), the author has leftGPUCullingSettings::enabledon, the bindless heap is present, and nothing is selected; anything else falls back to the classic CPU per-instance draw (a selection keeps it so the outline’s stencil mask still works), while the cull machinery stays primed so the pyramid remains fresh for when it resumes. The editor’s GPU Culling window authorsRenderSettings::gpu_culling— enable, frustum, occlusion, min-screen-diameter, a debug frustum freeze, and the per-frame statistics — and, as with post-processing, no pass names the editor. -
Shadows beyond the atlas (Phase 12.3). The punctual shadow atlas holds a fixed number of tiles, and a light that does not fit one used to shade unshadowed. It now gets shadowed stochastically instead:
engine/presentation/render/shaders/clustered_lighting.glslsplits a cluster’s lights into those with a tile (filtered against it as before) and those without, importance-samples a tier-scaled few of the latter per pixel, and marches the GI distance clipmap toward each withsdf_visibility(), weighting by one over the probability it was picked. Because that estimator is unbiased, the temporal resolve is the denoiser — no new pass, no new history.The field is the one
SDFProbeTraceralready builds for probe GI, offered throughIProbeTracer::visibility_field()so the shading pass depends on a field existing and never on which tier produced it; it reaches every shading pipeline through the bindless heap’s volume array, because the per-frame push set is full at its guaranteed 32 bindings. The consequence worth stating plainly: the number of shadowed lights stops being a memory budget and becomes a sample budget. -
Two queues, one schedule (Phase 11). The graph does not assume a single queue. A pass may declare
PassQueue::AsyncCompute, andcompile()splits the schedule wherever the queue changes intoSubmissions — one command buffer each, recorded and submitted in order. What orders them is derived, like the barriers, from the resource declarations: a submission waits on the latest earlier submission on the other queue that produced what it consumes or consumed what it overwrites (a dependency on its own queue is already ordered by submission order, and per-queue timeline values rise monotonically, so one wait covers every earlier one).The same walk marks which resources both queues touch, and only those are allocated with concurrent sharing (
TextureDescription::cross_queue) — the graph cannot transfer queue-family ownership, and paying for concurrent sharing on every transient would cost attachment compression for nothing. Two conditions a flagging pass owes the graph: everything it produces must be declared (a pass that hand-barriers a resource it owns would leave its consumers unsynchronised), and what it shares must be a graph transient rather than an import, whose sharing mode the graph cannot change. Flagged today: the clustered light cull and the GTAO horizon march. Gated three ways — a compute queue family distinct from graphics,QualityParameters::async_compute(off on Low), andFrameDeliverySettings::async_compute— and with any of them absent every pass records on the graphics queue exactly as before. -
Frame delivery (
ViewResources). Each queue carries one monotonic timeline semaphore: every submission signals its own value, waits on the value it depends on, and a frame slot is reusable once both timelines have reached what that slot submitted. Command buffers are handed out per submission, not per slot, so a frame that compiles to several submissions never records two of them into one buffer. How far the CPU may run ahead (FrameDeliverySettings::frames_in_flight, 2 or 3) and how frames are paced onto the display (PresentMode, applied throughIWindowRenderer::set_present_mode) are settings; all slots are allocated up front, so changing depth costs an idle and no reallocation. The editor’s Frame Delivery section authors the block. -
Reconstruction is an interface (
engine/presentation/render/source/frame/upscaler.hpp). Rendering below the output extent and reconstructing back up is a contract — colour, depth, motion, history, jitter, exposure, and the two extents — thatTAAPassis simply the first implementation of. A vendor upscaler (FSR/DLSS/XeSS) lands as another implementation rather than as a fork of the frame loop;Frame::upscaler_availabilityreports which backends this build carries, and one it does not resolves back to the built-in temporal reconstruction with the reason surfaced in the editor. -
engine/presentation/render/source/interop/— a device-local buffer whose memory another API can import by OS handle: a dedicated, exportable allocation stamped with the device UUID thatRenderDeviceDescription::required_uuidalready selects the graphics device by, exposed through the publicengine/presentation/render/include/SushiEngine/render/interop.hppwith no Vulkan, SYCL, or platform type in sight. The renderer exports only; importing belongs to whoever owns the other API, which for SushiRuntime means the runtime — the dependency points one way. -
engine/presentation/render/source/scene/,engine/presentation/render/source/geometry/,engine/presentation/render/source/textures/— the shared scene uniform block and descriptor/pipeline layout, the built-in unit meshes and the per-slot instance buffers, and the cloud noise volumes. The noise set (two Perlin-Worley volumes, an anisotropic cirrus volume, a weather map, and the view march’s precombined carve volume) is generated by compute dispatches at bring-up rather than on a CPU thread pool.The carve volume alone carries a mip chain, box-filtered by a compute pass so it wraps the way the sampler does, because it is the only one sampled at a world scale fixed in metres by a march whose step grows with distance — and the same bring-up submit reads its finest level back so the host can measure, per level, the variance the filter removed. The march needs that number: its coverage threshold is a percentile of a field that is uniform only before filtering, so a coarse fetch has to be thresholded against the spread it lost rather than against its mean.
Two supporting mechanisms make the graph usable day to day. GPUProfiler brackets every executed
pass with timestamp queries and resolves a slot’s results at the point its fence has already been
waited on, so per-pass GPU times cost no stall. Its pools are per queue as well as per frame
slot, each reset by the first command buffer recorded onto its own queue: a reset orders only
against writes on the same queue, and the graph orders the two queues against each other only
where it found a data dependency, so one shared pool would let a reset land after the other
queue’s timestamps and leave the frame unreadable.
Two things then guard against publishing a reading that has stopped moving. A readback may
decline — completion does not oblige a driver to have published its results — so
ISceneView::pass_timing_age() says how many rendered frames back the standing numbers came
from, and both panels dim and label such a row. And the main loop copies a viewport’s breakdown
only on frames it rendered, since a panel that is open but is not its dock node’s selected tab
does not render. That is what lets every later pass be landed against a measured budget.
ShaderLibrary ships build-time SPIR-V but also watches engine/presentation/render/shaders/
when it exists: an edited shader is recompiled in process with glslang, the device is idled, and
every pipeline is rebuilt — a compile error leaves the previous module in place and reports on
stderr.
VulkanSceneView is left as the orchestrator: build the frame context, register the passes,
compile, submit. It records no barrier and opens no render pass of its own. The precision
invariants are unchanged and inherited by every pass — camera-relative rendering (the eye
subtracted in double before the float cast) and reverse-Z.
1.2. Frame assembly
VulkanSceneView owns no pass as a member. Its constructor calls compose_scene_passes
(engine/presentation/render/source/composition/scene_pass_composer.cpp), which runs five family
composers, one file each: sky, lighting, geometry, water and post (compose_sky.cpp and its
siblings). A family constructs its passes into a Composition::PassRegistry with
add<Pass>(...); the registry owns each pass and records the PassDescriptor the pass publishes:
its name, the passes it must follow, and a predicate that decides whether this frame runs it. A
pass that a later family needs by type is reachable through ScenePassLinks, one non-owning
pointer per such pass.
Construction order is not execution order. A pass is built when its family runs, because a
sibling may take it as a constructor argument. After the last family, the composer seals the
registry with frame_order() (composition/frame_order.cpp), a table naming every scene pass
once. The seal hands it to resolve_pass_order (composition/pass_order.cpp): among the passes
whose predecessors are placed, the one with the earliest row runs first. A pass missing from the
table, a row naming no pass, a duplicate name or a cycle ends the process with the name. Why each
row sits where it does is in the render module README, section “Frame order”
(engine/presentation/render/README.md).
Each frame PassRegistry::register_frame asks every predicate once, lets every enabled
ITargetOwner declare its targets, then calls register_pass on every enabled pass in the
sealed order. The graph’s pass, texture and buffer nodes are pooled and reset in place, pass
names are static const char*, the setup callable is a Core::FunctionRef and the execute
callable a Core::InlineFunction of 240 bytes, so building the graph does not allocate once the
pools have grown.
1.3. Resource ownership
A frame’s targets come from three owners.
- Frame-wide. A target two or more passes read, or one the view keeps across frames, is
declared by
ViewResources::declare_targetsthrough onedeclare_*member each inengine/presentation/render/source/rhi/vulkan/frame_target_set.cpp, and reaches every pass inFrame::FrameTargets. - Pass-owned. A target one pass reads belongs to that pass, which implements
Composition::ITargetOwnerand creates or imports it indeclare_targets, before any pass registers. - The post chain.
Composition::TargetDeclaration::post_inputis a cursor over the post chain’s colour. The view seeds it withpost_chain_base; each enabled link reads it and replaces it with its own output, so a disabled link drops out without a neighbour naming it.
An image that outlives the frame is a Resources::PersistentImage
(engine/presentation/render/source/resources/persistent_image.hpp): it owns the image, its
views, one view per mip and the state the graph tracks across frames, is move-only, and enters
each frame through import_into. Resources::HistoryPair owns two of them for an accumulation
and imports the write and read halves by frame parity. Images read through the bindless heap or
the shared scene set, and images the host reads, still barrier by hand; the module README lists
them under “Images that still barrier by hand”.

