Refine Gwanaksan summit marker proportions and inscription
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"sourceHash": "6f90fbd7927a723bbf3c46e68f66f22819666d374f069ae12087cc34a30af0c2",
|
"sourceHash": "ec19bec7b7dded0b4ed8e0733dea4ff2a87976869461cde9a99f6608c6b7836b",
|
||||||
"bufferHash": "d8cf0a36a46572060189e2ca9e9a61e448fb3d0b441abc346b56b7c4603e0d65",
|
"bufferHash": "d8cf0a36a46572060189e2ca9e9a61e448fb3d0b441abc346b56b7c4603e0d65",
|
||||||
"byteLength": 11036560,
|
"byteLength": 11036560,
|
||||||
"terrain": {
|
"terrain": {
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 4.9 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.5 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.9 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 3.0 MiB After Width: | Height: | Size: 3.1 MiB |
Binary file not shown.
Binary file not shown.
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"sourceHash": "8f7306b64c88b4d706c8bd8ecce0385fe1f5f64502123735b13d7affc2cb8ba7",
|
"sourceHash": "504596fa53badac50aad45e4f4aff3431257f26a39a8c5438b044c7e4ffbdaca",
|
||||||
"bufferHash": "59f836d4ff44c1f047c4165c99672fc9e7a04f9981f15561f4277754577d6e5a",
|
"bufferHash": "5916b60f37298fbb046645b0854f233d2926ee817641841d8c38dad071c26eef",
|
||||||
"byteLength": 15601904,
|
"byteLength": 15601904,
|
||||||
"gzipBytes": 6138340,
|
"gzipBytes": 6138350,
|
||||||
"originalVertices": 1180560,
|
"originalVertices": 1180560,
|
||||||
"vertices": 209224,
|
"vertices": 209224,
|
||||||
"triangles": 393520,
|
"triangles": 393520,
|
||||||
"originalAttributeBytes": 61389120,
|
"originalAttributeBytes": 61389120,
|
||||||
"generateMs": 3773.8809,
|
"generateMs": 3849.3699000000006,
|
||||||
"decodeMs": 13.8812999999991
|
"decodeMs": 13.474900000001071
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"resolution": 2048,
|
||||||
|
"grainScale": 145,
|
||||||
|
"crystalScale": 75,
|
||||||
|
"fineScale": 600,
|
||||||
|
"roughness": 0.91,
|
||||||
|
"relief": 0.0018,
|
||||||
|
"faceTint": 1.0,
|
||||||
|
"rimTint": 0.76,
|
||||||
|
"terrainBlend": 0.82,
|
||||||
|
"terrainSaturation": 0.65,
|
||||||
|
"terrainBrightness": 1.15,
|
||||||
|
"groundPaletteBlend": 0.55,
|
||||||
|
"grainColors": [
|
||||||
|
[0.26, [0.045, 0.049, 0.052, 1]],
|
||||||
|
[0.39, [0.18, 0.19, 0.19, 1]],
|
||||||
|
[0.46, [0.49, 0.50, 0.49, 1]],
|
||||||
|
[0.56, [0.72, 0.73, 0.71, 1]],
|
||||||
|
[0.72, [0.88, 0.89, 0.86, 1]]
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"description": "Hand-traced polygon silhouettes from the user supplied 191x377 reference. Coordinates follow the photograph; depth is an artistic estimate. Rings after the first are holes.",
|
||||||
|
"layout": {"scale": 0.65, "anchor": [0.18, 1.1], "glyphVerticalOffsets": [0, -0.12, -0.24], "captionVerticalOffset": -0.24},
|
||||||
|
"caption": {
|
||||||
|
"text": "해발 629 m",
|
||||||
|
"font": "C:/Windows/Fonts/HANBatangB.ttf",
|
||||||
|
"left": -0.125,
|
||||||
|
"baseline": 0.20,
|
||||||
|
"runs": [
|
||||||
|
{"text":"해발", "width":0.20, "height":0.080, "gap":0.04},
|
||||||
|
{"text":"629", "width":0.24, "height":0.105, "gap":0.05},
|
||||||
|
{"text":"m", "width":0.10, "height":0.075, "gap":0}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"glyphs": [
|
||||||
|
{"character":"冠", "bounds":[60,20,142,99], "placement":[0.18,1.78,0.53,0.45], "shapes":[
|
||||||
|
[[[67,36],[75,31],[88,29],[104,25],[117,24],[126,20],[132,25],[140,29],[142,34],[134,37],[125,34],[117,31],[102,33],[89,37],[82,44],[76,49],[68,50],[65,45]]],
|
||||||
|
[[[86,42],[94,38],[102,38],[105,43],[100,49],[97,54],[104,56],[105,61],[98,65],[100,71],[95,76],[98,82],[106,86],[118,87],[127,82],[129,72],[129,60],[133,66],[135,77],[138,84],[136,90],[124,94],[108,97],[96,93],[87,87],[81,94],[72,94],[66,91],[63,86],[69,81],[73,75],[70,71],[73,66],[80,64],[85,60],[82,55],[79,54],[81,48]], [[86,71],[82,76],[82,81],[87,77],[90,72]]],
|
||||||
|
[[[112,35],[118,34],[125,40],[124,44],[119,48],[120,53],[127,51],[129,56],[125,60],[120,61],[119,74],[116,82],[110,84],[105,78],[101,75],[103,71],[110,74],[112,68],[111,62],[105,65],[101,61],[104,56],[112,54],[112,46],[108,44]]]
|
||||||
|
]},
|
||||||
|
{"character":"岳", "bounds":[60,129,142,214], "placement":[0.15,1.29,0.55,0.46], "shapes":[
|
||||||
|
[[[77,153],[86,145],[99,138],[109,131],[117,130],[122,135],[119,141],[110,145],[98,147],[91,152],[90,158],[97,157],[108,151],[116,149],[124,153],[125,159],[119,162],[116,171],[128,170],[134,173],[139,174],[142,181],[136,183],[128,180],[117,181],[105,184],[91,186],[78,190],[68,189],[62,186],[60,181],[67,180],[76,179],[80,175],[79,169],[75,164]], [[92,164],[98,160],[105,161],[103,171],[97,175],[91,175]]],
|
||||||
|
[[[83,191],[88,188],[93,188],[95,194],[92,201],[100,199],[102,190],[108,185],[115,187],[116,192],[111,199],[119,197],[121,190],[127,187],[132,190],[130,197],[126,203],[122,208],[115,211],[107,209],[103,214],[97,211],[95,207],[86,211],[80,212],[77,207],[76,201],[73,198],[75,194]]]
|
||||||
|
]},
|
||||||
|
{"character":"山", "bounds":[57,250,145,313], "placement":[0.20,0.83,0.57,0.38], "shapes":[
|
||||||
|
[[[58,305],[64,298],[68,287],[72,277],[78,277],[81,282],[80,289],[78,296],[87,294],[91,282],[95,270],[99,257],[105,255],[110,251],[115,255],[118,262],[113,268],[109,272],[108,281],[105,289],[107,293],[114,292],[115,281],[119,275],[125,277],[129,281],[129,287],[125,293],[131,294],[135,291],[140,294],[143,302],[145,307],[138,307],[127,306],[117,307],[106,308],[94,308],[83,311],[71,313],[62,311],[57,309]]]
|
||||||
|
]}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"scale": [1.30, 1.30, 1.20],
|
||||||
|
"leftBowlDepth": 0.42,
|
||||||
|
"cleftDepth": 0.19,
|
||||||
|
"leftShoulderLift": 0.20,
|
||||||
|
"rightFaceOffset": 0.10,
|
||||||
|
"bedrockRadius": 2.15,
|
||||||
|
"bedrockBlend": 0.8,
|
||||||
|
"bedrockOverlap": 0.12
|
||||||
|
}
|
||||||
@@ -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.')
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"""Author and bake a dedicated light-grey granite material in Blender.
|
||||||
|
|
||||||
|
The procedural material is retained for editing; the temporary bake plane is
|
||||||
|
removed. Other terrain textures and the monument's silhouette stay unchanged.
|
||||||
|
Run detail-marker-inscription.py afterwards to rebake the preserved lettering.
|
||||||
|
"""
|
||||||
|
import bpy, json, math
|
||||||
|
from pathlib import Path
|
||||||
|
from mathutils import Vector
|
||||||
|
from mathutils.noise import noise_vector
|
||||||
|
|
||||||
|
ROOT = Path('D:/project/sunrise')
|
||||||
|
spec = json.loads((ROOT/'scripts/constants/marker-granite.json').read_text())
|
||||||
|
scene = bpy.data.scenes['Summit slab and marker']
|
||||||
|
bpy.context.window.scene = scene
|
||||||
|
body = bpy.data.objects['Photo matched summit boulder']
|
||||||
|
mat = bpy.data.materials.get('Marker grey granite authoring') or bpy.data.materials.new('Marker grey granite authoring')
|
||||||
|
mat.use_nodes = True
|
||||||
|
mat.use_fake_user = True
|
||||||
|
nodes, links = mat.node_tree.nodes, mat.node_tree.links
|
||||||
|
nodes.clear()
|
||||||
|
bs = nodes.new('ShaderNodeBsdfPrincipled')
|
||||||
|
out = nodes.new('ShaderNodeOutputMaterial')
|
||||||
|
links.new(bs.outputs['BSDF'],out.inputs['Surface'])
|
||||||
|
uv = nodes.new('ShaderNodeTexCoord')
|
||||||
|
|
||||||
|
def noise(scale):
|
||||||
|
n = nodes.new('ShaderNodeTexNoise')
|
||||||
|
n.inputs['Scale'].default_value = scale
|
||||||
|
n.inputs['Detail'].default_value = 2
|
||||||
|
links.new(uv.outputs['UV'],n.inputs['Vector'])
|
||||||
|
return n
|
||||||
|
|
||||||
|
grain = noise(spec['grainScale'])
|
||||||
|
fine = noise(spec['fineScale'])
|
||||||
|
cloud = noise(18)
|
||||||
|
ramp = nodes.new('ShaderNodeValToRGB')
|
||||||
|
for i,(position,color) in enumerate(spec['grainColors']):
|
||||||
|
element = ramp.color_ramp.elements[i] if i < 2 else ramp.color_ramp.elements.new(position)
|
||||||
|
element.position = position
|
||||||
|
element.color = color
|
||||||
|
links.new(grain.outputs['Fac'],ramp.inputs['Fac'])
|
||||||
|
crystal = nodes.new('ShaderNodeTexVoronoi')
|
||||||
|
crystal.inputs['Scale'].default_value = spec['crystalScale']
|
||||||
|
links.new(uv.outputs['UV'],crystal.inputs['Vector'])
|
||||||
|
mica = nodes.new('ShaderNodeValToRGB')
|
||||||
|
mica.color_ramp.elements[0].position = .12
|
||||||
|
mica.color_ramp.elements[0].color = (.11,.12,.13,1)
|
||||||
|
mica.color_ramp.elements[1].position = .26
|
||||||
|
mica.color_ramp.elements[1].color = (1,1,1,1)
|
||||||
|
links.new(crystal.outputs['Distance'],mica.inputs['Fac'])
|
||||||
|
mix = nodes.new('ShaderNodeMixRGB')
|
||||||
|
mix.blend_type = next(i.identifier for i in mix.bl_rna.properties['blend_type'].enum_items if i.identifier == 'MULTIPLY')
|
||||||
|
mix.inputs[0].default_value = .8
|
||||||
|
links.new(ramp.outputs['Color'],mix.inputs[1])
|
||||||
|
links.new(mica.outputs['Color'],mix.inputs[2])
|
||||||
|
cloud_ramp = nodes.new('ShaderNodeValToRGB')
|
||||||
|
cloud_ramp.color_ramp.elements[0].position = .3
|
||||||
|
cloud_ramp.color_ramp.elements[0].color = (.36,.38,.37,1)
|
||||||
|
cloud_ramp.color_ramp.elements[1].position = .65
|
||||||
|
cloud_ramp.color_ramp.elements[1].color = (1,1,1,1)
|
||||||
|
links.new(cloud.outputs['Fac'],cloud_ramp.inputs['Fac'])
|
||||||
|
weather = nodes.new('ShaderNodeMixRGB')
|
||||||
|
weather.blend_type = mix.blend_type
|
||||||
|
weather.inputs[0].default_value = .5
|
||||||
|
links.new(mix.outputs[0],weather.inputs[1])
|
||||||
|
links.new(cloud_ramp.outputs[0],weather.inputs[2])
|
||||||
|
# The same source as the underlying summit rock anchors the monument's palette.
|
||||||
|
terrain = nodes.new('ShaderNodeTexImage')
|
||||||
|
terrain.image = bpy.data.images.load(str(ROOT/'public/terrain/materials/rock-color.jpg'),check_existing=True)
|
||||||
|
links.new(uv.outputs['UV'],terrain.inputs['Vector'])
|
||||||
|
tone = nodes.new('ShaderNodeHueSaturation')
|
||||||
|
tone.inputs['Saturation'].default_value = spec['terrainSaturation']
|
||||||
|
tone.inputs['Value'].default_value = spec['terrainBrightness']
|
||||||
|
links.new(terrain.outputs['Color'],tone.inputs['Color'])
|
||||||
|
slab = bpy.data.objects['SLAB_SURFACE']
|
||||||
|
root = bpy.data.objects['summit-marker']
|
||||||
|
nearest = min(slab.data.vertices,key=lambda v:(v.co.x-root.location.x)**2+(v.co.y-root.location.y)**2)
|
||||||
|
loop = next(l for l in slab.data.loops if l.vertex_index == nearest.index)
|
||||||
|
ground_uv = slab.data.uv_layers.active.data[loop.index].uv
|
||||||
|
ground = nodes.new('ShaderNodeTexImage')
|
||||||
|
ground.image = bpy.data.images['Summit unique weathered granite']
|
||||||
|
ground.inputs['Vector'].default_value = (ground_uv.x,ground_uv.y,0)
|
||||||
|
palette = nodes.new('ShaderNodeMixRGB')
|
||||||
|
palette.inputs[0].default_value = spec['groundPaletteBlend']
|
||||||
|
links.new(tone.outputs['Color'],palette.inputs[1])
|
||||||
|
links.new(ground.outputs['Color'],palette.inputs[2])
|
||||||
|
surface = nodes.new('ShaderNodeMixRGB')
|
||||||
|
surface.inputs[0].default_value = spec['terrainBlend']
|
||||||
|
links.new(weather.outputs[0],surface.inputs[1])
|
||||||
|
links.new(palette.outputs[0],surface.inputs[2])
|
||||||
|
links.new(surface.outputs[0],bs.inputs['Base Color'])
|
||||||
|
bs.inputs['Roughness'].default_value = spec['roughness']
|
||||||
|
bump = nodes.new('ShaderNodeBump')
|
||||||
|
bump.inputs['Distance'].default_value = spec['relief']
|
||||||
|
bump.inputs['Strength'].default_value = .65
|
||||||
|
links.new(grain.outputs['Fac'],bump.inputs['Height'])
|
||||||
|
micro = nodes.new('ShaderNodeBump')
|
||||||
|
micro.inputs['Distance'].default_value = spec['relief']*.18
|
||||||
|
micro.inputs['Strength'].default_value = .4
|
||||||
|
terrain_normal = nodes.new('ShaderNodeTexImage')
|
||||||
|
terrain_normal.image = bpy.data.images.load(str(ROOT/'public/terrain/materials/rock-normal.jpg'),check_existing=True)
|
||||||
|
terrain_normal.image.colorspace_settings.name = 'Non-Color'
|
||||||
|
links.new(uv.outputs['UV'],terrain_normal.inputs['Vector'])
|
||||||
|
normal = nodes.new('ShaderNodeNormalMap')
|
||||||
|
normal.inputs['Strength'].default_value = .65
|
||||||
|
links.new(terrain_normal.outputs['Color'],normal.inputs['Color'])
|
||||||
|
links.new(normal.outputs['Normal'],bump.inputs['Normal'])
|
||||||
|
links.new(fine.outputs['Fac'],micro.inputs['Height'])
|
||||||
|
links.new(bump.outputs['Normal'],micro.inputs['Normal'])
|
||||||
|
links.new(micro.outputs['Normal'],bs.inputs['Normal'])
|
||||||
|
|
||||||
|
mesh = bpy.data.meshes.new('Temporary granite bake plane')
|
||||||
|
mesh.from_pydata([(0,0,0),(1,0,0),(1,1,0),(0,1,0)],[],[(0,1,2,3)])
|
||||||
|
mesh.uv_layers.new(name='UVMap')
|
||||||
|
for loop in mesh.loops:
|
||||||
|
mesh.uv_layers.active.data[loop.index].uv = mesh.vertices[loop.vertex_index].co.xy
|
||||||
|
plane = bpy.data.objects.new(mesh.name,mesh)
|
||||||
|
scene.collection.objects.link(plane)
|
||||||
|
mesh.materials.append(mat)
|
||||||
|
for obj in scene.objects:
|
||||||
|
obj.select_set(False)
|
||||||
|
plane.select_set(True)
|
||||||
|
bpy.context.view_layer.objects.active = plane
|
||||||
|
target = nodes.new('ShaderNodeTexImage')
|
||||||
|
nodes.active = target
|
||||||
|
images = {}
|
||||||
|
try:
|
||||||
|
for suffix,bake_type in [('color','DIFFUSE'),('normal','NORMAL')]:
|
||||||
|
name = 'marker-grey-granite-'+suffix
|
||||||
|
image = bpy.data.images.get(name) or bpy.data.images.new(name,width=spec['resolution'],height=spec['resolution'],alpha=False)
|
||||||
|
if suffix == 'normal':
|
||||||
|
image.colorspace_settings.name = 'Non-Color'
|
||||||
|
target.image = image
|
||||||
|
scene.render.bake.use_pass_direct = False
|
||||||
|
scene.render.bake.use_pass_indirect = False
|
||||||
|
scene.render.bake.use_pass_color = True
|
||||||
|
bpy.ops.object.bake(type=next(i.identifier for i in bpy.ops.object.bake.get_rna_type().properties['type'].enum_items if i.identifier == bake_type))
|
||||||
|
image.filepath_raw = str(ROOT/'public/terrain/materials'/(name+'.png'))
|
||||||
|
image.file_format = next(i.identifier for i in image.bl_rna.properties['file_format'].enum_items if i.identifier == 'PNG')
|
||||||
|
image.save()
|
||||||
|
image.pack()
|
||||||
|
images[suffix] = image
|
||||||
|
finally:
|
||||||
|
bpy.data.objects.remove(plane,do_unlink=True)
|
||||||
|
bpy.data.meshes.remove(mesh)
|
||||||
|
|
||||||
|
# Replace only this monument's source textures; surrounding rocks keep theirs.
|
||||||
|
for i,material in enumerate(list(body.data.materials)):
|
||||||
|
if material.users > 1:
|
||||||
|
material = material.copy()
|
||||||
|
body.data.materials[i] = material
|
||||||
|
for n in material.node_tree.nodes:
|
||||||
|
if n.type != 'TEX_IMAGE' or not n.image:
|
||||||
|
continue
|
||||||
|
if n.image.name.startswith(('rock-color','marker-grey-granite-color')):
|
||||||
|
n.image = images['color']
|
||||||
|
elif n.image.name.startswith(('rock-normal','marker-grey-granite-normal')):
|
||||||
|
n.image = images['normal']
|
||||||
|
shader = next(n for n in material.node_tree.nodes if n.type == 'BSDF_PRINCIPLED')
|
||||||
|
shader.inputs['Roughness'].default_value = spec['roughness']
|
||||||
|
|
||||||
|
# Recompute neutral weathering, rather than repeatedly brightening the old tint.
|
||||||
|
colors = body.data.color_attributes['Granite weathering']
|
||||||
|
for v in body.data.vertices:
|
||||||
|
x,y,z = v.co
|
||||||
|
panel = abs((x-.18)/max(.16,.57-.18*z))
|
||||||
|
rim = min(1,max(0,(panel-.8)/.4))
|
||||||
|
rim = rim*rim*(3-2*rim)
|
||||||
|
weathering = .045*noise_vector(Vector((x*9,z*9,2.7)))[0]
|
||||||
|
seam = .05*math.exp(-((x+.32-.18*z)/.07)**2)
|
||||||
|
flecks = noise_vector(Vector((x*31,z*31,y*31)))[0]
|
||||||
|
patina = .20*max(0,min(1,(flecks-.05)*3))*rim
|
||||||
|
cleft = .12*math.exp(-((x+.18-.12*z)/.085)**2)
|
||||||
|
grey = spec['faceTint']*(1-rim)+spec['rimTint']*rim+weathering-seam-patina-cleft
|
||||||
|
colors.data[v.index].color = (grey,grey,grey,1)
|
||||||
|
body['graniteFinish'] = 'Light neutral grey quartz/feldspar, dark mica specks, granular microrelief; photo-guided'
|
||||||
|
print('Dedicated grey granite baked; marker source nodes updated, geometry and inscription unchanged.')
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""Shape the left hollow and deep cleft from the supplied oblique photos.
|
||||||
|
|
||||||
|
Restores a saved mesh baseline before every run so edits never accumulate.
|
||||||
|
The front UV atlas and inscription remain attached to their original vertices.
|
||||||
|
"""
|
||||||
|
import bpy, math, json
|
||||||
|
from mathutils import Vector
|
||||||
|
from pathlib import Path
|
||||||
|
ROOT = Path('D:/project/sunrise')
|
||||||
|
spec = json.loads((ROOT/'scripts/constants/marker-shape.json').read_text())
|
||||||
|
scene = bpy.data.scenes['Summit slab and marker']
|
||||||
|
bpy.context.window.scene = scene
|
||||||
|
body = bpy.data.objects['Photo matched summit boulder']
|
||||||
|
root = bpy.data.objects['summit-marker']
|
||||||
|
baseline = bpy.data.meshes.get('Marker before asymmetric hollow')
|
||||||
|
if baseline is None:
|
||||||
|
baseline = body.data.copy()
|
||||||
|
baseline.name = 'Marker before asymmetric hollow'
|
||||||
|
baseline.use_fake_user = True
|
||||||
|
mesh = body.data
|
||||||
|
for vertex, source in zip(mesh.vertices,baseline.vertices):
|
||||||
|
x,y,z = source.co
|
||||||
|
front = max(0,min(1,(.15-y)/.5))
|
||||||
|
bowl = math.exp(-((x+.55)/.34)**2-((z-.86)/.67)**4)
|
||||||
|
# A narrow, slanting cleft between the sunken left bowl and inscribed slab.
|
||||||
|
cleft = math.exp(-((x+.18-.12*z)/.065)**2)*math.exp(-((z-.86)/.88)**4)
|
||||||
|
shoulder = math.exp(-((x+.44)/.48)**2)*max(0,min(1,(z-.8)/.7))
|
||||||
|
vertex.co = (x-.13*shoulder+spec['rightFaceOffset']*max(0,min(1,(x+.12)/.6)),
|
||||||
|
y+front*(spec['leftBowlDepth']*bowl+spec['cleftDepth']*cleft),
|
||||||
|
z+spec['leftShoulderLift']*shoulder)
|
||||||
|
mesh.update()
|
||||||
|
root.scale = spec['scale']
|
||||||
|
root['blockedRadius'] = round(max(math.hypot(v.co.x*root.scale.x,v.co.y*root.scale.y) for v in mesh.vertices)+.12,2)
|
||||||
|
body['asymmetricHollow'] = True
|
||||||
|
# Close the local fissure under the enlarged stone using the shared terrain mesh.
|
||||||
|
# This surface is exported for both rendering and walking; no separate pedestal.
|
||||||
|
bpy.context.view_layer.update()
|
||||||
|
slab = bpy.data.objects['SLAB_SURFACE']
|
||||||
|
ground_baseline = bpy.data.meshes.get('Slab before marker seating')
|
||||||
|
if ground_baseline is None:
|
||||||
|
ground_baseline = slab.data.copy()
|
||||||
|
ground_baseline.name = 'Slab before marker seating'
|
||||||
|
ground_baseline.use_fake_user = True
|
||||||
|
base = min((body.matrix_world@v.co).z for v in mesh.vertices)
|
||||||
|
for vertex,source in zip(slab.data.vertices,ground_baseline.vertices):
|
||||||
|
vertex.co = source.co
|
||||||
|
d = math.hypot(vertex.co.x-root.location.x,vertex.co.y-root.location.y)
|
||||||
|
if d >= spec['bedrockRadius']+spec['bedrockBlend']:
|
||||||
|
continue
|
||||||
|
t = max(0,min(1,(d-spec['bedrockRadius'])/spec['bedrockBlend']))
|
||||||
|
weight = 1-t*t*(3-2*t)
|
||||||
|
target = base+spec['bedrockOverlap']+.025*math.sin(vertex.co.x*3+vertex.co.y*2)
|
||||||
|
if vertex.co.z < target:
|
||||||
|
vertex.co.z += (target-vertex.co.z)*weight
|
||||||
|
slab.data.update()
|
||||||
|
scene.camera.location = root.location+Vector((1.7,-10,3.5))
|
||||||
|
scene.camera.rotation_euler = (root.location+Vector((0,0,1.5))-scene.camera.location).to_track_quat('-Z','Y').to_euler()
|
||||||
|
scene.camera.data.lens = 55
|
||||||
|
print('Asymmetric left hollow sculpted; scale',spec['scale'],'blocked radius',root['blockedRadius'])
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export const SUMMIT_MARKER = {
|
||||||
|
assetRevision: '8de117c42335',
|
||||||
|
buriedDepth: 0.12,
|
||||||
|
footHeight: 0.18,
|
||||||
|
} as const;
|
||||||
File diff suppressed because one or more lines are too long
@@ -4,6 +4,7 @@ import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
|||||||
import { STRUCTURES, STRUCTURE_ASSETS } from '../constants/structures.ts';
|
import { STRUCTURES, STRUCTURE_ASSETS } from '../constants/structures.ts';
|
||||||
import { SUMMIT_RELIEF } from '../constants/summitRelief.ts';
|
import { SUMMIT_RELIEF } from '../constants/summitRelief.ts';
|
||||||
import { SUMMIT_PLATEAU } from '../constants/summitPlateau.ts';
|
import { SUMMIT_PLATEAU } from '../constants/summitPlateau.ts';
|
||||||
|
import { SUMMIT_MARKER } from '../constants/summitMarker.ts';
|
||||||
import { summitPosition } from '../controls/summitCoordinates.ts';
|
import { summitPosition } from '../controls/summitCoordinates.ts';
|
||||||
|
|
||||||
export type StructureAssets = Map<string, Group>;
|
export type StructureAssets = Map<string, Group>;
|
||||||
@@ -14,7 +15,8 @@ export async function loadStructures(includeRelief = false): Promise<StructureAs
|
|||||||
const ids = [...STRUCTURES.towers.map(s => s.id), 'weather-radar', ...STRUCTURES.decks.map(s => s.id),
|
const ids = [...STRUCTURES.towers.map(s => s.id), 'weather-radar', ...STRUCTURES.decks.map(s => s.id),
|
||||||
...(includeRelief ? [SUMMIT_RELIEF.assetId, SUMMIT_PLATEAU.marker.assetId] : [])];
|
...(includeRelief ? [SUMMIT_RELIEF.assetId, SUMMIT_PLATEAU.marker.assetId] : [])];
|
||||||
return new Map(await Promise.all(ids.map(async id => {
|
return new Map(await Promise.all(ids.map(async id => {
|
||||||
const model = await loader.loadAsync(STRUCTURE_ASSETS.baseUrl + '/' + id + '.glb');
|
const revision = id === SUMMIT_PLATEAU.marker.assetId ? `?v=${SUMMIT_MARKER.assetRevision}` : '';
|
||||||
|
const model = await loader.loadAsync(STRUCTURE_ASSETS.baseUrl + '/' + id + '.glb' + revision);
|
||||||
return [id, model.scene] as const;
|
return [id, model.scene] as const;
|
||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Group, Mesh, type Material, type BufferGeometry, type MeshStandardMaterial } from 'three';
|
import { Box3, Group, Mesh, Vector3, type Material, type BufferGeometry, type MeshStandardMaterial } from 'three';
|
||||||
import { SUMMIT_PLATEAU } from '../constants/summitPlateau.ts';
|
import { SUMMIT_PLATEAU } from '../constants/summitPlateau.ts';
|
||||||
|
import { SUMMIT_MARKER } from '../constants/summitMarker.ts';
|
||||||
|
|
||||||
export function createSummitMarker(asset: Group, start: {x: number; z: number},
|
export function createSummitMarker(asset: Group, start: {x: number; z: number},
|
||||||
groundAt: (x: number, z: number) => number, rockMaterial: MeshStandardMaterial) {
|
groundAt: (x: number, z: number) => number, rockMaterial: MeshStandardMaterial) {
|
||||||
@@ -7,6 +8,23 @@ export function createSummitMarker(asset: Group, start: {x: number; z: number},
|
|||||||
group.name = SUMMIT_PLATEAU.marker.assetId;
|
group.name = SUMMIT_PLATEAU.marker.assetId;
|
||||||
const [x,z] = SUMMIT_PLATEAU.marker.offset;
|
const [x,z] = SUMMIT_PLATEAU.marker.offset;
|
||||||
group.position.set(start.x+x, groundAt(start.x+x,start.z+z), start.z+z);
|
group.position.set(start.x+x, groundAt(start.x+x,start.z+z), start.z+z);
|
||||||
|
group.add(asset);
|
||||||
|
group.updateMatrixWorld(true);
|
||||||
|
const base = new Box3().setFromObject(asset).min.y;
|
||||||
|
const point = new Vector3();
|
||||||
|
let seatingOffset = Infinity;
|
||||||
|
asset.traverse(object => {
|
||||||
|
if (!(object instanceof Mesh)) return;
|
||||||
|
const positions = object.geometry.getAttribute('position');
|
||||||
|
for (let i=0; i<positions.count; i++) {
|
||||||
|
point.fromBufferAttribute(positions,i).applyMatrix4(object.matrixWorld);
|
||||||
|
if (point.y-base > SUMMIT_MARKER.footHeight) continue;
|
||||||
|
seatingOffset = Math.min(seatingOffset,
|
||||||
|
groundAt(point.x,point.z)-SUMMIT_MARKER.buriedDepth-point.y);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (Number.isFinite(seatingOffset)) group.position.y += seatingOffset;
|
||||||
|
group.updateMatrixWorld(true);
|
||||||
const granite = rockMaterial.clone();
|
const granite = rockMaterial.clone();
|
||||||
granite.vertexColors = false;
|
granite.vertexColors = false;
|
||||||
const materials = new Set<Material>([granite]);
|
const materials = new Set<Material>([granite]);
|
||||||
@@ -17,7 +35,6 @@ export function createSummitMarker(asset: Group, start: {x: number; z: number},
|
|||||||
(Array.isArray(object.material) ? object.material : [object.material]).forEach(m => materials.add(m));
|
(Array.isArray(object.material) ? object.material : [object.material]).forEach(m => materials.add(m));
|
||||||
if (object.name.replaceAll('_',' ') === 'Marker granite body') object.material = granite;
|
if (object.name.replaceAll('_',' ') === 'Marker granite body') object.material = granite;
|
||||||
});
|
});
|
||||||
group.add(asset);
|
|
||||||
return { group, dispose() {
|
return { group, dispose() {
|
||||||
geometries.forEach(g => g.dispose());
|
geometries.forEach(g => g.dispose());
|
||||||
materials.forEach(m => m.dispose());
|
materials.forEach(m => m.dispose());
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { BoxGeometry, Group, Mesh, MeshStandardMaterial, Vector3 } from 'three';
|
||||||
|
import { createSummitMarker } from '../src/scene/summitMarker.ts';
|
||||||
|
|
||||||
|
test('marker seats its foot on sloping bedrock without deforming the stone', () => {
|
||||||
|
const asset = new Group();
|
||||||
|
const mesh = new Mesh(new BoxGeometry(4, 4, 2), new MeshStandardMaterial());
|
||||||
|
mesh.position.y = 2;
|
||||||
|
asset.add(mesh);
|
||||||
|
const groundAt = (x: number, z: number) => 12 + .3*x - .2*z;
|
||||||
|
const rock = new MeshStandardMaterial();
|
||||||
|
const marker = createSummitMarker(asset, {x:0,z:0}, groundAt, rock);
|
||||||
|
marker.group.updateMatrixWorld(true);
|
||||||
|
const position = mesh.geometry.getAttribute('position');
|
||||||
|
const point = new Vector3();
|
||||||
|
let nearestContact = Infinity;
|
||||||
|
for (let i=0; i<position.count; i++) {
|
||||||
|
point.fromBufferAttribute(position,i).applyMatrix4(mesh.matrixWorld);
|
||||||
|
if (position.getY(i)<0) {
|
||||||
|
const burial = groundAt(point.x,point.z)-point.y;
|
||||||
|
assert.ok(burial > .05, `base must stay below terrain: ${burial}`);
|
||||||
|
assert.equal(position.getY(i),-2,'base geometry remains unchanged');
|
||||||
|
nearestContact = Math.min(nearestContact,burial);
|
||||||
|
} else {
|
||||||
|
assert.equal(position.getY(i),2,'upper geometry remains unchanged');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.ok(nearestContact < .3,'the stone is seated close to its lowest contact');
|
||||||
|
marker.dispose();
|
||||||
|
rock.dispose();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user