◈ 夜宵吧

月亮是两段圆弧

入口页背景是逐像素画出来的:一帧一帧往缓冲区写 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 和亮度——那条曲线画出来,黑线为什么存在就不再需要争辩。

数值不会说谎,眼睛会。