I made something that I think is pretty neat, and I want to tell you about it.

This is a little hot air balloon made out of alternating layers of brass and bronze that stack together with these angled facets:

It’s 3D printed, sort of, but it really is solid metal – it’s not a metallic filament. It’s made by “lost wax casting,” where you 3D print a model out of resin, then pack it in plaster, and then once the plaster dries you melt out the resin and fill the void with molten–

You know what? This is neat, but this actually isn’t what I wanted to tell you about.

Neither is that, but we’re getting closer.

That’s the 3D model that the balloon is cast from. I didn’t actually cast the balloon – I paid someone else to do that for me – but I did make the 3D model.

And it’s an interesting 3D model. It’s not a triangle mesh, like most 3D shapes you encounter. It has no faces; it has no vertices. Instead, it’s made entirely out of math: this balloon is a pure function of 3D space.

Here, take a look:

(def thickness 25)
(def sections 12)
(def angle (pi * 0.25))
(def lobe-intensity 1)
(def bezel 1)
(def bronziness 1.5)
(def branzino false)
(ball [(100 / (lobe-intensity + 1)) 100 100]
| union :r 50 (cylinder y 25 50 | move [0 -100 0])
| scale y (ss p.y -100 100 1 0.8)
| intersect :r bezel
  (plane y | shell thickness
  | color (gl/if (mod $i.y 2 | = 0) (pow default-3d-color bronziness) default-3d-color)
  | tile: $i [0 thickness 0]
  | rotate z (remap- parity * angle)
  | gl/let [parity (mod $i 2)] _)
| radial: $i y sections
| move y 40
)

There’s the source code to that hot air balloon. Mess around with it. Edit some constants. Pull up the autocomplete with ctrl-space, and see what else it can do.

This is called Bauble, and this is what I wanted to tell you about.

Bauble is a tool – toy? – that I wrote in 2022, because I wanted to make pictures with math on my computer. And not just simple geometrical things like that. I wanted to make pictures like this:

(defn fork [shape f1 f2]
  (union (f1 shape) (f2 shape)))

(defn spoon [shape f1 f2]
  (union shape (f1 shape) (f2 shape)))

(defn cel [shape rgb]
  (shade shape :f (fn [light] (gl/do
    (var cel-shadow (step 0.8 light.brightness + 1 / 2))
    (var cel-shading (dot light.direction normal * light.color | quantize 2 * cel-shadow))
    (var regular-shading (dot light.direction normal * light.color * cel-shadow))
    (var b (mix cel-shading regular-shading 0.5 + 0.5))
    (b * rgb)))))

(setdyn *lights* [(light/directional 1 [-2 -2 -1] 1024 :shadow 0.25)])

(def ear
  (cone y 40 153 :r 12
  | morph 0.15 (sphere 46 | move y 64)
  | union :r 13
    (cylinder y 26 30 | move y -10)
  | scale z 0.5))

(def ears
  (ear
  | move x 134
  | rotate z (tau * -0.01)
  | mirror x))

(def body
  (ball [1 0.75 0.5 * 100]
  | union :r 72 (ball [0.58 (.84 * 0.75) 0.5 * 250] | move y -156)))

(defn body-color [$] (cel $ (hsv (4 / 6) 0.1 0.3)))

(def decoration (rect [32 10] :r 10 | rotate (q.x * 0.044 - pi) | rotate pi))
(def top-decorations
  (fork decoration
    (fn [$] ($ | rotate -0.21))
    (fn [$] ($ | scale 0.9 | move x 71 | rotate -0.30))
  | move x 40
  | mirror x))
(def bottom-decorations
  (spoon decoration
    (fn [$] ($ | scale 0.95 | move x 76 y 0 | rotate -0.14))
    (fn [$] ($ | scale 0.9  | rotate -0.59 | move x 140 y -37))
  | mirror x))

(def decorations (union top-decorations (bottom-decorations | move y -80)))

(def tummy-patch
  (box 110 :r 64
  | morph 0.70 (sphere 118)
  | move z 60 y -137
  | cel (hsl (/ 69 255) 0.10 0.65)))

(def body-and-ears
  (union :r 6
    body
    (ears | scale 0.42 | move y 81)
  | body-color
  | union-color (subtract tummy-patch (decorations | extrude z inf | scale 0.5 | move y -46))))

(def eyes
  ( sphere 14
  | union :r 4 (box [14 0 1] | move z 4)
  | cel 10 | union-color (sphere 5 | move z 14 | cel 0.05)
  | scale z 0.5
  | rotate x -0.37 y 0.34
  | move [52 28 43]
  | mirror x))

(def arms
  (box [15 100 (ss p.y -100 50 25 40)] :r 15
  | rotate z (p.y * 0.002)
  | rotate z 0.30
  | move x 131 y -122
  | mirror x
  | body-color))

(def whiskers
  (union
    (line [0 0 0] [85 0 0] 1.5 0.5 | rotate z 0.03 | move [0 6 0])
    (line [0 0 0] [82 0 0] 1.5 0.5 | rotate z -0.03)
    (line [0 0 0] [87 0 0] 1.5 0.5 | rotate z -0.10 | move [0 -6 3])
  | move [60 0 41]
  | mirror x
  | color 0.1))

(def floor (ground -300 | cel (hsv 0.7 0.1 0.04)))

(def nostrils
  (cylinder z 2.5 3
  | move [7 1 4]
  | mirror x
  | color [0 0 0]))

(def nose
  (ball 11
  | subtract :r 2 (cylinder z 10 10 | move x 11 y -12 | mirror x :r 1)
  | scale z 0.6 y 0.7 x 1.5
  | rotate x -0.5
  | cel (hsv 0 0.0 0.02)
  | union-color nostrils
  | scale 1.20
  | move z 48 y 30))

(def lilypad
  (cylinder y (5 * sin (theta + 1 * 3) * cos (theta - 1 * 2) + 40) 0.3
  | move y (sin (theta * 4) * cos (theta * 2 + (length p.xz / 8 + 18)) * dot p.xz * 0.005 + (ss p.z 0 40 0 -10))
  | slow 0.7
  | union :r 5 (cylinder y 1 10 :r 1 | move y 10 | rotate x 0.2 z (sin (p.y / 4) * 0.1))
  | move y 77
  | cel (hsv (2 / 6) 0.9 0.5)
  | gl/let [theta (atan2 p.xz)] _))

(union
  eyes
  (union body-and-ears
  arms :r (10 - (distance [(abs p.x) p.y p.z] [120 -78 -1] / 10) | max 0)
  | expound (perlin p 15) 0.05 2)
  whiskers
  floor
  nose
  lilypad
| tint white (fresnel 5 * 0.3))

I had just discovered signed distance functions, and I was enamored by the power that they give you to sculpt space with simple mathematical expressions.

Signed distance functions – SDFs – are amazing, and if this is the first time you’re hearing about them, you should probably drop everything you’re doing today and watch this twenty-five minute video of Inigo Quilez using signed distance functions to create an animation instead. Yes, twenty-five minutes is a lot of minutes. It’s worth it.

I know you didn’t actually watch the video, but the overall gist is that someone very smart and very good at math describes an animation he created out of pure functions of time and space. But the description is pretty high-level: he says things like “we’ll define three circles that we spin as we move down the parameterization of the curve,” which is a beautiful way to think about the effect he uses to create the braids in that video – but how do you actually do that?

Well, you write several hundred lines of something called GLSL, plumbing arguments around and looking up how to construct rotation matrices and forgetting that matrices are column-major in GLSL and trying to remember what you stuck in the w component of this vector and, well, you can do it, and lots of people have, but not without losing some of the mathematical elegance of the original, intuitive presentation.

Because I really just want to write “gimme three circles extruded along a bezier curve, and rotate them by an angle that varies with the current position along the curve,” you know?

(circle 10
| color (hsv ($i + 3 / 6) 0.6 1)
| radial: $i 3 5
| rotate ($t * tau * 4 + t)
| bezier: $t [-100 0 100] [0 100 0] [100 0 -100]
    :to (osc t 5 0 1 | ss 0.1 1))

(You can click to pause any of the animations on this page.)

So: Bauble. I wrote Bauble to solve this impedance mismatch, so that I could play around with the SDFs the way that I wanted to play around with SDFs: in a functional, expression-oriented programming language. SDFs are signed distance functions, remember, and primitive operations on SDFs like rotation or translation are literal function composition. You can write a function that takes an SDF and an angle and returns a new SDF – a new function – for the rotated shape.

But you can’t write that in GLSL! GLSL doesn’t have first-class functions, so you actually have to do this composition by hand: if you want to rotate a shape, you have to rotate its input coordinate first, then pass the newly-rotated point in space to the SDF. Which, like, is fine, but it’s friction, and that’s the very simplest sort of operation – once you get into more interesting higher-order operations like instanced tiling, the friction stops feeling fine.

(gl/def apothem (osc t 15 5 10))
(circle (oss t 7 (apothem * 0.5) (apothem * 2 / sqrt 3))
| shade (hsv (hash $i + (t / 10)) 0.75 0.8)
| with-lights (light/point 1 (P + normal))
| move x (mod $i.y 2 * apothem)
| tile: $i [(apothem * 2) (apothem * sqrt 3)] :oversample true
| revolve z | rotate x (t / 20) z (t / 5)
| intersect (cylinder x 150 20 :r 20) :r 2)
(set camera (camera/perspective [403 0 0] :fov 45))

Bauble is not just a higher-level language, though. It’s more accurate to say that I started working on Bauble because I was frustrated with the speed at which I was able to write shaders using SDFs. Not just the verbose manual composition, but the verbose manual composition: it’s hard to compose a detailed scene in pure code! I didn’t only want to make abstract “shadery” looking things. I wanted to be able to make characters too, and that requires a degree of precise and subjective control: I wanted to be able to drag things around, edit shapes interactively, see my shader update live, look at it from different angles… but instead I was over here backspacing over a 1.4, typing 1.5, recompiling, and deciding if it looks better or worse.

# "manta raymarching"
(gl/def bg (ok/mix [0.1 0.1 0.25] sky (ray.direction.y | remap+)))
(triangle [(ss (q.y * gl/if (< q.x 0) 1 0) 0 65 65 40) 130] | rotate (q.y / 270)
| extrude y | expand (ss p.z 140 0 1 3)
| union :r 10 (cone x 18 -184 :r 10 | scale [1 1 2] | move x 49)
| shell 4
| subtract :r 5 (ball [30 30 30] | move x 75)
| union :r 10
  (rect [11 5] :r [0 5 5 0]
    | extrude z
    | expand 1
    | rotate x (p.y / -30)
    | rotate x (p.x / 40) | pivot [-10 0 0]
    | rotate y (p.x / 200 + (sin+ t * 0.5)) | pivot [-10 0 0]
    | move x 70 z 28)
| scale [1 (ss p.x 0 -100 1 0.2) 1]
| union :r 3 (cone x 5 -100 | move x -45)
| shade (mix (blue * 0.03) [0.9 1 1]
(2000 / (distance (abs p.xz) [57 117] | pow 3) | clamp 0 1)
) :g 20
| mirror z
| union-color (
  (shape/2d
    (gl/if (> (hash ($i + 100) * (14 / length ($i * [2 1]))) 0.5)
      (distance q (hash2 $i * 4) - (ss (hash $i) 0 1 1 1 * (ss normal.y 0.5 1)))
      1000))
  | tile: $i [4 4] :oversample true | shade white | extrude y inf)
| morph (shade r3 gray) :distance 0 :color (1 - occlusion :dist 40 | ss 0.4 0.5
+ (dot normal -y | max 0)
)
| union (ball 2 | move [61 -3 32] | mirror z | shade [0 0 0] :g 20 :s 1)
| rotate x (ss p.z 200 0 1 0 * osc t 3 pi/4 -pi/4) | pivot [0 0 10]
| rotate x (ss p.z -200 0 1 0 * osc (t + (sin t * 0.1)) 3 -pi/4 pi/4) | pivot [0 0 -10]
| rotate z (p.x / 800 * osc t 3 -1 1 + osc t 3 -0.1 0.1) | pivot [(oss t 6 -100 100) 0 0]
| rotate x (osc t 6 -0.1 0.1) y (osc t 12 -0.2 0.2)
| bound (ball 140 | move x -20) 20
| move (hash3 $i * 400 + [(osc t 3 -10 10) (oss t 6 -40 40) 0])
| gl/let [t (hash $i * 10 + t)] _
| tile: $i (vec3 700) :limit [50000 8 10] :oversample true
| move x (t * 150)
| map-color (fn [c] (mix c bg (depth / 5000 | pow 2 | clamp 0 1)))
| slow 0.8)

(set background-color bg)

And the camera, gosh – modeling in 3D with a fixed camera is hard. And – while I realize this sounds really dumb – I think the math to calculate a perspective matrix and position a camera where you want pointed in the direction that you want is actually much harder than any the math related to the actual SDFs that you’re trying to render.

So I whipped up a little hack that would basically just concatenate strings of GLSL for me, and put them in a little window with a moving camera.

It took me a few days to get it working: I decided that I wanted to use Janet as my “high level” language, because I’d had a positive experience with the language before, and I knew that it could at least in theory run inside the browser. I had never used WebAssembly before, and I had barely used Janet at this point, and even following existing examples I had quite a time getting it to work. Nevermind that it had been almost a decade since I’d done web development seriously, and my sole experience with WebGL at that point was making a few visuals for an old blog post.

But I got something working, eventually. Here’s the very first demo I ever recorded of the thing that would become Bauble:

Notice the dark, oversaturated colors. I didn’t know I had to do my own gamma correction! This was like my third ever shader. I had no idea what I was doing.

But even though this was extremely crude – it was literally GLSL string concatenation, of a few fixed primitive shapes, with no dynamic expressions of any kind – it was already so much better than writing GLSL by hand. Even just being able to type [1 2 3] instead of vec3(1.0, 2.0, 3.0) was worth the time I’d spent on it.

And it was fun. There’s something so viscerally satisfying about making something you can touch and play with and see in real time like this. I was having fun working on this little toy, so I kept working on it.

I implemented an orbital camera. I switched the editor to CodeMirror, and learned how to write a Janet grammar for it, so that I could directly manipulate the parsed AST to edit values with my mouse (ctrl-click and drag on any number!). With CodeMirror came TypeScript, which I had never used before, and some cruel prank called “rollup,” and I got to experience firsthand the hell of the modern JavaScript ecosystem. I wrote a UI, and decided to try something called SolidJS, which I’ve mildly regretted ever since.

(union
  (revolve shape y radius
  | move y (atan2+ p.xz / tau * sep + (round (p.y / sep) * sep))
  )
  (revolve shape y radius
  | move y (atan2+ p.xz / tau * sep + (round (p.y / sep) - 1 * sep))
  )
| let [shape (circle 2
  | shade (hsv (hash $i + hash $j + (t * 0.1)) 0.7 1) :s 1 :g 10
  | with-lights (light/ambient 1 normal)
  | tile: $i [10 10] :limit 4
  | radial: $j 5 50
  | rotate (t / 3))] _
| gl/let [radius (osc p.y 1000 50 200) sep 146] _
| rotate y t)

Everything was very new and exciting, and I learned a lot about Wasm and Janet and OpenGL and SDFs and procedural art in general.

And I kept growing the capabilities of Bauble’s… compiler? Would we call it a compiler? It was still, at this point, a glorified string concatenator. But I taught it how to concatenate real fancy-like; I added support for custom dynamic expressions so that you could write things like “rotate space around the y-axis by an angle that varies with the current y coordinate:”

(star 100 50 | extrude y 100
| rotate y (osc t 3 | ss 0.1 0.9 * pi/2 * p.y / 100 + (0.5 * t))
| slow 0.5)

Eventually I even implemented animations, and complex surface-blending operations, and higher-order bounding operations to improve rendering performance, and domain repetition, and, and…

And finally my crowning achievement: custom dynamic lighting, with raymarched soft shadows, which you could specify on a shape-by-shape basis, and whose properties could vary over time and space to produce complex, interesting effects.

(def light-count 6)
(defn light [i]
  (gl/def at (rotate [0 (osc t 5 20 200) 60] y (i / light-count * tau + t)))
  (light/point  (hsv (i / light-count) 1 1) at :shadow 0.25
    :brightness (100 / (dot P at | abs | pow (osc t 3 0.7 1.2)) | min 2)))
(octahedron 20 :r 5 | rotate x (t + $i) y (t + $i) z (t + $i)
| shade (hsv $i 0.6 0.5)
| union (ground -40 | shade gray)
| with-lights ;(seq [i :range [0 light-count]] (light i))
| gl/let [$i (hash $i)] _
| tile: $i [80 0 80])

It was the most complicated feature of Bauble, one that stretched its string concatenator to the absolute limits, one that had to be special-cased in the typechecker in order to generate correct code, and one that would still occasionally generate invalid GLSL if you looked at it wrong.

It was also the last “must-have” feature. Once lighting was done, Bauble was “finished.” I wrote some token documentation, and a little tutorial, and I announced Bauble to the world. I forced myself to stop hacking on it for a little while, because I had more important things to do, and I went outside for the first time in two months.

(cylinder y 100 20 :r 20
| union (cone y 30 50 :r 5) :r 50
| expound (fbm 5 simplex [p.x p.z (distance p [0 50 0] - (t * 20) + (atan2+ p.xz / pi * -150) )] [50 50 20]) 3
| shade (mix blue sky 0.5) :g 30 :s 1
| slow 0.5
| tint sky (fresnel 5))
(set camera (camera/perspective [-180 100 0]))

I didn’t set it aside for long. But when I returned to it, when I looked back over what I had wrought in this furious coding binge, I found…

You know that scene in Raiders of the Lost Ark where they open up the roof of the Well of Souls, and they drop a torch down there, and the ground is just a solid mass of writhing snakes?

That was basically the codebase that I had produced.

(morph 0.88
(ball 40 | move x 10 | rotate y (t * 2) | move y (osc t 20 -150 150))
(hexagon :r 5 10
| revolve x (80 + (40 * hash [$i $j]))
| shade (hsv (hash ($i + $j) * 0.04 - 0.03) 1 0.8) :g 15 :s 0.2
| radial: $j y 20 :oversample true :sample-from -1)
| rotate y (p.y / 40 | sin * (mod $i 2 * 2 - 1))
| rotate y (mod $i 2 * tau)
| radial: $i y 2 :oversample true :sample-from -1
| rotate y (t / 10)
| with-lights (light/directional white [-1 -2 0 | normalize] 300 :shadow 0.1)
  (light/ambient 0.25 normal)
| slow 0.5)

See, the string concatenation never just went away. The whole core “compiler” was still based on this fragile web of carefully crafted, hardcoded GLSL primitives. There was never, at any point, an abstract syntax tree. There was a sort of weird builder-like imperative “code printer” thing that sort of implicitly tracked an AST and like knew what was in scope at some times, but, like, if you ever wrote a function with a local variable called p you’d break everything, because p is, obviously, the name of a dynamic variable that–

You know what? I don’t need to explain it. I’m sure you can believe me when I say that it was awful code.

(color r2 (teal * 0.1 *
  (fbm 8 :f (fn [q] (rotate (q * 2) (pi * sin (t / 100))))
    (fn [q] (cos q.x + sin q.y /)) q (osc t 20 30 90))))
(set aa-grid-size 2)

But it wasn’t just the code. It was also a bad product. It was too limiting: Bauble was a tool for making shaders with SDFs, but it didn’t give you any way to actually write your own signed distance functions. You just couldn’t write arbitrary shader code. You could write some custom expressions, using a limited subset of the functions available to you in real GLSL, but there was no way that you, as a Bauble user, could have implemented any of the provided built-ins. There was no “escape hatch” to pure GLSL.

So you were limited to the built-ins, and there just wasn’t that much built-into it. It was missing so many things: I wanted 2D SDFs, and extrusions into 3D space. I wanted to be able to distort normals without altering distance fields. I wanted to be able to define custom material shaders that could use Bauble’s native shadow casting – you could define custom colors of course, but the only light-aware material in all of Bauble was a simple Blinn-Phong shader. And that fact was, of course, hardcoded.

(defn strip [axis q]
  (revolve (trapezoid (mix lo hi h) (mix hi lo h) 20 :r 2 | rotate (h * pi) t
  | gl/let [lo 0 hi 10 h (atan2+ q / tau)] _) axis 100))
(union
  (strip y p.xz | move x -50 | shade sky)
  (strip z p.xy | move x 50 | shade orange)
| rotate z (t / 3) y (t / 2)
| tint purple (fresnel 5 * 0.5))

Maybe more than anything else, I wanted to add 3D mesh export – I wanted to be able to export Bauble shapes into OBJ files or STL files or whatever the right one is today, because I wanted to 3D print my Baubles. But I also wanted to add custom camera support, and anti-aliasing, and video export…

But I had stretched my strings to the breaking point. Even I couldn’t understand what I’d written, and I knew that, if I wanted to keep growing Bauble, I would have to rewrite the core compiler from scratch.

(gl/let [t1 (t / 4)
         t2 (ss (fract t1) 0.2 1 (floor t1) (ceil t1))]
  (defn nudge [i]
    (hash3 i - 0.5
    | normalize
    | rotate y (osc t2 4 -0.5 0.5) z (osc t2 3 -0.5 0.5) x (osc t2 2 -0.5 0.5)))
  (intersect :r (s * 20 + 1)
    (plane [+1 +1 +1 + nudge 0 | normalize] 80)
    (plane [+1 +1 -1 + nudge 1 | normalize] 80)
    (plane [+1 -1 +1 + nudge 2 | normalize] 80)
    (plane [+1 -1 -1 + nudge 3 | normalize] 80)
    (plane [-1 +1 +1 + nudge 4 | normalize] 80)
    (plane [-1 +1 -1 + nudge 5 | normalize] 80)
    (plane [-1 -1 +1 + nudge 6 | normalize] 80)
    (plane [-1 -1 -1 + nudge 7 | normalize] 80)
  | expound (perlin p (20 * s + 30)) (20 * s) 20
  | shade (ok/hcl (t2 * 0.4) 0.4 0.6) | with-lights (light/ambient 1 normal)
  | gl/let [s (osc t2 1 0 1)] _
  | rotate [1 -1 -1 | normalize] (t / 10)
  | tint normal+ (fresnel 3)
  | tint white (fresnel 0.5 * 0.3)
  | map-color (fn [c] (c * (mix 0.1 1 (dot normal [-1 1 1 | normalize] | max 0))))))

Fortunately though, over the course of Bauble’s development, I had produced a comprehensive suite of test scripts with reference images that demonstrated all of the edge cases and problems that I had faced and already fixed and…

No, of course not. I can’t even type that with straight fingers. There were no tests. Actually, worse: there was one test. And it was failing.

I tried to fix it, when I finally noticed it was broken. I tried to reverse engineer my own code, untangle my spaghetti mess to figure out how it had ever worked in the first place, but eventually I gave up. It just wasn’t worth it. There was nothing worth salvaging, and the thought of starting over from scratch after all of the work I’d already done was so discouraging that I just stopped working on Bauble altogether.

And that’s the story of Bauble. It’s a sad story, a story of a codebase collapsing under its own weight, of a prototype trying to grow into a product, and finding that the old aphorism still holds true.

(defn half-hour [offset]
  (capsule y 300 (ss p.y 0 300 0 100) (ss p.y 0 300 80 100)
  | expound (perlin [0 (t + 2 | log * 300 + offset) 0 + p] 50) 20 10
  ))
(defn cel [shape color1 color2]
  (color shape (mix color1 color2 (fresnel 5 | quantize 3))
  | tint (vec3 -0.3) (fresnel 1 | quantize 3)))
(union :r 10
  (half-hour 0 | move y 4 | cel sky white)
  (gl/with [p [1 -1 1 * p]] (half-hour 1000 | move y 0 | cel orange red))
  | scale [1 0.5 1])
(set camera (camera/perspective [0 100 400]))

Two years passed.

(color r2
  (ok/mix (ok/hcl (length q | ss 0 150 0.2 0.4 + (t / 30)) (length q / 200 | ss 0 1 0.3 0.1) 0.9)
    (vec3 0.1)
  (fbm 3 perlin (normalize [q 10] * (ss t 0 20 0 20 + t)) [(vec2 (length q | sqrt)) 6]
+ (length q / 150)))
| rotate (t / 20))

I used Bauble on and off, but found myself increasingly annoyed by its limitations. Occasionally I would even try adding new features, but I could barely type through my hazmat suit.

I kept meaning to write a blog post about Bauble, about everything that I’d learned – how to embed Janet into a website and make an interactive art project that doesn’t use JavaScript – but I never got around to it. Meanwhile I wrote a book about Janet, and dedicated a chapter to my embedding experience, but it never even mentions Bauble.

Despite being the most interesting side project that I’ve ever worked on, I haven’t written anything about it until now.

(gl/def sun-dir [0 -0.5 -1 | normalize])
(defn broad-height [xz] (perlin xz 10000 * 2000))
(gl/def sky-color (mix [1 0.5 1] [0 0 0] (ray.direction.y * 2) * 0.5
| mix (hsv (1 / 6) 0.5 1) (dot ray.direction (- sun-dir) | clamp 0 1)))
(plane y (perlin p.xz 600 * 200 + broad-height P.xz)
| expound (osc p.y 30) 10
| shade (hsv (0.6 / 6) 0.6 0.5 + (normal.yzx * 0.1)) :g (normal.y * 10) :s 0.1
| with-lights (light/directional 1 sun-dir 500 :shadow 0.5) (light/ambient (hsv 0.3 0.3 0.5) normal)
| map-color (fn [c] (mix c sky-color (1 - exp (* -0.0001 depth)) | pow [1.5 2 1.5]))
| slow 0.9)
(set background-color sky-color)
(gl/def camera-xz [(t * -500) 0])
(set camera (camera/perspective [camera-xz.x (broad-height camera-xz + 500) camera-xz.y] :dir [-1 -0.5 -0.10 | normalize]
| camera/tilt (osc t 20 -0.1 0.1)
| camera/pan (osc t 30 -0.1 0.1)))

I’m writing about it now because I recently took a few months off work, for the usual reason, and after some weeks of sleep deprivation and exhaustion and elation I found that I had a few cycles to spend on a side project.

But not a hard side project. Not something that required concentration, or prolonged stretches of focus – luxuries that I have temporarily foresworn. I needed something that I could, almost literally, do in my sleep.

(torus y 100 50
| expound (fbm 6 simplex p [100 50 100])
  (osc t 10 | ss 0.25 0.75 | mix 1 (simplex+ (p + 1000 + (t * 10)) 300 | ss 0.2 1 * 15 + 1) _)
  16
| shade 0.8 :g 40 :s 0.5
| rotate y (t / 2.5)
| slow 0.4
| tint [1 0.5 0.5] (fresnel 0.25 * 0.1)
| tint white (fresnel 5 * 0.1))
(set camera (camera/perspective [0 250 300 | rotate y (t / 2)]))

So I rewrote Bauble. Or rather, I didn’t rewrite Bauble – instead, I did all of the boring things that I never bothered with the first time around. I wrote a GLSL AST library, with a little pretty-printer. I wrote a typed expression-oriented language that adds “first-class” functions to GLSL. I wrote a Janet DSL for constructing programs in this high-level language, and I added Janet wrappers for (almost) all of GLSL’s built-in functions. I made a command-line interface to Bauble, using vanilla OpenGL instead of WebGL, so that I could finally write a real test suite.

I wrote a real test suite.

(def mouth (ball 50 | subtract (ball 200 | move y 200) | move z 100 | shade black))

(defn fork [shape f1 f2]
  (union (f1 shape) (f2 shape)))

(def teeth
  (box :r 2 [5 10 2]
  | rotate x 0.30
  | move y -40
  | fork
    (fn [$] ($ | move x 6))
    (fn [$] ($ | rotate x -0.04 | move x -6 z -1))
  | shade white :g 10 :s 1 )) # :ambient 0.5
(def eye-center [39 265 41])
(def eyelid
  (ball 40
  | move eye-center
  | subtract :r 10
    (ball 30 | move eye-center | move [0 -10 16])))
(def eye-color
  (shade r3 white  :g 10 :s 1 # :ambient 0.3
  | union (ball 10 | move z 30 | shade black :g 10 :s 1)))
(def eyeball
  (ball 35
  | shade white :g 10 :s 1
  | union-color (ball 10 | move [0 0 30] | shade black :g 10 :s 1)
  | rotate x 0.34 y (sin t | ss 0 0.1 * 0.2 - 0.1)
  | move eye-center))
(def neck
  (cylinder y 30 100
  | move y 100
  | rotate z (p.y * -0.001)
  | rotate x (p.y * 0.0010)
  | move y 85
  | union :r 10 eyelid))
(def head (ball 100 | scale y 0.9))

(def feet (box [20 30 20] :r 15
  | union :r 15 (box :r 10 [10 20 30]
  | fork
    (fn [$] ($ | rotate y 0.1 | move x 8))
    (fn [$] ($ | rotate y -0.1 | move x -11))
  | rotate x 0.015 y 0.37
  | move [15 -31 22])
  | move [50 -82 0]
  | rotate y 0.12
  | subtract :r 5 (plane y -110)
  | mirror x))

(union
  (union :r 60 head neck | union :r 10 feet | shade green :s 0.5 :g 6 | tint white (fresnel 15 * 0.5) | subtract :s 2 mouth)
  eyeball
  (teeth | move z 80)
  (ground -110 | shade gray)
| scale 0.5)

I worked bottom-up this time, building one boring primitive at a time and stacking them on top of each other. It was not the joyful exploratory everything-is-new interactive process of building Bauble for the first time, but it was still rewarding: I could see where it was going, and how to get there. It was delayed gratification this time, knowing that if I just got through the slog of the rewrite, I would be rewarded with something that I could be proud of.

(union :r 10
  (cone y 120 200)
  (cone y 100 150 | move [80 0 -44])
  (cone y 89 137 | move [-110 0 -9])
| expound (fbm :f 2.2 5 perlin [2 1 2 * p] 80) 20 40
| slow 0.5
| shade (normal+ | rotate y 1.34 | pow 2)
| move y (osc t 10 | ss 0 0.8 -230 -50)
| union :s 20 (plane y (osc (perlin [1 2 * p.xz] 200 + (t * 0.5)) 1 0 10) | shade sky :g 20 :s 1 | tint (fresnel 3))
)

And I am, now. This is a new Bauble, and it is, across every axis, a better Bauble.

You’ve already seen it of course, but let me give you a quick tour of what you can do with it now.

You can edit complicated shaders without lag: Bauble uses “web workers” now, so that all of the Janet evaluation and compilation and rendering takes place off of the UI thread. This… this doesn’t actually seem to work very well in Chrome or Safari, at least on my “Apple Silicon” MacBook – recompilation is pretty stuttery, taking around 100ms with both the OpenGL and Metal backends. But it’s buttery smooth in Firefox. Weird. WebGL rendering performance is also just universally better in Firefox – if any of the examples on this page are dipping below 60fps, maybe try switching?

You can export 3D models, and you can 3D print them:

That shape on the left is called a “gyroid,” and it was the first Bauble that I ever 3D printed (or, well, had someone cast in bronze for me). There’s no gyroid primitive in Bauble, but you can create custom shapes by writing out an implicit function directly:

(def gyroid (shape/3d (gl/with [p (p / 15)]
  (dot (cos p) (sin p.yzx)) + 1 * 10)))

(intersect :r 2.5 gyroid (ball 145))

# Er okay so this is not a "real" gyroid;
# it's more like a half-filled gyroid,
# because it's hard to print thin walls.
# The real deal looks like this:

# (def gyroid (shape/3d (gl/with [p (p / 15)]
#   (dot (cos p) (sin p.yzx)) * 10)))
#
# (intersect :r 2.5 (gyroid | shell 1) (ball 145))

I don’t have a 3D printer, and this feature is pretty new, so I haven’t really explored this very much yet. Also Bauble’s mesh export is… primitive, to say the least. It’s just marching cubes, which means you have to generate pretty large models if you want to preserve fine details. I realize that there are many better algorithms for triangulating SDFs, and Bauble should probably use one of them. But… I can only code for a few minutes a week now, and I spent my whole budget on the next feature.

You can embed Bauble on other pages. Not the way that I’ve been doing – the crimes I committed to Bauble’s build system in order to embed the editor here are not really replicable. But you can export your shaders to GLSL, and embed them on any page to add interactive 3D examples in a few lines of code. No one even needs to know you’re using Bauble:

How do planet work?

Since Bauble pre-compiles the shader, the actual “bauble.js” that you have to embed is just a single 8kb pure-JS file.1 You don’t need to include the Janet compiler or WebAssembly or anything fancy like that – in fact, you don’t even have to use the Bauble library at all. You can construct the graphics context and compile the shader and draw it yourself, if you’d like.

There’s a biannual event called the “lisp game jam,” and I think it would be fun to use Bauble to render the graphics for a game. Janet has pretty good bindings to Raylib, and you could use that to handle the input and sounds, but render all the graphics with Bauble.

Here, click on this, and then move around with WASD:

Obviously that’s not… a game, exactly. There’s no hit detection, and fire probably shouldn’t cast shadows. But, you know, that’s 30 lines of Bauble code plus 40 lines of JS for the event handling? Imagine what you could do if you weren’t furiously trying to finish the blog post you started writing months ago.

One of my favorite new features of Bauble is that you can edit vectors interactively. Not just the ctrl-click-and-drag on scalars that I highlighted already, but actual dragging vectors around in 3D. Here: put your cursor inside the [50 100 150], and then open quad view with alt-q. You should see crosshairs, and then you can cmd- or ctrl-click and drag one of the orthographic viewports to edit the vector with your mouse. Try it on the [0 0 0] too, to move the box around!

(box [50 100 150]
| move [0 0 0])

So that’s everything you can do with Bauble.

Except… it’s not, is it? I just listed all of the things that I can do with Bauble. Because I know some things about SDFs, and I know Janet, and I understand this weird DSL that I’ve created. But you don’t – yet. How could you?

Which brings me to the last, biggest, and most important new feature of Bauble:

This is https://bauble.studio/help. I wrote a giant reference page with hundreds of interactive examples of every primitive and operation and thing that you can possibly do with Bauble. And it’s available right in the editor, any time you trigger autocomplete: the reference page and completions are both generated from the docstrings of the actual Janet functions. And in case the docstrings aren’t sufficient, there is a little source link next to every single definition that will take you straight to the code.

The documentation isn’t perfect: some some small helpers are missing examples; the “escape hatch” to writing raw GLSL isn’t really described at all, and it doesn’t include any of the functions that Bauble lifts directly from GLSL. And I fully realize that a reference like this is no substitute for a decent tutorial.

Bauble still needs a proper tutorial, and one day I’ll write it.

One day I’ll write the Book of Bauble, and explain SDFs and procedural noise and periodic distortions of space and all the tricks that I’ve learned, and how you can apply them to Bauble.

One day.

Let’s say… eighteen years from now, just to be on the safe side.


  1. This is a little misleading, because the compiled shaders themselves are like 5-15kb each. I could minify their source, which would help a bit, but even without doing that, a single Bauble and the player library clocks in around 2% the size of embedding p5.js. (Comparing minified, uncompressed sizes, which is, again, misleading.) ↩︎