天天看點

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
    Three.js入門之做一個簡單的3D場景内添加标點的功能
  • 最後安裝最關鍵的

    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/" frame 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;
}
           
  • 最終效果
    Three.js入門之做一個簡單的3D場景内添加标點的功能

源碼連結

  • https://github.com/LChi1103/threejs-dome