"""Surfaces for the Mako, the Spearfish's and the Manta's method (models/tender-spearfish/build/surfaces_spearfish.py), run on Tender_Mako.blend after the build: blender -b Tender_Mako.blend --python build/surfaces_mako.py (generated by build/make_surfaces.py from this head + the Spearfish's helper functions and run, with this head's screen_uvs) * hard surfaces take tiling sets from the user's own Season 2 packs (already in the Unreal project), read in place from models/vessel-interior/textures: ScratchedMetal under the paints, StainedSteel for bare metal, FloorPanel_A on the cockpit deck, Glass_DirtRough as a smudge mask, T_Scratch_Norm for fine scratches. The seats take the CC0 leather scan the captain's quarters brought in (models/vessel-interior/source_textures/leather_white). Nothing is downloaded. * two maps baked from the model in Cycles per atlas: Masks (R occlusion, G edges, B crevices and seams) and BevelNormal (hard edges rounded 12 mm, the shell's panel seams as grooves). Exterior atlas 4096 px, cabin atlas 2048 px. * the colours follow the user's sheet: an orange paint under a clear coat on the shell, hood, collar, jaw and fins, a dark slate lower hull and side pods, mid grey big fins and ventral fin, light blue-tinted glass, graphite fittings, yellow marks on the docking clips. Worn as a working machine is: chips at exposed edges down to dark primer, grime in crevices, smudges in the clear coat; no rust. Two UV layers on every mesh (the order matters for Unreal, which builds its tangents from the first): UVMap (UV0) a unique unwrap in the part's square of its atlas (the baked maps); the screens map onto T_Mako_Screens. UV_Tile (UV1) metres, box-projected (the tiling sets). """ import bpy, bmesh, math, os, time, json import numpy as np from mathutils import Vector OUT = os.path.dirname(bpy.data.filepath) HERE = os.path.join(OUT, "build") TEX = os.path.join(OUT, "textures") os.makedirs(TEX, exist_ok=True) VI = os.path.normpath(os.path.join(OUT, "..", "vessel-interior")) PACK = os.path.join(VI, "textures", "ModSci_Engineer", "Textures") SCR = ("Base/ScratchedMetal/T_ScratchedMetal_Diff.png", "Base/ScratchedMetal/T_ScratchedMetal_Rough.png", "Base/ScratchedMetal/T_ScratchedMetal_Normal.png") STN = ("Base/StainedSteel/T_StainSteeled_Diff.png", "Base/StainedSteel/T_StainSteeled_Rough.png", None) FLOOR = ("FloorPanel_A/T_FloorPanel_A_diff.png", "FloorPanel_A/T_FloorPanel_A_Rough.png", "FloorPanel_A/T_FloorPanel_A_normals.png") LEATHER = tuple(os.path.join(VI, "source_textures", "leather_white", f) for f in ("leather_white_diff_2k.jpg", "leather_white_rough_2k.jpg", "leather_white_nor_gl_2k.jpg")) DIRT = "Base/Glass/T_Glass_DirtRough.png" SCRATCH_N = "Base/Glass/T_Scratch_Norm.png" REPORT = {} t0 = time.time() # a re-run replaces the maps of the run before: drop their images first, or the new ones come out named ".001" for _im in list(bpy.data.images): if _im.name.startswith("T_Mako_"): bpy.data.images.remove(_im) for _f in os.listdir(TEX): if _f.startswith("T_Mako_") and _f.count(".") > 1: os.remove(os.path.join(TEX, _f)) ATLASES = { "Exterior": dict(size=4096, rects={"SM_Mako_Hull": (0.0, 0.0, 0.86), "SM_Mako_Glass": (0.86, 0.0, 0.14), "SM_Mako_Hatch": (0.86, 0.14, 0.14), "SM_Mako_Arm_L": (0.86, 0.28, 0.07), "SM_Mako_Arm_R": (0.93, 0.28, 0.07), "SM_Mako_Rotor": (0.86, 0.35, 0.07)}), "Cabin": dict(size=2048, rects={"SM_Mako_Cabin": (0.0, 0.0, 1.0)}), } BEVEL_R, EDGE_R = 0.012, 0.020 SEAM_W0, SEAM_W1, SEAM_DEPTH = 0.0075, 0.0030, 0.0030 # v03: the sheet draws the plate seams thick and dark SURF = { "MAKO | Hull paint": dict(set=SCR, tint=(0.92, 0.30, 0.012), metal=0.0, tile=1.20, shade=(0.95, 1.04), rough=(0.34, 0.26), normal=0.04, dirt=0.02, dirt_tile=2.4, grime=0.50, wear=0.14, wear_col=(0.15, 0.155, 0.16), wear_rough=0.45, coat=1.0, coat_rough=0.05, coat_dirt=0.04), "MAKO | Jaw paint": dict(set=SCR, tint=(0.66, 0.11, 0.016), metal=0.0, tile=1.20, shade=(0.95, 1.04), rough=(0.34, 0.26), normal=0.04, dirt=0.02, dirt_tile=2.4, grime=0.22, wear=0.14, wear_col=(0.15, 0.155, 0.16), wear_rough=0.45, coat=1.0, coat_rough=0.05, coat_dirt=0.04), "MAKO | Lower hull": dict(set=(STN[0], SCR[1], SCR[2]), tint=(0.055, 0.064, 0.078), metal=0.0, tile=0.90, shade=(0.92, 1.08), rough=(0.50, 0.38), normal=0.12, dirt=0.05, dirt_tile=1.9, grime=0.15, wear=0.30, wear_col=(0.26, 0.27, 0.28), wear_rough=0.36, coat=0.35, coat_rough=0.10, coat_dirt=0.06), "MAKO | Graphite": dict(set=(STN[0], SCR[1], SCR[2]), tint=(0.028, 0.031, 0.036), metal=0.0, tile=0.70, shade=(0.92, 1.08), rough=(0.55, 0.42), normal=0.15, dirt=0.06, dirt_tile=1.9, grime=0.15, wear=0.40, wear_col=(0.30, 0.31, 0.32), wear_rough=0.36), "MAKO | Fin grey": dict(set=SCR, tint=(0.27, 0.32, 0.37), metal=0.0, tile=1.00, shade=(0.94, 1.05), rough=(0.40, 0.30), normal=0.06, dirt=0.03, dirt_tile=2.0, grime=0.18, wear=0.22, wear_col=(0.42, 0.43, 0.44), wear_rough=0.36, coat=0.6, coat_rough=0.08, coat_dirt=0.05), "MAKO | Black gloss": dict(set=(None, SCR[1], None), tint=(0.006, 0.007, 0.009), metal=0.0, tile=0.30, shade=(1.0, 1.0), rough=(0.12, 0.05), normal=0.0, dirt=0.05, dirt_tile=0.5, grime=0.0, wear=0.0, coat=1.0, coat_rough=0.03, coat_dirt=0.05), "MAKO | Steel": dict(set=STN, tint=(0.60, 0.61, 0.63), metal=1.0, tile=0.90, shade=(0.93, 1.05), rough=(0.24, 0.32), normal=0.50, normal_map=SCRATCH_N, dirt=0.05, dirt_tile=0.9, grime=0.20, wear=0.0), "MAKO | Rubber": dict(set=(None, SCR[1], None), tint=(0.017, 0.017, 0.018), metal=0.0, tile=0.40, shade=(1.0, 1.0), rough=(0.86, 0.62), normal=0.0, dirt=0.08, dirt_tile=1.2, grime=0.10, wear=0.0, dust=0.03), "MAKO | Yellow marking": dict(set=SCR, tint=(0.80, 0.50, 0.025), metal=0.0, tile=0.60, shade=(0.94, 1.04), rough=(0.40, 0.32), normal=0.06, dirt=0.06, dirt_tile=1.4, grime=0.22, wear=0.50, wear_col=(0.50, 0.51, 0.52), wear_rough=0.36, coat=0.6, coat_rough=0.06, coat_dirt=0.1), "MAKO | Cabin panel": dict(set=SCR, tint=(0.32, 0.35, 0.39), metal=0.0, tile=0.90, shade=(0.90, 1.05), rough=(0.62, 0.42), normal=0.30, dirt=0.04, dirt_tile=2.0, grime=0.25, wear=0.15, wear_col=(0.45, 0.46, 0.47), wear_rough=0.40), "MAKO | Cabin floor": dict(set=FLOOR, tint=(0.18, 0.18, 0.19), metal=0.6, tile=1.00, shade=(0.80, 1.10), rough=(0.55, 0.35), normal=0.80, dirt=0.07, dirt_tile=1.5, grime=0.30, wear=0.0), "MAKO | Seat": dict(set=LEATHER, tint=(0.11, 0.14, 0.18), metal=0.0, tile=0.55, shade=(0.80, 1.15), rough=(0.62, 0.40), normal=0.6, dirt=0.04, dirt_tile=1.2, grime=0.25, wear=0.0, abs_paths=True), } GLOW = {"MAKO | Lamp light": ((1.0, 0.92, 0.80), 30.0), "MAKO | Cabin light": ((0.72, 0.86, 1.0), 18.0)} GLASSES = {"MAKO | Glass": (0.45, 0.78, 0.97), "MAKO | Lamp lens": (0.98, 0.99, 1.0)} # light blue, as the sheet (passed twice) SCREEN_INK = (0.35, 0.80, 1.0) # blue-white instruments, amber for alerts SCREEN_ALERT = (1.0, 0.55, 0.10) def screen_uvs(o): """every Screen plate: its display face (the biggest face of the plate, on the side toward the cockpit's middle) gets a third of the sheet (the console's three in order across, the small ones by turn); the plate's other faces a dark corner""" me = o.data names = [m.name for m in me.materials] si = names.index("MAKO | Screen") uvl = me.uv_layers["UVMap"].data bm = bmesh.new(); bm.from_mesh(me); bm.faces.ensure_lookup_table() todo = {f.index for f in bm.faces if f.material_index == si} islands = [] while todo: seed = todo.pop(); isl = [seed]; stack = [seed] while stack: f = bm.faces[stack.pop()] for e in f.edges: for g in e.link_faces: if g.index in todo: todo.discard(g.index); isl.append(g.index); stack.append(g.index) islands.append(isl) bm.free() def centre(ids): return sum((me.polygons[i].center for i in ids), Vector()) / len(ids) islands.sort(key=lambda isl: (round(centre(isl).y, 1), centre(isl).x)) n_disp = 0 for k, isl in enumerate(islands): c = centre(isl) # which way the display looks: the console's three toward the seat, the rear bay's rack lights outboard, the rest up D = Vector((0, 0.616, 0.788)) if c.y < -0.6 else (Vector((math.copysign(1, c.x), 0, 0)) if c.y > 0.5 else Vector((0, 0, 1))) amax = max(me.polygons[i].area for i in isl) best = max(isl, key=lambda i: me.polygons[i].normal.dot(D) + (0.0 if me.polygons[i].area > 0.5 * amax else -2.0)) third = k % 3 for i in isl: p = me.polygons[i] if i == best: nrm = p.normal right = Vector((1, 0, 0)) if abs(nrm.x) < 0.8 else Vector((0, 1, 0)) right = (right - right.dot(nrm) * nrm).normalized() up = nrm.cross(right).normalized() cos_ = [me.vertices[me.loops[li].vertex_index].co for li in range(p.loop_start, p.loop_start + p.loop_total)] us = [q.dot(right) for q in cos_]; vs = [q.dot(up) for q in cos_] for li, q in zip(range(p.loop_start, p.loop_start + p.loop_total), cos_): uu = (third + 0.04 + 0.92 * (q.dot(right) - min(us)) / max(1e-6, max(us) - min(us))) / 3.0 vv = 0.04 + 0.92 * (q.dot(up) - min(vs)) / max(1e-6, max(vs) - min(vs)) uvl[li].uv = (uu, vv) n_disp += 1 else: for li in range(p.loop_start, p.loop_start + p.loop_total): uvl[li].uv = (0.004, 0.004) return n_disp # ------------------------------------------------------------------ panel seams (v03), drawn per pixel from their measured lines # The sheet draws the shell as plates with thick dark seams: TOP the transverse band between the front and the rear plates # (two curved lines a side) and seams across the rear shell; SIDE the rear hull's recessed side panel. They are computed in the # bake's shader from the surface position (distance to each line), so they are crisp at the atlas's resolution and do not # depend on where the grid's vertices are. The collar, the dorsal spine and the shell's edge are real geometry. _MS = {} exec(open(os.path.join(HERE, "mk_measure.py"), encoding="utf-8").read(), _MS) TOP_SEGS = [] for _line in _MS["PLATE_LINES"]: _pts = [(lat * _MS["S"], _MS["Yi"](x)) for x, lat in _line] for _sg in (1, -1): _P = [(_sg * a, b) for a, b in _pts] TOP_SEGS += list(zip(_P, _P[1:])) for _x in (600.0, 730.0): TOP_SEGS.append(((-0.75, _MS["Yi"](_x)), (0.75, _MS["Yi"](_x)))) _x0, _x1, _y0, _y1, _rr = _MS["SIDE_PANEL"] PANEL = dict(cy=0.5 * (_MS["Yi"](_x0) + _MS["Yi"](_x1)), cz=0.5 * (_MS["Zi"](_y0) + _MS["Zi"](_y1)), hy=0.5 * abs(_MS["Yi"](_x1) - _MS["Yi"](_x0)), hz=0.5 * abs(_MS["Zi"](_y0) - _MS["Zi"](_y1)), r=_rr * _MS["S"]) def seam_value(nt, loc_x=0): """0..1: how much of a panel seam this point is (the shell's plate seams seen from above, the rear side panel's outline)""" N = nt.nodes L = nt.links def mth(op, a, b=None, clamp=False): n = N.new('ShaderNodeMath'); n.operation = op; n.use_clamp = clamp for i, v in enumerate((a, b)): if v is None: continue if isinstance(v, (int, float)): n.inputs[i].default_value = v else: L.new(v, n.inputs[i]) return n.outputs[0] def vm(op, a, b=None): n = N.new('ShaderNodeVectorMath'); n.operation = op for i, v in enumerate((a, b)): if v is None: continue if isinstance(v, (tuple, list)): n.inputs[i].default_value = v else: L.new(v, n.inputs[i]) return n.outputs["Value"] if op in ('LENGTH', 'DOT_PRODUCT') else n.outputs["Vector"] geo = N.new('ShaderNodeNewGeometry') sp = N.new('ShaderNodeSeparateXYZ'); L.new(geo.outputs["Position"], sp.inputs[0]) sn = N.new('ShaderNodeSeparateXYZ'); L.new(geo.outputs["Normal"], sn.inputs[0]) cxy = N.new('ShaderNodeCombineXYZ'); L.new(sp.outputs["X"], cxy.inputs[0]); L.new(sp.outputs["Y"], cxy.inputs[1]) P2 = cxy.outputs[0] dmin = None for (a, b) in TOP_SEGS: A = (a[0], a[1], 0.0); AB = (b[0] - a[0], b[1] - a[1], 0.0) L2 = max(1e-9, AB[0] ** 2 + AB[1] ** 2) pa = vm('SUBTRACT', P2, A) t = mth('DIVIDE', vm('DOT_PRODUCT', pa, AB), L2, clamp=True) sc = N.new('ShaderNodeVectorMath'); sc.operation = 'SCALE'; sc.inputs[0].default_value = AB; L.new(t, sc.inputs["Scale"]) d = vm('LENGTH', vm('SUBTRACT', pa, sc.outputs["Vector"])) dmin = d if dmin is None else mth('MINIMUM', dmin, d) gate_top = mth('GREATER_THAN', sn.outputs["Z"], 0.35) def band(d): mr = N.new('ShaderNodeMapRange'); mr.interpolation_type = 'SMOOTHSTEP' mr.inputs["From Min"].default_value = SEAM_W0; mr.inputs["From Max"].default_value = SEAM_W1 mr.inputs["To Min"].default_value = 0.0; mr.inputs["To Max"].default_value = 1.0 L.new(d, mr.inputs["Value"]) return mr.outputs["Result"] top = mth('MULTIPLY', band(dmin), gate_top) # the side panel: a rounded rectangle in (y, z) on both flanks qy = mth('SUBTRACT', mth('ABSOLUTE', mth('SUBTRACT', sp.outputs["Y"], PANEL["cy"])), PANEL["hy"] - PANEL["r"]) qz = mth('SUBTRACT', mth('ABSOLUTE', mth('SUBTRACT', sp.outputs["Z"], PANEL["cz"])), PANEL["hz"] - PANEL["r"]) cq = N.new('ShaderNodeCombineXYZ'); L.new(qy, cq.inputs[0]); L.new(qz, cq.inputs[1]) outside = vm('LENGTH', vm('MAXIMUM', cq.outputs[0], (0.0, 0.0, 0.0))) inside = mth('MINIMUM', mth('MAXIMUM', qy, qz), 0.0) sdf = mth('SUBTRACT', mth('ADD', outside, inside), PANEL["r"]) gate_side = mth('GREATER_THAN', mth('ABSOLUTE', sn.outputs["X"]), 0.55) gate_lat = mth('GREATER_THAN', mth('ABSOLUTE', sp.outputs["X"]), 0.15) side = mth('MULTIPLY', mth('MULTIPLY', band(mth('ABSOLUTE', sdf)), gate_side), gate_lat) return mth('MAXIMUM', top, side) def dress_glass(mat, tint): """the canopy reads light blue, as the sheet draws it: mostly clear, a tenth of it a light-blue body colour, a clear coat for the sheen, fingerprints and dust in the roughness""" t = Tree(mat) b = t.node('ShaderNodeBsdfPrincipled') out = t.node('ShaderNodeOutputMaterial') t.link(b.outputs[0], out.inputs["Surface"]) dirt = t.tex(tex_image(os.path.join(PACK, DIRT), "Non-Color"), t.mapped(t.uv("UV_Tile"), 1.0 / 0.7), "smudges and dust").outputs["Color"] b.inputs["Base Color"].default_value = tuple(tint) + (1,) b.inputs["Transmission Weight"].default_value = 0.78 if mat.name.endswith("Glass") else 1.0 b.inputs["IOR"].default_value = 1.49 b.inputs["Coat Weight"].default_value = 0.5 b.inputs["Coat Roughness"].default_value = 0.02 t.put(b.inputs["Roughness"], t.ranged(dirt, 0.0, 0.03, "smudged", 0.0, 1.0)) mat.diffuse_color = tuple(tint) + (0.35,) try: mat.surface_render_method = 'DITHERED' except Exception: pass # ------------------------------------------------------------------ UV layers def tile_uv(co, n): ax = 0 if abs(n.x) >= abs(n.y) and abs(n.x) >= abs(n.z) else (1 if abs(n.y) >= abs(n.z) else 2) return (co.y, co.z) if ax == 0 else ((co.x, co.z) if ax == 1 else (co.x, co.y)) def uv_layers(o, rect, size): me = o.data nl = len(me.loops) tile = np.zeros(nl * 2, np.float32) if "UV_Tile" in me.uv_layers: me.uv_layers["UV_Tile"].data.foreach_get("uv", tile) while len(me.uv_layers): me.uv_layers.remove(me.uv_layers[0]) me.uv_layers.new(name="UVMap") me.uv_layers.new(name="UV_Tile") me.uv_layers["UV_Tile"].data.foreach_set("uv", tile) me.uv_layers.active = me.uv_layers["UVMap"] for o2 in bpy.context.view_layer.objects: o2.select_set(False) o.hide_set(False) o.select_set(True) bpy.context.view_layer.objects.active = o bpy.ops.object.mode_set(mode='EDIT') bpy.ops.mesh.select_all(action='SELECT') x0, y0, s = rect bpy.ops.uv.smart_project(angle_limit=math.radians(60), island_margin=6.0 / (s * size), area_weight=0.0, correct_aspect=True, scale_to_bounds=False) try: bpy.ops.uv.select_all(action='SELECT') bpy.ops.uv.pack_islands(rotate=True, rotate_method='CARDINAL', scale=True, margin_method='FRACTION', margin=6.0 / (s * size), shape_method='CONCAVE') except Exception as ex: print("pack skipped", o.name, ex) bpy.ops.object.mode_set(mode='OBJECT') me = o.data uv = np.zeros(nl * 2, np.float32) me.uv_layers["UVMap"].data.foreach_get("uv", uv) uv = uv.reshape(-1, 2) pad = 4.0 / (s * size) uv = np.array([x0, y0], np.float32) + (pad + uv * (1 - 2 * pad)) * s me.uv_layers["UVMap"].data.foreach_set("uv", uv.ravel()) # metres for the tiling sets wherever the builder has not written them tile = tile.reshape(-1, 2) vco = [v.co.copy() for v in me.vertices] lv = [l.vertex_index for l in me.loops] for p in me.polygons: ls, lt = p.loop_start, p.loop_total if np.any(tile[ls:ls + lt] != 0.0): continue for li in range(ls, ls + lt): tile[li] = tile_uv(vco[lv[li]], p.normal) me.uv_layers["UV_Tile"].data.foreach_set("uv", tile.ravel()) me.uv_layers["UVMap"].active_render = True a3 = sum(p.area for p in me.polygons) a2 = 0.0 for p in me.polygons: q = uv[p.loop_start:p.loop_start + p.loop_total] a2 += 0.5 * abs(float(np.sum(q[:, 0] * np.roll(q[:, 1], -1) - np.roll(q[:, 0], -1) * q[:, 1]))) return dict(atlas_px_per_m=round(math.sqrt(a2 * size ** 2 / a3), 1), fill=round(a2 / (s * s), 3)) # ------------------------------------------------------------------ the screens: a drawn instrument sheet and its UVs def make_screens(path, n=512): """three panels side by side (sonar, heading and depth, cargo hold), cyan on a dark blue ground; own drawing""" W = 3 * n yy, xx = np.mgrid[0:n, 0:W].astype(np.float32) img = np.zeros((n, W, 3), np.float32) img[:] = (0.004, 0.012, 0.020) cyan = np.array(SCREEN_INK, np.float32) def put(mask, col, a=1.0): img[mask] = img[mask] * (1 - a) + col * a u = xx / n; v = yy / n # 1 sonar cx, cy = 0.5, 0.5 r = np.hypot(u - cx, v - cy) for rr in (0.12, 0.24, 0.36, 0.46): put((np.abs(r - rr) < 0.004) & (u < 1), cyan * 0.55) ang = np.arctan2(v - cy, u - cx) sweep = ((ang - 0.6) % (2 * math.pi)) put((r < 0.46) & (sweep < 0.9) & (u < 1), cyan * 0.30, 0.35) put((np.abs(u - cx) < 0.002) & (r < 0.46), cyan * 0.4); put((np.abs(v - cy) < 0.002) & (r < 0.46) & (u < 1), cyan * 0.4) for bx, by, br in ((0.62, 0.36, 0.014), (0.41, 0.70, 0.010), (0.70, 0.62, 0.012)): put(np.hypot(u - bx, v - by) < br, np.array(SCREEN_ALERT)) # 2 heading tape and depth ladder m2 = (u >= 1) & (u < 2) for k in range(-6, 7): x = 1.5 + k * 0.06 put(m2 & (np.abs(u - x) < 0.003) & (v > 0.12) & (v < (0.20 if k % 2 else 0.24)), cyan * 0.8) put(m2 & (np.abs(u - 1.5) < 0.006) & (v > 0.07) & (v < 0.27), np.array(SCREEN_ALERT)) for k in range(9): y = 0.36 + k * 0.065 put(m2 & (np.abs(v - y) < 0.003) & (u > 1.15) & (u < (1.30 if k % 2 else 1.36)), cyan * 0.7) put(m2 & (u > 1.42) & (u < 1.86) & (v > 0.40) & (v < 0.86) & ((np.abs(u - 1.64) < 0.002) | (np.abs(v - 0.63) < 0.002)), cyan * 0.4) put(m2 & (np.abs(np.hypot(u - 1.64, v - 0.63) - 0.18) < 0.004), cyan * 0.7) # 3 cargo hold: eight bins with fill bars m3 = u >= 2 for k in range(8): x0 = 2.10 + k * 0.10 fill = (0.85, 0.62, 0.40, 0.95, 0.20, 0.55, 0.70, 0.10)[k] frame = m3 & (u > x0) & (u < x0 + 0.07) & (v > 0.15) & (v < 0.85) inner = m3 & (u > x0 + 0.008) & (u < x0 + 0.062) & (v > 0.85 - 0.692 * fill) & (v < 0.842) put(frame & ~(m3 & (u > x0 + 0.004) & (u < x0 + 0.066) & (v > 0.154) & (v < 0.846)), cyan * 0.6) put(inner, (np.array(SCREEN_ALERT) if fill > 0.9 else cyan) * 0.75) img = np.clip(img, 0, 1) srgb = np.where(img <= 0.0031308, img * 12.92, 1.055 * np.power(img, 1 / 2.4) - 0.055) rgba = np.concatenate([srgb, np.ones((n, W, 1), np.float32)], -1)[::-1] im = bpy.data.images.new("T_Mako_Screens", W, n, alpha=False) im.pixels.foreach_set(rgba.astype(np.float32).ravel()) im.filepath_raw = path im.file_format = 'PNG' im.save() im.colorspace_settings.name = 'sRGB' return im # ------------------------------------------------------------------ the two baked maps def use_gpu(): sc = bpy.context.scene sc.render.engine = 'CYCLES' try: prefs = bpy.context.preferences.addons['cycles'].preferences for kind in ('OPTIX', 'CUDA', 'HIP', 'ONEAPI'): try: prefs.compute_device_type = kind except Exception: continue prefs.refresh_devices() if any(d.type == kind for d in prefs.devices): for d in prefs.devices: d.use = d.type == kind sc.cycles.device = 'GPU' return kind except Exception as ex: print("GPU not set:", ex) sc.cycles.device = 'CPU' return 'CPU' def bake_atlas(name, parts, size, skip_mats=("MAKO | Screen",)): t1 = time.time() kind = use_gpu() sc = bpy.context.scene N = size def blank(nm, rgba): im = bpy.data.images.new(nm, N, N, alpha=False) im.colorspace_settings.name = 'Non-Color' im.pixels.foreach_set(np.tile(np.array(rgba, np.float32), N * N)) return im masks = blank("T_Mako_%s_Masks" % name, (1.0, 0.0, 1.0, 1.0)) bevel = blank("T_Mako_%s_BevelNormal" % name, (0.5, 0.5, 1.0, 1.0)) mm = bpy.data.materials.new("_bake_masks") mm.use_nodes = True nt = mm.node_tree nt.nodes.clear() ao = nt.nodes.new('ShaderNodeAmbientOcclusion'); ao.samples = 16; ao.only_local = True; ao.inputs["Distance"].default_value = 0.55 cv = nt.nodes.new('ShaderNodeAmbientOcclusion'); cv.samples = 16; cv.only_local = True; cv.inputs["Distance"].default_value = 0.06 bv = nt.nodes.new('ShaderNodeBevel'); bv.samples = 8; bv.inputs["Radius"].default_value = EDGE_R geo = nt.nodes.new('ShaderNodeNewGeometry') dot = nt.nodes.new('ShaderNodeVectorMath'); dot.operation = 'DOT_PRODUCT' nt.links.new(bv.outputs["Normal"], dot.inputs[0]); nt.links.new(geo.outputs["Normal"], dot.inputs[1]) inv = nt.nodes.new('ShaderNodeMath'); inv.operation = 'SUBTRACT'; inv.inputs[0].default_value = 1.0 nt.links.new(dot.outputs["Value"], inv.inputs[1]) edg = nt.nodes.new('ShaderNodeMath'); edg.operation = 'MULTIPLY'; edg.inputs[1].default_value = 3.5; edg.use_clamp = True nt.links.new(inv.outputs[0], edg.inputs[0]) seam = seam_value(nt) sm = nt.nodes.new('ShaderNodeMath'); sm.operation = 'MULTIPLY'; sm.inputs[1].default_value = -0.85 nt.links.new(seam, sm.inputs[0]) sm2 = nt.nodes.new('ShaderNodeMath'); sm2.operation = 'ADD'; sm2.inputs[1].default_value = 1.0 nt.links.new(sm.outputs[0], sm2.inputs[0]) crev = nt.nodes.new('ShaderNodeMath'); crev.operation = 'MULTIPLY' nt.links.new(cv.outputs["AO"], crev.inputs[0]); nt.links.new(sm2.outputs[0], crev.inputs[1]) cc = nt.nodes.new('ShaderNodeCombineColor') nt.links.new(ao.outputs["AO"], cc.inputs[0]); nt.links.new(edg.outputs[0], cc.inputs[1]); nt.links.new(crev.outputs[0], cc.inputs[2]) em = nt.nodes.new('ShaderNodeEmission') nt.links.new(cc.outputs[0], em.inputs["Color"]) om = nt.nodes.new('ShaderNodeOutputMaterial') nt.links.new(em.outputs[0], om.inputs["Surface"]) ti = nt.nodes.new('ShaderNodeTexImage'); ti.image = masks nt.nodes.active = ti mn = bpy.data.materials.new("_bake_bevel") mn.use_nodes = True nt2 = mn.node_tree b2 = nt2.nodes["Principled BSDF"] bv2 = nt2.nodes.new('ShaderNodeBevel'); bv2.samples = 8; bv2.inputs["Radius"].default_value = BEVEL_R bump = nt2.nodes.new('ShaderNodeBump'); bump.invert = True bump.inputs["Strength"].default_value = 1.0; bump.inputs["Distance"].default_value = SEAM_DEPTH nt2.links.new(seam_value(nt2), bump.inputs["Height"]) nt2.links.new(bv2.outputs["Normal"], bump.inputs["Normal"]) nt2.links.new(bump.outputs["Normal"], b2.inputs["Normal"]) ti2 = nt2.nodes.new('ShaderNodeTexImage'); ti2.image = bevel nt2.nodes.active = ti2 sc.cycles.samples = 16 sc.cycles.use_denoising = False sc.render.bake.margin = 6 sc.render.bake.use_clear = False def run(): for p in parts: me = p.data.copy() bm = bmesh.new(); bm.from_mesh(me) names = [m.name for m in me.materials] dead = [names.index(n) for n in skip_mats if n in names] if dead: bmesh.ops.delete(bm, geom=[f for f in bm.faces if f.material_index in dead], context='FACES') for f in bm.faces: f.material_index = 0 bm.to_mesh(me); bm.free() me.materials.clear() me.materials.append(mm) me.uv_layers.active = me.uv_layers["UVMap"] me.uv_layers["UVMap"].active_render = True tmp = bpy.data.objects.new("_bake_" + p.name, me) sc.collection.objects.link(tmp) tmp.matrix_world = p.matrix_world for o2 in bpy.context.scene.objects: if o2 is not None: o2.select_set(False) tmp.select_set(True) bpy.context.view_layer.objects.active = tmp try: bpy.ops.object.bake(type='EMIT', margin=6, use_clear=False) me.materials.clear() me.materials.append(mn) bpy.ops.object.bake(type='NORMAL', normal_space='TANGENT', margin=6, use_clear=False) finally: bpy.data.objects.remove(tmp) bpy.data.meshes.remove(me) try: run() except Exception as ex: if kind == 'CPU': raise print("bake on %s failed (%s), again on the CPU" % (kind, ex)) kind = 'CPU' sc.cycles.device = 'CPU' run() for im in (masks, bevel): im.filepath_raw = os.path.join(TEX, im.name + ".png") im.file_format = 'PNG' im.save() bpy.data.materials.remove(mm) bpy.data.materials.remove(mn) return masks, bevel, dict(device=kind, seconds=round(time.time() - t1, 1), size=N) # ------------------------------------------------------------------ node helpers (as the docking arm's) def tex_image(path, colorspace="sRGB"): path = os.path.normpath(path) for im in bpy.data.images: if im.filepath and os.path.normpath(bpy.path.abspath(im.filepath)) == path: return im im = bpy.data.images.load(path) im.colorspace_settings.name = colorspace return im _RANGE = {} def tex_range(im, lo=3, hi=97): if im.name not in _RANGE: n, ch = im.size[0] * im.size[1], im.channels px = np.empty(n * ch, np.float32) im.pixels.foreach_get(px) px = px.reshape(n, ch) if ch >= 3: c = px[:, :3] if im.colorspace_settings.name == 'sRGB': c = np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4) v = c @ np.array([0.2126, 0.7152, 0.0722], np.float32) else: v = px[:, 0] _RANGE[im.name] = (round(float(np.percentile(v, lo)), 4), round(float(np.percentile(v, hi)), 4)) return _RANGE[im.name] class Tree: def __init__(self, mat): mat.use_nodes = True self.nt = mat.node_tree self.nt.nodes.clear() def node(self, kind, label=None, **props): n = self.nt.nodes.new(kind) if label: n.label = label for k, v in props.items(): setattr(n, k, v) i = len(self.nt.nodes) - 1 n.location = (-2000 + 240 * (i // 4), 500 - 320 * (i % 4)) return n def link(self, a, b): self.nt.links.new(a, b) def put(self, sock, v): if isinstance(v, bpy.types.NodeSocket): self.nt.links.new(v, sock) else: sock.default_value = v def math(self, op, a, b=None, clamp=False, label=None): n = self.node('ShaderNodeMath', label, operation=op, use_clamp=clamp) self.put(n.inputs[0], a) if b is not None: self.put(n.inputs[1], b) return n.outputs[0] def ranged(self, v, lo, hi, label=None, f0=0.0, f1=1.0, smooth=False): n = self.node('ShaderNodeMapRange', label) if smooth: n.interpolation_type = 'SMOOTHSTEP' self.put(n.inputs["Value"], v) n.inputs["From Min"].default_value = f0; n.inputs["From Max"].default_value = f1 n.inputs["To Min"].default_value = lo; n.inputs["To Max"].default_value = hi return n.outputs["Result"] def mixc(self, fac, a, b, label=None): n = self.node('ShaderNodeMix', label, data_type='RGBA') self.put(n.inputs[0], fac) self.put(n.inputs[6], a if isinstance(a, bpy.types.NodeSocket) else tuple(a) + (1.0,)) self.put(n.inputs[7], b if isinstance(b, bpy.types.NodeSocket) else tuple(b) + (1.0,)) return n.outputs[2] def mixf(self, fac, a, b, label=None): n = self.node('ShaderNodeMix', label, data_type='FLOAT') self.put(n.inputs[0], fac); self.put(n.inputs[2], a); self.put(n.inputs[3], b) return n.outputs[0] def uv(self, name): return self.node('ShaderNodeUVMap', uv_map=name).outputs["UV"] def mapped(self, src, scale, label=None): n = self.node('ShaderNodeMapping', label) self.nt.links.new(src, n.inputs["Vector"]) n.inputs["Scale"].default_value = (scale, scale, scale) return n.outputs["Vector"] def tex(self, im, vec, label=None): n = self.node('ShaderNodeTexImage', label, image=im) self.nt.links.new(vec, n.inputs["Vector"]) return n def path_of(rel, r): return rel if r.get("abs_paths") else os.path.join(PACK, rel) def dress(mat, r, masks=None, bevel=None): t = Tree(mat) b = t.node('ShaderNodeBsdfPrincipled') out = t.node('ShaderNodeOutputMaterial') t.link(b.outputs[0], out.inputs["Surface"]) u0 = t.uv("UV_Tile") mp = t.mapped(u0, 1.0 / r["tile"], "one repeat = %.2f m" % r["tile"]) diff, rough, normal = r["set"] dirt = t.tex(tex_image(os.path.join(PACK, DIRT), "Non-Color"), t.mapped(u0, 1.0 / r["dirt_tile"]), "smudges and dust").outputs["Color"] ao = edge = crev = n_bevel = None if masks is not None: u1 = t.uv("UVMap") sep = t.node('ShaderNodeSeparateColor') t.link(t.tex(masks, u1, "baked: R occlusion, G edges, B crevices and seams").outputs["Color"], sep.inputs[0]) ao, edge, crev = sep.outputs[0], sep.outputs[1], sep.outputs[2] nb = t.node('ShaderNodeNormalMap', "rounded edges and seams", uv_map="UVMap") t.link(t.tex(bevel, u1, "baked: rounded edges, seam grooves").outputs["Color"], nb.inputs["Color"]) n_bevel = nb.outputs["Normal"] col = tuple(r["tint"]) if diff: im = tex_image(path_of(diff, r)) lo, hi = tex_range(im) bw = t.node('ShaderNodeRGBToBW') t.link(t.tex(im, mp, "surface, diffuse").outputs["Color"], bw.inputs[0]) vm = t.node('ShaderNodeVectorMath', "tint x shade", operation='SCALE') vm.inputs[0].default_value = tuple(r["tint"]) t.link(t.ranged(bw.outputs[0], r["shade"][0], r["shade"][1], "shade the tint", lo, hi), vm.inputs["Scale"]) col = vm.outputs["Vector"] grime = None if crev is not None and r["grime"] > 0: grime = t.math('MULTIPLY', t.math('POWER', t.math('SUBTRACT', 1.0, crev), 1.4), r["grime"], clamp=True, label="shade in crevices and seams") col = t.mixc(grime, col, (0.004, 0.004, 0.004), "darken crevices") if r.get("dust"): col = t.mixc(t.math('MULTIPLY', dirt, r["dust"]), col, (0.55, 0.53, 0.50), "dust") rim = tex_image(path_of(rough, r), "Non-Color") rlo, rhi = tex_range(rim) ro = t.tex(rim, mp, "surface, roughness").outputs["Color"] rr = t.ranged(ro, r["rough"][0], r["rough"][1], "roughness range", rlo, rhi) rr = t.math('ADD', rr, t.math('MULTIPLY', dirt, r["dirt"]), clamp=True, label="smudges dull the sheen") if grime is not None: rr = t.math('ADD', rr, t.math('MULTIPLY', grime, 0.35), clamp=True, label="crevices are matte") metal = r["metal"] if edge is not None and r["wear"] > 0: breakup = t.ranged(ro, 1.25, 0.55, "scratches break the wear up", rlo, rhi) exposed = t.ranged(ao, 0.0, 1.0, "only where the part is exposed", 0.72, 0.93, smooth=True) w = t.math('MULTIPLY', t.math('MULTIPLY', t.math('MULTIPLY', edge, breakup), exposed), r["wear"]) w = t.ranged(w, 0.0, 1.0, "chipped edge", 0.38, 0.72, smooth=True) col = t.mixc(w, col, tuple(r["wear_col"]), "under the coating") rr = t.mixf(w, rr, r["wear_rough"]) metal = t.mixf(w, r["metal"], 1.0 if r["metal"] > 0.5 else 0.6) t.put(b.inputs["Base Color"], tuple(col) + (1.0,) if isinstance(col, tuple) else col) t.put(b.inputs["Roughness"], rr) t.put(b.inputs["Metallic"], metal) if r.get("coat"): b.inputs["Coat Weight"].default_value = r["coat"] t.put(b.inputs["Coat Roughness"], t.math('ADD', r["coat_rough"], t.math('MULTIPLY', dirt, r["coat_dirt"]), clamp=True, label="clear coat, smudged")) nm_file = r.get("normal_map") or normal n_fine = None if nm_file and r["normal"] > 0: nd = t.node('ShaderNodeNormalMap', "fine relief", uv_map="UV_Tile") nd.inputs["Strength"].default_value = r["normal"] t.link(t.tex(tex_image(path_of(nm_file, r), "Non-Color"), mp, "surface, normal").outputs["Color"], nd.inputs["Color"]) n_fine = nd.outputs["Normal"] if n_bevel is not None and n_fine is not None: geo = t.node('ShaderNodeNewGeometry') a1 = t.node('ShaderNodeVectorMath', operation='ADD'); t.link(n_bevel, a1.inputs[0]); t.link(n_fine, a1.inputs[1]) s1 = t.node('ShaderNodeVectorMath', operation='SUBTRACT'); t.link(a1.outputs[0], s1.inputs[0]); t.link(geo.outputs["Normal"], s1.inputs[1]) nz = t.node('ShaderNodeVectorMath', "both normals", operation='NORMALIZE'); t.link(s1.outputs[0], nz.inputs[0]) t.link(nz.outputs[0], b.inputs["Normal"]) if r.get("coat"): t.link(n_bevel, b.inputs["Coat Normal"]) # the coat follows the shape and the seams, not the scratches elif n_bevel is not None or n_fine is not None: t.link(n_bevel if n_bevel is not None else n_fine, b.inputs["Normal"]) mat.diffuse_color = tuple(r["tint"]) + (1.0,) mat.roughness = 0.4 mat.metallic = r["metal"] def dress_glow(mat, color, strength, image=None): t = Tree(mat) b = t.node('ShaderNodeBsdfPrincipled') out = t.node('ShaderNodeOutputMaterial') t.link(b.outputs[0], out.inputs["Surface"]) b.inputs["Base Color"].default_value = (0.02, 0.025, 0.03, 1) b.inputs["Roughness"].default_value = 0.18 if image is not None: tx = t.tex(image, t.uv("UVMap"), "instrument sheet") t.link(tx.outputs["Color"], b.inputs["Emission Color"]) else: b.inputs["Emission Color"].default_value = tuple(color) + (1,) b.inputs["Emission Strength"].default_value = strength mat.diffuse_color = tuple(color) + (1.0,) # ------------------------------------------------------------------ run O = bpy.data.objects parts = {o.name: o for o in O if o.type == 'MESH' and o.name.startswith("SM_Mako")} REPORT["uv"] = {} for atlas, spec in ATLASES.items(): for nm, rect in spec["rects"].items(): REPORT["uv"][nm] = uv_layers(parts[nm], rect, spec["size"]) screens = make_screens(os.path.join(TEX, "T_Mako_Screens.png")) REPORT["screen_faces"] = screen_uvs(parts["SM_Mako_Cabin"]) baked = {} for atlas, spec in ATLASES.items(): plist = [parts[n] for n in spec["rects"]] masks, bevel, info = bake_atlas(atlas, plist, spec["size"]) baked[atlas] = (masks, bevel) REPORT["bake_" + atlas] = info # materials: one per kind. The cabin reads its own atlas, so the kinds it shares with the outside (graphite, steel) get cabin copies cab = parts["SM_Mako_Cabin"] users = {} for o in parts.values(): for sl in o.material_slots: if sl.material: users.setdefault(sl.material.name, set()).add(o.name) KEY, ATL = {}, {} for sl in cab.material_slots: m = sl.material if m and m.name in SURF and users[m.name] - {cab.name}: cm = m.copy() cm.name = "MAKO | Cabin " + m.name.split("| ")[1].lower() sl.material = cm KEY[cm.name], ATL[cm.name] = m.name, "Cabin" for m in list(bpy.data.materials): if m.name in SURF: KEY[m.name] = m.name ATL[m.name] = "Cabin" if users.get(m.name) == {cab.name} else "Exterior" for m in bpy.data.materials: if m.name in KEY: masks, bevel = baked[ATL[m.name]] dress(m, SURF[KEY[m.name]], masks, bevel) elif m.name in GLOW: dress_glow(m, *GLOW[m.name]) elif m.name == "MAKO | Screen": dress_glow(m, SCREEN_INK, 2.2, screens) elif m.name in GLASSES: dress_glass(m, GLASSES[m.name]) REPORT["material_atlas"] = ATL REPORT["materials"] = sorted(m.name for m in bpy.data.materials if m.users and m.name.startswith("MAKO")) REPORT["seconds"] = round(time.time() - t0, 1) bpy.ops.file.make_paths_relative() bpy.ops.wm.save_mainfile() json.dump(REPORT, open(os.path.join(HERE, "surfaces_report.json"), "w"), indent=1) print("SURFACES OK", json.dumps(REPORT))