""" Preview renders of a .blend with EEVEE, without touching the file. blender -b --python build/bl_render.py -- [--collection NAME] [--only 01,02] views.json: a list of views {"name": "plan", "type": "plan", "cut": 2.6, "margin": 2.0, "res": [1600, 1200], "bounds": [x0, y0, x1, y1]} top-down orthographic view. Everything above `cut` metres is clipped away (ceilings), so the floor plan and the furniture show. `bounds` (m) is optional; without it the visible meshes' bounds are used. {"name": "hall", "type": "persp", "eye": [x, y, z], "look": [x, y, z], "fov": 75, "res": [1600, 900]} {"name": "cam", "type": "camera", "object": "CameraActor", "res": [1600, 900]} Optional per view: "exposure" (stops), "world" (background strength), "hide": [name fragments to hide]. """ import json import math import os import sys import bpy from mathutils import Vector argv = sys.argv[sys.argv.index("--") + 1:] OUT_DIR, PREFIX, VIEWS = argv[0], argv[1], argv[2] ONLY_COLLECTION = argv[argv.index("--collection") + 1] if "--collection" in argv else None # "--only 01,02": just the views whose name begins with one of these (a long list takes several runs) ONLY_VIEWS = tuple(argv[argv.index("--only") + 1].split(",")) if "--only" in argv else None scene = bpy.context.scene EEVEE = "BLENDER_EEVEE" for eng in ("BLENDER_EEVEE_NEXT", "BLENDER_EEVEE"): try: scene.render.engine = eng EEVEE = eng break except TypeError: continue ee = scene.eevee for prop, val in (("taa_render_samples", 48), ("use_raytracing", True), ("use_shadows", True), ("use_gtao", True), ("ray_tracing_method", "SCREEN"), ("use_fast_gi", True)): try: setattr(ee, prop, val) except Exception: # noqa: BLE001 pass try: ee.ray_tracing_options.resolution_scale = "2" ee.fast_gi_method = "GLOBAL_ILLUMINATION" except Exception: # noqa: BLE001 pass scene.render.image_settings.file_format = "PNG" scene.render.image_settings.color_mode = "RGB" scene.render.film_transparent = False scene.view_settings.view_transform = "AgX" try: scene.view_settings.look = "AgX - Medium High Contrast" except Exception: # noqa: BLE001 pass # The file's own world (its sky) is used unless a view asks for a plain one with "world" / "world_color"; # plan views always get the plain white one. FILE_WORLD = scene.world FILE_EXPOSURE = scene.view_settings.exposure world = bpy.data.worlds.new("PreviewWorld") world.use_nodes = True world.node_tree.nodes.clear() bg = world.node_tree.nodes.new("ShaderNodeBackground") out = world.node_tree.nodes.new("ShaderNodeOutputWorld") world.node_tree.links.new(bg.outputs[0], out.inputs[0]) def set_world(v, plain_default): if plain_default or "world" in v or "world_color" in v or FILE_WORLD is None: scene.world = world bg.inputs["Color"].default_value = tuple(v.get("world_color", (1, 1, 1) if plain_default else (0.55, 0.62, 0.7))) + (1,) bg.inputs["Strength"].default_value = float(v.get("world", 1.2 if plain_default else 0.3)) else: scene.world = FILE_WORLD def visible_bounds(): lo = Vector((1e9, 1e9, 1e9)) hi = Vector((-1e9, -1e9, -1e9)) cols = None if ONLY_COLLECTION: root = bpy.data.collections.get(ONLY_COLLECTION) cols = set(root.all_objects) if root else None for ob in scene.objects: if ob.type != "MESH" or ob.hide_render or (cols is not None and ob not in cols): continue if not ob.visible_get(): continue for c in ob.bound_box: p = ob.matrix_world @ Vector(c) lo = Vector((min(lo.x, p.x), min(lo.y, p.y), min(lo.z, p.z))) hi = Vector((max(hi.x, p.x), max(hi.y, p.y), max(hi.z, p.z))) return lo, hi cam_data = bpy.data.cameras.new("PreviewCam") cam = bpy.data.objects.new("PreviewCam", cam_data) scene.collection.objects.link(cam) with open(VIEWS, "r", encoding="utf-8") as f: views = json.load(f) os.makedirs(OUT_DIR, exist_ok=True) for v in views: if ONLY_VIEWS is not None and not v["name"].startswith(ONLY_VIEWS): continue hidden = [] for frag in v.get("hide", []): for ob in scene.objects: if (frag in ob.name or (ob.data is not None and frag in ob.data.name)) and not ob.hide_render: ob.hide_render = True hidden.append(ob) res = v.get("res", [1600, 900]) scene.render.resolution_x, scene.render.resolution_y = res scene.render.resolution_percentage = 100 scene.view_settings.exposure = float(v.get("exposure", FILE_EXPOSURE)) kind = v.get("type", "persp") use_cam = cam if kind == "plan": b = v.get("bounds") if b: x0, y0, x1, y1 = b else: lo, hi = visible_bounds() x0, y0, x1, y1 = lo.x, lo.y, hi.x, hi.y mg = float(v.get("margin", 1.0)) x0, y0, x1, y1 = x0 - mg, y0 - mg, x1 + mg, y1 + mg cut = float(v.get("cut", 2.6)) cam_data.type = "ORTHO" w, h = x1 - x0, y1 - y0 aspect = res[0] / res[1] cam_data.ortho_scale = max(w, h * aspect) cam_data.sensor_fit = "HORIZONTAL" cam.location = ((x0 + x1) / 2, (y0 + y1) / 2, cut + 0.001) cam.rotation_euler = (0.0, 0.0, math.radians(float(v.get("rot", 0.0)))) cam_data.clip_start = 0.001 cam_data.clip_end = 200.0 set_world(v, True) elif kind == "camera": use_cam = bpy.data.objects[v["object"]] set_world(v, False) else: cam_data.type = "PERSP" cam_data.sensor_fit = "HORIZONTAL" cam_data.angle = math.radians(float(v.get("fov", 75.0))) cam_data.clip_start = 0.05 cam_data.clip_end = 2000.0 eye = Vector(v["eye"]) look = Vector(v["look"]) cam.location = eye cam.rotation_euler = (look - eye).to_track_quat("-Z", "Y").to_euler() set_world(v, False) # "engine": "workbench" gives a fast solid-shaded picture (no lights, back faces culled as Unreal does): # for checking geometry, not for looks if v.get("engine") == "workbench": scene.render.engine = "BLENDER_WORKBENCH" sh = scene.display.shading sh.light = "STUDIO" sh.color_type = v.get("color", "MATERIAL") sh.show_backface_culling = bool(v.get("cull", True)) sh.show_cavity = True sh.cavity_type = "BOTH" sh.show_shadows = False scene.display.render_aa = "8" else: scene.render.engine = EEVEE scene.camera = use_cam path = os.path.join(OUT_DIR, "%s_%s.png" % (PREFIX, v["name"])) scene.render.filepath = path bpy.ops.render.render(write_still=True) if kind == "plan": # what the picture covers, for build/annotate_plan.py (grid and coordinates) with open(path + ".json", "w", encoding="utf-8") as f: json.dump({"cx": cam.location.x, "cy": cam.location.y, "width": cam_data.ortho_scale, "height": cam_data.ortho_scale / (res[0] / res[1]), "rot": float(v.get("rot", 0.0))}, f) for ob in hidden: ob.hide_render = False print("rendered", path)