diff --git a/blender/export_cesium.py b/blender/export_cesium.py index d5e9203..0c68596 100644 --- a/blender/export_cesium.py +++ b/blender/export_cesium.py @@ -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: diff --git a/docs/changelog.md b/docs/changelog.md index 1b3315e..22ed5c1 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -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 里近看正常、远看整棵树是暗色块。逐项排查: