*/ private array $textures = []; /** @var array sprite key => [texture, w, h] (doubled) */ private array $spriteTex = []; private ?Level $level = null; /** @var array */ 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); } }