Создание 3D-галереи, управляемой прокруткой, с использованием траектории камеры Blender с Three.js и GSAP | Кодропс

Создание 3D-галереи, управляемой прокруткой, с использованием траектории камеры Blender с Three.js и GSAP | Кодропс



В этом уроке мы создадим интерактивную 3D-галерею, которая будет следовать нарисованному от руки пути. Изображения распределяются по кривой, созданной в Blender, а камера перемещается по сцене в ответ на прокрутку. По мере приближения к камере изображения масштабируются, как будто попадая в фокус, а остальные остаются на заднем плане.

Результат напоминает непрерывную съемку с помощью тележки камеры. Каждый прокрутка продвигает камеру немного дальше по пути, создавая иллюзию путешествия по трехмерному пространству.

Мы будем использовать три инструмента:

  • Блендер нарисовать и экспортировать путь камеры
  • Три.js восстановить путь и визуализировать сцену
  • ГСАП для управления движением камеры и сглаживания переходов масштаба

Вдохновение для этого исходит от цифровое произведение искусства от творческой студии БАКССТУДИЯ.

1. Рисование и экспорт траектории камеры из Blender

Все начинается в Blender, где мы создаем путь, по которому будет следовать камера.

Добавьте кривую (Add → Curve → Bezier), переключитесь на Режим редактированияи придайте ему форму, как вам нравится. Кривая определяет ритм галереи: спираль, волна, резкий поворот или длинный прямой участок — все это создаст разные впечатления. Это главное творческое решение в проекте.

Как только вы будете довольны путем, мы экспортируем его в файл JSON, который можно будет восстановить в Three.js.

Экспорт кривой

Откройте блендер Сценарии рабочую область, создайте новый скрипт (Scripting → New) и вставьте следующий код Python:

import bpy
import json

obj = bpy.context.active_object
depsgraph = bpy.context.evaluated_depsgraph_get()
obj_eval = obj.evaluated_get(depsgraph)

mesh = obj_eval.to_mesh()

points = []
for v in mesh.vertices:
    co = obj.matrix_world @ v.co
    points.append([round(co.x, 3), round(co.z, 3), round(-co.y, 3)])

obj_eval.to_mesh_clear()

path = "/you/path/path1.json"
with open(path, "w") as f:
    json.dump(points, f)

print("export done")

Одна строка заслуживает более пристального внимания:

points.append([round(co.x, 3), round(co.z, 3), round(-co.y, 3)])

Blender и Three.js используют разные системы координат. В Блендере З ось направлена ​​вверх, тогда как в Three.js Да ось — вертикальная ось. Экспорт координат как есть приведет к повороту всей кривой, что приведет к неправильному перемещению камеры по сцене.

Это переназначение преобразует координаты Blender в систему координат Three.js:

  • XX
  • ZY
  • Y-Z (знак минус сохраняет правильную ориентацию)

После завершения экспорта выберите кривую и запустите скрипт. Он генерирует файл JSON, содержащий выборочные точки:

[[-27.559, 0.0, -0.0], [-27.56, 0.02, -0.022], [-27.56, 0.04, -0.044], ...]

Поместите экспортированный файл в public/paths/path1.json. В следующем разделе мы загрузим его в Three.js и восстановим кривую.

2. Настройка сцены и реконструкция кривой.

Экспортировав кривую, мы теперь можем реконструировать ее в Three.js и построить вокруг нее сцену.

Настройка сцены

Начнем с создания средства рендеринга, сцены и камеры:

const renderer = new THREE.WebGLRenderer({ antialias: true })
renderer.setPixelRatio(Math.min(devicePixelRatio, 2))
renderer.setSize(window.innerWidth, window.innerHeight)
document.getElementById('canvas-container').appendChild(renderer.domElement)

const scene = new THREE.Scene()
scene.background = new THREE.Color(0xffffff)
scene.fog = new THREE.Fog(0xffffff, 10, 40)

const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 200)

Туман не важен для эффекта, но он помогает усилить ощущение глубины, постепенно уводя отдаленные изображения на задний план.

Константы сцены

Далее давайте определим константы, управляющие сценой:

const SCALE = 16 // The curve is defined in Blender units. multiply it by 16 to obtain a consistent size within the scene.
const TEX_VARIANTS = 12 // Number of images in the /img folder
const textureLoader = new THREE.TextureLoader()
const textures = loadTextureVariants(TEX_VARIANTS, textureLoader)

const TOTAL = 500 // Total number of planes to create along the curve
const CAM_Z = 10 // Camera offset along the Z axis
const FOCUS_DIST = 5.5 // The distance from the camera at which planes start to scale up
const MAX_SCALE = 14 // The maximum scale factor for the planes
const Z_GATE = 11 // Filters out planes that are too far away in depth to avoid unnecessary calculations

const LATERAL_OFFSET_RANGE = [-1, 1]
const DEPTH_OFFSET_RANGE = [-0.75, 0.75]
const SIZE_RANGE = [0.18, 0.4]

Загрузка и реконструкция кривой

Далее мы загрузим экспортированный файл JSON и восстановим кривую, используя THREE.CatmullRomCurve3:

function toScaledVector3([x, y, z], scale) {
  return new THREE.Vector3(x * scale, y * scale, z * scale)
}

function buildCurve(raw) {
  const points = raw.map(p => toScaledVector3(p, SCALE))
  return new THREE.CatmullRomCurve3(points, true, 'catmullrom', 0.5)
}

THREE.CatmullRomCurve3 принимает массив THREE.Vector3 точки и генерирует плавную кривую, проходящую через каждую из них. Конструктор принимает несколько важных параметров:

  • true закрывает кривую, позволяя камере двигаться бесконечно, не достигая конца.
  • 'catmullrom' выбирает алгоритм интерполяции. Catmull-Rom создает плавную кривую, гарантируя, что она проходит через каждую точку.
  • 0.5 устанавливает натяжение кривой, контролируя, насколько сильно она изгибается между точками.

Мы загружаем экспортированный JSON асинхронно при запуске приложения:

async function init() {
  const raw = await fetch('/paths/path1.json').then(r => r.json())
  const curve = buildCurve(raw)

Чтение кривой

Чтобы разместить объекты вдоль кривой, нам нужны две части информации в любой заданной точке: ее положение и направление, в котором движется кривая. Мы можем получить и то, и другое с помощью небольшой служебной функции:

function getCurveFrame(curve, t) {
  const pos = curve.getPoint
  const tangent = curve.getTangent
  return { pos, nx: -tangent.y, ny: tangent.x }
}

t является нормализованным значением между 0 и 1 представляющий прогресс по кривой. curve.getPoint

Чтобы разбросать плоскости по обе стороны пути, нам также нужен вектор, перпендикулярный касательной. Мы вычисляем это нормальный повернув касательную на 90° в XY самолет:

Затем нормаль используется для применения боковых смещений относительно кривой, поэтому плоскости естественным образом следуют за каждым изгибом, а не смещаются вдоль фиксированной мировой оси.

nx и ny определить компоненты нормы.

3. Распределение плоскостей по кривой.

Теперь, когда кривая готова, мы можем заполнить сцену. Мы создадим 500 плоскостей изображения, равномерно распределим их по пути и назначим каждой случайную текстуру.

Загрузка текстур

Начнем с предварительной загрузки всех вариантов текстур:

const textureLoader = new THREE.TextureLoader()
const textures = loadTextureVariants(TEX_VARIANTS, textureLoader)

function loadTextureVariants(count, loader) {
  return Array.from({ length: count }, (_, i) => loader.load(`/img/picture${i + 1}.webp`))
}

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

Создание и размещение плоскостей

const planes = []

for (let i = 0; i 

Плоскости не располагаются случайно вдоль кривой. Их базовое положение определяется t = i / TOTALобеспечивая их равномерное распределение от начала до конца. Случайность применяется только к их боковому смещению, смещению по глубине и размеру, создавая более органичный макет без создания кластеров или пробелов.

Вместо смещения плоскостей вдоль фиксированной мировой оси мы используем локальную нормаль кривой (nx, ny). Это сохраняет распределение перпендикулярным траектории, независимо от того, как изгибается кривая.

Мы также храним несколько значений в каждой сетке. userData. Сюда входит его положение на кривой (t), его случайные смещения и многоразовый аниматор масштаба. Поскольку эти значения никогда не меняются, их можно просто считывать во время каждого кадра анимации, а не пересчитывать.

Создание масштабного аниматора

Каждый самолет имеет собственный масштабный аниматор, созданный с помощью gsap.quickTo():

function createScaleAnimator(mesh) {
  const proxy = { value: 1 }
  return gsap.quickTo(proxy, 'value', {
    duration: 0.4,
    ease: 'power3.out',
    onUpdate: () => mesh.scale.setScalar(proxy.value),
  })
}

gsap.quickTo() возвращает функцию многократного использования, которая анимирует свойство в соответствии с любым значением, которое вы ему передаете. Вместо создания новой анимации каждый раз при изменении масштаба она обновляет существующую. Это именно то, что нам здесь нужно: каждая плоскость получает новый целевой масштаб в каждом кадре анимации. Без quickTo()мы будем создавать 500 новых анимаций на каждый кадр, что быстро станет узким местом в производительности.

proxy Объект действует как промежуточное значение, которое GSAP может анимировать. При каждом обновлении мы просто копируем proxy.value к сетке с помощью mesh.scale.setScalar(). Это сохраняет логику анимации отдельно от объекта Three.js, сохраняя при этом плавный переход масштаба.

4. Движение камеры с помощью прокрутки

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

Принцип

Положение камеры определяется одним параметром: tкоторый варьируется от 0 к 1. Значение 0 соответствует началу кривой, а 1 возвращает нас в ту же точку, поскольку кривая замкнута.

Вместо обновления t непосредственно из ввода прокрутки мы сохраняем два значения:

  • targetT: обновляется немедленно при прокрутке пользователем.
  • camProxy.t: сглаженная версия targetTанимация GSAP.

Такое разделение позволяет камере мгновенно реагировать на действия пользователя, сохраняя при этом плавное перемещение по сцене.

const camProxy = { t: 0 }
const setCamT = gsap.quickTo(camProxy, 't', { duration: 1, ease: 'power3.out' })

let targetT = 0
const SENSITIVITY = 1 / (window.innerHeight * 4)

SENSITIVITY контролирует, насколько камера перемещается при заданном вводе прокрутки. Масштабируем его по window.innerHeight сохраняет ощущение единообразия взаимодействия на экранах разных размеров, поэтому прокрутка области просмотра на всю высоту перемещает камеру примерно на одинаковую величину независимо от устройства.

Захват прокрутки с помощью GSAP Observer

Мы будем использовать GSAP Observer плагин для захвата ввода пользователя:

Observer.create({
  target: window,
  type: 'wheel,touch,pointer',
  onChange: (self) => {
    targetT += self.deltaY * SENSITIVITY
    setCamT(targetT)
  },
})

Observer — это плагин GSAP, который объединяет ввод с помощью колесика, сенсорного ввода и указателя в рамках единого API. Вместо обработки разных событий для разных устройств мы всегда получаем одни и те же нормализованные события. deltaY значение, что делает логику прокрутки идентичной на настольных и сенсорных экранах.

При каждом входном событии мы обновляем targetT и позвони setCamT(). Вместо того, чтобы сразу перейти к новому значению, gsap.quickTo() плавно оживляет camProxy.t к нему, используя одну секунду power3.out простота. Поскольку положение камеры определяется camProxy.tкамера естественным образом скользит по кривой.

Обновление камеры в цикле анимации

Внутри animate() цикл, мы преобразуем ток camProxy.t значение в 3D-позицию:

function animate() {
  requestAnimationFrame(animate)

  const t = ((1 - camProxy.t) % 1 + 1) % 1

  const pathPos = curve.getPoint
  camera.position.set(pathPos.x, pathPos.y, pathPos.z + CAM_Z)

CAM_Z смещает камеру на 10 единиц по миру З ось. Вместо того, чтобы располагаться прямо на кривой, камера остается немного впереди нее, обеспечивая лучший угол обзора сцены и не позволяя ей проходить через плоскости.

Одна строка заслуживает более пристального внимания:

const t = ((1 - camProxy.t) % 1 + 1) % 1

Выражение выполняет три функции:

  • 1 - camProxy.t меняет направление движения, поэтому прокрутка вниз перемещает камеру вперед по кривой.
  • % 1 переносит значение так, чтобы оно оставалось в нормализованном диапазоне кривой 0 к 1.
  • ((...) + 1) % 1 правильно обрабатывает отрицательные значения. Поскольку оператор JavaScript по модулю может возвращать отрицательные результаты, добавление 1 перед подачей заявки % 1 еще раз гарантирует, что t всегда остается в [0, 1) интервал.

5. Масштабирование изображений для фокусировки

Последний шаг – это то, что придает галерее ощущение глубины. По мере перемещения камеры по сцене ближайшие изображения плавно масштабируются, как будто попадая в фокус, а удаленные остаются в исходном размере.

Принцип

В каждом кадре анимации мы перебираем все 500...



Источник

Оставьте комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Прокрутить вверх