项目介绍
前阵子在网络上闲逛时,突然刷到某个科技类的官网首页------具体哪家就不提了,页面中央三个球体缓缓自转,背景透明,偶尔轮换一下C位,那种简洁又有科技感的视觉效果一下子把我钉在屏幕前。

具体效果可以点击这里查看。
我盯着看了好一会儿,脑子里冒出的第一个念头不是"这设计真棒",而是:这个效果,我也能用Three.js写出来吗?经常去三体星系进行太空旅行的小伙伴应该都知道,三体星系里有三个太阳,它们不规则运动,时而"恒纪元"温暖如春,时而"乱纪元"冰火两重天。我眼前的这三个球体,恍惚间就像是三个缩小版的太阳------虽然没有三体人那么悲壮,但那种"轮流登上舞台中央"的感觉,简直一模一样。
那一刻我甚至脑补了一段旁白:"太阳的运行之所以没有规律,是因为我们的世界中有三颗太阳,它们在相互引力的作用下,做着无法预测的三体运动。"但是我们页面上的三个球体的运动轨迹确并没有这么的不可预测。
于是,我关上脑洞,打开编辑器,新建了一个项目文件夹;就这样,一次由科幻联想和技术冲动混合驱动的复刻之旅开始了。
这篇文章不是什么官方教程,更像是我自己复刻过程的"踩坑笔记"。如果你也对Three.js感兴趣,或者想做一个类似的交互组件,希望我的弯路能帮你节省几个小时的调试时间。
整体页面设计
首先,我们需要设计整体的页面布局;我们发现第一屏滚动到第二屏的时候,球体进行了一下切换;但是从第二屏往后滚动,球体就没有了;因此我们将第一屏和第二屏放在同一个div下,作为页面的首屏:
vue
<template>
<div class="page">
<!-- 第一屏 -->
<div class="wrap-1">
<!-- three.js场景容器 -->
<div id="webgl-output" class="h-full w-full"></div>
<!-- 第一个子屏 -->
<div class="screen screen-1"></div>
<!-- 第二个子屏 -->
<div class="screen screen-2"></div>
</div>
<!-- 第二屏 -->
<div class="wrap-2">
</div>
<!-- 第三屏 -->
<div class="wrap-3">
</div>
</div>
</template>
由于我们页面都设置了overflow: hidden,页面没有滚动条,我们就绑定滚轮事件,滚轮超过一定的阈值时,切换到下一个屏:
typescript
import { useEventListener } from '@vueuse/core'
// 翻页阈值
const TURNING_PAGE_THRESHOLD = 20
useEventListener(document, 'wheel', event => {
if (event.deltaY >= TURNING_PAGE_THRESHOLD) {
scrollNext()
} else if (event.deltaY <= -TURNING_PAGE_THRESHOLD) {
scrollPrev()
}
})
接着,我们就看下这两个函数怎么来操作,进行下一屏的切换:
typescript
// 当前屏索引
const nowIndex = ref(0)
function scrollNext() {
if (nowIndex.value === 0) {
nowIndex.value++
const tl = gsap.timeline()
tl.to('.screen-1', {
opacity: 0,
y: -100,
duration: 1,
}).to('.screen-2', {
opacity: 1,
y: 0,
duration: 1,
onComplete() {
isScrolling.value = false
},
})
} else if (nowIndex.value >= 1) {
nowIndex.value++
gsap.to('.page', {
scrollTop: window.innerHeight * factor,
onComplete() {
isScrolling.value = false
},
})
}
}
当nowIndex从0切换到1时,我们对两个子屏进行动画切换的效果,后续我们加上了球体的切换,也只要在这里加一下即可;从第二屏往后滚动,球体就没有了,我们只需要将.page滚动到对应的scrollTop位置即可;scrollPrev函数的操作则相反,这里不再赘述了。
三个球体动画切换
上一章,我们介绍了整体效果和页面设计,现在终于到了动手写three.js代码的环节------也是我最期待的部分。这一章,我们从零开始,一步步实现三个球体的完整交互效果。先搞定基础场景(创建球体),再解决球体的动态定位(让球体适配任何屏幕),接着用GSAP做出流畅的轮播动画,最后用Raycaster赋予球体"被点击"的能力。
基础场景搭建
不管最终效果多炫酷,Three.js 项目的第一步永远是固定的三件套:场景(Scene)、相机(Camera)、渲染器(Renderer)。就像盖房子要先打地基,这三样东西没搭好,后面全是空中楼阁。
在实际项目中,笔者习惯封装一个Basic类来统一管理这些初始化工作,这样做的好处非常明显:
- 避免在每个组件里重复写样板代码。
- 方便统一配置(如开启/关闭辅助轴、设置背景透明等)。
- 后续如果要在同一个页面创建多个独立场景,也更容易扩展。
下面是在项目中Basic类的简化版代码(完整版还包含辅助轴、轨道控制、阴影等可选功能):
typescript
import { Scene, PerspectiveCamera, WebGLRenderer, Clock } from 'three'
export default class Basic {
public scene: Scene
public camera: PerspectiveCamera
public renderer: WebGLRenderer
private timer: number = 0
constructor() {
this.scene = new Scene()
this.camera = new PerspectiveCamera(fov, aspect, near, far)
this.camera.lookAt(0, 0, 0)
this.renderer = new WebGLRenderer({ antialias: true })
this.renderer.setPixelRatio(window.devicePixelRatio)
dom.appendChild(this.renderer.domElement)
window.addEventListener('resize', () => this.resize())
this.render()
}
resize = () => {
this.renderer.setSize(width, height)
this.camera.aspect = width / height
this.camera.updateProjectionMatrix()
}
render = () => {
this.renderer.render(this.scene, this.camera)
this.timer = requestAnimationFrame(this.render)
}
}
实际使用的时候,我们只需要在项目的主类中实例化 Basic,并传入所需的配置参数:
typescript
import Basic from './Basic'
export default class Index {
basic: Basic
constructor(){
this.basic = new Basic({
// 是否开启辅助轴
axes: false,
// 渲染前的回调函数
beforeRenderer: this.render.bind(this),
// 相机位置
cameraPosition: new Vector3(0, 0, 10),
// 相机视野
fov: 20,
// 相机近裁剪面
near: 0.01,
// 相机远裁剪面
far: 1000,
})
}
}
创建球体
基础场景搭建好后,终于到了最让人兴奋的环节------把三个球体变出来。三个球体长得差不多,但纹理和大气颜色各不相同,我写了一个createBall工厂函数来统一创建:
typescript
class Index{
initMesh() {
const sphereGeometry = new SphereGeometry(1, 64, 64)
const ball1 = this.createBall(texture1, new Vector3(0.0274, 0.204, 0.482), 1, sphereGeometry)
const ball2 = this.createBall(texture2, new Vector3(0, 0.212, 0.266), 2, sphereGeometry)
const ball3 = this.createBall(texture3, new Vector3(0.0235, 0.0627, 0.329), 3, sphereGeometry)
}
createBall(
texture: Texture | null,
atmosphereColor: Vector3,
active: 1 | 2 | 3,
sphereGeometry: SphereGeometry
) {
const material = new ShaderMaterial({
vertexShader,
fragmentShader,
uniforms: {
// 球体贴图
globeTexture: {
value: texture,
},
// 大气颜色
atmosphereColor: {
value: atmosphereColor,
},
},
})
const ball = new Mesh(sphereGeometry, material)
ball.userData.active = active
return ball
}
}
这里有个小细节:userData.active是 Three.js 提供的一个"挂载自定义数据"的字段。我把 1、2、3 三个编号直接挂在球体上,后面用Raycaster检测点击时,直接读这个值就知道是哪个球被点了。
Three.js 内置了 MeshStandardMaterial、MeshPhongMaterial 等标准材质,为什么非要自己写着色器?因为我想实现两种特殊效果:
-
大气散射颜色:每个球体除了贴图之外,还要有一层半透明的"大气层"颜色------就像地球的蓝紫色大气边缘。标准材质很难优雅地做到"贴图颜色 + 自发光色 + 边缘光"的混合。 -
边缘发光:球体边缘要比中心亮一些,模拟星球大气对太阳光的散射。这种效果需要根据视角方向与表面法线的夹角来计算,在着色器里只需要几行代码就能搞定。
顶点着色器的主要任务,是把纹理坐标(uv)和法线(normal)传递给片段着色器,同时计算好顶点的最终投影位置。
glsl
varying vec2 vertexUV;
varying vec3 vertexNormal;
void main(){
vertexUV=uv;
vertexNormal=normalize(normalMatrix*normal);
gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.);
}
片段着色器是真正"画"出球体的地方。先上完整代码,再拆解关键逻辑:
glsl
uniform sampler2D globeTexture;
uniform vec3 atmosphereColor;
varying vec2 vertexUV;
varying vec3 vertexNormal;
void main(){
// 计算大气强度:法线与视线方向(0,0,1)的点积
// 视线方向指向相机,法线越朝向相机,dot值越大,大气越弱
float intensity=1.05-dot(vertexNormal,vec3(0.,0.,1.));
// 大气层颜色:强度越高,颜色越饱和
vec3 atmosphere=atmosphereColor*pow(intensity,1.5);
// 边缘发光:强度更高次幂,只出现在边缘
float edgeGlow=pow(intensity,6.)*.6;
vec3 edgeColor=vec3(1.)*edgeGlow;
// 最终颜色 = 大气色 + 贴图色 + 边缘光
vec3 finalColor=atmosphere+texture2D(globeTexture,vertexUV).xyz+edgeColor;
gl_FragColor=vec4(finalColor,1.);
}
dot(vertexNormal, vec3(0,0,1))------这个点积算的是"你正对镜头的程度"。朝着镜头的面,法线和视线方向一致,点积接近于1;边缘就越接近0。用1.05减一下,边缘的地方强度反而高,正好用在大气层上。
atmosphere = atmosphereColor * pow(intensity, 1.5)------pow让大气从中心到边缘过渡得更柔和,不至于整个球都亮成一片。就跟夕阳染红天边的感觉差不多,只在边缘处漂亮地晕开。
edgeGlow = pow(intensity, 6) * 0.6------6次方之后,只有边缘那一圈强度高的地方才有亮色,乘以0.6控制亮度,模拟球体被侧后方阳光打亮的高光边。
最后三者加起来------大气色(蓝紫氛围)+ 贴图颜色 + 白色边缘光,科技感星球的视觉风格就有了。
这几行着色器代码虽然不长,但调试起来是真的磨人。intensity 的指数从1.5改成2.0,球体边缘的大气层可能就完全变了样;edgeGlow的系数调高一点,高光又过曝。每次改完都要重新编译、刷新页面、盯着球体边缘看半天------循环往复几十次。
这种精细活,对显示器的要求其实很高。还好有我最近新换的明基RD270Q帮了大忙:2K精细画质下,球体边缘的色阶过渡和光晕细节一览无余,不像以前用1080p屏幕时总有些模糊。而且它有个编程模式,专门优化代码区域的对比度,长时间看白色背景的编辑器不会刺眼;通过屏幕下方的按钮就可以快速切换不同的显示模式。

我们可以再看下开启编程模式和关闭编程模式的区别,可以看到代码颜色的对比度有着明显的提升:

最让我惊喜的是它的彩纸模式------这是一种模拟电子墨水屏的视觉效果。这个模式把响应时间和刷新率做了优化,同时提升了色彩饱和度,看起来就像在阅读一张柔和的彩色纸张,完全不累眼。
相比传统LCD那种直射光的刺眼感,RD270Q的亮度非常柔和,即使连续写几个小时代码、来回翻文档,眼睛也不会发酸发干。对于经常熬夜写Three.js的我来说,这简直是"续命"级别的功能。同样的代码,在彩纸模式下看着很舒服,可一换到笔者的笔记本电脑屏幕上,背光直射得眼睛直发酸,差距太明显了。

动态位置计算
上一节我们搭好了场景和基础球体,但现在有一个硬骨头要啃:如何让三个球体在不同屏幕尺寸下都能自动"贴"在正确的位置?具体来说,我想要达成的效果是:
- 左侧的球体始终紧贴屏幕左边缘,并且只露出一半(另一半在屏幕外)。
- 右侧的球体同理,贴在右边缘,半露出。
- 底部的球体固定在页面底部。
在CSS里实现响应式太简单了 ------ 一个 @media 查询 + flex布局 就能搞定。但在 Three.js 的3D世界里,没有现成的"靠边站" API,所有物体的位置都是世界坐标系下的绝对坐标。屏幕变大,球体不会自动跟着跑,必须手动重算。
在开始算位置之前,先定义一套"状态机"。笔者用一个变量nowActive来表示当前哪个球体在中间(激活状态),取值 1、2、3。在不同状态下,三个球体的目标位置排列如下:

你可能疑惑:三个球体左中右排列组合,不应该是 6 种状态吗?没错,如果纯排列,确实有 6 种。但我其实只定义了第一种状态(左 = 球1,中 = 球2,右 = 球3),后面的状态通过"旋转"得到------就像你把三个棋子整体往左移一格,边缘的那个跳到另一边。这种"循环移位置"的设计让代码简单了很多,只需要维护一组基准位置即可。
球体旋转的状态确定后,我们需要动态计算球体在三维空间中的位置了,我们将第一屏和第二屏球体所需要在的六个位置在图中表示出来如下:

为了拿到球体在 3D 空间中的实际坐标,我想过两种方案。
第一种方案,也是最常见最通用的方案,我们使用JS监听窗口大小的改变,然后根据窗口大小来动态计算球体的位置;这种方案缺点也很明显:需要针对不同屏幕比例反复调参。
第二种方案,有一天我在写 Raycaster 做点击检测时,突然冒出一个念头:既然射线可以从鼠标位置打到 3D 物体上,那我能不能反过来 ------ 从屏幕边缘发射射线,打到一块透明的"感应板"上,交点不就是我想要的边缘位置吗?
于是就有了下面这套"歪门邪道"但极其优雅的方案。
⚠️局限性提醒:这个方案假设相机是固定不动的(位置和朝向不变)。如果相机可以旋转或移动,那射线碰撞点就会变,不适用。正好我们这次的项目相机锁死,完美契合。
在场景中添加一个足够大的透明平面,让它正好覆盖相机的视野范围,并将它存到数组中,方便后续的计算:
typescript
class Index{
intersectPlaneObjects: Mesh[] = []
initPlane() {
const planeGeo = new PlaneGeometry(20, 20)
const material = new MeshBasicMaterial({
color: 0xffffff,
opacity: 0,
transparent: true,
})
const planeMesh = new Mesh(planeGeo, material)
this.scene.add(planeMesh)
this.intersectPlaneObjects.push(planeMesh)
}
}
我们要获取"左边缘中点"和"右边缘中点"对应的3D坐标。以左边缘为例,坐标起点在左上角,屏幕坐标 (0, innerHeight/2)。
- 先把屏幕坐标转换成归一化设备坐标(NDC),范围 [-1, 1]。
- 用 Raycaster 从相机发出射线,穿过那个 NDC 点,与透明平面求交。
- 交点的位置就是左边缘球体应该待的地方。
typescript
class Index {
calcResponsive() {
const posData = {
before: new Vector3(0, 0, 0),
left: new Vector3(0, 0, 0),
center: new Vector3(0, 0, 0),
right: new Vector3(0, 0, 0),
after: new Vector3(0, 0, 0),
active: new Vector3(0, 0, 0),
}
const screenX = 0,
screenY = window.innerHeight / 2
const px = (screenX / window.innerWidth) * 2 - 1
const py = -(screenY / window.innerHeight) * 2 + 1
this.raycaster.setFromCamera(new Vector2(px, py), this.basic.camera)
const intersects = this.raycaster.intersectObjects(this.intersectPlaneObjects)
if (intersects.length) {
const leftPos = intersects[0].point
posData.left = leftPos.clone()
leftPos.x -= 2
posData.before = leftPos.clone()
}
// 省略其他位置的计算
return posData
}
}
当用户缩放浏览器窗口或旋转设备时,需要重新调用calcResponsive并让球体飞到新位置上。同时要配合防抖(debounce)函数,避免窗口在变化过程中频繁触发导致卡顿。
typescript
import { debounce } from 'lodash-es'
class Index {
animationBall{
const posData = this.calcResponsive()
// ...
}
resizeBall = debounce(() => {
// 更新动画让球体移动到新位置
this.animationBall(this.nowActive as 1|2|3)
}, 100)
// 记得在构造函数里监听 resize
constructor() {
// ...
window.addEventListener('resize', this.resizeBall)
}
}
最终响应式的实现效果可以点击这里查看,不管屏幕尺寸如何改变,球体最终都能稳稳的回到属于自己的位置上。
调试响应式定位时,我得反复缩放浏览器窗口、刷新页面,检查左右球体是否始终紧贴屏幕边缘。这种高频的视觉比对,其实挺耗眼神的。此外不同时间段、不同环境光线下,对显示器模式的需求其实很不一样------白天写代码希望屏幕亮一点、对比度高一些;晚上调试Three.js效果时又希望光线柔和、不刺眼;偶尔翻文档查资料,又想要接近纸张的阅读感。
以前我得手动去按显示器背后的按钮,一级一级菜单切换模式,麻烦不说,还打断思路。最近用的明基RD270Q搭配的 Display Pilot 2软件里有个FLOW功能,帮我完美的解决了这个问题。

FLOW可以根据我的使用习惯和时段自动切换显示模式。比如我在设置中设定:上午9点到11点和下午1点到5点自动进入「编程模式」,代码高亮更清晰;晚上7点以后自动切换到「彩纸模式」,模拟电子墨水屏的柔和质感,亮度降低、色彩饱和度却恰到好处,长时间盯着也不累。
这种"无感切换"的体验真的很舒服。你不用惦记着去调显示器,它自己就帮你把最合适的模式准备好了。对于我这种经常在写代码、调效果、查文档之间反复横跳的人来说,FLOW功能省下的不只是几秒时间,还有保持专注的心流状态。

动画轮播设计
上面,三个球体整出来了,响应式位置也搞定了,接下来就是让它们动起来。动画这块我们选择GSAP,用起来顺手,缓动效果也细腻。
我们写个animationBall函数,传进去一个激活状态(1、2 或 3,代表哪个球体站中间)。函数第一件事,先调用 calcResponsive拿到当前屏幕下的六个关键位置(左、右、中、before、after、active)。
然后定义一个positionMap,把三种状态下三个球体分别应该去的位置映射好:
typescript
class Index {
animationBall(active: 1 | 2 | 3) {
const posData = this.calcResponsive()
const positionMap: Record<1 | 2 | 3, [Vector3, Vector3, Vector3]> = {
// 球1中间,球2左,球3右
1: [posData.center, posData.left, posData.right],
// 球1右,球2中间,球3左
2: [posData.right, posData.center, posData.left],
// 球1左,球2右,球3中间
3: [posData.left, posData.right, posData.center],
}
}
}
位置定好了,该让它们动起来了。我创建一条 GSAP 时间线 gsapTimeline,先把旧的杀掉(避免多个动画打架),然后新建一条:
typescript
class Index {
gsapTimeline?: gsap.core.Timeline
animationBall(active: 1 | 2 | 3) {
this.gsapTimeline?.kill()
this.gsapTimeline = gsap.timeline()
this.balls.forEach((ball, i) => {
const pos = positions[i]!
this.gsapTimeline?.to(
ball.position,
{
duration: 1,
ease: 'power1.inOut',
x: pos.x,
y: pos.y,
z: pos.z,
},
0
)
})
}
}
注意to方法的最后一个参数0,表示把这三个动画全部插在时间线的第0秒开始,也就是并行播放------三个球一起移动,而不是一个接一个。这样看起来才像"轮换",不是"排队"。
GSAP的时间线用起来很顺手,你不用操心每一帧怎么算中间值,它全包了。而且power1.inOut这个缓动很神奇,球体滑入滑出的时候,速度变化非常自然,不会突然急停。
我们还可以设置一个定时器,让球体自动切换。
typescript
class Index {
startTimer() {
this.timer = setInterval(() => {
if (this.nowActive === 1) {
this.nowActive = 2
} else if (this.nowActive === 2) {
this.nowActive = 3
} else if (this.nowActive === 3) {
this.nowActive = 1
}
if (this.nowActive !== 0) {
this.animationThreeBall(this.nowActive)
}
}, BALL_CHANGE_DURATION)
}
}

射线让球体"听得懂"用户点击
光有自动轮播还不够,我想让用户点哪个球,哪个球就立马站到C位。这就需要让球体"听得懂"鼠标点哪儿。
Three.js里没有 DOM 元素那种现成的 click 事件,得用 Raycaster(射线检测器)自己实现。思路很简单:从相机发射一条射线,穿过鼠标点击的位置,看它撞到了哪个球。代码写起来也不复杂,我直接在渲染器的 canvas 上绑了个 click 事件:
typescript
this.basic.renderer.domElement.addEventListener('click', (e: MouseEvent) => {
const { clientX, clientY } = e
const px = (clientX / window.innerWidth) * 2 - 1
const py = -(clientY / window.innerHeight) * 2 + 1
this.raycaster.setFromCamera(
new Vector2(px, py),
this.basic.camera
)
const intersects = this.raycaster.intersectObjects(this.balls)
if (intersects.length) {
const intersect = intersects[0]!.object
if (typeof intersect.userData.active === 'number') {
this.nowActive = intersect.userData.active as 1 | 2 | 3
this.animationThreeBall(this.nowActive)
}
}
})
整个过程分三步:
-
坐标转换:鼠标点的像素坐标 (clientX, clientY) 要转成 Three.js 认识的归一化坐标 (px, py)。这个公式容易记混,我们可以写了个screenToNDC函数封装起来。
-
射线检测:raycaster.setFromCamera 从相机位置朝着鼠标方向射出一根无形的线,intersectObjects 返回所有碰到的物体(按距离排序)。
-
取出命中的球体:intersects[0] 是最前面的那个球。之前创建球体时塞进去的 userData.active 终于派上用场了,直接用它更新 nowActive 状态,然后调用动画函数切换位置。
跑起来之后,你会发现射线检测非常灵敏------哪怕只点到球体的边缘,它也能精准命中,有种"指哪打哪"的感觉,比自动轮播还爽。
最终的实现效果可以点击这里查看。
本文所有源码敬请关注公众号【前端壹读】,后台回复关键词【三球轮播】即可获取。
总结与展望
好了,这篇文章到这里就差不多了。我们把整个项目从头到尾捋了一遍:从最开始的页面布局设计,到Three.js基础场景的三件套搭建,再到用ShaderMaterial写出带大气层和边缘光的球体------那几行GLSL代码虽然调试的时候让我挠掉不少头发,但最后球体边缘那一圈淡淡的蓝紫色晕开时,确实值了。
接着我们解决了每个Three.js新手都会头疼的问题:不同屏幕尺寸下物体怎么"自适应页面"?笔者通过射线法,巧妙地实现了3D场景中的"响应式布局"。这种方法不需要复杂的数学公式,而且天然适配任何屏幕比例 ------ 只要相机固定,边缘位置总是正确的。加上debounce防抖后,窗口缩放时球体也能稳稳当当地重新定位。
动画轮播那块,GSAP的时间线是常规操作,三个球并行动画加上power1.inOut缓动,切换过程看着就舒服。自动轮播的定时器和手动点击打断的逻辑也做了衔接,不会出现动画打架的情况。最后用Raycaster让球体"听得懂"用户点击------坐标转换、射线检测、拿到userData里存好的编号,三步走,点哪个球哪个就滑到C位。
对了,写这篇文章和调试代码的整个过程,我一直用着明基RD270Q------全球首款为编码人士打造的专业编程显示器。它的编程模式让代码高亮更舒服,2K精细画质把Three.js球体的大气层渐变呈现得清清楚楚,而DisplayPilot 2软件 可以随时调节显示参数,不用摸显示器后面的按键。如果你也是经常长时间盯屏幕的开发者,护眼和画质这件事,值得认真对待。
最后想说的是,技术复刻这件事,有时候不在于效果多复杂,而在于你在过程中解决了多少个"当时以为搞不定"的问题。这篇文章记录的,正是我从"这个效果我也能用Three.js写出来吗?"到"嗯,我真的写出来了"的完整过程。希望你读完之后,至少能少踩几个我踩过的坑。如果有什么更好的实现思路,欢迎评论区留言聊聊。