feat(video): HDR10/P010 end to end (phase 6)

The client now advertises VIDEO_CAP_10BIT|HDR and carries the result all
the way to glass:

- csc_rows is bit-depth exact (10-bit limited code points differ from
  8-bit by ~half a code) and folds in the P010/X6 MSB-packing factor;
  new 10-bit white/black tests.
- The CSC shader grows a params block: mode 0 passes the transfer
  through (SDR as-is, or PQ onto an HDR10 swapchain); mode 1 tonemaps
  PQ→SDR in-shader (ST.2084 EOTF, 203-nit reference white exposure,
  BT.2020→709, soft maxRGB rolloff, sRGB encode) for desktops without
  an HDR surface. PUNKTFUNK_TONEMAP_PEAK tunes the rolloff.
- The presenter probes VK_EXT_swapchain_colorspace + an HDR10/ST.2084
  10-bit surface format and flips modes in-band with the stream's PQ
  signaling: fence-quiesce, then CSC pass + video image (10-bit
  A2B10G10R10 intermediate — PQ in 8 bits bands) + overlay pipe +
  swapchain rebuild through the deferred-destroy rules.
- P010 decodes through all three paths: Vulkan Video (X6 multiplanar
  pool, R10X6 plane views), VAAPI dmabuf (R16/RG1616 plane imports),
  software (swscale as before).
- session pump advertises the caps; the host still gates Main10 behind
  its PUNKTFUNK_10BIT policy.

Probed on glass hardware: the KDE/NVIDIA surface exposes
A2B10G10R10+HDR10_ST2084, so true PQ passthrough is available there.
Known v1 gaps: software-decode PQ shows untonemapped (8-bit RGBA
carries the transfer baked); the SDR overlay composites unscaled onto
an HDR10 surface (dim OSD); no vkSetHdrMetadataEXT yet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 22:31:48 +02:00
parent c299a41a67
commit 4543a3f529
10 changed files with 416 additions and 153 deletions
+70 -16
View File
@@ -1,8 +1,20 @@
// NV12 → RGBA with the stream's CICP signaling — the Vulkan port of the GL presenter's
// fragment shader (video_gl.rs). The YUV→RGB matrix + range expansion arrive as three
// push-constant rows precomputed on the CPU (csc.rs `csc_rows`): rgb[i] = dot(r_i.xyz,
// yuv) + r_i.w — one shader for BT.601/709/2020, full/limited. The chroma plane is
// half-res; the linear sampler interpolates, same as the GL path.
// YCbCr (2-plane 4:2:0) → RGBA with the stream's CICP signaling — the Vulkan port of
// the GL presenter's fragment shader, grown depth- and HDR-aware.
//
// The YUV→RGB matrix + range expansion arrive as three push-constant rows precomputed
// on the CPU (csc.rs `csc_rows` — bit-depth exact, including the P010/X6 MSB-packing
// factor): rgb[i] = dot(r_i.xyz, yuv) + r_i.w. One shader for BT.601/709/2020,
// full/limited, 8- and 10-bit. The chroma plane is half-res; the linear sampler
// interpolates, same as the GL path.
//
// params.x selects the output mode:
// 0 — passthrough: the transfer stays baked (SDR BT.709 shown as-is; PQ BT.2020
// written to an HDR10 swapchain that expects exactly PQ-encoded values).
// 1 — PQ → SDR tonemap (an HDR stream on a desktop without an HDR10 surface):
// PQ EOTF → linear light (nits/10000), exposure anchored at the 203-nit HDR
// reference white, BT.2020→709 primaries, a soft maxRGB rolloff for highlights
// (BT.2390-flavored simplicity, not libplacebo), then sRGB encode.
// params.y = tonemap peak (display-relative, ~= peak_nits / 203).
//
// Regenerate: shaders/build.sh (committed .spv, no build-time toolchain).
#version 450
@@ -17,20 +29,62 @@ layout(push_constant) uniform Csc {
vec4 r0;
vec4 r1;
vec4 r2;
vec4 params; // x: mode, y: tonemap peak, z/w: reserved
} pc;
// SMPTE ST.2084 (PQ) EOTF: code value → display-referred linear, normalized to 1.0 =
// 10000 nits.
vec3 pq_eotf(vec3 e) {
const float m1 = 0.1593017578125; // 2610/16384
const float m2 = 78.84375; // 2523/4096 * 128
const float c1 = 0.8359375; // 3424/4096
const float c2 = 18.8515625; // 2413/4096 * 32
const float c3 = 18.6875; // 2392/4096 * 32
vec3 p = pow(max(e, vec3(0.0)), vec3(1.0 / m2));
return pow(max(p - c1, vec3(0.0)) / (c2 - c3 * p), vec3(1.0 / m1));
}
// BT.2020 → BT.709 primaries (linear light).
vec3 bt2020_to_709(vec3 c) {
return mat3(
1.6605, -0.1246, -0.0182,
-0.5876, 1.1329, -0.1006,
-0.0728, -0.0083, 1.1187
) * c;
}
// Linear → sRGB OETF.
vec3 srgb_oetf(vec3 c) {
c = clamp(c, 0.0, 1.0);
bvec3 lo = lessThanEqual(c, vec3(0.0031308));
vec3 hi = 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055;
return mix(hi, c * 12.92, vec3(lo));
}
void main() {
vec3 yuv = vec3(texture(u_y, v_uv).r, texture(u_c, v_uv).rg);
frag = vec4(
clamp(
vec3(
dot(pc.r0.xyz, yuv) + pc.r0.w,
dot(pc.r1.xyz, yuv) + pc.r1.w,
dot(pc.r2.xyz, yuv) + pc.r2.w
),
0.0,
1.0
),
1.0
vec3 rgb = vec3(
dot(pc.r0.xyz, yuv) + pc.r0.w,
dot(pc.r1.xyz, yuv) + pc.r1.w,
dot(pc.r2.xyz, yuv) + pc.r2.w
);
if (pc.params.x > 0.5) {
// PQ BT.2020 → SDR BT.709: linearize, anchor exposure at the 203-nit HDR
// reference white (SDR diffuse white), convert primaries, roll off highlights.
vec3 lin = pq_eotf(clamp(rgb, 0.0, 1.0)) * (10000.0 / 203.0);
lin = max(bt2020_to_709(lin), vec3(0.0));
float peak = max(pc.params.y, 1.0001);
float l = max(lin.r, max(lin.g, lin.b));
if (l > 1.0) {
// Soft maxRGB rolloff: identity below 1.0, asymptotic to `peak` above —
// keeps colors from clipping to white the way per-channel clamp would.
float mapped = 1.0 + (l - 1.0) / (1.0 + (l - 1.0) / (peak - 1.0));
lin *= mapped / l;
}
rgb = srgb_oetf(lin);
} else {
rgb = clamp(rgb, 0.0, 1.0);
}
frag = vec4(rgb, 1.0);
}
Binary file not shown.
+46 -15
View File
@@ -11,9 +11,15 @@ use anyhow::{Context as _, Result};
use ash::vk;
use pf_client_core::video::ColorDesc;
/// The push-constant block: three vec4 rows, `rgb[i] = dot(r[i].xyz, yuv) + r[i].w`.
/// One layout for BT.601/709/2020 × full/limited; PQ transfer joins in the HDR phase.
pub fn csc_rows(desc: ColorDesc) -> [[f32; 4]; 3] {
/// The push-constant block's matrix half: three vec4 rows,
/// `rgb[i] = dot(r[i].xyz, yuv) + r[i].w` — bit-depth exact.
///
/// `depth` picks the limited-range code points (8-bit: 16/235/240 over 255; 10-bit:
/// 64/940/960 over 1023 — NOT the same normalized values, the difference is ~half a
/// code). `msb_packed` folds in the P010/X6 packing factor: 10 significant bits live in
/// the MSBs of 16, so a UNORM16 sample reads `code·64/65535` — multiplying by
/// `65535/65472` recovers exact `code/1023`.
pub fn csc_rows(desc: ColorDesc, depth: u8, msb_packed: bool) -> [[f32; 4]; 3] {
// BT.601 (5/6), BT.2020 (9/10); everything else — incl. unspecified — is the host's
// BT.709 SDR default (mirrors the software path's swscale coefficient choice).
let (kr, kb) = match desc.matrix {
@@ -22,13 +28,22 @@ pub fn csc_rows(desc: ColorDesc) -> [[f32; 4]; 3] {
_ => (0.2126, 0.0722),
};
let kg = 1.0 - kr - kb;
let max = f64::from((1u32 << depth) - 1); // 255 / 1023
let step = f64::from(1u32 << (depth - 8)); // code points per 8-bit step: 1 / 4
let pack = if msb_packed { 65535.0 / 65472.0 } else { 1.0 };
let (sy, oy, sc) = if desc.full_range {
(1.0f64, 0.0f64, 1.0f64)
(pack, 0.0f64, pack)
} else {
(255.0 / 219.0, -16.0 / 255.0, 255.0 / 224.0)
(
pack * max / (219.0 * step),
-(16.0 * step) / max,
pack * max / (224.0 * step),
)
};
// rgb = M * (yuv + off) = M*yuv + M*off — rows of M with the offset dot folded into w.
let off = [oy, -0.5, -0.5];
// rgb = M * (yuv + off) = M*yuv + M*off — rows of M with the offset dot folded into
// w. `yuv` is the SAMPLED (packed) value, so the offsets divide by the packing
// factor to land on the same scale.
let off = [oy / pack, -0.5 / pack, -0.5 / pack];
let m = [
[sy, 0.0, 2.0 * (1.0 - kr) * sc],
[
@@ -58,12 +73,14 @@ pub struct CscPass {
}
impl CscPass {
pub fn new(device: &ash::Device) -> Result<CscPass> {
// One color attachment: the presenter's R8G8B8A8 video image. Content is fully
/// `attachment_format` = the video image's format: R8G8B8A8 for SDR, a 10-bit
/// format when the pass writes PQ (8 bits would band the PQ curve visibly).
pub fn new(device: &ash::Device, attachment_format: vk::Format) -> Result<CscPass> {
// One color attachment: the presenter's video image. Content is fully
// overwritten (DONT_CARE load), and the pass ends in TRANSFER_SRC so the
// existing letterbox blit consumes it with no extra barrier.
let attachment = [vk::AttachmentDescription::default()
.format(vk::Format::R8G8B8A8_UNORM)
.format(attachment_format)
.samples(vk::SampleCountFlags::TYPE_1)
.load_op(vk::AttachmentLoadOp::DONT_CARE)
.store_op(vk::AttachmentStoreOp::STORE)
@@ -139,7 +156,7 @@ impl CscPass {
let set_layouts = [set_layout];
let push = [vk::PushConstantRange::default()
.stage_flags(vk::ShaderStageFlags::FRAGMENT)
.size(48)]; // three vec4 rows
.size(64)]; // three vec4 rows + a params vec4 (mode, tonemap peak)
let pipeline_layout = unsafe {
device.create_pipeline_layout(
&vk::PipelineLayoutCreateInfo::default()
@@ -340,11 +357,25 @@ mod tests {
})
}
/// 10-bit limited MSB-packed (P010/X6): reference white Y=940, black Y=64, neutral
/// chroma 512 — sampled as UNORM16 of `code << 6`.
#[test]
fn bt2020_10bit_limited_white_black() {
let rows = csc_rows(desc(9, false), 10, true);
let s = |code: u32| ((code << 6) as f32) / 65535.0;
let white = apply(&rows, [s(940), s(512), s(512)]);
let black = apply(&rows, [s(64), s(512), s(512)]);
for (w, b) in white.iter().zip(black) {
assert!((w - 1.0).abs() < 0.002, "white {white:?}");
assert!(b.abs() < 0.002, "black {black:?}");
}
}
/// Reference white (Y=235, U=V=128 limited) → RGB 1.0; reference black (Y=16) → 0.0
/// — the GL presenter's test, in row form.
#[test]
fn bt709_limited_white_black() {
let rows = csc_rows(desc(1, false));
let rows = csc_rows(desc(1, false), 8, false);
let white = apply(&rows, [235.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0]);
let black = apply(&rows, [16.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0]);
for (w, b) in white.iter().zip(black) {
@@ -357,12 +388,12 @@ mod tests {
/// matrix-code dispatch), same as the GL presenter's test.
#[test]
fn full_range_and_red_excursion() {
let rows = csc_rows(desc(5, true));
let rows = csc_rows(desc(5, true), 8, false);
let white = apply(&rows, [1.0, 0.5, 0.5]);
assert!(white.iter().all(|v| (v - 1.0).abs() < 1e-5), "{white:?}");
let red = apply(&rows, [0.0, 0.5, 1.0]);
assert!((red[0] - 2.0 * (1.0 - 0.299) * 0.5).abs() < 1e-4, "{red:?}");
let rows709 = csc_rows(desc(1, true));
let rows709 = csc_rows(desc(1, true), 8, false);
let red709 = apply(&rows709, [0.0, 0.5, 1.0]);
assert!(
(red709[0] - 2.0 * (1.0 - 0.2126) * 0.5).abs() < 1e-4,
@@ -377,7 +408,7 @@ mod tests {
fn rows_match_the_gl_matrix_form() {
for (matrix, full) in [(1u8, false), (1, true), (5, false), (9, false), (9, true)] {
let d = desc(matrix, full);
let rows = csc_rows(d);
let rows = csc_rows(d, 8, false);
// Reimplementation of video_gl::yuv_to_rgb's application for comparison.
let (kr, kb) = match matrix {
5 | 6 => (0.299f32, 0.114f32),
+23 -10
View File
@@ -1,4 +1,5 @@
//! VAAPI dmabuf → Vulkan import: per-plane `VkImage`s (R8 luma + GR88 chroma) with the
//! VAAPI dmabuf → Vulkan import: per-plane `VkImage`s (R8/GR88 for NV12, R16/GR1616
//! for 10-bit P010) with the
//! surface's explicit DRM format modifier — the same layer-wise import the EGL presenter
//! (`video_gl.rs`) proved on this hardware, minus the toolkit. Same-Mesa export/import
//! is the contract; anything a driver rejects surfaces as a clean error and the caller
@@ -9,8 +10,10 @@ use ash::vk;
use pf_client_core::video::{DmabufFrame, DrmFrameGuard};
use std::os::fd::{BorrowedFd, IntoRawFd as _};
/// `fourcc('N','V','1','2')` — the only VAAPI decoder output today (8-bit 4:2:0).
/// `fourcc('N','V','1','2')` — 8-bit 4:2:0 VAAPI output.
const DRM_FORMAT_NV12: u32 = 0x3231_564e;
/// `fourcc('P','0','1','0')` — 10-bit 4:2:0, 10 bits MSB-aligned in 16 (the HDR path).
const DRM_FORMAT_P010: u32 = 0x3031_3050;
const DRM_FORMAT_MOD_INVALID: u64 = 0x00ff_ffff_ffff_ffff;
/// `DRM_FORMAT_MOD_LINEAR` — the fallback when the export carried no explicit modifier.
const DRM_FORMAT_MOD_LINEAR: u64 = 0;
@@ -34,6 +37,8 @@ pub struct HwFrame {
pub color: pf_client_core::video::ColorDesc,
pub width: u32,
pub height: u32,
/// 10-bit MSB-packed (P010) — the CSC picks its depth-exact rows off this.
fourcc: u32,
images: [vk::Image; 2],
memories: [vk::DeviceMemory; 2],
views: [vk::ImageView; 2],
@@ -41,6 +46,11 @@ pub struct HwFrame {
}
impl HwFrame {
/// 10-bit MSB-packed layout (P010)?
pub fn is_p010(&self) -> bool {
self.fourcc == DRM_FORMAT_P010
}
/// The raw plane images — the presenter's foreign-acquire barriers need them.
pub fn luma_image(&self) -> vk::Image {
self.images[0]
@@ -80,11 +90,13 @@ pub fn import(
if std::env::var_os("PUNKTFUNK_HW_FAULT").is_some_and(|v| v == "import") {
bail!("injected import failure (PUNKTFUNK_HW_FAULT=import)");
}
if frame.fourcc != DRM_FORMAT_NV12 {
bail!("hw presenter handles NV12 only (got {:#x})", frame.fourcc);
}
let (luma_fmt, chroma_fmt) = match frame.fourcc {
DRM_FORMAT_NV12 => (vk::Format::R8_UNORM, vk::Format::R8G8_UNORM),
DRM_FORMAT_P010 => (vk::Format::R16_UNORM, vk::Format::R16G16_UNORM),
other => bail!("hw presenter handles NV12/P010 only (got {other:#x})"),
};
if frame.planes.len() < 2 {
bail!("NV12 needs 2 planes (got {})", frame.planes.len());
bail!("2-plane 4:2:0 needs 2 planes (got {})", frame.planes.len());
}
// EGL could leave an INVALID modifier to the driver's implied choice; explicit-
// modifier images can't — LINEAR is the only honest guess (debug-visible if wrong).
@@ -102,7 +114,7 @@ pub fn import(
ext_mem_fd,
frame.width,
frame.height,
vk::Format::R8_UNORM,
luma_fmt,
y.fd,
y.offset,
y.stride,
@@ -114,7 +126,7 @@ pub fn import(
ext_mem_fd,
frame.width.div_ceil(2),
frame.height.div_ceil(2),
vk::Format::R8G8_UNORM,
chroma_fmt,
c.fd,
c.offset,
c.stride,
@@ -159,14 +171,14 @@ pub fn import(
device.free_memory(luma_mem, None);
device.free_memory(chroma_mem, None);
};
let luma_view = match view(luma_img, vk::Format::R8_UNORM) {
let luma_view = match view(luma_img, luma_fmt) {
Ok(v) => v,
Err(e) => {
destroy_images(&[]);
return Err(e);
}
};
let chroma_view = match view(chroma_img, vk::Format::R8G8_UNORM) {
let chroma_view = match view(chroma_img, chroma_fmt) {
Ok(v) => v,
Err(e) => {
destroy_images(&[luma_view]);
@@ -180,6 +192,7 @@ pub fn import(
color: frame.color,
width: frame.width,
height: frame.height,
fourcc: frame.fourcc,
images: [luma_img, chroma_img],
memories: [luma_mem, chroma_mem],
views: [luma_view, chroma_view],
+165 -27
View File
@@ -336,6 +336,12 @@ pub struct Presenter {
/// gone through a fence cycle.
retired_display: Vec<DisplayGarbage>,
format: vk::SurfaceFormatKHR,
/// The surface's HDR10/ST.2084 pairing, when the stack offers one.
hdr10_format: Option<vk::SurfaceFormatKHR>,
/// PQ frames are on screen and the swapchain is in HDR10 mode.
hdr_active: bool,
/// The video image / CSC attachment format for the current mode.
video_format: vk::Format,
present_mode: vk::PresentModeKHR,
swapchain: vk::SwapchainKHR,
images: Vec<vk::Image>,
@@ -368,6 +374,16 @@ impl Presenter {
let app_info = vk::ApplicationInfo::default()
.application_name(&app_name)
.api_version(vk::API_VERSION_1_3);
// HDR10 presentation needs the extended colorspaces at the INSTANCE level.
let mut instance_extensions: Vec<String> = instance_extensions.to_vec();
let inst_available = unsafe { entry.enumerate_instance_extension_properties(None) }
.unwrap_or_default();
let has_colorspace_ext = inst_available.iter().any(|e| {
e.extension_name_as_c_str() == Ok(c"VK_EXT_swapchain_colorspace")
});
if has_colorspace_ext {
instance_extensions.push("VK_EXT_swapchain_colorspace".into());
}
let ext_cstrings: Vec<CString> = instance_extensions
.iter()
.map(|e| CString::new(e.as_str()).unwrap())
@@ -551,7 +567,7 @@ impl Presenter {
} else {
None
};
let csc = CscPass::new(&device)?;
let csc = CscPass::new(&device, vk::Format::R8G8B8A8_UNORM)?;
// The exported handle bundle for FFmpeg's Vulkan Video decoder (None = the
// decoder chain skips straight to VAAPI/software). Extension lists must mirror
@@ -587,9 +603,10 @@ impl Presenter {
None
};
let format = pick_format(&surface_i, pdev, surface)?;
let (format, hdr10_format) =
pick_formats(&surface_i, pdev, surface, has_colorspace_ext)?;
let present_mode = pick_present_mode(&surface_i, pdev, surface)?;
tracing::info!(?format, ?present_mode, "swapchain config");
tracing::info!(?format, ?hdr10_format, ?present_mode, "swapchain config");
let overlay_pipe = OverlayPipe::new(&device, format.format)?;
let cmd_pool = unsafe {
@@ -635,6 +652,9 @@ impl Presenter {
retired_hw: None,
retired_display: Vec::new(),
format,
hdr10_format,
hdr_active: false,
video_format: vk::Format::R8G8B8A8_UNORM,
present_mode,
swapchain: vk::SwapchainKHR::null(),
images: Vec::new(),
@@ -777,6 +797,54 @@ impl Presenter {
}
}
/// Flip the presenter between SDR and HDR10 output (stream SDR↔PQ, in-band). A
/// fence quiesce, then everything format-bound is rebuilt: the CSC pass + video
/// image (10-bit intermediate — PQ in 8 bits bands visibly), the overlay pipe, and
/// the swapchain (old one parked per the deferred-destroy rules).
fn set_hdr_mode(&mut self, window: &sdl3::video::Window, on: bool) -> Result<()> {
let target = if on {
self.hdr10_format.expect("caller checked availability")
} else {
// Recompute the SDR pick? It never changed — the sdr format is immutable.
// (self.format currently holds the HDR pairing.)
pick_formats(&self.surface_i, self.pdev, self.surface, false)?.0
};
tracing::info!(hdr = on, format = ?target, "switching presentation mode");
self.quiesce_own()?;
self.video_format = if on {
vk::Format::A2B10G10R10_UNORM_PACK32
} else {
vk::Format::R8G8B8A8_UNORM
};
self.csc.destroy(&self.device); // fence-safe: only our cmd bufs reference it
self.csc = CscPass::new(&self.device, self.video_format)?;
if let Some(v) = self.video.take() {
unsafe {
self.device.destroy_framebuffer(v.framebuffer, None);
self.device.destroy_image_view(v.view, None);
self.device.destroy_image(v.image, None);
self.device.free_memory(v.memory, None);
}
}
// New overlay pipe against the new swapchain format; the old one's targets are
// parked (empty sems/swapchain — those ride the recreate below).
let mut old_pipe = std::mem::replace(
&mut self.overlay_pipe,
OverlayPipe::new(&self.device, target.format)?,
);
let (overlay_views, overlay_framebuffers) = old_pipe.take_targets();
self.retired_display.push(DisplayGarbage {
swapchain: vk::SwapchainKHR::null(),
render_sems: Vec::new(),
overlay_views,
overlay_framebuffers,
});
old_pipe.destroy(&self.device);
self.format = target;
self.hdr_active = on;
self.recreate_swapchain(window)
}
/// 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
@@ -791,6 +859,22 @@ impl Presenter {
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()),
FrameInput::Dmabuf(d) => Some(d.color.is_pq()),
FrameInput::VkFrame(v) => Some(v.color.is_pq()),
};
if let Some(pq) = frame_pq {
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.
@@ -910,7 +994,8 @@ impl Presenter {
width: v.width,
height: v.height,
};
self.record_csc(v.framebuffer, extent, f.color);
let ten_bit = f.is_p010();
self.record_csc(v.framebuffer, extent, f.color, if ten_bit { 10 } else { 8 }, ten_bit);
}
// Vulkan-Video frame: the decoded image is already on THIS device. Read the
@@ -932,7 +1017,9 @@ impl Presenter {
width: v.width,
height: v.height,
};
self.record_csc(v.framebuffer, extent, f.color);
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);
}
@@ -1168,6 +1255,8 @@ impl Presenter {
framebuffer: vk::Framebuffer,
extent: vk::Extent2D,
color: pf_client_core::video::ColorDesc,
depth: u8,
msb_packed: bool,
) {
unsafe {
self.device.cmd_begin_render_pass(
@@ -1214,8 +1303,19 @@ impl Presenter {
&[self.csc.desc_set],
&[],
);
let rows = csc_rows(color);
let bytes = std::slice::from_raw_parts(rows.as_ptr().cast::<u8>(), 48);
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,
@@ -1228,16 +1328,25 @@ impl Presenter {
}
}
/// Per-plane views over a Vulkan-Video frame's multiplanar image (R8 luma +
/// R8G8 chroma — the CSC pass's exact sampling contract; the frames pool was
/// created MUTABLE_FORMAT for this). NV12 only, like the rest of the pipeline.
/// 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]> {
if f.vk_format != vk::Format::G8_B8R8_2PLANE_420_UNORM.as_raw() {
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 G8_B8R8_2PLANE_420)",
"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,
@@ -1260,8 +1369,8 @@ impl Presenter {
}
.context("vk-frame plane view")
};
let luma = make(vk::ImageAspectFlags::PLANE_0, vk::Format::R8_UNORM)?;
let chroma = match make(vk::ImageAspectFlags::PLANE_1, vk::Format::R8G8_UNORM) {
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) };
@@ -1318,7 +1427,7 @@ impl Presenter {
self.device.create_image(
&vk::ImageCreateInfo::default()
.image_type(vk::ImageType::TYPE_2D)
.format(vk::Format::R8G8B8A8_UNORM)
.format(self.video_format)
.extent(vk::Extent3D {
width,
height,
@@ -1347,7 +1456,7 @@ impl Presenter {
&vk::ImageViewCreateInfo::default()
.image(image)
.view_type(vk::ImageViewType::TYPE_2D)
.format(vk::Format::R8G8B8A8_UNORM)
.format(self.video_format)
.subresource_range(subresource_range()),
None,
)
@@ -1515,24 +1624,47 @@ fn pick_device(
bail!("no Vulkan device with a graphics+present queue family")
}
/// Prefer BGRA8 UNORM (the near-universal presentable format); RGBA8 second; else
/// SDR: prefer BGRA8 UNORM (the near-universal presentable format); RGBA8 second; else
/// whatever the surface offers first. UNORM (not SRGB) — the decoded RGBA is already
/// display-referred, the blit must not re-encode it.
fn pick_format(
/// display-referred, the blit must not re-encode it. HDR: a 10-bit UNORM format paired
/// with the HDR10/ST.2084 colorspace, when the instance ext + surface offer one (KDE/
/// gamescope with HDR enabled; absent elsewhere → the shader tonemaps instead).
fn pick_formats(
surface_i: &ash::khr::surface::Instance,
pdev: vk::PhysicalDevice,
surface: vk::SurfaceKHR,
) -> Result<vk::SurfaceFormatKHR> {
colorspace_ext: bool,
) -> Result<(vk::SurfaceFormatKHR, Option<vk::SurfaceFormatKHR>)> {
let formats = unsafe { surface_i.get_physical_device_surface_formats(pdev, surface) }?;
let mut sdr = None;
for want in [vk::Format::B8G8R8A8_UNORM, vk::Format::R8G8B8A8_UNORM] {
if let Some(f) = formats.iter().find(|f| f.format == want) {
return Ok(*f);
if let Some(f) = formats
.iter()
.find(|f| f.format == want && f.color_space == vk::ColorSpaceKHR::SRGB_NONLINEAR)
{
sdr = Some(*f);
break;
}
}
formats
.first()
.copied()
.ok_or_else(|| anyhow!("surface offers no formats"))
let sdr = sdr
.or_else(|| formats.first().copied())
.ok_or_else(|| anyhow!("surface offers no formats"))?;
let hdr10 = colorspace_ext
.then(|| {
formats
.iter()
.find(|f| {
f.color_space == vk::ColorSpaceKHR::HDR10_ST2084_EXT
&& matches!(
f.format,
vk::Format::A2B10G10R10_UNORM_PACK32
| vk::Format::A2R10G10B10_UNORM_PACK32
)
})
.copied()
})
.flatten();
Ok((sdr, hdr10))
}
/// FIFO unless overridden (`PUNKTFUNK_PRESENT_MODE=mailbox|immediate`) and available —
@@ -1555,6 +1687,12 @@ fn pick_present_mode(
})
}
/// 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 Contain-fit letterbox: video (vw×vh) into the swapchain extent, centered.
fn letterbox(extent: vk::Extent2D, vw: u32, vh: u32) -> (vk::Offset3D, vk::Offset3D) {
let (ew, eh) = (f64::from(extent.width), f64::from(extent.height));