Skip to main content

khora_data/render/
frame_graph.rs

1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Per-frame render graph: agents enqueue declarative passes here, the engine
16//! drains the graph after the scheduler completes and submits the recorded
17//! command buffers in topological order.
18//!
19//! Lives in `khora-data` because it is a *data container* — there is no
20//! GORNA strategy to negotiate, no behavior to swap. The submission step
21//! (`submit_frame_graph`) is invoked directly by `EngineCore`.
22
23use std::collections::{HashMap, HashSet, VecDeque};
24use std::sync::{Arc, Mutex};
25
26use khora_core::renderer::api::command::CommandBufferId;
27use khora_core::renderer::GraphicsDevice;
28
29/// Logical resource handle in the frame graph — used for declaring reads/writes.
30///
31/// Resources here are *abstract*: the actual GPU views live in `FrameContext`
32/// (`ColorTarget`, `DepthTarget`, `ShadowAtlasView`). The graph only orders
33/// passes; it does not allocate or alias resources yet.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
35pub enum ResourceId {
36    /// Swapchain (or offscreen viewport) color attachment.
37    Color,
38    /// Frame depth attachment.
39    Depth,
40    /// Shadow atlas — written by `ShadowAgent`, read by `RenderAgent`.
41    ShadowAtlas,
42    /// Custom resource keyed by an opaque integer (plugin / future use).
43    Custom(u64),
44}
45
46/// Declarative description of a render pass.
47#[derive(Debug, Clone)]
48pub struct PassDescriptor {
49    /// Debug-friendly pass name.
50    pub name: &'static str,
51    /// Resources read by this pass.
52    pub reads: Vec<ResourceId>,
53    /// Resources written by this pass.
54    pub writes: Vec<ResourceId>,
55}
56
57impl PassDescriptor {
58    /// Builds a descriptor with the given name and no declared resources.
59    pub fn new(name: &'static str) -> Self {
60        Self {
61            name,
62            reads: Vec::new(),
63            writes: Vec::new(),
64        }
65    }
66
67    /// Adds a read dependency.
68    pub fn reads(mut self, id: ResourceId) -> Self {
69        self.reads.push(id);
70        self
71    }
72
73    /// Adds a write dependency.
74    pub fn writes(mut self, id: ResourceId) -> Self {
75        self.writes.push(id);
76        self
77    }
78}
79
80/// One pre-recorded pass: descriptor + finished command buffer.
81struct RecordedPass {
82    descriptor: PassDescriptor,
83    command_buffer: CommandBufferId,
84}
85
86/// A render pass an agent contributes for this frame.
87///
88/// Instead of locking the shared [`SharedFrameGraph`] to `add_pass` directly,
89/// a recording agent writes its contribution into a per-layer slot on its
90/// [`OutputDeck`](khora_core::lane::OutputDeck) ([`ScenePassSlot`],
91/// [`UiPassSlot`], [`OverlayPassSlot`]). The engine folds those slots into the
92/// graph in a fixed layer order (scene → ui → overlay) after the agent wave —
93/// preserving the previous insertion order while freeing the agents from the
94/// `Mutex<FrameGraph>`, so they can run concurrently.
95pub struct PassContribution {
96    /// The pass's resource read/write declaration.
97    pub descriptor: PassDescriptor,
98    /// The finished command buffer recorded for the pass.
99    pub command_buffer: CommandBufferId,
100}
101
102/// Deck slot carrying the main scene pass, written by `RenderAgent`.
103#[derive(Default)]
104pub struct ScenePassSlot(pub Option<PassContribution>);
105
106/// Deck slot carrying the UI pass, written by `UiAgent`.
107#[derive(Default)]
108pub struct UiPassSlot(pub Option<PassContribution>);
109
110/// Deck slot carrying the skybox / environment-background pass, written by
111/// `SkyboxAgent`. Folded in after the scene pass and before overlays, so the
112/// sky sits behind the geometry (depth-tested) and under the debug overlays.
113#[derive(Default)]
114pub struct SkyboxPassSlot(pub Option<PassContribution>);
115
116/// Deck slot carrying the overlay / debug-viz pass, written by `OverlayAgent`.
117#[derive(Default)]
118pub struct OverlayPassSlot(pub Option<PassContribution>);
119
120/// Per-frame collection of recorded passes.
121///
122/// Agents append passes during `execute()`. `submit_frame_graph` drains the
123/// graph in topological order and submits the buffers to the device.
124#[derive(Default)]
125pub struct FrameGraph {
126    passes: Vec<RecordedPass>,
127}
128
129impl FrameGraph {
130    /// Creates an empty frame graph.
131    pub fn new() -> Self {
132        Self { passes: Vec::new() }
133    }
134
135    /// Records a pass: descriptor + the command buffer the agent already produced.
136    pub fn add_pass(&mut self, descriptor: PassDescriptor, command_buffer: CommandBufferId) {
137        self.passes.push(RecordedPass {
138            descriptor,
139            command_buffer,
140        });
141    }
142
143    /// Number of recorded passes.
144    pub fn len(&self) -> usize {
145        self.passes.len()
146    }
147
148    /// Whether the graph has no recorded passes.
149    pub fn is_empty(&self) -> bool {
150        self.passes.is_empty()
151    }
152
153    /// Drops all recorded passes without submitting them — used to recover
154    /// from a frame error before the engine reaches the submit step.
155    pub fn clear(&mut self) {
156        self.passes.clear();
157    }
158
159    /// Drains the graph and returns command buffers in submission order.
160    ///
161    /// The order is computed by a stable Kahn's algorithm over read/write
162    /// dependencies: a pass that writes resource `R` must run before any
163    /// pass that reads `R`. Insertion order is the tie-breaker, so when the
164    /// declared order is already valid (the common case) the result equals
165    /// insertion order.
166    pub fn compile(&mut self) -> Vec<CommandBufferId> {
167        let passes = std::mem::take(&mut self.passes);
168        sorted_passes(passes)
169            .into_iter()
170            .map(|p| p.command_buffer)
171            .collect()
172    }
173}
174
175/// Shared, mutable handle on the per-frame graph — registered as a service.
176pub type SharedFrameGraph = Arc<Mutex<FrameGraph>>;
177
178/// Drains `graph` and submits every recorded command buffer in topological
179/// order. Returns the number of submitted buffers.
180pub fn submit_frame_graph(graph: &SharedFrameGraph, device: &dyn GraphicsDevice) -> usize {
181    let buffers = {
182        let mut guard = graph.lock().expect("FrameGraph mutex poisoned");
183        guard.compile()
184    };
185    let count = buffers.len();
186    for buffer in buffers {
187        device.submit_command_buffer(buffer);
188    }
189    count
190}
191
192// ─────────────────────────────────────────────────────────────────────────────
193// Topological sort — stable Kahn's algorithm
194// ─────────────────────────────────────────────────────────────────────────────
195
196fn sorted_passes(passes: Vec<RecordedPass>) -> Vec<RecordedPass> {
197    let n = passes.len();
198    if n <= 1 {
199        return passes;
200    }
201
202    // Edge i → j when pass i writes a resource that pass j reads (and j > i in
203    // insertion order; the latest writer is the one that produces the value
204    // for downstream readers).
205    let mut last_writer: HashMap<ResourceId, usize> = HashMap::new();
206    let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
207    let mut in_deg = vec![0usize; n];
208
209    for (i, pass) in passes.iter().enumerate() {
210        for read in &pass.descriptor.reads {
211            if let Some(&writer) = last_writer.get(read) {
212                if writer != i {
213                    adj[writer].push(i);
214                    in_deg[i] += 1;
215                }
216            }
217        }
218        for write in &pass.descriptor.writes {
219            last_writer.insert(*write, i);
220        }
221    }
222
223    let mut queue: VecDeque<usize> = (0..n).filter(|&i| in_deg[i] == 0).collect();
224    let mut order = Vec::with_capacity(n);
225    let mut visited: HashSet<usize> = HashSet::new();
226    while let Some(i) = queue.pop_front() {
227        if !visited.insert(i) {
228            continue;
229        }
230        order.push(i);
231        for &j in &adj[i] {
232            in_deg[j] -= 1;
233            if in_deg[j] == 0 {
234                queue.push_back(j);
235            }
236        }
237    }
238
239    debug_assert_eq!(order.len(), n, "FrameGraph cycle detected");
240
241    let mut indexed: Vec<Option<RecordedPass>> = passes.into_iter().map(Some).collect();
242    order
243        .into_iter()
244        .map(|i| indexed[i].take().expect("pass already taken"))
245        .collect()
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use khora_core::renderer::api::command::CommandBufferId;
252
253    fn buf(id: u64) -> CommandBufferId {
254        CommandBufferId(id)
255    }
256
257    #[test]
258    fn empty_graph_compiles_empty() {
259        let mut g = FrameGraph::new();
260        assert_eq!(g.compile().len(), 0);
261    }
262
263    #[test]
264    fn single_pass_returned_as_is() {
265        let mut g = FrameGraph::new();
266        g.add_pass(PassDescriptor::new("scene"), buf(1));
267        let order = g.compile();
268        assert_eq!(order, vec![buf(1)]);
269    }
270
271    #[test]
272    fn insertion_order_preserved_when_dependencies_match() {
273        let mut g = FrameGraph::new();
274        g.add_pass(
275            PassDescriptor::new("shadow").writes(ResourceId::ShadowAtlas),
276            buf(1),
277        );
278        g.add_pass(
279            PassDescriptor::new("scene")
280                .reads(ResourceId::ShadowAtlas)
281                .writes(ResourceId::Color),
282            buf(2),
283        );
284        g.add_pass(
285            PassDescriptor::new("ui")
286                .reads(ResourceId::Color)
287                .writes(ResourceId::Color),
288            buf(3),
289        );
290        assert_eq!(g.compile(), vec![buf(1), buf(2), buf(3)]);
291    }
292
293    #[test]
294    fn reads_force_writer_first_even_if_inserted_late() {
295        // A reader inserted before the writer is allowed: insertion-order tie
296        // breaks ties only when no edge is established. Here we test that
297        // edges are correctly built when reads precede writes in the input.
298        let mut g = FrameGraph::new();
299        // pass0 reads X but no one writes X yet → no edge.
300        g.add_pass(PassDescriptor::new("a").reads(ResourceId::Color), buf(1));
301        // pass1 writes X.
302        g.add_pass(PassDescriptor::new("b").writes(ResourceId::Color), buf(2));
303        // pass2 reads X → must be after pass1.
304        g.add_pass(PassDescriptor::new("c").reads(ResourceId::Color), buf(3));
305
306        let order = g.compile();
307        let pos_b = order.iter().position(|b| *b == buf(2)).unwrap();
308        let pos_c = order.iter().position(|b| *b == buf(3)).unwrap();
309        assert!(pos_b < pos_c);
310    }
311
312    #[test]
313    fn clear_drops_passes() {
314        let mut g = FrameGraph::new();
315        g.add_pass(PassDescriptor::new("scene"), buf(1));
316        g.add_pass(PassDescriptor::new("ui"), buf(2));
317        assert_eq!(g.len(), 2);
318        g.clear();
319        assert!(g.is_empty());
320    }
321}