Skip to main content

oximedia_gpu/
command_buffer.rs

1//! GPU command buffer recording and submission.
2#![allow(dead_code)]
3
4use std::collections::VecDeque;
5
6/// Type of a GPU command.
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub enum CommandType {
9    /// Draw call
10    Draw,
11    /// Compute dispatch
12    Compute,
13    /// Copy / transfer
14    Copy,
15    /// Resource barrier / transition
16    Barrier,
17    /// Clear a render target
18    Clear,
19    /// Begin / end render pass markers
20    RenderPassMarker,
21}
22
23impl CommandType {
24    /// Returns `true` if the command represents a draw call.
25    #[must_use]
26    pub fn is_draw(&self) -> bool {
27        matches!(self, Self::Draw)
28    }
29
30    /// Returns `true` if the command is a compute dispatch.
31    #[must_use]
32    pub fn is_compute(&self) -> bool {
33        matches!(self, Self::Compute)
34    }
35
36    /// Returns `true` if the command transfers data between buffers/textures.
37    #[must_use]
38    pub fn is_copy(&self) -> bool {
39        matches!(self, Self::Copy)
40    }
41}
42
43/// A single recorded GPU command with metadata.
44#[derive(Debug, Clone)]
45pub struct CommandEntry {
46    /// Type of the command.
47    pub command_type: CommandType,
48    /// Opaque payload (e.g., serialised draw parameters).
49    pub payload: Vec<u8>,
50    /// Human-readable label for debugging.
51    pub label: String,
52}
53
54impl CommandEntry {
55    /// Create a new command entry.
56    #[must_use]
57    pub fn new(command_type: CommandType, label: impl Into<String>) -> Self {
58        Self {
59            command_type,
60            payload: Vec::new(),
61            label: label.into(),
62        }
63    }
64
65    /// Create a new entry with a raw payload.
66    #[must_use]
67    pub fn with_payload(
68        command_type: CommandType,
69        label: impl Into<String>,
70        payload: Vec<u8>,
71    ) -> Self {
72        Self {
73            command_type,
74            payload,
75            label: label.into(),
76        }
77    }
78
79    /// Estimate the GPU cost (in arbitrary units) of executing this command.
80    ///
81    /// Draw calls are assumed to be more expensive than compute dispatches,
82    /// which in turn are more expensive than copies.
83    #[allow(clippy::cast_precision_loss)]
84    #[must_use]
85    pub fn estimated_cost(&self) -> f32 {
86        let base: f32 = match self.command_type {
87            CommandType::Draw => 10.0,
88            CommandType::Compute => 8.0,
89            CommandType::Copy => 3.0,
90            CommandType::Barrier => 1.0,
91            CommandType::Clear => 2.0,
92            CommandType::RenderPassMarker => 0.1,
93        };
94        // Payload size adds a small overhead proportional to data moved.
95        base + self.payload.len() as f32 * 0.001
96    }
97}
98
99/// State of a [`CommandBuffer`].
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub enum CommandBufferState {
102    /// Ready to record commands.
103    Recording,
104    /// Recording finished; ready to submit.
105    Executable,
106    /// Submitted to GPU queue; cannot be re-used until reset.
107    Pending,
108    /// Buffer has been reset and can start recording again.
109    Reset,
110}
111
112/// A GPU command buffer that records and submits work to the GPU.
113pub struct CommandBuffer {
114    commands: VecDeque<CommandEntry>,
115    state: CommandBufferState,
116    label: String,
117}
118
119impl CommandBuffer {
120    /// Create a new, empty command buffer in the `Recording` state.
121    #[must_use]
122    pub fn new(label: impl Into<String>) -> Self {
123        Self {
124            commands: VecDeque::new(),
125            state: CommandBufferState::Recording,
126            label: label.into(),
127        }
128    }
129
130    /// Record a new command into the buffer.
131    ///
132    /// # Panics
133    ///
134    /// Panics if the buffer is not in the `Recording` state.
135    pub fn record(&mut self, entry: CommandEntry) {
136        assert_eq!(
137            self.state,
138            CommandBufferState::Recording,
139            "CommandBuffer '{}' must be in Recording state to accept new commands",
140            self.label
141        );
142        self.commands.push_back(entry);
143    }
144
145    /// Finish recording and transition the buffer to `Executable`.
146    ///
147    /// Returns `false` if the buffer was not in `Recording` state.
148    pub fn finish(&mut self) -> bool {
149        if self.state == CommandBufferState::Recording {
150            self.state = CommandBufferState::Executable;
151            true
152        } else {
153            false
154        }
155    }
156
157    /// Simulate submission to the GPU queue.
158    ///
159    /// Returns the list of submitted commands (for testing / inspection) and
160    /// transitions the buffer to `Pending`.
161    ///
162    /// Returns `None` if the buffer is not `Executable`.
163    pub fn submit(&mut self) -> Option<Vec<CommandEntry>> {
164        if self.state != CommandBufferState::Executable {
165            return None;
166        }
167        self.state = CommandBufferState::Pending;
168        Some(self.commands.iter().cloned().collect())
169    }
170
171    /// Reset the buffer, clearing all recorded commands.
172    pub fn reset(&mut self) {
173        self.commands.clear();
174        self.state = CommandBufferState::Reset;
175    }
176
177    /// Begin a fresh recording pass after a reset.
178    ///
179    /// Returns `false` if the buffer was not in `Reset` state.
180    pub fn begin(&mut self) -> bool {
181        if self.state == CommandBufferState::Reset {
182            self.state = CommandBufferState::Recording;
183            true
184        } else {
185            false
186        }
187    }
188
189    /// Number of recorded commands.
190    #[must_use]
191    pub fn command_count(&self) -> usize {
192        self.commands.len()
193    }
194
195    /// Current state of the buffer.
196    #[must_use]
197    pub fn state(&self) -> CommandBufferState {
198        self.state
199    }
200
201    /// Label of this buffer.
202    #[must_use]
203    pub fn label(&self) -> &str {
204        &self.label
205    }
206
207    /// Total estimated GPU cost of all recorded commands.
208    #[allow(clippy::cast_precision_loss)]
209    #[must_use]
210    pub fn total_estimated_cost(&self) -> f32 {
211        self.commands.iter().map(CommandEntry::estimated_cost).sum()
212    }
213}
214
215// ---------------------------------------------------------------------------
216// Double-buffered command submission
217// ---------------------------------------------------------------------------
218
219/// Identifies one of the two command-buffer slots used in double buffering.
220#[derive(Debug, Clone, Copy, PartialEq, Eq)]
221pub enum BufferSlot {
222    /// First slot (index 0).
223    A,
224    /// Second slot (index 1).
225    B,
226}
227
228impl BufferSlot {
229    /// Return the other slot.
230    #[must_use]
231    pub fn flip(self) -> Self {
232        match self {
233            Self::A => Self::B,
234            Self::B => Self::A,
235        }
236    }
237
238    /// Numeric index of this slot.
239    #[must_use]
240    pub fn index(self) -> usize {
241        match self {
242            Self::A => 0,
243            Self::B => 1,
244        }
245    }
246}
247
248/// Lifecycle state of a slot in [`DoubleBufferedSubmitter`].
249#[derive(Debug, Clone, Copy, PartialEq, Eq)]
250pub enum SlotState {
251    /// No work has been recorded; slot is free.
252    Idle,
253    /// CPU is currently recording commands into this slot.
254    Recording,
255    /// Recorded and ready to send to the GPU queue.
256    ReadyToSubmit,
257    /// Submitted to the GPU queue; GPU may still be executing.
258    Inflight,
259    /// GPU execution finished; results are available.
260    Retired,
261}
262
263/// Double-buffered GPU command submission.
264///
265/// Maintains two independent [`CommandBuffer`] slots (A and B).  While the GPU
266/// executes the commands in slot B, the CPU records new work into slot A, and
267/// vice-versa.  This overlaps CPU recording with GPU execution to maximise
268/// throughput.
269///
270/// # Typical cycle (one frame)
271///
272/// ```text
273/// 1. begin_record(recording_slot)   → SlotState::Recording
274/// 2. … record commands …
275/// 3. finish_record(recording_slot)  → SlotState::ReadyToSubmit
276/// 4. submit(recording_slot)         → SlotState::Inflight
277/// 5. mark_retired(other_slot)       → SlotState::Retired (after GPU fence)
278/// 6. reset(retired_slot)            → SlotState::Idle
279/// ```
280pub struct DoubleBufferedSubmitter {
281    slots: [CommandBuffer; 2],
282    slot_states: [SlotState; 2],
283    /// The slot currently being recorded by the CPU.
284    active_slot: BufferSlot,
285    /// Number of full A→B→A cycles completed.
286    frame_count: u64,
287}
288
289impl DoubleBufferedSubmitter {
290    /// Create a new double-buffered submitter with both slots idle.
291    #[must_use]
292    pub fn new() -> Self {
293        let mut slot_a = CommandBuffer::new("DoubleBuffer-SlotA");
294        let mut slot_b = CommandBuffer::new("DoubleBuffer-SlotB");
295        // Reset both so they start in `Reset` state and can `begin()`.
296        slot_a.reset();
297        slot_b.reset();
298        Self {
299            slots: [slot_a, slot_b],
300            slot_states: [SlotState::Idle, SlotState::Idle],
301            active_slot: BufferSlot::A,
302            frame_count: 0,
303        }
304    }
305
306    /// Current state of `slot`.
307    #[must_use]
308    pub fn state(&self, slot: BufferSlot) -> SlotState {
309        self.slot_states[slot.index()]
310    }
311
312    /// The slot the CPU is currently (or will next) record into.
313    #[must_use]
314    pub fn active_slot(&self) -> BufferSlot {
315        self.active_slot
316    }
317
318    /// Total number of complete double-buffer cycles (frames) submitted.
319    #[must_use]
320    pub fn frame_count(&self) -> u64 {
321        self.frame_count
322    }
323
324    /// Begin recording commands into `slot`.
325    ///
326    /// Returns `false` if `slot` is not currently `Idle`.
327    pub fn begin_record(&mut self, slot: BufferSlot) -> bool {
328        if self.slot_states[slot.index()] != SlotState::Idle {
329            return false;
330        }
331        self.slots[slot.index()].begin();
332        self.slot_states[slot.index()] = SlotState::Recording;
333        self.active_slot = slot;
334        true
335    }
336
337    /// Record a command entry into the active recording slot.
338    ///
339    /// Returns `false` if `slot` is not in `Recording` state.
340    pub fn record(&mut self, slot: BufferSlot, entry: CommandEntry) -> bool {
341        if self.slot_states[slot.index()] != SlotState::Recording {
342            return false;
343        }
344        self.slots[slot.index()].record(entry);
345        true
346    }
347
348    /// Finish recording for `slot`.
349    ///
350    /// Returns `false` if `slot` is not in `Recording` state.
351    pub fn finish_record(&mut self, slot: BufferSlot) -> bool {
352        if self.slot_states[slot.index()] != SlotState::Recording {
353            return false;
354        }
355        let ok = self.slots[slot.index()].finish();
356        if ok {
357            self.slot_states[slot.index()] = SlotState::ReadyToSubmit;
358        }
359        ok
360    }
361
362    /// Submit the commands in `slot` to the (simulated) GPU queue.
363    ///
364    /// Returns the submitted commands on success, or `None` if `slot` is not
365    /// `ReadyToSubmit`.
366    pub fn submit(&mut self, slot: BufferSlot) -> Option<Vec<CommandEntry>> {
367        if self.slot_states[slot.index()] != SlotState::ReadyToSubmit {
368            return None;
369        }
370        let cmds = self.slots[slot.index()].submit()?;
371        self.slot_states[slot.index()] = SlotState::Inflight;
372        self.frame_count += 1;
373        Some(cmds)
374    }
375
376    /// Mark `slot` as retired (GPU execution complete).
377    ///
378    /// Returns `false` if `slot` is not `Inflight`.
379    pub fn mark_retired(&mut self, slot: BufferSlot) -> bool {
380        if self.slot_states[slot.index()] != SlotState::Inflight {
381            return false;
382        }
383        self.slot_states[slot.index()] = SlotState::Retired;
384        true
385    }
386
387    /// Reset `slot` to `Idle` so it can be recorded into again.
388    ///
389    /// Returns `false` if `slot` is not `Retired`.
390    pub fn reset_slot(&mut self, slot: BufferSlot) -> bool {
391        if self.slot_states[slot.index()] != SlotState::Retired {
392            return false;
393        }
394        self.slots[slot.index()].reset();
395        self.slot_states[slot.index()] = SlotState::Idle;
396        true
397    }
398
399    /// Number of commands recorded in `slot`.
400    #[must_use]
401    pub fn command_count(&self, slot: BufferSlot) -> usize {
402        self.slots[slot.index()].command_count()
403    }
404}
405
406impl Default for DoubleBufferedSubmitter {
407    fn default() -> Self {
408        Self::new()
409    }
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415
416    fn make_draw() -> CommandEntry {
417        CommandEntry::new(CommandType::Draw, "draw_quad")
418    }
419
420    fn make_compute() -> CommandEntry {
421        CommandEntry::new(CommandType::Compute, "dispatch_cs")
422    }
423
424    fn make_copy() -> CommandEntry {
425        CommandEntry::new(CommandType::Copy, "copy_buffer")
426    }
427
428    // --- CommandType tests ---
429
430    #[test]
431    fn test_is_draw_true() {
432        assert!(CommandType::Draw.is_draw());
433    }
434
435    #[test]
436    fn test_is_draw_false_for_compute() {
437        assert!(!CommandType::Compute.is_draw());
438    }
439
440    #[test]
441    fn test_is_compute_true() {
442        assert!(CommandType::Compute.is_compute());
443    }
444
445    #[test]
446    fn test_is_copy_true() {
447        assert!(CommandType::Copy.is_copy());
448    }
449
450    #[test]
451    fn test_is_copy_false_for_barrier() {
452        assert!(!CommandType::Barrier.is_copy());
453    }
454
455    // --- CommandEntry tests ---
456
457    #[test]
458    fn test_entry_estimated_cost_draw_greater_than_copy() {
459        let draw = make_draw();
460        let copy = make_copy();
461        assert!(draw.estimated_cost() > copy.estimated_cost());
462    }
463
464    #[test]
465    fn test_entry_estimated_cost_payload_increases_cost() {
466        let small = CommandEntry::with_payload(CommandType::Copy, "s", vec![0u8; 10]);
467        let large = CommandEntry::with_payload(CommandType::Copy, "l", vec![0u8; 1000]);
468        assert!(large.estimated_cost() > small.estimated_cost());
469    }
470
471    #[test]
472    fn test_entry_label_stored() {
473        let e = CommandEntry::new(CommandType::Draw, "my_draw");
474        assert_eq!(e.label, "my_draw");
475    }
476
477    // --- CommandBuffer tests ---
478
479    #[test]
480    fn test_new_buffer_is_recording() {
481        let buf = CommandBuffer::new("test");
482        assert_eq!(buf.state(), CommandBufferState::Recording);
483    }
484
485    #[test]
486    fn test_record_increments_count() {
487        let mut buf = CommandBuffer::new("test");
488        buf.record(make_draw());
489        buf.record(make_compute());
490        assert_eq!(buf.command_count(), 2);
491    }
492
493    #[test]
494    fn test_finish_transitions_to_executable() {
495        let mut buf = CommandBuffer::new("test");
496        buf.record(make_draw());
497        assert!(buf.finish());
498        assert_eq!(buf.state(), CommandBufferState::Executable);
499    }
500
501    #[test]
502    fn test_submit_returns_commands() {
503        let mut buf = CommandBuffer::new("test");
504        buf.record(make_draw());
505        buf.record(make_copy());
506        buf.finish();
507        let cmds = buf.submit().expect("submit should succeed");
508        assert_eq!(cmds.len(), 2);
509    }
510
511    #[test]
512    fn test_submit_transitions_to_pending() {
513        let mut buf = CommandBuffer::new("test");
514        buf.record(make_compute());
515        buf.finish();
516        buf.submit();
517        assert_eq!(buf.state(), CommandBufferState::Pending);
518    }
519
520    #[test]
521    fn test_reset_clears_commands_and_sets_reset_state() {
522        let mut buf = CommandBuffer::new("test");
523        buf.record(make_draw());
524        buf.finish();
525        buf.submit();
526        buf.reset();
527        assert_eq!(buf.command_count(), 0);
528        assert_eq!(buf.state(), CommandBufferState::Reset);
529    }
530
531    #[test]
532    fn test_begin_after_reset_allows_recording() {
533        let mut buf = CommandBuffer::new("test");
534        buf.reset();
535        assert!(buf.begin());
536        buf.record(make_draw());
537        assert_eq!(buf.command_count(), 1);
538    }
539
540    #[test]
541    fn test_total_estimated_cost_sums_entries() {
542        let mut buf = CommandBuffer::new("test");
543        buf.record(make_draw());
544        buf.record(make_copy());
545        let expected = make_draw().estimated_cost() + make_copy().estimated_cost();
546        assert!((buf.total_estimated_cost() - expected).abs() < 1e-4);
547    }
548
549    #[test]
550    fn test_label_stored() {
551        let buf = CommandBuffer::new("my_buf");
552        assert_eq!(buf.label(), "my_buf");
553    }
554
555    #[test]
556    fn test_submit_fails_when_not_executable() {
557        let mut buf = CommandBuffer::new("test");
558        buf.record(make_draw());
559        // Not finished yet — still Recording
560        assert!(buf.submit().is_none());
561    }
562
563    #[test]
564    fn test_finish_fails_when_already_executable() {
565        let mut buf = CommandBuffer::new("test");
566        buf.record(make_draw());
567        buf.finish();
568        // Calling finish again should return false
569        assert!(!buf.finish());
570    }
571
572    // --- BufferSlot tests ---
573
574    #[test]
575    fn test_buffer_slot_flip() {
576        assert_eq!(BufferSlot::A.flip(), BufferSlot::B);
577        assert_eq!(BufferSlot::B.flip(), BufferSlot::A);
578    }
579
580    #[test]
581    fn test_buffer_slot_index() {
582        assert_eq!(BufferSlot::A.index(), 0);
583        assert_eq!(BufferSlot::B.index(), 1);
584    }
585
586    // --- DoubleBufferedSubmitter tests ---
587
588    #[test]
589    fn test_double_buffer_initial_state() {
590        let db = DoubleBufferedSubmitter::new();
591        assert_eq!(db.state(BufferSlot::A), SlotState::Idle);
592        assert_eq!(db.state(BufferSlot::B), SlotState::Idle);
593        assert_eq!(db.frame_count(), 0);
594    }
595
596    #[test]
597    fn test_double_buffer_begin_record() {
598        let mut db = DoubleBufferedSubmitter::new();
599        assert!(db.begin_record(BufferSlot::A));
600        assert_eq!(db.state(BufferSlot::A), SlotState::Recording);
601    }
602
603    #[test]
604    fn test_double_buffer_begin_record_fails_when_not_idle() {
605        let mut db = DoubleBufferedSubmitter::new();
606        db.begin_record(BufferSlot::A);
607        // Already Recording — cannot begin again
608        assert!(!db.begin_record(BufferSlot::A));
609    }
610
611    #[test]
612    fn test_double_buffer_full_cycle_slot_a() {
613        let mut db = DoubleBufferedSubmitter::new();
614        assert!(db.begin_record(BufferSlot::A));
615        db.record(BufferSlot::A, make_draw());
616        assert!(db.finish_record(BufferSlot::A));
617        assert_eq!(db.state(BufferSlot::A), SlotState::ReadyToSubmit);
618        let cmds = db.submit(BufferSlot::A).expect("submit should succeed");
619        assert_eq!(cmds.len(), 1);
620        assert_eq!(db.state(BufferSlot::A), SlotState::Inflight);
621        assert_eq!(db.frame_count(), 1);
622        assert!(db.mark_retired(BufferSlot::A));
623        assert_eq!(db.state(BufferSlot::A), SlotState::Retired);
624        assert!(db.reset_slot(BufferSlot::A));
625        assert_eq!(db.state(BufferSlot::A), SlotState::Idle);
626    }
627
628    #[test]
629    fn test_double_buffer_interleaved_slots() {
630        let mut db = DoubleBufferedSubmitter::new();
631        // Record slot A
632        db.begin_record(BufferSlot::A);
633        db.record(BufferSlot::A, make_compute());
634        db.finish_record(BufferSlot::A);
635        // While A is ready-to-submit, record slot B
636        db.begin_record(BufferSlot::B);
637        db.record(BufferSlot::B, make_copy());
638        db.finish_record(BufferSlot::B);
639        // Submit both
640        assert!(db.submit(BufferSlot::A).is_some());
641        assert!(db.submit(BufferSlot::B).is_some());
642        assert_eq!(db.frame_count(), 2);
643    }
644
645    #[test]
646    fn test_double_buffer_submit_fails_when_not_ready() {
647        let mut db = DoubleBufferedSubmitter::new();
648        db.begin_record(BufferSlot::A);
649        // Not finished yet — submit should fail
650        assert!(db.submit(BufferSlot::A).is_none());
651    }
652
653    #[test]
654    fn test_double_buffer_mark_retired_fails_when_not_inflight() {
655        let mut db = DoubleBufferedSubmitter::new();
656        assert!(!db.mark_retired(BufferSlot::A));
657    }
658
659    #[test]
660    fn test_double_buffer_reset_slot_fails_when_not_retired() {
661        let mut db = DoubleBufferedSubmitter::new();
662        assert!(!db.reset_slot(BufferSlot::A));
663    }
664
665    #[test]
666    fn test_double_buffer_command_count() {
667        let mut db = DoubleBufferedSubmitter::new();
668        db.begin_record(BufferSlot::B);
669        db.record(BufferSlot::B, make_draw());
670        db.record(BufferSlot::B, make_draw());
671        assert_eq!(db.command_count(BufferSlot::B), 2);
672    }
673}