PL
games/doom-jumphp / src / Engine / HdRenderer.php · commit 8e1cb9f
<?phpnamespace App\Engine;use App\Engine\Map\Level;use App\Engine\Map\LevelBuilder;use App\Engine\Map\Line;use App\Engine\Map\Sector;use JumPHP\Gl\Mat4;use JumPHP\Gl\WebGl;/** * The high-definition renderer: the same level drawn by the GPU (OpenGL on the desktop, WebGL in the browser) at * the window's resolution, behind the 2D HUD (JumPHP's 3D layer, 'native'): *  - geometry built once per level: wall quads from the lines, floors / ceilings from the cells of the level text; *    heights are read from a small "sector" texture, so doors and lifts move without rebuilding anything; *  - textures doubled with Scale2x (smooth pixel art) and filtered, relief from their brightness, fine grain; *  - the sector light with the original's distance fade, 8 dynamic point lights (shots, projectiles, lamps, *    explosions) with specular highlights, fog; *  - sprites as camera-facing billboards with the same lighting, the weapon on top; *  - post-processing: bloom, vignette, filmic curve, damage / pickup tint. */final class HdRenderer{    public const HFOV = 90.0;    public const ASPECT = 1.6;      // the view: 320 x 200    private static ?self $instance = null;    private bool $ready = false;    private bool $enabled = false;    private int $world = 0;    private int $sprite = 0;    private int $screen = 0;    private int $skyProg = 0;    private int $quad = 0;    private int $sectors = 0;    private int $skyTex = 0;    /** @var array<string, int> */    private array $textures = [];    /** @var array<string, array{0: int, 1: int, 2: int}> sprite key => [texture, w, h] (doubled) */    private array $spriteTex = [];    private ?Level $level = null;    /** @var array<string, array{buffer: int, w: int, h: int, flat: bool, name: string}> */    private array $batches = [];    private array $tint = [0, 0, 0, 0];    private function __construct(private WebGl $gl, private Art $art)    {    }    public static function get(WebGl $gl, Art $art): self    {        return self::$instance ??= new self($gl, $art);    }    public function isAvailable(): bool    {        return $this->gl->isAvailable();    }    /** Turns the 3D layer and the post-processing on (once). */    public function enable(): void    {        if (!$this->isAvailable()) {            return;        }        $this->setup();        if (!$this->enabled) {            $this->gl->setLayer('below', 'native', [255, 0, 255]);            // anti-aliased edges in the desktop app (browsers and phones keep their fill rate)            $this->gl->setSupersample(\JumPHP\Kernel\Platform::isDesktop() ? 2 : 1);            $this->gl->setPostShader(HdShaders::POST, ['u_tint' => [0, 0, 0, 0]]);            $this->enabled = true;        }    }    public function disable(): void    {        if ($this->enabled) {            $this->gl->disable();            $this->enabled = false;        }    }    public function isEnabled(): bool    {        return $this->enabled;    }    /** Damage / pickup flash: [r, g, b, amount] 0..255 / 0..1. */    public function setTint(?array $tint): void    {        $t = $tint === null ? [0, 0, 0, 0] : [$tint[0] / 255, $tint[1] / 255, $tint[2] / 255, $tint[3] * 0.8];        if ($t !== $this->tint) {            $this->tint = $t;            $this->gl->setUniform('u_tint', $t);        }    }    private function setup(): void    {        if ($this->ready) {            return;        }        $this->ready = true;        $gl = $this->gl;        $this->world = $gl->program(HdShaders::WORLD_VS, HdShaders::WORLD_FS);        $this->sprite = $gl->program(HdShaders::SPRITE_VS, HdShaders::SPRITE_FS);        $this->screen = $gl->program(HdShaders::SCREEN_VS, HdShaders::SCREEN_FS);        $this->skyProg = $gl->program(HdShaders::SKY_VS, HdShaders::SKY_FS);        $this->quad = $gl->buffer([0, 0, 1, 0, 1, 1, 0, 0, 1, 1, 0, 1]);        $this->sectors = $gl->textureFromRgba(256, 2, str_repeat("\0", 256 * 2 * 4), false);        $sky = $this->art->sky;        $this->skyTex = $gl->textureFromRgba($sky['w'], $sky['h'], self::rgba(self::transpose($sky['px'], $sky['w'], $sky['h']), $sky['w'], $sky['h'], false), true);    }    // ----------------------------------------------------------------------------------- textures    /** Palette indices (row-major) -> RGBA bytes; index 255 transparent. */    private static function rgba(string $px, int $w, int $h, bool $alpha = true): string    {        static $map = null;        if ($map === null) {            $map = Palette::rgbaMap();            $map["\xFF"] = "\0\0\0\0";        }        return strtr($px, $map);    }    private static function transpose(string $colMajor, int $w, int $h): string    {        $out = str_repeat("\0", $w * $h);        for ($x = 0; $x < $w; $x++) {            $col = substr($colMajor, $x * $h, $h);            for ($y = 0; $y < $h; $y++) {                $out[$y * $w + $x] = $col[$y];            }        }        return $out;    }    /** Scale2x (EPX) on palette indices: twice the size, staircases smoothed, colours unchanged. */    public static function scale2x(string $px, int $w, int $h, bool $wrap): string    {        $out = str_repeat("\0", $w * $h * 4);        $w2 = $w * 2;        for ($y = 0; $y < $h; $y++) {            $yu = $y > 0 ? $y - 1 : ($wrap ? $h - 1 : 0);            $yd = $y < $h - 1 ? $y + 1 : ($wrap ? 0 : $h - 1);            for ($x = 0; $x < $w; $x++) {                $xl = $x > 0 ? $x - 1 : ($wrap ? $w - 1 : 0);                $xr = $x < $w - 1 ? $x + 1 : ($wrap ? 0 : $w - 1);                $p = $px[$y * $w + $x];                $a = $px[$yu * $w + $x];                $b = $px[$y * $w + $xr];                $c = $px[$y * $w + $xl];                $d = $px[$yd * $w + $x];                $o = $y * 2 * $w2 + $x * 2;                $out[$o] = ($c === $a && $c !== $d && $a !== $b) ? $a : $p;                $out[$o + 1] = ($a === $b && $a !== $c && $b !== $d) ? $b : $p;                $out[$o + $w2] = ($d === $c && $d !== $b && $c !== $a) ? $c : $p;                $out[$o + $w2 + 1] = ($b === $d && $b !== $a && $d !== $c) ? $d : $p;            }        }        return $out;    }    private function wallTexture(string $name): int    {        if (!isset($this->textures["W$name"])) {            $t = $this->art->walls[$name] ?? $this->art->walls['TECH'];            $rows = self::scale2x(self::transpose($t['px'], $t['w'], $t['h']), $t['w'], $t['h'], true);            $this->textures["W$name"] = $this->gl->textureFromRgba($t['w'] * 2, $t['h'] * 2, self::rgba($rows, $t['w'] * 2, $t['h'] * 2), true);        }        return $this->textures["W$name"];    }    private function flatTexture(string $name): int    {        if (!isset($this->textures["F$name"])) {            $px = $this->art->flats[$name] ?? $this->art->flats['FLOOR1'];            $this->textures["F$name"] = $this->gl->textureFromRgba(128, 128, self::rgba(self::scale2x($px, 64, 64, true), 128, 128), true);        }        return $this->textures["F$name"];    }    /** @return array{0: int, 1: int, 2: int} */    private function spriteTexture(array $pic, string $key): array    {        if (!isset($this->spriteTex[$key])) {            // pad by one transparent pixel: filtered edges fade instead of smearing            $w = $pic['w'] + 2;            $h = $pic['h'] + 2;            $rows = str_repeat("\xFF", $w * $h);            for ($x = 0; $x < $pic['w']; $x++) {                $col = substr($pic['px'], $x * $pic['h'], $pic['h']);                for ($y = 0; $y < $pic['h']; $y++) {                    $rows[($y + 1) * $w + $x + 1] = $col[$y];                }            }            $big = self::scale2x($rows, $w, $h, false);            $this->spriteTex[$key] = [$this->gl->textureFromRgba($w * 2, $h * 2, self::rgba($big, $w * 2, $h * 2), true), $w, $h];        }        return $this->spriteTex[$key];    }    // ----------------------------------------------------------------------------------- geometry    /** Builds the vertex buffers of a level (once per level). */    private function build(Level $level): void    {        foreach ($this->batches as $b) {            $this->gl->delete($b['buffer']);        }        $this->batches = [];        $this->level = $level;        if (count($level->sectors) > 256) {            throw new \RuntimeException('HD renderer: at most 256 sectors');        }        $data = [];        foreach ($level->lines as $line) {            $this->wallSide($data, $line, 0);            if ($line->back !== null) {                $this->wallSide($data, $line, 1);            }        }        $this->floors($data, $level);        foreach ($data as $key => $floats) {            $flat = $key[0] === 'F';            $name = substr($key, 1);            $info = $flat ? ['w' => 64, 'h' => 64] : ($this->art->walls[$name] ?? $this->art->walls['TECH']);            $this->batches[$key] = ['buffer' => $this->gl->buffer($floats), 'w' => $info['w'], 'h' => $info['h'], 'flat' => $flat, 'name' => $name];        }    }    /**     * A vertex: map x, y; z = height of (sector, 0 floor | 1 ceiling) clamped (mode 1 min, 2 max) by another one;     * texture u, v reference (sector, which) + add; light sector, kind (0 wall, 1 floor, 2 ceiling), normal.     */    private static function v(array &$out, float $x, float $y, array $z, float $u, array $vref, int $light, int $kind, float $nx, float $ny, ?array $clamp = null): void    {        array_push($out, $x, $y, $z[0], $z[1], $u, $vref[0], $vref[1], $vref[2], $light, $kind, $nx, $ny,            $clamp[0] ?? 0, $clamp[1] ?? 0, $clamp[2] ?? 0, 0);    }    private function quad(array &$data, string $tex, array $p1, array $p2, array $bottom, array $top, array $vref, int $light,        float $u1, float $u2, float $nx, float $ny, ?array $clampBottom = null, ?array $clampTop = null): void    {        $d = &$data["W$tex"];        $d ??= [];        self::v($d, $p1[0], $p1[1], $bottom, $u1, $vref, $light, 0, $nx, $ny, $clampBottom);        self::v($d, $p2[0], $p2[1], $bottom, $u2, $vref, $light, 0, $nx, $ny, $clampBottom);        self::v($d, $p2[0], $p2[1], $top, $u2, $vref, $light, 0, $nx, $ny, $clampTop);        self::v($d, $p1[0], $p1[1], $bottom, $u1, $vref, $light, 0, $nx, $ny, $clampBottom);        self::v($d, $p2[0], $p2[1], $top, $u2, $vref, $light, 0, $nx, $ny, $clampTop);        self::v($d, $p1[0], $p1[1], $top, $u1, $vref, $light, 0, $nx, $ny, $clampTop);    }    private function wallSide(array &$data, Line $line, int $sideNo): void    {        $side = $sideNo === 0 ? $line->front : $line->back;        $s = $side->sector;        $o = $sideNo === 0 ? $line->backSector : $line->frontSector;        [$p1, $p2] = $sideNo === 0 ? [[$line->x1, $line->y1], [$line->x2, $line->y2]] : [[$line->x2, $line->y2], [$line->x1, $line->y1]];        $len = $line->length;        $nx = ($p2[1] - $p1[1]) / $len;          // the right side of the direction: towards this sector        $ny = -($p2[0] - $p1[0]) / $len;        $u1 = $side->xoff;        $u2 = $side->xoff + $len;        $sid = $s->id;        if ($o === null) {            $tex = $side->mid ?? 'TECH';            $th = $this->art->walls[$tex]['h'] ?? 128;            $vref = ($line->flags & Line::UNPEG_BOTTOM) ? [$sid, 0, $th + $side->yoff] : [$sid, 1, $side->yoff];            $this->quad($data, $tex, $p1, $p2, [$sid, 0], [$sid, 1], $vref, $sid, $u1, $u2, $nx, $ny);            return;        }        $oid = $o->id;        // upper part: from the other ceiling up to this one (nothing between two skies)        if (!($s->ceilPic === SoftRenderer::SKY && $o->ceilPic === SoftRenderer::SKY)) {            $tex = $side->top ?? 'STEP';            $th = $this->art->walls[$tex]['h'] ?? 128;            $vref = ($line->flags & Line::UNPEG_TOP) ? [$sid, 1, $side->yoff] : [$oid, 1, $th + $side->yoff];            $this->quad($data, $tex, $p1, $p2, [$oid, 1], [$sid, 1], $vref, $sid, $u1, $u2, $nx, $ny, [$sid, 1, 1]);        }        // lower part: from this floor up to the other one        $tex = $side->bottom ?? 'STEP';        $vref = ($line->flags & Line::UNPEG_BOTTOM) ? [$sid, 1, $side->yoff] : [$oid, 0, $side->yoff];        $this->quad($data, $tex, $p1, $p2, [$sid, 0], [$oid, 0], $vref, $sid, $u1, $u2, $nx, $ny, null, [$sid, 0, 2]);        // bars between two areas        if ($side->mid !== null) {            $this->quad($data, $side->mid, $p1, $p2, [$sid, 0], [$sid, 1], [$sid, 1, $side->yoff], $sid, $u1, $u2, $nx, $ny, [$oid, 0, 2], [$oid, 1, 1]);        }    }    /** Floors and ceilings from the cells of the level text: runs of a row merged into long quads. */    private function floors(array &$data, Level $level): void    {        $map = $level->data['map'];        $legend = $level->data['legend'];        $rows = count($map);        $cell = LevelBuilder::CELL;        foreach ($map as $r => $row) {            $y1 = ($rows - $r) * $cell;            $y0 = $y1 - $cell;            $run = null;            $n = strlen($row);            for ($c = 0; $c <= $n; $c++) {                $ch = $c < $n ? $row[$c] : ' ';                $def = $legend[$ch] ?? ['solid' => true];                $sector = null;                if (empty($def['solid']) && !isset($def['diag'])) {                    $sector = $level->sectorAt($c * $cell + $cell / 2, $y0 + $cell / 2);                }                if ($run !== null && ($sector === null || $sector !== $run[1])) {                    $this->flatQuad($data, $run[1], [[$run[0] * $cell, $y0], [$c * $cell, $y0], [$c * $cell, $y1], [$run[0] * $cell, $y1]]);                    $run = null;                }                if ($sector !== null && $run === null) {                    $run = [$c, $sector];                }                if (isset($def['diag'])) {                    $x0 = $c * $cell;                    $x1 = $x0 + $cell;                    // the open triangle: the corners that are not the solid one                    $corners = ['nw' => [$x0, $y1], 'ne' => [$x1, $y1], 'se' => [$x1, $y0], 'sw' => [$x0, $y0]];                    $opposite = ['nw' => 'se', 'ne' => 'sw', 'se' => 'nw', 'sw' => 'ne'][$def['diag']];                    unset($corners[$def['diag']]);                    $o = $corners[$opposite];                    $s = $level->sectorAt($o[0] + ($o[0] === $x0 ? 8 : -8), $o[1] + ($o[1] === $y0 ? 8 : -8));                    $this->flatTri($data, $s, array_values($corners));                }            }        }    }    private function flatQuad(array &$data, Sector $s, array $p): void    {        $this->flatTri($data, $s, [$p[0], $p[1], $p[2]]);        $this->flatTri($data, $s, [$p[0], $p[2], $p[3]]);    }    private function flatTri(array &$data, Sector $s, array $p): void    {        $id = $s->id;        $f = &$data['F' . $s->floorPic];        $f ??= [];        foreach ($p as [$x, $y]) {            self::v($f, $x, $y, [$id, 0], 0, [0, 0, 0], $id, 1, 0, 0);        }        unset($f);        if ($s->ceilPic !== SoftRenderer::SKY) {            $c = &$data['F' . $s->ceilPic];            $c ??= [];            foreach ($p as [$x, $y]) {                self::v($c, $x, $y, [$id, 1], 0, [0, 0, 0], $id, 2, 0, 0);            }        }    }    // ------------------------------------------------------------------------------------- frame    /**     * Draws a frame. $things: [x, y, z, angle, sprite, frame, bright, sector light] seen; $lights: [x, y, z, radius,     * r, g, b] (0..1); $weapon: [picture, ox, oy, light 0..1, bright, cache key].     */    public function render(Level $level, float $x, float $y, float $z, float $angle, array $things, array $lights, ?array $weapon): void    {        if (!$this->isAvailable()) {            return;        }        $this->enable();        if ($this->level !== $level) {            $this->build($level);        }        $gl = $this->gl;        $this->uploadSectors($level);        $gl->clear([10, 6, 8, 255]);        $tanHalf = tan(deg2rad(self::HFOV / 2));        $fovY = rad2deg(2 * atan($tanHalf / self::ASPECT));        $proj = Mat4::perspective($fovY, self::ASPECT, 2.0, 6000.0);        $eye = [$x, $z, -$y];        $view = Mat4::lookAt($eye, [$x + cos($angle), $z, -$y - sin($angle)], [0, 1, 0]);        $vp = Mat4::multiply($proj, $view);        // the sky first, behind everything        $gl->draw($this->skyProg, [            'attributes' => ['a_corner' => [$this->quad, 2, 0, 0]],            'uniforms' => ['u_angle' => $angle, 'u_tanHalf' => $tanHalf, 'u_aspect' => self::ASPECT],            'textures' => ['u_tex' => $this->skyTex], 'depth' => false,        ]);        // lights        $lpos = array_fill(0, 32, 0.0);        $lcol = array_fill(0, 32, 0.0);        foreach (array_slice($lights, 0, 8) as $i => $l) {            [$lpos[$i * 4], $lpos[$i * 4 + 1], $lpos[$i * 4 + 2], $lpos[$i * 4 + 3]] = [$l[0], $l[1], $l[2], $l[3]];            [$lcol[$i * 4], $lcol[$i * 4 + 1], $lcol[$i * 4 + 2]] = [$l[4], $l[5], $l[6]];        }        $common = ['u_vp' => $vp, 'u_eye' => [$x, $y, $z], 'u_lpos' => $lpos, 'u_lcol' => $lcol, 'u_fog' => [0.09, 0.06, 0.07], 'u_gamma' => 1.15];        foreach ($this->batches as $b) {            $tex = $b['flat'] ? $this->flatTexture($this->art->flatFrame[$b['name']] ?? $b['name']) : $this->wallTexture($b['name']);            $gl->draw($this->world, [                'attributes' => ['a_pos' => [$b['buffer'], 4, 16, 0], 'a_tex' => [$b['buffer'], 4, 16, 4],                    'a_info' => [$b['buffer'], 4, 16, 8], 'a_clamp' => [$b['buffer'], 4, 16, 12]],                'uniforms' => $common + ['u_texSize' => [$b['w'], $b['h']]],                'textures' => ['u_sectors' => $this->sectors, 'u_tex' => $tex],            ]);        }        // sprites        $right = [sin($angle), -cos($angle), 0.0];        foreach ($things as [$tx, $ty, $tz, $tangle, $sprite, $frame, $bright, $light]) {            $rot = ((int)floor(SoftRenderer::norm(atan2($y - $ty, $x - $tx) - $tangle + M_PI / 8) / (M_PI / 4)) % 8) + 1;            $f = $this->art->spriteFrame($sprite, $frame, $rot);            if ($f === null) {                continue;            }            [$pic, $key] = $f;            [$tex, $w, $h] = $this->spriteTexture($pic, $key);            $gl->draw($this->sprite, [                'attributes' => ['a_corner' => [$this->quad, 2, 0, 0]],                'uniforms' => $common + ['u_center' => [$tx, $ty, $tz], 'u_rect' => [$pic['left'] + 1, $pic['top'] + 1, $w, $h],                    'u_right' => $right, 'u_sector' => $light, 'u_bright' => $bright ? 1.0 : 0.0],                'textures' => ['u_tex' => $tex],            ]);        }        // the weapon in the hands: the picture made for 320 x 168, at the bottom of the 320 x 200 view        if ($weapon !== null) {            [$pic, $ox, $oy, $lum, $bright, $key] = $weapon;            [$tex, $w, $h] = $this->spriteTexture($pic, 'weapon:' . $key);            $left = 160 - $pic['left'] - 1 + $ox;            $top = 84 - $pic['top'] - 1 + $oy + 32;            $l = $bright ? 1.3 : max(0.25, $lum);            $gl->draw($this->screen, [                'attributes' => ['a_corner' => [$this->quad, 2, 0, 0]],                'uniforms' => ['u_rect' => [$left / 160 - 1, 1 - $top / 100, $w / 160, $h / 100], 'u_light' => [$l, $l * 0.97, $l * 0.92]],                'textures' => ['u_tex' => $tex], 'depth' => false,            ]);        }    }    private function uploadSectors(Level $level): void    {        $data = str_repeat("\0", 256 * 2 * 4);        foreach ($level->sectors as $s) {            $f = max(0, min(65535, (int)round(($s->floor + 2048) * 8)));            $c = max(0, min(65535, (int)round(($s->ceil + 2048) * 8)));            $o = $s->id * 4;            $data[$o] = chr($f >> 8);            $data[$o + 1] = chr($f & 255);            $data[$o + 2] = chr($c >> 8);            $data[$o + 3] = chr($c & 255);            $data[1024 + $o] = chr(max(0, min(255, $s->light)));        }        $this->gl->textureFromRgba(256, 2, $data, false, $this->sectors);    }}