如切如磋,如琢如磨。——《诗经·卫风·淇奥》
丝是一根线,织起来才成纹。四面体是最省的立体:四个面,不多不少,每个面都能独自撑起一套秩序。这一次我把线交给四个面去织。
于是有了这一页:首屏是一颗纯由光粒聚成的正四面体,四个面各刻着一个字;拖动它转,点其中一面,镜头就推近进那一面;右下角那枚骰子也是粒子聚的,掷下去,四面体会飞快旋起来、慢下来、停住,朝你的那一面自己走进场景。
四个面里,三面是画布,一面是壁纸。技术上说,这是一次把 Canvas2D 的对称绘画引擎和 Three.js 的粒子系统拼在一个页面里的尝试。
二、四个面
四面各刻一字,也是各自的路数。
绢 · 原版 Silk
白底,纤而软的丝。笔不是一条折线,而是一条会自己蠕动的短链——指针只负责往链尾塞新粒子,链本身被一层噪声方向场一直推着走。于是三件事是同一条力学的必然结果:按住不动它仍然在画(力没有停,绳一直在动),拖得快会甩出优雅的长弧,抬笔之后还会自己收一会儿尾。一笔下去同时长出多份——镜像、环绕或向心螺旋,份数可调。
用的是加色混合(lighter)配透明画布(底色来自页面):在不透明白底上画加色,颜色会被一路加到白,等于什么都画不出来。每一遍只有 0.09 的透明度,画面上那层通透感和细密的丝理,是同一片地方被叠了几百遍自己堆出来的——不是叠了一层滤镜。全站没有一处 shadowBlur:几万条线段上开阴影会直接掉帧。
格 · Pixel-Silk
同一套对称、同一套快捷键、同一个调色盘,只换渲染层——线不再是线,而是硬边的方块。于是同一笔在绢面上是丝,在格面上是织物的经纬。切面不改画,只是换一层皮。
霓 · Cyber-Silk
深底,霓虹,虚线描边加横向扫描纹理。多一个故障偏移滑块:拉高它,每一段线会在渲染时随机错开一点,像信号不稳的画面。可选铺一层暗网格做底。
归 · 归境
这一面不能画,只能扰动。整张壁纸被逐像素拆成四万余颗光粒:悬停,粒子从光标处荡开一圈涟漪;点击,一圈冲击波推出去;拖拽,路过的粒子被划亮,拖出一条光的尾迹;长按,粒子开始往外飘、画面一寸寸碎开。
然后松手。所有光粒沿各自的弹性弹回原位,轮廓收得急,背景收得慢——像水面归平。
你可以扰动这些光粒,但它们终将回到自己的位置。
三、怎么玩
| 操作 | 枢纽 | 画布(绢 / 格 / 霓) | 归境 |
|---|---|---|---|
| 拖动 | 旋转四面体 | 作画 | 划出光尾迹 |
| 点击 | 进入所指的面 | — | 放出冲击波 |
| 悬停 | — | — | 荡开涟漪 |
| 长按 | — | — | 画面碎开 |
| 空格 | — | 从头再织(清空) | — |
| Z | — | 退回一笔 | — |
| Esc | — | 回到四面体 | 回到四面体 |
骰子掷出的结果不是随便跳一个页,而是让四面体自己转过去:先加速自转,再阻尼减速,最后落到目标面上给你看一秒,才推近进场景。地址栏会同步成 #d 这样的深链,可以直接把某一面发给别人,浏览器后退键也能在场景之间回退。
音效全部现场合成(WebAudio 的正弦与带通噪声),没有加载任何音频文件:运笔有声,快笔音高上行;掷骰子停稳时四个面各有一个音;枢纽有一个很轻的低频环境音。
四、几个技术上的决定
拾取打在不可见的实体四面体上,不打在粒子点云上。 Points 的射线检测要靠 threshold 猜一个命中半径,边缘会飘、还会误判成相邻的面。做法是另备一个实体四面体专门挨打,intersect.faceIndex 直接给出面号——它压根不加进场景,只是每帧把旋转组的姿态同步过去再 updateMatrixWorld。
加色必须预乘。 gl_FragColor 输出 vec4(rgb × a, a),配 blendFunc(ONE, ONE) 与 premultipliedAlpha: true。用常见的 (SRC_ALPHA, ONE) 亮度会掉一个数量级——画面上就是”怎么调都不亮”。
不开深度测试,靠面法线压暗背面。 加色叠加本来就不需要深度,但四面体是立体的,不处理的话背面的字会透过正面糊成一片。按 dot(面法线, 视线) 把背面压到 13%,体积感就回来了。
四个场景共用一个引擎。 噪声方向场、那条会蠕动的链(createPen / stepPen)、对称变换、历史栈、音效都在 silk-symmetry.ts 里,A/B/C 只是把 kind 传进同一个 paintStroke。作画中的增量绘制和撤销后的全量重绘画的是同一批曲线,否则撤销一次画面就会走样。
不做真路由,做 hash 深链。 真跳转会销毁并重建 WebGL 上下文,枢纽那颗四面体的粒子状态就丢了,“推近再跳转”的连续感也就没了。用 pushState 写 hash,场景停在同一个页面里换,白送浏览器后退键。
归境的模拟放在 CPU。 要”惯性 + 弹性归位”就必须同时持有位置与速度两个状态,无状态的顶点着色器做不到(能凑一个位移函数,但那就不叫弹簧了)。四万颗粒子的定长数组循环,每帧 1–3ms,比写一套 ping-pong 离屏渲染便宜太多。粒子位置数组就是渲染用的那个 position 缓冲,模拟直接往里写,省掉一次逐帧拷贝。
五、踩过的坑
- 白底画布上不能用
lighter。 加色在不透明白底上会把颜色一路加到纯白,什么都画不出来。所以画布本身是透明的,白色来自 CSS 背景。 opacity: 0不挡命中测试。 引导文字淡出后仍然盖在画布上,第一次作画会被它吃掉。- 全站装饰会糊上来。 fullBleed 页面绕开了主布局,站点的樱花、壁纸、顶部渐变、欢迎条会一起压在画面上;压掉它们只能用全局样式,而全局样式必须用
:has()钉住作用域,否则会去打别的页面。 Space不能全局抢。 首屏下面就是正文,抢了空格翻页就没了——快捷键只在对应画布激活时生效。- 底部渐隐会和底色打架。 场景层渐隐到透明之后露出来的是舞台自己的底色,浅色场景在白页上就会看到底下一道黑边。舞台底色得用同一条曲线一起渐隐。
六、素材说明
首屏与四个场景不加载任何图片:四面体、丝纹、烟花、涟漪、声波全是当场算出来的。唯一一张图是归境那面的壁纸——它被逐像素采样成粒子,所以必须在站内(跨域图会被画布污染、直接抛错)、也只需要 960 宽。
换图只改 public/assets/images/silk/wallpaper.webp 一个文件:亮度地板与轮廓阈值都是按分位数现算的,换一张整体偏暗或偏亮的图也不用回来改数字。壁纸里的角色美术版权属原作者,这里仅作个人非商用的展示与采样。
四面皆丝,掷则归位。