Как создать генеративные иллюстрации с помощью Three.js

Создайте динамичное, генеративное произведение искусства, используя Three.js и сетчатые структуры, вдохновленное минималистскими геометрическими работами Лигии Кларк.

В этом руководстве мы разработаем генеративное произведение искусства, вдохновленное творчеством выдающейся бразильской художницы Лигии Кларк. Ее картины, основанные на минимализме и геометрии, идеально подходят для интерпретации с помощью сетки и генеративных алгоритмов:

Преимущества использования сетки
Сетки — фундаментальный инструмент дизайна, применяемый от типографики до дизайна интерьеров. Их значение выходит далеко за рамки дизайна, охватывая архитектуру, математику, науку, технологии и изобразительное искусство. Ключевое свойство любой сетки — увеличение числа повторов расширяет возможности, добавляя детализацию и ритм. Например, изображение 2x2 пикселя ограничено 4 цветовыми значениями, в то время как изображение 1080x1080 предоставляет 1 166 400 пикселей для цветовых вариаций.
Настройка проекта
Перед началом кодирования настроим проект и структуру папок. В этом проекте будут использоваться vite
, react
и react three fiber
для удобства и быстрой разработки, но вы можете выбрать любой удобный инструмент.
npm create vite@latest generative-art-with-three -- --template react
После создания проекта с помощью Vite
установим Three.js
и React Three Fiber
, а также соответствующие типы.
cd generative-art-with-three
npm i three @react-three/fiber
npm i -D @types/three
Удалим ненужные файлы, такие как vite.svg
(папка public
), App.css
и папку assets
. Создадим папку components
в папке src
для компонентов проекта. Главный компонент назовем Lygia.jsx
, но вы можете использовать любое имя.
├─ public
├─ src
│ ├─ components
│ │ └─ Lygia.jsx
│ ├─ App.jsx
│ ├─ index.css
│ └─ main.jsx
├─ .gitignore
├─ eslint.config.js
├─ index.html
├─ package-lock.json
├─ package.json
├─ README.md
└─ vite.config.js
Продолжим настройку Three.js
/React Three Fiber
.
Настройка React Three Fiber
React Three Fiber упрощает настройку, автоматически обрабатывая WebGLRenderer
и основные элементы: scene (сцену), camera (камеру), canvas (изменение размера холста) и animation loop (анимационный цикл). Все это заключено в компонент Canvas
. Компоненты, добавляемые в Canvas
, должны соответствовать Three.js API. Вместо ручного создания экземпляров классов и добавления их в сцену, мы используем их как компоненты React (соблюдая camelCase).
// Vanilla Three.js
const scene = new Scene()
const mesh = new Mesh(new PlaneGeometry(), new MeshBasicMaterial())
scene.add(mesh)
// React Three Fiber
import { Canvas } from "@react-three/fiber";
function App() {
return (
<Canvas>
<mesh>
<planeGeometry />
<meshBasicMaterial />
</mesh>
</Canvas>
);
}
export default App;
Для полноэкранного отображения приложения добавим стили в index.css
.
html,
body,
#root {
height: 100%;
margin: 0;
}
Запустив приложение командой npm run dev
, вы увидите следующее:

Поздравляем! Вы создали самое простое приложение! Перейдём к созданию сетки.
Разработка сетки
После импорта референсного изображения Лигии Кларк в Figma и создания макета, эксперименты показали, что большинство элементов размещаются в сетке 50x86 (без отступов). Хотя существуют более точные методы определения модульной сетки, этого достаточно для нашей задачи. Переведем эту структуру сетки в код в файле Lygia.jsx
:
import { useMemo, useRef } from "react";
import { Object3D } from "three";
import { useFrame } from "@react-three/fiber";
const dummy = new Object3D();
const LygiaGrid = ({ width = 50, height = 86 }) => {
const mesh = useRef();
const squares = useMemo(() => {
const temp = [];
for (let i = 0; i < width; i++) {
for (let j = 0; j < height; j++) {
temp.push({
x: i - width / 2,
y: j - height / 2,
});
}
}
return temp;
}, [width, height]);
useFrame(() => {
for (let i = 0; i < squares.length; i++) {
const { x, y } = squares[i];
dummy.position.set(x, y, 0);
dummy.updateMatrix();
mesh.current.setMatrixAt(i, dummy.matrix);
}
mesh.current.instanceMatrix.needsUpdate = true;
});
return (
<instancedMesh ref={mesh} args={[null, null, width * height]}>
<planeGeometry />
<meshBasicMaterial wireframe color="black" />
</instancedMesh>
);
};
export { LygiaGrid };
Далее разберем каждый элемент кода:
- Создайте переменную с именем
dummy
и назначьте ее объектуObject3D
из Three.js. Это позволит нам сохранять позиции и любые другие преобразования. Мы будем использовать ее для передачи всех этих преобразований в сетку. У него нет никакой другой функции, отсюда и названиеdummy
(подробнее об этом позже). - Мы добавляем элементы и высоту сетки в качестве реквизита нашего компонента.
- Мы будем использовать перехватчики React
useRef
, чтобы иметь возможность ссылаться наinstancedMesh
(подробнее об этом позже).. - Чтобы иметь возможность задать позиции всех наших экземпляров, мы заранее вычисляем их в функции. Мы используем хук
useMemo
из React, потому что по мере увеличения сложности мы сможем сохранять вычисления между повторными рендерами (он будет обновляться только в том случае, если значения массива зависимостей обновятся [width
,height
]). Внутри памятки у нас есть два циклаfor
для определения ширины и высоты, и мы задаем позиции, используяi
, чтобы задать позициюx
, иj
, чтобы задать позициюy
. Мы будем делить ширину и высоту на два, чтобы наша сетка элементов была центрирована. - У нас есть два варианта установки позиций: хук
useEffect
из React или хукuseFrame
из React Three Fiber. Мы выбрали последний, потому что это цикл рендеринга. Это позволит нам анимировать элементы, на которые ссылаются. - Внутри хука
useFrame
мы проходим по всем экземплярам с помощьюsquares.length
. Здесь мы деконструируем наши предыдущиеx
иy
для каждого элемента. Мы передаем их нашему фиктивному объекту, а затем используемupdateMatrix()
для применения изменений. - Наконец , мы возвращаем
<instancedMesh/>
, который оборачивает наш<planeGeometry/>
, который будет нашими квадратами 1×1, и<meshBasicMaterial/>
— в противном случае мы бы ничего не увидели. Мы также устанавливаем каркасную опору так, чтобы было видно, что это сетка 50×86 квадратов, а не большой прямоугольник.
Теперь мы можем импортировать наш компонент в наше основное приложение и использовать его внутри компонента <Canvas/>
. Чтобы просмотреть всю нашу сетку, нам нужно будет отрегулировать z
-позицию камеры до 65
.
import { Canvas } from "@react-three/fiber";
import { Lygia } from "./components/Lygia";
function App() {
return (
<Canvas camera={{ position: [0, 0, 65] }}>
<Lygia />
</Canvas>
);
}
export default App;
Результат будет выглядеть следующим образом

Деконструкция сетки
Одно из самых сложных — отказаться от усвоенных правил, будь то в искусстве, математике или программировании. В работах Лигии Кларк заметно, что некоторые элементы намеренно выходят за пределы строгой сетки.
Рассмотрим столбцы: всего их 12, при этом столбцы под номерами 2, 4, 7, 8, 10 и 11 меньше остальных (столбцы 1, 3, 5, 6, 9 и 12 — больше). Более того, ширина этих меньших столбцов различна (например, столбец 2 шире столбца 10). Для моделирования этого создадим массив меньших номеров столбцов: `[2, 4, 7, 8, 10, 11]`. Проблема в том, что у нас 50 столбцов, а не 12. Простым решением будет циклическое повторение массива из 12 номеров и использование коэффициента масштабирования для определения ширины столбцов (каждый столбец будет содержать 4.1666 квадратов (50/12)):
const dummy = new Object3D();
const LygiaGrid = ({ width = 50, height = 80, columns = 12 }) => {
const mesh = useRef();
const smallColumns = [2, 4, 7, 8, 10, 11];
const squares = useMemo(() => {
const temp = [];
let x = 0;
for (let i = 0; i < columns; i++) {
const ratio = width / columns;
const column = smallColumns.includes(i + 1) ? ratio - 2 : ratio + 2;
for (let j = 0; j < height; j++) {
temp.push({
x: x + column / 2 - width / 2,
y: j - height / 2,
scaleX: column,
});
}
x += column;
}
return temp;
}, [width, height]);
useFrame(() => {
for (let i = 0; i < squares.length; i++) {
const { x, y, scaleX } = squares[i];
dummy.position.set(x, y, 0);
dummy.scale.set(scaleX, 1, 1);
dummy.updateMatrix();
mesh.current.setMatrixAt(i, dummy.matrix);
}
mesh.current.instanceMatrix.needsUpdate = true;
});
return (
<instancedMesh ref={mesh} args={[null, null, columns * height]}>
<planeGeometry />
<meshBasicMaterial color="red" wireframe />
</instancedMesh>
);
};
export { LygiaGrid };
Итак, мы зацикливаем наши столбцы, мы устанавливаем наше ratio
равным width
сетки, деленной на columns
. Затем мы устанавливаем column
равным нашему ratio
минус 2
, если он есть в списке наших маленьких столбцов, или ratio плюс 2
, если его там нет. Затем мы делаем то же самое, что и раньше, но наш x
немного отличается. Поскольку наши столбцы являются случайными числами, нам нужно суммировать текущую ширину column
с x
в конце нашего первого цикла:

Для более естественного разрушения сетки используем шум (например, библиотеку Open Simplex Noise, но подойдут и другие).
npm i open-simplex-noise
С учетом шума, цикл for
будет выглядеть следующим образом:
import { makeNoise2D } from "open-simplex-noise";
const noise = makeNoise2D(Date.now());
const LygiaGrid = ({ width = 50, height = 86, columns = 12 }) => {
const mesh = useRef();
const smallColumns = [2, 4, 7, 8, 10, 11];
const squares = useMemo(() => {
const temp = [];
let x = 0;
for (let i = 0; i < columns; i++) {
const n = noise(i, 0) * 5;
const remainingWidth = width - x;
const ratio = remainingWidth / (columns - i);
const column = smallColumns.includes(i + 1)
? ratio / MathUtils.mapLinear(n, -1, 1, 3, 4)
: ratio * MathUtils.mapLinear(n, -1, 1, 1.5, 2);
const adjustedColumn = i === columns - 1 ? remainingWidth : column;
for (let j = 0; j < height; j++) {
temp.push({
x: x + adjustedColumn / 2 - width / 2,
y: j - height / 2,
scaleX: adjustedColumn,
});
}
x += column;
}
return temp;
}, [width, height]);
// Rest of code...
Сначала мы импортируем функцию makeNoise2D
из open-simplex-noise
, затем создаем переменную noise
, которая равна ранее импортированной makeNoise2D
с аргументом Date.now()
. Теперь мы можем перейти к нашему циклу for
.
- Мы добавляем постоянную переменную под названием
n
, которая равна нашей функцииnoise
. Мы передаем в качестве аргумента приращение (i
) из нашего цикла и умножаем его на5
, что даст нам больше значений между -1 и 1. - Поскольку мы будем использовать случайные числа, нам нужно отслеживать нашу оставшуюся ширину, которая будет равна нашему
remaningWidth
, деленному на количествоcolumns
минус текущее количество столбцовi
. - Далее у нас та же логика, что и раньше, чтобы проверить, находится ли столбец в нашем списке
smallColumns
, но с небольшим изменением; мы используем шумn
. В этом случае используем функциюmapLinear
из Three.jsMathUtils
и сопоставим значение с[-1, 1]
с[3, 4]
в случае, если столбец находится в наших маленьких столбцах, или с[1.5, 2]
в случае, если это не так. Обратите внимание, что мы делим его или умножаем. Попробуйте свои значения. - Наконец, если это последний столбец, мы используем наш
remaningWidth
.

Теперь остался только один шаг, нам нужно задать высоту нашей строки. Для этого нам просто нужно добавить опору для rows
, как мы это делали для columns
, и выполнить цикл по ней, а в верхней части useMemo
мы можем разделить нашу height
на количество rows
. Не забудьте, наконец, присвоить ему значение temp
в качестве scale
и использовать его в useFrame
.
const LygiaGrid = ({ width = 50, height = 86, columns = 12, rows = 10 }) => {
...
const squares = useMemo(() => {
const temp = [];
let x = 0;
const row = height / rows;
for (let i = 0; i < columns; i++) {
const n = noise(i, 0) * 5;
const remainingWidth = width - x;
const ratio = remainingWidth / (columns - i);
const column = smallColumns.includes(i + 1)
? ratio / MathUtils.mapLinear(n, -1, 1, 3, 4)
: ratio * MathUtils.mapLinear(n, -1, 1, 1.5, 2);
const adjustedColumn = i === columns - 1 ? remainingWidth : column;
for (let j = 0; j < rows; j++) {
temp.push({
x: x + adjustedColumn / 2 - width / 2,
y: j * row + row / 2 - height / 2,
scaleX: adjustedColumn,
scaleY: row,
});
}
x += column;
}
return temp;
}, [width, height, columns, rows]);
useFrame(() => {
for (let i = 0; i < squares.length; i++) {
const { x, y, scaleX, scaleY } = squares[i];
dummy.position.set(x, y, 0);
dummy.scale.set(scaleX, scaleY, 1);
dummy.updateMatrix();
mesh.current.setMatrixAt(i, dummy.matrix);
}
mesh.current.instanceMatrix.needsUpdate = true;
});
...
Кроме того, помните, что количество наших instanceMesh
должно быть равно columns * rows
:
<instancedMesh ref={mesh} args={[null, null, columns * rows]}>
После всего этого мы, наконец, увидим ритм более случайного характера. Поздравляю, вы сломали сетку:

Добавление Цвета
Помимо использования масштаба для разбола нашей сетки, мы также можем использовать еще один незаменимый элемент нашего мира; цвет. Для этого мы создадим палитру в нашей сетке и передадим наши цвета нашим экземплярам. Но сначала нам нужно будет извлечь палитру из изображения. Здесь использовался ручной подход; импорт изображения в Figma и использование инструмента eyey-dropper
, но вы, вероятно, можете использовать инструмент экстрактора палитры:

Как только у нас будет наша палитра, мы сможем преобразовать ее в список и передать в качестве реквизита компонента, это будет удобно в случае, если мы захотим передать другую палитру извне компонента. Здесь мы снова будем использовать памятку об использовании для сохранения наших цветов:
//...
import { Color, MathUtils, Object3D } from "three";
//...
const palette =["#B04E26","#007443","#263E66","#CABCA2","#C3C3B7","#8EA39C","#E5C03C","#66857F","#3A5D57",]
const c = new Color();
const LygiaGrid = ({ width = 50, height = 86, columns = 12, rows = 10, palette = palette }) => {
//...
const colors = useMemo(() => {
const temp = [];
for (let i = 0; i < columns; i++) {
for (let j = 0; j < rows; j++) {
const rand = noise(i, j) * 1.5;
const colorIndex = Math.floor(
MathUtils.mapLinear(rand, -1, 1, 0, palette.length - 1)
);
const color = c.set(palette[colorIndex]).toArray();
temp.push(color);
}
}
return new Float32Array(temp.flat());
}, [columns, rows, palette]);
})
//...
return (
<instancedMesh ref={mesh} args={[null, null, columns * rows]}>
<planeGeometry>
<instancedBufferAttribute
attach="attributes-color"
args={[colors, 3]}
/>
</planeGeometry>
<meshBasicMaterial vertexColors toneMapped={false} />
</instancedMesh>
);
Как мы делали раньше, давайте объясним пункт за пунктом, что здесь происходит:
- Обратите внимание, что мы объявили константу
c
, которая равнаColor
three.js. Это будет иметь то же применение, что иdummy
, но вместо того, чтобы хранить матрицу, мы будем хранить цвет. - Мы используем константу
colors
для хранения наших рандомизированных цветов. - Мы снова проходим по нашим
columns
иrows
, поэтому длина наших цветов будет равна длине наших экземпляров. - Внутри двухмерного цикла мы создаем случайную переменную, называемую
rand
, где мы снова используем нашу функциюnoise
. Здесь мы используем наши переменныеi
иj
из цикла. Мы делаем это, чтобы получить более плавный результат при выборе наших цветов. Если мы умножим его на1,5
, это даст нам больше разнообразия, и это то, что нам нужно. colorIndex
представляет собой переменную, которая будет хранить индекс, который будет идти от0
до нашейpalette.length
. Для этого мы снова отображаем наши значенияrand
с1
и1
на0
иpalette.length
, которая в данном случае равна9
.- Мы округляем значение, поэтому получаем только целочисленные значения.
- Используйте константу
c
для установкиset
текущего цвета. Мы делаем это с помощьюpalette[colorIndex
]. Отсюда мы используем метод three.jsColor toArray()
, который преобразует шестнадцатеричный цвет в массив[r,g,b]
. - Сразу после этого мы помещаем цвет в наш
temp
массив. - После завершения обоих циклов мы возвращаем
Float32Array
, содержащий наш массивtemp
в сплющенном виде, поэтому мы получим все цвета как[r,g,b,r,g,b,r,g,b,r,g,b...]
- Теперь мы можем использовать наш цветовой массив. Как видите, он используется внутри
<planeGeometry>
как<instancedBufferAttribute />
. У экземпляра буфера есть два свойства:attach="attributes-color"
иargs={[colors, 3]}
.Attach="attributes-color"
взаимодействует с внутренней системой шейдеров three.js и будет использоваться для каждого из наших экземпляров. Значением этого атрибута являетсяargs={[colors, 3]}
, поэтому мы передаем наш массивcolors
и3
, что указывает на то, что это массив цветовr, g, b
. - Наконец, чтобы активировать этот атрибут в наших фрагментных шейдерах, нам нужно установить
vertexColors
в значениеtrue
в нашем<meshBasicMaterial />
.
Как только мы все это сделаем, мы получим следующий результат:

Мы очень близки к нашему конечному результату, но, если мы проверим оригинальное произведение искусства, то увидим, что красный цвет не используется в более широких столбцах, противоположное происходит с желтым, также некоторые цвета более распространены в более широких столбцах, чем в меньших столбцах. Есть много способов решить эту проблему, но один из быстрых способов решить эту проблему - иметь две функции отображения; одну для маленьких столбцов и одну для более широких. Это будет выглядеть примерно так:
const colors = useMemo(() => {
const temp = [];
for (let i = 0; i < columns; i++) {
for (let j = 0; j < rows; j++) {
const rand = noise(i, j) * 1.5;
const range = smallColumns.includes(i + 1)
? [0, 4] // 1
: [1, palette.length - 1]; // 1
const colorIndex = Math.floor(
MathUtils.mapLinear(rand, -1.5, 1.5, ...range)
);
const color = c.set(palette[colorIndex]).toArray();
temp.push(color);
}
}
return new Float32Array(temp.flat());
}, [columns, rows, palette]);
Вот что происходит:
- Если текущий столбец находится в
smallColumns
, то диапазон, который мы хотим использовать из нашей палитры, составляет от0
до4
. А если нет, то: от1
(без красного) доpalette.length - 1
. - Затем в функции
map
мы передаем этот новый массив и распространяем его так, чтобы получить0, 4
или1, palette.length - 1
, в зависимости от выбранной нами логики.
Одно из того, что нужно иметь в виду, это то, что используются фиксированные значения из палитры. Если вы хотите быть более избирательными, вы можете создать список с парами ключ-значение. Вот результат, который мы получили после применения функции double map
:

Теперь вы можете итерировать, используя разные числа в функции makeNoise2D
. Например, makeNoise2D(10)
даст вам результат выше. Поиграйте с разными значениями, чтобы увидеть, что у вас получится!
Добавление графического интерфейса пользователя
Один из лучших способов поэкспериментировать с генеративной системой - это добавление графического пользовательского интерфейса (GUI). В этом разделе мы рассмотрим, как реализовать.
Во-первых, нам нужно будет установить удивительную библиотеку, которая значительно упрощает процесс; leva.
npm i leva
Как только мы его установим, мы сможем использовать его следующим образом:
import { Canvas } from "@react-three/fiber";
import { Lygia } from "./components/Lygia";
import { useControls } from "leva";
function App() {
const { width, height } = useControls({
width: { value: 50, min: 1, max: 224, step: 1 },
height: { value: 80, min: 1, max: 224, step: 1 },
});
return (
<Canvas camera={{ position: [0, 0, 65] }}>
<Lygia width={width} height={height} />
</Canvas>
);
}
export default App;
- Мы импортируем хук
useControls
изleva
. - Используем наш хук внутри приложения и определяем значения ширины и высоты.
- Наконец, мы передаем нашу ширину и высоту опорам нашего компонента Lygia.
В правом верхнем углу экрана вы увидите новую панель, где вы можете настроить наши значения с помощью ползунка, как только вы их измените, вы увидите, что сетка меняет свою ширину и/или высоту.

Теперь, когда мы знаем, как это работает, мы можем начать добавлять остальные значения следующими - следующими явами:
import { Canvas } from "@react-three/fiber";
import { Lygia } from "./components/Lygia";
import { useControls } from "leva";
function App() {
const { width, height, columns, rows, color1, color2, color3, color4, color5, color6, color7, color8, color9 } = useControls({
width: { value: 50, min: 1, max: 224, step: 1 },
height: { value: 80, min: 1, max: 224, step: 1 },
columns: { value: 12, min: 1, max: 500, step: 1 },
rows: { value: 10, min: 1, max: 500, step: 1 },
palette: folder({
color1: "#B04E26",
color2: "#007443",
color3: "#263E66",
color4: "#CABCA2",
color5: "#C3C3B7",
color6: "#8EA39C",
color7: "#E5C03C",
color8: "#66857F",
color9: "#3A5D57",
}),
});
return (
<Canvas camera={{ position: [0, 0, 65] }}>
<Lygia
width={width}
height={height}
columns={columns}
rows={rows}
palette={[color1, color2, color3, color4, color5, color6, color7, color8, color9]}
/>
</Canvas>
);
}
export default App;
На первый взгляд, это много, но, как и все, что мы делали раньше, все по-другому. Мы объявляем наши строки и столбцы так же, как и для ширины и высоты. Цвета имеют те же шестнадцатеричные значения, что и в нашей палитре, мы просто группируем их, используя функцию folder
от leva
. После деконструкции мы можем использовать их в качестве переменных для нашего Lygia props
. Обратите внимание, что в палитре prop мы используем массив всех цветов, точно так же, как палитра определяется внутри компонента,
Теперь вы увидите что-то вроде следующего изображения:

Теперь мы можем изменять наши цвета и количество столбцов и строк, но очень быстро мы можем увидеть проблему; внезапно наши столбцы не имеют такого ритма, как раньше. Это происходит потому, что наши маленькие колонки не динамичны. Мы можем легко решить эту проблему, используя записку, в которой наши столбцы пересчитываются при изменении количества столбцов:
const smallColumns = useMemo(() => {
const baseColumns = [2, 4, 7, 8, 10, 11];
if (columns <= 12) {
return baseColumns;
}
const additionalColumns = Array.from(
{ length: Math.floor((columns - 12) / 2) },
() => Math.floor(Math.random() * (columns - 12)) + 13
);
return [...new Set([...baseColumns, ...additionalColumns])].sort(
(a, b) => a - b
);
}, [columns]);
Теперь наша генеративная система готова к использованию.
Применение и практика
Эффективность сетчатой системы обусловлена ее широкими возможностями. Несмотря на простоту, это мощный инструмент, позволяющий создавать бесконечно разнообразные работы. Рекомендуется экспериментировать с сеткой, воспроизводя существующие примеры или создавая собственные. Представлены несколько примеров, которые, как надеемся, послужат источником вдохновения:
Герхард Рихтер
Например, добавив случайность boolean
к столбцам и изменив цветовую палитру, можно приблизиться к стилю абстрактных работ Джеральда Рихтера:


Третье измерение
Цвет может обозначать глубину: синий — даль, жёлтый — близость, красный — исходная точка. Аналогичный подход использовали художники движения De Stijl.

Другие элементы
Как насчет включения кругов, треугольников или линий? Может быть, текстуры? Возможности безграничны - вы можете экспериментировать с различными элементами искусства, дизайна, науки или математики.

Заключение
В данной статье представлено воссоздание произведений искусства Лигии Кларк и изучение возможностей модульной сетки. Подробно описаны принципы работы с сеткой и методы ее модификации для достижения уникального результата. Приведены примеры произведений искусства, которые могут быть воссозданы с использованием модульной сетки.
Рекомендуется экспериментировать с модульной сеткой, создавая собственные произведения искусства, отражающие индивидуальный стиль. Результаты можно опубликовать и поделиться ими.
Ссылки:
- Полный код: https://github.com/eduardfossas/codrops-generative-artwork-three
- Демо: https://tympanus.net/Tutorials/GenerativeArtworkThreejs/