Бесконечный холст — одно из самых красивых впечатлений, которое вы можете предложить пользователям. Одним из преимуществ Интернета является то, что мы можем создавать опыт, который невозможно получить в реальном мире. Вы можете создавать 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_WxRECT_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, и мне очень любопытно посмотреть, что придумают другие.
Если вам понравился этот урок и вы хотите увидеть больше, ставьте лайки, делитесь и комментируйте, и, возможно, я сделаю несколько более сложных углубленных проектов. Идите вперед и стройте вещи.