diff --git a/leenkx/blender/lnx/exporter.py b/leenkx/blender/lnx/exporter.py index bd7c0b6..e4077fe 100644 --- a/leenkx/blender/lnx/exporter.py +++ b/leenkx/blender/lnx/exporter.py @@ -610,19 +610,30 @@ class LeenkxExporter: return None def write_bone_matrices(self, scene, action): - # profile_time = time.time() begin_frame, end_frame = int(action.frame_range[0]), int(action.frame_range[1]) if len(self.bone_tracks) > 0: - for i in range(begin_frame, end_frame + 1): - scene.frame_set(i) - for track in self.bone_tracks: - values, pose_bone = track[0], track[1] - parent = pose_bone.parent - if parent: - values += LeenkxExporter.write_matrix((parent.matrix.inverted_safe() @ pose_bone.matrix)) - else: - values += LeenkxExporter.write_matrix(pose_bone.matrix) - # print('Bone matrices exported in ' + str(time.time() - profile_time)) + hidden_states = {} + try: + for obj in list(scene.collection.all_objects): + if obj is not None and obj.type != 'ARMATURE' and not obj.hide_viewport: + hidden_states[obj] = False + obj.hide_viewport = True + + for i in range(begin_frame, end_frame + 1): + scene.frame_set(i) + for track in self.bone_tracks: + values, pose_bone = track[0], track[1] + parent = pose_bone.parent + if parent: + values += LeenkxExporter.write_matrix((parent.matrix.inverted_safe() @ pose_bone.matrix)) + else: + values += LeenkxExporter.write_matrix(pose_bone.matrix) + finally: + for obj, was_hidden in hidden_states.items(): + try: + obj.hide_viewport = was_hidden + except ReferenceError: + pass @staticmethod def has_baked_material(bobject, materials): @@ -849,23 +860,36 @@ class LeenkxExporter: out_object['tilesheet_ref'] = bobject.lnx_tilesheet out_object['tilesheet_action_ref'] = bobject.lnx_tilesheet_action - - if len(bobject.vertex_groups) > 0: + if len(bobject.vertex_groups) > 0 and bobject.data is not None and len(bobject.data.vertices) > 0: out_object['vertex_groups'] = [] + _verts = bobject.data.vertices + _num_verts = len(_verts) + _all_co = np.empty(_num_verts * 3, dtype=' 0: + _values = [] + for vi in indices: + base = vi * 3 + _values.append(_all_str[base]) + _values.append(_all_str[base + 1]) + _values.append(_all_str[base + 2]) + else: + _values = [] + out_object['vertex_groups'].append({ 'name': group.name, - 'value': verts - } - out_object['vertex_groups'].append(out_vertex_groups) + 'value': _values + }) if len(bobject.lnx_camera_list) > 0: out_camera_list = [] @@ -1114,13 +1138,27 @@ class LeenkxExporter: _o.select_set(False) skelobj.select_set(True) - bake_result = bpy.ops.nla.bake( - frame_start=int(action.frame_range[0]), - frame_end=int(action.frame_range[1]), - step=1, - only_selected=False, - visual_keying=True - ) + _bake_hidden = {} + try: + for _obj in list(bpy.context.scene.collection.all_objects): + if _obj is not None and _obj.type != 'ARMATURE' and not _obj.hide_viewport: + _bake_hidden[_obj] = False + _obj.hide_viewport = True + + bake_result = bpy.ops.nla.bake( + frame_start=int(action.frame_range[0]), + frame_end=int(action.frame_range[1]), + step=1, + only_selected=False, + visual_keying=True + ) + finally: + for _obj, _was_hidden in _bake_hidden.items(): + try: + _obj.hide_viewport = _was_hidden + except ReferenceError: + pass + action = skelobj.animation_data.action skelobj.select_set(False) @@ -1225,44 +1263,67 @@ class LeenkxExporter: else: group_remap.append(-1) - bone_count_array = np.empty(len(export_mesh.loops), dtype='= 0: #and bone_weight != 0.0: - bone_values.append((bone_weight, bone_index)) - total_weight += bone_weight - bone_count += 1 + total_weight = 0.0 + for gi in range(n): + g = groups[gi] + bone_index = _group_remap[g.group] + if bone_index >= 0: + bone_values.append((g.weight, bone_index)) + total_weight += g.weight + + bone_count = len(bone_values) + if bone_count == 0: + continue if bone_count > 4: - bone_count = 4 bone_values.sort(reverse=True) bone_values = bone_values[:4] - - bone_count_array[index] = bone_count - for bv in bone_values: - bone_weight_array[count] = bv[0] - bone_index_array[count] = bv[1] - count += 1 + bone_count = 4 + total_weight = sum(bv[0] for bv in bone_values) if total_weight not in (0.0, 1.0): normalizer = 1.0 / total_weight - for i in range(bone_count): - bone_weight_array[count - i - 1] *= normalizer + bone_values = [(bv[0] * normalizer, bv[1]) for bv in bone_values] + + vert_bone_count[vi] = bone_count + for j in range(bone_count): + vert_bone_weight[vi, j] = bone_values[j][0] + vert_bone_index[vi, j] = bone_values[j][1] + + bone_count_array = vert_bone_count[_loop_vidx] + + loop_bone_weight = vert_bone_weight[_loop_vidx] + loop_bone_index = vert_bone_index[_loop_vidx] + loop_bone_count = bone_count_array + + slot_idx = np.arange(4, dtype=' maxdim: - maxdim = abs(v.uv[0]) - if abs(v.uv[1]) > maxdim: - maxdim = abs(v.uv[1]) + _uv0_raw = np.empty(len(lay0.data) * 2, dtype=' 0 else 0.0 + if _uv0_absmax > maxdim: + maxdim = _uv0_absmax if has_tex1: lay1 = uv_layers[t1map] - for v in lay1.data: - if abs(v.uv[0]) > maxdim: - maxdim = abs(v.uv[0]) - maxdim_uvlayer = lay1 - if abs(v.uv[1]) > maxdim: - maxdim = abs(v.uv[1]) - maxdim_uvlayer = lay1 + _uv1_raw = np.empty(len(lay1.data) * 2, dtype=' 0 else 0.0 + if _uv1_absmax > maxdim: + maxdim = _uv1_absmax + maxdim_uvlayer = lay1 if has_morph_target: morph_data = np.empty(num_verts * 2, dtype=' maxdim: - maxdim = abs(v.uv[0]) - maxdim_uvlayer = lay2 - if abs(v.uv[1]) > maxdim: - maxdim = abs(v.uv[1]) - maxdim_uvlayer = lay2 + _uv2_raw = np.empty(len(lay2.data) * 2, dtype=' 0 else 0.0 + if _uv2_absmax > maxdim: + maxdim = _uv2_absmax + maxdim_uvlayer = lay2 if maxdim > 1: o['scale_tex'] = maxdim invscale_tex = (1 / o['scale_tex']) * 32767 @@ -1636,102 +1703,87 @@ class LeenkxExporter: invscale_pos = (1 / scale_pos) * 32767 verts = export_mesh.vertices + + _all_co = np.empty(len(verts) * 3, dtype=' 0: + _tri_loops = np.empty(num_tris * 3, dtype=' 1: - for i in range(len(mats)): # Multi-mat mesh - if mats[i] == mats[index]: # Default material for empty slots - ia['material'] = i - break - o['index_arrays'].append(ia) + _tri_loops_2d = _tri_loops.reshape(-1, 3) + + for mat_idx in range(max(len(mats), 1)): + mask = (_tri_mat_idx == mat_idx) + num_mat_tris = int(np.count_nonzero(mask)) + if num_mat_tris == 0: + continue + + mat_tri_loops = _tri_loops_2d[mask].flatten().astype(' 1: + for i in range(len(mats)): # Multi-mat mesh + if mats[i] == mats[mat_idx]: # Default material for empty slots + ia['material'] = i + break + o['index_arrays'].append(ia) # Pack pdata *= invscale_pos @@ -1821,7 +1873,6 @@ class LeenkxExporter: def export_mesh(self, object_ref): """Exports a single mesh object.""" - # profile_time = time.time() table = object_ref[1]["objectTable"] bobject = table[0] oid = lnx.utils.safestr(object_ref[1]["structName"]) @@ -1921,7 +1972,6 @@ class LeenkxExporter: out_mesh['dynamic_usage'] = bobject.data.lnx_dynamic_usage self.write_mesh(bobject, fp, out_mesh) - # print('Mesh exported in ' + str(time.time() - profile_time)) if hasattr(bobject, 'evaluated_get'): bobject_eval.to_mesh_clear() @@ -2680,13 +2730,12 @@ class LeenkxExporter: } # Set viewport camera projection - if is_viewport_camera: - proj, is_persp = self.get_viewport_projection_matrix() - if proj is not None: - if is_persp: - self.extract_projection(out_camera, proj, with_planes=False) - else: - self.extract_ortho(out_camera, proj) + proj, is_persp = self.get_viewport_projection_matrix() + if proj is not None: + if is_persp: + self.extract_projection(out_camera, proj, with_planes=False) + else: + self.extract_ortho(out_camera, proj) self.output['camera_datas'].append(out_camera) out_object = { diff --git a/leenkx/blender/lnx/handlers.py b/leenkx/blender/lnx/handlers.py index 04af3e5..11ddbce 100644 --- a/leenkx/blender/lnx/handlers.py +++ b/leenkx/blender/lnx/handlers.py @@ -43,6 +43,8 @@ _last_render_engine = None def on_depsgraph_update_post(self): if state.proc_build is not None: return + if state.is_exporting: + return # Recache depsgraph = bpy.context.evaluated_depsgraph_get() diff --git a/leenkx/blender/lnx/lib/server.py b/leenkx/blender/lnx/lib/server.py index b6f43f0..f6991d7 100644 --- a/leenkx/blender/lnx/lib/server.py +++ b/leenkx/blender/lnx/lib/server.py @@ -1,19 +1,75 @@ import atexit +import gzip import http.server +import io +import os import socketserver import subprocess haxe_server = None +_GZIP_MIN_SIZE = 1400 + +_COMPRESSIBLE = { + 'text/html', 'text/css', 'text/javascript', 'text/plain', + 'application/javascript', 'application/json', 'application/xml', + 'application/wasm', 'image/svg+xml', +} + def run_tcp(port: int, do_log: bool): class HTTPRequestHandler(http.server.SimpleHTTPRequestHandler): + protocol_version = "HTTP/1.1" + def log_message(self, format, *args): if do_log: print(format % args) + def end_headers(self): + self.send_header('Access-Control-Allow-Origin', '*') + self.send_header('Cache-Control', 'no-cache') + super().end_headers() + + def do_GET(self): + ae = self.headers.get('Accept-Encoding', '') + if 'gzip' not in ae: + return super().do_GET() + + fpath = self.translate_path(self.path) + if os.path.isdir(fpath): + return super().do_GET() + + try: + ctype = self.guess_type(fpath) + if ctype.split(';')[0].strip() not in _COMPRESSIBLE: + return super().do_GET() + fsize = os.path.getsize(fpath) + if fsize < _GZIP_MIN_SIZE: + return super().do_GET() + + with open(fpath, 'rb') as f: + raw = f.read() + + buf = io.BytesIO() + with gzip.GzipFile(fileobj=buf, mode='wb', compresslevel=1) as gz: + gz.write(raw) + compressed = buf.getvalue() + + self.send_response(200) + self.send_header('Content-Type', ctype) + self.send_header('Content-Length', str(len(compressed))) + self.send_header('Content-Encoding', 'gzip') + self.end_headers() + self.wfile.write(compressed) + except (FileNotFoundError, PermissionError): + self.send_error(404) + + class ThreadedHTTPServer(socketserver.ThreadingTCPServer): + allow_reuse_address = True + daemon_threads = True + try: - http_server = socketserver.TCPServer(("", port), HTTPRequestHandler) + http_server = ThreadedHTTPServer(("", port), HTTPRequestHandler) http_server.serve_forever() except: print("Server already running") diff --git a/leenkx/blender/lnx/lightmapper/utility/encoding.py b/leenkx/blender/lnx/lightmapper/utility/encoding.py index 58545e0..1fa6de3 100644 --- a/leenkx/blender/lnx/lightmapper/utility/encoding.py +++ b/leenkx/blender/lnx/lightmapper/utility/encoding.py @@ -5,7 +5,7 @@ from fractions import Fraction from gpu_extras.batch import batch_for_shader if bpy.app.version < (4, 0, 0): - import bgl + import bgl def splitLogLuvAlphaAtlas(imageIn, outDir, quality): pass diff --git a/leenkx/blender/lnx/lightmapper/utility/gui/Viewport.py b/leenkx/blender/lnx/lightmapper/utility/gui/Viewport.py index 6a8663c..b77f446 100644 --- a/leenkx/blender/lnx/lightmapper/utility/gui/Viewport.py +++ b/leenkx/blender/lnx/lightmapper/utility/gui/Viewport.py @@ -2,8 +2,7 @@ import bpy, blf, os, gpu from gpu_extras.batch import batch_for_shader if bpy.app.version < (4, 0, 0): - import bgl - + import bgl class ViewportDraw: def __init__(self, context, text): diff --git a/leenkx/blender/lnx/make.py b/leenkx/blender/lnx/make.py index 13bb8c3..9a14849 100644 --- a/leenkx/blender/lnx/make.py +++ b/leenkx/blender/lnx/make.py @@ -9,6 +9,7 @@ import shutil import stat from string import Template import subprocess +import tempfile import threading import time import traceback @@ -181,6 +182,14 @@ def clear_external_scenes(): appended_scenes = [] def export_data(fp, sdk_path): + state.is_exporting = True + try: + export_data_impl(fp, sdk_path) + finally: + state.is_exporting = False + + +def export_data_impl(fp, sdk_path): load_external_blends() wrd = bpy.data.worlds['Lnx'] @@ -316,7 +325,11 @@ def export_data(fp, sdk_path): shaders_path = build_dir + '/compiled/Shaders' if not os.path.exists(shaders_path): os.makedirs(shaders_path) - write_data.write_compiledglsl(defs + cdefs, make_variants=has_config) + inc_changed = write_data.write_compiledglsl(defs + cdefs, make_variants=has_config) + + if inc_changed: + for g in glob.glob(shaders_path + '/*.glsl'): + os.utime(g, None) # Write referenced shader passes if not os.path.isfile(build_dir + '/compiled/Shaders/shader_datas.lnx') or state.last_world_defs != wrd.world_defs: @@ -625,7 +638,7 @@ def _kill_all_viewport_processes(): for viewport_id in list(_viewport_processes.keys()): _kill_viewport_process(viewport_id) - + if lnx.utils.get_os() == 'win': try: result = subprocess.run( @@ -643,20 +656,20 @@ def _kill_all_viewport_processes(): def run_viewport_runtime(viewport_id, width=1920, height=1080): """Launch a viewport which gets its own Krom process with unique shared memory.""" global _viewport_processes - + if 'Lnx' not in bpy.data.worlds: log.warn('No Lnx world found - cannot start viewport server') return None, None - + _kill_viewport_process(viewport_id) - + shmem_name = _get_viewport_shmem_name(viewport_id) wrd = bpy.data.worlds['Lnx'] krom_location, krom_path = lnx.utils.krom_paths() path = lnx.utils.get_fp_build() + '/debug/krom' path_resources = path + '-resources' - + if not os.path.exists(path + '/krom.js'): log.warn(f'Krom build not found at {path}/krom.js - build project first') return None, None @@ -669,7 +682,7 @@ def run_viewport_runtime(viewport_id, width=1920, height=1080): cmd.append(str(os.getpid())) if wrd.lnx_audio == 'Disabled': cmd.append('--nosound') - + cmd.append('--viewport-server') cmd.append('--shmem') cmd.append(shmem_name) @@ -703,13 +716,13 @@ def build_viewport(viewport_id, width=1920, height=1080): if not wrd: log.warn('No Lnx world found - cannot build for viewport') return - + krom_js_path = lnx.utils.get_fp_build() + '/debug/krom/krom.js' if os.path.exists(krom_js_path) and not wrd.lnx_recompile: log.info(f'Using cached viewport build for {viewport_id}') run_viewport_runtime(viewport_id, width, height) return - + pending_entry = (viewport_id, width, height) if pending_entry not in _viewport_pending_launches: _viewport_pending_launches.append(pending_entry) @@ -719,7 +732,7 @@ def build_viewport(viewport_id, width=1920, height=1080): # _viewport_proc_build checks viewport ,not state.proc_build (which is for play button) if _viewport_build_in_progress: if _viewport_proc_build is not None and _viewport_proc_build.poll() is None: - # log.info(f'Build in progress: {viewport_id} - launching when ready') + log.info(f'Build in progress, viewport {viewport_id} will launch when ready') return else: log.info(f'Resetting stale viewport build state') @@ -738,20 +751,20 @@ def build_viewport(viewport_id, width=1920, height=1080): state.is_play = False # NOT play mode state.is_publish = False state.is_export = False - + log.clear(clear_warnings=True, clear_errors=True) sdk_path = lnx.utils.get_sdk_path() fp = lnx.utils.get_fp() os.chdir(fp) - + sources_path = 'Sources/' + lnx.utils.safestr(wrd.lnx_project_package) if not os.path.exists(sources_path): os.makedirs(sources_path) - + log.info('Exporting scene data...') export_data(fp, sdk_path) - + log.info('Starting Krom compilation for viewport...') compile_viewport(assets_only=(not wrd.lnx_recompile)) @@ -811,7 +824,7 @@ def viewport_build_done(): if result == 0: bpy.data.worlds['Lnx'].lnx_recompile = False - + if _viewport_pending_launches: pending = _viewport_pending_launches.copy() _viewport_pending_launches.clear() @@ -829,18 +842,20 @@ def play_viewport(viewport_id, width=1920, height=1080): global _viewport_build_in_progress, _viewport_pending_launches, _viewport_proc_build if not viewport_id: + log.error('No viewport_id: play_viewport requires an id') return - + if 'Lnx' not in bpy.data.worlds: + log.error('No Lnx world found - cannot start viewport server') return wrd = bpy.data.worlds['Lnx'] - + krom_js_path = lnx.utils.get_fp_build() + '/debug/krom/krom.js' if os.path.exists(krom_js_path) and not wrd.lnx_recompile: run_viewport_runtime(viewport_id, width, height) return - + pending_entry = (viewport_id, width, height) if pending_entry not in _viewport_pending_launches: _viewport_pending_launches.append(pending_entry) @@ -851,13 +866,13 @@ def play_viewport(viewport_id, width=1920, height=1080): return else: _viewport_build_in_progress = False # Reset stale state - + _viewport_build_in_progress = True sdk_path = lnx.utils.get_sdk_path() fp = lnx.utils.get_fp() os.chdir(fp) - + sources_path = 'Sources/' + lnx.utils.safestr(wrd.lnx_project_package) if not os.path.exists(sources_path): os.makedirs(sources_path) @@ -867,7 +882,7 @@ def play_viewport(viewport_id, width=1920, height=1080): state.is_publish = False state.is_export = False export_data(fp, sdk_path) - + compile_viewport(assets_only=(not wrd.lnx_recompile)) @@ -982,16 +997,8 @@ def build_success(): if wrd.lnx_runtime == 'Browser': os.chdir(lnx.utils.get_fp()) prefs = lnx.utils.get_lnx_preferences() - host = 'localhost' - t = threading.Thread(name='localserver', - target=lnx.lib.server.run_tcp, - args=(prefs.html5_server_port, - prefs.html5_server_log), - daemon=True) - t.start() build_dir = lnx.utils.build_dir() path = '{}/debug/html5/'.format(build_dir) - url = 'http://{}:{}/{}'.format(host, prefs.html5_server_port, path) browser = webbrowser.get() browsername = None if hasattr(browser, "name"): @@ -1004,6 +1011,14 @@ def build_success(): if len(envcmd) == 0: log.warn(f"Your {envvar} environment variable is set to an empty string") else: + host = 'localhost' + t = threading.Thread(name='localserver', + target=lnx.lib.server.run_tcp, + args=(prefs.html5_server_port, + prefs.html5_server_log), + daemon=True) + t.start() + url = 'http://{}:{}/{}'.format(host, prefs.html5_server_port, path) tplstr = Template(envcmd).safe_substitute({ 'host': host, 'port': prefs.html5_server_port, @@ -1016,10 +1031,27 @@ def build_success(): }) cmd = re.split(' +', tplstr) if len(cmd) == 0: + # try file:// protocol with a Chromium-based browser + fast = browsername if browsername and any(b in browsername.lower() for b in ('chrome', 'chromium', 'edge', 'msedge')) else lnx.utils.find_browser() + if fast is not None: + file_url = 'file:///' + os.path.abspath(path + 'index.html').replace('\\', '/') + subprocess.Popen([fast, '--allow-file-access-from-files', '--no-first-run', + '--user-data-dir=' + os.path.join(tempfile.gettempdir(), 'leenkx_browser'), + file_url]) + return + host = 'localhost' + t = threading.Thread(name='localserver', + target=lnx.lib.server.run_tcp, + args=(prefs.html5_server_port, + prefs.html5_server_log), + daemon=True) + t.start() + url = 'http://{}:{}/{}'.format(host, prefs.html5_server_port, path) if browsername in (None, '', 'default'): webbrowser.open(url) return - cmd = [browsername, url] + else: + cmd = [browsername, url] elif wrd.lnx_runtime == 'Krom': if wrd.lnx_live_patch: live_patch.start() diff --git a/leenkx/blender/lnx/make_state.py b/leenkx/blender/lnx/make_state.py index 19afcda..d872367 100644 --- a/leenkx/blender/lnx/make_state.py +++ b/leenkx/blender/lnx/make_state.py @@ -16,6 +16,7 @@ if not lnx.is_reload(__name__): proc_publish_build = None mod_scripts = [] is_export = False + is_exporting = False is_play = False is_publish = False is_viewport = False diff --git a/leenkx/blender/lnx/material/cycles.py b/leenkx/blender/lnx/material/cycles.py index 25ea4ac..a7b33b2 100644 --- a/leenkx/blender/lnx/material/cycles.py +++ b/leenkx/blender/lnx/material/cycles.py @@ -1007,7 +1007,7 @@ def make_texture( if max_size > 0 and image is not None: original_filepath = filepath filepath = resize_texture_if_needed(image, filepath, max_size) - + if filepath != original_filepath: resized_filename = lnx.utils.extract_filename(filepath) tex['file'] = lnx.utils.safestr(resized_filename) diff --git a/leenkx/blender/lnx/material/cycles_nodes/nodes_shader.py b/leenkx/blender/lnx/material/cycles_nodes/nodes_shader.py index 9658566..6cb8aab 100644 --- a/leenkx/blender/lnx/material/cycles_nodes/nodes_shader.py +++ b/leenkx/blender/lnx/material/cycles_nodes/nodes_shader.py @@ -150,7 +150,7 @@ if bpy.app.version > (4, 1, 0): if state.parse_surface: c.write_normal(node.inputs[5]) state.out_basecol = c.parse_vector_input(node.inputs[0]) - + sss_input = node.inputs.get('Subsurface Weight') or node.inputs.get('Subsurface') if sss_input is not None: if sss_input.is_linked or sss_input.default_value > 0.0: diff --git a/leenkx/blender/lnx/material/cycles_nodes/nodes_texture.py b/leenkx/blender/lnx/material/cycles_nodes/nodes_texture.py index 347296d..70f32d1 100644 --- a/leenkx/blender/lnx/material/cycles_nodes/nodes_texture.py +++ b/leenkx/blender/lnx/material/cycles_nodes/nodes_texture.py @@ -310,7 +310,6 @@ def parse_tex_noise(node: bpy.types.ShaderNodeTexNoise, out_socket: bpy.types.No if bpy.app.version < (5, 0, 0): def parse_tex_pointdensity(node: bpy.types.ShaderNodeTexPointDensity, out_socket: bpy.types.NodeSocket, state: ParserState) -> Union[floatstr, vec3str]: # Pass through - # Color if out_socket == node.outputs[0]: return c.to_vec3([0.0, 0.0, 0.0]) diff --git a/leenkx/blender/lnx/material/make.py b/leenkx/blender/lnx/material/make.py index 757e86c..f786ff3 100644 --- a/leenkx/blender/lnx/material/make.py +++ b/leenkx/blender/lnx/material/make.py @@ -166,18 +166,14 @@ def material_needs_sss(material: Material) -> bool: if sss_node is not None and sss_node.outputs[0].is_linked: return True - for sss_node in lnx.node_utils.iter_nodes_by_type(material.node_tree, 'BSDF_PRINCIPLED'): - if sss_node is not None and sss_node.outputs[0].is_linked and (sss_node.inputs[1].is_linked or sss_node.inputs[1].default_value != 0.0): - return True - - for sss_node in mat_utils.iter_nodes_leenkxpbr(material.node_tree): - if sss_node is not None and sss_node.outputs[0].is_linked and (sss_node.inputs[8].is_linked or sss_node.inputs[8].default_value != 0.0): - return True - for principled_node in lnx.node_utils.iter_nodes_by_type(material.node_tree, 'BSDF_PRINCIPLED'): if principled_node is not None and principled_node.outputs[0].is_linked: sss_input = principled_node.inputs.get('Subsurface Weight') or principled_node.inputs.get('Subsurface') if sss_input is not None and (sss_input.is_linked or sss_input.default_value > 0.0): return True + for sss_node in mat_utils.iter_nodes_leenkxpbr(material.node_tree): + if sss_node is not None and sss_node.outputs[0].is_linked and (sss_node.inputs[8].is_linked or sss_node.inputs[8].default_value != 0.0): + return True + return False diff --git a/leenkx/blender/lnx/material/make_depth.py b/leenkx/blender/lnx/material/make_depth.py index e781de7..d5c5774 100644 --- a/leenkx/blender/lnx/material/make_depth.py +++ b/leenkx/blender/lnx/material/make_depth.py @@ -94,15 +94,16 @@ def make(context_id, rpasses, shadowmap=False, shadowmap_transparent=False): make_attrib.write_norpos(con_depth, vert) frag.write_attrib('vec3 n = normalize(wnormal);') cycles.parse(mat_state.nodes, con_depth, vert, frag, geom, tesc, tese, basecol_only=True, parse_opacity=True) - elif parse_opacity: - frag.write('float opacity;') - frag.write('float ior;') + else: + if parse_opacity: + frag.write('float opacity;') + frag.write('float ior;') - if(con_depth).is_elem('morph'): - make_morph_target.morph_pos(vert) + if(con_depth).is_elem('morph'): + make_morph_target.morph_pos(vert) - if con_depth.is_elem('bone'): - make_skin.skin_pos(vert) + if con_depth.is_elem('bone'): + make_skin.skin_pos(vert) if (not is_disp and parse_custom_particle): cycles.parse(mat_state.nodes, con_depth, vert, frag, geom, tesc, tese, parse_surface=False, parse_opacity=parse_opacity) diff --git a/leenkx/blender/lnx/material/make_shader.py b/leenkx/blender/lnx/material/make_shader.py index 983ae54..27f482b 100644 --- a/leenkx/blender/lnx/material/make_shader.py +++ b/leenkx/blender/lnx/material/make_shader.py @@ -133,6 +133,7 @@ def build(material: Material, mat_users: Dict[Material, List[Object]], mat_lnxus shader_data_path = lnx.utils.get_fp_build() + '/compiled/Shaders/' + shader_data_name + '.lnx' assets.add_shader_data(shader_data_path) + # Store SSS state in the return tuple so it's preserved per-material needs_sss_result = mat_state.needs_sss return rpasses, mat_state.data, shader_data_name, bind_constants, bind_textures, needs_sss_result @@ -172,10 +173,9 @@ def write_shader(rel_path: str, shader: Shader, ext: str, rpass: str, matname: s shader_path = lnx.utils.get_fp() + '/' + rel_path + '/' + shader_file assets.add_shader(shader_path) if not os.path.isfile(shader_path) or not keep_cache: - with open(shader_path, 'w') as f: - f.write(shader.get()) + written = lnx.utils.write_if_changed(shader_path, shader.get()) - if shader.noprocessing: + if written and shader.noprocessing: cwd = os.getcwd() os.chdir(lnx.utils.get_fp() + '/' + rel_path) hlslbin_path = lnx.utils.get_sdk_path() + '/lib/leenkx_tools/hlslbin/hlslbin.exe' diff --git a/leenkx/blender/lnx/render_engine.py b/leenkx/blender/lnx/render_engine.py index 22e5efd..918bf0f 100644 --- a/leenkx/blender/lnx/render_engine.py +++ b/leenkx/blender/lnx/render_engine.py @@ -154,14 +154,14 @@ def _deferred_set_rendered_mode(): _rendered_mode_pending = False _rendered_mode_retries = 0 return None - + space.shading.type = 'RENDERED' area.tag_redraw() _rendered_mode_pending = False _rendered_mode_retries = 0 return None - + _rendered_mode_retries += 1 if _rendered_mode_retries < 10: print(f"Viewport VIEW_3D not found, retrying... ({_rendered_mode_retries})") @@ -232,14 +232,14 @@ class KromViewportEngine(bpy.types.RenderEngine): return current_mode = space.shading.type - + if self._last_shading_mode == 'SOLID' and current_mode == 'RENDERED': self._initialized = False self._rendered_mode_set = False self._krom_launch_attempted = False self._cleanup_shared_memory() self._pending_camera_sync = True - + if self._last_shading_mode == 'RENDERED' and current_mode != 'RENDERED': self._rendered_mode_set = False # keep krom running in solid mode for switching back ti rendered @@ -260,9 +260,9 @@ class KromViewportEngine(bpy.types.RenderEngine): view_matrix = rv3d.view_matrix proj_matrix = rv3d.window_matrix - + view_data = struct.pack('<16f', *[view_matrix[i][j] for j in range(4) for i in range(4)]) - + proj_data = struct.pack('<16f', *[proj_matrix[i][j] for j in range(4) for i in range(4)]) if sys.platform == 'win32': @@ -275,7 +275,7 @@ class KromViewportEngine(bpy.types.RenderEngine): self._shm.write(view_data) self._shm.seek(OFFSET_PROJ_MATRIX) self._shm.write(proj_data) - + except Exception as e: print(f"Failed camera sync: {e}") @@ -295,11 +295,11 @@ class KromViewportEngine(bpy.types.RenderEngine): if not self._viewport_id: import time self._viewport_id = hex(int(time.time() * 1000) % 0xFFFFFF)[-6:] - + region = context.region width = region.width // 2 if region else 960 height = region.height if region else 540 - + viewport_id = self._viewport_id engine_id = self._engine_id shmem_name = _get_viewport_shmem_name(viewport_id) @@ -331,7 +331,7 @@ class KromViewportEngine(bpy.types.RenderEngine): self._ensure_initialized() if self._initialized: return True - + if self._viewport_id is None and context is not None: try: space = context.space_data @@ -339,11 +339,11 @@ class KromViewportEngine(bpy.types.RenderEngine): self._viewport_id = hex(space.as_pointer())[-6:] except: pass - + if self._viewport_id is None: import time self._viewport_id = hex(int(time.time() * 1000) % 0xFFFFFF)[-6:] - + shmem_name = _get_viewport_shmem_name(self._viewport_id) self._shmem_name = shmem_name @@ -356,7 +356,7 @@ class KromViewportEngine(bpy.types.RenderEngine): kernel32 = ctypes.windll.kernel32 FILE_MAP_ALL_ACCESS = 0x000F001F - + kernel32.OpenFileMappingW.argtypes = [ctypes.wintypes.DWORD, ctypes.wintypes.BOOL, ctypes.wintypes.LPCWSTR] kernel32.OpenFileMappingW.restype = ctypes.wintypes.HANDLE @@ -371,7 +371,7 @@ class KromViewportEngine(bpy.types.RenderEngine): kernel32.VirtualQuery.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_size_t] kernel32.VirtualQuery.restype = ctypes.c_size_t - + handle = kernel32.OpenFileMappingW( FILE_MAP_ALL_ACCESS, False, @@ -395,7 +395,7 @@ class KromViewportEngine(bpy.types.RenderEngine): kernel32.CloseHandle(handle) print(f"Failed to map shared memory: {error}") return False - + self._shm_handle = handle self._shm_ptr = ptr @@ -420,11 +420,10 @@ class KromViewportEngine(bpy.types.RenderEngine): actual_size = mbi.RegionSize self._shm_size = actual_size - + self._shm_buffer = (ctypes.c_ubyte * actual_size).from_address(ptr) self._initialized = True - # print(f"Connected: {shmem_name}") return True except Exception as e: print(f"Failed to open shared memory: {e}") @@ -451,7 +450,6 @@ class KromViewportEngine(bpy.types.RenderEngine): self._shm_file = open(shm_path, 'r+b') self._shm = mmap.mmap(self._shm_file.fileno(), max_size, mmap.MAP_SHARED, mmap.PROT_READ | mmap.PROT_WRITE) self._initialized = True - # print(f"Connected: {shm_path}") return True except Exception as e: print(f"Failed to open /dev/shm: {e}") @@ -548,7 +546,7 @@ class KromViewportEngine(bpy.types.RenderEngine): header = self._read_header() if not header: return None - + if header['frame_id'] == self._last_frame_id: return None @@ -570,7 +568,7 @@ class KromViewportEngine(bpy.types.RenderEngine): pixel_buffer = (ctypes.c_ubyte * pixel_size)() ctypes.memmove(pixel_buffer, self._shm_ptr + VIEWPORT_HEADER_SIZE, pixel_size) pixels = bytes(pixel_buffer) - + ready_bytes = struct.pack(' 0 else 10.0 - + target = camera_pos + forward * view_dist - + rv3d.view_location = target rv3d.view_rotation = quat @@ -687,7 +685,7 @@ class KromViewportEngine(bpy.types.RenderEngine): self._ensure_initialized() if not self._initialized: return - + if hasattr(self, '_last_requested_width') and hasattr(self, '_last_requested_height'): if self._last_requested_width == width and self._last_requested_height == height: return @@ -764,23 +762,23 @@ class KromViewportEngine(bpy.types.RenderEngine): # calculate event offset in ring buffer event_offset = OFFSET_INPUT_EVENTS + (write_idx % MAX_INPUT_EVENTS) * INPUT_EVENT_SIZE - + event_data = struct.pack(' 0 and region.height > 0: @@ -879,13 +877,13 @@ class KromViewportEngine(bpy.types.RenderEngine): # TODO: input forwarding when viewport is active global _active_krom_engine, _active_krom_engines, _active_viewport_id, _input_operator_running _active_krom_engine = self # legacy single-engine reference - + if self._viewport_id: _active_krom_engines[self._viewport_id] = self _active_viewport_id = self._viewport_id self._set_input_enabled(True) - + if not _input_operator_running: bpy.app.timers.register(_start_input_capture, first_interval=0.1) else: @@ -895,13 +893,13 @@ class KromViewportEngine(bpy.types.RenderEngine): if self._input_check_counter > 60: self._input_check_counter = 0 bpy.app.timers.register(_start_input_capture, first_interval=0.5) - + region = context.region if region and region.width > 0 and region.height > 0: self._request_resize(region.width, region.height) - + self._read_krom_camera(context) - + pixels = self._read_framebuffer() if pixels: @@ -910,16 +908,16 @@ class KromViewportEngine(bpy.types.RenderEngine): if not self._texture: self._draw_placeholder(context) return - + self._create_shader() - + if HAS_GPU_STATE: gpu.state.blend_set('NONE') else: bgl.glDisable(bgl.GL_BLEND) - + region = context.region - + vertices = ( (0, 0), (region.width, 0), @@ -1000,13 +998,13 @@ class KROM_OT_viewport_input(bpy.types.Operator): pass _input_operator_running = False return {'CANCELLED'} - + if event.type in {'SCREEN_SET', 'SCREEN_CHANGED'}: return {'PASS_THROUGH'} - + if not context.space_data or not context.area: return {'PASS_THROUGH'} - + if context.space_data.type == 'VIEW_3D': if context.engine != 'KROM_VIEWPORT': try: @@ -1016,18 +1014,18 @@ class KROM_OT_viewport_input(bpy.types.Operator): pass _input_operator_running = False return {'CANCELLED'} - + current_engine = self._get_engine_for_area(context, event) if current_engine: self._engine = current_engine - + try: if not self._engine or not self._engine._initialized: return {'PASS_THROUGH'} except ReferenceError: _input_operator_running = False return {'CANCELLED'} - + region = context.region if not region: return {'PASS_THROUGH'} @@ -1041,7 +1039,7 @@ class KROM_OT_viewport_input(bpy.types.Operator): if not mouse_in_region: return {'PASS_THROUGH'} - + area = context.area if area: mouse_x_abs = event.mouse_x @@ -1059,14 +1057,14 @@ class KROM_OT_viewport_input(bpy.types.Operator): mouse_x = event.mouse_region_x # flip Y for Krom mouse_y = region.height - event.mouse_region_y - + try: if event.type == 'MOUSEMOVE': self._engine._send_input_event(INPUT_EVENT_MOUSE_MOVE, x=mouse_x, y=mouse_y) self._last_mouse_x = mouse_x self._last_mouse_y = mouse_y return {'RUNNING_MODAL'} - + if event.type == 'LEFTMOUSE': if event.value == 'PRESS': self._engine._send_input_event(INPUT_EVENT_MOUSE_DOWN, button=MOUSE_BUTTON_LEFT, x=mouse_x, y=mouse_y) @@ -1087,14 +1085,14 @@ class KROM_OT_viewport_input(bpy.types.Operator): elif event.value == 'RELEASE': self._engine._send_input_event(INPUT_EVENT_MOUSE_UP, button=MOUSE_BUTTON_MIDDLE, x=mouse_x, y=mouse_y) return {'RUNNING_MODAL'} - + if event.type == 'WHEELUPMOUSE': self._engine._send_input_event(INPUT_EVENT_MOUSE_WHEEL, delta=1) return {'RUNNING_MODAL'} if event.type == 'WHEELDOWNMOUSE': self._engine._send_input_event(INPUT_EVENT_MOUSE_WHEEL, delta=-1) return {'RUNNING_MODAL'} - + if event.value in {'PRESS', 'RELEASE'}: key_code = self._blender_to_krom_key(event.type) if key_code is not None: @@ -1106,7 +1104,7 @@ class KROM_OT_viewport_input(bpy.types.Operator): except ReferenceError: _input_operator_running = False return {'CANCELLED'} - + return {'PASS_THROUGH'} def _blender_to_krom_key(self, blender_key): @@ -1136,15 +1134,15 @@ class KROM_OT_viewport_input(bpy.types.Operator): if event: mouse_x = event.mouse_x mouse_y = event.mouse_y - + for window in bpy.context.window_manager.windows: for area in window.screen.areas: if area.type != 'VIEW_3D': continue - + if (area.x <= mouse_x < area.x + area.width and area.y <= mouse_y < area.y + area.height): - + for space in area.spaces: if space.type == 'VIEW_3D': try: @@ -1154,7 +1152,7 @@ class KROM_OT_viewport_input(bpy.types.Operator): except: pass break - + if context.space_data: try: viewport_id = hex(context.space_data.as_pointer())[-6:] @@ -1162,10 +1160,10 @@ class KROM_OT_viewport_input(bpy.types.Operator): return _active_krom_engines[viewport_id] except: pass - + if _active_viewport_id and _active_viewport_id in _active_krom_engines: return _active_krom_engines[_active_viewport_id] - + return _active_krom_engine def invoke(self, context, event): @@ -1209,7 +1207,7 @@ def get_panels(): 'VIEWLAYER_PT_filter', 'VIEWLAYER_PT_layer_passes', } - + compatible_engines = {'BLENDER_RENDER', 'BLENDER_EEVEE', 'BLENDER_EEVEE_NEXT'} panels = [] @@ -1256,7 +1254,7 @@ class KROM_OT_stop_viewport(bpy.types.Operator): def execute(self, context): import lnx.make as make - + viewport_id = self.viewport_id if not viewport_id and context.space_data: viewport_id = hex(context.space_data.as_pointer())[-6:] @@ -1279,12 +1277,12 @@ def draw_viewport_stop_button(self, context): space = context.space_data if not space or not hasattr(space, 'shading'): return - + if space.shading.type != 'RENDERED': return - + viewport_id = hex(space.as_pointer())[-6:] - + global _active_krom_engines if viewport_id not in _active_krom_engines: layout = self.layout @@ -1301,12 +1299,12 @@ def register(): bpy.utils.register_class(KromViewportEngine) bpy.utils.register_class(KROM_OT_viewport_input) bpy.utils.register_class(KROM_OT_stop_viewport) - + bpy.types.VIEW3D_HT_header.append(draw_viewport_stop_button) - + for panel in get_panels(): panel.COMPAT_ENGINES.add('KROM_VIEWPORT') - + if _on_depsgraph_update not in bpy.app.handlers.depsgraph_update_post: bpy.app.handlers.depsgraph_update_post.append(_on_depsgraph_update) atexit.register(_cleanup_krom_on_exit) @@ -1319,21 +1317,21 @@ def unregister(): _active_krom_engines.clear() _active_viewport_id = None _input_operator_running = False - + if _on_depsgraph_update in bpy.app.handlers.depsgraph_update_post: bpy.app.handlers.depsgraph_update_post.remove(_on_depsgraph_update) - + _cleanup_krom_on_exit() - + try: atexit.unregister(_cleanup_krom_on_exit) except: pass - + for panel in get_panels(): if 'KROM_VIEWPORT' in panel.COMPAT_ENGINES: panel.COMPAT_ENGINES.remove('KROM_VIEWPORT') - + bpy.types.VIEW3D_HT_header.remove(draw_viewport_stop_button) bpy.utils.unregister_class(KROM_OT_stop_viewport) diff --git a/leenkx/blender/lnx/utils.py b/leenkx/blender/lnx/utils.py index 09f7e98..47ceeaf 100644 --- a/leenkx/blender/lnx/utils.py +++ b/leenkx/blender/lnx/utils.py @@ -52,6 +52,46 @@ class WorkingDir: def __exit__(self, exc_type, exc_val, exc_tb): os.chdir(self.prev_cwd) +def write_if_changed(filepath, content): + """Write content to filepath only if it differs from existing file preserving mtime when unchanged""" + if os.path.isfile(filepath): + with open(filepath, 'r') as f: + if f.read() == content: + return False + with open(filepath, 'w') as f: + f.write(content) + return True + +def find_browser(): + """Find a browser that supports file:// with --allow-file-access-from-files - Chrome/Chromium/Edge""" + osn = get_os() + if osn == 'win': + candidates = [] + for p in (os.environ.get('PROGRAMFILES', ''), os.environ.get('PROGRAMFILES(X86)', ''), os.environ.get('LOCALAPPDATA', '')): + if p: + candidates.append(os.path.join(p, 'Google', 'Chrome', 'Application', 'chrome.exe')) + candidates.append(os.path.join(p, 'Google', 'Chrome', 'chrome.exe')) + candidates.append(os.path.join(p, 'Google', 'Chromium', 'chrome.exe')) + candidates.append(os.path.join(p, 'Chromium', 'Application', 'chrome.exe')) + for p in (os.environ.get('PROGRAMFILES(X86)', ''), os.environ.get('PROGRAMFILES', '')): + if p: + candidates.append(os.path.join(p, 'Microsoft', 'Edge', 'Application', 'msedge.exe')) + for c in candidates: + if os.path.isfile(c): + return c + elif osn == 'mac': + for app in ('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Chromium.app/Contents/MacOS/Chromium', + '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge'): + if os.path.isfile(app): + return app + else: + for name in ('google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser', 'microsoft-edge'): + found = shutil.which(name) + if found: + return found + return None + def write_lnx(filepath, output): if filepath.endswith('.lz4'): with open(filepath, 'wb') as f: @@ -279,7 +319,7 @@ def get_khamake_threads() -> int: addon_prefs = get_lnx_preferences() if hasattr(addon_prefs, 'khamake_threads_use_auto') and addon_prefs.khamake_threads_use_auto: return -1 - return 1 if not hasattr(addon_prefs, 'khamake_threads') else addon_prefs.khamake_threads + return -1 if not hasattr(addon_prefs, 'khamake_threads') else addon_prefs.khamake_threads def get_compilation_server(): addon_prefs = get_lnx_preferences() diff --git a/leenkx/blender/lnx/write_data.py b/leenkx/blender/lnx/write_data.py index a152b35..fd721ec 100644 --- a/leenkx/blender/lnx/write_data.py +++ b/leenkx/blender/lnx/write_data.py @@ -1,4 +1,5 @@ import glob +import io import json import os import shutil @@ -623,7 +624,8 @@ def write_compiledglsl(defs, make_variants): rpdat = lnx.utils.get_rp() wrd = bpy.data.worlds['Lnx'] shadowmap_size = lnx.utils.get_cascade_size(rpdat) if rpdat.rp_shadows else 0 - with open(lnx.utils.build_dir() + '/compiled/Shaders/compiled.inc', 'w') as f: + inc_path = lnx.utils.build_dir() + '/compiled/Shaders/compiled.inc' + with io.StringIO() as f: f.write( """#ifndef _COMPILED_GLSL_ #define _COMPILED_GLSL_ @@ -883,6 +885,7 @@ const float clusterNear = 3.0; f.write("""#endif // _COMPILED_GLSL_ """) + return lnx.utils.write_if_changed(inc_path, f.getvalue()) def write_traithx(class_path): wrd = bpy.data.worlds['Lnx']