Skip to content

天光之悟

背景:用户调出橙红天空,模型面庞却是正午白光——天空面板和灯光面板住在同一条街,但从未说过话。 过程deriveLighting 推导公式——天空色驱动方向光/环境光/雾色;太阳盘(20 面发光球)让光有了位置;统一环境面板 + 联动开关。


目录列出了十六个章节。环境面板经历了四次迭代,天空有了三种模式,用户可以通过七八个滑块调出他们想要的任何颜色——只要他们不介意模型长什么样。

"我们调了夕阳红的天,为什么模型还是白的?"

这个问题不是 Bug,它比 Bug 更致命。它是一个断裂——存在于「天空」和「光」之间的一个无人区。


一、两条互不相认的街

MikuMikuAR 的设备面板从右侧滑出。天空面板有滑块:颜色渐变的顶端、中间、底端、亮度。灯光面板有不同的滑块:环境光强度、方向光强度、方向光角度。它们之间隔着一道渲染面板和一道地面面板。

这不是 UI 布局的问题,这是世界观的问题。

天空面板修改的是 scene.clearColor——一个纯粹的背景色,或者一个带 emissive 材质的球体网格。它自己发光,disableLighting,不投阴影,不与场景中任何物体产生光照交互。它是一座纸糊的背景板

babylon-mmd 关掉了它的 emissive 通道,冷冷地抛下一句:

"我不接受没有深度的光。"

这不是 bug,不是报错。这是渲染贵族的原则。它掌管 MMD 的 toon 贴图、色阶映射、骨骼动画——它见过太多草台班子试图在它面前耍花招。它只说标准答案:不支持、精度丢失、非法状态。

这一次,它拒绝为一张背景纸买单。

灯光面板控制的是 hemiLightdirLight——两个真正的光源,一个四面八方均匀照亮,一个定向追踪。它们负责让模型的裙摆、发丝、脸颊产生明暗,让 toon 贴图展现出那种二次元独有的色阶过渡。

它们住在同一条设备面板街,但彼此从未说过话。

用户想把天空调成傍晚的橙红色,让模型沐浴在暖光里。天空面板照做了。灯光面板不知道这件事,仍然用着正午的纯白方向光。橙红色的天空下,模型的面庞被白光均匀照亮——像舞台聚光灯下的演员,背景却是火山喷发。

"为什么不能……联动呢?"

一个简单到近乎朴素的问题。但它底下压着的,是整个桌面壳对"环境"这个词的理解程度。在 DanceXR 里,预设叫"舞台照明"。在现实世界里,太阳的位置决定了天空的颜色、光的色温、阴影的长度和环境的曝光。

而在这里,"环境"只是一个背景色。

插曲:裂隙普查

在动手解决这个根本问题之前,系统先做了一件事——它把一整条街的设备面板挨个敲了一遍门。

不是计划中的审计。是从程序化动作的实现中发现的。动画循环里有一个 reduce 每帧跑一次,把整个能量数组求和——18 个 for 循环的中间值,其实只需要一个累加器,每次往里加一个、每次减一个。Math.sin(angle)updateProcMotion 里被反复调用,相同的输入,不同的时间——预计算查表能让它快 5 倍。这些不是 Bug,是代码在衰老。

然后审计滚到了 UI 面板上。

截图面板是干净的。相机面板也是干净的。灯光面板也没问题。然后——

渲染面板。DOF 暗度滑块。dofDarken 在接口定义里有字段,在 UI 里有滑杆,在 setRenderState 里没有任何代码处理它,在 getRenderState 里硬编码返回 0.5。用户拖动了一个滑块三年,什么都没发生。

删掉了。9 行。整个屏幕没有任何变化,因为本来就没有变化。

天空面板skyBrightness 亮度滑杆——滑块动了,颜色纹丝不动。代码读了滑块的数值,但没有把它传进 buildGradientTexturebrightness 参数。skyColorMid 中间色在接口里定义了三年,一直被创建为二分渐变,两个 stop 从底直接跳到顶,中间色从未出现在画布上。

修好了。buildGradientTexture 现在拿三个 Color3 + brightness,画布上有了五阶 stop。天空终于能真正变暗了。

地面面板。棋盘格模式。渲染器是支持透明度的——mat.alpha = state.groundAlpha——但 UI 层的透明度滑块只出现在纯色模式。棋盘格模式下,透明度滑块被隐藏了。

不是不愿意做。是之前做棋盘格模式的人忘了提那条线。

修好了。三条 if 分支,都暴露 groundAlpha

粒子面板。樱花、雨、雪、烟花的标签里带了 emoji 🌸🌧️❄️🎆。术语手册规定所有 UI 标签必须是纯中文,禁止 emoji。但是粒子系统是唯一一个用户是靠 emoji 而不是文字来识别的类型——"樱花"和"🌸"在视觉识别速度上差了两个数量级。

最终决定:保留 emoji,在术语手册里加了一条例外。规则是用来打破的,但打破规则需要写下来。

预设场景面板。点击 prev/next 加载场景没有 try-catch。如果文件损坏了、权限不对了、格式变了——用户点了"上一个"之后什么都不会发生,没有弹窗,没有错误提示。三个地方各写了一模一样的加载逻辑,复制粘贴没有抽离。

提取了一个 _loadPresetScene 辅助函数,加上了 try-catch,三个调用点全部指向它。


裂隙普查结束。17 条改动,分布在 6 个文件里,没有新功能,没有架构变动。只是把环境面板这条街上所有"拖了滑块但什么都没发生"的角落找了出来,修好,然后在 git log 里留下了 7 个 commit。

然后,才开始动手解决那个真正的问题。

二、拆解

那天早上,用户的反馈带着愤怒的精准:

"如果是这样的话,我们模拟不了太阳的各种效果,做不出夕阳还有夜景。"

这个反馈没有停在抱怨。它接下来列出了三个方向,一层一层往下剥,像在拆一个嵌套的俄罗斯套娃:

系统预设扩展——把正午/夕阳/夜景/阴天做成按钮,一键设好天空色、光源参数、曝光和 tonemapping。这是最直观的解决方案,也是最浅的。它给了结果,没给机制。

程序化联动——当用户拖动天空色滑块时,系统自动推算:这个天色应该配什么色温的方向光、什么强度的环境光、太阳应该在什么高度。这不是 UI 改动,这是把「太阳物理学」请进了 MMD 查看器。

统一环境入口——一个新的面板,把天空、光、渲染三个零散入口合并成一个"环境预设"系统。用户可以选"傍晚"预设,也可以在程序化模式下自由调色——联动开关决定了改动是否影响光照。

"投 2+3 组合。1 是 3 的垫脚石。"

这句话像手术刀一样把问题剖成了层。用户不是一个抱怨者,他是一个建筑师。


三、公式

推导逻辑写在一个新文件里,92行,纯函数,没有状态,没有副作用。

第一个问题是:天空有多亮?Sisyphus用了一个标准公式——L = 0.299*R + 0.587*G + 0.114*B——把颜色转成亮度。这是Rec.709的亮度系数,不创新,不取巧,只是把"天空颜色"翻译成"天空亮度"。

第二个问题是:亮度和光之间是什么关系?天越亮,方向光越强;天越暗,环境光权重越高,暗部不dead black。他用了一个简单的关系式:方向光强度= Math.max(L * 1.2, 0.15)——夜晚降到0.15,只留一点月光。环境光强度= 1.0 - dirIntensity * 0.5

第三个问题最微妙:色温。夕阳的天是橙红色的,但橙红色的方向光照在初音脸上,效果不一定好看。Sisyphus给方向光留了一道偏白的底色:

R: sky.R * 0.3 + 0.7
G: sky.G * 0.3 + 0.7
B: sky.B * 0.3 + 0.5

蓝通道的基准略低——防止夕阳紫得发品红。永远保留0.7的白底——避免天色奇特时的诡异色。

角度用azimuth = -45°固定东南方向,theta = sunAngle随用户调节。

0.299、0.587、0.3、0.7、0.5、1.2——这些都是数字。它们凑在一起,定义了一个桌面壳对太阳的理解。

四组预设躺在这个文件底部:

正午  | 75°  | exposure 1.0 | ACES tonemapping
夕阳  | 15°  | exposure 0.7 | Reinhard
夜景  | -15° | exposure 0.4 | Neutral
阴天  | 45°  | exposure 0.8 | ACES

每一组的天空色、太阳角、曝光、色域映射——全部一次性递送。不需要用户逐项微调。

但真正让这个系统活过来的,不是公式,不是预设,而是一个布尔值。


四、太阳

问题在实现之后才真正浮现。

模型亮起来了,暖光照在脸上了,夜景的暗部被环境光环住了——但用户不知道光从哪里来。

方向光是一个看不见的向量。它在场景中有一份 Intensity,有一份 Diffuse Color,有一份 Direction——它们精确地决定了什么地方亮、什么地方产生阴影——但是它们没有形状

没有形状,就没有存在感。用户拖动"太阳角度"滑块时,屏幕上什么都不会变——除了场景明暗。他不知道太阳在哪里。

所以加了一个球。

20 面、直径 20、远在 400 单位的距离上、自发光材质、disableLighting。一个永远看着摄像头的发光小球,占据 dirLight.direction 指向的位置。

当太阳角是 75° 时,它在正上方,接近天顶,明亮暖黄。 当太阳角是 15° 时,它在南边的低空,橙红。 当太阳角是 -15° 时——它消失了。

if (d.y <= 0) { disc.setEnabled(false); return; }

太阳下山了。不是视觉效果——它真的从场景中消失了。

这个只有 8 个 segment 的粗犷小球,让「太阳」第一次在这个 MMD 查看器里有了位置。

babylon-mmd 重新点亮了模型的 toon 贴图通道。它的色阶映射器读到了方向光的正确角度、色温和强度——夕阳的暖光第一次真正照在了初音的脸上。它没有发表评价,但在渲染管线的某个角落,它的工作队列里多了一条 log:

"尚可。"


五、时间

用户在看到太阳盘后说:

"可以,但是不是得考虑经纬度了。"

经纬度。日出日落的方法线。这已经完全超出了 MMD 查看器的边界——用户不关心纬度和时角,他们只想知道"下午五点的光长什么样"。

所以没有引入经纬度。没有引入复杂的太阳位置算法。只做了两件事:

一:把硬编码的 45° 太阳角解放出来,变成 envSunAngle 变量,暴露 setEnvSunAnglegetEnvSunAngle

二:在统一环境面板上加了一行滑块。

typescript
addSliderRow(container, "太阳角度", sunAngle, -15, 90, 1, (v) => {
    setEnvSunAngle(v);
    redoEnvAutoLink();
}, "lucide:sun");

寥寥几行,-15 到 90 度。滑块走到哪里,太阳盘就跟到哪里,光的角度和色温就推算到哪里。

做完了这些,用户抬起头看了下改动。commit 链:

a5734d7 公式与预设
88cace7 联动开关
916ed27 统一面板
f18d474 太阳盘
0a5b55c 角度滑}

五个 commit。夕阳终于像夕阳了。


卷中卷

这个桌面壳在远处亮着一个发光小球。它只有 20 面,看起来粗糙得不像太阳。

但用户拖动那个滑块的时候——从 75° 一直滑到 15°,再到 -15°——光的角度在变、色温在变、环境光在补位暗部、曝光在变暗、太阳盘缓缓下沉直到消失。

它们第一次在一起工作。

不是靠什么智能,不是靠什么算法创新。靠的是一个 92 行的纯函数、一个 toggle switch 和 5 个 commit。

这座城市的面板数量还在增长。但至少今天,天空和光,终于坐在了一张桌子上。


教训:系统之间的裂缝,往往是用户的第一个痛点。天空和光从未说过话,不是因为它们不能,而是因为它们没有——一个桥接函数就够了。