diff --git a/bseq/__init__.py b/bseq/__init__.py index 08a8e37..4430bb4 100644 --- a/bseq/__init__.py +++ b/bseq/__init__.py @@ -3,7 +3,7 @@ from .properties import BSEQ_scene_property, BSEQ_obj_property, BSEQ_mesh_property from .panels import BSEQ_UL_Obj_List, BSEQ_List_Panel, BSEQ_Settings, BSEQ_PT_Import, BSEQ_PT_Import_Child1, BSEQ_PT_Import_Child2, BSEQ_Globals_Panel, BSEQ_Advanced_Panel, BSEQ_Templates, BSEQ_UL_Att_List, draw_template from .messenger import subscribe_to_selected, unsubscribe_to_selected -from .importer import update_obj +from .callback import load_obj from .globals import * import bpy @@ -12,10 +12,10 @@ @persistent def BSEQ_initialize(scene): - if update_obj not in bpy.app.handlers.frame_change_post: + if load_obj not in bpy.app.handlers.frame_change_post: # Insert at the beginning, so that it runs before other frame change handlers. # The other handlers don't need to be in the first position. - bpy.app.handlers.frame_change_post.insert(0, update_obj) + bpy.app.handlers.frame_change_post.insert(0, load_obj) if auto_refresh_active not in bpy.app.handlers.frame_change_post: bpy.app.handlers.frame_change_post.append(auto_refresh_active) if auto_refresh_all not in bpy.app.handlers.frame_change_post: diff --git a/bseq/callback.py b/bseq/callback.py index 079b702..9a7b3d0 100644 --- a/bseq/callback.py +++ b/bseq/callback.py @@ -1,6 +1,9 @@ import bpy import fileseq import traceback +import time +from .frame_buffer import init as framebuffer_init, terminate as framebuffer_terminate, queue_load as framebuffer_queue_load, flush_buffer as framebuffer_flush_buffer +from .importer import update_obj from .utils import show_message_box @@ -60,4 +63,17 @@ def poll_material(self, material): return not material.is_grease_pencil def poll_edit_obj(self, object): - return object.BSEQ.init \ No newline at end of file + return object.BSEQ.init + +def update_framebuffer(self, context) -> None: + if self.buffer_next_frame: + framebuffer_init() + else: + framebuffer_terminate() + +def load_obj(scene, depsgraph=None): + if scene.BSEQ.buffer_next_frame: + framebuffer_flush_buffer(scene, depsgraph) + framebuffer_queue_load(scene, depsgraph) + return + update_obj(scene, depsgraph) diff --git a/bseq/frame_buffer.py b/bseq/frame_buffer.py new file mode 100644 index 0000000..e117437 --- /dev/null +++ b/bseq/frame_buffer.py @@ -0,0 +1,172 @@ +import concurrent.futures +import time +import bpy +import meshio +from .importer import load_into_ram, update_obj, update_mesh, apply_transformation +from bpy.app.handlers import persistent + +_executor: concurrent.futures.ThreadPoolExecutor +_init = False + +# This needs to be persistent to keep the executor +@persistent +def init() -> None: + global _executor, _init + _executor = concurrent.futures.ThreadPoolExecutor(max_workers=5) + _executor.submit(pow, 2, 2) + _init = True + print("init") + +class Frame(): + _future: concurrent.futures.Future + _loading_threads: list[concurrent.futures.Future] + _buffer_meshes: dict[str, meshio.Mesh] + _buffer_data: dict[str, bpy.types.Mesh] + _buffer_timings: dict[str, float] + _frame: int = -1 + loading_complete: bool = False + + def __init__(self): + self._buffer_meshes = {} + self._buffer_data = {} + self._buffer_timings = {} + self._loading_threads = [] + + def _load_data_into_buffer(self, meshio_mesh, object: bpy.types.Object): + """ Applies the meshio data to a copy of the object mesh """ + start_time = time.perf_counter() + + buffer_data = object.data.copy() + update_mesh(meshio_mesh, buffer_data) + self._buffer_data[object.name_full] = buffer_data + + end_time = time.perf_counter() + print("update mesh() took ", (end_time - start_time) * 1000, " ms") + + def _load_buffer_to_data(self, object: bpy.types.Object, meshio_mesh, depsgraph): + """ Swaps the object mesh with the buffered mesh """ + if object.name_full in self._buffer_data: + old_mesh = object.data + object.data = self._buffer_data[object.name_full] + # We remove the old mesh data to prevent memory leaks + bpy.data.meshes.remove(old_mesh, do_unlink=False) + apply_transformation(meshio_mesh, object, depsgraph) + + def _obj_load(self, obj, scene, depsgraph): + """ Buffering Obj Job for the executor """ + start_time = time.perf_counter() + mesh = load_into_ram(obj, scene, depsgraph, target_frame=self._frame) + if isinstance(mesh, meshio.Mesh): + self._buffer_meshes[obj.name_full] = mesh + self._load_data_into_buffer(mesh, obj) + end_time = time.perf_counter() + # move to main threaded + self._buffer_timings[obj.name_full] = (end_time - start_time) * 1000 + + def _load_objs(self, scene, depsgraph): + """ Job which submits all object buffering jobs + Also updates the buffering status in the UI + """ + start = time.perf_counter() + self._frame = scene.frame_current + scene.frame_step + self._buffer_meshes = {} + self._buffer_data = {} + self._buffer_timings = {} + self._loading_threads = [] + n_loaded = 0 + for obj in bpy.data.objects: + future = _executor.submit(self._obj_load, obj, scene, depsgraph) + self._loading_threads.append(future) + for future in concurrent.futures.as_completed(self._loading_threads): + n_loaded += 1 + # Due to multithreading, Blender may forbid writing to the loading status while rendering + # In this case, we just skip the update + try: + scene.BSEQ.loading_status = f"{n_loaded}/{len(bpy.data.objects)}" + except Exception as e: + print("Skipped updating loading status") + + concurrent.futures.wait(self._loading_threads) + scene.BSEQ.loading_status = "Complete" + self._loading_threads.clear() + end = time.perf_counter() + #print("load_objs() took ", (end - start) * 1000, " ms") + + def _clear_buffer(self): + if not hasattr(self, "_buffer_meshes") or len(self._buffer_meshes) == 0: + print("buffer empty") + return + self._buffer_meshes.clear() + self._buffer_data.clear() + self._buffer_timings.clear() + + def flush_buffer(self, scene, depsgraph, *, target_frame: int = -1): + """ Applies the buffer to the scene and clears the buffer afterwards + + target_frame -- indicates the current frame which is to be loaded, + '-1' indicates do not use buffered frame + + If target_frame does not coincide with the buffered frame, the buffer will be + invalidated and the scene is loaded without pre-buffering + + """ + start_time = time.perf_counter() + #print() + # if no target_frame is specified or if the target_frame does not coincide + # with the buffered frame, invalidate the buffer and update the scene serially + if target_frame == -1 or self._frame != target_frame: + self._frame = -1 + update_obj(scene, depsgraph) + print("invalidate buffer") + for _, obj in self._buffer_data.items(): + bpy.data.meshes.remove(obj, do_unlink=False) + self._clear_buffer() + return + # Barrier to wait until loading is actually completed + concurrent.futures.wait([self._future]) + for obj in bpy.data.objects: + if obj.name_full in self._buffer_meshes: + self._load_buffer_to_data(obj, self._buffer_meshes[obj.name_full], depsgraph) + obj.BSEQ.last_benchmark = self._buffer_timings[obj.name_full] + + self._clear_buffer() + end_time = time.perf_counter() + #print("update_scene() took ", (end_time - start_time) * 1000, " ms") + + def queue_load(self, scene, depsgraph): + """ Queues the next frame which is determined by the current frame and the set frame step + + Also initialises the executor if not initialized already + """ + start = time.perf_counter() + global _executor, _init + if not _init: + init() + + self._frame = scene.frame_current + scene.frame_step + start_queue = time.perf_counter() + self._future = _executor.submit(self._load_objs, scene, depsgraph) + scene.BSEQ.loading_status = "Queued" + end = time.perf_counter() + #print("queue_load():\n\ttotal: ", (end - start) * 1000, "\n\tcopy: ", (copy_end - copy_start) * 1000, "\n\tqueuing:", (end - start_queue) * 1000) + +_frame = Frame() + +def queue_load(scene, depsgraph=None) -> None: + start_time = time.perf_counter() + _frame.queue_load(scene, depsgraph) + #print("queue_load() took ", (time.perf_counter() - start_time) * 1000, " ms") + +def flush_buffer(scene, depsgraph) -> None: + _frame.flush_buffer(scene, depsgraph, target_frame=scene.frame_current) + +def terminate() -> None: + global _init + if not _init: + return + _executor.shutdown(wait=False, cancel_futures=True) + _init = False + for _, obj in _frame._buffer_data.items(): + bpy.data.meshes.remove(obj, do_unlink=False) + _frame._clear_buffer() + print("terminated") \ No newline at end of file diff --git a/bseq/importer.py b/bseq/importer.py index 523b6f7..cfa460a 100644 --- a/bseq/importer.py +++ b/bseq/importer.py @@ -10,6 +10,7 @@ import time # this import is not useless from .additional_file_formats import * +from typing import Optional def extract_edges(cell: meshio.CellBlock): if cell.type == "line": @@ -172,7 +173,6 @@ def update_mesh(meshio_mesh, mesh): mesh.polygons.foreach_set("loop_start", faces_loop_start) mesh.polygons.foreach_set("loop_total", faces_loop_total) mesh.polygons.foreach_set("use_smooth", [shade_scheme] * len(faces_loop_total)) - # newer function but is about 4 times slower # mesh.clear_geometry() # mesh.from_pydata(mesh_vertices, edge_data, face_data) @@ -193,6 +193,7 @@ def update_mesh(meshio_mesh, mesh): k = "bseq_" + k attribute = create_or_retrieve_attribute(mesh, k, v) if attribute is None: + continue name_string = None if attribute.data_type == "FLOAT": @@ -283,80 +284,94 @@ def create_obj(fileseq, use_relative, root_path, transform_matrix=Matrix.Identit bpy.ops.object.select_all(action="DESELECT") bpy.context.view_layer.objects.active = object -def update_obj(scene, depsgraph=None): - for obj in bpy.data.objects: - start_time = time.perf_counter() - - if obj.BSEQ.init == False: - continue - if obj.BSEQ.enabled == False: - continue - if obj.mode != "OBJECT": - continue - if depsgraph is not None: - current_frame = obj.evaluated_get(depsgraph).BSEQ.frame - else: - show_message_box("Warning: Might not be able load the correct frame because the dependency graph is not available.", "BSEQ Warning") - current_frame = obj.BSEQ.frame - meshio_mesh = None - - # in case the blender file was created on windows system, but opened in linux system - full_path = get_absolute_path(obj, scene) +def load_into_ram(obj, scene, depsgraph, *, target_frame = -1) -> Optional[meshio.Mesh]: + if obj.BSEQ.init == False: + return None + if obj.BSEQ.enabled == False: + return None + if obj.mode != "OBJECT": + return None - fs = fileseq.FileSequence(full_path) + if target_frame != -1: + current_frame = target_frame + elif depsgraph is not None: + current_frame = obj.evaluated_get(depsgraph).BSEQ.frame + else: + show_message_box("Warning: Might not be able load the correct frame because the dependency graph is not available.", "BSEQ Warning") + current_frame = obj.BSEQ.frame + meshio_mesh = None - if obj.BSEQ.use_advance and obj.BSEQ.script_name: - script = bpy.data.texts[obj.BSEQ.script_name] - try: - exec(script.as_string()) - except Exception as e: - show_message_box(traceback.format_exc(), "running script: " + obj.BSEQ.script_name + " failed: " + str(e), - "ERROR") - continue - - if 'process' in locals(): - user_process = locals()['process'] - try: - user_process(fs, current_frame, obj.data) - obj.BSEQ.current_file = "Controlled by user process" - except Exception as e: - show_message_box("Error when calling user process: " + traceback.format_exc(), icon="ERROR") - del locals()['process'] - # this continue means if process exist, all the remaining code will be ignored, whethere or not error occurs - continue - - elif 'preprocess' in locals(): - user_preprocess = locals()['preprocess'] - try: - meshio_mesh = user_preprocess(fs, current_frame) - obj.BSEQ.current_file = "Controlled by user preprocess" - except Exception as e: - show_message_box("Error when calling user preprocess: " + traceback.format_exc(), icon="ERROR") - # this continue means only if error occures, then goes to next bpy.object - continue - finally: - del locals()['preprocess'] - else: - if obj.BSEQ.match_frames: - fs_frames = fs.frameSet() - if current_frame in fs_frames: - filepath = fs[fs_frames.index(current_frame)] - filepath = os.path.normpath(filepath) - meshio_mesh = load_meshio_from_path(fs, filepath, obj) - else: - meshio_mesh = meshio.Mesh([], []) - else: - filepath = fs[current_frame % len(fs)] + # in case the blender file was created on windows system, but opened in linux system + full_path = get_absolute_path(obj, scene) + + fs = fileseq.FileSequence(full_path) + + if obj.BSEQ.use_advance and obj.BSEQ.script_name: + script = bpy.data.texts[obj.BSEQ.script_name] + try: + exec(script.as_string()) + except Exception as e: + show_message_box(traceback.format_exc(), "running script: " + obj.BSEQ.script_name + " failed: " + str(e), + "ERROR") + return meshio_mesh + + if 'process' in locals(): + user_process = locals()['process'] + try: + user_process(fs, current_frame, obj.data) + obj.BSEQ.current_file = "Controlled by user process" + except Exception as e: + show_message_box("Error when calling user process: " + traceback.format_exc(), icon="ERROR") + del locals()['process'] + # this continue means if process exist, all the remaining code will be ignored, whethere or not error occurs + return meshio_mesh + + elif 'preprocess' in locals(): + user_preprocess = locals()['preprocess'] + try: + meshio_mesh = user_preprocess(fs, current_frame) + obj.BSEQ.current_file = "Controlled by user preprocess" + except Exception as e: + show_message_box("Error when calling user preprocess: " + traceback.format_exc(), icon="ERROR") + # this continue means only if error occures, then goes to next bpy.object + return meshio_mesh + finally: + del locals()['preprocess'] + else: + if obj.BSEQ.match_frames: + fs_frames = fs.frameSet() + if current_frame in fs_frames: + filepath = fs[fs_frames.index(current_frame)] filepath = os.path.normpath(filepath) meshio_mesh = load_meshio_from_path(fs, filepath, obj) + else: + meshio_mesh = meshio.Mesh([], []) + else: + filepath = fs[current_frame % len(fs)] + filepath = os.path.normpath(filepath) + meshio_mesh = load_meshio_from_path(fs, filepath, obj) - if not isinstance(meshio_mesh, meshio.Mesh): - show_message_box('function preprocess does not return meshio object', "ERROR") - continue - update_mesh(meshio_mesh, obj.data) + if not isinstance(meshio_mesh, meshio.Mesh): + show_message_box('function preprocess does not return meshio object', "ERROR") + return None + + return meshio_mesh - apply_transformation(meshio_mesh, obj, depsgraph) +def update_scene(obj, meshio_mesh, scene, depsgraph): + update_mesh(meshio_mesh, obj.data) + + apply_transformation(meshio_mesh, obj, depsgraph) + +def update_obj(scene, depsgraph=None): + for obj in bpy.data.objects: + start_time = time.perf_counter() + + mesh = load_into_ram(obj, scene, depsgraph) + if not isinstance(mesh, meshio.Mesh): + continue + update_scene(obj, mesh, scene, depsgraph) end_time = time.perf_counter() obj.BSEQ.last_benchmark = (end_time - start_time) * 1000 + # print("update_obj(): ", obj.BSEQ.last_benchmark) \ No newline at end of file diff --git a/bseq/load_obj.py b/bseq/load_obj.py new file mode 100644 index 0000000..e69de29 diff --git a/bseq/panels.py b/bseq/panels.py index c70cdd2..5be9133 100644 --- a/bseq/panels.py +++ b/bseq/panels.py @@ -92,6 +92,14 @@ def draw(self, context): col2.prop(sim_loader, "auto_refresh_active", text="") col1.label(text="Auto Refresh All") col2.prop(sim_loader, "auto_refresh_all", text="") + col1.label(text="Buffer Next Frame") + col2.prop(sim_loader, "buffer_next_frame", text="") + if sim_loader.buffer_next_frame: + col1.label(text="Loading Status") + row2 = col2.row() + row2.enabled = False + row2.prop(sim_loader, "loading_status", text="") + class BSEQ_Advanced_Panel(BSEQ_Panel, bpy.types.Panel): bl_label = "Advanced Settings" diff --git a/bseq/properties.py b/bseq/properties.py index dd93647..82fed5a 100644 --- a/bseq/properties.py +++ b/bseq/properties.py @@ -79,6 +79,14 @@ class BSEQ_scene_property(bpy.types.PropertyGroup): description="Auto refresh all sequences every frame", default=False, ) + + buffer_next_frame: bpy.props.BoolProperty(name='Buffer next frame while rendering', + description="Starts loading the next sequence frame into the RAM while rendering the current frame", + default=False, + update=update_framebuffer + ) + + loading_status: bpy.props.StringProperty(default="") use_custom_transform: bpy.props.BoolProperty(name='Custom Transform', description="Use a custom transformation matrix when importing", diff --git a/bseq/utils.py b/bseq/utils.py index 161542c..f4273eb 100644 --- a/bseq/utils.py +++ b/bseq/utils.py @@ -67,7 +67,12 @@ def load_meshio_from_path(fileseq, filepath, obj = None): try: meshio_mesh = meshio.read(filepath) if obj is not None: - obj.BSEQ.current_file = filepath + # While multithreading, this may raise an exception during rendering + # Since current_file seems not to be used elsewhere, we skip it when an exception occurs + try: + obj.BSEQ.current_file = filepath + except Exception as e: + print("Current file not updated") except Exception as e: show_message_box("Error when reading: " + filepath + ",\n" + traceback.format_exc(), "Meshio Loading Error" + str(e),