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/
|
||||
outputs/
|
||||
__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 道路面、标线、斑马线和箭头。
|
||||
`--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`:
|
||||
|
||||
|
||||
@@ -16,6 +16,18 @@ import sys
|
||||
import bpy
|
||||
import numpy as np
|
||||
|
||||
# --factory-startup does not put the script's own directory on sys.path, so the
|
||||
# osmassets package next to this file is not importable without this.
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
if _HERE not in sys.path:
|
||||
sys.path.insert(0, _HERE)
|
||||
|
||||
from osmassets.materials import link_alpha_clip # noqa: E402
|
||||
|
||||
|
||||
# Marks a material this exporter produced, so a second pass over an instanced
|
||||
# mesh's shared slots can recognise its own output and leave it alone.
|
||||
EXPORT_PREFIX = "Cesium "
|
||||
|
||||
EXPORT_TINTS = {
|
||||
"Grass": ((0.12, 0.48, 0.08), 0.72),
|
||||
@@ -117,6 +129,48 @@ def source_texture_scale(material):
|
||||
return (1.0, 1.0, 1.0)
|
||||
|
||||
|
||||
def source_alpha_clipped(material):
|
||||
"""Whether the source material carries its silhouette in a texture alpha.
|
||||
|
||||
Two conditions, because either alone gives a wrong answer. A link into the
|
||||
Principled Alpha input is not enough: shrub_02 arrives from glTF with a
|
||||
Math node wired there even though its JPEG diffuse is opaque, and taking
|
||||
that at face value re-encodes an opaque texture as a PNG and makes Cesium
|
||||
alpha-test 270 tufts for nothing. An alpha channel alone is not enough
|
||||
either: fat_tree.png is RGBA with every texel at 1.0.
|
||||
|
||||
So ask both — the author wired alpha, and the texture actually cuts.
|
||||
"""
|
||||
node = principled_bsdf(material)
|
||||
if not node or "Alpha" not in node.inputs:
|
||||
return False
|
||||
if not node.inputs["Alpha"].links:
|
||||
return False
|
||||
diffuse = image_for(material, want_normal=False)
|
||||
return diffuse is not None and image_has_cutout(diffuse)
|
||||
|
||||
|
||||
_CUTOUT_CACHE = {}
|
||||
|
||||
|
||||
def image_has_cutout(image, threshold=0.5):
|
||||
"""Whether any of the image's texels are transparent enough to be cut.
|
||||
|
||||
A full pass over the pixel buffer, so memoise it — the exporter asks once
|
||||
per material and several materials can share one texture.
|
||||
"""
|
||||
if image.name in _CUTOUT_CACHE:
|
||||
return _CUTOUT_CACHE[image.name]
|
||||
width, height = image.size
|
||||
result = False
|
||||
if width and height:
|
||||
alpha = np.empty(width * height * 4, dtype=np.float32)
|
||||
image.pixels.foreach_get(alpha)
|
||||
result = bool((alpha[3::4] < threshold).any())
|
||||
_CUTOUT_CACHE[image.name] = result
|
||||
return result
|
||||
|
||||
|
||||
def tinted_image(source, name, tint, factor):
|
||||
existing = bpy.data.images.get(name)
|
||||
if existing:
|
||||
@@ -141,7 +195,57 @@ def cesium_tinted_image(material, source):
|
||||
return source
|
||||
color, factor = tint
|
||||
safe_name = material.name.replace(" ", "_")
|
||||
return tinted_image(source, f"Cesium {safe_name} Baked", color, factor)
|
||||
return tinted_image(
|
||||
source, f"{EXPORT_PREFIX}{safe_name} Baked", color, factor)
|
||||
|
||||
|
||||
def alpha_dilated_image(source, name, threshold=0.5, passes=8):
|
||||
"""Flood the opaque colour outward underneath the cut-out.
|
||||
|
||||
SpeedTree writes pure black wherever a leaf card is cut away — 97% of the
|
||||
apple atlas's transparent area is exactly (0, 0, 0). An alpha mask hides
|
||||
that at full resolution, but Cesium mip-maps the texture and every mip
|
||||
level averages those black texels into the leaf edges, so the crown grows a
|
||||
dark fringe that thickens with distance. Blender's preview renders at mip
|
||||
0 and never shows it, which is why this only surfaces in the viewer.
|
||||
|
||||
Replacing the colour under the cut-out with its nearest opaque neighbours
|
||||
leaves no black to bleed. Alpha is copied through untouched, so the
|
||||
silhouette is byte-for-byte what it was.
|
||||
"""
|
||||
existing = bpy.data.images.get(name)
|
||||
if existing:
|
||||
return existing
|
||||
width, height = source.size
|
||||
pixels = np.empty(width * height * 4, dtype=np.float32)
|
||||
source.pixels.foreach_get(pixels)
|
||||
rgba = pixels.reshape((height, width, 4))
|
||||
rgb = rgba[..., :3].copy()
|
||||
filled = rgba[..., 3] >= threshold
|
||||
|
||||
# Each pass pushes the colour one texel further out, so `passes` is how
|
||||
# many mip levels' worth of filter footprint gets covered.
|
||||
for _ in range(passes):
|
||||
if filled.all():
|
||||
break
|
||||
weight = filled[..., None].astype(np.float32)
|
||||
total = np.zeros_like(rgb)
|
||||
count = np.zeros((height, width, 1), dtype=np.float32)
|
||||
for shift, axis in ((1, 0), (-1, 0), (1, 1), (-1, 1)):
|
||||
total += np.roll(rgb * weight, shift, axis=axis)
|
||||
count += np.roll(weight, shift, axis=axis)
|
||||
edge = (~filled) & (count[..., 0] > 0)
|
||||
rgb[edge] = total[edge] / count[edge]
|
||||
filled = filled | edge
|
||||
|
||||
dilated = rgba.copy()
|
||||
dilated[..., :3] = rgb
|
||||
result = bpy.data.images.new(name, width=width, height=height, alpha=True)
|
||||
result.file_format = "PNG"
|
||||
result.colorspace_settings.name = "sRGB"
|
||||
result.pixels.foreach_set(dilated.ravel())
|
||||
result.pack()
|
||||
return result
|
||||
|
||||
|
||||
def tree_crown_image():
|
||||
@@ -175,7 +279,7 @@ def tree_crown_image():
|
||||
|
||||
def make_export_material(material):
|
||||
result = material.copy()
|
||||
result.name = "Cesium " + material.name
|
||||
result.name = EXPORT_PREFIX + material.name
|
||||
result.use_nodes = True
|
||||
nodes = result.node_tree.nodes
|
||||
links = result.node_tree.links
|
||||
@@ -205,10 +309,17 @@ def make_export_material(material):
|
||||
|
||||
diffuse = image_for(material, want_normal=False)
|
||||
normal = image_for(material, want_normal=True)
|
||||
# Foliage that carries its silhouette in the texture's alpha has to keep
|
||||
# that channel; every other material is flattened to opaque below.
|
||||
alpha_clipped = source_alpha_clipped(material)
|
||||
if material.name == "Tree Crown":
|
||||
diffuse = tree_crown_image()
|
||||
else:
|
||||
diffuse = cesium_tinted_image(material, diffuse)
|
||||
if alpha_clipped and diffuse is not None:
|
||||
safe_name = material.name.replace(" ", "_")
|
||||
diffuse = alpha_dilated_image(
|
||||
diffuse, f"{EXPORT_PREFIX}{safe_name} Dilated")
|
||||
if material.name in EXPORT_BASE_COLOR_OVERRIDES:
|
||||
diffuse = None
|
||||
normal = None
|
||||
@@ -221,6 +332,7 @@ def make_export_material(material):
|
||||
mapping.inputs["Scale"].default_value = source_texture_scale(material)
|
||||
links.new(texcoord.outputs["UV"], mapping.inputs["Vector"])
|
||||
|
||||
diffuse_node = None
|
||||
if diffuse:
|
||||
image = nodes.new("ShaderNodeTexImage")
|
||||
image.location = (-200, 80)
|
||||
@@ -228,6 +340,7 @@ def make_export_material(material):
|
||||
image.extension = "REPEAT"
|
||||
links.new(mapping.outputs["Vector"], image.inputs["Vector"])
|
||||
links.new(image.outputs["Color"], bsdf.inputs["Base Color"])
|
||||
diffuse_node = image
|
||||
|
||||
if normal:
|
||||
normal_tex = nodes.new("ShaderNodeTexImage")
|
||||
@@ -242,7 +355,14 @@ def make_export_material(material):
|
||||
links.new(normal_tex.outputs["Color"], normal_map.inputs["Color"])
|
||||
links.new(normal_map.outputs["Normal"], bsdf.inputs["Normal"])
|
||||
|
||||
if "Alpha" in bsdf.inputs:
|
||||
if alpha_clipped and diffuse_node is not None:
|
||||
# A leaf crown is a handful of quads whose shape lives entirely in this
|
||||
# channel. Pinning Alpha to 1.0 — which is what the rest of the scene
|
||||
# wants — exports those quads whole, and the cut-away regions of a
|
||||
# SpeedTree atlas are black, so Cesium draws black slabs.
|
||||
link_alpha_clip(result, diffuse_node.outputs["Alpha"], bsdf,
|
||||
cutoff=material.alpha_threshold)
|
||||
elif "Alpha" in bsdf.inputs:
|
||||
bsdf.inputs["Alpha"].default_value = 1.0
|
||||
return result
|
||||
|
||||
@@ -305,6 +425,14 @@ def export(args):
|
||||
if not slot.material:
|
||||
continue
|
||||
source = slot.material
|
||||
# Instanced props share one mesh datablock, and material slots live
|
||||
# on the mesh, so the first tree already swapped in the export
|
||||
# material for all 181 of them. Without this the next instance
|
||||
# wraps that result again — "Cesium Cesium Cesium ..." — and since
|
||||
# the baked-image cache is keyed by material name, every round
|
||||
# embeds another multi-megabyte copy of the same texture.
|
||||
if source.name.startswith(EXPORT_PREFIX):
|
||||
continue
|
||||
if source.name not in material_map:
|
||||
material_map[source.name] = make_export_material(source)
|
||||
slot.material = material_map[source.name]
|
||||
|
||||
@@ -116,6 +116,11 @@ TUFT_LIMIT_PER_LAWN = 100
|
||||
TUFT_TINT = (0.15, 0.52, 0.09)
|
||||
TUFT_TINT_FACTOR = 0.66
|
||||
|
||||
# Tree styles. The two built from mesh batches live in this file; the rest are
|
||||
# vendored models handled by osmassets.tree, which owns that list. A model style
|
||||
# whose asset is missing falls back to "natural" rather than planting nothing.
|
||||
TREE_STYLES = frozenset(("natural", "procedural")) | frozenset(_tree.MODEL_STYLES)
|
||||
|
||||
|
||||
def cli_args():
|
||||
values = {"osm": None, "geojson": None, "output": None, "render": None,
|
||||
@@ -143,8 +148,9 @@ def cli_args():
|
||||
values["office_overrides"] = set()
|
||||
else:
|
||||
values["office_overrides"] = set()
|
||||
if values.get("tree_style") not in {"natural", "procedural", "polyhaven"}:
|
||||
raise RuntimeError("--tree-style must be 'natural', 'procedural', or 'polyhaven'")
|
||||
if values.get("tree_style") not in TREE_STYLES:
|
||||
raise RuntimeError("--tree-style must be one of: "
|
||||
+ ", ".join(sorted(TREE_STYLES)))
|
||||
return values
|
||||
|
||||
|
||||
@@ -712,20 +718,25 @@ def build(args):
|
||||
height=parse_height(row_tags, 5.0))
|
||||
trees.extend(row_samples)
|
||||
row_tree_count += len(row_samples)
|
||||
tree_style = args.get("tree_style")
|
||||
tree_style_used = tree_style
|
||||
if trees:
|
||||
tree_style = args.get("tree_style")
|
||||
if tree_style == "polyhaven":
|
||||
_tree.assemble(trees, props_c)
|
||||
elif tree_style == "natural":
|
||||
# A model style returns 0 when its vendored asset is missing; that drops
|
||||
# through to "natural" so a clean checkout still gets trees.
|
||||
placed = (_tree.assemble(trees, props_c, tree_style)
|
||||
if tree_style in _tree.MODEL_STYLES else 0)
|
||||
if not placed:
|
||||
tree_style_used = "procedural" if tree_style == "procedural" else "natural"
|
||||
tree_trunk = material_from_spec(catalog.MATERIALS["tree_trunk"])
|
||||
add_natural_tree_instances(
|
||||
trees, props_c, tree_trunk,
|
||||
material_from_spec(catalog.MATERIALS["tree_crown_dark"]),
|
||||
material_from_spec(catalog.MATERIALS["tree_crown_light"]))
|
||||
else:
|
||||
tree_trunk = material_from_spec(catalog.MATERIALS["tree_trunk"])
|
||||
add_tree_batch(trees, props_c, tree_trunk,
|
||||
material_from_spec(catalog.MATERIALS["tree_crown"]))
|
||||
if tree_style_used == "procedural":
|
||||
add_tree_batch(
|
||||
trees, props_c, tree_trunk,
|
||||
material_from_spec(catalog.MATERIALS["tree_crown"]))
|
||||
else:
|
||||
add_natural_tree_instances(
|
||||
trees, props_c, tree_trunk,
|
||||
material_from_spec(catalog.MATERIALS["tree_crown_dark"]),
|
||||
material_from_spec(catalog.MATERIALS["tree_crown_light"]))
|
||||
|
||||
for feature in point_features:
|
||||
if feature["tags"].get("amenity") != "fountain":
|
||||
@@ -793,6 +804,9 @@ def build(args):
|
||||
scene["tree_node_count"] = individual_tree_count
|
||||
scene["tree_row_count"] = row_tree_count
|
||||
scene["tree_count"] = len(trees)
|
||||
scene["tree_style"] = tree_style
|
||||
# Differs from tree_style when a model style's asset was missing.
|
||||
scene["tree_style_used"] = tree_style_used
|
||||
scene["road_feature_counts"] = json.dumps(road_counts, ensure_ascii=True)
|
||||
|
||||
os.makedirs(os.path.dirname(args["output"]), exist_ok=True)
|
||||
@@ -819,6 +833,7 @@ def build(args):
|
||||
"tree_nodes": individual_tree_count,
|
||||
"tree_row_instances": row_tree_count,
|
||||
"trees": len(trees),
|
||||
"tree_style": tree_style_used,
|
||||
"road_features": road_counts}, ensure_ascii=True))
|
||||
|
||||
|
||||
|
||||
@@ -149,6 +149,52 @@ def make_textured_material(name, diffuse_file, normal_file, roughness,
|
||||
return material
|
||||
|
||||
|
||||
def link_alpha_clip(material, alpha_output, bsdf, cutoff=0.5):
|
||||
"""Wire a texture's alpha into `bsdf` as a hard cut-out.
|
||||
|
||||
The obvious wiring — alpha straight into the Alpha socket — is wrong for
|
||||
anything destined for glTF. Blender 4.2 stopped deriving a material's
|
||||
alpha mode from `blend_method` (still writable, now a no-op: setting 'CLIP'
|
||||
reads back 'HASHED') and made the exporter infer it from the node tree
|
||||
instead. It recognises exactly a few shapes; a bare link is not one of
|
||||
them, and falls through to alphaMode=BLEND. Foliage exported as BLEND
|
||||
makes Cesium depth-sort thousands of leaf quads it cannot order correctly.
|
||||
|
||||
So build the shape the exporter looks for — `1 - (alpha < cutoff)` — which
|
||||
it reads back as alphaMode=MASK with this cutoff. EEVEE gets the same
|
||||
thing for free: alpha is 0 or 1 by the time it reaches the BSDF, so the
|
||||
viewport shows the crisp cut-out Cesium will, not a dithered approximation.
|
||||
|
||||
See the exporter's `detect_alpha_clip` in
|
||||
scripts/addons_core/io_scene_gltf2/blender/exp/material/search_node_tree.py.
|
||||
"""
|
||||
nodes = material.node_tree.nodes
|
||||
links = material.node_tree.links
|
||||
|
||||
less_than = nodes.new("ShaderNodeMath")
|
||||
less_than.operation = "LESS_THAN"
|
||||
less_than.location = (-60, 320)
|
||||
less_than.inputs[1].default_value = cutoff
|
||||
|
||||
invert = nodes.new("ShaderNodeMath")
|
||||
invert.operation = "SUBTRACT"
|
||||
invert.location = (110, 320)
|
||||
invert.inputs[0].default_value = 1.0
|
||||
|
||||
links.new(alpha_output, less_than.inputs[0])
|
||||
links.new(less_than.outputs["Value"], invert.inputs[1])
|
||||
links.new(invert.outputs["Value"], bsdf.inputs["Alpha"])
|
||||
|
||||
# EEVEE Next takes its cut-out handling from surface_render_method, not
|
||||
# from blend_method. 'DITHERED' still casts a leaf-shaped shadow;
|
||||
# 'BLENDED' does not.
|
||||
material.surface_render_method = "DITHERED"
|
||||
material.alpha_threshold = cutoff
|
||||
# Leaf cards are single-sided quads seen from both sides; culling
|
||||
# backfaces would empty out half of every crown.
|
||||
material.use_backface_culling = False
|
||||
|
||||
|
||||
def from_spec(spec):
|
||||
"""Build a material from a `catalog.MATERIALS` entry."""
|
||||
if spec["kind"] == "textured":
|
||||
|
||||
@@ -1,127 +1,318 @@
|
||||
"""Import Poly Haven island_tree_01 as instanced-tree asset.
|
||||
"""Instanced tree assets — the model side of `--tree-style`.
|
||||
|
||||
The runtime counterpart to blender/tools/ingest_tree.py. Instead of exporting
|
||||
pre-baked GLBs (which would duplicate textures), this module appends the
|
||||
vendored .blend at scene-build time. The appended objects pull in their
|
||||
material datablocks and texture images automatically — the textures live next
|
||||
to the .blend and are resolved via relative paths.
|
||||
Two vendored models, reduced to one runtime shape: import once, bake the source
|
||||
object's orientation into a mesh copy, measure it, then link one lightweight
|
||||
object per tree that reuses that datablock. Nothing is duplicated per tree, so
|
||||
the .blend and the exported GLB carry each mesh and each texture exactly once
|
||||
no matter how many trees are planted.
|
||||
|
||||
load_tree_variants() is the analog of load_tuft_variants() for trees: import
|
||||
once, copy the meshes, remove the imported objects, return mesh datablocks
|
||||
ready for instancing.
|
||||
apple SpeedTree Red Delicious, 4.5k tris, alpha-cut leaf cards
|
||||
fattree low-poly cartoon tree, 2.2k tris, opaque geometry
|
||||
|
||||
Materials are rebuilt here rather than taken from the source files, because
|
||||
neither arrives usable. 57% of the apple's colour texture is transparent —
|
||||
those are leaf cards, and without an alpha-clipped setup the crown renders as a
|
||||
solid ball of intersecting quads. fattree ships a bare Diffuse BSDF and a
|
||||
texture path that only resolves next to the original .blend.
|
||||
|
||||
A third style, `polyhaven`, used to live here. It appended what the Poly Haven
|
||||
island_tree_01 file calls its LOD1 objects, but those are not whole trees: the
|
||||
branch parts are 0.4-unit twigs and the leaf parts are flat clusters hanging
|
||||
below their own origin, both meant to be scattered by the geometry-nodes setup
|
||||
in that file. Planting them directly gave twigs, which is what sent us looking
|
||||
for these two models. Removed along with the 78MB asset.
|
||||
"""
|
||||
|
||||
import math
|
||||
import os
|
||||
from collections import namedtuple
|
||||
|
||||
import bpy
|
||||
|
||||
from osmassets.materials import link_alpha_clip
|
||||
|
||||
TREE_BLEND = os.path.join(
|
||||
|
||||
MODEL_ROOT = os.path.abspath(os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)), "..", "..",
|
||||
"assets", "models", "polyhaven", "island_tree_01",
|
||||
"island_tree_01_1k.blend",
|
||||
)
|
||||
"assets", "models",
|
||||
))
|
||||
|
||||
# Branch / leaf LOD1 pairings for the four variants.
|
||||
# Each pairing is one logical tree that the caller instances as a unit.
|
||||
VARIANT_PAIRS = [("a", "b"), ("b", "a"), ("c", "a"), ("d", "b")]
|
||||
APPLE_DIR = os.path.join(MODEL_ROOT, "speedtree", "apple_low")
|
||||
APPLE_OBJ = os.path.join(APPLE_DIR, "RedDeliciousApple.obj")
|
||||
APPLE_COLOR = os.path.join(APPLE_DIR, "textures", "apple_color_2k.png")
|
||||
APPLE_NORMAL = os.path.join(APPLE_DIR, "textures", "apple_normal_2k.png")
|
||||
|
||||
FATTREE_DIR = os.path.join(MODEL_ROOT, "lyrog", "fattree")
|
||||
FATTREE_BLEND = os.path.join(FATTREE_DIR, "fattree.blend")
|
||||
FATTREE_COLOR = os.path.join(FATTREE_DIR, "textures", "fat_tree.png")
|
||||
|
||||
MIN_TREE_HEIGHT = 4.0
|
||||
# Leaf cards are cut at half opacity: the apple atlas's alpha is near-binary
|
||||
# already, so a different threshold only changes edge thickness.
|
||||
ALPHA_CUTOFF = 0.5
|
||||
# Golden angle. Successive trees face directions that never repeat and never
|
||||
# settle into a pattern, so a tree_row reads as planted rather than stamped.
|
||||
GOLDEN_TURN = 0.61803398875
|
||||
SCALE_JITTER = 0.14
|
||||
TILT_JITTER = math.radians(3.0)
|
||||
|
||||
|
||||
def load_tree_variants():
|
||||
"""Import the vendored Poly Haven tree and return meshes ready to instance.
|
||||
# meshes — mesh datablocks instanced together at one transform
|
||||
# height — the variant's own height, what a target height is divided by
|
||||
# base_z — the variant's own ground line, what drops the trunk onto z=0
|
||||
TreeVariant = namedtuple("TreeVariant", "meshes height base_z")
|
||||
|
||||
Returns a list of (branch_mesh, leaf_mesh) tuples, one per variant.
|
||||
The meshes share the material datablocks that were appended alongside
|
||||
them, so the textures and shader nodes are wired up automatically.
|
||||
|
||||
Returns [] when the source .blend is absent — the caller falls back to
|
||||
procedural trees so a clean checkout still builds.
|
||||
# --------------------------------------------------------------------------
|
||||
# import plumbing
|
||||
|
||||
|
||||
def _bake(obj, name):
|
||||
"""Copy obj's mesh with its rotation and scale applied, but not its
|
||||
position.
|
||||
|
||||
Orientation has to be baked: the OBJ importer leaves the apple's Y-up to
|
||||
Z-up conversion sitting on the object, so a raw mesh copy would plant the
|
||||
tree on its side. Position must *not* be, because a source file's
|
||||
translation is where the artist parked the model in their own scene —
|
||||
fattree sits 2.9m up in the air — and baking that in would offset every
|
||||
instance by it. Dropping it costs nothing: `base_z` measures whatever
|
||||
ground line the mesh ends up with, and assemble() corrects for it.
|
||||
"""
|
||||
if not os.path.exists(TREE_BLEND):
|
||||
mesh = obj.data.copy()
|
||||
mesh.transform(obj.matrix_world.to_3x3().to_4x4())
|
||||
mesh.name = name
|
||||
mesh.use_fake_user = True
|
||||
return mesh
|
||||
|
||||
|
||||
def _measure(meshes):
|
||||
"""Return (height, base_z) for a variant's meshes in their shared space."""
|
||||
zs = [vertex.co.z for mesh in meshes for vertex in mesh.vertices]
|
||||
if not zs:
|
||||
return 1.0, 0.0
|
||||
low, high = min(zs), max(zs)
|
||||
return max(high - low, 1e-6), low
|
||||
|
||||
|
||||
def _discard(objects):
|
||||
"""Remove imported objects along with the meshes they brought in.
|
||||
|
||||
The _bake copies carry a fake user and survive. Dropping only the objects
|
||||
would strand their original meshes at zero users, which in turn keeps the
|
||||
source materials and their megabytes of texture alive in the file.
|
||||
"""
|
||||
for obj in objects:
|
||||
mesh = obj.data if obj.type == "MESH" else None
|
||||
bpy.data.objects.remove(obj, do_unlink=True)
|
||||
if mesh is not None and mesh.users == 0:
|
||||
bpy.data.meshes.remove(mesh)
|
||||
|
||||
|
||||
def _purge_orphans(before_materials, before_images):
|
||||
"""Drop the materials and images an import created that nothing now uses.
|
||||
|
||||
Both importers build a material from the source file's own description and
|
||||
load its textures. We replace that material, so without this the .blend
|
||||
ships a second, unreferenced copy of every 2k texture.
|
||||
"""
|
||||
for material in set(bpy.data.materials) - before_materials:
|
||||
if material.users == 0:
|
||||
bpy.data.materials.remove(material)
|
||||
for image in set(bpy.data.images) - before_images:
|
||||
if image.users == 0:
|
||||
bpy.data.images.remove(image)
|
||||
|
||||
|
||||
def _image(path, non_color=False):
|
||||
"""Load a texture once, keyed by filename so repeat calls share it."""
|
||||
key = os.path.basename(path)
|
||||
image = bpy.data.images.get(key)
|
||||
if image is None:
|
||||
image = bpy.data.images.load(path)
|
||||
image.name = key
|
||||
if non_color:
|
||||
image.colorspace_settings.name = "Non-Color"
|
||||
return image
|
||||
|
||||
|
||||
def _foliage_material(name, color_path, normal_path=None, alpha_clip=False,
|
||||
roughness=0.72):
|
||||
"""Principled setup for a textured tree, alpha-clipped when asked.
|
||||
|
||||
The cut-out goes through materials.link_alpha_clip rather than straight
|
||||
into the Alpha socket — see that function for why the extra two nodes are
|
||||
what makes the crown survive the trip to Cesium.
|
||||
"""
|
||||
material = bpy.data.materials.get(name)
|
||||
if material:
|
||||
return material
|
||||
|
||||
material = bpy.data.materials.new(name)
|
||||
material.use_nodes = True
|
||||
nodes = material.node_tree.nodes
|
||||
links = material.node_tree.links
|
||||
bsdf = next(n for n in nodes if n.type == "BSDF_PRINCIPLED")
|
||||
bsdf.inputs["Roughness"].default_value = roughness
|
||||
bsdf.inputs["Metallic"].default_value = 0.0
|
||||
|
||||
color_tex = nodes.new("ShaderNodeTexImage")
|
||||
color_tex.image = _image(color_path)
|
||||
color_tex.location = (-540, 260)
|
||||
links.new(color_tex.outputs["Color"], bsdf.inputs["Base Color"])
|
||||
|
||||
if normal_path and os.path.exists(normal_path):
|
||||
normal_tex = nodes.new("ShaderNodeTexImage")
|
||||
normal_tex.image = _image(normal_path, non_color=True)
|
||||
normal_tex.location = (-540, -140)
|
||||
normal_map = nodes.new("ShaderNodeNormalMap")
|
||||
normal_map.location = (-250, -140)
|
||||
links.new(normal_tex.outputs["Color"], normal_map.inputs["Color"])
|
||||
links.new(normal_map.outputs["Normal"], bsdf.inputs["Normal"])
|
||||
|
||||
if alpha_clip:
|
||||
link_alpha_clip(material, color_tex.outputs["Alpha"], bsdf,
|
||||
cutoff=ALPHA_CUTOFF)
|
||||
return material
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# loaders — each returns [] when its model is absent, so a clean checkout
|
||||
# still builds and the caller falls back to procedural trees
|
||||
|
||||
|
||||
def _load_apple():
|
||||
"""SpeedTree Red Delicious: one mesh, alpha-cut leaf cards, normal-mapped."""
|
||||
if not os.path.exists(APPLE_OBJ):
|
||||
return []
|
||||
|
||||
# Build ours first: the OBJ importer reuses an already-loaded image when the
|
||||
# .mtl resolves to the same file, so the 2k textures land in the file once.
|
||||
material = _foliage_material("AppleTree", APPLE_COLOR, APPLE_NORMAL,
|
||||
alpha_clip=True, roughness=0.68)
|
||||
|
||||
before_objects = set(bpy.data.objects)
|
||||
before_materials = set(bpy.data.materials)
|
||||
before_images = set(bpy.data.images)
|
||||
bpy.ops.wm.obj_import(filepath=APPLE_OBJ)
|
||||
imported = [obj for obj in set(bpy.data.objects) - before_objects
|
||||
if obj.type == "MESH"]
|
||||
if not imported:
|
||||
return []
|
||||
|
||||
meshes = []
|
||||
for index, obj in enumerate(imported):
|
||||
mesh = _bake(obj, "AppleTree_%02d" % index)
|
||||
mesh.materials.clear()
|
||||
mesh.materials.append(material)
|
||||
meshes.append(mesh)
|
||||
height, base_z = _measure(meshes)
|
||||
|
||||
_discard(imported)
|
||||
_purge_orphans(before_materials, before_images)
|
||||
return [TreeVariant(meshes, height, base_z)]
|
||||
|
||||
|
||||
def _load_fattree():
|
||||
"""Low-poly cartoon tree: opaque geometry, one diffuse texture.
|
||||
|
||||
The crown is real geometry and the texture's alpha is 1.0 everywhere, so
|
||||
unlike the apple this needs no cut-out — and no normal map, which the
|
||||
source does not ship.
|
||||
"""
|
||||
if not os.path.exists(FATTREE_BLEND):
|
||||
return []
|
||||
|
||||
before_objects = set(bpy.data.objects)
|
||||
|
||||
# Append one object at a time: bpy.ops.wm.append with a multi-file list
|
||||
# can crash when objects in the same library share data that was already
|
||||
# linked by an earlier append in the same batch.
|
||||
wanted = []
|
||||
for branch_id, leaf_id in VARIANT_PAIRS:
|
||||
wanted.append(f"island_tree_01_branches_{branch_id}_LOD1")
|
||||
wanted.append(f"island_tree_01_leaves_{leaf_id}_LOD1")
|
||||
|
||||
for name in wanted:
|
||||
try:
|
||||
bpy.ops.wm.append(
|
||||
filepath=TREE_BLEND + "/Object/" + name,
|
||||
directory=TREE_BLEND + "/Object/",
|
||||
files=[{"name": name}],
|
||||
link=False,
|
||||
)
|
||||
except RuntimeError:
|
||||
# Object already linked by an earlier append of a sibling mesh
|
||||
# that shared materials; harmless.
|
||||
pass
|
||||
|
||||
before_materials = set(bpy.data.materials)
|
||||
before_images = set(bpy.data.images)
|
||||
try:
|
||||
bpy.ops.wm.append(
|
||||
filepath=FATTREE_BLEND + "/Object/fattree",
|
||||
directory=FATTREE_BLEND + "/Object/",
|
||||
files=[{"name": "fattree"}],
|
||||
link=False,
|
||||
)
|
||||
except RuntimeError:
|
||||
return []
|
||||
imported = [obj for obj in set(bpy.data.objects) - before_objects
|
||||
if obj.type == "MESH"]
|
||||
|
||||
# Rebuild the pairs from the imported object names.
|
||||
variants = []
|
||||
for branch_id, leaf_id in VARIANT_PAIRS:
|
||||
b_obj = bpy.data.objects.get(
|
||||
f"island_tree_01_branches_{branch_id}_LOD1")
|
||||
l_obj = bpy.data.objects.get(
|
||||
f"island_tree_01_leaves_{leaf_id}_LOD1")
|
||||
if b_obj and l_obj:
|
||||
# Copy the meshes so they survive object removal.
|
||||
b_mesh = b_obj.data.copy()
|
||||
b_mesh.name = "PolyHavenTree_Branch_" + branch_id
|
||||
b_mesh.use_fake_user = True
|
||||
l_mesh = l_obj.data.copy()
|
||||
l_mesh.name = "PolyHavenTree_Leaf_" + leaf_id
|
||||
l_mesh.use_fake_user = True
|
||||
variants.append((b_mesh, l_mesh))
|
||||
obj = bpy.data.objects.get("fattree")
|
||||
if obj is None:
|
||||
_discard(imported)
|
||||
_purge_orphans(before_materials, before_images)
|
||||
return []
|
||||
|
||||
for obj in imported:
|
||||
bpy.data.objects.remove(obj, do_unlink=True)
|
||||
material = _foliage_material("FatTree", FATTREE_COLOR, alpha_clip=False,
|
||||
roughness=0.85)
|
||||
mesh = _bake(obj, "FatTree")
|
||||
mesh.materials.clear()
|
||||
mesh.materials.append(material)
|
||||
height, base_z = _measure([mesh])
|
||||
|
||||
tris = sum(len(bm.loop_triangles) + len(lm.loop_triangles)
|
||||
for bm, lm in variants)
|
||||
print(f"Poly Haven tree variants loaded: {len(variants)} "
|
||||
f"({tris} tris per instance)")
|
||||
return variants
|
||||
_discard(imported)
|
||||
_purge_orphans(before_materials, before_images)
|
||||
return [TreeVariant([mesh], height, base_z)]
|
||||
|
||||
|
||||
def assemble(positions, collection):
|
||||
"""Place island_tree_01 instances at `positions`.
|
||||
LOADERS = {
|
||||
"apple": _load_apple,
|
||||
"fattree": _load_fattree,
|
||||
}
|
||||
|
||||
# The styles this module can serve, for the CLI to validate against.
|
||||
MODEL_STYLES = tuple(LOADERS)
|
||||
|
||||
|
||||
def assemble(positions, collection, style="apple"):
|
||||
"""Place instanced trees of `style` at `positions`.
|
||||
|
||||
positions is a list of (x, y, height) tuples as gathered by the two
|
||||
tree-collecting loops (point nodes + tree_row samples).
|
||||
tree-collecting loops (point nodes + tree_row samples). `height` is the
|
||||
OSM height where tagged and a constant default otherwise, which means every
|
||||
sample along one tree_row arrives with an identical value — the per-index
|
||||
jitter below is what stops a row of forty from reading as one tree stamped
|
||||
forty times.
|
||||
|
||||
Returns the number of trees placed (0 when the model is unavailable).
|
||||
Returns the number of trees placed, or 0 when the style's model is absent
|
||||
or unknown, which is the caller's signal to fall back to procedural trees.
|
||||
"""
|
||||
import math # noqa — tree.py has no top-level math import otherwise
|
||||
|
||||
variants = load_tree_variants()
|
||||
loader = LOADERS.get(style)
|
||||
if loader is None:
|
||||
return 0
|
||||
variants = loader()
|
||||
if not variants:
|
||||
return 0
|
||||
|
||||
for index, (x, y, height) in enumerate(positions):
|
||||
branch_mesh, leaf_mesh = variants[index % len(variants)]
|
||||
target = max(4.0, height)
|
||||
factor = target / 3.2
|
||||
for index, (x, y, target_height) in enumerate(positions):
|
||||
variant = variants[index % len(variants)]
|
||||
# Irrational periods stand in for an RNG: no repeat over any realistic
|
||||
# tree count, and a pure function of the index, so rebuilding an area
|
||||
# plants the identical forest.
|
||||
scale_wobble = 1.0 + SCALE_JITTER * math.sin(index * 2.399963)
|
||||
target = max(MIN_TREE_HEIGHT, target_height) * scale_wobble
|
||||
factor = target / variant.height
|
||||
yaw = ((index * GOLDEN_TURN) % 1.0) * math.tau
|
||||
tilt_x = TILT_JITTER * math.sin(index * 1.114517)
|
||||
tilt_y = TILT_JITTER * math.cos(index * 0.927295)
|
||||
# Scaled and negated, the variant's own ground line drops the trunk
|
||||
# onto z=0 whatever the source file used as its origin.
|
||||
z = -variant.base_z * factor
|
||||
|
||||
b_obj = bpy.data.objects.new(f"Tree_PH_{index:03d}_Branch", branch_mesh)
|
||||
b_obj.location = (x, y, 0.0)
|
||||
b_obj.scale = (factor, factor, factor)
|
||||
b_obj.rotation_euler = (0, 0, (index * 1.61803398875) % 1.0 * math.tau)
|
||||
collection.objects.link(b_obj)
|
||||
|
||||
l_obj = bpy.data.objects.new(f"Tree_PH_{index:03d}_Leaf", leaf_mesh)
|
||||
l_obj.location = (x, y, 0.0)
|
||||
l_obj.scale = (factor, factor, factor)
|
||||
l_obj.rotation_euler = (0, 0, (index * 1.61803398875) % 1.0 * math.tau)
|
||||
collection.objects.link(l_obj)
|
||||
for slot, mesh in enumerate(variant.meshes):
|
||||
obj = bpy.data.objects.new(
|
||||
"Tree_%s_%04d_%d" % (style, index, slot), mesh)
|
||||
obj.location = (x, y, z)
|
||||
obj.scale = (factor, factor, factor)
|
||||
obj.rotation_euler = (tilt_x, tilt_y, yaw)
|
||||
collection.objects.link(obj)
|
||||
|
||||
tris = 0
|
||||
for variant in variants:
|
||||
for mesh in variant.meshes:
|
||||
mesh.calc_loop_triangles()
|
||||
tris += len(mesh.loop_triangles)
|
||||
print("Tree style %r: %d variants, %d tris per instance, %d planted"
|
||||
% (style, len(variants), tris // max(1, len(variants)), len(positions)))
|
||||
return len(positions)
|
||||
|
||||
@@ -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
|
||||
},
|
||||
"blender": {
|
||||
"treeStyle": "polyhaven",
|
||||
"treeStyle": "apple",
|
||||
"officeOverrides": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# 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
|
||||
|
||||
- 重构 Blender 脚本为 `osmassets` 包:`generate_scene.py` 从 1271 行缩减到 826 行 (-35%),纯函数可脱离 Blender 测试 (42 个 unittest)
|
||||
@@ -10,9 +25,9 @@
|
||||
- `catalog.py` — 道路图层和材质规格的唯一定义源,含 cesium 导出参数
|
||||
- 要素注册表:`water.py` / `grass.py` / `scrub.py` / `tree.py`,各导出一个 `assemble()` 函数
|
||||
- 新增 parity 工具链:`scene_digest.py` + `glb-digest.js` + `parity.js`,两区域全 PARITY OK
|
||||
- 接入 Poly Haven island_tree_01 真实扫描树模型:`tree_style` 新增 `polyhaven` 选项,181 棵树 113k tris + PBR 贴图
|
||||
- 新增 `blender/tools/ingest_tree.py` 用于离线减面导出树模型
|
||||
- `nantaizi-lake-innovation-valley` 配置默认切换为 `treeStyle: polyhaven`
|
||||
- 接入 Poly Haven island_tree_01 真实扫描树模型:`tree_style` 新增 `polyhaven` 选项(已于 2026-07-31 移除,见上)
|
||||
- 新增 `blender/tools/ingest_tree.py` 用于离线减面导出树模型(已于 2026-07-31 移除)
|
||||
- `nantaizi-lake-innovation-valley` 配置默认切换为 `treeStyle: polyhaven`(现为 `apple`)
|
||||
|
||||
## 2026-07-28
|
||||
|
||||
|
||||
Reference in New Issue
Block a user