Generative graphics are not just AI

And by the way, since we’re talking about beauty in code: we just launched “Code Beauty Contest 2.0” It's time to show that even simple algorithms can create something impressive. It is precisely such works, where mathematical elegance is hidden behind external simplicity, that often turn out to be the most interesting.

Three approaches to one problem

All you need to do to create impressive graphics is just plain JavaScript. No neural networks, no magic – just code and mathematics. Konva.js, p5.js, pixijs – these libraries allow you to create directly in the browser. And we’ll start with the classics of the genre – Conway’s “Games of Life”. This cellular automaton clearly demonstrates how the simplest rules give rise to complex, fascinating patterns.

P5.js is a direct descendant of Processing, a language that artists and designers have used for years to create digital art. Konva.js simplifies working with Canvas to the level of a child's designer. And Pixi.js takes care of performance optimization, allowing you to create smooth animations even on low-end devices.

P5.js – successor to Processing

P5.js is more than just a drawing library. This is a whole philosophy of creative coding, transferred to the web from the world of Processing. And if Processing was a language for artists, then P5.js became a library for web developers with a creative streak. For simplicity, we will run the code in official online editor.

The “Game of Life” on P5.js looks as succinct as possible:

JavaScript
// Функция для создания 2D массива
function make2DArray(cols, rows) {
    let arr = new Array(cols);
    for (let i = 0; i < cols; i++) {
        arr[i] = new Array(rows);
    }
    return arr;
}

// Функция для подсчёта соседей
function countNeighbors(grid, x, y) {
    let sum = 0;
    for (let i = -1; i <= 1; i++) {
        for (let j = -1; j <= 1; j++) {
            let col = (x + i + cols) % cols;
            let row = (y + j + rows) % rows;
            sum += grid[col][row];
        }
    }
    sum -= grid[x][y];
    return sum;
}

let cells = [];
const resolution = 10;
const cols = 80;
const rows = 60;

function setup() {
    createCanvas(800, 600);
    for (let i = 0; i < cols; i++) {
        cells[i] = [];
        for (let j = 0; j < rows; j++) {
            cells[i][j] = floor(random(2));
        }
    }
    frameRate(10); // Замедляем для наглядности
}

function draw() {
    background(255);
    
    // Отрисовка текущего состояния
    for (let i = 0; i < cols; i++) {
        for (let j = 0; j < rows; j++) {
            let x = i * resolution;
            let y = j * resolution;
            if (cells[i][j] === 1) {
                fill(0);
                stroke(128);
                rect(x, y, resolution - 1, resolution - 1);
            }
        }
    }
    
    // Расчёт следующего поколения
    let next = make2DArray(cols, rows);
    
    for (let i = 0; i < cols; i++) {
        for (let j = 0; j < rows; j++) {
            let neighbors = countNeighbors(cells, i, j);
            
            if (cells[i][j] === 1 && (neighbors < 2 || neighbors > 3)) {
                next[i][j] = 0;    // Смерть от одиночества или перенаселения
            } else if (cells[i][j] === 0 && neighbors === 3) {
                next[i][j] = 1;    // Рождение
            } else {
                next[i][j] = cells[i][j]; // Стазис
            }
        }
    }
    cells = next;
}

Konva.js – object-oriented approach

Konva.js looks at the problem differently. Here, each cell is an independent object that can be controlled independently. This approach gives more control over what is happening. Unlike P5.js, Konva.js does not have its own online editor, so let’s wrap the JS code in a simple HTML page, which we will then open in the browser:

HTML
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Konva.js Game of Life</title>
    <script src="https://cdn.jsdelivr.net/npm/konva@9.2.0/konva.min.js"></script>
    <style>
        #container {
            border: 1px solid #ccc;
            width: 800px;
            height: 600px;
            margin: auto;
            display: block;
        }
    </style>
</head>
<body>
    <div id="container"></div>
    
    <script>
        const stage = new Konva.Stage({
            container: 'container',
            width: 800,
            height: 600
        });

        const layer = new Konva.Layer();
        const cells = [];
        const cellSize = 10;
        const cols = 80;
        const rows = 60;

        // Инициализация поля
        for (let i = 0; i < cols; i++) {
            cells[i] = [];
            for (let j = 0; j < rows; j++) {
                const cell = new Konva.Rect({
                    x: i * cellSize,
                    y: j * cellSize,
                    width: cellSize - 1,
                    height: cellSize - 1,
                    fill: Math.random() > 0.5 ? 'black' : 'white',
                    stroke: '#ddd',
                    strokeWidth: 0.5
                });
                cells[i][j] = {
                    alive: cell.fill() === 'black',
                    shape: cell
                };
                layer.add(cell);
            }
        }
        stage.add(layer);

        // Функция обновления состояния
        function updateState() {
            const newStates = [];
            for (let i = 0; i < cols; i++) {
                newStates[i] = [];
                for (let j = 0; j < rows; j++) {
                    const neighbors = countNeighbors(i, j);
                    const currentState = cells[i][j].alive;

                    if (currentState && (neighbors < 2 || neighbors > 3)) {
                        newStates[i][j] = false;
                    } else if (!currentState && neighbors === 3) {
                        newStates[i][j] = true;
                    } else {
                        newStates[i][j] = currentState;
                    }
                }
            }

            // Обновляем только изменившиеся клетки
            for (let i = 0; i < cols; i++) {
                for (let j = 0; j < rows; j++) {
                    if (cells[i][j].alive !== newStates[i][j]) {
                        cells[i][j].alive = newStates[i][j];
                        cells[i][j].shape.fill(newStates[i][j] ? 'black' : 'white');
                    }
                }
            }
            layer.draw();
        }

        // Подсчет количества соседей
        function countNeighbors(x, y) {
            let sum = 0;
            for (let i = -1; i <= 1; i++) {
                for (let j = -1; j <= 1; j++) {
                    const col = (x + i + cols) % cols;
                    const row = (y + j + rows) % rows;
                    sum += cells[col][row].alive ? 1 : 0;
                }
            }
            sum -= cells[x][y].alive ? 1 : 0;
            return sum;
        }

        // Обновляем каждые 500ms
        setInterval(updateState, 500);
    </script>
</body>
</html>

PixiJS – the power of WebGL

PixiJS was originally created for games, but its capabilities are also great for generative graphics. Especially when you need maximum performance. You can experiment with it as with P5.js in the official online editor:

JavaScript
import { Application, Graphics } from 'pixi.js';

(async () => {
    // Константы для игры
    const CELL_SIZE = 10;
    const GRID_WIDTH = 80;
    const GRID_HEIGHT = 60;
    
    // Создаем новое приложение
    const app = new Application();

    // Инициализируем приложение
    await app.init({ 
        background: '#1099bb', 
        resizeTo: window 
    });

    // Добавляем холст приложения в тело документа
    document.body.appendChild(app.canvas);

    // Создаем две сетки для текущего и следующего состояния
    let currentGrid = Array(GRID_HEIGHT).fill().map(() => 
        Array(GRID_WIDTH).fill().map(() => Math.random() < 0.3));
    let nextGrid = Array(GRID_HEIGHT).fill().map(() => 
        Array(GRID_WIDTH).fill(false));

    // Создаем графический объект для отрисовки клеток
    const cells = new Graphics();
    app.stage.addChild(cells);

    // Подсчет живых соседей для клетки
    function countNeighbors(grid, x, y) {
        let count = 0;
        for (let i = -1; i <= 1; i++) {
            for (let j = -1; j <= 1; j++) {
                if (i === 0 && j === 0) continue;
                const newY = (y + i + GRID_HEIGHT) % GRID_HEIGHT;
                const newX = (x + j + GRID_WIDTH) % GRID_WIDTH;
                if (grid[newY][newX]) count++;
            }
        }
        return count;
    }

    // Обновление сетки по правилам Конвея
    function updateGrid() {
        for (let y = 0; y < GRID_HEIGHT; y++) {
            for (let x = 0; x < GRID_WIDTH; x++) {
                const neighbors = countNeighbors(currentGrid, x, y);
                const cell = currentGrid[y][x];
                
                // Применяем правила Конвея
                if (cell && (neighbors < 2 || neighbors > 3)) {
                    nextGrid[y][x] = false; // Смерть от одиночества или перенаселения
                } else if (!cell && neighbors === 3) {
                    nextGrid[y][x] = true;  // Рождение
                } else {
                    nextGrid[y][x] = cell;  // Остается без изменений
                }
            }
        }

        // Меняем сетки местами
        [currentGrid, nextGrid] = [nextGrid, currentGrid];
    }

    // Отрисовка текущего состояния
    function drawGrid() {
        cells.clear();
        
        // Рисуем живые клетки
        cells.beginFill(0xFFFFFF);
        for (let y = 0; y < GRID_HEIGHT; y++) {
            for (let x = 0; x < GRID_WIDTH; x++) {
                if (currentGrid[y][x]) {
                    cells.drawRect(
                        x * CELL_SIZE, 
                        y * CELL_SIZE, 
                        CELL_SIZE - 1, 
                        CELL_SIZE - 1
                    );
                }
            }
        }
        cells.endFill();
    }

    // Центрирование сетки в окне
    function centerGrid() {
        cells.x = (app.screen.width - GRID_WIDTH * CELL_SIZE) / 2;
        cells.y = (app.screen.height - GRID_HEIGHT * CELL_SIZE) / 2;
    }

    // Обработка изменения размера окна
    window.addEventListener('resize', centerGrid);
    centerGrid();

    // Добавляем интерактивность для переключения клеток при клике
    app.stage.eventMode="static";
    app.stage.on('pointertap', (event) => {
        const bounds = cells.getBounds();
        const x = Math.floor((event.global.x - bounds.x) / CELL_SIZE);
        const y = Math.floor((event.global.y - bounds.y) / CELL_SIZE);
        
        if (x >= 0 && x < GRID_WIDTH && y >= 0 && y < GRID_HEIGHT) {
            currentGrid[y][x] = !currentGrid[y][x];
            drawGrid();
        }
    });

    // Цикл анимации
    let frameCount = 0;
    app.ticker.add(() => {
        frameCount++;
        if (frameCount % 10 === 0) { // Обновление каждые 10 кадров для замедления анимации
            updateGrid();
            drawGrid();
        }
    });
})();

Not just cells

Cellular automata are, of course, classics, but the capabilities of our libraries are much wider. Let's look at other examples of generative graphics, and let's start, perhaps also in order, with P5.js.

Harmonic vibrations on P5.js

JavaScript
let angle = 0;
const waves = 5;

function setup() {
    createCanvas(800, 600);
    colorMode(HSB, 100);
    noFill();
}

function draw() {
    background(95);
    
    for (let w = 0; w < waves; w++) {
        beginShape();
        strokeWeight(2);
        stroke(w * 20, 80, 80);
        
        for (let x = 0; x < width; x += 5) {
            let y = height/2 + 
                sin(angle + x * 0.02 + w) * 100 * 
                sin(angle * 0.4);
            vertex(x, y);
        }
        endShape();
    }
    angle += 0.05;
}

Just a few lines of code, and the output is mesmerizing waves, shimmering with all the colors of the rainbow. No complex mathematical formulas, just sines and cosines from the school curriculum.

Fractal trees on Konva.js

JavaScript
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Konva.js Fractal Tree</title>
    <script src="https://cdn.jsdelivr.net/npm/konva@9.2.0/konva.min.js"></script>
    <style>
        #container {
            border: 1px solid #ccc;
            width: 800px;
            height: 600px;
            margin: auto;
            display: block;
        }
    </style>
</head>
<body>
    <div id="container"></div>

    <script>
        // Создаём сцену Konva, указывая контейнер для холста и его размеры
        const stage = new Konva.Stage({
            container: 'container', // контейнер, куда будет помещён холст
            width: 800,             // ширина холста
            height: 600             // высота холста
        });

        // Создаём слой, на который будем добавлять элементы (линии)
        const layer = new Konva.Layer();
        stage.add(layer); // добавляем слой на сцену

        // Рекурсивная функция для рисования ветвей дерева
        function drawBranch(startX, startY, len, angle, depth) {
            // Условие завершения рекурсии — глубина равна 0, ветвь больше не рисуется
            if (depth === 0) return;

            // Вычисляем конечные координаты текущей ветви
            const endX = startX + len * Math.cos(angle); // конечная координата X
            const endY = startY + len * Math.sin(angle); // конечная координата Y

            // Создаём линию с текущими координатами и цветом, зависящим от глубины
            const line = new Konva.Line({
                points: [startX, startY, endX, endY],   // массив точек линии: [началоX, началоY, конецX, конецY]
                stroke: `hsl(${120 + depth * 15}, 100%, ${20 + depth * 8}%)`, // цвет линии, зависящий от глубины
                strokeWidth: depth                     // ширина линии уменьшается с глубиной
            });

            // Добавляем линию на слой
            layer.add(line);

            // Рекурсивно вызываем функцию для рисования двух новых ветвей с изменённым углом и длиной
            // Левая ветвь (уменьшаем угол)
            drawBranch(endX, endY, len * 0.7, angle - 0.5, depth - 1);
            // Правая ветвь (увеличиваем угол)
            drawBranch(endX, endY, len * 0.7, angle + 0.5, depth - 1);
        }

        // Начинаем рисовать дерево с корневой ветви
        // Начальная точка (400, 550), длина первой ветви — 120, угол — вверх (-Math.PI / 2), глубина — 9
        drawBranch(400, 550, 120, -Math.PI / 2, 9);

        // Отображаем все добавленные элементы на слое
        layer.draw();
    </script>
</body>
</html>

Particles in motion on PixiJS

JavaScript
import { Application, Sprite, Graphics } from 'pixi.js';


(async () => {
   // Создаём новое приложение
   const app = new Application();


   // Инициализируем приложение
   await app.init({
       background: '#000000',
       resizeTo: window
   });


   // Добавляем холст в DOM
   document.body.appendChild(app.canvas);


   const particles = [];
   const particleCount = 1000;


   // Создаём текстуру для частицы
   const particleGraphics = new Graphics()
       .beginFill(0xFFFFFF)
       .drawCircle(0, 0, 2)
       .endFill();


   const particleTexture = app.renderer.generateTexture(particleGraphics);


   // Инициализируем частицы
   for (let i = 0; i < particleCount; i++) {
       const particle = new Sprite(particleTexture);
      
       // Устанавливаем начальную позицию
       particle.x = Math.random() * app.screen.width;
       particle.y = Math.random() * app.screen.height;
      
       // Случайный цвет для каждой частицы
       particle.tint = Math.random() * 0xFFFFFF;
      
       // Добавляем скорость
       particle.velocity = {
           x: Math.random() * 2 - 1,
           y: Math.random() * 2 - 1
       };


       // Добавляем альфа-канал для разнообразия
       particle.alpha = 0.5 + Math.random() * 0.5;
      
       // Центрируем точку вращения
       particle.anchor.set(0.5);
      
       // Добавляем частицу в массив и на сцену
       particles.push(particle);
       app.stage.addChild(particle);
   }


   // Анимируем частицы
   app.ticker.add(() => {
       particles.forEach(particle => {
           // Обновляем позицию
           particle.x += particle.velocity.x;
           particle.y += particle.velocity.y;


           // Отражение от границ
           if (particle.x < 0) {
               particle.x = 0;
               particle.velocity.x *= -1;
           } else if (particle.x > app.screen.width) {
               particle.x = app.screen.width;
               particle.velocity.x *= -1;
           }


           if (particle.y < 0) {
               particle.y = 0;
               particle.velocity.y *= -1;
           } else if (particle.y > app.screen.height) {
               particle.y = app.screen.height;
               particle.velocity.y *= -1;
           }


           // Добавляем небольшое вращение
           particle.rotation += 0.01 * (particle.velocity.x + particle.velocity.y);
       });
   });


   // Обработка изменения размера окна
   window.addEventListener('resize', () => {
       // При изменении размера окна перераспределяем частицы
       particles.forEach(particle => {
           if (particle.x > app.screen.width) particle.x = app.screen.width;
           if (particle.y > app.screen.height) particle.y = app.screen.height;
       });
   });


})();

What to choose?

After all these examples, the question naturally arises: which library to use? The answer, as usual in programming, depends on the task.

P5.js is ideal for rapid prototyping and learning. Its simple API allows you to focus on the creative side without getting distracted by technical details. If you're just starting to dive into the world of generative graphics, start here.

Konva.js is a great choice for interactive applications with many independent objects. Particularly good when you need to manipulate individual elements or handle mouse and touch events.

PixiJS is worth choosing when performance is critical. Thousands of particles, complex animations, constant screen updates – here he feels like a fish in water.

To create beauty, you don’t necessarily need neural networks and gigabytes of data. Sometimes simple math formulas and a basic understanding of geometry are enough. And modern JavaScript libraries make the process of creating such visualizations accessible to almost everyone.

And if you are interested in such experiments, now is the time to show them to the world. “Code Beauty Contest» from Sber is an excellent opportunity for this. After all, beautiful code is not only about correct indentations and meaningful variable names. These are also elegant algorithms that give rise to real digital art.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *