From b265ab863c598ab6a091a38f7d2d1833a594f8dc Mon Sep 17 00:00:00 2001 From: Onek8 Date: Thu, 6 Nov 2025 16:13:39 +0000 Subject: [PATCH 1/4] Update leenkx/blender/lnx/props.py --- leenkx/blender/lnx/props.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/leenkx/blender/lnx/props.py b/leenkx/blender/lnx/props.py index d981ea9..7eca246 100644 --- a/leenkx/blender/lnx/props.py +++ b/leenkx/blender/lnx/props.py @@ -283,6 +283,21 @@ def init_properties(): ) bpy.types.World.lnx_texture_quality = FloatProperty(name="Texture Quality", default=1.0, min=0.0, max=1.0, subtype='FACTOR', update=assets.invalidate_compiler_cache) bpy.types.World.lnx_sound_quality = FloatProperty(name="Sound Quality", default=0.9, min=0.0, max=1.0, subtype='FACTOR', update=assets.invalidate_compiler_cache) + bpy.types.World.lnx_max_texture_size = EnumProperty( + name="Max Texture Size", + description="Maximum texture resolution for runtime. Larger textures will be automatically downscaled.", + items=[ + ('0', 'Unlimited', 'No size limit (may cause WebGL texture unit errors with many textures)'), + ('256', '0.25K (256px)', 'Maximum 0.25K resolution - Best for mobile'), + ('512', '0.5K (512px)', 'Maximum 0.5K resolution - Best for mobile'), + ('1024', '1K (1024px)', 'Maximum 1K resolution - Best for mobile'), + ('2048', '2K (2048px)', 'Maximum 2K resolution - Recommended for WebGL'), + ('4096', '4K (4096px)', 'Maximum 4K resolution - Desktop only'), + ('8192', '8K (8192px)', 'Maximum 8K resolution - High-end desktop only'), + ], + default='2048', + update=assets.invalidate_compiler_cache + ) bpy.types.World.lnx_copy_override = BoolProperty(name="Copy Override", description="Overrides any existing files when copying", default=False, update=assets.invalidate_compiled_data) bpy.types.World.lnx_minimize = BoolProperty(name="Binary Scene Data", description="Export scene data in binary", default=True, update=assets.invalidate_compiled_data) bpy.types.World.lnx_minify_js = BoolProperty(name="Minify JS", description="Minimize JavaScript output when publishing", default=True) From b72a22b5e973cca4ac7ce0dba9820d544b418706 Mon Sep 17 00:00:00 2001 From: Onek8 Date: Thu, 6 Nov 2025 16:17:18 +0000 Subject: [PATCH 2/4] Update leenkx/blender/lnx/props_ui.py --- leenkx/blender/lnx/props_ui.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/leenkx/blender/lnx/props_ui.py b/leenkx/blender/lnx/props_ui.py index d3d6bd0..50af3e3 100644 --- a/leenkx/blender/lnx/props_ui.py +++ b/leenkx/blender/lnx/props_ui.py @@ -1220,6 +1220,12 @@ class LNX_PT_ProjectFlagsPanel(bpy.types.Panel): row.prop(wrd, 'lnx_canvas_img_scaling_quality', expand=True) col.prop(wrd, 'lnx_texture_quality') col.prop(wrd, 'lnx_sound_quality') + + col = column_with_heading(layout, 'Texture Optimization') + col.prop(wrd, 'lnx_max_texture_size') + if wrd.lnx_max_texture_size != '0': + box = col.box() + box.label(text=f"Textures larger than {wrd.lnx_max_texture_size}px will be automatically downscaled on export", icon='INFO') col = column_with_heading(layout, 'External Assets') col.prop(wrd, 'lnx_copy_override') From 7076fb6b7e51515c1b09c8d6a495bb9379090816 Mon Sep 17 00:00:00 2001 From: Onek8 Date: Thu, 6 Nov 2025 16:29:46 +0000 Subject: [PATCH 3/4] Update leenkx/blender/lnx/material/cycles.py --- leenkx/blender/lnx/material/cycles.py | 65 +++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/leenkx/blender/lnx/material/cycles.py b/leenkx/blender/lnx/material/cycles.py index f5c495f..2b7ec81 100644 --- a/leenkx/blender/lnx/material/cycles.py +++ b/leenkx/blender/lnx/material/cycles.py @@ -53,9 +53,69 @@ else: # Particle info export particle_info: Dict[str, bool] = {} +texture_resize_cache = {} + state: Optional[ParserState] +def resize_texture_if_needed(image: bpy.types.Image, filepath: str, max_size: int) -> str: + """ + Resize texture if it exceeds max_size. Returns path to resized texture or original. + Caches results to avoid re-processing unchanged textures. + """ + if max_size <= 0: + return filepath + + if image.size[0] <= max_size and image.size[1] <= max_size: + return filepath + + cache_key = (filepath, max_size, os.path.getmtime(filepath) if os.path.exists(filepath) else 0) + if cache_key in texture_resize_cache: + cached_path = texture_resize_cache[cache_key] + if os.path.exists(cached_path): + return cached_path + + width, height = image.size[0], image.size[1] + if width > height: + new_width = max_size + new_height = int((height / width) * max_size) + else: + new_height = max_size + new_width = int((width / height) * max_size) + + build_dir = lnx.utils.get_fp_build() + resized_dir = os.path.join(build_dir, 'compiled', 'Assets', 'resized_textures') + os.makedirs(resized_dir, exist_ok=True) + + basename = os.path.basename(filepath) + name, ext = os.path.splitext(basename) + resized_path = os.path.join(resized_dir, f"{name}_{max_size}px{ext}") + if os.path.exists(resized_path): + src_mtime = os.path.getmtime(filepath) + dst_mtime = os.path.getmtime(resized_path) + if dst_mtime >= src_mtime: + print(f"Using cached: {basename} -> {new_width}x{new_height}") + texture_resize_cache[cache_key] = resized_path + return resized_path + try: + from PIL import Image as PILImage + img = PILImage.open(filepath) + img_resized = img.resize((new_width, new_height), PILImage.Resampling.LANCZOS) + img_resized.save(resized_path, quality=95, optimize=True) + print(f"Resized: {basename} {width}x{height} -> {new_width}x{new_height}") + texture_resize_cache[cache_key] = resized_path + return resized_path + except ImportError: + print(f"WARNING: PIL/Pillow not installed. Install with: pip install Pillow") + print(f"Skipping resize for: {basename}") + return filepath + except Exception as e: + print(f"WARNING: Failed to resize {basename}: {e}") + return filepath + + + + def parse(nodes, con: ShaderContext, vert: Shader, frag: Shader, geom: Shader, tesc: Shader, tese: Shader, parse_surface=True, parse_opacity=True, parse_displacement=True, basecol_only=False): @@ -897,6 +957,11 @@ def make_texture( lnx.utils.convert_image(image, converted_path, file_format=fmt) lnx.assets.add(converted_path) else: + wrd = bpy.data.worlds['Lnx'] + max_size = int(wrd.lnx_max_texture_size) + if max_size > 0 and image is not None: + filepath = resize_texture_if_needed(image, filepath, max_size) + # Link image path to assets # TODO: Khamake converts .PNG to .jpg? Convert ext to lowercase on windows if lnx.utils.get_os() == 'win': From 3bee97a5600c8f72f6e8963955ea6daa5db73662 Mon Sep 17 00:00:00 2001 From: LeenkxTeam Date: Fri, 14 Nov 2025 17:41:39 +0000 Subject: [PATCH 4/4] Update leenkx/blender/lnx/material/cycles.py --- leenkx/blender/lnx/material/cycles.py | 83 ++++++++++++++++++++------- 1 file changed, 63 insertions(+), 20 deletions(-) diff --git a/leenkx/blender/lnx/material/cycles.py b/leenkx/blender/lnx/material/cycles.py index 2b7ec81..f74fcf8 100644 --- a/leenkx/blender/lnx/material/cycles.py +++ b/leenkx/blender/lnx/material/cycles.py @@ -69,9 +69,12 @@ def resize_texture_if_needed(image: bpy.types.Image, filepath: str, max_size: in if image.size[0] <= max_size and image.size[1] <= max_size: return filepath - cache_key = (filepath, max_size, os.path.getmtime(filepath) if os.path.exists(filepath) else 0) - if cache_key in texture_resize_cache: - cached_path = texture_resize_cache[cache_key] + wrd = bpy.data.worlds['Lnx'] + texture_quality = wrd.lnx_texture_quality + + cache_key = (filepath, max_size, texture_quality, os.path.getmtime(filepath) if os.path.exists(filepath) else 0) + if cache_key in _texture_resize_cache: + cached_path = _texture_resize_cache[cache_key] if os.path.exists(cached_path): return cached_path @@ -84,38 +87,78 @@ def resize_texture_if_needed(image: bpy.types.Image, filepath: str, max_size: in new_width = int((width / height) * max_size) build_dir = lnx.utils.get_fp_build() - resized_dir = os.path.join(build_dir, 'compiled', 'Assets', 'resized_textures') + resized_dir = os.path.join(build_dir, 'compiled', 'Assets', 'unpacked') os.makedirs(resized_dir, exist_ok=True) basename = os.path.basename(filepath) name, ext = os.path.splitext(basename) - resized_path = os.path.join(resized_dir, f"{name}_{max_size}px{ext}") + quality_suffix = f"q{int(texture_quality * 100)}" + resized_path = os.path.join(resized_dir, f"{name}_{max_size}px_{quality_suffix}{ext}") + if os.path.exists(resized_path): src_mtime = os.path.getmtime(filepath) dst_mtime = os.path.getmtime(resized_path) if dst_mtime >= src_mtime: - print(f"Using cached: {basename} -> {new_width}x{new_height}") - texture_resize_cache[cache_key] = resized_path + _texture_resize_cache[cache_key] = resized_path return resized_path + try: - from PIL import Image as PILImage - img = PILImage.open(filepath) - img_resized = img.resize((new_width, new_height), PILImage.Resampling.LANCZOS) - img_resized.save(resized_path, quality=95, optimize=True) - print(f"Resized: {basename} {width}x{height} -> {new_width}x{new_height}") - texture_resize_cache[cache_key] = resized_path - return resized_path - except ImportError: - print(f"WARNING: PIL/Pillow not installed. Install with: pip install Pillow") - print(f"Skipping resize for: {basename}") + ffmpeg_path = lnx.utils.get_ffmpeg_path() + + if ffmpeg_path is None or ffmpeg_path == '': + print(f"[Texture Optimizer] WARNING: FFmpeg not found. Please set FFmpeg path in addon preferences.") + print(f"[Texture Optimizer] Skipping resize for: {basename}") + return filepath + + file_ext = os.path.splitext(filepath)[1].lower().lstrip('.') + + cmd = [ + ffmpeg_path, + '-y', + '-i', filepath, + '-vf', f'scale={new_width}:{new_height}:flags=lanczos', + ] + + if file_ext in ('png', 'tga', 'bmp'): + compression_level = round((1.0 - texture_quality) * 9) + cmd.extend(['-compression_level', str(compression_level)]) + else: + qscale = round(2 + (1.0 - texture_quality) * 29) + cmd.extend(['-q:v', str(qscale)]) + + cmd.append(resized_path) + + startupinfo = None + if os.name == 'nt': + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + startupinfo.wShowWindow = subprocess.SW_HIDE + + result = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + startupinfo=startupinfo, + timeout=60 + ) + + if result.returncode == 0 and os.path.exists(resized_path): + print(f"[Texture Optimizer] Resized: {basename} {width}x{height} -> {new_width}x{new_height}") + _texture_resize_cache[cache_key] = resized_path + return resized_path + else: + error_msg = result.stderr.decode('utf-8', errors='ignore') if result.stderr else 'Unknown error' + print(f"[Texture Optimizer] WARNING: FFmpeg failed to resize {basename}: {error_msg}") + return filepath + + except subprocess.TimeoutExpired: + print(f"[Texture Optimizer] WARNING: FFmpeg timeout while resizing {basename}") return filepath except Exception as e: - print(f"WARNING: Failed to resize {basename}: {e}") + print(f"[Texture Optimizer] WARNING: Failed to resize {basename}: {e}") return filepath - - def parse(nodes, con: ShaderContext, vert: Shader, frag: Shader, geom: Shader, tesc: Shader, tese: Shader, parse_surface=True, parse_opacity=True, parse_displacement=True, basecol_only=False):