181 lines
8.1 KiB
Python
181 lines
8.1 KiB
Python
"""Bake photo-guided vector outlines into the existing marker; run inside Blender.
|
|
|
|
Edits authored vector shapes, not the reference photograph. Existing stone and UVs
|
|
are reused. Color and tangent normal are baked for the same glTF material.
|
|
"""
|
|
import json
|
|
from pathlib import Path
|
|
import bpy
|
|
import numpy as np
|
|
|
|
ROOT = Path('D:/project/sunrise')
|
|
scene = bpy.data.scenes['Summit slab and marker']
|
|
bpy.context.window.scene = scene
|
|
body = bpy.data.objects['Photo matched summit boulder']
|
|
mat = body.data.materials[0]
|
|
nodes, links = mat.node_tree.nodes, mat.node_tree.links
|
|
bs = next(n for n in nodes if n.type == 'BSDF_PRINCIPLED')
|
|
atlas = next(n for n in nodes if n.type == 'UVMAP' and n.uv_map == 'Painted monument atlas')
|
|
spec = json.loads((ROOT / 'scripts/constants/marker-inscription.json').read_text(encoding='utf-8'))
|
|
N = 2048
|
|
mask = np.zeros((N, N), dtype=np.float32)
|
|
|
|
def atlas_point(x, z, vertical_offset):
|
|
ax, az = spec['layout']['anchor']
|
|
scale = spec['layout']['scale']
|
|
x = ax+(x-ax)*scale
|
|
z = az+(z-az)*scale+vertical_offset
|
|
return ((.02+.46*(x+1.3)/2.6)*N, (.02+.96*(z+.2)/2.3)*N)
|
|
|
|
def polygon(points, value):
|
|
p = np.array(points)
|
|
lo = np.maximum(0, np.floor(p.min(axis=0)).astype(int))
|
|
hi = np.minimum(N-1, np.ceil(p.max(axis=0)).astype(int))
|
|
X, Y = np.meshgrid(np.arange(lo[0],hi[0]+1)+.5, np.arange(lo[1],hi[1]+1)+.5)
|
|
inside = np.zeros(X.shape, dtype=bool)
|
|
for a, b in zip(p, np.roll(p, -1, axis=0)):
|
|
if abs(b[1]-a[1]) < 1e-9:
|
|
continue
|
|
inside ^= ((a[1]>Y) != (b[1]>Y)) & (X < (b[0]-a[0])*(Y-a[1])/(b[1]-a[1])+a[0])
|
|
patch = mask[lo[1]:hi[1]+1,lo[0]:hi[0]+1]
|
|
patch[inside] = value
|
|
|
|
for glyph_index, glyph in enumerate(spec['glyphs']):
|
|
x0,y0,x1,y1 = glyph['bounds']
|
|
cx,top,w,h = glyph['placement']
|
|
for shape in glyph['shapes']:
|
|
for i, ring in enumerate(shape):
|
|
points = []
|
|
for x,y in ring:
|
|
px = cx+((x-x0)/(x1-x0)-.5)*w
|
|
pz = top-(y-y0)/(y1-y0)*h
|
|
points.append(atlas_point(px,pz,spec['layout']['glyphVerticalOffsets'][glyph_index]))
|
|
polygon(points, 1 if i == 0 else 0)
|
|
|
|
# Rasterize small serif lettering into the same atlas; no font or text mesh is
|
|
# exported. Separate runs match the photo's smaller Korean label and unit.
|
|
caption = spec['caption']
|
|
font = bpy.data.fonts.load(caption['font'], check_existing=True)
|
|
left = caption['left']
|
|
for run in caption['runs']:
|
|
curve = bpy.data.curves.new('Temporary elevation lettering', type='FONT')
|
|
curve.body = run['text']
|
|
curve.font = font
|
|
curve.resolution_u = 16
|
|
obj = bpy.data.objects.new('Temporary elevation lettering', curve)
|
|
scene.collection.objects.link(obj)
|
|
bpy.context.view_layer.update()
|
|
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
|
mesh = evaluated.to_mesh()
|
|
try:
|
|
mesh.calc_loop_triangles()
|
|
coordinates = np.array([(v.co.x,v.co.y) for v in mesh.vertices])
|
|
low, high = coordinates.min(axis=0), coordinates.max(axis=0)
|
|
for tri in mesh.loop_triangles:
|
|
points = []
|
|
for vertex in tri.vertices:
|
|
x,y = (coordinates[vertex]-low)/(high-low)
|
|
px = left+x*run['width']
|
|
pz = caption['baseline']+y*run['height']
|
|
points.append(atlas_point(px,pz,spec['layout']['captionVerticalOffset']))
|
|
polygon(points,1)
|
|
finally:
|
|
evaluated.to_mesh_clear()
|
|
bpy.data.objects.remove(obj,do_unlink=True)
|
|
bpy.data.curves.remove(curve)
|
|
left += run['width']+run['gap']
|
|
if font.users == 0:
|
|
bpy.data.fonts.remove(font)
|
|
|
|
# A sub-texel soft edge preserves the chisel silhouette without stair steps.
|
|
soft = (mask*4 + np.roll(mask,1,0)+np.roll(mask,-1,0)+np.roll(mask,1,1)+np.roll(mask,-1,1))/8
|
|
rng = np.random.default_rng(194)
|
|
wear = rng.uniform(.94,1,mask.shape).astype(np.float32)
|
|
wear[rng.random(mask.shape)<.007] = .55
|
|
|
|
def write_image(name, values):
|
|
image = bpy.data.images.get(name) or bpy.data.images.new(name,width=N,height=N,alpha=False)
|
|
if tuple(image.size) != (N,N):
|
|
image.scale(N,N)
|
|
rgba = np.ones((N,N,4),dtype=np.float32)
|
|
rgba[:,:,:3] = values[:,:,None]
|
|
image.pixels.foreach_set(rgba.ravel())
|
|
image.update()
|
|
image.pack()
|
|
return image
|
|
|
|
write_image('Hanja pigment mask', soft*wear)
|
|
paint = next(n for n in nodes if n.type == 'MIX_RGB' and n.inputs[0].is_linked)
|
|
paint.inputs[2].default_value = (.008,.012,.014,1)
|
|
target = next(n for n in nodes if n.type == 'TEX_IMAGE' and n.image and n.image.name == 'Painted Hanja on granite')
|
|
links.new(paint.outputs[0], bs.inputs['Base Color'])
|
|
nodes.active = target
|
|
for obj in scene.objects:
|
|
obj.select_set(False)
|
|
body.select_set(True)
|
|
bpy.context.view_layer.objects.active = body
|
|
scene.render.bake.use_pass_direct = False
|
|
scene.render.bake.use_pass_indirect = False
|
|
scene.render.bake.use_pass_color = True
|
|
scene.render.bake.margin = 8
|
|
bake_types = bpy.ops.object.bake.get_rna_type().properties['type'].enum_items
|
|
bpy.ops.object.bake(type=next(i.identifier for i in bake_types if i.identifier == 'DIFFUSE'))
|
|
target.image.save()
|
|
target.image.pack()
|
|
links.new(target.outputs['Color'],bs.inputs['Base Color'])
|
|
|
|
# Bake the shallow engraving plus the original granite normal into the atlas.
|
|
# Reuse nodes on rerun; always start from the original granite normal.
|
|
def node(kind, label):
|
|
found = next((n for n in nodes if n.label == label),None)
|
|
if found is None:
|
|
found = nodes.new(kind)
|
|
found.label = label
|
|
return found
|
|
|
|
depth = node('ShaderNodeTexImage','Inscription depth source')
|
|
depth.image = write_image('Inscription recessed height',1-soft)
|
|
depth.image.colorspace_settings.name = 'Non-Color'
|
|
links.new(atlas.outputs['UV'],depth.inputs['Vector'])
|
|
original_normal = next(n for n in nodes if n.type == 'NORMAL_MAP' and n.label != 'Baked inscription normal')
|
|
# The old material used a default tangent basis; explicitly retain its grain UV.
|
|
original_normal.uv_map = 'Granite grain UV'
|
|
bump = node('ShaderNodeBump','Shallow inscription bevel')
|
|
bump.inputs['Strength'].default_value = .65
|
|
bump.inputs['Distance'].default_value = .0025
|
|
links.new(depth.outputs['Color'],bump.inputs['Height'])
|
|
links.new(original_normal.outputs['Normal'],bump.inputs['Normal'])
|
|
links.new(bump.outputs['Normal'],bs.inputs['Normal'])
|
|
normal_target = node('ShaderNodeTexImage','Baked inscription normal texture')
|
|
normal_image = bpy.data.images.get('Marker inscription normal') or bpy.data.images.new('Marker inscription normal',width=N,height=N,alpha=False)
|
|
normal_image.colorspace_settings.name = 'Non-Color'
|
|
normal_target.image = normal_image
|
|
links.new(atlas.outputs['UV'],normal_target.inputs['Vector'])
|
|
nodes.active = normal_target
|
|
# The edge material needs its own temporary bake target to keep its maps intact.
|
|
temporary = []
|
|
for side in list(body.data.materials)[1:]:
|
|
previous = side.node_tree.nodes.active
|
|
t = side.node_tree.nodes.new('ShaderNodeTexImage')
|
|
t.image = normal_image
|
|
side.node_tree.nodes.active = t
|
|
temporary.append((side,t,previous))
|
|
try:
|
|
bpy.ops.object.bake(type=next(i.identifier for i in bake_types if i.identifier == 'NORMAL'))
|
|
finally:
|
|
for side,t,previous in temporary:
|
|
side.node_tree.nodes.remove(t)
|
|
side.node_tree.nodes.active = previous
|
|
normal_image.filepath_raw = str(ROOT / 'public/terrain/materials/marker-inscription-normal.png')
|
|
normal_image.file_format = next(i.identifier for i in normal_image.bl_rna.properties['file_format'].enum_items if i.identifier == 'PNG')
|
|
normal_image.save()
|
|
normal_image.pack()
|
|
normal = node('ShaderNodeNormalMap','Baked inscription normal')
|
|
normal.uv_map = 'Painted monument atlas'
|
|
links.new(normal_target.outputs['Color'],normal.inputs['Color'])
|
|
links.new(normal.outputs['Normal'],bs.inputs['Normal'])
|
|
body['inscriptionStyle'] = 'Photo-guided silhouette polygons with negative spaces, dense charcoal pigment and shallow baked engraving'
|
|
scene.render.filepath = str(ROOT / 'assets/summit/blender-plateau-study/marker-detailed-inscription.png')
|
|
bpy.ops.render.render(write_still=True)
|
|
print('Detailed inscription baked:',int(np.count_nonzero(mask)),'texels, 3 glyphs +',caption['text'],'; color + tangent normal.')
|