"""Render a .blend model with a texture blended over its albedo. Every material on every mesh is replaced with a Principled BSDF whose base colour is the model's albedo mixed with the supplied texture, with the model's roughness and normal (bump) maps applied. A light is placed behind the camera. The albedo/roughness/normal images are taken from the model's existing material unless overridden on the command line. Texture alpha is respected: transparent areas keep the albedo. By default --texture is a UV texture. With --project it is instead an ordinary picture (e.g. a face) that is stretched over the model's silhouette as seen from the camera, projected onto the visible side (leaving the stem as-is) and baked into the UV layout first (save that with --save-projection). Usage: python render_model.py MODEL.blend --texture TEXTURE.png --output out.png [options] or directly: blender -b --disable-autoexec MODEL.blend --python render_model.py -- \ --texture TEXTURE.png --output out.png [options] Options include --blend-mode MIX|MULTIPLY|OVERLAY|..., --blend-factor 0-1, --project/--project-size/--project-offset-x/-y/--stem-max-width, --albedo/--roughness/--normal overrides, --light-strength, --light-left/--light-up, --ambient, --unlit, --azimuth, --elevation, --transparent; see --help. """ import argparse import math import os import shutil import sys try: import bmesh import bpy import numpy as np from mathutils import Vector except ImportError: # Run with plain Python: relaunch inside Blender. # python render_model.py MODEL.blend --texture TEX --output OUT [...] if len(sys.argv) < 2 or not sys.argv[1].endswith(".blend"): sys.exit("usage: python render_model.py MODEL.blend --texture TEX --output OUT [options]") blender = os.environ.get("BLENDER", shutil.which("blender")) if not blender: sys.exit("blender not found on PATH (or set $BLENDER)") os.execv(blender, [blender, "-b", "--disable-autoexec", sys.argv[1], "--python", os.path.abspath(__file__), "--", *sys.argv[2:]]) BLEND_MODES = ["MIX", "MULTIPLY", "OVERLAY", "SCREEN", "SOFT_LIGHT", "ADD", "DARKEN", "LIGHTEN", "COLOR", "HUE", "SATURATION", "VALUE"] def parse_args(): argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) p.add_argument("--texture", required=True, help="image blended over the albedo") p.add_argument("--output", required=True, help="output image path (PNG)") p.add_argument("--blend-mode", default="MIX", choices=BLEND_MODES, help="how --texture combines with the albedo") p.add_argument("--blend-factor", type=float, default=0.5, help="0 = albedo only, 1 = full --texture effect") g = p.add_argument_group("projection") g.add_argument("--project", action="store_true", help="project --texture onto the model from the camera direction " "instead of using it as a UV texture") g.add_argument("--project-azimuth", type=float, help="projection azimuth (default: --azimuth)") g.add_argument("--project-elevation", type=float, help="projection elevation (default: --elevation)") g.add_argument("--project-size", type=float, default=1.0, help="projected size relative to the model's silhouette (1 = stretched to fill it)") g.add_argument("--project-offset-x", type=float, default=0.0, help="shift right, as a fraction of the silhouette width") g.add_argument("--project-offset-y", type=float, default=0.0, help="shift up, as a fraction of the silhouette height") g.add_argument("--stem-max-width", type=float, default=0.12, help="UV islands in the top half narrower than this fraction of the model's " "width count as the stem and keep the albedo (0 = no stem detection)") g.add_argument("--project-resolution", type=int, default=1024, help="baked UV texture size in px") g.add_argument("--save-projection", help="also save the baked UV texture to this PNG") p.add_argument("--albedo", help="albedo image (default: from the model's material)") p.add_argument("--roughness", help="roughness image (default: from the model's material)") p.add_argument("--normal", help="OpenGL normal map (default: from the model's material)") p.add_argument("--normal-strength", type=float, default=1.0) p.add_argument("--light-strength", type=float, default=3.0, help="light brightness, roughly sun-equivalent W/m^2 at the model") p.add_argument("--light-left", type=float, default=35.0, help="degrees the light is swung to the camera's left (negative = right)") p.add_argument("--light-up", type=float, default=35.0, help="degrees the light is raised above the camera's view axis (negative = down)") p.add_argument("--ambient", type=float, default=0.1, help="uniform fill light strength (0 = none)") p.add_argument("--unlit", action="store_true", help="skip lighting/roughness/normal; emit the blended colour as-is") p.add_argument("--resolution", type=int, default=1024, help="square output size in px") p.add_argument("--samples", type=int, default=64, help="render samples") p.add_argument("--engine", default="CYCLES", choices=["CYCLES", "BLENDER_EEVEE"]) p.add_argument("--transparent", action="store_true", help="transparent background") p.add_argument("--background", default="0,0,0", help="background RGB 0-1 if not transparent") p.add_argument("--azimuth", type=float, default=30.0, help="camera rotation about Z in degrees (0 = front, from -Y)") p.add_argument("--elevation", type=float, default=20.0, help="camera elevation in degrees") p.add_argument("--margin", type=float, default=1.15, help="framing margin (>1 = more space)") return p.parse_args(argv) def bounds(meshes): """Centre, bounding-sphere radius and largest dimension of the meshes.""" corners = [o.matrix_world @ Vector(c) for o in meshes for c in o.bound_box] lo = Vector(min(c[i] for c in corners) for i in range(3)) hi = Vector(max(c[i] for c in corners) for i in range(3)) return (lo + hi) / 2, (hi - lo).length / 2, max(hi - lo) def view_basis(azimuth, elevation): """Direction from the model towards a camera at azimuth/elevation, plus the camera's right/up.""" az, el = math.radians(azimuth), math.radians(elevation) direction = Vector((math.cos(el) * math.sin(az), -math.cos(el) * math.cos(az), math.sin(el))) right = (-direction).cross(Vector((0, 0, 1))) if right.length < 1e-6: # looking straight up/down right = Vector((1, 0, 0)) right.normalize() return direction, right, right.cross(-direction) def linked_image(material, socket_name): """Find the image feeding a Principled BSDF input, looking through one Normal Map node.""" if not (material and material.use_nodes): return None for node in material.node_tree.nodes: if node.type != "BSDF_PRINCIPLED" or not node.inputs[socket_name].is_linked: continue src = node.inputs[socket_name].links[0].from_node if src.type == "NORMAL_MAP" and src.inputs["Color"].is_linked: src = src.inputs["Color"].links[0].from_node if src.type == "TEX_IMAGE": return src.image return None def load_image(path, non_color=False): img = bpy.data.images.load(bpy.path.abspath(path), check_existing=True) if non_color: img.colorspace_settings.name = "Non-Color" return img def uv_islands(bm, uv_layer): """Group faces into UV islands (faces sharing an edge with matching UVs).""" def edge_uvs(loop): return {loop[uv_layer].uv.to_tuple(5), loop.link_loop_next[uv_layer].uv.to_tuple(5)} seen, islands = set(), [] for start in bm.faces: if start.index in seen: continue seen.add(start.index) stack, island = [start], [] while stack: face = stack.pop() island.append(face) for loop in face.loops: for other in loop.edge.link_loops: if other.face.index not in seen and edge_uvs(other) == edge_uvs(loop): seen.add(other.face.index) stack.append(other.face) islands.append(island) return islands def mark_stem(meshes, max_width): """Store a 'projection_keep' face-corner attribute (0 on the stem) and return non-stem vertices. The stem is any UV island in the top half of the model whose horizontal extent is under max_width x the model's width. """ center, _, _ = bounds(meshes) corners = [o.matrix_world @ Vector(c) for o in meshes for c in o.bound_box] model_width = max(max(c[i] for c in corners) - min(c[i] for c in corners) for i in (0, 1)) body_verts = [] for obj in meshes: mesh, mw = obj.data, obj.matrix_world stem_faces = set() if max_width > 0 and mesh.uv_layers.active: bm = bmesh.new() bm.from_mesh(mesh) for island in uv_islands(bm, bm.loops.layers.uv.active): pts = [mw @ v.co for f in island for v in f.verts] spread = max(max(p[i] for p in pts) - min(p[i] for p in pts) for i in (0, 1)) if spread < max_width * model_width and min(p.z for p in pts) > center.z: stem_faces.update(f.index for f in island) bm.free() print(f"{obj.name}: {len(stem_faces)} stem faces kept as-is") keep = mesh.attributes.get("projection_keep") or \ mesh.attributes.new("projection_keep", "FLOAT", "CORNER") for poly in mesh.polygons: stem = poly.index in stem_faces for li in poly.loop_indices: keep.data[li].value = 0.0 if stem else 1.0 if not stem: body_verts.extend(mw @ mesh.vertices[vi].co for vi in poly.vertices) return body_verts def bake_projection(scene, meshes, image, args): """Stretch `image` over the model's silhouette as seen from the camera direction, project it onto the front-facing surfaces (except the stem) and bake to UV RGBA.""" azimuth = args.azimuth if args.project_azimuth is None else args.project_azimuth elevation = args.elevation if args.project_elevation is None else args.project_elevation center, _, _ = bounds(meshes) direction, right, up = view_basis(azimuth, elevation) # Silhouette bounds of the body (stem excluded) in the projector's right/up plane. body = mark_stem(meshes, args.stem_max_width) xs = [(p - center).dot(right) for p in body] ys = [(p - center).dot(up) for p in body] sil_w, sil_h = max(xs) - min(xs), max(ys) - min(ys) mid_x = (min(xs) + max(xs)) / 2 + args.project_offset_x * sil_w mid_y = (min(ys) + max(ys)) / 2 + args.project_offset_y * sil_h mat = bpy.data.materials.new("projection") mat.use_nodes = True nodes, links = mat.node_tree.nodes, mat.node_tree.links nodes.clear() width = sil_w * args.project_size height = sil_h * args.project_size origin = center + right * mid_x + up * mid_y geo = nodes.new("ShaderNodeNewGeometry") rel = nodes.new("ShaderNodeVectorMath") rel.operation = "SUBTRACT" rel.inputs[1].default_value = origin links.new(geo.outputs["Position"], rel.inputs[0]) def image_coord(axis, span): # (p - origin) . axis / span + 0.5 dot = nodes.new("ShaderNodeVectorMath") dot.operation = "DOT_PRODUCT" dot.inputs[1].default_value = axis links.new(rel.outputs["Vector"], dot.inputs[0]) scale = nodes.new("ShaderNodeMath") scale.operation = "MULTIPLY_ADD" scale.inputs[1].default_value = 1 / span scale.inputs[2].default_value = 0.5 links.new(dot.outputs["Value"], scale.inputs[0]) return scale.outputs["Value"] coord = nodes.new("ShaderNodeCombineXYZ") links.new(image_coord(right, width), coord.inputs["X"]) links.new(image_coord(up, height), coord.inputs["Y"]) tex = nodes.new("ShaderNodeTexImage") tex.image = image tex.extension = "CLIP" links.new(coord.outputs["Vector"], tex.inputs["Vector"]) # Only surfaces facing the projector (so it doesn't bleed onto the back), minus the stem. facing = nodes.new("ShaderNodeVectorMath") facing.operation = "DOT_PRODUCT" facing.inputs[1].default_value = direction links.new(geo.outputs["Normal"], facing.inputs[0]) fade = nodes.new("ShaderNodeMapRange") fade.inputs["From Min"].default_value = 0.0 fade.inputs["From Max"].default_value = 0.05 links.new(facing.outputs["Value"], fade.inputs["Value"]) keep = nodes.new("ShaderNodeAttribute") keep.attribute_name = "projection_keep" visible = nodes.new("ShaderNodeMath") visible.operation = "MULTIPLY" links.new(fade.outputs["Result"], visible.inputs[0]) links.new(keep.outputs["Fac"], visible.inputs[1]) mask = nodes.new("ShaderNodeMath") mask.operation = "MULTIPLY" links.new(tex.outputs["Alpha"], mask.inputs[0]) links.new(visible.outputs["Value"], mask.inputs[1]) emit = nodes.new("ShaderNodeEmission") out = nodes.new("ShaderNodeOutputMaterial") links.new(emit.outputs["Emission"], out.inputs["Surface"]) target = nodes.new("ShaderNodeTexImage") # bake target: the active, unconnected node nodes.active = target assign_material(meshes, mat) bpy.ops.object.select_all(action="DESELECT") for obj in meshes: obj.select_set(True) bpy.context.view_layer.objects.active = meshes[0] scene.render.engine = "CYCLES" scene.cycles.samples = 1 scene.cycles.device = "CPU" res = args.project_resolution def bake(source, name, non_color=False): img = bpy.data.images.new(name, res, res) if non_color: img.colorspace_settings.name = "Non-Color" for link in list(emit.inputs["Color"].links): links.remove(link) links.new(source, emit.inputs["Color"]) target.image = img bpy.ops.object.bake(type="EMIT", margin=16) px = np.empty(res * res * 4, dtype=np.float32) img.pixels.foreach_get(px) return px rgba = bake(tex.outputs["Color"], "bake_color") rgba[3::4] = bake(mask.outputs["Value"], "bake_mask", non_color=True)[0::4] result = bpy.data.images.new("projected_texture", res, res, alpha=True) result.pixels.foreach_set(rgba) if args.save_projection: result.filepath_raw = bpy.path.abspath(args.save_projection) result.file_format = "PNG" result.save() print(f"Wrote {result.filepath_raw}") return result def make_material(args, maps, texture): mat = bpy.data.materials.new("blended_texture") mat.use_nodes = True nodes, links = mat.node_tree.nodes, mat.node_tree.links nodes.clear() uv = nodes.new("ShaderNodeUVMap") def image_node(image): tex = nodes.new("ShaderNodeTexImage") tex.image = image links.new(uv.outputs["UV"], tex.inputs["Vector"]) return tex # Base colour: albedo blended with the user texture. user_tex = image_node(texture) if maps["albedo"]: mix = nodes.new("ShaderNodeMix") mix.data_type = "RGBA" mix.blend_type = args.blend_mode mix.clamp_result = True # Factor = blend factor x texture alpha, so transparent areas keep the albedo. factor = nodes.new("ShaderNodeMath") factor.operation = "MULTIPLY" factor.inputs[1].default_value = args.blend_factor links.new(user_tex.outputs["Alpha"], factor.inputs[0]) links.new(factor.outputs["Value"], mix.inputs["Factor"]) a = next(s for s in mix.inputs if s.name == "A" and s.type == "RGBA") b = next(s for s in mix.inputs if s.name == "B" and s.type == "RGBA") links.new(image_node(maps["albedo"]).outputs["Color"], a) links.new(user_tex.outputs["Color"], b) color = next(s for s in mix.outputs if s.name == "Result" and s.type == "RGBA") else: print("No albedo found; using --texture alone") color = user_tex.outputs["Color"] out = nodes.new("ShaderNodeOutputMaterial") if args.unlit: emit = nodes.new("ShaderNodeEmission") links.new(color, emit.inputs["Color"]) links.new(emit.outputs["Emission"], out.inputs["Surface"]) return mat bsdf = nodes.new("ShaderNodeBsdfPrincipled") links.new(color, bsdf.inputs["Base Color"]) if maps["roughness"]: maps["roughness"].colorspace_settings.name = "Non-Color" links.new(image_node(maps["roughness"]).outputs["Color"], bsdf.inputs["Roughness"]) if maps["normal"]: maps["normal"].colorspace_settings.name = "Non-Color" nmap = nodes.new("ShaderNodeNormalMap") nmap.inputs["Strength"].default_value = args.normal_strength links.new(image_node(maps["normal"]).outputs["Color"], nmap.inputs["Color"]) links.new(nmap.outputs["Normal"], bsdf.inputs["Normal"]) links.new(bsdf.outputs["BSDF"], out.inputs["Surface"]) return mat def find_maps(args, meshes): original = next((m for o in meshes for m in o.data.materials if m), None) maps = {} for key, socket, override in [("albedo", "Base Color", args.albedo), ("roughness", "Roughness", args.roughness), ("normal", "Normal", args.normal)]: maps[key] = load_image(override) if override else linked_image(original, socket) print(f"{key}: {maps[key].filepath if maps[key] else 'none'}") return maps def assign_material(meshes, mat): for obj in meshes: obj.data.materials.clear() obj.data.materials.append(mat) def setup_world(scene, transparent, rgb, ambient): # Camera rays see the background colour; everything else sees a uniform # grey fill of strength `ambient`, so the background doesn't light the model. world = bpy.data.worlds.new("render_world") scene.world = world world.use_nodes = True nodes, links = world.node_tree.nodes, world.node_tree.links nodes.clear() fill = nodes.new("ShaderNodeBackground") fill.inputs["Strength"].default_value = ambient visible = nodes.new("ShaderNodeBackground") visible.inputs["Color"].default_value = (*rgb, 1.0) path = nodes.new("ShaderNodeLightPath") mix = nodes.new("ShaderNodeMixShader") out = nodes.new("ShaderNodeOutputWorld") links.new(path.outputs["Is Camera Ray"], mix.inputs["Fac"]) links.new(fill.outputs["Background"], mix.inputs[1]) links.new(visible.outputs["Background"], mix.inputs[2]) links.new(mix.outputs["Shader"], out.inputs["Surface"]) scene.render.film_transparent = transparent def frame_camera(scene, meshes, azimuth, elevation, margin): center, radius, _ = bounds(meshes) cam_data = bpy.data.cameras.new("render_cam") cam = bpy.data.objects.new("render_cam", cam_data) scene.collection.objects.link(cam) scene.camera = cam # Distance so the bounding sphere fits the field of view. fov = cam_data.angle dist = radius * margin / math.sin(fov / 2) direction, _, _ = view_basis(azimuth, elevation) cam.location = center + direction * dist cam.rotation_euler = (center - cam.location).to_track_quat("-Z", "Y").to_euler() cam_data.clip_start = dist / 100 cam_data.clip_end = dist * 10 return center, dist, radius def add_light_behind_camera(scene, center, cam_dist, radius, args): # Point light behind the camera, swung left/up (in the camera's frame) around the model. direction, right, up = view_basis(args.azimuth, args.elevation) a, b = math.radians(args.light_left), math.radians(args.light_up) direction = (direction * math.cos(a) - right * math.sin(a)) * math.cos(b) + up * math.sin(b) light_dist = cam_dist * 1.5 data = bpy.data.lights.new("camera_light", "POINT") data.shadow_soft_size = radius * 0.5 # Blender point light power P gives ~P / (4 pi^2 d^2) W/m^2 at distance d. data.energy = args.light_strength * 4 * math.pi ** 2 * light_dist ** 2 light = bpy.data.objects.new("camera_light", data) light.location = center + direction * light_dist scene.collection.objects.link(light) def setup_render(scene, args): scene.render.engine = args.engine if args.engine == "CYCLES": scene.cycles.samples = args.samples scene.cycles.use_denoising = False scene.cycles.device = "CPU" else: scene.eevee.taa_render_samples = args.samples r = scene.render r.resolution_x = r.resolution_y = args.resolution r.resolution_percentage = 100 r.image_settings.file_format = "PNG" r.image_settings.color_mode = "RGBA" if args.transparent else "RGB" r.filepath = bpy.path.abspath(args.output) # "Standard" keeps texture colours faithful (Filmic/AgX would remap them). scene.view_settings.view_transform = "Standard" scene.view_settings.look = "None" scene.view_settings.exposure = 0.0 scene.view_settings.gamma = 1.0 def main(): args = parse_args() scene = bpy.context.scene meshes = [o for o in scene.objects if o.type == "MESH" and o.visible_get()] if not meshes: sys.exit("No visible mesh objects in scene") # Only the lights/camera this script adds should be used. for o in scene.objects: if o.type in {"LIGHT", "CAMERA"}: o.hide_render = True maps = find_maps(args, meshes) texture = load_image(args.texture) if args.project: texture = bake_projection(scene, meshes, texture, args) assign_material(meshes, make_material(args, maps, texture)) setup_world(scene, args.transparent, [float(v) for v in args.background.split(",")], 0.0 if args.unlit else args.ambient) center, dist, radius = frame_camera(scene, meshes, args.azimuth, args.elevation, args.margin) if not args.unlit: add_light_behind_camera(scene, center, dist, radius, args) setup_render(scene, args) bpy.ops.render.render(write_still=True) print(f"Wrote {scene.render.filepath}") if __name__ == "__main__": main()