SOURCE / PINNED RELEASE
Made of little things.
Outbreak: Soycoon City
- Release
- db7750b1d009…
- Author-recorded commit
- 3ee3f5fd27a6…
- License
- LICENSE
- Author’s source reference
- nostr://npub182jczunncwe0jn6frpqwq3e0qjws7yqqnc3auccqv9nte2dnd63scjm4rf/wss%3A%2F%2Fgit.napplet.soy%2F/n-b87c04571a9
Archive hash verified: dbebdc772b142a1c…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
"""Author low-poly rigs/actions in Blender and export editable .blend + embedded GLB.
Run: blender --background --factory-startup --python scripts/build-characters.py
"""
import bpy, math, json, hashlib, random
from pathlib import Path
from mathutils import Vector, Matrix
ROOT=Path(__file__).resolve().parent.parent
FPS=24
manifest={'version':1,'format':'glTF 2.0 / GLB','sources':'models/*.blend','models':[]}
def material(name,color):
m=bpy.data.materials.new(name);m.diffuse_color=(*color,1);m.use_nodes=True
shader=m.node_tree.nodes.get('Principled BSDF');shader.inputs['Base Color'].default_value=(*color,1);shader.inputs['Roughness'].default_value=1;shader.inputs['Metallic'].default_value=0
return m
def part(name,center,size,mat,bone,kind='box',bevel=0):
if kind=='ball':bpy.ops.mesh.primitive_uv_sphere_add(segments=8,ring_count=4,location=center)
elif kind=='tube':bpy.ops.mesh.primitive_cone_add(vertices=6,radius1=1,radius2=.78,depth=2,location=center)
else:bpy.ops.mesh.primitive_cube_add(size=2,location=center)
obj=bpy.context.object;obj.name=name;obj.scale=tuple(s/2 for s in size);bpy.ops.object.transform_apply(location=False,rotation=False,scale=True)
if bevel:
mod=obj.modifiers.new('Soft silhouette','BEVEL');mod.width=bevel;mod.segments=1;bpy.ops.object.modifier_apply(modifier=mod.name)
obj.data.materials.append(mat);group=obj.vertex_groups.new(name=bone);group.add(list(range(len(obj.data.vertices))),1,'REPLACE')
return obj
def link_segment(name,a,b,width,depth,mat,bone):
obj=part(name,(Vector(a)+Vector(b))/2,(width,depth,(Vector(b)-Vector(a)).length),mat,bone,'tube')
obj.rotation_euler=(Vector(b)-Vector(a)).to_track_quat('Z','Y').to_euler();bpy.ops.object.transform_apply(location=False,rotation=True,scale=False);return obj
def combine(objects,name,rig):
bpy.ops.object.select_all(action='DESELECT')
for o in objects:o.select_set(True)
bpy.context.view_layer.objects.active=objects[0];bpy.ops.object.join();obj=bpy.context.object;obj.name=name
mod=obj.modifiers.new('Rig','ARMATURE');mod.object=rig;obj.parent=rig;obj.matrix_parent_inverse=rig.matrix_world.inverted();return obj
def torso(mat):
rings=[(.99,.29,.22),(1.16,.32,.245),(1.43,.445,.255),(1.52,.29,.205)]
vertices=[]
for z,w,d in rings:
for i in range(8):
a=math.pi/8+i*math.pi/4;vertices.append((math.cos(a)*w/2,math.sin(a)*d/2,z))
faces=[tuple(reversed(range(8))),tuple(range(24,32))]
for ring in range(3):
for i in range(8):faces.append((ring*8+i,ring*8+(i+1)%8,(ring+1)*8+(i+1)%8,(ring+1)*8+i))
mesh=bpy.data.meshes.new('Tapered shirt');mesh.from_pydata(vertices,[],faces);mesh.materials.append(mat)
obj=bpy.data.objects.new('Tapered shirt',mesh);bpy.context.collection.objects.link(obj)
for name in ['Spine','Chest']:
group=obj.vertex_groups.new(name=name);group.add([i for i,v in enumerate(vertices) if (v[2]>=1.4)==(name=='Chest')],1,'REPLACE')
return obj
def create_rig(tofu):
h=.72 if tofu else .91;shoulder=1.38 if tofu else 1.5;knee=.37 if tofu else .49;ankle=.1
bpy.ops.object.armature_add();rig=bpy.context.object;rig.name='Soybert' if tofu else 'PrecinctZombie';bpy.ops.object.mode_set(mode='EDIT');bones=rig.data.edit_bones;bones.remove(bones[0])
defs=[('Root',(0,0,0),(0,0,.15),None),('Hips',(0,0,h),(0,0,h+.16),'Root'),('Spine',(0,0,h+.16),(0,0,shoulder-.16),'Hips'),('Chest',(0,0,shoulder-.16),(0,0,shoulder+.08),'Spine'),('Head',(0,0,1.48 if tofu else 1.66),(0,0,1.78 if tofu else 1.90),'Chest')]
for side,s in [('L',-1),('R',1)]:
x=s*(.17 if tofu else .105);armx=s*(.365 if tofu else .235)
defs += [(f'Thigh_{side}',(x,0,h),(x,0,knee),'Hips'),(f'Shin_{side}',(x,0,knee),(x,0,ankle),f'Thigh_{side}'),(f'Foot_{side}',(x,0,ankle),(x,-.18,.08),f'Shin_{side}'),(f'Arm_{side}',(armx,-.1 if tofu else 0,shoulder),(armx,-.1 if tofu else 0,shoulder-(.33 if tofu else .28)),'Chest'),(f'Forearm_{side}',(armx,-.1 if tofu else 0,shoulder-(.33 if tofu else .28)),(armx,-.1 if tofu else 0,shoulder-(.66 if tofu else .56)),f'Arm_{side}'),(f'Hand_{side}',(armx,-.1 if tofu else 0,shoulder-(.66 if tofu else .56)),(armx,-.1 if tofu else 0,shoulder-(.76 if tofu else .66)),f'Forearm_{side}')]
if tofu:defs += [('Weapon',(0.0,-.13,1.17),(0.0,-.13,1.30),'Chest'),('Muzzle',(0.0,-1.11,1.20),(0.0,-1.11,1.28),'Weapon')]
for name,a,b,parent in defs:
bone=bones.new(name);bone.head=a;bone.tail=b
if parent:bone.parent=bones[parent]
bpy.ops.object.mode_set(mode='OBJECT');rig.show_in_front=True
for bone in rig.pose.bones:bone.rotation_mode='XYZ'
return rig,defs
def mesh_character(tofu,rig,defs):
cream=material('Tofu / warm ivory',(.84,.72,.46));pale=material('Tofu / highlight',(.98,.90,.66));pore=material('Tofu / pores',(.63,.43,.20));dark=material('Face / charcoal',(.033,.016,.009));blush=material('Face / blush',(.72,.22,.20));white=material('Face / eye glint',(1,.96,.80))
shirt=material('Uniform',(.15,.24,.25));skin=material('Undead / skin',(.38,.43,.30));trousers=material('Trousers',(.085,.11,.105));blood=material('Old blood',(.28,.035,.03));shoe=material('Boots',(.035,.044,.04));wood=material('Shotgun / walnut',(.24,.085,.025));metal=material('Shotgun / steel',(.10,.11,.105));brass=material('Badge / brass',(.61,.45,.19))
body=[];head=[];weapon=[]
if tofu:
body += [part('Tofu block',(0,.015,1.25),(.64,.39,1.03),cream,'Spine',bevel=.045),part('Tofu top',(0,.01,1.76),(.60,.365,.04),pale,'Spine',bevel=.015)]
for side in [-1,1]:
body.append(part('eye',(side*.145,-.196,1.45),(.088,.024,.116),dark,'Spine','ball'));body.append(part('glint',(side*.145-.012,-.211,1.48),(.024,.012,.028),white,'Spine','ball'));body.append(part('cheek',(side*.225,-.195,1.32),(.12,.018,.072),blush,'Spine','ball'))
for i in range(7):
t=math.pi*(i/6);body.append(part('smile',(.08*math.cos(t),-.20,1.29-.043*math.sin(t)),(.03,.014,.028),dark,'Spine','ball'))
rng=random.Random(17)
for i in range(12):body.append(part('pore',(rng.uniform(-.27,.27),-.187,rng.uniform(.82,1.68)),(.017,.012,.012),pore,'Spine','ball'))
else:
body += [part('Pelvis',(0,.025,.92),(.30,.22,.22),trousers,'Hips',bevel=.045),torso(shirt),part('Neck',(0,0,1.60),(.115,.12,.14),skin,'Head','tube')]
head += [part('head_skin',(0,-.005,1.775),(.23,.225,.255),skin,'Head','ball'),part('head_hair',(0,.02,1.875),(.235,.225,.064),dark,'Head','ball'),part('head_nose',(0,-.14,1.765),(.047,.065,.067),skin,'Head',bevel=.012)]
for side in [-1,1]:head += [part('head_eye',(side*.060,-.111,1.81),(.045,.017,.022),dark,'Head'),part('head_ear',(side*.128,0,1.78),(.035,.065,.082),skin,'Head','ball')]
head += [part('head_jaw',(0,-.125,1.70),(.10,.015,.025),blood,'Head'),part('head_teeth',(0,-.134,1.713),(.085,.007,.012),white,'Head')]
body += [part('Police badge',(-.11,-.12,1.36),(.055,.012,.085),brass,'Spine'),part('Shirt placket',(0,-.126,1.24),(.022,.012,.35),trousers,'Spine'),part('Belt',(0,-.003,1.0),(.32,.245,.055),shoe,'Hips')]
rng=random.Random(31)
for i in range(9):body.append(part('Torn shirt',(.13+rng.uniform(-.075,.04),-.124,1.24+rng.uniform(-.13,.12)),(.015+rng.random()*.065,.014,.02+rng.random()*.06),blood,'Spine'))
bones={name:(a,b) for name,a,b,parent in defs}
for side in ['L','R']:
for prefix,width,depth,mat in [('Thigh',.125 if tofu else .145,.135 if tofu else .16,cream if tofu else trousers),('Shin',.095 if tofu else .12,.11 if tofu else .13,cream if tofu else trousers),('Arm',.11 if tofu else .12,.12 if tofu else .13,cream if tofu else shirt),('Forearm',.095 if tofu else .10,.10 if tofu else .11,cream if tofu else skin)]:
name=f'{prefix}_{side}';a,b=bones[name];body.append(link_segment(name,a,b,width,depth,mat,name))
a,b=bones[f'Hand_{side}'];body.append(part('Hand',(Vector(a)+Vector(b))/2,(.115,.085,.12),cream if tofu else skin,f'Hand_{side}','ball'))
a,b=bones[f'Foot_{side}'];body.append(part('Foot',(a[0],-.075,.065),(.15 if tofu else .155,.28,.13),cream if tofu else shoe,f'Foot_{side}',bevel=.035))
if tofu:
weapon += [part('Stock',(0.0,-.205,1.16),(.115,.32,.125),wood,'Weapon',bevel=.018),part('Receiver',(0.0,-.46,1.185),(.10,.22,.13),metal,'Weapon',bevel=.012),link_segment('Barrel',(0.0,-.48,1.22),(0.0,-1.08,1.22),.046,.046,metal,'Weapon'),link_segment('Magazine',(0.0,-.52,1.16),(0.0,-.91,1.16),.042,.042,metal,'Weapon'),part('Pump',(0.0,-.58,1.145),(.11,.22,.10),wood,'Weapon',bevel=.013),part('Trigger',(0.0,-.37,1.105),(.025,.055,.058),metal,'Weapon')]
meshes=[combine(body,'BodyMesh',rig)]
if head:meshes.append(combine(head,'HeadMesh',rig))
if weapon:meshes.append(combine(weapon,'ShotgunMesh',rig))
return meshes
def pose(rig,name,t,tofu):
for bone in rig.pose.bones:bone.rotation_euler=(0,0,0);bone.location=(0,0,0);bone.scale=(1,1,1)
phase=t*math.pi*2
def rot(b,x=0,y=0,z=0):rig.pose.bones[b].rotation_euler=(x,y,z)
def loc(b,x=0,y=0,z=0):rig.pose.bones[b].location=(x,y,z)
if tofu:
for side in ['L','R']:rot(f'Arm_{side}',-1.2 if side=='L' else -1.45,0,.40 if side=='L' else -.40);rot(f'Forearm_{side}',.15 if side=='L' else 1.0)
if name in ['Aim','AimUp','Shoot','ShootUp','WalkAim','WalkAimUp']:
for side in ['L','R']:rot(f'Arm_{side}',-1.25 if side=='L' else -1.45,0,.40 if side=='L' else -.40);rot(f'Forearm_{side}',.15 if side=='L' else 1.0)
if name in ['AimUp','ShootUp','WalkAimUp']:
rot('Chest',-.30);rot('Weapon',-.37)
for side in ['L','R']:rot(f'Arm_{side}',-1.40,0,.36 if side=='L' else -.36);rot(f'Forearm_{side}',.62)
if name in ['Shoot','ShootUp']:
kick=max(0,1-t/.35) if t>0 else 0
rot('Spine',-.10*kick);rig.pose.bones['Weapon'].rotation_euler.x-=.30*kick;loc('Weapon',0,-.045*kick,0)
for side in ['L','R']:rig.pose.bones[f'Forearm_{side}'].rotation_euler.x+=.24*kick
if name=='Reload':
rot('Weapon',.28+math.sin(t*math.pi)*.08,0,-.22);rot('Arm_L',-.50,0,.22);rot('Forearm_L',.65+math.sin(phase*2)*.38);rot('Arm_R',-.72);rot('Forearm_R',.6)
else:
rot('Spine',.12);rot('Head',-.08,0,.08)
for side in ['L','R']:rot(f'Arm_{side}',-1.08+(side=='L')*.22,0,.17 if side=='L' else -.10);rot(f'Forearm_{side}',.18)
if name=='Attack':
lunge=math.sin(math.pi*min(1,t/.55)) if t<.55 else 0
rot('Spine',.12+.23*lunge);loc('Root',0,0,-.11*lunge)
for side in ['L','R']:rot(f'Arm_{side}',-1.35-.2*lunge);rot(f'Forearm_{side}',.08)
if name in ['Walk','Run','WalkAim','WalkAimUp']:
amount=.65 if name=='Run' else .35 if tofu else .26
for side,s in [('L',1),('R',-1)]:
rot(f'Thigh_{side}',math.sin(phase)*amount*s);rot(f'Shin_{side}',max(0,-math.sin(phase)*s)*.54);rot(f'Foot_{side}',-.06*math.sin(phase)*s)
loc('Hips',0,.015*math.cos(phase*2),0);rig.pose.bones['Spine'].rotation_euler.z+=math.sin(phase)*.03
if name in ['Idle','Aim','AimUp']:
rig.pose.bones['Chest'].rotation_euler.x+=math.sin(phase)*.012
if name=='Hit':rot('Spine',-.24*math.sin(math.pi*t));rot('Head',.12*math.sin(math.pi*t));rot('Root',0,0,.06*math.sin(math.pi*t))
if name=='Death':
collapse=min(1,max(0,(t-.12)/.88));rot('Root',-(math.pi/2)*collapse,0,.10*collapse);loc('Root',0,.16*collapse,0)
for side in ['L','R']:rot(f'Thigh_{side}',-.12*collapse);rot(f'Shin_{side}',.20*collapse);rot(f'Arm_{side}',-.3*collapse,0,.50*collapse if side=='L' else -.50*collapse)
rot('Head',.16*collapse)
if tofu and name not in ['Death']:
# Bake a two-bone solve into ordinary FK keys. Artists can edit the
# resulting actions without runtime constraints or engine-specific IK.
bpy.context.view_layer.update()
weapon=rig.pose.bones['Weapon'];rest=rig.data.bones['Weapon'].matrix_local
for side,grip in [('L',(0,-.58,1.13)),('R',(0,-.33,1.12))]:
arm=rig.pose.bones[f'Arm_{side}'];fore=rig.pose.bones[f'Forearm_{side}'];hand=rig.pose.bones[f'Hand_{side}']
a=arm.head.copy();target=weapon.matrix @ rest.inverted() @ Vector(grip)
direction=(target-a).normalized();distance=min((target-a).length,arm.length+fore.length-.001)
along=(arm.length**2-fore.length**2+distance**2)/(2*distance)
pole=Vector((-.3 if side=='L' else .3,.2,-1));pole=(pole-direction*pole.dot(direction)).normalized()
elbow=a+direction*along+pole*math.sqrt(max(0,arm.length**2-along**2))
arm.matrix=Matrix.LocRotScale(a,(elbow-a).to_track_quat('Y','Z'),Vector((1,1,1)));bpy.context.view_layer.update()
fore.matrix=Matrix.LocRotScale(elbow,(target-elbow).to_track_quat('Y','Z'),Vector((1,1,1)));bpy.context.view_layer.update()
hand.rotation_euler=(0,0,0)
def animate(rig,tofu):
durations={'Idle':2,'Walk':1.05 if tofu else 1.7,'Hit':.45,'Death':1.45}
if tofu:durations.update({'Run':.65,'Aim':2,'AimUp':2,'WalkAim':1.25,'WalkAimUp':1.25,'Shoot':.55,'ShootUp':.55,'Reload':1.5})
else:durations['Attack']=.85
for name,duration in durations.items():
rig.animation_data_create();action=bpy.data.actions.new(name);action.use_fake_user=True;rig.animation_data.action=action
frames=max(2,round(duration*FPS))
keys=sorted(set([1,2,*range(4,frames+1,3),frames]))
for frame in keys:
bpy.context.scene.frame_set(frame);pose(rig,name,(frame-1)/(frames-1),tofu)
for bone in rig.pose.bones:
bone.keyframe_insert(data_path='rotation_euler',frame=frame);bone.keyframe_insert(data_path='location',frame=frame)
track=rig.animation_data.nla_tracks.new();track.name=name;strip=track.strips.new(name,1,action);track.mute=True
rig.animation_data.action=bpy.data.actions['Idle'];pose(rig,'Idle',0,tofu);bpy.context.scene.frame_set(1)
return durations
def presentation(rig):
scene=bpy.context.scene;scene.world=scene.world or bpy.data.worlds.new('Studio');scene.world.color=(.08,.08,.08);scene.render.engine='BLENDER_EEVEE';scene.render.resolution_x=640;scene.render.resolution_y=640;scene.render.resolution_percentage=100
bpy.ops.object.camera_add(location=(3.0,-5.5,2.6));camera=bpy.context.object;camera.name='Presentation camera';camera.rotation_euler=(Vector((0,0,1.05))-camera.location).to_track_quat('-Z','Y').to_euler();camera.data.type='ORTHO';camera.data.ortho_scale=2.55;scene.camera=camera
for position,energy,size in [((1,-3,4),500,4),((-3,-1,2),240,3),((0,3,3),450,2)]:
bpy.ops.object.light_add(type='AREA',location=position);light=bpy.context.object;light.data.energy=energy;light.data.shape='DISK';light.data.size=size;light.rotation_euler=(Vector((0,0,1))-light.location).to_track_quat('-Z','Y').to_euler()
for screen in bpy.data.screens:
for area in screen.areas:
if area.type=='VIEW_3D':area.spaces.active.region_3d.view_distance=3;area.spaces.active.region_3d.view_location=(0,0,1.0)
for kind in ['soybert','zombie']:
bpy.ops.wm.read_factory_settings(use_empty=True);bpy.context.preferences.filepaths.save_version=0;bpy.context.scene.render.fps=FPS;bpy.context.scene.unit_settings.system='METRIC'
tofu=kind=='soybert';rig,defs=create_rig(tofu);meshes=mesh_character(tofu,rig,defs);clips=animate(rig,tofu);presentation(rig)
source=ROOT/'models'/f'{kind}.blend';target=ROOT/'src/models'/f'{kind}.glb'
bpy.ops.wm.save_as_mainfile(filepath=str(source),compress=True)
bpy.ops.object.select_all(action='DESELECT');rig.select_set(True)
for mesh in meshes:mesh.select_set(True)
bpy.context.view_layer.objects.active=rig
bpy.ops.export_scene.gltf(filepath=str(target),export_format='GLB',use_selection=True,export_animations=True,export_animation_mode='ACTIONS',export_anim_single_armature=True,export_skins=True,export_yup=True,export_apply=False,export_extras=True)
binary=target.read_bytes();manifest['models'].append({'id':kind,'source':str(source.relative_to(ROOT)),'path':str(target.relative_to(ROOT)),'sha256':hashlib.sha256(binary).hexdigest(),'size':len(binary),'clips':list(clips),'bones':[d[0] for d in defs],'triangles':sum(len(poly.vertices)-2 for m in meshes for poly in m.data.polygons),'heightMeters':1.78 if tofu else 1.9})
# Artist preview kept outside the public source tree.
bpy.context.scene.render.filepath=str(ROOT/'.napplet-space/testing'/f'{kind}-blender.png');bpy.ops.render.render(write_still=True)
(ROOT/'models/manifest.json').write_text(json.dumps(manifest,indent=2)+'\n')
print('Exported editable character sources and animated GLBs')
