Created
March 2, 2023 22:07
-
-
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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