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


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


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

    效果展示

    在这里插入图片描述

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

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

    本文知识点

    • 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> 
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    对于 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>
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    接下来,创建一个场景(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>
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25

    上面 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);  // 添加到场景
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    为方便调试,我们添加相机轨道控制器 (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);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    调整相机位置

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

    camera.position.z = 20;
    
    • 1

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

    在这里插入图片描述

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

    在汉诺塔游戏中,桌台是所有物体中宽度最大的物体,因此我们需要将其大小占满视野的宽度。为了实现这一点,我们需要求出相机在 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);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    在这里插入图片描述

    添加光源

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

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

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

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

    在这里插入图片描述

    添加柱杆

    生成柱杆

    柱杆的形状是一个圆柱体,我们可以用 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 物体中
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

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

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

    在这里插入图片描述

    调整柱杆位置

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

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

    在这里插入图片描述

    对应的代码实现:

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

    在这里插入图片描述

    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);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    在这里插入图片描述

    代码重构

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

    为了解决这个问题,我们需要及时对代码进行重构,将不同的细节进行分层管理。这里采用了 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();
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106
    • 107
    • 108
    • 109
    • 110
    • 111
    • 112
    • 113
    • 114
    • 115
    • 116
    • 117
    • 118
    • 119
    • 120
    • 121
    • 122
    • 123
    • 124
    • 125
    • 126
    • 127
    • 128
    • 129
    • 130
    • 131
    • 132
    • 133
    • 134
    • 135
    • 136
    • 137
    • 138
    • 139
    • 140
    • 141
    • 142
    • 143
    • 144
    • 145
    • 146
    • 147
    • 148
    • 149
    • 150
    • 151
    • 152
    • 153
    • 154
    • 155
    • 156
    • 157
    • 158
    • 159
    • 160
    • 161
    • 162
    • 163
    • 164
    • 165
    • 166
    • 167
    • 168

    添加柱杆底座

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

    我们计划在柱杆底部增加一个底座,以增强其装饰效果。底座的形状是一个上小下大中间空的喇叭状物体,由于这种形状没有现成的几何体可用,我们决定采用车削缓冲几何体(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);
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28

    生成的底座 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) {...}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    在这里插入图片描述

    添加盘子

    生成盘子

    对于带孔的盘子这种形状,我们首先会想到用圆环缓冲几何体(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);
    
    • 1
    • 2
    • 3
    • 4

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

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

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

    torus.scale.z = 0.1;
    
    • 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);
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32

    在这里插入图片描述

    多个盘子的堆叠显示

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

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

    const model = {
      ...
      plate: {
        nums: 5,  // 盘子数量
        height: 0.5,
        colors: [
          '#c186e0', '#997feb', '#59b1ff', '#36cfc9',
          '#bae637', '#e7d558', '#ff9c6e', '#ff6b6b'
        ]
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

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

    const presenter = {
      init() {
        ...
        this.addPlates();  // 添加盘子
      }
      addPlates() {
        
      }
    	...
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    下面来编写 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);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21

    在这里插入图片描述

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

    const unitX = model.tableSize.width / 2 / 3;
    group.position.x = -unitX * 2;
    
    • 1
    • 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';
    
    • 1
    • 2

    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)
          );
        });
      }
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24

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

    const presenter = {
    	init() {
        ...
        model.loadFont().then(() => {
          this.addPillars();  // 添加柱杆
          this.addPlates();  // 添加盘子
        });
      }
      ...
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    创建文本类

    定义一个 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);
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

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

    const presenter = {
      ...
      createText(text, options) {
        return new Text(model.font, text, options);
      }
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    添加柱杆标签

    柱杆类新加一个参数 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;
      }
      ...
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23

    添加盘子标签

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

    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;
      }
      ...
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

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

    在这里插入图片描述

    至此,这个案例中的所有 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🌟TinyEngineTinyVueTinyNGTinyCLI~

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

  • 相关阅读:
    2022年5月17日刷题
    「区块链+数字身份」:DID 身份认证的新战场
    《中国棒球》:推进备战·开启新篇章
    Python可变参数*args和**kwargs
    C++小程序——“靠谱”的预测器
    344. 反转字符串
    特征衍生工程
    抖店无货源如何上架商品?抖店上货教程标题
    Spring Boot入门(23):【实战】通过AOP拦截Spring Boot日志并将其存入数据库
    75、SpringBoot 整合 MyBatis------使用 Mapper 作为 Dao 组件
  • 原文地址:https://blog.csdn.net/OpenTiny/article/details/137018137