NL

Elfs & Orcs Tower Defense

door malabke · Strategie
5,0 (1)
▶ Spelen 💬 Reacties van spelers Broncode 🕘 Versies Bewerken GitHub
games/elfs-orcs-tower-defense / src / Ui / World3d.php · commit 65500d9
<?phpnamespace App\Ui;use App\View3d\GroundMap;use App\View3d\MapModel;use App\View3d\Mesh;use App\View3d\Props;use App\View3d\Rig;use App\View3d\Units;use JumPHP\Gl\WebGl;/** * The battle's 3D layer (WebGL in the browser, OpenGL on the desktop), painted like a storybook: one shader for * everything, the map built once as one buffer, and every tower, creature and projectile a model (one buffer each, * built the first time it is asked for) drawn with its own matrix. The 2D scene draws over it, composed by its * alpha: scenes clear the 2D with clearColour() (transparent) and whatever they draw over it (smooth text, * translucent panels of any colour) lies over the 3D as it is. The layer is at the view texture's resolution: with * the renderer's HD (game.yaml `hd: auto`) that is the window's. On an engine that cannot compose by alpha (the * playground's shared engine before it has this project's changes: see Hd) the 3D shows through the key colour KEY * instead: clearColour() is KEY, veil() turns translucent colours black, Paint::$keyed tells the interface. * * The shader reads the vertex's material (Mesh::$mat): the ground is painted from the map's GroundMap texture * (grass patches, the road with a wobbly edge, water with glints, soft shadows) and a tiling noise texture * (assets/textures/noise.png: patches, grain, clouds); leaves and grass sway in the wind and let the sun through; * specks of light float; soft shadows fade out at their edge. Light: a warm sun that wraps softly, a cool sky, * a warm bounce from the ground, cloud shadows drifting over everything, haze in the distance. * * Per frame: begin($rig, $time), then shadow() first, then draw() / disc() / ring() / ball(), glows last. */final class World3d{    /** What the 2D picture is cleared with where the 3D shows: transparent (alpha composing). */    public const CLEAR = [0, 0, 0, 0];    /** ... or this key colour, on an engine without alpha composing. */    public const KEY = [255, 0, 255];    private const VERTEX = <<<'GLSL'        attribute vec3 a_pos;        attribute vec3 a_normal;        attribute vec3 a_color;        attribute float a_mat;        uniform mat4 u_vp;        uniform mat4 u_model;        uniform float u_time;        uniform vec3 u_eye;        uniform vec4 u_rect;        uniform vec2 u_cloud;        uniform mat4 u_bones[16];        varying vec3 v_color;        varying vec3 v_normal;        varying vec3 v_view;        varying vec4 v_uv;        varying vec4 v_sky;        varying vec4 v_misc;        void main() {            vec4 rest = vec4(a_pos, 1.0);            vec3 nrm = a_normal;            float mat = a_mat;            if (a_mat > 9.5) {                // a creature's vertex, posed by its bone (Mesh::BONE + bone, METAL_BONE + bone: it shines)                float b = a_mat > 29.5 ? a_mat - 30.0 : a_mat - 10.0;                mat4 bone = u_bones[int(b + 0.5)];                rest = bone * rest;                nrm = (bone * vec4(a_normal, 0.0)).xyz;                mat = a_mat > 29.5 ? 7.0 : 0.0;            }            vec4 w = u_model * rest;            float extra = length(a_pos.xz);            if (mat > 0.01 && mat <= 1.0) {                // leaves, grass: the wind, more at the top                float ph = u_time * 1.7 + w.x * 0.05 + w.z * 0.037;                float sway = (sin(ph) + 0.35 * sin(ph * 2.7 + 1.3)) * mat * max(a_pos.y, 0.0) * 0.11;                w.x += sway;                w.z += sway * 0.35;            } else if (mat > 3.5 && mat < 5.0) {                // a speck of light drifting about                float s = (mat - 4.0) * 6.2831;                w.xyz += vec3(sin(u_time * 0.31 + s) * 10.0, sin(u_time * 0.47 + s * 2.0) * 4.0, cos(u_time * 0.23 + s * 3.0) * 8.0);                extra = 0.55 + 0.45 * sin(u_time * 2.3 + s * 5.0);            }            gl_Position = u_vp * w;            v_normal = (u_model * vec4(nrm, 0.0)).xyz;            v_color = a_color;            v_view = u_eye - w.xyz;            v_uv = vec4((w.xz - u_rect.xy) * u_rect.zw, w.xz * 0.01);            // the clip position as it is: the fragment shader divides it by w (exact where a triangle crosses an edge)            v_sky = vec4(w.xz * (1.0 / 700.0) + u_cloud, gl_Position.xy);            v_misc = vec4(a_pos.y, extra, mat, gl_Position.w);            if (mat > 2.5 && mat < 3.5) {                // the sky of a vignette: two sizes of clouds drifting by, and how low in its frame (-1 top, 1 bottom)                v_uv = vec4(w.xz * 0.021 - u_cloud * 1.7, w.xz * 0.01 + u_cloud);                v_misc.y = a_pos.z / 36.0;            }        }        GLSL;    private const FRAGMENT = <<<'GLSL'        #ifdef GL_FRAGMENT_PRECISION_HIGH        precision highp float;        #else        precision mediump float;        #endif        uniform vec3 u_light;        uniform vec3 u_sun;        uniform vec3 u_sky;        uniform vec3 u_bounce;        uniform vec3 u_tint;        uniform float u_alpha;        uniform float u_emit;        uniform float u_ao;        uniform vec3 u_fog;        uniform vec2 u_fogRange;        uniform vec3 u_grass0;        uniform vec3 u_grass1;        uniform vec3 u_grass2;        uniform vec3 u_dirt0;        uniform vec3 u_dirt1;        uniform vec3 u_water0;        uniform vec3 u_water1;        uniform float u_lava;        uniform vec2 u_flow;        uniform sampler2D u_ground;        uniform sampler2D u_noise;        uniform vec4 u_clip;        uniform vec4 u_vig;        uniform float u_fade;        varying vec3 v_color;        varying vec3 v_normal;        varying vec3 v_view;        varying vec4 v_uv;        varying vec4 v_sky;        varying vec4 v_misc;        void main() {            // where it lands on the screen (-1..1)            vec2 ndc = v_sky.zw / v_misc.w;            // only inside the frame of a vignette (the whole screen otherwise)            if (ndc.x < u_clip.x || ndc.y < u_clip.y || ndc.x > u_clip.z || ndc.y > u_clip.w) {                discard;            }            // the corners of the picture (the screen's, or a vignette's frame) a little darker            vec2 corner = (ndc - u_vig.xy) * u_vig.zw;            float edge = 1.0 - 0.12 * dot(corner, corner);            float mat = v_misc.z;            if (mat > 5.5 && mat < 6.5) {                // a soft shadow: its colour (u_tint), fading out towards its edge                gl_FragColor = vec4(u_tint * u_fade, u_alpha * (1.0 - smoothstep(0.2, 1.0, v_misc.y)));                return;            }            if (mat > 2.5 && mat < 3.5) {                // the sky of a vignette: its colours as they are, soft clouds drifting by, thicker low down                float c = texture2D(u_noise, v_uv.zw).a * 0.65 + texture2D(u_noise, v_uv.xy).a * 0.35;                float puff = smoothstep(0.5, 0.72, c) * mix(0.3, 0.9, smoothstep(-0.55, 0.85, v_misc.y));                gl_FragColor = vec4(mix(v_color, vec3(1.0, 0.97, 0.93), puff) * edge * u_fade, 1.0);                return;            }            vec3 n = normalize(v_normal);            vec3 base = v_color * u_tint;            float sunVis = 1.0;            float open = 1.0;            float wet = 0.0;            vec3 glow = vec3(0.0);            if (mat > 1.5 && mat < 2.5) {                vec4 g = texture2D(u_ground, v_uv.xy);                // three sizes of the noise, turned so that its grid does not show                vec2 q = v_uv.zw;                vec4 nBig = texture2D(u_noise, vec2(q.x * 0.604 - q.y * 0.354, q.x * 0.354 + q.y * 0.604));                vec4 nMid = texture2D(u_noise, vec2(q.x * 1.678 + q.y * 2.115, q.y * 1.678 - q.x * 2.115));                vec4 nFine = texture2D(u_noise, q * 9.0);                // grass: soft patches of a deep and a light green, sunny tips, a brush grain                vec3 grass = mix(u_grass0, u_grass1, smoothstep(0.25, 0.75, nBig.r * 0.7 + nMid.g * 0.3));                grass = mix(grass, u_grass2, smoothstep(0.6, 0.9, nMid.r) * 0.5);                grass *= 0.9 + 0.2 * nFine.b;                // the road: its edge wobbles, lighter in the middle, a trodden darker band beside it                float r = g.r + (nMid.b - 0.5) * 0.24 + (nFine.g - 0.5) * 0.08;                float road = smoothstep(0.46, 0.54, r);                vec3 dirt = mix(u_dirt1, u_dirt0, clamp(smoothstep(0.5, 0.95, g.r) * 0.7 + nMid.g * 0.35, 0.0, 1.0));                dirt *= 0.93 + 0.14 * nFine.r;                grass *= 1.0 - 0.18 * smoothstep(0.3, 0.47, r) * (1.0 - road);                base = mix(grass, dirt, road) * v_color;                // water (lava on burnt land): a dark wet shore, a light edge, glints that move                float wv = g.g + (nMid.r - 0.5) * 0.22;                float water = smoothstep(0.48, 0.56, wv);                base *= 1.0 - 0.28 * smoothstep(0.28, 0.5, wv) * (1.0 - water) * (1.0 - u_lava);                // deep in the middle, clear in the shallows, glints that move, a light line along the shore                vec3 wc = mix(u_water0, u_water1, clamp(nMid.g * 0.3 + (1.0 - smoothstep(0.55, 0.95, wv)) * 0.45, 0.0, 1.0));                float glint = texture2D(u_noise, v_uv.zw * 6.0 + u_flow).b * texture2D(u_noise, v_uv.zw * 3.7 - u_flow * 0.7).g;                wc += vec3(smoothstep(0.42, 0.6, glint)) * 0.4;                wc = mix(wc, u_water1 * 1.15, (1.0 - smoothstep(0.0, 0.3, water - 0.12)) * 0.4);                base = mix(base, wc, water);                wet = water * (1.0 - u_lava);                glow = u_lava * water * wc * 0.8;                sunVis = g.b;                open = g.a;            }            // clouds passing over the sun            float cloud = mix(0.62, 1.0, smoothstep(0.3, 0.62, texture2D(u_noise, v_sky.xy).a));            float ndl = dot(n, u_light);            float direct = clamp((ndl + 0.3) / 1.3, 0.0, 1.0) * sunVis * cloud;            vec3 light = mix(u_bounce, u_sky, n.y * 0.5 + 0.5) * open + u_sun * direct;            if (mat > 0.01 && mat <= 1.0) {                // leaves let the sun through                light += u_sun * 0.25 * clamp(0.25 - ndl, 0.0, 1.0) * cloud;            }            float foot = mix(1.0, mix(0.58, 1.0, smoothstep(0.0, 7.0, v_misc.x)), u_ao);            vec3 col = base * light * foot;            vec3 eye = normalize(v_view);            float rim = pow(1.0 - clamp(dot(n, eye), 0.0, 1.0), 3.0);            col += u_sky * rim * 0.2 * u_ao;            if (mat > 6.5 && mat < 7.5) {                // metal: a glint of the sun, the sky in its edges                float spec = pow(max(dot(n, normalize(u_light + eye)), 0.0), 24.0) * sunVis * cloud;                col = col * 0.85 + u_sun * spec * 0.7 + u_sky * rim * 0.25;            }            col += wet * u_sky * 0.16 + glow;            if (mat > 3.5 && mat < 5.0) {                col = base * v_misc.y;            }            col = mix(col, base * 1.3, u_emit);            // painted grading: a little more colour, warm lights and cool shadows            float lum = dot(col, vec3(0.299, 0.587, 0.114));            col = mix(vec3(lum), col, 1.1);            col *= mix(vec3(0.93, 0.97, 1.07), vec3(1.04, 1.0, 0.93), smoothstep(0.15, 0.85, lum));            float f = clamp((v_misc.w - u_fogRange.x) / (u_fogRange.y - u_fogRange.x), 0.0, 1.0);            col = mix(col, u_fog, f * 0.7);            // a layer over the 2D darkens with the scene fades itself (u_fade: how much colour is left)            gl_FragColor = vec4(col * edge * u_fade, u_alpha);        }        GLSL;    private const SUN_COLOUR = [0.98, 0.88, 0.70];    private const SKY_COLOUR = [0.46, 0.56, 0.76];    private const BOUNCE_COLOUR = [0.40, 0.38, 0.28];    private ?WebGl $gl = null;    private int $prog = 0;    private int $noise = 0;    /** @var array<string, array{0: int, 1: int}> model key => [buffer, vertices] */    private array $models = [];    /** @var array{key: string, buffer: int, count: int, fog: array<int, float>, texture: int, palette: array<string, array<int, float>>, lava: float}|null */    private ?array $scene = null;    /** @var array{0: float, 1: float, 2: float} */    private array $light;    public function __construct()    {        [$x, $y, $z] = MapModel::SUN;        $len = sqrt($x * $x + $y * $y + $z * $z);        $this->light = [$x / $len, $y / $len, $z / $len];    }    /**     * Turns the 3D layer on (below the 2D, composed by its alpha, or through KEY on an older engine; smoothed).     * False without WebGL / OpenGL.     */    public function boot(WebGl $gl): bool    {        if (!$gl->isAvailable()) {            return false;        }        $this->gl = $gl;        if ($this->prog === 0) {            $this->prog = $gl->program(self::VERTEX, self::FRAGMENT);            $this->noise = $gl->texture('assets/textures/noise.png', true, true);        }        if ($this->prog === 0) {            return false;        }        if (Hd::alphaLayers()) {            $gl->setLayer('below', 'view', null);            Paint::$keyed = false;        } else {            $gl->setLayer('below', 'native', self::KEY);            Paint::$keyed = true;        }        $gl->setSupersample(2);        return true;    }    /** The 3D layer off (a scene that showed it ends). */    public function shutdown(WebGl $gl): void    {        $gl->disable();        Paint::$keyed = false;    }    /** What the 2D must be cleared with where the 3D shows. @return int[] */    public function clearColour(): array    {        return Paint::$keyed ? self::KEY : self::CLEAR;    }    /** A translucent colour laid over the 3D: as it is, or black over a key-coloured layer (only black darkens it well). */    public static function veil(array $rgba): array    {        return Paint::$keyed ? [0, 0, 0, $rgba[3] ?? 255] : $rgba;    }    /** Builds the land of a map (once per map and side's colours). */    public function loadMap(string $id, array $map, array $colors): void    {        $key = $id . '|' . ($colors['main'] ?? '');        if ($this->gl === null || ($this->scene['key'] ?? '') === $key) {            return;        }        $built = MapModel::build($map, $colors);        $mesh = $built['mesh'];        // one buffer and one texture for the land, their contents replaced from map to map        if ($this->scene === null) {            $buffer = $this->gl->buffer($mesh->v);            $texture = $this->gl->textureFromRgba(GroundMap::W, GroundMap::H, $built['ground'], true);        } else {            $buffer = $this->scene['buffer'];            $texture = $this->scene['texture'];            $this->gl->updateBuffer($buffer, $mesh->v);            $this->gl->textureFromRgba(GroundMap::W, GroundMap::H, $built['ground'], true, $texture);        }        $this->scene = [            'key' => $key,            'buffer' => $buffer,            'count' => $mesh->vertexCount(),            'fog' => $built['fog'],            'texture' => $texture,            'palette' => $built['palette'],            'lava' => $built['lava'] ? 1.0 : 0.0,        ];    }    /** Starts a frame: clears, then draws the land with the camera, the light and the time (wind, clouds, water). */    public function begin(Rig $rig, float $time = 0.0): void    {        if ($this->gl === null || $this->scene === null) {            return;        }        $this->inStudio = false;        $this->pending = [];        $fog = $this->scene['fog'];        [$rx, $rz, $rw, $rh] = MapModel::RECT;        $this->gl->clear([(int)($fog[0] * 255), (int)($fog[1] * 255), (int)($fog[2] * 255), 255]);        // the textures stay bound for every draw of the frame (no other draw binds one)        $this->gl->draw($this->prog, $this->options($this->scene['buffer'], $this->scene['count'], [            'u_vp' => $rig->viewProj(),            'u_eye' => $rig->eye(),            'u_time' => fmod($time, 3600.0),            'u_rect' => [$rx, $rz, 1 / $rw, 1 / $rh],            // texture offsets wrapped here: the shader's own floats may be short            'u_cloud' => [fmod($time * 0.006, 1.0), fmod($time * 0.0025, 1.0)],            'u_flow' => [fmod($time * 0.021, 1.0), fmod($time * 0.013, 1.0)],            'u_light' => $this->light,            'u_sun' => self::SUN_COLOUR,            'u_sky' => Mesh::mix(self::SKY_COLOUR, $fog, 0.3),            'u_bounce' => self::BOUNCE_COLOUR,            'u_fog' => $fog,            'u_fogRange' => [560.0, 1400.0],            'u_lava' => $this->scene['lava'],            'u_clip' => self::NO_CLIP,            'u_vig' => self::WHOLE_SCREEN,            'u_fade' => 1.0,            'u_model' => self::matrix(0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0),            'u_tint' => [1.0, 1.0, 1.0],            'u_alpha' => 1.0,            'u_emit' => 0.0,            'u_ao' => 0.0,        ] + $this->scene['palette'], false, ['u_ground' => $this->scene['texture'], 'u_noise' => $this->noise]));    }    /**     * Starts a frame without any land: the layer cleared with $background, the same light (no fog, no clouds).     * For pictures of single models (PortraitScene: the portraits of the interface). $fade: how much of the colours     * is left (1: all; a layer over the 2D darkens with the scene fades by it).     * @param int[] $background     */    public function studio(Rig $rig, ?array $background, ?array $vp = null, array $clip = self::NO_CLIP, float $fade = 1.0): void    {        if ($this->gl === null) {            return;        }        $this->inStudio = true;        if ($background !== null) {            $this->gl->clear([$background[0], $background[1], $background[2], 255]);        }        // the uniforms shared by the frame's draws go with its first one (begin() sends them with the land)        $this->pending = [            'u_clip' => $clip,            'u_vig' => self::WHOLE_SCREEN,            'u_fade' => max(0.0, min(1.0, $fade)),            'u_vp' => $vp ?? $rig->viewProj(),            'u_eye' => $rig->eye(),            'u_time' => 0.0,            'u_rect' => [0.0, 0.0, 1.0, 1.0],            'u_cloud' => [0.0, 0.0],            'u_flow' => [0.0, 0.0],            'u_light' => $this->light,            'u_sun' => self::SUN_COLOUR,            'u_sky' => self::SKY_COLOUR,            'u_bounce' => self::BOUNCE_COLOUR,            'u_fog' => self::SKY_COLOUR,            'u_fogRange' => [90000.0, 99000.0],            'u_lava' => 0.0,        ];    }    /** u_clip that lets everything through */    private const NO_CLIP = [-9.0, -9.0, 9.0, 9.0];    /** u_vig: the corners darkened are the screen's (centre, 1 / half size) */    private const WHOLE_SCREEN = [0.0, 0.0, 1.0, 1.0];    /**     * The 3D layer OVER the 2D, transparent where nothing is drawn: small 3D scenes on a 2D screen (the vignettes of     * the menus). Works on every engine (no alpha composing needed). False without WebGL / OpenGL.     */    public function bootOver(WebGl $gl): bool    {        if (!$gl->isAvailable()) {            return false;        }        $this->gl = $gl;        if ($this->prog === 0) {            $this->prog = $gl->program(self::VERTEX, self::FRAGMENT);            $this->noise = $gl->texture('assets/textures/noise.png', true, true);        }        if ($this->prog === 0) {            return false;        }        $gl->setLayer('above', 'native');        Paint::$keyed = false;        $gl->setSupersample(2);        return true;    }    /**     * Starts a vignette: a small 3D scene seen by $rig (made with the frame's size) drawn into the frame     * [$x, $y, $w, $h] of a $viewW x $viewH screen; nothing of it shows outside the frame. Its sky comes first,     * opaque: no 2D shows through the frame (no dark fringe round the models; the soft shadows' blending lets a     * little of what is under the frame through: keep it dark there). Then draw() its models. The layer is over     * the 2D, so the scene fades do not darken it: give their level as $fade (see studio()). $time: the wind, the     * clouds.     * @param array{0: float, 1: float, 2: float, 3: float} $frame     */    public function vignette(Rig $rig, array $frame, float $viewW, float $viewH, float $fade = 1.0, float $time = 0.0): void    {        [$x, $y, $w, $h] = $frame;        $sx = $w / $viewW;        $sy = $h / $viewH;        $cx = ($x + $w / 2) / $viewW * 2 - 1;        $cy = 1 - ($y + $h / 2) / $viewH * 2;        // the frame's own projection, squeezed into its place on the screen (clip space: x' = sx x + cx w)        $vp = $rig->viewProj();        for ($c = 0; $c < 4; $c++) {            $wc = $vp[$c * 4 + 3];            $vp[$c * 4] = $sx * $vp[$c * 4] + $cx * $wc;            $vp[$c * 4 + 1] = $sy * $vp[$c * 4 + 1] + $cy * $wc;        }        $this->studio($rig, null, $vp, [$cx - $sx, $cy - $sy, $cx + $sx, $cy + $sy], $fade);        if ($this->gl === null) {            return;        }        $this->pending['u_vig'] = [$cx, $cy, 1 / $sx, 1 / $sy];        $this->pending['u_time'] = fmod($time, 3600.0);        $this->pending['u_cloud'] = [fmod($time * 0.012, 1.0), fmod($time * 0.004, 1.0)];        [$buffer, $count] = $this->model('vignette:sky', static fn () => self::sky());        $this->gl->draw($this->prog, $this->options($buffer, $count, [            // the sky's quad laid on the frame, at the very back            'u_vp' => [$sx / self::SKY_W, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -$sy / self::SKY_H, 0.0, 0.0, $cx, $cy, 0.9999, 1.0],            'u_model' => self::matrix(0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0),            'u_tint' => [1.0, 1.0, 1.0],            'u_alpha' => 1.0,            'u_emit' => 0.0,            'u_ao' => 0.0,        ] + $this->pending, false, ['u_ground' => $this->noise, 'u_noise' => $this->noise]));        // the models that follow: the camera's projection again        $this->pending = ['u_vp' => $vp];    }    /** half the size of the sky's quad: x -SKY_W..SKY_W, z -SKY_H..SKY_H (the noise's clouds: a few per frame) */    private const SKY_W = 60.0;    private const SKY_H = 36.0;    /** The sky of the vignettes: blue above, a warm light at the horizon, a misty blue below (the island floats). */    private static function sky(): Mesh    {        $m = (new Mesh())->material(Mesh::SKY);        $rows = [[-1.05, [0.47, 0.64, 0.86]], [0.18, [0.96, 0.88, 0.74]], [1.05, [0.70, 0.76, 0.89]]];        [$w, $h] = [self::SKY_W * 1.05, self::SKY_H];        $up = [0.0, 1.0, 0.0];        for ($i = 0; $i < 2; $i++) {            [$z0, $c0] = [$rows[$i][0] * $h, $rows[$i][1]];            [$z1, $c1] = [$rows[$i + 1][0] * $h, $rows[$i + 1][1]];            $m->tri([-$w, 0.0, $z0], [$w, 0.0, $z0], [$w, 0.0, $z1], $up, $c0, $c0, $c1);            $m->tri([-$w, 0.0, $z0], [$w, 0.0, $z1], [-$w, 0.0, $z1], $up, $c0, $c1, $c1);        }        return $m;    }    /** @var array<string, mixed> uniforms for the next draw (studio()) */    private array $pending = [];    private bool $inStudio = false;    /**     * A model (built by $build the first time) at ($x, $y, $z), turned by $yaw about y, scaled.     * @param callable(): Mesh $build     * @param float|array{0: float, 1: float, 2: float} $scale     * @param array{0: float, 1: float, 2: float} $tint multiplies the colours (above 1: brighter)     * @param float $ao how much its foot is darkened (things standing on the ground: 1)     * @param list<float>|null $bones a posed creature's bone matrices (Skeleton::matrices())     */    public function draw(string $key, callable $build, float $x, float $y, float $z, float $yaw = 0.0, float|array $scale = 1.0,                         array $tint = [1.0, 1.0, 1.0], float $alpha = 1.0, float $emit = 0.0, float $ao = 1.0, ?array $bones = null): void    {        if ($this->gl === null || ($this->scene === null && !$this->inStudio)) {            return;        }        [$buffer, $count] = $this->model($key, $build);        if ($count === 0) {            return;        }        [$sx, $sy, $sz] = is_array($scale) ? $scale : [$scale, $scale, $scale];        $textures = $this->pending !== [] ? ['u_ground' => $this->noise, 'u_noise' => $this->noise] : [];        $uniforms = $this->pending + [            'u_model' => self::matrix($x, $y, $z, $yaw, $sx, $sy, $sz),            'u_tint' => $tint,            'u_alpha' => $alpha,            'u_emit' => $emit,            'u_ao' => $ao,        ];        if ($bones !== null) {            $uniforms['u_bones'] = $bones;        }        $this->gl->draw($this->prog, $this->options($buffer, $count, $uniforms, $alpha < 1.0, $textures));        $this->pending = [];    }    /**     * A creature (Units: an enemy, a soldier, a hero, with its rider when it has one) in a pose: $anim (walk, fight,     * idle, stun; fly for flyers) at $t (cycles for walk / fight / fly, seconds for idle / stun), its feet at     * ($x, $y, $z), facing $yaw (as draw()), $scale times its own size.     * @param array{0: float, 1: float, 2: float} $tint     */    public function creature(string $sprite, string $anim, float $t, float $x, float $y, float $z, float $yaw = 0.0, float $scale = 1.0,                             array $tint = [1.0, 1.0, 1.0], float $alpha = 1.0): void    {        if ($this->gl === null) {            return;        }        $k = $scale * Units::size($sprite);        foreach (Units::parts($sprite) as $part) {            $this->draw("c:$sprite:$part", static fn () => Units::build($sprite, $part), $x, $y, $z, $yaw, $k, $tint, $alpha, 0.0, 0.7,                Units::pose($sprite, $anim, $t, $part));        }    }    /**     * A soft shadow on the ground at height $y: $rx long (along $yaw), $rz wide. Draw them before what casts them.     * The shadows of one frame do not darken each other (they write the same depth).     */    public function shadow(float $x, float $z, float $rx, float $rz, float $yaw = 0.0, float $alpha = 0.42, float $y = 0.35): void    {        $this->draw('unit:shadow', static fn () => Props::softDisc(), $x, $y, $z, $yaw, [$rx, 1.0, $rz], [0.10, 0.12, 0.26], $alpha, 0.0, 0.0);    }    /** A flat disc on the ground, $r wide (ranges, blasts). */    public function disc(float $x, float $z, float $r, array $tint, float $alpha, float $y = 0.6, float $emit = 0.0): void    {        $this->draw('unit:disc', static fn () => Props::disc(), $x, $y, $z, 0.0, [$r, 1.0, $r], $tint, $alpha, $emit, 0.0);    }    /** A thin ring on the ground, $r wide. */    public function ring(float $x, float $z, float $r, array $tint, float $alpha, float $y = 0.8): void    {        $this->draw('unit:ring', static fn () => Props::ring(), $x, $y, $z, 0.0, [$r, 1.0, $r], $tint, $alpha, 1.0, 0.0);    }    /** A ball in the air, $r wide. */    public function ball(float $x, float $y, float $z, float $r, array $tint, float $alpha = 1.0, float $emit = 1.0): void    {        $this->draw('unit:ball', static fn () => Props::ball(), $x, $y, $z, 0.0, $r, $tint, $alpha, $emit, 0.0);    }    /** @return array{0: int, 1: int} */    private function model(string $key, callable $build): array    {        if (!isset($this->models[$key])) {            /** @var Mesh $mesh */            $mesh = $build();            $this->models[$key] = [$mesh->vertexCount() > 0 ? $this->gl->buffer($mesh->v) : 0, $mesh->vertexCount()];        }        return $this->models[$key];    }    /** @return array<string, mixed> */    private function options(int $buffer, int $count, array $uniforms, bool $blend, array $textures = []): array    {        $s = Mesh::STRIDE;        return [            'attributes' => ['a_pos' => [$buffer, 3, $s, 0], 'a_normal' => [$buffer, 3, $s, 3], 'a_color' => [$buffer, 3, $s, 6], 'a_mat' => [$buffer, 1, $s, 9]],            'count' => $count,            'uniforms' => $uniforms,            'textures' => $textures,            'blend' => $blend,        ];    }    /** x, y, z move, $yaw turns about y (+x towards -z), then the scale: 16 floats, column-major. @return float[] */    private static function matrix(float $x, float $y, float $z, float $yaw, float $sx, float $sy, float $sz): array    {        [$c, $s] = [cos($yaw), sin($yaw)];        return [$sx * $c, 0.0, -$sx * $s, 0.0, 0.0, $sy, 0.0, 0.0, $sz * $s, 0.0, $sz * $c, 0.0, $x, $y, $z, 1.0];    }}