fix: 树发黑的真正原因是反照率没提亮,并加饱和度让远看更绿

emissive 提到 0.8 仍然发黑:它乘的是本来就很暗的反照率,0.8 × 深绿约等于
sRGB [0.22, 0.31, 0.11],还是暗的。要提的是反照率本身。

Cesium 截图定位:草亮黄绿、建筑近白、路面浅灰,只有树暗。这棵树图集的叶片
本来就是深绿(绿色系像素均值 sRGB [0.249, 0.35, 0.12]),按真实反照率渲染
是对的,但场景里其他材质都被手工提亮过——EXPORT_TINTS 草 0.72、带肋墙面
0.86,EXPORT_EMISSION_OVERRIDES 建筑 0.18,全是针对 Cesium 偏白的默认光照
调出来的。新资产没调过,是唯一如实渲染的东西,放旁边就显得发黑。

- FOLIAGE_ALBEDO_GAIN = 2.1,在抠图植被 dilate 那一遍顺带乘上。用增益而非
  tint:其他材质是单一表面,而这是同时装着叶片/树皮/果实的图集,往绿色混会
  把树干染绿。缩放保留色相关系,只把整体抬到和邻居一样的曝光。
- FOLIAGE_SATURATION = 1.75,绕各像素自身 Rec.709 亮度做饱和度拉伸。图集本
  来就偏灰(平均饱和度 0.22),远看时 mip 把叶片、树皮、缝隙混在一起会更往
  中性靠。绕亮度拉伸把叶片推绿而基本不动本来中性的部分,也没有绿色 tint 强
  加给树干的色相偏移。叶片 sRGB → [0.262, 0.529, 0.021],饱和度 0.22 → 0.34,
  过曝到纯白的像素占 0.3%。
- FOLIAGE_EMISSION 回调 0.25,它只负责给背光面兜底。
- 新增 triangulate_mesh():export_tangents 打开后刷了 55 行「切向空间只能只算
  三角/四边形」,MeshBatch 建的 OSM 轮廓都是 n-gon。glTF 只有三角形,导出时
  无论如何都会三角化,提前做不改变任何输出三角形(三角数 51719 前后一致),
  切线 49/102 → 102/102,警告归零。

被数据排除的假设:贴图颜色全链路逐位一致;模拟 mip 链可见像素亮度
0.127→0.124;法线贴图抠图区是干净平面法线;GLB 重新导入 Blender 渲染正常。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-31 13:23:44 +08:00
parent afada31469
commit eb4a0d9b2a
2 changed files with 115 additions and 6 deletions

View File

@@ -33,7 +33,37 @@ EXPORT_PREFIX = "Cesium "
# shadowed side of a crown off Cesium's near-black ambient floor. Kept well
# under the 0.18 the buildings use: a tree still has to read as lit from one
# side, it just must not go to black.
FOLIAGE_EMISSION = 0.22
FOLIAGE_EMISSION = 0.25
# Multiplier on a cut-out foliage albedo before export.
#
# This is the knob that actually controls how dark the trees read, and it
# exists because the apple atlas is genuinely dark: its green texels average
# sRGB (0.249, 0.35, 0.12), a deep forest green, and the bark is darker still.
# Rendered at true albedo that is correct — but nothing else in this scene is
# at true albedo. Every other material goes through EXPORT_TINTS (grass mixes
# 72% toward a bright green, the ribbed facade 86% toward white) and
# EXPORT_EMISSION_OVERRIDES (0.18 on the buildings), all hand-tuned against
# Cesium's washed-out default lighting. A new asset dropped in untuned is the
# one thing rendering honestly, and next to the rest it reads as black.
#
# A gain rather than a tint, because a tint is what the other materials use and
# it is wrong here: they are single-surface, this is an atlas holding leaves,
# bark and fruit at once, and mixing it toward green would turn the trunk
# green. Scaling preserves the hue relationships and just lifts the whole
# thing into the same exposure as its neighbours.
FOLIAGE_ALBEDO_GAIN = 2.1
# Saturation multiplier applied with the gain, around each texel's own
# luminance. The gain alone lifts the crown to the right brightness but leaves
# it reading grey-green at distance: this atlas is desaturated to begin with
# (mean saturation 0.22), and mip-averaging a crown mixes leaves with bark and
# sky-gaps, pulling it further toward neutral exactly when the tree gets small.
#
# Scaling the distance from luminance pushes the leaves green without touching
# what is already neutral much, and without the hue shift a green tint would
# force on the trunk — bark just becomes a warmer brown, which it should be.
FOLIAGE_SATURATION = 1.75
EXPORT_TINTS = {
"Grass": ((0.12, 0.48, 0.08), 0.72),
@@ -205,8 +235,9 @@ def cesium_tinted_image(material, source):
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.
def alpha_dilated_image(source, name, threshold=0.5, passes=8, gain=1.0,
saturation=1.0):
"""Flood the opaque colour outward underneath the cut-out, and lift it.
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
@@ -218,6 +249,11 @@ def alpha_dilated_image(source, name, threshold=0.5, passes=8):
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.
`gain` and `saturation` grade the result into the same exposure and colour
as the rest of the scene — see FOLIAGE_ALBEDO_GAIN and FOLIAGE_SATURATION.
Both are applied after the flood so the filled border keeps matching the
leaves it was copied from, and the result is clipped at 1.0.
"""
existing = bpy.data.images.get(name)
if existing:
@@ -244,6 +280,14 @@ def alpha_dilated_image(source, name, threshold=0.5, passes=8):
rgb[edge] = total[edge] / count[edge]
filled = filled | edge
if saturation != 1.0:
# Rec.709 luminance, so the push is around perceived brightness rather
# than the channel average.
luma = rgb @ np.asarray([0.2126, 0.7152, 0.0722], dtype=np.float32)
rgb = luma[..., None] + (rgb - luma[..., None]) * saturation
if gain != 1.0 or saturation != 1.0:
rgb = np.clip(rgb * gain, 0.0, 1.0)
dilated = rgba.copy()
dilated[..., :3] = rgb
result = bpy.data.images.new(name, width=width, height=height, alpha=True)
@@ -325,7 +369,8 @@ def make_export_material(material):
if alpha_clipped and diffuse is not None:
safe_name = material.name.replace(" ", "_")
diffuse = alpha_dilated_image(
diffuse, f"{EXPORT_PREFIX}{safe_name} Dilated")
diffuse, f"{EXPORT_PREFIX}{safe_name} Dilated",
gain=FOLIAGE_ALBEDO_GAIN, saturation=FOLIAGE_SATURATION)
if material.name in EXPORT_BASE_COLOR_OVERRIDES:
diffuse = None
normal = None
@@ -424,6 +469,37 @@ def apply_mesh_modifiers(obj):
pass
def triangulate_mesh(obj):
"""Split n-gons into triangles ahead of the exporter.
glTF has no n-gons, so the exporter triangulates on the way out regardless
— doing it here does not change a single output triangle. What it changes
is tangents: Blender can only build a tangent basis on tris and quads, and
every footprint this pipeline extrudes from OSM is an n-gon, so with
export_tangents on each one logged "切向空间只能只算三角/四边形" and shipped
without a basis. Triangulating first turns ~55 failures into tangents.
Skipped for meshes that are already triangles, which covers the instanced
props — those share one datablock across hundreds of objects and
modifier_apply refuses to touch multi-user data.
"""
if obj.type != "MESH" or not obj.data.polygons:
return
if all(len(polygon.vertices) <= 3 for polygon in obj.data.polygons):
return
bpy.ops.object.select_all(action="DESELECT")
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
modifier = obj.modifiers.new("ExportTriangulate", "TRIANGULATE")
modifier.min_vertices = 4
try:
bpy.ops.object.modifier_apply(modifier=modifier.name)
except RuntimeError:
# Multi-user data. The exporter still triangulates it, we just lose the
# tangent basis for that mesh.
obj.modifiers.remove(modifier)
def export(args):
if not os.path.exists(args["blend"]):
raise FileNotFoundError(args["blend"])
@@ -441,10 +517,11 @@ def export(args):
continue
meshes.append(obj)
apply_mesh_modifiers(obj)
# Hundreds of grass tufts share four mesh datablocks; unwrapping is a
# property of the mesh, so doing it once per datablock is enough.
# Hundreds of grass tufts share four mesh datablocks; unwrapping and
# triangulating are properties of the mesh, so once per datablock.
if obj.data.name not in unwrapped:
unwrapped.add(obj.data.name)
triangulate_mesh(obj)
unwrap_mesh(obj)
for slot in obj.material_slots:
if not slot.material: