大家好,我是前端西瓜哥。
今天我们来学习三维渲染。
这里需要选定一个三维渲染引擎,这里选择最流行的 threejs。
threejs 是什么
threejs 是一个 javascript 的 3D 库,体积轻量,易于使用,非常流行,可以获取到大量的社区资源。
threejs 在底层渲染器(通常是 WebGL)的基础上封装出一些更高层的概念,像是 Geometry、Material、Camera,然后将它们组织在一起,渲染出一个三维的世界。
这样你就不必用底层的渲染器一点点绘制三角形、直线等图形然后通过三维几何运算组合起来,使用 threejs 能很快就构建出自己想要的三位场景。
先简单理解几个重要概念。
Geometry
Geometry 是 “几何图形” 的意思。
在 threejs 中,会提供很多基础几何图形,比如立方体(BoxGeometry)、圆形(CircleGeometry)等。
它们继承自 BufferGeometry 类,可以通过参数(比如立方体的长宽高),计算生成带有顶点、索引、法线、uv 等信息的对象。
从 WebGL 的角度,Geometry 主要提供了顶点信息。或者可以说 Geometry 的作用是生成了一堆三角面片。
我们先创建一个立方体。
const geometry = new THREE.BoxGeometry(1, 1, 1);
立方体的中心点对着场景的原点位置。
Material
只有三角形面片还不够,那只是一个白模,我们还要要给这些三角面片进行着色。
这里就需要 threejs 的 Material 类了。
Material 是 “材质” 的意思,定义了几何图形的颜色。threejs 也提供了很多 Material 类,如
-
MeshBasicMaterial,基础材质,适用于不想受光照影响的场景,设置是什么颜色或纹理就是什么,不受环境影响;
-
MeshPhongMaterial,一种 Blinn–Phong 反射模型,适合需要高光效果的物体。
-
MeshLambertMaterial;
-
...
这里我们创建一个红色的 phong 材质,这个材质。
const material = new THREE.MeshPhongMaterial({
color: 0x44aa88, // 红色
});
Mesh
三角面片(geometry)有了,着色方案(material)也有了。
下面我们需要用 Mesh 将它们组合起来。
Mesh 就是 “网格” 的意思,在 threejs 代表一个可渲染对象。
创建出 mesh。
const mesh = new THREE.Mesh( geometry, material );
mesh 实例就是一个 3d 模型,它可以设置位置、缩放、旋转等信息,然后添加到场景(scene)上。
Scene
Scence 是 “场景” 的意思,需要渲染出来的三位几何体需要放到场景里才会被渲染出来。
创建 Scene 实例。
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x87ceeb); // 天蓝色
Scene 的背景色默认是黑色,可以自己设置。
将前面创建好的 mesh 添加到场景中。
scene.add(mesh);
mesh 没有设置位置,默认放在 scene 的原点位置,即 (0, 0, 0)。
Camera
场景有了,场景上也三维物体了。下面我们需要观测它,这就引入了三维渲染的经典元素——摄像机(Camera)了。
常用的摄像机有两种,一种是透视相机(PerspectiveCamera),有透视效果,表现为近大远小,是对人眼的透视效果的仿真。
另一种是 正交相机(OrthographicCamera),表现为远近一样大。比如你设置线的长度是 1px,那不管你怎么拉近拉远相机,都一直是 1px。正交相机常用于工业制图、游戏中的伪 2d 效果(如跳一跳)中。
这里我们用透视相机,它更符合人眼的认知直觉。
透视相机的视锥(Viewing frustum)是一个四棱台。
视锥空间内的物体会被渲染出来,这个空间外的部份会被剔除掉。
靠近 near 的线的投影会比远离的线要大,以实现近大远小的效果。
创建一个透视相机。
const fov = 70;
const aspect = width / height;
const near = 0.01;
const far = 1000;
const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
透视相机需要设置很多参数,这些参数以后再慢慢说。
透视相机的位置默认为坐标原点,其实跑到了前面 mesh 物体的内部了。这导致我们看不到这个立方体,因为 threejs 默认会做背面剔除优化。
我们把 camera 远离 mesh 一些。
camera.position.set(1, 1, 5);
camera 还可以设置朝向哪里,可以设置为你需要观测的物体,默认是朝向原点位置。camera 会基于自己的位置和 lookAt 位置旋转摄像机。
camera.lookAt(0, 0, 0);
threejs 坐标系
顺便说一下 threejs 的坐标系。
threejs 的坐标系是遵循 WebGL 的表达,使用右手坐标系,x 轴向右(红色),y 轴向上(绿色),z 轴向用户(蓝色)。
从 2d 升级到 3d 的过程,可以理解为你在看一个壁画,里面的平面的图形突然有了厚度。
这时候你就会开始感受到坐标系各种各样的痛苦了,不同框架的坐标系不同。
就说 2d 的 canvas 2d 和 svg 这些用的是设备坐标系,x 向右,y 向下,属于是左手坐标系了,如果把 2d 坐标直接放到 threejs 上,会发现这个图形倒转了过来,所以这里还要给这些点做一个翻转矩阵运算才行。
还有就是有时候我们希望 z 朝上,而不是朝向用户(比如 blender 软件)。这种情况下 2d 就是铺在桌面上的图纸,转 3d 就是平地起高楼。
有个办法是我们加个容器,让它沿着 x 轴翻转 90 度,然后把物体都放这里面,倒是可以实现。
但是如果你用一些插件,你会发现它们还是基于 z 朝向用户的逻辑,导致效果很奇怪,你可能需要再处理下。
如果你要整合各种模块,但这些模块的坐标系都不统一,那改吧改吧,够你吃一壶的。
Light
下面是打光了。
我们使用的 phong 材质是会受到光照影响的,所以如果没有光,不管设置什么颜色和贴图,对应的都是纯黑色。
因此我们需要提供光照。
我们创建一个直射光对象。
const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
directionalLight.position.set(1, 2, 3);
scene.add(directionalLight);
光的颜色设置为白色,强度为 1,然后放到物体的右上角位置,最后添加到 scene 中。
Renderer
Renderer 是渲染器,作用是将场景(Scene)和摄像机(Camera)组合在一起,将摄像机看到的场景的那部分 3D 场景,投影到 2D 的画布元素上。
每个渲染器实例里面维护了对应的 canvas DOM 元素,我们需要将这个元素添加到 html 上才能看到渲染结果。
通常我们用的渲染器是 WebGLRenderer,基于 WebGL。
const renderer = new THREE.WebGLRenderer({ antialias: true });
threejs 也有面向新一代渲染的 WebGPURenderer,但还是实验性质,问题挺多的,浏览器对 WebGPU 的支持也不稳定,目前生产环境还是不太建议上的。
此外还有 SVGRenderer、CSS3DRenderer,不过也有不少问题,支持的特性也比 WebGLRenderer 少。
设置宽高:
renderer.setSize(width, height);
添加到 html 下:
document.body.appendChild(renderer.domElement);
执行渲染:
renderer.render(scene, camera);
最终渲染效果为:
添加辅助工具
虽然成功渲染了三维物体,但我们只能看这么一个方向,看不到背面,不知道原点在哪里,不确定物体的位置是否正确,以及不知道打光是怎么打的,是否放对了位置。
下面我们将添加几个辅助工具。
首先是坐标轴可视化:
const axesHelper = new THREE.AxesHelper(50);
scene.add(axesHelper);
然后是光照朝向的可视化:
const lightHelper = new THREE.DirectionalLightHelper(directionalLight, 1);
scene.add(lightHelper);
最后可以通过拖拽鼠标移动摄像机位置的控制器 OrbitControls。
左键拖拽会围绕中心点进行轨道移动,滚轮拉远拉近摄像头,右键拖拽是当前观察平面的水平垂直移动。另外它是适配移动端的,整挺好。
特别注意的是 OrbitControls 类不是在核心包里,而是在分离的其他的文件夹下,因为它属于一种扩展功能,没必要让 threejs 的核心包体积过大。
import { OrbitControls } from "three/examples/jsm/Addons.js";
// ...
const controls = new OrbitControls(camera, renderer.domElement);
// 开启了阻尼效果,就是释放鼠标后,摄像头还会短暂慢慢微小移动,可以去掉。
controls.enableDamping = true;
摄像机改变位置,是需要重渲染的,所以我们还要每帧都要主动调用一下更新操作:
renderer.setAnimationLoop(animate);
function animate() {
controls.update();
renderer.render(scene, camera);
}
最终效果
看看最后的效果。
可以看到,没打到光的面是黑色,感兴趣的读者可以试着再加个环境光。
完整代码
import * as THREE from"three";
import { OrbitControls } from"three/examples/jsm/Addons.js";
const width = window.innerWidth;
const height = window.innerHeight;
/***************** scene *****************/
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x87ceeb); // 天蓝色
/***************** mesh *****************/
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshPhongMaterial({
color: 0xff0000,
});
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
/***************** camera *****************/
const fov = 70;
const aspect = width / height;
const near = 0.01;
const far = 1000;
const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
camera.position.set(1, 1, 5);
camera.lookAt(0, 0, 0);
/***************** 光源 *****************/
const directionalLight = new THREE.DirectionalLight(0xffffff, 1); // 方向光
directionalLight.position.set(1, 2, 3);
scene.add(directionalLight);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(width, height);
document.body.appendChild(renderer.domElement);
/***************** 辅助工具 *****************/
const axesHelper = new THREE.AxesHelper(50);
scene.add(axesHelper);
const lightHelper = new THREE.DirectionalLightHelper(directionalLight, 1);
scene.add(lightHelper);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
renderer.render(scene, camera);
// 每帧更新
renderer.setAnimationLoop(animate);
function animate() {
controls.update();
renderer.render(scene, camera);
}
结尾
最近开始学 threejs 了,搞了点简单仿真测试,验证算法是否正确。
这篇开写,算是正式开始写 3d 相关的文章了。
我是前端西瓜哥,关注我,学习更多三维图形知识。