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:
@@ -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:
|
||||
|
||||
@@ -1,5 +1,37 @@
|
||||
# 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(二)远看整棵树发黑
|
||||
|
||||
黑色色块修掉后,Cesium 里近看正常、远看整棵树是暗色块。逐项排查:
|
||||
|
||||
Reference in New Issue
Block a user