refactor(presenter/W8): split vk.rs into vk/ directory module

Break the 2513-line pf-presenter/src/vk.rs into a vk/ directory module (mod.rs +
6 concern submodules), keeping ALL type definitions in vk/mod.rs so every submodule
(a descendant of vk) sees the Presenter/OverlayPipe/etc. private fields with no
field bumps:
  - vk/setup.rs      : Presenter::new + device/format/present-mode selection
  - vk/present.rs    : the per-frame present path (present + CSC record + AVVkFrame
                       sync) — HOT PATH, moved whole
  - vk/reconfig.rs   : swapchain recreate/resize + HDR reconfiguration
  - vk/resources.rs  : video-image/staging (re)build + Retired-frame destruction
  - vk/overlay_pipe.rs: the presenter-side overlay composite pipeline
  - vk/gpu.rs        : memory allocation, image barriers, geometry helpers (+ tests)
vk/mod.rs keeps FrameInput/Presenter/OverlayPipe/VideoImage/Staging/Retired/HwCtx*
+ the public accessors + Drop. Methods/free-fns a sibling submodule calls became
pub(super) (~18); zero field bumps, zero re-exports (Presenter/FrameInput never
leave mod.rs). lib.rs unchanged (`pub mod vk;` resolves to vk/mod.rs). The moved
overlay shader include_bytes! gained one `../` for the deeper dir. Pure move; no
behavior change; the hot present path keeps only static pub(super) calls (inlinable).

Verified both platforms: Linux (home-worker-5) clippy -p pf-presenter
(--all-targets -D warnings) + test; Windows (winbox, ASCII CARGO_HOME) clippy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-17 14:39:36 +02:00
parent 22a61e0b48
commit 61118cbdd4
8 changed files with 2577 additions and 2513 deletions
+866
View File
@@ -0,0 +1,866 @@
//! The per-frame present path (route input → video image → CSC → blit → present). HOT PATH.
use super::gpu::*;
use super::{FrameInput, Presenter, Retired};
use crate::csc::csc_rows;
#[cfg(target_os = "linux")]
use crate::dmabuf::{self, HwFrame};
use crate::overlay::OverlayFrame;
use anyhow::{bail, Context as _, Result};
use ash::vk;
use ash::vk::Handle as _;
use pf_client_core::video::VkVideoFrame;
impl Presenter {
/// 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,
/// 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
}
// SDR↔HDR follows the FRAMES' own signaling (the host flips PQ in-band):
// switch modes before anything touches this frame. Only where the surface
// offers HDR10 — otherwise PQ stays on the SDR swapchain and the CSC shader
// tonemaps (mode 1).
let frame_pq = match &input {
FrameInput::Redraw => None,
FrameInput::Cpu(f) => Some(f.color.is_pq()),
#[cfg(target_os = "linux")]
FrameInput::Dmabuf(d) => Some(d.color.is_pq()),
FrameInput::VkFrame(v) => Some(v.color.is_pq()),
#[cfg(windows)]
FrameInput::D3d11(d) => Some(d.color.is_pq()),
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
FrameInput::PyroWave(f) => Some(f.color.is_pq()), // always SDR today
};
if let Some(pq) = frame_pq {
// A PQ stream we can only tone-map (no HDR10 surface) is the silent failure behind
// "HDR isn't advertised": the compositor never sees an HDR-committing app. Say so
// once — its presence proves PQ IS arriving and the surface/compositor is the
// blocker (on the Deck: gamescope's WSI layer not visible in the flatpak sandbox);
// its absence, with a plain SDR stream, points back at the host not sending PQ.
if pq && self.hdr10_format.is_none() && !self.hdr_downgrade_warned {
self.hdr_downgrade_warned = true;
tracing::warn!(
"PQ (HDR10) stream tone-mapped to SDR — the surface offers no HDR10 \
colorspace, so no HDR is committed to the compositor. Under gamescope this \
usually means the gamescope Vulkan WSI layer is not visible in the sandbox."
);
}
let want = pq && self.hdr10_format.is_some();
if want != self.hdr_active {
self.set_hdr_mode(window, want)?;
}
}
// Hardware frames prepare before anything touches the queue: an import/view the
// driver rejects must fail out here, before this present consumed the acquire
// semaphore.
#[cfg(target_os = "linux")]
let mut hw_frame: Option<HwFrame> = None;
#[cfg(windows)]
let mut win_frame: Option<crate::d3d11::HwFrame> = None;
let mut vk_frame: Option<(VkVideoFrame, [vk::ImageView; 2])> = None;
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
let mut pyro_frame: Option<pf_client_core::video_pyrowave::PyroWavePlanarFrame> = None;
let cpu_frame = match input {
FrameInput::Redraw => None,
FrameInput::Cpu(f) => Some(f),
#[cfg(target_os = "linux")]
FrameInput::Dmabuf(d) => {
let hw = self
.hw
.as_ref()
.context("hardware frame without dmabuf support")?;
hw_frame = Some(dmabuf::import(&self.device, &hw.ext_mem_fd, d)?);
None
}
#[cfg(windows)]
FrameInput::D3d11(d) => {
let hw = self
.hw_win
.as_ref()
.context("D3D11 frame without win32 import support")?;
win_frame = Some(crate::d3d11::import(&self.device, &hw.ext_mem_win32, &d)?);
None
}
FrameInput::VkFrame(v) => {
let views = self.vkframe_plane_views(&v)?;
vk_frame = Some((v, views));
None
}
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
FrameInput::PyroWave(f) => {
pyro_frame = Some(f);
None
}
};
// One frame in flight: the fence covers the command buffer, the staging buffer
// AND the previously submitted hw frame — waiting makes all three reusable.
unsafe {
if self.submitted {
self.device.wait_for_fences(&[self.fence], true, u64::MAX)?;
self.submitted = false;
}
self.device.reset_fences(&[self.fence])?;
}
if let Some(old) = self.retired_hw.take() {
old.destroy(&self.device);
}
if let Some(f) = cpu_frame {
self.stage_frame(f)?;
}
#[cfg(target_os = "linux")]
if let Some(f) = &hw_frame {
if self
.video
.as_ref()
.is_none_or(|v| v.width != f.width || v.height != f.height)
{
self.rebuild_video_image(f.width, f.height)?;
tracing::info!(width = f.width, height = f.height, "video image (re)built");
}
// Safe while nothing in flight references the set — the fence wait above.
self.csc
.bind_planes(&self.device, f.luma_view, f.chroma_view);
}
#[cfg(windows)]
if let Some(f) = &win_frame {
if self
.video
.as_ref()
.is_none_or(|v| v.width != f.width || v.height != f.height)
{
self.rebuild_video_image(f.width, f.height)?;
tracing::info!(width = f.width, height = f.height, "video image (re)built");
}
}
if let Some((f, views)) = &vk_frame {
if self
.video
.as_ref()
.is_none_or(|v| v.width != f.width || v.height != f.height)
{
self.rebuild_video_image(f.width, f.height)?;
tracing::info!(width = f.width, height = f.height, "video image (re)built");
}
self.csc.bind_planes(&self.device, views[0], views[1]);
}
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
if let Some(f) = &pyro_frame {
if self
.video
.as_ref()
.is_none_or(|v| v.width != f.width || v.height != f.height)
{
self.rebuild_video_image(f.width, f.height)?;
tracing::info!(width = f.width, height = f.height, "video image (re)built");
}
let planar = self
.csc_planar
.as_ref()
.context("PyroWave frame but the device failed the pyrowave probe")?;
planar.bind_planes_planar(&self.device, f.views.map(vk::ImageView::from_raw));
}
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(
self.swapchain,
u64::MAX,
self.acquire_sem,
vk::Fence::null(),
)
} {
Ok(r) => r,
Err(vk::Result::ERROR_OUT_OF_DATE_KHR) => {
// Never submitted — the import (if any) dies here, GPU never saw it.
#[cfg(target_os = "linux")]
if let Some(f) = hw_frame {
f.destroy(&self.device);
}
#[cfg(windows)]
if let Some(f) = win_frame {
f.destroy(&self.device);
}
self.recreate_swapchain(window)?;
return Ok(false);
}
Err(e) => return Err(e).context("vkAcquireNextImageKHR"),
};
let swap_image = self.images[index as usize];
unsafe {
self.device.begin_command_buffer(
self.cmd_buf,
&vk::CommandBufferBeginInfo::default()
.flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT),
)?;
// Dmabuf frame: acquire the foreign planes, then the CSC pass renders
// NV12→RGBA into the video image (render pass ends it in TRANSFER_SRC for
// the blit below).
#[cfg(target_os = "linux")]
if let (Some(f), Some(v)) = (&hw_frame, &self.video) {
for view_image in [f.luma_image(), f.chroma_image()] {
foreign_acquire_barrier(&self.device, self.cmd_buf, view_image, self.qfi);
}
let extent = vk::Extent2D {
width: v.width,
height: v.height,
};
let ten_bit = f.is_p010();
self.record_csc(
v.framebuffer,
extent,
f.color,
if ten_bit { 10 } else { 8 },
ten_bit,
);
}
// D3D11 frame: acquire the imported BGRA texture from the external "queue
// family" (the keyed mutex on the submit is the actual cross-API sync) and
// blit it into the video image — the frame arrives as ready sRGB from the
// decoder's VideoProcessor, so there is no CSC pass; the blit converts the
// BGRA→RGBA component order. Same layout dance as the CPU staging path.
#[cfg(windows)]
if let (Some(f), Some(v)) = (&win_frame, &self.video) {
external_acquire_barrier(&self.device, self.cmd_buf, f.image(), self.qfi);
barrier(
&self.device,
self.cmd_buf,
v.image,
vk::ImageLayout::UNDEFINED,
vk::ImageLayout::TRANSFER_DST_OPTIMAL,
);
let extent = vk::Offset3D {
x: v.width as i32,
y: v.height as i32,
z: 1,
};
let blit = vk::ImageBlit::default()
.src_subresource(subresource_layers())
.src_offsets([vk::Offset3D::default(), extent])
.dst_subresource(subresource_layers())
.dst_offsets([vk::Offset3D::default(), extent]);
self.device.cmd_blit_image(
self.cmd_buf,
f.image(),
vk::ImageLayout::TRANSFER_SRC_OPTIMAL,
v.image,
vk::ImageLayout::TRANSFER_DST_OPTIMAL,
&[blit],
vk::Filter::NEAREST, // 1:1 — the composite blit below does the scaling
);
barrier(
&self.device,
self.cmd_buf,
v.image,
vk::ImageLayout::TRANSFER_DST_OPTIMAL,
vk::ImageLayout::TRANSFER_SRC_OPTIMAL,
);
}
// Vulkan-Video frame: the decoded image is already on THIS device. Read the
// live sync state under the frames lock (held through submission — the
// AVVulkanFramesContext contract), acquire from the decode queue family,
// then the same CSC pass.
let mut vk_sync: Option<VkFrameSync> = None;
if let (Some((f, _)), Some(v)) = (&vk_frame, &self.video) {
let sync = lock_vkframe(f);
vkframe_acquire_barrier(
&self.device,
self.cmd_buf,
vk::Image::from_raw(sync.image),
vk::ImageLayout::from_raw(sync.layout),
sync.queue_family,
self.qfi,
);
let extent = vk::Extent2D {
width: v.width,
height: v.height,
};
let ten_bit =
f.vk_format == vk::Format::G10X6_B10X6R10X6_2PLANE_420_UNORM_3PACK16.as_raw();
self.record_csc(
v.framebuffer,
extent,
f.color,
if ten_bit { 10 } else { 8 },
ten_bit,
);
vk_sync = Some(sync);
}
// PyroWave frame: the planes are already on THIS device, decode
// fence-complete and barriered to fragment sampling (GENERAL) by the
// decoder — no acquire needed, just the planar CSC pass.
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
if let (Some(f), Some(v)) = (&pyro_frame, &self.video) {
let extent = vk::Extent2D {
width: v.width,
height: v.height,
};
self.record_csc_planar(v.framebuffer, extent, f.color);
}
// New frame: staging → video image (stride carried by buffer_row_length).
if let (Some(f), Some(v), Some(s)) = (cpu_frame, &self.video, &self.staging) {
barrier(
&self.device,
self.cmd_buf,
v.image,
vk::ImageLayout::UNDEFINED,
vk::ImageLayout::TRANSFER_DST_OPTIMAL,
);
let region = vk::BufferImageCopy::default()
.buffer_row_length((f.stride / 4) as u32)
.image_subresource(subresource_layers())
.image_extent(vk::Extent3D {
width: v.width,
height: v.height,
depth: 1,
});
self.device.cmd_copy_buffer_to_image(
self.cmd_buf,
s.buffer,
v.image,
vk::ImageLayout::TRANSFER_DST_OPTIMAL,
&[region],
);
barrier(
&self.device,
self.cmd_buf,
v.image,
vk::ImageLayout::TRANSFER_DST_OPTIMAL,
vk::ImageLayout::TRANSFER_SRC_OPTIMAL,
);
}
// Swapchain image: discard old content, clear to black (the letterbox bars),
// blit the video in, hand to present.
barrier(
&self.device,
self.cmd_buf,
swap_image,
vk::ImageLayout::UNDEFINED,
vk::ImageLayout::TRANSFER_DST_OPTIMAL,
);
self.device.cmd_clear_color_image(
self.cmd_buf,
swap_image,
vk::ImageLayout::TRANSFER_DST_OPTIMAL,
&vk::ClearColorValue {
float32: [0.0, 0.0, 0.0, 1.0],
},
&[subresource_range()],
);
if let Some(v) = &self.video {
let (dst0, dst1) = letterbox(self.extent, v.width, v.height);
let blit = vk::ImageBlit::default()
.src_subresource(subresource_layers())
.src_offsets([
vk::Offset3D { x: 0, y: 0, z: 0 },
vk::Offset3D {
x: v.width as i32,
y: v.height as i32,
z: 1,
},
])
.dst_subresource(subresource_layers())
.dst_offsets([dst0, dst1]);
self.device.cmd_blit_image(
self.cmd_buf,
v.image,
vk::ImageLayout::TRANSFER_SRC_OPTIMAL,
swap_image,
vk::ImageLayout::TRANSFER_DST_OPTIMAL,
&[blit],
vk::Filter::LINEAR,
);
}
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];
let cmd_bufs = [self.cmd_buf];
let mut wait_sems = vec![self.acquire_sem];
let mut wait_stages = vec![vk::PipelineStageFlags::TRANSFER];
let mut signal_sems = vec![render_sem];
// The Vulkan-Video frame's timeline semaphore: wait for the decoder's value,
// signal value+1 when our reads are done (FFmpeg's per-submission contract).
let mut wait_values = vec![0u64];
let mut signal_values = vec![0u64];
if let Some(sync) = &vk_sync {
let sem = vk::Semaphore::from_raw(sync.semaphore);
wait_sems.push(sem);
wait_stages.push(vk::PipelineStageFlags::FRAGMENT_SHADER);
wait_values.push(sync.sem_value);
signal_sems.push(sem);
signal_values.push(sync.sem_value + 1);
}
let mut timeline = vk::TimelineSemaphoreSubmitInfo::default()
.wait_semaphore_values(&wait_values)
.signal_semaphore_values(&signal_values);
let mut submit = vk::SubmitInfo::default()
.wait_semaphores(&wait_sems)
.wait_dst_stage_mask(&wait_stages)
.command_buffers(&cmd_bufs)
.signal_semaphores(&signal_sems);
if vk_sync.is_some() {
submit = submit.push_next(&mut timeline);
}
// D3D11 frame: bracket the submit in the shared texture's keyed mutex, key 0
// both ways (the decode side copies under acquire(0)/release(0) too) — the
// GPU-side acquire is what orders our sampling after the decoder's copy, and
// our completion release is what unblocks the ring slot's reuse.
#[cfg(windows)]
let keyed_mem;
#[cfg(windows)]
let keyed_keys = [0u64];
#[cfg(windows)]
let keyed_timeouts = [2000u32];
#[cfg(windows)]
let mut keyed_info;
#[cfg(windows)]
if let Some(f) = &win_frame {
// Bisect knob: PUNKTFUNK_D3D11_NO_MUTEX=1 skips the acquire/release pair
// (torn frames possible — debugging only).
if std::env::var_os("PUNKTFUNK_D3D11_NO_MUTEX").is_none() {
keyed_mem = [f.memory()];
keyed_info = vk::Win32KeyedMutexAcquireReleaseInfoKHR::default()
.acquire_syncs(&keyed_mem)
.acquire_keys(&keyed_keys)
.acquire_timeouts(&keyed_timeouts)
.release_syncs(&keyed_mem)
.release_keys(&keyed_keys);
submit = submit.push_next(&mut keyed_info);
}
}
let submitted = {
// Queue external sync vs the pump's FFmpeg submits (see `queue_lock`).
let _q = self.queue_lock.guard();
self.device.queue_submit(self.queue, &[submit], self.fence)
};
// Write the new sync state back and release the frames lock REGARDLESS of
// the submit outcome (an abandoned lock would wedge the decoder).
if let Some(sync) = vk_sync.take() {
let ok = submitted.is_ok();
unlock_vkframe(
vk_frame
.as_ref()
.map(|(f, _)| f)
.expect("vk_sync implies vk_frame"),
&sync,
ok,
self.qfi,
);
}
submitted?;
self.submitted = true;
// The hw frame is on the GPU now — park it until the fence proves the reads
// done (destroyed at the next present's fence wait, or in Drop). At most one
// of hw_frame/vk_frame is set (they route from the same `input`).
self.retired_hw = vk_frame
.take()
.map(|(frame, views)| Retired::Vk { frame, views });
#[cfg(target_os = "linux")]
if let Some(f) = hw_frame.take() {
self.retired_hw = Some(Retired::Dmabuf(f));
}
#[cfg(windows)]
if let Some(f) = win_frame.take() {
self.retired_hw = Some(Retired::D3d11(f));
}
let swapchains = [self.swapchain];
let indices = [index];
let present_sems = [render_sem];
// Same queue external-sync rule as the submit above. Scoped tightly: the
// OUT_OF_DATE arm re-enters the lock via recreate_swapchain's queue drain.
let present_res = {
let _q = self.queue_lock.guard();
self.swap_d.queue_present(
self.queue,
&vk::PresentInfoKHR::default()
.wait_semaphores(&present_sems)
.swapchains(&swapchains)
.image_indices(&indices),
)
};
match present_res {
Ok(_) => Ok(true),
Err(vk::Result::ERROR_OUT_OF_DATE_KHR) => {
self.recreate_swapchain(window)?;
Ok(false)
}
Err(e) => Err(e).context("vkQueuePresentKHR"),
}
}
}
/// Record the NV12→RGBA CSC pass into the video image (framebuffer): fullscreen
/// triangle, CICP-driven push-constant rows. Shared by the dmabuf and Vulkan-Video
/// paths — only the plane views bound beforehand differ.
///
/// # Safety
/// `self.cmd_buf` must be in the recording state; the CSC descriptor set must point
/// at live plane views.
unsafe fn record_csc(
&self,
framebuffer: vk::Framebuffer,
extent: vk::Extent2D,
color: pf_client_core::video::ColorDesc,
depth: u8,
msb_packed: bool,
) {
unsafe {
self.device.cmd_begin_render_pass(
self.cmd_buf,
&vk::RenderPassBeginInfo::default()
.render_pass(self.csc.render_pass)
.framebuffer(framebuffer)
.render_area(vk::Rect2D {
offset: vk::Offset2D { x: 0, y: 0 },
extent,
}),
vk::SubpassContents::INLINE,
);
self.device.cmd_bind_pipeline(
self.cmd_buf,
vk::PipelineBindPoint::GRAPHICS,
self.csc.pipeline,
);
self.device.cmd_set_viewport(
self.cmd_buf,
0,
&[vk::Viewport {
x: 0.0,
y: 0.0,
width: extent.width as f32,
height: 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.device.cmd_bind_descriptor_sets(
self.cmd_buf,
vk::PipelineBindPoint::GRAPHICS,
self.csc.pipeline_layout,
0,
&[self.csc.desc_set],
&[],
);
let rows = csc_rows(color, depth, msb_packed);
// Mode 1 = PQ→SDR tonemap (a PQ stream without an HDR10 surface); mode 0
// passes the transfer through (SDR as-is, or PQ onto the HDR10 swapchain).
let mode = if color.is_pq() && !self.hdr_active {
1.0f32
} else {
0.0
};
let peak = std::env::var("PUNKTFUNK_TONEMAP_PEAK")
.ok()
.and_then(|v| v.parse::<f32>().ok())
.unwrap_or(4.9); // ≈1000 nits over the 203-nit reference
let mut pc = [0f32; 16];
pc[..12].copy_from_slice(bytemuck_rows(&rows));
pc[12] = mode;
pc[13] = peak;
let bytes = std::slice::from_raw_parts(pc.as_ptr().cast::<u8>(), 64);
self.device.cmd_push_constants(
self.cmd_buf,
self.csc.pipeline_layout,
vk::ShaderStageFlags::FRAGMENT,
0,
bytes,
);
self.device.cmd_draw(self.cmd_buf, 3, 1, 0, 0);
self.device.cmd_end_render_pass(self.cmd_buf);
}
}
/// [`record_csc`] over the planar (PyroWave) pass — always 8-bit, no MSB packing.
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
unsafe fn record_csc_planar(
&self,
framebuffer: vk::Framebuffer,
extent: vk::Extent2D,
color: pf_client_core::video::ColorDesc,
) {
// The planar pass exists whenever a PyroWave frame reached us (checked at bind).
let Some(planar) = self.csc_planar.as_ref() else {
return;
};
unsafe {
self.device.cmd_begin_render_pass(
self.cmd_buf,
&vk::RenderPassBeginInfo::default()
.render_pass(planar.render_pass)
.framebuffer(framebuffer)
.render_area(vk::Rect2D {
offset: vk::Offset2D { x: 0, y: 0 },
extent,
}),
vk::SubpassContents::INLINE,
);
self.device.cmd_bind_pipeline(
self.cmd_buf,
vk::PipelineBindPoint::GRAPHICS,
planar.pipeline,
);
self.device.cmd_set_viewport(
self.cmd_buf,
0,
&[vk::Viewport {
x: 0.0,
y: 0.0,
width: extent.width as f32,
height: 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.device.cmd_bind_descriptor_sets(
self.cmd_buf,
vk::PipelineBindPoint::GRAPHICS,
planar.pipeline_layout,
0,
&[planar.desc_set],
&[],
);
let rows = csc_rows(color, 8, false);
let mut pc = [0f32; 16];
pc[..12].copy_from_slice(bytemuck_rows(&rows));
pc[12] = 0.0; // SDR passthrough — PyroWave has no PQ path
pc[13] = 0.0;
let bytes = std::slice::from_raw_parts(pc.as_ptr().cast::<u8>(), 64);
self.device.cmd_push_constants(
self.cmd_buf,
planar.pipeline_layout,
vk::ShaderStageFlags::FRAGMENT,
0,
bytes,
);
self.device.cmd_draw(self.cmd_buf, 3, 1, 0, 0);
self.device.cmd_end_render_pass(self.cmd_buf);
}
}
/// Per-plane views over a Vulkan-Video frame's multiplanar image — the CSC pass's
/// exact sampling contract (the frames pool was created MUTABLE_FORMAT for this).
/// 8-bit NV12 (R8 + R8G8) and 10-bit P010/X6 (R10X6 + R10X6G10X6).
fn vkframe_plane_views(&self, f: &VkVideoFrame) -> Result<[vk::ImageView; 2]> {
let (luma_fmt, chroma_fmt) = if f.vk_format == vk::Format::G8_B8R8_2PLANE_420_UNORM.as_raw()
{
(vk::Format::R8_UNORM, vk::Format::R8G8_UNORM)
} else if f.vk_format == vk::Format::G10X6_B10X6R10X6_2PLANE_420_UNORM_3PACK16.as_raw() {
(
vk::Format::R10X6_UNORM_PACK16,
vk::Format::R10X6G10X6_UNORM_2PACK16,
)
} else {
bail!(
"Vulkan-Video pool format {} unsupported (expected 2-plane 4:2:0, 8/10-bit)",
f.vk_format
);
};
// img[0] is creation-constant (only the sync fields need the frames lock).
let image =
vk::Image::from_raw(
unsafe { (*(f.vkframe as *const pf_ffvk::AVVkFrame)).img[0] } as u64,
);
let make = |aspect: vk::ImageAspectFlags, format: vk::Format| {
unsafe {
self.device.create_image_view(
&vk::ImageViewCreateInfo::default()
.image(image)
.view_type(vk::ImageViewType::TYPE_2D)
.format(format)
.subresource_range(
vk::ImageSubresourceRange::default()
.aspect_mask(aspect)
.level_count(1)
.layer_count(1),
),
None,
)
}
.context("vk-frame plane view")
};
let luma = make(vk::ImageAspectFlags::PLANE_0, luma_fmt)?;
let chroma = match make(vk::ImageAspectFlags::PLANE_1, chroma_fmt) {
Ok(v) => v,
Err(e) => {
unsafe { self.device.destroy_image_view(luma, None) };
return Err(e);
}
};
Ok([luma, chroma])
}
}
/// Flatten the 3×vec4 rows for the push-constant block.
fn bytemuck_rows(rows: &[[f32; 4]; 3]) -> &[f32] {
// SAFETY: [[f32;4];3] is 12 contiguous f32s.
unsafe { std::slice::from_raw_parts(rows.as_ptr().cast::<f32>(), 12) }
}
/// The live sync state of an `AVVkFrame`, snapshotted under the frames lock.
struct VkFrameSync {
image: u64,
semaphore: u64,
sem_value: u64,
layout: i32,
queue_family: u32,
}
/// Lock the frame and read its live sync state (the presenter's submit must wait
/// `sem_value` and signal `sem_value + 1`). The lock is held until [`unlock_vkframe`].
// bindgen's enum repr is target-dependent (u32 Linux/clang, i32 MSVC) — the layout cast
// is required on one platform and a no-op on the other.
#[allow(clippy::unnecessary_cast)]
fn lock_vkframe(f: &VkVideoFrame) -> VkFrameSync {
unsafe {
let lock: unsafe extern "C" fn(*mut pf_ffvk::AVHWFramesContext, *mut pf_ffvk::AVVkFrame) =
std::mem::transmute(f.lock_frame);
let fc = f.frames_ctx as *mut pf_ffvk::AVHWFramesContext;
let vkf = f.vkframe as *mut pf_ffvk::AVVkFrame;
lock(fc, vkf);
VkFrameSync {
image: (*vkf).img[0] as u64,
semaphore: (*vkf).sem[0] as u64,
sem_value: (*vkf).sem_value[0],
layout: (*vkf).layout[0] as i32,
queue_family: (*vkf).queue_family[0],
}
}
}
/// Write the post-submission state back (FFmpeg waits these on its next use of the
/// frame) and release the lock. On a failed submit only the lock is released.
fn unlock_vkframe(f: &VkVideoFrame, sync: &VkFrameSync, submitted: bool, graphics_qf: u32) {
unsafe {
let vkf = f.vkframe as *mut pf_ffvk::AVVkFrame;
if submitted {
(*vkf).sem_value[0] = sync.sem_value + 1;
(*vkf).layout[0] =
vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL.as_raw() as pf_ffvk::VkImageLayout;
if sync.queue_family != vk::QUEUE_FAMILY_IGNORED {
(*vkf).queue_family[0] = graphics_qf;
}
}
let unlock: unsafe extern "C" fn(*mut pf_ffvk::AVHWFramesContext, *mut pf_ffvk::AVVkFrame) =
std::mem::transmute(f.unlock_frame);
unlock(f.frames_ctx as *mut pf_ffvk::AVHWFramesContext, vkf);
}
}