Moonlight
← back to tech

2026 年 9 月 9 日 · 4 min read

这个站点是怎么做的:一轮会随滚动变化的弯月

首页的弯月是一个球体加一段片元着色器:光源方向决定月相,暗面留一点地照,滚动进度推动光源转向。

Next.jsReact Three FiberGLSLFramer Motion

首页那轮弯月不是模型也不是贴图,是一个球体加一段片元着色器。这篇记录几个关键决定。

月相由光源方向决定

真实的月相就是太阳照亮球体的角度。把光源方向写成一个和滚动进度绑定的向量:

float kz = mix(-1.9, -0.25, uPhase);
vec3 L = normalize(vec3(0.82, -0.72, kz));
float d = dot(N, L);

x = 0.82, y = -0.72 决定月牙挂在右下角;kz 越负、光越从背后来,月牙越薄。uPhase 绑到页面滚动进度,向下滚动时光源慢慢转向正面,月牙渐厚。

平涂而不是渐变

真实的球面受光是渐变的,但那样月牙的边会糊掉,不像 🌙。这里故意把明暗交界做成硬边,只在边上留很窄的过渡:

float lit = smoothstep(-0.01, 0.06, d);
lit *= 0.86 + 0.14 * smoothstep(0.0, 0.5, d);

再把亮面稍微过曝一点,让月牙自己发光:

vec3 col = earth + bright * lit;
col = col / (col + vec3(0.22));
col = pow(col, vec3(0.8));

暗面留一点地照

完全黑的暗面会变成一个黑球。留一点极弱的、偏蓝的地照,再把暗面的透明度压到很低,远看就只剩一弯月牙,凑近能隐约看到月面:

vec3 earth = uEarth * albedo * 0.09;
float alpha = mix(uDarkAlpha, 1.0, smoothstep(0.0, 0.05, d));

月面纹理来自三层 fbm 噪声:大尺度的月海、中尺度的环形山、细尺度的颗粒。不用贴图,噪声出来的纹理每个尺度都对得上。

滚动驱动

框架用 Framer Motion 的 useScroll,但有一个坑:新版本在支持 ViewTimeline 的浏览器上会把进度转成原生滚动动画,在 position: sticky 的舞台里会算错并且静默失效。解法是把进度复制进一个普通的 MotionValue,所有派生值都走 JS:

const { scrollYProgress } = useScroll({ target, offset });
const p = useMotionValue(0);
useMotionValueEvent(scrollYProgress, "change", (v) => p.set(v));

性能

  • 落叶用实例化网格,一个 draw call。
  • 萤火虫是点精灵加法混合,鼠标靠近时轻轻吸过去。
  • 移动端星点减半,dpr 上限 1.5,没有 WebGL 时退回一张静态 SVG。