SOURCE / PINNED RELEASE
Made of little things.
Soybert 3D
- Release
- 4bf80e758af1…
- Author-recorded commit
- 298fa2d0e8b1…
- License
- LICENSE
- Author’s source reference
- nostr://npub1n8ga89w8h6tvwamxusfyzexw8gjy84yxu9rxgnmk955cxtml4ujswzxydd/wss%3A%2F%2Fgit.napplet.soy%2F/n-9021e1b6a96
Archive hash verified: 7083223a53bbd320…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
"""Run inside Blender (5.1). Builds only the Soybert collection and exports the rig."""
import bpy, math, json, base64, struct, random
from pathlib import Path
from mathutils import Vector, Matrix
ROOT = Path('/Users/schlaus/workspace/napplets/soybert-3d')
# Preserve unrelated scene work. Replace only our generated collection.
for action in list(bpy.data.actions):
if action.get('soybert_generated'):
bpy.data.actions.remove(action)
old = bpy.data.collections.get('Soybert')
if old:
for o in list(old.objects): bpy.data.objects.remove(o, do_unlink=True)
bpy.data.collections.remove(old)
coll = bpy.data.collections.new('Soybert')
bpy.context.scene.collection.children.link(coll)
for o in bpy.context.scene.objects:
if o.name in ('Cube','Camera','Light'): o.hide_set(True); o.hide_render=True
def own(o):
for c in list(o.users_collection): c.objects.unlink(o)
coll.objects.link(o)
return o
def material(name, color, roughness=.55, metallic=0):
m=bpy.data.materials.get(name) or bpy.data.materials.new(name)
m.diffuse_color=(*color,1)
m.use_nodes=True
p=m.node_tree.nodes.get('Principled BSDF')
p.inputs['Base Color'].default_value=(*color,1)
p.inputs['Roughness'].default_value=roughness
p.inputs['Metallic'].default_value=metallic
return m
# Material values are linear. Warm ivory remains bright under studio lighting.
ivory=material('Tofu · warm ivory',(.91,.77,.51),.48)
cream=material('Tofu · milk highlights',(1,.91,.70),.42)
brown=material('Face · cocoa',(.045,.016,.008),.23)
white=material('Eye · softbox reflections',(1,.99,.9),.18)
pink=material('Cheeks · strawberry',(.95,.23,.27),.65)
blush=material('Cheeks · hatch',(.65,.085,.10),.6)
pore=material('Tofu · pores',(.70,.46,.22),.9)
wood=material('Shotgun · walnut',(.25,.085,.025),.5)
woodlight=material('Shotgun · honey grain',(.46,.18,.054),.48)
steel=material('Shotgun · graphite',(.055,.061,.068),.34,.6)
edge=material('Shotgun · edge metal',(.18,.19,.20),.28,.7)
black=material('Shotgun · dark bore',(.009,.011,.012),.85)
parts=[]
def finish(o,name,mat,bone):
own(o); o.name=name; o.data.materials.append(mat)
for p in o.data.polygons: p.use_smooth=True
o['bone']=bone; parts.append(o)
return o
def ell(name,loc,scale,mat=ivory,bone='body'):
bpy.ops.mesh.primitive_uv_sphere_add(segments=20,ring_count=12,location=loc)
o=bpy.context.object; o.scale=scale
return finish(o,name,mat,bone)
def box(name,loc,dims,radius,mat=ivory,bone='body'):
bpy.ops.mesh.primitive_cube_add(size=1,location=loc)
o=bpy.context.object; o.scale=dims
bpy.ops.object.transform_apply(location=False,rotation=False,scale=True)
bevel=o.modifiers.new('Soft rounded edges','BEVEL'); bevel.width=radius; bevel.segments=6
bpy.ops.object.modifier_apply(modifier=bevel.name)
finish(o,name,mat,bone)
weighted=o.modifiers.new('Weighted corner normals','WEIGHTED_NORMAL'); weighted.keep_sharp=True; weighted.weight=50
return o
def tube(name,points,radius,mat,bone='body'):
data=bpy.data.curves.new(name,'CURVE'); data.dimensions='3D'; data.resolution_u=12
data.bevel_depth=radius; data.bevel_resolution=3
sp=data.splines.new('POLY'); sp.points.add(len(points)-1)
for p,co in zip(sp.points,points): p.co=(*co,1)
o=bpy.data.objects.new(name,data); coll.objects.link(o)
bpy.ops.object.select_all(action='DESELECT'); o.select_set(True); bpy.context.view_layer.objects.active=o
bpy.ops.object.convert(target='MESH')
return finish(bpy.context.object,name,mat,bone)
def rod(name,a,b,radius,mat,bone='shotgun'):
v=Vector(b)-Vector(a)
bpy.ops.mesh.primitive_cylinder_add(vertices=24,radius=radius,depth=v.length,location=(Vector(a)+Vector(b))/2)
o=bpy.context.object; o.rotation_euler=v.to_track_quat('Z','Y').to_euler()
bevel=o.modifiers.new('Rounded ends','BEVEL'); bevel.width=.018; bevel.segments=3
bpy.ops.object.modifier_apply(modifier=bevel.name)
return finish(o,name,mat,bone)
box('Tofu body',(0,0,1.65),(2.16,1.3,2.19),.24)
# A subtle top inset echoes the white rim in the illustration.
box('Cream top',(0,0,2.716),(1.88,1.06,.065),.03,cream)
for side in (-1,1):
x=.44*side
ell('Bright cocoa eye', (x,-.645,1.98),(.145,.072,.183),brown)
ell('Eye catchlight',(x-.035,-.706,2.042),(.043,.02,.047),white)
ell('Small eye sparkle',(x+.042,-.709,1.923),(.018,.01,.019),white)
ell('Rosy cheek',(.70*side,-.65,1.665),(.236,.034,.147),pink)
for off in (-.054,.054):
tube('Blush dash',[(.70*side+off-.024,-.687,1.64),(.70*side+off+.02,-.687,1.70)],.014,blush)
# U-shaped extruded smile with rounded ends.
smile=[]
for i in range(25):
t=math.pi+math.pi*i/24
smile.append((.205*math.cos(t),-.686,1.79+.145*math.sin(t)))
tube('Happy smile',smile,.038,brown)
for pt in (smile[0],smile[-1]): ell('Smile end',pt,(.038,.038,.038),brown)
# Deterministic small pores on front, top, sides and back.
rng=random.Random(19)
for x,z,s in [(-.84,2.36,.045),(.82,2.36,.033),(-.25,2.47,.03),(.56,2.55,.042),(-.87,1.07,.035),(.87,1.3,.045),(.63,.78,.03),(-.39,.73,.036),(.26,2.39,.024)]:
ell('Front pore',(x,-.649,z),(s,.007,s*.73),pore)
for i in range(11):
x=rng.uniform(-.79,.79); y=rng.uniform(-.43,.43); s=rng.uniform(.026,.05)
ell('Top pore',(x,y,2.754),(s,s*.7,.006),pore)
for side in (-1,1):
for i in range(9):
y=rng.uniform(-.36,.36); z=rng.uniform(.84,2.42); s=rng.uniform(.025,.05)
ell('Side pore',(1.077*side,y,z),(.007,s*.75,s),pore)
for i in range(12):
x=rng.uniform(-.79,.79); z=rng.uniform(.85,2.4); s=rng.uniform(.024,.046)
ell('Back pore',(x,.648,z),(s,.007,s*.76),pore)
# Feet and independent leg bones.
for side,label in ((-1,'L'),(1,'R')):
x=.52*side
ell('Leg '+label,(x,0,.48),(.195,.20,.31),ivory,'leg_'+label)
ell('Foot '+label,(x,-.14,.18),(.32,.39,.18),cream,'leg_'+label)
# Shotgun: pump-action shotgun angled across the chest.
origin=Vector((-.08,-1.08,1.11)); d=Vector((.96,-.28,-.025)).normalized()
up=Vector((0,0,1)); across=Vector((-d.y,d.x,0))
def gp(t,z=0): return origin+d*t+up*z
stock=box('Walnut stock',gp(-.91,-.025),(.80,.23,.34),.065,wood,'shotgun')
stock.rotation_euler[2]=math.atan2(d.y,d.x)
cap=box('Rubber butt pad',gp(-1.29,-.025),(.09,.25,.36),.035,steel,'shotgun'); cap.rotation_euler[2]=math.atan2(d.y,d.x)
receiver=box('Shotgun receiver',gp(-.22),(.75,.28,.32),.08,steel,'shotgun'); receiver.rotation_euler[2]=math.atan2(d.y,d.x)
rod('Upper barrel',gp(.08,.095),gp(1.34,.095),.095,steel)
rod('Magazine tube',gp(.10,-.085),gp(1.14,-.085),.071,edge)
rod('Wooden pump',gp(.36,-.09),gp(.94,-.09),.133,woodlight,'pump')
for i in range(7):
t=.38+i*.085
rod('Pump groove',gp(t,-.09),gp(t+.022,-.09),.137,wood,'pump')
rod('Muzzle rim',gp(1.32,.095),gp(1.39,.095),.118,edge)
rod('Recessed muzzle',gp(1.39,.095),gp(1.396,.095),.082,black)
# Rounded trigger loop.
pts=[gp(-.35+.20*math.cos(t),-.19+.145*math.sin(t)) for t in [i*2*math.pi/32 for i in range(33)]]
tube('Trigger guard',pts,.035,steel,'shotgun')
# Hands cradle the grip and pump.
ell('Grip mitten',gp(-.52,-.18),(.22,.22,.22),cream,'hand_grip')
ell('Pump mitten',gp(.64,-.22),(.23,.20,.21),cream,'hand_pump')
ell('Grip thumb',gp(-.46,-.04)-across*.16,(.095,.10,.12),cream,'hand_grip')
ell('Pump thumb',gp(.67,-.08)-across*.16,(.10,.10,.11),cream,'hand_pump')
# Slim pale grain lines across stock.
for z in (-.10,0,.10):
tube('Wood grain',[gp(-1.19,z)+across*-.12,gp(-.68,z+.025)+across*-.12],.009,woodlight,'shotgun')
# Rig. All bones point down Blender -Y, which becomes +Z in the browser.
rigdata=bpy.data.armatures.new('Soybert skeleton'); rig=bpy.data.objects.new('Soybert Rig',rigdata); coll.objects.link(rig)
bpy.ops.object.select_all(action='DESELECT'); rig.select_set(True); bpy.context.view_layer.objects.active=rig
bpy.ops.object.mode_set(mode='EDIT')
bones=[('root',None,(0,0,0)),('body','root',(0,0,1.35)),('leg_L','root',(-.52,0,.53)),('leg_R','root',(.52,0,.53)),('shotgun','body',tuple(origin)),('pump','shotgun',tuple(gp(.65,-.09))),('hand_grip','shotgun',tuple(gp(-.52,-.18))),('hand_pump','pump',tuple(gp(.64,-.22)))]
for name,parent,pos in bones:
b=rigdata.edit_bones.new(name); b.head=pos; b.tail=Vector(pos)+Vector((0,-.28,0))
if parent: b.parent=rigdata.edit_bones[parent]
bpy.ops.object.mode_set(mode='OBJECT')
rig.show_in_front=True
bpy.context.view_layer.update()
# Bake evaluated normals and world coordinates into an efficient inlined export before skinning.
materials=[]; matmap={}; buckets={}
for obj in parts:
m=obj.data.materials[0]
if m.name not in matmap:
matmap[m.name]=len(materials)
p=m.node_tree.nodes.get('Principled BSDF')
materials.append({'name':m.name,'color':list(m.diffuse_color[:3]),'roughness':p.inputs['Roughness'].default_value,'metalness':p.inputs['Metallic'].default_value})
mid=matmap[m.name]; buckets.setdefault(mid,[]).append(obj)
positions=[]; normals=[]; skin=[]; indices=[]; groups=[]
deps=bpy.context.evaluated_depsgraph_get()
for mid,objects in buckets.items():
start=len(indices)
for obj in objects:
evaluated=obj.evaluated_get(deps); mesh=evaluated.to_mesh(); mesh.calc_loop_triangles()
world=obj.matrix_world; normalmat=world.to_3x3().inverted().transposed(); cache={}
boneidx=[b[0] for b in bones].index(obj['bone'])
for tri in mesh.loop_triangles:
for li in tri.loops:
loop=mesh.loops[li]; p=world@mesh.vertices[loop.vertex_index].co
n=(normalmat@mesh.corner_normals[li].vector).normalized()
key=(loop.vertex_index,tuple(round(v,5) for v in n))
if key not in cache:
cache[key]=len(skin)
positions.extend((p.x,p.z,-p.y)); normals.extend((n.x,n.z,-n.y)); skin.append(boneidx)
indices.append(cache[key])
evaluated.to_mesh_clear()
groups.append({'start':start,'count':len(indices)-start,'material':mid})
def packed(values,fmt): return base64.b64encode(struct.pack('<'+fmt*len(values),*values)).decode()
def yup(v): return [v[0],v[2],-v[1]]
model={'version':1,'materials':materials,'groups':groups,'positions':packed(positions,'f'),'normals':packed(normals,'f'),'skin':packed(skin,'B'),'indices':packed(indices,'I'),'bones':[],'muzzle':yup(gp(1.42,.095)),'barrelDirection':yup(d),'vertexCount':len(skin),'triangleCount':len(indices)//3}
for name,parent,pos in bones:
parentpos=next((b[2] for b in bones if b[0]==parent),(0,0,0))
model['bones'].append({'name':name,'parent':parent,'position':yup(Vector(pos)-Vector(parentpos))})
(ROOT/'src/assets/soybert-rig.json').write_text(json.dumps(model,separators=(',',':')))
# Bind each piece rigidly to its articulated bone, retaining editable separate meshes.
for obj in parts:
vg=obj.vertex_groups.new(name=obj['bone']); vg.add(list(range(len(obj.data.vertices))),1,'REPLACE')
mod=obj.modifiers.new('Soybert skin','ARMATURE'); mod.object=rig
obj.parent=rig
# Author reusable actions on the Blender armature. Bone local Y is world -Y.
scene=bpy.context.scene; scene.render.fps=30
rig.animation_data_create()
for title,duration in [('Idle',60),('Walk',24),('Jump',30),('Shoot',18),('Celebrate',90)]:
action=bpy.data.actions.new(title); action['soybert_generated']=True; action.use_fake_user=True; rig.animation_data.action=action
for frame in range(1,duration+2,3):
t=(frame-1)/duration; phase=t*math.tau
for pb in rig.pose.bones:
pb.rotation_mode='XYZ'; pb.location=(0,0,0); pb.rotation_euler=(0,0,0); pb.scale=(1,1,1)
body=rig.pose.bones['body']
if title=='Idle':
body.location.z=.022*math.sin(phase); body.rotation_euler.y=.018*math.sin(phase)
elif title=='Walk':
body.location.z=.09*abs(math.sin(phase)); body.rotation_euler.y=.065*math.sin(phase); body.rotation_euler.x=.14
rig.pose.bones['leg_L'].rotation_euler.x=.95*math.sin(phase)
rig.pose.bones['leg_R'].rotation_euler.x=-.95*math.sin(phase)
elif title=='Jump':
rig.pose.bones['root'].location.z=1.15*math.sin(math.pi*t)
rig.pose.bones['leg_L'].rotation_euler.x=-.28*math.sin(math.pi*t)
rig.pose.bones['leg_R'].rotation_euler.x=.24*math.sin(math.pi*t)
elif title=='Shoot':
kick=max(0,1-t*4)
rig.pose.bones['shotgun'].location.y=-.17*kick
body.rotation_euler.x=-.08*kick
rig.pose.bones['pump'].location.y=-.20*math.sin(math.pi*min(1,max(0,(t-.2)/.8)))
else:
body.location.z=.13*abs(math.sin(phase*3)); body.rotation_euler.y=.15*math.sin(phase*3)
rig.pose.bones['root'].rotation_euler.z=phase
for pb in rig.pose.bones:
for path in ('location','rotation_euler','scale'): pb.keyframe_insert(data_path=path,frame=frame,group=pb.name)
track=rig.animation_data.nla_tracks.new(); track.name=title
strip=track.strips.new(title,1,action); track.mute=True
rig.animation_data.action=None
for pb in rig.pose.bones: pb.location=(0,0,0); pb.rotation_euler=(0,0,0); pb.scale=(1,1,1)
scene.frame_set(1); scene.frame_end=90
# A studio camera and lights for the editable asset.
def aim(o,point): o.rotation_euler=(Vector(point)-o.location).to_track_quat('-Z','Y').to_euler()
bpy.ops.object.camera_add(location=(4,-7,3.6)); cam=own(bpy.context.object); cam.name='Soybert portrait camera'; aim(cam,(0,0,1.4)); cam.data.type='ORTHO'; cam.data.ortho_scale=4.6; scene.camera=cam
for name,loc,power,size in [('Key',(-3,-4,6),650,5),('Fill',(4,-1,4),450,4),('Rim',(0,3,5),800,3)]:
bpy.ops.object.light_add(type='AREA',location=loc); lamp=own(bpy.context.object); lamp.name=name; lamp.data.energy=power; lamp.data.shape='DISK'; lamp.data.size=size; aim(lamp,(0,0,1.3))
scene.world.color=(.15,.17,.14)
scene.render.engine='CYCLES'; scene.cycles.samples=32
scene.render.resolution_x=900; scene.render.resolution_y=900; scene.render.resolution_percentage=100
scene.render.film_transparent=True
# Save the editable source, and export only rigged character parts.
bpy.ops.object.select_all(action='DESELECT')
for obj in parts+[rig]: obj.select_set(True)
bpy.context.view_layer.objects.active=rig
bpy.ops.wm.save_as_mainfile(filepath=str(ROOT/'art/soybert.blend'))
bpy.ops.export_scene.gltf(filepath=str(ROOT/'art/soybert.glb'),export_format='GLB',use_selection=True,export_animations=True,export_animation_mode='ACTIONS',export_nla_strips=True)
# Leave the open viewport looking at the character.
for screen in bpy.data.screens:
for area in screen.areas:
if area.type=='VIEW_3D':
area.spaces.active.region_3d.view_distance=5.8
area.spaces.active.region_3d.view_location=Vector((0,0,1.4))
area.spaces.active.region_3d.view_rotation=cam.rotation_euler.to_quaternion()
area.spaces.active.shading.type='MATERIAL'
result={'vertices':len(skin),'triangles':len(indices)//3,'bones':len(bones),'meshes':len(parts),'actions':[a.name for a in bpy.data.actions],'blend':str(ROOT/'art/soybert.blend'),'glb':str(ROOT/'art/soybert.glb')}
