# -*- 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: 6 clusters of ore pieces + 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 = arg('--out') or (os.path.join(HERE, '_quick') if QUICK else ROOT) # --out: a full-size build somewhere else 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 # the ore atlases RES_ROCK = 1024 if QUICK else 4096 # each rock's own set HI_ORE = 16 if QUICK else 36 # cube subdivisions of a sculpted nugget SRC = os.path.join(ROOT, 'source_textures') SCANS = {'dark_rock': 2.42, 'dark_rock_02': 2.0, 'rock_05': 1.5} # Poly Haven (CC0): edge of the scanned area in metres 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.32 # how far the cluster's base plane lies under the rock surface, in cluster radii FOOT_DROP = 0.12 # the rock may fall this far (in radii) under the footprint before a seat is refused sc = bpy.context.scene noise.seed_set(1) # noise_vector takes its offsets from a clock-seeded generator: without this no two builds agree 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) # 2026-10-13, after "they are too distinct make them less": one family. Every ore sits on the same dark rock with only a # trace of its own colour round the clusters, the pieces are smaller, sit deeper, are duller and carry rock dust. NUG = dict(sub=(1, 3), planes=(3, 7), lump=0.12, fine=0.035, pits=0.03) BLOBS = dict(big=(6, 9), small=(3, 6), first_big=(0.115, 0.14), first_small=(0.085, 0.11), r=(0.05, 0.10)) ROCK_A = dict(scan='dark_rock', gain=1.7, tint=(1.0, 0.98, 0.96)) # black veined rock ROCK_B = dict(scan='dark_rock_02', gain=1.25, tint=(1.0, 0.98, 0.97)) # dark layered rock ORES = { # ---- the game's own three (DESIGN_UE 2.12). glow = the game's ore glow colours (7.8) 'Ferrite': dict(key='ferrite', label='Basalt Ferrite', glow=(1.0, 0.45, 0.12), family='nugget', shape=NUG, blobs=BLOBS, **ROCK_A, # "ferrite is kinda iron so make it look like" (the user, 2026-10-13): the iron look, its rust a little warmer crust='rock_05', crust_tint=(0.40, 0.27, 0.22), vein=(0.34, 0.15, 0.07), vein_rough=0.55, vein_metal=0.0, metal=dict(c1=(0.30, 0.29, 0.29), c2=(0.19, 0.185, 0.19), edge=(0.46, 0.44, 0.43), tarn=(0.20, 0.075, 0.03), tarn_cav=0.95, tarn_patch=0.8, patch_lo=0.48, tarn_metal=0.0, rough=0.40)), 'Cobalt': dict(key='cobalt', label='Cobalt Glass', glow=(0.15, 0.75, 1.0), family='prism', look='glass', **ROCK_A, stain=(0.03, 0.07, 0.10), stain_amt=0.3, vein=(0.05, 0.20, 0.34), vein_rough=0.25, vein_metal=0.0, prism=dict(big=(7, 10), small=(4, 7), sat=(3, 7), mains=(2, 1), L_main=(0.24, 0.38), L=(0.09, 0.22), L_sat=(0.05, 0.08), thick=(0.15, 0.22), base=0.012, sides=[5, 6, 6, 6, 6], lean_main=(0.05, 0.3), lean=(0.3, 0.95), chunk_thick=(0.15, 0.2), chunk_sides=[5, 6, 6])), # ---- seven real metals, asked for on 2026-10-13. Not in the game's design yet; their glow colours are mine 'Iron': dict(key='iron', label='Iron', glow=(1.0, 0.32, 0.12), family='nugget', **ROCK_A, # kidney ore: steel-grey hematite under rust shape=dict(sub=(3, 6), planes=(0, 1), lump=0.06, fine=0.02, pits=0.02, ks=0.22), blobs=dict(big=(4, 7), small=(2, 5), first_big=(0.12, 0.15), first_small=(0.09, 0.115), r=(0.06, 0.11)), crust='rock_05', crust_tint=(0.38, 0.25, 0.21), vein=(0.30, 0.11, 0.06), vein_rough=0.6, vein_metal=0.0, metal=dict(c1=(0.26, 0.255, 0.26), c2=(0.17, 0.165, 0.17), edge=(0.40, 0.39, 0.39), tarn=(0.16, 0.055, 0.025), tarn_cav=0.95, tarn_patch=0.85, patch_lo=0.44, tarn_metal=0.0, rough=0.42)), 'Copper': dict(key='copper', label='Copper', glow=(0.25, 1.0, 0.65), family='nugget', **ROCK_B, # native copper, green where it weathers shape=dict(sub=(2, 5), planes=(2, 5), lump=0.17, fine=0.045, pits=0.03), blobs=dict(big=(6, 9), small=(3, 6), first_big=(0.11, 0.135), first_small=(0.08, 0.105), r=(0.045, 0.095)), stain=(0.05, 0.17, 0.13), stain_amt=0.35, vein=(0.07, 0.26, 0.20), vein_rough=0.55, vein_metal=0.0, metal=dict(c1=(0.72, 0.36, 0.22), c2=(0.50, 0.22, 0.13), edge=(0.85, 0.50, 0.34), tarn=(0.06, 0.028, 0.018), tarn_cav=0.7, tarn_patch=0.4, tarn_metal=0.2, rough=0.40, patina=(0.07, 0.24, 0.19), patina_amt=0.7)), 'Silver': dict(key='silver', label='Silver', glow=(0.75, 0.88, 1.0), family='nugget', **ROCK_A, # native silver, much of it tarnished black shape=dict(sub=(1, 4), planes=(1, 4), lump=0.14, fine=0.04, pits=0.03), blobs=dict(big=(6, 9), small=(3, 6), first_big=(0.10, 0.13), first_small=(0.075, 0.10), r=(0.045, 0.09)), stain=(0.20, 0.20, 0.21), stain_amt=0.2, vein=(0.62, 0.62, 0.61), vein_rough=0.32, vein_metal=1.0, metal=dict(c1=(0.72, 0.72, 0.71), c2=(0.55, 0.55, 0.55), edge=(0.88, 0.88, 0.87), tarn=(0.022, 0.022, 0.026), tarn_cav=0.9, tarn_patch=0.75, tarn_metal=0.3, rough=0.30)), 'Gold': dict(key='gold', label='Gold', glow=(1.0, 0.78, 0.25), family='nugget', **ROCK_B, # water-worn nuggets with dirt in their hollows shape=dict(sub=(1, 3), planes=(0, 2), lump=0.10, fine=0.03, pits=0.02), blobs=dict(big=(5, 8), small=(3, 6), first_big=(0.105, 0.13), first_small=(0.075, 0.10), r=(0.04, 0.09)), stain=(0.22, 0.21, 0.19), stain_amt=0.35, vein=(0.80, 0.60, 0.26), vein_rough=0.30, vein_metal=1.0, metal=dict(c1=(0.92, 0.68, 0.28), c2=(0.80, 0.55, 0.20), edge=(1.0, 0.82, 0.48), tarn=(0.10, 0.06, 0.03), tarn_cav=0.7, tarn_patch=0.25, tarn_metal=0.4, rough=0.30)), 'Lead': dict(key='lead', label='Lead', glow=(0.55, 0.65, 0.90), family='cube', **ROCK_A, # galena: intergrown lead-grey cubes stain=(0.13, 0.14, 0.16), stain_amt=0.25, vein=(0.38, 0.39, 0.43), vein_rough=0.34, vein_metal=1.0), 'Platinum': dict(key='platinum', label='Platinum', glow=(0.90, 0.95, 1.0), family='nugget', **ROCK_B, # small smooth grey grains and pebbles shape=dict(sub=(0, 2), planes=(0, 1), lump=0.07, fine=0.02, pits=0.012, ks=0.35), blobs=dict(big=(6, 10), small=(4, 7), first_big=(0.07, 0.09), first_small=(0.055, 0.07), r=(0.03, 0.06)), stain=(0.10, 0.11, 0.12), stain_amt=0.2, vein=(0.56, 0.55, 0.54), vein_rough=0.40, vein_metal=1.0, metal=dict(c1=(0.62, 0.61, 0.59), c2=(0.50, 0.49, 0.48), edge=(0.74, 0.73, 0.71), tarn=(0.06, 0.06, 0.062), tarn_cav=0.7, tarn_patch=0.3, tarn_metal=0.5, rough=0.42)), 'Titanium': dict(key='titanium', label='Titanium', glow=(0.70, 0.50, 1.0), family='prism', look='rutile', **ROCK_A, # rutile needles stain=(0.12, 0.06, 0.05), stain_amt=0.25, vein=(0.20, 0.08, 0.06), vein_rough=0.30, vein_metal=0.5, prism=dict(big=(8, 12), small=(5, 8), sat=(2, 5), mains=(4, 2), L_main=(0.18, 0.32), L=(0.08, 0.18), L_sat=(0.04, 0.07), thick=(0.06, 0.10), base=0.008, sides=[4, 4, 4, 8], lean_main=(0.05, 0.6), lean=(0.3, 1.2), chunk_thick=(0.08, 0.12), chunk_sides=[4, 4, 8])), } # "delete the pearl how ... is pearl an ore" (the user, 2026-10-13): Seam Pearl, its two patches and its family of # pieces (grown spheres) are gone from this set. The game's design (DESIGN_UE 2.12) still names it as its third ore. LIB_SEED = dict(Ferrite=0, Cobalt=1, Iron=3, Copper=4, Silver=5, Gold=6, Lead=7, Platinum=8, Titanium=9) # 2 was Pearl for _g in ORES.values(): # what a scan brings with it _g.setdefault('relief', {'dark_rock': 0.10, 'dark_rock_02': 0.12}[_g['scan']]) _g.setdefault('vein_lum', {'dark_rock': (0.03, 0.085), 'dark_rock_02': (0.085, 0.17)}[_g['scan']]) RADII_A = [0.39, 0.33, 0.29, 0.26, 0.23, 0.20, 0.18, 0.17, 0.15, 0.14, 0.13] RADII_B = [0.44, 0.39, 0.35, 0.32, 0.29, 0.26, 0.24, 0.22, 0.20, 0.18, 0.17, 0.16, 0.15, 0.14, 0.13, 0.13] RADII_S = [0.33, 0.28, 0.25, 0.21, 0.19, 0.17, 0.15, 0.14, 0.13] D2R = math.pi / 180 # rock shapes: (centre, radii, yaw) lobes. They echo the four rocks of the reference picture, plus two layered ones. SHAPES = { 'slab': dict(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)]), 'twin': dict(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)]), 'pyramid': dict(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)]), 'mound': dict(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)]), 'shelf': dict(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)]), 'ridge': dict(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)]), } def patch(ore, ab, shape, seed, **kw): d = dict(SHAPES[shape]) d.update(name='%s_%s' % (ore, ab), ore=ore, seed=seed, shape=shape, **kw) return d PATCHES = [ patch('Ferrite', 'A', 'slab', 11), patch('Ferrite', 'B', 'twin', 23), patch('Cobalt', 'A', 'pyramid', 37), patch('Cobalt', 'B', 'mound', 41), # vary: the lobes are stretched a little per seed, so two rocks of one shape do not look alike patch('Iron', 'A', 'slab', 101, vary=True), patch('Iron', 'B', 'mound', 103, vary=True), patch('Copper', 'A', 'shelf', 107, vary=True), patch('Copper', 'B', 'twin', 109, vary=True), patch('Silver', 'A', 'pyramid', 113, vary=True), patch('Silver', 'B', 'twin', 127, vary=True), patch('Gold', 'A', 'slab', 131, vary=True), patch('Gold', 'B', 'ridge', 137, vary=True), patch('Lead', 'A', 'pyramid', 139, vary=True), patch('Lead', 'B', 'mound', 149, vary=True), patch('Platinum', 'A', 'shelf', 151, vary=True), patch('Platinum', 'B', 'ridge', 157, vary=True), patch('Titanium', 'A', 'slab', 163, vary=True), patch('Titanium', 'B', 'twin', 167, vary=True), ] 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, check=True): Vs, Fs, off = [], [], 0 for V, F in parts: if check: F = fix_winding(V - V.mean(0), [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) r = np.array(r, float) * (rng.uniform(0.88, 1.12, 3) if spec.get('vary') else 1.0) s.lobes.append((np.array(c, float), r, 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 = [] s.ks, s.kp = 0.22, 0.02 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, s.ks) for n, h in s.planes: d = smax(d, P @ n - h, s.kp) 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.15: k *= k * min(1.0, (m - 0.15) * 5.0) h -= 0.016 * 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 class Nugget(Rock): """A lump of native metal: fused ellipsoids, a few crystal flats, lumpy, pitted when seen close. How many lobes and flats and how lumpy is the ore's `shape` entry.""" def __init__(s, rng, size, st): s.size, s.st = size, st s.so = Vector(rng.uniform(-50, 50, 3).tolist()) s.lobes = [(np.zeros(3), size * rng.uniform(0.78, 1.2, 3), randrot(rng))] for _ in range(int(rng.integers(*st['sub']))): s.lobes.append((unit(rng.normal(size=3)) * size * rng.uniform(0.45, 0.7), size * rng.uniform(0.45, 0.7, 3), randrot(rng))) s.planes = [(unit(rng.normal(size=3)), size * rng.uniform(0.62, 0.9)) for _ in range(int(rng.integers(*st['planes'])))] s.ks, s.kp = st.get('ks', 0.3) * size, 0.05 * size s.c0, s.rmax = np.zeros(3), size * 2.4 def points(s, D, fine): P = s.cast(D) H = np.zeros(len(P)) fr, vor, sz, so, st = noise.fractal, noise.voronoi, s.size, s.so, s.st for i in range(len(P)): v = Vector(P[i].tolist()) / sz + so # in units of the nugget's size h = fr(v * 1.6, 1.0, 2.0, 3) * st['lump'] if fine: h += fr(v * 5.0, 1.0, 2.0, 3) * st['fine'] d = vor(v * 7.0)[0][0] if d < 0.28: # pits h -= st['pits'] * (1.0 - d / 0.28) ** 2 H[i] = h * sz return P + D * H[:, None] def nugget(rng, size, st): ng = Nugget(rng, size, st) D, F, _ = quad_sphere(4 if size > 0.1 else 3) Dh, Fh, _ = quad_sphere(HI_ORE) return ng.points(D, False), F.tolist(), ng.points(Dh, True), Fh.tolist() def shard(rng, L, rad, sides, double=False): """A prism crystal with a pointed termination. Also returns (distance along its axis, the same 0..1) per vertex.""" ang = (np.arange(sides) + rng.uniform(-0.2, 0.2, sides)) * 2 * math.pi / sides rr = rad * rng.uniform(0.85, 1.15, sides) tip = rad * rng.uniform(0.9, 1.7) zb = tip if double else -0.07 zs = np.full(sides, L - tip) tp = rng.uniform(0.85, 1.0) r0 = np.stack([rr * np.cos(ang), rr * np.sin(ang), np.full(sides, zb)], 1) r1 = np.stack([rr * tp * np.cos(ang), rr * tp * np.sin(ang), zs], 1) if double: # a broken piece: the same section at both ends r1[:, :2] = r0[:, :2] apex = np.array([rng.uniform(-0.35, 0.35) * rad, rng.uniform(-0.35, 0.35) * rad, L]) bot = np.array([rng.uniform(-0.35, 0.35) * rad, rng.uniform(-0.35, 0.35) * rad, 0.0]) if double else np.array([0, 0, zb]) V = np.concatenate([r0, r1, apex[None], bot[None]]) n = sides F = [] for i in range(n): j = (i + 1) % n F += [[i, j, n + j, n + i], [n + i, n + j, 2 * n], [j, i, 2 * n + 1]] ax = V[:, 2] - V[:, 2].min() return V, F, np.stack([ax, ax / ax.max()], 1) CUBE_V = np.array([[x, y, z] for x in (-1, 1) for y in (-1, 1) for z in (-1, 1)], float) * 0.5 CUBE_F = [[0, 1, 3, 2], [4, 6, 7, 5], [0, 4, 5, 1], [2, 3, 7, 6], [0, 2, 6, 4], [1, 5, 7, 3]] def cube(rng, size, R): """A galena cube: six flat faces, a little off square.""" return (CUBE_V * (size * rng.uniform(0.82, 1.18, 3))) @ R.T, CUBE_F 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 as_chunk(out): """Centred, and as large as the game's loose chunk.""" V = out['V'] mid = (V.max(0) + V.min(0)) * 0.5 k = CHUNK_SIZE / (V.max(0) - V.min(0)).max() out['V'] = (V - mid) * k if 'hiV' in out: out['hiV'] = (out['hiV'] - mid) * k if 'attrs' in out: out['attrs']['ax'] = out['attrs']['ax'] * k return out def pieces(lo, hi, ax, chunk=False): V, F = merge(lo) out = dict(V=V, F=F) if hi: out['hiV'], out['hiF'] = merge(hi, check=False) if ax: # per vertex: along the crystal, the same 0..1, the crystal's axis A = np.concatenate([a for a, _ in ax]) out['attrs'] = dict(ax=A[:, 0], ax01=A[:, 1], axis=np.concatenate([np.tile(d, (len(a), 1)) for a, d in ax])) return as_chunk(out) if chunk else out def gen_cluster(ore, rng, big): g = ORES[ore] fam = g['family'] lo, hi, ax = [], [], [] if fam == 'nugget': b = g['blobs'] n = int(rng.integers(*(b['big'] if big else b['small']))) for c, r in pack_blobs(rng, n, rng.uniform(*(b['first_big'] if big else b['first_small'])), b['r'], 0.20, (0.15, 0.7)): V, F, Vh, Fh = nugget(rng, r, g['shape']) lo.append((V + c, F)) hi.append((Vh + c, Fh)) elif fam == 'prism': pr = g['prism'] n = int(rng.integers(*(pr['big'] if big else pr['small']))) sat = int(rng.integers(*pr['sat'])) for j in range(n + sat): main, small = j < pr['mains'][0 if big else 1], j >= n L = rng.uniform(*pr['L_main']) if main else rng.uniform(*pr['L_sat']) if small else rng.uniform(*pr['L']) rad = L * rng.uniform(*pr['thick']) + pr['base'] a = rng.uniform(0, 2 * math.pi) rr = (0.05 if main else 0.19 if small else 0.16) * math.sqrt(rng.uniform(0.05, 1)) lean = rng.uniform(*pr['lean_main']) if main else rng.uniform(*pr['lean']) la = a + rng.uniform(-0.6, 0.6) V, F, A = shard(rng, L, rad, int(rng.choice(pr['sides']))) R = rot_to([lean * math.cos(la), lean * math.sin(la), 1.0]) lo.append((V @ R.T + np.array([rr * math.cos(a), rr * math.sin(a), 0]), F)) ax.append((A, R[:, 2])) elif fam == 'cube': n = int(rng.integers(6, 11)) if big else int(rng.integers(3, 7)) R0 = randrot(rng) # most cubes of a cluster share a lattice, some are twinned off it for c, r in pack_blobs(rng, n, rng.uniform(0.07, 0.09) if big else rng.uniform(0.055, 0.07), (0.035, 0.07), 0.19, (0.35, 0.9)): V, F = cube(rng, r / 0.6, R0 if rng.uniform() < 0.6 else randrot(rng)) lo.append((V + c, F)) return pieces(lo, hi, ax) def gen_chunk(ore, rng): g = ORES[ore] fam = g['family'] lo, hi, ax = [], [], [] if fam == 'nugget': V, F, Vh, Fh = nugget(rng, 0.14, g['shape']) lo.append((V, F)) hi.append((Vh, Fh)) for _ in range(int(rng.integers(1, 3))): V, F, Vh, Fh = nugget(rng, rng.uniform(0.06, 0.085), g['shape']) c = unit(rng.normal(size=3)) * 0.12 lo.append((V + c, F)) hi.append((Vh + c, Fh)) elif fam == 'prism': pr = g['prism'] for k in range(int(rng.integers(2, 4))): L = 0.34 if k == 0 else rng.uniform(0.16, 0.24) V, F, A = shard(rng, L, L * rng.uniform(*pr['chunk_thick']), int(rng.choice(pr['chunk_sides'])), double=True) R = np.eye(3) if k == 0 else randrot(rng) lo.append(((V - np.array([0, 0, L * 0.5])) @ R.T + (0 if k == 0 else rng.normal(scale=0.02, size=3)), F)) ax.append((A, R[:, 2])) elif fam == 'cube': R0 = randrot(rng) for k in range(int(rng.integers(2, 5))): V, F = cube(rng, 0.2 if k == 0 else rng.uniform(0.09, 0.15), R0 if k < 2 else randrot(rng)) lo.append((V + (0 if k == 0 else unit(rng.normal(size=3)) * 0.11), F)) return pieces(lo, hi, ax, chunk=True) # ------------------------------------------------------------------------------------------------ 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) ROCK_DUST = (0.05, 0.046, 0.042) def scan_layer(t, P, name, seed): """A scanned rock surface, box-projected at its real size: colour, roughness, height.""" mp = t.new('ShaderNodeMapping') t.L.new(P, mp.inputs['Vector']) mp.inputs['Location'].default_value = tuple(np.random.default_rng(seed).uniform(0, 1, 3)) mp.inputs['Scale'].default_value = (1.0 / SCANS[name],) * 3 out = {} for key, suffix, data in (('col', 'diff_2k.jpg', False), ('rough', 'rough_2k.jpg', True), ('h', 'disp_2k.exr', True)): im = bpy.data.images.load(os.path.join(SRC, name, '%s_%s' % (name, suffix)), check_existing=True) if data: im.colorspace_settings.name = 'Non-Color' n = t.new('ShaderNodeTexImage', projection='BOX', interpolation='Cubic') n.projection_blend = 0.22 n.image = im t.L.new(mp.outputs[0], n.inputs[0]) out[key] = n.outputs[0] return out def mat_rock_proc(name, ore, strata, seed): """Rock for the bake source: a scanned surface, with the ore's traces laid over it by masks. 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) n4 = t.noise(P, 0.55, 2, 0.5) S = scan_layer(t, P, g['scan'], seed) col = t.mix(1.0, S['col'], tuple(g['gain'] * c for c in g['tint']), 'MULTIPLY') rough, h = S['rough'], S['h'] bw = t.new('ShaderNodeRGBToBW') t.L.new(S['col'], bw.inputs[0]) near = t.smooth(t.add(seat, t.mul(t.sub(n4, 0.5), 0.5)), 0.2, 0.7) if g.get('crust'): # weathered, iron-stained crust around the clusters C = scan_layer(t, P, g['crust'], seed + 7) cm = t.mul(t.smooth(t.add(t.add(seat, t.mul(t.sub(t.noise(P, 2.6, 4, 0.6), 0.5), 0.9)), t.mul(t.sub(C['h'], 0.5), 0.5)), 0.84, 0.94), 0.6) cc = C['col'] if g.get('crust_desat'): # a pale gangue instead of a rusty crust cbw = t.new('ShaderNodeRGBToBW') t.L.new(cc, cbw.inputs[0]) cc = t.mix(g['crust_desat'], cc, cbw.outputs[0]) col = t.mix(cm, col, t.mix(1.0, cc, g['crust_tint'], 'MULTIPLY')) rough = t.mixf(cm, rough, C['rough']) h = t.mixf(cm, h, t.add(t.mul(C['h'], 0.5), 0.3)) else: # mineral staining around the clusters stain = t.mul(t.mul(t.smooth(seat, 0.45, 0.9), t.smooth(t.noise(P, 3.0, 4, 0.6), 0.4, 0.7)), t.mul(t.smooth(t.noise(P, 11.0, 3, 0.6), 0.35, 0.65), t.add(0.4, t.mul(t.inv(t.smooth(S['h'], 0.2, 0.6)), 0.6)))) col = t.mix(t.mul(stain, g['stain_amt']), col, g['stain']) col = t.mix(t.mul(t.inv(t.smooth(S['h'], 0.15, 0.5)), 0.3), col, CAV) # dirt in the scan's own hollows 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.45, 0.72)) col = t.mix(t.mul(silt, 0.35), col, SILT) for m, a in ((crack, 0.5), (cav, 0.55)): col = t.mix(t.mul(m, a), col, CAV) if strata: fr = t.math('FRACT', t.attr('strata')) col = t.mix(t.mul(t.add(t.smooth(fr, 0.90, 0.99), t.inv(t.smooth(fr, 0.01, 0.10)), True), 0.5), col, CAV) col = t.mix(t.mul(edge, 0.35), col, t.mix(1.0, col, (2.2, 2.2, 2.2), 'MULTIPLY')) # the scan's own pale veins carry the ore near the clusters vein = t.mul(t.smooth(bw.outputs[0], *g['vein_lum']), near) col = t.mix(t.mul(sock, 0.6), col, t.mix(1.0, col, (1.9, 1.8, 1.7), 'MULTIPLY')) # fresh break under a cluster fleck = t.mul(t.mul(sock, t.smooth(t.noise(P, 60.0, 2, 0.5), 0.70, 0.76)), 0.7) ore_m = t.add(t.mul(vein, 0.5), t.mul(fleck, 0.7), True) col = t.mix(ore_m, col, g['vein']) emis = t.mix(t.add(t.mul(vein, 0.25), t.mul(fleck, 0.3), True), (0, 0, 0), g['glow']) rough = t.mixf(ore_m, t.mixf(t.mul(silt, 0.6), rough, 0.95), g['vein_rough']) return t.finish(col, rough, t.mul(ore_m, g['vein_metal']), emis, t.bump(h, g['relief']), ao) def mat_ore_proc(name, ore): """Nuggets: for the sculpted bake source. Crystals and cubes: on the game mesh itself.""" g = ORES[ore] fam = g['family'] t = NT(new_mat(name)) P = t.new('ShaderNodeTexCoord').outputs['Object'] geo = t.new('ShaderNodeNewGeometry') rnd = geo.outputs['Random Per Island'] foot = t.inv(t.smooth(t.sepz(P), -0.03, 0.04)) # where the piece meets the rock def bevel(radius): bev = t.new('ShaderNodeBevel', samples=6) bev.inputs['Radius'].default_value = radius 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]) return bev.outputs[0], t.math('MULTIPLY', t.sub(1.0, dot.outputs['Value']), 6.0, True) if fam == 'nugget': m = g['metal'] ao = t.ao(0.05) cav = t.inv(t.smooth(ao, 0.4, 0.95)) edge = t.smooth(geo.outputs['Pointiness'], 0.52, 0.60) n, n2 = t.noise(P, 70.0, 4, 0.6), t.noise(P, 14.0, 3, 0.55) tarn = t.add(t.mul(cav, m['tarn_cav']), t.mul(t.smooth(t.noise(P, 5.0, 4, 0.65), m.get('patch_lo', 0.58), m.get('patch_lo', 0.58) + 0.17), m['tarn_patch']), True) col = t.mix(n2, m['c1'], m['c2']) col = t.mix(t.mul(edge, 0.6), col, m['edge']) col = t.mix(tarn, col, m['tarn']) flat, dull = t.mul(tarn, 1.0 - m['tarn_metal']), tarn if m.get('patina'): # a carbonate crust over the metal, in the hollows and in patches pat = t.mul(t.add(t.mul(cav, 0.8), t.smooth(t.noise(P, 7.0, 4, 0.7), 0.46, 0.62), True), m['patina_amt']) col = t.mix(pat, col, t.mix(t.noise(P, 30.0, 3, 0.6), m['patina'], tuple(c * 0.55 for c in m['patina']))) flat, dull = t.add(flat, pat, True), t.add(dull, pat, True) col = t.mix(t.mul(foot, 0.85), col, DARK) flat, dull = t.add(flat, foot, True), t.add(dull, foot, True) rough = t.add(t.sub(t.add(m['rough'], t.mul(n, 0.18)), t.mul(edge, 0.08)), t.mul(dull, 0.5), True) metal = t.inv(flat) emask = t.mul(cav, 0.15) pits = t.inv(t.smooth(t.voronoi(P, 160.0), 0.10, 0.35)) nrm = t.bump(t.sub(t.mul(n, 0.4), t.mul(pits, 0.5)), 0.0015) elif fam == 'prism' and g['look'] == 'glass': ao = t.ao(0.06) cav = t.inv(t.smooth(ao, 0.4, 0.95)) bev, edge = bevel(0.004) ax01 = t.attr('ax01') root = t.inv(t.smooth(t.add(ax01, t.mul(t.sub(t.noise(P, 20.0, 3, 0.6), 0.5), 0.25)), 0.02, 0.26)) # milky root veil = t.smooth(t.noise(t.warp(P, 5.0, 0.06), 11.0, 3, 0.6), 0.55, 0.75) # clouds inside the glass col = t.mix(t.smooth(ax01, 0.4, 1.0), (0.002, 0.008, 0.020), (0.005, 0.035, 0.075)) col = t.mix(t.mul(root, 0.6), col, (0.03, 0.065, 0.085)) col = t.mix(t.mul(veil, 0.25), col, (0.03, 0.12, 0.19)) col = t.mix(t.mul(edge, 0.4), col, (0.04, 0.11, 0.17)) col = t.mix(t.mul(cav, 0.5), col, (0.001, 0.004, 0.010)) col = t.mix(t.mul(foot, 0.8), col, DARK) rough = t.add(t.add(0.04, t.mul(root, 0.35)), t.add(t.mul(veil, 0.05), t.mul(edge, 0.06)), True) metal = t.mul(foot, 0.0) emask = t.mul(t.add(0.006, t.mul(veil, 0.06)), t.inv(t.mul(root, 0.7))) nrm = t.bump(t.mul(t.mul(root, t.noise(P, 120.0, 3, 0.6)), 2.0), 0.0010, bev) # only the root is rough elif fam == 'prism': # rutile: red-black, half-metallic, grooved along its length ao = t.ao(0.05) cav = t.inv(t.smooth(ao, 0.4, 0.95)) bev, edge = bevel(0.0025) axis = t.new('ShaderNodeAttribute', attribute_name='axis').outputs[1] along = t.new('ShaderNodeVectorMath', operation='DOT_PRODUCT') t.L.new(P, along.inputs[0]) t.L.new(axis, along.inputs[1]) sc_ = t.new('ShaderNodeVectorMath', operation='SCALE') t.L.new(axis, sc_.inputs[0]) t.L.new(along.outputs['Value'], sc_.inputs['Scale']) across = t.new('ShaderNodeVectorMath', operation='SUBTRACT') # the position with its part along the crystal taken out t.L.new(P, across.inputs[0]) t.L.new(sc_.outputs[0], across.inputs[1]) streak = t.noise(across.outputs[0], 220.0, 2, 0.5) root = t.inv(t.smooth(t.attr('ax01'), 0.02, 0.2)) col = t.mix(t.smooth(streak, 0.3, 0.7), (0.025, 0.010, 0.009), (0.09, 0.035, 0.026)) col = t.mix(t.mul(edge, 0.45), col, (0.26, 0.12, 0.085)) col = t.mix(t.mul(root, 0.5), col, (0.09, 0.06, 0.05)) col = t.mix(t.mul(cav, 0.6), col, (0.008, 0.004, 0.004)) col = t.mix(t.mul(foot, 0.8), col, DARK) rough = t.add(t.add(0.13, t.mul(streak, 0.12)), t.add(t.mul(root, 0.3), t.mul(cav, 0.2)), True) metal = t.mul(t.inv(foot), 0.6) emask = t.add(0.004, t.mul(cav, 0.05)) nrm = t.bump(streak, 0.0007, bev) elif fam == 'cube': # galena: bright lead-grey faces, some gone dull, stepped along the cleavage ao = t.ao(0.05) cav = t.inv(t.smooth(ao, 0.4, 0.95)) bev, edge = bevel(0.003) n = t.noise(P, 45.0, 3, 0.55) patch_ = t.smooth(t.noise(P, 6.0, 4, 0.65), 0.48, 0.68) vc = t.new('ShaderNodeTexVoronoi', feature='F1', distance='CHEBYCHEV') t.L.new(P, vc.inputs['Vector']) vc.inputs['Scale'].default_value = 34.0 step = t.new('ShaderNodeRGBToBW') t.L.new(vc.outputs['Color'], step.inputs[0]) col = t.mix(t.mul(rnd, 0.6), (0.38, 0.39, 0.43), (0.28, 0.29, 0.32)) col = t.mix(t.mul(patch_, 0.8), col, (0.11, 0.115, 0.13)) col = t.mix(t.mul(edge, 0.4), col, (0.55, 0.56, 0.60)) col = t.mix(t.mul(cav, 0.7), col, (0.02, 0.02, 0.025)) col = t.mix(t.mul(foot, 0.85), col, DARK) rough = t.add(t.add(0.26, t.mul(patch_, 0.35)), t.add(t.mul(n, 0.08), t.mul(cav, 0.3)), True) metal = t.inv(t.add(t.mul(foot, 0.9), t.mul(patch_, 0.25), True)) emask = t.mul(cav, 0.08) nrm = t.bump(t.add(t.mul(step.outputs[0], 0.7), t.mul(n, 0.15)), 0.0012, bev) grime = t.mul(t.add(t.inv(t.smooth(t.sepz(P), -0.03, 0.11)), t.mul(t.add(t.mul(cav, 0.6), t.smooth(t.noise(P, 9.0, 4, 0.65), 0.5, 0.75), True), g.get('dust', 0.45)), True), 0.8) col = t.mix(grime, col, ROCK_DUST) # rock dust and grime: low on the piece, in its hollows, in patches rough = t.mixf(grime, rough, 0.85) metal = t.mul(metal, t.inv(grime)) return t.finish(col, rough, metal, t.mix(t.mul(emask, 0.5), (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, res=None): """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'): px = (res or RES) if ch != 'E' else min(res or RES, RES) # the glow needs no more than the ore atlas has img = bpy.data.images.new('T_%s_%s' % (name, ch), px, px, 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), px * px)) 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 joined_copy(objs, coll): """One mesh holding all the pieces where they stand: the single target of a high-to-low bake.""" bm = bmesh.new() for ob in objs: tmp = ob.data.copy() tmp.transform(ob.matrix_world) bm.from_mesh(tmp) bpy.data.meshes.remove(tmp) me = bpy.data.meshes.new('_bake_low') bm.to_mesh(me) bm.free() ob = bpy.data.objects.new('_bake_low', me) coll.objects.link(ob) add_triangulate(ob) return ob def build_library(ore, oi, coll, ucx_coll): g = ORES[ore] rng = np.random.default_rng(900 + LIB_SEED[ore] * 37) objs, info = [], [] for k in range(9): cluster = k < 6 gm = gen_cluster(ore, rng, big=k < 2) if cluster else gen_chunk(ore, rng) V, F = gm['V'], gm['F'] 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=g['family'] == 'nugget') for an, av in gm.get('attrs', {}).items(): if av.ndim == 2: ob.data.attributes.new(an, 'FLOAT_VECTOR', 'POINT').data.foreach_set('vector', av.astype(np.float32).ravel()) else: ob.data.attributes.new(an, 'FLOAT', 'POINT').data.foreach_set('value', av.astype(np.float32)) 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, hi=(gm.get('hiV'), gm.get('hiF'), gm.get('hi_attrs', {})), 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) sculpted = info[0]['hi'][0] is not None if not sculpted or NOBAKE: for ob in objs: ob.data.materials.append(pm['mat']) tex = None if not NOBAKE: log('bake ore atlas', ore) if sculpted: # from the sculpted source onto the game meshes his = [] for ob, inf in zip(objs, info): hi = new_obj(ob.name + '_HI', inf['hi'][0], inf['hi'][1], coll, smooth=True) hi.location = ob.location for an, av in inf['hi'][2].items(): hi.data.attributes.new(an, 'FLOAT', 'POINT').data.foreach_set('value', av.astype(np.float32)) hi.data.materials.append(pm['mat']) his.append(hi) bpy.context.view_layer.update() low = joined_copy(objs, coll) tex = bake_set('Ore_%s' % ore, [low], his, pm, s2a=True, extr=0.025) for o in his + [low]: me = o.data bpy.data.objects.remove(o) bpy.data.meshes.remove(me) else: tex = bake_set('Ore_%s' % ore, objs, [], pm, s2a=False) gmat = mat_game('M_Ore_%s' % ore, tex, 0.3) for ob in objs: ob.data.materials.clear() ob.data.materials.append(gmat) bpy.data.materials.remove(pm['mat']) for inf in info: inf.pop('hi') 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, spec['seed'])['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, spec['seed']) 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, res=RES_ROCK) 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, 0.5)) 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) if foot < 0.0: # the rock dips under one side of the foot: seat it that much deeper extra = 0.003 - foot o = o - z * extra for r in range(3): M[r][3] = o[r] ob.matrix_local = M foot += extra 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 hdr = os.path.join(bpy.utils.resource_path('LOCAL'), 'datafiles', 'studiolights', 'world', 'studio.exr') if os.path.exists(hdr): # Blender's own studio HDRI, packed into the file env = N.new('ShaderNodeTexEnvironment') env.image = bpy.data.images.load(hdr) env.image.pack() L.new(env.outputs[0], b1.inputs[0]) b1.inputs[1].default_value = 0.40 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' sc.view_settings.exposure = 0.0 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, 4 if len(shots) > 6 else 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) # baked at the origin, then moved to its place: one column per ore type, the A rocks in front, the B rocks behind p['root'].location = (list(ORES).index(spec['ore']) * 4.6, 0.0 if spec['name'].endswith('_A') else -5.5, 0) patches.append(p) bpy.context.view_layer.update() # report + manifest report = dict(device=dev, quick=QUICK, rock_texture_px=RES_ROCK, ore_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( ore=p['spec']['ore'], shape=p['spec']['shape'], 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(HERE if OUT == ROOT else OUT, '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()