"""Saumwacht: the Verdant Rift, an editable Blender environment in real metres. The two 6x8 gameplay grids remain unobstructed. All vegetation, bridges, architectural details and rock strata are actual meshes, not a backdrop. Run: ALSOFT_DRIVERS=null blender -b --python-exit-code 1 --python this_file.py """ from pathlib import Path import sys, math, random, json, hashlib import bpy from mathutils import Vector, noise PROJECT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(PROJECT/'assets-source/phase4/blender')) from common import mesh_object, ellipsoid, cylinder, cone, srgb_to_linear from reference_high_detail import hd_box, hd_curve, hd_ring, hd_rod, hd_finish R = random.Random(10092026) scene = bpy.data.scenes.new('The_Verdant_Rift') bpy.context.window.scene = scene scene.unit_settings.system = 'METRIC' scene['description'] = 'Two intact 12 x 40 metre paths; forest sanctuary above a river gorge.' M = {} for key, color, rough, metal in [ ('limestone','918875',.89,0),('cliff','666D68',.92,0), ('soil','404F39',.97,0),('bark','534332',.93,0), ('leaf_dark','314B38',.89,0),('leaf_mid','526A42',.85,0), ('leaf_light','8B9655',.79,0),('brass','BEA36D',.53,.65), ('teal','69CEB9',.36,.2),('coral','DF9A7A',.36,.2), ('flower','C4BB93',.72,0),('paving','707367',.92,0)]: mat=bpy.data.materials.new('ENV_'+key); mat.use_nodes=True c=tuple(srgb_to_linear(int(color[i:i+2],16)/255) for i in (0,2,4)) mat.diffuse_color=(*c,1) bs=mat.node_tree.nodes.get('Principled BSDF') bs.inputs['Base Color'].default_value=(*c,1) bs.inputs['Roughness'].default_value=rough bs.inputs['Metallic'].default_value=metal if key in ['teal','coral']: bs.inputs['Emission Color'].default_value=(*c,1) bs.inputs['Emission Strength'].default_value=.65 M[key]=mat def p(x,z,h=0): return (x,-z,h) def box(name,x,z,h,sx,sz,sh,key='limestone',radius=.035,angle=0): return hd_box(name,p(x,z,h),(sx,sz,sh),M[key],radius,(0,0,angle)) def rock(name,x,z,h,sx,sz,sh,seed=0): # Angular, eroded stratified rock, authored rings rather than round spheres. rng=random.Random(seed+917);verts=[];faces=[];sides=14;levels=13 phase=rng.uniform(0,6.28) for row in range(levels): t=row/(levels-1) shoulder=.55+.45*math.sin(math.pi*t)**.3 ledge=[1.0,.93,1.05,.96][row%4] for col in range(sides): angle=math.tau*col/sides+phase n=noise.noise(Vector((math.cos(angle)*2,math.sin(angle)*2,t*3+seed))) radius=shoulder*ledge*(1+n*.16) px=math.cos(angle)*sx*radius py=math.sin(angle)*sz*radius pz=(t*2-1)*sh+n*.07*sh verts.append((px,py,pz)) for row in range(levels-1): for col in range(sides): a=row*sides+col;b=row*sides+(col+1)%sides faces.append((a,b,b+sides,a+sides)) faces.append(tuple(reversed(range(sides)))) faces.append(tuple((levels-1)*sides+k for k in range(sides))) obj=mesh_object(name,verts,faces,M['cliff']);obj.location=p(x,z,h) hd_finish(obj,.035,2,False) return obj def leaf_mesh(name,center,radius,seed): """An airy spray of individually curved, closed six-sided leaves.""" rng=random.Random(seed); verts=[]; faces=[] for i in range(90): direction=Vector((rng.uniform(-1,1),rng.uniform(-1,1),rng.uniform(-.6,.7))).normalized() point=Vector(center)+direction*radius*rng.random()**.38 tangent=Vector((rng.uniform(-1,1),rng.uniform(-1,1),rng.uniform(-.4,.4))).normalized() side=tangent.cross(Vector((0,0,1))).normalized() length=rng.uniform(.18,.35); width=length*.47; base=len(verts) verts.extend([tuple(point-tangent*length),tuple(point-side*width),tuple(point+tangent*length), tuple(point+side*width),tuple(point+Vector((0,0,length*.22))),tuple(point-Vector((0,0,.018)))]) faces.extend([tuple(base+j for j in f) for f in [(0,1,4),(1,2,4),(2,3,4),(3,0,4),(1,0,5),(2,1,5),(3,2,5),(0,3,5)]]) return mesh_object(name,verts,faces,M[['leaf_dark','leaf_mid','leaf_light'][seed%3]]) def tree(x,z,size,seed): rng=random.Random(seed); base=Vector(p(x,z,-.3)) lean=Vector((rng.uniform(-.5,.5),rng.uniform(-.5,.5),0))*size height=size*3.8 trunk=[base,base+Vector((0,0,height*.28)),base+lean*.7+Vector((0,0,height*.68)),base+lean+Vector((0,0,height))] hd_curve('Tree_%d_Trunk'%seed,trunk,[size*.38,size*.25,size*.15,.035],M['bark'],18,9) for j in range(7): a=j*math.tau/7 root=base+Vector((math.cos(a),math.sin(a),0))*size*1.3 root.z=-.21 hd_curve('Tree_%d_Root_%d'%(seed,j),[root,base+Vector((math.cos(a)*.3,math.sin(a)*.3,.3))*size,trunk[1]],[.03,size*.14,.12*size],M['bark'],10,6) for j in range(11): a=j*2.399+seed branch=base+lean*.5+Vector((0,0,height*(.4+j*.035))) length=size*(1.7-j*.065) end=branch+Vector((math.cos(a)*length,math.sin(a)*length,size*(.9+j*.055))) hd_curve('Tree_%d_Branch_%d'%(seed,j),[branch,(branch+end)*.5+Vector((0,0,.18)),end],[size*.11,size*.065,.012],M['bark'],10,6) leaf_mesh('Tree_%d_Canopy_%d'%(seed,j),end,size*.68,seed*100+j) leaf_mesh('Tree_%d_Spray_%d'%(seed,j),end*.55+branch*.45,size*.5,seed*200+j) def fern(x,z,scale,seed): rng=random.Random(seed); verts=[]; faces=[] base=Vector(p(x,z,-.08)) for j in range(7): a=math.tau*j/7+seed direction=Vector((math.cos(a),math.sin(a),0)) side=direction.cross(Vector((0,0,1))) length=scale*rng.uniform(.65,1.05) for k in range(1,8): t=k/8 center=base+direction*length*t+Vector((0,0,math.sin(t*math.pi)*length*.6)) for sign in [-1,1]: end=center+side*sign*length*.25*math.sin(t*math.pi)+direction*length*.09 i=len(verts) verts.extend([tuple(center-direction*.025),tuple(end),tuple(center+direction*.08),tuple(center+Vector((0,0,.015)))]) faces.extend([(i,i+1,i+3),(i+1,i+2,i+3),(i+2,i,i+3),(i+2,i+1,i)]) mesh_object('Fern_'+str(seed),verts,faces,M['leaf_mid' if seed%2 else 'leaf_light']) def ruin_pillar(x,z,height,team,seed): box('Pillar_Plinth',x,z,.10,1.30,1.30,.38) box('Pillar_Foot',x,z,.36,1.04,1.04,.16,'brass',.025) for level in range(int(height/.5)): box('Pillar_Block',x,z,.63+level*.48,.8,.8,.46,angle=(seed%3-1)*.018) box('Pillar_Capital',x,z,height+.2,1.06,1.06,.24) box('Pillar_Cap',x,z,height+.38,.94,.94,.12,'brass',.018) # Deep vertical grooves and inset illuminated rune bars on all four faces. for sx,sz in [(1,0),(-1,0),(0,1),(0,-1)]: for off in [-.21,.21]: box('Pillar_Groove',x+sx*.408+sz*off,z+sz*.408+sx*off,height*.52+.25,.034 if sx else .065,.065 if sx else .034,height*.63,'brass',.008) box('Pillar_Inscription',x+sx*.425,z+sz*.425,height*.67,.038 if sx else .095,.095 if sx else .038,.4,'teal' if team==0 else 'coral',.009) # A sculpted, continuous ravine. The causeways are flat and above the terrain. nx,nz=224,256 verts=[]; faces=[] for j in range(nz+1): z=-52+j*104/nz for i in range(nx+1): x=-48+i*96/nx v=noise.noise_vector(Vector((x*.16,z*.12,3))) river=math.exp(-(abs(x)/3.4)**4) h=-.42-5.1*river + v.x*.34 outer=max(0,abs(x)-29)/11 h+=outer*(1.7+v.y*2.7) if 3 < abs(x) < 16 and abs(z)<21: h=min(h,-.24) shore=max(0,min(1,(math.sqrt((x/48)**2+(z/52)**2)-.70)/.27)) shore=shore*shore*(3-2*shore) h=h*(1-shore)-5.5*shore verts.append(p(x,z,h)) for j in range(nz): for i in range(nx): k=j*(nx+1)+i faces.append((k,k+nx+1,k+nx+2,k+1)) terrain=mesh_object('Sculpted_River_Gorge',verts,faces,M['soil']) for face in terrain.data.polygons: face.use_smooth=True solid=terrain.modifiers.new('Closed bedrock','SOLIDIFY'); solid.thickness=2 bpy.context.view_layer.objects.active=terrain bpy.ops.object.modifier_apply(modifier=solid.name) for team,c in enumerate([-9,9]): # Granite retaining wall: jointed masonry above real rock walls. box('Causeway_Foundation',c,0,-.65,12.6,41,1.1,'cliff',.13) for side in [-1,1]: for j in range(21): z=-20+j*2 box('Edge_Coping',c+side*6.2,z,-.04,.48,1.94,.26,'limestone',.06) if j%3==0: box('Boundary_Socket',c+side*6.18,z,.12,.58,.7,.25,'limestone',.05) box('Boundary_Inlay',c+side*6.18,z,.25,.12,.40,.02,'teal' if team==0 else 'coral',.008) # Side buttresses with stepped lower construction. if j%4==0: box('Wall_Buttress',c+side*6.4,z,-1.0,.75,1.15,2.4,'limestone',.09) box('Wall_Buttress_Foot',c+side*6.4,z,-2.15,1,1.4,.3,'limestone',.06) for j in range(11): rock('Stratified_Cliff',c+side*6.7,-21+j*4,-2.55,1.05,2.45,2.45,100+team*30+j) # Quiet broad paving, recessed bevels and minor rotation keep it natural. for row in range(20): for col in range(6): box('Paving',c-5+col*2,-19+row*2,-.082,1.975,1.975,.15,'paving',.018) # Portals at the arrival end, clear 10m passage under a ruined arch. for s in [-1,1]: ruin_pillar(c+s*5.25,-20.7,3.1,team,team*2+s) for t in range(3): box('Portal_Corbel',c+s*(5.1-t*.32),-20.7,3.42+t*.26,1.2,1.15,.32,'limestone',.045) # Tall spire and layered ring patio around the crystal, outside workers. for radius,height,depth in [(3.8,-.06,.12),(3.5,.025,.055)]: obj=cylinder('Sanctum_Patio',p(c,18.2,height),radius,depth,M['limestone'],96) hd_finish(obj,.025,2) for rad in [3.25,3.5]: hd_ring('Sanctum_Inlay',p(c,18.2,.067),rad,.025,M['brass'],segments=128) for s in [-1,1]: ruin_pillar(c+s*4.45,20.6,2.6,team,40+s) for i in range(8): x=c-4.2+(i%4)*2.8; z=13.7+(i//4)*2.2 box('Gatherer_Station',x,z+.55,.05,1.6,1.0,.12,'limestone',.05) box('Gatherer_Channel',x,z+.55,.12,1.16,.06,.025,'teal' if team==0 else 'coral',.01) # Rear stairs communicate an entrance without covering the arena. for step in range(6): box('Sanctum_Stairs',c,21.3+step*.35,-.08-step*.08,5.6,.45,.2,'limestone',.035) # Ruined footbridges connect the courtyards over the gorge. for z in [-22.5,23.6]: for j in range(13): x=-5.1+j*.85 h=.25+math.sin((j/12)*math.pi)*.5 box('Bridge_Voussoir',x,z,h-.52,.86,2.3,1,'limestone',.075) box('Bridge_Paving',x,z,h,.83,2.28,.12,'paving',.018) for side in [-1,1]: box('Bridge_Parapet',x,z+side*1.2,h+.25,.81,.18,.50,'limestone',.035) # Vegetation frames the board; no trees or grass on either gameplay grid. for i in range(30): side=-1 if i%2 else 1 x=side*R.uniform(19.5,29.5); z=R.uniform(-29,29) tree(x,z,R.uniform(.78,1.35),i+1) for i in range(100): x=R.choice([-1,1])*R.uniform(17.0,30); z=R.uniform(-31,31) rock('Forest_Boulder',x,z,-.1,R.uniform(.25,1.3),R.uniform(.35,1.5),R.uniform(.3,1.1),i) for i in range(370): x=R.choice([-1,1])*R.uniform(16.3,29); z=R.uniform(-31,31) fern(x,z,R.uniform(.30,.85),i) for i in range(28): z=R.uniform(-29,29); x=R.choice([-1,1])*R.uniform(2.1,2.9) rock('River_Bank',x,z,-3.2,R.uniform(.6,1),R.uniform(1,2),R.uniform(1.2,2.8),300+i) for i in range(14): x=R.choice([-1,1])*R.uniform(17,22); z=R.uniform(-28,28) for j in range(3): box('Fallen_Ruin',x+j*.32,z+j*.42,.12+j*.1,1.5,.8,.55,'limestone',.07,angle=i*.4) # Meadow tussocks on the outer banks, absent from all gameplay cells. verts=[];faces=[] for i in range(2200): x=R.choice([-1,1])*R.uniform(16.6,30.5);z=R.uniform(-32,32) for j in range(5): h=R.uniform(.13,.38);w=R.uniform(.012,.03);a=R.uniform(0,math.tau) base=Vector(p(x+R.uniform(-.11,.11),z+R.uniform(-.11,.11),-.24)) side=Vector((math.cos(a),math.sin(a),0))*w bend=Vector((math.sin(a),math.cos(a),0))*h*.36 mid=base+Vector((0,0,h*.56))+bend*.3 end=base+Vector((0,0,h))+bend n=len(verts) verts.extend([tuple(base-side),tuple(base+side),tuple(mid+side*.7),tuple(mid-side*.7),tuple(end),tuple(mid+Vector((0,0,.012)))]) faces.extend([(n,n+1,n+2,n+3),(n+3,n+2,n+4),(n+1,n,n+5),(n+2,n+1,n+5),(n+4,n+2,n+5),(n+3,n+4,n+5),(n,n+3,n+5)]) mesh_object('Meadow_11000_Curved_Blades',verts,faces,M['leaf_mid']) # Editable source retains every object. Runtime merges by material and chunk # for culling, greatly reducing the number of draws compared with source parts. source=PROJECT/'assets-source/phase10/verdant-rift.blend' bpy.data.libraries.write(str(source),{scene},fake_user=True,compress=True) objects=[o for o in scene.objects if o.type=='MESH'] source_count=len(objects) groups={} for obj in objects: center=obj.matrix_world.translation key=(int(math.floor(center.x/16)),int(math.floor(center.y/16)),obj.data.materials[0].name) groups.setdefault(key,[]).append(obj) for key,parts in groups.items(): bpy.ops.object.select_all(action='DESELECT') for obj in parts: obj.select_set(True) bpy.context.view_layer.objects.active=parts[0] bpy.ops.object.join() parts[0].name='Chunk_%d_%d_%s'%key bpy.ops.object.select_all(action='SELECT') out=PROJECT/'assets/environment/verdant-rift.glb' bpy.ops.export_scene.gltf(filepath=str(out),export_format='GLB',use_selection=True,export_apply=True,export_yup=True,export_cameras=False,export_lights=False,export_animations=False) tris=0 for obj in scene.objects: if obj.type=='MESH': obj.data.calc_loop_triangles();tris+=len(obj.data.loop_triangles) record={'name':'Die grĂ¼ne Kluft','source_objects':source_count,'runtime_chunks':len(groups),'triangles':tris,'seed':10092026,'blender':bpy.app.version_string,'sha256':hashlib.sha256(out.read_bytes()).hexdigest(),'playable_grids':2,'cell_size_m':2,'grid_cells':[6,8]} (PROJECT/'assets-source/phase10/environment-manifest.json').write_text(json.dumps(record,indent=2)+'\n') print('ENVIRONMENT_EXPORTED',json.dumps(record),flush=True)