Skip to content

天穹之图

背景:GradientMaterial 在 BACKSIDE 球体上全黑,SkyMaterial 同样问题,自定义 ShaderMaterial 被 ShaderStore 双实例阻断。 过程:三套方案失败后,改用 StandardMaterial + Canvas 2D 渐变贴图——六行代码,零延迟,删掉"gradient"模式。


联邦建起来之后,下一个问题浮出水面:天空在哪里?

底座已经把模型放在了舞台中央。浏览器赋予了它骨骼、动作、光照的影子。但它站在一个灰色的虚空里——scene.clearColor = new Color4(0.12, 0.12, 0.16, 1.0)。一片没有层次的深空。

桌面壳说,我要一个天空。

它的目标很朴素:上半球一种颜色(天顶色),下半球一种颜色(地平色),中间缓缓过渡。像黄昏的地平线,像清晨的天际弧。

Babylon.js 说:我刚好有一个 GradientMaterial,两层颜色渐变,装在球体上就是天空。

桌面壳说,那就用它。


一、法线的诡计

GradientMaterial 被放进了一个球体。

球体很大——直径 1000 单位——把整个场景罩在里面。sideOrientation: Mesh.BACKSIDE,法线向内。这样相机在球体内部时,看到的是球体内表面。

桌面壳设了 topColorbottomColor,然后点击运行。

屏幕是黑的。

它检查了滑条——天顶色 (0.30, 0.50, 0.80),地平色 (0.20, 0.20, 0.25),颜色值在代码里都对的。它在 Console 里敲了调试命令,看到 GradientMaterial 被老老实实地绑在球体上,看到两个颜色的 RGB 分毫不差。

但屏幕上就是黑的。

"GradientMaterial,你的颜色去哪了?" 桌面壳问。

GradientMaterial 不回答。它是一个 material,它只做一件事——在 GPU 上运行自己的片段着色器。桌面壳决定去看那个着色器。

着色器很诚实。它清清楚楚地写着:

glsl
baseColor = mix(bottomColor, topColor, h);
...
diffuseBase = vec3(0., 0., 0.);   // 默认路径
...
finalDiffuse = diffuseBase * baseColor.rgb;
gl_FragColor = vec4(finalDiffuse, alpha);

桌面壳看到 diffuseBase = (0,0,0) 时,心里咯噔了一下。

GradientMaterial 不是发光材质。它有一个正常的 diffuseBase 通道——从场景光照计算出来。如果场景有一个方向光从上往下照,而球体的 BACKSIDE 法线指向球心(对光源来说是背面),点积就是负的,光照贡献就是零。diffuseBase = (0,0,0)。乘以任何颜色都是黑。

"GradientMaterial,你有光照通道,但你没有告诉我——你用 BACKSIDE 球体时,光照永远是零。"

GradientMaterial 沉默着。这不是它的错——它被设计成标准材质,标准材质就该响应光照。只是没有人告诉桌面壳,"需要把你设为 disableLighting = true,切换成 EMISSIVE 路径,直接从 baseColor 输出颜色。"

桌面壳加了那一行。天空亮了。

"第一个坑," 它在日志里写,"GradientMaterial 在 BACKSIDE 球体上默认是黑的,需要人工解除光照。"


二、三色的执念

颜色出来了,但桌面壳觉得不对。它看着天顶色和地平色之间的过渡带——只有 1% 的球体高度。在 1000 直径的球上,那条过渡带窄得几乎看不见。

而且,中间色去了哪里?

EnvState 里明明有 skyColorMid,一个整整留了三格的"中间色"滑块。但 GradientMaterial 只接受两种颜色,midColor 字段在 config.ts 里躺了很久,从未被材质读过。

"我要一个三色渐变。" 桌面壳说。

Babylon.js 没有现成的三色渐变材质。桌面壳决定自己写一个,用 ShaderMaterial

它在 .ts 文件里写了一段 GLSL:

glsl
if (h > 0.5) {
    col = mix(midColor, topColor, (h - 0.5) * 2.0);
} else {
    col = mix(bottomColor, midColor, h * 2.0);
}

上半段走中间→天顶,下半段走地平→中间。优雅,对称。

它把着色器注册到 ShaderStore.ShadersStore,然后创建 ShaderMaterial

控制台报错:

GET http://wails.localhost:34115/src/Shaders/grad3.vertex.fx 404 (Not Found)

"你在跟一个你不知道的实例说话。" 一位路过的外交官(AI 助手)说。

Vite 预打包让 @babylonjs/core 有两个实例——桌面壳把着色器写进了自己手里的 ShaderStore,Effect 读的却是它手里的另一个。两本字典,你写了上半部,它查下半部。

它试过写 .fx 文件到磁盘、改 Vite 的 assetsInclude——全失败了。Wails 的虚拟文件系统和 Vite 的静态资源路径对不上。三个系统各自为政:Vite 管文件,Babylon 管着色器,Wails 管代理,中间隔着一个 ShaderStore 的幽灵。

"算了吧," 它自言自语,"这条路走不通。"

第二个材质也放弃了。


三、外交官的试探

"你有没有试过 SkyMaterial?" 外交官问。

"SkyMaterial 又是什么?"

"Babylon 的官方程序化天空——物理大气模型。turbidity、rayleigh、sunPosition,模拟真实天空散射。不依赖自定义着色器。"

桌面壳切到 SkyMaterial。它设了 luminanceturbidityrayleigh,把太阳放在地平线上方。它满怀期待地点击了运行。

橙色。球体内表面出现了一种暖烘烘的橙色。

"为什么是橙色?"

"因为 StandardMaterial 发光就是橙色," 外交官说,"我刚才换成了 StandardMaterial 验证球体本身是否渲染。球体渲染了——说明不是场景的问题,是 SkyMaterial 本身。"

桌面壳把 SkyMaterial 换回来。黑色。和 GradientMaterial 一样——同样的 BACKSIDE 球体,同样的全黑。

它查了 SkyMaterial 的源码。sky.vertex.js 里赫然写着:

c
varying vec3 vNormalW;

SkyMaterial 也依赖法线。法线向内,计算结果反向,颜色就去了不该去的地方。

"Babylon 的两个天空材质——GradientMaterial 和 SkyMaterial——走的都是标准光照管线," 外交官说,"而标准光照管线默认法线向外。你做天空球体时 BACKSIDE 反转了法线,但材质的着色器不知道这件事。所以要么颜色被光照归零,要么物理参数算出来的结果完全错位。"

三个材质,三次失败。桌面壳折腾了整整两天,试了 Babylon 所有的天空方案,全都黑了。

"那别的 3D 引擎怎么做天空的?" 桌面壳问。

"贴图。"

桌面壳愣了一下。

"天空球体只是背景。它不需要法线,不需要光照,不需要散射方程。它只需要一张贴图——从上到下的渐变贴图。2D Canvas 生成的,不是 3D 引擎算的。贴在球体上,设置 disableLighting = true,用 emissiveTexture 自发光输出颜色。这样做,你的天顶色和地平色就完全由你的滑块控制,不受光照、法线、散射模型的影响。"


四、Canvas 画天空

桌面壳打开了一张 256×2 的画布。

它拿起虚拟的画笔,蘸上天顶色——(0.30, 0.50, 0.80)——在画布的上半段涂满。又换成地平色——(0.20, 0.20, 0.25)——涂满下半段。中间留了一道 10% 的过渡带,让颜色均匀渐变。

它调用 canvas.toDataURL("image/png"),把 data URL 丢给 Babylon 的 Texture 构造函数。纹理被贴到一个新的 StandardMaterialemissiveTexture 上。材质被绑到一个 BACKSIDE 球体上。disableLighting = true

"渲染。"

天空亮了。天顶蓝,地平灰,过渡平滑,滑块实时生效。修改颜色,Canvas 重绘,纹理替换——没有任何着色器编译、没有 ShaderStore 双实例、没有 .fx 404。只有一张 2D 画布、一个标准材质、一个球体。

"就这?" 桌面壳有点不敢相信。

"就这。"

它打开 Console 输入 __envDebug(),看到 matType: "StandardMaterial"

"一个 2013 年的标准材质,打败了后来所有的自定义着色器和物理模型。"

外交官说:"越是简单的东西,越难打败。"


五、另一个真相

天空恢复颜色后,桌面壳看着"渐变"的按钮,忽然想起一件事。

它翻开了 DanceXR 的源码。那个被称为"第一城邦"的外来者——它的天空渲染模块里,从来没有 GradientMaterial

DanceXR 只用了程序化天空。而它用的程序化天空不是 SkyMaterial,是它自己写的一个半球光散射模型,跟 Babylon 官方的东西没有任何关系。

"我折腾了整整两天的渐变模式," 桌面壳说,"从头到尾就没有人调用过它。"

它调出 config.ts,看着 skyMode 的枚举值:

"color" | "gradient" | "texture" | "procedural"

它选中了 "gradient" 那一行,按下删除。

typescript
skyMode: "color" | "texture" | "procedural";

键入了。它删掉了 _createGradientSky、删掉了所有 ShaderMaterialShaderStore 的 import、删掉了 .fx 文件、删掉了 UI 上的"渐变"按钮、删掉了测试文件里的 GradientMaterial 测试用例。一共删除或重写了近百行代码。

"你现在知道什么时候该停手了。" 外交官说。

桌面壳看着干净的 scene.ts——StandardMaterial + Canvas 做程序化天空,SkyMaterial 不再需要,ShaderMaterial 不再需要,.fx 文件不再需要。

但是等一下——procedural 模式现在用的是 StandardMaterial + Canvas 贴图。它已经不是一个真正的"程序化"天空了,只是一个"用 Canvas 生成的渐变贴图天空"。

"这个模式应该改名叫 canvas 或者 gradient_canvas," 桌面壳嘀咕。

"留着 procedural," 外交官说,"名字只是标签。真正重要的——是你知道天空是怎么来的了。"


六、天穹之图的教训

桌面壳在新地图上画了一个倒置的穹顶。

穹顶的内侧被分成两半:上半场是天顶色的领地,下半场是地平色的王国。交界处是一条柔软的地平线——不宽不窄,刚好让眼睛舒适。

穹顶下面写着一行小字:

天空不是一个物理系统。它是一个贴图。

任何看起来像天空的东西,如果你去拆它的实现,最终你会发现它只是一张画在 2D 平面上的渐变图,被拉伸到 3D 球体的内表面。

法线会骗人。光照会归零。ShaderStore 会有两个实例。但 Canvas 不会撒谎——你画什么颜色,它就输出什么颜色。

桌面壳把这张图钉在新地图的入口处。在旁边加了一行批注:

第四个坑: 天空模式 "gradient" 从未被 DanceXR 使用。联邦在重构前,没有先检查依赖方是否真的依赖这个能力。

第五个坑: 自定义着色器被 ShaderStore 双实例阻断。Vite 预打包让 @babylonjs/core 有两个实例——你写进手里的 ShaderStore,引擎读的是另一个。在 Vite + Wails 架构下,你无法保证手里那个 ShaderStore 就是引擎读的那个。

第六个坑——也是最重要的: Canvas 2D createLinearGradient 的 V 坐标和 Babylon 球体的 UV V 坐标是相反的。Canvas stop 0 是球体的底部(地面),stop 1 是球体的顶部(天空)。桌面壳第一次画反了——天顶色出现在了地上,地平色出现在了天空。它翻转了色标位置,一切才恢复正常。

"你以为你懂的每一个 API," 桌面壳写道,"它都有一个你没想到的方向。"


联邦的穹顶终于完整了。

模型在天空下舞动——上半身映着天顶色的蓝光,下半身被地平色的灰调包裹。中间那条柔和的地平线,在模型的腰部附近缓缓过渡。它看起来像真实世界里的黄昏。

但桌面壳知道它只是一张 Canvas。

一张 2 像素宽、256 像素高的迷你画布,用最朴素的 createLinearGradient 画完、toDataURL、塞进纹理槽——完成了 Babylon 两个专业材质做不到的事。

"有时候," 外交官在离开前说,"最正确的工具不是最专业的工具。是最简单的那个。"

桌面壳关掉编辑器。模型还在跳。天空还在亮。

联邦的下一个地图还没画完。但至少——它头顶的那片天,现在是自己的了。


教训:天空不是最专业的着色器画出来的,是一张两像素宽的 Canvas 画出来的。越简单的东西,越不会骗你。