Проект: разбор трёхмерной анимированной ёлки в браузере

Наряжаем ёлку правильно

Проект: разбор трёхмерной анимированной ёлки в браузере

Сегодня разбираем сложный, но крутой веб-проект Ксении Кондрашовой — трёхмерную анимированную ёлку с украшениями. Это действительно хардкор: вся магия происходит в скрипте и построена на владении 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) — она отвечает за анимацию и отрисовку всех деталей.

Зачем мне всё это?

Многие думают, что фронтенд — это просто вёрстка и работа с тегами. Но кроме этого фронтенд — это полноценное программирование со сложными механиками и глубокими знаниями из разных областей. Конечно, не каждому фронтендеру такое нужно, но если вы это умеете, то сможете заниматься более крутыми проектами, чем обычная сборка страниц.

А где это применяется, кроме развлечений?

Где угодно, где нужен интерактив и красота:

  • интернет-магазины;
  • сервисы для интерьеров, когда можно добавить шкаф или кровать в виртуальную комнату, а потом покрутить с разных сторон;
  • браузерные игры (которые можно легко превратить в обычные десктопные и мобильные);
  • в спецпроектах и маркетинговых активностях, чтобы удержать читателя на странице и вдохновить его на что-то новое.

Короче: такие технологии — необязательно про развлечения, у них есть масса полезных применений.

👉 Нарядить ёлку на странице проекта.

Вам слово

Приходите к нам в соцсети поделиться своим мнением о статье и почитать, что пишут другие. А ещё там выходит дополнительный контент, которого нет на сайте: шпаргалки, опросы и разная дурка. В общем, вот тележка, вот ВК — велком!

Обложка: Алексей Сухов
Корректор: Елена Грицун
Вёрстка: Кирилл Климентьев
Соцсети: Юлия Зубарева

Получите ИТ-профессию
В «Яндекс Практикуме» можно стать разработчиком, тестировщиком, аналитиком и менеджером цифровых продуктов. Первая часть обучения всегда бесплатная, чтобы попробовать и найти то, что вам по душе. Дальше — программы трудоустройства.
Получите ИТ-профессию Получите ИТ-профессию Получите ИТ-профессию Получите ИТ-профессию
А вы читали это?
hard