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

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