«Прогулка по лесу» — небольшой лес на three.js, по которому можно гулять прямо в браузере. Там есть пологие холмы, ручей, петляющий по долине, десятки тысяч травинок и листьев, колышущихся на ветру, цветы, рыбки, бабочки и низкое солнце золотого часа. У воды стоит девочка в красном платье; кликните по земле — и она пойдёт туда. Всё работает на three.js r160 и WebGL 2 без скачиваемых моделей и текстур-картинок: всё генерируется кодом при загрузке.
В этой статье разбираем, как это устроено, с короткими фрагментами настоящего исходного кода: рельеф, трава, ветер, деревья и 3D-сцена на three.js, по которой можно ходить, без физического движка и навмеша.
Рендерер, тонмаппинг и небо золотого часа
Рендерер рисует в контейнер сцены на странице и настроен на физически освещённую сцену под открытым небом:
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 1.75));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 0.68;
Тонмаппинг ACES Filmic мягко сглаживает яркие блики, а не обрезает их, — это важно, когда в кадре небо; экспозиция заметно ниже 1, чтобы луг не выгорал.
Небо — аддон Sky из three.js, шейдер, моделирующий атмосферное рассеяние. Мы задаём мутность и рэлеевское рассеяние для слегка дымчатого дня и поднимаем солнце всего на 16° над горизонтом — именно это даёт всей сцене тёплый вид с длинными тенями:
const phi = THREE.MathUtils.degToRad(90 - 16); // sun 16° up: golden hour
const theta = THREE.MathUtils.degToRad(150);
sunDir.setFromSphericalCoords(1, phi, theta);
skyU.sunPosition.value.copy(sunDir);
const pmrem = new THREE.PMREMGenerator(renderer);
scene.environment = pmrem.fromScene(sky).texture;
Строка с PMREMGenerator — дешёвый выигрыш: она запекает небо в предфильтрованную карту окружения, и каждый MeshStandardMaterial в сцене бесплатно получает окружающий свет и отражения цвета неба. Поверх этого — тёплый DirectionalLight, направленный вдоль того же sunDir (чтобы тени совпадали с небом), мягкая карта теней 2048 × 2048, покрывающая область 100 × 100 вокруг поляны, сине-зелёный HemisphereLight для заполняющего света и экспоненциальный туман (FogExp2) бледного шалфейного оттенка, растворяющий даль.
Процедурный рельеф из одной функции высоты
Всё в сцене, что касается земли, спрашивает у одной функции, где находится земля. Сначала ручей: его осевая линия — кривая x = cxOf(z) из двух синусоид разной частоты, поэтому он петляет без явных повторов:
const cxOf = z => 8 + 2.6 * Math.sin(z * 0.045) + 1.3 * Math.sin(z * 0.11 + 1.3);
function terrainH(x, z) {
const d = Math.abs(x - cxOf(z));
const rolling = (Math.sin(x * 0.13 + z * 0.07) + Math.sin(x * 0.05 - z * 0.11 + 1.7)) * 0.35
+ Math.sin(x * 0.021 + z * 0.017 + 0.5) * 0.9;
const flat = ss(3.2, 7.5, d); // flatten near the water
const carve = (1 - ss(0, 4.6, d)) * 1.15; // dig the valley
const bank = Math.exp(-((d - 5.6) ** 2) / 3.0) * 0.28; // slight lip on the bank
return rolling * flat - carve + bank;
}
Читайте её по слоям. rolling — три наложенные синусоиды для пологих холмов. d — горизонтальное расстояние до осевой линии ручья, а ss — smoothstep. Умножение холмов на flat гасит их у воды, чтобы берега были ровными; carve вычитает до 1,15 единицы прямо под ручьём, прорезая русло; а bank — небольшой гауссов горб примерно в 5,6 единицы от оси, который читается как естественный приподнятый край берега.
Меш — это повёрнутый горизонтально PlaneGeometry(240, 240, 190, 190), высота каждой вершины которого берётся из terrainH. Тот же цикл записывает цвета вершин по d: тёмное дно в пределах 3 единиц, песок, переходящий в траву между 3,6 и 5,4 единицы, и дальше трава с небольшими синусоидальными вариациями оттенка и яркости. Один вызов computeVertexNormals() — и рельеф готов, без карты высот и библиотеки шума.
Поскольку terrainH дешёвая и детерминированная, она же расставляет 160 камешков на дне ручья, 70 камней на берегах, каждую травинку, цветок и дерево — и ступни девочки. Ничего не висит в воздухе и ничего не проваливается.
Водная лента, повторяющая русло
Вода — не большая плоскость, обрезанная рельефом. Это собственный BufferGeometry: полоса из 240 рядов, в каждом по две вершины по обе стороны от cxOf(z), поэтому лента изгибается точно вместе с руслом:
for (let i = 0; i < rows; i++) {
const z = waterZ0 + (waterZ1 - waterZ0) * (i / (rows - 1));
const cx = cxOf(z);
wPos.set([cx - WATER_HALF, WATER_Y, z, cx + WATER_HALF, WATER_Y, z], i * 6);
}
Её материал — MeshPhysicalMaterial с transmission: 0.92, ior: 1.33 (показатель преломления воды), небольшой толщиной и бирюзовым цветом затухания. Transmission заставляет three.js отрисовать непрозрачную сцену в дополнительный буфер и выбирать из него сквозь поверхность, так что галька и дно просвечивают с лёгким преломлением и оттенком. Это дороже обычной прозрачности, но даёт убедительную мелкую воду за очень небольшой объём кода.
Рябь считается на CPU: каждый кадр 480 высот вершин полосы задаются суммой трёх синусоид от положения и времени, а нормали пересчитываются. Это почти ничего не стоит. В нашей статье про штормовой океан та же идея используется в куда большем масштабе.
25 000 травинок за один вызов отрисовки
Про траву спрашивают чаще всего, и по большей части это один InstancedMesh. Одна травинка начинается как тонкий PlaneGeometry(0.085, 1.05, 1, 3), сдвинутый так, чтобы основание было в начале координат, а затем каждой вершине придаётся форма:
const yy = p.getY(i) / 1.05;
p.setX(i, p.getX(i) * (1 - yy * 0.85)); // taper to a point
p.setZ(i, p.getZ(i) + yy * yy * 0.32); // curve forward
const v = 0.5 + yy * 0.65; // dark root, bright tip
Градиент вершин от тёмного к светлому — дешёвая замена ambient occlusion в густом газоне. Материал — двусторонний MeshStandardMaterial с vertexColors.
Затем 25 000 экземпляров рассыпаются по диску радиусом 58. Формула r = Math.sqrt(Math.random()) * 58 сохраняет равномерную плотность по всему диску (без квадратного корня травинки сбиваются к центру), а любая точка ближе 4,6 единицы к ручью отбрасывается, чтобы вода оставалась чистой. Каждый экземпляр получает случайный поворот, лёгкий наклон, масштаб от 0,6 до 1,7 и собственный цвет через setColorAt. Three.js умножает цвет экземпляра на градиент вершин, так что у каждой травинки свой оттенок зелёного, но один и тот же переход от тёмного к светлому.
Итог: 25 000 травинок, одна геометрия, один материал, один вызов отрисовки, а матрицы строятся один раз при запуске.
Ветер через onBeforeCompile
Собственный шейдер травы мы не писали. Вместо этого небольшой хелпер правит вершинный шейдер стандартного материала сразу после того, как three.js вычислит локальную позицию, — так всё встроенное освещение, тени и туман продолжают работать:
material.onBeforeCompile = (shader) => {
shader.uniforms.uTime = uTime;
shader.vertexShader = 'uniform float uTime;\n' + shader.vertexShader.replace(
'#include <begin_vertex>',
`#include <begin_vertex>
float wph = instanceMatrix[3].x * 0.8 + instanceMatrix[3].z * 0.6;
float wh = max(position.y, 0.0);
transformed.x += (sin(uTime * 1.7 + wph) + sin(uTime * 2.9 + wph * 1.7) * 0.4) * 0.17 * wh;
transformed.z += cos(uTime * 1.3 + wph * 1.2) * 0.102 * wh;`);
};
(Немного упрощено: настоящий хелпер оборачивает строку с instanceMatrix в #ifdef USE_INSTANCING и принимает амплитуду параметром.) Работает это благодаря трём деталям:
- Фаза от положения.
instanceMatrix[3]— смещение экземпляра, поэтомуwph— это фаза, плавно меняющаяся по полю. Соседние травинки движутся почти синхронно, и движение катится по лугу порывами, а не дёргается у всех травинок одновременно. - Вес по высоте. Умножение на
position.y(0 у корня) держит травинку на месте, а её кончик раскачивается сильнее всего. - Один общий uniform. Все пропатченные материалы получают один и тот же объект
uTime, поэтому одно присваивание за кадр,uTime.value = t, анимирует и траву, и листву всех семи деревьев сразу.
Для листьев тот же хелпер вызывается с меньшей амплитудой (0,07) и без взвешивания по высоте, так что каждая карточка листа смещается целиком, а не изгибается.
Деревья из цилиндров и карточек листьев
Каждый ствол — это стопка из двух-трёх сужающихся сегментов CylinderGeometry с небольшим случайным наклоном, пять расходящихся цилиндров-корней у основания и несколько ветвей, направленных к кроне, — всё с текстурой коры, нарисованной в canvas при запуске (вертикальные штрихи кривыми Безье поверх коричневого).
Крона — ещё один InstancedMesh: простой квадрат со стороной 0,62 единицы с нарисованной в canvas текстурой листа, вырезанной через alphaTest: 0.45. Альфа-тест вместо настоящей прозрачности означает отсутствие сортировки и корректную глубину, что необходимо при тысячах перекрывающихся карточек. Карточки распределяются по нескольким сферическим кластерам на дерево пропорционально объёму кластера и размещаются в основном ближе к внешней стороне каждого кластера, чтобы крона выглядела пышной, но не сплошной:
const rr = c[3] * (0.55 + 0.45 * Math.cbrt(Math.random()));
dmy.position.set(c[0] + dir.x * rr, c[1] + dir.y * rr * 0.85, c[2] + dir.z * rr);
Листья сверху кластера окрашены ярче и теплее, чем снизу, — это имитирует солнечный свет, пробивающийся сквозь листву. На большом главном дереве 9 000 карточек листьев, на каждом из шести деревьев поменьше — 2 200, всего около 22 200. За ними, в 62–90 единицах, стоит кольцо из 90 инстанцированных конусов; в тумане они читаются как далёкая кромка леса и придают поляне глубину почти бесплатно.
Ходьба по клику: рейкаст, ограничение, прогулка
Камера использует OrbitControls, и обе кнопки мыши, левая и правая, назначены на вращение (на сенсорных экранах правой кнопки нет). Поэтому первая задача — отличить клик от перетаскивания. На pointerup мы игнорируем событие, если указатель сдвинулся больше чем на 6 пикселей или нажатие длилось дольше 700 мс; иначе пускаем луч из камеры через точку клика:
raycaster.setFromCamera(ndc, camera);
const hit = raycaster.intersectObject(ground, false)[0];
if (hit) setGirlTarget(hit.point.x, hit.point.z);
Проверяется только меш рельефа, поэтому рейкаст остаётся быстрым, а кликнуть «сквозь» траву до земли можно всегда.
Затем setGirlTarget применяет два правила без всякого навмеша. Если цель ближе 4,4 единицы к осевой линии ручья, она отодвигается обратно на тот берег, где девочка сейчас стоит, — поэтому клик через воду отправляет её к ближней кромке, а не вброд. А если цель дальше 26 единиц от центра, она подтягивается обратно на эту окружность, чтобы девочка оставалась в лесу. В итоговой точке ставится маркер-кольцо, которое расширяется и гаснет за 0,9 секунды.
Ходьба — несколько строк в цикле анимации. Она идёт прямо к цели со скоростью 2,6 единицы в секунду и поворачивается к ней кратчайшим путём:
const want = Math.atan2(dx, dz);
let turn = (want - g.heading + Math.PI * 3) % (Math.PI * 2) - Math.PI;
g.heading += turn * Math.min(1, dt * 7);
Приведение разницы углов к диапазону −π…π — вот что не даёт ей крутиться на полный оборот, когда цель чуть позади за левым плечом. Значение gait плавно меняется между 0 (стоит) и 1 (идёт), и на него масштабируется всё: размах ног и рук, небольшой подскок от abs(sin(phase)), лёгкий наклон вперёд и подпрыгивание косичек. Она плавно трогается и останавливается, а в покое её сменяет едва заметное дыхание. Её высота — просто terrainH в её точке, поэтому она повторяет каждый склон.
Рыбки, бабочки и падающие листья
Всё мелкое и подвижное — дешёвое параметрическое движение, без всякой симуляции:
- Рыбки. Десять рыбок, каждая — сплюснутая сфера с плавниками из shape-геометрии, плывут вдоль
cxOf(z)со смещением по полосе и переходят на другой конец ручья, доплыв до края. Каждая смотрит немного вперёд вдоль кривой, чтобы держаться по течению, а хвост качается по синусоиде. - Бабочки. Шесть пар крыльев с текстурой лепестков летают по петлям в духе фигур Лиссажу (разные скорости синусоид по x и z), смотрят в сторону полёта и быстро машут крыльями, никогда не опускаясь ниже 0,8 единицы над рельефом.
- Падающие листья. Шестьдесят карточек листьев в одном
InstancedMeshпадают с главного дерева по циклу на 11 единиц, сносимые в сторону и кувыркаясь; их матрицы переписываются каждый кадр. - Воздух. 500 аддитивных пылинок пыльцы, семь плывущих спрайтов облаков и четыре плоскости световых лучей, направленных вдоль
sunDirсквозь большое дерево.
Постобработка: свечение и цветокоррекция
Кадр проходит через цепочку EffectComposer: RenderPass, затем UnrealBloomPass с умеренной силой 0,32 и высоким порогом, чтобы светились только самые яркие участки неба и освещённые солнцем края листьев, затем OutputPass для тонмаппинга и перевода в sRGB и, наконец, собственный ShaderPass. Поскольку цветокоррекция идёт после output-прохода, она работает с готовыми к выводу цветами, и её числа легко понимать:
col = mix(col, col * col * (3.0 - 2.0 * col), 0.22); // gentle S-curve
col += vec3(0.035, 0.018, -0.012) * smoothstep(0.55, 1.0, luma); // warm highlights
col += vec3(-0.018, 0.004, 0.022) * (1.0 - smoothstep(0.0, 0.45, luma)); // cool shadows
Ещё она поднимает насыщенность на 10% и добавляет виньетку. Тёплые света поверх слегка бирюзовых теней превращают зелёную-на-зелёном сцену в поздний вечер, а не в рендер по умолчанию.
Как не перегрузить страницу
Для веб-страницы это тяжёлая сцена, поэтому ей нужно вести себя вежливо:
- Инстансинг везде, где что-то повторяется: трава, листья, падающие листья, галька, камни и дальняя кромка леса — каждый по одному
InstancedMesh. - Pixel ratio ограничен 1,75. Иначе телефоны с высоким DPI прогоняли бы втрое больше пикселей через bloom и transmission.
- Пауза вне экрана.
IntersectionObserverостанавливает циклrequestAnimationFrame, когда видно меньше 15% сцены, а обработчикvisibilitychange— когда вкладка скрыта. Есть и кнопка «Пауза». - Время кадра ограничено 50 мс, поэтому после паузы мир спокойно продолжает идти, а не прыгает вперёд.
- Дешёвый рейкаст только по мешу земли, никогда — по инстанцированной листве.
- Учитывается «уменьшение движения». Медленный облёт камеры в простое отключён для посетителей, включивших в системе уменьшение движения, и навсегда прекращается при первом левом клике, касании или перетаскивании левой кнопкой.
Выводы
- Сделайте одну функцию высоты источником истины. Если рельеф, объекты и персонаж берут высоту из неё, сцена держится вместе.
- Сначала постройте ручей, потом рельеф вокруг него. Кривая осевой линии сразу даёт русло, меш воды, пути рыбок и правило «не заходить в воду».
- Трава — это инстансинг плюс правка вершинного шейдера. Одна оформленная травинка, 25 000 матриц и несколько строк в
onBeforeCompileдают очень многое. - Для открытой сцены ходьбе по клику не нужен навмеш: рейкаст по земле, ограничение цели простыми правилами и плавные поворот и походка.
- Настроение создают свет и цветокоррекция. Низкое солнце, окружение через PMREM, туман, мягкое свечение и тёплая/холодная коррекция делают не меньше любой модели.
Сцена работает на странице «Прогулка по лесу»: покликайте по лугу, повращайте камеру и посмотрите, как ветер гуляет по траве. Если понравилось, прочитайте, как мы сделали штормовой океан на three.js и как мы анимируем человечка с двухкостной IK.