Use lightweight natural tree rendering

This commit is contained in:
2026-07-27 12:36:55 +08:00
parent cf69c99d20
commit 59a22ea697
9 changed files with 161 additions and 196640 deletions

View File

@@ -1,16 +0,0 @@
newmtl None
Ka 1.000000 1.000000 1.000000
Kd 1.000000 1.000000 1.000000
Ks 0.000000 0.000000 0.000000
d 1.000000
illum 1
map_Kd HazelnutBark.png
newmtl None_Hazelnut.png
Ka 1.000000 1.000000 1.000000
Kd 1.000000 1.000000 1.000000
Ks 0.000000 0.000000 0.000000
d 1.000000
illum 1
map_Kd HazelnutLeaves.png
map_d HazelnutLeaves.png

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

View File

@@ -11,10 +11,12 @@
--osm "/path/to/input.osm" \
--geojson "/path/to/osm2streets_web_out" \
--output "/path/to/output.blend" \
--render "/path/to/preview.png"
--render "/path/to/preview.png" \
--tree-style natural
```
`--geojson` 为可选参数。不提供时,道路使用简单的 OSM highway 折线,而非详细 osm2streets 几何。
`--tree-style` 可选 `natural``procedural`。默认 `natural` 使用低面数树干和多层不规则树冠,形态比球形程序化树冠更自然,渲染也比外部 OBJ 树模型轻;`procedural` 最轻但更卡通。
使用 `--office-overrides` 指定一组 OSM way ID逗号分隔这些建筑将渲染为办公楼风格即使其 OSM 标签为 `building=industrial`
@@ -25,7 +27,7 @@
### 说明
- OSM `bounds` 元素定义场景范围(排除远处的地铁等关系成员)
- `natural=tree` 节点 → 独立树木(程序化或模型树冠)
- `natural=tree` 节点 → 独立树木(默认低面数自然树冠)
- `natural=tree_row` 路径 → 沿路径均匀分布的树木
- `landuse=grass` → 绿色地面
- `natural=scrub` → 低矮灌木丛

View File

@@ -19,6 +19,9 @@ import numpy as np
EXPORT_TINTS = {
"Grass": ((0.12, 0.48, 0.08), 0.72),
"Tree Crown Dark": ((0.06, 0.22, 0.05), 0.18),
"Tree Crown Light": ((0.16, 0.42, 0.09), 0.16),
"Scrub Ground Cover": ((0.08, 0.28, 0.07), 0.28),
"Office White Plaster Facade": ((0.92, 0.94, 0.92), 0.38),
"Office White Metal Facade": ((0.92, 0.94, 0.92), 0.68),
"Office Light Flat Roof": ((0.82, 0.86, 0.88), 0.35),
@@ -33,12 +36,18 @@ EXPORT_METALLIC_OVERRIDES = {
}
EXPORT_BASE_COLOR_OVERRIDES = {
"Tree Crown": (0.11, 0.34, 0.075),
"Tree Crown Dark": (0.065, 0.24, 0.055),
"Tree Crown Light": (0.14, 0.40, 0.085),
"Office White Plaster Facade": (0.93, 0.94, 0.91),
"Office White Metal Facade": (0.93, 0.94, 0.91),
"Office Light Flat Roof": (0.88, 0.90, 0.88),
}
EXPORT_EMISSION_OVERRIDES = {
"Tree Crown": ((0.04, 0.11, 0.035), 0.02),
"Tree Crown Dark": ((0.025, 0.07, 0.02), 0.015),
"Tree Crown Light": ((0.045, 0.12, 0.03), 0.015),
"Office White Plaster Facade": ((0.93, 0.94, 0.91), 0.18),
"Office White Metal Facade": ((0.93, 0.94, 0.91), 0.18),
"Office Light Flat Roof": ((0.88, 0.90, 0.88), 0.14),
@@ -85,10 +94,17 @@ def source_color(material):
return color
def source_principled_value(material, input_name, fallback):
def principled_bsdf(material):
if not material.use_nodes:
return fallback
node = material.node_tree.nodes.get("Principled BSDF")
return None
for node in material.node_tree.nodes:
if node.type == "BSDF_PRINCIPLED":
return node
return None
def source_principled_value(material, input_name, fallback):
node = principled_bsdf(material)
if not node or input_name not in node.inputs:
return fallback
return node.inputs[input_name].default_value
@@ -144,8 +160,8 @@ def tree_crown_image():
np.sin((x * 89.0 + y * 67.0) * np.pi) * 0.07
)
noise = np.clip(0.5 + noise, 0.0, 1.0)[..., None]
dark = np.asarray((0.035, 0.16, 0.045), dtype=np.float32)
light = np.asarray((0.12, 0.42, 0.13), dtype=np.float32)
dark = np.asarray((0.04, 0.17, 0.04), dtype=np.float32)
light = np.asarray((0.17, 0.46, 0.115), dtype=np.float32)
rgb = dark + (light - dark) * noise
rgba = np.concatenate(
(rgb, np.ones((size, size, 1), dtype=np.float32)), axis=2)

View File

@@ -27,23 +27,17 @@ import xml.etree.ElementTree as ET
from collections import defaultdict
import bpy
from mathutils import Matrix, Vector
from mathutils import Vector
TEXTURE_ROOT = os.path.abspath(os.path.join(
os.path.dirname(__file__), "..", "assets", "textures", "polyhaven"
))
MODEL_ROOT = os.path.abspath(os.path.join(
os.path.dirname(__file__), "..", "assets", "models", "polyhaven"
))
TREE_MODEL_PATH = os.path.join(
MODEL_ROOT, "78-hazelnutbush", "Hazelnut.obj"
)
def cli_args():
values = {"osm": None, "geojson": None, "output": None, "render": None,
"office_overrides": ""}
"office_overrides": "", "tree_style": "natural"}
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
i = 0
while i < len(argv):
@@ -67,6 +61,8 @@ def cli_args():
values["office_overrides"] = set()
else:
values["office_overrides"] = set()
if values.get("tree_style") not in {"natural", "procedural"}:
raise RuntimeError("--tree-style must be 'natural' or 'procedural'")
return values
@@ -145,11 +141,20 @@ def new_collection(name):
return collection
def principled_bsdf(material):
if not material.use_nodes:
return None
for node in material.node_tree.nodes:
if node.type == "BSDF_PRINCIPLED":
return node
return None
def make_material(name, color, roughness=0.8, metallic=0.0):
material = bpy.data.materials.get(name) or bpy.data.materials.new(name)
material.diffuse_color = (*color, 1.0)
material.use_nodes = True
bsdf = material.node_tree.nodes.get("Principled BSDF")
bsdf = principled_bsdf(material)
if bsdf:
bsdf.inputs["Base Color"].default_value = (*color, 1.0)
bsdf.inputs["Roughness"].default_value = roughness
@@ -160,7 +165,7 @@ def make_material(name, color, roughness=0.8, metallic=0.0):
def add_procedural_surface(material, colors, scale=2.0, detail=2.0, bump_strength=0.08):
nodes = material.node_tree.nodes
links = material.node_tree.links
bsdf = nodes.get("Principled BSDF")
bsdf = principled_bsdf(material)
if not bsdf:
return
noise = nodes.new("ShaderNodeTexNoise")
@@ -192,7 +197,9 @@ def make_textured_material(name, diffuse_file, normal_file, roughness,
material = make_material(name, (0.5, 0.5, 0.5), roughness, metallic)
nodes = material.node_tree.nodes
links = material.node_tree.links
bsdf = nodes.get("Principled BSDF")
bsdf = principled_bsdf(material)
if not bsdf:
return material
texcoord = nodes.new("ShaderNodeTexCoord")
mapping = nodes.new("ShaderNodeMapping")
mapping.inputs["Scale"].default_value = (scale, scale, scale)
@@ -231,124 +238,6 @@ def make_textured_material(name, diffuse_file, normal_file, roughness,
return material
def make_image_material(name, image_path, roughness=0.8, metallic=0.0,
alpha_path=None, alpha_clip=0.33,
cull_backface=False, tint=None, tint_factor=0.0,
saturation=1.0, value=1.0, emission_color=None,
emission_strength=0.0):
material = make_material(name, (0.5, 0.5, 0.5), roughness, metallic)
nodes = material.node_tree.nodes
links = material.node_tree.links
bsdf = nodes.get("Principled BSDF")
if not bsdf or not os.path.exists(image_path):
return material
image = nodes.new("ShaderNodeTexImage")
image.image = bpy.data.images.load(image_path, check_existing=True)
color_output = image.outputs["Color"]
if saturation != 1.0 or value != 1.0:
hsv = nodes.new("ShaderNodeHueSaturation")
hsv.inputs["Saturation"].default_value = saturation
hsv.inputs["Value"].default_value = value
links.new(color_output, hsv.inputs["Color"])
color_output = hsv.outputs["Color"]
if tint and tint_factor > 0.0:
tint_node = nodes.new("ShaderNodeRGB")
tint_node.outputs["Color"].default_value = (*tint, 1.0)
mix = nodes.new("ShaderNodeMixRGB")
mix.blend_type = "MIX"
mix.inputs["Fac"].default_value = tint_factor
links.new(color_output, mix.inputs[1])
links.new(tint_node.outputs["Color"], mix.inputs[2])
color_output = mix.outputs["Color"]
links.new(color_output, bsdf.inputs["Base Color"])
if "Specular IOR Level" in bsdf.inputs:
bsdf.inputs["Specular IOR Level"].default_value = 0.2
elif "Specular" in bsdf.inputs:
bsdf.inputs["Specular"].default_value = 0.2
if emission_color and emission_strength > 0.0:
if "Emission Color" in bsdf.inputs:
bsdf.inputs["Emission Color"].default_value = (*emission_color, 1.0)
elif "Emission" in bsdf.inputs:
bsdf.inputs["Emission"].default_value = (*emission_color, 1.0)
if "Emission Strength" in bsdf.inputs:
bsdf.inputs["Emission Strength"].default_value = emission_strength
alpha_source = image
if alpha_path and os.path.exists(alpha_path):
alpha_source = nodes.new("ShaderNodeTexImage")
alpha_source.image = bpy.data.images.load(alpha_path, check_existing=True)
alpha_source.image.colorspace_settings.name = "Non-Color"
if "Alpha" in bsdf.inputs:
links.new(alpha_source.outputs["Alpha"], bsdf.inputs["Alpha"])
if hasattr(material, "blend_method"):
material.blend_method = "CLIP" if alpha_path else "OPAQUE"
if hasattr(material, "shadow_method"):
material.shadow_method = "CLIP" if alpha_path else "OPAQUE"
if hasattr(material, "alpha_threshold"):
material.alpha_threshold = alpha_clip
if hasattr(material, "surface_render_method") and alpha_path:
material.surface_render_method = "DITHERED"
if hasattr(material, "use_backface_culling"):
material.use_backface_culling = cull_backface
return material
def import_model(filepath):
extension = os.path.splitext(filepath)[1].lower()
if extension in {".gltf", ".glb"}:
bpy.ops.import_scene.gltf(filepath=filepath)
return
if extension == ".obj":
if hasattr(bpy.ops.wm, "obj_import"):
bpy.ops.wm.obj_import(filepath=filepath)
return
bpy.ops.import_scene.obj(filepath=filepath)
return
raise RuntimeError("Unsupported tree model format: " + extension)
def assign_tree_model_materials(objects):
model_dir = os.path.dirname(TREE_MODEL_PATH)
bark = make_image_material(
"Hazelnut Bark",
os.path.join(model_dir, "HazelnutBark.png"),
roughness=0.86,
)
leaves = make_image_material(
"Hazelnut Leaves",
os.path.join(model_dir, "HazelnutLeaves.png"),
roughness=0.82,
alpha_path=os.path.join(model_dir, "HazelnutLeavesMask.png"),
alpha_clip=0.18,
cull_backface=False,
tint=(0.25, 0.52, 0.18),
tint_factor=0.62,
saturation=1.45,
value=1.42,
emission_color=(0.21, 0.36, 0.15),
emission_strength=0.26,
)
for obj in objects:
if obj.type != "MESH":
continue
obj.data.materials.clear()
lowered = obj.name.lower()
if "leaf" in lowered:
obj.data.materials.append(leaves)
else:
obj.data.materials.append(bark)
for polygon in obj.data.polygons:
polygon.material_index = 0
polygon.use_smooth = True
class MeshBatch:
def __init__(self, name, collection, material):
self.name = name
@@ -637,66 +526,103 @@ def add_tree_batch(positions, collection, trunk_material, leaf_material):
leaves.finish()
def add_tree_model_instances(positions, collection):
if not positions or not os.path.exists(TREE_MODEL_PATH):
return False
def add_natural_tree_instances(positions, collection, trunk_material,
leaf_dark_material, leaf_light_material):
trunk = MeshBatch("Tree_Natural_Trunks", collection, trunk_material)
lower = MeshBatch("Tree_Natural_Crowns_Dark", collection, leaf_dark_material)
upper = MeshBatch("Tree_Natural_Crowns_Light", collection, leaf_light_material)
trunk_sides = 9
crown_sides = 9
crown_rings = 5
before = set(bpy.data.objects)
try:
import_model(TREE_MODEL_PATH)
except Exception as exc:
print("TREE_MODEL_IMPORT_FAILED", exc)
return False
template_objects = [obj for obj in bpy.data.objects if obj not in before]
template_meshes = [obj for obj in template_objects if obj.type == "MESH"]
if not template_meshes:
for obj in template_objects:
bpy.data.objects.remove(obj, do_unlink=True)
return False
assign_tree_model_materials(template_meshes)
for obj in template_objects:
link_object_to_collection(obj, collection)
min_z = min(
(obj.matrix_world @ Vector(corner)).z
for obj in template_meshes
for corner in obj.bound_box
def add_blob(batch, cx, cy, cz, rx, ry, rz, phase, squash=1.0):
start = len(batch.vertices)
for ring in range(crown_rings):
latitude = -math.pi / 2 + math.pi * ring / (crown_rings - 1)
ring_radius = math.cos(latitude)
for side in range(crown_sides):
angle = math.tau * side / crown_sides
wobble = (
1.0 +
0.14 * math.sin(phase + side * 1.31 + ring * 0.83) +
0.07 * math.sin(phase * 0.7 + side * 2.11)
)
max_z = max(
(obj.matrix_world @ Vector(corner)).z
for obj in template_meshes
for corner in obj.bound_box
)
source_height = max(0.1, max_z - min_z)
base_shift = Matrix.Translation((0.0, 0.0, -min_z))
for obj in template_objects:
obj.matrix_world = base_shift @ obj.matrix_world
obj.hide_viewport = True
obj.hide_render = True
obj.name = "Tree_Template_" + obj.name
batch.vertices.append((
cx + rx * ring_radius * math.cos(angle) * wobble,
cy + ry * ring_radius * math.sin(angle) * wobble,
cz + rz * math.sin(latitude) * squash,
))
for ring in range(crown_rings - 1):
for side in range(crown_sides):
next_side = (side + 1) % crown_sides
batch.faces.append((
start + ring * crown_sides + side,
start + ring * crown_sides + next_side,
start + (ring + 1) * crown_sides + next_side,
start + (ring + 1) * crown_sides + side,
))
for index, (x, y, height) in enumerate(positions):
target_height = max(4.6, min(9.2, height * 1.12))
scale = target_height / source_height
yaw = Matrix.Rotation((index * 1.61803398875) % math.tau, 4, "Z")
transform = Matrix.Translation((x, y, 0.0)) @ yaw @ Matrix.Diagonal(
(scale, scale, scale, 1.0)
)
for template in template_objects:
inst = template.copy()
if template.data:
inst.data = template.data
inst.animation_data_clear()
inst.matrix_world = transform @ template.matrix_world
inst.hide_viewport = False
inst.hide_render = False
inst.name = "Tree_Model_" + str(index)
collection.objects.link(inst)
target_height = max(4.8, min(8.8, height * 1.08))
phase = index * 1.61803398875
trunk_height = target_height * (0.48 + 0.05 * math.sin(phase))
trunk_radius = max(0.13, target_height * 0.038)
lean_x = math.sin(phase * 1.7) * target_height * 0.025
lean_y = math.cos(phase * 1.3) * target_height * 0.025
return True
base = len(trunk.vertices)
trunk_levels = [
(0.0, trunk_radius),
(trunk_height * 0.55, trunk_radius * 0.78),
(trunk_height, trunk_radius * 0.48),
]
for level_index, (z, radius) in enumerate(trunk_levels):
offset_x = lean_x * level_index / (len(trunk_levels) - 1)
offset_y = lean_y * level_index / (len(trunk_levels) - 1)
for side in range(trunk_sides):
angle = math.tau * side / trunk_sides
trunk.vertices.append((
x + offset_x + radius * math.cos(angle),
y + offset_y + radius * math.sin(angle),
z,
))
trunk.faces.append(tuple(base + i for i in range(trunk_sides - 1, -1, -1)))
for level_index in range(len(trunk_levels) - 1):
row = base + level_index * trunk_sides
next_row = row + trunk_sides
for side in range(trunk_sides):
next_side = (side + 1) % trunk_sides
trunk.faces.append((row + side, row + next_side,
next_row + next_side, next_row + side))
top_row = base + (len(trunk_levels) - 1) * trunk_sides
trunk.faces.append(tuple(top_row + i for i in range(trunk_sides)))
crown_x = x + lean_x
crown_y = y + lean_y
crown_z = trunk_height + target_height * 0.22
crown_r = target_height * (0.35 + 0.035 * math.sin(phase * 0.9))
# Dark lower mass gives the canopy volume when viewed obliquely.
add_blob(lower, crown_x, crown_y, crown_z - crown_r * 0.08,
crown_r * 0.95, crown_r * 0.78, crown_r * 0.52,
phase, squash=0.82)
add_blob(lower, crown_x - crown_r * 0.46, crown_y + crown_r * 0.05,
crown_z - crown_r * 0.02, crown_r * 0.62, crown_r * 0.50,
crown_r * 0.42, phase + 0.8, squash=0.80)
add_blob(lower, crown_x + crown_r * 0.42, crown_y - crown_r * 0.08,
crown_z, crown_r * 0.58, crown_r * 0.48,
crown_r * 0.40, phase + 1.9, squash=0.80)
# Lighter upper clumps break the silhouette without adding heavy geometry.
add_blob(upper, crown_x + crown_r * 0.05, crown_y + crown_r * 0.04,
crown_z + crown_r * 0.34, crown_r * 0.70,
crown_r * 0.58, crown_r * 0.38, phase + 2.7, squash=0.74)
add_blob(upper, crown_x - crown_r * 0.24, crown_y - crown_r * 0.22,
crown_z + crown_r * 0.23, crown_r * 0.46,
crown_r * 0.40, crown_r * 0.30, phase + 3.5, squash=0.72)
trunk.finish()
lower.finish()
upper.finish()
def polygon_area(ring):
@@ -741,7 +667,7 @@ def add_scrub_patch(name, ring, material, collection):
width = max(0.1, xmax - xmin)
depth = max(0.1, ymax - ymin)
area = polygon_area(ring)
clump_count = max(8, min(70, int(area / 24.0) + 6))
clump_count = max(10, min(90, int(area / 18.0) + 8))
sides = 10
rings = 4
@@ -780,8 +706,8 @@ def add_scrub_patch(name, ring, material, collection):
if not point_in_polygon((x, y), ring):
continue
scale = 0.65 + 0.55 * ((attempts * 0.754877666) % 1.0)
add_dome(x, y, 0.82 * scale, 0.64 * scale,
0.18 + 0.14 * scale, attempts * 0.91)
add_dome(x, y, 0.95 * scale, 0.72 * scale,
0.34 + 0.28 * scale, attempts * 0.91)
added += 1
obj = batch.finish()
@@ -905,7 +831,7 @@ def build(args):
scrub_mat = make_textured_material(
"Scrub Ground Cover", "leafy_grass_diff_1k.jpg",
"leafy_grass_nor_gl_1k.jpg", roughness=0.96, scale=13.0,
tint=(0.05, 0.34, 0.08), tint_factor=0.42)
tint=(0.09, 0.32, 0.07), tint_factor=0.42)
fountain_mats = {
"fountain_stone": make_material("Fountain Stone", (0.42, 0.45, 0.43), 0.72),
"fountain_water": make_material("Fountain Water", (0.03, 0.32, 0.42), 0.16, 0.05),
@@ -1068,13 +994,28 @@ def build(args):
trees.extend(row_samples)
row_tree_count += len(row_samples)
if trees:
if not add_tree_model_instances(trees, props_c):
tree_style = args.get("tree_style")
if tree_style == "natural":
tree_trunk = make_textured_material(
"Tree Trunk", "bark_brown_01_diff_1k.jpg",
"bark_brown_01_nor_gl_1k.jpg", roughness=0.92, scale=5.0)
tree_leaf = make_material("Tree Crown", (0.08, 0.30, 0.09), 0.88)
leaf_dark = make_material("Tree Crown Dark", (0.065, 0.25, 0.055), 0.90)
add_procedural_surface(leaf_dark,
((0.035, 0.14, 0.035), (0.12, 0.36, 0.08)),
scale=3.2, detail=3.8, bump_strength=0.08)
leaf_light = make_material("Tree Crown Light", (0.13, 0.42, 0.09), 0.88)
add_procedural_surface(leaf_light,
((0.07, 0.25, 0.05), (0.22, 0.56, 0.13)),
scale=3.6, detail=3.4, bump_strength=0.07)
add_natural_tree_instances(trees, props_c, tree_trunk,
leaf_dark, leaf_light)
else:
tree_trunk = make_textured_material(
"Tree Trunk", "bark_brown_01_diff_1k.jpg",
"bark_brown_01_nor_gl_1k.jpg", roughness=0.92, scale=5.0)
tree_leaf = make_material("Tree Crown", (0.10, 0.36, 0.08), 0.88)
add_procedural_surface(tree_leaf,
((0.035, 0.16, 0.045), (0.12, 0.42, 0.13)),
((0.04, 0.18, 0.04), (0.18, 0.50, 0.12)),
scale=2.8, detail=3.2, bump_strength=0.10)
add_tree_batch(trees, props_c, tree_trunk, tree_leaf)