Contents

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. DeviceInformation exposes the physical device’s UUID — the key a later milestone matches against SushiRuntime’s SYCL device for zero-copy interop. RenderDeviceDescription carries a SurfaceFactory hook 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 is engine/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 an ISceneView slot’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 to Vulkan::FramePresenter (engine/presentation/render/source/rhi/vulkan/frame_presenter.hpp), which queues them and presents each in turn from its own thread, spaced by Frame::FramePacer’s deadline for that frame, so two presents that would otherwise land in the same refresh interval do not.

    A WindowRendererDescription with no surface_factory (PLATFORM0 S6) builds no swapchain at all: begin_frame/present_scene_view/end_frame become well-defined no-ops, and a host still gets a working device, asset library, and create_scene_view() — the offscreen-render, no-window shape se player --headless uses for CI.

    A queue mutex, forced by the presenter thread. VkQueue is 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 taking queue_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’ SLOTS frame slots (engine/presentation/render/source/rhi/vulkan/view_resources.hpp) owns PRESENTED_PER_SLOT presentable images beyond its resolve — the composited real frame plus MAX_GENERATED_FRAMES generated ones — and all of them exist from startup, unconditionally: at SLOTS = 3 and MAX_GENERATED_FRAMES = 3, that is 12 extra RESOLVE_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 live generated_frames setting 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_FRAMES is 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 by sushiengine_render_probe to validate the pipeline in CI. A second, independent headless path is IWindowRenderer itself with no surface (above) — the one se player --headless uses, since it needs the same create_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 by IWindowRenderer::create_scene_view()): an offscreen camera view of a MeshInstance set (each tagged with a MeshKind — Box, Sphere, or Cylinder — plus per-kind shape params, or an imported mesh instead of a primitive when its mesh field is set, docs/archive/design/STATIC_MESH_AUTHORING.md) plus a ground grid, drawn from a CameraView. The Vulkan implementation (engine/presentation/render/source/rhi/vulkan/vulkan_scene_view.*) rotates through ViewResources::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 with ImGui::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_UINT id target carrying each instance’s picking id, copied to a host buffer each frame so pick(x, y) resolves a click to the entity under the cursor (GPU id-buffer picking); render also 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). render also takes a const 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 every vkCmdBeginRendering scope — 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 (a VkPipelineCache persisted to disk) and GraphicsPipelineFactory (four independently cached VK_EXT_graphics_pipeline_library halves, with monolithic creation as the fallback when the extension is absent), SamplerCache, and ShaderLibrary.

    A pipeline never makes a pass wait on its own best version: GraphicsPipelineFactory hands out a PipelineHandle pointing 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 reasoning TextureLibrary’s streaming retirement below uses. DescriptorWriter and bind_descriptor_set() are the matching write/bind seam every pass, the compute/RT passes, and the scene layout route through, so the announced VK_EXT_descriptor_heap lands 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 same IRenderPass::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: DOFPass and MotionBlurPass (gather-based, tier-gated) each hand their output to the next stage; AutoExposurePass builds a luminance histogram the scene view reads back to adapt the exposure; BloomPass builds a Karis-averaged mip pyramid into a half-resolution target; and TonemapPass is 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 one PostProcessUniforms block (scene-set binding 31) the scene view fills from RenderSettings::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, as applied_exposure beside metered_luminance and exposure_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_exposure publishes it to every pass, not only the Scene view, and StarPass is 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-frame GPUInstance storage-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 shape MaterialSystem and MotionSystem already use.

    CullPass then 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 one VkDrawIndexedIndirectCommand per 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).

    OcclusionPass owns that pyramid: a persistent max-Z (farthest-depth) mip chain built after the depth prepass, the conservative twin of the HiZPass nearest-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 of engine/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 same engine/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 left GPUCullingSettings::enabled on, 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 authors RenderSettings::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.glsl splits 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 with sdf_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 SDFProbeTracer already builds for probe GI, offered through IProbeTracer::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, and compile() splits the schedule wherever the queue changes into Submissions — 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), and FrameDeliverySettings::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 through IWindowRenderer::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 — that TAAPass is 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_availability reports 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 that RenderDeviceDescription::required_uuid already selects the graphics device by, exposed through the public engine/presentation/render/include/SushiEngine/render/interop.hpp with 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_targets through one declare_* member each in engine/presentation/render/source/rhi/vulkan/frame_target_set.cpp, and reaches every pass in Frame::FrameTargets.
  • Pass-owned. A target one pass reads belongs to that pass, which implements Composition::ITargetOwner and creates or imports it in declare_targets, before any pass registers.
  • The post chain. Composition::TargetDeclaration::post_input is a cursor over the post chain’s colour. The view seeds it with post_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”.