Programming

Degenerate can be programmed using Rust or JavaScript.

JavaScript Scripts

Degenerate scripts are written in JavaScript and sent to a Web Worker for execution. The script then sends back a series of Filter objects from the worker thread, which are used to configure image filters that the renderer applies in the main thread.

The JavaScript API is concerned with setting properties of the current Filter object, sending Filter objects to the main thread, and populating the sidebar with interactive widgets.

Individual image filters are relatively simple, but iterated application of one or more filters can produce surprising and beautiful results, and varying image filters over time or applying the same image filter in a loop can yield striking animations.

Rust Programs

The Rust programming interface is undocumented.

Image Filter Properties

Image filters read from a source image and write to a destination image. Every time an image filter is applied, those images are swapped.

Image filters have a number of properties, including a coordinate transformation, which determines whence input image pixels will be sampled; a signed distance field, which determines which of those pixels will be modified; and an color transformation, which determines how those pixels will be modified.

For each pixel in the image, an image filter operates with roughly the following steps:

  1. Generate the coordinates of the current pixel
  2. Transform those coordinates by the current transform
  3. If wrapping is enabled and the transformed pixel coordinates are out of bounds, wrap them back in bounds
  4. Sample the source image at those coordinates if they are in bounds, otherwise use the current default color
  5. If the pixel is inside of the current signed distance field, apply the color transformation, otherwise use the original color
  6. Save the generated pixel to the destination image

API

'use strict';

importScripts('gl-matrix-min.js', 'randchacha_browser.min.js');

glMatrix.glMatrix.setMatrixArrayType(Array);

let mat2 = glMatrix.mat2;
let mat2d = glMatrix.mat2d;
let mat3 = glMatrix.mat3;
let mat4 = glMatrix.mat4;
let quat = glMatrix.quat;
let quat2 = glMatrix.quat2;
let vec2 = glMatrix.vec2;
let vec3 = glMatrix.vec3;
let vec4 = glMatrix.vec4;

// Field that covers all pixels.
//
// ```
// all();
// render();
// ```
//
// All is the default field, so the above example could have been written
// as:
//
// ```
// render();
// ```
function all() {
  state.filter.field = 'All';
}

// Set the alpha blending factor. `alpha` will be used to blend the
// transformed color with the original color. See `www/fragment.glsl` for
// the blend equation.
//
// ```
// alpha(0.5);
// render();
// ```
function alpha(alpha) {
  state.filter.alpha = alpha;
}

// Assert that `condition` is true, otherwise throw `message`.
function assert(condition, message) {
  if (!condition) {
    throw message ?? 'assertion failed';
  }
}

// A checkerboard pattern.
//
// ```
// check;
// render();
// ```
function check() {
  state.filter.field = 'Check';
}

// Choose a random element from `array`.
//
// ```
// scale(choose([1,2,3]));
// ```
function choose(array) {
  return state.rng.choose(array);
}

// Create a new checkbox widget with the label `name`, and return true if it is
// checked. Calls with same `name` will all refer to the same checkbox, making
// it safe to call repeatedly.
//
// ```
// while(true) {
//  reboot();
//  if (checkbox('x')) {
//    x();
//  } else {
//    circle();
//  }
//  await render();
// }
// ```
function checkbox(name) {
  self.postMessage(
    JSON.stringify({
      widget: {
        name,
        widget: 'checkbox',
      },
    })
  );
  return !!widgets['checkbox-' + name];
}

// A circle.
//
// ```
// circle();
// render();
// ```
function circle() {
  state.filter.field = 'Circle';
}

// Clear the canvas.
//
// ```
// check();
// render();
// clear();
// ```
function clear() {
  self.postMessage(JSON.stringify('clear'));
}

// A cross field.
//
// ```
// cross();
// render();
// ```
function cross() {
  state.filter.field = 'Cross';
}

// Set the decibel range for normalization of raw frequency data into values
// usable in the fragment shader. Frequency data, by default, is expressed in
// decibels. Decibels are logarithmic, with 0 representing the loudest possible
// sound, and -∞ representing the quietest possible sound. This is inconvenient
// and unintuitive to work with, so frequency data decibel values are
// normalized to values between 0 and 1, where 0 is the silence and 1 is loud.
// This normalization requires selecting cut-off min and max decibel values.
// Values below `min` are clamped to 0, and values above `max` are clamped to
// 1. Setting the min value too low will cause noise to appear in the
// normalized frequency data. Setting the min value too high will remove quiet
// sounds from the frequency data. Setting the max value too high will reduce
// the dynamic range of the normalized values, and setting the max value too
// low will clip loud sounds, causing them to all map to 1. The default range
// is [-100, -30], which is reasonable for most applications.
//
// ```
// equalizer()
// record();
// wrap(true);
// while(true) {
//   let min = slider('min', -300, 0, 1, -100);
//   let max = slider('max', -300, 0, 1, -30);
//   decibelRange(min, max);
//   clear();
//   await render();
// }
// ```
function decibelRange(min, max) {
  self.postMessage(JSON.stringify({ decibelRange: { min, max } }));
}

// Set the default color. The default color is returned whenever a pixel is sampled
// out of bounds due to a rotation, scale, or other sample coordinate transformation.
//
// ```
// defaultColor([255, 0, 255]);
// rotate(0.01 * TAU);
// render();
// ```
function defaultColor(defaultColor) {
  state.filter.defaultColor = defaultColor;
}

// Return the number of milliseconds that have elapsed between this frame and the last.
// Returns 0 for the first frame.
//
// ```
// let rotation = 0;
//
// while(true) {
//   reboot();
//   x();
//   rotation += delta() / 30000 * TAU;
//   rotate(rotation);
//   await render();
// }
// ```
function delta() {
  return state.delta;
}

// Return the number of milliseconds that have elapsed since the page was loaded.
//
// ```
// while(true) {
//   reboot()
//   circle();
//   scale(0.75 * elapsed() / 20000);
//   await render();
// }
// ```
function elapsed() {
  return Date.now() - state.start;
}

// An equalizer pattern.
//
// ```
// record();
// equalizer();
// while(true) {
//   clear();
//   await render();
// }
// ```
function equalizer() {
  state.filter.field = 'Equalizer';
}

// Returns a promise that resolves when the browser is ready to display a new
// frame. Call `await frame()` in your rendering loop to only render when
// necessary and make sure each frame is displayed after rendering.
//
// ```
// scale(0.99);
// while (true) {
//   circle();
//   render();
//   x()
//   render();
//   await frame();
// }
// ```
async function frame() {
  await new Promise((resolve, reject) => {
    state.frameCallbacks.push(resolve);
  });
}

// A frequency field.
function frequency() {
  state.filter.field = 'Frequency';
}

// Set the color transformation to the identity transformation. The identity
// transformation returns the sampled pixel unchanged. Useful for applying
// transformations, such as scales or rotation, without changing the sampled
// pixels.
//
// ```
// identity();
// render();
// ```
function identity() {
  mat4.identity(state.filter.colorTransform);
}

// If `coordinates` is true, use the coordinate of the sample as the input color,
// instead of the color of the pixel in the source image. Defaults to false.
// Useful for creating gradients or debugging coordinate transforms.
//
// When true, RGB will be set to (x, y, 0)
//
// ```
// coordinates(true);
// render();
// ```
function coordinates(coordinates) {
  state.filter.coordinates = coordinates;
}

// Set the color transformation to inversion.
//
// ```
// x();
// invert();
// render();
// ```
//
// Inversion is the default color transformation, so the above example
// could have been written as:
//
// ```
// x();
// render();
// ```
function invert() {
  mat4.fromScaling(state.filter.colorTransform, vec3.fromValues(-1, -1, -1));
}

// Field that covers pixels where the pixel's index mod `divisor` is equal to `remainder`.
//
// ```
// mod(7,0);
// render();
// ```
function mod(divisor, remainder) {
  state.filter.field = { Mod: { divisor, remainder } };
}

// Set the oscillator gain. The oscillator produces a sine wave tone, useful
// for debugging audio-reactive scripts.
//
// ```
// equalizer()
// while(true) {
//   oscillatorFrequency(slider('frequency', 0, 20000, 1, 0));
//   oscillatorGain(slider('gain', 0, 1, 0.01, 0.25));
//   clear();
//   await render();
// }
// ```
function oscillatorGain(oscillatorGain) {
  self.postMessage(JSON.stringify({ oscillatorGain }));
}

// Set the oscillator frequency to `hz` hertz. The oscillator produces a sine wave tone,
// useful for debugging audio-reactive scripts.
//
// ```
// equalizer()
// while(true) {
//   oscillatorFrequency(slider('frequency', 0, 20000, 1, 0));
//   oscillatorGain(slider('gain', 0, 1, 0.01, 0.25));
//   clear();
//   await render();
// }
// ```
function oscillatorFrequency(oscillatorFrequency) {
  self.postMessage(JSON.stringify({ oscillatorFrequency }));
}

// Create a new radio button widget with the label `name` and options `options`,
// and return the selected option. `options` must be a list of strings. Calls with
// same `name` will all refer to the same radio button widget, making it safe to
// call repeatedly.
//
// ```
// while(true) {
//   reboot();
//   switch (radio('field', ['x', 'circle', 'cross'])) {
//     case 'x':
//       x();
//       break;
//     case 'circle':
//       circle();
//       break;
//     case 'cross':
//       cross();
//       break;
//   }
//   await render();
// }
// ```
function radio(name, options) {
  self.postMessage(
    JSON.stringify({
      widget: {
        name,
        widget: {
          radio: { options },
        },
      },
    })
  );
  return widgets['radio-' + name] ?? options[0];
}

// Reset the image filter and clear the canvas.
// ```
// x();
// render();
// reboot();
// ```
function reboot() {
  reset();
  clear();
}

// Enable audio recording.
function record() {
  self.postMessage(JSON.stringify('record'));
}

// Send the current filter to the main thread to be rendered. Like `frame()`,
// returns a promise that will resolve when the browser is ready to display a
// new frame. Use `await frame();` when you want to render multiple times before
// presenting a new frame, and `await render();` when you want to render once
// per frame.
//
// ```
// scale(0.99);
// circle();
// while (true) {
//   await render();
// }
// ```
async function render() {
  self.postMessage(JSON.stringify({ render: state.filter }));
  await frame();
}

// Reset the image filter.
//
// ```
// x();
// render();
// reset();
// ```
function reset() {
  state.filter = new Filter();
}

// Set resolution to a fixed value. Normally, the resolution increases and
// decreases automatically as the window is resized. This is usually what you
// want, but it is convenient to override it if you want to render at a fixed
// size, for example for saving high-resolution images:
//
// ```
// resolution(4096);
// x();
// render();
// save();
// ```
function resolution(resolution) {
  if (Number.isInteger(resolution)) {
    self.postMessage(JSON.stringify({ resolution }));
  }
}

// Use rotation as the color transformation.
//
// Valid values for `axis` are `red`, `green`, and `blue`. Applying `rotateColor` multiple
// times around different axes is a good way to get a variety of colors. Since `rotateColor`
// rotates the vector around the provided color axis, it will not change the amount of the
// axis's color. So if you want red, e.g., rotate about the `green` or `blue` axes.
//
// ```
// rotateColor('red', 0.5 * TAU);
// all();
// render();
// ```
function rotateColor(axis, radians) {
  switch (axis) {
    case 'red':
      mat4.fromXRotation(state.filter.colorTransform, radians);
      break;
    case 'green':
      mat4.fromYRotation(state.filter.colorTransform, radians);
      break;
    case 'blue':
      mat4.fromZRotation(state.filter.colorTransform, radians);
      break;
  }
}

// Set coordinate transform to a rotation.
//
// ```
// rotate(0.1);
// x();
// render();
// ```
function rotate(rotation) {
  transform(rotation, [1.0, 1.0], [0.0, 0.0]);
}

// Field that covers pixels where `pixel.y % (nrows + step) < nrows`. Will cover `nrows` pixels and then
// skip `step` pixels.
//
// ```
// rows(1, 9);
// render();
// ```
function rows(on, off) {
  state.filter.field = { Rows: { on, off } };
}

// Save the current canvas as a PNG.
//
// ```
// resolution(4096);
// x();
// render();
// save();
// ```
function save() {
  self.postMessage(JSON.stringify('save'));
}

// Seed RNG with `n`.
//
// ```
// seed(1);
// let a = choose([1,2,3]);
// seed(1);
// let b = choose([1,2,3]);
// console.assert(a == b);
// ```
function seed(n) {
  state.rng.seed(n);
}

// Set the current scale to `scale`. The scale factor is applied to sample coordinates before
// looking up the pixel under those coordinates.
//
// ```
// circle();
// render();
// scale(0.5);
// render();
// ```
function scale(scale) {
  transform(0, [scale, scale], [0.0, 0.0]);
}

// Return a promise that resolves after `ms` milliseconds.
//
// ```
// circle();
// render();
// await sleep(1000);
// scale(0.5);
// render();
// ```
function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

// Create a new slider widget with the given `name`, `min`, `max`, `step`, and
// `initial` values. Calls with same `name` will all refer to the same slider,
// making it safe to call repeatedly.
//
// ```
// x()
// while(true) {
//   rotateColor('green', slider('color rotation', 0, TAU, 0.001, 0));
//   clear();
//   await render();
// }
// ```
function slider(name, min, max, step, initial) {
  self.postMessage(
    JSON.stringify({
      widget: {
        name,
        widget: {
          slider: {
            min: min ?? 0,
            max: max ?? 1,
            step: step ?? 0.001,
            initial: initial ?? min ?? 0,
          },
        },
      },
    })
  );
  return widgets['slider-' + name] ?? initial;
}

// A square field.
//
// ```
// square();
// render();
// ```
function square() {
  state.filter.field = 'Square';
}

// A field that covers pixels where the audio time domain data is large.
function timeDomain() {
  state.filter.field = 'TimeDomain';
}

// Execute the filter `times` times.
function times(times) {
  state.filter.times = times;
}

// A field covering the top half of the canvas.
//
// ```
// top();
// render();
// ```
function top() {
  state.filter.field = 'Top';
}

// Set the coordinate transform using `rotation`, `scale`, and `translation`.
// Arguments that are omitted or undefined are skipped.
//
// ```
// circle();
// render();
// transform(TAU / 3, [2.0, 0.5], [0.1, 0.5]);
// render();
// ```
function transform(rotation, scale, translation) {
  mat3.identity(state.filter.positionTransform);
  mat3.rotate(
    state.filter.positionTransform,
    state.filter.positionTransform,
    rotation
  );
  mat3.scale(
    state.filter.positionTransform,
    state.filter.positionTransform,
    scale
  );
  mat3.translate(
    state.filter.positionTransform,
    state.filter.positionTransform,
    translation
  );
}

// A Waveform field.
//
// ```
// record();
// wave();
// while(true) {
//   clear();
//   await render();
// }
// ```
function wave() {
  state.filter.field = 'Wave';
}

// Set wrap. When `wrap` is `true`, out of bounds samples will be wrapped back within bounds.
//
// ```
// x();
// wrap();
// scale(0.1);
// render();
// ```
function wrap(warp) {
  state.filter.wrap = warp;
}

// An X field.
//
// ```
// x();
// render();
// ```
function x() {
  state.filter.field = 'X';
}

// The ratio of a circle's circumference to its diameter. Useful for expressing
// rotations in radians, where a full 360° turn is equal to `2 * PI`. For
// example, to rotate 1/4 turn, use `rotate(1/4 * 2 * PI)`.
const PI = Math.PI;

// The ratio of a circle's circumference to its radius. Useful for expressing
// rotations in radians, where a full 360° turn is equal to `TAU` For example,
// to rotate 1/4 turn, use `rotate(1/4 * TAU)`.
const TAU = Math.PI * 2;

class Rng {
  constructor(seed) {
    this.seed(0);
  }

  choose(array) {
    return array[this._rng.nextU32() % array.length];
  }

  seed(seed) {
    const _seed = new Uint8Array(32);
    _seed[0] = seed;
    this._rng = new randchacha.ChaChaRng(_seed);
  }
}

class Filter {
  constructor() {
    this.alpha = 1.0;
    this.colorTransform = mat4.fromScaling(
      mat4.create(),
      vec3.fromValues(-1, -1, -1)
    );
    this.positionTransform = mat3.create();
    this.coordinates = false;
    this.defaultColor = [0.0, 0.0, 0.0];
    this.field = 'All';
    this.times = 1;
    this.wrap = false;
  }
}

class State {
  constructor() {
    this.delta = 0;
    this.frameCallbacks = [];
    this.rng = new Rng();
    this.start = Date.now();
    this.filter = new Filter();
    this.frame = this.start;
  }
}

let state = null;
let widgets = {};

self.addEventListener('message', async function (event) {
  const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
  const message = JSON.parse(event.data);
  switch (message.tag) {
    case 'frame':
      if (state) {
        for (let callback of state.frameCallbacks) {
          callback();
        }
        state.frameCallbacks.length = 0;
        let now = Date.now();
        state.delta = now - state.frame;
        state.frame = now;
      }
      break;
    case 'script':
      state = new State();
      try {
        await new AsyncFunction(message.content)();
      } catch (error) {
        self.postMessage(JSON.stringify({ error: error.toString() }));
      }
      self.postMessage(JSON.stringify('done'));
      break;
    case 'widget':
      widgets[message.content.key] = message.content.value;
      break;
  }
});