Генеративная графика — не только ИИ
- суббота, 26 октября 2024 г. в 00:00:05
Привет, Хабр! В прошлый раз мы с вами создавали «Игру жизни» на Godot. Движок показал себя отлично, но для такой простой задачи это всё равно что забивать микроскопом гвозди. Особенно когда речь идёт о веб‑экспорте.
В последнее время стоит заикнуться про генерацию изображений, как все сразу вспоминают про нейросети. Stable Diffusion, Midjourney и прочие модели впечатляют, не спорю. Но давайте взглянем на другую сторону генеративного искусства. Ту, где картинки создаются не гигабайтами весов нейронной сети, а несколькими килобайтами JavaScript-кода.
И кстати раз уж речь зашла про красоту в коде: мы как раз запустили «Конкурс красоты кода 2.0». Самое время показать, что даже простые алгоритмы могут создавать нечто впечатляющее. Именно такие работы, где за внешней простотой скрывается математическая элегантность, часто оказываются самыми интересными.
Для создания впечатляющей графики достаточно обычного JavaScript. Никаких нейросетей, никакой магии — только код и математика. Konva.js, p5.js, pixijs — эти библиотеки позволяют творить прямо в браузере. И начнём мы с классики жанра — «Игры жизни» Конвея. Этот клеточный автомат наглядно демонстрирует, как простейшие правила порождают сложные, завораживающие узоры.
P5.js — прямой наследник Processing, языка, который годами использовали художники и дизайнеры для создания цифрового искусства. Konva.js упрощает работу с Canvas до уровня детского конструктора. А Pixi.js берет на себя оптимизацию производительности, позволяя создавать плавные анимации даже на слабых устройствах.
P5.js — это не просто библиотека для рисования. Это целая философия творческого кодинга, перенесённая в веб из мира Processing. И если Processing был языком для художников, то P5.js стал библиотекой для веб‑разработчиков с творческой жилкой. Для простоты запускать код будем в официальном онлайн‑редакторе.
«Игра жизни» на P5.js выглядит максимально лаконично:
// Функция для создания 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 смотрит на задачу иначе. Здесь каждая клетка — самостоятельный объект, которым можно управлять независимо. Такой подход даёт больше контроля над происходящим. Своего онлайн‑редактора Konva.js в отличие от P5.js не имеет, так что обернём JS код в простенькую 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 изначально создавался для игр, но его возможности отлично подходят и для генеративной графики. Особенно когда нужна максимальная производительность. Поэкспериментировать с ним как и с P5.js можно в официальном онлайн‑редакторе:
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();
}
});
})();
Клеточные автоматы — это, конечно, классика, но возможности наших библиотек куда шире. Давайте посмотрим на другие примеры генеративной графики, и начнём, пожалуй также по порядку, с P5.js.
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;
}
Всего несколько строчек кода, а на выходе получаем завораживающие волны, переливающиеся всеми цветами радуги. Никаких сложных математических формул, только синусы и косинусы из школьной программы.
<!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>
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;
});
});
})();
После всех этих примеров закономерно возникает вопрос: какую же библиотеку использовать? Ответ, как обычно в программировании — зависит от задачи.
P5.js идеален для быстрого прототипирования и обучения. Его простой API позволяет сосредоточиться на творческой составляющей, не отвлекаясь на технические детали. Если вы только начинаете погружаться в мир генеративной графики — начните с него.
Konva.js — отличный выбор для интерактивных приложений с множеством независимых объектов. Особенно хорош, когда нужно манипулировать отдельными элементами или обрабатывать события мыши и касания.
PixiJS стоит выбирать, когда производительность критична. Тысячи частиц, сложные анимации, постоянное обновление экрана — здесь он чувствует себя как рыба в воде.
Для создания красоты не обязательно нужны нейросети и гигабайты данных. Иногда достаточно простых математических формул и базового понимания геометрии. А современные JavaScript‑библиотеки делают процесс создания таких визуализаций доступным практически для каждого.
И если вы увлекаетесь подобными экспериментами — самое время показать их миру. «Конкурс красоты кода» от Сбера — отличная возможность для этого. Ведь красивый код — это не только правильные отступы и говорящие названия переменных. Это ещё и элегантные алгоритмы, порождающие настоящее цифровое искусство.