一个静态页面、30 000 个采样点,把氢原子的概率密度实时画成一团可以旋转的电子云。
在线体验:打开电子云实验室

一、目标:不是一张会动的海报
最初的想法很具体:做一个深空紫黑背景、发光的品红点云、带 HUD 仪表盘的「氢原子电子云」页面,还要能切换量子数。
但我不想把它做成一张「会动的海报」。点云必须来自真实的波函数——切换 n、ℓ、m 时,云的结构要跟着物理规律变化;同时要足够流畅,手机上也能拖动旋转。
最后定下的约束是:
- 单文件 HTML,无框架、无构建、无 CDN;
- 真实的氢原子波函数采样,不是预设模型;
- WebGL 渲染,30 000 个点实时旋转;
- 桌面端右侧仪表盘,移动端上下堆叠;
- 支持 PNG 导出,WebGL 不可用时自动降级为 2D。
最终文件约 62 KB。
二、物理:先把波函数算对
2.1 分离变量
氢原子(Z = 1)的定态波函数可以写成径向部分和角向部分的乘积:
ψnℓm(r, θ, φ) = Rnℓ(r) · Yℓm(θ, φ)
我们看到的电子云,本质是概率密度 |ψ|² 的分布。所以只要把 R 和 Y 都算对,采样出来的点云自然就是对的。
2.2 径向波函数
采用原子单位(a₀ = 1),径向函数为:
Rnℓ(r) = √[ (2/n)³ · (n−ℓ−1)! / (2n·(n+ℓ)!) ] · e^(−ρ/2) · ρ^ℓ · L^(2ℓ+1)_(n−ℓ−1)(ρ)
其中 ρ = 2r/n,L 是关联拉盖尔多项式。拉盖尔多项式用递推计算,避免直接展开阶乘和幂:
(q+1)·L(q+1) = (2q+1+k−x)·L(q) − (q+k)·L(q−1)
代码很短:
function radialR(n, l, r) {
const rho = 2 * r / n;
const norm = Math.sqrt(
Math.pow(2 / n, 3) * fact(n - l - 1) / (2 * n * fact(n + l))
);
return norm * Math.exp(-rho / 2)
* Math.pow(rho, l)
* laguerre(n - l - 1, 2 * l + 1, rho);
}
2.3 角向:实球谐与相位
为了画出「正相位 / 负相位」两种颜色,我用的是实球谐函数,而不是复数形式:
- m = 0:
Y = N · Pℓ(cosθ) - m > 0:
Y = √2 · N · Pℓ^m(cosθ) · cos(mφ) - m < 0:
Y = √2 · N · Pℓ^|m|(cosθ) · sin(|m|φ)
归一化系数:
N = √[ (2ℓ+1)/(4π) · (ℓ−|m|)!/(ℓ+|m|)! ]
Pℓ^m 用标准的连带勒让德递推算。函数值的符号就是相位:R 和 Y 同号时标成品红,异号时标成青蓝。这样 2s 会出现「内层粉、外层蓝」,3d、4f 的相邻花瓣也会自动交替颜色。
2.4 可验证的物理量
除了云本身,界面上还实时显示这些量,方便对照:
| 物理量 | 公式 | 说明 |
|---|---|---|
| 平均半径 ⟨r⟩ | [3n² − ℓ(ℓ+1)] / 2 |
单位 a₀ |
| 能级 En | −13.606 / n² |
单位 eV |
| 径向节面 | n − ℓ − 1 |
球壳状节点 |
| 角向节面 | ℓ |
通过原子核的节面 |
三、采样:把 |ψ|² 变成 30 000 个点
3.1 为什么不能均匀撒点
如果在一个立方体里均匀随机取点,得到的只是一团均匀的雾,完全看不出轨道形状。正确做法是按 |ψ|² 采样:概率密度大的地方点更密。
径向和角向可以分开采样,因为 |ψ|² = |R(r)|² · |Y(θ,φ)|²。
3.2 径向:累积分布 + 逆变换
径向的概率分布是 P(r) = r²|R(r)|²。先在 [0, rmax] 上以 6 500 个步长积分,得到归一化的累积分布 CDF,然后用逆变换采样:
function sampleR(rad, u) {
let lo = 0, hi = rad.steps;
while (lo + 1 < hi) {
const mid = (lo + hi) >> 1;
if (rad.cdf[mid] < u) lo = mid;
else hi = mid;
}
const a = rad.cdf[lo], b = rad.cdf[hi];
const t = b > a ? (u - a) / (b - a) : 0;
return (lo + t) * rad.dr;
}
这个方法没有拒绝、不会浪费样本,而且 ⟨r⟩ 的抽样误差能稳定在 1% 以内。
3.3 角向:|P|² 逆变换 + φ 拒绝采样
角向部分 |Y|² 可以拆成 |Pℓ^m(cosθ)|² 和 cos²(mφ)(或 sin²(mφ))的乘积。
cosθ:同样建一条 1 600 步的 CDF,逆变换采样;φ:m = 0 时均匀采样;m ≠ 0 时用拒绝采样,接受概率是cos²(mφ)或sin²(mφ),平均两次就能接受一个。
这样每个点只需要两次 CDF 查找(径向和 cosθ)、一次 Legendre 计算和少量三角函数。30 000 个点的生成在几十毫秒量级,切换量子数时几乎感觉不到等待。
3.4 相位决定颜色
采样时同时记录两个符号:径向函数 R(r) 的符号,以及角向函数 Y 的符号。两者相乘为负,就写入「负相位」标记。渲染时按标记选择品红或青蓝,这就是画面里颜色交替的来源。
四、渲染:WebGL 点精灵
4.1 为什么不用 Canvas 2D
30 000 个点、每个点还要有柔和的辉光,用 Canvas 2D 的 arc 或 drawImage 每帧重绘会非常吃力。WebGL 的 gl.POINTS 可以一次性提交所有点,光栅化和混合都交给 GPU,还能用点精灵(point sprite)在片元着色器里画出圆形柔光。
4.2 顶点着色器:旋转、投影、景深
每一帧只更新一个 3×3 的旋转矩阵,点的位置不变。顶点着色器负责旋转、正交投影,以及按深度调整点的大小和亮度:
vec3 p = uRot * aPos;
float t = clamp(p.z / uZ, -1.0, 1.0);
gl_Position = vec4(
p.x * uScale / (uRes.x * 0.5),
p.y * uScale / (uRes.y * 0.5),
-t * 0.8, 1.0
);
gl_PointSize = uPoint
* (0.62 + 0.72 * (t * 0.5 + 0.5))
* (0.70 + 0.60 * aRand);
aRand 是每个点自带的随机数,让点的大小略有差异,看起来更自然;深度越靠前的点越大、越亮,云就有了体积感。
4.3 片元着色器与加法混合
点精灵的圆形柔光来自一个高斯衰减:
vec2 d = gl_PointCoord - 0.5;
float r2 = dot(d, d) * 4.0;
if (r2 > 1.0) discard;
float a = exp(-r2 * 2.9) * vA;
gl_FragColor = vec4(vColor * a, a);
混合模式用 ONE, ONE 加法混合。大量点重叠的地方会自然累积成亮白色核心,边缘则保持透明,这正是电子云「中间亮、四周淡」的观感来源。
4.4 三层辉光与曝光补偿
单个点只有 1.85 px 左右,直接画会显得很「沙」。所以每帧画三遍:
| 层 | 点大小 | 透明度 | 作用 |
|---|---|---|---|
| 外层辉光 | 6.5× | 0.028 | 大范围雾状光晕 |
| 中层光晕 | 2.8× | 0.082 | 让点与点之间连成云 |
| 核心点 | 1× | 0.86 | 保留颗粒质感 |
三层叠加后,既有细密的颗粒,又有柔和的整体辉光。
还有一个容易被忽略的问题:手机屏幕小,同样 30 000 个点挤在更小的面积里,重叠会更多,中心容易过曝成一大块白色。所以我按点云在屏幕上的实际半径做了一次曝光补偿:
exposure = clamp((visR / 232)^1.1, 0.42, 1.02)
屏幕越小,单点亮度越低,中心就能保持一个紧凑的白色核心,而不是糊成一片。
4.5 背景与 HUD
点云之外的部分基本都是 CSS:
- 星野用一块独立的 2D canvas 画,带缓慢闪烁;
- 背景是径向渐变叠加网格和噪点,中心留出紫色光晕;
- 扫描线、四角括号、准星、坐标刻度都是伪元素或小 div;
- 原子核是一个带
box-shadow光晕的小圆点,外加呼吸动画。
这样 GPU 只负责最重的点云,界面装饰交给浏览器合成,性能和效果都比较平衡。
五、界面:把实验室装进一屏
5.1 布局
桌面端是一个两栏网格:左边是占满剩余空间的 3D 舞台,右边是 344–412 px 的仪表盘。窗口宽度小于 940 px 时切换成单栏,舞台固定约 54vh,仪表盘依次堆叠在下面。

右侧面板从上到下依次是:
- 轨道名与 n、ℓ、m 三个量子数胶囊;
- ⟨r⟩、En、节面数量;
- 径向分布曲线;
- 量子数选择器和预设。
5.2 径向分布曲线
曲线用 Canvas 2D 画,采样 460 个点,填充渐变、描边发光,并在 ⟨r⟩ 处画一条虚线。切换轨道时用裁剪区域做从左到右的绘制动画,看起来像仪器在重新扫描。
横轴刻度用「1 / 2 / 2.5 / 5 × 10^k」的规则取整,避免出现 3.333 这种刻度。
5.3 交互
- 拖拽旋转:记录指针速度,松手后有轻微惯性;
- 滚轮缩放:限制在 0.55–2.8 倍;
- 双击复位:用缓动把视角转回初始角度;
- 自动旋转:默认开启,拖拽时暂停,松手 1.7 秒后恢复;
- 快捷键:
1–6切换主量子数,空格暂停旋转,R复位视角。
量子数选择器会随 n、ℓ 的变化重建:n 增大时 ℓ 的可选范围变长,ℓ 变化时 m 的范围跟着变,选中的值超出范围会自动收缩到合法值。
5.4 导出与降级
「导出 PNG」会把星野、点云和标题重新合成到一张 2 倍分辨率的画布上,下载为图片。
如果浏览器不支持 WebGL,页面会自动改用 Canvas 2D:在 JS 里做同样的旋转和投影,用 fillRect 和 lighter 混合画出点云。效果不如 WebGL 细腻,但至少能看、能转。
另外,prefers-reduced-motion 为真时会自动关闭旋转,避免不适。
六、验证:让物理站得住
只靠「看起来像」是不够的,所以我加了一个自检模式(?selftest=1):在很宽的范围上数值积分 R²r²,检查归一化;再用 3 000 个点抽样,比较抽样 ⟨r⟩ 和理论值。
| 轨道 | 归一化积分 | 抽样 ⟨r⟩ / 理论 ⟨r⟩ | 相对误差 |
|---|---|---|---|
| 1s | 1.0000 | 1.49 / 1.50 | 0.6% |
| 2s | 1.0000 | 6.07 / 6.00 | 1.2% |
| 2p | 1.0000 | 5.01 / 5.00 | 0.2% |
| 3d | 1.0000 | 10.50 / 10.50 | 0.0% |
| 4f | 1.0000 | 17.89 / 18.00 | 0.6% |
| 6h | 1.0000 | 39.06 / 39.00 | 0.2% |
归一化全部是 1.0000,抽样均值误差都在 1.2% 以内(界面里用 30 000 点,误差更小)。
视觉上也逐项核对过:2s 是粉色内层加蓝色外层;3d 是四瓣;4f 是八瓣;6h 的 m = 5 会形成一圈交替相位的高阶花瓣。
七、部署到 WordPress
页面本身是纯静态的,所以部署非常简单:把 index.html、style.css、app.js 三个文件放到站点的 /cloud/ 目录,nginx 直接作为静态文件返回,不经过 PHP。
入口挂在 Argon 主题的两个菜单位置上:
toolbar_menu:桌面端顶部导航;leftbar_menu:左侧卡片和手机抽屉。
两个位置共用同一个「站点导航」菜单,里面有「电子云实验室 /cloud/」和「结构式画板 /structure/」两个自定义链接。用相对路径的好处是,无论站点以后绑定 IP 还是域名,入口都不会失效。
Argon 默认开启了 PJAX。实测点击入口时,静态页找不到主题需要的 #primary 容器,PJAX 会回退成整页加载,因此可以正常打开,不会出现半截页面。
改动菜单前我先备份了数据库,确认没问题后才继续。
八、还能怎么改进
- 轨道切换动画:现在切换是淡入淡出,可以进一步在两组采样点之间插值,让「变形」过程连续可见;
- 更多轨道:把 n 放开到 8,或者加入时间依赖态、叠加态的干涉;
- 采样搬进 Web Worker:避免高 n、大点数时主线程卡顿;
- 离屏渲染 + 色调映射:用 FBO 做真正的 Bloom,而不是多层叠加模拟;
- WebGPU:把 30 000 点提到几十万点,同时保留 60 fps;
- 移动端手势:双指捏合缩放、单指旋转,目前只做了拖拽和滚轮。
结语
这个页面最花时间的部分其实不是写 CSS,而是把物理、采样和渲染三件事对齐:波函数必须正确,采样必须忠实于 |ψ|²,渲染则要在「看得清结构」和「看起来像一团云」之间找平衡。
当 2s 的内外两层、3d 的四瓣、6h 的一圈花瓣随着量子数切换实时出现时,那种「公式真的在屏幕上长出来了」的感觉,是做这个页面最大的乐趣。
