现代前端如何入门 3D 开发

avatar
花呗借呗前端团队 @蚂蚁集团

前言

随着 3D 技术在 web 领域的应用,带来了用户体验质的飞跃。前端作为业务的主力军,3D 技术的进步也在不断塑造前端业务新形态。精细的 3D 仿真模型展示让用户不在现场也能了解产品细节,AR/VR 技术在住房家装乃至医疗诊断的广泛应用,使得虚拟试衣、远程诊断成为我们日常使用的功能。
image.png

web3D 简介

Web图形API的发展

正是因为 Web 图形 API 的发展,才让我们在 web 端就有了操纵图形的能力,才能做出这么多有趣新奇的互动产品。
image.png
传统的 HTML 和 CSS 可以说是应用最广泛的图形技术了,但是对于复杂图形来说,它维护成本很高,性能开销大。SVG 放大缩小不降低质量,一定程度上弥补了 HTML + CSS 不足,但它的最小单元是图形,而非像素,细节处理能力不足。canvas2D 就是我们今天所熟知的 <canvas> 标签,它调用绘图指令直接在页面上绘制图形,并且表现力深入到了像素级。但到这里还只在二维的世界里,直到 2011 年发布的 WebGL 的 1.0 标准规范。
相对于 canvas2D,WebGL 不仅仅是只增加了一个 z 轴,而是更加底层的图形编程技术。WebGL 是基于openGL 在浏览器实现,利用了 GPU 并行处理的特性,可以渲染各种复杂的 2D 和 3D 图形。这也让它在处理大量数据展现的时候,性能大大优于之前。
今年我们可能多多少少听说过 WebGPU,它可以看作是下一代 WebGL。WebGL 是基于 openGL 在浏览器中实现。而 openGL 在 GPU 届可以是一个老古董了,现代 GPU 硬件技术的飞速进步无法在它身上反应。因此苹果提出了基于 Vulkan、Metal 和 Direct3D 12 的 WebGPU,今年据说会推出 1.0 版本。
简单回顾后我们可以看到,与传统游戏类图形开发相比,Web端更多的是技术栈的不同,图形底端接口和实现技术是相同的。

引擎

image.png
有了这些图形 API,前端已经具备了操纵图形的能力。可能大家看过《WebGL 编程指南》,翻开第一章发现绘制一个三角形居然要写 180 多行代码。这说明普通的开发人员直接通过图形库进行开发是非常困难,这时候就需要借助引擎了。就像我们前端会调用的各种库一样,可以把引擎看作一种对于图形的库,它提供各种工具,让图形呈现的更好、效率更高,为上层的开发团队提供了开发平台。

常见引擎

目前市面上流行的引擎可以分为 native 端和 web 端两大类。
在 native 端,Unity 是我们比较熟悉的老牌游戏引擎;cocos 2D-3D 主要用于小游戏,可以说是国内 2D 小游戏的首选;Laya 则主打 3D 类小游戏。目前微信小游戏的游戏引擎以 Laya、Cocos 为主。
在 web 端比较常见是 three.js。它面向前端,最早微信小游戏中的跳一跳就是借助于 three.js。但它缺乏应对复杂游戏场景的能力,更接近一个 3D 渲染框架。 2D 方面有 pixi.js,是一个轻量的渲染框架。以及 Oasis Engine,它面向前端,有完备的互动功能。

Oasis Engine

Oasis Engine 目前已在 github 上开源两年,star 数接近3k,经过亿万级的业务考验。今年初支付宝五福的空中写福、AR 打年兽就是由 Oasis Engine 支持开发。
Oasis Engine 面向移动端业务,面向前端工程师。随着移动端业务的爆发,涌现出许多新的互动需求,而旧有框架无法满足。市面上面向 web 端的图形引擎互动功能上相对缺失,没有对前端性能优化,很难跨平台运行。
针对以上痛点,Oasis Engine 定位于一个功能全面的引擎。架构上采用组件化,能提供多样互动功能应对业务复杂度。能够一次开发到处运行。

搭建静态场景

了解 3D 技术和前端的渊源、引擎的概念之后,我们先搭建一个简单的场景。

模型

搭建场景首先会想到需要模型,就像我们前端也需要美术给素材才有图可切。工作中模型一般是由美术在建模软件中制作并导出,我们可以先从 sketchfab 下载一个现成模型。
与图片界的主流传输格式是 png/jpg 类似,web3D 的主流格式是 glTF(GL Transmission Format)。这是由制定开放行业标准的行业协会 Khronos 发布的一种能高效传输和加载 3D 场景的规范,其功能涵盖了 FBX(动画)、OBJ(模型)等传统模型格式,基本支持 3D 场景中的所有特性。

场景 实体 组件

把 glTF 模型展示在网页之前,先介绍一些3D领域的通用概念。
image.png

  • 引擎:引擎扮演着总控制器的角色,它能够控制画布(支持跨平台)的一切行为,包括后面我们要说的资源管理、场景管理、引擎的执行/暂停/继续、创建实体等功能。
  • 场景:通俗来说就是一个空舞台,一个引擎可以有多个场景,但只有一个场景是激活的。
  • 实体:如果把场景当作一个空舞台,那么实体就是舞台上的演员。在场景中有唯一id表示,可以被创建、被销毁,可以添加组件、删除组件。
  • 组件:为实体提供各种能力,如果想让一个实体变成一个相机,只需要在该实体上添加相机组件。

基于组件进行架构的系统,组合优先于继承。比如希望一个实体既可以发光也可以出声,那么添加灯光组件和声音组件就能做到了。这种方式非常适合互动这种复杂度高的业务——特定功能只增加一个组件即可,便于扩展。

那么如何把这个模型显示在网页上呢?我们需要首先激活引擎,添加场景,然后调用引擎的资源管理器直接加载 glTF 模型。

// 初始化引擎
const engine = new WebGLEngine(“canvas");

// 激活场景
const scene = engine.sceneManager.activeScene;
const rootEntity = scene.createRootEntity();

// 加载模型
engine.resourceManager
.load<GLTFResource>(
  “*.gltf"
)
.then((gltf) => {
  const { defaultSceneRoot } = gltf;
  rootEntity.addChild(defaultSceneRoot);
})

engine.run();

这个时候场景还是一团漆黑,因为还缺乏一些必不可少的组件——我们还没有给这个场景一个观看的眼睛。

相机

就像刚才讲基于组件的架构一样,我们可以给场景增加一个实体,该实体添加相机组件,调整相机实体的位置、朝向,模型就出现在了画面中了。

// 添加相机
const cameraEntity = rootEntity.createChild("camera");
const camera = cameraEntity.addComponent(Camera);
cameraEntity.transform.setPosition(15, 9, 15);
cameraEntity.transform.lookAt(new Vector3(0, 0, 0));

实际上相机是一个概念的抽象,我们把三维空间内的场景变换到屏幕画布的这个三维投影的过程抽象成为相机。
image.png
想象三维空间中有一个点是相机,从这个点放射出去形成区域,我们规定的能看到的最远距离到远裁剪面,最近距离是到近裁剪面,在两者之间的形状就是所谓视锥体,我们在屏幕上看到的就是视锥体范围内的物体,不在视锥体范围内的物体被剪裁,不会呈现在屏幕上。除了近平面、远平面,影响这个视锥体形状还有视角 FOV,即在竖直方向上的夹角;以及呈象屏幕的高宽比。
有正交投影相机和透视投影相机两种。透视投影跟人眼看到的世界是一样的,近大远小;刚从的视锥体也是根据这个来讲的。正交投影则远近都是一样的大小,三维空间中平行的线,投影到二维空间也一定是平行的。大部分场景都适合使用透视相机。
image.png

灯光

跟之前类似,我们直接在场景中创建实体,实体上添加灯光组件,调整灯光颜色、强度等参数。这样就有了光。

// 添加方向光
const lightEntity = rootEntity.createChild("Light");
const directLight = lightEntity.addComponent(DirectLight);
directLight.color.set(0.8, 0.8, 1, 1);
directLight.intensity = 2;
lightEntity.transform.setRotation(-45, 0, 0);

// 设置环境光
const ambientLight = scene.ambientLight;
ambientLight.diffuseSolidColor.set(0.8, 0.8, 1, 1);
ambientLight.diffuseIntensity = 0.5;

灯光可以分为两大类,一种是直接照明,有平行光,点光源,聚光灯这三种。另一种是间接光照——环境光。
image.png
一般场景只需要使用默认的环境光就可以了,如果环境光无法满足需求,可以适当添加平行光和点光源来补充光照细节。出于性能考虑,尽量不要超过 4 个直接光。
image.png

变换

一般来说场景中不止有一个模型,那么如何摆放多个模型以达成目标效果呢?
image.png
为了描述这些模型的位置,引入坐标系,我们使用右手坐标系。坐标系的原点位于渲染画布的几何中心。对于物体位置的描述,指的是物体的几何中心的位置。空间单位我们可以简单的理解为 1 = 1m,它是为了和建模软件统一,并不是屏幕上的实际大小。
对于每一个实体来说,我们都需要知道它的位置,一般来说在创建一个新的实体时,都会给这个实体自动添加变换组件。变换组件能够对实体的位移,旋转,缩放等进行操作,完成想要的几何变换。经过这一系列操作我们就把模型移动到了想要的位置。
image.png
这个时候看起来还是不够生动,那么除了调整灯光,我们也可以考虑物体和光的关系,就是模型的材质上打主意。

网格 材质

image.png
模型是由两部分构成的,网格和材质。引擎中网格渲染器组件很好的解释了这点。如果要实现目标形状,需要创建实体,添加网格渲染器,指定这个渲染器的形状、材质。

const unlitEntity = rootEntity.createChild("unlit");
const unlitRenderer = unlitEntity.addComponent(MeshRenderer);
const unlitMaterial = new UnlitMaterial(engine);
unlitRenderer.mesh = PrimitiveMesh.createSphere(engine, 1, 64);
unlitRenderer.setMaterial(unlitMaterial);

引擎提供了常见几何形状,比如球体、锥体、胶囊体等。引擎中也内置了三种经典材质:Unlit、Blinn-Phong、PBR。

  • Unlit 材质:仅使用颜色与纹理渲染,不计算光照。
  • PBR 材质:Physically Based Rendering 的缩写。遵循能量守恒,符合物理规则,渲染效果真实。
  • Blinn-Phong 材质:不是基于物理的渲染,但也能在一定程度上体现出光照。

材质决定了物体和光的关系,纹理作为材质的一个重要属性,决定了模型身上的图案。图片、canvas 画布、原始数据、视频等都可以用来当作纹理。
对于当前场景从平衡总体效果和性能的角度,可以局部使用 PBR 材质,大部分使用 Unlit 材质。

场景背景

另一个对视觉效果有极大改变的是场景背景。
背景可以是纯色的,也可以是天空模式,天空可以是通过6个纹理组成的立方体纹理形成的天空盒,也可以是 HDR 全景图。
image.png
在天空盒纹理和全景图中就储存了环境光照信息,如果读取其中的环境光信息并赋给场景,那么就开启了 IBL 模式。一般使用 PBR (image-based Lighting)材质时候,我们不会使用纯色模式,而是使用一张 HDR 贴图用作环境反射,更好的体现 PBR 材质的效果。
image.png

小结

经过以上几步已经快速搭建出一个场景了。
在展示类项目中,滑动屏幕绕展品进行观看的需求非常典型。按照基于组件架构的思路,可以推断出是给带有相机的实体增加了能够控制该实体位置的组件。如图所示,orbitControl 是一个常见的相机控件,我们给相机实体增加了这一组件,进而拥有了这一功能。
image.png
RunningScene7.gif

让它动起来!

动画

场景怎么能动起来呢?首先能想到的是模型自带动画。
给导入的 glTF 模型增加一个动画组件,选择播放的动画片段的名称,模型就可以动起来了。可以指定它仅播放一次,还是循环播放;修改它播放的速度等。

  engine.resourceManager
    .load<GLTFResource>("*.gltf")
    .then((asset) => {
      const { defaultSceneRoot } = asset;
      rootEntity.addChild(defaultSceneRoot);
      const animator = defaultSceneRoot.getComponent(Animator);
      animator.play("run");
  })

3D 开发中会接触到的动画基本可以分为 2D 和 3D 动画两类。2D 包括 spine、lottie,3D 动画可以分为骨骼动画和形变动画两种。
骨骼动画,也叫蒙皮动画,是游戏、影视中最常用的动画技术。它包括骨骼和蒙皮两部分数据,互相连接的骨骼组成骨架结构,通过改变骨骼的朝向和位置来生成动画。形变动画是指在变化中几何对象拓扑关系保持不变的一种动画,目前在数字人捏脸上应用的非常多。
以上多种动画在引擎中都支持播放。如果一个模型上有多个动画,还可以通过状态机组织动画片段进行编排,实现更加灵活丰富的动画效果。
RunningScene.gif

脚本

除了默认动画的播放外,如果想让场景中的角色进行移动,要怎么办呢?
这时候就需要用到脚本组件。和往常一样,我们要创建自己的脚本组件,编写互动逻辑,将它添加到角色实体上。
脚本组件非常强大,它扩展自引擎提供的 Script 基类,可以通过它来写任何想要的功能。它提供了非常丰富的生命周期钩子函数,只要调用特定的回调函数,引擎就会在特定的时期自动执行相关脚本,不需要手工调用。可以类比 React 的生命周期进行理解。
以最常用的生命周期回调函数onUpdate为例, 它在每一帧渲染前调用。如果想让物体的行为、状态和方位每一帧都更新,那么这些操作可以放在onUpdate中。

class HeroScript extends Script {
  /**
  * The main loop, called frame by frame.
  * @param deltaTime - The deltaTime when the script update.
  */
  onUpdate(deltaTime: number): void {
    this.entity.transform.translate(0.1,0,0.1)
  }
}

heroEntity.addComponent(HeroScript)

onUpdate这类涉及实体的的状态变更的生命周期回调函数外,还有和鼠标键盘输入相关的回调,以及和场景相关的回调。在使用时可以查看官方文档,有非常详细的说明。
RunningScene3.gif

物理系统

脚本组件让我们拥有了操控三维世界的强大能力,但是目前实体还是在自行移动,和我们没有产生交互。如果想让角色跟随我们在屏幕上的点击,点到哪里移到哪里,要怎么办呢?
这里就需要了解一下和交互密不可分的引擎组成部分——物理系统。引入物理系统的最大的好处是使得场景中的物体有了物理响应。这么说可能有点抽象,翻译成代码是引入引擎提供的物理系统,在希望有物理响应的实体上增加碰撞器组件,并指定这个组件的形状。

import { LitePhysics } from "@oasis-engine/physics-lite";

const engine = new WebGLEngine("canvas");
engine.physicsManager.initialize(LitePhysics);

const boxCollider = boxEntity.addComponent(StaticCollider);
boxCollider.addShape(physicsBox);

物理.gif
当两个都有碰撞器组件的实体发生接触时,两者会根据物理定律改变原先的运动。比如图中这些椅子,在落下后撞击其他的椅子,它们会弹开翻滚等等,改变了原来运动的方向、速度。还可以触发脚本里的回调函数onCollisionEnter onCollisionStay onCollisionExit,比如指定在它们两个碰撞时改变颜色,碰撞结束恢复颜色。
引擎提供了PhysXlitePhysics两种物理系统,PhysX功能强大但体积也相应较大;litePhysics则是量级轻功能简单,可以按需选择。

交互

如果想让角色跟随屏幕上的点击,点到哪里移到哪里,要怎么办呢?
这是一个最为常见的人机交互需求,可以把它简化成两步。第一步:屏幕上的点击转化为三维空间中的点。第二步:物体移动到这个目标点。相信通过对于脚本的了解,已经可以轻松完成第二步了。那么二维的点是如何转化到维空间中呢?
image.png
主要依靠的是射线检测的方式,即调用相机组件的screenPointToRay方法,把取屏幕接收到坐标信息转换为三维空间中的一条射线。当它穿过有碰撞体组件的实体时,可以通过射线的方向、到碰撞体的距离获得两者交点,从而转化为三维空间中的点。
对于这个场景来说,就是给地面实体一个平面形状的碰撞体,监听点击事件,获取射线在地面的交点,从而让达到指哪打哪。
RunningScene4.gif


小结

对这个搭建好的场景来说,想让实体位置变换,比如金币道具一直旋转,那么可以给金币所在实体添加一个脚本组件,让它每一帧都转过一定角度;想让主角到达屏幕上点击的位置,可以利用射线检测和物理系统中的碰撞体进行实现;想让主角达到位置后有一个特效,可以在这个时机播放模型中的动画。
image.png
在此基础上已经可以延伸出一个完整的互动类游戏。这个游戏有待机、对局中、对局结束三种关键状态。不同的状态下,场景中角色、道具状态、能触发的事件是不同的。这就是一个简单的用以编写游戏逻辑的状态机的概念。比如在对局中角色才是奔跑的,待机和结束都在原地。金币在开始的时候不移动,在对局中才会生成、能被吃掉。

RunningScene6.gif

工作流

前面提到了不少次美术,那么我们前端和美术各自的职责是什么,整个 3D 类开发的工作流是怎样的呢?

工作流

image.png
工作流的起点一般从原画开始,原画指的是手绘的描述角色关键造型、动作的画。
经过加工后变为用以搭建场景的素材。2D 输出的素材一般是 2D 精灵图,3D 输出的素材一般是各种 3D 模型。
素材的使用方式按照是否有编辑器不同。如果引擎有配套编辑器,如 Unity,那么可以直接导入编辑器进行可视化编辑。如果没有编辑器,需要将资源上传至 CDN ,通过代码进行场景拼装。
各种素材组合成场景后,添加脚本编写游戏逻辑。一切完成后项目发布。

编辑器

image.png
Oasis Engine 有配套编辑器。它是在线网页编辑器,可以以所见即所得,以 low code 的方式快捷的搭建场景,编写互动逻辑。预计明年初对外开发,欢迎使用。

结语

我们了解了 3D 和前端的渊源,学习了 3D 开发的通用核心概念,搭建场景并且能用多种方式让场景动起来。掌握这些之后已经可以完成一般的互动类业务了。对 web3D 和互动开发有兴趣的同学可以浏览 Oasis Engine 官网和我们一起探索图形的世界。

如何联系我们

添加群管理员微信:zengxinxin2010

网站

官网地址
oasisengine.cn
Engine 源码地址
github.com/oasis-engin…
Engine Toolkit 源码地址
github.com/oasis-engin…
示例地址
github.com/JujieX/Orie…