Сегодня разбираем сложный, но крутой веб-проект Ксении Кондрашовой — трёхмерную анимированную ёлку с украшениями. Это действительно хардкор: вся магия происходит в скрипте и построена на владении 3D-движками.
Эту статью мы пишем в апреле 2025 года, когда внезапно во многих городах снова появился снег (а мы только начали привыкать к весне и к тому, что всё наконец растаяло). Если вы читаете это в июле и у вас тоже вдруг снег — мы не виноваты.
Мы не будем разбирать каждую строку кода, а вместо этого сосредоточимся на технологиях, полезности и возможностях, которые они открывают. Зацените сами, какая красота:

Что делаем сегодня
Будем разбирать код проекта, но не построчно, как обычно, а с точки зрения технологий и возможностей. Почему так: чтобы понять весь код на JS, нужно очень плотно погрузиться в шейдеры, логику и возможности трёхмерной отрисовки, объяснить про камеры, движение объектов — проще говоря, полностью овладеть знаниями о трёхмерной графике в браузере. Чтобы не превращать статью в учебник по 3D, мы сосредоточимся на логике и том, как эти технологии можно применить где-то ещё.
В чём идея проекта
Сам проект с ёлкой с точки зрения фронта очень простой: у нас есть HTML-файл с базовой разметкой блоков (для ёлки и надписи) и CSS-файл с простыми параметрами для всей страницы.
Вся жизнь — внутри JS-кода, который отвечает вообще за всё:
- подготавливает трёхмерную сцену;
- настраивает виртуальные камеры (откуда мы смотрим на ёлку);
- создаёт освещение;
- регулирует тени;
- задаёт анимацию движений;
- обрабатывает нажатия на надпись и добавляет шарики…
…и всё это крутится, светится и работает вместе как одно целое.
Получается, что это уже не простая вёрстка страницы, где фронтендер размещает элементы и управляет всем в HTML-тегах, а полноценное программирование со сложными нюансами. Поэтому если кто-то вам скажет, что фронтенд — это не про разработку, покажите ему эту статью.
HTML и CSS
Всё, что нам нужно от HTML, — разместить на странице два блока (с ёлкой и надписью) и подключить скрипты. Но скриптов будет несколько: один наш, в котором вся логика проекта, а другие — это как раз движки и модули, которые обеспечивают совместную работу разных элементов между собой.
<!DOCTYPE html>
<html lang="ru" >
<head>
<meta charset="UTF-8">
<title>Наряжаем ёлку</title>
<link rel="stylesheet" href="./style.css">
</head>
<body>
<div class="container">
<canvas id="tree-canvas"></canvas>
</div>
<div class="title">
Добавить шарик
</div>
<script async src="https://unpkg.com/es-module-shims@1.6.3/dist/es-module-shims.js"></script>
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.164.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.164.0/examples/jsm/"
}
}
</script>
<script type="module" src="./script.js"></script>
</body>
</html>
Обратите внимание на команду <script type=”importmap”>
: с её помощью мы импортируем в проект нужные нам модули со скриптами так, чтобы потом к ним можно было обращаться по своим именам, использовать команды оттуда и управлять порядком загрузки. Проще говоря, это мини-сборщик модулей, который берёт на себя часть работы по подключению и правильной загрузке скриптов.
Теперь посмотрим на CSS. В нём — только базовые настройки страницы, фоновый цвет, отступы и положение блока с ёлкой и параметры для блока с текстом. Здесь ничего сложного или необычного, просто указываем, где мы хотим поставить ёлку с надписью и как эти два блока располагаются друг относительно друга:
html,
body {
padding: 0;
margin: 0;
}
.container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: #040229;
}
.title {
position: fixed;
top: 75%;
left: 50%;
transform: translate(-50%, -50%);
font-family: monospace;
user-select: none;
pointer-events: none;
color: white;
}
Что интересного в скрипте
Мы не будем разбирать весь скрипт, а вместо этого пройдём по основным блокам. Вот сам скрипт (под катом):
import * as THREE from 'three';
const containerEl = document.querySelector(".container");
const canvasEl = document.querySelector("#tree-canvas");
const params = {
stripesNumber: 15,
stripeWidth: .03,
}
const pointer = new THREE.Vector2();
let renderer, scene, camera, orbit, lightHolder, touchPlane, raycaster;
let ballGeometry, stripeGeometry;
const stripes = [];
const balls = [];
initScene();
render();
window.addEventListener("resize", updateSceneSize);
window.addEventListener("click", e => {
pointer.x = (e.clientX / window.innerWidth) * 2 - 1;
pointer.y = -(e.clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(pointer, camera);
const intersects = raycaster.intersectObject(touchPlane);
if (intersects) {
addBall(intersects[0].point, balls.length - 1, performance.now());
}
});
function initScene() {
renderer = new THREE.WebGLRenderer({
antialias: true,
canvas: canvasEl,
alpha: true
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
scene = new THREE.Scene();
camera = new THREE.PerspectiveCamera(45, containerEl.clientWidth / containerEl.clientHeight, .1, 10);
camera.position.set(0, 1, 4);
camera.lookAt(0, 0, 0);
raycaster = new THREE.Raycaster();
updateSceneSize();
const ambientLight = new THREE.AmbientLight(0xffffff, 2.5);
scene.add(ambientLight);
const sideLight = new THREE.DirectionalLight(0xffffff, 1);
sideLight.position.set(1, 2, 4);
sideLight.castShadow = true;
sideLight.shadow.mapSize.width = 2048;
sideLight.shadow.mapSize.height = 2048;
sideLight.shadow.camera.near = 3;
sideLight.shadow.camera.far = 8;
sideLight.shadow.camera.left = -1;
sideLight.shadow.camera.right = 1;
sideLight.shadow.camera.top = 1;
sideLight.shadow.camera.bottom = -1;
sideLight.shadow.bias = -.0001;
sideLight.shadow.radius = 6;
sideLight.shadow.normalBias = 0.02;
lightHolder = new THREE.Group();
lightHolder.add(sideLight);
scene.add(lightHolder);
const leftLight = new THREE.DirectionalLight(0xffffff, .5);
leftLight.position.set(-2, 0, 0);
scene.add(leftLight);
stripeGeometry = new THREE.CylinderGeometry(1, 1, params.stripeWidth, 128, 32, true);
ballGeometry = new THREE.IcosahedronGeometry(.06, 16);
for (let i = 0; i < params.stripesNumber; i++) {
const material = new THREE.MeshStandardMaterial({
color: new THREE.Color().setHSL(.3, 1, .5),
roughness: .3,
metalness: .8,
side: THREE.DoubleSide
})
material.userData.time = {value: 0};
material.userData.stripeIdx = {value: i};
material.userData.stripeHeight = {value: 1 / params.stripesNumber};
material.userData.Height = {value: 1 / params.stripesNumber};
const obc = (shader) => {
shader.uniforms.u_time = material.userData.time;
shader.uniforms.u_stripe_idx = material.userData.stripeIdx;
shader.uniforms.u_stripe_height = material.userData.stripeHeight;
shader.vertexShader = `
#define TWO_PI 6.28318530718
uniform float u_time;
uniform float u_stripe_idx;
uniform float u_stripe_height;
float random(vec3 p) {
return fract(sin(dot(p, vec3(12.9898, 78.233, 54.53))) * 43758.5453);
}
float noise(vec3 p) {
vec3 i = floor(p);
vec3 f = fract(p);
float a = random(i);
float b = random(i + vec3(1.0, 0.0, 0.0));
float c = random(i + vec3(0.0, 1.0, 0.0));
float d = random(i + vec3(1.0, 1.0, 0.0));
float e = random(i + vec3(0.0, 0.0, 1.0));
float f_ = random(i + vec3(1.0, 0.0, 1.0));
float g = random(i + vec3(0.0, 1.0, 1.0));
float h = random(i + vec3(1.0, 1.0, 1.0));
vec3 u = f * f * (3.0 - 2.0 * f);
float x0 = mix(a, b, u.x);
float x1 = mix(c, d, u.x);
float x2 = mix(e, f_, u.x);
float x3 = mix(g, h, u.x);
float y0 = mix(x0, x1, u.y);
float y1 = mix(x2, x3, u.y);
return mix(y0, y1, u.z);
}
vec3 displacement(vec3 p) {
float stripe_y_offset = u_stripe_height * u_stripe_idx;
p.y += stripe_y_offset;
vec2 xz = p.xz;
xz *= (1. - stripe_y_offset);
xz *= smoothstep(.15, .3, stripe_y_offset);
xz *= (1. + .3 * sin(40. * stripe_y_offset + 2. * u_time));
p = vec3(xz[0], p.y, xz[1]);
float n = .3 * noise(1.2 * p + vec3(2., .1, 2.) * u_time);
p -= p * n;
p.y -= .5;
p.y *= 1.5;
return p;
}
vec3 orthogonal(vec3 v) {
return normalize(abs(v.x) > abs(v.z) ? vec3(-v.y, v.x, 0.0) : vec3(0.0, -v.z, v.y));
}
` + shader.vertexShader;
shader.vertexShader = shader.vertexShader.replace(
'void main() {',
`
void main() {
vec3 displacedPosition = displacement(position);
vec3 displacedNormal = normalize(normal);
float offset = .5 / 128.;
vec3 tangent = orthogonal(normal);
vec3 bitangent = normalize(cross(normal, tangent));
vec3 neighbour1 = position + tangent * offset;
vec3 neighbour2 = position + bitangent * offset;
vec3 displacedNeighbour1 = displacement(neighbour1);
vec3 displacedNeighbour2 = displacement(neighbour2);
vec3 displacedTangent = displacedNeighbour1 - displacedPosition;
vec3 displacedBitangent = displacedNeighbour2 - displacedPosition;
displacedNormal = normalize(cross(displacedTangent, displacedBitangent));
`
);
shader.vertexShader = shader.vertexShader.replace(
'#include <displacementmap_vertex>',
`transformed = displacedPosition;`
);
shader.vertexShader = shader.vertexShader.replace(
'#include <defaultnormal_vertex>',
THREE.ShaderChunk.defaultnormal_vertex.replace(
'vec3 transformedNormal = objectNormal;',
`vec3 transformedNormal = displacedNormal;`
)
);
shader.fragmentShader = shader.fragmentShader.replace(
`<colorspace_fragment>`,
`<colorspace_fragment>
if (!gl_FrontFacing) {
gl_FragColor.rgb *= .6; gl_FragColor.b += .2;
}
`
);
}
material.onBeforeCompile = obc;
const stripe = new THREE.Mesh(stripeGeometry, material);
stripe.receiveShadow = true;
stripe.customDepthMaterial = new THREE.MeshDepthMaterial({
depthPacking: THREE.RGBADepthPacking
});
stripe.customDepthMaterial.onBeforeCompile = obc;
stripes.push(stripe);
scene.add(stripe);
}
const touchPlaneGeometry = new THREE.PlaneGeometry(100, 100);
const touchPlaneMaterial = new THREE.MeshBasicMaterial({
color: 0x880000,
visible: false
});
touchPlane = new THREE.Mesh(touchPlaneGeometry, touchPlaneMaterial);
touchPlane.rotateZ(-.5 * Math.PI);
scene.add(touchPlane);
}
function addBall(pos, bIdx, clickTime) {
const material = new THREE.MeshStandardMaterial({
color: new THREE.Color().setHSL(.4 * (bIdx % 4 - 1) - .6, .9, .5),
roughness: .2,
metalness: .5,
})
const ballWrapper = new THREE.Group();
const ball = new THREE.Mesh(ballGeometry, material);
ball.castShadow = true;
ball.receiveShadow = true;
const scale = .6 + Math.max(.1, (.5 - .02 * bIdx)) * Math.random();
ball.scale.set(scale, scale, scale);
ball.position.copy(pos);
balls.push({ball, ballWrapper, pos, clickTime});
ballWrapper.add(ball);
scene.add(ballWrapper);
}
function render(time) {
const t = .001 * time;
lightHolder.quaternion.copy(camera.quaternion);
stripes.forEach(s => {
s.material.userData.time.value = t;
})
balls.forEach((b, bIdx) => {
const aT = .001 * (time - b.clickTime);
const posY = .1 * (5 - Math.abs(5 - (bIdx % (2 * 5)))) - .3;
const posZ = .5 * (posY - .5) - .3;
const posTarget = new THREE.Vector3(0, posY, posZ);
b.ball.position.copy(b.pos.clone().lerp(posTarget, Math.min(aT, 1)));
const rotTarget = new THREE.Vector2(
.4 * Math.sin(2. * aT),
(b.pos.x > 0 ? 1 : -1) * 2 * aT
);
b.ballWrapper.rotation.set(rotTarget.x, rotTarget.y, 0);
});
renderer.render(scene, camera);
requestAnimationFrame(render);
}
function updateSceneSize() {
camera.aspect = containerEl.clientWidth / containerEl.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(containerEl.clientWidth, containerEl.clientHeight);
}
Первое, что тут происходит, — импорт всех модулей и функций из другого модуля, который мы подключили в HTML. Именно на них будет строиться почти вся работа скрипта.
import * as THREE from ‘three’;
Дальше задаются параметры, переменные и константы — они отвечают за объекты и способы их взаимодействия друг с другом.
После этого идут два обработчика событий — изменения размера окна (resize
) и клика на странице (click
). Первый принудительно отрисовывает всю сцену заново, чтобы ёлка отображалась пропорционально размерам экрана, а второй добавляет новый шарик на ёлку.
Следом — основная и самая большая функция initScene()
, в которой, собственно, и происходит основная работа:
- устанавливаются параметры рендера;
- создаётся новая сцена для отображения (то, что мы увидим в итоге);
- настраивается положение камеры (откуда мы смотрим на ёлку);
- добавляется освещение и тени;
- задаётся геометрия и внешний вид элементов;
- настраиваются текстуры, отражения и физика.
А дальше идёт большая магия работы с шейдерами — элементами, из которых и состоит трёхмерная графика. Когда встречаете в коде vertexShader
— это как раз оно. Там прописывается движение ёлки, шаров и всего, что происходит из трёхмерностей на экране. Чтобы в этом разбираться, нужно знать, как работают подобные движки, их особенности и правила, по которым всё это взаимодействует между собой.
Наконец, в скрипте пишется функция добавления шара addBall()
— в ней генерируется новый шарик и отправляется в сцену. Завершает код функция render(time)
— она отвечает за анимацию и отрисовку всех деталей.
Зачем мне всё это?
Многие думают, что фронтенд — это просто вёрстка и работа с тегами. Но кроме этого фронтенд — это полноценное программирование со сложными механиками и глубокими знаниями из разных областей. Конечно, не каждому фронтендеру такое нужно, но если вы это умеете, то сможете заниматься более крутыми проектами, чем обычная сборка страниц.
А где это применяется, кроме развлечений?
Где угодно, где нужен интерактив и красота:
- интернет-магазины;
- сервисы для интерьеров, когда можно добавить шкаф или кровать в виртуальную комнату, а потом покрутить с разных сторон;
- браузерные игры (которые можно легко превратить в обычные десктопные и мобильные);
- в спецпроектах и маркетинговых активностях, чтобы удержать читателя на странице и вдохновить его на что-то новое.
Короче: такие технологии — необязательно про развлечения, у них есть масса полезных применений.
👉 Нарядить ёлку на странице проекта.
Вам слово
Приходите к нам в соцсети поделиться своим мнением о статье и почитать, что пишут другие. А ещё там выходит дополнительный контент, которого нет на сайте: шпаргалки, опросы и разная дурка. В общем, вот тележка, вот ВК — велком!