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 premultiplied0xAARRGGBB, 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 imagesimg().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 boundscanvas(|b, p| p.image_file_cover(b, "photos/beach.jpg"));
// pixels in memory, decoded synchronously (any thread), shared cheaplylet 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.
// React (and @blender-ui/native's DOM API): the same <img> as Rust<img src="photos/beach.jpg" style={{ width: 160, height: 160, objectFit: 'cover' }} /><div style={{ backgroundImage: 'url(photos/tile.png)', backgroundSize: '32px 32px' }} />import blenderui as buifrom blenderui.components import Image
Image("photos/beach.jpg", fit="cover") # a component; fit as CSS object-fit
fmt, w, h, frames = bui.image_decode(open("logo.webp", "rb").read(), 256, 256)rgba, delay_ms = frames[0] # straight RGBA bytesbui.image_probe(data) # ("webp", w, h, frames): header onlytkinter: PhotoImage(file=...) reads Tk’s formats (PNG, GIF with format="gif -index n", PPM/PGM) through the kit, and also JPEG, WebP, BMP, TGA, QOI, HDR and SVG. A canvas item can show a file directly: node.canvas_set(id, 6, [x0, y0, x1, y1], src="photo.jpg", fit=2).
/* an image node, a label/button image, or a canvas item */BKNode *n = BK_node_new("image");BK_node_set_image_source(n, "photos/beach.jpg", BK_FIT_COVER);
BKCanvasItem it = {.type = BK_ITEM_IMAGE, .coords = (float[]){0, 0, 120, 120}, .ncoords = 4, .src = "photos/beach.jpg", .fit = BK_FIT_COVER};BK_canvas_set_item(canvas, &it);
/* synchronous decode, any thread */BKImBuf *ib = BK_imbuf_load("logo.webp", 256, 256); /* fit within, never upscaled */int w, h, frames = BK_imbuf_size(ib, &w, &h);const uint32_t *px = BK_imbuf_pixels(ib, 0); /* premultiplied 0xAARRGGBB */BK_imbuf_free(ib);import blenderui as buifrom blenderui import imgui
tex, w, h = bui.image_texture(open("logo.png", "rb").read()) # an ImTextureIDimgui.image(tex, (w, h))bui.texture_free(tex) # when doneRust: blenderui::Image::open("logo.png").unwrap().texture(), freed with blenderui::sys::BUI_texture_free. C: BK_imbuf_texture(ib, 0).
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
frameattribute (<img src="spinner.gif" frame="0">),Image(src, frame=2),BK_node_set_image_frame(node, n + 1)or a canvas item’sframe; without it the animation plays.blenderui::set_reduced_motion(true),bui.set_reduced_motion()orBK_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_nextstep an animation frame by frame; Rustblenderui::Animation, Pythonblenderui.AnimatedTexture, which keeps a texture up to date:
import blenderui as buifrom blenderui import imgui
spinner = bui.AnimatedTexture(open("spinner.gif", "rb").read(), 64, 64)# every frame:spinner.update() # steps when the frame's delay is overimgui.image(spinner.texture, spinner.size)How it works
- 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. - 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.
- 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. - 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.
- 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 benchClose bench
| Format | Image | BUI (its decoder) | Wuffs | stb_image | libspng | libjpeg-turbo | libwebp |
|---|---|---|---|---|---|---|---|
| PNG (RGB) | 1024 x 640 | 5.1 (Wuffs) | 4.6 | 7.4 | 5.4 | ||
| 3840 x 2400 | 55 (Wuffs) | 49 | 82 | 54 | |||
| PNG (RGBA) | 1024 x 640 | 9.6 (Wuffs) | 8.7 | 10.8 | 7.5 | ||
| 3840 x 2400 | 98 (Wuffs) | 86 | 94 | 67 | |||
| JPEG | 1024 x 640 | 4.4 (libjpeg-turbo) | 2.2 | 3.6 | 4.1 | ||
| 3840 x 2400 | 50 (libjpeg-turbo) | 34 | 41 | 47 | |||
| JPEG thumbnail | 3840 x 2400 to 256 px | 11.6 (DCT scaling) | 34 + scale | 41 + scale | 9.9 (1/8: 480 px) | ||
| WebP lossy | 1024 x 640 | 4.1 (libwebp) | 11.4 | 3.5 | |||
| 3840 x 2400 | 50 (libwebp) | 129 | 39 | ||||
| WebP lossless | 3840 x 2400 | 104 (libwebp) | 231 | 89 | |||
| WebP animated | 160 x 120, 10 frames | 2.5 (libwebp) | fails | needs demux | |||
| GIF | 1024 x 640 | 1.8 (Wuffs) | 1.7 | 5.3 | |||
| BMP | 1024 x 640 | 1.1 (Wuffs) | 1.0 | 4.6 | |||
| TGA | 1024 x 640 | 3.5 (Wuffs) | 2.9 | 4.5 | |||
| QOI | 1024 x 640 | 4.6 (Wuffs) | 3.6 | ||||
| Radiance HDR | 1024 x 640 | 8.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 benchClose bench
| Images | Path | First screen | Frame p50 / p99 | Settled full redraw | Allocations per settled frame | Peak / steady memory | After closing |
|---|---|---|---|---|---|---|---|
| 1,000 | Async, CPU renderer | 155 ms | 1.75 / 2.93 ms | 1.21 ms | 0 | 100 / 81 MB | 0 blocks left |
| Async, GPU renderer | 134 ms | 0.23 / 1.29 ms | 0.05 ms | 0 | 139 / 122 MB | 0 blocks left | |
| Previous path (sync, full size, kept) | 430 ms | 4.05 / 95.6 ms | 11.1 ms | 0 | 2,521 / 2,518 MB | 0 blocks left | |
| 10,000 | Async, CPU renderer | 133 ms | 1.88 / 3.92 ms | 1.14 ms | 0 | 348 / 325 MB | 0 blocks left |
| Async, GPU renderer | 145 ms | 0.30 / 1.13 ms | 0.04 ms | 0 | 383 / 363 MB | 0 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 benchClose bench
| Images | Path | First screen | Frame p50 / p99 | Settled full redraw | Allocations per settled frame | Peak / steady memory | After closing |
|---|---|---|---|---|---|---|---|
| 1,000 | Async, CPU renderer | 155 ms | 1.75 / 2.93 ms | 1.21 ms | 0 | 100 / 81 MB | 0 blocks left |
| Async, GPU renderer | 134 ms | 0.23 / 1.29 ms | 0.05 ms | 0 | 139 / 122 MB | 0 blocks left | |
| Previous path (sync, full size, kept) | 430 ms | 4.05 / 95.6 ms | 11.1 ms | 0 | 2,521 / 2,518 MB | 0 blocks left | |
| 10,000 | Async, CPU renderer | 133 ms | 1.88 / 3.92 ms | 1.14 ms | 0 | 348 / 325 MB | 0 blocks left |
| Async, GPU renderer | 145 ms | 0.30 / 1.13 ms | 0.04 ms | 0 | 383 / 363 MB | 0 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 benchClose bench
| Animations | Format, renderer | Render p50 / p99 | Render time per s | Decode time per s | Frames decoded per s | Memory (cache) | Off screen, 3 s |
|---|---|---|---|---|---|---|---|
| 200 | GIF, CPU | 0.76 / 1.48 ms | 46 ms | 269 ms | 1,995 | 69 MB (35 MB) | 0 decodes, 0 renders |
| WebP, CPU | 0.97 / 2.25 ms | 31 ms | 509 ms | 2,007 | 60 MB (38 MB) | 0 decodes, 0 renders | |
| GIF, GPU | 0.71 / 1.80 ms | 22 ms | 246 ms | 1,996 | 77 MB (35 MB) | 0 decodes, 0 renders | |
| WebP, GPU | 0.51 / 1.10 ms | 20 ms | 553 ms | 1,997 | 71 MB (38 MB) | 0 decodes, 0 renders | |
| 1,000 (about 240 on screen) | GIF, CPU | 1.51 / 3.08 ms | 62 ms | 327 ms | 2,600 | 83 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
| Decoder | Formats in BUI | Code | Memory safety | Licence |
|---|---|---|---|---|
| Wuffs 0.4 | PNG, GIF, BMP, TGA, QOI | 213 KB | Proven bounds and overflow checks (Wuffs language) | MIT or Apache-2.0 (used as MIT) |
| libjpeg-turbo 3.1.2, 8-bit decoder | JPEG | 158 KB | C, fuzzed (OSS-Fuzz) | IJG, BSD-3-Clause, zlib |
| libwebp 1.6.0, decoder + demux | WebP | 257 KB | C, fuzzed (OSS-Fuzz) | BSD-3-Clause |
| BUI Radiance HDR, PNM | HDR, PNM | 4 KB | C, every read bounds-checked | GPL-2.0-or-later |
| nanosvg (already in the kit for Blender's icons) | SVG | 1 KB adaptor | C | zlib |
| BUI image module (imbuf, cache, loader, draw) | 22 KB | GPL-2.0-or-later | ||
| Not used: stb_image | 93 KB (all formats) | C, history of CVEs on untrusted input | MIT / public domain | |
| Not used: libspng + zlib | 56 KB + zlib | C, fuzzed | BSD-2-Clause | |
| Not used: libjpeg-turbo, all precisions | 385 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.writeencodes PNG and PPM in Python.
Last updated