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 water as _water # noqa: E402
|
||||||
from osmassets import grass as _grass # noqa: E402
|
from osmassets import grass as _grass # noqa: E402
|
||||||
from osmassets import scrub as _scrub # 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,
|
# 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
|
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(
|
MODEL_ROOT = os.path.abspath(os.path.join(
|
||||||
os.path.dirname(__file__), "..", "assets", "models", "polyhaven"
|
os.path.dirname(__file__), "..", "assets", "models", "polyhaven"
|
||||||
@@ -144,8 +177,8 @@ def cli_args():
|
|||||||
values["office_overrides"] = set()
|
values["office_overrides"] = set()
|
||||||
else:
|
else:
|
||||||
values["office_overrides"] = set()
|
values["office_overrides"] = set()
|
||||||
if values.get("tree_style") not in {"natural", "procedural"}:
|
if values.get("tree_style") not in {"natural", "procedural", "polyhaven"}:
|
||||||
raise RuntimeError("--tree-style must be 'natural' or 'procedural'")
|
raise RuntimeError("--tree-style must be 'natural', 'procedural', or 'polyhaven'")
|
||||||
return values
|
return values
|
||||||
|
|
||||||
|
|
||||||
@@ -714,13 +747,17 @@ def build(args):
|
|||||||
trees.extend(row_samples)
|
trees.extend(row_samples)
|
||||||
row_tree_count += len(row_samples)
|
row_tree_count += len(row_samples)
|
||||||
if trees:
|
if trees:
|
||||||
tree_trunk = material_from_spec(catalog.MATERIALS["tree_trunk"])
|
tree_style = args.get("tree_style")
|
||||||
if args.get("tree_style") == "natural":
|
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(
|
add_natural_tree_instances(
|
||||||
trees, props_c, tree_trunk,
|
trees, props_c, tree_trunk,
|
||||||
material_from_spec(catalog.MATERIALS["tree_crown_dark"]),
|
material_from_spec(catalog.MATERIALS["tree_crown_dark"]),
|
||||||
material_from_spec(catalog.MATERIALS["tree_crown_light"]))
|
material_from_spec(catalog.MATERIALS["tree_crown_light"]))
|
||||||
else:
|
else:
|
||||||
|
tree_trunk = material_from_spec(catalog.MATERIALS["tree_trunk"])
|
||||||
add_tree_batch(trees, props_c, tree_trunk,
|
add_tree_batch(trees, props_c, tree_trunk,
|
||||||
material_from_spec(catalog.MATERIALS["tree_crown"]))
|
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["output"]), exist_ok=True)
|
||||||
os.makedirs(os.path.dirname(args["render"]), 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.wm.save_as_mainfile(filepath=args["output"])
|
||||||
bpy.ops.render.render(write_still=True)
|
bpy.ops.render.render(write_still=True)
|
||||||
print("SCENE_DONE", json.dumps({"output": args["output"],
|
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