p5.js is a JavaScript library for creative coding that lets us bring our ideas into the world quickly, as the example from the get started page shows—you can type it into the p5.js editor to see it in action.
function setup() {
createCanvas(640, 480);
}
function draw() {
fill(mouseIsPressed ? 0 : 255)
ellipse(mouseX, mouseY, 80, 80);
}
ClojureScript, a dialect of the Clojure programming language that compiles to JavaScript, can help us establish a more immediate connection between what we are thinking and the p5.js sketch we are creating.
Let's start by porting the example. To get the most of out this, you can download the project files and code along. The full source code is also available.
ClojureScript evaluates lists (things wrapped in parentheses) as calls. For instance, createCanvas(640 480)
translates to (js/createCanvas 640 480)
—that is, the literal notation for the list whose first element is the js/createCanvas
symbol, followed by the numbers 640
and 480
. We need to prefix createCanvas
with js/
to let the compiler know we are referring to the global function named createCanvas
. Every definition in ClojureScript belongs to a namespace (something similar to a JavaScript module) and js
is a special namespace that lets us use JavaScript variables. For the same reason, we need to set the setup
and draw
functions as properties of the window
object to make them globally available.
;; src/sketch/core.cljs
(ns sketch.core) ; all definitions below will belong to to sketch.core namespace
(defn setup [] ; define the setup function
(js/createCanvas 640 480))
(defn draw [] ; define the draw function
(js/fill (if js/mouseIsPressed 0 255))
(js/ellipse js/mouseX js/mouseY 80 80))
;; make setup and draw global functions
(set! (.-setup js/window) setup)
(set! (.-draw js/window) draw)
Even though it looks like a regular function call, the if
expression provided as the only argument of the js/fill
function is a special form. Special forms are the basic building block on top of which the rest of ClojureScript is built and have special syntax and evaluation rules. For instance, you only want to evaluate one branch of if
. ns
and set!
are also special forms.
We use defn
to define functions, which happens to be a macro that relies on the def
and fn*
special forms. Macros also have particular syntax and evaluation rules but, unlike special forms, we can create our own—and we will later!
So far we've been using p5.js in global mode, which is nice for quickly sketching things out but can make things complicated if we want to have multiple sketches. In that scenario, is better to use instance mode and create p5
objects ourselves. The p5
constructor takes a function that serves as a template for the sketch and an optional DOM element that will contain the sketch.
To ensure the container is in the document, we use some functions provided by the goog.dom
module from Google Closure library. It's available as a ClojureScript namespace with the same name.
;; src/sketch/core.cljs
(ns sketch.core
(:require [goog.dom :as d])) ; require goog.dom, alias to d to save some typing
;; p5.js functions and variables are no longer global but methods and properties
;; of the sketch object p.
(defn setup [p]
(.createCanvas p 640 480))
(defn draw [p]
(.fill p (if (.-mouseIsPressed p) 0 255))
(.ellipse p (.-mouseX p) (.-mouseY p) 80 80))
;; If we can't find an element with the given id, append a new div with that id
;; to the body of the document.
(def parent-id "example")
(when-not (d/getElement parent-id)
(d/append js/document.body (d/createDom "div" #js {:id parent-id})))
;; Declare an example sketch by creating a new instance of js/p5.
(def example
(new js/p5
(fn [p] ; fn creates an anonymous function, p is the sketch object
(set! (.-setup p) (fn [] (setup p))) ; attach the setup method to the sketch
(set! (.-draw p) (fn [] (draw p)))) ; attach the draw method to the sketch
parent-id))
When attaching the setup
and draw
methods, we need to create anonymous functions in order to get a reference to the sketch object, named p
by convention. We could have inlined the definitions of those functions instead.
The code above definitively works but contains a lot of detail. Let's remove some of it and try to get back the convenience of global mode. The first step is to identify some tasks that we always have to perform to define a sketch, so we can write some functions to take care of them. We can place those functions in the sketch.p5
namespace. That way, they won't get intertwined with the code that is concerned with the sketch implementation.
The bodies of the instance
and ensure-parent
functions below look quite similar to the original code. The set-methods
function, however, deserves some explanation.
;; src/sketch/p5.cljs
(ns sketch.p5
(:require [goog.object :as o]
[goog.dom :as d]))
;; A lot of new things are going on inside the set-methods function, continue
;; reading for the details.
(defn- set-methods [p spec] ; uses defn- to make the function private
(doseq [[name f] spec]
(o/set p name (fn [] (f p)))))
(defn instance [methods-spec parent-id]
(new js/p5
(fn [p] (set-methods p methods-spec))
parent-id))
(defn ensure-parent [id]
(when-not (d/getElement id)
(d/append js/document.body (d/createDom "div" #js {:id id}))))
The set-methods
function expects its second argument spec
to be a vector containing the specification of the methods—a vector is a ClojureScript data structure similar to an array. Each method specification is itself also a vector that has two elements: the name of the method and a function with its implementation.
[["setup" setup] ["draw" draw]] ; method spec example
The doseq
executes its body for each element of spec
, binding its contents to name
and f
through destructuring—a feature analogue to the one present in modern JavaScript. We are using goog.object/set
instead of set!
because the property name
is a string. We don't want to hard-code names since a sketch may use just one of those methods, and may use others.
(set-methods p [["setup" setup] ["draw" draw]])
;; executes
(o/set p "setup" (fn [] (setup p)))
(o/set p "draw" (fn [] (draw p)))
We can now return to our sketch definition. The code still ensures there's a container and creates a new instance of the p5
object, but it's not at all concerned on how to do that stuff. We can also go ahead and inline the setup
and draw
functions since there isn't much going on in their surroundings.
;; src/sketch/core.cljs
(ns sketch.core
(:require [sketch.p5 :as p5]))
(def parent-id "example")
(p5/ensure-parent parent-id)
(def example
(p5/instance
[["setup" (fn [p]
(.createCanvas p 640 480))]
["draw" (fn [p]
(.fill p (if (.-mouseIsPressed p) 0 255))
(.ellipse p (.-mouseX p) (.-mouseY p) 80 80))]]
parent-id))
There's nothing particularly special about what we have built until now. Using p5.js instance mode in plain JavaScript looks nicer.
ensureParent("example");
const example = new p5((p) => {
p.setup = function() {
p.createCanvas(480 120);
}
p.draw = function() {
p.fill(p.mouseIsPressed ? 0 : 255);
p.ellipse(p.mouseX, p.mouseY, 80, 80);
}
}, "example");
But here's the thing. We can take all these pieces and combine them to define a new language construct defsketch
that will:
- Ensure there's a container element in the document.
- Create a new
p5
instance with the provided implementation and append it to the container. - Give the
p5
instance a name.
To extend the syntax of the language, we need to create a macro. A macro takes some arguments and uses them to create an expression. That expression is what actually gets evaluated in runtime when you call the macro. Before diving into the implementation of defsketch
, we need to get familiar with a couple of ClojureScript features.
The quote
special form suppresses the evaluation of its argument and yields it as it is.
(+ 1 1) ; => 2
(quote (+ 1 1)) ; => (+ 1 1)
The quote character '
provides a shortcut for doing the same thing.
'(+ 1 1) ; => (+ 1 1)
The backquote character `
works similarly, but it fully-qualifies the symbols it encounters—in other words, it adds their namespace.
`(+ 1 1) ; => (cljs.core/+ 1 1)
Furthermore, inside a backquoted expression, the tilde character allows to unquote some sub-expressions. We can think of backquoted expressions as templates where tilde characters mark placeholders.
`(+ 1 2 ~(+ 1 2)) ; => (cljs.core/+ 1 2 3)
We are all set now, below is the implementation of the defsketch
macro. Even though the definition of a macro looks like the definition of a function, there are some differences to keep in mind.
- Macros are applied during compilation and build the expressions that get invoked at runtime—because the ClojureScript compiler is a Clojure program, macro code is written in files with the .clj extension.
- The arguments of a macro are unevaluated ClojureScript code. As we said before, it is composed of ClojureScript data structures—which we can manipulate.
;; src/sketch/p5.clj
(ns sketch.p5)
(defmacro defsketch [name methods-spec]
;; `let` binds the result of the `(str name)` expression to the `parent-id`
;; symbol which we can refer to inside the body of the `let`, kinda similar to
;; `let` in js.
(let [parent-id (str name)] ; `str` converts its argument to a string
`(do (ensure-parent ~parent-id) ; `do` evaluates multiple expressions, returns last
(def ~name (instance ~methods-spec ~parent-id)))))
To bring the macro to the sketch.p5
ClojureScript namespace, we need to add the :require-macros
option to its ns
form.
;; src/sketch/p5.cljs
(ns sketch.p5
(:require [goog.object :as o]
[goog.dom :as d])
(:require-macros [sketch.p5])) ; because both namespaces have the same name,
; all macros from the Clojure namespace are now
; available in the ClojureScript namespace
;; ...
We can use the macroexpand-1
function to see the expression macro is creating.
(macroexpand-1 '(p5/defsketch example
[["setup" (fn [p]
(.createCanvas p 640 480))]
["draw" (fn [p]
(.fill p (if (.-mouseIsPressed p) 0 255))
(.ellipse p (.-mouseX p) (.-mouseY p) 80 80))]]))
;; results in...
(do (sketch.p5/ensure-parent "example")
(def example (sketch.p5/instance
[["setup" (fn [p]
(.createCanvas p 640 480))]
["draw" (fn [p]
(.fill p (if (.-mouseIsPressed p) 0 255))
(.ellipse p (.-mouseX p) (.-mouseY p) 80 80))]]
"example")))
It works! The code generated by the macro is not identical to the code we have previously written, but its behavior is equivalent. It would be nice to have a better syntax to define the methods, though. What if, instead of
["setup" (fn [p] (.createCanvas p 640 480))]
we could write something like
(setup [p] (.createCanvas p 640 480)) ; let's call this a "method form"
which is idiomatic for macros that expect implementations. Let's try it! We can start by using destructuring to grab the first
element of the list and gather the rest
of the elements in another list—the [first & rest]
binding vector from the let
form below behaves similarly as a [left, ...rest]
array placed on the left-hand side of an assignment in JavaScript.
(let [[first & rest] '(setup [p] (.createCanvas p 640 480))]
[first rest])
; => [setup ([p] (.createCanvas p 480 120))]
So, now we have to do two things. First, we need to turn the first element of the vector into a string. Then we need to prepend clojure.core/fn
to the second.
(let [[first & rest] '(setup [p] (.createCanvas p 640 480))]
[(str first) (conj rest 'clojure.core/fn)])
; => ["setup" (clojure.core/fn [p] (.createCanvas p 480 120))]
We can transform that into a generic function with more descriptive arguments names than first
and rest
.
(defn- method-form->method-spec [[name & args-and-body]]
[(str name) (conj args-and-body 'clojure.core/fn)])
Then, the defsketch
macro can recreate the methods-spec
vector by applying method-form->method-spec
to every element of metod-forms
with the help of the mapv
function.
;; src/sketch/p5.clj
(ns sketch.p5)
(defn- method-form->method-spec [[name & args-and-body]]
[(str name) (conj args-and-body 'clojure.core/fn)])
(defmacro defsketch [name & method-forms] ; grab every arg after name in method-forms
(let [parent-id (str name)
methods-spec (mapv method-form->method-spec method-forms)]
`(do (ensure-parent ~parent-id)
(def ~name (instance ~methods-spec ~parent-id)))))
Finally, we can write our sketch using the new syntax.
;; src/sketch/core.cljs
;; ...
(p5/defsketch example
(setup [p]
(.createCanvas p 640 480))
(draw [p]
(.fill p (if (.-mouseIsPressed p) 0 255))
(.ellipse p (.-mouseX p) (.-mouseY p) 80 80)))
Wow, that's awesome for less than 40 lines of code! But we don't have to stop there. In the next article, we'll take advantage of other cool ClojureScript features to make our sketch code even more concise.
Top comments (1)
Thank you Gabriel! This is hugely helpful - I've been interested in bridging from Lisp dialects to Javascript, particularly p5.js and d3.js. For creative coding applied to audio visual installations. Your post has been just what I was looking for! When is your follow up article, and can you point me to anything else in the Clojure / p5 ecosystem in the meantime?