Three.js入门之做一个简单的3D场景内添加标点的功能

什么是Three.js?

  • 百度百科上是这么说的:

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

一些有用的链接

基本概念

  • Tips:这里只作为核心概念的基本介绍,更详细请阅读上面链接的内容
  • 要创建一个threejs应用,就必须了解组成threejs应用的基本概念:场景、相机、渲染器
  • 相机:我们在屏幕上看场景内容的视图工具,相当于我们的眼睛
  • 场景:一些模型或者其它等所在的环境,相当于我们用眼睛看到的周围的各种物体等的环境,我们创建的各种模型都是直接通过add函数加进这里的
  • 渲染器:负责把相机和场景渲染到浏览器视图上

环境准备(使用webpack搭建开发)

  • 初始化nodejs项目
npm init -y
  • 安装webpack、webpack-cli
npm i --save-dev webpack
npm i --save-dev webpack-cli
  • 安装一些loader、plugin
npm i --sece-dev @babel/core
npm i --sece-dev @babel/plugin-transform-runtime
npm i --sece-dev @babel/preset-env
npm i --sece-dev babel-loader
npm i --sece-dev css-loader
npm i --sece-dev html-loader
npm i --save-dev clean-webpack-plugin
npm i --save-dev html-webpack-plugin
npm i --save-dev copy-webpack-plugin
  • 创建并配置 webpack.config.js
const path = require('path')
const webpack = require('webpack')
const HtmlWebPackPlugin = require('html-webpack-plugin')
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');

module.exports = {
  mode: 'development',
  entry: './src/index.js',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'main.js'
  },
  devtool: 'eval-cheap-module-source-map',
  devServer: {
    contentBase: path.join(__dirname, 'dist'),
    port: 9000,
    host: '0.0.0.0',
    hot: true
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: [
          { loader: 'babel-loader', options: { presets: [['@babel/preset-env', { useBuiltIns: 'usage' }]] } }
        ]
      },
      {
        test: /\.css$/i,
        exclude: /node_modules/,
        use: ['style-loader', 'css-loader'],
      },
      {
        test: /\.html$/,
        loader: 'html-loader'
      },
    ]
  },
  plugins: [
    new CleanWebpackPlugin(),
    new HtmlWebPackPlugin({ template: './src/index.html' }),
    new webpack.HotModuleReplacementPlugin(),
    new CopyWebpackPlugin(
      { patterns: [{ from: path.resolve(__dirname, 'public'), to: 'public' }] }
    )
  ]
}

  • 之后添加一些目录和文件,整个项目的结构如下图;其中/public目录是一些需要访问的资源(如图片、3D模型等);/src/assets是一些其静态资源;/src/common是业务代码;/src/index.html是模板html; /src/index.html是入口js
    在这里插入图片描述
  • 最后安装最关键的Threejs
npm i --save three

开始开发

  • Tips:代码里面用到的 Utils.XXX 的函数是笔者自行封装的,在文章底部有本文源码链接
  • html 里面添加一个 Three.js 的容器元素
<style>
 - {
	margin: 0;
	padding: 0;
}
html {
	overflow: hidden !important;
	height: 100vh;
	width: 100vw;
}
body, #canvas {
	height: 100%;
	width: 100%;
}
</style>
...一些其他代码
<canvas id="canvas"></canvas>
...一些其他代码
  • 创建一个 test.js 并在 index.js 导入使用
  • test.js 中导入必要的依赖
// 导入threejs模块
import * as THREE from 'three';

// 由threejs官方提供的验证浏览器是否支持webgl的工具函数,需要到https://github.com/mrdoob/three.js/blob/master/examples/jsm/WebGL.js获取
import { WEBGL } from './WebGL.js';

// 轨道控制器,用来给场景添加可用鼠标来移动旋转场景的功能
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';

// 变换控制器,用来给某一个模型添加可以用鼠标来在场景内移动该模型的功能
import { TransformControls } from 'three/examples/jsm/controls/TransformControls.js';
  • 创建场景、相机、渲染器、灯光(Tips:这里的代码以及之后的代码都是在 WEBGL.isWebGLAvailable() 验证了浏览器有WebGL功能后编写的)
// 使用Utils工具里面的init函数初始化场景、相机、渲染器
let { scene, camera, renderer } = Utils.init(
  { bgColor: 0xf0f0f0 },
  {
    fov: 85,
    // 记得在html里加一个canvas元素,这个元素是渲染出来的视图的容器
    aspect: document.getElementById('canvas').innerWidth / document.getElementById('canvas').innerHeight,
    near: 0.1,
    far: 100000
  }
);

// Utils.init 实现细节
function init (
  sceneConfig = {
    // 场景的背景色
    bgColor: 0xeeeeee
  },
  cameraConfig = {
    // 相机的视野角度
    fov: 75,
    // 相机的宽高比
    aspect: document.getElementById('canvas').innerWidth / document.getElementById('canvas').innerHeight,
    // 近截面(物体某些部分比摄像机的远截面远或者比近截面近的时候,该这些部分将不会被渲染到场景中)
    near: 0.1,
    // 远截面
    far: 1000
  },
  rendererConfig = {
    // 渲染器挂载的dom容器
    canvas: document.getElementById('canvas')
  }
) {
  // 创建场景
  const scene = new THREE.Scene();
  // 设置场景的背景色
  scene.background = new THREE.Color(sceneConfig.bgColor);
  // 创建相机
  const camera = new THREE.PerspectiveCamera( cameraConfig.fov, cameraConfig.aspect, cameraConfig.near, cameraConfig.far );
  // 创建渲染器
  const renderer = new THREE.WebGLRenderer(rendererConfig); 

  return { scene, camera, renderer };
}

// 创建完基本的 scene, camera, renderer 后,要设置相机的位置,不然相机会在(0, 0, 0)的位置;然后添加灯光,没有灯光的话,可能会看不到我们添加的模型
// 相机位置
camera.position.set( 0, 250, 1000 );

// 给场景添加一个环境光
scene.add( new THREE.AmbientLight( 0xf0f0f0 ) );
  • 添加轨道控制器
// 创建一个轨道控制器实例,传入刚刚创建的的相机实例以及渲染器的容器dom对象
const controls = new OrbitControls(camera, renderer.domElement);
// 设置旋转的中心点
controls.target.set(0, 0, 0);
  • 添加变换控制器
// 创建一个变换控制器实例,传入刚刚创建的的相机实例以及渲染器的容器dom对象
let transformControl = new TransformControls( camera, renderer.domElement );

// transformControl的dragging(拖动事件)发生时改变就控制一下轨道控制器启用禁用(因为要拖拽当前的模型,所以要禁用旋转)
transformControl.addEventListener( 'dragging-changed', ( event ) => {
  controls.enabled = !event.value;
} );

// 添加变换控制器到场景里
scene.add( transformControl )
  • 添加一个底座平面,并且在这个底座上添加一些网格,以便标点参考位置
// 添加一个底座平面
// 平面几何
const planeGeometry = new THREE.PlaneGeometry( 2000, 2000 );

// 把xy平面变为xZ平面
planeGeometry.rotateX( - Math.PI / 2 );

// 基础网格材质
const planeMaterial = new THREE.MeshBasicMaterial();

// 把平面几何和基础网格材质 生成平面网格
const plane = new THREE.Mesh( planeGeometry, planeMaterial );

// 平面网格向下(y轴负方向)移动200单位
plane.position.y = -200;

// 把平面添加到场景里面
scene.add( plane );

// 网格辅助器
// 创建一个网格辅助器的实例,传入参数 坐标格尺寸、坐标格细分次数
const helper = new THREE.GridHelper( 2000, 100 ); 

// 向下(y轴负方向)移动199单位,与底座平面几乎重合
helper.position.y = - 199;

// 透明度
helper.material.opacity = 0.25;

// 是否可透明
helper.material.transparent = true;

// 添加到场景
scene.add( helper );
  • 添加一个立方体在(0, 0, 0),给标点做参考物体
// 创建以一个立方体
const geometry = new THREE.BoxGeometry(50, 50, 50);

// 创建一个网格材质
const material = new THREE.MeshPhongMaterial( { color: 0x00ff00 } );

// 把立方体和材质添加到一个网格中
const cube = new THREE.Mesh( geometry, material );

// 设置立方体的位置
cube.position.set(0, 0, 0);

// 把网格添加到场景
scene.add( cube ); 
  • 定义添加标点的工厂函数,并初始化一个默认标点
// 一个存储标点实例对象模型的数组(给标点添加事件时有用)
let objArr = []

// 利用纹理加载器,加载一个图片,用来做标点的样式
const map = new THREE.TextureLoader().load( "/public/icon.png" );

// 利用这个图片创建一个精灵图材质(无论在哪个视角看,精灵图材质的模型都是面向我们的),sizeAttenuation属性是让模型不随视图内容的缩小放大而缩小放大
const spriteMaterial = new THREE.SpriteMaterial( { map: map, sizeAttenuation: false } );

// 创建第二个精灵图材质,depthTest是让这个模型被其它模型遮挡仍然能被看见(默认被遮住时不能透过模型被看见),opacity设置透明度(为什么要弄两个材质?为了让标点被遮住时有被遮住的效果)
const spriteMaterial2 = new THREE.SpriteMaterial( { map: map, sizeAttenuation: false, depthTest: false, opacity: 0.2 } );

// 创建精灵图模型实例的函数
function createMarker (m) {
  return new THREE.Sprite( m );
}

// 创建一个标点的函数
function createMarkerCon() {
  // 第一个精灵图模型
  let sprite1 = createMarker(spriteMaterial)
  // 第二个精灵图模型
  let sprite2 = createMarker(spriteMaterial2)
  // 第一个精灵图模型 把 第二个精灵图模型 添加为子模型
  sprite1.add(sprite2)
  // 设置精灵图模型的尺寸缩放
  sprite1.scale.set(0.1, 0.1, 0.1);
  // 设置精灵图模型初始位置
  sprite1.position.set(100, 100, 0);
  // 因为场景里不可能只有标点,所以要对精灵图模型添加特异性字段进行区分
  sprite1.isMarker = true;
  // 把第一个精灵图模型添加到场景
  scene.add(sprite1);
  // 把标点(第一个精灵图模型)添加到objArr
  objArr.push(sprite1);
}

// 创建一个标点
createMarkerCon()
  • 定义获取所有标点位置的函数
let getPosition = () => {
  // 遍历 objArr 数组
  for (let i = 0; i < objArr.length; i++) {
    // 创建一个三维空间的点对象
    let p = new THREE.Vector3();
    // 把标点相对于世界(场景)的坐标复制到 p
    objArr[i].getWorldPosition(p);
    console.log('--------------- -- ');
    console.log('marker - ', i);
    console.log(p);
  }
  alert('位置信息已在控制台输出');
}
  • 在html里面添加两个按钮,添加标点的按钮 和 获取标点位置的按钮,给两个按钮添加点击事件
.btn {
	position: absolute;
	top: 25px;
	right: 25px;
	background-color: #000;
	padding: 5px;
	color: #fff;
	cursor: pointer;
	border-radius: 5px;
}
.btn-2 { top: 60px; }
.btn-3 { top: 95px; }
...一些其他代码
<div class="btn" id="add">Add Popup Marker</div>
<div class="btn btn-2" id="get">Get Position</div>
...一些其他代码
let add = document.getElementById('add');
let get = document.getElementById('get');
add.onclick = createMarkerCon;
get.onclick = getPosition;
  • 给标点添加鼠标的点击、拖拽等事件,以便能利用鼠标对标点位置进行调整。在threejs的视图里面不进行一些转换,是没办法监听模型的事件的,要利用Raycaster来计算焦点,获取哪个模型与射线相交,从而让模型触发事件
// 创建一个射线实例对象
let raycaster = new THREE.Raycaster();

// 创建一个二维空间点的对象(x,y),在进行将鼠标位置归一化为设备坐标时(x 和 y 方向的取值范围是 (-1 to +1))有用
let mouse = new THREE.Vector2();

// 存储 鼠标按下时的二维空间点
let onDownPosition = new THREE.Vector2();

// 存储 鼠标松开时的二维空间点
let onUpPosition = new THREE.Vector2();

// 鼠标在移动时触发的事件
let onPointermove = ( event ) => {
  // 通过 Utils.onTransitionMouseXYZ 函数把将鼠标位置归一化为设备坐标(实现细节请直接看Utils工具类)
  mouse = Utils.onTransitionMouseXYZ(event, renderer.domElement);

  // 通过摄像机和鼠标位置更新射线
  raycaster.setFromCamera( mouse, camera );

  // 计算模型和射线的焦点(objArr就是之前存储标点模型的数组)
  var intersects = raycaster.intersectObjects(objArr);

  // 获取到有焦点的模型的数组后,对于不是当前 transformControl 变换器正在变换的模型的焦点模型,把这个模型添加到 transformControl ,让当前变换的模型为获取到焦点的模型
  if ( intersects.length > 0 ) {
    const object = intersects[ 0 ].object;
    if ( object !== transformControl.object ) {
      transformControl.attach( object );
    }
  }
}

// 鼠标按键按下时触发的事件
let onPointerdown = ( event ) => {
  onDownPosition.x = event.clientX;
  onDownPosition.y = event.clientY;
}

// 鼠标按键松开时触发的事件(相当于点击事件触发)
let onPointerup = ( event ) => {
  onUpPosition.x = event.clientX;
  onUpPosition.y = event.clientY;

// 如果鼠标按键按下和松开的时候是在同一个点同一个位置,则取消 transformControl 变换器正在变换的模型的变化状态,然后触发点击事件
  if ( onDownPosition.distanceTo( onUpPosition ) === 0 ) {
    transformControl.detach();
    onClick(event)
  }
}

// 点击事件(在onPointerup函数里调用)
let onClick = (event) => {
  // 通过 Utils.onTransitionMouseXYZ 函数把将鼠标位置归一化为设备坐标(实现细节请直接看Utils工具类)
  let mouse = Utils.onTransitionMouseXYZ(event, renderer.domElement);

  // 通过摄像机和鼠标位置更新射线
  raycaster.setFromCamera( mouse, camera );

  // 计算模型和射线的焦点(objArr就是之前存储标点模型的数组)
  let intersects = raycaster.intersectObjects(objArr);

  // 如果有相交的标点模型,就做一些事情,比如显示弹窗(这不是threejs的内容,不进行介绍,要在html里面加一个弹窗元素,直接看代码即可)
  if ( intersects.length > 0 ) {
    const object = intersects[ 0 ].object;
    if (object.isMarker) {

      // 弹窗内容
      let info = document.getElementById('info');
      info.style = 'display: inline-block;top: ' + (event.clientY - 50) + 'px;left: ' + (event.clientX + 50) + 'px;'
      // info.innerHTML='<iframe src="http://localhost:9000/" frameborder="0" style="width: 100%;height: 300px"></iframe>'

      // 计算合适的弹窗大小和位置
      let body = document.getElementById('html');
      setTimeout(() => {
        body.scrollTop = 1;
        body.scrollLeft = 1;
        if (body.scrollTop) {
          info.style.top = event.clientY - info.clientHeight + 50 + 'px';
          if (event.clientY < info.clientHeight) {
            let { num2 } = numLow10(event.clientX, info.clientWidth)
            info.style.height = num2 + 100 + 'px';
            info.style.top = event.clientX - num2 + 'px';
          }
        }
        if (body.scrollLeft) {
          info.style.left = event.clientX - info.clientWidth - 50 + 'px';
          if (event.clientX < info.clientWidth) {
            let { num2 } = numLow10(event.clientX, info.clientWidth)
            info.style.width = num2 - 100 + 'px';
            info.style.left = event.clientX - num2 + 'px';
          }
        }
      }, 10)

      // 如果 num1 < num2 num2就减少10 的递归函数
      function numLow10 (num1, num2) {
        console.log(num1, num2)
        if (num1 < num2)
          return numLow10(num1, num2 - 10)
        else
          return { num1, num2 }
      }
    }
  } else {
    info.style =  'display: none';
  }
}
// 添加事件委托
window.addEventListener( 'pointermove', onPointermove, false );
window.addEventListener( 'pointerdown', onPointerdown, false );
window.addEventListener( 'pointerup', onPointerup, false );

// Utils.onTransitionMouseXYZ 实现细节
// 将鼠标位置归一化为设备坐标。x 和 y 方向的取值范围是 (-1 to +1)
function onTransitionMouseXYZ( event, domElement ) {
  let mouse = new THREE.Vector2();
  let domElementLeft = domElement.getBoundingClientRect().left
  let domElementTop = domElement.getBoundingClientRect().top
  mouse.x =  ((event.clientX - domElementLeft) / domElement.clientWidth) * 2 - 1
  mouse.y = -((event.clientY - domElementTop) / domElement.clientHeight) * 2 + 1
  return mouse;
}
  • 最后,虽然上面添加了很多东西,但是还是缺少很重要的一步,现在页面是看不到效果的,因为还没有利用初始化好的 renderer 对象来把相机场景渲染到视图上。现在来完成这一步
function animate() {
  // 在浏览器重绘之前渲染(requestAnimationFrame是什么?请看:https://developer.mozilla.org/zh-CN/docs/Web/API/Window/requestAnimationFrame)
  requestAnimationFrame( animate );

  // 在视图的大小发生变化的时候,是需要更新相机的宽高比的,不然看到的场景会发生变形,使用Utils.updateCameraAspect实现(实现细节请直接看Utils工具类)
  Utils.updateCameraAspect(renderer, camera);

  // 用渲染器把 场景 和 相机 渲染到页面
  renderer.render( scene, camera );
}
animate();

// Utils.updateCameraAspect 实现细节
// 看看宽高是否有变化,就有更新宽高比
function updateCameraAspect (renderer, camera) {
  if (resizeRendererToDisplaySize(renderer)) {
    const canvas = renderer.domElement;
    camera.aspect = canvas.clientWidth / canvas.clientHeight;
    camera.updateProjectionMatrix();
  }
}
// 画布的宽高动态设置
function resizeRendererToDisplaySize (renderer) {
  const canvas = renderer.domElement;
  const pixelRatio = window.devicePixelRatio;
  const width = canvas.clientWidth * pixelRatio | 0;
  const height = canvas.clientHeight * pixelRatio | 0;
  const needResize = canvas.width !== width || canvas.height !== height;
  if (needResize) {
    renderer.setSize(width, height, false);
  }
  return needResize;
}
  • 最终效果
    在这里插入图片描述

源码链接

  • 4
    点赞
  • 35
    收藏
    觉得还不错? 一键收藏
  • 6
    评论
评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值