From f6c7a5a8b8bf5578ce87d40a478dd9a381c78407 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:14:26 +0800 Subject: [PATCH 1/5] perf(gpui): add streaming image patch that reuses one texture per stream, with backend tests and a CI job --- .github/workflows/ci.yml | 63 + .../patches/zed-streaming-image.patch | 3049 +++++++++++++++++ scripts/prepare-gpui-dependency.sh | 1 + scripts/prepare-gpui-dependency.test.mjs | 7 + 4 files changed, 3120 insertions(+) create mode 100644 apps/desktop-gpui/patches/zed-streaming-image.patch diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 57810c00587..f8ff8eb5548 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,7 @@ jobs: rust: ${{ steps.filter.outputs.rust }} gpui: ${{ steps.filter.outputs.rust == 'true' || steps.filter.outputs.gpui == 'true' || steps.desktop-resources.outputs.desktop-resources == 'true' }} tauri-plugins: ${{ steps.filter.outputs.tauri-plugins }} + gpui-patches: ${{ steps.filter.outputs.gpui-patches }} steps: - name: Checkout uses: actions/checkout@v4 @@ -65,6 +66,9 @@ jobs: - 'scripts/sync-desktop-versions.mjs' - 'scripts/verify-gpui-release-inputs.mjs' - 'scripts/verify-windows-stack.mjs' + gpui-patches: + - 'apps/desktop-gpui/patches/**' + - 'scripts/prepare-gpui-dependency.sh' tauri-plugins: - 'Cargo.lock' - 'bun.lock' @@ -370,6 +374,65 @@ jobs: fi cargo +1.95.0 test --manifest-path apps/desktop-gpui/Cargo.toml --locked --target ${{ matrix.settings.target }} -p cap-gpui-kernels + # Cap only builds gpui's crates as dependencies, so their own tests never run + # in the app jobs above. Run the ones covering Cap's patches against real + # devices: Metal on macOS, WARP on Windows, and Mesa's lavapipe (Vulkan) and + # llvmpipe (GL) on Linux. Zed's lockfile predates the patches' dependency + # changes, so these builds cannot use --locked. + gpui-patch-tests: + name: GPUI patch tests (${{ matrix.settings.name }}) + needs: changes + if: needs.changes.outputs.gpui-patches == 'true' + timeout-minutes: 90 + strategy: + fail-fast: false + matrix: + settings: + - name: macOS + runner: blacksmith-6vcpu-macos-15 + - name: Windows + runner: blacksmith-8vcpu-windows-2025 + - name: Linux + runner: blacksmith-8vcpu-ubuntu-2404 + runs-on: ${{ matrix.settings.runner }} + env: + CARGO_PROFILE_DEV_DEBUG: "0" + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Linux desktop dependencies + if: ${{ runner.os == 'Linux' }} + uses: ./.github/actions/install-desktop-deps + + - name: Install software GPU drivers + if: ${{ runner.os == 'Linux' }} + run: sudo apt-get install -y --no-install-recommends mesa-vulkan-drivers libvulkan1 libegl1 libegl-mesa0 libgl1-mesa-dri + + - name: Rust setup + uses: dtolnay/rust-toolchain@1.95.0 + + - name: Prepare GPUI dependency + shell: bash + run: ./scripts/prepare-gpui-dependency.sh + + - name: Test GPUI patches + shell: bash + working-directory: ../zed-cap + run: | + cargo +1.95.0 test -p gpui --lib stream + if [[ "$RUNNER_OS" == "Linux" ]]; then + WGPU_BACKEND=vulkan cargo +1.95.0 test -p gpui_wgpu --lib wgpu_atlas -- --nocapture + WGPU_BACKEND=gl cargo +1.95.0 test -p gpui_wgpu --lib wgpu_atlas -- --nocapture + else + cargo +1.95.0 test -p gpui_wgpu --lib wgpu_atlas -- --nocapture + fi + if [[ "$RUNNER_OS" == "macOS" ]]; then + cargo +1.95.0 test -p gpui_macos --lib -- metal_atlas metal_renderer + elif [[ "$RUNNER_OS" == "Windows" ]]; then + cargo +1.95.0 test -p gpui_windows --lib directx_atlas + fi + tauri-plugins: name: Verify Tauri plugin versions runs-on: blacksmith-8vcpu-ubuntu-2404 diff --git a/apps/desktop-gpui/patches/zed-streaming-image.patch b/apps/desktop-gpui/patches/zed-streaming-image.patch new file mode 100644 index 00000000000..6d67d833357 --- /dev/null +++ b/apps/desktop-gpui/patches/zed-streaming-image.patch @@ -0,0 +1,3049 @@ +diff --git a/crates/gpui/src/gpui.rs b/crates/gpui/src/gpui.rs +index 4b1679c..a143fab 100644 +--- a/crates/gpui/src/gpui.rs ++++ b/crates/gpui/src/gpui.rs +@@ -47,6 +47,7 @@ pub mod profiler; + pub mod queue; + mod scene; + mod shared_uri; ++mod streaming_image; + mod style; + mod styled; + mod subscription; +@@ -145,6 +146,7 @@ pub use refineable::*; + pub use scene::*; + pub use shared_uri::*; + use std::{any::Any, future::Future}; ++pub use streaming_image::*; + pub use style::*; + pub use styled::*; + pub use subscription::*; +diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs +index 09adcdd..7dd2d38 100644 +--- a/crates/gpui/src/platform.rs ++++ b/crates/gpui/src/platform.rs +@@ -40,7 +40,8 @@ use crate::{ + FontRun, ForegroundExecutor, GlyphId, GpuSpecs, Hsla, ImageSource, Keymap, LineLayout, Pixels, + PlatformGestures, PlatformInput, Point, Priority, RenderGlyphParams, RenderImage, + RenderImageParams, RenderSvgParams, Scene, ShapedGlyph, ShapedRun, SharedString, Size, +- SvgRenderer, SystemWindowTab, Task, Window, WindowControlArea, hash, point, px, size, ++ StreamFrame, StreamingImageId, SvgRenderer, SystemWindowTab, Task, Window, WindowControlArea, ++ hash, point, px, size, + }; + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + use anyhow::bail; +@@ -1316,6 +1317,17 @@ pub trait PlatformAtlas { + build: &mut dyn FnMut() -> Result, Cow<'a, [u8]>)>>, + ) -> Result>; + fn remove(&self, key: &AtlasKey); ++ /// Returns the tile of the texture that holds `frame` for the streaming ++ /// image `id`, uploading the frame first when its generation differs from ++ /// the one the texture holds. Stream textures are never packed with, or ++ /// evicted alongside, atlas tiles. ++ fn get_or_update_stream( ++ &self, ++ id: StreamingImageId, ++ frame: &StreamFrame, ++ ) -> Result>; ++ /// Releases the texture of the streaming image `id`, if there is one. ++ fn remove_stream(&self, id: StreamingImageId); + } + + #[doc(hidden)] +@@ -1368,6 +1380,24 @@ pub struct AtlasTile { + pub bounds: Bounds, + } + ++impl AtlasTile { ++ /// The tile covering the whole texture of a streaming image. ++ pub fn for_stream(index: u32, size: Size) -> Self { ++ Self { ++ texture_id: AtlasTextureId { ++ index, ++ kind: AtlasTextureKind::Stream, ++ }, ++ tile_id: TileId(0), ++ padding: 0, ++ bounds: Bounds { ++ origin: Point::default(), ++ size, ++ }, ++ } ++ } ++} ++ + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + #[repr(C)] + #[expect(missing_docs)] +@@ -1393,6 +1423,7 @@ pub enum AtlasTextureKind { + Monochrome = 0, + Polychrome = 1, + Subpixel = 2, ++ Stream = 3, + } + + #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +diff --git a/crates/gpui/src/platform/test/window.rs b/crates/gpui/src/platform/test/window.rs +index 8894350..562ec92 100644 +--- a/crates/gpui/src/platform/test/window.rs ++++ b/crates/gpui/src/platform/test/window.rs +@@ -2,8 +2,9 @@ use crate::{ + AnyWindowHandle, AtlasKey, AtlasTextureId, AtlasTile, Bounds, DevicePixels, + DispatchEventResult, GpuSpecs, Pixels, PlatformAtlas, PlatformDisplay, + PlatformHeadlessRenderer, PlatformInput, PlatformInputHandler, PlatformWindow, Point, +- PromptButton, RequestFrameOptions, Scene, Size, TestPlatform, TileId, WindowAppearance, +- WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowParams, ++ PromptButton, RequestFrameOptions, Scene, Size, StreamFrame, StreamingImageId, TestPlatform, ++ TileId, WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, ++ WindowParams, + }; + use collections::HashMap; + use gpui_util::ResultExt as _; +@@ -345,6 +346,9 @@ impl PlatformWindow for TestWindow { + pub(crate) struct TestAtlasState { + next_id: u32, + tiles: HashMap, ++ streams: HashMap, ++ stream_uploads: usize, ++ removed_streams: Vec, + } + + pub(crate) struct TestAtlas(Mutex); +@@ -354,8 +358,21 @@ impl TestAtlas { + TestAtlas(Mutex::new(TestAtlasState { + next_id: 0, + tiles: HashMap::default(), ++ streams: HashMap::default(), ++ stream_uploads: 0, ++ removed_streams: Vec::new(), + })) + } ++ ++ #[cfg(test)] ++ pub(crate) fn stream_uploads(&self) -> usize { ++ self.0.lock().stream_uploads ++ } ++ ++ #[cfg(test)] ++ pub(crate) fn removed_streams(&self) -> Vec { ++ self.0.lock().removed_streams.clone() ++ } + } + + impl PlatformAtlas for TestAtlas { +@@ -405,4 +422,35 @@ impl PlatformAtlas for TestAtlas { + let mut state = self.0.lock(); + state.tiles.remove(key); + } ++ ++ fn get_or_update_stream( ++ &self, ++ id: StreamingImageId, ++ frame: &StreamFrame, ++ ) -> anyhow::Result> { ++ let mut state = self.0.lock(); ++ let state = &mut *state; ++ if let Some((generation, tile)) = state.streams.get_mut(&id) ++ && tile.bounds.size == frame.size() ++ { ++ let tile = *tile; ++ if *generation != frame.generation() { ++ *generation = frame.generation(); ++ state.stream_uploads += 1; ++ } ++ return Ok(Some(tile)); ++ } ++ state.next_id += 1; ++ let tile = AtlasTile::for_stream(state.next_id, frame.size()); ++ state.streams.insert(id, (frame.generation(), tile)); ++ state.stream_uploads += 1; ++ Ok(Some(tile)) ++ } ++ ++ fn remove_stream(&self, id: StreamingImageId) { ++ let mut state = self.0.lock(); ++ if state.streams.remove(&id).is_some() { ++ state.removed_streams.push(id); ++ } ++ } + } +diff --git a/crates/gpui/src/streaming_image.rs b/crates/gpui/src/streaming_image.rs +new file mode 100644 +index 0000000..3cb3416 +--- /dev/null ++++ b/crates/gpui/src/streaming_image.rs +@@ -0,0 +1,363 @@ ++use crate::{DevicePixels, Size}; ++use anyhow::{Context as _, Result}; ++use parking_lot::Mutex; ++use std::{ ++ fmt, ++ sync::{ ++ Arc, Weak, ++ atomic::{AtomicU64, Ordering}, ++ }, ++}; ++ ++/// Identifies a [`StreamingImage`] for the lifetime of the process. ++#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] ++pub struct StreamingImageId(pub u64); ++ ++/// The byte order of a [`StreamingImage`]'s pixels. ++#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] ++pub enum StreamingImageFormat { ++ /// Blue, green, red, alpha, the order [`RenderImage`](crate::RenderImage) uses. ++ Bgra8, ++ /// Red, green, blue, alpha. ++ Rgba8, ++} ++ ++/// One frame of a [`StreamingImage`], as returned by [`StreamingImage::current`]. ++#[derive(Clone)] ++pub struct StreamFrame { ++ generation: u64, ++ size: Size, ++ stride: usize, ++ format: StreamingImageFormat, ++ bytes: Arc>, ++} ++ ++impl StreamFrame { ++ /// Increases every time the image receives a new frame. ++ pub fn generation(&self) -> u64 { ++ self.generation ++ } ++ ++ /// The frame's size in pixels. ++ pub fn size(&self) -> Size { ++ self.size ++ } ++ ++ /// The number of bytes from the start of one row to the start of the next. ++ pub fn stride(&self) -> usize { ++ self.stride ++ } ++ ++ /// The byte order of the frame's pixels. ++ pub fn format(&self) -> StreamingImageFormat { ++ self.format ++ } ++ ++ /// The frame's rows, [`Self::stride`] bytes apart. Holds at least ++ /// `stride * height` bytes. ++ pub fn bytes(&self) -> &[u8] { ++ &self.bytes ++ } ++} ++ ++impl fmt::Debug for StreamFrame { ++ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { ++ f.debug_struct("StreamFrame") ++ .field("generation", &self.generation) ++ .field("size", &self.size) ++ .field("stride", &self.stride) ++ .field("format", &self.format) ++ .field("len", &self.bytes.len()) ++ .finish() ++ } ++} ++ ++/// An image whose pixels are replaced frame after frame, such as a video or a ++/// camera preview. ++/// ++/// Each window that paints the image keeps one texture for it and uploads into ++/// that texture only when the frame has changed, instead of packing every frame ++/// into the sprite atlas as a new image. The texture is released once every ++/// handle is dropped or the image is [cleared](Self::clear). ++/// ++/// Handles are cheap to clone and frames can be set from any thread. ++#[derive(Clone)] ++pub struct StreamingImage(Arc); ++ ++struct Shared { ++ id: StreamingImageId, ++ format: StreamingImageFormat, ++ state: Mutex, ++} ++ ++#[derive(Default)] ++struct State { ++ generation: u64, ++ frame: Option, ++ spare: Option>, ++} ++ ++impl StreamingImage { ++ /// Creates an image with no frame, whose frames are in the given byte order. ++ pub fn new(format: StreamingImageFormat) -> Self { ++ static NEXT_ID: AtomicU64 = AtomicU64::new(0); ++ Self(Arc::new(Shared { ++ id: StreamingImageId(NEXT_ID.fetch_add(1, Ordering::Relaxed)), ++ format, ++ state: Mutex::default(), ++ })) ++ } ++ ++ /// The image's process-wide identifier. ++ pub fn id(&self) -> StreamingImageId { ++ self.0.id ++ } ++ ++ /// The byte order of the image's frames. ++ pub fn format(&self) -> StreamingImageFormat { ++ self.0.format ++ } ++ ++ /// Replaces the current frame with `bytes`, whose rows are `stride` bytes ++ /// apart. ++ /// ++ /// Returns the previous frame's buffer when nothing else holds it any more, ++ /// so the producer can write the next frame into it instead of allocating. ++ pub fn set_frame( ++ &self, ++ size: Size, ++ stride: usize, ++ bytes: Vec, ++ ) -> Result>> { ++ validate_frame(size, stride, bytes.len())?; ++ let previous = self.replace_frame(size, stride, bytes); ++ Ok(previous.and_then(|frame| Arc::try_unwrap(frame.bytes).ok())) ++ } ++ ++ /// Copies `bytes`, whose rows are `stride` bytes apart, into a new current ++ /// frame. The copy reuses the buffer of an earlier frame once nothing else ++ /// holds it, so a steady stream of same-sized frames does not allocate. ++ pub fn copy_frame(&self, size: Size, stride: usize, bytes: &[u8]) -> Result<()> { ++ validate_frame(size, stride, bytes.len())?; ++ let len = stride * size.height.0 as usize; ++ let mut buffer = self.0.state.lock().spare.take().unwrap_or_default(); ++ buffer.clear(); ++ buffer.extend_from_slice(&bytes[..len]); ++ let previous = self.replace_frame(size, stride, buffer); ++ if let Some(previous) = previous.and_then(|frame| Arc::try_unwrap(frame.bytes).ok()) { ++ let mut state = self.0.state.lock(); ++ if state.frame.is_some() { ++ state.spare = Some(previous); ++ } ++ } ++ Ok(()) ++ } ++ ++ /// The current frame, if any. ++ pub fn current(&self) -> Option { ++ self.0.state.lock().frame.clone() ++ } ++ ++ /// Drops the current frame. Windows stop painting the image and release ++ /// its texture on their next draw. ++ pub fn clear(&self) { ++ let mut state = self.0.state.lock(); ++ state.frame = None; ++ state.spare = None; ++ } ++ ++ pub(crate) fn downgrade(&self) -> WeakStreamingImage { ++ WeakStreamingImage(Arc::downgrade(&self.0)) ++ } ++ ++ fn replace_frame( ++ &self, ++ size: Size, ++ stride: usize, ++ bytes: Vec, ++ ) -> Option { ++ let mut state = self.0.state.lock(); ++ state.generation += 1; ++ let frame = StreamFrame { ++ generation: state.generation, ++ size, ++ stride, ++ format: self.0.format, ++ bytes: Arc::new(bytes), ++ }; ++ state.frame.replace(frame) ++ } ++} ++ ++impl fmt::Debug for StreamingImage { ++ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { ++ f.debug_struct("StreamingImage") ++ .field("id", &self.0.id) ++ .field("format", &self.0.format) ++ .finish_non_exhaustive() ++ } ++} ++ ++pub(crate) struct WeakStreamingImage(Weak); ++ ++impl WeakStreamingImage { ++ pub(crate) fn has_frame(&self) -> bool { ++ self.0 ++ .upgrade() ++ .is_some_and(|shared| shared.state.lock().frame.is_some()) ++ } ++} ++ ++fn validate_frame(size: Size, stride: usize, len: usize) -> Result<()> { ++ anyhow::ensure!( ++ size.width.0 > 0 && size.height.0 > 0, ++ "streaming image frames must not be empty, got {}x{}", ++ size.width.0, ++ size.height.0 ++ ); ++ let row_bytes = size.width.0 as usize * 4; ++ anyhow::ensure!( ++ stride >= row_bytes && stride.is_multiple_of(4), ++ "stride {stride} must be a multiple of 4 and at least {row_bytes} for a frame {} pixels wide", ++ size.width.0 ++ ); ++ let required = stride ++ .checked_mul(size.height.0 as usize) ++ .context("streaming image frame is too large")?; ++ anyhow::ensure!( ++ len >= required, ++ "a {}x{} frame with stride {stride} needs {required} bytes, got {len}", ++ size.width.0, ++ size.height.0 ++ ); ++ Ok(()) ++} ++ ++#[cfg(test)] ++mod tests { ++ use super::*; ++ use crate::size; ++ ++ fn frame_size(width: i32, height: i32) -> Size { ++ size(DevicePixels(width), DevicePixels(height)) ++ } ++ ++ #[test] ++ fn handles_are_send_and_sync() { ++ fn assert_send_sync() {} ++ assert_send_sync::(); ++ assert_send_sync::(); ++ } ++ ++ #[test] ++ fn ids_are_unique() { ++ let first = StreamingImage::new(StreamingImageFormat::Bgra8); ++ let second = StreamingImage::new(StreamingImageFormat::Bgra8); ++ assert_ne!(first.id(), second.id()); ++ assert_eq!(first.clone().id(), first.id()); ++ } ++ ++ #[test] ++ fn each_frame_advances_the_generation() { ++ let image = StreamingImage::new(StreamingImageFormat::Rgba8); ++ assert!(image.current().is_none()); ++ ++ image.set_frame(frame_size(2, 1), 8, vec![1; 8]).unwrap(); ++ let first = image.current().unwrap(); ++ assert_eq!(first.generation(), 1); ++ assert_eq!(first.size(), frame_size(2, 1)); ++ assert_eq!(first.stride(), 8); ++ assert_eq!(first.format(), StreamingImageFormat::Rgba8); ++ assert_eq!(first.bytes(), &[1; 8]); ++ ++ image.copy_frame(frame_size(1, 2), 4, &[2; 8]).unwrap(); ++ let second = image.current().unwrap(); ++ assert_eq!(second.generation(), 2); ++ assert_eq!(second.size(), frame_size(1, 2)); ++ assert_eq!(second.bytes(), &[2; 8]); ++ assert_eq!(first.bytes(), &[1; 8]); ++ } ++ ++ #[test] ++ fn set_frame_returns_the_previous_buffer_once_nothing_holds_it() { ++ let image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ assert_eq!( ++ image.set_frame(frame_size(1, 1), 4, vec![1; 4]).unwrap(), ++ None ++ ); ++ ++ let returned = image.set_frame(frame_size(1, 1), 4, vec![2; 4]).unwrap(); ++ assert_eq!(returned, Some(vec![1; 4])); ++ ++ let held = image.current().unwrap(); ++ let returned = image.set_frame(frame_size(1, 1), 4, vec![3; 4]).unwrap(); ++ assert_eq!(returned, None); ++ assert_eq!(held.bytes(), &[2; 4]); ++ } ++ ++ #[test] ++ fn copy_frame_reuses_earlier_buffers() { ++ let image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ let pixels = vec![7u8; 64 * 64 * 4]; ++ image.copy_frame(frame_size(64, 64), 256, &pixels).unwrap(); ++ let first = image.current().unwrap().bytes().as_ptr(); ++ image.copy_frame(frame_size(64, 64), 256, &pixels).unwrap(); ++ let second = image.current().unwrap().bytes().as_ptr(); ++ image.copy_frame(frame_size(64, 64), 256, &pixels).unwrap(); ++ let third = image.current().unwrap().bytes().as_ptr(); ++ ++ assert_ne!(first, second); ++ assert_eq!(first, third); ++ } ++ ++ #[test] ++ fn copy_frame_copies_whole_padded_rows() { ++ let image = StreamingImage::new(StreamingImageFormat::Rgba8); ++ let bytes: Vec = (0..40).collect(); ++ image.copy_frame(frame_size(1, 2), 16, &bytes).unwrap(); ++ assert_eq!(image.current().unwrap().bytes(), &bytes[..32]); ++ } ++ ++ #[test] ++ fn invalid_frames_are_rejected_without_changing_the_image() { ++ let image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ image.set_frame(frame_size(2, 2), 8, vec![0; 16]).unwrap(); ++ ++ assert!(image.set_frame(frame_size(0, 2), 8, vec![0; 16]).is_err()); ++ assert!(image.set_frame(frame_size(2, 0), 8, vec![0; 16]).is_err()); ++ assert!(image.set_frame(frame_size(2, 2), 4, vec![0; 16]).is_err()); ++ assert!(image.set_frame(frame_size(2, 2), 10, vec![0; 20]).is_err()); ++ assert!(image.set_frame(frame_size(2, 2), 8, vec![0; 15]).is_err()); ++ assert!(image.copy_frame(frame_size(2, 2), 12, &[0; 20]).is_err()); ++ assert!( ++ image ++ .copy_frame(frame_size(i32::MAX, i32::MAX), usize::MAX - 3, &[]) ++ .is_err() ++ ); ++ ++ let current = image.current().unwrap(); ++ assert_eq!(current.generation(), 1); ++ assert_eq!(current.size(), frame_size(2, 2)); ++ } ++ ++ #[test] ++ fn clear_drops_the_frame_and_later_frames_keep_counting() { ++ let image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ let weak = image.downgrade(); ++ assert!(!weak.has_frame()); ++ ++ image.set_frame(frame_size(1, 1), 4, vec![0; 4]).unwrap(); ++ assert!(weak.has_frame()); ++ ++ image.clear(); ++ assert!(image.current().is_none()); ++ assert!(!weak.has_frame()); ++ ++ image.copy_frame(frame_size(1, 1), 4, &[0; 4]).unwrap(); ++ assert_eq!(image.current().unwrap().generation(), 2); ++ assert!(weak.has_frame()); ++ ++ drop(image); ++ assert!(!weak.has_frame()); ++ } ++} +diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs +index de95b7d..75c3539 100644 +--- a/crates/gpui/src/window.rs ++++ b/crates/gpui/src/window.rs +@@ -21,6 +21,7 @@ use crate::{ + WindowOptions, WindowParams, WindowTextSystem, point, prelude::*, profiler, px, rems, size, + transparent_black, + }; ++use crate::{AtlasTile, StreamingImage, StreamingImageId, WeakStreamingImage}; + + use anyhow::{Context as _, Result, anyhow}; + use collections::{FxHashMap, FxHashSet}; +@@ -1102,6 +1103,7 @@ pub struct Window { + pub(crate) platform_window: Box, + display_id: Option, + sprite_atlas: Arc, ++ painted_streams: FxHashMap, + text_system: Arc, + text_rendering_mode: Rc>, + rem_size: Pixels, +@@ -1837,6 +1839,7 @@ impl Window { + platform_window, + display_id, + sprite_atlas, ++ painted_streams: FxHashMap::default(), + text_system, + text_rendering_mode: cx.text_rendering_mode.clone(), + rem_size: px(16.), +@@ -2839,6 +2842,7 @@ impl Window { + let _arena_scope = ElementArenaScope::enter(&cx.element_arena); + + self.invalidate_entities(); ++ self.release_unpainted_streams(); + cx.entities.clear_accessed(); + debug_assert!(self.rendered_entity_stack.is_empty()); + self.invalidator.set_dirty(false); +@@ -2967,6 +2971,17 @@ impl Window { + mem::swap(&mut entities, entities_ref.deref_mut()); + } + ++ fn release_unpainted_streams(&mut self) { ++ let sprite_atlas = &self.sprite_atlas; ++ self.painted_streams.retain(|id, image| { ++ let keep = image.has_frame(); ++ if !keep { ++ sprite_atlas.remove_stream(*id); ++ } ++ keep ++ }); ++ } ++ + fn invalidate_entities(&mut self) { + let mut views = self.invalidator.take_views(); + for entity in views.drain() { +@@ -4587,8 +4602,6 @@ impl Window { + ) -> Result<()> { + self.invalidator.debug_assert_paint(); + +- let crop = (visible != fitted).then_some((visible, fitted)); +- let bounds = self.snap_bounds(visible); + let params = RenderImageParams { + image_id: data.id, + frame_index, +@@ -4606,6 +4619,66 @@ impl Window { + ))) + })? + .expect("Callback above only returns Some"); ++ self.insert_polychrome_tile(visible, fitted, corner_radii, tile, grayscale); ++ Ok(()) ++ } ++ ++ /// Paint the current frame of a [`StreamingImage`] into the scene for the ++ /// next frame at the current z-index. Paints nothing while the image has ++ /// no frame. ++ /// ++ /// The window uploads a frame at most once, however many elements paint ++ /// it, and releases the image's texture once every handle to the image is ++ /// dropped or the image is cleared. ++ /// ++ /// This method should only be called as part of the paint phase of element drawing. ++ pub fn paint_streaming_image( ++ &mut self, ++ bounds: Bounds, ++ corner_radii: Corners, ++ image: &StreamingImage, ++ grayscale: bool, ++ ) -> Result<()> { ++ self.paint_streaming_image_fitted(bounds, bounds, corner_radii, image, grayscale) ++ } ++ ++ /// [`Self::paint_streaming_image`] for object-fit layouts, with the same ++ /// contract as [`Self::paint_image_fitted`]. ++ /// ++ /// This method should only be called as part of the paint phase of element drawing. ++ pub fn paint_streaming_image_fitted( ++ &mut self, ++ visible: Bounds, ++ fitted: Bounds, ++ corner_radii: Corners, ++ image: &StreamingImage, ++ grayscale: bool, ++ ) -> Result<()> { ++ self.invalidator.debug_assert_paint(); ++ ++ let Some(frame) = image.current() else { ++ return Ok(()); ++ }; ++ self.painted_streams ++ .entry(image.id()) ++ .or_insert_with(|| image.downgrade()); ++ let Some(tile) = self.sprite_atlas.get_or_update_stream(image.id(), &frame)? else { ++ return Ok(()); ++ }; ++ self.insert_polychrome_tile(visible, fitted, corner_radii, tile, grayscale); ++ Ok(()) ++ } ++ ++ fn insert_polychrome_tile( ++ &mut self, ++ visible: Bounds, ++ fitted: Bounds, ++ corner_radii: Corners, ++ tile: AtlasTile, ++ grayscale: bool, ++ ) { ++ let crop = (visible != fitted).then_some((visible, fitted)); ++ let bounds = self.snap_bounds(visible); + // Crop the atlas tile to the visible fraction of the fitted box + // (proportional UV mapping; ≤1px rounding on arbitrary crops). + let tile = match crop { +@@ -4646,7 +4719,6 @@ impl Window { + tile, + opacity, + }); +- Ok(()) + } + + /// Paint a surface into the scene for the next frame at the current z-index. +@@ -6990,11 +7062,12 @@ mod frame_pacer_tests { + #[cfg(test)] + mod tests { + use crate::{ +- AppContext as _, Bounds, Context, FocusHandle, InteractiveElement as _, IntoElement, +- ParentElement as _, Pixels, Render, Styled as _, TestAppContext, Window, canvas, div, px, +- size, ++ AppContext as _, Bounds, Context, Corners, DevicePixels, FocusHandle, ++ InteractiveElement as _, IntoElement, ParentElement as _, Pixels, PlatformAtlas, Render, ++ StreamingImage, StreamingImageFormat, Styled as _, TestAppContext, Window, canvas, div, ++ platform::TestAtlas, px, size, + }; +- use std::{cell::Cell, rc::Rc}; ++ use std::{cell::Cell, rc::Rc, sync::Arc}; + + struct RootView { + explicit_size: bool, +@@ -7075,6 +7148,77 @@ mod tests { + } + } + ++ struct StreamView { ++ images: Vec, ++ } ++ ++ impl Render for StreamView { ++ fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { ++ let images = self.images.clone(); ++ canvas( ++ |_, _, _| {}, ++ move |bounds, _, window, _| { ++ for image in &images { ++ window ++ .paint_streaming_image(bounds, Corners::default(), image, false) ++ .unwrap(); ++ } ++ }, ++ ) ++ .size_full() ++ } ++ } ++ ++ #[test] ++ fn streams_upload_once_per_generation_and_are_released_when_unreachable() { ++ let mut cx = TestAppContext::single(); ++ let frame_size = size(DevicePixels(2), DevicePixels(2)); ++ let first = StreamingImage::new(StreamingImageFormat::Bgra8); ++ let second = StreamingImage::new(StreamingImageFormat::Rgba8); ++ first.set_frame(frame_size, 8, vec![0; 16]).unwrap(); ++ second.set_frame(frame_size, 8, vec![0; 16]).unwrap(); ++ let window = cx.add_window({ ++ let images = vec![first.clone(), first.clone(), second.clone()]; ++ move |_, _| StreamView { images } ++ }); ++ let atlas = Arc::new(TestAtlas::new()); ++ cx.update_window(window.into(), |_, window, _| { ++ window.sprite_atlas = atlas.clone() as Arc; ++ }) ++ .unwrap(); ++ let draw = |cx: &mut TestAppContext| { ++ cx.update_window(window.into(), |_, window, cx| { ++ window.draw(cx).clear(); ++ window.painted_streams.len() ++ }) ++ .unwrap() ++ }; ++ ++ assert_eq!(draw(&mut cx), 2); ++ assert_eq!(atlas.stream_uploads(), 2); ++ assert_eq!(draw(&mut cx), 2); ++ assert_eq!(atlas.stream_uploads(), 2); ++ ++ first.set_frame(frame_size, 8, vec![1; 16]).unwrap(); ++ assert_eq!(draw(&mut cx), 2); ++ assert_eq!(atlas.stream_uploads(), 3); ++ assert!(atlas.removed_streams().is_empty()); ++ ++ second.clear(); ++ assert_eq!(draw(&mut cx), 1); ++ assert_eq!(atlas.removed_streams(), vec![second.id()]); ++ ++ window ++ .update(&mut cx, |view, _, _| view.images.clear()) ++ .unwrap(); ++ let first_id = first.id(); ++ drop(first); ++ assert_eq!(draw(&mut cx), 0); ++ assert_eq!(atlas.removed_streams(), vec![second.id(), first_id]); ++ assert_eq!(draw(&mut cx), 0); ++ assert_eq!(atlas.removed_streams().len(), 2); ++ } ++ + /// When a focus listener moves focus again (e.g. a dock forwarding focus to its + /// active panel), the resulting focus events must be dispatched without waiting + /// for an unrelated redraw of the window. +diff --git a/crates/gpui_linux/src/linux/headless/window.rs b/crates/gpui_linux/src/linux/headless/window.rs +index e41b097..dac72f9 100644 +--- a/crates/gpui_linux/src/linux/headless/window.rs ++++ b/crates/gpui_linux/src/linux/headless/window.rs +@@ -18,8 +18,8 @@ use gpui::{ + AtlasKey, AtlasTextureId, AtlasTile, Bounds, Capslock, DevicePixels, DispatchEventResult, + DisplayId, GpuSpecs, Modifiers, Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, + PlatformInputHandler, PlatformWindow, Point, PromptButton, PromptLevel, RequestFrameOptions, +- Scene, Size, TileId, WindowAppearance, WindowBackgroundAppearance, WindowBounds, +- WindowControlArea, WindowParams, px, ++ Scene, Size, StreamFrame, StreamingImageId, TileId, WindowAppearance, ++ WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowParams, px, + }; + + #[derive(Debug)] +@@ -239,6 +239,7 @@ struct HeadlessAtlas(Mutex); + struct HeadlessAtlasState { + next_id: u32, + tiles: HashMap, ++ streams: HashMap, + } + + impl PlatformAtlas for HeadlessAtlas { +@@ -284,4 +285,25 @@ impl PlatformAtlas for HeadlessAtlas { + fn remove(&self, key: &AtlasKey) { + self.0.lock().tiles.remove(key); + } ++ ++ fn get_or_update_stream( ++ &self, ++ id: StreamingImageId, ++ frame: &StreamFrame, ++ ) -> anyhow::Result> { ++ let mut state = self.0.lock(); ++ if let Some(tile) = state.streams.get(&id) ++ && tile.bounds.size == frame.size() ++ { ++ return Ok(Some(*tile)); ++ } ++ state.next_id += 1; ++ let tile = AtlasTile::for_stream(state.next_id, frame.size()); ++ state.streams.insert(id, tile); ++ Ok(Some(tile)) ++ } ++ ++ fn remove_stream(&self, id: StreamingImageId) { ++ self.0.lock().streams.remove(&id); ++ } + } +diff --git a/crates/gpui_macos/src/metal_atlas.rs b/crates/gpui_macos/src/metal_atlas.rs +index d007abb..f418f88 100644 +--- a/crates/gpui_macos/src/metal_atlas.rs ++++ b/crates/gpui_macos/src/metal_atlas.rs +@@ -4,11 +4,21 @@ use derive_more::{Deref, DerefMut}; + use etagere::BucketedAtlasAllocator; + use gpui::{ + AtlasKey, AtlasTextureId, AtlasTextureKind, AtlasTextureList, AtlasTile, Bounds, DevicePixels, +- PlatformAtlas, Point, Size, ++ PlatformAtlas, Point, Size, StreamFrame, StreamingImageFormat, StreamingImageId, + }; + use metal::Device; + use parking_lot::Mutex; +-use std::borrow::Cow; ++use std::{ ++ borrow::Cow, ++ sync::{ ++ Arc, ++ atomic::{AtomicU32, Ordering}, ++ }, ++}; ++ ++const MAX_STREAM_TEXTURE_SIZE: i32 = 16384; ++const SINGLE_SLOT_STREAM_BYTES: u64 = 32 * 1024 * 1024; ++const SPARE_SLOT_RELEASE_UPLOADS: u32 = 120; + + pub(crate) struct MetalAtlas(Mutex); + +@@ -20,9 +30,44 @@ impl MetalAtlas { + monochrome_textures: Default::default(), + polychrome_textures: Default::default(), + tiles_by_key: Default::default(), ++ streams: Vec::new(), ++ next_stream_index: 0, ++ stream_textures_created: 0, + })) + } + ++ /// The texture a stream sprite batch should sample, plus a lease that keeps ++ /// later uploads out of that texture until the GPU is done with it. `None` ++ /// once the stream has been removed. ++ pub(crate) fn lease_stream_texture( ++ &self, ++ id: AtlasTextureId, ++ ) -> Option<(metal::Texture, MetalStreamLease)> { ++ let lock = self.0.lock(); ++ let stream = lock ++ .streams ++ .iter() ++ .find(|stream| stream.index == id.index)?; ++ let slot = stream.slots.get(stream.front)?; ++ Some(( ++ slot.texture.0.clone(), ++ MetalStreamLease::new(&slot.in_flight), ++ )) ++ } ++ ++ /// (live stream textures, their total bytes, stream textures created so ++ /// far) for COMET_GPU_STATS. ++ pub(crate) fn stream_stats(&self) -> (usize, u64, u64) { ++ let lock = self.0.lock(); ++ let mut count = 0; ++ let mut bytes = 0; ++ for slot in lock.streams.iter().flat_map(|stream| &stream.slots) { ++ count += 1; ++ bytes += slot.texture.width() * slot.texture.height() * 4; ++ } ++ (count, bytes, lock.stream_textures_created) ++ } ++ + pub(crate) fn metal_texture(&self, id: AtlasTextureId) -> metal::Texture { + self.0.lock().texture(id).metal_texture.clone() + } +@@ -54,6 +99,9 @@ struct MetalAtlasState { + monochrome_textures: AtlasTextureList, + polychrome_textures: AtlasTextureList, + tiles_by_key: FxHashMap, ++ streams: Vec, ++ next_stream_index: u32, ++ stream_textures_created: u64, + } + + impl PlatformAtlas for MetalAtlas { +@@ -89,7 +137,7 @@ impl PlatformAtlas for MetalAtlas { + let textures = match id.kind { + AtlasTextureKind::Monochrome => &mut lock.monochrome_textures, + AtlasTextureKind::Polychrome => &mut lock.polychrome_textures, +- AtlasTextureKind::Subpixel => unreachable!(), ++ AtlasTextureKind::Subpixel | AtlasTextureKind::Stream => unreachable!(), + }; + + let Some(texture_slot) = textures +@@ -110,6 +158,18 @@ impl PlatformAtlas for MetalAtlas { + } + } + } ++ ++ fn get_or_update_stream( ++ &self, ++ id: StreamingImageId, ++ frame: &StreamFrame, ++ ) -> Result> { ++ self.0.lock().update_stream(id, frame).map(Some) ++ } ++ ++ fn remove_stream(&self, id: StreamingImageId) { ++ self.0.lock().streams.retain(|stream| stream.id != id); ++ } + } + + impl MetalAtlasState { +@@ -122,7 +182,7 @@ impl MetalAtlasState { + let textures = match texture_kind { + AtlasTextureKind::Monochrome => &mut self.monochrome_textures, + AtlasTextureKind::Polychrome => &mut self.polychrome_textures, +- AtlasTextureKind::Subpixel => unreachable!(), ++ AtlasTextureKind::Subpixel | AtlasTextureKind::Stream => unreachable!(), + }; + + if let Some(tile) = textures +@@ -167,7 +227,7 @@ impl MetalAtlasState { + pixel_format = metal::MTLPixelFormat::BGRA8Unorm; + usage = metal::MTLTextureUsage::ShaderRead; + } +- AtlasTextureKind::Subpixel => unreachable!(), ++ AtlasTextureKind::Subpixel | AtlasTextureKind::Stream => unreachable!(), + } + texture_descriptor.set_pixel_format(pixel_format); + texture_descriptor.set_usage(usage); +@@ -183,7 +243,7 @@ impl MetalAtlasState { + let texture_list = match kind { + AtlasTextureKind::Monochrome => &mut self.monochrome_textures, + AtlasTextureKind::Polychrome => &mut self.polychrome_textures, +- AtlasTextureKind::Subpixel => unreachable!(), ++ AtlasTextureKind::Subpixel | AtlasTextureKind::Stream => unreachable!(), + }; + + let index = texture_list.free_list.pop(); +@@ -214,10 +274,173 @@ impl MetalAtlasState { + let textures = match id.kind { + AtlasTextureKind::Monochrome => &self.monochrome_textures, + AtlasTextureKind::Polychrome => &self.polychrome_textures, +- AtlasTextureKind::Subpixel => unreachable!(), ++ AtlasTextureKind::Subpixel | AtlasTextureKind::Stream => unreachable!(), + }; + textures[id.index as usize].as_ref().unwrap() + } ++ ++ fn update_stream(&mut self, id: StreamingImageId, frame: &StreamFrame) -> Result { ++ let size = frame.size(); ++ anyhow::ensure!( ++ size.width.0 <= MAX_STREAM_TEXTURE_SIZE && size.height.0 <= MAX_STREAM_TEXTURE_SIZE, ++ "streaming image frame {}x{} exceeds the {MAX_STREAM_TEXTURE_SIZE} pixel texture limit", ++ size.width.0, ++ size.height.0 ++ ); ++ let pixel_format = match frame.format() { ++ StreamingImageFormat::Bgra8 => metal::MTLPixelFormat::BGRA8Unorm, ++ StreamingImageFormat::Rgba8 => metal::MTLPixelFormat::RGBA8Unorm, ++ }; ++ let position = match self.streams.iter().position(|stream| stream.id == id) { ++ Some(position) => { ++ let stream = &mut self.streams[position]; ++ if stream.size != size || stream.pixel_format != pixel_format { ++ *stream = MetalAtlasStream::new(id, stream.index, size, pixel_format); ++ } ++ position ++ } ++ None => { ++ let index = self.next_stream_index; ++ self.next_stream_index = self.next_stream_index.wrapping_add(1); ++ self.streams ++ .push(MetalAtlasStream::new(id, index, size, pixel_format)); ++ self.streams.len() - 1 ++ } ++ }; ++ let stream = &mut self.streams[position]; ++ if stream.slots.is_empty() || stream.generation != frame.generation() { ++ let created = stream.upload(&self.device, self.is_apple_gpu, frame); ++ self.stream_textures_created += created as u64; ++ } ++ Ok(AtlasTile::for_stream(stream.index, size)) ++ } ++} ++ ++/// A streaming image's textures. Frames go into a slot the GPU is not reading; ++/// a second slot exists only while uploads keep finding the current one busy. ++struct MetalAtlasStream { ++ id: StreamingImageId, ++ index: u32, ++ size: Size, ++ pixel_format: metal::MTLPixelFormat, ++ generation: u64, ++ slots: Vec, ++ front: usize, ++ uncontended_uploads: u32, ++} ++ ++struct MetalStreamSlot { ++ texture: AssertSend, ++ in_flight: Arc, ++} ++ ++/// Marks a stream slot as read by a command buffer until dropped. ++pub(crate) struct MetalStreamLease(Arc); ++ ++impl MetalStreamLease { ++ fn new(in_flight: &Arc) -> Self { ++ in_flight.fetch_add(1, Ordering::AcqRel); ++ Self(in_flight.clone()) ++ } ++} ++ ++impl Drop for MetalStreamLease { ++ fn drop(&mut self) { ++ self.0.fetch_sub(1, Ordering::AcqRel); ++ } ++} ++ ++impl MetalAtlasStream { ++ fn new( ++ id: StreamingImageId, ++ index: u32, ++ size: Size, ++ pixel_format: metal::MTLPixelFormat, ++ ) -> Self { ++ Self { ++ id, ++ index, ++ size, ++ pixel_format, ++ generation: 0, ++ slots: Vec::new(), ++ front: 0, ++ uncontended_uploads: 0, ++ } ++ } ++ ++ /// Writes `frame` into a slot no command buffer is reading and makes it ++ /// the front slot, without ever waiting on the GPU. Returns how many ++ /// textures it created. ++ fn upload( ++ &mut self, ++ device: &metal::DeviceRef, ++ is_apple_gpu: bool, ++ frame: &StreamFrame, ++ ) -> u32 { ++ let frame_bytes = self.size.width.0 as u64 * self.size.height.0 as u64 * 4; ++ let max_slots = if frame_bytes > SINGLE_SLOT_STREAM_BYTES { ++ 1 ++ } else { ++ 2 ++ }; ++ let is_free = |slot: &MetalStreamSlot| slot.in_flight.load(Ordering::Acquire) == 0; ++ let mut created = 0; ++ let target = if self.slots.get(self.front).is_some_and(is_free) { ++ self.uncontended_uploads += 1; ++ self.front ++ } else { ++ self.uncontended_uploads = 0; ++ if let Some(index) = self.slots.iter().position(is_free) { ++ index ++ } else if self.slots.len() < max_slots { ++ created += 1; ++ self.slots.push(self.new_slot(device, is_apple_gpu)); ++ self.slots.len() - 1 ++ } else { ++ created += 1; ++ let index = (self.front + 1) % self.slots.len(); ++ self.slots[index] = self.new_slot(device, is_apple_gpu); ++ index ++ } ++ }; ++ ++ let region = ++ metal::MTLRegion::new_2d(0, 0, self.size.width.0 as u64, self.size.height.0 as u64); ++ self.slots[target].texture.replace_region( ++ region, ++ 0, ++ frame.bytes().as_ptr() as *const _, ++ frame.stride() as u64, ++ ); ++ self.front = target; ++ self.generation = frame.generation(); ++ ++ if self.uncontended_uploads >= SPARE_SLOT_RELEASE_UPLOADS && self.slots.len() > 1 { ++ self.slots.swap(0, self.front); ++ self.slots.truncate(1); ++ self.front = 0; ++ self.uncontended_uploads = 0; ++ } ++ created ++ } ++ ++ fn new_slot(&self, device: &metal::DeviceRef, is_apple_gpu: bool) -> MetalStreamSlot { ++ let descriptor = metal::TextureDescriptor::new(); ++ descriptor.set_width(self.size.width.0 as u64); ++ descriptor.set_height(self.size.height.0 as u64); ++ descriptor.set_pixel_format(self.pixel_format); ++ descriptor.set_usage(metal::MTLTextureUsage::ShaderRead); ++ descriptor.set_storage_mode(if is_apple_gpu { ++ metal::MTLStorageMode::Shared ++ } else { ++ metal::MTLStorageMode::Managed ++ }); ++ MetalStreamSlot { ++ texture: AssertSend(device.new_texture(&descriptor)), ++ in_flight: Arc::default(), ++ } ++ } + } + + struct MetalAtlasTexture { +@@ -295,7 +518,7 @@ unsafe impl Send for AssertSend {} + #[cfg(test)] + mod tests { + use super::*; +- use gpui::PlatformAtlas; ++ use gpui::{PlatformAtlas, StreamingImage}; + use std::borrow::Cow; + + fn create_atlas() -> Option { +@@ -396,4 +619,355 @@ mod tests { + let key = make_image_key(999, 0); + atlas.remove(&key); + } ++ fn stream_size(width: i32, height: i32) -> Size { ++ Size { ++ width: DevicePixels(width), ++ height: DevicePixels(height), ++ } ++ } ++ ++ fn set_stream_frame( ++ image: &StreamingImage, ++ size: Size, ++ value: u8, ++ ) -> StreamFrame { ++ let stride = size.width.0 as usize * 4; ++ image ++ .set_frame(size, stride, vec![value; stride * size.height.0 as usize]) ++ .unwrap(); ++ image.current().unwrap() ++ } ++ ++ fn update_stream(atlas: &MetalAtlas, image: &StreamingImage, frame: &StreamFrame) -> AtlasTile { ++ atlas ++ .get_or_update_stream(image.id(), frame) ++ .expect("stream upload should succeed") ++ .expect("stream upload returns a tile") ++ } ++ ++ fn stream_slot_textures(atlas: &MetalAtlas, image: &StreamingImage) -> Vec { ++ use metal::foreign_types::ForeignType as _; ++ let lock = atlas.0.lock(); ++ let stream = lock ++ .streams ++ .iter() ++ .find(|stream| stream.id == image.id()) ++ .expect("stream should exist"); ++ stream ++ .slots ++ .iter() ++ .map(|slot| slot.texture.as_ptr() as usize) ++ .collect() ++ } ++ ++ fn stream_textures_created(atlas: &MetalAtlas) -> u64 { ++ atlas.0.lock().stream_textures_created ++ } ++ ++ fn read_texture(device: &metal::DeviceRef, texture: &metal::TextureRef) -> Vec { ++ let queue = device.new_command_queue(); ++ let command_buffer = queue.new_command_buffer(); ++ let buffer = read_back_into_new_buffer(device, command_buffer, texture); ++ command_buffer.commit(); ++ command_buffer.wait_until_completed(); ++ buffer_bytes(&buffer) ++ } ++ ++ fn read_back_into_new_buffer( ++ device: &metal::DeviceRef, ++ command_buffer: &metal::CommandBufferRef, ++ texture: &metal::TextureRef, ++ ) -> metal::Buffer { ++ let row_bytes = texture.width() * 4; ++ let len = row_bytes * texture.height(); ++ let buffer = device.new_buffer(len, metal::MTLResourceOptions::StorageModeShared); ++ let blit = command_buffer.new_blit_command_encoder(); ++ blit.copy_from_texture_to_buffer( ++ texture, ++ 0, ++ 0, ++ metal::MTLOrigin { x: 0, y: 0, z: 0 }, ++ metal::MTLSize { ++ width: texture.width(), ++ height: texture.height(), ++ depth: 1, ++ }, ++ &buffer, ++ 0, ++ row_bytes, ++ len, ++ metal::MTLBlitOption::None, ++ ); ++ blit.end_encoding(); ++ buffer ++ } ++ ++ fn buffer_bytes(buffer: &metal::BufferRef) -> Vec { ++ unsafe { ++ std::slice::from_raw_parts(buffer.contents() as *const u8, buffer.length() as usize) ++ .to_vec() ++ } ++ } ++ ++ #[test] ++ fn stream_updates_of_the_same_size_reuse_one_texture() { ++ let Some(atlas) = create_atlas() else { ++ return; ++ }; ++ let image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ let size = stream_size(96, 54); ++ ++ let first = update_stream(&atlas, &image, &set_stream_frame(&image, size, 1)); ++ let textures = stream_slot_textures(&atlas, &image); ++ for value in 2..10 { ++ let tile = update_stream(&atlas, &image, &set_stream_frame(&image, size, value)); ++ assert_eq!(tile, first); ++ } ++ ++ assert_eq!(first.texture_id.kind, AtlasTextureKind::Stream); ++ assert_eq!(first.bounds.size, size); ++ assert_eq!(stream_slot_textures(&atlas, &image), textures); ++ assert_eq!(textures.len(), 1); ++ assert_eq!(stream_textures_created(&atlas), 1); ++ let (texture, _lease) = atlas.lease_stream_texture(first.texture_id).unwrap(); ++ assert!( ++ read_texture(&atlas.0.lock().device, &texture) ++ .iter() ++ .all(|&byte| byte == 9) ++ ); ++ } ++ ++ #[test] ++ fn stream_skips_upload_when_the_generation_is_unchanged() { ++ let Some(atlas) = create_atlas() else { ++ return; ++ }; ++ let image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ let size = stream_size(8, 8); ++ let frame = set_stream_frame(&image, size, 1); ++ let tile = update_stream(&atlas, &image, &frame); ++ let (texture, _lease) = atlas.lease_stream_texture(tile.texture_id).unwrap(); ++ texture.replace_region( ++ metal::MTLRegion::new_2d(0, 0, 8, 8), ++ 0, ++ vec![5u8; 8 * 8 * 4].as_ptr() as *const _, ++ 32, ++ ); ++ ++ assert_eq!(update_stream(&atlas, &image, &frame), tile); ++ let device = atlas.0.lock().device.0.clone(); ++ assert!( ++ read_texture(&device, &texture) ++ .iter() ++ .all(|&byte| byte == 5) ++ ); ++ } ++ ++ #[test] ++ fn stream_size_change_reallocates_once() { ++ let Some(atlas) = create_atlas() else { ++ return; ++ }; ++ let image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ let small = update_stream( ++ &atlas, ++ &image, ++ &set_stream_frame(&image, stream_size(64, 36), 1), ++ ); ++ let small_textures = stream_slot_textures(&atlas, &image); ++ ++ let large_size = stream_size(128, 72); ++ let large = update_stream(&atlas, &image, &set_stream_frame(&image, large_size, 2)); ++ let large_textures = stream_slot_textures(&atlas, &image); ++ update_stream(&atlas, &image, &set_stream_frame(&image, large_size, 3)); ++ ++ assert_eq!(large.texture_id, small.texture_id); ++ assert_eq!(large.bounds.size, large_size); ++ assert_ne!(large_textures, small_textures); ++ assert_eq!(stream_slot_textures(&atlas, &image), large_textures); ++ assert_eq!(stream_textures_created(&atlas), 2); ++ } ++ ++ #[test] ++ fn stream_format_change_reallocates() { ++ let Some(atlas) = create_atlas() else { ++ return; ++ }; ++ let size = stream_size(16, 16); ++ let bgra = StreamingImage::new(StreamingImageFormat::Bgra8); ++ let rgba = StreamingImage::new(StreamingImageFormat::Rgba8); ++ let bgra_tile = update_stream(&atlas, &bgra, &set_stream_frame(&bgra, size, 1)); ++ let rgba_tile = update_stream(&atlas, &rgba, &set_stream_frame(&rgba, size, 1)); ++ ++ let (bgra_texture, _) = atlas.lease_stream_texture(bgra_tile.texture_id).unwrap(); ++ let (rgba_texture, _) = atlas.lease_stream_texture(rgba_tile.texture_id).unwrap(); ++ assert_ne!(bgra_tile.texture_id, rgba_tile.texture_id); ++ assert_eq!( ++ bgra_texture.pixel_format(), ++ metal::MTLPixelFormat::BGRA8Unorm ++ ); ++ assert_eq!( ++ rgba_texture.pixel_format(), ++ metal::MTLPixelFormat::RGBA8Unorm ++ ); ++ } ++ ++ #[test] ++ fn images_and_streams_never_share_textures() { ++ let Some(atlas) = create_atlas() else { ++ return; ++ }; ++ let small = stream_size(64, 64); ++ let image_key = make_image_key(1, 0); ++ let image_tile = insert_tile(&atlas, &image_key, small); ++ let image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ let stream_tile = update_stream(&atlas, &image, &set_stream_frame(&image, small, 1)); ++ let second_image_tile = insert_tile(&atlas, &make_image_key(2, 0), small); ++ ++ assert_eq!(image_tile.texture_id.kind, AtlasTextureKind::Polychrome); ++ assert_eq!(second_image_tile.texture_id, image_tile.texture_id); ++ assert_eq!(stream_tile.texture_id.kind, AtlasTextureKind::Stream); ++ assert_eq!( ++ atlas ++ .0 ++ .lock() ++ .polychrome_textures ++ .textures ++ .iter() ++ .flatten() ++ .count(), ++ 1 ++ ); ++ ++ atlas.remove(&image_key); ++ assert!(atlas.lease_stream_texture(stream_tile.texture_id).is_some()); ++ atlas.remove_stream(image.id()); ++ assert!(atlas.lease_stream_texture(stream_tile.texture_id).is_none()); ++ let _texture = atlas.metal_texture(second_image_tile.texture_id); ++ atlas.remove_stream(image.id()); ++ } ++ ++ #[test] ++ fn removed_streams_upload_again_with_a_fresh_texture_index() { ++ let Some(atlas) = create_atlas() else { ++ return; ++ }; ++ let image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ let frame = set_stream_frame(&image, stream_size(8, 8), 1); ++ let first = update_stream(&atlas, &image, &frame); ++ atlas.remove_stream(image.id()); ++ let second = update_stream(&atlas, &image, &frame); ++ ++ assert_ne!(first.texture_id, second.texture_id); ++ assert!(atlas.lease_stream_texture(first.texture_id).is_none()); ++ assert!(atlas.lease_stream_texture(second.texture_id).is_some()); ++ assert_eq!(stream_textures_created(&atlas), 2); ++ } ++ ++ #[test] ++ fn oversized_stream_frames_are_rejected() { ++ let Some(atlas) = create_atlas() else { ++ return; ++ }; ++ let image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ let size = stream_size(MAX_STREAM_TEXTURE_SIZE + 1, 1); ++ let stride = size.width.0 as usize * 4; ++ image.set_frame(size, stride, vec![0; stride]).unwrap(); ++ assert!( ++ atlas ++ .get_or_update_stream(image.id(), &image.current().unwrap()) ++ .is_err() ++ ); ++ } ++ ++ fn assert_upload_waits_for_no_reader(is_apple_gpu: bool) { ++ let Some(device) = metal::Device::system_default() else { ++ return; ++ }; ++ let atlas = MetalAtlas::new(device.clone(), is_apple_gpu); ++ let image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ let size = stream_size(64, 64); ++ let tile = update_stream(&atlas, &image, &set_stream_frame(&image, size, 1)); ++ ++ let queue = device.new_command_queue(); ++ let command_buffer = queue.new_command_buffer(); ++ let (texture, lease) = atlas.lease_stream_texture(tile.texture_id).unwrap(); ++ let first_read = read_back_into_new_buffer(&device, command_buffer, &texture); ++ ++ assert_eq!( ++ update_stream(&atlas, &image, &set_stream_frame(&image, size, 2)), ++ tile ++ ); ++ command_buffer.commit(); ++ command_buffer.wait_until_completed(); ++ drop(lease); ++ ++ assert!(buffer_bytes(&first_read).iter().all(|&byte| byte == 1)); ++ assert_eq!(stream_slot_textures(&atlas, &image).len(), 2); ++ assert_eq!(stream_textures_created(&atlas), 2); ++ let (texture, _lease) = atlas.lease_stream_texture(tile.texture_id).unwrap(); ++ assert!( ++ read_texture(&device, &texture) ++ .iter() ++ .all(|&byte| byte == 2) ++ ); ++ ++ update_stream(&atlas, &image, &set_stream_frame(&image, size, 3)); ++ assert_eq!(stream_textures_created(&atlas), 2); ++ } ++ ++ #[test] ++ fn stream_upload_skips_a_slot_an_uncommitted_command_buffer_reads() { ++ assert_upload_waits_for_no_reader(true); ++ } ++ ++ #[test] ++ fn stream_upload_skips_a_slot_an_uncommitted_command_buffer_reads_managed() { ++ assert_upload_waits_for_no_reader(false); ++ } ++ ++ #[test] ++ fn stream_releases_its_spare_slot_once_uploads_stop_contending() { ++ let Some(atlas) = create_atlas() else { ++ return; ++ }; ++ let image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ let size = stream_size(32, 32); ++ let tile = update_stream(&atlas, &image, &set_stream_frame(&image, size, 0)); ++ let lease = atlas.lease_stream_texture(tile.texture_id).unwrap(); ++ update_stream(&atlas, &image, &set_stream_frame(&image, size, 1)); ++ drop(lease); ++ assert_eq!(stream_slot_textures(&atlas, &image).len(), 2); ++ ++ for value in 0..SPARE_SLOT_RELEASE_UPLOADS { ++ update_stream(&atlas, &image, &set_stream_frame(&image, size, value as u8)); ++ } ++ assert_eq!(stream_slot_textures(&atlas, &image).len(), 1); ++ let (texture, _lease) = atlas.lease_stream_texture(tile.texture_id).unwrap(); ++ let expected = (SPARE_SLOT_RELEASE_UPLOADS - 1) as u8; ++ let device = atlas.0.lock().device.0.clone(); ++ assert!( ++ read_texture(&device, &texture) ++ .iter() ++ .all(|&byte| byte == expected) ++ ); ++ } ++ ++ #[test] ++ fn large_streams_keep_a_single_slot_and_never_block() { ++ let Some(atlas) = create_atlas() else { ++ return; ++ }; ++ let image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ let size = stream_size(3840, 2304); ++ let tile = update_stream(&atlas, &image, &set_stream_frame(&image, size, 1)); ++ let (held, lease) = atlas.lease_stream_texture(tile.texture_id).unwrap(); ++ ++ update_stream(&atlas, &image, &set_stream_frame(&image, size, 2)); ++ assert_eq!(stream_slot_textures(&atlas, &image).len(), 1); ++ assert_eq!(stream_textures_created(&atlas), 2); ++ let device = atlas.0.lock().device.0.clone(); ++ assert!(read_texture(&device, &held).iter().all(|&byte| byte == 1)); ++ drop(lease); ++ } + } +diff --git a/crates/gpui_macos/src/metal_renderer.rs b/crates/gpui_macos/src/metal_renderer.rs +index 1d35af1..57afb61 100644 +--- a/crates/gpui_macos/src/metal_renderer.rs ++++ b/crates/gpui_macos/src/metal_renderer.rs +@@ -1,4 +1,4 @@ +-use crate::metal_atlas::MetalAtlas; ++use crate::metal_atlas::{MetalAtlas, MetalStreamLease}; + use anyhow::Result; + use block::ConcreteBlock; + use cocoa::{ +@@ -7,9 +7,9 @@ use cocoa::{ + quartzcore::AutoresizingMask, + }; + use gpui::{ +- AtlasTextureId, BackdropBlur, Background, Bounds, ContentMask, Corners, DevicePixels, +- DrawOrder, MonochromeSprite, PaintSurface, Path, Point, PolychromeSprite, PrimitiveBatch, Quad, +- ScaledPixels, Scene, Shadow, Size, Underline, point, size, ++ AtlasTextureId, AtlasTextureKind, BackdropBlur, Background, Bounds, ContentMask, Corners, ++ DevicePixels, DrawOrder, MonochromeSprite, PaintSurface, Path, Point, PolychromeSprite, ++ PrimitiveBatch, Quad, ScaledPixels, Scene, Shadow, Size, Underline, point, size, + }; + #[cfg(any(test, feature = "test-support"))] + use image::RgbaImage; +@@ -928,6 +928,7 @@ impl MetalRenderer { + let alpha = if self.opaque { 1. } else { 0. }; + let mut instance_offset = 0; + let mut retained_surface_textures = CommandBufferResources::default(); ++ let mut retained_stream_leases = CommandBufferResources::default(); + + // Big scratch textures are only worth holding while they're in use: + // after a run of frames that don't need them, drop them and let the +@@ -1141,6 +1142,7 @@ impl MetalRenderer { + &mut instance_offset, + viewport_size, + command_encoder, ++ &mut retained_stream_leases, + ), + PrimitiveBatch::Surfaces(range) => self.draw_surfaces( + &scene.surfaces[range], +@@ -1181,6 +1183,7 @@ impl MetalRenderer { + self.maybe_log_gpu_stats(); + + retained_surface_textures.retain_until_completion(command_buffer); ++ retained_stream_leases.retain_until_completion(command_buffer); + + Ok(command_buffer.to_owned()) + } +@@ -1217,6 +1220,8 @@ impl MetalRenderer { + 1 + }; + let (atlas_textures, atlas_bytes) = self.sprite_atlas.texture_stats(); ++ let (stream_textures, stream_bytes, stream_textures_created) = ++ self.sprite_atlas.stream_stats(); + let (pool_buffers, pool_buffer_size) = { + let pool = self.instance_buffer_pool.lock(); + (pool.buffers.len(), pool.buffer_size) +@@ -1224,7 +1229,7 @@ impl MetalRenderer { + // stderr, not `log`: consumers (comet) run tracing without a log + // bridge, and this line only exists when explicitly opted into. + eprintln!( +- "[gpu-stats] device: {:.1} MB; atlas: {} textures, {:.1} MB; instance pool: {} × {:.1} MB; backdrop scratch: {:.1} MB; path intermediate: {:.1} MB", ++ "[gpu-stats] device: {:.1} MB; atlas: {} textures, {:.1} MB; instance pool: {} × {:.1} MB; backdrop scratch: {:.1} MB; path intermediate: {:.1} MB; streams: {} textures, {:.1} MB, {} created", + self.device.current_allocated_size() as f64 / (1024.0 * 1024.0), + atlas_textures, + atlas_bytes as f64 / (1024.0 * 1024.0), +@@ -1232,6 +1237,9 @@ impl MetalRenderer { + pool_buffer_size as f64 / (1024.0 * 1024.0), + backdrop_bytes as f64 / (1024.0 * 1024.0), + path_bytes as f64 / (1024.0 * 1024.0), ++ stream_textures, ++ stream_bytes as f64 / (1024.0 * 1024.0), ++ stream_textures_created, + ); + } + +@@ -1832,13 +1840,22 @@ impl MetalRenderer { + instance_offset: &mut usize, + viewport_size: Size, + command_encoder: &metal::RenderCommandEncoderRef, ++ retained_stream_leases: &mut CommandBufferResources, + ) -> bool { + if sprites.is_empty() { + return true; + } + align_offset(instance_offset); + +- let texture = self.sprite_atlas.metal_texture(texture_id); ++ let texture = if texture_id.kind == AtlasTextureKind::Stream { ++ let Some((texture, lease)) = self.sprite_atlas.lease_stream_texture(texture_id) else { ++ return true; ++ }; ++ retained_stream_leases.push(lease); ++ texture ++ } else { ++ self.sprite_atlas.metal_texture(texture_id) ++ }; + let texture_size = size( + DevicePixels(texture.width() as i32), + DevicePixels(texture.height() as i32), +@@ -2583,6 +2600,7 @@ mod backdrop_blur_tests { + border_color: transparent_black(), + corner_radii: Corners::default(), + border_widths: Edges::default(), ++ fade: Default::default(), + }); + } + +@@ -2748,3 +2766,144 @@ mod backdrop_blur_tests { + ); + } + } ++ ++#[cfg(test)] ++mod streaming_image_tests { ++ use super::*; ++ use gpui::{ ++ AtlasKey, AtlasTile, ImageId, PlatformAtlas, RenderImageParams, StreamingImage, ++ StreamingImageFormat, ++ }; ++ use image::RgbaImage; ++ use std::borrow::Cow; ++ ++ const VIEW: i32 = 96; ++ const WIDTH: i32 = 40; ++ const HEIGHT: i32 = 30; ++ ++ fn new_renderer() -> Option { ++ metal::Device::system_default()?; ++ Some( ++ MetalRenderer::new_headless(Arc::new(Mutex::new(InstanceBufferPool::default()))) ++ .expect("failed to initialize headless renderer"), ++ ) ++ } ++ ++ fn bgra_pattern() -> Vec { ++ let mut seed = 0x2545_f491_u32; ++ let mut bytes = Vec::with_capacity((WIDTH * HEIGHT * 4) as usize); ++ for _ in 0..WIDTH * HEIGHT { ++ for _ in 0..3 { ++ seed ^= seed << 13; ++ seed ^= seed >> 17; ++ seed ^= seed << 5; ++ bytes.push(seed as u8); ++ } ++ bytes.push(255); ++ } ++ bytes ++ } ++ ++ fn frame_size() -> Size { ++ size(DevicePixels(WIDTH), DevicePixels(HEIGHT)) ++ } ++ ++ fn render(renderer: &mut MetalRenderer, tile: Option) -> RgbaImage { ++ let viewport = Bounds { ++ origin: point(ScaledPixels(0.), ScaledPixels(0.)), ++ size: size(ScaledPixels(VIEW as f32), ScaledPixels(VIEW as f32)), ++ }; ++ let mut scene = Scene::default(); ++ if let Some(tile) = tile { ++ scene.insert_primitive(PolychromeSprite { ++ order: 0, ++ pad: 0, ++ grayscale: false.into(), ++ opacity: 1., ++ bounds: Bounds { ++ origin: point(ScaledPixels(10.), ScaledPixels(12.)), ++ size: size(ScaledPixels(WIDTH as f32), ScaledPixels(HEIGHT as f32)), ++ }, ++ content_mask: ContentMask { bounds: viewport }, ++ corner_radii: Corners::default(), ++ fade: Default::default(), ++ tile, ++ }); ++ } ++ scene.finish(); ++ renderer ++ .render_scene_to_image(&scene, size(DevicePixels(VIEW), DevicePixels(VIEW))) ++ .expect("render_scene_to_image failed") ++ } ++ ++ #[test] ++ fn stream_sprites_render_exactly_like_atlas_images() { ++ let Some(mut renderer) = new_renderer() else { ++ return; ++ }; ++ let atlas = renderer.sprite_atlas().clone(); ++ let bgra = bgra_pattern(); ++ let image_tile = atlas ++ .get_or_insert_with( ++ &AtlasKey::Image(RenderImageParams { ++ image_id: ImageId(usize::MAX), ++ frame_index: 0, ++ }), ++ &mut || Ok(Some((frame_size(), Cow::Borrowed(bgra.as_slice())))), ++ ) ++ .unwrap() ++ .unwrap(); ++ ++ let bgra_stream = StreamingImage::new(StreamingImageFormat::Bgra8); ++ bgra_stream ++ .set_frame(frame_size(), WIDTH as usize * 4, bgra.clone()) ++ .unwrap(); ++ let bgra_tile = atlas ++ .get_or_update_stream(bgra_stream.id(), &bgra_stream.current().unwrap()) ++ .unwrap() ++ .unwrap(); ++ ++ let stride = WIDTH as usize * 4 + 64; ++ let mut rgba = vec![0x5a; stride * HEIGHT as usize]; ++ for (row, source) in rgba ++ .chunks_exact_mut(stride) ++ .zip(bgra.chunks_exact(WIDTH as usize * 4)) ++ { ++ for (pixel, source) in row.chunks_exact_mut(4).zip(source.chunks_exact(4)) { ++ pixel.copy_from_slice(&[source[2], source[1], source[0], source[3]]); ++ } ++ } ++ let rgba_stream = StreamingImage::new(StreamingImageFormat::Rgba8); ++ rgba_stream.set_frame(frame_size(), stride, rgba).unwrap(); ++ let rgba_tile = atlas ++ .get_or_update_stream(rgba_stream.id(), &rgba_stream.current().unwrap()) ++ .unwrap() ++ .unwrap(); ++ ++ let expected = render(&mut renderer, Some(image_tile)); ++ let pixel = expected.get_pixel(10, 12); ++ assert_eq!(pixel.0, [bgra[2], bgra[1], bgra[0], 255]); ++ assert_ne!(expected, render(&mut renderer, None)); ++ assert!(render(&mut renderer, Some(bgra_tile)) == expected); ++ assert!(render(&mut renderer, Some(rgba_tile)) == expected); ++ } ++ ++ #[test] ++ fn removed_stream_batches_are_skipped() { ++ let Some(mut renderer) = new_renderer() else { ++ return; ++ }; ++ let atlas = renderer.sprite_atlas().clone(); ++ let stream = StreamingImage::new(StreamingImageFormat::Bgra8); ++ stream ++ .set_frame(frame_size(), WIDTH as usize * 4, bgra_pattern()) ++ .unwrap(); ++ let tile = atlas ++ .get_or_update_stream(stream.id(), &stream.current().unwrap()) ++ .unwrap() ++ .unwrap(); ++ atlas.remove_stream(stream.id()); ++ ++ assert!(render(&mut renderer, Some(tile)) == render(&mut renderer, None)); ++ } ++} +diff --git a/crates/gpui_wgpu/src/wgpu_atlas.rs b/crates/gpui_wgpu/src/wgpu_atlas.rs +index 3cf71ac..413add7 100644 +--- a/crates/gpui_wgpu/src/wgpu_atlas.rs ++++ b/crates/gpui_wgpu/src/wgpu_atlas.rs +@@ -3,7 +3,7 @@ use collections::FxHashMap; + use etagere::{BucketedAtlasAllocator, size2}; + use gpui::{ + AtlasKey, AtlasTextureId, AtlasTextureKind, AtlasTextureList, AtlasTile, Bounds, DevicePixels, +- PlatformAtlas, Point, Size, ++ PlatformAtlas, Point, Size, StreamFrame, StreamingImageFormat, StreamingImageId, + }; + use parking_lot::Mutex; + use std::{borrow::Cow, ops, sync::Arc}; +@@ -23,6 +23,14 @@ fn etagere_point_to_device(point: etagere::Point) -> Point { + + pub struct WgpuAtlas(Mutex); + ++#[cfg(not(test))] ++const STREAM_TEXTURE_USAGES: wgpu::TextureUsages = ++ wgpu::TextureUsages::TEXTURE_BINDING.union(wgpu::TextureUsages::COPY_DST); ++#[cfg(test)] ++const STREAM_TEXTURE_USAGES: wgpu::TextureUsages = wgpu::TextureUsages::TEXTURE_BINDING ++ .union(wgpu::TextureUsages::COPY_DST) ++ .union(wgpu::TextureUsages::COPY_SRC); ++ + struct PendingUpload { + id: AtlasTextureId, + bounds: Bounds, +@@ -37,6 +45,22 @@ struct WgpuAtlasState { + storage: WgpuAtlasStorage, + tiles_by_key: FxHashMap, + pending_uploads: Vec, ++ streams: Vec, ++ next_stream_index: u32, ++} ++ ++/// A streaming image's texture. Frames are written straight into it while ++/// painting; wgpu orders those writes after earlier submissions that sample it. ++struct WgpuAtlasStream { ++ id: StreamingImageId, ++ index: u32, ++ size: Size, ++ format: wgpu::TextureFormat, ++ swizzle: bool, ++ generation: u64, ++ texture: wgpu::Texture, ++ view: wgpu::TextureView, ++ scratch: Vec, + } + + pub struct WgpuTextureInfo { +@@ -58,6 +82,8 @@ impl WgpuAtlas { + storage: WgpuAtlasStorage::default(), + tiles_by_key: Default::default(), + pending_uploads: Vec::new(), ++ streams: Vec::new(), ++ next_stream_index: 0, + })) + } + +@@ -82,6 +108,19 @@ impl WgpuAtlas { + } + } + ++ /// The view of a streaming image's texture, or `None` once the stream has ++ /// been removed. ++ pub fn get_stream_texture_info(&self, id: AtlasTextureId) -> Option { ++ let lock = self.0.lock(); ++ let stream = lock ++ .streams ++ .iter() ++ .find(|stream| stream.index == id.index)?; ++ Some(WgpuTextureInfo { ++ view: stream.view.clone(), ++ }) ++ } ++ + /// Clears all cached textures and tiles, forcing them to be recreated. + /// Use this for incremental recovery when the device is still valid. + pub fn clear(&self) { +@@ -89,6 +128,7 @@ impl WgpuAtlas { + lock.storage = WgpuAtlasStorage::default(); + lock.tiles_by_key.clear(); + lock.pending_uploads.clear(); ++ lock.streams.clear(); + } + + /// Handles device lost by clearing all textures and cached tiles. +@@ -101,6 +141,7 @@ impl WgpuAtlas { + lock.storage = WgpuAtlasStorage::default(); + lock.tiles_by_key.clear(); + lock.pending_uploads.clear(); ++ lock.streams.clear(); + } + } + +@@ -153,9 +194,106 @@ impl PlatformAtlas for WgpuAtlas { + } + } + } ++ ++ fn get_or_update_stream( ++ &self, ++ id: StreamingImageId, ++ frame: &StreamFrame, ++ ) -> Result> { ++ self.0.lock().update_stream(id, frame).map(Some) ++ } ++ ++ fn remove_stream(&self, id: StreamingImageId) { ++ self.0.lock().streams.retain(|stream| stream.id != id); ++ } + } + + impl WgpuAtlasState { ++ fn update_stream(&mut self, id: StreamingImageId, frame: &StreamFrame) -> Result { ++ let size = frame.size(); ++ anyhow::ensure!( ++ size.width.0 as u32 <= self.max_texture_size ++ && size.height.0 as u32 <= self.max_texture_size, ++ "streaming image frame {}x{} exceeds the {} pixel texture limit", ++ size.width.0, ++ size.height.0, ++ self.max_texture_size ++ ); ++ u32::try_from(frame.stride()).context("streaming image stride is too large")?; ++ let (format, swizzle) = match frame.format() { ++ StreamingImageFormat::Rgba8 => (wgpu::TextureFormat::Rgba8Unorm, false), ++ StreamingImageFormat::Bgra8 ++ if self.color_texture_format == wgpu::TextureFormat::Bgra8Unorm => ++ { ++ (wgpu::TextureFormat::Bgra8Unorm, false) ++ } ++ StreamingImageFormat::Bgra8 => (wgpu::TextureFormat::Rgba8Unorm, true), ++ }; ++ let position = self.streams.iter().position(|stream| stream.id == id); ++ let position = match position { ++ Some(position) ++ if self.streams[position].size == size ++ && self.streams[position].format == format ++ && self.streams[position].swizzle == swizzle => ++ { ++ position ++ } ++ Some(position) => { ++ let index = self.streams[position].index; ++ self.streams[position] = self.new_stream(id, index, size, format, swizzle); ++ position ++ } ++ None => { ++ let index = self.next_stream_index; ++ self.next_stream_index = self.next_stream_index.wrapping_add(1); ++ let stream = self.new_stream(id, index, size, format, swizzle); ++ self.streams.push(stream); ++ self.streams.len() - 1 ++ } ++ }; ++ let stream = &mut self.streams[position]; ++ if stream.generation != frame.generation() { ++ stream.write(&self.queue, frame); ++ } ++ Ok(AtlasTile::for_stream(stream.index, size)) ++ } ++ ++ fn new_stream( ++ &self, ++ id: StreamingImageId, ++ index: u32, ++ size: Size, ++ format: wgpu::TextureFormat, ++ swizzle: bool, ++ ) -> WgpuAtlasStream { ++ let texture = self.device.create_texture(&wgpu::TextureDescriptor { ++ label: Some("streaming_image"), ++ size: wgpu::Extent3d { ++ width: size.width.0 as u32, ++ height: size.height.0 as u32, ++ depth_or_array_layers: 1, ++ }, ++ mip_level_count: 1, ++ sample_count: 1, ++ dimension: wgpu::TextureDimension::D2, ++ format, ++ usage: STREAM_TEXTURE_USAGES, ++ view_formats: &[], ++ }); ++ let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); ++ WgpuAtlasStream { ++ id, ++ index, ++ size, ++ format, ++ swizzle, ++ generation: 0, ++ texture, ++ view, ++ scratch: Vec::new(), ++ } ++ } ++ + fn allocate( + &mut self, + size: Size, +@@ -196,6 +334,7 @@ impl WgpuAtlasState { + let format = match kind { + AtlasTextureKind::Monochrome => wgpu::TextureFormat::R8Unorm, + AtlasTextureKind::Subpixel | AtlasTextureKind::Polychrome => self.color_texture_format, ++ AtlasTextureKind::Stream => unreachable!(), + }; + + let texture = self.device.create_texture(&wgpu::TextureDescriptor { +@@ -306,6 +445,7 @@ impl ops::Index for WgpuAtlasStorage { + AtlasTextureKind::Monochrome => &self.monochrome_textures, + AtlasTextureKind::Subpixel => &self.subpixel_textures, + AtlasTextureKind::Polychrome => &self.polychrome_textures, ++ AtlasTextureKind::Stream => unreachable!(), + } + } + } +@@ -316,6 +456,7 @@ impl ops::IndexMut for WgpuAtlasStorage { + AtlasTextureKind::Monochrome => &mut self.monochrome_textures, + AtlasTextureKind::Subpixel => &mut self.subpixel_textures, + AtlasTextureKind::Polychrome => &mut self.polychrome_textures, ++ AtlasTextureKind::Stream => unreachable!(), + } + } + } +@@ -336,6 +477,7 @@ impl ops::Index for WgpuAtlasStorage { + AtlasTextureKind::Monochrome => &self.monochrome_textures, + AtlasTextureKind::Subpixel => &self.subpixel_textures, + AtlasTextureKind::Polychrome => &self.polychrome_textures, ++ AtlasTextureKind::Stream => unreachable!(), + }; + textures[id.index as usize] + .as_ref() +@@ -343,6 +485,47 @@ impl ops::Index for WgpuAtlasStorage { + } + } + ++impl WgpuAtlasStream { ++ fn write(&mut self, queue: &wgpu::Queue, frame: &StreamFrame) { ++ let width = self.size.width.0 as u32; ++ let height = self.size.height.0 as u32; ++ let (data, bytes_per_row) = if self.swizzle { ++ let row_bytes = width as usize * 4; ++ self.scratch.clear(); ++ self.scratch.reserve(row_bytes * height as usize); ++ for row in frame.bytes().chunks(frame.stride()).take(height as usize) { ++ for pixel in row[..row_bytes].chunks_exact(4) { ++ self.scratch ++ .extend_from_slice(&[pixel[2], pixel[1], pixel[0], pixel[3]]); ++ } ++ } ++ (self.scratch.as_slice(), row_bytes as u32) ++ } else { ++ (frame.bytes(), frame.stride() as u32) ++ }; ++ queue.write_texture( ++ wgpu::TexelCopyTextureInfo { ++ texture: &self.texture, ++ mip_level: 0, ++ origin: wgpu::Origin3d::ZERO, ++ aspect: wgpu::TextureAspect::All, ++ }, ++ data, ++ wgpu::TexelCopyBufferLayout { ++ offset: 0, ++ bytes_per_row: Some(bytes_per_row), ++ rows_per_image: None, ++ }, ++ wgpu::Extent3d { ++ width, ++ height, ++ depth_or_array_layers: 1, ++ }, ++ ); ++ self.generation = frame.generation(); ++ } ++} ++ + struct WgpuAtlasTexture { + id: AtlasTextureId, + allocator: BucketedAtlasAllocator, +@@ -402,13 +585,13 @@ fn swizzle_upload_data(bytes: &[u8], format: wgpu::TextureFormat) -> Vec { + mod tests { + use super::*; + use gpui::block_on; +- use gpui::{ImageId, RenderImageParams}; ++ use gpui::{ImageId, RenderImageParams, StreamingImage}; + use std::sync::Arc; + + fn test_device_and_queue() -> anyhow::Result<(Arc, Arc)> { + block_on(async { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { +- backends: wgpu::Backends::all(), ++ backends: wgpu::Backends::from_env().unwrap_or(wgpu::Backends::all()), + flags: wgpu::InstanceFlags::default(), + backend_options: wgpu::BackendOptions::default(), + memory_budget_thresholds: wgpu::MemoryBudgetThresholds::default(), +@@ -422,6 +605,11 @@ mod tests { + }) + .await + .map_err(|error| anyhow::anyhow!("failed to request adapter: {error}"))?; ++ let info = adapter.get_info(); ++ eprintln!( ++ "wgpu test adapter: {} ({:?}, {:?})", ++ info.name, info.backend, info.device_type ++ ); + let (device, queue) = adapter + .request_device(&wgpu::DeviceDescriptor { + label: Some("wgpu_atlas_test_device"), +@@ -507,6 +695,396 @@ mod tests { + Ok(()) + } + ++ struct StreamTest { ++ device: Arc, ++ queue: Arc, ++ atlas: WgpuAtlas, ++ } ++ ++ fn stream_test(color_texture_format: wgpu::TextureFormat) -> Option { ++ let (device, queue) = match test_device_and_queue() { ++ Ok(device_and_queue) => device_and_queue, ++ Err(error) => { ++ eprintln!("skipping: {error}"); ++ return None; ++ } ++ }; ++ let atlas = WgpuAtlas::new(device.clone(), queue.clone(), color_texture_format); ++ Some(StreamTest { ++ device, ++ queue, ++ atlas, ++ }) ++ } ++ ++ fn stream_size(width: i32, height: i32) -> Size { ++ Size { ++ width: DevicePixels(width), ++ height: DevicePixels(height), ++ } ++ } ++ ++ fn pixels(size: Size, seed: u8) -> Vec { ++ (0..size.width.0 * size.height.0 * 4) ++ .map(|index| (index as u8).wrapping_mul(31).wrapping_add(seed)) ++ .collect() ++ } ++ ++ fn set_frame(image: &StreamingImage, size: Size, bytes: Vec) -> StreamFrame { ++ image ++ .set_frame(size, size.width.0 as usize * 4, bytes) ++ .unwrap(); ++ image.current().unwrap() ++ } ++ ++ fn update(atlas: &WgpuAtlas, image: &StreamingImage, frame: &StreamFrame) -> AtlasTile { ++ atlas ++ .get_or_update_stream(image.id(), frame) ++ .expect("stream upload should succeed") ++ .expect("stream upload returns a tile") ++ } ++ ++ fn stream_texture(atlas: &WgpuAtlas, image: &StreamingImage) -> wgpu::Texture { ++ atlas ++ .0 ++ .lock() ++ .streams ++ .iter() ++ .find(|stream| stream.id == image.id()) ++ .expect("stream should exist") ++ .texture ++ .clone() ++ } ++ ++ fn copy_to_buffer( ++ test: &StreamTest, ++ texture: &wgpu::Texture, ++ bounds: Bounds, ++ ) -> wgpu::Buffer { ++ let bytes_per_row = ++ (bounds.size.width.0 as u32 * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT); ++ let buffer = test.device.create_buffer(&wgpu::BufferDescriptor { ++ label: Some("stream_readback"), ++ size: (bytes_per_row * bounds.size.height.0 as u32) as u64, ++ usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, ++ mapped_at_creation: false, ++ }); ++ let mut encoder = test ++ .device ++ .create_command_encoder(&wgpu::CommandEncoderDescriptor::default()); ++ encoder.copy_texture_to_buffer( ++ wgpu::TexelCopyTextureInfo { ++ texture, ++ mip_level: 0, ++ origin: wgpu::Origin3d { ++ x: bounds.origin.x.0 as u32, ++ y: bounds.origin.y.0 as u32, ++ z: 0, ++ }, ++ aspect: wgpu::TextureAspect::All, ++ }, ++ wgpu::TexelCopyBufferInfo { ++ buffer: &buffer, ++ layout: wgpu::TexelCopyBufferLayout { ++ offset: 0, ++ bytes_per_row: Some(bytes_per_row), ++ rows_per_image: None, ++ }, ++ }, ++ wgpu::Extent3d { ++ width: bounds.size.width.0 as u32, ++ height: bounds.size.height.0 as u32, ++ depth_or_array_layers: 1, ++ }, ++ ); ++ test.queue.submit(Some(encoder.finish())); ++ buffer ++ } ++ ++ fn read_buffer(test: &StreamTest, buffer: &wgpu::Buffer, size: Size) -> Vec { ++ buffer.map_async(wgpu::MapMode::Read, .., |result| result.unwrap()); ++ test.device ++ .poll(wgpu::PollType::wait_indefinitely()) ++ .unwrap(); ++ let row_bytes = size.width.0 as usize * 4; ++ let bytes_per_row = buffer.size() as usize / size.height.0 as usize; ++ let mapped = buffer.get_mapped_range(..); ++ let bytes = mapped ++ .chunks(bytes_per_row) ++ .flat_map(|row| row[..row_bytes].to_vec()) ++ .collect(); ++ drop(mapped); ++ buffer.unmap(); ++ bytes ++ } ++ ++ fn read_stream(test: &StreamTest, image: &StreamingImage, size: Size) -> Vec { ++ let texture = stream_texture(&test.atlas, image); ++ let bounds = Bounds { ++ origin: Point::default(), ++ size, ++ }; ++ let buffer = copy_to_buffer(test, &texture, bounds); ++ read_buffer(test, &buffer, size) ++ } ++ ++ fn swizzled(bytes: &[u8]) -> Vec { ++ bytes ++ .chunks_exact(4) ++ .flat_map(|pixel| [pixel[2], pixel[1], pixel[0], pixel[3]]) ++ .collect() ++ } ++ ++ #[test] ++ fn stream_updates_of_the_same_size_reuse_one_texture() { ++ let Some(test) = stream_test(wgpu::TextureFormat::Bgra8Unorm) else { ++ return; ++ }; ++ let image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ let size = stream_size(64, 36); ++ let first = update( ++ &test.atlas, ++ &image, ++ &set_frame(&image, size, pixels(size, 1)), ++ ); ++ let texture = stream_texture(&test.atlas, &image); ++ for seed in 2..10 { ++ let tile = update( ++ &test.atlas, ++ &image, ++ &set_frame(&image, size, pixels(size, seed)), ++ ); ++ assert_eq!(tile, first); ++ } ++ ++ assert_eq!(first.texture_id.kind, AtlasTextureKind::Stream); ++ assert_eq!(first.bounds.size, size); ++ assert!(stream_texture(&test.atlas, &image) == texture); ++ assert_eq!(read_stream(&test, &image, size), pixels(size, 9)); ++ } ++ ++ #[test] ++ fn stream_size_change_reallocates_once() { ++ let Some(test) = stream_test(wgpu::TextureFormat::Bgra8Unorm) else { ++ return; ++ }; ++ let image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ let small = stream_size(64, 36); ++ let large = stream_size(128, 72); ++ let small_tile = update( ++ &test.atlas, ++ &image, ++ &set_frame(&image, small, pixels(small, 1)), ++ ); ++ let small_texture = stream_texture(&test.atlas, &image); ++ let large_tile = update( ++ &test.atlas, ++ &image, ++ &set_frame(&image, large, pixels(large, 2)), ++ ); ++ let large_texture = stream_texture(&test.atlas, &image); ++ update( ++ &test.atlas, ++ &image, ++ &set_frame(&image, large, pixels(large, 3)), ++ ); ++ ++ assert_eq!(large_tile.texture_id, small_tile.texture_id); ++ assert_eq!(large_tile.bounds.size, large); ++ assert!(large_texture != small_texture); ++ assert!(stream_texture(&test.atlas, &image) == large_texture); ++ assert_eq!(read_stream(&test, &image, large), pixels(large, 3)); ++ } ++ ++ #[test] ++ fn images_and_streams_never_share_textures() { ++ let Some(test) = stream_test(wgpu::TextureFormat::Bgra8Unorm) else { ++ return; ++ }; ++ let size = stream_size(64, 64); ++ let image_key = AtlasKey::Image(RenderImageParams { ++ image_id: ImageId(1), ++ frame_index: 0, ++ }); ++ let image_tile = test ++ .atlas ++ .get_or_insert_with(&image_key, &mut || { ++ Ok(Some((size, Cow::Owned(pixels(size, 1))))) ++ }) ++ .unwrap() ++ .unwrap(); ++ let image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ let stream_tile = update( ++ &test.atlas, ++ &image, ++ &set_frame(&image, size, pixels(size, 2)), ++ ); ++ ++ assert_eq!(image_tile.texture_id.kind, AtlasTextureKind::Polychrome); ++ assert_eq!(stream_tile.texture_id.kind, AtlasTextureKind::Stream); ++ assert_eq!( ++ test.atlas ++ .0 ++ .lock() ++ .storage ++ .polychrome_textures ++ .iter_mut() ++ .count(), ++ 1 ++ ); ++ ++ test.atlas.remove(&image_key); ++ test.atlas.before_frame(); ++ assert!( ++ test.atlas ++ .get_stream_texture_info(stream_tile.texture_id) ++ .is_some() ++ ); ++ test.atlas.remove_stream(image.id()); ++ assert!( ++ test.atlas ++ .get_stream_texture_info(stream_tile.texture_id) ++ .is_none() ++ ); ++ test.atlas.remove_stream(image.id()); ++ } ++ ++ #[test] ++ fn stream_textures_hold_what_atlas_image_tiles_hold() { ++ for atlas_format in [ ++ wgpu::TextureFormat::Bgra8Unorm, ++ wgpu::TextureFormat::Rgba8Unorm, ++ ] { ++ let Some(test) = stream_test(atlas_format) else { ++ return; ++ }; ++ let size = stream_size(64, 32); ++ let bgra = pixels(size, 7); ++ let image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ update(&test.atlas, &image, &set_frame(&image, size, bgra.clone())); ++ ++ assert_eq!(stream_texture(&test.atlas, &image).format(), atlas_format); ++ assert_eq!( ++ read_stream(&test, &image, size), ++ swizzle_upload_data(&bgra, atlas_format) ++ ); ++ } ++ } ++ ++ #[test] ++ fn stream_formats_and_strides_upload_the_same_pixels() { ++ for atlas_format in [ ++ wgpu::TextureFormat::Bgra8Unorm, ++ wgpu::TextureFormat::Rgba8Unorm, ++ ] { ++ let Some(test) = stream_test(atlas_format) else { ++ return; ++ }; ++ let size = stream_size(64, 16); ++ let bgra = pixels(size, 3); ++ let rgba = swizzled(&bgra); ++ ++ let bgra_image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ update( ++ &test.atlas, ++ &bgra_image, ++ &set_frame(&bgra_image, size, bgra.clone()), ++ ); ++ let stride = 64 * 4 + 32; ++ let mut padded = vec![0xee; stride * 16]; ++ for (row, source) in padded ++ .chunks_exact_mut(stride) ++ .zip(rgba.chunks_exact(64 * 4)) ++ { ++ row[..64 * 4].copy_from_slice(source); ++ } ++ let rgba_image = StreamingImage::new(StreamingImageFormat::Rgba8); ++ rgba_image.set_frame(size, stride, padded).unwrap(); ++ update(&test.atlas, &rgba_image, &rgba_image.current().unwrap()); ++ ++ let bgra_texture = stream_texture(&test.atlas, &bgra_image); ++ let rgba_texture = stream_texture(&test.atlas, &rgba_image); ++ assert_eq!(rgba_texture.format(), wgpu::TextureFormat::Rgba8Unorm); ++ let as_rgba = |texture: &wgpu::Texture, bytes: Vec| match texture.format() { ++ wgpu::TextureFormat::Bgra8Unorm => swizzled(&bytes), ++ _ => bytes, ++ }; ++ assert_eq!( ++ as_rgba(&bgra_texture, read_stream(&test, &bgra_image, size)), ++ rgba ++ ); ++ assert_eq!(read_stream(&test, &rgba_image, size), rgba); ++ } ++ } ++ ++ #[test] ++ fn stream_writes_are_ordered_with_submitted_reads() { ++ let Some(test) = stream_test(wgpu::TextureFormat::Bgra8Unorm) else { ++ return; ++ }; ++ let image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ let size = stream_size(64, 64); ++ let bounds = Bounds { ++ origin: Point::default(), ++ size, ++ }; ++ update( ++ &test.atlas, ++ &image, ++ &set_frame(&image, size, pixels(size, 1)), ++ ); ++ let texture = stream_texture(&test.atlas, &image); ++ let first = copy_to_buffer(&test, &texture, bounds); ++ update( ++ &test.atlas, ++ &image, ++ &set_frame(&image, size, pixels(size, 2)), ++ ); ++ let second = copy_to_buffer(&test, &texture, bounds); ++ ++ assert_eq!(read_buffer(&test, &first, size), pixels(size, 1)); ++ assert_eq!(read_buffer(&test, &second, size), pixels(size, 2)); ++ } ++ ++ #[test] ++ fn cleared_atlas_uploads_streams_again() { ++ let Some(test) = stream_test(wgpu::TextureFormat::Bgra8Unorm) else { ++ return; ++ }; ++ let image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ let size = stream_size(64, 8); ++ let frame = set_frame(&image, size, pixels(size, 4)); ++ let tile = update(&test.atlas, &image, &frame); ++ let texture = stream_texture(&test.atlas, &image); ++ ++ test.atlas.clear(); ++ assert!( ++ test.atlas ++ .get_stream_texture_info(tile.texture_id) ++ .is_none() ++ ); ++ let tile = update(&test.atlas, &image, &frame); ++ assert!( ++ test.atlas ++ .get_stream_texture_info(tile.texture_id) ++ .is_some() ++ ); ++ assert!(stream_texture(&test.atlas, &image) != texture); ++ assert_eq!(read_stream(&test, &image, size), pixels(size, 4)); ++ } ++ ++ #[test] ++ fn oversized_stream_frames_are_rejected() { ++ let Some(test) = stream_test(wgpu::TextureFormat::Bgra8Unorm) else { ++ return; ++ }; ++ let image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ let width = test.atlas.0.lock().max_texture_size as i32 + 1; ++ let size = stream_size(width, 1); ++ let frame = set_frame(&image, size, vec![0; width as usize * 4]); ++ assert!(test.atlas.get_or_update_stream(image.id(), &frame).is_err()); ++ } ++ + #[test] + fn swizzle_upload_data_preserves_bgra_uploads() { + let input = vec![0x10, 0x20, 0x30, 0x40]; +diff --git a/crates/gpui_wgpu/src/wgpu_renderer.rs b/crates/gpui_wgpu/src/wgpu_renderer.rs +index 729a5c4..2bfbc4e 100644 +--- a/crates/gpui_wgpu/src/wgpu_renderer.rs ++++ b/crates/gpui_wgpu/src/wgpu_renderer.rs +@@ -2,9 +2,9 @@ use crate::surface_frame::SurfaceFrame; + use crate::{CompositorGpuHint, WgpuAtlas, WgpuContext}; + use bytemuck::{Pod, Zeroable}; + use gpui::{ +- AtlasTextureId, Background, Bounds, DevicePixels, GpuSpecs, MonochromeSprite, Path, Point, +- PolychromeSprite, PrimitiveBatch, Quad, ScaledPixels, Scene, Shadow, Size, SubpixelSprite, +- Underline, get_gamma_correction_ratios, ++ AtlasTextureId, AtlasTextureKind, Background, Bounds, DevicePixels, GpuSpecs, MonochromeSprite, ++ Path, Point, PolychromeSprite, PrimitiveBatch, Quad, ScaledPixels, Scene, Shadow, Size, ++ SubpixelSprite, Underline, get_gamma_correction_ratios, + }; + use log::warn; + #[cfg(not(target_family = "wasm"))] +@@ -1448,7 +1448,14 @@ impl WgpuRenderer { + instance_offset: &mut u64, + pass: &mut wgpu::RenderPass<'_>, + ) -> bool { +- let tex_info = self.atlas.get_texture_info(texture_id); ++ let tex_info = if texture_id.kind == AtlasTextureKind::Stream { ++ let Some(tex_info) = self.atlas.get_stream_texture_info(texture_id) else { ++ return true; ++ }; ++ tex_info ++ } else { ++ self.atlas.get_texture_info(texture_id) ++ }; + let data = unsafe { Self::instance_bytes(sprites) }; + self.draw_instances_with_texture( + data, +diff --git a/crates/gpui_windows/src/directx_atlas.rs b/crates/gpui_windows/src/directx_atlas.rs +index d49ec18..cea181d 100644 +--- a/crates/gpui_windows/src/directx_atlas.rs ++++ b/crates/gpui_windows/src/directx_atlas.rs +@@ -11,9 +11,11 @@ use windows::Win32::Graphics::{ + + use gpui::{ + AtlasKey, AtlasTextureId, AtlasTextureKind, AtlasTextureList, AtlasTile, Bounds, DevicePixels, +- PlatformAtlas, Point, Size, ++ PlatformAtlas, Point, Size, StreamFrame, StreamingImageFormat, StreamingImageId, + }; + ++const MAX_STREAM_TEXTURE_SIZE: i32 = 16384; ++ + pub(crate) struct DirectXAtlas(Mutex); + + struct DirectXAtlasState { +@@ -23,6 +25,20 @@ struct DirectXAtlasState { + polychrome_textures: AtlasTextureList, + subpixel_textures: AtlasTextureList, + tiles_by_key: FxHashMap, ++ streams: Vec, ++ next_stream_index: u32, ++} ++ ++/// A streaming image's texture, written with `UpdateSubresource`; the ++/// immediate context orders each write after earlier draws that sample it. ++struct DirectXAtlasStream { ++ id: StreamingImageId, ++ index: u32, ++ size: Size, ++ format: DXGI_FORMAT, ++ generation: u64, ++ texture: ID3D11Texture2D, ++ view: [Option; 1], + } + + struct DirectXAtlasTexture { +@@ -43,9 +59,23 @@ impl DirectXAtlas { + polychrome_textures: Default::default(), + subpixel_textures: Default::default(), + tiles_by_key: Default::default(), ++ streams: Vec::new(), ++ next_stream_index: 0, + })) + } + ++ pub(crate) fn get_stream_texture_view( ++ &self, ++ id: AtlasTextureId, ++ ) -> Option<[Option; 1]> { ++ let lock = self.0.lock(); ++ let stream = lock ++ .streams ++ .iter() ++ .find(|stream| stream.index == id.index)?; ++ Some(stream.view.clone()) ++ } ++ + pub(crate) fn get_texture_view( + &self, + id: AtlasTextureId, +@@ -67,6 +97,7 @@ impl DirectXAtlas { + lock.polychrome_textures = AtlasTextureList::default(); + lock.subpixel_textures = AtlasTextureList::default(); + lock.tiles_by_key.clear(); ++ lock.streams.clear(); + } + } + +@@ -107,6 +138,7 @@ impl PlatformAtlas for DirectXAtlas { + AtlasTextureKind::Monochrome => &mut lock.monochrome_textures, + AtlasTextureKind::Polychrome => &mut lock.polychrome_textures, + AtlasTextureKind::Subpixel => &mut lock.subpixel_textures, ++ AtlasTextureKind::Stream => unreachable!(), + }; + let cached_texture_index = if id.kind == AtlasTextureKind::Polychrome { + textures.textures.iter().position(|entry| { +@@ -138,6 +170,18 @@ impl PlatformAtlas for DirectXAtlas { + } + } + } ++ ++ fn get_or_update_stream( ++ &self, ++ id: StreamingImageId, ++ frame: &StreamFrame, ++ ) -> anyhow::Result> { ++ self.0.lock().update_stream(id, frame) ++ } ++ ++ fn remove_stream(&self, id: StreamingImageId) { ++ self.0.lock().streams.retain(|stream| stream.id != id); ++ } + } + + impl DirectXAtlasState { +@@ -151,6 +195,7 @@ impl DirectXAtlasState { + AtlasTextureKind::Monochrome => &mut self.monochrome_textures, + AtlasTextureKind::Polychrome => &mut self.polychrome_textures, + AtlasTextureKind::Subpixel => &mut self.subpixel_textures, ++ AtlasTextureKind::Stream => unreachable!(), + }; + + if let Some(tile) = textures +@@ -201,6 +246,7 @@ impl DirectXAtlasState { + bind_flag = D3D11_BIND_SHADER_RESOURCE; + bytes_per_pixel = 4; + } ++ AtlasTextureKind::Stream => unreachable!(), + } + let texture_desc = D3D11_TEXTURE2D_DESC { + Width: size.width.0 as u32, +@@ -231,6 +277,7 @@ impl DirectXAtlasState { + AtlasTextureKind::Monochrome => &mut self.monochrome_textures, + AtlasTextureKind::Polychrome => &mut self.polychrome_textures, + AtlasTextureKind::Subpixel => &mut self.subpixel_textures, ++ AtlasTextureKind::Stream => unreachable!(), + }; + let index = texture_list.free_list.pop(); + let view = unsafe { +@@ -271,7 +318,113 @@ impl DirectXAtlasState { + AtlasTextureKind::Subpixel => { + &self.subpixel_textures[id.index as usize].as_ref().unwrap() + } ++ AtlasTextureKind::Stream => unreachable!(), ++ } ++ } ++ ++ fn update_stream( ++ &mut self, ++ id: StreamingImageId, ++ frame: &StreamFrame, ++ ) -> anyhow::Result> { ++ let size = frame.size(); ++ anyhow::ensure!( ++ size.width.0 <= MAX_STREAM_TEXTURE_SIZE && size.height.0 <= MAX_STREAM_TEXTURE_SIZE, ++ "streaming image frame {}x{} exceeds the {MAX_STREAM_TEXTURE_SIZE} pixel texture limit", ++ size.width.0, ++ size.height.0 ++ ); ++ let stride = u32::try_from(frame.stride()) ++ .map_err(|_| anyhow::anyhow!("streaming image stride is too large"))?; ++ let format = match frame.format() { ++ StreamingImageFormat::Bgra8 => DXGI_FORMAT_B8G8R8A8_UNORM, ++ StreamingImageFormat::Rgba8 => DXGI_FORMAT_R8G8B8A8_UNORM, ++ }; ++ let position = self.streams.iter().position(|stream| stream.id == id); ++ let position = match position { ++ Some(position) ++ if self.streams[position].size == size ++ && self.streams[position].format == format => ++ { ++ position ++ } ++ Some(position) => { ++ let index = self.streams[position].index; ++ let Some(stream) = self.new_stream(id, index, size, format) else { ++ self.streams.remove(position); ++ return Ok(None); ++ }; ++ self.streams[position] = stream; ++ position ++ } ++ None => { ++ let index = self.next_stream_index; ++ let Some(stream) = self.new_stream(id, index, size, format) else { ++ return Ok(None); ++ }; ++ self.next_stream_index = self.next_stream_index.wrapping_add(1); ++ self.streams.push(stream); ++ self.streams.len() - 1 ++ } ++ }; ++ let stream = &mut self.streams[position]; ++ if stream.generation != frame.generation() { ++ unsafe { ++ self.device_context.UpdateSubresource( ++ &stream.texture, ++ 0, ++ None, ++ frame.bytes().as_ptr() as _, ++ stride, ++ 0, ++ ); ++ } ++ stream.generation = frame.generation(); + } ++ Ok(Some(AtlasTile::for_stream(stream.index, size))) ++ } ++ ++ fn new_stream( ++ &self, ++ id: StreamingImageId, ++ index: u32, ++ size: Size, ++ format: DXGI_FORMAT, ++ ) -> Option { ++ let texture_desc = D3D11_TEXTURE2D_DESC { ++ Width: size.width.0 as u32, ++ Height: size.height.0 as u32, ++ MipLevels: 1, ++ ArraySize: 1, ++ Format: format, ++ SampleDesc: DXGI_SAMPLE_DESC { ++ Count: 1, ++ Quality: 0, ++ }, ++ Usage: D3D11_USAGE_DEFAULT, ++ BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32, ++ CPUAccessFlags: 0, ++ MiscFlags: 0, ++ }; ++ let mut texture: Option = None; ++ let mut view = None; ++ unsafe { ++ self.device ++ .CreateTexture2D(&texture_desc, None, Some(&mut texture)) ++ .ok()?; ++ self.device ++ .CreateShaderResourceView(texture.as_ref()?, None, Some(&mut view)) ++ .ok()?; ++ } ++ Some(DirectXAtlasStream { ++ id, ++ index, ++ size, ++ format, ++ generation: 0, ++ texture: texture?, ++ view: [view], ++ }) + } + } + +@@ -357,17 +510,26 @@ fn etagere_point_to_device(value: etagere::Point) -> Point { + #[cfg(test)] + mod tests { + use super::*; +- use gpui::{ImageId, RenderImageParams}; ++ use gpui::{ImageId, RenderImageParams, StreamingImage}; + use std::borrow::Cow; + use windows::Win32::{ + Foundation::HMODULE, + Graphics::{ + Direct3D::D3D_DRIVER_TYPE_WARP, +- Direct3D11::{D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_SDK_VERSION, D3D11CreateDevice}, ++ Direct3D11::{ ++ D3D11_CPU_ACCESS_READ, D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_MAP_READ, ++ D3D11_MAPPED_SUBRESOURCE, D3D11_SDK_VERSION, D3D11_USAGE_STAGING, ++ D3D11CreateDevice, ++ }, + }, + }; + + fn create_atlas() -> Option { ++ let (device, device_context) = create_device()?; ++ Some(DirectXAtlas::new(&device, &device_context)) ++ } ++ ++ fn create_device() -> Option<(ID3D11Device, ID3D11DeviceContext)> { + let mut device: Option = None; + let mut device_context: Option = None; + unsafe { +@@ -384,7 +546,7 @@ mod tests { + ) + } + .ok()?; +- Some(DirectXAtlas::new(&device?, &device_context?)) ++ Some((device?, device_context?)) + } + + fn make_image_key(image_id: usize) -> AtlasKey { +@@ -433,6 +595,264 @@ mod tests { + assert_eq!(tile_b.texture_id, keeper_tile.texture_id); + } + ++ fn stream_size(width: i32, height: i32) -> Size { ++ Size { ++ width: DevicePixels(width), ++ height: DevicePixels(height), ++ } ++ } ++ ++ fn pixels(size: Size, seed: u8) -> Vec { ++ (0..size.width.0 * size.height.0 * 4) ++ .map(|index| (index as u8).wrapping_mul(31).wrapping_add(seed)) ++ .collect() ++ } ++ ++ fn set_frame(image: &StreamingImage, size: Size, bytes: Vec) -> StreamFrame { ++ image ++ .set_frame(size, size.width.0 as usize * 4, bytes) ++ .unwrap(); ++ image.current().unwrap() ++ } ++ ++ fn update(atlas: &DirectXAtlas, image: &StreamingImage, frame: &StreamFrame) -> AtlasTile { ++ atlas ++ .get_or_update_stream(image.id(), frame) ++ .expect("stream upload should succeed") ++ .expect("stream upload returns a tile") ++ } ++ ++ fn stream_texture(atlas: &DirectXAtlas, image: &StreamingImage) -> ID3D11Texture2D { ++ atlas ++ .0 ++ .lock() ++ .streams ++ .iter() ++ .find(|stream| stream.id == image.id()) ++ .expect("stream should exist") ++ .texture ++ .clone() ++ } ++ ++ fn copy_to_staging(atlas: &DirectXAtlas, texture: &ID3D11Texture2D) -> ID3D11Texture2D { ++ let lock = atlas.0.lock(); ++ let mut desc = D3D11_TEXTURE2D_DESC::default(); ++ unsafe { texture.GetDesc(&mut desc) }; ++ desc.Usage = D3D11_USAGE_STAGING; ++ desc.BindFlags = 0; ++ desc.CPUAccessFlags = D3D11_CPU_ACCESS_READ.0 as u32; ++ let mut staging: Option = None; ++ unsafe { ++ lock.device ++ .CreateTexture2D(&desc, None, Some(&mut staging)) ++ .unwrap(); ++ let staging = staging.unwrap(); ++ lock.device_context.CopyResource(&staging, texture); ++ staging ++ } ++ } ++ ++ fn read_staging(atlas: &DirectXAtlas, staging: &ID3D11Texture2D) -> Vec { ++ let lock = atlas.0.lock(); ++ let mut desc = D3D11_TEXTURE2D_DESC::default(); ++ unsafe { staging.GetDesc(&mut desc) }; ++ let mut mapped = D3D11_MAPPED_SUBRESOURCE::default(); ++ unsafe { ++ lock.device_context ++ .Map(staging, 0, D3D11_MAP_READ, 0, Some(&mut mapped)) ++ .unwrap(); ++ } ++ let row_bytes = desc.Width as usize * 4; ++ let mut bytes = Vec::with_capacity(row_bytes * desc.Height as usize); ++ for row in 0..desc.Height as usize { ++ let row = unsafe { ++ std::slice::from_raw_parts( ++ (mapped.pData as *const u8).add(row * mapped.RowPitch as usize), ++ row_bytes, ++ ) ++ }; ++ bytes.extend_from_slice(row); ++ } ++ unsafe { lock.device_context.Unmap(staging, 0) }; ++ bytes ++ } ++ ++ fn read_stream(atlas: &DirectXAtlas, image: &StreamingImage) -> Vec { ++ let staging = copy_to_staging(atlas, &stream_texture(atlas, image)); ++ read_staging(atlas, &staging) ++ } ++ ++ fn swizzled(bytes: &[u8]) -> Vec { ++ bytes ++ .chunks_exact(4) ++ .flat_map(|pixel| [pixel[2], pixel[1], pixel[0], pixel[3]]) ++ .collect() ++ } ++ ++ #[test] ++ fn stream_updates_of_the_same_size_reuse_one_texture() { ++ let Some(atlas) = create_atlas() else { ++ return; ++ }; ++ let image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ let size = stream_size(64, 36); ++ let first = update(&atlas, &image, &set_frame(&image, size, pixels(size, 1))); ++ let texture = stream_texture(&atlas, &image); ++ for seed in 2..10 { ++ let tile = update(&atlas, &image, &set_frame(&image, size, pixels(size, seed))); ++ assert_eq!(tile, first); ++ } ++ ++ assert_eq!(first.texture_id.kind, AtlasTextureKind::Stream); ++ assert_eq!(first.bounds.size, size); ++ assert_eq!(stream_texture(&atlas, &image), texture); ++ assert_eq!(read_stream(&atlas, &image), pixels(size, 9)); ++ } ++ ++ #[test] ++ fn stream_size_change_reallocates_once() { ++ let Some(atlas) = create_atlas() else { ++ return; ++ }; ++ let image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ let small = stream_size(64, 36); ++ let large = stream_size(128, 72); ++ let small_tile = update(&atlas, &image, &set_frame(&image, small, pixels(small, 1))); ++ let small_texture = stream_texture(&atlas, &image); ++ let large_tile = update(&atlas, &image, &set_frame(&image, large, pixels(large, 2))); ++ let large_texture = stream_texture(&atlas, &image); ++ update(&atlas, &image, &set_frame(&image, large, pixels(large, 3))); ++ ++ assert_eq!(large_tile.texture_id, small_tile.texture_id); ++ assert_eq!(large_tile.bounds.size, large); ++ assert_ne!(large_texture, small_texture); ++ assert_eq!(stream_texture(&atlas, &image), large_texture); ++ assert_eq!(read_stream(&atlas, &image), pixels(large, 3)); ++ } ++ ++ #[test] ++ fn removing_images_leaves_streams_alone() { ++ let Some(atlas) = create_atlas() else { ++ return; ++ }; ++ let size = stream_size(64, 64); ++ let image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ let stream_tile = update(&atlas, &image, &set_frame(&image, size, pixels(size, 5))); ++ let stream = stream_texture(&atlas, &image); ++ ++ let first_key = make_image_key(20); ++ let second_key = make_image_key(21); ++ let first = insert_tile(&atlas, &first_key, stream_size(1280, 720)); ++ atlas.remove(&first_key); ++ let second = insert_tile(&atlas, &second_key, stream_size(1280, 720)); ++ atlas.remove(&second_key); ++ ++ assert_eq!(first.texture_id.kind, AtlasTextureKind::Polychrome); ++ assert_eq!(first.texture_id, second.texture_id); ++ assert_eq!( ++ atlas ++ .0 ++ .lock() ++ .polychrome_textures ++ .textures ++ .iter() ++ .flatten() ++ .count(), ++ 1 ++ ); ++ assert!( ++ atlas ++ .get_stream_texture_view(stream_tile.texture_id) ++ .is_some() ++ ); ++ assert_eq!(stream_texture(&atlas, &image), stream); ++ assert_eq!(read_stream(&atlas, &image), pixels(size, 5)); ++ ++ atlas.remove_stream(image.id()); ++ assert!( ++ atlas ++ .get_stream_texture_view(stream_tile.texture_id) ++ .is_none() ++ ); ++ atlas.remove_stream(image.id()); ++ } ++ ++ #[test] ++ fn device_loss_uploads_streams_again() { ++ let Some(atlas) = create_atlas() else { ++ return; ++ }; ++ let image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ let size = stream_size(64, 8); ++ let frame = set_frame(&image, size, pixels(size, 4)); ++ let tile = update(&atlas, &image, &frame); ++ ++ let (device, device_context) = create_device().unwrap(); ++ atlas.handle_device_lost(&device, &device_context); ++ assert!(atlas.get_stream_texture_view(tile.texture_id).is_none()); ++ let tile = update(&atlas, &image, &frame); ++ assert!(atlas.get_stream_texture_view(tile.texture_id).is_some()); ++ assert_eq!(read_stream(&atlas, &image), pixels(size, 4)); ++ } ++ ++ #[test] ++ fn stream_writes_are_ordered_with_earlier_copies() { ++ let Some(atlas) = create_atlas() else { ++ return; ++ }; ++ let image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ let size = stream_size(64, 64); ++ update(&atlas, &image, &set_frame(&image, size, pixels(size, 1))); ++ let texture = stream_texture(&atlas, &image); ++ let first = copy_to_staging(&atlas, &texture); ++ update(&atlas, &image, &set_frame(&image, size, pixels(size, 2))); ++ let second = copy_to_staging(&atlas, &texture); ++ ++ assert_eq!(read_staging(&atlas, &first), pixels(size, 1)); ++ assert_eq!(read_staging(&atlas, &second), pixels(size, 2)); ++ } ++ ++ #[test] ++ fn stream_formats_and_strides_upload_the_same_pixels() { ++ let Some(atlas) = create_atlas() else { ++ return; ++ }; ++ let size = stream_size(64, 16); ++ let bgra = pixels(size, 3); ++ let rgba = swizzled(&bgra); ++ let bgra_image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ update( ++ &atlas, ++ &bgra_image, ++ &set_frame(&bgra_image, size, bgra.clone()), ++ ); ++ let stride = 64 * 4 + 32; ++ let mut padded = vec![0xee; stride * 16]; ++ for (row, source) in padded ++ .chunks_exact_mut(stride) ++ .zip(rgba.chunks_exact(64 * 4)) ++ { ++ row[..64 * 4].copy_from_slice(source); ++ } ++ let rgba_image = StreamingImage::new(StreamingImageFormat::Rgba8); ++ rgba_image.set_frame(size, stride, padded).unwrap(); ++ update(&atlas, &rgba_image, &rgba_image.current().unwrap()); ++ ++ assert_eq!(read_stream(&atlas, &bgra_image), bgra); ++ assert_eq!(read_stream(&atlas, &rgba_image), rgba); ++ } ++ ++ #[test] ++ fn oversized_stream_frames_are_rejected() { ++ let Some(atlas) = create_atlas() else { ++ return; ++ }; ++ let image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ let size = stream_size(MAX_STREAM_TEXTURE_SIZE + 1, 1); ++ let frame = set_frame(&image, size, vec![0; size.width.0 as usize * 4]); ++ assert!(atlas.get_or_update_stream(image.id(), &frame).is_err()); ++ } ++ + #[test] + fn test_reuses_one_unreferenced_polychrome_texture() { + let Some(atlas) = create_atlas() else { +diff --git a/crates/gpui_windows/src/directx_renderer.rs b/crates/gpui_windows/src/directx_renderer.rs +index 35b504e..e4b605b 100644 +--- a/crates/gpui_windows/src/directx_renderer.rs ++++ b/crates/gpui_windows/src/directx_renderer.rs +@@ -712,7 +712,14 @@ impl DirectXRenderer { + } + let devices = self.devices.as_ref().context("devices missing")?; + let resources = self.resources.as_ref().context("resources missing")?; +- let texture_view = self.atlas.get_texture_view(texture_id); ++ let texture_view = if texture_id.kind == AtlasTextureKind::Stream { ++ let Some(texture_view) = self.atlas.get_stream_texture_view(texture_id) else { ++ return Ok(()); ++ }; ++ texture_view ++ } else { ++ self.atlas.get_texture_view(texture_id) ++ }; + self.pipelines.poly_sprites.draw_range_with_texture( + &devices.device, + &devices.device_context, diff --git a/scripts/prepare-gpui-dependency.sh b/scripts/prepare-gpui-dependency.sh index bffbd90c10d..18f1b8eb9f8 100755 --- a/scripts/prepare-gpui-dependency.sh +++ b/scripts/prepare-gpui-dependency.sh @@ -8,6 +8,7 @@ patch_paths=( "apps/desktop-gpui/patches/zed-windows.patch" "apps/desktop-gpui/patches/zed-linux.patch" "apps/desktop-gpui/patches/zed-idle-frames.patch" + "apps/desktop-gpui/patches/zed-streaming-image.patch" ) patch_files=("${patch_paths[@]/#/$repo_root/}") base_revision="5d1f83d9f27a19bec1fb241dc33b42238af9cf8d" diff --git a/scripts/prepare-gpui-dependency.test.mjs b/scripts/prepare-gpui-dependency.test.mjs index e66507100a4..98a920eb1b7 100644 --- a/scripts/prepare-gpui-dependency.test.mjs +++ b/scripts/prepare-gpui-dependency.test.mjs @@ -122,6 +122,7 @@ test("dependency verification handles overlapping patches without changing the c writeFileSync(path.join(upstream, "renderer.rs"), "base\n"); writeFileSync(path.join(upstream, "windows.rs"), "base\n"); writeFileSync(path.join(upstream, "frames.rs"), "base\n"); + writeFileSync(path.join(upstream, "atlas.rs"), "base\n"); commit(); const base = git(upstream, "rev-parse", "HEAD").trim(); const patch = (name) => { @@ -142,6 +143,8 @@ test("dependency verification handles overlapping patches without changing the c patch("zed-linux.patch"); writeFileSync(path.join(upstream, "frames.rs"), "paced frames\n"); patch("zed-idle-frames.patch"); + writeFileSync(path.join(upstream, "atlas.rs"), "streaming images\n"); + patch("zed-streaming-image.patch"); const script = path.join(cap, "scripts/prepare-gpui-dependency.sh"); writeFileSync( script, @@ -165,6 +168,10 @@ test("dependency verification handles overlapping patches without changing the c readFileSync(path.join(zed, "frames.rs"), "utf8"), "paced frames\n", ); + assert.equal( + readFileSync(path.join(zed, "atlas.rs"), "utf8"), + "streaming images\n", + ); }); writeFileSync(path.join(zed, "unrelated.txt"), "preserve staged work\n"); From fc37d62230baf4cf5ef07f7e631c3b2cce024f3f Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:23:09 +0800 Subject: [PATCH 2/5] fix(gpui): keep stream textures alive while comparing them and allow long git paths for Windows patch tests --- .github/workflows/ci.yml | 4 +++ .../patches/zed-streaming-image.patch | 26 +++++++++++++------ 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f8ff8eb5548..99277310108 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -409,6 +409,10 @@ jobs: if: ${{ runner.os == 'Linux' }} run: sudo apt-get install -y --no-install-recommends mesa-vulkan-drivers libvulkan1 libegl1 libegl-mesa0 libgl1-mesa-dri + - name: Allow long paths in Zed's git dependencies + if: ${{ runner.os == 'Windows' }} + run: git config --global core.longpaths true + - name: Rust setup uses: dtolnay/rust-toolchain@1.95.0 diff --git a/apps/desktop-gpui/patches/zed-streaming-image.patch b/apps/desktop-gpui/patches/zed-streaming-image.patch index 6d67d833357..85d0fa11ba8 100644 --- a/apps/desktop-gpui/patches/zed-streaming-image.patch +++ b/apps/desktop-gpui/patches/zed-streaming-image.patch @@ -819,7 +819,7 @@ index e41b097..dac72f9 100644 + } } diff --git a/crates/gpui_macos/src/metal_atlas.rs b/crates/gpui_macos/src/metal_atlas.rs -index d007abb..f418f88 100644 +index d007abb..007b965 100644 --- a/crates/gpui_macos/src/metal_atlas.rs +++ b/crates/gpui_macos/src/metal_atlas.rs @@ -4,11 +4,21 @@ use derive_more::{Deref, DerefMut}; @@ -1140,7 +1140,7 @@ index d007abb..f418f88 100644 use std::borrow::Cow; fn create_atlas() -> Option { -@@ -396,4 +619,355 @@ mod tests { +@@ -396,4 +619,365 @@ mod tests { let key = make_image_key(999, 0); atlas.remove(&key); } @@ -1170,8 +1170,7 @@ index d007abb..f418f88 100644 + .expect("stream upload returns a tile") + } + -+ fn stream_slot_textures(atlas: &MetalAtlas, image: &StreamingImage) -> Vec { -+ use metal::foreign_types::ForeignType as _; ++ fn stream_slot_textures(atlas: &MetalAtlas, image: &StreamingImage) -> Vec { + let lock = atlas.0.lock(); + let stream = lock + .streams @@ -1181,10 +1180,15 @@ index d007abb..f418f88 100644 + stream + .slots + .iter() -+ .map(|slot| slot.texture.as_ptr() as usize) ++ .map(|slot| slot.texture.0.clone()) + .collect() + } + ++ fn texture_ptrs(textures: &[metal::Texture]) -> Vec<*mut metal::MTLTexture> { ++ use metal::foreign_types::ForeignType as _; ++ textures.iter().map(|texture| texture.as_ptr()).collect() ++ } ++ + fn stream_textures_created(atlas: &MetalAtlas) -> u64 { + atlas.0.lock().stream_textures_created + } @@ -1251,7 +1255,10 @@ index d007abb..f418f88 100644 + + assert_eq!(first.texture_id.kind, AtlasTextureKind::Stream); + assert_eq!(first.bounds.size, size); -+ assert_eq!(stream_slot_textures(&atlas, &image), textures); ++ assert_eq!( ++ texture_ptrs(&stream_slot_textures(&atlas, &image)), ++ texture_ptrs(&textures) ++ ); + assert_eq!(textures.len(), 1); + assert_eq!(stream_textures_created(&atlas), 1); + let (texture, _lease) = atlas.lease_stream_texture(first.texture_id).unwrap(); @@ -1308,8 +1315,11 @@ index d007abb..f418f88 100644 + + assert_eq!(large.texture_id, small.texture_id); + assert_eq!(large.bounds.size, large_size); -+ assert_ne!(large_textures, small_textures); -+ assert_eq!(stream_slot_textures(&atlas, &image), large_textures); ++ assert_ne!(texture_ptrs(&large_textures), texture_ptrs(&small_textures)); ++ assert_eq!( ++ texture_ptrs(&stream_slot_textures(&atlas, &image)), ++ texture_ptrs(&large_textures) ++ ); + assert_eq!(stream_textures_created(&atlas), 2); + } + From 8649a44889c9581fd5484d0e96fe980a01d2ee85 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:36:04 +0800 Subject: [PATCH 3/5] ci: run the wgpu atlas tests only where wgpu has an adapter --- .github/workflows/ci.yml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 99277310108..f4afff1a583 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -377,7 +377,8 @@ jobs: # Cap only builds gpui's crates as dependencies, so their own tests never run # in the app jobs above. Run the ones covering Cap's patches against real # devices: Metal on macOS, WARP on Windows, and Mesa's lavapipe (Vulkan) and - # llvmpipe (GL) on Linux. Zed's lockfile predates the patches' dependency + # llvmpipe (GL) on Linux. Windows renders with D3D11 and its runners expose + # no wgpu adapter, so wgpu's tests run on macOS and Linux only. Zed's lockfile predates the patches' dependency # changes, so these builds cannot use --locked. gpui-patch-tests: name: GPUI patch tests (${{ matrix.settings.name }}) @@ -428,12 +429,10 @@ jobs: if [[ "$RUNNER_OS" == "Linux" ]]; then WGPU_BACKEND=vulkan cargo +1.95.0 test -p gpui_wgpu --lib wgpu_atlas -- --nocapture WGPU_BACKEND=gl cargo +1.95.0 test -p gpui_wgpu --lib wgpu_atlas -- --nocapture - else + elif [[ "$RUNNER_OS" == "macOS" ]]; then cargo +1.95.0 test -p gpui_wgpu --lib wgpu_atlas -- --nocapture - fi - if [[ "$RUNNER_OS" == "macOS" ]]; then cargo +1.95.0 test -p gpui_macos --lib -- metal_atlas metal_renderer - elif [[ "$RUNNER_OS" == "Windows" ]]; then + else cargo +1.95.0 test -p gpui_windows --lib directx_atlas fi From a6fc0072a8107a590dd26088f3afeff7bdd92721 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 22 Oct 2026 04:28:33 +0800 Subject: [PATCH 4/5] perf(gpui): write wgpu stream frames in before_frame with the atlas uploads instead of during paint --- .../patches/zed-streaming-image.patch | 90 ++++++++++++++----- 1 file changed, 69 insertions(+), 21 deletions(-) diff --git a/apps/desktop-gpui/patches/zed-streaming-image.patch b/apps/desktop-gpui/patches/zed-streaming-image.patch index 85d0fa11ba8..4c5b8ca94d9 100644 --- a/apps/desktop-gpui/patches/zed-streaming-image.patch +++ b/apps/desktop-gpui/patches/zed-streaming-image.patch @@ -1759,7 +1759,7 @@ index 1d35af1..57afb61 100644 + } +} diff --git a/crates/gpui_wgpu/src/wgpu_atlas.rs b/crates/gpui_wgpu/src/wgpu_atlas.rs -index 3cf71ac..413add7 100644 +index 3cf71ac..dc71046 100644 --- a/crates/gpui_wgpu/src/wgpu_atlas.rs +++ b/crates/gpui_wgpu/src/wgpu_atlas.rs @@ -3,7 +3,7 @@ use collections::FxHashMap; @@ -1786,7 +1786,7 @@ index 3cf71ac..413add7 100644 struct PendingUpload { id: AtlasTextureId, bounds: Bounds, -@@ -37,6 +45,22 @@ struct WgpuAtlasState { +@@ -37,6 +45,25 @@ struct WgpuAtlasState { storage: WgpuAtlasStorage, tiles_by_key: FxHashMap, pending_uploads: Vec, @@ -1794,8 +1794,10 @@ index 3cf71ac..413add7 100644 + next_stream_index: u32, +} + -+/// A streaming image's texture. Frames are written straight into it while -+/// painting; wgpu orders those writes after earlier submissions that sample it. ++/// A streaming image's texture. The newest painted frame is held, not copied, ++/// and written straight from its buffer in [`WgpuAtlas::before_frame`], where ++/// atlas uploads happen; wgpu orders that write after earlier submissions that ++/// sample the texture. +struct WgpuAtlasStream { + id: StreamingImageId, + index: u32, @@ -1803,13 +1805,14 @@ index 3cf71ac..413add7 100644 + format: wgpu::TextureFormat, + swizzle: bool, + generation: u64, ++ pending: Option, + texture: wgpu::Texture, + view: wgpu::TextureView, + scratch: Vec, } pub struct WgpuTextureInfo { -@@ -58,6 +82,8 @@ impl WgpuAtlas { +@@ -58,6 +85,8 @@ impl WgpuAtlas { storage: WgpuAtlasStorage::default(), tiles_by_key: Default::default(), pending_uploads: Vec::new(), @@ -1818,7 +1821,7 @@ index 3cf71ac..413add7 100644 })) } -@@ -82,6 +108,19 @@ impl WgpuAtlas { +@@ -82,6 +111,19 @@ impl WgpuAtlas { } } @@ -1838,7 +1841,7 @@ index 3cf71ac..413add7 100644 /// Clears all cached textures and tiles, forcing them to be recreated. /// Use this for incremental recovery when the device is still valid. pub fn clear(&self) { -@@ -89,6 +128,7 @@ impl WgpuAtlas { +@@ -89,6 +131,7 @@ impl WgpuAtlas { lock.storage = WgpuAtlasStorage::default(); lock.tiles_by_key.clear(); lock.pending_uploads.clear(); @@ -1846,7 +1849,7 @@ index 3cf71ac..413add7 100644 } /// Handles device lost by clearing all textures and cached tiles. -@@ -101,6 +141,7 @@ impl WgpuAtlas { +@@ -101,6 +144,7 @@ impl WgpuAtlas { lock.storage = WgpuAtlasStorage::default(); lock.tiles_by_key.clear(); lock.pending_uploads.clear(); @@ -1854,7 +1857,7 @@ index 3cf71ac..413add7 100644 } } -@@ -153,9 +194,106 @@ impl PlatformAtlas for WgpuAtlas { +@@ -153,9 +197,108 @@ impl PlatformAtlas for WgpuAtlas { } } } @@ -1917,7 +1920,8 @@ index 3cf71ac..413add7 100644 + }; + let stream = &mut self.streams[position]; + if stream.generation != frame.generation() { -+ stream.write(&self.queue, frame); ++ stream.generation = frame.generation(); ++ stream.pending = Some(frame.clone()); + } + Ok(AtlasTile::for_stream(stream.index, size)) + } @@ -1952,6 +1956,7 @@ index 3cf71ac..413add7 100644 + format, + swizzle, + generation: 0, ++ pending: None, + texture, + view, + scratch: Vec::new(), @@ -1961,7 +1966,7 @@ index 3cf71ac..413add7 100644 fn allocate( &mut self, size: Size, -@@ -196,6 +334,7 @@ impl WgpuAtlasState { +@@ -196,6 +339,7 @@ impl WgpuAtlasState { let format = match kind { AtlasTextureKind::Monochrome => wgpu::TextureFormat::R8Unorm, AtlasTextureKind::Subpixel | AtlasTextureKind::Polychrome => self.color_texture_format, @@ -1969,7 +1974,19 @@ index 3cf71ac..413add7 100644 }; let texture = self.device.create_texture(&wgpu::TextureDescriptor { -@@ -306,6 +445,7 @@ impl ops::Index for WgpuAtlasStorage { +@@ -289,6 +433,11 @@ impl WgpuAtlasState { + }, + ); + } ++ for stream in &mut self.streams { ++ if let Some(frame) = stream.pending.take() { ++ stream.write(&self.queue, &frame); ++ } ++ } + } + } + +@@ -306,6 +455,7 @@ impl ops::Index for WgpuAtlasStorage { AtlasTextureKind::Monochrome => &self.monochrome_textures, AtlasTextureKind::Subpixel => &self.subpixel_textures, AtlasTextureKind::Polychrome => &self.polychrome_textures, @@ -1977,7 +1994,7 @@ index 3cf71ac..413add7 100644 } } } -@@ -316,6 +456,7 @@ impl ops::IndexMut for WgpuAtlasStorage { +@@ -316,6 +466,7 @@ impl ops::IndexMut for WgpuAtlasStorage { AtlasTextureKind::Monochrome => &mut self.monochrome_textures, AtlasTextureKind::Subpixel => &mut self.subpixel_textures, AtlasTextureKind::Polychrome => &mut self.polychrome_textures, @@ -1985,7 +2002,7 @@ index 3cf71ac..413add7 100644 } } } -@@ -336,6 +477,7 @@ impl ops::Index for WgpuAtlasStorage { +@@ -336,6 +487,7 @@ impl ops::Index for WgpuAtlasStorage { AtlasTextureKind::Monochrome => &self.monochrome_textures, AtlasTextureKind::Subpixel => &self.subpixel_textures, AtlasTextureKind::Polychrome => &self.polychrome_textures, @@ -1993,7 +2010,7 @@ index 3cf71ac..413add7 100644 }; textures[id.index as usize] .as_ref() -@@ -343,6 +485,47 @@ impl ops::Index for WgpuAtlasStorage { +@@ -343,6 +495,46 @@ impl ops::Index for WgpuAtlasStorage { } } @@ -2034,14 +2051,13 @@ index 3cf71ac..413add7 100644 + depth_or_array_layers: 1, + }, + ); -+ self.generation = frame.generation(); + } +} + struct WgpuAtlasTexture { id: AtlasTextureId, allocator: BucketedAtlasAllocator, -@@ -402,13 +585,13 @@ fn swizzle_upload_data(bytes: &[u8], format: wgpu::TextureFormat) -> Vec { +@@ -402,13 +594,13 @@ fn swizzle_upload_data(bytes: &[u8], format: wgpu::TextureFormat) -> Vec { mod tests { use super::*; use gpui::block_on; @@ -2057,7 +2073,7 @@ index 3cf71ac..413add7 100644 flags: wgpu::InstanceFlags::default(), backend_options: wgpu::BackendOptions::default(), memory_budget_thresholds: wgpu::MemoryBudgetThresholds::default(), -@@ -422,6 +605,11 @@ mod tests { +@@ -422,6 +614,11 @@ mod tests { }) .await .map_err(|error| anyhow::anyhow!("failed to request adapter: {error}"))?; @@ -2069,7 +2085,7 @@ index 3cf71ac..413add7 100644 let (device, queue) = adapter .request_device(&wgpu::DeviceDescriptor { label: Some("wgpu_atlas_test_device"), -@@ -507,6 +695,396 @@ mod tests { +@@ -507,6 +704,428 @@ mod tests { Ok(()) } @@ -2116,10 +2132,12 @@ index 3cf71ac..413add7 100644 + } + + fn update(atlas: &WgpuAtlas, image: &StreamingImage, frame: &StreamFrame) -> AtlasTile { -+ atlas ++ let tile = atlas + .get_or_update_stream(image.id(), frame) + .expect("stream upload should succeed") -+ .expect("stream upload returns a tile") ++ .expect("stream upload returns a tile"); ++ atlas.before_frame(); ++ tile + } + + fn stream_texture(atlas: &WgpuAtlas, image: &StreamingImage) -> wgpu::Texture { @@ -2425,6 +2443,36 @@ index 3cf71ac..413add7 100644 + } + + #[test] ++ fn stream_writes_wait_for_before_frame_and_keep_only_the_newest_frame() { ++ let Some(test) = stream_test(wgpu::TextureFormat::Bgra8Unorm) else { ++ return; ++ }; ++ let image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ let size = stream_size(64, 16); ++ let bounds = Bounds { ++ origin: Point::default(), ++ size, ++ }; ++ update( ++ &test.atlas, ++ &image, ++ &set_frame(&image, size, pixels(size, 1)), ++ ); ++ let texture = stream_texture(&test.atlas, &image); ++ for seed in 2..5 { ++ test.atlas ++ .get_or_update_stream(image.id(), &set_frame(&image, size, pixels(size, seed))) ++ .unwrap(); ++ } ++ let before = copy_to_buffer(&test, &texture, bounds); ++ test.atlas.before_frame(); ++ let after = copy_to_buffer(&test, &texture, bounds); ++ ++ assert_eq!(read_buffer(&test, &before, size), pixels(size, 1)); ++ assert_eq!(read_buffer(&test, &after, size), pixels(size, 4)); ++ } ++ ++ #[test] + fn cleared_atlas_uploads_streams_again() { + let Some(test) = stream_test(wgpu::TextureFormat::Bgra8Unorm) else { + return; From cc1b1fa010a461eef77675b232f30e16aebaea4d Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:31:02 +0800 Subject: [PATCH 5/5] test(gpui): fail patch tests in CI when the GPU device is unavailable instead of skipping --- .github/workflows/ci.yml | 2 + .../patches/zed-streaming-image.patch | 57 ++++++++++++++----- 2 files changed, 45 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f4afff1a583..9204dd12da0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -424,6 +424,8 @@ jobs: - name: Test GPUI patches shell: bash working-directory: ../zed-cap + env: + GPUI_REQUIRE_TEST_DEVICE: "1" run: | cargo +1.95.0 test -p gpui --lib stream if [[ "$RUNNER_OS" == "Linux" ]]; then diff --git a/apps/desktop-gpui/patches/zed-streaming-image.patch b/apps/desktop-gpui/patches/zed-streaming-image.patch index 4c5b8ca94d9..227458ff043 100644 --- a/apps/desktop-gpui/patches/zed-streaming-image.patch +++ b/apps/desktop-gpui/patches/zed-streaming-image.patch @@ -819,7 +819,7 @@ index e41b097..dac72f9 100644 + } } diff --git a/crates/gpui_macos/src/metal_atlas.rs b/crates/gpui_macos/src/metal_atlas.rs -index d007abb..007b965 100644 +index d007abb..65f38d5 100644 --- a/crates/gpui_macos/src/metal_atlas.rs +++ b/crates/gpui_macos/src/metal_atlas.rs @@ -4,11 +4,21 @@ use derive_more::{Deref, DerefMut}; @@ -1131,7 +1131,7 @@ index d007abb..007b965 100644 } struct MetalAtlasTexture { -@@ -295,7 +518,7 @@ unsafe impl Send for AssertSend {} +@@ -295,11 +518,20 @@ unsafe impl Send for AssertSend {} #[cfg(test)] mod tests { use super::*; @@ -1139,8 +1139,22 @@ index d007abb..007b965 100644 + use gpui::{PlatformAtlas, StreamingImage}; use std::borrow::Cow; ++ fn system_device() -> Option { ++ let device = metal::Device::system_default(); ++ assert!( ++ device.is_some() || std::env::var_os("GPUI_REQUIRE_TEST_DEVICE").is_none(), ++ "GPUI_REQUIRE_TEST_DEVICE is set but no Metal device is available" ++ ); ++ device ++ } ++ fn create_atlas() -> Option { -@@ -396,4 +619,365 @@ mod tests { +- let device = metal::Device::system_default()?; ++ let device = system_device()?; + Some(MetalAtlas::new(device, true)) + } + +@@ -396,4 +628,365 @@ mod tests { let key = make_image_key(999, 0); atlas.remove(&key); } @@ -1416,7 +1430,7 @@ index d007abb..007b965 100644 + } + + fn assert_upload_waits_for_no_reader(is_apple_gpu: bool) { -+ let Some(device) = metal::Device::system_default() else { ++ let Some(device) = system_device() else { + return; + }; + let atlas = MetalAtlas::new(device.clone(), is_apple_gpu); @@ -1507,7 +1521,7 @@ index d007abb..007b965 100644 + } } diff --git a/crates/gpui_macos/src/metal_renderer.rs b/crates/gpui_macos/src/metal_renderer.rs -index 1d35af1..57afb61 100644 +index 1d35af1..4be15aa 100644 --- a/crates/gpui_macos/src/metal_renderer.rs +++ b/crates/gpui_macos/src/metal_renderer.rs @@ -1,4 +1,4 @@ @@ -1613,7 +1627,7 @@ index 1d35af1..57afb61 100644 }); } -@@ -2748,3 +2766,144 @@ mod backdrop_blur_tests { +@@ -2748,3 +2766,150 @@ mod backdrop_blur_tests { ); } } @@ -1633,7 +1647,13 @@ index 1d35af1..57afb61 100644 + const HEIGHT: i32 = 30; + + fn new_renderer() -> Option { -+ metal::Device::system_default()?; ++ if metal::Device::system_default().is_none() { ++ assert!( ++ std::env::var_os("GPUI_REQUIRE_TEST_DEVICE").is_none(), ++ "GPUI_REQUIRE_TEST_DEVICE is set but no Metal device is available" ++ ); ++ return None; ++ } + Some( + MetalRenderer::new_headless(Arc::new(Mutex::new(InstanceBufferPool::default()))) + .expect("failed to initialize headless renderer"), @@ -1759,7 +1779,7 @@ index 1d35af1..57afb61 100644 + } +} diff --git a/crates/gpui_wgpu/src/wgpu_atlas.rs b/crates/gpui_wgpu/src/wgpu_atlas.rs -index 3cf71ac..dc71046 100644 +index 3cf71ac..84c0043 100644 --- a/crates/gpui_wgpu/src/wgpu_atlas.rs +++ b/crates/gpui_wgpu/src/wgpu_atlas.rs @@ -3,7 +3,7 @@ use collections::FxHashMap; @@ -2085,7 +2105,7 @@ index 3cf71ac..dc71046 100644 let (device, queue) = adapter .request_device(&wgpu::DeviceDescriptor { label: Some("wgpu_atlas_test_device"), -@@ -507,6 +704,428 @@ mod tests { +@@ -507,6 +704,432 @@ mod tests { Ok(()) } @@ -2099,6 +2119,10 @@ index 3cf71ac..dc71046 100644 + let (device, queue) = match test_device_and_queue() { + Ok(device_and_queue) => device_and_queue, + Err(error) => { ++ assert!( ++ std::env::var_os("GPUI_REQUIRE_TEST_DEVICE").is_none(), ++ "GPUI_REQUIRE_TEST_DEVICE is set but no wgpu device is available: {error}" ++ ); + eprintln!("skipping: {error}"); + return None; + } @@ -2548,7 +2572,7 @@ index 729a5c4..2bfbc4e 100644 self.draw_instances_with_texture( data, diff --git a/crates/gpui_windows/src/directx_atlas.rs b/crates/gpui_windows/src/directx_atlas.rs -index d49ec18..cea181d 100644 +index d49ec18..719fd3c 100644 --- a/crates/gpui_windows/src/directx_atlas.rs +++ b/crates/gpui_windows/src/directx_atlas.rs @@ -11,9 +11,11 @@ use windows::Win32::Graphics::{ @@ -2782,7 +2806,7 @@ index d49ec18..cea181d 100644 } } -@@ -357,17 +510,26 @@ fn etagere_point_to_device(value: etagere::Point) -> Point { +@@ -357,17 +510,31 @@ fn etagere_point_to_device(value: etagere::Point) -> Point { #[cfg(test)] mod tests { use super::*; @@ -2803,7 +2827,12 @@ index d49ec18..cea181d 100644 }; fn create_atlas() -> Option { -+ let (device, device_context) = create_device()?; ++ let device = create_device(); ++ assert!( ++ device.is_some() || std::env::var_os("GPUI_REQUIRE_TEST_DEVICE").is_none(), ++ "GPUI_REQUIRE_TEST_DEVICE is set but no WARP device is available" ++ ); ++ let (device, device_context) = device?; + Some(DirectXAtlas::new(&device, &device_context)) + } + @@ -2811,7 +2840,7 @@ index d49ec18..cea181d 100644 let mut device: Option = None; let mut device_context: Option = None; unsafe { -@@ -384,7 +546,7 @@ mod tests { +@@ -384,7 +551,7 @@ mod tests { ) } .ok()?; @@ -2820,7 +2849,7 @@ index d49ec18..cea181d 100644 } fn make_image_key(image_id: usize) -> AtlasKey { -@@ -433,6 +595,264 @@ mod tests { +@@ -433,6 +600,264 @@ mod tests { assert_eq!(tile_b.texture_id, keeper_tile.texture_id); }