From 89375ed0866d2bb27fc40afca5b0cc1048a3cadc Mon Sep 17 00:00:00 2001 From: Jake Blakeley Date: Mon, 6 Jul 2026 23:22:30 -0700 Subject: [PATCH 1/2] Fix: grouped source tree handling in check_layer_source and replace_layer_type check_layer_source added the source node to the layer tree instead of the source group tree, so toggling a channel on a layer that uses a source group (e.g. with smooth bump enabled) could recreate the source with a blank image. Redirect it through get_source_tree. replace_layer_type removed the source's input link from the layer tree rather than the source group tree that actually owns it, raising "Unable to locate link in node tree" when changing the type of a layer that uses a source group. Use source_tree for the removal. --- Layer.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Layer.py b/Layer.py index e5c82328..9e75c3e5 100644 --- a/Layer.py +++ b/Layer.py @@ -34,6 +34,9 @@ def get_normal_map_type_items(self, context): def check_layer_source(layer, tree=None, image=None, vcol=None, setup_edge_detect=True): if tree == None: tree = get_tree(layer) + # Source can be inside a group + tree = get_source_tree(layer, tree) + # Add source if layer.type == 'VCOL': source, dirty = check_new_node(tree, layer, 'source', get_vcol_bl_idname(), 'Source', True) @@ -4680,7 +4683,7 @@ def replace_layer_type(layer, new_type, item_name='', remove_data=False): setattr(layer, 'cache_' + layer.type.lower(), source.name) # Remove uv input link if any(source.inputs) and any(source.inputs[0].links): - tree.links.remove(source.inputs[0].links[0]) + source_tree.links.remove(source.inputs[0].links[0]) source.label = '' else: remove_node(source_tree, layer, 'source', remove_data=remove_data) From 416f7729edceb995dcd4c343bd4bc93d51cd73f6 Mon Sep 17 00:00:00 2001 From: Jake Blakeley Date: Mon, 6 Jul 2026 23:22:49 -0700 Subject: [PATCH 2/2] Add Triplanar texture coordinate type for layers and masks Projects the source along the three object axes and blends them by the surface normal, so it works with any source (images and procedural textures alike) in both Eevee and Cycles, back to Blender 2.80. The source group is wrapped in a per-entity node tree that instances the source three times with swizzled object-space coordinates. Color always blends all three projections to avoid stretching, while the per-side controls only fade coverage through the alpha weights. Works with smooth bump (the neighbor sources share the wrapper) and layer/mask duplication. Controls, exposed on both layers and masks: - Projection Blend: transition softness between projection axes - Expand: grows shown-side coverage to counteract fading when sides are hidden - Show +X/-X/+Y/-Y/+Z/-Z: per-side visibility, selected by the normal direction All the weight math is built from scalar Math, Separate/Combine XYZ and Mix nodes so it evaluates identically in both engines with no version-specific fallbacks. Triplanar utility node groups are generated in lib.py following the existing programmatic node group convention. --- Layer.py | 18 ++- Mask.py | 4 +- Triplanar.py | 253 ++++++++++++++++++++++++++++++++++ __init__.py | 3 +- common.py | 49 ++++++- input_outputs.py | 22 ++- lib.py | 326 +++++++++++++++++++++++++++++++++++++++++++- node_connections.py | 30 +++- subtree.py | 31 +++-- ui.py | 49 ++++++- 10 files changed, 757 insertions(+), 28 deletions(-) create mode 100644 Triplanar.py diff --git a/Layer.py b/Layer.py index 9e75c3e5..29475586 100644 --- a/Layer.py +++ b/Layer.py @@ -1,7 +1,7 @@ import bpy, time, re, os, random, numpy from bpy.props import * from bpy_extras.io_utils import ImportHelper -from . import Modifier, lib, Mask, transition, ImageAtlas, UDIM, NormalMapModifier, ListItem, BaseOperator, Decal +from . import Modifier, lib, Mask, transition, ImageAtlas, UDIM, NormalMapModifier, ListItem, BaseOperator, Decal, Triplanar from .common import * #from . import bake_common from .node_arrangements import * @@ -5216,14 +5216,18 @@ def duplicate_layer_nodes_and_images(tree, specific_layers=[], packed_duplicate= if layer.source_group != '': source_group = ttree.nodes.get(layer.source_group) source_group.node_tree = source_group.node_tree.copy() - source = source_group.node_tree.nodes.get(layer.source) + + # Triplanar wrapper keeps the actual source tree inside + inner_tree = Triplanar.duplicate_triplanar_inner_tree(source_group.node_tree) + source_tree = inner_tree if inner_tree else source_group.node_tree + source = source_tree.nodes.get(layer.source) for d in neighbor_directions: s = ttree.nodes.get(getattr(layer, 'source_' + d)) if s: s.node_tree = source_group.node_tree # Duplicate layer modifier groups - duplicate_layer_modifier_tree(layer, source_group.node_tree) + duplicate_layer_modifier_tree(layer, source_tree) else: source = ttree.nodes.get(layer.source) @@ -5297,7 +5301,11 @@ def duplicate_layer_nodes_and_images(tree, specific_layers=[], packed_duplicate= if mask.group_node != '': mask_group = ttree.nodes.get(mask.group_node) mask_group.node_tree = mask_group.node_tree.copy() - mask_source = mask_group.node_tree.nodes.get(mask.source) + + # Triplanar wrapper keeps the actual source tree inside + inner_tree = Triplanar.duplicate_triplanar_inner_tree(mask_group.node_tree) + mask_tree = inner_tree if inner_tree else mask_group.node_tree + mask_source = mask_tree.nodes.get(mask.source) for d in neighbor_directions: s = ttree.nodes.get(getattr(mask, 'source_' + d)) @@ -7420,7 +7428,7 @@ def update_layer_use_baked(self, context): reconnect_yp_nodes(self.id_data) rearrange_yp_nodes(self.id_data) -class YLayer(bpy.types.PropertyGroup, Decal.BaseDecal): +class YLayer(bpy.types.PropertyGroup, Decal.BaseDecal, Triplanar.BaseTriplanar): name : StringProperty( name = 'Layer Name', description = 'Layer name', diff --git a/Mask.py b/Mask.py index b361b2af..2ec12ea0 100644 --- a/Mask.py +++ b/Mask.py @@ -1,7 +1,7 @@ import bpy, re, time, random from bpy.props import * from bpy_extras.io_utils import ImportHelper -from . import lib, ImageAtlas, MaskModifier, UDIM, ListItem, BaseOperator, Decal +from . import lib, ImageAtlas, MaskModifier, UDIM, ListItem, BaseOperator, Decal, Triplanar from .common import * from .node_connections import * from .node_arrangements import * @@ -2444,7 +2444,7 @@ def update_mask_uniform_scale_enabled(self, context): reconnect_layer_nodes(layer) rearrange_layer_nodes(layer) -class YLayerMask(bpy.types.PropertyGroup, Decal.BaseDecal): +class YLayerMask(bpy.types.PropertyGroup, Decal.BaseDecal, Triplanar.BaseTriplanar): name : StringProperty( name = 'Mask Name', diff --git a/Triplanar.py b/Triplanar.py new file mode 100644 index 00000000..dafdc06b --- /dev/null +++ b/Triplanar.py @@ -0,0 +1,253 @@ +import bpy, re +from . import lib +from bpy.props import * +from .common import * +from .node_connections import create_link + +def refresh_triplanar_wrapper(wrapper, source_tree): + ''' Make sure triplanar wrapper interface mirrors the source tree outputs + and its internal nodes sample the source tree three times ''' + + # Vector input should be the first socket + inp = get_tree_input_by_name(wrapper, 'Vector') + if not inp: new_tree_input(wrapper, 'Vector', 'NodeSocketVector') + + valid_input_names = ['Vector'] + list(triplanar_input_props) + for socket_name in triplanar_input_props: + inp = get_tree_input_by_name(wrapper, socket_name) + if not inp: + inp = new_tree_input(wrapper, socket_name, 'NodeSocketFloatFactor') + inp.min_value = 0.0 + inp.max_value = 1.0 + inp.default_value = triplanar_input_defaults.get(socket_name, 1.0) + + for inp in reversed(get_tree_inputs(wrapper)): + if inp.name not in valid_input_names: + remove_tree_input(wrapper, inp) + + # Mirror source tree outputs + valid_output_names = [] + for outp in get_tree_outputs(source_tree): + tout = get_tree_output_by_name(wrapper, outp.name) + if not tout: + socket_type = 'NodeSocketFloat' if outp.name.endswith(io_suffix['ALPHA']) or outp.name == 'Value' else 'NodeSocketColor' + new_tree_output(wrapper, outp.name, socket_type) + valid_output_names.append(outp.name) + + for outp in reversed(get_tree_outputs(wrapper)): + if outp.name not in valid_output_names: + remove_tree_output(wrapper, outp) + + start = wrapper.nodes.get(TREE_START) + end = wrapper.nodes.get(TREE_END) + + # Prep node + prep = wrapper.nodes.get(TRIPLANAR_PREP) + if not prep: + prep = wrapper.nodes.new('ShaderNodeGroup') + prep.name = TRIPLANAR_PREP + prep.label = TRIPLANAR_PREP + prep_tree = lib.get_triplanar_prep_tree() + if prep.node_tree != prep_tree: prep.node_tree = prep_tree + + create_link(wrapper, start.outputs['Vector'], prep.inputs['Vector']) + for socket_name in triplanar_input_props: + create_link(wrapper, start.outputs[socket_name], prep.inputs[socket_name]) + + # Source instances + sources = {} + for i, axis in enumerate(triplanar_axes): + source = wrapper.nodes.get(TRIPLANAR_SOURCE_PREFIX + axis) + if not source: + source = wrapper.nodes.new('ShaderNodeGroup') + source.name = TRIPLANAR_SOURCE_PREFIX + axis + source.label = TRIPLANAR_SOURCE_PREFIX + axis + if source.node_tree != source_tree: source.node_tree = source_tree + source.location = (400, -i * 300) + create_link(wrapper, prep.outputs['Vector ' + axis], source.inputs[0]) + sources[axis] = source + + # Blend node per source tree output pair + alpha_pair_names = [name + io_suffix['ALPHA'] for name in valid_output_names] + main_output_names = [name for name in valid_output_names if name not in alpha_pair_names] + valid_blend_names = [] + for i, name in enumerate(main_output_names): + alpha_name = name + io_suffix['ALPHA'] + paired = alpha_name in valid_output_names + + blend = wrapper.nodes.get(TRIPLANAR_BLEND_PREFIX + name) + if not blend: + blend = wrapper.nodes.new('ShaderNodeGroup') + blend.name = TRIPLANAR_BLEND_PREFIX + name + blend.label = TRIPLANAR_BLEND_PREFIX + name + blend_tree = lib.get_triplanar_blend_tree() if paired else lib.get_triplanar_blend_value_tree() + if blend.node_tree != blend_tree: blend.node_tree = blend_tree + blend.location = (700, -i * 300) + valid_blend_names.append(blend.name) + + for axis in triplanar_axes: + if paired: + create_link(wrapper, sources[axis].outputs[name], blend.inputs['Color ' + axis]) + create_link(wrapper, sources[axis].outputs[alpha_name], blend.inputs['Alpha ' + axis]) + create_link(wrapper, prep.outputs['Color Weight ' + axis], blend.inputs['Color Weight ' + axis]) + create_link(wrapper, prep.outputs['Alpha Weight ' + axis], blend.inputs['Alpha Weight ' + axis]) + else: + create_link(wrapper, sources[axis].outputs[name], blend.inputs['Value ' + axis]) + create_link(wrapper, prep.outputs['Alpha Weight ' + axis], blend.inputs['Weight ' + axis]) + + if paired: + create_link(wrapper, blend.outputs['Color'], end.inputs[name]) + create_link(wrapper, blend.outputs['Alpha'], end.inputs[alpha_name]) + else: + create_link(wrapper, blend.outputs['Value'], end.inputs[name]) + + # Remove unused blend nodes + for node in [n for n in wrapper.nodes if n.name.startswith(TRIPLANAR_BLEND_PREFIX)]: + if node.name not in valid_blend_names: + wrapper.nodes.remove(node) + + start.location = (0, 0) + prep.location = (200, 0) + end.location = (1000, 0) + +def get_triplanar_instances(entity, tree): + ''' Get all group nodes sharing the entity source tree ''' + + m2 = re.match(r'^yp\.layers\[(\d+)\]\.masks\[(\d+)\]$', entity.path_from_id()) + group_prop = 'group_node' if m2 else 'source_group' + + instances = [] + names = [getattr(entity, group_prop)] + [getattr(entity, 'source_' + d) for d in neighbor_directions] + for name in names: + node = tree.nodes.get(name) + if node and node.type == 'GROUP' and node.node_tree: + instances.append(node) + + return instances + +def check_entity_triplanar_nodes(entity, tree=None): + ''' Wrap or unwrap the entity source tree with a triplanar wrapper ''' + + yp = entity.id_data.yp + + m1 = re.match(r'^yp\.layers\[(\d+)\]$', entity.path_from_id()) + m2 = re.match(r'^yp\.layers\[(\d+)\]\.masks\[(\d+)\]$', entity.path_from_id()) + + if m1: + entity_enabled = get_layer_enabled(entity) + layer = entity + prefix = LAYERGROUP_PREFIX + elif m2: + entity_enabled = get_mask_enabled(entity) + layer = yp.layers[int(m2.group(1))] + prefix = MASKGROUP_PREFIX + else: return + + if not tree: tree = get_tree(layer) + if not tree: return + + instances = get_triplanar_instances(entity, tree) + if not instances: return + + # Instances can point to the source tree or a wrapper around it + current_tree = instances[0].node_tree + source_tree = get_triplanar_source_tree(current_tree) + wrapper = current_tree if source_tree else None + if not source_tree: source_tree = current_tree + + if entity_enabled and is_entity_using_triplanar(entity): + if not wrapper: + wrapper = bpy.data.node_groups.new(prefix + entity.name + ' Triplanar', 'ShaderNodeTree') + create_essential_nodes(wrapper) + + refresh_triplanar_wrapper(wrapper, source_tree) + + for instance in instances: + if instance.node_tree != wrapper: instance.node_tree = wrapper + + elif wrapper: + for instance in instances: + if instance.node_tree != source_tree: instance.node_tree = source_tree + + remove_datablock(bpy.data.node_groups, wrapper) + +def unwrap_triplanar_group_node(group_node): + ''' Point the group node back to the actual source tree and delete the wrapper ''' + + if not group_node or group_node.type != 'GROUP': return + source_tree = get_triplanar_source_tree(group_node.node_tree) + if source_tree: + wrapper = group_node.node_tree + group_node.node_tree = source_tree + remove_datablock(bpy.data.node_groups, wrapper) + +def duplicate_triplanar_inner_tree(wrapper): + ''' Duplicate the source tree inside a copied triplanar wrapper so it's no longer shared ''' + + inner_tree = get_triplanar_source_tree(wrapper) + if not inner_tree: return None + + inner_copy = inner_tree.copy() + for axis in triplanar_axes: + source = wrapper.nodes.get(TRIPLANAR_SOURCE_PREFIX + axis) + if source: source.node_tree = inner_copy + + return inner_copy + +class BaseTriplanar(): + + triplanar_blend : FloatProperty( + name = 'Triplanar Blend', + description = 'Blend smoothness between triplanar projection axes', + subtype = 'FACTOR', + min=0.0, max=1.0, default=0.2, precision=3 + ) + + triplanar_expand : FloatProperty( + name = 'Triplanar Expand', + description = 'Expand the coverage of shown sides to counteract fading when some sides are hidden', + subtype = 'FACTOR', + min=0.0, max=1.0, default=0.0, precision=3 + ) + + triplanar_show_pos_x : FloatProperty( + name = 'Show +X', + description = 'Show triplanar projection on faces pointing along the object +X axis', + subtype = 'FACTOR', + min=0.0, max=1.0, default=1.0, precision=3 + ) + + triplanar_show_neg_x : FloatProperty( + name = 'Show -X', + description = 'Show triplanar projection on faces pointing along the object -X axis', + subtype = 'FACTOR', + min=0.0, max=1.0, default=1.0, precision=3 + ) + + triplanar_show_pos_y : FloatProperty( + name = 'Show +Y', + description = 'Show triplanar projection on faces pointing along the object +Y axis', + subtype = 'FACTOR', + min=0.0, max=1.0, default=1.0, precision=3 + ) + + triplanar_show_neg_y : FloatProperty( + name = 'Show -Y', + description = 'Show triplanar projection on faces pointing along the object -Y axis', + subtype = 'FACTOR', + min=0.0, max=1.0, default=1.0, precision=3 + ) + + triplanar_show_pos_z : FloatProperty( + name = 'Show +Z', + description = 'Show triplanar projection on faces pointing along the object +Z axis', + subtype = 'FACTOR', + min=0.0, max=1.0, default=1.0, precision=3 + ) + + triplanar_show_neg_z : FloatProperty( + name = 'Show -Z', + description = 'Show triplanar projection on faces pointing along the object -Z axis', + subtype = 'FACTOR', + min=0.0, max=1.0, default=1.0, precision=3 + ) diff --git a/__init__.py b/__init__.py index 6503b266..f0004f00 100644 --- a/__init__.py +++ b/__init__.py @@ -28,6 +28,7 @@ def is_available(module_relpath): importlib.reload(bake_common) importlib.reload(modifier_common) importlib.reload(Decal) + importlib.reload(Triplanar) importlib.reload(ui) importlib.reload(subtree) importlib.reload(transition_common) @@ -63,7 +64,7 @@ def is_available(module_relpath): from . import lib from . import BaseOperator from . import Localization - from . import image_ops, bake_common, modifier_common, Decal, ui, subtree, transition_common, input_outputs, node_arrangements, node_connections + from . import image_ops, bake_common, modifier_common, Decal, Triplanar, ui, subtree, transition_common, input_outputs, node_arrangements, node_connections from . import vector_displacement_lib, vector_displacement from . import vcol_editor, transition, BakeTarget, BakeInfo, UDIM, ImageAtlas, MaskModifier, Mask, Modifier, NormalMapModifier, Layer, ListItem, Bake, BakeToLayer, Root, versioning if is_available('.addon_updater_ops'): from . import addon_updater_ops diff --git a/common.py b/common.py index b4217f3b..044706e5 100644 --- a/common.py +++ b/common.py @@ -51,6 +51,33 @@ TEMP_ACTIVE_IMAGE_NAME = '.YP_TEMP_ACTIVE_IMAGE' TEMP_ACTIVE_IMAGE_NODE_NAME = '.YP_TEMP_ACTIVE_IMAGE_NODE' +TRIPLANAR_PREP = 'Triplanar Prep' +TRIPLANAR_BLEND_PREFIX = 'Triplanar Blend ' +TRIPLANAR_SOURCE_PREFIX = 'Triplanar Source ' +triplanar_axes = ['X', 'Y', 'Z'] +triplanar_side_props = { + '+X' : 'triplanar_show_pos_x', + '-X' : 'triplanar_show_neg_x', + '+Y' : 'triplanar_show_pos_y', + '-Y' : 'triplanar_show_neg_y', + '+Z' : 'triplanar_show_pos_z', + '-Z' : 'triplanar_show_neg_z', +} + +# Triplanar wrapper input socket names paired with their entity properties +triplanar_input_props = { + 'Blend' : 'triplanar_blend', + 'Expand' : 'triplanar_expand', +} +for side, prop in triplanar_side_props.items(): + triplanar_input_props['Show ' + side] = prop + +# Show side inputs default to 1.0 +triplanar_input_defaults = { + 'Blend' : 0.2, + 'Expand' : 0.0, +} + def is_bl_newer_than(major, minor=0, patch=0): return bpy.app.version >= (major, minor, patch) @@ -507,6 +534,7 @@ def get_vertex_color_label(capital=11): ('Window', 'Window', ''), ('Reflection', 'Reflection', ''), ('Decal', 'Decal', ''), + ('Triplanar', 'Triplanar', ''), ) mask_texcoord_type_items = ( @@ -518,6 +546,7 @@ def get_vertex_color_label(capital=11): ('Window', 'Window', ''), ('Reflection', 'Reflection', ''), ('Decal', 'Decal', ''), + ('Triplanar', 'Triplanar', ''), ('Layer', 'Use Layer Vector', ''), ) @@ -617,6 +646,7 @@ def get_vertex_color_label(capital=11): 'Window' : 'Texcoord Window', 'Reflection' : 'Texcoord Reflection', 'Decal' : 'Texcoord Object', + 'Triplanar' : 'Texcoord Object', } math_method_items = ( @@ -2360,6 +2390,14 @@ def get_mod_tree(entity): return tree +def get_triplanar_source_tree(tree): + ''' Get the actual source tree if the passed tree is a triplanar wrapper ''' + if not tree: return None + source_x = tree.nodes.get(TRIPLANAR_SOURCE_PREFIX + 'X') + if source_x and source_x.type == 'GROUP' and source_x.node_tree: + return source_x.node_tree + return None + def get_mask_tree(mask, layer_tree=None, ignore_group=False): if not layer_tree: @@ -2379,6 +2417,8 @@ def get_mask_tree(mask, layer_tree=None, ignore_group=False): else: return None if not group_node or group_node.type != 'GROUP': return layer_tree + inner_tree = get_triplanar_source_tree(group_node.node_tree) + if inner_tree: return inner_tree return group_node.node_tree def get_mask_source(mask, get_baked=False, layer_tree=None): @@ -2428,6 +2468,8 @@ def get_channel_source_tree(ch, layer=None, tree=None): if ch.source_group != '': source_group = tree.nodes.get(ch.source_group) if source_group: + inner_tree = get_triplanar_source_tree(source_group.node_tree) + if inner_tree: return inner_tree return source_group.node_tree return tree @@ -2468,6 +2510,8 @@ def get_source_tree(layer, tree=None): if layer.source_group != '': source_group = tree.nodes.get(layer.source_group) + inner_tree = get_triplanar_source_tree(source_group.node_tree) + if inner_tree: return inner_tree return source_group.node_tree return tree @@ -3441,7 +3485,10 @@ def get_udim_segment_mapping_offset(segment): offset_y += tiles_height + 1 def is_mapping_possible(entity_type): - return entity_type not in {'VCOL', 'BACKGROUND', 'COLOR', 'GROUP', 'HEMI', 'OBJECT_INDEX', 'COLOR_ID', 'BACKFACE', 'EDGE_DETECT', 'MODIFIER', 'AO'} + return entity_type not in {'VCOL', 'BACKGROUND', 'COLOR', 'GROUP', 'HEMI', 'OBJECT_INDEX', 'COLOR_ID', 'BACKFACE', 'EDGE_DETECT', 'MODIFIER', 'AO'} + +def is_entity_using_triplanar(entity): + return entity.texcoord_type == 'Triplanar' and not entity.use_baked and is_mapping_possible(entity.type) def clear_mapping(entity, use_baked=False): diff --git a/input_outputs.py b/input_outputs.py index 5e2547df..9ab49527 100644 --- a/input_outputs.py +++ b/input_outputs.py @@ -1,5 +1,5 @@ import bpy, re -from . import lib, Decal +from . import lib, Decal, Triplanar from .common import * from .transition_common import * from .subtree import * @@ -575,6 +575,13 @@ def check_all_layer_channel_io_and_nodes(layer, tree=None, specific_ch=None, do_ Decal.check_entity_decal_nodes(mask, tree) #check_mask_image_linear_node(mask) + # Triplanar wrappers need source trees, which are only checked on normal channel updates + check_layer_source_tree(layer) + check_mask_source_tree(layer) + Triplanar.check_entity_triplanar_nodes(layer, tree) + for mask in layer.masks: + Triplanar.check_entity_triplanar_nodes(mask, tree) + # Linear nodes check_yp_linear_nodes(yp, layer, False) @@ -744,7 +751,12 @@ def check_layer_tree_ios(layer, tree=None, remove_props=False, hard_reset=False) if layer.texcoord_type == 'Decal': dirty = create_prop_input(layer, 'decal_distance_value', valid_inputs, input_index, dirty, float_factor_input_names) input_index += 1 - + + elif layer.texcoord_type == 'Triplanar': + for prop_name in triplanar_input_props.values(): + dirty = create_prop_input(layer, prop_name, valid_inputs, input_index, dirty, float_factor_input_names) + input_index += 1 + if is_bl_newer_than(2, 81) and layer.enable_uniform_scale and is_layer_using_vector(layer) and layer.segment_name == '': dirty = create_prop_input(layer, 'uniform_scale_value', valid_inputs, input_index, dirty, float_factor_input_names) input_index += 1 @@ -894,6 +906,12 @@ def check_layer_tree_ios(layer, tree=None, remove_props=False, hard_reset=False) dirty = create_prop_input(mask, 'decal_distance_value', valid_inputs, input_index, dirty, float_factor_input_names) input_index += 1 + # Mask triplanar + elif mask.texcoord_type == 'Triplanar': + for prop_name in triplanar_input_props.values(): + dirty = create_prop_input(mask, prop_name, valid_inputs, input_index, dirty, float_factor_input_names) + input_index += 1 + # Color ID if mask.type == 'COLOR_ID': dirty = create_prop_input(mask, 'color_id', valid_inputs, input_index, dirty, float_factor_input_names) diff --git a/lib.py b/lib.py index b7df289e..7691a77c 100644 --- a/lib.py +++ b/lib.py @@ -151,6 +151,11 @@ DECAL_PROCESS = '~yPL Decal Process' +TRIPLANAR = '~yPL Triplanar' +TRIPLANAR_BLEND = '~yPL Triplanar Blend' +TRIPLANAR_BLEND_VALUE = '~yPL Triplanar Blend Value' +TRIPLANAR_REVISION_MARKER = '__yp_triplanar_rev_3' + SMOOTH_PREFIX = '~yPL Smooth ' # Nodes that require Blender 2.81 at minimum @@ -379,7 +384,7 @@ def get_neighbor_uv_tree_name(texcoord_type, entity): different_uv = check_uv_difference_to_main_uv(entity) if different_uv: return NEIGHBOR_UV_OTHER_UV return NEIGHBOR_UV_TANGENT - if texcoord_type in {'Generated', 'Normal', 'Object', 'Decal'}: + if texcoord_type in {'Generated', 'Normal', 'Object', 'Decal', 'Triplanar'}: return NEIGHBOR_UV_OBJECT if texcoord_type in {'Camera', 'Window', 'Reflection'}: return NEIGHBOR_UV_CAMERA @@ -506,6 +511,325 @@ def get_smooth_mix_node(blend_type, layer_type=''): return tree +def new_math_node(tree, operation, loc, value=None): + node = tree.nodes.new('ShaderNodeMath') + node.operation = operation + node.location = loc.copy() + if value != None: node.inputs[1].default_value = value + return node + +def get_triplanar_prep_tree(): + ''' Convert a vector to three planar projection vectors and their blend weights. + Weights use object space normal components normalized by their maximum, + so they stay stable at any blend sharpness. Color weights are normalized by the + sum of shown sides, while alpha weights are normalized by the sum of all sides, + so hiding a side fades the entity out on faces pointing that way. ''' + + tree = bpy.data.node_groups.get(TRIPLANAR) + if tree: + if tree.nodes.get(TRIPLANAR_REVISION_MARKER): return tree + + # Rebuild the tree since it comes from an older revision + tree.nodes.clear() + for inp in reversed(get_tree_inputs(tree)): remove_tree_input(tree, inp) + for outp in reversed(get_tree_outputs(tree)): remove_tree_output(tree, outp) + else: + tree = bpy.data.node_groups.new(TRIPLANAR, 'ShaderNodeTree') + + # IO + new_tree_input(tree, 'Vector', 'NodeSocketVector') + for socket_name in triplanar_input_props: + inp = new_tree_input(tree, socket_name, 'NodeSocketFloatFactor') + inp.min_value = 0.0 + inp.max_value = 1.0 + inp.default_value = triplanar_input_defaults.get(socket_name, 1.0) + + for axis in triplanar_axes: + new_tree_output(tree, 'Vector ' + axis, 'NodeSocketVector') + for axis in triplanar_axes: + new_tree_output(tree, 'Color Weight ' + axis, 'NodeSocketFloat') + for axis in triplanar_axes: + new_tree_output(tree, 'Alpha Weight ' + axis, 'NodeSocketFloat') + + create_essential_nodes(tree) + + start = tree.nodes.get(TREE_START) + end = tree.nodes.get(TREE_END) + + loc = Vector((0, 0)) + start.location = loc + + # Projection vectors + loc.x += 200 + sep_pos = tree.nodes.new('ShaderNodeSeparateXYZ') + sep_pos.location = loc + tree.links.new(start.outputs['Vector'], sep_pos.inputs[0]) + + loc.x += 200 + swizzles = {'X': ('Y', 'Z'), 'Y': ('X', 'Z'), 'Z': ('X', 'Y')} + for axis in triplanar_axes: + com = tree.nodes.new('ShaderNodeCombineXYZ') + com.location = loc + tree.links.new(sep_pos.outputs[swizzles[axis][0]], com.inputs[0]) + tree.links.new(sep_pos.outputs[swizzles[axis][1]], com.inputs[1]) + tree.links.new(com.outputs[0], end.inputs['Vector ' + axis]) + loc.y -= 150 + + # Object space normal + loc = Vector((0, -600)) + texcoord = tree.nodes.new('ShaderNodeTexCoord') + texcoord.location = loc + + loc.x += 200 + sep_nor = tree.nodes.new('ShaderNodeSeparateXYZ') + sep_nor.location = loc + tree.links.new(texcoord.outputs['Normal'], sep_nor.inputs[0]) + + # Blend factor to weight exponent + exp_max = new_math_node(tree, 'MAXIMUM', loc + Vector((0, -200)), 0.001) + tree.links.new(start.outputs['Blend'], exp_max.inputs[0]) + exponent = new_math_node(tree, 'POWER', loc + Vector((200, -200)), -2.0) + tree.links.new(exp_max.outputs[0], exponent.inputs[0]) + + loc.x += 200 + abses = {} + for i, axis in enumerate(triplanar_axes): + abs_node = new_math_node(tree, 'ABSOLUTE', loc + Vector((0, -i * 150))) + tree.links.new(sep_nor.outputs[axis], abs_node.inputs[0]) + abses[axis] = abs_node + + # Normalize absolute normal by its largest component + loc.x += 200 + max_xy = new_math_node(tree, 'MAXIMUM', loc) + tree.links.new(abses['X'].outputs[0], max_xy.inputs[0]) + tree.links.new(abses['Y'].outputs[0], max_xy.inputs[1]) + max_xyz = new_math_node(tree, 'MAXIMUM', loc + Vector((0, -150))) + tree.links.new(max_xy.outputs[0], max_xyz.inputs[0]) + tree.links.new(abses['Z'].outputs[0], max_xyz.inputs[1]) + max_safe = new_math_node(tree, 'MAXIMUM', loc + Vector((0, -300)), 0.00001) + tree.links.new(max_xyz.outputs[0], max_safe.inputs[0]) + + loc.x += 200 + weights = {} + for i, axis in enumerate(triplanar_axes): + div = new_math_node(tree, 'DIVIDE', loc + Vector((0, -i * 300))) + tree.links.new(abses[axis].outputs[0], div.inputs[0]) + tree.links.new(max_safe.outputs[0], div.inputs[1]) + + power = new_math_node(tree, 'POWER', loc + Vector((200, -i * 300))) + tree.links.new(div.outputs[0], power.inputs[0]) + tree.links.new(exponent.outputs[0], power.inputs[1]) + weights[axis] = power + + # Select show factor by normal direction so each side of the axis can be hidden separately + loc.x += 400 + shown_weights = {} + for i, axis in enumerate(triplanar_axes): + is_positive = new_math_node(tree, 'GREATER_THAN', loc + Vector((0, -i * 300)), 0.0) + tree.links.new(sep_nor.outputs[axis], is_positive.inputs[0]) + + side_diff = new_math_node(tree, 'SUBTRACT', loc + Vector((0, -i * 300 - 150))) + tree.links.new(start.outputs['Show +' + axis], side_diff.inputs[0]) + tree.links.new(start.outputs['Show -' + axis], side_diff.inputs[1]) + + side_select = new_math_node(tree, 'MULTIPLY', loc + Vector((200, -i * 300))) + tree.links.new(is_positive.outputs[0], side_select.inputs[0]) + tree.links.new(side_diff.outputs[0], side_select.inputs[1]) + + show = new_math_node(tree, 'ADD', loc + Vector((200, -i * 300 - 150))) + tree.links.new(start.outputs['Show -' + axis], show.inputs[0]) + tree.links.new(side_select.outputs[0], show.inputs[1]) + + mul = new_math_node(tree, 'MULTIPLY', loc + Vector((400, -i * 300))) + tree.links.new(weights[axis].outputs[0], mul.inputs[0]) + tree.links.new(show.outputs[0], mul.inputs[1]) + shown_weights[axis] = mul + + loc.x += 400 + + # Sum of all weights and sum of shown weights + loc.x += 200 + full_sum = new_math_node(tree, 'ADD', loc) + tree.links.new(weights['X'].outputs[0], full_sum.inputs[0]) + tree.links.new(weights['Y'].outputs[0], full_sum.inputs[1]) + full_sum_1 = new_math_node(tree, 'ADD', loc + Vector((0, -150))) + tree.links.new(full_sum.outputs[0], full_sum_1.inputs[0]) + tree.links.new(weights['Z'].outputs[0], full_sum_1.inputs[1]) + full_sum_safe = new_math_node(tree, 'MAXIMUM', loc + Vector((0, -300)), 0.00001) + tree.links.new(full_sum_1.outputs[0], full_sum_safe.inputs[0]) + + shown_sum = new_math_node(tree, 'ADD', loc + Vector((0, -450))) + tree.links.new(shown_weights['X'].outputs[0], shown_sum.inputs[0]) + tree.links.new(shown_weights['Y'].outputs[0], shown_sum.inputs[1]) + shown_sum_1 = new_math_node(tree, 'ADD', loc + Vector((0, -600))) + tree.links.new(shown_sum.outputs[0], shown_sum_1.inputs[0]) + tree.links.new(shown_weights['Z'].outputs[0], shown_sum_1.inputs[1]) + + # Coverage of shown sides, expandable to counteract fading when some sides are hidden + loc.x += 200 + coverage = new_math_node(tree, 'DIVIDE', loc + Vector((0, -900))) + tree.links.new(shown_sum_1.outputs[0], coverage.inputs[0]) + tree.links.new(full_sum_safe.outputs[0], coverage.inputs[1]) + + expand_inv = new_math_node(tree, 'SUBTRACT', loc + Vector((0, -1050))) + expand_inv.inputs[0].default_value = 1.0 + tree.links.new(start.outputs['Expand'], expand_inv.inputs[1]) + expand_inv_safe = new_math_node(tree, 'MAXIMUM', loc + Vector((200, -1050)), 0.001) + tree.links.new(expand_inv.outputs[0], expand_inv_safe.inputs[0]) + + expanded_coverage = new_math_node(tree, 'DIVIDE', loc + Vector((400, -900))) + tree.links.new(coverage.outputs[0], expanded_coverage.inputs[0]) + tree.links.new(expand_inv_safe.outputs[0], expanded_coverage.inputs[1]) + expanded_coverage_clamped = new_math_node(tree, 'MINIMUM', loc + Vector((600, -900)), 1.0) + tree.links.new(expanded_coverage.outputs[0], expanded_coverage_clamped.inputs[0]) + + # Color always blends all projections to avoid stretching, + # hiding sides only fades the entity out through the alpha weights + for i, axis in enumerate(triplanar_axes): + color_weight = new_math_node(tree, 'DIVIDE', loc + Vector((0, -i * 300))) + tree.links.new(weights[axis].outputs[0], color_weight.inputs[0]) + tree.links.new(full_sum_safe.outputs[0], color_weight.inputs[1]) + tree.links.new(color_weight.outputs[0], end.inputs['Color Weight ' + axis]) + + alpha_weight = new_math_node(tree, 'MULTIPLY', loc + Vector((800, -i * 300))) + tree.links.new(color_weight.outputs[0], alpha_weight.inputs[0]) + tree.links.new(expanded_coverage_clamped.outputs[0], alpha_weight.inputs[1]) + tree.links.new(alpha_weight.outputs[0], end.inputs['Alpha Weight ' + axis]) + + loc.x += 1000 + end.location = loc + + marker = tree.nodes.new('NodeFrame') + marker.name = TRIPLANAR_REVISION_MARKER + marker.label = '' + + return tree + +def get_triplanar_blend_tree(): + ''' Blend three triplanar samples of a color and alpha pair ''' + + tree = bpy.data.node_groups.get(TRIPLANAR_BLEND) + if tree: return tree + + tree = bpy.data.node_groups.new(TRIPLANAR_BLEND, 'ShaderNodeTree') + + # IO + for axis in triplanar_axes: + new_tree_input(tree, 'Color ' + axis, 'NodeSocketColor') + for axis in triplanar_axes: + new_tree_input(tree, 'Alpha ' + axis, 'NodeSocketFloat') + for axis in triplanar_axes: + new_tree_input(tree, 'Color Weight ' + axis, 'NodeSocketFloat') + for axis in triplanar_axes: + new_tree_input(tree, 'Alpha Weight ' + axis, 'NodeSocketFloat') + + new_tree_output(tree, 'Color', 'NodeSocketColor') + new_tree_output(tree, 'Alpha', 'NodeSocketFloat') + + create_essential_nodes(tree) + + start = tree.nodes.get(TREE_START) + end = tree.nodes.get(TREE_END) + + loc = Vector((0, 0)) + start.location = loc + + loc.x += 200 + color_muls = {} + for i, axis in enumerate(triplanar_axes): + mul = simple_new_mix_node(tree) + mixcol0, mixcol1, mixout = get_mix_color_indices(mul) + mul.blend_type = 'MULTIPLY' + mul.inputs[0].default_value = 1.0 + mul.location = loc + Vector((0, -i * 200)) + tree.links.new(start.outputs['Color ' + axis], mul.inputs[mixcol0]) + tree.links.new(start.outputs['Color Weight ' + axis], mul.inputs[mixcol1]) + color_muls[axis] = (mul, mixout) + + loc.x += 200 + add_xy = simple_new_mix_node(tree) + mixcol0, mixcol1, add_xy_out = get_mix_color_indices(add_xy) + add_xy.blend_type = 'ADD' + add_xy.inputs[0].default_value = 1.0 + add_xy.location = loc + tree.links.new(color_muls['X'][0].outputs[color_muls['X'][1]], add_xy.inputs[mixcol0]) + tree.links.new(color_muls['Y'][0].outputs[color_muls['Y'][1]], add_xy.inputs[mixcol1]) + + loc.x += 200 + add_xyz = simple_new_mix_node(tree) + mixcol0, mixcol1, mixout = get_mix_color_indices(add_xyz) + add_xyz.blend_type = 'ADD' + add_xyz.inputs[0].default_value = 1.0 + add_xyz.location = loc + tree.links.new(add_xy.outputs[add_xy_out], add_xyz.inputs[mixcol0]) + tree.links.new(color_muls['Z'][0].outputs[color_muls['Z'][1]], add_xyz.inputs[mixcol1]) + tree.links.new(add_xyz.outputs[mixout], end.inputs['Color']) + + # Alpha + loc = Vector((200, -700)) + alpha_muls = {} + for i, axis in enumerate(triplanar_axes): + mul = new_math_node(tree, 'MULTIPLY', loc + Vector((0, -i * 150))) + tree.links.new(start.outputs['Alpha ' + axis], mul.inputs[0]) + tree.links.new(start.outputs['Alpha Weight ' + axis], mul.inputs[1]) + alpha_muls[axis] = mul + + alpha_add_xy = new_math_node(tree, 'ADD', loc + Vector((200, 0))) + tree.links.new(alpha_muls['X'].outputs[0], alpha_add_xy.inputs[0]) + tree.links.new(alpha_muls['Y'].outputs[0], alpha_add_xy.inputs[1]) + alpha_add_xyz = new_math_node(tree, 'ADD', loc + Vector((400, 0))) + tree.links.new(alpha_add_xy.outputs[0], alpha_add_xyz.inputs[0]) + tree.links.new(alpha_muls['Z'].outputs[0], alpha_add_xyz.inputs[1]) + tree.links.new(alpha_add_xyz.outputs[0], end.inputs['Alpha']) + + end.location = Vector((800, 0)) + + return tree + +def get_triplanar_blend_value_tree(): + ''' Blend three triplanar samples of a single value ''' + + tree = bpy.data.node_groups.get(TRIPLANAR_BLEND_VALUE) + if tree: return tree + + tree = bpy.data.node_groups.new(TRIPLANAR_BLEND_VALUE, 'ShaderNodeTree') + + # IO + for axis in triplanar_axes: + new_tree_input(tree, 'Value ' + axis, 'NodeSocketFloat') + for axis in triplanar_axes: + new_tree_input(tree, 'Weight ' + axis, 'NodeSocketFloat') + + new_tree_output(tree, 'Value', 'NodeSocketFloat') + + create_essential_nodes(tree) + + start = tree.nodes.get(TREE_START) + end = tree.nodes.get(TREE_END) + + loc = Vector((0, 0)) + start.location = loc + + loc.x += 200 + muls = {} + for i, axis in enumerate(triplanar_axes): + mul = new_math_node(tree, 'MULTIPLY', loc + Vector((0, -i * 150))) + tree.links.new(start.outputs['Value ' + axis], mul.inputs[0]) + tree.links.new(start.outputs['Weight ' + axis], mul.inputs[1]) + muls[axis] = mul + + add_xy = new_math_node(tree, 'ADD', loc + Vector((200, 0))) + tree.links.new(muls['X'].outputs[0], add_xy.inputs[0]) + tree.links.new(muls['Y'].outputs[0], add_xy.inputs[1]) + add_xyz = new_math_node(tree, 'ADD', loc + Vector((400, 0))) + tree.links.new(add_xy.outputs[0], add_xyz.inputs[0]) + tree.links.new(muls['Z'].outputs[0], add_xyz.inputs[1]) + tree.links.new(add_xyz.outputs[0], end.inputs['Value']) + + end.location = loc + Vector((600, 0)) + + return tree + def clean_unused_libraries(): for ng in bpy.data.node_groups: if ng.name.startswith('~yPL ') and ng.users == 0: diff --git a/node_connections.py b/node_connections.py index 3060040f..8cfb8662 100644 --- a/node_connections.py +++ b/node_connections.py @@ -1274,11 +1274,13 @@ def reconnect_yp_nodes(tree, merged_layer_ids = []): for tc in texcoords: inp = node.inputs.get(io_names[tc]) - if inp: + # Triplanar uses object texture coordinate + outp_name = 'Object' if tc == 'Triplanar' else tc + if inp: if parallax_ch and parallax: - create_link(tree, parallax.outputs[TEXCOORD_IO_PREFIX + tc], inp) - else: - create_link(tree, get_essential_node(tree, TEXCOORD)[tc], inp) + create_link(tree, parallax.outputs[TEXCOORD_IO_PREFIX + outp_name], inp) + else: + create_link(tree, get_essential_node(tree, TEXCOORD)[outp_name], inp) # Background layer if layer.type == 'BACKGROUND': @@ -1712,7 +1714,8 @@ def reconnect_mask_source_nodes(mask, layer_tree): # Mask inside a node group has start and end node group_node = layer_tree.nodes.get(mask.group_node) if group_node: - tree = group_node.node_tree + # Mask tree can be inside a triplanar wrapper + tree = get_mask_tree(mask, layer_tree) start = tree.nodes.get(TREE_START) end = tree.nodes.get(TREE_END) else: @@ -1772,6 +1775,16 @@ def reconnect_mask_source_nodes(mask, layer_tree): return source, val +def reconnect_triplanar_input_links(tree, entity, targets): + ''' Connect triplanar prop inputs to all triplanar wrapper instances ''' + + for socket_name, prop_name in triplanar_input_props.items(): + inp = get_essential_node(tree, TREE_START).get(get_entity_input_name(entity, prop_name)) + if not inp: continue + for target in targets: + if target and socket_name in target.inputs: + create_link(tree, inp, target.inputs[socket_name]) + def reconnect_layer_nodes(layer, ch_idx=-1, merge_mask=False): yp = layer.id_data.yp @@ -1907,6 +1920,9 @@ def reconnect_layer_nodes(layer, ch_idx=-1, merge_mask=False): if 'Vector' in source.inputs: create_link(tree, vector, source.inputs['Vector']) + if layer.texcoord_type == 'Triplanar': + reconnect_triplanar_input_links(tree, layer, [source, source_n, source_s, source_e, source_w]) + if layer.use_baked or layer.type not in {'VCOL', 'BACKGROUND', 'COLOR', 'GROUP', 'HEMI', 'OBJECT_INDEX', 'EDGE_DETECT', 'AO'}: if uv_neighbor: @@ -2191,6 +2207,10 @@ def reconnect_layer_nodes(layer, ch_idx=-1, merge_mask=False): create_link(tree, mask_vector, mask_source.inputs[0]) + if mask.texcoord_type == 'Triplanar': + triplanar_targets = [mask_source] + [nodes.get(getattr(mask, 'source_' + d)) for d in nsew_letters] + reconnect_triplanar_input_links(tree, mask, triplanar_targets) + # Mask UV uniform scale value if is_bl_newer_than(2, 81): uniform_scale_value = get_essential_node(tree, TREE_START).get(get_entity_input_name(mask, 'uniform_scale_value')) diff --git a/subtree.py b/subtree.py index 16fa6037..88a7e49d 100644 --- a/subtree.py +++ b/subtree.py @@ -1,5 +1,5 @@ import bpy, re, bmesh -from . import lib, Modifier, MaskModifier +from . import lib, Modifier, MaskModifier, Triplanar from .common import * from .node_arrangements import * from .node_connections import * @@ -146,6 +146,8 @@ def disable_layer_source_tree(layer, layer_tree=None, source_group=None): if not layer_tree: layer_tree = get_tree(layer) if not source_group: source_group = layer_tree.nodes.get(layer.source_group) + Triplanar.unwrap_triplanar_group_node(source_group) + if source_group: source_ref = source_group.node_tree.nodes.get(layer.source) baked_source_ref = source_group.node_tree.nodes.get(layer.baked_source) @@ -187,12 +189,16 @@ def disable_layer_source_tree(layer, layer_tree=None, source_group=None): remove_node(layer_tree, layer, 'source_e') remove_node(layer_tree, layer, 'source_w') -def check_layer_source_tree(layer, smooth_bump_enabled): +def check_layer_source_tree(layer, smooth_bump_enabled=None): + + if smooth_bump_enabled == None: + smooth_bump_ch = get_smooth_bump_channel(layer) + smooth_bump_enabled = smooth_bump_ch != None and get_channel_enabled(smooth_bump_ch) and is_height_process_needed(layer) layer_tree = get_tree(layer) source_group = layer_tree.nodes.get(layer.source_group) - if (smooth_bump_enabled and + if ((smooth_bump_enabled or is_entity_using_triplanar(layer)) and (layer.use_baked or layer.type not in {'VCOL', 'BACKGROUND', 'COLOR', 'GROUP', 'HEMI', 'OBJECT_INDEX', 'BACKFACE', 'EDGE_DETECT', 'MODIFIER'}) ): # Enable source group @@ -257,7 +263,7 @@ def check_layer_source_tree(layer, smooth_bump_enabled): else: move_mod_groups(layer, layer_tree, source_tree) else: - source_tree = source_group.node_tree + source_tree = get_source_tree(layer, layer_tree) source = source_tree.nodes.get(layer.source) baked_source = source_tree.nodes.get(layer.baked_source) @@ -483,6 +489,7 @@ def disable_mask_source_tree(layer, mask): if mask.group_node != '': layer_tree = get_tree(layer) + Triplanar.unwrap_triplanar_group_node(layer_tree.nodes.get(mask.group_node)) mask_tree = get_mask_tree(mask) source_ref = mask_tree.nodes.get(mask.source) @@ -747,10 +754,11 @@ def check_mask_source_tree(layer, specific_mask=None): #, ch=None): for i, mask in enumerate(layer.masks): if specific_mask and specific_mask != mask: continue - if smooth_bump_ch and get_channel_enabled(smooth_bump_ch, layer, yp.channels[ch_idx]) and get_mask_enabled(mask) and ( + if (get_mask_enabled(mask) and is_entity_using_triplanar(mask)) or ( + smooth_bump_ch and get_channel_enabled(smooth_bump_ch, layer, yp.channels[ch_idx]) and get_mask_enabled(mask) and ( mask.channels[ch_idx].enable and height_process_needed and (write_height_ch or i < chain) and (mask.use_baked or mask.type not in {'VCOL', 'HEMI', 'OBJECT_INDEX', 'COLOR_ID', 'BACKFACE', 'EDGE_DETECT', 'MODIFIER'}) - ): + )): enable_mask_source_tree(layer, mask) else: disable_mask_source_tree(layer, mask) @@ -1578,16 +1586,18 @@ def check_layer_projection_blends(layer): if hasattr(source, 'projection_blend'): source.projection_blend = layer.projection_blend + override_blend = layer.triplanar_blend if layer.texcoord_type == 'Triplanar' else layer.projection_blend + for ch in layer.channels: if ch.override and ch.override_type == 'IMAGE': source = get_channel_source(ch, layer) if hasattr(source, 'projection_blend'): - source.projection_blend = layer.projection_blend + source.projection_blend = override_blend if ch.override_1 and ch.override_1_type == 'IMAGE': source = get_channel_source_1(ch, layer) if hasattr(source, 'projection_blend'): - source.projection_blend = layer.projection_blend + source.projection_blend = override_blend def check_layer_projections(layer): # Set image source projection @@ -1596,14 +1606,15 @@ def check_layer_projections(layer): source.projection = 'BOX' if layer.texcoord_type in {'Generated', 'Object'} else 'FLAT' # Set channel override images + # NOTE: Triplanar uses native box projection for override images since they aren't inside the entity source tree for ch in layer.channels: if ch.override and ch.override_type == 'IMAGE': source = get_channel_source(ch, layer) - source.projection = 'BOX' if layer.texcoord_type in {'Generated', 'Object'} else 'FLAT' + source.projection = 'BOX' if layer.texcoord_type in {'Generated', 'Object', 'Triplanar'} else 'FLAT' if ch.override_1 and ch.override_1_type == 'IMAGE': source = get_channel_source_1(ch, layer) - source.projection = 'BOX' if layer.texcoord_type in {'Generated', 'Object'} else 'FLAT' + source.projection = 'BOX' if layer.texcoord_type in {'Generated', 'Object', 'Triplanar'} else 'FLAT' # Check projection blends check_layer_projection_blends(layer) diff --git a/ui.py b/ui.py index 11d3056e..ce9c9960 100644 --- a/ui.py +++ b/ui.py @@ -1771,6 +1771,11 @@ def draw_layer_vector(context, layout, layer, layer_tree, source, image, vcol, i split = split_layout(rrow, 0.4, align=True) split.prop(layer, 'texcoord_type', text='') split.prop(texcoord, 'object', text='') + elif layer.texcoord_type == 'Triplanar' and not lui.expand_vector: + rrow.scale_x = 0.5 + split = split_layout(rrow, 0.5, align=True) + split.prop(layer, 'texcoord_type', text='') + draw_input_prop(split, layer, 'triplanar_blend', layer=layer) else: rrow.prop(layer, 'texcoord_type', text='') @@ -1853,7 +1858,29 @@ def draw_layer_vector(context, layout, layer, layer_tree, source, image, vcol, i rrow = boxcol.row(align=True) rrow.label(text='', icon='BLANK1') rrow.operator('wm.y_set_decal_object_position_to_sursor', text='Set Position to Cursor', icon='CURSOR') - + + if layer.texcoord_type == 'Triplanar': + rrow = boxcol.row(align=True) + rrow.label(text='', icon='BLANK1') + splits = split_layout(rrow, 0.5, align=True) + splits.label(text='Projection Blend:') + draw_input_prop(splits, layer, 'triplanar_blend', layer=layer) + + rrow = boxcol.row(align=True) + rrow.label(text='', icon='BLANK1') + splits = split_layout(rrow, 0.5, align=True) + splits.label(text='Expand:') + draw_input_prop(splits, layer, 'triplanar_expand', layer=layer) + + for axis in triplanar_axes: + rrow = boxcol.row(align=True) + rrow.label(text='', icon='BLANK1') + splits = split_layout(rrow, 0.5, align=True) + splits.label(text='Show Sides:' if axis == 'X' else '') + rrrow = splits.row(align=True) + draw_input_prop(rrrow, layer, triplanar_side_props['+' + axis], None, '+' + axis, layer=layer) + draw_input_prop(rrrow, layer, triplanar_side_props['-' + axis], None, '-' + axis, layer=layer) + if layer.texcoord_type != 'Decal' and not is_using_image_atlas: mapping = get_layer_mapping(layer) @@ -3161,6 +3188,10 @@ def draw_layer_masks(context, layout, layer, specific_mask=None): if texcoord: ssplit.prop(mask, 'texcoord_type', text='') ssplit.prop(texcoord, 'object', text='') + elif mask.texcoord_type == 'Triplanar' and not maskui.expand_vector: + rrrow = split_layout(rrow, 0.5, align=True) + rrrow.prop(mask, 'texcoord_type', text='') + draw_input_prop(rrrow, mask, 'triplanar_blend', layer=layer) else: rrow.prop(mask, 'texcoord_type', text='') @@ -3222,6 +3253,22 @@ def draw_layer_masks(context, layout, layer, specific_mask=None): else: boxcol.operator('wm.y_select_decal_object', icon='EMPTY_DATA') boxcol.operator('wm.y_set_decal_object_position_to_sursor', text='Set Position to Cursor', icon='CURSOR') + if mask.texcoord_type == 'Triplanar': + splits = split_layout(boxcol, 0.5, align=True) + splits.label(text='Projection Blend:') + draw_input_prop(splits, mask, 'triplanar_blend', layer=layer) + + splits = split_layout(boxcol, 0.5, align=True) + splits.label(text='Expand:') + draw_input_prop(splits, mask, 'triplanar_expand', layer=layer) + + for axis in triplanar_axes: + splits = split_layout(boxcol, 0.5, align=True) + splits.label(text='Show Sides:' if axis == 'X' else '') + rrrow = splits.row(align=True) + draw_input_prop(rrrow, mask, triplanar_side_props['+' + axis], None, '+' + axis, layer=layer) + draw_input_prop(rrrow, mask, triplanar_side_props['-' + axis], None, '-' + axis, layer=layer) + if mask.texcoord_type != 'Decal' and not is_using_image_atlas: mapping = get_mask_mapping(mask)