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

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

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

纹理的使用

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

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

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

javascript 复制代码
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的属性进行调整:

javascript 复制代码
const textureLoader = new THREE.TextureLoader();  
const texture = textureLoader.load('path/to/your/image.jpg', () => {  
    console.log('Texture loaded');  
});

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

image

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

javascript 复制代码
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对象,分别表示在水平方向和垂直方向上的重复次数:

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

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

wrapS和wrapT

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

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

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

javascript 复制代码
texture.wrapS = texture.wrapT = THREE.RepeatWrapping;  

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

rotation

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

javascript 复制代码
texture.rotation = 45;

我们看旋转的效果:

offset

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

javascript 复制代码
texture.offset.set(0.8, 0.8);

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

我们看下偏移的效果:

flip

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

javascript 复制代码
texture.flipX = true;
texture.flipY = true;

needsUpdate

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

javascript 复制代码
texture.needsUpdate = true;

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

预加载所有纹理

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

javascript 复制代码
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中:

javascript 复制代码
const loadingManager = new LoadingManager();

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

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

javascript 复制代码
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加载我们的模型文件:

javascript 复制代码
{
    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中就可以来添加网格对象了:

javascript 复制代码
{
    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的源码就会看到如下代码,因此两者本质上是同一个东西:

javascript 复制代码
// 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调试:

javascript 复制代码
{
  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加载管理器,加载过程中在页面中间显示一个圆形的进度条和百分比:

javascript 复制代码
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图片加载器来加载图片,我们下面会看到它的作用。

javascript 复制代码
{
  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属性即可。

javascript 复制代码
{
    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的材质:

javascript 复制代码
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);
});

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

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

javascript 复制代码
{
  // 设置左侧选中的部位
  setOptions(opt) {
    this.active = opt;
  }
}

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

javascript 复制代码
{
  // 设置下方材质和颜色
  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为新的材质即可。

javascript 复制代码
{
  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属性即可。

本文所有源码敬请扫码,回复关键词【椅子模型换肤】即可获取。

相关推荐
前端李易安2 分钟前
Webpack 热更新(HMR)详解:原理与实现
前端·webpack·node.js
红绿鲤鱼3 分钟前
React-自定义Hook与逻辑共享
前端·react.js·前端框架
Domain-zhuo13 分钟前
什么是JavaScript原型链?
开发语言·前端·javascript·jvm·ecmascript·原型模式
小丁爱养花21 分钟前
前端三剑客(三):JavaScript
开发语言·前端·javascript
ZwaterZ29 分钟前
vue el-table表格点击某行触发事件&&操作栏点击和row-click冲突问题
前端·vue.js·elementui·c#·vue
西凉河的葛三叔33 分钟前
vue3+elementui-plus el-dialog全局配置点击空白处不关闭弹窗
前端·vue3·elementui-plus
周三有雨41 分钟前
【面试题系列Vue07】Vuex是什么?使用Vuex的好处有哪些?
前端·vue.js·面试·typescript
木古古181 小时前
使用chrome 访问虚拟机Apache2 的默认页面,出现了ERR_ADDRESS_UNREACHABLE这个鸟问题
前端·chrome·apache
爱米的前端小笔记1 小时前
前端八股自学笔记分享—页面布局(二)
前端·笔记·学习·面试·求职招聘