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 # 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 # 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. # 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 = { EXPORT_TINTS = {
"Grass": ((0.12, 0.48, 0.08), 0.72), "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) source, f"{EXPORT_PREFIX}{safe_name} Baked", color, factor)
def alpha_dilated_image(source, name, threshold=0.5, passes=8): def alpha_dilated_image(source, name, threshold=0.5, passes=8, gain=1.0,
"""Flood the opaque colour outward underneath the cut-out. 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 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 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 Replacing the colour under the cut-out with its nearest opaque neighbours
leaves no black to bleed. Alpha is copied through untouched, so the leaves no black to bleed. Alpha is copied through untouched, so the
silhouette is byte-for-byte what it was. 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) existing = bpy.data.images.get(name)
if existing: if existing:
@@ -244,6 +280,14 @@ def alpha_dilated_image(source, name, threshold=0.5, passes=8):
rgb[edge] = total[edge] / count[edge] rgb[edge] = total[edge] / count[edge]
filled = filled | 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 = rgba.copy()
dilated[..., :3] = rgb dilated[..., :3] = rgb
result = bpy.data.images.new(name, width=width, height=height, alpha=True) 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: if alpha_clipped and diffuse is not None:
safe_name = material.name.replace(" ", "_") safe_name = material.name.replace(" ", "_")
diffuse = alpha_dilated_image( 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: if material.name in EXPORT_BASE_COLOR_OVERRIDES:
diffuse = None diffuse = None
normal = None normal = None
@@ -424,6 +469,37 @@ def apply_mesh_modifiers(obj):
pass 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): def export(args):
if not os.path.exists(args["blend"]): if not os.path.exists(args["blend"]):
raise FileNotFoundError(args["blend"]) raise FileNotFoundError(args["blend"])
@@ -441,10 +517,11 @@ def export(args):
continue continue
meshes.append(obj) meshes.append(obj)
apply_mesh_modifiers(obj) apply_mesh_modifiers(obj)
# Hundreds of grass tufts share four mesh datablocks; unwrapping is a # Hundreds of grass tufts share four mesh datablocks; unwrapping and
# property of the mesh, so doing it once per datablock is enough. # triangulating are properties of the mesh, so once per datablock.
if obj.data.name not in unwrapped: if obj.data.name not in unwrapped:
unwrapped.add(obj.data.name) unwrapped.add(obj.data.name)
triangulate_mesh(obj)
unwrap_mesh(obj) unwrap_mesh(obj)
for slot in obj.material_slots: for slot in obj.material_slots:
if not slot.material: if not slot.material:

View File

@@ -1,5 +1,37 @@
# Changelog # Changelog
## 2026-07-31远看发黑的真正原因反照率没被提亮
(二)里加的 emissive 提到 0.8 仍然发黑。原因是 emissive 乘的是本来就很暗的
反照率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其他材质是单一表面所以 tint 合适,而这是一张同时装着叶片、
树皮、果实的图集,往绿色混会把树干也染绿。缩放保留色相关系,只是把整体抬到和
邻居一样的曝光。叶片 sRGB `[0.249, 0.35, 0.12]``[0.36, 0.50, 0.18]`
过曝到纯白的像素只占 0.4%
- `FOLIAGE_EMISSION` 回调到 0.25:它的职责只是给背光面兜底,不是主要提亮手段
- 增益之后远看仍偏灰绿,再加 `FOLIAGE_SATURATION = 1.75`,绕各像素自身
Rec.709 亮度做饱和度拉伸。这张图集本来就偏灰(平均饱和度 0.22),而远看时
mip 会把叶片、树皮和缝隙混在一起,越小越往中性靠。绕亮度拉伸能把叶片推绿而
基本不动本来就中性的部分,也没有绿色 tint 强加给树干的色相偏移——树皮只是变
暖一点。叶片 sRGB → `[0.262, 0.529, 0.021]`,整体饱和度 0.22 → 0.34
- 新增 `triangulate_mesh()``export_tangents` 打开后刷了 55 行
「切向空间只能只算三角/四边形」——`MeshBatch` 建的 OSM 轮廓都是 n-gon
Blender 只能给三角/四边形算切线。glTF 本来就只有三角形,导出时无论如何都会
三角化,所以提前做不改变任何一个输出三角形(实测三角数 51719 前后一致),
但切线从 49/102 变成 102/102警告归零
排查中被数据排除的假设,记下来免得重走:贴图颜色全链路逐位一致(不是 gamma
模拟 mip 链可见像素亮度 0.127→0.124(不是 mipmap法线贴图抠图区是干净平面法线
(不是法线污染);把导出的 GLB 重新导入 Blender 渲染,树是正常的(文件没问题)。
## 2026-07-31远看整棵树发黑 ## 2026-07-31远看整棵树发黑
黑色色块修掉后Cesium 里近看正常、远看整棵树是暗色块。逐项排查: 黑色色块修掉后Cesium 里近看正常、远看整棵树是暗色块。逐项排查: