原创 何贤 2025-03-31 08:30 重庆
点击关注公众号,“技术干货” 及时达!
0.前置条件
欢迎阅读本篇文章!在深入探讨 Three.js
和 Shader (GLSL)
的进阶内容之前,确保您已经具备以下基础知识:
Three.js
的基本概念和使用方法,包括场景(Scene
)、相机(Camera
)、渲染器(Renderer
)、几何体(Geometry
)、材质(Material
)和网格(Mesh
)等核心组件。如果您还不熟悉这些内容,建议先学习 Three.js
的入门教程。我比较推荐外网 Threejs
知名博主Bruno Simon 出的 threejs-journey
入门课程。当然如果您想让我分享我的学习路径可以在评论区留言,人数够多我会着手开始撰写学习路线。GLSL
(OpenGL Shading Language)的编写,因此您需要了解 GLSL
的基本语法,包括顶点着色器(Vertex Shader)和片元着色器(Fragment Shader)的编写,以及如何在 Three.js
中使用自定义着色器1. Hero Section 概览
Hero Section 是网页设计中的一个术语,通常指页面顶部的一个大型横幅区域。但对于开发人员而言,这个概念可以更直观地理解为用户在访问网站的瞬间所感受到的视觉冲击,或者促使用户停留在该网站的关键因素。
随着 AI 与 机器人 概念在国内的又一波井喷式爆发,越来越多的企业开始步入这一科技蓝海。无论是工业机器人、服务机器人,还是智能家居设备,这些领域的技术创新正在重塑我们的生活方式和商业模式。
随着 Web3D 开发门槛的降低以及数字资产获取难度的减少,全新的交互方式或许很快将成为主流。然而,在展望未来的同时,我们仍需立足当下。希望这篇文章能够帮助您在接手机器人官网开发时,轻松攻克技术难关。(人要吃饭的嘛!嘿嘿)
闲话少说,让我们直接进入正题,看看今日要实现的 Hero Section!(注:GIF 压缩较多,实际效果会更加惊艳。)
PC端在线预览地址:sint-copy.vercel.app/
Debug调试界面:sint-copy.vercel.app/#debug
源码地址:github.com/hexianWeb/s…
原网站地址: sint.gg/
2.基础场景搭建
首先让那个我们一起来解读一下这个场景里面有什么。首先映入眼帘的是一个 静态机器人,它并非完全静止,而是在页面中 轻轻晃动。机器人周围的空间中,弥漫着 大、中、小三种尺寸的粒子。最后,一条 飞线 从页面左侧缓缓滑向右侧。
那么让我来分别实现这三个场景吧!
2.1 基础场景搭建
对于场景三要素以及如何搭建一个基础场景我曾在过往文章中提及,这里我不再过多的赘述。 您需要能够独立搭建以下场景(或者直接将截图上传到AI tools
要求复现以下场景)
2.2 机器人资源获取(不感兴趣可跳过)
如果对 3D 资源的生成和获取不感兴趣可以直接前往源码文件夹下
public/sint
下载robot.glb
现在我们要开始着手引入静态机器人,现在的 AI 3D Generation
技术逐渐趋于可用范围,我有时候不光会在免费的3D 模型网站(sketch 3D, Free3D)
也会在如:
MeshyAI:https:www.meshy.ai
Hyper3D:https:hyper3d.ai
混元3D:https:3d.hunyuan.tencent.com
等3D
模型网站上获取到一些3D
模型资源,比如Hyper3D
官网上就能获得前阵子比较火的宇数机器人
模型。
随后我们需要用到一个神奇的网站mixamo:https://www.mixamo.com ,主要用于 3D 角色动画 的制作和优化。只要你上传.fbx
或者 .obj
格式的文件,然后选择你想要的动作即可快速给你的模型添加动画,这里我们以刚刚的宇树机器人为例做一个示范。
将压缩包上传到 mixamo 并等待模型解压,随后模型展示无报错后点NEXT
进入以下界面 参考右侧界面对模型关节进行配对。
这里有一些同学可能使用了自己的模型,
上传后进行关节配对出现 以下错误
此时可能需要导入进建模工具检查一下。通常可能是因为模型中某个部位跟其余部位并没有连接在一起。存在“断肢”,可以先用简单的网格基本体连接,后上次 mixamo 导入完动画再进行删除 !
这样就可以正常绑定关节节点,
然后点击右侧动作就能给模型赋予对应动画
现在选择合适的动作赋予机器人就好了。
这里我们搜索idle
并赋予机器人
此时这个模型就被赋予静态动画了。是不是很简单!
随后点击with Skin
,然后你就获得了一个带有动画的模型!
随后我们简单的到一些在线3D
预览网站传入模型,发现模型的纹理全部丢失了!!
这个时候我们可以借助Blender
恢复我们的纹理,还记得之前在Hyper 3D下载FBX时的下载的压缩包吗?解压后进入对应目录就可以看到我们丢失的纹理部分。
我们先导入FBX
进入Blender
,然后选择我们的机器人,随后新建一个材质。
然后为他新增一个图像纹理
随后将图像纹理传入对应模型。
2.3出现吧!机器人
在成功修复纹理问题并导出 .glb
模型后,我们获得了一个静态资源文件 robot.glb
。接下来,我们将这个模型引入到 Three.js 场景中,并调整其大小和位置,使其完美融入设计。
步骤 1:加载机器人模型
首先,使用 GLTFLoader
加载 robot.glb
模型文件,并调整模型的大小和位置:
this.loader = new GLTFLoader();
this.loader.load('path/to/robot.glb', (gltf) => {
this.model = gltf.scene;
// 调整模型大小
this.model.scale.set(0.02, 0.02, 0.02);
// 设置模型位置
this.model.position.set(0, -2.81, 0);
// 将模型添加到场景中
this.scene.add(this.model);
});
步骤 2:播放机器人动画
为了让机器人更加生动,我们可以播放其 idle
动画。以下是实现代码:
// 设置动画
this.animation = {
mixer: new THREE.AnimationMixer(this.model), // 创建动画混合器
actions: {}, // 存储动画动作
animations: this.resources.items.sintRobot.animations // 获取动画片段
};
// 为每个动画创建动作
this.animation.animations.forEach(clip => {
this.animation.actions[clip.name] = this.animation.mixer.clipAction(clip);
});
// 默认播放第一个动画
if (this.animation.animations.length > 0) {
this.currentAction = this.animation.actions[this.animation.animations[0].name];
this.currentAction.play();
}
步骤 3:更新动画帧
在 requestAnimationFrame
中更新动画混合器,以确保动画流畅播放:
if (this.animation.mixer) {
this.animation.mixer.update(this.time.delta);
}
随后不断调整相机的位置和FOV
(如果使用透视相机的话),来达到最佳效果!这一点需要你自己去不断调试!
差不多这样就可以了
2.4 灯光设置
上述图片不难看出,只有 环境光存在展示出的模型展示效果实在称不上好的范畴。因为在 Three.js 中,灯光是提升场景高级感和真实感的关键因素之一。通过合理配置灯光,让光线和材质进行作用,可以为场景增添层次感、深度和氛围。以下是我的做法,可供你作为一个参考!
// 第一个方向光源
this.directionalLight1 = new THREE.DirectionalLight();
this.directionalLight1.position.set(1, 2, 2);
this.directionalLight1.color.setRGB(0.13, 0.09, 0.15);
this.directionalLight1.intensity = 3;
this.scene.add(this.directionalLight1);
// 第二个方向光源
this.directionalLight2 = new THREE.DirectionalLight();
this.directionalLight2.position.set(-1, 3, 1);
this.directionalLight2.color.setRGB(0.45, 0.36, 0.22);
this.directionalLight2.intensity = 4.1;
this.scene.add(this.directionalLight2);
// 环境光
this.ambientLight = new THREE.AmbientLight();
this.ambientLight.color.setRGB(
0.309_468_922_806_742_8,
0,
0.964_686_247_893_661_2
);
this.ambientLight.intensity = 2;
this.scene.add(this.ambientLight);
3.粒子飞舞
此时的背景太过空旷了不是吗?让我们加入一些粒子来填充这个空白的空间。原作中空间就充斥的大量粒子,但仔细看所有的粒子都和机器人擦肩而过。我当前的想法是这样,为了避免粒子在进行飘动过程中出现穿过机器模型这种尴尬行为。我选择在机器人的前后两处增加平面,将他们放置在粒子“飘动”的最大距离,并在平面上进行位置采样。这样粒子不会出现在机器表面飘进飘出的尴尬情况。 当然你也可以在机器人表面采样,或者在Blender 用凸壳做出一个 lowpoly
风格的外壳,再或者沿着法线进行一定距离的偏移,毕竟条条大路通罗马嘛!
步骤 1:创建采样平面
首先,我们创建两个平面几何体,分别放置在场景的 z = 0.5 和 z = -0.5 位置,作为粒子的采样源。记得别忘了updateMatrixWorld
更新世界矩阵哦
// 创建一个平面几何体,宽高与视口大小相同
const planeGeometry = new THREE.PlaneGeometry(
2 * this.sizes.aspect,
2,
1,
1
);
// 创建基础材质
const planeMaterial = new THREE.MeshBasicMaterial({
color: 0xFF_FF_FF,
wireframe: true
});
// 创建平面网格
this.plane = new THREE.Mesh(planeGeometry, planeMaterial);
// 克隆一个平面让其置于 -0.5 z
const clonedPlane = this.plane.clone();
clonedPlane.position.set(0, 0, -0.5);
this.scene.add(clonedPlane);
this.plane.position.set(0, 0, 0.5);
// 将平面添加到场景中
this.scene.add(this.plane);
// 更新世界矩阵 防止采样到 setPosition前的坐标(重要)
this.plane.updateMatrixWorld();
clonedPlane.updateMatrixWorld();
// 创建粒子系统
this.geometryParticles = new ParticleSystem([this.plane, clonedPlane]);
// 隐藏原始平面
this.plane.visible = false;
clonedPlane.visible = false;
步骤 2:采样粒子位置
那么如何在平面上挑选初始粒子位置呢?我这里通过 MeshSurfaceSampler
对平面进行采样,确定粒子的位置、噪声值、速度和大小的代码,当然你可以刻通过创建一个具有一定顶点数量的平面,随后取positionArray
中随机index
的position
的顶点作为初始粒子位置!我没有去研究过两种采样逻辑到底谁的效率会更高,但是方向,本次案例最多粒子数不会超过1000就能获得好的效果,所以不需要对性能优化太过苛刻
const planeGeometry = new THREE.PlaneGeometry(
2,
2,
1000,
1000
);
const randomArray =[];
const positions = planeGeometry.attributes.position.array;
while(index < 采样粒子数量) {
const randomIndex = Math.floor(Math.random() * positions.length / 3);
const particlePosition = new THREE.Vector3(
positions[randomIndex * 3],
positions[randomIndex * 3 + 1],
positions[randomIndex * 3 + 2]
);
index++
randomArray.push(particlePosition)
}
而笔者使用的则是MeshSurfaceSampler
,代码如下:
samplePoints(positions, noises, speeds, sizes) {
const tempPosition = new THREE.Vector3();
// 为每个源网格创建一个采样器
const samplers = this.sourceMeshes.map(mesh =>new MeshSurfaceSampler(mesh).build());
// 计算每个网格应该采样的点数
const pointsPerMesh = Math.floor(this.parameters.count / this.sourceMeshes.length);
const remainingPoints = this.parameters.count % this.sourceMeshes.length;
let currentIndex = 0;
// 对每个网格进行采样
this.sourceMeshes.forEach((mesh, meshIndex) => {
const sampler = samplers[meshIndex];
// 计算当前网格需要采样的点数
const currentMeshPoints = pointsPerMesh + (meshIndex < remainingPoints ? 1 : 0);
// 对当前网格进行采样
for (let i = 0; i < currentMeshPoints; i++) {
sampler.sample(tempPosition);
tempPosition.applyMatrix4(mesh.matrixWorld);
positions[currentIndex * 3] = tempPosition.x;
positions[currentIndex * 3 + 1] = tempPosition.y;
positions[currentIndex * 3 + 2] = tempPosition.z;
const noiseValues = [0.3, 0.6, 0.9];
noises[currentIndex * 3] = noiseValues[Math.floor(Math.random() * 3)];
noises[currentIndex * 3 + 1] = noiseValues[Math.floor(Math.random() * 3)];
noises[currentIndex * 3 + 2] = noiseValues[Math.floor(Math.random() * 3)];
speeds[currentIndex] = 0.5 + Math.random();
sizes[currentIndex] = 0.3 + Math.random() * 0.7;
currentIndex++;
}
});
}
步骤 3:应用基础材质
为了快速查看粒子效果,我们可以使用 PointsMaterial
作为临时材质:
this.material = new THREE.PointsMaterial({
color: '#ffffff',
size:5,
});
步骤4:扰动粒子
现在我们创建 ShaderMaterial
然后通过 操控vertexShader
来达到粒子扰动
// 创建着色器材质
this.material = new THREE.ShaderMaterial({
vertexShader: vertexShader,
fragmentShader: fragmentShader,
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
uniforms: {
pixelRatio: { value: Math.min(window.devicePixelRatio, 2) },
time: { value: 0 },
size: { value: this.parameters.size },
speed: { value: this.parameters.speed },
opacity: { value: this.parameters.opacity },
color: { value: new THREE.Color(this.parameters.color) },
isOrthographic: { value: isOrthographic },
sizeAttenuation: { value: this.parameters.sizeAttenuation }
}
})
fragmentShader
非常的简单,代码如下,基本上就白色 + 片元距离衰减。
void main() {
float distanceToCenter = distance(gl_PointCoord, vec2(0.5));
float strength = 0.05 / distanceToCenter - 0.1;
gl_FragColor = vec4(vec3(1.0), strength );
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
vertexShader
内容则偏复杂,但也仅仅用到一些三角函数,你可以引入一些经典噪声函数或者让gpt
集成fbm
配合您的鼠标互动来增加扰动性,但我们这里保持简单。
uniform float pixelRatio;
uniform float time;
uniform float size;
uniform float speed;
uniform float opacity;
uniform vec3 color;
uniform bool sizeAttenuation;
attribute vec3 noise;
attribute float particleSpeed;
attribute float particleSize;
varying vec3 vColor;
varying float vOpacity;
void main() {
vec4 modelPosition = modelMatrix * vec4(position, 1.0);
// 更新粒子位置,基于时间和噪声值
modelPosition.y += sin(time * speed * particleSpeed + modelPosition.x * noise.x * 100.0) * 0.2;
modelPosition.z += cos(time * speed * particleSpeed + modelPosition.x * noise.y * 100.0) * 0.2;
modelPosition.x += cos(time * speed * particleSpeed + modelPosition.x * noise.z * 100.0) * 0.2;
vec4 viewPosition = viewMatrix * modelPosition;
vec4 projectionPostion = projectionMatrix * viewPosition;
gl_Position = projectionPostion;
// 正交相机下粒子大小处理
if (isOrthographic) {
gl_PointSize = size * particleSize * 8.0 * pixelRatio; // 正交相机下使用较小的乘数
} else {
gl_PointSize = size * particleSize * 25.0 * pixelRatio;
// 只在透视相机且启用大小衰减时应用距离衰减
if (sizeAttenuation) {
gl_PointSize *= (1.0 / - viewPosition.z);
}
}
vColor = color;
vOpacity = opacity;
}
现在看起来还不错,尤其从正面视角。
4.UI 来袭
此时我们场景搭建的差不多
添加上UI看看效果如何:
虽然效果尚可,但整体显得有些平淡。为了增强视觉吸引力,我们在画布后方添加了一个 径向渐变背景,为场景增添层次感和氛围感。
现将Threejs
画布设置为透明
this.instance = new THREE.WebGLRenderer({
canvas: this.canvas,
antialias: true,
alpha: true // 启用透明背景
});
再使用
this.instance.setClearColor(0x00_00_00, 0); // 设置透明背景
最后使用 CSS 为画布背后的元素设置径向渐变背景:
<main class="flex h-full bg-[radial-gradient(circle_at_50%_50%,rgb(27,27,47),rgb(15,15,31)_50%,rgb(0,0,0)_100%)] text-color">
<!-- UI部分 -->
<canvas id="canvas" class="z-[0] h-full w-full"></canvas>
</main>
在复刻这个网站时,我一开始的设想是利用体积光来实现背景的渐变效果。然而,查看源网站后发现,实际上背景是通过普通的页面元素(CSS 渐变)实现的。这让我意识到,虽然我们掌握了 Three.js 这样的 3D 技术,但并不意味着所有视觉效果都需要依赖 3D 技术来实现。Three.js 是一个强大的 3D 渲染框架,但它并不是解决所有问题的唯一工具。对于一些简单的视觉效果(如背景渐变),使用 CSS 或其他前端技术可能会更加高效和灵活。我们需要根据实际需求选择合适的技术,避免过度依赖 3D 技术,从而保持代码的简洁性和性能。
5.视差效果(Parallax Effect)
如果场景中只有静态的机器人,互动性会显得不足。为此,我们引入 视差效果,利用用户的鼠标移动来增强用户体验。
步骤 1:获取归一化鼠标坐标
将鼠标位置标准化到 [-1, 1]
范围:
window.addEventListener('mousemove', (event) => {
// 将鼠标位置标准化到 [-1, 1] 范围
this.normalizedMouse.x = (event.clientX / window.innerWidth) * 2 - 1;
this.normalizedMouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
});
步骤2:平滑插值实现视差效果
在大多数视差方案中,通常是直接更新相机的位置来实现效果。然而,这里我们采用了一种更优雅的方式:通过更新相机的 lookAt
目标点,并结合平滑插值,实现自然的视差效果。这种方式不仅更加灵活,还能避免相机位置突变带来的不自然感。
const { x, y } = this.normalizedMouse;
// 创建视点目标,随鼠标移动但幅度更小
const lookAtTarget = new THREE.Vector3(
this.scene.position.x + x * this.parallax.factor * 0.25,
this.scene.position.y + y * this.parallax.factor * 0.15,
this.scene.position.z
);
// 如果是第一次更新,初始化当前目标点
if (!this.currentLookAtTarget) {
this.currentLookAtTarget = lookAtTarget.clone();
}
// 平滑插值过渡到新的目标点
this.currentLookAtTarget.lerp(lookAtTarget, 0.07);
// 相机平滑过渡看向这个动态目标点
this.camera.lookAt(this.currentLookAtTarget);
通过以上实现,我们得到了一个动态的视差效果,增强了用户的互动体验:
6.流行飞线
在 Three.js 中,飞线效果是一种常见的视觉效果,通常用于展示路径、连接关系或动态轨迹。实现飞线效果的核心思路是:构造曲线,对曲线上的点进行采样,然后利用这些点创建网格基本体。我们可以借助现成的库(如 meshline),或者使用 Three.js 自带的几何体(如 TubeGeometry
或 Line
)。
关键点:利用透明度属性实现渐变效果
1. 创建曲线几何体
首先,我们使用 BufferGeometry
从曲线点集创建几何体:
this.lineGeometry = new THREE.BufferGeometry().setFromPoints(this.curvePoints);
this.curvePoints
是曲线上的点集,通常通过贝塞尔曲线或其他曲线生成算法生成。
2. 为顶点添加透明度属性
为了实现飞线的渐变透明效果,我们需要为每个顶点添加一个 alpha
属性。这个属性将根据顶点在曲线上的位置动态计算透明度值:
const alphas = new Float32Array(this.curvePoints.length);
for (let index = 0; index < this.curvePoints.length; index++) {
// 计算当前点在曲线上的位置 (0-1)
const t = index / (this.curvePoints.length - 1);
// 使用正弦函数实现平滑的透明度过渡
alphas[index] = Math.sin(t * Math.PI) * this.parameters.lineOpacity;
}
this.lineGeometry.setAttribute('alpha', new THREE.BufferAttribute(alphas, 1));
3. 创建着色器材质
为了实现透明度的动态控制,我们使用 ShaderMaterial
,并在顶点着色器和片段着色器中处理透明度属性:
this.lineMaterial = new THREE.ShaderMaterial({
vertexShader: `
attribute float alpha;
varying float vAlpha;
void main() {
vAlpha = alpha;
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
gl_Position = projectionMatrix * mvPosition;
}
`,
fragmentShader: `
uniform vec3 color;
varying float vAlpha;
void main() {
gl_FragColor = vec4(color, vAlpha);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
`,
uniforms: {
color: { value: new THREE.Color(this.parameters.lineColor) }
},
transparent: true, //启用透明效果
depthWrite: false,
blending: THREE.AdditiveBlending //使用叠加混合模式,增强飞线的视觉效果。
});
其余的想飞线中Point
如何更新位置我就不过多介绍了! 最后的效果如图:
7.最后一些话
技术的未来与前端迁移
随着 AI 技术的快速发展,各类技术的门槛正在大幅降低,以往被视为高门槛的 3D
技术也不例外。与此同时,过去困扰开发者的数字资产构建成本问题,也正在被最新的 3D generation
技术所攻克。这意味着,在不久的将来,前端开发将迎来一次技术迁移,开发者需要掌握更新颖的交互方式和更出色的视觉效果。
为什么选择 Three.js
?
Three.js
作为最流行的 WebGL
库之一,不仅简化了三维图形的开发流程,还提供了丰富的功能和强大的扩展性。无论是创建复杂的 3D 场景,还是实现炫酷的视觉效果,Three.js
都能帮助开发者快速实现目标。
本专栏的愿景
本专栏的愿景是通过分享 Three.js
的中高级应用和实战技巧,帮助开发者更好地将 3D
技术应用到实际项目中,打造令人印象深刻的 Hero Section
。我们希望通过本专栏的内容,能够激发开发者的创造力,推动 Web3D
技术的普及和应用。
点击关注公众号,“技术干货” 及时达!