ThreeJs 从基础入门到实现3D地球动画与智慧园区雏形

8,767 阅读15分钟

本期论点: 元宇宙爆火的当下,webGL及其衍生技术将会如何参与共建元宇宙?

一. threejs 学习之路

1.threejs 基础

既然是模拟的现实世界,那必然有共通之处!
  • 我们存在于现实宇宙,Models存在于他们的世界,那就,从为他们初始化一个宇宙开始吧
import * as THREE from "three";
// 主场景
// 在vue里 非响应式变量可声明data之外,提高性能呢
const scene = new THREE.Scene();

这里的 Scene 就相当于我们的三维世界,能包含一切事物,除了特殊的 时间 !

那怎么赋予我们创建的Models世界 时间维度呢? 那得看时间在宇宙中起了一个什么作用,才能在计算机中模仿不是吗.

时间静止会发生什么?(各位绅士请出门右转,这里没有你想要的)

假如意识也静止了 那么一切都永恒了. 假如意识依旧在活跃,想控制身体,但你还是动不了,除非时间主宰一个个普朗克时间的开放时间流动,这像什么?

渲染器!

渲染器是用来渲染出场景的,而场景包含光源,相机,模型,控制器等等,这一切构成了他们的世界 那就,初始化一个吧,让models可以一帧一帧的动起来

  initRender() {
      // 把你的渲染器渲染的场景挂载到哪一个dom上?
       container = this.$refs.container;
      // 创建渲染器 全局的renderer
      // webGLRenderer 是最常用的渲染器 其他的还有基于css3D的css3DRenderer ,和基于canvas的canvasRenderer
      renderer = new THREE.WebGLRenderer({
        antialias: true, // 抗锯齿
        precision: "highp", // 着色精度选择
        logarithmicDepthBuffer: true, // 消除模型交错闪烁
        preserveDrawingBuffer: true // 是否可以提取canvas 绘图的缓冲
        // shadowMap: true, // 开启阴影渲染 大量计算
        // alpha: true // 画布是否透明
      });
      // 设置场景显示区背景色
      renderer.setClearColor(0x000000, 0);
      // 场景显示区尺寸 (铺满)
      renderer.setSize(window.innerWidth, window.innerHeight);
      // 设置像素比 如果移动端掉帧 去掉这个试试
      renderer.setPixelRatio(window.devicePixelRatio);
      // 可以如此设置样式 例如确保cssRender与render不遮盖
      // renderer.domElement.style.position = "absolute";
      // renderer.domElement.style.top = 0;
      // renderer.domElement.style.zIndex = "1"; 
      // 挂载dom
      container.appendChild(renderer.domElement);
    },
    ...
    

"好黑啊,我们这是在哪里? 蔚" 金克斯摸着辫子疑惑的说

"站在光里的大人物,看不见身处黑暗的我们,咱们得去寻找光" 蔚拉住了金克斯的手

"这次... 这次我一定能帮上忙的!" 金克斯看向蔚的方向

"我一直相信你,我们一起 打破这黑夜!"

那就,为她们初始化一个光源吧

    // 初始化灯光
    initLight() {
      //环境光 意思就是,为什么明明我在屋子里背光,按理说一片黑暗才对,但是还有光呢,环境反射的光
      ambientLight = new THREE.AmbientLight(0xf1e2e2, 1.25);
      // 加入世界里去 不加入不会渲染哦
      scene.add(ambientLight);
      //点光源 白色灯光,亮度0.6
      pointLight = new THREE.PointLight(0xbfbfbf, 0.6);
      //设置点光源位置,改变光源的位置
      pointLight.position.set(0, 40, 80);
      scene.add(pointLight);
      // 还有聚光灯和平行光  用法同上
    },

峡谷动物园 "蔚! 你看你看,那个小猴子像不像我做的小猴子炸弹"
...
"蔚? 你为什么一直盯着天空看" 金克斯抬头看了看,好奇的问
零散的云彩,随风飘摇
"我有一种被操纵的感觉,很奇怪,很真实,就像是..玩偶?" 蔚沉思道
"不,你就是你,你就是 蔚" 金克斯抱住蔚的手说
蔚抱住了金克斯 "嗯 ! 我们永远是姐妹,金克斯的含义就是金克斯"

如何全方位监视? 毕竟我们是第三人称上帝视角 得有个金手指不是吗

那就,添加一个相机把

     // 加载相机 与 相机控制
    initCamera() {
      // 透视相机 与此对应的 是平行视角相机 而 我们看世界就是远小近大的透视相机
      // 具体参数详解搜一下吧 很简单
      camera = new THREE.PerspectiveCamera(
        30, // 视界 大部分是 30-90 比如游戏就可以调节视野大小
        window.innerWidth / window.innerHeight, // 投影的宽高比
        1, // 我的理解 : 距离相机多近就不渲染了?
        100000 // 距离相机多远就不渲染了?
      );
      // 相机位置
      camera.position.x = 697.1343985659603; // 是不是觉得这么多小数怎么出来的? 很吓人?
      camera.position.y = 1784.0457888299613;// 后边调试那里会说到 如何快速开发
      camera.position.z = 1566.095679557605; // 嗯
      
      //相机以Y轴方向为上方(当值为1时即为上) 限制在y轴只能在正方向,比如加了地面元素之后,别跑到地下穿模了
      camera.up.x = 0;
      camera.up.y = 1;
      camera.up.z = 0;
      //相机看向哪个坐标 (原点)
      camera.lookAt({
         x: 0,
         y: 0,
         z: 0
      });
      
      //加载相机的鼠标操作 左键上下左右拖动 右键平移 滑轮缩放
      // 引入 import { OrbitControls } from "three/examples/jsm/controls/OrbitControls"; // 相机控制
      controls = new OrbitControls(camera, renderer.domElement);
      //设置相机初始焦点
      controls.target = new THREE.Vector3(x, y, z); //(0,1,0);
      //是否可以缩放
      controls.enableZoom = true;
      //是否自动旋转
      controls.autoRotate = false;
      //最大纵向旋转角度
      controls.maxPolarAngle = Math.PI / 2;
      //是否开启右键拖拽
      controls.enablePan = true;
      // 设置旋转速度
      controls.rotateSpeed = 1;
      // 使动画循环使用时阻尼或自传,意思是否有惯性
      controls.enableDamping = true;
      // 设置相机距离原点的最近距离
      controls.minDistance = 1;
      // 设置相机距离原点的最远距离
      controls.maxDistance = 20000;
    },

看到这里,迷糊了吗,我们来捋一下 首先我们创建了 Scene,来存放世界元素,都有什么? 光源,相机,相机控制,和模型. 首先初始化一个Renderer,确保世界是能运动的,然后我们加入light,加入环境光让我们看清世界里的元素,加入点光源或其他光源,让模型有立体感,然后我们加入Camera 与 Controls ,确保我们能用鼠标全方位查看模型,其实到了这里,主要场景搭建完了,还差最关键的一步,把场景渲染出来!

介绍一个关键函数 requestAnimationFrame(); 作用是让浏览器自动在最优时间内执行下一帧动画 那么我们渲染器输出的动画帧,就需要用这个函数递归调用来输出图像并顺便优化性能

    startAnimate() {
      // 更新相机位置
      controls.update(); 
      // 更新Tween 动画帧
      if (TWEEN) {
        TWEEN.update();
      }
      // 也可以不单独写
      this.render();
      // 存起来可用于清理循环动画 这很消耗资源
      ranimationID = requestAnimationFrame(this.startAnimate);// 递归调用
     
    },
    render() {
      // 调用render函数 渲染场景
      renderer.render(scene, camera);
      // 如果两个场景渲染器 当然也需要更新
      if (rendererCSS) {
        rendererCSS.render(cssScene, camera);
      }
    },

到了这里 我们的画面应该是这样的

?

对 啥也没有 除了一个场景的背景色(0x000000)

那咋办? 你问我 我问谁啊 我当时可是急死了

不急,我们先加一个天空盒(什么是天空盒去百度一下)压压惊 上边的都是函数可以汇总在一个里

 // 初始化3D 场景
    init3dScene() {
      // 创建一个组
      this.whole = new THREE.Group();
      this.yuanquGroup = new THREE.Group();
      this.yuanquBuildGroup = new THREE.Group();
      // 初始化场景渲染器
      this.initRender();
      // 初始化天空盒
      this.initScene();
      // 初始化相机与相机控制
      this.initCamera();
      // 加载灯光
      this.initLight();
      // 视角控制 待会说 
      this.animateVis();
      // 启动渲染
      this.startAnimate();
      // 加载模型 也待会说
      this.loadMainScene();

    },
     // 加载辅助场景
    initScene() {
      // 当你需要去更新你场景中对象矩阵的时候,会涉及到计算,如果只是静态对象,并且操作不频繁,你可以关闭matrixAutoUpdate参数,手动去更新矩阵
      scene.matrixAutoUpdate = false;
      // 辅助坐标系
      var axes = new THREE.AxisHelper(20);
      scene.add(axes);
      // 设置背景
      // 添加天空盒第一种方式
      /**  我从官网扒的星空天空盒
      skyBox: [
        "skybox/dark-s_px.jpg",
        "skybox/dark-s_nx.jpg",
        "skybox/dark-s_py.jpg",
        "skybox/dark-s_ny.jpg",
        "skybox/dark-s_pz.jpg",
        "skybox/dark-s_nz.jpg"
      ],*/
      scene.background = new THREE.CubeTextureLoader().load(this.skyBox);
    },
    

成功了!

让我们看看效果!(有什么屏幕录制gif的神器求推荐)

1.gif

2.threejs 学习资料

分享一些我的收藏

  1. Threejs 零基础入门
  2. Threejs 官网
  3. 体验一下极致的web3D 应用 yyds
  4. three.js 实现图片粒子爆炸特效
  5. three.js 动效方案
  6. Gltf 格式详解 很详细了属于是
  7. 嗯 一个很炫酷的3D 博客
  8. 使用Three.js实现3D楼盘展示
  9. 关于图片 文字 canvas 纹理的不错博客
  10. 一个在网页中嵌入3d模型的东东

3.threejs 调试

直接上结论,在谷歌浏览器里加一个

image.png

然后在调试工具里就有啦,修改模型的位置,材质,之类的都会实时修改,舒服了,前文里提到的高精度的小数
其实就是打印的camera.position,当你需要某个位置的参数,在render()里打印一下好了,暂未发现更好的解决办法

image.png

二. 3D 模型加载与 css3DRenderer

加载3d模型,首先要添加相应的 loader 文件,这些js文件,都在官网example演示文件夹里

这里就以 gltf 3d文件为例,演示一下3d模型加载

首先了解一下gltf格式 我的理解是: 通过一个json格式文件,描述贴图资源与3d模型对应位置信息的文件 里边的内容就是一些json描述的配置,而贴图资源可以内联为imag base64编码 也可以单独分出一个.bin文件存储这些编码,也可以引用图片文件 这是内联的 image.png 这是有bin文件和图片的

image.png image.png

知道了gltf 文件格式,那我们怎么引入到场景中呢

好,直接上成品代码(这里用了本地的gltf资源文件,是内联格式的)

小二 ! 上酒 !

loader.js 文件
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader"; // .gltf 文件 loader

const loader = {
    // 关于代码写法欢迎指教 我是前端1年小白
    //加载GLTF文件 只加载模型,没有动画
    loadGLTFOnly(objs, callback) {
        let loader = new GLTFLoader();
        let tempArr = [];
        objs.forEach((one) => {
            // 放在本地一样 找到gltf文件即可
            loader.load('http://你家服务器地址/cdn/' + one.gltfUrl, function (gltf) {
                let model = gltf.scene;
                model.name = one.name;
                console.log(model.name + '  done!');
                // 返回每一个加载的model 加入到组中
                callback(model);
                
            });
        });
    }
   
}
export default loader;

就这么简单? 对啊 毕竟前人都为我们写好loader和教程了 怎么用? 上边提到一个组的概念,其实就是比如你加载一个人体模型,分个组吧,把耳鼻口分到头组,把左右手分到手组,如果老板喜欢无面人,那你就把头组的耳鼻口 remove掉,如果老板说我喜欢刑天,那就把头组去掉,嘿嘿 我们返回的model就可以直接加到组里了,当然你要是想直接Scene.add(yourModels) 也不是不可以!

下面我们在原有的基础上加入两个模型,瞅瞅效果,是不是还是黑的?

增加一个initMainScene() 函数到你的init3dScene()中
    // 加载主要场景
    initMainScene() {
      let elements = [];
      elements = [
        {
          gltfUrl: "gltf/超大的地面场地.gltf",
          name: "cdcd",
          level: "园区"
        },
        {
          gltfUrl: "gltf/周边的建筑.gltf",
          name: "buildings",
          level: "园区"
        }
      ];
      loader.loadGLTFOnly(elements, model => {
      // 园区的模型放在这个组里
        this.yuanquGroup.add(model);
    
      });
      // 全部模型 便于管理
      this.whole.add(this.yuanquGroup);
      // 不要漏了这一步
      scene.add(this.whole);
    },

成功了! 不黑了!

2.gif

我们成功加入了3D模型,并且让他们的世界有了光!

关于css3DRendeer 用到的并不会很多,最震撼的效果就是 官网的 元素周期表了

Thress js 3DRenderer 实现炫酷元素周期表

在项目中的使用 会与主场景 webGl renderer 发生一些不可描述的作用 而且 css3D 场景与 主场景 并不会产生模型的遮挡,css3D 模型一直在主场景模型之前,我还没解决这个问题 比如这样

屏幕截图 2021-12-02 164028.jpg image.png

就很难受 所以尽量只用一个渲染器 一个Scene 我这里怎么用的css3D?

其实就是把dom 节点,喂到 cssRenderer 嘴里,剩下的它全包了 image.png 然后拿到dom节点

let node = document.getElementById(elementId).cloneNode(true);

然后用 CSS3DSprite 或者 CSS3DObject 转化一下

cssObject = new CSS3DSprite(node); cssObject = new CSS3DObject(node);

把cssobject加入到cssScene中就行了,不详解了

三. 实现地球入场动画

这不得先上图?

3.gif 其实很简单,主要就是相机动画

animation.js
import { TWEEN } from "three/examples/jsm/libs/tween.module.min.js"; // 动画补帧器
import * as THREE from "three";

const animate = {
    //相机移动实现漫游等动画
    animateCamera(camera, controls, newP, newT, time = 2000, callBack) {
        var tween = new TWEEN.Tween({
            x1: camera.position.x, // 相机x
            y1: camera.position.y, // 相机y
            z1: camera.position.z, // 相机z
            x2: controls.target.x, // 控制点的中心点x
            y2: controls.target.y, // 控制点的中心点y
            z2: controls.target.z, // 控制点的中心点z
        });
        tween.to(
            {
                x1: newP.x,
                y1: newP.y,
                z1: newP.z,
                x2: newT.x,
                y2: newT.y,
                z2: newT.z,
            },
            time
        );
        tween.onUpdate(function (object) {
            camera.position.x = object.x1;
            camera.position.y = object.y1;
            camera.position.z = object.z1;
            controls.target.x = object.x2;
            controls.target.y = object.y2;
            controls.target.z = object.z2;
            controls.update();
        });
        tween.onComplete(function () {
            controls.enabled = true;
            callBack();
        });
        tween.easing(TWEEN.Easing.Cubic.InOut);
        tween.start();
    },


}
export default animate;

gif掉帧严重,原动画还是很流畅的.

关于实现 稍微麻烦点的就是得打印出来每个位置点,传进去分几段的动画终点 穿云动画用到一个组件 "image-sprite": "^1.0.0" 这里感谢一个开源项目 这个动画借鉴了很多 大佬

大佬模仿腾讯qqXplan 原qq项目

四. 实现园区管理雏形

其实这才是web3d的主打亮点吧
从智慧城市到智慧园区,包括虚拟博物馆和3D的产品展示,都能直接体现web3D的独特价值-真实直观

1. 模型加载与场景视角切换

gltf文件导出时应该是可以设置位置信息的,加载之后有一个默认位置,因此园区的模型位置, 你可以手动修改position.x/y/z,最好的就是在建模导出时就确定好相对位置

加载图片资源可以用 resource-loader 一款通用的资源加载器

加载模型时的回调 可以用 manager = new THREE.LoadingManager(); 管理

     //资源加载控制
      manager = new THREE.LoadingManager();
      manager.onProgress = function (item, loaded, total) {
        console.log(((loaded / total) * 100).toFixed(2) + "%");
      };
      ...
     // 加载器接收一个manager对象作为参数
      let loader = new GLTFLoader(manager);
    

关于场景切换 目前我的解决方案就是在不断add 或者 remove 组中的成员,相应的视图也会刷新 ,有多种控制器供选择,找了个图 来源 image.png 我们常用的就是轨道控制器,可以实现基本拖拽缩放事件,其他控制器可以根据场景选择

2. 精灵模型

我们加载完模型之后,一般都需要在3D场景中展示一些信息,这就需要精灵模型了

简而言之,就是一种一直朝向摄像头的模型,我们只能看见正面,看不见反面,在Three中有一套系统的解决方案 4.gif

           let texture = new THREE.TextureLoader().load(item.url);
            let spriteMaterial = new THREE.SpriteMaterial({
                map: texture, // 加载精灵材质的背景
                opacity: 1 
            });
            let sprite = new THREE.Sprite(spriteMaterial);// 创建精灵模型
            // 设置基本信息 就ok了
            sprite.scale.set(item.scale.x, item.scale.y, item.scale.z);
            sprite.position.set(item.position.x, item.position.y, item.position.z);
            sprite.name = item.name;

也有css3DSprite 可以将css 转为3d 精灵模型 用法简单

3. canvas转纹理

  最基本的包含文字图片的canvas转为纹理贴图,
  然后从Echart图标转为纹理,
  从视频<video>标签转为视频纹理,
  将flv rtsp 直播或监控视频流转为视频纹理

文字图片:

首先感谢一下 上文提到的 收藏第九条的作者大大 直接上代码

   // 获取一个canvas 图文纹理
    getTextCanvas(w, h, textArr, imgUrl) {
        // canvas 宽高最好是2的倍数
        let width = w; 
        let height = h;
        // 创建一个canvas元素 获取上下文环境
        let canvas = document.createElement('canvas');
        let ctx = canvas.getContext('2d');
        canvas.width = width;
        canvas.height = height;
        // 设置样式
        ctx.textAlign = 'start';
        ctx.fillStyle = 'rgba(44, 62, 80, 0.65)';
        ctx.fillRect(0, 0, width, height);
        //添加背景图片,进行异步,否则可能会过早渲染,导致空白
        return new Promise((resolve, reject) => {
            let img = new Image();
            img.src = require('@/assets/images/' + imgUrl);
            img.onload = () => {
                //将画布处理为透明
                ctx.clearRect(0, 0, width, height);
                //绘画图片
                ctx.drawImage(img, 0, 0, width, height);
                ctx.textBaseline = 'middle';
                // 由于文字需要自己排版 所以循环一下
                // item 是自定义的文字对象 包含文字内容 字体大小颜色 位置信息等
                textArr.forEach(item => {
                    ctx.font = item.font;
                    ctx.fillStyle = item.color;
                    ctx.fillText(item.text, item.x, item.y, 1024);
                })
                resolve(canvas)
            };
            //图片加载失败的方法
            img.onerror = (e) => {
                reject(e)
            }
        });
    },
    
主要就是对于图片加载的异步处理,保证渲染之前canvas加载完毕,函数可以直接食用哦
比如这样
  this.getTextCanvas(1024, 512, element.textArr, element.imgUrl).then((canvas) => {
            let textMap = new THREE.CanvasTexture(canvas); // 关键一步
            let textMaterial = new THREE.MeshLambertMaterial({ // 关于材质并未讲解 实操即可熟悉                 这里是漫反射类似纸张的材质,对应的就有高光类似金属的材质.
                map: textMap, // 设置纹理贴图
                transparent: true,
                side: THREE.DoubleSide,// 这里是双面渲染的意思
            });
    let planeGeometry = new THREE.PlaneBufferGeometry(planeWidth, planeHeight); // 平面3维几何体PlaneGeometry
    let planeMesh = new THREE.Mesh(planeGeometry, textMaterial);
   

结果是这样的

image.png

Echart图表:

不多说 上代码 soEasy
    // 获取一个 echart 图表 canvas
    getEchart(w, h, option,) {
        let canvas = document.createElement('canvas');
        canvas.width = w;
        canvas.height = h;
        return new Promise((resolve, reject) => {
            let myChart = echarts.init(canvas, 'dark');
            try {
                // 很显然 用过Echart的都知道这个option是啥 不多说
                myChart.setOption(option, false);
                myChart.on(
                    'finished',
                    () => {

                        resolve(canvas);
                    }
                )
            } catch (e) {
                reject(e)
            }
        });
    },

结果图:

image.png

我知道你们想问什么!

确实没法交互,因为已经转成纹理贴图了,如果有大佬知道如何交互,希望不吝赐教

视频:

其实更简单,但是我测试的时候 有巨坑 帮你们排雷了
坑1: video 标签的muted问题,muted让video静音播放可以解决谷歌浏览器设置了 autoplay 却不自动播放的问题
解释1: 经过一番探sou究suo,感觉是浏览器的安全策略,默认禁止网页获取音频权限,
需要申请,就是左上角弹个小框是否允许网页播放音频之类的,所以导致autopay不管用了
坑2: 因为我这个之全屏幕web3D ,其他2D 组件都飘在主场景canvas上,
所以我第一次测试Video为了美感把他visible隐藏了,结果打死也渲染不出来视频纹理!
最后发现video组件只有在视窗内, 或者设置了z-index = -1,隐藏在组件下,才能播放...才能实时渲染出视    频纹理
解释2:未知
    // 获取一个视频纹理
    getVideoTexture(id) {
        // video 标签的ID
        let video = document.getElementById(id);
        let _texture = new THREE.VideoTexture(video);
        _texture.minFilter = THREE.LinearFilter;
        _texture.wrapS = _texture.wrapT = THREE.ClampToEdgeWrapping;
        _texture.format = THREE.RGBFormat;
        return _texture;

    },
    

结果图

5.gif

直播流:

关于直播流吧,rtmp流 依赖于flash,我在谷歌上没有实现,目前测试的一个flv地址,可以跑通,用的是flv.js
       具体使用方法,参考flvjs教程
       if (flvjs.isSupported()) {
            var videoElement = document.getElementById("videoElement");
            var flvPlayer = flvjs.createPlayer(
              {
                type: "flv",
                isLive: true,
                hasAudio: true,
                hasVideo: true,
                url: this.url
              },
              {
                autoCleanupSourceBuffer: true,
                enableWorker: true,
                enableStashBuffer: false,
                stashInitialSize: 128
              }
            );
            flvPlayer.attachMediaElement(videoElement);
            flvPlayer.load();
            flvPlayer.play();
          }

其实原理 依旧是依赖于Video 标签 ,只不过是利用flv.js 将直播流实时的用video 标签播放出来,然后后面的就是转视频纹理的流程了

五. 优化与展望

最近看了ThingJs 官网的实例开发项目,发现原来web3D 还有这么多玩法,自己还需努力学习,但是我感觉web3D 性能依旧不足以支持 什么智慧城市 ,而且大部分数据展示,也只是拿3D模型当背景板,并没有结合的很好,感觉web3D 是不是缺少一个很实在的落地点呢,个人想法,会不会与元宇宙,碰撞出火花. 最后,都看到这儿了,若是觉得文章尚可,求一个免费的赞喽...