405 lines
15 KiB
Python
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()
|