Repe [T3DU] and Moises Jpelaez updates

This commit is contained in:
2026-05-12 23:54:06 -07:00
parent 6b404f9da6
commit 39091e8db3
147 changed files with 5539 additions and 1750 deletions

View File

@ -55,6 +55,16 @@ def reset():
shader_cons['voxel_frag'] = []
shader_cons['voxel_geom'] = []
def reset_shader_cons():
# Reset shader comparison arrays to prevent cross-scene shader merging
global shader_cons
shader_cons['mesh_vert'] = []
shader_cons['depth_vert'] = []
shader_cons['depth_frag'] = []
shader_cons['voxel_vert'] = []
shader_cons['voxel_frag'] = []
shader_cons['voxel_geom'] = []
def add(asset_file):
global assets

File diff suppressed because it is too large Load Diff

View File

@ -133,7 +133,7 @@ def export_mesh_data(self, export_mesh: bpy.types.Mesh, bobject: bpy.types.Objec
# Shape keys UV are exported separately, so reduce UV count by 1
num_uv_layers -= 1
morph_uv_index = self.get_morph_uv_index(bobject.data)
has_tex = self.get_export_uvs(export_mesh) or num_uv_layers > 0 # TODO FIXME: this should use an `and` instead of `or`. Workaround to completely ignore if the mesh has the `export_uvs` flag. Only checking the `uv_layers` to bypass issues with materials in linked objects.
has_tex = self.get_export_uvs(export_mesh) or num_uv_layers > 0 # FIXME: this should use an `and` instead of `or`. Workaround to completely ignore if the mesh has the `export_uvs` flag. Only checking the `uv_layers` to bypass issues with materials in linked objects.
if self.has_baked_material(bobject, export_mesh.materials):
has_tex = True
has_tex1 = has_tex and num_uv_layers > 1
@ -177,16 +177,16 @@ def export_mesh_data(self, export_mesh: bpy.types.Mesh, bobject: bpy.types.Objec
for v in lay0.data:
if abs(v.uv[0]) > maxdim:
maxdim = abs(v.uv[0])
if abs(v.uv[1]) > maxdim:
maxdim = abs(v.uv[1])
if abs(1.0 - v.uv[1]) > maxdim:
maxdim = abs(1.0 - v.uv[1])
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])
if abs(1.0 - v.uv[1]) > maxdim:
maxdim = abs(1.0 - v.uv[1])
maxdim_uvlayer = lay1
if has_morph_target:
morph_data = np.empty(num_verts * 2, dtype='<f4')
@ -195,8 +195,8 @@ def export_mesh_data(self, export_mesh: bpy.types.Mesh, bobject: bpy.types.Objec
if abs(v.uv[0]) > maxdim:
maxdim = abs(v.uv[0])
maxdim_uvlayer = lay2
if abs(v.uv[1]) > maxdim:
maxdim = abs(v.uv[1])
if abs(1.0 - v.uv[1]) > maxdim:
maxdim = abs(1.0 - v.uv[1])
maxdim_uvlayer = lay2
if maxdim > 1:
o['scale_tex'] = maxdim

View File

@ -344,7 +344,7 @@ def apply_materials(load_atlas=0):
else:
mat.node_tree.links.new(lightmapNode.outputs[0], mixNode.inputs[6]) #Connect lightmap node to mixnode
mat.node_tree.links.new(baseColorNode.outputs[0], mixNode.inputs[7]) #Connect basecolor to pbr node
mat.node_tree.links.new(mixNode.outputs[0], mainNode.inputs[2]) #Connect mixnode to pbr node
mat.node_tree.links.new(mixNode.outputs[2], mainNode.inputs[0]) #Connect mixnode to pbr node
if not scene.TLM_EngineProperties.tlm_target == "vertex":
mat.node_tree.links.new(UVLightmap.outputs[0], lightmapNode.inputs[0]) #Connect uvnode to lightmapnode

View File

@ -811,7 +811,7 @@ def set_settings():
print(bpy.app.version)
if bpy.app.version[0] == 3 or byp.app.version[0] == 4:
if bpy.app.version[0] == 3 or bpy.app.version[0] == 4:
if cycles.device == "GPU":
scene.cycles.tile_size = 256
else:

View File

@ -0,0 +1,144 @@
"""Utilities for handling linked blend files in Leenkx exports."""
from contextlib import contextmanager
from pathlib import Path
from typing import Dict, Optional
import bpy
import lnx
if lnx.is_reload(__name__):
pass
else:
lnx.enable_reload(__name__)
def is_linked(bdata) -> bool:
"""Check if a data block is linked from an external library."""
if bdata is None:
return False
return bdata.library is not None
def get_library_name(bdata) -> Optional[str]:
"""Get the library filename for a linked data block."""
if bdata is None or bdata.library is None:
return None
return bdata.library.name
def get_library_path(bdata) -> Optional[Path]:
"""Get the absolute path to the library .blend file."""
if bdata is None or bdata.library is None:
return None
return Path(bpy.path.abspath(bdata.library.filepath))
def asset_name(bdata) -> Optional[str]:
"""Get qualified asset name with library suffix for linked data."""
if bdata is None:
return None
name = bdata.name
if bdata.library is not None:
name += '_' + bdata.library.name
return name
def get_source_path(bdata) -> Optional[Path]:
"""Get Sources folder path for a linked data block's project."""
lib_path = get_library_path(bdata)
if lib_path is None:
return None
sources_path = lib_path.parent / 'Sources'
if sources_path.exists() and sources_path.is_dir():
return sources_path
return None
class TransformEvaluator:
"""Context manager for evaluating linked object transforms (Blender 4.2+ workaround)."""
def __init__(self, bobject: bpy.types.Object, scene: bpy.types.Scene,
depsgraph: bpy.types.Depsgraph):
self.bobject = bobject
self.scene = scene
self.depsgraph = depsgraph
self._temp_collection: Optional[bpy.types.Collection] = None
self._evaluated_obj: Optional[bpy.types.Object] = None
self._is_linked = False
def __enter__(self) -> 'TransformEvaluator':
if bpy.app.version >= (4, 2, 0):
self._is_linked = self.bobject.name not in self.scene.collection.children
if self._is_linked:
self._temp_collection = bpy.data.collections.new("_lnx_temp_eval")
bpy.context.scene.collection.children.link(self._temp_collection)
self._temp_collection.objects.link(self.bobject)
temp_depsgraph = bpy.context.evaluated_depsgraph_get()
self._evaluated_obj = self.bobject.evaluated_get(temp_depsgraph)
else:
self._evaluated_obj = self.bobject.evaluated_get(self.depsgraph)
else:
self._evaluated_obj = self.bobject
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if self._is_linked and self._temp_collection is not None:
try:
self._temp_collection.objects.unlink(self.bobject)
bpy.context.scene.collection.children.unlink(self._temp_collection)
bpy.data.collections.remove(self._temp_collection)
except Exception:
pass
self._temp_collection = None
return False
@property
def evaluated_object(self) -> bpy.types.Object:
return self._evaluated_obj
@property
def matrix_local(self):
if bpy.app.version >= (4, 2, 0):
return self._evaluated_obj.matrix_local.copy()
return self.bobject.matrix_local
@contextmanager
def evaluated_mesh(bobject: bpy.types.Object, scene: bpy.types.Scene,
depsgraph: bpy.types.Depsgraph, apply_modifiers: bool = True):
"""Context manager for mesh export with Blender 4.2+ linked object workaround."""
temp_collection = None
is_linked = False
try:
if apply_modifiers and bpy.app.version >= (4, 2, 0):
is_linked = bobject.name not in scene.collection.children
if is_linked:
temp_collection = bpy.data.collections.new("_lnx_temp_mesh_eval")
bpy.context.scene.collection.children.link(temp_collection)
temp_collection.objects.link(bobject)
temp_depsgraph = bpy.context.evaluated_depsgraph_get()
bobject_eval = bobject.evaluated_get(temp_depsgraph)
yield bobject_eval, temp_depsgraph
finally:
if is_linked and temp_collection is not None:
try:
temp_collection.objects.unlink(bobject)
bpy.context.scene.collection.children.unlink(temp_collection)
bpy.data.collections.remove(temp_collection)
except Exception:
pass
def discover_linked_sources() -> Dict[str, Path]:
"""Discover Sources folders from all linked libraries."""
sources: Dict[str, Path] = {}
for lib in bpy.data.libraries:
lib_path = Path(bpy.path.abspath(lib.filepath))
sources_path = lib_path.parent / 'Sources'
if sources_path.exists() and sources_path.is_dir():
sources[lib.name] = sources_path
return sources

View File

@ -28,6 +28,8 @@ def init_categories():
lnx_nodes.add_category('Logic', icon='OUTLINER', section="basic",
description="Logic nodes are used to control execution flow using branching, loops, gates etc.")
lnx_nodes.add_category('Event', icon='INFO', section="basic")
lnx_nodes.add_category('Signal', icon='LINKED', section="basic",
description="Signal nodes provide type-safe, instance-based event communication between traits.")
lnx_nodes.add_category('Input', icon='GREASEPENCIL', section="basic")
lnx_nodes.add_category('Native', icon='MEMORY', section="basic",
description="The Native category contains nodes which interact with the system (Input/Output functionality, etc.) or Haxe.")

View File

@ -0,0 +1,18 @@
from lnx.logicnode.lnx_nodes import *
class GetTilesheetFlipNode(LnxLogicTreeNode):
"""Returns the flip state of the tilesheet.
@output Flip X: Whether the sprite is flipped horizontally.
@output Flip Y: Whether the sprite is flipped vertically.
"""
bl_idname = 'LNGetTilesheetFlipNode'
bl_label = 'Get Tilesheet Flip'
lnx_version = 1
lnx_section = 'tilesheet'
def lnx_init(self, context):
self.add_input('LnxNodeSocketObject', 'Object')
self.add_output('LnxBoolSocket', 'Flip X')
self.add_output('LnxBoolSocket', 'Flip Y')

View File

@ -2,8 +2,8 @@ from lnx.logicnode.lnx_nodes import *
class GetTilesheetStateNode(LnxLogicTreeNode):
"""Returns the information about the current tilesheet of the given object.
@output Active Tilesheet: Current active tilesheet.
@output Tilesheet: Tilesheet name.
@output Active Action: Current action in the tilesheet.
@ -15,23 +15,28 @@ class GetTilesheetStateNode(LnxLogicTreeNode):
"""
bl_idname = 'LNGetTilesheetStateNode'
bl_label = 'Get Tilesheet State'
lnx_version = 2
lnx_version = 4
lnx_section = 'tilesheet'
def lnx_init(self, context):
self.add_input('LnxNodeSocketObject', 'Object')
self.add_output('LnxStringSocket', 'Active Tilesheet')
self.add_output('LnxStringSocket', 'Tilesheet')
self.add_output('LnxStringSocket', 'Active Action')
self.add_output('LnxIntSocket', 'Frame')
self.add_output('LnxIntSocket', 'Absolute Frame')
self.add_output('LnxBoolSocket', 'Is Paused')
def get_replacement_node(self, node_tree: bpy.types.NodeTree):
if self.lnx_version not in (0, 1):
raise LookupError()
return NodeReplacement(
'LNGetTilesheetStateNode', self.lnx_version, 'LNGetTilesheetStateNode', 2,
in_socket_mapping={}, out_socket_mapping={0:1, 1:3, 2:4}
)
if self.lnx_version in (0, 1):
return NodeReplacement(
'LNGetTilesheetStateNode', self.lnx_version, 'LNGetTilesheetStateNode', 4,
in_socket_mapping={}, out_socket_mapping={0: 1, 1: 3, 2: 4}
)
elif self.lnx_version in (2, 3):
# Version 2 and 3 have same outputs, just rename Material to Tilesheet
return NodeReplacement(
'LNGetTilesheetStateNode', self.lnx_version, 'LNGetTilesheetStateNode', 4,
in_socket_mapping={0: 0}, out_socket_mapping={0: 0, 1: 1, 2: 2, 3: 3, 4: 4}
)
raise LookupError()

View File

@ -1,16 +1,16 @@
from lnx.logicnode.lnx_nodes import *
class PlayTilesheetNode(LnxLogicTreeNode):
class PlayTilesheetActionNode(LnxLogicTreeNode):
"""Plays the given tilesheet action."""
bl_idname = 'LNPlayTilesheetNode'
bl_label = 'Play Tilesheet'
bl_idname = 'LNPlayTilesheetActionNode'
bl_label = 'Play Tilesheet Action'
lnx_version = 1
lnx_section = 'tilesheet'
def lnx_init(self, context):
self.add_input('LnxNodeSocketAction', 'In')
self.add_input('LnxNodeSocketObject', 'Object')
self.add_input('LnxStringSocket', 'Name')
self.add_input('LnxStringSocket', 'Action')
self.add_output('LnxNodeSocketAction', 'Out')
self.add_output('LnxNodeSocketAction', 'Done')

View File

@ -1,16 +1,15 @@
from lnx.logicnode.lnx_nodes import *
class SetActiveTilesheetNode(LnxLogicTreeNode):
"""Set the active tilesheet."""
bl_idname = 'LNSetActiveTilesheetNode'
bl_label = 'Set Active Tilesheet'
class SetTilesheetActionNode(LnxLogicTreeNode):
"""Sets the tilesheet action for the given object."""
bl_idname = 'LNSetTilesheetActionNode'
bl_label = 'Set Tilesheet Action'
lnx_version = 1
lnx_section = 'tilesheet'
def lnx_init(self, context):
self.add_input('LnxNodeSocketAction', 'In')
self.add_input('LnxNodeSocketObject', 'Object')
self.add_input('LnxStringSocket', 'Tilesheet')
self.add_input('LnxStringSocket', 'Action')
self.add_output('LnxNodeSocketAction', 'Out')
self.add_output('LnxNodeSocketAction', 'Out')

View File

@ -0,0 +1,21 @@
from lnx.logicnode.lnx_nodes import *
class SetTilesheetFlipNode(LnxLogicTreeNode):
"""Set the flip state of the tilesheet for UV-based sprite flipping.
This is useful for billboarded sprites where mesh scaling cannot be used.
@input Flip X: Flip the sprite horizontally.
@input Flip Y: Flip the sprite vertically.
"""
bl_idname = 'LNSetTilesheetFlipNode'
bl_label = 'Set Tilesheet Flip'
lnx_version = 1
lnx_section = 'tilesheet'
def lnx_init(self, context):
self.add_input('LnxNodeSocketAction', 'In')
self.add_input('LnxNodeSocketObject', 'Object')
self.add_input('LnxBoolSocket', 'Flip X')
self.add_input('LnxBoolSocket', 'Flip Y')
self.add_output('LnxNodeSocketAction', 'Out')

View File

@ -0,0 +1,54 @@
from lnx.logicnode.lnx_nodes import *
class DrawImageRenderNode(LnxLogicTreeNode):
"""Draws an image render target.
@input Draw: Activate to draw the image on this frame. The input must
be (indirectly) called from an `On Render2D` node.
@input In: Activate to retrieve the imaga render target.
@input Camera: the render target image of the camera.
@input Color: The color that the image's pixels are multiplied with.
@input Left/Center/Right: Horizontal anchor point of the image.
0 = Left, 1 = Center, 2 = Right
@input Top/Middle/Bottom: Vertical anchor point of the image.
0 = Top, 1 = Middle, 2 = Bottom
@input X/Y: Position of the anchor point in pixels.
@input Width/Height: Size of the image in pixels.
@input sX/Y: Position of the sub anchor point in pixels.
@input sWidth/sHeight: Size of the sub image in pixels.
@input Angle: Rotation angle in radians. Image will be rotated cloclwiswe
at the anchor point.
@input Render2D: include Render 2D draws.
@output Out: Activated after the image has been drawn.
@see [`kha.graphics2.Graphics.drawImage()`](http://kha.tech/api/kha/graphics2/Graphics.html#drawImage).
"""
bl_idname = 'LNDrawImageRenderNode'
bl_label = 'Draw Image Render'
lnx_section = 'draw'
lnx_version = 1
def lnx_init(self, context):
self.add_input('LnxNodeSocketAction', 'Draw')
self.add_input('LnxNodeSocketAction', 'In')
self.add_input('LnxNodeSocketObject', 'Camera')
self.add_input('LnxColorSocket', 'Color', default_value=[1.0, 1.0, 1.0, 1.0])
self.add_input('LnxIntSocket', '0/1/2 = Left/Center/Right', default_value=0)
self.add_input('LnxIntSocket', '0/1/2 = Top/Middle/Bottom', default_value=0)
self.add_input('LnxFloatSocket', 'X')
self.add_input('LnxFloatSocket', 'Y')
self.add_input('LnxFloatSocket', 'Width')
self.add_input('LnxFloatSocket', 'Height')
self.add_input('LnxFloatSocket', 'sX')
self.add_input('LnxFloatSocket', 'sY')
self.add_input('LnxFloatSocket', 'sWidth')
self.add_input('LnxFloatSocket', 'sHeight')
self.add_input('LnxFloatSocket', 'Angle')
self.add_input('LnxBoolSocket', 'Render2D')
self.add_output('LnxNodeSocketAction', 'Out')

View File

@ -12,9 +12,9 @@ class DrawSubImageNode(LnxLogicTreeNode):
@input Top/Middle/Bottom: Vertical anchor point of the image.
0 = Top, 1 = Middle, 2 = Bottom
@input X/Y: Position of the anchor point in pixels.
@input Width/Height: Size of the sub image in pixels.
@input sX/Y: Position of the sub anchor point in pixels.
@input sWidth/Height: Size of the image in pixels.
@input Width/Height: Size of the image in pixels.
@input sX/sY: Position of the sub anchor point in pixels.
@input sWidth/sHeight: Size of the sub image in pixels.
@input Angle: Rotation angle in radians. Image will be rotated cloclwiswe
at the anchor point.
@output Out: Activated after the image has been drawn.

View File

@ -0,0 +1,37 @@
from lnx.logicnode.lnx_nodes import *
class DrawToImageNode(LnxLogicTreeNode):
"""Writes the given draw image to the given file. If the image
already exists, the existing content of the image is overwritten.
@input Image File: the name of the image
@input Color: The color that the image's pixels are multiplied with.
@input Width: width of the image file.
@input Height: heigth of the image file.
@input sX: sub position of first x pixel of the sub image (0 for start).
@input sY: sub position of first y pixel of the sub image (0 for start).
@input sWidth: width of the sub image.
@input sHeight: height of the sub image.
WARNING: Calling getPixels() on a renderTarget with non-standard non-POT dimensions
can cause a system crash. Ensure renderTarget resolution is a power of two
(e.g., 256x256) or a common standard resolution (e.g., 1920x1080).
"""
bl_idname = 'LNDrawToImageNode'
bl_label = 'Draw To Image'
lnx_section = 'draw'
lnx_version = 1
def lnx_init(self, context):
self.add_input('LnxNodeSocketAction', 'In')
self.add_input('LnxStringSocket', 'Image File')
self.add_input('LnxColorSocket', 'Color', default_value=[1.0, 1.0, 1.0, 1.0])
self.add_input('LnxIntSocket', 'Width')
self.add_input('LnxIntSocket', 'Height')
self.add_input('LnxIntSocket', 'sX')
self.add_input('LnxIntSocket', 'sY')
self.add_input('LnxIntSocket', 'sWidth')
self.add_input('LnxIntSocket', 'sHeight')
self.add_output('LnxNodeSocketAction', 'Out')

View File

@ -0,0 +1,69 @@
from lnx.logicnode.lnx_nodes import *
import bpy
class DrawToScreenNode(LnxLogicTreeNode):
"""Draws a Render Target image to screen.
@input Draw: Activate to draw the Render Target image to screen. The input must
be (indirectly) called from an `On Render2D` node.
@input In: Activate to get the Render Target image of the render 2d draws.
@input Draw Width/Height: Size of the Render Target image in pixels.
@input Image: The filename of the image.
@input Color: The color that the image's pixels are multiplied with.
@input Left/Center/Right: Horizontal anchor point of the image.
0 = Left, 1 = Center, 2 = Right
@input Top/Middle/Bottom: Vertical anchor point of the image.
0 = Top, 1 = Middle, 2 = Bottom
@input X/Y: Position of the anchor point in pixels.
@input Width/Height: Size of the sub image in pixels.
@input sX/Y: Position of the sub anchor point in pixels.
@input sWidth/Height: Size of the image in pixels.
@input Angle: Rotation angle in radians. Image will be rotated cloclwiswe
at the anchor point.
@input Clear Image: Clear the image before drawing to it
@output Out: Activated after the image has been drawn.
@output Draw: Input for the render 2d draws.
@see [`kha.graphics2.Graphics.drawImage()`](http://kha.tech/api/kha/graphics2/Graphics.html#drawImage).
"""
bl_idname = 'LNDrawToScreenNode'
bl_label = 'Draw To Screen'
lnx_section = 'draw'
lnx_version = 2
def lnx_init(self, context):
self.add_input('LnxNodeSocketAction', 'Draw')
self.add_input('LnxNodeSocketAction', 'In')
self.add_input('LnxIntSocket', 'Draw Width')
self.add_input('LnxIntSocket', 'Draw Height')
self.add_input('LnxColorSocket', 'Color', default_value=[1.0, 1.0, 1.0, 1.0])
self.add_input('LnxIntSocket', '0/1/2 = Left/Center/Right', default_value=0)
self.add_input('LnxIntSocket', '0/1/2 = Top/Middle/Bottom', default_value=0)
self.add_input('LnxFloatSocket', 'X')
self.add_input('LnxFloatSocket', 'Y')
self.add_input('LnxFloatSocket', 'Width')
self.add_input('LnxFloatSocket', 'Height')
self.add_input('LnxFloatSocket', 'sX')
self.add_input('LnxFloatSocket', 'sY')
self.add_input('LnxFloatSocket', 'sWidth')
self.add_input('LnxFloatSocket', 'sHeight')
self.add_input('LnxFloatSocket', 'Angle')
self.add_input('LnxBoolSocket', 'Clear Image')
self.add_output('LnxNodeSocketAction', 'Out')
self.add_output('LnxNodeSocketAction', 'Draw')
def get_replacement_node(self, node_tree: bpy.types.NodeTree):
if self.lnx_version not in (0, 1):
raise LookupError()
return NodeReplacement(
"LNDrawToScreenNode", self.lnx_version,
"LNDrawToScreenNode", 2,
in_socket_mapping={0:1, 1:2, 2:3, 3:4, 4:5, 5:6, 6:7, 7:8, 8:9, 9:10, 10:11, 11:12, 12:13, 13:14, 14:15},
out_socket_mapping={0:0},
)

View File

@ -4,6 +4,7 @@ class OnUpdateNode(LnxLogicTreeNode):
"""Activates the output on every frame.
@option Update: (default) activates the output every frame.
@option Fixed Update: activates the output at a fixed time step.
@option Late Update: activates the output after all non-late updates are calculated.
@option Physics Pre-Update: activates the output before calculating the physics.
Only available when using a physics engine."""
@ -13,6 +14,7 @@ class OnUpdateNode(LnxLogicTreeNode):
property0: HaxeEnumProperty(
'property0',
items = [('Update', 'Update', 'Update'),
('Fixed Update', 'Fixed Update', 'Fixed Update'),
('Late Update', 'Late Update', 'Late Update'),
('Physics Pre-Update', 'Physics Pre-Update', 'Physics Pre-Update')],
name='On', default='Update')

View File

@ -88,7 +88,7 @@ class LnxAnimActionSocket(LnxCustomSocket):
default_value_raw: PointerProperty(name='Action', type=bpy.types.Action, update=_on_update_socket)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
super(LnxAnimActionSocket, self).__init__(*args, **kwargs)
if self.default_value_get is not None:
self.default_value_raw = self.default_value_get
self.default_value_get = None
@ -510,7 +510,7 @@ class LnxObjectSocket(LnxCustomSocket):
default_value_raw: PointerProperty(name='Object', type=bpy.types.Object, update=_on_update_socket)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
super(LnxObjectSocket, self).__init__(*args, **kwargs)
if self.default_value_get is not None:
self.default_value_raw = self.default_value_get
self.default_value_get = None

View File

@ -15,8 +15,8 @@ class SelectOutputNode(LnxLogicTreeNode):
lnx_version = 1
min_outputs = 2
def __init__(self):
super(SelectOutputNode, self).__init__()
def __init__(self, *args, **kwargs):
super(SelectOutputNode, self).__init__(*args, **kwargs)
array_nodes[self.get_id_str()] = self
def lnx_init(self, context):

View File

@ -29,7 +29,7 @@ class CameraSetNode(LnxLogicTreeNode):
if self.property0 == 'DoF F-Stop':
self.add_input('LnxFloatSocket', 'DoF F-Stop', default_value=128.0)#8
if self.property0 == 'Tonemapping':
self.add_input('LnxIntSocket', 'Tonemapping', default_value=5)#9
self.add_input('LnxIntSocket', 'Tonemapping', default_value=0)#9
if self.property0 == 'Distort':
self.add_input('LnxFloatSocket', 'Distort', default_value=2.0)#10
if self.property0 == 'Film Grain':
@ -75,12 +75,12 @@ class CameraSetNode(LnxLogicTreeNode):
layout.label(text="1: Filmic2")
layout.label(text="2: Reinhard")
layout.label(text="3: Uncharted2")
layout.label(text="5: Agx")
layout.label(text="6: None")
layout.label(text="5: None")
layout.prop(self, 'property0')
def get_replacement_node(self, node_tree: bpy.types.NodeTree):
if self.lnx_version not in range(0, 4):
if self.lnx_version not in range(0, 6):
raise LookupError()
elif self.lnx_version == 1:
newnode = node_tree.nodes.new('LNCameraSetNode')

View File

@ -17,7 +17,8 @@ class ProbabilisticIndexNode(LnxLogicTreeNode):
num_choices: IntProperty(default=0, min=0)
def __init__(self):
def __init__(self, *args, **kwargs):
super(ProbabilisticIndexNode, self).__init__(*args, **kwargs)
array_nodes[str(id(self))] = self
def lnx_init(self, context):

View File

@ -0,0 +1,45 @@
from lnx.logicnode.lnx_nodes import *
class EmitSignalNode(LnxLogicTreeNode):
"""Emits a Signal with optional arguments.
Connect a Signal instance to the Signal input. When this node is activated,
it calls emit() on the Signal, passing any connected arguments to all
connected OnSignal nodes.
Use 'Add Arg' to add input sockets for passing data to listeners.
@seeNode Signal
@seeNode On Signal"""
bl_idname = 'LNEmitSignalNode'
bl_label = 'Emit Signal'
lnx_version = 1
lnx_section = 'signal'
min_inputs = 2
def __init__(self, *args, **kwargs):
super(EmitSignalNode, self).__init__(*args, **kwargs)
array_nodes[str(id(self))] = self
def lnx_init(self, context):
self.add_input('LnxNodeSocketAction', 'In')
self.add_input('LnxDynamicSocket', 'Signal')
self.add_output('LnxNodeSocketAction', 'Out')
def draw_buttons(self, context, layout):
row = layout.row(align=True)
op = row.operator('lnx.node_add_input', text='Add Arg', icon='PLUS', emboss=True)
op.node_index = str(id(self))
op.socket_type = 'LnxDynamicSocket'
op.name_format = "Arg {0}"
op.index_name_offset = -1
column = row.column(align=True)
op = column.operator('lnx.node_remove_input', text='', icon='X', emboss=True)
op.node_index = str(id(self))
if len(self.inputs) == self.min_inputs:
column.enabled = False

View File

@ -0,0 +1,25 @@
from lnx.logicnode.lnx_nodes import *
class GlobalSignalNode(LnxLogicTreeNode):
"""Gets or creates a global Signal by name.
Global Signals are stored in a static registry and can be accessed from
any logic tree in the scene. Provide a unique name to identify the signal.
Use this for communication between different objects or logic trees without
needing to pass Signal references directly.
@seeNode Signal
@seeNode On Signal
@seeNode Emit Signal"""
bl_idname = 'LNGlobalSignalNode'
bl_label = 'Global Signal'
lnx_version = 1
lnx_section = 'signal'
def lnx_init(self, context):
self.add_input('LnxStringSocket', 'Property')
self.add_output('LnxDynamicSocket', 'Signal')

View File

@ -0,0 +1,44 @@
from lnx.logicnode.lnx_nodes import *
class OnSignalNode(LnxLogicTreeNode):
"""Activates the output when the given Signal emits.
Connect a Signal instance to the input. When that Signal emits,
the output is activated and emitted arguments are available on
the dynamic output sockets.
Use 'Add Arg' to add output sockets for receiving emitted data.
@seeNode Signal
@seeNode Emit Signal"""
bl_idname = 'LNOnSignalNode'
bl_label = 'On Signal'
lnx_version = 1
lnx_section = 'signal'
min_outputs = 1
def __init__(self, *args, **kwargs):
super(OnSignalNode, self).__init__(*args, **kwargs)
array_nodes[str(id(self))] = self
def lnx_init(self, context):
self.add_input('LnxDynamicSocket', 'Signal')
self.add_output('LnxNodeSocketAction', 'Out')
def draw_buttons(self, context, layout):
row = layout.row(align=True)
op = row.operator('lnx.node_add_output', text='Add Arg', icon='PLUS', emboss=True)
op.node_index = str(id(self))
op.socket_type = 'LnxDynamicSocket'
op.name_format = "Arg {0}"
op.index_name_offset = 0
column = row.column(align=True)
op = column.operator('lnx.node_remove_output', text='', icon='X', emboss=True)
op.node_index = str(id(self))
if len(self.outputs) == self.min_outputs:
column.enabled = False

View File

@ -0,0 +1,27 @@
from lnx.logicnode.lnx_nodes import *
class SignalNode(LnxLogicTreeNode):
"""Creates a new Signal or references an existing Signal from an object's property.
**Standalone Mode (default):**
Creates a new Signal instance that can be connected to OnSignal and EmitSignal nodes.
The Signal is stored in the LogicTree and persists for the lifetime of the trait.
**Reference Mode:**
When Object and Property inputs are connected, retrieves an existing Signal
from a Haxe trait's property using reflection.
@seeNode On Signal
@seeNode Emit Signal"""
bl_idname = 'LNSignalNode'
bl_label = 'Signal'
lnx_version = 1
lnx_section = 'signal'
def lnx_init(self, context):
self.add_input('LnxNodeSocketObject', 'Object')
self.add_input('LnxStringSocket', 'Property')
self.add_output('LnxDynamicSocket', 'Signal')

View File

@ -0,0 +1,3 @@
from lnx.logicnode.lnx_nodes import add_node_section
add_node_section(name='signal', category='Signal')

View File

@ -0,0 +1,28 @@
from lnx.logicnode.lnx_nodes import *
class SetFirstPersonControllerNode(LnxLogicTreeNode):
"""Config Visual"""
bl_idname = 'LNSetFirstPersonControllerNode'
bl_label = 'Set FirstPersonControllerSettings'
lnx_section = 'props'
lnx_version = 1
def lnx_init(self, context):
self.add_input('LnxNodeSocketAction', 'In')
self.add_input('LnxNodeSocketObject', 'Object')
"""Config de la camara"""
self.add_input('LnxFloatSocket', 'RotationSpeed')
self.add_input('LnxFloatSocket', 'MaxPitch')
self.add_input('LnxFloatSocket', 'MinPitch')
self.add_input('LnxFloatSocket', 'MoveSpeed')
self.add_input('LnxFloatSocket', 'RunSpeed')
self.add_input('LnxBoolSocket', 'EnableJump')
self.add_input('LnxBoolSocket', 'EnableAllowAirJump')
self.add_input('LnxBoolSocket', 'EnableRun')
self.add_input('LnxBoolSocket', 'EnableStamina')
self.add_input('LnxBoolSocket', 'EnableFatigue')
self.add_output('LnxNodeSocketAction', 'Out')

View File

@ -0,0 +1,28 @@
from lnx.logicnode.lnx_nodes import *
class SetOverheadPersonControllerNode(LnxLogicTreeNode):
"""Config Visual"""
bl_idname = 'LNSetOverheadPersonControllerNode'
bl_label = 'Set OverheadPersonControllerSettings'
lnx_section = 'props'
lnx_version = 2
def lnx_init(self, context):
self.add_input('LnxNodeSocketAction', 'In')
self.add_input('LnxNodeSocketObject', 'Object')
"""Suavizado"""
self.add_input('LnxBoolSocket', 'EnableSmoothTrack')
self.add_input('LnxFloatSocket', 'SmoothSpeed')
self.add_input('LnxFloatSocket', 'MoveSpeed')
self.add_input('LnxFloatSocket', 'RunSpeed')
self.add_input('LnxBoolSocket', 'EnableJump')
self.add_input('LnxBoolSocket', 'EnableAllowAirJump')
self.add_input('LnxBoolSocket', 'EnableRun')
self.add_input('LnxBoolSocket', 'EnableStamina')
self.add_input('LnxBoolSocket', 'EnableFatigue')
self.add_output('LnxNodeSocketAction', 'Out')

View File

@ -0,0 +1,15 @@
from lnx.logicnode.lnx_nodes import *
class ReplaceObjectNode(LnxLogicTreeNode):
"""Replace location and rotation between two objects"""
bl_idname = 'LNReplaceObjectNode'
bl_label = 'Replace Object'
lnx_version = 2
def lnx_init(self, context):
self.add_input('LnxNodeSocketAction', 'In')
self.add_input('LnxNodeSocketObject', 'Base')
self.add_input('LnxNodeSocketObject', 'Replace')
self.add_input('LnxBoolSocket', 'Invert')
self.add_input('LnxBoolSocket', 'Scale')
self.add_output('LnxNodeSocketAction', 'Out')

View File

@ -118,10 +118,12 @@ def remove_readonly(func, path, excinfo):
func(path)
appended_scenes = []
linked_blend_paths = []
linked_scenes = []
def load_external_blends():
global appended_scenes
global linked_scenes
global linked_blend_paths
wrd = bpy.data.worlds['Lnx']
if not hasattr(wrd, 'lnx_external_blends_path'):
@ -146,22 +148,24 @@ def load_external_blends():
with bpy.data.libraries.load(blend_path, link=True) as (data_from, data_to):
data_to.scenes = list(data_from.scenes)
linked_blend_paths.append(blend_path)
for scn in data_to.scenes:
if scn is not None and scn not in appended_scenes:
# make name unique with file name
scn.name += "_" + filename.replace(".blend", "")
appended_scenes.append(scn)
if scn is not None and scn not in linked_scenes:
linked_scenes.append(scn)
log.info(f"Loaded external blend: {blend_path}")
except Exception as e:
log.error(f"Failed to load external blend {blend_path}: {e}")
def clear_external_scenes():
global appended_scenes
if not appended_scenes:
global linked_blend_paths
global linked_scenes
if not linked_scenes and not linked_blend_paths:
return
for scn in appended_scenes:
for scn in linked_scenes:
try:
bpy.data.scenes.remove(scn, do_unlink=True)
except Exception as e:
@ -169,7 +173,7 @@ def clear_external_scenes():
for lib in list(bpy.data.libraries):
try:
if lib.users == 0:
if lib.users == 0 or lib.filepath in linked_blend_paths:
bpy.data.libraries.remove(lib)
except Exception as e:
log.error(f"Failed to remove library {lib.name}: {e}")
@ -179,7 +183,8 @@ def clear_external_scenes():
except Exception as e:
log.error(f"Failed to purge orphan data: {e}")
appended_scenes = []
linked_scenes = []
linked_blend_paths = []
def export_data(fp, sdk_path):
state.is_exporting = True
@ -190,6 +195,11 @@ def export_data(fp, sdk_path):
def export_data_impl(fp, sdk_path):
# Reload all libraries to retrieve updated data without needing to restart Blender
for lib in bpy.data.libraries:
lib.reload()
log.info(f"Reloaded: {lib.filepath}")
load_external_blends()
wrd = bpy.data.worlds['Lnx']
@ -263,8 +273,10 @@ def export_data_impl(fp, sdk_path):
for scene in bpy.data.scenes:
if scene.lnx_export:
# Reset shader comparison arrays to prevent cross-scene shader merging
assets.reset_shader_cons()
ext = '.lz4' if LeenkxExporter.compress_enabled else '.lnx'
asset_path = build_dir + '/compiled/Assets/' + lnx.utils.safestr(scene.name) + ext
asset_path = build_dir + '/compiled/Assets/' + lnx.utils.safestr(scene.name + "_" + os.path.basename(scene.library.filepath).replace(".blend", "") if scene.library else scene.name) + ext
LeenkxExporter.export_scene(bpy.context, asset_path, scene=scene, depsgraph=depsgraph)
if LeenkxExporter.export_physics:
physics_found = True

View File

@ -149,7 +149,7 @@ def add_world_defs():
wrd.world_defs += '_Clusters'
assets.add_khafile_def('lnx_clusters')
if '_Rad' in wrd.world_defs and '_Brdf' not in wrd.world_defs:
if ('_Rad' in wrd.world_defs or ('_EnvCol' in wrd.world_defs and rpdat.lnx_material_model == 'Full')) and '_Brdf' not in wrd.world_defs:
wrd.world_defs += '_Brdf'
assets.add_khafile_def("lnx_brdf")
@ -369,6 +369,8 @@ def build():
assets.add_khafile_def('rp_voxels={0}'.format(rpdat.rp_voxels))
assets.add_khafile_def('rp_voxelgi_resolution={0}'.format(rpdat.rp_voxelgi_resolution))
assets.add_khafile_def('rp_voxelgi_resolution_z={0}'.format(rpdat.rp_voxelgi_resolution_z))
else:
assets.add_khafile_def('rp_voxels=Off')
if rpdat.lnx_rp_resolution == 'Custom':
assets.add_khafile_def('rp_resolution_filter={0}'.format(rpdat.lnx_rp_resolution_filter))

View File

@ -31,6 +31,29 @@ callback = None
shader_datas = []
def add_world_def(world: bpy.types.World, define: str):
if define not in world.world_defs:
world.world_defs += define
def add_global_def(define: str):
wrd = bpy.data.worlds['Lnx']
if define not in wrd.world_defs:
wrd.world_defs += define
def add_irradiance_defs(world: bpy.types.World, rpdat):
if rpdat.lnx_irradiance and rpdat.lnx_material_model != 'Solid':
add_world_def(world, '_Irr')
add_global_def('_Irr')
assets.add_khafile_def("lnx_irradiance")
def mark_color_environment(world: bpy.types.World):
add_world_def(world, '_EnvCol')
add_global_def('_EnvCol')
def build():
"""Builds world shaders for all exported worlds."""
global shader_datas
@ -38,6 +61,10 @@ def build():
wrd = bpy.data.worlds['Lnx']
rpdat = lnx.utils.get_rp()
if rpdat is None:
log.error("No render path found. Please ensure a valid render path is selected.")
return
mobile_mat = rpdat.lnx_material_model == 'Mobile' or rpdat.lnx_material_model == 'Solid'
envpath = os.path.join(lnx.utils.get_fp_build(), 'compiled', 'Assets', 'envmaps')
@ -170,7 +197,7 @@ def build_node_tree(world: bpy.types.World, frag: Shader, vert: Shader, con: Sha
# film_transparent, do not render
if bpy.context.scene is not None and bpy.context.scene.render.film_transparent:
world.world_defs += '_EnvCol'
mark_color_environment(world)
frag.add_uniform('vec3 backgroundCol', link='_backgroundCol')
frag.write('fragColor.rgb = backgroundCol;')
return
@ -191,15 +218,11 @@ def build_node_tree(world: bpy.types.World, frag: Shader, vert: Shader, con: Sha
# No world nodes/no output node, use background color
if not is_parsed:
solid_mat = rpdat.lnx_material_model == 'Solid'
if rpdat.lnx_irradiance and not solid_mat:
world.world_defs += '_Irr'
assets.add_khafile_def("lnx_irradiance")
add_irradiance_defs(world, rpdat)
col = world.color
world.lnx_envtex_color = [col[0], col[1], col[2], 1.0]
world.lnx_envtex_strength = 1.0
world.world_defs += '_EnvCol'
assets.add_khafile_def("lnx_envcol")
mark_color_environment(world)
# Clouds enabled
if rpdat.lnx_clouds and world.lnx_use_clouds:
@ -209,12 +232,13 @@ def build_node_tree(world: bpy.types.World, frag: Shader, vert: Shader, con: Sha
wrd.world_defs += '_EnvClouds'
frag_write_clouds(world, frag)
if '_EnvSky' in world.world_defs or '_EnvTex' in world.world_defs or '_EnvImg' in world.world_defs or '_EnvClouds' in world.world_defs:
if '_EnvSky' in world.world_defs or '_EnvTex' in world.world_defs or '_EnvImg' in world.world_defs or '_EnvCol' in world.world_defs or '_EnvClouds' in world.world_defs:
frag.add_uniform('float envmapStrength', link='_envmapStrength')
# Clear background color
if '_EnvCol' in world.world_defs:
frag.write('fragColor.rgb = backgroundCol;')
frag.add_uniform('vec3 backgroundCol', link='_backgroundCol')
frag.write('fragColor.rgb = backgroundCol * envmapStrength;')
elif '_EnvTex' in world.world_defs and '_EnvLDR' in world.world_defs:
frag.write('fragColor.rgb = pow(fragColor.rgb, vec3(2.2));')
@ -273,15 +297,11 @@ def parse_world_output(world: bpy.types.World, node_output: bpy.types.Node, frag
def parse_surface(world: bpy.types.World, node_surface: bpy.types.Node, frag: Shader):
wrd = bpy.data.worlds['Lnx']
rpdat = lnx.utils.get_rp()
solid_mat = rpdat.lnx_material_model == 'Solid'
if node_surface.type in ('BACKGROUND', 'EMISSION'):
# Append irradiance define
if rpdat.lnx_irradiance and not solid_mat:
wrd.world_defs += '_Irr'
assets.add_khafile_def("lnx_irradiance")
add_irradiance_defs(world, rpdat)
# Extract environment strength
# Todo: follow/parse strength input
@ -292,12 +312,8 @@ def parse_surface(world: bpy.types.World, node_surface: bpy.types.Node, frag: Sh
frag.write(f'fragColor.rgb = {out};')
if not node_surface.inputs[0].is_linked:
solid_mat = rpdat.lnx_material_model == 'Solid'
if rpdat.lnx_irradiance and not solid_mat:
world.world_defs += '_Irr'
assets.add_khafile_def("lnx_irradiance")
world.lnx_envtex_color = node_surface.inputs[0].default_value
world.lnx_envtex_strength = 1.0
mark_color_environment(world)
else:
log.warn(f'World node type {node_surface.type} must not be connected to the world output node!')

View File

@ -321,29 +321,18 @@ def parse_shader(node: bpy.types.Node, socket: bpy.types.NodeSocket) -> Tuple[st
'MIX_SHADER',
'ADD_SHADER',
'BSDF_PRINCIPLED',
'PRINCIPLED_BSDF',
'BSDF_DIFFUSE',
'DIFFUSE_BSDF',
'BSDF_GLOSSY',
'GLOSSY_BSDF',
'BSDF_SHEEN',
'SHEEN_BSDF',
'AMBIENT_OCCLUSION',
'BSDF_ANISOTROPIC',
'ANISOTROPIC_BSDF',
'EMISSION',
'BSDF_GLASS',
'GLASS_BSDF',
'BSDF_REFRACTION',
'REFRACTION_BSDF',
'HOLDOUT',
'SUBSURFACE_SCATTERING',
'BSDF_TRANSLUCENT',
'TRANSLUCENT_BSDF',
'BSDF_TRANSPARENT',
'TRANSPARENT_BSDF',
'BSDF_VELVET',
'VELVET_BSDF',
)
state.reset_outs()
@ -377,7 +366,7 @@ def parse_shader(node: bpy.types.Node, socket: bpy.types.NodeSocket) -> Tuple[st
mat_state.emission_type = mat_state.EmissionType.SHADED
if state.parse_opacity:
state.out_opacity = parse_value_input(node.inputs[1])
state.out_ior = 1.450;
state.out_ior = 1.450
else:
return parse_group(node, socket)
@ -394,6 +383,21 @@ def parse_shader(node: bpy.types.Node, socket: bpy.types.NodeSocket) -> Tuple[st
return state.get_outs()
# 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
def parse_displacement_input(inp):
if inp.is_linked:
l = inp.links[0]
@ -504,6 +508,20 @@ def parse_vector(node: bpy.types.Node, socket: bpy.types.NodeSocket) -> str:
return "vec3(0, 0, 0)"
# 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'
def parse_normal_map_color_input(inp, strength_input=None):
frag = state.frag
@ -731,7 +749,7 @@ def store_var_name(node: bpy.types.Node) -> str:
return name + '_store'
def texture_store(node, tex, tex_name, to_linear=False, tex_link=None, default_value=None, is_lnx_mat_param=None):
def texture_store(node, tex, tex_name, to_linear=False, unpremultiply=False, tex_link=None, default_value=None, is_lnx_mat_param=None):
curshader = state.curshader
tex_store = store_var_name(node)
@ -770,6 +788,9 @@ def texture_store(node, tex, tex_name, to_linear=False, tex_link=None, default_v
else:
curshader.write('vec4 {0} = texture({1}, {2}.xy);'.format(tex_store, tex_name, uv_name))
if unpremultiply:
curshader.write('if ({0}.a > 0.0) {0}.rgb /= {0}.a;'.format(tex_store))
if to_linear:
curshader.write('{0}.rgb = pow({0}.rgb, vec3(2.2));'.format(tex_store))

View File

@ -1,9 +1,9 @@
str_tex_proc = """
// <https://www.shadertoy.com/view/4dS3Wd>
// By Morgan McGuire @morgan3d, http://graphicscodex.com
float hash_f(const float n) { return fract(sin(n) * 1e4); }
float hash_f(const vec2 p) { return fract(1e4 * sin(17.0 * p.x + p.y * 0.1) * (0.1 + abs(sin(p.y * 13.0 + p.x)))); }
float hash_f(const vec3 co){ return fract(sin(dot(co.xyz, vec3(12.9898,78.233,52.8265)) * 24.384) * 43758.5453); }
// Hash functions by Dave Hoskins
// <https://www.shadertoy.com/view/4djSRW>
float hash_f(float p) { p = fract(p * 0.1031); p *= p + 33.33; p *= p + p; return fract(p); }
float hash_f(vec2 p) { vec3 p3 = fract(vec3(p.xyx) * 0.1031); p3 += dot(p3, p3.yzx + 33.33); return fract((p3.x + p3.y) * p3.z); }
float hash_f(vec3 p3) { p3 = fract(p3 * 0.1031); p3 += dot(p3, p3.zyx + 31.32); return fract((p3.x + p3.y) * p3.z); }
float noise(const vec3 x) {
const vec3 step = vec3(110, 241, 171);
@ -418,11 +418,12 @@ float tex_brick_blender_f(vec3 co,
str_tex_wave = """
float tex_wave_f(const vec3 p, const int type, const int profile, const float dist, const float detail, const float detail_scale) {
float tex_wave_f(const vec3 p, const int type, const int profile, const float dist, const float detail, const float detail_scale, const float phase_offset) {
float n;
if(type == 0) n = (p.x + p.y + p.z) * 9.5;
else n = length(p) * 13.0;
if(dist != 0.0) n += dist * fractal_noise(p * detail_scale, detail) * 2.0 - 1.0;
n += phase_offset;
if(profile == 0) { return 0.5 + 0.5 * sin(n - PI); }
else {
n /= 2.0 * PI;

View File

@ -83,37 +83,28 @@ def parse_clamp(node: bpy.types.ShaderNodeClamp, out_socket: bpy.types.NodeSocke
def parse_valtorgb(node: bpy.types.ShaderNodeValToRGB, out_socket: bpy.types.NodeSocket, state: ParserState) -> Union[floatstr, vec3str]:
# Alpha (TODO: make ColorRamp calculation vec4-based and split afterwards)
if out_socket == node.outputs[1]:
return '1.0'
input_fac: bpy.types.NodeSocket = node.inputs[0]
alpha_out = out_socket == node.outputs[1]
fac: str = c.parse_value_input(input_fac) if input_fac.is_linked else c.to_vec1(input_fac.default_value)
interp = node.color_ramp.interpolation
elems = node.color_ramp.elements
if len(elems) == 1:
if alpha_out:
return c.to_vec1(elems[0].color[3]) # Return alpha from the color
else:
return c.to_vec3(elems[0].color) # Return RGB
name_prefix = c.node_name(node.name).upper()
if alpha_out:
cols_var = name_prefix + '_ALPHAS'
else:
cols_var = name_prefix + '_COLS'
return c.to_vec3(elems[0].color)
# Write color array
# The last entry is included twice so that the interpolation
# between indices works (no out of bounds error)
cols_var = c.node_name(node.name).upper() + '_COLS'
if state.current_pass == ParserPass.REGULAR:
if alpha_out:
cols_entries = ', '.join(f'{elem.color[3]}' for elem in elems)
# Add last value twice to avoid out of bounds access
cols_entries += f', {elems[len(elems) - 1].color[3]}'
state.curshader.add_const("float", cols_var, cols_entries, array_size=len(elems) + 1)
else:
# Create array of RGB values for color output
cols_entries = ', '.join(f'vec3({elem.color[0]}, {elem.color[1]}, {elem.color[2]})' for elem in elems)
cols_entries += f', vec3({elems[len(elems) - 1].color[0]}, {elems[len(elems) - 1].color[1]}, {elems[len(elems) - 1].color[2]})'
state.curshader.add_const("vec3", cols_var, cols_entries, array_size=len(elems) + 1)
cols_entries = ', '.join(f'vec3({elem.color[0]}, {elem.color[1]}, {elem.color[2]})' for elem in elems)
cols_entries += f', vec3({elems[len(elems) - 1].color[0]}, {elems[len(elems) - 1].color[1]}, {elems[len(elems) - 1].color[2]})'
state.curshader.add_const("vec3", cols_var, cols_entries, array_size=len(elems) + 1)
fac_var = c.node_name(node.name) + '_fac' + state.get_parser_pass_suffix()
state.curshader.write(f'float {fac_var} = {fac};')
@ -131,22 +122,22 @@ def parse_valtorgb(node: bpy.types.ShaderNodeValToRGB, out_socket: bpy.types.Nod
# Linear interpolation
else:
# Write factor array - same for both color and alpha
facs_var = name_prefix + '_FACS'
# Write factor array
facs_var = c.node_name(node.name).upper() + '_FACS'
if state.current_pass == ParserPass.REGULAR:
facs_entries = ', '.join(str(elem.position) for elem in elems)
# Add one more entry at the rightmost position to avoid out of bounds access
# Add one more entry at the rightmost position so that the
# interpolation between indices works (no out of bounds error)
facs_entries += ', 1.0'
state.curshader.add_const("float", facs_var, facs_entries, array_size=len(elems) + 1)
# Calculation for interpolation position
# Mix color
prev_stop_fac = f'{facs_var}[{index_var}]'
next_stop_fac = f'{facs_var}[{index_var} + 1]'
prev_stop_col = f'{cols_var}[{index_var}]'
next_stop_col = f'{cols_var}[{index_var} + 1]'
rel_pos = f'({fac_var} - {prev_stop_fac}) * (1.0 / ({next_stop_fac} - {prev_stop_fac}))'
# Use mix function for both alpha and color outputs (mix works on floats too)
return f'mix({prev_stop_col}, {next_stop_col}, max({rel_pos}, 0.0))'
if bpy.app.version > (3, 2, 0):

View File

@ -248,7 +248,7 @@ def parse_objectinfo(node: bpy.types.ShaderNodeObjectInfo, out_socket: bpy.types
def parse_particleinfo(node: bpy.types.ShaderNodeParticleInfo, out_socket: bpy.types.NodeSocket, state: ParserState) -> Union[floatstr, vec3str]:
particles_on = lnx.utils.get_rp().lnx_particles == 'On'
particles_on = lnx.utils.get_rp().lnx_particles == 'GPU'
# Index
if out_socket == node.outputs[0]:
@ -310,27 +310,23 @@ def parse_texcoord(node: bpy.types.ShaderNodeTexCoord, out_socket: bpy.types.Nod
return 'vec3(0.0)'
state.con.add_elem('tex', 'short2norm')
state.dxdy_varying_input_value = True
return 'vec3(texCoord.x, 1.0 - texCoord.y, 0.0)'
elif out_socket == node.outputs[3]: # Object
state.dxdy_varying_input_value = True
return 'mposition'
elif out_socket == node.outputs[4]: # Camera
state.curshader.add_uniform('mat4 V', link='_viewMatrix')
if not state.frag.contains('vec3 viewPosition;'):
state.frag.write_init('vec3 viewPosition = (V * vec4(wposition, 1.0)).xyz;')
state.dxdy_varying_input_value = True
return 'viewPosition'
return 'vec3(0.0)' # 'vposition'
elif out_socket == node.outputs[5]: # Window
# TODO: Don't use gl_FragCoord here, it uses different axes on different graphics APIs
state.frag.add_uniform('vec2 screenSize', link='_screenSize')
state.dxdy_varying_input_value = True
return f'vec3(gl_FragCoord.xy / screenSize, 0.0)'
elif out_socket == node.outputs[6]: # Reflection
state.curshader.add_uniform('vec3 eye', link='_cameraPosition')
if not state.frag.contains('vec3 reflectionVector;'):
state.frag.write_init('vec3 reflectionVector = reflect(normalize(wposition - eye), normalize(n));')
state.dxdy_varying_input_value = True
return 'reflectionVector'
if state.context == ParserContext.WORLD:
state.dxdy_varying_input_value = True
return 'n'
return 'vec3(0.0)'
def parse_uvmap(node: bpy.types.ShaderNodeUVMap, out_socket: bpy.types.NodeSocket, state: ParserState) -> vec3str:

View File

@ -85,6 +85,8 @@ def parse_addshader(node: bpy.types.ShaderNodeAddShader, out_socket: NodeSocket,
state.out_opacity = '({0} * 0.5 + {1} * 0.5)'.format(opac1, opac2)
state.out_ior = '({0} * 0.5 + {1} * 0.5)'.format(ior1, ior2)
# TODO: Refactor using c.get_*_input()
if bpy.app.version < (2, 92, 0):
def parse_bsdfprincipled(node: bpy.types.ShaderNodeBsdfPrincipled, out_socket: NodeSocket, state: ParserState) -> None:
@ -224,12 +226,6 @@ if bpy.app.version < (4, 1, 0):
c.write_normal(node.inputs[2])
state.out_basecol = c.parse_vector_input(node.inputs[0])
state.out_roughness = c.parse_value_input(node.inputs[1])
# Prevent black material when metal = 1.0 and roughness = 0.0
try:
if float(state.out_roughness) < 0.00101:
state.out_roughness = '0.001'
except ValueError:
pass
state.out_metallic = '1.0'
else:
def parse_bsdfglossy(node: bpy.types.ShaderNodeBsdfAnisotropic, out_socket: NodeSocket, state: ParserState) -> None:
@ -237,12 +233,6 @@ else:
c.write_normal(node.inputs[4])
state.out_basecol = c.parse_vector_input(node.inputs[0])
state.out_roughness = c.parse_value_input(node.inputs[1])
# Prevent black material when metal = 1.0 and roughness = 0.0
try:
if float(state.out_roughness) < 0.00101:
state.out_roughness = '0.001'
except ValueError:
pass
state.out_metallic = '1.0'

View File

@ -28,12 +28,11 @@ if lnx.is_reload(__name__):
else:
lnx.enable_reload(__name__)
def parse_tex_brick(node: bpy.types.ShaderNodeTexBrick, out_socket: bpy.types.NodeSocket, state: ParserState) -> Union[floatstr, vec3str]:
state.curshader.add_function(c_functions.str_tex_brick_blender)
if node.inputs[0].is_linked:
co = c.parse_vector_input(node.inputs[0])
if node.inputs['Vector'].is_linked:
co = c.get_vector_input(node, ['Vector'])
else:
co = 'bposition'
@ -42,23 +41,23 @@ def parse_tex_brick(node: bpy.types.ShaderNodeTexBrick, out_socket: bpy.types.No
squash_amount = node.squash
squash_frequency = node.squash_frequency
col1 = c.parse_vector_input(node.inputs[1])
col2 = c.parse_vector_input(node.inputs[2])
col3 = c.parse_vector_input(node.inputs[3])
scale = c.parse_value_input(node.inputs[4])
mortar_size = c.parse_value_input(node.inputs[5])
mortar_smooth = c.parse_value_input(node.inputs[6])
bias = c.parse_value_input(node.inputs[7])
brick_width = c.parse_value_input(node.inputs[8])
row_height = c.parse_value_input(node.inputs[9])
#res = f'tex_brick({co} * {scale}, {col1}, {col2}, {col3})'
col1 = c.get_vector_input(node, ['Color1'])
col2 = c.get_vector_input(node, ['Color2'])
mortar = c.get_vector_input(node, ['Mortar'])
scale = c.get_value_input(node, ['Scale'])
mortar_size = c.get_value_input(node, ['Mortar Size'])
mortar_smooth = c.get_value_input(node, ['Mortar Smooth'])
bias = c.get_value_input(node, ['Bias'])
brick_width = c.get_value_input(node, ['Brick Width'])
row_height = c.get_value_input(node, ['Row Height'])
#res = f'tex_brick({co} * {scale}, {col1}, {col2}, {mortar})'
# Color
if out_socket == node.outputs[0]:
res = f'tex_brick_blender({co}, {col1}, {col2}, {col3}, {scale}, {mortar_size}, {mortar_smooth}, {bias}, {brick_width}, {row_height}, {offset_amount}, {offset_frequency}, {squash_amount}, {squash_frequency})'
if out_socket == node.outputs['Color']:
res = f'tex_brick_blender({co}, {col1}, {col2}, {mortar}, {scale}, {mortar_size}, {mortar_smooth}, {bias}, {brick_width}, {row_height}, {offset_amount}, {offset_frequency}, {squash_amount}, {squash_frequency})'
# Fac
else:
res = f'tex_brick_blender_f({co}, {col1}, {col2}, {col3}, {scale}, {mortar_size}, {mortar_smooth}, {bias}, {brick_width}, {row_height}, {offset_amount}, {offset_frequency}, {squash_amount}, {squash_frequency})'
res = f'tex_brick_blender_f({co}, {col1}, {col2}, {mortar}, {scale}, {mortar_size}, {mortar_smooth}, {bias}, {brick_width}, {row_height}, {offset_amount}, {offset_frequency}, {squash_amount}, {squash_frequency})'
return res
@ -66,28 +65,28 @@ def parse_tex_brick(node: bpy.types.ShaderNodeTexBrick, out_socket: bpy.types.No
def parse_tex_checker(node: bpy.types.ShaderNodeTexChecker, out_socket: bpy.types.NodeSocket, state: ParserState) -> Union[floatstr, vec3str]:
state.curshader.add_function(c_functions.str_tex_checker)
if node.inputs[0].is_linked:
co = c.parse_vector_input(node.inputs[0])
if node.inputs['Vector'].is_linked:
co = c.get_vector_input(node, ['Vector'])
else:
co = 'bposition'
scale = c.get_value_input(node, ['Scale'])
# Color
if out_socket == node.outputs[0]:
col1 = c.parse_vector_input(node.inputs[1])
col2 = c.parse_vector_input(node.inputs[2])
scale = c.parse_value_input(node.inputs[3])
if out_socket == node.outputs['Color']:
col1 = c.get_vector_input(node, ['Color1'])
col2 = c.get_vector_input(node, ['Color2'])
res = f'tex_checker({co}, {col1}, {col2}, {scale})'
# Fac
else:
scale = c.parse_value_input(node.inputs[3])
res = 'tex_checker_f({0}, {1})'.format(co, scale)
return res
def parse_tex_gradient(node: bpy.types.ShaderNodeTexGradient, out_socket: bpy.types.NodeSocket, state: ParserState) -> Union[floatstr, vec3str]:
if node.inputs[0].is_linked:
co = c.parse_vector_input(node.inputs[0])
if node.inputs['Vector'].is_linked:
co = c.get_vector_input(node, ['Vector'])
else:
co = 'bposition'
@ -108,7 +107,7 @@ def parse_tex_gradient(node: bpy.types.ShaderNodeTexGradient, out_socket: bpy.ty
f = f'max(1.0 - sqrt({co}.x * {co}.x + {co}.y * {co}.y + {co}.z * {co}.z), 0.0)'
# Color
if out_socket == node.outputs[0]:
if out_socket == node.outputs['Color']:
res = f'vec3(clamp({f}, 0.0, 1.0))'
# Fac
else:
@ -119,7 +118,7 @@ def parse_tex_gradient(node: bpy.types.ShaderNodeTexGradient, out_socket: bpy.ty
def parse_tex_image(node: bpy.types.ShaderNodeTexImage, out_socket: bpy.types.NodeSocket, state: ParserState) -> Union[floatstr, vec3str]:
# Color or Alpha output
use_color_out = out_socket == node.outputs[0]
use_color_out = out_socket == node.outputs['Color']
if state.context == ParserContext.OBJECT:
tex_store = c.store_var_name(node)
@ -147,11 +146,12 @@ def parse_tex_image(node: bpy.types.ShaderNodeTexImage, out_socket: bpy.types.No
state.curshader.write_textures += 1
if node.lnx_material_param and tex['file'] is not None:
tex_default_file = tex['file']
unpremultiply = node.image is not None and node.image.alpha_mode != 'CHANNEL_PACKED'
if use_color_out:
to_linear = node.image is not None and node.image.colorspace_settings.name == 'sRGB'
res = f'{c.texture_store(node, tex, tex_name, to_linear, tex_link=tex_link, default_value=tex_default_file, is_lnx_mat_param=is_lnx_mat_param)}.rgb'
res = f'{c.texture_store(node, tex, tex_name, to_linear, unpremultiply, tex_link=tex_link, default_value=tex_default_file, is_lnx_mat_param=is_lnx_mat_param)}.rgb'
else:
res = f'{c.texture_store(node, tex, tex_name, tex_link=tex_link, default_value=tex_default_file, is_lnx_mat_param=is_lnx_mat_param)}.a'
res = f'{c.texture_store(node, tex, tex_name, unpremultiply, tex_link=tex_link, default_value=tex_default_file, is_lnx_mat_param=is_lnx_mat_param)}.a'
state.curshader.write_textures -= 1
return res
@ -162,8 +162,8 @@ def parse_tex_image(node: bpy.types.ShaderNodeTexImage, out_socket: bpy.types.No
'file': ''
}
if use_color_out:
return '{0}.rgb'.format(c.texture_store(node, tex, tex_name, to_linear=False, tex_link=tex_link, is_lnx_mat_param=is_lnx_mat_param))
return '{0}.a'.format(c.texture_store(node, tex, tex_name, to_linear=True, tex_link=tex_link, is_lnx_mat_param=is_lnx_mat_param))
return '{0}.rgb'.format(c.texture_store(node, tex, tex_name, to_linear=False, unpremultiply=False, tex_link=tex_link, is_lnx_mat_param=is_lnx_mat_param))
return '{0}.a'.format(c.texture_store(node, tex, tex_name, to_linear=True, unpremultiply=False, tex_link=tex_link, is_lnx_mat_param=is_lnx_mat_param))
# Pink color for missing texture
else:
@ -240,15 +240,15 @@ def parse_tex_image(node: bpy.types.ShaderNodeTexImage, out_socket: bpy.types.No
def parse_tex_magic(node: bpy.types.ShaderNodeTexMagic, out_socket: bpy.types.NodeSocket, state: ParserState) -> Union[floatstr, vec3str]:
state.curshader.add_function(c_functions.str_tex_magic)
if node.inputs[0].is_linked:
co = c.parse_vector_input(node.inputs[0])
if node.inputs['Vector'].is_linked:
co = c.get_vector_input(node, ['Vector'])
else:
co = 'bposition'
scale = c.parse_value_input(node.inputs[1])
scale = c.get_value_input(node, ['Scale'])
# Color
if out_socket == node.outputs[0]:
if out_socket == node.outputs['Color']:
res = f'tex_magic({co} * {scale} * 4.0)'
# Fac
else:
@ -260,17 +260,17 @@ if bpy.app.version < (4, 1, 0):
def parse_tex_musgrave(node: bpy.types.ShaderNodeTexMusgrave, out_socket: bpy.types.NodeSocket, state: ParserState) -> Union[floatstr, vec3str]:
state.curshader.add_function(c_functions.str_tex_musgrave)
if node.inputs[0].is_linked:
co = c.parse_vector_input(node.inputs[0])
if node.inputs['Vector'].is_linked:
co = c.get_vector_input(node, ['Vector'])
else:
co = 'bposition'
scale = c.parse_value_input(node.inputs['Scale'])
detail = c.parse_value_input(node.inputs[3])
distortion = c.parse_value_input(node.inputs[4])
res = f'tex_musgrave_f({co} * {scale} * 0.5, {detail}, {distortion})'
scale = c.get_value_input(node, ['Scale'])
detail = c.get_value_input(node, ['Detail'])
dimension = c.get_value_input(node, ['Dimension'])
res = f'tex_musgrave_f({co} * {scale} * 0.5, {detail}, {dimension})' # FIXME: a `distortion` is applied instead of a `dimension`
return res
@ -280,28 +280,30 @@ def parse_tex_noise(node: bpy.types.ShaderNodeTexNoise, out_socket: bpy.types.No
c.assets_add(os.path.join(lnx.utils.get_sdk_path(), 'leenkx', 'Assets', 'noise256.png'))
c.assets_add_embedded_data('noise256.png')
state.curshader.add_uniform('sampler2D snoise256', link='$noise256.png')
if node.inputs[0].is_linked:
co = c.parse_vector_input(node.inputs[0])
if node.inputs['Vector'].is_linked:
co = c.get_vector_input(node, ['Vector'])
else:
co = 'bposition'
scale = c.parse_value_input(node.inputs[2])
detail = c.parse_value_input(node.inputs[3])
roughness = c.parse_value_input(node.inputs[4])
distortion = c.parse_value_input(node.inputs[5])
scale = c.get_value_input(node, ['Scale'])
detail = c.get_value_input(node, ['Detail'])
roughness = c.get_value_input(node, ['Roughness'])
distortion = c.get_value_input(node, ['Distortion'])
if bpy.app.version >= (4, 1, 0):
if node.noise_type == "FBM":
state.curshader.add_function(c_functions.str_tex_musgrave)
if out_socket == node.outputs[1]:
if out_socket == node.outputs['Color']:
res = 'vec3(tex_musgrave_f({0} * {1}, {2}, {3}), tex_musgrave_f({0} * {1} + 120.0, {2}, {3}), tex_musgrave_f({0} * {1} + 168.0, {2}, {3}))'.format(co, scale, detail, distortion)
else:
res = f'tex_musgrave_f({co} * {scale} * 1.0, {detail}, {distortion})'
else:
if out_socket == node.outputs[1]:
if out_socket == node.outputs['Color']:
res = 'vec3(tex_noise({0} * {1},{2},{3}), tex_noise({0} * {1} + 120.0,{2},{3}), tex_noise({0} * {1} + 168.0,{2},{3}))'.format(co, scale, detail, distortion)
else:
res = 'tex_noise({0} * {1},{2},{3})'.format(co, scale, detail, distortion)
if node.normalize:
res = f'(1.0 - ({res}))'
else:
if out_socket == node.outputs[1]:
if out_socket == node.outputs['Color']:
res = 'vec3(tex_noise({0} * {1},{2},{3}), tex_noise({0} * {1} + 120.0,{2},{3}), tex_noise({0} * {1} + 168.0,{2},{3}))'.format(co, scale, detail, distortion)
else:
res = 'tex_noise({0} * {1},{2},{3})'.format(co, scale, detail, distortion)
@ -311,7 +313,7 @@ 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]:
if out_socket == node.outputs['Color']:
return c.to_vec3([0.0, 0.0, 0.0])
# Density
else:
@ -546,21 +548,24 @@ def parse_tex_voronoi(node: bpy.types.ShaderNodeTexVoronoi, out_socket: bpy.type
m = 2
elif node.distance == 'MINKOWSKI':
m = 3
# TODO: Add node.distance == 'MANHATHAN'
# Add node.feature
# Add node.voronoi_dimensions
c.write_procedurals()
state.curshader.add_function(c_functions.str_tex_voronoi)
if node.inputs[0].is_linked:
co = c.parse_vector_input(node.inputs[0])
if node.inputs['Vector'].is_linked:
co = c.get_vector_input(node, ['Vector'])
else:
co = 'bposition'
scale = c.parse_value_input(node.inputs[2])
exp = c.parse_value_input(node.inputs[4])
randomness = c.parse_value_input(node.inputs[5])
scale = c.get_value_input(node, ['Scale'])
exp = c.get_value_input(node, ['Exponent'])
randomness = c.get_value_input(node, ['Randomness'])
# Color or Position
if out_socket == node.outputs[1] or out_socket == node.outputs[2]:
if out_socket == node.outputs['Color'] or out_socket == node.outputs['Position']:
res = 'tex_voronoi({0}, {1}, {2}, {3}, {4}, {5})'.format(co, randomness, m, outp, scale, exp)
# Distance
else:
@ -572,14 +577,16 @@ def parse_tex_voronoi(node: bpy.types.ShaderNodeTexVoronoi, out_socket: bpy.type
def parse_tex_wave(node: bpy.types.ShaderNodeTexWave, out_socket: bpy.types.NodeSocket, state: ParserState) -> Union[floatstr, vec3str]:
c.write_procedurals()
state.curshader.add_function(c_functions.str_tex_wave)
if node.inputs[0].is_linked:
co = c.parse_vector_input(node.inputs[0])
if node.inputs['Vector'].is_linked:
co = c.get_vector_input(node, ['Vector'])
else:
co = 'bposition'
scale = c.parse_value_input(node.inputs[1])
distortion = c.parse_value_input(node.inputs[2])
detail = c.parse_value_input(node.inputs[3])
detail_scale = c.parse_value_input(node.inputs[4])
scale = c.get_value_input(node, ['Scale'])
distortion = c.get_value_input(node, ['Distortion'])
detail = c.get_value_input(node, ['Detail'])
detail_scale = c.get_value_input(node, ['Detail Scale'])
phase_offset = c.get_value_input(node, ['Phase Offset'])
if node.wave_profile == 'SIN':
wave_profile = 0
else:
@ -590,10 +597,10 @@ def parse_tex_wave(node: bpy.types.ShaderNodeTexWave, out_socket: bpy.types.Node
wave_type = 1
# Color
if out_socket == node.outputs[0]:
res = 'vec3(tex_wave_f({0} * {1},{2},{3},{4},{5},{6}))'.format(co, scale, wave_type, wave_profile, distortion, detail, detail_scale)
if out_socket == node.outputs['Color']:
res = 'vec3(tex_wave_f({0} * {1},{2},{3},{4},{5},{6},{7}))'.format(co, scale, wave_type, wave_profile, distortion, detail, detail_scale, phase_offset)
# Fac
else:
res = 'tex_wave_f({0} * {1},{2},{3},{4},{5},{6})'.format(co, scale, wave_type, wave_profile, distortion, detail, detail_scale)
res = 'tex_wave_f({0} * {1},{2},{3},{4},{5},{6},{7})'.format(co, scale, wave_type, wave_profile, distortion, detail, detail_scale, phase_offset)
return res

View File

@ -25,8 +25,8 @@ else:
def parse_curvevec(node: bpy.types.ShaderNodeVectorCurve, out_socket: bpy.types.NodeSocket, state: ParserState) -> vec3str:
fac = c.parse_value_input(node.inputs[0])
vec = c.parse_vector_input(node.inputs[1])
fac = c.get_value_input(node, ['Fac'])
vec = c.get_vector_input(node, ['Vector'])
curves = node.mapping.curves
name = c.node_name(node.name)
# mapping.curves[0].points[0].handle_type # bezier curve
@ -42,19 +42,17 @@ def parse_bump(node: bpy.types.ShaderNodeBump, out_socket: bpy.types.NodeSocket,
return 'vec3(0.0)'
# Interpolation strength
strength = c.parse_value_input(node.inputs[0])
# Height multiplier
# distance = c.parse_value_input(node.inputs[1])
height = c.parse_value_input(node.inputs[2])
strength = c.get_value_input(node, ['Strength'])
# distance = c.get_value_input(node, ['Distance'])
height = c.get_value_input(node, ['Height'])
# normal = c.get_vector_input(node, ['Normal'])
state.current_pass = ParserPass.DX_SCREEN_SPACE
height_dx = c.parse_value_input(node.inputs[2])
height_dx = c.get_value_input(node, ['Height'])
state.current_pass = ParserPass.DY_SCREEN_SPACE
height_dy = c.parse_value_input(node.inputs[2])
height_dy = c.get_value_input(node, ['Height'])
state.current_pass = ParserPass.REGULAR
# nor = c.parse_vector_input(node.inputs[3])
if height_dx != height or height_dy != height:
tangent = f'{c.dfdx_fine("wposition")} + n * ({height_dx} - {height})'
bitangent = f'{c.dfdy_fine("wposition")} + n * ({height_dy} - {height})'
@ -79,11 +77,12 @@ def parse_bump(node: bpy.types.ShaderNodeBump, out_socket: bpy.types.NodeSocket,
def parse_mapping(node: bpy.types.ShaderNodeMapping, out_socket: bpy.types.NodeSocket, state: ParserState) -> vec3str:
# TODO: Add support for "Normal" type
# Only "Point", "Texture" and "Vector" types supported for now..
# More information about the order of operations for this node:
# https://docs.blender.org/manual/en/latest/render/shader_nodes/vector/mapping.html#properties
input_vector: bpy.types.NodeSocket = node.inputs[0]
input_vector: bpy.types.NodeSocket = node.inputs['Vector']
input_location: bpy.types.NodeSocket = node.inputs['Location']
input_rotation: bpy.types.NodeSocket = node.inputs['Rotation']
input_scale: bpy.types.NodeSocket = node.inputs['Scale']
@ -145,44 +144,48 @@ def parse_normal(node: bpy.types.ShaderNodeNormal, out_socket: bpy.types.NodeSoc
return nor1
elif out_socket == node.outputs['Dot']:
nor2 = c.parse_vector_input(node.inputs["Normal"])
nor2 = c.get_vector_input(node, ["Normal"])
return f'dot({nor1}, {nor2})'
def parse_normalmap(node: bpy.types.ShaderNodeNormalMap, out_socket: bpy.types.NodeSocket, state: ParserState) -> vec3str:
if state.curshader == state.tese:
return c.parse_vector_input(node.inputs[1])
return c.get_vector_input(node, ["Normal"])
else:
# TODO:
# space = node.space
# map = node.uv_map
# Color
c.parse_normal_map_color_input(node.inputs[1], node.inputs[0])
c.parse_normal_map_color_input(node.inputs['Color'], node.inputs['Strength'])
return 'n'
def parse_vectortransform(node: bpy.types.ShaderNodeVectorTransform, out_socket: bpy.types.NodeSocket, state: ParserState) -> vec3str:
# type = node.vector_type
# TODO:
# vector_type = node.vector_type
# conv_from = node.convert_from
# conv_to = node.convert_to
# Pass through
return c.parse_vector_input(node.inputs[0])
return c.get_vector_input(node, ['Vector'])
def parse_displacement(node: bpy.types.ShaderNodeDisplacement, out_socket: bpy.types.NodeSocket, state: ParserState) -> vec3str:
height = c.parse_value_input(node.inputs[0])
midlevel = c.parse_value_input(node.inputs[1])
scale = c.parse_value_input(node.inputs[2])
nor = c.parse_vector_input(node.inputs[3])
# TODO:
# space = node.space
height = c.get_value_input(node, ['Height'])
midlevel = c.get_value_input(node, ['Midlevel'])
scale = c.get_value_input(node, ['Scale'])
nor = c.get_vector_input(node, ['Normal'])
return f'(vec3({height}) * {scale})'
def parse_vectorrotate(node: bpy.types.ShaderNodeVectorRotate, out_socket: bpy.types.NodeSocket, state: ParserState) -> vec3str:
type = node.rotation_type
input_vector: bpy.types.NodeSocket = c.parse_vector_input(node.inputs[0])
input_center: bpy.types.NodeSocket = c.parse_vector_input(node.inputs[1])
input_axis: bpy.types.NodeSocket = c.parse_vector_input(node.inputs[2])
input_angle: bpy.types.NodeSocket = c.parse_value_input(node.inputs[3])
input_rotation: bpy.types.NodeSocket = c.parse_vector_input(node.inputs[4])
input_vector: bpy.types.NodeSocket = c.get_vector_input(node, ['Vector'])
input_center: bpy.types.NodeSocket = c.get_vector_input(node, ['Center'])
input_axis: bpy.types.NodeSocket = c.get_vector_input(node, ['Axis'])
input_angle: bpy.types.NodeSocket = c.get_value_input(node, ['Angle'])
input_rotation: bpy.types.NodeSocket = c.get_vector_input(node, ['Rotation'])
if node.invert:
input_invert = "0"

View File

@ -1,100 +1,212 @@
from typing import Optional
import lnx.material.cycles as cycles
import lnx.material.mat_state as mat_state
import lnx.material.make_skin as make_skin
import lnx.material.make_particle as make_particle
import lnx.material.make_inst as make_inst
import lnx.material.make_tess as make_tess
import lnx.material.make_morph_target as make_morph_target
from lnx.material.shader import Shader, ShaderContext
import lnx.utils
if lnx.is_reload(__name__):
cycles = lnx.reload_module(cycles)
mat_state = lnx.reload_module(mat_state)
make_skin = lnx.reload_module(make_skin)
make_particle = lnx.reload_module(make_particle)
make_inst = lnx.reload_module(make_inst)
make_tess = lnx.reload_module(make_tess)
make_morph_target = lnx.reload_module(make_morph_target)
lnx.material.shader = lnx.reload_module(lnx.material.shader)
from lnx.material.shader import Shader, ShaderContext
lnx.utils = lnx.reload_module(lnx.utils)
else:
lnx.enable_reload(__name__)
def write_vertpos(vert):
billboard = mat_state.material.lnx_billboard
particle = mat_state.material.lnx_particle_flag
# Particles
if particle:
if lnx.utils.get_rp().lnx_particles == 'On':
if lnx.utils.get_rp().lnx_particles == 'GPU':
make_particle.write(vert, particle_info=cycles.particle_info)
# Billboards
if billboard == 'spherical':
vert.add_uniform('mat4 WV', '_worldViewMatrix')
vert.add_uniform('mat4 P', '_projectionMatrix')
vert.write('gl_Position = P * (WV * vec4(0.0, 0.0, spos.z, 1.0) + vec4(spos.x, spos.y, 0.0, 0.0));')
else:
vert.add_uniform('mat4 WVP', '_worldViewProjectionMatrix')
vert.write('gl_Position = WVP * spos;')
else:
# Billboards
if billboard == 'spherical':
vert.add_uniform('mat4 WVP', '_worldViewProjectionMatrixSphere')
elif billboard == 'cylindrical':
vert.add_uniform('mat4 WVP', '_worldViewProjectionMatrixCylinder')
else: # off
vert.add_uniform('mat4 WVP', '_worldViewProjectionMatrix')
vert.write('gl_Position = WVP * spos;')
def write_norpos(con_mesh: ShaderContext, vert: Shader, declare=False, write_nor=True):
is_bone = con_mesh.is_elem('bone')
is_morph = con_mesh.is_elem('morph')
if is_morph:
make_morph_target.morph_pos(vert)
if is_bone:
make_skin.skin_pos(vert)
if write_nor:
prep = 'vec3 ' if declare else ''
if is_morph:
make_morph_target.morph_nor(vert, is_bone, prep)
if is_bone:
make_skin.skin_nor(vert, is_morph, prep)
if not is_morph and not is_bone:
vert.write_attrib(prep + 'wnormal = normalize(N * vec3(nor.xy, pos.w));')
if con_mesh.is_elem('ipos'):
make_inst.inst_pos(con_mesh, vert)
def write_tex_coords(con_mesh: ShaderContext, vert: Shader, frag: Shader, tese: Optional[Shader]):
rpdat = lnx.utils.get_rp()
if con_mesh.is_elem('tex'):
vert.add_out('vec2 texCoord')
vert.add_uniform('float texUnpack', link='_texUnpack')
if mat_state.material.lnx_tilesheet_flag:
if mat_state.material.lnx_particle_flag and rpdat.lnx_particles == 'On':
make_particle.write_tilesheet(vert)
else:
vert.add_uniform('vec2 tilesheetOffset', '_tilesheetOffset')
vert.write_attrib('texCoord = tex * texUnpack + tilesheetOffset;')
vert.add_uniform('vec2 tilesheetFlip', '_tilesheetFlip')
vert.add_uniform('vec2 tilesheetTiles', '_tilesheetTiles')
vert.write_attrib('vec2 tileSize = vec2(1.0 / tilesheetTiles.x, 1.0 / tilesheetTiles.y);')
vert.write_attrib('vec2 tileUV = tex * texUnpack;')
vert.write_attrib('tileUV.x = mix(tileUV.x, tileSize.x - tileUV.x, tilesheetFlip.x);')
vert.write_attrib('tileUV.y = mix(tileUV.y, tileSize.y - tileUV.y, tilesheetFlip.y);')
vert.write_attrib('texCoord = tileUV + tilesheetOffset;')
else:
vert.write_attrib('texCoord = tex * texUnpack;')
if tese is not None:
tese.write_pre = True
make_tess.interpolate(tese, 'texCoord', 2, declare_out=frag.contains('texCoord'))
tese.write_pre = False
if con_mesh.is_elem('tex1'):
vert.add_out('vec2 texCoord1')
vert.add_uniform('float texUnpack', link='_texUnpack')
vert.write_attrib('texCoord1 = tex1 * texUnpack;')
if tese is not None:
tese.write_pre = True
make_tess.interpolate(tese, 'texCoord1', 2, declare_out=frag.contains('texCoord1'))
tese.write_pre = False

View File

@ -112,7 +112,7 @@ def make(context_id, rpasses, shadowmap=False, shadowmap_transparent=False):
make_inst.inst_pos(con_depth, vert)
rpdat = lnx.utils.get_rp()
if mat_state.material.lnx_particle_flag and rpdat.lnx_particles == 'On':
if mat_state.material.lnx_particle_flag and rpdat.lnx_particles == 'GPU':
make_particle.write(vert, shadowmap=shadowmap)
if is_disp:

View File

@ -58,6 +58,7 @@ def make(context_id, rpasses):
con['alpha_blend_destination'] = mat.lnx_blending_destination_alpha
con['alpha_blend_operation'] = mat.lnx_blending_operation_alpha
con['depth_write'] = False
con['compare_mode'] = mat.lnx_compare_mode
elif particle:
pass
# Depth prepass was performed, exclude mat with depth read that
@ -500,7 +501,13 @@ def make_forward_solid(con_mesh):
vert.add_uniform('float texUnpack', link='_texUnpack')
if mat_state.material.lnx_tilesheet_flag:
vert.add_uniform('vec2 tilesheetOffset', '_tilesheetOffset')
vert.write('texCoord = tex * texUnpack + tilesheetOffset;')
vert.add_uniform('vec2 tilesheetFlip', '_tilesheetFlip')
vert.add_uniform('vec2 tilesheetTiles', '_tilesheetTiles')
vert.write('vec2 tileSize = vec2(1.0 / tilesheetTiles.x, 1.0 / tilesheetTiles.y);')
vert.write('vec2 tileUV = tex * texUnpack;')
vert.write('tileUV.x = mix(tileUV.x, tileSize.x - tileUV.x, tilesheetFlip.x);')
vert.write('tileUV.y = mix(tileUV.y, tileSize.y - tileUV.y, tilesheetFlip.y);')
vert.write('texCoord = tileUV + tilesheetOffset;')
else:
vert.write('texCoord = tex * texUnpack;')
@ -571,7 +578,7 @@ def make_forward(con_mesh):
frag.write('fragColor[0].rgb = tonemapFilmic(fragColor[0].rgb);')
# Particle opacity
if mat_state.material.lnx_particle_flag and lnx.utils.get_rp().lnx_particles == 'On' and mat_state.material.lnx_particle_fade:
if mat_state.material.lnx_particle_flag and lnx.utils.get_rp().lnx_particles == 'GPU' and mat_state.material.lnx_particle_fade:
frag.write('fragColor[0].rgb *= p_fade;')
@ -695,10 +702,10 @@ def make_forward_base(con_mesh, parse_opacity=False, transluc_pass=False):
if '_Brdf' in wrd.world_defs:
frag.write('envl.rgb *= 1.0 - F;')
if '_Rad' in wrd.world_defs:
frag.write('envl += prefilteredColor * F;')
frag.write('envl += prefilteredColor * F * 1.5;')
elif '_EnvCol' in wrd.world_defs:
frag.add_uniform('vec3 backgroundCol', link='_backgroundCol')
frag.write('envl += backgroundCol * F;')
frag.write('envl += backgroundCol * F * 1.5;')
frag.add_uniform('float envmapStrength', link='_envmapStrength')
frag.write('envl *= envmapStrength * occlusion;')

View File

@ -43,7 +43,7 @@ def write(vert, particle_info=None, shadowmap=False):
for tex_slot in psettings.texture_slots:
if not tex_slot: break
if not tex_slot.use_map_size: break # TODO: check also for other influences
if tex_slot.texture and tex_slot.texture.use_color_ramp:
if tex_slot.texture.use_color_ramp:
if tex_slot.texture.color_ramp and tex_slot.texture.color_ramp.elements:
ramp_el_len = len(tex_slot.texture.color_ramp.elements.items())
for element in tex_slot.texture.color_ramp.elements:
@ -250,7 +250,7 @@ def write(vert, particle_info=None, shadowmap=False):
vert.write('wnormal = normalize(rotate_around(wnormal, r_angle));')
# Particle fade
if mat_state.material.lnx_particle_flag and lnx.utils.get_rp().lnx_particles == 'On' and mat_state.material.lnx_particle_fade:
if mat_state.material.lnx_particle_flag and lnx.utils.get_rp().lnx_particles == 'GPU' and mat_state.material.lnx_particle_fade:
vert.add_out('float p_fade')
vert.write('p_fade = sin(min((p_age / 2) * 3.141592, 3.141592));')

View File

@ -93,7 +93,7 @@ def make_gi(context_id):
# Voxelized particles
particle = mat_state.material.lnx_particle_flag
if particle and rpdat.lnx_particles == 'On':
if particle and rpdat.lnx_particles == 'GPU':
# make_particle.write(vert, particle_info=cycles.particle_info)
frag.write_pre = True
frag.write('const float p_index = 0;')

View File

@ -59,6 +59,7 @@ def get_signature(mat, object: bpy.types.Object):
sign += mat.lnx_billboard
sign += '_skin' if lnx.utils.export_bone_data(object) else '0'
sign += '_morph' if lnx.utils.export_morph_targets(object) else '0'
# sign += '_tilesheet' if mat.lnx_tilesheet_flag else '0'
return sign
def traverse_tree2(node, ar):

View File

@ -1,3 +1,4 @@
import bpy
import lnx.utils
if lnx.is_reload(__name__):
@ -286,7 +287,8 @@ class Shader:
ar[0] = 'floats'
ar[1] = ar[1].split('[', 1)[0]
elif ar[0] == 'mat4' and '[' in ar[1]:
ar[0] = 'floats'
if not (ar[1].startswith('LWVPSpot') and not '_Clusters' in bpy.data.worlds['Lnx'].world_defs): #HACK: do not convert mat4 to floats when using single spot lights
ar[0] = 'floats'
ar[1] = ar[1].split('[', 1)[0]
self.context.add_constant(ar[0], ar[1], link=link, default_value=default_value, is_lnx_mat_param=is_lnx_mat_param)
if top:

View File

@ -31,7 +31,7 @@ else:
lnx.enable_reload(__name__)
# Leenkx version
lnx_version = '2025.1'
lnx_version = '2026.9'
lnx_commit = '$Id: 6b2644d47db169cedd95593497cc283207d23a74 $'
def get_project_html5_copy(self):
@ -151,7 +151,10 @@ def init_properties():
bpy.types.World.lnx_project_version_autoinc = BoolProperty(name="Auto-increment Build Number", description="Auto-increment build number", default=True, update=assets.invalidate_compiler_cache)
bpy.types.World.lnx_project_bundle = StringProperty(name="Bundle", description="Exported project bundle", default="org.leenkx3d", update=assets.invalidate_compiler_cache, set=set_project_bundle, get=get_project_bundle)
# External Blend Files
bpy.types.World.lnx_external_blends_path = StringProperty(name="External Blends", description="Directory containing external blend files to include in export", default="", subtype='DIR_PATH', update=assets.invalidate_compiler_cache)
if bpy.app.version >= (4, 5, 0):
bpy.types.World.lnx_external_blends_path = StringProperty(name="External Blends", description="Directory containing external blend files to include in export", default="", subtype='DIR_PATH', update=assets.invalidate_compiler_cache, options={'PATH_SUPPORTS_BLEND_RELATIVE'})
else:
bpy.types.World.lnx_external_blends_path = StringProperty(name="External Blends", description="Directory containing external blend files to include in export", default="", subtype='DIR_PATH', update=assets.invalidate_compiler_cache)
# Android Settings
bpy.types.World.lnx_project_android_sdk_min = IntProperty(name="Minimal Version SDK", description="Minimal Version Android SDK", default=23, min=14, max=30, update=assets.invalidate_compiler_cache)
bpy.types.World.lnx_project_android_sdk_target = IntProperty(name="Target Version SDK", description="Target Version Android SDK", default=26, min=26, max=30, update=assets.invalidate_compiler_cache)
@ -196,8 +199,14 @@ def init_properties():
bpy.types.World.lnx_project_win_build_cpu = IntProperty(name="CPU Count", description="Specifies the maximum number of concurrent processes to use when building", default=1, min=1, max=multiprocessing.cpu_count())
bpy.types.World.lnx_project_win_build_open = BoolProperty(name="Open Build Directory", description="Open the build directory after successfully assemble", default=False)
bpy.types.World.lnx_project_icon = StringProperty(name="Icon (PNG)", description="Exported project icon, must be a PNG image", default="", subtype="FILE_PATH", update=assets.invalidate_compiler_cache)
bpy.types.World.lnx_project_root = StringProperty(name="Root", description="Set root folder for linked assets", default="", subtype="DIR_PATH", update=assets.invalidate_compiler_cache)
if bpy.app.version >= (4, 5, 0):
bpy.types.World.lnx_project_icon = StringProperty(name="Icon (PNG)", description="Exported project icon, must be a PNG image", default="", subtype="FILE_PATH", update=assets.invalidate_compiler_cache, options={'PATH_SUPPORTS_BLEND_RELATIVE'})
else:
bpy.types.World.lnx_project_icon = StringProperty(name="Icon (PNG)", description="Exported project icon, must be a PNG image", default="", subtype="FILE_PATH", update=assets.invalidate_compiler_cache)
if bpy.app.version >= (4, 5, 0):
bpy.types.World.lnx_project_root = StringProperty(name="Root", description="Set root folder for linked assets", default="", subtype="DIR_PATH", update=assets.invalidate_compiler_cache, options={'PATH_SUPPORTS_BLEND_RELATIVE'})
else:
bpy.types.World.lnx_project_root = StringProperty(name="Root", description="Set root folder for linked assets", default="", subtype="DIR_PATH", update=assets.invalidate_compiler_cache)
bpy.types.World.lnx_physics = EnumProperty(
items=[('Disabled', 'Disabled', 'Disabled'),
('Auto', 'Auto', 'Auto'),
@ -418,9 +427,6 @@ def init_properties():
bpy.types.Object.lnx_relative_physics_constraint = BoolProperty(name="Relative Physics Constraint", description="Add physics constraint relative to the parent object or collection when spawned", default=False)
bpy.types.Object.lnx_animation_enabled = BoolProperty(name="Animation", description="Enable skinning & timeline animation", default=True)
bpy.types.Object.lnx_tilesheet = StringProperty(name="Tilesheet", description="Set tilesheet animation", default='')
bpy.types.Object.lnx_tilesheet_action = StringProperty(name="Tilesheet Action", description="Set startup action", default='')
bpy.types.Object.lnx_use_custom_tilesheet_node = BoolProperty(name="Use custom tilesheet node", description="Use custom tilesheet shader node", default=False)
# For speakers
bpy.types.Speaker.lnx_play_on_start = BoolProperty(name="Play on Start", description="Play this sound automatically", default=False)
bpy.types.Speaker.lnx_loop = BoolProperty(name="Loop", description="Loop this sound", default=False)
@ -616,8 +622,31 @@ def init_properties():
description="Particles have independent transform updates following emitter compared to a static baked particle system used if emitters dont generally move around.",
default=True
)
bpy.types.ParticleSettings.lnx_is_unique = BoolProperty(name="Is Unique", description="Make this particle system look different each time it starts", default=False)
bpy.types.ParticleSettings.lnx_is_unique = BoolProperty(name="Is Unique", description="Make this particle system look different each time it starts. Only affects GPU particles. Default behavior for CPU particles", default=False)
bpy.types.ParticleSettings.lnx_local_coords = BoolProperty(name="Local Coords", description="Keep spawned particles parented to their emitter. Only affects CPU particles. Default behavior for GPU particles at the moment", default=False)
bpy.types.ParticleSettings.lnx_loop = BoolProperty(name="Loop", description="Loop this particle system", default=False)
bpy.types.ParticleSettings.lnx_use_rotations = BoolProperty(name="Use Rotations", description="Enable particle rotations", default=False)
bpy.types.ParticleSettings.lnx_rotation_mode = EnumProperty(
name="Rotation Mode",
description="Rotation orientation mode",
items=[
('NONE', "None", "None"),
('NOR', "Normal", "Normal"),
('NOR_TAN', "Normal-Tangent", "Normal-Tangent"),
('VEL', "Velocity/Hair", "Velocity/Hair"),
('GLOB_X', "Global X", "Global X"),
('GLOB_Y', "Global Y", "Global Y"),
('GLOB_Z', "Global Z", "Global Z"),
('OB_X', "Object X", "Object X"),
('OB_Y', "Object Y", "Object Y"),
('OB_Z', "Object Z", "Object Z"),
],
default='NONE'
)
bpy.types.ParticleSettings.lnx_rotation_factor_random = FloatProperty(name="Rotation Random", description="Random rotation factor", default=0.0, min=0.0, max=1.0)
bpy.types.ParticleSettings.lnx_phase_factor = FloatProperty(name="Phase", description="Rotation phase factor", default=0.0, min=0.0, max=1.0)
bpy.types.ParticleSettings.lnx_phase_factor_random = FloatProperty(name="Phase Random", description="Random rotation phase factor", default=0.0, min=0.0, max=1.0)
bpy.types.ParticleSettings.lnx_use_dynamic_rotation = BoolProperty(name="Dynamic Rotation", description="Enable dynamic rotation updates", default=False)
bpy.types.ParticleSettings.lnx_count_mult = FloatProperty(name="Multiply Count", description="Multiply particle count when rendering in Leenkx", default=1.0)
# Actions
bpy.types.Action.lnx_root_motion_pos = BoolProperty(name="Root Motion Position", description="Enable position root motion", default=False)

View File

@ -66,7 +66,7 @@ def update_preset(self, context):
rpdat.rp_stereo = False
rpdat.rp_voxelgi_resolution = '32'
rpdat.lnx_voxelgi_size = 0.125
rpdat.rp_voxels = 'Voxel GI'
rpdat.rp_voxels = 'Off'
rpdat.rp_render_to_texture = True
rpdat.rp_supersampling = '1'
rpdat.rp_antialiasing = 'SMAA'
@ -142,7 +142,7 @@ def update_preset(self, context):
rpdat.rp_hdr = True
rpdat.rp_background = 'World'
rpdat.rp_stereo = False
rpdat.rp_voxels = 'Voxel GI'
rpdat.rp_voxels = 'Off'
rpdat.rp_voxelgi_resolution = '64'
rpdat.lnx_voxelgi_size = 0.125
rpdat.lnx_voxelgi_step = 0.01
@ -444,7 +444,7 @@ class LnxRPListItem(bpy.types.PropertyGroup):
rp_draw_order: EnumProperty(
items=[('Auto', 'Auto', 'Auto'),
('Distance', 'Distance', 'Distance'),
('Shader', 'Shader', 'Shader')],
('Index', 'Index', 'Index')],
name='Draw Order', description='Sort objects', default='Auto', update=assets.invalidate_compiled_data)
rp_depth_texture: BoolProperty(name="Depth Texture", description="Current render-path state", default=False)
rp_depth_texture_state: EnumProperty(
@ -669,9 +669,10 @@ class LnxRPListItem(bpy.types.PropertyGroup):
('Off', 'Off', 'Off')],
name='Shape key', description='Enable shape keys', default='On', update=assets.invalidate_shader_cache)
lnx_particles: EnumProperty(
items=[('On', 'On', 'On'),
items=[('GPU', 'GPU', 'GPU'),
('CPU', 'CPU', 'CPU'),
('Off', 'Off', 'Off')],
name='Particles', description='Enable particle simulation', default='On', update=assets.invalidate_shader_cache)
name='Particles', description='Enable particle simulation', default='GPU', update=assets.invalidate_shader_cache)
# Material override flags
lnx_culling: BoolProperty(name="Culling", default=True)
lnx_two_sided_area_light: BoolProperty(name="Two-Sided Area Light", description="Emit light from both faces of area plane", default=False, update=assets.invalidate_shader_cache)

View File

@ -1,211 +1,120 @@
import bpy
from bpy.props import *
class LnxTilesheetEventListItem(bpy.types.PropertyGroup):
"""An event triggered on a specific frame within a tilesheet action."""
name: StringProperty(
name="Event Name",
description="Name of the event to trigger",
default="event")
frame_prop: IntProperty(
name="Frame",
description="Frame number when this event triggers",
default=0,
min=0)
class LnxTilesheetActionListItem(bpy.types.PropertyGroup):
"""An action (animation sequence) within a tilesheet with per-action properties."""
name: StringProperty(
name="Name",
description="A name for this item",
description="Name of this tilesheet action",
default="Untitled")
start_prop: IntProperty(
name="Start",
description="A name for this item",
description="Starting frame index",
default=0)
end_prop: IntProperty(
name="End",
description="A name for this item",
description="Ending frame index",
default=0)
loop_prop: BoolProperty(
name="Loop",
description="A name for this item",
description="Whether this action should loop",
default=True)
class LNX_UL_TilesheetActionList(bpy.types.UIList):
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
# We could write some code to decide which icon to use here...
custom_icon = 'OBJECT_DATAMODE'
# Make sure your code supports all 3 layout types
if self.layout_type in {'DEFAULT', 'COMPACT'}:
layout.prop(item, "name", text="", emboss=False, icon=custom_icon)
elif self.layout_type in {'GRID'}:
layout.alignment = 'CENTER'
layout.label(text="", icon = custom_icon)
class LnxTilesheetActionListNewItem(bpy.types.Operator):
# Add a new item to the list
bl_idname = "lnx_tilesheetactionlist.new_item"
bl_label = "Add a new item"
def execute(self, context):
wrd = bpy.data.worlds['Lnx']
trait = wrd.lnx_tilesheetlist[wrd.lnx_tilesheetlist_index]
trait.lnx_tilesheetactionlist.add()
trait.lnx_tilesheetactionlist_index = len(trait.lnx_tilesheetactionlist) - 1
return{'FINISHED'}
class LnxTilesheetActionListDeleteItem(bpy.types.Operator):
"""Delete the selected item from the list"""
bl_idname = "lnx_tilesheetactionlist.delete_item"
bl_label = "Deletes an item"
@classmethod
def poll(self, context):
"""Enable if there's something in the list"""
wrd = bpy.data.worlds['Lnx']
if len(wrd.lnx_tilesheetlist) == 0:
return False
trait = wrd.lnx_tilesheetlist[wrd.lnx_tilesheetlist_index]
return len(trait.lnx_tilesheetactionlist) > 0
def execute(self, context):
wrd = bpy.data.worlds['Lnx']
trait = wrd.lnx_tilesheetlist[wrd.lnx_tilesheetlist_index]
list = trait.lnx_tilesheetactionlist
index = trait.lnx_tilesheetactionlist_index
list.remove(index)
if index > 0:
index = index - 1
trait.lnx_tilesheetactionlist_index = index
return{'FINISHED'}
class LnxTilesheetActionListMoveItem(bpy.types.Operator):
"""Move an item in the list"""
bl_idname = "lnx_tilesheetactionlist.move_item"
bl_label = "Move an item in the list"
bl_options = {'INTERNAL'}
direction: EnumProperty(
items=(
('UP', 'Up', ""),
('DOWN', 'Down', "")
))
@classmethod
def poll(self, context):
"""Enable if there's something in the list"""
wrd = bpy.data.worlds['Lnx']
if len(wrd.lnx_tilesheetlist) == 0:
return False
trait = wrd.lnx_tilesheetlist[wrd.lnx_tilesheetlist_index]
return len(trait.lnx_tilesheetactionlist) > 0
def move_index(self):
# Move index of an item render queue while clamping it
wrd = bpy.data.worlds['Lnx']
trait = wrd.lnx_tilesheetlist[wrd.lnx_tilesheetlist_index]
index = trait.lnx_tilesheetactionlist_index
list_length = len(trait.lnx_tilesheetactionlist) - 1
new_index = 0
if self.direction == 'UP':
new_index = index - 1
elif self.direction == 'DOWN':
new_index = index + 1
new_index = max(0, min(new_index, list_length))
trait.lnx_tilesheetactionlist.move(index, new_index)
trait.lnx_tilesheetactionlist_index = new_index
def execute(self, context):
wrd = bpy.data.worlds['Lnx']
trait = wrd.lnx_tilesheetlist[wrd.lnx_tilesheetlist_index]
list = trait.lnx_tilesheetactionlist
index = trait.lnx_tilesheetactionlist_index
if self.direction == 'DOWN':
neighbor = index + 1
self.move_index()
elif self.direction == 'UP':
neighbor = index - 1
self.move_index()
else:
return{'CANCELLED'}
return{'FINISHED'}
class LnxTilesheetListItem(bpy.types.PropertyGroup):
name: StringProperty(
name="Name",
description="A name for this item",
default="Untitled")
tilesx_prop: IntProperty(
name="Tiles X",
description="A name for this item",
default=0)
description="Number of horizontal tiles for this action",
default=1,
min=1)
tilesy_prop: IntProperty(
name="Tiles Y",
description="A name for this item",
default=0)
description="Number of vertical tiles for this action",
default=1,
min=1)
framerate_prop: FloatProperty(
framerate_prop: IntProperty(
name="Frame Rate",
description="A name for this item",
default=4.0)
description="Animation frame rate for this action (frames per second)",
default=4,
min=1)
lnx_tilesheetactionlist: CollectionProperty(type=LnxTilesheetActionListItem)
lnx_tilesheetactionlist_index: IntProperty(name="Index for lnx_tilesheetactionlist", default=0)
mesh_prop: StringProperty(
name="Mesh",
description="Optional mesh data to swap to when playing this action (brings its own material/texture/UVs)",
default="")
class LNX_UL_TilesheetList(bpy.types.UIList):
# Events list for this action
events: CollectionProperty(type=LnxTilesheetEventListItem)
events_index: IntProperty(name="Event Index", default=0)
class LNX_UL_TilesheetActionList(bpy.types.UIList):
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
# We could write some code to decide which icon to use here...
custom_icon = 'OBJECT_DATAMODE'
custom_icon = 'PLAY'
# Make sure your code supports all 3 layout types
if self.layout_type in {'DEFAULT', 'COMPACT'}:
layout.prop(item, "name", text="", emboss=False, icon=custom_icon)
elif self.layout_type in {'GRID'}:
layout.alignment = 'CENTER'
layout.label(text="", icon=custom_icon)
class LnxTilesheetListNewItem(bpy.types.Operator):
"""Add a new item to the list"""
bl_idname = "lnx_tilesheetlist.new_item"
bl_label = "Add a new item"
class LnxTilesheetActionListNewItem(bpy.types.Operator):
"""Add a new action to the tilesheet"""
bl_idname = "lnx_tilesheetactionlist.new_item"
bl_label = "Add Action"
def execute(self, context):
wrd = bpy.data.worlds['Lnx']
wrd.lnx_tilesheetlist.add()
wrd.lnx_tilesheetlist_index = len(wrd.lnx_tilesheetlist) - 1
return{'FINISHED'}
obj = context.object
if obj is None:
return {'CANCELLED'}
obj.lnx_tilesheet_actionlist.add()
obj.lnx_tilesheet_actionlist_index = len(obj.lnx_tilesheet_actionlist) - 1
return {'FINISHED'}
class LnxTilesheetListDeleteItem(bpy.types.Operator):
"""Delete the selected item from the list"""
bl_idname = "lnx_tilesheetlist.delete_item"
bl_label = "Deletes an item"
class LnxTilesheetActionListDeleteItem(bpy.types.Operator):
"""Delete the selected action from the tilesheet"""
bl_idname = "lnx_tilesheetactionlist.delete_item"
bl_label = "Delete Action"
@classmethod
def poll(self, context):
""" Enable if there's something in the list """
wrd = bpy.data.worlds['Lnx']
return len(wrd.lnx_tilesheetlist) > 0
def poll(cls, context):
obj = context.object
return obj is not None and len(obj.lnx_tilesheet_actionlist) > 0
def execute(self, context):
wrd = bpy.data.worlds['Lnx']
list = wrd.lnx_tilesheetlist
index = wrd.lnx_tilesheetlist_index
obj = context.object
action_list = obj.lnx_tilesheet_actionlist
index = obj.lnx_tilesheet_actionlist_index
list.remove(index)
action_list.remove(index)
if index > 0:
index = index - 1
wrd.lnx_tilesheetlist_index = index
return{'FINISHED'}
obj.lnx_tilesheet_actionlist_index = index
return {'FINISHED'}
class LnxTilesheetListMoveItem(bpy.types.Operator):
"""Move an item in the list"""
bl_idname = "lnx_tilesheetlist.move_item"
bl_label = "Move an item in the list"
class LnxTilesheetActionListMoveItem(bpy.types.Operator):
"""Move an action in the list"""
bl_idname = "lnx_tilesheetactionlist.move_item"
bl_label = "Move Action"
bl_options = {'INTERNAL'}
direction: EnumProperty(
@ -215,56 +124,266 @@ class LnxTilesheetListMoveItem(bpy.types.Operator):
))
@classmethod
def poll(self, context):
""" Enable if there's something in the list. """
wrd = bpy.data.worlds['Lnx']
return len(wrd.lnx_tilesheetlist) > 0
def move_index(self):
# Move index of an item render queue while clamping it
wrd = bpy.data.worlds['Lnx']
index = wrd.lnx_tilesheetlist_index
list_length = len(wrd.lnx_tilesheetlist) - 1
new_index = 0
if self.direction == 'UP':
new_index = index - 1
elif self.direction == 'DOWN':
new_index = index + 1
new_index = max(0, min(new_index, list_length))
wrd.lnx_tilesheetlist.move(index, new_index)
wrd.lnx_tilesheetlist_index = new_index
def poll(cls, context):
obj = context.object
return obj is not None and len(obj.lnx_tilesheet_actionlist) > 0
def execute(self, context):
wrd = bpy.data.worlds['Lnx']
list = wrd.lnx_tilesheetlist
index = wrd.lnx_tilesheetlist_index
obj = context.object
action_list = obj.lnx_tilesheet_actionlist
index = obj.lnx_tilesheet_actionlist_index
list_length = len(action_list) - 1
if self.direction == 'DOWN':
neighbor = index + 1
self.move_index()
if self.direction == 'UP':
new_index = max(0, index - 1)
else: # DOWN
new_index = min(list_length, index + 1)
elif self.direction == 'UP':
neighbor = index - 1
self.move_index()
action_list.move(index, new_index)
obj.lnx_tilesheet_actionlist_index = new_index
return {'FINISHED'}
class LnxTilesheetEventListNewItem(bpy.types.Operator):
"""Add a new event to the current action"""
bl_idname = "lnx_tilesheetactionlist.new_event"
bl_label = "Add Event"
@classmethod
def poll(cls, context):
obj = context.object
return obj is not None and len(obj.lnx_tilesheet_actionlist) > 0
def execute(self, context):
obj = context.object
if obj.lnx_tilesheet_actionlist_index < 0:
return {'CANCELLED'}
action = obj.lnx_tilesheet_actionlist[obj.lnx_tilesheet_actionlist_index]
action.events.add()
action.events_index = len(action.events) - 1
return {'FINISHED'}
class LnxTilesheetEventListDeleteItem(bpy.types.Operator):
"""Delete the selected event from the current action"""
bl_idname = "lnx_tilesheetactionlist.delete_event"
bl_label = "Delete Event"
@classmethod
def poll(cls, context):
obj = context.object
if obj is None or len(obj.lnx_tilesheet_actionlist) == 0:
return False
if obj.lnx_tilesheet_actionlist_index < 0:
return False
action = obj.lnx_tilesheet_actionlist[obj.lnx_tilesheet_actionlist_index]
return len(action.events) > 0
def execute(self, context):
obj = context.object
action = obj.lnx_tilesheet_actionlist[obj.lnx_tilesheet_actionlist_index]
events = action.events
index = action.events_index
events.remove(index)
if index > 0:
action.events_index = index - 1
return {'FINISHED'}
class LnxTilesheetSlice(bpy.types.Operator):
"""Slice the UV map based on tile dimensions - scales UVs to fit one tile"""
bl_idname = "lnx_tilesheet.slice"
bl_label = "Slice"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
obj = context.object
if obj is None or obj.type != 'MESH':
return False
if len(obj.lnx_tilesheet_actionlist) == 0:
return False
if obj.lnx_tilesheet_actionlist_index < 0:
return False
action = obj.lnx_tilesheet_actionlist[obj.lnx_tilesheet_actionlist_index]
# Check if action has a mesh specified, and that mesh has UVs
if action.mesh_prop != '':
mesh_data = bpy.data.meshes.get(action.mesh_prop)
if mesh_data is None or not mesh_data.uv_layers:
return False
else:
return{'CANCELLED'}
return{'FINISHED'}
# Fall back to object's own mesh
if not obj.data.uv_layers:
return False
return True
def execute(self, context):
obj = context.object
if obj.lnx_tilesheet_actionlist_index < 0:
self.report({'ERROR'}, "No action selected")
return {'CANCELLED'}
action = obj.lnx_tilesheet_actionlist[obj.lnx_tilesheet_actionlist_index]
tiles_x = action.tilesx_prop
tiles_y = action.tilesy_prop
if tiles_x < 1 or tiles_y < 1:
self.report({'ERROR'}, "Tiles X and Y must be at least 1")
return {'CANCELLED'}
# Get mesh from action's mesh_prop, or fall back to object's mesh
if action.mesh_prop != '':
mesh = bpy.data.meshes.get(action.mesh_prop)
if mesh is None:
self.report({'ERROR'}, f"Mesh '{action.mesh_prop}' not found")
return {'CANCELLED'}
mesh_name = action.mesh_prop
else:
mesh = obj.data
mesh_name = obj.data.name
if not mesh.uv_layers:
self.report({'ERROR'}, f"Mesh '{mesh_name}' has no UV layers")
return {'CANCELLED'}
uv_layer = mesh.uv_layers.active.data
# Calculate target tile size
tile_width = 1.0 / tiles_x
tile_height = 1.0 / tiles_y
# Find current UV bounding box
min_u = min_v = float('inf')
max_u = max_v = float('-inf')
for loop_uv in uv_layer:
min_u = min(min_u, loop_uv.uv[0])
max_u = max(max_u, loop_uv.uv[0])
min_v = min(min_v, loop_uv.uv[1])
max_v = max(max_v, loop_uv.uv[1])
current_width = max_u - min_u
current_height = max_v - min_v
if current_width == 0 or current_height == 0:
self.report({'ERROR'}, f"UV map on '{mesh_name}' has zero dimensions")
return {'CANCELLED'}
# Scale and position UVs to fit in first tile (0,0) to (tile_width, tile_height)
for loop_uv in uv_layer:
# Normalize to 0-1 range
norm_u = (loop_uv.uv[0] - min_u) / current_width
norm_v = (loop_uv.uv[1] - min_v) / current_height
# Scale to tile size
loop_uv.uv[0] = norm_u * tile_width
loop_uv.uv[1] = norm_v * tile_height
mesh.update()
self.report({'INFO'}, f"UVs sliced to {tiles_x}x{tiles_y} grid (tile size: {tile_width:.3f} x {tile_height:.3f})")
return {'FINISHED'}
class LNX_PT_TilesheetPanel(bpy.types.Panel):
bl_label = "Leenkx Tilesheet"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "object"
bl_options = {'DEFAULT_CLOSED'}
@classmethod
def poll(cls, context):
return context.object is not None and context.object.type == 'MESH'
def draw_header(self, context):
obj = context.object
self.layout.prop(obj, "lnx_tilesheet_enabled", text="")
def draw(self, context):
layout = self.layout
layout.use_property_split = True
layout.use_property_decorate = False
obj = context.object
layout.enabled = obj.lnx_tilesheet_enabled
# Start action dropdown
layout.prop_search(obj, "lnx_tilesheet_default_action", obj, "lnx_tilesheet_actionlist", text="Start Action")
row = layout.row()
row.prop(obj, "lnx_tilesheet_flipx")
row.prop(obj, "lnx_tilesheet_flipy")
# Actions list
layout.separator()
layout.label(text="Actions")
rows = 2
if len(obj.lnx_tilesheet_actionlist) > 1:
rows = 4
row = layout.row()
row.template_list("LNX_UL_TilesheetActionList", "The_List", obj, "lnx_tilesheet_actionlist", obj, "lnx_tilesheet_actionlist_index", rows=rows)
col = row.column(align=True)
col.operator("lnx_tilesheetactionlist.new_item", icon='ADD', text="")
col.operator("lnx_tilesheetactionlist.delete_item", icon='REMOVE', text="")
if len(obj.lnx_tilesheet_actionlist) > 1:
col.separator()
op = col.operator("lnx_tilesheetactionlist.move_item", icon='TRIA_UP', text="")
op.direction = 'UP'
op = col.operator("lnx_tilesheetactionlist.move_item", icon='TRIA_DOWN', text="")
op.direction = 'DOWN'
# Selected action details (per-action properties)
if obj.lnx_tilesheet_actionlist_index >= 0 and len(obj.lnx_tilesheet_actionlist) > 0:
adat = obj.lnx_tilesheet_actionlist[obj.lnx_tilesheet_actionlist_index]
box = layout.box()
# Grid dimensions
row = box.row()
row.use_property_split = False
row.prop(adat, "tilesx_prop")
row.prop(adat, "tilesy_prop")
row = box.row()
row.operator("lnx_tilesheet.slice", text="Slice")
# Frame range
row = box.row()
row.use_property_split = False
row.prop(adat, "start_prop")
row.prop(adat, "end_prop")
# Framerate and loop
box.prop(adat, "framerate_prop")
box.prop(adat, "loop_prop")
# Optional mesh (dropdown from bpy.data.meshes)
box.prop_search(adat, "mesh_prop", bpy.data, "meshes", text="Mesh")
# Events section
box.separator()
box.label(text="Events")
row = box.row()
col = row.column()
for i, evt in enumerate(adat.events):
evt_row = col.row(align=True)
evt_row.prop(evt, "name", text="")
evt_row.prop(evt, "frame_prop", text="Frame")
row = box.row(align=True)
row.operator("lnx_tilesheetactionlist.new_event", icon='ADD', text="Add Event")
row.operator("lnx_tilesheetactionlist.delete_event", icon='REMOVE', text="Remove Event")
__REG_CLASSES = (
LnxTilesheetEventListItem,
LnxTilesheetActionListItem,
LNX_UL_TilesheetActionList,
LnxTilesheetActionListNewItem,
LnxTilesheetActionListDeleteItem,
LnxTilesheetActionListMoveItem,
LnxTilesheetListItem,
LNX_UL_TilesheetList,
LnxTilesheetListNewItem,
LnxTilesheetListDeleteItem,
LnxTilesheetListMoveItem,
LnxTilesheetEventListNewItem,
LnxTilesheetEventListDeleteItem,
LnxTilesheetSlice,
LNX_PT_TilesheetPanel,
)
__reg_classes, unregister = bpy.utils.register_classes_factory(__REG_CLASSES)
@ -272,5 +391,28 @@ __reg_classes, unregister = bpy.utils.register_classes_factory(__REG_CLASSES)
def register():
__reg_classes()
bpy.types.World.lnx_tilesheetlist = CollectionProperty(type=LnxTilesheetListItem)
bpy.types.World.lnx_tilesheetlist_index = IntProperty(name="Index for lnx_tilesheetlist", default=0)
# Tilesheet properties on Object (one tilesheet per object)
bpy.types.Object.lnx_tilesheet_enabled = BoolProperty(
name="Tilesheet",
description="Enable tilesheet animation for this object",
default=False)
bpy.types.Object.lnx_tilesheet_default_action = StringProperty(
name="Start Action",
description="Start action to play on spawn",
default="")
bpy.types.Object.lnx_tilesheet_flipx = BoolProperty(
name="Flip X",
description="Flip the tilesheet horizontally",
default=False)
bpy.types.Object.lnx_tilesheet_flipy = BoolProperty(
name="Flip Y",
description="Flip the tilesheet vertically",
default=False)
bpy.types.Object.lnx_tilesheet_actionlist = CollectionProperty(type=LnxTilesheetActionListItem)
bpy.types.Object.lnx_tilesheet_actionlist_index = IntProperty(
name="Action Index",
default=0)

View File

@ -29,7 +29,7 @@ else:
ICON_HAXE = ui_icons.get_id('haxe')
ICON_NODES = 'NODETREE'
ICON_CANVAS = 'NODE_COMPOSITING'
ICON_CANVAS = 'WINDOW'
ICON_BUNDLED = ui_icons.get_id('bundle')
ICON_WASM = ui_icons.get_id('wasm')
@ -131,7 +131,7 @@ class LNX_UL_TraitList(bpy.types.UIList):
elif item.type_prop == "WebAssembly":
custom_icon_value = ICON_WASM
elif item.type_prop == "UI Canvas":
custom_icon = "NODE_COMPOSITING"
custom_icon = "WINDOW"
elif item.type_prop == "Bundled Script":
custom_icon_value = ICON_BUNDLED
elif item.type_prop == "Logic Nodes":
@ -996,7 +996,7 @@ def draw_traits_panel(layout: bpy.types.UILayout, obj: Union[bpy.types.Object, b
row.operator("lnx.new_canvas", icon="FILE_NEW").is_object = is_object
column = row.column(align=True)
column.enabled = item.canvas_name_prop != ''
column.operator("lnx.edit_canvas", icon="NODE_COMPOSITING").is_object = is_object
column.operator("lnx.edit_canvas", icon="WINDOW").is_object = is_object
refresh_op = "lnx.refresh_object_scripts" if is_object else "lnx.refresh_scripts"
row.operator(refresh_op, text="Refresh", icon="FILE_REFRESH")

View File

@ -16,8 +16,7 @@ PROP_TYPE_ICONS = {
"CameraObject": "CAMERA_DATA",
"LightObject": "LIGHT_DATA",
"MeshObject": "MESH_DATA",
"SpeakerObject": "OUTLINER_DATA_SPEAKER",
"TSceneFormat": "SCENE_DATA"
"SpeakerObject": "OUTLINER_DATA_SPEAKER"
}
@ -61,8 +60,7 @@ class LnxTraitPropListItem(bpy.types.PropertyGroup):
("CameraObject", "Camera Object", "Camera Object Type"),
("LightObject", "Light Object", "Light Object Type"),
("MeshObject", "Mesh Object", "Mesh Object Type"),
("SpeakerObject", "Speaker Object", "Speaker Object Type"),
("TSceneFormat", "Scene", "Scene Type")),
("SpeakerObject", "Speaker Object", "Speaker Object Type")),
name="Type",
description="The type of this property",
default="String")
@ -74,7 +72,6 @@ class LnxTraitPropListItem(bpy.types.PropertyGroup):
value_vec3: FloatVectorProperty(name="Value", size=3)
value_vec4: FloatVectorProperty(name="Value", size=4)
value_object: PointerProperty(name="Value", type=bpy.types.Object, poll=filter_objects)
value_scene: PointerProperty(name="Value", type=bpy.types.Scene)
else:
type: EnumProperty(
items=(
@ -90,8 +87,7 @@ class LnxTraitPropListItem(bpy.types.PropertyGroup):
("CameraObject", "Camera Object", "Camera Object Type"),
("LightObject", "Light Object", "Light Object Type"),
("MeshObject", "Mesh Object", "Mesh Object Type"),
("SpeakerObject", "Speaker Object", "Speaker Object Type"),
("TSceneFormat", "Scene", "Scene Type")),
("SpeakerObject", "Speaker Object", "Speaker Object Type")),
name="Type",
description="The type of this property",
default="String",
@ -104,7 +100,6 @@ class LnxTraitPropListItem(bpy.types.PropertyGroup):
value_vec3: FloatVectorProperty(name="Value", size=3, override={"LIBRARY_OVERRIDABLE"})
value_vec4: FloatVectorProperty(name="Value", size=4, override={"LIBRARY_OVERRIDABLE"})
value_object: PointerProperty(name="Value", type=bpy.types.Object, poll=filter_objects, override={"LIBRARY_OVERRIDABLE"})
value_scene: PointerProperty(name="Value", type=bpy.types.Scene, override={"LIBRARY_OVERRIDABLE"})
def set_value(self, val):
# Would require way too much effort, so it's out of scope here.
@ -153,10 +148,6 @@ class LnxTraitPropListItem(bpy.types.PropertyGroup):
if self.value_object is not None:
return self.value_object.name
return ""
if self.type == "TSceneFormat":
if self.value_scene is not None:
return self.value_scene.name
return ""
return self.value_string
@ -176,8 +167,6 @@ class LNX_UL_PropList(bpy.types.UIList):
if self.layout_type in {'DEFAULT', 'COMPACT'}:
if item.type.endswith("Object"):
sp.prop_search(item, "value_object", context.scene, "objects", text="", icon=custom_icon)
elif item.type.endswith("TSceneFormat"):
sp.prop_search(item, "value_scene", bpy.data, "scenes", text="", icon=custom_icon)
else:
use_emboss = item.type in ("Bool", "String")
sp.prop(item, item_value_ref, text="", emboss=use_emboss)

View File

@ -92,16 +92,6 @@ class LNX_PT_ObjectPropsPanel(bpy.types.Panel):
if obj.type == 'MESH':
layout.prop(obj, 'lnx_instanced')
wrd = bpy.data.worlds['Lnx']
layout.prop_search(obj, "lnx_tilesheet", wrd, "lnx_tilesheetlist", text="Tilesheet")
if obj.lnx_tilesheet != '':
selected_ts = None
for ts in wrd.lnx_tilesheetlist:
if ts.name == obj.lnx_tilesheet:
selected_ts = ts
break
layout.prop_search(obj, "lnx_tilesheet_action", selected_ts, "lnx_tilesheetactionlist", text="Action")
layout.prop(obj, "lnx_use_custom_tilesheet_node")
# Properties list
lnx.props_properties.draw_properties(layout, obj)
@ -227,6 +217,7 @@ class LNX_PT_ParticlesPropsPanel(bpy.types.Panel):
layout.prop(obj.settings, 'lnx_auto_start')
layout.prop(obj.settings, 'lnx_dynamic_emitter')
layout.prop(obj.settings, 'lnx_is_unique')
layout.prop(obj.settings, 'lnx_local_coords')
layout.prop(obj.settings, 'lnx_loop')
layout.prop(obj.settings, 'lnx_count_mult')
@ -864,11 +855,13 @@ class LNX_PT_LeenkxPlayerPanel(bpy.types.Panel):
row.operator("lnx.play", icon="PLAY")
else:
if bpy.app.version < (3, 0, 0):
row.operator("lnx.stop", icon="CANCEL", text="")
elif bpy.app.version > (3, 0, 0) and bpy.app.version < (4, 3, 2):
row.operator("lnx.stop", icon="SEQUENCE_COLOR_01", text="")
row.operator("lnx.stop", icon="CANCEL")
elif bpy.app.version > (3, 0, 0) and bpy.app.version < (4, 5, 0):
row.operator("lnx.stop", icon="SEQUENCE_COLOR_01")
elif bpy.app.version >= (4, 5, 0):
row.operator("lnx.stop", icon="STRIP_COLOR_01")
else:
row.operator("lnx.stop", icon="EVENT_MEDIASTOP", text="")
row.operator("lnx.stop", icon="EVENT_MEDIASTOP")
row.operator("lnx.clean_menu", icon="BRUSH_DATA")
col = layout.box().column()
@ -1369,6 +1362,7 @@ class LeenkxStopButton(bpy.types.Operator):
elif state.proc_build != None:
state.proc_build.terminate()
state.proc_build = None
make.clear_external_scenes()
lnx.write_probes.check_last_cmft_time()
@ -1550,11 +1544,13 @@ class LNX_PT_TopbarPanel(bpy.types.Panel):
row.operator("lnx.play", icon="PLAY", text="")
else:
if bpy.app.version < (3, 0, 0):
row.operator("lnx.stop", icon="CANCEL", text="")
elif bpy.app.version > (3, 0, 0) and bpy.app.version < (4, 3, 2):
row.operator("lnx.stop", icon="SEQUENCE_COLOR_01", text="")
row.operator("lnx.stop", icon="CANCEL")
elif bpy.app.version > (3, 0, 0) and bpy.app.version < (4, 5, 0):
row.operator("lnx.stop", icon="SEQUENCE_COLOR_01")
elif bpy.app.version >= (4, 5, 0):
row.operator("lnx.stop", icon="STRIP_COLOR_01")
else:
row.operator("lnx.stop", icon="EVENT_MEDIASTOP", text="")
row.operator("lnx.stop", icon="EVENT_MEDIASTOP")
row.operator("lnx.clean_menu", icon="BRUSH_DATA", text="")
row.operator("lnx.open_editor", icon="DESKTOP", text="")
row.operator("lnx.open_project_folder", icon="FILE_FOLDER", text="")
@ -2435,64 +2431,6 @@ class LNX_PT_TerrainPanel(bpy.types.Panel):
layout.operator('lnx.generate_terrain')
layout.prop(scn, 'lnx_terrain_object')
class LNX_PT_TilesheetPanel(bpy.types.Panel):
bl_label = "Leenkx Tilesheet"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "material"
bl_options = {'DEFAULT_CLOSED'}
def draw(self, context):
layout = self.layout
layout.use_property_split = True
layout.use_property_decorate = False
wrd = bpy.data.worlds['Lnx']
rows = 2
if len(wrd.lnx_tilesheetlist) > 1:
rows = 4
row = layout.row()
row.template_list("LNX_UL_TilesheetList", "The_List", wrd, "lnx_tilesheetlist", wrd, "lnx_tilesheetlist_index", rows=rows)
col = row.column(align=True)
col.operator("lnx_tilesheetlist.new_item", icon='ADD', text="")
col.operator("lnx_tilesheetlist.delete_item", icon='REMOVE', text="")
if len(wrd.lnx_tilesheetlist) > 1:
col.separator()
op = col.operator("lnx_tilesheetlist.move_item", icon='TRIA_UP', text="")
op.direction = 'UP'
op = col.operator("lnx_tilesheetlist.move_item", icon='TRIA_DOWN', text="")
op.direction = 'DOWN'
if wrd.lnx_tilesheetlist_index >= 0 and len(wrd.lnx_tilesheetlist) > 0:
dat = wrd.lnx_tilesheetlist[wrd.lnx_tilesheetlist_index]
layout.prop(dat, "tilesx_prop")
layout.prop(dat, "tilesy_prop")
layout.prop(dat, "framerate_prop")
layout.label(text='Actions')
rows = 2
if len(dat.lnx_tilesheetactionlist) > 1:
rows = 4
row = layout.row()
row.template_list("LNX_UL_TilesheetList", "The_List", dat, "lnx_tilesheetactionlist", dat, "lnx_tilesheetactionlist_index", rows=rows)
col = row.column(align=True)
col.operator("lnx_tilesheetactionlist.new_item", icon='ADD', text="")
col.operator("lnx_tilesheetactionlist.delete_item", icon='REMOVE', text="")
if len(dat.lnx_tilesheetactionlist) > 1:
col.separator()
op = col.operator("lnx_tilesheetactionlist.move_item", icon='TRIA_UP', text="")
op.direction = 'UP'
op = col.operator("lnx_tilesheetactionlist.move_item", icon='TRIA_DOWN', text="")
op.direction = 'DOWN'
if dat.lnx_tilesheetactionlist_index >= 0 and len(dat.lnx_tilesheetactionlist) > 0:
adat = dat.lnx_tilesheetactionlist[dat.lnx_tilesheetactionlist_index]
layout.prop(adat, "start_prop")
layout.prop(adat, "end_prop")
layout.prop(adat, "loop_prop")
class LnxPrintTraitsButton(bpy.types.Operator):
bl_idname = 'lnx.print_traits'
bl_label = 'Print All Traits'
@ -3058,7 +2996,6 @@ __REG_CLASSES = (
LNX_PT_LodPanel,
LnxGenTerrainButton,
LNX_PT_TerrainPanel,
LNX_PT_TilesheetPanel,
LnxPrintTraitsButton,
LNX_PT_MaterialNodePanel,
LNX_OT_UpdateFileSDK,

View File

@ -380,7 +380,7 @@ def get_haxe_path():
if get_os() == 'win':
return get_kha_path() + '/Tools/windows_x64/haxe.exe'
elif get_os() == 'mac':
return get_kha_path() + '/Tools/macos/haxe'
return get_kha_path() + '/Tools/macos_x64/haxe'
else:
return get_kha_path() + '/Tools/linux_x64/haxe'
@ -484,7 +484,7 @@ def fetch_script_props(filename: str):
# Property type is annotated
if p_type is not None:
if p_type.startswith("iron.object.") or p_type == "iron.data.SceneFormat.TSceneFormat":
if p_type.startswith("iron.object."):
p_type = p_type[12:]
elif p_type.startswith("iron.math."):
p_type = p_type[10:]
@ -562,7 +562,7 @@ def get_type_default_value(prop_type: str):
if prop_type == "Float":
return 0.0
if prop_type == "String" or prop_type in (
"Object", "CameraObject", "LightObject", "MeshObject", "SpeakerObject", "TSceneFormat"):
"Object", "CameraObject", "LightObject", "MeshObject", "SpeakerObject"):
return ""
if prop_type == "Bool":
return False
@ -804,13 +804,13 @@ def get_haxe_json_string(d: dict) -> str:
return s
def asset_name(bdata):
if bdata == None:
return None
s = bdata.name
# Append library name if linked
if bdata.library is not None:
s += '_' + bdata.library.name
return s
"""Get the qualified asset name, with library suffix for linked data.
For local assets, returns just the name.
For linked assets, returns 'name_libraryname' to ensure uniqueness.
"""
import lnx.linked_utils as linked_utils
return linked_utils.asset_name(bdata)
def asset_path(s):
"""Remove leading '//'"""
@ -868,7 +868,7 @@ def check_blender_version(op: bpy.types.Operator):
"""Check whether the Blender version is supported by Leenkx,
if not, report in UI.
"""
if bpy.app.version[:2] not in [(4, 4), (4, 2), (3, 6), (3, 3)]:
if bpy.app.version[:2] not in [(4, 5), (4, 4), (4, 2), (3, 6), (3, 3)]:
op.report({'INFO'}, 'INFO: For Leenkx to work correctly, use a Blender LTS version')

View File

@ -1,4 +1,5 @@
import glob
import importlib.util
import io
import json
import os
@ -10,6 +11,7 @@ from typing import List
import bpy
import lnx.assets as assets
import lnx.log as log
import lnx.make_renderpath as make_renderpath
import lnx.make_state as state
import lnx.utils
@ -17,6 +19,7 @@ import lnx.utils
if lnx.is_reload(__name__):
import lnx
assets = lnx.reload_module(assets)
log = lnx.reload_module(log)
make_renderpath = lnx.reload_module(make_renderpath)
state = lnx.reload_module(state)
lnx.utils = lnx.reload_module(lnx.utils)
@ -24,6 +27,96 @@ else:
lnx.enable_reload(__name__)
def get_library_hooks():
"""Discover and load `lnx_hooks.py` from Libraries and Subprojects.
Each hook module can define a write_main() function that returns a dict with:
- 'imports': Haxe import statements to add at the top
- 'main_pre': Code to add at the start of main() before Starter.main()
- 'main_post': Code to add at the end of main() after Starter.main()
- 'wrap_init': Tuple of (before, after) strings to wrap around the main() body
- 'priority': Integer for wrap_init nesting order (lower = outer, default = 0)
- 'defines': List of Haxe defines to add to khafile.js
- 'parameters': List of Haxe parameters to add to khafile.js
- 'assets': List of asset entries for khafile.js. Each entry can be:
- A string path: "Assets/icons.png"
- A tuple (path, options_dict): ("Assets/icons.png", {"noCompress": True})
Multiple wrap_init hooks are nested based on priority:
- Priority 0 (outer): LibA.init(function() {
- Priority 10 (inner): LibB.init(function() {
- Starter.main(); - Priority 10 (inner): });
- Priority 0 (outer): });
Returns a dict with combined strings for each injection point.
"""
hooks = {
'imports': '', # Haxe import statements
'main_pre': '', # Code to add at the start of main() before Starter.main()
'main_post': '', # Code to add at the end of main() after Starter.main()
'wrap_inits': [], # List of (priority, before, after) tuples
'defines': [], # List of Haxe defines for khafile.js
'parameters': [], # List of Haxe parameters for khafile.js
'assets': [] # List of assets for khafile.js
}
project_path = lnx.utils.get_fp()
hook_dirs = []
# Check Libraries folder
libraries_path = os.path.join(project_path, 'Libraries')
if os.path.exists(libraries_path):
for lib in os.listdir(libraries_path):
lib_path = os.path.join(libraries_path, lib)
if os.path.isdir(lib_path):
hook_dirs.append((lib, lib_path))
# Check Subprojects folder
subprojects_path = os.path.join(project_path, 'Subprojects')
if os.path.exists(subprojects_path):
for lib in os.listdir(subprojects_path):
lib_path = os.path.join(subprojects_path, lib)
if os.path.isdir(lib_path):
hook_dirs.append((lib, lib_path))
# Load each hook module
for lib_name, lib_path in hook_dirs:
hook_file = os.path.join(lib_path, 'lnx_hooks.py')
if os.path.isfile(hook_file):
try:
spec = importlib.util.spec_from_file_location(f"lnx_hooks_{lib_name}", hook_file)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
if hasattr(module, 'write_main'):
result = module.write_main()
if result:
if 'imports' in result and result['imports']:
hooks['imports'] += result['imports'] + '\n'
if 'main_pre' in result and result['main_pre']:
hooks['main_pre'] += result['main_pre'] + '\n'
if 'main_post' in result and result['main_post']:
hooks['main_post'] += result['main_post'] + '\n'
if 'wrap_init' in result and result['wrap_init']:
priority = result.get('priority', 0)
hooks['wrap_inits'].append((priority, result['wrap_init'][0], result['wrap_init'][1]))
if 'defines' in result and result['defines']:
hooks['defines'].extend(result['defines'])
if 'parameters' in result and result['parameters']:
hooks['parameters'].extend(result['parameters'])
if 'assets' in result and result['assets']:
hooks['assets'].extend(result['assets'])
log.info(f"Loaded library hook: {lib_name}")
except Exception as e:
log.warn(f"Failed to load hook from {lib_name}: {e}")
# Sort wrap_inits by priority (lower = outer wrapper)
hooks['wrap_inits'].sort(key=lambda x: x[0])
return hooks
def on_same_drive(path1: str, path2: str) -> bool:
drive_path1, _ = os.path.splitdrive(path1)
drive_path2, _ = os.path.splitdrive(path2)
@ -70,6 +163,9 @@ def write_khafilejs(is_play, export_physics: bool, export_navigation: bool, expo
wrd = bpy.data.worlds['Lnx']
rpdat = lnx.utils.get_rp()
# Get library hooks for defines and parameters
hooks = get_library_hooks()
sdk_path = lnx.utils.get_sdk_path()
rel_path = lnx.utils.get_relative_paths() # Convert absolute paths to relative
project_path = lnx.utils.get_fp()
@ -78,13 +174,47 @@ def write_khafilejs(is_play, export_physics: bool, export_navigation: bool, expo
# Whether to use relative paths for paths inside the SDK
do_relpath_sdk = rel_path and on_same_drive(sdk_path, project_path)
# Determine if assets should go to data folder (used for hook assets and later)
use_data_dir = is_publish and (state.target == 'krom-windows' or state.target == 'krom-linux' or state.target == 'windows-hl' or state.target == 'linux-hl' or state.target == 'html5')
with open('khafile.js', 'w', encoding="utf-8") as khafile:
khafile.write(
"""// Auto-generated
let project = new Project('""" + lnx.utils.safesrc(wrd.lnx_project_name + '-' + wrd.lnx_project_version) + """');
project.addSources('Sources');
""")
# Add library hook assets
for asset in hooks['assets']:
dest_opt = ', destination: "data/{name}"' if use_data_dir else ''
if isinstance(asset, tuple):
path, options = asset
opts = []
for key, value in options.items():
if isinstance(value, bool):
opts.append(f"{key}: {'true' if value else 'false'}")
elif isinstance(value, str):
opts.append(f"{key}: '{value}'")
else:
opts.append(f"{key}: {value}")
if use_data_dir and 'destination' not in options:
opts.append('destination: "data/{name}"')
opts_str = ', '.join(opts)
khafile.write(f'project.addAssets("{path}", {{ {opts_str} }});\n')
else:
khafile.write(f'project.addAssets("{asset}", {{ notinlist: true{dest_opt} }});\n')
# Add library hook defines
for d in hooks['defines']:
khafile.write("project.addDefine('" + d + "');\n")
for p in assets.khafile_params:
khafile.write("project.addParameter('" + p + "');\n")
# Add library hook parameters
for p in hooks['parameters']:
khafile.write("project.addParameter('" + p + "');\n")
khafile.write("project.addSources('Sources');\n")
# Auto-add assets located in Bundled directory
if os.path.exists('Bundled'):
@ -247,8 +377,7 @@ project.addSources('Sources');
shaders_path = os.path.relpath(shaders_path, project_path).replace('\\', '/')
khafile.write('project.addShaders("' + shaders_path + '", { noprocessing: true, noembed: ' + str(noembed).lower() + ' });\n')
# Move assets for published game to /data folder
use_data_dir = is_publish and (state.target == 'krom-windows' or state.target == 'krom-linux' or state.target == 'windows-hl' or state.target == 'linux-hl' or state.target == 'html5')
# Add define for data directory usage
if use_data_dir:
assets.add_khafile_def('lnx_data_dir')
@ -353,6 +482,10 @@ project.addSources('Sources');
if rpdat.lnx_particles != 'Off':
assets.add_khafile_def('lnx_particles')
if rpdat.lnx_particles == 'GPU':
assets.add_khafile_def('lnx_gpu_particles')
elif rpdat.lnx_particles == 'CPU':
assets.add_khafile_def('lnx_cpu_particles')
if rpdat.rp_draw_order == 'Index':
assets.add_khafile_def('lnx_draworder_index')
@ -369,9 +502,6 @@ project.addSources('Sources');
if wrd.lnx_winresize or state.target == 'html5':
assets.add_khafile_def('lnx_resizable')
if get_winmode(wrd.lnx_winmode) == 1 and state.target.startswith('html5'):
assets.add_khafile_def('kha_html5_disable_automatic_size_adjust')
# if bpy.data.scenes[0].unit_settings.system_rotation == 'DEGREES':
# assets.add_khafile_def('lnx_degrees')
@ -381,9 +511,6 @@ project.addSources('Sources');
for d in assets.khafile_defs:
khafile.write("project.addDefine('" + d + "');\n")
for p in assets.khafile_params:
khafile.write("project.addParameter('" + p + "');\n")
if state.target.startswith('android'):
bundle = 'org.leenkx3d.' + wrd.lnx_project_package if wrd.lnx_project_bundle == '' else wrd.lnx_project_bundle
khafile.write("project.targetOptions.android_native.package = '{0}';\n".format(lnx.utils.safestr(bundle)))
@ -424,7 +551,10 @@ project.addSources('Sources');
khafile.write("project.targetOptions.ios.bundle = '{0}';\n".format(lnx.utils.safestr(bundle)))
if wrd.lnx_project_icon != '':
shutil.copy(bpy.path.abspath(wrd.lnx_project_icon), project_path + '/icon.png')
icon_src = os.path.normpath(bpy.path.abspath(wrd.lnx_project_icon))
icon_dst = os.path.normpath(os.path.join(project_path, 'icon.png'))
if not os.path.isfile(icon_dst) or not os.path.samefile(icon_src, icon_dst):
shutil.copy2(icon_src, icon_dst)
if wrd.lnx_khafile is not None:
khafile.write(wrd.lnx_khafile.as_string())
@ -494,10 +624,18 @@ def write_mainhx(scene_name, resx, resy, is_play, is_publish):
elif rpdat.rp_driver != 'Leenkx':
pathpack = rpdat.rp_driver.lower()
# Get library hooks
hooks = get_library_hooks()
with open('Sources/Main.hx', 'w', encoding="utf-8") as f:
f.write(
"""// Auto-generated
package;\n""")
package;
""")
# Write hook imports
if hooks['imports']:
f.write(hooks['imports'])
f.write("""
class Main {
@ -511,10 +649,9 @@ class Main {
public static inline var voxelgiVoxelSize = """ + str(round(rpdat.lnx_voxelgi_size * 100) / 100) + """;""")
if rpdat.rp_bloom:
if bpy.app.version <= (4, 2, 4):
f.write(f"public static var bloomRadius = {bpy.context.scene.eevee.bloom_radius if rpdat.lnx_bloom_follow_blender else rpdat.lnx_bloom_radius};")
else:
f.write(f"public static var bloomRadius = {rpdat.lnx_bloom_radius};")
follow_blender = rpdat.lnx_bloom_follow_blender if bpy.app.version < (4, 3, 0) else False
f.write(f"public static var bloomRadius = {bpy.context.scene.eevee.bloom_radius if follow_blender else rpdat.lnx_bloom_radius};")
if rpdat.lnx_rp_resolution == 'Custom':
f.write("""
@ -523,49 +660,71 @@ class Main {
f.write("""\n
public static function main() {""")
# Calculate base indentation (2 tabs = 8 spaces)
base_indent = " "
wrap_count = len(hooks['wrap_inits'])
# Write wrap_init opening statements (outer to inner, sorted by priority)
for i, (priority, before, after) in enumerate(hooks['wrap_inits']):
indent = base_indent + (" " * i)
f.write("\n" + indent + before)
# Calculate content indentation based on wrap depth
content_indent = base_indent + (" " * wrap_count)
# Write main_pre hooks
if hooks['main_pre']:
f.write("\n" + content_indent + hooks['main_pre'].replace('\n', '\n' + content_indent))
if rpdat.lnx_skin != 'Off':
f.write("""
iron.object.BoneAnimation.skinMaxBones = """ + str(rpdat.lnx_skin_max_bones) + """;""")
f.write("\n" + content_indent + "iron.object.BoneAnimation.skinMaxBones = " + str(rpdat.lnx_skin_max_bones) + ";")
if rpdat.rp_shadows:
if rpdat.rp_shadowmap_cascades != '1':
f.write("""
iron.object.LightObject.cascadeCount = """ + str(rpdat.rp_shadowmap_cascades) + """;
iron.object.LightObject.cascadeSplitFactor = """ + str(rpdat.lnx_shadowmap_split) + """;""")
f.write("\n" + content_indent + "iron.object.LightObject.cascadeCount = " + str(rpdat.rp_shadowmap_cascades) + ";")
f.write("\n" + content_indent + "iron.object.LightObject.cascadeSplitFactor = " + str(rpdat.lnx_shadowmap_split) + ";")
if rpdat.lnx_shadowmap_bounds != 1.0:
f.write("""
iron.object.LightObject.cascadeBounds = """ + str(rpdat.lnx_shadowmap_bounds) + """;""")
f.write("\n" + content_indent + "iron.object.LightObject.cascadeBounds = " + str(rpdat.lnx_shadowmap_bounds) + ";")
if is_publish and wrd.lnx_loadscreen:
asset_references = list(set(assets.assets))
loadscreen_class = 'leenkx.trait.internal.LoadingScreen'
if os.path.isfile(lnx.utils.get_fp() + '/Sources/' + wrd.lnx_project_package + '/LoadingScreen.hx'):
loadscreen_class = wrd.lnx_project_package + '.LoadingScreen'
f.write("""
leenkx.system.Starter.numAssets = """ + str(len(asset_references)) + """;
leenkx.system.Starter.drawLoading = """ + loadscreen_class + """.render;""")
f.write("\n" + content_indent + "leenkx.system.Starter.numAssets = " + str(len(asset_references)) + ";")
f.write("\n" + content_indent + "leenkx.system.Starter.drawLoading = " + loadscreen_class + ".render;")
if wrd.lnx_ui == 'Enabled':
if wrd.lnx_canvas_img_scaling_quality == 'low':
f.write("""
leenkx.ui.Canvas.imageScaleQuality = kha.graphics2.ImageScaleQuality.Low;""")
f.write("\n" + content_indent + "leenkx.ui.Canvas.imageScaleQuality = kha.graphics2.ImageScaleQuality.Low;")
elif wrd.lnx_canvas_img_scaling_quality == 'high':
f.write("""
leenkx.ui.Canvas.imageScaleQuality = kha.graphics2.ImageScaleQuality.High;""")
f.write("\n" + content_indent + "leenkx.ui.Canvas.imageScaleQuality = kha.graphics2.ImageScaleQuality.High;")
# Write Starter.main call
starter_indent = content_indent + " "
f.write("\n" + content_indent + "leenkx.system.Starter.main(")
f.write("\n" + starter_indent + "'" + lnx.utils.safestr(scene_name) + scene_ext + "',")
f.write("\n" + starter_indent + str(winmode) + ",")
f.write("\n" + starter_indent + ('true' if wrd.lnx_winresize else 'false') + ",")
f.write("\n" + starter_indent + ('true' if wrd.lnx_winminimize else 'false') + ",")
f.write("\n" + starter_indent + ('true' if (wrd.lnx_winresize and wrd.lnx_winmaximize) else 'false') + ",")
f.write("\n" + starter_indent + str(resx) + ",")
f.write("\n" + starter_indent + str(resy) + ",")
f.write("\n" + starter_indent + str(int(rpdat.lnx_samples_per_pixel)) + ",")
f.write("\n" + starter_indent + ('true' if wrd.lnx_vsync else 'false') + ",")
f.write("\n" + starter_indent + pathpack + ".renderpath.RenderPathCreator.get")
f.write("\n" + content_indent + ");")
# Write main_post hooks
if hooks['main_post']:
f.write("\n" + content_indent + hooks['main_post'].replace('\n', '\n' + content_indent))
# Write wrap_init closing statements (inner to outer, reverse order)
for i, (priority, before, after) in enumerate(reversed(hooks['wrap_inits'])):
indent = base_indent + (" " * (wrap_count - 1 - i))
f.write("\n" + indent + after)
f.write("""
leenkx.system.Starter.main(
'""" + lnx.utils.safestr(scene_name) + scene_ext + """',
""" + str(winmode) + """,
""" + ('true' if wrd.lnx_winresize else 'false') + """,
""" + ('true' if wrd.lnx_winminimize else 'false') + """,
""" + ('true' if (wrd.lnx_winresize and wrd.lnx_winmaximize) else 'false') + """,
""" + str(resx) + """,
""" + str(resy) + """,
""" + str(int(rpdat.lnx_samples_per_pixel)) + """,
""" + ('true' if wrd.lnx_vsync else 'false') + """,
""" + pathpack + """.renderpath.RenderPathCreator.get
);
}
}""")
@ -584,28 +743,27 @@ def write_indexhtml(w, h, is_publish):
"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>""")
<meta charset='utf-8'/>""")
if rpdat.rp_stereo or wrd.lnx_winmode == 'Fullscreen':
f.write("""
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
""")
<meta name='viewport' content='width=device-width, initial-scale=1.0, user-scalable=no'>""")
f.write("""
<title>"""+html.escape( wrd.lnx_project_name)+"""</title>
</head>
<body style="margin: 0; padding: 0;">
<body style='margin: 0; padding: 0'>
""")
if rpdat.rp_stereo or wrd.lnx_winmode == 'Fullscreen':
f.write("""
<canvas style="object-fit: contain; min-width: 100%; max-width: 100%; max-height: 100vh; min-height: 100vh; display: block;" id='khanvas' tabindex='-1'""" + str(popupmenu_in_browser) + """></canvas>
<canvas style='object-fit: contain; min-width: 100%; max-width: 100%; max-height: 100vh; min-height: 100vh; display: block;' id='khanvas' tabindex='-1'""" + str(popupmenu_in_browser) + """></canvas>
""")
else:
if wrd.lnx_winmode != 'Headless':
f.write("""
<p align="center"><canvas align="center" style="outline: none;" id='khanvas' width='""" + str(w) + """' height='""" + str(h) + """' tabindex='-1'""" + str(popupmenu_in_browser) + """></canvas></p>
<p align='center'><canvas align='center' style='outline: none;' id='khanvas' width='""" + str(w) + """' height='""" + str(h) + """' tabindex='-1'""" + str(popupmenu_in_browser) + """></canvas></p>
""")
else:
f.write("""
<canvas align="center" style="display: none;" id='khanvas' width='""" + str(w) + """' height='""" + str(h) + """' tabindex='-1'""" + str(popupmenu_in_browser) + """></canvas>
<canvas align='center' style='display: none;' id='khanvas' width='""" + str(w) + """' height='""" + str(h) + """' tabindex='-1'""" + str(popupmenu_in_browser) + """></canvas>
<script>
// Quick solution for headless mode only for HTML target
window.onload = function() {
@ -614,7 +772,7 @@ def write_indexhtml(w, h, is_publish):
</script>
""")
f.write("""
<script src='kha.js'></script>
<script type='text/javascript' src='kha.js'></script>
</body>
</html>
""")
@ -707,16 +865,13 @@ const float ssgiRadius = """ + str(round(rpdat.lnx_ssgi_radius * 100) / 100) + "
""")
if rpdat.rp_bloom:
follow_blender = rpdat.lnx_bloom_follow_blender
follow_blender = rpdat.lnx_bloom_follow_blender if bpy.app.version < (4, 3, 0) else False
eevee_settings = bpy.context.scene.eevee
if bpy.app.version <= (4, 2, 4):
threshold = eevee_settings.bloom_threshold if follow_blender else rpdat.lnx_bloom_threshold
strength = eevee_settings.bloom_intensity if follow_blender else rpdat.lnx_bloom_strength
knee = eevee_settings.bloom_knee if follow_blender else rpdat.lnx_bloom_knee
else:
threshold = rpdat.lnx_bloom_threshold
strength = rpdat.lnx_bloom_strength
knee = rpdat.lnx_bloom_knee
threshold = eevee_settings.bloom_threshold if follow_blender else rpdat.lnx_bloom_threshold
strength = eevee_settings.bloom_intensity if follow_blender else rpdat.lnx_bloom_strength
knee = eevee_settings.bloom_knee if follow_blender else rpdat.lnx_bloom_knee
f.write(
"""const float bloomThreshold = """ + str(round(threshold * 100) / 100) + """;

View File

@ -160,7 +160,7 @@ def write_probes(image_filepath: str, disable_hdr: bool, from_srgb: bool, cached
kraffiti_path = kha_path + '/Kinc/Tools/windows_x64/kraffiti.exe'
elif lnx.utils.get_os() == 'mac':
cmft_path = '"' + sdk_path + '/lib/leenkx_tools/cmft/cmft-osx"'
kraffiti_path = '"' + kha_path + '/Kinc/Tools/macos/kraffiti"'
kraffiti_path = '"' + kha_path + '/Kinc/Tools/macos_x64/kraffiti"'
else:
cmft_path = '"' + sdk_path + '/lib/leenkx_tools/cmft/cmft-linux64"'
kraffiti_path = '"' + kha_path + '/Kinc/Tools/linux_x64/kraffiti"'
@ -439,8 +439,10 @@ def write_sky_irradiance(base_name):
def write_color_irradiance(base_name, col):
"""Constant color irradiance"""
# Adjust to Cycles
irradiance_floats = [col[0] * 1.13, col[1] * 1.13, col[2] * 1.13]
# Match shIrradiance()'s c4 factor so a constant color environment
# decodes back to the same diffuse radiance at strength 1.
sh_l00 = 1.0 / 0.886227
irradiance_floats = [col[0] * sh_l00, col[1] * sh_l00, col[2] * sh_l00]
for i in range(0, 24):
irradiance_floats.append(0.0)