Blog

2026-04-30 / Design System

一次完整的个人站技术重构记录:像素边界、Three.js shader cube、标题 mask、GSAP pinned section 和 WebGL 图片预览如何组合成稳定的视觉系统。

像素融合风格的个人站重构:从视觉系统到滚动叙事

这次个人站重构不是一次单纯的视觉换皮。真正要解决的问题是:页面里已经有大标题、shader、滚动动画、项目预览、技术贴纸、深浅色模式和自定义鼠标,如果每个效果都独立成立,整体仍然会变得混乱。

所以这次我把目标收束成一套更明确的系统:像素边界提供秩序,shader 提供空间与流动,GSAP 负责滚动叙事,排版负责信息权重。所有效果都必须围绕这四个方向工作,不能只是“看起来有技术感”。

本文按实现顺序记录这次重构:如何定义视觉规则、如何让 Three.js cube 参与标题排版、如何用 GSAP 做 pinned scrolltelling、如何处理项目图片的流体预览,以及最后为了可维护性做的取舍。

初始问题

重构前的页面主要有三个问题。

第一个是视觉噪声过高。网格、渐变、阴影、描边、装饰图形和大标题同时出现,单独看每个元素都有理由,但放在一起会互相抢视觉优先级。用户无法快速判断哪个是内容,哪个是装饰。

第二个是 3D 元素和页面内容脱节。hero 里有形体,但它只是被摆在页面里,并没有真正影响标题、滚动或交互关系。这样的 3D 很容易变成“旁边有个模型”,而不是界面的一部分。

第三个是 section 之间缺少连续性。每个区域都像一个独立实验:hero 是一套语言,profile 是另一套语言,projects 又换一套。页面滚动时没有稳定的节奏,用户感知到的是拼贴,而不是叙事。

这决定了重构不能从某个组件开始,而应该先定义系统规则。

视觉系统规则

我最后保留了三种主要视觉语言。

第一种是像素边界。像素感不等于把所有东西做成复古游戏风,而是把边界、按钮、标签、hover、滚动条和图标尺寸变得更硬、更明确。它负责“测量感”和“界面秩序”。

第二种是 shader。shader 只出现在需要空间、遮挡、流动或图像扰动的区域。它不能被滥用到普通文字区域,否则阅读会被持续干扰。

第三种是大排版。标题是页面的核心重量,尤其 hero 的 VISUAL SYSTEMS。任何 3D、mask 或滚动动画都必须围绕标题建立关系,而不是和标题抢主视觉。

对应到实现层面,规则可以概括为:

txt
Pixel boundary  -> CSS border / corner / mono labels / cursor state
Shader motion   -> Three.js cube / WebGL liquid image preview
Editorial type  -> display font / large heading / strict line-height
Scrolltelling   -> GSAP timeline / ScrollTrigger / pinned section

这些规则不是为了限制效果,而是为了让效果有一致的判断标准。后续所有组件都围绕这个标准调整。

组件边界

交互页面最容易失控的地方是状态来源太多。鼠标、滚动、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 结构简化后是这样:

tsx
function HeroSection() {
  const cubeRef = useRef<HTMLDivElement>(null);
  const titleMaskRef = useRef<HTMLDivElement>(null);
 
  useCubeTitleMask(titleMaskRef, cubeRef);
 
  return (
    <section id="hero">
      <HeroTitleObject objectRef={cubeRef} />
 
      <div ref={titleMaskRef}>
        <HeroHeadline />
        <HeroHeadline
          animated={false}
          aria-hidden
          className="hero-title-reaction"
        />
      </div>
    </section>
  );
}

这里的重点是反色标题仍然是真实 DOM 文本,而不是 canvas 里重新画一遍。这样字体、换行、响应式尺寸都天然和原始标题保持一致。

DOM 与 WebGL 的位置同步

cube 是一个独立 canvas,标题是 DOM。要让反色区域跟随 cube,必须把 cube 在视口中的位置换算到标题坐标系里。

ts
const cubeRect = cube.getBoundingClientRect();
const titleRect = title.getBoundingClientRect();
 
const centerX = cubeRect.left + cubeRect.width / 2 - titleRect.left;
const centerY = cubeRect.top + cubeRect.height / 2 - titleRect.top;
const radiusX = cubeRect.width * 0.36;
const radiusY = cubeRect.height * 0.43;
 
title.style.setProperty("--hero-mask-x", `${centerX}px`);
title.style.setProperty("--hero-mask-y", `${centerY}px`);
title.style.setProperty("--hero-mask-rx", `${radiusX}px`);
title.style.setProperty("--hero-mask-ry", `${radiusY}px`);

这里没有使用固定 CSS 坐标,因为 cube 的位置会被视口宽度、标题尺寸、滚动位置和 canvas 尺寸共同影响。尤其在宽屏下,静态坐标会让 cube 看起来偏离视觉中心。

同步逻辑用 requestAnimationFrame 执行,同时监听 resize 和 scroll:

ts
const tick = () => {
  syncMask();
  frame = window.requestAnimationFrame(tick);
};
 
tick();
window.addEventListener("resize", syncMask);
window.addEventListener("scroll", syncMask, { passive: true });

这让标题反色区域和 cube 的遮挡关系保持实时对应。

CSS mask 实现反色

反色标题层通过 CSS mask 控制显示区域。mask 的中心点和半径来自上一步写入的 CSS 变量。

css
.hero-title-reaction {
  color: var(--background);
  mask-image: radial-gradient(
    ellipse var(--hero-mask-rx, 11rem) var(--hero-mask-ry, 14rem) at
      var(--hero-mask-x, 83%) var(--hero-mask-y, 50%),
    black 0 52%,
    transparent 72%
  );
}

这个方案比在 canvas 中做文字反色更稳。文字仍然由浏览器排版,反色只是同一份标题的第二层视觉表现。

需要注意的是,mask 不应该过硬。完全硬边会让遮挡显得像贴图错误;过软又会失去 cube 与标题的接触感。因此这里让 black 0 52%transparent 72% 留出一个较短过渡。

Cube shader 的限制

cube 使用 ShaderMaterial,但 shader 不能破坏立方体结构。之前的问题是材质里会出现黑色圆斑、脏色块或面之间的空隙,这些都会让它看起来像渲染错误。

顶点着色器只做非常小的位移:

glsl
vec3 p = position;
float flow = sin(p.y * 4.0 + uTime * 1.2);
flow += sin(p.z * 5.5 - uTime * 0.9);
flow += cos((p.x + p.y) * 3.2 + uScroll * 2.4);
flow /= 3.0;
 
p += normalize(position + vec3(0.001)) * (flow * 0.006);

位移幅度必须小,否则 BoxGeometry 的面会在视觉上断开。这里的目标是让材质有流动感,而不是让 cube 变形。

片元着色器负责材质层次:

glsl
vec3 color = mix(ink, pearl, smoothstep(0.08, 0.95, light) * 0.78);
color = mix(color, ember, band * 0.11 + vFault * 0.13);
color = mix(color, ice, stream * 0.18);
color = max(color, vec3(0.32, 0.32, 0.3));

这里保留暖色 band、冷色 stream 和少量 dither,但通过 max(color, vec3(...)) 限制最低亮度,避免黑块污染标题区域。

GSAP 入场与滚动系统

首页动画统一放在 useGSAP 里。这样可以利用 GSAP context 做清理,同时避免动画选择器影响其他页面。

ts
useGSAP(
  () => {
    const reduceMotion = window.matchMedia(
      "(prefers-reduced-motion: reduce)"
    ).matches;
 
    const intro = gsap.timeline({ defaults: { ease: "power4.out" } });
    intro.to(".intro-line", {
      yPercent: 0,
      opacity: 1,
      skewY: 0,
      duration: reduceMotion ? 0.2 : 1.4,
      stagger: 0.15,
    });
  },
  { scope: container }
);

这里有两个必要约束:

  • 动画必须限制在当前页面 scope 内。
  • 必须处理 prefers-reduced-motion,不能强制所有用户观看复杂滚动动画。

Protocol pinned section

Protocol 区域使用 ScrollTrigger 做 pinned scrolltelling。用户滚动到该区域后,内容固定在视口中,技术贴纸逐步从空间里翻出。

DOM 结构简化如下:

tsx
<div className="protocol-pin">
  <div className="relative min-h-[68vh] overflow-hidden">
    <div className="protocol-copy">...</div>
    <div className="sticker-field">
      {TECH_LOGOS.map((logo) => (
        <TechLogo key={logo.label} logo={logo} />
      ))}
    </div>
  </div>
</div>

sticker-field 是绝对定位层,不参与普通布局。这样贴纸出现不会改变 section 高度,也不会推开正文。

初始状态不是简单透明,而是带有 3D 姿态:

ts
gsap.set(".tech-sticker", {
  filter: "blur(10px) saturate(1.35)",
  opacity: 0,
  rotateX: -82,
  rotateY: 54,
  rotateZ: -24,
  scale: 0.16,
  transformPerspective: 900,
  y: 140,
  z: -160,
});

滚动时间线负责降低正文层存在感,并让贴纸逐个出现:

ts
const protocolTimeline = gsap.timeline({
  scrollTrigger: {
    trigger: ".protocol-pin",
    start: "top top",
    end: "+=1700",
    pin: true,
    scrub: 0.8,
  },
});
 
protocolTimeline
  .to(".protocol-copy", {
    opacity: 0.24,
    scale: 0.96,
    duration: 0.8,
    ease: "none",
  })
  .to(
    ".tech-sticker",
    {
      filter: "blur(0px) saturate(1)",
      opacity: 1,
      rotateX: 0,
      rotateY: 0,
      rotateZ: 0,
      scale: 1,
      y: 0,
      z: 0,
      stagger: { each: 0.12, from: "center" },
      duration: 0.95,
      ease: "back.out(1.6)",
    },
    0.15
  );

stagger.from = "center" 是一个重要细节。它让贴纸从中间向外扩散,而不是从左到右机械播放。贴纸翻转只执行一次,完成后保持稳定;否则 section 会一直抖动,影响阅读。

技术贴纸的分布和明暗模式

贴纸使用彩色 SVG,但不能平均铺满屏幕。平均分布会像图标墙,和滚动叙事无关。这里更适合近似正态分布:中心密度更高,边缘更少,并通过不同尺寸和旋转角度制造层次。

位置不使用随机数,而是显式配置:

ts
const TECH_LOGOS = [
  {
    label: "TypeScript",
    src: "/logos/typescript.svg",
    size: "h-14 w-14",
    x: "left-[38%]",
    y: "top-[28%]",
    rotate: "rotate-6",
  },
];

原因是这个区域属于主视觉,构图需要可控。随机分布适合背景粒子,不适合需要反复调校的内容区域。

暗色模式下,彩色 logo 会遇到可读性问题,尤其是黑色或深色 SVG。处理方式不是统一降低透明度,而是单独调整亮度、饱和度和边缘识别:

css
.tech-logo-img {
  filter: saturate(1.04)
    drop-shadow(0 1px 0 oklch(0.98 0.01 75 / 0.42));
}
 
.dark .tech-logo-img {
  filter: saturate(1.14) brightness(1.12)
    drop-shadow(0 0 0.6rem oklch(0.95 0.01 75 / 0.22));
}

这类处理不是装饰优化,而是信息可读性问题。

项目图片的 LiquidImage

项目预览图使用 WebGL canvas 做 hover 扰动。这里不直接把图片变形,而是在图片上方覆盖一个 shader 层。默认状态下图片保持灰度和可读,hover 时 canvas 透明度提高,出现像素化和流体扰动。

组件结构:

tsx
<div
  className="liquid-image"
  onPointerEnter={() => {
    hoverTargetRef.current = 1;
  }}
  onPointerLeave={() => {
    hoverTargetRef.current = 0;
  }}
  onPointerMove={handlePointerMove}
>
  <img alt={alt} draggable={false} src={src} />
  <canvas className="liquid-image-canvas" ref={canvasRef} />
</div>

shader 中先计算 cover 后的 UV,保证图片不会因为容器比例变化而拉伸:

glsl
vec2 coverUv(vec2 uv) {
  float frameAspect = uResolution.x / uResolution.y;
  float imageAspect = uImageResolution.x / uImageResolution.y;
  vec2 result = uv;
 
  if (frameAspect > imageAspect) {
    float scale = imageAspect / frameAspect;
    result.y = (uv.y - 0.5) * scale + 0.5;
  } else {
    float scale = frameAspect / imageAspect;
    result.x = (uv.x - 0.5) * scale + 0.5;
  }
 
  return result;
}

然后根据鼠标位置做局部扰动:

glsl
vec2 toMouse = uv - uMouse;
float distanceToMouse = length(toMouse);
float ripple = sin(distanceToMouse * 28.0 - uTime * 5.4)
  * 0.032
  * hover;
 
vec2 pull = normalize(toMouse + vec2(0.001)) * ripple;
vec2 distortedUv = clamp(uv + flow - pull, vec2(0.006), vec2(0.994));

CSS 中图片和 canvas 分层:

css
.liquid-image img {
  position: absolute;
  inset: 0;
  object-fit: cover;
  filter: grayscale(1) contrast(1.08);
}
 
.liquid-image-canvas {
  position: absolute;
  inset: 0;
  opacity: 0.22;
  transition: opacity 360ms ease;
}
 
.liquid-image:hover .liquid-image-canvas {
  opacity: 0.86;
}

这套方案的关键是默认状态克制,hover 状态增强。如果默认 shader 太强,项目图就会丢失信息价值。

自定义鼠标和 hover 状态

页面使用自定义鼠标时,最容易出现的问题是 hover 状态僵硬。现在的处理是:鼠标进入可交互元素后,四角 marker 过渡到目标元素四角,而不是瞬间跳过去。

这类动效的原则是让鼠标反馈表达“当前元素可交互”,而不是做一个永远抢注意力的装饰层。尤其在文字密集区域,鼠标样式必须足够清楚,但不能遮挡内容。

可维护性取舍

这次重构最后保留了一些明确限制:

  • shader 只用于 hero 和项目图,不进入普通正文。
  • 网格和渐变只在必要区域出现,避免作为全局装饰。
  • 贴纸不随机生成,使用显式配置保证构图可复现。
  • GSAP 动画集中在页面作用域内,避免跨页面副作用。
  • WebGL 组件必须在卸载时释放资源。

例如 Three.js 资源清理:

ts
return () => {
  window.cancelAnimationFrame(frame);
  window.removeEventListener("pointermove", handlePointerMove);
  geometry.dispose();
  wireGeometry.dispose();
  material.dispose();
  wireMaterial.dispose();
  renderer.dispose();
};

如果忽略这一步,开发过程中反复进入页面会留下 WebGL 资源,最终表现为显存占用增加或浏览器警告。

总结

这次重构真正有效的地方,不是某一个 shader 或某一段 GSAP 动画,而是让每个效果都有清晰职责。

cube 不再是孤立模型,而是参与标题遮挡;贴纸不再是图标堆叠,而是滚动叙事的一部分;项目图片不再只是静态截图,而是在 hover 时提供流体反馈;像素语言不再是表层风格,而是贯穿边界、按钮、滚动条和鼠标状态的结构规则。

一个实验风格页面要保持专业,关键不是效果足够多,而是效果之间有关系,并且这些关系能被代码稳定维护。

[ End of Note ]