mirror of
https://github.com/harry7557558/spirula-studio.git
synced 2026-10-02 02:44:54 +08:00
312 lines
11 KiB
Python
312 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""Render assets/icon.png, the application icon.
|
|
|
|
The shape is the fractal spirula from one ofharry7557558/spirulae's
|
|
examples -- the u=1 level set of a logarithmic-spiral chart, iterated
|
|
the way a Mandelbulb iterates a sphere -- in the same turbo orbit-trap colour.
|
|
|
|
Run it when the icon changes. assets/icon.png is committed and
|
|
tools/package_macos.sh reads that, so neither the build nor packaging needs
|
|
what this imports.
|
|
|
|
pip install taichi
|
|
python3 tools/make_icon.py
|
|
"""
|
|
|
|
import os
|
|
|
|
import numpy as np
|
|
import taichi as ti
|
|
from PIL import Image
|
|
|
|
# ---- canvas -------------------------------------------------------------
|
|
SIZE = 1024
|
|
SS = 8 # supersampling: the whorls are finer than a pixel
|
|
INSET = 100 # macOS: 824 of artwork inside a 1024 canvas
|
|
RADIUS = 185 # corner radius of that 824 square
|
|
|
|
# ---- camera -------------------------------------------------------------
|
|
# Nearly down the spiral axis, from the side the whorls open away from: only
|
|
# from here do they stack into concentric blades around the pole, the reading
|
|
# worth having in an icon. The near side is a featureless dome. Well off the
|
|
# axis, so the disc is an ellipse and the shell has a thickness.
|
|
CAM_DIST = 5.0
|
|
CAM_TILT_DEG = 140.0
|
|
CAM_AZIM_DEG = 310.0
|
|
FOV_DEG = 60.0
|
|
KEY_DIR = (-0.5, 0.5, -0.7)
|
|
|
|
# ---- shape --------------------------------------------------------------
|
|
SP_B, SP_P, SP_S = 0.1, 1.5, 0.4
|
|
SP_NMAX, SP_K, SP_M, SP_N, SP_A = 2.0, 18.0, 6.0, 8.0, 0.5
|
|
SP_CSCALE, SP_NITER = 0.6, 20
|
|
|
|
BOUND_R = 5.0
|
|
MAX_STEPS = 12000
|
|
SHADOW_STEPS = 3000
|
|
MAX_STEP = 0.001
|
|
SURFACE_OFF = 0.002
|
|
|
|
ASSETS = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
|
"assets")
|
|
OUT = os.path.join(ASSETS, "icon.png")
|
|
ICO = os.path.join(ASSETS, "icon.ico")
|
|
SMALL = os.path.join(ASSETS, "icon_128.png")
|
|
|
|
# ---- README banner ------------------------------------------------------
|
|
# The same shape from the same camera, framed wide and close: the whorls run
|
|
# off all four edges, so the strip is subject rather than background. Artwork
|
|
# only -- every word on the page stays markdown, editable and translatable.
|
|
BANNER = os.path.join(ASSETS, "banner.png")
|
|
BAN_W, BAN_H = 1600, 480
|
|
BAN_FOV_DEG = 13.0 # vertical; tight enough that the strip is all shell
|
|
BAN_SHIFT = -0.30 # the pole off to the right of centre, not on it
|
|
|
|
try:
|
|
ti.init(arch=ti.gpu, default_fp=ti.f32)
|
|
except Exception:
|
|
ti.init(arch=ti.cpu, default_fp=ti.f32)
|
|
|
|
vec3 = ti.types.vector(3, ti.f32)
|
|
PI = 3.14159265358979
|
|
|
|
W = SIZE * SS
|
|
buf = ti.Vector.field(4, dtype=ti.f32, shape=(W, W)) # rgb + hit
|
|
ban = ti.Vector.field(4, dtype=ti.f32, shape=(BAN_H * SS, BAN_W * SS))
|
|
|
|
|
|
@ti.func
|
|
def xyz2uvw(p: vec3) -> vec3:
|
|
r = ti.sqrt(p.x * p.x + p.y * p.y)
|
|
a = ti.atan2(p.y, p.x)
|
|
nf = ti.min((ti.log(ti.max(r, 1e-12)) / SP_B - a) / (2.0 * PI), SP_NMAX)
|
|
v0 = 2.0 * PI * ti.floor(nf) + a
|
|
v1 = 2.0 * PI * ti.ceil(nf) + a
|
|
rc0, rc1 = ti.exp(SP_B * v0), ti.exp(SP_B * v1)
|
|
v, rc = v0, rc0
|
|
if ti.abs(r - rc1) < ti.abs(r - rc0):
|
|
v, rc = v1, rc1
|
|
d = r - rc
|
|
l = ti.sqrt(d * d + p.z * p.z)
|
|
return ti.Vector([l / (SP_S * ti.pow(rc, SP_P)), v, ti.atan2(d, p.z)])
|
|
|
|
|
|
@ti.func
|
|
def uvw2xyz(q: vec3) -> vec3:
|
|
rc = ti.exp(SP_B * q.y)
|
|
l = q.x * SP_S * ti.pow(rc, SP_P)
|
|
r = rc + l * ti.sin(q.z)
|
|
return ti.Vector([r * ti.cos(q.y), r * ti.sin(q.y), l * ti.cos(q.z)])
|
|
|
|
|
|
# Distance estimate, and the orbit trap that colours the surface.
|
|
@ti.func
|
|
def spirula_de(p_in: vec3):
|
|
m = SP_M / ti.sqrt(1.0 + SP_A * SP_A)
|
|
n = SP_N / ti.sqrt(1.0 + SP_A * SP_A)
|
|
c = p_in * SP_CSCALE
|
|
z = c
|
|
dr = 1.0
|
|
r = 0.0
|
|
trap = 1e9
|
|
for _ in range(SP_NITER):
|
|
r = z.norm()
|
|
trap = ti.min(trap, r)
|
|
if r > 64.0:
|
|
break
|
|
q = xyz2uvw(z)
|
|
u, v, w = q.x, q.y, q.z
|
|
uk = ti.pow(u, SP_K)
|
|
z1 = uvw2xyz(ti.Vector([uk, m * (v + SP_A * w), n * (-SP_A * v + w)])) + c
|
|
|
|
rc, rc2 = ti.exp(SP_B * v), ti.exp(SP_B * m * v)
|
|
uk1 = ti.pow(u, SP_K - 1.0) * ti.pow(rc2 / rc, SP_P)
|
|
r_in = ti.max(ti.abs(rc + u * SP_S * ti.pow(rc, SP_P) * ti.sin(w)), 1e-8)
|
|
r_out = ti.abs(rc2 + uk * SP_S * ti.pow(rc2, SP_P) * ti.sin(n * w))
|
|
dr = ti.max(ti.max(SP_K * uk1, n * uk1), m * r_out / r_in) * dr + 1.0
|
|
z = z1
|
|
r = ti.max(z.norm(), 1e-8)
|
|
return 0.5 * ti.log(r) * r / dr, trap
|
|
|
|
|
|
@ti.func
|
|
def normal_at(p: vec3) -> vec3:
|
|
e = 1e-4
|
|
dx = spirula_de(p + vec3([e, 0, 0]))[0] - spirula_de(p - vec3([e, 0, 0]))[0]
|
|
dy = spirula_de(p + vec3([0, e, 0]))[0] - spirula_de(p - vec3([0, e, 0]))[0]
|
|
dz = spirula_de(p + vec3([0, 0, e]))[0] - spirula_de(p - vec3([0, 0, e]))[0]
|
|
return ti.Vector([dx, dy, dz]).normalized()
|
|
|
|
|
|
# Turbo, to color this fractal with.
|
|
@ti.func
|
|
def turbo(t: ti.f32) -> vec3:
|
|
c = ti.Vector([-266.03287948, -63.82163439, -97.24397473])
|
|
c = c * t + ti.Vector([874.85901369, 202.98596195, 447.99287080])
|
|
c = c * t + ti.Vector([-1061.31232675, -245.26823636, -766.82678386])
|
|
c = c * t + ti.Vector([550.44382083, 149.40813531, 604.07329733])
|
|
c = c * t + ti.Vector([-90.46877071, -55.09180383, -203.76964931])
|
|
c = c * t + ti.Vector([-10.15061040, 9.64581379, 9.26380342])
|
|
c = c * t + ti.Vector([2.94711014, 2.06520532, 6.33792818])
|
|
c = c * t + ti.Vector([0.14637796, 0.08594198, 0.23431523])
|
|
return ti.max(ti.min(c, 1.0), 0.0)
|
|
|
|
|
|
# Three light levels and a rim, never a gradient: banding is what survives the
|
|
# downsample to 16 px.
|
|
@ti.func
|
|
def cel_shade(albedo: vec3, lum: ti.f32, rim: ti.f32, lit: ti.f32) -> vec3:
|
|
band = 0.34
|
|
if lum > 0.42:
|
|
band = 0.68
|
|
if lum > 0.74:
|
|
band = 1.0
|
|
band = ti.min(band, 0.34 + 0.66 * lit)
|
|
col = albedo * band
|
|
return col + (ti.Vector([1.0, 0.97, 0.90]) - col) * rim * lit
|
|
|
|
|
|
# Hard shadow, one ray, no softening: a cel shader wants the terminator sharp.
|
|
@ti.func
|
|
def shadow(p: vec3, ldir: vec3) -> ti.f32:
|
|
t = 4.0 * SURFACE_OFF
|
|
b = p.dot(ldir)
|
|
tfar = -b + ti.sqrt(ti.max(b * b - (p.dot(p) - BOUND_R * BOUND_R), 0.0))
|
|
lit = 1.0
|
|
for _ in range(SHADOW_STEPS):
|
|
if t > tfar:
|
|
break
|
|
de = spirula_de(p + ldir * t)[0]
|
|
if de < SURFACE_OFF:
|
|
lit = 0.0
|
|
break
|
|
t += ti.min(ti.max(de * 0.7, 5e-4), MAX_STEP)
|
|
return lit
|
|
|
|
|
|
# tan_half is the VERTICAL half-FOV, so the two outputs frame the shell the
|
|
# same way down the short axis and the wide one only sees further sideways.
|
|
@ti.kernel
|
|
def render(out: ti.template(), eye: vec3, fwd: vec3, right: vec3, up: vec3,
|
|
tan_half: ti.f32, shift: ti.f32, key_in: vec3):
|
|
key = key_in.normalized()
|
|
h, w = out.shape[0], out.shape[1]
|
|
aspect = ti.cast(w, ti.f32) / ti.cast(h, ti.f32)
|
|
for y, x in out:
|
|
px = (2.0 * (x + 0.5) / w - 1.0 + shift) * tan_half * aspect
|
|
py = (1.0 - 2.0 * (y + 0.5) / h) * tan_half
|
|
rd = (fwd + right * px + up * py).normalized()
|
|
|
|
# Bounding sphere first: outside it the estimator is meaningless.
|
|
b = eye.dot(rd)
|
|
disc = b * b - (eye.dot(eye) - BOUND_R * BOUND_R)
|
|
col = ti.Vector([0.0, 0.0, 0.0])
|
|
hit = 0.0
|
|
if disc > 0.0:
|
|
sq = ti.sqrt(disc)
|
|
t = ti.max(-b - sq, 0.0)
|
|
tfar = -b + sq
|
|
for _ in range(MAX_STEPS):
|
|
if t > tfar:
|
|
break
|
|
p = eye + rd * t
|
|
de, trap = spirula_de(p)
|
|
if de < SURFACE_OFF:
|
|
nrm = normal_at(p)
|
|
if nrm.dot(rd) > 0.0:
|
|
nrm = -nrm
|
|
diff = 0.5 + 0.5 * nrm.dot(key)
|
|
rim = 0.45 * ti.pow(ti.max(1.0 + nrm.dot(rd), 0.0), 3.0)
|
|
col = cel_shade(turbo(0.5 - 0.5 * ti.cos(2.0 * trap)),
|
|
diff, rim, shadow(p + nrm * 4.0 * SURFACE_OFF, key))
|
|
hit = 1.0
|
|
break
|
|
t += ti.min(ti.max(de * 0.7, 5e-4), MAX_STEP)
|
|
out[y, x] = ti.Vector([col.x, col.y, col.z, hit])
|
|
|
|
|
|
def camera():
|
|
t = np.radians(CAM_TILT_DEG)
|
|
a = np.radians(CAM_AZIM_DEG)
|
|
eye = CAM_DIST * np.array([np.sin(t) * np.cos(a), np.sin(t) * np.sin(a), np.cos(t)])
|
|
fwd = -eye / np.linalg.norm(eye)
|
|
right = np.cross(fwd, [0.0, 1.0, 0.0])
|
|
right /= np.linalg.norm(right)
|
|
up = np.cross(right, fwd)
|
|
return eye, fwd, right, up
|
|
|
|
|
|
def ink(mask, rgb):
|
|
"""A silhouette line, drawn by eroding the hit mask."""
|
|
m = mask > 0.5
|
|
e = m.copy()
|
|
for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)):
|
|
e &= np.roll(m, (dy, dx), (0, 1))
|
|
edge = (m & ~e)[..., None]
|
|
return np.where(edge, rgb * 0.18, rgb)
|
|
|
|
|
|
def water(w, h, seed_x=0.72, seed_y=0.80):
|
|
"""Deep water, lit from one corner. Both outputs stand on it."""
|
|
yy, xx = np.mgrid[0:h, 0:w].astype(np.float32)
|
|
yy, xx = yy / max(h - 1, 1), xx / max(w - 1, 1)
|
|
top = np.array([0.043, 0.055, 0.110], np.float32)
|
|
bottom = np.array([0.075, 0.150, 0.210], np.float32)
|
|
img = top + (bottom - top) * yy[..., None]
|
|
glow = np.exp(-(((xx - seed_x) ** 2 + (yy - seed_y) ** 2)) / (2 * 0.35 ** 2))
|
|
return img + glow[..., None] * np.array([0.015, 0.035, 0.045], np.float32)
|
|
|
|
|
|
def quantize(im, dither=Image.Dither.NONE):
|
|
"""256 adaptive colours: a third of the file size. Large flat areas need
|
|
the dither -- the banner's water banded visibly without it."""
|
|
return im.convert(mode="P", colors=256, palette=Image.Palette.ADAPTIVE,
|
|
dither=dither)
|
|
|
|
|
|
def rounded_mask():
|
|
from PIL import ImageDraw
|
|
s = 4
|
|
m = Image.new("L", (SIZE * s, SIZE * s), 0)
|
|
ImageDraw.Draw(m).rounded_rectangle(
|
|
[INSET * s, INSET * s, (SIZE - INSET) * s - 1, (SIZE - INSET) * s - 1],
|
|
radius=RADIUS * s, fill=255)
|
|
return m.resize((SIZE, SIZE), Image.LANCZOS)
|
|
|
|
|
|
def shade(field, fov_deg, size, shift=0.0):
|
|
"""Trace into `field`, composite over the water, and downsample to `size`."""
|
|
eye, fwd, right, up = camera()
|
|
render(field, eye.tolist(), fwd.tolist(), right.tolist(), up.tolist(),
|
|
float(np.tan(np.radians(fov_deg) * 0.5)), shift, KEY_DIR)
|
|
out = field.to_numpy()
|
|
rgb, mask = out[..., :3], out[..., 3]
|
|
h, w = mask.shape
|
|
rgb = ink(mask, np.where(mask[..., None] > 0.5, rgb, water(w, h)))
|
|
img = Image.fromarray((np.clip(rgb, 0, 1) * 255 + 0.5).astype(np.uint8))
|
|
return img.resize(size, Image.LANCZOS).convert("RGBA")
|
|
|
|
|
|
def main():
|
|
img = shade(buf, FOV_DEG, (SIZE, SIZE))
|
|
img.putalpha(rounded_mask())
|
|
quantize(img).save(OUT, minimize=True)
|
|
|
|
# Two derived forms, both committed because the build has no Python: the
|
|
# .ico Windows compiles into the executable (src/app/app.rc), and the small
|
|
# PNG GuiMain.cpp hands X11. Both are cropped past the macOS inset, which
|
|
# would otherwise leave them a tenth smaller than every neighbour in a
|
|
# taskbar.
|
|
art = img.crop((INSET, INSET, SIZE - INSET, SIZE - INSET))
|
|
art.save(ICO, sizes=[(16, 16), (24, 24), (32, 32), (48, 48),
|
|
(64, 64), (128, 128), (256, 256)])
|
|
quantize(art.resize((128, 128), Image.LANCZOS)).save(SMALL, minimize=True)
|
|
|
|
quantize(shade(ban, BAN_FOV_DEG, (BAN_W, BAN_H), BAN_SHIFT),
|
|
Image.Dither.FLOYDSTEINBERG).save(BANNER, minimize=True)
|
|
print(f"wrote {OUT}, {ICO}, {SMALL}, {BANNER} (traced at {SS}x)")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|