feat(session): Skia console UI — overlay contract + stats OSD/capture HUD (phase 4a)

The §6.1 presenter↔console-UI contract lands: pf-presenter exposes its
device (SharedDevice) and composites at most one premultiplied-alpha
quad per frame (new overlay.frag + LOAD render pass over the swapchain;
zero cost while the overlay returns None). pf-console-ui implements it
with skia-safe on the shared VkDevice: DirectContext via the ash
dispatch chain, a ring of two offscreen render targets (one-frame-in-
flight safe), damage-driven redraws — the OSD re-renders at 1 Hz, the
hint on capture toggles, nothing per-frame. Skia never touches the
swapchain. The session binary carries it behind the default ui feature:
4.9 MB stripped without, 10 MB with (measured).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 18:43:24 +02:00
parent 433a23da7a
commit 021a2261f6
15 changed files with 1031 additions and 42 deletions
+30 -7
View File
@@ -168,7 +168,13 @@ impl CscPass {
)
}?[0];
let pipeline = build_pipeline(device, render_pass, pipeline_layout)?;
let pipeline = build_fullscreen_pipeline(
device,
render_pass,
pipeline_layout,
include_bytes!("../shaders/nv12_csc.frag.spv"),
false, // opaque — the CSC output IS the video
)?;
Ok(CscPass {
render_pass,
@@ -217,19 +223,23 @@ impl CscPass {
}
}
fn build_pipeline(
/// A bufferless fullscreen-triangle pipeline over `fullscreen.vert` + the given
/// fragment SPIR-V, dynamic viewport/scissor. `blend` = premultiplied-alpha over the
/// destination (the overlay composite); `false` = opaque write (the CSC pass). Shared
/// by both passes — the geometry and states are identical.
pub(crate) fn build_fullscreen_pipeline(
device: &ash::Device,
render_pass: vk::RenderPass,
layout: vk::PipelineLayout,
frag_spv: &[u8],
blend: bool,
) -> Result<vk::Pipeline> {
// Committed SPIR-V (shaders/build.sh) — include_bytes! alignment is unspecified, so
// read_spv copies into aligned Vec<u32>s.
let vert = ash::util::read_spv(&mut std::io::Cursor::new(
&include_bytes!("../shaders/fullscreen.vert.spv")[..],
))?;
let frag = ash::util::read_spv(&mut std::io::Cursor::new(
&include_bytes!("../shaders/nv12_csc.frag.spv")[..],
))?;
let frag = ash::util::read_spv(&mut std::io::Cursor::new(frag_spv))?;
let vert_mod = unsafe {
device.create_shader_module(&vk::ShaderModuleCreateInfo::default().code(&vert), None)
}?;
@@ -271,8 +281,21 @@ fn build_pipeline(
.line_width(1.0);
let multisample = vk::PipelineMultisampleStateCreateInfo::default()
.rasterization_samples(vk::SampleCountFlags::TYPE_1);
let blend_attachment = [vk::PipelineColorBlendAttachmentState::default()
.color_write_mask(vk::ColorComponentFlags::RGBA)];
let blend_attachment = [if blend {
// Premultiplied alpha over the destination (Skia surfaces are premultiplied).
vk::PipelineColorBlendAttachmentState::default()
.color_write_mask(vk::ColorComponentFlags::RGBA)
.blend_enable(true)
.src_color_blend_factor(vk::BlendFactor::ONE)
.dst_color_blend_factor(vk::BlendFactor::ONE_MINUS_SRC_ALPHA)
.color_blend_op(vk::BlendOp::ADD)
.src_alpha_blend_factor(vk::BlendFactor::ONE)
.dst_alpha_blend_factor(vk::BlendFactor::ONE_MINUS_SRC_ALPHA)
.alpha_blend_op(vk::BlendOp::ADD)
} else {
vk::PipelineColorBlendAttachmentState::default()
.color_write_mask(vk::ColorComponentFlags::RGBA)
}];
let blend = vk::PipelineColorBlendStateCreateInfo::default().attachments(&blend_attachment);
let info = vk::GraphicsPipelineCreateInfo::default()
+2
View File
@@ -18,6 +18,8 @@ pub mod input;
#[cfg(target_os = "linux")]
pub mod keymap_sdl;
#[cfg(target_os = "linux")]
pub mod overlay;
#[cfg(target_os = "linux")]
mod run;
#[cfg(target_os = "linux")]
pub mod vk;
+63
View File
@@ -0,0 +1,63 @@
//! The presenter↔console-UI contract (punktfunk-planning
//! `linux-client-rearchitecture.md` §6.1): the presenter exposes its device and
//! composites at most ONE sampled RGBA quad per frame; the overlay implementation
//! (pf-console-ui, Skia) fills offscreen images on its own damage-driven schedule. No
//! Skia type crosses this line — everything here is ash — and a `frame()` returning
//! `None` costs the hot path nothing (the quad isn't even recorded).
use ash::vk;
/// The presenter's device, shared with the overlay so its renderer (Skia's
/// `DirectContext`) creates resources on the same VkDevice/queue. Handles stay valid for
/// the presenter's lifetime — the overlay must be dropped before it (the run loop owns
/// both and drops the overlay first).
pub struct SharedDevice {
pub entry: ash::Entry,
pub instance: ash::Instance,
pub physical_device: vk::PhysicalDevice,
pub device: ash::Device,
pub queue: vk::Queue,
pub queue_family_index: u32,
}
/// What the overlay may draw this frame — composed by the run loop from session state.
/// Milestone 1 (OSD/HUD) is text-shaped; the console library replaces this with a
/// richer scene enum when it moves in.
pub struct FrameCtx<'a> {
/// Swapchain size in pixels — the overlay renders 1:1.
pub width: u32,
pub height: u32,
/// Multi-line stats OSD (top-left panel); `None` = hidden.
pub stats: Option<&'a str>,
/// The capture hint (bottom-center pill, "click to capture…"); `None` = hidden.
pub hint: Option<&'a str>,
}
/// One overlay image ready to composite: RGBA, PREMULTIPLIED alpha, already in
/// `SHADER_READ_ONLY_OPTIMAL`, sized `width`×`height` (normally the `FrameCtx` size; a
/// stale size during a resize just stretches for a frame).
pub struct OverlayFrame {
pub image: vk::Image,
pub view: vk::ImageView,
pub width: u32,
pub height: u32,
}
/// The console-UI side. Object-safe; the session binary passes
/// `Option<Box<dyn Overlay>>` (None = the Skia-free power-user build).
pub trait Overlay {
/// One-time setup on the presenter's device.
fn init(&mut self, shared: &SharedDevice) -> anyhow::Result<()>;
/// Input routing, before capture sees the event. `true` = consumed (a menu is up) —
/// the event must not reach capture/forwarding. The OSD/HUD milestone consumes
/// nothing; the console library will.
fn handle_event(&mut self, event: &sdl3::event::Event) -> bool;
/// Once per presenter iteration. Damage-driven: re-render (flush + transition to
/// SHADER_READ_ONLY) only when the content or size changed, else return the previous
/// image. `None` = nothing to composite. The returned image must stay untouched
/// until `frame()` runs again (the presenter runs one frame in flight and the
/// implementation keeps a ring of two, so alternating satisfies this).
fn frame(&mut self, ctx: &FrameCtx) -> anyhow::Result<Option<OverlayFrame>>;
}
+86 -21
View File
@@ -7,6 +7,7 @@
//! (Ctrl+Alt+Shift+S toggles). Logs go to stderr (the binary configures tracing so).
use crate::input::Capture;
use crate::overlay::{FrameCtx, Overlay, OverlayFrame};
use crate::vk::{FrameInput, Presenter};
use anyhow::{Context as _, Result};
use pf_client_core::gamepad::GamepadService;
@@ -31,6 +32,10 @@ pub struct SessionOpts {
/// Called once on `Connected` with the host's fingerprint (trust persistence is the
/// binary's business — this loop stays store-agnostic).
pub on_connected: Option<Box<dyn FnMut([u8; 32])>>,
/// The console-UI overlay (§6.1) — `None` is the Skia-free power-user build (stats
/// stay stdout-only). An overlay whose `init` fails degrades to `None` with a
/// warning rather than killing the session.
pub overlay: Option<Box<dyn Overlay>>,
}
pub enum Outcome {
@@ -68,7 +73,16 @@ where
.map_err(|e| anyhow::anyhow!("vulkan instance extensions: {e}"))?;
let mut presenter = Presenter::new(&window, &instance_exts).context("vulkan presenter")?;
// A valid black frame immediately — the window is honest while the connect runs.
presenter.present(&window, FrameInput::Redraw)?;
presenter.present(&window, FrameInput::Redraw, None)?;
let mut overlay = opts.overlay.take();
if let Some(o) = overlay.as_mut() {
if let Err(e) = o.init(&presenter.shared_device()) {
tracing::warn!(error = %format!("{e:#}"),
"console-UI overlay init failed — continuing without it");
overlay = None;
}
}
let gamepad_subsystem = sdl.gamepad().context("SDL gamepad")?;
let (gamepad, mut pump) = GamepadService::pumped(gamepad_subsystem);
@@ -119,6 +133,11 @@ where
// demotes the decoder to software via the shared flag — once per session.
let mut dmabuf_demoted = false;
let mut hw_fails = 0u32;
// The OSD's text (multi-line; rebuilt each Stats window) and the console-UI frame
// currently composited. The frame persists across presents within an iteration —
// the overlay only re-renders when `frame()` runs again (ring of two, see §6.1).
let mut osd_text = String::new();
let mut overlay_frame: Option<OverlayFrame> = None;
let outcome = 'main: loop {
// --- SDL events (input, window, gamepads) ---------------------------------------
@@ -134,6 +153,13 @@ where
queued.push(e);
}
for event in queued {
// The console UI sees input first: a consumed event (its menu is up) never
// reaches capture/forwarding. The OSD/HUD milestone consumes nothing.
if let Some(o) = overlay.as_mut() {
if o.handle_event(&event) {
continue;
}
}
match event {
Event::Quit { .. } => {
// Window close / SIGINT: deliberate user exit — QUIT_CLOSE_CODE so
@@ -155,10 +181,10 @@ where
}
WindowEvent::PixelSizeChanged(..) | WindowEvent::Resized(..) => {
presenter.recreate_swapchain(&window)?;
presenter.present(&window, FrameInput::Redraw)?;
presenter.present(&window, FrameInput::Redraw, overlay_frame.as_ref())?;
}
WindowEvent::Exposed => {
presenter.present(&window, FrameInput::Redraw)?;
presenter.present(&window, FrameInput::Redraw, overlay_frame.as_ref())?;
}
_ => {}
},
@@ -302,8 +328,9 @@ where
}
}
SessionEvent::Stats(s) => {
osd_text = stats_text(&mode_line, &s, &presented, hdr);
if print_stats {
print_stats_line(&mode_line, &s, &presented, hdr);
println!("stats: {}", osd_text.replace('\n', " | "));
}
}
SessionEvent::Failed { msg, trust_rejected } => {
@@ -320,6 +347,35 @@ where
}
}
// --- Console UI: damage-driven overlay re-render for this iteration --------------
if let Some(o) = overlay.as_mut() {
let (pw, ph) = window.size_in_pixels();
let hint = match &capture {
Some(cap) if !cap.captured() => Some(if gamepad.active().is_some() {
HINT_WITH_PAD
} else {
HINT_KEYBOARD
}),
_ => None,
};
let ctx = FrameCtx {
width: pw,
height: ph,
stats: (print_stats && connector.is_some() && !osd_text.is_empty())
.then_some(osd_text.as_str()),
hint,
};
match o.frame(&ctx) {
Ok(f) => overlay_frame = f,
Err(e) => {
tracing::warn!(error = %format!("{e:#}"),
"overlay frame failed — disabling the console UI");
overlay = None;
overlay_frame = None;
}
}
}
// --- Frames: drain to the newest, upload + present -------------------------------
let mut newest: Option<DecodedFrame> = None;
while let Ok(f) = handle.frames.try_recv() {
@@ -334,11 +390,11 @@ where
let did_present = match image {
DecodedImage::Cpu(c) => {
hdr = c.color.is_pq();
presenter.present(&window, FrameInput::Cpu(&c))?
presenter.present(&window, FrameInput::Cpu(&c), overlay_frame.as_ref())?
}
DecodedImage::Dmabuf(d) if presenter.supports_dmabuf() && !dmabuf_demoted => {
hdr = d.color.is_pq();
match presenter.present(&window, FrameInput::Dmabuf(d)) {
match presenter.present(&window, FrameInput::Dmabuf(d), overlay_frame.as_ref()) {
Ok(p) => {
hw_fails = 0;
p
@@ -406,6 +462,10 @@ where
};
handle.stop.store(true, Ordering::SeqCst);
// Overlay resources live on the presenter's device: quiesce the queue first, drop
// the overlay (its Drop destroys the Skia surfaces), THEN the presenter tears down.
presenter.wait_idle();
drop(overlay);
Ok(outcome)
}
@@ -417,32 +477,37 @@ struct PresentedWindow {
display_ms: f32,
}
/// One `stats:` line per 1 s window — the OSD's numbers (design/stats-unification.md) in
/// terminal form: headline end-to-end capture→displayed, then the per-stage p50s.
fn print_stats_line(mode_line: &str, s: &Stats, p: &PresentedWindow, hdr: bool) {
let mut line = format!(
"stats: {mode_line} · {:.0} fps · {:.1} Mb/s · {}{} · e2e {:.1}/{:.1} ms (p50/p95)",
/// The capture hints (`ui_stream` parity — the words the user reads while released).
const HINT_KEYBOARD: &str = "Click the stream to capture input · Ctrl+Alt+Shift+Q releases · \
Ctrl+Alt+Shift+D disconnects · Ctrl+Alt+Shift+S stats";
const HINT_WITH_PAD: &str = "Click the stream to capture input · Ctrl+Alt+Shift+Q releases · \
Ctrl+Alt+Shift+D disconnects · hold L1 + R1 + Start + Select to leave";
/// The unified stats window (design/stats-unification.md) as OSD text — multi-line for
/// the console-UI panel; the stdout `stats:` line joins it with `|`.
fn stats_text(mode_line: &str, s: &Stats, p: &PresentedWindow, hdr: bool) -> String {
let mut text = format!(
"{mode_line} · {:.0} fps · {:.1} Mb/s · {}{}",
s.fps,
s.mbps,
if s.decoder.is_empty() { "-" } else { s.decoder },
if hdr { " · HDR" } else { "" },
p.e2e_p50_ms,
p.e2e_p95_ms,
);
text.push_str(&format!(
"\ne2e {:.1}/{:.1} ms (p50/p95)",
p.e2e_p50_ms, p.e2e_p95_ms
));
if s.split {
line.push_str(&format!(
" · host {:.1} · net {:.1}",
s.host_ms, s.net_ms
));
text.push_str(&format!(" · host {:.1} · net {:.1}", s.host_ms, s.net_ms));
} else {
line.push_str(&format!(" · host+net {:.1}", s.host_net_ms));
text.push_str(&format!(" · host+net {:.1}", s.host_net_ms));
}
line.push_str(&format!(
text.push_str(&format!(
" · decode {:.1} · display {:.1} ms",
s.decode_ms, p.display_ms
));
if s.lost > 0 {
line.push_str(&format!(" · lost {} ({:.1}%)", s.lost, s.lost_pct));
text.push_str(&format!("\nlost {} ({:.1}%)", s.lost, s.lost_pct));
}
println!("{line}");
text
}
+307 -11
View File
@@ -14,8 +14,9 @@
//! arrival-paced by the caller: a frame input on each decoded frame,
//! `FrameInput::Redraw` re-blits the retained video image (expose/resize redraws).
use crate::csc::{csc_rows, CscPass};
use crate::csc::{build_fullscreen_pipeline, csc_rows, CscPass};
use crate::dmabuf::{self, HwFrame};
use crate::overlay::{OverlayFrame, SharedDevice};
use anyhow::{anyhow, bail, Context as _, Result};
use ash::vk;
use pf_client_core::video::{CpuFrame, DmabufFrame};
@@ -35,6 +36,191 @@ struct HwCtx {
csc: CscPass,
}
/// The overlay composite: one premultiplied-alpha quad blended over the swapchain image
/// after the video blit (the §6.1 contract's presenter half). Always built — it has no
/// Skia dependency and costs nothing while no overlay frame arrives (the render pass
/// isn't even recorded).
struct OverlayPipe {
render_pass: vk::RenderPass,
set_layout: vk::DescriptorSetLayout,
pipeline_layout: vk::PipelineLayout,
pipeline: vk::Pipeline,
desc_pool: vk::DescriptorPool,
desc_set: vk::DescriptorSet,
sampler: vk::Sampler,
/// Per-swapchain-image render targets, rebuilt with the swapchain.
views: Vec<vk::ImageView>,
framebuffers: Vec<vk::Framebuffer>,
}
impl OverlayPipe {
fn new(device: &ash::Device, format: vk::Format) -> Result<OverlayPipe> {
// LOAD the blitted video, blend the overlay, end PRESENT-ready — this pass owns
// the swapchain image's final transition on overlay frames.
let attachment = [vk::AttachmentDescription::default()
.format(format)
.samples(vk::SampleCountFlags::TYPE_1)
.load_op(vk::AttachmentLoadOp::LOAD)
.store_op(vk::AttachmentStoreOp::STORE)
.initial_layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL)
.final_layout(vk::ImageLayout::PRESENT_SRC_KHR)];
let color_ref = [vk::AttachmentReference::default()
.attachment(0)
.layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL)];
let subpass = [vk::SubpassDescription::default()
.pipeline_bind_point(vk::PipelineBindPoint::GRAPHICS)
.color_attachments(&color_ref)];
let deps = [vk::SubpassDependency::default()
.src_subpass(vk::SUBPASS_EXTERNAL)
.dst_subpass(0)
.src_stage_mask(vk::PipelineStageFlags::ALL_COMMANDS)
.src_access_mask(vk::AccessFlags::MEMORY_WRITE)
.dst_stage_mask(vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT)
.dst_access_mask(
vk::AccessFlags::COLOR_ATTACHMENT_READ | vk::AccessFlags::COLOR_ATTACHMENT_WRITE,
)];
let render_pass = unsafe {
device.create_render_pass(
&vk::RenderPassCreateInfo::default()
.attachments(&attachment)
.subpasses(&subpass)
.dependencies(&deps),
None,
)
}
.context("overlay render pass")?;
let sampler = unsafe {
device.create_sampler(
&vk::SamplerCreateInfo::default()
.mag_filter(vk::Filter::LINEAR)
.min_filter(vk::Filter::LINEAR)
.address_mode_u(vk::SamplerAddressMode::CLAMP_TO_EDGE)
.address_mode_v(vk::SamplerAddressMode::CLAMP_TO_EDGE)
.address_mode_w(vk::SamplerAddressMode::CLAMP_TO_EDGE),
None,
)
}?;
let samplers = [sampler];
let bindings = [vk::DescriptorSetLayoutBinding::default()
.binding(0)
.descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
.descriptor_count(1)
.stage_flags(vk::ShaderStageFlags::FRAGMENT)
.immutable_samplers(&samplers)];
let set_layout = unsafe {
device.create_descriptor_set_layout(
&vk::DescriptorSetLayoutCreateInfo::default().bindings(&bindings),
None,
)
}?;
let set_layouts = [set_layout];
let pipeline_layout = unsafe {
device.create_pipeline_layout(
&vk::PipelineLayoutCreateInfo::default().set_layouts(&set_layouts),
None,
)
}?;
let pool_sizes = [vk::DescriptorPoolSize::default()
.ty(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
.descriptor_count(1)];
let desc_pool = unsafe {
device.create_descriptor_pool(
&vk::DescriptorPoolCreateInfo::default()
.max_sets(1)
.pool_sizes(&pool_sizes),
None,
)
}?;
let desc_set = unsafe {
device.allocate_descriptor_sets(
&vk::DescriptorSetAllocateInfo::default()
.descriptor_pool(desc_pool)
.set_layouts(&set_layouts),
)
}?[0];
let pipeline = build_fullscreen_pipeline(
device,
render_pass,
pipeline_layout,
include_bytes!("../shaders/overlay.frag.spv"),
true, // premultiplied blend over the video
)?;
Ok(OverlayPipe {
render_pass,
set_layout,
pipeline_layout,
pipeline,
desc_pool,
desc_set,
sampler,
views: Vec::new(),
framebuffers: Vec::new(),
})
}
/// Rebuild the per-swapchain-image views + framebuffers (swapchain recreation).
fn rebuild_targets(
&mut self,
device: &ash::Device,
images: &[vk::Image],
format: vk::Format,
extent: vk::Extent2D,
) -> Result<()> {
self.destroy_targets(device);
for &image in images {
let view = unsafe {
device.create_image_view(
&vk::ImageViewCreateInfo::default()
.image(image)
.view_type(vk::ImageViewType::TYPE_2D)
.format(format)
.subresource_range(subresource_range()),
None,
)
}?;
self.views.push(view);
let attachments = [view];
let fb = unsafe {
device.create_framebuffer(
&vk::FramebufferCreateInfo::default()
.render_pass(self.render_pass)
.attachments(&attachments)
.width(extent.width)
.height(extent.height)
.layers(1),
None,
)
}?;
self.framebuffers.push(fb);
}
Ok(())
}
fn destroy_targets(&mut self, device: &ash::Device) {
unsafe {
for fb in self.framebuffers.drain(..) {
device.destroy_framebuffer(fb, None);
}
for v in self.views.drain(..) {
device.destroy_image_view(v, None);
}
}
}
fn destroy(&mut self, device: &ash::Device) {
self.destroy_targets(device);
unsafe {
device.destroy_pipeline(self.pipeline, None);
device.destroy_pipeline_layout(self.pipeline_layout, None);
device.destroy_descriptor_pool(self.desc_pool, None);
device.destroy_descriptor_set_layout(self.set_layout, None);
device.destroy_sampler(self.sampler, None);
device.destroy_render_pass(self.render_pass, None);
}
}
}
/// The one video image (device-local RGBA the size of the decoded stream) + its staging.
/// `view`/`framebuffer` exist only on hw-capable devices (the CSC pass renders into it).
struct VideoImage {
@@ -67,6 +253,8 @@ pub struct Presenter {
qfi: u32,
/// Dmabuf import + CSC — `None` when the device lacks the import extensions.
hw: Option<HwCtx>,
/// The console-UI composite quad (§6.1's presenter half).
overlay_pipe: OverlayPipe,
/// The submitted hw frame (plane images + decoder-surface guard): its GPU reads end
/// with the in-flight fence, so it's destroyed right after the next fence wait.
retired_hw: Option<HwFrame>,
@@ -175,6 +363,7 @@ impl Presenter {
let format = pick_format(&surface_i, pdev, surface)?;
let present_mode = pick_present_mode(&surface_i, pdev, surface)?;
tracing::info!(?format, ?present_mode, "swapchain config");
let overlay_pipe = OverlayPipe::new(&device, format.format)?;
let cmd_pool = unsafe {
device.create_command_pool(
@@ -213,6 +402,7 @@ impl Presenter {
queue,
qfi,
hw,
overlay_pipe,
retired_hw: None,
format,
present_mode,
@@ -285,6 +475,8 @@ impl Presenter {
self.swapchain = swapchain;
self.images = unsafe { self.swap_d.get_swapchain_images(swapchain) }?;
self.extent = extent;
self.overlay_pipe
.rebuild_targets(&self.device, &self.images, self.format.format, extent)?;
for s in self.render_sems.drain(..) {
unsafe { self.device.destroy_semaphore(s, None) };
@@ -310,11 +502,36 @@ impl Presenter {
self.hw.is_some()
}
/// Quiesce the queue — the run loop calls this before dropping the overlay so
/// nothing in flight still references its images.
pub fn wait_idle(&self) {
unsafe { self.device.device_wait_idle() }.ok();
}
/// The device handles the console-UI overlay renders on (§6.1). Valid for the
/// presenter's lifetime; the run loop drops the overlay first.
pub fn shared_device(&self) -> SharedDevice {
SharedDevice {
entry: self.entry.clone(),
instance: self.instance.clone(),
physical_device: self.pdev,
device: self.device.clone(),
queue: self.queue,
queue_family_index: self.qfi,
}
}
/// Present one frame: route `input` into the video image (staging upload or dmabuf
/// import + CSC pass; `Redraw` re-blits what's retained), clear, letterbox-blit,
/// present. Returns false when the swapchain was out of date — the caller recreates
/// (with current window state) and may retry.
pub fn present(&mut self, window: &sdl3::video::Window, input: FrameInput) -> Result<bool> {
/// blend the console-UI `overlay` quad if one arrived, present. Returns false when
/// the swapchain was out of date — the caller recreates (with current window state)
/// and may retry.
pub fn present(
&mut self,
window: &sdl3::video::Window,
input: FrameInput,
overlay: Option<&OverlayFrame>,
) -> Result<bool> {
if self.extent.width == 0 || self.extent.height == 0 {
return Ok(true); // minimized — nothing to do
}
@@ -363,6 +580,18 @@ impl Presenter {
let hw = self.hw.as_ref().unwrap();
hw.csc.bind_planes(&self.device, f.luma_view, f.chroma_view);
}
if let Some(o) = overlay {
// Point the composite at this overlay image (same fence-wait safety).
let infos = [vk::DescriptorImageInfo::default()
.image_view(o.view)
.image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)];
let writes = [vk::WriteDescriptorSet::default()
.dst_set(self.overlay_pipe.desc_set)
.dst_binding(0)
.descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
.image_info(&infos)];
unsafe { self.device.update_descriptor_sets(&writes, &[]) };
}
let (index, _suboptimal) = match unsafe {
self.swap_d.acquire_next_image(
@@ -535,13 +764,79 @@ impl Presenter {
vk::Filter::LINEAR,
);
}
barrier(
&self.device,
self.cmd_buf,
swap_image,
vk::ImageLayout::TRANSFER_DST_OPTIMAL,
vk::ImageLayout::PRESENT_SRC_KHR,
);
if let Some(o) = overlay {
// Cross-submit visibility for the overlay image (Skia flushed it on this
// queue): same-layout barrier = execution + memory dependency only.
barrier(
&self.device,
self.cmd_buf,
o.image,
vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL,
vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL,
);
barrier(
&self.device,
self.cmd_buf,
swap_image,
vk::ImageLayout::TRANSFER_DST_OPTIMAL,
vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL,
);
// The composite pass blends the quad and ends the image PRESENT-ready.
self.device.cmd_begin_render_pass(
self.cmd_buf,
&vk::RenderPassBeginInfo::default()
.render_pass(self.overlay_pipe.render_pass)
.framebuffer(self.overlay_pipe.framebuffers[index as usize])
.render_area(vk::Rect2D {
offset: vk::Offset2D { x: 0, y: 0 },
extent: self.extent,
}),
vk::SubpassContents::INLINE,
);
self.device.cmd_bind_pipeline(
self.cmd_buf,
vk::PipelineBindPoint::GRAPHICS,
self.overlay_pipe.pipeline,
);
self.device.cmd_set_viewport(
self.cmd_buf,
0,
&[vk::Viewport {
x: 0.0,
y: 0.0,
width: self.extent.width as f32,
height: self.extent.height as f32,
min_depth: 0.0,
max_depth: 1.0,
}],
);
self.device.cmd_set_scissor(
self.cmd_buf,
0,
&[vk::Rect2D {
offset: vk::Offset2D { x: 0, y: 0 },
extent: self.extent,
}],
);
self.device.cmd_bind_descriptor_sets(
self.cmd_buf,
vk::PipelineBindPoint::GRAPHICS,
self.overlay_pipe.pipeline_layout,
0,
&[self.overlay_pipe.desc_set],
&[],
);
self.device.cmd_draw(self.cmd_buf, 3, 1, 0, 0);
self.device.cmd_end_render_pass(self.cmd_buf);
} else {
barrier(
&self.device,
self.cmd_buf,
swap_image,
vk::ImageLayout::TRANSFER_DST_OPTIMAL,
vk::ImageLayout::PRESENT_SRC_KHR,
);
}
self.device.end_command_buffer(self.cmd_buf)?;
let render_sem = self.render_sems[index as usize];
@@ -779,6 +1074,7 @@ impl Drop for Presenter {
if let Some(hw) = self.hw.take() {
hw.csc.destroy(&self.device);
}
self.overlay_pipe.destroy(&self.device);
for s in self.render_sems.drain(..) {
self.device.destroy_semaphore(s, None);
}