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

3
.gitignore vendored
View File

@@ -2,4 +2,5 @@
node_modules/ node_modules/
outputs/ outputs/
__pycache__/ __pycache__/
assets/models/polyhaven/island_tree_01/ assets/models/speedtree/
assets/models/lyrog/

47
assets/models/SOURCES.md Normal file
View File

@@ -0,0 +1,47 @@
# 树模型资产来源
`assets/models/` 下的树模型是第三方素材,**已 gitignore**,不随仓库分发。
干净 checkout 缺这些文件时 `tree.assemble()` 返回 0`generate_scene.py`
自动回落到 `natural` 样式,构建不会失败。
放置路径见 `blender/osmassets/tree.py` 顶部的常量。
## apple`--tree-style apple`
- 名称RedDeliciousAppleSpeedTree 工具导出)
- 路径:`assets/models/speedtree/apple_low/`
- `RedDeliciousApple.obj` — 4475 tris单网格
- `RedDeliciousApple.mtl` — 已重写,见文件内注释
- `textures/apple_color_2k.png` — 由原始 4096² PNG 降采样,**必须保留 alpha 通道**
57% 的像素是抠掉的叶片卡片
- `textures/apple_normal_2k.png` — 同上降采样
原始素材另有 `.fbx` / `.cgf` / `.st` / `.tif` 以及 `_SS` / `_Subsurface` 贴图,
管线不用,未收录。
降采样命令(原始 4k 贴图 16MB + 20MB降到 2k 后 4.3MB + 4.7MB
```bash
sips -Z 2048 -s format png <原始>_Color.png --out textures/apple_color_2k.png
sips -Z 2048 -s format png <原始>_Normal.png --out textures/apple_normal_2k.png
```
## fattree`--tree-style fattree`
- 名称fat tree作者署名 Lyrog付费素材
- 路径:`assets/models/lyrog/fattree/`
- `fattree.blend` — 取其中名为 `fattree` 的对象2238 tris
- `textures/fat_tree.png` — 原文件名带空格(`fat tree.png`),已改为下划线
源 .blend 里的贴图路径是 `//fat tree.png`,只在原始目录下能解析;
运行时不依赖它,`tree.py` 会丢掉源材质并按上面的路径重建一个 Principled 材质。
该 .blend 里另有两个 `plant` 对象(灌木),管线未使用。
## 已移除
`polyhaven/island_tree_01`76MB已于 2026-07-31 删除,连同 `--tree-style polyhaven`
`blender/tools/ingest_tree.py`。原因见 `docs/changelog.md`:该文件的 `*_LOD1`
对象不是完整的树,而是给几何节点散布用的枝叶碎片。
若要重新取用Poly Haven 上是 CC0https://polyhaven.com/a/island_tree_01
`polyhaven/shrub_02` 仍在使用(草丛散布),不受影响。

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 道路面、标线、斑马线和箭头。 `--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` 使用 `--office-overrides` 指定一组 OSM way ID逗号分隔这些建筑将渲染为办公楼风格即使其 OSM 标签为 `building=industrial`

View File

@@ -16,6 +16,18 @@ import sys
import bpy import bpy
import numpy as np 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 = { EXPORT_TINTS = {
"Grass": ((0.12, 0.48, 0.08), 0.72), "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) 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): def tinted_image(source, name, tint, factor):
existing = bpy.data.images.get(name) existing = bpy.data.images.get(name)
if existing: if existing:
@@ -141,7 +195,57 @@ def cesium_tinted_image(material, source):
return source return source
color, factor = tint color, factor = tint
safe_name = material.name.replace(" ", "_") 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(): def tree_crown_image():
@@ -175,7 +279,7 @@ def tree_crown_image():
def make_export_material(material): def make_export_material(material):
result = material.copy() result = material.copy()
result.name = "Cesium " + material.name result.name = EXPORT_PREFIX + material.name
result.use_nodes = True result.use_nodes = True
nodes = result.node_tree.nodes nodes = result.node_tree.nodes
links = result.node_tree.links links = result.node_tree.links
@@ -205,10 +309,17 @@ def make_export_material(material):
diffuse = image_for(material, want_normal=False) diffuse = image_for(material, want_normal=False)
normal = image_for(material, want_normal=True) 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": if material.name == "Tree Crown":
diffuse = tree_crown_image() diffuse = tree_crown_image()
else: else:
diffuse = cesium_tinted_image(material, diffuse) 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: if material.name in EXPORT_BASE_COLOR_OVERRIDES:
diffuse = None diffuse = None
normal = None normal = None
@@ -221,6 +332,7 @@ def make_export_material(material):
mapping.inputs["Scale"].default_value = source_texture_scale(material) mapping.inputs["Scale"].default_value = source_texture_scale(material)
links.new(texcoord.outputs["UV"], mapping.inputs["Vector"]) links.new(texcoord.outputs["UV"], mapping.inputs["Vector"])
diffuse_node = None
if diffuse: if diffuse:
image = nodes.new("ShaderNodeTexImage") image = nodes.new("ShaderNodeTexImage")
image.location = (-200, 80) image.location = (-200, 80)
@@ -228,6 +340,7 @@ def make_export_material(material):
image.extension = "REPEAT" image.extension = "REPEAT"
links.new(mapping.outputs["Vector"], image.inputs["Vector"]) links.new(mapping.outputs["Vector"], image.inputs["Vector"])
links.new(image.outputs["Color"], bsdf.inputs["Base Color"]) links.new(image.outputs["Color"], bsdf.inputs["Base Color"])
diffuse_node = image
if normal: if normal:
normal_tex = nodes.new("ShaderNodeTexImage") 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_tex.outputs["Color"], normal_map.inputs["Color"])
links.new(normal_map.outputs["Normal"], bsdf.inputs["Normal"]) 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 bsdf.inputs["Alpha"].default_value = 1.0
return result return result
@@ -305,6 +425,14 @@ def export(args):
if not slot.material: if not slot.material:
continue continue
source = slot.material 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: if source.name not in material_map:
material_map[source.name] = make_export_material(source) material_map[source.name] = make_export_material(source)
slot.material = material_map[source.name] 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 = (0.15, 0.52, 0.09)
TUFT_TINT_FACTOR = 0.66 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(): def cli_args():
values = {"osm": None, "geojson": None, "output": None, "render": None, values = {"osm": None, "geojson": None, "output": None, "render": None,
@@ -143,8 +148,9 @@ 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", "polyhaven"}: if values.get("tree_style") not in TREE_STYLES:
raise RuntimeError("--tree-style must be 'natural', 'procedural', or 'polyhaven'") raise RuntimeError("--tree-style must be one of: "
+ ", ".join(sorted(TREE_STYLES)))
return values return values
@@ -712,20 +718,25 @@ def build(args):
height=parse_height(row_tags, 5.0)) height=parse_height(row_tags, 5.0))
trees.extend(row_samples) trees.extend(row_samples)
row_tree_count += len(row_samples) row_tree_count += len(row_samples)
if trees:
tree_style = args.get("tree_style") tree_style = args.get("tree_style")
if tree_style == "polyhaven": tree_style_used = tree_style
_tree.assemble(trees, props_c) if trees:
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"]) tree_trunk = material_from_spec(catalog.MATERIALS["tree_trunk"])
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( 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:
tree_trunk = material_from_spec(catalog.MATERIALS["tree_trunk"])
add_tree_batch(trees, props_c, tree_trunk,
material_from_spec(catalog.MATERIALS["tree_crown"]))
for feature in point_features: for feature in point_features:
if feature["tags"].get("amenity") != "fountain": if feature["tags"].get("amenity") != "fountain":
@@ -793,6 +804,9 @@ def build(args):
scene["tree_node_count"] = individual_tree_count scene["tree_node_count"] = individual_tree_count
scene["tree_row_count"] = row_tree_count scene["tree_row_count"] = row_tree_count
scene["tree_count"] = len(trees) 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) scene["road_feature_counts"] = json.dumps(road_counts, ensure_ascii=True)
os.makedirs(os.path.dirname(args["output"]), exist_ok=True) os.makedirs(os.path.dirname(args["output"]), exist_ok=True)
@@ -819,6 +833,7 @@ def build(args):
"tree_nodes": individual_tree_count, "tree_nodes": individual_tree_count,
"tree_row_instances": row_tree_count, "tree_row_instances": row_tree_count,
"trees": len(trees), "trees": len(trees),
"tree_style": tree_style_used,
"road_features": road_counts}, ensure_ascii=True)) "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 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): def from_spec(spec):
"""Build a material from a `catalog.MATERIALS` entry.""" """Build a material from a `catalog.MATERIALS` entry."""
if spec["kind"] == "textured": 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 Two vendored models, reduced to one runtime shape: import once, bake the source
pre-baked GLBs (which would duplicate textures), this module appends the object's orientation into a mesh copy, measure it, then link one lightweight
vendored .blend at scene-build time. The appended objects pull in their object per tree that reuses that datablock. Nothing is duplicated per tree, so
material datablocks and texture images automatically — the textures live next the .blend and the exported GLB carry each mesh and each texture exactly once
to the .blend and are resolved via relative paths. no matter how many trees are planted.
load_tree_variants() is the analog of load_tuft_variants() for trees: import apple SpeedTree Red Delicious, 4.5k tris, alpha-cut leaf cards
once, copy the meshes, remove the imported objects, return mesh datablocks fattree low-poly cartoon tree, 2.2k tris, opaque geometry
ready for instancing.
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 import os
from collections import namedtuple
import bpy 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__)), "..", "..", os.path.dirname(os.path.abspath(__file__)), "..", "..",
"assets", "models", "polyhaven", "island_tree_01", "assets", "models",
"island_tree_01_1k.blend", ))
)
# Branch / leaf LOD1 pairings for the four variants. APPLE_DIR = os.path.join(MODEL_ROOT, "speedtree", "apple_low")
# Each pairing is one logical tree that the caller instances as a unit. APPLE_OBJ = os.path.join(APPLE_DIR, "RedDeliciousApple.obj")
VARIANT_PAIRS = [("a", "b"), ("b", "a"), ("c", "a"), ("d", "b")] 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(): # meshes — mesh datablocks instanced together at one transform
"""Import the vendored Poly Haven tree and return meshes ready to instance. # 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 [] return []
before_objects = set(bpy.data.objects) before_objects = set(bpy.data.objects)
before_materials = set(bpy.data.materials)
# Append one object at a time: bpy.ops.wm.append with a multi-file list before_images = set(bpy.data.images)
# 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: try:
bpy.ops.wm.append( bpy.ops.wm.append(
filepath=TREE_BLEND + "/Object/" + name, filepath=FATTREE_BLEND + "/Object/fattree",
directory=TREE_BLEND + "/Object/", directory=FATTREE_BLEND + "/Object/",
files=[{"name": name}], files=[{"name": "fattree"}],
link=False, link=False,
) )
except RuntimeError: except RuntimeError:
# Object already linked by an earlier append of a sibling mesh return []
# that shared materials; harmless.
pass
imported = [obj for obj in set(bpy.data.objects) - before_objects imported = [obj for obj in set(bpy.data.objects) - before_objects
if obj.type == "MESH"] if obj.type == "MESH"]
# Rebuild the pairs from the imported object names. obj = bpy.data.objects.get("fattree")
variants = [] if obj is None:
for branch_id, leaf_id in VARIANT_PAIRS: _discard(imported)
b_obj = bpy.data.objects.get( _purge_orphans(before_materials, before_images)
f"island_tree_01_branches_{branch_id}_LOD1") return []
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: material = _foliage_material("FatTree", FATTREE_COLOR, alpha_clip=False,
bpy.data.objects.remove(obj, do_unlink=True) 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) _discard(imported)
for bm, lm in variants) _purge_orphans(before_materials, before_images)
print(f"Poly Haven tree variants loaded: {len(variants)} " return [TreeVariant([mesh], height, base_z)]
f"({tris} tris per instance)")
return variants
def assemble(positions, collection): LOADERS = {
"""Place island_tree_01 instances at `positions`. "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 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 loader = LOADERS.get(style)
if loader is None:
variants = load_tree_variants() return 0
variants = loader()
if not variants: if not variants:
return 0 return 0
for index, (x, y, height) in enumerate(positions): for index, (x, y, target_height) in enumerate(positions):
branch_mesh, leaf_mesh = variants[index % len(variants)] variant = variants[index % len(variants)]
target = max(4.0, height) # Irrational periods stand in for an RNG: no repeat over any realistic
factor = target / 3.2 # 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) for slot, mesh in enumerate(variant.meshes):
b_obj.location = (x, y, 0.0) obj = bpy.data.objects.new(
b_obj.scale = (factor, factor, factor) "Tree_%s_%04d_%d" % (style, index, slot), mesh)
b_obj.rotation_euler = (0, 0, (index * 1.61803398875) % 1.0 * math.tau) obj.location = (x, y, z)
collection.objects.link(b_obj) obj.scale = (factor, factor, factor)
obj.rotation_euler = (tilt_x, tilt_y, yaw)
l_obj = bpy.data.objects.new(f"Tree_PH_{index:03d}_Leaf", leaf_mesh) collection.objects.link(obj)
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)
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) 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())

View File

@@ -29,7 +29,7 @@
"osm2lanes": true "osm2lanes": true
}, },
"blender": { "blender": {
"treeStyle": "polyhaven", "treeStyle": "apple",
"officeOverrides": "" "officeOverrides": ""
} }
} }

View File

@@ -1,5 +1,20 @@
# Changelog # Changelog
## 2026-07-31
- 树渲染改用两个第三方模型,`tree_style` 新增 `apple` / `fattree`
- `apple` — SpeedTree Red Delicious4475 tris叶片 alpha 抠图 + 法线贴图4k 贴图降采样为 2k
- `fattree` — 低面数卡通树2238 tris实体几何
- 高度不再用魔数缩放,改为按模型自身包围盒归一化到目标高度,树根落在 z=0
- 每棵树按序号做确定性抖动:缩放 ±14%、黄金角偏航、±3° 倾斜,成排的行道树不再是同一棵树盖章
- 移除 `polyhaven` 树样式和 island_tree_01 资产76MB+ `blender/tools/ingest_tree.py`。该样式 append 的 `*_LOD1` 对象并不是完整的树:枝干只有 0.41 单位高,叶片是挂在原点下方的平面簇,原本是给源文件里的几何节点散布用的,直接种下去只有树枝
- 修复 Cesium 导出丢失 alpha 抠图:`make_export_material` 原本把 Alpha 恒定写死为 1.0,叶片卡片整块导出,而 SpeedTree 图集抠掉的区域是纯黑,在 Cesium 里表现为黑色色块
- Blender 4.2 起 glTF 导出不再读 `blend_method`(仍可写但已失效,写 `CLIP` 读回来是 `HASHED`),改为从节点树推断 alpha 模式。新增 `materials.link_alpha_clip()` 构造导出器识别的 `1 - (alpha < cutoff)` 节点形状,得到 `alphaMode=MASK`,同时 EEVEE 里也变成硬边抠图
- 新增 `alpha_dilated_image()`:把不透明像素的颜色向抠图区域外扩 8 圈。97.5% 的透明像素是纯黑Cesium 生成 mipmap 时会把黑色平均进叶片边缘。叶片边缘相邻的纯黑像素占比从 10.3% 降到 0.6%
- 修复导出器对实例化网格重复包装材质:材质槽在 mesh 上181 棵树共享一个 datablock第一棵替换后其余 180 棵会把结果再包一次,产生 `Cesium Cesium Cesium ...` 的材质名且烘焙图缓存按材质名索引每轮都会再嵌一份同样的贴图。GLB 22.46MB → 20.76MBmaterials 270 → 23
- `source_alpha_clipped()` 同时要求「材质连了 Alpha」和「贴图确实有抠图」shrub_02 从 glTF 带进来一个 Math 节点接在 Alpha 上,但它的 JPEG 贴图 alpha 全是 1.0,只判断前者会把它误判为抠图材质,白白重编码成 1.2MB PNG 并让 Cesium 对 270 丛草做 alpha test
- 场景新增 `tree_style` / `tree_style_used` 属性;模型资产缺失时自动回落到 `natural`
## 2026-07-30 ## 2026-07-30
- 重构 Blender 脚本为 `osmassets` 包:`generate_scene.py` 从 1271 行缩减到 826 行 (-35%),纯函数可脱离 Blender 测试 (42 个 unittest) - 重构 Blender 脚本为 `osmassets` 包:`generate_scene.py` 从 1271 行缩减到 826 行 (-35%),纯函数可脱离 Blender 测试 (42 个 unittest)
@@ -10,9 +25,9 @@
- `catalog.py` — 道路图层和材质规格的唯一定义源,含 cesium 导出参数 - `catalog.py` — 道路图层和材质规格的唯一定义源,含 cesium 导出参数
- 要素注册表:`water.py` / `grass.py` / `scrub.py` / `tree.py`,各导出一个 `assemble()` 函数 - 要素注册表:`water.py` / `grass.py` / `scrub.py` / `tree.py`,各导出一个 `assemble()` 函数
- 新增 parity 工具链:`scene_digest.py` + `glb-digest.js` + `parity.js`,两区域全 PARITY OK - 新增 parity 工具链:`scene_digest.py` + `glb-digest.js` + `parity.js`,两区域全 PARITY OK
- 接入 Poly Haven island_tree_01 真实扫描树模型:`tree_style` 新增 `polyhaven` 选项181 棵树 113k tris + PBR 贴图 - 接入 Poly Haven island_tree_01 真实扫描树模型:`tree_style` 新增 `polyhaven` 选项(已于 2026-07-31 移除,见上)
- 新增 `blender/tools/ingest_tree.py` 用于离线减面导出树模型 - 新增 `blender/tools/ingest_tree.py` 用于离线减面导出树模型(已于 2026-07-31 移除)
- `nantaizi-lake-innovation-valley` 配置默认切换为 `treeStyle: polyhaven` - `nantaizi-lake-innovation-valley` 配置默认切换为 `treeStyle: polyhaven`(现为 `apple`
## 2026-07-28 ## 2026-07-28