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:
@@ -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_trunk = material_from_spec(catalog.MATERIALS["tree_trunk"])
|
||||
if args.get("tree_style") == "natural":
|
||||
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"])
|
||||
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)
|
||||
bpy.ops.file.pack_all()
|
||||
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
102
blender/osmassets/tree.py
Normal 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
|
||||
138
blender/tools/ingest_tree.py
Normal file
138
blender/tools/ingest_tree.py
Normal 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())
|
||||
Reference in New Issue
Block a user