Skip to content

renderffx/butterflies

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

butterflies

a reactive ui framework that runs in the browser, written in zig, compiled to webassembly, rendered with webgpu.

you write zig code. it compiles to a small wasm binary. the browser loads it and renders everything through webgpu. no virtual dom. no diffing. no garbage collector. just raw speed.

how butterflies works zig source signals, widgets, math style, layout, effects wasm binary ~2mb release small zero alloc runtime browser webgpu canvas 60fps rendering the rendering pipeline you call app.begin() layout engine computes rects render tree flattened cmds gpu renderer sdf quads webgpu single draw call

what you get

the framework gives you a way to build user interfaces using zig. you describe your ui as a tree of widgets. each frame, the framework lays out the tree, figures out where everything goes, and sends it to the gpu in one batch.

here is what the kitchen sink example looks like when rendered:

butterflies kitchen sink every widget and pattern in one place home settings about cpu 42.7% memory 1.2gb latency 0.8ms fps 120 counter demo current value: 42 minus one plus one reset progress bars brightness: 75%

getting started

you need zig 0.13 and bun. that is it.

# install bun if you do not have it
curl -fsSL https://bun.sh/install | bash

# clone and enter the repo
git clone https://github.com/renderffx/road-track.git
cd road-track/butterflies

# install npm deps
bun install

# build the wasm binary
zig build wasm-browser

# start the dev server
bun run dev

# open http://localhost:8080

the first time you run zig build wasm-browser it will take a few seconds. after that it is fast because zig caches everything.

how to write a widget

here is the simplest possible app. it shows a counter with plus and minus buttons.

const fwk = @import("root.zig");
const App = fwk.api.App;
const Color = fwk.style_mod.Color;
const Length = fwk.style_mod.Length;
const Rect = fwk.style_mod.Rect;

var app: App = undefined;
var count: i32 = 0;

fn render() void {
    app.begin(.{
        .width = Length.px(400),
        .height = Length.px(300),
        .background = Color.rgb(18, 18, 24),
        .direction = .column,
        .padding = Rect.all(24),
    });

    app.text("count: {d}", .{count});
    app.spacer(0, 16);

    app.row(.{});
    if (app.button(.{}, "minus")) {
        if (count > 0) count -= 1;
    }
    app.spacer(8, 0);
    if (app.button(.{}, "plus")) {
        count +|= 1;
    }
    app.end();

    app.end();
}

that is it. you call app.begin() to open a container, add stuff inside it, then call app.end() to close it. buttons return true when clicked. text uses zig format strings. everything else is handled by the framework.

the widget catalog

here is every widget you can use. each one works the same way: call it inside a begin/end block.

text

displays text. uses zig format strings so you can embed values.

app.text("hello {s}", .{"world"});
app.text("score: {d}", .{42});
app.text("pi is {d:.2}", .{3.14159});

button

a clickable button. returns true when the user clicks it.

if (app.button(.{}, "click me")) {
    // do something when clicked
}

you can style the button with background color, padding, border radius:

if (app.button(.{
    .background = Color.rgb(180, 40, 40),
    .border_radius = 8,
}, "danger")) {
    // handle danger action
}

slider

a horizontal slider that modifies a value. you give it a pointer to a float and it updates it when the user drags.

var volume: f32 = 50;
app.slider(.{}, &volume, 0, 100, 300);
app.text("volume: {d:.0}%", .{volume});

the last number (300) is the pixel width of the slider track.

toggle

a switch that flips a boolean. returns true when toggled.

var dark_mode: bool = true;
if (app.toggle(.{}, &dark_mode)) {
    // state changed
}
app.text("  dark mode", .{});

progress bar

shows a fraction as a filled bar.

app.progressBar(.{}, 0.65, 400);  // 65% full, 400px wide

dropdown

cycles through a list of options. each click moves to the next option.

var selected: u32 = 0;
const options = [_][]const u8{ "red", "green", "blue" };
app.dropdown(.{}, &selected, &options);

input

a text input field. you pass it a buffer and a length pointer.

var buf: [128]u8 = undefined;
var len: u16 = 0;
app.input(.{}, &buf, &len);

colored box

a simple colored rectangle. useful for swatches, dividers, indicators.

app.coloredBox(Color.rgb(255, 0, 0), 40, 40);  // red, 40x40

spacer

invisible spacing between elements.

app.spacer(0, 16);  // 0 wide, 16 tall (vertical gap)
app.spacer(8, 0);   // 8 wide, 0 tall (horizontal gap)

row and column

layout containers. row puts children side by side. column stacks them.

app.row(.{});
app.button(.{}, "left");
app.button(.{}, "right");
app.end();

app.column(.{});
app.text("top", .{});
app.text("bottom", .{});
app.end();

scroll

a scrollable container.

app.scroll(.{
    .width = Length.px(300),
    .height = Length.px(200),
});
// lots of content here
app.end();

styling

every widget accepts a StyleProps struct. you only need to set what you want. everything else uses defaults.

app.begin(.{
    .width = Length.px(200),
    .height = Length.px(100),
    .background = Color.rgb(25, 25, 35),
    .border_radius = 10,
    .padding = Rect.all(12),
    .direction = .column,
});

here is what each property does:

property what it does example
width how wide Length.px(200) or Length.pct(50)
height how tall Length.px(100) or Length.pct(100)
background fill color Color.rgb(25, 25, 35)
color text color Color.WHITE
border_radius rounded corners border_radius = 10
padding inner spacing Rect.all(12) or Rect{ .top = 8, .left = 4 }
margin outer spacing Rect.all(8)
direction row or column .direction = .row
opacity transparency opacity = 0.5
z_index layer order z_index = 10

colors

you can make colors three ways:

Color.rgb(255, 128, 0)           // from red, green, blue
Color.rgba(255, 128, 0, 128)     // with alpha
Color.fromHex(0xFF8000)           // from hex code

lengths

Length.px(200)     // 200 pixels
Length.pct(50)     // 50% of parent
Length.auto()      // fit content
Length.fill()      // fill remaining space

the full pipeline

here is exactly what happens every frame, from your zig code to pixels on screen:

what happens each frame step 1: describe you call begin, text, button, slider, end this builds a tree step 2: layout engine computes x, y, width, height for every node step 3: flatten tree becomes a flat list of render commands (quads) step 4: draw webgpu renders all quads in a single draw call inside the wasm binary signal graph dirty tracking ecs registry entity slots style store 2048 styles layout engine flexbox subset math simd memory: slab allocator (entities) + arena allocator (per frame) + string interning (fNV-1a)

the kitchen sink example

the file src/examples_kitchen_sink.zig shows every feature in one app. it has:

  • a tab bar that switches between three views
  • metric cards showing live data
  • a counter with increment, decrement, and reset
  • progress bars that respond to a slider
  • a dark mode toggle
  • a dropdown selector
  • a text input field
  • color swatches

to run it, you point the main entry to renderApp() instead of the default example. or just read the source to see how each widget is used.

the test suite in that file verifies that every tab renders, every widget works, and the counter behaves correctly.

zig build test

this runs all the zig unit tests including the kitchen sink tests.

memory model

butterflies does zero heap allocations at runtime. here is how:

memory layout slab allocator fixed size blocks entities, styles, layout nodes alloc: O(1) pop from free list free: O(1) push to free list arena allocator bump pointer per frame scratch text buffers, temp alloc: advance pointer free: reset to start string interning fNV-1a hash deduplicates strings one copy per unique str lookup: O(1) hash map store: grow-only pool

the slab allocator is the workhorse. it pre-allocates a fixed number of blocks (2048 styles, 1024 entities, 4096 effects). when you need a new entity, it pops from a free list. when you are done, it pushes back. no malloc, no free, no gc.

the arena allocator is used for temporary data that lives only one frame. text buffers, format strings, intermediate layout results. at the end of the frame, the arena resets to the beginning. everything is gone. ready for next frame.

string interning means if you use the same string in 100 places, it only exists once in memory. the fNV-1a hash gives you O(1) lookups.

signals and reactivity

butterflies has a reactive signal graph. when a signal changes, anything that depends on it gets re-evaluated.

// signals are created through the wasm api
// nz_create_signal(0.0) -> returns an id
// nz_set_signal(id, new_value)
// nz_get_signal(id) -> reads the value

// the framework tracks which signals each effect reads
// when you call nz_set_signal, all dependent effects re-run

the signal system supports:

  • automatic dependency tracking (reads during effect execution register dependencies)
  • batch updates (multiple signal changes in one tick)
  • version numbers (check if a signal changed since last read)
  • overflow sentinel (0xFFFFFFFF means invalid/unset)
signal dependency graph signal A signal B effect 1 effect 2 re-render

testing

there are three levels of tests:

zig unit tests

these test the framework internals. signals, memory, layout, math. they run fast and do not need a browser.

zig build test

browser e2e tests

these use playwright to open a real browser, load the wasm, and verify it works.

bun run test:e2e

the full suite

this runs everything: zig tests, browser tests, security checks, performance benchmarks, accessibility audits, visual regression, and bdd step definitions.

bun run nuclear

that one command builds, tests, measures coverage, runs mutation testing, and gives you a full quality report.

project structure

butterflies/
  src/
    api.zig              the app struct with all widgets
    wasm_entry.zig       wasm exports (nz_init, nz_tick, etc)
    core/
      signal.zig         reactive signals
      effect.zig         effect system (4096 slots)
      batch.zig          batched updates
      ecs.zig            entity component system
      slot_map.zig       generational handles
      benchmark.zig      timing and stats
      profiler.zig       scoped profiling
      frame_scheduler.zig fixed timestep
    ui/
      style.zig          colors, lengths, styles (2048 slots)
      layout.zig         flexbox layout engine
      component.zig      element store
      render_tree.zig    tree flattening
      event_dispatch.zig input events
    renderer/
      webgpu.zig         gpu device management
      gpu_render.zig     sdf quad rendering
      webgpu_api.zig     webgpu bindings
    memory/
      slab.zig           slab allocator
      arena.zig          arena allocator
      string_intern.zig  fNV-1a string dedup
    math/
      simd.zig           vec2, vec3, vec4, mat4
    threading/
      lockfree_queue.zig spsc lock-free queue
      worker_pool.zig    thread pool
  web/
    index.html           entry page (csp hardened)
    framework.js         browser runtime
    shaders.wgsl         webgpu shaders
  tests/
    e2e.spec.js          playwright e2e
    security/            security tests
    performance/         perf regression
    accessibility/       a11y tests
    visual/              screenshot tests
    browser/             cdp browser tests
    utils/               test helpers
  scripts/
    quality-gate.js      full quality pipeline
    coverage.js          coverage analysis
    mutation-test.js     mutation testing
    qa-checklist.js      automated qa checks

building for production

# builds with size budget (fails if > 2mb)
bun run build:prod:check

# the output is at:
# zig-out/wasm/butterflies-browser.wasm

for deployment, copy the web/ folder and the wasm binary to any static host. there is an nginx config included if you want to serve it yourself.

# quick test with nginx
cp nginx.conf /etc/nginx/sites-available/butterflies.conf
ln -s /etc/nginx/sites-available/butterflies.conf /etc/nginx/sites-enabled/
nginx -t && systemctl reload nginx

browser support

you need a browser with webgpu. that means chrome 113+, edge 113+, or opera 99+. firefox and safari do not support webgpu yet.

the framework itself (zig to wasm) works in any modern browser. only the rendering part needs webgpu.

performance targets

the framework is designed for speed. here are the targets:

metric target
average frame time less than 1ms
p99 frame time less than 5ms
wasm binary size less than 2mb
memory per frame zero allocations
render calls per frame 1 (batched)
layout computation sub-millisecond for 1000 nodes

how it renders

the renderer uses signed distance fields (sdf) to draw rounded rectangles. instead of rasterizing pixels, the gpu computes whether each pixel is inside or outside the shape. this means:

  • one draw call for the entire ui
  • resolution independent (looks sharp at any zoom)
  • rounded corners for free (just a math formula)
  • no texture atlas needed

the vertex shader receives position, uv, color, and corner radius for each quad. the fragment shader computes the sdf and outputs the pixel color. alpha blending handles overlap.

sdf rendering of a rounded box rounded rect border_radius = 12 sharp rect border_radius = 0 the gpu computes distance to edge per pixel, no rasterization needed

license

mit. do whatever you want with it.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages