天空为什么是蓝的:拖一下太阳高度,看清瑞利散射怎么染色
同一颗太阳,正午把天空照成淡蓝,落到地平线时又能把天烧成橙红。答案在瑞利散射:短波散射强,路径一长,蓝光先被散射殆尽,剩下的红光就染上了天空。可我真去搜这个问题,翻出来的基本是 App 广告和几张互相矛盾的示意图——没有一张能让我拖着看颜色是怎么变的。
所以我把这件事做成了一个网页:拖一下太阳高度角,天顶、地平线、太阳圆盘的颜色实时变,右边五条波长(400 / 475 / 510 / 570 / 650 nm)各自的散射贡献条跟着动,点一下「运行自检」还能把模型算出的数字验一遍。
元信息先摆出来:
- 源码:公开仓库 Demo Park → codearts-rayleigh-sky-lab,收录提交
e7c718b- 生成物:单个
index.html,纯前端、零依赖、不联网、不上传,全部计算在本地完成- 本地运行:双击文件即可;或
python -m http.server 8000- 复跑验证:页面自检 8/8 通过,
airmass(90°)=0.9997、(650/450)⁴=4.353这不是天体物理模拟器,也不是天气预报。页面上只有一件事:把太阳高度角和天空颜色之间那条公式,摆成能拖着看的。

为什么值得看:蓝其实是一段被散射掉的路
「天空是蓝的,因为瑞利散射,散射强度和 1/λ⁴ 成正比」——这句话几乎人人都听过,但它解释不了另一件人人都见过的事:同一片天,为什么正午是蓝的,日落是红的?
把太阳高度角当成一个能拖的变量,这件事就变得可算了。正午太阳在 90°,阳光垂直穿大气,大气光学质量 airmass 只有 1.00,路径最短,蓝光还没被散射完就到了眼睛里;太阳降到 10°,airmass 已经 5.59;降到 2°,是 19.43;贴到地平线,37.92。路径越长,蓝光在半路就被散射掉,最后落到视线里的只剩长波的红。
这个页面比一张示意图多出来的,是三个决定:
- 路径长度用 Kasten & Young (1989) 的经验公式,不用简单的 1/sin(γ)。1/sin 在低高度角会直接发散到无穷大,Kasten-Young 给的是 90° → 0.9997、30° → 1.9943、10° → 5.5860,和天文上常用的 airmass 表对得上。
- 散射强度不只乘 1/λ⁴,还要乘大气衰减 e^(−κ·m·(550/λ)⁴)。只乘 1/λ⁴ 的话,蓝光那 4.35 倍的优势会一直赢,日落永远红不起来;加上指数项,路径一长蓝光的贡献先掉到 0,红光才接得住。
- 五个波长各自算权重再混成 RGB,而不是先合成一个颜色再去调色相。所以右侧色条上的百分比和左边天空的颜色,是同一套数字算出来的,改一个滑杆两边一起动。
也说两点不足。 地平线那一圈的渐变不是第二套 airmass 公式,而是把当前 m 乘 1.35 再加 3.0 的近似,够用,但不是严格解。另外太阳高度在 5°~20° 之间时天顶会偏灰白(12° 时是 rgb(150,153,166)),简化光谱混出来的颜色比真实天空少一点饱和,看起来更像雾天。
任务描述
需求是直接粘进码道 Web 输入框的,原文如下:
开发一个单文件 index.html 的「天空实验室」网页应用,纯前端、零依赖、不联网。用瑞利散射解释天空和日落颜色:输入太阳高度角(-6°到90°),用 Kasten-Young 公式算大气光学质量 airmass(高度角 90° 时 airmass=1.00,30° 时约 2.00,10° 时约 5.6),散射强度正比于 1/λ^4(450nm 蓝光散射强度约为 650nm 红光的 4.35 倍),再按波长加权合成天空渐变颜色:太阳越高天空越蓝,越低越红。交互要有:太阳高度角滑杆(拖动即改天空颜色)、地平线以上的天空渐变和太阳圆盘、airmass 数值、蓝光红光散射比、当前 RGB、400/475/510/570/650nm 五个波长的示意色条。再加一个「自检」按钮,点下去跑 8 条数值断言(例如 airmass(90°)≈1.00、airmass(30°)≈2.00、蓝红散射比≈4.35、正午合成色蓝分量大于红分量、日落合成色红分量大于蓝分量),页面上显示「自检 8/8 通过」或具体失败条目。输入校验:高度角必须是 -6 到 90 的数字,空值或非法值要在页面上给出明确提示且不崩。界面深色简洁。完成后打开预览,让我直接看到运行效果。

这轮码道实际做了什么
没有让它去“润”一个漂亮的天气动画,动作全部围绕上面那段需求:
- 交付单文件
/workspace/index.html,界面、物理计算、自检断言、输入校验全在一个文件里,无第三方依赖。 - 物理核心是四个纯函数:
scatterRatio(λ)=(550/λ)⁴、kastenYoung(h)=1/(sin h + 0.50572·(h+6.07995)^-1.6364)、spectrumWeights(m)把「太阳光谱 × 散射比 × 大气衰减」乘起来,最后blendLinear归一化后按 RGB 混合。 - 交互按需求补齐:滑杆和数字框双向同步,太阳圆盘随高度升降、随路径变红(
sunColorStyle用1-e^(-m/7)做热化),地平线以下整页转夜色。 - 自检写成了 8 条纯函数断言,点按钮逐条渲染,最后给「自检 8/8 通过 ✓」或具体失败条目。
- 做完自己打开内置预览确认能跑,我就是在预览里看到的运行效果。

代码亮点
整页最值钱的是三个函数,各自只干一件事。
1. airmass:低角度不用 1/sin
function kastenYoung(hDeg){
const rad = hDeg * Math.PI / 180;
return 1 / (Math.sin(rad) + 0.50572 * Math.pow(hDeg + 6.07995, -1.6364));
}
function airmass(hDeg){
if (hDeg < 0) return Infinity;
const m = kastenYoung(hDeg);
return (Number.isFinite(m) && m > 0) ? m : Infinity;
}
1/sin(γ) 在 0° 直接发散,Kasten-Young 加的那个经验项把 0° 收在 37.92、10° 收在 5.5860,低角度也能算下去。地平线以下直接返回 Infinity,交给上层判成夜色。
2. 权重:散射和衰减必须一起算
w: SUN_SPECTRUM[l] * scatterRatio(l) * Math.exp(-K_SCATTER * m * scatterRatio(l))
K_SCATTER 取 0.13,是单位 airmass 下 550nm 的散射光学厚度。少了后面那个指数项,蓝光的 4.35 倍优势会一直保持,日落永远是蓝的。
3. 输入校验:非法值不崩、不乱显示
function parseAngle(raw){
if (raw === null || raw === undefined) return null;
const s = String(raw).trim();
if (s === "") return null;
const n = Number(s);
if (!Number.isFinite(n)) return null;
if (n < -6 || n > 90) return null;
return n;
}
Number.isFinite 顺手挡掉 NaN 和 Infinity,越界一律返回 null,交给调用方决定「给提示 + 保留上一次有效值」。

验证与踩坑
生成完我没有只看截图,而是把 index.html 拉到本地打开了复跑。
- 自检 8/8 通过(页面点出来的原文):
airmass(90°) = 0.9997、airmass(30°) = 1.9943、airmass(10°) = 5.5860、(650/450)⁴ = 4.353、正午合成色B=0.59 > R=0.26、日落合成色R=0.92 > B=0.05、黄昏亮度lum 109 < 151、5 类非法值全部拒绝。 - 滑杆数值扫描:90° → m=1.00、
rgb(138,150,201);30° → 1.99;10° → 5.59、rgb(150,150,156);5° → 10.31;2° → 19.43、rgb(57,35,36);0° → 37.92;−6° → 无穷大、rgb(10,14,28)转夜色。颜色是一路连续变过去的,不是几段硬编码渐变。 - 失败路径:数字框里输入字母或越界值,页面立刻红框 + 提示
请输入 -6° ~ 90° 之间的数字(当前显示上一次的有效值),airmass 停在 4.71(上一次有效的 12°),天空颜色不动;失焦后输入框回退成 45。 - 一个真实的小坑:
<input type="number">会直接把字母清空,所以输入abc实际是走「空值」分支触发的提示——非法输入真正的分支只有两条:空值、越界。这算浏览器行为,不算代码问题,但测试时得知道,否则会以为字母那一条没被覆盖。

准备环境:进入码道 Web
浏览器打开码道 Web 版:https://devcloud.cn-north-4.huaweicloud.com/chat?source=dmzntgwsf&sourcead=dmzntgwwbwz,登录后就能在对话窗口输入需求,不需要装软件。
码道有三种使用方式:WebUI(浏览器对话)、TUI(终端命令行)和桌面 IDE(IDE 插件),本文用 WebUI 版。
本地复现
生成物只有一个 index.html,复现没有依赖:
- 从 Demo Park 的本案例目录下载
index.html。 - 双击打开,页面默认停在 45°。
- 想用静态服务也行:
python -m http.server 8000,然后访问http://localhost:8000。 - 点「运行自检」,应该看到「自检 8/8 通过 ✓」。
使用码道体会
这轮下来能带走的,主要是几条和模型打交道的习惯:
- 把验收方式写进需求。结尾那句「完成后打开预览,让我直接看到运行效果」,是这个项目里省掉来回最多的一句话。
- 数值断言要连着参考值一起给。我把 90° / 30° / 10° 三个 airmass 参考值写进了需求,它就把自检做到了页面上;只写「用 Kasten-Young 公式」,交出来的多半只是个好看的渐变。
- 「不崩」也得写清楚。需求里写明「非法值要给明确提示且不崩」,比事后自己补校验省事。
- 单文件需求要把边界写满。纯前端、零依赖、不联网这三条一写,产出就是双击能开的文件,没有安装步骤,也没有环境问题。
- Agent 说完成不算验收。8/8 是页面里点出来的数字,不是回复里那句话;滑杆我还是自己拖了一遍,非法输入也自己输了一次。
- 截图要带模型名和自检输出。一张干净的效果图证明不了什么,带自检结果和模型名的截图才是证据。
亲手点三下
页面打开就能验,三下够了:
- 看尺子:点「运行自检」,确认是
自检 8/8 通过 ✓,再核一下(650/450)⁴ = 4.353这条——蓝光散射强 4.35 倍,是整页的起点。 - 换个状态:把太阳从 90° 慢慢拖到 0°,看 airmass 从 1.00 涨到 37.92,同时 400nm 的贡献从 100% 掉到 0%、650nm 从 19% 涨到 100%。
- 看失败路径:数字框里输入
abc或200,必须看到红框 +请输入 -6° ~ 90° 之间的数字(当前显示上一次的有效值),天空颜色停在上一帧、不白屏、控制台不报错。
直接下载试玩
- Demo Park 仓库:https://atomgit.com/deli007/demo_park
- 本案例目录:https://atomgit.com/deli007/demo_park/tree/main/codearts-rayleigh-sky-lab
- 收录提交:
e7c718b
总结
把「天空为什么是蓝的」做成一个能拖的页面之后,最重要的不是那张好看的渐变,而是 airmass 和衰减项这两处:一个负责让低角度也能算,一个负责让蓝光真的会被散射掉。改这两行里的任何一个,日落都会立刻变得不对——这就是公式能拖出来的好处。想接着玩,可以补上直射阳光的色温、加个「一天的时间轴自动播放」,或者把地面反射也算进去。
- 点赞
- 收藏
- 关注作者
评论(0)