最近开始学Three.js
了。市面上的资料并不算多,系统性的更少。有些教程照着做都是因为版本问题所以可能会卡住。一边学习一边记录一下。
推荐一下学习的资料:
它是基于WebGL
封装的一个库,由于浏览器支持3D,所以可以在浏览器运行。
使用webpack
搭建,你可以借助vscode
中的live serve
插件。webapck
环境搭建如下:
mkdir three-demo
cd three-demo
npm init
npm install webpack webpack-cli webpack-dev-server html-webpack-plugin three
新建public/index.html
:
DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Learn Three.jstitle>
head>
<body>
body>
html>
新建src/index.js
:
import * as THREE from 'three';
let scene, camera, renrender, mesh;
let width = window.innerWidth;
let height = window.innerHeight;
const initScene = () => {
scene = new THREE.Scene();
}
const initCamera = () => {
camera = new THREE.PerspectiveCamera(50, width / height, 0.1, 10000);
camera.position.set(200, 300, 200);
camera.lookAt(scene.position);
}
const addObject = () => {
let geometry = new THREE.BoxGeometry(100, 100, 100);
let meterial = new THREE.MeshBasicMaterial();
mesh = new THREE.Mesh(geometry, meterial);
scene.add(mesh);
}
const initRenderer = () => {
renrender = new THREE.WebGLRenderer();
renrender.setSize(width, height);
renrender.setClearColor('0xFFFFFF', 1);
document.body.appendChild(renrender.domElement);
renrender.render(scene, camera);
}
const init = () => {
initScene();
initCamera();
addObject();
initRenderer();
}
init();
新建webpack.config.js
:
const HtmlWebpackPlugin = require('html-webpack-plugin')
const path = require('path');
module.exports = {
entry: './src/index.js',
plugins: [
new HtmlWebpackPlugin({
title: 'Learn Three.js',
filename: 'index.html',
template: 'public/index.html'
})
],
devServer: {
static: {
directory: path.join(__dirname, 'public'),
},
compress: true,
port: 9000,
}
}
修改package.json
:
"scripts": {
"start": "webpack serve --mode development --open",
"build": "webpack"
},
执行npm run start
,打开 http://localhost:9000/即可发现页面上有一个在旋转的正方体。
在敲three.js
之前,先循序渐进学习一些基础概念。这里先不阐述具体的API,后期实战的时候再补起来。
three.js
需要一个舞台,这个舞台称之为Scene
(场景),场景的创建特别简单:
const scene = new THREE.Scene();
有了舞台之后,需要有一个照相机(Camera
),将舞台上的内容拍摄下来传递给观众。在three.js
中有多种相机类型,我们主要了透视照相机PerspectiveCamera
。这种照相机比较符合现实生活,近处的物体偏大,远处的物体偏小:
const camera = new THREE.PerspectiveCamera(50, width / height, 0.1, 1000);
fov
: 第一个参数是视角,我们人眼差不多可以看到180°的世界,这里推荐视角一般为50°
;aspect
: 渲染横向和纵向比,推荐为window.innerWidth / window.innerHeight
;near
:定义了从距离摄像机多近的距离开始渲染,为了能够从摄像机的位置看到所有物体,所以推荐值为0.1
;far
:定义了摄像机可以看到多远。不能设置太大,因为会影响性能,所以推荐值为1000
。如果在绘制的过程中,你看不到你的物体,那么请检查以下,你的摄像机有没有拍摄到他们。(也可以直接翻到下面坐标系和深入相机那两节)
你要渲染的内容,这里可以有多种创建方式,主要创建流程一般是这样子的:骨架 + 材料 = 物体,将物体放入场景中。
// 先用某种图像类型创建物体骨架
let geometry = new THREE.BoxGeometry(100, 100, 100);
// 用某种材料
let meterial = new THREE.MeshBasicMaterial();
// 物体 = 骨架 + 材料
let mesh = new THREE.Mesh(geometry, meterial);
// 将物体添加到场景中
scene.add(mesh);
渲染器的作用就是将 场景 + 摄像机 + 物体 渲染到浏览器上。渲染器有多种,但是常见的一般使用WebGLRenderer
。
// 创建渲染器
const renrender = new THREE.WebGLRenderer();
// 设置渲染器大小(一般就是渲染整个屏幕)
renrender.setSize(window.innerWidth, window.innerHeight);
// 设置背景颜色
renrender.setClearColor('0xFFFFFF', 1);
// 将渲染器加入到dom中
document.body.appendChild(renrender.domElement);
// 开始渲染
renrender.render(scene, camera);
所以从最前面的源码中我们就不难体会了:
const init = () => {
// 初始化场景,即创建一个舞台
initScene();
// 初始化摄像机,要看到舞台需要借助摄像机
initCamera();
// 往场景中添加物体:舞台上的表演者
addObject();
// 初始化渲染器,借助渲染器将其呈现在页面中
initRenderer();
}
init();
在计算机世界里,3D世界是由点组成的,两个点组成一条直线,三个不在一条直线上的点就能够组成一个三角形面,无数三角形面就可以组成各种性值的物体。所以正方体一共由12个三角形面。
跟点相关的构造函数由Vector2、Vector3
和Vector4
。不同的数值对应着是2D
、3D
还是4D
向量。(4D是啥…回头学习一下)
const addPoint = () => {
const points = [];
// 创建一个(-100, 0, 0)的点
points.push(new THREE.Vector3(-100, 0, 0));
// Vector3是Math方法,不是Object方法(详见文档分类)
const geometry = new THREE.BufferGeometry().setFromPoints(points);
// 使用的是点材质
const material = new THREE.PointsMaterial({
color: 0xff0000,
size: 5.0
});
const point = new THREE.Points(geometry, material);
scene.add(point);
}
点其实就是一个方向像素。
两点组成一条线,跟线有关的Object
构造函数有Line
。接下来我们来画一条白色的线。
const addLine = () => {
const points = [];
points.push(new THREE.Vector3(0, 0, 0));
points.push(new THREE.Vector3(100, 0, 0));
const geometry = new THREE.BufferGeometry().setFromPoints(points);
// 使用的是线材质
const material = new THREE.LineBasicMaterial({
color: '0xffffff'
});
const point = new THREE.Line(geometry, material);
scene.add(point);
}
因为是跟着教程学的,里面提到了一个渐变色线的例子。里面的three
的版本比较老,所以有问题,自己实现了一下:
const addGradientLine = () => {
const points = [];
points.push(new THREE.Vector3(0, 0, 0));
points.push(new THREE.Vector3(100, 0, 0));
var colors = new Float32Array([
1, 0, 0,
0, 1, 0,
0, 0, 1,
1, 1, 0,
0, 1, 1,
1, 0, 1,
]);
const geometry = new THREE.BufferGeometry().setFromPoints(points);
// 每3个就组成一个RGB颜色给一个点
geometry.attributes.color = new THREE.BufferAttribute(colors, 3);
const material = new THREE.LineBasicMaterial({
// 这里是使用顶点的颜色作为线的颜色,由于两点的颜色不同,所以呈现渐变色
vertexColors: THREE.VertexColors,
});
const point = new THREE.Line(geometry, material);
scene.add(point);
}
文档上虽然提供了一个更改线宽度的属性,但是也有说道就是在浏览器下设置跟不设置没啥区别,考虑了性能问题。
.linewidth : Float
Controls line thickness. Default is 1.
Due to limitations of the OpenGL Core Profile with the WebGL renderer on most platforms linewidth will always be 1 regardless of the set value.
线中还有两个构造函数:
LineLoop
: 将线头和线尾封闭起来;LineSegments
:两个点为一组形成一条线const addLoopLine = () => {
const points = [];
points.push(new THREE.Vector3(0, 0, 0));
points.push(new THREE.Vector3(100, 0, 0));
points.push(new THREE.Vector3(0, 100, 0));
const geometry = new THREE.BufferGeometry().setFromPoints(points);
const material = new THREE.LineBasicMaterial({
color: '0xffffff',
});
const point = new THREE.LineLoop(geometry, material);
scene.add(point);
}
const addSegmentsLine = () => {
const points = [];
points.push(new THREE.Vector3(0, 0, 0));
points.push(new THREE.Vector3(100, 0, 0));
points.push(new THREE.Vector3(0, 100, 0));
points.push(new THREE.Vector3(200, 0, 0));
const geometry = new THREE.BufferGeometry().setFromPoints(points);
const material = new THREE.LineBasicMaterial({
color: '0xffffff',
});
const point = new THREE.LineSegments(geometry, material);
scene.add(point);
}
如果点的个数不为偶数,则会忽略多出来的点。
其实从上面我们就不难发现了,三个点可以形成一个面。接下来完成一个作业,画一个围棋棋盘。
const addObject = () => {
const points = [];
points.push(new THREE.Vector3(-500, 0, 0));
points.push(new THREE.Vector3(500, 0, 0));
const geometry = new THREE.BufferGeometry().setFromPoints(points);
const material = new THREE.LineBasicMaterial( { color: 0xffffff, opacity: 0.2 } );
for(let i = 0; i <= 20; i++){
let line = new THREE.Line(geometry, material)
// 分别平行移动到z轴的不同位置
line.position.z = ( i * 50 ) - 500;
scene.add( line );
line = new THREE.Line( geometry, material );
line.position.z = ( i * 50 ) - 500;
line.rotation.y = 90 * Math.PI / 180;
scene.add( line );
}
}
是不是感觉上面的关于坐标的东西有点看不懂,不急,我们接下来研究一下坐标系,再来接释一下上面的坐标转换。
three.js
中含有坐标系,依靠的是右手坐标系,大拇指指向右侧,代表x轴,食指指向里面,代表y轴,中指朝上代表z轴。借助AxesHelper
我们可以在Scene
中绘制坐标轴。
const addAxes = () => {
// 这里50代表坐标轴长度
const axesHelper = new THREE.AxesHelper(50);
scene.add(axesHelper);
}
x轴是红色,y轴代表绿色,而z轴代表蓝色。
回过头来我们看看上面那个绘制棋盘的代码。
for(let i = 0; i <= 20; i++){
let line = new THREE.Line(geometry, material)
// 在z轴方向上,每隔一段距离绘制一条线
line.position.z = ( i * 50 ) - 500;
scene.add( line );
// ...
}
for(let i = 0; i <= 20; i++){
let line = new THREE.Line(geometry, material)
// ...
// 在x轴的方向,每隔一段数据绘制一条线
line.position.x = ( i * 50 ) - 500;
scene.add( line );
}
然后我们需要把这个线绕着y轴旋转90°,这里要记住Math.PI/180就为1°,所以90°的计算方式为90 * Math.PI/180
。即:
for(let i = 0; i <= 20; i++){
let line = new THREE.Line(geometry, material)
// ...
line.position.x = ( i * 50 ) - 500;
line.rotation.y = 90 * Math.PI / 180;
scene.add( line );
}
学完坐标系,我们回过头来学习以下相机的问题。你是否会出现渲染出来啥都没有的情况。那肯定是你的相机设置有问题。关于相机我们要来学习一下position
、lookAt
和up
这几个经常用到的属性。
这是设置相机的站立的位置的,默认情况下它位于原点(0,0,0),我们也可以修改:
camera.position.set(0, 100, 0);
那么此时照相机所在的位置就是在y
轴上。
站好了位置,接下来要告诉照相机拍哪个方向。
// 对着场景中心点拍
camera.lookAt(scene.position);
// 直接指定拍照的点
camera.lookAt({ x : 0, y : 0, z : 0 });
我们拍照的时候可以向上拍,想左拍,像右拍等。three.js
中也有类似的方法,不过是沿着x、y、z
轴拍。
当我们做如下设置的时候,坐标轴会发生变化。
camera.up.x = 0;
camera.up.y = 0,
// z轴为1,表示以z轴为相机的上方。(默认y轴为上方)
camera.up.z = 1;
我们用我们的棋盘例子来距离,默认情况下,我们是y轴向上,所以camera.up
的默认值是0, 1, 0
如果我们设置为1,0,0
,即x轴向上,那么:
const initCamera = () => {
camera = new THREE.PerspectiveCamera(50, width / height, 0.1, 10000);
camera.position.set(200, 300, 200);
camera.up.x = 1;
camera.up.y = 0;
camera.up.z = 0;
camera.lookAt(scene.position);
}
如果设置为0,0,1
,即z
轴向上啦:
const initCamera = () => {
camera = new THREE.PerspectiveCamera(50, width / height, 0.1, 10000);
camera.position.set(200, 300, 200);
camera.up.x = 0;
camera.up.y = 0;
camera.up.z = 1;
camera.lookAt(scene.position);
}
需要注意:如果camera.up
不起作用,一定要看看**camera.up
是否设置在camera.lookAt
前**!
接下来聊一聊动画。前面提过,我们需要使用渲染器,将场景和照相机通过renderer.render()
显示在浏览器上面,如果我们改变了物体的位置等属性,就必须重新调用一次render
函数,才可以将新的场景绘制到浏览器当中去。不然浏览器不会自动刷新场景的。
为了实现循环,我们可以使用requesetAnimationFrame
。这个函数比其setInterval
、setTimeout
有很多优点:
物体动起来有两种方式。一种是本身自己动了,一种是它相对的物体动了。
我们先给场景中加一个正方体:
const addObject = () => {
let geometry = new THREE.BoxGeometry(100, 100, 100);
let meterial = new THREE.MeshBasicMaterial();
mesh = new THREE.Mesh(geometry, meterial);
scene.add(mesh);
}
然后我们移动以下摄像机,往x轴方向走,这样物体看起来就是往x轴的反方向走了。
const render = () => {
requestAnimationFrame(render);
camera.position.x += 1;
renrender.render(scene, camera);
}
const render = () => {
requestAnimationFrame(render);
mesh.position.x += 1;
renrender.render(scene, camera);
}
帧率可以理解成是屏幕刷新率。现在最佳的帧率是60fps,因为现在设备一般使用60HZ,显示器每秒刷新60次,这样页面才不会卡顿。
我们可以使用state.js
来获得查看three.js
中的动画性能。
npm install stats.js
import * as STATS from 'stats.js';
// ...
// 在页面左上角中显示它
const initState = () => {
stats = new STATS();
stats.setMode(0); // 0: fps, 1: ms
stats.domElement.style.position = 'absolute';
stats.domElement.style.left = '0px';
stats.domElement.style.top = '0px';
document.body.appendChild( stats.domElement );
}
// 每次重新render的时候,调用更新
const render = () => {
requestAnimationFrame(render);
mesh.rotation.x += 0.01;
renrender.render(scene, camera);
// 更新,得到fps
stats.update();
}
当帧数较低的时候,你就要注意了,可能是你的代码性能太低了造成的。一般情况下,帧数都可以跑到60的。
可以借助第三方库来实现动画效果。
npm i @tweenjs/tween.js
将我们的正方体每隔3s,将往x轴的反方向移动400单位。
import * as TWEEN from '@tweenjs/tween.js';
// ...
const render = () => {
requestAnimationFrame(render);
new TWEEN.Tween(mesh.position)
.to({x: -400}, 3000)
.repeat(Infinity)
.start();
renrender.render(scene, camera);
stats.update();
// 记得也要不断的更新Tween
TWEEN.update();
}
学过物理的都知道,世界上有了光,反射到我们的眼睛里,我们才看得见东西。没有光的时候我们是看不到任何物体的。在three.js
中也是同理的。你可能会问,为什么前面没有涉及到光,但是渲染出来的东西还可以被看到,因为我们采用了基础材质,它与光无光,下面一节会讲到,如果使用了一些需要有光才可以看到的材质,那么就一定要设置光源了。
光源有个基类叫做Light
,派生了很多种光源。我们来了解一下派生的光源。
THREE.AmbientLight
: 环境光源,该光源的颜色将会叠加到场景现有的物体的颜色上,颜色会应用到全局,没有特别的来源方向,可以弱化阴影或者添加一些额外的颜色;THREE.PointLight:
点光源,从空间的一点向所有方向发射光线,不可以投射阴影;THREE.SpotLight
: 聚光源,可以投射阴影;THREE.DirectionalLight
: 平行光,可以创建阴影,其光强并不会随着目标对象距离的增大而减弱;THREE.HemisphereLight
: 自然光,无法创建阴影,它考虑了天空和地面的反射;THREE.AreaLight
: 区域光源,可以从一个很大的区域发射光线,而不是一个点,无法创建阴影;THREE.LensFlare
: 这不是光源,但是可以给场景添加光晕效果。修改前面的正方体例子,修改材质为MeshLambertMaterial
:
const addObject = () => {
let geometry = new THREE.BoxGeometry(100, 100, 100);
let meterial = new THREE.MeshLambertMaterial();
mesh = new THREE.Mesh(geometry, meterial);
scene.add(mesh);
}
此时是看不到的物体的,因为没有光。
如果我们加入了光之后(这里加入了环境光):
const addLight = () => {
let light = new THREE.AmbientLight( 0xffffff );
scene.add(light);
}
物体就看得到了。
前面提到了,材质由于有了光源可以被大家伙看到了(基本材质不需要光源)。我们来介绍几种常见的材质:
MeshBasicMaterial
: 网络基础材质,用于给几何体赋予一种简单的颜色,可以显示几何体的线框;
MeshLambertMaterial: 考虑光照影响的材质,用于创建暗淡的、不光亮的物体;
MeshPhongMaterial
: 考虑光照影响的材质,用于创建光亮的物体;
LineBasicMaterial
: 用户直线几何体,用来创建着色的直线;
MeshDepthMaterial
: 外观的颜色深度是由物体到摄像机的距离决定的。
纹理,你可以理解成喷漆、衣服啥的。我们画一个物体,有些内容我们还是无法自己纯代码写出来的,所以可以借助设计师,让他们为我们的物体提供一个“衣服”。纹理类叫做Texture
。
THREE.Texture( image, mapping, wrapS, wrapT, magFilter, minFilter, format, type, anisotropy )
image
: 是图片路径;mapping
:表示的是纹理坐标;wrapS
:表示x轴的纹理的回环方式,就是当纹理的宽度小于需要贴图的平面的宽度的时候,平面剩下的部分应该p以何种方式贴图的问题;wrapT
:表示y轴的纹理回环方式;magFilter
和minFilter
表示过滤的方式;format
:表示加载的图片的格式,这个参数可以取值THREE.RGBAFormat
,RGBFormat
等。THREE.RGBAFormat
表示每个像素点要使用四个分量表示,分别是红、绿、蓝、透明来表示。RGBFormat
则不使用透明,也就是说纹理不会有透明的效果;type
:表示存储纹理的内存的每一个字节的格式,是有符号,还是没有符号,是整形,还是浮点型。不过这里默认是无符号型;anisotropy
:各向异性过滤。使用各向异性过滤能够使纹理的效果更好,但是会消耗更多的内存、CPU、GPU时间。在正常的情况下,你在0.0到1.0的范围内指定纹理坐标。当我们用一幅图来做纹理的时候,那么这幅图就隐示的被赋予了如图一样的纹理坐标,这个纹理坐标将被对应到一个形状上。
const addObject = () => {
const geometry = new THREE.BoxGeometry(100, 100, 100);
const texture = new THREE.TextureLoader().load('img/1.jpg');
const meterial = new THREE.MeshBasicMaterial({
map: texture
});
mesh = new THREE.Mesh(geometry, meterial);
scene.add(mesh);
}
使用TextureLoader
加载纹理图片,然后赋值给MeshBasicMaterial
的map
属性。这时候你会发现你的页面可能会出现问题。
首先图片可能会出现跨域,所以我们建议运行的时候采用服务器运行,因为我们使用的是webpack
所以没有这个问题。将图片放置在public/img
下,这样我们直接访问http://localhost:9000/img/1.jpg
就可以直接访问到,这也是为什么上面的路径是这么写的原因。
接下来重中之重,load
图片的过程是异步的,意味着你现在渲染其实是整个页面是黑的,图片还没加载完成,前面我们提到过如果物体的位置、颜色等属性发生变化的时候,应该重新渲染才对,所以我们要采用requestAnimationFrame
刷新页面。
const render = () => {
requestAnimationFrame(render);
renrender.render(scene, camera);
}
最终例子如下:
import * as THREE from 'three';
let scene, camera, renrender, mesh;
let width = window.innerWidth;
let height = window.innerHeight;
const initScene = () => {
scene = new THREE.Scene();
}
const initCamera = () => {
camera = new THREE.PerspectiveCamera(50, width / height, 0.1, 10000);
camera.position.set(200, 300, 200);
camera.lookAt(scene.position);
}
const addObject = () => {
const geometry = new THREE.BoxGeometry(100, 100, 100);
const texture = new THREE.TextureLoader().load('img/1.jpg');
const meterial = new THREE.MeshBasicMaterial({
map: texture
});
mesh = new THREE.Mesh(geometry, meterial);
scene.add(mesh);
}
const initRenderer = () => {
renrender = new THREE.WebGLRenderer();
renrender.setSize(width, height);
document.body.appendChild(renrender.domElement);
renrender.render(scene, camera);
}
const render = () => {
requestAnimationFrame(render);
renrender.render(scene, camera);
}
const init = () => {
initScene();
initCamera();
addObject();
initRenderer();
render();
}
init();
如有错误,欢迎指出,感谢阅读~