Refine Gwanaksan summit marker proportions and inscription
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
"""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.')
|
||||
Reference in New Issue
Block a user