""" Blender side of the Modular SciFi Season 1 kit (import with `import bl_kit` from a script run inside Blender). 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/StarterBundle/ModularSci_Int/Meshes/SM_Wall_A') # bpy mesh, metres, Blender axes mat = kit.material('/Game/StarterBundle/ModularSci_Int/Materials/MI_Wall_A') 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: _: HW hallways, PR props, CC command centre, IN interiors, MT materials pack. The materials are rebuilt from the pack's parameters (tint masks, trims, lights, glass, decals). They are a close preview, not the Unreal shaders: no detail normals, the second (lightmap) UV set does not exist in the source meshes so the two textures that use it are box-projected, animated screens are still. """ import json import math import os import re 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/StarterBundle/" PACK_SHORT = { "ModularScifiHallways": "HW", "ModularScifiProps": "PR", "ModularSci_Comm": "CC", "ModularSci_Int": "IN", "ModularSciFiMats": "MT", "NatureMaterials_Ice": "ICE", "UE4_Assets": "UE4", "SciFI_Lite_Assets": "LITE", } # Further packs, exported the same way into their own folder beside kit_export: # build\ue_export_kit.ps1 -Root /Game/SciFI_Lite_Assets -Out models\anchorage\kit_export_lite # python build\make_proxies.py 1024 --src kit_export_lite --prefix SciFI_Lite_Assets --json proxies_lite.json EXTRA_KITS = ("kit_export_lite",) # 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 slot_order(fbx_names, slots): """For each material of an imported FBX, in the file's order, the index of the Unreal slot it belongs to. Unreal writes the materials by their asset names, and a material that sits in two slots a second time as _.""" base = [(s.get("material") or "").rsplit("/", 1)[-1] for s in slots] names = [re.sub(r"\.\d{3}$", "", n) for n in fbx_names] # Blender's own suffix for a taken name order = [None] * len(names) free = set(range(len(base))) for i, n in enumerate(names): # same place, same name: the usual case if i < len(base) and (n == base[i] or re.sub(r"_\d+$", "", n) == base[i]): order[i] = i free.discard(i) for i, n in enumerate(names): # same name in another place if order[i] is None: hit = [j for j in sorted(free) if n == base[j]] or \ [j for j in sorted(free) if re.sub(r"_\d+$", "", n) == base[j]] if hit: order[i] = hit[0] free.discard(hit[0]) for i in range(len(names)): # unknown names: by position, as before if order[i] is None: order[i] = i if (i in free or not free) else min(free) free.discard(order[i]) return order def short_name(ue_path): if ue_path.startswith(GAME): rest = ue_path[len(GAME):] elif ue_path.startswith("/Game/"): rest = ue_path[len("/Game/"):] # a pack that lies beside the bundle, not in it else: rest = 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) for extra in EXTRA_KITS: d = os.path.join(ROOT, extra) if not os.path.isfile(os.path.join(d, "meshes.json")): continue with open(os.path.join(d, "meshes.json"), "r", encoding="utf-8") as f: for p, rec in json.load(f).items(): rec["_dir"] = d # its FBX lies in that folder, not in kit_export self.meshes[p] = rec with open(os.path.join(d, "materials.json"), "r", encoding="utf-8") as f: self.materials.update(json.load(f)["materials"]) for name in sorted(os.listdir(TEX_DIR)): if name.startswith("proxies_") and name.endswith(".json"): with open(os.path.join(TEX_DIR, name), "r", encoding="utf-8") as f: self.proxies.update(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(rec.get("_dir", 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" # the pack's vertex colours are plain white except where a material reads them needs_vcol = any((self.materials.get(s["material"]) or {}).get("base", "").endswith("M_Glass_Base") for s in rec["slots"]) if not needs_vcol: for attr in list(me.color_attributes): me.color_attributes.remove(attr) # The FBX lists its materials in the order of the mesh's sections. That is nearly always the order of # Unreal's material slots, but not always: SM_Satelite_01 of the Sci-Fi Lite pack has two of them the other # way round, and taking them by position put its decal material on the whole body. So each FBX material is # matched to its slot by name and the faces are renumbered: material slot i here is slot i in Unreal # (per-instance overrides and the layout count on that). slots = rec["slots"] order = slot_order([m.name if m else "" for m in me.materials], slots) if order != list(range(len(order))): idx = [0] * len(me.polygons) me.polygons.foreach_get("material_index", idx) me.polygons.foreach_set("material_index", [order[i] if i < len(order) else i for i in idx]) self.log("mesh %s: the FBX's materials are not in slot order (%s), matched by name" % (ue_path, order)) while len(me.materials) < len(slots): me.materials.append(None) for i, s in enumerate(slots): 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=("ModularScifiHallways", "ModularScifiProps", "ModularSci_Comm", "ModularSci_Int")): 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 b == "MM_DecalsAtlas_Subset": # the Sci-Fi Lite pack's detail: a decal material too, but on thin geometry of the mesh itself return self.build_lite_decals() if "DEFERRED_DECAL" in domain: return self.build_decal() if b in ("M_Base_2C", "M_Base_3C"): return self.build_mask_tint(("R", "G", "B") if b == "M_Base_3C" else ("R", "G")) if b == "M_Base_4C_Scr": return self.build_4c() if b == "M_Base_Tile_Scr": return self.build_tile() if b == "M_Base_Trim": return self.build_trim() if b == "M_SolidColor_Base" or b == "M_SolidC": return self.build_solid() if b == "M_Base_Pipes": return self.build_pipes() if b in ("M_Base_Wires", "M_Base_Wires_B"): return self.build_wires() if b in ("M_Lights", "M_Base_SolidLight", "M_Light_Base", "M_Lights_Base", "M_SolidLight_Parent", "M_EnergyEmissive"): return self.build_light(additive="ADDITIVE" in blend) if b in ("M_Screen_Parent", "M_UIScreen_Parent", "M_UI_Parent"): return self.build_screen(additive="ADDITIVE" in blend) if b == "M_IceBase": return self.build_ice() if b in ("M_Cave_SimpleSnow", "M_BigSnow"): return self.build_snow() if b == "M_IceChunks_01": return self.build_ice_chunks() # the Sci-Fi Lite pack (/Game/SciFI_Lite_Assets) if b == "MM_TriplanarMapping": return self.build_lite_triplanar() if b == "MM_Emissive": return self.build_lite_emissive() if b in ("MM_BasicColor", "MM_Screen"): c = self.v("Color", (0.02, 0.02, 0.02)) return self.build_flat(c, 0.0, max(0.05, min(1.0, self.s("Roughness", 0.5)))) if "TRANSLUCENT" in blend or "Glass" in b or self.path.endswith("WorldGridMaterial"): return self.build_glass() if b == "M_CustomerMessage": return self.build_flat((0.8, 0.8, 0.8), 0.0, 0.8) 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_sep, 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_sep, 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 build_mask_tint(self, channels): """M_Base_2C / M_Base_3C: diffuse * colour picked by the mask's R, G (, B) channels, else 'Other Color'; metallic / roughness / specular picked the same way; emissive texture * 'Light Intensity'.""" bsdf = self.principled() diff = self.tex("Diffuse", y=300, x=-900) mask = self.tex("Mask", y=0, x=-900, colorspace="Non-Color") names = {"R": "Red", "G": "Green", "B": "Blue"} if diff is not None: d = self.desaturate(diff.outputs["Color"], self.s("Diffuse Desaturation"), y=300, x=-620) else: d = None tint_cols = [self.v("%s Color" % c) for c in channels] other = self.v("Other Color") plain = all(abs(c - 1.0) < 1e-3 for col in tint_cols + [other] for c in col) if mask is not None: sep = self.n("ShaderNodeSeparateColor", -620, 0) self.link(mask.outputs["Color"], sep.inputs["Color"]) tint = other if not plain: for i, c in enumerate(channels): tint = self.mix_color(tint, tint_cols[i], sep.outputs[{"R": 0, "G": 1, "B": 2}[c]], y=160 - 60 * i, x=-440 + 60 * i) met = self._mask_scalar(mask.outputs["Color"], [(c, self.s("Metallic %s" % names[c], 0.5)) for c in channels], self.s("Metallic Other", 0.5), y=-140) rgh = self._mask_scalar(mask.outputs["Color"], [(c, self.s("Roughness %s" % names[c], 0.5)) for c in channels], self.s("Roughness Other", 0.5), y=-300) self.link(met, bsdf.inputs["Metallic"]) self.rough_mask(bsdf, rgh, y=-460) else: tint = other bsdf.inputs["Metallic"].default_value = self.s("Metallic Other", 0.5) self.rough_mask(bsdf, self.s("Roughness Other", 0.5), y=-460) if d is not None: if plain: self.link(d, bsdf.inputs["Base Color"]) else: self.link(self.mix_color(d, tint, 1.0, y=300, x=-220, blend="MULTIPLY"), bsdf.inputs["Base Color"]) elif not hasattr(tint, "links"): bsdf.inputs["Base Color"].default_value = (tint[0], tint[1], tint[2], 1.0) bsdf.inputs["Specular IOR Level"].default_value = min(1.0, self.s("Spec Other", 0.5)) self.normal(bsdf, y=-760) li = self.s("Light Intensity", 0.0) if li > 0.0: em = self.tex("Emissive", y=560, x=-560) if em is not None: self.link(em.outputs["Color"], bsdf.inputs["Emission Color"]) bsdf.inputs["Emission Strength"].default_value = li * EMISSIVE_SCALE self.out(bsdf.outputs["BSDF"]) def build_4c(self): """M_Base_4C_Scr: Detail colour where mask.B is 0, Other where it is 1, then R and G colours on top.""" bsdf = self.principled() diff = self.tex("Diffuse", y=300, x=-900) mask = self.tex("Mask", y=0, x=-900, colorspace="Non-Color") d = self.desaturate(diff.outputs["Color"], self.s("Diffuse Desaturation"), y=300, x=-620) if diff else None if mask is not None: sep = self.n("ShaderNodeSeparateColor", -620, 0) self.link(mask.outputs["Color"], sep.inputs["Color"]) t = self.mix_color(self.v("Detail Color"), self.v("Other Color"), sep.outputs[2], y=180, x=-440) t = self.mix_color(t, self.v("R Color"), sep.outputs[0], y=120, x=-380) t = self.mix_color(t, self.v("G Color"), sep.outputs[1], y=60, x=-320) if d is not None: self.link(self.mix_color(d, t, 1.0, y=300, x=-220, blend="MULTIPLY"), bsdf.inputs["Base Color"]) met = self._mask_scalar(mask.outputs["Color"], [("R", self.s("Metallic Red", 0.75)), ("G", self.s("Metallic Green", 0.75))], self.s("Metallic Other", 0.5), y=-140) rgh = self._mask_scalar(mask.outputs["Color"], [("R", self.s("Roughness Red", 0.1)), ("G", self.s("Roughness Green", 0.1))], self.s("Roughness Other", 0.5), y=-300) self.link(met, bsdf.inputs["Metallic"]) self.rough_mask(bsdf, rgh, y=-460) elif d is not None: self.link(d, bsdf.inputs["Base Color"]) self.normal(bsdf, y=-760) self.out(bsdf.outputs["BSDF"]) def build_tile(self): """M_Base_Tile_Scr: like 4C with one mask colour (R or G by a switch).""" bsdf = self.principled() diff = self.tex("Diffuse", y=300, x=-900) mask = self.tex("Mask", y=0, x=-900, colorspace="Non-Color") d = self.desaturate(diff.outputs["Color"], self.s("Diffuse Desaturation"), y=300, x=-620) if diff else None ch = 0 if self.SW.get("Red/Green Color") else 1 if mask is not None: sep = self.n("ShaderNodeSeparateColor", -620, 0) self.link(mask.outputs["Color"], sep.inputs["Color"]) t = self.mix_color(self.v("Detail Color"), self.v("Other Color"), sep.outputs[2], y=180, x=-440) t = self.mix_color(t, self.v("Mask Color"), sep.outputs[ch], y=120, x=-380) if d is not None: self.link(self.mix_color(d, t, 1.0, y=300, x=-220, blend="MULTIPLY"), bsdf.inputs["Base Color"]) c = "R" if self.SW.get("Red/Green Metal") else "G" met = self._mask_scalar(mask.outputs["Color"], [(c, self.s("Metallic Mask", 0.75))], self.s("Metallic Other", 0.5), y=-140) c = "R" if self.SW.get("Red/Green Roughness") else "G" rgh = self._mask_scalar(mask.outputs["Color"], [(c, self.s("Roughness Mask", 0.1))], self.s("Roughness Other", 0.5), y=-300) self.link(met, bsdf.inputs["Metallic"]) self.rough_mask(bsdf, rgh, y=-460) elif d is not None: self.link(d, bsdf.inputs["Base Color"]) self.normal(bsdf, y=-760) self.out(bsdf.outputs["BSDF"]) def build_trim(self): """M_Base_Trim: diffuse(UV0) * base texture(lightmap UV, box-projected here) * Color.""" bsdf = self.principled() diff = self.tex("Diffuse", y=300, x=-900) col = self.v("Color") base = self.tex("Base Diffuse", y=40, x=-900, box=0.25) src = diff.outputs["Color"] if diff else None if src is not None and base is not None: src = self.mix_color(src, base.outputs["Color"], 1.0, y=300, x=-560, blend="MULTIPLY") if src is not None: 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("Metallic", 0.7) self.rough_mask(bsdf, self.s("Roughness", 0.5), y=-300, box=0.5) self.normal(bsdf, y=-620, strength=self.v("Normal Intensity")[0]) self.out(bsdf.outputs["BSDF"]) def build_solid(self): """M_SolidColor_Base: one colour; the command-centre version multiplies a dirt texture in ('Param').""" bsdf = self.principled() col = self.v("Diffuse Color", self.v("Color", (0.76, 0.76, 0.76))) self.mat.diffuse_color = (col[0], col[1], col[2], 1.0) dirt = self.tex("BaseTexture", y=300, x=-900, box=0.25) if self.SW.get("Param") else None if dirt is not None: src = dirt.outputs["Color"] p = self.s("Power", 1.0) if abs(p - 1.0) > 1e-3: g = self.n("ShaderNodeGamma", -620, 300) g.inputs["Gamma"].default_value = p self.link(src, g.inputs["Color"]) src = g.outputs["Color"] self.link(self.mix_color(src, col, 1.0, y=300, x=-380, blend="MULTIPLY"), 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.5) bsdf.inputs["Specular IOR Level"].default_value = min(1.0, self.s("Spec Other", self.s("Specular", 0.5))) if self.T.get("Roughness Blend Mask"): self.rough_mask(bsdf, self.s("Roughness", 0.5), y=-300, box=0.5) else: bsdf.inputs["Roughness"].default_value = self.s("Roughness", 0.5) self.out(bsdf.outputs["BSDF"]) def build_pipes(self): bsdf = self.principled() diff = self.tex("Diffuse", y=300, x=-900) col = self.v("Main 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("Metallic", 0.5) self.rough_mask(bsdf, self.s("Roughness", 0.5), y=-300) self.normal(bsdf, y=-620, strength=self.v("Normal Intensity")[0]) self.out(bsdf.outputs["BSDF"]) def build_wires(self): bsdf = self.principled() diff = self.tex("Diffuse", y=300, x=-900) col = self.v("Wire Color") if diff is not None: src = self.desaturate(diff.outputs["Color"], self.s("Desaturation Value"), y=300, x=-620) src = self.mix_color(src, col, 1.0, y=300, x=-440, blend="MULTIPLY") p = self.s("Wire Brightness", 1.0) if 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.link(src, bsdf.inputs["Base Color"]) bsdf.inputs["Metallic"].default_value = self.s("Metallic ", self.s("Metallic", 0.5)) bsdf.inputs["Roughness"].default_value = self.s("Roughness", 0.5) self.normal(bsdf, param="Norm", y=-520) self.out(bsdf.outputs["BSDF"]) def _emission_color(self): """(colour socket or None, colour tuple, strength) for the light materials.""" b = self.base_name col = self.v("Light Color", self.v("Color", (1.0, 1.0, 1.0))) strength = self.s("Brightness", self.s("Intensity", self.s("Light Brightness", 1.0))) tex_param = {"M_Lights": "Diffuse", "M_Lights_Base": "Diffuse"}.get(b) fixed = {"M_Light_Base": "T_Lights_Square", "M_SolidLight_Parent": "T_LampEmissive", "M_EnergyEmissive": "T_EnergyCore_EM"}.get(b) node = None if tex_param: node = self.tex(tex_param, y=300, x=-900) elif fixed: hit = [t for t in self.base_rec.get("used_textures", []) if t.endswith("/" + fixed)] if hit: uv = (self.s("Emissive Detail U", 1.0), self.s("Emissive Detail V", 1.0)) if b == "M_SolidLight_Parent" else None node = self.tex(hit[0], y=300, x=-900, uv_scale=uv) return node, col, strength def build_light(self, additive): node, col, strength = self._emission_color() 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=-380, blend="MULTIPLY") else: src = None self.mat.diffuse_color = (col[0], col[1], col[2], 1.0) if additive or self.base_name == "M_EnergyEmissive": 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 = strength * EMISSIVE_SCALE if additive: 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) else: self.out(em.outputs[0]) 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 = self.s("Metallic", 0.0) bsdf.inputs["Roughness"].default_value = max(0.05, self.s("Roughness", 0.5)) self.out(bsdf.outputs["BSDF"]) def build_screen(self, additive): b = self.base_name tex = self.tex("Emissive" if b == "M_Screen_Parent" else "UI Texture", y=300, x=-900) strength = self.s("Brightness", self.s("Overall Brightness", 1.0)) col = self.v("Overall Color", (1.0, 1.0, 1.0)) if b == "M_Screen_Parent": bsdf = self.principled() sb = self.v("ScreenBase", (0.0, 0.0, 0.0)) bsdf.inputs["Base Color"].default_value = (sb[0], sb[1], sb[2], 1.0) bsdf.inputs["Roughness"].default_value = max(0.05, self.s("Roughness", 0.1)) bsdf.inputs["Metallic"].default_value = self.s("Metallic", 0.0) if tex is not None: self.link(tex.outputs["Color"], bsdf.inputs["Emission Color"]) bsdf.inputs["Emission Strength"].default_value = strength * EMISSIVE_SCALE self.out(bsdf.outputs["BSDF"]) return em = self.n("ShaderNodeEmission", 60, 0) if tex is not None: src = self.desaturate(tex.outputs["Color"], self.s("Desaturation", self.s("Desat Value")), y=300, x=-620) 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, em.inputs["Color"]) em.inputs["Strength"].default_value = max(1.0, strength) * EMISSIVE_SCALE if additive: 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) else: self.out(em.outputs[0]) def build_glass(self): bsdf = self.principled() col = self.v("ColorGlass", self.v("Glass Color", (0.0, 0.0, 0.0))) op = self.s("Opacity", self.s("Opacity A", 0.05)) # the pack's glass is black-tinted and almost fully clear; keep a little body so it reads as a pane 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 bsdf.inputs["Alpha"].default_value = min(0.6, max(0.10, op * 2.5)) if self.base_name == "M_GlassLight": lc = self.v("LightColor", (1.0, 1.0, 1.0)) bsdf.inputs["Emission Color"].default_value = (lc[0], lc[1], lc[2], 1.0) bsdf.inputs["Emission Strength"].default_value = 0.15 * self.s("Glass Light Brightness", 1.0) * EMISSIVE_SCALE self.mat.diffuse_color = (0.55, 0.7, 0.8, 0.25) self.out(bsdf.outputs["BSDF"]) self.set_blend(True, two_sided=True) # -- outdoors: the ice the pack's showcase stands on -------------------------------------------------------- ICE_TEX = GAME + "NatureMaterials_Ice/Textures/" def build_ice(self): """M_IceBase, the pack's clear ice: a cracked ice texture, desaturated and tinted, over a deeper layer of white cracks that Unreal shifts with the view (bump offset) and shows through the surface. Here the two layers are added up into one OPAQUE surface; in Unreal the material is translucent.""" bsdf = self.principled() tile = self.s("Ice Tile", 8.0) base = self.tex("Base Texture", y=320, x=-900, uv_scale=(tile, tile)) deep = self.tex("Depth Texture", y=20, x=-900, uv_scale=(tile * 0.5, tile * 0.5), colorspace="Non-Color") col = self.v("Base Color") bright = self.s("Base Brightness", 1.0) src = None if base is not None and bright > 0.0: src = self.desaturate(base.outputs["Color"], self.s("Base Desaturation", 0.0), y=320, x=-620) src = self.mix_color(src, tuple(c * bright for c in col), 1.0, y=320, x=-440, blend="MULTIPLY") if deep is not None: dc = self.v("Depth Color", (0.01, 0.07, 0.12)) k = min(12.0, self.s("Depth Brightness", 1.0)) * 0.6 cracks = self.mix_color(deep.outputs["Color"], tuple(c * k for c in dc), 1.0, y=20, x=-620, blend="MULTIPLY") src = cracks if src is None else self.mix_color(src, cracks, 1.0, y=180, x=-260, blend="ADD") # Preview only: the same crack texture once more, nine times finer and faint. On a floe the pack's own # tiling makes one repeat 63 m wide, which is right from the air and a blur at one's feet; Unreal's # shader has its own near detail (normal mask, bump offset) that this preview does not. fine = self.tex("Depth Texture", y=-220, x=-900, uv_scale=(tile * 4.5, tile * 4.5), colorspace="Non-Color") if fine is not None: f2 = self.mix_color(fine.outputs["Color"], tuple(c * k * 0.45 for c in dc), 1.0, y=-220, x=-620, blend="MULTIPLY") src = self.mix_color(src, f2, 1.0, y=60, x=-120, blend="ADD") if src is not None: self.link(src, bsdf.inputs["Base Color"]) bsdf.inputs["Metallic"].default_value = self.s("Metallic Overall", 0.0) bsdf.inputs["Roughness"].default_value = max(0.10, self.s("Roughness Overall", 0.18)) bsdf.inputs["Specular IOR Level"].default_value = 0.6 nt = self.s("Ice Normal Tile", 8.0) t = self.tex("Base Normal", y=-460, x=-900, uv_scale=(nt, nt), colorspace="Non-Color") if t is not None: nm = self.n("ShaderNodeNormalMap", -220, -460) nm.inputs["Strength"].default_value = 0.6 self.link(t.outputs["Color"], nm.inputs["Color"]) self.link(nm.outputs["Normal"], bsdf.inputs["Normal"]) self.mat.diffuse_color = (0.50, 0.66, 0.76, 1.0) self.mat["preview_note"] = "opaque in this preview; translucent in Unreal" self.out(bsdf.outputs["BSDF"]) def build_snow(self): """M_Cave_SimpleSnow / M_BigSnow (UE sample content the pack uses outdoors): a snow layer under a dark blue tint, with a pale sheen at grazing angles. Their own textures are not part of the kit export; the ice pack's snow-mound normal map stands in, box-projected, so this is a likeness and not the Unreal shader.""" bsdf = self.principled() lw = self.n("ShaderNodeLayerWeight", -620, 320) lw.inputs["Blend"].default_value = 0.32 col = self.mix_color((0.085, 0.120, 0.175), (0.52, 0.64, 0.76), lw.outputs["Facing"], y=320, x=-380) self.link(col, bsdf.inputs["Base Color"]) bsdf.inputs["Metallic"].default_value = 0.0 bsdf.inputs["Roughness"].default_value = 0.48 bsdf.inputs["Specular IOR Level"].default_value = 0.45 t = self.tex(self.ICE_TEX + "T_SnowMound_01_N", y=-300, x=-560, box=0.12, colorspace="Non-Color") if t is not None: nm = self.n("ShaderNodeNormalMap", -220, -300) nm.inputs["Strength"].default_value = 1.0 self.link(t.outputs["Color"], nm.inputs["Color"]) self.link(nm.outputs["Normal"], bsdf.inputs["Normal"]) self.mat.diffuse_color = (0.20, 0.27, 0.36, 1.0) self.mat["preview_note"] = "a likeness: the Unreal material's own textures are not in the kit export" self.out(bsdf.outputs["BSDF"]) def build_ice_chunks(self): """M_IceChunks_01 (UE sample content): loose pale ice with light going through it. A likeness, as above.""" bsdf = self.principled() bsdf.inputs["Base Color"].default_value = (0.50, 0.68, 0.80, 1.0) bsdf.inputs["Roughness"].default_value = 0.30 bsdf.inputs["Metallic"].default_value = 0.0 for name, val in (("Subsurface Weight", 0.5), ("Subsurface Scale", 0.25)): if name in bsdf.inputs: bsdf.inputs[name].default_value = val if "Subsurface Radius" in bsdf.inputs: bsdf.inputs["Subsurface Radius"].default_value = (0.5, 0.8, 1.0) t = self.tex(self.ICE_TEX + "T_Ice_B_Norm", y=-300, x=-560, box=0.6, colorspace="Non-Color") if t is not None: nm = self.n("ShaderNodeNormalMap", -220, -300) nm.inputs["Strength"].default_value = 0.8 self.link(t.outputs["Color"], nm.inputs["Color"]) self.link(nm.outputs["Normal"], bsdf.inputs["Normal"]) self.mat.diffuse_color = (0.50, 0.68, 0.80, 1.0) self.mat["preview_note"] = "a likeness: the Unreal material's own textures are not in the kit export" self.out(bsdf.outputs["BSDF"]) # -- the Sci-Fi Lite pack ---------------------------------------------------------------------------------- def build_lite_triplanar(self): """MM_TriplanarMapping: the pack's painted metal, rubber and plastic. Its textures are laid on from three sides in space (no UVs are used), `Base Material Size` centimetres to one repeat; here that is Blender's box projection. Colour = albedo texture x `Main Material Color`; roughness = (texture x brightness) to the power of the contrast, as the material computes it; dirt is mixed in by the pack's grunge mask where the instance switches it on (the Unreal material also puts it on by height; this preview does not). The normal map is left out: a tangent-space map cannot be box-projected properly; the roughness map gives a slight bump instead.""" bsdf = self.principled() size = max(1.0, self.s("Base Material Size", 100.0)) box = 100.0 / size col = self.v("Main Material Color", (0.5, 0.5, 0.5)) alb = self.tex("Albedo", y=320, x=-900, box=box) src = None if alb is not None: src = self.mix_color(alb.outputs["Color"], tuple(c * self.s("Albedo Brighness", 1.0) for c in col), 1.0, y=320, x=-560, blend="MULTIPLY") if src is not None and self.SW.get("Dirt?"): dirt = self.tex("Dirt", y=80, x=-900, box=100.0 / max(1.0, self.s("Dirt Material Size", 300.0)), colorspace="Non-Color") if dirt is not None: mr = self.n("ShaderNodeMapRange", -560, 80) mr.inputs["From Min"].default_value = self.s("Dirt Min", 0.0) mr.inputs["From Max"].default_value = max(self.s("Dirt Min", 0.0) + 0.05, self.s("Dirt Max", 1.0)) mr.inputs["To Min"].default_value = 0.0 mr.inputs["To Max"].default_value = 0.35 self.link(dirt.outputs["Color"], mr.inputs["Value"]) dc = self.v("Dirt Color", (0.05, 0.04, 0.03)) src = self.mix_color(src, dc, mr.outputs["Result"], y=200, x=-320) 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.0) rgh = self.tex("Roughness", y=-260, x=-900, box=box, colorspace="Non-Color") if rgh is not None: mul = self.n("ShaderNodeMath", -560, -260, operation="MULTIPLY") mul.inputs[1].default_value = self.s("Roughness Brighness", 1.0) self.link(rgh.outputs["Color"], mul.inputs[0]) pw = self.n("ShaderNodeMath", -380, -260, operation="POWER") pw.inputs[1].default_value = self.s("Roughness Contrast", 1.0) pw.use_clamp = True self.link(mul.outputs[0], pw.inputs[0]) self.link(pw.outputs[0], bsdf.inputs["Roughness"]) bump = self.n("ShaderNodeBump", -200, -460) bump.inputs["Strength"].default_value = 0.12 bump.inputs["Distance"].default_value = 0.02 self.link(rgh.outputs["Color"], bump.inputs["Height"]) self.link(bump.outputs["Normal"], bsdf.inputs["Normal"]) else: bsdf.inputs["Roughness"].default_value = 0.5 self.mat.diffuse_color = (col[0], col[1], col[2], 1.0) self.out(bsdf.outputs["BSDF"]) def build_lite_decals(self): """MM_DecalsAtlas_Subset: the pack's small detail (hatch outlines, bolts, vents, small lights) is thin geometry lying on the meshes, textured from an atlas (made with DECALmachine) and blended onto what is underneath. As the Unreal graph computes it: it shows where the blue channel of the atlas' masks says so (the graph adds a term from the occlusion map, which the pack's instances switch off); inside the green channel, the "subset", it has the instance's own colour texture and is rough, outside it is black and smooth; the occlusion map darkens it. On top the atlas' normal map and lights. In Unreal it also shifts with the view (parallax) and its lights may blink.""" bsdf = self.principled() ao = self.tex("AO", y=320, x=-900, colorspace="Non-Color") masks = self.tex("Group Masks", y=40, x=-900, colorspace="Non-Color") if masks is not None: sep = self.n("ShaderNodeSeparateColor", -620, 40) self.link(masks.outputs["Color"], sep.inputs["Color"]) own = self.tex("Color", y=600, x=-900) col = self.mix_color((0.0, 0.0, 0.0), own.outputs["Color"] if own is not None else (0.8, 0.8, 0.8), sep.outputs["Green"], y=480, x=-380) if ao is not None: col = self.mix_color(col, ao.outputs["Color"], 1.0, y=320, x=-200, blend="DARKEN") self.link(col, bsdf.inputs["Base Color"]) self.link(sep.outputs["Blue"], bsdf.inputs["Alpha"]) rgh = self.n("ShaderNodeMapRange", -380, 140) rgh.inputs["To Min"].default_value = 0.12 # the graph gives 0 outside the subset: a mirror rgh.inputs["To Max"].default_value = min(1.0, self.s("Roughness", 1.0)) self.link(sep.outputs["Green"], rgh.inputs["Value"]) self.link(rgh.outputs["Result"], bsdf.inputs["Roughness"]) elif ao is not None: self.link(ao.outputs["Color"], bsdf.inputs["Base Color"]) bsdf.inputs["Metallic"].default_value = self.s("Metalness", 0.0) em = self.tex("Emission", y=-220, x=-900) k = min(6.0, self.s("Emission Intensity", 0.0)) if em is not None and k > 0.0: self.link(em.outputs["Color"], bsdf.inputs["Emission Color"]) bsdf.inputs["Emission Strength"].default_value = k * EMISSIVE_SCALE self.normal(bsdf, param="Normal", y=-520, strength=1.0) self.out(bsdf.outputs["BSDF"]) self.set_blend(True, two_sided=False) self.mat["preview_note"] = "a likeness: no parallax, lights do not blink" def build_lite_emissive(self): """MM_Emissive: a colour that glows.""" bsdf = self.principled() col = self.v("Color", (1.0, 1.0, 1.0)) 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 = min(8.0, self.s("Intensity", 1.0)) * EMISSIVE_SCALE bsdf.inputs["Roughness"].default_value = max(0.05, min(1.0, self.s("Roughness", 0.5))) self.mat.diffuse_color = (col[0], col[1], col[2], 1.0) self.out(bsdf.outputs["BSDF"]) 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. M_Base_DecalNorm (a dent/normal decal) is shown as its occlusion texture.""" bsdf = self.principled() if self.base_name == "M_Base_DecalNorm": occ = self.tex("OccDiffuse", y=300, x=-900) mask = self.tex("Mask", y=0, x=-900, colorspace="Non-Color") if occ is not None: self.link(occ.outputs["Color"], bsdf.inputs["Base Color"]) if mask is not None: self.link(mask.outputs["Color"], bsdf.inputs["Alpha"]) eb = self.s("Emissive Brightness", 0.0) if eb > 0.0: em = self.tex("Emissive", y=560, x=-560) if em is not None: ec = self.v("Emissive Color", (1, 1, 1)) self.link(self.mix_color(em.outputs["Color"], ec, 1.0, y=560, x=-260, blend="MULTIPLY"), bsdf.inputs["Emission Color"]) bsdf.inputs["Emission Strength"].default_value = eb * EMISSIVE_SCALE else: diff = self.tex("Diffuse", y=300, x=-900) mask = self.tex("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: # Unreal reads the opacity from the texture's colour (white = opaque), also where the file has # an alpha channel: the stripes' alpha is fully opaque and would give a black box 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)