我们在浏览一些3D网站时,经常能看到一些模型提供了切换外观的按钮,通过不同的颜色或者纹理的按钮切换,来让模型呈现出来更多的效果,比如给展示的汽车换个外观颜色之类的。本文我们先从简单的模型入手,看下给椅子模型切换纹理是如何来实现这样的效果。
首先既然要对纹理图片进行切换,那么我们对纹理的基本属性和用法要有一个简单的了解,本文的基础知识我们就从纹理Texture的使用开始。
纹理的使用
我们在加载纹理图片的时候,经常会看到wrapS = wrapT = RepeatWrapping
的写法,并且设置repeat,那么为什么要这样设置呢?我们简单看下纹理的用法。
首先纹理的使用,可以让我们的将图像应用到几何体上,实现更加真实和逼真的渲染效果;比如我们想让一块长方体呈现出石头的纹理或者门的木纹理,如果通过纯代码Shader实现,先不说能不能实现吧,如果效果用Shader去呈现,对GPU、内存的压力也会非常的大;但是如果我们贴个图片纹理上去,实现的效果相同,并且成本也相对低了很多。
纹理的创建方式有多种,首先我们可以通过THREE.Texture
构造函数来创建一个纹理对象,将图片Image传入构造函数中去:
const img = new Image();
img.src = "path/to/your/image.jpg";
const tx = new Texture(img);
img.onload = () => {
// 加载成功后更新纹理
tx.needsUpdate = true;
};
这里由于img是异步加载的,因此Texture的图像改变了,所以我们需要在图片的回调函数中重新更新纹理的needsUpdate
为true;另一种方式则更简单,直接使用TextureLoader
,通过名字我们也能看出来,它就是个Texture的加载器;加载后返回的纹理对象,我们还可以在回调函数中对Texture的属性进行调整:
const textureLoader = new THREE.TextureLoader();
const texture = textureLoader.load('path/to/your/image.jpg', () => {
console.log('Texture loaded');
});
接着,我们再来看纹理的几个常用属性的效果。
image
我们通过THREE.Texture
构造函数来创建一个纹理对象,如果后期还想修改纹理的图片,就可以通过它的一个重要的属性image
:
const img = new Image();
img.src = "path/to/your/image.jpg";
const tx = new Texture();
img.onload = () => {
// 图片加载完成后修改image属性
tx.image = img;
tx.needsUpdate = true;
};
这里我们新建一个空的Texture,当图片加载完成后,我们修改纹理的image属性,并设置needsUpdate
为true,这样纹理的图片就修改成功了。
repeat
我们纹理正常是平铺在物体的表面的,相当于object-fit: fill
的效果;但是会有拉伸的效果,因此需要进行重复排列;repeat
属性就是用来设置纹理在物体表面上的重复次数,它是一个THREE.Vector2
对象,分别表示在水平方向和垂直方向上的重复次数:
// 在水平和垂直方向上各重复两次
texture.repeat.set(2, 2);
比如我们对一个常见的木头纹理,设置重复排列两次,由于默认的包装模式是ClampToEdgeWrapping
,因此我们会看到如下的纹理:
wrapS和wrapT
wrapS
和wrapT
属性用于设置纹理在S(U)方向和T(V)方向上的包装模式,常见的包装模式有如下:
- THREE.ClampToEdgeWrapping:默认,纹理中的最后一个像素将延伸到网格的边缘。
- THREE.RepeatWrapping:纹理将简单地重复到无穷大。
- THREE.MirroredRepeatWrapping: 纹理将重复到无穷大,在每次重复时将进行镜像。
默认情况下wrapS和wrapT都是ClampToEdgeWrapping
,因此我们会看到上面设置了repeat后纹理彷佛模糊了一样,我们改为RepeatWrapping:
texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
再看重复的效果就会正常很多了。
rotation
rotation
属性用于设置纹理在物体表面上的旋转角度;它是一个数值,表示纹理绕其中心点旋转的角度(以弧度为单位)。
texture.rotation = 45;
我们看旋转的效果:
offset
offset
属性用于设置纹理在物体表面上的偏移量,它也是一个THREE.Vector2
对象,分别表示在水平方向和垂直方向上的偏移量:
texture.offset.set(0.8, 0.8);
偏移量的设置范围是0到1。
我们看下偏移的效果:
flip
flip
属性用于翻转纹理,它有两个布尔值属性,flipX
和flipY
,分别表示是否沿X轴和Y轴翻转纹理。
texture.flipX = true;
texture.flipY = true;
needsUpdate
needsUpdate
属性是一个布尔值,用于标记纹理是否需要在下次渲染时更新;当纹理的源图像发生变化时,需要将其设置为true:
texture.needsUpdate = true;
在了解了纹理的基本使用后,我们下面就可以来看下切换的案例了;既然要切换模型纹理,那我们至少需要异步加载两个以上的纹理图片,同时我们的3D模型也是异步加载的,因此笔者做换肤的模型练习,其实刚开始面临最大最棘手的问题是:3D模型和多张纹理图片如何加载后结合起来?如果都要通过异步嵌套,那么我们的代码会异常的繁杂;最后,经过几个案例的练习后,笔者找到了预加载和渐进加载两种方式。
预加载所有纹理
第一把椅子实现的方案,我们可以通过LoadingManager
加载管理器,来等待我们所有的模型和纹理文件加载完成后,再去创建添加网格对象Mesh;首先我们初始化环境,创建一个渲染器:
export default class Index {
constructor(options) {
this.renderer = new WebGLRenderer({
antialias: true,
});
this.renderer.setClearColor(0xffffff);
this.renderer.setPixelRatio(window.devicePixelRatio * 2);
this.renderer.setSize(window.innerWidth, window.innerHeight);
// 开启阴影
this.renderer.shadowMap.enabled = true;
this.renderer.autoClear = false;
// 其他初始化
}
}
使用shadowMap.enabled = true
开启阴影;然后新建LoadingManager,它的作用是用来管理我们所有的Loader加载进度的;新建后传入到我们下面所需要三个Loader中:
const loadingManager = new LoadingManager();
this.objLoader = new OBJLoader(loadingManager);
this.textureLoader = new TextureLoader(loadingManager);
this.cubeLoader = new CubeTextureLoader(loadingManager);
然后在loadAssets
初始化加载我们所有的资源文件,加载完成后使用initMesh
去创建网格对象了。
this.initLight();
this.loadAssets();
loadingManager.onProgress = (url, itemsLoaded, itemsTotal) => {
// 设置进度条
};
loadingManager.onError = () => {};
loadingManager.onLoad = () => {
// 加载完成
this.initMesh();
};
LoadingManager提供了三个回调函数:
- onLoad:所有加载器加载完成后。
- onProgress:当每个项目完成后,将调用此函数。
- onError:当一个加载器遇到错误时。
而在onProgress
函数中,也提供的几个参数:
- url:当前被加载的项的url。
- itemsLoaded:目前已加载项的个数。
- itemsTotal:总共所需要加载项的个数。
可以发现,通过itemsLoaded / itemsTotal * 100%
的计算公式,我们就可以计算出当前资源的加载进度,设置一个全屏的加载等待效果来缓解用户浏览空白页面的焦虑感,同时在onLoad
加载结束的回调中再把这个加载的弹框给隐藏。
在loadAssets
函数中,使用CubeTextureLoader
我们加载一些环境纹理的素材,TextureLoader
加载模型纹理,OBJLoader
加载我们的模型文件:
{
loadAssets() {
this.envMap = this.cubeLoader.load([
"posx.jpg",
"negx.jpg",
"posy.jpg",
"negy.jpg",
"posz.jpg",
"negz.jpg",
]);
this.textureLoader.load("fabric_blue.jpg", (texture) => {
texture.needsUpdate = true;
texture.wrapS = texture.wrapT = RepeatWrapping;
texture.repeat.set(2, 2);
this.fabricBlue = texture;
});
this.textureLoader.load("fabric_yellow.jpg", (texture) => {
texture.needsUpdate = true;
texture.wrapS = texture.wrapT = RepeatWrapping;
texture.repeat.set(2, 2);
this.fabricYellow = texture;
});
this.objLoader.load("cushion.obj", (obj) => {
this.cushionObj = obj;
});
// 省略加载其他素材
}
}
这里我们简单加载并全局保存了两种织物材质,蓝色的材质fabricBlue和黄色的材质fabricYellow;还有一个坐垫模型cushionObj。
等待所有的素材加载完成后,我们在initMesh中就可以来添加网格对象了:
{
initMesh() {
const group = new Object3D();
this.cushionObj.traverse((el) => {
if (el instanceof Mesh) {
el.material = new MeshStandardMaterial({
map: this.fabricBlue,
envMap: this.envMap,
// 省略其他属性
});
// 添加投影
el.receiveShadow = true;
el.castShadow = true;
}
});
this.cushionObj.position.y = -10;
group.add(this.cushionObj);
// 省略其他椅子的部件
this.scene.add(group);
}
}
这里我们新建了一个Object3D对象,它也是场景中的一个节点,不过和Mesh不同的是,它没有材质和几何体,我们只是用它来创建一个局部空间,有点类似三体中云天明送给程欣的一个小宇宙来躲避宇宙大坍缩,而我们可以利用这个空节点来承载椅子的各个部件,后面如果椅子需要旋转或者移动,我们直接在这个对象上进行操作即可;具体的使用方式也可以参考官网场景图
最后不要忘记将Object3D对象添加到scene场景中去。
有些案例中,我们还会看到使用了Group对象来包裹了子对象,其实Group继承自Object3D,我们打开three.js的源码就会看到如下代码,因此两者本质上是同一个东西:
// src/objects/Group.js
import { Object3D } from '../core/Object3D.js';
class Group extends Object3D {
constructor() {
super();
this.isGroup = true;
this.type = 'Group';
}
}
export { Group };
所有的网格对象添加完,我们的看到椅子大概就是这样的:
那么最关键的问题来了,如何可以让它的材质从fabricBlue切换到fabricYellow呢?我们添加gui调试:
{
initGui() {
const params = {
yellow() {
_this.cushionObj.traverse((el) => {
if (el instanceof Mesh) {
el.material.map = _this.fabricYellow;
el.material.needsUpdate = true;
}
});
},
blue() {
_this.cushionObj.traverse((el) => {
if (el instanceof Mesh) {
el.material.map = _this.fabricBlue;
el.material.needsUpdate = true;
}
});
},
};
const folder1 = this.gui.addFolder("织物材质");
folder1.add(params, "blue");
folder1.add(params, "yellow");
}
}
我们只需要在模型中找到需要改变的部分,修改它的map属性为对应的纹理,这样在页面上点击按钮切换就可以呈现不同的效果;这种方式最常见,也比较适合模型比较简单、纹理也不是很复杂的情况。
我们看下实际的页面效果:
渐进式加载纹理
渐进式加载纹理
,这是笔者给这种方式起的一种形象的名称,有点类似vue渐进式框架的意思;这种方式就是只要加载一点素材就添加到场景中来,比如加载一个椅子上的垫子模型,就把这个垫子添加进来展示,尽管垫子的纹理可能还没有加载好。
通过描述,我们就会发现这种方式会比前面的预加载的方式麻烦,因为不确定模型文件和纹理文件哪个先加载完成,并且还需要等加载完成后,再把两者结合起来。
但是这种方式的优势也很明显,用户不用漫长的等待所有素材加载完成,可以一点一点看到模型加载的整个过程,有点类似于搭积木的感觉;首先我们还是需要通过LoadingManager加载管理器,加载过程中在页面中间显示一个圆形的进度条和百分比:
const loadingManager = new LoadingManager();
this.objLoader = new OBJLoader(loadingManager);
this.imgLoader = new ImageLoader(loadingManager);
loadingManager.onStart = () => {
// 显示进度条
showLoading.value = true;
};
loadingManager.onProgress = (url, num, total) => {
// 进度条百分比
loadingNum.value = Math.floor(num / total * 100);
};
loadingManager.onLoad = () => {
// 隐藏进度条
showLoading.value = false;
};
this.loadAssets();
我们在onLoad
回调中也不需要初始化网格对象了,所有模型和纹理的加载都是在loadAssets
中完成的。这里我们也不需要TextureLoader了,而是创建了一个ImageLoader
图片加载器来加载图片,我们下面会看到它的作用。
{
loadAssets() {
this.leatherTexture = new Texture();
this.leatherBump = new Texture();
this.group = new Object3D();
this.imgLoader.load("leather_white.jpg", (img) => {
this.leatherTexture.image = img;
// 省略其他
});
this.imgLoader.load("leather_bump.jpg", (img) => {
this.leatherBump.image = img;
// 省略其他
});
// 坐垫模型
this.objLoader.load(
"barcelona-cushion.obj",
(obj) => {
obj.traverse((el) => {
if (el instanceof Mesh) {
el.material = new MeshStandardMaterial({
map: this.leatherTexture,
bumpMap: this.leatherBump,
// 省略其他属性
});
}
});
this.group.add(obj);
}
);
// 省略加载其他部件
this.scene.add(this.group);
},
};
这边我们使用了两种类型媒介对象
,首先就是通过Texture类创建的leatherTexture和leatherBump空材质对象,作为图片和模型之间的媒介;如果上面的jpg图片还没有加载,那么barcelona-cushion.obj加载空的材质,在图片加载完成后再给Texture的image
属性赋值,因此模型的加载就和纹理的加载进行了解耦。
修改了纹理的image属性后,不要忘记修改needsUpdate。
其次就是我们上面说的Object3D
对象,它可以作为整个模型加载的媒介;假设下面还有其他的obj模型,不管哪个模型先加载完成,都会向这个局部空间中去添加网格对象;我们来看下模型一点点加载的效果:
椅子模型加载完成后,我们也需要来改变它的纹理,不过和上面直接改变材质的map属性不同,这里只需要加载图片后直接修改全局的Texture的image属性即可。
{
initGui() {
const _this = this;
this.gui = initGui();
const params = {
White() {
_this.imgLoader.load("leather_white.jpg", (img) => {
_this.leatherTexture.image = img;
// 省略其他
});
},
Black() {
_this.imgLoader.load("leather_black.jpg", (img) => {
_this.leatherTexture.image = img;
// 省略其他
});
},
};
const folder = this.gui.addFolder("皮革颜色");
folder.add(params, "White");
folder.add(params, "Black");
}
}
我们在切换纹理的时候,ImageLoader也会触发加载器的回调函数,因此我们还会看到一个加载loading;我们看下实际的页面效果:
切换颜色或纹理
最后一把椅子案例,也比较有意思,选择不同椅子部位后,可以切换不同的材质或者颜色;这里我们加载它的模型,给每个部件重新创建一个MeshPhongMaterial的材质:
this.gltfLoader.load("chair.glb", (gltf) => {
const theModel = gltf.scene;
theModel.traverse((el) => {
if (el.isMesh) {
el.material = new MeshPhongMaterial({ color: 0xf1f1f1, shininess: 10 });
}
});
// 省略其他代码
this.theModel = theModel;
this.scene.add(theModel);
});
加载可以看下椅子的模型外观:
当我们点击右侧的时候,将椅子激活的部位保存起来。
{
// 设置左侧选中的部位
setOptions(opt) {
this.active = opt;
}
}
当点击下面材质和颜色的选项时,根据item的texture属性,判断是纹理还是颜色,如果是纹理的话加载Texture;如果是颜色的话,传入color,最后都生成了一个新的MeshPhongMaterial:
{
// 设置下方材质和颜色
setControls(item) {
const { texture, color, size, shininess = 10 } = item;
let new_mtl = null;
if (texture) {
const txt = this.textureLoader.load(texture);
txt.repeat.set(size[0], size[1]);
txt.wrapS = txt.wrapT = RepeatWrapping;
new_mtl = new MeshPhongMaterial({
map: txt,
shininess,
});
} else if (color) {
new_mtl = new MeshPhongMaterial({
color: parseInt(`0x${color}`),
shininess,
});
}
}
}
最后在模型theModel中找到对应的椅子部件,修改material为新的材质即可。
{
setControls(item) {
// 其他代码
if (new_mtl && this.theModel) {
this.theModel.traverse((el) => {
if (el.isMesh && el.name === this.active) {
el.material = new_mtl;
}
});
}
}
}
我们看下实际的页面效果:
总结
通过以上几个案例,这里笔者简单的总结一下;我们发现其实切换纹理的方式很简单,无非是两种方式,一种是修改材质的map属性,另一种就是修改纹理的image属性。
渐进加载方式确实比较有意思,通过修改纹理image属性,能让用户眼前一亮的感觉;但是如果只加载一个两个模型,其实应用的空间也不是很大,因为它需要去开辟一个局部空间来添加很多的模型和材质。
因此,很多时候,我们的模型不是很复杂的情况下,会选择一次性的去加载模型纹理;当需要修改哪个部位的纹理时,使用traverse
遍历模型后修改对应的map
属性即可。