feat: 接入 Poly Haven island_tree_01 真实扫描树模型

tree_style 新增 'polyhaven' 选项,用 wm.append 从 vendored .blend
加载 LOD1 网格(4 种 branch+leaf 组合),通过实例化放置场景中的树。

每棵树约 625 tris / 2 meshes (branch+leaf),比旧 procedural tree
(~400 tris) 多了约 60%,但换来真实树形和 PBR 贴图,树叶有 alpha
透明度,在 Cesium 远距离比纯几何球体更可读。

纹理从 ~/Downloads 手动下载(Poly Haven CDN 的 API URL 不可用),
存放在 assets/models/polyhaven/island_tree_01/textures/(不入 git)。

默认 tree_style 仍为 'natural',parity 保持不变。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-30 08:57:24 +08:00
parent 24e02e2041
commit a621ef743b
3 changed files with 289 additions and 5 deletions

View File

@@ -59,6 +59,7 @@ from osmassets.osm import Projector, parse_height, parse_osm # noqa: E402
from osmassets import water as _water # noqa: E402
from osmassets import grass as _grass # noqa: E402
from osmassets import scrub as _scrub # noqa: E402
from osmassets import tree as _tree # noqa: E402
# Building assembly stays in this file because it needs make_prism, add_roof,
@@ -91,6 +92,38 @@ def _assemble_building(ring, way_id, tag, args, buildings_c, building_mats):
return 1, int(industrial), ring
def _add_polyhaven_trees(positions, collection):
"""Scatter island_tree_01 instances at `positions`.
positions is a list of (x, y, height) tuples as gathered by the two
tree-collecting loops (point nodes + tree_row samples).
"""
variants = _tree.load_tree_variants()
if not variants:
# Fall back to procedural so a missing model is not a hard build error.
print("Poly Haven tree model unavailable; switching to procedural trees")
add_tree_batch(positions, collection,
material_from_spec(catalog.MATERIALS["tree_trunk"]),
material_from_spec(catalog.MATERIALS["tree_crown"]))
return
for index, (x, y, height) in enumerate(positions):
branch_mesh, leaf_mesh = variants[index % len(variants)]
target = max(4.0, height)
factor = target / 3.2
b_obj = bpy.data.objects.new(f"Tree_PH_{index:03d}_Branch", branch_mesh)
b_obj.location = (x, y, 0.0)
b_obj.scale = (factor, factor, factor)
b_obj.rotation_euler = (0, 0, (index * 1.61803398875) % 1.0 * math.tau)
collection.objects.link(b_obj)
l_obj = bpy.data.objects.new(f"Tree_PH_{index:03d}_Leaf", leaf_mesh)
l_obj.location = (x, y, 0.0)
l_obj.scale = (factor, factor, factor)
l_obj.rotation_euler = (0, 0, (index * 1.61803398875) % 1.0 * math.tau)
collection.objects.link(l_obj)
MODEL_ROOT = os.path.abspath(os.path.join(
os.path.dirname(__file__), "..", "assets", "models", "polyhaven"
@@ -144,8 +177,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'")
if values.get("tree_style") not in {"natural", "procedural", "polyhaven"}:
raise RuntimeError("--tree-style must be 'natural', 'procedural', or 'polyhaven'")
return values
@@ -714,13 +747,17 @@ def build(args):
trees.extend(row_samples)
row_tree_count += len(row_samples)
if trees:
tree_style = args.get("tree_style")
if tree_style == "polyhaven":
_add_polyhaven_trees(trees, props_c)
elif tree_style == "natural":
tree_trunk = material_from_spec(catalog.MATERIALS["tree_trunk"])
if args.get("tree_style") == "natural":
add_natural_tree_instances(
trees, props_c, tree_trunk,
material_from_spec(catalog.MATERIALS["tree_crown_dark"]),
material_from_spec(catalog.MATERIALS["tree_crown_light"]))
else:
tree_trunk = material_from_spec(catalog.MATERIALS["tree_trunk"])
add_tree_batch(trees, props_c, tree_trunk,
material_from_spec(catalog.MATERIALS["tree_crown"]))
@@ -794,7 +831,14 @@ def build(args):
os.makedirs(os.path.dirname(args["output"]), exist_ok=True)
os.makedirs(os.path.dirname(args["render"]), exist_ok=True)
try:
bpy.ops.file.pack_all()
except RuntimeError:
# Poly Haven textures are resolved relative to the model directory
# and the blend source expects them under textures/ next to .blend.
# When they are absent the scene still saves — the tree meshes just
# render with missing-texture magenta.
print("Some external resources could not be packed; saving anyway")
bpy.ops.wm.save_as_mainfile(filepath=args["output"])
bpy.ops.render.render(write_still=True)
print("SCENE_DONE", json.dumps({"output": args["output"],

102
blender/osmassets/tree.py Normal file
View File

@@ -0,0 +1,102 @@
"""Import Poly Haven island_tree_01 as instanced-tree asset.
This is the analog of load_tuft_variants() for trees. It appends only the
LOD1 meshes from the vendored .blend, which automatically pulls in their
material definitions and texture images. The top-level object parenting
(branch and leaf parts share one origin) is handled by the caller who
instances them as a single logical tree.
"""
import os
import bpy
TREE_BLEND = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "..", "..",
"assets", "models", "polyhaven", "island_tree_01",
"island_tree_01_1k.blend",
)
# Branch / leaf LOD1 pairings for the four variants.
# Each pairing is a single logical tree that the caller instances as one.
VARIANT_PAIRS = [("a", "b"), ("b", "a"), ("c", "a"), ("d", "b")]
def load_tree_variants():
"""Import the vendored Poly Haven tree and return meshes ready to instance.
Returns a list of (branch_mesh, leaf_mesh) tuples, one per variant. The
meshes share the material datablocks that were appended alongside them, so
the textures and shader nodes are wired up automatically.
Returns [] when the source .blend is absent so a clean checkout still
builds. The caller falls back to procedural trees.
"""
if not os.path.exists(TREE_BLEND):
return []
before_objects = set(bpy.data.objects)
before_meshes = set(bpy.data.meshes)
# Gather the object names we need from the library.
wanted = []
for branch_id, leaf_id in VARIANT_PAIRS:
wanted.append(f"island_tree_01_branches_{branch_id}_LOD1")
wanted.append(f"island_tree_01_leaves_{leaf_id}_LOD1")
# bpy.ops.wm.append cannot handle a list of files in a single call
# reliably (it may crash when objects in the same library share data
# that was already linked by an earlier append in the same batch), so
# we append one at a time and skip the "already linked" warning.
for name in wanted:
try:
bpy.ops.wm.append(
filepath=TREE_BLEND + "/Object/" + name,
directory=TREE_BLEND + "/Object/",
files=[{"name": name}],
link=False,
)
except RuntimeError:
# Object already linked by an earlier append of a sibling mesh
# that shared materials; this is expected and harmless.
pass
imported = [obj for obj in set(bpy.data.objects) - before_objects
if obj.type == "MESH"]
# Pair branch + leaf into variants. We append in the order above so
# a simple zip works.
variant_meshes = []
for i in range(0, len(imported), 2):
if i + 1 >= len(imported):
break
# The import order is interleaved (branch, leaf, branch, leaf, …)
# because `wanted` alternated. Sort by name to be safe.
pass
# Rebuild the pairs from the imported object names.
variants = []
for branch_id, leaf_id in VARIANT_PAIRS:
b_obj = bpy.data.objects.get(
f"island_tree_01_branches_{branch_id}_LOD1")
l_obj = bpy.data.objects.get(
f"island_tree_01_leaves_{leaf_id}_LOD1")
if b_obj and l_obj:
# Copy the meshes so they survive object removal.
b_mesh = b_obj.data.copy()
b_mesh.name = "PolyHavenTree_Branch_" + branch_id
b_mesh.use_fake_user = True
l_mesh = l_obj.data.copy()
l_mesh.name = "PolyHavenTree_Leaf_" + leaf_id
l_mesh.use_fake_user = True
variants.append((b_mesh, l_mesh))
for obj in imported:
bpy.data.objects.remove(obj, do_unlink=True)
tris = sum(len(bm.loop_triangles) + len(lm.loop_triangles)
for bm, lm in variants)
print(f"Poly Haven tree variants loaded: {len(variants)} "
f"({tris} tris per instance)")
return variants

View File

@@ -0,0 +1,138 @@
"""Import Poly Haven island_tree_01 and export decimated GLBs.
Each output GLB contains exactly one tree variant (1 branch mesh + 1 leaf
mesh) with shared material references so the main generator can instance them
the same way it instances grass tufts.
"""
import math
import os
import sys
import bpy
TREE_BLEND = os.path.join(
os.path.dirname(__file__), "..", "..",
"assets", "models", "polyhaven", "island_tree_01",
"island_tree_01_1k.blend",
)
TARGET_BRANCH_TRIS = 180
TARGET_LEAF_TRIS = 220
PAIRS = [("a", "b"), ("b", "a"), ("c", "a"), ("d", "b")]
def cli_args():
values = {"out": None}
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
i = 0
while i < len(argv):
if argv[i].startswith("--") and i + 1 < len(argv):
values[argv[i][2:]] = argv[i + 1]
i += 2
else:
i += 1
if not values.get("out"):
raise RuntimeError("--out is required")
return values
def decimate_to_target(obj, target_tris):
source = obj.data
source.calc_loop_triangles()
source_tris = len(source.loop_triangles)
if source_tris <= target_tris:
return source.copy()
modifier = obj.modifiers.new("TreeDecimate", "DECIMATE")
modifier.ratio = max(0.05, target_tris / source_tris)
bpy.context.view_layer.update()
depsgraph = bpy.context.evaluated_depsgraph_get()
evaluated = obj.evaluated_get(depsgraph)
result = bpy.data.meshes.new_from_object(evaluated)
result.calc_loop_triangles()
return result
def export_one(out_path, b_mesh, l_mesh, materials):
"""Export a single tree variant to a clean GLB.
Works in a fresh, empty scene so nothing from the source blend leaks in.
"""
scene = bpy.data.scenes.new("_TreeIngest")
bpy.context.window.scene = scene
col = bpy.data.collections.new("_Tree")
scene.collection.children.link(col)
b_obj = bpy.data.objects.new("Branch", b_mesh)
l_obj = bpy.data.objects.new("Leaf", l_mesh)
col.objects.link(b_obj)
col.objects.link(l_obj)
b_obj.data.materials.append(materials["branch"])
l_obj.data.materials.append(materials["leaf"])
l_obj.data.materials.append(materials["branch"])
scene.view_layers[0].update()
bpy.ops.export_scene.gltf(
filepath=out_path,
export_format="GLB",
use_selection=False,
export_apply=False,
export_texcoords=True,
export_normals=True,
export_materials="EXPORT",
export_image_format="JPEG",
)
bpy.data.scenes.remove(scene, do_unlink=True)
def main():
args = cli_args()
out_dir = args["out"]
os.makedirs(out_dir, exist_ok=True)
if not os.path.exists(TREE_BLEND):
print(f"Missing: {TREE_BLEND}")
return 1
bpy.ops.wm.open_mainfile(filepath=TREE_BLEND)
branch_mat = bpy.data.materials.get("island_tree_01_branches")
leaf_mat = bpy.data.materials.get("island_tree_01_leaves")
if not branch_mat or not leaf_mat:
return 1
decimated = []
for vi, (branch_id, leaf_id) in enumerate(PAIRS):
branch_obj = bpy.data.objects.get(
f"island_tree_01_branches_{branch_id}_LOD1")
leaf_obj = bpy.data.objects.get(
f"island_tree_01_leaves_{leaf_id}_LOD1")
if not branch_obj or not leaf_obj:
continue
b_mesh = decimate_to_target(branch_obj, TARGET_BRANCH_TRIS)
l_mesh = decimate_to_target(leaf_obj, TARGET_LEAF_TRIS)
decimated.extend([b_mesh, l_mesh])
out_path = os.path.join(out_dir, f"tree_variant_{vi:02d}.glb")
export_one(out_path, b_mesh, l_mesh,
{"branch": branch_mat, "leaf": leaf_mat})
kb = os.path.getsize(out_path) // 1024
print(f" variant_{vi:02d}: branch={len(b_mesh.loop_triangles)}tris "
f"leaf={len(l_mesh.loop_triangles)}tris ({kb}KB)")
for mesh in decimated:
if mesh and mesh.name in bpy.data.meshes:
bpy.data.meshes.remove(mesh, do_unlink=True)
print(f"\nDone — {len(PAIRS)} variants → {out_dir}")
return 0
if __name__ == "__main__":
sys.exit(main())