跳至主要內容

01.three.js学习之路

pinia原创大约 106 分钟3D可视化three.js

工具

  • 编辑器:VScode Atom sublime
  • 代码样式检查插件:linter formatters

官方

PARCEL打包工具

three.js的起步之旅

初始化设置

1.初始设置

  1. 准备一个html

2.创建场景

npm
npm install three
// 导入three.js
import * as THREE from 'three';
//1.创建一个场景
const scene = new THREE.Scene();

3.常见相机

//2.创建一个相机
//摄像机视锥体垂直视野角度
const fov = 75;
//摄像机视锥体长宽比
const aspect = window.innerWidth / window.innerHeight;
//摄像机视锥体近端面
const near = 0.1;
//摄像机视锥体远端面
const far = 1000;
const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
//相机的位置
camera.position.set(0, 0, 10);
//把相机添加到场景中
scene.add(camera);

4.创建可见对象

//3.添加一个物体
//创建一个立方体
const cubeGeometry = new THREE.BoxGeometry( 1, 1, 1 );
//创建一个材质
const cubeMaterial = new THREE.MeshBasicMaterial( { color: 0xffff00 } );
//根据几何体和材质创建一个网格
const cube = new THREE.Mesh( cubeGeometry, cubeMaterial );
//把网格添加到场景中
scene.add( cube );

5.创建渲染器

//4.创建一个渲染器
const renderer = new THREE.WebGLRenderer();
//设置渲染器的大小
renderer.setSize( window.innerWidth, window.innerHeight );
console.log(renderer)
//把渲染器添加到页面中
document.body.appendChild( renderer.domElement );

6.渲染场景

  1. 每一帧根据控制器更新画面

    因为控制器监听鼠标事件之后,要根据鼠标的拖动,来控制相机围绕目标运动,并根据运动之后的效果,显示出画面来。为了保证画面流畅渲染,选择使用请求动画帧requestAnimationFrame,在屏幕渲染下一帧画面时触发回调函数来执行画面的渲染。

  2. requestAnimationFrame

    是HTML5的新特性,区别于setTimeout和setInterval。requestAnimationFrame比后两者精确,采用系统时间间隔,保持最佳绘制效率,不会因为间隔时间过短,造成过度绘制,增加开销;也不会因为间隔时间太长,使动画卡顿不流畅,让各种网页动画效果能够有一个统一的刷新机制,从而节省系统资源,提高系统性能,改善视觉效果。

    requestAnimationFrame是由浏览器专门为动画提供的API,在运行时浏览器会自动优化方法的调用,并且如果页面不是激活状态下的话,动画会自动暂停,有效节省了CPU开销。

    因此屏幕每一帧都刷新一次画面,就需要执行

//5.创建一个场景通过相机和场景进行渲染
//一个渲染函数,由于浏览器刷新频率是60帧,所以每秒会调用60次
function render() {
    // 渲染器渲染场景
    renderer.render(scene, camera);
    // 下一帧调用render函数
    requestAnimationFrame(render);
    //如果后期需要控制器带有阻尼效果,或者自动旋转等效果,就需要加入controls.update()
  	controls.update()
}
render()

//方案二.创建一个场景通过相机和场景进行渲染
//一个渲染函数,由于浏览器刷新频率是60帧,所以每秒会调用60次
function render(time) {
    let t = time/1000%5
    // 物体的位置
    cube.position.x = t;
    cube.scale.x = t;
    cube.rotation.x = t;
    // 渲染器渲染场景
    renderer.render(scene, camera);
    // 下一帧调用render函数
    requestAnimationFrame(render);
}
render()

//最优方案:
// 设置时钟
//clock对象来跟踪时间
const clock = new THREE.Clock();

function render() {
    // 获取时钟运行的总时长
    let time = clock.getElapsedTime();
    // let deltaTime = clock.getDelta();
    // console.log('时钟运行的总时长',time)
    // console.log('俩次获取时间之间的间隔时间',deltaTime)
    cube.position.x = time % 5
    // 渲染器渲染场景
    renderer.render(scene, camera);
    // 下一帧调用render函数
    requestAnimationFrame(render);
}
render()

7.创建一个坐标轴

  1. 坐标辅助器

    一般我们在开发阶段,添加物体和设置物体位置,都需要参考一下坐标轴,方便查看是否放置到对应位置。所以一般添加坐标轴辅助器来作为参考,辅助器简单模拟3个坐标轴的对象。红色代表 X 轴. 绿色代表 Y 轴. 蓝色代表 Z 轴。

//6.创建一个坐标轴辅助器
const axesHelper = new THREE.AxesHelper( 5 );
scene.add( axesHelper );
  1. ArrowHelper箭头辅助器

    用于模拟方向的3维箭头对象

const dir = new THREE.Vector3( 1, 2, 0 );

//normalize the direction vector (convert to vector of length 1)
dir.normalize();

const origin = new THREE.Vector3( 0, 0, 0 );
const length = 1;
const hex = 0xffff00;

const arrowHelper = new THREE.ArrowHelper( dir, origin, length, hex );
scene.add( arrowHelper )
  1. 构造函数
ArrowHelper(dir : Vector3, origin : Vector3, length : Number, hex : Number, headLength : Number, headWidth : Number )

/**
dir -- 基于箭头原点的方向. 必须为单位向量. 
origin -- 箭头的原点. 
length -- 箭头的长度. 默认为 1. 
hex -- 定义的16进制颜色值. 默认为 0xffff00. 
headLength -- 箭头头部(锥体)的长度. 默认为箭头长度的0.2倍(0.2 * length). 
headWidth -- The width of the head of the arrow. Default is 0.2 * headLength.
**/

8.创建一个轨道控制器:必须传入2个参数:

  1. 相机,让哪一个相机围绕目标运动。默认目标是原点。立方体在原点处。
  2. 渲染的画布dom对象,用于监听鼠标事件控制相机的围绕运动
// 导入轨道控制器
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
//7.创建一个轨道控制器
const controls = new OrbitControls(camera, renderer.domElement);

9.综上所述代码

import * as THREE from "three";
// 导入轨道控制器
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";

// console.log(THREE);

// 目标:使用控制器查看3d物体

// 1、创建场景
const scene = new THREE.Scene();

// 2、创建相机
const camera = new THREE.PerspectiveCamera(
  75,
  window.innerWidth / window.innerHeight,
  0.1,
  1000
);

// 设置相机位置
camera.position.set(0, 0, 10);
scene.add(camera);

// 添加物体
// 创建几何体
const cubeGeometry = new THREE.BoxGeometry(1, 1, 1);
const cubeMaterial = new THREE.MeshBasicMaterial({ color: 0xffff00 });
// 根据几何体和材质创建物体
const cube = new THREE.Mesh(cubeGeometry, cubeMaterial);
// 将几何体添加到场景中
scene.add(cube);

// 初始化渲染器
const renderer = new THREE.WebGLRenderer();
// 设置渲染的尺寸大小
renderer.setSize(window.innerWidth, window.innerHeight);
// console.log(renderer);
// 将webgl渲染的canvas内容添加到body
document.body.appendChild(renderer.domElement);

// // 使用渲染器,通过相机将场景渲染进来
// renderer.render(scene, camera);

// 创建轨道控制器
const controls = new OrbitControls(camera, renderer.domElement);

// 添加坐标轴辅助器
const axesHelper = new THREE.AxesHelper(5);
scene.add(axesHelper);

function render() {
  renderer.render(scene, camera);
  //   渲染下一帧的时候就会调用render函数
  requestAnimationFrame(render);
}

render();

物体的位置

1.物体的移动(position)

cube.position.set(x,y,z)
cube.position.x = 0.2
cube.position.y = 0.2
cube.position.z = 0.2

2.物体的缩放(scale)

cube.scale.set(x,y,z)
cube.scale.x = 0.2
cube.scale.y = 0.2
cube.scale.z = 0.2

3.物体的旋转(rotation)

cube.rotation.set(Math.PI/4,0,0,'XYZ')
cube.rotation.x += 0.1

动画

1.动画帧数

  • 动画帧数要根据时间来创建
//5.创建一个场景通过相机和场景进行渲染
//一个渲染函数,由于浏览器刷新频率是60帧,所以每秒会调用60次
function render(time) {
    let t = time/1000%5
    // 物体的位置
    cube.position.x = t;
    cube.scale.x = t;
    cube.rotation.x = t;
    // 渲染器渲染场景
    renderer.render(scene, camera);
    // 下一帧调用render函数
    requestAnimationFrame(render);
}
render()

2.时间函数(Clock)

//clock对象来跟踪时间
const clock = new THREE.Clock();
let time = clock.getElapsedTime();//getElapsedTime ()获取自时钟启动后的秒数,时钟运行的总时长。
let deltaTime = clock.getDelta();//getDelta () 获取2帧之间的时间间隔。

3.动画库gsap

npm
npm install gsap
//导入gsap
import gsap from 'gsap';
//设置动画
gsap.to(cube.position,{ duration: 1, x: 2,  });
let animation = gsap.to(cube.position, {
    duration: 5, //动画时长
    x: 2 * Math.PI, //x轴旋转角度
    ease: "elastic.out(1, 0.3)", //缓动函数
    repeat: -1, //重复次数,-1为一直重复。
    yoyo: true, //往返运动,如果为 true,则每隔一个重复,补间将沿相反方向运行。(像悠悠球一样)默认值:false
    delay: 2, //延迟时间
    stagger: 0.2,//这是我们最喜欢的技巧之一!如果补间有多个目标,您可以轻松地在每个动画的开始之间添加一些交错效果:
    onStart: () => console.log('动画开始'), //动画开始函数
    onUpdate: () => console.log('动画更新'), //动画更新函数
    onComplete: () => console.log('动画完成'),//动画完成函数
});

window.addEventListener('dblclick', () => {
    animation.isActive() ? animation.pause() : animation.resume()//判断动画是否在运行
})

4.时间线

  • 时间线是创建易于调整、有弹性的动画序列的关键。当您将补间添加到时间线时,默认情况下,它们会按照添加的顺序一个接一个地播放。
// 创建时间线动画
let tl = gsap.timeline()

// 现在用tl代替以前的gsap来设置动画即可。
tl.to(".green", { x: 600, duration: 2 });
tl.to(".purple", { x: 600, duration: 1 });
tl.to(".orange", { x: 600, duration: 1 });

5.正确处理动画运动

  • 使用方式
    • window.requestAnimationFrame() 告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行。
function callback(){
  //下一帧渲染画面前,需要执行处理的函数
}
window.requestAnimationFrame(callback);
  • 请求动画帧间隔不固定
    • 当你准备更新动画时你应该调用此方法。这将使浏览器在下一次重绘之前调用你传入给该方法的动画函数 (即你的回调函数)。回调函数执行次数通常是每秒 60 次,但在大多数遵循 W3C 建议的浏览器中,回调函数执行次数通常与浏览器屏幕刷新次数相匹配。因此具体回调函数执行的间隔时间跟屏幕刷新次数、当前页面运行时负荷等因素有关。

控制器(OrbitControls)

1.阻尼

const controls = new OrbitControls(camera, renderer.domElement);
//设置控制器阻尼,使控制器有真实的效果,必须在render动画函数中调用controls.update()
controls.enableDamping = true;
function render() {
    controls.update()
    renderer.render(scene, camera);
    // 下一帧调用render函数
    requestAnimationFrame(render);
}
render()

画布自适应屏幕大小与全屏

监听渲染器变化,实时更新画面

//监听画面的变化,当画面发生变化时,重新设置渲染器的大小
window.addEventListener('resize', () => {
    renderer.setSize(window.innerWidth, window.innerHeight);//重新设置渲染器的大小
    camera.aspect = window.innerWidth / window.innerHeight;//重新设置相机的长宽比
    camera.updateProjectionMatrix();//更新相机的摄影矩阵
    renderer.setPixelRatio(window.devicePixelRatio)//设置渲染器像素比,防止画面模糊
})

双击全屏效果,双击推出全屏

//双击控制屏幕进入全屏,双击退出全屏
window.addEventListener('dblclick', () => {
    // !document.fullscreenElement ? renderer.domElement.requestFullscreen() : document.exitFullscreen()
    document.fullscreenElement ? document.exitFullscreen() : renderer.domElement.requestFullscreen()
})

UI界面控制库(dat.gui)

npm
npm install dat.gui --save-dev
//导入dat.gui
import * as dat from 'dat.gui';
import {color} from "dat.gui";
//创建dat.gui
const gui = new dat.GUI();

//创建一个对象
const paramsColor = {
    color: "#ff0000",
    fn:() => {
        //让立方体的颜色随机变化
        cube.material.color.set(Math.random() * 0xffffff)
    }
}

轴移动

//创建一个控制器
gui.add(cube.position, 'x').min(0).max(5).step(0.01).name('x轴位置').listen().onChange((value) => {
    console.log('值被修改了', value)
}).onFinishChange((value) => {
    console.log('值修改完成', value)
})
// gui.add(cube.position, 'x', 0, 5, 0.01).name('x轴位置').listen().onChange((value) => {
//     console.log(value)
// })

颜色选择器

//修改物体的颜色
gui.addColor(paramsColor, 'color').name('颜色').onChange((value) => {
    cube.material.color.set(value)
    cube.material.color.set(value)
})

物体是否显示

//修改物体是否显示
gui.add(cube, 'visible').name('是否显示').onChange((value) => {
    console.log(value)
})

设置按钮,触发某个事件

//设置按钮,点击按钮时触发某个事件
gui.add(paramsColor, 'fn').name('随机颜色')

文件夹(addFolder)

//设置一个文件夹
const folder = gui.addFolder('设置立方体')
folder.add(cube.material,'wireframe').name('是否显示线框')

物体的线框(wireframe)

folder.add(cube.material,'wireframe').name('是否显示线框')

全面认识物体(Geometry)

1.3D基础

Three.js经常会和WebGL混淆, 但也并不总是,three.js其实是使用WebGL来绘制三维效果的。 WebGL是一个只能画点、线和三角形的非常底层的系统. 想要用WebGL来做一些实用的东西通常需要大量的代码, 这就是Three.js的用武之地。它封装了诸如场景、灯光、阴影、材质、贴图、空间运算等一系列功能,让你不必要再从底层WebGL开始写起。

这套教程假设你已经了解了JavaScript,且大部分内容会使用 ES6的语法。点击这里查看你需要提前掌握的东西。 大部分支持Three.js的浏览器都会自动更新,所以绝大多数用户应该都能运行本套教程的代码。 如果你想在非常老的浏览器上运行此代码, 你需要一个像Babel一样的语法编译器 。 当然使用非常老的浏览器的用户可能根本不能运行Three.js。

人们在学习大多数编程语言的时候第一件事就是让电脑打印个"Hello World!"。 对于三维来说第一件事往往是创建一个三维的立方体。 所以我们从"Hello Cube!"开始。

在我们开始前,让我们试着让你了解一下一个three.js应用的整体结构。一个three.js应用需要创建很多对象,并且将他们关联在一起。下图是一个基础的three.js应用结构。

img
img

上图需要注意的事项:

  • 首先有一个渲染器(Renderer)。这可以说是three.js的主要对象。你传入一个场景(Scene)和一个摄像机(Camera)到渲染器(Renderer)中,然后它会将摄像机视椎体中的三维场景渲染成一个二维图片显示在画布上。
  • 其次有一个场景图 它是一个树状结构,由很多对象组成,比如图中包含了一个场景(Scene)对象 ,多个网格(Mesh)对象,光源(Light)对象,群组(Group),三维物体(Object3D),和摄像机(Camera)对象。一个场景(Scene)对象定义了场景图最基本的要素,并包了含背景色和雾等属性。这些对象通过一个层级关系明确的树状结构来展示出各自的位置和方向。子对象的位置和方向总是相对于父对象而言的。比如说汽车的轮子是汽车的子对象,这样移动和定位汽车时就会自动移动轮子。你可以在场景图的这篇文章中了解更多内容。注意图中摄像机(Camera)是一半在场景图中,一半在场景图外的。这表示在three.js中,摄像机(Camera)和其他对象不同的是,它不一定要在场景图中才能起作用。相同的是,摄像机(Camera)作为其他对象的子对象,同样会继承它父对象的位置和朝向。在场景图这篇文章的结尾部分有放置多个摄像机(Camera)在一个场景中的例子。
  • 网格(Mesh)对象可以理解为用一种特定的材质(Material)来绘制的一个特定的几何体(Geometry)。材质(Material)和几何体(Geometry)可以被多个网格(Mesh)对象使用。比如在不同的位置画两个蓝色立方体,我们会需要两个网格(Mesh)对象来代表每一个立方体的位置和方向。但只需一个几何体(Geometry)来存放立方体的顶点数据,和一种材质(Material)来定义立方体的颜色为蓝色就可以了。两个网格(Mesh)对象都引用了相同的几何体(Geometry)和材质(Material)。
  • 几何体(Geometry)对象顾名思义代表一些几何体,如球体、立方体、平面、狗、猫、人、树、建筑等物体的顶点信息。Three.js内置了许多基本几何体 。你也可以创建自定义几何体或从文件中加载几何体。
  • 材质(Material)对象代表绘制几何体的表面属性,包括使用的颜色,和光亮程度。一个材质(Material)可以引用一个或多个纹理(Texture),这些纹理可以用来,打个比方,将图像包裹到几何体的表面。
  • 纹理(Texture)对象通常表示一幅要么从文件中加载,要么在画布上生成,要么由另一个场景渲染出的图像。
  • 光源(Light)对象代表不同种类的光。

有了以上基本概念,我们接下来就来画个下图所示的"Hello Cube"吧。

img
img

首先是加载three.js

<script type="module">
  import * as THREE from '../../build/three.module.js';
</script>

把type="module"放到script标签中很重要。这可以让我们使用import关键字加载three.js。还有其他的方法可以加载three.js,但是自r106开始,使用模块是最推荐的方式。模块的优点是可以很方便地导入需要的其他模块。这样我们就不用再手动引入它们所依赖的其他文件了。

下一步我们需要一个canvas标签。

<body>
  <canvas id="c"></canvas>
</body>

Three.js需要使用这个canvas标签来绘制,所以我们要先获取它然后传给three.js。

<script type="module">
import * as THREE from '../../build/three.module.js';
 
function main() {
  const canvas = document.querySelector('#c');
  const renderer = new THREE.WebGLRenderer({canvas});
  ...
</script>

拿到canvas后我们需要创建一个WebGL渲染器(WebGLRenderer)。渲染器负责将你提供的所有数据渲染绘制到canvas上。之前还有其他渲染器,比如CSS渲染器(CSSRenderer)、Canvas渲染器(CanvasRenderer)。将来也可能会有WebGL2渲染器(WebGL2Renderer)或WebGPU渲染器(WebGPURenderer)。目前的话是WebGL渲染器(WebGLRenderer),它通过WebGL将三维空间渲染到canvas上。

注意这里有一些细节。如果你没有给three.js传canvas,three.js会自己创建一个 ,但是你必须手动把它添加到文档中。在哪里添加可能会不一样这取决你怎么使用, 我发现给three.js传一个canvas会更灵活一些。我可以将canvas放到任何地方, 代码都会找到它,假如我有一段代码是将canvas插入到文档中,那么当需求变化时, 我很可能必须去修改这段代码。

接下来我们需要一个透视摄像机(PerspectiveCamera)。

const fov = 75;
const aspect = 2;  // 相机默认值
const near = 0.1;
const far = 5;
const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);

fov是视野范围(field of view)的缩写。上述代码中是指垂直方向为75度。 注意three.js中大多数的角用弧度表示,但是因为某些原因透视摄像机使用角度表示。

aspect指画布的宽高比。我们将在别的文章详细讨论,在默认情况下 画布是300x150像素,所以宽高比为300/150或者说2。

near和far代表近平面和远平面,它们限制了摄像机面朝方向的可绘区域。 任何距离小于或超过这个范围的物体都将被裁剪掉(不绘制)。

这四个参数定义了一个 "视椎(frustum)"。 视椎(frustum)是指一个像被削去顶部的金字塔形状。换句话说,可以把"视椎(frustum)"想象成其他三维形状如球体、立方体、棱柱体、截椎体。 img

近平面和远平面的高度由视野范围决定,宽度由视野范围和宽高比决定。

视椎体内部的物体将被绘制,视椎体外的东西将不会被绘制。

摄像机默认指向Z轴负方向,上方向朝向Y轴正方向。我们将会把立方体放置在坐标原点,所以我们需要往后移一下摄像机才能显示出物体。

camera.position.z = 2;

下图是我们想要达到的效果。

img
img

我们能看到摄像机的位置在z = 2。它朝向Z轴负方向。我们的视椎体范围从摄像机前方0.1到5。因为这张图是俯视图,视野范围会受到宽高比的影响。画布的宽度是高度的两倍,所以水平视角会比我们设置的垂直视角75度要大。

然后我们创建一个场景(Scene)。场景(Scene)是three.js的基本的组成部分。需要three.js绘制的东西都需要加入到scene中。 我们将会在场景是如何工作的一文中详细讨论。

const scene = new THREE.Scene();

然后创建一个包含盒子信息的立方几何体(BoxGeometry)。几乎所有希望在three.js中显示的物体都需要一个包含了组成三维物体的顶点信息的几何体。

const boxWidth = 1;
const boxHeight = 1;
const boxDepth = 1;
const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);

然后创建一个基本的材质并设置它的颜色. 颜色的值可以用css方式和十六进制来表示。

const material = new THREE.MeshBasicMaterial({color: 0x44aa88});

再创建一个网格(Mesh)对象,它包含了:

  1. 几何体(Geometry)(物体的形状)
  2. 材质(Material)(如何绘制物体,光滑还是平整,什么颜色,什么贴图等等)
  3. 对象在场景中相对于他父对象的位置、朝向、和缩放。下面的代码中父对象即为场景对象。
const cube = new THREE.Mesh(geometry, material);

最后我们将网格添加到场景中。

scene.add(cube);

之后将场景和摄像机传递给渲染器来渲染出整个场景。

renderer.render(scene, camera);
img
img

很难看出来这是一个三维的立方体,因为我们直视Z轴的负方向并且立方体和坐标轴是对齐的,所以我们只能看到一个面。

我们来让立方体旋转起来,以便更好的在三维环境中显示。为了让它动起来我们需要用到一个渲染循环函数 requestAnimationFrame.

代码如下:

function render(time) {
  time *= 0.001;  // 将时间单位变为秒
 
  cube.rotation.x = time;
  cube.rotation.y = time;
 
  renderer.render(scene, camera);
 
  requestAnimationFrame(render);
}
requestAnimationFrame(render);

requestAnimationFrame函数会告诉浏览器你需要显示动画。传入一个函数作为回调函数。本例中的函数是render函数。如果你更新了跟页面显示有关的任何东西,浏览器会调用你传入的函数来重新渲染页面。我们这里是调用three.js的renderer.render函数来绘制我们的场景。

requestAnimationFrame会将页面开始加载到函数运行所经历的时间当作入参传给回调函数,单位是毫秒数。但我觉得用秒会更简单所以我将它转换成了秒。

然后我们把立方体的X轴和Y轴方向的旋转角度设置成这个时间。这些旋转角度是弧度制。一圈的弧度为2Π所以我们的立方体在每个方向旋转一周的时间为6.28秒。

最后渲染我们的场景并调用另一个帧动画函数来继续我们的循环。

回调函数之外在主进程中我们调用一次requestAnimationFrame来开始整个渲染循环。

img
img

效果好了一些但还是很难看出是三维的。我们来添加些光照效果,应该会有点帮助。three.js中有很多种类型的灯光,我们将在后期文章中详细讨论。现在我们先创建一盏平行光。

const color = 0xFFFFFF;
const intensity = 1;
const light = new THREE.DirectionalLight(color, intensity);
light.position.set(-1, 2, 4);
scene.add(light);

平行光有一个位置和目标点。默认值都为(0, 0, 0)。我们这里 将灯光的位置设为(-1, 2, 4),让它位于摄像机前面稍微左上方一点的地方。目标点还是(0, 0, 0),让它朝向坐标原点方向。

我们还需要改变下立方体的材质。MeshBasicMaterial材质不会受到灯光的影响。我们将他改成会受灯光影响的MeshPhongMaterial材质。

const material = new THREE.MeshPhongMaterial({color: 0x44aa88});  // 绿蓝色

这是我们新的项目结构

img
img

下面开始生效了。

img
img

现在应该可以很清楚的看出是三维立方体了。

我们再添加两个立方体来增添点趣味性。

每个立方体会引用同一个几何体和不同的材质,这样每个立方体将会是不同的颜色。

首先我们创建一个根据指定的颜色生成新材质的函数。它会根据指定的几何体生成对应网格,然后将网格添加进场景并设置其X轴的位置。

function makeInstance(geometry, color, x) {
  const material = new THREE.MeshPhongMaterial({color});
 
  const cube = new THREE.Mesh(geometry, material);
  scene.add(cube);
 
  cube.position.x = x;
 
  return cube;
}

然后我们将用三种不同的颜色和X轴位置调用三次函数,将生成的网格实例存在一个数组中。

const cubes = [
  makeInstance(geometry, 0x44aa88,  0),
  makeInstance(geometry, 0x8844aa, -2),
  makeInstance(geometry, 0xaa8844,  2),
];

最后我们将在渲染函数中旋转三个立方体。我们给每个立方体设置了稍微不同的旋转角度。

function render(time) {
  time *= 0.001;  // 将时间单位变为秒
 
  cubes.forEach((cube, ndx) => {
    const speed = 1 + ndx * .1;
    const rot = time * speed;
    cube.rotation.x = rot;
    cube.rotation.y = rot;
  });
 
  ...

这里是结果。

img
img

如果你对比上面的示意图可以看到此效果符合我们的预想。位置为X = -2 和 X = +2的立方体有一部分在我们的视椎体外面。他们大部分是被包裹的,因为水平方向的视角非常大。

我们的项目现在有了这样的结构

img
img

正如你看见的那样,我们有三个网格(Mesh)引用了相同的立方几何体(BoxGeometry)。每个网格(Mesh)引用了一个单独的MeshPhongMaterial材质来显示不同的颜色。

2.图元与3D形状

Three.js 有很多图元。图元就是一些 3D 的形状,在运行时根据大量参数生成。

使用图元是种很常见的做法,像使用球体作为地球,或者使用大量盒子来绘制 3D 图形。 尤其是用来试验或者刚开始学习 3D。 对大多数 3D 应用来说,更常见的做法是让美术在 3D 建模软件中创建 3D 模型, 像 Blender,Maya 或者 Cinema 4D。 之后在这个系列中,我们会涵盖到创建和加载来自 3D 建模软件的模型。 现在,让我们仅使用可以获得的图元。

基于 BufferGeometry 的图元是面向性能的类型。 几何体的顶点是直接生成为一个高效的类型数组形式,可以被上传到 GPU 进行渲染。 这意味着它们能更快的启动,占用更少的内存。但如果想修改数据,就需要复杂的编程。

基于 Geometry 的图元更灵活、更易修改。 它们根据 JavaScript 的类而来,像 Vector3 是 3D 的点,Face3 是三角形。 它们需要更多的内存,在能够被渲染前,Three.js 会将它们转换成相应的 BufferGeometry 表现形式。

如果你知道你不会操作图元,或者你擅长使用数学来操作它们,那么最好使用基于 BufferGeometry 的图元。 但如果你想在渲染前修改一些东西,那么 Geometry 的图元会更好操作。

举个简单的例子,BufferGeometry 不能轻松的添加新的顶点。 使用顶点的数量在创建时就定好了,相应的创建存储,填充顶点数据。 但用 Geometry 你就能随时添加顶点。

下面的很多图元都有默认的部分或者全部参数,所以可以根据你的需要选择使用。如果下面的形状不符合你的使用需求,你可以从 .obj 文件 或 .gltf 文件 加载几何体。 你也可以创建 自定义 Geometry。

1.BoxGeometry

盒子几何体,BoxGeometry是四边形的原始几何类,它通常使用构造函数所提供的“width”、“height”、“depth”参数来创建立方体或者不规则四边形。

img
img
const geometry = new THREE.BoxGeometry( 1, 1, 1 );
const material = new THREE.MeshBasicMaterial( {color: 0x00ff00} );
const cube = new THREE.Mesh( geometry, material );
scene.add( cube );

2.球缓冲几何体(SphereGeometry)

一个用于生成球体的类。

img
img
const geometry = new THREE.SphereGeometry( 15, 32, 16 );
const material = new THREE.MeshBasicMaterial( { color: 0xffff00 } );
const sphere = new THREE.Mesh( geometry, material );
scene.add( sphere );

还有一个重要的东西,就是所有形状都有多个设置来设置它们的细化程度。 一个很好的例子就是球形几何体。它可以这些参数:一圈组成的片数、从上到下的数量等。例如:

img
img

第一个球体一圈有 5 分片,高度为 3,一共 15 片,或者 30 个三角形。 第二个球体一圈有 24 分片,高度为 10,一共 240 片,或者 480 个三角形。 第三个球体一圈有 50 分片,高度为 50,一共 2500 片,或者 5000 个三角形。

由你决定需要细分成多少。看起来你可能需要较多数量的分片,但去除线,设置平面着色,我们就得到了:

img
img

现在并不明显是否右边有 5000 个三角形的比中间只有 480 个三角形的好更多。 如果你只是绘制少量球体,比如一个地球地图的球体,那么单个 10000 个三角形的球体就是个不错的选择。 但如果你要画 1000 个球体,那么 1000 个球体 x 10000 个三角形就是一千万个三角形。 想要动画流畅,你需要浏览器每秒绘制 60 帧,那么上面的场景就需要每秒绘制 6 亿个三角形。那是巨大的运算量。

3.平面缓冲几何体(PlaneGeometry)

一个用于生成平面几何体的类。

img
img
const geometry = new THREE.PlaneGeometry( 1, 1 );
const material = new THREE.MeshBasicMaterial( {color: 0xffff00, side: THREE.DoubleSide} );
const plane = new THREE.Mesh( geometry, material );
scene.add( plane );
img
img

左边的平面有 2 个三角形,右边的平面有 200 个三角形。不像球体,在多数平面的应用场景中,并没有什么折中的方法。 你可能只在你想要修改或者在某些方面封装一下的时候才将平面细分。对于盒子也是一样。

所以,选择适合你情况的方案。细分的越少,运行的越流畅,使用的内存也会更少。 你需要根据你的具体情况选择合适的方案。

4.圆形缓冲几何体(CircleGeometry)

CircleGeometry是欧式几何的一个简单形状,它由围绕着一个中心点的三角分段的数量所构造,由给定的半径来延展。 同时它也可以用于创建规则多边形,其分段数量取决于该规则多边形的边数。

img
img
const geometry = new THREE.CircleGeometry( 5, 32 );
const material = new THREE.MeshBasicMaterial( { color: 0xffff00 } );
const circle = new THREE.Mesh( geometry, material );
scene.add( circle );

5.圆锥缓冲几何体(ConeGeometry)

一个用于生成圆锥几何体的类。

img
img
const geometry = new THREE.ConeGeometry( 5, 20, 32 );
const material = new THREE.MeshBasicMaterial( {color: 0xffff00} );
const cone = new THREE.Mesh( geometry, material );
scene.add( cone );

6.圆柱缓冲几何体(CylinderGeometry)

一个用于生成圆柱几何体的类。

img
img
const geometry = new THREE.CylinderGeometry( 5, 5, 20, 32 );
const material = new THREE.MeshBasicMaterial( {color: 0xffff00} );
const cylinder = new THREE.Mesh( geometry, material );
scene.add( cylinder );

7.十二面缓冲几何体(DodecahedronGeometry)

一个用于创建十二面几何体的类。

img
img
const radius = 7;  // ui: radius
const geometry = new THREE.DodecahedronGeometry(radius);

8.边缘几何体(EdgesGeometry)

一个工具对象,将一个几何体作为输入,生成面夹角大于某个阈值的那条边。例如,你从顶上看一个盒子,你会看到有一条线穿过这个面,因为每个组成这个盒子的三角形都显示出来了。而如果使用 EdgesGeometryopen in new windowopen in new window 中间的线就会被移除。调整下面的 thresholdAngle,你就会看到夹角小于这个值的边消失了。

img
img
const geometry = new THREE.BoxGeometry( 100, 100, 100 );
const edges = new THREE.EdgesGeometry( geometry );
const line = new THREE.LineSegments( edges, new THREE.LineBasicMaterial( { color: 0xffffff } ) );
scene.add( line );

9.挤压缓冲几何体(ExtrudeGeometry)

从一个形状路径中,挤压出一个BufferGeometry。受挤压的 2D 形状,及可选的斜切。 这里我们挤压了一个心型。

img
img
const shape = new THREE.Shape();
const x = -2.5;
const y = -5;
shape.moveTo(x + 2.5, y + 2.5);
shape.bezierCurveTo(x + 2.5, y + 2.5, x + 2, y, x, y);
shape.bezierCurveTo(x - 3, y, x - 3, y + 3.5, x - 3, y + 3.5);
shape.bezierCurveTo(x - 3, y + 5.5, x - 1.5, y + 7.7, x + 2.5, y + 9.5);
shape.bezierCurveTo(x + 6, y + 7.7, x + 8, y + 4.5, x + 8, y + 3.5);
shape.bezierCurveTo(x + 8, y + 3.5, x + 8, y, x + 5, y);
shape.bezierCurveTo(x + 3.5, y, x + 2.5, y + 2.5, x + 2.5, y + 2.5);

const extrudeSettings = {
  steps: 2,  // ui: steps
  depth: 2,  // ui: depth
  bevelEnabled: true,  // ui: bevelEnabled
  bevelThickness: 1,  // ui: bevelThickness
  bevelSize: 1,  // ui: bevelSize
  bevelSegments: 2,  // ui: bevelSegments
};

const geometry = THREE.ExtrudeGeometry(shape, extrudeSettings);

10.形状缓冲几何体(ShapeGeometry)

从一个或多个路径形状中创建一个单面多边形几何体。

img
img
const shape = new THREE.Shape();
const x = -2.5;
const y = -5;
shape.moveTo(x + 2.5, y + 2.5);
shape.bezierCurveTo(x + 2.5, y + 2.5, x + 2, y, x, y);
shape.bezierCurveTo(x - 3, y, x - 3, y + 3.5, x - 3, y + 3.5);
shape.bezierCurveTo(x - 3, y + 5.5, x - 1.5, y + 7.7, x + 2.5, y + 9.5);
shape.bezierCurveTo(x + 6, y + 7.7, x + 8, y + 4.5, x + 8, y + 3.5);
shape.bezierCurveTo(x + 8, y + 3.5, x + 8, y, x + 5, y);
shape.bezierCurveTo(x + 3.5, y, x + 2.5, y + 2.5, x + 2.5, y + 2.5);
const geometry = new THREE.ShapeGeometry(shape);

11.二十面缓冲几何体(IcosahedronGeometry)

一个用于生成二十面体的类。

img
img
const radius = 7;  // ui: radius
const geometry = new THREE.IcosahedronGeometry(radius);

12.车削缓冲几何体(LatheGeometry)

创建具有轴对称性的网格,比如花瓶。车削绕着Y轴来进行旋转。

img
img
const points = [];
for ( let i = 0; i < 10; i ++ ) {
	points.push( new THREE.Vector2( Math.sin( i * 0.2 ) * 10 + 5, ( i - 5 ) * 2 ) );
}
const geometry = new THREE.LatheGeometry( points );
const material = new THREE.MeshBasicMaterial( { color: 0xffff00 } );
const lathe = new THREE.Mesh( geometry, material );
scene.add( lathe );

13.八面缓冲几何体(OctahedronGeometry)

一个用于创建八面体的类。

img
img
const radius = 7;  // ui: radius
const geometry = new THREE.OctahedronGeometry(radius);

14.多面缓冲几何体(PolyhedronGeometry)

多面体在三维空间中具有一些平面的立体图形。这个类将一个顶点数组投射到一个球面上,之后将它们细分为所需的细节级别。 这个类由DodecahedronGeometry、IcosahedronGeometry、OctahedronGeometry和TetrahedronGeometry 所使用,以生成它们各自的几何结构。将一些环绕着中心点的三角形投影到球体上。

img
img
const verticesOfCube = [
    -1,-1,-1,    1,-1,-1,    1, 1,-1,    -1, 1,-1,
    -1,-1, 1,    1,-1, 1,    1, 1, 1,    -1, 1, 1,
];

const indicesOfFaces = [
    2,1,0,    0,3,2,
    0,4,7,    7,3,0,
    0,1,5,    5,4,0,
    1,2,6,    6,5,1,
    2,3,7,    7,6,2,
    4,5,6,    6,7,4
];

const geometry = new THREE.PolyhedronGeometry( verticesOfCube, indicesOfFaces, 6, 2 );

15.圆环缓冲几何体(RingGeometry)

一个用于生成二维圆环几何体的类。中间有洞的 2D 圆盘

img
img
const geometry = new THREE.RingGeometry( 1, 5, 32 );
const material = new THREE.MeshBasicMaterial( { color: 0xffff00, side: THREE.DoubleSide } );
const mesh = new THREE.Mesh( geometry, material );
scene.add( mesh );

16.四面缓冲几何体(TetrahedronGeometry)

一个用于生成四面几何体的类。

img
img
const radius = 7;  // ui: radius
const geometry = new THREE.TetrahedronGeometry(radius);

17.圆环缓冲几何体(TorusGeometry)

一个用于生成圆环几何体的类。

img
img
const geometry = new THREE.TorusGeometry( 10, 3, 16, 100 );
const material = new THREE.MeshBasicMaterial( { color: 0xffff00 } );
const torus = new THREE.Mesh( geometry, material );
scene.add( torus );

18.圆环缓冲扭结几何体(TorusKnotGeometry)

创建一个圆环扭结,其特殊形状由一对互质的整数,p和q所定义。如果p和q不互质,创建出来的几何体将是一个环面链接。

img
img
const geometry = new THREE.TorusKnotGeometry( 10, 3, 100, 16 );
const material = new THREE.MeshBasicMaterial( { color: 0xffff00 } );
const torusKnot = new THREE.Mesh( geometry, material );
scene.add( torusKnot );

19.管道缓冲几何体(TubeGeometry)

创建一个沿着三维曲线延伸的管道。

img
img
class CustomSinCurve extends THREE.Curve {

	constructor( scale = 1 ) {

		super();

		this.scale = scale;

	}

	getPoint( t, optionalTarget = new THREE.Vector3() ) {

		const tx = t * 3 - 1.5;
		const ty = Math.sin( 2 * Math.PI * t );
		const tz = 0;

		return optionalTarget.set( tx, ty, tz ).multiplyScalar( this.scale );

	}

}

const path = new CustomSinCurve( 10 );
const geometry = new THREE.TubeGeometry( path, 20, 2, 8, false );
const material = new THREE.MeshBasicMaterial( { color: 0x00ff00 } );
const mesh = new THREE.Mesh( geometry, material );
scene.add( mesh );

20.网格几何体(WireframeGeometry)

这个类可以被用作一个辅助物体,来对一个geometry以线框的形式进行查看。

img
img
const geometry = new THREE.SphereGeometry( 100, 100, 100 );

const wireframe = new THREE.WireframeGeometry( geometry );

const line = new THREE.LineSegments( wireframe );
line.material.depthTest = false;
line.material.opacity = 0.25;
line.material.transparent = true;

scene.add( line );

3.自定义缓冲几何体

在three.js中, BufferGeometry 是用来代表所有几何体的一种方式。 BufferGeometry 本质上是一系列 BufferAttributes 的 名称 。每一个 BufferAttribute 代表一种类型数据的数组:位置,法线,颜色,uv,等等…… 这些合起来, BufferAttributes 代表每个顶点所有数据的 并行数组 。

img
img

上面提到,我们有四个属性:position, normal, color, uv 。 它们指的是 并行数组 ,代表每个属性的第N个数据集属于同一个顶点。index=4的顶点被高亮表示贯穿所有属性的平行数据定义一个顶点。

这就告诉我们,这是一个方块的数据图,高亮的地方代表一个角。

img
img

考虑下方块的单个角,不同的面都需要一个不同的法线。法线是面朝向的信息。在图中,在方块的角周围用箭头表示的法线,代表共用顶点位置的面需要指向不同方向的法线。

同理,一个角在不同的面需要不同的UVs。UVs是用来指定纹理区域中,画在相应顶点位置三角形的纹理坐标。你可以看到,绿色的面需要顶点的UV对应于F纹理的右上角,蓝色的面需要的UV对应于F纹理的左上角,红色的面需要的UV对应于F纹理的左下角。

一个简单的 顶点 是所有组成部分的集合。如果顶点需要其中任一部分变得不同,那么它必须是一个不同的顶点。

举一个简单的例子,让我们创建一个使用 BufferGeometry 的方块。方块很有趣,因为它看起来在角的地方共用顶点但实际上不是。在我们的例子中,我们将列出所有顶点数据,然后转化成并行数组,最后用它们创建 BufferAttributes 并添加到 BufferGeometry 。

我们从方块所需的所有数据开始。再次记住如果顶点有任何独一无二的部分,它必须是不同的顶点。像这里创建一个方块需要36个顶点,每个面2个三角形,每个三角形3个顶点,6个面=36个顶点。

const vertices = [
  // front
  { pos: [-1, -1,  1], norm: [ 0,  0,  1], uv: [0, 0], },
  { pos: [ 1, -1,  1], norm: [ 0,  0,  1], uv: [1, 0], },
  { pos: [-1,  1,  1], norm: [ 0,  0,  1], uv: [0, 1], },
 
  { pos: [-1,  1,  1], norm: [ 0,  0,  1], uv: [0, 1], },
  { pos: [ 1, -1,  1], norm: [ 0,  0,  1], uv: [1, 0], },
  { pos: [ 1,  1,  1], norm: [ 0,  0,  1], uv: [1, 1], },
  // right
  { pos: [ 1, -1,  1], norm: [ 1,  0,  0], uv: [0, 0], },
  { pos: [ 1, -1, -1], norm: [ 1,  0,  0], uv: [1, 0], },
  { pos: [ 1,  1,  1], norm: [ 1,  0,  0], uv: [0, 1], },
 
  { pos: [ 1,  1,  1], norm: [ 1,  0,  0], uv: [0, 1], },
  { pos: [ 1, -1, -1], norm: [ 1,  0,  0], uv: [1, 0], },
  { pos: [ 1,  1, -1], norm: [ 1,  0,  0], uv: [1, 1], },
  // back
  { pos: [ 1, -1, -1], norm: [ 0,  0, -1], uv: [0, 0], },
  { pos: [-1, -1, -1], norm: [ 0,  0, -1], uv: [1, 0], },
  { pos: [ 1,  1, -1], norm: [ 0,  0, -1], uv: [0, 1], },
 
  { pos: [ 1,  1, -1], norm: [ 0,  0, -1], uv: [0, 1], },
  { pos: [-1, -1, -1], norm: [ 0,  0, -1], uv: [1, 0], },
  { pos: [-1,  1, -1], norm: [ 0,  0, -1], uv: [1, 1], },
  // left
  { pos: [-1, -1, -1], norm: [-1,  0,  0], uv: [0, 0], },
  { pos: [-1, -1,  1], norm: [-1,  0,  0], uv: [1, 0], },
  { pos: [-1,  1, -1], norm: [-1,  0,  0], uv: [0, 1], },
 
  { pos: [-1,  1, -1], norm: [-1,  0,  0], uv: [0, 1], },
  { pos: [-1, -1,  1], norm: [-1,  0,  0], uv: [1, 0], },
  { pos: [-1,  1,  1], norm: [-1,  0,  0], uv: [1, 1], },
  // top
  { pos: [ 1,  1, -1], norm: [ 0,  1,  0], uv: [0, 0], },
  { pos: [-1,  1, -1], norm: [ 0,  1,  0], uv: [1, 0], },
  { pos: [ 1,  1,  1], norm: [ 0,  1,  0], uv: [0, 1], },
 
  { pos: [ 1,  1,  1], norm: [ 0,  1,  0], uv: [0, 1], },
  { pos: [-1,  1, -1], norm: [ 0,  1,  0], uv: [1, 0], },
  { pos: [-1,  1,  1], norm: [ 0,  1,  0], uv: [1, 1], },
  // bottom
  { pos: [ 1, -1,  1], norm: [ 0, -1,  0], uv: [0, 0], },
  { pos: [-1, -1,  1], norm: [ 0, -1,  0], uv: [1, 0], },
  { pos: [ 1, -1, -1], norm: [ 0, -1,  0], uv: [0, 1], },
 
  { pos: [ 1, -1, -1], norm: [ 0, -1,  0], uv: [0, 1], },
  { pos: [-1, -1,  1], norm: [ 0, -1,  0], uv: [1, 0], },
  { pos: [-1, -1, -1], norm: [ 0, -1,  0], uv: [1, 1], },
];

然后我们能将它们全部转换成3个并行数组

const positions = [];
const normals = [];
const uvs = [];
for (const vertex of vertices) {
  positions.push(...vertex.pos);
  normals.push(...vertex.norm);
  uvs.push(...vertex.uv);
}

最终我们能创建一个 BufferGeometryopen in new windowopen in new window ,然后为每个数组创建一个 BufferAttributeopen in new windowopen in new window 并添加到 BufferGeometryopen in new windowopen in new window

 const geometry = new THREE.BufferGeometry();
  const positionNumComponents = 3;
  const normalNumComponents = 3;
  const uvNumComponents = 2;
  geometry.setAttribute(
      'position',
      new THREE.BufferAttribute(new Float32Array(positions), positionNumComponents));
  geometry.setAttribute(
      'normal',
      new THREE.BufferAttribute(new Float32Array(normals), normalNumComponents));
  geometry.setAttribute(
      'uv',
      new THREE.BufferAttribute(new Float32Array(uvs), uvNumComponents));

注意名字很重要。你必须将属性的名字命名成three.js所期望的(除非你正在创建自定义着色器),在这里是 position、 normal 和 uv 。如果你想要设置顶点颜色则命名属性为 color 。

在上面我们创建了3个JavaScript原生数组, positions, normals 和 uvs 。 然后我们将他们转换为 Float32Array 的类型数组TypedArraysopen in new windowopen in new windowBufferAttributeopen in new windowopen in new window 是类型数组而不是原生数组。同时 BufferAttributeopen in new windowopen in new window 需要你设定每个顶点有多少组成成分。对于位置和法线,每个顶点我们需要3个组成成分,x、y和z。对于UVs我们需要2个,u和v。

img
img

那会是大量的数据。我们可以做点改善,可以用索引来代表顶点。看回我们的方块数据,每个面由2个三角形组成,每个三角形3个顶点,总共6个,但是其中2个是完全一样的;同样的位置,同样的法线,和同样的uv。因此,我们可以移除匹配的顶点,然后用索引代表他们。首先我们移除匹配的顶点。

const vertices = [
  // front
  { pos: [-1, -1,  1], norm: [ 0,  0,  1], uv: [0, 0], }, // 0
  { pos: [ 1, -1,  1], norm: [ 0,  0,  1], uv: [1, 0], }, // 1
  { pos: [-1,  1,  1], norm: [ 0,  0,  1], uv: [0, 1], }, // 2
  { pos: [ 1,  1,  1], norm: [ 0,  0,  1], uv: [1, 1], }, // 3
  // right
  { pos: [ 1, -1,  1], norm: [ 1,  0,  0], uv: [0, 0], }, // 4
  { pos: [ 1, -1, -1], norm: [ 1,  0,  0], uv: [1, 0], }, // 5
  { pos: [ 1,  1,  1], norm: [ 1,  0,  0], uv: [0, 1], }, // 6
  { pos: [ 1,  1, -1], norm: [ 1,  0,  0], uv: [1, 1], }, // 7
  // back
  { pos: [ 1, -1, -1], norm: [ 0,  0, -1], uv: [0, 0], }, // 8
  { pos: [-1, -1, -1], norm: [ 0,  0, -1], uv: [1, 0], }, // 9
  { pos: [ 1,  1, -1], norm: [ 0,  0, -1], uv: [0, 1], }, // 10
  { pos: [-1,  1, -1], norm: [ 0,  0, -1], uv: [1, 1], }, // 11
  // left
  { pos: [-1, -1, -1], norm: [-1,  0,  0], uv: [0, 0], }, // 12
  { pos: [-1, -1,  1], norm: [-1,  0,  0], uv: [1, 0], }, // 13
  { pos: [-1,  1, -1], norm: [-1,  0,  0], uv: [0, 1], }, // 14
  { pos: [-1,  1,  1], norm: [-1,  0,  0], uv: [1, 1], }, // 15
  // top
  { pos: [ 1,  1, -1], norm: [ 0,  1,  0], uv: [0, 0], }, // 16
  { pos: [-1,  1, -1], norm: [ 0,  1,  0], uv: [1, 0], }, // 17
  { pos: [ 1,  1,  1], norm: [ 0,  1,  0], uv: [0, 1], }, // 18
  { pos: [-1,  1,  1], norm: [ 0,  1,  0], uv: [1, 1], }, // 19
  // bottom
  { pos: [ 1, -1,  1], norm: [ 0, -1,  0], uv: [0, 0], }, // 20
  { pos: [-1, -1,  1], norm: [ 0, -1,  0], uv: [1, 0], }, // 21
  { pos: [ 1, -1, -1], norm: [ 0, -1,  0], uv: [0, 1], }, // 22
  { pos: [-1, -1, -1], norm: [ 0, -1,  0], uv: [1, 1], }, // 23
];
  

现在我们有24个唯一的顶点。然后我们为36个要画的顶点设定36个索引,通过调用 BufferGeometry.setIndex 并传入索引数组来创建12个三角形。

geometry.setAttribute(
    'position',
    new THREE.BufferAttribute(positions, positionNumComponents));
geometry.setAttribute(
    'normal',
    new THREE.BufferAttribute(normals, normalNumComponents));
geometry.setAttribute(
    'uv',
    new THREE.BufferAttribute(uvs, uvNumComponents));
 
geometry.setIndex([
   0,  1,  2,   2,  1,  3,  // front
   4,  5,  6,   6,  5,  7,  // right
   8,  9, 10,  10,  9, 11,  // back
  12, 13, 14,  14, 13, 15,  // left
  16, 17, 18,  18, 17, 19,  // top
  20, 21, 22,  22, 21, 23,  // bottom
]);

如果你没有提供法线数据的话, BufferGeometry 有个方法computeVertexNormals可以用来计算法线。不幸的是,因为如果顶点的其他数据不同的话,位置数据不能被共享,调用 computeVertexNormals 会让你的几何体像球面或者圆筒一样连接自身。

img
img

对于上面的圆筒,法线是通过 computeVertexNormals 方法创建的。 如果你仔细观察会发现在圆筒上有条缝。这是因为在圆筒的开始和结束的地方没有办法共享顶点数据,需要不同的UVs,所以该方法不知道它们是同样的顶点以平滑过度。只要知道一点,解决方法是应用自己的法线数据。

我们同样可以在一开始使用类型数组TypedArraysopen in new windowopen in new window取代JavaScript的原生数组。 缺点是你必须在一开始定义数组的大小。当然那不是很难,但是使用原生数组我们只需要用 push 将数据加入数组并最后通过 length 查看数组大小。使用类型数组我们没有这样的方法,所以需要记录添加的数据。

在这个例子,提前计算数组长度很简单,因为我们一开始使用一大块静态数据。

const numVertices = vertices.length;
const positionNumComponents = 3;
const normalNumComponents = 3;
const uvNumComponents = 2;
const positions = new Float32Array(numVertices * positionNumComponents);
const normals = new Float32Array(numVertices * normalNumComponents);
const uvs = new Float32Array(numVertices * uvNumComponents);
let posNdx = 0;
let nrmNdx = 0;
let uvNdx = 0;
for (const vertex of vertices) {
  positions.set(vertex.pos, posNdx);
  normals.set(vertex.norm, nrmNdx);
  uvs.set(vertex.uv, uvNdx);
  posNdx += positionNumComponents;
  nrmNdx += normalNumComponents;
  uvNdx += uvNumComponents;
}

geometry.setAttribute(
    'position',
    new THREE.BufferAttribute(positions, positionNumComponents));
geometry.setAttribute(
    'normal',
    new THREE.BufferAttribute(normals, normalNumComponents));
geometry.setAttribute(
    'uv',
    new THREE.BufferAttribute(uvs, uvNumComponents));
 
geometry.setIndex([
   0,  1,  2,   2,  1,  3,  // front
   4,  5,  6,   6,  5,  7,  // right
   8,  9, 10,  10,  9, 11,  // back
  12, 13, 14,  14, 13, 15,  // left
  16, 17, 18,  18, 17, 19,  // top
  20, 21, 22,  22, 21, 23,  // bottom
]);

一个使用类型数组的好理由,是如果你想动态更新顶点数据的任何一部分。

因为想不起动态更新顶点数据的好例子,所以我决定创建一个球面并从中央开始进进出出地移动每个四边形。但愿它是个有用的例子。

这里是用来产生球面的位置和索引数据的代码。代码共享了四边形内的顶点数据,但是四边形之间的没有共享,因为我们需要分别地移动每个四边形。

因为我懒,所以我通过3个 Object3D 对象的层级关系,计算球面的点。关于如何计算在这篇文章有解释the article on optimizing lots of objects。

function makeSpherePositions(segmentsAround, segmentsDown) {
  const numVertices = segmentsAround * segmentsDown * 6;
  const numComponents = 3;
  const positions = new Float32Array(numVertices * numComponents);
  const indices = [];
 
  const longHelper = new THREE.Object3D();
  const latHelper = new THREE.Object3D();
  const pointHelper = new THREE.Object3D();
  longHelper.add(latHelper);
  latHelper.add(pointHelper);
  pointHelper.position.z = 1;
  const temp = new THREE.Vector3();
 
  function getPoint(lat, long) {
    latHelper.rotation.x = lat;
    longHelper.rotation.y = long;
    longHelper.updateMatrixWorld(true);
    return pointHelper.getWorldPosition(temp).toArray();
  }
 
  let posNdx = 0;
  let ndx = 0;
  for (let down = 0; down < segmentsDown; ++down) {
    const v0 = down / segmentsDown;
    const v1 = (down + 1) / segmentsDown;
    const lat0 = (v0 - 0.5) * Math.PI;
    const lat1 = (v1 - 0.5) * Math.PI;
 
    for (let across = 0; across < segmentsAround; ++across) {
      const u0 = across / segmentsAround;
      const u1 = (across + 1) / segmentsAround;
      const long0 = u0 * Math.PI * 2;
      const long1 = u1 * Math.PI * 2;
 
      positions.set(getPoint(lat0, long0), posNdx);  posNdx += numComponents;
      positions.set(getPoint(lat1, long0), posNdx);  posNdx += numComponents;
      positions.set(getPoint(lat0, long1), posNdx);  posNdx += numComponents;
      positions.set(getPoint(lat1, long1), posNdx);  posNdx += numComponents;
 
      indices.push(
        ndx, ndx + 1, ndx + 2,
        ndx + 2, ndx + 1, ndx + 3,
      );
      ndx += 4;
    }
  }
  return {positions, indices};
}

然后我们像这样调用。

const segmentsAround = 24;
const segmentsDown = 16;
const {positions, indices} = makeSpherePositions(segmentsAround, segmentsDown);

因为返回的位置数据是单位球面位置,所以它们跟我们需要的法线数据完全一样,我们只需要复制它们。

const normals = positions.slice();

然后我们像之前一样设置属性

const geometry = new THREE.BufferGeometry();
const positionNumComponents = 3;
const normalNumComponents = 3;
 
const positionAttribute = new THREE.BufferAttribute(positions, positionNumComponents);
positionAttribute.setUsage(THREE.DynamicDrawUsage);
geometry.setAttribute(
    'position',
    positionAttribute);
geometry.setAttribute(
    'normal',
    new THREE.BufferAttribute(normals, normalNumComponents));
geometry.setIndex(indices);

我已经高亮一些区别。我们保存了位置属性的引用。 同时我们标记它为动态。这是提示THREE.js我们将会经常改变属性的内容。

在我们的渲染循环中,每一帧我们基于它们的法线更新位置

const temp = new THREE.Vector3();
 
...
 
for (let i = 0; i < positions.length; i += 3) {
  const quad = (i / 12 | 0);
  const ringId = quad / segmentsAround | 0;
  const ringQuadId = quad % segmentsAround;
  const ringU = ringQuadId / segmentsAround;
  const angle = ringU * Math.PI * 2;
  temp.fromArray(normals, i);
  temp.multiplyScalar(THREE.MathUtils.lerp(1, 1.4, Math.sin(time + ringId + angle) * .5 + .5));
  temp.toArray(positions, i);
}
positionAttribute.needsUpdate = true;

我们设置 positionAttribute.needsUpdate 告诉THREE.js更新我们的改变。

img
img

4.场景与物体

Three.js 的核心可以说是它的场景图(scene graph)。场景图在 3D 引擎是一个图中节点的层次结构,其中每个节点代表了一个局部空间(local space)。

img
img

这有点抽象,所以我们试着举一些例子。

比如这样一个例子:太阳系、太阳、地球、月亮。

img
img

地球绕着太阳转,月球绕着地球转,月球绕着地球转了一圈。从月球的角度看,它是在地球的 "局部空间 "中旋转。尽管它相对于太阳的运动是一些疯狂的像螺线图一样的曲线,但从月球的角度来看,它只需要关注自身围绕地球这个局部空间的旋转即可。

img
img

换个角度想,生活在地球上的你,不用思考关于地球自转和绕太阳公转的问题。你只是走路或开车或游泳或跑步,好像地球从未移动或者旋转。你走路、开车、游泳、跑步、生活在地球这个 "局部空间",即使相对于太阳来说,你是以每小时 1000 英里的速度绕着地球旋转,并以每小时 6 万 7 千英里的速度围绕太阳旋转。你在太阳系中的位置与头上的月亮相似,但你不必担心自己的位置。你只需担心你在地球 "局部空间 "中相对于地球的位置。

让我们一步一步来吧。想象一下,我们要做一个太阳、地球和月亮的图。我们先从太阳开始,只需制作一个球体,并将其置于原点。注意:我们用太阳、地球、月亮来演示如何使用场景图。当然,真正的太阳、地球和月亮使用的是物理学,但为了我们的目的,我们将用场景图来伪造它。

// 要更新旋转角度的对象数组
const objects = [];
 
// 一球多用
const radius = 1;
const widthSegments = 6;
const heightSegments = 6;
const sphereGeometry = new THREE.SphereGeometry(
  radius,
  widthSegments,
  heightSegments
);
 
const sunMaterial = new THREE.MeshPhongMaterial({ emissive: 0xffff00 });
const sunMesh = new THREE.Mesh(sphereGeometry, sunMaterial);
sunMesh.scale.set(5, 5, 5); // 扩大太阳的大小
scene.add(sunMesh);
objects.push(sunMesh);

我们使用的是一个低多边形球体(low-polygon sphere)。赤道周围只有 6 个分段。这是为了便于观察旋转情况。

因为我们会重用同一个球体,所以我们将太阳网格(sunMesh)的比例设置为 5x。

我们还将 phong 材质的 emissive 属性设置为黄色。phong 材质的放射属性(emissive)是基本上不受其他光照影响的固有颜色。光照会被添加到该颜色上。

我们也在场景的中心放置了一个点光源(point light)。稍后我们会介绍更多关于点光源的细节,但现在简单地说,点光源代表从一个点向各个方向发射的光源。

  const color = 0xffffff;
  const intensity = 3;
  const light = new THREE.PointLight(color, intensity);
  scene.add(light);

为了便于观察,我们要把摄像头放在原点的正上方向下看。最简单的方法是使用 lookAt 函数。 lookAt 函数让摄像机从它的位置“看向”我们传递 lookAt 的位置。在这样做之前,我们需要告诉摄像机的顶部朝向哪个方向,或者说哪个方向是摄像机的 "上"。对于大多数情况来说,正 Y 是向上的就足够了,但是由于我们是直视下方,我们需要告诉摄像机正 Z 是向上的。

const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
camera.position.set(0, 50, 0);
camera.up.set(0, 0, 1);
camera.lookAt(0, 0, 0);

在渲染循环中,根据之前的例子进行了调整,我们用这段代码旋转 objects 数组中的所有对象。

objects.forEach((obj) => {
  obj.rotation.y = time;
});

由于我们将 sunMesh 添加到 objects 数组中,它将会旋转。

img
img

现在让我们把地球(earth)也加进去。

const earthMaterial = new THREE.MeshPhongMaterial({
  color: 0x2233ff,
  emissive: 0x112244,
});
const earthMesh = new THREE.Mesh(sphereGeometry, earthMaterial);
earthMesh.position.x = 10;
scene.add(earthMesh);
objects.push(earthMesh);

我们做的材料是蓝色的,但是我们给它加了少量的放射蓝(emissive blue),这样它就会在我们的黑色背景下显示出来。

我们使用相同的 sphereGeometry 和新的蓝色的 earthMaterial 来制作一个 earthMesh 。我们将其定位在太阳的左边 10 个单位,并将其添加到场景中。由于我们将它添加到了我们的 objects 数组中,所以它也会旋转。

img
img

你可以看到太阳和地球都在自转,但地球并没有绕着太阳转。让我们把地球变成太阳的子节点吧。

sunMesh.add(earthMesh);

然后

img
img

到底发生了什么?为什么地球和太阳一样大?为什么离太阳这么远?我居然要把摄像机从 50 单位移到 150 单位以上才能看到地球。

我们让 earthMesh 成为 sunMesh 的一个子节点。sunMesh.scale.set(5, 5, 5) 将其比例设置为 5x。这意味着 sunMeshs 的局部空间是 5 倍大。这表示地球现在大了 5 倍,它与太阳的距离 ( earthMesh.position.x = 10 ) 也是 5 倍。

目前,我们的场景图是这样的:

img
img

为了解决这个问题,我们添加一个空的场景图节点。我们将把太阳和地球都作为该节点的子节点。

const solarSystem = new THREE.Object3D();
scene.add(solarSystem);
objects.push(solarSystem);
 
const sunMaterial = new THREE.MeshPhongMaterial({emissive: 0xFFFF00});
const sunMesh = new THREE.Mesh(sphereGeometry, sunMaterial);
sunMesh.scale.set(5, 5, 5);
solarSystem.add(sunMesh);
objects.push(sunMesh);
 
const earthMaterial = new THREE.MeshPhongMaterial({color: 0x2233FF, emissive: 0x112244});
const earthMesh = new THREE.Mesh(sphereGeometry, earthMaterial);
earthMesh.position.x = 10;
solarSystem.add(earthMesh);
objects.push(earthMesh);

这里我们创建了一个 Object3Dopen in new windowopen in new window 。像 Meshopen in new windowopen in new window 一样,它也是场景图中的一个节点,但与 Meshopen in new windowopen in new window 不同的是,它没有材质(material)和几何体(geometry)。它只是代表一个局部空间。

我们的新场景图是这样的:

img
img

sunMesh 和 earthMesh 都是 solarSystem 的子网格。三者都在旋转,现在因为 earthMesh 不是 sunMesh 的子网格,所以不再按 5 倍比例缩放。

img
img

好多了。地球比太阳小,而且绕着太阳转的同时自转。

延续同样的模式,我们再加一个月亮。

const earthOrbit = new THREE.Object3D();
earthOrbit.position.x = 10;
solarSystem.add(earthOrbit);
objects.push(earthOrbit);
 
const earthMaterial = new THREE.MeshPhongMaterial({color: 0x2233FF, emissive: 0x112244});
const earthMesh = new THREE.Mesh(sphereGeometry, earthMaterial);
earthOrbit.add(earthMesh);
objects.push(earthMesh);
 
const moonOrbit = new THREE.Object3D();
moonOrbit.position.x = 2;
earthOrbit.add(moonOrbit);
 
const moonMaterial = new THREE.MeshPhongMaterial({color: 0x888888, emissive: 0x222222});
const moonMesh = new THREE.Mesh(sphereGeometry, moonMaterial);
moonMesh.scale.set(.5, .5, .5);
moonOrbit.add(moonMesh);
objects.push(moonMesh);

我们再次添加了更多的隐形场景图节点。首先是一个名为 earthOrbit 的 Object3Dopen in new windowopen in new window ,并将新增 earthMesh 和 moonOrbit 都添加到其中。然后,我们把 moonMesh 添加到 moonOrbit 上。

新的场景图是这样的:

img
img

就是这样

img
img

你可以看到月亮照着本文开头所提到的螺线图形式旋转,但我们不必手动计算它。我们只需要设置我们的场景图来为我们做这件事。

绘制一些东西来可视化场景图中的节点通常很有用。Three.js 有一些很有帮助的,ummmm, 帮助工具可以用来 ummmm,...帮助我们实现这个功能。

其中一个叫做 AxesHelper 。它画了 3 条线,分别代表本地的 X, Y, 以及 Z轴。让我们为我们创建的每个节点都加上一个。

// 为每个节点添加一个AxesHelper
objects.forEach((node) => {
  const axes = new THREE.AxesHelper();
  axes.material.depthTest = false;
  axes.renderOrder = 1;
  node.add(axes);
});

在我们的例子中,我们希望轴即使在球体内部也能出现。要做到这一点,我们将其材质(material)的 depthTest 属性设置为 false,这意味着它们不会检查其是否在其他东西后面进行绘制。我们还将它们的 renderOrder 属性设置为 1(默认值为 0),这样它们就会在所有球体之后被绘制。否则一个球体可能会画在它们上面,把它们遮住。

img
img

我们可以看到x (红色) 和 z (蓝色)轴。由于我们是直视下方,而每个物体只是围绕 y 轴旋转,所以我们看不到y (绿色)轴。

可能很难看到其中一些轴,因为有 2 对重叠的轴。sunMesh 和 solarSystem 都在同一位置。同样地,earthMesh 和 earthOrbit 也在同一位置。让我们添加一些简单的控制方法,让我们可以为每个节点打开/关闭它们。同时,我们还可以添加另一个名为 GridHelper 的帮助工具。它可以在 X,Z 平面上创建一个 2D 网格。默认情况下,网格是 10x10 单位。

我们还将使用lil-gui,这是一个在 three.js 项目中非常流行的 UI 库。lil-gui 会获取一个对象和该对象上的属性名,并根据属性的类型自动生成一个 UI 来操作该属性。

我们要为每个节点制作一个 GridHelper 和一个 AxesHelper。我们需要为每个节点添加一个标签,所以我们将删除旧的循环,转而调用一些函数为每个节点添加帮助程序。

function makeAxisGrid(node, label, units) {
  const helper = new AxisGridHelper(node, units);
  gui.add(helper, 'visible').name(label);
}
 
makeAxisGrid(solarSystem, 'solarSystem', 25);
makeAxisGrid(sunMesh, 'sunMesh');
makeAxisGrid(earthOrbit, 'earthOrbit');
makeAxisGrid(earthMesh, 'earthMesh');
makeAxisGrid(moonOrbit, 'moonOrbit');
makeAxisGrid(moonMesh, 'moonMesh');

makeAxisGrid 创建了一个 AxisGridHelper 类,这是一个我们将创建的让 lil-gui 满意的类。就像上面说的那样,lil-gui 会自动地生成一个 UI 来操作某个对象的命名属性。它将根据属性的类型创建不同的 UI。我们希望它创建一个复选框,所以我们需要指定一个 bool 属性。但是,我们希望坐标轴和网格都能基于一个单一的属性出现/消失,所以我们将创建一个类,其有一个属性绑定了 getter 和 setter。这样我们就可以让 lil-gui 认为它在操作一个单一的属性,但是在内部我们可以为一个节点设置 AxesHelperopen in new windowopen in new windowGridHelperopen in new windowopen in new window 的可见(visible)属性。

// 打开/关闭轴和网格的可见性
// lil-gui 要求一个返回类型为bool型的属性
// 来创建一个复选框,所以我们为 `visible`属性
// 绑定了一个setter 和 getter。 从而让lil-gui
// 去操作该属性.
class AxisGridHelper {
  constructor(node, units = 10) {
    const axes = new THREE.AxesHelper();
    axes.material.depthTest = false;
    axes.renderOrder = 2; // 在网格渲染之后再渲染
    node.add(axes);
 
    const grid = new THREE.GridHelper(units, units);
    grid.material.depthTest = false;
    grid.renderOrder = 1;
    node.add(grid);
 
    this.grid = grid;
    this.axes = axes;
    this.visible = false;
  }
  get visible() {
    return this._visible;
  }
  set visible(v) {
    this._visible = v;
    this.grid.visible = v;
    this.axes.visible = v;
  }
}

需要注意的是,我们将 AxesHelperopen in new windowopen in new window 的 renderOrder 设置为 2,将GridHelperopen in new windowopen in new window的设置为 1,这样轴就会在网格之后绘制。否则网格可能会覆盖轴。

img
img

选中 solarSystem,你会看到地球是如何像我们上面设定的那样,从中心出发正好 10 个单位。你可以看到地球是如何处在 solarSystem 的局部空间(local space)内。同样地,如果你打开 earthOrbit,你会看到月球距离 earthOrbit 的局部空间(local space)的中心正好 2 个单位。

再举几个场景图的例子。在一个简单的游戏世界中,一辆汽车可能有这样的场景图。

img
img

如果你移动车体,所有的轮子都会随之移动。如果你想让车身和轮子分开弹跳,你可以将车身和轮子作为代表汽车框架的框架(frame)节点的子节点。

另一个例子是游戏世界中的人类。

img
img

你可以看到对于人类来说,场景图会变得很复杂。事实上,上面的场景图已经被简化了。例如,你可以把它扩展到覆盖每根手指(至少还有 28 个节点)和每个脚趾(还有 28 个节点),再加上脸部和下巴、眼睛,也许还有更多。

我们来做一个稍微复杂的场景图。我们来做一辆坦克。坦克将有 6 个轮子和一个炮塔。坦克会沿着一条路径行驶。会有一个球体在周围移动,坦克会瞄准球体。

这是场景图。网格(mesh)的颜色为绿色,Object3D 为蓝色,灯光(light)为金色,摄像机(camera)为紫色。其中一台摄像机没有被添加进场景图。

img 在代码中查看这些节点的设置。

对于目标,也就是坦克要瞄准的东西,有一个 targetOrbit ( Object3D ),它的旋转方式与上面的 earthOrbit 一样。targetElevation ( Object3D )是 targetOrbit 的一个子节点,它提供了一个从 targetOrbit 的偏移量和一个基准高度。它的子节点是另一个叫做 targetBob 的 Object3D,它只是相对于 targetElevation 上下摆动。最后是 targetMesh,它只是一个立方体,我们可以旋转并改变它的颜色。

// 移动目标
targetOrbit.rotation.y = time * 0.27;
targetBob.position.y = Math.sin(time * 2) * 4;
targetMesh.rotation.x = time * 7;
targetMesh.rotation.y = time * 13;
targetMaterial.emissive.setHSL((time * 10) % 1, 1, 0.25);
targetMaterial.color.setHSL((time * 10) % 1, 1, 0.25);

对于坦克来说,有一个叫做 tank 的 Object3Dopen in new windowopen in new window,用来移动它下面的所有子节点。代码中使用了 SplineCurveopen in new windowopen in new window,其接受用来定义曲线的一系列坐标为参数。0.0 是曲线的起始点,1.0 是曲线的终点。它首先获取当前的位置,也就是放置坦克的位置。然后获取在曲线稍远处的位置,并使用 Object3D.lookAtopen in new windowopen in new window 将坦克指向该方向。

const tankPosition = new THREE.Vector2();
const tankTarget = new THREE.Vector2();
 
...
 
// 移动坦克
const tankTime = time * .05;
curve.getPointAt(tankTime % 1, tankPosition);
curve.getPointAt((tankTime + 0.01) % 1, tankTarget);
tank.position.set(tankPosition.x, 0, tankPosition.y);
tank.lookAt(tankTarget.x, 0, tankTarget.y);

由于坦克顶部的炮塔是坦克的子节点,所以它会自动移动。如果要将它指向目标,我们只需要获取目标的世界位置(world position),然后再次使用 Object3D.lookAt。

const targetPosition = new THREE.Vector3();
 
...
 
// 炮台瞄准目标
targetMesh.getWorldPosition(targetPosition);
turretPivot.lookAt(targetPosition);

有一个 turretCamera,它是 turretMesh 的一个子节点,所以它会随着炮塔上下移动和旋转。我们让它瞄准目标。

// 让turretCamera瞄准目标
turretCamera.lookAt(targetPosition);

还有一个 targetCameraPivot,它是 targetBob 的子节点,所以它随着目标漂浮。我们将其瞄准坦克。它的目的是让 targetCamera 与目标本身偏移。如果我们把摄像头变成 targetBob 的子节点,并且只瞄准摄像头本身,那么它就会在目标内部。

// 让targetCameraPivot看向坦克
tank.getWorldPosition(targetPosition);
targetCameraPivot.lookAt(targetPosition);

最后,我们旋转所有的车轮

wheelMeshes.forEach((obj) => {
  obj.rotation.x = time * 3;
});

对于摄像机,我们在初始化时设置了一个包含所有 4 台摄像机的数组,并附有描述。

const cameras = [
  { cam: camera, desc: "detached camera" },
  { cam: turretCamera, desc: "on turret looking at target" },
  { cam: targetCamera, desc: "near target looking at tank" },
  { cam: tankCamera, desc: "above back of tank" },
];
 
const infoElem = document.querySelector("#info");

并在渲染时循环使用我们的摄像机。

const camera = cameras[(time * 0.25) % cameras.length | 0];
infoElem.textContent = camera.desc;
img
img

我希望这能让你对场景图的工作原理以及你可能使用它们的方法有一些了解。制作 Object3Dopen in new windowopen in new window 节点,并将东西作为它们的子节点,是使用好 three.js 这样的 3D 引擎的重要一步。通常来说,让东西按照你想要的方式移动和旋转可能需要一些复杂的数学来。例如,如果没有场景图,计算月亮的运动或者汽车的轮子相对于车身的位置会非常复杂,但是使用场景图就会变得简单很多。

import * as THREE from 'three';

function main() {
  const canvas = document.querySelector('#c');
  const renderer = new THREE.WebGLRenderer({canvas: canvas});
  renderer.setClearColor(0xAAAAAA);
  renderer.shadowMap.enabled = true;

  function makeCamera(fov = 40) {
    const aspect = 2;  // the canvas default
    const zNear = 0.1;
    const zFar = 1000;
    return new THREE.PerspectiveCamera(fov, aspect, zNear, zFar);
  }
  const camera = makeCamera();
  camera.position.set(8, 4, 10).multiplyScalar(3);
  camera.lookAt(0, 0, 0);

  const scene = new THREE.Scene();

  {
    const light = new THREE.DirectionalLight(0xffffff, 1);
    light.position.set(0, 20, 0);
    scene.add(light);
    light.castShadow = true;
    light.shadow.mapSize.width = 2048;
    light.shadow.mapSize.height = 2048;

    const d = 50;
    light.shadow.camera.left = -d;
    light.shadow.camera.right = d;
    light.shadow.camera.top = d;
    light.shadow.camera.bottom = -d;
    light.shadow.camera.near = 1;
    light.shadow.camera.far = 50;
    light.shadow.bias = 0.001;
  }

  {
    const light = new THREE.DirectionalLight(0xffffff, 1);
    light.position.set(1, 2, 4);
    scene.add(light);
  }

  const groundGeometry = new THREE.PlaneGeometry(50, 50);
  const groundMaterial = new THREE.MeshPhongMaterial({color: 0xCC8866});
  const groundMesh = new THREE.Mesh(groundGeometry, groundMaterial);
  groundMesh.rotation.x = Math.PI * -.5;
  groundMesh.receiveShadow = true;
  scene.add(groundMesh);

  const carWidth = 4;
  const carHeight = 1;
  const carLength = 8;

  const tank = new THREE.Object3D();
  scene.add(tank);

  const bodyGeometry = new THREE.BoxGeometry(carWidth, carHeight, carLength);
  const bodyMaterial = new THREE.MeshPhongMaterial({color: 0x6688AA});
  const bodyMesh = new THREE.Mesh(bodyGeometry, bodyMaterial);
  bodyMesh.position.y = 1.4;
  bodyMesh.castShadow = true;
  tank.add(bodyMesh);

  const tankCameraFov = 75;
  const tankCamera = makeCamera(tankCameraFov);
  tankCamera.position.y = 3;
  tankCamera.position.z = -6;
  tankCamera.rotation.y = Math.PI;
  bodyMesh.add(tankCamera);

  const wheelRadius = 1;
  const wheelThickness = .5;
  const wheelSegments = 6;
  const wheelGeometry = new THREE.CylinderGeometry(
      wheelRadius,     // top radius
      wheelRadius,     // bottom radius
      wheelThickness,  // height of cylinder
      wheelSegments);
  const wheelMaterial = new THREE.MeshPhongMaterial({color: 0x888888});
  const wheelPositions = [
    [-carWidth / 2 - wheelThickness / 2, -carHeight / 2,  carLength / 3],
    [ carWidth / 2 + wheelThickness / 2, -carHeight / 2,  carLength / 3],
    [-carWidth / 2 - wheelThickness / 2, -carHeight / 2, 0],
    [ carWidth / 2 + wheelThickness / 2, -carHeight / 2, 0],
    [-carWidth / 2 - wheelThickness / 2, -carHeight / 2, -carLength / 3],
    [ carWidth / 2 + wheelThickness / 2, -carHeight / 2, -carLength / 3],
  ];
  const wheelMeshes = wheelPositions.map((position) => {
    const mesh = new THREE.Mesh(wheelGeometry, wheelMaterial);
    mesh.position.set(...position);
    mesh.rotation.z = Math.PI * .5;
    mesh.castShadow = true;
    bodyMesh.add(mesh);
    return mesh;
  });

  const domeRadius = 2;
  const domeWidthSubdivisions = 12;
  const domeHeightSubdivisions = 12;
  const domePhiStart = 0;
  const domePhiEnd = Math.PI * 2;
  const domeThetaStart = 0;
  const domeThetaEnd = Math.PI * .5;
  const domeGeometry = new THREE.SphereGeometry(
    domeRadius, domeWidthSubdivisions, domeHeightSubdivisions,
    domePhiStart, domePhiEnd, domeThetaStart, domeThetaEnd);
  const domeMesh = new THREE.Mesh(domeGeometry, bodyMaterial);
  domeMesh.castShadow = true;
  bodyMesh.add(domeMesh);
  domeMesh.position.y = .5;

  const turretWidth = .1;
  const turretHeight = .1;
  const turretLength = carLength * .75 * .2;
  const turretGeometry = new THREE.BoxGeometry(
      turretWidth, turretHeight, turretLength);
  const turretMesh = new THREE.Mesh(turretGeometry, bodyMaterial);
  const turretPivot = new THREE.Object3D();
  turretMesh.castShadow = true;
  turretPivot.scale.set(5, 5, 5);
  turretPivot.position.y = .5;
  turretMesh.position.z = turretLength * .5;
  turretPivot.add(turretMesh);
  bodyMesh.add(turretPivot);

  const turretCamera = makeCamera();
  turretCamera.position.y = .75 * .2;
  turretMesh.add(turretCamera);

  const targetGeometry = new THREE.SphereGeometry(.5, 6, 3);
  const targetMaterial = new THREE.MeshPhongMaterial({color: 0x00FF00, flatShading: true});
  const targetMesh = new THREE.Mesh(targetGeometry, targetMaterial);
  const targetOrbit = new THREE.Object3D();
  const targetElevation = new THREE.Object3D();
  const targetBob = new THREE.Object3D();
  targetMesh.castShadow = true;
  scene.add(targetOrbit);
  targetOrbit.add(targetElevation);
  targetElevation.position.z = carLength * 2;
  targetElevation.position.y = 8;
  targetElevation.add(targetBob);
  targetBob.add(targetMesh);

  const targetCamera = makeCamera();
  const targetCameraPivot = new THREE.Object3D();
  targetCamera.position.y = 1;
  targetCamera.position.z = -2;
  targetCamera.rotation.y = Math.PI;
  targetBob.add(targetCameraPivot);
  targetCameraPivot.add(targetCamera);

  // Create a sine-like wave
  const curve = new THREE.SplineCurve( [
    new THREE.Vector2( -10, 0 ),
    new THREE.Vector2( -5, 5 ),
    new THREE.Vector2( 0, 0 ),
    new THREE.Vector2( 5, -5 ),
    new THREE.Vector2( 10, 0 ),
    new THREE.Vector2( 5, 10 ),
    new THREE.Vector2( -5, 10 ),
    new THREE.Vector2( -10, -10 ),
    new THREE.Vector2( -15, -8 ),
    new THREE.Vector2( -10, 0 ),
  ] );

  const points = curve.getPoints( 50 );
  const geometry = new THREE.BufferGeometry().setFromPoints( points );
  const material = new THREE.LineBasicMaterial( { color : 0xff0000 } );
  const splineObject = new THREE.Line( geometry, material );
  splineObject.rotation.x = Math.PI * .5;
  splineObject.position.y = 0.05;
  scene.add(splineObject);

  function resizeRendererToDisplaySize(renderer) {
    const canvas = renderer.domElement;
    const width = canvas.clientWidth;
    const height = canvas.clientHeight;
    const needResize = canvas.width !== width || canvas.height !== height;
    if (needResize) {
      renderer.setSize(width, height, false);
    }
    return needResize;
  }

  const targetPosition = new THREE.Vector3();
  const tankPosition = new THREE.Vector2();
  const tankTarget = new THREE.Vector2();

  const cameras = [
    { cam: camera, desc: 'detached camera', },
    { cam: turretCamera, desc: 'on turret looking at target', },
    { cam: targetCamera, desc: 'near target looking at tank', },
    { cam: tankCamera, desc: 'above back of tank', },
  ];

  const infoElem = document.querySelector('#info');

  function render(time) {
    time *= 0.001;

    if (resizeRendererToDisplaySize(renderer)) {
      const canvas = renderer.domElement;
      cameras.forEach((cameraInfo) => {
        const camera = cameraInfo.cam;
        camera.aspect = canvas.clientWidth / canvas.clientHeight;
        camera.updateProjectionMatrix();
      });
    }

    // move target
    targetOrbit.rotation.y = time * .27;
    targetBob.position.y = Math.sin(time * 2) * 4;
    targetMesh.rotation.x = time * 7;
    targetMesh.rotation.y = time * 13;
    targetMaterial.emissive.setHSL(time * 10 % 1, 1, .25);
    targetMaterial.color.setHSL(time * 10 % 1, 1, .25);

    // move tank
    const tankTime = time * .05;
    curve.getPointAt(tankTime % 1, tankPosition);
    curve.getPointAt((tankTime + 0.01) % 1, tankTarget);
    tank.position.set(tankPosition.x, 0, tankPosition.y);
    tank.lookAt(tankTarget.x, 0, tankTarget.y);

    // face turret at target
    targetMesh.getWorldPosition(targetPosition);
    turretPivot.lookAt(targetPosition);

    // make the turretCamera look at target
    turretCamera.lookAt(targetPosition);

    // make the targetCameraPivot look at the at the tank
    tank.getWorldPosition(targetPosition);
    targetCameraPivot.lookAt(targetPosition);

    wheelMeshes.forEach((obj) => {
      obj.rotation.x = time * 3;
    });

    const camera = cameras[time * .25 % cameras.length | 0];
    infoElem.textContent = camera.desc;

    renderer.render(scene, camera.cam);

    requestAnimationFrame(render);
  }

  requestAnimationFrame(render);
}

main();

html

<style>
  html, body {
    height: 100%;
    margin: 0;
  }
  #c {
    width: 100%;
    height: 100%;
    display: block;
  }
  #info {
    position: absolute;
    left: 1em;
    top: 1em;
    background: rgba(0,0,0,.8);
    padding: .5em;
    color: white;
    font-family: monospace;
  }

</style>
<canvas id="c"></canvas>
<div id="info"></div>
<script  type="importmap">{
  "imports": {
  "three": "https://threejs.org/build/three.module.js"
  }
  }</script><!-- Remove this when import maps will be widely supported -->
<script async src="https://unpkg.com/es-module-shims@1.3.6/dist/es-module-shims.js"></script>

5.材质详解

Three.js提供了多种类型的材质(material)。它们定义了对象在场景中的外型。你使用哪种材质取决于你想达到的目的。

有2种方法可以设置大部分的材质属性。一种是在实例化时设置,也就是我们之前看到的。

const material = new THREE.MeshPhongMaterial({
  color: 0xFF0000,    // 红色 (也可以使用CSS的颜色字符串)
  flatShading: true,
});

另一种是在实例化之后再设置

const material = new THREE.MeshPhongMaterial();
material.color.setHSL(0, 1, .5);  // 红色
material.flatShading = true;

注意,THREE.Coloropen in new windowopen in new window 类型的属性有多种设置方式。

material.color.set(0x00FFFF);    // 同 CSS的 #RRGGBB 风格
material.color.set(cssString);   // 任何 CSS 颜色字符串, 比如 'purple', '#F32',
                                 // 'rgb(255, 127, 64)',
                                 // 'hsl(180, 50%, 25%)'
material.color.set(someColor)    // 其他一些 THREE.Color
material.color.setHSL(h, s, l)   // 其中 h, s, 和 l 从 0 到 1
material.color.setRGB(r, g, b)   // 其中 r, g, 和 b 从 0 到 1

在实例化时,你可以传递一个十六进制数字或CSS字符串作为参数。

const m1 = new THREE.MeshBasicMaterial({color: 0xFF0000});         // 红色
const m2 = new THREE.MeshBasicMaterial({color: 'red'});            // 红色
const m3 = new THREE.MeshBasicMaterial({color: '#F00'});           // 红色
const m4 = new THREE.MeshBasicMaterial({color: 'rgb(255,0,0)'});   // 红色
const m5 = new THREE.MeshBasicMaterial({color: 'hsl(0,100%,50%)'}); // 红色

那么,我们就来看看three.js的几个材质。

1.MeshBasicMaterial(基础网格材质)

MeshBasicMaterial 不受光照的影响。MeshLambertMaterial 只在顶点计算光照,而 MeshPhongMaterial 则在每个像素计算光照。MeshPhongMaterial 还支持镜面高光。

img
img

2.MeshPhongMaterial (Phong网格材质)

MeshPhongMaterial 的 shininess 设置决定了镜面高光的光泽度。它的默认值是30。

img
img

3.MeshLambertMaterial(Lambert网格材质)

请注意,将 MeshLambertMaterial 或 MeshPhongMaterial 的 emissive 属性设置为颜色,并将颜色设置为黑色(phong的 shininess 为0),最终看起来就像 MeshBasicMaterial 一样。

img
img

既然MeshBasicMaterial、MeshLambertMaterial可以做到的,MeshPhongMaterial也可以做到,那为什么还要有这3种材质呢?原因是更复杂的材质会消耗更多的GPU功耗。在一个较慢的GPU上,比如说手机,你可能想通过使用一个不太复杂的材质来减少绘制场景所需的GPU功耗。同样,如果你不需要额外的功能,那就使用最简单的材质。如果你不需要照明和镜面高光,那么就使用 MeshBasicMaterial 。

MeshToonMaterial 与 MeshPhongMaterial 类似,但有一个很大的不同。它不是平滑地着色,而是使用一个渐变图(一个X乘1的纹理(X by 1 texture))来决定如何着色。默认使用的渐变图是前70%的部分使用70%的亮度,之后的部分使用100%的亮度,当然,你可以定义你自己的渐变图。这最终会给人一种2色调的感觉,看起来就像卡通一样。

img
img

接下来是2种基于物理渲染(Physically Based Rendering)的材质。Physically Based Rendering通常简称为PBR。

之前提到的材质使用简单的数学来制作,看起来是3D的,但它们并不是现实世界中实际存在的东西。2种PBR材质使用更复杂的数学来接近现实世界中的实际情况。

4.MeshStandardMaterial(标准网格材质)

第一个是 MeshStandardMaterial。MeshPhongMaterial 和 MeshStandardMaterial 最大的区别是它们使用的参数不同。MeshPhongMaterial 有一个参数用来设置 shininess 属性。MeshStandardMaterial 有2个参数用来分别设置 roughness 和 metalness 属性。

在基本层面,roughness 是 shininess 的对立面。粗糙度(roughness)高的东西,比如棒球,就不会有很强烈的反光,而不粗糙的东西,比如台球,就很有光泽。粗糙度的范围从0到1。

另一个设定,metalness,说的是材质的金属度。金属与非金属的表现不同。0代表非金属,1代表金属。

这里是 MeshStandardMaterial 的一个快速示例,从左至右看,粗糙度从0到1,从上至下看,金属度从0到1。

img
img

5.MeshPhysicalMaterial (物理网格材质)

MeshPhysicalMaterial 与 MeshStandardMaterial 相同,但它增加了一个clearcoat 参数,该参数从0到1,决定了要涂抹的清漆光亮层的程度,还有一个 clearCoatRoughness 参数,指定光泽层的粗糙程度。

这里是和上面一样的按 metalness 划分的 roughness 网格,但可以设置 clearcoat 和 clearCoatRoughness 。

img
img

各种标准材质的构建速度从最快到最慢:MeshBasicMaterial ➡ MeshLambertMaterial ➡ MeshPhongMaterial ➡ MeshStandardMaterial ➡ MeshPhysicalMaterial。构建速度越慢的材质,做出的场景越逼真,但在低功率或移动设备上,你可能需要思考代码的设计,使用构建速度较快的材质。

接下来的3种材质有特殊用途。ShadowMaterial 用于获取阴影创建的数据。我们还没有介绍过阴影。等到我们介绍的时候,我们会使用这个材质来看看其背后的原理。

MeshDepthMaterial 渲染每个像素的深度,其中处在摄像机负近端面的像素其深度为0,处在摄像机负远端面的像素其深度为1。使用这个属性可以实现一些特殊效果,这在之后我们会再讨论。

img
img

6.MeshNormalMaterial (法线网格材质)

MeshNormalMaterial 会显示几何体的法线。法线是一个特定的三角形或像素所面对的方向。MeshNormalMaterial 会绘制视图空间法线(相对于摄像机的法线)。x 是红色, y 是绿色, 以及 z 是蓝色,所以朝向右边的东西是粉红色,朝向左边的是水蓝色,朝上的是浅绿色,朝下的是紫色,朝向屏幕的是淡紫色。

img
img

7.ShaderMaterial (着色器材质)

ShaderMaterial 是通过three.js的着色器系统来制作自定义材质。RawShaderMaterial 则是可以用来制作完全自定义的着色器,不需要three.js的帮助。这两个材质涉及的话题都很广,我们后面会讲到。

大多数材质都共享一堆由 Material 定义的设置。所有的设置都可以在文档中找到,但我们先来看看两个最常用的属性。

flatShading:对象是否使用平面着色,默认为false。

img
img

side:要显示三角形的哪个面。默认值是 THREE.FrontSide,其他选项有 THREE.BackSide 和 THREE.DoubleSide(正反两面)。Three.js中,大多数3D对象可能都是不透明的实体,所以不需要绘制反面(面向实体内部的面)。设置 side 的最常见的原因是用于绘制平面或其他非实体对象,在这些对象中通常会看到三角形的反面。

下面是用 THREE.FrontSide 和 THREE.DoubleSide 绘制的6个平面。

img
img

关于材质,真的有很多需要考虑的地方,其实我们还有一堆东西要去做。特别是我们几乎忽略了纹理,它为我们提供了大量的选择。

material.needsUpdate

这个话题很少影响大多数three.js应用,但仅供参考......three.js会在使用材质时应用材质设置,其中 "使用 "意味着 "使用该材质的东西被渲染"。有些材质设置只应用一次,因为改变它们需要three.js做很多工作。在这种情况下,你需要设置 material.needsUpdate = true 来告诉 three.js 应用你的材质变化。当你在使用材质后再去更改设置,需要你去设置 needsUpdate的最常见的几种设置是:

  • flatShading
  • 添加或删除纹理改变纹理是可以的,但是如果想从使用无纹理切换到使用纹理,或者从使用纹理切换到无纹理,那么你需要设置 needsUpdate = true。在从有纹理到无纹理的情况下,往往是使用1x1像素的白色纹理更好。

如上所述,大多数应用程序从未遇到这些问题。大多数应用程序不会在平面阴影和非平面阴影之间切换。大多数应用程序也要么使用纹理,要么使用纯色给定的材料,他们很少从使用一个切换到使用另一个。

6.纹理详解

1.纹理

纹理一般是指我们常见的在一些第三方程序中创建的图像,如Photoshop或GIMP。比如我们把这张图片放在立方体上。

img
img

我们将修改我们的第一个例子中的其中一个。我们需要做的就是创建一个TextureLoaderopen in new windowopen in new window。调用它的loadopen in new windowopen in new window方法,同时传入图像的URL,并将材质的 map 属性设置为该方法的返回值,而不是设置它的 color属性。

const loader = new THREE.TextureLoader();
 
const material = new THREE.MeshBasicMaterial({
  map: loader.load('resources/images/wall.jpg'),
});

注意,我们使用的是 MeshBasicMaterialopen in new windowopen in new window, 所以没有必要增加

img
img

2.多种纹理

6个纹理,一个立方体的每个面都有一个,怎么样?

img
img

我们只需制作6种材料,并在创建 Meshopen in new windowopen in new window 时将它们作为一个数组传递给它们。

const loader = new THREE.TextureLoader();
 

const materials = [
  new THREE.MeshBasicMaterial({map: loader.load('resources/images/flower-1.jpg')}),
  new THREE.MeshBasicMaterial({map: loader.load('resources/images/flower-2.jpg')}),
  new THREE.MeshBasicMaterial({map: loader.load('resources/images/flower-3.jpg')}),
  new THREE.MeshBasicMaterial({map: loader.load('resources/images/flower-4.jpg')}),
  new THREE.MeshBasicMaterial({map: loader.load('resources/images/flower-5.jpg')}),
  new THREE.MeshBasicMaterial({map: loader.load('resources/images/flower-6.jpg')}),
];
const cube = new THREE.Mesh(geometry, materials);

有效果了!

img
img

但需要注意的是,并不是所有的几何体类型都支持多种材质。BoxGeometry 和 BoxGeometry 可以使用6种材料,每个面一个。ConeGeometry 和 ConeGeometry 可以使用2种材料,一种用于底部,一种用于侧面。 CylinderGeometry 和 CylinderGeometry 可以使用3种材料,分别是底部、顶部和侧面。对于其他情况,你需要建立或加载自定义几何体和(或)修改纹理坐标。

在其他3D引擎中,如果你想在一个几何体上使用多个图像,使用 纹理图集(Texture Atlas) 更为常见,性能也更高。纹理图集是将多个图像放在一个单一的纹理中,然后使用几何体顶点上的纹理坐标来选择在几何体的每个三角形上使用纹理的哪些部分。

什么是纹理坐标?它们是添加到一块几何体的每个顶点上的数据,用于指定该顶点对应的纹理的哪个部分。当我们开始构建自定义几何体时(building custom geometry),我们会介绍它们。

3.加载纹理

3.1简单的方法

本文的大部分代码都使用最简单的加载纹理的方法。我们创建一个 TextureLoader ,然后调用它的load方法。 这将返回一个 Texture 对象。

const texture = loader.load('resources/images/flower-1.jpg');

需要注意的是,使用这个方法,我们的纹理将是透明的,直到图片被three.js异步加载完成,这时它将用下载的图片更新纹理。

这有一个很大的好处,就是我们不必等待纹理加载,我们的页面会立即开始渲染。这对于很多用例来说可能都没问题,但如果我们想要的话,我们可以让three.js告诉我们何时纹理已经下载完毕。

3.2等待一个纹理加载

为了等待贴图加载,贴图加载器的 load 方法会在贴图加载完成后调用一个回调。回到上面的例子,我们可以在创建Mesh并将其添加到场景之前等待贴图加载,就像这样。

const loader = new THREE.TextureLoader();
loader.load('resources/images/wall.jpg', (texture) => {
  const material = new THREE.MeshBasicMaterial({
    map: texture,
  });
  const cube = new THREE.Mesh(geometry, material);
  scene.add(cube);
  cubes.push(cube);  // 添加到我们要旋转的立方体数组中
});

除非你清除你的浏览器的缓存并且连接缓慢,你不太可能看到任何差异,但放心,它正在等待纹理加载。

3.3等待多个纹理加载

要等到所有纹理都加载完毕,你可以使用 LoadingManager 。创建一个并将其传递给 TextureLoader,然后将其onLoad属性设置为回调。

const loadManager = new THREE.LoadingManager();
const loader = new THREE.TextureLoader(loadManager);
 
const materials = [
  new THREE.MeshBasicMaterial({map: loader.load('resources/images/flower-1.jpg')}),
  new THREE.MeshBasicMaterial({map: loader.load('resources/images/flower-2.jpg')}),
  new THREE.MeshBasicMaterial({map: loader.load('resources/images/flower-3.jpg')}),
  new THREE.MeshBasicMaterial({map: loader.load('resources/images/flower-4.jpg')}),
  new THREE.MeshBasicMaterial({map: loader.load('resources/images/flower-5.jpg')}),
  new THREE.MeshBasicMaterial({map: loader.load('resources/images/flower-6.jpg')}),
];
 
loadManager.onLoad = () => {
  const cube = new THREE.Mesh(geometry, materials);
  scene.add(cube);
  cubes.push(cube);  // 添加到我们要旋转的立方体数组中
};

LoadingManager 也有一个 onProgress 属性,我们可以设置为另一个回调来显示进度指示器。

首先,我们在HTML中添加一个进度条

<body>
  <canvas id="c"></canvas>
  <div id="loading">
    <div class="progress"><div class="progressbar"></div></div>
  </div>
</body>

然后给它加上CSS

#loading {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  display: flex;
  justify-content: center;
  align-items: center;
}
#loading .progress {
  margin: 1.5em;
  border: 1px solid white;
  width: 50vw;
}
#loading .progressbar {
  margin: 2px;
  background: white;
  height: 1em;
  transform-origin: top left;
  transform: scaleX(0);
}

然后在代码中,我们将在 onProgress 回调中更新 progressbar 的比例。调用它有如下几个参数:最后加载的项目的URL,目前加载的项目数量,以及加载的项目总数。

const loadingElem = document.querySelector('#loading');
const progressBarElem = loadingElem.querySelector('.progressbar');

loadManager.onLoad = () => {
  loadingElem.style.display = 'none';
  const cube = new THREE.Mesh(geometry, materials);
  scene.add(cube);
  cubes.push(cube);  // 添加到我们要旋转的立方体数组中
};

loadManager.onProgress = (urlOfLastItemLoaded, itemsLoaded, itemsTotal) => {
  const progress = itemsLoaded / itemsTotal;
  progressBarElem.style.transform = `scaleX(${progress})`;
};

除非你清除了你的缓存,而且连接速度很慢,否则你可能看不到加载栏。

4.从其他源加载纹理

要使用其他服务器上的图片,这些服务器需要发送正确的头文件。如果他们不发送,你就不能在three.js中使用这些图片,并且会得到一个错误。如果你运行提供图片的服务器,请确保它发送正确的头文件.。如果你不控制托管图片的服务器,而且它没有发送权限头文件,那么你就不能使用该服务器上的图片。

例如 imgur、flickr 和 github 都会发送头文件,允许你在 three.js 中使用他们服务器上托管的图片,使用 three.js。而其他大多数网站则不允许。

5.内存管理

纹理往往是three.js应用中使用内存最多的部分。重要的是要明白,一般来说,纹理会占用 宽度 * 高度 * 4 * 1.33 字节的内存。

注意,这里没有提到任何关于压缩的问题。我可以做一个.jpg的图片,然后把它的压缩率设置的超级高。比如说我在做一个房子的场景。在房子里面有一张桌子,我决定在桌子的顶面放上这个木质的纹理

img
img

那张图片只有157k,所以下载起来会比较快,但实际上它的大小是3024×3761像素.。按照上面的公式,那就是

302437614*1.33=60505764.5

在three.js中,这张图片会占用60兆(meg)的内存!。只要几个这样的纹理,你就会用完内存。

我之所以提出这个问题,是因为要知道使用纹理是有隐性成本的。为了让three.js使用纹理,必须把纹理交给GPU,而GPU一般都要求纹理数据不被压缩。

这个故事的寓意在于,不仅仅要让你的纹理的文件大小小,还得让你的纹理尺寸小。文件大小小=下载速度快。尺寸小=占用的内存少。你应该把它们做得多小?越小越好,而且看起来仍然是你需要的样子。

6.JPG vs PNG

这和普通的HTML差不多,JPG有损压缩,PNG有无损压缩,所以PNG的下载速度一般比较慢。但是,PNG支持透明度。PNG可能也适合作为非图像数据(non-image data)的格式,比如法线图,以及其他种类的非图像图,我们后面会介绍。

请记住,在WebGL中JPG使用的内存并不比PNG少。参见上文。

7.过滤和mips

让我们把这个16x16的纹理应用到

img
img

一个立方体上。

img
img

让我们把这个立方体画得非常小

嗯,我想这很难看得清楚。

GPU怎么知道小立方体的每一个像素需要使用哪些颜色?如果立方体小到只有1、2个像素呢?

这就是过滤(filtering)的意义所在。

如果是Photoshop,Photoshop会把几乎所有的像素平均在一起,来计算出这1、2个像素的颜色。这将是一个非常缓慢的操作。GPU用mipmaps解决了这个问题。

Mips 是纹理的副本,每一个都是前一个 mip 的一半宽和一半高,其中的像素已经被混合以制作下一个较小的 mip。Mips一直被创建,直到我们得到1x1像素的Mip。对于上面的图片,所有的Mip最终会变成这样的样子

img
img

现在,当立方体被画得很小,只有1或2个像素大时,GPU可以选择只用最小或次小级别的mip来决定让小立方体变成什么颜色。

在three.js中,当纹理绘制的尺寸大于其原始尺寸时,或者绘制的尺寸小于其原始尺寸时,你都可以做出相应的处理。

当纹理绘制的尺寸大于其原始尺寸时,你可以将 texture.magFilter 属性设置为 THREE.NearestFilter 或 THREE.LinearFilter 。NearestFilter 意味着只需从原始纹理中选取最接近的一个像素。对于低分辨率的纹理,这给你一个非常像素化的外观,就像Minecraft。

LinearFilter 是指从纹理中选择离我们应该选择颜色的地方最近的4个像素,并根据实际点与4个像素的距离,以适当的比例进行混合。

img
img

为了在绘制的纹理小于其原始尺寸时设置过滤器,你可以将 texture.minFilter 属性设置为下面6个值之一。

  • THREE.NearestFilter同上,在纹理中选择最近的像素。
  • THREE.LinearFilter和上面一样,从纹理中选择4个像素,然后混合它们
  • THREE.NearestMipmapNearestFilter选择合适的mip,然后选择一个像素。
  • THREE.NearestMipmapLinearFilter选择2个mips,从每个mips中选择一个像素,混合这2个像素。
  • THREE.LinearMipmapNearestFilter选择合适的mip,然后选择4个像素并将它们混合。
  • THREE.LinearMipmapLinearFilter选择2个mips,从每个mips中选择4个像素,然后将所有8个像素混合成1个像素。

下面是一个分别使用上面6个设置的例子

img
img

需要注意的是,使用 NearestFilter 和 LinearFilter 的左上方和中上方没有使用mips。正因为如此,它们在远处会闪烁,因为GPU是从原始纹理中挑选像素。左边只有一个像素被选取,中间有4个像素被选取并混合,但这还不足以得出一个好的代表颜色。其他4条做得比较好,右下角的LinearMipmapLinearFilter最好。

如果你点击上面的图片,它将在我们上面一直使用的纹理和每一个mip级别都是不同颜色的纹理之间切换。

img
img

这样就更清楚了。在左上角和中上角你可以看到第一个mip一直用到了远处。右上角和中下角你可以清楚地看到哪里使用了不同的mip。

切换回原来的纹理,你可以看到右下角是最平滑的,质量最高的。你可能会问为什么不总是使用这种模式。最明显的原因是有时你希望东西是像素化的,以达到复古的效果或其他原因。其次最常见的原因是,读取8个像素并混合它们比读取1个像素并混合要慢。虽然单个纹理不太可能成为快和慢的区别,但随着我们在这些文章中的进一步深入,我们最终会有同时使用4或5个纹理的材料的情况。4个纹理*每个纹理8个像素,就是查找32个像素的永远渲染的像素。在移动设备上,这一嗲可能需要被重点考虑。

click t

8.重复,偏移,旋转

纹理有重复、偏移和旋转纹理的设置。

默认情况下,three.js中的纹理是不重复的。要设置纹理是否重复,有2个属性,wrapS 用于水平包裹,wrapT 用于垂直包裹。

它们可以被设置为一下其中一个:

  • THREE.ClampToEdgeWrapping每条边上的最后一个像素无限重复。
  • THREE.RepeatWrapping纹理重复
  • THREE.MirroredRepeatWrapping在每次重复时将进行镜像

比如说,要开启两个方向的包裹。

someTexture.wrapS = THREE.RepeatWrapping;
someTexture.wrapT = THREE.RepeatWrapping;

重复是用[repeat]重复属性设置的。

const timesToRepeatHorizontally = 4;
const timesToRepeatVertically = 2;
someTexture.repeat.set(timesToRepeatHorizontally, timesToRepeatVertically);

纹理的偏移可以通过设置 offset 属性来完成。纹理的偏移是以单位为单位的,其中1个单位=1个纹理大小。换句话说,0 = 没有偏移,1 = 偏移一个完整的纹理数量。

const xOffset = .5;   // offset by half the texture
const yOffset = .25;  // offset by 1/4 the texture
someTexture.offset.set(xOffset, yOffset);

通过设置以弧度为单位的 rotation 属性以及用于选择旋转中心的 center 属性,可以设置纹理的旋转。它的默认值是0,0,从左下角开始旋转。像偏移一样,这些单位是以纹理大小为单位的,所以将它们设置为 .5,.5 将会围绕纹理中心旋转。

someTexture.center.set(.5, .5);
someTexture.rotation = THREE.MathUtils.degToRad(45);

让我们修改一下上面的示例,来试试这些属性吧

首先,我们要保留一个对纹理的引用,这样我们就可以对它进行操作。

const texture = loader.load('resources/images/wall.jpg');
const material = new THREE.MeshBasicMaterial({
  map: texture,
});

然后,我们会再次使用 lil-guiopen in new windowopen in new window 来提供一个简单的界面。

import {GUI} from '/examples/jsm/libs/lil-gui.module.min.js';

正如我们在之前的lil-gui例子中所做的那样,我们将使用一个简单的类来给lil-gui提供一个可以以度数为单位进行操作的对象,但它将以弧度为单位设置该属性。

class DegRadHelper {
  constructor(obj, prop) {
    this.obj = obj;
    this.prop = prop;
  }
  get value() {
    return THREE.MathUtils.radToDeg(this.obj[this.prop]);
  }
  set value(v) {
    this.obj[this.prop] = THREE.MathUtils.degToRad(v);
  }
}

我们还需要一个类,将 "123" 这样的字符串转换为 123 这样的数字,因为three.js的枚举设置需要数字,比如 wrapS 和 wrapT,但lil-gui只使用字符串来设置枚举。

class StringToNumberHelper {
  constructor(obj, prop) {
    this.obj = obj;
    this.prop = prop;
  }
  get value() {
    return this.obj[this.prop];
  }
  set value(v) {
    this.obj[this.prop] = parseFloat(v);
  }
}

利用这些类,我们可以为上面的设置设置一个简单的GUI。

const wrapModes = {
  'ClampToEdgeWrapping': THREE.ClampToEdgeWrapping,
  'RepeatWrapping': THREE.RepeatWrapping,
  'MirroredRepeatWrapping': THREE.MirroredRepeatWrapping,
};
 
function updateTexture() {
  texture.needsUpdate = true;
}
 
const gui = new GUI();
gui.add(new StringToNumberHelper(texture, 'wrapS'), 'value', wrapModes)
  .name('texture.wrapS')
  .onChange(updateTexture);
gui.add(new StringToNumberHelper(texture, 'wrapT'), 'value', wrapModes)
  .name('texture.wrapT')
  .onChange(updateTexture);
gui.add(texture.repeat, 'x', 0, 5, .01).name('texture.repeat.x');
gui.add(texture.repeat, 'y', 0, 5, .01).name('texture.repeat.y');
gui.add(texture.offset, 'x', -2, 2, .01).name('texture.offset.x');
gui.add(texture.offset, 'y', -2, 2, .01).name('texture.offset.y');
gui.add(texture.center, 'x', -.5, 1.5, .01).name('texture.center.x');
gui.add(texture.center, 'y', -.5, 1.5, .01).name('texture.center.y');
gui.add(new DegRadHelper(texture, 'rotation'), 'value', -360, 360)
  .name('texture.rotation');

最后需要注意的是,如果你改变了纹理上的 wrapS 或 wrapT,你还必须设置 texture.needsUpdateopen in new windowopen in new window,以便three.js知道并应用这些设置。其他的设置会自动应用。

img
img

这只是进入纹理主题的一个步骤。在某些时候,我们将介绍纹理坐标以及其他9种可应用于材料的纹理类型。

import * as THREE from 'three';
import {GUI} from 'three/addons/libs/lil-gui.module.min.js';

function main() {
  const canvas = document.querySelector('#c');
  const renderer = new THREE.WebGLRenderer({canvas});

  const fov = 75;
  const aspect = 2;  // the canvas default
  const near = 0.1;
  const far = 5;
  const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
  camera.position.z = 2;

  const scene = new THREE.Scene();

  const boxWidth = 1;
  const boxHeight = 1;
  const boxDepth = 1;
  const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);

  const cubes = [];  // just an array we can use to rotate the cubes
  const loader = new THREE.TextureLoader();

  const texture = loader.load('https://threejs.org/manual/examples/resources/images/wall.jpg');
  const material = new THREE.MeshBasicMaterial({
    map: texture,
  });
  const cube = new THREE.Mesh(geometry, material);
  scene.add(cube);
  cubes.push(cube);  // add to our list of cubes to rotate

  class DegRadHelper {
    constructor(obj, prop) {
      this.obj = obj;
      this.prop = prop;
    }
    get value() {
      return THREE.MathUtils.radToDeg(this.obj[this.prop]);
    }
    set value(v) {
      this.obj[this.prop] = THREE.MathUtils.degToRad(v);
    }
  }

  class StringToNumberHelper {
    constructor(obj, prop) {
      this.obj = obj;
      this.prop = prop;
    }
    get value() {
      return this.obj[this.prop];
    }
    set value(v) {
      this.obj[this.prop] = parseFloat(v);
    }
  }

  const wrapModes = {
    'ClampToEdgeWrapping': THREE.ClampToEdgeWrapping,
    'RepeatWrapping': THREE.RepeatWrapping,
    'MirroredRepeatWrapping': THREE.MirroredRepeatWrapping,
  };

  function updateTexture() {
    texture.needsUpdate = true;
  }

  const gui = new GUI();
  gui.add(new StringToNumberHelper(texture, 'wrapS'), 'value', wrapModes)
    .name('texture.wrapS')
    .onChange(updateTexture);
  gui.add(new StringToNumberHelper(texture, 'wrapT'), 'value', wrapModes)
    .name('texture.wrapT')
    .onChange(updateTexture);
  gui.add(texture.repeat, 'x', 0, 5, .01).name('texture.repeat.x');
  gui.add(texture.repeat, 'y', 0, 5, .01).name('texture.repeat.y');
  gui.add(texture.offset, 'x', -2, 2, .01).name('texture.offset.x');
  gui.add(texture.offset, 'y', -2, 2, .01).name('texture.offset.y');
  gui.add(texture.center, 'x', -.5, 1.5, .01).name('texture.center.x');
  gui.add(texture.center, 'y', -.5, 1.5, .01).name('texture.center.y');
  gui.add(new DegRadHelper(texture, 'rotation'), 'value', -360, 360)
    .name('texture.rotation');

  function resizeRendererToDisplaySize(renderer) {
    const canvas = renderer.domElement;
    const width = canvas.clientWidth;
    const height = canvas.clientHeight;
    const needResize = canvas.width !== width || canvas.height !== height;
    if (needResize) {
      renderer.setSize(width, height, false);
    }
    return needResize;
  }

  function render(time) {
    time *= 0.001;

    if (resizeRendererToDisplaySize(renderer)) {
      const canvas = renderer.domElement;
      camera.aspect = canvas.clientWidth / canvas.clientHeight;
      camera.updateProjectionMatrix();
    }

    cubes.forEach((cube, ndx) => {
      const speed = .2 + ndx * .1;
      const rot = time * speed;
      cube.rotation.x = rot;
      cube.rotation.y = rot;
    });

    renderer.render(scene, camera);

    requestAnimationFrame(render);
  }

  requestAnimationFrame(render);
}

main();

7.绘制透明物体

在three.js中,透明很简单,也很困难。

首先,我们来看简单的部分。让我们来制作一个包含8个立方体的场景,它们呈2 * 2 * 2网格排布。

function makeInstance(geometry, color, x, y, z) {
  const material = new THREE.MeshPhongMaterial({color});
 
  const cube = new THREE.Mesh(geometry, material);
  scene.add(cube);
  cube.position.set(x, y, z);
 
  return cube;
}

然后我们来创建8个立方体。

function hsl(h, s, l) {
  return (new THREE.Color()).setHSL(h, s, l);
}



{
  const d = 0.8;
  makeInstance(geometry, hsl(0 / 8, 1, .5), -d, -d, -d);
  makeInstance(geometry, hsl(1 / 8, 1, .5),  d, -d, -d);
  makeInstance(geometry, hsl(2 / 8, 1, .5), -d,  d, -d);
  makeInstance(geometry, hsl(3 / 8, 1, .5),  d,  d, -d);
  makeInstance(geometry, hsl(4 / 8, 1, .5), -d, -d,  d);
  makeInstance(geometry, hsl(5 / 8, 1, .5),  d, -d,  d);
  makeInstance(geometry, hsl(6 / 8, 1, .5), -d,  d,  d);
  makeInstance(geometry, hsl(7 / 8, 1, .5),  d,  d,  d);
}

我也调整了摄像机。

const fov = 75;
const aspect = 2;  // the canvas default
const near = 0.1;
const far = 25;
const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
camera.position.z = 2;

将背景色调整为白色。

const scene = new THREE.Scene();
scene.background = new THREE.Color('white');

还添加了第二个灯光,这样立方体的所有面都可以被照亮。

function addLight(...pos) {
  const color = 0xFFFFFF;
  const intensity = 1;
  const light = new THREE.DirectionalLight(color, intensity);
  light.position.set(...pos);
  scene.add(light);
}
addLight(-1, 2, 4);
addLight( 1, -1, -2);

让立方体变得透明,我们只需要设置transparentopen in new windowopen in new windowopacityopen in new windowopen in new window。opacity为1,物体完全不透明,opacity为0,物体将完全透明。

function makeInstance(geometry, color, x, y, z) {
  const material = new THREE.MeshPhongMaterial({
    color,
    opacity: 0.5,
    transparent: true,
  });

  const cube = new THREE.Mesh(geometry, material);
  scene.add(cube);

  cube.position.set(x, y, z);

  return cube;
}

然后,我们就得到了8个透明的立方体。

img
img

在例子中拖拉,来旋转视图。

这好像很简单,但是拉近一些看。立方体背后的面好像消失了。

img
img

没有后面的面

我们在材质文章中学习了side材质属性。 那么,让我们将side属性设置为THREE.DoubleSide来让每个立方体的所有面都被绘制。

const material = new THREE.MeshPhongMaterial({
  color,
  map: loader.load(url),
  opacity: 0.5,
  transparent: true,
  side: THREE.DoubleSide,
});

然后我们得到了

img
img

试试看,看起来好像起作用了,我们能看到后面的那些面。不过在更近距离的查看中,有些时候还是看不到。

img
img

每个立方体的左后面都消失了

这种情况之所以会发生,是因为3d物体的一般性绘制方式。对于每个几何体,一次绘制一个三角形。 当三角形的一个像素在被绘制的时候,会记录两件事情。一是像素的颜色,二是像素的深度。当下一个三角形被绘制的时候,对于深度大于先前被记录的深度的像素,将不会被绘制。

这种方式,对于不透明的物体工作得很好。不过,对于透明的物体不能正常工作。

这个问题的解决方案是将透明的物体进行排序,排在后面的物体比排在前面的物体先绘制。 THREE.js对于物体,比如Mesh就是这样做的, 否则上面第一个关于立方体的例子将会失败,因为一些立方体遮挡住了其它的立方体。不幸的是,为一个个的三角形进行排序将会十分的慢。

每个立方体有12个三角形,每个面有2个。三角形绘制的顺序和在几何体中构建的顺序是一致的, 取决于我们从哪个方向看向这些三角形,距离摄像机近一些的先被绘制。因此,在后面的那些三角形不会被绘制。这就是我们看不到后面的面的原因。

对于一个凸状物体,比如球体或是立方体,一种解决方案是将每一个立方体添加到场景中两次。一次带有仅绘制后面三角形的材质,另外一次带有仅绘制前面三角形的材质。

function makeInstance(geometry, color, x, y, z) {
  [THREE.BackSide, THREE.FrontSide].forEach((side) => {
    const material = new THREE.MeshPhongMaterial({
      color,
      opacity: 0.5,
      transparent: true,
      side,
    });

    const cube = new THREE.Mesh(geometry, material);
    scene.add(cube);

    cube.position.set(x, y, z);
  });
}

上面的办法好像可以工作。

img
img

它假定了three.js的排序是稳定的,意味着因为我们先添加了side: THREE.BackSide 的物体,还因为两个物体在同样的位置,这个物体将会在 side: THREE.FrontSide 的物体之前被绘制。

让我们制作2个相交的平面(删除了所有和立方体相关的代码)。 我们将会给每个平面添加纹理。

const planeWidth = 1;
const planeHeight = 1;
const geometry = new THREE.PlaneGeometry(planeWidth, planeHeight);

const loader = new THREE.TextureLoader();

function makeInstance(geometry, color, rotY, url) {
  const texture = loader.load(url, render);
  const material = new THREE.MeshPhongMaterial({
    color,
    map: texture,
    opacity: 0.5,
    transparent: true,
    side: THREE.DoubleSide,
  });

  const mesh = new THREE.Mesh(geometry, material);
  scene.add(mesh);

  mesh.rotation.y = rotY;
}

makeInstance(geometry, 'pink',       0,             'resources/images/happyface.png');
makeInstance(geometry, 'lightblue',  Math.PI * 0.5, 'resources/images/hmmmface.png');

这次我们可以使用side: THREE.DoubleSide因为同一时间我们只能看到一个平面的一个面。也请注意到我们将render 函数传递到了纹理加载函数中这样当纹理加载完成的时候,可以重新渲染场景。这是因为这个例子是使用 按需渲染代替了持续渲染。

img
img

我们又一次的看到了类似的问题。

img
img

一半的脸消失不见了

这里的解决方案是手动的将每个平面分割为2个,这样它们实际上就没有了交集。

function makeInstance(geometry, color, rotY, url) {
  const base = new THREE.Object3D();
  scene.add(base);
  base.rotation.y = rotY;

  [-1, 1].forEach((x) => {
    const texture = loader.load(url, render);
    texture.offset.x = x < 0 ? 0 : 0.5;
    texture.repeat.x = .5;
    const material = new THREE.MeshPhongMaterial({
      color,
      map: texture,
      opacity: 0.5,
      transparent: true,
      side: THREE.DoubleSide,
    });

    const mesh = new THREE.Mesh(geometry, material);
    base.add(mesh);

    mesh.position.x = x * .25;
  });
}

你如何完成取决于你。如果我在使用的是Blender这样的模型包,我可能会手动的调整纹理的坐标。这里我们使用的是PlaneGeometry,默认情况下会将纹理拉伸到整个平面。像我们前面讲到过的, 通过设置 texture.repeat和texture.offset,我们可以放缩和移动纹理,在每个平面上得到正确的一半脸的纹理。

上面的代码生成了一个Object3D对象,并且设置为2个平面的parent。旋转一个父级 Object3D 所需要的数学要比没有它时简单一些。

img
img

这种解决方案真的只能用于像2个不会改变相交位置的简单物体。

对于添加了纹理的物体,还有一种解决方案是设置alpha测试。

Alpha测试是指像素的alpha值低于某个水平的时候,three.js就不会绘制它。如果我们根本就不绘制某个像素,那么上面提到的深度问题就消失了。 对于具有相对尖锐边缘的纹理,这种方式工作得很好。例子中包含了树或植物上的叶子纹理或者一片草地。

让我们在两个平面上试一下。首先我们使用不同的纹理。上面的纹理都是100%不透明。现在2个纹理是透明的。

img
img
img
img

回到那两个相交的平面(我们分割之前),让我们使用纹理并且设置alphaTest。

function makeInstance(geometry, color, rotY, url) {
  const texture = loader.load(url, render);
  const material = new THREE.MeshPhongMaterial({
    color,
    map: texture,
    transparent: true,
    alphaTest: 0.5,
    side: THREE.DoubleSide,
  });
 
  const mesh = new THREE.Mesh(geometry, material);
  scene.add(mesh);
 
  mesh.rotation.y = rotY;
}
 

makeInstance(geometry, 'white', 0,             'resources/images/tree-01.png');
makeInstance(geometry, 'white', Math.PI * 0.5, 'resources/images/tree-02.png');

在我们运行之前,让我们添加一点UI,这样我们可以更简单的测试alphaTest 和 transparent 选项。我们将会使用在 three'js中的场景图文章中介绍过的lil-gui。

首先我们为lil-gui创建一个辅助类来为场景中的每种材质设置值。

class AllMaterialPropertyGUIHelper {
  constructor(prop, scene) {
    this.prop = prop;
    this.scene = scene;
  }
  get value() {
    const {scene, prop} = this;
    let v;
    scene.traverse((obj) => {
      if (obj.material && obj.material[prop] !== undefined) {
        v = obj.material[prop];
      }
    });
    return v;
  }
  set value(v) {
    const {scene, prop} = this;
    scene.traverse((obj) => {
      if (obj.material && obj.material[prop] !== undefined) {
        obj.material[prop] = v;
        obj.material.needsUpdate = true;
      }
    });
  }
}

然后我们来添加窗口。

const gui = new GUI();
gui.add(new AllMaterialPropertyGUIHelper('alphaTest', scene), 'value', 0, 1)
  .name('alphaTest')
  .onChange(requestRenderIfNotRequested);
gui.add(new AllMaterialPropertyGUIHelper('transparent', scene), 'value')
  .name('transparent')
  .onChange(requestRenderIfNotRequested);

当然我们需要引用lil-gui。

import * as THREE from '/build/three.module.js';
import {OrbitControls} from '/examples/jsm/controls/OrbitControls.js';
import {GUI} from '/examples/jsm/libs/lil-gui.module.min.js';

下面是结果。

img
img

可以看到起作用了,但是当放大看的时候,你可以看到一个平面有白色的线条。

img
img

这也是我们上面提到的深度问题。那个平面先被绘制,因此后面的平面将不会被绘制。 没有完美的解决方案。调整alphaTest并且打开或关闭 transparent来为你的场景寻找一个合适的解决方案。

从文章中可以知道,完美的透明是困难的,有着各种问题、取舍和变通方法。

举例来说,你有一辆车。汽车通常会在4个面上有挡风玻璃。如果你想要避免上面提到的排序问题, 你可能不得不将每一扇窗户成为它自己的物体,以便three.js可以排序这些窗户并以正确的顺序绘制它们。

如果你在制作一些植物或是草地,alpha测试是常用的解决方案。

采用那种方案取决于你的需求。

属性(attributes)

法相量(normal)

顶点位置(position)

//设置一个定点的数组
const vertices = new Float32Array([-1.0, -1.0, 1.0, 1.0, -1.0, 1.0, 1.0, 1.0, 1.0,1.0,1.0,1.0,-1.0,1.0,1.0,-1.0,-1.0,1.0])
// 创建一个几何体
const geometry = new THREE.BufferGeometry();
//把顶点数组添加到几何体中
geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3));
geometry.setAttribute()
//创建一个基本的材质
const basicMaterial = new THREE.MeshBasicMaterial({color: 0xffff00});
//根据几何体和材质创建一个网格
const mesh = new THREE.Mesh(geometry, basicMaterial);
scene.add(mesh);
console.log(mesh)

uv坐标(uv)

随机生成固定规模的几何体

for(let i=0;i<50;i++){
    //创建一个几何体
    const geometry = new THREE.BufferGeometry();
    ////每一个三角形需要三个顶点,三个顶点需要三个坐标,每个坐标需要三个分量
    const verticesArray  = new Float32Array(9)
    for(let j=0;j<9;j++){
      verticesArray[j] = Math.random()*10-5
    }
    //把顶点数组添加到几何体中
    geometry.setAttribute('position', new THREE.BufferAttribute(verticesArray, 3));
    //随机颜色
    const color = new THREE.Color(Math.random(),Math.random(),Math.random())
    //设置材质:颜色,材质是否透明,透明度
    const basicMaterial = new THREE.MeshBasicMaterial({color: color,transparent:true,opacity:0.5});
    //根据几何体和材质创建一个网格
    const mesh = new THREE.Mesh(geometry, basicMaterial);
    scene.add(mesh);
}

光照与阴影

阴影

    • 平行光(DirectionalLight)
    • 聚光灯(SpotLight)
    • 点光源(PointLight)
    • 平面光光源(RectAreaLight)
  • 材质
    • 基础网格材质
    • 标准网格材质
    • labert网格材质
    • phong网格材质
    • 物理网格材质
    • MeshToonMaterial

步骤

  1. 材质要满足能够对光源产生反应
  2. 设置渲染器开启阴影的渲染 renderer.shadowMap.enabled = true;
  3. 设置光照投射阴影 directionalLight.castShadow = true;
  4. 设置物体投射阴影 mesh.receiveShadow = true;
  5. 设置物体接收阴影 mesh.castShadow = true;

第一件事是设置渲染器中的阴影属性

const renderer = new THREE.WebGLRenderer({ canvas });
renderer.shadowMap.enabled = true;

我们还需要设置光能投射阴影

const directionalLight = new THREE.DirectionalLight(color, intensity);
directionalLight.castShadow = true;

在场景中的每个网格,我们都能设置它是否能投射阴影或被投射阴影。 这里我们只设置地面能被投射阴影,这样我们不需要关心地面投射阴影的问题。

const mesh = new THREE.Mesh(planeGeo, planeMat);
mesh.receiveShadow = true;

对于球体和立方体,我们需要设置他们都能投射阴影或者被投射阴影

const mesh = new THREE.Mesh(cubeGeo, cubeMat);
mesh.castShadow = true;
mesh.receiveShadow = true;

...

const mesh = new THREE.Mesh(sphereGeo, sphereMat);
mesh.castShadow = true;
mesh.receiveShadow = true;

设置阴影贴图的模糊度(radiusopen in new window)

//设置阴影贴图的模糊度
directionalLight.shadow.radius = 20;

设置阴影贴图的分辨率(mapSizeopen in new window : Vector2open in new window)

  • 尽量设置512px的倍数
//设置阴影贴图的分辨率
directionalLight.shadow.mapSize.set(2048, 2048);

灯光(Light)

环境光(AmbientLight)

  • 环境光,它没有方向,无法产生阴影,场景内任何一点受到的光照强度都是相同的,除了改变场景内所有物体的颜色以外,不会使物体产生明暗的变化,看起来并不像真正意义上的光照。通常的作用是提亮场景,让暗部不要太暗。
//环境光
const light = new THREE.AmbientLight(0xffffff, 1);
scene.add(light);

平行光(DirectionalLight)

  • 平行光 又叫方向光(DirectionalLight)常常用来表现太阳光照的效果。
//平行光
const directionalLight = new THREE.DirectionalLight(0xffffff);
directionalLight.position.set(10, 10, 10);
scene.add(directionalLight);

设置平行光投射相机的属性

//平型光
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.5);
directionalLight.position.set(20, 20, 20);
//3.设置光照投射阴影
directionalLight.castShadow = true;
//设置阴影贴图的模糊度
directionalLight.shadow.radius = 20;
//设置阴影贴图的分辨率
directionalLight.shadow.mapSize.set(2048, 2048);
//设置平行光投射相机的属性
directionalLight.shadow.camera.left = -10;
directionalLight.shadow.camera.right = 10;
directionalLight.shadow.camera.top = 10;
directionalLight.shadow.camera.bottom = -10;
directionalLight.shadow.camera.near = 0.5;
directionalLight.shadow.camera.far = 500;
scene.add(directionalLight);
//导入gui
import * as dat from 'dat.gui';
//设置gui
const gui = new dat.GUI();
const cameraFolder = gui.addFolder('设置相机')
cameraFolder.add(spotLight.shadow.camera,'near').min(0).max(20).step(0.1).name('近平面').onChange(()=>{
    //更新相机的摄影矩阵
    spotLight.shadow.camera.updateProjectionMatrix();
})
cameraFolder.add(sphere.position,'x').min(-20).max(20).step(0.1).name('x' +
    '轴').onChange(()=>{
    //更新相机的摄影矩阵
    spotLight.shadow.camera.updateProjectionMatrix();
})
cameraFolder.add(spotLight,'angle').min(0).max(Math.PI/3).step(Math.PI/36).name('设置角度的大小').onChange(()=>{
    //更新相机的摄影矩阵
    spotLight.shadow.camera.updateProjectionMatrix();
})
cameraFolder.add(spotLight,'distance').min(0).max(100).step(0.1).name('聚光灯的衰减').onChange(()=>{
    //更新相机的摄影矩阵
    spotLight.shadow.camera.updateProjectionMatrix();
})
cameraFolder.add(spotLight,'penumbra').min(0).max(1).step(0.1).name('聚光灯半影的衰减').onChange(()=>{
    //更新相机的摄影矩阵
    spotLight.shadow.camera.updateProjectionMatrix();
})
cameraFolder.add(spotLight,'decay').min(0).max(2).step(0.1).name('沿着光照距离衰减').onChange(()=>{
    //更新相机的摄影矩阵
    spotLight.shadow.camera.updateProjectionMatrix();
})

聚光灯(SpotLightopen in new window

  • 聚光灯可以看成是一个点光源被一个圆锥体限制住了光照的范围。实际上有两个圆锥,内圆锥和外圆锥。光照强度在两个锥体之间从设定的强度递减到 0(具体可以看下方 penumbra 参数)。
  • 聚光灯(SpotLight)类似方向光(DirectionalLight)一样需要一个目标点,光源的位置是圆锥的顶点,目标点处于圆锥的中轴线上。
//聚光灯
const spotLight = new THREE.SpotLight(0xffffff, 0.5);
//调节聚光灯的亮度
spotLight.intensity = 2;
//设置聚光灯的位置
spotLight.position.set(10, 10, 10);
//设置聚光灯投射阴影
spotLight.castShadow = true;
//设置阴影贴图的分辨率
spotLight.shadow.mapSize.set(4096, 4096)
//设置阴影贴图的模糊度
spotLight.shadow.radius = 20;
//聚光灯的目标
spotLight.target = sphere;
//聚光灯的角度
spotLight.angle = Math.PI / 4;
//聚光灯的衰减
spotLight.distance = 100;
//聚光灯半影衰减
spotLight.penumbra = 0.5;
//沿着光照距离衰减
spotLight.decay = 0.5;
//设置聚光灯透视相机的属性
spotLight.shadow.camera.near = 0.5;
spotLight.shadow.camera.far = 500;
spotLight.shadow.camera.fov = 30;
//设置聚光灯的角度
scene.add(spotLight);

注意

  • 设置pointLight.decay必须要注意下面渲染器的开启
//沿着光照距离进行渲染,需要对渲染器的physicallyCorrectLights属性进行设置
renderer.physicallyCorrectLights = true;

点光源(PointLight)

  • 点光源(PointLight)表示的是从一个点朝各个方向发射出光线的一种光照效果。我们修改
//点光源
const pointLight = new THREE.PointLight(0xff0000, 2);
//设置点光源的位置
pointLight.position.set(2, 2, 2);
//设置点光源投射的阴影
pointLight.castShadow = true;
//设置点光源的模糊度
pointLight.shadow.radius = 20;
//设置点光源的阴影贴图的分辨率
pointLight.shadow.mapSize.set(4096, 4096);
//设置点光源的目标对象
pointLight.target = sphere;
//设置点光源的角度
pointLight.angle = Math.PI / 6;
//设置点光源的衰减
pointLight.distance = 0;
//设置点光源的半影衰减
pointLight.penumbra = 0.5;
//设置点光源沿着光照的衰减
pointLight.decay = 0.5;
scene.add(pointLight);

注意

  • 设置pointLight.decay必须要注意下面渲染器的开启
//沿着光照距离进行渲染,需要对渲染器的physicallyCorrectLights属性进行设置
renderer.physicallyCorrectLights = true;

根据时间光源绕着物体旋转移动

//时间函数
const clock = new THREE.Clock();
//渲染函数
const render = () => {
    //根据时间小球绕着球体旋转
    const elapsedTime = clock.getElapsedTime();
    smallSphere.position.x = 2 * Math.cos(elapsedTime);
    smallSphere.position.z = 2 * Math.sin(elapsedTime);
    //更新控制器
    controls.update();
    //渲染器渲染
    renderer.render(scene, camera);
    //请求再次渲染
    requestAnimationFrame(render);
}
//调用渲染函数
render();

半球光(HemisphereLight)

半球光(HemisphereLight)的颜色是从天空到地面两个颜色之间的渐变,与物体材质的颜色作叠加后得到最终的颜色效果。一个点受到的光照颜色是由所在平面的朝向(法向量)决定的 —— 面向正上方就受到天空的光照颜色,面向正下方就受到地面的光照颜色,其他角度则是两个颜色渐变区间的颜色。

const skyColor = 0xB1E1FF;  // light blue
const groundColor = 0xB97A20;  // brownish orange
const intensity = 1;
const light = new THREE.HemisphereLight(skyColor, groundColor, intensity);
scene.add(light);

矩形区域光(RectAreaLight)

  • Three.js 中还有一种类型的光照,矩形区域光(RectAreaLight), 顾名思义,表示一个矩形区域的发射出来的光照,例如长条的日光灯或者天花板上磨砂玻璃透进来的自然光。
  • RectAreaLight 只能影响 MeshStandardMaterial 和 MeshPhysicalMaterial,所以我们把所有的材质都改为 MeshStandardMaterial。
 const planeGeo = new THREE.PlaneGeometry(planeSize, planeSize);
  const planeMat = new THREE.MeshStandardMaterial({
    map: texture,
    side: THREE.DoubleSide,
  });
  const mesh = new THREE.Mesh(planeGeo, planeMat);
  mesh.rotation.x = Math.PI * -.5;
  scene.add(mesh);
}
{
  const cubeSize = 4;
  const cubeGeo = new THREE.BoxGeometry(cubeSize, cubeSize, cubeSize);
 const cubeMat = new THREE.MeshStandardMaterial({color: '#8AC'});
  const mesh = new THREE.Mesh(cubeGeo, cubeMat);
  mesh.position.set(cubeSize + 1, cubeSize / 2, 0);
  scene.add(mesh);
}
{
  const sphereRadius = 3;
  const sphereWidthDivisions = 32;
  const sphereHeightDivisions = 16;
  const sphereGeo = new THREE.SphereGeometry(sphereRadius, sphereWidthDivisions, sphereHeightDivisions);
 const sphereMat = new THREE.MeshStandardMaterial({color: '#CA8'});
  const mesh = new THREE.Mesh(sphereGeo, sphereMat);
  mesh.position.set(-sphereRadius - 1, sphereRadius + 2, 0);
  scene.add(mesh);
}

为了使用 RectAreaLight,我们需要引入 three.js 的RectAreaLightUniformsLib 模块,同时使用 RectAreaLightHelper 来辅助查看灯光对象。

import * as THREE from '/build/three.module.js';
import {RectAreaLightUniformsLib} from '/examples/jsm/lights/RectAreaLightUniformsLib.js';
import {RectAreaLightHelper} from '/examples/jsm/helpers/RectAreaLightHelper.js';

我们需要先调用 RectAreaLightUniformsLib.init

function main() {
  const canvas = document.querySelector('#c');
  const renderer = new THREE.WebGLRenderer({canvas});
  RectAreaLightUniformsLib.init();

如果忘了引入和使用 RectAreaLightUniformsLib,光照还是可以显示,但是会看起来很奇怪(译者注:在示例的简单场景中没有发现区别),所以要确保有使用。

然后我们可以创建光照了

const color = 0xFFFFFF;
const intensity = 5;
const width = 12;
const height = 4;
const light = new THREE.RectAreaLight(color, intensity, width, height);
light.position.set(0, 10, 0);
light.rotation.x = THREE.MathUtils.degToRad(-90);
scene.add(light);

const helper = new RectAreaLightHelper(light);
light.add(helper);

需要注意的是,与方向光(DirectionalLight)和聚光灯(SpotLight)不同,矩形光不是使用目标点(target),而是使用自身的旋转角度来确定光照方向。另外,矩形光的辅助对象(RectAreaLightHelper)应该添加为光照的子节点,而不是添加为场景的子节点。

同时我们修改一下 GUI 代码,使我们可以旋转光源,调整 width 和 height 属性。

const gui = new GUI();
gui.addColor(new ColorGUIHelper(light, 'color'), 'value').name('color');
gui.add(light, 'intensity', 0, 10, 0.01);
gui.add(light, 'width', 0, 20);
gui.add(light, 'height', 0, 20);
gui.add(new DegRadHelper(light.rotation, 'x'), 'value', -180, 180).name('x rotation');
gui.add(new DegRadHelper(light.rotation, 'y'), 'value', -180, 180).name('y rotation');
gui.add(new DegRadHelper(light.rotation, 'z'), 'value', -180, 180).name('z rotation');

makeXYZGUI(gui, light.position, 'position');

场景如下所示:

img
img

关于光照,我们尚未提及的是 WebGLRenderer 中有一个设置项 physicallyCorrectLights。这个设置会影响(随着离光源的距离增加)光照如何减弱。这个设置会影响点光源(PointLight)和聚光灯(SpotLight),矩形区域光(RectAreaLight)会自动应用这个特性。

在设置光照时,基本思路是不要设置 distance 来表现光照的衰减,也不要设置 intensity。而是设置光照的 power 属性,以流明为单位,three.js 会进行物理计算,从而表现出接近真实的光照效果。在这种情况下 three.js 参与计算的长度单位是米,一个 60瓦 的灯泡大概是 800 流明强度。并且光源有一个 decay 属性,为了模拟真实效果,应该被设置为 2。

下面让我们测试看看。

首先开启 physicallyCorrectLights 模式

const renderer = new THREE.WebGLRenderer({canvas});
renderer.physicallyCorrectLights = true;

然后我们设置光照的参数,power 设置为 800 流明,decay 设置为 2,distance 设置为 Infinity。

const color = 0xFFFFFF;
const intensity = 1;
const light = new THREE.PointLight(color, intensity);
light.power = 800;
light.decay = 2;
light.distance = Infinity;

并且添加 gui 控制 power 和 decay

const gui = new GUI();
gui.addColor(new ColorGUIHelper(light, 'color'), 'value').name('color');
gui.add(light, 'decay', 0, 4, 0.01);
gui.add(light, 'power', 0, 2000);
img
img

需要注意,每添加一个光源到场景中,都会降低 three.js 渲染场景的速度,所以应该尽量使用最少的资源来实现想要的效果。

import * as THREE from 'three';
import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
import {GUI} from 'three/addons/libs/lil-gui.module.min.js';

function main() {
  const canvas = document.querySelector('#c');
  const renderer = new THREE.WebGLRenderer({canvas});
  renderer.physicallyCorrectLights = true;

  const fov = 45;
  const aspect = 2;  // the canvas default
  const near = 0.1;
  const far = 100;
  const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
  camera.position.set(0, 10, 20);

  const controls = new OrbitControls(camera, canvas);
  controls.target.set(0, 5, 0);
  controls.update();

  const scene = new THREE.Scene();
  scene.background = new THREE.Color('black');

  {
    const planeSize = 40;

    const loader = new THREE.TextureLoader();
    const texture = loader.load('https://threejs.org/manual/examples/resources/images/checker.png');
    texture.wrapS = THREE.RepeatWrapping;
    texture.wrapT = THREE.RepeatWrapping;
    texture.magFilter = THREE.NearestFilter;
    const repeats = planeSize / 2;
    texture.repeat.set(repeats, repeats);

    const planeGeo = new THREE.PlaneGeometry(planeSize, planeSize);
    const planeMat = new THREE.MeshPhongMaterial({
      map: texture,
      side: THREE.DoubleSide,
    });
    const mesh = new THREE.Mesh(planeGeo, planeMat);
    mesh.rotation.x = Math.PI * -.5;
    scene.add(mesh);
  }
  {
    const cubeSize = 4;
    const cubeGeo = new THREE.BoxGeometry(cubeSize, cubeSize, cubeSize);
    const cubeMat = new THREE.MeshPhongMaterial({color: '#8AC'});
    const mesh = new THREE.Mesh(cubeGeo, cubeMat);
    mesh.position.set(cubeSize + 1, cubeSize / 2, 0);
    scene.add(mesh);
  }
  {
    const sphereRadius = 3;
    const sphereWidthDivisions = 32;
    const sphereHeightDivisions = 16;
    const sphereGeo = new THREE.SphereGeometry(sphereRadius, sphereWidthDivisions, sphereHeightDivisions);
    const sphereMat = new THREE.MeshPhongMaterial({color: '#CA8'});
    const mesh = new THREE.Mesh(sphereGeo, sphereMat);
    mesh.position.set(-sphereRadius - 1, sphereRadius + 2, 0);
    scene.add(mesh);
  }

  class ColorGUIHelper {
    constructor(object, prop) {
      this.object = object;
      this.prop = prop;
    }
    get value() {
      return `#${this.object[this.prop].getHexString()}`;
    }
    set value(hexString) {
      this.object[this.prop].set(hexString);
    }
  }

  function makeXYZGUI(gui, vector3, name, onChangeFn) {
    const folder = gui.addFolder(name);
    folder.add(vector3, 'x', -10, 10).onChange(onChangeFn);
    folder.add(vector3, 'y', 0, 10).onChange(onChangeFn);
    folder.add(vector3, 'z', -10, 10).onChange(onChangeFn);
    folder.open();
  }

  {
    const color = 0xFFFFFF;
    const intensity = 1;
    const light = new THREE.PointLight(color, intensity);
    light.power = 800;
    light.decay = 2;
    light.distance = Infinity;
    light.position.set(0, 10, 0);
    scene.add(light);

    const helper = new THREE.PointLightHelper(light);
    scene.add(helper);

    const gui = new GUI();
    gui.addColor(new ColorGUIHelper(light, 'color'), 'value').name('color');
    gui.add(light, 'decay', 0, 4, 0.01);
    gui.add(light, 'power', 0, 1220);

    makeXYZGUI(gui, light.position, 'position');
  }

  function resizeRendererToDisplaySize(renderer) {
    const canvas = renderer.domElement;
    const width = canvas.clientWidth;
    const height = canvas.clientHeight;
    const needResize = canvas.width !== width || canvas.height !== height;
    if (needResize) {
      renderer.setSize(width, height, false);
    }
    return needResize;
  }

  function render() {

    if (resizeRendererToDisplaySize(renderer)) {
      const canvas = renderer.domElement;
      camera.aspect = canvas.clientWidth / canvas.clientHeight;
      camera.updateProjectionMatrix();
    }

    renderer.render(scene, camera);

    requestAnimationFrame(render);
  }

  requestAnimationFrame(render);
}

main();

加载器

  • 与上文纹理详解下面的加载纹理一致

纹理加载器(TextureLoader)

//单张纹理图的加载进度
let loadEvent = {}
loadEvent.onLoad = () => {
    console.log('纹理图加载完成')
}, loadEvent.onProgress = (e) => {
    console.log('图片加载进度', e)
}, loadEvent.onError = (e) => {
    console.log('图片加载错误', e)
}
const normalTexture = new THREE.TextureLoader().load('./textures/material01.jpg', loadEvent.onLoad, loadEvent.onProgress, loadEvent.onError);

加载管理器(LoadingManager)

//多张纹理图的加载进度
//左上角显示进度条
const div = document.createElement('div')
div.style.width = '200px'
div.style.height = '50px'
div.style.position = 'fixed'
div.style.top = '0'
div.style.left= '0'
div.style.color = '#fff'
document.body.appendChild(div)
let loadEvent = {}
loadEvent.onLoad = () => {
    console.log('纹理图加载完成')
}
loadEvent.onProgress = (url, num, total) => {
    console.log('现在加载纹理图片的地址:', url)
    console.log('图片加载进度:', num)
    console.log('加载器中纹理图片的总数:', total)
    let percent = `${(num / total * 100).toFixed(2)}%`
    console.log('加载器中纹理图片的加载进度的百分比:', percent)
    div.innerHTML = percent
}
loadEvent.onError = (e) => {
    console.log('图片加载错误', e)
}
const loadingManager = new THREE.LoadingManager(loadEvent.onLoad, loadEvent.onProgress, loadEvent.onError);
const Texture = new THREE.TextureLoader(loadingManager)
const normalTexture = Texture.load('./textures/material01.jpg');

材质(Material)

纹理(texture)

//创建一个几何体
const cubeGeometry = new THREE.BoxGeometry(1, 1, 1);
//导入纹理,纹理图片必须是dist目录下的
const texture = new THREE.TextureLoader().load('./textures/material01.jpg');
//创建一个基本的材质
const basicMaterial = new THREE.MeshBasicMaterial({color: 0xffff00,map:texture});
//根据几何体和材质创建一个网格
const cube = new THREE.Mesh(cubeGeometry, basicMaterial);
//把网格添加到场景中
scene.add(cube);

纹理属性

偏移量(offset)

texture.offset.set(2,2)
texture.offset.x = 0.5
texture.offset.y = 0.5

旋转(rotation)

texture.roatation = Math.PI/4

旋转中心(center)

texture.center.set(0.5,0.5)
texture.center.x = 0.5
texture.center.y = 0.5

重复次数(repeat)

//纹理的重复次数 重复的次数越多,纹理越小 u2v2
texture.repeat.set(2,2)
texture.repeat.x = 2
texture.repeat.y = 2

重复模式(wrapS)

  • THREE.RepeatWrapping:无限重复
  • THREE.MirroredRepeatWrapping:镜像重复
  • THREE.NearestFilter:最近点采样
  • THREE.ClampToEdgeWrapping :默认
//纹理的重复次数 重复的次数越多,纹理越小 u2v2
texture.repeat.set(3,3)
// texture.repeat.x = 2
// texture.repeat.y = 2
// 设置纹理重复的模式,水平
texture.wrapS = THREE.RepeatWrapping
// 设置纹理重复的模式,垂直
texture.wrapT = THREE.MirroredRepeatWrapping

纹理算法

.magFilteropen in new window : number

当一个纹素覆盖大于一个像素时,贴图将如何采样。默认值为THREE.LinearFilteropen in new window, 它将获取四个最接近的纹素,并在他们之间进行双线性插值。 另一个选项是THREE.NearestFilteropen in new window,它将使用最接近的纹素的值。 请参阅texture constantsopen in new window页面来了解详细信息。

.minFilteropen in new window : number

当一个纹素覆盖小于一个像素时,贴图将如何采样。默认值为THREE.LinearMipmapLinearFilteropen in new window, 它将使用mipmapping以及三次线性滤镜。

//texture纹理的显示设置
texture.minFilter = THREE.NearestFilter
texture.magFilter = THREE.NearestFilter
texture.magFilter = THREE.LinearFilter
texture.minFilter = THREE.LinearFilter
texture.magFilter = THREE.NearestMipMapNearestFilter
texture.minFilter = THREE.NearestMipMapNearestFilter
texture.magFilter = THREE.LinearMipmapLinearFilter
texture.minFilter = THREE.LinearMipmapLinearFilter

透明纹理(alphaMap)

01:透明不透明

//创建一个基本的材质,设置材质的颜色,设置材质的纹理,设置材质是否可以透明,设置材质的透明度贴图
const basicMaterial = new THREE.MeshBasicMaterial({color: 0xffff00,map:texture,transparent:true,alphaMap:texture});

渲染面(side)

//创建一个基本的材质,设置材质的颜色,设置材质的纹理,设置材质是否可以透明,设置材质的透明度贴图,透明度,设置材质的双面显示
const basicMaterial = new THREE.MeshBasicMaterial({color: 0xffff00,map:texture,transparent:true,alphaMap:texture,opacity:0.5,side:THREE.DoubleSide});
basicMaterial.side = THREE.DoubleSide

AO环境遮挡贴图(aoMap)

  • 该纹理的红色通道用作环境遮挡贴图。默认值为null。aoMap需要第二组UV。
//给几何体设置第二组uv坐标
cubeGeometry.setAttribute('uv2',new THREE.BufferAttribute(cubeGeometry.attributes.uv.array,2))

AO环境遮挡效果(aoMapIntensity)

  • 环境遮挡效果的强度。默认值为1。零是不遮挡效果。

置换立体贴图(displacementMap)

  • 位移贴图会影响网格顶点的位置,与仅影响材质的光照和阴影的其他贴图不同,移位的顶点可以投射阴影,阻挡其他对象, 以及充当真实的几何体。位移纹理是指:网格的所有顶点被映射为图像中每个像素的值(白色是最高的),并且被重定位。
//导入置换立体贴图
const displacementTexture = new THREE.TextureLoader().load('./textures/material01.jpg');

粗糙度贴图(roughness,roughnessMap)

  • roughness:材质的粗糙程度。0.0表示平滑的镜面反射,1.0表示完全漫反射。默认值为1.0。如果还提供roughnessMap,则两个值相乘。
  • roughnessMap:该纹理的绿色通道用于改变材质的粗糙度。
//导入粗糙度贴图
const roughnessTexture = new THREE.TextureLoader().load('./textures/material01.jpg');

金属度贴图(metalness,metalnessMap)

  • metalness:材质与金属的相似度。非金属材质,如木材或石材,使用0.0,金属使用1.0,通常没有中间值。 默认值为0.0。0.0到1.0之间的值可用于生锈金属的外观。如果还提供了metalnessMap,则两个值相乘。
  • metalnessMap:该纹理的蓝色通道用于改变材质的金属度。
//导入金属贴图
const metalnessTexture = new THREE.TextureLoader().load('./textures/material01.jpg');

法线贴图(normalMap,normalMapType)

  • normalMap:用于创建法线贴图的纹理。RGB值会影响每个像素片段的曲面法线,并更改颜色照亮的方式。法线贴图不会改变曲面的实际形状,只会改变光照。 In case the material has a normal map authored using the left handed convention, the y component of normalScale should be negated to compensate for the different handedness.
  • normalMapType:法线贴图的类型。选项为THREE.TangentSpaceNormalMap(默认)和THREE.ObjectSpaceNormalMap。
//导入法线贴图
const normalTexture = new THREE.TextureLoader().load('./textures/material01.jpg');

环境贴图(envMap)

const cubeTextureLoader = new THREE.CubeTextureLoader();
const envMapsTexture = cubeTextureLoader.load([
    '/textures/cube/px.jpg',
    '/textures/cube/nx.jpg',
    '/textures/cube/py.jpg',
    '/textures/cube/ny.jpg',
    '/textures/cube/pz.jpg',
    '/textures/cube/nz.jpg',
])

给场景添加背景(background,environment)

  • background :若不为空,在渲染场景的时候将设置背景,且背景总是首先被渲染的。 可以设置一个用于的“clear”的Color(颜色)、一个覆盖canvas的Texture(纹理), 或是 a cubemap as a CubeTexture or an equirectangular as a Texture。默认值为null。
  • environment : 若该值不为null,则该纹理贴图将会被设为场景中所有物理材质的环境贴图。 然而,该属性不能够覆盖已存在的、已分配给 MeshStandardMaterial.envMap 的贴图。默认为null。
scene.background = envMapsTexture;
scene.environment = envMapsTexture;

HDR贴图(RGBELoader)

//导入hdr环境贴图
import { RGBELoader } from 'three/examples/jsm/loaders/RGBELoader.js';
//加载HDR贴图
const rgbeLoader = new RGBELoader();
rgbeLoader.loadAsync('textures/office.hdr').then((texture) => {
    scene.background = texture;
    scene.environment = texture;
})

纹理映射(Texture-->mapping)

经纬线映射贴图(EquirectangularReflectionMapping)

rgbeLoader.loadAsync('textures/office.hdr').then((texture) => {
    texture.mapping = THREE.EquirectangularReflectionMapping;
    scene.background = texture;
    scene.environment = texture;
})

PBR

什么是PBR

  • 基于物理渲染
  • 以前的渲染是在模仿灯光的外观
  • 现在的渲染是在模仿光的实际行为

BPR组成部分

  • 灯光属性:直接照明(Direct Diffuse),间接照明(Indirect Diffuse),直接高光(Direct Specular),间接高光(Indirect Sprcular),阴影(shadows),环境光闭塞(Ambient seclusion)
  • 表面属性:基础色(Base Color),法线(normal),高光(Specular),粗糙度(Roughness),金属度(Metallic)

灯光属性

光线类型

  • 入射光
    • 直接照明(Direct Diffuse):直接从光源表面发射阴影表面的光
    • 间接照明(Indirect Diffuse):环境光和直接光经过反射第二次进入的光
  • 反射光
    • 镜面光:在进过表面反色聚焦在同一方向上进入人眼多的高亮光
    • 漫反射:光被散射并沿着各个方向离开表面

光与表面相互作用的类型

  • 直接漫反射:来自头源,从四面八方散发出来的直接高光
  • 直接高光(Direct Specular):直接来自光源并被集中反射的光
  • 间接漫反射:来自环境的光被表面散射的光
  • 间接高光(Indirect Sprcular 镜面反射):来自环境并被集中反射的光

阴影(shadows)

环境光闭塞(Ambient seclusion)

表面属性

基础色

  • 基础色贴图
    • 不包括任何照明或阴影
    • 基本颜色纹理应该看起来非常平坦
    • 使用真实世界的度量或获取最佳结果的数据

法线

  • 三维空间向量就可以计算出这个方向照射物体发出的颜色
    • 定义曲面的形状每个像素代表一个矢量
    • 该矢量指示表面所面对的方向即使是网格也是完全平坦的
    • 用于添加表面形状上的细节,这里三角形是实现不了的
    • 因为他们表示矢量数据,所以法线贴图是无法手工绘制的

高光(镜面光)

  • 用于直接和间接镜面照明的叠加
  • 直视表面时,定义反射率
  • 非金属表面反射约4%的光
  • 0.5代表4%的反射
  • 1.0代表8%的反射但对于大多数物体来说太高了
  • 在掠射角下,所有表面都是100%反射的,内置与引擎中的菲涅耳项
  • 镜面贴图
    • 高光贴图应该在0.5
    • 使用深色阴影来遮盖不应该反射光的裂缝
    • 一个裂缝贴图乘以0.5就是一个很好的高光贴图

粗糙度

  • 表面在微观尺度上的粗糙度
  • 表面是粗糙的
  • 黑色是光滑的
  • 控制反射的“焦点”
  • 平滑=强烈的反射
  • 粗糙=模糊的漫反射
  • 粗糙度贴图注意事项:
    • 没有技术限制,完全的艺术选择
    • 艺术家可以使用这张地图来定义表面的特征,并展示它的历史
    • 考虑一下被打磨光滑,磨损或老化的表面

金属度

  • 俩种不同的着色器通过金属度混合它们
  • 基本色编程高光色而不是漫反射颜色
  • 金属漫反射下是黑颜色的
  • 在底色下,镜面范围可达100%
  • 大多数金属的反光性在60%~100%'
  • 确保对金属颜色值使用的真实世界的测量值,并保持他们明亮
  • 当金属度为1时,镜面输入被忽略
  • 粗糙度贴图制作的注意事项
    • 将着色器切换到金属模式
    • 灰度值很奇怪时,最好用存白或者纯黑
    • 当金属色为白色时,请确保使用正确的金属底色值
    • 没有暗黑金属
    • 所有金属均为180srgb或更亮

金属和非金属的区别

  • 非金属:
    • 基础颜色=漫反射
    • 镜面反色=0.8%
  • 金属
    • 基础颜色= 0~100%的镜面反射
    • 镜面=0%
    • 漫反射总是黑色的

HDR

  • **HDR(High-Dynamic Range)**简单来说就是一种提高影像亮度和对比度的处理技术,它可以将每个暗部的细节变亮,暗的地方更暗,丰富更多细节色彩,让电影,图片都能呈现出极佳的效果。让你在观影时更接近真实环境中的视觉感受,这就是HDR存在的意义。传统SDR(标准对比度)最高亮度只有100nit,画面中高于100nit的部分将被失真(丢失),最低调试为0.1nit,画面中低于0.1nit的部分将被丢失。HDR技术的出现,让最高亮度达到数千nit,,最低亮度达到了0.0005nit,极大的拓展了画面中亮度高于100nit以及低于0.1nit部分的细节,同时让整幅画面看上去更加通透明快、细节丰富。
上次编辑于:
贡献者: 林深不见鹿