Files
KKBP_Importer/extras/createanimationlibrary.py
小煜 5d9e09e9c3 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
2025-12-06 15:26:19 +08:00

451 lines
20 KiB
Python

#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'}