Inloggen
NL
games/doom-jumphp / tools / art / Raster3d.php · commit 8e1cb9f
<?phpnamespace Tools\Art;use App\Engine\Palette;/** * A tiny ray tracer of shaded primitives (ellipsoids, capsules, boxes) into palette pictures: the monsters, items * and weapons are 3D models of a few primitives, "photographed" from 8 angles like the clay models of the 90s. * Offline only (tools/make-art.php): slow, exact, deterministic. * * Model space: x forward, y left, z up, origin between the feet, units = map units = pixels. */final class Raster3d{    /** @var array<int, array<string, mixed>> */    private array $prims = [];    /** An ellipsoid; $axes = 3 unit vectors (local x, y, z) or null for axis aligned. */    public function ellipsoid(array $c, array $r, int $ramp, array $o = []): self    {        $this->prims[] = ['t' => 'e', 'c' => $c, 'r' => $r, 'ramp' => $ramp, 'ax' => $o['axes'] ?? null] + $o;        return $this;    }    public function sphere(array $c, float $r, int $ramp, array $o = []): self    {        return $this->ellipsoid($c, [$r, $r, $r], $ramp, $o);    }    public function capsule(array $a, array $b, float $r, int $ramp, array $o = []): self    {        $this->prims[] = ['t' => 'c', 'a' => $a, 'b' => $b, 'rad' => $r, 'ramp' => $ramp] + $o;        return $this;    }    /** A box: centre, half sizes, optional yaw / pitch / roll (radians) */    public function box(array $c, array $h, int $ramp, array $o = []): self    {        $axes = self::axes($o['yaw'] ?? 0.0, $o['pitch'] ?? 0.0, $o['roll'] ?? 0.0);        $this->prims[] = ['t' => 'b', 'c' => $c, 'h' => $h, 'ramp' => $ramp, 'ax' => $axes] + $o;        return $this;    }    /** Rotation axes (columns of the local frame): yaw around z, then pitch around y, then roll around x. */    public static function axes(float $yaw, float $pitch, float $roll): array    {        $cy = cos($yaw); $sy = sin($yaw); $cp = cos($pitch); $sp = sin($pitch); $cr = cos($roll); $sr = sin($roll);        $x = [$cy * $cp, $sy * $cp, -$sp];        $y = [$cy * $sp * $sr - $sy * $cr, $sy * $sp * $sr + $cy * $cr, $cp * $sr];        $z = [$cy * $sp * $cr + $sy * $sr, $sy * $sp * $cr - $cy * $sr, $cp * $cr];        return [$x, $y, $z];    }    /** Applies a rigid transform to every primitive added since $from: rotation about the origin point $pivot. */    public function transform(int $from, array $pivot, float $yaw, float $pitch, float $roll = 0.0, array $move = [0, 0, 0], array $scale = [1, 1, 1]): void    {        $m = self::axes($yaw, $pitch, $roll);        $tp = function (array $p) use ($m, $pivot, $move, $scale): array {            $d = [($p[0] - $pivot[0]) * $scale[0], ($p[1] - $pivot[1]) * $scale[1], ($p[2] - $pivot[2]) * $scale[2]];            return [                $pivot[0] + $move[0] + $m[0][0] * $d[0] + $m[1][0] * $d[1] + $m[2][0] * $d[2],                $pivot[1] + $move[1] + $m[0][1] * $d[0] + $m[1][1] * $d[1] + $m[2][1] * $d[2],                $pivot[2] + $move[2] + $m[0][2] * $d[0] + $m[1][2] * $d[1] + $m[2][2] * $d[2],            ];        };        $tv = fn (array $v): array => [            $m[0][0] * $v[0] + $m[1][0] * $v[1] + $m[2][0] * $v[2],            $m[0][1] * $v[0] + $m[1][1] * $v[1] + $m[2][1] * $v[2],            $m[0][2] * $v[0] + $m[1][2] * $v[1] + $m[2][2] * $v[2],        ];        $avgScale = ($scale[0] + $scale[1] + $scale[2]) / 3;        for ($i = $from, $n = count($this->prims); $i < $n; $i++) {            $p = &$this->prims[$i];            if ($p['t'] === 'c') {                $p['a'] = $tp($p['a']);                $p['b'] = $tp($p['b']);                $p['rad'] *= $avgScale;            } else {                $p['c'] = $tp($p['c']);                $ax = $p['ax'] ?? [[1, 0, 0], [0, 1, 0], [0, 0, 1]];                $p['ax'] = [$tv($ax[0]), $tv($ax[1]), $tv($ax[2])];                if ($p['t'] === 'e') {                    $p['r'] = [$p['r'][0] * $scale[0], $p['r'][1] * $scale[1], $p['r'][2] * $scale[2]];                } else {                    $p['h'] = [$p['h'][0] * $scale[0], $p['h'][1] * $scale[1], $p['h'][2] * $scale[2]];                }            }            unset($p);        }    }    public function count(): int    {        return count($this->prims);    }    /**     * Photographs the model. Orthographic camera on the circle at angle $view (0 = in front of the model, looking at     * its face), $elevation radians above. Returns a sprite: ['w','h','left','top','px' => row-major palette string].     * $left / $top: where the model's origin is (pixels from the left edge, height above the origin of the top row).     */    public function render(float $view, float $elevation = 0.0, int $size = 128, float $scale = 1.0, ?array $persp = null): array    {        $ce = cos($elevation);        $se = sin($elevation);        // camera forward (looking at the origin), right, up        $f = [-cos($view) * $ce, -sin($view) * $ce, -$se];        $right = [-sin($view), cos($view), 0.0];        $up = [$f[1] * $right[2] - $f[2] * $right[1], $f[2] * $right[0] - $f[0] * $right[2], $f[0] * $right[1] - $f[1] * $right[0]];        $up = [-$up[0], -$up[1], -$up[2]];        $light = self::norm([-0.45, 0.55, 0.7]);   // camera space: from the upper left, in front        $lightW = [            $right[0] * $light[0] + $up[0] * $light[1] - $f[0] * $light[2],            $right[1] * $light[0] + $up[1] * $light[1] - $f[1] * $light[2],            $right[2] * $light[0] + $up[2] * $light[1] - $f[2] * $light[2],        ];        $w = $persp['w'] ?? $size;        $h = $persp['h'] ?? $size;        $ox = $persp['ox'] ?? $size / 2;        $oy = $persp['oy'] ?? $size * 0.8;        // screen bounds of each primitive (orthographic only)        $boxes = [];        foreach ($this->prims as $i => $p) {            if ($p['t'] === 'c') {                $pts = [$p['a'], $p['b']];                $rad = $p['rad'];            } else {                $pts = [$p['c']];                $rad = $p['t'] === 'e' ? max($p['r']) : sqrt($p['h'][0] ** 2 + $p['h'][1] ** 2 + $p['h'][2] ** 2);            }            $minx = $miny = INF;            $maxx = $maxy = -INF;            foreach ($pts as $q) {                if (isset($persp['eye'])) {                    $e = $persp['eye'];                    $rel = [$q[0] - $e[0], $q[1] - $e[1], $q[2] - $e[2]];                    $z = self::dot($rel, $f);                    if ($z < $rad + 1) {                        $minx = $miny = -INF;                        $maxx = $maxy = INF;                        continue;                    }                    $k = $persp['focal'] / ($z - $rad);                    $sx = $ox + self::dot($rel, $right) * $persp['focal'] / $z;                    $sy = $oy - self::dot($rel, $up) * $persp['focal'] / $z;                    $minx = min($minx, $sx - $rad * $k); $maxx = max($maxx, $sx + $rad * $k);                    $miny = min($miny, $sy - $rad * $k); $maxy = max($maxy, $sy + $rad * $k);                    continue;                }                $sx = $ox + self::dot($q, $right) * $scale;                $sy = $oy - self::dot($q, $up) * $scale;                $minx = min($minx, $sx - $rad * $scale); $maxx = max($maxx, $sx + $rad * $scale);                $miny = min($miny, $sy - $rad * $scale); $maxy = max($maxy, $sy + $rad * $scale);            }            $boxes[$i] = [$minx - 1, $miny - 1, $maxx + 1, $maxy + 1];        }        $pix = array_fill(0, $w * $h, Palette::TRANSPARENT);        for ($py = 0; $py < $h; $py++) {            for ($px = 0; $px < $w; $px++) {                if ($persp !== null && isset($persp['eye'])) {                    // perspective: camera at $persp['eye'], looking along f; focal length in pixels                    $eye = $persp['eye'];                    $fx = ($px + 0.5 - $ox) / $persp['focal'];                    $fy = ($oy - $py - 0.5) / $persp['focal'];                    $d = self::norm([$f[0] + $right[0] * $fx + $up[0] * $fy, $f[1] + $right[1] * $fx + $up[1] * $fy, $f[2] + $right[2] * $fx + $up[2] * $fy]);                    $o = $eye;                    $cand = [];                    foreach ($boxes as $i => $b) {                        if ($px >= $b[0] && $px <= $b[2] && $py >= $b[1] && $py <= $b[3]) {                            $cand[] = $i;                        }                    }                } else {                    $u = ($px + 0.5 - $ox) / $scale;                    $v = ($oy - $py - 0.5) / $scale;                    $o = [$right[0] * $u + $up[0] * $v - $f[0] * 500, $right[1] * $u + $up[1] * $v - $f[1] * 500, $right[2] * $u + $up[2] * $v - $f[2] * 500];                    $d = $f;                    $cand = [];                    foreach ($boxes as $i => $b) {                        if ($px >= $b[0] && $px <= $b[2] && $py >= $b[1] && $py <= $b[3]) {                            $cand[] = $i;                        }                    }                }                $best = INF;                $hitN = null;                $hitP = null;                foreach ($cand as $i) {                    $r = $this->hit($this->prims[$i], $o, $d);                    if ($r !== null && $r[0] < $best) {                        $best = $r[0];                        $hitN = $r[1];                        $hitP = $i;                    }                }                if ($hitP === null) {                    continue;                }                $p = $this->prims[$hitP];                $pos = [$o[0] + $d[0] * $best, $o[1] + $d[1] * $best, $o[2] + $d[2] * $best];                $pix[$py * $w + $px] = $this->shadeOf($p, $hitN, $lightW, $d, $pos);            }        }        return self::crop(self::outline($pix, $w, $h), $w, $h, $ox, $oy, $scale);    }    private function shadeOf(array $p, array $n, array $light, array $d, array $pos): int    {        $ramp = $p['ramp'];        if (!empty($p['emit'])) {            $facing = max(0.0, -self::dot($n, $d));            return Palette::index($ramp, (int)round(11 + 4 * $facing));        }        $lambert = max(0.0, self::dot($n, $light));        $rim = 1.0 - max(0.0, -self::dot($n, $d));        $base = $p['bright'] ?? 0;        $s = 2.5 + 11.0 * (0.18 + 0.82 * $lambert) - 2.0 * $rim + $base;        if (!empty($p['noise'])) {            $s += (Noise::value3($pos[0] * 0.35, $pos[1] * 0.35, $pos[2] * 0.35) - 0.5) * $p['noise'] * 4;        }        if (!empty($p['spec'])) {            $refl = 2 * self::dot($n, $light);            $rv = [$refl * $n[0] - $light[0], $refl * $n[1] - $light[1], $refl * $n[2] - $light[2]];            $s += max(0.0, -self::dot($rv, $d)) ** 12 * 5;        }        $max = $p['max'] ?? 15;        return Palette::index($ramp, (int)max(0, min($max, round($s))));    }    /** @return array{0: float, 1: array}|null distance and normal */    private function hit(array $p, array $o, array $d): ?array    {        if ($p['t'] === 'c') {            return self::hitCapsule($o, $d, $p['a'], $p['b'], $p['rad']);        }        $ax = $p['ax'] ?? [[1, 0, 0], [0, 1, 0], [0, 0, 1]];        $oc = [$o[0] - $p['c'][0], $o[1] - $p['c'][1], $o[2] - $p['c'][2]];        $lo = [self::dot($oc, $ax[0]), self::dot($oc, $ax[1]), self::dot($oc, $ax[2])];        $ld = [self::dot($d, $ax[0]), self::dot($d, $ax[1]), self::dot($d, $ax[2])];        if ($p['t'] === 'e') {            $r = $p['r'];            $so = [$lo[0] / $r[0], $lo[1] / $r[1], $lo[2] / $r[2]];            $sd = [$ld[0] / $r[0], $ld[1] / $r[1], $ld[2] / $r[2]];            $a = self::dot($sd, $sd);            $b = self::dot($so, $sd);            $c = self::dot($so, $so) - 1;            $disc = $b * $b - $a * $c;            if ($disc < 0) {                return null;            }            $t = (-$b - sqrt($disc)) / $a;            if ($t < 0) {                return null;            }            $hp = [$lo[0] + $ld[0] * $t, $lo[1] + $ld[1] * $t, $lo[2] + $ld[2] * $t];            $ln = [$hp[0] / ($r[0] ** 2), $hp[1] / ($r[1] ** 2), $hp[2] / ($r[2] ** 2)];        } else {            $h = $p['h'];            $tmin = -INF;            $tmax = INF;            $axis = 0;            $sign = 1;            for ($k = 0; $k < 3; $k++) {                if (abs($ld[$k]) < 1e-9) {                    if ($lo[$k] < -$h[$k] || $lo[$k] > $h[$k]) {                        return null;                    }                    continue;                }                $t1 = (-$h[$k] - $lo[$k]) / $ld[$k];                $t2 = ($h[$k] - $lo[$k]) / $ld[$k];                $s = -1;                if ($t1 > $t2) {                    [$t1, $t2] = [$t2, $t1];                    $s = 1;                }                if ($t1 > $tmin) {                    $tmin = $t1;                    $axis = $k;                    $sign = $s;                }                $tmax = min($tmax, $t2);                if ($tmin > $tmax) {                    return null;                }            }            if ($tmin < 0) {                return null;            }            $t = $tmin;            $ln = [0, 0, 0];            $ln[$axis] = $sign;        }        $n = self::norm([            $ax[0][0] * $ln[0] + $ax[1][0] * $ln[1] + $ax[2][0] * $ln[2],            $ax[0][1] * $ln[0] + $ax[1][1] * $ln[1] + $ax[2][1] * $ln[2],            $ax[0][2] * $ln[0] + $ax[1][2] * $ln[1] + $ax[2][2] * $ln[2],        ]);        return [$t, $n];    }    private static function hitCapsule(array $o, array $d, array $a, array $b, float $r): ?array    {        $ba = [$b[0] - $a[0], $b[1] - $a[1], $b[2] - $a[2]];        $oa = [$o[0] - $a[0], $o[1] - $a[1], $o[2] - $a[2]];        $baba = self::dot($ba, $ba);        $bard = self::dot($ba, $d);        $baoa = self::dot($ba, $oa);        $rdoa = self::dot($d, $oa);        $oaoa = self::dot($oa, $oa);        $qa = $baba - $bard * $bard;        $qb = $baba * $rdoa - $baoa * $bard;        $qc = $baba * $oaoa - $baoa * $baoa - $r * $r * $baba;        $hh = $qb * $qb - $qa * $qc;        if ($hh >= 0 && $qa > 1e-9) {            $t = (-$qb - sqrt($hh)) / $qa;            $y = $baoa + $t * $bard;            if ($y > 0 && $y < $baba && $t > 0) {                $pnt = [$o[0] + $d[0] * $t, $o[1] + $d[1] * $t, $o[2] + $d[2] * $t];                $k = $y / $baba;                return [$t, self::norm([$pnt[0] - $a[0] - $ba[0] * $k, $pnt[1] - $a[1] - $ba[1] * $k, $pnt[2] - $a[2] - $ba[2] * $k])];            }        }        // the caps        $best = null;        foreach ([$a, $b] as $cpt) {            $oc = [$o[0] - $cpt[0], $o[1] - $cpt[1], $o[2] - $cpt[2]];            $bb = self::dot($oc, $d);            $cc = self::dot($oc, $oc) - $r * $r;            $disc = $bb * $bb - $cc;            if ($disc >= 0) {                $t = -$bb - sqrt($disc);                if ($t > 0 && ($best === null || $t < $best[0])) {                    $pnt = [$o[0] + $d[0] * $t, $o[1] + $d[1] * $t, $o[2] + $d[2] * $t];                    $best = [$t, self::norm([$pnt[0] - $cpt[0], $pnt[1] - $cpt[1], $pnt[2] - $cpt[2]])];                }            }        }        return $best;    }    /** Darkens the silhouette's border pixels: the hand-drawn outline of pixel sprites. */    private static function outline(array $pix, int $w, int $h): array    {        $out = $pix;        $t = Palette::TRANSPARENT;        for ($y = 0; $y < $h; $y++) {            for ($x = 0; $x < $w; $x++) {                $i = $y * $w + $x;                if ($pix[$i] === $t) {                    continue;                }                $edge = ($x === 0 || $pix[$i - 1] === $t) || ($x === $w - 1 || $pix[$i + 1] === $t)                    || ($y === 0 || $pix[$i - $w] === $t) || ($y === $h - 1 || $pix[$i + $w] === $t);                if ($edge) {                    $c = $pix[$i];                    $out[$i] = ($c & 0xF0) + max(0, ($c & 15) - 4);                }            }        }        return $out;    }    /** Trims the transparent border. */    public static function crop(array $pix, int $w, int $h, float $ox, float $oy, float $scale = 1.0): array    {        $minx = $w; $miny = $h; $maxx = -1; $maxy = -1;        for ($y = 0; $y < $h; $y++) {            for ($x = 0; $x < $w; $x++) {                if ($pix[$y * $w + $x] !== Palette::TRANSPARENT) {                    $minx = min($minx, $x); $maxx = max($maxx, $x);                    $miny = min($miny, $y); $maxy = max($maxy, $y);                }            }        }        if ($maxx < 0) {            return ['w' => 1, 'h' => 1, 'left' => 0, 'top' => 0, 'px' => chr(Palette::TRANSPARENT)];        }        $cw = $maxx - $minx + 1;        $ch = $maxy - $miny + 1;        $s = '';        for ($y = $miny; $y <= $maxy; $y++) {            for ($x = $minx; $x <= $maxx; $x++) {                $s .= chr($pix[$y * $w + $x]);            }        }        return ['w' => $cw, 'h' => $ch, 'left' => (int)round($ox - $minx), 'top' => (int)round($oy - $miny), 'px' => $s];    }    public static function dot(array $a, array $b): float    {        return $a[0] * $b[0] + $a[1] * $b[1] + $a[2] * $b[2];    }    public static function norm(array $v): array    {        $l = sqrt($v[0] ** 2 + $v[1] ** 2 + $v[2] ** 2) ?: 1.0;        return [$v[0] / $l, $v[1] / $l, $v[2] / $l];    }}