Images

PNG, JPEG, WebP, GIF, BMP, TGA, QOI, Radiance HDR, PNM and SVG in every language, decoded once in C at the size they are drawn, off the UI thread, in a memory-budgeted cache. Formats, API, performance and limits.

BUI reads images once, in C, for every language: the same decoders behind Rust’s <img>, React’s <img>, a tkinter PhotoImage, a Blender-style Image component, a canvas item or a Dear ImGui texture. The design is Blender’s imbuf: one image type (BKImBuf, Blender’s ImBuf) and one table of file types (IMB_FILE_TYPES), without imbuf’s heavy dependencies.

Three things make it fast and light:

  • Decoded at the size it is drawn. A 4K photo shown as a 120 px thumbnail is decoded for 120 px: JPEGs with DCT scaling (Blender’s imb_thumbnail_jpeg), WebPs with libwebp’s scaler, the rest with one box-filter downscale. Pixels are premultiplied 0xAARRGGBB, the layout of the frame and of the GPU atlas, so drawing never converts.
  • Off the UI thread. Files decode on a few worker threads, what is on screen first, then prefetches. A request scrolled away before it ran is cancelled. When an image arrives, only its rectangle redraws (damage rendering).
  • One cache, with a budget. Decoded images are kept by (file, size, fit) within a memory budget (256 MiB by default); the least recently drawn go first, never what is on screen. Every byte has one owner and comes back when the cache is freed.

Formats

Format Read with Notes
PNG Wuffs all bit depths, palette, transparency; checksums skipped (as browsers do)
JPEG libjpeg-turbo 3.1.2 baseline, progressive, arithmetic, CMYK/YCCK; EXIF orientation; DCT-scaled thumbnails; 8-bit samples
WebP libwebp 1.6.0 lossy, lossless, alpha, animated
GIF Wuffs animated (all frames, with disposal and delays)
BMP, TGA, QOI Wuffs
Radiance HDR BUI, after Blender’s radiance_hdr shown like Blender’s Standard view: clipped and sRGB-encoded
PNM BUI PGM and PPM, ASCII and binary, 8 and 16 bit (what Tk reads)
SVG nanosvg (Blender’s copy, already used for its icons) rasterized at the drawn size; CMake BUI_IMAGE_SVG (on)

Not built in yet (planned as options, off by default): AVIF (dav1d + libavif), OpenEXR (tinyexr), TIFF, JPEG XL, and HEIC through the system (WIC on Windows, ImageIO on macOS).

Licences: Wuffs (MIT, used as MIT), libjpeg-turbo (IJG, BSD-3-Clause, zlib), libwebp (BSD-3-Clause) and nanosvg (zlib) are all compatible with BUI’s GPL-2.0-or-later. See License and credits.

Showing an image file

use blenderui::prelude::*;
// <img>: the natural size comes from the file's header (no layout jump);
// the pixels decode off the UI thread at the size drawn.
rsx! { <img src="photos/beach.jpg" class="w-40 h-40 object-cover" alt="Beach" /> }
// builder syntax, and CSS background images
img().src("photos/beach.jpg").w(px(160.0)).h(px(160.0));
div().style("background: url(photos/tile.png) repeat; background-size: 32px 32px");
// in a canvas: covered thumbnail, or scaled to the bounds
canvas(|b, p| p.image_file_cover(b, "photos/beach.jpg"));
// pixels in memory, decoded synchronously (any thread), shared cheaply
let logo = std::sync::Arc::new(blenderui::Image::open("logo.webp").unwrap());
img().image(logo.clone());

blenderui::Image also has decode(bytes, max_w, max_h), from_rgba, to_rgba and texture(). The data table has an image column (CellRender::Image: the cell’s text is a path) and the virtual list’s chat messages show Record::media; both load only the rows on screen.

Animations

Animated GIF and WebP play wherever an image file shows: <img>, CSS backgrounds, Image components, labels, canvas items, table cells and list items.

  • One frame at a time, at the size drawn. The cache holds the frame on screen and the next one, decoded ahead on the workers, plus the decoder’s own canvas; never the whole animation. Each frame shows for its own delay (GIF delays of 0 or 10 ms show for 100 ms, as browsers do), and the animation loops as many times as the file says. GIF disposal and blending and WebP’s canvas are composited by the decoders.
  • Only what is on screen moves. A frame change redraws only the image’s rectangle, on the window’s own frame timer (BK_TIMER_CUSTOM, shared with custom nodes): no continuous redraw loop. An animation scrolled out of view, clipped away, in a list or table row that isn’t shown, or in a window that isn’t rendering, pauses: no decoding, no timers, no redraws. It resumes when it shows again.
  • Pause, seek, reduced motion. Show one frame with the frame attribute (<img src="spinner.gif" frame="0">), Image(src, frame=2), BK_node_set_image_frame(node, n + 1) or a canvas item’s frame; without it the animation plays. blenderui::set_reduced_motion(true), bui.set_reduced_motion() or BK_set_animations(0) hold every animation on its current frame, and in Rust and JavaScript make @media (prefers-reduced-motion: reduce) match.
  • Your own loop (Dear ImGui): BK_anim_open / BK_anim_next step an animation frame by frame; Rust blenderui::Animation, Python blenderui.AnimatedTexture, which keeps a texture up to date:
import blenderui as bui
from blenderui import imgui
spinner = bui.AnimatedTexture(open("spinner.gif", "rb").read(), 64, 64)
# every frame:
spinner.update() # steps when the frame's delay is over
imgui.image(spinner.texture, spinner.size)

How it works

  1. Layout needs only the size: an <img>, a label image or an anchored canvas item reads the file’s header once (BK_image_info, cached), so the page never moves when the pixels arrive. Fixed-size boxes (thumbnails, object-fit) don’t even read it.
  2. Drawing looks the image up in the cache by (file, box size, fit). A miss queues a decode and draws nothing there yet; the rectangle is remembered.
  3. Workers (cores - 1, at most 4: BK_image_set_threads) take the newest visible request first, read the file, decode it at the box size, crop to what shows and hand it back. The window loop wakes (BK_window_wake), the result enters the cache, and the remembered rectangles redraw.
  4. Cancellation: a pending image whose rectangle is redrawn without being asked for again (it scrolled away) loses its waiter; with none left, the request is dropped before it runs.
  5. Eviction: at the start of a drawn frame, while the cache is over budget, the least recently drawn images go, but never ones drawn in the last two frames.

The software raster draws premultiplied pixels with bilinear filtering (exact copies at 1:1); the Direct3D 11 and OpenGL renderers upload each image once into an atlas (keyed by the image, not hashed per frame) and sample it the same way, so a grid of hundreds of thumbnails is one draw call. Images over 2047 px on a side get a texture of their own (up to 256 MiB of them, freed when the kit frees the image or after 120 frames unused), tiled only past the device’s texture limit (Direct3D feature level 10: 8192 px). A parity test draws them on the CPU and on the GPU, whole and tiled: no pixel differs by more than 2 levels.

Performance

A JPEG thumbnail

A 3840 x 2400 photo decoded for a 256 px thumbnail

Lower is better

3×faster than the fastest full-size decode

  • BUI
  • Others
  • BUIlibjpeg-turbo, DCT scaling11.6 ms
  • Wuffsfull size, then scale34.5 ms3× more
  • stb_imagefull size, then scale40.5 ms3.5× more
  • libjpeg-turbofull size, no SIMD46.6 ms4× more
Open bench
Image decoding: milliseconds per image (median), the decoder BUI uses in bold
FormatImageBUI (its decoder)Wuffsstb_imagelibspnglibjpeg-turbolibwebp
PNG (RGB)1024 x 6405.1 (Wuffs)4.67.45.4
3840 x 240055 (Wuffs)498254
PNG (RGBA)1024 x 6409.6 (Wuffs)8.710.87.5
3840 x 240098 (Wuffs)869467
JPEG1024 x 6404.4 (libjpeg-turbo)2.23.64.1
3840 x 240050 (libjpeg-turbo)344147
JPEG thumbnail3840 x 2400 to 256 px11.6 (DCT scaling)34 + scale41 + scale9.9 (1/8: 480 px)
WebP lossy1024 x 6404.1 (libwebp)11.43.5
3840 x 240050 (libwebp)12939
WebP lossless3840 x 2400104 (libwebp)23189
WebP animated160 x 120, 10 frames2.5 (libwebp)failsneeds demux
GIF1024 x 6401.8 (Wuffs)1.75.3
BMP1024 x 6401.1 (Wuffs)1.04.6
TGA1024 x 6403.5 (Wuffs)2.94.5
QOI1024 x 6404.6 (Wuffs)3.6
Radiance HDR1024 x 6408.0 (Blender's RGBE, sRGB)8.6 (to float)

Ryzen 7 5800U, Windows 11, MinGW gcc 15.2 -O2 (libjpeg-turbo and libwebp as built by their own CMake at -O3 for the comparison; libjpeg-turbo without SIMD, which needs NASM). One photo (Windows' img14.jpg, 3840 x 2400) scaled and re-encoded by ffmpeg; each file decoded from memory 21 times (7 at 3840 px), the median. Output: 8-bit, 4 channels (premultiplied BGRA for Wuffs and BUI, straight RGBA for stb_image and libspng). BUI is the whole kit path (BK_imbuf_decode: probe, decode, premultiplied pixels in one allocation). Wuffs and BUI also measured back to back in later runs: BUI was 5 to 10 % slower than bare Wuffs on PNG (its own allocation, header probe, MEM accounting). Program: bench/images/decoders.c.

Source: bench/images/decoders.c · Ryzen 7 5800U laptop, Windows 11, on AC power

Scrolling a thumbnail grid

1,000 photos as 120 px thumbnails, 1280 x 800, 40 px per frame: CPU time per frame

Lower is better

33×lower p99 frame time than decoding on the UI thread

  • p50
  • p99
  • BUI, asyncCPU renderer
    • p50: 1.75 ms
    • p99: 2.93 ms
  • BUI, asyncGPU renderer
    • p50: 0.23 ms
    • p99: 1.29 ms
  • Previous pathsync decode, CPU
    • p50: 4.05 ms
    • p99: 95.6 ms
Open bench
Thumbnail grid: 120 x 120 thumbnails (object-fit: cover) of 1024 x 640 photos (JPEG, PNG, WebP, RGBA PNG in turn), 1280 x 800 headless window, scrolled top to bottom at 40 px per frame, 60 Hz pacing
ImagesPathFirst screenFrame p50 / p99Settled full redrawAllocations per settled framePeak / steady memoryAfter closing
1,000Async, CPU renderer155 ms1.75 / 2.93 ms1.21 ms0100 / 81 MB0 blocks left
Async, GPU renderer134 ms0.23 / 1.29 ms0.05 ms0139 / 122 MB0 blocks left
Previous path (sync, full size, kept)430 ms4.05 / 95.6 ms11.1 ms02,521 / 2,518 MB0 blocks left
10,000Async, CPU renderer133 ms1.88 / 3.92 ms1.14 ms0348 / 325 MB0 blocks left
Async, GPU renderer145 ms0.30 / 1.13 ms0.04 ms0383 / 363 MB0 blocks left

Ryzen 7 5800U, 16 GB, Windows 11, 4 decode threads (the default: cores - 1, at most 4). First screen: the first frame until every visible thumbnail (about 70) is drawn. Frame time is the frame's CPU time (poll + display list + render), GPU execution not included. Memory is the process working set; the image cache held 55 MB (1,000) and 258 MB (10,000: its 256 MB budget, 5,347 evicted). Allocations are MEM_* blocks (the image module's allocator); the kit's own malloc calls are not counted here. After closing, every MEM block is freed (BK_image_exit); the working set went back to 9 to 12 MB on the CPU renderer and 30 to 32 MB on the GPU one (Direct3D stays loaded). The previous path is what blenderui's <img> did before (the png crate): a synchronous full-size decode on the UI thread the first time an image shows, kept forever; it is emulated here with BUI's decoders, so its decode time is if anything lower than before. Program: bench/images/image_grid.c (bui_image_bench).

Source: bench/images/image_grid.c · Ryzen 7 5800U laptop, Windows 11, on AC power

Memory, 1,000-photo grid

Peak working set while scrolling the whole grid once

Lower is better

25×less memory than keeping full-size decodes

  • BUI
  • Others
  • BUI, asyncCPU renderer100 MB
  • BUI, asyncGPU renderer139 MB
  • Previous pathsync, full size2,521 MB25× more
Open bench
Thumbnail grid: 120 x 120 thumbnails (object-fit: cover) of 1024 x 640 photos (JPEG, PNG, WebP, RGBA PNG in turn), 1280 x 800 headless window, scrolled top to bottom at 40 px per frame, 60 Hz pacing
ImagesPathFirst screenFrame p50 / p99Settled full redrawAllocations per settled framePeak / steady memoryAfter closing
1,000Async, CPU renderer155 ms1.75 / 2.93 ms1.21 ms0100 / 81 MB0 blocks left
Async, GPU renderer134 ms0.23 / 1.29 ms0.05 ms0139 / 122 MB0 blocks left
Previous path (sync, full size, kept)430 ms4.05 / 95.6 ms11.1 ms02,521 / 2,518 MB0 blocks left
10,000Async, CPU renderer133 ms1.88 / 3.92 ms1.14 ms0348 / 325 MB0 blocks left
Async, GPU renderer145 ms0.30 / 1.13 ms0.04 ms0383 / 363 MB0 blocks left

Ryzen 7 5800U, 16 GB, Windows 11, 4 decode threads (the default: cores - 1, at most 4). First screen: the first frame until every visible thumbnail (about 70) is drawn. Frame time is the frame's CPU time (poll + display list + render), GPU execution not included. Memory is the process working set; the image cache held 55 MB (1,000) and 258 MB (10,000: its 256 MB budget, 5,347 evicted). Allocations are MEM_* blocks (the image module's allocator); the kit's own malloc calls are not counted here. After closing, every MEM block is freed (BK_image_exit); the working set went back to 9 to 12 MB on the CPU renderer and 30 to 32 MB on the GPU one (Direct3D stays loaded). The previous path is what blenderui's <img> did before (the png crate): a synchronous full-size decode on the UI thread the first time an image shows, kept forever; it is emulated here with BUI's decoders, so its decode time is if anything lower than before. Program: bench/images/image_grid.c (bui_image_bench).

Source: bench/images/image_grid.c · Ryzen 7 5800U laptop, Windows 11, on AC power

200 animations at once

Animated GIF and WebP at 10 frames a second, 60 px each: render time per frame

Lower is better

0decodes and renders while they are off screen

  • p50
  • p99
  • GIFCPU renderer
    • p50: 0.76 ms
    • p99: 1.48 ms
  • WebPCPU renderer
    • p50: 0.97 ms
    • p99: 2.25 ms
  • GIFGPU renderer
    • p50: 0.71 ms
    • p99: 1.8 ms
  • WebPGPU renderer
    • p50: 0.51 ms
    • p99: 1.1 ms
Open bench
Many animations at once: animated images (120 x 120, 20 frames of 100 ms, looping) shown at 60 x 60 in a 1280 x 800 headless window for 5 s, driven like a window loop (the context's frame timer, decodes arriving, renders)
AnimationsFormat, rendererRender p50 / p99Render time per sDecode time per sFrames decoded per sMemory (cache)Off screen, 3 s
200GIF, CPU0.76 / 1.48 ms46 ms269 ms1,99569 MB (35 MB)0 decodes, 0 renders
WebP, CPU0.97 / 2.25 ms31 ms509 ms2,00760 MB (38 MB)0 decodes, 0 renders
GIF, GPU0.71 / 1.80 ms22 ms246 ms1,99677 MB (35 MB)0 decodes, 0 renders
WebP, GPU0.51 / 1.10 ms20 ms553 ms1,99771 MB (38 MB)0 decodes, 0 renders
1,000 (about 240 on screen)GIF, CPU1.51 / 3.08 ms62 ms327 ms2,60083 MB (47 MB)0 decodes, 0 renders

Ryzen 7 5800U, Windows 11, 4 decode threads. Render time is the UI thread's BK_render; decode time is measured around each decode on the workers (Windows' process CPU times are sampled at the clock tick and miss these short bursts, so they are not used). Every visible animation advances at its own 100 ms: about 2,000 frames a second for 200. The cache holds two frames per animation and its decoder's canvas. MEM allocations: 8 per decoded frame (the frame, the scaled frame and the scaler's tables on the worker, the step request on the UI thread). Off screen: the canvas stops showing them, and over the next 3 s nothing decodes, no timer fires and nothing renders. Every MEM block is freed after closing. Program: bench/images/image_grid.c (bui_image_bench <dir> anim <count> [gif|webp] [gpu]).

Source: bench/images/image_grid.c · Ryzen 7 5800U laptop, Windows 11, on AC power

Code each decoder adds (text + data of its objects, gcc -O2) and what it is
DecoderFormats in BUICodeMemory safetyLicence
Wuffs 0.4PNG, GIF, BMP, TGA, QOI213 KBProven bounds and overflow checks (Wuffs language)MIT or Apache-2.0 (used as MIT)
libjpeg-turbo 3.1.2, 8-bit decoderJPEG158 KBC, fuzzed (OSS-Fuzz)IJG, BSD-3-Clause, zlib
libwebp 1.6.0, decoder + demuxWebP257 KBC, fuzzed (OSS-Fuzz)BSD-3-Clause
BUI Radiance HDR, PNMHDR, PNM4 KBC, every read bounds-checkedGPL-2.0-or-later
nanosvg (already in the kit for Blender's icons)SVG1 KB adaptorCzlib
BUI image module (imbuf, cache, loader, draw)22 KBGPL-2.0-or-later
Not used: stb_image93 KB (all formats)C, history of CVEs on untrusted inputMIT / public domain
Not used: libspng + zlib56 KB + zlibC, fuzzedBSD-2-Clause
Not used: libjpeg-turbo, all precisions385 KB

Sizes are object-file text + data before linking (dead code the linker drops is still counted, so this is an upper bound); stb_image, libspng and full libjpeg-turbo are the added size of a minimal stripped executable. JPEG uses libjpeg-turbo, not Wuffs (faster at full size), because DCT scaling decodes thumbnails 3x faster and at a fraction of the memory, as Blender's imbuf does. SIMD for libjpeg-turbo needs NASM, which BUI's builds don't require.

Limits

  • Many large thumbnails on screen at once can overflow the 2048 x 2048 GPU atlas; it is then rebuilt each frame.
  • An animation inside a CSS effect layer (filters, masks, 3D transforms) is drawn once, on its first frame. The synchronous APIs that return every frame (BK_imbuf_decode, image_decode) stop at 128 MiB of frames; playback has no such limit.
  • GIF and WebP delays of 10 ms or less play at 100 ms, as in browsers.
  • Decoding is limited to 16384 pixels on a side; 12- and 16-bit JPEGs are not read.
  • There is no on-disk thumbnail cache yet (Blender’s IMB_thumb), and no encoder: PhotoImage.write encodes PNG and PPM in Python.
Edit this page on GitHub

Last updated