"""Real surfaces for the docking arm. exec'd inside build_dockarm.py (shares its namespace). The user, 2026-10-13: "do it more realistic with good textures". The first arm had one flat colour per material and long glowing lines. Now: * the hard surfaces take tiling texture 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 paint, StainedSteel for bare steel and under the dark coating, Glass_DirtRough as a smudge and dust mask, T_Scratch_Norm for fine scratches; * two maps baked from the model itself (Cycles) into one shared atlas, textures/T_DockArm_Masks.png (R ambient occlusion, G edges, B crevices) and textures/T_DockArm_BevelNormal.png (every hard edge rounded by 12 mm): a little shade sits in the crevices, the coating is chipped here and there on exposed edges, and edges catch the light instead of being knife-sharp; * glow is an accent: the light lines along booms and links are gone, the remaining lights are small and dimmer. The ship is a clean, grayish-white vessel, so this is a well-kept machine: no rust, no heavy grime. Every part has two UV layers (the order matters for Unreal, which builds its tangents from the first): UVMap (UV0) a unique unwrap inside the part's square of the shared atlas: the two baked maps; decal quads carry their decal-sheet coordinates here. Turret (3 equal sectors) and mount (6) unwrap ONE sector; the others use the same texels one whole tile further along U, which triples the turret's sharpness. UV_Tile (UV1) metres: surfaces of revolution unrolled, everything else box-projected. For the tiling sets. """ import time import numpy as np from mathutils import kdtree PACK = os.path.normpath(os.path.join(OUT, "..", "vessel-interior", "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) DIRT = "Base/Glass/T_Glass_DirtRough.png" SCRATCH_N = "Base/Glass/T_Scratch_Norm.png" ATLAS_SIZE = 4096 ATLAS_RECT = {"SM_DockArm_Boom": (0.00, 0.00, 0.45), "SM_DockArm_Turret": (0.45, 0.00, 0.35), "SM_DockArm_Head": (0.00, 0.45, 0.35), "SM_DockArm_LowerLink": (0.35, 0.45, 0.35), "SM_DockArm_UpperLink": (0.70, 0.45, 0.30), "SM_DockArm_Mount": (0.80, 0.00, 0.20), "SM_DockArm_Claw": (0.80, 0.20, 0.20)} # (0.70..1.00, 0.75..1.00) stays blank: the ship's pylon points its UV0 there # (equal sectors about Z, azimuth where sector 0 starts). The boundaries are put where no face centre lies: 2 degrees past # the fin that sits midway between two booms; midway between two flange bolts of the mount. ATLAS_SECTORS = {"SM_DockArm_Turret": (3, arm_yaw(0) - math.radians(58.0)), "SM_DockArm_Mount": (6, math.radians(40.0))} BEVEL_R, EDGE_R = 0.012, 0.020 # What each material is made of. tile: metres per repeat. shade: the diffuse map darkens/lightens the tint between these. # rough: what the roughness map's dark and bright ends become. dirt: smudge mask added to the roughness. grime: how far # crevices darken. wear: how readily exposed edges show wear_col (the metal under the coating); 0 = never. SURF = { "M_DockArm_Paint": dict(set=SCR, tint=(0.64, 0.65, 0.65), metal=0.0, tile=0.90, shade=(0.88, 1.05), rough=(0.60, 0.34), normal=0.35, dirt=0.05, dirt_tile=2.6, grime=0.22, wear=0.45, wear_col=(0.56, 0.57, 0.58), wear_rough=0.40), "M_DockArm_Graphite": dict(set=(STN[0], SCR[1], SCR[2]), tint=(0.040, 0.043, 0.048), metal=0.0, tile=0.70, shade=(0.87, 1.13), rough=(0.66, 0.42), normal=0.30, dirt=0.06, dirt_tile=1.9, grime=0.15, wear=0.50, wear_col=(0.30, 0.31, 0.32), wear_rough=0.36), "M_DockArm_Steel": dict(set=STN, tint=(0.60, 0.61, 0.63), metal=1.0, tile=0.50, shade=(0.80, 1.15), rough=(0.22, 0.36), normal=0.50, normal_map=SCRATCH_N, dirt=0.05, dirt_tile=0.9, grime=0.20, wear=0.0), "M_DockArm_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), "M_DockArm_SafetyOrange": dict(set=SCR, tint=(0.78, 0.19, 0.02), metal=0.0, tile=0.60, shade=(0.84, 1.06), rough=(0.62, 0.36), normal=0.40, dirt=0.06, dirt_tile=1.4, grime=0.25, wear=0.70, wear_col=(0.50, 0.51, 0.52), wear_rough=0.36), } HOSE_BAND = dict(set=STN, tint=(0.60, 0.61, 0.63), metal=1.0, tile=0.35, shade=(0.80, 1.15), rough=(0.22, 0.36), normal=0.50, normal_map=SCRATCH_N, dirt=0.05, dirt_tile=0.6, grime=0.0, wear=0.0) # Lights are kept only where they signal something (the head's lock ring, servo and claw indicators, pack status bars) and # are lit strips, not glare: at 6 and 5 (the first version) they bloomed white. GLOW = {"M_DockArm_GlowCyan": ((0.10, 0.85, 1.0), 1.2), "M_DockArm_GlowAmber": ((1.0, 0.55, 0.08), 1.2)} # ------------------------------------------------------------------ UV layers def tile_uv(co, n): """metres, by the dominant axis of the normal""" 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): me = o.data nl = len(me.loops) tile = np.zeros(nl * 2, np.float32) if "UV_Tile" in me.uv_layers: # written by lathe() for surfaces of revolution 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"] n_sec, a0 = ATLAS_SECTORS.get(o.name, (1, 0.0)) step = 2 * math.pi / n_sec sec = [int(((math.atan2(p.center.y, p.center.x) - a0) % (2 * math.pi)) / step) % n_sec if n_sec > 1 else 0 for p in me.polygons] # 1. a unique unwrap of sector 0, placed in the part's square of the atlas for o2 in bpy.context.view_layer.objects: o2.select_set(False) o.select_set(True) bpy.context.view_layer.objects.active = o bpy.ops.object.mode_set(mode='EDIT') bm = bmesh.from_edit_mesh(me) bm.faces.ensure_lookup_table() bm.faces.index_update() for f in bm.faces: f.select_set(False) for f in bm.faces: if sec[f.index] == 0: f.select_set(True) bm.select_flush_mode() bmesh.update_edit_mesh(me) x0, y0, s = ATLAS_RECT[o.name] bpy.ops.uv.smart_project(angle_limit=math.radians(66), island_margin=8.0 / (s * ATLAS_SIZE), area_weight=0.0, correct_aspect=True, scale_to_bounds=False) bpy.ops.object.mode_set(mode='OBJECT') lsec = np.zeros(nl, np.int32) for p in o.data.polygons: lsec[p.loop_start:p.loop_start + p.loop_total] = sec[p.index] m0 = lsec == 0 def read_uv(): a = np.zeros(nl * 2, np.float32) o.data.uv_layers["UVMap"].data.foreach_get("uv", a) return a.reshape(-1, 2) def fill(a): # share of the unit square the sector-0 faces cover return sum(0.5 * abs(float(np.sum(q[:, 0] * np.roll(q[:, 1], -1) - np.roll(q[:, 0], -1) * q[:, 1]))) for q in (a[p.loop_start:p.loop_start + p.loop_total] for p in o.data.polygons if sec[p.index] == 0)) uv = read_uv() try: # a tighter packing of the same islands (quarter turns only) bpy.ops.object.mode_set(mode='EDIT') bpy.ops.uv.select_all(action='SELECT') bpy.ops.uv.pack_islands(rotate=True, rotate_method='CARDINAL', scale=True, margin_method='FRACTION', margin=8.0 / (s * ATLAS_SIZE), shape_method='CONCAVE') bpy.ops.object.mode_set(mode='OBJECT') uv2 = read_uv() inside = uv2[m0].min() > -1e-4 and uv2[m0].max() < 1.0001 if inside and fill(uv2) > fill(uv) * 1.02: uv = uv2 except Exception as ex: print("UV pack skipped for %s: %s" % (o.name, ex)) if bpy.context.mode != 'OBJECT': bpy.ops.object.mode_set(mode='OBJECT') me = o.data at, tl = me.uv_layers["UVMap"], me.uv_layers["UV_Tile"] pad = 4.0 / (s * ATLAS_SIZE) uv[m0] = np.array([x0, y0], np.float32) + (pad + uv[m0] * (1 - 2 * pad)) * s # 2. the other sectors: the same texels, k whole tiles further along U vco = [v.co.copy() for v in me.vertices] lv = [l.vertex_index for l in me.loops] RK = [Matrix.Rotation(-k * step, 3, 'Z') for k in range(n_sec)] miss = 0 if n_sec > 1: kd = kdtree.KDTree(sum(1 for k in sec if k == 0)) for p in me.polygons: if sec[p.index] == 0: kd.insert(p.center, p.index) kd.balance() for p in me.polygons: k = sec[p.index] if not k: continue c, idx, d = kd.find(RK[k] @ p.center) q = me.polygons[idx] if d > 2e-4 or q.loop_total != p.loop_total: miss += 1 continue qv = [(vco[lv[li]], li) for li in q.loop_indices] for li in p.loop_indices: v = RK[k] @ vco[lv[li]] bco, bli = min(qv, key=lambda t: (t[0] - v).length_squared) if (bco - v).length > 2e-4: miss += 1 uv[li] = (uv[bli][0] + k, uv[bli][1]) if miss: # not as symmetric as assumed: give every face its own texels print("UV sectors: %s has %d faces without a twin, unwrapping it whole" % (o.name, miss)) ATLAS_SECTORS[o.name] = (1, 0.0) return uv_layers(o) at.data.foreach_set("uv", uv.ravel()) # 3. metres for the tiling sets, wherever lathe() has not written them tile = tile.reshape(-1, 2) for p in me.polygons: ls, lt = p.loop_start, p.loop_total if np.any(tile[ls:ls + lt] != 0.0): continue R = RK[sec[p.index]] n = R @ p.normal for li in range(ls, ls + lt): tile[li] = tile_uv(R @ vco[lv[li]], n) tl.data.foreach_set("uv", tile.ravel()) me.uv_layers.active = me.uv_layers["UVMap"] me.uv_layers["UVMap"].active_render = True # how sharp the baked maps are on this part a3 = sum(p.area for p in me.polygons if sec[p.index] == 0) a2 = 0.0 for p in me.polygons: if sec[p.index]: continue pts = uv[p.loop_start:p.loop_start + p.loop_total] a2 += 0.5 * abs(float(np.sum(pts[:, 0] * np.roll(pts[:, 1], -1) - np.roll(pts[:, 0], -1) * pts[:, 1]))) return dict(sectors=n_sec, atlas_px_per_m=round(math.sqrt(a2 * ATLAS_SIZE ** 2 / a3), 1), atlas_fill=round(a2 / (s * s), 3)) # ------------------------------------------------------------------ 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 hasattr(prefs, "refresh_devices") else prefs.get_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(parts, decal_mat, folder): t0 = time.time() kind = use_gpu() sc = bpy.context.scene N = ATLAS_SIZE def blank(name, rgba): im = bpy.data.images.new(name, 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_DockArm_Masks", (1.0, 0.0, 1.0, 1.0)) # nothing baked there: open, no edge, no crevice bevel = blank("T_DockArm_BevelNormal", (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]) 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(cv.outputs["AO"], 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 nt2.links.new(bv2.outputs["Normal"], b2.inputs["Normal"]) ti2 = nt2.nodes.new('ShaderNodeTexImage'); ti2.image = bevel nt2.nodes.active = ti2 sc.cycles.samples = 16 try: sc.cycles.use_denoising = False except Exception: pass 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] if decal_mat.name in names: di = names.index(decal_mat.name) bmesh.ops.delete(bm, geom=[f for f in bm.faces if f.material_index == di], 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) for o2 in bpy.context.view_layer.objects: 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, nm in ((masks, "T_DockArm_Masks"), (bevel, "T_DockArm_BevelNormal")): im.filepath_raw = os.path.join(folder, nm + ".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() - t0, 1), size=N) # ------------------------------------------------------------------ node helpers 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): """the brightness the map really spans (3rd to 97th percentile), so a range set below means what it says""" 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 dress(mat, r, masks=None, bevel=None, uv_tile="UV_Tile"): """A coated or bare metal surface: a tiling set + smudges; with the baked maps also shade in crevices, chips on exposed edges and rounded edges.""" 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").outputs["Color"], sep.inputs[0]) ao, edge, crev = sep.outputs[0], sep.outputs[1], sep.outputs[2] nb = t.node('ShaderNodeNormalMap', "rounded edges", uv_map="UVMap") t.link(t.tex(bevel, u1, "baked: rounded edges").outputs["Color"], nb.inputs["Color"]) n_bevel = nb.outputs["Normal"] # colour col = tuple(r["tint"]) if diff: im = tex_image(os.path.join(PACK, diff)) 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") col = t.mixc(grime, col, (0.02, 0.019, 0.018), "darken crevices") if r.get("dust"): col = t.mixc(t.math('MULTIPLY', dirt, r["dust"]), col, (0.55, 0.53, 0.50), "dust") # roughness rim = tex_image(os.path.join(PACK, rough), "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"] # chips on exposed edges: the coating knocked through to the metal under it, where the scratch map says so 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"]), "metal under the coating") rr = t.mixf(w, rr, r["wear_rough"]) metal = t.mixf(w, r["metal"], 1.0) t.put(b.inputs["Base Color"], col) t.put(b.inputs["Roughness"], rr) t.put(b.inputs["Metallic"], metal) # normal: rounded edges, plus the set's fine relief 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(os.path.join(PACK, nm_file), "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"]) 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.5 mat.metallic = r["metal"] def dress_glow(mat, color, strength): 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.03, 0.035, 1) b.inputs["Roughness"].default_value = 0.22 b.inputs["Emission Color"].default_value = tuple(color) + (1,) b.inputs["Emission Strength"].default_value = strength mat.diffuse_color = tuple(color) + (1.0,) def dress_glass(mat): """dark lens glass with fingerprints and dust in its sheen""" 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.6), "smudges and dust").outputs["Color"] b.inputs["Base Color"].default_value = (0.012, 0.014, 0.018, 1) t.put(b.inputs["Roughness"], t.ranged(dirt, 0.16, 0.24)) # smoked, not a mirror: a mirror-smooth lens showed the room upside down mat.diffuse_color = (0.02, 0.025, 0.03, 1.0) def dress_decal(mat, sheet): """printed markings; the scratch map takes bits of the print away""" t = Tree(mat) b = t.node('ShaderNodeBsdfPrincipled') out = t.node('ShaderNodeOutputMaterial') t.link(b.outputs[0], out.inputs["Surface"]) d = t.tex(sheet, t.uv("UVMap"), "markings") rim = tex_image(os.path.join(PACK, SCR[1]), "Non-Color") rlo, rhi = tex_range(rim) scr = t.tex(rim, t.mapped(t.uv("UV_Tile"), 1.0 / 0.45), "scratches").outputs["Color"] keep = t.ranged(scr, 0.25, 1.0, "scratched print", rlo, rlo + 0.55 * (rhi - rlo), smooth=True) t.link(d.outputs["Color"], b.inputs["Base Color"]) t.put(b.inputs["Alpha"], t.math('MULTIPLY', d.outputs["Alpha"], keep)) b.inputs["Roughness"].default_value = 0.55 try: mat.surface_render_method = 'DITHERED' except Exception: pass mat.diffuse_color = (0.72, 0.73, 0.74, 0.0) # ------------------------------------------------------------------ the hose: clear ducting with a spiral rib HOSE_RIBS = 14 # ribs per texture repeat (one repeat = one circumference of hose length, 0.69 m): pitch 4.9 cm RIB_COL, FILM_COL = (0.40, 0.43, 0.45), (0.80, 0.86, 0.88) def make_duct_textures(folder, n=1024, seed=11): """Clear flexible ducting with a spiral reinforcing rib: colour + alpha, roughness, normal (OpenGL). Tileable, own work (the kit's coated-pipe maps are baked for its own bent pieces and cannot be laid along a 14 m hose).""" rng = np.random.default_rng(seed) u = (np.arange(n) + 0.5) / n U, V = np.meshgrid(u, u) # U around the hose, V along it (one repeat = one circumference) circ = 2 * math.pi * HOSE_R pitch = circ / HOSE_RIBS ph = (V * HOSE_RIBS - U) % 1.0 # the helix: one pitch further along per turn dist = np.abs(ph - 0.5) * pitch # metres from the rib's centre line rib = np.clip((0.0070 - dist) / 0.0026, 0.0, 1.0) rib = rib * rib * (3 - 2 * rib) wr = np.zeros((n, n)) # slack film between the ribs: fine creases along the hose for _ in range(260): cu, cv = rng.random(2) su, sv = rng.uniform(0.010, 0.030), rng.uniform(0.02, 0.07) du = (U - cu + 0.5) % 1.0 - 0.5 dv = (V - cv + 0.5) % 1.0 - 0.5 wr += rng.uniform(-1.0, 1.0) * np.exp(-(du / su) ** 2 - (dv / sv) ** 2) wr /= np.abs(wr).max() film = 1.0 - rib h = 0.0040 * np.exp(-(dist / 0.0048) ** 2) - 0.0016 * film * np.sin(np.pi * ph) + 0.0006 * film * wr # metres px = circ / n gx = (np.roll(h, -1, 1) - np.roll(h, 1, 1)) / (2 * px) gy = (np.roll(h, -1, 0) - np.roll(h, 1, 0)) / (2 * px) ln = np.sqrt(gx ** 2 + gy ** 2 + 1.0) nrm = np.stack([-gx / ln * 0.5 + 0.5, -gy / ln * 0.5 + 0.5, 1.0 / ln * 0.5 + 0.5, np.ones_like(h)], -1) col = rib[..., None] * np.array(RIB_COL) + film[..., None] * np.array(FILM_COL) alpha = rib * 1.0 + film * (0.20 + 0.08 * np.abs(wr)) rgba = np.concatenate([col, alpha[..., None]], -1) rough = rib * 0.46 + film * (0.07 + 0.05 * np.abs(wr)) rgh = np.stack([rough, rough, rough, np.ones_like(h)], -1) out = [] for name, arr, cs, al in (("T_DockArm_Duct_BaseColor", rgba, 'sRGB', True), ("T_DockArm_Duct_Roughness", rgh, 'Non-Color', False), ("T_DockArm_Duct_Normal", nrm, 'Non-Color', False)): if cs == 'sRGB': # the array is linear; the file is sRGB a = arr.copy() c = a[..., :3] a[..., :3] = np.where(c <= 0.0031308, c * 12.92, 1.055 * np.power(np.clip(c, 0, 1), 1 / 2.4) - 0.055) arr = a img = bpy.data.images.new(name, n, n, alpha=al) img.colorspace_settings.name = 'Non-Color' # write the numbers as they are ... img.pixels.foreach_set(arr.astype(np.float32).ravel()) img.filepath_raw = os.path.join(folder, name + ".png") img.file_format = 'PNG' img.save() img.colorspace_settings.name = cs # ... and read them back in the right space if al: img.alpha_mode = 'CHANNEL_PACKED' out.append(img) return out def dress_duct(mat, col_img, rough_img, nrm_img): """Clear film: see-through head-on, full reflections at a glancing angle (Fresnel), opaque ribs, dust in the film.""" t = Tree(mat) out = t.node('ShaderNodeOutputMaterial') u0 = t.uv("UVMap") c = t.tex(col_img, u0, "rib and film, colour + alpha") ro = t.tex(rough_img, u0, "roughness").outputs["Color"] nm = t.node('ShaderNodeNormalMap', "rib relief and creases", uv_map="UVMap") t.link(t.tex(nrm_img, u0, "normal").outputs["Color"], nm.inputs["Color"]) dirt = t.tex(tex_image(os.path.join(PACK, DIRT), "Non-Color"), t.mapped(u0, 1.0 / 1.7), "dust and scuffs in the film").outputs["Color"] alpha = t.math('ADD', c.outputs["Alpha"], t.math('MULTIPLY', dirt, 0.10), clamp=True, label="dust makes the film a little milky") rr = t.math('ADD', ro, t.math('MULTIPLY', dirt, 0.22), clamp=True) body = t.node('ShaderNodeBsdfPrincipled', "ribs, dust, the film's own tint") t.link(c.outputs["Color"], body.inputs["Base Color"]) t.put(body.inputs["Roughness"], rr) t.link(nm.outputs["Normal"], body.inputs["Normal"]) clear = t.node('ShaderNodeBsdfTransparent') clear.inputs["Color"].default_value = (0.96, 0.99, 1.0, 1) m1 = t.node('ShaderNodeMixShader', "see-through where the film is clear") t.put(m1.inputs[0], alpha); t.link(clear.outputs[0], m1.inputs[1]); t.link(body.outputs[0], m1.inputs[2]) gl = t.node('ShaderNodeBsdfGlossy' if hasattr(bpy.types, 'ShaderNodeBsdfGlossy') else 'ShaderNodeBsdfAnisotropic') t.put(gl.inputs["Roughness"], rr) t.link(nm.outputs["Normal"], gl.inputs["Normal"]) fr = t.node('ShaderNodeFresnel') fr.inputs["IOR"].default_value = 1.50 t.link(nm.outputs["Normal"], fr.inputs["Normal"]) m2 = t.node('ShaderNodeMixShader', "reflections of a plastic film") t.link(fr.outputs[0], m2.inputs[0]); t.link(m1.outputs[0], m2.inputs[1]); t.link(gl.outputs[0], m2.inputs[2]) t.link(m2.outputs[0], out.inputs["Surface"]) try: mat.surface_render_method = 'DITHERED' except Exception: pass mat.use_backface_culling = False mat.diffuse_color = (0.78, 0.86, 0.88, 0.35) def surface_table(): """what a material is made of, for the layout JSON and the README (and for whoever rebuilds it in Unreal)""" rows = [] for name, r in list(SURF.items()) + [("M_DockArm_HoseBand", HOSE_BAND)]: rows.append(dict(material=name, diffuse=r["set"][0], roughness=r["set"][1], normal=r.get("normal_map") or r["set"][2], repeat_m=r["tile"], tint_linear=list(r["tint"]), metallic=r["metal"], shade_range=list(r["shade"]), roughness_range=list(r["rough"]), normal_strength=r["normal"], smudge_added_to_roughness=r["dirt"], smudge_repeat_m=r["dirt_tile"], crevice_shade=r["grime"], edge_chips=r["wear"])) return rows