git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commitab44fd20f362113854038b8eeb75fa3a45173901
parentb91b95e0b6
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-05 22:03
perf(images): update_pixel_regions writes only the rectangles that changed

A streaming caller that knows its damage -- cce-browser, whose engine
reports what each frame repainted -- can now replace just those regions
of an existing image instead of the whole picture. The pixels go over
tightly packed, region after region; the renderer copies them into
place in one submission (one BufferImageCopy each), keeping everything
outside them, which the existing in-place path already preserves by
transitioning from SHADER_READ_ONLY rather than UNDEFINED.

An update whose size or format no longer matches the image -- never
uploaded, resized, or from a replaced renderer -- writes nothing: a
fresh image would be blank outside the regions, so the caller must send
the whole picture with update_pixels. The WebGPU renderer writes each
region at its origin the same way.

Measured in cce-browser at scale 2: an overlay scrollbar fade costs
0.27 MB a frame instead of 15 MB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 src/draw/images.rs  | 53 +++++++++++++++++++++++++++++++++++++
 src/draw/mod.rs     |  4 +--
 src/vk/image.rs     | 75 +++++++++++++++++++++++++++++++++++++++++++----------
 src/vk/mod.rs       |  4 +--
 src/web/renderer.rs | 38 ++++++++++++++++++++++++++-
 5 files changed, 155 insertions(+), 19 deletions(-)

diff --git a/src/draw/images.rs b/src/draw/images.rs
index f123c74..ec1db89 100644
--- a/src/draw/images.rs
+++ b/src/draw/images.rs
@@ -42,9 +42,23 @@ pub enum Pending {
     /// Replace the contents of an image that already exists, keeping its
     /// id, its `VkImage` and its descriptor set.
     Update { id: u32, pixels: Vec<u8>, width: u32, height: u32, format: PixelFormat },
+    /// Replace only `regions` of an image that already exists; see
+    /// [`update_pixel_regions`]. `width`, `height` and `format` are the whole
+    /// image's, and an image that no longer matches them is left alone.
+    UpdateRegions {
+        id: u32,
+        pixels: Vec<u8>,
+        width: u32,
+        height: u32,
+        format: PixelFormat,
+        regions: Vec<Region>,
+    },
     Free { id: u32 },
 }
 
+/// A rectangle of an image in texels: `(x, y, width, height)`.
+pub type Region = (u32, u32, u32, u32);
+
 static PENDING: Mutex<Vec<Pending>> = Mutex::new(Vec::new());
 static NEXT_ID: AtomicU32 = AtomicU32::new(1);
 
@@ -110,6 +124,45 @@ pub fn update_pixels(id: u32, pixels: Vec<u8>, width: u32, height: u32, format:
     PENDING.lock().unwrap().push(Pending::Update { id, pixels, width, height, format });
 }
 
+/// Replace only the given rectangles of `id`, keeping the rest of what it
+/// holds.
+///
+/// For a streaming caller that knows what changed between two frames — a
+/// page whose engine reports damage. Copying and transferring a scrollbar's
+/// strip instead of the whole picture is the point: at 3840x2400 a full
+/// frame is 35 MB, and the strip a fraction of a percent of it.
+///
+/// `pixels` holds each region's texels tightly packed (`width * 4` bytes a
+/// row, no padding), one region after another in `regions` order. `width`,
+/// `height` and `format` describe the **whole** image and must match the one
+/// `id` names when the queue is drained. If they do not — the image was
+/// never uploaded, was resized, or belongs to a renderer that has since been
+/// replaced — nothing is written: the caller has lost track of what the
+/// image holds and must send the whole picture with [`update_pixels`]. That
+/// is the caller's contract, since only it knows what the rest of the image
+/// should be.
+pub fn update_pixel_regions(
+    id: u32,
+    pixels: Vec<u8>,
+    width: u32,
+    height: u32,
+    format: PixelFormat,
+    regions: Vec<Region>,
+) {
+    let mut bytes = 0usize;
+    for &(x, y, w, h) in &regions {
+        assert!(w > 0 && h > 0, "empty region");
+        assert!(x + w <= width && y + h <= height, "region outside the image");
+        bytes += (w * h * 4) as usize;
+    }
+    assert_eq!(pixels.len(), bytes, "8888 region size mismatch");
+    if regions.is_empty() {
+        retire_buffer(pixels);
+        return;
+    }
+    PENDING.lock().unwrap().push(Pending::UpdateRegions { id, pixels, width, height, format, regions });
+}
+
 /// A pixel buffer to fill, reusing one the renderer has finished with when
 /// there is one of at least `len` bytes.
 ///
diff --git a/src/draw/mod.rs b/src/draw/mod.rs
index f7f4afd..6f9a583 100644
--- a/src/draw/mod.rs
+++ b/src/draw/mod.rs
@@ -19,8 +19,8 @@ pub mod scene;
 pub mod shaders;
 
 pub use images::{
-    free_image, recycle_buffer, renderer_epoch, update_pixels, upload_pixels, upload_rgba,
-    upload_rgba_mipmapped, ImageQuad, PixelFormat,
+    free_image, recycle_buffer, renderer_epoch, update_pixel_regions, update_pixels,
+    upload_pixels, upload_rgba, upload_rgba_mipmapped, ImageQuad, PixelFormat, Region,
 };
 
 /// One scissored draw range of a 2D frame. `scissor` is (x, y, w, h) in
diff --git a/src/vk/image.rs b/src/vk/image.rs
index 369b66e..e3e782c 100644
--- a/src/vk/image.rs
+++ b/src/vk/image.rs
@@ -40,8 +40,8 @@ use gpu_allocator::MemoryLocation;
 use super::renderer::{create_cpu_buffer, destroy_cpu_buffer, AllocatedBuffer};
 use crate::draw::images::{image_table_built, retire_buffer, take_pending, Pending};
 pub use crate::draw::images::{
-    free_image, recycle_buffer, renderer_epoch, update_pixels, upload_pixels, upload_rgba,
-    upload_rgba_mipmapped, ImageQuad, PixelFormat,
+    free_image, recycle_buffer, renderer_epoch, update_pixel_regions, update_pixels,
+    upload_pixels, upload_rgba, upload_rgba_mipmapped, ImageQuad, PixelFormat, Region,
 };
 
 impl PixelFormat {
@@ -396,6 +396,22 @@ impl ImageStage {
                     }
                     retire_buffer(pixels);
                 }
+                Pending::UpdateRegions { id, pixels, width, height, format, regions } => {
+                    // Only into the picture these regions were cut from. A
+                    // fresh image here would be blank outside them, so a
+                    // mismatch writes nothing; see `update_pixel_regions`.
+                    let matches = self.images.get(&id).is_some_and(|gpu| {
+                        gpu.width == width && gpu.height == height && gpu.format == format
+                    });
+                    if matches {
+                        self.write_regions(
+                            device, allocator, queue, command_pool, id, &pixels, &regions,
+                        );
+                    } else {
+                        log::debug!("image {id}: region update for a {width}x{height} image it no longer matches, dropped");
+                    }
+                    retire_buffer(pixels);
+                }
                 Pending::Free { id } => self.destroy_image(device, allocator, id),
             }
         }
@@ -682,10 +698,49 @@ impl ImageStage {
         command_pool: vk::CommandPool,
         id: u32,
         pixels: &[u8],
+    ) {
+        let Some(&GpuImage { width, height, .. }) = self.images.get(&id) else {
+            return;
+        };
+        self.write_regions(device, allocator, queue, command_pool, id, pixels, &[(0, 0, width, height)]);
+    }
+
+    /// [`Self::write_into`] for part of the image: each region's texels,
+    /// packed one after another in `pixels`, copied into place in one
+    /// submission. Everything outside the regions keeps what it held.
+    #[allow(clippy::too_many_arguments)]
+    fn write_regions(
+        &mut self,
+        device: &ash::Device,
+        allocator: &mut Allocator,
+        queue: vk::Queue,
+        command_pool: vk::CommandPool,
+        id: u32,
+        pixels: &[u8],
+        regions: &[Region],
     ) {
         let Some(&GpuImage { image, width, height, mip_levels, .. }) = self.images.get(&id) else {
             return;
         };
+        let mut offset = 0u64;
+        let copies: Vec<vk::BufferImageCopy> = regions
+            .iter()
+            .map(|&(x, y, w, h)| {
+                let copy = vk::BufferImageCopy::default()
+                    .buffer_offset(offset)
+                    .buffer_row_length(w)
+                    .buffer_image_height(h)
+                    .image_subresource(
+                        vk::ImageSubresourceLayers::default()
+                            .aspect_mask(vk::ImageAspectFlags::COLOR)
+                            .layer_count(1),
+                    )
+                    .image_offset(vk::Offset3D { x: x as i32, y: y as i32, z: 0 })
+                    .image_extent(vk::Extent3D { width: w, height: h, depth: 1 });
+                offset += (w * h * 4) as u64;
+                copy
+            })
+            .collect();
         unsafe {
             let staging_buffer = {
                 let staging = self.staging_for(device, allocator, pixels.len());
@@ -713,9 +768,9 @@ impl ImageStage {
                 )
                 .unwrap();
             // Unlike a fresh upload this image holds a picture already, and
-            // it is in the layout the shader reads. Every byte is about to be
-            // overwritten, so its old contents need not be preserved — but
-            // the layout transition still has to be spelled out both ways.
+            // it is in the layout the shader reads. Transitioning *from* that
+            // layout (not UNDEFINED) keeps the contents, which a region
+            // update depends on: everything outside its regions must survive.
             device.cmd_pipeline_barrier(
                 cmd,
                 vk::PipelineStageFlags::FRAGMENT_SHADER,
@@ -738,15 +793,7 @@ impl ImageStage {
                 staging_buffer,
                 image,
                 vk::ImageLayout::TRANSFER_DST_OPTIMAL,
-                &[vk::BufferImageCopy::default()
-                    .buffer_row_length(width)
-                    .buffer_image_height(height)
-                    .image_subresource(
-                        vk::ImageSubresourceLayers::default()
-                            .aspect_mask(vk::ImageAspectFlags::COLOR)
-                            .layer_count(1),
-                    )
-                    .image_extent(vk::Extent3D { width, height, depth: 1 })],
+                &copies,
             );
             record_levels(device, cmd, image, width, height, mip_levels);
             device.end_command_buffer(cmd).unwrap();
diff --git a/src/vk/mod.rs b/src/vk/mod.rs
index ae5f3dc..080bef2 100644
--- a/src/vk/mod.rs
+++ b/src/vk/mod.rs
@@ -51,8 +51,8 @@ mod text;
 pub use compute::{workgroups, BindKind, Binding, ComputeDevice, Kernel, MAX_BINDINGS};
 pub use core::{SurfaceLost, SurfaceTarget, VkCore};
 pub use image::{
-    free_image, recycle_buffer, renderer_epoch, update_pixels, upload_pixels, upload_rgba,
-    upload_rgba_mipmapped, ImageQuad, PixelFormat,
+    free_image, recycle_buffer, renderer_epoch, update_pixel_regions, update_pixels,
+    upload_pixels, upload_rgba, upload_rgba_mipmapped, ImageQuad, PixelFormat, Region,
 };
 pub use renderer::{Batch2D, Frame2D, PlatePush, VkRenderer, MAX_PLATE_FEATURES};
 pub(crate) use renderer::present_debug;
diff --git a/src/web/renderer.rs b/src/web/renderer.rs
index 7ebae23..a1145c6 100644
--- a/src/web/renderer.rs
+++ b/src/web/renderer.rs
@@ -44,7 +44,7 @@ use web_sys::{
     GpuRenderPassColorAttachment, GpuRenderPassDescriptor, GpuRenderPassEncoder, GpuRenderPipeline,
     GpuRenderPipelineDescriptor, GpuSampler, GpuSamplerBindingLayout, GpuSamplerBindingType,
     GpuSamplerDescriptor, GpuShaderModuleDescriptor, GpuStoreOp, GpuTexelCopyBufferInfo,
-    GpuTexelCopyBufferLayout, GpuTexelCopyTextureInfo, GpuTexture, GpuTextureBindingLayout,
+    GpuOrigin3dDict, GpuTexelCopyBufferLayout, GpuTexelCopyTextureInfo, GpuTexture, GpuTextureBindingLayout,
     GpuTextureDescriptor, GpuTextureFormat, GpuTextureSampleType, GpuTextureView,
     GpuTextureViewDescriptor, GpuTextureViewDimension, GpuVertexAttribute, GpuVertexBufferLayout,
     GpuVertexFormat, GpuVertexState, HtmlCanvasElement,
@@ -286,6 +286,25 @@ fn write_texture(queue: &GpuQueue, texture: &GpuTexture, pixels: &[u8], w: u32,
     )
 }
 
+/// [`write_texture`] for one rectangle of `texture`, at `(x, y)`: `pixels`
+/// holds just that rectangle, tightly packed.
+fn write_texture_region(
+    queue: &GpuQueue,
+    texture: &GpuTexture,
+    pixels: &[u8],
+    (x, y, w, h): (u32, u32, u32, u32),
+) -> Result<(), JsValue> {
+    let layout = GpuTexelCopyBufferLayout::new();
+    layout.set_bytes_per_row(w * 4);
+    layout.set_rows_per_image(h);
+    let origin = GpuOrigin3dDict::new();
+    origin.set_x(x);
+    origin.set_y(y);
+    let target = GpuTexelCopyTextureInfo::new(texture);
+    target.set_origin_gpu_origin_3d_dict(&origin);
+    queue.write_texture_with_u8_slice_and_gpu_extent_3d_dict(&target, pixels, &layout, &extent(w, h))
+}
+
 /// A two-entry (texture, sampler) bind group for the glyph shader.
 fn texture_group(device: &GpuDevice, layout: &GpuBindGroupLayout, view: &GpuTextureView, sampler: &GpuSampler) -> GpuBindGroup {
     let entries = [GpuBindGroupEntry::new_with_gpu_texture_view(0, view), GpuBindGroupEntry::new(1, sampler)];
@@ -571,6 +590,23 @@ impl WebRenderer {
                     write_texture(&self.queue, &img.texture, &pixels, width, height)?;
                     retire_buffer(pixels);
                 }
+                Pending::UpdateRegions { id, pixels, width, height, format, regions } => {
+                    // Only into the picture the regions were cut from; see
+                    // `update_pixel_regions` for why a mismatch writes nothing.
+                    if let Some(img) = self
+                        .images
+                        .get(&id)
+                        .filter(|img| img.width == width && img.height == height && img.format == format)
+                    {
+                        let mut offset = 0usize;
+                        for &region in &regions {
+                            let len = (region.2 * region.3 * 4) as usize;
+                            write_texture_region(&self.queue, &img.texture, &pixels[offset..offset + len], region)?;
+                            offset += len;
+                        }
+                    }
+                    retire_buffer(pixels);
+                }
                 Pending::Free { id } => {
                     if let Some(old) = self.images.remove(&id) {
                         old.texture.destroy();