Fractals

The Mandelbrot set, drawn as text. It's the set of complex numbers c for which repeating z := z² + c, starting from zero, never runs off to infinity. Points in the set are drawn with @. Points outside it get a lighter character the sooner they escape, which draws the familiar bands around its edge.

Complex numbers

Complex is a builtin type - an alias for C64, with two F64 parts - so there's no struct to write and no library to add. Complex.new(x, y) is x + yi. Because it's Numeric, +, * and powers work on it like on any number.

z² is shorthand for z ^ 2: a superscript after a value raises it to that power, so the formula reads the same as on paper. magnitudeSquared() is |z|². Comparing it with 4.0 tests |z| > 2 without a square root.

Drawing

render maps each character cell to a point: the column to x, the row to y. Row and column are U64 counters, and as F64 converts them before the division, because arithmetic doesn't mix number types on its own. The escape time picks a character from shades. // is floor division, and ?? " " covers the index being out of range. cells.join() glues a row's cells together, and lines.join("\n") puts the rows on separate lines, with the argument as the separator between them.

@pure declares that escapeTime depends only on its argument, with no context reads or other side effects - the most expensive call here is also the easiest to reason about.

As an image

The same set as a PNG, one pixel per point. Image.generate calls the lambda once for every pixel with its column and row - (0, 0) is the top-left corner - and keeps the Color it returns. Color.fromHsv walks the hue around the color wheel as the escape time grows, so each band around the set gets its own color.

encodePng() turns the pixels into the bytes of a PNG file, and &fs.write saves them. !! passes on the error if the file can't be written. Image.decode reads PNG and BMP files back into an Image, and the test uses it to check the round trip: PNG is lossless, so the decoded image equals the original. See Image for the rest - cropping, resizing, rotating, and drawing one image on top of another.

The Mandelbrot set in black, with red, orange, yellow and green bands around its edge
mandelbrot.png, as the program writes it.

On the GPU

The PNG version computes 691,200 pixels one after another. A GPU runs the same steps for all of them at once, so it can redraw the set every time the view changes. This version runs in a web page with a <canvas id="mandelbrot">, through the browser's WebGPU.

Asking for the GPU…

The work per pixel moves into a shader, written in WGSL, WebGPU's shading language. #WGSL {{{ ... }}} keeps it in the program as a Wgsl value, with its brackets checked when the program compiles, and gpu.shader takes it as it is. vertexMain draws one triangle that covers the whole canvas. fragmentMain then runs once for every pixel under it, with the same loop as escapeTime and the same colors as shade: z² is written out by hand, and fromHsv stands in for Color.fromHsv. The GPU computes with f32, half the digits of F64, so a few pixels right on the edge of the set come out a different color than in the PNG.

What changes from one drawing to the next goes in a uniform buffer: the point at the center of the canvas, how far apart two pixels are, the step limit and the canvas size. view computes them, and floatBuffer copies them to the GPU as six F32s, in the order the shader's View struct lists its fields. The preview above uses the same shader. Dragging and zooming only write new numbers into that buffer and draw again.

The rest is WebGPU's usual setup. Gpu.request() gets the device, and canvas finds the element by its id. Both are IOErrors where there's nothing to find. Run anywhere but a browser, the program stops at the first !!. pipeline combines the shader with the canvas's pixel format. bindGroup binds the uniforms to the shader's @group(0) @binding(0). A GpuRenderPass records the commands, and submit sends them to the GPU and puts the result on the canvas.

Concepts shown

  • The builtin Complex type and superscript powers like z².
  • Typed constants, and converting between number types with as.
  • Nested for loops over ranges, with an early return.
  • Building text with arrays and join.
  • Marking a function @pure.
  • Drawing into an Image and saving it as a PNG.
  • Drawing on a canvas with Gpu: a WGSL shader, a uniform buffer and a render pass.