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