forked from unom/punktfunk
The ladder's last rung no longer runs FFmpeg. H.264 decodes through openh264, AV1 through rav1d, and HEVC is refused outright: no permissively licensed software HEVC decoder exists, so an HEVC session that exhausts its hardware rungs now tears down and re-dials advertising HEVC-less caps, and the host picks H.264. The plan calls that a first-class path; it is one. swscale is deleted, and with it the BT.601 default that its correction code existed to undo. Colour on the H.264 lane now comes from the same pf-bitstream planner every hardware rung submits from — openh264 reports no VUI at all — and AV1's comes per-picture from the sequence header. One colour source, one CSC: the old default is unrepresentable rather than merely fixed. Frames reach the presenter as three tightly-packed planes through the planar CSC pass, which had to be un-gated from the pyrowave feature and its device probe, since the last rung must exist on devices that failed that probe. rav1d rather than the dav1d crate, deliberately and against the plan's literal wording: dav1d-sys is system-deps-only, so it would add a system library and a .pc file to every client package — in the milestone family whose excision checklist exists to delete exactly that. rav1d is the same decoder, same licence, statically linked. The cost is honest: no-asm builds on both decoders, and software throughput is still unmeasured. The colour test is the milestone's exit criterion, so it is built to fail. Three fixtures, and a mutation check: hardcoding the swscale default turns the red bar to [255,24,0], and swapping Cb/Cr turns red to blue — a silent error no metadata assertion could catch. Review then disproved the range half of it numerically: with eight saturated bars, decoding the full-range fixture with the wrong range gives max error ZERO, because a mismatch only pushes values outside [0,1] where the shader clamps. A mid-tone was added; the wrong range now costs 11, well past the tolerance. The exit criterion I set was satisfiable by a test that proved nothing. Two blocking defects, both emergent rather than local. Software AV1 on a 10-bit stream never reached its typed refusal: rav1d is built 8-bit-only and returns ENOPROTOOPT, which the send loop turned into a generic error, so the pump's typed downcast missed and every AU failed identically — a permanent freeze on precisely the shipping case, since AV1 is advertised only where hardware AV1 exists and hardware AV1 plus HDR is Main 10. The shape is now read from the sequence header before any byte reaches the decoder, exactly as the H.264 leg reads the active SPS. And the new Reconnecting phase was the first state that is not streaming, not connecting, and still holding a live stream — which opened all three guards that had made a second launch impossible. Pressing A assigned over `stream` where every other site shuts down first, and StreamState has no Drop, so the old pump was detached: a second live session still submitting to a Vulkan device that gets destroyed underneath it. Nothing about the reconnect was wrong in isolation; the defect lived between a new state and three guards nobody re-examined. Start is now defensive and the retry raises the connecting modal, so the UI matches the state and B can cancel. Also closed: retry_caps was computed, tested and never applied, so a shape refusal could end a session reporting no codec available while a working retry existed; the retry inherited force_software sticky-true, landing an HEVC→H.264 fallback on software H.264 with working hardware H.264; it re-dialled with a stale mode; the CPU present arm had no survivable-failure handling where the pyrowave arm — same pass — has it; HEVC is no longer advertised when the decoder is pinned to software; and the software rung now feeds the recovery-point SEI it already had in hand to the re-anchor gate. ⚠ Two host-side gaps found while tracing, neither in scope here: Hello::launch is NOT idempotent (gog:/custom: targets spawn a second copy on a retry; the field is kept verbatim because dropping it orphans the gamescope display whose reuse key includes the command), and a reconnected session can never adopt a game predating its own launch stamp, so it has no game-exit detection. ⚠ OWED: the on-glass software run. ~200 lines of new Vulkan on a path that only runs because the GPU already failed, and no driver has seen it. The review's minimum check is sync validation enabled, a non-multiple-of-16 mode, a mid-session resize and demotion, and both colour matrices. Gates: container clippy -D warnings over four crates, 236 tests, workspace check. pf-vkdecode and pf-bitstream are byte-for-byte untouched, so the hardware rungs' 250/250 stands.
385 lines
18 KiB
Rust
385 lines
18 KiB
Rust
//! The NV12→RGBA color-space-conversion pass: coefficient rows from the stream's CICP
|
|
//! signaling (the GL presenter's `yuv_to_rgb`, folded into row form for the shader's
|
|
//! push constants) and the graphics pipeline that renders the two imported planes into
|
|
//! the presenter's video image.
|
|
//!
|
|
//! Deliberately NOT `VK_KHR_sampler_ycbcr_conversion`: the ext can't express the PQ path
|
|
//! coming with the HDR phase and its range handling varies by driver — these are the
|
|
//! proven, unit-tested coefficients (plan §5.2).
|
|
|
|
use anyhow::{Context as _, Result};
|
|
use ash::vk;
|
|
|
|
// The coefficient math lives in pf-client-core next to `ColorDesc` (one tested
|
|
// implementation shared with the Windows client's D3D11 constant buffer and mirrored by the
|
|
// Apple client's Swift port); re-exported here so presenter callers keep their import path.
|
|
pub use pf_client_core::video::csc_rows;
|
|
|
|
/// The pass objects (everything except the per-video-size framebuffer, which lives with
|
|
/// the video image). Destroyed explicitly via [`CscPass::destroy`] from the presenter's
|
|
/// `Drop` — no device handle is stored here.
|
|
pub struct CscPass {
|
|
pub render_pass: vk::RenderPass,
|
|
pub set_layout: vk::DescriptorSetLayout,
|
|
pub pipeline_layout: vk::PipelineLayout,
|
|
pub pipeline: vk::Pipeline,
|
|
pub desc_pool: vk::DescriptorPool,
|
|
pub desc_set: vk::DescriptorSet,
|
|
pub sampler: vk::Sampler,
|
|
}
|
|
|
|
impl CscPass {
|
|
/// `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> {
|
|
Self::build(
|
|
device,
|
|
attachment_format,
|
|
2,
|
|
include_bytes!("../shaders/nv12_csc.frag.spv"),
|
|
)
|
|
}
|
|
|
|
/// The planar 3-plane variant (separate Cb/Cr R8 planes). Same push-constant
|
|
/// contract.
|
|
///
|
|
/// Two producers now: the PyroWave decode output
|
|
/// (design/pyrowave-codec-plan.md §4.5) and — since M8 — the SOFTWARE rung, whose
|
|
/// I420 planes the presenter uploads and converts here instead of receiving swscale's
|
|
/// RGBA. That is why this is no longer feature-gated or probe-gated: the CPU rung is
|
|
/// the ladder's last one, so it must exist on every device, including the ones that
|
|
/// failed the pyrowave probe.
|
|
pub fn new_planar(device: &ash::Device, attachment_format: vk::Format) -> Result<CscPass> {
|
|
Self::build(
|
|
device,
|
|
attachment_format,
|
|
3,
|
|
include_bytes!("../shaders/planar_csc.frag.spv"),
|
|
)
|
|
}
|
|
|
|
fn build(
|
|
device: &ash::Device,
|
|
attachment_format: vk::Format,
|
|
plane_bindings: u32,
|
|
frag_spv: &[u8],
|
|
) -> 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(attachment_format)
|
|
.samples(vk::SampleCountFlags::TYPE_1)
|
|
.load_op(vk::AttachmentLoadOp::DONT_CARE)
|
|
.store_op(vk::AttachmentStoreOp::STORE)
|
|
.initial_layout(vk::ImageLayout::UNDEFINED)
|
|
.final_layout(vk::ImageLayout::TRANSFER_SRC_OPTIMAL)];
|
|
let color_ref = [vk::AttachmentReference::default()
|
|
.attachment(0)
|
|
.layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL)];
|
|
let subpass = [vk::SubpassDescription::default()
|
|
.pipeline_bind_point(vk::PipelineBindPoint::GRAPHICS)
|
|
.color_attachments(&color_ref)];
|
|
// Conservative scopes, matching the presenter's per-frame barrier granularity.
|
|
let deps = [
|
|
vk::SubpassDependency::default()
|
|
.src_subpass(vk::SUBPASS_EXTERNAL)
|
|
.dst_subpass(0)
|
|
.src_stage_mask(vk::PipelineStageFlags::ALL_COMMANDS)
|
|
.src_access_mask(vk::AccessFlags::MEMORY_WRITE)
|
|
.dst_stage_mask(vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT)
|
|
.dst_access_mask(vk::AccessFlags::COLOR_ATTACHMENT_WRITE),
|
|
vk::SubpassDependency::default()
|
|
.src_subpass(0)
|
|
.dst_subpass(vk::SUBPASS_EXTERNAL)
|
|
.src_stage_mask(vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT)
|
|
.src_access_mask(vk::AccessFlags::COLOR_ATTACHMENT_WRITE)
|
|
.dst_stage_mask(vk::PipelineStageFlags::TRANSFER)
|
|
.dst_access_mask(vk::AccessFlags::TRANSFER_READ),
|
|
];
|
|
// SAFETY: per the Vulkan contract above - the Vulkan handles used here are owned by this
|
|
// type and live for the call, and every builder struct is a local that outlives it.
|
|
let render_pass = unsafe {
|
|
device.create_render_pass(
|
|
&vk::RenderPassCreateInfo::default()
|
|
.attachments(&attachment)
|
|
.subpasses(&subpass)
|
|
.dependencies(&deps),
|
|
None,
|
|
)
|
|
}
|
|
.context("CSC render pass")?;
|
|
|
|
// SAFETY: per the Vulkan contract above - the Vulkan handles used here are owned by this
|
|
// type and live for the call, and every builder struct is a local that outlives it.
|
|
let sampler = unsafe {
|
|
device.create_sampler(
|
|
&vk::SamplerCreateInfo::default()
|
|
.mag_filter(vk::Filter::LINEAR)
|
|
.min_filter(vk::Filter::LINEAR)
|
|
.address_mode_u(vk::SamplerAddressMode::CLAMP_TO_EDGE)
|
|
.address_mode_v(vk::SamplerAddressMode::CLAMP_TO_EDGE)
|
|
.address_mode_w(vk::SamplerAddressMode::CLAMP_TO_EDGE),
|
|
None,
|
|
)
|
|
}?;
|
|
|
|
let samplers = [sampler];
|
|
let bindings: Vec<vk::DescriptorSetLayoutBinding> = (0..plane_bindings)
|
|
.map(|b| {
|
|
vk::DescriptorSetLayoutBinding::default()
|
|
.binding(b)
|
|
.descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
|
|
.descriptor_count(1)
|
|
.stage_flags(vk::ShaderStageFlags::FRAGMENT)
|
|
.immutable_samplers(&samplers)
|
|
})
|
|
.collect();
|
|
// SAFETY: per the Vulkan contract above - the Vulkan handles used here are owned by this
|
|
// type and live for the call, and every builder struct is a local that outlives it.
|
|
let set_layout = unsafe {
|
|
device.create_descriptor_set_layout(
|
|
&vk::DescriptorSetLayoutCreateInfo::default().bindings(&bindings),
|
|
None,
|
|
)
|
|
}?;
|
|
let set_layouts = [set_layout];
|
|
let push = [vk::PushConstantRange::default()
|
|
.stage_flags(vk::ShaderStageFlags::FRAGMENT)
|
|
.size(64)]; // three vec4 rows + a params vec4 (mode, tonemap peak)
|
|
// SAFETY: per the Vulkan contract above - the Vulkan handles used here are owned by this
|
|
// type and live for the call, and every builder struct is a local that outlives it.
|
|
let pipeline_layout = unsafe {
|
|
device.create_pipeline_layout(
|
|
&vk::PipelineLayoutCreateInfo::default()
|
|
.set_layouts(&set_layouts)
|
|
.push_constant_ranges(&push),
|
|
None,
|
|
)
|
|
}?;
|
|
|
|
let pool_sizes = [vk::DescriptorPoolSize::default()
|
|
.ty(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
|
|
.descriptor_count(plane_bindings)];
|
|
// SAFETY: per the Vulkan contract above - the Vulkan handles used here are owned by this
|
|
// type and live for the call, and every builder struct is a local that outlives it.
|
|
let desc_pool = unsafe {
|
|
device.create_descriptor_pool(
|
|
&vk::DescriptorPoolCreateInfo::default()
|
|
.max_sets(1)
|
|
.pool_sizes(&pool_sizes),
|
|
None,
|
|
)
|
|
}?;
|
|
// SAFETY: per the Vulkan contract above - the Vulkan handles used here are owned by this
|
|
// type and live for the call, and every builder struct is a local that outlives it.
|
|
let desc_set = unsafe {
|
|
device.allocate_descriptor_sets(
|
|
&vk::DescriptorSetAllocateInfo::default()
|
|
.descriptor_pool(desc_pool)
|
|
.set_layouts(&set_layouts),
|
|
)
|
|
}?[0];
|
|
|
|
let pipeline = build_fullscreen_pipeline(
|
|
device,
|
|
render_pass,
|
|
pipeline_layout,
|
|
frag_spv,
|
|
false, // opaque — the CSC output IS the video
|
|
)?;
|
|
|
|
Ok(CscPass {
|
|
render_pass,
|
|
set_layout,
|
|
pipeline_layout,
|
|
pipeline,
|
|
desc_pool,
|
|
desc_set,
|
|
sampler,
|
|
})
|
|
}
|
|
|
|
/// Point the descriptor set at this frame's plane views. Only safe while no
|
|
/// submitted command buffer references the set — the presenter's single in-flight
|
|
/// fence is waited before every record, which covers it.
|
|
pub fn bind_planes(&self, device: &ash::Device, luma: vk::ImageView, chroma: vk::ImageView) {
|
|
let infos = [luma, chroma].map(|view| {
|
|
[vk::DescriptorImageInfo::default()
|
|
.image_view(view)
|
|
.image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)]
|
|
});
|
|
let writes = [
|
|
vk::WriteDescriptorSet::default()
|
|
.dst_set(self.desc_set)
|
|
.dst_binding(0)
|
|
.descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
|
|
.image_info(&infos[0]),
|
|
vk::WriteDescriptorSet::default()
|
|
.dst_set(self.desc_set)
|
|
.dst_binding(1)
|
|
.descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
|
|
.image_info(&infos[1]),
|
|
];
|
|
// SAFETY: per the Vulkan contract above - recorded into a command buffer this code owns
|
|
// and has begun, referencing handles it also owns; nothing is submitted until the
|
|
// recording is ended.
|
|
unsafe { device.update_descriptor_sets(&writes, &[]) };
|
|
}
|
|
|
|
/// Planar variant of [`bind_planes`](Self::bind_planes): three single-component
|
|
/// plane views, in the layout their producer left them in — GENERAL for the pyrowave
|
|
/// decode, `SHADER_READ_ONLY_OPTIMAL` for the software rung's uploaded planes. Same
|
|
/// fence-wait safety contract.
|
|
pub fn bind_planes_planar(
|
|
&self,
|
|
device: &ash::Device,
|
|
planes: [vk::ImageView; 3],
|
|
layout: vk::ImageLayout,
|
|
) {
|
|
let infos = planes.map(|view| {
|
|
[vk::DescriptorImageInfo::default()
|
|
.image_view(view)
|
|
.image_layout(layout)]
|
|
});
|
|
let writes = [0u32, 1, 2].map(|b| {
|
|
vk::WriteDescriptorSet::default()
|
|
.dst_set(self.desc_set)
|
|
.dst_binding(b)
|
|
.descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
|
|
.image_info(&infos[b as usize])
|
|
});
|
|
// SAFETY: per the Vulkan contract above - recorded into a command buffer this code owns
|
|
// and has begun, referencing handles it also owns; nothing is submitted until the
|
|
// recording is ended.
|
|
unsafe { device.update_descriptor_sets(&writes, &[]) };
|
|
}
|
|
|
|
pub fn destroy(&self, device: &ash::Device) {
|
|
// SAFETY: per the Vulkan contract above - this destroys objects this type owns, and the
|
|
// GPU is known idle for them (the fence/queue-wait on the path here, or the swapchain
|
|
// being retired), which is the obligation that makes a destroy sound rather than the
|
|
// handle merely being non-null.
|
|
unsafe {
|
|
device.destroy_pipeline(self.pipeline, None);
|
|
device.destroy_pipeline_layout(self.pipeline_layout, None);
|
|
device.destroy_descriptor_pool(self.desc_pool, None);
|
|
device.destroy_descriptor_set_layout(self.set_layout, None);
|
|
device.destroy_sampler(self.sampler, None);
|
|
device.destroy_render_pass(self.render_pass, None);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A bufferless fullscreen-triangle pipeline over `fullscreen.vert` + the given
|
|
/// fragment SPIR-V, dynamic viewport/scissor. `blend` = premultiplied-alpha over the
|
|
/// destination (the overlay composite); `false` = opaque write (the CSC pass). Shared
|
|
/// by both passes — the geometry and states are identical.
|
|
pub(crate) fn build_fullscreen_pipeline(
|
|
device: &ash::Device,
|
|
render_pass: vk::RenderPass,
|
|
layout: vk::PipelineLayout,
|
|
frag_spv: &[u8],
|
|
blend: bool,
|
|
) -> Result<vk::Pipeline> {
|
|
// Committed SPIR-V (shaders/build.sh) — include_bytes! alignment is unspecified, so
|
|
// read_spv copies into aligned Vec<u32>s.
|
|
let vert = ash::util::read_spv(&mut std::io::Cursor::new(
|
|
&include_bytes!("../shaders/fullscreen.vert.spv")[..],
|
|
))?;
|
|
let frag = ash::util::read_spv(&mut std::io::Cursor::new(frag_spv))?;
|
|
// SAFETY: per the Vulkan contract above - the Vulkan handles used here are owned by this type
|
|
// and live for the call, and every builder struct is a local that outlives it.
|
|
let vert_mod = unsafe {
|
|
device.create_shader_module(&vk::ShaderModuleCreateInfo::default().code(&vert), None)
|
|
}?;
|
|
// SAFETY: per the Vulkan contract above - the Vulkan handles used here are owned by this type
|
|
// and live for the call, and every builder struct is a local that outlives it.
|
|
let frag_mod = unsafe {
|
|
device.create_shader_module(&vk::ShaderModuleCreateInfo::default().code(&frag), None)
|
|
};
|
|
let frag_mod = match frag_mod {
|
|
Ok(m) => m,
|
|
Err(e) => {
|
|
// SAFETY: per the Vulkan contract above - this destroys objects this type owns, and
|
|
// the GPU is known idle for them (the fence/queue-wait on the path here, or the
|
|
// swapchain being retired), which is the obligation that makes a destroy sound rather
|
|
// than the handle merely being non-null.
|
|
unsafe { device.destroy_shader_module(vert_mod, None) };
|
|
return Err(e).context("fragment shader module");
|
|
}
|
|
};
|
|
|
|
let entry = c"main";
|
|
let stages = [
|
|
vk::PipelineShaderStageCreateInfo::default()
|
|
.stage(vk::ShaderStageFlags::VERTEX)
|
|
.module(vert_mod)
|
|
.name(entry),
|
|
vk::PipelineShaderStageCreateInfo::default()
|
|
.stage(vk::ShaderStageFlags::FRAGMENT)
|
|
.module(frag_mod)
|
|
.name(entry),
|
|
];
|
|
let vertex_input = vk::PipelineVertexInputStateCreateInfo::default(); // bufferless
|
|
let assembly = vk::PipelineInputAssemblyStateCreateInfo::default()
|
|
.topology(vk::PrimitiveTopology::TRIANGLE_LIST);
|
|
// Dynamic viewport/scissor: the video size changes with the stream mode, the
|
|
// pipeline must not bake one in.
|
|
let viewport = vk::PipelineViewportStateCreateInfo::default()
|
|
.viewport_count(1)
|
|
.scissor_count(1);
|
|
let dynamic = [vk::DynamicState::VIEWPORT, vk::DynamicState::SCISSOR];
|
|
let dynamic_state = vk::PipelineDynamicStateCreateInfo::default().dynamic_states(&dynamic);
|
|
let raster = vk::PipelineRasterizationStateCreateInfo::default()
|
|
.polygon_mode(vk::PolygonMode::FILL)
|
|
.cull_mode(vk::CullModeFlags::NONE)
|
|
.line_width(1.0);
|
|
let multisample = vk::PipelineMultisampleStateCreateInfo::default()
|
|
.rasterization_samples(vk::SampleCountFlags::TYPE_1);
|
|
let blend_attachment = [if blend {
|
|
// Premultiplied alpha over the destination (Skia surfaces are premultiplied).
|
|
vk::PipelineColorBlendAttachmentState::default()
|
|
.color_write_mask(vk::ColorComponentFlags::RGBA)
|
|
.blend_enable(true)
|
|
.src_color_blend_factor(vk::BlendFactor::ONE)
|
|
.dst_color_blend_factor(vk::BlendFactor::ONE_MINUS_SRC_ALPHA)
|
|
.color_blend_op(vk::BlendOp::ADD)
|
|
.src_alpha_blend_factor(vk::BlendFactor::ONE)
|
|
.dst_alpha_blend_factor(vk::BlendFactor::ONE_MINUS_SRC_ALPHA)
|
|
.alpha_blend_op(vk::BlendOp::ADD)
|
|
} else {
|
|
vk::PipelineColorBlendAttachmentState::default()
|
|
.color_write_mask(vk::ColorComponentFlags::RGBA)
|
|
}];
|
|
let blend = vk::PipelineColorBlendStateCreateInfo::default().attachments(&blend_attachment);
|
|
|
|
let info = vk::GraphicsPipelineCreateInfo::default()
|
|
.stages(&stages)
|
|
.vertex_input_state(&vertex_input)
|
|
.input_assembly_state(&assembly)
|
|
.viewport_state(&viewport)
|
|
.rasterization_state(&raster)
|
|
.multisample_state(&multisample)
|
|
.color_blend_state(&blend)
|
|
.dynamic_state(&dynamic_state)
|
|
.layout(layout)
|
|
.render_pass(render_pass);
|
|
let pipeline =
|
|
// SAFETY: per the Vulkan contract above - a create/allocate call on the live device, over
|
|
// builder structs that are locals outliving the call; the handle it returns is owned by
|
|
// the value being built here.
|
|
unsafe { device.create_graphics_pipelines(vk::PipelineCache::null(), &[info], None) }
|
|
.map_err(|(_, e)| e)
|
|
.context("CSC pipeline");
|
|
// SAFETY: per the Vulkan contract above - this destroys objects this type owns, and the GPU is
|
|
// known idle for them (the fence/queue-wait on the path here, or the swapchain being retired),
|
|
// which is the obligation that makes a destroy sound rather than the handle merely being non-
|
|
// null.
|
|
unsafe {
|
|
device.destroy_shader_module(vert_mod, None);
|
|
device.destroy_shader_module(frag_mod, None);
|
|
}
|
|
Ok(pipeline?[0])
|
|
}
|