From 5526f5553e9310979f6906d83137fb07e593affa Mon Sep 17 00:00:00 2001 From: Alexandre Vargas Lopez Date: Sat, 24 Oct 2026 23:26:38 +0200 Subject: [PATCH 1/3] Update atlas tiles in place, from pixels or imported dma-bufs A producer that tracks damage, like an embedded browser, uploads only what changed into an image it already painted instead of a whole new image per frame: `Window::update_image` overwrites a region of the image's tile, and `RenderImage::blank` with `Window::insert_blank_image` gives a frame-sized tile without uploading blank pixels. On Linux, a dma-buf another process renders into is imported into the Vulkan device once (`Window::import_dmabuf`) and its damaged regions are copied on the GPU, so frames never touch the CPU. The device opens with VK_EXT_image_drm_format_modifier when the GPU has it, so tiled layouts import too: GPUs whose EGL renders only to tiled layouts (NVIDIA) share frames as well. `Window::dmabuf_target` names the render node and the layouts the atlas imports: uncompressed ones only, since wgpu moves an imported image out of an undefined layout without acquiring it from the producer's queue, which may reinitialize compression metadata. `on_complete` runs exactly once, outside the atlas lock, after the GPU has finished all work queued before it, errors included, so a producer can recycle a buffer as soon as the GPU stops reading it. Pixel uploads land before the frame's copies and drop the copies they cover. A lost device runs the pending completions and drops the imports; producers import again. --- Cargo.lock | 1 + crates/gpui/src/assets.rs | 12 + crates/gpui/src/platform.rs | 140 ++++ crates/gpui/src/window.rs | 69 +- crates/gpui_wgpu/Cargo.toml | 4 + crates/gpui_wgpu/src/dmabuf.rs | 394 ++++++++++ crates/gpui_wgpu/src/gpui_wgpu.rs | 2 + crates/gpui_wgpu/src/wgpu_atlas.rs | 1059 +++++++++++++++++++++++++- crates/gpui_wgpu/src/wgpu_context.rs | 34 +- 9 files changed, 1671 insertions(+), 44 deletions(-) create mode 100644 crates/gpui_wgpu/src/dmabuf.rs diff --git a/Cargo.lock b/Cargo.lock index a67bf1f198..34b35842f5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2805,6 +2805,7 @@ name = "gpui_wgpu" version = "0.1.0" dependencies = [ "anyhow", + "ash", "bytemuck", "collections", "cosmic-text", diff --git a/crates/gpui/src/assets.rs b/crates/gpui/src/assets.rs index cb8b47ed01..d759edef54 100644 --- a/crates/gpui/src/assets.rs +++ b/crates/gpui/src/assets.rs @@ -68,6 +68,18 @@ impl RenderImage { } } + /// A transparent image whose pixels arrive through + /// [`crate::Window::update_image`], e.g. frames another process renders. + /// [`crate::Window::insert_blank_image`] allocates its atlas tile without + /// uploading the blank pixels where the renderer supports it. + pub fn blank(size: Size) -> Self { + let width = size.width.0.max(1) as u32; + let height = size.height.0.max(1) as u32; + // Zeroed allocations stay untouched (and unresident) unless a renderer + // without blank tiles has to upload them. + Self::new([Frame::new(image::RgbaImage::new(width, height))]) + } + /// Convert this image into a byte slice. pub fn as_bytes(&self, frame_index: usize) -> Option<&[u8]> { self.data diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 2bb855bb8a..48ef6a2bb7 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -1314,6 +1314,104 @@ impl From for AtlasKey { } } +/// New pixels for a region of a tile the atlas already holds. +pub enum AtlasTileUpdate<'a> { + /// BGRA8 rows for the region, `bytes_per_row` apart. The atlas copies them + /// before returning. + Pixels { + /// The first row of the region; later rows follow `bytes_per_row` apart. + bytes: &'a [u8], + /// Distance between the starts of two rows. + bytes_per_row: u32, + }, + /// A dma-buf imported with [`PlatformAtlas::import_dmabuf`]. The GPU copies + /// the region whose top-left pixel in the buffer is `origin`. + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + Dmabuf { + /// The imported buffer. + buffer: DmabufId, + /// Where the region starts in the buffer, in pixels. + origin: Point, + }, +} + +/// Identifies a dma-buf imported into the GPU device behind an atlas. +#[cfg(any(target_os = "linux", target_os = "freebsd"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct DmabufId(pub u64); + +/// A dma-buf imported with [`crate::Window::import_dmabuf`]. Dropping it, on +/// any thread, releases the import once no queued copy reads it anymore. +#[cfg(any(target_os = "linux", target_os = "freebsd"))] +pub struct DmabufImport { + id: DmabufId, + release: Option>, +} + +#[cfg(any(target_os = "linux", target_os = "freebsd"))] +impl DmabufImport { + /// For atlases: an import named `id` that runs `release` when dropped. + pub fn new(id: DmabufId, release: impl FnOnce() + Send + 'static) -> Self { + Self { + id, + release: Some(Box::new(release)), + } + } + + /// The buffer to name in [`AtlasTileUpdate::Dmabuf`]. + pub fn id(&self) -> DmabufId { + self.id + } +} + +#[cfg(any(target_os = "linux", target_os = "freebsd"))] +impl Drop for DmabufImport { + fn drop(&mut self) { + if let Some(release) = self.release.take() { + release(); + } + } +} + +#[cfg(any(target_os = "linux", target_os = "freebsd"))] +impl std::fmt::Debug for DmabufImport { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_tuple("DmabufImport").field(&self.id).finish() + } +} + +/// A single-plane dma-buf another process rendered into. +#[cfg(any(target_os = "linux", target_os = "freebsd"))] +#[derive(Debug)] +pub struct Dmabuf<'a> { + /// The dma-buf file descriptor. Importing duplicates it. + pub fd: std::os::fd::BorrowedFd<'a>, + /// Width in pixels. + pub width: u32, + /// Height in pixels. + pub height: u32, + /// The DRM fourcc of the pixels, e.g. `AR24`. + pub fourcc: u32, + /// The DRM format modifier, which says how the pixels are laid out: + /// linear, or one of the GPU's tiled layouts. + pub modifier: u64, + /// Byte offset of the first pixel. + pub offset: u32, + /// Distance between the starts of two rows. + pub stride: u32, +} + +/// Where a producer should render for an atlas to import its dma-bufs without +/// copies. +#[cfg(any(target_os = "linux", target_os = "freebsd"))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DmabufTarget { + /// The DRM render node of the GPU the atlas lives on. + pub render_node: std::path::PathBuf, + /// The format modifiers of the `AR24` dma-bufs the atlas can import. + pub modifiers: Vec, +} + #[expect(missing_docs)] pub trait PlatformAtlas { fn get_or_insert_with<'a>( @@ -1322,6 +1420,48 @@ pub trait PlatformAtlas { build: &mut dyn FnMut() -> Result, Cow<'a, [u8]>)>>, ) -> Result>; fn remove(&self, key: &AtlasKey); + + /// Allocates a tile for `key` without uploading anything, for content that + /// only arrives through [`Self::update_tile`]. Returns whether the tile is + /// new, its content undefined, or `None` when this atlas cannot do that. + fn insert_blank(&self, _key: &AtlasKey, _size: Size) -> Result> { + Ok(None) + } + + /// Overwrites `region`, in tile-local device pixels, of the tile cached for + /// `key`. `on_complete` runs exactly once, on any thread and never with the + /// atlas locked, errors included: once the GPU finished all work queued + /// before it, so it no longer reads this update's source nor any earlier + /// one. Updates land in the order queued, except that pixels land before + /// the frame's dma-buf copies, dropping those they cover: a producer that + /// switches from copies to pixels updates the whole tile. Returns + /// `Ok(false)` when no tile is cached for `key` or this atlas cannot update + /// tiles in place; the caller then paints a whole new image instead. + fn update_tile( + &self, + _key: &AtlasKey, + _region: Bounds, + _update: AtlasTileUpdate<'_>, + on_complete: Option>, + ) -> Result { + if let Some(complete) = on_complete { + complete(); + } + Ok(false) + } + + /// The GPU this atlas lives on and the dma-bufs it can import, for + /// producers that should render where their frames need no copies. + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + fn dmabuf_target(&self) -> Option { + None + } + + /// Imports a dma-buf for [`AtlasTileUpdate::Dmabuf`] copies. + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + fn import_dmabuf(&self, _dmabuf: &Dmabuf<'_>) -> Result { + bail!("this renderer cannot import dma-bufs") + } } #[doc(hidden)] diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 47cc363603..86b3a02680 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -3659,8 +3659,8 @@ impl Window { .min(((fade.bounds.bottom().0 - center.y.0) / fade.bottom_band()).clamp(0.0, 1.0)); } if fade.left { - ramp = ramp - .min(((center.x.0 - fade.bounds.left().0) / fade.left_band()).clamp(0.0, 1.0)); + ramp = + ramp.min(((center.x.0 - fade.bounds.left().0) / fade.left_band()).clamp(0.0, 1.0)); } if fade.right { ramp = ramp @@ -4838,6 +4838,71 @@ impl Window { Ok(()) } + /// Overwrites a region of an image already painted with + /// [`Self::paint_image`], so a producer that tracks damage uploads only what + /// changed; a [`RenderImage::blank`] image gets its tile from + /// [`Self::insert_blank_image`] instead. Returns `Ok(false)` when the image + /// has no tile (or the renderer cannot update in place); paint a complete + /// image instead. `on_complete` runs exactly once, as + /// [`PlatformAtlas::update_tile`] describes. + pub fn update_image( + &mut self, + data: &RenderImage, + frame_index: usize, + region: Bounds, + update: crate::AtlasTileUpdate<'_>, + on_complete: Option>, + ) -> Result { + let key: crate::AtlasKey = RenderImageParams { + image_id: data.id, + frame_index, + } + .into(); + self.sprite_atlas + .update_tile(&key, region, update, on_complete) + } + + /// Allocates the tile of a [`RenderImage::blank`] image without uploading + /// its blank pixels. Returns whether the tile is new: its content is + /// undefined until [`Self::update_image`] fills all of it. An atlas can drop + /// tiles (a lost GPU), so call this before each round of updates. + pub fn insert_blank_image(&mut self, data: &RenderImage, frame_index: usize) -> Result { + let key: crate::AtlasKey = RenderImageParams { + image_id: data.id, + frame_index, + } + .into(); + match self.sprite_atlas.insert_blank(&key, data.size(frame_index))? { + Some(created) => Ok(created), + // Upload the blank pixels instead. + None => { + let size = data.size(frame_index); + let bytes = data + .as_bytes(frame_index) + .ok_or_else(|| anyhow!("image has no frame {frame_index}"))?; + let mut created = false; + self.sprite_atlas.get_or_insert_with(&key, &mut || { + created = true; + Ok(Some((size, Cow::Borrowed(bytes)))) + })?; + Ok(created) + } + } + } + + /// The GPU this window renders on and the dma-bufs it can import. + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + pub fn dmabuf_target(&self) -> Option { + self.sprite_atlas.dmabuf_target() + } + + /// Imports a dma-buf for [`crate::AtlasTileUpdate::Dmabuf`] updates of this + /// window's images, until the returned import is dropped. + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + pub fn import_dmabuf(&self, dmabuf: &crate::Dmabuf<'_>) -> Result { + self.sprite_atlas.import_dmabuf(dmabuf) + } + /// Add a node to the layout tree for the current frame. Takes the `Style` of the element for which /// layout is being requested, along with the layout ids of any children. This method is called during /// calls to the [`Element::request_layout`] trait method and enables any element to participate in layout. diff --git a/crates/gpui_wgpu/Cargo.toml b/crates/gpui_wgpu/Cargo.toml index d4483691c2..c277c42bc5 100644 --- a/crates/gpui_wgpu/Cargo.toml +++ b/crates/gpui_wgpu/Cargo.toml @@ -40,6 +40,10 @@ font-kit = { git = "https://github.com/zed-industries/font-kit", rev = "94b0f281 [target.'cfg(not(target_family = "wasm"))'.dependencies] pollster.workspace = true +# dma-buf import into the Vulkan device wgpu-hal created; must match wgpu-hal's ash. +[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies] +ash = "0.38" + [target.'cfg(target_family = "wasm")'.dependencies] wasm-bindgen.workspace = true wasm-bindgen-futures = "0.4" diff --git a/crates/gpui_wgpu/src/dmabuf.rs b/crates/gpui_wgpu/src/dmabuf.rs new file mode 100644 index 0000000000..5ef761fe47 --- /dev/null +++ b/crates/gpui_wgpu/src/dmabuf.rs @@ -0,0 +1,394 @@ +//! Imports dma-bufs other processes render into as Vulkan images, linear or +//! tiled, so the atlas can copy their damaged regions into its textures on +//! the GPU. + +use anyhow::{Context as _, Result, anyhow, ensure}; +use ash::vk; +use gpui::Dmabuf; +use std::{ + io::{Seek as _, SeekFrom}, + os::fd::{AsRawFd as _, IntoRawFd as _}, + path::PathBuf, +}; +use wgpu::hal::api::Vulkan; + +/// DRM's `AR24`, the only format the atlas imports: B, G, R, A bytes. +const FORMAT: vk::Format = vk::Format::B8G8R8A8_UNORM; + +/// Lets images take a dma-buf's layout, tiled ones included; wgpu leaves it off. +const MODIFIERS: &std::ffi::CStr = ash::ext::image_drm_format_modifier::NAME; + +const IMPORT_EXTENSIONS: [&std::ffi::CStr; 3] = [ + ash::khr::external_memory_fd::NAME, + ash::ext::external_memory_dma_buf::NAME, + MODIFIERS, +]; + +/// Opens a device on `adapter` that can import dma-bufs in any layout the GPU +/// renders, not only linear ones, which some GPUs cannot render into. `None` +/// when the adapter cannot, so the caller opens a plain device instead. +pub(crate) fn request_device( + adapter: &wgpu::Adapter, + desc: &wgpu::DeviceDescriptor<'_>, +) -> Option<(wgpu::Device, wgpu::Queue)> { + // wgpu checks these when it opens the device itself, not from here: let + // that path report what the adapter lacks. + if !adapter.features().contains(desc.required_features) + || !desc.required_limits.check_limits(&adapter.limits()) + { + return None; + } + let open = { + let hal = unsafe { adapter.as_hal::() }?; + let capabilities = hal.physical_device_capabilities(); + let version = capabilities + .properties() + .api_version + .min(hal.shared_instance().instance_api_version()); + // Vulkan 1.2 holds every extension this one depends on. + if version < vk::API_VERSION_1_2 || !capabilities.supports_extension(MODIFIERS) { + return None; + } + let callback: Box = Box::new(|args| { + if !args.extensions.contains(&MODIFIERS) { + args.extensions.push(MODIFIERS); + } + }); + let open = unsafe { + hal.open_with_callback( + desc.required_features, + &desc.required_limits, + &desc.memory_hints, + Some(callback), + ) + }; + match open { + Ok(open) => open, + Err(error) => { + log::warn!("no device that imports tiled dma-bufs: {error}"); + return None; + } + } + }; + unsafe { adapter.create_device_from_hal(open, desc) } + .inspect_err(|error| log::warn!("no device that imports tiled dma-bufs: {error}")) + .ok() +} + +/// The DRM render node of the physical GPU behind `device`. +pub(crate) fn render_node(device: &wgpu::Device) -> Option { + let hal = unsafe { device.as_hal::() }?; + let instance = hal.shared_instance(); + if instance.instance_api_version() < vk::API_VERSION_1_1 { + return None; + } + let raw = instance.raw_instance(); + let physical = hal.raw_physical_device(); + let supported = unsafe { raw.enumerate_device_extension_properties(physical) } + .ok()? + .iter() + .any(|extension| { + extension.extension_name_as_c_str() == Ok(ash::ext::physical_device_drm::NAME) + }); + if !supported { + return None; + } + let mut drm = vk::PhysicalDeviceDrmPropertiesEXT::default(); + let mut properties = vk::PhysicalDeviceProperties2::default().push_next(&mut drm); + unsafe { raw.get_physical_device_properties2(physical, &mut properties) }; + if drm.has_render == vk::FALSE { + return None; + } + device_node(drm.render_major as u64, drm.render_minor as u64) +} + +fn device_node(major: u64, minor: u64) -> Option { + use std::os::unix::fs::{FileTypeExt as _, MetadataExt as _}; + // Linux dev_t layout (see major()/minor() in sys/sysmacros.h). + let split = |dev: u64| { + ( + ((dev >> 32) & 0xffff_f000) | ((dev >> 8) & 0x0000_0fff), + ((dev >> 12) & 0xffff_ff00) | (dev & 0x0000_00ff), + ) + }; + std::fs::read_dir("/dev/dri") + .ok()? + .flatten() + .map(|entry| entry.path()) + .find(|path| { + std::fs::metadata(path).is_ok_and(|metadata| { + metadata.file_type().is_char_device() && split(metadata.rdev()) == (major, minor) + }) + }) +} + +/// The format modifiers of the `AR24` dma-bufs [`import`] takes. +pub(crate) fn importable_modifiers(device: &wgpu::Device) -> Vec { + unsafe { device.as_hal::() } + .map(|hal| layouts(&hal).into_iter().map(|(modifier, _)| modifier).collect()) + .unwrap_or_default() +} + +/// The `AR24` layouts the device imports from dma-bufs, each with the largest +/// image it takes: uncompressed, single-plane ones the GPU can copy from, as +/// producers share one fd per buffer. +fn layouts(hal: &wgpu::hal::vulkan::Device) -> Vec<(u64, vk::Extent3D)> { + let extensions = hal.enabled_device_extensions(); + if !IMPORT_EXTENSIONS.iter().all(|name| extensions.contains(name)) { + return Vec::new(); + } + let instance = hal.shared_instance().raw_instance(); + let physical = hal.raw_physical_device(); + let query = |list: &mut vk::DrmFormatModifierPropertiesListEXT| { + let mut properties = vk::FormatProperties2::default().push_next(list); + unsafe { + instance.get_physical_device_format_properties2(physical, FORMAT, &mut properties) + }; + }; + let mut list = vk::DrmFormatModifierPropertiesListEXT::default(); + query(&mut list); + let mut layouts = + vec![vk::DrmFormatModifierPropertiesEXT::default(); list.drm_format_modifier_count as usize]; + let mut list = + vk::DrmFormatModifierPropertiesListEXT::default().drm_format_modifier_properties(&mut layouts); + query(&mut list); + let count = list.drm_format_modifier_count as usize; + layouts.truncate(count); + layouts + .iter() + .filter(|layout| { + layout.drm_format_modifier_plane_count == 1 + && uncompressed(layout.drm_format_modifier) + && layout + .drm_format_modifier_tiling_features + .contains(vk::FormatFeatureFlags::TRANSFER_SRC) + }) + .filter_map(|layout| { + let modifier = layout.drm_format_modifier; + Some((modifier, max_extent(instance, physical, modifier)?)) + }) + .collect() +} + +/// Whether `modifier` lays pixels out without compression. wgpu moves an +/// imported image out of an undefined layout, with no acquire from the +/// producer's queue, which lets a driver reinitialize compression metadata +/// and lose the pixels; some compressed layouts keep that metadata in their +/// only plane. Layouts of other vendors, which this cannot decode, are out. +fn uncompressed(modifier: u64) -> bool { + let code = modifier & ((1 << 56) - 1); + match modifier >> 56 { + // Linear only: the rest is `DRM_FORMAT_MOD_INVALID`. + 0 => modifier == 0, + // Intel: X, Y, Yf and 4 tiling; the others add CCS. + 0x01 => matches!(code, 1 | 2 | 3 | 9), + // AMD: without DCC. + 0x02 => code >> 13 & 1 == 0, + // NVIDIA: without framebuffer compression. + 0x03 => code >> 23 & 7 == 0, + _ => false, + } +} + +/// The largest image in `modifier`'s layout the device imports from dma-bufs. +fn max_extent( + instance: &ash::Instance, + physical: vk::PhysicalDevice, + modifier: u64, +) -> Option { + let mut layout = vk::PhysicalDeviceImageDrmFormatModifierInfoEXT::default() + .drm_format_modifier(modifier) + .sharing_mode(vk::SharingMode::EXCLUSIVE); + let mut handle = vk::PhysicalDeviceExternalImageFormatInfo::default() + .handle_type(vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT); + let info = vk::PhysicalDeviceImageFormatInfo2::default() + .format(FORMAT) + .ty(vk::ImageType::TYPE_2D) + .tiling(vk::ImageTiling::DRM_FORMAT_MODIFIER_EXT) + .usage(vk::ImageUsageFlags::TRANSFER_SRC) + .push_next(&mut layout) + .push_next(&mut handle); + let mut external = vk::ExternalImageFormatProperties::default(); + let mut properties = vk::ImageFormatProperties2::default().push_next(&mut external); + unsafe { instance.get_physical_device_image_format_properties2(physical, &info, &mut properties) } + .ok()?; + let extent = properties.image_format_properties.max_extent; + external + .external_memory_properties + .external_memory_features + .contains(vk::ExternalMemoryFeatureFlags::IMPORTABLE) + .then_some(extent) +} + +/// Imports `dmabuf`, an `AR24` buffer in one of [`importable_modifiers`], as a +/// texture the GPU can copy from. +pub(crate) fn import(device: &wgpu::Device, dmabuf: &Dmabuf<'_>) -> Result { + let available = std::fs::File::from(dmabuf.fd.try_clone_to_owned()?) + .seek(SeekFrom::End(0)) + .context("dma-buf size")?; + let size = wgpu::Extent3d { + width: dmabuf.width, + height: dmabuf.height, + depth_or_array_layers: 1, + }; + let hal_texture = { + let hal = unsafe { device.as_hal::() } + .ok_or_else(|| anyhow!("dma-buf import needs the Vulkan backend"))?; + // Creating an image in a layout or size the device does not support + // is undefined behavior, not an error. + let max = layouts(&hal) + .into_iter() + .find_map(|(modifier, max)| (modifier == dmabuf.modifier).then_some(max)) + .ok_or_else(|| { + anyhow!("the GPU cannot import dma-bufs with modifier {:#018x}", dmabuf.modifier) + })?; + ensure!( + (1..=max.width).contains(&dmabuf.width) && (1..=max.height).contains(&dmabuf.height), + "the GPU imports at most {}x{} dma-bufs, not {}x{}", + max.width, + max.height, + dmabuf.width, + dmabuf.height + ); + let raw = hal.raw_device(); + let external_fd = + ash::khr::external_memory_fd::Device::new(hal.shared_instance().raw_instance(), raw); + let plane = [vk::SubresourceLayout { + offset: dmabuf.offset.into(), + size: 0, + row_pitch: dmabuf.stride.into(), + array_pitch: 0, + depth_pitch: 0, + }]; + let mut layout = vk::ImageDrmFormatModifierExplicitCreateInfoEXT::default() + .drm_format_modifier(dmabuf.modifier) + .plane_layouts(&plane); + let mut external = vk::ExternalMemoryImageCreateInfo::default() + .handle_types(vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT); + let info = vk::ImageCreateInfo::default() + .image_type(vk::ImageType::TYPE_2D) + .format(FORMAT) + .extent(vk::Extent3D { + width: dmabuf.width, + height: dmabuf.height, + depth: 1, + }) + .mip_levels(1) + .array_layers(1) + .samples(vk::SampleCountFlags::TYPE_1) + .tiling(vk::ImageTiling::DRM_FORMAT_MODIFIER_EXT) + .usage(vk::ImageUsageFlags::TRANSFER_SRC) + .sharing_mode(vk::SharingMode::EXCLUSIVE) + .initial_layout(vk::ImageLayout::UNDEFINED) + .push_next(&mut layout) + .push_next(&mut external); + let image = unsafe { raw.create_image(&info, None) }.with_context(|| { + format!( + "no {}x{} image with modifier {:#018x}, stride {}", + dmabuf.width, dmabuf.height, dmabuf.modifier, dmabuf.stride + ) + })?; + let memory = (|| -> Result { + let requirements = unsafe { raw.get_image_memory_requirements(image) }; + ensure!( + available >= requirements.size, + "dma-buf holds {available} bytes, the image needs {}", + requirements.size + ); + let mut fd_properties = vk::MemoryFdPropertiesKHR::default(); + unsafe { + external_fd.get_memory_fd_properties( + vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT, + dmabuf.fd.as_raw_fd(), + &mut fd_properties, + ) + }?; + let memory_types = requirements.memory_type_bits & fd_properties.memory_type_bits; + ensure!(memory_types != 0, "no memory type can hold this dma-buf"); + // A successful import takes ownership of the duplicate. + let duplicate = dmabuf.fd.try_clone_to_owned()?; + let mut import = vk::ImportMemoryFdInfoKHR::default() + .handle_type(vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT) + .fd(duplicate.as_raw_fd()); + let mut dedicated = vk::MemoryDedicatedAllocateInfo::default().image(image); + let allocate = vk::MemoryAllocateInfo::default() + .allocation_size(requirements.size) + .memory_type_index(memory_types.trailing_zeros()) + .push_next(&mut import) + .push_next(&mut dedicated); + let memory = unsafe { raw.allocate_memory(&allocate, None) }?; + let _ = duplicate.into_raw_fd(); + if let Err(error) = unsafe { raw.bind_image_memory(image, memory, 0) } { + unsafe { raw.free_memory(memory, None) }; + return Err(error.into()); + } + Ok(memory) + })(); + match memory { + // wgpu destroys the image and frees its memory with the texture. + Ok(memory) => unsafe { + hal.texture_from_raw( + image, + &wgpu::hal::TextureDescriptor { + label: Some("dmabuf"), + size, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Bgra8Unorm, + usage: wgpu::TextureUses::COPY_SRC, + memory_flags: wgpu::hal::MemoryFlags::empty(), + view_formats: Vec::new(), + }, + None, + wgpu::hal::vulkan::TextureMemory::Dedicated(memory), + ) + }, + Err(error) => { + unsafe { raw.destroy_image(image, None) }; + return Err(error); + } + } + }; + // wgpu's first barrier starts from an undefined layout, which keeps the + // contents of the uncompressed layouts `layouts` lists: drivers move no + // pixels for them. + Ok(unsafe { + device.create_texture_from_hal::( + hal_texture, + &wgpu::TextureDescriptor { + label: Some("dmabuf"), + size, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Bgra8Unorm, + usage: wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }, + ) + }) +} + +#[cfg(test)] +mod tests { + use super::uncompressed; + + #[test] + fn only_uncompressed_layouts_are_imported() { + for (modifier, expected, what) in [ + (0, true, "linear"), + (0x00ff_ffff_ffff_ffff, false, "invalid"), + (0x0100_0000_0000_0009, true, "Intel 4 tiling"), + (0x0100_0000_0000_000a, false, "Intel DG2 render CCS"), + (0x0100_0000_0000_0010, false, "Intel Lunar Lake CCS"), + (0x0200_0000_0040_1b03, true, "AMD without DCC"), + (0x0200_0000_0056_bb03, false, "AMD DCC"), + (0x0300_0000_0060_6014, true, "NVIDIA block linear"), + (0x0300_0000_00e0_8014, false, "NVIDIA compressed block linear"), + (0x0800_0000_0000_0001, false, "Arm AFBC"), + ] { + assert_eq!(uncompressed(modifier), expected, "{what}: {modifier:#018x}"); + } + } +} diff --git a/crates/gpui_wgpu/src/gpui_wgpu.rs b/crates/gpui_wgpu/src/gpui_wgpu.rs index c8f634670d..18d66b0feb 100644 --- a/crates/gpui_wgpu/src/gpui_wgpu.rs +++ b/crates/gpui_wgpu/src/gpui_wgpu.rs @@ -1,5 +1,7 @@ mod blur_kernel; mod cosmic_text_system; +#[cfg(any(target_os = "linux", target_os = "freebsd"))] +mod dmabuf; mod wgpu_atlas; mod wgpu_context; mod wgpu_renderer; diff --git a/crates/gpui_wgpu/src/wgpu_atlas.rs b/crates/gpui_wgpu/src/wgpu_atlas.rs index 3cf71ac98c..574fce853b 100644 --- a/crates/gpui_wgpu/src/wgpu_atlas.rs +++ b/crates/gpui_wgpu/src/wgpu_atlas.rs @@ -1,15 +1,21 @@ -use anyhow::{Context as _, Result}; +use anyhow::{Context as _, Result, ensure}; use collections::FxHashMap; use etagere::{BucketedAtlasAllocator, size2}; use gpui::{ - AtlasKey, AtlasTextureId, AtlasTextureKind, AtlasTextureList, AtlasTile, Bounds, DevicePixels, - PlatformAtlas, Point, Size, + AtlasKey, AtlasTextureId, AtlasTextureKind, AtlasTextureList, AtlasTile, AtlasTileUpdate, + Bounds, DevicePixels, PlatformAtlas, Point, Size, }; +#[cfg(any(target_os = "linux", target_os = "freebsd"))] +use gpui::{Dmabuf, DmabufId, DmabufImport, DmabufTarget}; use parking_lot::Mutex; use std::{borrow::Cow, ops, sync::Arc}; use crate::WgpuContext; +/// DRM fourcc `AR24`: B, G, R, A bytes, the order of the color atlas. +#[cfg(any(target_os = "linux", target_os = "freebsd"))] +const DRM_FORMAT_ARGB8888: u32 = u32::from_le_bytes(*b"AR24"); + fn device_size_to_etagere(size: Size) -> etagere::Size { size2(size.width.0, size.height.0) } @@ -21,7 +27,8 @@ fn etagere_point_to_device(point: etagere::Point) -> Point { } } -pub struct WgpuAtlas(Mutex); +/// Shared so dma-buf imports can release themselves from any thread. +pub struct WgpuAtlas(Arc>); struct PendingUpload { id: AtlasTextureId, @@ -29,6 +36,15 @@ struct PendingUpload { data: Vec, } +/// A region copied on the GPU from an imported dma-buf into a texture. +#[cfg(any(target_os = "linux", target_os = "freebsd"))] +struct PendingCopy { + source: DmabufId, + origin: Point, + id: AtlasTextureId, + bounds: Bounds, +} + struct WgpuAtlasState { device: Arc, queue: Arc, @@ -37,6 +53,27 @@ struct WgpuAtlasState { storage: WgpuAtlasStorage, tiles_by_key: FxHashMap, pending_uploads: Vec, + /// Run once the GPU finished the copies queued before them. + completions: Vec>, + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + dmabufs: FxHashMap, + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + next_dmabuf: u64, + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + pending_copies: Vec, + /// Released while a copy from them was still queued. + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + released_dmabufs: Vec, +} + +impl Drop for WgpuAtlasState { + fn drop(&mut self) { + // Nothing reads for this atlas anymore: producers may reuse what its + // last updates read. + for complete in self.completions.drain(..) { + complete(); + } + } } pub struct WgpuTextureInfo { @@ -50,7 +87,7 @@ impl WgpuAtlas { color_texture_format: wgpu::TextureFormat, ) -> Self { let max_texture_size = device.limits().max_texture_dimension_2d; - WgpuAtlas(Mutex::new(WgpuAtlasState { + WgpuAtlas(Arc::new(Mutex::new(WgpuAtlasState { device, queue, max_texture_size, @@ -58,7 +95,16 @@ impl WgpuAtlas { storage: WgpuAtlasStorage::default(), tiles_by_key: Default::default(), pending_uploads: Vec::new(), - })) + completions: Vec::new(), + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + dmabufs: Default::default(), + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + next_dmabuf: 1, + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + pending_copies: Vec::new(), + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + released_dmabufs: Vec::new(), + }))) } pub fn from_context(context: &WgpuContext) -> Self { @@ -70,8 +116,29 @@ impl WgpuAtlas { } pub fn before_frame(&self) { - let mut lock = self.0.lock(); - lock.flush_uploads(); + let (device, queue, copies, completions) = { + let mut lock = self.0.lock(); + lock.flush_uploads(); + ( + lock.device.clone(), + lock.queue.clone(), + lock.take_copies(), + std::mem::take(&mut lock.completions), + ) + }; + // Submitting and polling may run completion callbacks, which may call + // back into the atlas: never hold the lock across them. + if let Some(copies) = copies { + queue.submit([copies]); + } + if !completions.is_empty() { + queue.on_submitted_work_done(move || { + for complete in completions { + complete(); + } + }); + } + let _ = device.poll(wgpu::PollType::Poll); } pub fn get_texture_info(&self, id: AtlasTextureId) -> WgpuTextureInfo { @@ -89,18 +156,40 @@ impl WgpuAtlas { lock.storage = WgpuAtlasStorage::default(); lock.tiles_by_key.clear(); lock.pending_uploads.clear(); + // Imports stay valid on the same device; their queued copies have no + // destination left. Completions still run on the next flush. + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + lock.pending_copies.clear(); } /// Handles device lost by clearing all textures and cached tiles. /// The atlas will lazily recreate textures as needed on subsequent frames. pub fn handle_device_lost(&self, context: &WgpuContext) { - let mut lock = self.0.lock(); - lock.device = context.device.clone(); - lock.queue = context.queue.clone(); - lock.color_texture_format = context.color_texture_format(); - lock.storage = WgpuAtlasStorage::default(); - lock.tiles_by_key.clear(); - lock.pending_uploads.clear(); + let (completions, lost) = { + let mut lock = self.0.lock(); + let lost = ( + std::mem::replace(&mut lock.device, context.device.clone()), + std::mem::replace(&mut lock.queue, context.queue.clone()), + ); + lock.color_texture_format = context.color_texture_format(); + lock.storage = WgpuAtlasStorage::default(); + lock.tiles_by_key.clear(); + lock.pending_uploads.clear(); + // Imports belonged to the lost device: producers must import again. + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + { + lock.pending_copies.clear(); + lock.released_dmabufs.clear(); + lock.dmabufs.clear(); + } + (std::mem::take(&mut lock.completions), lost) + }; + // The lost device reads nothing anymore. + for complete in completions { + complete(); + } + // Dropping its queue runs the callbacks still pending on it. + drop(lost); } } @@ -127,6 +216,18 @@ impl PlatformAtlas for WgpuAtlas { } } + fn insert_blank(&self, key: &AtlasKey, size: Size) -> Result> { + let mut lock = self.0.lock(); + if lock.tiles_by_key.contains_key(key) { + return Ok(Some(false)); + } + let tile = lock + .allocate(size, key.texture_kind()) + .context("failed to allocate")?; + lock.tiles_by_key.insert(key.clone(), tile); + Ok(Some(true)) + } + fn remove(&self, key: &AtlasKey) { let mut lock = self.0.lock(); @@ -134,6 +235,10 @@ impl PlatformAtlas for WgpuAtlas { return; }; let id = tile.texture_id; + // Its queued copies would land on whatever takes its place. + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + lock.pending_copies + .retain(|copy| copy.id != id || !copy.bounds.intersects(&tile.bounds)); let Some(texture_slot) = lock.storage[id.kind].textures.get_mut(id.index as usize) else { return; @@ -145,6 +250,8 @@ impl PlatformAtlas for WgpuAtlas { if texture.is_unreferenced() { lock.pending_uploads .retain(|upload| upload.id != texture.id); + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + lock.pending_copies.retain(|copy| copy.id != texture.id); lock.storage[id.kind] .free_list .push(texture.id.index as usize); @@ -153,9 +260,173 @@ impl PlatformAtlas for WgpuAtlas { } } } + + fn update_tile( + &self, + key: &AtlasKey, + region: Bounds, + update: AtlasTileUpdate<'_>, + on_complete: Option>, + ) -> Result { + let mut lock = self.0.lock(); + let queued = lock.queue_update(key, region, update); + // Even an update that reads nothing later waits: an earlier copy may + // still read a buffer the caller recycles once this completes. + lock.completions.extend(on_complete); + queued + } + + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + fn dmabuf_target(&self) -> Option { + let (device, format) = { + let lock = self.0.lock(); + (lock.device.clone(), lock.color_texture_format) + }; + Some(DmabufTarget { + render_node: crate::dmabuf::render_node(&device)?, + // Copies cannot reorder channels into an RGBA atlas. + modifiers: if format == wgpu::TextureFormat::Bgra8Unorm { + crate::dmabuf::importable_modifiers(&device) + } else { + Vec::new() + }, + }) + } + + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + fn import_dmabuf(&self, dmabuf: &Dmabuf<'_>) -> Result { + let (device, format) = { + let lock = self.0.lock(); + (lock.device.clone(), lock.color_texture_format) + }; + ensure!( + format == wgpu::TextureFormat::Bgra8Unorm, + "the color atlas is {format:?}, not BGRA" + ); + ensure!( + dmabuf.fourcc == DRM_FORMAT_ARGB8888, + "unsupported dma-buf format {:?}", + dmabuf.fourcc.to_le_bytes() + ); + let texture = crate::dmabuf::import(&device, dmabuf)?; + let mut lock = self.0.lock(); + let id = DmabufId(lock.next_dmabuf); + lock.next_dmabuf += 1; + lock.dmabufs.insert(id, texture); + let state = Arc::downgrade(&self.0); + Ok(DmabufImport::new(id, move || { + if let Some(state) = state.upgrade() { + state.lock().release_dmabuf(id); + } + })) + } } impl WgpuAtlasState { + /// Drops an imported dma-buf once no pending copy reads it. + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + fn release_dmabuf(&mut self, id: DmabufId) { + if self.pending_copies.iter().any(|copy| copy.source == id) { + self.released_dmabufs.push(id); + } else { + // wgpu keeps it alive until submitted copies finish. + self.dmabufs.remove(&id); + } + } + + /// Queues `update`; `false` when `key` has no tile or this format cannot + /// take it. + fn queue_update( + &mut self, + key: &AtlasKey, + region: Bounds, + update: AtlasTileUpdate<'_>, + ) -> Result { + let Some(tile) = self.tiles_by_key.get(key).copied() else { + return Ok(false); + }; + let Some(format) = self.storage.get(tile.texture_id).map(|texture| texture.format) else { + return Ok(false); + }; + let tile_size = tile.bounds.size; + ensure!( + region.origin.x.0 >= 0 + && region.origin.y.0 >= 0 + && region.size.width.0 > 0 + && region.size.height.0 > 0 + && region.origin.x.0 + region.size.width.0 <= tile_size.width.0 + && region.origin.y.0 + region.size.height.0 <= tile_size.height.0, + "region {region:?} lies outside the {tile_size:?} tile" + ); + let bounds = Bounds { + origin: tile.bounds.origin + region.origin, + size: region.size, + }; + match update { + AtlasTileUpdate::Pixels { + bytes, + bytes_per_row, + } => { + let row = region.size.width.0 as usize * 4; + let rows = region.size.height.0 as usize; + let stride = bytes_per_row as usize; + ensure!( + stride >= row && bytes.len() >= stride * (rows - 1) + row, + "{} bytes cannot hold {rows} rows of {row} bytes, {stride} apart", + bytes.len() + ); + let mut data = Vec::with_capacity(row * rows); + for line in bytes.chunks(stride).take(rows) { + data.extend_from_slice(&line[..row]); + } + if format == wgpu::TextureFormat::Rgba8Unorm { + for pixel in data.chunks_exact_mut(4) { + pixel.swap(0, 2); + } + } + // Uploads land before this frame's copies: a copy queued + // earlier would overwrite these newer pixels. + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + self.pending_copies.retain(|copy| { + copy.id != tile.texture_id || bounds.intersect(©.bounds) != copy.bounds + }); + self.pending_uploads.push(PendingUpload { + id: tile.texture_id, + bounds, + data, + }); + Ok(true) + } + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + AtlasTileUpdate::Dmabuf { buffer, origin } => { + // A GPU copy cannot reorder channels. + if format != wgpu::TextureFormat::Bgra8Unorm { + return Ok(false); + } + let Some(source) = self.dmabufs.get(&buffer) else { + anyhow::bail!("dma-buf {buffer:?} is not imported"); + }; + ensure!( + origin.x.0 >= 0 + && origin.y.0 >= 0 + && (origin.x.0 + region.size.width.0) as u32 <= source.width() + && (origin.y.0 + region.size.height.0) as u32 <= source.height(), + "{:?} at {origin:?} lies outside the {}x{} dma-buf", + region.size, + source.width(), + source.height() + ); + self.pending_copies.push(PendingCopy { + source: buffer, + origin, + id: tile.texture_id, + bounds, + }); + Ok(true) + } + } + } + fn allocate( &mut self, size: Size, @@ -209,7 +480,14 @@ impl WgpuAtlasState { sample_count: 1, dimension: wgpu::TextureDimension::D2, format, - usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + usage: wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::COPY_DST + // Tests read tiles back. + | if cfg!(test) { + wgpu::TextureUsages::COPY_SRC + } else { + wgpu::TextureUsages::empty() + }, view_formats: &[], }); @@ -290,6 +568,65 @@ impl WgpuAtlasState { ); } } + + /// Encodes queued dma-buf copies. Submitted before the frame's draw, they + /// run after this frame's texture writes. + #[cfg(not(any(target_os = "linux", target_os = "freebsd")))] + fn take_copies(&mut self) -> Option { + None + } + + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + fn take_copies(&mut self) -> Option { + let mut commands = None; + if !self.pending_copies.is_empty() { + let mut encoder = self + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("dmabuf_copies"), + }); + for copy in self.pending_copies.drain(..) { + let (Some(texture), Some(source)) = + (self.storage.get(copy.id), self.dmabufs.get(©.source)) + else { + continue; + }; + encoder.copy_texture_to_texture( + wgpu::TexelCopyTextureInfo { + texture: source, + mip_level: 0, + origin: wgpu::Origin3d { + x: copy.origin.x.0 as u32, + y: copy.origin.y.0 as u32, + z: 0, + }, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyTextureInfo { + texture: &texture.texture, + mip_level: 0, + origin: wgpu::Origin3d { + x: copy.bounds.origin.x.0 as u32, + y: copy.bounds.origin.y.0 as u32, + z: 0, + }, + aspect: wgpu::TextureAspect::All, + }, + wgpu::Extent3d { + width: copy.bounds.size.width.0 as u32, + height: copy.bounds.size.height.0 as u32, + depth_or_array_layers: 1, + }, + ); + } + commands = Some(encoder.finish()); + } + // wgpu keeps released textures alive until the copies above finish. + for id in self.released_dmabufs.drain(..) { + self.dmabufs.remove(&id); + } + commands + } } #[derive(Default)] @@ -422,19 +759,28 @@ mod tests { }) .await .map_err(|error| anyhow::anyhow!("failed to request adapter: {error}"))?; - let (device, queue) = adapter - .request_device(&wgpu::DeviceDescriptor { - label: Some("wgpu_atlas_test_device"), - required_features: wgpu::Features::empty(), - required_limits: wgpu::Limits::downlevel_defaults() - .using_resolution(adapter.limits()) - .using_alignment(adapter.limits()), - memory_hints: wgpu::MemoryHints::MemoryUsage, - trace: wgpu::Trace::Off, - experimental_features: wgpu::ExperimentalFeatures::disabled(), - }) - .await - .map_err(|error| anyhow::anyhow!("failed to request device: {error}"))?; + let descriptor = wgpu::DeviceDescriptor { + label: Some("wgpu_atlas_test_device"), + required_features: wgpu::Features::empty(), + required_limits: wgpu::Limits::downlevel_defaults() + .using_resolution(adapter.limits()) + .using_alignment(adapter.limits()), + memory_hints: wgpu::MemoryHints::MemoryUsage, + trace: wgpu::Trace::Off, + experimental_features: wgpu::ExperimentalFeatures::disabled(), + }; + // The device GPUI opens, where it can. + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + let opened = crate::dmabuf::request_device(&adapter, &descriptor); + #[cfg(not(any(target_os = "linux", target_os = "freebsd")))] + let opened = None; + let (device, queue) = match opened { + Some(opened) => opened, + None => adapter + .request_device(&descriptor) + .await + .map_err(|error| anyhow::anyhow!("failed to request device: {error}"))?, + }; Ok((Arc::new(device), Arc::new(queue))) }) } @@ -507,6 +853,661 @@ mod tests { Ok(()) } + fn image_key(id: usize) -> AtlasKey { + AtlasKey::Image(RenderImageParams { + image_id: ImageId(id), + frame_index: 0, + }) + } + + fn size(width: i32, height: i32) -> Size { + Size { + width: DevicePixels(width), + height: DevicePixels(height), + } + } + + fn region(x: i32, y: i32, width: i32, height: i32) -> Bounds { + Bounds { + origin: Point { + x: DevicePixels(x), + y: DevicePixels(y), + }, + size: size(width, height), + } + } + + /// Flushes pending work and returns the tile's pixels, tightly packed. + fn read_tile(atlas: &WgpuAtlas, tile: AtlasTile) -> Vec { + atlas.before_frame(); + let lock = atlas.0.lock(); + let (width, height) = ( + tile.bounds.size.width.0 as u32, + tile.bounds.size.height.0 as u32, + ); + let padded = (width * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT); + let readback = lock.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("tile_readback"), + size: (padded * height) as u64, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + let mut encoder = lock + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor::default()); + encoder.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: &lock.storage[tile.texture_id].texture, + mip_level: 0, + origin: wgpu::Origin3d { + x: tile.bounds.origin.x.0 as u32, + y: tile.bounds.origin.y.0 as u32, + z: 0, + }, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &readback, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded), + rows_per_image: None, + }, + }, + wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + ); + lock.queue.submit([encoder.finish()]); + let slice = readback.slice(..); + slice.map_async(wgpu::MapMode::Read, |_| {}); + lock.device + .poll(wgpu::PollType::wait_indefinitely()) + .expect("readback"); + let data = slice.get_mapped_range(); + data.chunks(padded as usize) + .flat_map(|row| row[..width as usize * 4].to_vec()) + .collect() + } + + fn pixel(pixels: &[u8], width: u32, x: u32, y: u32) -> [u8; 4] { + let at = ((y * width + x) * 4) as usize; + pixels[at..at + 4].try_into().unwrap() + } + + /// A tile away from its texture's corner, so offsets within the atlas + /// matter. + fn offset_tile(atlas: &WgpuAtlas, key: &AtlasKey, size: Size, bytes: Vec) -> AtlasTile { + let corner = image_key(usize::MAX); + atlas + .get_or_insert_with(&corner, &mut || Ok(Some((self::size(3, 3), Cow::Owned(vec![0; 36]))))) + .unwrap(); + let tile = atlas + .get_or_insert_with(key, &mut || Ok(Some((size, Cow::Owned(bytes.clone()))))) + .unwrap() + .expect("tile"); + assert_ne!(tile.bounds.origin, Point::default(), "the tile sits at its texture's corner"); + tile + } + + #[test] + fn update_tile_overwrites_only_the_region() -> anyhow::Result<()> { + let (device, queue) = test_device_and_queue()?; + let atlas = WgpuAtlas::new(device, queue, wgpu::TextureFormat::Bgra8Unorm); + let key = image_key(7); + let tile = offset_tile(&atlas, &key, size(8, 6), [10, 20, 30, 255].repeat(48)); + // A 3x2 region at (4, 3), read from rows 16 bytes apart. + let mut source = vec![0u8; 16 * 2]; + for row in source.chunks_mut(16) { + row[..12].copy_from_slice(&[1, 2, 3, 255].repeat(3)); + } + let completed = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let flag = completed.clone(); + assert!(atlas.update_tile( + &key, + region(4, 3, 3, 2), + AtlasTileUpdate::Pixels { + bytes: &source, + bytes_per_row: 16, + }, + Some(Box::new(move || { + flag.store(true, std::sync::atomic::Ordering::SeqCst) + })), + )?); + // Only once the GPU finished what was queued before. + assert!(!completed.load(std::sync::atomic::Ordering::SeqCst)); + let pixels = read_tile(&atlas, tile); + assert!(completed.load(std::sync::atomic::Ordering::SeqCst)); + for y in 0..6 { + for x in 0..8 { + let inside = (4..7).contains(&x) && (3..5).contains(&y); + let expected = if inside { + [1, 2, 3, 255] + } else { + [10, 20, 30, 255] + }; + assert_eq!(pixel(&pixels, 8, x, y), expected, "pixel ({x}, {y})"); + } + } + Ok(()) + } + + #[test] + fn update_tile_needs_a_cached_tile_inside_its_bounds() -> anyhow::Result<()> { + let (device, queue) = test_device_and_queue()?; + let atlas = WgpuAtlas::new(device, queue, wgpu::TextureFormat::Bgra8Unorm); + let key = image_key(8); + let pixels = [0u8; 16]; + let update = || AtlasTileUpdate::Pixels { + bytes: &pixels, + bytes_per_row: 8, + }; + assert!(!atlas.update_tile(&key, region(0, 0, 2, 2), update(), None)?); + atlas.get_or_insert_with(&key, &mut || { + Ok(Some((size(4, 4), Cow::Owned(vec![0; 64])))) + })?; + assert!( + atlas + .update_tile(&key, region(3, 3, 2, 2), update(), None) + .is_err() + ); + assert!( + atlas + .update_tile(&key, region(0, 0, 2, 3), update(), None) + .is_err(), + "16 bytes cannot hold three 8-byte rows" + ); + atlas.remove(&key); + assert!(!atlas.update_tile(&key, region(0, 0, 2, 2), update(), None)?); + Ok(()) + } + + /// Renders into dma-bufs through GBM and GL, as a browser engine does. + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + mod producer { + use anyhow::{Result, ensure}; + use std::{ + ffi::{CStr, c_void}, + os::fd::{AsRawFd as _, FromRawFd as _, OwnedFd}, + ptr::null_mut, + }; + + const GBM_BO_USE_RENDERING: u32 = 1 << 2; + const EGL_PLATFORM_GBM_KHR: u32 = 0x31D7; + const EGL_OPENGL_ES_API: u32 = 0x30A0; + const EGL_CONTEXT_CLIENT_VERSION: i32 = 0x3098; + const EGL_NONE: i32 = 0x3038; + const EGL_LINUX_DMA_BUF_EXT: u32 = 0x3270; + const EGL_WIDTH: i32 = 0x3057; + const EGL_HEIGHT: i32 = 0x3056; + const EGL_LINUX_DRM_FOURCC_EXT: i32 = 0x3271; + const EGL_DMA_BUF_PLANE0_FD_EXT: i32 = 0x3272; + const EGL_DMA_BUF_PLANE0_OFFSET_EXT: i32 = 0x3273; + const EGL_DMA_BUF_PLANE0_PITCH_EXT: i32 = 0x3274; + const EGL_DMA_BUF_PLANE0_MODIFIER_LO_EXT: i32 = 0x3443; + const EGL_DMA_BUF_PLANE0_MODIFIER_HI_EXT: i32 = 0x3444; + const GL_FRAMEBUFFER: u32 = 0x8D40; + const GL_RENDERBUFFER: u32 = 0x8D41; + const GL_COLOR_ATTACHMENT0: u32 = 0x8CE0; + const GL_FRAMEBUFFER_COMPLETE: u32 = 0x8CD5; + const GL_SCISSOR_TEST: u32 = 0x0C11; + const GL_COLOR_BUFFER_BIT: u32 = 0x4000; + + #[link(name = "gbm")] + unsafe extern "C" { + fn gbm_create_device(fd: i32) -> *mut c_void; + fn gbm_device_destroy(device: *mut c_void); + fn gbm_bo_create_with_modifiers2( + device: *mut c_void, + width: u32, + height: u32, + format: u32, + modifiers: *const u64, + count: u32, + flags: u32, + ) -> *mut c_void; + fn gbm_bo_destroy(bo: *mut c_void); + fn gbm_bo_get_fd(bo: *mut c_void) -> i32; + fn gbm_bo_get_stride(bo: *mut c_void) -> u32; + fn gbm_bo_get_offset(bo: *mut c_void, plane: i32) -> u32; + fn gbm_bo_get_modifier(bo: *mut c_void) -> u64; + fn gbm_bo_get_plane_count(bo: *mut c_void) -> i32; + } + + #[link(name = "EGL")] + unsafe extern "C" { + fn eglGetPlatformDisplay( + platform: u32, + native: *mut c_void, + attributes: *const isize, + ) -> *mut c_void; + fn eglInitialize(display: *mut c_void, major: *mut i32, minor: *mut i32) -> u32; + fn eglTerminate(display: *mut c_void) -> u32; + fn eglBindAPI(api: u32) -> u32; + fn eglCreateContext( + display: *mut c_void, + config: *mut c_void, + share: *mut c_void, + attributes: *const i32, + ) -> *mut c_void; + fn eglDestroyContext(display: *mut c_void, context: *mut c_void) -> u32; + fn eglMakeCurrent( + display: *mut c_void, + draw: *mut c_void, + read: *mut c_void, + context: *mut c_void, + ) -> u32; + fn eglGetProcAddress(name: *const i8) -> *mut c_void; + } + + #[link(name = "GLESv2")] + unsafe extern "C" { + fn glGenRenderbuffers(n: i32, names: *mut u32); + fn glDeleteRenderbuffers(n: i32, names: *const u32); + fn glBindRenderbuffer(target: u32, name: u32); + fn glGenFramebuffers(n: i32, names: *mut u32); + fn glDeleteFramebuffers(n: i32, names: *const u32); + fn glBindFramebuffer(target: u32, name: u32); + fn glFramebufferRenderbuffer(target: u32, attachment: u32, rb_target: u32, rb: u32); + fn glCheckFramebufferStatus(target: u32) -> u32; + fn glEnable(cap: u32); + fn glScissor(x: i32, y: i32, width: i32, height: i32); + fn glClearColor(r: f32, g: f32, b: f32, a: f32); + fn glClear(mask: u32); + fn glFinish(); + } + + type CreateImage = unsafe extern "C" fn( + *mut c_void, + *mut c_void, + u32, + *mut c_void, + *const i32, + ) -> *mut c_void; + type DestroyImage = unsafe extern "C" fn(*mut c_void, *mut c_void) -> u32; + type ImageStorage = unsafe extern "C" fn(u32, *mut c_void); + + /// A GL context on one render node. + pub struct Producer { + _node: std::fs::File, + gbm: *mut c_void, + display: *mut c_void, + context: *mut c_void, + } + + /// A buffer GBM allocated for rendering. + pub struct Buffer { + bo: *mut c_void, + pub fd: OwnedFd, + pub modifier: u64, + pub offset: u32, + pub stride: u32, + pub width: u32, + pub height: u32, + } + + impl Drop for Buffer { + fn drop(&mut self) { + unsafe { gbm_bo_destroy(self.bo) }; + } + } + + fn proc(name: &CStr) -> Result { + let address = unsafe { eglGetProcAddress(name.as_ptr()) }; + ensure!(!address.is_null(), "no {name:?}"); + Ok(unsafe { std::mem::transmute_copy(&address) }) + } + + impl Producer { + pub fn new(node: &std::path::Path) -> Result { + let node = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(node)?; + let gbm = unsafe { gbm_create_device(node.as_raw_fd()) }; + ensure!(!gbm.is_null(), "no GBM device"); + let mut producer = Self { + _node: node, + gbm, + display: null_mut(), + context: null_mut(), + }; + producer.display = + unsafe { eglGetPlatformDisplay(EGL_PLATFORM_GBM_KHR, gbm, std::ptr::null()) }; + ensure!( + !producer.display.is_null() + && unsafe { eglInitialize(producer.display, null_mut(), null_mut()) } != 0, + "no EGL display" + ); + let attributes = [EGL_CONTEXT_CLIENT_VERSION, 2, EGL_NONE]; + producer.context = unsafe { + eglBindAPI(EGL_OPENGL_ES_API); + eglCreateContext(producer.display, null_mut(), null_mut(), attributes.as_ptr()) + }; + ensure!(!producer.context.is_null(), "no GLES context"); + Ok(producer) + } + + /// A buffer in exactly `modifier`, or `None` when the GPU cannot + /// render into that layout. + pub fn buffer(&self, width: u32, height: u32, modifier: u64) -> Option { + let bo = unsafe { + gbm_bo_create_with_modifiers2( + self.gbm, + width, + height, + super::DRM_FORMAT_ARGB8888, + &modifier, + 1, + GBM_BO_USE_RENDERING, + ) + }; + if bo.is_null() { + return None; + } + let fd = unsafe { gbm_bo_get_fd(bo) }; + if fd < 0 || unsafe { gbm_bo_get_plane_count(bo) } != 1 { + unsafe { gbm_bo_destroy(bo) }; + return None; + } + Some(Buffer { + bo, + fd: unsafe { OwnedFd::from_raw_fd(fd) }, + modifier: unsafe { gbm_bo_get_modifier(bo) }, + offset: unsafe { gbm_bo_get_offset(bo, 0) }, + stride: unsafe { gbm_bo_get_stride(bo) }, + width, + height, + }) + } + + /// Clears each `(x, y, width, height)` rectangle of `buffer`, in + /// rows from the first in memory, to its BGRA color, then waits + /// for the GPU. + pub fn paint(&self, buffer: &Buffer, rects: &[([i32; 4], [u8; 4])]) -> Result<()> { + let create: CreateImage = proc(c"eglCreateImageKHR")?; + let destroy: DestroyImage = proc(c"eglDestroyImageKHR")?; + let storage: ImageStorage = proc(c"glEGLImageTargetRenderbufferStorageOES")?; + let attributes = [ + EGL_WIDTH, + buffer.width as i32, + EGL_HEIGHT, + buffer.height as i32, + EGL_LINUX_DRM_FOURCC_EXT, + super::DRM_FORMAT_ARGB8888 as i32, + EGL_DMA_BUF_PLANE0_FD_EXT, + buffer.fd.as_raw_fd(), + EGL_DMA_BUF_PLANE0_OFFSET_EXT, + buffer.offset as i32, + EGL_DMA_BUF_PLANE0_PITCH_EXT, + buffer.stride as i32, + EGL_DMA_BUF_PLANE0_MODIFIER_LO_EXT, + buffer.modifier as u32 as i32, + EGL_DMA_BUF_PLANE0_MODIFIER_HI_EXT, + (buffer.modifier >> 32) as u32 as i32, + EGL_NONE, + ]; + unsafe { + ensure!( + eglMakeCurrent(self.display, null_mut(), null_mut(), self.context) != 0, + "cannot make the context current" + ); + let image = create( + self.display, + null_mut(), + EGL_LINUX_DMA_BUF_EXT, + null_mut(), + attributes.as_ptr(), + ); + ensure!(!image.is_null(), "EGL cannot import the buffer"); + let (mut renderbuffer, mut framebuffer) = (0, 0); + glGenRenderbuffers(1, &mut renderbuffer); + glBindRenderbuffer(GL_RENDERBUFFER, renderbuffer); + storage(GL_RENDERBUFFER, image); + glGenFramebuffers(1, &mut framebuffer); + glBindFramebuffer(GL_FRAMEBUFFER, framebuffer); + glFramebufferRenderbuffer( + GL_FRAMEBUFFER, + GL_COLOR_ATTACHMENT0, + GL_RENDERBUFFER, + renderbuffer, + ); + let complete = glCheckFramebufferStatus(GL_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE; + if complete { + glEnable(GL_SCISSOR_TEST); + for ([x, y, width, height], [b, g, r, a]) in rects { + glScissor(*x, *y, *width, *height); + let unit = |v: &u8| *v as f32 / 255.; + glClearColor(unit(r), unit(g), unit(b), unit(a)); + glClear(GL_COLOR_BUFFER_BIT); + } + glFinish(); + } + glDeleteFramebuffers(1, &framebuffer); + glDeleteRenderbuffers(1, &renderbuffer); + destroy(self.display, image); + eglMakeCurrent(self.display, null_mut(), null_mut(), null_mut()); + ensure!(complete, "the buffer is not a complete framebuffer"); + } + Ok(()) + } + } + + impl Drop for Producer { + fn drop(&mut self) { + unsafe { + if !self.context.is_null() { + eglDestroyContext(self.display, self.context); + } + if !self.display.is_null() { + eglTerminate(self.display); + } + gbm_device_destroy(self.gbm); + } + } + } + } + + /// Pages a GPU rendered with GL, in every layout the device imports, + /// copied region by region into a tile without touching the CPU. Runs on + /// every Vulkan GPU, as hybrid laptops render on either. + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + #[test] + fn dmabuf_regions_copy_into_the_tile() -> anyhow::Result<()> { + use std::{os::fd::AsFd as _, sync::atomic::Ordering::SeqCst}; + const BLUE: [u8; 4] = [255, 0, 0, 255]; + const RED: [u8; 4] = [0, 0, 255, 255]; + const GREEN: [u8; 4] = [0, 255, 0, 255]; + const NONE: [u8; 4] = [0, 0, 0, 0]; + // Odd sizes, so tiled layouts end mid-tile. + let (width, height) = (321u32, 201u32); + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { + backends: wgpu::Backends::VULKAN, + flags: wgpu::InstanceFlags::default(), + backend_options: wgpu::BackendOptions::default(), + memory_budget_thresholds: wgpu::MemoryBudgetThresholds::default(), + display: None, + }); + let mut tested = 0; + for adapter in block_on(instance.enumerate_adapters(wgpu::Backends::VULKAN)) { + let name = adapter.get_info().name; + let descriptor = wgpu::DeviceDescriptor { + label: Some("wgpu_atlas_dmabuf_test"), + required_limits: wgpu::Limits::downlevel_defaults() + .using_resolution(adapter.limits()) + .using_alignment(adapter.limits()), + ..Default::default() + }; + let Some((device, queue)) = crate::dmabuf::request_device(&adapter, &descriptor) else { + eprintln!("{name}: skipped, cannot import tiled dma-bufs"); + continue; + }; + let atlas = WgpuAtlas::new( + Arc::new(device), + Arc::new(queue), + wgpu::TextureFormat::Bgra8Unorm, + ); + let Some(target) = atlas.dmabuf_target() else { + eprintln!("{name}: skipped, no DRM render node"); + continue; + }; + let producer = producer::Producer::new(&target.render_node)?; + let mut layouts = Vec::new(); + for (index, &modifier) in target.modifiers.iter().enumerate() { + let Some(buffer) = producer.buffer(width, height, modifier) else { + continue; + }; + assert_eq!(buffer.modifier, modifier); + producer.paint( + &buffer, + &[ + ([0, 0, 321, 201], BLUE), + ([160, 0, 161, 150], RED), + ([0, 150, 321, 51], GREEN), + ], + )?; + let import = atlas.import_dmabuf(&Dmabuf { + fd: buffer.fd.as_fd(), + width, + height, + fourcc: DRM_FORMAT_ARGB8888, + modifier, + offset: buffer.offset, + stride: buffer.stride, + })?; + let key = image_key(100 + index); + let tile = offset_tile( + &atlas, + &key, + size(width as i32, height as i32), + vec![0; (width * height * 4) as usize], + ); + // A frame's damage: the red right half, a band of the green + // bottom, and a corner of the green moved to the top left. + let done = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + for (to, from) in [ + (region(160, 0, 161, 150), (160, 0)), + (region(0, 190, 321, 11), (0, 190)), + (region(0, 0, 16, 8), (305, 193)), + ] { + let done = done.clone(); + assert!(atlas.update_tile( + &key, + to, + AtlasTileUpdate::Dmabuf { + buffer: import.id(), + origin: Point { + x: DevicePixels(from.0), + y: DevicePixels(from.1), + }, + }, + Some(Box::new(move || { + done.fetch_add(1, SeqCst); + })), + )?); + } + let at = format!("{name}, modifier {modifier:#018x}"); + assert_eq!(done.load(SeqCst), 0, "{at}: done before the GPU copied"); + // Released from another thread while its copies are queued. + std::thread::spawn(move || drop(import)).join().unwrap(); + let pixels = read_tile(&atlas, tile); + assert_eq!(done.load(SeqCst), 3, "{at}"); + assert!(atlas.0.lock().dmabufs.is_empty(), "{at}: released after its copies"); + for (x, y, expected, what) in [ + (5, 5, GREEN, "moved corner"), + (15, 7, GREEN, "moved corner's last pixel"), + (16, 7, NONE, "beside the moved corner"), + (10, 10, NONE, "untouched"), + (159, 100, NONE, "left of the red"), + (160, 0, RED, "red's first pixel"), + (320, 149, RED, "red's last pixel"), + (200, 160, NONE, "outside the damage"), + (10, 195, GREEN, "green band"), + (320, 200, GREEN, "last pixel"), + ] { + assert_eq!(pixel(&pixels, width, x, y), expected, "{at}: {what} ({x}, {y})"); + } + // Pixels queued after a copy into the same place win, and a + // removed tile's copies spare the tile that takes its place. + let import = atlas.import_dmabuf(&Dmabuf { + fd: buffer.fd.as_fd(), + width, + height, + fourcc: DRM_FORMAT_ARGB8888, + modifier, + offset: buffer.offset, + stride: buffer.stride, + })?; + let whole = region(0, 0, width as i32, height as i32); + let copy = || AtlasTileUpdate::Dmabuf { + buffer: import.id(), + origin: Point::default(), + }; + let white = [255u8; 4].repeat((width * height) as usize); + assert!(atlas.update_tile(&key, whole, copy(), None)?); + let pixels = AtlasTileUpdate::Pixels { + bytes: &white, + bytes_per_row: width * 4, + }; + assert!(atlas.update_tile(&key, whole, pixels, None)?); + let pixels = read_tile(&atlas, tile); + assert_eq!(pixel(&pixels, width, 200, 100), [255; 4], "{at}: pixels after a copy"); + assert!(atlas.update_tile(&key, whole, copy(), None)?); + atlas.remove(&key); + let next = image_key(200 + index); + let replacement = atlas + .get_or_insert_with(&next, &mut || { + Ok(Some((size(width as i32, height as i32), Cow::Owned(white.clone())))) + })? + .expect("tile"); + assert_eq!(replacement.bounds, tile.bounds, "{at}: a tile in the freed place"); + let pixels = read_tile(&atlas, replacement); + assert_eq!(pixel(&pixels, width, 200, 100), [255; 4], "{at}: a removed tile's copy"); + atlas.remove(&next); + layouts.push(format!("{modifier:#018x}")); + } + eprintln!("{name}: copied pages in {} layouts: {}", layouts.len(), layouts.join(" ")); + tested += layouts.len(); + } + if tested == 0 { + eprintln!("skipped: no GPU imports dma-bufs it renders"); + } + Ok(()) + } + + /// Layouts and sizes the device does not support fail the import before + /// Vulkan sees them, as creating such images is undefined behavior. + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + #[test] + fn dmabuf_imports_refuse_unsupported_layouts_and_sizes() -> anyhow::Result<()> { + use std::os::fd::AsFd as _; + let (device, queue) = test_device_and_queue()?; + let atlas = WgpuAtlas::new(device, queue, wgpu::TextureFormat::Bgra8Unorm); + let file = std::fs::File::open("/dev/null")?; + let import = |width: u32, modifier: u64| { + atlas.import_dmabuf(&Dmabuf { + fd: file.as_fd(), + width, + height: 64, + fourcc: DRM_FORMAT_ARGB8888, + modifier, + offset: 0, + stride: width * 4, + }) + }; + // DRM_FORMAT_MOD_INVALID names no layout at all. + let error = import(256, 0x00ff_ffff_ffff_ffff).unwrap_err(); + assert!(format!("{error:#}").contains("modifier"), "{error:#}"); + let modifiers = atlas.dmabuf_target().map(|target| target.modifiers); + if let Some(&modifier) = modifiers.as_deref().and_then(|m| m.first()) { + let error = import(1 << 20, modifier).unwrap_err(); + assert!(format!("{error:#}").contains("at most"), "{error:#}"); + } + Ok(()) + } + #[test] fn swizzle_upload_data_preserves_bgra_uploads() { let input = vec![0x10, 0x20, 0x30, 0x40]; diff --git a/crates/gpui_wgpu/src/wgpu_context.rs b/crates/gpui_wgpu/src/wgpu_context.rs index 9dd5599393..0253fa3434 100644 --- a/crates/gpui_wgpu/src/wgpu_context.rs +++ b/crates/gpui_wgpu/src/wgpu_context.rs @@ -158,19 +158,27 @@ impl WgpuContext { let color_atlas_texture_format = Self::select_color_texture_format(adapter)?; - let (device, queue) = adapter - .request_device(&wgpu::DeviceDescriptor { - label: Some("gpui_device"), - required_features, - required_limits: wgpu::Limits::downlevel_defaults() - .using_resolution(adapter.limits()) - .using_alignment(adapter.limits()), - memory_hints: wgpu::MemoryHints::MemoryUsage, - trace: wgpu::Trace::Off, - experimental_features: wgpu::ExperimentalFeatures::disabled(), - }) - .await - .map_err(|e| anyhow::anyhow!("Failed to create wgpu device: {e}"))?; + let descriptor = wgpu::DeviceDescriptor { + label: Some("gpui_device"), + required_features, + required_limits: wgpu::Limits::downlevel_defaults() + .using_resolution(adapter.limits()) + .using_alignment(adapter.limits()), + memory_hints: wgpu::MemoryHints::MemoryUsage, + trace: wgpu::Trace::Off, + experimental_features: wgpu::ExperimentalFeatures::disabled(), + }; + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + let opened = crate::dmabuf::request_device(adapter, &descriptor); + #[cfg(not(any(target_os = "linux", target_os = "freebsd")))] + let opened = None; + let (device, queue) = match opened { + Some(opened) => opened, + None => adapter + .request_device(&descriptor) + .await + .map_err(|e| anyhow::anyhow!("Failed to create wgpu device: {e}"))?, + }; Ok(( device, From 5392ee18921076a7a8f7d74879e26003f07cf44e Mon Sep 17 00:00:00 2001 From: Alexandre Vargas Lopez Date: Sun, 25 Oct 2026 12:53:28 +0100 Subject: [PATCH 2/3] Keep display-rate frame callbacks in inactive windows An inactive window slows its frame callbacks to ~30 fps to save energy. Content that follows the display, such as video or a web page, asks for the next frame with `Window::on_next_display_frame` instead: the window keeps the display's rate for that callback, and slows down again once such requests stop. --- crates/gpui/src/window.rs | 70 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 68 insertions(+), 2 deletions(-) diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 86b3a02680..60457ade49 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -1096,6 +1096,9 @@ pub struct Window { pub(crate) next_tooltip_id: TooltipId, pub(crate) tooltip_bounds: Option, next_frame_callbacks: Rc>>, + /// A pending next-frame callback follows the display: see + /// [`Window::on_next_display_frame`]. + display_frame_requested: Rc>, pub(crate) dirty_views: FxHashSet, focus_listeners: SubscriberSet<(), AnyWindowFocusListener>, pub(crate) focus_lost_listeners: SubscriberSet<(), AnyObserver>, @@ -1435,6 +1438,7 @@ impl Window { let hovered = Rc::new(Cell::new(platform_window.is_hovered())); let needs_present = Rc::new(Cell::new(false)); let next_frame_callbacks: Rc>> = Default::default(); + let display_frame_requested = Rc::new(Cell::new(false)); let input_rate_tracker = Rc::new(RefCell::new(InputRateTracker::default())); let last_frame_time = Rc::new(Cell::new(None)); @@ -1548,6 +1552,7 @@ impl Window { let active = active.clone(); let needs_present = needs_present.clone(); let next_frame_callbacks = next_frame_callbacks.clone(); + let display_frame_requested = display_frame_requested.clone(); let input_rate_tracker = input_rate_tracker.clone(); move |request_frame_options| { let thermal_state = handle @@ -1556,13 +1561,14 @@ impl Window { // Throttle frame rate based on conditions: // - Thermal pressure (Serious/Critical): cap to ~60fps - // - Inactive window (not focused): cap to ~30fps to save energy + // - Inactive window (not focused): cap to ~30fps to save energy, + // unless content following the display asked for this frame let min_frame_interval = if !request_frame_options.force_render && !request_frame_options.require_presentation && next_frame_callbacks.borrow().is_empty() { None - } else if !active.get() { + } else if !active.get() && !display_frame_requested.get() { Some(Duration::from_micros(33333)) } else if let Some(ThermalState::Critical | ThermalState::Serious) = thermal_state { Some(Duration::from_micros(16667)) @@ -1588,6 +1594,7 @@ impl Window { last_frame_time.set(Some(now)); let next_frame_callbacks = next_frame_callbacks.take(); + display_frame_requested.set(false); if !next_frame_callbacks.is_empty() { handle .update(&mut cx, |_, window, cx| { @@ -1814,6 +1821,7 @@ impl Window { rendered_frame: Frame::new(DispatchTree::new(cx.keymap.clone(), cx.actions.clone())), next_frame: Frame::new(DispatchTree::new(cx.keymap.clone(), cx.actions.clone())), next_frame_callbacks, + display_frame_requested, next_hitbox_id: HitboxId(0), next_tooltip_id: TooltipId::default(), tooltip_bounds: None, @@ -2304,6 +2312,14 @@ impl Window { self.invalidator.request_frame(); } + /// Like [`Self::on_next_frame`], for content that follows the display, such + /// as video or a web page: an inactive window, which otherwise slows its + /// frames to save energy, keeps the display's rate for this callback. + pub fn on_next_display_frame(&self, callback: impl FnOnce(&mut Window, &mut App) + 'static) { + self.display_frame_requested.set(true); + self.on_next_frame(callback); + } + /// Schedule a frame to be drawn on the next animation frame. /// /// This is useful for elements that need to animate continuously, such as a video player or an animated GIF. @@ -2328,6 +2344,7 @@ impl Window { #[cfg(any(test, feature = "test-support"))] pub fn simulate_next_frame(&mut self, cx: &mut App) -> usize { let callbacks = self.next_frame_callbacks.take(); + self.display_frame_requested.set(false); let count = callbacks.len(); for callback in callbacks { callback(self, cx); @@ -7209,6 +7226,55 @@ mod tests { assert!(!platform.simulate_display_tick()); } + #[test] + fn inactive_windows_slow_frame_callbacks_but_not_display_frames() { + fn chain(window: &Window, display: Rc>, ticks: Rc>) { + let follows = display.get(); + let next = move |window: &mut Window, _: &mut crate::App| { + ticks.set(ticks.get() + 1); + chain(window, display, ticks); + }; + if follows { + window.on_next_display_frame(next); + } else { + window.on_next_frame(next); + } + } + let mut cx = TestAppContext::single(); + for display in [false, true] { + let handle = cx.add_window(|_, _| RootView { + explicit_size: false, + child_bounds: Rc::new(Cell::new(Bounds::default())), + }); + let ticks = Rc::new(Cell::new(0)); + let follows = Rc::new(Cell::new(display)); + let platform = cx + .update_window(handle.into(), |_, window, _| { + window.active.set(false); + chain(window, follows.clone(), ticks.clone()); + window.platform_window.as_test().unwrap().clone() + }) + .unwrap(); + // Back to back, like a fast display's vblanks. + for _ in 0..8 { + platform.simulate_display_tick(); + } + if display { + assert_eq!(ticks.get(), 8, "display frames keep the display's rate"); + // The next callback asks for an ordinary frame. + follows.set(false); + platform.simulate_display_tick(); + ticks.set(0); + for _ in 0..8 { + platform.simulate_display_tick(); + } + assert!(ticks.get() < 8, "it slows down again once display frames stop"); + } else { + assert!(ticks.get() < 8, "an inactive window slows its animations"); + } + } + } + struct NativeFrameProbe { desired: Rc>, displayed: Rc>, From b564a34413bb7ef1da04121c4a287758f94993d9 Mon Sep 17 00:00:00 2001 From: Alexandre Vargas Lopez Date: Sun, 25 Oct 2026 12:53:28 +0100 Subject: [PATCH 3/3] Wayland: keep frames coming after a present outside the frame loop A frame callback requests the next one, which only a commit sends. A draw forced outside the frame loop left `renderer_presented` set, so the next frame that drew nothing skipped that commit: the compositor never answered and frames stopped. Reset it with each frame callback. --- crates/gpui_linux/src/linux/wayland/window.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/gpui_linux/src/linux/wayland/window.rs b/crates/gpui_linux/src/linux/wayland/window.rs index 993b2ff3fa..fce24ecfd9 100644 --- a/crates/gpui_linux/src/linux/wayland/window.rs +++ b/crates/gpui_linux/src/linux/wayland/window.rs @@ -841,6 +841,10 @@ impl WaylandWindowStatePtr { pub fn frame(&self) { let mut state = self.state.borrow_mut(); state.surface.frame(&state.globals.qh, state.surface.id()); + // Only a present in this frame commits that request: one made outside + // the frame loop (a forced draw) must not stop the next commit, or the + // compositor never answers and frames stop. + state.renderer_presented = false; state.resize_throttle = false; let force_render = state.force_render_after_recovery; state.force_render_after_recovery = false;