一篇文章了解 threejs 在 vue 项目中的基本使用

Three.js 是一个跨浏览器的脚本,使用 JavaScript 函数库或 API 来在网页浏览器中创建和展示动画的三维计算机图形。为啥突然想写这么一篇文章的主要原因其实是前几天有个人需要我帮忙写一个简单的 demo,花了几个小时之后觉得基本上 threejs 基本的使用效果都实现了,之前就看过 threejs 的东西,但是一直没有时间静下心来整理汇总一下,所以说呢,今天时间比较充足,就稍微的记录一下。当然了,我也没有深入的学习使用,学习的时间很短,所以说也谈不上经验的分享,就算是一个简单的学习记录吧,浅看则以,切勿尽信。

threejs 相关资料

其实相对来说 threejs 的学习成本比较高的,需要掌握的知识相对来说会稍微杂一些,但是简单的入门倒是很简单,现在网上的资料还是很多的,无论是博客还是视频都是比较充足的,然后接下来的博文内容,就简单的介绍一些在 vue2 项目中 threejs 的基本使用。

threejs 介绍

threejs 是运行在浏览器中的 3D 引擎,是JavaScript编写的WebGL第三方库。提供了非常多的3D显示功能。开发者可以用它创建各种三维场景,包括了摄影机、光影、材质等各种对象。可以在它的主页上看到许多精彩的演示。不过,这款引擎还处在比较不成熟的开发阶段,其不够丰富的 API 以及匮乏的文档增加了初学者的学习难度(尤其是文档的匮乏)。

前言

在讲解 threejs 的时候,我们通过一个基本的简单的案例,来实现一个小的效果,然后把常用的 API、工具、功能稍微说一下哈!

这个案例我是使用 vue2 + 脚手架工具创建的项目,采用 javascript 开发。再次之前需要先准备一个 vue 的空项目,好在我们不需要使用网络请求,直接默认创建一个 vue2 的项目即可,不需要过多的配置。

安装 threejs

安装 threejs 的方式也很简单,直接使用 npm 工具就可以安装到项目里面使用:

npm install --save three

在终端输入命令然后回车等待执行完成就可以了!

在这里插入图片描述

安装完成之后,就可以看到 package.json 文件中已经包含了我们刚刚安装的 three 依赖。

在这里插入图片描述
同时,在 node_modules 文件夹下,也出现了 three 相关的包依赖。

在这里插入图片描述

这样,我们就成功将 threejs 相关的依赖添加到我们的项目,就可以继续进行后续 threejs 相关功能的开发了。

初始化项目

这个步骤就不多说了,直接使用 cli3 以上的版本创建一个 vue2 的项目,然年修改一下组件内容,创建一个 div 标签铺满整个浏览器页面就可以了。

<template>
  <div class="three-canvas" ref="threeTarget"></div>
</template>

<script>
export default {
  name: 'HelloWorld',
}
</script>

<style scoped>
  .three-canvas {
    width: 100%;
    height: 100%;
    overflow: hidden;
    background-color: #d6eaff;
  }
</style>

大体效果就是下面的样子,当然了这个无所谓了。

在这里插入图片描述

然后我们在这个组件中实现 threejs 效果,效果呢,挂载到我们创建的 <div class="three-canvas" ref="threeTarget"></div> 标签上面渲染。

为了保证项目代码稍微的有点规范性,我们创建一个 TEngine.js 文件,在当前组件引入,然后呢,所有与 threejs 初始化、操作等代码都是 TEngine.js 文件中实现。

在这里插入图片描述

创建渲染器 WebGLRenderer

接下来我们在 TEngine.js 文件中初始化一个 threejs ,首先第一步,我们需要有一个 dom 挂载我们创建的 threejs ,啥叫挂载呢,简单点说就是我创建的 3D 模型显示在哪里,我们之前初始化项目不是创建一个全屏的 div 吗?然后我们就把 3D 模型放在这个 div 上面显示。

第一步,我们现在 TEngine.js 中创建并交出一个 ThreeEngine 类,然后这个类,在组件中实例化就可以了,前面说了,需要一个 dom 节点挂载模型,那么我们首先得接收一个 dom 节点吧?所以说在构造器函数里面获取到传入的 dom 节点,然后挂载。

export class ThreeEngine {
  dom = null; // 挂载的 DOM
  // 构造器函数
  constructor(dom) {
    this.dom = dom
  }
}

然后我们就可以在组件中实例化这个类了。注意,需要在 mounted 生命周期钩子中实例化吧?不能在 created 生命周期钩子中,为啥,因为 mounted 才是 dom 都渲染完成吧,好:

<script>
  import { ThreeEngine } from './js/TEngine'
  export default {
    name: 'HelloWorld',
    data() {
      return {
        ThreeEngine: null,
      };
    },
    mounted() {
      this.ThreeEngine = new ThreeEngine(this.$refs.threeTarget)
    }
  }
</script>

OK,这样子第一步就完成了,但是呢页面没效果,因为我们刚刚开始,完全没有任何的 threejs 的操作。

接下来,就是 threejs 相关的操作了哈,都在 ThreeEngine 类的构造器函数中实现。

【引导】首先你想,我们想在一个 div 上面展示 3D 模型的东西,是不是首先得有一个东西把这个 3D 模型转换成我们浏览器可以展示的画面放在我们传递进来 div 上展示啊,这个帮助我们把 模型 展示到 div 上的东西就可以简单的理解成渲染器。举一个例子:老师说我们准备换一个新教室,老师想看一下新教室的布局,但是自己有事过不去,怎么办?找个同学小明帮忙过去看一下就可以了吧,怎么让老师亲眼看到?对,视频通话,找个小明拿手机拍摄,然后老师在手机上就可以看到这个新教室的布局了吧,那这个小明就是渲染器,我们就是老师,要看三维的教室。所以第一步,找一个小明。

好的,老师的渲染器是小明,而 threejs 的渲染器就是 WebGLRenderer。WebGLRenderer是 three 中提供的一个工具类,我们在使用之前需要先引入他,使用也很简单。

import { WebGLRenderer } from 'three'

首先创建一个渲染器:

let renderer = new WebGLRenderer()  // 创建渲染器

创建完成之后,我们需要把这个渲染器挂载到 dom 上面,这样,渲染器渲染的效果就可以展示在 div 上面,就是学生和老师打视频电话,才可以让老师在自己的手机看到新教室布局,可以理解成这个dom就是老师的手机屏幕哈。

dom.appendChild(renderer.domElement)  // 将渲染器挂载到dom

问题来了,我们告诉渲染器说:你把 threejs 的效果展示在 div 上面。可以渲染器有点蒙蔽还,就是我要渲染多大啊?这个 div 有高宽,我是渲染在这个 div 的那个部分呢?所以说还需要设置一下渲染器的大小吧?我们一般设置的和 dom 节点一样大小就可以。

renderer.setSize(dom.offsetWidth, dom.offsetHeight, true)

这样我们的渲染器初始化的全部代码就完成了!

import { WebGLRenderer } from 'three'

export class ThreeEngine {

  dom = null; // 挂载的 DOM
  
  constructor(dom) {

    // 创建渲染器
    let renderer = new WebGLRenderer({
      antialias: true,  // 开启抗锯齿
    })
    dom.appendChild(renderer.domElement)  // 将渲染器挂载到dom
    renderer.setSize(dom.offsetWidth, dom.offsetHeight, true)

    this.dom = dom
  }

}

我们看一下页面效果。

在这里插入图片描述

非常好,和没有初始化之前一模一样,为啥。

【引导】上面步骤,小明喊来了,老师的手机也有了。但是小明他不知道干啥啊?我们想让他去看新教室对吧?但是我们只是找到了小明,交代给小明说你去给我看,但是并没有告诉小明去看啥,这里让小明看的东西叫做场景,我们需要告诉小明看什么场景才可以。所以说下一步,找一个场景。

创建场景 Scene

threejs 中的场景是 Scene,同样这个也是 threejs 提供的工具类,使用的话也需要引入,创建一样简单。

import { WebGLRenderer, Scene } from 'three'

创建场景直接 new 就可以。

let scene = new Scene()  // 实例化场景
this.scene = scene

就这两行代码初始完场景了,然后到此为止,所有的代码就是下面这样的。

import { WebGLRenderer, Scene } from 'three'
export class ThreeEngine {
  dom = null; // 挂载的 DOM
  scene = null; // 场景
  constructor(dom) {
    // 创建渲染器
    let renderer = new WebGLRenderer({
      antialias: true,  // 开启抗锯齿
    })
    dom.appendChild(renderer.domElement)  // 将渲染器挂载到dom
    renderer.setSize(dom.offsetWidth, dom.offsetHeight, true)
    let scene = new Scene()  // 实例化场景
    this.dom = dom
    this.scene = scene
  }
}

我们看一下效果:

在这里插入图片描述

我勒个去!还是怎么东西没有,我之前一模一样。这又是为啥!

【引导】还是老师告诉小明小明看新教室,渲染器小明有了,场景也小明也了。但是小明到了之后懵了,为啥懵了,小明到了新教室,他不知道怎么给老师看新教室,我们想法是啥,小明拿手机打视频给老师看,但是小明不知道啊!我们得给小明一个有摄像头的手机才可以。继续分析,小明有相机了,但是小明比较笨,他不知道从那个角度拍给老师看(尽管小明笨,但不许换掉小明),所以说我们还得告诉小明拍摄的位置,也就是说从哪个角度拍摄吧。

创建相机并设置位置 PerspectiveCamera

threejs 中的相机是 PerspectiveCamera,他同样是 three 提供的工具类,我们需要引入,然后在实例化。

import { WebGLRenderer, Scene, PerspectiveCamera } from 'three'

怎么创建相机有几个步骤,首先实例化一个相机;然后需要设置相机的位置,就是从哪里拍;再然后设置相机拍摄的位置,就是拍具体哪里;最后可以设置相机角度,就是歪着拍还是竖着拍;

首先是初始化相机

// 实例化相机
let camera = new PerspectiveCamera(45, dom.offsetWidth / dom.offsetHeight, 1, 1000)  

这里传了几个参数,分别是啥意思稍微说一下。

  • 第一个参数 45 是 摄像机视锥体垂直视野角度,人眼看东西就差不多60度左右嘛,不可能看到头后面的东西,这里也是这个意思,一般就设置 45。
  • 第二个参数 dom.offsetWidth / dom.offsetHeight 是摄像机视锥体长宽比,我们就设置是我们 div 容器的长宽比就可以,如果不这样设置,可能会变形。因为我们看到的要和相机看到的一样大小,不然会被拉伸。
  • 第三个参数 1 是摄像机视锥体近端面
  • 第四个参数 1000 摄像机视锥体远端面

然后是设置相机位置,就是相机都放在哪里。

camera.position.set(50, 50, 50) // 设置相机位置

我们把相机放在 three 坐标 50 50 50 的位置。

然后是设置相机看向哪里,这里我们让相机看向原点。

camera.lookAt(new Vector3(0, 0, 0))  // 设置相机看先中心点

我们还可以设置相机自身的方向。

camera.up = new Vector3(0, 1, 0)  // 设置相机自身的方向

这里我们稍微补充一点知识点,因为没有图形学基础的话可能不好理解,首先说一点,threejs 坐标系是向右为 x 轴正方向,垂直屏幕向外为 z 轴的正方向,向上为 y 轴正方向。

在这里插入图片描述

所以说设置相机的位置和看向原点就理解了哈,然后渲染器默认加载完成后他的中心就是(0,0,0)原点,分别对应 (x,y,z)。
camera.up 是用来设置相机自身的方向设置 y = 1 表示 y 轴的正方向为相机向上的方向,可能没说明白,就是相机向上移动就是向 three 坐标系 y 轴的正方向移动。

到这里,我们初始化相机的部分就完成了,然后我们到此位置所有代码:

import { WebGLRenderer, Scene, PerspectiveCamera, Vector3 } from 'three'
export class ThreeEngine {
  dom = null; // 挂载的 DOM
  scene = null; // 场景
  constructor(dom) {
    // 创建渲染器
    let renderer = new WebGLRenderer({
      antialias: true,  // 开启抗锯齿
    })
    dom.appendChild(renderer.domElement)  // 将渲染器挂载到dom
    renderer.setSize(dom.offsetWidth, dom.offsetHeight, true)
    let scene = new Scene()  // 实例化场景
	// 实例化相机
    let camera = new PerspectiveCamera(45, dom.offsetWidth / dom.offsetHeight, 1, 1000)  	   
    camera.position.set(50, 50, 50) // 设置相机位置
    camera.lookAt(new Vector3(0, 0, 0))  // 设置相机看先中心点
    camera.up = new Vector3(0, 1, 0)  // 设置相机自身方向
    this.dom = dom
    this.scene = scene
  }
}

然后我们保存代码,看一下页面效果。

在这里插入图片描述

非常好,还是那个样子,啥都没有。

为啥呢?再来引导一波!

【引导】我们初始化了渲染器,找到小明了;初始化了场景,让小明去了新教室;相机准备好了,小明掏出手机对准了目标。但是没有视频啊!老师啥也看不到。所以我们接下来需要把这个相机和场景绑定到渲染器里面。

绑定很简单,只需要在初始化相机之后呢,把场景和相机绑进渲染器,让渲染器渲染就可以了:

renderer.render(scene, camera)  // 渲染器渲染场景和相机

OK,现在在看一下效果。

在这里插入图片描述
全部变黑了是吧?这就是成功了,为啥是黑的呢,因为现在这个场景没有东西,如果有东西的话就可以展示出来了吧。

添加模型 Mesh

现在我们创建一个立方体放进场景里面去,我们就可以看到一个模型了吧?好的,现在开始!

为了保证我们项目代码的结构,我们创建一个 TBaseObject.js 文件,用来存放基础的模型,然后这个文件中我们创建一个立方体模型,并返回出来。

我们就简单点,先声明一个数组抛出,然后数组里面是创建的模型,这样外面使用这个文件的时候,导入就可以获取模型的列表了。

export const allBaseObject = []  // 返回所有基础模型

然后创建一个立方体模型,当然也可以抛出去,也可以往数组里面添加一下,这样的话我们既可以单独使用这个立方体,也可以获取全部模型。

创建一个简单的立方体很简单,Mesh 是 three 提供的基于以三角形为polygon mesh(多边形网格)的物体的类,我们可以通过他创建一个立方体。

// 创建立方体
export const box = new Mesh(
  new BoxGeometry(20, 20, 20),  // 设置立方体的大小 (x 长度, y 高度 ,z 长度)
  new MeshStandardMaterial({   // 设置材质
    color: 'rgb(36, 172, 242)',  // 设置材质的颜色
  })
)

allBaseObject.push(stage)  // 添加到模型数组

小地方说一下哈,设置模型大小肯定需要的,这个模型多宽、多高、多长。那材质是啥意思,就是我们这个立方体的样式,比如颜色,光泽等属性,当然如果是实际模型可能还有贴图之类的。简单理解就是什么样子的。

当然,中间使用的类也需要引入一下。

import { BoxGeometry, Mesh, MeshStandardMaterial } from "three"

好,创建完成做一个事情,就是我们需要在 three 中把这个立方体添加进三维场景中,我们在 TEngine.js 文件中创建一个方法,用来向场景中添加模型。

  /**
   * 向场景中添加模型
   * @param  {...any} object 模型列表
   */
  addObject(...object) {
    object.forEach(elem => {
      this.scene.add(elem)  // 场景添加模型
    })
  }

然后我们在组件中把获取模型列表,然后呢,把模型添加到场景中。

import { allBaseObject } from './js/TBaseObject'

再 threejs 初始化完成后,调用我们写的方法,把模型列表添加到场景。

this.ThreeEngine.addObject(...allBaseObject)  // 添加基础模型

代码我最后会全部提交到 gitee,到时候如果需要可以看一下。

这样我们在看一下效果:

在这里插入图片描述
哇偶,还是黑色的。为啥呢,在引导一波!

【引导】小明开视频了,但是老师眼前一黑,为啥?没开灯呗!其实 threejs 还是很真实的,他里面集成了光线的设置,如果没有光线,就和实际生活一样,完全就是漆黑的一篇,真棒!那么接下来,我们给场景添加一个“自然光”。

光线添加

嗯,现实生活中光线有很多了,比如说房间一盏灯,点亮之后就是一个点光源向四周发散光,在比如聚光,各大晚会的聚光灯照在一个人身上这种。threejs 中也存在这种光源,我们先编写一个最简单的光线,叫 “自然光”。

注意一点,我们创建的很多东西如果想展示出来都需要添加到场景才可以,比如我们创建的立方体、现在要创建的自然光,以及后边说的光线辅助啥的都需要添加进场景才可以看到,那么我们写这个光线的时候和立方体一样,创建一个 TLights.js 文件,把光源创建出来,然后引入到组件然后添加进场景进行展示。

创建光线其实很简单:

import { AmbientLight } from "three"


/**
 * 光线
 */
export const allLights = []

// 添加环境光(自然光),设置自然光的颜色,设置自然光的强度(0 最暗, 1 最强)
export const ambientLight = new AmbientLight('rgb(255,255,255)', 0.8)

allLights.push(ambientLight)

threejs 中的自然光是 AmbientLight ,使用之前需要引入,引入完成实例化的时候需要传递两个参数:

  • 第一个参数是光线的颜色。
  • 第二个参数是光线的强度。0最暗,1最亮。

然后我们同样也是在 组件 中引入光线,然后将光线添加到场景。

this.ThreeEngine.addObject(...allLights)  // 添加光线

这样,光线就被我们添加到场景了,我们再来看一下效果。

在这里插入图片描述
啊? 还是黑色的!这又是怎么回事啊!!!!!

【说明】我们知道,页面是有刷新率的,比如 60hz 表示屏幕一秒钟渲染60个页面,我们的眼睛有延时,页面切换的太快,所以说我们看到的就是一个视频效果,但是 threejs 的渲染器,在初始化渲染器完成之后就只渲染了一次就不管了,所以说后边我们再修改场景修改模型的时候,并没有给我们渲染,所以说我们需要自己写代码然他渲染,怎么写呢,官网其实说的也很明白,一段代码加上就 OK 了。

接下来,我们在 构造器函数 最后加上这段代码,threejs 就会一直帮我们逐帧渲染页面效果。

    // 逐帧渲染threejs
    let animate = () => {
      renderer.render(scene, camera)  // 渲染器渲染场景和相机
      requestAnimationFrame(animate);
    }
    animate()

我们现在再来看效果:

在这里插入图片描述

终于,我们的立方体加载出来了。如果我们不设置正方体的位置,默认模型初始化加载在原点位置。

我们看到渲染器背景是黑色的,这是因为我们没有设置,他默认就是黑色的,我们可以给渲染器设置其他的颜色,在渲染器绑定完相机和场景之后:

renderer.setClearColor('rgb(239, 70, 1)')  // 设置渲染器的颜色

他就可以被设置成我们想设置的任意颜色。

在这里插入图片描述
好了,这就是最基本的使用。

轨道控制器 OrbitControls

上面我们说完了基本的初始化渲染器、相机、场景、添加模型、设置光线之后,我们发现一个问题啊,就是这个页面是静态的,我们之前看百度地图或者是其他 cesium 创建场景之后,鼠标可以拖动,放大缩小,但是现在我们编写的案例还不可以,接下来我们实现这个功能。

要想实现鼠标操控,需要使用 threejs 的另一个工具类,那就是 OrbitControls,它叫做轨道控制器。

怎么使用呢?首先需要引入进项目,主要,这个工具类不是 three 中提供的,而是在它提供的案例里面,我们需要单独引入。

import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'

引入完成,需要初始化轨道控制器。

let orbitControls = new OrbitControls(camera, renderer.domElement)

OK,初始化完成再去看效果,我们的案例就可以鼠标旋转缩放了。

在这里插入图片描述
这就是 轨道控制器 的基本使用。使用方式也很简单:

  • 鼠标左键按下拖拽:围绕视图中心点旋转。
  • 鼠标中键滚动:缩小放大,实际是相机靠近和远离。
  • 鼠标右键按下拖拽:移动场景。

【拓展】

再稍微拓展一个轨道控制器的地方,就是我们的轨道控制器鼠标按键功能,是可以设置的,因为我们后面可能介绍鼠标点击事件,所以说鼠标左键按下事件可能有冲突,所以说我们重新设置一下,中键功能不变,旋转改为右键操作,左键什么功能都没有。

    let orbitControls = new OrbitControls(camera, renderer.domElement)
    orbitControls.mouseButtons = {  // 设置鼠标功能键(轨道控制器)
      LEFT: null,  // 左键无功能
      MIDDLE: MOUSE.DOLLY,  // 中键缩放
      RIGHT: MOUSE.ROTATE   // 右键旋转
    }

里面使用了 MOUSE,这是 three 提供的,我们得引入一下:

import { WebGLRenderer, Scene, PerspectiveCamera, Vector3, MOUSE } from 'three'

这样设置之后,我们鼠标按键的功能就发生了变化,可以试一下。

在这里插入图片描述
OK,发现鼠标功能确实实现了。但是有没有发现一个很大的问题啊?就是根本看不出立方体的感觉来,你说他是立方体,我还就说他是一个多边形不停的变换呢!

确实是这样哈!正经的立方体他是有轮廓显示的,类似于下面:

在这里插入图片描述

但是现在没有为啥。稍微解释一下,为了看见这个立方体,我们使用了环境光,环境光有一个特点,啥特点呢,就是说,他在模型的每一个面上光照强度都是一样的,不会衰减,所以说我们看到的模型,他每个面放光是一样的,根本看不出立体感。如果想要立体感怎么办?很简单哈,换一种光线,不使用环境光了,我们使用一个点光源,从一个点射出一束光向四周扩散,这样的话,照在模型上,因为距离不一样,光照强度就不一样,立体感就出来了。

添加点光源 PointLight

我们之前在 TLights.js 文件创建了一个环境光,现在我们再创建一个点光源 PointLight,添加到场景中去。因为之前封装好了,我们只需要创建完点光源,然后把点光源放进光源数组就可以了吧。

创建点光源使用的是 PointLight,这个工具类同样是 three 中提供的,我们需要引入一下子。

import { AmbientLight, PointLight } from "three"

然后就是创建点光源,创建点光源和创建环境光有点不一样,因为他就像一个灯泡,需要有颜色、强度、能照射多远、光照衰减值,最后还有位置:

// 点光源
export const pointLight = new PointLight(
  'rgb(255,255,255)',
  0.5,
  600,
  0.2
)
pointLight.position.set(0, 100, 200)  // 设置点光源位置 (x,y,z)

allLights.push(pointLight)  // 将点光源添加到光源列表抛出

PointLight 有四个参数:

  • color - (可选参数)) 十六进制光照颜色。默认 0xffffff (白色)。
  • intensity - (可选参数) 光照强度。 缺省值 1。
  • distance - 这个距离表示从光源到光照强度为0的位置。 当设置为0时,光永远不会消失,默认0。
  • decay - 沿着光照距离的衰退量。默认 1。

OK,现在我们再来看一下添加完点光源之后,模型效果:

在这里插入图片描述
非常好,模型的立体感已经出来了。

模型部分拓展

我们既然说完了光线,其实还有很多中光线,可以去官网查看相关使用。

接下来我们稍微拓展一点儿东西哈,就是我们之前创建模型是使用的下面的代码:

// 创建立方体
export const box = new Mesh(
  new BoxGeometry(20, 20, 20),  // 设置立方体的大小
  new MeshStandardMaterial({   // 设置材质
    color: 'rgb(36, 172, 242)',  // 设置材质的颜色
  })
)

我们可以向这个模型添加数据的,比如我们设置个 name,我这个立方体叫做 “box” 可以吧。只需要这样写就可以配置他的 name 属性。

export const box = new Mesh(
  new BoxGeometry(20, 20, 20),  // 设置立方体的大小
  new MeshStandardMaterial({   // 设置材质
    color: 'rgb(36, 172, 242)',  // 设置材质的颜色
  })
)
box.name = 'box'

除了 name 之外还可以设置他的位置。

box.position.set(5, 5, 5)  // 设置模型位置 (x,y,z)

当然,位置信息也可以单独设置。

box.position.x = 5
box.position.y = 5
box.position.z = 5

单独设置每个坐标轴的位置也是可以的。

在实际开发的时候,比如我们有一个模型,我们需要给这个模型绑定一些数据,点击弹窗显示或者是鼠标悬浮显示的时候获取到这些数据,怎么绑定数据呢?其实我们可以直接设置,比如:

box.sheshimoxingshuju = {
  name: 'box',
  user: '我是ed.'
}

当然,threejs 提供了一个参数 userData 用来存放用户数据,建议放到那里面,默认我们都放到 uerData 里面,这样是为了以后多人开发,不至于每个人创建一个属性最后乱套了都。

export const box = new Mesh(
  new BoxGeometry(20, 20, 20),  // 设置立方体的大小
  new MeshStandardMaterial({   // 设置材质
    color: 'rgb(36, 172, 242)',  // 设置材质的颜色
  })
)
box.name = 'box'  // 设置模型 name
box.position.set(5, 5, 5)  // 设置模型位置
box.position.x = 5
box.position.y = 5
box.position.z = 5


box.sheshimoxingshuju = {
  name: 'box',
  user: '我是ed.'
}

box.userData = {
  name: '我是ed.'
}

怎么确定我们都设置成功了?我们打印一下就可以了。

在这里插入图片描述

我们直接打印一下 box 就可以看到我们配置的都是生效了的,都存进去了的。为啥突然想说这个,主要是想说一下 name 设置的,因为后边可能要根据模型的 name 从场景中获取模型,所以说一下模型的 name 怎么设置。我们去掉测试多余的代码哈。

然后再说一下模型的材质问题

还是这段代码:

export const box = new Mesh(
  new BoxGeometry(20, 20, 20),  // 设置立方体的大小
  new MeshStandardMaterial({   // 设置材质
    color: 'rgb(36, 172, 242)',  // 设置材质的颜色
  })
)

关于材质,我们只设置了一个颜色对吧!页面效果也显示出来了,然后是蓝色的很精致的小盒子,他除了颜色还可以设置其他的属性,比如:粗糙度 roughness

roughness 粗糙度是啥意思,就比如说我们生活里面,木头的粗糙度就很高,玻璃的粗糙度就很低。

roughness 上怎么提现粗糙度呢,roughness 的取值范围是 0 到 1。当 roughness 为 0 时,表示粗糙度最低,就越光滑;当 roughness 为 1 时,表示粗糙度最高,越粗糙。

比如我们给这个正方体设置一个粗糙度为 0 ,也就是最光滑。

export const box = new Mesh(
  new BoxGeometry(20, 20, 20),  // 设置立方体的大小
  new MeshStandardMaterial({   // 设置材质
    color: 'rgb(36, 172, 242)',  // 设置材质的颜色\
    roughness: 0   // 粗糙度(0 最光滑,1 最粗糙)
  })
)

我们怎么看效果,这也是为啥我在这里说粗糙度而不是在添加模型说的原因,我们在场景添加了一个点光源,可以理解成就是一个灯吧!如果一个物体,他表面光滑到一个程度之后他会反光的!我就把正方体的粗糙度调到最低,也就是最光滑的时候,他肯定会反光吧,那我们调节模型,看他有没有反光的时候。看效果:

在这里插入图片描述
找到反光的点了,是吧!但是如果我们粗糙度调到最高,是绝对不可能反光的,这里我们就不看了,有兴趣的可以自己看一下。

除了粗糙度,在说一个吧,就是 金属度 metalness

我们在生活中见过铁吧!见过不锈钢吧!见过铝合金吧!那种金属质感很酷吧?就算是相同的颜色,塑料和金属你一眼就分个大概吧!

metalness 就是用来设置模型金属质感的,他的取值也是从 0 到 1,当 metalness 为 0 表示金属质感最少,最不像金属;metalness 为 1 表示金属质感最强,最像金属。

我们在给模型添加一个金属质感。

export const box = new Mesh(
  new BoxGeometry(20, 20, 20),  // 设置立方体的大小
  new MeshStandardMaterial({   // 设置材质
    color: 'rgb(36, 172, 242)',  // 设置材质的颜色
    metalness: 0.5,   // 金属度 (1 最像金属,0 最不想金属)
    roughness: 0   // 粗糙度(0 最光滑,1 最粗糙)
  })
)

我这里金属质感设置的 0.5,为啥,现实生活中有没有一个感觉,就是一个金属块,表面越光滑,金属感越强,他的颜色就越暗,暗的发黑。

在这里插入图片描述

看,我设置完金属度之后,模型不如之前亮了,但是没有看出金属质感啊?别急,我移动一下,照样让他返回看一下,金属质感立马就出来了。

在这里插入图片描述
怎么样!厉害吧!啊哈哈哈哈!

好了,关于这个小的拓展部分就到这里吧!完成!

添加辅助线

这一部分说一些辅助工具,我们添加模型啥的,包括模型的定位,都是凭感觉,不知道各个轴的方向,也不知道原点位置,所以说能不能让原点位置和坐标轴可视化?

答案是肯定的, threejs 为我们提供了辅助线,用来可是画坐标轴。接下来就实现一下坐标轴的可视化操作。

首先我们还是和模型、光线一样,创建一个辅助文件 THelper.js ,在这个 js 文件中创建辅助线,然后抛出,在组件中接受,最后添加在场景里面,我们就可以看到坐标轴辅助线了。

首先坐标轴辅助线是 AxesHelper,这个工具类是 three 提供的,所以说我们需要单独引入一下。

import { AxesHelper } from 'three'

引入完成就可以使用来了。接下来创建辅助:

import { AxesHelper, GridHelper } from 'three'

export const allHelper = []

// 坐标辅助
export const axesHelper = new AxesHelper(500)  // 创建坐标辅助 (500 为辅助线的长度)

allHelper.push(axesHelper)  // 添加到辅助列表

还是,在组件中引入,然后就可以添加到场景里面去了。

  import { allHelper } from './js/THelper'

添加到场景:

this.ThreeEngine.addObject(...allHelper)   // 添加辅助

这样辅助线就添加到场景中去了,我们可以看一下效果。

在这里插入图片描述

这里的 红色线就是 x 轴,蓝色线就是 z 轴,绿色线就是 y 轴。

除了坐标辅助线,我们还可以添加地面网格线 GridHelper

import { AxesHelper, GridHelper } from 'three'

export const allHelper = []

// 坐标辅助
export const axesHelper = new AxesHelper(500)  // 创建坐标辅助

// 创建地面网格辅助
export const gridHelper = new GridHelper(100, 10, 'red', 'rgb(222, 225, 230)')

allHelper.push(axesHelper, gridHelper)

网格辅助线一共需要配置四个参数:

  • size – 坐标格尺寸. 默认为 10. 这就是网格组成的大正方形最大是多少
  • divisions – 坐标格细分次数. 默认为 10. 组成的最大的正方向平均分多少份
  • colorCenterLine – 中线颜色. 值可以为 Color 类型, 16进制 和 CSS 颜色名. 默认为 0x444444。这个是指网格和坐标的 x轴 z 轴重合线的颜色。
  • colorGrid – 坐标格网格线颜色. 值可以为 Color 类型, 16进制 和 CSS 颜色名. 默认为 0x888888

我们看一下效果:

在这里插入图片描述
这样,地面网格线也出来了。其实光线辅助也是有的,但是呢,我不想写了,如果需要的话去官网看一下怎么使用自己加进去就可以了。得接着往下说其他的功能了。

模型编辑逻辑梳理

到这里的话,模型加载展示基础就基本上完成了,如果是我们自己从网上下载的模型不是这样添加,我看看有时间开一篇新的博客说一下,但是这篇博客因为是基础嘛,就不说加载第三方模型的东西了。

现在这一部分说一下模型的编辑。

我们通过上面的步骤,成功的把立方体模型添加到场景了,但是我们发现,添加的位置是原点嘛,因为没有设置初始位置,所以说就是默认原点,那我现在想鼠标拖动这个模型,改变模型的位置,甚至是旋转这个模型,也可能拉伸这个模型让他进行形变怎么办?可以实现吗?

答案是可以的,threejs 帮我们提供了这样一个操作类,接下来我们就说一下这部分的使用。

【分析】

我们先来分析一波。我们想让模型通过鼠标拖拽的方式移动位置,他有几种移动的方向?这个和二维的不一样吧?二维的只有长宽,所以说移动一个二维的东西,只有 x 轴 和 y 轴移动吧?但是 threejs 是三维的,他除了 x轴,y轴 还有一个 z 轴,三条轴立体移动。同样如果旋转、形变也都是和二维是不一样的,不是拖拖鼠标在一个平面移动一下就可以的。

所以说,threejs 为我们提供了一个工具类叫做 变换控制器 TransformControls 。他可以提供一种类似于在数字内容创建工具(例如Blender)中对模型进行交互的方式,来在3D空间中变换物体。

他类似于这个样子:

在这里插入图片描述

我们要做的是什么操作,就是我们点击要移动模型,针对这个被点击的模型绑定一个 变换控制器,变换控制器有三根轴,分别对应的就是 threejs 坐标系的 x 轴 、y 轴 、z 轴,我们拖动 变换控制器 的轴,就可以实现对应模型的移动。

OK,所以说,首先要实现的一件事情是啥?不是初始化变换控制器,而是点击事件。

【分析】 再分析一波!二维里面我们看到一个正方形,我们想要点击正方形怎么做?鼠标移动上去直接点击就可以吧?但是我们 threejs 是三维的,是一个立体的空间,我问一下,看下面的图片,我把视图范围放大,让立方体离相机远一点,所以显得立方体变小了。

在这里插入图片描述

这个时候,我把鼠标移动到蓝色立方体那个位置,点击左键,有没有点击到立方体身上?答案肯定是没有!因为鼠标的位置也就是屏幕是二维的,但是正方体在三维场景里面,他是在屏幕里面,渲染器渲染出空间来了,鼠标和小方块直接是有空间的,有距离的,放到三维里面,鼠标没有点击到小方块,而是在空气上面点击了一下吧!!!一定要搞清楚哈,鼠标没有放到小方块上面,只是鼠标挡在了相机前面,把小方块挡住了而已,所以说你点击的不是小方块,是 threejs 的相机镜头啊!

所以,我们想要点击小方块怎么办呢?点击不了。

那我们想一下,我们想要点击小方块的目的是啥?是不是想给我们想要拖拽的小方块绑定一个变换控制器。那所以说,我们一定要点击到小方块吗?好像也不需要,只要让我们在点击鼠标的时候获取到小方块这个模型对象就可以了吧?

在生活当中,如果我们想让旁边的人关注远处的一个人怎么办?是不是你直接拿手指一下远处的人就可以了,别人通过你手指的方向,结合你看的方向,结合当时的场景就大体知道你指的是哪个人了吧?threejs 中,也提供了类似的功能,叫做 射线发射器 Raycaster

射线发射器 Raycaster 会根据鼠标在二维屏幕中点击的位置,结合当前相机的一些状态,比如位置、角度、方向等,从屏幕向鼠标点击的方向发出一条射线,把被射线穿过模型返回成一个列表回来,列表的顺序就是穿过的先后顺序,所以我们照着小方块点过去,射线一定会穿过小方块,当然可能还有其他的,但是第一个肯定是最先被射线穿过的小方块吧!毕竟我们没必要隔山打牛。

好的,逻辑捋清楚了,接下来就可以开始编写代码了。

初始化射线发射器 Raycaster

初始化射线发射器其实是很简单的事情,threejs 官方也为我们提供了 方式,只需要一行代码就可以实现了。

// 初始化射线发射器
let raycaster = new Raycaster()

根据上面一部分分析,我们知道鼠标要触发点击事件,然后把射线从屏幕打出去,看看打穿了哪些模型吧?好的,那么分析一个事情,我们点击鼠标,从点击的地方发出射线吧?OK,我们首先得知道鼠标的位置是吧,我们可以写一个鼠标移动的事件来获取鼠标实时位置吧?OK,插一句,其实这个获取鼠标位置可以在点击的时候获取到,但是我想特别的添加一个鼠标移动事件,为了后边一个案例做准备吧算是,我们添加鼠标移动事件其实就是在渲染器上面添加吧,因为他充满整个屏幕。

// 鼠标移动事件
renderer.domElement.addEventListener("mousemove", event => {
	let x = event.offsetX
    let y = event.offsetY
    console.log(x, y)
})

好的,我们鼠标移动事件写好了。其中 x , y 就是鼠标在屏幕的坐标。截取了一个图片,当鼠标在渲染器渲染的时候,可以看到鼠标在控制台的实时位置。

在这里插入图片描述
我们看到控制台已经在实时打印我们鼠标的位置了,但是呢,现在思考一个问题哈。就是我们获取到鼠标的位置,是相对于屏幕的。但是呢,我们一会配置射线发生器需要两个参数,分别是相机,他会获取相机角度,位置,方向,结合传递的第二个参数,鼠标点击位置,计算实际射线在 threejs 中射线穿过的模型。所以说,第二个参数的鼠标位置,应该是 threejs 视角的鼠标位置。这个位置和我们计算出来的相对于屏幕的鼠标位置是不一样的。

看下面一张图:

在这里插入图片描述
对于电脑屏幕来说,也就是我们上一步拿到的鼠标坐标,它是以左上角为 0,0 点,向左,向下逐渐变大,最大就是电脑视图的高度和宽度。

但是对于 threejs 视图来说呢,它是以视图的中心点为 0,0,向左变大,向下变大,且最大是 1。

所以说我们获取到了鼠标在电脑视图的坐标,需要计算得到在 threejs 视图的鼠标坐标啊,所以,我们获取到的鼠标坐标 x,y 通过计算获得 threejs 的坐标是下面这个算法:

对于 threejs 而言,他的原点就是屏幕宽度的一半和屏幕高度的一半。所以:

横轴: (x - width / 2) / (width / 2)
纵轴: (height / 2 - y) / (height / 2)

化简一下就是 

x / width * 2 - 1
-y * 2 / height + 1

OK,这样我们就获取到了 threejs 中鼠标的位置。

因为我们后边需要射线发射器传递两个参数,一个是相机,一个是鼠标,我们的第二个参数鼠标是一个二维的对象,我们先提前声明一下。

// 初始化鼠标位置
let mouse = new Vector2()
//  屏幕鼠标x,屏幕鼠标y  视图宽度,视图高度
let x = 0; let y = 0; let width = 0; let height = 0

然后在鼠标移动事件里面给鼠标对象设置他的 x 和 y:

renderer.domElement.addEventListener("mousemove", event => {
  x = event.offsetX
  y = event.offsetY
  width = renderer.domElement.offsetWidth
  height = renderer.domElement.offsetHeight
  mouse.x = x / width * 2 - 1
  mouse.y = -y * 2 / height + 1
})

这样子我们就成功的获取到了鼠标在 threejs 中的位置信息。

然后接下来就可以一编写点击事件了,点击事件要做的事情就是当我们按下鼠标之后,射出一个射线,被射线穿过的模型列表,都会给我们返回回来:

	// 鼠标点击事件
    renderer.domElement.addEventListener("click", event => {
      raycaster.setFromCamera(mouse, camera)  // 配置射线发射器,传递鼠标和相机对象
      const intersection = raycaster.intersectObjects(scene.children) // 获取射线发射器捕获的模型列表,传进去场景中所以模型,穿透的会返回我们
	  console.log(intersection)
    })

我们通过射线发射器捕获到了我们点击的模型,然后打印一下所有的数据看一下:

在这里插入图片描述

当我点击了立方体之后,控制台打印出来一个模型的列表,其实这个模型,就是点击的立方体。

在这里插入图片描述

我们展开看到 object 模型下面的 name 就是我们设置的 box 名字吧!

好的,有个问题说一下,抛开做的这个demo, 有时候我们点击一个位置,他打印出来的不是一个对象, 而是好几个,因为射线能穿过了好几个模型的,但是列表的第一个模型,肯定是我们点击的,因为这个列表是按照穿过的先后顺序返回的。还有一个,我们的辅助线,甚至是一会要使用的变换控制器,都会被射线穿过,都会被返回。

使用变换控制器 TransformControls

首先我们需要引入,这个引入和之前不一样,是单独的,在案例里面:

import { TransformControls } from 'three/examples/jsm/controls/TransformControls'

引入完成,我们需要初始化我们的 变换控制器。

// 初始化变换控制器
let transformControls = new TransformControls(camera, renderer.domElement)
scene.add(transformControls) // 将变换控制器添加至场景

这个要初始化在点击事件之前哈!然后,在点击事件中,我们得首先判断一下,点击下去有没有射穿模型,如果没有的话就没有必要给第一个模型添加变换控制器了吧。如果有,就给第一个模型添加变换控制器。

    // 点击事件
    renderer.domElement.addEventListener("click", event => {
      raycaster.setFromCamera(mouse, camera)  // 配置射线发射器,传递鼠标和相机对象
      const intersection = raycaster.intersectObjects(scene.children) // 获取射线发射器捕获的模型列表,传进去场景中所以模型,穿透的会返回我们
      if (intersection.length) {
        const object = intersection[0].object  // 获取第一个模型
        transformControls.attach(object)
      }
    })

完成到这里之后,我们就可以对模型进行编辑操作了。

在这里插入图片描述
好的,但是多操作几次发现是有问题的。比如我们先拖动,然后松开鼠标,在拖动的话,就发现 变化控制器添加到别的地方去了,就不再小正方体上面了。

这是什么原因造成的呢,因为我们所有的场景里面添加的东西,都是模型,所以说呢,变换控制器本身也是模型,当然,这不是主要原因,他会造成其他的问题,这个地方的原因是什么,我们可以在点击事件里面打印一句话:

    renderer.domElement.addEventListener("click", event => {
      console.log("click")
      raycaster.setFromCamera(mouse, camera)  // 配置射线发射器,传递鼠标和相机对象
      const intersection = raycaster.intersectObjects(scene.children) // 获取射线发射器捕获的模型列表,传进去场景中所以模型,穿透的会返回我们
      if (intersection.length) {
        const object = intersection[0].object  // 获取第一个模型
        transformControls.attach(object)
      }
    })

然后我们看一下打印的效果:

在这里插入图片描述

我们注意到,我们点击鼠标左键之后,打印出来了 click,但是我们拖拽完 变换控制器之后,又打印了一遍,为啥呢,第一次打印其实是点击小方块的,这个我们可以理解,第二次是因为我们点击变换控制器时候触发的呀。

所以需要解决一个问题,就是我们要处理一下:我们给变换控制器一个鼠标按下的事件,然后我们定义一个 变量记录是否是 变换控制器 按下的事件。

    let transing = false
    transformControls.addEventListener("mouseDown", event => {
      transing = true
    })

然后,在之前的点击事件中判断一下,如果是变化控制器按下的话,就不处理就可以了吧

    // 点击事件
    renderer.domElement.addEventListener("click", event => {
      if (transing) {
        transing = false
        return
      }
      raycaster.setFromCamera(mouse, camera)  // 配置射线发射器,传递鼠标和相机对象
      const intersection = raycaster.intersectObjects(scene.children) // 获取射线发射器捕获的模型列表,传进去场景中所以模型,穿透的会返回我们
      if (intersection.length) {
        const object = intersection[0].object  // 获取第一个模型
        transformControls.attach(object)
      }
    })

这样我们就成功修改掉那个 bug 了。

在这里插入图片描述

其实呢,还有一点小问题,就是之前说过的,我们所有场景里面添加的东西,都是模型,所以说呢,变换控制器本身也是模型。我问为了防止我们按下获取到的组件是变换控制器本身,所以说,我们按下鼠标获取点击模型之前,先把变换控制器移除,然后获取到模型之后再把变换控制器整出来。

    // 点击事件
    renderer.domElement.addEventListener("click", event => {
      if (transing) {
        transing = false
        return
      }
      scene.remove(transformControls) // 移除变换控制器
      transformControls.enabled = false // 停用变换控制器
      raycaster.setFromCamera(mouse, camera)  // 配置射线发射器,传递鼠标和相机对象
      const intersection = raycaster.intersectObjects(scene.children) // 获取射线发射器捕获的模型列表,传进去场景中所以模型,穿透的会返回我们
      if (intersection.length) {
        const object = intersection[0].object  // 获取第一个模型
        scene.add(transformControls) // 添加变换控制器
        transformControls.enabled = true // 启用变换控制器
        transformControls.attach(object)
      }
    })

这样就不用担心我们按下鼠标之后,点击到变换控制器本身了。

在这里插入图片描述

当然效果和之前是完全一样的。

然后到现在为止呢,变换控制器移动模型就可以了。

但是变换控制器远远不值这么点功能。除了移动位置之外,还可以实现形变和旋转。

比如说我们添加一个小功能,当我们:

  • 按下键盘 E 之后,可以对模型进行缩放。
  • 按下键盘 R 之后,可以对模型进行旋转。
  • 按下键盘 T 之后,改为对模型进行移动。

只需要监听一下键盘按下事件,改变变换控制器的类型就可以了。

    // 监听变换控制器模式更改
    document.addEventListener("keyup", event => {
      if (transformControls.enabled) {  // 变换控制器为启用状态执行
        if (event.key === 'e') { // 鼠标按下e键,模式改为缩放
          transformControls.mode = 'scale'
          return false
        }
        if (event.key === 'r') { // 鼠标按下r键,模式改为旋转
          transformControls.mode = 'rotate'
          return false
        }
        if (event.key === 't') { // 鼠标按下t键,模式改为平移
          transformControls.mode = 'translate'
          return false
        }
      }
    })

然后我们就按下按键实现效果了!

在这里插入图片描述
好的,这样的话效果就都实现了。

鼠标移动到模型变色

好了,接下来我们实现一个稍微简单的功能,记得之前点击按钮使用鼠标 x ,y 坐标的时候,我没有在点击事件中获取,而是特意写了一个鼠标移动监听事件吗?就是为了演示这个地方做准备的。

要实现鼠标移动上去模型变色,所以说呢,我们首先得知道模型有没有被鼠标移动上去,然后模型触发移入或者是移除的事件,时间里面就是改变模型的颜色吧!

我们首先创建一个变量,用来存储我们鼠标移入之后获取到的这个模型:

cacheObject = null  // 鼠标移入缓存效果

然后我们在鼠标移动的事件里面修改成下面的代码:

    renderer.domElement.addEventListener("mousemove", event => {
      x = event.offsetX
      y = event.offsetY
      width = renderer.domElement.offsetWidth
      height = renderer.domElement.offsetHeight
      mouse.x = x / width * 2 - 1
      mouse.y = -y * 2 / height + 1

      raycaster.setFromCamera(mouse, camera)  // 配置射线发射器
      scene.remove(transformControls)  // 移除变换控制器
      const intersection = raycaster.intersectObjects(scene.children)
      if (intersection.length) {
        const object = intersection[0].object
        if (object !== this.cacheObject) {  // 如果当前物体不等于缓存的物体
          if (this.cacheObject) { // 如果有缓存物体先执行之前物体的离开事件
            this.cacheObject.dispatchEvent({
              type: 'mouseleave'
            })
          }
          object.dispatchEvent({  // 添加当前物体进入事件
            type: 'mouseenter'
          })
        } else if (object === this.cacheObject) {  // 如果当前物体等于缓存的物体
          object.dispatchEvent({  // 执行移动事件
            type: 'mousemove'
          })
        }
        this.cacheObject = object
      } else {
        if (this.cacheObject) {  // 如果有缓存物体就先执行离开事件
          this.cacheObject.dispatchEvent({
            type: 'mouseleave'
          })
        }
        this.cacheObject = null
      }
    })

同时,我们得给 box 模型添加两个事件,分别是鼠标移入和鼠标移出的吧?

import { BoxGeometry, Color, Mesh, MeshStandardMaterial } from "three"

export const allBaseObject = []  // 返回所有基础模型

// 创建地面
export const box = new Mesh(
  new BoxGeometry(20, 20, 20),  // 设置立方体的大小
  new MeshStandardMaterial({   // 设置材质
    color: 'rgb(36, 172, 242)',  // 设置材质的颜色
    metalness: 0.5,   // 金属度 (1 最像金属,0 最不想金属)
    roughness: 0   // 粗糙度(0 最光滑,1 最粗糙)
  })
)
box.name = 'box'  // 设置模型 name

// 给模型添加鼠标移入事件
box.addEventListener("mouseenter", () => {
  box.material.color = new Color("#ff3366")  // 修改材质颜色为红色
})
// 给模型添加鼠标移除事件
box.addEventListener("mouseleave", () => {
  box.material.color = new Color("rgb(36, 172, 242)") // 恢复模型的材质
})


allBaseObject.push(box)  // 添加到模型数组

好的,接下来我们的效果就实现了。

在这里插入图片描述
好,我们看到我们鼠标移入移除就实现了模型材质颜色的切换。

但是发现一个问题,为啥鼠标在模型上,但是他有一段变成了最开始的颜色啊?

之前说过,场景里面所以的东西都是模型,射线发射器都会根据穿过顺序返回。也就是说,网格辅助线也是会被穿透的!网格辅助线其实也是有一定的宽高的,所以那时候射线发射器第一个穿过的是网格辅助线,但是辅助线没有实现鼠标移入移出时间,当辅助线移入的时候,就是小方块鼠标的移出吧!所以他恢复了之前的颜色。

结束语

好了,关于 threejs 的基本操作就这些,后期可能还会写一篇关于加载第三方模型的博文以及实现鼠标移动到模型上面显示 tip 标签的功能,今天这篇博文就到这里了,拜拜!

代码资料

我是𝒆𝒅. 的 gitee

Logo

旨在为数千万中国开发者提供一个无缝且高效的云端环境,以支持学习、使用和贡献开源项目。

更多推荐