Texture
即纹理,可以实现给材质添加上图片等功能
声明时要通过创建一个TextureLoader
来加载,如:
const texture = new THREE.TextureLoader().load("map-texture.jpg");
const material = new THREE.MeshBasicMaterial({
map: texture,
});
背后的原理:
const image = new Image();
const texture = new THREE.Texture(image);
image.onload = () => {
texture.needsUpdate = true;
};
image.src = "map-texture.jpg";
这就是材质加载出来的样子:
如果使用textureLoader
来加载,则不需要显式地调用texture.needsUpdate = true
来通知纹理更新,纹理会在加载完成后自动地渲染出效果
LoadingManager
可以用来监听纹理、字体等物体的加载节点,内部实现了onStart
、onLoad
、onError
、onProgress
的回调函数
uvmapping(待补充)
uv坐标是一组用来定位纹理位置的坐标,掌握这个属性很重要
mipmapping
看左上角的图,一个4x4的正方形可以由4个2x2的正方形构成,一个2x2的正方形可以由4个1x1的正方形构成
mipmapping
是一种GPU的计算策略,内部会将一张纹理图不断地拆解成若干个小单元,最后叠加起来构成纹理,同时这样也使得渲染性能得以提升
mipmapping
会对纹理图进行持续的计算直到裁剪到1*1
的像素为止,所以,使用纹理图时,要确保纹理图的宽高为2的幂次方,如256、512、1024等,否则在threejs
内部还要额外进行处理,浪费性能
结合mipmapping
使用的是minFilter
属性和magFilter
属性,一般设为NearestFilter
性能会比较好,锐利度也够高(why?)
当我们把minFilter
设置成NearestFilter
后,可以将texture.generateMipmaps
设为false
进一步提升性能
使用纹理图时,纹理图大小不要太大,以降低请求的时间
Material(材质)
要在场景中渲染几何体,需要同时给这个几何体添加相应的材质才可实现
创建material
时,通过配置map
属性来添加纹理,比如添加一张门的纹理:
几何体正常情况下会渲染成这样:
会发现纹理图不是想象中的大小,门的两侧有同色的拉伸效果,如果想要取消掉拉伸效果,可以提供一个alpha
材质,比如:
alphaMap
要求提供一张黑白图,图中白色部分会被渲染,黑色部分会被省略,配置后的效果:
注意,同时还需要配置transparent:true
才会生效
MeshNormalMaterial
法线材质使用纹理时可以使用带方向的纹理图
几何体的每个面都有n个顶点,可以通过widthSigments
和heightSigments
来设置,每个面的顶点由相交线相交得来,有多少个顶点就有多少个方向,
这种材质可以实现打光,折射和反射;拿灯光举例,之所以能看到几何体的某个面,是因为那个面的法线恰好指向我们自己或光源的方向,而其他非正面的方向就有不同程度的阴影或看不到(背面) ,这就是gpu
计算出3D
物体的光形态的原理
比如配置一个默认的MeshNormalMaterial
,效果如下:
假如加上一个flatShading
配置,如:
const material = new THREE.MeshNormalMaterial();
material.flatShading = true;
可以看到表面变得不光滑了:
可以再进一步开启wireFrame
观察构成几何体的平面,可以看到它们的表面就是由若干个三角形构成的
所以flatShading
的意义在于仅对这些平面进行着色,忽略平滑的计算效果
MeshMatcapMaterial
这个材质会自动根据提供的纹理图选择合适的颜色进行渲染,选择的依据是基于对灯光的模拟,比如下图:
添加到几何体上后:
const material = new THREE.MeshMatcapMaterial({
matcap: matcapTexture
});
这个纹理的好处在于可以直接使用图片来模拟灯光、阴影的效果,比如再来一个例子:
一个找matcap
纹理的仓库:github.com/nidorx/matcaps
,商用时需要确保自己有授权
也可以自己用3d绘制软件画一个球,做好灯光投射等配置后,输出矩形的matcap
图来使用
MeshDepthMaterial
深度材质,特点是离摄像机越近则越白,越远则越黑
需要注意的是,灯光不会作用于该材质上,所以使用了该材质后,ambientLight
、pointLight
都不会有效果
MeshLambertMaterial
对场景中使用LambertMaterial
,需要同时加上光源:
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
scene.add(ambientLight);
const light = new THREE.PointLight(0xffffff, 0.5);
light.position.x = 2;
light.position.y = 3;
light.position.z = 4;
scene.add(light);
const material = new THREE.MeshLambertMaterial();
因为有点光源的存在,当把摄像机拉到背面时,可以看到阴影
这是一个会受光影响的材质,表现力很强,性能好,但是当你放大到足够大时,几何体表面会有一些模糊的线条:
如果不添加光源,比如
ambientLight
,看到的物体就是一团黑
MeshPhongMaterial
相比于Lamber
,他不会有模糊的问题,自带“光反射”,但是性能不好,如果项目中出现了很多物体,导致整体性能不够好时,要考虑换掉这种材质
所有可以光反射的材质都可以设置shininess
(光反射强度),(lambert
不行,因为它不会对光做出反射)
使用Phong
时,shininess
用来控制反射强度,值越大,光反射越集中:
shininess = 0
shininess = 200
:
specular
用来控制光反射的颜色,比如设为红色时:
MeshToonMaterial
拥有类似卡通效果的材质,与lambert材质有类似的效果,但是由于颜色会发生急剧的变化,所以会有类似卡通的效果出现
从点光源的方向照射过来,到阴影的位置急剧变化,所以出现了类似断层的现象,看起来就和卡通的效果相似
一般可以配置gradientMap
属性来控制它颜色的效果,比如加载一张只有深灰、浅灰、白色的渐变图作为texture
:
代码:
const material = new THREE.MeshToonMaterial();
material.gradientMap = gradientTexture;
效果:
只是简单的设置gradientMap
后会让卡通效果消失,这是因为gpu
会自动使用mipmapping
进行计算,mipmapping
会把渐变色计算的非常精细,所以过渡效果就会很平滑,当像素被拆分得足够精细时,颜色急剧变化就不存在了
可以配置minFilter、magFilter
为NearestFilter
来恢复急剧的变化:
const material = new THREE.MeshToonMaterial();
gradientTexture.minFilter = THREE.NearestFilter;
gradientTexture.magFilter = THREE.NearestFilter;
material.gradientMap = gradientTexture;
这里是使用了只有3种颜色的渐变图片,当换成有5个渐变色时:
效果:
同时,因为使用了NearestFilter
,所以可以把generateMipmaps
设为false
来进一步减少性能开销
MeshStandardMaterial
这个材质与Lambert
和Phong
类似,但是它呈现的效果就更贴近现实,因为它的算法比较"真实",并且参数的设置上更利于我们理解
默认情况下的效果:
可以看到默认情况下metalness
和roughness
分别是0和1,当将他随意改变时,会发现比较神奇的效果:
官方文档对metalness
的解释,与金属有多相似
可以理解成反光的程度
:
roughness
的解释:
去到官方文档,可以看到相关的描述:1. 基于PBR渲染,显得更真实;2. 占用、计算成本更高;3. 使用时尽可能制定envMap
在使用该材质的情况下同时加上之前的纹理图,会出现更逼真的效果,比如:
const material = new THREE.MeshStandardMaterial();
gui.add(material, "metalness").min(0).max(1).step(0.0001);
gui.add(material, "roughness").min(0).max(1).step(0.0001);
material.map = doorColorTexture;
使用aoMap(AmbientOcclusion)
同时,为了更好地模拟阴影效果,可以引入aoMap
来实现,在官方文档中地描述:(全名:AmbientOcclusion
)
比如使用下面这张aoMao
纹理图:
const material = new THREE.MeshStandardMaterial();
gui.add(material, "metalness").min(0).max(1).step(0.0001);
gui.add(material, "roughness").min(0).max(1).step(0.0001);
material.map = doorColorTexture;
material.aoMap = doorAmbientOcclusionTexture;
material.aoMapIntensity = 1;
文档中要求第二组uv
坐标,可以这么实现:
const plane = new THREE.Mesh(new THREE.PlaneGeometry(1, 1, 100, 100), material);
plane.geometry.setAttribute(
"uv2",
new THREE.BufferAttribute(plane.geometry.attributes.uv.array, 2)
);
配置完成后,可以得到下面的效果,
调整aoMapIntensity
,可以看到更深的阴影:
displacementMap
位移贴图,官方文档的解释:
当使用下面这张图当作displacementMap
时:
图片的黑色部分代表其高度不受影响,从黑色到白色,高度的变化程度不断增高
得到的效果:
位移贴图可以用来呈现”高度“的效果,而”高度“的效果由几何体的顶点数量决定,拿中间的planeGeometry
举例,打开wireFrame
,当配置的widthSegments
和heightSegments
参数为10时:
const plane = new THREE.Mesh(new THREE.PlaneGeometry(1, 1, 10, 10), material);
它的顶点相对比较少:
此时如果配置上高度图,可以看到实际上就是顶点的位置发生了变化:
以此得到了类似高度的效果;而进一步将widthSegments
、heightSegments
设置成更高的值时,顶点也随之增多:那么顶点突起的部分则更加精细:
const plane = new THREE.Mesh(new THREE.PlaneGeometry(1, 1, 100, 100), material);
关闭wireFrame
,对比10和100的情况,会发现顶点越多,出来的效果就越好,这是合情合理的:
10的情况:
100的情况:
但是显然,我们不需要那么“高”的突起,所以可以通过displacementScale
(位移比例)来设置突起的程度,比如将displacementScale
设为0.1时,效果就已经很好了:
material.displacementMap = doorHeightTexture;
material.displacementScale = 0.1;
metalnessMap与roughNessMap
两张图,分别作为metalnessMap
和roughNessMap
:
代码配置上之后:
material.metalnessMap = doorMetalnessTexture;
material.roughnessMap = doorRoughnessTexture;
乍看没什么变化,但是移动一下视角会发现,表面的反射和光泽更加逼真了:
normalMap
同样的,加上一个normalMap
纹理图:
material.normalMap = doorNormalTexture;
乍看没什么变化,但是转换一下视角会发现,细节一下就出来了:
放大则看的更明显:
同样的,也可以配置normalScale
来调整法线纹理的比例:
最后,要实现一扇完美的门,再加上之前的alphaMa
即可:
const material = new THREE.MeshStandardMaterial();
material.side = THREE.DoubleSide;
// material.metalness = 0;
// material.roughness = 1;
gui.add(material, "metalness").min(0).max(1).step(0.0001);
gui.add(material, "roughness").min(0).max(1).step(0.0001);
material.map = doorColorTexture;
material.aoMap = doorAmbientOcclusionTexture;
gui.add(material, "aoMapIntensity").min(0).max(100).step(1);
gui.add(material, "displacementScale").min(0).max(1).step(0.001);
material.displacementMap = doorHeightTexture;
material.displacementScale = 0.1;
// material.wireframe = true;
material.metalnessMap = doorMetalnessTexture;
material.roughnessMap = doorRoughnessTexture;
material.normalMap = doorNormalTexture;
gui
.add(material.normalScale, "x")
.min(0)
.max(100)
.step(0.1)
.name("normalScaleX");
gui
.add(material.normalScale, "y")
.min(0)
.max(100)
.step(0.1)
.name("normalScaleY");
material.normalScale.set(10, 10);
material.transparent = true; // 要设置透明效果才行
material.alphaMap = doorAlphaTexture; // 配置alpha通道
使用envMap
可以在这个网站找高清环境贴图:https://hdri-haven.com/
然后去这个网站将图片转为cubeMap
6张图:https://matheowis.github.io/HDRI-to-CubeMap/
使用envMap
时,要使用具有6个方位图片的环境贴图纹理,需要使用CubeTextureLoader
来加载纹理,比如加载下面这六张图:
const cubeTextureLoader = new THREE.CubeTextureLoader();
const environmentMapTexture = cubeTextureLoader.load([
"/textures/environmentMaps/2/px.jpg",
"/textures/environmentMaps/2/nx.jpg",
"/textures/environmentMaps/2/py.jpg",
"/textures/environmentMaps/2/ny.jpg",
"/textures/environmentMaps/2/pz.jpg",
"/textures/environmentMaps/2/nz.jpg",
]);
加载环境贴图时,url
的路径是有顺序要求的:
加载环境纹理后,使用下面的配置:
const material = new THREE.MeshStandardMaterial();
material.metalness = 1;
material.roughness = 0;
gui.add(material, "metalness").min(0).max(1).step(0.0001);
gui.add(material, "roughness").min(0).max(1).step(0.0001);
material.envMap = environmentMapTexture;
得到的效果:
Text
实际使用时可以直接复制threejs/examples
里的typeface
字体和LICENSE
到自己的项目目录下使用
或者你有自定义的一套字体,将其在这个网站转换为typeface
格式字体:
https://gero3.github.io/facetype.js/
不过需要确保你有使用对应字体的授权
加载字体时,需要使用fontLoader
加载字体,使用TextGeometry
创建字体(拉伸)集合体
import { TextGeometry } from "three/addons/geometries/TextGeometry.js";
import { FontLoader } from "three/addons/loaders/FontLoader.js";
import * as THREE from "three";
const fontLoader = new FontLoader();
fontLoader.load("/fonts/helvetiker_regular.typeface.json", (font) => {
const textGeometry = new TextGeometry("Hello Three.js", {
font,
size: 12,
depth: 1,
curveSegments: 6,
bevelSegments: 2,
bevelEnabled: true,
bevelThickness: 0.03,
bevelSize: 0.02,
});
const textMaterial = new THREE.MeshBasicMaterial({
wireframe: true,
});
const mesh = new THREE.Mesh(textGeometry, textMaterial);
scene.add(mesh);
});
const camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000
);
camera.position.x = 0;
camera.position.y = 0;
camera.position.z = 300;
scene.add(camera);
写入上面的配置后,可以得到下面的结果:
curveSegments
和bevelSegments
会控制几何体顶点的数量,为了更好的性能,可以开着wireFrame
来不断将它们调低,找到能满足自己需求的最小值- 将视角放大,会看到在原点处,H字母的底部线是斜的,这是因为开启了
bevelEnabled
的原因
那如果我们想把字体“居中”,该怎么做呢?
正常来说,每个几何体都有一个固定的box
来将其包裹:
默认情况下,threejs
的几何体是用球状框包裹的,此时无法通过textGeometry.boundingBox
获取到它的边框值;
如果我们想获取到某个几何体的边框,需要先调用textGeometry.computedBoundingBox()
方法,将几何体外边框转成矩形边框
,然后才可以获取到该矩形边框
:
console.log('before:', JSON.parse(JSON.stringify(textGeometry.boundingBox)));
textGeometry.computeBoundingBox();
console.log('after:', JSON.parse(JSON.stringify(textGeometry.boundingBox)));
这一步骤是全程不可见的,没有什么办法来肉眼看到它的边框是怎样的
所以,居中实际上就是通过将几何体所有顶点沿着x轴、y轴、z轴移动其外层盒子框体最大x
、y
、z
值的一半来实现居中,主要通过translate
方法来实现:
textGeometry.computeBoundingBox();
console.log("before:", JSON.parse(JSON.stringify(textGeometry.boundingBox)));
textGeometry.translate(
-textGeometry.boundingBox.max.x * 0.5,
-textGeometry.boundingBox.max.y * 0.5,
-textGeometry.boundingBox.max.z * 0.5
);
textGeometry.computeBoundingBox()
console.log("after:", JSON.parse(JSON.stringify(textGeometry.boundingBox)));
但是移动后box
的最大最小值貌似不太对,按理说最小值的x应该和最大值的x之和为0,但是加上会发现它们有0.02
的差距
这个差距对应的就是上面配置的bevelSize: 0.02
,所以实际上精确的计算方式应该是:
textGeometry.translate(
-(textGeometry.boundingBox.max.x - 0.02) * 0.5,
-(textGeometry.boundingBox.max.y - 0.02) * 0.5,
-textGeometry.boundingBox.max.z * 0.5
);
当然,除了上面这种居中的方法,我们还可以使用textGeometry.center()
方法来一步到位,不需要搞得这么麻烦:
textGeometry.computeBoundingBox();
console.log("before:", JSON.parse(JSON.stringify(textGeometry.boundingBox)));
textGeometry.center();
textGeometry.computeBoundingBox();
console.log("after:", JSON.parse(JSON.stringify(textGeometry.boundingBox)));
一个移动的问题
注意,如果是自己计算,实际上有两种办法可以“居中”,但他们略有不同
一种是将几何体通过translate
方法修改所有顶点的位置
一种是对mesh.position
进行修改
textGeometry.translate(
-(textGeometry.boundingBox.max.x - 0.02) * 0.5,
-(textGeometry.boundingBox.max.y - 0.02) * 0.5,
-textGeometry.boundingBox.max.z * 0.5
);
// 或直接移动mesh
mesh.position.sub(
new THREE.Vector3(
(textGeometry.boundingBox.max.x - 0.02) * 0.5,
(textGeometry.boundingBox.max.y - 0.02) * 0.5,
textGeometry.boundingBox.max.z * 0.5
)
);
虽然居中的效果相同,但是当要进行旋转、缩放时,结果可能就会偏离预期了
如果是移动几何体顶点的方式或.center()
的方式,缩放、旋转都是以原点(0,0,0)进行:
但如果是通过移动mesh
来实现的话,其几何体中心并不是(0,0,0),缩放、旋转的中心是以原本的中心位置为基点进行的: