GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
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 ®ions {
+ 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, ®ions,
+ );
+ } 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 ®ion in ®ions {
+ 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();