Three.js:3D的初探

一、二维世界的“叛逃者”

2022年的春天,当产品经理将一份“3D产品可视化展厅”的需求文档放在我面前时,我盯着屏幕上那些“360度旋转”、“材质切换”、“光影效果”的字眼,第一次对前端这个领域产生了某种“维度上的恐慌”。

十年了。从平面布局到交互逻辑,从表单验证到状态管理,我自以为已经摸透了浏览器这个二维画布的所有脾气。可当第三个维度——那个名为“Z轴”的幽灵——突然闯入需求时,我发现自己像个只会画方块的原始人,突然被要求建造一座宫殿。

“不就是个3D么,”我听见自己用十年前面对第一个弹窗时的语气说,“应该……有库吧?”

二、第一个立方体的诞生

Three.js的文档像一座宏伟而迷宫般的城堡。我站在门口,手里拿着“Getting Started”这把生锈的钥匙。

“Scene(场景)、Camera(相机)、Renderer(渲染器)。”文档用不容置疑的语气宣告着三维世界的基本法则。我像个笨拙的造物主,在代码里搭建起第一个虚空:

javascript 复制代码
const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000)
const renderer = new THREE.WebGLRenderer()

然后,我创造了生命——一个红色的立方体:

javascript 复制代码
const geometry = new THREE.BoxGeometry()
const material = new THREE.MeshBasicMaterial({ color: 0xff0000 })
const cube = new THREE.Mesh(geometry, material)
scene.add(cube)

按下F5。浏览器里,一个完美的、孤独的、静止的红色方块悬浮在黑色虚空中央。

那一刻的震撼,不亚于十年前在IE6里弹出第一个“Hello World”。只是这一次,我不再是那个兴奋的新手,而是一个突然意识到自己无知的探险家——这个简单的立方体背后,是整整一个我从未涉足的几何学、光学、图形学的宇宙。

三、旋转,以及随之而来的眩晕

“让它转起来。”产品经理在需求里轻描淡写地写道。

我找到动画循环的代码,给立方体加上旋转:

javascript 复制代码
function animate() {
  requestAnimationFrame(animate)
  cube.rotation.x += 0.01
  cube.rotation.y += 0.01
  renderer.render(scene, camera)
}

立方体动了。优雅地、匀速地、在三维空间里自转着。

然后我的电脑风扇开始嘶吼。

打开Chrome的性能面板,我看到GPU使用率飙到了90%。那个看似简单的红色方块,正在以每秒60次的频率,让显卡进行着数以万计的矩阵运算。

“性能优化”这个老朋友,以全新的面貌回来了。这次不是网络请求太多,不是DOM操作太频繁,而是顶点数、面片数、着色器复杂度——一套我完全陌生的性能指标体系。

四、光的陷阱

当我想给立方体加上一些“真实感”时,我掉进了光的第一个坑。

“MeshBasicMaterial不需要光,”文档说,“但MeshPhongMaterial需要。”

我换上了Phong材质,然后在场景中添加了一盏点光源:

javascript 复制代码
const light = new THREE.PointLight(0xffffff, 1, 100)
light.position.set(10, 10, 10)
scene.add(light)

刷新页面。立方体消失了。

黑色的虚空里,什么都没有。我花了二十分钟才明白:相机的位置在(0,0,5),看着原点(0,0,0)。立方体在原点。光源在(10,10,10)。而立方体的默认位置……也在原点。

它们重叠了。在三维空间里,我看不见一个被光从侧面照亮、但本身与相机视线重叠的物体。

我移动了立方体。它出现了,一侧被照亮,另一侧陷入阴影——真实世界的物理规律,在代码里第一次如此直观地呈现。

五、加载器的迷宫

真正的需求来了:加载设计师提供的3D模型。

Three.js的加载器家族庞大得令人绝望:GLTFLoader、OBJLoader、FBXLoader、ColladaLoader……每个都有不同的依赖、不同的API、不同的坑。

我选择了GLTF——据说这是“Web的3D JPEG”。下载了Blender,看着那个复杂得像是航天飞机控制面板的界面,默默关掉。最后在某个模型网站买了一个现成的沙发模型。

javascript 复制代码
const loader = new GLTFLoader()
loader.load('models/sofa.gltf', (gltf) => {
  const sofa = gltf.scene
  scene.add(sofa)
}, undefined, (error) => {
  console.error('加载失败:', error)
})

控制台报错:跨域问题。3D模型文件通常很大,需要服务器正确配置MIME类型和CORS。又是一轮与运维的拉扯。

当沙发终于出现在场景中时,它大得像一座山。因为现实世界中的单位(米)和Three.js中的单位(无单位)没有自动换算。我手动缩放:

javascript 复制代码
sofa.scale.set(0.1, 0.1, 0.1)

它变成了正常大小,但材质丢失了——因为贴图路径是绝对路径,而我的服务器上没有那些纹理文件。

六、交互的维度升级

在二维世界里,点击事件是(x, y)坐标。在三维世界里,我需要知道鼠标“指向”三维空间中的哪个物体。

Raycaster(射线投射器)——这个听起来像是科幻武器的工具,成了我的新朋友:

javascript 复制代码
const raycaster = new THREE.Raycaster()
const mouse = new THREE.Vector2()

function onMouseClick(event) {
  // 将鼠标坐标归一化到[-1, 1]
  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 clickedObject = intersects[0].object
    console.log('点击了:', clickedObject)
  }
}

看似简单,但这里藏着第二个性能坑:场景中的物体越多,射线检测的计算量就越大。我需要空间分割算法、需要八叉树、需要所有那些游戏开发领域早已成熟、但前端世界很少接触的概念。

七、移动端的深渊

当我在iPhone上打开这个3D展厅时,灾难发生了。

帧率掉到了个位数。触摸旋转卡顿得像在看PPT。最致命的是:Safari对WebGL的内存限制严格得多,稍微复杂点的模型就直接崩溃。

“移动端性能优化”这个命题,在3D领域被提升到了地狱难度。我学会了:

  • 压缩纹理,从2048x2048降到512x512
  • 减少顶点数,用简化版的模型
  • 合并网格,减少draw call
  • 使用InstancedMesh批量渲染相同物体
  • 在不可见时暂停渲染循环

每一个优化,都意味着与设计师的又一轮拉扯:“这个细节不能删!”“这个纹理质量太差了!”“这和设计稿不一样!”

八、物理的幻觉

产品经理想要“真实感”:“沙发能不能有点弹性?用户点击时微微下陷?”

于是我又引入了Cannon.js——一个物理引擎。现在我的代码里有两个世界:Three.js的渲染世界,和Cannon.js的物理世界。它们需要同步:

javascript 复制代码
// 物理世界中的物体
const sofaBody = new CANNON.Body({ mass: 1 })
sofaBody.addShape(new CANNON.Box(new CANNON.Vec3(1, 0.5, 0.8)))

// 每帧同步位置和旋转
function updatePhysics() {
  world.step(1/60) // 物理世界前进一帧
  
  // 将物理世界的位置同步到渲染世界
  sofa.position.copy(sofaBody.position)
  sofa.quaternion.copy(sofaBody.quaternion)
}

bug出现了:有时候物理模拟会“爆炸”,沙发以光速飞向虚空。因为时间步长不稳定,因为碰撞检测的容差设置不对,因为质量、摩擦力、恢复系数的参数需要像炼金术一样反复调试。

九、3D的“切图仔”

深夜两点,我还在调整一个金属材质的反射率。metalness: 0.8, roughness: 0.2——不是,太亮了。metalness: 0.9, roughness: 0.1——现在看起来像塑料。

我突然笑了。十年前,我作为“切图仔”,在Photoshop里一个像素一个像素地调整边框阴影。十年后,我作为“3D前端”,在代码里一个参数一个参数地调整材质光泽。

技术栈变了,框架变了,维度变了。但有些东西没变:我们依然在实现设计师的视觉幻想,依然在和性能作斗争,依然在深夜里调试那些“看起来差不多但就是不对”的细节。

只是现在,我们的画布从二维变成了三维,我们的调色板从CSS变成了着色器,我们的敌人从IE6变成了GPU内存限制。

十、维度的启示

项目终于上线了。用户可以在网页里360度查看那个沙发,可以切换布料材质,可以看见光影在皮革表面的流动。

产品经理很满意。老板说“这个技术很前沿”。团队里的年轻人兴奋地问我Three.js的学习路线。

但我知道,我只是刚刚推开了3D世界的大门。门后还有:

  • 着色器编程,用GLSL直接与GPU对话
  • 后处理效果,景深、泛光、色彩校正
  • 骨骼动画,让人物模型动起来
  • 粒子系统,火焰、烟雾、魔法特效
  • 自定义几何体,生成程序化地形

前端的世界,因为WebGL和Three.js,突然变得无比深邃。我们不再只是“网页制作人”,我们成了“实时图形程序员”,我们涉足的领域开始与游戏开发、影视特效、工业仿真重叠。

那个红色的立方体还在我的测试页面里旋转着。它简单、纯粹,像十年前第一个HTML页面里的“Hello World”。

但我知道,从这个立方体出发,有一条路通向一个我从未想象过的、属于三维交互的未来。而这条路,才刚刚开始。

我保存了代码,关掉编辑器。窗外天已微亮。

十年了,我还在学习。只是这次,学习的曲线陡峭得像一座三维坐标系里的山峰——有X轴的广度,有Y轴的深度,还有Z轴的高度。

而前端这条路,正因为有了这第三个维度,才真正变得立体起来。