Image Pipeline
Overview
The image pipeline covers raster image decoding, the WebRender-facing texture caches, and the on-disk shader binary cache. WIP — image and texture handling is split across multiple caches with overlapping names; naming cleanup is queued behind API stability.
There are four caches, two layers, and one disk format. This page maps
them so a contributor can find the right one to extend. The four caches
serve distinct roles: layout writes a solved per-DOM-node table, the
runtime store holds the physical GL textures referenced by WebRender
display lists, the URL-keyed ImageCache resolves CSS
background-image: url(...) to decoded rasters, and ShaderDiskCache
persists WebRender shader binaries between runs.
The four caches
ImageCache. Lives atLayoutWindow.image_cache. Maps CSSbackground-image: url(...)URLs toImageRefdecoded rasters, plus the image-mask resolution table. Window-scoped. Defined incore/src/resources.rs.GlTextureCache(layout side). Lives atLayoutWindow.gl_texture_cache. Holds per-DOM-node texture metadata:(DomId, NodeId) → (ImageKey, ImageDescriptor, ExternalImageId). Layout solves it, WebRender translation consumes it. Window-scoped.gl_texture_cache(runtime store). Thread-localTEXTURE_CACHEindll/src/desktop/gl_texture_cache.rs. Holds actualTextureobjects keyed byExternalImageId, withEpochfor cleanup. Lives on the GL thread.ShaderDiskCache. Per-process. Persists WebRender shader binaries (ProgramBinary) on disk by source digest. Lives in the cache directory.
The naming clash between layout's GlTextureCache (solved metadata) and
the runtime gl_texture_cache module (actual textures) is flagged for
cleanup. They serve distinct roles: layout writes the solved table,
the runtime store holds the physical textures referenced by WebRender
display lists.
Stable ExternalImageId
#[repr(C)]
pub struct ExternalImageId {
pub inner: u64,
}
WebRender caches display lists across frames. When a display list
references an ExternalImageId, that ID must remain valid across frames
and point to the current texture. If IDs were generated fresh each frame,
cached display lists would reference stale IDs.
Two id-derivation strategies, both deterministic:
// Per (DomId, NodeId), used for canvas/GL callback textures:
pub(crate) struct TextureSlotKey {
pub dom_id: DomId,
pub node_id: NodeId,
}
impl TextureSlotKey {
pub fn to_external_image_id(&self) -> ExternalImageId {
let dom = self.dom_id.inner as u64;
let node = self.node_id.index() as u64;
let combined = (dom << 32) | (node & 0xFFFFFFFF);
ExternalImageId { inner: combined }
}
}
// Per ImageRef hash, used for raster images:
ExternalImageId { inner: image_ref_hash.inner as u64 }
The same DOM node (or the same ImageRef) thus always produces the same
ExternalImageId, so WebRender's cached display lists keep working.
Texture insertion API
The runtime texture store exposes two insertion functions, both routing through the same internal cache:
// (DomId, NodeId) → ExternalImageId via TextureSlotKey
pub fn insert_texture_for_node(
document_id: DocumentId,
dom_id: DomId,
node_id: NodeId,
epoch: Epoch,
texture: Texture,
) -> ExternalImageId;
// Caller-supplied ExternalImageId (already derived from an ImageRefHash)
pub fn insert_texture_by_id(
document_id: DocumentId,
external_image_id: ExternalImageId,
epoch: Epoch,
texture: Texture,
);
insert_texture_for_node calls insert_texture_by_id internally —
single keyspace, two convenience entry points. The cache layout is
DocumentId → ExternalImageId → TextureEntry { texture, epoch }.
Per-document because WebRender keeps one document per window.
Epoch-based eviction
Epoch is a per-document u32 frame counter incremented each render.
remove_old_epochs(document_id, current_epoch) walks the cache and
drops entries whose epoch is older than current_epoch - 1. The „− 1“
is for double-buffering: a frame that's actively rendering (or queued
for compositor) may still be referencing textures from the previous
epoch.
let current = current_epoch.into_u32();
let min_epoch_to_keep = if current >= 2 {
Epoch::from(current - 1)
} else {
Epoch::new()
};
The shell calls remove_old_epochs after each frame. Textures unused
for 2+ frames are dropped (and their underlying GL texture freed).
Thread-local enforcement
The runtime texture store uses thread_local!:
thread_local! {
static TEXTURE_CACHE: RefCell<Option<OrderedMap<DocumentId, GlTextureStorage>>> =
RefCell::new(None);
}
Texture creation requires an OpenGL context, which is single-threaded by
API contract. Putting the cache in thread_local! enforces this at the
type system level — a function that touches TEXTURE_CACHE cannot be
called from a non-GL thread without panic.
Raster image decode
Behind feature = "image_decoding". layout/src/image.rs wraps the
image crate behind FFI-friendly
types:
pub fn decode_raw_image_from_any_bytes(image_bytes: &[u8]) -> ResultRawImageDecodeImageError;
Format detection is image::guess_format. Supported pixel formats map
to RawImageFormat as follows: ImageLuma8 to R8, ImageLumaA8 to
RG8, ImageRgb8 to RGB8, ImageRgba8 to RGBA8, ImageLuma16 to
R16, ImageLumaA16 to RG16, ImageRgb16 to RGB16, ImageRgba16
to RGBA16, ImageRgb32F to RGBF32, and ImageRgba32F to RGBAF32.
RawImage carries pixel data as RawImageData (U8 / U16 / F32)
plus dimensions, format, and premultiplied_alpha: bool. The decoder
always returns premultiplied_alpha = false — premultiplication happens
later (in WebRender translation) if the descriptor flags request it.
DecodeImageError:
#[repr(C)]
pub enum DecodeImageError {
InsufficientMemory,
DimensionError,
UnsupportedImageFormat,
Unknown,
}
InsufficientMemory and DimensionError come from
image::error::LimitErrorKind. Image-format errors collapse into
Unknown because the underlying error variants don't have stable C ABI
shape.
Encoding
encode_png, encode_jpeg(image, quality), encode_bmp, encode_tga,
encode_tiff, encode_gif, encode_pnm. Each is gated behind a
per-format feature flag (png, jpeg, bmp, …). When the flag is off,
the function returns EncoderNotAvailable so callers don't crash on a
missing codec — just degrade.
translate_rawimage_colortype handles BGR8/BGRA8 → Rgb8/Rgba8
mapping. The TODO marker in the source flags an inconsistency: BGR/RGB
conversion isn't actually applied, just relabelled. Loaders that produce
BGRA8 and round-trip through encode_* will get colour-channel-swapped
output.
ImageRef and reference counting
#[repr(C)]
pub struct ImageRef {
pub data: *const DecodedImage,
pub copies: *const AtomicUsize,
pub run_destructor: bool,
}
C-ABI-compatible reference counting. data points to a heap
DecodedImage (the variant of which is hidden from C), copies points
to a heap AtomicUsize reference counter. Clone bumps copies;
Drop decrements and frees on zero.
ImageRef::into_inner() extracts DecodedImage if *copies == 1 (no
other holders); ImageRef::deep_copy() clones the underlying image.
Deep copy of DecodedImage::Gl(tex) returns NullImage because GL
textures cannot be cloned without the GL context — that's a known
limitation in the OpenGL trait surface.
DecodedImage covers raster (Raw), GL texture (Gl), null image
(NullImage), and callback-driven images (Callback). The callback
variant lets the layout postpone resolution until rendering — the
callback runs once we have a GL context and produces the actual
Texture.
ImageRefHash for content-addressed deduplication
ImageRefHash { inner: usize } is a stable hash of the ImageRef's
content. Two ImageRefs pointing at byte-identical decoded images
compare equal; two pointing at different bytes don't. Used as the
ExternalImageId derivation key for raster images (so two
<img src="x.png"> tags pointing at the same file get the same
texture).
image_ref_get_hash(image_ref) is the canonical hasher.
RendererResources
RendererResources holds parsed font and image resources per renderer
(per window). Layout reads from this when measuring image intrinsic
sizes (InlineImage::intrinsic_size). The image's natural width and
height come from the decoded RawImage's dimensions.
The split is: ImageCache (in LayoutWindow) is the DOM-side lookup
keyed by URL, RendererResources (also in LayoutWindow) is the
renderer-side lookup keyed by ImageKey / FontKey.
Shader binary disk cache
WebRender lazily compiles + links each shader on first use; the cost is
~10–50 ms per shader. ShaderDiskCache extracts the linked binary via
glGetProgramBinary and persists it. On the next run, glProgramBinary
skips compile + link.
Disk layout:
~/Library/Caches/azul/shaders/<renderer_hash>/ (macOS)
~/.cache/azul/shaders/<renderer_hash>/ (Linux, $XDG_CACHE_HOME aware)
%LOCALAPPDATA%\azul\shaders\<renderer_hash>\ (Windows)
<renderer_hash>/<digest_hex>.bin raw program binary
<renderer_hash>/<digest_hex>.meta 12 bytes: format (u32 LE) + digest (u64 LE)
The <renderer_hash> subdirectory is hash(gl_renderer_string + gl_version). When the user upgrades their GPU driver, <renderer_hash>
changes and old binaries are no longer found — automatic invalidation,
no version-gating logic needed.
ShaderDiskCache implements WebRender's ProgramCacheObserver:
impl ProgramCacheObserver for ShaderDiskCache {
fn save_shaders_to_disk(&self, entries: Vec<Arc<ProgramBinary>>);
fn set_startup_shaders(&self, _entries: Vec<Arc<ProgramBinary>>); // no-op
fn try_load_shader_from_disk(
&self,
digest: &ProgramSourceDigest,
program_cache: &Rc<ProgramCache>,
);
fn notify_program_binary_failed(&self, program_binary: &Arc<ProgramBinary>);
}
set_startup_shaders is a no-op — load_all_from_disk is called
explicitly at startup and loads every cached binary, so a separate
„startup set“ is redundant. notify_program_binary_failed removes both
.bin and .meta from disk: a cached binary that fails to re-link
(driver bug, GPU change WebRender didn't catch) is treated as poison
and not retried.
Pipeline: from CSS image to GPU texture
CSS background-image: url("logo.png")
│
▼ parser stores StyleBackgroundContent::Image(CssImageId)
│
▼ build_compact_cache(): extract CssImageId → records in tier2 props
│
▼ layout_dom_recursive (window.rs)
│ resolves CssImageId via ImageCache, gets ImageRef
│
▼ solver3 sizing.rs measures intrinsic size from ImageRef
│
▼ display_list.rs emits ImageCommand { image_ref, descriptor, bounds }
│
▼ wr_translate2.rs translates to WebRender display list
│ computes ExternalImageId from ImageRefHash
│ inserts texture into runtime store if needed
│ emits WR ImageDisplayItem with the ExternalImageId
│
▼ WebRender composites; on image lookup, gl_texture_integration.rs
│ serves the GL Texture from the runtime store
│
▼ glDrawElements with the texture bound
For GL callback / canvas content, the same pipeline runs but the
ImageRef's DecodedImage::Callback variant is invoked at translation
time. The callback produces a Texture that's inserted into the runtime
store keyed by (DomId, NodeId).to_external_image_id().
CSS image masks and effects
ImageDescriptor carries format, size, flags, and an
OptionImageMask. When a node has image-mask: url(...), the layout
side resolves the mask to an ImageRef and includes it in the
ImageDescriptor. WebRender uses the mask as an alpha brush during
composition.
Adding a new image format
For raster formats handled by the image crate:
- Add a feature flag in
layout/Cargo.toml(e.g.webp = ["image/webp"]). - The
imagecrate auto-supports the new format throughimage::guess_format;decode_raw_image_from_any_bytesrequires no change. - For encoding: add an
encode_<fmt>line via theencode_func!macro inimage.rs. - If the format has a unique
DynamicImage::Image*variant, extend the match indecode_raw_image_from_any_bytes.
For non-raster formats (SVG, PDF page images, video frames):
- Add a
DecodedImagevariant incore/src/resources.rs(SvgImage(parsed_svg),Pdf(...)). - Extend
deep_copyto handle the new variant. - Extend the renderer-side translator (
wr_translate2.rs) to convert the variant to a WebRender display item. - Update
RawImagetranslation only if the new format can be rasterized to RGBA — otherwise it stays in its native form for as long as possible.
Known gotchas
- Naming collision.
core::resources::GlTextureCache(solved metadata, layout-owned) vs thegl_texture_cachemodule indll/src/desktop/(runtime texture store, thread-local). They are not the same thing despite sharing a name. - GL textures cannot be
Cloned.ImageRef::deep_copyreturnsNullImageforDecodedImage::Gl. Code that needs an owned copy of a GL texture must blit it explicitly through GL. BGR8/BGRA8encode mislabel.translate_rawimage_colortypemaps both toRgb8/Rgba8without channel swap. ABGRA8image round-tripped throughencode_pngwill have R and B swapped in the output. Decoder side is fine — only encoder.RawImage::premultiplied_alphais alwaysfalsefrom the decoder. Premultiplication happens at WebRender translation time based onImageDescriptorFlags. Decoders don't premultiply.- Thread-local
TEXTURE_CACHEpanics if accessed off-thread. Any test or callback that touches the runtime texture store must run on the GL thread or use a stub. CPU-only headless tests skip this code path entirely.
Coming Up Next
- Rendering — display list to pixels
- WebRender Bridge — transactions, pipelines, IFrames
- GL Loading — per-platform GL symbol resolution
- Text Pipeline — font discovery, parsing, fallback chains