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

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

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

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

Cesium 侧修三处:

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

另修两个既有 bug:

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

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

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

View File

@@ -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)