"""Generate Destroyulator's first-person WEAPON meshes as GLBs, procedurally, in Blender. Why procedural instead of a generated mesh: a viewmodel weapon lives or dies on its PIVOT. The grip has to sit exactly on the shaft axis, at the origin, or the hand floats off the bat. That is a modelling constraint, not an art one, so it is authored in code where the numbers are exact and re-runnable. Hands and arms are NOT built here — see tools/gen_fps_arms.py, which cuts a properly rigged pair (with per-finger mixamorig bones) out of the GODVERSE modular character kit. build_hand()/build_arm() survive below as a no-dependency fallback only. /Applications/Blender.app/Contents/MacOS/Blender --background \ --python tools/gen_viewmodel.py -- [--render] CONVENTIONS (shared with ViewModel.gd — change both or neither) * Author in Blender Z-up; the glTF exporter rewrites to Y-up, so Blender +Z becomes glTF +Y. Everything below is described in BLENDER space. * Weapons: grip centre at the ORIGIN, shaft running +Z (so the head is up). ViewModel slides a second hand up a two-hander by translating along that axis alone. * 1 unit = 1 m, matching the GLB convention in the repo README. """ import bpy import bmesh import math import os import sys from mathutils import Vector, Matrix OUT_DIR = os.path.join( os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "game", "assets", "viewmodel") 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") # ---------------------------------------------------------------- scene helpers def reset_scene(): bpy.ops.wm.read_factory_settings(use_empty=True) def mat(name, color, rough=0.6, metal=0.0): """A plain Principled material. Colors are linear-ish sRGB values.""" m = bpy.data.materials.new(name) m.use_nodes = True bsdf = m.node_tree.nodes["Principled BSDF"] bsdf.inputs["Base Color"].default_value = (color[0], color[1], color[2], 1.0) bsdf.inputs["Roughness"].default_value = rough bsdf.inputs["Metallic"].default_value = metal return m def assign(obj, material): obj.data.materials.clear() obj.data.materials.append(material) return obj def shade_smooth(obj, angle_deg=35.0): """Smooth-by-angle. Blender 4.1+ dropped mesh.use_auto_smooth for an operator.""" bpy.context.view_layer.objects.active = obj obj.select_set(True) try: bpy.ops.object.shade_auto_smooth(angle=math.radians(angle_deg)) except Exception: bpy.ops.object.shade_smooth() obj.select_set(False) def bevel(obj, width=0.0015, segments=2): m = obj.modifiers.new("bevel", "BEVEL") m.width = width m.segments = segments m.limit_method = 'ANGLE' m.angle_limit = math.radians(40) def join(objs, name): """Join a list of meshes into one object (the first is the target).""" objs = [o for o in objs if o is not None] if not objs: return None if len(objs) == 1: objs[0].name = name return objs[0] bpy.ops.object.select_all(action='DESELECT') for o in objs: o.select_set(True) bpy.context.view_layer.objects.active = objs[0] bpy.ops.object.join() joined = bpy.context.view_layer.objects.active joined.name = name bpy.ops.object.select_all(action='DESELECT') return joined def apply_modifiers(obj): bpy.context.view_layer.objects.active = obj for m in list(obj.modifiers): try: bpy.ops.object.modifier_apply(modifier=m.name) except Exception: obj.modifiers.remove(m) # ---------------------------------------------------------------- primitive builders def cyl(r, depth, loc=(0, 0, 0), rot=(0, 0, 0), verts=16, r2=None): """Cylinder / tapered cone along +Z, centred on `loc`.""" if r2 is None: bpy.ops.mesh.primitive_cylinder_add(radius=r, depth=depth, vertices=verts, location=loc, rotation=rot) else: bpy.ops.mesh.primitive_cone_add(radius1=r, radius2=r2, depth=depth, vertices=verts, location=loc, rotation=rot) return bpy.context.active_object def box(size, loc=(0, 0, 0), rot=(0, 0, 0)): # primitive_cube_add(size=s) already spans -s/2..+s/2, i.e. SIDE LENGTH s. # Scaling by size/2 on top of that halves every box — which is exactly what # detached the cricket bat's blade from its handle the first time round. bpy.ops.mesh.primitive_cube_add(size=1.0, location=loc, rotation=rot) o = bpy.context.active_object o.scale = (size[0], size[1], size[2]) bpy.ops.object.transform_apply(scale=True) return o def ball(r, loc=(0, 0, 0), segs=12, rings=8): bpy.ops.mesh.primitive_uv_sphere_add(radius=r, segments=segs, ring_count=rings, location=loc) return bpy.context.active_object def segment_between(p0, p1, r0, r1, verts=10): """A tapered tube from p0 to p1. Used for arm bones.""" p0, p1 = Vector(p0), Vector(p1) d = p1 - p0 length = d.length if length < 1e-6: return None o = cyl(r0, length, loc=(0, 0, 0), verts=verts, r2=r1) quat = Vector((0, 0, 1)).rotation_difference(d.normalized()) o.matrix_world = Matrix.Translation(p0 + d * 0.5) @ quat.to_matrix().to_4x4() bpy.ops.object.transform_apply(location=True, rotation=True) return o def seg_box(p0, p1, thick, width, bev=0.006, segs=3): """A heavily-bevelled box spanning p0->p1. `thick` is the radial dimension, `width` the across-the-knuckles one. This is the glove's building block: fat rounded slabs, not thin tubes, so adjacent fingers read as one mitt.""" p0, p1 = Vector(p0), Vector(p1) d = p1 - p0 length = d.length if length < 1e-6: return None o = box((thick, length, width)) quat = Vector((0, 1, 0)).rotation_difference(d.normalized()) o.matrix_world = Matrix.Translation(p0 + d * 0.5) @ quat.to_matrix().to_4x4() bpy.ops.object.transform_apply(location=True, rotation=True) bevel(o, width=bev, segments=segs) apply_modifiers(o) return o # ---------------------------------------------------------------- the hand # # A chunky WORK GLOVE, not an anatomical hand. Two reasons: a viewmodel hand is 40 cm # from the lens and half off-screen, where big readable masses beat fourteen thin # phalanges (which just read as loose sausages); and a work glove is exactly right for # a game about clocking out and wrecking your workplace. # # Grip bore axis = +Z, palm on -X, fingers wrap over +Y -> +X -> -Y. # `curl` scales how far the fingers close: 1.0 = fist around a handle, 0.0 = flat. FINGER_Z = [0.033, 0.011, -0.011, -0.033] # index -> pinky, along the grip FINGER_LEN = [ (0.030, 0.023, 0.017), (0.032, 0.025, 0.018), (0.029, 0.023, 0.017), (0.024, 0.019, 0.014), ] FINGER_W = [0.022, 0.023, 0.022, 0.019] # nearly touching -> one mitt FINGER_T = [0.021, 0.022, 0.021, 0.018] # radial thickness GRIP_R = 0.019 # radius of the handle it closes on def build_finger(z, lengths, width, thick, curl, start_ang_deg, ring_r): """FK chain of 3 fat phalanges wrapping clockwise around the grip axis.""" parts = [] ang = math.radians(start_ang_deg) knuckle_ang = math.radians(start_ang_deg + 90.0) pos = Vector((ring_r * math.cos(knuckle_ang), ring_r * math.sin(knuckle_ang), z)) curls = [math.radians(50.0), math.radians(52.0), math.radians(44.0)] for i, ln in enumerate(lengths): d = Vector((math.cos(ang), math.sin(ang), 0.0)) nxt = pos + d * ln taper = 1.0 - i * 0.09 # overshoot each segment slightly so consecutive knuckles overlap and read solid s = seg_box(pos - d * 0.004, nxt + d * 0.004, thick * taper, width * taper, bev=0.0065, segs=3) if s: parts.append(s) pos = nxt ang -= curls[i] * curl return parts def build_hand(curl=1.0, name="hand_grip", skin=None, sleeve=None): """A right hand. The left is the same mesh mirrored by scale in Godot.""" parts = [] # --- palm: one rounded slab from the heel up to the knuckles --- palm = box((0.034, 0.082, 0.101), loc=(-(GRIP_R + 0.014), 0.000, -0.002)) bevel(palm, width=0.014, segments=4) apply_modifiers(palm) parts.append(palm) # heel, overlapping the palm so the two merge into one mass toward the wrist heel = box((0.036, 0.062, 0.086), loc=(-(GRIP_R + 0.012), -0.012, -0.040)) bevel(heel, width=0.016, segments=4) apply_modifiers(heel) parts.append(heel) # --- four fingers --- ring_r = GRIP_R + FINGER_T[0] * 0.5 for i, z in enumerate(FINGER_Z): start = 24.0 + (1.0 - curl) * 30.0 # opening the hand unwinds the start parts += build_finger(z, FINGER_LEN[i], FINGER_W[i], FINGER_T[i], curl, start, ring_r) # --- thumb: two chunky segments crossing the front of the fingers --- t_pos = Vector((-(GRIP_R + 0.008), -0.030, 0.048)) t_ang = math.radians(62.0 - 44.0 * curl) for i, ln in enumerate((0.036, 0.028)): d = Vector((math.cos(t_ang), math.sin(t_ang), 0.0)) nxt = t_pos + d * ln s = seg_box(t_pos - d * 0.005, nxt + d * 0.004, 0.026 - i * 0.003, 0.027 - i * 0.003, bev=0.0085, segs=3) if s: parts.append(s) t_pos = nxt t_ang -= math.radians(44.0) * curl hand = join(parts, name) assign(hand, skin) shade_smooth(hand, 34.0) # --- wrist cuff: overlaps the heel so the glove meets the sleeve with no gap --- cuff = cyl(0.040, 0.052, loc=(-0.014, -0.006, -0.066), r2=0.044, verts=18) bevel(cuff, width=0.004, segments=2) apply_modifiers(cuff) assign(cuff, sleeve) shade_smooth(cuff, 34.0) cuff.name = name + "_cuff" return hand, cuff # ---------------------------------------------------------------- arm segments def build_arm(skin, sleeve): """Upper arm and forearm, origin at the proximal joint, extending +Z.""" out = {} # forearm: elbow -> wrist, 0.26 m, tapering, with the shirt cuff rolled at the elbow fore = segment_between((0, 0, 0), (0, 0, 0.255), 0.049, 0.033, verts=14) fore.name = "forearm" assign(fore, skin) shade_smooth(fore, 40.0) roll = cyl(0.056, 0.052, loc=(0, 0, 0.012), r2=0.050, verts=16) assign(roll, sleeve) shade_smooth(roll, 40.0) roll.name = "forearm_sleeve" out["forearm"] = [fore, roll] # upper arm: shoulder -> elbow, sleeved almost the whole way (short-sleeve tee) upper = segment_between((0, 0, 0), (0, 0, 0.275), 0.058, 0.050, verts=14) upper.name = "upperarm" assign(upper, skin) shade_smooth(upper, 40.0) sleeve_m = segment_between((0, 0, -0.010), (0, 0, 0.150), 0.068, 0.058, verts=16) sleeve_m.name = "upperarm_sleeve" assign(sleeve_m, sleeve) shade_smooth(sleeve_m, 40.0) out["upperarm"] = [upper, sleeve_m] return out # ---------------------------------------------------------------- weapons # Grip centre at origin, shaft +Z. Each returns a list of objects to join. def w_bat(M): """Cricket bat — the founding doc's first weapon. Willow blade, rubber grip.""" parts = [] handle = cyl(0.0175, 0.30, loc=(0, 0, 0.09), r2=0.0195, verts=14) parts.append(assign(handle, M["rubber"])) for i in range(7): # grip rings r = cyl(0.0198, 0.008, loc=(0, 0, -0.045 + i * 0.030), verts=14) parts.append(assign(r, M["rubber"])) shoulder = cyl(0.021, 0.075, loc=(0, 0, 0.276), r2=0.030, verts=14) parts.append(assign(shoulder, M["willow"])) blade = box((0.108, 0.042, 0.430), loc=(0, 0.004, 0.525)) bevel(blade, width=0.006, segments=2) apply_modifiers(blade) parts.append(assign(blade, M["willow"])) spine = box((0.052, 0.030, 0.380), loc=(0, -0.030, 0.520)) # the ridge on the back bevel(spine, width=0.010, segments=2) apply_modifiers(spine) parts.append(assign(spine, M["willow"])) toe = box((0.108, 0.042, 0.030), loc=(0, 0.004, 0.742)) bevel(toe, width=0.012, segments=2) apply_modifiers(toe) parts.append(assign(toe, M["willow"])) return parts def w_sledge(M): """Sledgehammer — slow, enormous, the doc's answer to heavy wooden racks.""" parts = [] haft = cyl(0.019, 0.78, loc=(0, 0, 0.30), r2=0.024, verts=14) parts.append(assign(haft, M["hickory"])) for i in range(5): r = cyl(0.0205, 0.010, loc=(0, 0, -0.075 + i * 0.036), verts=14) parts.append(assign(r, M["rubber"])) head = box((0.098, 0.098, 0.215), loc=(0, 0, 0.700), rot=(math.radians(90), 0, 0)) bevel(head, width=0.007, segments=2) apply_modifiers(head) parts.append(assign(head, M["steel"])) collar = cyl(0.030, 0.055, loc=(0, 0, 0.678), verts=14) parts.append(assign(collar, M["steel"])) for s in (-1, 1): # slightly domed striking faces face = cyl(0.043, 0.016, loc=(0, s * 0.109, 0.700), rot=(math.radians(90), 0, 0), verts=16) parts.append(assign(face, M["steel_dark"])) return parts def w_crowbar(M): """Crowbar — medium speed, bites into steel. Painted red, worn to bare metal.""" parts = [] shaft = cyl(0.0115, 0.62, loc=(0, 0, 0.18), verts=6) # hex stock parts.append(assign(shaft, M["paint_red"])) grip = cyl(0.0135, 0.14, loc=(0, 0, -0.055), verts=6) parts.append(assign(grip, M["rubber"])) # the curved claw: short chords stepping through ~85 degrees ang = 0.0 pos = Vector((0, 0, 0.49)) for i in range(6): d = Vector((0.0, math.sin(ang), math.cos(ang))) nxt = pos + d * 0.030 s = segment_between(tuple(pos), tuple(nxt), 0.0115, 0.0112, verts=6) if s: parts.append(assign(s, M["steel"])) pos = nxt ang += math.radians(15.5) claw = box((0.026, 0.050, 0.014), loc=(0, pos.y + 0.020, pos.z + 0.004), rot=(math.radians(-22), 0, 0)) bevel(claw, width=0.003, segments=2) apply_modifiers(claw) parts.append(assign(claw, M["steel"])) chisel = box((0.028, 0.011, 0.055), loc=(0, 0, -0.150), rot=(0, math.radians(9), 0)) bevel(chisel, width=0.003, segments=2) apply_modifiers(chisel) parts.append(assign(chisel, M["steel"])) return parts def w_cutter(M): """Box cutter — useless on furniture, devastating on cardboard and paper.""" parts = [] body = box((0.020, 0.038, 0.150), loc=(0, 0, 0.030)) bevel(body, width=0.005, segments=2) apply_modifiers(body) parts.append(assign(body, M["plastic_yellow"])) track = box((0.022, 0.012, 0.100), loc=(0, 0.016, 0.030)) parts.append(assign(track, M["steel_dark"])) slider = box((0.014, 0.010, 0.022), loc=(0, 0.024, 0.020)) parts.append(assign(slider, M["steel"])) blade = box((0.010, 0.030, 0.062), loc=(0, 0.002, 0.132), rot=(math.radians(-8), 0, 0)) parts.append(assign(blade, M["blade"])) tip = box((0.010, 0.020, 0.020), loc=(0, -0.006, 0.168), rot=(math.radians(-32), 0, 0)) parts.append(assign(tip, M["blade"])) return parts def w_extinguisher(M): """Fire extinguisher — heavy two-hand swing, and the best throwable in the store.""" parts = [] bottle = cyl(0.058, 0.330, loc=(0, 0, -0.035), verts=20) parts.append(assign(bottle, M["paint_red"])) for z, r in ((-0.200, 0.052), (0.130, 0.050)): # domed ends d = ball(r, loc=(0, 0, z), segs=20, rings=10) d.scale = (1.12, 1.12, 0.62) bpy.ops.object.transform_apply(scale=True) parts.append(assign(d, M["paint_red"])) band = cyl(0.060, 0.045, loc=(0, 0, 0.020), verts=20) parts.append(assign(band, M["paint_dark"])) neck = cyl(0.020, 0.070, loc=(0, 0, 0.175), verts=14) parts.append(assign(neck, M["steel_dark"])) head = box((0.048, 0.062, 0.048), loc=(0, 0, 0.212)) bevel(head, width=0.005, segments=2) apply_modifiers(head) parts.append(assign(head, M["steel_dark"])) lever = box((0.030, 0.100, 0.014), loc=(0, 0.034, 0.240), rot=(math.radians(10), 0, 0)) bevel(lever, width=0.004, segments=2) apply_modifiers(lever) parts.append(assign(lever, M["steel"])) carry = box((0.028, 0.086, 0.013), loc=(0, 0.030, 0.196)) bevel(carry, width=0.004, segments=2) apply_modifiers(carry) parts.append(assign(carry, M["steel_dark"])) # hose looping down the side pos = Vector((0.0, 0.058, 0.196)) ang = math.radians(96) for i in range(7): d = Vector((0.0, math.cos(ang), -math.sin(ang))) nxt = pos + d * 0.042 s = segment_between(tuple(pos), tuple(nxt), 0.010, 0.010, verts=6) if s: parts.append(assign(s, M["rubber"])) pos = nxt ang -= math.radians(13) horn = cyl(0.014, 0.070, loc=tuple(pos + Vector((0, 0.012, -0.030))), r2=0.030, verts=14) parts.append(assign(horn, M["paint_dark"])) return parts # ---------------------------------------------------------------- export / render def export(obj_or_objs, name): objs = obj_or_objs if isinstance(obj_or_objs, list) else [obj_or_objs] objs = [o for o in objs if o is not None] bpy.ops.object.select_all(action='DESELECT') for o in objs: o.select_set(True) bpy.context.view_layer.objects.active = objs[0] path = os.path.join(OUT_DIR, name + ".glb") bpy.ops.export_scene.gltf( filepath=path, export_format='GLB', use_selection=True, export_apply=True, export_yup=True, ) tris = sum(len(o.data.loop_triangles) if o.data.loop_triangles else 0 for o in objs) print("[gen] %-18s -> %s" % (name, os.path.basename(path))) bpy.ops.object.select_all(action='DESELECT') def render_preview(name): """Optional turnaround still, so the generator can be checked without Godot.""" scene = bpy.context.scene # engine id moved around across versions (EEVEE -> EEVEE_NEXT -> EEVEE); take what exists 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 = 640 scene.render.resolution_y = 640 scene.render.film_transparent = False # read_factory_settings(use_empty=True) leaves no world, so renders come out black 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 is not None: bg.inputs[0].default_value = (0.05, 0.05, 0.06, 1.0) bg.inputs[1].default_value = 1.0 # --- frame whatever is in the scene: these assets range 13 cm to 85 cm, so a # --- fixed camera either crops the sledge or loses the box cutter in the distance. meshes = [o for o in scene.objects if o.type == 'MESH'] if not meshes: return lo = Vector((1e9, 1e9, 1e9)) hi = Vector((-1e9, -1e9, -1e9)) for o in meshes: 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.02) dist = radius * 3.0 direction = Vector((0.62, -0.72, 0.36)).normalized() bpy.ops.object.camera_add(location=tuple(centre + direction * dist)) cam = bpy.context.active_object cam.data.lens = 55 # point the camera down its -Z at the centre cam.rotation_mode = 'QUATERNION' cam.rotation_quaternion = (-direction).to_track_quat('-Z', 'Y') scene.camera = cam # lights scale with the subject so a 13 cm cutter isn't lit like an 85 cm sledge for offset, energy, size in ((Vector((1.1, -1.0, 1.2)), 55.0, 1.2), (Vector((-1.2, -0.5, 0.2)), 16.0, 1.8)): bpy.ops.object.light_add(type='AREA', location=tuple(centre + offset * dist)) L = bpy.context.active_object L.data.energy = energy * (dist ** 2) L.data.size = size * radius scene.render.filepath = os.path.join(PREVIEW_DIR, "" + name + ".png") bpy.ops.render.render(write_still=True) def materials(): return { "skin": mat("skin", (0.78, 0.55, 0.44), rough=0.72), "sleeve": mat("sleeve", (0.13, 0.13, 0.17), rough=0.85), "willow": mat("willow", (0.80, 0.68, 0.47), rough=0.62), "hickory": mat("hickory", (0.52, 0.36, 0.20), rough=0.68), "rubber": mat("rubber", (0.07, 0.07, 0.08), rough=0.92), "steel": mat("steel", (0.62, 0.63, 0.66), rough=0.34, metal=1.0), "steel_dark": mat("steel_dark", (0.24, 0.25, 0.28), rough=0.46, metal=1.0), "blade": mat("blade", (0.86, 0.87, 0.90), rough=0.16, metal=1.0), "paint_red": mat("paint_red", (0.62, 0.06, 0.06), rough=0.40), "paint_dark": mat("paint_dark", (0.10, 0.10, 0.12), rough=0.55), "plastic_yellow": mat("plastic_yellow", (0.85, 0.66, 0.10), rough=0.44), } WEAPONS = { "bat": w_bat, "sledge": w_sledge, "crowbar": w_crowbar, "cutter": w_cutter, "extinguisher": w_extinguisher, } def main(): os.makedirs(OUT_DIR, exist_ok=True) os.makedirs(PREVIEW_DIR, exist_ok=True) # Hands and arms are NOT built here — they come from the GODVERSE modular # character kit via tools/gen_fps_arms.py, which yields a properly rigged pair # with per-finger mixamorig bones. The procedural glove this script used to emit # was a stopgap and read as loose sausages next to the real thing. # build_hand()/build_arm() are kept below as a no-dependency fallback. # --- weapons --- for name, builder in WEAPONS.items(): reset_scene() M = materials() parts = builder(M) for p in parts: apply_modifiers(p) export(parts, name) if RENDER: render_preview(name) print("[gen] done -> %s" % OUT_DIR) main()