用three.js做一个3D汉诺塔游戏(上)

news2024/12/25 14:29:54

本文由孟智强同学原创,主要介绍了如何利用 three.js 开发 3D 应用,涵盖 3D 场景搭建、透视相机、几何体、材质、光源、3D 坐标计算、补间动画以及物体交互实现等知识点。


入门 three.js 也有一阵子了,我发现用它做 3D 挺有趣的,而且学习门槛也不算高。在这篇博文中,我想分享一下利用 three.js 做一个 3D 版汉诺塔(河内塔)的过程,以及对 three.js 相关知识点进行一次较为全面的实战总结。希望能与大家交流技术心得和经验,一起共同进步。

效果展示

在这里插入图片描述

游戏规则:将串在左边柱杆(A柱)上的盘子全部挪进右边柱杆(C柱)即可获胜;一次只能挪动最上面的一个盘子;每个盘子的上面只能放置比它小的盘子;可利用中间的柱杆(B柱)来中转、倒换盘子。

可自由选择游戏难度(盘子数量),游戏中途可随时重开,获胜后会有该局耗时和步数的统计信息。

  • 在线演示:https://kagol.github.io/hanoi/

  • 编程语言:JavaScript(ES6+)

  • 代码架构:MVP

  • 用到的框架/库

    • three.js - JavaScript 3D 框架
    • tween.js - 提供动画支持
    • canvas-confetti - 提供彩屑特效支持

本文知识点

  • 3D 场景初始化:场景、相机、渲染器
  • 透视相机的位置调整
  • 几何体:BoxGeometry、CylinderGeometry、LatheGeometry
  • 材质:MeshLambertMaterial、MeshPhongMaterial、MeshBasicMaterial
  • 光源:AmbientLight、SpotLightHelper、DirectionalLight
  • 更新材质的纹理:TextureLoader
  • 渲染 3D 文本:TextGeometry、FontLoader
  • 实现物体阴影效果
  • 3D 坐标的计算
  • 物体交互的实现:Raycaster、坐标归一化
  • 3D 资源的销毁释放
  • 补间动画、动画编排
  • MVP 架构、class 等

初始化

为了方便演示,避免引入底层框架(Vue、React、Angular…)的代码增加复杂度,本文中的案例没有使用前端底层框架和工程脚手架,而采用传统的 HTML 单文件方式来编写代码。

首先,准备一个空白容器,让它的尺寸与浏览器视窗大小相同,以充分利用屏幕空间。

<style>
  body {
    padding: 0;
    margin: 0;
    font: normal 14px/1.42857 Tahoma;
  }

  #app {height: 100vh;}
</style>

<div id="app"></div> <!-- 空白容器 -->

对于 JS 脚本,使用 导入映射 配置资源的 CDN 地址,这样就可以像使用 npm 包一样导入相关资源。

<script type="importmap">
  {
    "imports": {
      "three": "https://cdn.jsdelivr.net/npm/three@0.160.0/+esm",
      "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
    }
  }
</script>
<script type="module">
  import * as THREE from 'three';  // 丝滑导入 three.js
</script>

接下来,创建一个场景(Scene)、一个透视相机(PerspectiveCamera)和一个 WebGL 渲染器(WebGLRenderer),并将渲染器添加到 DOM 中。同时,编写一个渲染函数,使用requestAnimationFrame 方法循环渲染场景。以下是最基础的初始化代码:

<script type="module">
  import * as THREE from 'three';
  
  const containerEl = document.getElementById('app');
  const { width, height } = containerEl.getBoundingClientRect();

  /* 场景 */
  const scene = new THREE.Scene();
  
  /* 相机 */
  const fov = 45;  // 视野角度
  const camera = new THREE.PerspectiveCamera(fov, width / height, 1, 500);

  /* 渲染器 */
  const renderer = new THREE.WebGLRenderer({ alpha: true });
  renderer.setSize(width, height);
  renderer.setClearColor('#f8f8f6', 1);  // 设置初始化背景
  containerEl.appendChild(renderer.domElement);

  // 渲染场景(循环)
  (function animate() {
    requestAnimationFrame(animate);
    renderer.render(scene, camera);
  }());
</script>

上面 PerspectiveCamera 设置了 4 个参数,其中最后 2 个参数分别是相机视锥体的近端面和远端面,默认是 0.1 和 2000。这里将其设为 1 和 500,让相机与物体产生的视椎体 “更小、更接近”,以节省渲染性能。

添加桌台

在汉诺塔游戏中,场景里主要的 3D 物体包括桌台、柱杆和盘子,我们先来添加最简单的桌台到场景中。

桌台的形状是一个长方体,我们可以使用 BoxGeometry 来实现它,网格材质则使用 MeshLambertMaterial 模拟木质的非光泽表面。

const tableSize = {
  width: 30,  // 长
  depth: 10,  // 宽
  height: 0.5  // 高
};
const geometry = new THREE.BoxGeometry(  // 立方缓冲几何体
  ...['width', 'height', 'depth'].map(key => tableSize[key])
);
const material = new THREE.MeshLambertMaterial({ color: '#cccca6' });  // 材质
const table = new THREE.Mesh(geometry, material);
scene.add(table);  // 添加到场景

为方便调试,我们添加相机轨道控制器 (OrbitControls) 来控制相机的旋转、缩放和平移,从而可以控制场景的视角和观察点。另外,再添加辅助坐标轴和辅助网格线,方便更加直观地查看物体的位置。

import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

/* 相机轨道控制器 */
new OrbitControls(camera, renderer.domElement);

const axesHelper = new THREE.AxesHelper(100);  // 辅助坐标轴
const gridHelper = new THREE.GridHelper(50, 50);  // 辅助网格线
scene.add(axesHelper, gridHelper);

调整相机位置

运行上述代码后,桌台并没有在视图中“显示”出来。这是因为添加到场景中的桌台默认位于三维坐标原点 (0, 0, 0),而相机的默认位置也是三维坐标原点,此时相机位于桌台内部,相机视野范围内无法看到桌台。所以我们需要调整相机的 z 轴坐标位置,例如:

camera.position.z = 20;

这样,桌台以及辅助线就能够显示在视野中了。(桌台为什么是黑色的?后面会讲)

在这里插入图片描述

在场景初始化时,我们希望场景中的所有物体能够整体以合适的大小显示在视野中。那么,多少才算“合适的大小”呢?我们不需要为每个物体单独设置合适的尺寸,因为在透视相机中,场景中的物体遵循“近大远小”的规则。因此,只需调整相机的远近位置就能控制它们整体的视觉大小。

在汉诺塔游戏中,桌台是所有物体中宽度最大的物体,因此我们需要将其大小占满视野的宽度。为了实现这一点,我们需要求出相机在 z 轴上的坐标。

如果我们从桌台正上方,也就是通过场景 y 轴往下看,就会看到桌台的俯视图。一图胜千言,下图中 AC 的长度就是相机在 z 轴上的坐标。

在这里插入图片描述

已知视野角度 fov = 45°,则 ∠ACB = fov / 2,AB = 30 / 2,求 AC 的长度。利用三角函数中正切公式即可求出,代码如下:

const angle = fov / 2;  // 夹角
const rad = THREE.MathUtils.degToRad(angle);  // 转为弧度值
const distanceZ = tableSize.width / 2 / Math.tan(rad);
/**
 * 调整相机的 X 轴位置,让视野能同时看到桌台的顶部和侧面
 * 调整相机的 Z 轴位置,使桌台完整显示到场景 
 */
camera.position.set(0, 15, distanceZ);

在这里插入图片描述

添加光源

前面添加的桌台是一片漆黑,这是因为我们使用了 MeshLambertMaterial 材质,这种材质需要光源才能反射光线,从而显示物体表面。

我们来添加两个光源到场景中:一个环境光,用来照亮场景中的所有物体;一个平行光,模拟太阳光的效果,让物体产生明暗面,增强立体效果。

const ambientLight = new THREE.AmbientLight('#fff', 1);  // 环境光
const directLight = new THREE.DirectionalLight('#fff', 3);  // 平行光
scene.add(ambientLight, directLight);

有了光源后,桌台就能显示出颜色了。

在这里插入图片描述

添加柱杆

生成柱杆

柱杆的形状是一个圆柱体,我们可以用 CylinderGeometry 来实现它,网格材质则使用 MeshPhongMaterial 模拟光泽表面。

const pillarSize = {
  height: 5.4,  // 高度
  radius: 0.2,  // 半径
};
const pillarGeometry = new THREE.CylinderGeometry(
  ...['radius', 'radius', 'height'].map(key => pillarSize[key])
);
const pillarMaterial = new THREE.MeshPhongMaterial({ 
  color: '#e6e6e9',
  emissive: '#889',  // 放射光
});
const pillar = new THREE.Mesh(pillarGeometry, pillarMaterial);
table.add(pillar);  // 添加到 table 物体中

当渲染圆柱体时,可能会在曲面边缘产生毛边。为解决这个问题,我们可以开启渲染器的抗锯齿功能,这样可以让圆柱体的边缘在视觉显示上更加平滑。

/* 渲染器 */
const renderer = new THREE.WebGLRenderer({ 
  alpha: true, 
  antialias: true  // 开启抗锯齿
});

在这里插入图片描述

调整柱杆位置

由于添加到 three.js 场景中的 3D 物体,初始位置位于场景中央,即物体的中心位于三维坐标原点 (0, 0, 0) 处。因此,我们还需要调整柱杆的位置,使其立在桌面上。

具体来说,想让柱杆从桌台的中心位置变成位于桌台顶部,需要将柱杆位置向量的 y 坐标设置为柱杆高度的一半加上桌台高度的一半。参考下图,柱杆从左边的位置变成右边的位置,变动的距离(y 轴坐标)等于 a + b 的值,其中 a 为柱杆高度的一半,b 为桌台高度的一半。

在这里插入图片描述

对应的代码实现:

const y = (pillarSize.height + tableSize.height) / 2;
pillar.position.y = y;

在这里插入图片描述

3 根柱杆的排列

我们需要使用 3 根柱杆来移动盘子,这 3 根柱杆依次等距排开呈一行,那么它们的间距需要多少才合适呢?受 CSS 中 flex 布局的启发,我们可以得到柱杆的 3 种水平间距排列方式:

在这里插入图片描述

我们为其中的柱杆加上盘子的因素。通过观察,可以明显看出中间的 space-around 方案间距最为合理,可以避免盘子的溢出或重叠,是我们想要的排列方式。如下图所示:

在这里插入图片描述

在 space-around 方式的排列中,第一个元素到行首的距离和最后一个元素到行尾的距离等于相邻元素之间距离的一半。如果我们设第一个柱杆到桌台左边缘的间距为 x,则柱杆之间的间距就等于 2x,所以 x + 2x = tableWidth / 2,如此就能算出间距。如下图所示:

在这里插入图片描述

3 根柱杆的 y 和 z 轴坐标相同,我们接下来只需求出它们的 x 轴坐标即可。中间柱杆的 x 轴坐标为 0,根据前面求出的间距值,就可以算出第一根和最后一根柱杆的 x 轴坐标。对应的代码如下:

const pillarB = new THREE.Mesh(pillarGeometry, pillarMaterial);
const y = (pillarSize.height + tableSize.height) / 2;
const unitX = tableSize.width / 2 / 3;  // x + 2x = tableSize.width / 2

pillarB.position.y = y;

const pillarA = pillarB.clone();
pillarA.position.x = -unitX * 2;

const pillarC = pillarA.clone();
pillarC.position.x *= -1;

table.add(pillarA, pillarB, pillarC);

在这里插入图片描述

代码重构

现在我们已经成功添加了桌台和柱杆,但是如果我们回头看一下代码,会发现所有的细节都被平铺在同一层级上,而且,我们还需要添加关于盘子的代码以及复杂的交互逻辑。可以预见,我们的代码将逐步演变成面条式代码,变得难以维护。

为了解决这个问题,我们需要及时对代码进行重构,将不同的细节进行分层管理。这里采用了 MVP 架构,将代码分为三个层级:模型层、视图层和代理层。模型层负责数据的管理,视图层负责展示数据和渲染 UI,代理层则负责协调模型层和视图层之间的交互,同时处理一些业务逻辑。

重构后的代码如下:

import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

const model = {
  tableSize: {
    width: 30,  // 长
    depth: 10,  // 宽
    height: 0.5  // 高
  },
  pillarSize: {
    height: 5.4,
    radius: 0.2
  },

  scene: new THREE.Scene()
};

/* 容器 */
const containerView = {
  init() {
    this.el = document.getElementById('app');
  },
  get size() {
    return this.el.getBoundingClientRect();
  }
};

/* 相机 */
const cameraView = {
  init(width, height) {
    this.fov = 45;
    this.camera = new THREE.PerspectiveCamera(this.fov, width / height, 1, 500);
  },
  fitPosition(layoutWidth) {
    const angle = this.camera.fov / 2;  // 夹角
    const rad = THREE.MathUtils.degToRad(angle);  // 转为弧度值
    const cameraZ = layoutWidth / 2 / Math.tan(rad);
    // 调整相机的 Z 轴位置,使桌台元素完整显示到场景
    this.camera.position.set(0, 15, cameraZ);
  }
};

/* 渲染器 */
const rendererView = {
  init(width, height) {
    this.renderer = new THREE.WebGLRenderer({ 
      alpha: true,
      antialias: true  // 开启抗锯齿
    });
    this.domElement = this.renderer.domElement;
    this.setSize(width, height);
    this.renderer.setClearColor('#f8f8f6', 1);
  },
  appendToDOM(dom) {
    dom.appendChild(this.domElement);
  },
  setSize(width, height) {
    this.renderer.setSize(width, height);
  },
  render(scene, camera) {
    this.renderer.render(scene, camera);
  }
};

/* 轨道控制器 */
const controlsView = {
  init(camera, domElement) {
    const controls = new OrbitControls(camera, domElement);
    return controls;
  }
};

/* 辅助 */
class Helpers {
  constructor() {
    const axesHelper = new THREE.AxesHelper(100);
    const gridHelper = new THREE.GridHelper(50, 50);
    return [axesHelper, gridHelper];
  }
}

/* 灯光 */
class Lights {
  constructor() {
    const ambientLight = new THREE.AmbientLight('#fff', 1);  // 环境光
    const directLight = new THREE.DirectionalLight('#fff', 3);  // 平行光
    return [ambientLight, directLight];
  }
}

/* 桌台 */
class Table {
  constructor({ width, height, depth }) {
    const geometry = new THREE.BoxGeometry(width, height, depth);
    const material = new THREE.MeshLambertMaterial({ color: '#cccca6' });
    return new THREE.Mesh(geometry, material);
  }
}

/* 柱杆 */
class Pillar {
  constructor({ radius, height }) {
    const geometry = new THREE.CylinderGeometry(radius, radius, height);
    const material = new THREE.MeshPhongMaterial({ 
      color: '#e6e6e9',
      emissive: '#889', 
    });
    return new THREE.Mesh(geometry, material);
  }
}

const presenter = {
  init() {
    // 初始化容器
    containerView.init();
    const { width, height } = containerView.size;

    // 初始化相机
    cameraView.init(width, height);
    cameraView.fitPosition(model.tableSize.width);

    // 初始化渲染器
    rendererView.init(width, height);
    rendererView.appendToDOM(containerView.el);

    // 初始化相机轨道控制器
    controlsView.init(cameraView.camera, rendererView.domElement);

    // 添加辅助
    model.scene.add(...new Helpers());

    // 添加灯光
    model.scene.add(...new Lights());

    // 添加桌台元素
    model.scene.add(new Table(model.tableSize));

    // 添加柱杆
    this.addPillars();

    this.animate();
  },

  addPillars() {
    const { width: tableWidth, height: tableHeight } = model.tableSize;
    const { height: pillarHeight } = model.pillarSize;
    const y = (tableHeight + pillarHeight) / 2;
    const unitX = tableWidth / 2 / 3;
    const pillarsMap = new Map([
      ...['A', 'B', 'C'].map(key => [key, new Pillar(model.pillarSize)])
    ]);

    pillarsMap.get('A').position.set(-unitX * 2, y, 0);
    pillarsMap.get('B').position.set(0, y, 0);
    pillarsMap.get('C').position.set(unitX * 2, y, 0);

    const pillars = [...pillarsMap.values()];
    model.scene.add(...pillars);
  },

  /* 渲染循环 */
  animate() {
    requestAnimationFrame(this.animate.bind(this));
    rendererView.render(model.scene, cameraView.camera);
  }
};

presenter.init();

添加柱杆底座

完成代码重构后,代码层级就清晰多了,方便后续拓展功能。

我们计划在柱杆底部增加一个底座,以增强其装饰效果。底座的形状是一个上小下大中间空的喇叭状物体,由于这种形状没有现成的几何体可用,我们决定采用车削缓冲几何体(LatheGeometry)来生成它。

“车削缓冲几何体”这个翻译可能对于不了解机械加工的人来说比较生硬,不容易理解,私以为可以翻译成“旋转塑形几何体”比较直观,能够清晰地展现其原理和应用。其原理是先确定一系列的点,这些点连成一条线(路径),然后绕 y 轴旋转一定角度(默认是旋转360°),旋转过程中路径经过的面就会形成一个几何体,可以用来创建圆环、碗、瓶子等形状。可以将其想象成在旋转的陶轮上做陶胚,手指沿着泥胚的多个位置贴合成一条路径(如下图中的红线),在陶轮旋转后就能得到所需形状的胚体。

在这里插入图片描述

代码实现方面,为 Pillar 类拓展一个底座生成私有方法,利用正弦的特性来生成曲线路径点(这里用了10个路径点),传递给 LatheGeometry 构造函数生成几何体。关键代码如下:

const model = {
  ...
  pillarSize: {
    ...
    baseHeight: 0.18  // 底座高度
  }
};

class Pillar {
  ...  
  #createBase(r, height) {
    const pointNum = 10;
    const unitY = height / (pointNum - 1);
    const points = Array.from({ length: pointNum }).map((v, i) =>
      new THREE.Vector2(
        Math.sin(i * r) * r + r,
        -unitY * i
      )
    );
    const geometry = new THREE.LatheGeometry(points, 32);
    const material = new THREE.MeshLambertMaterial({
      color: '#353546',
      side: THREE.DoubleSide
    });

    return new THREE.Mesh(geometry, material);
  }
}

生成的底座 3D 物体如下图所示:

在这里插入图片描述

我们把它添加到柱杆的底部:

class Pillar {
  constructor({ radius, height, baseHeight }) {
    const geometry = new THREE.CylinderGeometry(radius, radius, height);
    const material = new THREE.MeshPhongMaterial({
      color: '#e6e6e9',
      emissive: '#889',  // 放射光
    });

    const body = new THREE.Mesh(geometry, material);
    const base = this.#createBase(radius, baseHeight);
    base.position.y = -height / 2 + baseHeight;
    body.add(base);

    return body;
  }
  
  #createBase(r, height) {...}
}

在这里插入图片描述

添加盘子

生成盘子

对于带孔的盘子这种形状,我们首先会想到用圆环缓冲几何体(TorusGeometry)来实现:通过调整圆环半径(第1个参数)控制盘子的大小,调整管道半径(第2个参数)控制盘子中心孔的大小。

const geometry = new THREE.TorusGeometry(2, 1.5);
const material = new THREE.MeshLambertMaterial({ color: '#FB6571' });
const torus = new THREE.Mesh(geometry, material);
scene.add(torus);

但是想要较小的中心孔径,就得设置较粗的管道半径,这么一来,制作出来的盘子就会“胖”得像甜甜圈!

16.胖盘子.gif17.甜甜圈.png

好在我们可以压缩这个“甜甜圈”在 z 轴方向的大小(缩放比)让它变薄。

torus.scale.z = 0.1;

在这里插入图片描述

不过效果还是差强人意,它并不像汉诺塔中那种很“润”的盘子。

我们的另一个方案是把盘子按显示的面分成4个部分,再分别生成这4个部分的形状,最后组合成盘子。

在这里插入图片描述

在这里插入图片描述

这种方式效果看着还行,不过需要处理 4 个形状的生成及组合,代码稍显复杂。最终我们采用了之前做柱杆底座时的车削几何体(旋转塑形几何体)来生成盘子形状。还记得用法吗?

下图是最终生成的盘子的横截面,绿线是 y 轴,蓝线是 x 轴。

在这里插入图片描述

根据旋转塑形几何体的特点,我们只需定义出 x 轴方向的形状路径(横截面),它就能绕 y 轴旋转生成完整的形状。其中 x 轴方向的形状路径如下图所示:

在这里插入图片描述

由于图形沿 x 轴的上下两部分是对称的,所以实际上我们只需关注上半部分的路径形状,即先定义上半部分的 5 个路径点(下图中的红点),再翻转生成下半部分的路径点。

在这里插入图片描述

盘子的代码如下:

class Plate {
  constructor(size, color) {
    this.size = size;

    const geometry = this.#createGeometry();
    const material = new THREE.MeshLambertMaterial({
      color,
      side: THREE.DoubleSide
    });

    return new THREE.Mesh(geometry, material);
  }
  
  #createGeometry() {
    const { radius, height, poreRadius } = this.size;
    const sideRadius = radius - poreRadius;
    const topPoints = [  // 上半部分的5个路径点(二维)
      new THREE.Vector2(poreRadius, 0),
      new THREE.Vector2(poreRadius, height / 2),
      new THREE.Vector2(sideRadius - 0.08, height / 2),
      new THREE.Vector2(sideRadius, height / 4),
      new THREE.Vector2(sideRadius, 0)
    ];
    // 翻转生成下半部分的路径点
    const bottomPoints = topPoints.map(vector =>
      vector.clone().setY(vector.y * -1)
    ).reverse();
    const points = [...topPoints, ...bottomPoints];

    return new THREE.LatheGeometry(points, 64);
  }
}

在这里插入图片描述

多个盘子的堆叠显示

在汉诺塔游戏中,有多个大小不同的盘子,游戏开始前,这些盘子按照从大到小的顺序从下往上依次堆叠,形成一个塔状结构。

我们先在模型层完善盘子的配置数据,为多个盘子准备不同的颜色:

const model = {
  ...
  plate: {
    nums: 5,  // 盘子数量
    height: 0.5,
    colors: [
      '#c186e0', '#997feb', '#59b1ff', '#36cfc9',
      '#bae637', '#e7d558', '#ff9c6e', '#ff6b6b'
    ]
  }
}

然后在代理层添加 addPlates 方法用来生成并添加多个盘子:

const presenter = {
  init() {
    ...
    this.addPlates();  // 添加盘子
  }
  addPlates() {
    
  }
	...
};

下面来编写 addPlates 这个方法。

汉诺塔中的盘子是按一定的大小规律堆叠在一起的,多个盘子的大小可以使用等比数列来控制。我们只需设定最大的一个盘子的尺寸,就能利用等比数列通项公式 a(n) = a1 × r^(n-1) 算出其他盘子的尺寸来。同时,盘子是沿 y 轴堆叠在一起的,只需算出第一个盘子的坐标,就能根据盘子所在堆叠层数算出其 y 轴坐标来。addPlates 代码实现如下:

const { height: plateHeight, colors, nums } = model.plate;
const { height: tableHeight, depth: tableDepth } = model.tableSize;
const maxPlateRadius = tableDepth / 2.5;
const platePoreRadius = model.pillarSize.radius + 0.04;  // 孔径(比支柱大一点)
const group = new THREE.Group();

Array.from({ length: nums }).forEach((v, i) => {
  // 使用等比数列从大到小创建不同半径的圆盘,0.87 为公比
  // an = a1 × r^(n-1)
  const r = maxPlateRadius - i * 0.87 ** (nums - 1);
  const plate = new Plate({ 
    radius: r, 
    height: plateHeight, 
    poreRadius: platePoreRadius
  }, colors[i]);

  plate.position.y = tableHeight + plateHeight * i;  // 第i个盘子的位置
  group.add(plate);
});

model.scene.add(group);

在这里插入图片描述

我们再将堆叠盘子 group 对象的位置设为左侧柱杆的位置,让盘子串在左侧柱杆上。

const unitX = model.tableSize.width / 2 / 3;
group.position.x = -unitX * 2;

在这里插入图片描述

添加标签文本

为了更加清晰地指代不同的柱杆和盘子,我们计划为它们添加文本标签。three.js 中实现文字有多种方案可供选择(传送门),这里我们选择文字几何体(TextGeometry)方案。

加载字体文件

文字几何体 TextGeometry 是一个 three.js 的附加组件,需要显式导入。此外,为了使其正常工作,还需要载入专用的字体文件( typeface.json)。

import { TextGeometry } from 'three/addons/geometries/TextGeometry.js';
import { FontLoader } from 'three/addons/loaders/FontLoader.js';

FontLoader 用来异步加载字体,这里加载 three.js 自带的字体。在模型层定义字体加载方法:

const model = {
  ...
  font: null,
  loadFont: () => {
    const url = 'https://cdn.jsdelivr.net/npm/three@0.160.0/examples/fonts/'
    const fontName = 'helvetiker_regular.typeface.json';

    return new Promise((resolve, reject) => {
      if (model.font) {
        return resolve();
      }

      new FontLoader().load(url + fontName,
        (font) => {
          // 字体加载成功,font 是一个表示字体的 Shape 类型的数组
          model.font = font;
          resolve();
        },
        null,
        (err) => reject(err)
      );
    });
  }
};

由于字体是异步加载的,文本标签必须在字体加载之后才能创建,因此我们需要调整代码逻辑,等待字体加载成功后再往场景中添加柱杆和盘子。

const presenter = {
	init() {
    ...
    model.loadFont().then(() => {
      this.addPillars();  // 添加柱杆
      this.addPlates();  // 添加盘子
    });
  }
  ...
};

创建文本类

定义一个 Text 类,使用 TextGeometry 几何体和 MeshBasicMaterial 材质生成 3D 文本。

class Text {
  constructor(font, text, { size, color }) {
    const geometry = new TextGeometry(String(text), {
      font,
      size,
      height: 0.02
    });
    geometry.center();  // 文本居中
    const material = new THREE.MeshBasicMaterial({ color });

    return new THREE.Mesh(geometry, material);
  }
}

在代理层新加一个 createText 方法,供视图层调用来创建 3D 文本。

const presenter = {
  ...
  createText(text, options) {
    return new Text(model.font, text, options);
  }
};

添加柱杆标签

柱杆类新加一个参数 key,用来接收柱杆标签名称(A/B/C),内部定义方法生成标签文本并将其放置在柱杆顶部:

class Pillar {
  constructor({ radius, height, baseHeight }, key) {
    this.size = { radius, height, baseHeight };
    ...        
    const text = this.#createLabel(key);		
    body.add(text);

    return body;
  }

  #createLabel(str) {
    const { radius, height } = this.size;
    const fontSize = radius * 2;
    const text = presenter.createText(str, {
      size: fontSize,
      color: '#202020'
    });

    text.position.y = height / 2 + fontSize;  // 位于柱杆顶部
    return text;
  }
  ...
}

添加盘子标签

与柱杆一样,我们给盘子类传入序号作为其标签内容,在内部定义方法生成标签文本,“贴”在盘子外围:

class Plate {
  constructor(size, color, i) {
    this.size = size;
    ...
    const text = this.#createLabel(i);
    body.add(text);

    return body;
  }  

  #createLabel(str) {
    const { radius, height, poreRadius } = this.size;
    const text = presenter.createText(str, {
      size: height / 1.6,
      color: '#fff'
    });

    text.position.z = radius - poreRadius;  // 标签位置
    return text;
  }
  ...
}

柱杆和盘子的标签添加完毕后的效果如下图所示:

在这里插入图片描述

至此,这个案例中的所有 3D 物体已全部搭建完毕。


下期预告:

  1. 细节优化,让场景中的 3D 物体更加真实;
  2. 为场景物体添加交互,让盘子动起来;
  3. 添加开始、重玩、结束等流程控制,完善游戏流程。

下期内容写作中,敬请关注。

关于 OpenTiny

在这里插入图片描述

OpenTiny 是一套企业级 Web 前端开发解决方案,提供跨端、跨框架、跨版本的 TinyVue 组件库,包含基于 Angular+TypeScript 的 TinyNG 组件库,拥有灵活扩展的低代码引擎 TinyEngine,具备主题配置系统TinyTheme / 中后台模板 TinyPro/ TinyCLI 命令行等丰富的效率提升工具,可帮助开发者高效开发 Web 应用。


欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~更多视频内容也可关注B站、抖音、小红书、视频号

OpenTiny 也在持续招募贡献者,欢迎一起共建

OpenTiny 官网:https://opentiny.design/
OpenTiny 代码仓库:https://github.com/opentiny/
TinyVue 源码:https://github.com/opentiny/tiny-vue
TinyEngine 源码: https://github.com/opentiny/tiny-engine

欢迎进入代码仓库 Star🌟TinyEngine、TinyVue、TinyNG、TinyCLI~

如果你也想要共建,可以进入代码仓库,找到 good first issue标签,一起参与开源贡献~

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1543189.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

unity 学习笔记 4.坐标系

下载源码 UnityPackage 目录 1.基础知识 1.1.世界坐标和局部坐标 1.2.屏幕坐标 2.坐标系转换 3.练习&#xff1a;判断鼠标单击的位置 1.基础知识 1.1.世界坐标和局部坐标 1.2.屏幕坐标 2.坐标系转换 3.练习&#xff1a;判断鼠标单击的位置 步骤&#xff1a; 将脚本挂载到小…

MP4短视频怎么提取gif?一招让你视频变gif

日常生活中看到各种各样有趣的gif表情包就会收藏到自己图片库里。但是我们想要自己制作这种有趣的gif动图时要怎么办呢&#xff1f;怎么通过MP4视频来制作gif动画呢&#xff1f;通过使用gif图片制作&#xff08;https://www.gif.cn/&#xff09;工具-GIF中文网&#xff0c;能够…

查看Linux系统重启的四种基本命令

目录 前言1. last2. uptime3. journalctl4. dmesg 前言 对于排查其原因推荐阅读&#xff1a;详细分析服务器自动重启原因&#xff08;涉及Linux、Window&#xff09; 在Linux中&#xff0c;有多种命令可以查看系统重启的信息 以下是其中一些常用的命令及其解释&#xff1a; …

Windows系统安装PyTorch框架支持AMD Radeon显卡/Intel显卡

前言 PyTorch框架作为一种主流的、对新手友好的深度学习框架&#xff0c;应用的范围越来越广泛&#xff0c;但是作为一种深度学习框架&#xff0c;使用显卡进行加速训练是一种常见的需求&#xff0c;而PyTorch框架官方支持对NVIDIA卡支持更加友好&#xff0c;这一点从官方的安…

【计算机组成】计算机组成与结构(四)

上一篇&#xff1a;【计算机组成】计算机组成与结构&#xff08;三&#xff09; &#xff08;7&#xff09;存储系统 计算机采用分级存储体系的主要目的是为了解决存储容量、成本和速度之间的矛盾问题。 两级存储:cache-主存、主存-辅存(虚拟存储体系) 局部性原理 ◆ 局部性…

AI程序员的诞生,对传统程序员的影响和堆技术产生的影响

一、全球首位AI程序员诞生&#xff0c;将会对程序员的影响有多大&#xff1f; AI程序员并不会抢走传统程序员的饭碗&#xff0c;而是为他们提供更多的工具和技术支持。实际上&#xff0c;AI技术在软件开发领域的应用可以帮助程序员更高效地进行开发、测试和维护工作&#xff0…

【python】flask执行上下文context,请求上下文和应用上下文原理解析

✨✨ 欢迎大家来到景天科技苑✨✨ &#x1f388;&#x1f388; 养成好习惯&#xff0c;先赞后看哦~&#x1f388;&#x1f388; &#x1f3c6; 作者简介&#xff1a;景天科技苑 &#x1f3c6;《头衔》&#xff1a;大厂架构师&#xff0c;华为云开发者社区专家博主&#xff0c;…

挺后悔,我敷衍地回答了“程序员如何提升抽象思维“

分享是最有效的学习方式。 博客&#xff1a;https://blog.ktdaddy.com/ 大家好&#xff0c;我是老猫。 大概在月初的时候&#xff0c;我发了一篇文章【当程序员之后&#xff1f;(真心话)】,在这篇文章中&#xff0c;提及了抽象思维对一名程序员的重要性。可能说得也比较笼统&a…

ZYNQ学习之PetaLinux开发环境搭建

基本都是摘抄正点原子的文章&#xff1a;<领航者 ZYNQ 之嵌入式Linux 开发指南 V3.2.pdf&#xff0c;因初次学习&#xff0c;仅作学习摘录之用&#xff0c;有不懂之处后续会继续更新~ FTP&#xff1a;File Transfer Protocol 一、Ubuntu 和 Windows 文件互传 1.1、开启 Ubu…

一篇复现Docker镜像操作与容器操作

华子目录 Docker镜像操作创建镜像方式1docker commit示例 方式2docker import示例1&#xff1a;从本地文件系统导入示例2&#xff1a;从远程URL导入注意事项 方式3docker build示例1&#xff1a;构建镜像并指定名称和标签示例2&#xff1a;使用自定义的 Dockerfile 路径构建镜像…

文献学习-22-Surgical-VQLA:具有门控视觉语言嵌入的转换器,用于机器人手术中的视觉问题本地化回答

Authors: Long Bai1† , Mobarakol Islam2† , Lalithkumar Seenivasan3 and Hongliang Ren1,3,4∗ , Senior Member, IEEE Source: 2023 IEEE International Conference on Robotics and Automation (ICRA 2023) May 29 - June 2, 2023. London, UK Abstract: 尽管有计算机辅…

【大模型】VS Code(Visual Studio Code)上安装的扩展插件用不了,设置VS Code工作区信任

文章目录 一、找到【管理工作区信任】二、页面显示处于限制模式&#xff0c;改为【信任】三、测试四、总结 【运行环境】win 11 相关文章&#xff1a; 【大模型】直接在VS Code(Visual Studio Code)上安装CodeGeeX插件的过程 【问题】之前在 VS Code上安装 CodeGeeX 插件后&…

52、Qt/窗口、常用类、ui相关学习20240321

一、使用Qt 自由发挥登录窗口的应用场景&#xff0c;实现一个登录窗口界面。 要求&#xff1a; 1. 需要使用Ui界面文件进行界面设计 2. ui界面上的组件相关设置&#xff0c;通过代码实现 3. 需要添加适当的动图。 代码&#xff1a; #include "widget.h" #incl…

quartz整合前端vue加后端springboot

因工作需求&#xff0c;需要能修改定时的任务&#xff0c;前端vue3&#xff0c;后端是springboot 看看页面效果&#xff1a; 首先maven加上引入 <dependency><groupId>org.quartz-scheduler</groupId><artifactId>quartz</artifactId><versi…

BufferedInputStream解读

咦咦咦&#xff0c;各位小可爱&#xff0c;我是你们的好伙伴——bug菌&#xff0c;今天又来给大家普及Java之IO流啦&#xff0c;别躲起来啊&#xff0c;听我讲干货还不快点赞&#xff0c;赞多了我就有动力讲得更嗨啦&#xff01;所以呀&#xff0c;养成先点赞后阅读的好习惯&am…

hash冲突四种解决办法,hash冲突除了拉链法还有什么?

1. 看hashmap 源码&#xff0c;有个问题&#xff0c;key 存放是 先hash 再与hash值的高16位值 进行异或运算。再与槽位size() 求模取余。如果多个不同的key 得出de数组位置相同。则采用链表依次存储。 2. 那么除了拉链法还有什么其他解决hash冲突的方法呢&#xff1f; a. 建立…

python程序打包

目录 1. 命令2. 安装2.1 PyInstaller2.2 cx_Freeze(笔者未用过) 3. 打包示例3.1 在 pycharm 中执行3.2 若使用打包命令时报错3.3 路径问题 python打包成可执行文件&#xff0c;用于在没有Python环境的地方运行该程序&#xff0c;与qt打包类似。&#xff08;笔者写的qt打包地址&…

Beans模块之工厂模块BeanNameAware

博主介绍&#xff1a;✌全网粉丝5W&#xff0c;全栈开发工程师&#xff0c;从事多年软件开发&#xff0c;在大厂呆过。持有软件中级、六级等证书。可提供微服务项目搭建与毕业项目实战&#xff0c;博主也曾写过优秀论文&#xff0c;查重率极低&#xff0c;在这方面有丰富的经验…

无人驾驶中的坐标转换

无人驾驶中的坐标转换 无人车上拥有各种各样的传感器&#xff0c;每个传感器的安装位置和角度又不尽相同。对于传感器的提供商&#xff0c;开始并不知道传感器会以什么角度&#xff0c;安装在什么位置&#xff0c;因此只能根据传感器自身建立坐标系。无人驾驶系统是一个多传感器…

Jenkins的快速入门

文章目录 一、Jenkins是什么&#xff1f;二、Jenkins安装和持续集成环境配置1.持续集成流程说明2.Gitlab代码托管服务器安装Gitlab简介&#xff1a;Gitlab安装Gitlab的使用切换中文添加组创建用户将用户添加到组创建项目idea中代码上传Gitlab 3.Jenkins持续集成环境服务器安装J…