2.5D Renderer

Stage 3: Binary Space Partition

Порталы 2D: соединяем сектора

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

Одного диапазона по X для этого уже недостаточно. Диапазон говорит только “эта колонка закрыта полностью”, но портал закрывает колонку не целиком: часть колонки может быть занята потолком текущего сектора, часть — полом, а середина остается открытой для дальнего сектора. Поэтому добавим два массива размером с ширину экрана: upperClip и lowerClip.

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

2.5D Renderer

2D Renderer

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

В результате BSP по-прежнему отвечает за порядок от ближнего к дальнему, wallRanges отбрасывает полностью закрытые колонки, а upperClip и lowerClip ограничивают видимую высоту внутри каждой колонки. Эти три механизма вместе позволяют корректно рисовать цепочку секторов через порталы.

Немного кода

BSP-обход остается прежним, но обычные и портальные сегменты теперь обрабатываются разными функциями. Обычная стена закрывает доступный интервал колонки, а портал рисует только верхнюю и нижнюю закрытые части, оставляя середину для следующего сектора.


  function render25d(
    ctx: CanvasRenderingContext2D,
    settings: Settings,
  ) {
    const camera = settings.camera;
    const allSegments = settings.level.linedefs;
    const bspTree = buildBSPTree(allSegments);
  
    const wallRanges = createSolidWallRanges(camera);
    const upperClip = new Array(camera.screen.width).fill(-1);
    const lowerClip = new Array(camera.screen.width).fill(camera.screen.height);
  
    traverseBSPTree(bspTree, camera, (bspNode: BSPLeaf) => {
      for (const seg of bspNode.segs) {
        const sector = seg.frontSector!;
  
        const projection = projectSeg(camera, sector, seg);
  
        if (!projection) {
          continue;
        }
  
        if (isPortal(seg)) {
          drawPortalSegment(ctx, camera, seg, projection, wallRanges, upperClip, lowerClip);
        } else {
          drawSolidSegment(ctx, camera, seg, projection, wallRanges, upperClip, lowerClip);
        }
      }
    });
  }
  

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


  function drawPortalSegment(
    ctx: CanvasRenderingContext2D,
    camera: Camera, 
    seg: Seg,
    projection: SegProjection, 
    solidWallRanges: SolidSegmentRange[],
    upperClip: number[],
    lowerClip: number[],
  ) {
    const frontSector = seg.frontSector!;
    const backSector = seg.backSector!;
    const cameraSide = getPointSide(seg, { x: camera.x, y: camera.y });
    const currentSector = cameraSide >= 0 ? frontSector : backSector;

    for (let x = xFrom; x <= xTo; x++) {
      if (!isWallVisible(x, solidWallRanges)) {
        continue;
      }

      const t = (x - xStart) / (xEnd - xStart);
      const top = startTop + (endTop - startTop) * t;
      const bottom = startBottom + (endBottom - startBottom) * t;

      const drawTop = Math.max(upperClip[x], top);
      const drawBottom = Math.min(lowerClip[x], bottom);

      if (drawTop > upperClip[x]) {
        drawVerticalLine(ctx, x, upperClip[x], drawTop, currentSector.ceilColor!);
        upperClip[x] = drawTop;
      }
      
      if (drawBottom < lowerClip[x]) {
        drawVerticalLine(ctx, x, drawBottom, lowerClip[x], currentSector.floorColor!);
        lowerClip[x] = drawBottom;
      }
    }
  }

Сначала функция определяет, с какой стороны портала находится камера. Это нужно, чтобы выбрать текущий сектор: именно его потолок и пол должны закрывать верхнюю и нижнюю часть колонки вокруг проема. Затем для каждой экранной колонки интерполируются top и bottom — верхняя и нижняя границы портального seg на экране.

Если между старым upperClip[x] и новым верхом портала есть место, рисуем там потолок текущего сектора и сдвигаем верхнюю границу вниз. Если между низом портала и lowerClip[x] есть место, рисуем пол и сдвигаем нижнюю границу вверх.