feat: Add KoikatsuBlenderPipeline addon with import/export functionality
- Add core addon files (__init__.py, KKPanel.py, preferences.py, common.py) - Add import pipeline with armature, mesh, and material modification modules - Add export pipeline with material baking and FBX preparation utilities - Add material combiner tool for texture atlas generation and optimization - Add extras utilities for animation, rigging, and asset management - Add bone orientation data from better_fbx for accurate skeletal structure - Add comprehensive documentation and wiki with multi-language support (EN, JP, ZH) - Add animation library retargeting lists for ARP and Rokoko motion capture - Add Rigify integration scripts for advanced rigging workflows - Add shader file (KK Shader V8.0.blend) for material rendering - Add manifest and license files for addon distribution - Add changelog documenting version history and improvements - Initialize complete Blender addon project for Koikatsu character import/export
This commit is contained in:
451
extras/createanimationlibrary.py
Normal file
451
extras/createanimationlibrary.py
Normal file
@@ -0,0 +1,451 @@
|
||||
#This will take a folder full of fbx animation files ripped from the game and create a blender animation library using the current model (for thumbnails)
|
||||
|
||||
# Usage instructions:
|
||||
# Export koikatsu fbx animation files using https://www.youtube.com/watch?v=XFt12n7ByBI&t=465s
|
||||
# You can also export multiple animations at the same time by shift clicking them in the window
|
||||
# put all the fbx files you exported into one folder
|
||||
# any subfolder names in that folder will be used to tag them
|
||||
|
||||
# import a character with a rigify armature
|
||||
# make sure you've got a camera and light pointed at the model (put it at an angle for better thumbnails)
|
||||
# make sure you've disabled the armature visibility (in the 3d view settings on the top bar, not in the outliner)
|
||||
# Suggested X and Y axis lines: ON
|
||||
# Suggested sun location and rotation [-1 m, 0 m, 0 m] , [78°, -40°, 27°]
|
||||
# Suggested world color [0,0,0]
|
||||
# Suggested camera location + rotation [-0.7 m, -1.9 m, 0.8 m] , [83.6°, -0°, -20°]
|
||||
# Suggested camera resolution [150 x 150]
|
||||
# Suggested Tpose model location + rotation [0,0,0], [0°,0°,0°]
|
||||
# Use https://www.youtube.com/watch?v=Nyxeb48mUfs&t=713s to setup the Rokoko retargeting addon with a random koikatsu fbx animation file from your folder
|
||||
# (make sure torso, torso tweak, arm fk, fingers detail, leg fk Rigify layers are visible)
|
||||
# (make sure ALL rokoko remapping / naming schemes are already setup using the random file)
|
||||
# (Alternatively, you can import the included "Rokoko custom target naming" .json file included in the /extras/animationlibrary/ directory to set it automatically)
|
||||
# (it just needs to be done once, then you can hit the save button in the rokoko retargeting panel to use it in any other file)
|
||||
# delete the random fbx animation you imported (setup is complete at this point)
|
||||
# save the file
|
||||
|
||||
# enter rendered view
|
||||
# Run the script by pressing the button in the panel
|
||||
# It will take about two hours on a good CPU to generate the library (three minutes per 11 poses, or 2 hours for ~440 poses / animations which adds up to about 1.2gb of fbx files)
|
||||
# You can also do it in small batches and rotate out the already imported fbx files for new ones (change your filename after each batch or the previous batches will be overwritten)
|
||||
# Or you can put a large list and kill blender when you want to pause the import process (it will automatically save a new file for every subfolder, and pick up from the previous subfolder)
|
||||
|
||||
# when finished, open every asset file and run the script on the bottom of this file to reduce the filesize
|
||||
# (You can also copy paste the script into a text editor before running the script so you can have it automatically loaded on every asset file that will be produced)
|
||||
# also recall that blender resets the fps to the framerate of the fbx file when importing
|
||||
|
||||
import bpy, os, time, mathutils, json, pathlib
|
||||
from bpy.props import StringProperty
|
||||
from .. import common as c
|
||||
from ..interface.dictionary_en import t
|
||||
def main(folder):
|
||||
|
||||
#stop if the file was not saved
|
||||
if not bpy.data.filepath:
|
||||
raise('File must be saved first!')
|
||||
|
||||
kkbp_character = False
|
||||
#delete the armature before starting to reduce console spam
|
||||
if c.get_body() and c.get_rig():
|
||||
kkbp_character = True
|
||||
if bpy.data.objects.get('Armature') and kkbp_character:
|
||||
n = bpy.data.objects['Armature'].data.name
|
||||
bpy.data.objects.remove(bpy.data.objects['Armature'])
|
||||
bpy.data.armatures.remove(bpy.data.armatures[n])
|
||||
c.toggle_console() #open console for some kind of progression
|
||||
start = time.time()
|
||||
rigify_armature = bpy.data.objects['RIG-Armature']
|
||||
bpy.context.view_layer.objects.active=rigify_armature
|
||||
bpy.ops.object.mode_set(mode = 'OBJECT')
|
||||
bpy.ops.rsl.clear_custom_bones()
|
||||
|
||||
if kkbp_character:
|
||||
#disable IKs
|
||||
rigify_armature.pose.bones["Right arm_parent"]["IK_FK"] = 1
|
||||
rigify_armature.pose.bones["Left arm_parent"]["IK_FK"] = 1
|
||||
rigify_armature.pose.bones["Right leg_parent"]["IK_FK"] = 1
|
||||
rigify_armature.pose.bones["Left leg_parent"]["IK_FK"] = 1
|
||||
|
||||
#disable head follow
|
||||
rigify_armature.pose.bones['torso']['neck_follow'] = 1.0
|
||||
rigify_armature.pose.bones['torso']['head_follow'] = 1.0
|
||||
|
||||
'''#reset all bone transforms
|
||||
for pb in bpy.context.selected_pose_bones_from_active_object:
|
||||
pb.matrix_basis = mathutils.Matrix() # == Matrix.Identity(4)
|
||||
'''
|
||||
|
||||
#import the fbx files
|
||||
fbx_files = []
|
||||
for subdir, dirs, files in os.walk(folder):
|
||||
for file in files:
|
||||
if '.fbx' in file:
|
||||
fbx_files.append((subdir, file, os.path.join(subdir, file)))
|
||||
|
||||
actions_from_set = []
|
||||
last_category = ''
|
||||
first_import = True
|
||||
original_file_name = bpy.data.filepath
|
||||
|
||||
for file in fbx_files:
|
||||
category = os.path.basename(file[0])
|
||||
original_file_number = os.path.basename(os.path.dirname(file[0]))
|
||||
filename = file[1]
|
||||
|
||||
#skip this file if the animation is for larger characters, or is a partial animation
|
||||
skip = False
|
||||
no_use = ['L_', 'M_', 'ML_', 'SM_', 'Te_', 'Yubi_', 'Sita_', 'Denma_', 'Vibe_']
|
||||
for item in no_use:
|
||||
if filename.startswith(item):
|
||||
skip = True
|
||||
if skip:
|
||||
continue
|
||||
|
||||
#also skip if this a file for this category already exists
|
||||
if os.path.exists(bpy.data.filepath.replace('.blend', ' ' + category + ' ' + str(original_file_number) + '.blend')):
|
||||
continue
|
||||
|
||||
#create a new file for every category because the asset browser seems to behave better with multiple small files vs one large file
|
||||
if last_category != category:
|
||||
#save, then create new file
|
||||
if last_category: #skip save for first file
|
||||
bpy.ops.wm.save_as_mainfile(filepath = bpy.data.filepath)
|
||||
bpy.ops.wm.save_as_mainfile(filepath = original_file_name.replace('.blend', ' ' + category + ' ' + str(original_file_number) + '.blend'))
|
||||
last_category = category
|
||||
|
||||
#new file, so delete the previous imported asset animations
|
||||
for act in actions_from_set:
|
||||
bpy.data.actions.remove(bpy.data.actions[act])
|
||||
actions_from_set = []
|
||||
|
||||
#load a script into the layout tab
|
||||
if bpy.data.screens.get('Layout'):
|
||||
for area in bpy.data.screens['Layout'].areas:
|
||||
if area.type == 'DOPESHEET_EDITOR':
|
||||
area.ui_type = 'TEXT_EDITOR'
|
||||
area.spaces[0].text = bpy.data.texts.new(name='Reduce filesize, save and close')
|
||||
area.spaces[0].text.write(
|
||||
'''#this script reduces the filesize of the library file to just the action data
|
||||
import bpy, time
|
||||
for obj in bpy.data.objects:
|
||||
bpy.data.objects.remove(obj)
|
||||
for arm in bpy.data.armatures:
|
||||
bpy.data.armatures.remove(arm)
|
||||
for mesh in bpy.data.meshes:
|
||||
bpy.data.meshes.remove(mesh)
|
||||
for mat in bpy.data.materials:
|
||||
bpy.data.materials.remove(mat)
|
||||
for img in bpy.data.images:
|
||||
bpy.data.images.remove(img)
|
||||
for node in bpy.data.node_groups:
|
||||
bpy.data.node_groups.remove(node)
|
||||
bpy.ops.wm.save_as_mainfile(filepath=bpy.data.filepath)
|
||||
time.sleep(3)
|
||||
bpy.ops.wm.quit_blender()
|
||||
|
||||
''')
|
||||
bpy.context.scene.frame_end = 40
|
||||
bpy.ops.import_scene.fbx(filepath=str(file[2]), global_scale=96)
|
||||
|
||||
imported_armature = bpy.context.object
|
||||
'''bpy.ops.object.mode_set(mode = 'EDIT')
|
||||
bpy.ops.armature.select_all(action='DESELECT')
|
||||
for bone in imported_armature.data.edit_bones:
|
||||
if (
|
||||
'k_f_' in bone.name
|
||||
or ('cf_hit_' in bone.name)
|
||||
or ('a_n_' in bone.name)
|
||||
):
|
||||
bone.select_head, bone.select_tail, bone.select = True, True, True
|
||||
bpy.ops.object.mode_set(mode = 'OBJECT')'''
|
||||
|
||||
#rokoko remap list still shows deleted bones for some fucking reason
|
||||
#clear all animation data from source armature, then re-key all bones for just this frame
|
||||
#duplicate source armature then delete the original source armature
|
||||
#rokoko list now shows correct bone list on source armature.002
|
||||
|
||||
#save the source action name so I can delete it later
|
||||
current_action_name = imported_armature.animation_data.action.name
|
||||
|
||||
#setup rokoko remapping
|
||||
my_areas = bpy.context.workspace.screens[0].areas
|
||||
for area in my_areas:
|
||||
for space in area.spaces:
|
||||
if space.type == 'VIEW_3D':
|
||||
space.show_object_viewport_armature = True
|
||||
bpy.context.scene.rsl_retargeting_armature_source = imported_armature
|
||||
bpy.context.scene.rsl_retargeting_armature_target = rigify_armature
|
||||
bpy.ops.rsl.build_bone_list()
|
||||
#setup all remapping stuff if this is the first imported animation
|
||||
if first_import and kkbp_character:
|
||||
first_import = False
|
||||
script_dir=pathlib.Path(__file__).parent
|
||||
remap_file = os.path.join(script_dir, "animationlibrary", "Rokoko retargeting list (koikatsu fbx to KKBP Rigify).json")
|
||||
json_file = open(remap_file)
|
||||
data = json.load(json_file)
|
||||
for row in bpy.context.scene.rsl_retargeting_bone_list:
|
||||
row.bone_name_target = "" #clear all bones
|
||||
for bone in data['bones']:
|
||||
for row in bpy.context.scene.rsl_retargeting_bone_list:
|
||||
if row.bone_name_source.lower() == data['bones'][bone][0].lower():
|
||||
row.bone_name_target = data['bones'][bone][1]
|
||||
bpy.ops.rsl.retarget_animation()
|
||||
|
||||
# then remove the fcurves of the retargeted bones that were not present in the retargeting list.
|
||||
# A lot of popping and fluctuation is present if these aren't removed
|
||||
retargeting_list = [
|
||||
'torso',
|
||||
'Spine_fk',
|
||||
'Chest_fk',
|
||||
'Upper Chest_fk',
|
||||
'Left arm_fk',
|
||||
'Left elbow_fk',
|
||||
'Left wrist_fk',
|
||||
'IndexFinger1_L',
|
||||
'IndexFinger2_L',
|
||||
'IndexFinger3_L',
|
||||
'LittleFinger1_L',
|
||||
'LittleFinger2_L',
|
||||
'LittleFinger3_L',
|
||||
'MiddleFinger1_L',
|
||||
'MiddleFinger2_L',
|
||||
'MiddleFinger3_L',
|
||||
'RingFinger1_L',
|
||||
'RingFinger2_L',
|
||||
'RingFinger3_L',
|
||||
'Thumb0_L',
|
||||
'Thumb1_L',
|
||||
'Thumb2_L',
|
||||
'Right arm_fk',
|
||||
'Right elbow_fk',
|
||||
'Right wrist_fk',
|
||||
'IndexFinger1_R',
|
||||
'IndexFinger2_R',
|
||||
'IndexFinger3_R',
|
||||
'LittleFinger1_R',
|
||||
'LittleFinger2_R',
|
||||
'LittleFinger3_R',
|
||||
'MiddleFinger1_R',
|
||||
'MiddleFinger2_R',
|
||||
'MiddleFinger3_R',
|
||||
'RingFinger1_R',
|
||||
'RingFinger2_R',
|
||||
'RingFinger3_R',
|
||||
'Thumb0_R',
|
||||
'Thumb1_R',
|
||||
'Thumb2_R',
|
||||
'neck',
|
||||
'head',
|
||||
'Hips_fk',
|
||||
'Left leg_fk',
|
||||
'Left knee_fk',
|
||||
'Left ankle_fk',
|
||||
'Left toe_fk',
|
||||
'Right leg_fk',
|
||||
'Right knee_fk',
|
||||
'Right ankle_fk',
|
||||
'Right toe_fk',
|
||||
'Left shoulder',
|
||||
'cf_j_waist02',
|
||||
'Right shoulder',
|
||||
'cf_j_kokan',
|
||||
#'cf_j_ana',
|
||||
'Breasts handle',
|
||||
'Left Breast handle',
|
||||
'cf_j_bust02_l',
|
||||
'cf_j_bust03_l',
|
||||
'cf_j_bnip02root_l',
|
||||
'cf_j_bnip02_l',
|
||||
'Right Breast handle',
|
||||
'cf_j_bust02_r',
|
||||
'cf_j_bust03_r',
|
||||
'cf_j_bnip02root_r',
|
||||
'cf_j_bnip02_r',
|
||||
#'Left Buttock handle',
|
||||
#'Right Buttock handle',
|
||||
]
|
||||
if kkbp_character:
|
||||
for index, action in enumerate(rigify_armature.animation_data.action.fcurves):
|
||||
bone_name = action.data_path[action.data_path.find('[')+2:action.data_path.find('].')-1]
|
||||
print('{} {} / {}'.format(action.data_path, index, len(rigify_armature.animation_data.action.fcurves)))
|
||||
if bone_name not in retargeting_list:
|
||||
rigify_armature.animation_data.action.fcurves.remove(action) #remove keyframes
|
||||
if rigify_armature.pose.bones.get(bone_name):
|
||||
rigify_armature.pose.bones[bone_name].matrix_basis = mathutils.Matrix() #reset rotation matrix
|
||||
|
||||
if bpy.context.scene.kkbp.animation_library_scale:
|
||||
#also scale the arms on the y axis by 5% because it makes the animation more accurate to the in-game one for some poses
|
||||
already_got_both_arms = False
|
||||
if kkbp_character:
|
||||
for y_scale_curve in [curve for curve in rigify_armature.animation_data.action.fcurves if (curve.array_index == 1) and ('scale' in curve.data_path)]:
|
||||
bone_name = y_scale_curve.data_path[y_scale_curve.data_path.find('[')+2:y_scale_curve.data_path.find('].')-1]
|
||||
if bone_name in ['Left arm_fk', 'Right arm_fk']:
|
||||
keyframe_limits = [int(y_scale_curve.keyframe_points[0].co.x), int(y_scale_curve.keyframe_points[-1].co.x)]
|
||||
for frame_number in range(keyframe_limits[0], keyframe_limits[1]+1):
|
||||
original_scale = y_scale_curve.evaluate(frame_number)
|
||||
y_scale_curve.keyframe_points.insert(frame_number, original_scale * 1.05)
|
||||
if already_got_both_arms:
|
||||
break #skip the rest
|
||||
already_got_both_arms = True
|
||||
|
||||
#select all rigify armature bones and create pose asset
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
rigify_armature.select_set(True)
|
||||
bpy.context.view_layer.objects.active=rigify_armature
|
||||
bpy.ops.object.mode_set(mode = 'POSE')
|
||||
bpy.ops.pose.select_all(action='SELECT')
|
||||
bpy.data.workspaces["Layout"].asset_library_reference = 'LOCAL'
|
||||
#translate name
|
||||
name = filename.replace('-p_cf_body_bone-0.fbx', '')
|
||||
name = name.replace('-p_cf_body_bone-1.fbx', '')
|
||||
translation_dict_normal = {
|
||||
'isu_':'Seated_',
|
||||
'suwari':'Sitting',
|
||||
'syagami':'Squat',
|
||||
'sadou':'Tea',
|
||||
'undou':'Excercise',
|
||||
'suiei':'Swimming',
|
||||
'tachi':'Standing',
|
||||
'manken':'Reading',
|
||||
'soine':'Laying2',
|
||||
'kasa':'Umbrella',
|
||||
'konbou':'Club',
|
||||
'aruki':'Walking',
|
||||
'haruki':'Running',
|
||||
'ne_0':'Sleep',
|
||||
'nugi':'Undressing',
|
||||
}
|
||||
translation_dict_h = {
|
||||
'_A_Idle':'Crotch grope idle',
|
||||
'_A_Loop':'Crotch grope',
|
||||
'_A_Touch':'Crotch grope start',
|
||||
'_K_Loop':'Kiss',
|
||||
'_K_Touch':'Kiss start',
|
||||
'_M_Idle':'Breast grope idle',
|
||||
'_M_Loop':'Breast grope',
|
||||
'_M_Touch':'Breast grope start',
|
||||
'_MLoop1': 'Masturbate',
|
||||
'_MLoop2': 'Masturbate2',
|
||||
'_Orgasm-': 'Climax-',
|
||||
'_Orgasm_A':'Climax end',
|
||||
'_Orgasm_B':'Climax end',
|
||||
'_Orgasm_Loop':'Climax',
|
||||
'_Orgasm_Start':'Climax start',
|
||||
'_S_Idle':'Butt grope idle',
|
||||
'_S_Loop':'Butt grope',
|
||||
'_S_Touch':'Butt grope start',
|
||||
'_Back_Dislikes':'Embarrassed back',
|
||||
'_Front_Dislikes':'Embarrassed front',
|
||||
|
||||
'_Oral_Idle_IN': 'Cum in mouth start',
|
||||
'_Oral_Idle': 'Cum in mouth',
|
||||
'_Stop_Idle': 'Idling stop',
|
||||
'_InsertIdle': 'Insert idle',
|
||||
'_Idle-': 'Idling-',
|
||||
'_Insert-': 'Insert-',
|
||||
'_M_IN_Start':'Climax inside start',
|
||||
'_SF_IN_Start':'Climax inside start',
|
||||
'_A_SS_IN_Start':'Climax inside start',
|
||||
'_SS_IN_Start':'Climax inside start',
|
||||
'_A_WF_IN_Start':'Climax inside start',
|
||||
'_A_WS_IN_Start':'Climax inside start',
|
||||
'_WF_IN_Start':'Climax inside start',
|
||||
'_A_M_IN_Start':'Climax inside start',
|
||||
'_WS_IN_Start':'Climax inside start',
|
||||
'_IN_Start':'Climax inside start',
|
||||
'_M_IN_Loop':'Climax inside',
|
||||
'_SF_IN_Loop':'Climax inside',
|
||||
'_SS_IN_Loop':'Climax inside',
|
||||
'_WF_IN_Loop':'Climax inside',
|
||||
'_WS_IN_Loop':'Climax inside',
|
||||
'_IN_Loop':'Climax inside',
|
||||
'_A_IN_A':'Climax inside end',
|
||||
'_SS_IN_A':'Climax inside end',
|
||||
'_A_WS_IN_A':'Climax inside end',
|
||||
'_WS_IN_A':'Climax inside end',
|
||||
'_IN_A':'Climax inside end',
|
||||
'_Pull':'Pull out',
|
||||
'_OUT_Start':'Climax outside start',
|
||||
'_M_OUT_Loop':'Climax outside',
|
||||
'_OUT_Loop':'Climax outside',
|
||||
'_OUT_A':'Climax outside end',
|
||||
'_OLoop': 'Loop',
|
||||
'_SLoop':'Fast loop',
|
||||
'_WLoop':'Slow loop',
|
||||
'_Drink_IN':'Swallow cum start',
|
||||
'_Drink_A':'Swallow cum end',
|
||||
'_Drink':'Swallow cum',
|
||||
'_Vomit_IN': 'Spit out cum start',
|
||||
'_Vomit_A': 'Spit out cum end',
|
||||
'_Vomit': 'Spit out cum',
|
||||
}
|
||||
|
||||
for item in translation_dict_normal:
|
||||
name = name.replace(item, translation_dict_normal[item])
|
||||
h_dict_used = False
|
||||
for item in translation_dict_h:
|
||||
if item in name:
|
||||
name = category + ' ' + name[1:] #add the description onto it because h animations don't have names, remove the S at the beginning of the name because all of them start with that
|
||||
name = name.replace(item, translation_dict_h[item])
|
||||
h_dict_used = True
|
||||
break
|
||||
|
||||
action = rigify_armature.animation_data.action
|
||||
action.asset_mark()
|
||||
rigify_armature.asset_mark()
|
||||
action.use_fake_user = True
|
||||
#render the first frame of the animation and set it as the preview
|
||||
bpy.context.scene.render.filepath = os.path.join(folder, "preview.png")
|
||||
#print(file[2])
|
||||
my_areas = bpy.context.workspace.screens[0].areas
|
||||
for area in my_areas:
|
||||
for space in area.spaces:
|
||||
if space.type == 'VIEW_3D':
|
||||
space.show_object_viewport_armature = False
|
||||
bpy.ops.render.opengl(write_still = True)
|
||||
with bpy.context.temp_override(id=action):
|
||||
bpy.ops.ed.lib_id_load_custom_preview(filepath=os.path.join(folder, "preview.png"))
|
||||
my_areas = bpy.context.workspace.screens[0].areas
|
||||
for area in my_areas:
|
||||
for space in area.spaces:
|
||||
if space.type == 'VIEW_3D':
|
||||
space.show_object_viewport_armature = True
|
||||
action.name = name
|
||||
#bpy.ops.poselib.create_pose_asset(activate_new_action=True, pose_name = name) #for pose assets instead
|
||||
action.asset_data.tags.new(filename)
|
||||
action.asset_data.tags.new(category)
|
||||
action.asset_data.tags.new(original_file_number)
|
||||
if h_dict_used:
|
||||
action.asset_data.tags.new('NSFW')
|
||||
action.asset_data.description = filename
|
||||
actions_from_set.append(action.name)
|
||||
|
||||
#delete the imported action, object and armature
|
||||
bpy.data.actions.remove(bpy.data.actions[current_action_name])
|
||||
imported_armature_armaturename = imported_armature.data.name
|
||||
bpy.data.objects.remove(imported_armature)
|
||||
bpy.data.armatures.remove(bpy.data.armatures[imported_armature_armaturename])
|
||||
print(file)
|
||||
|
||||
print(str(time.time() - start))
|
||||
bpy.ops.wm.save_as_mainfile(filepath = original_file_name.replace('.blend', ' ' + category + ' ' + str(original_file_number) + '.blend'))
|
||||
c.toggle_console() #close console
|
||||
|
||||
class anim_asset_lib(bpy.types.Operator):
|
||||
bl_idname = "kkbp.createanimassetlib"
|
||||
bl_label = "Create animation asset library"
|
||||
bl_description = t('animation_library_tt')
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
directory : StringProperty(maxlen=1024, default='', subtype='FILE_PATH', options={'HIDDEN'})
|
||||
filter_glob : StringProperty(default='', options={'HIDDEN'})
|
||||
data = None
|
||||
mats_uv = None
|
||||
structure = None
|
||||
|
||||
def execute(self, context):
|
||||
main(self.directory)
|
||||
return {'FINISHED'}
|
||||
|
||||
def invoke(self, context, event):
|
||||
context.window_manager.fileselect_add(self)
|
||||
return {'RUNNING_MODAL'}
|
||||
Reference in New Issue
Block a user