<?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]; }}