Standalone renderer layer: FrameData/DrawItem submission, scene and canvas as clients #18

Open
opened 2026-09-26 03:14:15 -07:00 by burkkyy · 0 comments
Owner

Goal

Make the renderer its own layer (a small "render engine") that builds and executes a render graph from plain per-frame data. Scene, Canvas and UI become clients of it; the renderer knows nothing about them.

Test for done-ness of the design: a different scene system (or none at all) could be built on top without changing the renderer.

Layers

┌──────────────────────────── Application ────────────────────────────┐
│ window, main loop: update → extract → renderer.render(frame)        │
├───────────── clients (know about the renderer) ─────────────────────┤
│ Scene / ECS / components   Canvas   UI plugin (ImGui)   (tilemap…)  │
│ SceneView, SceneViewRegistry, extractScene, extractCanvas, ImGuiPass│
├───────────── Renderer (knows nothing above) ────────────────────────┤
│ vocabulary: FrameData, RenderView, DrawItem, RenderFeature, handles │
│ Renderer, RenderGraph, drawers (Sprite/Model), ResourceRegistry     │
│ (Texture, Mesh, RenderTarget), RenderContext, Device, Swapchain     │
└─────────────────────────────────────────────────────────────────────┘

Renderer vocabulary

  • FrameData: this frame's RenderViews. Rebuilt every frame.
  • RenderView: target (invalid = backbuffer), viewProjection, clear color, DrawItems, and typed instance lists (e.g. sprite instances). Scene-level SceneView (scene + camera entity + target) is turned into one of these by extraction.
  • DrawItem: u64 sortKey, PipelineId, resource handles (texture, mesh), instance offset. Handles only, never pointers; lives one frame.
  • Drawers (*Renderer): one per PipelineId, owning its shaders, pipeline, descriptor layout, instance format and per-frame buffers; draws already-sorted batches. The pipeline set stays closed; variation (e.g. blend mode) goes in sort-key sub-bits, not new generic state.
  • Passes: render graph passes. RenderGraph is the pass representation; don't add a parallel RenderPass/DrawPacket data struct that duplicates its passes and access declarations.
  • RenderFeature: the extension point for anything that isn't a DrawItem: user shaders, compute, post-processing, the ImGui composite. A feature adds passes to the graph each frame. It gets a RenderGraph& only during addPasses() and never stores it. Features are ordered by phase (e.g. Scene, PostProcess, Overlay), not registration order.
  • Feature vs plugin: a feature plugs into the renderer's graph build. A plugin is an engine-level module that can touch several systems (input, console commands, update loop, renderer) and may register features. ImGui is a plugin that registers the ImGuiPass feature.

Ownership and frame lifecycle

  • Renderer owns RenderContext and RenderGraph. The graph is a member, reset() at the start of each frame. It keeps its allocations and gives persistent graph state (transient image/buffer pools, cached barrier plans, aliasing) a place to live later. Per-frame semantics are unchanged: rebuilt every frame.
  • No command buffer in the application API. Acquire/record/submit/present is renderer-internal. The only place a vk::CommandBuffer appears is the graph's ExecuteFn, which is the recording layer. The loop becomes:
    app.onUpdate(frameInfo);
    ui.beginFrame();           // ImGui NewFrame, CPU only
    app.onRender();            // clients extract into FrameData, CPU only
    renderer.render(frame);    // acquire → per-frame setup → build graph → execute → present
    
  • ResourceRegistry is a sibling of the renderer, not owned by it. The runtime owns it and the renderer takes it by reference (as in the sketch below). User code creates meshes, textures and targets through ResourceRegistryRef, and asset loading or headless tools shouldn't need a swapchain. Renderer-side, per-handle GPU state (descriptor caches such as SpriteRenderSystem::descriptorSets, ImGuiRenderer::textures) belongs to the features/drawers that use it. Lifetime: Device → ResourceRegistry → Renderer, torn down in reverse after waitIdle().
  • Decouple render target format from the swapchain. ResourceRegistry{device, renderContext.getImageFormat()} is what makes the registry look renderer-owned. Make the format a per-target creation parameter (or engine-config default) so the registry depends only on Device.

Interfaces (sketch)

// Vulkan-free header shared by clients and renderer.
struct DrawItem {
  u64 sortKey;
  PipelineId pipeline;         // Sprite, Mesh, ...
  TextureHandle texture;
  MeshHandle mesh;
  u32 instanceOffset;
};

struct RenderView {
  RenderTargetHandle target;   // invalid = backbuffer
  glm::mat4 viewProjection{1.f};
  ClearColor clearColor;
  std::vector<DrawItem> items;
  std::vector<SpriteInstance> spriteInstances;
};

struct FrameData {
  std::vector<RenderView> views;
  RenderView& addView(RenderTargetHandle target, ClearColor clearColor);
};

// Client side (next to what they read, e.g. scene/, canvas.cpp).
void extractScene(const Scene& scene, Entity camera, RenderView& out);
void extractCanvas(const Canvas& canvas, RenderView& out);

// Renderer side.
enum class RenderPhase { Scene, PostProcess, Overlay };

class RenderFeature {
 public:
  virtual ~RenderFeature() = default;
  virtual RenderPhase phase() const = 0;
  virtual void beginFrame(u32 frameIndex) {}   // after the frame's fence wait
  virtual void frameSkipped() {}               // swapchain not acquired this frame
  virtual void addPasses(RenderGraph& graph, FrameContext& frame) = 0;
};

// Per-frame context handed to features.
class FrameContext {
 public:
  ImageRef backbuffer() const;
  ImageRef importTarget(RenderTargetHandle target);  // imported once per frame
  u32 frameIndex() const;
};

class Renderer {
 public:
  Renderer(Window& window, Device& device, ResourceRegistry& resources);  // owns RenderContext
  void addFeature(std::unique_ptr<RenderFeature> feature);
  void render(const FrameData& frame);  // no-op if the swapchain can't be acquired
  vk::Format colorFormat() const;
};

Renderer::render: acquire the frame (return early, calling frameSkipped(), on resize/minimize); call each feature's beginFrame; graph.reset(); import the backbuffer; per view sort items by key, merge neighbours into batches, upload instance data, and add one graph pass per view (dispatching batches to drawers); let features add passes in phase order (UI composite is Overlay, so last); execute; present.

Where current code goes

  • Renderer layer: Device, RenderContext (owned by Renderer), Swapchain, RenderGraph (owned by Renderer), ResourceRegistry + pools (sibling, by reference), SpriteRenderSystem, ModelRenderSystem.
  • Client layer: Scene, Entity, components, SceneView, SceneViewRegistry, Canvas.
  • Straddling, to split:
    • SceneRenderer → extractScene (client) + generic sort/batch in Renderer.
    • Application::Impl::run graph building and renderContext.beginFrame()/endFrame() → Renderer::render. The viewTargets map becomes FrameContext::importTarget.
    • handles.hpp mixes render handles (Texture/Mesh/RenderTarget) with client handles (Scene/Panel/SceneView); split so renderer headers don't declare scene types.
    • ImGuiRenderer splits in two:
      • UI plugin (client, owned by runtime/editor): ImGui context, GLFW input, NewFrame, dock layout, reset_layout command, getTextureId.
      • ImGuiPass : RenderFeature (Overlay phase): ImGui_ImplVulkan_RenderDrawData into the backbuffer, per-target descriptor cache. It should declare sample() only for targets whose getTextureId was called this frame, instead of the current "main" pass sampling every view target.

Plan

Each step should build and run on its own.

  1. Renderer owns RenderContext + RenderGraph and takes over frame acquisition and graph building from Application::run. Application calls renderer.render(frame) with no command buffer. ImGui can stay a hard-coded final pass for this step.
  2. Vulkan-free header for DrawItem / RenderView / FrameData / instance structs.
  3. Split SceneRenderer into extractScene + sort/batch in Renderer. Renames: SpriteRenderSystem → SpriteRenderer, ModelRenderSystem → ModelRenderer, SpriteDrawItem → DrawItem.
  4. Port Canvas to extractCanvas. As the second client, it validates that the API isn't shaped around Scene.
  5. sophia_renderer CMake target so the boundary is enforced by the build (renderer can't include scene headers); split handles.hpp. Decouple render target format from the swapchain format so ResourceRegistry depends only on Device.
  6. RenderFeature + FrameContext, with phase ordering. Split ImGuiRenderer into the UI plugin and ImGuiPass (first real feature, replaces the hard-coded UI pass).
  7. Later: user shaders and compute through features. Needs buffers in RenderGraph (BufferRef, importBuffer, PassBuilder::readBuffer/writeBuffer, buffer barriers), compute passes that skip beginRendering, and handle-based graphics/compute pipelines created from user SPIR-V (ideally with reflection for layouts).

Watch for

  • No entity / component / "camera entity" concepts in the renderer; it only sees matrices. Picking later: generic per-item u32 userData, interpreted by the client.
  • Don't generalise past sprites until the mesh path exists; the second real producer shapes the abstraction.
  • Skipped frames and ImGui: once NewFrame runs before acquisition, a skipped frame must still close the ImGui frame (ImGui::EndFrame()) or the next NewFrame asserts. That's what RenderFeature::frameSkipped() is for.
  • Per-frame buffer writes after the fence wait: instance-buffer uploads (sprites, drawCalls) belong inside render() after acquisition, not in onRender(). onRender() only collects CPU-side data; this also lets CPU work for frame N+1 overlap the GPU on frame N.
  • Arbitrary shaders don't reopen the DrawItem pipeline set. The closed set covers built-in drawers; custom pipelines come in through features.

Related

  • #14 (2D scene support) introduced SpriteRenderSystem / SceneRenderer, which this refactors.
## Goal Make the renderer its own layer (a small "render engine") that builds and executes a render graph from plain per-frame data. Scene, Canvas and UI become **clients** of it; the renderer knows nothing about them. Test for done-ness of the design: a different scene system (or none at all) could be built on top without changing the renderer. ## Layers ``` ┌──────────────────────────── Application ────────────────────────────┐ │ window, main loop: update → extract → renderer.render(frame) │ ├───────────── clients (know about the renderer) ─────────────────────┤ │ Scene / ECS / components Canvas UI plugin (ImGui) (tilemap…) │ │ SceneView, SceneViewRegistry, extractScene, extractCanvas, ImGuiPass│ ├───────────── Renderer (knows nothing above) ────────────────────────┤ │ vocabulary: FrameData, RenderView, DrawItem, RenderFeature, handles │ │ Renderer, RenderGraph, drawers (Sprite/Model), ResourceRegistry │ │ (Texture, Mesh, RenderTarget), RenderContext, Device, Swapchain │ └─────────────────────────────────────────────────────────────────────┘ ``` ## Renderer vocabulary - **FrameData**: this frame's `RenderView`s. Rebuilt every frame. - **RenderView**: target (invalid = backbuffer), `viewProjection`, clear color, `DrawItem`s, and typed instance lists (e.g. sprite instances). Scene-level `SceneView` (scene + camera entity + target) is turned into one of these by extraction. - **DrawItem**: `u64 sortKey`, `PipelineId`, resource handles (texture, mesh), instance offset. Handles only, never pointers; lives one frame. - **Drawers** (`*Renderer`): one per `PipelineId`, owning its shaders, pipeline, descriptor layout, instance format and per-frame buffers; draws already-sorted batches. The pipeline set stays closed; variation (e.g. blend mode) goes in sort-key sub-bits, not new generic state. - **Passes**: render graph passes. `RenderGraph` *is* the pass representation; don't add a parallel `RenderPass`/`DrawPacket` data struct that duplicates its passes and access declarations. - **RenderFeature**: the extension point for anything that isn't a `DrawItem`: user shaders, compute, post-processing, the ImGui composite. A feature adds passes to the graph each frame. It gets a `RenderGraph&` only during `addPasses()` and never stores it. Features are ordered by **phase** (e.g. `Scene`, `PostProcess`, `Overlay`), not registration order. - **Feature vs plugin**: a *feature* plugs into the renderer's graph build. A *plugin* is an engine-level module that can touch several systems (input, console commands, update loop, renderer) and may *register* features. ImGui is a plugin that registers the `ImGuiPass` feature. ## Ownership and frame lifecycle - **Renderer owns `RenderContext` and `RenderGraph`.** The graph is a member, `reset()` at the start of each frame. It keeps its allocations and gives persistent graph state (transient image/buffer pools, cached barrier plans, aliasing) a place to live later. Per-frame semantics are unchanged: rebuilt every frame. - **No command buffer in the application API.** Acquire/record/submit/present is renderer-internal. The only place a `vk::CommandBuffer` appears is the graph's `ExecuteFn`, which is the recording layer. The loop becomes: ```cpp app.onUpdate(frameInfo); ui.beginFrame(); // ImGui NewFrame, CPU only app.onRender(); // clients extract into FrameData, CPU only renderer.render(frame); // acquire → per-frame setup → build graph → execute → present ``` - **ResourceRegistry is a sibling of the renderer, not owned by it.** The runtime owns it and the renderer takes it by reference (as in the sketch below). User code creates meshes, textures and targets through `ResourceRegistryRef`, and asset loading or headless tools shouldn't need a swapchain. Renderer-side, per-handle GPU state (descriptor caches such as `SpriteRenderSystem::descriptorSets`, `ImGuiRenderer::textures`) belongs to the features/drawers that use it. Lifetime: `Device` → `ResourceRegistry` → `Renderer`, torn down in reverse after `waitIdle()`. - **Decouple render target format from the swapchain.** `ResourceRegistry{device, renderContext.getImageFormat()}` is what makes the registry look renderer-owned. Make the format a per-target creation parameter (or engine-config default) so the registry depends only on `Device`. ## Interfaces (sketch) ```cpp // Vulkan-free header shared by clients and renderer. struct DrawItem { u64 sortKey; PipelineId pipeline; // Sprite, Mesh, ... TextureHandle texture; MeshHandle mesh; u32 instanceOffset; }; struct RenderView { RenderTargetHandle target; // invalid = backbuffer glm::mat4 viewProjection{1.f}; ClearColor clearColor; std::vector<DrawItem> items; std::vector<SpriteInstance> spriteInstances; }; struct FrameData { std::vector<RenderView> views; RenderView& addView(RenderTargetHandle target, ClearColor clearColor); }; // Client side (next to what they read, e.g. scene/, canvas.cpp). void extractScene(const Scene& scene, Entity camera, RenderView& out); void extractCanvas(const Canvas& canvas, RenderView& out); // Renderer side. enum class RenderPhase { Scene, PostProcess, Overlay }; class RenderFeature { public: virtual ~RenderFeature() = default; virtual RenderPhase phase() const = 0; virtual void beginFrame(u32 frameIndex) {} // after the frame's fence wait virtual void frameSkipped() {} // swapchain not acquired this frame virtual void addPasses(RenderGraph& graph, FrameContext& frame) = 0; }; // Per-frame context handed to features. class FrameContext { public: ImageRef backbuffer() const; ImageRef importTarget(RenderTargetHandle target); // imported once per frame u32 frameIndex() const; }; class Renderer { public: Renderer(Window& window, Device& device, ResourceRegistry& resources); // owns RenderContext void addFeature(std::unique_ptr<RenderFeature> feature); void render(const FrameData& frame); // no-op if the swapchain can't be acquired vk::Format colorFormat() const; }; ``` `Renderer::render`: acquire the frame (return early, calling `frameSkipped()`, on resize/minimize); call each feature's `beginFrame`; `graph.reset()`; import the backbuffer; per view sort items by key, merge neighbours into batches, upload instance data, and add one graph pass per view (dispatching batches to drawers); let features add passes in phase order (UI composite is `Overlay`, so last); execute; present. ## Where current code goes - **Renderer layer**: `Device`, `RenderContext` (owned by `Renderer`), `Swapchain`, `RenderGraph` (owned by `Renderer`), `ResourceRegistry` + pools (sibling, by reference), `SpriteRenderSystem`, `ModelRenderSystem`. - **Client layer**: `Scene`, `Entity`, components, `SceneView`, `SceneViewRegistry`, `Canvas`. - **Straddling, to split**: - `SceneRenderer` → `extractScene` (client) + generic sort/batch in `Renderer`. - `Application::Impl::run` graph building *and* `renderContext.beginFrame()/endFrame()` → `Renderer::render`. The `viewTargets` map becomes `FrameContext::importTarget`. - `handles.hpp` mixes render handles (Texture/Mesh/RenderTarget) with client handles (Scene/Panel/SceneView); split so renderer headers don't declare scene types. - `ImGuiRenderer` splits in two: - **UI plugin** (client, owned by runtime/editor): ImGui context, GLFW input, `NewFrame`, dock layout, `reset_layout` command, `getTextureId`. - **`ImGuiPass : RenderFeature`** (`Overlay` phase): `ImGui_ImplVulkan_RenderDrawData` into the backbuffer, per-target descriptor cache. It should declare `sample()` only for targets whose `getTextureId` was called this frame, instead of the current "main" pass sampling *every* view target. ## Plan Each step should build and run on its own. 1. `Renderer` owns `RenderContext` + `RenderGraph` and takes over frame acquisition and graph building from `Application::run`. Application calls `renderer.render(frame)` with no command buffer. ImGui can stay a hard-coded final pass for this step. 2. Vulkan-free header for `DrawItem` / `RenderView` / `FrameData` / instance structs. 3. Split `SceneRenderer` into `extractScene` + sort/batch in `Renderer`. Renames: `SpriteRenderSystem` → `SpriteRenderer`, `ModelRenderSystem` → `ModelRenderer`, `SpriteDrawItem` → `DrawItem`. 4. Port `Canvas` to `extractCanvas`. As the second client, it validates that the API isn't shaped around `Scene`. 5. `sophia_renderer` CMake target so the boundary is enforced by the build (renderer can't include scene headers); split `handles.hpp`. Decouple render target format from the swapchain format so `ResourceRegistry` depends only on `Device`. 6. `RenderFeature` + `FrameContext`, with phase ordering. Split `ImGuiRenderer` into the UI plugin and `ImGuiPass` (first real feature, replaces the hard-coded UI pass). 7. Later: user shaders and compute through features. Needs buffers in `RenderGraph` (`BufferRef`, `importBuffer`, `PassBuilder::readBuffer/writeBuffer`, buffer barriers), compute passes that skip `beginRendering`, and handle-based graphics/compute pipelines created from user SPIR-V (ideally with reflection for layouts). ## Watch for - No entity / component / "camera entity" concepts in the renderer; it only sees matrices. Picking later: generic per-item `u32 userData`, interpreted by the client. - Don't generalise past sprites until the mesh path exists; the second real producer shapes the abstraction. - **Skipped frames and ImGui:** once `NewFrame` runs before acquisition, a skipped frame must still close the ImGui frame (`ImGui::EndFrame()`) or the next `NewFrame` asserts. That's what `RenderFeature::frameSkipped()` is for. - **Per-frame buffer writes after the fence wait:** instance-buffer uploads (sprites, `drawCalls`) belong inside `render()` after acquisition, not in `onRender()`. `onRender()` only collects CPU-side data; this also lets CPU work for frame N+1 overlap the GPU on frame N. - **Arbitrary shaders don't reopen the `DrawItem` pipeline set.** The closed set covers built-in drawers; custom pipelines come in through features. ## Related - #14 (2D scene support) introduced `SpriteRenderSystem` / `SceneRenderer`, which this refactors.
burkkyy added the enhancement label 2026-09-26 03:14:15 -07:00
burkkyy added this to the sophia project 2026-09-26 03:14:28 -07:00
burkkyy moved this to Todo in sophia on 2026-09-26 03:14:35 -07:00
burkkyy moved this to In Progress in sophia on 2026-10-02 20:13:19 -07:00
Sign in to join this conversation.