Skip to content

Instantly share code, notes, and snippets.

@BrianOstrander
Created March 2, 2023 22:07
Show Gist options
  • Save BrianOstrander/e6674fad2ac7d4ccfa541f8ef1b0d9d6 to your computer and use it in GitHub Desktop.
Save BrianOstrander/e6674fad2ac7d4ccfa541f8ef1b0d9d6 to your computer and use it in GitHub Desktop.
Uses a template blender file to render multi-tile models from blender into tile sheets for Project Zomboid.
import sys, subprocess, bpy
from functools import partial
from pathlib import Path
RENDER_SHEET_OPERATOR_KEY = 'sw.zomboid_render_sheet'
RENDER_TILES_OPERATOR_KEY = 'sw.zomboid_render_tiles'
COLLECTION_SHEET_PREFIX = 'sheet#'
DIRECTORY_TEMP_SUFFIX = '_tile_renders'
DIRECTORY_RENDER_SUFFIX = '_renders'
ACTION_DELAY = 0.00001
CLEANUP = True
BOOLEAN_MODIFIER_KEY = 'sw_camera_reveal_bool'
DIRECTION_TILE_LOOKUP = {
'e': {
'a': 'a',
'b': 'b',
'c': 'c',
'd': 'd'
},
's': {
'a': 'b',
'b': 'd',
'c': 'a',
'd': 'c'
},
'w': {
'a': 'd',
'b': 'c',
'c': 'b',
'd': 'a'
},
'n': {
'a': 'c',
'b': 'a',
'c': 'd',
'd': 'b'
}
}
# EDITOR SETUP --------------------------------------
class ZomboidRenderSheetOperator(bpy.types.Operator):
bl_idname = RENDER_SHEET_OPERATOR_KEY
bl_label = "Render Sheet"
def execute(self, context):
zomboid_render(True)
return {'FINISHED'}
class ZomboidRenderTilesOperator(bpy.types.Operator):
bl_idname = RENDER_TILES_OPERATOR_KEY
bl_label = "Render Tiles"
def execute(self, context):
zomboid_render(False)
return {'FINISHED'}
class TOPBAR_MT_zomboid_menu(bpy.types.Menu):
bl_label = 'Zomboid'
def draw(self, context):
layout = self.layout
layout.operator(RENDER_SHEET_OPERATOR_KEY)
layout.operator(RENDER_TILES_OPERATOR_KEY)
def menu_draw(self, context):
self.layout.menu('TOPBAR_MT_zomboid_menu')
classes = (ZomboidRenderSheetOperator, ZomboidRenderTilesOperator, TOPBAR_MT_zomboid_menu)
def register():
for cls in classes:
bpy.utils.register_class(cls)
bpy.types.TOPBAR_MT_editor_menus.append(TOPBAR_MT_zomboid_menu.menu_draw)
def unregister():
bpy.types.TOPBAR_MT_editor_menus.remove(TOPBAR_MT_zomboid_menu.menu_draw)
for cls in classes:
bpy.utils.unregister_class(cls)
# UTILITY -----------------------------------------
def operator_exists(idname):
from bpy.ops import op_as_string
try:
op_as_string(idname)
return True
except:
return False
def delete_directory(target_path):
if not target_path:
raise Exception('Invalid target path provided!')
for sub in target_path.iterdir():
if sub.is_dir():
delete_directory(sub)
else:
sub.unlink()
target_path.rmdir()
def get_ancestor(obj, ancestor):
if obj.parent == ancestor:
return True
if obj.parent == None:
return False
return get_ancestor(obj.parent, ancestor)
def get_descendents(ancestor):
descendents = [ ancestor ]
for obj in bpy.data.objects:
if get_ancestor(obj, ancestor):
descendents.append(obj)
return descendents
# icon values = INFO, ERROR
def show_message(message = "", title = "Alert", icon = 'INFO'):
def draw_message(self, context):
self.layout.label(text=message)
bpy.context.window_manager.popup_menu(draw_message, title = title, icon = icon)
# RENDER -------------------------------------------
class ZomboidRenderState:
def rotation(self, root, direction=None):
z = 0
if direction == 'n':
z = 1.5708
elif direction == 'e':
z = 0
elif direction == 's':
z = 1.5708 * 3
elif direction == 'w':
z = 1.5708 * 2
root.rotation_euler = (0,0,z)
def tile(self, tile=None):
xyz = (-0.5,0.5,0)
if tile == 'a':
xyz = (-0.5,0.5,0)
elif tile == 'b':
xyz = (0.5,0.5,0)
elif tile == 'c':
xyz = (-0.5,-0.5,0)
elif tile == 'd':
xyz = (0.5,-0.5,0)
self.camera_origin.location = xyz
def clean_temp_directory(self, delete_only=False):
if self.temp_path.exists():
delete_directory(self.temp_path)
if not delete_only:
self.temp_path.mkdir()
def toggle_rendering(self, obj):
obj.hide_render = not obj.hide_render
def boolean_operation(self, obj, enable):
if enable:
boolean_modifier = obj.modifiers.new(BOOLEAN_MODIFIER_KEY, 'BOOLEAN')
boolean_modifier.object = self.reveal_boolean
boolean_modifier.solver = 'EXACT'
boolean_modifier.operation = 'INTERSECT'
else:
obj.modifiers.remove(obj.modifiers[BOOLEAN_MODIFIER_KEY])
def render(self, obj, name, row, column, direction, tile):
print('Rendering obj: {}, name: {}, row {}, column {}, direction {}, tile {}'.format(obj, name, row, column, direction, tile))
self.rotation(obj, direction)
self.tile(tile)
self.scene.render.image_settings.file_format = 'PNG'
self.scene.render.filepath = str(self.temp_path.joinpath('{}_{}_{}.png'.format(self.sheet_name, str(row).zfill(2), str(column).zfill(2))))
bpy.ops.render.render(write_still=True)
def stitch_renders(self, row_count, name):
try:
result = subprocess.run(['/usr/local/bin/montage {sheet_name}_*.png -tile 8x{row_count} -geometry {resolution_x}x{resolution_y}+0+0 -background transparent {name}.png'.format(sheet_name=self.sheet_name, row_count=row_count, resolution_x=self.resolution_x, resolution_y=self.resolution_y, name=Path('..').joinpath(self.render_path.name).joinpath(name))], cwd=self.temp_path, shell=True, capture_output=True)
except:
show_message('Error on stitch_renders: {}'.format(sys.exc_info()[0]), icon = 'ERROR')
def all_done(self):
print('All done with {}'.format(self.sheet_name))
if self.next_render:
self.next_render.begin()
else:
if self.cleanup:
self.clean_temp_directory(True)
show_message('Render complete!', icon = 'INFO')
def __init__(self, scene, cleanup, camera_origin, resolution_x, resolution_y, reveal_boolean, collection, temp_path, render_path):
self.scene = scene
self.cleanup = cleanup
self.camera_origin = camera_origin
self.resolution_x = resolution_x
self.resolution_y = resolution_y
self.reveal_boolean = reveal_boolean
self.collection = collection
self.temp_path = temp_path
self.render_path = render_path
self.next_render = None
self.action_stack = []
self.sheet_name = self.collection.name[len(COLLECTION_SHEET_PREFIX):]
self.is_last = False
def begin(self):
print('---- Begin processing of sheet "{}" ----'.format(self.sheet_name))
print('Building action stack...')
for collection in bpy.data.collections:
if collection.name.startswith(COLLECTION_SHEET_PREFIX):
for obj in collection.all_objects:
if not obj.hide_render:
self.action_stack.append(partial(self.toggle_rendering, obj))
montage_entries = []
row = 0
for obj in sorted(self.collection.all_objects, key=lambda o: o.name):
if obj.name.endswith('*') or obj.parent != None:
continue
obj_params = obj.name.split('_')
obj_name = obj_params[0]
obj_directions = 'e'
obj_tiles = 'a'
obj_sliced = 't'
for obj_param in obj_params:
if obj_param.startswith('d='):
obj_directions = obj_param[2:]
elif obj_param.startswith('t='):
obj_tiles = obj_param[2:]
elif obj_param.startswith('s='):
obj_sliced = obj_param[2:]
descendents = get_descendents(obj)
# Toggle render on
for descendent in descendents:
self.action_stack.append(partial(self.toggle_rendering, descendent))
if obj_sliced == 't' and descendent.type == 'MESH':
self.action_stack.append(partial(self.boolean_operation, descendent, True))
column = 0
for direction in obj_directions:
for tile in obj_tiles:
tile_correction = DIRECTION_TILE_LOOKUP[direction][tile]
self.action_stack.append(partial(self.render, obj, obj_name, row, column, direction, tile_correction))
column += 1
# Set back to facing east
self.action_stack.append(partial(self.rotation, obj, 'e'))
# Set camera back to default tile
self.action_stack.append(partial(self.tile, 'a'))
# Toggle render off
for descendent in descendents:
self.action_stack.append(partial(self.toggle_rendering, descendent))
if descendent.type == 'MESH':
self.action_stack.append(partial(self.boolean_operation, descendent, False))
montage_entries.append((row, column))
row += 1
self.action_stack.append(partial(self.stitch_renders, row - 1, self.sheet_name))
self.action_stack.append(partial(self.all_done))
print('Executing action stack...')
bpy.app.timers.register(self.update)
def update(self):
if len(self.action_stack) == 0:
print('Execution of action stack complete!')
print('-----------------------------------')
return None
self.action_stack.pop(0)()
return ACTION_DELAY
def zomboid_render(cleanup):
try:
subprocess.run(['/usr/local/bin/montage -version'], cwd=Path(bpy.context.blend_data.filepath).parent, shell=True, check=True)
print('montage found - confirmed ImageMagick installation...')
except subprocess.CalledProcessError as e:
show_message('ImageMagick\'s "montage" could not be found, are you certain ImageMagick is properly installed? Error:\n{}'.format(e), icon = 'ERROR')
return
camera_origin = bpy.data.objects['camera_origin']
if not camera_origin:
show_message('No object with name camera_origin was found!', icon = 'ERROR')
return
reveal_boolean = bpy.data.objects['reveal_boolean']
if not reveal_boolean:
show_message('No object with name reveal_boolean was found!', icon = 'ERROR')
return
scene = bpy.context.scene
resolution_x = scene.render.resolution_x
resolution_y = scene.render.resolution_y
file_path = Path(bpy.context.blend_data.filepath)
file_name = str(file_path.name).split('.blend')[0]
temp_path = file_path.parent.joinpath('{}{}'.format(file_name,DIRECTORY_TEMP_SUFFIX))
render_path = file_path.parent.joinpath('{}{}'.format(file_name,DIRECTORY_RENDER_SUFFIX))
if temp_path.exists():
delete_directory(temp_path)
if render_path.exists():
delete_directory(render_path)
render_path.mkdir()
with open(render_path.joinpath('DO NOT SAVE HERE.txt'), "w") as warning_file:
print('Do not save any changes to this directory, it will be overwritten by the next render operation!', file=warning_file)
first_render = None
previous_render = None
for collection in bpy.data.collections:
if collection.name.endswith('*'):
continue
if collection.name.startswith(COLLECTION_SHEET_PREFIX):
current_render = ZomboidRenderState(scene, cleanup, camera_origin, resolution_x, resolution_y, reveal_boolean, collection, temp_path, render_path)
if not first_render:
first_render = current_render
if previous_render:
previous_render.next_render = current_render
previous_render = current_render
if previous_render:
previous_render.is_last = True
first_render.begin()
else:
show_message('No collections with the "{}" prefix were found!'.format(COLLECTION_SHEET_PREFIX), icon = 'ERROR')
# MAIN ------------------------------------------
if __name__ == "__main__":
register()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment