Примечание редактора: Пока мы отсчитываем время до первой конференции Three.js в Париже в сентябре этого года, мы продолжаем празднование сообщества Three.js вместе с Франческо Микелини. В этом превосходном руководстве Франческо шаг за шагом знакомит нас с процессом превращения простого икосаэдра в динамический интерактивный 3D-кластер, попутно изучая Three.js, TSL, WebGPU и Three-Start.
🎟️Все еще нет билета? В рамках нашего партнерства с первой конференцией Three.js читатели Codrops могут использовать код CODROPS получить скидка 15%. Получите свой билет →
Для меня нет ничего необычного в том, что я нахожусь в какой-нибудь социальной сети в поисках вдохновения как для своих клиентских проектов, так и для потенциальных экспериментов.
Это руководство существует по этой причине: пост в инстаграме Некоторое время назад я заметил, что это в конечном итоге превратилось в интерактивную визуализацию.
Отказ от ответственности: это руководство сразу переходит к делу, поэтому я не буду описывать весь Three.js или какую-либо конкретную настройку сборщика. Я оставлю это тебе!
Разрушение всего эффекта
Прежде всего, давайте определимся, что содержит в себе эффект:
- Вращающийся икосаэдр, грани которого выдавлены.
- Такие грани масштабируются вверх и вниз (мы воспользуемся встроенной функцией шума)
- Эффект сглаживания постобработки
Вот и все! И хорошо, что две из этих трех частей уже включены в Three.js.
Что мы будем использовать
Заметка о «Трех стартах»
трехходовой — это совершенно новая библиотека, которая помогает запустить проект Three.js с помощью всего лишь нескольких строк кода и автоматически управляет всеми основными вещами, такими как цикл рендеринга, настройка рендеринга и камеры, события изменения размера и т. д.
Что мне больше всего нравится в нем, так это то, что он позволяет разделить приложение Three.js на модули и компоненты.
Модуль — это «глобальная функция», которая всегда работает во всем приложении; Загрузка активов, физика, управление вводом — все это можно поместить в модуль.
С другой стороны, компоненты — это, по сути, поведение, которое вы можете прикрепить к каждому Object3D.
Например, вы можете создать Spin компонент, который принимает axis и speed параметр, и при каждом обновлении жизненного цикла такого компонента вы можете вращать объект по этой оси в зависимости от скорости.
Кроме того, вы можете добавлять этот компонент несколько раз, если вам нужно заставить объект вращаться по нескольким осям с разной скоростью.
Хватит разговоров, начнем строить!
Настройка проекта
Для начала установим необходимые нам пакеты:
$ pnpm add three three-start gsap
Затем создайте базовый HTML:
three-start
Наконец, на выходе /src/main.js файл, загрузите базовую сцену Three.js:
import './style.css';
import * as THREE from "three/webgpu";
import { ThreeStart, ThreeContextEvents } from "three-start";
const starter = new ThreeStart();
starter.mount(document.getElementById("app"));
starter.start();
const { scene, camera } = starter.ctx;
camera.position.z = 4
// Log something on each update just to verify everything is working.
// This can be removed later.
starter.ctx.on(ThreeContextEvents.Update, () => {
console.log('update')
})
Вот и все. Теперь, если вы откроете браузер, вы не увидите… ну… ничего. Только черный.
Но если вы откроете консоль, вы увидите множество update сообщения. Это значит, что все работает!
Добавляем наш базовый икосаэдр
Во-первых, давайте создадим наш материал в materials/Inner.js просто чтобы все было организовано.
import { MeshNormalNodeMaterial, BackSide } from 'three/webgpu'
export const InnerMaterial = new MeshNormalNodeMaterial({
side: BackSide,
flatShading: true,
})
Пожалуйста, обрати внимание: мы рендерим задние грани с помощью side: BackSide намеренно, но мы вернемся к этому позже. Кроме того, мы используем MeshNormalNodeMaterial только как временное решение, чтобы проверить, что все работает как положено.
После этого приступаем к добавлению нашей сетки в main.js.
...
import { InnerMaterial } from "./materials/Inner";
...
const innerGeometry = new THREE.IcosahedronGeometry(1, 1);
const innerMesh = new THREE.Mesh(innerGeometry, InnerMaterial);
scene.add(innerMesh);
Вуаля, вот наш икосаэдр:

Прежде чем идти дальше, давайте добавим Spin поведение с использованием three-start. Давайте объявим это в behaviors/Spin.js
import { Object3DBehaviour } from "three-start";
export class Spin extends Object3DBehaviour {
#initRotY = 0
speed = 1
constructor(speed = 1) {
super()
this.speed = speed
}
onUpdate() {
// Make the object spin around the y-axis.
// The rotation is frame indepentent thanks to the usage of the delta time.
const dt = this.ctx.getDeltaTime()
this.object.rotation.y += dt * this.speed
}
onDestroy() {
this.object.rotation.y = this.#initRotY
}
}
Как видите, создание нового поведения на самом деле не составляет большого труда: вы начинаете с расширения Object3DBehaviour class, а затем вы сможете управлять всем с помощью перехватчиков жизненного цикла каждого компонента.
Наконец, добавьте Spin поведение икосаэдра в main.js.
// First, import the new addComponent module from three-start
import { ThreeStart, ThreeContextEvents, addComponent } from "three-start";
...
// Then add it to the mesh.
// In this case, `-0.4` is the `speed` parameter the `Spin` class accepts.
addComponent(innerMesh, Spin, -0.4);
Вот и все. Если вы все сделали правильно, вы должны увидеть вращающуюся сетку.
Пожалуйста, обрати внимание: если что-то визуально кажется «неправильным», это совершенно нормально, потому что мы рендерим внутренние грани сетки.
Создание лиц
Вот тут-то и начинается веселье. Давайте теперь создадим все лица. Мы будем использовать BatchedMesh для этой цели.
Во-первых, вот материал, который мы собираемся использовать.
// materials/Cluster.js
import { MeshNormalNodeMaterial } from 'three/webgpu'
export const ClusterMaterial = new MeshNormalNodeMaterial()
Затем фактическая сетка:
// main.js
import { ClusterMaterial } from './materials/Cluster'
...
function createExtrudedFaces(mesh) {
if (!mesh) return console.error('Mesh is required')
const { geometry } = mesh
const { position: meshPosition } = mesh
const positionAttribute = geometry.getAttribute('position')
const { length: numVertices } = positionAttribute.array
const faceCentroid = new THREE.Vector3()
const faceDirection = new THREE.Vector3()
const instanceMatrix = new THREE.Matrix4()
// We need to count how many faces the base mesh has.
// We can calculate it by dividing the number of vertices by the number of vertices per face.
// Each face has 3 vertices, and each vertex has 3 components (x, y, z), so we divide by 9.
const numFaces = numVertices / 9
// Create the batched mesh
const facesMesh = new THREE.BatchedMesh(
numFaces,
numVertices * 6,
numVertices * 6,
ClusterMaterial,
)
// Iterate over the vertices of the base mesh; 9, so 1 face, per loop iteration.
// For each loop iteration, we need to create a new instance geometry.
// The instance geometry will be a triangle with the same vertices as the base mesh.
let i
for (i = 0; i
Код хорошо прокомментирован, но вот основные моменты:
- Создать новый
BatchedMesh. - Перебираем все вершины, по 9 на итерацию, поскольку каждая грань состоит из 3 вершин. На каждой итерации получить все вершины и построить новую
BufferGeometry. - Вычислите нормали для каждой геометрии.
- Добавьте геометрию к ранее созданной
BatchedMesh. - Как только это будет сделано, добавьте
BatchedMeshк основной сетке.
Вот как это выглядит сейчас:

… и выдавливаем их
К сожалению, Three.js не предлагает способа выдавливания лица из коробки, поэтому нам придется делать это вручную.
На последнем этапе мы создали новый BufferGeometry для каждой грани, поэтому мы собираемся расширить этот шаг, просто добавив больше вершин в эти геометрии, перемещая их наружу вдоль faceDirection вектор.
На последнем шаге мы определили 3 вершины.
x1, y1, z1x2, y2, z2x3, y3, z3
Итак, начиная с этих трёх вершин, мы создадим ещё три вершины:
x4, y4, z4x5, y5, z5x6, y6, z6
…но мы их немного выдавим. Если непонятно, картинка стоит тысячи слов:

...
// Create the additional vertices in `createExtrudedFaces()`
const faceExtrusion = 0.45
const x4 = x1 + faceDirection.x * faceExtrusion
const y4 = y1 + faceDirection.y * faceExtrusion
const z4 = z1 + faceDirection.z * faceExtrusion
const x5 = x2 + faceDirection.x * faceExtrusion
const y5 = y2 + faceDirection.y * faceExtrusion
const z5 = z2 + faceDirection.z * faceExtrusion
const x6 = x3 + faceDirection.x * faceExtrusion
const y6 = y3 + faceDirection.y * faceExtrusion
const z6 = z3 + faceDirection.z * faceExtrusion
...
// Refactor the `attributeArray`constant like this
const attributeArray = new Float32Array([
x1, y1, z1,
x2, y2, z2,
x3, y3, z3,
x1, y1, z1,
x2, y2, z2,
x4, y4, z4,
x2, y2, z2,
x5, y5, z5,
x4, y4, z4,
x2, y2, z2,
x3, y3, z3,
x5, y5, z5,
x3, y3, z3,
x6, y6, z6,
x5, y5, z5,
x3, y3, z3,
x1, y1, z1,
x6, y6, z6,
x1, y1, z1,
x4, y4, z4,
x6, y6, z6,
x4, y4, z4,
x5, y5, z5,
x6, y6, z6,
])
Теперь вот результат:

Имея эти 6 вершин, мы смогли построить каждый треугольник, который образует каждый экземпляр out. BatchedMesh.
Хороший! Теперь все на месте (почти). Остановимся на визуальной части.
Анимируйте экземпляры с помощью шума
Теперь перейдите к materials/Cluster.js и отредактируйте его следующим образом:
import { MeshNormalNodeMaterial } from 'three/webgpu'
import { attribute, positionLocal, Fn, float, mx_noise_float, time } from 'three/tsl'
export const ClusterMaterial = new MeshNormalNodeMaterial()
const scaleMin = float(0.15)
const scaleMax = float(0.75)
const centered = attribute('position', 'vec3')
const centroid = positionLocal.sub(centered)
ClusterMaterial.positionNode = Fn(() => {
const t = time.mul(0.5)
const noise = mx_noise_float(centroid.yz.add
noise.remapAssign(-1, 1, scaleMin, scaleMax)
return centroid.add(centered.mul(noise))
})()
Здесь мы генерируем значение шума, используя центроид каждого экземпляра плюс время в качестве начального значения. Это значение шума, которое исходит из -1 к 1 по умолчанию, затем переназначается на 0.15 - 0.75 диапазон, придающий этому органичному движению.

Прежде чем приступить к работе с цветом
Прежде всего, мы можем скрыть внутреннюю сетку, обновив InnerMaterial:
import { MeshNormalNodeMaterial, BackSide } from 'three/webgpu'
export const InnerMaterial = new MeshNormalNodeMaterial({
side: BackSide,
flatShading: true,
colorWrite: false, // Add this ...
depthWrite: false, // ... and this
})
Мы устанавливаем colorWrite: false потому что мы не хотим, чтобы сетка рисовалась на сцене, НО она нам все равно понадобится в сцене для заключительной части урока, так что это самый оптимальный способ.
Также, depthWrite: false необходим, потому что в противном случае вы увидите множество глюков на обратной стороне сетки из-за z-fightg.
Вот как сцена выглядит сейчас:

На первый взгляд можно подумать, что у экземпляров отсутствует лицо, но это не так.
Ошибка заключается в том порядке, в котором мы определили вершины этой конкретной грани, но хорошо то, что решение действительно простое: вам просто нужно поменять местами две вершины, неважно какие.
Это связано с тем, что передняя и задняя части грани зависят от порядка объявления вершин: если они определены по часовой стрелке, грань направлена внутрь; если они определены против часовой стрелки, грань направлена наружу.
// main.js
...
// This is the first face we declare in `attributeArray`
// Change this ...
x1, y1, z1,
x2, y2, z2,
x3, y3, z3,
// ... to this
x1, y1, z1,
x3, y3, z3,
x2, y2, z2,
...

Цвета, цвета, цвета
Это легко. Все экземпляры имеют в основном три цвета:
- Белый для лиц, направленных к центру.
- Серый (почти) для лиц сбоку
- Черный для внешних сторон
Мы можем управлять цветом каждого лица, используя скалярное произведение. Я не собираюсь копировать определение скалярного произведения, но вот что оно делает:
- Когда два нормализованных вектора обращены друг к другу, он возвращает
1 - Когда они перпендикулярны, возвращается
0 - Когда они указывают в одном направлении, он возвращается
-1
Учитывая это, мы можем вычислить скалярное произведение между нормалью каждого экземпляра и вектором, указывающим от экземпляра сетки к центру, и переназначить его в диапазон. [0, 1] и используйте это значение на mix функция для получения окончательного цвета:
Но прежде чем сделать это, мы должны изменить тип нашего ClusterMaterial от MeshNormalNodeMaterial к MeshBasicNodeMaterialиначе мы не сможем изменить выходной цвет.
// From this ...
import { MeshNormalNodeMaterial } from 'three/webgpu'
export const ClusterMaterial = new MeshNormalNodeMaterial()
// ... to...