Одна и та же веб-страница способна рисовать десятки тысяч непохожих изображений и ни разу не повториться. Всё нужное для этого уже встроено в браузер: элемент canvas, двумерный контекст рисования и немного математики. Генеративная графика превращает код в кисть, а параметры композиции в рычаги, которые крутят до нужного настроения. Дальше разобран весь путь: от управляемой случайности и поля потока до анимации, точной печати и выгрузки готового постера, включая мелочи, на которых обычно и спотыкаются.
От пустого растра к живой картинке за несколько понятных шагов
canvas - это растровый холст, пиксельная сетка, а не коллекция фигур. Пока атрибуты width и height не заданы, холст имеет размер 300 на 150 пикселей, а стили CSS лишь растягивают готовую картинку, и при нарушении пропорций она заметно искажается. Поэтому размер битовой карты задают атрибутами или свойствами DOM, а не одной лишь таблицей стилей. Рисование идёт через контекст, который возвращает метод getContext с аргументом "2d".
const canvas = document.querySelector("#art");
const ctx = canvas.getContext("2d");
ctx.fillStyle = "#101418";
ctx.fillRect(0, 0, 800, 600);
ctx.beginPath();
ctx.arc(400, 300, 140, 0, Math.PI * 2);
ctx.fillStyle = "#e8e2d6";
ctx.fill();
Здесь fillRect заливает фон, beginPath открывает новый путь, arc добавляет окружность от нуля до двух пи радиан, а fill закрашивает всё собранное. Режим у холста мгновенный: команда сразу меняет пиксели, готовых фигур в памяти нет, отменить штрих нельзя, привычных слоёв не существует. Ограничение оборачивается плюсом: рисунок полностью описан текстом программы, значит, его можно повторить, разветвить и поставить на поток. Преобразования работают так же прямолинейно: сдвиг, поворот и масштаб меняют систему координат целиком, а пара save и restore возвращает её из стека.
ctx.save();
ctx.translate(400, 300);
ctx.rotate(Math.PI / 12);
ctx.fillStyle = "#e8e2d6";
ctx.fillRect(-120, -30, 240, 60);
ctx.restore();
Число пи, поделённое на двенадцать, даёт ровно 15 градусов, и плашка аккуратно ложится под таким наклоном. Дальше вопрос только в том, чем наполнить сцену и как не утонуть в ручном переборе.
Псевдослучайность с фиксированным зерном делает композицию воспроизводимой
Math.random при каждой загрузке выдаёт новую последовательность, и удачная композиция теряется навсегда. Художнику нужна управляемая случайность: генератор с зерном, в котором одно и то же число рождает одну и ту же картинку до последнего пикселя. Классическое решение - это функция mulberry32 с 32-битным состоянием: пара десятков строк, зато пространство зёрен насчитывает 4 294 967 296 значений.
function mulberry32(seed) {
return function () {
seed |= 0;
seed = (seed + 0x6D2B79F5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const random = mulberry32(20261021);
const x = random() * 800;
const radius = 20 + random() * 60;
По сути, зерно - это адрес композиции в каталоге из четырёх миллиардов работ. Перебор превращается в прогулку: цикл от 1 до 50 выводит полсотни вариантов, из них выбирают три сильные, а потом уточняют. Серия постеров собирается из соседних зёрен, и числа 1001, 1002 и 1003 дают работы-родственники, а не клоны. Дизайнер, которому к пятнице нужен десяток фонов для обложек, просто прогоняет алгоритм по диапазону и отдаёт на выбор лучшие. В переписке с типографией зерно пишут рядом с номером варианта, и картина восстанавливается на чужой машине без долгих пояснений.
Для естественного разброса ровного генератора мало, поверх него строят распределения. Сумма четырёх случайных чисел минус два даёт мягкий колокол вместо равномерной сыпучки, и размеры фигур перестают выглядеть механическими.
function gauss() {
const sum = random() + random() + random() + random();
return (sum - 2) / 2;
}
const r = 60 * (1 + gauss() * 0.4);
Поле потока из шума ведёт линии как невидимая рука художника
Самый узнаваемый жанр генеративной графики - это поле потока: функция шума назначает каждой точке плоскости угол, тысячи коротких шагов по этим углам складываются в шелковистые пряди, будто чернила расходятся в воде. Готовый шум берут из пакета simplex-noise версии 4.0.3 с фабриками createNoise2D и createNoise3D, а библиотека p5.js версии 2.3.4 оборачивает холст в художественный интерфейс с методами setup и draw. Суть жанра видна даже на двух строках тригонометрии.
function field(x, y) {
const a = Math.sin(x * 0.006) * Math.PI;
const b = Math.cos(y * 0.004 + 2.1) * Math.PI;
return a + b;
}
ctx.strokeStyle = "rgba(214, 40, 40, 0.55)";
ctx.lineWidth = 1.1;
for (let i = 0; i < 500; i++) {
let x = random() * 800;
let y = random() * 600;
ctx.beginPath();
ctx.moveTo(x, y);
for (let step = 0; step < 140; step++) {
const angle = field(x, y);
x += Math.cos(angle) * 2;
y += Math.sin(angle) * 2;
ctx.lineTo(x, y);
}
ctx.stroke();
}
Пятьсот кривых по сто сорок сегментов дают семьдесят тысяч отрезков, и там, где линии пересекаются, полупрозрачный красный с альфой 0.55 накапливает плотность и сам выстраивает свечение. Коэффициенты 0.006 и 0.004 задают характер поля: меньше, и получается спокойная долина, больше, и поверхность покрывается нервной рябью. Палитру держат массивом из пяти-шести цветов и выбирают из неё тем же зерном, тогда оттенки живут в связке с композицией, а скромный набор работает лучше радуги, потому что ограничение рождает стиль, а изобилие превращает полотно в кашу.
import { createNoise3D } from "simplex-noise";
const noise3D = createNoise3D();
const angle = noise3D(x * 0.003, y * 0.003, t * 0.15) * Math.PI * 2;
Третий аргумент шумовой функции - это время. Поле начинает дышать, пряди медленно перестраиваются, и статичная картинка превращается в живой фон, который не спорит с передним планом.
Плотность пикселей решает судьбу тонких линий и мелких деталей
Свойство window.devicePixelRatio возвращает отношение физических пикселей экрана к пикселям CSS. На экранах с двойной плотностью оно равно 2, на многих телефонах доходит до 3. Если внутренняя битовая карта остаётся размером один к одному, браузер растягивает холст, и линии толщиной в пиксель превращаются в размытые валики. Резкость не восстанавливается постфактум, её закладывают в буфер заранее.
function setupHiDPI(canvas, cssW, cssH) {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.style.width = cssW + "px";
canvas.style.height = cssH + "px";
canvas.width = Math.round(cssW * dpr);
canvas.height = Math.round(cssH * dpr);
const ctx = canvas.getContext("2d");
ctx.scale(dpr, dpr);
return ctx;
}
Ограничение двойкой - это осознанный компромисс: на устройстве с плотностью 3 полная отрисовка заполняет в девять раз больше пикселей, и живая сцена начинает буксовать. Для анимации крышку в 2 оставляют, для статичного постера берут полную плотность. Битовая карта фиксирована по пикселям, поэтому размер выбирают до первой линии: перерисовать сцену заново дешевле, чем вытягивать размытый снимок.
Анимация держит ритм монитора и не тратит кадры впустую
requestAnimationFrame просит браузер вызвать функцию перед следующей перерисовкой, и частота вызовов совпадает с частотой обновления дисплея: чаще всего 60 Гц, на игровых мониторах встречаются 75, 120 и 144 Гц. В свёрнутых вкладках и скрытых фреймах вызовы приостанавливаются, что бережёт заряд батареи. Скорость сцены привязывают не к кадрам, а ко времени между ними, тогда на 60 и на 144 Гц движение выглядит одинаковым.
let prev = 0;
function frame(now) {
const dt = Math.min((now - prev) / 1000, 0.05);
prev = now;
ctx.fillStyle = "rgba(16, 20, 24, 0.08)";
ctx.fillRect(0, 0, 800, 600);
updateParticles(dt);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
Полупрозрачная заливка вместо clearRect оставляет шлейф, и частицы превращаются в кометы. Ограничитель в 0.05 секунды спасает от скачка после паузы: без него проснувшаяся вкладка одним кадром переносит сцену на несколько секунд вперёд. Частицы держат в одном массиве объектов и обновляют одним проходом, без сборки новых массивов на каждом кадре: лишние аллокации будят сборщик мусора, и картинка вздрагивает ровно в этот момент. Бюджет одного кадра при 60 Гц равен примерно 16 миллисекундам, и всё, что не уложилось, роняет плавность. Тяжёлые сцены уносят с главного потока через OffscreenCanvas: интерфейс отвязывает холст от документа и разрешает операции рендера в контексте воркера, то есть в отдельном потоке. Когда нужна работа на уровне самих пикселей, getImageData отдаёт массив RGBA, где на каждый пиксель приходится по четыре байта, для холста 800 на 600 это 1 920 000 байт, а putImageData возвращает обработанные значения обратно.
Режимы наложения и градиенты заменяют плоской фигуре глубину и свечение
По умолчанию globalCompositeOperation равен "source-over": новое покрытие просто закрашивает старое. Значение "lighter" складывает цветовые каналы, и на тёмном фоне пересечения начинают светиться. Режим "multiply" перемножает пиксели слоёв и всегда даёт более тёмную картину, "screen" инвертирует, перемножает и инвертирует обратно, действуя ровно наоборот, а "overlay" комбинирует оба поведения в зависимости от яркости нижнего слоя. Радиальный градиент от цвета к прозрачному в паре со сложением превращает скучный круг в неоновую лампу.
ctx.globalCompositeOperation = "lighter";
for (const [x, y] of spots) {
const g = ctx.createRadialGradient(x, y, 0, x, y, 90);
g.addColorStop(0, "rgba(90, 160, 255, 0.55)");
g.addColorStop(1, "rgba(90, 160, 255, 0)");
ctx.fillStyle = g;
ctx.fillRect(x - 90, y - 90, 180, 180);
}
ctx.globalCompositeOperation = "source-over";
Общая прозрачность регулируется свойством globalAlpha в диапазоне от 0.0 до 1.0, где ноль означает полную прозрачность. Отдельного внимания заслуживают тени: shadowColor задаёт цвет, shadowOffsetX и shadowOffsetY смещение, shadowBlur силу размытия. Коварная деталь: размытие не выражается числом пикселей и не зависит от текущей матрицы преобразований, по умолчанию оно равно нулю, а отрицательные значения и NaN браузер молча игнорирует. После ctx.scale(dpr, dpr) фигуры растут, а тени остаются прежними, так что смещение и размытие доводят руками. Мелкое ремесло живёт и в свойствах линий: lineCap со значением "round" скругляет концы отрезков, lineJoin отвечает за стыки, а setLineDash превращает сплошную линию в пунктир, и чередование рисующих и пустых участков поверх поля потока создаёт эффект вышивки.
Экспорт в PNG и подготовка файла к печати в высоком разрешении
Метод toBlob отдаёт двоичный объект картинки, из которого собирают файл для скачивания, а toDataURL возвращает вместо этого строку с данными. Печать требует заранее посчитать математику: формат A4 при плотности 300 точек на дюйм это 2480 на 3508 пикселей, A3 вырастает до 3508 на 4961, а A2 до 4961 на 7016, потому что миллиметры делят на 25.4 и умножают на 300. Схема одна: выбрать печатный размер, домножить все координаты на масштаб и отрисовать сцену заново с тем же зерном.
canvas.toBlob((blob) => {
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = "poster-" + seed + ".png";
link.click();
URL.revokeObjectURL(link.href);
}, "image/png");
Для тиража процесс складывается в короткий цикл, и порядок в нём важнее скорости:
1. зафиксировать формат холста, палитру и сетку композиции;
2. написать скетч и вынести настройки в параметры с зерном случайности;
3. перебрать зёрна, отобрать сильные композиции и отсеять слабые;
4. закрепить зерно финального изображения как номер карточки в серии;
5. отрисовать копию в печатном разрешении и выгрузить PNG.
Генеративная графика на canvas держится на трёх опорах: воспроизводимая случайность, честная плотность пикселей и аккуратный экспорт. Когда зерно зафиксировано, тысяча показов страницы рождает одну и ту же картинку, а смена одного числа открывает соседнюю работу. Машина рисует, человек выбирает, и это разделение труда оказалось на удивление продуктивным.