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:
@@ -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.
@@ -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),
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user