Reference

Engines

The three engines a piece can run on, three.js, GLSL and p5.js, and the shape of each one’s code.

Every piece runs on one of three engines. The studio calls them WebGL 3D (three.js), GLSL (a fragment shader) and Canvas 2D (p5.js), and the gallery's filters call them 3D, Shader and 2D. Each has its own shape of code, which this page sets out, so you can write a piece by hand or change what Perlin wrote without breaking it.

Whichever engine it is, the runtime owns the canvas, its size, the frame loop, pausing and capture. Your code draws; the runtime decides when. What a piece's code can use beyond the engine itself (controls, the pointer, sound, assets and saving) is on The runtime API.

Which engine to use

EngineLanguageGood forCan use
WebGL 3DJavaScript module, three.js r186Anything spatial: objects, materials, lighting, particles in space, cameras, post-processingControls, pointer, sound, textures, every kind of asset, saving files, VR and AR
GLSLGLSL ES 3.00 fragment shaderRaymarched worlds, fractals, noise fields, abstract motion graphicsControls from annotated uniforms, the pointer, the previous frame
Canvas 2DJavaScript, p5.js 1.11 in global modeFlow fields, line work, patterns, typography, particle trailsControls, pointer, sound, textures, images, fonts and video, saving files

To pick one before Perlin builds, use the switch in the prompt box: Auto, 3D, Shader or 2D. When you change a piece, Perlin keeps its engine unless you ask for another.

Only the WebGL 3D and Canvas 2D engines can use your assets in their code, and only WebGL 3D can load a 3D model or go into VR and AR. Any engine can carry a timeline with text, image and video layers over it.

WebGL 3D

The code is an ES module. Its default export is a function that sets the scene up once and returns what to do each frame.

import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

export default function sketch({ scene, camera, renderer, gui }) {
  const params = { speed: 0.4, colour: '#ff7a45' };

  scene.background = new THREE.Color('#07080c');
  camera.position.set(0, 1, 5);
  const orbit = new OrbitControls(camera, renderer.domElement);
  orbit.enableDamping = true;

  const material = new THREE.MeshStandardMaterial({ color: params.colour, roughness: 0.3 });
  const knot = new THREE.Mesh(new THREE.TorusKnotGeometry(1, 0.32, 200, 32), material);
  scene.add(knot, new THREE.HemisphereLight('#ffffff', '#202030', 1.5));

  gui.add(params, 'speed', 0, 2).name('Speed');
  gui.addColor(params, 'colour').name('Colour').onChange((v) => material.color.set(v));

  return {
    update({ delta }) {
      knot.rotation.y += delta * params.speed;
      orbit.update();
    },
  };
}

What the function receives

NameWhat it is
THREEThe three.js module, the same one import * as THREE from 'three' gives
sceneA THREE.Scene, rendered for you every frame
cameraA PerspectiveCamera: field of view 50, near 0.1, far 2000, at (0, 0, 6) facing the origin
rendererA WebGLRenderer, antialiased, sized to the canvas. Colour output is sRGB, with no tone mapping and shadows off until you turn them on
canvasThe canvas it draws on, for pointer listeners
size{ width, height, pixelRatio }, kept current
width, heightThe current size in CSS pixels
pointerWhere the mouse, pen or finger is; see Pointer and size
guiThe controls; see Controls
fontsAddresses of built-in typefaces for FontLoader: helvetiker, helvetikerRegular, optimer, gentilis, droidSans, droidSerif, mono
sfx, textures, assetsSound, images and your uploaded files; see The runtime API
xrVR and AR; see VR and AR in code

The function may be async, for example to await a model or a typeface before the first frame.

What it returns

Every member is optional.

MemberWhen it runs
update({ time, delta, frame, pointer })Every frame, before rendering. time and delta are in seconds and stop while the piece is paused. delta is never more than 0.1
render()Instead of the default renderer.render(scene, camera), for example composer.render() with post-processing
resize({ width, height, pixelRatio })After the runtime has resized the renderer and updated the camera's aspect
dispose()When the page goes away
cameraA different camera to render with

Rules of the engine

  • Import only from three and three/addons/…. They resolve to three.js r186.
  • Never create a renderer, call requestAnimationFrame or setAnimationLoop, or add a canvas of your own. The runtime does all of that.
  • Only the camera you're given is updated on resize. An OrthographicCamera of your own, an EffectComposer or a render target needs its own sizing in resize().
  • The pixel ratio is capped at 2.

GLSL

The code is one fragment shader, Shadertoy style. Define mainImage; the #version line, precision and main() are added for you.

uniform float uSpeed; // @control value=0.5 min=0.0 max=2.0 label="Speed"
uniform int uRings;   // @control value=8 min=2 max=24 label="Rings"
uniform vec3 uTint;   // @control value=#4cc9f0 label="Tint"
uniform bool uPulse;  // @control value=true label="Pulse"

void mainImage(out vec4 fragColor, in vec2 fragCoord) {
  vec2 uv = (fragCoord - 0.5 * iResolution.xy) / iResolution.y;
  float d = length(uv);
  float wave = sin(d * float(uRings) * 6.2831 - iTime * uSpeed * 4.0);
  float glow = smoothstep(0.0, 1.0, wave) * (uPulse ? 0.6 + 0.4 * sin(iTime) : 1.0);
  vec3 col = uTint * glow * (1.0 - d);
  fragColor = vec4(pow(max(col, 0.0), vec3(0.4545)), 1.0);
}

Built-in uniforms

Use these without declaring them.

UniformTypeWhat it holds
iResolutionvec3The canvas size in device pixels (z is 1)
iTimefloatSeconds since the piece started; stops while paused
iTimeDeltafloatSeconds since the last frame
iFrameRatefloatFrames a second
iFrameintFrames drawn so far
iMousevec4xy is the pointer in pixels, followed on hover and starting at the centre. zw is where it was last pressed: positive while pressed, negative once released
iDatevec4Year, month (counting from 0), day, and seconds into the day
iChannel0sampler2DThe previous frame. Mentioning it turns feedback on; sample it with texture(iChannel0, fragCoord / iResolution.xy)

Controls from uniforms

A uniform becomes a control when its line ends in a // @control comment. Uniforms without one aren't set by anything, and names starting with i and a capital are kept for the built-ins.

TypeControlExample
floatSlideruniform float uZoom; // @control value=1.0 min=0.5 max=4.0
intSlider in whole stepsuniform int uSteps; // @control value=96 min=24 max=160
boolSwitchuniform bool uGrain; // @control value=true
vec3 or vec4Colour pickeruniform vec3 uGlow; // @control value=#ff7a45

The keys after @control:

  • value is where it starts. A colour takes a hex value, or three numbers from 0 to 1 such as value=1.0,0.5,0.2. A vec4 colour's alpha is always 1.
  • min and max set a slider's range, and step its increments.
  • label="…" names it in the panel. Without one, the uniform's name is used, minus a leading u before a capital: uSpeed shows as Speed.
  • folder="…" groups it with other controls of the same folder.

A control's key, for keyframes and for the embed API, is the uniform's name, after its folder and a dot when it has one: uSpeed, or Light.uGlow.

Shader details

  • GLSL ES 3.00 is strict: write 1.0 rather than 1, convert with float(i), use texture(), and give every loop a constant bound.
  • The colour you write is shown as it is, with alpha ignored. Do your own tone mapping and gamma.
  • The engine draws at a lower resolution when a shader can't keep up, down to half, and comes back up when it can. Build coordinates from iResolution and the piece stays right at any resolution and shape.
  • The pixel ratio is capped at 1.5.

Canvas 2D

The code is a plain script for p5.js 1.11 in global mode: no import or export, with setup() and draw().

const params = { count: 400, speed: 1, ink: '#f1d72c' };
let dots = [];

function setup() {
  createCanvas(windowWidth, windowHeight);
  gui.add(params, 'count', 50, 2000, 1).name('Dots').onChange(reset);
  gui.add(params, 'speed', 0.1, 4).name('Speed');
  gui.addColor(params, 'ink').name('Ink');
  reset();
}

function reset() {
  dots = Array.from({ length: params.count }, () => createVector(random(width), random(height)));
}

function draw() {
  background(10, 30);
  stroke(params.ink);
  for (const p of dots) {
    const a = noise(p.x * 0.003, p.y * 0.003, frameCount * 0.004) * TWO_PI * 2;
    p.x = (p.x + cos(a) * params.speed + width) % width;
    p.y = (p.y + sin(a) * params.speed + height) % height;
    point(p.x, p.y);
  }
}

Canvas details

  • gui, sfx, textures, assets and vybe are globals. A variable of your own with the same name hides the runtime's.
  • A canvas made with createCanvas(windowWidth, windowHeight) is resized for you, and keeps its last frame, scaled to cover, so trails don't flash to black. Define windowResized() yourself when anything else depends on the size.
  • mouseX, mouseY, mouseIsPressed, mousePressed(), mouseDragged() and mouseReleased() answer to touch as well as the mouse. For several fingers, read touches.
  • Images come from textures.get(id), which you can call in setup() or later. Don't reach for loadImage, loadFont or loadSound: the frame can't fetch files from other sites. Use textures, assets and sfx instead.
  • A still piece draws in draw(), calls noLoop(), and calls redraw() from its controls' onChange.

What no engine can do

A piece runs in a sealed frame, so it can't reach the page around it or the rest of the internet. It can't send anything out or fetch from other sites: only the engine's own library and your assets are loaded for it. It has no localStorage, cookies or clipboard, can't open windows or show alert, and a frame that navigates away is taken down. Draw any interface on the canvas, take values through gui, and give the viewer files with vybe.save.

A loop that runs for more than 3 seconds is stopped, and the piece reports which line it was on.

Still stuck, or something here out of date? Tell us