我们在浏览一些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 = () => { 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
属性用于设置纹理在物体表面上的旋转角度;它是一个数值,表示纹理绕其中心点旋转的角度(以弧度为单位)。
我们看旋转的效果:
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
加载我们的模型文件:
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的源码就会看到如下代码,因此两者本质上是同一个东西:
| 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的材质:
| 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:
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为新的材质即可。
谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里
| { 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