Four hundred thousand points and a little wind.
Nefos (νέφος) is Greek for cloud.
Lay out a regular grid with one point for every two pixels, then let the GPU push every single one of them around with noise.
No point knows about its neighbours. The cloud only appears because points carried by the same current pile up along the same ridges.
The vertex shader
Every point runs through this once per frame. Two simplex noise fields give it a direction, and a soft, noisy mask decides how much of that push it gets, so the edges stay calm and the body billows.
glsl·nefos.vert
#version 300 es
in vec2 aPosition;
uniform float uTime;
uniform vec2 uRandomVec2;
uniform float uPointSize;
out float vAlpha;
void main() {
vec2 p = aPosition;
vec3 q = vec3(p * 1.4 + uRandomVec2, uTime * 0.12);
// two noise fields push every point of the grid
vec2 flow = vec2(snoise(q), snoise(q + 31.7));
// a soft, noisy mask shapes the cloud
float edge = snoise(vec3(p * 1.1 - uRandomVec2, uTime * 0.05)) * 0.25;
float body = smoothstep(0.92, 0.25, length(p * vec2(0.9, 1.25)) + edge);
vec2 pos = p * 0.82 + flow * 0.22 * body;
gl_Position = vec4(pos, 0.0, 1.0);
gl_PointSize = uPointSize;
vAlpha = body * (0.55 + 0.45 * snoise(vec3(p * 5.0, uTime * 0.3)));
}
Points are blended additively (gl.blendFunc(gl.ONE, gl.ONE)), so wherever the flow crowds them together the cloud glows brighter.
Playing along with p5
javascript·nefos.js
const SIZE = 1280;
let gl, glCanvas, timeLoc, count;
let time = 0;
function setup() {
createCanvas(SIZE, SIZE);
pixelDensity(1);
background(16);
// a second, offscreen canvas for the GPU work
glCanvas = document.createElement('canvas');
glCanvas.width = glCanvas.height = SIZE;
gl = glCanvas.getContext('webgl2');
const program = createProgram(gl, vert, frag);
gl.useProgram(program);
// one point for every two pixels
const cols = SIZE / 2;
const positions = new Float32Array(cols * cols * 2);
let k = 0;
for (let col = 0; col < cols; col++) {
for (let row = 0; row < cols; row++) {
positions[k++] = -1 + (2 * col + 1) / cols;
positions[k++] = 1 - (2 * row + 1) / cols;
}
}
count = cols * cols;
setAttributeVec2(gl, program, 'aPosition', positions);
gl.uniform2f(gl.getUniformLocation(program, 'uRandomVec2'), random(300), random(300));
gl.uniform1f(gl.getUniformLocation(program, 'uPointSize'), 1.5);
timeLoc = gl.getUniformLocation(program, 'uTime');
gl.enable(gl.BLEND);
gl.blendFunc(gl.ONE, gl.ONE);
}
function draw() {
// draw every point on the GPU
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.uniform1f(timeLoc, time);
gl.drawArrays(gl.POINTS, 0, count);
// p5 handles the trails: fade, then composite the new frame
background(16, 16, 18, 26);
drawingContext.drawImage(glCanvas, 0, 0, width, height);
time += 0.02;
}
createProgram and setAttributeVec2 are the usual few lines of WebGL boilerplate: compile both shaders, link them, then upload the grid into a buffer bound to aPosition.
References