2.5D Renderer

Stage 5: Текстурирование

Горизонтальное: пол и потолок

2.5D Renderer

2D Renderer

Управление камерой WASD и ZX

Со стенами все было относительно просто: мы уже знали экранную вертикальную полосу стены и могли растянуть по ней текстуру сверху вниз. Пол и потолок устроены иначе. Это горизонтальные плоскости, которые уходят в глубину, поэтому для них недостаточно взять одну вертикальную координату на экране и линейно растянуть bitmap.

Для пола и потолка сектору нужно хранить отдельные ID текстур. Высоты floorHeight и ceilHeight уже использовались при проекции стен, а теперь они еще говорят, на какой горизонтальной плоскости bitmap нужно искать точку пересечения луча.


  const roomSector: Sector = {
    // ..
    floorHeight: 0,
    floorTexture: 'floor',
    ceilHeight: 400,
    ceilTexture: 'ceil',
    // ..
  };

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


  if (drawTop > upperClip[x]) {
    if (sector.ceilTexture) {
      drawTexturedFloorCeil(buffer, camera, sector, x, upperClip[x], drawTop, false);
    } else {
      drawVerticalLine(buffer, x, upperClip[x], drawTop, ceilColor);
    }
  }

  // ..

  if (drawBottom < lowerClip[x]) {
    if (sector.floorTexture) {
      drawTexturedFloorCeil(buffer, camera, sector, x, drawBottom, lowerClip[x], true);
    } else {
      drawVerticalLine(buffer, x, drawBottom, lowerClip[x], floorColor);
    }
  }

Вся отдельная логика собрана в drawTexturedFloorCeil. Один и тот же код подходит и для пола, и для потолка: флаг isFloor выбирает текстуру и сторону экрана. Для пола нужны пиксели ниже центра экрана, для потолка - выше центра.


  function drawTexturedFloorCeil(
    imageData: ImageData,
    camera: Camera,
    sector: Sector,
    x: number,
    yStart: number,
    yEnd: number,
    isFloor: boolean
  ): void {
    const textureName = isFloor ? sector.floorTexture : sector.ceilTexture;
    if (!textureName) return;

    const texture = textures[textureName];
    if (!texture) return;

    // ..
  }

Основная идея: для каждого пикселя пола или потолка мы восстанавливаем, какой точке мира он соответствует.


  const distToPlane = isFloor
    ? camera.z - sector.floorHeight
    : sector.ceilHeight - camera.z;

  const p = y - camera.screen.height / 2;
  const rowDistance = distToPlane / Math.abs(p);

  const planeLength = Math.tan(camera.fov.radians / 2);
  const planeX = -camera.angle.sin * planeLength;
  const planeY = camera.angle.cos * planeLength;

  const t = x / camera.screen.width;
  const rayDirX = camera.angle.cos + planeX * (2 * t - 1);
  const rayDirY = camera.angle.sin + planeY * (2 * t - 1);

  const worldX = camera.x + rayDirX * rowDistance;
  const worldY = camera.y + rayDirY * rowDistance;

  let texX = (worldX / texture.scale) % 1;
  let texY = (worldY / texture.scale) % 1;

Значение distToPlane - это расстояние от камеры до горизонтальной плоскости по вертикали. Для пола камера находится выше плоскости, поэтому берем camera.z - floorHeight. Для потолка камера находится ниже плоскости, поэтому берем ceilHeight - camera.z. Это не расстояние вперед по взгляду, а именно высотная разница: насколько нужно опуститься до пола или подняться до потолка.

Затем берем расстояние p от пикселя до центра экрана по вертикали. Чем ближе пиксель к центру, тем дальше находится точка на плоскости. Поэтому rowDistance считается через вертикальное расстояние distToPlane и наклон луча, который в этой упрощенной формуле выражен через Math.abs(p).

Значение planeX и planeY нужны, чтобы получить не только центральный луч камеры, но и луч для любого столбца экрана. Направление камеры задается вектором camera.angle.cos/camera.angle.sin. А экранная плоскость идет поперек взгляда, поэтому берем перпендикуляр к этому направлению: (-sin, cos). Длина этого бокового вектора равна tan(FOV / 2): так левый и правый края плоскости совпадают с границами угла обзора.

Затем по экранной координате x строим направление луча: слева луч чуть повернут к левому краю FOV, справа - к правому. Умножаем это направление на rowDistance и получаем мировые координаты worldX/worldY. Эти координаты уже можно превратить в координаты текстуры.

Двойка в выражении 2 * t - 1 появляется из-за перевода координаты столбца из диапазона 0..1 в диапазон -1..1. При t = 0 получаем левый край экранной плоскости, при t = 0.5 - центр, а при t = 1 - правый край. Поэтому planeX/planeY добавляется с отрицательным, нулевым или положительным весом.

Значение texture.scale задает размер повторения текстуры в мире. После деления на scale берется дробная часть: так один и тот же bitmap повторяется по всей плоскости. Отрицательные координаты дополнительно приводятся к диапазону от 0 до 1.

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

Реализация шага на github