◈ 夜宵吧

给站点做了一个入口页

给站点做了一个入口页:一句话,加一段会随鼠标扰动的长城,粒子拼的。

听起来是装饰,实际推翻了四版——第一版全黑什么都看不见,第二版文字压在城墙上,第三版手机上一划就糊成漩涡,第四版塔被排斥力吹歪。每一次都不是调参能解决的,得换方法。

这篇按版本顺序记录四次失败和各自的根因,涉及画布缓冲、设备像素比、子步积分,以及「哪些元素不该参与扰动」。想用 Canvas 做类似效果的,前四版可以直接跳过。

第一版:什么都不显示

第一版的想法很直接:粒子从四面八方飞过来,拼成长城。

结果截图出来,屏幕上什么也没有。

不是黑屏,是那种"你知道它该在那儿,但你看不见"的状态。零星几颗暗点,像屏幕脏了。

排查比写代码本身长得多。先怀疑粒子没收敛,把物理跑了几千帧;再怀疑无头浏览器的动画帧不推进;最后一层层剥到坐标换算。

问题在这里:画布的尺寸乘了设备像素比,而逐像素写入的缓冲区没有乘。

于是"能显示的那一半"和"显示不了的那一半"被一刀切开。在放大比例较高的屏幕上,右边和下边直接少了一大块。

这类错误最麻烦的地方在于:它不会报错。屏幕上就是少了一半东西,而你以为那是设计。

顺带记一个教训:putImageData 不受变换矩阵影响。它直接写设备像素,你在它之前设的 setTransform 对它完全无效。

第二版:构图全乱

修完之后长城立起来了,但新问题立刻浮上来:画面太满,文字压在山脊上,烽火台从标题中间穿过去。

开始调构图:把长城压到画面下三分之一当地平线,让出上方的"天空"给文字;把四座烽火台从中央挪开,给按钮留一条通道;把竖向屏幕的塔身比例调粗,否则手机上全是细长的杆。

这些都得靠算。用设备像素比和视口尺寸推导出每个元素应该在的位置,再验证文字区和长城区不重叠。

第三版:手机上一划就糊

鼠标划过城墙本该激起涟漪,结果在手机上划一下,整段墙体被搅成一团漩涡。

查下来是排斥力乘了时间步长,而时间步长在子步里又被衰减了一遍——设备帧率越高、子步越多,扰动就被放大得越厉害。

删掉那一个乘号就好了。这是那种改一行、效果天差地别的 bug。

第四版:塔被吹歪了

最后一个意外在塔身上。烽火台是刚性建筑,但排斥力是逐颗粒子施加的,于是塔被"剪切"成了斜柱。

试过给整座塔一个共同锚点,让它整体平移。结果更糟——共享状态在遍历中被反复覆写,半边屏幕糊成一团。

真正的解法很朴素:让塔不参与扰动。塔该是固若金汤的,动的是墙。

最后

回头看,这个页面全部代码加起来不到一千行,但绝大部分时间花在三件事上:发现哪里不对、确认那是真的不对、确认改完之后真的对了。

中间有很多次,我以为自己看到的是设计,其实那是 bug。

最后一点体会:像素级的视觉问题,别靠眼睛判断。截一张图,数一数有多少个像素亮着、平均亮度是多少。

数字不会说谎,眼睛会。

后来又加了白天

星空版上线几天后,白天点开也是满天星星,很怪。于是做了个跟随时间的主题:6 点到 18 点白天,其余黑夜,也可以手动切。

换一套颜色,不是加一层滤镜

第一版想省事:给白色主体加一层滤波。结果亮粒子直接消失——城墙是用光的多少画出来的,压暗就等于抹掉。

只能换调色板。夜里城墙是赭金色,白天改成深墨绿——第一次试的深赭色在浅蓝天上几乎看不见,因为两者亮度太接近。同一个颜色在深底和浅底上不是同一个颜色。

首屏不能闪

主题要在浏览器画第一帧之前就定下来,否则会先看到黑底、再跳成白底。

做法是一段同步脚本,放在 <head> 里最靠前的位置。后来收紧内容安全策略、禁掉内联脚本时,这段差点白屏——策略一上,它就被拦了。

所以它必须是外部文件、同步加载:

<script src="/theme-boot.js"></script>

切换时清画布

切到白天后,月亮会残留好几秒才消失。因为画布是逐帧用透明色叠上去的,旧像素靠"每帧衰减一点"慢慢淡出。

正确的做法是切换时直接把缓冲区清零:

ctx.clearRect(0, 0, W, H);
buf.fill(0);

一行代码,省掉几秒钟的视觉垃圾。

一个静默覆盖

云和极光用了同一个动画名 drift。同一个 CSS 里同名的 @keyframes,后写的会覆盖先写的——于是云在动,极光不动,控制台一句警告都没有。

改了名就好。这类问题只能靠"这条动画没生效"这个现象反推,没有任何报错可看。

代价

它是一个独立单页,和主应用不共享任何东西。 样式、组件、构建流程都用不上——好处是它只需要一个 canvas 和几行 CSS,坏处是所有东西都得重写一遍。

逐像素写入靠 CPU。 1440×900 的画布每帧写一百三十万个像素,桌面机上没问题,低端手机会掉帧。所以粒子数按屏幕尺寸给上限,最差的设备上会稀一些。

没有降级方案。 关掉 JavaScript 就是一片黑,浏览器不支持 canvas 同理。这页的性质决定了它只能「有或没有」,做不了渐进增强——放一张静态图当兜底其实更好,没做。