(un)Regular Tech Adventures: Blender Add-Ons

[

Recently I’ve coded a Blender Add-On to automate various tasks. Here’s what I’ve learnt from it.

Blender Add-On

Blender has a feature that allows you to script out actions in Python. Furthermore, you can then package your scripts as blender addons

In this case, I’ll be using the osu-replay-blender-animator I recently made as an example. I’ll be referencing most of the code from there. However, the main methods & concepts would be the same

Scripting in Blender

Blender allows you to manipulate the scene with Python. Here’s some sample code that allows you to

  1. Select object
  2. Translate & scale it, and key frame it
  3. Create objects, rename
  4. Create & assign materials to objects
import bpy.ops

import bpy
from bpy import context

# Get the current scene
scene = context.scene

# Get the 3D cursor location
#cursor = scene.cursor.location

# Get the active object (assume we have one)
obj = context.active_object

def add_keyframe(x, y, z, frame):
    obj.location[0] = x
    obj.location[1] = y 
    obj.location[2] = z
    obj.scale[0] = 1
    obj.scale[1] = 1
    obj.scale[2] = 1
    #obj.keyframe_delete(data_path='location', frame=frame)
    obj.keyframe_insert(data_path='location', frame=frame) # , index=2
    obj.keyframe_insert(data_path='scale', frame=frame)
    
add_keyframe(1,1,1,10)

# Adding Objects
class Wrapper
    def generateAssembly(self):
        origin = (0, 0, 0)
        
        bpy.ops.object.empty_add(type='PLAIN_AXES')
        bpy.context.active_object.name = 'osuReplayCursor'
        self.cursor = bpy.context.object
        self.cursor.location = origin
        
        bpy.ops.mesh.primitive_cube_add()
        bpy.context.active_object.name = 'osuReplayCursorVisible'
        self.cursorVisible = bpy.context.object
        self.cursorVisible.location = origin
        self.cursorVisible.scale = self.cursor_scale
        self.cursorVisible.parent = self.cursor
        
        bpy.ops.mesh.primitive_plane_add()
        bpy.context.active_object.name = 'osuReplayPlanePlayfield'
        self.plane = bpy.context.object
        self.plane.scale = (
            self.dimensions[0]*self.scale_factor/2, # Divide to adjust scale factor
            self.dimensions[1]*self.scale_factor/2, 
            1
        )
        self.plane.location = (
            origin[0] + self.dimensions[0]*self.scale_factor/2, # midpoint
            origin[1] + self.dimensions[1]*self.scale_factor/2,
            0
        ) # middle of plane
        
        bpy.ops.mesh.primitive_plane_add()
        bpy.context.active_object.name = 'osuReplayPlaneVideo'
        self.planeVideo = bpy.context.object
    # Materials
    def linkVideoToPlane(self, filename, filedirectory):
        #bpy.ops.material.new()# "osuReplayPlaneVideoMaterial")
        self.video_mat = bpy.data.materials.new(name="osuReplayPlaneVideoMaterial") #bpy.data.materials.get("osuReplayPlaneVideoMaterial")
        self.video_mat.use_nodes = True
        
        # Assign it to object
        if self.planeVideo.data.materials:
            # assign to 1st material slot
            self.planeVideo.data.materials[0] = self.video_mat
        else:
            # no slots
            self.planeVideo.data.materials.append(self.video_mat)
        
        bpy.ops.image.open(filepath=filedirectory+filename)
        
        video_mat_links = self.planeVideo.active_material.node_tree.links
        video_mat_nodes = self.planeVideo.active_material.node_tree.nodes
        
        node_texture = video_mat_nodes.new(type='ShaderNodeTexImage')
        node_texture.image = bpy.data.images[filename]
        MAX = 1048574
        node_texture.image_user.frame_duration = MAX # Currently not working
        node_texture.image_user.frame_offset = 217 # Hardcoded
        node_texture.image_user.use_auto_refresh = True # Loop Through Video
                
        video_shader = video_mat_nodes.get('Principled BSDF')
        video_mat_links.new(video_shader.inputs["Base Color"], node_texture.outputs["Color"])
        # bpy.data.materials.remove(self.video_mat) 

If you want to figure out how to do something in Python, there probably is already a way. But you may need to google and search for stackoverflow posts. Alternatively, under preferences, you can check the show Python Tooltip option, which will show you the related python method for the action you are doing.

Structure of a Blender Add On

Your add on is in the form of a single zip file

addon.zip
  - addon/
    - __init__.py
    - other python files you may want

Most of the code below should be in __init__.py

Metadata

Put this at the very top of the file

bl_info = {
    "name": "Generate osu Replay Keyframes",
    "blender": (2, 80, 0),
    "category": "Object",
}

Importing Libraries

Remember to import your blender python libraries

import bpy
import bpy.ops
from bpy import context 

You can also import other packages in the same directory. For example, if you have a package generator.py, you can import it like this

from .generator import YourStuff

Panel

This is the part that gives your add on a usable Interface. For this, you would need to

  1. Define your global variables that you can set in the add on panel

  2. Add them in the panel with row.prop()

  3. Add an operator (to use your variables) with col.operator()

# == GLOBAL VARIABLES
PROPS = [
    ('replay_file', bpy.props.StringProperty(name='Processed Replay File Path', default='/tmp/replay.osr')),
    ('video_file_directory', bpy.props.StringProperty(name='Video File Directory', default='/tmp/')),
    ('video_file_name', bpy.props.StringProperty(name='Video File Name', default='file.mp4')),
    ('aim', bpy.props.BoolProperty(name='Aiming', default=True)),
    ('tapx', bpy.props.BoolProperty(name='TapX', default=False)),
    ('tap_bitmask', bpy.props.IntProperty(name='TapX', default=31)),
]

class SamplePanel(bpy.types.Panel):
    """ Display panel in 3D view"""
    bl_label = "osu Replay Animator"
    bl_space_type = "VIEW_3D"
    bl_region_type = "UI"
    bl_options = {'HEADER_LAYOUT_EXPAND'}
    
    def draw(self, context):
        layout = self.layout
        col = layout.column(align=True)
        
        for (prop_name, _) in PROPS:
            row = col.row()
            if prop_name == 'version':
                row = row.row()
                row.enabled = context.scene.add_version
            row.prop(context.scene, prop_name)
        
        col.operator("object.osu_replay_keyframes", text="Generate Replay Object")

Operator

Operators are where your actual code happens. It is the section which modifies your blender scene.

The global variables here can be retrieved using context.scene.<var_name>

Here’s a sample format

class GenerateOsuReplayKeyframes(bpy.types.Operator):
    """My Object Moving Script"""      # Use this as a tooltip for menu items and buttons.
    bl_idname = "object.osu_replay_keyframes"        # Unique identifier for buttons and menu items to reference.
    bl_label = "Generate Object with osu Replay Keyframes"         # Display name in the interface.
    bl_options = {'REGISTER', 'UNDO'}  # Enable undo for the operator.
    
    def execute(self, context):        # execute() is called when running the operator.        
        # Insert code to run here
        value = context.scene.aim # Example retrieval of variable
        return {'FINISHED'}            # Lets Blender know the operator finished successfully.

Registering everything

This code is the one which actually sets up your classes in Blender. It puts your props/operators/panels etc. into your scene for you to use.

classes = (
    SamplePanel, GenerateOsuReplayKeyframes
)
    

def register():
    for (prop_name, prop_value) in PROPS:
        setattr(bpy.types.Scene, prop_name, prop_value)
    for cls in classes:
        bpy.utils.register_class(cls)
        '''
        bpy.types.VIEW3D_MT_object.append(
            lambda self, context: self.layout.operator(GenerateOsuReplayKeyframes.bl_idname)
        )  # Adds the new operator to an existing menu.
        '''

def unregister():
    for (prop_name, _) in PROPS:
        delattr(bpy.types.Scene, prop_name)
    for cls in classes:
        bpy.utils.unregister_class(cls)

# This allows you to run the script directly from Blender's Text editor
# to test the add-on without having to install it.
if __name__ == "__main__":
    register()

Conclusion

With that, you should have what you need to make a simple Blender Plugin. Check out my plugin too!

Useful Resources

  1. https://docs.blender.org/manual/en/latest/advanced/scripting/addon_tutorial.html
  2. https://blender.stackexchange.com/questions/78359/set-active-object-with-python
  3. https://blender.stackexchange.com/questions/45101/adding-named-objects-in-blender-with-python-api
  4. https://blender.stackexchange.com/questions/9200/how-to-make-object-a-a-parent-of-object-b-via-blenders-python-api

view rawSetLocationRotationScaleToAnObjectWithAName.py hosted with ❤ by GitHub

  1. https://blenderartists.org/t/create-and-assign-materials-to-selected-objects/1281181/2
  2. https://blenderartists.org/t/deleting-all-materials-in-script/594160/2

Useful - create panel

  1. https://medium.com/geekculture/creating-a-custom-panel-with-blenders-python-api-b9602d890663