| """ |
| Blender script to render images of 3D models. |
| |
| This script is adopted from the Trellis rendering script: |
| https://github.com/microsoft/TRELLIS/blob/main/dataset_toolkits/render.py |
| |
| """ |
|
|
| import argparse |
| import math |
| import os |
| import platform |
| import random |
| import sys |
| from pathlib import Path |
| from typing import Any, Callable, Dict, Generator, Literal, Optional, Tuple |
|
|
| import bpy |
| import numpy as np |
| from mathutils import Vector |
|
|
| pathdir = Path(__file__).parent |
| sys.path.append(pathdir.as_posix()) |
|
|
| print(dir(bpy), bpy.__path__) |
|
|
| IMPORT_FUNCTIONS: Dict[str, Callable] = { |
| ".obj": bpy.ops.wm.obj_import, |
| ".glb": bpy.ops.import_scene.gltf, |
| ".gltf": bpy.ops.import_scene.gltf, |
| } |
|
|
|
|
| def center_and_scale_mesh(scale_value: float = 1.0) -> None: |
| """Centers and scales the scene to fit in a unit cube. |
| For example, |
| scale_value = 1.0 ==> [-0.5, 0.5] |
| scale_value = 2.0 ==> [-1.0, 1.0] |
| """ |
| |
| mesh_objects = [obj for obj in bpy.context.scene.objects if obj.type == "MESH"] |
| if not mesh_objects: |
| return |
|
|
| |
| min_coords = Vector((float("inf"),) * 3) |
| max_coords = Vector((float("-inf"),) * 3) |
|
|
| for obj in mesh_objects: |
| |
| for vertex in obj.data.vertices: |
| world_coord = obj.matrix_world @ vertex.co |
| min_coords.x = min(min_coords.x, world_coord.x) |
| min_coords.y = min(min_coords.y, world_coord.y) |
| min_coords.z = min(min_coords.z, world_coord.z) |
| max_coords.x = max(max_coords.x, world_coord.x) |
| max_coords.y = max(max_coords.y, world_coord.y) |
| max_coords.z = max(max_coords.z, world_coord.z) |
|
|
| |
| center = (min_coords + max_coords) / 2 |
| dimensions = max_coords - min_coords |
| scale = scale_value / max( |
| dimensions.x, dimensions.y, dimensions.z |
| ) |
|
|
| |
| empty = bpy.data.objects.new("Parent_Empty", None) |
| bpy.context.scene.collection.objects.link(empty) |
|
|
| |
| for obj in mesh_objects: |
| obj.parent = empty |
|
|
| |
| empty.location = -center |
|
|
| |
| empty.scale = (scale, scale, scale) |
|
|
| bpy.context.view_layer.update() |
| bpy.ops.object.select_all(action="DESELECT") |
| empty.select_set(True) |
| bpy.context.view_layer.objects.active = empty |
| bpy.ops.object.transform_apply(location=True, rotation=True, scale=True) |
| print(f"Empty location: {empty.location}") |
| print(f"Empty scale: {empty.scale}") |
|
|
| return scale |
|
|
|
|
| def normalize_scene() -> None: |
| """Normalizes the scene by scaling and translating it to fit in a unit cube centered |
| at the origin. |
| |
| Mostly taken from the Point-E / Shap-E rendering script |
| (https://github.com/openai/point-e/blob/main/point_e/evals/scripts/blender_script.py#L97-L112), |
| but fix for multiple root objects: (see bug report here: |
| https://github.com/openai/shap-e/pull/60). |
| |
| Returns: |
| The new parent object that all objects descend from. |
| """ |
| if len(list(get_scene_root_objects())) > 1: |
| |
| parent_empty = bpy.data.objects.new("ParentEmpty", None) |
| bpy.context.scene.collection.objects.link(parent_empty) |
|
|
| |
| for obj in get_scene_root_objects(): |
| if obj != parent_empty: |
| obj.parent = parent_empty |
|
|
| bbox_min, bbox_max = scene_bbox() |
| scale = 1 / max(bbox_max - bbox_min) |
| for obj in get_scene_root_objects(): |
| obj.scale = obj.scale * scale |
|
|
| |
| bpy.context.view_layer.update() |
| bbox_min, bbox_max = scene_bbox() |
| offset = -(bbox_min + bbox_max) / 2 |
| for obj in get_scene_root_objects(): |
| obj.matrix_world.translation += offset |
| bpy.ops.object.select_all(action="DESELECT") |
| bbox_min, bbox_max = scene_bbox() |
| print(f"After normalize_scene: bbox_min: {bbox_min}, bbox_max: {bbox_max}") |
|
|
| |
| bpy.data.objects["Camera"].parent = None |
|
|
| return parent_empty |
|
|
|
|
| def reset_cameras() -> None: |
| """Resets the cameras in the scene to a single default camera.""" |
| |
| bpy.ops.object.select_all(action="DESELECT") |
| bpy.ops.object.select_by_type(type="CAMERA") |
| bpy.ops.object.delete() |
|
|
| |
| bpy.ops.object.camera_add() |
|
|
| |
| new_camera = bpy.context.active_object |
| new_camera.name = "Camera" |
|
|
| |
| scene.camera = new_camera |
|
|
|
|
| def get_camera_with_position(x, y, z, fov_degrees=40): |
| camera = bpy.data.objects["Camera"] |
| camera.data.angle = math.radians(fov_degrees) |
| camera.location = np.array([x, y, z]) |
| direction = -camera.location |
| rot_quat = direction.to_track_quat("-Z", "Y") |
| camera.rotation_euler = rot_quat.to_euler() |
| return camera |
|
|
|
|
| def reset_scene() -> None: |
| """Resets the scene to a clean state. |
| |
| Returns: |
| None |
| """ |
| |
| for obj in bpy.data.objects: |
| if obj.type not in {"CAMERA", "LIGHT"}: |
| bpy.data.objects.remove(obj, do_unlink=True) |
|
|
| |
| for material in bpy.data.materials: |
| bpy.data.materials.remove(material, do_unlink=True) |
|
|
| |
| for texture in bpy.data.textures: |
| bpy.data.textures.remove(texture, do_unlink=True) |
|
|
| |
| for image in bpy.data.images: |
| bpy.data.images.remove(image, do_unlink=True) |
|
|
|
|
| def load_object(object_path: str) -> None: |
| """Loads a model with a supported file extension into the scene. |
| |
| Args: |
| object_path (str): Path to the model file. |
| |
| Raises: |
| ValueError: If the file extension is not supported. |
| |
| Returns: |
| None |
| """ |
| file_extension = Path(object_path).suffix |
| if file_extension is None or file_extension == "": |
| raise ValueError(f"Unsupported file type: {object_path}") |
|
|
| |
| import_function = IMPORT_FUNCTIONS[file_extension] |
|
|
| if file_extension in {".glb", ".gltf"}: |
| import_function(filepath=object_path, merge_vertices=True) |
| else: |
| import_function(filepath=object_path) |
|
|
|
|
| def clear_lights(): |
| bpy.ops.object.select_all(action="DESELECT") |
| for obj in bpy.context.scene.objects.values(): |
| if isinstance(obj.data, bpy.types.Light): |
| obj.select_set(True) |
| bpy.ops.object.delete() |
|
|
|
|
| def create_light( |
| location, |
| energy=1.0, |
| angle=0.5 * math.pi / 180, |
| light_type: Literal["POINT", "SUN", "SPOT", "AREA"] = "SUN", |
| ): |
| |
| light_data = bpy.data.lights.new(name="Light", type=light_type) |
| light_data.energy = energy |
| if light_type != "AREA" and light_type != "POINT": |
| light_data.angle = angle |
| light_object = bpy.data.objects.new(name="Light", object_data=light_data) |
|
|
| direction = -location |
| rot_quat = direction.to_track_quat("-Z", "Y") |
| light_object.rotation_euler = rot_quat.to_euler() |
| bpy.context.view_layer.update() |
|
|
| bpy.context.collection.objects.link(light_object) |
| light_object.location = location |
|
|
|
|
| def create_uniform_lights( |
| distance=2.0, |
| energy=3.0, |
| light_type: Literal["POINT", "SUN", "SPOT", "AREA"] = "SUN", |
| ): |
| clear_lights() |
| create_light(Vector([1, 0, 0]) * distance, energy=energy, light_type=light_type) |
| create_light(-Vector([1, 0, 0]) * distance, energy=energy, light_type=light_type) |
| create_light(Vector([0, 1, 0]) * distance, energy=energy, light_type=light_type) |
| create_light(-Vector([0, 1, 0]) * distance, energy=energy, light_type=light_type) |
| create_light(Vector([0, 0, 1]) * distance, energy=energy, light_type=light_type) |
| create_light(-Vector([0, 0, 1]) * distance, energy=energy, light_type=light_type) |
|
|
|
|
| def create_light_at_camera_position( |
| camera_position: Vector, |
| energy=1.5, |
| use_shadow=False, |
| light_type: Literal["POINT", "SUN", "SPOT", "AREA"] = "SUN", |
| ): |
| clear_lights() |
| create_light(camera_position, energy=energy, light_type=light_type) |
| |
| if not use_shadow: |
| for light in bpy.data.lights: |
| light.use_shadow = False |
|
|
|
|
| def set_world_background_color( |
| color: Tuple[float, float, float, float] = (1.0, 1.0, 1.0, 1.0), |
| ) -> None: |
| bpy.context.scene.world.use_nodes = True |
| bpy.context.scene.world.node_tree.nodes["Background"].inputs[ |
| 0 |
| ].default_value = color |
| bpy.context.scene.view_settings.view_transform = "Standard" |
|
|
|
|
| def scene_bbox( |
| single_obj: Optional[bpy.types.Object] = None, ignore_matrix: bool = False |
| ) -> Tuple[Vector, Vector]: |
| """Returns the bounding box of the scene. |
| |
| Taken from Shap-E rendering script |
| (https://github.com/openai/shap-e/blob/main/shap_e/rendering/blender/blender_script.py#L68-L82) |
| |
| Args: |
| single_obj (Optional[bpy.types.Object], optional): If not None, only computes |
| the bounding box for the given object. Defaults to None. |
| ignore_matrix (bool, optional): Whether to ignore the object's matrix. Defaults |
| to False. |
| |
| Raises: |
| RuntimeError: If there are no objects in the scene. |
| |
| Returns: |
| Tuple[Vector, Vector]: The minimum and maximum coordinates of the bounding box. |
| """ |
| bbox_min = (math.inf,) * 3 |
| bbox_max = (-math.inf,) * 3 |
| found = False |
| for obj in get_scene_meshes() if single_obj is None else [single_obj]: |
| found = True |
| for coord in obj.bound_box: |
| coord = Vector(coord) |
| if not ignore_matrix: |
| coord = obj.matrix_world @ coord |
| bbox_min = tuple(min(x, y) for x, y in zip(bbox_min, coord)) |
| bbox_max = tuple(max(x, y) for x, y in zip(bbox_max, coord)) |
|
|
| if not found: |
| raise RuntimeError("no objects in scene to compute bounding box for") |
|
|
| return Vector(bbox_min), Vector(bbox_max) |
|
|
|
|
| def get_scene_root_objects() -> Generator[bpy.types.Object, None, None]: |
| """Returns all root objects in the scene. |
| |
| Yields: |
| Generator[bpy.types.Object, None, None]: Generator of all root objects in the |
| scene. |
| """ |
| for obj in bpy.context.scene.objects.values(): |
| if not obj.parent and not isinstance(obj.data, bpy.types.Light): |
| yield obj |
|
|
|
|
| def get_scene_meshes() -> Generator[bpy.types.Object, None, None]: |
| """Returns all meshes in the scene. |
| |
| Yields: |
| Generator[bpy.types.Object, None, None]: Generator of all meshes in the scene. |
| """ |
| for obj in bpy.context.scene.objects.values(): |
| if isinstance(obj.data, (bpy.types.Mesh)): |
| yield obj |
|
|
|
|
| def delete_missing_textures() -> Dict[str, Any]: |
| """Deletes all missing textures in the scene. |
| |
| Returns: |
| Dict[str, Any]: Dictionary with keys "count", "files", and "file_path_to_color". |
| "count" is the number of missing textures, "files" is a list of the missing |
| texture file paths, and "file_path_to_color" is a dictionary mapping the |
| missing texture file paths to a random color. |
| """ |
| missing_file_count = 0 |
| out_files = [] |
| file_path_to_color = {} |
|
|
| |
| for material in bpy.data.materials: |
| if material.use_nodes: |
| for node in material.node_tree.nodes: |
| if node.type == "TEX_IMAGE": |
| image = node.image |
| if image is not None: |
| file_path = bpy.path.abspath(image.filepath) |
| if file_path == "": |
| |
| continue |
|
|
| if not os.path.exists(file_path): |
| |
| connected_node = node.outputs[0].links[0].to_node |
|
|
| if connected_node.type == "BSDF_PRINCIPLED": |
| if file_path not in file_path_to_color: |
| |
| random_color = [random.random() for _ in range(3)] |
| file_path_to_color[file_path] = random_color + [1] |
|
|
| connected_node.inputs[ |
| "Base Color" |
| ].default_value = file_path_to_color[file_path] |
|
|
| |
| material.node_tree.nodes.remove(node) |
| missing_file_count += 1 |
| out_files.append(image.filepath) |
| return { |
| "count": missing_file_count, |
| "files": out_files, |
| "file_path_to_color": file_path_to_color, |
| } |
|
|
|
|
| def setup_environment_lighting(envmap_path): |
| world = bpy.context.scene.world |
| world.use_nodes = True |
| nodes = world.node_tree.nodes |
| links = world.node_tree.links |
|
|
| |
| for node in nodes: |
| nodes.remove(node) |
|
|
| |
| bg_node = nodes.new(type="ShaderNodeBackground") |
| bg_node.location = (0, 0) |
|
|
| |
| env_tex_node = nodes.new(type="ShaderNodeTexEnvironment") |
| env_tex_node.location = (-300, 0) |
|
|
| |
| env_tex_node.image = bpy.data.images.load(envmap_path) |
|
|
| |
| world_output_node = nodes.new(type="ShaderNodeOutputWorld") |
| world_output_node.location = (300, 0) |
|
|
| |
| links.new(env_tex_node.outputs["Color"], bg_node.inputs["Color"]) |
| links.new(bg_node.outputs["Background"], world_output_node.inputs["Surface"]) |
|
|
|
|
| def create_solid_color_material(name, color): |
| mat = bpy.data.materials.new(name) |
| mat.use_nodes = True |
| node_tree = mat.node_tree |
| color_node = node_tree.nodes.new("ShaderNodeBsdfDiffuse") |
| color_node.inputs["Color"].default_value = color |
| mat_output = node_tree.nodes["Material Output"] |
| node_tree.links.new(color_node.outputs["BSDF"], mat_output.inputs["Surface"]) |
| return mat |
|
|
|
|
| def create_phong_material(name, color): |
| mat = bpy.data.materials.new(name) |
| mat.use_nodes = True |
| node_tree = mat.node_tree |
| spec_node = node_tree.nodes.new("ShaderNodeBsdfPrincipled") |
| print(spec_node.inputs.keys()) |
| spec_node.inputs["Base Color"].default_value = color |
| spec_node.inputs["Roughness"].default_value = 0.5 |
| spec_node.inputs["Metallic"].default_value = 1.0 |
| mat_output = node_tree.nodes["Material Output"] |
| node_tree.links.new(spec_node.outputs["BSDF"], mat_output.inputs["Surface"]) |
| return mat |
|
|
|
|
| def render_object( |
| object_file: str, |
| num_renders: int, |
| output_dir: str, |
| transparent_background: bool = False, |
| environment_map: str = None, |
| ) -> None: |
| """Saves rendered images for given asset to specified output directory. |
| |
| Args: |
| object_file (str): Path to the object file. |
| num_renders (int): Number of renders to save of the object. |
| output_dir (str): Path to the directory where the rendered images and metadata |
| will be saved. The rendered images will be saved in the subdirectory |
| `output_dir/stemname`. |
| transparent_background (bool): Whether to use transparent background, |
| otherwise the background is white. |
| Returns: |
| None |
| """ |
| os.makedirs(output_dir, exist_ok=True) |
|
|
| |
| reset_scene() |
| load_object(object_file) |
|
|
| if transparent_background: |
| scene.render.film_transparent = True |
| else: |
| scene.render.film_transparent = False |
|
|
| set_world_background_color([0.2, 0.2, 0.2, 1.0]) |
|
|
| |
| _ = normalize_scene() |
|
|
| |
| cam = scene.objects["Camera"] |
| fov_degrees = 40.0 |
| cam.data.angle = np.radians(fov_degrees) |
|
|
| |
| cam_constraint = cam.constraints.new(type="TRACK_TO") |
| cam_constraint.track_axis = "TRACK_NEGATIVE_Z" |
| cam_constraint.up_axis = "UP_Y" |
| empty = bpy.data.objects.new("Empty", None) |
| empty.location = (0, 0, 0) |
| scene.collection.objects.link(empty) |
| cam_constraint.target = empty |
| cam.parent = empty |
|
|
| |
| delete_missing_textures() |
|
|
| if environment_map: |
| setup_environment_lighting(environment_map) |
| else: |
| create_uniform_lights(energy=1.0, light_type="SUN") |
|
|
| camera_position = [0, -2, 0] |
|
|
| |
| stepsize = 360.0 / num_renders |
|
|
| def render_views(name): |
| for i in range(num_renders): |
| |
| _ = get_camera_with_position( |
| camera_position[0], |
| camera_position[1], |
| camera_position[2], |
| fov_degrees=fov_degrees, |
| ) |
|
|
| |
| render_path = os.path.abspath( |
| os.path.join(output_dir, f"{i:03d}_{name}.png") |
| ) |
|
|
| |
| scene.render.filepath = render_path |
|
|
| |
| os.makedirs(output_dir, exist_ok=True) |
|
|
| |
| bpy.ops.render.render(write_still=True) |
|
|
| context.view_layer.objects.active = empty |
| empty.rotation_euler[2] += math.radians(stepsize) |
|
|
| |
| |
| textured_mat = create_solid_color_material("default texture", [0.6, 0.6, 0.6, 1]) |
|
|
| for obj in get_scene_meshes(): |
| if obj.active_material is None: |
| obj.active_material = textured_mat |
|
|
| render_views("textured") |
|
|
|
|
| def enable_gpus(device_type, use_cpus=False): |
| preferences = bpy.context.preferences |
| cycles_preferences = preferences.addons["cycles"].preferences |
| cycles_preferences.refresh_devices() |
| try: |
| devices = cycles_preferences.devices |
| except: |
| print("No devices detected") |
| if device_type == "CPU": |
| return [] |
| else: |
| raise RuntimeError(f"No devices detected, set use_cpus to True") |
|
|
| assert device_type in [ |
| "CUDA", |
| "METAL", |
| "OPENCL", |
| "CPU", |
| "NONE", |
| ], f"Unsupported device type: {device_type}" |
|
|
| try: |
| |
| iter(devices) |
| except TypeError: |
| |
| devices = [devices] |
|
|
| activated_gpus = [] |
| for device in devices: |
| if device.type == "CPU": |
| device.use = use_cpus |
| else: |
| device.use = True |
| activated_gpus.append(device.name) |
|
|
| if device_type == "CUDA": |
| cycles_preferences.compute_device_type = "CUDA" |
| bpy.context.scene.cycles.device = "GPU" |
| elif device_type == "METAL": |
| cycles_preferences.compute_device_type = "METAL" |
| bpy.context.scene.cycles.device = "GPU" |
| elif device_type == "OPENCL": |
| cycles_preferences.compute_device_type = "OPENCL" |
| bpy.context.scene.cycles.device = "GPU" |
| else: |
| raise RuntimeError(f"Unsupported device type: {device_type}") |
|
|
| return activated_gpus |
|
|
|
|
| def set_render_settings(engine, resolution): |
| |
| render.engine = engine |
| render.image_settings.file_format = "PNG" |
| render.image_settings.color_mode = "RGBA" |
| render.resolution_x = resolution |
| render.resolution_y = resolution |
| render.resolution_percentage = 100 |
|
|
| |
| scene.cycles.device = "GPU" |
| scene.cycles.use_adaptive_sampling = True |
| scene.cycles.adaptive_threshold = 0.1 |
| scene.cycles.samples = 64 |
| scene.cycles.adaptive_min_samples = 1 |
| scene.cycles.filter_width = 2 |
| scene.cycles.use_fast_gi = True |
| scene.cycles.fast_gi_method = "REPLACE" |
| world.light_settings.ao_factor = 1.0 |
| world.light_settings.distance = 10 |
| scene.cycles.use_denoising = True |
| scene.cycles.denoising_use_gpu = True |
|
|
| |
| scene.render.use_persistent_data = True |
|
|
| |
| scene.eevee.use_shadows = True |
| scene.eevee.use_raytracing = True |
| scene.eevee.ray_tracing_options.use_denoise = True |
| scene.eevee.use_fast_gi = True |
| scene.eevee.fast_gi_method = "GLOBAL_ILLUMINATION" |
| scene.eevee.ray_tracing_options.trace_max_roughness = 0.5 |
| scene.eevee.fast_gi_resolution = "2" |
| scene.eevee.fast_gi_ray_count = 2 |
| scene.eevee.fast_gi_step_count = 8 |
|
|
|
|
| def print_devices(): |
| print("Devices:") |
| preferences = bpy.context.preferences |
| cycles_preferences = preferences.addons["cycles"].preferences |
| cycles_preferences.refresh_devices() |
|
|
| devices = cycles_preferences.devices |
| for device in devices: |
| print(f' [{device.id}]<{device.type}> "{device.name}" Using: {device.use}') |
|
|
| print(f"Compute device type: {cycles_preferences.compute_device_type}") |
| print(f"Cycles device: {bpy.context.scene.cycles.device}") |
|
|
|
|
| if __name__ == "__main__": |
| parser = argparse.ArgumentParser() |
| parser.add_argument( |
| "--object_path", |
| type=str, |
| required=False, |
| help="Path to the object file", |
| ) |
| parser.add_argument( |
| "--output_dir", |
| type=str, |
| required=True, |
| help="Path to the directory where the rendered images and metadata will be saved.", |
| ) |
| parser.add_argument( |
| "--engine", |
| type=str, |
| default="BLENDER_EEVEE_NEXT", |
| choices=["CYCLES", "BLENDER_EEVEE_NEXT"], |
| ) |
| parser.add_argument( |
| "--num_renders", |
| type=int, |
| default=12, |
| help="Number of renders to save of the object.", |
| ) |
| parser.add_argument( |
| "--render_resolution", |
| type=int, |
| default=512, |
| help="Resolution of the rendered images.", |
| ) |
| parser.add_argument( |
| "--transparent_background", |
| action="store_true", |
| help="Whether to use transparent background", |
| ) |
| parser.add_argument( |
| "--environment_map", |
| default=None, |
| type=str, |
| help="Use the given environment map for lighting", |
| ) |
|
|
| argv = sys.argv[sys.argv.index("--") + 1 :] |
| args = parser.parse_args(argv) |
|
|
| context = bpy.context |
| scene = context.scene |
| render = scene.render |
| world = bpy.data.worlds["World"] |
|
|
| set_render_settings(args.engine, args.render_resolution) |
|
|
| |
| platform = platform.system() |
| if platform == "Darwin": |
| activated_gpus = enable_gpus("METAL", use_cpus=True) |
| elif platform == "Linux": |
| activated_gpus = enable_gpus("CUDA", use_cpus=False) |
| else: |
| raise RuntimeError("Unsupported platform") |
| print(f"Activated GPUs: {activated_gpus}") |
|
|
| print_devices() |
|
|
| render_object( |
| object_file=args.object_path, |
| num_renders=args.num_renders, |
| output_dir=args.output_dir, |
| transparent_background=args.transparent_background, |
| environment_map=args.environment_map, |
| ) |
|
|