JA
games/doom-jumphp / src / Engine / SoftRenderer.php · commit 8e1cb9f
<?phpnamespace App\Engine;use App\Engine\Map\Level;use App\Engine\Map\Line;use App\Engine\Map\Node;use App\Engine\Map\Seg;use App\Engine\Map\Sector;use App\Engine\Map\Subsector;/** * The software renderer, a PHP port of the original engine's (r_main.c, r_bsp.c, r_segs.c, r_plane.c, * r_things.c, r_draw.c), with floats instead of fixed point: *  - the BSP tree is walked front to back; each seg is clipped against the columns already filled *    (solid segs) and drawn column by column (walls), while marking the floor and ceiling spans above / below it; *  - floors and ceilings are drawn afterwards as horizontal spans (visplanes); *  - sprites are sorted back to front and clipped by the silhouettes of the walls in front of them; *  - lighting = colormaps chosen by the sector light and the distance. * The picture is a string of palette indices, w x h (w = 320, or 160 in low detail). */final class SoftRenderer{    public const SKY = 'SKY';    private const UNSET = 100000;    private const SIL_BOTTOM = 1;    private const SIL_TOP = 2;    private const MAXINT = 1e9;    public string $fb = '';    public int $frameCount = 0;    /** statistics of the last frame */    public array $stats = [];    public int $extraLight = 0;    /** the viewer: interpolation factor between the previous and the current tic */    public float $alpha = 1.0;    private float $centerX;    private float $centerY;    private float $projX;    private float $projY;    private float $clipAngle;    /** @var float[] */    private array $xToAngle = [];    private array $xToCos = [];    private float $vx = 0.0;    private float $vy = 0.0;    private float $vz = 0.0;    private float $vang = 0.0;    private float $vcos = 1.0;    private float $vsin = 0.0;    private Level $level;    private array $ceilClip = [];    private array $floorClip = [];    private array $solid = [];    private array $drawSegs = [];    private array $planes = [];    private array $sprites = [];    private int $validCount = 0;    private ?object $viewer = null;    // the seg being drawn    private Seg $seg;    private Sector $front;    private ?Sector $back = null;    private ?int $floorPlane = null;    private ?int $ceilPlane = null;    public function __construct(private Art $art, public int $w = 320, public int $h = 168)    {        $this->setSize($w, $h);    }    public function setSize(int $w, int $h): void    {        $this->w = $w;        $this->h = $h;        $this->centerX = $w / 2;        $this->centerY = $h / 2;        $this->projX = $w / 2;        $this->projY = 160.0;        $this->clipAngle = atan($this->centerX / $this->projX);        $this->xToAngle = [];        $this->xToCos = [];        for ($x = 0; $x < $w; $x++) {            $a = atan(($this->centerX - ($x + 0.5)) / $this->projX);            $this->xToAngle[$x] = $a;            $this->xToCos[$x] = cos($a);        }        $this->fb = str_repeat("\0", $w * $h);    }    /**     * Draws the level seen from (x, y, z) looking at $angle. $viewer: the thing to hide (the player).     */    public function render(Level $level, float $x, float $y, float $z, float $angle, ?object $viewer = null): void    {        $this->level = $level;        $this->viewer = $viewer;        $this->vx = $x;        $this->vy = $y;        $this->vz = $z;        $this->vang = self::norm($angle);        $this->vcos = cos($this->vang);        $this->vsin = sin($this->vang);        $this->frameCount++;        $this->validCount++;        $this->ceilClip = array_fill(0, $this->w, -1);        $this->floorClip = array_fill(0, $this->w, $this->h);        $this->solid = [[-self::MAXINT, -1], [$this->w, self::MAXINT]];        $this->drawSegs = [];        $this->planes = [];        $this->sprites = [];        $this->stats = ['segs' => 0, 'planes' => 0, 'sprites' => 0];        $this->renderNode($level->root);        $this->drawPlanes();        $this->drawMasked();        $this->stats['planes'] = count($this->planes);        $this->stats['sprites'] = count($this->sprites);    }    public static function norm(float $a): float    {        $a = fmod($a, 2 * M_PI);        return $a < 0 ? $a + 2 * M_PI : $a;    }    /** -PI..PI */    private static function signed(float $a): float    {        $a = self::norm($a);        return $a > M_PI ? $a - 2 * M_PI : $a;    }    private function angleToX(float $rel): int    {        $x = (int)round($this->centerX - tan($rel) * $this->projX);        return $x < 0 ? 0 : ($x > $this->w ? $this->w : $x);    }    // ----------------------------------------------------------------------------------------- BSP (r_bsp.c)    private function renderNode(int $n): void    {        if ($n < 0) {            $this->subsector($this->level->subsectors[-1 - $n]);            return;        }        $node = $this->level->nodes[$n];        $side = (($this->vx - $node->x) * $node->dy - ($this->vy - $node->y) * $node->dx) >= 0 ? 0 : 1;        $this->renderNode($node->children[$side]);        if ($this->checkBBox($node->bbox[$side ^ 1])) {            $this->renderNode($node->children[$side ^ 1]);        }    }    private const CHECKCOORD = [        [3, 0, 2, 1], [3, 0, 2, 0], [3, 1, 2, 0], [0, 0, 0, 0],        [2, 0, 2, 1], [0, 0, 0, 0], [3, 1, 3, 0], [0, 0, 0, 0],        [2, 0, 3, 1], [2, 1, 3, 1], [2, 1, 3, 0],    ];    /** R_CheckBBox: can a part of this box be seen (not off screen, not behind solid walls)? */    private function checkBBox(array $box): bool    {        $bx = $this->vx <= $box[2] ? 0 : ($this->vx < $box[3] ? 1 : 2);        $by = $this->vy >= $box[0] ? 0 : ($this->vy > $box[1] ? 1 : 2);        $pos = ($by << 2) + $bx;        if ($pos === 5) {            return true;        }        $c = self::CHECKCOORD[$pos];        $x1 = $box[$c[0]];        $y1 = $box[$c[1]];        $x2 = $box[$c[2]];        $y2 = $box[$c[3]];        $a1 = self::norm(atan2($y1 - $this->vy, $x1 - $this->vx) - $this->vang);        $a2 = self::norm(atan2($y2 - $this->vy, $x2 - $this->vx) - $this->vang);        $span = self::norm($a1 - $a2);        if ($span >= M_PI) {            return true;        }        $clip = $this->clipAngle;        $t = self::norm($a1 + $clip);        if ($t > 2 * $clip) {            $t -= 2 * $clip;            if ($t >= $span) {                return false;            }            $a1 = $clip;        }        $t = self::norm($clip - $a2);        if ($t > 2 * $clip) {            $t -= 2 * $clip;            if ($t >= $span) {                return false;            }            $a2 = -$clip;        }        $sx1 = $this->angleToX(self::signed($a1));        $sx2 = $this->angleToX(self::signed($a2));        if ($sx1 === $sx2) {            return false;        }        $sx2--;        $i = 0;        while ($this->solid[$i][1] < $sx2) {            $i++;        }        return !($sx1 >= $this->solid[$i][0] && $sx2 <= $this->solid[$i][1]);    }    private function subsector(Subsector $sub): void    {        $f = $sub->sector;        $this->front = $f;        $this->floorPlane = $f->floor < $this->vz ? $this->findPlane($f->floor, $f->floorPic, $f->light) : null;        $this->ceilPlane = ($f->ceil > $this->vz || $f->ceilPic === self::SKY) ? $this->findPlane($f->ceil, $f->ceilPic, $f->light) : null;        if ($f->validCount !== $this->validCount) {            $f->validCount = $this->validCount;            $this->addSprites($f);        }        foreach ($sub->segs as $seg) {            $this->addLine($seg);        }    }    private function addLine(Seg $seg): void    {        $a1 = atan2($seg->y1 - $this->vy, $seg->x1 - $this->vx);        $a2 = atan2($seg->y2 - $this->vy, $seg->x2 - $this->vx);        $span = self::norm($a1 - $a2);        if ($span >= M_PI) {            return;      // back side        }        $a1 = self::norm($a1 - $this->vang);        $a2 = self::norm($a2 - $this->vang);        $clip = $this->clipAngle;        $t = self::norm($a1 + $clip);        if ($t > 2 * $clip) {            $t -= 2 * $clip;            if ($t >= $span) {                return;            }            $a1 = $clip;        }        $t = self::norm($clip - $a2);        if ($t > 2 * $clip) {            $t -= 2 * $clip;            if ($t >= $span) {                return;            }            $a2 = -$clip;        }        $x1 = $this->angleToX(self::signed($a1));        $x2 = $this->angleToX(self::signed($a2));        if ($x1 >= $x2) {            return;        }        $this->seg = $seg;        $back = $seg->backSector;        $this->back = $back;        $front = $this->front;        if ($back === null || $back->ceil <= $front->floor || $back->floor >= $front->ceil) {            $this->clipSolid($x1, $x2 - 1);            return;        }        if ($back->ceil !== $front->ceil || $back->floor !== $front->floor) {            $this->clipPass($x1, $x2 - 1);            return;        }        if ($back->ceilPic === $front->ceilPic && $back->floorPic === $front->floorPic && $back->light === $front->light            && $seg->sidedef->mid === null) {            return;        }        $this->clipPass($x1, $x2 - 1);    }    private function clipSolid(int $first, int $last): void    {        $s = &$this->solid;        $start = 0;        while ($s[$start][1] < $first - 1) {            $start++;        }        if ($first < $s[$start][0]) {            if ($last < $s[$start][0] - 1) {                $this->storeWallRange($first, $last);                array_splice($s, $start, 0, [[$first, $last]]);                return;            }            $this->storeWallRange($first, $s[$start][0] - 1);            $s[$start][0] = $first;        }        if ($last <= $s[$start][1]) {            return;        }        $next = $start;        while ($last >= $s[$next + 1][0] - 1) {            $this->storeWallRange($s[$next][1] + 1, $s[$next + 1][0] - 1);            $next++;            if ($last <= $s[$next][1]) {                $s[$start][1] = $s[$next][1];                if ($next !== $start) {                    array_splice($s, $start + 1, $next - $start);                }                return;            }        }        $this->storeWallRange($s[$next][1] + 1, $last);        $s[$start][1] = $last;        if ($next !== $start) {            array_splice($s, $start + 1, $next - $start);        }    }    private function clipPass(int $first, int $last): void    {        $s = $this->solid;        $start = 0;        while ($s[$start][1] < $first - 1) {            $start++;        }        if ($first < $s[$start][0]) {            if ($last < $s[$start][0] - 1) {                $this->storeWallRange($first, $last);                return;            }            $this->storeWallRange($first, $s[$start][0] - 1);        }        if ($last <= $s[$start][1]) {            return;        }        while ($last >= $s[$start + 1][0] - 1) {            $this->storeWallRange($s[$start][1] + 1, $s[$start + 1][0] - 1);            $start++;            if ($last <= $s[$start][1]) {                return;            }        }        $this->storeWallRange($s[$start][1] + 1, $last);    }    // ------------------------------------------------------------------------------------ walls (r_segs.c)    /** The colormap of a wall / sprite: sector light (with the fake contrast) and scale. */    private function lightLevel(int $light, float $scale, int $contrast = 0): int    {        $ln = ($light >> 4) + $this->extraLight + $contrast;        $ln = $ln < 0 ? 0 : ($ln > 15 ? 15 : $ln);        $j = (int)($scale * 16);        if ($j > 47) {            $j = 47;        }        $level = (15 - $ln) * 4 - ($j >> 1);        return $level < 0 ? 0 : ($level > 31 ? 31 : $level);    }    private function storeWallRange(int $start, int $stop): void    {        if ($start > $stop) {            return;        }        $this->stats['segs']++;        $seg = $this->seg;        $front = $this->front;        $back = $this->back;        $side = $seg->sidedef;        $line = $seg->line;        $line->mapped = true;        $vz = $this->vz;        $w = $this->w;        $h = $this->h;        $cy = $this->centerY;        $sa = $seg->angle;        $ux = cos($sa);        $uy = sin($sa);        $dist = ($this->vx - $seg->x1) * $uy - ($this->vy - $seg->y1) * $ux;        if ($dist < 0.1) {            $dist = 0.1;        }        $sF = ($this->vx - $seg->x1) * $ux + ($this->vy - $seg->y1) * $uy;        $base = $this->vang - $sa - M_PI / 2;        $scale1 = $this->scaleAt($start, $base, $dist);        $scale2 = $stop > $start ? $this->scaleAt($stop, $base, $dist) : $scale1;        $step = $stop > $start ? ($scale2 - $scale1) / ($stop - $start) : 0.0;        $ds = [            'x1' => $start, 'x2' => $stop, 'scale1' => $scale1, 'scale2' => $scale2, 'step' => $step, 'seg' => $seg,            'sil' => 0, 'bsil' => self::MAXINT, 'tsil' => -self::MAXINT, 'top' => null, 'bottom' => null, 'masked' => null,        ];        $worldTop = $front->ceil - $vz;        $worldBottom = $front->floor - $vz;        $midTex = $topTex = $bottomTex = null;        $midMid = $topMid = $bottomMid = 0.0;        $masked = false;        $worldHigh = $worldLow = 0.0;        $markFloor = $markCeil = false;        $art = $this->art;        if ($back === null) {            $midTex = $side->mid ?? 'TECH';            $markFloor = $markCeil = true;            if ($line->flags & Line::UNPEG_BOTTOM) {                $midMid = $front->floor + $art->walls[$midTex]['h'] - $vz;            } else {                $midMid = $worldTop;            }            $midMid += $side->yoff;            $ds['sil'] = self::SIL_BOTH;            $ds['top'] = 'full';            $ds['bottom'] = 'full';        } else {            $sil = 0;            if ($front->floor > $back->floor) {                $sil |= self::SIL_BOTTOM;                $ds['bsil'] = $front->floor;            } elseif ($back->floor > $vz) {                $sil |= self::SIL_BOTTOM;                $ds['bsil'] = self::MAXINT;            }            if ($front->ceil < $back->ceil) {                $sil |= self::SIL_TOP;                $ds['tsil'] = $front->ceil;            } elseif ($back->ceil < $vz) {                $sil |= self::SIL_TOP;                $ds['tsil'] = -self::MAXINT;            }            if ($back->ceil <= $front->floor) {                $ds['bottom'] = 'full';                $ds['bsil'] = self::MAXINT;                $sil |= self::SIL_BOTTOM;            }            if ($back->floor >= $front->ceil) {                $ds['top'] = 'full';                $ds['tsil'] = -self::MAXINT;                $sil |= self::SIL_TOP;            }            $ds['sil'] = $sil;            $worldHigh = $back->ceil - $vz;            $worldLow = $back->floor - $vz;            if ($front->ceilPic === self::SKY && $back->ceilPic === self::SKY) {                $worldTop = $worldHigh;            }            $markFloor = $worldLow !== $worldBottom || $back->floorPic !== $front->floorPic || $back->light !== $front->light;            $markCeil = $worldHigh !== $worldTop || $back->ceilPic !== $front->ceilPic || $back->light !== $front->light;            if ($back->ceil <= $front->floor || $back->floor >= $front->ceil) {                $markCeil = $markFloor = true;            }            if ($worldHigh < $worldTop) {                $topTex = $side->top ?? 'STEP';                if ($line->flags & Line::UNPEG_TOP) {                    $topMid = $worldTop;                } else {                    $topMid = $back->ceil + $art->walls[$topTex]['h'] - $vz;                }                $topMid += $side->yoff;            }            if ($worldLow > $worldBottom) {                $bottomTex = $side->bottom ?? 'STEP';                $bottomMid = (($line->flags & Line::UNPEG_BOTTOM) ? $worldTop : $worldLow) + $side->yoff;            }            if ($side->mid !== null) {                $masked = true;                $ds['masked'] = [];            }        }        $textured = $midTex !== null || $topTex !== null || $bottomTex !== null || $masked;        $offset = $seg->offset + $side->xoff + $sF;        if ($front->floor >= $vz) {            $markFloor = false;        }        if ($front->ceil <= $vz && $front->ceilPic !== self::SKY) {            $markCeil = false;        }        $topFrac = $cy - $worldTop * $scale1;        $topStep = -$step * $worldTop;        $bottomFrac = $cy - $worldBottom * $scale1;        $bottomStep = -$step * $worldBottom;        $pixHigh = $pixHighStep = $pixLow = $pixLowStep = 0.0;        if ($back !== null) {            if ($worldHigh < $worldTop) {                $pixHigh = $cy - $worldHigh * $scale1;                $pixHighStep = -$step * $worldHigh;            }            if ($worldLow > $worldBottom) {                $pixLow = $cy - $worldLow * $scale1;                $pixLowStep = -$step * $worldLow;            }        }        if ($markCeil && $this->ceilPlane !== null) {            $this->ceilPlane = $this->checkPlane($this->ceilPlane, $start, $stop);        } else {            $markCeil = false;        }        if ($markFloor && $this->floorPlane !== null) {            $this->floorPlane = $this->checkPlane($this->floorPlane, $start, $stop);        } else {            $markFloor = false;        }        // the fake contrast of the original: east-west walls darker, north-south walls brighter        $contrast = $seg->y1 === $seg->y2 ? -1 : ($seg->x1 === $seg->x2 ? 1 : 0);        $light = $front->light;        $fb = &$this->fb;        $ceilClip = &$this->ceilClip;        $floorClip = &$this->floorClip;        $cp = $markCeil ? $this->ceilPlane : null;        $fp = $markFloor ? $this->floorPlane : null;        $scale = $scale1;        $midInfo = $midTex !== null ? $art->walls[$midTex] : null;        $topInfo = $topTex !== null ? $art->walls[$topTex] : null;        $botInfo = $bottomTex !== null ? $art->walls[$bottomTex] : null;        $vang = $this->vang;        for ($x = $start; $x <= $stop; $x++) {            $yl = (int)ceil($topFrac);            if ($yl < $ceilClip[$x] + 1) {                $yl = $ceilClip[$x] + 1;            }            if ($cp !== null) {                $top = $ceilClip[$x] + 1;                $bottom = $yl - 1;                if ($bottom >= $floorClip[$x]) {                    $bottom = $floorClip[$x] - 1;                }                if ($top <= $bottom) {                    $this->planes[$cp]['top'][$x] = $top;                    $this->planes[$cp]['bottom'][$x] = $bottom;                }            }            $yh = (int)floor($bottomFrac);            if ($yh >= $floorClip[$x]) {                $yh = $floorClip[$x] - 1;            }            if ($fp !== null) {                $top = $yh + 1;                $bottom = $floorClip[$x] - 1;                if ($top <= $ceilClip[$x]) {                    $top = $ceilClip[$x] + 1;                }                if ($top <= $bottom) {                    $this->planes[$fp]['top'][$x] = $top;                    $this->planes[$fp]['bottom'][$x] = $bottom;                }            }            $texCol = 0;            $level = 0;            $iscale = 1.0;            if ($textured) {                $phi = $vang + $this->xToAngle[$x] - $sa - M_PI / 2;                $texCol = (int)floor($offset - $dist * tan($phi));                $level = $this->lightLevel($light, $scale, $contrast);                $iscale = 1.0 / $scale;            }            if ($midInfo !== null) {                if ($yl <= $yh) {                    $this->column($fb, $art->wall($midTex, $level), $midInfo, $texCol, $x, $yl, $yh, $midMid, $iscale);                }                $ceilClip[$x] = $h;                $floorClip[$x] = -1;            } else {                if ($topInfo !== null) {                    $mid = (int)floor($pixHigh);                    $pixHigh += $pixHighStep;                    if ($mid >= $floorClip[$x]) {                        $mid = $floorClip[$x] - 1;                    }                    if ($mid >= $yl) {                        $this->column($fb, $art->wall($topTex, $level), $topInfo, $texCol, $x, $yl, $mid, $topMid, $iscale);                        $ceilClip[$x] = $mid;                    } else {                        $ceilClip[$x] = $yl - 1;                    }                } elseif ($markCeil) {                    $ceilClip[$x] = $yl - 1;                }                if ($botInfo !== null) {                    $mid = (int)ceil($pixLow);                    $pixLow += $pixLowStep;                    if ($mid <= $ceilClip[$x]) {                        $mid = $ceilClip[$x] + 1;                    }                    if ($mid <= $yh) {                        $this->column($fb, $art->wall($bottomTex, $level), $botInfo, $texCol, $x, $mid, $yh, $bottomMid, $iscale);                        $floorClip[$x] = $mid;                    } else {                        $floorClip[$x] = $yh + 1;                    }                } elseif ($markFloor) {                    $floorClip[$x] = $yh + 1;                }                if ($masked) {                    $ds['masked'][$x] = $texCol;                }            }            $scale += $step;            $topFrac += $topStep;            $bottomFrac += $bottomStep;        }        // silhouettes for the sprites        if ((($ds['sil'] & self::SIL_TOP) || $masked) && $ds['top'] === null) {            $ds['top'] = array_slice($this->ceilClip, $start, $stop - $start + 1);        }        if ((($ds['sil'] & self::SIL_BOTTOM) || $masked) && $ds['bottom'] === null) {            $ds['bottom'] = array_slice($this->floorClip, $start, $stop - $start + 1);        }        if ($masked && !($ds['sil'] & self::SIL_TOP)) {            $ds['sil'] |= self::SIL_TOP;            $ds['tsil'] = -self::MAXINT;        }        if ($masked && !($ds['sil'] & self::SIL_BOTTOM)) {            $ds['sil'] |= self::SIL_BOTTOM;            $ds['bsil'] = self::MAXINT;        }        $this->drawSegs[] = $ds;    }    private const SIL_BOTH = 3;    private function scaleAt(int $x, float $base, float $dist): float    {        $phi = $base + $this->xToAngle[$x];        $s = $this->projY * cos($phi) / ($dist * $this->xToCos[$x]);        return $s < 0.004 ? 0.004 : ($s > 64.0 ? 64.0 : $s);    }    /** R_DrawColumn: one vertical strip of a wall texture (column-major pixels). */    private function column(string &$fb, string $px, array $info, int $u, int $x, int $yl, int $yh, float $texMid, float $iscale): void    {        $th = $info['h'];        $hmask = $info['hmask'];        $base = ($u & $info['wmask']) * $th;        $frac = $texMid + ($yl - $this->centerY + 0.5) * $iscale + 8192.0;        $w = $this->w;        $o = $yl * $w + $x;        for ($y = $yl; $y <= $yh; $y++) {            $fb[$o] = $px[$base + (((int)$frac) & $hmask)];            $frac += $iscale;            $o += $w;        }    }    // ------------------------------------------------------------------------------ planes (r_plane.c)    private function findPlane(float $height, string $pic, int $light): int    {        if ($pic === self::SKY) {            $height = 0.0;            $light = 0;        }        foreach ($this->planes as $i => $p) {            if ($p['height'] === $height && $p['pic'] === $pic && $p['light'] === $light) {                return $i;            }        }        $this->planes[] = ['height' => $height, 'pic' => $pic, 'light' => $light, 'minx' => $this->w, 'maxx' => -1,            'top' => array_fill(0, $this->w, self::UNSET), 'bottom' => array_fill(0, $this->w, -1)];        return count($this->planes) - 1;    }    private function checkPlane(int $i, int $start, int $stop): int    {        $p = &$this->planes[$i];        if ($start < $p['minx']) {            $intrl = $p['minx'];            $unionl = $start;        } else {            $unionl = $p['minx'];            $intrl = $start;        }        if ($stop > $p['maxx']) {            $intrh = $p['maxx'];            $unionh = $stop;        } else {            $unionh = $p['maxx'];            $intrh = $stop;        }        $free = true;        for ($x = $intrl; $x <= $intrh; $x++) {            if ($p['top'][$x] !== self::UNSET) {                $free = false;                break;            }        }        if ($free) {            $p['minx'] = $unionl;            $p['maxx'] = $unionh;            return $i;        }        $this->planes[] = ['height' => $p['height'], 'pic' => $p['pic'], 'light' => $p['light'], 'minx' => $start, 'maxx' => $stop,            'top' => array_fill(0, $this->w, self::UNSET), 'bottom' => array_fill(0, $this->w, -1)];        unset($p);        return count($this->planes) - 1;    }    private function drawPlanes(): void    {        $fb = &$this->fb;        $w = $this->w;        foreach ($this->planes as $p) {            if ($p['minx'] > $p['maxx']) {                continue;            }            if ($p['pic'] === self::SKY) {                $this->drawSky($fb, $p);                continue;            }            $top = $p['top'];            $bottom = $p['bottom'];            $minx = $p['minx'];            $maxx = $p['maxx'];            $top[$maxx + 1] = self::UNSET;            $bottom[$maxx + 1] = -1;            $top[$minx - 1] = self::UNSET;            $bottom[$minx - 1] = -1;            $planeH = abs($p['height'] - $this->vz);            $ln = ($p['light'] >> 4) + $this->extraLight;            $ln = $ln < 0 ? 0 : ($ln > 15 ? 15 : $ln);            $startMap = (15 - $ln) * 4;            $spanStart = [];            for ($x = $minx; $x <= $maxx + 1; $x++) {                $t1 = $top[$x - 1];                $b1 = $bottom[$x - 1];                $t2 = $top[$x];                $b2 = $bottom[$x];                while ($t1 < $t2 && $t1 <= $b1) {                    $this->mapPlane($fb, $p['pic'], $planeH, $startMap, $t1, $spanStart[$t1], $x - 1);                    $t1++;                }                while ($b1 > $b2 && $b1 >= $t1) {                    $this->mapPlane($fb, $p['pic'], $planeH, $startMap, $b1, $spanStart[$b1], $x - 1);                    $b1--;                }                while ($t2 < $t1 && $t2 <= $b2) {                    $spanStart[$t2] = $x;                    $t2++;                }                while ($b2 > $b1 && $b2 >= $t2) {                    $spanStart[$b2] = $x;                    $b2--;                }            }        }    }    /** R_MapPlane: one horizontal span of a floor / ceiling. */    private function mapPlane(string &$fb, string $pic, float $planeH, int $startMap, int $y, int $x1, int $x2): void    {        $dy = abs($y - $this->centerY + 0.5);        $distance = $planeH * $this->projY / ($dy < 0.5 ? 0.5 : $dy);        $j = (int)($distance / 16);        $level = $startMap - (int)(80 / ($j + 1));        $level = $level < 0 ? 0 : ($level > 31 ? 31 : $level);        $flat = $this->art->flat($pic, $level);        $lat = $distance / $this->projX;        $xs = $this->vsin * $lat;        $ys = -$this->vcos * $lat;        $off = $x1 + 0.5 - $this->centerX;        $xf = $this->vx + $this->vcos * $distance + $xs * $off + 65536.0;        $yf = -($this->vy + $this->vsin * $distance + $ys * $off) + 65536.0;        $ys = -$ys;        $o = $y * $this->w + $x1;        for ($x = $x1; $x <= $x2; $x++) {            $fb[$o++] = $flat[((((int)$yf) & 63) << 6) | (((int)$xf) & 63)];            $xf += $xs;            $yf += $ys;        }    }    private function drawSky(string &$fb, array $p): void    {        $sky = $this->art->sky;        $px = $sky['px'];        $sh = $sky['h'];        $w = $this->w;        $scaleY = 168 / max(1, $this->h) * ($this->h / 168);        for ($x = $p['minx']; $x <= $p['maxx']; $x++) {            $t = $p['top'][$x];            $b = $p['bottom'][$x];            if ($t > $b) {                continue;            }            $ang = self::norm($this->vang + $this->xToAngle[$x]);            $col = ((int)(1024 - $ang / (2 * M_PI) * 1024)) & 255;            $base = $col * $sh;            $o = $t * $w + $x;            for ($y = $t; $y <= $b; $y++) {                $row = (int)(($y - $this->centerY) * $scaleY + 100);                $fb[$o] = $px[$base + ($row < 0 ? 0 : ($row > 127 ? 127 : $row))];                $o += $w;            }        }    }    // ---------------------------------------------------------------------------- sprites (r_things.c)    private function addSprites(Sector $sector): void    {        $a = $this->alpha;        foreach ($sector->things as $t) {            if ($t === $this->viewer || !$t->visible || $t->sprite === '') {                continue;            }            $x = $t->px + ($t->x - $t->px) * $a;            $y = $t->py + ($t->y - $t->py) * $a;            $z = $t->pz + ($t->z - $t->pz) * $a;            $trx = $x - $this->vx;            $try = $y - $this->vy;            $tz = $trx * $this->vcos + $try * $this->vsin;            if ($tz < 4) {                continue;            }            $tx = $trx * $this->vsin - $try * $this->vcos;     // to the right            $xscale = $this->projX / $tz;            $yscale = $this->projY / $tz;            if (abs($tx) > $tz * 2 + 64) {                continue;            }            $rot = 0;            $ang = atan2($this->vy - $y, $this->vx - $x);            $rot = ((int)floor(self::norm($ang - $t->angle + M_PI / 8) / (M_PI / 4)) % 8) + 1;            $frame = $this->art->spriteFrame($t->sprite, $t->frame, $rot);            if ($frame === null) {                continue;            }            [$pic, $key] = $frame;            $sx = $this->centerX + $tx * $xscale;            $x1 = (int)ceil($sx - $pic['left'] * $xscale - 0.5);            $x2 = (int)ceil($sx + ($pic['w'] - $pic['left']) * $xscale - 0.5) - 1;            if ($x1 >= $this->w || $x2 < 0) {                continue;            }            $gzt = $z + $pic['top'];            $level = $t->bright ? 0 : $this->lightLevel($sector->light, $yscale);            $start = $x1 < 0 ? 0 : $x1;            $this->sprites[] = [                'x1' => $start, 'x2' => $x2 >= $this->w ? $this->w - 1 : $x2,                'scale' => $yscale, 'xiscale' => 1 / $xscale,                'startfrac' => ($start + 0.5 - ($sx - $pic['left'] * $xscale)) / $xscale,                'gx' => $x, 'gy' => $y, 'gz' => $z, 'gzt' => $gzt, 'mid' => $gzt - $this->vz,                'pic' => $pic, 'px' => $this->art->spritePixels($pic, $key, $level),            ];        }    }    private function drawMasked(): void    {        usort($this->sprites, fn (array $a, array $b) => $a['scale'] <=> $b['scale']);        foreach ($this->sprites as $s) {            $this->drawSprite($s);        }        // the masked walls (bars) not drawn yet        for ($i = count($this->drawSegs) - 1; $i >= 0; $i--) {            if ($this->drawSegs[$i]['masked'] !== null) {                $this->maskedRange($i, $this->drawSegs[$i]['x1'], $this->drawSegs[$i]['x2']);            }        }    }    private function drawSprite(array $spr): void    {        $x1 = $spr['x1'];        $x2 = $spr['x2'];        $clipBot = [];        $clipTop = [];        for ($x = $x1; $x <= $x2; $x++) {            $clipBot[$x] = -2;            $clipTop[$x] = -2;        }        for ($i = count($this->drawSegs) - 1; $i >= 0; $i--) {            $ds = $this->drawSegs[$i];            if ($ds['x1'] > $x2 || $ds['x2'] < $x1 || (!$ds['sil'] && $ds['masked'] === null)) {                continue;            }            $r1 = max($ds['x1'], $x1);            $r2 = min($ds['x2'], $x2);            $low = min($ds['scale1'], $ds['scale2']);            $high = max($ds['scale1'], $ds['scale2']);            $line = $ds['seg'];            $onBack = (($spr['gx'] - $line->x1) * ($line->y2 - $line->y1) - ($spr['gy'] - $line->y1) * ($line->x2 - $line->x1)) < 0;            if ($high < $spr['scale'] || ($low < $spr['scale'] && !$onBack)) {                if ($ds['masked'] !== null) {                    $this->maskedRange($i, $r1, $r2);                }                continue;            }            $sil = $ds['sil'];            if ($spr['gz'] >= $ds['bsil']) {                $sil &= ~self::SIL_BOTTOM;            }            if ($spr['gzt'] <= $ds['tsil']) {                $sil &= ~self::SIL_TOP;            }            if ($sil & self::SIL_BOTTOM) {                for ($x = $r1; $x <= $r2; $x++) {                    if ($clipBot[$x] === -2) {                        $clipBot[$x] = $ds['bottom'] === 'full' ? -1 : $ds['bottom'][$x - $ds['x1']];                    }                }            }            if ($sil & self::SIL_TOP) {                for ($x = $r1; $x <= $r2; $x++) {                    if ($clipTop[$x] === -2) {                        $clipTop[$x] = $ds['top'] === 'full' ? $this->h : $ds['top'][$x - $ds['x1']];                    }                }            }        }        $fb = &$this->fb;        $w = $this->w;        $h = $this->h;        $pic = $spr['pic'];        $px = $spr['px'];        $ph = $pic['h'];        $pw = $pic['w'];        $scale = $spr['scale'];        $iscale = 1 / $scale;        $topScreen = $this->centerY - $spr['mid'] * $scale;        $frac = $spr['startfrac'];        $xi = $spr['xiscale'];        for ($x = $x1; $x <= $x2; $x++, $frac += $xi) {            $col = (int)$frac;            if ($col < 0 || $col >= $pw) {                continue;            }            [$first, $last] = $pic['ranges'][$col];            if ($first < 0) {                continue;            }            $cb = $clipBot[$x] === -2 ? $h : $clipBot[$x];            $ct = $clipTop[$x] === -2 ? -1 : $clipTop[$x];            $yl = (int)ceil($topScreen + $first * $scale);            $yh = (int)ceil($topScreen + ($last + 1) * $scale) - 1;            if ($yl <= $ct) {                $yl = $ct + 1;            }            if ($yh >= $cb) {                $yh = $cb - 1;            }            if ($yl > $yh) {                continue;            }            $base = $col * $ph;            $row = ($yl + 0.5 - $topScreen) * $iscale;            $o = $yl * $w + $x;            for ($y = $yl; $y <= $yh; $y++) {                $r = (int)$row;                if ($r > $last) {                    $r = $last;                }                $c = $px[$base + $r];                if ($c !== "\xFF") {                    $fb[$o] = $c;                }                $row += $iscale;                $o += $w;            }        }    }    /** R_RenderMaskedSegRange: bars / grates on two-sided lines, once per column. */    private function maskedRange(int $i, int $x1, int $x2): void    {        $ds = &$this->drawSegs[$i];        $seg = $ds['seg'];        $side = $seg->sidedef;        $tex = $side->mid;        $info = $this->art->walls[$tex] ?? null;        if ($info === null) {            return;        }        $front = $seg->frontSector;        $back = $seg->backSector;        $th = $info['h'];        if ($seg->line->flags & Line::UNPEG_BOTTOM) {            $mid = max($front->floor, $back->floor) + $th - $this->vz;        } else {            $mid = min($front->ceil, $back->ceil) - $this->vz;        }        $mid += $side->yoff;        $contrast = $seg->y1 === $seg->y2 ? -1 : ($seg->x1 === $seg->x2 ? 1 : 0);        $fb = &$this->fb;        $w = $this->w;        for ($x = $x1; $x <= $x2; $x++) {            if (!isset($ds['masked'][$x])) {                continue;            }            $u = $ds['masked'][$x];            unset($ds['masked'][$x]);            $scale = $ds['scale1'] + ($x - $ds['x1']) * $ds['step'];            $level = $this->lightLevel($front->light, $scale, $contrast);            $px = $this->art->wall($tex, $level);            $top = $this->centerY - $mid * $scale;            $yl = (int)ceil($top);            $yh = (int)ceil($top + $th * $scale) - 1;            $ct = is_array($ds['top']) ? $ds['top'][$x - $ds['x1']] : -1;            $cb = is_array($ds['bottom']) ? $ds['bottom'][$x - $ds['x1']] : $this->h;            if ($yl <= $ct) {                $yl = $ct + 1;            }            if ($yh >= $cb) {                $yh = $cb - 1;            }            $iscale = 1 / $scale;            $base = ($u & $info['wmask']) * $th;            $row = ($yl + 0.5 - $top) * $iscale;            $o = $yl * $w + $x;            for ($y = $yl; $y <= $yh; $y++) {                $r = (int)$row;                $c = $px[$base + ($r >= $th ? $th - 1 : $r)];                if ($c !== "\xFF") {                    $fb[$o] = $c;                }                $row += $iscale;                $o += $w;            }        }    }    /** The weapon in the hands (r_things.c's psprites), drawn over everything. */    public function drawWeapon(array $pic, float $ox, float $oy, int $level): void    {        $fb = &$this->fb;        $w = $this->w;        $xs = $w / 320;           // weapon pictures are made for 320 columns        $ys = $this->h / 168;        $px = Palette::darken($pic['px'], $level);        $left = 160 - $pic['left'] + $ox;        $top = 84 - $pic['top'] + $oy;        $x1 = (int)ceil($left * $xs);        $x2 = (int)ceil(($left + $pic['w']) * $xs) - 1;        for ($x = max(0, $x1); $x <= min($w - 1, $x2); $x++) {            $col = (int)(($x + 0.5) / $xs - $left);            if ($col < 0 || $col >= $pic['w']) {                continue;            }            [$first, $last] = $pic['ranges'][$col];            if ($first < 0) {                continue;            }            $yl = max(0, (int)ceil(($top + $first) * $ys));            $yh = min($this->h - 1, (int)ceil(($top + $last + 1) * $ys) - 1);            $base = $col * $pic['h'];            for ($y = $yl; $y <= $yh; $y++) {                $r = (int)(($y + 0.5) / $ys - $top);                if ($r < 0 || $r >= $pic['h']) {                    continue;                }                $c = $px[$base + $r];                if ($c !== "\xFF") {                    $fb[$y * $w + $x] = $c;                }            }        }    }    /** A row-major palette picture (logo, status pictures) into the frame, 255 = transparent. */    public function blit(array $pic, int $x, int $y): void    {        $fb = &$this->fb;        $w = $pic['w'];        for ($j = 0; $j < $pic['h']; $j++) {            $yy = $y + $j;            if ($yy < 0 || $yy >= $this->h) {                continue;            }            for ($i = 0; $i < $w; $i++) {                $c = $pic['px'][$j * $w + $i];                $xx = $x + $i;                if ($c !== "\xFF" && $xx >= 0 && $xx < $this->w) {                    $fb[$yy * $this->w + $xx] = $c;                }            }        }    }    /** The level's lines seen so far are on the automap: Line::$mapped (set while drawing). */    public function viewAngle(): float    {        return $this->vang;    }}