带你入门 web3d 开发(上)


theme: fancy
highlight: atom-one-dark

真实案例欢迎扫码体验:

ch0-1.png
里面主要就涉及模型显示、数据与模型结合、场景交互三部分内容。本文也是从这三个点出发,举简单的例子带大家入入门。

three api

首先还是要先介绍一下 three 基础的 api (概念、功能模块)

  • 帧的概念
    three 里面必写的逻辑
update() {
    //每帧渲染
    //...
    //每帧渲染
    window.requestAnimationFrame(() => {
      update();
    });
  }
  • loader
    用来加载外部资源:纹理图片、3d 模型等。如 GLTFLoader 用来加载 gltf、glb 等模型文件;TextureLoader 用来加载纹理图片。
  • canvas 元素
    就是那个 canvas((^_-)
  • render
    把 3d 空间在相机中的画面渲染到 canvas
  • scene
    3D 场景根对象,所有物体都会在 scene 对象下
  • Object 3D (包括相机)
    最基础的属性:position、rotation 。物体 3d 变换可以通过设置如上两个属性进行,比如相机的运镜效果就是通过改变相机的 position 和 rotation 来实现的。
  • 相机
    3d 空间最常用的是透视相机,下文所述“相机”都是指透视相机

基础示 🌰

class App3dScene {
  constructor() {
    this.canvas = document.getElementById("app-canvas");
    this.renderer = App3dScene.createRenderer(this.canvas);
    this.scene = App3dScene.createScene();
    this.camera = App3dScene.createCamera();
    this.scene.add(this.camera);
    this.update();
    this.onWindowResize();
  }

  static createScene() {
    return new THREE.Scene();
  }

  static createCamera() {
    const camera = new THREE.PerspectiveCamera(
      70,
      window.innerWidth / window.innerHeight,
      0.01,
      10000
    );
    return camera;
  }

  static createRenderer(canvas) {
    const renderer = new THREE.WebGLRenderer({
      antialias: true,
      alpha: true,
      canvas,
    });
    renderer.setSize(canvas.clientWidth, canvas.clientHeight);
    return renderer;
  }

  update() {
    //   console.log('update')
    this.renderer.render(this.scene, this.camera);
    this.req = window.requestAnimationFrame(() => {
      this.update();
    });
  }

  destroy() {
    window.cancelAnimationFrame(this.req);
    window.removeEventListener("resize", this.fn);
  }

  onWindowResize() {
    const fn = () => {
      this.camera.aspect = window.innerWidth / window.innerHeight;
      this.camera.updateProjectionMatrix();
      this.renderer.setSize(window.innerWidth, window.innerHeight);
    };
    window.addEventListener("resize", fn, false);
    this.fn = fn;
  }
}

功能与技术点介绍

真实案例的细节和功能比较多,下文讲解是从技术点出发,不涉及业务。

  • 模型显示:采用“面片系统”(几何结构+烘焙贴图)的方案搭建场景。代码上和加载 3d 模型一样,区别在于模型资源内部不同。这种方式其实主要是 web 上的性能优化。
  • 动态数据与模型融合:动态数据指用户上传的图片,画框是模型中的一部分。上传后两者要相互适应,图片数据融合进模型。
  • 交互系统:单指滑动浏览,点击与画框交互。

技术点集合写了个完整的 demo,源码点这里,扫码可体验:

ch0-2.png

模型显示

对于刚入门 3D 开发的人来说,可能经常遇到一个问题,就是明明把物体或模型加到场景中了,但是没显示出来。所有的原因都可以归结于空间关系不对。所以下面我们先来说说空间关系。

空间关系

物体在相机的视野范围内(图中灰色的四棱台空间)才能看到物体。反应到代码中相机的位置、物体的位置、相机空间的大小、模型大小就很重要。

space_camera.jpg
下文中的相机传参与图片对应

static createCamera() {
    const camera = new THREE.PerspectiveCamera(
      90,//fov
      window.innerWidth / window.innerHeight,//width/height
      0.01,//near
      10000//far
    );
    return camera;
}

回顾我们案例,相机是在房间模型里面的。我们先举个最最基础的例子,让相机位于一个正方体中间。

function createCube() {
  const cubeGeometry = new THREE.BoxGeometry(15, 15, 15);
  cubeGeometry.scale(-1, 1, 1); //图贴到里面
  const loader = new THREE.TextureLoader();
  const f = loader.load(`https://res5.xingtuyunlian.com/1651199904993468893`);
  const cubeMaterial = [
    new THREE.MeshBasicMaterial({ color: "blue", transparent: true }), //right
    new THREE.MeshBasicMaterial({ color: "yellow", transparent: true }), //left
    new THREE.MeshBasicMaterial({ transparent: true, color: "red" }), //top
    new THREE.MeshBasicMaterial({ transparent: true, color: "green" }), //bottom
    new THREE.MeshBasicMaterial({ color: "black", transparent: true }), //front
    new THREE.MeshBasicMaterial({ map: f, transparent: true }), //back 背面为图片,其他面都是颜色
  ];
  const cube = new THREE.Mesh(cubeGeometry, cubeMaterial);
  return cube;
}
class App3dScene {
  constructor() {
    // ....
    this.camera = App3dScene.createCamera();
    this.camera.position.set(0, 0, 0); //相机放在原点
    const cube = createCube(); //创建边长为5,各个面颜色不同的正方体
    cube.position.set(0, 0, 0); //正方体也放在原点偏下的位置
    // 实现相机正好在物体的正中央
    this.scene.add(this.camera, model);
    // ....
  }
  //...
}

大概效果就是:

相机默认是朝向 z 轴负方向的!也就是说相机和其他物体(正方向都是+z)不一样。

模型能否正常显示其实和正方体是一样的,也是需要注意模型与相机的空间位置关系。因模型资源不能直接对外使用,所以不直接用模型举例啦,列一下模型加载需要注意的点:

  • 要用对应的 loader 加载,比如 gltf 文件用 GLTFLoader。
// dirName 目录 fileName 文件名
function loadGLTFGroup(dirName, fileName) {
  // 封装为 promise 实际业务中用起来更方便
  return new Promise((resolve) => {
    const loader = new GLTFLoader().setPath(`${dirName}`);
    const loadSuccess = (model) => {
      const GLTFGroup = model.scene;
      resolve(GLTFGroup);
    };
    // 还支持 progress error 回调函数
    loader.load(fileName, loadSuccess);
  });
}
  • gltf 文件渲染需要设置 render.encoding = sRGBEncoding;
  • 与美术同事的对接上会有一些协定。结合我们的真实案例,下一小节具体列出一些细节点

实际工作中的 tips

如果模型是动态数据,并且存在组合情况,比如我的项目中整体呈现的是一座楼,每一层都有一个独立的模型文件,层层之间自由组合形成不同的楼。

以下称模型文件中楼层主体部分为“楼层主体”,整个模型的空间称为“模型空间”。

  • 原点:在模型软件中,不同模型文件的模型空间原点相对于楼层主体都在类似位置。不能 a 模型文件的原点在楼层的房顶左上角,b 模型文件的原点在楼层的地面中间,这样杂乱的原点在组合楼层时需要每个楼层单独位移调整,徒增麻烦。
  • 坐标轴:模型软件与 three 坐标轴不一样,需要模型设计师制作模型的时以 three 的坐标轴为准。比如 blender 中 z 轴朝上,而在 three 中 y 轴朝上。并且需要楼层正面保持统一性。不能 a 楼层的正面是朝 z 轴正方向,b 楼层的正面朝 x 轴的负方向。
  • 尺寸:不同模型文件楼层主体尺寸应该保持差不多的尺寸。不能 a 的楼层主体是 5m*5m*5m,b 的楼层主体是 500m*500m*500m。这样组合时,楼层堆叠就很不协调,而且可能会超出相机空间或显示太小。

动态数据与模型融合:画作处理

有如下比例不一的图片:

ch4-p0-1.png
要放到画框里显示,画框的是模型中的一部分。(例子中画框摆放看起来比较随意,因为无法用真实数据,是模拟的,实际是模型设计师契合模型摆放的)

细节功能点包括:

  • 画框要适配图片的比例进行变形
  • 不超过预设的画框的范围,类似 css 的 object-fit:contain

图片数据与模型的融合方案

(笔者也是小白入门,如果你有更好的方案欢迎留言!)

思路

废话不多说,直接看流程图:

ch4-p1-1.png
模型中的相框部分单独作为一个 glb 文件,与其他部分(墙壁、房顶、底面、摆件等)剥离开来,方便解析。
解析模型数据,得到相框的位置、旋转量、尺寸。从后端获取图片 url。根据这些信息去创建新的相框物体、图画物体。

==================================================

通过 loader 加载模型后,查看相框模型的数据,如图:
ch4-p1-2.png
可以看到相框的位置和旋转量容易获取分别是 position、rotation。但是相框的大小不好获取,没有边长参数而是一组顶点数据。

==================================================

那怎么从这些数字中计算出画框的长宽呢,利用正方形的几何特点,取巧地解析了边长数据:
ch4-p1-3.png
所以边长其实就是:2*|position[0]| 即:Math.abs(position[0])*2

代码

// 关键方法一 从模型中解析出相框的数据(锚点数据)
function getAnchors(dirName, fileName) {
  const loader = new GLTFLoader().setPath(`${dirName}`);
  const loadSuccess = (v) => {
    // 从模型中取出锚点信息
    let anchors = v.scene.children.map((item, index) => {
      // 获取 位置、旋转等信息
      const { position, rotation, name, geometry, material } = item;
      // 计算边长
      const halfOfWidth = geometry.attributes.position.array[0];
      const sideLength = 2 * Math.abs(halfOfWidth);
      // 获取相框 框框的颜色
      const borderColor = material.color;
      // 记录锚点信息
      return {
        position,
        rotation,
        name: `floor-${floorIndex + 1}~${name}`,
        imgIndex: index,
        sideLength,
        borderColor,
      };
    });
    resolve(anchors);
  };

  loader.load(fileName, loadSuccess);
}

// 关键方法二 图片不超过相框范围,类似 object-fit:contain 的效果。
/**
 *
 * @param {Number} naturalWidth 图片的宽
 * @param {Number} naturalHeight 图片的高
 * @param {Number} maxSize 相框边长
 * @returns
 */
function calculateSize(naturalWidth, naturalHeight, maxSize) {
  let size = [];
  const k = naturalHeight / naturalWidth;
  if (k > 1) {
    //竖版
    const h = maxSize;
    const w = h / k;
    size = [w, h];
  }
  if (k === 1) {
    //正方形
    size = [maxSize, maxSize];
  }
  if (k < 1) {
    //横版
    const w = maxSize;
    const h = w * k;
    size = [w, h];
  }
  return size;
}

// 关键方法三 获取图片自然宽高
function getTexture(imgUrl) {
  return new Promise((res, rej) => {
    const loadSuccess = (result) => {
      const { naturalHeight, naturalWidth } = result.source.data;
      res({ texture, nw: naturalWidth, nh: naturalHeight });
    };
    const texture = new TextureLoader().load(imgUrl, loadSuccess);
  });
}

剩下的就是拿到这些信息去创建画作了,比较简单就不放代码了。
demo 完整源码点这里

更多思考

其实是一路踩过的坑啦(#^.^#)

另一种方案

相框的位置、旋转量、尺寸、图片的 url、尺寸都是来自后端的 json,根据这些信息去创建画作(3d object),而不需要解析模型数据。

这个方案符合常规的开发思路,对前端来说是最友好最方便的。
但实际上可行性差,因为模型软件不具备直接导出部分 json 数据的能力,模型导出的数据是二进制文件或者类似这样的 json:

它是很不直观的,不满足我们所需的数据结构。
如果偏要模型导出指定的数据结构,还需要针对模型软件开发一个插件,成本更大不如用上面的 loader 方式解析模型数据。

另二种方案

不用解析每个相框的定位、边长等信息,直接把相框的几何体按照图片的比例收缩,然后把图片纹理贴到相框上并留些边框的距离。

这个方案复用了模型中的 3d 相框的位置、旋转量、几何体,看起来不用读取顶点数据了,少了计算,省了事儿。但局部纹理贴图(需要留边距)还是需要计算出边长的具体值,three 里可没有 css 的 calc(100%-边距 px)这样的算法。

就算不考虑边距,还有一些痛点。
期望:ch4-p2-4.png可能的错误情况:ch4-p2-5.png
要避免这种情况,需要模型设计师有一定的纹理映射知识,这对于模型制作者来说会有一定的学习成本,增加了工作流程中的沟通成本。

所以呀,这个方案也是被我pass掉的。

交互

写不动了,下篇见 ~

© 版权声明
THE END
喜欢就支持一下吧
点赞9 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容