Skip to main content

Crate images_and_words

Crate images_and_words 

Source
Expand description

GPU middleware for graphics applications and games: the layer between a raw GPU API and a game engine.

You bring your own game loop, physics, sound, and existing codebase. images_and_words gives you a portable, pass-based GPU API in which you say what your memory pattern is — written once or every frame, read by the CPU or the GPU, buffer or texture — and get a resource that is already placed in the right kind of memory, multibuffered where it needs to be, and synchronized without you writing a fence.

It is written for the developer who already knows how to make CPU code fast and wants that knowledge to carry over. GPU performance is mostly a memory story — where the data lives, how often it moves, whether anyone is waiting on it, and whether it’s in the layout the hardware likes — and those are questions you already know how to ask. The types here are named after the answers, so that a static resource feels fast for the reason a const table feels fast, a dynamic one feels like the double buffer you’d have written by hand, and the crate is free to transcode either into whatever GPU-native format the backend prefers.

logo

§Quick start

A triangle on screen, start to finish. Add the crate with the wgpu backend:

[dependencies]
images_and_words = { version = "*", features = ["backend_wgpu"] }

Then, in an async context (the crate uses some_executor, not tokio — see Threading and async):

use images_and_words::bindings::BindStyle;
use images_and_words::images::{Engine, projection::WorldCoord, view::View};
use images_and_words::images::render_pass::{DrawCommand, PassDescriptor};
use images_and_words::images::shader::{FragmentShader, VertexShader};

// Shaders are WGSL on every backend.
const VS: &str = r#"
struct Out { @builtin(position) pos: vec4<f32>, @location(0) color: vec4<f32> }
@vertex fn vs_main(@builtin(vertex_index) i: u32) -> Out {
    var o: Out;
    switch i {
        case 0u: { o.pos = vec4(-0.5, -0.5, 0.1, 1.0); o.color = vec4(1.0, 0.0, 0.0, 1.0); }
        case 1u: { o.pos = vec4( 0.5, -0.5, 0.1, 1.0); o.color = vec4(0.0, 1.0, 0.0, 1.0); }
        default: { o.pos = vec4( 0.0,  0.5, 0.1, 1.0); o.color = vec4(0.0, 0.0, 1.0, 1.0); }
    }
    return o;
}"#;
const FS: &str = r#"
@fragment fn fs_main(@location(0) color: vec4<f32>) -> @location(0) vec4<f32> { return color; }"#;

// A View is a drawing surface. `View::for_testing()` is surfaceless; a real
// application gets one from a window (`View::from_surface`, with the
// `app_window` feature — see examples/simple_scene.rs).
let engine = Engine::rendering_to(View::for_testing(), WorldCoord::new(0.0, 0.0, 2.0))
    .await
    .expect("create engine");

// A pass = shaders + the resources they read + a draw command.
let pass = PassDescriptor::new(
    "triangle".to_string(),
    VertexShader::new("vs", VS.to_string()),
    FragmentShader::new("fs", FS.to_string()),
    BindStyle::new(),             // nothing bound; the shader makes its own vertices
    DrawCommand::TriangleList(1), // one triangle
    false,                        // depth test
    false,                        // alpha blend
);

// The Port owns the camera and the render loop.
let port = engine.main_port();
port.add_fixed_pass(pass).await;
port.force_render().await;
// In an application you'd call `port.start().await` instead, and the port
// re-renders whenever something it depends on changes.

§Feeding it data every frame

The dynamic buffer is the piece of this crate you’ll touch most. Write to it from the CPU whenever you like; it multibuffers internally so the write never waits on the GPU, and a bound buffer that changed marks the port dirty so the next frame picks it up — you don’t manage frame indices or fences.

use images_and_words::bindings::{BindStyle, bind_style::{BindSlot, Stage}};
use images_and_words::bindings::forward::dynamic::buffer::{Buffer, CRepr};
use images_and_words::bindings::visible_to::GPUBufferUsage;
use images_and_words::images::Engine;

// Any `#[repr(C)]` type can live in a buffer. `CRepr` is your promise
// that the layout matches what the shader expects.
#[repr(C)]
#[derive(Copy, Clone)]
struct Uniforms { time: f32, _pad: [f32; 3] }
unsafe impl CRepr for Uniforms {}

let engine = Engine::for_testing().await.unwrap();
let uniforms = Buffer::<Uniforms>::new(
    engine.bound_device().clone(),
    1,                                  // element count
    GPUBufferUsage::VertexShaderRead,
    "uniforms",
    |_index| Uniforms { time: 0.0, _pad: [0.0; 3] },
).await.unwrap();

// Bind it to @group(0) @binding(0) in the vertex stage...
let mut bind_style = BindStyle::new();
bind_style.bind_dynamic_buffer(BindSlot::new(0), Stage::Vertex, &uniforms);
// ...build a PassDescriptor with `bind_style` as in the quick start, then per frame:

let mut guard = uniforms.access_write().await;
guard.write(&[Uniforms { time: 1.0, _pad: [0.0; 3] }], 0).await;
drop(guard); // releases the backing store to the GPU

examples/animated_scene.rs is this pattern as a complete program.

§Why GPU middleware

Suppose you want to write a game, or you have CPU code that is too slow and suspect the GPU would help. The usual choices are

  • a game engine (Unity, Unreal, Godot): often much more than you need, hard to customize, and awkward to bring an existing codebase into — you adopt its scene graph, its asset pipeline, its build, and its opinions, and you get its performance where your problem resembles its demos; or
  • a low-level API (Vulkan, Metal, DX12) or a portability layer over one (wgpu, MoltenVK, WebGPU): full control, but you re-solve synchronization, multibuffering, and resource placement on every project, and portability is your problem.

There is also a newer pitch: leave your CPU code alone and have a compiler run it on the GPU — map a SIMD lane onto a warp lane and call it done. That is genuinely clever for the arithmetic, and it is aimed at the least of your problems. The hot loop was never the hard part; you were going to rewrite it as a shader anyway, and it’s forty lines. What costs weeks is everything around it: getting data onto the GPU in a layout it can read, keeping the CPU from stalling on it, keeping the GPU from reading it half-written, and doing all of that on more than one vendor’s hardware. A translation layer that hides the memory model doesn’t remove those costs; it makes them illegible, and usually ties them to one ISA. images_and_words takes the opposite bet: your knowledge should port, not your source. Keep the mental model you have for making CPU code fast; point it at types that name GPU memory patterns; write the kernel in WGSL, which runs on every backend.

StrategyExamplesAPI conceptsSync burdenShadersRuntime sizePortabilityDev speedRuntime speed
Game engineUnity, Unreal, GodotScene, nodes, camera, materialsLowMostly built-inMassiveExcellentVery highGreat if your use case is theirs
Low-level APIDX12, Vulkan, MetalPasses, shaders, buffers, texturesHighBYO, unlimitedNoneWrite once, run onceVery lowExtreme
Layered / constructedwgpu, MoltenVK, WebGPUPasses, shaders, buffers, texturesHighBYO; translation has rough edgesSomeGood in theoryLowGood; varies when translated
CPU-code-on-GPUSIMD→warp compilersYour existing loopsHiddenYour CPU code, transliteratedSomeOne vendor at a timeHighGood on the ALU; opaque on memory
GPU middlewareimages_and_wordsPasses, shaders, camera, higher-order buffers/textures, multibufferingMedium-lowBYO WGSL, via the backendSomeGood in theoryMediumGood, and legible

Middleware is a niche the ecosystem mostly skips: a cross-platform GPU abstraction that still lets you keep your own sound, physics, accessibility, and everything else you already have.

Three goals shaped this crate specifically:

  1. Prototype GPU acceleration in a day or two, on every platform at once. The usual story is “this CPU code is too slow, GPU might help, but a prototype on one platform is a week.” It should be an afternoon.
  2. Make GPU performance legible to a CPU programmer. Each resource type encodes one memory pattern, so its cost is knowable before you measure it, and each one can be tuned or replaced individually without leaving the API. Performance work is choosing better types, not rewriting the renderer. See What you already know below.
  3. Don’t run into a wall. Unlike an engine, IW doesn’t make you shoehorn your application into a pile of dependencies you didn’t pick; it is a library, and it stays out of your main — even where that is genuinely hard, as when one platform insists the GPU be driven from thread A and another insists on anything but thread A. That is the crate’s problem: you .await, and it lands the work on whichever thread the platform wants. And the abstractions are deliberately the ones every GPU API shares — passes, shaders, buffers, textures, and the memory patterns above — so an algorithm you write against IW is one you could carry to any other backend, or to the raw API, if you ever outgrow it. That is a design constraint, not a promise: the crate is meant to be a target you can leave, and so a target you don’t need to fear committing to.

§What you already know

If you have optimized CPU code, you already have most of the intuitions this API asks for. The table is the same one; only the numbers move.

On the CPU you’d say…Here that is…And the intuition transfers because…
“Make it const / build it once at startup”a static buffer or textureWritten once, read forever: the crate can move it into GPU-private memory in the GPU’s native layout and never touch it again. Excellent by construction.
“Double-buffer it so the producer never waits”a dynamic buffer or textureIt is that double buffer (or triple), with the frame-boundary handoff done for you. Better than what you’d hand-roll from statics, because the crate knows when the GPU is done.
“Struct-of-arrays, aligned, cache-line friendly”#[repr(C)] + CRepr, and pixel formatsThe GPU has preferred formats and alignments just as the cache does; the type system carries them so you don’t discover them from a validation error.
“Which cores/caches read this?”the usage you pass at constructionDeclaring the readers lets the crate pick placement and transitions, the way you’d pick a memory ordering or a NUMA node.
“Don’t redo work nothing changed”dirty-driven renderingA frame is drawn when a bound resource, the camera, or the view changed. Otherwise nothing runs.
“The compiler can pick a better instruction; let it”WGSL, compiled by the backendYou write the kernel once; the driver picks the ISA. There is no vendor ISA in your source.

Where the intuition doesn’t transfer, the crate tries to say so at the type: the GPU cannot read a buffer while you write it (hence access guards), and copies across the bus are far more expensive than copies within a cache (hence static vs dynamic being a type, not a flag).

§Concepts

§Engine, View, Port, Camera

  • Engine owns the GPU device and is the entry point. Engine::rendering_to takes a View and an initial camera position; Engine::for_testing is surfaceless for tests and doctests.
  • View is a drawing surface: a window’s surface under the app_window feature, or nothing at all under for_testing.
  • Port is a viewport onto a view. It holds the Camera (via Port::camera), the list of render passes, and the render loop (Port::start / Port::stop / Port::force_render), and it publishes frame timing through await_values observers.
  • Rendering is dirty-driven, not timer-driven: a port re-renders when a bound resource changes, the Camera moves, or the view resizes. Nothing changed, nothing drawn.

§Render passes and shaders

A PassDescriptor bundles a VertexShader, a FragmentShader, a BindStyle declaring which resources each stage reads, and a DrawCommand. Shaders are WGSL on every backend. Passes registered with Port::add_fixed_pass run every frame in order.

BindStyle also has built-ins the crate maintains for you: bind_camera_matrix and bind_frame_counter.

§Bindings: say the memory pattern, get the resource

Every GPU resource in bindings is classified along three orthogonal axes, and the module path spells out the choice:

bindings::forward::dynamic::buffer::Buffer
          ───┬───  ───┬───  ──┬───
       direction  mutability  resource type

The point of the classification is that each combination is a memory pattern the crate knows how to implement well, and the type name tells you what you’ll pay for:

  • Resource type — buffer or texture. Buffers hold any CRepr type with a layout you control and are indexed by element. Textures hold a fixed pixel format and get hardware sampling and filtering; on the GPU they are free to live in a swizzled, tiled or compressed layout you never see.
  • Mutability — static or dynamic. A static resource is uploaded once; the crate can copy it into GPU-private memory, transcode it into the backend’s native format, and never synchronize it again — many GPU reads per CPU write, at the cost of GPU-native memory bandwidth for the one write. A dynamic resource is written from the CPU throughout its life; it lives in CPU-visible memory and is multibuffered so writes never stall on the GPU. Choosing wrong is not a bug, it’s a performance cliff you can see coming from the type name.
  • Direction — who writes, who reads. Forward (CPU→GPU) is implemented. Reverse (GPU→CPU readback), sideways (GPU→GPU, e.g. render-to-texture chains) and omnidirectional are planned and reserved in the naming.

Which one to reach for:

You have…UseWhat the crate does with it
Mesh geometry that never changesforward::static::BufferOne upload into GPU-private memory; bind as vertex/index buffer; no synchronization, ever
A texture loaded from disk or generated onceforward::static::TextureUploaded once and encoded into the GPU’s native texture layout; sampled at full speed
Uniforms, camera-adjacent state, CPU-simulated particlesforward::dynamic::BufferCPU-visible, multibuffered; the CPU always has a free backing store to write into
A texture you repaint from the CPU per frameforward::dynamic::FrameTextureMultibuffered CPU-side staging + GPU texture; the copy is scheduled around your frame
A lookup tablestatic Buffer or TextureWhichever access pattern the shader wants; both go GPU-native

Static resources are built with an initializer closure or from a slice; textures take a TextureConfig. Every constructor takes a usage (GPUBufferUsage / TextureUsage) — which is the other half of the memory pattern: it tells the crate which stages will read the resource, and so how to place and transition it — and a debug name that shows up in GPU debuggers.

§Multibuffering, in one paragraph

A dynamic resource owns several backing stores. While the GPU reads one, the CPU writes another; access_write hands you whichever store the GPU is not using, and dropping the guard makes it eligible for the next frame. The CPU therefore never blocks on the GPU to update data, and the GPU never reads a half-written buffer. Static resources skip all of this — that’s why the split exists, and why static resources are cheaper than dynamic ones with the same contents.

§Software textures and pixel formats

bindings::software::texture is a CPU-side texture with the same Sampleable interface as the GPU ones, so you can generate or inspect image data before uploading it (and test without a GPU):

use images_and_words::bindings::software::texture::Texture;
use images_and_words::pixel_formats::{RGBA8UnormSRGB, RGBA8UnormSRGBPixel};

let tex = Texture::<RGBA8UnormSRGB>::new(4, 4, RGBA8UnormSRGBPixel::default());
assert_eq!((tex.width(), tex.height()), (4, 4));

pixel_formats is a set of zero-sized format types (e.g. RGBA8UnormSRGB) each paired with a #[repr(C)] pixel type, so a texture’s format is checked at compile time rather than at bind time.

§Backends and platform support

The backend is chosen at compile time by feature:

FeatureEffect
backend_wgpuThe production backend, on wgpu. You want this.
app_windowRe-exports app_window and adds View::from_surface so you can draw into a real window.
wgpu_webglLets the wgpu backend fall back to WebGL2 in browsers without WebGPU. See the caveats below.
(none)A no-op backend that type-checks and does nothing. Exists so the crate builds everywhere and as a template for new backends.

Through wgpu this reaches Direct3D 12, Vulkan, Metal, WebGPU and WebGL2, on Windows, macOS, Linux, iOS, Android and WebAssembly. Backend choice can be forced at build time with IMAGES_AND_WORDS_BACKENDS=gl (same grammar as WGPU_BACKEND; it is compiled in, so it works in browsers where std::env is empty).

That is what compiles. What is supported is tiered, because the crate is designed against current desktop GPUs and tolerates the rest:

  1. 🚀 Windows 10+ (DX12, Vulkan 1.3+)
  2. 🎮 recent Linux (Vulkan 1.3+, DX12 via Proton)
  3. 🎮 recent macOS (Metal, MoltenVK 1.3+)
  4. 🆗 WebGPU (Chrome 141+, Safari 26.0+, Firefox 142 on Windows)
  5. 💥 WebGL2 (Firefox off Windows, Safari 18.x, …)
  6. 💥 other wgpu GL targets (GL 3, GL ES 3, …)
  • 🚀 first-class: practically any issue you hit is worth filing.
  • 🎮 good: fairly well tested; bugs are rarer but still wanted.
  • 🆗 the design baseline: correctness problems are bugs; performance problems may need escape hatches the API doesn’t offer.
  • 💥 below the baseline: works for a subset of the API and panics otherwise; expected to bitrot.

WebGL2 is kept for now, because iOS 18.x and non-Windows Firefox still lack WebGPU, but expect it to be cut: it multiplies WASM size (~5×), several planned features cannot exist inside its constraints, and wgpu’s GL path is not something I plan to improve.

I intend the API to outlive any one backend; direct Metal and Vulkan backends exist in various states of development.

§Threading and async

This is the part most likely to surprise you.

  • Everything is async, on some_executor — not tokio. Constructors, writes and the render loop are futures. Tests and doctests use wasm_lite_std::async_doctest! / #[wasm_lite::wasm_lite_test], which run the same code natively and in a browser.
  • The GPU has a home thread. On macOS/iOS and in browsers, wgpu must be driven from the main thread; the crate enforces this internally and moves work there for you, but the examples show the required setup: enter app_window::application::main, then run your async code in a task. Copy examples/simple_scene.rs rather than inventing your own bootstrap.
  • Resources are Send. Buffers, textures and their access guards can cross task boundaries; the crate handles getting the actual GPU work back to the right thread.
  • On wasm32, don’t hold std::sync::Mutex anywhere the render path can reach. It is futex-backed and a contended lock aborts the browser main thread (Atomics.wait cannot be called in this context). Use wasm_lite_std::Mutex. This crate follows the rule; your code around it must too.

§WebAssembly

Browser builds target wasm32-unknown-unknown with shared memory, so they need nightly Rust (std is rebuilt with atomics) and the link flags in this repository’s .cargo/config.toml. build/wasm_example.sh <example> produces a browser build of an example; browser tests go through the wasm_lite runner (cargo install wasm_lite_cli).

§If wasm-bindgen is in your graph, repeat the shim patch

This crate’s browser code is wasm_lite. Two binding systems cannot coexist in one wasm module, so any wasm-bindgen code that reaches your build has to be lowered onto wasm_lite as well. This crate’s manifest does that by substituting wasm_lite’s compatibility shim for wasm-bindgen: as far as cargo is concerned the shim is wasm-bindgen, wasm-bindgen crates compile against it unchanged, and one wasm_lite build codegen pass covers the whole module.

You need that substitution whenever you are linking into the wasm-bindgen ecosystem for any reason — and with backend_wgpu you always are, because wgpu’s web backend is wasm-bindgen code. Your own #[wasm_bindgen] glue, or a web-sys/js-sys crate you pull in, is covered by the same patch. Cargo does not inherit [patch] from dependencies, so it goes in your manifest, with the same rev this crate’s Cargo.toml pins:

[patch.crates-io]
wasm-bindgen = { git = "https://github.com/drewcrawford/wasm_lite", rev = "d683abb6a4a132ff875b57f24deab593c4ac630c" }

[target.'cfg(target_arch="wasm32")'.dependencies]
# `[patch]` only substitutes the exact version the shim declares, and merely
# *warns* on a mismatch — without this pin the real wasm-bindgen is linked silently.
# Verify:  grep -c 'name = "wasm-bindgen"' Cargo.lock   ->  1
wasm-bindgen = "=0.2.108"

Patch only wasm-bindgen; wasm_lite / wasm_lite_std resolve from crates.io, and replacing them with git or path dependencies duplicates the runtime.

For your own browser code, consider writing it in wasm_lite directly — it’s very cool, and it is what this crate does. If you can’t, and something wasm-bindgen-shaped fails to build against the shim, that is a bug in wasm_lite: please file it there.

§Status and roadmap

Implemented: forward static Buffer and Texture, forward dynamic Buffer and FrameTexture, render passes, camera, dirty-driven render loop, wgpu backend on the platforms above.

Planned, in roughly this order: reverse bindings (readback), sideways bindings (render-to-texture chains), compute passes, additional native backends.

The API is pre-1.0 and will change where the roadmap needs it to.

§Contributing

If you are motivated enough to consider writing your own solution to this problem, I would rather have your help here. Development commands, the test matrix, and the browser verification scripts are documented in the repository’s CLAUDE.md / AGENTS.md; scripts/check_all is the one-shot health check.

Logging goes through logwise:

logwise::log!("Here is foo: {foo}", foo = foo);

There is no logging domain to declare any more: the facade derives package, target and module from the call site. Failures that a consumer should see are stable events instead — images_and_words.debug_capture.* are the ones this crate emits.

§License

Parity 7.0.0 or PolyForm Noncommercial 1.0.0, at your option; commercial licenses available.

Re-exports§

pub use await_values;
pub use vectormatrix;

Modules§

bindings
GPU resource binding types organized along three conceptual axes.
entry_point
The handle to the platform’s graphics API, and the first thing you construct.
images
Core rendering engine and graphics pipeline components.
pixel_formats
Type-safe pixel format definitions for textures and framebuffers.

Type Aliases§

Priority
Task priority levels for async operations.
Strategy
Parallel execution strategy for batch operations.