render.py was lifted from the scratch pipeline with its default TEXDIR still aimed at tools/IconExtract's build output — a tool that is not in the repo. forge.py overrides the value, so the icons built correctly and the staleness was invisible; anyone importing render.py directly would have been sent to a path that never existed. Default now matches where tools/MosswartArt actually writes, and a missing texture prints a warning instead of silently dropping out: a partial texture set renders some parts flat grey, which reads as a lighting bug rather than a missing extraction step. Both icons still reproduce byte-for-byte. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
241 lines
9.8 KiB
Python
241 lines
9.8 KiB
Python
"""Software rasterizer for the exported mosswart mesh.
|
|
|
|
Loads the geometry and textures dumped by tools/MosswartArt into work/, and
|
|
renders a lit, shaded, supersampled image. Deliberately simple: z-buffer,
|
|
perspective camera, Lambert key/fill/rim + Blinn specular for wet skin.
|
|
"""
|
|
import json, glob, os, math
|
|
import numpy as np
|
|
from PIL import Image
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
# Default matches where tools/MosswartArt writes its texture dump. forge.py
|
|
# overrides this when --work points elsewhere.
|
|
TEXDIR = os.path.join(HERE, "work", "textures")
|
|
|
|
|
|
# ---------------------------------------------------------------- data loading
|
|
def load_textures(ids):
|
|
out = {}
|
|
for tid in ids:
|
|
hits = glob.glob(os.path.join(TEXDIR, tid + "_*.png"))
|
|
if not hits:
|
|
# Say so: a partial texture set renders some parts flat grey, which
|
|
# is easy to mistake for a lighting problem rather than a missing
|
|
# extraction step.
|
|
print(f" WARNING: no texture found for {tid} in {TEXDIR}")
|
|
continue
|
|
im = Image.open(hits[0]).convert("RGBA")
|
|
out[tid] = np.asarray(im, dtype=np.float32) / 255.0
|
|
return out
|
|
|
|
|
|
def load_mesh(path, parts_filter=None, ignore_transform=False):
|
|
doc = json.load(open(path))
|
|
P, N, UV, TRI, TEX = [], [], [], [], []
|
|
for part in doc["parts"]:
|
|
if parts_filter is not None and part["index"] not in parts_filter:
|
|
continue
|
|
m = np.array(part["m"], dtype=np.float64).reshape(4, 4)
|
|
if ignore_transform:
|
|
m = np.eye(4)
|
|
# System.Numerics Matrix4x4 is row-vector convention: v' = v * M
|
|
rot = m[:3, :3]
|
|
trans = m[3, :3]
|
|
for sub in part["sub"]:
|
|
v = np.array(sub["v"], dtype=np.float64)
|
|
if len(v) == 0:
|
|
continue
|
|
base = len(P)
|
|
pos = v[:, 0:3] @ rot + trans
|
|
nrm = v[:, 3:6] @ rot
|
|
ln = np.linalg.norm(nrm, axis=1, keepdims=True)
|
|
nrm = np.divide(nrm, np.where(ln == 0, 1, ln))
|
|
P.append(pos)
|
|
N.append(nrm)
|
|
UV.append(v[:, 6:8])
|
|
idx = np.array(sub["i"], dtype=np.int64).reshape(-1, 3) + base
|
|
TRI.append(idx)
|
|
TEX += [sub["tex"]] * len(idx)
|
|
return (np.concatenate(P), np.concatenate(N), np.concatenate(UV),
|
|
np.concatenate(TRI), TEX)
|
|
|
|
|
|
# ---------------------------------------------------------------- math helpers
|
|
def look_at(eye, target, up=np.array([0.0, 0.0, 1.0])):
|
|
f = target - eye
|
|
f = f / np.linalg.norm(f)
|
|
s = np.cross(f, up)
|
|
if np.linalg.norm(s) < 1e-9:
|
|
s = np.cross(f, np.array([0.0, 1.0, 0.0]))
|
|
s = s / np.linalg.norm(s)
|
|
u = np.cross(s, f)
|
|
return np.stack([s, u, -f]) # rows: right, up, back
|
|
|
|
|
|
def orbit_eye(target, radius, az_deg, el_deg):
|
|
az, el = math.radians(az_deg), math.radians(el_deg)
|
|
return target + radius * np.array([
|
|
math.cos(el) * math.cos(az),
|
|
math.cos(el) * math.sin(az),
|
|
math.sin(el)])
|
|
|
|
|
|
# ---------------------------------------------------------------- rasterizer
|
|
def render(mesh, textures, size=512, ss=3, az=90.0, el=8.0, fov=26.0,
|
|
target=None, radius=None, fit=1.0,
|
|
key=(-0.45, -0.85, 0.45), rim=(0.7, 0.75, -0.15),
|
|
bg=(0, 0, 0, 0), key_col=(1.06, 1.04, 0.92), amb_top=(0.30, 0.34, 0.24),
|
|
amb_bot=(0.10, 0.10, 0.06), rim_col=(0.55, 0.72, 0.30), spec_pow=26.0,
|
|
spec_amt=0.42, key_cam=None, rim_cam=None, fill_cam=None,
|
|
fill_col=(0.0, 0.0, 0.0), exposure=1.0, gamma=1.0, matcaps=None):
|
|
P, N, UV, TRI, TEX = mesh
|
|
W = size * ss
|
|
|
|
if target is None:
|
|
lo, hi = P.min(axis=0), P.max(axis=0)
|
|
target = (lo + hi) / 2.0
|
|
if radius is None:
|
|
ext = np.linalg.norm(P.max(axis=0) - P.min(axis=0))
|
|
radius = ext / (2.0 * math.tan(math.radians(fov) / 2.0)) * fit
|
|
|
|
eye = orbit_eye(target, radius, az, el)
|
|
V = look_at(eye, target)
|
|
|
|
cam = (P - eye) @ V.T
|
|
ncam = N @ V.T
|
|
|
|
f = 1.0 / math.tan(math.radians(fov) / 2.0)
|
|
z = -cam[:, 2]
|
|
z = np.where(z < 1e-6, 1e-6, z)
|
|
sx = (cam[:, 0] * f / z * 0.5 + 0.5) * W
|
|
sy = (1.0 - (cam[:, 1] * f / z * 0.5 + 0.5)) * W
|
|
|
|
color = np.zeros((W, W, 3), dtype=np.float32)
|
|
alpha = np.zeros((W, W), dtype=np.float32)
|
|
depth = np.full((W, W), 1e30, dtype=np.float32)
|
|
|
|
keyd = np.array(key, dtype=np.float64); keyd /= np.linalg.norm(keyd)
|
|
rimd = np.array(rim, dtype=np.float64); rimd /= np.linalg.norm(rimd)
|
|
keyc = V @ keyd
|
|
rimc = V @ rimd
|
|
# Camera-relative light directions make art direction repeatable across
|
|
# camera angles -- the key stays on the same side of the face regardless
|
|
# of where the model happens to be facing in world space.
|
|
if key_cam is not None:
|
|
keyc = np.array(key_cam, dtype=np.float64); keyc /= np.linalg.norm(keyc)
|
|
if rim_cam is not None:
|
|
rimc = np.array(rim_cam, dtype=np.float64); rimc /= np.linalg.norm(rimc)
|
|
fillc = None
|
|
if fill_cam is not None:
|
|
fillc = np.array(fill_cam, dtype=np.float64); fillc /= np.linalg.norm(fillc)
|
|
viewd = np.array([0.0, 0.0, 1.0])
|
|
half = keyc + viewd
|
|
half = half / np.linalg.norm(half)
|
|
|
|
for t in range(len(TRI)):
|
|
i0, i1, i2 = TRI[t]
|
|
x0, y0 = sx[i0], sy[i0]
|
|
x1, y1 = sx[i1], sy[i1]
|
|
x2, y2 = sx[i2], sy[i2]
|
|
area = (x1 - x0) * (y2 - y0) - (x2 - x0) * (y1 - y0)
|
|
if abs(area) < 1e-9:
|
|
continue
|
|
minx = max(int(math.floor(min(x0, x1, x2))), 0)
|
|
maxx = min(int(math.ceil(max(x0, x1, x2))), W - 1)
|
|
miny = max(int(math.floor(min(y0, y1, y2))), 0)
|
|
maxy = min(int(math.ceil(max(y0, y1, y2))), W - 1)
|
|
if minx > maxx or miny > maxy:
|
|
continue
|
|
|
|
xs = np.arange(minx, maxx + 1)
|
|
ys = np.arange(miny, maxy + 1)
|
|
gx, gy = np.meshgrid(xs + 0.5, ys + 0.5)
|
|
w0 = ((x1 - gx) * (y2 - gy) - (x2 - gx) * (y1 - gy)) / area
|
|
w1 = ((x2 - gx) * (y0 - gy) - (x0 - gx) * (y2 - gy)) / area
|
|
w2 = 1.0 - w0 - w1
|
|
inside = (w0 >= 0) & (w1 >= 0) & (w2 >= 0)
|
|
if not inside.any():
|
|
continue
|
|
|
|
zt = w0 * z[i0] + w1 * z[i1] + w2 * z[i2]
|
|
sub = depth[miny:maxy + 1, minx:maxx + 1]
|
|
closer = inside & (zt < sub)
|
|
if not closer.any():
|
|
continue
|
|
|
|
u = w0 * UV[i0, 0] + w1 * UV[i1, 0] + w2 * UV[i2, 0]
|
|
v = w0 * UV[i0, 1] + w1 * UV[i1, 1] + w2 * UV[i2, 1]
|
|
nx = w0 * ncam[i0, 0] + w1 * ncam[i1, 0] + w2 * ncam[i2, 0]
|
|
ny = w0 * ncam[i0, 1] + w1 * ncam[i1, 1] + w2 * ncam[i2, 1]
|
|
nz = w0 * ncam[i0, 2] + w1 * ncam[i1, 2] + w2 * ncam[i2, 2]
|
|
nl = np.sqrt(nx * nx + ny * ny + nz * nz)
|
|
nl = np.where(nl == 0, 1, nl)
|
|
nx, ny, nz = nx / nl, ny / nl, nz / nl
|
|
# two-sided: AC meshes are frequently single-sided but authored either way
|
|
flip = nz < 0
|
|
nx = np.where(flip, -nx, nx); ny = np.where(flip, -ny, ny); nz = np.where(flip, -nz, nz)
|
|
|
|
tex = textures.get(TEX[t])
|
|
if tex is None:
|
|
rgb = np.ones(u.shape + (3,), dtype=np.float32) * 0.6
|
|
ta = np.ones(u.shape, dtype=np.float32)
|
|
else:
|
|
th, tw = tex.shape[0], tex.shape[1]
|
|
fu = (u % 1.0) * (tw - 1)
|
|
fv = (v % 1.0) * (th - 1)
|
|
u0 = np.clip(np.floor(fu).astype(np.int32), 0, tw - 1)
|
|
v0 = np.clip(np.floor(fv).astype(np.int32), 0, th - 1)
|
|
u1 = np.clip(u0 + 1, 0, tw - 1)
|
|
v1 = np.clip(v0 + 1, 0, th - 1)
|
|
du = (fu - u0)[..., None]
|
|
dv = (fv - v0)[..., None]
|
|
texel = (tex[v0, u0] * (1 - du) * (1 - dv) + tex[v0, u1] * du * (1 - dv)
|
|
+ tex[v1, u0] * (1 - du) * dv + tex[v1, u1] * du * dv)
|
|
rgb = texel[..., :3]
|
|
ta = texel[..., 3]
|
|
|
|
ndl = np.clip(nx * keyc[0] + ny * keyc[1] + nz * keyc[2], 0, 1)
|
|
ndr = np.clip(nx * rimc[0] + ny * rimc[1] + nz * rimc[2], 0, 1) ** 2.4
|
|
up = np.clip(nz * 0.35 + 0.65, 0, 1)
|
|
amb = (np.array(amb_bot)[None, None, :] +
|
|
(np.array(amb_top) - np.array(amb_bot))[None, None, :] * up[..., None])
|
|
ndh = np.clip(nx * half[0] + ny * half[1] + nz * half[2], 0, 1)
|
|
spec = (ndh ** spec_pow) * spec_amt * ndl
|
|
|
|
lightsum = amb + np.array(key_col)[None, None, :] * ndl[..., None]
|
|
if fillc is not None:
|
|
ndf = np.clip(nx * fillc[0] + ny * fillc[1] + nz * fillc[2], 0, 1)
|
|
lightsum = lightsum + np.array(fill_col)[None, None, :] * ndf[..., None]
|
|
lit = (rgb * lightsum
|
|
+ np.array(rim_col)[None, None, :] * ndr[..., None]
|
|
+ spec[..., None]) * exposure
|
|
if gamma != 1.0:
|
|
lit = np.clip(lit, 0, None) ** gamma
|
|
|
|
# Metal: a Lambert term cannot produce chrome, because chrome is almost
|
|
# entirely reflection. Sample a matcap by the camera-space normal instead.
|
|
mc = matcaps.get(TEX[t]) if matcaps else None
|
|
if mc is not None:
|
|
mh, mw = mc.shape[0], mc.shape[1]
|
|
mu = np.clip((nx * 0.5 + 0.5) * (mw - 1), 0, mw - 1).astype(np.int32)
|
|
mv = np.clip((1.0 - (ny * 0.5 + 0.5)) * (mh - 1), 0, mh - 1).astype(np.int32)
|
|
lit = mc[mv, mu][..., :3] * exposure
|
|
ta = np.ones(u.shape, dtype=np.float32)
|
|
|
|
m = closer & (ta > 0.35)
|
|
if not m.any():
|
|
continue
|
|
ys_i, xs_i = np.nonzero(m)
|
|
color[miny + ys_i, minx + xs_i] = lit[ys_i, xs_i]
|
|
alpha[miny + ys_i, minx + xs_i] = 1.0
|
|
depth[miny + ys_i, minx + xs_i] = zt[ys_i, xs_i]
|
|
|
|
rgba = np.concatenate([np.clip(color, 0, 1), alpha[..., None]], axis=2)
|
|
img = Image.fromarray((rgba * 255).astype(np.uint8), "RGBA")
|
|
return img.resize((size, size), Image.LANCZOS)
|
|
|
|
|
|
def all_texture_ids(path):
|
|
doc = json.load(open(path))
|
|
return sorted({s["tex"] for p in doc["parts"] for s in p["sub"]})
|