# -*- coding: utf-8 -*- """Minable ore patches for The Descent. Run headless (never in the user's open Blender): blender -b --factory-startup --python build_ore_patches.py -- [--quick] [--only Ferrite_A] [--no-bake] [--cpu] What it makes (see ../README.md): * per ore type (ferrite, cobalt, pearl): 6 crystal clusters + 3 loose chunks, one shared texture atlas * 6 host rocks (two per ore type), each with its own baked texture set * 6 assembled patches: a rock with separate cluster objects seated in sockets (each one can be mined off) * UCX hulls, FBX files, OrePatches.json (the layout for the game), previews, build_report.json Topology: every rock is a subdivided cube (quads only) pushed onto an analytic rock shape, so the game mesh and the high-resolution bake source come from the same function. No booleans anywhere. """ import bpy, bmesh, sys, os, json, math, time import numpy as np from mathutils import Vector, Matrix, noise from mathutils.bvhtree import BVHTree T0 = time.time() HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.dirname(HERE) ARGS = sys.argv[sys.argv.index('--') + 1:] if '--' in sys.argv else [] def arg(name, default=None): if name in ARGS: i = ARGS.index(name) return ARGS[i + 1] if i + 1 < len(ARGS) and not ARGS[i + 1].startswith('--') else True return default QUICK = bool(arg('--quick', False)) NOBAKE = bool(arg('--no-bake', False)) ONLY = arg('--only') OUT = os.path.join(HERE, '_quick') if QUICK else ROOT TEX, FBX, PRE = (os.path.join(OUT, d) for d in ('textures', 'fbx', 'previews')) for d in (TEX, FBX, os.path.join(FBX, 'assembled'), PRE): os.makedirs(d, exist_ok=True) RES = 512 if QUICK else 2048 HI_N = 72 if QUICK else 160 # cube subdivisions of the bake source SPP = 4 if QUICK else 16 RENDER_SPP = 32 if QUICK else 160 CLUSTER_R = 0.25 # footprint radius the cluster meshes are modelled at CHUNK_SIZE = 0.34 # the game's loose chunk size (DESIGN_UE 2.12) SEAT_SINK = 0.24 # how far the cluster's base plane lies under the rock surface, in cluster radii FOOT_DROP = 0.09 # the rock may fall this far (in radii) under the footprint before a seat is refused sc = bpy.context.scene def log(*a): print('[ore %6.1fs]' % (time.time() - T0), *a, flush=True) # ------------------------------------------------------------------------------------------------ ore types # glow = the game's ore glow colours (DESIGN_UE 7.8) ORES = { 'Ferrite': dict(key='ferrite', label='Basalt Ferrite', glow=(1.0, 0.45, 0.12), stain=(0.10, 0.045, 0.02), stain_amt=0.35, vein=(0.85, 0.42, 0.13), vein_rough=0.32, vein_metal=1.0, vein_scale=3.4, socket=(0.07, 0.03, 0.012)), 'Cobalt': dict(key='cobalt', label='Cobalt Glass', glow=(0.15, 0.75, 1.0), stain=(0.018, 0.055, 0.08), stain_amt=0.4, vein=(0.08, 0.42, 0.60), vein_rough=0.12, vein_metal=0.0, vein_scale=3.0, socket=(0.012, 0.04, 0.06)), 'Pearl': dict(key='pearl', label='Seam Pearl', glow=(1.0, 0.25, 0.8), stain=(0.15, 0.09, 0.12), stain_amt=0.35, vein=(0.70, 0.40, 0.58), vein_rough=0.22, vein_metal=0.0, vein_scale=2.6, socket=(0.09, 0.04, 0.07)), } RADII_A = [0.44, 0.38, 0.33, 0.29, 0.26, 0.23, 0.21, 0.19, 0.17, 0.16, 0.15] RADII_B = [0.50, 0.44, 0.40, 0.36, 0.33, 0.30, 0.27, 0.25, 0.23, 0.21, 0.20, 0.18, 0.17, 0.16, 0.15, 0.14] RADII_S = [0.38, 0.32, 0.28, 0.24, 0.21, 0.19, 0.17, 0.15, 0.14] D2R = math.pi / 180 # lobes: (centre, radii, yaw). Shapes echo the four rocks of the reference picture. PATCHES = [ dict(name='Ferrite_A', ore='Ferrite', seed=11, nlo=20, sink=0.28, radii=RADII_A, planes=14, cut=(0.68, 0.88), lobes=[((0, 0, 0.30), (1.15, 0.85, 0.80), 0.3)]), # blocky slab dict(name='Ferrite_B', ore='Ferrite', seed=23, nlo=26, sink=0.30, radii=RADII_B, planes=12, cut=(0.78, 0.94), lobes=[((-0.75, 0.10, 0.30), (1.05, 0.95, 0.90), 0.2), ((0.80, -0.15, 0.22), (1.0, 0.9, 0.75), 0.5)]), # twin dict(name='Cobalt_A', ore='Cobalt', seed=37, nlo=20, sink=0.25, radii=RADII_S, planes=5, cut=(0.62, 0.85), fixed_planes=[(k * 72 * D2R + j, e * D2R, c) for k, (j, e, c) in enumerate( [(0.1, 36, 0.50), (-0.2, 41, 0.47), (0.25, 33, 0.52), (0.0, 44, 0.46), (-0.15, 38, 0.50)])], lobes=[((0, 0, 0.20), (1.15, 1.05, 1.40), 0.0)]), # pyramid dict(name='Cobalt_B', ore='Cobalt', seed=41, nlo=26, sink=0.30, radii=RADII_B, planes=7, cut=(0.82, 0.97), lobes=[((0, 0, 0.35), (1.25, 1.15, 1.25), 0.0), ((0.95, 0.45, 0.05), (0.70, 0.70, 0.60), 0.0), ((-0.85, -0.60, 0.0), (0.75, 0.65, 0.55), 0.0), ((0.10, -0.90, 0.0), (0.60, 0.55, 0.50), 0.0)]), # mound dict(name='Pearl_A', ore='Pearl', seed=53, nlo=20, sink=0.25, radii=RADII_A, planes=9, cut=(0.72, 0.90), strata=0.11, lobes=[((0, 0, 0.14), (1.35, 1.0, 0.66), 0.25)]), # laminated shelf dict(name='Pearl_B', ore='Pearl', seed=67, nlo=26, sink=0.28, radii=RADII_B, planes=10, cut=(0.74, 0.92), strata=0.13, lobes=[((-0.70, 0.0, 0.22), (1.25, 0.85, 0.88), 0.15), ((0.85, 0.20, 0.10), (1.15, 1.0, 0.62), -0.4)]), ] if ONLY: PATCHES = [p for p in PATCHES if p['name'] in ONLY.split(',')] USED_ORES = [o for o in ORES if any(p['ore'] == o for p in PATCHES)] # ------------------------------------------------------------------------------------------------ maths def unit(v): v = np.asarray(v, float) return v / np.linalg.norm(v) def smin(a, b, k): h = np.clip(0.5 + 0.5 * (b - a) / k, 0, 1) return b * (1 - h) + a * h - k * h * (1 - h) def smax(a, b, k): return -smin(-a, -b, k) def sstep(x, lo, hi): t = np.clip((x - lo) / (hi - lo), 0, 1) return t * t * (3 - 2 * t) def rot_to(axis): """3x3 that turns +Z onto axis.""" return np.array(Vector((0, 0, 1)).rotation_difference(Vector(unit(axis).tolist())).to_matrix()) def randrot(rng): q = unit(rng.normal(size=4)) from mathutils import Quaternion return np.array(Quaternion(q.tolist()).to_matrix()) _QS = {} def quad_sphere(n): """Cube with n x n quads a side, pushed onto the unit sphere. Returns dirs, quads, cube-face id per quad.""" if n not in _QS: lin = np.tan(np.linspace(-1, 1, n + 1) * math.pi / 4) # evens out the cell size I, J = np.meshgrid(np.arange(n + 1), np.arange(n + 1), indexing='ij') pts, quads, fid = [], [], [] for f, (a, s) in enumerate([(0, 1), (0, -1), (1, 1), (1, -1), (2, 1), (2, -1)]): b, c = (a + 1) % 3, (a + 2) % 3 p = np.zeros((n + 1, n + 1, 3)) p[..., a] = s p[..., b] = lin[I] p[..., c] = lin[J] * s # keeps every face wound outward idx = f * (n + 1) ** 2 + I * (n + 1) + J q = np.stack([idx[:-1, :-1], idx[1:, :-1], idx[1:, 1:], idx[:-1, 1:]], -1).reshape(-1, 4) pts.append(p.reshape(-1, 3)) quads.append(q) fid.append(np.full(len(q), f)) P = np.concatenate(pts) Q = np.concatenate(quads) _, first, inv = np.unique(np.round(P * 1e6).astype(np.int64), axis=0, return_index=True, return_inverse=True) inv = inv.reshape(-1) V = P[first] V /= np.linalg.norm(V, axis=1)[:, None] _QS[n] = (V, inv[Q], np.concatenate(fid)) V, F, fid = _QS[n] return V.copy(), F.copy(), fid def vnormals(V, F): F = np.asarray(F) fn = np.cross(V[F[:, 2]] - V[F[:, 0]], V[F[:, 3]] - V[F[:, 1]]) N = np.zeros_like(V) for k in range(4): np.add.at(N, F[:, k], fn) return N / np.maximum(np.linalg.norm(N, axis=1), 1e-12)[:, None] def fix_winding(V, F): vol = 0.0 for f in F: for k in range(1, len(f) - 1): vol += float(np.dot(V[f[0]], np.cross(V[f[k]], V[f[k + 1]]))) return F if vol >= 0 else [list(reversed(f)) for f in F] def merge(parts): Vs, Fs, off = [], [], 0 for V, F in parts: c = V.mean(0) F = fix_winding(V - c, [list(map(int, f)) for f in F]) Vs.append(V) Fs += [[i + off for i in f] for f in F] off += len(V) return np.concatenate(Vs), Fs # ------------------------------------------------------------------------------------------------ rock shape class Rock: """A star-shaped rock: smooth union of ellipsoid lobes, chiselled by planes, then broken up by noise.""" def __init__(s, spec): s.spec = spec rng = np.random.default_rng(spec['seed']) s.so = Vector(rng.uniform(-50, 50, 3).tolist()) s.lobes = [] for c, r, yaw in spec['lobes']: cy, sy = math.cos(yaw), math.sin(yaw) s.lobes.append((np.array(c, float), np.array(r, float), np.array([[cy, -sy, 0], [sy, cy, 0], [0, 0, 1.0]]))) def support(n): return max(c @ n + np.linalg.norm((R.T @ n) * r) for c, r, R in s.lobes) s.planes = [] for az, el, cut in spec.get('fixed_planes', []): n = np.array([math.cos(el) * math.cos(az), math.cos(el) * math.sin(az), math.sin(el)]) s.planes.append((n, support(n) * cut)) k = 0 while k < spec['planes']: n = unit(rng.normal(size=3)) if n[2] < -0.15: continue s.planes.append((n, support(n) * rng.uniform(*spec['cut']))) k += 1 cs = np.array([c for c, _, _ in s.lobes]) ws = np.array([np.prod(r) for _, r, _ in s.lobes]) s.c0 = (cs * ws[:, None]).sum(0) / ws.sum() s.c0[2] = max(0.05, s.c0[2] * 0.6) s.rmax = max(np.linalg.norm(c - s.c0) + r.max() for c, r, _ in s.lobes) + 0.3 s.zf = -spec['sink'] s.strata = spec.get('strata', 0.0) s.sn = unit([0.14, 0.06, 1.0]) s.seats = [] assert s.sdf(s.c0[None])[0] < 0, 'ray origin is outside the rock' def sdf(s, P): d = None for c, r, R in s.lobes: q = ((P - c) @ R) / r di = (np.sqrt((q * q).sum(1)) - 1.0) * r.min() d = di if d is None else smin(d, di, 0.22) for n, h in s.planes: d = smax(d, P @ n - h, 0.02) return d def cast(s, D): steps = 72 lo = np.zeros(len(D)) hi = np.full(len(D), s.rmax / steps) found = np.zeros(len(D), bool) for k in range(steps, 0, -1): # from outside in: the outermost crossing t = s.rmax * k / steps new = (s.sdf(s.c0 + D * t) < 0) & ~found lo[new] = t hi[new] = t + s.rmax / steps found |= new for _ in range(20): mid = (lo + hi) * 0.5 ins = s.sdf(s.c0 + D * mid[:, None]) < 0 lo = np.where(ins, mid, lo) hi = np.where(ins, hi, mid) return s.c0 + D * lo[:, None] def detail(s, P, fine): n = len(P) H = np.zeros(n) crack = np.zeros(n) strata = np.zeros(n) fr, vor, nv, ns = noise.fractal, noise.voronoi, noise.noise_vector, noise.noise so, sn, sh = s.so, s.sn, s.strata for i in range(n): p = P[i] v = Vector((p[0], p[1], p[2])) + so h = fr(v * 0.7, 1.0, 2.0, 3) * 0.09 q = v * 1.8 dist, pts = vor(q) # broken, tilted chips, joined along their borders e = dist[1] - dist[0] a = (q - pts[0]).dot(nv(pts[0] * 3.17)) if e < 0.16: k = 1.0 - e / 0.16 k = k * k * (3.0 - 2.0 * k) a += ((q - pts[1]).dot(nv(pts[1] * 3.17)) - a) * 0.5 * k if fine: m = ns(v * 0.55) # grooves along some of the borders only if m > 0.0: k *= k * min(1.0, m * 5.0) h -= 0.022 * k crack[i] = k h += a * 0.085 if fine: h += fr(v * 4.5, 1.0, 2.0, 4) * 0.014 + fr(v * 14.0, 1.0, 2.0, 2) * 0.004 if sh: w = (p[0] * sn[0] + p[1] * sn[1] + p[2] * sn[2]) / sh w += 0.3 * math.sin(w * 1.7) g = (2.0 * (w - math.floor(w)) - 1.0) ** 6 # thin groove between two layers h -= 0.022 * g strata[i] = w H[i] = h return H, crack, strata def surface(s, D, fine=False, sockets=True): P = s.cast(D) H, crack, strata = s.detail(P, fine) P = P + D * H[:, None] z = P[:, 2] low = z < s.zf # flat underside, buried in the terrain P[low, 2] = s.zf + (z[low] - s.zf) * 0.12 seat = np.zeros(len(P)) sock = np.zeros(len(P)) if sockets: for st in s.seats: d = np.linalg.norm(P - st['p'], axis=1) rho = st['rho'] w = np.clip(1.0 - (d / (1.15 * rho)) ** 2, 0, 1) ** 2 P -= st['n'][None] * (0.22 * rho * w)[:, None] seat = np.maximum(seat, 1.0 - sstep(d, 0.9 * rho, 2.6 * rho)) sock = np.maximum(sock, 1.0 - sstep(d, 0.45 * rho, 0.95 * rho)) return P, dict(seat=seat, socket=sock, crack=crack, strata=strata) def pick_seats(P, N, F, spec, rng, lib): """Where the clusters sit: gathered around a few hot spots, largest first, never crowding each other, only where the rock carries the whole foot of the cluster (not on a ridge or over an edge), and never where the cluster would run into one that is already placed.""" big = [i for i in lib['info'] if i['kind'] == 'cluster' and i['big']] small = [i for i in lib['info'] if i['kind'] == 'cluster' and not i['big']] bvh = BVHTree.FromPolygons(P.tolist(), F.tolist()) cand = np.where((P[:, 2] > 0.14) & (N[:, 2] > -0.3))[0] hot = P[rng.choice(cand, 3, replace=False)] d2 = ((P[cand][:, None, :] - hot[None]) ** 2).sum(-1) w = np.exp(-d2 / (2 * 0.55 ** 2)).sum(1) + 0.04 order = rng.choice(len(cand), len(cand), replace=False, p=w / w.sum()) def fit(p, n, rho): """Mean normal of the rock under the footprint, or None where the rock falls away from it.""" x = unit(np.cross(n, [0.3, 0.5, 0.81])) y = np.cross(n, x) ns = [n] for k in range(8): a = k * math.pi / 4 q = p + (x * math.cos(a) + y * math.sin(a)) * 0.75 * rho hit = bvh.ray_cast(Vector((q + n * (0.5 + rho)).tolist()), Vector((-n).tolist())) if hit[0] is None or (np.array(hit[0]) - p) @ n < -FOOT_DROP * rho: return None hn = np.array(hit[1]) ns.append(hn if hn @ n > 0 else -hn) return unit(np.sum(ns, 0)) seats = [] for rank, rho in enumerate(spec['radii']): placed = False while not placed and rho >= 0.13: # a cluster that finds no room is tried smaller for k in order: p = P[cand[k]] if p[2] < 0.14 + rho * 0.5 or any(np.linalg.norm(p - st['p']) <= (rho + st['rho']) * 0.92 for st in seats): continue n = fit(p, N[cand[k]], rho) if n is None: continue pick = big if rank < 3 else small ci = pick[int(rng.integers(len(pick)))] x = unit(np.cross(n, unit(rng.normal(size=3)))) y = np.cross(n, x) sc_, o = rho / CLUSTER_R, p - n * (SEAT_SINK * rho) tr = BVHTree.FromPolygons(((ci['V'] @ np.stack([x, y, n])) * sc_ + o).tolist(), ci['F']) if any(tr.overlap(st['bvh']) for st in seats): continue seats.append(dict(p=p.copy(), n=n, rho=rho, x=x, y=y, o=o, s=sc_, ci=ci, bvh=tr, rank=rank)) placed = True break rho *= 0.85 return seats # ------------------------------------------------------------------------------------------------ ore pieces def nugget(rng, size, n): V, F, _ = quad_sphere(n) V = V * (size * rng.uniform(0.78, 1.22, 3)) for _ in range(int(rng.integers(7, 11))): # chisel flats nr = unit(rng.normal(size=3)) d = V @ nr - size * rng.uniform(0.52, 0.86) m = d > 0 V[m] -= np.outer(d[m], nr) V += rng.normal(scale=size * 0.025, size=V.shape) return V @ randrot(rng).T, F.tolist() def shard(rng, L, rad, sides, double=False): ang = (np.arange(sides) + rng.uniform(-0.22, 0.22, sides)) * 2 * math.pi / sides rr = rad * rng.uniform(0.82, 1.18, sides) zb = 0.2 * L if double else -0.07 zs = L * rng.uniform(0.62, 0.82) + rng.uniform(-0.05, 0.05, sides) * L tp = rng.uniform(0.78, 1.0) r0 = np.stack([rr * np.cos(ang), rr * np.sin(ang), np.full(sides, zb) + (rng.uniform(-0.04, 0.04, sides) * L if double else 0)], 1) r1 = np.stack([rr * tp * np.cos(ang), rr * tp * np.sin(ang), zs], 1) apex = np.array([rng.uniform(-0.45, 0.45) * rad, rng.uniform(-0.45, 0.45) * rad, L]) bot = np.array([rng.uniform(-0.4, 0.4) * rad, rng.uniform(-0.4, 0.4) * rad, 0.0]) if double else np.array([0, 0, zb]) V = np.concatenate([r0, r1, apex[None], bot[None]]) s = sides F = [] for i in range(s): j = (i + 1) % s F += [[i, j, s + j, s + i], [s + i, s + j, 2 * s], [j, i, 2 * s + 1]] return V, F def nodule(rng, r, n): V, F, _ = quad_sphere(n) return (V * (r * rng.uniform(0.86, 1.0, 3))) @ randrot(rng).T, F.tolist() def pack_blobs(rng, n, r_first, r_rng, spread, zr): """Blobs that overlap their neighbours a little, like a grown cluster.""" placed = [(np.array([0.0, 0.0, r_first * zr[1] * 0.7]), r_first)] tries = 0 while len(placed) < n and tries < 600: tries += 1 r = rng.uniform(*r_rng) a = rng.uniform(0, 2 * math.pi) d = rng.uniform(0.04, spread) c = np.array([d * math.cos(a), d * math.sin(a), r * rng.uniform(*zr)]) ds = [np.linalg.norm(c - pc) / (r + pr) for pc, pr in placed] if 0.52 < min(ds) < 0.9: placed.append((c, r)) return placed def gen_cluster(ore, rng, big): parts = [] if ore == 'Ferrite': n = int(rng.integers(6, 9)) if big else int(rng.integers(3, 6)) for c, r in pack_blobs(rng, n, rng.uniform(0.115, 0.14) if big else rng.uniform(0.085, 0.11), (0.05, 0.10), 0.20, (0.15, 0.7)): V, F = nugget(rng, r, 3 if r > 0.08 else 2) parts.append((V + c, F)) elif ore == 'Cobalt': n = int(rng.integers(7, 11)) if big else int(rng.integers(4, 7)) for j in range(n): main = j < (2 if big else 1) L = rng.uniform(0.36, 0.52) if main else rng.uniform(0.12, 0.30) rad = L * rng.uniform(0.10, 0.15) + 0.012 a = rng.uniform(0, 2 * math.pi) rr = (0.05 if main else 0.17) * math.sqrt(rng.uniform(0.05, 1)) lean = rng.uniform(0.05, 0.3) if main else rng.uniform(0.3, 0.95) la = a + rng.uniform(-0.6, 0.6) V, F = shard(rng, L, rad, int(rng.choice([5, 6, 6, 6, 4]))) V = V @ rot_to([lean * math.cos(la), lean * math.sin(la), 1.0]).T + np.array([rr * math.cos(a), rr * math.sin(a), 0]) parts.append((V, F)) else: n = int(rng.integers(8, 13)) if big else int(rng.integers(4, 8)) for c, r in pack_blobs(rng, n, rng.uniform(0.105, 0.13) if big else rng.uniform(0.08, 0.105), (0.045, 0.09), 0.21, (0.15, 0.7)): V, F = nodule(rng, r, 4 if r > 0.1 else 3) parts.append((V + c, F)) return merge(parts) def gen_chunk(ore, rng): parts = [] if ore == 'Ferrite': V, F = nugget(rng, 0.14, 3) parts.append((V, F)) for _ in range(int(rng.integers(1, 3))): V, F = nugget(rng, rng.uniform(0.06, 0.085), 2) parts.append((V + unit(rng.normal(size=3)) * 0.12, F)) elif ore == 'Cobalt': for k in range(int(rng.integers(2, 4))): L = 0.34 if k == 0 else rng.uniform(0.18, 0.26) V, F = shard(rng, L, L * rng.uniform(0.13, 0.17), int(rng.choice([5, 6, 6])), double=True) V = (V - np.array([0, 0, L * 0.5])) @ (np.eye(3) if k == 0 else randrot(rng)).T parts.append((V + (0 if k == 0 else rng.normal(scale=0.025, size=3)), F)) else: c0 = np.zeros(3) for k in range(int(rng.integers(2, 4))): r = rng.uniform(0.08, 0.105) if k == 0 else rng.uniform(0.055, 0.08) c = c0 if k == 0 else unit(rng.normal(size=3)) * (0.10 + r) * 0.72 V, F = nodule(rng, r, 4 if k == 0 else 3) parts.append((V + c, F)) V, F = merge(parts) V -= (V.max(0) + V.min(0)) * 0.5 V *= CHUNK_SIZE / (V.max(0) - V.min(0)).max() return V, F # ------------------------------------------------------------------------------------------------ blender helpers def new_coll(name, parent=None): c = bpy.data.collections.new(name) (parent or sc.collection).children.link(c) return c def new_obj(name, V, F, coll, smooth=False): me = bpy.data.meshes.new(name) me.from_pydata(np.asarray(V, float).tolist(), [], F.tolist() if isinstance(F, np.ndarray) else F) me.update() if smooth: me.polygons.foreach_set('use_smooth', [True] * len(me.polygons)) ob = bpy.data.objects.new(name, me) coll.objects.link(ob) return ob def select(objs, active=None): for o in bpy.context.view_layer.objects: o.select_set(False) for o in objs: o.select_set(True) bpy.context.view_layer.objects.active = active or objs[0] def add_triangulate(ob): """The bake and the FBX use this triangulation; the mesh itself stays quads.""" m = ob.modifiers.new('Triangulate', 'TRIANGULATE') m.quad_method = 'SHORTEST_DIAGONAL' m.min_vertices = 4 def hull_obj(name, V, coll, ndir): k = np.arange(ndir) + 0.5 phi = np.arccos(1 - 2 * k / ndir) th = math.pi * (1 + 5 ** 0.5) * k dirs = np.stack([np.cos(th) * np.sin(phi), np.sin(th) * np.sin(phi), np.cos(phi)], 1) pts = V[np.unique(np.argmax(V @ dirs.T, axis=0))] bm = bmesh.new() res = bmesh.ops.convex_hull(bm, input=[bm.verts.new(p) for p in pts.tolist()]) junk = [g for g in res['geom_interior'] + res['geom_unused'] if isinstance(g, bmesh.types.BMVert)] if junk: bmesh.ops.delete(bm, geom=list(set(junk)), context='VERTS') bmesh.ops.recalc_face_normals(bm, faces=bm.faces) me = bpy.data.meshes.new(name) bm.to_mesh(me) bm.free() ob = bpy.data.objects.new(name, me) coll.objects.link(ob) ob.display_type = 'WIRE' ob.hide_render = True return ob def audit(ob): bm = bmesh.new() bm.from_mesh(ob.data) r = dict(verts=len(bm.verts), quads=sum(len(f.verts) == 4 for f in bm.faces), tris=sum(len(f.verts) == 3 for f in bm.faces), ngons=sum(len(f.verts) > 4 for f in bm.faces), open_edges=sum(len(e.link_faces) == 1 for e in bm.edges), nonmanifold_edges=sum(len(e.link_faces) > 2 for e in bm.edges), zero_area=sum(f.calc_area() < 1e-9 for f in bm.faces), uv=bool(ob.data.uv_layers)) r['game_tris'] = r['quads'] * 2 + r['tris'] bm.free() return r # ------------------------------------------------------------------------------------------------ shader nodes def rgba(c): return tuple(c) + (1.0,) if len(c) == 3 else tuple(c) def new_mat(name): m = bpy.data.materials.new(name) try: m.use_nodes = True except Exception: pass m.node_tree.nodes.clear() return m class NT: def __init__(s, mat): s.mat, s.t = mat, mat.node_tree s.N, s.L = s.t.nodes, s.t.links def new(s, typ, **kw): n = s.N.new(typ) for k, v in kw.items(): setattr(n, k, v) return n def put(s, sock, v): if isinstance(v, bpy.types.NodeSocket): s.L.new(v, sock) elif isinstance(v, (tuple, list)): sock.default_value = rgba(v) if len(sock.default_value) == 4 else tuple(v)[:3] else: sock.default_value = v @staticmethod def by_id(socks, ident): for k in socks: if k.identifier == ident: return k raise KeyError(ident) def math(s, op, a, b=None, clamp=False): n = s.new('ShaderNodeMath', operation=op, use_clamp=clamp) s.put(n.inputs[0], a) if b is not None: s.put(n.inputs[1], b) return n.outputs[0] def mul(s, a, b): return s.math('MULTIPLY', a, b) def add(s, a, b, clamp=False): return s.math('ADD', a, b, clamp) def sub(s, a, b): return s.math('SUBTRACT', a, b) def inv(s, a): return s.math('SUBTRACT', 1.0, a, True) def smooth(s, x, lo, hi): n = s.new('ShaderNodeMapRange', interpolation_type='SMOOTHSTEP') s.put(n.inputs[0], x) n.inputs[1].default_value, n.inputs[2].default_value = lo, hi return n.outputs[0] def mix(s, f, a, b, blend='MIX'): n = s.new('ShaderNodeMix', data_type='RGBA', blend_type=blend) s.put(s.by_id(n.inputs, 'Factor_Float'), f) s.put(s.by_id(n.inputs, 'A_Color'), a) s.put(s.by_id(n.inputs, 'B_Color'), b) return s.by_id(n.outputs, 'Result_Color') def mixf(s, f, a, b): n = s.new('ShaderNodeMix', data_type='FLOAT') s.put(s.by_id(n.inputs, 'Factor_Float'), f) s.put(s.by_id(n.inputs, 'A_Float'), a) s.put(s.by_id(n.inputs, 'B_Float'), b) return s.by_id(n.outputs, 'Result_Float') def ramp(s, x, stops): n = s.new('ShaderNodeValToRGB') els = n.color_ramp.elements els[0].position, els[0].color = stops[0][0], rgba(stops[0][1]) els[1].position, els[1].color = stops[-1][0], rgba(stops[-1][1]) for p, c in stops[1:-1]: els.new(p).color = rgba(c) s.put(n.inputs[0], x) return n.outputs[0] def noise(s, vec, scale, detail=3.0, rough=0.5, color=False): n = s.new('ShaderNodeTexNoise') s.L.new(vec, n.inputs['Vector']) n.inputs['Scale'].default_value, n.inputs['Detail'].default_value, n.inputs['Roughness'].default_value = scale, detail, rough return n.outputs[1] if color else n.outputs[0] def voronoi(s, vec, scale, feature='F1'): n = s.new('ShaderNodeTexVoronoi', feature=feature) s.L.new(vec, n.inputs['Vector']) n.inputs['Scale'].default_value = scale return n.outputs['Distance'] def warp(s, vec, scale, amt): a = s.new('ShaderNodeVectorMath', operation='SUBTRACT') s.L.new(s.noise(vec, scale, 2.0, 0.5, color=True), a.inputs[0]) a.inputs[1].default_value = (0.5, 0.5, 0.5) b = s.new('ShaderNodeVectorMath', operation='SCALE') s.L.new(a.outputs[0], b.inputs[0]) b.inputs['Scale'].default_value = amt d = s.new('ShaderNodeVectorMath', operation='ADD') s.L.new(vec, d.inputs[0]) s.L.new(b.outputs[0], d.inputs[1]) return d.outputs[0] def attr(s, name): return s.new('ShaderNodeAttribute', attribute_name=name).outputs[2] def ao(s, dist): n = s.new('ShaderNodeAmbientOcclusion', samples=8, only_local=True) n.inputs['Distance'].default_value = dist return n.outputs[1] def sepz(s, vec): n = s.new('ShaderNodeSeparateXYZ') s.L.new(vec, n.inputs[0]) return n.outputs[2] def bump(s, height, dist, normal=None): n = s.new('ShaderNodeBump') n.inputs['Distance'].default_value = dist s.L.new(height, n.inputs['Height']) if normal is not None: s.L.new(normal, n.inputs['Normal']) return n.outputs[0] def finish(s, bc, rough, metal, emis, normal, ao): b = s.new('ShaderNodeBsdfPrincipled') o = s.new('ShaderNodeOutputMaterial') e = s.new('ShaderNodeEmission') s.put(b.inputs['Base Color'], bc) s.put(b.inputs['Roughness'], rough) s.put(b.inputs['Metallic'], metal) s.put(b.inputs['Emission Color'], emis) b.inputs['Emission Strength'].default_value = 1.0 s.L.new(normal, b.inputs['Normal']) s.L.new(b.outputs[0], o.inputs[0]) cc = s.new('ShaderNodeCombineColor') s.put(cc.inputs[0], ao) s.put(cc.inputs[1], rough) s.put(cc.inputs[2], metal) return dict(mat=s.mat, nt=s, bsdf=b, out=o, emit=e, BC=bc, E=emis, ORM=cc.outputs[0]) DARK, MID, LIGHT = (0.030, 0.032, 0.037), (0.085, 0.088, 0.096), (0.14, 0.14, 0.15) BROWN, EDGE, SILT, CAV = (0.085, 0.066, 0.05), (0.20, 0.20, 0.21), (0.17, 0.16, 0.14), (0.010, 0.010, 0.012) def mat_rock_proc(name, ore, strata): """Basalt for the bake source. Reads the point attributes seat / socket / crack / strata.""" g = ORES[ore] t = NT(new_mat(name)) P = t.new('ShaderNodeTexCoord').outputs['Object'] geo = t.new('ShaderNodeNewGeometry') seat, sock, crack = t.attr('seat'), t.attr('socket'), t.attr('crack') ao = t.ao(0.30) cav = t.inv(t.smooth(ao, 0.45, 0.95)) edge = t.smooth(geo.outputs['Pointiness'], 0.507, 0.545) n1, n2 = t.noise(P, 1.2, 5, 0.55), t.noise(P, 8.0, 6, 0.62) n3, n4 = t.noise(P, 42.0, 3, 0.5), t.noise(P, 0.55, 2, 0.5) col = t.ramp(n1, [(0.32, DARK), (0.72, MID)]) col = t.mix(t.mul(t.smooth(n2, 0.5, 0.78), 0.55), col, LIGHT) col = t.mix(t.mul(t.smooth(n4, 0.5, 0.68), 0.5), col, BROWN) ves = t.mul(t.inv(t.smooth(t.voronoi(P, 60.0), 0.12, 0.42)), t.smooth(t.noise(P, 3.3, 2, 0.5), 0.5, 0.62)) # gas pits hair = t.mul(t.inv(t.smooth(t.voronoi(t.warp(P, 2.2, 0.18), 7.0, 'DISTANCE_TO_EDGE'), 0.0, 0.014)), t.smooth(t.noise(P, 1.7, 2, 0.5), 0.52, 0.66)) # hairline cracks silt = t.mul(t.smooth(t.sepz(geo.outputs['Normal']), 0.55, 0.95), t.smooth(t.noise(P, 2.4, 4, 0.6), 0.42, 0.7)) col = t.mix(t.mul(silt, 0.5), col, SILT) for m, a in ((ves, 0.75), (hair, 0.8), (crack, 0.75), (cav, 0.6)): col = t.mix(t.mul(m, a), col, CAV) col = t.mix(t.mul(edge, 0.5), col, EDGE) h = t.sub(t.add(t.mul(n2, 0.5), t.mul(n3, 0.25)), t.add(t.mul(ves, 0.9), hair)) if strata: # laminated like shell: pale and dark layers w = t.attr('strata') fr = t.math('FRACT', w) seam = t.add(t.smooth(fr, 0.90, 0.99), t.inv(t.smooth(fr, 0.01, 0.10)), True) col = t.mix(t.mul(t.smooth(t.math('FRACT', t.mul(w, 0.5)), 0.46, 0.54), 0.45), col, (0.20, 0.17, 0.185)) col = t.mix(t.mul(seam, 0.7), col, CAV) stain = t.mul(seat, t.smooth(t.noise(P, 3.0, 4, 0.6), 0.3, 0.7)) col = t.mix(t.mul(stain, g['stain_amt']), col, g['stain']) vd = t.voronoi(t.warp(P, 1.2, 0.6), g['vein_scale'], 'DISTANCE_TO_EDGE') vmask = t.mul(t.smooth(t.add(seat, t.mul(t.sub(n4, 0.5), 0.5)), 0.45, 0.85), t.smooth(t.noise(P, 2.1, 3, 0.6), 0.50, 0.66)) vein = t.mul(t.inv(t.smooth(vd, 0.002, 0.013)), vmask) col = t.mix(t.mul(t.mul(sock, t.smooth(t.noise(P, 5.0, 4, 0.6), 0.3, 0.6)), 0.75), col, g['socket']) fleck = t.mul(t.mul(sock, t.inv(t.smooth(t.voronoi(P, 38.0), 0.10, 0.22))), t.smooth(t.noise(P, 6.0, 2, 0.5), 0.45, 0.6)) vein = t.add(vein, fleck, True) # ore left in the socket once the cluster is gone col = t.mix(vein, col, g['vein']) emask = vein emis = t.mix(emask, (0, 0, 0), g['glow']) rough = t.mixf(silt, t.sub(0.86, t.mul(edge, 0.22)), 0.95) rough = t.mixf(vein, rough, g['vein_rough']) return t.finish(col, rough, t.mul(vein, g['vein_metal']), emis, t.bump(h, 0.012), ao) def mat_ore_proc(name, ore): g = ORES[ore] t = NT(new_mat(name)) P = t.new('ShaderNodeTexCoord').outputs['Object'] geo = t.new('ShaderNodeNewGeometry') rnd = geo.outputs['Random Per Island'] ao = t.ao(0.10) crev = t.inv(t.smooth(ao, 0.35, 0.92)) foot = t.inv(t.smooth(t.sepz(P), -0.03, 0.045)) # where the piece meets the rock if ore == 'Ferrite': n, nl = t.noise(P, 22.0, 4, 0.6), t.noise(P, 5.0, 3, 0.5) col = t.mix(rnd, (1.0, 0.58, 0.20), (0.92, 0.40, 0.11)) col = t.mix(t.mul(t.smooth(n, 0.45, 0.72), 0.5), col, (1.0, 0.74, 0.38)) col = t.mix(t.mul(crev, 0.85), col, (0.15, 0.045, 0.012)) col = t.mix(t.mul(foot, 0.8), col, DARK) rough = t.add(t.add(0.28, t.mul(n, 0.2)), t.mul(crev, 0.3), True) metal = t.inv(t.add(t.mul(foot, 0.9), t.mul(crev, 0.3), True)) emask = t.add(t.mul(crev, 0.9), t.mul(t.smooth(nl, 0.62, 0.8), 0.25), True) bev = t.new('ShaderNodeBevel', samples=6) bev.inputs['Radius'].default_value = 0.012 nrm = t.bump(t.add(t.mul(n, 0.6), t.mul(t.noise(P, 60.0, 2, 0.5), 0.3)), 0.004, bev.outputs[0]) elif ore == 'Cobalt': bev = t.new('ShaderNodeBevel', samples=6) bev.inputs['Radius'].default_value = 0.006 dot = t.new('ShaderNodeVectorMath', operation='DOT_PRODUCT') t.L.new(bev.outputs[0], dot.inputs[0]) t.L.new(geo.outputs['True Normal'], dot.inputs[1]) edge = t.math('MULTIPLY', t.sub(1.0, dot.outputs['Value']), 7.0, True) zg = t.smooth(t.sepz(P), 0.0, 0.42) n = t.noise(P, 9.0, 4, 0.55) frost = t.mul(t.smooth(t.noise(P, 30.0, 3, 0.6), 0.6, 0.82), 0.25) col = t.mix(zg, (0.006, 0.09, 0.16), (0.02, 0.28, 0.46)) col = t.mix(frost, col, (0.45, 0.72, 0.78)) col = t.mix(t.mul(edge, 0.6), col, (0.35, 0.80, 0.90)) col = t.mix(t.mul(foot, 0.7), col, DARK) rough = t.add(t.add(0.07, t.mul(frost, 0.7)), t.mul(edge, 0.1), True) metal = t.mul(foot, 0.0) emask = t.add(t.add(0.07, t.mul(crev, 0.5)), t.add(t.mul(t.smooth(n, 0.5, 0.75), 0.30), t.mul(edge, 0.4)), True) emask = t.mul(emask, t.sub(1.0, t.mul(zg, 0.4))) nrm = t.bump(t.noise(P, 26.0, 3, 0.5), 0.002, bev.outputs[0]) else: n = t.noise(P, 14.0, 3, 0.5) col = t.mix(rnd, (0.85, 0.55, 0.70), (0.92, 0.78, 0.84)) col = t.mix(t.mul(t.smooth(n, 0.45, 0.7), 0.55), col, (0.70, 0.26, 0.55)) col = t.mix(t.mul(crev, 0.8), col, (0.22, 0.05, 0.16)) col = t.mix(t.mul(foot, 0.6), col, DARK) rough = t.add(t.add(0.16, t.mul(n, 0.1)), t.mul(crev, 0.3), True) metal = t.mul(t.inv(foot), 0.25) emask = t.add(t.add(0.08, t.mul(crev, 0.75)), t.mul(rnd, 0.12), True) nrm = t.bump(t.noise(P, 45.0, 3, 0.5), 0.0015) return t.finish(col, rough, metal, t.mix(emask, (0, 0, 0), g['glow']), nrm, ao) def mat_game(name, tex, emis): """What the game gets: four textures on a Principled BSDF. The normal map is Unreal's (green down).""" t = NT(new_mat(name)) def img(ch, data): im = bpy.data.images.load(tex[ch], check_existing=True) if data: im.colorspace_settings.name = 'Non-Color' n = t.new('ShaderNodeTexImage') n.image = im n.label = ch return n.outputs[0] orm = t.new('ShaderNodeSeparateColor') t.L.new(img('ORM', True), orm.inputs[0]) sn = t.new('ShaderNodeSeparateColor') t.L.new(img('N', True), sn.inputs[0]) cn = t.new('ShaderNodeCombineColor') t.L.new(sn.outputs[0], cn.inputs[0]) t.L.new(t.sub(1.0, sn.outputs[1]), cn.inputs[1]) t.L.new(sn.outputs[2], cn.inputs[2]) nm = t.new('ShaderNodeNormalMap') t.L.new(cn.outputs[0], nm.inputs['Color']) b = t.new('ShaderNodeBsdfPrincipled') o = t.new('ShaderNodeOutputMaterial') t.L.new(t.mix(0.6, img('BC', False), orm.outputs[0], 'MULTIPLY'), b.inputs['Base Color']) t.L.new(orm.outputs[1], b.inputs['Roughness']) t.L.new(orm.outputs[2], b.inputs['Metallic']) t.L.new(img('E', False), b.inputs['Emission Color']) b.inputs['Emission Strength'].default_value = emis t.L.new(nm.outputs[0], b.inputs['Normal']) t.L.new(b.outputs[0], o.inputs[0]) t.mat['emissive_strength'] = emis return t.mat # ------------------------------------------------------------------------------------------------ baking def setup_cycles(): sc.render.engine = 'CYCLES' dev = 'CPU' if not arg('--cpu'): try: prefs = bpy.context.preferences.addons['cycles'].preferences for typ in ('HIP', 'OPTIX', 'CUDA', 'ONEAPI'): try: prefs.compute_device_type = typ prefs.get_devices() if [d for d in prefs.devices if d.type == typ]: for d in prefs.devices: d.use = (d.type == typ) dev = typ break except Exception: pass except Exception as e: log('no cycles prefs', e) sc.cycles.device = 'GPU' if dev != 'CPU' else 'CPU' sc.cycles.use_adaptive_sampling = False sc.cycles.use_denoising = False try: sc.render.bake.margin_type = 'EXTEND' except Exception: pass log('cycles device', dev) return dev def bake_set(name, targets, sources, PM, s2a, extr=0.0): """Bake N / BC / ORM / E. s2a: from the high source onto the game mesh; else each target onto itself.""" if s2a: tm = new_mat('_bake_target') tree = tm.node_tree for ob in targets: ob.data.materials.clear() ob.data.materials.append(tm) else: tree = PM['nt'].t tn = tree.nodes.new('ShaderNodeTexImage') tree.nodes.active = tn tn.select = True nt = PM['nt'] out = {} sc.cycles.samples = SPP for ch in ('N', 'BC', 'ORM', 'E'): img = bpy.data.images.new('T_%s_%s' % (name, ch), RES, RES, alpha=False, is_data=ch in ('N', 'ORM')) img.pixels.foreach_set(np.tile(np.array((0.5, 0.5, 1, 1) if ch == 'N' else (0, 0, 0, 1), np.float32), RES * RES)) tn.image = img for l in list(PM['out'].inputs[0].links): nt.L.remove(l) if ch == 'N': nt.L.new(PM['bsdf'].outputs[0], PM['out'].inputs[0]) else: for l in list(PM['emit'].inputs[0].links): nt.L.remove(l) nt.L.new(PM[ch], PM['emit'].inputs[0]) nt.L.new(PM['emit'].outputs[0], PM['out'].inputs[0]) select(sources + targets if s2a else targets, targets[0]) kw = dict(type='NORMAL' if ch == 'N' else 'EMIT', margin=8 if s2a else 5, use_clear=s2a, use_selected_to_active=s2a) if s2a: kw.update(cage_extrusion=extr, max_ray_distance=extr * 2.5) if ch == 'N': kw.update(normal_space='TANGENT', normal_r='POS_X', normal_g='NEG_Y', normal_b='POS_Z') t1 = time.time() bpy.ops.object.bake(**kw) path = os.path.join(TEX, 'T_%s_%s.png' % (name, ch)) img.filepath_raw = path img.file_format = 'PNG' img.save() bpy.data.images.remove(img) out[ch] = path log(' baked', os.path.basename(path), '%.1fs' % (time.time() - t1)) tree.nodes.remove(tn) if s2a: for ob in targets: ob.data.materials.clear() bpy.data.materials.remove(tm) return out # ------------------------------------------------------------------------------------------------ build: library def build_library(ore, oi, coll, ucx_coll): g = ORES[ore] rng = np.random.default_rng(900 + oi * 37) objs, info = [], [] for k in range(9): cluster = k < 6 V, F = gen_cluster(ore, rng, big=k < 2) if cluster else gen_chunk(ore, rng) name = ('SM_OreCluster_%s_%02d' % (ore, k + 1)) if cluster else ('SM_OreChunk_%s_%02d' % (ore, k - 5)) ob = new_obj(name, V, F, coll, smooth=(ore == 'Pearl')) ob.location = (k * 0.95, 8.0 + oi * 1.6, 0.0 if cluster else CHUNK_SIZE * 0.5) ob['ore_type'] = g['key'] ob['kind'] = 'cluster' if cluster else 'loose_chunk' add_triangulate(ob) hull = hull_obj('UCX_%s_01' % name, V, ucx_coll, 20) hull.location = ob.location objs.append(ob) info.append(dict(name=name, kind=ob['kind'], big=k < 2, hull=hull, V=V, F=F, size_m=[round(float(x), 3) for x in (V.max(0) - V.min(0))])) bpy.context.view_layer.update() select(objs) bpy.ops.object.mode_set(mode='EDIT') bpy.ops.mesh.select_all(action='SELECT') bpy.ops.uv.smart_project(angle_limit=66 * D2R, island_margin=0.012) bpy.ops.object.mode_set(mode='OBJECT') pm = mat_ore_proc('M_Ore_%s_bake' % ore, ore) for ob in objs: ob.data.materials.append(pm['mat']) tex = None if not NOBAKE: log('bake ore atlas', ore) tex = bake_set('Ore_%s' % ore, objs, [], pm, s2a=False) gm = mat_game('M_Ore_%s' % ore, tex, 1.6) for ob in objs: ob.data.materials.clear() ob.data.materials.append(gm) bpy.data.materials.remove(pm['mat']) return dict(objs=objs, info=info, tex=tex) # ------------------------------------------------------------------------------------------------ build: patch def build_patch(spec, pi, lib, coll, ucx_coll): ore = spec['ore'] g = ORES[ore] rock = Rock(spec) rng = np.random.default_rng(spec['seed'] + 1000) D, F, fid = quad_sphere(spec['nlo']) P0, _ = rock.surface(D, sockets=False) rock.seats = pick_seats(P0, vnormals(P0, F), F, spec, rng, lib) Plo, _ = rock.surface(D) rname = 'SM_OreRock_' + spec['name'] lo = new_obj(rname, Plo, F, coll, smooth=True) lo['ore_type'] = g['key'] # UVs: the twelve cube edges are the seams, six islands bm = bmesh.new() bm.from_mesh(lo.data) for e in bm.edges: lf = e.link_faces e.seam = bool(len(lf) == 2 and fid[lf[0].index] != fid[lf[1].index]) bm.to_mesh(lo.data) bm.free() lo.data.uv_layers.new(name='UVMap') select([lo]) bpy.ops.object.mode_set(mode='EDIT') bpy.ops.mesh.select_all(action='SELECT') bpy.ops.uv.unwrap(method='ANGLE_BASED', margin=0.02) try: bpy.ops.uv.average_islands_scale() bpy.ops.uv.pack_islands(rotate=True, margin=0.012) except Exception as e: log(' pack_islands skipped:', e) bpy.ops.object.mode_set(mode='OBJECT') add_triangulate(lo) tex = None if NOBAKE: lo.data.materials.append(mat_rock_proc('M_%s_proc' % rname, ore, rock.strata)['mat']) else: t1 = time.time() Dh, Fh, _ = quad_sphere(HI_N) Ph, at = rock.surface(Dh, fine=True) hi = new_obj(rname + '_HI', Ph, Fh, coll, smooth=True) for k, v in at.items(): hi.data.attributes.new(k, 'FLOAT', 'POINT').data.foreach_set('value', v.astype(np.float32)) pm = mat_rock_proc('M_%s_bake' % rname, ore, rock.strata) hi.data.materials.append(pm['mat']) log(' bake source %d quads in %.1fs' % (len(Fh), time.time() - t1)) tex = bake_set('OreRock_' + spec['name'], [lo], [hi], pm, s2a=True, extr=0.07) me = hi.data bpy.data.objects.remove(hi) bpy.data.meshes.remove(me) bpy.data.materials.remove(pm['mat']) lo.data.materials.append(mat_game('M_' + rname, tex, 3.0)) hull = hull_obj('UCX_%s_01' % rname, Plo, ucx_coll, 42) root = bpy.data.objects.new('OrePatch_' + spec['name'], None) root.empty_display_type = 'PLAIN_AXES' root.empty_display_size = 0.5 coll.objects.link(root) root['ore_type'] = g['key'] for ob in (lo, hull): ob.parent = root # clusters in their sockets bvh = BVHTree.FromPolygons(Plo.tolist(), F.tolist()) insts, rows, gaps = [], [], [] for k, st in enumerate(rock.seats): rho, z, x, y, s, o, ci = st['rho'], st['n'], st['x'], st['y'], st['s'], st['o'], st['ci'] M = Matrix.Identity(4) for r in range(3): M[r][0], M[r][1], M[r][2], M[r][3] = x[r] * s, y[r] * s, z[r] * s, o[r] src = bpy.data.objects[ci['name']] ob = bpy.data.objects.new('OrePatch_%s_Ore_%02d' % (spec['name'], k + 1), src.data) coll.objects.link(ob) ob.parent = root ob.matrix_local = M add_triangulate(ob) units = 2 if st['rank'] < 4 else 1 ob['ore_type'], ob['ore_units'], ob['cluster_mesh'] = g['key'], units, ci['name'] insts.append(ob) # is the foot buried: rock surface above (+) the cluster's base plane, at 12 points round the foot (70 % radius) foot = 9.0 for a in range(12): q = o + (x * math.cos(a * math.pi / 6) + y * math.sin(a * math.pi / 6)) * 0.7 * rho hit = bvh.ray_cast(Vector((q + z * (0.5 + rho)).tolist()), Vector((-z).tolist())) foot = min(foot, float((np.array(hit[0]) - q) @ z) if hit[0] is not None else -9.0) gaps.append(foot) e = M.to_euler('XYZ') q = M.to_quaternion() rows.append(dict(name='Ore_%02d' % (k + 1), object=ob.name, mesh=ci['name'], units=units, radius_m=round(rho, 3), pos_m=[round(float(v), 4) for v in o], normal=[round(float(v), 4) for v in z], quat_wxyz=[round(v, 5) for v in q], euler_xyz_deg=[round(math.degrees(v), 3) for v in e], scale=round(s, 4))) ext = Plo.max(0) - Plo.min(0) bpy.context.view_layer.update() return dict(spec=spec, root=root, rock=lo, hull=hull, insts=insts, tex=tex, rows=rows, size=[round(float(v), 3) for v in ext], above=round(float(Plo[:, 2].max()), 3), buried=round(float(-Plo[:, 2].min()), 3), gaps=gaps) # ------------------------------------------------------------------------------------------------ export def export_fbx(objs, path): select(objs) bpy.ops.export_scene.fbx(filepath=path, use_selection=True, object_types={'MESH', 'EMPTY'}, use_mesh_modifiers=True, mesh_smooth_type='FACE', add_leaf_bones=False, bake_anim=False, axis_forward='-Y', axis_up='Z', global_scale=1.0, apply_unit_scale=True, apply_scale_options='FBX_SCALE_NONE', use_space_transform=True, bake_space_transform=False, path_mode='STRIP', use_custom_props=True) def export_all(libs, patches): n = 0 for ore, lib in libs.items(): for ob, inf in zip(lib['objs'], lib['info']): keep = ob.location.copy() ob.location = inf['hull'].location = (0, 0, 0) bpy.context.view_layer.update() export_fbx([ob, inf['hull']], os.path.join(FBX, ob.name + '.fbx')) ob.location = inf['hull'].location = keep n += 1 for p in patches: keep = p['root'].location.copy() p['root'].location = (0, 0, 0) bpy.context.view_layer.update() export_fbx([p['rock'], p['hull']], os.path.join(FBX, p['rock'].name + '.fbx')) export_fbx([p['root'], p['rock'], p['hull']] + p['insts'], os.path.join(FBX, 'assembled', p['root'].name + '.fbx')) p['root'].location = keep n += 2 bpy.context.view_layer.update() log('exported', n, 'fbx') # ------------------------------------------------------------------------------------------------ studio, renders def build_studio(): coll = new_coll('Studio') w = bpy.data.worlds.new('OreStudio') sc.world = w try: w.use_nodes = True except Exception: pass N, L = w.node_tree.nodes, w.node_tree.links N.clear() lp, b1, b2, mx, o = (N.new(x) for x in ('ShaderNodeLightPath', 'ShaderNodeBackground', 'ShaderNodeBackground', 'ShaderNodeMixShader', 'ShaderNodeOutputWorld')) b1.inputs[0].default_value, b1.inputs[1].default_value = (0.35, 0.45, 0.55, 1), 0.30 # what the surfaces see b2.inputs[0].default_value, b2.inputs[1].default_value = (0.012, 0.013, 0.016, 1), 1.0 # what the camera sees L.new(lp.outputs['Is Camera Ray'], mx.inputs[0]) L.new(b1.outputs[0], mx.inputs[1]) L.new(b2.outputs[0], mx.inputs[2]) L.new(mx.outputs[0], o.inputs[0]) gm = new_mat('M_StudioGround') t = NT(gm) b, mo = t.new('ShaderNodeBsdfPrincipled'), t.new('ShaderNodeOutputMaterial') b.inputs['Base Color'].default_value, b.inputs['Roughness'].default_value = (0.006, 0.006, 0.007, 1), 0.95 t.L.new(b.outputs[0], mo.inputs[0]) gr = new_obj('Studio_Ground', np.array([[-60, -60, 0], [60, -60, 0], [60, 60, 0], [-60, 60, 0.0]]), [[0, 1, 2, 3]], coll) gr.data.materials.append(gm) gr.hide_select = True cam = bpy.data.objects.new('Studio_Camera', bpy.data.cameras.new('Studio_Camera')) cam.data.lens = 60 coll.objects.link(cam) sc.camera = cam lights = [] for nm, col, size in (('Key', (1.0, 0.95, 0.88), 3.0), ('Rim', (0.6, 0.8, 1.0), 2.0), ('Fill', (0.8, 0.9, 1.0), 4.0)): ld = bpy.data.lights.new('Studio_' + nm, 'AREA') ld.color, ld.size = col, size lo = bpy.data.objects.new('Studio_' + nm, ld) coll.objects.link(lo) lights.append(lo) sc.render.resolution_x, sc.render.resolution_y = (640, 480) if QUICK else (1280, 960) sc.render.image_settings.file_format = 'PNG' sc.view_settings.view_transform = 'AgX' for look in ('AgX - Medium High Contrast', 'Medium High Contrast'): try: sc.view_settings.look = look break except Exception: pass return cam, lights def aim(ob, target): ob.rotation_euler = (Vector(target) - ob.location).to_track_quat('-Z', 'Y').to_euler() def frame(cam, lights, objs, side=1.0, view=None): pts = [] for ob in objs: mw = np.array(ob.matrix_world) V = np.empty(len(ob.data.vertices) * 3) ob.data.vertices.foreach_get('co', V) V = V.reshape(-1, 3) @ mw[:3, :3].T + mw[:3, 3] pts.append(V[V[:, 2] > -0.02]) P = np.concatenate(pts) c = (P.max(0) + P.min(0)) * 0.5 R = float(np.linalg.norm(P - c, axis=1).max()) fov = 2 * math.atan(min(cam.data.sensor_width, cam.data.sensor_width * sc.render.resolution_y / sc.render.resolution_x) * 0.5 / cam.data.lens) d = R / math.sin(fov * 0.5) * 0.94 if view is not None: # a row of pieces: fit the width, look from the front d = (P[:, 0].max() - P[:, 0].min()) * 0.5 / math.tan(math.atan(cam.data.sensor_width * 0.5 / cam.data.lens)) * 1.12 cam.location = Vector(c.tolist()) + Vector(unit(view or [-0.62 * side, -1.0, 0.50]).tolist()) * d aim(cam, c.tolist()) for lo, off, pw in zip(lights, ((-1.4, -1.6, 2.4), (1.8, 1.9, 1.3), (2.2, -1.6, 0.7)), (260, 220, 50)): lo.location = Vector(c.tolist()) + Vector(off) * R * 1.5 lo.data.energy = pw * R * R aim(lo, c.tolist()) def render(path): sc.cycles.samples = RENDER_SPP sc.cycles.use_denoising = True sc.render.filepath = path bpy.ops.render.render(write_still=True) log('rendered', os.path.basename(path)) def load_px(path): im = bpy.data.images.load(path) w, h = im.size a = np.empty(w * h * 4, np.float32) im.pixels.foreach_get(a) bpy.data.images.remove(im) return a.reshape(h, w, 4) def sheet(paths, cols, out): tiles = [load_px(p) for p in paths] h, w = tiles[0].shape[:2] rows = (len(tiles) + cols - 1) // cols big = np.zeros((rows * h, cols * w, 4), np.float32) big[..., 3] = 1 for i, tl in enumerate(tiles): r, c = i // cols, i % cols big[(rows - 1 - r) * h:(rows - r) * h, c * w:(c + 1) * w] = tl im = bpy.data.images.new('_sheet', cols * w, rows * h, alpha=False) im.pixels.foreach_set(big.ravel()) im.filepath_raw = out im.file_format = 'PNG' im.save() bpy.data.images.remove(im) def render_all(libs, patches, cam, lights): everything = [o for p in patches for o in [p['rock']] + p['insts']] + [o for l in libs.values() for o in l['objs']] def show(objs): for o in everything: o.hide_render = o not in objs shots = [] for i, p in enumerate(patches): objs = [p['rock']] + p['insts'] show(objs) frame(cam, lights, objs, side=1.0 if i % 2 == 0 else -1.0) path = os.path.join(PRE, p['root'].name + '.png') render(path) shots.append(path) if len(shots) > 1: sheet(shots, 3 if len(shots) > 2 else 2, os.path.join(PRE, 'OrePatches_sheet.png')) # mining stages of one patch: full, half mined, mined out p = patches[min(1, len(patches) - 1)] objs = [p['rock']] + p['insts'] frame(cam, lights, objs) stages = [] for k, keep in enumerate((len(p['insts']), len(p['insts']) // 2, 0)): show([p['rock']] + p['insts'][:keep]) path = os.path.join(PRE, '%s_stage%d.png' % (p['root'].name, k + 1)) render(path) stages.append(path) sheet(stages, 3, os.path.join(PRE, 'OrePatch_mining_stages.png')) # the pieces of each ore type res = sc.render.resolution_x, sc.render.resolution_y sc.render.resolution_x, sc.render.resolution_y = (960, 200) if QUICK else (2400, 500) for ore, lib in libs.items(): show(lib['objs']) frame(cam, lights, lib['objs'], view=[-0.12, -1.0, 0.42]) render(os.path.join(PRE, 'OrePieces_%s.png' % ore)) sc.render.resolution_x, sc.render.resolution_y = res for o in everything: o.hide_render = False # ------------------------------------------------------------------------------------------------ main def main(): for ob in list(bpy.data.objects): bpy.data.objects.remove(ob) for c in list(bpy.data.collections): bpy.data.collections.remove(c) sc.name = 'Ore Patches' sc.unit_settings.system, sc.unit_settings.scale_length = 'METRIC', 1.0 dev = setup_cycles() c_lib, c_pat, c_ucx = new_coll('Library'), new_coll('Patches'), new_coll('Collision') libs = {} for oi, ore in enumerate(ORES): if ore in USED_ORES: libs[ore] = build_library(ore, oi, new_coll('Library_' + ore, c_lib), c_ucx) log('library', ore, 'done') patches = [] for pi, spec in enumerate(PATCHES): log('patch', spec['name']) p = build_patch(spec, pi, libs[spec['ore']], new_coll('OrePatch_' + spec['name'], c_pat), c_ucx) p['root'].location = (pi * 4.6, 0, 0) # baked at the origin, then moved to its place in the row patches.append(p) bpy.context.view_layer.update() # report + manifest report = dict(device=dev, quick=QUICK, texture_px=RES, meshes={}, patches={}) for ore, lib in libs.items(): for ob in lib['objs']: report['meshes'][ob.name] = audit(ob) manifest = dict( about='Minable ore patches for The Descent. Blender units: metres, Z up, right-handed. For Unreal: x100, flip Y.', patch_frame='Patch origin is on the terrain surface; +Z points out of the rock face. The rock continues below z=0 (buried).', mining='Each entry of a patch\'s "clusters" list is one cluster mesh seated in a socket of the rock. Mining removes it and ' 'frees "units" loose chunks. When the list is empty the patch is mined out: fade the rock\'s emissive to 0.', ore_types={}, patches=[]) for ore, lib in libs.items(): g = ORES[ore] manifest['ore_types'][g['key']] = dict( label=g['label'], glow_rgb=g['glow'], material='M_Ore_' + ore, textures={k: 'textures/' + os.path.basename(v) for k, v in (lib['tex'] or {}).items()}, cluster_meshes=[i['name'] for i in lib['info'] if i['kind'] == 'cluster'], chunk_meshes=[i['name'] for i in lib['info'] if i['kind'] != 'cluster'], cluster_footprint_radius_m=CLUSTER_R, chunk_size_m=CHUNK_SIZE, sizes_m={i['name']: i['size_m'] for i in lib['info']}) for p in patches: a = audit(p['rock']) report['meshes'][p['rock'].name] = a ore_tris = sum(report['meshes'][r['mesh']]['game_tris'] for r in p['rows']) report['patches'][p['root'].name] = dict( clusters=len(p['rows']), units=sum(r['units'] for r in p['rows']), rock_tris=a['game_tris'], ore_tris=ore_tris, total_tris=a['game_tris'] + ore_tris, size_m=p['size'], above_ground_m=p['above'], buried_m=p['buried'], foot_buried_min_m=round(min(p['gaps']), 3), clusters_with_foot_exposed=sum(g < -0.002 for g in p['gaps']), note='foot_buried = rock surface above the cluster base plane round its foot; everything below that plane is meant to be hidden') manifest['patches'].append(dict( name=p['root'].name, ore=ORES[p['spec']['ore']]['key'], rock_mesh=p['rock'].name, rock_material='M_' + p['rock'].name, rock_collision=p['hull'].name, textures={k: 'textures/' + os.path.basename(v) for k, v in (p['tex'] or {}).items()}, size_m=p['size'], above_ground_m=p['above'], buried_m=p['buried'], units_total=sum(r['units'] for r in p['rows']), clusters=p['rows'])) with open(os.path.join(OUT, 'OrePatches.json'), 'w') as f: json.dump(manifest, f, indent=1) with open(os.path.join(OUT if QUICK else HERE, 'build_report.json'), 'w') as f: json.dump(report, f, indent=1) for k, v in report['patches'].items(): log(k, v) bad = {k: v for k, v in report['meshes'].items() if v['ngons'] or v['open_edges'] or v['nonmanifold_edges'] or v['zero_area'] or not v['uv']} log('meshes with audit findings:', bad or 'none') export_all(libs, patches) cam, lights = build_studio() for scr in bpy.data.screens: # open on the textured view for ar in scr.areas: for sp in ar.spaces: if sp.type == 'VIEW_3D': sp.shading.type = 'MATERIAL' c_ucx.hide_viewport = True blend = os.path.join(OUT, 'OrePatches.blend') bpy.ops.wm.save_as_mainfile(filepath=blend) try: bpy.ops.file.make_paths_relative() bpy.ops.wm.save_mainfile() except Exception as e: log('relative paths skipped:', e) if not arg('--no-render'): render_all(libs, patches, cam, lights) frame(cam, lights, [patches[0]['rock']] + patches[0]['insts']) bpy.ops.wm.save_mainfile() log('done') main()