diff --git a/README.md b/README.md index 1d525d0..aeed6a7 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,31 @@ # Texture VFX Control: Blender Add-on -WIP \ No newline at end of file +This Blender add-on provides with shader-based effects on image/sequence/movie textures. It enables users to perform video composition directly in the 3D space without using the compositor or the sequencer. + +The functionalities of this add-on include: + +- Texture Playback Control + - Setting the speed and loop mode of sequence/movie textures +- VFX Shaders + - Blur + - Chroma Key + - Outline + + +## Requirements + +Blender 3.3+ or Blender 4.0 + +## Installation + +WIP + +## Usage + +WIP + +### Playback Control Driver + +### VFX Shader Node Groups + +## Credits \ No newline at end of file diff --git a/__init__.py b/__init__.py index 96b923b..e0d9e7f 100644 --- a/__init__.py +++ b/__init__.py @@ -5,7 +5,7 @@ "description" : "Shader-based video playback control and composition VFXs", "blender" : (3, 3, 0), "version" : (0, 1, 0), - "location" : "Node Editor", + "location" : "View3D > Object > Quick Effects, or Shader Editor > Add", "warning" : "This addon is still in an early stage of development", "doc_url": "", "wiki_url": "", diff --git a/operators/__init__.py b/operators/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/op_driver.py b/operators/op_playback.py similarity index 92% rename from op_driver.py rename to operators/op_playback.py index 9261987..539de0c 100644 --- a/op_driver.py +++ b/operators/op_playback.py @@ -1,12 +1,6 @@ import bpy -from .utils import get_target_node - -def add_driver_variable(driver, id, data_path, name): - var = driver.variables.new() - var.name = name - var.type = 'SINGLE_PROP' - var.targets[0].id = id - var.targets[0].data_path = f'["{data_path}"]' +from ..utils.driver import add_driver_variable +from ..utils.node import get_target_node class AddTexturePlaybackDriverOperator(bpy.types.Operator): """Map the playback of a movie/sequence texture to a custom float property by adding a driver to the offset value""" @@ -114,13 +108,8 @@ def add_driver_to_node(node): add_driver_variable(playback_driver.driver, obj, datapath_start, 's') add_driver_variable(playback_driver.driver, obj, datapath_playhead, 'p') add_driver_variable(playback_driver.driver, obj, datapath_duration, 'd') - - scene_var = playback_driver.driver.variables.new() - scene_var.name = 't' - scene_var.type = 'SINGLE_PROP' - scene_var.targets[0].id_type = 'SCENE' - scene_var.targets[0].id = bpy.context.scene - scene_var.targets[0].data_path = 'frame_current' + add_driver_variable(playback_driver.driver, bpy.context.scene, 'frame_current', 't', + id_type='SCENE', custom_property=False) playback_driver.driver.expression = 'min(max(floor(p*d)+s-t, s-t), s+d-t-1)' diff --git a/op_shader_nodes.py b/operators/op_shader_nodes.py similarity index 78% rename from op_shader_nodes.py rename to operators/op_shader_nodes.py index d80db86..e5f237d 100644 --- a/op_shader_nodes.py +++ b/operators/op_shader_nodes.py @@ -1,16 +1,8 @@ import bpy -from .utils import append_node_group, get_target_node, copy_driver - -def add_node_group(node_tree, name, location=(0,0)) -> bpy.types.ShaderNodeGroup: - node = node_tree.nodes.new('ShaderNodeGroup') - node.node_tree = append_node_group(name) - node.location = location - return node - -def add_uv_input(node_tree, location=(0,0)) -> bpy.types.ShaderNodeUVMap: - node = node_tree.nodes.new('ShaderNodeUVMap') - node.location = location - return node +import math +from ..utils.asset import append_node_group +from ..utils.node import * +from ..utils.driver import copy_driver, add_driver_variable def get_fx_chain_info(tex_node, fx_types=['tfx_ChromaKey']): """ @@ -283,10 +275,10 @@ class OutlineOperator(bpy.types.Operator): default = (1.0,1.0,.0,1.0), min = 0.0, max = 1.0, size = 4, ) - outline_size: bpy.props.FloatVectorProperty( + outline_size: bpy.props.FloatProperty( name = "Size", - default = (0.5, 0.5), - min = 0, soft_max = 5, size = 2, step = 1 + default = 0.5, + min = 0, soft_max = 5, step = 1 ) outline_outer: bpy.props.BoolProperty( name = "Outer Contour", @@ -300,15 +292,45 @@ class OutlineOperator(bpy.types.Operator): name='Blur', default=0, min=0, max=0.1, step=1 ) - + directional: bpy.props.BoolProperty( + name='Directional', + default = False + ) + direction: bpy.props.EnumProperty( + name='Direction', + items=[('FIXED', 'Fixed Angle', ''), + ('REF', 'Following a Light', '')], + default='FIXED' + ) + angle: bpy.props.FloatProperty( + name='Angle', + default=0.25*math.pi, min=-2*math.pi, max=2*math.pi, + unit='ROTATION', + ) + light_name: bpy.props.StringProperty( + name='Source', + default='', + search=lambda self, context, edit_text: [obj.name for obj in context.scene.objects] + ) + def draw(self, context): layout = self.layout layout.prop(self, 'outline_color') layout.prop(self, 'outline_size') - layout.prop(self, 'outline_outer') - layout.prop(self, 'outline_inner') + row = layout.row() + row.prop(self, 'outline_outer') + row.prop(self, 'outline_inner') layout.prop(self, 'blur_strength') - + layout.prop(self, 'directional') + if self.directional: + layout.prop(self, 'direction') + if self.direction == 'FIXED': + layout.prop(self, 'angle') + else: + layout.label(text="Light Information:") + box = layout.box() + box.prop(self, 'light_name') + def invoke(self, context, event): return context.window_manager.invoke_props_dialog(self) @@ -323,7 +345,9 @@ def execute(self, context): if not tex_node: self.report({"WARNING"}, "Cannot find any eligible texture node to perform the operation.") return {'FINISHED'} - + if tex_node.extension == 'CLIP': + tex_node.extension = 'EXTEND' + # Duplicate nodes for edge detection node_chain, inside_links, output_color_links, output_alpha_links, \ output_color_socket, output_alpha_socket = get_fx_chain_info(tex_node) @@ -359,18 +383,63 @@ def execute(self, context): post_node = add_node_group(target_node_tree, 'tfx_OutlinePost', location=(node_chain[-1].location[0] + 200, node_chain[-1].location[1])) + post_node.inputs['Color'].default_value = self.outline_color + post_node.inputs['Inner'].default_value = float(self.outline_inner) + post_node.inputs['Outer'].default_value = float(self.outline_outer) + + # Set offset values considering different factors + ratio = tex_node.image.size[0] / tex_node.image.size[1] + converted_outline_size = [self.outline_size / 100.0, self.outline_size * ratio / 100.0] if ratio < 1 \ + else [self.outline_size / ratio / 100.0, self.outline_size / 100.0] + if self.directional and self.direction == 'FIXED': + pre_node.inputs['U_Offset'].default_value = converted_outline_size[0] * math.cos(self.angle) + pre_node.inputs['V_Offset'].default_value = converted_outline_size[1] * math.sin(self.angle) + else: + pre_node.inputs['U_Offset'].default_value = converted_outline_size[0] + pre_node.inputs['V_Offset'].default_value = converted_outline_size[1] + + # If use a light source, add extra node groups and drivers + if self.directional and self.direction == 'REF': + light_obj = context.scene.objects[self.light_name] if self.light_name in context.scene.objects else None + if light_obj: + light_node = add_node_group(target_node_tree, 'tfx_LightVector', + location=(pre_node.location[0] - 200, + pre_node.location[1] - 150)) + # Set light direction: currently only supports point light + target_node_tree.links.new(light_node.outputs['X'], pre_node.inputs['U_Offset']) + target_node_tree.links.new(light_node.outputs['Y'], pre_node.inputs['V_Offset']) + light_loc_drivers = light_node.inputs['Light'].driver_add('default_value') + for i,dr in enumerate(light_loc_drivers): + dr.driver.type = 'SCRIPTED' + add_driver_variable(dr.driver, light_obj, f'location[{i}]', 'var', custom_property=False) + dr.driver.expression = 'var' + # Set light strength + light_node.inputs['Scale'].default_value[0] = converted_outline_size[0] + light_node.inputs['Scale'].default_value[1] = converted_outline_size[1] + + # Set light color: disabled for now + if False and light_obj.type == 'LIGHT': + light_color_drivers = post_node.inputs['Color'].driver_add('default_value') + for i,dr in enumerate(light_color_drivers): + dr.driver.type = 'SCRIPTED' + if i < 3: + add_driver_variable(dr.driver, light_obj.data, f'color[{i}]', 'var', + id_type='LIGHT', custom_property=False) + dr.driver.expression = 'var' + else: + dr.driver.expression = '1.0' + + # Connect to the FX nodes target_node_tree.links.new(uv_socket, pre_node.inputs['UV']) for tag in ['U+', 'U-', 'V+', 'V-']: target_node_tree.links.new(pre_node.outputs[tag], offset_uv_inputs[tag]) target_node_tree.links.new(offset_alpha_outputs[tag], post_node.inputs[tag]) target_node_tree.links.new(output_color_socket, post_node.inputs['Image']) target_node_tree.links.new(output_alpha_socket, post_node.inputs['Alpha']) - pre_node.inputs['U_Offset'].default_value = self.outline_size[0] / 100.0 - pre_node.inputs['V_Offset'].default_value = self.outline_size[1] / 100.0 - post_node.inputs['Color'].default_value = self.outline_color - post_node.inputs['Inner'].default_value = float(self.outline_inner) - post_node.inputs['Outer'].default_value = float(self.outline_outer) - + if self.directional: + target_node_tree.links.new(output_alpha_socket, post_node.inputs['U-']) + target_node_tree.links.new(output_alpha_socket, post_node.inputs['V-']) + # Reconnect output sockets for link in output_color_links: target_node_tree.links.new(post_node.outputs['Image'], link.to_socket) diff --git a/res/assets.blend b/res/assets.blend index 44d8a49..cde68e2 100644 Binary files a/res/assets.blend and b/res/assets.blend differ diff --git a/utils.py b/utils.py deleted file mode 100644 index b857a5b..0000000 --- a/utils.py +++ /dev/null @@ -1,58 +0,0 @@ -import bpy -import os - -def get_target_node(node_tree, - filter = lambda node: (node.type == 'TEX_IMAGE' and node.image) - ): - """ - Find the shader node to perform operations. - First, check the active node. If it is not qualified, search through all nodes to find the first eligible one - """ - nodes = node_tree.nodes - if nodes.active and filter(nodes.active): - return nodes.active, node_tree - # Search recursively for node groups - elif nodes.active and nodes.active.type=='GROUP' and nodes.active.node_tree: - return get_target_node(nodes.active.node_tree, filter) - else: - for node in nodes: - if filter(node): - return node, node_tree - return None, None - -def copy_driver(src, dst): - """ - Copy all attributes of the source driver to an empty destination driver - """ - dst.type = src.type - for src_var in src.variables: - dst_var = dst.variables.new() - dst_var.name = src_var.name - dst_var.type = src_var.type - dst_var.targets[0].id_type = src_var.targets[0].id_type - dst_var.targets[0].id = src_var.targets[0].id - dst_var.targets[0].data_path = src_var.targets[0].data_path - dst.expression = src.expression - -def get_library_blend_file(): - file_name = 'res/assets.blend' - script_file = os.path.realpath(__file__) - directory = os.path.dirname(script_file) - file_path = os.path.join(directory, file_name) - return file_path - -def append_node_group(node_group_name): - if node_group_name in bpy.data.node_groups: - return bpy.data.node_groups[node_group_name] - - mode = bpy.context.mode - bpy.ops.object.mode_set(mode='OBJECT') - file_path = get_library_blend_file() - inner_path = 'NodeTree' - bpy.ops.wm.append( - filepath=os.path.join(file_path, inner_path, node_group_name), - directory=os.path.join(file_path, inner_path), - filename=node_group_name - ) - bpy.ops.object.mode_set(mode=mode) - return bpy.data.node_groups[node_group_name] \ No newline at end of file diff --git a/utils/asset.py b/utils/asset.py new file mode 100644 index 0000000..87b7fa2 --- /dev/null +++ b/utils/asset.py @@ -0,0 +1,25 @@ +import bpy +import os + +def get_library_blend_file(): + file_name = '../res/assets.blend' + script_file = os.path.realpath(__file__) + directory = os.path.dirname(script_file) + file_path = os.path.join(directory, file_name) + return file_path + +def append_node_group(node_group_name): + if node_group_name in bpy.data.node_groups: + return bpy.data.node_groups[node_group_name] + + mode = bpy.context.mode + bpy.ops.object.mode_set(mode='OBJECT') + file_path = get_library_blend_file() + inner_path = 'NodeTree' + bpy.ops.wm.append( + filepath=os.path.join(file_path, inner_path, node_group_name), + directory=os.path.join(file_path, inner_path), + filename=node_group_name + ) + bpy.ops.object.mode_set(mode=mode) + return bpy.data.node_groups[node_group_name] \ No newline at end of file diff --git a/utils/driver.py b/utils/driver.py new file mode 100644 index 0000000..d83062a --- /dev/null +++ b/utils/driver.py @@ -0,0 +1,23 @@ +import bpy + +def add_driver_variable(driver, id, data_path, name, id_type='OBJECT', custom_property = True): + var = driver.variables.new() + var.name = name + var.type = 'SINGLE_PROP' + var.targets[0].id_type = id_type + var.targets[0].id = id + var.targets[0].data_path = f'["{data_path}"]' if custom_property else data_path + +def copy_driver(src, dst): + """ + Copy all attributes of the source driver to an empty destination driver + """ + dst.type = src.type + for src_var in src.variables: + dst_var = dst.variables.new() + dst_var.name = src_var.name + dst_var.type = src_var.type + dst_var.targets[0].id_type = src_var.targets[0].id_type + dst_var.targets[0].id = src_var.targets[0].id + dst_var.targets[0].data_path = src_var.targets[0].data_path + dst.expression = src.expression \ No newline at end of file diff --git a/utils/node.py b/utils/node.py new file mode 100644 index 0000000..a2356fd --- /dev/null +++ b/utils/node.py @@ -0,0 +1,32 @@ +import bpy +from ..utils.asset import append_node_group + +def get_target_node(node_tree, + filter = lambda node: (node.type == 'TEX_IMAGE' and node.image) + ): + """ + Find the shader node to perform operations. + First, check the active node. If it is not qualified, search through all nodes to find the first eligible one + """ + nodes = node_tree.nodes + if nodes.active and filter(nodes.active): + return nodes.active, node_tree + # Search recursively for node groups + elif nodes.active and nodes.active.type=='GROUP' and nodes.active.node_tree: + return get_target_node(nodes.active.node_tree, filter) + else: + for node in nodes: + if filter(node): + return node, node_tree + return None, None + +def add_node_group(node_tree, name, location=(0,0)) -> bpy.types.ShaderNodeGroup: + node = node_tree.nodes.new('ShaderNodeGroup') + node.node_tree = append_node_group(name) + node.location = location + return node + +def add_uv_input(node_tree, location=(0,0)) -> bpy.types.ShaderNodeUVMap: + node = node_tree.nodes.new('ShaderNodeUVMap') + node.location = location + return node \ No newline at end of file