像素融合风格的个人站重构:从视觉系统到滚动叙事
这次个人站重构不是一次单纯的视觉换皮。真正要解决的问题是:页面里已经有大标题、shader、滚动动画、项目预览、技术贴纸、深浅色模式和自定义鼠标,如果每个效果都独立成立,整体仍然会变得混乱。
所以这次我把目标收束成一套更明确的系统:像素边界提供秩序,shader 提供空间与流动,GSAP 负责滚动叙事,排版负责信息权重。所有效果都必须围绕这四个方向工作,不能只是“看起来有技术感”。
本文按实现顺序记录这次重构:如何定义视觉规则、如何让 Three.js cube 参与标题排版、如何用 GSAP 做 pinned scrolltelling、如何处理项目图片的流体预览,以及最后为了可维护性做的取舍。
初始问题
重构前的页面主要有三个问题。
第一个是视觉噪声过高。网格、渐变、阴影、描边、装饰图形和大标题同时出现,单独看每个元素都有理由,但放在一起会互相抢视觉优先级。用户无法快速判断哪个是内容,哪个是装饰。
第二个是 3D 元素和页面内容脱节。hero 里有形体,但它只是被摆在页面里,并没有真正影响标题、滚动或交互关系。这样的 3D 很容易变成“旁边有个模型”,而不是界面的一部分。
第三个是 section 之间缺少连续性。每个区域都像一个独立实验:hero 是一套语言,profile 是另一套语言,projects 又换一套。页面滚动时没有稳定的节奏,用户感知到的是拼贴,而不是叙事。
这决定了重构不能从某个组件开始,而应该先定义系统规则。
视觉系统规则
我最后保留了三种主要视觉语言。
第一种是像素边界。像素感不等于把所有东西做成复古游戏风,而是把边界、按钮、标签、hover、滚动条和图标尺寸变得更硬、更明确。它负责“测量感”和“界面秩序”。
第二种是 shader。shader 只出现在需要空间、遮挡、流动或图像扰动的区域。它不能被滥用到普通文字区域,否则阅读会被持续干扰。
第三种是大排版。标题是页面的核心重量,尤其 hero 的 VISUAL SYSTEMS。任何 3D、mask 或滚动动画都必须围绕标题建立关系,而不是和标题抢主视觉。
对应到实现层面,规则可以概括为:
这些规则不是为了限制效果,而是为了让效果有一致的判断标准。后续所有组件都围绕这个标准调整。
组件边界
交互页面最容易失控的地方是状态来源太多。鼠标、滚动、shader 时间、窗口尺寸、深浅色模式和图片加载都可能改变画面。如果全部写在首页组件里,后面几乎无法维护。
因此我把核心效果拆成几个独立模块:
HeroTitleObject:只负责 Three.js cube 渲染。LiquidImage:只负责项目图片 hover 时的 WebGL 扰动。CustomCursor:只负责鼠标位置和 hover target 状态。- 首页 route:负责 section 编排、GSAP timeline 和数据渲染。
这样的拆分保证每个组件只有一个主要职责。比如 HeroTitleObject 不知道标题内容,标题 mask 也不直接操作 Three.js scene;两者通过 DOM 尺寸关系建立联系。
Hero:让 cube 参与标题排版
hero 的关键不是放一个 3D cube,而是让 cube 和标题发生实时遮挡关系。最终结构分为三层:
- 原始标题层:正常渲染
VISUAL SYSTEMS。 - 反色标题层:完全重叠在原始标题上,只在 cube 覆盖区域可见。
- Three.js cube:视觉上位于标题下方,但几何位置上压住标题。
React 结构简化后是这样:
这里的重点是反色标题仍然是真实 DOM 文本,而不是 canvas 里重新画一遍。这样字体、换行、响应式尺寸都天然和原始标题保持一致。
DOM 与 WebGL 的位置同步
cube 是一个独立 canvas,标题是 DOM。要让反色区域跟随 cube,必须把 cube 在视口中的位置换算到标题坐标系里。
这里没有使用固定 CSS 坐标,因为 cube 的位置会被视口宽度、标题尺寸、滚动位置和 canvas 尺寸共同影响。尤其在宽屏下,静态坐标会让 cube 看起来偏离视觉中心。
同步逻辑用 requestAnimationFrame 执行,同时监听 resize 和 scroll:
这让标题反色区域和 cube 的遮挡关系保持实时对应。
CSS mask 实现反色
反色标题层通过 CSS mask 控制显示区域。mask 的中心点和半径来自上一步写入的 CSS 变量。
这个方案比在 canvas 中做文字反色更稳。文字仍然由浏览器排版,反色只是同一份标题的第二层视觉表现。
需要注意的是,mask 不应该过硬。完全硬边会让遮挡显得像贴图错误;过软又会失去 cube 与标题的接触感。因此这里让 black 0 52% 到 transparent 72% 留出一个较短过渡。
Cube shader 的限制
cube 使用 ShaderMaterial,但 shader 不能破坏立方体结构。之前的问题是材质里会出现黑色圆斑、脏色块或面之间的空隙,这些都会让它看起来像渲染错误。
顶点着色器只做非常小的位移:
位移幅度必须小,否则 BoxGeometry 的面会在视觉上断开。这里的目标是让材质有流动感,而不是让 cube 变形。
片元着色器负责材质层次:
这里保留暖色 band、冷色 stream 和少量 dither,但通过 max(color, vec3(...)) 限制最低亮度,避免黑块污染标题区域。
GSAP 入场与滚动系统
首页动画统一放在 useGSAP 里。这样可以利用 GSAP context 做清理,同时避免动画选择器影响其他页面。
这里有两个必要约束:
- 动画必须限制在当前页面 scope 内。
- 必须处理
prefers-reduced-motion,不能强制所有用户观看复杂滚动动画。
Protocol pinned section
Protocol 区域使用 ScrollTrigger 做 pinned scrolltelling。用户滚动到该区域后,内容固定在视口中,技术贴纸逐步从空间里翻出。
DOM 结构简化如下:
sticker-field 是绝对定位层,不参与普通布局。这样贴纸出现不会改变 section 高度,也不会推开正文。
初始状态不是简单透明,而是带有 3D 姿态:
滚动时间线负责降低正文层存在感,并让贴纸逐个出现:
stagger.from = "center" 是一个重要细节。它让贴纸从中间向外扩散,而不是从左到右机械播放。贴纸翻转只执行一次,完成后保持稳定;否则 section 会一直抖动,影响阅读。
技术贴纸的分布和明暗模式
贴纸使用彩色 SVG,但不能平均铺满屏幕。平均分布会像图标墙,和滚动叙事无关。这里更适合近似正态分布:中心密度更高,边缘更少,并通过不同尺寸和旋转角度制造层次。
位置不使用随机数,而是显式配置:
原因是这个区域属于主视觉,构图需要可控。随机分布适合背景粒子,不适合需要反复调校的内容区域。
暗色模式下,彩色 logo 会遇到可读性问题,尤其是黑色或深色 SVG。处理方式不是统一降低透明度,而是单独调整亮度、饱和度和边缘识别:
这类处理不是装饰优化,而是信息可读性问题。
项目图片的 LiquidImage
项目预览图使用 WebGL canvas 做 hover 扰动。这里不直接把图片变形,而是在图片上方覆盖一个 shader 层。默认状态下图片保持灰度和可读,hover 时 canvas 透明度提高,出现像素化和流体扰动。
组件结构:
shader 中先计算 cover 后的 UV,保证图片不会因为容器比例变化而拉伸:
然后根据鼠标位置做局部扰动:
CSS 中图片和 canvas 分层:
这套方案的关键是默认状态克制,hover 状态增强。如果默认 shader 太强,项目图就会丢失信息价值。
自定义鼠标和 hover 状态
页面使用自定义鼠标时,最容易出现的问题是 hover 状态僵硬。现在的处理是:鼠标进入可交互元素后,四角 marker 过渡到目标元素四角,而不是瞬间跳过去。
这类动效的原则是让鼠标反馈表达“当前元素可交互”,而不是做一个永远抢注意力的装饰层。尤其在文字密集区域,鼠标样式必须足够清楚,但不能遮挡内容。
可维护性取舍
这次重构最后保留了一些明确限制:
- shader 只用于 hero 和项目图,不进入普通正文。
- 网格和渐变只在必要区域出现,避免作为全局装饰。
- 贴纸不随机生成,使用显式配置保证构图可复现。
- GSAP 动画集中在页面作用域内,避免跨页面副作用。
- WebGL 组件必须在卸载时释放资源。
例如 Three.js 资源清理:
如果忽略这一步,开发过程中反复进入页面会留下 WebGL 资源,最终表现为显存占用增加或浏览器警告。
总结
这次重构真正有效的地方,不是某一个 shader 或某一段 GSAP 动画,而是让每个效果都有清晰职责。
cube 不再是孤立模型,而是参与标题遮挡;贴纸不再是图标堆叠,而是滚动叙事的一部分;项目图片不再只是静态截图,而是在 hover 时提供流体反馈;像素语言不再是表层风格,而是贯穿边界、按钮、滚动条和鼠标状态的结构规则。
一个实验风格页面要保持专业,关键不是效果足够多,而是效果之间有关系,并且这些关系能被代码稳定维护。