Files
punktfunk/crates/pf-presenter/src/vk/present.rs
T
enricobuehler dab40ed98e feat(client/windows): PyroWave decode + surface it in the GUI codec picker
The decoder was gated to Linux because, when it landed, the Windows client
still had its own in-process WinUI/D3D11 presenter and the PyroWave present
path there was an open question. That client has since been retired: Windows
now spawns the SAME Vulkan session presenter as Linux, and the decoder is
plain Vulkan compute on the presenter's device (no fds, no dmabuf, no D3D11
interop), so the question that gated it answered itself. pyrowave-sys already
builds on Windows too -- the Windows HOST encoder ships on it.

So this is a port by un-gating: every cfg(all(target_os = "linux", feature =
"pyrowave")) becomes any(linux, windows) -- decoder module, backend variant,
Decoder::new_pyrowave, the CODEC_PYROWAVE advertisement, the session pump's
opt-in/build/label arms, and the presenter's planar CSC pass. No new code.

Then offer it in the Windows GUI, which is what prompted this. It stays
preference-only (resolve_codec never auto-picks it) and a host or device that
can't do PyroWave just falls back down the ladder to HEVC.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 16:10:42 +02:00

900 lines
38 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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(any(target_os = "linux", windows), feature = "pyrowave"))]
FrameInput::PyroWave(f) => Some(f.color.is_pq()),
};
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(any(target_os = "linux", windows), 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(any(target_os = "linux", windows), 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(any(target_os = "linux", windows), 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 RGB 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 RGB from the
// decoder's VideoProcessor (sRGB BGRA8, or PQ RGB10A2 on the HDR ring —
// matching the HDR-mode video image), so there is no CSC pass; the blit
// converts 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(any(target_os = "linux", windows), 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];
// On-glass timing (T0.2): attach a monotonically increasing present id the
// PresentTimer's `vkWaitForPresentKHR` resolves to real visibility.
let ids = [self.next_present_id + 1];
let mut pid_info = vk::PresentIdKHR::default().present_ids(&ids);
let mut present_info = vk::PresentInfoKHR::default()
.wait_semaphores(&present_sems)
.swapchains(&swapchains)
.image_indices(&indices);
if self.present_timer.is_some() {
self.next_present_id += 1;
present_info = present_info.push_next(&mut pid_info);
}
// 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, &present_info)
};
match present_res {
Ok(_) => {
// A failed present's id may never signal — claimable only on Ok.
if self.present_timer.is_some() {
self.last_presented = Some((self.swapchain, self.next_present_id));
}
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(any(target_os = "linux", windows), 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],
&[],
);
// An HDR (PQ) pyrowave session carries P010-style 10-bit studio codes MSB-packed
// into 16-bit planes (design/pyrowave-444-hdr.md §2.2) — same sampling scale as
// the P010 path; SDR sessions are plain 8-bit BT.709 limited. Depth follows the
// colour contract (negotiation couples 10-bit ⟺ PQ for this codec).
let (depth, msb_packed) = if color.is_pq() {
(10, true)
} else {
(8, false)
};
let rows = csc_rows(color, depth, msb_packed);
// Mode 1 = PQ→SDR tonemap (PQ stream without an HDR10 surface); mode 0 passes
// the transfer through — identical to the NV12 arm above.
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,
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);
}
}