"""Cut a first-person ARMS rig out of the GODVERSE modular character kit. Source: `character_kit_modular/exports/franken_kachujin_ch01hands.glb` — the ch01 hands grafted onto a Kachujin body, all deforming through the standard 65-bone `mixamorig:` skeleton (see character_kit_modular/README.md, "GODVERSE socket standard v1"). We want the viewmodel, so we keep the two hand meshes plus the arm/sleeve region of the body, drop the rest of the character, and shrink the textures. The ARMATURE IS KEPT WHOLE AND UNRENAMED — that is the kit's contract, and it is also what lets ViewModel.gd pose individual finger bones (`mixamorig:RightHandIndex1` …) per weapon grip at runtime. /Applications/Blender.app/Contents/MacOS/Blender --background \ --python tools/gen_fps_arms.py -- [--render] Output: game/assets/viewmodel/fps_arms.glb """ import bpy import bmesh import math import os import sys from mathutils import Vector SRC = os.path.expanduser( "~/Documents/character_kit_modular/exports/franken_kachujin_ch01hands.glb") OUT_DIR = os.path.join( os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "game", "assets", "viewmodel") OUT = os.path.join(OUT_DIR, "fps_arms.glb") RENDER = "--render" in sys.argv # Previews are build artefacts, not game assets — keep them OUT of the Godot project # or the importer picks them up as textures. PREVIEW_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "previews") KEEP_MESHES = {"ch01_hand_L", "ch01_hand_R"} # Body verts to keep: everything the arm bones drive (this is the shirt sleeve). # Split per side, because the viewmodel instances this rig once PER HAND and hides the # other side — which is impossible if both sleeves share one mesh. SLEEVE_SIDES = { "arms_sleeve_L": ["mixamorig:LeftArm", "mixamorig:LeftForeArm", "mixamorig:LeftHand"], "arms_sleeve_R": ["mixamorig:RightArm", "mixamorig:RightForeArm", "mixamorig:RightHand"], } WEIGHT_MIN = 0.35 TEX_MAX = 1024 def log(*a): print("[arms]", *a) def find_armature(): for o in bpy.data.objects: if o.type == 'ARMATURE': return o return None def _cut_to_groups(obj, group_names): """Delete every vertex not driven by `group_names`. Returns the surviving count.""" gidx = {g.name: g.index for g in obj.vertex_groups} want = set(gidx[n] for n in group_names if n in gidx) if not want: return 0 bm = bmesh.new() bm.from_mesh(obj.data) bm.verts.ensure_lookup_table() deform = bm.verts.layers.deform.active if deform is None: bm.free() raise SystemExit("mesh has no deform weights") doomed = [v for v in bm.verts if sum(wt for gi, wt in v[deform].items() if gi in want) < WEIGHT_MIN] bmesh.ops.delete(bm, geom=doomed, context='VERTS') bm.to_mesh(obj.data) n = len(bm.verts) bm.free() obj.data.update() return n def main(): os.makedirs(OUT_DIR, exist_ok=True) os.makedirs(PREVIEW_DIR, exist_ok=True) bpy.ops.wm.read_factory_settings(use_empty=True) log("importing", os.path.basename(SRC)) bpy.ops.import_scene.gltf(filepath=SRC) arm = find_armature() if arm is None: raise SystemExit("no armature in source") log("armature:", arm.name, "bones:", len(arm.data.bones)) # The kit README is explicit: imported GLBs arrive posed with an action assigned. # Measuring or cutting before resetting to rest gives garbage. arm.data.pose_position = 'REST' if arm.animation_data: arm.animation_data_clear() for ob in bpy.data.objects: if ob.animation_data: ob.animation_data_clear() meshes = [o for o in bpy.data.objects if o.type == 'MESH'] log("meshes:", [(o.name, len(o.data.vertices)) for o in meshes]) body = None for o in meshes: if o.name in KEEP_MESHES: continue if o.name.lower().startswith("kachujin") and len(o.data.vertices) > 2000: body = o # the torso/limbs mesh (the shirt) else: bpy.data.objects.remove(o, do_unlink=True) kept = [o for o in bpy.data.objects if o.type == 'MESH'] log("kept meshes:", [o.name for o in kept]) # ---- trim the body down to a left and a right sleeve ------------------------ # bmesh rather than edit-mode operators: toggling modes to push a per-vertex # selection through bpy.ops is fragile (it silently deleted the whole mesh), and # the deform layer gives the weights directly. if body is not None: for name, groups in SLEEVE_SIDES.items(): side = body.copy() side.data = body.data.copy() side.name = name bpy.context.collection.objects.link(side) if _cut_to_groups(side, groups) == 0: bpy.data.objects.remove(side, do_unlink=True) log("sleeve", name, "empty — dropped") else: log("sleeve", name, "verts:", len(side.data.vertices)) bpy.data.objects.remove(body, do_unlink=True) body = None # ---- shrink textures ------------------------------------------------------- for img in list(bpy.data.images): if img.users == 0: bpy.data.images.remove(img) continue if max(img.size) > TEX_MAX: w, h = img.size s = TEX_MAX / float(max(w, h)) img.scale(max(int(w * s), 1), max(int(h * s), 1)) log("resized", img.name, "->", tuple(img.size)) # ---- report the arm rest pose so ViewModel.gd can be authored against it ---- ebones = arm.data.bones for n in ("mixamorig:RightArm", "mixamorig:RightForeArm", "mixamorig:RightHand", "mixamorig:RightHandIndex1", "mixamorig:RightHandThumb1"): b = ebones.get(n) if b: log("rest %-28s head=%s len=%.4f" % ( n, tuple(round(x, 4) for x in b.head_local), b.length)) total = sum(len(o.data.vertices) for o in bpy.data.objects if o.type == 'MESH') log("total verts:", total) # ---- export ---------------------------------------------------------------- bpy.ops.object.select_all(action='SELECT') bpy.ops.export_scene.gltf( filepath=OUT, export_format='GLB', use_selection=True, export_apply=False, # keep the armature modifier live (skinned export) export_skins=True, export_animations=False, export_yup=True, export_image_format='JPEG', ) log("wrote", OUT, "%.1f MB" % (os.path.getsize(OUT) / 1e6)) if RENDER: render_preview() def render_preview(): scene = bpy.context.scene engines = scene.render.bl_rna.properties['engine'].enum_items.keys() for want in ('BLENDER_EEVEE_NEXT', 'BLENDER_EEVEE', 'BLENDER_WORKBENCH'): if want in engines: scene.render.engine = want break scene.render.resolution_x = 800 scene.render.resolution_y = 800 if scene.world is None: scene.world = bpy.data.worlds.new("preview") scene.world.use_nodes = True bg = scene.world.node_tree.nodes.get("Background") if bg: bg.inputs[0].default_value = (0.06, 0.06, 0.07, 1.0) lo = Vector((1e9,) * 3) hi = Vector((-1e9,) * 3) for o in scene.objects: if o.type != 'MESH': continue for c in o.bound_box: w = o.matrix_world @ Vector(c) lo = Vector((min(lo.x, w.x), min(lo.y, w.y), min(lo.z, w.z))) hi = Vector((max(hi.x, w.x), max(hi.y, w.y), max(hi.z, w.z))) centre = (lo + hi) * 0.5 radius = max((hi - lo).length * 0.5, 0.01) dist = radius * 2.6 d = Vector((0.55, -0.78, 0.30)).normalized() bpy.ops.object.camera_add(location=tuple(centre + d * dist)) cam = bpy.context.active_object cam.data.lens = 60 cam.rotation_mode = 'QUATERNION' cam.rotation_quaternion = (-d).to_track_quat('-Z', 'Y') scene.camera = cam for off, e, s in ((Vector((1.0, -1.0, 1.0)), 60.0, 1.2), (Vector((-1.1, -0.4, 0.2)), 18.0, 1.8)): bpy.ops.object.light_add(type='AREA', location=tuple(centre + off * dist)) L = bpy.context.active_object L.data.energy = e * (dist ** 2) L.data.size = s * radius scene.render.filepath = os.path.join(PREVIEW_DIR, "fps_arms.png") bpy.ops.render.render(write_still=True) main()