GL Canvas

Introduction

WIP. The GL callback ABI is stable; what you can call on the GL context is the OpenGL 3.2-core subset exposed by GlContextPtr.

A render-image callback gives you a fresh GL context and the laid-out bounding box of a DOM node, and asks you to return an ImageRef for that node. Use it whenever the pixel content depends on size or animation: an OpenGL scene, a tessellated SVG that needs custom transforms, a chart sized to fit its container.

The callback

pub type RenderImageCallbackType =
    extern "C" fn(RefAny, RenderImageCallbackInfo) -> ImageRef;

Wrap a function pointer in RenderImageCallback::create(...) and convert it to the FFI-compatible form with .to_core():

use azul::prelude::*;

extern "C" 
fn render(_data: RefAny, _info: RenderImageCallbackInfo) -> ImageRef {
    // ... build texture ...
    panic!("returned a Texture-backed ImageRef")
}

fn build(state: RefAny) -> Dom {
    Dom::create_image(ImageRef::callback(
        RenderImageCallback::create(render).to_core(),
        state,
    ))
}

RenderImageCallbackInfo exposes:

  • get_bounds(). Returns HidpiAdjustedBounds with logical and physical sizes.
  • get_gl_context(). Returns OptionGlContextPtr. None on backends without GL.
  • get_callback_node_id(). The node ID this callback is attached to.
  • get_ctx(). The FFI context, used by Python and C bindings.

Allocating a texture

Texture::allocate_rgba8 allocates an RGBA8 texture sized to a PhysicalSizeU32 and clears it to a background color:

use azul::prelude::*;

fn body(info: &mut RenderImageCallbackInfo) -> Option<Texture> {
    let gl = info.get_gl_context().into_option()?;
    let size = info.get_bounds().get_physical_size();
    let mut texture = Texture::allocate_rgba8(
        gl, size, ColorU::white(),
    );
    texture.clear();
    Some(texture)
}

Sizing matches the post-layout physical pixel box, so the texture and the on-screen draw area are 1:1. The renderer doesn't rescale.

Drawing tessellated SVG into a texture

Texture::draw_tesselated_svg_gpu_node takes a GPU mesh, a target size, a fill color, and an optional list of transforms. Transforms are StyleTransforms — the same ones the CSS layer uses — so percentage translations resolve against the texture size:

use azul::prelude::*;

fn run(
    texture: &mut Texture, 
    mesh: TessellatedGPUSvgNode,
    size: PhysicalSizeU32, 
    deg: f32
) {
    texture.draw_tesselated_svg_gpu_node(
        mesh,
        size,
        ColorU::from_str("#cc00cc"),
        vec![
            StyleTransform::Translate(StyleTransformTranslate2D {
                x: PixelValue::percent(50.0),
                y: PixelValue::percent(50.0),
            }),
            StyleTransform::Rotate(AngleValue::deg(deg)),
        ],
    );
}

To anti-alias the result, call texture.apply_fxaa() before returning. It's a single-pass post-process suitable for vector content.

Returning the texture

A texture becomes an ImageRef via ImageRef::gl_texture(texture). Refs are reference-counted, so returning a clone after every callback is fine:

use azul::prelude::*;

fn done(t: Texture) -> ImageRef {
  ImageRef::gl_texture(t)
}

If something fails (no GL context, missing data, GPU error), return a null_image of the requested size. The renderer treats it as transparent and reserves space in the layout.

When the callback runs

A render-image callback runs:

  1. Once on first display of the DOM node, after layout has assigned the node a size.
  2. On every frame the DOM is re-built, if the node is still in the tree. To drive animation without rebuilding, return Update::RefreshDom from a timer callback so the framework reissues the render.
  3. Never if the node never enters the tree, or if the render-image feature is disabled (e.g. headless mode without a GL context).

The renderer doesn't memoize results. The callback is responsible for caching its own state in the RefAny it received.

A complete loop

The end-to-end pattern: tessellate SVG geometry once, upload to GPU buffers in a startup callback, redraw on every animation tick. The shape of the code is:

use azul::prelude::*;

struct AppState {
    rotation_deg: f32,
    fill_buffer: Option<TessellatedGPUSvgNode>,
}

extern "C" 
fn render(mut data: RefAny, mut info: RenderImageCallbackInfo) -> ImageRef {
    let size = info.get_bounds().get_physical_size();
    let invalid = ImageRef::null_image(
        size.width as usize, size.height as usize,
        RawImageFormat::RGBA8, U8VecRef::from(&[][..]),
    );
    let result = (|| {
        let mut state = data.downcast_mut::<AppState>()?;
        let gl = info.get_gl_context().into_option()?;
        let buffer = state.fill_buffer.as_ref()?.clone();
        let mut texture = Texture::allocate_rgba8(gl, size, ColorU::white());
        texture.clear();
        texture.draw_tesselated_svg_gpu_node(
            buffer, size, ColorU::from_str("#0080ff"),
            vec![],
        );
        texture.apply_fxaa();
        Some(ImageRef::gl_texture(texture))
    })();
    result.unwrap_or(invalid)
}

The full working example lives in examples/rust/src/opengl.rs. It parses GeoJSON, tessellates the polygons once, and rotates the result on a timer.

Lifetime and cleanup

Texture is reference-counted and frees its GL texture when the last clone drops. Returning a fresh ImageRef::gl_texture(...) from each callback invocation is the normal pattern: the previous frame's texture is dropped automatically by the framework once the new one replaces it.

GlContextPtr is also reference-counted. Cloning it in your RefAny state is safe and avoids re-fetching it via info.get_gl_context() on hot paths.

Coming Up Next

  • Animations — CSS transitions and @keyframes
  • SVG — Parsing and rendering SVG documents
  • Images — Loading raster images and CSS backgrounds

Back to guide index