""" Blender side of the Modular SciFi Season 2 kit (import with `import bl_kit` from a script run inside Blender). Adapted from models/anchorage/build/bl_kit.py (Season 1): the Kit class is the same, the material builders are written for the Season 2 base materials. It reads what build/ue_export_kit.py wrote (kit_export/meshes.json, materials.json, the FBX files) and the 1K texture copies (textures/, build/make_proxies.py), and gives: kit = Kit() me = kit.mesh('/Game/ModSci_Engineer/Meshes/SM_Wall_HB_A') # bpy mesh, metres, Blender axes mat = kit.material('/Game/ModSci_Engineer/Materials/MI_Trims_A_Brushed') ob = kit.place(ue_mesh_path, matrix, collection, name=None, overrides=None) Conventions * Unreal is cm, left-handed (X forward, Y right, Z up). Blender is m, right-handed. A point maps as (x, y, z)cm -> (x, -y, z)/100 m, which is also what Unreal's FBX exporter and Blender's importer do together. * Every mesh datablock carries ["ue_mesh"] = its Unreal asset path, every material ["ue_material"]. That is what lets a layout built here be spawned in Unreal later from the original assets. * Names: _: SI interiors, EN engineer, EP engineer props. The materials are rebuilt from the pack's parameters (colour masks, trims, pipes, lights, glass, decals). They are a close preview, not the Unreal shaders: no detail normals, no panning emissive masks, no refraction. """ import json import math import os import bpy from mathutils import Matrix HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.dirname(HERE) KIT_DIR = os.path.join(ROOT, "kit_export") TEX_DIR = os.path.join(ROOT, "textures") GAME = "/Game/" PACK_SHORT = {"ModSciInteriors": "SI", "ModSci_Engineer": "EN", "ModSci_EngiProps": "EP"} PACKS = tuple(PACK_SHORT) # Unreal emissive multipliers are tuned for its eye adaptation and bloom; one factor brings them into range here. EMISSIVE_SCALE = 1.0 # --------------------------------------------------------------------------------------------------------------- # transforms # --------------------------------------------------------------------------------------------------------------- def ue_rows_to_matrix(rows): """Unreal world matrix as four rows (X axis, Y axis, Z axis, origin; cm) -> Blender 4x4 (m).""" X, Y, Z, W = rows return Matrix(( (X[0], -Y[0], Z[0], W[0] / 100.0), (-X[1], Y[1], -Z[1], -W[1] / 100.0), (X[2], -Y[2], Z[2], W[2] / 100.0), (0.0, 0.0, 0.0, 1.0), )) def matrix_to_ue_rows(m): """Blender 4x4 (m) -> Unreal world matrix rows (cm). Inverse of ue_rows_to_matrix.""" return [ [m[0][0], -m[1][0], m[2][0], 0.0], [-m[0][1], m[1][1], -m[2][1], 0.0], [m[0][2], -m[1][2], m[2][2], 0.0], [m[0][3] * 100.0, -m[1][3] * 100.0, m[2][3] * 100.0, 1.0], ] def ue_xform(x=0.0, y=0.0, z=0.0, yaw=0.0, sx=1.0, sy=1.0, sz=1.0, pitch=0.0, roll=0.0): """Blender matrix from an Unreal location (cm), rotation (degrees, Unreal's Pitch/Yaw/Roll) and scale.""" sp, cp = math.sin(math.radians(pitch)), math.cos(math.radians(pitch)) sw, cw = math.sin(math.radians(yaw)), math.cos(math.radians(yaw)) sr, cr = math.sin(math.radians(roll)), math.cos(math.radians(roll)) X = (cp * cw * sx, cp * sw * sx, sp * sx) Y = ((sr * sp * cw - cr * sw) * sy, (sr * sp * sw + cr * cw) * sy, -sr * cp * sy) Z = (-(cr * sp * cw + sr * sw) * sz, (cw * sr - cr * sp * sw) * sz, cr * cp * sz) return ue_rows_to_matrix([X, Y, Z, (x, y, z)]) def short_name(ue_path): rest = ue_path[len(GAME):] if ue_path.startswith(GAME) else ue_path.strip("/") parts = rest.split("/") return "%s_%s" % (PACK_SHORT.get(parts[0], parts[0][:3].upper()), parts[-1]) # --------------------------------------------------------------------------------------------------------------- class Kit(object): def __init__(self, log=print): with open(os.path.join(KIT_DIR, "meshes.json"), "r", encoding="utf-8") as f: self.meshes = json.load(f) with open(os.path.join(KIT_DIR, "materials.json"), "r", encoding="utf-8") as f: self.materials = json.load(f)["materials"] with open(os.path.join(TEX_DIR, "proxies.json"), "r", encoding="utf-8") as f: self.proxies = json.load(f) self.log = log self._mesh = {} self._mat = {} self._img = {} self._adopt_existing() def _adopt_existing(self): """Datablocks already in the file (a kit library that was opened) are reused, not imported again.""" for me in bpy.data.meshes: p = me.get("ue_mesh") if p: self._mesh[p] = me for mat in bpy.data.materials: p = mat.get("ue_material") if p: self._mat[p] = mat # -- images ------------------------------------------------------------------------------------------------- def image(self, ue_tex, colorspace=None): if not ue_tex: return None if ue_tex in self._img: return self._img[ue_tex] rec = self.proxies.get(ue_tex) img = None if rec and rec.get("file"): path = os.path.join(TEX_DIR, rec["file"].replace("/", os.sep)) if os.path.isfile(path): img = bpy.data.images.load(path, check_existing=True) img.colorspace_settings.name = colorspace or ("sRGB" if rec.get("srgb") else "Non-Color") img["ue_texture"] = ue_tex self._img[ue_tex] = img return img # -- materials ---------------------------------------------------------------------------------------------- def material(self, ue_path): if ue_path in self._mat: return self._mat[ue_path] rec = self.materials.get(ue_path) name = short_name(ue_path) if ue_path else "NONE" mat = bpy.data.materials.new(name) mat["ue_material"] = ue_path or "" self._mat[ue_path] = mat try: _MaterialBuilder(self, mat, ue_path, rec).build() except Exception as e: # noqa: BLE001 self.log("material %s: %s: %s" % (ue_path, type(e).__name__, e)) return mat # -- meshes ------------------------------------------------------------------------------------------------- def mesh(self, ue_path): if ue_path in self._mesh: return self._mesh[ue_path] rec = self.meshes.get(ue_path) if rec is None: self.log("mesh not in the kit export: %s" % ue_path) self._mesh[ue_path] = None return None objs_before = set(bpy.data.objects) mats_before = set(bpy.data.materials) bpy.ops.wm.fbx_import(filepath=os.path.join(KIT_DIR, rec["fbx"].replace("/", os.sep))) new = [o for o in bpy.data.objects if o not in objs_before] mesh_objs = [o for o in new if o.type == "MESH"] if len(mesh_objs) != 1: self.log("unexpected FBX content for %s: %s" % (ue_path, [(o.name, o.type) for o in new])) ob = mesh_objs[0] me = ob.data me.transform(ob.matrix_world) # cm -> m; the import left a 0.01 object scale me.update() me.name = short_name(ue_path) me["ue_mesh"] = ue_path me.use_fake_user = True if len(me.uv_layers): me.uv_layers[0].name = "UVMap" if len(me.uv_layers) > 1: me.uv_layers[1].name = "UV1" # M_Base_Trim reads its base texture through the second set for attr in list(me.color_attributes): # no Season 2 material reads vertex colours me.color_attributes.remove(attr) # FBX keeps Unreal's slot order, one material per slot n = len(me.materials) for i, s in enumerate(rec["slots"]): if i >= n: break me.materials[i] = self.material(s["material"]) if s["material"] else self.material(None) for o in new: bpy.data.objects.remove(o) for m in [m for m in bpy.data.materials if m not in mats_before and not m.get("ue_material") and m.users == 0]: bpy.data.materials.remove(m) self._mesh[ue_path] = me return me def all_mesh_paths(self, packs=PACKS): return sorted(p for p in self.meshes if p[len(GAME):].split("/")[0] in packs) # -- placing ------------------------------------------------------------------------------------------------ def place(self, ue_mesh, matrix, collection, name=None, overrides=None, props=None): """One instance of a kit mesh. `overrides`: {slot index: Unreal material path} for per-instance materials (Unreal's override materials); the mesh data stays shared.""" me = self.mesh(ue_mesh) if me is None: return None ob = bpy.data.objects.new(name or me.name, me) ob.matrix_world = matrix collection.objects.link(ob) ob["ue_mesh"] = ue_mesh if overrides: for idx, mpath in overrides.items(): idx = int(idx) if idx < len(ob.material_slots): ob.material_slots[idx].link = "OBJECT" ob.material_slots[idx].material = self.material(mpath) if props: for k, v in props.items(): ob[k] = v return ob # --------------------------------------------------------------------------------------------------------------- # material builder # --------------------------------------------------------------------------------------------------------------- class _MaterialBuilder(object): def __init__(self, kit, mat, ue_path, rec): self.kit, self.mat, self.path, self.rec = kit, mat, ue_path, rec or {} p = self.rec.get("params", {}) self.S, self.V, self.T, self.SW = p.get("scalars", {}), p.get("vectors", {}), p.get("textures", {}), p.get("switches", {}) self.base = (self.rec.get("base") or (ue_path if self.rec.get("type") == "Material" else "") or "") self.base_name = self.base.split("/")[-1] self.base_rec = kit.materials.get(self.base, self.rec) mat.use_nodes = True self.nt = mat.node_tree self.nodes, self.links = self.nt.nodes, self.nt.links self.nodes.clear() self.x = -900 # helpers def n(self, kind, x=None, y=0, **props): node = self.nodes.new(kind) node.location = (self.x if x is None else x, y) for k, v in props.items(): setattr(node, k, v) return node def link(self, a, b): self.links.new(a, b) def s(self, name, default=0.0): return float(self.S.get(name, default)) def v(self, name, default=(1.0, 1.0, 1.0)): c = self.V.get(name) return tuple(c[:3]) if c else tuple(default) def out(self, surface): o = self.n("ShaderNodeOutputMaterial", 400, 0) self.link(surface, o.inputs["Surface"]) def tex(self, param_or_path, y=0, uv_scale=None, box=None, colorspace=None, x=None): path = self.T.get(param_or_path, param_or_path if str(param_or_path).startswith("/") else None) img = self.kit.image(path, colorspace) if img is None: return None node = self.n("ShaderNodeTexImage", x, y) node.image = img if box: tc = self.n("ShaderNodeTexCoord", (x or self.x) - 420, y) mp = self.n("ShaderNodeMapping", (x or self.x) - 220, y) mp.inputs["Scale"].default_value = (box, box, box) self.link(tc.outputs["Object"], mp.inputs["Vector"]) self.link(mp.outputs["Vector"], node.inputs["Vector"]) node.projection = "BOX" node.projection_blend = 0.25 elif uv_scale and tuple(uv_scale) != (1.0, 1.0): tc = self.n("ShaderNodeTexCoord", (x or self.x) - 420, y) mp = self.n("ShaderNodeMapping", (x or self.x) - 220, y) mp.inputs["Scale"].default_value = (uv_scale[0], uv_scale[1], 1.0) self.link(tc.outputs["UV"], mp.inputs["Vector"]) self.link(mp.outputs["Vector"], node.inputs["Vector"]) return node def principled(self): b = self.n("ShaderNodeBsdfPrincipled", 60, 0) return b def mix_color(self, a, b, fac, y=0, x=None, blend="MIX"): """a, b: socket or rgb tuple; fac: socket or float.""" m = self.n("ShaderNodeMix", x, y, data_type="RGBA", blend_type=blend) m.clamp_factor = True for sock, val in ((m.inputs[6], a), (m.inputs[7], b)): if hasattr(val, "is_linked") or hasattr(val, "links"): self.link(val, sock) else: sock.default_value = (val[0], val[1], val[2], 1.0) if hasattr(fac, "links"): self.link(fac, m.inputs[0]) else: m.inputs[0].default_value = fac return m.outputs[2] def desaturate(self, color_socket, amount, y=0, x=None): if amount <= 0.001: return color_socket h = self.n("ShaderNodeHueSaturation", x, y) h.inputs["Saturation"].default_value = max(0.0, 1.0 - amount) self.link(color_socket, h.inputs["Color"]) return h.outputs["Color"] def normal(self, bsdf, param="Normal", y=-520, strength=1.0): t = self.tex(param, y=y, colorspace="Non-Color", x=-560) if t is None: return nm = self.n("ShaderNodeNormalMap", -220, y) nm.inputs["Strength"].default_value = strength self.link(t.outputs["Color"], nm.inputs["Color"]) self.link(nm.outputs["Normal"], bsdf.inputs["Normal"]) def rough_mask(self, bsdf, rough_socket_or_value, y=-300, uv_param=("R Detail U", "R Detail V"), box=None): """roughness * clamp(CheapContrast(mask(uv * detail), contrast))""" mask = self.tex("Roughness Blend Mask", y=y, x=-560, colorspace="Non-Color", uv_scale=None if box else (self.s(uv_param[0], 1.0), self.s(uv_param[1], 1.0)), box=box) mul = self.n("ShaderNodeMath", -220, y, operation="MULTIPLY") mul.use_clamp = True if hasattr(rough_socket_or_value, "links"): self.link(rough_socket_or_value, mul.inputs[0]) else: mul.inputs[0].default_value = rough_socket_or_value if mask is None: mul.inputs[1].default_value = 1.0 else: src = mask.outputs["Color"] c = self.s("Roughness Blend Contrast", 0.0) if abs(c) > 1e-4: mr = self.n("ShaderNodeMapRange", -390, y - 40) mr.inputs["To Min"].default_value = -c mr.inputs["To Max"].default_value = 1.0 + c self.link(src, mr.inputs["Value"]) src = mr.outputs["Result"] self.link(src, mul.inputs[1]) self.link(mul.outputs[0], bsdf.inputs["Roughness"]) def set_blend(self, blended=True, two_sided=None): m = self.mat if blended: m.surface_render_method = "BLENDED" try: m.use_transparent_shadow = True except Exception: # noqa: BLE001 pass if two_sided is None: two_sided = bool(self.base_rec.get("two_sided")) m.use_backface_culling = not two_sided # -- dispatch ----------------------------------------------------------------------------------------------- def build(self): b = self.base_name blend = str(self.base_rec.get("blend_mode", "")) domain = str(self.base_rec.get("material_domain", "")) self.mat.use_backface_culling = not bool(self.base_rec.get("two_sided")) if not self.rec: return self.build_flat((0.5, 0.5, 0.5), 0.0, 0.6) if self.path else self.build_flat((0.3, 0.3, 0.3), 0, 0.6) if "DEFERRED_DECAL" in domain: return self.build_decal() if b in ("M_Base_Props", "M_Base_Props_Masked"): return self.build_props(masked=(b == "M_Base_Props_Masked")) if b == "M_Base_Trim": return self.build_trim() if b == "M_Base_Pipes": return self.build_pipes() if b == "M_Base_RegUV1": return self.build_reg() if b == "M_Base_Solid": c = self.v("Color", (0.5, 0.5, 0.5)) return self.build_flat(c, self.s("Metallic", 0.0), max(0.04, self.s("Roughness", 0.5))) if b in ("M_Base_LightTrim", "M_SolidLight", "M_TextureEmissive", "M_Base_BasicUI"): return self.build_emissive(additive="ADDITIVE" in blend) if "TRANSLUCENT" in blend or "Glass" in b: return self.build_glass() return self.build_flat((0.5, 0.5, 0.5), 0.2, 0.5) # -- kinds -------------------------------------------------------------------------------------------------- def build_flat(self, color, metallic, roughness): bsdf = self.principled() bsdf.inputs["Base Color"].default_value = (color[0], color[1], color[2], 1.0) bsdf.inputs["Metallic"].default_value = metallic bsdf.inputs["Roughness"].default_value = roughness self.mat.diffuse_color = (color[0], color[1], color[2], 1.0) self.out(bsdf.outputs["BSDF"]) def _mask_scalar(self, mask_color, names, other, y, x=-220): """other + sum(mask_c * (value_c - other)); names: list of (mask channel, value).""" dot = self.n("ShaderNodeVectorMath", x, y, operation="DOT_PRODUCT") vec = {"R": 0.0, "G": 0.0, "B": 0.0} for ch, val in names: vec[ch] = val - other dot.inputs[1].default_value = (vec["R"], vec["G"], vec["B"]) self.link(mask_color, dot.inputs[0]) add = self.n("ShaderNodeMath", x + 170, y, operation="ADD") add.use_clamp = True self.link(dot.outputs["Value"], add.inputs[0]) add.inputs[1].default_value = other return add.outputs[0] def _rough_clamped(self, bsdf, rough, mask_param="Roughness Blend Mask", y=-460, uv=None, uvmap=None): """roughness * clamp(mask, Base Rough Min, Base Rough Max) (the pack's CheapContrast is 0 everywhere)""" mask = self.tex(mask_param, y=y, x=-560, colorspace="Non-Color", uv_scale=uv) mul = self.n("ShaderNodeMath", -40, y, operation="MULTIPLY") mul.use_clamp = True if hasattr(rough, "links"): self.link(rough, mul.inputs[0]) else: mul.inputs[0].default_value = rough if mask is None: mul.inputs[1].default_value = 0.5 * (self.s("Base Rough Min", 0.3) + self.s("Base Rough Max", 0.5)) else: if uvmap: self._use_uv1(mask, uv, y) cl = self.n("ShaderNodeClamp", -220, y) cl.inputs["Min"].default_value = self.s("Base Rough Min", 0.3) cl.inputs["Max"].default_value = self.s("Base Rough Max", 0.5) self.link(mask.outputs["Color"], cl.inputs["Value"]) self.link(cl.outputs[0], mul.inputs[1]) self.link(mul.outputs[0], bsdf.inputs["Roughness"]) def _use_uv1(self, tex_node, scale, y): """Feed an image node from the second UV set ('UV1'), tiled.""" for l in list(tex_node.inputs["Vector"].links): self.links.remove(l) uvn = self.n("ShaderNodeUVMap", tex_node.location.x - 420, y) uvn.uv_map = "UV1" mp = self.n("ShaderNodeMapping", tex_node.location.x - 220, y) s = scale or (1.0, 1.0) mp.inputs["Scale"].default_value = (s[0], s[1], 1.0) self.link(uvn.outputs["UV"], mp.inputs["Vector"]) self.link(mp.outputs["Vector"], tex_node.inputs["Vector"]) def _tinted(self, src, mask, other_param="Other Color", y=300): """src * colour picked by the mask's R, G, B channels (Red/Green/Blue Color), else Other Color.""" cols = [self.v("Red Color"), self.v("Green Color"), self.v("Blue Color")] other = self.v(other_param) if all(abs(c - 1.0) < 1e-3 for col in cols + [other] for c in col): return src, None if mask is None: return self.mix_color(src, other, 1.0, y=y, x=-220, blend="MULTIPLY"), None sep = self.n("ShaderNodeSeparateColor", -620, 0) self.link(mask.outputs["Color"], sep.inputs["Color"]) tint = other for i in range(3): tint = self.mix_color(tint, cols[i], sep.outputs[i], y=160 - 60 * i, x=-440 + 60 * i) return self.mix_color(src, tint, 1.0, y=y, x=-220, blend="MULTIPLY"), sep def build_props(self, masked=False): """M_Base_Props: desaturated diffuse * mask colour; metallic / roughness picked by the mask; emissive * boost.""" bsdf = self.principled() diff = self.tex("Diffuse", y=300, x=-900) mask = self.tex("Color Mask", y=0, x=-900, colorspace="Non-Color") if diff is not None: d = self.desaturate(diff.outputs["Color"], self.s("Diffuse Desaturation"), y=300, x=-620) d, _ = self._tinted(d, mask) self.link(d, bsdf.inputs["Base Color"]) ch = ("R", "G", "B") names = {"R": "Red", "G": "Green", "B": "Blue"} if mask is not None: met = self._mask_scalar(mask.outputs["Color"], [(c, self.s("Metallic %s" % names[c], 0.7)) for c in ch], self.s("Metallic Other", 0.7), y=-140) rgh = self._mask_scalar(mask.outputs["Color"], [(c, self.s("Roughness %s" % names[c], 0.5)) for c in ch], self.s("Roughness Other", 0.5), y=-300) self.link(met, bsdf.inputs["Metallic"]) else: bsdf.inputs["Metallic"].default_value = self.s("Metallic Other", 0.7) rgh = self.s("Roughness Other", 0.5) self._rough_clamped(bsdf, rgh, uv=(self.s("R Detail U", 1.0), self.s("R Detail V", 1.0))) bsdf.inputs["Specular IOR Level"].default_value = min(1.0, self.s("Specular Other", 0.5)) self.normal(bsdf, y=-760) boost = self.s("Emissive Boost", 0.0) if boost > 0.0: em = self.tex("Emissive", y=560, x=-900) if em is not None: src = self.desaturate(em.outputs["Color"], self.s("Emissive Desaturation"), y=560, x=-620) light = self.V.get("Emissive Light") if light and any(abs(c - 1.0) > 1e-3 for c in light[:3]): src = self.mix_color(src, tuple(light[:3]), 1.0, y=560, x=-440, blend="MULTIPLY") if self.SW.get("Masked Emissive"): emm = self.tex("Emissive Mask", y=800, x=-900, colorspace="Non-Color") if emm is not None: src = self.mix_color(src, (0.0, 0.0, 0.0), emm.outputs["Color"], y=560, x=-260) self.link(src, bsdf.inputs["Emission Color"]) bsdf.inputs["Emission Strength"].default_value = boost * EMISSIVE_SCALE if masked: om = self.tex("Opacity Mask", y=-1000, x=-560, colorspace="Non-Color") if om is not None: self.link(om.outputs["Color"], bsdf.inputs["Alpha"]) self.set_blend(False) try: self.mat.surface_render_method = "DITHERED" except Exception: # noqa: BLE001 pass self.out(bsdf.outputs["BSDF"]) def build_trim(self): """M_Base_Trim: trim sheet (UV0) * tiling base texture (second UV set) * mask colour.""" bsdf = self.principled() diff = self.tex("Diffuse", y=300, x=-900) mask = self.tex("Color Mask", y=0, x=-900, colorspace="Non-Color") base = self.tex("Base Diffuse", y=560, x=-900) src = diff.outputs["Color"] if diff else None if base is not None: self._use_uv1(base, (self.s("Base Tile U", 1.0), self.s("Base Tile V", 1.0)), 560) src = self.mix_color(src, base.outputs["Color"], 1.0, y=300, x=-640, blend="MULTIPLY") if src is not None else base.outputs["Color"] if src is not None: src = self.desaturate(src, self.s("Diffuse Desaturation"), y=300, x=-460) src, _ = self._tinted(src, mask) self.link(src, bsdf.inputs["Base Color"]) ch = ("R", "G", "B") names = {"R": "Red", "G": "Green", "B": "Blue"} if mask is not None: met = self._mask_scalar(mask.outputs["Color"], [(c, self.s("Metallic %s" % names[c], 0.7)) for c in ch], self.s("Metallic", 0.7), y=-140) rgh = self._mask_scalar(mask.outputs["Color"], [(c, self.s("Roughness %s" % names[c], 0.5)) for c in ch], self.s("Roughness", 0.5), y=-300) self.link(met, bsdf.inputs["Metallic"]) else: bsdf.inputs["Metallic"].default_value = self.s("Metallic", 0.7) rgh = self.s("Roughness", 0.5) self._rough_clamped(bsdf, rgh, uv=(self.s("R Detail U", 1.0), self.s("R Detail V", 1.0)), uvmap="UV1") bsdf.inputs["Specular IOR Level"].default_value = min(1.0, self.s("Specular", 0.5)) self.normal(bsdf, y=-760, strength=self.v("Normal Intensity")[0]) self.out(bsdf.outputs["BSDF"]) def build_pipes(self): """M_Base_Pipes: tiling diffuse * colour, with the pipe's label sheet laid over it where its mask says.""" bsdf = self.principled() diff = self.tex("Diffuse", y=300, x=-900, uv_scale=(self.s("Diffuse U", 1.0), self.s("Diffuse V", 1.0))) col = self.v("Diffuse Color") src = None if diff is not None: src = diff.outputs["Color"] if any(abs(c - 1.0) > 1e-3 for c in col): src = self.mix_color(src, col, 1.0, y=300, x=-560, blend="MULTIPLY") if self.SW.get("Param", True): dec = self.tex("Decal", y=40, x=-900) dm = self.tex("Decal Mask", y=-220, x=-900, colorspace="Non-Color") if dec is not None and dm is not None: a = src if src is not None else col src = self.mix_color(a, dec.outputs["Color"], dm.outputs["Color"], y=300, x=-380) if src is not None: self.link(src, bsdf.inputs["Base Color"]) else: bsdf.inputs["Base Color"].default_value = (col[0], col[1], col[2], 1.0) bsdf.inputs["Metallic"].default_value = self.s("Metallic", 0.3) self._rough_clamped(bsdf, self.s("Overall Roughness", 0.5), y=-520, uv=(self.s("R Detail U", 1.0), self.s("R Detail V", 1.0))) bsdf.inputs["Specular IOR Level"].default_value = min(1.0, self.s("Specular", 0.5)) t = self.tex("Normal", y=-800, colorspace="Non-Color", x=-560, uv_scale=(self.s("Norm U", 1.0), self.s("Norm V", 1.0))) if t is not None: nm = self.n("ShaderNodeNormalMap", -220, -800) nm.inputs["Strength"].default_value = self.v("Normal Intensity")[0] self.link(t.outputs["Color"], nm.inputs["Color"]) self.link(nm.outputs["Normal"], bsdf.inputs["Normal"]) self.out(bsdf.outputs["BSDF"]) def build_reg(self): """M_Base_RegUV1: diffuse * colour, its own roughness texture clamped to [min, max], one metal value.""" bsdf = self.principled() diff = self.tex("Diffuse", y=300, x=-900) col = self.v("Color") if diff is not None: src = diff.outputs["Color"] if any(abs(c - 1.0) > 1e-3 for c in col): src = self.mix_color(src, col, 1.0, y=300, x=-380, blend="MULTIPLY") self.link(src, bsdf.inputs["Base Color"]) else: bsdf.inputs["Base Color"].default_value = (col[0], col[1], col[2], 1.0) bsdf.inputs["Metallic"].default_value = self.s("Metal", 0.7) self._rough_clamped(bsdf, 1.0, mask_param="Roughness", y=-300, uv=(self.s("R Detail U", 1.0), self.s("R Detail V", 1.0))) bsdf.inputs["Specular IOR Level"].default_value = min(1.0, self.s("Specular", 0.4)) t = self.tex("Normal", y=-620, colorspace="Non-Color", x=-560, uv_scale=(self.s("Norm Detail U", 1.0), self.s("Norm Detail V", 1.0))) if t is not None: nm = self.n("ShaderNodeNormalMap", -220, -620) self.link(t.outputs["Color"], nm.inputs["Color"]) self.link(nm.outputs["Normal"], bsdf.inputs["Normal"]) self.out(bsdf.outputs["BSDF"]) def build_emissive(self, additive): """M_Base_LightTrim ((diffuse * colour) ^ power * intensity), M_SolidLight, M_TextureEmissive, M_Base_BasicUI.""" b = self.base_name tex_param = {"M_Base_LightTrim": "Diffuse", "M_TextureEmissive": "Emissive", "M_Base_BasicUI": "UI Diffuse"}.get(b) col = self.v("Light Color", self.v("Color", (1.0, 1.0, 1.0))) strength = self.s("Light Boost", self.s("Intensity", self.s("Brightness", 1.0))) node = self.tex(tex_param, y=300, x=-900) if tex_param else None src = None if node is not None: src = self.desaturate(node.outputs["Color"], self.s("Desaturation"), y=300, x=-620) src = self.mix_color(src, col, 1.0, y=300, x=-440, blend="MULTIPLY") p = self.s("Power", 1.0) if b == "M_Base_LightTrim" and abs(p - 1.0) > 1e-3: g = self.n("ShaderNodeGamma", -260, 300) g.inputs["Gamma"].default_value = p self.link(src, g.inputs["Color"]) src = g.outputs["Color"] self.mat.diffuse_color = (col[0], col[1], col[2], 1.0) self.mat["ue_emissive"] = True if additive: em = self.n("ShaderNodeEmission", 60, 0) if src is not None: self.link(src, em.inputs["Color"]) else: em.inputs["Color"].default_value = (col[0], col[1], col[2], 1.0) em.inputs["Strength"].default_value = max(1.0, strength) * EMISSIVE_SCALE tr = self.n("ShaderNodeBsdfTransparent", 60, -160) add = self.n("ShaderNodeAddShader", 240, 0) self.link(em.outputs[0], add.inputs[0]) self.link(tr.outputs[0], add.inputs[1]) self.out(add.outputs[0]) self.set_blend(True, two_sided=True) return bsdf = self.principled() if src is not None: self.link(src, bsdf.inputs["Base Color"]) self.link(src, bsdf.inputs["Emission Color"]) else: bsdf.inputs["Base Color"].default_value = (col[0], col[1], col[2], 1.0) bsdf.inputs["Emission Color"].default_value = (col[0], col[1], col[2], 1.0) bsdf.inputs["Emission Strength"].default_value = strength * EMISSIVE_SCALE bsdf.inputs["Metallic"].default_value = 0.0 bsdf.inputs["Roughness"].default_value = 0.4 self.out(bsdf.outputs["BSDF"]) def build_glass(self): bsdf = self.principled() col = self.v("Glass Color", self.v("Color", (0.0, 0.0, 0.0))) op = self.s("Opacity", self.s("Opacity B", 0.3)) tint = tuple(0.02 + 0.6 * c for c in col) bsdf.inputs["Base Color"].default_value = (tint[0], tint[1], tint[2], 1.0) bsdf.inputs["Roughness"].default_value = max(0.03, self.s("Roughness", 0.0)) bsdf.inputs["Metallic"].default_value = 0.0 bsdf.inputs["Specular IOR Level"].default_value = 1.0 coloured = self.base_name in ("M_AlarmLight_Glass", "M_FireExtinguisher_Glass") bsdf.inputs["Alpha"].default_value = min(0.75, max(0.12, op)) if coloured else 0.22 if coloured: bsdf.inputs["Emission Color"].default_value = (col[0], col[1], col[2], 1.0) bsdf.inputs["Emission Strength"].default_value = 0.3 * self.s("EmissiveBoost", 1.0) * EMISSIVE_SCALE self.mat.diffuse_color = (col[0], col[1], col[2], 0.5) else: self.mat.diffuse_color = (0.55, 0.7, 0.8, 0.25) self.out(bsdf.outputs["BSDF"]) self.set_blend(True, two_sided=True) def build_decal(self): """Deferred decals, shown here on a plane: diffuse tinted by the mask's R/G/B colours, alpha from the opacity texture's colour (white = opaque).""" bsdf = self.principled() diff = self.tex("Diffuse", y=300, x=-900) mask = self.tex("Color Mask", y=0, x=-900, colorspace="Non-Color") opac = self.tex("Opacity Mask", y=-300, x=-900, colorspace="Non-Color") if diff is not None: d = self.desaturate(diff.outputs["Color"], self.s("Desaturation"), y=300, x=-620) cols = [self.v("Red"), self.v("Green"), self.v("Blue")] if mask is not None and any(abs(c - 1.0) > 1e-3 for col in cols for c in col): sep = self.n("ShaderNodeSeparateColor", -620, 0) self.link(mask.outputs["Color"], sep.inputs["Color"]) t = (1.0, 1.0, 1.0) for i in range(3): t = self.mix_color(t, cols[i], sep.outputs[i], y=160 - 60 * i, x=-440 + 60 * i) d = self.mix_color(d, t, 1.0, y=300, x=-220, blend="MULTIPLY") self.link(d, bsdf.inputs["Base Color"]) if opac is not None: self.link(opac.outputs["Color"], bsdf.inputs["Alpha"]) bsdf.inputs["Metallic"].default_value = self.s("Metallic", 0.5) bsdf.inputs["Roughness"].default_value = self.s("Roughness", 0.45) self.out(bsdf.outputs["BSDF"]) self.set_blend(True, two_sided=False) self.mat["ue_decal"] = True # --------------------------------------------------------------------------------------------------------------- # scene helpers shared by the build scripts # --------------------------------------------------------------------------------------------------------------- def get_collection(name, parent=None): col = bpy.data.collections.get(name) if col is None: col = bpy.data.collections.new(name) (parent or bpy.context.scene.collection).children.link(col) return col def exclude_collection(name, exclude=True): def walk(lc): if lc.collection.name == name: lc.exclude = exclude return True return any(walk(c) for c in lc.children) walk(bpy.context.view_layer.layer_collection)