mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 09:15:19 +03:00
feat: nescapture pacing, improvements and deps update (#334)
Make nescapture better with proper queue family checks, FPS limiting,
semaphore usage and other.. also updated deps like pollster and
pixelforge.
<!-- greptile_comment -->
<!-- greptile_summary -->
<h2><a
href="https://app.greptile.com/api/retrigger?id=64083904"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/RetriggerDark.svg?v=1"><source
media="(prefers-color-scheme: light)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/Retrigger.svg?v=1"><img
alt="Retrigger"
src="https://greptile-static-assets.s3.amazonaws.com/badges/Retrigger.svg?v=1"
align="right"></picture></a>Confidence Score: 5/5</h2>
The final review contains no accepted findings, so the PR appears safe
to merge.
<h3>Summary</h3>
- This PR reworks `nescapture` frame capture around a four-slot DMA-BUF
ring, tracks presentation queue families, adds semaphore-based
capture/present ordering, introduces capture frame-rate pacing, carries
presentation timestamps through encoding, and updates Vulkan-related
dependencies.
<sub>Reviews (1) · Last reviewed commit: ["feat: Update deps and remove
deprecated
..."](79e21f4aac)</sub>
<!-- /greptile_comment -->
---------
Co-authored-by: DatCaptainHorse <DatCaptainHorse@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
8246aa5538
commit
15ad60bf49
11
Cargo.lock
generated
11
Cargo.lock
generated
@@ -165,8 +165,8 @@ checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ash"
|
name = "ash"
|
||||||
version = "0.38.0+1.4.329"
|
version = "0.38.0+1.4.352"
|
||||||
source = "git+https://github.com/ash-rs/ash?rev=55dd56906bbb5760e9e9e6c56f45be67f67e0649#55dd56906bbb5760e9e9e6c56f45be67f67e0649"
|
source = "git+https://github.com/ash-rs/ash?rev=f4c2ca3e4f6b998d5254ad101a32f024d87cdec2#f4c2ca3e4f6b998d5254ad101a32f024d87cdec2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libloading",
|
"libloading",
|
||||||
]
|
]
|
||||||
@@ -3095,10 +3095,11 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "pixelforge"
|
name = "pixelforge"
|
||||||
version = "0.9.1"
|
version = "0.9.1"
|
||||||
source = "git+https://github.com/hgaiser/pixelforge.git?rev=2ee4d7ac6e470cc34270e48d6e9d6f3fc1d7c379#2ee4d7ac6e470cc34270e48d6e9d6f3fc1d7c379"
|
source = "git+https://github.com/hgaiser/pixelforge.git?rev=936d412e1a73917e0e108c4ab18bf5208b1681ac#936d412e1a73917e0e108c4ab18bf5208b1681ac"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ash",
|
"ash",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
|
"futures-core",
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
@@ -3148,9 +3149,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pollster"
|
name = "pollster"
|
||||||
version = "0.4.0"
|
version = "1.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
|
checksum = "bc6355899e1c9462875b6757c79f3caa011a1fdae12bbb1a2e72dd1f234f8336"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "polyval"
|
name = "polyval"
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ crate-type = ["cdylib"]
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# Vulkan bindings
|
# Vulkan bindings
|
||||||
ash = { git = "https://github.com/ash-rs/ash", rev = "55dd56906bbb5760e9e9e6c56f45be67f67e0649" }
|
ash = { git = "https://github.com/ash-rs/ash", rev = "f4c2ca3e4f6b998d5254ad101a32f024d87cdec2" }
|
||||||
# Shader fingerprinting
|
# Shader fingerprinting
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
bytemuck = "1"
|
bytemuck = "1"
|
||||||
@@ -20,7 +20,7 @@ bytemuck = "1"
|
|||||||
# Concurrent state maps
|
# Concurrent state maps
|
||||||
dashmap = "6"
|
dashmap = "6"
|
||||||
once_cell = "1"
|
once_cell = "1"
|
||||||
pollster = "0.4.0"
|
pollster = "1.0"
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
@@ -32,11 +32,10 @@ toml = "0.8"
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
|
||||||
# Vulkan Video hardware encoding.
|
# Vulkan Video hardware encoding.
|
||||||
pixelforge = { git = "https://github.com/hgaiser/pixelforge.git", rev = "2ee4d7ac6e470cc34270e48d6e9d6f3fc1d7c379", features = ["dmabuf"] }
|
pixelforge = { git = "https://github.com/hgaiser/pixelforge.git", rev = "936d412e1a73917e0e108c4ab18bf5208b1681ac", features = ["dmabuf"] }
|
||||||
|
|
||||||
# libc for DMA-BUF OS primitives
|
# libc for DMA-BUF OS primitives
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
|
||||||
# Shared IPC protocol
|
# Shared IPC protocol
|
||||||
nesprotocol = { path = "../../crates/nesprotocol" }
|
nesprotocol = { path = "../../crates/nesprotocol" }
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,17 @@
|
|||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// capture.rs — Frame capture helpers
|
// capture.rs — Frame capture helpers
|
||||||
//
|
//
|
||||||
// final_image is allocated with VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT
|
// Each ring slot is allocated with VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT
|
||||||
// so that after the GPU blit we can export an fd and import it into pixelforge's
|
// so that after the GPU blit we can export an fd and import it into pixelforge's
|
||||||
// separate VkDevice for zero-copy hardware encoding via DmaBufImporter.
|
// separate VkDevice for zero-copy hardware encoding via DmaBufImporter.
|
||||||
//
|
//
|
||||||
// After ensure_final_image allocates (or re-allocates) the image, we query
|
// The row stride comes from the image's SubresourceLayout, queried once at
|
||||||
// its SubresourceLayout and cache the row stride in DeviceState::final_stride.
|
// allocation; the encoder needs it to import the LINEAR image correctly.
|
||||||
// The stride is needed by the encoder to correctly import the LINEAR image.
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
use crate::state::{CB_STATE, CaptureResources, DEVICE_STATE};
|
use crate::state::{CB_STATE, CAPTURE_SLOTS, CaptureRing, CaptureSlot, DEVICE_STATE};
|
||||||
use ash::vk::{self, Handle};
|
use ash::vk::{self, Handle};
|
||||||
use std::os::raw::c_int;
|
use std::os::raw::c_int;
|
||||||
use std::sync::atomic::Ordering;
|
|
||||||
|
|
||||||
fn make_subresource_range() -> vk::ImageSubresourceRange {
|
fn make_subresource_range() -> vk::ImageSubresourceRange {
|
||||||
vk::ImageSubresourceRange {
|
vk::ImageSubresourceRange {
|
||||||
@@ -216,12 +214,8 @@ unsafe fn alloc_image(
|
|||||||
|
|
||||||
// ── Stride query ──────────────────────────────────────────────────────────────
|
// ── Stride query ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Query and cache the row stride of final_image.
|
/// Row stride in bytes of a LINEAR image, or 0 on failure.
|
||||||
/// Returns stride in bytes; 0 on failure.
|
pub unsafe fn query_stride(ds: &crate::state::DeviceState, image: vk::Image) -> u32 {
|
||||||
pub unsafe fn query_and_cache_final_stride(
|
|
||||||
ds: &crate::state::DeviceState,
|
|
||||||
image: vk::Image,
|
|
||||||
) -> u32 {
|
|
||||||
let subresource = vk::ImageSubresource {
|
let subresource = vk::ImageSubresource {
|
||||||
aspect_mask: vk::ImageAspectFlags::COLOR,
|
aspect_mask: vk::ImageAspectFlags::COLOR,
|
||||||
mip_level: 0,
|
mip_level: 0,
|
||||||
@@ -235,9 +229,7 @@ pub unsafe fn query_and_cache_final_stride(
|
|||||||
depth_pitch: 0,
|
depth_pitch: 0,
|
||||||
};
|
};
|
||||||
unsafe { (ds.fp.get_image_subresource_layout)(ds.raw, image, &subresource, &mut layout) };
|
unsafe { (ds.fp.get_image_subresource_layout)(ds.raw, image, &subresource, &mut layout) };
|
||||||
let stride = layout.row_pitch as u32;
|
layout.row_pitch as u32
|
||||||
ds.final_stride.store(stride, Ordering::Relaxed);
|
|
||||||
stride
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── DMA-BUF fd export ─────────────────────────────────────────────────────────
|
// ── DMA-BUF fd export ─────────────────────────────────────────────────────────
|
||||||
@@ -296,30 +288,183 @@ pub unsafe fn ensure_hudless_image(ds: &crate::state::DeviceState, w: u32, h: u3
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub unsafe fn ensure_final_image(ds: &crate::state::DeviceState, w: u32, h: u32, f: vk::Format) {
|
// ── Capture ring lifecycle ────────────────────────────────────────────────────
|
||||||
let mut ig = ds.final_image.lock().unwrap();
|
|
||||||
let mut mg = ds.final_memory.lock().unwrap();
|
/// Bring `ring` up to a ring able to hold `w`x`h` `f` frames, building or
|
||||||
let mut sg = ds.final_size.lock().unwrap();
|
/// rebuilding it as needed. Returns false when the caller should skip this
|
||||||
if let (Some(i), Some(m)) = (*ig, *mg) {
|
/// frame.
|
||||||
let (ew, eh, ef) = *sg;
|
///
|
||||||
if ew >= w && eh >= h && ef == f {
|
/// A rebuild destroys images the encoder may still be reading, so it only
|
||||||
return;
|
/// happens when every slot has come back. Resolution changes are rare and one
|
||||||
|
/// dropped frame at a resize is not worth a use-after-free.
|
||||||
|
unsafe fn ensure_capture_ring(
|
||||||
|
ds: &crate::state::DeviceState,
|
||||||
|
ring: &mut Option<CaptureRing>,
|
||||||
|
w: u32,
|
||||||
|
h: u32,
|
||||||
|
f: vk::Format,
|
||||||
|
queue_family: u32,
|
||||||
|
) -> bool {
|
||||||
|
if let Some(existing) = ring.as_ref() {
|
||||||
|
let (ew, eh, ef) = existing.size;
|
||||||
|
if ew >= w && eh >= h && ef == f && existing.queue_family == queue_family {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if !ds.capture_slots.all_free() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if let Some(old) = ring.take() {
|
||||||
|
unsafe { destroy_capture_ring(ds, old) };
|
||||||
}
|
}
|
||||||
unsafe { (ds.fp.destroy_image)(ds.raw, i, std::ptr::null()) };
|
|
||||||
unsafe { (ds.fp.free_memory)(ds.raw, m, std::ptr::null()) };
|
|
||||||
*ig = None;
|
|
||||||
*mg = None;
|
|
||||||
ds.final_stride.store(0, Ordering::Relaxed);
|
|
||||||
}
|
}
|
||||||
if let Some((i, m)) = unsafe { allocate_dmabuf_image(ds, w, h, f, "final") } {
|
match unsafe { create_capture_ring(ds, w, h, f, queue_family) } {
|
||||||
// Query stride immediately after allocation so it's available on first frame.
|
Some(fresh) => {
|
||||||
unsafe { query_and_cache_final_stride(ds, i) };
|
*ring = Some(fresh);
|
||||||
*ig = Some(i);
|
true
|
||||||
*mg = Some(m);
|
}
|
||||||
*sg = (w, h, f);
|
None => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
unsafe fn create_capture_ring(
|
||||||
|
ds: &crate::state::DeviceState,
|
||||||
|
w: u32,
|
||||||
|
h: u32,
|
||||||
|
f: vk::Format,
|
||||||
|
queue_family: u32,
|
||||||
|
) -> Option<CaptureRing> {
|
||||||
|
let pci = vk::CommandPoolCreateInfo {
|
||||||
|
s_type: vk::StructureType::COMMAND_POOL_CREATE_INFO,
|
||||||
|
p_next: std::ptr::null(),
|
||||||
|
flags: vk::CommandPoolCreateFlags::RESET_COMMAND_BUFFER,
|
||||||
|
// Was hard-coded to family 0, which is only correct when the game
|
||||||
|
// happens to present on family 0.
|
||||||
|
queue_family_index: queue_family,
|
||||||
|
_marker: std::marker::PhantomData,
|
||||||
|
};
|
||||||
|
let mut command_pool = vk::CommandPool::null();
|
||||||
|
if unsafe { (ds.fp.create_command_pool)(ds.raw, &pci, std::ptr::null(), &mut command_pool) }
|
||||||
|
!= vk::Result::SUCCESS
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ai = vk::CommandBufferAllocateInfo {
|
||||||
|
s_type: vk::StructureType::COMMAND_BUFFER_ALLOCATE_INFO,
|
||||||
|
p_next: std::ptr::null(),
|
||||||
|
command_pool,
|
||||||
|
level: vk::CommandBufferLevel::PRIMARY,
|
||||||
|
command_buffer_count: CAPTURE_SLOTS as u32,
|
||||||
|
_marker: std::marker::PhantomData,
|
||||||
|
};
|
||||||
|
let mut cbs = [vk::CommandBuffer::null(); CAPTURE_SLOTS];
|
||||||
|
if unsafe { (ds.fp.allocate_command_buffers)(ds.raw, &ai, cbs.as_mut_ptr()) }
|
||||||
|
!= vk::Result::SUCCESS
|
||||||
|
{
|
||||||
|
unsafe { (ds.fp.destroy_command_pool)(ds.raw, command_pool, std::ptr::null()) };
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-signalled: the first wait on a fresh slot must return immediately.
|
||||||
|
let fci = vk::FenceCreateInfo {
|
||||||
|
s_type: vk::StructureType::FENCE_CREATE_INFO,
|
||||||
|
p_next: std::ptr::null(),
|
||||||
|
flags: vk::FenceCreateFlags::SIGNALED,
|
||||||
|
_marker: std::marker::PhantomData,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut slots = Vec::with_capacity(CAPTURE_SLOTS);
|
||||||
|
for (i, &command_buffer) in cbs.iter().enumerate() {
|
||||||
|
let Some((image, memory)) = (unsafe { allocate_dmabuf_image(ds, w, h, f, "capture") })
|
||||||
|
else {
|
||||||
|
unsafe { destroy_partial_ring(ds, command_pool, slots) };
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let mut fence = vk::Fence::null();
|
||||||
|
if unsafe { (ds.fp.create_fence)(ds.raw, &fci, std::ptr::null(), &mut fence) }
|
||||||
|
!= vk::Result::SUCCESS
|
||||||
|
{
|
||||||
|
unsafe { (ds.fp.destroy_image)(ds.raw, image, std::ptr::null()) };
|
||||||
|
unsafe { (ds.fp.free_memory)(ds.raw, memory, std::ptr::null()) };
|
||||||
|
unsafe { destroy_partial_ring(ds, command_pool, slots) };
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let stride = unsafe { query_stride(ds, image) };
|
||||||
|
// Export once. Each frame hands the encoder a dup of this fd, which
|
||||||
|
// costs a file-descriptor clone instead of a kernel export per frame.
|
||||||
|
let dmabuf_fd = unsafe { get_dmabuf_fd(ds, memory) }.unwrap_or(-1);
|
||||||
|
if dmabuf_fd < 0 {
|
||||||
|
log::warn!("capture slot {i}: no DMA-BUF export, falling back to CPU readback");
|
||||||
|
}
|
||||||
|
slots.push(CaptureSlot {
|
||||||
|
image,
|
||||||
|
memory,
|
||||||
|
dmabuf_fd,
|
||||||
|
stride,
|
||||||
|
command_buffer,
|
||||||
|
fence,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"capture ring: {CAPTURE_SLOTS} slots of {w}x{h} fmt={} on queue family {queue_family}",
|
||||||
|
f.as_raw()
|
||||||
|
);
|
||||||
|
Some(CaptureRing {
|
||||||
|
command_pool,
|
||||||
|
slots,
|
||||||
|
size: (w, h, f),
|
||||||
|
queue_family,
|
||||||
|
present_wait: Vec::new(),
|
||||||
|
retired: Vec::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn destroy_partial_ring(
|
||||||
|
ds: &crate::state::DeviceState,
|
||||||
|
command_pool: vk::CommandPool,
|
||||||
|
slots: Vec<CaptureSlot>,
|
||||||
|
) {
|
||||||
|
unsafe {
|
||||||
|
for slot in slots {
|
||||||
|
(ds.fp.destroy_fence)(ds.raw, slot.fence, std::ptr::null());
|
||||||
|
(ds.fp.destroy_image)(ds.raw, slot.image, std::ptr::null());
|
||||||
|
(ds.fp.free_memory)(ds.raw, slot.memory, std::ptr::null());
|
||||||
|
if slot.dmabuf_fd >= 0 {
|
||||||
|
libc::close(slot.dmabuf_fd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(ds.fp.destroy_command_pool)(ds.raw, command_pool, std::ptr::null());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tear a ring down. The caller must have established that no slot is in
|
||||||
|
/// flight; the fence wait here only covers work already submitted.
|
||||||
|
pub unsafe fn destroy_capture_ring(ds: &crate::state::DeviceState, ring: CaptureRing) {
|
||||||
|
let fences: Vec<vk::Fence> = ring.slots.iter().map(|s| s.fence).collect();
|
||||||
|
if !fences.is_empty() {
|
||||||
|
unsafe {
|
||||||
|
let _ = (ds.fp.wait_for_fences)(
|
||||||
|
ds.raw,
|
||||||
|
fences.len() as u32,
|
||||||
|
fences.as_ptr(),
|
||||||
|
vk::TRUE,
|
||||||
|
5_000_000_000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unsafe {
|
||||||
|
if let Some(destroy) = ds.fp.destroy_semaphore {
|
||||||
|
for &sem in ring.present_wait.iter().chain(ring.retired.iter()) {
|
||||||
|
if sem != vk::Semaphore::null() {
|
||||||
|
destroy(ds.raw, sem, std::ptr::null());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unsafe { destroy_partial_ring(ds, ring.command_pool, ring.slots) };
|
||||||
|
}
|
||||||
|
|
||||||
// ── HUDless command injection ─────────────────────────────────────────────────
|
// ── HUDless command injection ─────────────────────────────────────────────────
|
||||||
|
|
||||||
pub unsafe fn inject_hudless_copy(cb: vk::CommandBuffer, dk: usize) {
|
pub unsafe fn inject_hudless_copy(cb: vk::CommandBuffer, dk: usize) {
|
||||||
@@ -451,57 +596,85 @@ pub unsafe fn inject_hudless_copy(cb: vk::CommandBuffer, dk: usize) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Final frame GPU blit (swapchain → final_image) ────────────────────────────
|
// ── Final frame GPU blit (swapchain → capture slot) ──────────────────────────
|
||||||
|
|
||||||
pub unsafe fn capture_final_frame(
|
/// What a successful capture hands back to the present hook.
|
||||||
|
pub struct CaptureSubmission {
|
||||||
|
/// Keeps the ring slot reserved until the encoder is finished with it.
|
||||||
|
pub slot: crate::slots::SlotGuard,
|
||||||
|
/// The semaphore the present must now wait on. The application's own wait
|
||||||
|
/// semaphores were consumed by the blit submission, so presenting on them
|
||||||
|
/// again would be a double wait.
|
||||||
|
pub present_wait: vk::Semaphore,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Blit the presented swapchain image into a ring slot, ahead of the present.
|
||||||
|
///
|
||||||
|
/// Two orderings have to hold and neither did before.
|
||||||
|
///
|
||||||
|
/// The blit must not read the swapchain image before the game has finished
|
||||||
|
/// rendering into it. The game signals that with the semaphores it attached to
|
||||||
|
/// `VkPresentInfoKHR`, so the blit waits on exactly those.
|
||||||
|
///
|
||||||
|
/// The game must not render into that image again before the blit has read it.
|
||||||
|
/// Presentation is what releases the image back to the application, so the
|
||||||
|
/// present is made to wait on a semaphore the blit signals. The old code
|
||||||
|
/// submitted the blit from a worker thread after `vkQueuePresentKHR` had
|
||||||
|
/// already returned, which guaranteed neither.
|
||||||
|
///
|
||||||
|
/// Returns `None` when the frame cannot be captured, in which case the caller
|
||||||
|
/// must present unmodified — the application's semaphores have not been touched.
|
||||||
|
pub unsafe fn capture_present_frame(
|
||||||
ds: &crate::state::DeviceState,
|
ds: &crate::state::DeviceState,
|
||||||
queue: vk::Queue,
|
queue: vk::Queue,
|
||||||
si: vk::Image,
|
si: vk::Image,
|
||||||
fmt: vk::Format,
|
fmt: vk::Format,
|
||||||
ext: vk::Extent2D,
|
ext: vk::Extent2D,
|
||||||
_frame: u64,
|
image_index: usize,
|
||||||
) {
|
app_waits: &[vk::Semaphore],
|
||||||
|
) -> Option<CaptureSubmission> {
|
||||||
if ext.width == 0 || ext.height == 0 {
|
if ext.width == 0 || ext.height == 0 {
|
||||||
return;
|
return None;
|
||||||
|
}
|
||||||
|
if !ds
|
||||||
|
.swapchain_transfer_src
|
||||||
|
.load(std::sync::atomic::Ordering::Relaxed)
|
||||||
|
{
|
||||||
|
return None;
|
||||||
}
|
}
|
||||||
unsafe { ensure_final_image(ds, ext.width, ext.height, fmt) };
|
|
||||||
let fi = match *ds.final_image.lock().unwrap() {
|
|
||||||
Some(i) => i,
|
|
||||||
None => return,
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Lazy-init reusable capture resources ──────────────────────
|
// A command buffer may only be submitted to the family its pool was created
|
||||||
let mut res_guard = ds.capture_resources.lock().unwrap();
|
// for. An unknown queue means one the layer never saw through
|
||||||
let res = match res_guard.as_mut() {
|
// vkGetDeviceQueue, so there is nothing safe to assume about it.
|
||||||
Some(r) => r,
|
let queue_family = *crate::state::QUEUE_TO_FAMILY.get(&queue.as_raw())?;
|
||||||
None => match unsafe { create_capture_resources(ds) } {
|
|
||||||
Some(r) => {
|
|
||||||
*res_guard = Some(r);
|
|
||||||
res_guard.as_mut().unwrap()
|
|
||||||
}
|
|
||||||
None => return,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
let idx = res.current;
|
let mut ring_guard = ds.capture_ring.lock().ok()?;
|
||||||
let cb = res.command_buffers[idx];
|
if !unsafe {
|
||||||
let fence = res.fences[idx];
|
ensure_capture_ring(ds, &mut ring_guard, ext.width, ext.height, fmt, queue_family)
|
||||||
|
} {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let ring = ring_guard.as_mut()?;
|
||||||
|
|
||||||
// Wait for THIS slot's previous use to finish (not the other slot).
|
let present_wait = unsafe { ensure_present_semaphore(ds, ring, image_index) }?;
|
||||||
// Use a short timeout — if the GPU is busy with game rendering, skip
|
|
||||||
// this capture instead of stalling the game's render loop.
|
// Never blocks: a frame with no free slot is one the encoder has not caught
|
||||||
|
// up with, and stalling the game's present to wait for it would be worse
|
||||||
|
// than skipping it.
|
||||||
|
let guard = ds.capture_slots.try_acquire()?;
|
||||||
|
let slot = ring.slots.get(guard.index())?;
|
||||||
|
let cb = slot.command_buffer;
|
||||||
|
let fence = slot.fence;
|
||||||
|
let fi = slot.image;
|
||||||
|
|
||||||
|
// A free slot's fence is already signalled — the capture worker waits on it
|
||||||
|
// before the encoder ever sees the frame. This covers the paths that
|
||||||
|
// abandon a frame and return the slot without that wait.
|
||||||
unsafe {
|
unsafe {
|
||||||
let result = (ds.fp.wait_for_fences)(ds.raw, 1, &fence, vk::TRUE, 1_000_000); // 1ms timeout
|
if (ds.fp.wait_for_fences)(ds.raw, 1, &fence, vk::TRUE, 2_000_000) != vk::Result::SUCCESS {
|
||||||
if result != vk::Result::SUCCESS {
|
return None;
|
||||||
// GPU not ready — skip this capture, try next slot
|
|
||||||
res.current = (idx + 1) % 4;
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
let _ = (ds.fp.reset_fences)(ds.raw, 1, &fence);
|
let _ = (ds.fp.reset_fences)(ds.raw, 1, &fence);
|
||||||
}
|
|
||||||
|
|
||||||
// Reset and re-record
|
|
||||||
unsafe {
|
|
||||||
let _ = (ds.fp.reset_command_buffer)(cb, vk::CommandBufferResetFlags::empty());
|
let _ = (ds.fp.reset_command_buffer)(cb, vk::CommandBufferResetFlags::empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -513,7 +686,7 @@ pub unsafe fn capture_final_frame(
|
|||||||
_marker: std::marker::PhantomData,
|
_marker: std::marker::PhantomData,
|
||||||
};
|
};
|
||||||
if unsafe { (ds.fp.begin_command_buffer)(cb, &bi) } != vk::Result::SUCCESS {
|
if unsafe { (ds.fp.begin_command_buffer)(cb, &bi) } != vk::Result::SUCCESS {
|
||||||
return;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let b1 = image_barrier!(
|
let b1 = image_barrier!(
|
||||||
@@ -591,7 +764,7 @@ pub unsafe fn capture_final_frame(
|
|||||||
(ds.fp.cmd_pipeline_barrier)(
|
(ds.fp.cmd_pipeline_barrier)(
|
||||||
cb,
|
cb,
|
||||||
vk::PipelineStageFlags::TRANSFER,
|
vk::PipelineStageFlags::TRANSFER,
|
||||||
vk::PipelineStageFlags::TOP_OF_PIPE,
|
vk::PipelineStageFlags::BOTTOM_OF_PIPE,
|
||||||
vk::DependencyFlags::empty(),
|
vk::DependencyFlags::empty(),
|
||||||
0,
|
0,
|
||||||
std::ptr::null(),
|
std::ptr::null(),
|
||||||
@@ -603,89 +776,115 @@ pub unsafe fn capture_final_frame(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if unsafe { (ds.fp.end_command_buffer)(cb) } != vk::Result::SUCCESS {
|
if unsafe { (ds.fp.end_command_buffer)(cb) } != vk::Result::SUCCESS {
|
||||||
return;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let wait_stages = vec![vk::PipelineStageFlags::TRANSFER; app_waits.len()];
|
||||||
let subi = vk::SubmitInfo {
|
let subi = vk::SubmitInfo {
|
||||||
s_type: vk::StructureType::SUBMIT_INFO,
|
s_type: vk::StructureType::SUBMIT_INFO,
|
||||||
p_next: std::ptr::null(),
|
p_next: std::ptr::null(),
|
||||||
wait_semaphore_count: 0,
|
wait_semaphore_count: app_waits.len() as u32,
|
||||||
p_wait_semaphores: std::ptr::null(),
|
p_wait_semaphores: if app_waits.is_empty() {
|
||||||
p_wait_dst_stage_mask: std::ptr::null(),
|
std::ptr::null()
|
||||||
|
} else {
|
||||||
|
app_waits.as_ptr()
|
||||||
|
},
|
||||||
|
p_wait_dst_stage_mask: if wait_stages.is_empty() {
|
||||||
|
std::ptr::null()
|
||||||
|
} else {
|
||||||
|
wait_stages.as_ptr()
|
||||||
|
},
|
||||||
command_buffer_count: 1,
|
command_buffer_count: 1,
|
||||||
p_command_buffers: &cb,
|
p_command_buffers: &cb,
|
||||||
signal_semaphore_count: 0,
|
signal_semaphore_count: 1,
|
||||||
p_signal_semaphores: std::ptr::null(),
|
p_signal_semaphores: &present_wait,
|
||||||
_marker: std::marker::PhantomData,
|
_marker: std::marker::PhantomData,
|
||||||
};
|
};
|
||||||
|
|
||||||
unsafe {
|
unsafe {
|
||||||
if (ds.fp.queue_submit)(queue, 1, &subi, fence) != vk::Result::SUCCESS {
|
if (ds.fp.queue_submit)(queue, 1, &subi, fence) != vk::Result::SUCCESS {
|
||||||
log::warn!("capture queue_submit failed — frame skipped");
|
log::warn!("capture queue_submit failed — frame skipped");
|
||||||
|
// The submit never happened, so nothing waited on the application's
|
||||||
|
// semaphores and nothing will signal ours. Re-signal the fence by
|
||||||
|
// hand so the slot is reusable, and let the caller present as the
|
||||||
|
// application intended.
|
||||||
let _ = (ds.fp.reset_fences)(ds.raw, 1, &fence);
|
let _ = (ds.fp.reset_fences)(ds.raw, 1, &fence);
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Toggle to the next slot
|
|
||||||
res.current = (idx + 1) % 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
unsafe fn create_capture_resources(ds: &crate::state::DeviceState) -> Option<CaptureResources> {
|
|
||||||
let pci = vk::CommandPoolCreateInfo {
|
|
||||||
s_type: vk::StructureType::COMMAND_POOL_CREATE_INFO,
|
|
||||||
p_next: std::ptr::null(),
|
|
||||||
flags: vk::CommandPoolCreateFlags::RESET_COMMAND_BUFFER, // allow per-cb reset
|
|
||||||
queue_family_index: 0,
|
|
||||||
_marker: std::marker::PhantomData,
|
|
||||||
};
|
|
||||||
let mut cp = vk::CommandPool::null();
|
|
||||||
if unsafe { (ds.fp.create_command_pool)(ds.raw, &pci, std::ptr::null(), &mut cp) }
|
|
||||||
!= vk::Result::SUCCESS
|
|
||||||
{
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let ai = vk::CommandBufferAllocateInfo {
|
|
||||||
s_type: vk::StructureType::COMMAND_BUFFER_ALLOCATE_INFO,
|
|
||||||
p_next: std::ptr::null(),
|
|
||||||
command_pool: cp,
|
|
||||||
level: vk::CommandBufferLevel::PRIMARY,
|
|
||||||
command_buffer_count: 4,
|
|
||||||
_marker: std::marker::PhantomData,
|
|
||||||
};
|
|
||||||
let mut cbs = [vk::CommandBuffer::null(); 4];
|
|
||||||
if unsafe { (ds.fp.allocate_command_buffers)(ds.raw, &ai, cbs.as_mut_ptr()) }
|
|
||||||
!= vk::Result::SUCCESS
|
|
||||||
{
|
|
||||||
unsafe { (ds.fp.destroy_command_pool)(ds.raw, cp, std::ptr::null()) };
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create fences PRE-SIGNALED so the first wait_for_fences returns immediately
|
|
||||||
let fci = vk::FenceCreateInfo {
|
|
||||||
s_type: vk::StructureType::FENCE_CREATE_INFO,
|
|
||||||
p_next: std::ptr::null(),
|
|
||||||
flags: vk::FenceCreateFlags::SIGNALED,
|
|
||||||
_marker: std::marker::PhantomData,
|
|
||||||
};
|
|
||||||
let mut fences = [vk::Fence::null(); 4];
|
|
||||||
for f in &mut fences {
|
|
||||||
if unsafe { (ds.fp.create_fence)(ds.raw, &fci, std::ptr::null(), f) } != vk::Result::SUCCESS
|
|
||||||
{
|
|
||||||
unsafe { (ds.fp.destroy_command_pool)(ds.raw, cp, std::ptr::null()) };
|
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Some(CaptureResources {
|
Some(CaptureSubmission {
|
||||||
command_pool: cp,
|
slot: guard,
|
||||||
command_buffers: cbs,
|
present_wait,
|
||||||
fences,
|
|
||||||
current: 0,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set aside the semaphore a failed present was given.
|
||||||
|
///
|
||||||
|
/// A present that returns an error may or may not have waited on it, so it can
|
||||||
|
/// neither be signalled again nor destroyed while it might still be pending.
|
||||||
|
/// The next capture for that image index creates a fresh one.
|
||||||
|
pub fn retire_present_semaphore(ds: &crate::state::DeviceState, image_index: usize) {
|
||||||
|
let Ok(mut ring_guard) = ds.capture_ring.lock() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(ring) = ring_guard.as_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Some(slot) = ring.present_wait.get_mut(image_index) {
|
||||||
|
let sem = std::mem::replace(slot, vk::Semaphore::null());
|
||||||
|
if sem != vk::Semaphore::null() {
|
||||||
|
ring.retired.push(sem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set aside every per-image semaphore, for a swapchain that is going away.
|
||||||
|
pub fn retire_all_present_semaphores(ds: &crate::state::DeviceState) {
|
||||||
|
let Ok(mut ring_guard) = ds.capture_ring.lock() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(ring) = ring_guard.as_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for sem in std::mem::take(&mut ring.present_wait) {
|
||||||
|
if sem != vk::Semaphore::null() {
|
||||||
|
ring.retired.push(sem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The semaphore for `image_index`, created on first use.
|
||||||
|
///
|
||||||
|
/// One per swapchain image rather than one per ring slot: a binary semaphore
|
||||||
|
/// cannot be signalled again until its previous wait has completed, and the
|
||||||
|
/// application reacquiring the image is the only evidence of that available
|
||||||
|
/// from inside the layer.
|
||||||
|
unsafe fn ensure_present_semaphore(
|
||||||
|
ds: &crate::state::DeviceState,
|
||||||
|
ring: &mut CaptureRing,
|
||||||
|
image_index: usize,
|
||||||
|
) -> Option<vk::Semaphore> {
|
||||||
|
let create = ds.fp.create_semaphore?;
|
||||||
|
if ring.present_wait.len() <= image_index {
|
||||||
|
ring.present_wait.resize(image_index + 1, vk::Semaphore::null());
|
||||||
|
}
|
||||||
|
if ring.present_wait[image_index] == vk::Semaphore::null() {
|
||||||
|
let ci = vk::SemaphoreCreateInfo {
|
||||||
|
s_type: vk::StructureType::SEMAPHORE_CREATE_INFO,
|
||||||
|
p_next: std::ptr::null(),
|
||||||
|
flags: vk::SemaphoreCreateFlags::empty(),
|
||||||
|
_marker: std::marker::PhantomData,
|
||||||
|
};
|
||||||
|
let mut sem = vk::Semaphore::null();
|
||||||
|
if unsafe { create(ds.raw, &ci, std::ptr::null(), &mut sem) } != vk::Result::SUCCESS {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
ring.present_wait[image_index] = sem;
|
||||||
|
}
|
||||||
|
Some(ring.present_wait[image_index])
|
||||||
|
}
|
||||||
|
|
||||||
// ── CPU pixel readback (fallback when DMA-BUF unavailable) ───────────────────
|
// ── CPU pixel readback (fallback when DMA-BUF unavailable) ───────────────────
|
||||||
|
|
||||||
pub unsafe fn read_frame_pixels(
|
pub unsafe fn read_frame_pixels(
|
||||||
|
|||||||
@@ -228,18 +228,6 @@ pub unsafe extern "system" fn vkCmdEndRenderPass(command_buffer: vk::CommandBuff
|
|||||||
None => return,
|
None => return,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Phase 4: track the largest extent we've seen (main framebuffer)
|
|
||||||
let current_extent = CB_STATE
|
|
||||||
.get(&cb_key)
|
|
||||||
.map(|r| r.current_image_extent)
|
|
||||||
.unwrap_or(None);
|
|
||||||
if let Some(ext) = current_extent {
|
|
||||||
let mut largest = ds.largest_extent.lock().unwrap();
|
|
||||||
if ext.width * ext.height > largest.width * largest.height {
|
|
||||||
*largest = ext;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 4: check if we need to inject a HUDless capture copy (device-level)
|
// Phase 4: check if we need to inject a HUDless capture copy (device-level)
|
||||||
let needs_hudless_capture = ds
|
let needs_hudless_capture = ds
|
||||||
.pending_capture_frame
|
.pending_capture_frame
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ use ash::vk::{self, Handle};
|
|||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
use std::os::raw::c_void;
|
use std::os::raw::c_void;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::Ordering;
|
|
||||||
|
|
||||||
const VK_LAYER_LINK_INFO: u32 = 0;
|
const VK_LAYER_LINK_INFO: u32 = 0;
|
||||||
|
|
||||||
@@ -95,32 +94,17 @@ pub unsafe extern "system" fn vkCreateDevice(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Bump queue count for dedicated capture queue ──────────────────
|
|
||||||
// Add one extra queue to the first queue family so capture
|
|
||||||
// submissions don't compete with game rendering.
|
|
||||||
let mut capture_queue_index = 0u32;
|
|
||||||
let queue_infos: Vec<vk::DeviceQueueCreateInfo> = if ci.queue_create_info_count > 0
|
|
||||||
&& !ci.p_queue_create_infos.is_null()
|
|
||||||
{
|
|
||||||
let slice = unsafe {
|
|
||||||
std::slice::from_raw_parts(ci.p_queue_create_infos, ci.queue_create_info_count as usize)
|
|
||||||
};
|
|
||||||
let mut qis = slice.to_vec();
|
|
||||||
if let Some(first) = qis.first_mut() {
|
|
||||||
capture_queue_index = first.queue_count; // use the NEXT index
|
|
||||||
first.queue_count += 1;
|
|
||||||
}
|
|
||||||
qis
|
|
||||||
} else {
|
|
||||||
Vec::new()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Try with injected extensions first.
|
// Try with injected extensions first.
|
||||||
|
//
|
||||||
|
// The device's queue create info is passed through unchanged. An earlier
|
||||||
|
// version bumped the first family's queue count by one to get a dedicated
|
||||||
|
// capture queue, which was then never used — and could not be: the capture
|
||||||
|
// blit has to be submitted to the queue the game presents on, or it gains
|
||||||
|
// no ordering against the present. All the bump did was risk exceeding the
|
||||||
|
// family's available queue count on the way in.
|
||||||
let mut modified_ci = *ci;
|
let mut modified_ci = *ci;
|
||||||
modified_ci.enabled_extension_count = extended.len() as u32;
|
modified_ci.enabled_extension_count = extended.len() as u32;
|
||||||
modified_ci.pp_enabled_extension_names = extended.as_ptr();
|
modified_ci.pp_enabled_extension_names = extended.as_ptr();
|
||||||
modified_ci.queue_create_info_count = queue_infos.len() as u32;
|
|
||||||
modified_ci.p_queue_create_infos = queue_infos.as_ptr();
|
|
||||||
|
|
||||||
let mut dmabuf_available = true;
|
let mut dmabuf_available = true;
|
||||||
let result =
|
let result =
|
||||||
@@ -150,8 +134,6 @@ pub unsafe extern "system" fn vkCreateDevice(
|
|||||||
let device = unsafe { *p_device };
|
let device = unsafe { *p_device };
|
||||||
|
|
||||||
// Cache physical device memory properties
|
// Cache physical device memory properties
|
||||||
let mut mem_props = vk::PhysicalDeviceMemoryProperties::default();
|
|
||||||
unsafe { (istate.get_physical_device_memory_properties)(physical_device, &mut mem_props) };
|
|
||||||
|
|
||||||
macro_rules! load {
|
macro_rules! load {
|
||||||
($name:literal) => {
|
($name:literal) => {
|
||||||
@@ -206,6 +188,8 @@ pub unsafe extern "system" fn vkCreateDevice(
|
|||||||
|
|
||||||
// Phase 4 — synchronisation
|
// Phase 4 — synchronisation
|
||||||
create_fence: load!(b"vkCreateFence\0"),
|
create_fence: load!(b"vkCreateFence\0"),
|
||||||
|
create_semaphore: try_load!(b"vkCreateSemaphore\0"),
|
||||||
|
destroy_semaphore: try_load!(b"vkDestroySemaphore\0"),
|
||||||
destroy_fence: load!(b"vkDestroyFence\0"),
|
destroy_fence: load!(b"vkDestroyFence\0"),
|
||||||
create_command_pool: load!(b"vkCreateCommandPool\0"),
|
create_command_pool: load!(b"vkCreateCommandPool\0"),
|
||||||
destroy_command_pool: load!(b"vkDestroyCommandPool\0"),
|
destroy_command_pool: load!(b"vkDestroyCommandPool\0"),
|
||||||
@@ -231,28 +215,6 @@ pub unsafe extern "system" fn vkCreateDevice(
|
|||||||
cmd_draw_indexed_indirect_count: try_load!(b"vkCmdDrawIndexedIndirectCount\0"),
|
cmd_draw_indexed_indirect_count: try_load!(b"vkCmdDrawIndexedIndirectCount\0"),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Retrieve capture queue from the bumped slot ───────────────────
|
|
||||||
let mut capture_queue = vk::Queue::null();
|
|
||||||
if queue_infos
|
|
||||||
.first()
|
|
||||||
.map(|q| q.queue_count > 1)
|
|
||||||
.unwrap_or(false)
|
|
||||||
{
|
|
||||||
let qi = &queue_infos[0];
|
|
||||||
unsafe {
|
|
||||||
(fp.get_device_queue)(
|
|
||||||
device,
|
|
||||||
qi.queue_family_index,
|
|
||||||
capture_queue_index,
|
|
||||||
&mut capture_queue,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
log::info!(
|
|
||||||
"capture queue: family={} index={capture_queue_index}",
|
|
||||||
qi.queue_family_index
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let key = unsafe { dispatch_key(device.as_raw() as *const c_void) };
|
let key = unsafe { dispatch_key(device.as_raw() as *const c_void) };
|
||||||
|
|
||||||
// Phase 3: load shader hash config
|
// Phase 3: load shader hash config
|
||||||
@@ -290,23 +252,18 @@ pub unsafe extern "system" fn vkCreateDevice(
|
|||||||
hudless_image: std::sync::Mutex::new(None),
|
hudless_image: std::sync::Mutex::new(None),
|
||||||
hudless_memory: std::sync::Mutex::new(None),
|
hudless_memory: std::sync::Mutex::new(None),
|
||||||
hudless_size: std::sync::Mutex::new((0, 0, vk::Format::UNDEFINED)),
|
hudless_size: std::sync::Mutex::new((0, 0, vk::Format::UNDEFINED)),
|
||||||
final_image: std::sync::Mutex::new(None),
|
capture_ring: std::sync::Mutex::new(None),
|
||||||
final_memory: std::sync::Mutex::new(None),
|
capture_slots: crate::slots::SlotPool::new(crate::state::CAPTURE_SLOTS),
|
||||||
final_size: std::sync::Mutex::new((0, 0, vk::Format::UNDEFINED)),
|
|
||||||
final_stride: std::sync::atomic::AtomicU32::new(0),
|
|
||||||
swapchain: std::sync::Mutex::new(None),
|
swapchain: std::sync::Mutex::new(None),
|
||||||
swapchain_images: std::sync::Mutex::new(Vec::new()),
|
swapchain_images: std::sync::Mutex::new(Vec::new()),
|
||||||
swapchain_format: std::sync::Mutex::new(vk::Format::UNDEFINED),
|
swapchain_format: std::sync::Mutex::new(vk::Format::UNDEFINED),
|
||||||
|
swapchain_transfer_src: std::sync::atomic::AtomicBool::new(false),
|
||||||
swapchain_extent: std::sync::Mutex::new(vk::Extent2D {
|
swapchain_extent: std::sync::Mutex::new(vk::Extent2D {
|
||||||
width: 0,
|
width: 0,
|
||||||
height: 0,
|
height: 0,
|
||||||
}),
|
}),
|
||||||
swapchain_colorspace: std::sync::atomic::AtomicU32::new(0),
|
swapchain_colorspace: std::sync::atomic::AtomicU32::new(0),
|
||||||
frame_counter: std::sync::atomic::AtomicU64::new(0),
|
frame_counter: std::sync::atomic::AtomicU64::new(0),
|
||||||
largest_extent: std::sync::Mutex::new(vk::Extent2D {
|
|
||||||
width: 0,
|
|
||||||
height: 0,
|
|
||||||
}),
|
|
||||||
|
|
||||||
hud_detected_frame: std::sync::atomic::AtomicBool::new(false),
|
hud_detected_frame: std::sync::atomic::AtomicBool::new(false),
|
||||||
pending_capture_frame: std::sync::atomic::AtomicBool::new(false),
|
pending_capture_frame: std::sync::atomic::AtomicBool::new(false),
|
||||||
@@ -315,24 +272,8 @@ pub unsafe extern "system" fn vkCreateDevice(
|
|||||||
|
|
||||||
encoder: std::sync::Mutex::new(None),
|
encoder: std::sync::Mutex::new(None),
|
||||||
|
|
||||||
capture_resources: std::sync::Mutex::new(None),
|
|
||||||
capture_queue: std::sync::Mutex::new(capture_queue),
|
|
||||||
fake_images: std::sync::Mutex::new(Vec::new()),
|
|
||||||
fake_memories: std::sync::Mutex::new(Vec::new()),
|
|
||||||
fake_fds: std::sync::Mutex::new(Vec::new()),
|
|
||||||
fake_strides: std::sync::Mutex::new(Vec::new()),
|
|
||||||
fake_available: std::sync::Mutex::new(Vec::new()),
|
|
||||||
fake_image_count: std::sync::atomic::AtomicU32::new(0),
|
|
||||||
fake_swapchain: std::sync::Mutex::new(None),
|
|
||||||
signal_queue: std::sync::Mutex::new(vk::Queue::null()),
|
|
||||||
next_acquire: std::sync::atomic::AtomicU32::new(0),
|
|
||||||
memory_properties: std::sync::Mutex::new(mem_props),
|
|
||||||
acquire_dummy_pool: std::sync::Mutex::new(vk::CommandPool::null()),
|
|
||||||
acquire_dummy_cb: std::sync::Mutex::new(vk::CommandBuffer::null()),
|
|
||||||
cached_dmabuf_fd: std::sync::atomic::AtomicI32::new(-1),
|
|
||||||
|
|
||||||
target_fps: std::sync::atomic::AtomicU32::new(0),
|
frame_gate: std::sync::Mutex::new(crate::pacing::FrameGate::from_env()),
|
||||||
last_capture_time: std::sync::Mutex::new(None),
|
|
||||||
capture_tx: std::sync::Mutex::new(None),
|
capture_tx: std::sync::Mutex::new(None),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -377,42 +318,26 @@ pub unsafe extern "system" fn vkDestroyDevice(
|
|||||||
// Brief yield to let threads notice the disconnect.
|
// Brief yield to let threads notice the disconnect.
|
||||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||||
|
|
||||||
// ── 2. Clean up capture resources (double-buffered cmd pool + fences) ─
|
// ── 2. Tear down the capture ring ─────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Images, memory, fences, exported fds and the per-swapchain-image
|
||||||
|
// semaphores all belong to the ring now, so one teardown covers what used
|
||||||
|
// to be three separate steps.
|
||||||
{
|
{
|
||||||
let mut res_guard = ds.capture_resources.lock().unwrap();
|
let ring = ds.capture_ring.lock().unwrap().take();
|
||||||
if let Some(res) = res_guard.take() {
|
if let Some(ring) = ring {
|
||||||
unsafe {
|
if !ds.capture_slots.all_free() {
|
||||||
// Wait for any in-flight capture commands to finish before
|
log::warn!(
|
||||||
// destroying the fences / command pool.
|
"device destroyed with {} capture slot(s) still in flight",
|
||||||
let _ = (ds.fp.wait_for_fences)(
|
crate::state::CAPTURE_SLOTS - ds.capture_slots.available()
|
||||||
ds.raw,
|
|
||||||
res.fences.len() as u32,
|
|
||||||
res.fences.as_ptr(),
|
|
||||||
vk::TRUE,
|
|
||||||
5_000_000_000, // 5 seconds — should be instant
|
|
||||||
);
|
);
|
||||||
for &f in &res.fences {
|
|
||||||
(ds.fp.destroy_fence)(ds.raw, f, std::ptr::null());
|
|
||||||
}
|
|
||||||
(ds.fp.destroy_command_pool)(ds.raw, res.command_pool, std::ptr::null());
|
|
||||||
}
|
}
|
||||||
log::debug!("capture resources destroyed");
|
unsafe { crate::capture::destroy_capture_ring(&ds, ring) };
|
||||||
|
log::debug!("capture ring destroyed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 3. Free final_image / final_memory ────────────────────────────────
|
// ── 3. Free hudless_image / hudless_memory ────────────────────────────
|
||||||
{
|
|
||||||
let img = ds.final_image.lock().unwrap().take();
|
|
||||||
let mem = ds.final_memory.lock().unwrap().take();
|
|
||||||
if let Some(i) = img {
|
|
||||||
unsafe { (ds.fp.destroy_image)(ds.raw, i, std::ptr::null()) };
|
|
||||||
}
|
|
||||||
if let Some(m) = mem {
|
|
||||||
unsafe { (ds.fp.free_memory)(ds.raw, m, std::ptr::null()) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 4. Free hudless_image / hudless_memory ────────────────────────────
|
|
||||||
{
|
{
|
||||||
let img = ds.hudless_image.lock().unwrap().take();
|
let img = ds.hudless_image.lock().unwrap().take();
|
||||||
let mem = ds.hudless_memory.lock().unwrap().take();
|
let mem = ds.hudless_memory.lock().unwrap().take();
|
||||||
@@ -424,19 +349,18 @@ pub unsafe extern "system" fn vkDestroyDevice(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 5. Close cached DMA-BUF fd ────────────────────────────────────────
|
// ── 4. Clean up queue → device key mappings for this device ───────────
|
||||||
{
|
let stale: Vec<u64> = QUEUE_TO_DEVICE_KEY
|
||||||
let fd = ds.cached_dmabuf_fd.load(Ordering::Relaxed);
|
.iter()
|
||||||
if fd >= 0 {
|
.filter(|e| *e.value() == key)
|
||||||
unsafe { libc::close(fd) };
|
.map(|e| *e.key())
|
||||||
log::debug!("cached DMA-BUF fd {} closed", fd);
|
.collect();
|
||||||
}
|
for q in stale {
|
||||||
|
crate::state::QUEUE_TO_FAMILY.remove(&q);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 6. Clean up queue → device key mappings for this device ───────────
|
|
||||||
QUEUE_TO_DEVICE_KEY.retain(|_, dk| *dk != key);
|
QUEUE_TO_DEVICE_KEY.retain(|_, dk| *dk != key);
|
||||||
|
|
||||||
// ── 7. Call the real vkDestroyDevice ──────────────────────────────────
|
// ── 5. Call the real vkDestroyDevice ──────────────────────────────────
|
||||||
unsafe { (ds.fp.destroy_device)(device, p_allocator) };
|
unsafe { (ds.fp.destroy_device)(device, p_allocator) };
|
||||||
|
|
||||||
log::info!("vkDestroyDevice complete");
|
log::info!("vkDestroyDevice complete");
|
||||||
@@ -454,11 +378,7 @@ pub unsafe extern "system" fn vkGetDeviceQueue(
|
|||||||
unsafe { (ds.fp.get_device_queue)(device, queue_family_index, queue_index, p_queue) };
|
unsafe { (ds.fp.get_device_queue)(device, queue_family_index, queue_index, p_queue) };
|
||||||
let queue = unsafe { *p_queue };
|
let queue = unsafe { *p_queue };
|
||||||
QUEUE_TO_DEVICE_KEY.insert(queue.as_raw(), key);
|
QUEUE_TO_DEVICE_KEY.insert(queue.as_raw(), key);
|
||||||
// Store first queue for acquire semaphore signaling
|
crate::state::QUEUE_TO_FAMILY.insert(queue.as_raw(), queue_family_index);
|
||||||
let mut sq = ds.signal_queue.lock().unwrap();
|
|
||||||
if *sq == vk::Queue::null() {
|
|
||||||
*sq = queue;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -209,6 +209,16 @@ pub type PFN_vkCreateFence = unsafe extern "system" fn(
|
|||||||
pub type PFN_vkDestroyFence =
|
pub type PFN_vkDestroyFence =
|
||||||
unsafe extern "system" fn(vk::Device, vk::Fence, *const vk::AllocationCallbacks);
|
unsafe extern "system" fn(vk::Device, vk::Fence, *const vk::AllocationCallbacks);
|
||||||
|
|
||||||
|
pub type PFN_vkCreateSemaphore = unsafe extern "system" fn(
|
||||||
|
vk::Device,
|
||||||
|
*const vk::SemaphoreCreateInfo,
|
||||||
|
*const vk::AllocationCallbacks,
|
||||||
|
*mut vk::Semaphore,
|
||||||
|
) -> vk::Result;
|
||||||
|
|
||||||
|
pub type PFN_vkDestroySemaphore =
|
||||||
|
unsafe extern "system" fn(vk::Device, vk::Semaphore, *const vk::AllocationCallbacks);
|
||||||
|
|
||||||
pub type PFN_vkCreateCommandPool = unsafe extern "system" fn(
|
pub type PFN_vkCreateCommandPool = unsafe extern "system" fn(
|
||||||
vk::Device,
|
vk::Device,
|
||||||
*const vk::CommandPoolCreateInfo,
|
*const vk::CommandPoolCreateInfo,
|
||||||
@@ -337,6 +347,10 @@ pub struct NextDeviceFn {
|
|||||||
// Phase 4 — synchronisation
|
// Phase 4 — synchronisation
|
||||||
pub create_fence: PFN_vkCreateFence,
|
pub create_fence: PFN_vkCreateFence,
|
||||||
pub destroy_fence: PFN_vkDestroyFence,
|
pub destroy_fence: PFN_vkDestroyFence,
|
||||||
|
/// Core since 1.0, but loaded optionally so a driver that somehow fails to
|
||||||
|
/// resolve it degrades to no capture rather than to a null-pointer call.
|
||||||
|
pub create_semaphore: Option<PFN_vkCreateSemaphore>,
|
||||||
|
pub destroy_semaphore: Option<PFN_vkDestroySemaphore>,
|
||||||
pub create_command_pool: PFN_vkCreateCommandPool,
|
pub create_command_pool: PFN_vkCreateCommandPool,
|
||||||
pub destroy_command_pool: PFN_vkDestroyCommandPool,
|
pub destroy_command_pool: PFN_vkDestroyCommandPool,
|
||||||
pub reset_command_pool: PFN_vkResetCommandPool,
|
pub reset_command_pool: PFN_vkResetCommandPool,
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ const VK_COLOR_SPACE_HDR10_ST2084_EXT: u32 = colorspace(ash::vk::ColorSpaceKHR::
|
|||||||
const VK_COLOR_SPACE_EXTENDED_SRGB_LINEAR_EXT: u32 =
|
const VK_COLOR_SPACE_EXTENDED_SRGB_LINEAR_EXT: u32 =
|
||||||
colorspace(ash::vk::ColorSpaceKHR::EXTENDED_SRGB_LINEAR_EXT);
|
colorspace(ash::vk::ColorSpaceKHR::EXTENDED_SRGB_LINEAR_EXT);
|
||||||
const VK_COLOR_SPACE_BT2020_LINEAR_EXT: u32 = colorspace(ash::vk::ColorSpaceKHR::BT2020_LINEAR_EXT);
|
const VK_COLOR_SPACE_BT2020_LINEAR_EXT: u32 = colorspace(ash::vk::ColorSpaceKHR::BT2020_LINEAR_EXT);
|
||||||
const VK_COLOR_SPACE_DOLBYVISION_EXT: u32 = colorspace(ash::vk::ColorSpaceKHR::DOLBYVISION_EXT);
|
|
||||||
const VK_COLOR_SPACE_HDR10_HLG_EXT: u32 = colorspace(ash::vk::ColorSpaceKHR::HDR10_HLG_EXT);
|
const VK_COLOR_SPACE_HDR10_HLG_EXT: u32 = colorspace(ash::vk::ColorSpaceKHR::HDR10_HLG_EXT);
|
||||||
|
|
||||||
/// The converter input format for a swapchain's `VkFormat`, or `None` when
|
/// The converter input format for a swapchain's `VkFormat`, or `None` when
|
||||||
@@ -94,9 +93,7 @@ pub fn vk_format_to_input_format(vk_format: u32) -> Option<InputFormat> {
|
|||||||
|
|
||||||
pub fn vk_colorspace_to_color_space(vk_colorspace: u32) -> ColorSpace {
|
pub fn vk_colorspace_to_color_space(vk_colorspace: u32) -> ColorSpace {
|
||||||
match vk_colorspace {
|
match vk_colorspace {
|
||||||
VK_COLOR_SPACE_HDR10_ST2084_EXT
|
VK_COLOR_SPACE_HDR10_ST2084_EXT | VK_COLOR_SPACE_HDR10_HLG_EXT => ColorSpace::Bt2020,
|
||||||
| VK_COLOR_SPACE_DOLBYVISION_EXT
|
|
||||||
| VK_COLOR_SPACE_HDR10_HLG_EXT => ColorSpace::Bt2020,
|
|
||||||
|
|
||||||
// Both are linear, so the inverse sRGB EOTF that `SrgbToBt2020Pq` applies
|
// Both are linear, so the inverse sRGB EOTF that `SrgbToBt2020Pq` applies
|
||||||
// would decode data that was never encoded. `Bt709LinearToBt2020Pq` is
|
// would decode data that was never encoded. `Bt709LinearToBt2020Pq` is
|
||||||
@@ -180,6 +177,19 @@ pub struct CapturedFrame {
|
|||||||
pub height: u32,
|
pub height: u32,
|
||||||
pub vk_format: u32,
|
pub vk_format: u32,
|
||||||
pub vk_colorspace: u32,
|
pub vk_colorspace: u32,
|
||||||
|
/// When the game presented this frame. Carried all the way to the wire so
|
||||||
|
/// the timestamp describes the frame rather than the encoder's backlog.
|
||||||
|
pub present_time: Instant,
|
||||||
|
/// Reserves the capture ring slot this frame's DMA-BUF lives in. Dropping
|
||||||
|
/// the frame — encoded, skipped, or abandoned — returns the slot, so the
|
||||||
|
/// present hook can never blit over a buffer the encoder is still reading.
|
||||||
|
pub slot: Option<crate::slots::SlotGuard>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An encode in flight, with the time of the present it came from.
|
||||||
|
struct EncodedFrame {
|
||||||
|
future: EncodeFuture,
|
||||||
|
present_time: Instant,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum FrameSource {
|
pub enum FrameSource {
|
||||||
@@ -353,7 +363,7 @@ impl PipelineHandle {
|
|||||||
.ok_or_else(|| "no hardware video encoder found on this GPU".to_string())?;
|
.ok_or_else(|| "no hardware video encoder found on this GPU".to_string())?;
|
||||||
|
|
||||||
let (frame_tx, frame_rx) = mpsc::sync_channel::<CapturedFrame>(2);
|
let (frame_tx, frame_rx) = mpsc::sync_channel::<CapturedFrame>(2);
|
||||||
let (encoded_tx, encoded_rx) = mpsc::sync_channel::<EncodeFuture>(2);
|
let (encoded_tx, encoded_rx) = mpsc::sync_channel::<EncodedFrame>(2);
|
||||||
let (reconfig_tx, reconfig_rx) = mpsc::channel::<EncodeSettingsChange>();
|
let (reconfig_tx, reconfig_rx) = mpsc::channel::<EncodeSettingsChange>();
|
||||||
let shutdown = Arc::new(AtomicBool::new(false));
|
let shutdown = Arc::new(AtomicBool::new(false));
|
||||||
let idr_requested = Arc::new(AtomicBool::new(false));
|
let idr_requested = Arc::new(AtomicBool::new(false));
|
||||||
@@ -399,6 +409,8 @@ impl PipelineHandle {
|
|||||||
width: config.width as u16,
|
width: config.width as u16,
|
||||||
height: config.height as u16,
|
height: config.height as u16,
|
||||||
encode_ms: encode_avg_ms.clone(),
|
encode_ms: encode_avg_ms.clone(),
|
||||||
|
idr_requested: idr_requested.clone(),
|
||||||
|
epoch: Instant::now(),
|
||||||
};
|
};
|
||||||
thread::Builder::new()
|
thread::Builder::new()
|
||||||
.name("nescapture-ipc".into())
|
.name("nescapture-ipc".into())
|
||||||
@@ -596,7 +608,7 @@ pub struct EncodeSettingsChange {
|
|||||||
fn encoder_thread(
|
fn encoder_thread(
|
||||||
mut cfg: EncoderConfig,
|
mut cfg: EncoderConfig,
|
||||||
frame_rx: mpsc::Receiver<CapturedFrame>,
|
frame_rx: mpsc::Receiver<CapturedFrame>,
|
||||||
encoded_tx: mpsc::SyncSender<EncodeFuture>,
|
encoded_tx: mpsc::SyncSender<EncodedFrame>,
|
||||||
shutdown: Arc<AtomicBool>,
|
shutdown: Arc<AtomicBool>,
|
||||||
) {
|
) {
|
||||||
let ctx = cfg.ctx;
|
let ctx = cfg.ctx;
|
||||||
@@ -738,6 +750,12 @@ fn encoder_thread(
|
|||||||
state.encoder.request_idr();
|
state.encoder.request_idr();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Each ring slot is a distinct DMA-BUF, so the importer caches an
|
||||||
|
// imported image per slot. Importing every frame under index 0 would
|
||||||
|
// have handed the encoder whichever buffer happened to be imported
|
||||||
|
// first, for every frame after it.
|
||||||
|
let buffer_index = raw.slot.as_ref().map(|s| s.index()).unwrap_or(0);
|
||||||
|
|
||||||
let result = match &mut raw.source {
|
let result = match &mut raw.source {
|
||||||
FrameSource::DmaBuf {
|
FrameSource::DmaBuf {
|
||||||
fd,
|
fd,
|
||||||
@@ -758,6 +776,7 @@ fn encoder_thread(
|
|||||||
raw.height,
|
raw.height,
|
||||||
raw.vk_format,
|
raw.vk_format,
|
||||||
frame_number,
|
frame_number,
|
||||||
|
buffer_index,
|
||||||
),
|
),
|
||||||
None => {
|
None => {
|
||||||
unsafe { libc::close(owned_fd) };
|
unsafe { libc::close(owned_fd) };
|
||||||
@@ -782,7 +801,21 @@ fn encoder_thread(
|
|||||||
match result {
|
match result {
|
||||||
Err(e) => log::warn!("encode frame {frame_number}: {e}"),
|
Err(e) => log::warn!("encode frame {frame_number}: {e}"),
|
||||||
Ok(future) => {
|
Ok(future) => {
|
||||||
let _ = encoded_tx.try_send(future);
|
// Blocking, deliberately. Dropping an encoded frame does not
|
||||||
|
// just waste the encode — it breaks the reference chain. The
|
||||||
|
// encoder's DPB believes the frame exists and codes later
|
||||||
|
// frames against it, so a decoder that never receives it shows
|
||||||
|
// corruption until the next IDR. Blocking here pushes back
|
||||||
|
// through `frame_rx` to `push_frame`, where a drop is free:
|
||||||
|
// that frame never entered the encoder and no later frame
|
||||||
|
// refers to it.
|
||||||
|
let pending = EncodedFrame {
|
||||||
|
future,
|
||||||
|
present_time: raw.present_time,
|
||||||
|
};
|
||||||
|
if encoded_tx.send(pending).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -907,6 +940,7 @@ fn gpu_encode_frame(
|
|||||||
height: u32,
|
height: u32,
|
||||||
vk_format: u32,
|
vk_format: u32,
|
||||||
frame_number: u32,
|
frame_number: u32,
|
||||||
|
buffer_index: usize,
|
||||||
) -> Result<EncodeFuture> {
|
) -> Result<EncodeFuture> {
|
||||||
use ash::vk;
|
use ash::vk;
|
||||||
|
|
||||||
@@ -920,7 +954,7 @@ fn gpu_encode_frame(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let (imported_image, needs_layout_transition) = importer
|
let (imported_image, needs_layout_transition) = importer
|
||||||
.import_or_reuse(0, width, height, bgra_vk_fmt, &[plane])
|
.import_or_reuse(buffer_index, width, height, bgra_vk_fmt, &[plane])
|
||||||
.map_err(|e| anyhow::anyhow!("DmaBufImporter: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("DmaBufImporter: {e}"))?;
|
||||||
|
|
||||||
unsafe { libc::close(fd) };
|
unsafe { libc::close(fd) };
|
||||||
@@ -1042,11 +1076,15 @@ struct IpcConfig {
|
|||||||
width: u16,
|
width: u16,
|
||||||
height: u16,
|
height: u16,
|
||||||
encode_ms: Arc<AtomicU32>,
|
encode_ms: Arc<AtomicU32>,
|
||||||
|
/// Shared with the encoder thread, which honours it on the next frame.
|
||||||
|
idr_requested: Arc<AtomicBool>,
|
||||||
|
/// Zero point for wire timestamps.
|
||||||
|
epoch: Instant,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ipc_send_thread(
|
fn ipc_send_thread(
|
||||||
cfg: IpcConfig,
|
cfg: IpcConfig,
|
||||||
encoded_rx: mpsc::Receiver<EncodeFuture>,
|
encoded_rx: mpsc::Receiver<EncodedFrame>,
|
||||||
shutdown: Arc<AtomicBool>,
|
shutdown: Arc<AtomicBool>,
|
||||||
) {
|
) {
|
||||||
let socket = match UnixDatagram::unbound() {
|
let socket = match UnixDatagram::unbound() {
|
||||||
@@ -1069,7 +1107,9 @@ fn ipc_send_thread(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let start_time = Instant::now();
|
// Fixed before the first frame arrives, so every timestamp shares an epoch
|
||||||
|
// even though frames are stamped from their own present.
|
||||||
|
let start_time = cfg.epoch;
|
||||||
let mut frame_count: u64 = 0;
|
let mut frame_count: u64 = 0;
|
||||||
|
|
||||||
'outer: loop {
|
'outer: loop {
|
||||||
@@ -1105,11 +1145,12 @@ fn ipc_send_thread(
|
|||||||
if shutdown.load(Ordering::Relaxed) {
|
if shutdown.load(Ordering::Relaxed) {
|
||||||
break 'outer;
|
break 'outer;
|
||||||
}
|
}
|
||||||
let result = match encoded_rx.recv_timeout(std::time::Duration::from_millis(100)) {
|
let (result, present_time) =
|
||||||
Ok(p) => pollster::block_on(p),
|
match encoded_rx.recv_timeout(std::time::Duration::from_millis(100)) {
|
||||||
Err(mpsc::RecvTimeoutError::Timeout) => continue,
|
Ok(p) => (pollster::block_on(p.future), p.present_time),
|
||||||
Err(mpsc::RecvTimeoutError::Disconnected) => break 'outer,
|
Err(mpsc::RecvTimeoutError::Timeout) => continue,
|
||||||
};
|
Err(mpsc::RecvTimeoutError::Disconnected) => break 'outer,
|
||||||
|
};
|
||||||
let pkt = match result {
|
let pkt = match result {
|
||||||
Ok(p) => p,
|
Ok(p) => p,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -1124,7 +1165,13 @@ fn ipc_send_thread(
|
|||||||
cfg.encode_ms.store(enc_ms.to_bits(), Ordering::Relaxed);
|
cfg.encode_ms.store(enc_ms.to_bits(), Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
|
|
||||||
let timestamp_ms = start_time.elapsed().as_millis() as u32;
|
// From the present, not from here. Stamping at send time folded
|
||||||
|
// however long the frame spent queued for the encoder into the
|
||||||
|
// timestamp, so the receiver could not tell capture time from
|
||||||
|
// backlog and had nothing honest to pace on.
|
||||||
|
let timestamp_ms = present_time
|
||||||
|
.saturating_duration_since(start_time)
|
||||||
|
.as_millis() as u32;
|
||||||
let mut flags = if pkt.is_key_frame { FLAG_KEYFRAME } else { 0 };
|
let mut flags = if pkt.is_key_frame { FLAG_KEYFRAME } else { 0 };
|
||||||
// Set FLAG_RECONFIG on the first frame after an encoder reconfig.
|
// Set FLAG_RECONFIG on the first frame after an encoder reconfig.
|
||||||
// Clear it after setting so only the first frame is marked.
|
// Clear it after setting so only the first frame is marked.
|
||||||
@@ -1149,7 +1196,11 @@ fn ipc_send_thread(
|
|||||||
log::warn!("IPC send failed ({} frames dropped): {e}", error_count);
|
log::warn!("IPC send failed ({} frames dropped): {e}", error_count);
|
||||||
last_warn = Instant::now();
|
last_warn = Instant::now();
|
||||||
}
|
}
|
||||||
// Socket disconnected — reconnect
|
// Socket disconnected — reconnect. The frames lost while it
|
||||||
|
// was down are gone from the reference chain, so ask for an
|
||||||
|
// IDR rather than resuming into a stream the receiver cannot
|
||||||
|
// reconstruct.
|
||||||
|
cfg.idr_requested.store(true, Ordering::Relaxed);
|
||||||
log::warn!("IPC disconnected, reconnecting...");
|
log::warn!("IPC disconnected, reconnecting...");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -1284,7 +1335,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn hdr_colour_spaces_select_the_hdr_arm() {
|
fn hdr_colour_spaces_select_the_hdr_arm() {
|
||||||
for cs in [Cs::HDR10_ST2084_EXT, Cs::DOLBYVISION_EXT, Cs::HDR10_HLG_EXT] {
|
for cs in [Cs::HDR10_ST2084_EXT, Cs::HDR10_HLG_EXT] {
|
||||||
let raw = cs.as_raw() as u32;
|
let raw = cs.as_raw() as u32;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
vk_colorspace_to_color_space(raw),
|
vk_colorspace_to_color_space(raw),
|
||||||
@@ -1336,7 +1387,6 @@ mod tests {
|
|||||||
Cs::SRGB_NONLINEAR,
|
Cs::SRGB_NONLINEAR,
|
||||||
Cs::PASS_THROUGH_EXT,
|
Cs::PASS_THROUGH_EXT,
|
||||||
Cs::HDR10_ST2084_EXT,
|
Cs::HDR10_ST2084_EXT,
|
||||||
Cs::DOLBYVISION_EXT,
|
|
||||||
Cs::HDR10_HLG_EXT,
|
Cs::HDR10_HLG_EXT,
|
||||||
Cs::EXTENDED_SRGB_LINEAR_EXT,
|
Cs::EXTENDED_SRGB_LINEAR_EXT,
|
||||||
Cs::BT2020_LINEAR_EXT,
|
Cs::BT2020_LINEAR_EXT,
|
||||||
@@ -1409,7 +1459,6 @@ mod tests {
|
|||||||
for cs in [
|
for cs in [
|
||||||
Cs::SRGB_NONLINEAR,
|
Cs::SRGB_NONLINEAR,
|
||||||
Cs::HDR10_ST2084_EXT,
|
Cs::HDR10_ST2084_EXT,
|
||||||
Cs::DOLBYVISION_EXT,
|
|
||||||
Cs::HDR10_HLG_EXT,
|
Cs::HDR10_HLG_EXT,
|
||||||
Cs::EXTENDED_SRGB_LINEAR_EXT,
|
Cs::EXTENDED_SRGB_LINEAR_EXT,
|
||||||
Cs::PASS_THROUGH_EXT,
|
Cs::PASS_THROUGH_EXT,
|
||||||
|
|||||||
@@ -31,9 +31,11 @@ mod dmabuf_import;
|
|||||||
mod encode;
|
mod encode;
|
||||||
mod framebuffer;
|
mod framebuffer;
|
||||||
mod instance;
|
mod instance;
|
||||||
|
mod pacing;
|
||||||
mod pipeline;
|
mod pipeline;
|
||||||
mod present;
|
mod present;
|
||||||
mod shader;
|
mod shader;
|
||||||
|
mod slots;
|
||||||
mod state;
|
mod state;
|
||||||
mod swapchain;
|
mod swapchain;
|
||||||
|
|
||||||
|
|||||||
172
apps/nescapture/src/pacing.rs
Normal file
172
apps/nescapture/src/pacing.rs
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// pacing.rs — capture frame-rate gate
|
||||||
|
//
|
||||||
|
// The layer is the only place that sees every frame the game produces, so it
|
||||||
|
// is the only place that can decide which ones are worth capturing. Nothing
|
||||||
|
// here consults the compositor: no vblank, no surface, no present feedback,
|
||||||
|
// just the monotonic clock. nescope runs uncapped by design and must stay out
|
||||||
|
// of this decision.
|
||||||
|
//
|
||||||
|
// The gate only ever *drops*. It never waits, never blocks the game's present,
|
||||||
|
// and never admits more frames than the game offered. A game running below the
|
||||||
|
// target is passed through untouched.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
/// Admits at most `target_fps` frames per second, dropping the rest.
|
||||||
|
///
|
||||||
|
/// The deadline advances by a fixed interval rather than being recomputed from
|
||||||
|
/// the admitted frame's arrival time, so a game presenting slightly off-cadence
|
||||||
|
/// does not accumulate drift.
|
||||||
|
pub struct FrameGate {
|
||||||
|
/// Zero means uncapped — every frame is admitted.
|
||||||
|
interval: Duration,
|
||||||
|
/// Frames arriving within this much of the deadline are admitted early.
|
||||||
|
///
|
||||||
|
/// Without it, a game running at exactly the target rate beats against the
|
||||||
|
/// gate: presents land a hair before each deadline, get rejected, and the
|
||||||
|
/// stream loses a frame every time the two rates drift past each other.
|
||||||
|
slack: Duration,
|
||||||
|
/// `None` until the first frame establishes the cadence.
|
||||||
|
next_deadline: Option<Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FrameGate {
|
||||||
|
pub fn new(target_fps: u32) -> Self {
|
||||||
|
let interval = if target_fps == 0 {
|
||||||
|
Duration::ZERO
|
||||||
|
} else {
|
||||||
|
Duration::from_nanos(1_000_000_000 / u64::from(target_fps))
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
interval,
|
||||||
|
slack: interval / 8,
|
||||||
|
next_deadline: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the target from the environment. `0` disables the gate.
|
||||||
|
pub fn from_env() -> Self {
|
||||||
|
let fps = std::env::var("NESCAPTURE_FPS")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(60);
|
||||||
|
Self::new(fps)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Frames per second this gate admits, or 0 when uncapped.
|
||||||
|
pub fn target_fps(&self) -> u32 {
|
||||||
|
if self.interval.is_zero() {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
(1_000_000_000 / self.interval.as_nanos().max(1)) as u32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decide whether the frame presented at `now` should be captured.
|
||||||
|
pub fn admit(&mut self, now: Instant) -> bool {
|
||||||
|
if self.interval.is_zero() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let Some(deadline) = self.next_deadline else {
|
||||||
|
self.next_deadline = Some(now + self.interval);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
if now + self.slack < deadline {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance one interval from the deadline, not from `now`, so a game
|
||||||
|
// presenting slightly early or late keeps an exact cadence.
|
||||||
|
let advanced = deadline + self.interval;
|
||||||
|
|
||||||
|
// Slip clamp. If the game stalled — a load screen, a shader compile, a
|
||||||
|
// hitch — the deadline can end up many intervals in the past. Advancing
|
||||||
|
// by one interval at a time would leave the gate "owing" frames and it
|
||||||
|
// would admit a burst of them back to back the moment the game resumes,
|
||||||
|
// which is exactly when the GPU can least afford it. Drop the debt and
|
||||||
|
// restart the cadence from now.
|
||||||
|
self.next_deadline = Some(if advanced <= now {
|
||||||
|
now + self.interval
|
||||||
|
} else {
|
||||||
|
advanced
|
||||||
|
});
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const HZ60: Duration = Duration::from_nanos(16_666_666);
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn uncapped_admits_everything() {
|
||||||
|
let mut g = FrameGate::new(0);
|
||||||
|
let t = Instant::now();
|
||||||
|
for i in 0..100 {
|
||||||
|
assert!(g.admit(t + Duration::from_micros(i)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fast_game_is_thinned_to_the_target() {
|
||||||
|
// 300 fps offered, 60 wanted: one frame in five, over a full second.
|
||||||
|
let mut g = FrameGate::new(60);
|
||||||
|
let t0 = Instant::now();
|
||||||
|
let step = Duration::from_nanos(3_333_333);
|
||||||
|
let admitted = (0..300).filter(|i| g.admit(t0 + step * *i)).count();
|
||||||
|
assert!((59..=61).contains(&admitted), "admitted {admitted}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn slow_game_passes_through_untouched() {
|
||||||
|
// 30 fps offered, 60 wanted: nothing may be dropped.
|
||||||
|
let mut g = FrameGate::new(60);
|
||||||
|
let t0 = Instant::now();
|
||||||
|
let step = Duration::from_nanos(33_333_333);
|
||||||
|
for i in 0..60 {
|
||||||
|
assert!(g.admit(t0 + step * i), "dropped frame {i} of a 30 fps game");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn matched_rate_does_not_beat_against_the_gate() {
|
||||||
|
// A game at exactly the target rate must not lose frames to jitter.
|
||||||
|
let mut g = FrameGate::new(60);
|
||||||
|
let t0 = Instant::now();
|
||||||
|
let mut at = t0;
|
||||||
|
for i in 0..120 {
|
||||||
|
// ±1ms of jitter around a perfect 60 Hz cadence.
|
||||||
|
let jitter = if i % 2 == 0 {
|
||||||
|
Duration::from_millis(1)
|
||||||
|
} else {
|
||||||
|
Duration::ZERO
|
||||||
|
};
|
||||||
|
at += HZ60;
|
||||||
|
assert!(g.admit(at + jitter), "dropped frame {i} of a 60 fps game");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_stall_does_not_bank_a_burst() {
|
||||||
|
let mut g = FrameGate::new(60);
|
||||||
|
let t0 = Instant::now();
|
||||||
|
assert!(g.admit(t0));
|
||||||
|
|
||||||
|
// Two seconds of nothing — a load screen.
|
||||||
|
let resume = t0 + Duration::from_secs(2);
|
||||||
|
|
||||||
|
// The first frame back is admitted immediately...
|
||||||
|
assert!(g.admit(resume));
|
||||||
|
|
||||||
|
// ...but the game resuming at 300 fps must not replay the ~120 frames
|
||||||
|
// the gate "missed" during the stall. Across the next 100ms it may
|
||||||
|
// admit only what 60 fps allows: about six, not all thirty offered.
|
||||||
|
let step = Duration::from_nanos(3_333_333);
|
||||||
|
let burst = (1..30).filter(|i| g.admit(resume + step * *i)).count();
|
||||||
|
assert!((4..=7).contains(&burst), "admitted {burst} frames in 100ms");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,25 @@
|
|||||||
use crate::capture;
|
use crate::capture;
|
||||||
use crate::encode::{CapturedFrame, FrameSource, PipelineConfig, PipelineHandle};
|
use crate::encode::{CapturedFrame, FrameSource, PipelineConfig, PipelineHandle};
|
||||||
|
use crate::slots::SlotGuard;
|
||||||
use crate::state::{DEVICE_STATE, QUEUE_TO_DEVICE_KEY};
|
use crate::state::{DEVICE_STATE, QUEUE_TO_DEVICE_KEY};
|
||||||
use ash::vk::{self, Handle};
|
use ash::vk::{self, Handle};
|
||||||
use std::os::raw::c_void;
|
use std::os::raw::c_void;
|
||||||
use std::sync::atomic::Ordering;
|
use std::sync::atomic::Ordering;
|
||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
|
|
||||||
|
/// A frame already blitted into a ring slot, waiting to be handed to the
|
||||||
|
/// encoder. The GPU work is submitted before this is queued, so the worker's
|
||||||
|
/// only job is to wait for it and export the buffer.
|
||||||
pub struct CaptureJob {
|
pub struct CaptureJob {
|
||||||
pub queue: vk::Queue,
|
|
||||||
pub sc_image: vk::Image,
|
|
||||||
pub sc_fmt: vk::Format,
|
|
||||||
pub sc_ext: vk::Extent2D,
|
|
||||||
pub frame: u64,
|
|
||||||
pub ds_key: usize,
|
pub ds_key: usize,
|
||||||
|
/// Holds the ring slot until the encoder is finished with it.
|
||||||
|
pub slot: SlotGuard,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub sc_fmt: vk::Format,
|
||||||
|
/// When the game handed this frame to `vkQueuePresentKHR`. The only honest
|
||||||
|
/// capture time — everything downstream is queued behind something.
|
||||||
|
pub present_time: std::time::Instant,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[unsafe(no_mangle)]
|
#[unsafe(no_mangle)]
|
||||||
@@ -37,7 +44,7 @@ pub unsafe extern "system" fn vkQueuePresentKHR(
|
|||||||
None => return vk::Result::ERROR_DEVICE_LOST,
|
None => return vk::Result::ERROR_DEVICE_LOST,
|
||||||
};
|
};
|
||||||
|
|
||||||
let frame = ds.frame_counter.fetch_add(1, Ordering::Relaxed);
|
ds.frame_counter.fetch_add(1, Ordering::Relaxed);
|
||||||
ds.hud_detected_frame.store(false, Ordering::Relaxed);
|
ds.hud_detected_frame.store(false, Ordering::Relaxed);
|
||||||
ds.pending_capture_frame.store(false, Ordering::Relaxed);
|
ds.pending_capture_frame.store(false, Ordering::Relaxed);
|
||||||
ds.capture_injected_frame.store(false, Ordering::Relaxed);
|
ds.capture_injected_frame.store(false, Ordering::Relaxed);
|
||||||
@@ -50,58 +57,155 @@ pub unsafe extern "system" fn vkQueuePresentKHR(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let pi = unsafe { &*p_present_info };
|
let pi = unsafe { &*p_present_info };
|
||||||
if pi.swapchain_count > 0 && !pi.p_swapchains.is_null() && !pi.p_image_indices.is_null() {
|
|
||||||
let idx = unsafe { *pi.p_image_indices as usize };
|
|
||||||
let (sc_image, sc_fmt, sc_ext) = {
|
|
||||||
let images = ds.swapchain_images.lock().unwrap();
|
|
||||||
let fmt = *ds.swapchain_format.lock().unwrap();
|
|
||||||
let ext = *ds.swapchain_extent.lock().unwrap();
|
|
||||||
if idx < images.len() && ext.width > 0 && ext.height > 0 {
|
|
||||||
(Some(images[idx]), fmt, ext)
|
|
||||||
} else {
|
|
||||||
(None, fmt, ext)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(sc_image) = sc_image {
|
// Rewriting the wait semaphores is only well defined for a single
|
||||||
// No time-based throttle — let the encoder channel provide natural backpressure
|
// swapchain. A multi-swapchain present is rare enough that passing it
|
||||||
let should = true;
|
// through untouched beats getting the interposition subtly wrong.
|
||||||
|
let single_swapchain = pi.swapchain_count == 1
|
||||||
|
&& !pi.p_swapchains.is_null()
|
||||||
|
&& !pi.p_image_indices.is_null();
|
||||||
|
|
||||||
if should {
|
let submission = if single_swapchain {
|
||||||
if let Ok(enc) = ds.encoder.lock() {
|
unsafe { try_capture(&ds, queue, pi) }
|
||||||
if let Some(ref h) = *enc {
|
} else {
|
||||||
h.capture_attempts.fetch_add(1, Ordering::Relaxed);
|
None
|
||||||
}
|
};
|
||||||
}
|
|
||||||
// Ensure capture worker is running
|
let call_down = |info: *const vk::PresentInfoKHR| match ds.fp.queue_present_khr {
|
||||||
{
|
Some(f) => unsafe { f(queue, info) },
|
||||||
let mut ctx = ds.capture_tx.lock().unwrap();
|
None => vk::Result::ERROR_EXTENSION_NOT_PRESENT,
|
||||||
if ctx.is_none() {
|
};
|
||||||
let (tx, rx) = mpsc::channel();
|
|
||||||
let key = unsafe { crate::dispatch_key(ds.raw.as_raw() as *const c_void) };
|
let Some(submission) = submission else {
|
||||||
start_capture_worker(key, rx);
|
return call_down(p_present_info);
|
||||||
*ctx = Some(tx);
|
};
|
||||||
}
|
|
||||||
}
|
// The blit consumed the application's wait semaphores, so the present waits
|
||||||
// Queue job to worker thread — don't block present
|
// on ours instead. Presenting on the originals as well would be a second
|
||||||
let job = CaptureJob {
|
// wait on an already-consumed signal.
|
||||||
queue,
|
let wait = submission.present_wait;
|
||||||
sc_image,
|
let rewritten = vk::PresentInfoKHR {
|
||||||
sc_fmt,
|
s_type: pi.s_type,
|
||||||
sc_ext,
|
p_next: pi.p_next,
|
||||||
frame,
|
wait_semaphore_count: 1,
|
||||||
ds_key: unsafe { crate::dispatch_key(ds.raw.as_raw() as *const c_void) },
|
p_wait_semaphores: &wait,
|
||||||
};
|
swapchain_count: pi.swapchain_count,
|
||||||
if let Ok(capture_tx) = ds.capture_tx.lock() {
|
p_swapchains: pi.p_swapchains,
|
||||||
let _ = capture_tx.as_ref().unwrap().send(job);
|
p_image_indices: pi.p_image_indices,
|
||||||
}
|
p_results: pi.p_results,
|
||||||
}
|
_marker: std::marker::PhantomData,
|
||||||
|
};
|
||||||
|
|
||||||
|
let image_index = unsafe { *pi.p_image_indices } as usize;
|
||||||
|
let result = call_down(&rewritten);
|
||||||
|
|
||||||
|
// Only hand the frame on once the present has been accepted. A failed
|
||||||
|
// present drops the job, which returns the slot.
|
||||||
|
if matches!(result, vk::Result::SUCCESS | vk::Result::SUBOPTIMAL_KHR) {
|
||||||
|
queue_for_encode(&ds, submission);
|
||||||
|
} else {
|
||||||
|
// The blit signalled this semaphore; whether the failed present waited
|
||||||
|
// on it is undefined. Set it aside rather than signal it twice.
|
||||||
|
capture::retire_present_semaphore(&ds, image_index);
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything the present hook needs to carry from the blit to the worker.
|
||||||
|
struct Submission {
|
||||||
|
slot: SlotGuard,
|
||||||
|
present_wait: vk::Semaphore,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
sc_fmt: vk::Format,
|
||||||
|
present_time: std::time::Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn try_capture(
|
||||||
|
ds: &crate::state::DeviceState,
|
||||||
|
queue: vk::Queue,
|
||||||
|
pi: &vk::PresentInfoKHR,
|
||||||
|
) -> Option<Submission> {
|
||||||
|
let image_index = unsafe { *pi.p_image_indices } as usize;
|
||||||
|
let (sc_image, sc_fmt, sc_ext) = {
|
||||||
|
let images = ds.swapchain_images.lock().ok()?;
|
||||||
|
let fmt = *ds.swapchain_format.lock().ok()?;
|
||||||
|
let ext = *ds.swapchain_extent.lock().ok()?;
|
||||||
|
if image_index >= images.len() || ext.width == 0 || ext.height == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
(images[image_index], fmt, ext)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Gate before any GPU work is queued. A game presenting faster than the
|
||||||
|
// target would otherwise pay a full blit and DMA-BUF export for frames the
|
||||||
|
// encoder throws away moments later.
|
||||||
|
let present_time = std::time::Instant::now();
|
||||||
|
let admitted = match ds.frame_gate.lock() {
|
||||||
|
Ok(mut gate) => gate.admit(present_time),
|
||||||
|
// A poisoned gate must not stop the stream; capture everything.
|
||||||
|
Err(_) => true,
|
||||||
|
};
|
||||||
|
if !admitted {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(enc) = ds.encoder.lock() {
|
||||||
|
if let Some(ref h) = *enc {
|
||||||
|
h.capture_attempts.fetch_add(1, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
match ds.fp.queue_present_khr {
|
let app_waits: &[vk::Semaphore] = if pi.wait_semaphore_count == 0 || pi.p_wait_semaphores.is_null()
|
||||||
Some(f) => unsafe { f(queue, p_present_info) },
|
{
|
||||||
None => vk::Result::ERROR_EXTENSION_NOT_PRESENT,
|
&[]
|
||||||
|
} else {
|
||||||
|
unsafe {
|
||||||
|
std::slice::from_raw_parts(pi.p_wait_semaphores, pi.wait_semaphore_count as usize)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let submission = unsafe {
|
||||||
|
capture::capture_present_frame(ds, queue, sc_image, sc_fmt, sc_ext, image_index, app_waits)
|
||||||
|
}?;
|
||||||
|
|
||||||
|
Some(Submission {
|
||||||
|
slot: submission.slot,
|
||||||
|
present_wait: submission.present_wait,
|
||||||
|
width: sc_ext.width,
|
||||||
|
height: sc_ext.height,
|
||||||
|
sc_fmt,
|
||||||
|
present_time,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn queue_for_encode(ds: &crate::state::DeviceState, submission: Submission) {
|
||||||
|
let ds_key = unsafe { crate::dispatch_key(ds.raw.as_raw() as *const c_void) };
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut ctx = match ds.capture_tx.lock() {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
if ctx.is_none() {
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
start_capture_worker(ds_key, rx);
|
||||||
|
*ctx = Some(tx);
|
||||||
|
}
|
||||||
|
if let Some(tx) = ctx.as_ref() {
|
||||||
|
// Unbounded, but bounded in practice: the ring hands out a fixed
|
||||||
|
// number of slots and a job holds one for its whole life, so the
|
||||||
|
// queue can never exceed the slot count.
|
||||||
|
let _ = tx.send(CaptureJob {
|
||||||
|
ds_key,
|
||||||
|
slot: submission.slot,
|
||||||
|
width: submission.width,
|
||||||
|
height: submission.height,
|
||||||
|
sc_fmt: submission.sc_fmt,
|
||||||
|
present_time: submission.present_time,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +214,6 @@ pub fn start_capture_worker(ds_key: usize, capture_rx: mpsc::Receiver<CaptureJob
|
|||||||
.name("nescapture-capture".into())
|
.name("nescapture-capture".into())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
while let Ok(job) = capture_rx.recv() {
|
while let Ok(job) = capture_rx.recv() {
|
||||||
let t0 = std::time::Instant::now();
|
|
||||||
let ds = match DEVICE_STATE.get(&job.ds_key) {
|
let ds = match DEVICE_STATE.get(&job.ds_key) {
|
||||||
Some(s) => s.clone(),
|
Some(s) => s.clone(),
|
||||||
None => {
|
None => {
|
||||||
@@ -118,97 +221,84 @@ pub fn start_capture_worker(ds_key: usize, capture_rx: mpsc::Receiver<CaptureJob
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// Do the blit on the worker's own time
|
|
||||||
unsafe {
|
// Copy the slot's handles out and release the ring lock before
|
||||||
capture::capture_final_frame(
|
// waiting: the present hook needs that lock every frame and
|
||||||
&ds,
|
// must not queue behind a GPU wait.
|
||||||
job.queue,
|
let Some((fence, dmabuf_fd, stride, image, memory)) = ({
|
||||||
job.sc_image,
|
let ring = ds.capture_ring.lock().unwrap();
|
||||||
job.sc_fmt,
|
ring.as_ref()
|
||||||
job.sc_ext,
|
.and_then(|r| r.slots.get(job.slot.index()))
|
||||||
job.frame,
|
.map(|s| (s.fence, s.dmabuf_fd, s.stride, s.image, s.memory))
|
||||||
)
|
}) else {
|
||||||
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Export DMA-BUF and push to encoder
|
// The encoder reads this buffer from pixelforge's own VkDevice,
|
||||||
let source = {
|
// which shares no timeline with ours, so the handover has to be
|
||||||
let mem_guard = ds.final_memory.lock().unwrap();
|
// on the CPU. Waiting here rather than in the present hook is
|
||||||
let stride = ds.final_stride.load(Ordering::Relaxed);
|
// the whole point of the worker thread.
|
||||||
if let Some(mem) = *mem_guard {
|
let waited = unsafe {
|
||||||
unsafe { try_make_dmabuf_source(&ds, mem, stride) }.unwrap_or_else(|| {
|
(ds.fp.wait_for_fences)(ds.raw, 1, &fence, vk::TRUE, 1_000_000_000)
|
||||||
let (w, h, _) = *ds.final_size.lock().unwrap();
|
};
|
||||||
let img = ds.final_image.lock().unwrap().unwrap();
|
if waited != vk::Result::SUCCESS {
|
||||||
unsafe { capture::read_frame_pixels(&ds, img, mem, w, h) }
|
log::warn!("capture blit did not complete — frame dropped");
|
||||||
.map(FrameSource::Pixels)
|
continue;
|
||||||
.unwrap_or(FrameSource::Pixels(Vec::new()))
|
}
|
||||||
})
|
|
||||||
} else {
|
let source = if dmabuf_fd >= 0 {
|
||||||
|
let duped = unsafe { libc::dup(dmabuf_fd) };
|
||||||
|
if duped < 0 {
|
||||||
|
log::warn!("dup of capture DMA-BUF failed — frame dropped");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
FrameSource::DmaBuf {
|
||||||
|
fd: duped,
|
||||||
|
stride,
|
||||||
|
modifier: 0,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match unsafe {
|
||||||
|
capture::read_frame_pixels(&ds, image, memory, job.width, job.height)
|
||||||
|
} {
|
||||||
|
Some(p) if !p.is_empty() => FrameSource::Pixels(p),
|
||||||
|
_ => continue,
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let (w, h, _) = *ds.final_size.lock().unwrap();
|
// Lazy-init encoder
|
||||||
if !matches!(&source, FrameSource::Pixels(p) if p.is_empty()) {
|
{
|
||||||
// Lazy-init encoder
|
let mut enc = ds.encoder.lock().unwrap();
|
||||||
{
|
if enc.is_none() {
|
||||||
let mut enc = ds.encoder.lock().unwrap();
|
if let Some(cfg) = PipelineConfig::from_env(job.width, job.height) {
|
||||||
if enc.is_none() {
|
match PipelineHandle::new(cfg) {
|
||||||
if let Some(cfg) = PipelineConfig::from_env(w, h) {
|
Ok(h) => *enc = Some(h),
|
||||||
ds.target_fps.store(cfg.fps, Ordering::Relaxed);
|
Err(e) => panic!("{e}"),
|
||||||
match PipelineHandle::new(cfg) {
|
|
||||||
Ok(h) => *enc = Some(h),
|
|
||||||
Err(e) => panic!("{e}"),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let enc_guard = ds.encoder.lock().unwrap();
|
}
|
||||||
if let Some(ref encoder) = *enc_guard {
|
let enc_guard = ds.encoder.lock().unwrap();
|
||||||
let capture_elapsed = t0.elapsed().as_secs_f32() * 1000.0;
|
if let Some(ref encoder) = *enc_guard {
|
||||||
encoder
|
// Measured from the game's present, not from the start of
|
||||||
.capture_ms
|
// this iteration: the wait above is part of what capture
|
||||||
.store(capture_elapsed.to_bits(), Ordering::Relaxed);
|
// costs, and timing only the parts after it hid that.
|
||||||
encoder.push_frame(CapturedFrame {
|
let capture_elapsed = job.present_time.elapsed().as_secs_f32() * 1000.0;
|
||||||
source,
|
encoder
|
||||||
width: w,
|
.capture_ms
|
||||||
height: h,
|
.store(capture_elapsed.to_bits(), Ordering::Relaxed);
|
||||||
vk_format: job.sc_fmt.as_raw() as u32,
|
encoder.push_frame(CapturedFrame {
|
||||||
vk_colorspace: ds.swapchain_colorspace.load(Ordering::Relaxed),
|
source,
|
||||||
});
|
width: job.width,
|
||||||
}
|
height: job.height,
|
||||||
|
vk_format: job.sc_fmt.as_raw() as u32,
|
||||||
|
vk_colorspace: ds.swapchain_colorspace.load(Ordering::Relaxed),
|
||||||
|
present_time: job.present_time,
|
||||||
|
slot: Some(job.slot),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
log::info!("capture worker exiting");
|
log::info!("capture worker exiting");
|
||||||
})
|
})
|
||||||
.ok();
|
.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
unsafe fn try_make_dmabuf_source(
|
|
||||||
ds: &crate::state::DeviceState,
|
|
||||||
mem: vk::DeviceMemory,
|
|
||||||
stride: u32,
|
|
||||||
) -> Option<FrameSource> {
|
|
||||||
let cached = ds.cached_dmabuf_fd.load(Ordering::Relaxed);
|
|
||||||
let fd = if cached >= 0 {
|
|
||||||
let duped = unsafe { libc::dup(cached) };
|
|
||||||
if duped >= 0 {
|
|
||||||
duped
|
|
||||||
} else {
|
|
||||||
let fresh = unsafe { capture::get_dmabuf_fd(ds, mem)? };
|
|
||||||
ds.cached_dmabuf_fd.store(fresh, Ordering::Relaxed);
|
|
||||||
unsafe { libc::dup(fresh) }
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let fresh = unsafe { capture::get_dmabuf_fd(ds, mem)? };
|
|
||||||
ds.cached_dmabuf_fd.store(fresh, Ordering::Relaxed);
|
|
||||||
unsafe { libc::dup(fresh) }
|
|
||||||
};
|
|
||||||
if fd < 0 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some(FrameSource::DmaBuf {
|
|
||||||
fd,
|
|
||||||
stride,
|
|
||||||
modifier: 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
100
apps/nescapture/src/slots.rs
Normal file
100
apps/nescapture/src/slots.rs
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// slots.rs — ownership of the capture ring's destination buffers
|
||||||
|
//
|
||||||
|
// A captured frame travels from the present hook, through the capture worker,
|
||||||
|
// into the encoder thread, and its DMA-BUF must not be written again until the
|
||||||
|
// encoder has finished reading it. Tracking that by hand across three threads
|
||||||
|
// is how the single-buffer version got it wrong. Instead the slot index is
|
||||||
|
// carried by a guard that returns it to the pool when it drops, wherever that
|
||||||
|
// happens to be — including on the error paths that abandon a frame.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
pub struct SlotPool {
|
||||||
|
free: Mutex<Vec<usize>>,
|
||||||
|
count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SlotPool {
|
||||||
|
pub fn new(count: usize) -> Arc<Self> {
|
||||||
|
Arc::new(Self {
|
||||||
|
free: Mutex::new((0..count).collect()),
|
||||||
|
count,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take a slot, or `None` if every one is still downstream.
|
||||||
|
///
|
||||||
|
/// Never blocks. This is called from `vkQueuePresentKHR`, on the game's own
|
||||||
|
/// thread, where waiting for the encoder to catch up would be a stutter the
|
||||||
|
/// player can feel. A frame with no slot is simply not captured.
|
||||||
|
pub fn try_acquire(self: &Arc<Self>) -> Option<SlotGuard> {
|
||||||
|
let index = self.free.lock().ok()?.pop()?;
|
||||||
|
Some(SlotGuard {
|
||||||
|
pool: Arc::clone(self),
|
||||||
|
index,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many slots are not currently downstream.
|
||||||
|
pub fn available(&self) -> usize {
|
||||||
|
self.free.lock().map(|f| f.len()).unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when nothing is in flight — the only safe moment to tear the ring
|
||||||
|
/// down or rebuild it at a new resolution.
|
||||||
|
pub fn all_free(&self) -> bool {
|
||||||
|
self.available() == self.count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SlotGuard {
|
||||||
|
pool: Arc<SlotPool>,
|
||||||
|
index: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SlotGuard {
|
||||||
|
pub fn index(&self) -> usize {
|
||||||
|
self.index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for SlotGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if let Ok(mut free) = self.pool.free.lock() {
|
||||||
|
free.push(self.index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_pool_hands_out_every_slot_once() {
|
||||||
|
let pool = SlotPool::new(4);
|
||||||
|
let held: Vec<_> = (0..4).map(|_| pool.try_acquire().unwrap()).collect();
|
||||||
|
assert!(pool.try_acquire().is_none(), "handed out a fifth slot");
|
||||||
|
let mut indices: Vec<_> = held.iter().map(|g| g.index()).collect();
|
||||||
|
indices.sort_unstable();
|
||||||
|
assert_eq!(indices, vec![0, 1, 2, 3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dropping_a_guard_returns_its_slot() {
|
||||||
|
let pool = SlotPool::new(2);
|
||||||
|
let a = pool.try_acquire().unwrap();
|
||||||
|
let b = pool.try_acquire().unwrap();
|
||||||
|
assert!(pool.try_acquire().is_none());
|
||||||
|
assert!(!pool.all_free());
|
||||||
|
|
||||||
|
drop(a);
|
||||||
|
assert_eq!(pool.available(), 1);
|
||||||
|
assert!(pool.try_acquire().is_some());
|
||||||
|
|
||||||
|
drop(b);
|
||||||
|
assert!(pool.all_free());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,12 +10,58 @@ use dashmap::DashMap;
|
|||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
// Holds capture-specific resources instead of re-creating them *each frame*
|
/// How many frames may be in flight between the present hook and the encoder.
|
||||||
pub struct CaptureResources {
|
///
|
||||||
|
/// The path holds at most three at once — two queued for the encoder plus the
|
||||||
|
/// one it is working on — so four leaves a slot spare and the present hook
|
||||||
|
/// practically never finds the ring empty.
|
||||||
|
pub const CAPTURE_SLOTS: usize = 4;
|
||||||
|
|
||||||
|
/// One destination for the swapchain blit, with everything that belongs to it.
|
||||||
|
///
|
||||||
|
/// Every slot owns its image: the previous version rotated four command buffers
|
||||||
|
/// over a single shared destination, so a capture overwrote the frame the
|
||||||
|
/// encoder was still reading.
|
||||||
|
pub struct CaptureSlot {
|
||||||
|
pub image: vk::Image,
|
||||||
|
pub memory: vk::DeviceMemory,
|
||||||
|
/// Exported once at allocation and duplicated per frame. -1 if the export
|
||||||
|
/// failed, which sends that frame down the CPU readback path instead.
|
||||||
|
pub dmabuf_fd: std::os::raw::c_int,
|
||||||
|
pub stride: u32,
|
||||||
|
pub command_buffer: vk::CommandBuffer,
|
||||||
|
/// Signalled when this slot's blit has finished reading the swapchain and
|
||||||
|
/// writing the slot. The capture worker waits on it before handing the
|
||||||
|
/// DMA-BUF to the encoder, which reads it from a different VkDevice and so
|
||||||
|
/// cannot be synchronised with a semaphore.
|
||||||
|
pub fence: vk::Fence,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct CaptureRing {
|
||||||
pub command_pool: vk::CommandPool,
|
pub command_pool: vk::CommandPool,
|
||||||
pub command_buffers: [vk::CommandBuffer; 4],
|
pub slots: Vec<CaptureSlot>,
|
||||||
pub fences: [vk::Fence; 4],
|
pub size: (u32, u32, vk::Format),
|
||||||
pub current: usize,
|
/// Queue family the command pool was created for. Command buffers may only
|
||||||
|
/// be submitted to a queue of the family their pool belongs to, so a
|
||||||
|
/// present arriving on a different family rebuilds the ring rather than
|
||||||
|
/// submitting invalid work.
|
||||||
|
pub queue_family: u32,
|
||||||
|
/// Semaphores that were handed to a present which then failed.
|
||||||
|
///
|
||||||
|
/// Whether such a present waited on the semaphore is unknowable, so it can
|
||||||
|
/// neither be signalled again nor safely destroyed while it might still be
|
||||||
|
/// pending. They are set aside here and destroyed with the device. A
|
||||||
|
/// swapchain recreation retires at most one per image.
|
||||||
|
pub retired: Vec<vk::Semaphore>,
|
||||||
|
/// Signalled by the capture blit, waited on by the present that follows it.
|
||||||
|
///
|
||||||
|
/// Indexed by *swapchain image index*, not by ring slot. A binary semaphore
|
||||||
|
/// may not be re-signalled until its previous wait has completed, and the
|
||||||
|
/// only guarantee of that available here is the application's own acquire:
|
||||||
|
/// it cannot present image N again until it has reacquired it, and it
|
||||||
|
/// cannot reacquire it until the present that waited on this semaphore is
|
||||||
|
/// done.
|
||||||
|
pub present_wait: Vec<vk::Semaphore>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Per-pipeline records ──────────────────────────────────────────────────────
|
// ── Per-pipeline records ──────────────────────────────────────────────────────
|
||||||
@@ -60,12 +106,11 @@ pub struct DeviceState {
|
|||||||
pub hudless_size: std::sync::Mutex<(u32, u32, vk::Format)>,
|
pub hudless_size: std::sync::Mutex<(u32, u32, vk::Format)>,
|
||||||
|
|
||||||
// Phase 4: final-frame capture (DMA-BUF exportable)
|
// Phase 4: final-frame capture (DMA-BUF exportable)
|
||||||
pub final_image: std::sync::Mutex<Option<vk::Image>>,
|
pub capture_ring: std::sync::Mutex<Option<CaptureRing>>,
|
||||||
pub final_memory: std::sync::Mutex<Option<vk::DeviceMemory>>,
|
/// Which ring slots are free. Held separately from the ring itself so a
|
||||||
pub final_size: std::sync::Mutex<(u32, u32, vk::Format)>,
|
/// slot can be returned from the encoder thread without taking the lock
|
||||||
/// Row stride in bytes of `final_image`, queried once after allocation.
|
/// the present hook needs.
|
||||||
/// Zero until the first frame is captured.
|
pub capture_slots: Arc<crate::slots::SlotPool>,
|
||||||
pub final_stride: std::sync::atomic::AtomicU32,
|
|
||||||
|
|
||||||
// Phase 4: swapchain tracking
|
// Phase 4: swapchain tracking
|
||||||
pub swapchain: std::sync::Mutex<Option<vk::SwapchainKHR>>,
|
pub swapchain: std::sync::Mutex<Option<vk::SwapchainKHR>>,
|
||||||
@@ -75,9 +120,13 @@ pub struct DeviceState {
|
|||||||
/// Used to derive color space for the encoder (SDR vs HDR10 etc.).
|
/// Used to derive color space for the encoder (SDR vs HDR10 etc.).
|
||||||
pub swapchain_colorspace: std::sync::atomic::AtomicU32,
|
pub swapchain_colorspace: std::sync::atomic::AtomicU32,
|
||||||
pub swapchain_extent: std::sync::Mutex<vk::Extent2D>,
|
pub swapchain_extent: std::sync::Mutex<vk::Extent2D>,
|
||||||
|
/// Whether the swapchain was created with TRANSFER_SRC usage. When the
|
||||||
|
/// driver refuses it the layer falls back to the application's own usage
|
||||||
|
/// flags, and blitting from those images would be undefined — so capture
|
||||||
|
/// stays off for the life of that swapchain.
|
||||||
|
pub swapchain_transfer_src: std::sync::atomic::AtomicBool,
|
||||||
|
|
||||||
pub frame_counter: std::sync::atomic::AtomicU64,
|
pub frame_counter: std::sync::atomic::AtomicU64,
|
||||||
pub largest_extent: std::sync::Mutex<vk::Extent2D>,
|
|
||||||
|
|
||||||
// Phase 3/4: per-frame HUD detection flags
|
// Phase 3/4: per-frame HUD detection flags
|
||||||
pub hud_detected_frame: std::sync::atomic::AtomicBool,
|
pub hud_detected_frame: std::sync::atomic::AtomicBool,
|
||||||
@@ -88,32 +137,12 @@ pub struct DeviceState {
|
|||||||
// Phase 7: encode + IPC pipeline (lazy-init on first frame)
|
// Phase 7: encode + IPC pipeline (lazy-init on first frame)
|
||||||
pub encoder: std::sync::Mutex<Option<PipelineHandle>>,
|
pub encoder: std::sync::Mutex<Option<PipelineHandle>>,
|
||||||
|
|
||||||
// Re-usable capture resources (double-buffered)
|
|
||||||
pub capture_resources: std::sync::Mutex<Option<CaptureResources>>,
|
|
||||||
/// Dedicated queue for capture submissions (separate from game rendering).
|
|
||||||
pub capture_queue: std::sync::Mutex<vk::Queue>,
|
|
||||||
// Fake swapchain pool (headless — no real present)
|
|
||||||
pub fake_images: std::sync::Mutex<Vec<vk::Image>>,
|
|
||||||
pub fake_memories: std::sync::Mutex<Vec<vk::DeviceMemory>>,
|
|
||||||
pub fake_fds: std::sync::Mutex<Vec<std::os::raw::c_int>>,
|
|
||||||
pub fake_strides: std::sync::Mutex<Vec<u32>>,
|
|
||||||
pub fake_available: std::sync::Mutex<Vec<bool>>,
|
|
||||||
pub fake_image_count: std::sync::atomic::AtomicU32,
|
|
||||||
pub fake_swapchain: std::sync::Mutex<Option<vk::SwapchainKHR>>,
|
|
||||||
pub signal_queue: std::sync::Mutex<vk::Queue>,
|
|
||||||
pub next_acquire: std::sync::atomic::AtomicU32,
|
|
||||||
pub memory_properties: std::sync::Mutex<vk::PhysicalDeviceMemoryProperties>,
|
|
||||||
pub acquire_dummy_pool: std::sync::Mutex<vk::CommandPool>,
|
|
||||||
pub acquire_dummy_cb: std::sync::Mutex<vk::CommandBuffer>,
|
|
||||||
/// Cached DMA-BUF fd for final_memory (-1 = not cached).
|
|
||||||
/// Avoids a kernel ioctl per frame.
|
|
||||||
pub cached_dmabuf_fd: std::sync::atomic::AtomicI32,
|
|
||||||
|
|
||||||
// ── Frame-rate throttle ───────────────────────────────────────────
|
// ── Frame-rate throttle ───────────────────────────────────────────
|
||||||
/// Target FPS for capture throttling (set from HUDLESS_FPS on pipeline init).
|
/// Decides which presented frames are worth capturing. Consulted in the
|
||||||
pub target_fps: std::sync::atomic::AtomicU32,
|
/// present hook, before any GPU work is queued, so a dropped frame costs
|
||||||
/// Timestamp of last captured frame (for rate limiting).
|
/// nothing beyond the comparison.
|
||||||
pub last_capture_time: std::sync::Mutex<Option<std::time::Instant>>,
|
pub frame_gate: std::sync::Mutex<crate::pacing::FrameGate>,
|
||||||
/// Channel for threaded capture worker (present → worker).
|
/// Channel for threaded capture worker (present → worker).
|
||||||
pub capture_tx: std::sync::Mutex<Option<std::sync::mpsc::Sender<crate::present::CaptureJob>>>,
|
pub capture_tx: std::sync::Mutex<Option<std::sync::mpsc::Sender<crate::present::CaptureJob>>>,
|
||||||
}
|
}
|
||||||
@@ -151,5 +180,8 @@ pub static CB_STATE: Lazy<DashMap<u64, CbState>> = Lazy::new(DashMap::new);
|
|||||||
/// VkQueue → device dispatch key
|
/// VkQueue → device dispatch key
|
||||||
pub static QUEUE_TO_DEVICE_KEY: Lazy<DashMap<u64, usize>> = Lazy::new(DashMap::new);
|
pub static QUEUE_TO_DEVICE_KEY: Lazy<DashMap<u64, usize>> = Lazy::new(DashMap::new);
|
||||||
|
|
||||||
|
/// VkQueue → queue family index, recorded at vkGetDeviceQueue.
|
||||||
|
pub static QUEUE_TO_FAMILY: Lazy<DashMap<u64, u32>> = Lazy::new(DashMap::new);
|
||||||
|
|
||||||
/// VkCommandBuffer → device dispatch key
|
/// VkCommandBuffer → device dispatch key
|
||||||
pub static CMD_BUF_TO_DEVICE_KEY: Lazy<DashMap<u64, usize>> = Lazy::new(DashMap::new);
|
pub static CMD_BUF_TO_DEVICE_KEY: Lazy<DashMap<u64, usize>> = Lazy::new(DashMap::new);
|
||||||
|
|||||||
@@ -38,14 +38,28 @@ pub unsafe extern "system" fn vkCreateSwapchainKHR(
|
|||||||
|
|
||||||
let result = unsafe { create_fn(device, &modified_ci, p_allocator, p_swapchain) };
|
let result = unsafe { create_fn(device, &modified_ci, p_allocator, p_swapchain) };
|
||||||
// If the driver rejects TRANSFER_SRC (e.g. composited window), try without.
|
// If the driver rejects TRANSFER_SRC (e.g. composited window), try without.
|
||||||
let result = if result != vk::Result::SUCCESS {
|
let transfer_src = result == vk::Result::SUCCESS;
|
||||||
unsafe { create_fn(device, ci, p_allocator, p_swapchain) }
|
let result = if transfer_src {
|
||||||
} else {
|
|
||||||
result
|
result
|
||||||
|
} else {
|
||||||
|
unsafe { create_fn(device, ci, p_allocator, p_swapchain) }
|
||||||
};
|
};
|
||||||
if result != vk::Result::SUCCESS {
|
if result != vk::Result::SUCCESS {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
if !transfer_src {
|
||||||
|
log::warn!(
|
||||||
|
"swapchain refused TRANSFER_SRC — capture disabled for this swapchain. \
|
||||||
|
Blitting from images the driver did not grant transfer usage is undefined."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ds.swapchain_transfer_src
|
||||||
|
.store(transfer_src, Ordering::Relaxed);
|
||||||
|
|
||||||
|
// A fresh swapchain means fresh images behind the same indices. The
|
||||||
|
// per-image capture semaphores may still be pending on presents from the
|
||||||
|
// outgoing swapchain, so they are set aside rather than reused.
|
||||||
|
crate::capture::retire_all_present_semaphores(&ds);
|
||||||
|
|
||||||
*ds.swapchain.lock().unwrap() = Some(unsafe { *p_swapchain });
|
*ds.swapchain.lock().unwrap() = Some(unsafe { *p_swapchain });
|
||||||
*ds.swapchain_format.lock().unwrap() = ci.image_format;
|
*ds.swapchain_format.lock().unwrap() = ci.image_format;
|
||||||
|
|||||||
Reference in New Issue
Block a user