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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
47
assets/models/SOURCES.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# 树模型资产来源
|
||||||
|
|
||||||
|
`assets/models/` 下的树模型是第三方素材,**已 gitignore**,不随仓库分发。
|
||||||
|
干净 checkout 缺这些文件时 `tree.assemble()` 返回 0,`generate_scene.py`
|
||||||
|
自动回落到 `natural` 样式,构建不会失败。
|
||||||
|
|
||||||
|
放置路径见 `blender/osmassets/tree.py` 顶部的常量。
|
||||||
|
|
||||||
|
## apple(`--tree-style apple`)
|
||||||
|
|
||||||
|
- 名称:RedDeliciousApple(SpeedTree 工具导出)
|
||||||
|
- 路径:`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 上是 CC0:https://polyhaven.com/a/island_tree_01
|
||||||
|
|
||||||
|
`polyhaven/shrub_02` 仍在使用(草丛散布),不受影响。
|
||||||
@@ -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 Delicious,4.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`:
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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())
|
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
"osm2lanes": true
|
"osm2lanes": true
|
||||||
},
|
},
|
||||||
"blender": {
|
"blender": {
|
||||||
"treeStyle": "polyhaven",
|
"treeStyle": "apple",
|
||||||
"officeOverrides": ""
|
"officeOverrides": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,20 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-07-31
|
||||||
|
|
||||||
|
- 树渲染改用两个第三方模型,`tree_style` 新增 `apple` / `fattree`:
|
||||||
|
- `apple` — SpeedTree Red Delicious,4475 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.76MB,materials 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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user