singe/util/neonCoastArt.py

405 lines
15 KiB
Python

# 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()