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:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user