Pixels and glyphs¶
Games, fractals, fire, fluids, ray tracers and digital rain are ordinary
maya programs. There is no separate canvas loop and no paint callback: the
model holds the state, update() advances it on a Tick, and view()
returns an element built from a picture. Two elements cover every case:
pixels(img)draws anImage(a w × h RGB buffer), two pixels per cell.glyphs(grid)draws aGlyphsgrid of coloured characters.
Both live in <maya/element/pixels.hpp>. They compose like any other
element: put a status bar under them with v(...), a border around them,
or a sparse Glyphs over an image in a zstack.
What changed.
canvas_run,CanvasConfigand the(StylePool&, int W, int H)/(Canvas&, int W, int H)callbacks are deleted for applications. Pre-interning styles, keeping style ids in globals and re-interning them on resize are all gone:pixels()andglyphs()do the style bookkeeping for you.
Image¶
struct Rgb { std::uint8_t r = 0, g = 0, b = 0; }; // 24-bit colour
Image img(w, h); // w × h pixels, row-major, black
Image img(w, h, Rgb{0,0,40}); // ...filled with a colour
img(x, y) = Rgb{255, 128, 0}; // read / write a pixel
img.width(); img.height(); img.empty();
img.fill(Rgb{}); // clear
img.data(); // std::vector<Rgb>&, row-major
Image is a plain value type: copy it, keep it in a Model, compare it with
==.
A terminal cell is two pixels tall: pixels() draws each cell as the upper
half block ▀ with the foreground set to the top pixel and the background to
the bottom pixel. So a screen of cols × rows cells is cols × 2·rows
pixels:
fill_rows: per-pixel shaders on every core¶
When the picture is a pure function of the model (a fractal, a ray tracer,
a plasma), compute it with fill_rows:
Rows are spread over the machine's cores and interleaved (thread t
takes rows t, t+n, …), because cost varies by region and contiguous bands
would leave most threads idle behind the slowest. The function must only
read shared state. Small images (under 4096 pixels) run on one thread.
pixels()¶
pixels(Image img) // copies the image into the element
pixels(std::shared_ptr<const Image> img) // shares it: building the view copies a pointer
The element fills the space layout gives it. The image is sampled to that size (nearest neighbour), so a model can either keep a fixed-resolution buffer and let it stretch, or size itself to the screen by handling a resize message (exact size = no sampling). Either way the view stays a pure function of the model.
Cost: one style lookup per cell through a small direct-mapped cache keyed on the packed (top, bottom) colour pair, and the frame diff sends only the cells that changed.
Tip: quantise colours for the style cache¶
Every distinct (top, bottom) pair becomes an interned style. A smooth gradient or a tone-mapped ray tracer can produce millions of distinct pairs, which thrashes the cache and the style pool. Dropping the low 3 bits of each channel (5 bits per channel) is visually indistinguishable and keeps the working set small:
auto q = [](float f) { // [0,1] -> 0..255, 5-bit
return static_cast<std::uint8_t>(static_cast<int>(std::clamp(f, 0.f, 1.f) * 255.f) & 0xF8);
};
return Rgb{q(r), q(g), q(b)};
raymarch.cpp and space3d.cpp do exactly this. Palette-driven images
(fire, life, a 256-entry lookup table) don't need it: they already use few
colours.
Glyphs and glyphs()¶
Glyphs is the text twin of Image: a cols × rows grid of coloured
characters, for character art such as digital rain, a starfield of ., *
and +, or a spectrum of block glyphs.
struct Glyph {
char32_t ch = U' ';
Rgb fg {200, 200, 200};
Rgb bg {0, 0, 0};
bool bold = false;
};
Glyphs g(cols, rows); // filled with Glyph{}
Glyphs g(cols, rows, Glyph{U' ', {}, {0, 8, 0}});
g(x, y) = Glyph{U'ア', Rgb{120, 255, 140}};
if (g.in(x, y)) { /* bounds check */ }
g.text(2, 0, U"SCORE 120", Rgb{255, 255, 0}, Rgb{}, /*bold=*/true); // clipped
glyphs(std::move(g)) is the element. The grid is drawn from the top left
of its slot and is not scaled, so size it to the slot in update() from a
resize message, like an Image. Cells with ch == 0 are left untouched, so a
sparse grid can sit on top of something else in a zstack.
A complete program¶
A plasma that fills the screen, animated at ~60 Hz, with a status bar and a
pause key. The model sizes itself from ResizeEvent; view() builds the
image; subscribe() stops the timer while paused, so a still screen costs
nothing.
#include <maya/element/pixels.hpp>
#include <maya/host/run.hpp>
#include <algorithm>
#include <chrono>
#include <cmath>
#include <cstdint>
#include <optional>
#include <string>
#include <variant>
using namespace maya;
using namespace maya::dsl;
using namespace std::chrono_literals;
struct Plasma {
struct Model {
int w = 0, h = 0; // pixels: w = columns, h = 2 × (rows - 1)
float t = 0.f;
bool paused = false;
};
struct Tick {};
struct Resize { int cols, rows; };
struct Pause {};
struct Quit {};
using Msg = std::variant<Tick, Resize, Pause, Quit>;
using Cmd = jaal::Cmd<Msg>;
using Sub = jaal::Sub<Msg>;
static Cmd update(Model& m, Resize r) {
m.w = std::max(1, r.cols);
m.h = std::max(2, (r.rows - 1) * 2); // one row of cells for the status bar
return {};
}
static Cmd update(Model& m, Tick) { m.t += 0.03f; return {}; }
static Cmd update(Model& m, Pause) { m.paused = !m.paused; return {}; }
static Cmd update(Model&, Quit) { return Cmd::quit(0); }
static Image render(const Model& m) {
auto q = [](float f) { // 5-bit channels keep the style cache small
return static_cast<std::uint8_t>(static_cast<int>(std::clamp(f, 0.f, 1.f) * 255.f) & 0xF8);
};
Image img(m.w, m.h);
img.fill_rows([&](int x, int y) -> Rgb {
const float u = static_cast<float>(x) / 16.f, v = static_cast<float>(y) / 8.f;
const float s = std::sin(u + m.t) + std::sin(v - m.t) + std::sin((u + v) * 0.5f + m.t * 1.3f);
return {q(0.5f + 0.5f * std::sin(s)), q(0.5f + 0.5f * std::sin(s + 2.f)), q(0.5f + 0.5f * std::sin(s + 4.f))};
});
return img;
}
static Element status_bar(const Model& m) {
return h(text(" PLASMA") | Bold | Fg<255, 140, 40>,
text(" [space] pause [q] quit") | Dim,
spacer(),
text(m.paused ? "paused " : "")) | bgc(Color::rgb(20, 20, 20));
}
static Element view(const Model& m) {
if (m.w == 0) return text(""); // no size yet
return v(pixels(render(m)), status_bar(m));
}
static std::optional<Msg> on(const Model&, const ResizeEvent& r) {
return Resize{r.width.value, r.height.value};
}
static std::optional<Msg> on(const Model&, const KeyEvent& k) {
static const keymap<Msg> keys{{' ', Pause{}}, {'q', Quit{}}, {SpecialKey::Escape, Quit{}}};
return keys(k);
}
static Sub subscribe(const Model& m) {
if (m.paused) return Sub::none();
return Sub::every(16ms, Tick{});
}
static bool subs_key(const Model& m) { return m.paused; }
};
static_assert(Program<Plasma>);
int main() { return run<Plasma>({.title = "plasma"}); }
Points to notice:
- All state is in the Model. The image is rebuilt from it in
view(); nothing is kept in globals or statics. - Size comes from a message. The
ResizeEventoverload delivers the terminal size (also once at start-up);update()turns it into pixel dimensions. Until it arrives,m.w == 0and the view is empty. - Animation is a subscription.
Sub::every(16ms, Tick{})drives the frame rate.subs_key()tells the runtime whatsubscribe()depends on, so the timer is only rebuilt whenpausedflips. - Simulation state lives in the model, too. For a stateful effect (the
Doom fire's heat field, Life's grid, particles) keep the buffer in the
Model, advance it in
update(Model&, Tick), and haverender()map it through a palette into an Image. Seeexamples/doom_fire.cpp.
Where to look next¶
| Example | Shows |
|---|---|
doom_fire.cpp |
heat field in the Model, palette LUT into an Image, embers drawn as whole cells |
mandelbrot.cpp |
fill_rows per-pixel shader, zoom/pan messages |
raymarch.cpp, space3d.cpp |
ray tracing with fill_rows and 5-bit quantisation |
fluid.cpp, life.cpp, particles.cpp |
simulations stepped in update() |
breakout.cpp, snake.cpp |
games: pixels plus text overlays |
matrix.cpp |
Glyphs digital rain |
Low-level: Canvas and StylePool¶
Canvas, Cell and StylePool still exist as the renderer's internals:
the element tree paints into a Canvas, cells are packed into 64 bits and
diffed with SIMD, and styles are interned into a StylePool. Applications
no longer touch them: pixels() and glyphs() are the cell-level surface.
See Wire efficiency for how frames are diffed.