feat: pyrowave support (#354)

At this point, why not..

Calling it `nespyro` since it's slightly modified, uses slang shader
language instead, along other things..

---------

Co-authored-by: DatCaptainHorse <DatCaptainHorse@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kristian Ollikainen
2026-10-01 23:53:19 +03:00
committed by GitHub
co-authored by DatCaptainHorse Claude Opus 5.5
parent ca7115dba8
commit cf46bf4d12
62 changed files with 15649 additions and 589 deletions
+3
View File
@@ -39,3 +39,6 @@ libc = "0.2"
# Shared IPC protocol
nesprotocol = { path = "../../crates/nesprotocol" }
# PyroWave, the LAN codec, on the same device as pixelforge.
nespyro = { path = "../../crates/nespyro" }
File diff suppressed because it is too large Load Diff
+73 -8
View File
@@ -41,10 +41,9 @@ pub unsafe extern "system" fn vkCreateInstance(
let next_create: PFN_vkCreateInstance =
unsafe { load_instance_fn(next_gipa, vk::Instance::null(), b"vkCreateInstance\0") };
// The encoder needs Vulkan 1.1 on the game's instance to run on the game's
// device. An application asking for 1.0 is raised to 1.1 where the loader
// has it: 1.1 only adds to 1.0, so nothing the application can do behaves
// any differently, and without it the encoder needs a device of its own.
// The encoders run on the game's device, so the game's instance has to be
// new enough for them: 1.1 for pixelforge, 1.3 for nespyro. See
// `raised_version`.
let asked = unsafe {
let app = (*p_create_info).p_application_info;
if app.is_null() {
@@ -57,7 +56,7 @@ pub unsafe extern "system" fn vkCreateInstance(
let mut raised_ci;
let mut create_info = p_create_info;
let mut api_version = asked;
if asked < vk::API_VERSION_1_1 && unsafe { loader_version(next_gipa) } >= vk::API_VERSION_1_1 {
if let Some(raised) = raised_version(asked, unsafe { loader_version(next_gipa) }) {
raised_app = unsafe {
let app = (*p_create_info).p_application_info;
if app.is_null() {
@@ -66,13 +65,17 @@ pub unsafe extern "system" fn vkCreateInstance(
*app
}
};
raised_app.api_version = vk::API_VERSION_1_1;
raised_app.api_version = raised;
raised_ci = unsafe { *p_create_info };
raised_ci.p_application_info = &raised_app;
create_info = &raised_ci;
api_version = vk::API_VERSION_1_1;
api_version = raised;
log::info!(
"instance asked for Vulkan 1.0; created as 1.1 so the encoder can share its devices"
"instance asked for Vulkan {}.{}; created as {}.{} so the encoders can share its devices",
vk::api_version_major(asked),
vk::api_version_minor(asked),
vk::api_version_major(raised),
vk::api_version_minor(raised),
);
}
@@ -115,6 +118,27 @@ pub unsafe extern "system" fn vkCreateInstance(
vk::Result::SUCCESS
}
/// The version to create the game's instance at instead of the one it asked
/// for, or `None` to leave it alone.
///
/// As high as 1.3, which nespyro needs, and as the loader allows. A newer
/// version only adds to an older one: a game cannot depend on behaviour it
/// never asked for, and the only side of a session that has to care about old
/// Vulkan is the client, which this layer is not. A loader below 1.1 is left
/// alone, since then neither encoder can share the device anyway. The patch is
/// dropped, as the version names a major.minor and its patch level is the
/// loader's business.
fn raised_version(asked: u32, loader: u32) -> Option<u32> {
let target = vk::API_VERSION_1_3.min(loader);
let target = vk::make_api_version(
0,
vk::api_version_major(target),
vk::api_version_minor(target),
0,
);
(target >= vk::API_VERSION_1_1 && asked < target).then_some(target)
}
/// The highest instance version the loader below supports; 1.0 when it cannot
/// say, since vkEnumerateInstanceVersion is itself a 1.1 addition.
unsafe fn loader_version(next_gipa: crate::dispatch::PFN_vkGetInstanceProcAddr) -> u32 {
@@ -145,3 +169,44 @@ pub unsafe extern "system" fn vkDestroyInstance(
unsafe { (state.destroy_instance)(instance, p_allocator) };
}
}
#[cfg(test)]
mod tests {
use super::raised_version;
use ash::vk;
const LOADER_1_4: u32 = vk::make_api_version(0, 1, 4, 321);
#[test]
fn an_old_instance_is_raised_to_1_3() {
for asked in [
0,
vk::API_VERSION_1_0,
vk::API_VERSION_1_1,
vk::API_VERSION_1_2,
] {
assert_eq!(raised_version(asked, LOADER_1_4), Some(vk::API_VERSION_1_3));
}
}
/// Raising is only ever upward: a game asking for more keeps it.
#[test]
fn a_new_enough_instance_is_left_alone() {
assert_eq!(raised_version(vk::API_VERSION_1_3, LOADER_1_4), None);
assert_eq!(raised_version(LOADER_1_4, LOADER_1_4), None);
}
#[test]
fn an_older_loader_gets_what_it_has() {
let loader = vk::make_api_version(0, 1, 2, 198);
assert_eq!(
raised_version(vk::API_VERSION_1_0, loader),
Some(vk::API_VERSION_1_2)
);
assert_eq!(raised_version(vk::API_VERSION_1_2, loader), None);
assert_eq!(
raised_version(vk::API_VERSION_1_0, vk::API_VERSION_1_0),
None
);
}
}
+17 -7
View File
@@ -326,14 +326,24 @@ fn encoder_ready(ds: &crate::state::DeviceState, ds_key: usize, width: u32, heig
// On the game's own device where it was created for that, and
// on a device of the encoder's own where it was not, or where
// adopting it fails.
let shared = ds.shared.as_ref().and_then(|s| match s.video_context() {
Ok(ctx) => Some(ctx),
Err(e) => {
log::warn!(
"could not encode on the game's device ({e}); using a device of its own"
);
None
// Each encoder the device was set up for is adopted on its own; a
// failure in one costs only that one. Only when neither is left
// does the encoder move to a device of its own, which can then
// host Vulkan Video and nothing else.
let shared = ds.shared.as_ref().and_then(|s| {
let video = s.video_context().and_then(|r| {
r.map_err(|e| log::warn!("Vulkan Video on the game's device failed ({e})"))
.ok()
});
let pyro = s.pyro_context().and_then(|r| {
r.map_err(|e| log::warn!("PyroWave on the game's device failed ({e})"))
.ok()
});
if video.is_none() && pyro.is_none() {
log::warn!("no encoder on the game's device; using a device of its own");
return None;
}
Some(crate::encode::SharedEncoders { video, pyro })
});
let on_shared = shared.is_some();
match PipelineHandle::new(cfg, shared) {
+621 -81
View File
@@ -6,6 +6,10 @@
// because two devices share no timeline. Here the game's device is created
// with what the encoder needs, and the encoder is handed that device instead.
//
// Two encoders can live there: pixelforge's Vulkan Video encoder and
// nespyro's PyroWave. Each is checked on its own, and the device gets the
// additions of whichever it can host — one, both, or, if neither, none.
//
// Three things are added to the game's vkCreateDevice, and none of them
// changes what the game gets:
//
@@ -33,9 +37,13 @@ use std::collections::BTreeMap;
/// the device's queue create infos.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct QueuePlan {
pub encode: DeviceQueue,
/// Vulkan Video encode, when pixelforge is hosted.
pub encode: Option<DeviceQueue>,
/// pixelforge's converter and all of nespyro. Both encoders submit from the
/// one encoder thread, so they share it freely.
pub compute: DeviceQueue,
pub transfer: DeviceQueue,
/// pixelforge's copies, when pixelforge is hosted.
pub transfer: Option<DeviceQueue>,
/// How many queues to create in each family, where that is more than the
/// game asked for.
pub counts: BTreeMap<u32, u32>,
@@ -71,7 +79,32 @@ fn rank(flags: vk::QueueFlags) -> u32 {
r
}
/// Pick a queue for every role.
/// Which roles a plan has to fill.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Wants {
/// An encode queue, and the family the encoder would like it from.
pub encode: Option<Option<u32>>,
/// A transfer queue.
pub transfer: bool,
}
impl Wants {
/// What pixelforge needs: encode, compute and transfer.
pub fn video(preferred_encode: Option<u32>) -> Self {
Self {
encode: Some(preferred_encode),
transfer: true,
}
}
/// What nespyro alone needs: compute, which every plan has.
pub const PYRO_ONLY: Self = Self {
encode: None,
transfer: false,
};
}
/// Pick a queue for every role `wants` names, and a compute queue always.
///
/// `families` is the device's queue families, `game` how many queues the game
/// asked for in each (and with which flags). The encoder takes at most one
@@ -83,7 +116,7 @@ fn rank(flags: vk::QueueFlags) -> u32 {
pub fn plan_queues(
families: &[vk::QueueFamilyProperties],
game: &BTreeMap<u32, (u32, vk::DeviceQueueCreateFlags)>,
preferred_encode: Option<u32>,
wants: Wants,
internally_synchronized_queues: bool,
) -> Option<QueuePlan> {
let mut counts: BTreeMap<u32, u32> = BTreeMap::new();
@@ -146,13 +179,16 @@ pub fn plan_queues(
None
};
let encode = pick(
vk::QueueFlags::VIDEO_ENCODE_KHR,
preferred_encode,
&mut counts,
&mut ours,
&mut shared,
)?;
let encode = match wants.encode {
Some(preferred) => Some(pick(
vk::QueueFlags::VIDEO_ENCODE_KHR,
preferred,
&mut counts,
&mut ours,
&mut shared,
)?),
None => None,
};
let compute = pick(
vk::QueueFlags::COMPUTE,
None,
@@ -160,13 +196,17 @@ pub fn plan_queues(
&mut ours,
&mut shared,
)?;
let transfer = pick(
vk::QueueFlags::TRANSFER,
Some(compute.family),
&mut counts,
&mut ours,
&mut shared,
)?;
let transfer = if wants.transfer {
Some(pick(
vk::QueueFlags::TRANSFER,
Some(compute.family),
&mut counts,
&mut ours,
&mut shared,
)?)
} else {
None
};
Some(QueuePlan {
encode,
@@ -191,6 +231,52 @@ pub enum Feature {
VideoEncodeIntraRefresh,
VideoEncodeQuantizationMap,
InternallySynchronizedQueues,
// nespyro's. The first two are Vulkan 1.0 core features, which live in
// `VkPhysicalDeviceFeatures` rather than in a struct of their own.
ShaderInt16,
ShaderStorageImageWriteWithoutFormat,
StorageBuffer16BitAccess,
StorageBuffer8BitAccess,
ShaderInt8,
ShaderFloat16,
BufferDeviceAddress,
SubgroupSizeControl,
ComputeFullSubgroups,
}
impl Feature {
/// Whether this is a Vulkan 1.0 feature, set in `VkPhysicalDeviceFeatures`
/// rather than in a pNext struct of its own.
fn is_core(self) -> bool {
matches!(
self,
Self::ShaderInt16 | Self::ShaderStorageImageWriteWithoutFormat
)
}
}
/// The features nespyro asks for.
pub fn pyro_features(f: &nespyro::DeviceFeatures) -> Vec<Feature> {
use Feature as F;
[
(f.shader_int16, F::ShaderInt16),
(
f.shader_storage_image_write_without_format,
F::ShaderStorageImageWriteWithoutFormat,
),
(f.storage_buffer_16bit_access, F::StorageBuffer16BitAccess),
(f.storage_buffer_8bit_access, F::StorageBuffer8BitAccess),
(f.shader_int8, F::ShaderInt8),
(f.shader_float16, F::ShaderFloat16),
(f.timeline_semaphore, F::TimelineSemaphore),
(f.buffer_device_address, F::BufferDeviceAddress),
(f.synchronization2, F::Synchronization2),
(f.subgroup_size_control, F::SubgroupSizeControl),
(f.compute_full_subgroups, F::ComputeFullSubgroups),
]
.into_iter()
.filter_map(|(on, feature)| on.then_some(feature))
.collect()
}
/// The features `f` asks for, plus the one queue sharing needs.
@@ -307,6 +393,79 @@ unsafe fn field(
vk::PhysicalDeviceInternallySynchronizedQueuesFeaturesKHR,
internally_synchronized_queues
),
(S::PHYSICAL_DEVICE_FEATURES_2, F::ShaderInt16) => Some(unsafe {
&raw mut (*(p as *mut vk::PhysicalDeviceFeatures2))
.features
.shader_int16
}),
(S::PHYSICAL_DEVICE_FEATURES_2, F::ShaderStorageImageWriteWithoutFormat) => Some(unsafe {
&raw mut (*(p as *mut vk::PhysicalDeviceFeatures2))
.features
.shader_storage_image_write_without_format
}),
(S::PHYSICAL_DEVICE_VULKAN_1_1_FEATURES, F::StorageBuffer16BitAccess) => {
at!(
vk::PhysicalDeviceVulkan11Features,
storage_buffer16_bit_access
)
}
(S::PHYSICAL_DEVICE_16BIT_STORAGE_FEATURES, F::StorageBuffer16BitAccess) => {
at!(
vk::PhysicalDevice16BitStorageFeatures,
storage_buffer16_bit_access
)
}
(S::PHYSICAL_DEVICE_VULKAN_1_2_FEATURES, F::StorageBuffer8BitAccess) => {
at!(
vk::PhysicalDeviceVulkan12Features,
storage_buffer8_bit_access
)
}
(S::PHYSICAL_DEVICE_8BIT_STORAGE_FEATURES, F::StorageBuffer8BitAccess) => {
at!(
vk::PhysicalDevice8BitStorageFeatures,
storage_buffer8_bit_access
)
}
(S::PHYSICAL_DEVICE_VULKAN_1_2_FEATURES, F::ShaderInt8) => {
at!(vk::PhysicalDeviceVulkan12Features, shader_int8)
}
(S::PHYSICAL_DEVICE_SHADER_FLOAT16_INT8_FEATURES, F::ShaderInt8) => {
at!(vk::PhysicalDeviceShaderFloat16Int8Features, shader_int8)
}
(S::PHYSICAL_DEVICE_VULKAN_1_2_FEATURES, F::ShaderFloat16) => {
at!(vk::PhysicalDeviceVulkan12Features, shader_float16)
}
(S::PHYSICAL_DEVICE_SHADER_FLOAT16_INT8_FEATURES, F::ShaderFloat16) => {
at!(vk::PhysicalDeviceShaderFloat16Int8Features, shader_float16)
}
(S::PHYSICAL_DEVICE_VULKAN_1_2_FEATURES, F::BufferDeviceAddress) => {
at!(vk::PhysicalDeviceVulkan12Features, buffer_device_address)
}
(S::PHYSICAL_DEVICE_BUFFER_DEVICE_ADDRESS_FEATURES, F::BufferDeviceAddress) => {
at!(
vk::PhysicalDeviceBufferDeviceAddressFeatures,
buffer_device_address
)
}
(S::PHYSICAL_DEVICE_VULKAN_1_3_FEATURES, F::SubgroupSizeControl) => {
at!(vk::PhysicalDeviceVulkan13Features, subgroup_size_control)
}
(S::PHYSICAL_DEVICE_SUBGROUP_SIZE_CONTROL_FEATURES, F::SubgroupSizeControl) => {
at!(
vk::PhysicalDeviceSubgroupSizeControlFeatures,
subgroup_size_control
)
}
(S::PHYSICAL_DEVICE_VULKAN_1_3_FEATURES, F::ComputeFullSubgroups) => {
at!(vk::PhysicalDeviceVulkan13Features, compute_full_subgroups)
}
(S::PHYSICAL_DEVICE_SUBGROUP_SIZE_CONTROL_FEATURES, F::ComputeFullSubgroups) => {
at!(
vk::PhysicalDeviceSubgroupSizeControlFeatures,
compute_full_subgroups
)
}
_ => None,
}
}
@@ -338,6 +497,11 @@ enum OwnedFeature {
IntraRefresh(vk::PhysicalDeviceVideoEncodeIntraRefreshFeaturesKHR<'static>),
QpMap(vk::PhysicalDeviceVideoEncodeQuantizationMapFeaturesKHR<'static>),
SharedQueues(vk::PhysicalDeviceInternallySynchronizedQueuesFeaturesKHR<'static>),
Storage16(vk::PhysicalDevice16BitStorageFeatures<'static>),
Storage8(vk::PhysicalDevice8BitStorageFeatures<'static>),
Float16Int8(vk::PhysicalDeviceShaderFloat16Int8Features<'static>),
Bda(vk::PhysicalDeviceBufferDeviceAddressFeatures<'static>),
SubgroupSize(vk::PhysicalDeviceSubgroupSizeControlFeatures<'static>),
}
impl OwnedFeature {
@@ -377,6 +541,33 @@ impl OwnedFeature {
vk::PhysicalDeviceInternallySynchronizedQueuesFeaturesKHR::default()
.internally_synchronized_queues(true),
),
F::StorageBuffer16BitAccess => Self::Storage16(
vk::PhysicalDevice16BitStorageFeatures::default().storage_buffer16_bit_access(true),
),
F::StorageBuffer8BitAccess => Self::Storage8(
vk::PhysicalDevice8BitStorageFeatures::default().storage_buffer8_bit_access(true),
),
F::ShaderInt8 => Self::Float16Int8(
vk::PhysicalDeviceShaderFloat16Int8Features::default().shader_int8(true),
),
F::ShaderFloat16 => Self::Float16Int8(
vk::PhysicalDeviceShaderFloat16Int8Features::default().shader_float16(true),
),
F::BufferDeviceAddress => Self::Bda(
vk::PhysicalDeviceBufferDeviceAddressFeatures::default()
.buffer_device_address(true),
),
F::SubgroupSizeControl => Self::SubgroupSize(
vk::PhysicalDeviceSubgroupSizeControlFeatures::default()
.subgroup_size_control(true),
),
F::ComputeFullSubgroups => Self::SubgroupSize(
vk::PhysicalDeviceSubgroupSizeControlFeatures::default()
.compute_full_subgroups(true),
),
F::ShaderInt16 | F::ShaderStorageImageWriteWithoutFormat => {
unreachable!("core features have no struct of their own")
}
}
}
@@ -391,6 +582,11 @@ impl OwnedFeature {
Self::IntraRefresh(s) => std::ptr::from_mut(s).cast(),
Self::QpMap(s) => std::ptr::from_mut(s).cast(),
Self::SharedQueues(s) => std::ptr::from_mut(s).cast(),
Self::Storage16(s) => std::ptr::from_mut(s).cast(),
Self::Storage8(s) => std::ptr::from_mut(s).cast(),
Self::Float16Int8(s) => std::ptr::from_mut(s).cast(),
Self::Bda(s) => std::ptr::from_mut(s).cast(),
Self::SubgroupSize(s) => std::ptr::from_mut(s).cast(),
};
p.cast()
}
@@ -398,8 +594,13 @@ impl OwnedFeature {
impl FeaturePatch {
/// Turn on every feature in `wanted` for a device whose create info has
/// `chain` as its pNext. Returns the patch and the pNext the create info
/// should carry instead.
/// `chain` as its pNext. Returns the patch, the pNext the create info
/// should carry instead, and the Vulkan 1.0 features the chain had no
/// `VkPhysicalDeviceFeatures2` to hold — those belong in
/// `pEnabledFeatures`, see [`core_features`].
///
/// Structs of ours are searched like the game's, so two features sharing
/// one struct type land in one struct: a chain may hold each type once.
///
/// # Safety
///
@@ -408,16 +609,18 @@ impl FeaturePatch {
pub unsafe fn apply(
chain: *const std::ffi::c_void,
wanted: &[Feature],
) -> (Self, *const std::ffi::c_void) {
) -> (Self, *const std::ffi::c_void, Vec<Feature>) {
let mut patch = Self {
restores: Vec::new(),
owned: Vec::new(),
};
let mut missing: Vec<Feature> = Vec::new();
let mut core_missing: Vec<Feature> = Vec::new();
// Ours sit in front of the game's chain, in the order they were added.
let mut head = chain;
for &feature in wanted {
let mut found = false;
let mut p = chain as *mut vk::BaseOutStructure<'_>;
let mut p = head as *mut vk::BaseOutStructure<'_>;
while !p.is_null() {
let s_type = unsafe { (*p).s_type };
if let Some(bit) = unsafe { field(s_type, p, feature) } {
@@ -427,20 +630,23 @@ impl FeaturePatch {
}
p = unsafe { (*p).p_next };
}
if !found {
missing.push(feature);
if found {
continue;
}
if feature.is_core() {
core_missing.push(feature);
continue;
}
}
let mut head = chain;
for feature in missing.into_iter().rev() {
let mut owned = Box::new(OwnedFeature::new(feature));
let base = owned.base();
unsafe { (*base).p_next = head as *mut _ };
head = base as *const _;
unsafe { (*base).p_next = chain as *mut _ };
match patch.owned.last_mut() {
Some(last) => unsafe { (*last.base()).p_next = base },
None => head = base as *const _,
}
patch.owned.push(owned);
}
(patch, head)
(patch, head, core_missing)
}
/// Put the application's chain back as it was.
@@ -455,6 +661,28 @@ impl FeaturePatch {
}
}
/// `pEnabledFeatures` with `missing` turned on: the game's own, copied, or a
/// fresh set when it gave none.
///
/// Only used when the chain holds no `VkPhysicalDeviceFeatures2`, since the
/// two may not be given together, and with one the bits went there instead.
pub fn core_features(
game: Option<&vk::PhysicalDeviceFeatures>,
missing: &[Feature],
) -> vk::PhysicalDeviceFeatures {
let mut out = game.copied().unwrap_or_default();
for feature in missing {
match feature {
Feature::ShaderInt16 => out.shader_int16 = vk::TRUE,
Feature::ShaderStorageImageWriteWithoutFormat => {
out.shader_storage_image_write_without_format = vk::TRUE
}
other => debug_assert!(!other.is_core(), "{other:?} has no place here"),
}
}
out
}
// ── Extensions ────────────────────────────────────────────────────────────────
/// `game` with every name in `add` it does not already have, as pointers
@@ -475,28 +703,51 @@ pub fn merged_extensions(
out
}
/// Everything the device needs beyond what the game asked for: the pixelforge
/// requirements, and the queue plan that satisfies them.
/// Everything the device needs beyond what the game asked for: each hosted
/// encoder's requirements, and the queue plan that satisfies them.
pub struct Additions {
pub requirements: DeviceRequirements,
/// pixelforge's, when the device can encode Vulkan Video.
pub video: Option<DeviceRequirements>,
/// nespyro's, when the device can run PyroWave.
pub pyro: Option<nespyro::DeviceRequirements>,
pub queues: QueuePlan,
}
impl Additions {
/// The extensions to add, including the one queue sharing needs.
/// The extensions to add, including the one queue sharing needs. Each
/// named once, however many encoders want it.
pub fn extensions(&self) -> Vec<&'static std::ffi::CStr> {
let mut names = self.requirements.extensions.clone();
let mut names: Vec<&'static std::ffi::CStr> = Vec::new();
let mut add = |name: &'static std::ffi::CStr| {
if !names.contains(&name) {
names.push(name);
}
};
for name in self.video.iter().flat_map(|r| r.extensions.iter()) {
add(name);
}
for name in self.pyro.iter().flat_map(|r| r.extensions.iter()) {
add(name);
}
if !self.queues.internally_synchronized.is_empty() {
names.push(ash::khr::internally_synchronized_queues::NAME);
add(ash::khr::internally_synchronized_queues::NAME);
}
names
}
/// Each named once, however many encoders want it.
pub fn features(&self) -> Vec<Feature> {
wanted_features(
&self.requirements.features,
!self.queues.internally_synchronized.is_empty(),
)
let share = !self.queues.internally_synchronized.is_empty();
let mut out = match &self.video {
Some(r) => wanted_features(&r.features, share),
None => wanted_features(&DeviceFeatures::default(), share),
};
for f in self.pyro.iter().flat_map(|r| pyro_features(&r.features)) {
if !out.contains(&f) {
out.push(f);
}
}
out
}
}
@@ -630,6 +881,8 @@ pub struct PreparedDevice {
extensions: Vec<*const std::ffi::c_char>,
patch: FeaturePatch,
p_next: *const std::ffi::c_void,
/// The 1.0 features, when they had to go in `pEnabledFeatures`.
enabled_features: Option<Box<vk::PhysicalDeviceFeatures>>,
}
/// Work out what to add to the game's device so the encoder can run on it.
@@ -664,17 +917,44 @@ pub unsafe fn prepare(
return None;
}
let (entry, instance) = instance_view(istate, next_gdpa);
let requirements = match pixelforge::VideoContextBuilder::new().encode_device_requirements(
let video = match pixelforge::VideoContextBuilder::new().encode_device_requirements(
&entry,
&instance,
physical_device,
) {
Ok(r) => r,
Ok(r) => Some(r),
Err(e) => {
log::info!("the game's device cannot host the encoder ({e})");
return None;
log::info!("the game's device cannot host Vulkan Video encode ({e})");
None
}
};
// nespyro uses Vulkan 1.3 commands, which a device only has when its
// instance asked for 1.3 as well as the GPU having it.
let pyro = if istate.api_version < vk::API_VERSION_1_3 {
log::info!("instance is below Vulkan 1.3; no PyroWave on the game's device");
None
} else {
match nespyro::DeviceRequirements::query(&instance, physical_device, nespyro::Roles::ENCODE)
{
Ok(r) => Some(r),
Err(e) => {
log::info!("the game's device cannot host PyroWave ({e})");
None
}
}
};
if video.is_none() && pyro.is_none() {
log::info!("neither encoder fits the game's device; encoding on a device of its own");
return None;
}
let internally_synchronized_queues = match &video {
Some(r) => r.internally_synchronized_queues,
None => supports_internally_synchronized_queues(&instance, physical_device),
};
let wants = match &video {
Some(r) => Wants::video(r.queues.encode),
None => Wants::PYRO_ONLY,
};
let families = unsafe { instance.get_physical_device_queue_family_properties(physical_device) };
let game_infos: &[vk::DeviceQueueCreateInfo<'_>] = if ci.queue_create_info_count == 0
@@ -691,12 +971,7 @@ pub unsafe fn prepare(
.map(|q| (q.queue_family_index, (q.queue_count, q.flags)))
.collect();
let Some(queues) = plan_queues(
&families,
&game,
requirements.queues.encode,
requirements.internally_synchronized_queues,
) else {
let Some(queues) = plan_queues(&families, &game, wants, internally_synchronized_queues) else {
log::info!(
"no queue for the encoder that the game does not submit to; \
encoding on a device of its own"
@@ -704,7 +979,12 @@ pub unsafe fn prepare(
return None;
};
log::info!(
"encoder on the game's device: encode queue {:?}, compute queue {:?}, transfer queue {:?}{}",
"encoders on the game's device ({}): encode queue {:?}, compute queue {:?}, transfer queue {:?}{}",
match (video.is_some(), pyro.is_some()) {
(true, true) => "Vulkan Video and PyroWave",
(true, false) => "Vulkan Video",
_ => "PyroWave",
},
queues.encode,
queues.compute,
queues.transfer,
@@ -759,11 +1039,17 @@ pub unsafe fn prepare(
}
let additions = Additions {
requirements,
video,
pyro,
queues,
};
let extensions = merged_extensions(extensions, &additions.extensions());
let (patch, p_next) = unsafe { FeaturePatch::apply(ci.p_next, &additions.features()) };
let (patch, p_next, core_missing) =
unsafe { FeaturePatch::apply(ci.p_next, &additions.features()) };
let enabled_features = (!core_missing.is_empty()).then(|| {
let game = unsafe { ci.p_enabled_features.as_ref() };
Box::new(core_features(game, &core_missing))
});
Some(PreparedDevice {
additions,
@@ -774,9 +1060,33 @@ pub unsafe fn prepare(
extensions,
patch,
p_next,
enabled_features,
})
}
/// Whether `physical_device` supports `VK_KHR_internally_synchronized_queues`,
/// extension and feature both. pixelforge answers this when it is hosted; this
/// is the same question for a device hosting only nespyro.
fn supports_internally_synchronized_queues(
instance: &ash::Instance,
physical_device: vk::PhysicalDevice,
) -> bool {
let Ok(exts) = (unsafe { instance.enumerate_device_extension_properties(physical_device) })
else {
return false;
};
if !exts.iter().any(|e| {
e.extension_name_as_c_str()
.is_ok_and(|n| n == ash::khr::internally_synchronized_queues::NAME)
}) {
return false;
}
let mut features = vk::PhysicalDeviceInternallySynchronizedQueuesFeaturesKHR::default();
let mut query = vk::PhysicalDeviceFeatures2::default().push(&mut features);
unsafe { instance.get_physical_device_features2(physical_device, &mut query) };
features.internally_synchronized_queues != 0
}
impl PreparedDevice {
/// `ci` with the additions in. Valid while `self` is.
pub fn create_info<'a>(&'a self, ci: &vk::DeviceCreateInfo<'a>) -> vk::DeviceCreateInfo<'a> {
@@ -786,6 +1096,9 @@ impl PreparedDevice {
out.p_queue_create_infos = self.queue_infos.as_ptr().cast();
out.enabled_extension_count = self.extensions.len() as u32;
out.pp_enabled_extension_names = self.extensions.as_ptr();
if let Some(features) = &self.enabled_features {
out.p_enabled_features = &raw const **features;
}
out
}
@@ -811,6 +1124,9 @@ pub struct SharedDevice {
/// created below Vulkan 1.2 still has: the encoder enabled the extension.
timeline: ash::khr::timeline_semaphore::Device,
pub queues: QueuePlan,
/// Which encoders the device was created for.
video: bool,
pyro: bool,
}
impl SharedDevice {
@@ -860,6 +1176,8 @@ impl SharedDevice {
device,
timeline,
queues: additions.queues,
video: additions.video.is_some(),
pyro: additions.pyro.is_some(),
}
}
@@ -902,26 +1220,56 @@ impl SharedDevice {
}
/// A pixelforge context on the game's device, submitting to the queues
/// the plan set aside.
pub fn video_context(&self) -> Result<pixelforge::VideoContext, pixelforge::PixelForgeError> {
pixelforge::VideoContextBuilder::new()
.app_name("nescapture")
.with_encode_queue(self.queues.encode)
.with_compute_queue(self.queues.compute)
.with_transfer_queue(self.queues.transfer)
.build_from_existing_encode(
self.entry.clone(),
/// the plan set aside. `None` when the device was not created for it.
pub fn video_context(
&self,
) -> Option<Result<pixelforge::VideoContext, pixelforge::PixelForgeError>> {
if !self.video {
return None;
}
let (Some(encode), Some(transfer)) = (self.queues.encode, self.queues.transfer) else {
return None;
};
Some(
pixelforge::VideoContextBuilder::new()
.app_name("nescapture")
.with_encode_queue(encode)
.with_compute_queue(self.queues.compute)
.with_transfer_queue(transfer)
.build_from_existing_encode(
self.entry.clone(),
self.instance.clone(),
self.physical_device,
self.device.clone(),
),
)
}
/// A nespyro context on the game's device, on the plan's compute queue.
/// `None` when the device was not created for it.
///
/// No queue lock: the encoder thread is the only submitter to that queue
/// apart from a game sharing it, and a queue shared with the game was
/// created internally synchronized.
pub fn pyro_context(&self) -> Option<Result<nespyro::Context, nespyro::Error>> {
self.pyro.then(|| {
nespyro::Context::from_existing(
self.instance.clone(),
self.physical_device,
self.device.clone(),
nespyro::DeviceQueue::new(self.queues.compute.family, self.queues.compute.index),
nespyro::QueueLock::none(),
nespyro::Roles::ENCODE,
)
})
}
/// The queue families that touch a capture image: the one the game
/// presents on, where the blit runs, and the encoder's.
pub fn image_families(&self, present_family: u32) -> Vec<u32> {
let mut out = vec![present_family];
for f in [self.queues.compute.family, self.queues.transfer.family] {
let transfer = self.queues.transfer.map(|q| q.family);
for f in std::iter::once(self.queues.compute.family).chain(transfer) {
if !out.contains(&f) {
out.push(f);
}
@@ -966,19 +1314,25 @@ mod tests {
#[test]
fn spare_queues_keep_the_encoder_off_the_games_queue() {
let plan = plan_queues(&amd(), &game(&[(0, 1)]), Some(2), false).unwrap();
assert_eq!(plan.encode, DeviceQueue::new(2, 0));
let plan = plan_queues(&amd(), &game(&[(0, 1)]), Wants::video(Some(2)), false).unwrap();
assert_eq!(plan.encode, Some(DeviceQueue::new(2, 0)));
// Compute on the compute family, not on the game's graphics queue.
assert_eq!(plan.compute, DeviceQueue::new(1, 0));
// And copies share it: one thread submits both.
assert_eq!(plan.transfer, plan.compute);
assert_eq!(plan.transfer, Some(plan.compute));
assert!(plan.internally_synchronized.is_empty());
}
#[test]
fn a_family_the_game_already_uses_gets_one_more_queue() {
// The game took two of the four compute queues for itself.
let plan = plan_queues(&amd(), &game(&[(0, 1), (1, 2)]), Some(2), false).unwrap();
let plan = plan_queues(
&amd(),
&game(&[(0, 1), (1, 2)]),
Wants::video(Some(2)),
false,
)
.unwrap();
assert_eq!(plan.compute, DeviceQueue::new(1, 2));
assert_eq!(plan.counts.get(&1), Some(&3));
}
@@ -987,11 +1341,14 @@ mod tests {
fn with_every_capable_family_full_nothing_is_planned_unless_sharing_is_safe() {
// The game took all four compute queues and its one graphics queue.
let full = game(&[(0, 1), (1, 4)]);
assert_eq!(plan_queues(&amd(), &full, Some(2), false), None);
assert_eq!(
plan_queues(&amd(), &full, Wants::video(Some(2)), false),
None
);
// Sharing picks the compute family over the graphics one, since the
// game renders on the latter.
let plan = plan_queues(&amd(), &full, Some(2), true).unwrap();
let plan = plan_queues(&amd(), &full, Wants::video(Some(2)), true).unwrap();
assert_eq!(plan.compute, DeviceQueue::new(1, 0));
assert_eq!(plan.internally_synchronized, vec![1]);
}
@@ -1009,17 +1366,17 @@ mod tests {
#[test]
fn without_a_spare_the_games_queue_is_shared_where_that_is_safe() {
let plan = plan_queues(&intel(), &game(&[(0, 1)]), Some(1), true).unwrap();
assert_eq!(plan.encode, DeviceQueue::new(1, 0));
let plan = plan_queues(&intel(), &game(&[(0, 1)]), Wants::video(Some(1)), true).unwrap();
assert_eq!(plan.encode, Some(DeviceQueue::new(1, 0)));
assert_eq!(plan.compute, DeviceQueue::new(0, 0));
assert_eq!(plan.transfer, DeviceQueue::new(0, 0));
assert_eq!(plan.transfer, Some(DeviceQueue::new(0, 0)));
assert_eq!(plan.internally_synchronized, vec![0]);
}
#[test]
fn without_a_spare_or_a_safe_share_there_is_no_plan() {
assert_eq!(
plan_queues(&intel(), &game(&[(0, 1)]), Some(1), false),
plan_queues(&intel(), &game(&[(0, 1)]), Wants::video(Some(1)), false),
None
);
}
@@ -1028,15 +1385,21 @@ mod tests {
fn a_protected_queue_is_never_shared() {
let mut g = game(&[(0, 1)]);
g.insert(0, (1, vk::DeviceQueueCreateFlags::PROTECTED));
assert_eq!(plan_queues(&intel(), &g, Some(1), true), None);
assert_eq!(plan_queues(&intel(), &g, Wants::video(Some(1)), true), None);
}
#[test]
fn a_game_that_already_encodes_keeps_its_encode_queue() {
let mut families = amd();
families[2].queue_count = 2;
let plan = plan_queues(&families, &game(&[(0, 1), (2, 1)]), Some(2), false).unwrap();
assert_eq!(plan.encode, DeviceQueue::new(2, 1));
let plan = plan_queues(
&families,
&game(&[(0, 1), (2, 1)]),
Wants::video(Some(2)),
false,
)
.unwrap();
assert_eq!(plan.encode, Some(DeviceQueue::new(2, 1)));
}
#[test]
@@ -1046,7 +1409,7 @@ mod tests {
v13.p_next = (&raw mut v12).cast();
let chain = (&raw const v13).cast();
let (patch, head) = unsafe {
let (patch, head, _) = unsafe {
FeaturePatch::apply(
chain,
&[Feature::Synchronization2, Feature::TimelineSemaphore],
@@ -1067,7 +1430,7 @@ mod tests {
fn a_feature_with_no_home_gets_a_struct_in_front_of_the_chain() {
let v12 = vk::PhysicalDeviceVulkan12Features::default();
let chain = (&raw const v12).cast();
let (patch, head) = unsafe { FeaturePatch::apply(chain, &[Feature::Synchronization2]) };
let (patch, head, _) = unsafe { FeaturePatch::apply(chain, &[Feature::Synchronization2]) };
assert_ne!(head, chain);
let first = head as *const vk::BaseInStructure<'_>;
unsafe {
@@ -1087,7 +1450,7 @@ mod tests {
Feature::TimelineSemaphore,
Feature::VideoEncodeAv1,
];
let (patch, head) = unsafe { FeaturePatch::apply(std::ptr::null(), &wanted) };
let (patch, head, _) = unsafe { FeaturePatch::apply(std::ptr::null(), &wanted) };
let mut seen = Vec::new();
let mut p = head as *const vk::BaseInStructure<'_>;
while !p.is_null() {
@@ -1118,6 +1481,183 @@ mod tests {
assert_eq!(merged.len(), 2);
}
// ── PyroWave beside, or instead of, Vulkan Video ─────────────────────────
/// A GPU with no video encode family still gets a compute queue of its
/// own for nespyro, and no encode or transfer queue it would not use.
#[test]
fn pyrowave_alone_plans_only_compute() {
let families = vec![family(GFX, 1), family(COMPUTE, 2)];
let plan = plan_queues(&families, &game(&[(0, 1)]), Wants::PYRO_ONLY, false).unwrap();
assert_eq!(plan.compute, DeviceQueue::new(1, 0));
assert_eq!(plan.encode, None);
assert_eq!(plan.transfer, None);
assert!(plan.internally_synchronized.is_empty());
}
/// Without the video family there is nothing to plan an encode queue on,
/// and asking for one must fail rather than quietly skip it.
#[test]
fn vulkan_video_without_an_encode_family_has_no_plan() {
let families = vec![family(GFX, 1), family(COMPUTE, 2)];
assert_eq!(
plan_queues(&families, &game(&[(0, 1)]), Wants::video(None), true),
None
);
}
/// Two features of one struct type must land in one struct: a pNext chain
/// may hold each type once, and int8 and float16 share
/// `VkPhysicalDeviceShaderFloat16Int8Features`.
#[test]
fn features_sharing_a_struct_share_one_of_ours() {
let wanted = [
Feature::ShaderInt8,
Feature::ShaderFloat16,
Feature::SubgroupSizeControl,
Feature::ComputeFullSubgroups,
];
let (patch, head, core) = unsafe { FeaturePatch::apply(std::ptr::null(), &wanted) };
assert!(core.is_empty());
let mut seen = Vec::new();
let mut p = head as *const vk::BaseInStructure<'_>;
while !p.is_null() {
seen.push(unsafe { (*p).s_type });
p = unsafe { (*p).p_next };
}
assert_eq!(
seen,
vec![
vk::StructureType::PHYSICAL_DEVICE_SHADER_FLOAT16_INT8_FEATURES,
vk::StructureType::PHYSICAL_DEVICE_SUBGROUP_SIZE_CONTROL_FEATURES,
]
);
let f = unsafe { &*(head as *const vk::PhysicalDeviceShaderFloat16Int8Features<'_>) };
assert_eq!((f.shader_int8, f.shader_float16), (vk::TRUE, vk::TRUE));
unsafe { patch.restore() };
}
/// Ours go in front of the game's chain, which is then still reachable
/// from the head and left as it was.
#[test]
fn our_structs_lead_into_the_games_chain() {
let v11 = vk::PhysicalDeviceVulkan11Features::default();
let chain = (&raw const v11).cast();
let (patch, head, _) = unsafe {
FeaturePatch::apply(
chain,
&[
Feature::BufferDeviceAddress,
Feature::StorageBuffer16BitAccess,
],
)
};
let first = head as *const vk::BaseInStructure<'_>;
unsafe {
assert_eq!(
(*first).s_type,
vk::StructureType::PHYSICAL_DEVICE_BUFFER_DEVICE_ADDRESS_FEATURES
);
assert_eq!((*first).p_next.cast(), chain, "the game's chain was lost");
}
// 16-bit storage had a home in the game's Vulkan 1.1 struct.
assert_eq!(v11.storage_buffer16_bit_access, vk::TRUE);
unsafe { patch.restore() };
assert_eq!(v11.storage_buffer16_bit_access, vk::FALSE);
}
/// The 1.0 features live in `VkPhysicalDeviceFeatures`. Inside a chained
/// `VkPhysicalDeviceFeatures2` they are set there and put back after.
#[test]
fn core_features_go_in_a_chained_features2() {
let mut f2 = vk::PhysicalDeviceFeatures2::default();
f2.features.shader_int16 = vk::FALSE;
let chain = (&raw const f2).cast();
let (patch, head, core) = unsafe {
FeaturePatch::apply(
chain,
&[
Feature::ShaderInt16,
Feature::ShaderStorageImageWriteWithoutFormat,
],
)
};
assert!(core.is_empty(), "Features2 was there to hold them");
assert_eq!(head, chain);
assert_eq!(f2.features.shader_int16, vk::TRUE);
assert_eq!(
f2.features.shader_storage_image_write_without_format,
vk::TRUE
);
unsafe { patch.restore() };
assert_eq!(f2.features.shader_int16, vk::FALSE);
}
/// Without one they are reported, never chained as a struct of their own:
/// a Features2 of ours beside the game's `pEnabledFeatures` is invalid.
#[test]
fn core_features_without_features2_are_handed_back() {
let (patch, head, core) = unsafe {
FeaturePatch::apply(
std::ptr::null(),
&[Feature::ShaderInt16, Feature::ShaderInt8],
)
};
assert_eq!(core, vec![Feature::ShaderInt16]);
let first = head as *const vk::BaseInStructure<'_>;
unsafe {
assert_eq!(
(*first).s_type,
vk::StructureType::PHYSICAL_DEVICE_SHADER_FLOAT16_INT8_FEATURES
);
assert!((*first).p_next.is_null());
}
unsafe { patch.restore() };
}
/// The game's own `pEnabledFeatures` is copied, never written: it is the
/// game's memory, and the copy keeps every bit it asked for.
#[test]
fn enabled_features_are_a_copy_with_ours_added() {
let game = vk::PhysicalDeviceFeatures {
geometry_shader: vk::TRUE,
..Default::default()
};
let ours = core_features(
Some(&game),
&[
Feature::ShaderInt16,
Feature::ShaderStorageImageWriteWithoutFormat,
],
);
assert_eq!(ours.geometry_shader, vk::TRUE);
assert_eq!(ours.shader_int16, vk::TRUE);
assert_eq!(ours.shader_storage_image_write_without_format, vk::TRUE);
assert_eq!(game.shader_int16, vk::FALSE);
let fresh = core_features(None, &[Feature::ShaderInt16]);
assert_eq!(fresh.shader_int16, vk::TRUE);
assert_eq!(fresh.geometry_shader, vk::FALSE);
}
#[test]
fn nespyro_features_map_to_ours() {
let f = nespyro::DeviceFeatures {
shader_int16: true,
shader_float16: true,
subgroup_size_control: true,
..Default::default()
};
assert_eq!(
pyro_features(&f),
vec![
Feature::ShaderInt16,
Feature::ShaderFloat16,
Feature::SubgroupSizeControl
]
);
}
// ── The null instance a layer below need not survive ─────────────────────
/// On construction `ash::Entry` asks for the instance-global commands with