载尘望星 · 交互长卷

时间走马灯

寰宇 · 水墨

点上面进寰宇星空 · 汉字从光点里长出罗盘点下面入水墨丹青 · 点纸落字、拖纸走墨河上过一下手,波纹会往两边走

双位面对望—

退回博客

星空、河、罗盘、胶卷、墨,全部当场算出来 · Canvas 2D

日月之行,若出其中;星汉灿烂,若出其里。——曹操《观沧海》

上一版这一页只有一张宣纸、一只墨钟。它是对的,但它只讲了”旧”这一件事。这一版想讲的是旧与旧的对岸:上面是寰宇,下面是水墨,中间那条河既是分界,也是它们唯一的通道。

首屏没有钟。上半是黑宇宙里的一片星尘,一道金色地平环横在中线偏上的位置,是这条河的顶边;下半是一块暖白的宣纸,四周是山水的留白。中间那条银金色的带子会自己流动,手在上面过一下,一圈涟漪会同时往星空和水墨里各推一段波纹 —— 那是这一页唯一一处”两个位面在说话”。

点上面,星空向两侧散开:胶卷环向外旋开,被它包着的那颗光点松开 —— 七十个汉字从光点里一个一个飞出来,沿半径找到自己的位置,组装成一只完整的罗盘时钟,针最后归位。点下面,淡墨从中心晕开,字帖纸片轻扬起来,你手里就有一块空白的宣纸了。再点一次胶卷,汉字又沿原路收回那颗光点 —— 组装和收回是同一条弹簧的两个方向。

二、寰宇星空#

星空不是一张贴图。星星按深度分了三档:远的暗而小、视差弱;近的亮、视差强。指针在画面上移动时,星空跟着偏一点,靠得近的那片星尘会主动让开 —— 不是躲,是被推开之后自己慢慢飘回来(位移逐帧衰减)。

垂落的星轨是最像原图的那一笔:一条条细线从上方沉下来,靠近地平线时被照成暖色,落到河面上再从头开始。

进入寰宇的时候,与其另写一套”散开”动画,不如把每个星的横向位移写成「正比于 focus 乘它到中线的距离」。focus 从 0 走到 1 的过程,本身就是从中间裂开的过程 —— 少一套状态,也少一处会对不上的地方。

三、时间之河#

河道是一条会流的银金流体。它不是”贴一张渐变再把渐变挪一挪”:十几道横着走的短线按相位错开画出来,有的快有的慢,于是”流动”是真的在流。时间越旧,它走得越慢。

河的另一件事是当通道。手从河上过,或者点一下河,就会在那一处攒一个”扰动余波”:一圈扁椭圆的涟漪、以及上下各五道往两边推的波前(往星空那侧是冷白,往宣纸那侧是墨色)。这就是”波纹会传播到上下两个半位面”的全部实现 —— 一个数,两个读者。

四、罗盘时钟#

罗盘全部身家就是一堆汉字。 收拢的时候你看不到它:所有字压成中心一颗微弱的光点,被胶卷球裹着,只从胶片缝里透出一点暖光。进来(或者点胶卷)的时候,光点松开,字一颗一颗从中心沿半径飞出去 —— 外圈先行、内圈随后,针是最后归位的那一件。再点一次胶卷,它们又沿原路飞回去,重新压成那颗光点。

这件事没有为它单独立一套动画:组装度直接从胶卷展开度(reelOpen)派生,收与放是同一条弹簧。组装没完成的时候罗盘不接受抓取 —— 字还在飞,按下去抓到的都是错的位置。

罗盘的读数与现实一致:中心是真实时间,针就是秒针,三圈汉字刻度环按此刻的时辰与月份自动落在该在的那一格上。

三圈字不是”字库渲染”的,也不是我挑着好看的词凑的:

圈内容依据
外圈子丑寅卯辰巳午未申酉戌亥十二地支,子时从二十三点起,两小时一格
中圈夜半 鸡鸣 平旦 日出 食时 隅中 日中 日昃 晡时 日入 黄昏 人定十二时辰的旧名,与地支一一对应
内圈正月 二月 … 冬月 腊月十二月令,按当前月份落格

它们是一笔一笔写出来的。 七十个字的笔顺数据在仓库里(glyphs.ts),环上每个字都是一次真实的落笔:起笔轻入、行笔有浓淡、收笔拉出飞白的枯笔丝、底下还铺一层更宽更淡的洇。运行期不加载任何字体,换台机器字形也不会变。

三种拖拽,三种手感:

  • 拨针:圈数不限。松手之后不是”啪”地跳回此刻,而是缓动着转回去(弹簧 ζ≈0.87,正好不荡)。拖得快的时候针后面会带出两三道残影 —— 这是这一页唯一一处”运动模糊”,也是拨针唯一的动势来源。
  • 转盘:按住盘面拖,整只盘俯仰偏转。松手是低阻尼弹簧(ζ≈0.26),能看见它来回摆四五次才停 —— 这一条是特意调的,太快停住就没有”立体”的重量感了。
  • 转环:按住某一圈汉字拖,松手阻尼收敛到”当前这一格正对盘顶”。环只是整体转,一个字一格,所以字永远不会被拉扁或压变形。

记忆点。 在水墨那半边写完一个字,罗盘外圈就会落一颗会呼吸的金点,位置就是当时拨出去的角度。点它,时间会跳回那一刻并停三秒,再自己走回来。这是两个位面之间第二条看不见的线。

五、胶卷环#

包住罗盘的是一圈胶片。它的模型是三维的圆环:圆心在盘心,半径 r,环面从盘面抬起 α、绕盘轴转 β。把所有 α 从 0 铺到 π 就包成一颗”胶片球”;把所有 α 抬到接近 90° 就变成一圈立起来的胶片笼 —— 展开就是 α 从铺开走到立起,收缩就是走回去。点击胶片本体就切换,一次动画里两种姿态是连续插值的。

四套旋转逻辑是分开的,这是这一节最需要小心的地方:

  1. 胶片随时间慢慢自滚 —— 一个只涨不回头的时间相位。
  2. 拖针时全部快速旋转 —— 一个”跟手相位”,由指针的角速度累加、自己衰减;另加自转。
  3. 转罗盘时跟着罗盘的三维姿态 —— 这一套只读 tilt 值,自己一点都不转。它是四套里最容易写错的一套:一不留神在这里也去动那个跟手相位,胶卷就会在罗盘转的时候自己转起来,看起来”飘”。
  4. 转汉字环时随机一条独立自转 —— 挑一条,它的 β 单独加速,环停稳了它就收工。

前后分层不是靠 z-index 猜的。 每个采样点都算出了它在相机空间的深度,于是每条环天然分成”近的一半”和”远的一半”:先把所有远半画完,再画罗盘,最后画所有近半。上一版”胶卷把罗盘裁掉一块、层序乱”的毛病,根子就是没算这个深度 —— 在这里它是免费的。

点某一格(不是拖),会全屏放大:上下各一条电影黑边,底下五个极简色调(复古/冷调/暖金/黑白/青绿),滤镜只作用在放大后的那一帧上,帧外的画面一点不受影响。再点一下,或者 Esc,退出。

六、水墨丹青#

点下位面进去,是一块白宣纸。这半边没有一个底部按钮 —— 只有左下角一个三选一的小开关(楷书汉字|水墨画面|擦除字迹,也可以按 M 轮换)。开关只管一件事:你接下来点画布的那一下,触发什么。

  • 落字(楷书模式):点画布,随机一个字从点击处起笔,按真实笔顺一笔一笔落下去 —— 起笔轻入、行笔浓淡、收笔飞白、落笔处洇开。写完压深一点、冻住,烘进纸里。
  • 晕画(画面模式):点画布,落点先聚一颗墨,然后一幅水墨从那颗墨里向外晕开 —— 径向的 reveal 带着羽毛边一路走到铺满整幅,像记忆从纸底醒来。同时在场最多两幅;第三幅登场时,最旧的那幅用两三秒慢慢隐去,不是瞬删。
  • 擦墨(擦除模式):点画布,落点附近的墨团与汉字从纸上抠掉,当场重烘一张纸。只擦”字迹”,不动已经晕开的画 —— 画归它自己的上限管。
  • 墨线:按住拖,沿路径连续落墨。它不是画笔 —— 线是一颗一颗墨点”滴”出来的,所以它会自己长大、自己晕开。
  • 泼墨:按住不动约半秒,当场炸开一团大的。等松手再炸手感是错的,所以判定放在了心跳里。

飘在指针附近的四张字帖纸片,点一下展开一行字,再点折起。

墨迹落下就留住。 没有自动消失,没有一键清空 —— 只有总量超了上限(墨点四十、字八个、画两幅),最古老的那批才被挑出来缓慢降低透明度,淡完才从纸上抹掉。字和画退场前不进烘,只剩”当场画、越来越淡”这一条命。

两条工程底线:

墨点、写完的字、浮完的画,全部烘进一张离屏”纸”,每帧只贴一次图。不这么做,纸上攒到几十团墨时每帧要重画几十条七十二点的路径,必掉帧。而全屏晕画的 reveal 也有它自己的离屏:先在一张”晕”画布上 cover 铺图、用径向渐变扣出圆晕,再整张贴上主画布 —— 每帧一次贴图,与半径无关。于是”可以一直玩”和”不会越玩越卡”是同一件事 —— 不是靠限制用户,是靠不让老东西继续花钱。

七、怎么玩#

操作寰宇星空水墨丹青双位面对望
点一下盘中针:拨针;盘面:转盘;汉字圈:转环;胶卷:展卷/收卷;胶卷格:看大图按模式触发:落字 / 晕画 / 擦墨上半:进寰宇;下半:入水墨;河上:起涟漪
拖动同上,全程跟手墨线—
长按约半秒—泼墨—
M—轮换 楷书汉字 / 水墨画面 / 擦除字迹—
滚轮——正文照常滚
Esc退出到双位面退出到双位面收起说明面板
R回到此刻——

画布上的抓取做了误触阈值:按下只记录”抓到了什么”,拖过六像素才真正认领 —— 在针上点一下不会再顺手把罗盘转过去。

触屏能做的,键鼠都有对应手势,没有砍掉任何一样。四张画布指针点不到,所以「展开胶卷」「退出」「回到此刻」与那个模式开关都做成了真的按钮,Tab 走得到、读屏读得出。字帖纸片本身就是按钮。

八、一份状态,五个读者#

这一页有五个会各自动的东西(星空、河、墨、罗盘、胶卷),而它们又彼此耦合:拨针 → 墨变旧 → 星变暖 → 河变慢 → 胶卷滚得更快;写完字 → 罗盘落金点。

所以共享状态只有一份,全部在 dual.ts 里推进,模块只读不写。颜色也不是各模块自己写死的:墨色由「偏移」插值出来,星空色偏、金的成色、河的流速都从同一个数派生。

统一状态机只有七个值,同一时刻只允许一个主动操作:

idle · dragging_pointer · dragging_compass · dragging_text
· rolling_film · ink_writing · frame_preview

所有入口(指针按下、按钮、胶片格)都要先过一道关卡,抢不到就什么都不做。这是”点一下胶片格,结果罗盘也顺手转过去了”这类串台毛病的唯一防线。

场景(surface / cosmos / ink)是另一个维度,不混进这七个值里 —— 拖针的时候场景可能正在从双位面过渡到寰宇,如果把它们塞进同一个枚举,那就必然要出现”边过渡边拖针”这种非法态。场景是一个连续量(focus),用弹簧推着走,于是”星空向两侧散开”和”淡墨从中心晕开”是同一个数的两个方向。

九、性能#

四张画布按”重绘预算”分层,不按视觉分层。 星空几乎每帧都在动;河永远在流;墨痕只在”正在长/正在淡”时才要每帧画,沉下去的贴一次图;罗盘与胶卷必须共用一张画布,因为胶片要前半圈在罗盘前、后半圈在罗盘后,拆成两张就只能靠 z-index 硬凑 —— 而那正是上一版的病根。四者的刷新频率差两个数量级,压在一张画布上就只能按最贵的那个花。

静止时降到 24fps。 物理(时间推进、弹簧、余波)仍按 requestAnimationFrame 走 —— 降的只是”画”,不是”时间”。滚轮能动、秒针会走,只是画面在没人动的时候不必按 60 帧空转。

移动端降级三条:窄屏 / 粗指针 / 核心数不高的设备自动走 lite —— 星数从约六百降到约两百、胶卷从七条降到四条、取消拨针的运动模糊、四张画布的设备像素比压到 1.25。罗盘的三维在移动端仍然是同一套算法(各向异性缩放),只是摆动幅度小一些,不需要为它准备第二套代码。

没有引入 Three.js。 一只平放的圆盘绕盘内两轴转,正投影本来就是一个椭圆,用各向异性缩放表达是精确的;真正需要透视的是胶卷,它们不在盘面上,那一层自己按三维投影算。于是”罗盘的三维感”和”胶卷的三维感”是读同一组 tilt 值得到的 —— 少一个库,也少一处”两边对不上”的可能。

十、踩过的坑#

  • “淡”不能用 alpha,要往底色里和。 胶片是半透明的琥珀,罗盘在它后面。如果胶片用 alpha = 0.5 画,罗盘上那些刻度和汉字会从胶片里透上来,看起来像胶片上印错了东西。做法是把胶片色往它背后的底色里和(lerp(底, 胶片色, keep)),数值上看着一样,背后却不再透。任何半透明层,先问一句”后面有谁会透上来”。
  • 按帧增量积分,不要用绝对角度。 拨针如果每帧直接读 atan2 算绝对角度,针一旦被拨过半圈,角度就折回来了 —— 症状是”再往下拨突然弹回此刻”,而且两圈以上永远够不着。改成累加每帧的角差、跨过 ±π 时自己补一圈。
  • “按名字取东西”取不到时不会报错,只会静默跑默认。 上一版在试验台上踩过:姿态参数被截断成另一个名字、于是整整两轮的效果从来没被真的看过,而它一声不响。所以这一页的挂载点、画布、按钮,凡是取不到的当场抛错,宁可炸得响,不要静默画错。
  • 命中优先级是”精度优先”,不是”谁画在上面谁先”。 针是一条 ±4.9° 的窄楔形、记忆点是 0.075R 的小点,而胶片格是 ~18px 的方块、还随自滚不停换位置。让胶片格先判,它滚过针上的那几秒里”按针”就会变成”打开一张全屏照片”。所以次序是:针 → 记忆点 → 胶片格 → 汉字圈 → 盘面 → 胶卷。而且判定一律在盘面局部坐标里做 —— 把屏幕坐标用 tilt 与自转的逆矩阵变回去,否则盘一歪,“看着在针上”点下去就变成了转盘。
  • 画布抓取要过误触阈值。 按下只记录”抓到了什么”(针/环/盘),拖过六像素才真正认领拖拽。没有这个,“在针上点一下”也会短暂占住状态机,把同一时刻别的操作全挡掉。
  • 组装动画期间,命中要关门。 汉字还在从中心往外飞的时候,针和环的”位置”全是中间态,这时候抓到哪儿都是错的。组装度没到 0.8,罗盘的命中判定直接返回空 —— 宁可这几百毫秒点不动,不能让”看着在针上”抓到别处。
  • 烘进离屏的位图,贴的时候必须按”现在的河”裁。 那张离屏”纸”是整张画布大小的位图,里面的内容只在”当时”的纸面区域画过。进了寰宇(河涨到 0.86h)不裁的话,烘进去的墨和画会整片压到星空上 —— 局部小图时这条看不见,全屏水墨画让它变成”满屏穿帮”。
  • 秒和毫秒不是一回事,而它们看起来一样。 晕染写的是 REVEAL = 2.4(秒),而共享态里的 now 是 Date.now()(毫秒):两边直接相减,等于把 2.4 秒当成 2.4 毫秒用 —— 晕染一帧就铺满、淡出一帧就没了、写字一帧就写完,全都”看起来像没做动画”。现在这个模块内部一律先 now * 0.001 换算,常量继续写成年月可读的秒。
  • 数据是 y 向上还是向下,画的人必须知道。 笔顺骨架烘出来是 y 向下(与 canvas 同向),而描绘那一侧一度按”数据 y 向上”在做 cy - y·scale —— 于是每一个字都上下颠倒。小字(罗盘刻度环,二十来像素)看不出来,一笔三百二十像素的大字倒着,一眼就穿帮。方向和画法只能有一套,翻错了整页都倒着。
  • Astro 的作用域样式管不到”运行期建出来”的节点。 字帖纸片是 JS createElement 造的,而 scoped style 会给选择器钉一个 [data-astro-cid-*],新节点身上没有这个属性 —— 写 .dr-slip { … } 一条都不匹配,纸片退化成没底、没尺寸、没竖排的裸按钮。运行期节点一律 :global(),作用域锚点留在模板里那个容器上(那排全屏色调按钮也是同一个坑)。
  • 长按判定不能等松手。 泼墨如果写成”按住超过半秒、松手时炸开”,用户按着的时候画面毫无反应,会以为没生效。判定放进心跳里,够半秒就当场炸。
  • 半透明的东西叠在一起会互相吃掉对比度。 渗墨的三层柔边如果每层都老老实实叠,边缘会糊成一片灰晕;所以三层分别用 0.05 / 0.1 / 0.2 的浓度,最外那层几乎看不见,只负责让边界”不那么像剪出来的”。
  • 对比度是算出来的,不是挑出来的。 次要文字色定在 #6f685b,压在宣纸 #f2ece0 上是 4.72:1,刚好过正文那条 4.5:1 的线。这种数不能靠眼睛,要靠算 —— 在纸上”看着柔和”的颜色,往往就是没达标的那种。

十一、素材说明#

这一页的画面不加载任何位图:星空、河、罗盘、胶卷、宣纸纤维、写出来的字,全部是四张叠在一起的画布当场算出来的。

两处例外,都是内容而不是画面:

  • 浮现出来的十四幅水墨画(public/assets/images/dual/ink/)。原图右下角带平台水印,统一从右下角切掉一截(保留 95.2% 宽 × 86.4% 高)再压成 webp —— 切的是水印,不是画面重点,而且不做修补:抹掉比裁掉更容易留下一处不该有的”干净”。
  • 胶卷里的电影格目前是占位,暂时借用上面那十四幅画。换成真照片时只需要改 data.ts 里的一个数组,帧的尺寸、齿孔、纹理、前后层序都不用碰。

一个字的笔顺数据烘在仓库里(glyphs.ts,七十个字),来源是 hanzi-writer 的字形数据,只在离线烘制时下载过一次,运行时既不联网也不依赖任何字体。字形的来源是公开的笔顺数据,这里只作个人非商用的展示。


星在上面,墨在下面,中间那条河一直在流。

WELCOME欢迎来到载尘望星

很高兴在这里遇见你。