月亮是两段圆弧
入口页背景是逐像素画出来的:一帧一帧往缓冲区写 RGBA,最后一次 putImageData 上屏。
月亮就画在这块缓冲区里。麻烦不在画圆,在「暗的那一半」是什么形状——这一块我改了四版才对,中间交了两次「看起来像月亮但其实是错的」的作业。
这篇把四次尝试的失败原因逐个拆开,包括最后一圈查了两天才找到的黑边。如果你也要用像素画月相,前三版可以直接跳过。
第一版:一条直线
光从右上打过来,于是把每个像素投影到光的方向上:
const along = dx * sunX + dy * sunY; // dx,dy 是相对月心的单位向量
const lit = along > -0.2; // 超过阈值算受光
结果是一条直线把圆切开,像球上盖了块斜的白布。
这不是实现错误,是方法的必然结果:一维线性投影只能切出直线。
第二版:椭圆
改成在「光方向 / 垂直方向」的坐标系里用椭圆分界:
const u = dx * sx + dy * sy; // 沿光方向
const v = dx * px + dy * py; // 垂直光方向
const boundary = a * Math.sqrt(Math.max(0, 1 - u * u));
const lit = Math.abs(v) < boundary;
有弧度了,但形状不对:看起来是一个圆里套了个歪的橄榄形,亮的边缘是蛋形,不像月牙。
原因是两条边界的曲率不匹配。外弧是正圆(半径 1),内弧是这个椭圆(长轴 1、短轴 a),两者在视觉上凑不成同一个球的两段轮廓。真实月相的明暗界线确实是椭圆弧,但这个椭圆的短轴并不等于我拍的那个 a——它由月相角决定,光靠调参凑不出来。
第三版:两个圆
真实月相的形状是两段圆弧围成的:
- 外弧是月球轮廓,半径固定
- 内弧是明暗界线,是另一个半径的圆
于是不再画「一个圆减一块」,而是画「一个圆与另一个圆的交集」:
// 内圆随月相缩放
const r2 = 1.55 - 0.55 * phase;
// 内圆圆心沿光方向偏移:满月时偏移到远处(交集≈全盘),
// 新月时偏到近处(交集≈空)
const off = (1 + r2) - 2 * r2 * phase;
// 当前像素是否落在内圆里
const qd = Math.hypot(dx - sx * off, dy - sy * off);
const lit = smooth(r2 - qd, 0.10);
phase = 0 时内圆半径 1.55、偏移 2.55,完全盖住月球;phase = 1 时半径 1、偏移 0,交集为空。中间任意值给出任意粗细的月牙。
这一步有个具体的 bug:第一版我写的是 const cx2 = dx - sx * off,把当前像素坐标当成了圆心。这样算出来的圆跟着像素一起移动,交集永远是空。
圆心必须是相对月心的固定偏移,不能带像素坐标。
那条黑边
形状对了之后,月亮外面一直有一圈细黑线。这件事花的时间比画月亮本身长。
第一反应是颜色写错了——把边缘颜色往夜色里压,没用;把颜色和透明度一起压,黑线更明显。
真正的原因是透明度的过渡不连续。沿过月心的一条水平线取 60 个采样点,打印相对半径、alpha 和亮度:
r=1.095 | a=244 lum=213
r=1.115 | a= 15 lum=213 ← alpha 掉到 15
r=1.135 | a= 32 lum=249 ← 又跳回 32,而且更亮
月盘的 alpha 收到 0,紧挨着的月晕又从 32 起步,中间夹了一个 15。肉眼看到的「一圈暗」,本质是这条 V 形曲线。
修法是把月盘和月晕的 alpha 并成一条单调曲线:
if (d <= 0.92) a = 0.96; // 月面,实心
else if (d <= 1) a = mix(0.96, 0.42, smooth(d)); // 临边渐暗
else a = 0.42 * Math.exp(-5.5 * (d - 1)); // 月晕
从头到尾没有台阶。改完再扫同一条线:244 → 231 → 192 → 147 → 113 → 102 → 93 → 84 → 76 → 62 → 56,全程递减,黑线消失。
最初写的是「月盘自己收边、月晕自己起边」两段独立逻辑。它更灵活,但也是那条线的全部原因——两个各自合理的曲线,在交界处不一定接得上。
另外两处
颜色。 第一版的受光面是暖米白(255, 243, 206),在深色背景上像块水泥。换成偏亮的黄(255, 214, 92)才出来「这是个光源」的感觉。暗面用深蓝灰(38, 42, 56),不能太亮——第一版给够了亮度,结果月亮看起来像个蓝色实心球,比受光面还抢眼。
逐像素缓冲要注意的。 putImageData 忽略画布的变换矩阵,直接写设备像素。缓冲区尺寸必须乘上设备像素比,否则在高分屏上会有一半粒子被丢弃——这个 bug 的早期表现是「画布右下半部分什么都没有」,而循环里没有报错,因为那些粒子是被边界判断静默跳过的。
数值汇总
| 参数 | 值 | 作用 |
|---|---|---|
| 明暗界线 | edgeU = cosφ·√(1−v²) |
投影椭圆,不再是猜的圆 |
cosφ |
1 − 2×phase | 0 新月 → −1 满月 |
| 受光色 | (255, 214, 92) | 亮黄 |
| 暗面色 | (38, 42, 56) | 深蓝灰 |
| 月面 alpha | 0.96 | 实心不透明 |
| 临边压暗 | 0.14 | 原 0.20,满月时太显眼 |
| 渐暗起点 | d = 0.95 | 原 0.92,边缘留一圈纱 |
| 月晕起点 | d = 1.0,α = 0.42 | 衔接点 |
| 月晕衰减 | exp(−5.5t) | 约 1.1 倍半径外归零 |
后来:每天换一张脸
上面这一版只解决"月牙长什么样"。真正的问题是:它得跟今天的月亮对上。
相位是个平均值
用朔望月 29.530588853 天,参考点是 2000-01-06 18:14 UTC 的新月。月龄 = 距那个点的时间对周期取余:
const age = ((Date.now() - NEW_MOON_MS) % SYNODIC_MS) / 86400000;
const f = age / 29.530588853;
const lit = (1 - Math.cos(2 * Math.PI * f)) / 2; // 照亮比例
误差来自"朔望月不是常数"——真实轨道是椭圆,还有摄动。拿它算 2026-10-20 深夜:月龄 9.25 天,照亮 69.3%,盈凸月。实际也是盈凸月。按平均周期算满月会落在 10 月 25 日中午前后,误差约 ±14 小时。
对"首页挂个月亮"这件事,这个精度绰绰有余。
亮面朝哪边
北半球看:上弦月在右边亮,下弦月在左边亮。所以太阳方向向量要随盈方翻转:
const sx = waxing ? 0.66 : -0.66, sy = -0.75;
这是唯一能一眼看出对不对的判据。 如果某天打开页面发现残月亮在右边,那一定是相位符号写反了。
界线必须是投影椭圆
我先试过"两个圆相交"的几何法:一个圆是月盘,另一个按相位改变半径和位置,交集就是月牙。
画出来发现上弦月有六成亮——应该是正好一半。因为两个圆是硬拼的,它们的交点落在哪,不由相位决定。
正确做法是把问题投影到一维。设像素在月盘内的坐标是 (dx, dy)(单位圆),太阳方向 (sx, sy):
const u = dx * sx + dy * sy; // 沿太阳方向的距离
const v = dx * -sy + dy * sx; // 垂直方向
const edgeU = cosPhi * Math.sqrt(Math.max(0, 1 - v * v));
const litT = (u - edgeU) / 0.10; // 0.10 是羽化带宽
关键在于 √(1−v²):离太阳轴越远的地方,分界线越往内收——这正是一个圆的投影。cosPhi = 0 时它退化成竖直的直线(上弦月),cosPhi = ±1 时收到左右边缘(新月/满月)。
第一版写的是线性投影(u > 某个常数),那只会在圆里斜着切一刀,像盖了块白布。
新月不该有光环
最暗的那晚,月亮成一个深色圆盘,外面一圈暖黄的晕——看起来像日食,不像月亮。
因为月晕颜色是固定的暖黄。修法是让它跟着照亮比例变暗:
const hw = Math.min(1, phase / 0.55);
cr = 40 + (214 - 40) * hw; // 新月时接近暗面那种灰蓝
这样新月是"几乎看不见的暗影",十三天后才是"一圈发光的东西"。
怎么验
改系统时间不现实。用调试协议里的 Page.addScriptToEvaluateOnNewDocument,在页面脚本跑起来前覆盖 Date.now:
const _n = Date.now;
Date.now = () => _n() + offsetMs; // 偏移到想要的相位
配合把主题锁成夜间,然后对五个相位各截一张:新月(全暗)、娥眉月(右细)、上弦(正好一半)、满月(全亮)、残月(左细)。
五张图摆在一起看,比任何断言都可靠。这些都是测试期的临时注入,代码里没留任何调试开关。
回头看
四次尝试,每次的错法都不一样:
第一版错在判据。 线性投影只能产出直线,而月相的分界线是弧。这不是实现问题,是方法问题——工具用错,调多少参数都没用。
第二版错在形状不匹配。 外缘是正圆、内缘是椭圆,长短轴还是我拍的。两条边界的曲率对不上,看起来就是「圆里套了颗蛋」。
第三版错在数值不连续。 形状终于对了,但月亮外面套着一圈黑线。查了两天才发现不是颜色写错,而是月盘和光晕各自收边,中间夹出了一段下凹的 alpha 曲线。
三件事的共性是:肉眼能看出「不对」,看不出「哪里不对」。 真正定位问题的是把一条穿过月心的水平线取六十个采样点,打印相对半径、alpha 和亮度——那条曲线画出来,黑线为什么存在就不再需要争辩。
数值不会说谎,眼睛会。