""" Preview renders of a .blend with EEVEE, without touching the file. blender -b --python build/bl_render.py -- [--collection NAME] 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], "lights": [collection name prefixes] = only the lamps in those collections stay on for this view. "engine": "CYCLES" renders that view path-traced (with "samples", default 192, denoised, on the GPU if there is one): light bounces, soft shadows and true reflections, which is what shows a surface's roughness and normal maps. "only": [collection name prefixes] = nothing outside those collections is rendered in this view (a room by itself renders in a fraction of the time, and no lamp of another deck can reach it). (Use "lights" to compare a room before and after a change. With all of the layout's 206 shadowed lamps on, EEVEE runs out of shadow tiles and lamps of other decks leak into the picture, differently on every run: the same view rendered twice differs in 200,000 pixels, and a wall can come out twice as bright for no reason in the model.) """ import json import math import os import sys import bpy from mathutils import Vector argv = sys.argv[sys.argv.index("--") + 1:] # (absolute: Blender resolves a relative render path against the drive root, not the working directory) OUT_DIR, PREFIX, VIEWS = os.path.abspath(argv[0]), argv[1], os.path.abspath(argv[2]) ONLY_COLLECTION = argv[argv.index("--collection") + 1] if "--collection" in argv else None scene = bpy.context.scene for eng in ("BLENDER_EEVEE_NEXT", "BLENDER_EEVEE"): try: scene.render.engine = eng break except TypeError: continue EEVEE = scene.render.engine CYCLES = {} def set_engine(v): """EEVEE unless the view asks for Cycles.""" if str(v.get("engine", "")).upper() != "CYCLES": scene.render.engine = EEVEE return scene.render.engine = "CYCLES" cy = scene.cycles cy.samples = int(v.get("samples", 192)) cy.use_denoising = True cy.max_bounces, cy.diffuse_bounces, cy.glossy_bounces, cy.transparent_max_bounces = 8, 4, 4, 8 cy.sample_clamp_indirect = 8.0 if "device" not in CYCLES: dev = None try: prefs = bpy.context.preferences.addons["cycles"].preferences for t in ("OPTIX", "HIP", "CUDA", "ONEAPI"): try: prefs.compute_device_type = t prefs.get_devices() if [d for d in prefs.devices if d.type == t]: for d in prefs.devices: d.use = (d.type == t) dev = t break except Exception: # noqa: BLE001 pass except Exception as e: # noqa: BLE001 print("cycles device setup failed", e) CYCLES["device"] = dev print("CYCLES DEVICE", dev) cy.device = "GPU" if CYCLES["device"] else "CPU" 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: 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) lit = tuple(v.get("lights", ())) if lit: for ob in scene.objects: if ob.type == "LIGHT" and not ob.hide_render and not any(c.name.startswith(lit) for c in ob.users_collection): ob.hide_render = True hidden.append(ob) only = tuple(v.get("only", ())) if only: for ob in scene.objects: if ob is not cam and not ob.hide_render and not any(c.name.startswith(only) for c in ob.users_collection): ob.hide_render = True hidden.append(ob) set_engine(v) if "frame" in v: # (rigs driven by the timeline: the pressure door is open at frame 100) scene.frame_set(int(v["frame"])) 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 == "side": # a section seen from starboard, bow to the right: everything on the near side of y = `cut` is clipped away; # "bounds": [x0, z0, x1, z1] x0, z0, x1, z1 = v["bounds"] cut = float(v.get("cut", 0.0)) cam_data.type = "ORTHO" aspect = res[0] / res[1] cam_data.ortho_scale = max(x1 - x0, (z1 - z0) * aspect) cam_data.sensor_fit = "HORIZONTAL" cam.location = ((x0 + x1) / 2, cut - 0.001, (z0 + z1) / 2) cam.rotation_euler = (math.radians(90.0), 0.0, 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) 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)