Бесконечный холст — одно из самых красивых впечатлений, которое вы можете предложить пользователям. Одним из преимуществ Интернета является то, что мы можем создавать опыт, который невозможно получить в реальном мире. Вы можете создавать 3D-модели в Blender любой длины и ширины без каких-либо пространственных ограничений реального мира.
Однако бесконечные холсты — не новое изобретение. У 3D-инструментов они уже давно есть, как и у игровых движков и других инструментов для создателей. Наша задача, если мы решим ее принять, состоит в том, чтобы объединить идеи, которые нам нужны, из этих уже существующих инструментов и создать нашу собственную упрощенную версию этого.
Фонды
Для этого нам нужно сначала понять, что такое 3D-проекция. Идея бесконечного холста заключается в том, что есть мир (в котором размещены все объекты) и есть камера (которая является просто проекцией мира). Что видит пользователь, то и проецирует камера. И пользователь может перемещаться по камере так, как считает нужным. Таким образом, мир может существовать в, казалось бы, бесконечном пространстве, и вы можете идти по отрицательной и положительной осям x-y до бесконечности. Если вам нужно, вы можете ограничить камеру показом только одной области, но вы также можете разрешить неограниченное исследование на своем холсте, если хотите.
То, как это делается для 3D-проекции, завораживает, но для нас может оказаться излишним реализовывать проекционные матрицы. Поскольку мы моделируем 2D-мир на нашем холсте, похожем на Figma, наша математика выглядит намного проще, если учесть всего несколько геометрических концепций, которые мы должны иметь в виду.
В нашем случае наша камера представляет собой точку на оси z и всегда направлена на нашу плоскость x-y. Мы можем контролировать допустимый зум, ограничивая камеру только некоторыми значениями z. Наш холст может растягиваться до бесконечности по осям x и y.
Во-первых, нам нужно найти нашу начальную проекцию. Это означает, что нам нужно сначала выяснить, где разместить нашу камеру. Мы хотим спроецировать только то количество холста, которое можно нарисовать в браузере. Нет смысла рисовать больше холста, когда он не будет виден пользователю. Итак, мы будем работать с соотношением сторон экрана нашего браузера.
Чтобы придумать наши уравнения, давайте попробуем нарисовать область холста. Мы начнем с проецирования холста от (0,0) до (ширина экрана, высота экрана) в плоскости x-y. Давайте попробуем выяснить, где разместить нашу камеру, чтобы мы могли видеть эту область. Координаты x и y камеры просты. Разместим их посередине проекции, смотря прямо вниз. Таким образом, координаты x и y равны (ширина экрана/2, высота экрана/2).
Чтобы разместить координату z нашей камеры, нам нужно установить константу, которая останется неизменной независимо от масштаба или положения камеры. Эта константа — угол камеры. Мы будем держать его на уровне 30 градусов, но мы также можем поэкспериментировать с другими значениями.
Как только мы узнаем наш угол, мы можем найти высоту камеры. Но прежде чем мы это сделаем, давайте вернемся к некоторым простым геометрическим понятиям.
tan θ is also called as law of tangent. The tangent formula for a right-angled triangle can be defined as the ratio of the opposite side of a triangle to the adjacent side. Tan(θ) = (ScreenWidth / 2) / z z = ScreenWidth / (2 * Tan(θ))
Получив эту формулу, мы можем разместить камеру. И каждый раз, когда мы перемещаем камеру, мы можем определить ширину видимого прямоугольника проекции, используя приведенную выше формулу. Как только вы определите ширину, мы можем получить соотношение сторон экрана нашего браузера к высоте. Причина, по которой мы хотим сохранить то же соотношение сторон, заключается в том, что мы не хотим рисовать больше, чем может видеть пользователь.
Выполнение
Теперь, когда мы изучили основные концепции 2D-проекций для Infinite Canvas, мы можем перейти к этапу реализации. Прежде чем мы перейдем к управлению состоянием и логике, давайте установим ожидания от того, что мы строим. Мы хотим проверить несколько вещей-
- Мы должны увеличивать и уменьшать масштаб холста и видеть больше холста.
- Мы должны иметь возможность перемещать камеру влево и вправо и видеть разные части холста.
Чтобы проверить эти вещи, это самое простое доказательство концепции, которое мы можем сделать, которое проверит все наши условия:
Идея в том, что мы рисуем кучу блоков разного цвета и разный текст в середине каждого блока. Сначала мы устанавливаем камеру ближе к середине холста, а затем, когда мы масштабируем и прокручиваем, мы можем видеть наш бесконечный холст в действии.
Я буду использовать Pixi.js в качестве нашей библиотеки WebGL для рисования, но точная структура для рисования не имеет значения. Управление состоянием и логика могут быть поняты и реализованы в любой библиотеке и на любом языке. После того, как основа заложена, его можно использовать везде.
import { Application, Container, Graphics, Text } from "pixi.js"; // Listener that will host our zoom and strafe camera actions const wheelListener = (e: Event) => { e.preventDefault(); e.stopPropagation(); }; // Listener to keep track of our pointer. Used for accurate zoom const pointerListener = (event: PointerEvent) => { CanvasStore.movePointer(event.offsetX, event.offsetY); }; class App { // Draws blocks with texts in the middle of them private drawCanvas() { const container = new Container(); const colors = [ 0xf1f7ed, 0x61c9a8, 0x7ca982, 0xe0eec6, 0xc2a83e, 0xff99c8, 0xfcf6bd, 0x9c92a3, 0xc6b9cd, ]; const texts = [ "Infinite", "Canvases", "Are", "Easy", "When", "You", "Know", "The", "Fundamentals", ]; const rectW = 500; const rectH = 500; for (let i = 0; i < 9; i++) { const block = new Container(); const randomColor = colors[i]; const bg = new Graphics(); const leftOffset = (i % 3) * rectW; const topOffset = Math.floor(i / 3) * rectH; bg.beginFill(randomColor); bg.drawRect(leftOffset, topOffset, rectW, rectH); bg.endFill(); block.addChild(bg); const textElement = new Text(texts[i], { fontSize: 10, fill: 0x000000, fontWeight: "700", wordWrap: false, }); textElement.anchor.set(0.5); textElement.position.set(leftOffset + block.width / 2, topOffset + block.height / 2); block.addChild(textElement); container.addChild(block); } return container; } attach(root: HTMLElement) { const app = new Application({ width: document.body.clientWidth, height: document.body.clientHeight, backgroundColor: 0xffffff, resolution: 2, antialias: true, autoDensity: true, }); root.appendChild(app.view); const canvas = this.drawCanvas(); app.stage.addChild(canvas); root.addEventListener("mousewheel", wheelListener, { passive: false }); root.addEventListener("pointermove", pointerListener, { passive: true, }); } detach(root: HTMLElement) { root.removeEventListener("mousewheel", wheelListener); root.removeEventListener("pointermove", pointerListener); } } // Loading and Unloading logic for our app let _app: App | null; window.onload = () => { _app = new App(); _app.attach(document.body); }; window.onbeforeunload = () => { if (_app) _app.detach(document.body); };
Государственное управление
Мы сейчас нарисовали наш холст, но не в состоянии оценить его во всей красе. Мы не можем ни прокручивать, ни увеличивать масштаб. Это потому, что мы не настроили нашу камеру, так что давайте начнем с этого. На данный момент мы сохраним синглтон под названием CanvasStore, который содержит нашу логику и состояние камеры. Мы можем переместить все это в более совершенные структуры в зависимости от масштаба и потребностей нашего приложения, но это не требуется для данного доказательства концепции.
interface CanvasState { pixelRatio: number; // our resolution for dip calculations container: { //holds information related to our screen container width: number; height: number; }; camera: { //holds camera state x: number; y: number; z: number; }; } export const getInitialCanvasState = (): CanvasState => { return { pixelRatio: window.devicePixelRatio || 1, container: { width: 0, height: 0, }, camera: { x: 0, y: 0, z: 0, }, }; }; const radians = (angle: number) => { return angle * (Math.PI / 180); }; export const CAMERA_ANGLE = radians(30); export const RECT_W = 500; export const RECT_H = 500; export default class CanvasStore { private static get data() { if (!canvasData) canvasData = { pixelRatio: window.devicePixelRatio || 1, pixelsPerFrame: 1, container: { width: 0, height: 0, }, pointer: { x: 0, y: 0, }, canvas: { width: 0, height: 0, }, camera: { x: 0, y: 0, z: 0, }, }; return canvasData; } static initialize(width: number, height: number) { const containerWidth = width; const containerHeight = height; canvasData = getInitialCanvasState(); canvasData.pixelRatio = window.devicePixelRatio || 1; canvasData.container.width = containerWidth; canvasData.container.height = containerHeight; canvasData.camera.x = 1.5 * RECT_W; canvasData.camera.y = 1.5 * RECT_H; canvasData.camera.z = containerWidth / (2 * Math.tan(CAMERA_ANGLE)); } public static get screen() { const { x, y, z } = this.camera; const aspect = this.aspect; const angle = radians(30); return cameraToScreenCoordinates(x, y, z, angle, aspect); } public static get camera() { return this.data.camera; } public static get scale() { const { width: w, height: h } = CanvasStore.screen; const { width: cw, height: ch } = CanvasStore.container; return { x: cw / w, y: ch / h }; }
Одной из важных концепций в блоке кода выше является метод initialize
, который устанавливает следующие значения:
- Сохраните наш
pixelRatio
для независимых от плотности расчетов пикселей, если это необходимо. - Сохраняет высоту и ширину нашего элемента холста в браузере (в настоящее время все тело нашего документа, но может быть любого размера)
- Вычисляет и устанавливает исходное положение камеры. Он находится в середине наших 9 кварталов. Каждый блок имеет размерность
RECT_W
xRECT_H
. А формулу высоты камеры мы выложили в начале этого поста
Другой важной концепцией является функция cameraToScreenCoordinates
, которую мы еще не рассмотрели. Эта функция работает следующим образом: она принимает координаты камеры, угол камеры, соотношение сторон контейнера и возвращает нам ту часть бесконечного холста, которая видна пользователю. Таким образом, мы можем просто отобразить прямоугольник, заданный возвращаемым значением этой функции, и пользователь увидит то, что видит камера.
export const cameraToScreenCoordinates = ( x: number, y: number, z: number, cameraAngle: number, screenAspect: number ) => { const width = 2 * z * Math.tan(CAMERA_ANGLE); const height = width / screenAspect; const screenX = x - width / 2; const screenY = y - height / 2; return { x: screenX, y: screenY, width, height }; };
Интеграция проекции камеры
Теперь, когда мы настроили нашу логику и состояние, нам нужно связать их с нашим кодом дисплея, чтобы мы видели и рисовали только ту проекцию камеры, которая должна быть показана пользователю. Для этого нам нужно переписать нашу attach
функцию, которую мы использовали выше. Теперь это выглядит так:
attach(root: HTMLElement) { CanvasStore.initialize( document.body.clientWidth, document.body.clientHeight ); const app = new Application({ width: document.body.clientWidth, height: document.body.clientHeight, backgroundColor: 0xffffff, resolution: 2, antialias: true, autoDensity: true, }); root.appendChild(app.view); const canvas = this.drawCanvas(); app.stage.addChild(canvas); app.ticker.add(() => { const { x, y } = CanvasStore.screen; const scale = CanvasStore.scale; canvas.position.set(-scale.x * x, -scale.y * y); canvas.scale.set(scale.x, scale.y); }); root.addEventListener("mousewheel", wheelListener, { passive: false }); root.addEventListener("pointermove", pointerListener, { passive: true, }); }
Мы инициализировали наше хранилище в начале приложения, и в каждом кадре мы перемещаем наше приложение, чтобы переместить область, указанную CanvasStore.screen
, в (0,0) и ( ширина экрана, высота экрана).
Так что теперь, исходя из масштаба и положения, пользователю видна только проекция камеры. Мы также можем добавить к этому маску, чтобы рисовать только часть в зависимости от размера экрана, но это остается для пользователя в качестве упражнения.
Реализация прокрутки и масштабирования с опорной точкой
Теперь, когда мы настроили наше отображение и наше состояние, мы можем погрузиться в самую важную и интересную часть этого урока — заставить вещи двигаться. Вся польза Infinite Canvas в том, что вы можете бездельничать и перемещаться повсюду. Давайте посмотрим на функцию прокрутки, которую мы поместим в наш CanvasStore
:
public static moveCamera(mx: number, my: number) { const scrollFactor = 1.5; const deltaX = mx * scrollFactor; const deltaY = my * scrollFactor; const { x, y, z } = this.camera; this.data.camera.x += deltaX; this.data.camera.y += deltaY; // move pointer by the same amount this.movePointer(deltaY, deltaY); } public static movePointer(deltaX: number, deltaY: number) { const scale = this.scale; const { x: left, y: top } = this.screen; this.data.pointer.x = left + deltaX / scale.x; this.data.pointer.y = top + deltaY / scale.y; }
Логика в этом довольно проста, мы перемещаем камеру и указатель в зависимости от прокрутки. У нас также есть scrollFactor, так что мы можем ускорить или замедлить поведение прокрутки. Почему мы сохраняем положение указателя? Что ж, мы это поймем, когда дойдем до реализации следующей части — прокрутки с опорной точкой. Лучший способ описать это поведение — показать его
На увеличенном изображении выше вы можете видеть, что мы определяем положение указателя пользователя и обязательно увеличиваем масштаб в том месте, на котором находится его указатель. Как мы это делаем, мы меняем масштаб, но также немного прокручиваем холст, чтобы указатель по-прежнему указывал на тот же пиксель, что и раньше. Теперь, когда мы поняли, как работает это поведение, мы можем погрузиться в логику:
const scaleWithAnchorPoint = ( anchorPointX: number, anchorPointY: number, cameraX1: number, cameraY1: number, scaleX1: number, scaleY1: number, scaleX2: number, scaleY2: number ) => { const cameraX2 = (anchorPointX * (scaleX2 - scaleX1) + scaleX1 * cameraX1) / scaleX2; const cameraY2 = (anchorPointY * (scaleY2 - scaleY1) + scaleY1 * cameraY1) / scaleY2; return { x: cameraX2, y: cameraY2 }; }; public static zoomCamera(deltaX: number, deltaY: number) { // Normal zoom is quite slow, we want to scale the amount quite a bit const zoomScaleFactor = 10; const deltaAmount = zoomScaleFactor * Math.max(deltaY); const { x: oldX, y: oldY, z: oldZ } = this.camera; const oldScale = { ...this.scale }; const { width: containerWidth, height: containerHeight } = this.container; const { width, height } = cameraToScreenCoordinates( oldX, oldY, oldZ + deltaAmount, this.cameraAngle, this.aspect ); const newScaleX = containerWidth / width; const newScaleY = containerHeight / height; const { x: newX, y: newY } = scaleWithAnchorPoint( this.pointer.x, this.pointer.y, oldX, oldY, oldScale.x, oldScale.y, newScaleX, newScaleY ); const newZ = oldZ + deltaAmount; this.data.camera = { x: newX, y: newY, z: newZ, }; }
Теперь мы можем интегрировать это в наш обработчик колеса прокрутки, который мы определили выше:
const wheelListener = (e: Event) => { e.preventDefault(); e.stopPropagation(); const friction = 1; const event = e as WheelEvent; const deltaX = event.deltaX * friction; const deltaY = event.deltaY * friction; if (!event.ctrlKey) { CanvasStore.moveCamera(deltaX, deltaY); } else { CanvasStore.zoomCamera(deltaX, deltaY); } };
Сделав это, мы наконец можем посмотреть на результат нашего бесконечного холста. Мы начинаем с камеры в середине 5-го блока, мы прокручиваем, уменьшаем и увеличиваем несколько раз текст, чтобы показать, как это работает вместе.
Заключение
Это был пример того, как сделать простую проекцию 2D-камеры для имитации бесконечного холста. Единственное, что осталось, — это разместить вещи на бесконечном холсте, и эта система камер теоретически должна быть в состоянии удерживать и показывать любое количество контента в зависимости от варианта использования. Меня всегда восхищали инструменты для творчества с интерфейсами Canvas, и мне очень любопытно посмотреть, что придумают другие.
Если вам понравился этот урок и вы хотите увидеть больше, ставьте лайки, делитесь и комментируйте, и, возможно, я сделаю несколько более сложных углубленных проектов. Идите вперед и стройте вещи.