import os import shutil import math from typing import Any, Type import bpy import lnx.assets from lnx import log, make from lnx.exporter import LeenkxExporter from lnx.logicnode.lnx_nodes import LnxLogicTreeNode import lnx.make_state as state import lnx.node_utils import lnx.utils if lnx.is_reload(__name__): lnx.assets = lnx.reload_module(lnx.assets) lnx.exporter = lnx.reload_module(lnx.exporter) from lnx.exporter import LeenkxExporter log = lnx.reload_module(log) lnx.logicnode.lnx_nodes = lnx.reload_module(lnx.logicnode.lnx_nodes) from lnx.logicnode.lnx_nodes import LnxLogicTreeNode make = lnx.reload_module(make) state = lnx.reload_module(state) lnx.node_utils = lnx.reload_module(lnx.node_utils) lnx.utils = lnx.reload_module(lnx.utils) else: lnx.enable_reload(__name__) patch_id = 0 """Current patch id""" __running = False """Whether live patch is currently active""" scene_cache = {} """Keys: (event_id, object_name) -> cached value tuple""" # Any object can act as a message bus owner msgbus_owner = object() def start(): """Start the live patch session.""" log.debug("Live patch session started") listen(bpy.types.Object, "location", "obj_location") listen(bpy.types.Object, "rotation_euler", "obj_rotation") listen(bpy.types.Object, "scale", "obj_scale") # 'energy' is defined in sub classes only, also workaround for # https://developer.blender.org/T88408 for light_type in (bpy.types.AreaLight, bpy.types.PointLight, bpy.types.SpotLight, bpy.types.SunLight): listen(light_type, "color", "light_color") listen(light_type, "energy", "light_energy") listen(light_type, "use_shadow", "light_shadow") listen(light_type, "lnx_clip_start", "light_near") listen(light_type, "lnx_clip_end", "light_far") listen(light_type, "lnx_fov", "light_fov") listen(light_type, "lnx_shadows_bias", "light_bias") listen(bpy.types.SpotLight, "spot_size", "light_spot_size") listen(bpy.types.SpotLight, "spot_blend", "light_spot_blend") listen(bpy.types.PointLight, "shadow_soft_size", "light_size") listen(bpy.types.SpotLight, "shadow_soft_size", "light_size") listen(bpy.types.AreaLight, "size", "light_area_size") listen(bpy.types.AreaLight, "size_y", "light_area_size") listen(bpy.types.Camera, "lens", "cam_props") listen(bpy.types.Camera, "sensor_width", "cam_props") listen(bpy.types.Camera, "clip_start", "cam_props") listen(bpy.types.Camera, "clip_end", "cam_props") listen(bpy.types.Camera, "lnx_frustum_culling", "cam_props") global __running __running = True def stop(): """Stop the live patch session.""" global __running, patch_id if __running: __running = False patch_id = 0 log.debug("Live patch session stopped") bpy.msgbus.clear_by_owner(msgbus_owner) def get_obj_transforms(obj): loc = (round(obj.location.x, 6), round(obj.location.y, 6), round(obj.location.z, 6)) rot = obj.rotation_euler.to_quaternion() rot = (round(rot.x, 6), round(rot.y, 6), round(rot.z, 6), round(rot.w, 6)) scale = (round(obj.scale.x, 6), round(obj.scale.y, 6), round(obj.scale.z, 6)) return loc, rot, scale def get_light_props(obj): light = obj.data props = { 'light_color': (round(light.color[0], 6), round(light.color[1], 6), round(light.color[2], 6)), 'light_energy': round(light.energy, 6), 'light_shadow': bool(light.use_shadow), 'light_near': round(light.lnx_clip_start, 6), 'light_far': round(light.lnx_clip_end, 6), 'light_fov': round(light.lnx_fov, 6), 'light_bias': round(light.lnx_shadows_bias * 0.0001, 6), } if light.type == 'SPOT': half_angle = light.spot_size * 0.5 outer_cos = math.cos(half_angle) blend = max(0.0, min(1.0, light.spot_blend)) inner_angle = math.atan(math.tan(half_angle) * (1.0 - blend)) props['light_spot_size'] = round(outer_cos, 6) props['light_spot_blend'] = round(max(0.0001, math.cos(inner_angle) - outer_cos), 6) if light.type in ('POINT', 'SPOT'): props['light_size'] = round(light.shadow_soft_size * 10, 6) if light.type == 'AREA': props['light_area_size'] = (round(light.size, 6), round(light.size_y, 6)) return props def get_camera_props(obj): cam = obj.data return { 'cam_props': ( round(cam.lens, 6), round(cam.sensor_width, 6), round(cam.clip_start, 6), round(cam.clip_end, 6), bool(cam.lnx_frustum_culling), ), } def get_world_clear_color(): scene = bpy.context.scene if scene.world is None: return (0.051, 0.051, 0.051, 1.0), 1.0 if scene.world.node_tree is None: c = scene.world.color return (round(c[0], 6), round(c[1], 6), round(c[2], 6), 1.0), 1.0 if 'Background' in scene.world.node_tree.nodes: bg = scene.world.node_tree.nodes['Background'] col = bg.inputs[0].default_value strength = bg.inputs[1].default_value r = max(min(col[0], 1.0), 0.0) g = max(min(col[1], 1.0), 0.0) b = max(min(col[2], 1.0), 0.0) a = max(min(col[3], 1.0), 0.0) return (round(r, 6), round(g, 6), round(b, 6), round(a, 6)), round(strength, 6) return (0.051, 0.051, 0.051, 1.0), 1.0 DEPSGRAPH_HANDLERS = { 'is_updated_transform': [ ('obj_location', lambda obj: get_obj_transforms(obj)[0]), ('obj_rotation', lambda obj: get_obj_transforms(obj)[1]), ('obj_scale', lambda obj: get_obj_transforms(obj)[2]), ], } def on_depsgraph_update(updates): if not __running: return False changed = False for update in updates: uid = update.id if isinstance(uid, bpy.types.Object): name = uid.name if update.is_updated_transform: for event_id, extractor in DEPSGRAPH_HANDLERS['is_updated_transform']: current = extractor(uid) cache_key = (event_id, name) if cache_key not in scene_cache: scene_cache[cache_key] = current elif current != scene_cache[cache_key]: scene_cache[cache_key] = current send_event(event_id, name) changed = True if update.is_updated_shading and isinstance(uid.data, bpy.types.Light): for event_id, current in get_light_props(uid).items(): cache_key = (event_id, uid.data.name) if cache_key not in scene_cache: scene_cache[cache_key] = current elif current != scene_cache[cache_key]: scene_cache[cache_key] = current send_event(event_id, name) changed = True if update.is_updated_shading and isinstance(uid.data, bpy.types.Camera): for event_id, current in get_camera_props(uid).items(): cache_key = (event_id, uid.data.name) if cache_key not in scene_cache: scene_cache[cache_key] = current elif current != scene_cache[cache_key]: scene_cache[cache_key] = current send_event(event_id, name) changed = True elif isinstance(uid, bpy.types.Scene): for obj in bpy.data.objects: visibility_key = ('obj_visibility', obj.name) current = not obj.hide_get() if visibility_key not in scene_cache: scene_cache[visibility_key] = current elif current != scene_cache[visibility_key]: scene_cache[visibility_key] = current send_event('obj_visibility', obj.name) changed = True elif isinstance(uid, bpy.types.World): clear_color_key = ('world_clear_color', uid.name) current = get_world_clear_color() if clear_color_key not in scene_cache: scene_cache[clear_color_key] = current elif current != scene_cache[clear_color_key]: scene_cache[clear_color_key] = current send_event('world_clear_color') changed = True return changed def patch_export(): """Re-export the current scene and update the game accordingly.""" if not __running or state.proc_build is not None: return lnx.assets.invalidate_enabled = False with lnx.utils.WorkingDir(lnx.utils.get_fp()): asset_path = lnx.utils.get_fp_build() + '/compiled/Assets/' + lnx.utils.safestr(bpy.context.scene.name) + '.lnx' LeenkxExporter.export_scene(bpy.context, asset_path, scene=bpy.context.scene) dir_std_shaders_dst = os.path.join(lnx.utils.build_dir(), 'compiled', 'Shaders', 'std') if not os.path.isdir(dir_std_shaders_dst): dir_std_shaders_src = os.path.join(lnx.utils.get_sdk_path(), 'leenkx', 'Shaders', 'std') shutil.copytree(dir_std_shaders_src, dir_std_shaders_dst) node_path = lnx.utils.get_node_path() khamake_path = lnx.utils.get_khamake_path() cmd = [ node_path, khamake_path, 'krom', '--shaderversion', '330', '--parallelAssetConversion', '4', '--to', lnx.utils.build_dir() + '/debug', '--nohaxe', '--noproject' ] lnx.assets.invalidate_enabled = True state.proc_build = make.run_proc(cmd, patch_done) def patch_done(): """Signal Iron to reload the running scene after a re-export.""" js = 'iron.Scene.patch();' write_patch(js) state.proc_build = None def write_patch(js: str): """Write the given javascript code to 'krom.patch'.""" global patch_id with open(lnx.utils.get_fp_build() + '/debug/krom/krom.patch', 'w', encoding='utf-8') as f: patch_id += 1 f.write(str(patch_id) + '\n') f.write(js) def listen(rna_type: Type[bpy.types.bpy_struct], prop: str, event_id: str): """Subscribe to '.'. The event_id can be choosen freely but must match with the id used in send_event(). """ bpy.msgbus.subscribe_rna( key=(rna_type, prop), owner=msgbus_owner, args=(event_id, ), notify=send_event # options={"PERSISTENT"} ) def send_event(event_id: str, opt_data: Any = None): """Send the result of the given event to Krom.""" if not __running: return obj_name = opt_data if isinstance(opt_data, str) else None if obj_name is not None: obj_data = bpy.data.objects.get(obj_name) elif hasattr(bpy.context, 'object') and bpy.context.object is not None: obj_data = bpy.context.object obj_name = obj_data.name else: obj_data = None if obj_data is not None and obj_data.mode == "OBJECT": if event_id == "obj_location": vec = obj_data.location js = f'var o = iron.Scene.active.getChild("{obj_name}"); if (o) {{ o.transform.loc.set({vec[0]}, {vec[1]}, {vec[2]}); o.transform.dirty = true; }}' write_patch(js) elif event_id == 'obj_scale': vec = obj_data.scale js = f'var o = iron.Scene.active.getChild("{obj_name}"); if (o) {{ o.transform.scale.set({vec[0]}, {vec[1]}, {vec[2]}); o.transform.dirty = true; }}' write_patch(js) elif event_id == 'obj_rotation': vec = obj_data.rotation_euler.to_quaternion() js = f'var o = iron.Scene.active.getChild("{obj_name}"); if (o) {{ o.transform.rot.set({vec[1]}, {vec[2]}, {vec[3]}, {vec[0]}); o.transform.dirty = true; }}' write_patch(js) elif event_id == 'light_color': light: bpy.types.Light = obj_data.data vec = light.color js = f'var lRaw = iron.Scene.active.getLight("{light.name}").data.raw; lRaw.color[0]={vec[0]}; lRaw.color[1]={vec[1]}; lRaw.color[2]={vec[2]};' write_patch(js) elif event_id == 'light_energy': light: bpy.types.Light = obj_data.data # Align strength to Leenkx, see exporter.export_light() # TODO: Use exporter.export_light() and simply reload all raw light data in Iron? strength_fac = 1.0 if light.type == 'SUN': strength_fac = 0.325 elif light.type in ('POINT', 'SPOT', 'AREA'): strength_fac = 0.01 js = f'var lRaw = iron.Scene.active.getLight("{light.name}").data.raw; lRaw.strength={light.energy * strength_fac};' write_patch(js) elif event_id == 'light_shadow': light: bpy.types.Light = obj_data.data js = f'var lRaw = iron.Scene.active.getLight("{light.name}").data.raw; lRaw.cast_shadow={"true" if light.use_shadow else "false"};' write_patch(js) elif event_id == 'light_near': light: bpy.types.Light = obj_data.data js = f'var lRaw = iron.Scene.active.getLight("{light.name}").data.raw; lRaw.near_plane={light.lnx_clip_start};' write_patch(js) elif event_id == 'light_far': light: bpy.types.Light = obj_data.data js = f'var lRaw = iron.Scene.active.getLight("{light.name}").data.raw; lRaw.far_plane={light.lnx_clip_end};' write_patch(js) elif event_id == 'light_fov': light: bpy.types.Light = obj_data.data js = f'var lRaw = iron.Scene.active.getLight("{light.name}").data.raw; lRaw.fov={light.lnx_fov};' write_patch(js) elif event_id == 'light_bias': light: bpy.types.Light = obj_data.data js = f'var lRaw = iron.Scene.active.getLight("{light.name}").data.raw; lRaw.shadows_bias={light.lnx_shadows_bias * 0.0001};' write_patch(js) elif event_id == 'light_spot_size': light: bpy.types.Light = obj_data.data half_angle = light.spot_size * 0.5 outer_cos = math.cos(half_angle) js = f'var lRaw = iron.Scene.active.getLight("{light.name}").data.raw; lRaw.spot_size={outer_cos};' write_patch(js) elif event_id == 'light_spot_blend': light: bpy.types.Light = obj_data.data half_angle = light.spot_size * 0.5 outer_cos = math.cos(half_angle) blend = max(0.0, min(1.0, light.spot_blend)) inner_angle = math.atan(math.tan(half_angle) * (1.0 - blend)) spot_blend = max(0.0001, math.cos(inner_angle) - outer_cos) js = f'var lRaw = iron.Scene.active.getLight("{light.name}").data.raw; lRaw.spot_blend={spot_blend};' write_patch(js) elif event_id == 'light_size': light: bpy.types.Light = obj_data.data js = f'var lRaw = iron.Scene.active.getLight("{light.name}").data.raw; lRaw.light_size={light.shadow_soft_size * 10};' write_patch(js) elif event_id == 'light_area_size': light: bpy.types.Light = obj_data.data js = f'var lRaw = iron.Scene.active.getLight("{light.name}").data.raw; lRaw.size={light.size}; lRaw.size_y={light.size_y};' write_patch(js) elif event_id == 'cam_props': cam: bpy.types.Camera = obj_data.data render = bpy.context.scene.render projection = obj_data.calc_matrix_camera( bpy.context.evaluated_depsgraph_get(), x=render.resolution_x, y=render.resolution_y, scale_x=render.pixel_aspect_x, scale_y=render.pixel_aspect_y) focal_y = projection[1][1] depth_z = projection[2][2] depth_w = projection[2][3] far_near_ratio = (depth_z - 1.0) / (depth_z + 1.0) fov = 2.0 * math.atan(1.0 / focal_y) near = (depth_w * (1.0 - far_near_ratio)) / (2.0 * far_near_ratio) far = far_near_ratio * near cam_obj = f'iron.Scene.active.getCamera("{cam.name}")' js = f'var c = {cam_obj}; var cRaw = c.data.raw; cRaw.fov={fov}; cRaw.near_plane={near}; cRaw.far_plane={far}; cRaw.frustum_culling={"true" if cam.lnx_frustum_culling else "false"}; c.buildProjection();' write_patch(js) elif event_id == 'obj_visibility': visible = not obj_data.hide_get() js = f'var o = iron.Scene.active.getChild("{obj_name}"); if (o) o.visible = {"true" if visible else "false"};' write_patch(js) elif obj_data is not None: patch_export() if event_id == 'world_clear_color': color, strength = get_world_clear_color() bg_int = (int(color[3] * 255) << 24) + (int(color[0] * 255) << 16) + (int(color[1] * 255) << 8) + int(color[2] * 255) js = f'var w = iron.Scene.active.world; if (w) {{ w.raw.background_color={bg_int}; if (w.probe) w.probe.raw.strength={strength}; }}' write_patch(js) if event_id == 'ln_insert_link': node: LnxLogicTreeNode link: bpy.types.NodeLink node, link = opt_data # This event is called twice for a connection but we only need # send it once if node == link.from_node: tree_name = lnx.node_utils.get_export_tree_name(node.get_tree()) # [1:] is used here because make_logic already uses that for # node names if lnx_debug is used from_node_name = lnx.node_utils.get_export_node_name(node)[1:] to_node_name = lnx.node_utils.get_export_node_name(link.to_node)[1:] from_index = lnx.node_utils.get_socket_index(node.outputs, link.from_socket) to_index = lnx.node_utils.get_socket_index(link.to_node.inputs, link.to_socket) js = f'LivePatch.patchCreateNodeLink("{tree_name}", "{from_node_name}", "{to_node_name}", "{from_index}", "{to_index}");' write_patch(js) elif event_id == 'ln_update_prop': node: LnxLogicTreeNode prop_name: str node, prop_name = opt_data tree_name = lnx.node_utils.get_export_tree_name(node.get_tree()) node_name = lnx.node_utils.get_export_node_name(node)[1:] value = lnx.node_utils.haxe_format_prop_value(node, prop_name) if prop_name.endswith('_get'): # Hack because some nodes use a different Python property # name than they use in Haxe prop_name = prop_name[:-4] js = f'LivePatch.patchUpdateNodeProp("{tree_name}", "{node_name}", "{prop_name}", {value});' write_patch(js) elif event_id == 'ln_socket_val': node: LnxLogicTreeNode socket: bpy.types.NodeSocket node, socket = opt_data socket_index = lnx.node_utils.get_socket_index(node.inputs, socket) if socket_index != -1: tree_name = lnx.node_utils.get_export_tree_name(node.get_tree()) node_name = lnx.node_utils.get_export_node_name(node)[1:] value = socket.get_default_value() inp_type = socket.lnx_socket_type if inp_type in ('VECTOR', 'RGB'): value = f'new iron.Vec4({lnx.node_utils.haxe_format_socket_val(value, array_outer_brackets=False)}, 1.0)' elif inp_type == 'RGBA': value = f'new iron.Vec4({lnx.node_utils.haxe_format_socket_val(value, array_outer_brackets=False)})' elif inp_type == 'ROTATION': value = f'new iron.Quat({lnx.node_utils.haxe_format_socket_val(value, array_outer_brackets=False)})' elif inp_type == 'OBJECT': value = f'iron.Scene.active.getChild("{value}")' if value != '' else 'null' else: value = lnx.node_utils.haxe_format_socket_val(value) js = f'LivePatch.patchUpdateNodeInputVal("{tree_name}", "{node_name}", {socket_index}, {value});' write_patch(js) elif event_id == 'ln_create': node: LnxLogicTreeNode = opt_data tree_name = lnx.node_utils.get_export_tree_name(node.get_tree()) node_name = lnx.node_utils.get_export_node_name(node)[1:] node_type = 'leenkx.logicnode.' + node.bl_idname[2:] prop_names = list(lnx.node_utils.get_haxe_property_names(node)) prop_py_names, prop_hx_names = zip(*prop_names) if len(prop_names) > 0 else ([], []) prop_values = (getattr(node, prop_name) for prop_name in prop_py_names) prop_datas = lnx.node_utils.haxe_format_socket_val(list(zip(prop_hx_names, prop_values))) inp_data = [(inp.lnx_socket_type, inp.get_default_value()) for inp in node.inputs] inp_data = lnx.node_utils.haxe_format_socket_val(inp_data) out_data = [(out.lnx_socket_type, out.get_default_value()) for out in node.outputs] out_data = lnx.node_utils.haxe_format_socket_val(out_data) js = f'LivePatch.patchNodeCreate("{tree_name}", "{node_name}", "{node_type}", {prop_datas}, {inp_data}, {out_data});' write_patch(js) elif event_id == 'ln_delete': node: LnxLogicTreeNode = opt_data tree_name = lnx.node_utils.get_export_tree_name(node.get_tree()) node_name = lnx.node_utils.get_export_node_name(node)[1:] js = f'LivePatch.patchNodeDelete("{tree_name}", "{node_name}");' write_patch(js) elif event_id == 'ln_copy': newnode: LnxLogicTreeNode node: LnxLogicTreeNode newnode, node = opt_data # Use newnode to get the tree, node has no id_data at this moment tree_name = lnx.node_utils.get_export_tree_name(newnode.get_tree()) newnode_name = lnx.node_utils.get_export_node_name(newnode)[1:] node_name = lnx.node_utils.get_export_node_name(node)[1:] props_list = '[' + ','.join(f'"{p}"' for _, p in lnx.node_utils.get_haxe_property_names(node)) + ']' inp_data = [(inp.lnx_socket_type, inp.get_default_value()) for inp in newnode.inputs] inp_data = lnx.node_utils.haxe_format_socket_val(inp_data) out_data = [(out.lnx_socket_type, out.get_default_value()) for out in newnode.outputs] out_data = lnx.node_utils.haxe_format_socket_val(out_data) js = f'LivePatch.patchNodeCopy("{tree_name}", "{node_name}", "{newnode_name}", {props_list}, {inp_data}, {out_data});' write_patch(js) elif event_id == 'ln_update_sockets': node: LnxLogicTreeNode = opt_data tree_name = lnx.node_utils.get_export_tree_name(node.get_tree()) node_name = lnx.node_utils.get_export_node_name(node)[1:] inp_data = '[' for idx, inp in enumerate(node.inputs): inp_data += '{' # is_linked can be true even if there are no links if the # user starts dragging a connection away before releasing # the mouse if inp.is_linked and len(inp.links) > 0: inp_data += 'isLinked: true,' inp_data += f'fromNode: "{lnx.node_utils.get_export_node_name(inp.links[0].from_node)[1:]}",' inp_data += f'fromIndex: {lnx.node_utils.get_socket_index(inp.links[0].from_node.outputs, inp.links[0].from_socket)},' else: inp_data += 'isLinked: false,' inp_data += f'socketType: "{inp.lnx_socket_type}",' inp_data += f'socketValue: {lnx.node_utils.haxe_format_socket_val(inp.get_default_value())},' inp_data += f'toIndex: {idx}' inp_data += '},' inp_data += ']' out_data = '[' for idx, out in enumerate(node.outputs): out_data += '[' for link in out.links: out_data += '{' if out.is_linked: out_data += 'isLinked: true,' out_data += f'toNode: "{lnx.node_utils.get_export_node_name(link.to_node)[1:]}",' out_data += f'toIndex: {lnx.node_utils.get_socket_index(link.to_node.inputs, link.to_socket)},' else: out_data += 'isLinked: false,' out_data += f'socketType: "{out.lnx_socket_type}",' out_data += f'socketValue: {lnx.node_utils.haxe_format_socket_val(out.get_default_value())},' out_data += f'fromIndex: {idx}' out_data += '},' out_data += '],' out_data += ']' js = f'LivePatch.patchSetNodeLinks("{tree_name}", "{node_name}", {inp_data}, {out_data});' write_patch(js) def on_operator(operator_id: str): """As long as bpy.msgbus doesn't listen to changes made by operators (*), additionally notify the callback manually. (*) https://developer.blender.org/T72109 """ if not __running: return if operator_id in IGNORE_OPERATORS: return if operator_id == 'TRANSFORM_OT_translate': send_event('obj_location') elif operator_id in ('TRANSFORM_OT_rotate', 'TRANSFORM_OT_trackball'): send_event('obj_rotation') elif operator_id == 'TRANSFORM_OT_resize': send_event('obj_scale') elif operator_id in ('OBJECT_OT_hide_view_set', 'OBJECT_OT_hide_view_clear', 'OBJECT_OT_visible_all', 'OUTLINER_OT_hide'): visible = operator_id != 'OBJECT_OT_hide_view_set' if hasattr(bpy.context, 'object') and bpy.context.object is not None: obj_name = bpy.context.object.name js = f'var o = iron.Scene.active.getChild("{obj_name}"); if (o) o.visible = {"true" if visible else "false"};' write_patch(js) # Rebuild else: patch_export() # Don't re-export the scene for the following operators IGNORE_OPERATORS = ( 'LNX_OT_node_add_input', 'LNX_OT_node_add_input_output', 'LNX_OT_node_add_input_value', 'LNX_OT_node_add_output', 'LNX_OT_node_call_func', 'LNX_OT_node_remove_input', 'LNX_OT_node_remove_input_output', 'LNX_OT_node_remove_input_value', 'LNX_OT_node_remove_output', 'LNX_OT_node_search', 'NODE_OT_delete', 'NODE_OT_duplicate_move', 'NODE_OT_hide_toggle', 'NODE_OT_link', 'NODE_OT_move_detach_links', 'NODE_OT_select', 'NODE_OT_translate_attach', 'NODE_OT_translate_attach_remove_on_cancel', 'OBJECT_OT_editmode_toggle', 'OUTLINER_OT_item_activate', 'UI_OT_button_string_clear', 'UI_OT_eyedropper_id', 'VIEW3D_OT_select', 'VIEW3D_OT_select_box', )