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 picture name => texture id */ private array $tex = []; /** @var array 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 chunk index => its GL resources */ private array $chunks = []; /** @var array chunks to build again */ private array $dirty = []; /** @var array card batches of this frame, one per picture */ private array $groups = []; /** @var array 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 $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; } }