#generative-art #processing #state #abstraction #draw #call #zero-cost

nightly pollock

An ergonomic and performant processing-like library for generative art and simple games in Rust

5 unstable releases

Uses old Rust 2015

0.3.2 Sep 28, 2018
0.2.2 Sep 27, 2018
0.2.1 Sep 27, 2018
0.2.0 Sep 27, 2018
0.1.0 Sep 23, 2018

#1581 in Rust patterns

Download history 8/week @ 2024-02-26 66/week @ 2024-04-01

66 downloads per month

Unlicense and AGPL-3.0-or-later

1.5MB
1K SLoC

Pollock

A framework for bad code

Pollock is an idiomatic-enough reimplementation of Processing's ideals and API in Rust, for use to make generative art, small games in game jams, or just as a toy to play with. It tries to put as few restrictions on your code as possible, so writing using it feels as comfortable as writing code in a dynamic language but with less bugs and better performance. Having said that, right now performance in debug mode is pretty terrible because I make heavy use of zero-cost abstractions which heavily rely on optimisations. Performance in release mode is already fantastic, though, and probably can be improved further. One limitation is that I definitely always want to have an immediate-mode interface, although I would love to work out a way to at least partly use retained mode in the backend. I already batch up all calls into a single draw call.

It's still pre-pre-pre-alpha, but it already has some cool features like saving videos and an emulator-style savestate system which can be used to give a workflow similar to hot reloading - making a change, rerunning the program and reloading the state to get back to where you were.

The Pollock design philosophy

  • Worse is better
  • Don't make me think
  • Performance is king, but usability is God

Pollock is designed to allow quick hacking on code that you don't intend to ever maintain. It's for experiments and mistakes.

If you've used Processing before the API will look familiar. If you've used some popular Rust game frameworks the API will probably look totally alien. For a start, you don't define a trait. I chose to use the builder pattern to avoid the problem of having to annotate types everywhere, and to mean that you need to understand less of Rust to be able to write something, even if it's only something simple, in Rust.

extern crate pollock;

use pollock::*;

fn main() {
    Pollock::setup(|p| {
        p.size = (600, 480);
        // Like 
        p.background = Fill::default();
        p.fill = Fill::none();
        p.stroke = Stroke::new(rgb(0, 0, 0), 3);
    }).draw(|p| {
        // Example spinning square
        let mut p = p.push();
        let size = 50;
        let (w, h) = p.size;
        let frame_count = p.frame_count as f64;

        p.rotate(frame_count / 100.);
        p.translate(v2(w, h) / 2.);
        p.rect(v2(-size / 2, -size / 2), size, size);
    }).run();
}

v2 creates a vector, push is a Rustified version of Processing's pushMatrix (the changes are popped when the new p goes out of scope). I think that the rest is self-explanatory. I use the Vector2 type from nalgebra for my vector type, although this might change in the future since the error messages when using this type are utterly indefensible. It does allow you to do really nice stuff out of the box that is really unwieldy in Processing. For example, you can just add and subtract vectors, multiply them by scalars and all other nice things that are useful in a context where you're working with 2D graphics. I don't currently expose many of nalgebra's features but as I get more feedback on the design of the API I may add more.

Something that I don't show off in this example or the example below is that I don't have an equivalent of startShape and endShape, everything's just done with iterators. You can build both polygons and lines (with joins done automatically) from an iterator over V2s (i.e. 2D vectors). This is very performant, arguably more ergonomic, and doesn't have the possibility of accidentally forgetting to call endShape.

Handling mutable state

The way you do mutable state in Python is to just use global variables. This works in Pollock, too (although they'd be mutable variables in the scope of the function that you're calling Pollock::run in), but Pollock additionally supports the concept of a state struct. A benefit to this is that you can get savestates for free, but it does add an additional type annotation burden so for simple projects you can just use global variables.

Using a state struct is as easy as just returning it from the setup function and then using p.state to access it in your main loop. For example, for a simple sketch that shows some bouncing balls it might look something like this:

extern crate pollock;
extern crate serde;
#[macro_use]
extern crate serde_derive;

use pollock::*;

#[derive(Serialize, Deserialize)]
struct State {
    balls: Vec<(V2, V2)>,
}

fn main() {
    let radius = 10.;

    Pollock::setup(|p| {
        p.size = (600, 480);
        p.background = Fill::default();
        p.stroke = Stroke::none();
        // We add alpha so we can see where the balls intersect
        // (also so I can show off the `rgba` function.
        p.fill = Fill::new(rgba(0, 0, 0, 100));

        State {
            balls: (0..10)
                .map(|_| {
                    (
                        v2(
                            p.random_range(0., p.width() as f64),
                            p.random_range(0., p.height() as f64),
                        ),
                        v2(
                            if p.random() { -1 } else { 1 },
                            if p.random() { -1 } else { 1 },
                        ),
                    )
                }).collect(),
        }
    }).draw(|p| {
        let (width, height) = p.size;
        // A limitation of Rust's disjointness analysis means that
        // we have to loop twice here.
        for (pos, vel) in &mut p.state.balls {
            if pos.x < radius {
                vel.x = 1.
            } else if pos.x > width as f64 - radius {
                vel.x = -1.
            }

            if pos.y < radius {
                vel.y = 1.
            } else if pos.y > height as f64 - radius {
                vel.y = -1.
            }

            *pos += *vel * 1.;
        }

        for (pos, _) in p.state.balls.iter() {
            p.circle(*pos, radius);
        }
    })
    .on_key_down(Key::O, |p| p.save_state("state"))
    .on_key_down(Key::P, |p| p.load_state("state"))
    .on_key_down(Key::Space, |p| p.paused = !p.paused)
    .run();
}

You can see that it's very terse and (in my opinion) very readable, although you would have to know that returning state from setup makes it accessible in p.state which is admittedly fairly magic.

In case you're wondering, you can access p.state in setup, but it just has the type ().

How you can help

Use it! Tell me where features are missing and whether it's actually as easy to use as I hope it is. I do worry that my heavy use of generics and Deref might make it a little difficult to read the documentation, but this can probably be helped with careful use of type aliases. I want to do some work to port Processing tutorials to Pollock to find out where common operations aren't supported.

Here's an example gif from a Processing sketch that I ported to Pollock:

Future features:

  • Images
  • Text
  • Sound input (from a file and/or from the microphone) for visualisations
  • Mouse input
  • Module system to add extra functionality to PollockState that looks like it was implemented in the core library. I have some ideas of how to use the type system to achieve this but I haven't put anything concrete down yet.
  • More inbuilt maths helpers

Dependencies

~26–37MB
~442K SLoC