QFramework 随笔

参考资料:

  1. Unity游戏框架搭建决定版 QFramework的二次开发
  2. “您好 我是QFramework”
  3. QFramework v1.0 使用指南
  4. 基础 | 三层架构与MVC模式

Introduction

QFramework.cs 提供了 MVC、分层、CQRS、事件驱动、数据驱动等工具,除了这些工具,QFramework.cs 还提供了架构使用规范。

QFramework 内置模块如下:

  • 0.Framework:核心架构(包含一套系统设计架构)

  • 1.CoreKit: 核心工具库、插件管理

  • 2.ResKit:资源管理套件(快速开发)

  • 3.UIKit:UI 管理套件(支持自动绑定、代码生成)

  • 4.Audio:音频方案

    MVC

    MVC模式是软件工程中常见的一种软件架构模式,该模式把软件系统(项目)分为三个基本部分:**模型(Model)、视图(View)和控制器(Controller)**使用此模式有诸多优势,例如:简化后期对项目的修改、扩展等维护操作;使项目的某一部分变得可以重复利用;使项目的结构更加直观。

    1. **视图(View):**负责界面的显示,以及与用户的交互功能。实际在Unity中,这一部分往往指 UI 的呈现。

    2. 控制器(Controller):可以理解为一个分发器,用来决定对于视图发来的请求(命令),需要用哪一个模型来处理,以及处理完后需要跳回(通过事件更改)到哪一个视图。即用来连接视图和模型。

    3. **模型(Model):**模型持有所有的数据、状态和程序逻辑。模型接受视图数据(的命令),并返回最终的处理结果(,触发事件)。

      img

CQRS 命令和查询责任分离

一种将数据存储的读取操作和更新操作分离的模式。

Query 是一个可选的概念,如果游戏中数据的查询逻辑并不是很重的话,直接在 Controller 的表现逻辑里写就可以了,但是查询数据比较重,或者项目规模非常大的话,最好是用 Query 来承担查询的逻辑。

img

事件驱动

在事件驱动编程中,系统的流程是由外部事件(如用户输入或外部数据更改)驱动的。程序会对发生的事件做出反应。其核心思想是定义并使用事件处理器

用户输入 => 事件响应 => 代码运行 => 刷新页面状态

InputSystem 帮助Unity开发者将用户输入抽象为事件

数据驱动

操作UI(用户输入)=> 触发事件 => 响应处理 => 更新数据 => 更新UI(呈现)

BindableProperty<T> 提供了快速的绑定

Framework

系统设计架构,核心概念包括Architecture、Command、Event、Model、System

Architecture

可以将Architecture视为一个项目模块的管理器(System)的集合, 省去创建大量零散管理器单例的麻烦

使用注册的方式将当前项目所使用的 模块 系统和 工具 添加进内部的IOC容器中,方便管理。

Controller

赋予 MonoBehaviour 脚本对象访问架构的能力

Model

同类的公共数据

架构规范与推荐用法

QFramework 架构提供了四个层级:

  • 表现层:IController
  • 系统层:ISystem
  • 数据层:IModel
  • 工具层:IUtility

通用规则

  • IController 更改 ISystem、IModel 的状态必须用Command
  • ISystem、IModel 状态发生变更后通知 IController 必须用事件或BindableProperty
  • IController可以获取ISystem、IModel对象来进行数据查询
  • ICommand、IQuery 不能有状态,
  • 上层可以直接获取下层,下层不能获取上层对象
  • 下层向上层通信用事件
  • 上层向下层通信用方法调用(只是做查询,状态变更用 Command),IController 的交互逻辑为特别情况,只能用 Command

表现层

ViewController 层。

IController接口,负责接收输入和状态变化时的表现,一般情况下,MonoBehaviour 均为表现层

  • 可以获取 System、Model
  • 可以发送 Command、Query
  • 可以监听 Event

系统层

System层

ISystem接口,帮助IController承担一部分逻辑,在多个表现层共享的逻辑,比如计时系统、商城系统、成就系统等

  • 可以获取 System、Model

  • 可以监听Event

  • 可以发送Event

数据层

IModel接口,负责数据的定义、数据的增删查改方法的提供

  • 可以获取 Utility
  • 可以发送 Event

工具层

Utility层

IUtility接口,负责提供基础设施,比如存储方法、序列化方法、网络连接方法、蓝牙方法、SDK、框架继承等。啥都干不了,可以集成第三方库,或者封装API

常用技巧

TypeEventSystem

独立的事件系统,可以理解为群发和广播,创建结构体传递事件内容

public struct PlayerDieEvent { }

一些简单的事件可以通过 .Register

Hands

最近Unity XRTK 终于加入了对手势追踪的支持,这里记录回顾一下相关的组件和类

This diagram illustrates the current Unity XR plug-in framework structure, and how it works with platform provider implementations.

Setup

为了能够正常使用手势追踪,需要XR相关的包升级。这里的 Editor 版本为 2021.3.20f1。

将XR相关的包升级

Upgrade packages

如果在包管理器中看不到升级的按钮,那么可以通过左上角“➕” -> “Add package by name“,分别输入:

com.unity.xr.handscom.unity.xr.interaction.toolkitcom.unity.xr.management 来完成升级。

注意:这些包可能对Editor版本有一定要求,具体可以查看对应说明:
XR Interaction Toolkit 2.3.1

XR Plugin Management 4.3.3

XR Hands 1.1.0

Config XR Plug-in Management

Edit -> Project Settings -> XR Plug-in Managerment 在对应平台下勾选 OpenXR

在对应平台下勾选 OpenXR

-> OpenXR 进入子菜单OpenXR,添加将使用的设备的预设,并勾选相关特性

添加将使用的设备的预设,并勾选相关特性

然后导入一些示例场景和预制体:

例如 XRTK 下的: Starter Assets, XR Device Simulator, Hands Interaction Demo

与 XR Hands 下的:HandVisualizer

在Project窗口中搜索 Complete XR Origin Hands Set Up,并将此预制体放入场景中,即可以运行场景查看效果。手和控制器的切换追随设备,并能实时体现在游戏中。 某些版本的XR组件下可能出现切换时Editor暂停,可以尝试更新组件。

将此预制体放入场景中

Access Hand Data

Refer to:Access hand data

有时候我们希望除了一些回调外,能获得手部更详实的位姿数据, 以实现网络同步等等更多的功能。

我们可以订阅 XRHandSubsystem,并获取数据,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
void Start()
{
XRHandSubsystem m_Subsystem =
XRGeneralSettings.Instance?
.Manager?
.activeLoader?
.GetLoadedSubsystem<XRHandSubsystem>();

if (m_Subsystem != null)
m_Subsystem.updatedHands += OnHandUpdate;
}

void OnHandUpdate(XRHandSubsystem subsystem,
XRHandSubsystem.UpdateSuccessFlags updateSuccessFlags,
XRHandSubsystem.UpdateType updateType)
{
switch (updateType)
{
case XRHandSubsystem.UpdateType.Dynamic:
// Update game logic that uses hand data
break;
case XRHandSubsystem.UpdateType.BeforeRender:
for (var i = XRHandJointID.BeginMarker.ToIndex();
i < XRHandJointID.EndMarker.ToIndex();
i++)
{
var trackingData = subsystem.rightHand .GetJoint(XRHandJointIDUtility.FromIndex(i));

if (trackingData.TryGetPose(out Pose pose))
{
// displayTransform is some GameObject's Transform component
Debug.Log($"[{nameof(XRHand)}]Joint{i} : {nameof(Position)} {pose.position} | {nameof(Rotate)} {pose.rotation}");
}
}
break;
}
}

General settings container used to house the instance of the active settings as well as the manager instance used to load the loaders with.

XR Loader abstract class used as a base class for specific provider implementations. Providers should implement subclasses of this to provide specific initialization and management implementations that make sense for their supported scenarios and needs.

此时可以看到,Joint点的数据已被正常获得并打印。

HandVisualizer

HandVisulizer 类控制着手势的渲染,将上述我们获得的Joints的空间位置与旋转翻译成SkinnedMeshRenderer的变化,渲染逻辑可见其OnUpdateHands()方法中,大致通过更新Joint的位置带动蒙皮的变换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
var originTransform = xrOrigin.Origin.transform;
var originPose = new Pose(originTransform.position, originTransform.rotation);

var wristPose = Pose.identity;
UpdateJoint(debugDrawJoints, velocityType, originPose, hand.GetJoint(XRHandJointID.Wrist), ref wristPose);
UpdateJoint(debugDrawJoints, velocityType, originPose, hand.GetJoint(XRHandJointID.Palm), ref wristPose, false);

for (int fingerIndex = (int)XRHandFingerID.Thumb;
fingerIndex <= (int)XRHandFingerID.Little;
++fingerIndex)
{
var parentPose = wristPose;
var fingerId = (XRHandFingerID)fingerIndex;

int jointIndexBack = fingerId.GetBackJointID().ToIndex();
for (int jointIndex = fingerId.GetFrontJointID().ToIndex();
jointIndex <= jointIndexBack;
++jointIndex)
{
if (m_JointXforms[jointIndex] != null)
UpdateJoint(debugDrawJoints, velocityType, originPose, hand.GetJoint(XRHandJointIDUtility.FromIndex(jointIndex)), ref parentPose);
}
}

Duplicate Hands

复制手部的运动其实也就是复制其骨骼的Transform

Notes of FEM Simulation of 3D Deformable Solids

Elasticity in three dimensions

Deformation map and deformation gradient

${\pmb{R}^3 = }$ all vectors with 3 real components.

${\pmb{R}^n = }$ all vectors with n real components.

When the object undergoes deformation, every material point $\vec{X}$ is being displaced to a new deformed location which is, by convention, denoted by a lowercase variable $\vec{x}$ The relation between each material point and its deformation function $\vec{\phi} : R^{3} \rightarrow R^{3} $.
$$
\vec{x} = \vec{\phi}(\vec{X})·1
$$
An important physical quantity derived directly from $\vec{\phi}(\vec{X})$, is the deformation gradient tensor ${ \pmb{F} \in \pmb{R^{3\times3}}}$

If we write ${\vec{X} = (X_1,X_2,X_3)^T} or{\vec{X} = (X,Y,Z)^T} $ and ${\vec{\phi}(\vec{X}) = (\vec{\phi_1}(\vec{X}),\vec{\phi_2}(\vec{X}),\vec{\phi_3}(\vec{X}))^T}$

注意是原每一组点根据三个对应关系被分别转换到三个分量上

for the three components of the vector-valued function ${\vec{\phi}}$, the deformation gradient is written as:
$$
\pmb{F}:= \frac{\partial(\phi_1,\phi_2,\phi_3)}{\partial({X_1,X_2,X_3})} = \left( \begin{matrix}\frac{\partial\phi_1}{\partial X_1} & \frac{\partial\phi_1}{\partial X_2} & \frac{\partial\phi_1}{\partial X_3}\
\frac{\partial\phi_2}{\partial X_1} & \frac{\partial\phi_2}{\partial X_2} & \frac{\partial\phi_2}{\partial X_3}\
\frac{\partial\phi_3}{\partial X_1} & \frac{\partial\phi_3}{\partial X_2} & \frac{\partial\phi_3}{\partial X_3}\end{matrix}\right)
$$
or, in index notation ${ F_{ij} = \phi_{i,j} }$ . In simple terms, the deformation gradient measures the amount of change in shape and size of a material body relative to its original configuration. The magnitude of the deformation gradient can be used to determine the amount of deformation or strain that has occurred, and its orientation can be used to determine the direction of deformation.

Note that, in general, $\pmb{F}$ will be spatially varying across ${\Omega}$, which is the volumetric domain occupied by the object. This domain will be referred to as the reference(or undefined configuration)

Strain energy and hyperelasticity

One of the consequences of elastic deformation is the accumulation of potential energy in the deformed body, which is referred to as strain energy ${E[\phi]}$ in the context of deformable solids. It is suggested that the energy is fully determined by the deformation map of a given configuration.

However intuitive, this statement nevertheless reflects a significant hypothesis that led to this formulation: we have assumed that the potential energy associated with a deformed configuration only depends on the final deformed shape, and not on the deformation path over time that brought the body into its current configuration.

The independence of the strain energy on the prior deformation history is a characteristic property of so-called hyperelastic materials. This property of is closely related with the fact that elastic forces of hyperelastic materials are conservative: the total work done by the internal elastic forces in a deformation path depends solely on the initial and final configurations, not the path itself.

Different parts of a deforming body undergo shape changes of different severity. As a consequence, the relation between deformation and strain energy is better defined on a local scale. We achieve that by introducing an energy density function ${\Psi[\phi;\vec{X}]}$ which measures the strain energy per unit undeformed volume on an infinitesimal domain ${dV}$ around the material point $\vec{X}$. We can then obtain the total energy for the deforming body by integrating the energy density function over the entire domain ${\Omega}$:
$$
E[\phi] = \int_\Omega\Psi[\phi;\vec{X}]d\vec{X}
$$

Introduction to VR Development

Introduction

Virtual reality (VR) is a simulated experience that employs pose tracking and 3D near-eye displays to give the user an immersive feel of a virtual world.

The equipment I am using now is Pico Neo3 based on PICO Unity Integration SDK

对于使用Unity开发, 其 XR Interactioin Toolkit 包已经提供了相当实用强力的脚本与抽象。本文会给出一些踩的坑和常见用法。

Environment:

  1. Unity Editor 2021.3.5f1c1

  2. XR Interaction Toolkit | XR Interaction Toolkit | 2.2.0

  3. Preview Tool provided by Pico Official

Notice

  • 2.2.0中,XRKT提供了一些新的方法与属性, 一些旧版的被标记为弃用。本例中的代码可能有些无法在老版本中实现,但旧版一般也都提供了近似的方法与属性。

  • 推测在使用Preview Tool时(有线连接),尤其是在PC端提示绿标“连接成功” 时,PC端的以太网会连接到一个”NDIS”设备,导致一些暂时的网络异常(无法访问网页)。

Interaction Toolkit

这可以被理解为使用Unity进行XR开发的核心,进行相关的配置后,几乎一切目前的VR设备可以被抽象为Unity的Device。XR Origin管理着这些设备。

在本文的项目中,直接使用了示例场景中的Introduction to VR Development.prefab预制体,其已经配置好了XR Origin,Input Action Manager InteractionManager EventSystem 等等基础的交互组件。

624b6fa1d9d4a945c593557a04b5a698.png

624b6fa1d9d4a945c593557a04b5a698.png

Interactor & Interactable

XRTK提供的主要交互方式为Interactor,可以大致分为

RayInteractor,Direct Interactor,Teleport Interactor三种,分别可以理解为射线交互,近距离的抓取抛掷交互,传送交互。其均继承自XRBaseControllerInteactor

可以被交互的物体需要挂载Interactable组件,并合理配置其Layer与Interaction Layer

Layer & Raycast Mask

即Inspector中右上角的Layer概念,可以控制对射线的遮罩。

Interaction Layer

配置Interactor可以交互的层,只有Interactable的Interaction Layer被包括在了Interactor的Interaction Layer中,Interactor才能与之交互。

例如:

CUBE 物体挂载的 Interactable组件中,勾选Interaction Layer为Deployable。并且CUBE的Layer 被囊括在Interactor中的Raycast Configuration -> Raycast Mask 中。

对应RayInteractor中的Interaction Layer囊括了此Deployable,那么CUBE可以被此Interactor交互。

e320974c3f47a8dd692c384abe24a8d8.png

e320974c3f47a8dd692c384abe24a8d8.png

498a2344f6b99fd75cf255dc921c6923.png

498a2344f6b99fd75cf255dc921c6923.png

Events

Interactor 对物体的交互可以抽象为两种类型,即Hover(悬停,待选,摸)Select(选取,拿起,抓起)。每个类型又提供了EnteredExited两个事件。

Development Practice

Assign Interaction Layer

通过代码指定Interaction Layer,在以下情况下,分别代表1 - Default 2 - Deployable

d5223dbaf91397bab58910ed077ae74e.png

d5223dbaf91397bab58910ed077ae74e.png

1
2
XRGrabInteractable xRGrabInteractable = gameObject.GetOrAddComponent<XRGrabInteractable>();
xRGrabInteractable.interactionLayers = 2;

Bind Events and Handler Methods

绑定selectEnteredselectExited等等事件,Handler方法接受对应的EventArgs,以XRInteractorHoverEnteredHandler(HoverEnterEventArgs args)为例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class DragManager: SingletonForMonobehaviour<DragManager>
{
//首先声明Interactor类,并在U
public XRDirectInteractor leftDirectInteractor;
public XRRayInteractor leftRayInteractor;

private void InitXRInteractor()
{
leftRayInteractor.hoverEntered.AddListener(XRInteractorHoverEnteredHandler); }
}

private void XRInteractorHoverEnteredHandler(HoverEnterEventArgs args)
{
Debug.log("[Interactor]Enter Hover"):
}

}

获取当前交互的物体

1
2
3
4
5
private void XRInteractorHoverEnteredHandler(HoverEnterEventArgs args)
{
IXRHoverInteractable hoverComponent = args.interactableObject;
GameObject obj = hoverComponent.transform.gameObject;
}

UI Elements

在UI组件上挂载TrackedDeviceGraphicRaycaster,可以使得其接受对应的Hover、Select事件

Input Event

响应对应的按键。

Base Concepts & Models

PICO Neo3为例:

43cc2a7be65a3d7abe62537ceab2e0b1.png

43cc2a7be65a3d7abe62537ceab2e0b1.png

The table below describes the mappings between PICO controller buttons and Unity keys:

Button Unity Keys
Menu CommonUsages.menuButton: represents whether the Menu button has been activated (pressed).
Trigger - CommonUsages.triggerButton: represents whether the Trigger button has been activated (pressed).
- CommonUsages.trigger: represents the degree to which the Trigger button was pressed. For example, in an archery game, it represents how full the bow has been drawn.
Grip - CommonUsages.gripButton: represents whether the Grip button has been activated (pressed).
- CommonUsages.grip: represents the degree to which the Grip button was pressed. For example, in an archery game, it represents how full the bow has been drawn.
Joystick - CommonUsages.primary2DAxisClick: represents whether the Joystick has been activated (pressed).
- CommonUsages.primary2DAxis: represents whether the Joystick has been moved upward, downward, leftward, or rightward.
X/A CommonUsages.primaryButton: represents whether the X/A button has been activated (pressed).
Y/B CommonUsages.secondaryButton: represents whether the Y/B button has been activated (pressed).

Development Practice

下面给出了代码中获取到输入、事件的用法。

也许2.2.0的VRKT提供了相应事件,可以自行搜索用法。此处给出的代码参考PicoXR中的输入事件_窗外听轩雨的博客-CSDN博客 的内容。

Get Input Device

通过XRNode获取设备

1
2
3
4
5
6
//XRNode为枚举变量
//常用的有 Head LeftHand RightHand
//根据这些枚举可以轻松获得指定的头盔,左手柄,右手柄
InputDevice headController = InputDevices.GetDeviceAtXRNode(XRNode.Head);
InputDevice leftHandController = InputDevices.GetDeviceAtXRNode(XRNode.LeftHand);
InputDevice rightHandController = InputDevices.GetDeviceAtXRNode(XRNode.RightHand);

Try Get Input Value

1
2
3
4
5
6
7
8
9
10
InputDevice device;
//省略device的获取 使用前要先获取
public void Test()
{
bool isDown; //记录是否按下
if(device.TryGetFeatureValue(CommonUsages.triggerButton,out isDown) && isDown)
{
//xxxxx 处理逻辑
}
}

InputEvent.cs

将输入转为事件,分离事件与处理逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.XR;
using Common;
/// <summary>
/// 提供各种输入事件
/// </summary>
public class InputEvent:MonoSingleton<InputEvent>
{
//*************输入设别**************************
InputDevice leftHandController;
InputDevice rightHandController;
InputDevice headController;

//**************对外提供公开事件******************
#region public event

public Action onLeftTriggerEnter;
public Action onLeftTriggerDown;
public Action onLeftTriggerUp;

public Action onRightTriggerEnter;
public Action onRightTriggerDown;
public Action onRightTriggerUp;

public Action onLeftGripEnter;
public Action onLeftGripDown;
public Action onLeftGripUp;

public Action onRightGripEnter;
public Action onRightGripDown;
public Action onRightGripUp;

public Action onLeftAppButtonEnter;
public Action onLeftAppButtonDown;
public Action onLeftAppButtonUp;

public Action onRightAppButtonEnter;
public Action onRightAppButtonDown;
public Action onRightAppButtonUp;

public Action onLeftJoyStickEnter;
public Action onLeftJoyStickDown;
public Action onLeftJoyStickUp;

public Action onRightJoyStickEnter;
public Action onRightJoyStickDown;
public Action onRightJoyStickUp;

public Action<Vector2> onLeftJoyStickMove;
public Action<Vector2> onRightJoyStickMove;

public Action onLeftAXButtonEnter;
public Action onLeftAXButtonDown;
public Action onLeftAXButtonUp;

public Action onLeftBYButtonEnter;
public Action onLeftBYButtonDown;
public Action onLeftBYButonUp;

public Action onRightAXButtonEnter;
public Action onRightAXButtonDown;
public Action onRightAXButtonUp;

public Action onRightBYButtonEnter;
public Action onRightBYButtonDown;
public Action onRightBYButtonUp;

#endregion

//提供状态字典独立记录各个feature的状态
Dictionary<string, bool> stateDic;

//单例模式提供的初始化函数
protected override void Init()
{
base.Init();
leftHandController = InputDevices.GetDeviceAtXRNode(XRNode.LeftHand);
rightHandController = InputDevices.GetDeviceAtXRNode(XRNode.RightHand);
headController = InputDevices.GetDeviceAtXRNode(XRNode.Head);
stateDic = new Dictionary<string, bool>();

}
//*******************事件源的触发**************************

/// <summary>
/// 按钮事件源触发模板
/// </summary>
/// <param name="device">设备</param>
/// <param name="usage">功能特征</param>
/// <param name="btnEnter">开始按下按钮事件</param>
/// <param name="btnDown">按下按钮事件</param>
/// <param name="btnUp">抬起按钮事件</param>
private void ButtonDispatchModel(InputDevice device,InputFeatureUsage<bool> usage,Action btnEnter,Action btnDown,Action btnUp)
{
Debug.Log("usage:" + usage.name);
//为首次执行的feature添加bool状态 -- 用以判断Enter和Up状态
string featureKey = device.name + usage.name;
if(!stateDic.ContainsKey(featureKey))
{
stateDic.Add(featureKey, false);
}

bool isDown;
if(device.TryGetFeatureValue(usage,out isDown) && isDown)
{
if(!stateDic[featureKey])
{
stateDic[featureKey] = true;
if(btnEnter != null)
btnEnter();
}
if(btnDown!=null)
btnDown();
}
else
{
if(stateDic[featureKey])
{
if(btnUp!=null)
btnUp();
stateDic[featureKey] = false;
}
}
}

/// <summary>
/// 摇杆事件源触发模板
/// </summary>
/// <param name="device">设备</param>
/// <param name="usage">功能特征</param>
/// <param name="joyStickMove">移动摇杆事件</param>
private void JoyStickDispatchModel(InputDevice device,InputFeatureUsage<Vector2> usage,Action<Vector2> joyStickMove)
{
Vector2 axis;
if (device.TryGetFeatureValue(usage, out axis) && !axis.Equals(Vector2.zero))
{
if(joyStickMove!=null)
joyStickMove(axis);
}
}

//******************每帧轮询监听事件***********************
private void Update()
{
ButtonDispatchModel(leftHandController, CommonUsages.triggerButton, onLeftTriggerEnter, onLeftTriggerDown, onLeftTriggerUp);
ButtonDispatchModel(rightHandController, CommonUsages.triggerButton, onRightTriggerEnter, onRightTriggerDown, onRightTriggerUp);

ButtonDispatchModel(leftHandController, CommonUsages.gripButton, onLeftGripEnter, onLeftGripDown, onLeftGripUp);
ButtonDispatchModel(rightHandController, CommonUsages.gripButton, onRightGripEnter, onRightGripDown, onRightGripUp);

ButtonDispatchModel(leftHandController, CommonUsages.primaryButton, onLeftAXButtonEnter, onLeftAXButtonDown, onLeftAXButtonUp);
ButtonDispatchModel(rightHandController, CommonUsages.primaryButton, onRightAXButtonEnter, onRightAXButtonDown, onRightAXButtonUp);

ButtonDispatchModel(leftHandController, CommonUsages.secondaryButton, onLeftBYButtonEnter, onLeftBYButtonDown, onLeftBYButonUp);
ButtonDispatchModel(rightHandController, CommonUsages.secondaryButton, onRightBYButtonEnter, onRightBYButtonDown, onRightBYButtonUp);

ButtonDispatchModel(leftHandController, CommonUsages.primary2DAxisClick, onLeftJoyStickEnter, onLeftJoyStickDown, onLeftJoyStickUp);
ButtonDispatchModel(rightHandController, CommonUsages.primary2DAxisClick, onRightJoyStickEnter, onRightJoyStickDown, onRightJoyStickUp);

ButtonDispatchModel(leftHandController, CommonUsages.menuButton, onLeftAppButtonEnter, onLeftAppButtonDown, onLeftAppButtonUp);
ButtonDispatchModel(rightHandController, CommonUsages.menuButton, onRightAppButtonEnter, onRightAppButtonDown,onRightAppButtonUp);

JoyStickDispatchModel(leftHandController, CommonUsages.primary2DAxis, onLeftJoyStickMove);
JoyStickDispatchModel(rightHandController, CommonUsages.primary2DAxis, onRightJoyStickMove);
}
}

Bind Events and Handler Methods

将对应的输入事件与处理事件想绑定,此处给出了EnterDownUp三个事件

private void BindXRInputEvent()
{
    InputEvent.Instance.onLeftAXButtonEnter += AXButtonEnterHandler;
    InputEvent.Instance.onLeftAXButtonUp += AXButtonUpHandler;

    InputEvent.Instance.onLeftBYButtonEnter += BYButtonEnterHandler;
    InputEvent.Instance.onLeftBYButonUp += BYButtonUpHandler;
}


private void AXButtonEnterHandler()
{
    Debug.Log("[Input] LeftAXButtonEnter is called");
    if (!hasStarted) return;
    UIManager.Instance.OpenPanel(eUIPanelType.ChooseBluePrintPanel);
}

private void AXButtonUpHandler()
{
    Debug.Log("[Input] LeftAXButtonUp is called");
    if (!hasStarted) return;
    UIManager.Instance.PopPanel();
}

Build

由于Neo3是安卓设备,在Unity打包时会遇到一些打安卓包的坑,主要原因是外网的一些资源不能正常访问。主要的解决办法时换源或者使用代理。

I Shot You First: Networking the Gameplay of Halo: Reach

Refer to: I Shot You First: Networking the Gameplay of Halo: Reach

Architecture

Common simplifying approaches

  1. Lockstep(deterministic, input-passing)

    对于输入和模拟及时性要求不高的游戏,例如RTS。

    通过Game Loop来控制游戏的演进

  2. Reliable transport protocols(TCP or homegrown)

    需要高带宽和可靠的环境

    TCP的延迟问题需要解决

  3. Send all networked state as a single blob(atomically)

    总是同步所有物体的情况,对于小规模的网络是非常好的

Halo has to solve the hard problem

Highly competitive multiplayer action game

对于现代的射击游戏,每个玩家都会对游戏产生关键影响,生成大量数据
75fabf2ee588209b82c06dd08ab51f57.png
显然,将一切物体网络化(需要同步)的成本是非常高的

TRIBES points the way

The TRIBES Engine Networking Model

A host/client model, resilient to cheating

Protocols for semi-reliable data delivery(不强制重传)

Terms

Replication

The communication of state or events to a remote peer

peers 之间的同步性

Authority

Permission to update the persistent sate of an object

Prediction

Extrapolating the current properties of an entitiy based on historial authoritative data and local guesses about the future

客户端在收到两个网络状态(信息)之间,本地对网络游戏物体的预测、判断

Bungie’s Networking Stack

Layer Purpose
Game Runs the game
Game Interface Extract and apply replicated data(提取游戏世界中的定量数据)
Prioritization Rate the priority of all possible replication options (选择优先发送什么数据)
Replication Protocols with various reliability guarantees
Channel Manager Flow and congestion control
Transport Send & receive on sockets

Replication Protocol

State Data

hots -> only client

Guaranteed eventual delivery of most current state, host -> client only

  • Object position

  • Object health

  • Other Key Game Info

Events(describing transitions )

Unreliable notifications of transient occurrences, telling why something happen.

Why player is dead

host -> client and client -> host

  • Fire the Weapon

  • This weapon was fired

几乎所有Event都有可能被dropped

Control data

渲染流水线与Shader概念

渲染流水线基础概念

输入是一个虚拟摄像机、一些光源、一些Shader以及纹理等等。最终目的在于生成或者说是渲染一张二维纹理。

应用阶段(通常由CPU负责实现)、几何阶段、光栅化阶段

渲染流水线中的3个概念阶段

应用阶段

在这一阶段中, 开发者有3个主要任务: 首先, 我们需要准备好场景数据, 例如摄像机的位 置、 视锥体、 场景中包含了哪些模型、 使用了哪些光源等等;其次, 为了提高渲染性能, 我们往 往需要做一个粗粒度剔除(culling)工作, 以把那些不可见的物体剔除出去, 这样就不需要再移 交给几何阶段进行处理;最后, 我们需要设置好每个模型的渲染状态。这些渲染状态包括但不限于它使用的材质(漫反射颜色,高光反射颜色)、使用的纹理、使用的Shader等等。这一阶段最重要的输出是渲染所需要的几何信息,即渲染图元(rendering primitives)。通俗来讲,渲染图元可以是点、线、三角面等。这些渲染图元将会被传递给下一个阶段——几何阶段

  1. 把数据加载到显存中

  2. 设置渲染状态

  3. 调用 Draw Call

    在同一状态下渲染3个网格

设置渲染状态

这些状态定义了场景中的网格是怎样被渲染的

例如, 使用哪个顶点着色器 (Vertex Shader) I片元着色器 (Fragment Shader)、 光源属性、材质等。 如果我们没有更改渲染状态, 那么所有的网格都将使用同一种渲染状态。 下图显示了当使用同 一种渲染状态时, 渲染 3 个不同网格的结果。在准备好上述所有工作后, CPU 就需要调用一个渲染命令来告诉GPU: “嘿!老兄, 我都帮 你把数据准备好啦, 你可以按照我的设置来开始渲染啦! ” 而这个渲染命令就是 Draw Call。

调用 Draw Call

相信接触过渲染优化的读者应该都听说过 Draw Call。 实际上, Draw Call 就是一个命令, 它 的发起方是 CPU, 接收方是GPU 。这个命令仅仅会指向一个需要被渲染的图元 (primitives) 列表, 而不会再包含任何材质信息-这是因为我们已经在上一个阶段中(设置渲染状态)完成了

当给定了一个 Draw Call 时, GPU 就会根据渲染状态(例如材质、 纹理、 若色器等)和所有 输入的顶点数据来进行计算, 最终输出成屏器上显示的那些漂亮的像素。 而这个计算过程, 就是 我们下一节要讲的GPU 流水线

几何阶段

几何阶段用于处理所有和我们要绘制的几何相关的事情。

例如,决定需要绘制的图元是什么, 怎样绘制它们,在哪里绘制它们。这一阶段通常在 GPU 上进行

几何阶段负责和每个渲染图元打交道,进行逐顶点、逐多边形的操作。这个阶段可以进一步 分成更小的流水线阶段 。几何阶段的一个重要任务就是把顶点坐标变换到屏幕空间中 ,再交给光栅器进行处理。通过对输入的渲染图元进行多步处理后,这一阶段将会输出屏幕空间的二维顶点坐标、每个顶点对应的深度值、着色等相关信息,并传递给下一个阶段。

光栅化阶段

此阶段将会使用上个阶段传递的数据来产生屏幕上的像素,并渲染出最终的图像。这 段也是在 GPU 上运行 。光栅化的任务主要是决定每个渲染图元中 的哪些像素应该被绘制在屏幕上。它需要对上一个阶段得到的逐顶点数据 (例如纹理坐标 、顶点颜色等)进行插值,然后再进行逐像素处理。

GPU 流水线

GPU的渲染流水线实现

颜色表示了不同阶段的可配置性或可编程性:绿色表示该流水线阶段是完全可编程控制的,黄色表示该流水线阶段可以配置但不是可编程的,蓝色表示该流水线阶段是由 GPU 固定实现的,开发者没有任何控制权。实线表示该 Shader 必须由开发者编程实现,虚线表示该 Shader 是可选的

GPU 的渲染流水线接收顶点数据作为输入。

几何阶段

顶点着色器 (Vertex Shader) 是完全可编程的,它通常用千实现顶点的空间变换、顶点着色等功能。

曲面细分着色器 (Tessellation Shader) 是一个可选的着色器,它用于细分图元。

几何着 色器 (Geometry Shader) 同样是一个可选的着色器,它可以被用于执行逐图元(Per-Primitive) 的着色操作,或者被用于产生更多的图元。

下一个流水线阶段是裁剪 (Clipping), 这一阶段的目的是将那些不在摄像机视野内的顶点裁剪掉,并剔除某些三角图元的面片。这个阶段是可配置的。 例如,我们可以使用自定义的裁剪平面来配置裁剪区域,也可以通过指令控制裁剪三角图元的正 面还是背面。

几何概念阶段的最后一个流水线阶段是屏幕映射 (Screen Mapping) 。此阶段是不可配置和编程的,它负责把每个图元的坐标转换到屏幕坐标系中。

顶点着色器 Vertex Shader

顶点着色器的处理单位是顶点,也就是说,输入进来的每个顶点都会调用一次顶点着色器。

顶点着色器本身不可以创建或者销毁任何顶点,而且无法得到顶点与顶点之间的关系。例如,我们无法得知两个顶 点是否属千同一个三角网格。但正是因为这样的相互独立性,GPU 可以利用本身的特性并行化处理每一个顶点,这意味着这一阶段的处理速度会很快。

顶点着色器需要完成的工作主要有:坐标变换和逐顶点光照。当然,除了这两个主要任务外, 顶点着色器还可以输出后续阶段所需的数据。下图展示了在顶点着色器中对顶点位置进行坐标变换并计算顶点颜色的过程。

GPU在每个输入的网格顶点上都会调用顶点着色器

坐标变换。顾名思义,就是对顶点的坐标(即位置)进行某种变换。顶点着色器可以在这 一步中改变顶点的位置,这在顶点动画中是非常有用的。例如,我们可以通过改变顶点位置来模拟水面、布料等。但需要注意的是无论我们在顶点着色器中怎样改变顶点的位置,一个最基本的顶点着色器必须完成的一个工作是,把顶点坐标从模型空间转换到齐次裁剪空间 。想想看,我们在顶点着色器中是不是会看到类似下面的代码

1
o.pos = mul(UNITY_MVP, v.position); 

类似上面这句代码的功能,就是把顶点坐标转换到齐次裁剪坐标系下,接着通常再由硬件做透视除法后,最终得到归一化的设备坐标 (Normalized Device Coordinates , NDC)

顶点着色器会将模型顶点的位置变换到齐次裁剪坐标空间下,进行输出后再由硬件做透视除法得到 NDC 下的坐标

需要注意的是,上图给出的坐标范围是 OpenGL 同时也是 Unity 使用的 NDC, 它的 ${z}$ 分量 范围在[-1,1] 之间,而在 DirectX 中, NDC 分量范围是 [0, 1] 。顶点着色器可 以有不同的输出方式。最常见的输出路径是经光栅化后交给片元着色器进行处理。而在现代的 Shader Model 它还可以把数据发送给曲面细分着色器或几何着色器。

裁剪 Clipping

由于我们的场景可能会很大 ,而摄像机的视野范围很有可能不会覆盖所有的场景物体, 一个很自然的想法就是,那些不在摄像机视野范围的物体不需要被处理 。而裁剪 (Clipping) 就是为 了完成这个目的而被提出来的。

一个图元和摄像机视野的关系有完全在视野内、部分在视野内、完全在视野外。完全在视野内的图元就继续传递给下一个流水线阶段,完全在视野外的图元不会继续向下传递,因为它们不需要被渲染。而那些部分在视野内的图元需要进行一个处理,这就是裁剪。例如 ,一条线段的一个顶点在视野内 ,而另一个顶点不在视野内,那么在视野外部的顶点应该使用一个新的顶点来代替,这个新的顶点位于这条线段和视野边界的交点处。

只有在单位立方体的图元才需要被继续处理

和顶点着色器不同,这一步是不可编程的,即我们无法通过编程来控制裁剪的过程,而是硬件上的固定操作,但我们可以自定义一个裁剪操作来对这一步进行配置。

屏幕映射 Screen Mapping

这一步输入的坐标仍然是三维坐标系下的坐标(范围在单位立方体内)。屏幕映射 (Screen Mapping) 的任务是把每个图元的坐标转换到 屏幕坐标系 (Screen Coordinates) 下。屏幕坐标系是一个二维坐标系,它和我们用于显示画面的分辨率有很大关系。

假设,我们需要把场景渲染到一个窗口上,窗口的范围是从最小的窗口坐标${(x_1,y_1)}$到最大的窗口坐标${(x_2,y_2)}$, 其中${x_1<x_2}$且${y_1 < y_2}$。由于我们输入的坐标范围在-1 到1, 因此可以想象到,这个过程实际是一个缩放的过程,如下图所示。你可能会问,那么输入的 ${z}$ 坐标会怎么样呢?屏幕映射不会对输入的 ${z}$ 坐标做任何处理。

实际上,屏幕坐标系和${z}$坐标一起构成了一个坐标系,叫窗口坐标系 (Window Coordinates) 。这些值会一起被传递到光栅化阶段

image-20230130162910716

屏幕映射得到的屏幕坐标决定了这个顶点对应屏幕上哪个像素以及距离这个像素有多远。

OpenGL和DirectX的屏幕坐标系差异。对干一张 512\*512 大小的图像,在 OpenGL 中其 (0, 0) 点在左下角,而 DirectX 中其(0, O) 点在左上角

光栅化阶段

光栅化概念阶段中的三角形设置 (Triangle Setup)三角形遍历 (Triangle Traversal) 阶段也都是固定函数 (Fixed-Function) 的阶段。接下来的片元着色器 (Fragment Shader), 则是完全可编程的,它用于实现逐片元 Per-Fragment 的着色操作。最后,逐片元操作 (Per-Fragment Operations) 阶段负责执行很多重要的操作,例如修改颜色、深度缓冲、进行混合等,它不是可编程的,但具有很高的可配置性。

三角形设置 Triangle Setup

由这一步开始就进入了光栅化阶段。从上一个阶段输出的信息是屏幕坐标系下的顶点位置以及和它相关的额外信息,如深度值 (z 坐标)、法线方向、视角方向等。光栅化阶段有两个最重要的目标:计算每个图元覆盖了哪些像素,以及为这些像素计算它们的颜色。

光栅化的第一个流水线阶段是三角形设置 (Triangle Setup) 。这个阶段会计算光栅化一个三角网格所需的信息。具体来说,上一个阶段输出的都是三角网格的顶点,即我们得到的是三角网格每条边的两个端点。但如果要得到整个三角网格对像素的覆盖情况我们就必须计算每条边上 的像素坐标。为了能够计算边界像素的坐标信息,我们就需要得到三角形边界的表示方式。这样一个计算三角网格表示数据的过程就叫做三角形设置。它的输出是为了给下一个阶段做准备。

三角形遍历 Triangle Traversal

三角形遍历 (Triangle Traversal) 阶段将会检查每个像素是否被一个三角网格所覆盖。如果被覆盖的话,就会生成一个片元 (fragment) 。而这样一个找到哪些像素被三角网格覆盖的过程就是三角形遍历,这个阶段也被称为扫描变换 (Scan Conversion)

三角形遍历阶段会根据上一个阶段的计算结果来判断一个三角网格覆盖了哪些像素,并使用三角网格3个顶点的顶点信息对整个覆盖区域的像素进行插值。下图展示了三角形遍历阶段的简化计算过程。

三角形遍历的过程

这一步的输出就是得到一个片元序列。需要注意的是,一个片元并不是真正意义上的像素,而是包含了很多状态的集合,这些状态用于计算每个像素的最终颜色。这些状态包括了(但不限于)它的屏幕坐标深度信息,以及其他从几何阶段输出的顶点信息,例如法线、纹理坐标等。

片元着色器 Fragment Shader

片元着色器 (Fragment Shader) 是另一个非常重要的可编程着色器阶段。在 DirectX 中,片元着色器被称为像素着色器 (Pixel Shader), 但片元着色器是一个更合适的名字,因为此时的片元并不是一个真正意义上的像素。

前面的光栅化阶段实际上并不会影响屏幕上每个像素的颜色值,而是会产生一系列的数据信息,用来表述一个三角网格是怎样覆盖每个像素的。而每个片元就负责存储这样一系列数据。真正会对像素产生影响的阶段是下一个流水线阶段——逐片元操作 (Per-Fragment Operations) 我们随后就会讲到。、

片元着色器的输入是上一个阶段对顶点信息插值得到的结果。更具体来说,是根据那些从顶点着色器中输出的数据插值得到的。而它的输出是一个或者多个颜色值。

这一阶段可以完成很多重要的渲染技术,其中最重要的技术之一就是纹理采样。为了在片元着色器中进行纹理采样,我们通常会在顶点着色器阶段输出每个顶点对应的纹理坐标,然后经过光栅化阶段对三角网格的3个顶点对应的纹理坐标进行插值后,就可以得到其覆盖的片元的纹理

根据上一步插值后的片元信息,片元着色器计算该片元的输出颜色

虽然片元着色器可以完成很多重要效果 但它的局限在于,它仅可以影响单个片元。也就是说,当执行片元着色器时,它不可以将自己的任何结果直接发送给它的邻居们。有一个情况例外, 就是片元着色器可以访问到导数信息 (gradient 或者说是 derivative) 。

逐片元操作 Per Fragment Operations

逐片元操作 (Per Fragment Operations) OpenGL 中的说法,在DirectX中,这一阶段被称为输出合井阶段 (Output-Merger) Merger 这个词可能更容易让读者明白这一步骤的目的:合并。而 OpenGL 中的名字可以让读者明白这个阶段的操作单位, 即是对每一个片元进行一些操作。那么问题来了,要合并哪些数据?又要进行哪些操作呢?

这一阶段有几个主要任务

  1. 决定每个片元的可见性。这涉及了很多测试工作,例如深度测试、模板测试等。
  2. 如果一个片元通过了所有的测试,就需要把这个片元的颜色值和已经存储在颜色缓冲区中的颜色进行合并,或者说是混合。

需要指明的是,逐片元操作阶段是高度可配置性的,即我们可以设置每一步的操作细节 。

这个阶段首先需要解决每个片元的可见性问题。这需要进行一系列测试。这就好比考试, 一个片元只有通过了所有的考试,才能最终获得和 GPU 谈判的资格,这个资格指的是它可以和颜色缓冲区进行合并。如果它没有通过其中的某一个测试,那么对不起,之前为了产生这个片元所做的所有工作都是白费的,因为这个片元会被舍弃掉。 Poor fragment! 下图给出了简化后的逐片元操作所做的操作。

逐片元操作阶段所做的操作

作为一个想充分提高性能的 GPU, 它会希望尽可能早地知道哪些片元是会被舍弃的,对于这些片元就不需要再使用片元着色器来计算它们的颜色。 在 Unity 给出的渲染流水线中, 我们也可以发现它给出的深度测试是在片元着色器之前。这种将深度测试提前执行的技术通常也被称为Early-Z技术。

但是,如果将这些测试提前的话, 其检验结果可能会与片元着色器中的一些操作冲突。 例如,如果我们在片元着色器进行了透明度测试,而这个片元没有通过透明度测试, 我们会在着色器中调用 APJ C例如 clip 函数)来手动将其舍弃掉。 这就导致 GPU 无法提前执行各种测试。 因此,现代的 GPU 会判断片元着色器中的操作是否和提前测试发生冲突,如果有冲突,就会禁用提前测试。但是, 这样也会造成性能上的下降, 因为有更多片元需要被处理了。 这也是透明度测试会导致性能下降的原因。

现在大多数 GPU 都支持一种称为提前深度测试(Early depth testing)的硬件功能。提前深度测试允许深度测试在片段着色器之前运行。明确一个片段永远不会可见的 (它是其它物体的后面) 我们可以更早地放弃该片段。

片段着色器通常是相当费时的所以我们应该尽量避免运行它们。对片段着色器提前深度测试一个限制是,你不应该写入片段的深度值。如果片段着色器将写入其深度值,提前深度测试是不可能的,OpenGL不能事先知道深度值。

当模型的图元经过了上而层层计算和测试后, 就会显示到我们的屏幕上。 我们的屏幕显示的 就是颜色缓冲区中的颜色值。 但是,为了避免我们看到那些正在进行光栅化的图元,GPU 会使用双重缓冲 (Double Buffering) 的策略。这意味着,对场景的渲染是在幕后发生的, 即在后置缓冲 (Back Buffer) 中。 一旦场景已经被渲染到了后置缓冲中, GPU 就会交换后置缓冲区和前置缓冲 (Front Buffer) 中的内容, 而前置缓冲区是之前显示在屏幕上的图像。 由此, 保证了我们看到的图像总是连续的。

总结与常识

概括来说,我们的应用程序运行在 CPU 上。应用程序可以通过调用 OpenGL DirectX 的图形接口将渲染所需的数据,如顶点数据、纹理数据、材质参数等数据存储在显存中的特定区域 随后,开发者可以通过图像编程接口发出渲染命令,这些渲染命令也被称为 Draw Call, 它们将会 被显卡驱动翻译成 GPU 能够理解的代码,进行真正的绘制。

下图可以看出, 一个显卡除了有图像处理单元 GPU 外,还拥有自己的内存,这个内存通常被称为显存 (Video Random Access Memory, VRAM) GPU 可以在显存中存储任何数据, 但对于渲染来说一些数据类型是必需的,例如用于屏幕显示的图像缓冲、深度缓冲等。

image-20230130224205472

投影

不管是透视投影(Perspective Projection)摄像机还是正交投影(Orthographic Projection)摄像机,视锥体都是由Top、Bottom、Right、Left以及Near和Far共六个平面组成。

利用投影的方法将顶点坐标从3D转变为2D:

首先将顶点的x、y、z坐标分别除以w分量(写给大家看的“透视除法” —— 齐次坐标和投影https://www.jianshu.com/p/7e701d7bfd79),得到标准化的设备坐标(Normalized Device Coordinate。NDC)。

空间变换流程图与变换矩阵

模型空间(本地空间) —模型变换(利用模型变换矩阵)—>世界空间 —视变换(利用视变换举证)—> 摄像机空间(观察空间)—投影变换(利用裁切矩阵)—>裁切空间 —屏幕投影(利用投影矩阵)—> 屏幕空间(得到屏幕像素坐标)

Shader和材质的关系与区别

Shader 实际上就是一段程序,它负责把输入的顶点数据按照代码里指定的方式进行处理,并对输入的颜色或者贴图等进行计算,然后输出数据。图像绘制单元获取到输出的数据便可将图像绘制出来,最终呈现在屏幕上。

Shader 程序代码,再加上开放的参数设置以及关联的贴图等等,为实现某种效果而打包存储在一起,最终得到的就是材质(Material)。
材质是Shader的实例化资源,一个Shader可以实例化为多个材质,并且调节为不同的材质效果。最后把材质指定给某个模型就可以渲染出对应的效果了。

Draw Call

Draw Call 本身的含义很简单,就是CPU 调用图像编程接口,如 OpenGL 中的 glDrawElements 命令或者 DirectX 中的 DrawlndexedPrimitive ,以命令 GPU 进行渲染的操作。

为什么 Draw Call 多了会影响帧率?

在每次调用 Draw Call 之前, CPU 需要向 GPU 发送很多内容,包括数据、状态和命令等。在这一阶段, CPU 需要完成很多工作,例如检查渲染状态等。而一旦 CPU 完成了这些准备工作, GPU 就可以开始本次的渲染。 GPU 的渲染能力是很强的,渲染 200 个还是 2 000个三角网格通常没有什么区别,因此渲染速度往往快于 CPU 提交命令的速度。如果 Draw Call 的数量太多, CPU 就会把大量时间花费在提交 Draw Call 上,造成 CPU 的过载。下图显示了这样一个例子:

命令缓冲区中的虚线方框表示 GPU 已经完成的命令

尽管减少 Draw Call 的方法有很多,但我们这里仅讨论使用批处理 (Batching) 的方法。 我们讲过,提交大量很小的 Draw Call 会造成 CPU 的性能瓶颈,即 CPU 把时间都花费在准 Draw Call 的工作上了 。那么,一个很显然的优化想法就是把很多小的 DrawCall 合并成一个大 Draw Call, 这就是批处理的思想。图 2.21 显示了批处理所做的工作。

如何减少 Draw Call?

尽管减少 Draw Call 的方法有很多,但我们这里仅讨论使用批处理 (Batching) 的方法。我们讲过,提交大量很小的 Draw Call 会造成 CPU 的性能瓶颈,即 CPU 把时间都花费在准备 Draw Call 的工作上了。那么,一个很显然的优化想法就是把很多小的 DrawCall 合并成一个大的 Draw Call, 这就是批处理的思想。

需要注意的是,由于我们需要在 CPU 的内存中合并网格,而合并的过程是需要消耗时间的。 因此,批处理技术更加适合于那些静态的物体,例如不会移动的大地、石头等,对于这些静态物体我们只需要合并一次即可。当然,我们也可以对动态物体进行批处理。但是,由于这些物体是不断运动的,因此每一帧都需要重新进行合并然后再发送给 GPU, 这对空间和时间都会造成一定的影响。

使用批处理合并的网格将会使用同一种渲染状态

在游戏开发过程中,为了减少 Draw Call 的开销,有两点需要注意。

  1. 避免使用大蜇很小的网格。当不可避免地需要使用很小的网格结构时,考虑是否可以合并它们
  2. 避免使用过多的材质。尽量在不同的网格之间共用同一个材质。

固定管线渲染

固定函数的流水线 (Fixed-Function Pipeline), 也简称为固定管线,通常是指在较旧的 GPU 上实现的渲染流水线。这种流水线只给开发者提供一些配置操作,但开发者没有对流水线阶段的完全控制权。

固定管线通常提供了一系列接口,这些接口包含了一个函数入口点 (Function Entry Points) 集合,这些函数入口点会匹配 GPU 上的一个特定的逻辑功能。开发者们通过这些接口来控制渲染流水线。换句话说,固定渲染管线是只可配置的管线。一个形象的比喻是,我们在使用固定管线进行渲染时,就好像在控制电路上的多个开关,我们可以选择打开或者关闭一个开关,但永远无法控制整个电路的排布。

简化的渲染完整流水线

建立场景

  • 在真正开始渲染之前,需要对整个场景进行预先设置,例如:摄像机视角、灯光设置以及物化设置等等。

可见性检测

  • 有了摄像机,就可以基于摄像机视角检测场景中所有物体的可见性。这一步在实时渲染中极为重要,因为可以避免把时间和性能浪费在渲染一些视角之外的物体上。

设置渲染状态

  • 一旦检测到某个物体是可见的,接下来就需要把它绘制出来了。但是由于不同物体的渲染状态可能不同(如何进行深度测试、如何与背景图像进行混合等),因此在开始渲染该物体之前首先需要设置该物体的渲染状态。

几何体生成与提交

  • 接着,就需要向渲染API提交几何体数据了,一般所提交的数据为三角形的顶点数据。例如:顶点坐标、法线向量、UV等。

变换与光照

  • 当渲染API获取到三角形顶点数据之后,就需要将顶点坐标从模型空间变换到摄像机空间,并且同时进行顶点光照计算。

背面剔除与裁切

  • 变换到摄像机空间之后,那些背对着摄像机的三角形会被剔除,然后再被变换到裁切空间中,将视锥体之外的部分裁切掉。

投影到屏幕空间

  • 在裁切空间中经历裁切之后的多边形会通过投影从三维变为平面,输出到屏幕空间中。

光栅化

  • 屏幕空间中的几何体还需要经过光栅化处理才能转变为2D的像素信息。

像素着色 nm

  • 最后,计算每个像素的颜色,并把这些颜色信息输出到屏幕上。