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

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