2025-01-22 16:18:30 +01:00
#
# This module builds upon Cycles nodes work licensed as
# Copyright 2011-2013 Blender Foundation
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
import os
import shutil
2026-02-24 17:35:26 -08:00
import subprocess
2025-01-22 16:18:30 +01:00
from typing import Any , Dict , Optional , Tuple
import bpy
2026-02-24 17:35:26 -08:00
import os
2025-01-22 16:18:30 +01:00
2026-02-24 17:35:26 -08:00
import lnx . assets as assets
2025-01-22 16:18:30 +01:00
import lnx . log as log
import lnx . make_state
import lnx . material . cycles_functions as c_functions
import lnx . material . node_meta as node_meta
import lnx . material . mat_state as mat_state
from lnx . material . parser_state import ParserState , ParserContext , ParserPass
from lnx . material . shader import Shader , ShaderContext , floatstr , vec3str
import lnx . node_utils
import lnx . utils
if lnx . is_reload ( __name__ ) :
lnx . assets = lnx . reload_module ( lnx . assets )
log = lnx . reload_module ( log )
lnx . make_state = lnx . reload_module ( lnx . make_state )
c_functions = lnx . reload_module ( c_functions )
lnx . material . cycles_nodes = lnx . reload_module ( lnx . material . cycles_nodes )
node_meta = lnx . reload_module ( node_meta )
from lnx . material . cycles_nodes import *
mat_state = lnx . reload_module ( mat_state )
lnx . material . parser_state = lnx . reload_module ( lnx . material . parser_state )
from lnx . material . parser_state import ParserState , ParserContext , ParserPass
lnx . material . shader = lnx . reload_module ( lnx . material . shader )
from lnx . material . shader import Shader , ShaderContext , floatstr , vec3str
lnx . node_utils = lnx . reload_module ( lnx . node_utils )
lnx . utils = lnx . reload_module ( lnx . utils )
else :
lnx . enable_reload ( __name__ )
# Particle info export
particle_info : Dict [ str , bool ] = { }
2025-11-06 16:29:46 +00:00
texture_resize_cache = { }
2025-01-22 16:18:30 +01:00
state : Optional [ ParserState ]
2025-11-06 16:29:46 +00:00
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
2026-09-17 15:59:45 -04:00
2025-11-06 16:29:46 +00:00
if image . size [ 0 ] < = max_size and image . size [ 1 ] < = max_size :
return filepath
2026-09-17 15:59:45 -04:00
2025-11-14 17:41:39 +00:00
wrd = bpy . data . worlds [ ' Lnx ' ]
texture_quality = wrd . lnx_texture_quality
2026-09-17 15:59:45 -04:00
2025-11-14 17:41:39 +00:00
cache_key = ( filepath , max_size , texture_quality , os . path . getmtime ( filepath ) if os . path . exists ( filepath ) else 0 )
2026-08-27 22:41:52 -07:00
if cache_key in texture_resize_cache :
cached_path = texture_resize_cache [ cache_key ]
2025-11-06 16:29:46 +00:00
if os . path . exists ( cached_path ) :
return cached_path
2026-09-17 15:59:45 -04:00
2025-11-06 16:29:46 +00:00
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 )
2026-09-17 15:59:45 -04:00
2025-11-06 16:29:46 +00:00
build_dir = lnx . utils . get_fp_build ( )
2025-11-14 17:41:39 +00:00
resized_dir = os . path . join ( build_dir , ' compiled ' , ' Assets ' , ' unpacked ' )
2025-11-06 16:29:46 +00:00
os . makedirs ( resized_dir , exist_ok = True )
2026-09-17 15:59:45 -04:00
2025-11-06 16:29:46 +00:00
basename = os . path . basename ( filepath )
name , ext = os . path . splitext ( basename )
2025-11-14 17:41:39 +00:00
quality_suffix = f " q { int ( texture_quality * 100 ) } "
resized_path = os . path . join ( resized_dir , f " { name } _ { max_size } px_ { quality_suffix } { ext } " )
2026-09-17 15:59:45 -04:00
2025-11-06 16:29:46 +00:00
if os . path . exists ( resized_path ) :
src_mtime = os . path . getmtime ( filepath )
dst_mtime = os . path . getmtime ( resized_path )
if dst_mtime > = src_mtime :
2026-08-27 22:41:52 -07:00
texture_resize_cache [ cache_key ] = resized_path
2025-11-06 16:29:46 +00:00
return resized_path
2026-09-17 15:59:45 -04:00
2025-11-06 16:29:46 +00:00
try :
2025-11-14 17:41:39 +00:00
ffmpeg_path = lnx . utils . get_ffmpeg_path ( )
2026-09-17 15:59:45 -04:00
2025-11-14 17:41:39 +00:00
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
2026-09-17 15:59:45 -04:00
2025-11-14 17:41:39 +00:00
file_ext = os . path . splitext ( filepath ) [ 1 ] . lower ( ) . lstrip ( ' . ' )
2026-09-17 15:59:45 -04:00
2025-11-14 17:41:39 +00:00
cmd = [
ffmpeg_path ,
2026-09-17 15:59:45 -04:00
' -y ' ,
2025-11-14 17:41:39 +00:00
' -i ' , filepath ,
' -vf ' , f ' scale= { new_width } : { new_height } :flags=lanczos ' ,
]
2026-09-17 15:59:45 -04:00
2025-11-14 17:41:39 +00:00
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 ) ] )
2026-09-17 15:59:45 -04:00
2025-11-14 17:41:39 +00:00
cmd . append ( resized_path )
2026-09-17 15:59:45 -04:00
2025-11-14 17:41:39 +00:00
startupinfo = None
2026-09-17 15:59:45 -04:00
if os . name == ' nt ' :
2025-11-14 17:41:39 +00:00
startupinfo = subprocess . STARTUPINFO ( )
startupinfo . dwFlags | = subprocess . STARTF_USESHOWWINDOW
startupinfo . wShowWindow = subprocess . SW_HIDE
2026-09-17 15:59:45 -04:00
2025-11-14 17:41:39 +00:00
result = subprocess . run (
cmd ,
stdout = subprocess . PIPE ,
stderr = subprocess . PIPE ,
startupinfo = startupinfo ,
timeout = 60
)
2026-09-17 15:59:45 -04:00
2025-11-14 17:41:39 +00:00
if result . returncode == 0 and os . path . exists ( resized_path ) :
print ( f " [Texture Optimizer] Resized: { basename } { width } x { height } -> { new_width } x { new_height } " )
2026-08-27 22:41:52 -07:00
texture_resize_cache [ cache_key ] = resized_path
2025-11-14 17:41:39 +00:00
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
2026-09-17 15:59:45 -04:00
2025-11-14 17:41:39 +00:00
except subprocess . TimeoutExpired :
print ( f " [Texture Optimizer] WARNING: FFmpeg timeout while resizing { basename } " )
2025-11-06 16:29:46 +00:00
return filepath
except Exception as e :
2025-11-14 17:41:39 +00:00
print ( f " [Texture Optimizer] WARNING: Failed to resize { basename } : { e } " )
2025-11-06 16:29:46 +00:00
return filepath
2025-01-22 16:18:30 +01:00
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 ) :
global state
state = ParserState ( ParserContext . OBJECT , mat_state . material . name )
state . parse_surface = parse_surface
state . parse_opacity = parse_opacity
state . parse_displacement = parse_displacement
state . basecol_only = basecol_only
state . con = con
state . vert = vert
state . frag = frag
state . geom = geom
state . tesc = tesc
state . tese = tese
output_node = node_by_type ( nodes , ' OUTPUT_MATERIAL ' )
if output_node is not None :
custom_particle_node = node_by_name ( nodes , ' LnxCustomParticleNode ' )
parse_material_output ( output_node , custom_particle_node )
# Make sure that individual functions in this module aren't called with an incorrect/old parser state, set it to
# None so that it will raise exceptions when not set
2026-07-27 12:10:11 -07:00
# store extended BRDF feature flags before destroying parser state
import re as _re
def _extract_vec3 ( s ) :
2026-08-07 02:04:01 -07:00
s = s . strip ( )
m = _re . match ( r ' vec3 \ s* \ ((.*) \ ) \ s*$ ' , s )
if not m :
return ' 1.0 ' , ' 1.0 ' , ' 1.0 '
inner = m . group ( 1 ) . strip ( )
parts = [ ]
depth = 0
start = 0
for i , ch in enumerate ( inner ) :
if ch == ' ( ' :
depth + = 1
elif ch == ' ) ' :
depth - = 1
elif ch == ' , ' and depth == 0 :
parts . append ( inner [ start : i ] . strip ( ) )
start = i + 1
parts . append ( inner [ start : ] . strip ( ) )
if len ( parts ) == 1 :
return parts [ 0 ] , parts [ 0 ] , parts [ 0 ]
if len ( parts ) == 3 :
return parts [ 0 ] , parts [ 1 ] , parts [ 2 ]
2026-07-27 12:10:11 -07:00
return ' 1.0 ' , ' 1.0 ' , ' 1.0 '
def _try_float ( val_str , default = 0.0 ) :
try :
return float ( val_str )
except ( ValueError , TypeError ) :
return default
_sss_r , _sss_g , _sss_b = _extract_vec3 ( state . out_subsurface_radius )
_sss_scale = state . out_subsurface_scale
_sheen_tint_r , _sheen_tint_g , _sheen_tint_b = _extract_vec3 ( state . out_sheen_tint )
_coat_tint_r , _coat_tint_g , _coat_tint_b = _extract_vec3 ( state . out_coat_tint )
2026-08-07 02:04:01 -07:00
_spec_tint_r , _spec_tint_g , _spec_tint_b = _extract_vec3 ( state . out_specular_tint )
2026-07-27 12:10:11 -07:00
_sss_color_r , _sss_color_g , _sss_color_b = _extract_vec3 ( state . out_subsurface_color )
mat_state . features = {
' clearcoat ' : state . out_clearcoat ,
' clearcoatRough ' : state . out_clearcoat_rough ,
' coatIOR ' : state . out_coat_ior ,
' coatTintR ' : _coat_tint_r ,
' coatTintG ' : _coat_tint_g ,
' coatTintB ' : _coat_tint_b ,
' sheen ' : state . out_sheen ,
' sheenRough ' : state . out_sheen_rough ,
' sheenTintR ' : _sheen_tint_r ,
' sheenTintG ' : _sheen_tint_g ,
' sheenTintB ' : _sheen_tint_b ,
' subsurface ' : state . out_subsurface ,
' subsurfaceAnisotropy ' : state . out_subsurface_anisotropy ,
2026-08-07 02:04:01 -07:00
' subsurfaceScale ' : _sss_scale ,
2026-07-27 12:10:11 -07:00
' subsurfaceRadiusR ' : _sss_r ,
' subsurfaceRadiusG ' : _sss_g ,
' subsurfaceRadiusB ' : _sss_b ,
' subsurfaceColorR ' : _sss_color_r ,
' subsurfaceColorG ' : _sss_color_g ,
' subsurfaceColorB ' : _sss_color_b ,
' anisotropy ' : state . out_anisotropy ,
' anisoRot ' : state . out_aniso_rot ,
' transmission ' : state . out_transmission ,
' transmissionRough ' : state . out_transmission_rough ,
' ior ' : state . out_ior ,
' thinWall ' : state . out_thin_wall ,
2026-08-07 02:04:01 -07:00
' specularTintR ' : _spec_tint_r ,
' specularTintG ' : _spec_tint_g ,
' specularTintB ' : _spec_tint_b ,
2026-07-27 12:10:11 -07:00
}
2025-01-22 16:18:30 +01:00
state = None
def parse_material_output ( node : bpy . types . Node , custom_particle_node : bpy . types . Node ) :
global particle_info
parse_surface = state . parse_surface
parse_opacity = state . parse_opacity
parse_displacement = state . parse_displacement
particle_info = {
' index ' : False ,
2025-09-19 19:31:45 +00:00
' random ' : False ,
2025-01-22 16:18:30 +01:00
' age ' : False ,
' lifetime ' : False ,
' location ' : False ,
' size ' : False ,
' velocity ' : False ,
' angular_velocity ' : False
}
wrd = bpy . data . worlds [ ' Lnx ' ]
mat_state . emission_type = mat_state . EmissionType . NO_EMISSION
2026-09-04 10:46:13 -07:00
#for debugDraw color in deferred
if ' _EmissionShaded ' not in wrd . world_defs :
wrd . world_defs + = ' _EmissionShaded '
lnx . assets . add_khafile_def ( ' rp_gbuffer_emission ' )
2026-09-20 11:15:14 -07:00
disp_geom , disp_bump = disp_mode ( node )
disp_active = parse_displacement and node . inputs [ 2 ] . is_linked and ( disp_geom != ' off ' or disp_bump )
2025-01-22 16:18:30 +01:00
# Surface
if parse_surface or parse_opacity :
state . parents = [ ]
state . parsed = set ( )
2026-09-20 11:15:14 -07:00
state . parsing = set ( )
state . node_uids = { }
state . node_uids_used = set ( )
2025-01-22 16:18:30 +01:00
state . normal_parsed = False
curshader = state . frag
state . curshader = curshader
2026-07-24 17:02:00 -07:00
outs = parse_shader_input ( node . inputs [ 0 ] )
out_basecol = outs [ 0 ]
out_roughness = outs [ 1 ]
out_metallic = outs [ 2 ]
out_occlusion = outs [ 3 ]
out_specular = outs [ 4 ]
out_opacity = outs [ 5 ]
out_ior = outs [ 6 ]
out_emission_col = outs [ 7 ]
2026-09-20 11:15:14 -07:00
if parse_surface and not state . basecol_only and disp_active and disp_bump :
state . dxdy_scale = ' 0.1 '
out_disp = parse_displacement_input ( node . inputs [ 2 ] )
state . current_pass = ParserPass . DX_SCREEN_SPACE
out_disp_dx = parse_displacement_input ( node . inputs [ 2 ] )
state . current_pass = ParserPass . DY_SCREEN_SPACE
out_disp_dy = parse_displacement_input ( node . inputs [ 2 ] )
state . current_pass = ParserPass . REGULAR
state . dxdy_scale = ' 1.0 '
curshader . write ( ' vec3 disp = {0} ; ' . format ( out_disp ) )
curshader . write ( ' vec3 disp_dx = {0} ; ' . format ( out_disp_dx ) )
curshader . write ( ' vec3 disp_dy = {0} ; ' . format ( out_disp_dy ) )
curshader . write ( ' float bump_h = dot(disp, n); ' )
curshader . write ( ' vec2 bump_dHd = vec2(dot(disp_dx, n + {0} ), dot(disp_dy, n + {1} )) - bump_h; ' . format ( dfdx_fine ( ' n ' ) , dfdy_fine ( ' n ' ) ) )
curshader . write ( ' vec3 bump_dPdx = {0} ; ' . format ( dfdx_fine ( ' wposition ' ) ) )
curshader . write ( ' vec3 bump_dPdy = {0} ; ' . format ( dfdy_fine ( ' wposition ' ) ) )
curshader . write ( ' vec3 bump_Rx = cross(bump_dPdy, n); ' )
curshader . write ( ' vec3 bump_Ry = cross(n, bump_dPdx); ' )
curshader . write ( ' float bump_det = dot(bump_dPdx, bump_Rx); ' )
curshader . write ( ' vec3 bump_surfgrad = bump_dHd.x * bump_Rx + bump_dHd.y * bump_Ry; ' )
curshader . write ( ' float bump_facing = gl_FrontFacing ? 1.0 : -1.0; ' )
curshader . write ( ' n = normalize(0.1 * abs(bump_det) * n - bump_facing * sign(bump_det) * bump_surfgrad); ' )
2025-01-22 16:18:30 +01:00
if parse_surface :
curshader . write ( f ' basecol = { out_basecol } ; ' )
curshader . write ( f ' roughness = { out_roughness } ; ' )
curshader . write ( f ' metallic = { out_metallic } ; ' )
curshader . write ( f ' occlusion = { out_occlusion } ; ' )
curshader . write ( f ' specular = { out_specular } ; ' )
curshader . write ( f ' emissionCol = { out_emission_col } ; ' )
2026-07-24 17:02:00 -07:00
curshader . write ( f ' subsurface = { outs [ 8 ] } ; ' )
curshader . write ( f ' subsurfaceRadius = { outs [ 9 ] } ; ' )
curshader . write ( f ' subsurfaceColor = { outs [ 10 ] } ; ' )
curshader . write ( f ' specularTint = { outs [ 11 ] } ; ' )
curshader . write ( f ' anisotropy = { outs [ 12 ] } ; ' )
curshader . write ( f ' anisoRot = { outs [ 13 ] } ; ' )
curshader . write ( f ' sheen = { outs [ 14 ] } ; ' )
curshader . write ( f ' sheenRough = { outs [ 15 ] } ; ' )
curshader . write ( f ' sheenTint = { outs [ 16 ] } ; ' )
curshader . write ( f ' clearcoat = { outs [ 17 ] } ; ' )
curshader . write ( f ' clearcoatRough = { outs [ 18 ] } ; ' )
curshader . write ( f ' transmission = { outs [ 19 ] } ; ' )
curshader . write ( f ' transmissionRough = { outs [ 20 ] } ; ' )
curshader . write ( f ' thinWall = { outs [ 21 ] } ; ' )
curshader . write ( f ' tangent = { outs [ 22 ] } ; ' )
2026-07-27 12:10:11 -07:00
curshader . write ( f ' subsurfaceScale = { outs [ 23 ] } ; ' )
curshader . write ( f ' subsurfaceAnisotropy = { outs [ 24 ] } ; ' )
curshader . write ( f ' coatIOR = { outs [ 25 ] } ; ' )
curshader . write ( f ' coatTint = { outs [ 26 ] } ; ' )
2025-01-22 16:18:30 +01:00
if mat_state . emission_type == mat_state . EmissionType . SHADELESS :
if ' _EmissionShadeless ' not in wrd . world_defs :
wrd . world_defs + = ' _EmissionShadeless '
elif mat_state . emission_type == mat_state . EmissionType . SHADED :
if ' _EmissionShaded ' not in wrd . world_defs :
wrd . world_defs + = ' _EmissionShaded '
lnx . assets . add_khafile_def ( ' rp_gbuffer_emission ' )
if parse_opacity :
curshader . write ( ' opacity = {0} ; ' . format ( out_opacity ) )
curshader . write ( ' ior = {0} ; ' . format ( out_ior ) )
# Volume
# parse_volume_input(node.inputs[1])
2026-09-20 11:15:14 -07:00
if disp_active and disp_geom != ' off ' :
2025-01-22 16:18:30 +01:00
state . parents = [ ]
state . parsed = set ( )
2026-09-20 11:15:14 -07:00
state . parsing = set ( )
state . node_uids = { }
state . node_uids_used = set ( )
2025-01-22 16:18:30 +01:00
state . normal_parsed = False
2026-09-20 11:15:14 -07:00
if disp_geom == ' tessellation ' and state . tese is not None :
2025-01-22 16:18:30 +01:00
state . curshader = state . tese
else :
state . curshader = state . vert
out_disp = parse_displacement_input ( node . inputs [ 2 ] )
state . curshader . write ( ' vec3 disp = {0} ; ' . format ( out_disp ) )
if custom_particle_node is not None :
2026-09-20 11:15:14 -07:00
if not ( disp_active and disp_geom != ' off ' ) :
2025-01-22 16:18:30 +01:00
state . parents = [ ]
state . parsed = set ( )
2026-09-20 11:15:14 -07:00
state . parsing = set ( )
state . node_uids = { }
state . node_uids_used = set ( )
2025-01-22 16:18:30 +01:00
state . normal_parsed = False
state . curshader = state . vert
custom_particle_node . parse ( state . curshader , state . con )
def parse_group ( node , socket ) : # Entering group
index = socket_index ( node , socket )
output_node = node_by_type ( node . node_tree . nodes , ' GROUP_OUTPUT ' )
if output_node is None :
return
inp = output_node . inputs [ index ]
state . parents . append ( node )
out_group = parse_input ( inp )
state . parents . pop ( )
return out_group
def parse_group_input ( node : bpy . types . Node , socket : bpy . types . NodeSocket ) :
index = socket_index ( node , socket )
parent = state . parents . pop ( ) # Leaving group
inp = parent . inputs [ index ]
res = parse_input ( inp )
state . parents . append ( parent ) # Return to group
return res
def parse_input ( inp : bpy . types . NodeSocket ) :
if inp . type == ' SHADER ' :
return parse_shader_input ( inp )
elif inp . type in ( ' RGB ' , ' RGBA ' , ' VECTOR ' ) :
return parse_vector_input ( inp )
elif inp . type == ' VALUE ' :
return parse_value_input ( inp )
def parse_shader_input ( inp : bpy . types . NodeSocket ) - > Tuple [ str , . . . ] :
# Follow input
if inp . is_linked :
link = inp . links [ 0 ]
if link . from_node . type == ' REROUTE ' :
return parse_shader_input ( link . from_node . inputs [ 0 ] )
if link . from_socket . type != ' SHADER ' :
log . warn ( f ' Node tree " { tree_name ( ) } " : socket " { link . from_socket . name } " of node " { link . from_node . name } " cannot be connected to a shader socket ' )
state . reset_outs ( )
return state . get_outs ( )
return parse_shader ( link . from_node , link . from_socket )
else :
# Return default shader values
state . reset_outs ( )
return state . get_outs ( )
def parse_shader ( node : bpy . types . Node , socket : bpy . types . NodeSocket ) - > Tuple [ str , . . . ] :
supported_node_types = (
' MIX_SHADER ' ,
' ADD_SHADER ' ,
' BSDF_PRINCIPLED ' ,
' BSDF_DIFFUSE ' ,
' BSDF_GLOSSY ' ,
' BSDF_SHEEN ' ,
' AMBIENT_OCCLUSION ' ,
' BSDF_ANISOTROPIC ' ,
' EMISSION ' ,
' BSDF_GLASS ' ,
' HOLDOUT ' ,
' SUBSURFACE_SCATTERING ' ,
2026-07-24 17:02:00 -07:00
' BSDF_REFRACTION ' ,
' BSDF_TOON ' ,
' BSDF_HAIR ' ,
' BSDF_HAIR_PRINCIPLED ' ,
2025-01-22 16:18:30 +01:00
' BSDF_TRANSLUCENT ' ,
' BSDF_TRANSPARENT ' ,
' BSDF_VELVET ' ,
)
state . reset_outs ( )
if node . type in supported_node_types :
node_meta . get_node_meta ( node ) . parse_func ( node , socket , state )
elif node . type == ' GROUP ' :
if node . node_tree . name . startswith ( ' Leenkx PBR ' ) :
if state . parse_surface :
# Normal
if node . inputs [ 5 ] . is_linked and node . inputs [ 5 ] . links [ 0 ] . from_node . type == ' NORMAL_MAP ' :
log . warn ( tree_name ( ) + ' - Do not use Normal Map node with Leenkx PBR, connect Image Texture directly ' )
parse_normal_map_color_input ( node . inputs [ 5 ] )
emission_factor = f ' clamp( { parse_value_input ( node . inputs [ 6 ] ) } , 0.0, 1.0) '
basecol = parse_vector_input ( node . inputs [ 0 ] )
# Multiply base color with inverse of emission factor to
# copy behaviour of the Mix Shader node used in the group
# (less base color -> less shading influence)
state . out_basecol = f ' ( { basecol } * (1 - { emission_factor } )) '
state . out_occlusion = parse_value_input ( node . inputs [ 2 ] )
state . out_roughness = parse_value_input ( node . inputs [ 3 ] )
state . out_metallic = parse_value_input ( node . inputs [ 4 ] )
# Emission
if node . inputs [ 6 ] . is_linked or node . inputs [ 6 ] . default_value != 0.0 :
state . out_emission_col = f ' ( { basecol } * { emission_factor } ) '
mat_state . emission_type = mat_state . EmissionType . SHADED
if state . parse_opacity :
state . out_opacity = parse_value_input ( node . inputs [ 1 ] )
2026-05-12 23:54:06 -07:00
state . out_ior = 1.450
2025-01-22 16:18:30 +01:00
else :
return parse_group ( node , socket )
elif node . type == ' GROUP_INPUT ' :
return parse_group_input ( node , socket )
elif node . type == ' CUSTOM ' :
if node . bl_idname == ' LnxShaderDataNode ' :
return node_meta . get_node_meta ( node ) . parse_func ( node , socket , state )
else :
log . warn ( f ' Node tree " { tree_name ( ) } " : material node type { node . type } not supported ' )
return state . get_outs ( )
2026-05-12 23:54:06 -07:00
# Use an array of socket names for compatibility across Blender versions
def get_vector_input ( node : bpy . types . Node , socket_names : Tuple [ str , . . . ] ) - > vec3str :
for name in socket_names :
if name in node . inputs :
try :
return parse_vector_input ( node . inputs [ name ] )
except Exception :
log . warn ( f ' Failed to parse input " { name } " on node " { node . name } " ' )
else :
# FIXME: Fallback to default value if the node isn't found
log . warn ( f ' Input " { name } " not found on node " { node . name } " , returning default None ' )
return None
2025-01-22 16:18:30 +01:00
def parse_displacement_input ( inp ) :
if inp . is_linked :
l = inp . links [ 0 ]
if l . from_node . type == ' REROUTE ' :
return parse_displacement_input ( l . from_node . inputs [ 0 ] )
2026-09-20 11:15:14 -07:00
if l . from_socket . type in ( ' VALUE ' , ' INT ' , ' BOOLEAN ' ) :
nor = ' n ' if state . curshader . shader_type == ' frag ' else ' wnormal '
return ' {0} * vec3( {1} ) ' . format ( nor , parse_value_input ( inp ) )
2025-01-22 16:18:30 +01:00
return parse_vector_input ( inp )
else :
return None
def parse_vector_input ( inp : bpy . types . NodeSocket ) - > vec3str :
""" Return the parsed result of the given input socket. """
# Follow input
if inp . is_linked :
link = inp . links [ 0 ]
if link . from_node . type == ' REROUTE ' :
return parse_vector_input ( link . from_node . inputs [ 0 ] )
res_var = write_result ( link )
st = link . from_socket . type
if st in ( ' RGB ' , ' RGBA ' , ' VECTOR ' ) :
return res_var
2026-09-20 11:15:14 -07:00
elif st in ( ' VALUE ' , ' INT ' , ' BOOLEAN ' ) :
2025-01-22 16:18:30 +01:00
return f ' vec3( { res_var } ) '
else :
log . warn ( f ' Node tree " { tree_name ( ) } " : socket " { link . from_socket . name } " of node " { link . from_node . name } " cannot be connected to a vector-like socket ' )
return to_vec3 ( [ 0.0 , 0.0 , 0.0 ] )
# Unlinked reroute
elif inp . type == ' VALUE ' :
return to_vec3 ( [ 0.0 , 0.0 , 0.0 ] )
# Use direct socket value
else :
if mat_batch ( ) and inp . is_uniform :
return to_uniform ( inp )
else :
return to_vec3 ( inp . default_value )
def parse_vector ( node : bpy . types . Node , socket : bpy . types . NodeSocket ) - > str :
""" Parses the vector/color output value from the given node and socket. """
supported_node_types = (
' ATTRIBUTE ' ,
# RGB outputs
' RGB ' ,
' TEX_BRICK ' ,
' TEX_CHECKER ' ,
' TEX_ENVIRONMENT ' ,
' TEX_GRADIENT ' ,
' TEX_IMAGE ' ,
' TEX_MAGIC ' ,
' TEX_MUSGRAVE ' ,
' TEX_NOISE ' ,
' TEX_POINTDENSITY ' ,
' TEX_SKY ' ,
' TEX_VORONOI ' ,
' TEX_WAVE ' ,
2026-09-04 10:46:13 -07:00
' TEX_GABOR ' ,
' TEX_WHITE_NOISE ' ,
2025-01-22 16:18:30 +01:00
' VERTEX_COLOR ' ,
' BRIGHTCONTRAST ' ,
' GAMMA ' ,
' HUE_SAT ' ,
' INVERT ' ,
' MIX ' ,
2026-09-20 11:15:14 -07:00
' MIX_RGB ' ,
2025-01-22 16:18:30 +01:00
' BLACKBODY ' ,
' VALTORGB ' ,
' CURVE_VEC ' ,
' CURVE_RGB ' ,
' COMBINE_COLOR ' ,
' COMBHSV ' ,
' COMBRGB ' ,
' WAVELENGTH ' ,
# Vector outputs
' CAMERA ' ,
' NEW_GEOMETRY ' ,
' HAIR_INFO ' ,
' OBJECT_INFO ' ,
' PARTICLE_INFO ' ,
' TANGENT ' ,
' TEX_COORD ' ,
' UVMAP ' ,
2026-09-20 11:15:14 -07:00
' VOLUME_INFO ' ,
' BEVEL ' ,
2025-01-22 16:18:30 +01:00
' BUMP ' ,
' MAPPING ' ,
' NORMAL ' ,
' NORMAL_MAP ' ,
' VECT_TRANSFORM ' ,
' COMBXYZ ' ,
' VECT_MATH ' ,
' DISPLACEMENT ' ,
2026-09-04 10:46:13 -07:00
' VECTOR_DISPLACEMENT ' ,
2025-01-22 16:18:30 +01:00
' VECTOR_ROTATE ' ,
)
if node . type in supported_node_types :
return node_meta . get_node_meta ( node ) . parse_func ( node , socket , state )
elif node . type == ' GROUP ' :
return parse_group ( node , socket )
elif node . type == ' GROUP_INPUT ' :
return parse_group_input ( node , socket )
elif node . type == ' CUSTOM ' :
if node . bl_idname == ' LnxShaderDataNode ' :
return node_meta . get_node_meta ( node ) . parse_func ( node , socket , state )
log . warn ( f ' Node tree " { tree_name ( ) } " : material node type { node . type } not supported ' )
return " vec3(0, 0, 0) "
2026-05-12 23:54:06 -07:00
# Use an array of socket names for compatibility across Blender versions
def get_value_input ( node : bpy . types . Node , socket_names : Tuple [ str , . . . ] ) - > floatstr :
for name in socket_names :
if name in node . inputs :
try :
return parse_value_input ( node . inputs [ name ] )
except Exception :
log . warn ( f ' Failed to parse input " { name } " on node " { node . name } " ' )
else :
# FIXME: Fallback to default value if the node isn't found
log . warn ( f ' Input " { name } " not found on node " { node . name } " , returning default 1.0 ' )
return ' 1.0 '
2026-09-04 10:46:13 -07:00
def parse_normal_map_color_input ( inp , strength_input = None , space = ' TANGENT ' ) :
2025-01-22 16:18:30 +01:00
frag = state . frag
if state . basecol_only or not inp . is_linked or state . normal_parsed :
return
state . normal_parsed = True
frag . write_normal + = 1
2026-09-17 15:59:45 -04:00
2026-09-04 10:46:13 -07:00
color_val = parse_vector_input ( inp )
strength = parse_value_input ( strength_input ) if strength_input is not None else ' 1.0 '
if space == ' TANGENT ' :
if not get_lnx_export_tangents ( ) or mat_get_material ( ) . lnx_decal :
frag . write ( f ' vec3 texn = ( { color_val } ) * 2.0 - 1.0; ' )
frag . write ( ' texn.y = -texn.y; ' )
frag . add_include ( ' std/normals.glsl ' )
frag . write ( ' mat3 TBN = cotangentFrame(n, -vVec, texCoord); ' )
2025-01-22 16:18:30 +01:00
if strength != ' 1.0 ' :
2026-09-04 10:46:13 -07:00
frag . write ( f ' texn.xy *= { strength } ; ' )
frag . write ( ' n = TBN * normalize(texn); ' )
else :
frag . write ( f ' vec3 texn = ( { color_val } ) * 2.0 - 1.0; ' )
if strength != ' 1.0 ' :
frag . write ( f ' texn.xy *= { strength } ; ' )
2026-09-20 11:15:14 -07:00
frag . write ( ' vec3 bitan = cross(n, TBN[0]) * sign(dot(cross(n, TBN[0]), TBN[1])); ' )
frag . write ( ' n = normalize(mat3(TBN[0], bitan, n) * texn); ' )
2026-09-04 10:46:13 -07:00
state . con . add_elem ( ' tang ' , ' short4norm ' )
2026-09-17 15:59:45 -04:00
2026-09-04 10:46:13 -07:00
elif space in [ ' OBJECT ' , ' BLENDER_OBJECT ' ] :
frag . add_uniform ( ' mat3 N ' , ' _normalMatrix ' )
frag . write ( f ' vec3 objn = ( { color_val } ) * 2.0 - 1.0; ' )
if strength != ' 1.0 ' :
frag . write ( f ' objn = mix(vec3(0.0, 0.0, 1.0), objn, { strength } ); ' )
frag . write ( ' n = normalize(N * objn); ' )
elif space in [ ' WORLD ' , ' BLENDER_WORLD ' ] :
frag . write ( f ' vec3 worldn = ( { color_val } ) * 2.0 - 1.0; ' )
if strength != ' 1.0 ' :
frag . write ( f ' n = normalize(mix(n, worldn, { strength } )); ' )
else :
frag . write ( ' n = normalize(worldn); ' )
2025-01-22 16:18:30 +01:00
frag . write_normal - = 1
def parse_value_input ( inp : bpy . types . NodeSocket ) - > floatstr :
# Follow input
if inp . is_linked :
link = inp . links [ 0 ]
if link . from_node . type == ' REROUTE ' :
return parse_value_input ( link . from_node . inputs [ 0 ] )
res_var = write_result ( link )
socket_type = link . from_socket . type
if socket_type in ( ' RGB ' , ' RGBA ' , ' VECTOR ' ) :
# RGB to BW
return rgb_to_bw ( res_var )
2026-09-20 11:15:14 -07:00
elif socket_type in ( ' VALUE ' , ' INT ' , ' BOOLEAN ' ) :
2025-01-22 16:18:30 +01:00
return res_var
else :
log . warn ( f ' Node tree " { tree_name ( ) } " : socket " { link . from_socket . name } " of node " { link . from_node . name } " cannot be connected to a scalar value socket ' )
return ' 0.0 '
# Use value from socket
else :
if mat_batch ( ) and inp . is_uniform :
return to_uniform ( inp )
else :
return to_vec1 ( inp . default_value )
def parse_value ( node , socket ) :
supported_node_types = (
' ATTRIBUTE ' ,
' CAMERA ' ,
' FRESNEL ' ,
' NEW_GEOMETRY ' ,
' HAIR_INFO ' ,
' LAYER_WEIGHT ' ,
' LIGHT_PATH ' ,
' OBJECT_INFO ' ,
' PARTICLE_INFO ' ,
' VALUE ' ,
' WIREFRAME ' ,
' TEX_BRICK ' ,
' TEX_CHECKER ' ,
' TEX_GRADIENT ' ,
' TEX_IMAGE ' ,
' TEX_MAGIC ' ,
' TEX_MUSGRAVE ' ,
' TEX_NOISE ' ,
' TEX_POINTDENSITY ' ,
' TEX_VORONOI ' ,
' TEX_WAVE ' ,
2026-09-04 10:46:13 -07:00
' TEX_GABOR ' ,
' TEX_WHITE_NOISE ' ,
2025-01-22 16:18:30 +01:00
' LIGHT_FALLOFF ' ,
' NORMAL ' ,
' CLAMP ' ,
' VALTORGB ' ,
' MATH ' ,
' MIX ' ,
' RGBTOBW ' ,
' SEPARATE_COLOR ' ,
' SEPHSV ' ,
' SEPRGB ' ,
' SEPXYZ ' ,
' VECT_MATH ' ,
' MAP_RANGE ' ,
2026-09-20 11:15:14 -07:00
' VOLUME_INFO ' ,
2025-01-22 16:18:30 +01:00
)
if node . type in supported_node_types :
return node_meta . get_node_meta ( node ) . parse_func ( node , socket , state )
elif node . type == ' GROUP ' :
if node . node_tree . name . startswith ( ' Leenkx PBR ' ) :
# Displacement
if socket == node . outputs [ 1 ] :
return parse_value_input ( node . inputs [ 7 ] )
else :
return None
else :
return parse_group ( node , socket )
elif node . type == ' GROUP_INPUT ' :
return parse_group_input ( node , socket )
elif node . type == ' CUSTOM ' :
if node . bl_idname == ' LnxShaderDataNode ' :
return node_meta . get_node_meta ( node ) . parse_func ( node , socket , state )
log . warn ( f ' Node tree " { tree_name ( ) } " : material node type { node . type } not supported ' )
return ' 0.0 '
def vector_curve ( name , fac , points ) :
curshader = state . curshader
# Write Ys array
ys_var = name + ' _ys ' + state . get_parser_pass_suffix ( )
curshader . write ( ' float {0} [ {1} ]; ' . format ( ys_var , len ( points ) ) ) # TODO: Make const
for i in range ( 0 , len ( points ) ) :
curshader . write ( ' {0} [ {1} ] = {2} ; ' . format ( ys_var , i , points [ i ] . location [ 1 ] ) )
# Get index
fac_var = name + ' _fac ' + state . get_parser_pass_suffix ( )
curshader . write ( ' float {0} = {1} ; ' . format ( fac_var , fac ) )
index = ' 0 '
for i in range ( 1 , len ( points ) ) :
index + = ' + ( {0} > {1} ? 1 : 0) ' . format ( fac_var , points [ i ] . location [ 0 ] )
# Write index
index_var = name + ' _i ' + state . get_parser_pass_suffix ( )
curshader . write ( ' int {0} = {1} ; ' . format ( index_var , index ) )
# Linear
# Write Xs array
facs_var = name + ' _xs ' + state . get_parser_pass_suffix ( )
curshader . write ( ' float {0} [ {1} ]; ' . format ( facs_var , len ( points ) ) ) # TODO: Make const
for i in range ( 0 , len ( points ) ) :
curshader . write ( ' {0} [ {1} ] = {2} ; ' . format ( facs_var , i , points [ i ] . location [ 0 ] ) )
# Map vector
return ' mix( {0} [ {1} ], {0} [ {1} + 1], ( {2} - {3} [ {1} ]) * (1.0 / ( {3} [ {1} + 1] - {3} [ {1} ]) )) ' . format ( ys_var , index_var , fac_var , facs_var )
2026-08-07 02:04:01 -07:00
def write_normal ( inp , target_var = ' n ' ) :
2025-01-22 16:18:30 +01:00
if inp . is_linked and inp . links [ 0 ] . from_node . type != ' GROUP_INPUT ' :
normal_res = parse_vector_input ( inp )
if normal_res != None :
2026-08-07 02:04:01 -07:00
state . curshader . write ( ' {0} = {1} ; ' . format ( target_var , normal_res ) )
2025-01-22 16:18:30 +01:00
def is_parsed ( node_store_name : str ) :
return node_store_name in state . parsed
def res_var_name ( node : bpy . types . Node , socket : bpy . types . NodeSocket ) - > str :
""" Return the name of the variable that stores the parsed result
from the given node and socket . """
2026-09-20 11:15:14 -07:00
name = node_uid ( node ) + ' _ ' + safesrc ( socket . name ) + ' _res '
2025-01-22 16:18:30 +01:00
if ' __ ' in name : # Consecutive _ are reserved
name = name . replace ( ' _ ' , ' _x ' )
return name
def write_result ( link : bpy . types . NodeLink ) - > Optional [ str ] :
""" Write the parsed result of the given node link to the shader. """
res_var = res_var_name ( link . from_node , link . from_socket )
2026-09-20 11:15:14 -07:00
need_dxdy_offset = node_need_reevaluation_for_screenspace_derivative ( link . from_node , link . from_socket )
2025-01-22 16:18:30 +01:00
if need_dxdy_offset :
res_var + = state . get_parser_pass_suffix ( )
# Unparsed node
if not is_parsed ( res_var ) :
state . parsed . add ( res_var )
2026-09-20 11:15:14 -07:00
state . parsing . add ( res_var )
2025-01-22 16:18:30 +01:00
st = link . from_socket . type
2026-09-20 11:15:14 -07:00
try :
if st in ( ' RGB ' , ' RGBA ' , ' VECTOR ' ) :
res = parse_vector ( link . from_node , link . from_socket )
if res is None :
log . error ( f ' { link . from_node . name } returned `None` while parsing! ' )
return None
state . curshader . write ( f ' vec3 { res_var } = { res } ; ' )
elif st in ( ' VALUE ' , ' INT ' , ' BOOLEAN ' ) :
res = parse_value ( link . from_node , link . from_socket )
if res is None :
log . error ( f ' { link . from_node . name } returned `None` while parsing! ' )
return None
if link . from_node . type == " VALUE " and not link . from_node . lnx_material_param :
state . curshader . add_const ( ' float ' , res_var , res )
else :
state . curshader . write ( f ' float { res_var } = { res } ; ' )
2025-01-22 16:18:30 +01:00
else :
2026-09-20 11:15:14 -07:00
state . curshader . write ( f ' float { res_var } = 0.0; ' )
finally :
state . parsing . discard ( res_var )
2025-01-22 16:18:30 +01:00
if state . dxdy_varying_input_value :
state . curshader . write ( f ' { res_var } = { apply_screenspace_derivative_offset_if_required ( res_var ) } ; ' )
state . dxdy_varying_input_value = False
2026-09-20 11:15:14 -07:00
elif res_var in state . parsing :
log . warn ( f ' Node tree " { tree_name ( ) } " : cyclic dependency on node " { link . from_node . name } " ( { res_var } ); a node cannot depend on itself. Check the node group for a feedback loop. ' )
return ' vec3(0.0) ' if link . from_socket . type in ( ' RGB ' , ' RGBA ' , ' VECTOR ' ) else ' 0.0 '
2025-01-22 16:18:30 +01:00
# Normal map already parsed, return
elif link . from_node . type == ' NORMAL_MAP ' :
return None
return res_var
def write_procedurals ( ) :
if state . curshader not in state . procedurals_written :
state . procedurals_written . add ( state . curshader )
def glsl_type ( socket_type : str ) :
""" Socket to glsl type. """
if socket_type in ( ' RGB ' , ' RGBA ' , ' VECTOR ' ) :
return ' vec3 '
else :
return ' float '
def to_uniform ( inp : bpy . types . NodeSocket ) :
uname = safesrc ( inp . node . name ) + safesrc ( inp . name )
state . curshader . add_uniform ( glsl_type ( inp . type ) + ' ' + uname )
return uname
def store_var_name ( node : bpy . types . Node ) - > str :
2026-09-20 11:15:14 -07:00
name = node_uid ( node )
2025-01-22 16:18:30 +01:00
if name [ - 1 ] == " _ " :
return name + ' _x_store ' # Prevent consecutive __
return name + ' _store '
2026-05-12 23:54:06 -07:00
def texture_store ( node , tex , tex_name , to_linear = False , unpremultiply = False , tex_link = None , default_value = None , is_lnx_mat_param = None ) :
2025-01-22 16:18:30 +01:00
curshader = state . curshader
tex_store = store_var_name ( node )
if node_need_reevaluation_for_screenspace_derivative ( node ) :
tex_store + = state . get_parser_pass_suffix ( )
if is_parsed ( tex_store ) :
return tex_store
state . parsed . add ( tex_store )
if is_lnx_mat_param is None :
mat_bind_texture ( tex )
state . con . add_elem ( ' tex ' , ' short2norm ' )
curshader . add_uniform ( ' sampler2D {0} ' . format ( tex_name ) , link = tex_link , default_value = default_value , is_lnx_mat_param = is_lnx_mat_param )
triplanar = node . projection == ' BOX '
2026-09-04 10:46:13 -07:00
spherical = node . projection == ' SPHERE '
tube = node . projection == ' TUBE '
2025-01-22 16:18:30 +01:00
if node . inputs [ 0 ] . is_linked :
uv_name = parse_vector_input ( node . inputs [ 0 ] )
if triplanar :
2026-09-04 10:46:13 -07:00
uv_name = ' vec3( {0} .x, {0} .y, 1.0 - {0} .z) ' . format ( uv_name )
elif spherical or tube :
uv_name = ' vec3( {0} .x, {0} .y, {0} .z) ' . format ( uv_name )
2025-01-22 16:18:30 +01:00
else :
uv_name = ' vec2( {0} .x, 1.0 - {0} .y) ' . format ( uv_name )
else :
2026-09-04 10:46:13 -07:00
uv_name = ' vec3(texCoord.xy, 0.0) ' if triplanar or spherical or tube else ' texCoord '
2025-01-22 16:18:30 +01:00
if triplanar :
if not curshader . has_include ( ' std/mapping.glsl ' ) :
curshader . add_include ( ' std/mapping.glsl ' )
if state . normal_parsed :
nor = ' TBN[2] '
else :
nor = ' n '
2026-09-20 11:15:14 -07:00
blend = node . projection_blend
2026-09-04 10:46:13 -07:00
curshader . write ( ' vec4 {0} = boxProjection( {1} , {2} , {3} , clamp( {4} , 0.0, 1.0)); ' . format ( tex_store , tex_name , nor , uv_name , blend ) )
elif spherical :
if not curshader . has_include ( ' std/mapping.glsl ' ) :
curshader . add_include ( ' std/mapping.glsl ' )
curshader . write ( ' vec4 {0} = texture( {1} , sphericalMapping( {2} )); ' . format ( tex_store , tex_name , uv_name ) )
elif tube :
if not curshader . has_include ( ' std/mapping.glsl ' ) :
curshader . add_include ( ' std/mapping.glsl ' )
curshader . write ( ' vec4 {0} = texture( {1} , tubeMapping( {2} )); ' . format ( tex_store , tex_name , uv_name ) )
2025-01-22 16:18:30 +01:00
else :
if mat_state . texture_grad :
curshader . write ( ' vec4 {0} = textureGrad( {1} , {2} .xy, g2.xy, g2.zw); ' . format ( tex_store , tex_name , uv_name ) )
else :
curshader . write ( ' vec4 {0} = texture( {1} , {2} .xy); ' . format ( tex_store , tex_name , uv_name ) )
2026-05-12 23:54:06 -07:00
if unpremultiply :
curshader . write ( ' if ( {0} .a > 0.0) {0} .rgb /= {0} .a; ' . format ( tex_store ) )
2025-01-22 16:18:30 +01:00
if to_linear :
curshader . write ( ' {0} .rgb = pow( {0} .rgb, vec3(2.2)); ' . format ( tex_store ) )
return tex_store
def apply_screenspace_derivative_offset_if_required ( coords : str ) - > str :
""" Apply screen-space derivative offsets to the given coordinates,
if required by the current ParserPass .
"""
# Derivative functions are only available in fragment shaders
if state . curshader . shader_type == ' frag ' :
if state . current_pass == ParserPass . DX_SCREEN_SPACE :
2026-09-20 11:15:14 -07:00
coords = f ' ( { coords } ) + { dfdx_fine ( coords ) } * ( { state . dxdy_scale } ) '
2025-01-22 16:18:30 +01:00
elif state . current_pass == ParserPass . DY_SCREEN_SPACE :
2026-09-20 11:15:14 -07:00
coords = f ' ( { coords } ) + { dfdy_fine ( coords ) } * ( { state . dxdy_scale } ) '
2025-01-22 16:18:30 +01:00
return ' ( ' + coords + ' ) '
2026-09-20 11:15:14 -07:00
def node_need_reevaluation_for_screenspace_derivative (
node : bpy . types . Node ,
socket : Optional [ bpy . types . NodeSocket ] = None ,
parents : Tuple [ bpy . types . Node , . . . ] = ( ) ,
) - > bool :
2025-01-22 16:18:30 +01:00
if state . current_pass not in ( ParserPass . DX_SCREEN_SPACE , ParserPass . DY_SCREEN_SPACE ) :
return False
2026-09-20 11:15:14 -07:00
if node . type == ' GROUP ' :
output_node = node_by_type ( node . node_tree . nodes , ' GROUP_OUTPUT ' )
if output_node is None :
return False
if socket is not None :
index = socket_index ( node , socket )
inputs = [ output_node . inputs [ index ] ] if index is not None and index < len ( output_node . inputs ) else [ ]
else :
inputs = output_node . inputs
for inp in inputs :
c_node , c_socket = lnx . node_utils . input_get_connected_node ( inp )
if c_node is not None and node_need_reevaluation_for_screenspace_derivative ( c_node , c_socket , parents + ( node , ) ) :
return True
return False
if node . type == ' GROUP_INPUT ' :
if socket is None or len ( parents ) == 0 :
return False
index = socket_index ( node , socket )
parent = parents [ - 1 ]
if index is None or index > = len ( parent . inputs ) :
return False
c_node , c_socket = lnx . node_utils . input_get_connected_node ( parent . inputs [ index ] )
if c_node is None :
return False
return node_need_reevaluation_for_screenspace_derivative ( c_node , c_socket , parents [ : - 1 ] )
2025-01-22 16:18:30 +01:00
should_compute_offset = node_meta . get_node_meta ( node ) . compute_dxdy_variants
if should_compute_offset == node_meta . ComputeDXDYVariant . ALWAYS :
return True
elif should_compute_offset == node_meta . ComputeDXDYVariant . NEVER :
return False
# ComputeDXDYVariant.DYNAMIC
for inp in node . inputs :
2026-09-20 11:15:14 -07:00
c_node , c_socket = lnx . node_utils . input_get_connected_node ( inp )
2025-01-22 16:18:30 +01:00
if c_node is None :
continue
2026-09-20 11:15:14 -07:00
if node_need_reevaluation_for_screenspace_derivative ( c_node , c_socket , parents ) :
2025-01-22 16:18:30 +01:00
return True
return False
def dfdx_fine ( val : str ) - > str :
# GL_ARB_derivative_control is unavailable in OpenGL ES (= no fine/coarse variants),
# OES_standard_derivatives is automatically enabled in kha.SystemImpl
return f ' dFdx( { val } ) ' if lnx . utils . is_gapi_gl_es ( ) else f ' dFdxFine( { val } ) '
def dfdy_fine ( val : str ) - > str :
return f ' dFdy( { val } ) ' if lnx . utils . is_gapi_gl_es ( ) else f ' dFdyFine( { val } ) '
def to_vec1 ( v ) :
2026-09-17 15:59:45 -04:00
if isinstance ( v , bool ) :
return ' 1.0 ' if v else ' 0.0 '
if v == ' False ' :
return ' 0.0 '
if v == ' True ' :
return ' 1.0 '
2025-01-22 16:18:30 +01:00
return str ( v )
def to_vec2 ( v ) :
return f ' vec2( { v [ 0 ] } , { v [ 1 ] } ) '
def to_vec3 ( v ) :
return f ' vec3( { v [ 0 ] } , { v [ 1 ] } , { v [ 2 ] } ) '
def cast_value ( val : str , from_type : str , to_type : str ) - > str :
""" Casts a value that is already parsed in a glsl string to another
value in a string .
vec2 types are not supported ( not used in the node editor ) and there
is no cast towards int types . If casting from vec3 to vec4 , the w
coordinate / alpha channel is filled with a 1.
If this function is called with invalid parameters , a TypeError is
raised .
"""
if from_type == to_type :
return val
if from_type in ( ' int ' , ' float ' ) :
if to_type in ( ' int ' , ' float ' ) :
return val
elif to_type in ( ' vec2 ' , ' vec3 ' , ' vec4 ' ) :
return f ' { to_type } ( { val } ) '
elif from_type == ' vec3 ' :
if to_type == ' float ' :
return rgb_to_bw ( val )
elif to_type == ' vec4 ' :
return f ' vec4( { val } , 1.0) '
elif from_type == ' vec4 ' :
if to_type == ' float ' :
return rgb_to_bw ( val )
elif to_type == ' vec3 ' :
return f ' { val } .xyz '
raise TypeError ( " Invalid type cast in shader! " )
def rgb_to_bw ( res_var : vec3str ) - > floatstr :
# Blender uses the default OpenColorIO luma coefficients which
# originally come from the Rec. 709 standard (see ITU-R BT.709-6 Item 3.3)
return f ' dot( { res_var } , vec3(0.2126, 0.7152, 0.0722)) '
def node_by_type ( nodes , ntype : str ) - > bpy . types . Node :
for n in nodes :
if n . type == ntype :
return n
def node_by_name ( nodes , name : str ) - > bpy . types . Node :
for n in nodes :
if n . bl_idname == name :
return n
def socket_index ( node : bpy . types . Node , socket : bpy . types . NodeSocket ) - > int :
for i in range ( 0 , len ( node . outputs ) ) :
if node . outputs [ i ] == socket :
return i
def node_name ( s : str ) - > str :
""" Return a unique and safe name for a node for shader code usage. """
for p in state . parents :
s = p . name + ' _ ' + s
if state . curshader . write_textures > 0 :
s + = ' _texread '
s = safesrc ( s )
if ' __ ' in s : # Consecutive _ are reserved
s = s . replace ( ' _ ' , ' _x ' )
return s
2026-09-20 11:15:14 -07:00
def node_uid ( node : bpy . types . Node ) - > str :
ctx = ( tuple ( p . as_pointer ( ) for p in state . parents ) , state . curshader . write_textures > 0 )
key = ( node . as_pointer ( ) , ctx )
name = state . node_uids . get ( key )
if name is None :
base = node_name ( node . name )
name = base
i = 1
while name in state . node_uids_used :
i + = 1
name = f ' { base } { i } ' if base . endswith ( ' _ ' ) else f ' { base } _ { i } '
state . node_uids [ key ] = name
state . node_uids_used . add ( name )
return name
2025-01-22 16:18:30 +01:00
##
def make_texture (
image : bpy . types . Image , tex_name : str , matname : str ,
interpolation : str , extension : str ,
) - > Optional [ Dict [ str , Any ] ] :
""" Creates a texture binding entry for the scene ' s export data
( ' bind_textures ' ) for a given texture image .
"""
tex = { ' name ' : tex_name }
if image is None :
return None
if matname is None :
matname = mat_state . material . name
# Get filepath
filepath = image . filepath
if filepath == ' ' :
if image . packed_file is not None :
filepath = ' ./ ' + image . name
has_ext = filepath . endswith ( ( ' .jpg ' , ' .png ' , ' .hdr ' ) )
if not has_ext :
# Raw bytes, write converted .jpg to /unpacked
filepath + = ' .raw '
elif image . source == " GENERATED " :
unpack_path = os . path . join ( lnx . utils . get_fp_build ( ) , ' compiled ' , ' Assets ' , ' unpacked ' )
if not os . path . exists ( unpack_path ) :
os . makedirs ( unpack_path )
filepath = os . path . join ( unpack_path , image . name + " .jpg " )
lnx . utils . convert_image ( image , filepath , " JPEG " )
else :
log . warn ( matname + ' / ' + image . name + ' - invalid file path ' )
return None
else :
filepath = lnx . utils . to_absolute_path ( filepath , image . library )
# Reference image name
texpath = lnx . utils . asset_path ( filepath )
texfile = lnx . utils . extract_filename ( filepath )
tex [ ' file ' ] = lnx . utils . safestr ( texfile )
s = tex [ ' file ' ] . rsplit ( ' . ' , 1 )
if len ( s ) == 1 :
log . warn ( matname + ' / ' + image . name + ' - file extension required for image name ' )
return None
ext = s [ 1 ] . lower ( )
do_convert = ext not in ( ' jpg ' , ' png ' , ' hdr ' , ' mp4 ' ) # Convert image
if do_convert :
new_ext = ' png ' if ( ext in ( ' tga ' , ' dds ' ) ) else ' jpg '
tex [ ' file ' ] = tex [ ' file ' ] . rsplit ( ' . ' , 1 ) [ 0 ] + ' . ' + new_ext
if image . packed_file is not None or not is_ascii ( texfile ) :
# Extract packed data / copy non-ascii texture
unpack_path = os . path . join ( lnx . utils . get_fp_build ( ) , ' compiled ' , ' Assets ' , ' unpacked ' )
if not os . path . exists ( unpack_path ) :
os . makedirs ( unpack_path )
unpack_filepath = os . path . join ( unpack_path , tex [ ' file ' ] )
if do_convert :
if not os . path . isfile ( unpack_filepath ) :
fmt = ' PNG ' if new_ext == ' png ' else ' JPEG '
lnx . utils . convert_image ( image , unpack_filepath , file_format = fmt )
else :
# Write bytes if size is different or file does not exist yet
if image . packed_file is not None :
if not os . path . isfile ( unpack_filepath ) or os . path . getsize ( unpack_filepath ) != image . packed_file . size :
with open ( unpack_filepath , ' wb ' ) as f :
f . write ( image . packed_file . data )
# Copy non-ascii texture
else :
if not os . path . isfile ( unpack_filepath ) or os . path . getsize ( unpack_filepath ) != os . path . getsize ( texpath ) :
shutil . copy ( texpath , unpack_filepath )
lnx . assets . add ( unpack_filepath )
else :
if not os . path . isfile ( lnx . utils . asset_path ( filepath ) ) :
log . warn ( ' Material ' + matname + ' / ' + image . name + ' - file not found( ' + filepath + ' ) ' )
return None
if do_convert :
unpack_path = os . path . join ( lnx . utils . get_fp_build ( ) , ' compiled ' , ' Assets ' , ' unpacked ' )
if not os . path . exists ( unpack_path ) :
os . makedirs ( unpack_path )
converted_path = os . path . join ( unpack_path , tex [ ' file ' ] )
# TODO: delete cache when file changes
if not os . path . isfile ( converted_path ) :
fmt = ' PNG ' if new_ext == ' png ' else ' JPEG '
lnx . utils . convert_image ( image , converted_path , file_format = fmt )
lnx . assets . add ( converted_path )
else :
2025-11-06 16:29:46 +00:00
wrd = bpy . data . worlds [ ' Lnx ' ]
max_size = int ( wrd . lnx_max_texture_size )
if max_size > 0 and image is not None :
2026-02-24 17:35:26 -08:00
original_filepath = filepath
2025-11-06 16:29:46 +00:00
filepath = resize_texture_if_needed ( image , filepath , max_size )
2026-04-27 19:21:50 -07:00
2026-02-24 17:35:26 -08:00
if filepath != original_filepath :
resized_filename = lnx . utils . extract_filename ( filepath )
tex [ ' file ' ] = lnx . utils . safestr ( resized_filename )
2026-09-17 15:59:45 -04:00
2025-01-22 16:18:30 +01:00
# Link image path to assets
# TODO: Khamake converts .PNG to .jpg? Convert ext to lowercase on windows
if lnx . utils . get_os ( ) == ' win ' :
s = filepath . rsplit ( ' . ' , 1 )
lnx . assets . add ( lnx . utils . asset_path ( s [ 0 ] + ' . ' + s [ 1 ] . lower ( ) ) )
else :
lnx . assets . add ( lnx . utils . asset_path ( filepath ) )
# if image_format != 'RGBA32':
# tex['format'] = image_format
rpdat = lnx . utils . get_rp ( )
texfilter = rpdat . lnx_texture_filter
if texfilter == ' Anisotropic ' :
interpolation = ' Smart '
elif texfilter == ' Linear ' :
interpolation = ' Linear '
elif texfilter == ' Point ' :
interpolation = ' Closest '
if interpolation == ' Cubic ' : # Mipmap linear
tex [ ' mipmap_filter ' ] = ' linear '
tex [ ' generate_mipmaps ' ] = True
elif interpolation == ' Smart ' : # Mipmap anisotropic
tex [ ' min_filter ' ] = ' anisotropic '
tex [ ' mipmap_filter ' ] = ' linear '
tex [ ' generate_mipmaps ' ] = True
elif interpolation == ' Closest ' :
tex [ ' min_filter ' ] = ' point '
tex [ ' mag_filter ' ] = ' point '
# else defaults to linear
if extension != ' REPEAT ' : # Extend or clip
tex [ ' u_addressing ' ] = ' clamp '
tex [ ' v_addressing ' ] = ' clamp '
if image . source == ' MOVIE ' :
tex [ ' source ' ] = ' movie '
tex [ ' min_filter ' ] = ' linear '
tex [ ' mag_filter ' ] = ' linear '
tex [ ' mipmap_filter ' ] = ' no '
tex [ ' generate_mipmaps ' ] = False
return tex
def make_texture_from_image_node ( image_node : bpy . types . ShaderNodeTexImage , tex_name : str , matname : str = None ) - > Optional [ Dict [ str , Any ] ] :
if matname is None :
matname = mat_state . material . name
return make_texture ( image_node . image , tex_name , matname , image_node . interpolation , image_node . extension )
def is_pow ( num ) :
return ( ( num & ( num - 1 ) ) == 0 ) and num != 0
def is_ascii ( s ) :
return len ( s ) == len ( s . encode ( ) )
##
def get_lnx_export_tangents ( ) :
return bpy . data . worlds [ ' Lnx ' ] . lnx_export_tangents
def safesrc ( name ) :
return lnx . utils . safesrc ( name )
def disp_enabled ( ) :
return lnx . utils . disp_enabled ( lnx . make_state . target )
2026-09-20 11:15:14 -07:00
def disp_mode ( output_node ) :
inp = output_node . inputs [ 2 ]
if not inp . is_linked :
return ( ' off ' , False )
l = inp . links [ 0 ]
if l . from_node . type == ' GROUP ' and l . from_node . node_tree . name . startswith ( ' Leenkx PBR ' ) and \
not l . from_node . inputs [ 7 ] . is_linked :
return ( ' off ' , False )
mech = lnx . utils . get_rp ( ) . lnx_rp_displacement
if bpy . app . version > = ( 4 , 1 , 0 ) :
method = mat_state . material . displacement_method
else :
method = getattr ( mat_state . material . cycles , ' displacement_method ' , ' BUMP ' )
if mech == ' Off ' :
return ( ' off ' , False )
if mech == ' Bump ' :
return ( ' off ' , True )
if mech == ' Tessellation ' and not disp_enabled ( ) :
log . warn ( ' Tessellation not available on ' + lnx . make_state . target )
return ( ' off ' , True )
geom = ' tessellation ' if mech == ' Tessellation ' else ' vertex '
if method == ' BUMP ' :
return ( geom , False )
return ( geom , method in ( ' DISPLACEMENT ' , ' BOTH ' ) )
2025-01-22 16:18:30 +01:00
def assets_add ( path ) :
lnx . assets . add ( path )
def assets_add_embedded_data ( path ) :
lnx . assets . add_embedded_data ( path )
def tree_name ( ) - > str :
return state . tree_name
def mat_batch ( ) :
return mat_state . batch
def mat_bind_texture ( tex ) :
mat_state . bind_textures . append ( tex )
def mat_get_material ( ) :
return mat_state . material
def mat_get_material_users ( ) :
return mat_state . mat_users