From 2139990818b8aff348a04623de9b7a73324090ac Mon Sep 17 00:00:00 2001 From: que01 Date: Fri, 31 Jul 2026 11:30:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=A0=91=E6=B8=B2=E6=9F=93=E6=94=B9?= =?UTF-8?q?=E7=94=A8=20apple/fattree=20=E6=A8=A1=E5=9E=8B=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20Cesium=20alpha=20=E6=8A=A0=E5=9B=BE?= =?UTF-8?q?=E4=B8=A2=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitignore | 3 +- assets/models/SOURCES.md | 47 +++ blender/README.md | 9 +- blender/export_cesium.py | 134 ++++++- blender/generate_scene.py | 43 +- blender/osmassets/materials.py | 46 +++ blender/osmassets/tree.py | 373 +++++++++++++----- blender/tools/ingest_tree.py | 143 ------- .../nantaizi-lake-innovation-valley.json | 2 +- docs/changelog.md | 21 +- 10 files changed, 564 insertions(+), 257 deletions(-) create mode 100644 assets/models/SOURCES.md delete mode 100644 blender/tools/ingest_tree.py diff --git a/.gitignore b/.gitignore index be18d6d..9a0e112 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ node_modules/ outputs/ __pycache__/ -assets/models/polyhaven/island_tree_01/ +assets/models/speedtree/ +assets/models/lyrog/ diff --git a/assets/models/SOURCES.md b/assets/models/SOURCES.md new file mode 100644 index 0000000..9e8ee95 --- /dev/null +++ b/assets/models/SOURCES.md @@ -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` 仍在使用(草丛散布),不受影响。 diff --git a/blender/README.md b/blender/README.md index 6567959..d26523c 100644 --- a/blender/README.md +++ b/blender/README.md @@ -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`: diff --git a/blender/export_cesium.py b/blender/export_cesium.py index 820fc35..abb3afb 100644 --- a/blender/export_cesium.py +++ b/blender/export_cesium.py @@ -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] diff --git a/blender/generate_scene.py b/blender/generate_scene.py index a8cd113..a7b95c9 100644 --- a/blender/generate_scene.py +++ b/blender/generate_scene.py @@ -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)) diff --git a/blender/osmassets/materials.py b/blender/osmassets/materials.py index a099040..4af3045 100644 --- a/blender/osmassets/materials.py +++ b/blender/osmassets/materials.py @@ -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": diff --git a/blender/osmassets/tree.py b/blender/osmassets/tree.py index ccd752b..d7c5c94 100644 --- a/blender/osmassets/tree.py +++ b/blender/osmassets/tree.py @@ -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) diff --git a/blender/tools/ingest_tree.py b/blender/tools/ingest_tree.py deleted file mode 100644 index 7edfeca..0000000 --- a/blender/tools/ingest_tree.py +++ /dev/null @@ -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()) diff --git a/config/areas/nantaizi-lake-innovation-valley.json b/config/areas/nantaizi-lake-innovation-valley.json index 4f8760b..2d2c301 100644 --- a/config/areas/nantaizi-lake-innovation-valley.json +++ b/config/areas/nantaizi-lake-innovation-valley.json @@ -29,7 +29,7 @@ "osm2lanes": true }, "blender": { - "treeStyle": "polyhaven", + "treeStyle": "apple", "officeOverrides": "" } } diff --git a/docs/changelog.md b/docs/changelog.md index 84750e4..224499b 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -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