一、前言
之前完成了 Three.js 复刻的一版空月之歌:juejin.cn/post/748185...。
最近空月之歌的活动页面又更新了,新增了一些特效和交互。正好最近在学习 Three.js,便想着通过复刻新活动场景来练练手。于是,开启了这场 Three.js 的奇妙之旅。
最终效果展示

GitHub 地址:https://github.com/qirong77/genshin-impact-moon 喜欢的话点个🌟 谢谢~
在线访问地址:https://qirong77.github.io/genshin-impact-moon/
这次开发大约花了 3 天时间(再多就没法干了,老米不给我发工资) 。
部分 UI 和样式可能没有完全还原,欢迎感兴趣的同学 PR 支持~目前仍在持续优化中。
二、场景分析
本次更新主要新增了以下功能:
- 丝滑的来回切换动画:如上图所示。
- 动态图标:11 个地方势力的图标在圆环上动态移动。
- 地方势力详情展示:包括图案主体、说明文案,以及右侧 TAB 的切换功能。
三、实现思路
3.1 切换动画实现
初步实现动画
为了更直观地理解动画的实现,我们从动画的初始状态和最终状态入手进行推测。
动画开始时,轮盘是倾斜的;动画结束时,轮盘垂直于用户视角。
根据 3D 视角原理,这可以通过以下两种方式实现:
- 移动相机,保持轮盘不动。
- 移动轮盘,保持相机不动。
通过观察官网动画的背景未发生倾斜,可以推测是轮盘发生了移动。
假设轮盘的初始旋转角度为 rotation = [X, Y, Z]
,目标状态为 [0, 0, Z]
。 (因为最终轮盘是正对我们的,所以 xy 的坐标为 0)
我们需要一个平滑的变化函数来实现这一过渡。类似于 CSS 动画中的线性过渡或平滑过渡。
我们可以使用第三方库 gsap
,其内置了多种过渡函数,能够轻松实现这一效果。
js
function galaxyAnimation() {
// 遍历所有子元素并设置透明度动画
galaxyGroup.children.forEach((child) => {
gsap.to(galaxyGroup.rotation, {
x: 0,
y: 0,
z: 0,
duration: 2,
ease,
});
gsap.to(galaxyGroup.position, {
x: 0,
y: 0,
z: -2,
duration: 2,
ease,
});
}

提升动画的视觉效果
目前的动画虽然功能完整,但缺乏视觉冲击力。通过进一步分析,原动画不仅包含旋转,还伴随着轮盘的放大(即 Z 轴方向的拉近)。这利用了近大远小的视觉原理。
因此,我们可以在旋转动画的基础上,叠加一个拉近拉远的动画,从而实现更具冲击力的场景切换效果。
!!注意和上图的效果进行对比。

js
function cameraAnimation() {
const { x, y, z } = camera.position;
gsap.to(camera.position, {
x,
y,
z: z - 2,
duration: 1,
ease,
onComplete: () => {
gsap.to(camera.position, {
x,
y,
z,
duration: 1,
ease,
});
},
});
}
3.2 地方势力的图标实现
分析
地方势力就是刚开始的页面中的那些图标,这些图标围绕一个圆盘移动。通常情况下,只需更新每个物体的 rotation
即可实现。
但如果使用平面展示每个物体 ,在旋转时会导致视角偏移。 如下图所示

为确保展示效果正确,应通过更新物体的位置而非整体旋转来实现移动。所以这里就会涉及到一些数学的计算,不过也不麻烦,可以简单看下面的代码:
js
function animate() {
// 增加旋转角度
rotationAngle += 0.001;
// 更新每个mesh的位置,而不是旋转整个group
initialPositions.forEach((item) => {
// 使用旋转矩阵计算新的位置
const cos = Math.cos(rotationAngle);
const sin = Math.sin(rotationAngle);
// 应用旋转变换到初始位置
const newX = item.initialX * cos - item.initialY * sin;
const newY = item.initialX * sin + item.initialY * cos;
// 更新位置,保持z和朝向不变
item.mesh.position.set(newX, newY, item.mesh.position.z);
});
// 使用requestAnimationFrame实现连续动画
requestAnimationFrame(animate);
}
光线投射器和高亮效果实现
当鼠标悬停在某个物体上时,会触发高亮效果(颜色加深、体积变大)。如下面所示:

实现步骤如下:
- 确定鼠标悬停的物体。
- 为该物体添加动画。
因为在 Three.js 场景中,无法直接使用类似 DOM 的事件监听。我们可以通过 raycaster
工具检测鼠标位置与物体的交互。
"Raycaster"(光线投射器 )基于光线投射原理,从一个起点沿特定方向(简单理解为用户的视角)发射虚拟光线 ,检测光线与场景中物体的相交情况。
图中橙色箭头类似光线投射路径,穿过不同几何物体,形象展示光线与物体的交互 ,体现 Raycaster 检测光线与物体相交的过程。

具体步骤如下:
- 创建
raycaster
实例。 - 将鼠标屏幕坐标转换为归一化设备坐标(NDC)。
- 使用
raycaster.setFromCamera
方法生成射线。 - 使用
raycaster.intersectObjects
检测射线与场景中物体的交点。 - 如果检测到交点,为相应物体添加高亮效果。
代码示例:
js
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
window.addEventListener('mousemove', (event) => {
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(scene.children);
if (intersects.length > 0) {
const hoveredObject = intersects[0].object;
hoveredObject.material.emissive.setHex(0xff0000); // 高亮效果
}
});
3.3 地方势力详情展示
扫描线动画实现

扫描线动画通过周期性高亮图片的局部区域,模拟灯光扫过的效果。
假设扫描线初始位置为 [0, 0.1]
(图片从左到右的 10% 位置)。扫描线对应的像素会被高亮,可通过以下两种方式实现:
- 提高透明度。
- 增加颜色饱和度和亮度(通过同时提高 RGB 通道值,使颜色更接近白色)。
一般来说第二种方式更合理,因为透明度调整范围有限。
scss
// 获取当前扫描位置
float scanPos = mod(u_time * u_scanSpeed, 1.0);
// 创建柔和的扫描线效果
float scanStart = scanPos;
float scanEnd = scanPos + u_scanWidth;
// 使用smoothstep创建平滑过渡的扫描线
float scanLine = smoothstep(scanStart - u_scanSmoothness, scanStart + u_scanSmoothness, v_uv.x) -
smoothstep(scanEnd - u_scanSmoothness, scanEnd + u_scanSmoothness, v_uv.x);
最终实现效果

星星动效实现
下面是官方实现的截图:

星星动效就是在图标中添加星星移动效果。目前我能想到的还是通过 Shader 实现。
Shader 实现思路
- 确定星星的数量,满足条件的 UV 坐标作为星星圆心。
- 根据圆心生成随机方向
d
和速度v
,计算星星的当前坐标。 - 根据坐标和半径计算星星的辐射范围,并通过离圆心的距离确定发光强度。
- 为实现闪烁效果,可叠加一个闪烁函数,最终得出亮度值。
js
// 星星函数,根据位置和时间生成星星
float star(vec2 uv, float index) {
// 为每个星星生成一个随机位置
float randomX = random(vec2(index, 0.5));
float randomY = random(vec2(index, 0.8));
// 添加基于时间的移动
float moveX = sin(u_time * 0.1 * random(vec2(index, 0.3))) * u_starMovementSpeed;
float moveY = cos(u_time * 0.1 * random(vec2(index, 0.7))) * u_starMovementSpeed;
// 星星位置
vec2 starPos = vec2(randomX + moveX, randomY + moveY);
// 计算当前像素到星星中心的距离
float dist = distance(uv, starPos);
// 星星闪烁效果
float twinkle = sin(u_time * u_twinkleSpeed * random(vec2(index, 0.9))) * 0.5 + 0.5;
// 如果距离小于星星大小,则显示星星
return smoothstep(u_starSize, 0.0, dist) * u_starBrightness * twinkle;
}
最终效果如下:

虽然实现了需求 ,但增加星星数量后会导致性能问题,导致我的 M1Pro 直接卡死,应该需优化计算逻辑。不过目前我没想到优化的方式,就不把这些星星放到项目中了。
切换动画实现
如果让你实现下面的一个切换的 TAB,那么你需要创建几个实例呢?

第一想法是 11 个排成一列,然后不断地左右移动切换。
后面做的时候发现只要创建 1 个就行了,可以看到动图的前后切换中间有短暂的黑屏。我们只需要在切换下一个之前,修改对应的纹理图案就行。
STEP1:创建TAB栏和下面的描述(略)
STEP2:实现切换的动画
切换的动画有两个步骤:
- 将卡片移动到左后方(过渡动画)
- 将卡片移动到右后方(立刻执行, 此时有短暂的时间屏幕不会展示图片)
- 将卡片移动到正前方(过渡动画)
下面是一个消失动画的举例:
js
// 消失动画
const disappear = (imagePagh: string) => {
NodeKraiState.isAnimation = true;
NodeKraiState.isFirstShow = false;
gsap.to(group.position, {
x: -2,
z: -2,
duration: 0.8,
ease: "power2.inOut",
});
gsap.to(mainImageMesh.material.uniforms.u_opacity, {
value: 0,
duration: 0.8,
ease: "power2.inOut",
onComplete: () => {
mainImageMesh.material.uniforms.u_texture.value = new THREE.TextureLoader().load(imagePagh);
appear();
},
});
};
最终效果如下:

四、补充
制作一个优秀的交互网站需要大量精力,尤其是细节的打磨。
例如当我们进行转场动画的时候,轮盘会突然加速然后平滑过渡到原来的速度来提升观感,篇幅有限就不追溯了
此外,调试参数和贴图优化也需要大量时间,对缺乏美术基础的前端来说是个挑战。
最后,欢迎访问项目地址并提出建议~ ,后面应该还会继续优化~
GitHub 地址:https://github.com/qirong77/genshin-impact-moon 喜欢的点个🌟吧 谢谢~