Skip to content

Concepts

Embedding #

Use this path when your program already owns the window, the event loop, or the GPU. For example, a game engine can draw a Knots UI into its own render passes.

Import the modules #

exe.root_module.addImport("ui", knots.module("ui"));
exe.root_module.addImport("input", knots.module("input"));
exe.root_module.addImport("render", knots.module("render"));
// Only when you draw with the bundled GPU backend:
exe.root_module.addImport("renderer", knots.module("renderer"));
// Only when you also use the Knots window:
exe.root_module.addImport("window", knots.module("window"));

The host loop #

This loop uses the Knots window and the bundled GPU backend, but owns every pass:

var context_ui = try ui.Context.init(gpa, .{});
defer context_ui.deinit();

while (window.isOpen()) {
    window.pollEvents(io);
    defer window.finishInputFrame();

    var frame = try context_ui.beginFrame(.{
        .input = try window.collectInput(),
        .now_ms = now_ms,
        .delta_ns = delta_ns,
        .logical_extent = window.getSize(),
        .physical_extent = window.getFramebufferSize(),
        .content_scale = window.getContentScale(),
    });
    defer frame.deinit();

    try buildUi(&frame);
    const output = try context_ui.endFrame(&frame);
    // Apply the effects, then draw. See below.
}

ui.Context.Config has two fields: ui (the theme, fonts, and state lifetimes) and arena_reset_mode.

The input data is borrowed until endFrame or abortFrame. The output is borrowed until the next beginFrame. Copy the data that you need for longer.

Apply the effects #

Apply each effect one time, before the next frame:

window.setCursorShape(output.cursor_shape);
if (output.clipboard_write) |text| _ = try window.setClipboardText(gpa, text);
if (output.close) break;
if (output.redraw) scheduleAnotherFrame();
  • text_input is true when a text field has focus. Show the on-screen keyboard or start text input on platforms that need it.
  • capture_pointer and capture_keyboard are true when the UI uses the pointer or the keyboard. Do not give that input to your game or scene.
  • accessibility is the snapshot for assistive technology. Read Accessibility.

Draw with Painter #

Painter draws a packet with the bundled GPU backend. It works in two stages:

  1. prepare uploads the packet data for one upload slot. It returns a Prepared value.
  2. encode records the draw commands into a render pass that you started. You can encode the same Prepared value into more than one compatible pass.
const painter = try renderer.Painter.create(gpa, render_context, gpu_frame.uploadSlotCount());
defer painter.destroyAfterWait();

// Each frame:
var submission = try gpu_frame.begin();
const prepared = try painter.prepare(&output.packet, &.{
    .width = extent.width,
    .height = extent.height,
    .content_scale = window.getContentScale(),
    .upload_slot = submission.upload_slot,
    .frame_context = submission,
    .linear_target = false,
});

var pass = try submission.beginRenderPass(.{
    .label = "ui",
    .color_attachment = .{ .clear_color = .{ 0, 0, 0, 1 } },
});
try painter.encode(&prepared, &pass);
pass.end();
try submission.submit();

Set linear_target = true when the target texture stores linear color, not sRGB. The embedded example is a complete program. It also shows how to create the device, the surface, and the render context.

Backdrop effects #

encode skips backdrop effects, because a backdrop must read the pixels behind it. To draw backdrops, set backdrops = true in the prepare options, and draw into a target that the painter can sample. Then use encodeSegment with a Painter.Cursor. It stops at each backdrop group. End the pass, capture the backdrop, and continue in a new pass that loads the target. renderer.Renderer does this for you.

Ownership and lifetimes #

  • Packet data, input slices, image bytes, and callback data are borrowed. Use them before the next frame, or copy them for async work.
  • Keep textures and callback resources alive until the GPU completes the work.
  • Complete the previous GPU work for an upload slot before you use the slot again in prepare.
  • Call Painter.destroyAfterWait only after all GPU work is complete.
  • The painter synchronizes glyph uploads and releases old resources by upload slot. You do not need to acknowledge glyph uploads.

Let Knots own the surface #

If you own the window but not the GPU passes, use renderer.Renderer. It owns the surface and draws a packet with one call:

const knots_renderer = try renderer.Renderer.create(gpa, render_context, window_handle, width, height, .{});
defer knots_renderer.destroy();

_ = knots_renderer.render(&output.packet, content_scale);

Call resize when the window size changes.

Write a custom renderer #

A renderer for another graphics API reads render.Packet directly. It does not need the renderer module. It must:

  • Draw the vertices, glyph instances, clips, images, and backdrops of the packet.
  • Upload the glyph atlas. Upload only the changed rows when the atlas base_revision matches the revision that it has. Otherwise, upload the full atlas.
  • Check the packet extensions, and reject commands that it does not support.

Vulkan shader sources #

A custom Vulkan renderer can use the Knots shaders. The build script exports their source paths:

const Knots = @import("knots");

const vertex = Knots.vulkanUIShaderSource(knots, .ui_primitives_vertex);
const fragment = Knots.vulkanUIShaderSource(knots, .ui_primitives_fragment);
// Also: .ui_primitives_instance_vertex, .slug_vertex, .slug_fragment

The shaders are Zig files. Compile them for the spirv32-vulkan target.