bigo-frontend / blog

👨🏻‍💻👩🏻‍💻 bigo前端技术博客
https://juejin.cn/user/4450420286057022/posts
MIT License
129 stars 9 forks source link

【bigo】Three.js加载3D模型实战 #76

Open ac731064535 opened 3 years ago

ac731064535 commented 3 years ago

一、Threejs简介

Three.js是基于原生WebGl API和着色器封装得到的3D引擎,也就是一个.js库。通过原生WebGL直接编写程序,会比较麻烦,一般开发项目直接使用Three.js引擎。

  1. 程序的整体结构

    Three.js提供了丰富的API,模型和灯光组成场景,Threes.js的渲染器结合场景相机,就能渲染出3d效果。

  1. 模型

    Three.js提供各种几何体Geometry的API用来构造几何体,也可以使用模型加载器,例如OBJLoader加载.obj文件,几何体Geometry和材质Material结合,组成模型Mesh。材质可以类比现实于生活的材料,比如玻璃、木材等,几何体就是一个骨架,就像窗户的铝合金框架,给框架安装玻璃后才能成为窗户。也就是给几何体拼接材质后,才会组成一个模型。Three.js也提供各种创建各种材质的API,材质可以设置颜色,也可以使用贴图。 几何体 + 材质 => 模型

    窗体骨架 + 玻璃 => 窗户

    本案例是通过OBJLoader加载美术提供的.obj文件,加载完毕后,再给其设置纹理贴图

    let kikiModel = null;
    let texture;
    const manager = new THREE.LoadingManager(() => {
     kikiModel.traverse((child) => {
     if (child.isMesh) {
        child.material.map = texture; // 模型加载完毕后设置纹理贴图
     }
     });
    });
    // texture
    const textureLoader = new THREE.TextureLoader(manager);
    texture = textureLoader.load(cardInfo.texture);
    // model
    const loader = new OBJLoader(manager);
    loader.load(
    'https://static-web.likeevideo.com/as/likee-static/page-44111/kiki.obj.png',
    (obj) => {
    kikiModel = obj;
    });

    使用不同的材质可以得到不同的kiki模型

  2. 光源

    世间五彩缤纷,是因为有光,如果没有关,所有物体都是黑色。所以为了更好的渲染场景,Three.js提供了生活中常见的一些光源api。包括环境光AmbientLight、点光源PointLightSpotLight、平行光DirectionalLight和聚光灯光源SpotLight,效果如下

    /*
    * 环境光
    * 环境光是没有特定方向的光源,主要是均匀整体改变Threejs物体表面的明暗效果的,环境光颜色 * RGB成分分别和物体材质颜色RGB成分分别相乘
    */
    const ambient = new THREE.AmbientLight(0x444444);
    scene.add(ambient);
    /*
    * 点光源
    * 和环境光不同,环境光不需要设置光源位置,而点光源需要设置位置属性.position,光源位置不 * 同,物体表面被照亮的面不同,远近不同则明暗程度不同
    */
    var point = new THREE.PointLight(0xffffff);
    //设置点光源位置,改变光源的位置
    point.position.set(400, 200, 300);
    scene.add(point);
    /*
    * 平行光
    * 两点确定一条直线,通过position和target确定一条直线,确定平行光的方向
    */
    var directionalLight = new THREE.DirectionalLight(0xffffff, 1);
    // 设置光源的方向:通过光源position属性和目标指向对象的position属性计算
    directionalLight.position.set(80, 100, 50);
    // 方向光指向对象网格模型mesh2,可以不设置,默认的位置是0,0,0
    directionalLight.target = mesh2;
    scene.add(directionalLight);
    /*
    * 聚光光源
    * 聚光灯光源包括position、target和分散角度angle
    */
    var spotLight = new THREE.SpotLight(0xffffff);
    // 设置聚光光源位置
    spotLight.position.set(200, 200, 200);
    // 聚光灯光源指向网格模型mesh2
    spotLight.target = mesh2;
    // 设置聚光光源发散角度
    spotLight.angle = Math.PI / 6
    scene.add(spotLight);//光对象添加到scene场景中

    从几何体的顶部照射两道光,可以看到点光源的两侧有阴影,而平行光则没有

  3. 纹理贴图

    通过纹理贴图加载器TextureLoader的load()方法加载一张图片可以返回一个纹理对象Texture,纹理对象Texture可以作为模型材质颜色贴图.map属性的值。

    材质的颜色贴图属性.map设置后,模型会从纹理贴图上采集像素值,这时候一般来说不需要再设置材质颜色.color.map贴图之所以称之为颜色贴图就是因为网格模型会获得颜色贴图的颜色值RGB。

    const loader = new THREE.OBJLoader();
    const textureLoader = new THREE.TextureLoader();
    // 执行load方法,加载纹理贴图成功后,返回一个纹理对象Texture
    textureLoader.load('./public/img/texture1.jpg', function(texture) {
     loader.load('https://static-web.likeevideo.com/as/likee-static/page-44111/kiki.obj.png',function (obj) {
       // 控制台查看返回结构:包含一个网格模型Mesh的组Group
       console.log(obj);
       // 查看加载器生成的材质对象:MeshPhongMaterial
       console.log(obj.children[0].material);
       obj.children[0].material.map = texture;
       scene.add(obj);
       render();
     })
    })
  4. 摄像机

    Three.js是通过场景、渲染器和相机构造出的3D效果,相机对象分为正投影相机OrthographicCamera和透视投影相机PerspectiveCamera。正视投影相当于平行光,投影面积只跟投影角度有关,跟投影距离无关。透视投影则相当于人眼观察世界,从相机的位置发散出去。

    创建摄像机

    /**
    * 正投影相机设置
    * OrthographicCamera( left, right, top, bottom, near, far )
    */
    var width = window.innerWidth; //窗口宽度
    var height = window.innerHeight; //窗口高度
    var k = width / height; //窗口宽高比
    var s = 150; //三维场景显示范围控制系数,系数越大,显示的范围越大
    //创建相机对象
    var camera = new THREE.OrthographicCamera(-s * k, s * k, s, -s, 1, 1000);
    camera.position.set(200, 300, 200); //设置相机位置
    camera.lookAt(scene.position); //设置相机方向(指向的场景对象)
    
    /**
    * 透视投影相机设置
    * PerspectiveCamera( fov, aspect, near, far )
    */
    var width = window.innerWidth; //窗口宽度
    var height = window.innerHeight; //窗口高度
    /**透视投影相机对象*/
    var camera = new THREE.PerspectiveCamera(60, width / height, 1, 1000);
    camera.position.set(200, 300, 200); //设置相机位置
    camera.lookAt(scene.position); //设置相机方向(指向的场景对象)

二、本次实践

  1. 协作关系

    3D美术导出kiki的模型文件kiki.obj,及5张不同kiki的纹理贴图。前端使用Three.js提供的OBJLoader模型加载器加载kiki.obj模型,加载完毕后,给其设置相应kiki公仔的纹理贴图。

  2. 初始化

    首先初始化渲染出三位场景的必备因素,场景、相机和渲染器

    // 初始化场景
    initScene() {
     this.scene = new THREE.Scene();
     const ambientLight = new THREE.AmbientLight(0xcccccc, 0.9);
     this.scene.add(ambientLight);
    }
    // 初始化相机
    initCamera() {
     this.camera = new THREE.PerspectiveCamera(45, this.container.width / this.container.height, 1, 2000);
     this.camera.position.z = 1800;
    
     const pointLight = new THREE.PointLight(0xffffff, 0.3);
     pointLight.position.set(0, 1600, 0);
     this.camera.add(pointLight);
     this.scene.add(this.camera);
    }
    // 初始化渲染器
    initRenderer() {
     this.renderer = new THREE.WebGLRenderer({ alpha: true });
     this.renderer.setClearColor(0x000000, 0);
     this.renderer.setPixelRatio(window.devicePixelRatio);
     this.renderer.setSize(this.container.width, this.container.height);
     this.container.dom.appendChild(this.renderer.domElement);
    }
  3. 加载模型

    由于打包工具不具备解析obj文件的loader,所以需要将文件上传到CDN服务器,然后从服务器加载,然obj无法上传cdn,通过修改拓展名完成上传。

    loadModel(type) {
     return new Promise((resolve) => {
       let kikiModel = null;
    
       // 打印模型加载进度
       function onProgress(xhr) {
         if (xhr.lengthComputable) {
           const percentComplete = (xhr.loaded / xhr.total) * 100;
           console.log(`model ${Math.round(percentComplete, 2)}% downloaded`);
         }
       }
    
       function onError() {}
    
       const loader = new OBJLoader(manager);
       loader.load(
         'https://static-web.likeevideo.com/as/likee-static/page-44111/kiki.obj.png', // 上传到CDN的模型文件
         (obj) => {
           kikiModel = this.changePivot(0, 500, 0, obj);
         },
         onProgress,
         onError
       );
     });
    }
  4. 加载材质

    加载材质有两种方案,方案一,前端自然光,加载烘焙后贴图;方案二,前端自然光+点光源,加载无灯光处理的贴图。本案例采用方案二

    // 创建一个加载管理器,在模型加载完毕后给其设置texture
    const manager = new THREE.LoadingManager(() => {
     kikiModel.traverse((child) => {
       if (child.isMesh) {
         child.material.map = texture;
       }
     });
     kikiModel.position.y = -100;
     resolve(kikiModel);
    });
    
    // texture
    const textureLoader = new THREE.TextureLoader(manager);
    console.log(cardInfo, cardInfo.texture);
    texture = textureLoader.load(cardInfo.texture);
  5. 模型控制

    Three.js提供有控制模型的控件OrbitControls.js,引入后可通过var controls = new THREE.OrbitControls(camera,renderer.domElement);来控制模型。由于本案例产品只需要模型能通过绕Y轴进行预览,所以没有引入控件,而是通过监听touchstart和touchmove事件,及结合requestAnimationFrame进行控制。

    // 通过touchstart和touchmove得到手势的偏移量
    initEvent() {
     this.renderer.domElement.addEventListener('touchstart', this.touchStart.bind(this));
     this.renderer.domElement.addEventListener('touchmove', this.touchmove.bind(this));
    }
    
    touchStart(e) {
     e.preventDefault();
     this.startX = e.touches[0].pageX;
     this.startY = e.touches[0].pageY;
     this.endX = e.touches[0].pageX;
     this.endY = e.touches[0].pageY;
    }
    
    touchmove(e) {
     e.preventDefault();
     this.endX = e.touches[0].pageX;
     this.endY = e.touches[0].pageY;
    }
    // 根据手势划过的距离,得到模型旋转的角度,并通过requestAnimationFrame不断进行渲染
    animate() {
     if (this.inited && this.kikiModel && this.kikiModel.rotateY) {
       if (this.kikiModel) {
         const deltaX = this.endX - this.startX;
         this.kikiModel.rotateY(deltaX / 40);
       }
    
       this.startX = this.endX;
       this.startY = this.endY;
     }
     if (this.inited) {
       requestAnimationFrame(this.animate.bind(this));
       this.camera.lookAt(this.scene.position);
       this.renderer.render(this.scene, this.camera);
     }
    }
  6. 防止内存泄漏

    组件关闭时移除相应时间,并重置requestAnimationFrame的标志位

    destroy() {
     console.log('beforeDestroy');
     this.aniPlayer = null;
     this.renderer.domElement.removeEventListener('touchstart', this.touchStart);
     this.renderer.domElement.removeEventListener('touchmove', this.touchmove);
     this.scene = defaultData.scene;
     this.camera = defaultData.camera;
     this.renderer = defaultData.renderer;
     this.kikiModel = defaultData.kikiModel;
     this.allKikiModel = defaultData.allKikiModel;
     this.startX = defaultData.startX;
     this.startY = defaultData.startY;
     this.endX = defaultData.endX;
     this.endY = defaultData.endY;
     this.inited = false;
    }

参考文章

Three.js教程