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
|
# 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:
|
||||||
|
|||||||
@@ -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 里近看正常、远看整棵树是暗色块。逐项排查:
|
||||||
|
|||||||
Reference in New Issue
Block a user