Accedi
IT
games/open-lemmings / src / View / World3d.php · commit 04e2e2a
<?phpnamespace App\View;use App\Service\Art;use App\Sim\Lumi;use JumPHP\Gl\WebGl;use JumPHP\Kernel\Engine;use JumPHP\Util\Color;use JumPHP\Util\Time;/** * The 3D view of a level, drawn in the GL layer ($engine->gl(), under the 2D HUD): the terrain extruded in depth * (TerrainMesh, one buffer and one texture per chunk of TerrainView, rebuilt only when a chunk changes), the * Lumikins, the hatch, the exit and the particles as cards facing the camera at the middle of the depth, the * pools as boxes with a waving top, the sky and the parallax layers far behind. * * Pictures are sent to GL as GD's raw bytes when imagegd() exists (A R G B, decoded by the shaders: no loop over the * pixels in PHP), else as RGBA. Nothing is made before the 3D view is first asked for (load()). */final class World3d{    /** floats per vertex of the cards: x y z, corner dx dy, u v, r g b a */    private const FLOATS = 11;    private const MAX_QUADS = 8000;    private const TERRAIN_VS = <<<'GLSL'        attribute vec3 a_pos;        attribute vec2 a_uv;        attribute float a_shade;        uniform mat4 u_mvp;        varying vec2 v_uv;        varying float v_shade;        void main() {            v_uv = a_uv;            v_shade = a_shade;            gl_Position = u_mvp * vec4(a_pos, 1.0);        }        GLSL;    private const TERRAIN_FS = <<<'GLSL'        precision mediump float;        uniform sampler2D u_tex;        uniform float u_argb;        varying vec2 v_uv;        varying float v_shade;        void main() {            vec4 t = texture2D(u_tex, v_uv);            vec4 c = u_argb > 0.5 ? vec4(t.g, t.b, t.a, clamp(1.0 - t.r * 2.0079, 0.0, 1.0)) : t;            if (c.a < 0.5) {                discard;            }            // the clear pixels of the picture are black: a smooth filter mixes them in at the edges, divided out here            gl_FragColor = vec4(min(c.rgb / c.a * v_shade, vec3(1.0)), 1.0);        }        GLSL;    private const CARD_VS = <<<'GLSL'        attribute vec3 a_pos;        attribute vec2 a_corner;        attribute vec2 a_uv;        attribute vec4 a_color;        uniform mat4 u_mvp;        uniform vec3 u_right;        varying vec2 v_uv;        varying vec4 v_color;        void main() {            v_uv = a_uv;            v_color = a_color;            vec3 p = a_pos + u_right * a_corner.x + vec3(0.0, a_corner.y, 0.0);            gl_Position = u_mvp * vec4(p, 1.0);        }        GLSL;    private const CARD_FS = <<<'GLSL'        #ifdef GL_FRAGMENT_PRECISION_HIGH        precision highp float;        #else        precision mediump float;        #endif        uniform sampler2D u_tex;        uniform float u_argb;        uniform float u_cut;        uniform float u_alpha;        uniform float u_wrap;        uniform vec4 u_outline;        uniform vec2 u_texel;        varying vec2 v_uv;        varying vec4 v_color;        vec4 at(vec2 uv) {            vec4 t = texture2D(u_tex, uv);            return u_argb > 0.5 ? vec4(t.g, t.b, t.a, clamp(1.0 - t.r * 2.0079, 0.0, 1.0)) : t;        }        void main() {            // u_wrap: 0 clamp, 1 repeat sideways, 2 mirrored repeat both ways (the sky: no stretched top row)            vec2 uv = v_uv;            if (u_wrap > 1.5) {                uv = 1.0 - abs(fract(v_uv * 0.5) * 2.0 - 1.0);            } else if (u_wrap > 0.5) {                uv.x = fract(v_uv.x);            }            vec4 c = at(uv);            if (u_outline.a > 0.0) {                // a glowing outline of the shape (a Lumikin hidden behind the ground) and a faint fill                float n = max(max(at(uv + vec2(u_texel.x, 0.0)).a, at(uv - vec2(u_texel.x, 0.0)).a),                              max(at(uv + vec2(0.0, u_texel.y)).a, at(uv - vec2(0.0, u_texel.y)).a));                n = max(n, max(max(at(uv + u_texel * 0.7).a, at(uv - u_texel * 0.7).a),                               max(at(uv + vec2(u_texel.x, -u_texel.y) * 0.7).a, at(uv - vec2(u_texel.x, -u_texel.y) * 0.7).a)));                float ring = clamp(n - c.a, 0.0, 1.0);                float a = max(ring, c.a * 0.3) * u_outline.a;                if (a < 0.02) {                    discard;                }                gl_FragColor = vec4(mix(u_outline.rgb, c.rgb, c.a * 0.4 * (1.0 - ring)), a);                return;            }            c *= v_color;            c.a *= u_alpha;            if (c.a < u_cut) {                discard;            }            gl_FragColor = c;        }        GLSL;    private WebGl $gl;    private bool $loaded = false;    private bool $argb;    private int $terrainProg = 0;    private int $cardProg = 0;    private int $indices = 0;    /** @var array<string, int> picture name => texture id */    private array $tex = [];    /** @var array<string, array{0: float, 1: float}> picture name => size in retro pixels */    private array $sizes = [];    /** batches drawn with another batch's picture */    private const GROUP_PICTURE = ['pool' => 'white', 'lumi-hidden' => 'lumi'];    /** @var array<int, array{buf: int, tex: int, quads: int}> chunk index => its GL resources */    private array $chunks = [];    /** @var array<int, true> chunks to build again */    private array $dirty = [];    /** @var array<string, array{buf: int, v: float[], n: int}> card batches of this frame, one per picture */    private array $groups = [];    /** @var array<int, float[]> the pools' colours, 0..1 */    private array $liquid = [];    private float $buildMs = 0;    public function __construct(private Engine $engine, private Art $art, private WorldView $view)    {        $this->gl = $engine->gl();        $this->argb = function_exists('imagegd');    }    public function isAvailable(): bool    {        return $this->gl->isAvailable();    }    /** Milliseconds spent building terrain chunks in the last prepare() (tests, tuning). */    public function buildMs(): float    {        return $this->buildMs;    }    private function load(): void    {        if ($this->loaded) {            return;        }        $this->loaded = true;        $gl = $this->gl;        $this->terrainProg = $gl->program(self::TERRAIN_VS, self::TERRAIN_FS);        $this->cardProg = $gl->program(self::CARD_VS, self::CARD_FS);        $idx = [];        for ($q = 0; $q < self::MAX_QUADS; $q++) {            $b = $q * 4;            array_push($idx, $b, $b + 1, $b + 2, $b, $b + 2, $b + 3);        }        $this->indices = $gl->indexBuffer($idx);        $hd = $this->art->scale > 1;        $t = $this->view->themeName();        $dir = $hd ? 'assets/hd' : 'assets';        $names = ['lumi', 'hatch', 'exit', 'glow-amber', 'glow-red', 'glow-violet', 'glow-white', 'glow-cyan', 'glow-green', 'flash'];        foreach ($names as $name) {            $this->picture($name, "$dir/sprites/$name.png", $hd);        }        foreach (['cursor', 'cursor-on'] as $name) {            $this->picture($name, "assets/sprites/$name.png", false);        }        if ($hd) {            $this->picture('shadow', 'assets/hd/sprites/shadow.png', true);        }        // the HD sky is a JPEG, which GD does not read in the runtimes: its PNG copy at twice the retro size        // (tools/art_hd.py sky_gl()); the retro sky when it is missing        if (!$hd || !$this->picture('bg-sky', "assets/hd/bg/$t-sky-gl.png", true, 2)) {            $this->picture('bg-sky', "assets/bg/$t-sky.png", $hd);        }        $this->picture('bg-far', "$dir/bg/$t-far.png", $hd);        $this->picture('bg-near', "$dir/bg/$t-near.png", $hd);        $white = imagecreatetruecolor(2, 2);        imagefill($white, 0, 0, imagecolorallocate($white, 255, 255, 255));        $this->tex['white'] = $this->upload($white, false);        $this->sizes['white'] = [2.0, 2.0];        foreach ($this->view->theme()['liquid'] as $i => $c) {            $c = Color::of($c);            $this->liquid[$i] = [$c->r / 255, $c->g / 255, $c->b / 255];        }        $tv = $this->view->terrain;        for ($i = 0; $i < $tv->cols() * $tv->rows(); $i++) {            $this->dirty[$i] = true;        }        $tv->touched = [];    }    private function picture(string $name, string $file, bool $smooth, ?int $scale = null): bool    {        $path = $this->engine->config->path($file);        $data = @file_get_contents($path);        $img = $data === false ? false : @imagecreatefromstring($data);        if ($img === false) {            $this->tex[$name] = 0;            $this->sizes[$name] = [1.0, 1.0];            return false;        }        $k = $scale ?? (str_contains($file, 'assets/hd/') ? $this->art->scale : 1);        $this->sizes[$name] = [imagesx($img) / $k, imagesy($img) / $k];        if ($k > 2) {            // HD pictures down to twice the retro size: GL has no mipmaps here, a picture shrunk too much loses its            // fine details (the stars of the sky) and shimmers            // (imagescale keeps the colours of the clear pixels, spread there by tools/art_hd.py: no dark fringes;            // imagecopyresampled would turn them black)            $small = imagescale($img, (int)round(imagesx($img) * 2 / $k), (int)round(imagesy($img) * 2 / $k), IMG_BILINEAR_FIXED);            if ($small !== false) {                $img = $small;            }        }        $this->tex[$name] = $this->upload($img, $smooth);        return true;    }    private function upload(\GdImage $img, bool $smooth): int    {        if (!imageistruecolor($img)) {            imagepalettetotruecolor($img);        }        imagesavealpha($img, true);        $w = imagesx($img);        $h = imagesy($img);        if ($this->argb) {            ob_start();            imagegd($img);            $bytes = (string)ob_get_clean();            $bytes = substr($bytes, strlen($bytes) - $w * $h * 4);        } else {            $bytes = WebGl::rgbaFromGd($img);        }        return $this->gl->textureFromRgba($w, $h, $bytes, $smooth);    }    /**     * Builds the chunks that changed (those in view first) for at most $budgetMs (at least one).     * @return bool true when every chunk in view is up to date (the 3D view can be shown)     */    public function prepare(Camera3d $cam, float $budgetMs): bool    {        $this->load();        $tv = $this->view->terrain;        $this->dirty += $tv->touched;        $tv->touched = [];        [$x0, $x1] = $cam->visibleX();        $c0 = max(0, (int)floor($x0 / TerrainView::CW));        $c1 = min($tv->cols() - 1, (int)floor($x1 / TerrainView::CW));        $visible = [];        $others = [];        foreach ($this->dirty as $i => $_) {            $c = $i % $tv->cols();            if ($c >= $c0 && $c <= $c1) {                $visible[] = $i;            } else {                $others[] = $i;            }        }        $start = Time::now();        $built = 0;        foreach (array_merge($visible, $others) as $i) {            if ($built > 0 && (Time::now() - $start) * 1000 >= $budgetMs) {                break;            }            $this->build($i);            unset($this->dirty[$i]);            $built++;        }        $this->buildMs = (Time::now() - $start) * 1000;        foreach ($visible as $i) {            if (isset($this->dirty[$i])) {                return false;            }        }        return true;    }    private function build(int $i): void    {        $tv = $this->view->terrain;        $t = $this->view->world->terrain;        $cx = ($i % $tv->cols()) * TerrainView::CW;        $cy = intdiv($i, $tv->cols()) * TerrainView::CH;        $w = min(TerrainView::CW, $t->width - $cx);        $h = min(TerrainView::CH, $t->height - $cy);        $old = $this->chunks[$i] ?? ['buf' => 0, 'tex' => 0, 'quads' => 0];        if (!$t->anySolid($cx, $cy, $w, $h)) {            $this->chunks[$i] = ['quads' => 0] + $old;            return;        }        [$v, $n] = TerrainMesh::chunk($t, $cx, $cy, $w, $h);        if ($n > self::MAX_QUADS) {            $n = self::MAX_QUADS;            $v = array_slice($v, 0, $n * 4 * TerrainMesh::FLOATS);        }        [$pw, $ph, $pixels] = $tv->chunkPixels($i, $this->argb);        $tex = $this->gl->textureFromRgba($pw, $ph, $pixels, $tv->scale() > 1, $old['tex'] ?: null);        if ($old['buf'] > 0) {            $this->gl->updateBuffer($old['buf'], $v);            $buf = $old['buf'];        } else {            $buf = $this->gl->buffer($v, true);        }        $this->chunks[$i] = ['buf' => $buf, 'tex' => $tex, 'quads' => $n];    }    // --- one frame ----------------------------------------------------------------------------------------------    public function render(Camera3d $cam, ?Lumi $hover, bool $hoverOk, float $camX, float $camY): void    {        $this->load();        $gl = $this->gl;        $view = $this->view;        $world = $view->world;        $time = $view->time();        foreach ($this->groups as &$g) {            $g['v'] = [];            $g['n'] = 0;        }        unset($g);        $gl->clear([12, 8, 24, 255]);        $mvp = $cam->mvp();        $right = $cam->right;        // far away: the sky and the two parallax layers, planes behind the level (Backdrop: at 0 degrees exactly the        // 2D layers, turned they are seen at an angle with real parallax), back to front, no depth test        foreach ([['bg-sky', Backdrop::SKY, 2.0], ['bg-far', Backdrop::FAR, 1.0], ['bg-near', Backdrop::NEAR, 1.0]] as [$name, $speed, $wrap]) {            [$tw, $th] = $this->sizes[$name];            $q = Backdrop::plane($cam, $speed, $tw, $th, $speed === Backdrop::SKY ? 0.0 : $camX * $speed);            if ($q !== null) {                $gr = &$this->group($name);                foreach ($q as [$x, $y, $z, $u, $v]) {                    array_push($gr['v'], $x, $y, $z, 0.0, 0.0, $u, $v, 1.0, 1.0, 1.0, 1.0);                }                $gr['n']++;                unset($gr);            }            $this->drawGroup($name, $mvp, $right, ['depth' => false, 'blend' => true, 'cut' => 0.01, 'wrap' => $wrap]);        }        // the terrain: the chunks in view        $tv = $view->terrain;        [$x0, $x1] = $cam->visibleX();        $c0 = max(0, (int)floor($x0 / TerrainView::CW));        $c1 = min($tv->cols() - 1, (int)floor($x1 / TerrainView::CW));        for ($row = 0; $row < $tv->rows(); $row++) {            for ($c = $c0; $c <= $c1; $c++) {                $ch = $this->chunks[$row * $tv->cols() + $c] ?? null;                if ($ch === null || $ch['quads'] === 0) {                    continue;                }                $gl->draw($this->terrainProg, [                    'attributes' => ['a_pos' => [$ch['buf'], 3, 6, 0], 'a_uv' => [$ch['buf'], 2, 6, 3], 'a_shade' => [$ch['buf'], 1, 6, 5]],                    'uniforms' => ['u_mvp' => $mvp, 'u_argb' => $this->argb ? 1.0 : 0.0],                    'textures' => ['u_tex' => $ch['tex']],                    'indices' => $this->indices,                    'count' => $ch['quads'] * 6,                ]);            }        }        // pools: a box of liquid with a waving top        $zf = Camera3d::DEPTH / 2 - 0.4;        $zb = -Camera3d::DEPTH / 2 + 0.4;        [$l0, $l1, $l2] = [$this->liquid[0] ?? [0.3, 0.8, 1.0], $this->liquid[1] ?? [0.1, 0.4, 0.8], $this->liquid[2] ?? [0.05, 0.2, 0.5]];        foreach ($world->liquids as $pool) {            [$px0, $px1, $top] = $pool;            $bottom = $pool[3] ?? WorldView::VIEW_H + 4;            if ($px1 < $x0 || $px0 > $x1) {                continue;            }            for ($x = $px0; $x <= $px1; $x += 6) {                $xe = min($px1 + 1, $x + 6);                $wa = sin($x * 0.21 + $time * 3.1) + 0.6 * sin($x * 0.47 - $time * 2.3);                $wb = sin($xe * 0.21 + $time * 3.1) + 0.6 * sin($xe * 0.47 - $time * 2.3);                $ya = $top + 1 + $wa * 1.4;                $yb = $top + 1 + $wb * 1.4;                $crest = max($wa, $wb) > 0.9 ? [1.0, 1.0, 1.0] : $l0;                $this->quad('pool', [$x, -$ya, $zf], [$xe, -$yb, $zf], [$xe, -$yb, $zb], [$x, -$ya, $zb], 0.5, 0.5, 0.5, 0.5, [...$crest, 1.0]);                $this->quad('pool', [$x, -$ya, $zf], [$xe, -$yb, $zf], [$xe, -$yb - 3, $zf], [$x, -$ya - 3, $zf], 0.5, 0.5, 0.5, 0.5, [...$l0, 1.0]);                $this->quadColors('pool', [$x, -$ya - 3, $zf], [$xe, -$yb - 3, $zf], [$xe, -$bottom, $zf], [$x, -$bottom, $zf], [...$l1, 1.0], [...$l2, 1.0]);            }        }        $this->drawGroup('pool', $mvp, $right, ['blend' => false, 'cut' => 0.0]);        $hd = $this->art->scale > 1;        // shadows on the ground (HD set)        if ($hd) {            foreach ($world->lumis as $l) {                if ($l->alive && !$l->is(Lumi::FALL, Lumi::GLIDE, Lumi::LEAVE) && $l->x > $x0 && $l->x < $x1) {                    // a soft oval on the top of the ground, a little lifted so that it never flickers in it                    $y = -$l->y + 0.35;                    $sx = $l->x + 0.5;                    $this->quad('shadow', [$sx - 7, $y, 4], [$sx + 7, $y, 4], [$sx + 7, $y, -4], [$sx - 7, $y, -4], 0, 0, 1, 1, [1, 1, 1, 0.75]);                }            }            $this->drawGroup('shadow', $mvp, $right, ['blend' => true, 'cut' => 0.02]);        }        // the hatch and the exit        $glows = [];        [$hx, $hy] = $world->hatch;        $glows[] = ['glow-violet', $hx - 30, $hy - 48, 60, 60, null];        $this->card('hatch', $hx - 24, $hy - 34, 48, 26, null);        [$ex, $ey] = $world->exit;        $pulse = 62 + 8 * sin($time * 3);        $glows[] = ['glow-amber', $ex - $pulse, $ey - 22 - $pulse, 2 * $pulse, 2 * $pulse, null];        $this->card('exit', $ex - 20, $ey - 43, 40, 44, [((int)($time * 9) % 6) * 40, 0, 40, 44]);        $this->drawGroup('hatch', $mvp, $right, ['blend' => true, 'cut' => 0.1]);        $this->drawGroup('exit', $mvp, $right, ['blend' => true, 'cut' => 0.1]);        // the Lumikins, back to front        $lumis = [];        foreach ($world->lumis as $l) {            if ($l->alive && $l->x > $x0 - 20 && $l->x < $x1 + 20) {                $lumis[] = [$cam->nearness($l->x, $l->y), $l];            }        }        usort($lumis, static fn (array $a, array $b) => $a[0] <=> $b[0]);        $glowName = 'glow-' . ($view->theme()['glow'] ?? 'amber');        foreach ($lumis as [, $l]) {            /** @var Lumi $l */            $warn = $l->fuse >= 0 || $l->state === Lumi::STOP;            $glows[] = [$warn && ($l->frame >> 2) % 2 ? 'glow-red' : 'glow-amber', $l->x - 15, $l->y - 27, 31, 31, null];            [$row, $frames, $ticks] = WorldView::ANIM[$l->state];            $frame = $l->state === Lumi::LEAVE ? min(3, intdiv($l->frame, $ticks)) : intdiv($l->frame, $ticks) % $frames;            $this->card('lumi', $l->x - ($l->dir > 0 ? 8 : 7), $l->y - 16, 16, 16, [$frame * 16, $row * 16, 16, 16], $l->dir < 0);            if (self::hidden($cam, $world->terrain, $l->x, $l->y)) {                $this->card('lumi-hidden', $l->x - ($l->dir > 0 ? 9 : 8), $l->y - 17, 18, 18, [$frame * 16 - 1, $row * 16 - 1, 18, 18], $l->dir < 0);            }            if ($l->fuse < 0 && $l->is(Lumi::WALK, Lumi::FALL)) {                if ($l->climber) {                    $this->card('white', $l->x - 4, $l->y - 19, 2, 2, null, false, [0.36, 0.95, 0.88, 1]);                }                if ($l->glider) {                    $this->card('white', $l->x + 3, $l->y - 19, 2, 2, null, false, [0.36, 0.88, 0.42, 1]);                }            }        }        if ($hover !== null && $hover->alive) {            $this->card($hoverOk ? 'cursor-on' : 'cursor', $hover->x - 7, $hover->y - 16, null, null, null);        }        // particles and motes        foreach ($view->particles->all() as $p) {            if ($p[0] < $x0 || $p[0] > $x1) {                continue;            }            $k = $p[4] / $p[5];            $c = $p[6];            $this->card('white', $p[0], $p[1], $p[7], $p[7], null, false, [$c[0] / 255, $c[1] / 255, $c[2] / 255, $k > 0.5 ? 1.0 : $k * 2]);        }        $mote = Color::of($view->theme()['mote']);        $moteColor = [$mote->r / 255, $mote->g / 255, $mote->b / 255, 1.0];        $night = $view->themeName() === 'night';        for ($i = 0; $i < 26; $i++) {            // spread over what a turned camera sees, at several depths            $mx = $camX - 200 + fmod($i * 97.3 * 1.8 + $time * (4 + $i % 5) + 8800, 880);            $my = $camY + fmod($i * 53.7 + ($night ? 1 : -1) * $time * (3 + $i % 4) + 2320, 232) + 4 * sin($time * 1.3 + $i);            $pulse = 0.5 + 0.5 * sin($time * (1.5 + ($i % 3) * 0.6) + $i * 2.1);            if ($pulse > 0.25) {                $z = (($i * 7) % 9 - 4) * 9.0;                if ($i % 3 === 0) {                    $glows[] = [$glowName, $mx - 6, $my - 6, 13, 13, $z];                }                $this->card('white', $mx, $my, 1, 1, null, false, $moteColor, $z);            }        }        $this->drawGroup('lumi', $mvp, $right, ['blend' => true, 'cut' => 0.1]);        $this->drawGroup('white', $mvp, $right, ['blend' => true, 'cut' => 0.05]);        foreach (['cursor', 'cursor-on'] as $name) {            $this->drawGroup($name, $mvp, $right, ['blend' => true, 'cut' => 0.1]);        }        // a Lumikin hidden by the ground (deep in a shaft, behind a wall): its glowing outline, through everything        [$sw, $sh] = $this->sizes['lumi'] ?? [128.0, 160.0];        $this->drawGroup('lumi-hidden', $mvp, $right, ['blend' => true, 'cut' => 0.02, 'depth' => false,            'outline' => [1.0, 0.9, 0.45, 1.0], 'texel' => [1.2 / $sw, 1.2 / $sh]]);        // glows: behind their owner (pushed away from the camera), back to front        foreach ($view->flashes() as [$fx, $fy, $life, $tex]) {            $s = $tex === 'flash' ? 150 * min(1, $life * 5) : 40 * min(1, $life * 8);            $glows[] = [$tex, $fx - $s / 2, $fy - $s / 2, $s, $s, null];        }        foreach ($world->liquids as $pool) {            [$px0, $px1, $top] = $pool;            if ($px1 >= $x0 && $px0 <= $x1) {                $glows[] = [$view->themeName() === 'forge' ? 'glow-amber' : 'glow-cyan', $px0 - 12, $top - 22, $px1 - $px0 + 25, 26, null, [0, 0, 64, 32]];            }        }        foreach ($glows as $i => $g) {            $glows[$i][7] = $cam->nearness($g[1] + $g[3] / 2, $g[2] + $g[4] / 2, $g[5] ?? 0.0);        }        usort($glows, static fn (array $a, array $b) => $a[7] <=> $b[7]);        $order = [];        foreach ($glows as $g) {            $this->card($g[0], $g[1], $g[2], $g[3], $g[4], $g[6] ?? null, false, null, $g[5] ?? 0.0, 3.0, $cam);            $order[$g[0]] = true;        }        foreach (array_keys($order) as $name) {            $this->drawGroup($name, $mvp, $right, ['blend' => true, 'cut' => 0.01]);        }    }    /**     * A card facing the camera, given by its rectangle in level coordinates (drawn where the 2D view draws it),     * turning around its own vertical middle line.     * @param array{0: float, 1: float, 2: float, 3: float}|null $src part of the picture, retro pixels     * @param float[]|null $color     */    private function card(string $name, float $left, float $top, ?float $w, ?float $h, ?array $src, bool $flip = false, ?array $color = null,                          float $z = 0.0, float $push = 0.0, ?Camera3d $cam = null): void    {        [$sw, $sh] = $this->sizes[self::GROUP_PICTURE[$name] ?? $name] ?? [1.0, 1.0];        $src ??= [0, 0, $sw, $sh];        $w ??= $src[2];        $h ??= $src[3];        $u0 = $src[0] / $sw;        $u1 = ($src[0] + $src[2]) / $sw;        if ($flip) {            [$u0, $u1] = [$u1, $u0];        }        $v0 = $src[1] / $sh;        $v1 = ($src[1] + $src[3]) / $sh;        $ax = $left + $w / 2;        $ay = $top + $h;        $px = $ax;        $pz = $z;        if ($cam !== null && $push != 0.0) {            $px += $cam->forward[0] * $push;            $pz += $cam->forward[2] * $push;        }        [$r, $g, $b, $a] = $color ?? [1.0, 1.0, 1.0, 1.0];        $hw = $w / 2;        $gr = &$this->group($name);        array_push($gr['v'],            $px, -$ay, $pz, -$hw, $h, $u0, $v0, $r, $g, $b, $a,            $px, -$ay, $pz, $hw, $h, $u1, $v0, $r, $g, $b, $a,            $px, -$ay, $pz, $hw, 0.0, $u1, $v1, $r, $g, $b, $a,            $px, -$ay, $pz, -$hw, 0.0, $u0, $v1, $r, $g, $b, $a);        $gr['n']++;    }    /** A quad in 3D (4 corners: a b c d), one colour. @param float[] $a @param float[] $b @param float[] $c @param float[] $d @param float[] $color */    private function quad(string $name, array $a, array $b, array $c, array $d, float $u0, float $v0, float $u1, float $v1, array $color): void    {        $this->quadColors($name, $a, $b, $c, $d, $color, $color, $u0, $v0, $u1, $v1);    }    /** A quad in 3D, $top colour on a b, $bottom on c d. @param float[] $a @param float[] $b @param float[] $c @param float[] $d @param float[] $top @param float[] $bottom */    private function quadColors(string $name, array $a, array $b, array $c, array $d, array $top, array $bottom, float $u0 = 0.5, float $v0 = 0.5,                                float $u1 = 0.5, float $v1 = 0.5): void    {        $gr = &$this->group($name);        [$tr, $tg, $tb, $ta] = $top;        [$br, $bg, $bb, $ba] = $bottom;        array_push($gr['v'],            $a[0], $a[1], $a[2], 0.0, 0.0, $u0, $v0, $tr, $tg, $tb, $ta,            $b[0], $b[1], $b[2], 0.0, 0.0, $u1, $v0, $tr, $tg, $tb, $ta,            $c[0], $c[1], $c[2], 0.0, 0.0, $u1, $v1, $br, $bg, $bb, $ba,            $d[0], $d[1], $d[2], 0.0, 0.0, $u0, $v1, $br, $bg, $bb, $ba);        $gr['n']++;    }    /**     * Is a Lumikin (feet at x, y) hidden by the ground from the camera? The rays from its body to the eye, between the     * middle plane and the front of the terrain, are sampled on the mask.     */    public static function hidden(Camera3d $cam, \App\Sim\Terrain $t, float $x, float $y): bool    {        [$ex, $ey, $ez] = $cam->eye;        if ($ez <= 0) {            return false;        }        $front = Camera3d::DEPTH / 2;        foreach ([4.0, 11.0] as $up) {            $py = -($y - $up);            for ($i = 1; $i <= 6; $i++) {                $k = $front / $ez * $i / 6;                $qx = (int)floor($x + ($ex - $x) * $k);                $qy = (int)floor(-($py + ($ey - $py) * $k));                if ($qx >= 0 && $qx < $t->width && $qy >= 0 && $qy < $t->height && $t->mask[$qy * $t->width + $qx] !== "\0") {                    return true;                }            }        }        return false;    }    /** @return array{buf: int, v: float[], n: int} */    private function &group(string $name): array    {        if (!isset($this->groups[$name])) {            $this->groups[$name] = ['buf' => 0, 'v' => [], 'n' => 0];        }        return $this->groups[$name];    }    /**     * Sends a batch (once per frame) and draws it. $options: depth, blend, cut (alpha below = not drawn), alpha, wrap.     * @param float[] $mvp @param float[] $right @param array<string, mixed> $options     */    private function drawGroup(string $name, array $mvp, array $right, array $options, bool $send = true): void    {        $gr = &$this->group($name);        $n = min($gr['n'], self::MAX_QUADS);        $texName = self::GROUP_PICTURE[$name] ?? $name;        if ($n === 0 || ($this->tex[$texName] ?? 0) === 0) {            return;        }        if ($send) {            if ($gr['buf'] === 0) {                $gr['buf'] = $this->gl->buffer($gr['v'], true);            } else {                $this->gl->updateBuffer($gr['buf'], $gr['v']);            }        }        $buf = $gr['buf'];        $this->gl->draw($this->cardProg, [            'attributes' => [                'a_pos' => [$buf, 3, self::FLOATS, 0], 'a_corner' => [$buf, 2, self::FLOATS, 3],                'a_uv' => [$buf, 2, self::FLOATS, 5], 'a_color' => [$buf, 4, self::FLOATS, 7],            ],            'uniforms' => [                'u_mvp' => $mvp, 'u_right' => $right, 'u_argb' => $this->argb ? 1.0 : 0.0, 'u_cut' => (float)($options['cut'] ?? 0.1),                'u_alpha' => (float)($options['alpha'] ?? 1.0), 'u_wrap' => (float)($options['wrap'] ?? 0.0),                'u_outline' => $options['outline'] ?? [0.0, 0.0, 0.0, 0.0], 'u_texel' => $options['texel'] ?? [0.0, 0.0],            ],            'textures' => ['u_tex' => $this->tex[$texName]],            'indices' => $this->indices,            'count' => $n * 6,            'depth' => $options['depth'] ?? true,            'blend' => $options['blend'] ?? true,        ]);    }    /** Frees every GL resource (the level ends). */    public function release(): void    {        if (!$this->loaded) {            return;        }        foreach ($this->chunks as $ch) {            $this->gl->delete($ch['buf']);            $this->gl->delete($ch['tex']);        }        foreach ($this->groups as $g) {            $this->gl->delete($g['buf']);        }        foreach ($this->tex as $id) {            $this->gl->delete($id);        }        $this->gl->delete($this->indices);        $this->gl->delete($this->terrainProg);        $this->gl->delete($this->cardProg);        $this->chunks = [];        $this->groups = [];        $this->tex = [];        $this->loaded = false;    }}