浅探Web 3D技术:强大而有趣的Three.js

3,070 阅读11分钟

前言

长期以来作为web开发者和用户,我们都习惯了呈现2D效果的页面和图像,但随着大数据、物联网、5G、VR等技术的兴起,许多行业和产品的需求开始转向寻求3D效果的呈现。世界正在悄然变化,在座各位若干年后旧时代的残党如果想要登上新时代的航船,阅读本文将会是你迈向港口最初的一小步

Web 3D技术能做什么

产品3D浏览

在Web 2D时代,网页上的产品展示往往是通过图片或者二维图形来体现,如果想3D展示一个产品,往往需要通过unity3D或ue4开发一个桌面应用,这样做往往很难随意传播,因为需要用户下载程序很麻烦。

如今使用Web 3D技术我们可以通过Web的方式展示产品的三维模型,一个超链接就可以随意传播。加上5G技术的持续推广,各种产品在线3D展示将会变得越来越普及,比如一家汽车公司的新款轿车可以在官网上在线预览,以及一些电商平台会通过3D模型取代2D图片。现在你朋友推荐推荐给你一款新产品,你会说发一张图片看看,也许将来你会说发来一个3D模型链接看看

汽车

沙发

室内设计

数据可视化

大数据时代,Web 3D技术给海量数据的可视化提供了更加能发挥自由想象力的实现方案

Echarts 3D 可视化

科教领域

蛋白质结构可视化案例

分子结构可视化

WebVR

对于现在比较火的VR、AR概念,Web 3D技术的出现,也是一个好消息,如果想预览一些VR内容,完全可以不下载一个VR相关的APP,通过3D引擎实现VR内容发布,然后用户直接通过微信等社交方式推广,直接打开VR内容链接就可以观看。VR与Web3D技术结合自然就衍生出来一个新的概念WebVR,也就是基于Web实现的VR内容。

Google WebVR

And more!

物联网可视化、游戏、工业设计... ...一切能向3D世界扩展的用户场景

技术名词解释

OpenGL

OpenGL全称Open Graphics Library,即开放式图形库,它是一套跨语言的图形API规范,定义了一系列用来操作2D&3D图形和图像的函数,由Khronos组织制定并维护(OpenGL并非唯一的图形API规范,其它还有DirectX、Metal等)

英伟达等GPU的硬件开发商会提供满足OpenGL等规范的实现,即“显卡驱动”,它负责将OpenGL等规范定义的API命令翻译为GPU指令。也就是说通过调用各个厂商提供的驱动函数,就可以操作其GPU。从这个角度来看,也可以说OpenGL是GPU功能的调用规范

OpenGL ES

OpenGL ES (OpenGL for Embedded Systems,嵌入式OpenGL) 是OpenGL的子集,针对手机、Pad和游戏主机等嵌入式设备而设计,去除了许多不必要和性能较低的API接口。

GLSL

GLSL (OpenGL Shading Language,OpenGL着色语言)是一种特殊的有着类似于 C 语言的语法的OpenGL 着色语言,一般用于编写着色器程序,在显卡驱动中编译执行 。GLSL 不同于 JavaScript, 它是强类型语言,并且内置很多数学公式用于计算向量和矩阵

着色器实际上就是绘制东西到屏幕上的函数。着色器运行在 GPU 中,有两种类型: 顶点着色器 (Vertex Shader) 和片元着色器 (Fragment Shader). 前者是将形状转换到真实的 3D 绘制坐标中,后者是计算最终渲染的颜色和其他属性用的。

WebGL

WebGL(Web Graphics Library)是一种3D绘图标准,这种绘图技术标准允许把JavaScript和OpenGL ES 2.0结合在一起,通过增加OpenGL ES 2.0的一个JavaScript绑定,WebGL可以为HTML5 Canvas提供硬件3D加速渲染,这样Web开发人员就可以借助系统显卡来在浏览器里更流畅地展示3D场景和模型了,还能创建复杂的导航和数据视觉化。显然,WebGL技术标准免去了开发网页专用渲染插件的麻烦,可被用于创建具有复杂3D结构的网站页面,甚至可以用来设计3D网页游戏等等。

image

总结:实现了WebGL的浏览器能够支持JS操作GPU从而进行2D&3D图形渲染

WebGL 程序包括用 JavaScript 写的控制代码,以及在GPU中执行的GLSL着色代码

Three.js

Three.js是基于原生WebGL封装的3D图形工具库,JS开发者通过它可以屏蔽OpenGL和WebGL的许多底层概念,通过较为浅显的API实现Web 3D渲染功能

WebGL工作原理

在OpenGL/OpenGL ES/WebGL中我们只能渲染绘制三种图元(图形单元):点、线、三角形。不过所有其他图形,从圆到球体、从立方体到复杂三维模型,都可以由一个个三角形组成

image

首先简单了解下OpenGL编程:在OpenGL/OpenGL ES中,开发者一般编写的是顶点着色器和片元着色器。下面是图形渲染管线

image

顶点着色器操作 3D 空间的坐标并且每个顶点都会调用一次这个函数。其目的是设置 gl_Position 等变量:

void main() {
  // 这是一个特殊的全局内置变量,它是用来存储当前顶点的位置
  gl_Position = makeCalculationsToHaveCoordinates;
}

这个 void main() 函数是定义全局gl_Position 变量的标准方式。所有在这个函数里面的代码都会被着色器执行。 如果将 3D 空间中的位置投射到 2D 屏幕上这些信息都会保存在计算结果的变量中。

在顶点着色器进行的业务处理有:

  • 矩阵变换的计算

  • 计算光照公式生成逐顶点颜色

  • 生成/变换纹理坐标

片元 (或者纹理) 着色器在计算时定义了每像素的 RGBA 颜色,每个像素只调用一次片段着色器。这个着色器的作用是设置 gl_FragColor 变量,也是一个 GLSL 内置变量:

void main() {
  gl_FragColor = makeCalculationsToHaveColor;
}

在片元着色器的业务处理有:

  • 计算颜色

  • 获取纹素

  • 往像素点中填充颜色值 它可以用于图片/视频中每个像素的颜色填充。比如给视频添加滤镜,实际上就是将视频中每个图片的像素点颜色填充进行修改

回到WebGL,它是基于OpenGL ES的,所以具有类似的工作原理,其绘制过程包括以下三步:

1、获取顶点坐标 (顶点就是勾勒物体形状轮廓的一系列三维坐标点)

image

由于顶点数据往往成千上万,在获取到顶点坐标后,我们通常会将它存储在显存,即缓存区内,方便GPU更快读取

2、图元装配(即画出一个个三角形)

image

前面OpenGL工作原理那里提到过,顶点着色器的主要职能是进行顶点坐标转化(三维点投射到屏幕坐标)以及确定顶点颜色等

3、光栅化(生成片元,即一个个像素点)

image

流程总览:

image

Three.js使用入门

概述

Three.js的主要功能是渲染3D图像,它基于WebGL封装,隐藏和帮助自动处理了大量绘制细节,其API设计思维十分贴合现实场景,因此容易理解和使用。

在现实生活中我们要拍摄一张照片,必要的元素是:场景&相机。其中前者是由拍摄对象(人、动植物、建筑物等)和光照(阳光/灯光、亮度、角度)构成的;而后者则需要确定拍摄的视角位置,视线方向等因素。这些变量确定下来后按下快门,就得到一张照片最终的呈现效果

image

现实中的拍摄.png

Three.js的思维亦是类似:要渲染一帧静态的3D图像,需要明确场景(模型+光照)+ 相机(位置、方向、投影方式),最后使用渲染器“按下快门”即可

image

Three.js 3D图像渲染.png

另外,如果我们需要做3D动画,在静态图像基础上设置定时器,使得图像逐帧渐变渲染即可。再如果我们想要通过鼠标等对3D图形进行旋转缩放等操作,则引入Three.js的控件,它会监听相关事件并自动完成图形的视觉变化。

重要概念

个人认为顶点、几何体、材质、模型是初学Three.js时最容易造成错乱的基本概念,后面的Demo也会提到,所以先简单介绍一下:

顶点&几何体

顶点就是一系列的三维坐标,每个三维坐标代表一个点;

几何体其实就是顶点的集合,因为顶点可以勾勒出物体的几何轮廓。

Three.js中创建几何体主要有以下方式:

  1. 使用内置的几何体(立方体、球体等基本形状)

  2. 外部导入(使用3D建模软件的文件数据,以构建复杂形状)

  3. 自定义顶点坐标集合

image

材质

所谓材质,简单地说就是字面意思,呈现几何体表面效果(比如塑料材质、金属材质等)的设置。通过材质我们可以赋予一个物体特定的颜色、透明度、纹理等效果

模型

模型 = 几何体 + 材质,我理解可以认为是Three.js世界中真实存在的物体,不过模型分为若干种类(点模型、线模型、网格模型等),同样的几何体+材质,如果选择生成不同类型的模型,会得到不同的渲染效果,如下所示

image

材质和模型的对应关系

使用材质的时候,要注意材质和模型的对应关系,需要按正确的关系进行搭配

image

快速上手:可旋转缩放的简单3D图形

image

API可查找官方文档

我们可以通过script或npm的方式在项目中引入three.js,前者需要下载源码仓库或者找到可用的CDN,后者则普通地安装npm包即可:

$ npm i three
$ npm i -D @types/three

核心源码

import * as React from "react";
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
import "./styles.css";
const { useEffect } = React;

const render3d = () => {
  /**
   * 创建场景对象Scene
   */
  var scene = new THREE.Scene();

  /**
   * 创建并添加网格模型到场景
   */
  // 立方体
  var geometry1 = new THREE.BoxGeometry(100, 100, 100);
  // 材质
  var material1 = new THREE.MeshLambertMaterial({
    color: 0x0000ff
  });
  // 网格模型
  var mesh1 = new THREE.Mesh(geometry1, material1); //网格模型对象Mesh
  scene.add(mesh1); //网格模型添加到场景中

  // 球体
  var geometry2 = new THREE.SphereGeometry(60, 40, 40);
  // 材质
  var material2 = new THREE.MeshPhongMaterial({
    color: 0xffff00,
    specular: 0x4488ee,
    shininess: 12
  });
  // 网格模型
  var mesh2 = new THREE.Mesh(geometry2, material2); //网格模型对象Mesh
  mesh2.translateY(120); //球体网格模型沿Y轴正方向平移120
  scene.add(mesh2);

  /**
   * 设置光源并添加到场景
   */
  //点光源
  var point = new THREE.PointLight(0x444444);
  point.position.set(100, 100, 100); //点光源位置
  scene.add(point); //点光源添加到场景中
  //环境光
  var ambient = new THREE.AmbientLight(0x444444);
  scene.add(ambient);

  /**
   * 相机设置
   */
  var width = window.innerWidth; //窗口宽度
  var height = window.innerHeight; //窗口高度
  var k = width / height; //窗口宽高比
  var s = 300; //三维场景显示范围控制系数,系数越大,显示的范围越大
  //创建相机对象
  var camera = new THREE.OrthographicCamera(-s * k, s * k, s, -s, 1, 1000);
  camera.position.set(250, 300, 200); //设置相机位置
  camera.lookAt(scene.position); //设置相机方向(指向的场景对象)

  /**
   * 创建渲染器对象并进行渲染(传入场景+相机)
   */
  var renderer = new THREE.WebGLRenderer();
  renderer.setSize(width, height); //设置渲染区域尺寸
  renderer.setClearColor(0xb9d3ff, 1); //设置背景颜色
  document.body.appendChild(renderer.domElement); //body元素中插入canvas对象
  function render() {
    renderer.render(scene, camera); //执行渲染操作
  }
  render();
  var controls = new OrbitControls(camera, renderer.domElement); //创建控件对象
  controls.addEventListener("change", render); //监听鼠标、键盘事件
};

export default function App() {
  useEffect(() => {
    render3d();
  }, []);
  return null;
}

Demo:3D粒子波浪

image.png 这个Demo相比快速上手的例子,特别的点在于:

  1. 采用了点模型来模拟粒子,而非网格模型

  2. 使用自定义着色器材质来得到点模型的球状效果

  3. 动画效果

import * as React from "react";
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
import Stats from "three/examples/jsm/libs/stats.module.js";
import "./styles.css";
const { useEffect } = React;

const render3d = () => {
  const SEPARATION = 100;
  const AMOUNTX = 50;
  const AMOUNTY = 50;

  let container;
  let camera: THREE.PerspectiveCamera;
  let scene: THREE.Scene;
  const stats = Stats();
  const renderer = new THREE.WebGLRenderer({ antialias: true });
  let particles: THREE.Points;

  let count = 0;
  let mouseX = 0;
  let mouseY = 0;

  let windowHalfX = window.innerWidth / 2;
  let windowHalfY = window.innerHeight / 2;

  function init() {
    container = document.createElement("div");
    document.body.appendChild(container);

    camera = new THREE.PerspectiveCamera(
      75,
      window.innerWidth / window.innerHeight,
      1,
      10000
    );
    camera.position.z = 1000;

    scene = new THREE.Scene();

    const numParticles = AMOUNTX * AMOUNTY;

    const positions = new Float32Array(numParticles * 3);
    const scales = new Float32Array(numParticles);

    let i = 0;
    let j = 0;

    for (let ix = 0; ix < AMOUNTX; ix++) {
      for (let iy = 0; iy < AMOUNTY; iy++) {
        positions[i] = ix * SEPARATION - (AMOUNTX * SEPARATION) / 2; // x
        positions[i + 1] = 0; // y
        positions[i + 2] = iy * SEPARATION - (AMOUNTY * SEPARATION) / 2; // z

        scales[j] = 1;

        i += 3;
        j++;
      }
    }
    const geometry = new THREE.BufferGeometry();
    geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
    geometry.setAttribute("scale", new THREE.BufferAttribute(scales, 1));

    const vertexShader = `
    attribute float scale;

    void main() {

      vec4 mvPosition = modelViewMatrix * vec4( position, 1.0 );

      gl_PointSize = scale * ( 300.0 / - mvPosition.z );

      gl_Position = projectionMatrix * mvPosition;

    }
    `;
    const fragmentShader = `
    uniform vec3 color;

    void main() {

      if ( length( gl_PointCoord - vec2( 0.5, 0.5 ) ) > 0.475 ) discard;

      gl_FragColor = vec4( color, 1.0 );

    }
    `;
    const material = new THREE.ShaderMaterial({
      uniforms: {
        color: { value: new THREE.Color(0x00ffff) }
      },
      vertexShader,
      fragmentShader
    });

    particles = new THREE.Points(geometry, material);
    scene.add(particles);

    renderer.setPixelRatio(window.devicePixelRatio);
    renderer.setSize(window.innerWidth, window.innerHeight);
    container.appendChild(renderer.domElement);

    container.appendChild(stats.dom);

    container.style.touchAction = "none";

    function onPointerMove(event: PointerEvent) {
      if (event.isPrimary === false) return;

      mouseX = event.clientX - windowHalfX;
      mouseY = event.clientY - windowHalfY;
    }
    container.addEventListener("pointermove", onPointerMove);

    //
    function onWindowResize() {
      windowHalfX = window.innerWidth / 2;
      windowHalfY = window.innerHeight / 2;

      camera.aspect = window.innerWidth / window.innerHeight;
      camera.updateProjectionMatrix();

      renderer.setSize(window.innerWidth, window.innerHeight);
    }
    window.addEventListener("resize", onWindowResize);
  }

  function render() {
    camera.position.x += (mouseX - camera.position.x) * 0.05;
    camera.position.y += (-mouseY - camera.position.y) * 0.05;
    camera.lookAt(scene.position);

    const positions = particles.geometry.attributes.position.array;
    const scales = particles.geometry.attributes.scale.array;

    let i = 0,
      j = 0;

    for (let ix = 0; ix < AMOUNTX; ix++) {
      for (let iy = 0; iy < AMOUNTY; iy++) {
        positions[i + 1] =
          Math.sin((ix + count) * 0.3) * 50 + Math.sin((iy + count) * 0.5) * 50;

        scales[j] =
          (Math.sin((ix + count) * 0.3) + 1) * 20 +
          (Math.sin((iy + count) * 0.5) + 1) * 20;

        i += 3;
        j++;
      }
    }

    particles.geometry.attributes.position.needsUpdate = true;
    particles.geometry.attributes.scale.needsUpdate = true;

    renderer.render(scene, camera);

    count += 0.1;
  }

  function animate() {
    requestAnimationFrame(animate);
    render();
    stats.update();
  }

  init();
  animate();
};

export default function App() {
  useEffect(() => {
    render3d();
  }, []);
  return null;
}

其它Three.js Demo:

threejs.org/examples/#w…

参考文档

OpenGL是什么:www.jianshu.com/p/d6694ccc5…

WebGL & Three.js工作原理:mp.weixin.qq.com/s/X17M-OC\_…

顶点着色器&片元着色器:www.jianshu.com/p/1122b46a1…

GLSL 着色器:developer.mozilla.org/zh-CN/docs/…

Three.js零基础入门:www.yanhuangxueyuan.com/Three.js/

Three.js官方文档:threejs.org/docs/index.…