"""Phase 10 sculpt pass: physical eroded stone and furrowed bark. Adds mid-frequency relief to selected large surfaces. Flat ornaments, crystal facets, leaf ribs and joint names are preserved. The editable .blend also keeps an optional high-resolution sculpt modifier stack; the GLB uses the budgeted game mesh, so twenty copies of a hero do not load its offline sculpt resolution. """ import bpy, bmesh from math import sin, cos from mathutils import Vector, noise def sculpt_game_surfaces(spec): if spec['faction'] not in ['glutpakt','wurzelbund','rift'] and spec['role'] not in ['worker','core']: return candidates=[] for obj in list(bpy.context.scene.objects): if obj.type!='MESH' or not obj.data.materials: continue mat=obj.data.materials[0].name kind='bark' if mat.startswith('SW_HD_W_') and any(k in mat for k in ['bark','wood']) else 'rock' if any(k in mat for k in ['SW_HD_basalt','SW_HD_edge']) else '' # Tiny strands and thin trim already carry explicit modeled details. if not kind or len(obj.data.polygons)<20 or any(k in obj.name for k in ['Grain','Vein','Ring','Trim','Rim','Groove','Toe','Fang']): continue bpy.context.view_layer.update() if spec["faction"]=="wurzelbund" and any(k in obj.name for k in ["Scale","Rib","Growth","Split"]): continue extent=max(obj.dimensions) if extent < .16: continue candidates.append((extent,obj,kind)) candidates.sort(key=lambda row:row[0],reverse=True) for extent,obj,kind in candidates[:18]: bpy.context.view_layer.objects.active=obj # Bake existing scale, keeping the semantic part transform intact. from mathutils import Matrix obj.data.transform(Matrix.Diagonal((*obj.scale,1))) obj.scale=(1,1,1) bm=bmesh.new();bm.from_mesh(obj.data) bmesh.ops.subdivide_edges(bm,edges=list(bm.edges),cuts=1,use_grid_fill=True) bm.normal_update() amplitude=min(.014,extent*.023) if kind=='bark' else min(.009,extent*.012) for v in bm.verts: p=v.co n=noise.noise_vector(p*19+Vector((1.7,3.2,.6))) if kind=='bark': grain=noise.noise(Vector((p.x*46,p.y*46,p.z*5.5))) ridges=sin(p.x*103+p.y*81+n.z*1.3) amount=(grain*.7+ridges*.3)*amplitude else: amount=(n.x*.8+noise.noise(p*61)*.2)*amplitude v.co+=v.normal*amount bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces)) bm.to_mesh(obj.data);bm.free();obj.data.update() for face in obj.data.polygons: face.use_smooth=True obj['sculpt_surface']=kind obj['relief_depth_m']=amplitude def add_offline_sculpt_modifiers(objects): """Only called AFTER GLB export. Render-visible high mesh remains editable.""" for obj in objects: if obj.type!='MESH' or not obj.get('sculpt_surface'): continue subdiv=obj.modifiers.new('HIGH RES · surface refinement','SUBSURF') subdiv.subdivision_type='SIMPLE';subdiv.levels=1;subdiv.render_levels=2 tex=bpy.data.textures.new(obj.name+'_Microrelief',type='CLOUDS') tex.noise_scale=.012;tex.noise_depth=2 displace=obj.modifiers.new('HIGH RES · sculpted microrelief','DISPLACE') displace.texture=tex;displace.strength=.0018;displace.mid_level=.5 def add_offline_materials(objects, project): """Editable Blender PBR nodes; optional project maps, standalone-safe fallback. Runtime shaders remain Godot-authored. Blender sources use the same oak height map where available, with procedural pores for the other surfaces. Images are external resources, avoiding 33 duplicate copies of a 4K map. """ materials={m for o in objects if o.type=='MESH' for m in o.data.materials if m} for mat in materials: if mat.get('phase10_pbr') or not mat.name.startswith('SW_HD_'): continue nodes=mat.node_tree.nodes;links=mat.node_tree.links bsdf=nodes.get('Principled BSDF') if not bsdf or bsdf.inputs['Emission Strength'].default_value>0: continue coord=nodes.new('ShaderNodeTexCoord');coord.location=(-700,100) grain=nodes.new('ShaderNodeTexNoise');grain.location=(-470,100) grain.inputs['Scale'].default_value=65;grain.inputs['Detail'].default_value=4 links.new(coord.outputs['Object'],grain.inputs['Vector']) height=grain.outputs['Fac'] is_bark=mat.name.startswith('SW_HD_W_') and any(k in mat.name for k in ['bark','wood','groove']) path=project/'assets/materials/phase10/oak-height-4k.png' if is_bark and path.exists(): image=nodes.new('ShaderNodeTexImage');image.location=(-460,-150) image.image=bpy.data.images.load(str(path),check_existing=True) image.image.colorspace_settings.name='Non-Color' image.projection='BOX';image.projection_blend=.25 links.new(coord.outputs['Generated'],image.inputs['Vector']) height=image.outputs['Color'] bump=nodes.new('ShaderNodeBump');bump.location=(-120,-80) bump.inputs['Strength'].default_value=.32 if is_bark else .16 bump.inputs['Distance'].default_value=.018 if is_bark else .009 links.new(height,bump.inputs['Height']);links.new(bump.outputs['Normal'],bsdf.inputs['Normal']) mat['phase10_pbr']=True