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
+15
View File
@@ -0,0 +1,15 @@
//! The Skia console UI (punktfunk-planning `linux-client-rearchitecture.md` §6): an
//! [`Overlay`] implementation rendering on the PRESENTER's Vulkan device into offscreen
//! RGBA images the presenter composites as one premultiplied quad. Skia never touches
//! the swapchain, and nothing here runs while the overlay has nothing to show — the
//! §6.1 invariants live or die in this crate.
//!
//! Milestone 1 (this file): the stats OSD panel + the capture-hint pill — small on
//! purpose, it proves the whole shared-device pipeline. The gamepad library moves in
//! next.
#[cfg(target_os = "linux")]
mod skia_overlay;
#[cfg(target_os = "linux")]
pub use skia_overlay::SkiaOverlay;
+330
View File
@@ -0,0 +1,330 @@
//! Skia on the presenter's device: `DirectContext` over the shared handles, a ring of
//! two offscreen render-target surfaces (the presenter runs one frame in flight and may
//! still be sampling the previous image while we render the next), damage-driven
//! redraws (content/size change only — an unchanged OSD costs zero GPU work per frame).
use anyhow::{anyhow, Context as _, Result};
use ash::vk as avk;
use ash::vk::Handle as _;
use pf_presenter::overlay::{FrameCtx, Overlay, OverlayFrame, SharedDevice};
use skia_safe::gpu::vk as skvk;
use skia_safe::gpu::{self, DirectContext, SurfaceOrigin};
use skia_safe::{Canvas, Color4f, Font, FontMgr, Paint, Point, RRect, Rect, Surface};
/// Skia's GPU resource budget — the OSD/HUD need a few MB; the console library will
/// revisit (the plan budgets 64 MB on Deck-class shared memory).
const RESOURCE_CACHE_BYTES: usize = 64 << 20;
/// One offscreen target: the Skia surface + the raw Vulkan handles the presenter
/// samples. The image is Skia-owned (freed with the surface); the view is ours.
struct Slot {
surface: Surface,
image: avk::Image,
view: avk::ImageView,
width: u32,
height: u32,
}
/// What the current ring slot has drawn — re-render only when this changes.
#[derive(PartialEq, Clone, Default)]
struct Drawn {
width: u32,
height: u32,
stats: Option<String>,
hint: Option<String>,
}
pub struct SkiaOverlay {
/// Set by `init`; `None` until then (and after an init failure the run loop drops
/// the whole overlay, so mid-session these are always `Some`).
gpu: Option<Gpu>,
slots: [Option<Slot>; 2],
/// Which slot the LAST returned frame lives in — the next render takes the other.
current: usize,
drawn: Drawn,
font: Option<Font>,
}
struct Gpu {
device: ash::Device,
queue_family_index: u32,
context: DirectContext,
// Keep the loader library + instance dispatch alive as long as the DirectContext
// (its baked fn pointers live inside libvulkan).
_entry: ash::Entry,
_instance: ash::Instance,
}
impl SkiaOverlay {
#[allow(clippy::new_without_default)]
pub fn new() -> SkiaOverlay {
SkiaOverlay {
gpu: None,
slots: [None, None],
current: 0,
drawn: Drawn::default(),
font: None,
}
}
}
impl Drop for SkiaOverlay {
/// The run loop quiesces the queue before dropping us; releasing the views + Skia
/// surfaces (which free their VkImages) is then safe. Field order drops the slots
/// before the DirectContext.
fn drop(&mut self) {
if let Some(gpu) = &mut self.gpu {
for slot in self.slots.iter_mut().flat_map(Option::take) {
unsafe { gpu.device.destroy_image_view(slot.view, None) };
drop(slot.surface);
}
gpu.context.flush_and_submit();
}
}
}
impl Overlay for SkiaOverlay {
fn init(&mut self, shared: &SharedDevice) -> Result<()> {
// Skia resolves its Vulkan entry points through us: instance-scoped names via
// the loader, device-scoped via the device — the exact same dispatch ash uses.
// Resolution completes inside `make_vulkan` (the DirectContext bakes its fn
// table); the closure and its clones end with `init`.
let entry = shared.entry.clone();
let instance = shared.instance.clone();
let get_proc = move |of: skvk::GetProcOf| -> *const std::ffi::c_void {
unsafe {
match of {
skvk::GetProcOf::Instance(raw_instance, name) => entry
.get_instance_proc_addr(avk::Instance::from_raw(raw_instance as _), name)
.map_or(std::ptr::null(), |f| f as *const std::ffi::c_void),
skvk::GetProcOf::Device(raw_device, name) => (instance.fp_v1_0()
.get_device_proc_addr)(
avk::Device::from_raw(raw_device as _), name
)
.map_or(std::ptr::null(), |f| f as *const std::ffi::c_void),
}
}
};
let backend = unsafe {
skvk::BackendContext::new(
shared.instance.handle().as_raw() as _,
shared.physical_device.as_raw() as _,
shared.device.handle().as_raw() as _,
(
shared.queue.as_raw() as _,
shared.queue_family_index as usize,
),
&get_proc,
)
};
let mut context = gpu::direct_contexts::make_vulkan(&backend, None)
.ok_or_else(|| anyhow!("Skia DirectContext over the shared device"))?;
context.set_resource_cache_limit(RESOURCE_CACHE_BYTES);
let typeface = FontMgr::new()
.match_family_style("monospace", skia_safe::FontStyle::normal())
.context("no monospace typeface via fontconfig")?;
self.font = Some(Font::new(typeface, 14.0));
self.gpu = Some(Gpu {
device: shared.device.clone(),
queue_family_index: shared.queue_family_index,
context,
_entry: shared.entry.clone(),
_instance: shared.instance.clone(),
});
tracing::info!("Skia console UI on the presenter's device");
Ok(())
}
fn handle_event(&mut self, _event: &sdl3::event::Event) -> bool {
false // the OSD/HUD consume nothing; the console library will
}
fn frame(&mut self, ctx: &FrameCtx) -> Result<Option<OverlayFrame>> {
if ctx.stats.is_none() && ctx.hint.is_none() {
self.drawn = Drawn::default(); // forget content so re-show re-renders
return Ok(None);
}
let want = Drawn {
width: ctx.width,
height: ctx.height,
stats: ctx.stats.map(str::to_owned),
hint: ctx.hint.map(str::to_owned),
};
if want == self.drawn {
// Unchanged — hand the presenter the already-rendered image.
return Ok(self
.slots[self.current]
.as_ref()
.map(|s| OverlayFrame {
image: s.image,
view: s.view,
width: s.width,
height: s.height,
}));
}
// Render into the OTHER slot — the presenter may still be sampling the current
// one (one frame in flight; the ring of two is exactly deep enough).
let next = 1 - self.current;
self.ensure_slot(next, ctx.width, ctx.height)?;
let gpu = self.gpu.as_mut().expect("init ran");
let slot = self.slots[next].as_mut().expect("just ensured");
let canvas = slot.surface.canvas();
canvas.clear(Color4f::new(0.0, 0.0, 0.0, 0.0));
let font = self.font.as_ref().expect("init ran");
if let Some(stats) = &want.stats {
draw_osd_panel(canvas, font, stats, 12.0, 12.0);
}
if let Some(hint) = &want.hint {
draw_hint_pill(canvas, font, hint, ctx.width, ctx.height);
}
// Flush on the shared queue, ending in SHADER_READ_ONLY on our family — the
// layout the presenter's composite samples (its own barrier covers visibility).
gpu.context.flush_surface_with_texture_state(
&mut slot.surface,
&gpu::FlushInfo::default(),
Some(&skvk::mutable_texture_states::new_vulkan(
skvk::ImageLayout::SHADER_READ_ONLY_OPTIMAL,
gpu.queue_family_index,
)),
);
gpu.context.submit(None);
self.current = next;
self.drawn = want;
Ok(Some(OverlayFrame {
image: slot.image,
view: slot.view,
width: slot.width,
height: slot.height,
}))
}
}
impl SkiaOverlay {
/// Make `slots[i]` a render target of exactly `width`×`height` (rebuilt on resize).
fn ensure_slot(&mut self, i: usize, width: u32, height: u32) -> Result<()> {
if self.slots[i]
.as_ref()
.is_some_and(|s| s.width == width && s.height == height)
{
return Ok(());
}
let gpu = self.gpu.as_mut().expect("init ran");
if let Some(old) = self.slots[i].take() {
// Any in-flight sampling of THIS slot ended two presents ago (the ring
// alternates and the presenter waits its fence before each record).
unsafe { gpu.device.destroy_image_view(old.view, None) };
}
let info = skia_safe::ImageInfo::new_n32_premul(
(width.max(1) as i32, height.max(1) as i32),
None,
);
let mut surface = gpu::surfaces::render_target(
&mut gpu.context,
gpu::Budgeted::Yes,
&info,
None,
SurfaceOrigin::TopLeft,
None,
false,
None,
)
.context("Skia render-target surface")?;
let texture =
gpu::surfaces::get_backend_texture(
&mut surface,
skia_safe::surface::BackendHandleAccess::FlushRead,
)
.context("surface backend texture")?;
let image_info = texture
.vulkan_image_info()
.context("backend texture is not Vulkan")?;
let image = avk::Image::from_raw(*image_info.image() as u64);
let view = unsafe {
gpu.device.create_image_view(
&avk::ImageViewCreateInfo::default()
.image(image)
.view_type(avk::ImageViewType::TYPE_2D)
.format(avk::Format::from_raw(image_info.format as i32))
.subresource_range(
avk::ImageSubresourceRange::default()
.aspect_mask(avk::ImageAspectFlags::COLOR)
.level_count(1)
.layer_count(1),
),
None,
)
}
.context("overlay image view")?;
self.slots[i] = Some(Slot {
surface,
image,
view,
width,
height,
});
Ok(())
}
}
/// The stats OSD: a translucent rounded panel, one text line per `\n` (the GTK OSD's
/// look, minus the toolkit).
fn draw_osd_panel(canvas: &Canvas, font: &Font, text: &str, x: f32, y: f32) {
let (_, metrics) = font.metrics();
let line_h = metrics.descent - metrics.ascent + metrics.leading;
let lines: Vec<&str> = text.lines().collect();
let widest = lines
.iter()
.map(|l| font.measure_str(l, None).0)
.fold(0.0f32, f32::max);
let (pad_x, pad_y) = (10.0, 8.0);
let panel = Rect::from_xywh(
x,
y,
widest + 2.0 * pad_x,
line_h * lines.len() as f32 + 2.0 * pad_y,
);
canvas.draw_rrect(
RRect::new_rect_xy(panel, 8.0, 8.0),
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.62), None),
);
let text_paint = Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.92), None);
for (i, line) in lines.iter().enumerate() {
canvas.draw_str(
line,
Point::new(
x + pad_x,
y + pad_y - metrics.ascent + line_h * i as f32,
),
font,
&text_paint,
);
}
}
/// The capture hint: a centered pill near the bottom edge (the GTK hint's position).
fn draw_hint_pill(canvas: &Canvas, font: &Font, text: &str, width: u32, height: u32) {
let (_, metrics) = font.metrics();
let line_h = metrics.descent - metrics.ascent;
let text_w = font.measure_str(text, None).0;
let (pad_x, pad_y) = (14.0, 8.0);
let w = text_w + 2.0 * pad_x;
let h = line_h + 2.0 * pad_y;
let x = (width as f32 - w) / 2.0;
let y = height as f32 - h - 24.0;
canvas.draw_rrect(
RRect::new_rect_xy(Rect::from_xywh(x, y, w, h), h / 2.0, h / 2.0),
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.62), None),
);
canvas.draw_str(
text,
Point::new(x + pad_x, y + pad_y - metrics.ascent),
font,
&Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.92), None),
);
}