Three.js入门之做一个简单的3D场景内添加标点的功能
·
什么是Three.js?
- 百度百科上是这么说的:
Three.js是JavaScript编写的WebGL第三方库。提供了非常多的3D显示功能。运行在浏览器中的 3D 引擎,你可以用它创建各种三维场景,包括了摄影机、光影、材质等各种对象。你可以在它的主页上看到许多精彩的演示。不过,这款引擎还处在比较不成熟的开发阶段,其不够丰富的 API 以及匮乏的文档增加了初学者的学习难度(尤其是文档的匮乏)three.js的代码托管在github上面。
一些有用的链接
- Three.js的基本概念:https://threejs.org/docs/index.html#manual/en/introduction/Creating-a-scene
- 入门教程:https://threejsfundamentals.org/
- Github:https://github.com/mrdoob/three.js/tree/master
基本概念
- 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;
}
- 最终效果
源码链接
更多推荐
已为社区贡献3条内容
所有评论(0)