feat: 树渲染改用 apple/fattree 模型,修复 Cesium alpha 抠图丢失

tree_style 新增 apple(SpeedTree Red Delicious,4475 tris,alpha 抠图叶片
+ 法线贴图)和 fattree(低面数卡通树,2238 tris,实体几何)。

高度改为按模型自身包围盒归一化到目标高度,树根落在 z=0,不再用魔数;
每棵树按序号做确定性抖动(缩放 ±14%、黄金角偏航、±3° 倾斜),
tree_row 采样高度全部相同,不抖动就是同一棵树盖章 156 次。

移除 polyhaven 样式和 island_tree_01 资产(76MB)+ ingest_tree.py:
该样式 append 的 *_LOD1 并不是完整的树,枝干只有 0.41 单位高,叶片是
挂在原点下方的平面簇,本是给源文件几何节点散布用的碎片。

Cesium 侧修三处:

- make_export_material 把 Alpha 恒定写死为 1.0,叶片卡片整块导出,而
  SpeedTree 图集抠掉的区域是纯黑,在 Cesium 里就是黑色色块。
- Blender 4.2 起 glTF 导出不再读 blend_method(仍可写但已失效,写 CLIP
  读回来是 HASHED),改为从节点树推断 alpha 模式,alpha 直连 BSDF 会落到
  BLEND。新增 materials.link_alpha_clip() 构造导出器识别的
  1 - (alpha < cutoff) 结构,得到 alphaMode=MASK。
- 新增 alpha_dilated_image():把不透明像素颜色向抠图区域外扩 8 圈,
  避免 mipmap 把黑色平均进叶缘。叶缘相邻的纯黑像素 10.3% → 0.6%。

另修两个既有 bug:

- 材质槽在 mesh 上,181 棵树共享一个 datablock,第一棵替换后其余会把结果
  再包一层,产生 Cesium Cesium Cesium... 的材质名;烘焙图缓存按材质名索引,
  每轮再嵌一份同样的贴图。GLB 22.46MB → 20.76MB,materials 270 → 23。
- shrub_02 从 glTF 带进来一个 Math 节点接在 Alpha 上,但其 JPEG 贴图 alpha
  全是 1.0,只判断「连了 Alpha」会误判为抠图材质。

模型资产为第三方素材,已 gitignore,缺失时回落到 natural;
来源见 assets/models/SOURCES.md。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-31 11:30:16 +08:00
parent b35b094ed2
commit 2139990818
10 changed files with 564 additions and 257 deletions

View File

@@ -24,7 +24,14 @@ npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json
```
`--geojson` 为可选参数。不提供时,道路使用简单的 OSM highway 折线,而非详细 osm2streets 几何。实际资产生成建议先跑 `intermediates` 阶段,为 Blender 提供 osm2streets 道路面、标线、斑马线和箭头。
`--tree-style` 可选 `natural``procedural`。默认 `natural` 使用低面数树干和多层不规则树冠,形态比球形程序化树冠更自然,渲染也比外部 OBJ 树模型轻;`procedural` 最轻但更卡通
`--tree-style` 可选 `apple``fattree``natural``procedural`
- `apple`拟真——SpeedTree Red Delicious4.5k tris叶片是 alpha 抠图卡片,带法线贴图。
- `fattree`卡通——低面数卡通树2.2k tris纯实体几何无抠图。
- `natural`——低面数树干 + 多层不规则树冠,不依赖外部模型。
- `procedural`——最轻,球形树冠。
`apple``fattree` 依赖 `assets/models/` 下的第三方模型(已 gitignore。模型缺失时自动回落到 `natural`,干净 checkout 仍可构建;实际使用的样式记录在场景的 `tree_style_used` 属性里。
使用 `--office-overrides` 指定一组 OSM way ID逗号分隔这些建筑将渲染为办公楼风格即使其 OSM 标签为 `building=industrial`

View File

@@ -16,6 +16,18 @@ import sys
import bpy
import numpy as np
# --factory-startup does not put the script's own directory on sys.path, so the
# osmassets package next to this file is not importable without this.
_HERE = os.path.dirname(os.path.abspath(__file__))
if _HERE not in sys.path:
sys.path.insert(0, _HERE)
from osmassets.materials import link_alpha_clip # noqa: E402
# Marks a material this exporter produced, so a second pass over an instanced
# mesh's shared slots can recognise its own output and leave it alone.
EXPORT_PREFIX = "Cesium "
EXPORT_TINTS = {
"Grass": ((0.12, 0.48, 0.08), 0.72),
@@ -117,6 +129,48 @@ def source_texture_scale(material):
return (1.0, 1.0, 1.0)
def source_alpha_clipped(material):
"""Whether the source material carries its silhouette in a texture alpha.
Two conditions, because either alone gives a wrong answer. A link into the
Principled Alpha input is not enough: shrub_02 arrives from glTF with a
Math node wired there even though its JPEG diffuse is opaque, and taking
that at face value re-encodes an opaque texture as a PNG and makes Cesium
alpha-test 270 tufts for nothing. An alpha channel alone is not enough
either: fat_tree.png is RGBA with every texel at 1.0.
So ask both — the author wired alpha, and the texture actually cuts.
"""
node = principled_bsdf(material)
if not node or "Alpha" not in node.inputs:
return False
if not node.inputs["Alpha"].links:
return False
diffuse = image_for(material, want_normal=False)
return diffuse is not None and image_has_cutout(diffuse)
_CUTOUT_CACHE = {}
def image_has_cutout(image, threshold=0.5):
"""Whether any of the image's texels are transparent enough to be cut.
A full pass over the pixel buffer, so memoise it — the exporter asks once
per material and several materials can share one texture.
"""
if image.name in _CUTOUT_CACHE:
return _CUTOUT_CACHE[image.name]
width, height = image.size
result = False
if width and height:
alpha = np.empty(width * height * 4, dtype=np.float32)
image.pixels.foreach_get(alpha)
result = bool((alpha[3::4] < threshold).any())
_CUTOUT_CACHE[image.name] = result
return result
def tinted_image(source, name, tint, factor):
existing = bpy.data.images.get(name)
if existing:
@@ -141,7 +195,57 @@ def cesium_tinted_image(material, source):
return source
color, factor = tint
safe_name = material.name.replace(" ", "_")
return tinted_image(source, f"Cesium {safe_name} Baked", color, factor)
return tinted_image(
source, f"{EXPORT_PREFIX}{safe_name} Baked", color, factor)
def alpha_dilated_image(source, name, threshold=0.5, passes=8):
"""Flood the opaque colour outward underneath the cut-out.
SpeedTree writes pure black wherever a leaf card is cut away — 97% of the
apple atlas's transparent area is exactly (0, 0, 0). An alpha mask hides
that at full resolution, but Cesium mip-maps the texture and every mip
level averages those black texels into the leaf edges, so the crown grows a
dark fringe that thickens with distance. Blender's preview renders at mip
0 and never shows it, which is why this only surfaces in the viewer.
Replacing the colour under the cut-out with its nearest opaque neighbours
leaves no black to bleed. Alpha is copied through untouched, so the
silhouette is byte-for-byte what it was.
"""
existing = bpy.data.images.get(name)
if existing:
return existing
width, height = source.size
pixels = np.empty(width * height * 4, dtype=np.float32)
source.pixels.foreach_get(pixels)
rgba = pixels.reshape((height, width, 4))
rgb = rgba[..., :3].copy()
filled = rgba[..., 3] >= threshold
# Each pass pushes the colour one texel further out, so `passes` is how
# many mip levels' worth of filter footprint gets covered.
for _ in range(passes):
if filled.all():
break
weight = filled[..., None].astype(np.float32)
total = np.zeros_like(rgb)
count = np.zeros((height, width, 1), dtype=np.float32)
for shift, axis in ((1, 0), (-1, 0), (1, 1), (-1, 1)):
total += np.roll(rgb * weight, shift, axis=axis)
count += np.roll(weight, shift, axis=axis)
edge = (~filled) & (count[..., 0] > 0)
rgb[edge] = total[edge] / count[edge]
filled = filled | edge
dilated = rgba.copy()
dilated[..., :3] = rgb
result = bpy.data.images.new(name, width=width, height=height, alpha=True)
result.file_format = "PNG"
result.colorspace_settings.name = "sRGB"
result.pixels.foreach_set(dilated.ravel())
result.pack()
return result
def tree_crown_image():
@@ -175,7 +279,7 @@ def tree_crown_image():
def make_export_material(material):
result = material.copy()
result.name = "Cesium " + material.name
result.name = EXPORT_PREFIX + material.name
result.use_nodes = True
nodes = result.node_tree.nodes
links = result.node_tree.links
@@ -205,10 +309,17 @@ def make_export_material(material):
diffuse = image_for(material, want_normal=False)
normal = image_for(material, want_normal=True)
# Foliage that carries its silhouette in the texture's alpha has to keep
# that channel; every other material is flattened to opaque below.
alpha_clipped = source_alpha_clipped(material)
if material.name == "Tree Crown":
diffuse = tree_crown_image()
else:
diffuse = cesium_tinted_image(material, diffuse)
if alpha_clipped and diffuse is not None:
safe_name = material.name.replace(" ", "_")
diffuse = alpha_dilated_image(
diffuse, f"{EXPORT_PREFIX}{safe_name} Dilated")
if material.name in EXPORT_BASE_COLOR_OVERRIDES:
diffuse = None
normal = None
@@ -221,6 +332,7 @@ def make_export_material(material):
mapping.inputs["Scale"].default_value = source_texture_scale(material)
links.new(texcoord.outputs["UV"], mapping.inputs["Vector"])
diffuse_node = None
if diffuse:
image = nodes.new("ShaderNodeTexImage")
image.location = (-200, 80)
@@ -228,6 +340,7 @@ def make_export_material(material):
image.extension = "REPEAT"
links.new(mapping.outputs["Vector"], image.inputs["Vector"])
links.new(image.outputs["Color"], bsdf.inputs["Base Color"])
diffuse_node = image
if normal:
normal_tex = nodes.new("ShaderNodeTexImage")
@@ -242,7 +355,14 @@ def make_export_material(material):
links.new(normal_tex.outputs["Color"], normal_map.inputs["Color"])
links.new(normal_map.outputs["Normal"], bsdf.inputs["Normal"])
if "Alpha" in bsdf.inputs:
if alpha_clipped and diffuse_node is not None:
# A leaf crown is a handful of quads whose shape lives entirely in this
# channel. Pinning Alpha to 1.0 — which is what the rest of the scene
# wants — exports those quads whole, and the cut-away regions of a
# SpeedTree atlas are black, so Cesium draws black slabs.
link_alpha_clip(result, diffuse_node.outputs["Alpha"], bsdf,
cutoff=material.alpha_threshold)
elif "Alpha" in bsdf.inputs:
bsdf.inputs["Alpha"].default_value = 1.0
return result
@@ -305,6 +425,14 @@ def export(args):
if not slot.material:
continue
source = slot.material
# Instanced props share one mesh datablock, and material slots live
# on the mesh, so the first tree already swapped in the export
# material for all 181 of them. Without this the next instance
# wraps that result again — "Cesium Cesium Cesium ..." — and since
# the baked-image cache is keyed by material name, every round
# embeds another multi-megabyte copy of the same texture.
if source.name.startswith(EXPORT_PREFIX):
continue
if source.name not in material_map:
material_map[source.name] = make_export_material(source)
slot.material = material_map[source.name]

View File

@@ -116,6 +116,11 @@ TUFT_LIMIT_PER_LAWN = 100
TUFT_TINT = (0.15, 0.52, 0.09)
TUFT_TINT_FACTOR = 0.66
# Tree styles. The two built from mesh batches live in this file; the rest are
# vendored models handled by osmassets.tree, which owns that list. A model style
# whose asset is missing falls back to "natural" rather than planting nothing.
TREE_STYLES = frozenset(("natural", "procedural")) | frozenset(_tree.MODEL_STYLES)
def cli_args():
values = {"osm": None, "geojson": None, "output": None, "render": None,
@@ -143,8 +148,9 @@ def cli_args():
values["office_overrides"] = set()
else:
values["office_overrides"] = set()
if values.get("tree_style") not in {"natural", "procedural", "polyhaven"}:
raise RuntimeError("--tree-style must be 'natural', 'procedural', or 'polyhaven'")
if values.get("tree_style") not in TREE_STYLES:
raise RuntimeError("--tree-style must be one of: "
+ ", ".join(sorted(TREE_STYLES)))
return values
@@ -712,20 +718,25 @@ def build(args):
height=parse_height(row_tags, 5.0))
trees.extend(row_samples)
row_tree_count += len(row_samples)
tree_style = args.get("tree_style")
tree_style_used = tree_style
if trees:
tree_style = args.get("tree_style")
if tree_style == "polyhaven":
_tree.assemble(trees, props_c)
elif tree_style == "natural":
# A model style returns 0 when its vendored asset is missing; that drops
# through to "natural" so a clean checkout still gets trees.
placed = (_tree.assemble(trees, props_c, tree_style)
if tree_style in _tree.MODEL_STYLES else 0)
if not placed:
tree_style_used = "procedural" if tree_style == "procedural" else "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"]))
if tree_style_used == "procedural":
add_tree_batch(
trees, props_c, tree_trunk,
material_from_spec(catalog.MATERIALS["tree_crown"]))
else:
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"]))
for feature in point_features:
if feature["tags"].get("amenity") != "fountain":
@@ -793,6 +804,9 @@ def build(args):
scene["tree_node_count"] = individual_tree_count
scene["tree_row_count"] = row_tree_count
scene["tree_count"] = len(trees)
scene["tree_style"] = tree_style
# Differs from tree_style when a model style's asset was missing.
scene["tree_style_used"] = tree_style_used
scene["road_feature_counts"] = json.dumps(road_counts, ensure_ascii=True)
os.makedirs(os.path.dirname(args["output"]), exist_ok=True)
@@ -819,6 +833,7 @@ def build(args):
"tree_nodes": individual_tree_count,
"tree_row_instances": row_tree_count,
"trees": len(trees),
"tree_style": tree_style_used,
"road_features": road_counts}, ensure_ascii=True))

View File

@@ -149,6 +149,52 @@ def make_textured_material(name, diffuse_file, normal_file, roughness,
return material
def link_alpha_clip(material, alpha_output, bsdf, cutoff=0.5):
"""Wire a texture's alpha into `bsdf` as a hard cut-out.
The obvious wiring — alpha straight into the Alpha socket — is wrong for
anything destined for glTF. Blender 4.2 stopped deriving a material's
alpha mode from `blend_method` (still writable, now a no-op: setting 'CLIP'
reads back 'HASHED') and made the exporter infer it from the node tree
instead. It recognises exactly a few shapes; a bare link is not one of
them, and falls through to alphaMode=BLEND. Foliage exported as BLEND
makes Cesium depth-sort thousands of leaf quads it cannot order correctly.
So build the shape the exporter looks for — `1 - (alpha < cutoff)` — which
it reads back as alphaMode=MASK with this cutoff. EEVEE gets the same
thing for free: alpha is 0 or 1 by the time it reaches the BSDF, so the
viewport shows the crisp cut-out Cesium will, not a dithered approximation.
See the exporter's `detect_alpha_clip` in
scripts/addons_core/io_scene_gltf2/blender/exp/material/search_node_tree.py.
"""
nodes = material.node_tree.nodes
links = material.node_tree.links
less_than = nodes.new("ShaderNodeMath")
less_than.operation = "LESS_THAN"
less_than.location = (-60, 320)
less_than.inputs[1].default_value = cutoff
invert = nodes.new("ShaderNodeMath")
invert.operation = "SUBTRACT"
invert.location = (110, 320)
invert.inputs[0].default_value = 1.0
links.new(alpha_output, less_than.inputs[0])
links.new(less_than.outputs["Value"], invert.inputs[1])
links.new(invert.outputs["Value"], bsdf.inputs["Alpha"])
# EEVEE Next takes its cut-out handling from surface_render_method, not
# from blend_method. 'DITHERED' still casts a leaf-shaped shadow;
# 'BLENDED' does not.
material.surface_render_method = "DITHERED"
material.alpha_threshold = cutoff
# Leaf cards are single-sided quads seen from both sides; culling
# backfaces would empty out half of every crown.
material.use_backface_culling = False
def from_spec(spec):
"""Build a material from a `catalog.MATERIALS` entry."""
if spec["kind"] == "textured":

View File

@@ -1,127 +1,318 @@
"""Import Poly Haven island_tree_01 as instanced-tree asset.
"""Instanced tree assets — the model side of `--tree-style`.
The runtime counterpart to blender/tools/ingest_tree.py. Instead of exporting
pre-baked GLBs (which would duplicate textures), this module appends the
vendored .blend at scene-build time. The appended objects pull in their
material datablocks and texture images automatically — the textures live next
to the .blend and are resolved via relative paths.
Two vendored models, reduced to one runtime shape: import once, bake the source
object's orientation into a mesh copy, measure it, then link one lightweight
object per tree that reuses that datablock. Nothing is duplicated per tree, so
the .blend and the exported GLB carry each mesh and each texture exactly once
no matter how many trees are planted.
load_tree_variants() is the analog of load_tuft_variants() for trees: import
once, copy the meshes, remove the imported objects, return mesh datablocks
ready for instancing.
apple SpeedTree Red Delicious, 4.5k tris, alpha-cut leaf cards
fattree low-poly cartoon tree, 2.2k tris, opaque geometry
Materials are rebuilt here rather than taken from the source files, because
neither arrives usable. 57% of the apple's colour texture is transparent —
those are leaf cards, and without an alpha-clipped setup the crown renders as a
solid ball of intersecting quads. fattree ships a bare Diffuse BSDF and a
texture path that only resolves next to the original .blend.
A third style, `polyhaven`, used to live here. It appended what the Poly Haven
island_tree_01 file calls its LOD1 objects, but those are not whole trees: the
branch parts are 0.4-unit twigs and the leaf parts are flat clusters hanging
below their own origin, both meant to be scattered by the geometry-nodes setup
in that file. Planting them directly gave twigs, which is what sent us looking
for these two models. Removed along with the 78MB asset.
"""
import math
import os
from collections import namedtuple
import bpy
from osmassets.materials import link_alpha_clip
TREE_BLEND = os.path.join(
MODEL_ROOT = os.path.abspath(os.path.join(
os.path.dirname(os.path.abspath(__file__)), "..", "..",
"assets", "models", "polyhaven", "island_tree_01",
"island_tree_01_1k.blend",
)
"assets", "models",
))
# Branch / leaf LOD1 pairings for the four variants.
# Each pairing is one logical tree that the caller instances as a unit.
VARIANT_PAIRS = [("a", "b"), ("b", "a"), ("c", "a"), ("d", "b")]
APPLE_DIR = os.path.join(MODEL_ROOT, "speedtree", "apple_low")
APPLE_OBJ = os.path.join(APPLE_DIR, "RedDeliciousApple.obj")
APPLE_COLOR = os.path.join(APPLE_DIR, "textures", "apple_color_2k.png")
APPLE_NORMAL = os.path.join(APPLE_DIR, "textures", "apple_normal_2k.png")
FATTREE_DIR = os.path.join(MODEL_ROOT, "lyrog", "fattree")
FATTREE_BLEND = os.path.join(FATTREE_DIR, "fattree.blend")
FATTREE_COLOR = os.path.join(FATTREE_DIR, "textures", "fat_tree.png")
MIN_TREE_HEIGHT = 4.0
# Leaf cards are cut at half opacity: the apple atlas's alpha is near-binary
# already, so a different threshold only changes edge thickness.
ALPHA_CUTOFF = 0.5
# Golden angle. Successive trees face directions that never repeat and never
# settle into a pattern, so a tree_row reads as planted rather than stamped.
GOLDEN_TURN = 0.61803398875
SCALE_JITTER = 0.14
TILT_JITTER = math.radians(3.0)
def load_tree_variants():
"""Import the vendored Poly Haven tree and return meshes ready to instance.
# meshes — mesh datablocks instanced together at one transform
# height — the variant's own height, what a target height is divided by
# base_z — the variant's own ground line, what drops the trunk onto z=0
TreeVariant = namedtuple("TreeVariant", "meshes height base_z")
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 — the caller falls back to
procedural trees so a clean checkout still builds.
# --------------------------------------------------------------------------
# import plumbing
def _bake(obj, name):
"""Copy obj's mesh with its rotation and scale applied, but not its
position.
Orientation has to be baked: the OBJ importer leaves the apple's Y-up to
Z-up conversion sitting on the object, so a raw mesh copy would plant the
tree on its side. Position must *not* be, because a source file's
translation is where the artist parked the model in their own scene —
fattree sits 2.9m up in the air — and baking that in would offset every
instance by it. Dropping it costs nothing: `base_z` measures whatever
ground line the mesh ends up with, and assemble() corrects for it.
"""
if not os.path.exists(TREE_BLEND):
mesh = obj.data.copy()
mesh.transform(obj.matrix_world.to_3x3().to_4x4())
mesh.name = name
mesh.use_fake_user = True
return mesh
def _measure(meshes):
"""Return (height, base_z) for a variant's meshes in their shared space."""
zs = [vertex.co.z for mesh in meshes for vertex in mesh.vertices]
if not zs:
return 1.0, 0.0
low, high = min(zs), max(zs)
return max(high - low, 1e-6), low
def _discard(objects):
"""Remove imported objects along with the meshes they brought in.
The _bake copies carry a fake user and survive. Dropping only the objects
would strand their original meshes at zero users, which in turn keeps the
source materials and their megabytes of texture alive in the file.
"""
for obj in objects:
mesh = obj.data if obj.type == "MESH" else None
bpy.data.objects.remove(obj, do_unlink=True)
if mesh is not None and mesh.users == 0:
bpy.data.meshes.remove(mesh)
def _purge_orphans(before_materials, before_images):
"""Drop the materials and images an import created that nothing now uses.
Both importers build a material from the source file's own description and
load its textures. We replace that material, so without this the .blend
ships a second, unreferenced copy of every 2k texture.
"""
for material in set(bpy.data.materials) - before_materials:
if material.users == 0:
bpy.data.materials.remove(material)
for image in set(bpy.data.images) - before_images:
if image.users == 0:
bpy.data.images.remove(image)
def _image(path, non_color=False):
"""Load a texture once, keyed by filename so repeat calls share it."""
key = os.path.basename(path)
image = bpy.data.images.get(key)
if image is None:
image = bpy.data.images.load(path)
image.name = key
if non_color:
image.colorspace_settings.name = "Non-Color"
return image
def _foliage_material(name, color_path, normal_path=None, alpha_clip=False,
roughness=0.72):
"""Principled setup for a textured tree, alpha-clipped when asked.
The cut-out goes through materials.link_alpha_clip rather than straight
into the Alpha socket — see that function for why the extra two nodes are
what makes the crown survive the trip to Cesium.
"""
material = bpy.data.materials.get(name)
if material:
return material
material = bpy.data.materials.new(name)
material.use_nodes = True
nodes = material.node_tree.nodes
links = material.node_tree.links
bsdf = next(n for n in nodes if n.type == "BSDF_PRINCIPLED")
bsdf.inputs["Roughness"].default_value = roughness
bsdf.inputs["Metallic"].default_value = 0.0
color_tex = nodes.new("ShaderNodeTexImage")
color_tex.image = _image(color_path)
color_tex.location = (-540, 260)
links.new(color_tex.outputs["Color"], bsdf.inputs["Base Color"])
if normal_path and os.path.exists(normal_path):
normal_tex = nodes.new("ShaderNodeTexImage")
normal_tex.image = _image(normal_path, non_color=True)
normal_tex.location = (-540, -140)
normal_map = nodes.new("ShaderNodeNormalMap")
normal_map.location = (-250, -140)
links.new(normal_tex.outputs["Color"], normal_map.inputs["Color"])
links.new(normal_map.outputs["Normal"], bsdf.inputs["Normal"])
if alpha_clip:
link_alpha_clip(material, color_tex.outputs["Alpha"], bsdf,
cutoff=ALPHA_CUTOFF)
return material
# --------------------------------------------------------------------------
# loaders — each returns [] when its model is absent, so a clean checkout
# still builds and the caller falls back to procedural trees
def _load_apple():
"""SpeedTree Red Delicious: one mesh, alpha-cut leaf cards, normal-mapped."""
if not os.path.exists(APPLE_OBJ):
return []
# Build ours first: the OBJ importer reuses an already-loaded image when the
# .mtl resolves to the same file, so the 2k textures land in the file once.
material = _foliage_material("AppleTree", APPLE_COLOR, APPLE_NORMAL,
alpha_clip=True, roughness=0.68)
before_objects = set(bpy.data.objects)
before_materials = set(bpy.data.materials)
before_images = set(bpy.data.images)
bpy.ops.wm.obj_import(filepath=APPLE_OBJ)
imported = [obj for obj in set(bpy.data.objects) - before_objects
if obj.type == "MESH"]
if not imported:
return []
meshes = []
for index, obj in enumerate(imported):
mesh = _bake(obj, "AppleTree_%02d" % index)
mesh.materials.clear()
mesh.materials.append(material)
meshes.append(mesh)
height, base_z = _measure(meshes)
_discard(imported)
_purge_orphans(before_materials, before_images)
return [TreeVariant(meshes, height, base_z)]
def _load_fattree():
"""Low-poly cartoon tree: opaque geometry, one diffuse texture.
The crown is real geometry and the texture's alpha is 1.0 everywhere, so
unlike the apple this needs no cut-out — and no normal map, which the
source does not ship.
"""
if not os.path.exists(FATTREE_BLEND):
return []
before_objects = set(bpy.data.objects)
# Append one object at a time: bpy.ops.wm.append with a multi-file list
# can crash when objects in the same library share data that was already
# linked by an earlier append in the same batch.
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")
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; harmless.
pass
before_materials = set(bpy.data.materials)
before_images = set(bpy.data.images)
try:
bpy.ops.wm.append(
filepath=FATTREE_BLEND + "/Object/fattree",
directory=FATTREE_BLEND + "/Object/",
files=[{"name": "fattree"}],
link=False,
)
except RuntimeError:
return []
imported = [obj for obj in set(bpy.data.objects) - before_objects
if obj.type == "MESH"]
# 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))
obj = bpy.data.objects.get("fattree")
if obj is None:
_discard(imported)
_purge_orphans(before_materials, before_images)
return []
for obj in imported:
bpy.data.objects.remove(obj, do_unlink=True)
material = _foliage_material("FatTree", FATTREE_COLOR, alpha_clip=False,
roughness=0.85)
mesh = _bake(obj, "FatTree")
mesh.materials.clear()
mesh.materials.append(material)
height, base_z = _measure([mesh])
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
_discard(imported)
_purge_orphans(before_materials, before_images)
return [TreeVariant([mesh], height, base_z)]
def assemble(positions, collection):
"""Place island_tree_01 instances at `positions`.
LOADERS = {
"apple": _load_apple,
"fattree": _load_fattree,
}
# The styles this module can serve, for the CLI to validate against.
MODEL_STYLES = tuple(LOADERS)
def assemble(positions, collection, style="apple"):
"""Place instanced trees of `style` at `positions`.
positions is a list of (x, y, height) tuples as gathered by the two
tree-collecting loops (point nodes + tree_row samples).
tree-collecting loops (point nodes + tree_row samples). `height` is the
OSM height where tagged and a constant default otherwise, which means every
sample along one tree_row arrives with an identical value — the per-index
jitter below is what stops a row of forty from reading as one tree stamped
forty times.
Returns the number of trees placed (0 when the model is unavailable).
Returns the number of trees placed, or 0 when the style's model is absent
or unknown, which is the caller's signal to fall back to procedural trees.
"""
import math # noqa — tree.py has no top-level math import otherwise
variants = load_tree_variants()
loader = LOADERS.get(style)
if loader is None:
return 0
variants = loader()
if not variants:
return 0
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
for index, (x, y, target_height) in enumerate(positions):
variant = variants[index % len(variants)]
# Irrational periods stand in for an RNG: no repeat over any realistic
# tree count, and a pure function of the index, so rebuilding an area
# plants the identical forest.
scale_wobble = 1.0 + SCALE_JITTER * math.sin(index * 2.399963)
target = max(MIN_TREE_HEIGHT, target_height) * scale_wobble
factor = target / variant.height
yaw = ((index * GOLDEN_TURN) % 1.0) * math.tau
tilt_x = TILT_JITTER * math.sin(index * 1.114517)
tilt_y = TILT_JITTER * math.cos(index * 0.927295)
# Scaled and negated, the variant's own ground line drops the trunk
# onto z=0 whatever the source file used as its origin.
z = -variant.base_z * factor
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)
for slot, mesh in enumerate(variant.meshes):
obj = bpy.data.objects.new(
"Tree_%s_%04d_%d" % (style, index, slot), mesh)
obj.location = (x, y, z)
obj.scale = (factor, factor, factor)
obj.rotation_euler = (tilt_x, tilt_y, yaw)
collection.objects.link(obj)
tris = 0
for variant in variants:
for mesh in variant.meshes:
mesh.calc_loop_triangles()
tris += len(mesh.loop_triangles)
print("Tree style %r: %d variants, %d tris per instance, %d planted"
% (style, len(variants), tris // max(1, len(variants)), len(positions)))
return len(positions)

View File

@@ -1,143 +0,0 @@
"""Import Poly Haven island_tree_01 and export decimated GLBs.
Each output GLB is a single tree variant (branch + leaf) with shared
material references. This is the offline ingest tool; the runtime module
(osmassets/tree.py) loads directly from the vendored .blend via wm.append.
"""
import math
import os
import sys
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",
)
# Target triangle counts for the decimated per-tree mesh.
# LOD1 branches are 135-570 tris, LOD1 leaves 380-1531 tris.
# 180 + 220 = 400 per tree, on par with the procedural trees.
TARGET_BRANCH_TRIS = 180
TARGET_LEAF_TRIS = 220
# Branch / leaf LOD1 pairings for the four variants.
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):
"""Decimate a mesh to roughly target_tris, return new mesh datablock."""
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 in a fresh temp scene."""
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:
print("Missing materials in source blend")
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:
print(f"Skipping variant {vi}: missing LOD1 objects")
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())