我做了三把椅子原来纹理这样加载切换

  我们在浏览一些3D网站时,经常能看到一些模型提供了切换外观的按钮,通过不同的颜色或者纹理的按钮切换,来让模型呈现出来更多的效果,比如给展示的汽车换个外观颜色之类的。本文我们先从简单的模型入手,看下给椅子模型切换纹理是如何来实现这样的效果。

  首先既然要对纹理图片进行切换,那么我们对纹理的基本属性和用法要有一个简单的了解,本文的基础知识我们就从纹理Texture的使用开始。

纹理的使用

  我们在加载纹理图片的时候,经常会看到wrapS = wrapT = RepeatWrapping的写法,并且设置repeat,那么为什么要这样设置呢?我们简单看下纹理的用法。

  首先纹理的使用,可以让我们的将图像应用到几何体上,实现更加真实和逼真的渲染效果;比如我们想让一块长方体呈现出石头的纹理或者门的木纹理,如果通过纯代码Shader实现,先不说能不能实现吧,如果效果用Shader去呈现,对GPU、内存的压力也会非常的大;但是如果我们贴个图片纹理上去,实现的效果相同,并且成本也相对低了很多。

  纹理的创建方式有多种,首先我们可以通过THREE.Texture构造函数来创建一个纹理对象,将图片Image传入构造函数中去:

1
2
3
4
5
6
7
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的属性进行调整:

1
2
3
4
const textureLoader = new THREE.TextureLoader();  
const texture = textureLoader.load('path/to/your/image.jpg', () => {
console.log('Texture loaded');
});

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  接着,我们再来看纹理的几个常用属性的效果。

image

  我们通过THREE.Texture构造函数来创建一个纹理对象,如果后期还想修改纹理的图片,就可以通过它的一个重要的属性image

1
2
3
4
5
6
7
8
9
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对象,分别表示在水平方向和垂直方向上的重复次数:

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

1
2
// 在水平和垂直方向上各重复两次
texture.repeat.set(2, 2);

  比如我们对一个常见的木头纹理,设置重复排列两次,由于默认的包装模式是ClampToEdgeWrapping,因此我们会看到如下的纹理:

重复排列两次

wrapS和wrapT

  wrapSwrapT属性用于设置纹理在S(U)方向和T(V)方向上的包装模式,常见的包装模式有如下:

  • THREE.ClampToEdgeWrapping:默认,纹理中的最后一个像素将延伸到网格的边缘。
  • THREE.RepeatWrapping:纹理将简单地重复到无穷大。
  • THREE.MirroredRepeatWrapping: 纹理将重复到无穷大,在每次重复时将进行镜像。

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  默认情况下wrapS和wrapT都是ClampToEdgeWrapping,因此我们会看到上面设置了repeat后纹理彷佛模糊了一样,我们改为RepeatWrapping:

1
texture.wrapS = texture.wrapT = THREE.RepeatWrapping;  

  再看重复的效果就会正常很多了。

重复排列两次

rotation

  rotation属性用于设置纹理在物体表面上的旋转角度;它是一个数值,表示纹理绕其中心点旋转的角度(以弧度为单位)。

1
texture.rotation = 45;

  我们看旋转的效果:

旋转

offset

  offset属性用于设置纹理在物体表面上的偏移量,它也是一个THREE.Vector2对象,分别表示在水平方向和垂直方向上的偏移量:

1
texture.offset.set(0.8, 0.8);

偏移量的设置范围是0到1。

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  我们看下偏移的效果:

偏移

flip

  flip属性用于翻转纹理,它有两个布尔值属性,flipXflipY,分别表示是否沿X轴和Y轴翻转纹理。

1
2
texture.flipX = true;
texture.flipY = true;

needsUpdate

  needsUpdate属性是一个布尔值,用于标记纹理是否需要在下次渲染时更新;当纹理的源图像发生变化时,需要将其设置为true:

1
texture.needsUpdate = true;

  在了解了纹理的基本使用后,我们下面就可以来看下切换的案例了;既然要切换模型纹理,那我们至少需要异步加载两个以上的纹理图片,同时我们的3D模型也是异步加载的,因此笔者做换肤的模型练习,其实刚开始面临最大最棘手的问题是:3D模型和多张纹理图片如何加载后结合起来?如果都要通过异步嵌套,那么我们的代码会异常的繁杂;最后,经过几个案例的练习后,笔者找到了预加载和渐进加载两种方式。

预加载所有纹理

  第一把椅子实现的方案,我们可以通过LoadingManager加载管理器,来等待我们所有的模型和纹理文件加载完成后,再去创建添加网格对象Mesh;首先我们初始化环境,创建一个渲染器:

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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中:

1
2
3
4
5
const loadingManager = new LoadingManager();

this.objLoader = new OBJLoader(loadingManager);
this.textureLoader = new TextureLoader(loadingManager);
this.cubeLoader = new CubeTextureLoader(loadingManager);

  然后在loadAssets初始化加载我们所有的资源文件,加载完成后使用initMesh去创建网格对象了。

1
2
3
4
5
6
7
8
9
10
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加载我们的模型文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
{
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中就可以来添加网格对象了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{
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的源码就会看到如下代码,因此两者本质上是同一个东西:

1
2
3
4
5
6
7
8
9
10
// 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调试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
{
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加载管理器,加载过程中在页面中间显示一个圆形的进度条和百分比:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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图片加载器来加载图片,我们下面会看到它的作用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
{
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属性即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{
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的材质:

1
2
3
4
5
6
7
8
9
10
11
12
13
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);
});

  加载可以看下椅子的模型外观:

椅子模型效果

  当我们点击右侧的时候,将椅子激活的部位保存起来。

1
2
3
4
5
6
{
// 设置左侧选中的部位
setOptions(opt) {
this.active = opt;
}
}

  当点击下面材质和颜色的选项时,根据item的texture属性,判断是纹理还是颜色,如果是纹理的话加载Texture;如果是颜色的话,传入color,最后都生成了一个新的MeshPhongMaterial:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{
// 设置下方材质和颜色
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为新的材质即可。

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

1
2
3
4
5
6
7
8
9
10
11
12
{
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属性即可。

本文所有源码敬请关注公众号【前端壹读】,后台回复关键词【椅子模型换肤】即可获取。

参考

  最后感谢下面网站的模型和素材,笔者用以学习和研究。

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

threedpad.com
tympanus.net


本网所有内容文字和图片,版权均属谢小飞所有,任何媒体、网站或个人未经本网协议授权不得转载、链接、转贴或以其他方式复制发布/发表。如需转载请关注公众号【前端壹读】后回复【转载】。