给夜空缀上星星
入口页原来只有长城。城墙横贯底部,四座烽火台立着,上方一片深色的空——那片空本来是留给铭牌文字的,但文字只占中间一小块,四周什么都没有,看久了像没填满。
加星空看着简单,实际有三个约束:不能再多一层画布、不能用粒子表示星星、在 2 倍像素比的屏幕上要让一颗星看起来像星而不是像灰尘。
这篇讲这三个约束怎么把方案逼到一个具体位置,以及最后落地的不到一百行代码。
为什么不是再加一层画布
最直观的做法是新建一个 canvas 铺在背景上,专画星星。这样最省事,代价是多一个图层、多一次合成,而且两个画布的层级、缩放、视口变化都要各自维护一遍。 更麻烦的是层级顺序。星星要在文字后面、纯色底前面,就得精确控制堆叠顺序。项目里已经有四层遮罩了,再插一层进去,迟早会因为顺序问题出一堆意外。 所以最后选了另一条路:把星星写进长城用的那张逐像素缓冲。
共用一张缓冲
长城本身就是用 putImageData 整块刷出来的。每帧的流程大致是:
淡出上一帧 → 写入粒子 → putImageData → 画连线
那么只要在「淡出之后、写入粒子之前」插一步画星星,星星就自然出现在长城的后面。不用新图层,不用管堆叠顺序,也不多一次绘制调用。 顺序上有个要求:星星必须在淡出之后画。否则它会跟着粒子一起被拖尾衰减,每帧减一点,很快就看不见了。
星星不能是粒子
这一步花的时间比想象中多。 一开始想当然地复用粒子系统:两千多颗星,每颗有位置、有速度,套用同一套弹簧积分。跑起来立刻发现问题—— 长城是一个整体向外扩张的轮廓,两千多颗粒子朝各自的落点聚,视觉上很自然;星星是散布的,它们本来就该待着不动,一旦用弹簧去拉,就会以中心为起点形成发射状收拢,看起来像被吸尘器吸向画面中心。 而且物理开销是实打实的。四千多颗粒子已经占满了预算,再加两千颗星,帧率会掉到没法看。 所以星星是纯数据:每颗只存位置、亮度、色相、闪烁相位和视差深度。不参与物理,不进积分循环,只在渲染时被读一次。
亮度分档
第一版的星星几乎看不见。 原因很直白——我按 0.18 到 0.40 的透明度生成,在接近黑色的背景上,视觉上就是一片模糊的灰点。而且暗星占七成,整体就塌下去了。 调成三档:
| 档位 | 占比 | 透明度 |
|---|---|---|
| 暗星 | 80% | 0.34 ~ 0.56 |
| 中亮 | 17% | 0.56 ~ 0.76 |
| 亮星 | 3% | 0.80 ~ 1.00 |
| 关键在于下限。暗星占绝大多数,它的下限决定了整个星空的底色,抬到 0.34 之后,画面立刻有了颗粒感。 |
一个像素还是两个像素
设备像素比大于 1 的屏幕上,会出现一个很隐蔽的问题:星星看起来像灰尘,而不是星。 长城是成片的,少掉一半像素只会让墙面薄一点,看不出来。星星是孤点,单个设备像素在高 DPR 屏上只相当于半个 CSS 像素,视觉上就是一个极小的点。 原因和之前修过的那个 bug 同源:putImageData 不走变换矩阵,缓冲是按设备像素算的,而星星位置要先乘回设备像素。 补法是 DPR 大于 1 时,在星核的右下各补一个像素,透明度六成。这样一个星在屏幕上确实占到约 2×2 个设备像素,才有看得见的体积。
视差
鼠标移动时星星会轻微让开,用深度系数控制幅度:远处的星几乎不动,近处的星偏移明显。恒星其实没有可观测的视差,但这是通用的视觉语言,看的人不会较真。 水平方向做了环绕:星星移出左边界就从右边补回来,这样快速拖动鼠标时画面边缘不会突然变空。
结果
星星加进去之后,那片空不再是没填满,而是夜空。长城的金黄和星空的冷白形成冷暖对比,文字浮在两者之间。 算下来每百万像素六百多颗,一个常见笔记本屏幕大约七百多颗。这个密度是试出来的——太少像噪点,太多又抢了长城的戏。 回头看,这一步没有引入任何新依赖,只多了不到一百行代码。因为选在了最合适的地方插进去,而不是在它旁边又盖了一层。
代价与没做的
星星是死的。 它们每帧重绘,但位置只在初始化时算好,除了跟随鼠标的轻微视差之外不动。真实夜空里星星在缓慢移动——做这件事只要加一个慢速漂移相位,成本不高,但会让盯着看的人觉得画面在爬。没做。
密度是手调出来的。 每百万像素六百多颗,试了几轮:太少像屏幕脏了,太多又抢长城的戏。它和屏幕比例绑定,换宽高比就要重新看。
没有换肤。 星色定在三档冷白到暖白,配金色城墙。哪天换主色,这套配色得整组重挑。