import bpy import json import math import mathutils import numpy as np class BpyRenderer: def __init__(self, resolution=512, engine="BLENDER_EEVEE", geo_mode=False, split_normal=False): """ engine: - "CYCLES" - "BLENDER_EEVEE" (Blender 3.x common) - "BLENDER_EEVEE_NEXT" (Blender 4.x common) - "EEVEE" / "EEVEE_NEXT" (aliases, optional) """ self.resolution = resolution self.engine = engine self.geo_mode = geo_mode self.split_normal = split_normal self.import_functions = self._setup_import_functions() def _setup_import_functions(self): import_functions = { "obj": bpy.ops.wm.obj_import, "glb": bpy.ops.import_scene.gltf, "gltf": bpy.ops.import_scene.gltf, "usd": bpy.ops.import_scene.usd, "fbx": bpy.ops.import_scene.fbx, "stl": bpy.ops.import_mesh.stl, "usda": bpy.ops.import_scene.usda, "dae": bpy.ops.wm.collada_import, "ply": bpy.ops.wm.ply_import, "abc": bpy.ops.wm.alembic_import, "blend": bpy.ops.wm.append, } return import_functions # ------------------------- # Engine helpers # ------------------------- def _resolve_render_engine(self, requested: str) -> str: """ Robustly set render engine across Blender versions. Blender 4.x may not accept "BLENDER_EEVEE" and instead uses "BLENDER_EEVEE_NEXT". """ req = (requested or "").upper() if req in {"EEVEE", "BLENDER_EEVEE"}: candidates = ["BLENDER_EEVEE", "BLENDER_EEVEE_NEXT"] elif req in {"EEVEE_NEXT", "BLENDER_EEVEE_NEXT"}: candidates = ["BLENDER_EEVEE_NEXT", "BLENDER_EEVEE"] elif req in {"CYCLES"}: candidates = ["CYCLES"] elif req in {"WORKBENCH", "BLENDER_WORKBENCH"}: candidates = ["BLENDER_WORKBENCH"] else: candidates = [requested] last_err = None for eng in candidates: try: bpy.context.scene.render.engine = eng return eng except Exception as e: last_err = e continue raise ValueError(f"Failed to set render engine from {candidates}. Last error: {last_err}") def _init_eevee_settings(self, render_samples: int = 64): """ EEVEE / EEVEE Next settings (close to huanngzh/bpy-renderer defaults). """ scene = bpy.context.scene # Render basics scene.render.image_settings.file_format = "PNG" scene.render.image_settings.color_mode = "RGBA" scene.render.film_transparent = True # EEVEE quality knobs # In Blender, eevee settings live under scene.eevee. # These fields are used by many scripts including bpy-renderer. :contentReference[oaicite:2]{index=2} if hasattr(scene, "eevee"): try: scene.eevee.taa_render_samples = int(render_samples) except Exception: pass # These flags may not exist in every minor version; guard them. for name, val in [ ("use_gtao", True), ("use_ssr", True), ("use_bloom", True), ]: if hasattr(scene.eevee, name): try: setattr(scene.eevee, name, val) except Exception: pass # Normals quality (also in bpy-renderer init) :contentReference[oaicite:3]{index=3} if hasattr(scene.render, "use_high_quality_normals"): try: scene.render.use_high_quality_normals = True except Exception: pass def _init_cycles_settings(self, render_samples: int = 128): scene = bpy.context.scene scene.render.image_settings.file_format = "PNG" scene.render.image_settings.color_mode = "RGBA" scene.render.film_transparent = True scene.cycles.samples = int(render_samples) scene.cycles.filter_type = "BOX" scene.cycles.filter_width = 1 scene.cycles.diffuse_bounces = 1 scene.cycles.glossy_bounces = 1 scene.cycles.transparent_max_bounces = (3 if not self.geo_mode else 0) scene.cycles.transmission_bounces = (3 if not self.geo_mode else 1) scene.cycles.use_denoising = True # GPU (best-effort) try: scene.cycles.device = "GPU" bpy.context.preferences.addons["cycles"].preferences.get_devices() bpy.context.preferences.addons["cycles"].preferences.compute_device_type = "CUDA" except Exception: pass # ------------------------- # Public init # ------------------------- def init_render_settings(self): # Resolution bpy.context.scene.render.resolution_x = self.resolution bpy.context.scene.render.resolution_y = self.resolution bpy.context.scene.render.resolution_percentage = 100 # Pick engine robustly (EEVEE vs EEVEE_NEXT etc.) actual_engine = self._resolve_render_engine(self.engine) # Samples: # - For geo_mode: keep minimal samples for speed # - For RGB: moderate samples if actual_engine == "CYCLES": samples = 128 if not self.geo_mode else 1 self._init_cycles_settings(render_samples=samples) else: # EEVEE family samples = 64 if not self.geo_mode else 1 self._init_eevee_settings(render_samples=samples) def init_scene(self): for obj in bpy.data.objects: 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 init_camera(self): cam = bpy.data.objects.new("Camera", bpy.data.cameras.new("Camera")) bpy.context.collection.objects.link(cam) bpy.context.scene.camera = cam cam.data.sensor_height = cam.data.sensor_width = 32 cam_constraint = cam.constraints.new(type="TRACK_TO") cam_constraint.track_axis = "TRACK_NEGATIVE_Z" cam_constraint.up_axis = "UP_Y" cam_empty = bpy.data.objects.new("Empty", None) cam_empty.location = (0, 0, 0) bpy.context.scene.collection.objects.link(cam_empty) cam_constraint.target = cam_empty return cam def init_lighting(self): bpy.ops.object.select_all(action="DESELECT") bpy.ops.object.select_by_type(type="LIGHT") bpy.ops.object.delete() default_light = bpy.data.objects.new("Default_Light", bpy.data.lights.new("Default_Light", type="POINT")) bpy.context.collection.objects.link(default_light) default_light.data.energy = 1000 default_light.location = (4, 1, 6) default_light.rotation_euler = (0, 0, 0) top_light = bpy.data.objects.new("Top_Light", bpy.data.lights.new("Top_Light", type="AREA")) bpy.context.collection.objects.link(top_light) top_light.data.energy = 10000 top_light.location = (0, 0, 10) top_light.scale = (100, 100, 100) bottom_light = bpy.data.objects.new("Bottom_Light", bpy.data.lights.new("Bottom_Light", type="AREA")) bpy.context.collection.objects.link(bottom_light) bottom_light.data.energy = 1000 bottom_light.location = (0, 0, -10) bottom_light.rotation_euler = (0, 0, 0) return {"default_light": default_light, "top_light": top_light, "bottom_light": bottom_light} def load_object(self, object_path): file_extension = object_path.split(".")[-1].lower() if file_extension not in self.import_functions: raise ValueError(f"Unsupported file type: {file_extension}") import_function = self.import_functions[file_extension] print(f"Loading object from {object_path}") if file_extension == "blend": import_function(directory=object_path, link=False) elif file_extension in {"glb", "gltf"}: import_function(filepath=object_path, merge_vertices=True, import_shading="NORMALS") else: import_function(filepath=object_path) def delete_invisible_objects(self): bpy.ops.object.select_all(action="DESELECT") for obj in bpy.context.scene.objects: if obj.hide_viewport or obj.hide_render: obj.hide_viewport = False obj.hide_render = False obj.hide_select = False obj.select_set(True) bpy.ops.object.delete() invisible_collections = [col for col in bpy.data.collections if col.hide_viewport] for col in invisible_collections: bpy.data.collections.remove(col) def split_mesh_normal(self): bpy.ops.object.select_all(action="DESELECT") objs = [obj for obj in bpy.context.scene.objects if obj.type == "MESH"] bpy.context.view_layer.objects.active = objs[0] for obj in objs: obj.select_set(True) bpy.ops.object.mode_set(mode="EDIT") bpy.ops.mesh.select_all(action="SELECT") bpy.ops.mesh.split_normals() bpy.ops.object.mode_set(mode="OBJECT") bpy.ops.object.select_all(action="DESELECT") def override_material(self): new_mat = bpy.data.materials.new(name="Override0123456789") new_mat.use_nodes = True new_mat.node_tree.nodes.clear() bsdf = new_mat.node_tree.nodes.new("ShaderNodeBsdfDiffuse") bsdf.inputs[0].default_value = (0.5, 0.5, 0.5, 1) bsdf.inputs[1].default_value = 1 output = new_mat.node_tree.nodes.new("ShaderNodeOutputMaterial") new_mat.node_tree.links.new(bsdf.outputs["BSDF"], output.inputs["Surface"]) bpy.context.scene.view_layers["View Layer"].material_override = new_mat def scene_bbox(self): bbox_min = (math.inf,) * 3 bbox_max = (-math.inf,) * 3 found = False scene_meshes = [obj for obj in bpy.context.scene.objects.values() if isinstance(obj.data, bpy.types.Mesh)] for obj in scene_meshes: found = True for coord in obj.bound_box: coord = mathutils.Vector(coord) 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 mathutils.Vector(bbox_min), mathutils.Vector(bbox_max) def normalize_scene(self): scene_root_objects = [obj for obj in bpy.context.scene.objects.values() if not obj.parent] if len(scene_root_objects) > 1: scene = bpy.data.objects.new("ParentEmpty", None) bpy.context.scene.collection.objects.link(scene) for obj in scene_root_objects: obj.parent = scene else: scene = scene_root_objects[0] bbox_min, bbox_max = self.scene_bbox() print(f"[INFO] Bounding box: {bbox_min}, {bbox_max}") scale = 1 / max(bbox_max - bbox_min) scene.scale = scene.scale * scale bpy.context.view_layer.update() bbox_min, bbox_max = self.scene_bbox() offset = -(bbox_min + bbox_max) / 2 scene.matrix_world.translation += offset bpy.ops.object.select_all(action="DESELECT") return scale, offset def set_camera_from_matrix(self, cam, transform_matrix): matrix = mathutils.Matrix(transform_matrix) cam.matrix_world = matrix bpy.context.view_layer.update() def render_from_transforms(self, file_path, transforms_json_path, output_path): with open(transforms_json_path, "r") as f: transforms_data = json.load(f) self.init_render_settings() # Load scene if file_path.endswith(".blend"): self.delete_invisible_objects() else: self.init_scene() self.load_object(file_path) if self.split_normal: self.split_mesh_normal() print("[INFO] Scene initialized.") scale, offset = self.normalize_scene() print(f"[INFO] Scene normalized with auto scale: {scale}, offset: {offset}") cam = self.init_camera() self.init_lighting() print("[INFO] Camera and lighting initialized.") if self.geo_mode: self.override_material() # NOTE: your transforms_json format seems like a list-of-dicts. transform_matrix = transforms_data[0]["transform_matrix"] camera_angle_x = transforms_data[0].get("camera_angle_x", None) self.set_camera_from_matrix(cam, transform_matrix) if camera_angle_x is not None: cam.data.lens = 16 / np.tan(camera_angle_x / 2) bpy.context.scene.render.filepath = output_path bpy.ops.render.render(write_still=True) bpy.context.view_layer.update() def render_from_transforms( file_path, transforms_json_path, output_path, resolution=512, engine="BLENDER_EEVEE", geo_mode=False, split_normal=False, ): renderer = BpyRenderer(resolution=resolution, engine=engine, geo_mode=geo_mode, split_normal=split_normal) return renderer.render_from_transforms(file_path, transforms_json_path, output_path) if __name__ == "__main__": file_path = "./assets/example.glb" transforms_json_path = "transforms.json" output_path = "./assets/img.png" # Recommended: # - engine="BLENDER_EEVEE" for Blender 3.x # - engine="BLENDER_EEVEE_NEXT" for Blender 4.x # This script auto-fallbacks between them. render_from_transforms( file_path=file_path, transforms_json_path=transforms_json_path, output_path=output_path, resolution=512, engine="BLENDER_EEVEE", ) # import bpy # import json # import math # import mathutils # import numpy as np # class BpyRenderer: # def __init__(self, resolution=512, engine="CYCLES", geo_mode=False, split_normal=False): # self.resolution = resolution # self.engine = engine # self.geo_mode = geo_mode # self.split_normal = split_normal # self.import_functions = self._setup_import_functions() # def _setup_import_functions(self): # import_functions = { # "obj": bpy.ops.wm.obj_import, # "glb": bpy.ops.import_scene.gltf, # "gltf": bpy.ops.import_scene.gltf, # "usd": bpy.ops.import_scene.usd, # "fbx": bpy.ops.import_scene.fbx, # "stl": bpy.ops.import_mesh.stl, # "usda": bpy.ops.import_scene.usda, # "dae": bpy.ops.wm.collada_import, # "ply": bpy.ops.wm.ply_import, # "abc": bpy.ops.wm.alembic_import, # "blend": bpy.ops.wm.append, # } # return import_functions # def init_render_settings(self): # bpy.context.scene.render.engine = self.engine # bpy.context.scene.render.resolution_x = self.resolution # bpy.context.scene.render.resolution_y = self.resolution # bpy.context.scene.render.resolution_percentage = 100 # bpy.context.scene.render.image_settings.file_format = "PNG" # bpy.context.scene.render.image_settings.color_mode = "RGBA" # bpy.context.scene.render.film_transparent = True # if self.engine == "CYCLES": # bpy.context.scene.render.engine = "CYCLES" # bpy.context.scene.cycles.samples = 128 if not self.geo_mode else 1 # bpy.context.scene.cycles.filter_type = "BOX" # bpy.context.scene.cycles.filter_width = 1 # bpy.context.scene.cycles.diffuse_bounces = 1 # bpy.context.scene.cycles.glossy_bounces = 1 # bpy.context.scene.cycles.transparent_max_bounces = (3 if not self.geo_mode else 0) # bpy.context.scene.cycles.transmission_bounces = (3 if not self.geo_mode else 1) # bpy.context.scene.cycles.use_denoising = True # try: # bpy.context.scene.cycles.device = "GPU" # bpy.context.preferences.addons["cycles"].preferences.get_devices() # bpy.context.preferences.addons["cycles"].preferences.compute_device_type = "CUDA" # except: # pass # def init_scene(self): # for obj in bpy.data.objects: # 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 init_camera(self): # cam = bpy.data.objects.new("Camera", bpy.data.cameras.new("Camera")) # bpy.context.collection.objects.link(cam) # bpy.context.scene.camera = cam # cam.data.sensor_height = cam.data.sensor_width = 32 # cam_constraint = cam.constraints.new(type="TRACK_TO") # cam_constraint.track_axis = "TRACK_NEGATIVE_Z" # cam_constraint.up_axis = "UP_Y" # cam_empty = bpy.data.objects.new("Empty", None) # cam_empty.location = (0, 0, 0) # bpy.context.scene.collection.objects.link(cam_empty) # cam_constraint.target = cam_empty # return cam # def init_lighting(self): # bpy.ops.object.select_all(action="DESELECT") # bpy.ops.object.select_by_type(type="LIGHT") # bpy.ops.object.delete() # default_light = bpy.data.objects.new("Default_Light", bpy.data.lights.new("Default_Light", type="POINT")) # bpy.context.collection.objects.link(default_light) # default_light.data.energy = 1000 # default_light.location = (4, 1, 6) # default_light.rotation_euler = (0, 0, 0) # top_light = bpy.data.objects.new("Top_Light", bpy.data.lights.new("Top_Light", type="AREA")) # bpy.context.collection.objects.link(top_light) # top_light.data.energy = 10000 # top_light.location = (0, 0, 10) # top_light.scale = (100, 100, 100) # bottom_light = bpy.data.objects.new("Bottom_Light", bpy.data.lights.new("Bottom_Light", type="AREA")) # bpy.context.collection.objects.link(bottom_light) # bottom_light.data.energy = 1000 # bottom_light.location = (0, 0, -10) # bottom_light.rotation_euler = (0, 0, 0) # return {"default_light": default_light, "top_light": top_light, "bottom_light": bottom_light} # def load_object(self, object_path): # file_extension = object_path.split(".")[-1].lower() # if file_extension not in self.import_functions: # raise ValueError(f"Unsupported file type: {file_extension}") # import_function = self.import_functions[file_extension] # print(f"Loading object from {object_path}") # if file_extension == "blend": # import_function(directory=object_path, link=False) # elif file_extension in {"glb", "gltf"}: # import_function(filepath=object_path, merge_vertices=True, import_shading="NORMALS") # else: # import_function(filepath=object_path) # def delete_invisible_objects(self): # bpy.ops.object.select_all(action="DESELECT") # for obj in bpy.context.scene.objects: # if obj.hide_viewport or obj.hide_render: # obj.hide_viewport = False # obj.hide_render = False # obj.hide_select = False # obj.select_set(True) # bpy.ops.object.delete() # invisible_collections = [col for col in bpy.data.collections if col.hide_viewport] # for col in invisible_collections: # bpy.data.collections.remove(col) # def unhide_all_objects(self): # for obj in bpy.context.scene.objects: # obj.hide_set(False) # def convert_to_meshes(self): # bpy.ops.object.select_all(action="DESELECT") # bpy.context.view_layer.objects.active = [obj for obj in bpy.context.scene.objects if obj.type == "MESH"][0] # for obj in bpy.context.scene.objects: # obj.select_set(True) # bpy.ops.object.convert(target="MESH") # def triangulate_meshes(self): # bpy.ops.object.select_all(action="DESELECT") # objs = [obj for obj in bpy.context.scene.objects if obj.type == "MESH"] # bpy.context.view_layer.objects.active = objs[0] # for obj in objs: # obj.select_set(True) # bpy.ops.object.mode_set(mode="EDIT") # bpy.ops.mesh.reveal() # bpy.ops.mesh.select_all(action="SELECT") # bpy.ops.mesh.quads_convert_to_tris(quad_method="BEAUTY", ngon_method="BEAUTY") # bpy.ops.object.mode_set(mode="OBJECT") # bpy.ops.object.select_all(action="DESELECT") # def split_mesh_normal(self): # bpy.ops.object.select_all(action="DESELECT") # objs = [obj for obj in bpy.context.scene.objects if obj.type == "MESH"] # bpy.context.view_layer.objects.active = objs[0] # for obj in objs: # obj.select_set(True) # bpy.ops.object.mode_set(mode="EDIT") # bpy.ops.mesh.select_all(action="SELECT") # bpy.ops.mesh.split_normals() # bpy.ops.object.mode_set(mode="OBJECT") # bpy.ops.object.select_all(action="DESELECT") # def override_material(self): # new_mat = bpy.data.materials.new(name="Override0123456789") # new_mat.use_nodes = True # new_mat.node_tree.nodes.clear() # bsdf = new_mat.node_tree.nodes.new("ShaderNodeBsdfDiffuse") # bsdf.inputs[0].default_value = (0.5, 0.5, 0.5, 1) # bsdf.inputs[1].default_value = 1 # output = new_mat.node_tree.nodes.new("ShaderNodeOutputMaterial") # new_mat.node_tree.links.new(bsdf.outputs["BSDF"], output.inputs["Surface"]) # bpy.context.scene.view_layers["View Layer"].material_override = new_mat # def scene_bbox(self): # bbox_min = (math.inf,) * 3 # bbox_max = (-math.inf,) * 3 # found = False # scene_meshes = [obj for obj in bpy.context.scene.objects.values() if isinstance(obj.data, bpy.types.Mesh)] # for obj in scene_meshes: # found = True # for coord in obj.bound_box: # coord = mathutils.Vector(coord) # 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 mathutils.Vector(bbox_min), mathutils.Vector(bbox_max) # def normalize_scene(self): # scene_root_objects = [obj for obj in bpy.context.scene.objects.values() if not obj.parent] # if len(scene_root_objects) > 1: # scene = bpy.data.objects.new("ParentEmpty", None) # bpy.context.scene.collection.objects.link(scene) # for obj in scene_root_objects: # obj.parent = scene # else: # scene = scene_root_objects[0] # bbox_min, bbox_max = self.scene_bbox() # print(f"[INFO] Bounding box: {bbox_min}, {bbox_max}") # scale = 1 / max(bbox_max - bbox_min) # scene.scale = scene.scale * scale # bpy.context.view_layer.update() # bbox_min, bbox_max = self.scene_bbox() # offset = -(bbox_min + bbox_max) / 2 # scene.matrix_world.translation += offset # bpy.ops.object.select_all(action="DESELECT") # return scale, offset # def set_camera_from_matrix(self, cam, transform_matrix): # matrix = mathutils.Matrix(transform_matrix) # cam.matrix_world = matrix # bpy.context.view_layer.update() # def render_from_transforms(self, file_path, transforms_json_path, output_path): # with open(transforms_json_path, 'r') as f: # transforms_data = json.load(f) # self.init_render_settings() # if file_path.endswith(".blend"): # self.delete_invisible_objects() # else: # self.init_scene() # self.load_object(file_path) # if self.split_normal: # self.split_mesh_normal() # print("[INFO] Scene initialized.") # scale, offset = self.normalize_scene() # print(f"[INFO] Scene normalized with auto scale: {scale}, offset: {offset}") # cam = self.init_camera() # self.init_lighting() # print("[INFO] Camera and lighting initialized.") # if self.geo_mode: # self.override_material() # transform_matrix = transforms_data[0]["transform_matrix"] # camera_angle_x = transforms_data[0]["camera_angle_x"] # self.set_camera_from_matrix(cam, transform_matrix) # if camera_angle_x is not None: # cam.data.lens = 16 / np.tan(camera_angle_x / 2) # bpy.context.scene.render.filepath = output_path # bpy.ops.render.render(write_still=True) # bpy.context.view_layer.update() # def render_from_transforms(file_path, transforms_json_path, output_path, resolution=512, engine="CYCLES", geo_mode=False, split_normal=False): # renderer = BpyRenderer(resolution=resolution, engine=engine, geo_mode=geo_mode, split_normal=split_normal) # return renderer.render_from_transforms(file_path, transforms_json_path, output_path) # if __name__ == "__main__": # file_path = "./assets/example.glb" # transforms_json_path = "transforms.json" # output_path = "./assets/img.png" # render_from_transforms(file_path=file_path, transforms_json_path=transforms_json_path, output_path=output_path)