Warning
This project has to this point been mostly written by AI coding tools, and while I plan to personally vet every line, I haven't gotten around to doing so!
Render a DVUI UI directly into Blender via a Zig backend that defers draw commands to a small Python addon. Inspired by BlenderImgui, but for DVUI and without exposing UI calls to Python — your UI is written in Zig and compiled into a shared library.
The repo is a single Zig workspace with two sub-packages, a Python addon, and a build helper that lets external projects package their own DVUI app as a Blender addon:
backend/ zig pkg: deferred-render dvui Backend (no GL calls of its own)
sample_app/ zig pkg: a minimal dvui app exposing `frame()`
src/lib.zig integrator: cdylib with the C ABI (mouse + keyboard + render)
addon/ Blender Python addon (ctypes wrapper + bpy.gpu rendering +
modal operator forwarding events to DVUI)
build.zig buildBlenderAddon() helper for external projects
scripts/ test_blender.py for one-shot iterate-and-screenshot runs
The Zig side records (vertices, indices, draw_commands, textures) per
frame; the Python side pulls them out and submits them to Blender's
gpu module each redraw. No raw OpenGL is called from Zig — Blender owns
the GL context.
- Zig 0.15.2+
- Blender 4.x or 5.x (tested on 5.1.1)
- Linux (tested). macOS/Windows not yet exercised; the ctypes loader
picks the right
dylib/dllsuffix but nothing else has been verified
zig buildThis produces zig-out/lib/libblender_dvui.so (the addon resolves it
via BLENDER_DVUI_LIB env var or by falling back to that path).
The bundled sample addon lives at addon/. The test scripts put the
repo on sys.path so the addon imports as a package without needing to
be installed into Blender's user addons folder:
zig build && blender --python scripts/test_blender.pyWhen Blender finishes loading:
- The script invokes
bpy.ops.dvui_sample.start("INVOKE_DEFAULT"), which is a modal operator — it converts the largest area toNODE_EDITOR(the sample's configured space), attaches a draw handler, and forwards mouse / wheel / keyboard / text events to DVUI. - The Node Editor's N-panel gains a
DVUI Sampletab with start/stop buttons (also:bpy.ops.dvui_sample.stop()). bpy.ops.dvui_sample.start()is automatically invoked from the test script ~1.5s after launch.
To drive it manually, start Blender normally and from the Python console:
import sys; sys.path.insert(0, "/path/to/blender-dvui")
import addon as dvui_addon
dvui_addon.register()
dvui_addon.start() # modal operator picks up cursor events
# ... interact ...
dvui_addon.stop()
dvui_addon.unregister()blender --background --python scripts/import_check.pyLoads the cdylib, registers/unregisters the addon, and exits. Useful to catch ctypes-signature drift after editing the C ABI; the wrapper asserts struct sizes match the Zig side at import time.
zig build && blender --python scripts/test_blender.py -- --auto-quitAfter ~4s the script writes scripts/out/dvui_overlay.png (using
bpy.ops.screen.screenshot_area so popups/splash don't appear) and
quits Blender. Useful while tweaking the UI.
Add --click to also drive a synthesized click sweep over the floating
window through the C ABI; the sample app prints [sample_app] click #N
when its button registers a press, which is a quick way to confirm the
input pipeline still works after backend / Python changes.
If Blender's splash window blocks the viewport during testing, disable it once:
blender --background --python -c \
"import bpy; bpy.context.preferences.view.show_splash = False; bpy.ops.wm.save_userpref()"The sample UI lives in:
sample_app/src/app.zig
A DVUI app exposes:
pub fn frame() !void; // required
pub fn init(win: *dvui.Window) !void; // optional, called once after Window.init
pub fn deinit() void; // optional, called once before Window.deinit
// (deinit may also be `!void`)frame() is called once per overlay redraw between Window.begin and
Window.end. init / deinit run outside any begin/end window pair —
use them for any setup that allocates resources, parses CLI args, or
otherwise can't be done inside a frame. Inside frame() you write
normal DVUI code:
pub fn frame() !void {
var float = dvui.floatingWindow(@src(), .{}, .{});
defer float.deinit();
float.dragAreaSet(dvui.windowHeader("Hello", "", null));
if (dvui.button(@src(), "Click me", .{}, .{})) {
// handle click
}
}After editing, rebuild and restart the running session:
zig build
# in Blender:
bpy.ops.dvui_sample.stop()
bpy.ops.dvui_sample.start()Hot-reload of the cdylib mid-session isn't supported — Python's
ctypes.CDLLkeeps the library mapped for the life of the process. Restart Blender (or stop, evict fromsys.modules, re-import) to pick up a new build.
For DVUI's full widget vocabulary (layouts, themes, scrolling, etc.),
see https://david-vanderson.github.io/ and the examples under
~/opensource/dvui/examples/ if you have it cloned.
build.zig exposes a buildBlenderAddon helper. From the build.zig of
your own project that already has a DVUI app module:
// build.zig
const std = @import("std");
const blender_dvui = @import("blender_dvui");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const dvui_dep = b.dependency("dvui", .{
.target = target,
.optimize = optimize,
.backend = .custom, // required
.libc = true,
.freetype = false,
.@"stb-image" = true,
});
const dvui_mod = dvui_dep.module("dvui");
// Your DVUI app: must expose `pub fn frame() !void` and import dvui.
const my_app = b.addModule("my_app", .{
.root_source_file = b.path("src/app.zig"),
.target = target,
.imports = &.{ .{ .name = "dvui", .module = dvui_mod } },
});
const blender_dep = b.dependency("blender_dvui", .{
.target = target,
.optimize = optimize,
});
const addon = blender_dvui.buildBlenderAddon(b, .{
.blender_dvui_dep = blender_dep,
.dvui_module = dvui_mod,
.app_module = my_app,
.app_name = "My Awesome UI",
.space_type = "VIEW_3D", // any Blender editor enum
.target = target,
.optimize = optimize,
});
// Make `zig build` build the addon by default…
b.getInstallStep().dependOn(addon.step);
// …or gate it behind a custom step:
// const blender_step = b.step("blender-addon", "Build addon");
// blender_step.dependOn(addon.step);
}buildBlenderAddon returns a BlenderAddon whose .step is the
top-level step that triggers cdylib + Python file installation, and
.lib is the cdylib Compile step (handy if you need to add C
sources, link extra system libraries, etc.).
build.zig.zon should pull in both dvui (with the same hash this
repo uses; see build.zig.zon) and blender_dvui (path or url):
.dependencies = .{
.dvui = .{ .url = "...", .hash = "..." },
.blender_dvui = .{ .path = "../blender-dvui" },
},zig build then writes a self-contained addon directory and a
matching zip:
zig-out/
blender_addon/
my_awesome_ui-blender.zip (ready for Install from Disk…)
my_awesome_ui/
__init__.py (templated for app_name / slug / space_type)
dvui_native.py (verbatim copy)
overlay.py (verbatim copy)
libmy_awesome_ui_dvui.so
Either point Blender's Edit > Preferences > Add-ons > Install from
Disk... at the zip, or drop the directory into Blender's addons/
folder directly. Then enable "My Awesome UI". The N-panel of the
configured editor will gain a tab labeled with app_name and a
start/stop button. bpy.ops.<slug>.start() / <slug>.stop() are the
operator names.
Note: there is no truly new editor type. Blender's Editor-Type dropdown is C-registered and
bpy.types.Spaceis not subclassable from Python, so any Python addon — this one included — has to host its UI inside an existing Space (VIEW_3D,IMAGE_EDITOR, …) via adraw_handler_addPOST_PIXEL hook plus a modal operator for input.space_typeselects which one.
Blender will pick up addons from any <scripts>/addons/<name>/ layout
referenced via BLENDER_USER_SCRIPTS. If you set
install_root = "scripts/addons" in the helper, the build output is
already in that shape:
zig build blender-addon
# Tell Blender where to find addons, enable for this session, run it:
BLENDER_USER_SCRIPTS=$PWD/zig-out/scripts \
blender --addons my_awesome_ui
# Auto-start in the existing 3D viewport (or whichever space_type
# was passed to buildBlenderAddon):
BLENDER_USER_SCRIPTS=$PWD/zig-out/scripts \
blender --addons my_awesome_ui \
--python-expr "
import bpy
def _start():
win = bpy.context.window_manager.windows[0]
area = next(a for a in win.screen.areas if a.type == 'VIEW_3D')
region = next(r for r in area.regions if r.type == 'WINDOW')
with bpy.context.temp_override(window=win, area=area, region=region):
bpy.ops.my_awesome_ui.start('INVOKE_DEFAULT')
bpy.app.timers.register(_start, first_interval=1.0)
"bpy.context.preferences.addons.keys() only lists persistently
enabled addons (saved in user prefs); --addons enables for the
current session only. Confirm enable-state with
addon_utils.check('my_awesome_ui') returning (True, True). To make
the enable persist across launches:
blender -b --python-expr "import bpy; \
bpy.ops.preferences.addon_enable(module='my_awesome_ui'); \
bpy.ops.wm.save_userpref()"The space_type parameter is one of the standard Blender editor enums
("VIEW_3D", "IMAGE_EDITOR", "NODE_EDITOR", "GRAPH_EDITOR",
"PROPERTIES", "INFO", "TEXT_EDITOR", etc.) — pick whichever
Editor you'd like the overlay to take over. Blender doesn't allow
registering brand-new editor types from Python, but configuring an
existing area to host a DVUI overlay is the next-best thing.
The bundled addon/overlay.py defines a modal operator per app. Once
started, it forwards every Blender event to DVUI:
| Blender event | DVUI call |
|---|---|
MOUSEMOVE |
dvui_event_mouse_motion(x, y) (top-left origin) |
LEFTMOUSE / RIGHT / MIDDLEMOUSE |
dvui_event_mouse_button(button, pressed) |
WHEELUP/DOWNMOUSE |
dvui_event_mouse_wheel(0, ±dvui.scroll_speed) (default 80) |
| Special keys (TAB, RET, BACK_SPACE, arrows, PAGE_UP/DOWN, HOME, END, INSERT, SPACE, modifiers, A–Z) | dvui_event_key(code, pressed, mods) |
event.unicode on PRESS |
dvui_event_text(utf8 bytes) |
bpy.app.handlers.load_pre |
dvui_event_app_quit then dvui_event_window_close |
Addon.unregister |
dvui_event_app_quit then dvui_event_window_close |
Each event function returns whether DVUI consumed it; when it did, the
modal operator returns RUNNING_MODAL (event swallowed); otherwise it
passes through so Blender's regular handling still applies. The Y axis
is flipped inside the operator so DVUI sees top-left origin pixel
coordinates.
The C ABI also exposes everything else DVUI accepts, callable directly
from Python via the loaded library handle on session.native.lib (or
through helper methods on DvuiSession):
| C function | Python helper | DVUI API |
|---|---|---|
dvui_event_text_select(start, end) |
session.text_select(s, e) |
Window.addEventTextSelect |
dvui_event_focus(x, y, button) |
session.focus_at(x, y, b) |
Window.addEventFocus |
dvui_event_touch_motion(finger, x, y, dx, dy) |
session.touch_motion(...) |
Window.addEventTouchMotion |
dvui_event_window_close(ctx) |
(auto on session.stop) |
Window.addEventWindow(.close) |
dvui_event_app_quit(ctx) |
session.app_quit() |
Window.addEventApp(.quit) |
dvui_cursor_over_floating(ctx) |
lib.dvui_cursor_over_floating(ctx) |
Window.cursorRequestedFloating |
Blender exposes no native source for touch / pen-tip events at the
modal-operator level, so dvui_event_touch_motion is wired only as a
convenience entry point — call it from your own code if you obtain
touch state another way.
backend/src/backend.zig implements DVUI's Backend interface in a
deferred style:
drawClippedTrianglesappends to in-memoryvertices/indices/commandslists (indices are rewritten to be global to the merged vertex buffer)textureCreateallocates a CPU-side RGBA buffer, hands DVUI a monotonic id as the opaque texture pointer, and queues the id for Python to upload as agpu.types.GPUTexturetextureDestroyqueues the id for Python to drop itsGPUTexture
If you change the layout of Vertex / DrawCmd / TextureInfo, also
update the matching ctypes.Structures in addon/dvui_native.py. The
module asserts struct sizes against the Zig-side dvui_*_size exports
at import time, so a mismatch fails loudly.
addon/overlay.py owns the per-frame render loop on the Python side:
- One
gpu.types.GPUShaderbuilt viaGPUShaderCreateInfo(vertex uses an orthoProjMtxmapping DVUI's top-left pixel space to clip space; fragment isfrag = v_col * texture(tex, v_uv)) - A 1×1 white
GPUTextureis bound when DVUI emitstexture_id == 0 - Texture creates/destroys are drained each frame; pixel data goes
through a one-time
ctypes.string_at->Buffer("FLOAT", ...)conversion (current Blender'sGPUTextureconstructor only accepts float buffers) - Scissor rect from each
DrawCmdis mapped from DVUI's top-left origin to GL's bottom-left origin
overlay.make_addon(app_name, space_type, slug=, lib_basename=) is the
factory that produces the per-app Operator / Panel classes; the
auto-generated addons from buildBlenderAddon call it from their
templated __init__.py.
- DVUI's
Backendinterface is duck-typed; we implement the methods on a plain struct and passdvui.Backend.init(&self)toWindow.init - The
dvuidependency is taken with.backend = .customso the baredvuimodule is exposed; the rootbuild.zigdoeslinkBackendmanually (backend_mod.addImport("dvui", dvui_mod); dvui_mod.addImport("backend", backend_mod);) - Premultiplied alpha all the way through —
Color.PMAis what DVUI hands us, Blender'sgpu.state.blend_set("ALPHA_PREMULT")matches - For the data-flow diagram, struct layouts, and the rationale behind the deferred-render split see ARCHITECTURE.md
- No new editor type.
bpy.types.Spaceis C-registered and cannot be subclassed from Python; the addon hosts dvui inside an existing Space (VIEW_3D,NODE_EDITOR,IMAGE_EDITOR, …) selected byspace_type. InNODE_EDITORwe register a customNodeTreesubclass so the area is at least recognizable in the tree-type dropdown. - Cdylib is not hot-reloadable. Python's
ctypes.CDLLkeeps the.somapped for the life of the Blender process. Restart Blender afterzig build. - One DVUI session per addon, one area per session. The modal picks the first matching area; multi-viewport setups only render in one of them.
- Linux-tested only. ctypes loader picks the right
dylib/dllsuffix for macOS/Windows but nothing else has been exercised. - Cdylib only — no staticlib path.
| DVUI API | Status |
|---|---|
textureCreateTarget |
stub returns error.TextureCreate |
textureClearTarget |
no-op |
textureReadTarget |
stub returns error.TextureRead |
textureFromTarget / textureFromTargetTemp |
stub returns error.NotImplemented |
textureDestroyTarget |
no-op |
renderTarget |
no-op (rendering always goes to Blender's framebuffer) |
textureUpdateSubRect |
not exposed |
| AccessKit | disabled (.accesskit = .off) |
| FreeType font rendering | disabled — uses bundled stb_truetype |
tiny-file-dialogs |
disabled — use dvui.dialogNativeFile* paths through Blender's own dialogs instead |
tree-sitter |
disabled |
Widgets that rely on render-target textures (dvui.Picture, anything
that does an offscreen pass) won't work until those backend hooks are
implemented.