2022- 现代前端如何入门3D 开发
前言
随着

web3D 简介
Web 图形API 的发展
正是因为

传统的<canvas>
标签,它调用绘图指令直接在页面上绘制图形,并且表现力深入到了像素级。但到这里还只在二维的世界里,直到
相对于
今年我们可能多多少少听说过
简单回顾后我们可以看到,与传统游戏类图形开发相比,
引擎

有了这些图形
常见引擎
目前市面上流行的引擎可以分为
在
在
Oasis Engine
Oasis Engine 目前已在 github 上开源两年,
针对以上痛点,
搭建静态场景
了解
模型
搭建场景首先会想到需要模型,就像我们前端也需要美术给素材才有图可切。工作中模型一般是由美术在建模软件中制作并导出,我们可以先从 sketchfab 下载一个现成模型。
与图片界的主流传输格式是
场景 实体 组件
把

- 引擎:引擎扮演着总控制器的角色,它能够控制画布(支持跨平台)的一切行为,包括后面我们要说的资源管理、场景管理、引擎的执行
/ 暂停/ 继续、创建实体等功能。 - 场景:通俗来说就是一个空舞台,一个引擎可以有多个场景,但只有一个场景是激活的。
- 实体:如果把场景当作一个空舞台,那么实体就是舞台上的演员。在场景中有唯一
id 表示,可以被创建、被销毁,可以添加组件、删除组件。 - 组件:为实体提供各种能力,如果想让一个实体变成一个相机,只需要在该实体上添加相机组件。
基于组件进行架构的系统,组合优先于继承。比如希望一个实体既可以发光也可以出声,那么添加灯光组件和声音组件就能做到了。这种方式非常适合互动这种复杂度高的业务——特定功能只增加一个组件即可,便于扩展。
那么如何把这个模型显示在网页上呢?我们需要首先激活引擎,添加场景,然后调用引擎的资源管理器直接加载
// 初始化引擎
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));
实际上相机是一个概念的抽象,我们把三维空间内的场景变换到屏幕画布的这个三维投影的过程抽象成为相机。

想象三维空间中有一个点是相机,从这个点放射出去形成区域,我们规定的能看到的最远距离到远裁剪面,最近距离是到近裁剪面,在两者之间的形状就是所谓视锥体,我们在屏幕上看到的就是视锥体范围内的物体,不在视锥体范围内的物体被剪裁,不会呈现在屏幕上。除了近平面、远平面,影响这个视锥体形状还有视角
有正交投影相机和透视投影相机两种。透视投影跟人眼看到的世界是一样的,近大远小;刚从的视锥体也是根据这个来讲的。正交投影则远近都是一样的大小,三维空间中平行的线,投影到二维空间也一定是平行的。大部分场景都适合使用透视相机。

灯光
跟之前类似,我们直接在场景中创建实体,实体上添加灯光组件,调整灯光颜色、强度等参数。这样就有了光。
// 添加方向光
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;
灯光可以分为两大类,一种是直接照明,有平行光,点光源,聚光灯这三种。另一种是间接光照——环境光。

一般场景只需要使用默认的环境光就可以了,如果环境光无法满足需求,可以适当添加平行光和点光源来补充光照细节。出于性能考虑,尽量不要超过

变换
一般来说场景中不止有一个模型,那么如何摆放多个模型以达成目标效果呢?

为了描述这些模型的位置,引入坐标系,我们使用右手坐标系。坐标系的原点位于渲染画布的几何中心。对于物体位置的描述,指的是物体的几何中心的位置。空间单位我们可以简单的理解为
对于每一个实体来说,我们都需要知道它的位置,一般来说在创建一个新的实体时,都会给这个实体自动添加变换组件。变换组件能够对实体的位移,旋转,缩放等进行操作,完成想要的几何变换。经过这一系列操作我们就把模型移动到了想要的位置。

这个时候看起来还是不够生动,那么除了调整灯光,我们也可以考虑物体和光的关系,就是模型的材质上打主意。
网格 材质

模型是由两部分构成的,网格和材质。引擎中网格渲染器组件很好的解释了这点。如果要实现目标形状,需要创建实体,添加网格渲染器,指定这个渲染器的形状、材质。
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 材质:不是基于物理的渲染,但也能在一定程度上体现出光照。
材质决定了物体和光的关系,纹理作为材质的一个重要属性,决定了模型身上的图案。图片、
对于当前场景从平衡总体效果和性能的角度,可以局部使用
场景背景
另一个对视觉效果有极大改变的是场景背景。
背景可以是纯色的,也可以是天空模式,天空可以是通过

在天空盒纹理和全景图中就储存了环境光照信息,如果读取其中的环境光信息并赋给场景,那么就开启了

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


让它动起来!
动画
场景怎么能动起来呢?首先能想到的是模型自带动画。
给导入的
engine.resourceManager
.load<GLTFResource>("*.gltf")
.then((asset) => {
const { defaultSceneRoot } = asset;
rootEntity.addChild(defaultSceneRoot);
const animator = defaultSceneRoot.getComponent(Animator);
animator.play("run");
})
骨骼动画,也叫蒙皮动画,是游戏、影视中最常用的动画技术。它包括骨骼和蒙皮两部分数据,互相连接的骨骼组成骨架结构,通过改变骨骼的朝向和位置来生成动画。形变动画是指在变化中几何对象拓扑关系保持不变的一种动画,目前在数字人捏脸上应用的非常多。
以上多种动画在引擎中都支持播放。如果一个模型上有多个动画,还可以通过状态机组织动画片段进行编排,实现更加灵活丰富的动画效果。

脚本
除了默认动画的播放外,如果想让场景中的角色进行移动,要怎么办呢?
这时候就需要用到脚本组件。和往常一样,我们要创建自己的脚本组件,编写互动逻辑,将它添加到角色实体上。
脚本组件非常强大,它扩展自引擎提供的 Script
基类,可以通过它来写任何想要的功能。它提供了非常丰富的生命周期钩子函数,只要调用特定的回调函数,引擎就会在特定的时期自动执行相关脚本,不需要手工调用。可以类比
以最常用的生命周期回调函数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
这类涉及实体的的状态变更的生命周期回调函数外,还有和鼠标键盘输入相关的回调,以及和场景相关的回调。在使用时可以查看官方文档,有非常详细的说明。

物理系统
脚本组件让我们拥有了操控三维世界的强大能力,但是目前实体还是在自行移动,和我们没有产生交互。如果想让角色跟随我们在屏幕上的点击,点到哪里移到哪里,要怎么办呢?
这里就需要了解一下和交互密不可分的引擎组成部分——物理系统。引入物理系统的最大的好处是使得场景中的物体有了物理响应。这么说可能有点抽象,翻译成代码是引入引擎提供的物理系统,在希望有物理响应的实体上增加碰撞器组件,并指定这个组件的形状。
import { LitePhysics } from "@oasis-engine/physics-lite";
const engine = new WebGLEngine("canvas");
engine.physicsManager.initialize(LitePhysics);
const boxCollider = boxEntity.addComponent(StaticCollider);
boxCollider.addShape(physicsBox);

当两个都有碰撞器组件的实体发生接触时,两者会根据物理定律改变原先的运动。比如图中这些椅子,在落下后撞击其他的椅子,它们会弹开翻滚等等,改变了原来运动的方向、速度。还可以触发脚本里的回调函数onCollisionEnter
onCollisionStay
onCollisionExit
,比如指定在它们两个碰撞时改变颜色,碰撞结束恢复颜色。
引擎提供了PhysX
和litePhysics
两种物理系统,PhysX
功能强大但体积也相应较大;litePhysics
则是量级轻功能简单,可以按需选择。
交互
如果想让角色跟随屏幕上的点击,点到哪里移到哪里,要怎么办呢?
这是一个最为常见的人机交互需求,可以把它简化成两步。第一步:屏幕上的点击转化为三维空间中的点。第二步:物体移动到这个目标点。相信通过对于脚本的了解,已经可以轻松完成第二步了。那么二维的点是如何转化到维空间中呢?

主要依靠的是射线检测的方式,即调用相机组件的screenPointToRay
方法,把取屏幕接收到坐标信息转换为三维空间中的一条射线。当它穿过有碰撞体组件的实体时,可以通过射线的方向、到碰撞体的距离获得两者交点,从而转化为三维空间中的点。
对于这个场景来说,就是给地面实体一个平面形状的碰撞体,监听点击事件,获取射线在地面的交点,从而让达到指哪打哪。

小结
对这个搭建好的场景来说,想让实体位置变换,比如金币道具一直旋转,那么可以给金币所在实体添加一个脚本组件,让它每一帧都转过一定角度;想让主角到达屏幕上点击的位置,可以利用射线检测和物理系统中的碰撞体进行实现;想让主角达到位置后有一个特效,可以在这个时机播放模型中的动画。

在此基础上已经可以延伸出一个完整的互动类游戏。这个游戏有待机、对局中、对局结束三种关键状态。不同的状态下,场景中角色、道具状态、能触发的事件是不同的。这就是一个简单的用以编写游戏逻辑的状态机的概念。比如在对局中角色才是奔跑的,待机和结束都在原地。金币在开始的时候不移动,在对局中才会生成、能被吃掉。

工作流
前面提到了不少次美术,那么我们前端和美术各自的职责是什么,整个
工作流

工作流的起点一般从原画开始,原画指的是手绘的描述角色关键造型、动作的画。
经过加工后变为用以搭建场景的素材。
素材的使用方式按照是否有编辑器不同。如果引擎有配套编辑器,如
各种素材组合成场景后,添加脚本编写游戏逻辑。一切完成后项目发布。
编辑器

结语
我们了解了