# Draws the art for demos/neonCoast: every picture is made from shapes and arithmetic here, so it is # ours and can be drawn again. # # The sky is the important one. It is an equirectangular picture, the whole sphere around the # camera, and the 3D scene both shows it and lights with it: a chrome object reflects whatever is in # it. So besides the sunset and the stars it carries the striped sun straight ahead (the sky's centre # faces the camera's default view) and, in the lower half, the neon grid in perspective, as if a # floor ran to the horizon in every direction. Chrome then reflects the sun and the grid, which is # the whole look. # # Usage: python3 util/neonCoastArt.py import math import os import numpy as np from PIL import Image, ImageDraw, ImageFilter, ImageFont REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) OUT = os.path.join(REPO, "demos", "neonCoast", "art") SKY_W = 2048 SKY_H = 1024 SUN_LAT = 9.0 # Degrees above the horizon of the sun's centre SUN_RADIUS = 13.0 # Degrees GRID_CELL = 4.0 # The painted floor grid's cell, in camera heights FONT_BOLD = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" FONT_WIDE = "/usr/share/fonts/truetype/lato/Lato-BlackItalic.ttf" # The palette: night at the top, through violet and magenta, to orange at the horizon. SKY_STOPS = [ (0.00, (6, 3, 22)), (0.45, (26, 8, 60)), (0.72, (88, 18, 110)), (0.88, (200, 40, 120)), (1.00, (255, 120, 70)), ] MAGENTA = np.array([255, 40, 200]) CYAN = np.array([40, 230, 255]) def save(image, name): image.save(os.path.join(OUT, name)) print("%-16s %d x %d" % (name, image.width, image.height)) def ramp(stops, t): t = np.clip(t, 0, 1) out = np.zeros(t.shape + (3,)) for (t0, c0), (t1, c1) in zip(stops[:-1], stops[1:]): m = (t >= t0) & (t <= t1) k = ((t - t0) / (t1 - t0))[m][:, None] out[m] = np.array(c0) * (1 - k) + np.array(c1) * k return out # The sun's own look, wherever it is drawn: yellow at the top to magenta at the bottom, with # horizontal bands cut out of its lower half that thicken toward the bottom. u, v run -1 to 1. def sunMask(u, v): inside = (u * u + v * v) <= 1.0 band = np.ones_like(u, dtype=bool) for k in range(7): centre = 0.12 + k * 0.14 half = 0.012 + k * 0.009 band &= ~((v > centre - half) & (v < centre + half)) return inside & band def sunColour(v): return ramp([(0.0, (255, 240, 120)), (0.5, (255, 140, 60)), (1.0, (255, 40, 150))], (v + 1) / 2) def sky(): lon = (np.arange(SKY_W) + 0.5) / SKY_W * 2 * math.pi - math.pi lat = math.pi / 2 - (np.arange(SKY_H) + 0.5) / SKY_H * math.pi LON, LAT = np.meshgrid(lon, lat) img = np.zeros((SKY_H, SKY_W, 3)) # Above the horizon: the gradient by height, and stars thinning toward the horizon. up = LAT >= 0 img[up] = ramp(SKY_STOPS, 1 - LAT[up] / (math.pi / 2)) rng = np.random.default_rng(7) for _ in range(2600): x = rng.integers(0, SKY_W) y = int(rng.random() ** 1.8 * SKY_H * 0.42) b = rng.random() ** 2 * 255 img[y, x] = np.maximum(img[y, x], [b, b, min(255, b * 1.1)]) # Behind the camera, a city's cyan glow along the horizon: the half of the sky a chrome face # toward the camera reflects, so the chrome shades cyan above and magenta below. city = np.exp(-np.maximum(LAT, 0) / math.radians(14)) * np.clip(-np.cos(LON), 0, 1) ** 0.7 * up img = img * (1 - 0.85 * city[..., None]) + city[..., None] * np.array([60, 210, 255]) * 0.95 # The sun straight ahead, its glow first. du = (LON / math.radians(SUN_RADIUS)) dv = -(LAT - math.radians(SUN_LAT)) / math.radians(SUN_RADIUS) dist = np.sqrt(du * du + dv * dv) glow = np.exp(-np.maximum(dist - 1, 0) * 2.2) * (LAT > -0.01) img += glow[..., None] * np.array([255, 70, 120]) * 0.55 mask = sunMask(du, dv) img[mask] = sunColour(dv[mask]) # Below the horizon: a dark floor and the grid on it in perspective, fading into haze. down = LAT < 0 dirX = np.cos(LAT) * np.sin(LON) dirZ = -np.cos(LAT) * np.cos(LON) t = 1.0 / np.maximum(-np.sin(LAT), 1e-4) # The camera stands in the middle of a cell, so no line runs straight under it. gx = t * dirX / GRID_CELL + 0.5 gz = t * dirZ / GRID_CELL + 0.5 fx = np.abs(gx - np.round(gx)) fz = np.abs(gz - np.round(gz)) # Lines a constant width on the floor get thinner with distance; their brightness is kept by # widening the soft edge as they shrink, then faded into the horizon's haze. width = 0.03 + 0.002 * t line = np.clip(1 - np.minimum(fx, fz) / width, 0, 1) ** 1.5 haze = np.clip((t - 6) / 60, 0, 1) floor = np.array([14, 4, 30]) * (1 - haze[..., None]) + np.array([120, 30, 110]) * haze[..., None] lineColour = MAGENTA * (1 - haze[..., None] * 0.7) below = floor + line[..., None] * lineColour img[down] = below[down] img = np.clip(img, 0, 255).astype(np.uint8) return Image.fromarray(img, "RGB") # The floor tile: a cell of the neon grid, glowing magenta lines on black, with alpha, used as the # floor's colour and its emissive map at once. Tiled, the line at each edge meets its neighbour's. def gridTile(colour, size=256, width=5.0, glow=18.0): y, x = np.mgrid[0:size, 0:size] + 0.5 d = np.minimum(np.minimum(x, size - x), np.minimum(y, size - y)) core = np.clip(1 - (d - width / 2), 0, 1) halo = np.exp(-d / glow) * 0.5 v = np.clip(core + halo, 0, 1) img = np.zeros((size, size, 4)) img[..., :3] = np.array(colour) * v[..., None] img[..., 3] = 255 return Image.fromarray(img.astype(np.uint8), "RGBA") # A heightmap for the mountains: a ridge line with a valley in the middle where the sun sits, # rising toward both ends, with ridged noise for the peaks. Red carries the height. def mountains(w=256, h=64): rng = np.random.default_rng(3) x = np.linspace(-1, 1, w) z = np.linspace(0, 1, h) X, Z = np.meshgrid(x, z) base = np.clip(np.abs(X) * 1.4 - 0.15, 0, 1) noise = np.zeros_like(X) amp = 1.0 freq = 3.0 for _ in range(5): phase = rng.random(4) * 10 n = np.sin(X * freq * 3.1 + phase[0]) * np.sin(Z * freq * 2.3 + phase[1]) + 0.5 * np.sin((X + Z) * freq * 4.7 + phase[2]) noise += (1 - np.abs(n)) * amp amp *= 0.5 freq *= 2.0 ridge = np.exp(-((Z - 0.5) ** 2) / 0.05) hgt = np.clip(base * (0.4 + 0.6 * noise / 1.9) * ridge, 0, 1) img = (hgt * 255).astype(np.uint8) return Image.fromarray(np.stack([img, img, img], -1), "RGB") SS = 4 # Sprites are drawn this many times larger and shrunk, for smooth edges def big(w, h): return Image.new("RGBA", (w * SS, h * SS), (0, 0, 0, 0)) def shrink(image, w, h): return image.resize((w, h), Image.LANCZOS) def pts(points): return [(x * SS, y * SS) for x, y in points] # The car from behind, low and wide: a black body traced in magenta, a cyan glint on the rear # window, a full-width taillight bar, and a glow under it on the road. def car(): w, h = 176, 80 im = big(w, h) d = ImageDraw.Draw(im) d.ellipse(pts([(6, 64), (170, 80)]), fill=(255, 30, 180, 90)) d.rectangle(pts([(16, 58), (40, 76)]), fill=(12, 8, 16, 255)) d.rectangle(pts([(136, 58), (160, 76)]), fill=(12, 8, 16, 255)) body = [(4, 40), (18, 30), (158, 30), (172, 40), (172, 66), (4, 66)] d.polygon(pts(body), fill=(18, 12, 30, 255), outline=(255, 40, 200, 255), width=2 * SS) roof = [(38, 30), (52, 8), (124, 8), (138, 30)] d.polygon(pts(roof), fill=(26, 16, 44, 255), outline=(255, 40, 200, 255), width=2 * SS) d.polygon(pts([(46, 28), (57, 12), (119, 12), (130, 28)]), fill=(40, 120, 200, 255)) d.polygon(pts([(60, 26), (70, 13), (84, 13), (74, 26)]), fill=(150, 240, 255, 255)) d.rectangle(pts([(10, 42), (166, 50)]), fill=(255, 20, 60, 255)) d.rectangle(pts([(12, 44), (164, 48)]), fill=(255, 150, 160, 255)) d.rectangle(pts([(70, 54), (106, 60)]), fill=(230, 230, 240, 255)) return shrink(im, w, h) def palm(): w, h = 200, 230 im = big(w, h) d = ImageDraw.Draw(im) trunk = [] for i in range(41): t = i / 40 x = 100 + 18 * math.sin(t * 2.2) - 10 * t trunk.append((x, 40 + t * 180)) for (x0, y0), (x1, y1) in zip(trunk[:-1], trunk[1:]): wdt = 7 + (y0 - 40) / 180 * 5 d.line(pts([(x0, y0), (x1, y1)]), fill=(10, 4, 16, 255), width=int(wdt * SS)) top = trunk[0] dark = (10, 4, 16, 255) for k in range(9): ang = -math.pi * 0.98 + k * math.pi * 0.98 / 8 + (0.12 if k % 2 else -0.08) length = 78 + 12 * math.sin(k * 1.7) spine = [] for i in range(17): t = i / 16 spine.append((top[0] + math.cos(ang) * length * t, top[1] + math.sin(ang) * length * t * 0.55 + t * t * 52)) for i, ((x0, y0), (x1, y1)) in enumerate(zip(spine[:-1], spine[1:])): t = i / 16 d.line(pts([(x0, y0), (x1, y1)]), fill=dark, width=max(1, int((4 - 3 * t) * SS))) # Leaflets hang off both sides of the spine, longest near its middle, swept back and down. dx = x1 - x0 dy = y1 - y0 n = math.hypot(dx, dy) + 1e-9 px, py = -dy / n, dx / n leaf = 13 * math.sin(math.pi * min(1, t * 1.1)) + 2 if i % 2: continue for side in (-1, 1): ex = x0 + side * px * leaf + dx / n * leaf * 0.6 ey = y0 + side * py * leaf + dy / n * leaf * 0.6 + leaf * 0.35 d.line(pts([(x0, y0), (ex, ey)]), fill=dark, width=int(2.2 * SS)) d.ellipse(pts([(top[0] - 7, top[1] - 5), (top[0] + 7, top[1] + 7)]), fill=dark) # A magenta rim on the side away from the viewer's left: the sunset behind it. a = np.array(im) alpha = a[..., 3] > 0 edge = alpha & ~np.roll(alpha, -SS, axis=1) a[edge] = [230, 40, 160, 255] return shrink(Image.fromarray(a), w, h) def lamp(): w, h = 64, 200 im = big(w, h) d = ImageDraw.Draw(im) d.rectangle(pts([(30, 20), (35, 199)]), fill=(20, 14, 30, 255)) d.line(pts([(33, 22), (8, 14)]), fill=(20, 14, 30, 255), width=4 * SS) d.ellipse(pts([(0, 8), (18, 20)]), fill=(255, 245, 210, 255)) return shrink(im, w, h) # A city along the horizon: blocks of every height, lit windows, a few towers with a red light on # top. It tiles sideways. def skyline(): w, h = 1280, 150 rng = np.random.default_rng(5) im = Image.new("RGBA", (w, h), (0, 0, 0, 0)) d = ImageDraw.Draw(im) x = 0 while x < w: bw = int(rng.integers(26, 70)) bh = int(rng.integers(30, 140)) shade = int(rng.integers(14, 30)) d.rectangle([x, h - bh, min(w - 1, x + bw - 3), h - 1], fill=(shade, shade // 2, shade + 16, 255)) for wy in range(h - bh + 6, h - 6, 7): for wx in range(x + 4, x + bw - 6, 6): if rng.random() < 0.35: c = (255, 210, 120, 255) if rng.random() < 0.7 else (80, 220, 255, 255) d.rectangle([wx, wy, wx + 2, wy + 3], fill=c) if bh > 110: d.rectangle([x + bw // 2 - 1, h - bh - 14, x + bw // 2, h - bh], fill=(30, 20, 40, 255)) d.ellipse([x + bw // 2 - 3, h - bh - 18, x + bw // 2 + 3, h - bh - 12], fill=(255, 40, 60, 255)) x += bw return im # The night road a row at a time, as in the arcade racers: four kinds of row (light and dark road, # light and dark ground) at HAZE levels each, fading into the violet haze at the horizon. STRIP_W = 256 HAZE = 8 HAZE_RGB = np.array([110, 28, 100]) def roadStrip(): img = np.zeros((4 * HAZE, STRIP_W, 4), dtype=np.float64) x = np.arange(STRIP_W) for kind in range(4): for level in range(HAZE): if kind < 2: row = np.tile(np.array([16, 10, 26], dtype=np.float64), (STRIP_W, 1)) edge = (x < 10) | (x >= STRIP_W - 10) row[edge] = [255, 40, 200] inner = ((x >= 10) & (x < 14)) | ((x >= STRIP_W - 14) & (x < STRIP_W - 10)) row[inner] = [120, 20, 100] if kind == 0: for lane in (1, 2): c = 10 + (STRIP_W - 20) * lane // 3 row[(x >= c - 2) & (x < c + 2)] = [40, 230, 255] else: row = np.tile(np.array([10, 4, 22] if kind == 2 else [20, 6, 36], dtype=np.float64), (STRIP_W, 1)) k = level / (HAZE - 1) * 0.8 row = row * (1 - k) + HAZE_RGB * k img[kind * HAZE + level, :, :3] = row img[kind * HAZE + level, :, 3] = 255 return Image.fromarray(img.astype(np.uint8), "RGBA") # A shaded ball for vector bobs: white and grey, so spriteSetTint colours it. def bob(size=64): y, x = np.mgrid[0:size, 0:size] + 0.5 cx = cy = size / 2 r = size / 2 - 1 dx = (x - cx) / r dy = (y - cy) / r d2 = dx * dx + dy * dy inside = d2 <= 1 nz = np.sqrt(np.clip(1 - d2, 0, 1)) light = np.clip(dx * -0.4 + dy * -0.5 + nz * 0.77, 0, 1) spec = np.clip(light, 0, 1) ** 24 v = 0.18 + 0.7 * light + 0.6 * spec a = np.clip((1 - np.sqrt(d2)) * r, 0, 1) * inside img = np.zeros((size, size, 4)) img[..., :3] = np.clip(v, 0, 1)[..., None] * 255 img[..., 3] = a * 255 return Image.fromarray(img.astype(np.uint8), "RGBA") # A soft round glow, white, for additive light: bloom on the overlay. def glow(size=128, power=2.2): y, x = np.mgrid[0:size, 0:size] + 0.5 d = np.sqrt((x - size / 2) ** 2 + (y - size / 2) ** 2) / (size / 2) v = np.clip(1 - d, 0, 1) ** power img = np.zeros((size, size, 4)) img[..., :3] = 255 img[..., 3] = v * 255 return Image.fromarray(img.astype(np.uint8), "RGBA") # Lettering in eighties chrome: a sky-to-sunset gradient with a hard horizon line through the middle, # a white outline, a dark outline outside that, and a soft magenta glow behind. def chromeText(text, size, font=FONT_WIDE, pad=24): f = ImageFont.truetype(font, size * SS) box = f.getbbox(text) w = (box[2] - box[0]) // SS + pad * 2 h = (box[3] - box[1]) // SS + pad * 2 mask = Image.new("L", (w * SS, h * SS), 0) ImageDraw.Draw(mask).text((pad * SS - box[0], pad * SS - box[1]), text, font=f, fill=255) m = np.array(mask).astype(np.float64) / 255 rows = np.linspace(0, 1, h * SS) top = (pad * SS) / (h * SS) t = np.clip((rows - top) / (1 - 2 * top), 0, 1)[:, None] sky = np.array([20, 40, 120]) + (np.array([200, 245, 255]) - np.array([20, 40, 120])) * (t / 0.5) sunset = np.array([90, 20, 60]) + (np.array([255, 120, 200]) - np.array([90, 20, 60])) * ((t - 0.5) / 0.5) rowColour = np.where(t < 0.5, sky, sunset) grad = np.repeat(rowColour[:, None, :], w * SS, axis=1) from scipy import ndimage outer = ndimage.maximum_filter(m, size=5 * SS) ring = ndimage.maximum_filter(m, size=2 * SS) halo = ndimage.gaussian_filter(outer, 7 * SS) img = np.zeros((h * SS, w * SS, 4)) img[..., :3] = np.array([255, 40, 200]) * 1.0 img[..., 3] = np.clip(halo * 1.4, 0, 1) * 180 img[..., :3] = np.where(outer[..., None] > 0.5, [20, 4, 30], img[..., :3]) img[..., 3] = np.where(outer > 0.5, 255, img[..., 3]) img[..., :3] = np.where(ring[..., None] > 0.5, [255, 255, 255], img[..., :3]) img[..., :3] = np.where(m[..., None] > 0.5, grad, img[..., :3]) return shrink(Image.fromarray(np.clip(img, 0, 255).astype(np.uint8), "RGBA"), w, h) def flag(): w, h = 512, 320 im = Image.new("RGBA", (w, h), (20, 6, 40, 255)) d = ImageDraw.Draw(im) for i in range(8): y = int(h * 0.62 + i * i * 2.2) d.rectangle([0, y, w, y + 2], fill=(255, 40, 200, 255)) d.ellipse([w // 2 - 90, 40, w // 2 + 90, 220], fill=(255, 160, 70, 255)) logo = chromeText("NEON COAST", 64) logo = logo.resize((w - 40, int(logo.height * (w - 40) / logo.width)), Image.LANCZOS) im.alpha_composite(logo, (20, h // 2 - logo.height // 2 + 20)) return im def main(): os.makedirs(OUT, exist_ok=True) save(sky(), "sky.png") save(gridTile(MAGENTA), "grid.png") save(gridTile(CYAN, width=3.0, glow=10.0), "gridCyan.png") save(mountains(), "mountains.png") save(car(), "car.png") save(palm(), "palm.png") save(lamp(), "lamp.png") save(skyline(), "skyline.png") save(roadStrip(), "road.png") save(bob(), "bob.png") save(glow(), "glow.png") save(chromeText("NEON COAST", 96), "logo.png") save(chromeText("SINGE", 120), "singe.png") save(flag(), "flag.png") if __name__ == "__main__": main()