这里是前言介绍。
在开发Unity的package并且推送到Package Manager时,有时需要判断项目工程中有没有包含某个package。一种可行的解决办法是在当前package的Assembly definition(asmdef)文件的“Version Defines”中的Resource
中添加想要判断的package,然后设定一个对应的Define
,详细可见https://docs.unity3d.com/2022.2/Documentation/Manual/class-AssemblyDefinitionImporter.html。假设我们添加的“Define”叫“PACKAGE_1”添加后,我们在代码中就可以这样写:
1 | #if PACKAGE_1 // 如果包含package1#else // 如果不包含package1#endif |
但是,此时又会出现另一个问题:如果我们想判断当前项目包不包含自己的package怎么办?在“Version Defines”中我们只能添加当前项目中包含的package,而我们的项目中是不可能包含我们在开发的package的。解决办法也很简单——直接编辑asmdef文件,在他的versionDefines
中加入如下代码,替换对应的name
和define
使其对应你的package即可,如有需要也可以添加expression
:
1 | "versionDefines": [ { "name": "com.mycompany.mypackagename", "expression": "", "define": "MY_PACKAGE" } ], |
之后,就可以像上面一样用#if MY_PACKAGE
判断当前项目有没有包含你自己的package了。
]]>to be continued…
测试环境:
Unity2021.3.24f1
崩溃堆栈Logcat
1 | E *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** |
arm64 + 开启多线程渲染 游戏会崩溃会卡死
arm64 + 关闭多线程渲染 游戏不会崩溃很稳定
解决方案:
第一种:关闭多线程渲染
第二种:升级Unity版本
相关资料
https://answer.uwa4d.com/question/5f1668490314525f3f1dd866
https://answer.uwa4d.com/question/62b02ad09b935a01f583ac56
]]>to be continued…
CDN的全称是Content Delivery Network,即内容分发网络。CDN是构建在网络之上的内容分发网络,依靠部署在各地的边缘服务器,通过中心平台的负载均衡、内容分发、调度等功能模块,使用户就近获取所需内容,降低网络拥塞,提高用户访问响应速度和命中率。CDN的关键技术主要有内容存储和分发技术。——百度百科
由于某些原因,很多公用免费的 CDN 资源在中国大陆并不很好用,就算是付费的,也有一定的限制,例如每天的刷新次数有限之类的。那有没有一款造福人类的,或者造福中国大陆的公用 CDN 呢?
先看看官方的介绍:
这是在中国大陆唯一有 license 的公有 CDN,而且实际使用中的访问速度也是极快的(虽然官网打开速度慢
官网地址:https://www.jsdelivr.com 没梯子访问可能有点慢,不过CDN的节点是很快的 可以引用的资源包括NPM、github、wordpress的所有资源,github可以是任意体积小于50M的仓库。
以github为例,只需要通过符合 JSDelivr 规则的 URL 引用,即可直接使用 Github 中的资源。
1 | // 用户名/仓库名@版本号/文件名 |
详细规则参考官网:https://www.jsdelivr.com/
以我一张图片为例:
原github链接:https://raw.githubusercontent.com/Tamsiree/Assets/master/DeskTop/4e0f6b142732b595eacf1be7b549e1f0.jpg
CDN后的链接:https://cdn.jsdelivr.net/gh/Tamsiree/Assets@master/DeskTop/4e0f6b142732b595eacf1be7b549e1f0.jpg
除了jsdelivr
, 推荐几个免费的且不限流量的CDN,还有 staticaly
githack
都是全球通用的,
官网地址:https://www.staticaly.com
轻松地从GitHub / GitLab / Bitbucket等加载您的项目 没有流量限制或限制。
文件通过超快速全球CDN提供。 在URL(不是分支)中使用特定标记或提交哈希。
根据URL永久缓存文件。 除master分支外,文件在浏览器中缓存1年。 具体用法:
1 | # GitHub CDN |
直接从GitHub,Bitbucket或GitLab提供原始文件
官网地址:http://raw.githack.com/ 具体用法和上面的staticaly
很类似
1 | # Github CDN |
参考链接:
]]>to be continued…
本文会详细介绍三维软件中7种坐标的特点与关系,还有在脚本编辑和shader系统中的使用方法后续更新
unity 使用的是左手坐标系,即:
↑=Y,↓=-Y,前=Z,后=-Z,←=-X,→=X。
总共有七种坐标系,分别是模型坐标、世界坐标、观察坐标、裁剪坐标、屏幕坐标、ui坐标,uv坐标这七种坐标会按照一定顺序转换在程序中运行,实际上这就是模型从计算到在屏幕上显示的过程。
Local Space (模型坐标):就是一个模型自己的坐标,如果该模型下有子模型,则子模型也拥有自己的模型坐标,多个子模型在父模型下共享父坐标。
坐标:
模型原点为(0,0)
需要说明的是一个模型坐标的原点位置在创建模型的时候就定义好了。如果需要更改只能在blender或3dmax等建模软件中设定。unity是不能设置模型坐标原点的。
获取方式:
在unity脚本中:
transform.localPosition
在unity shader中:
顶点着色器默认输入的就是模型坐标
World Space(世界坐标):场景中的一切物体,包括模型,灯具,相机都共享一个绝对位置,这就是世界坐标。所有在模型坐标中的变化最终都会映射到世界坐标中。
坐标:
世界的中心点:0,0
获取方式:
在unity脚本中:
使用transform.position可以获得该位置坐标。
在unity shader中:
需要 将模型坐标转换为世界坐标才能获得。
ViewPort Space (观察坐标):每个相机都是一个观察者,每个观察者都有自己的视界。
到这一步,会把世界坐标转换为以摄像机为参考的原点和方向。
坐标:
左下角为(0,0)
右上角为(1,1)
Z的位置是以相机的世界单位来衡量的。
获取方式:
Clip Space (裁剪坐标):裁剪坐标是将观察世界的一部分截取出来。摄像机在取景的时候使用一个六面体去处理可视的区域,所有在这个区域的物体才会被显示。那么你肯定会有以为,为什么不直接用观察坐标,为什么一定要用这个裁剪呢? 首先,相机有两种模式,即透视相机与正交镜头(没有近大远小的透视效果)。这两种模式的切换会在裁剪坐标中处理。其次裁剪坐标规定了那些模型计算后可以显示,那些模型计算后但不用显示。所以必须有这个步骤。
Screen Space(屏幕坐标):屏幕坐标用于将裁剪坐标中的物体信息映射到屏幕上。我们知道三维世界是立体的,但不管是几维世界,只要实在屏幕上显示的就一定是2d的。
坐标:
左下角为(0,0)点
右上角为(Screen.width,Screen.height)
获取方式:
Z的位置是以相机的世界单位来衡量的。
Screen.width = Camera.pixelWidth
Screen.height = Camera.pixelHeigth
ui坐标是特殊的坐标系统,专门用于处理ui组件。ui坐标系是可视化窗口的最顶层,世界坐标系的物体都会被ui坐标系遮挡。
坐标:
左下角为(0,0)点
右上角为(Screen.width,Screen.height)
Z轴影响显示层级位置
获取方式:
相比之前的六种坐标系,uv坐标系只负责定位贴图和模型的关系
坐标:
左下角为(0,0)
右上角为(1,1)
获取方式:
camera.WorldToScreenPoint(transform.position);这样可以将世界坐标转换为屏幕坐标。其中camera为场景中的camera对象。
1 | public class Follow_2d3d : MonoBehaviour |
该代码实现效果:ui中的2d元素会与三维物体重合,移动三维物体,2d元素会跟着移动
camera.ScreenToViewportPoint(Input.GetTouch(0).position);这样可以将屏幕坐标转换为视口坐标。其中camera为场景中的camera对象。
1 | public class Follow_2d3d : MonoBehaviour |
该代码实现效果:ui中的2d元素会与三维物体重合,移动2d元素,三维物体会跟着移动
camera.ViewportToScreenPoint();
camera.ViewportToWorldPoint();
鼠标拖拽ui,不能直接使用position赋值。需要通过相机转换。
参考链接:[ unity 七种坐标系统详解与互相转换的方法 模型坐标、世界坐标、观察坐标(视口坐标)、裁剪坐标、屏幕坐标、ui坐标、uv坐标 ]
]]>to be continued…
如果你对cg glsl hlsl 顶点着色器 片段着色器 表面着色器 固定渲染管线 等等有所疑惑,或是想学会unity的渲染,看这一篇就足够了。另外我博客的shader分类中还有很多shader教程和源码,每篇源码都有实现思路、语法功能注释,还在不断更新添加中。感兴趣的请自行查看
点击vs的工具>扩展和更新>联机>visual studio marketplace
搜素shader
点击下载
重启vs
弹出installer,点击修改 即可。
着色器语言有三种:
hlsl(DirectX)
glsl(OpenGL)
cg(支持以上两种)
Shader Lab 是unity封装了cg、hlsl、glsl的unity专有着色器语言。shaderlab具有跨平台、图形化编程、便于着色器与unity通信等优点。在unity2018.3.5f以后版本,可以使用图形化工具shader graph来大幅缩减shader编程成本,使这个令人头疼的语言走入寻常百姓家。。。。。但作为一个unityer最好具有基本的Shader Lab编程能力,至少了解基本效果的代码实现过程。
把模型比作一只兔子,那mesh就是兔子的骨架,顶点就是骨架的端点,片段就是由顶点组成的面,材质就是皮肉用来装饰片段的,shader就是用来控制如何显示材质的。
顶点着色器和片段着色器都有自己独立的硬件处理单元。该硬件处理单元拥有非常强大的并行运算能力,非常擅长矩阵计算,片段处理器还可以告诉查询纹理信息。
白话:顶点着色器负责定位像素位置!片段着色器负责修改像素颜色!!
顶点着色程序与片断着色程序通常是同时存在,相互配合,前者的输出作为后者的输入。不过,也可以只有顶点着色程序。如果只有顶点着色程序,那么只对输入的顶点进行操作,而顶点内部的点则按照硬件默认的方式自动插值。例如,输入一个三角面片,顶点着色程序对其进行phong光照计算,只计算三个顶点的光照颜色,而三角面片内部点的颜色按照硬件默认的算法(Gourand明暗处理或者快速phong明暗处理)进行插值,如果图形硬件比较先进,默认的处理算法较好(快速phong明暗处理),则效果也会较好;如果图形硬件使用Gourand明暗处理算法,则会出现马赫带效应(条带化)。
而片断着色程序是对每个片断进行独立的颜色计算,并且算法由自己编写,不但可控性好,而且可以达到更好的效果。
由于GPU对数据进行并行处理,所以每个数据都会执行一次shader程序程序。即,每个顶点数据都会执行一次顶点程序;每个片段都会执行一次片段程序。
片段就是所有三维顶点在光栅化之后的数据集合,这些数据没有经过深度值比较,而屏幕显示的像素是经过深度比较的。
作用
顶点着色器负责顶点坐标变换,片段着色器负责像素的颜色计算。顶点着色器计算好坐标信息后传入片段着色器计算颜色。所以顶点着色器和片段着色器是合作关系。
表面着色器是封装了顶点和片段着色器的新api。与他们属于上下层关系。
shader编译时会将表面渲染代码编译成多个pass代码块,再分解成顶点/片元着色器。
区别
顶点着色器用于处理顶点。片段着色器用于处理面。
表面着色器是对顶点着色器与片段着色器的进一步封装。
即是说,表面着色器有一套即成的处理办法,不用去搞那些细节。
而顶点着色器和片段着色器更接近底层,可以处理一些细节问题。
1 | Shader "path/name" { |
属性的结构:_CG变量名 (“unity可见的变量名”, 属性类型) = 值
例:_Color (“My Color”, Color) = (1, 1, 1, .5)
属性类型表
类型 | 说明 | 实例 |
---|---|---|
Int | 整型 | (.1, 2) |
Float | 浮点数 | .5 |
Vector | 四维向量 | (.5, 1 , 1, 0.5) |
Range | 范围1-2.3的浮点数 | (1, 2.3) |
Color | RGBA颜色 | (1,1,1,.5) |
2D | 2d贴图, 2d纹理,默认值可以为一个代表默认tint颜色的字符串,可以是空字符串或者”white”,”black”,”gray”,”bump”中的一个 | ”white”{} |
3D | 3d贴图 | |
Cube | 6面立方贴图 | ”white”{} |
Rect | 矩形贴图 | ”white”{} |
1 | SubShader { |
tags可以填写多个命令,可以控制渲染时机。可以通过在unity的asset窗口中点击shader查看tags生效情况。
标签 | 说明 | 值 |
---|---|---|
“RenderType”=“Opaque” | 系统在渲染不透明物体时调用该shader | |
“RenderType” = “Transparent” | 系统在渲染透明物体时调用该shader,绝大部分透明的物体、包括粒子特效都使用这个 | |
“RenderType” = “Background” | 系统渲染背景时调用,天空盒都使用这个 | |
“RenderType” = “Overlay” | 系统渲染gui镜头时调用,GUI、镜头光晕都使用这个 | |
“IgnoreProjector”=“True” | 忽略Projectors | |
“ForceNoShadowCasting”=“True” | 不生成阴影 | |
“Queue”=“xxx” | 指定渲染队列顺序,下面有详细说明 |
Queue的说明
关键字 | 说明 |
---|---|
Background | 最先调用的,用来渲染天空盒或背景 |
Geometry | 默认值,用来渲染非透明物体(一般情况下,场景中的绝大多数物体应该是非透明的) |
AlphaTest | 用来渲染经过Alpha Test的像素,单独为AlphaTest设定一个Queue是出于对效率的考虑 |
Transparent | 以从后往前的顺序渲染透明物体 |
Overlay | 用来渲染叠加的效果,是渲染的最后阶段(比如镜头光晕等特效) |
这些预定义的值本质上是一组定义整数,Background = 1000, Geometry = 2000, AlphaTest = 2450, Transparent = 3000,最后Overlay = 4000。在我们实际设置Queue值时,不仅能使用上面的几个预定义值,我们也可以指定自己的Queue值,写成类似这样:“Queue”=“Transparent+100”,表示一个在Transparent之后100的Queue上进行调用。通过调整Queue值,我们可以确保某些物体一定在另一些物体之前或者之后渲染,这个技巧有时候很有用处。(比如遮挡描边效果,应该就是这么来的)
细节等级。大家玩吃鸡的时候,从飞机上跳下,这时看到地图上的建筑都是比较粗糙的块,当距离慢慢拉近,建筑模型变得越发精致,这就是LOD技术,根据不同的范围使用不同的模型。shader的LOD也是同样用法,不同细节等级,使用不同的LOD。在Unity的Quality Settings中可以设定最大LOD值,当当前LOD小于shader LOD时,那个sub shader就会失效.
VertexLit及其系列 = 100
Decal, Reflective VertexLit = 150
Diffuse = 200
Diffuse Detail, Reflective Bumped Unlit, Reflective Bumped VertexLit = 250
Bumped, Specular = 300
Bumped Specular = 400
Parallax = 500
Parallax Specular = 600### 三、实例
pass是实现着色器具体代码的地方。一个subshader内可以有多个pass。但尽可能用较少的pass实现是对性能的考虑。
pass内的tags说明
pass内的tags有别与subshader中的tags
取值 | 例子 | 说明 |
---|---|---|
Always | “LightMode”=“Always” | 不管是用哪种渲染路径,该pass总是会被渲染。但不计算任何光照 |
Forwardbase | “LightMode”=“ForwardBase” | 用于向前渲染,该pass会计算环境光,重要的平行光,逐顶点/SH光源和lightmaps |
ForwardAdd | “LightMode”=“ForwardAdd” | 用于向前渲染,该pass会计算额外的逐像素光源,每个pass对应一个光源 |
Deferred | “LightMode”=“Deferred” | 用于向前渲染,该pass会渲染G缓冲,G-buffer |
ShadowCaster | “LightMode”=“ShadowCaster” | 把物体的深度信息渲染到盈盈映射纹理(shadowmap)或一张深度纹理中,用于渲染产生阴影的物体 |
ShadowCollector | “LightMode”=“ShadowCollector” | 用于收集物体阴影到屏幕坐标Buff里 |
PrepassBase | 用于遗留的延迟渲染,该pass会渲染法线和高光反射的指数部分 | |
PrepassFinal | 用于遗留的延迟渲染,该pass通过合并纹理、光照和自发光来渲染得到最后的颜色 | |
Vertex、VertexLMRGBM和VertexLM | 用于遗留的顶点照明渲染 |
pass 内的代码分为:固定渲染管线、可编程顶点/片段渲染管线,可编程表面渲染管线。
下面是对三种渲染管线的详细介绍。
ragma用于对渲染器的控制。
ragma 参数表
命令 | 参数 | 实例 | 说明 |
---|---|---|---|
vertex | #pragma vertex name | 将函数name的代码编译为顶点程序 | |
fragment | #pragma fragment name | 将函数name的代码编译为片元程序 | |
geometry | #pragma geometry name | 将函数name的代码编译为DX10的几何着色器 | |
hull | #pragma hull name | 将函数name 的代码编译为DX11hull着色器 | |
domain | #pragma domain name | 将函数name 的代码编译为DX11 domain着色器 | |
fragmentoption option | #pragma fragmentoption option | 添加选项到编译的OpenGL片段程序。对顶点程序或编译目标不是opengl的程序无效 | |
target | target 2.0、target 3.0、target 4.0、target 5.0 | #pragma target name | 设置着色器的编译目标,对应不同版本的着色器模型 |
only_renderers space separated | d3d9(direct3d 9)、d3d11、opengl、gles(opengl 2s 2.0)、xbox360、ps3、flash | #pragma only_renderers space separated names | 仅编译到指定的渲染平台 |
exclude_renderers space separated | d3d9(direct3d 9)、d3d11、opengl、gles(opengl 2s 2.0)、xbox360、ps3、flash | #pragma exclude_renderers space separated names | 不编译到指定的渲染平台 |
glsl | #pragma glsl | 为桌面系统的opengl进行编译时,将cg/hlsl代码转为glsl代码 | |
glsl_no_auto_normalization | #pragma glsl_no_auto_normalization name | 编译到移动平台glsl时(ios/android), 关闭在定点着色器中对法线向量和切线向量自动进行规范化 |
命令 | 实例 | 说明 |
---|---|---|
POSITION | 模型空间中的顶点位置,一般是float4类型 | |
NORMAL | 顶点法线,float3类型 | |
TANGENT | 顶点切线 float4 | |
TEXCOORD0~N | 该顶点纹理坐标,0是第一组,一般是flkoat2 或float4类型 | |
COLOR | 定点颜色,通常是fixed4或float4类型 |
命令 | 实例 | 说明 |
---|---|---|
SV_POSITION | 裁剪空间中的顶点坐标,结构体中必须包含一个用该语义修饰的变量 | |
COLOR0 | 用于输出第一组顶点颜色 | |
COLOR1 | 通常用于输出第二组顶点颜色 | |
TEXCOORD0~TEXCOORD7 | 通常用于输出纹理坐标 |
命令 | 实例 | 说明 |
---|---|---|
SV_Target | 输出值将会存到渲染目标(render target)中 |
命令 | 实例 | 说明 |
---|---|---|
UNITY_MATRIX_MVP | 当前的模型观察投影矩阵,用于将顶点/方向矢量从模型空间变换到裁剪空间 | |
UNITY_MATRIX_MV | 当前的模型观察矩阵,用于将顶点/方向矢量从模型空间变换到观察空间 | |
UNITY_MATRIX_V | 当前的观察矩阵,用于将顶点/方向矢量从世界空间变换到观察空间 | |
UNITY_MATRIX_P | 当前的投影矩阵,用于将顶点/方向矢量从观察空间变换到裁剪空间 | |
UNITY_MATRIX_VP | 当前的观察投影矩阵,用于将顶点/方向矢量从世界空间变换到裁剪空间 | |
UNITY_MATRIX_T_MV | UNITY_MATRIX_MV 的转置矩阵 | |
UNITY_MATRIX_IT_MV | UNITY_MATRIX_MV的逆转置矩阵,用于将法线从模型空间变换到观察空间,也可以用于得到UNITY_MATRIX_MV的逆矩阵 | |
_Object2World | 当前的模型矩阵,用于将顶点/方向矢量从模型空间变换到世界空间 | |
_World2Object | _Object2World的逆转矩阵,用于将顶点/方向矢量从世界空间变换到模型空间 |
命令 | 说明 | 实例 |
---|---|---|
float4 UnityObjectToClipPos(float3 pos) | 将一个点从object空间转换成相机在均匀坐标下的剪辑空间。这就相当于 mul(UNITY_MATRIX_MVP, float4(pos, 1.0)), 应该在它的位置上使用。 | |
float3 UnityObjectToViewPos(float3 pos) | 将一个点从object空间转换为view空间。这就相当于mul(UNITY_MATRIX_MV, float4(pos, 1.0)).xyz, 应该在它的位置上使用。 |
命令 | 说明 | 实例 |
---|
命令 | 实例 | 说明 |
---|---|---|
float3 _WorldSpaceCameraPos | 该摄像机在世界空间中的位置 | |
float4 _ProjectionParams | x=1.0 或-1.0(使用反转的投影矩阵渲染时是负数),y=Near,z=Far,w= 1.0+1.0/Far, 其中near和far分别是近裁剪平面和远裁剪平面与摄像机的距离 | |
float4 _ScreenParams | x=width,y=height,z=1.0+1.0/width,w=1.0+1.0/height, 其中width和height分别是该摄像机的渲染目标 (render target)的像素宽度和高度 | |
float4 _ZBufferParams | x=1-Far/near,yFar/Near, 最x/Far,wy/Far,该变量用于线性化Z缓存中的深度值 | |
floart4 unity_OrhoParams | x=width,y=height,z无意义,w=1.0(该相机是正交相机)或w=0.0(透视相机),其中width和height是正交投影相机的宽和高 | |
float4x4 unity_CameraProjection | 该摄像机的投影矩阵 | |
floart4x4 unity_CameraInvProjection | 该摄像机的投影矩阵的逆矩阵 | |
float4 unity_CameraWorldClipPlanes | 该摄像机的6个裁剪屏幕在世界空间下的等式,按左右上下近远的顺序裁剪平面 |
命令 | 实例 | 说明 |
---|---|---|
float4 _Time | _Time.x;_Time.y; _Time.z; _Time.w; | t是自该场景加载开始所经过的时间,4个分量分别是t/20,t,2t,3t |
float4 _SinTime | t是时间的正限制,4个分量分别是t/8,t/4,t/2,t | |
float4 _Costime | t是时间的余弦值,t/8,t/4.t/2,t | |
float4 unity_DeltaTime | dt是时间增量,4个值分别是dt,1/dt,smoothDt,1/smoothDt |
UnityCG.cginc 该文件中包含了很多即成的参数方法。使用十分方便
1 | CGPROGRAM |
命令 | 参数 | 实例 | 说明 |
---|---|---|---|
appdata_base | 顶点位置、顶点法线、第一组纹理坐标 | float4 vertex : POSITION; float3 normal : NORMAL; float4 texcoord: TEXCOORD0; | 可用于顶点着色器的输入 |
appdata_tan | 顶点位置、顶点切线、顶点法线、第一组纹理坐标 | float4 vertex : POSITION; float4 tangent : TANGENT; float3 normal : NORMAL; float4 texcoord : TEXCOORD0; | 可用于顶点着色器的输入 |
appdata_full | 顶点位置、顶点切线、顶点法线、四组(或更多)纹理坐标i | cfloat4 vertex : POSITION; float4 tangent : TANGENT; float3 normal : NORMAL; float4 texcoord : TEXCOORD0; float4 texcoord1 : TEXCOORD1; float4 texcoord2 : TEXCOORD2; float4 texcoord3 : TEXCOORD3; #if defined(SHADER_API_XBOX360) half4 texcoord4 : TEXCOORD4; half4 texcoord5 : TEXCOORD5; #endif fixed4 color : COLOR; | 可用于顶点着色器的输入 |
appdata_img | 可用于顶点着色器的输入 | float4 vertex : POSITION; half2 texcoord : TEXCOORD0; | 可用于顶点着色器的输入 |
v2f_img | 裁剪空间中的位置、纹理坐标 | 可用于顶点着色器的输出 |
命令 | 参数 | 实例 | 说明 |
---|---|---|---|
float4 WorldSpaceViewDir(float4 v) | 输入一个模型空间中的顶点位置,返回世界空间中从该点到摄像机的观察方向 | ||
float4 UnityWorldSpaceViewDir(float4 v) | 输入一个世界空间中的顶点位置,返回世界空间中从该点到摄像机的观察方向 | ||
float4 ObjSpaceViewDir(float4 v) | 输入一个模型空间中的顶点位置,返回模型空间中从该店到摄像机的观察方向 | ||
float4 WorldSpace LightDir(flaot4 v) | 仅用于向前渲染。 输入一个模型空间中的顶点位置,返回世界空间中从该点到光源的光照方向。没有被归一化 | ||
float4 ObjectSpaceLightDir(float4 v) | 仅用于向前渲染中,输入一个模型空间中的顶点位置, 返回模型空间中从该点到光源的光照方向。没有被归一化 | ||
float4 UnityWorldSpaceLightDir(float4 v) | 仅用于向前渲染中,输入一个世界空间中的顶点位置, 返回世界空间中从该点到光源的光照方向。没有被归一化 | ||
float3 UnityObjectToWorldNormal(float3 norm) | 把法线方向从模型空间中转换到世界空间中 | ||
float3 UnityObjectToWorldDir(float3 dir) | 把方向矢量从模型空间中变换到世界空间中 | ||
float3 Unity WorldToObjectDir(float3 dir) | 把方向矢量从世界空间变换到模型空间中 |
函数 | 说明 | 实例 |
---|---|---|
radians(degree) | 角度变弧度(一般默认都用弧度) | |
degrees(radian) | 弧度变角度 | |
sin(angle), cos(angle), tan(angle) | 三角函数 | |
asin(x) | arc sine, 返回弧度 [-PI/2, PI/2]; | |
acos(x) | arc cosine,返回弧度 [0, PI] | |
atan(y, x) | arc tangent, 返回弧度 [-PI, PI]; | |
atan(y/x) | arc tangent, 返回弧度 [-PI/2, PI/2]; | |
pow(x, y) | x的y次方 | |
exp(x) | 指数, log(x) | |
exp2(x) | 2的x次方, log2(x) | |
sqrt(x) | x的根号; | |
inversesqrt(x) | x根号的倒数 | |
abs(x) | 绝对值 | |
sign(x) | 取当前数值的正负符号,返回 1, 0 或 -1 | (x>0;x=0;x<0) |
floor(x) | 底部取整 | |
ceil(x) | 顶部取整 | |
fract(x) | 取小数部分 | |
mod(x, y) | 取模, x - y*floor(x/y) | |
min(x, y) | 取最小值 | |
max(x, y) | 取最大值 | |
clamp(x, min, max) | min(max(x, min), max); | |
mix(x, y, a) | x, y的线性混叠, x(1-a) + y*a; | |
step(edge, x) | 如 x smoothstep(edge0, edge1, x): threshod smooth transition时使用。 edge0<=edge0时为0.0, x>=edge1时为1.0 | |
length(x) | 向量长度 | |
distance(p0, p1) | 两点距离, length(p0-p1); | |
dot(x, y) | 点积,各分量分别相乘 后 相加 | |
cross(x, y) | 差积 | x[1]*y[2]-y[1]*x[2], x[2]*y[0] - y[2]*x[0], x[0]*y[1] - y[0]*x[1] |
normalize(x) | 归一化 | length(x)=1; |
faceforward(N, I, Nref) | 如 dot(Nref, I)< 0则N, 否则 -N | |
reflect(I, N) | I的反射方向 | I -2*dot(N, I)*N, N必须先归一化 |
refract(I, N, eta) | 折射 | k=1.0-etaeta(1.0 - dot(N, I) * dot(N, I)); 如k<0.0 则0.0,否则 etaI - (etadot(N, I)+sqrt(k))*N |
matrixCompMult(matX, matY) | 矩阵相乘, 每个分量 自行相乘 | r[j] = x[j]*y[j]; |
lessThan(vecX, vecY) | 向量 每个分量比较 x < y | |
lessThanEqual(vecX, vecY) | 向量 每个分量比较 x<=y | |
greaterThan(vecX, vecY) | 向量 每个分量比较 x>y | |
greaterThanEqual(vecX, vecY) | 向量 每个分量比较 x>=y | |
equal(vecX, vecY) | 向量 每个分量比较 x==y | |
notEqual(vecX, vexY) | 向量 每个分量比较 x!=y | |
any(bvecX) | 只要有一个分量是true, 则true | |
all(bvecX) | 所有分量是true, 则true | |
not(bvecX) | 所有分量取反 |
固定功能管线着色器的关键代码都在Pass的材质设置Material{}和纹理设置SetTexture{}部分。
目前固定着色器已经逐渐退出市场,只在为兼容一些老旧硬件设备而存在。
1 | Shader "Custom/VertexList" { |
功能强大,且用途最多的渲染器
顶点/片段渲染管线 卸载pass块中,用CGPROGRAM 标签包裹。
该shader 会实现根据观察方向而变色的效果
1 | // Upgrade NOTE: replaced 'mul(UNITY_MATRIX_MVP,*)' with 'UnityObjectToClipPos(*)' |
备受unity宠爱的渲染器
在Unity中,表面着色器的关键代码用Cg/HLSL语言编写,然后嵌在ShaderLab的结构代码中使用。使用表面着色器,用户仅需要编写最关键的表面函数,其余周边代码将由Unity自动生成,包括适配各种光源类型、渲染实时阴影以及集成到前向/延迟渲染管线中等。
光照模型可以是内置的Lambert和BlinnPhong,或者是自定义的光照模型。
表面函数的作用是接收输入的UV或者附加数据,然后进行处理,最后将结构填充到输出结构体SurfaceOutPut中。
表面着色器的输入参数表
数据类型 | 参数 | 说明 |
---|---|---|
float3 | viewDir | 视角方向 |
float4 | COLOR | 每个顶点的插值颜色 |
float4 | screenPos | 屏幕坐标(使用.xy/.w来获得屏幕2D坐标) |
float3 | worldPos | 世界坐标 |
float3 | worldRefl | 世界坐标系中的反射向量 |
float3 | worldNormal | 世界坐标系中的法线向量 |
INTERNAL_DATA | 当输入结构包含worldRefl或worldNormal且表面函数会写入输出结构的Normal字段是需包含此声明 |
表面着色器的输出参数表
1 | struct SurfaceOutput{ |
实例说明
1 | Shader "Custom/surfShader" |
该连接中包含很多着色器代码实例,具体的代码和详细的注释,不定时更新中
https://blog.csdn.net/lengyoumo/article/details/99676462
三大测试:深度测试、透明测试、模版测试
重点:三大测试与剔除都是决定是否显示像素条件!混合是指有透明物体的情况下像素该如何叠加显示
深度测试依据物体在镜头前的空间位置排序。
透明测试依据颜色透明度,也就是alpha值。
模版测试依据自定义的值,当同样带有模版值的元素叠加时触发
剔除与三种测试渲染顺序按先后排列。
Cull 是剔除的意思。
命令 | 说明 | 实例 |
---|---|---|
Off | 绘制所有的面 | Cull Off |
Front | 不绘制面向相机部分的面 | Cull Front |
Back | 不绘制背对相机的面 | Cull Back |
当透明度到达指定值,就输出像素,否则抛弃
语法:
指令 | 说明 | 实例 |
---|---|---|
Greater | 大于,只渲染大于该值的像素。 | alphatest greater [_alphaValue] //类似于抠图 |
Less | 小于,只渲染小于该值的像素。 | 类似于反向抠图 |
GEqual | 大于等于 | |
LEqual | 小于等于 | |
Equal | 等于 | |
NotEqual | 不等于 | |
Always | 总是 | |
Never | 永不 | |
Off | 关闭 | alphatest Off |
例1 表面着色器
1 | 实例,只要声明 alphatest greater [_alphaValue] 即可。 |
例2 片段着色器
1 | fixed4 frag(v2f i):SV_Target{ |
混合命令
指令 | 说明 | 实例 |
---|---|---|
Blend Off | 关闭混合 | |
Blend SrcFactor DstFactor | 开启混合,并设置混合因子,片元颜色胡i成因SrcFactor,而已经存在颜色缓存中的颜色会诚意DstFactor,然后把两者相加后再存入颜色缓冲中 | |
Blend SrcFactor DstFactor, SrcFactorA DstFactorA | 上同,使用不同因子来混合透明通道 | |
BlendOp BlendOperation | 并非是把源颜色和目标颜色简单相加后混合,而是使用BlendOperation对他们进行其他操作 |
混合因子
指令 | 说明 | 实例 |
---|---|---|
One | 因子值为1 | |
Zero | 因子值为0 | |
SrcColor | 因子为源颜色值(当前片元),当用于混合rgb时,使用SrcColor的RGb分量作为混合因子,当用于混合a的混合等式时,使用SrcColor的A分量作为混合因子 | |
SrcAlpha | 因子为源颜色的透明度,A通道 | |
DstColor | 因子为目标颜色(已经存在颜色缓存中的颜色),当用于混合rgb时,使用DstColor的RGb分量作为混合因子,当用于混合a的混合等式时,使用DstColor的A分量作为混合因子 | |
DstAlpha | 因子为源颜色的透明度,A通道 | |
OneMinusSrcColor | 因子为 1 - 源颜色,其余与SrcColor相同 | |
OneMinusSrcAlpha | 因子为 1-源颜色的透明度值 | |
OneMinusDstColor | 因子为 1- 目标颜色,其余与DstColor相同 | |
OneMinusDstAlpha | 因子为 1- 目标颜色透明度 |
混合操作
BlendOp
指令 | 说明 | 实例 |
---|---|---|
Add | 将混合后的源颜色和目标颜色相加 | |
Sub | 将混合后的源颜色减去混合后的目标颜色 | |
RevSub | 用混合后的目标颜色减去混合后的源颜色 | |
Min | 使用源颜色和目标i颜色中较小的值 | |
Max | 使用源颜色和目标颜色中较大的值 |
片段
正常:Blend SrcAlpha OneMunusSrcAlph
柔和相加 Blend OneMinusDstColor One
正片叠底:Blend DstColor Zero
两倍相乘:Blend DstColor SrcColor
变暗:BlendOp Min
Blend One One
变量:BlendOp Max
Blend One One
滤色:Blend OneMinusDstColor One 等偶同于Blend One OneMinusSrcColor
线性减淡:Blend One One
啥是模板测试,每个像素都有一个stencil值,在同一个像素上,所有shader的stencil都共享这一个值,当有其他带有遮罩像素与其重合时就能获取到该值,并根据自身的stencil值处理或。典型的应用就是遮罩显示。你可以选择每次重合都增加1,然后再指定某个物体,当值达到某个数量级再显示。这样的场景,比如,有个隐身的怪物,你只有使用圣水喷雾才能让他现行,但必须喷3次才行,这样,空中就存在了3次叠加的雾,透过这个3层雾就能看到怪物了。但你偏一下角度,透过两层wu就看不到。
Stencil完整语法:
1 | stencil{ |
模板语法
参数 | 说明 | 实例 |
---|---|---|
Ref | ref用来设定参考值(范围0-255)。这个值用来与stencilbuffer比较 | |
ReadMask | ReadMask 从字面意思的理解就是读遮罩,readMask将和referenceValue以及stencilBufferValue进行按位与(&)操作,readMask取值范围也是0-255的整数,默认值为255,二进制位11111111,即读取的时候不对referenceValue和stencilBufferValue产生效果,读取的还是原始值 | |
WriteMask | WriteMask是当写入模板缓冲时进行掩码操作(按位与【&】),writeMask取值范围是0-255的整数,默认值也是255,即当修改stencilBufferValue值时,写入的仍然是原始值。 | |
Comp | Comp是定义参考值(referenceValue)与缓冲值(stencilBufferValue)比较的操作函数,默认值:always | |
Pass | Pass是定义当模板测试(和深度测试)通过时,则根据(stencilOperation值)对模板缓冲值(stencilBufferValue)进行处理,默认值:keep | |
Fail | Fail是定义当模板测试(和深度测试)失败时,则根据(stencilOperation值)对模板缓冲值(stencilBufferValue)进行处理,默认值:keep | |
ZFail | ZFail是定义当模板测试通过而深度测试失败时,则根据(stencilOperation值)对模板缓冲值(stencilBufferValue)进行处理,默认值:keep |
模板对比
指令 | 说明 | 实例 |
---|---|---|
Greater | 大于,只渲染大于该值的像素。 | alphatest greater [_alphaValue] //类似于抠图 |
Less | 小于,只渲染小于该值的像素。 | 类似于反向抠图 |
GEqual | 大于等于 | |
LEqual | 小于等于 | |
Equal | 等于 | |
NotEqual | 不等于 | |
Always | 总是 | |
Never | 永不 | |
Off | 关闭 | alphatest Off |
模板操作
指令 | 说明 | 实例 |
---|---|---|
Keep | 保留当前缓冲中的内容,即stencilBufferValue不变 | |
Zero | 将0写入缓冲,即stencilBufferValue值变为0。 | |
Replace | 将参考值写入缓冲,即将referenceValue赋值给stencilBufferValue。 | |
IncrSat | stencilBufferValue加1,如果stencilBufferValue超过255了,那么保留为255,即不大于255。 | |
DecrSat | stencilBufferValue减1,如果stencilBufferValue超过为0,那么保留为0,即不小于0。 | |
Invert | 将当前模板缓冲值(stencilBufferValue)按位取反 | |
IncrWrap | 当前缓冲的值加1,如果缓冲值超过255了,那么变成0,(然后继续自增) | |
DecrWrap | 当前缓冲的值减1,如果缓冲值已经为0,那么变成255,(然后继续自减) 。 |
实例:遮罩
将此shader付给遮罩物体
1 | Shader "Custom/st1" { |
将此shader付给被遮罩物体
1 | // Upgrade NOTE: replaced 'mul(UNITY_MATRIX_MVP,*)' with 'UnityObjectToClipPos(*)' |
即可完成
根据深度值选择通过测试。
Cull Back | Front | Off
ZWrite On | Off:用于控制是否将对象的像素写入深度缓冲(默认开启),如果需要绘制纯色物体,便将此项打开。如需绘制半透明效果,则关闭深度缓冲。
开启深度写入:当两个像素重合时,根据深度缓冲中的值对比,剔除掉离相机较远的那个,留下最近的那个显示。
关闭深度写入:不剔除任何像素,按顺序覆盖像素。(半透明物体需要这个)
ZTest Less | Greater | LEqual | GEqual | Equal | NotEqual | Always
用于控制深度测试如何执行, 缺省值是LEqual。如果要绘制的像素的Z值 小余等于深度缓冲区中的值,那么就用新的像素颜色值替换。
Offset Factor,Units
利用Factor和Units来定义深度偏移
Factor参数表示Z缩放的最大斜率的值
Units参数表示可分辨的最小深度缓冲区的值
利用该句法,我们就可以强制使位于同一位置上的两个集合体中的一个几何体绘制在另一个的上层
以上几个值可以行内写。
1 | //顶点着色器正文 |
SetTexture[_MainTex]{
Combine Primary * Texture
}
SeparateSpecular On
Blend SrcAlpha OneMinusSrcAlpha
该文章参考了下列文章:
https://www.jianshu.com/p/5be2801e226c
https://docs.unity3d.com/Manual/SL-Pass.html
https://blog.csdn.net/weixin\_33973609/article/details/85751777
https://blog.csdn.net/pizi0475/article/details/6574700
https://blog.csdn.net/zx1091515459/article/details/79262053
https://blog.csdn.net/zyq20130118/article/details/52874639
https://blog.csdn.net/qq\_38572472/article/details/79020122
https://www.cnblogs.com/Jason-c/p/8385946.html
https://blog.csdn.net/qq826364410/article/details/81744032 关于测试
喜欢看书的同学,重点推荐冯乐乐的shader入门教程,虽然说是入门教程,但写的由浅入深阅读曲线十分不错。重点推荐。csdn还有她的博客请自搜。
新地址
https://github.com/QianMo/Awesome-Unity-Shader
https://blog.csdn.net/lyh916/category\_6208767.html
https://blog.csdn.net/ynnmnm/article/details/69791337
]]>to be continued…
这里是前言介绍。
先解释下什么是静态常量(Const)以及什么是动态常量(Readonly)。 ** 静态常量(Const)是指编译器在编译时候会对常量进行解析,并将常量的值替换成初始化的那个值。 ** 动态常量(Readonly)的值则是在运行的那一刻才获得的,编译器编译期间将其标示为只读常量,而不用常量的值代替,这样动态常量不必在声明的时候就初始化,而可以延迟到构造函数中初始化。
静态常量(Compile-time Constant)
动态常量(Runtime Constant)
定义
声明的同时要设置常量值。
声明的时候可以不需要进行设置常量值,可以在类的构造函数中进行设置。
类型限制
只能修饰基元类型,枚举类型或者字符串类型。
没有限制,可以用它定义任何类型的常量。
对于类对象而言
对于所有类的对象而言,常量的值是一样的。
对于类的不同对象而言,常量的值可以是不一样的。
内存消耗
无。
要分配内存,保存常量实体。
综述
性能要略高,无内存开销,但是限制颇多,不灵活。
灵活,方便,但是性能略低,且有内存开销。
举个例子来说明一下
public static readonly int NumberA = NumberB * 10;
public static readonly int NumberB = 10;
public const int NumberC = NumberD*10;
public const int NumberD = 10;
static void Main(string[] args)
{
Console.WriteLine(“NumberA is {0}, NumberB is {1}.”, NumberA, NumberB);//NumberA is 0, NumberB is 10.
Console.WriteLine(“NumberC is {0}, NumberD is {1}.”, NumberC, NumberD);//NumberC is 100, NumberD is 10.
Console.ReadKey();
}
复制
以上是语法方面的应用,那在实际的用法上,还是有些微妙的变化,通常不易发觉. 举个例子来说明一下: 在程序集DoTestConst.dll 中有一个类MyClass,定义了一个公开的静态变量Count
public static class MyClass
{
public const int Count = 10;
}
然后另外一个应用程序中引用DoTestConst.dll,并在代码中作如下调用:
public static void Main(string[] args)
{
Console.WriteLine(DoTestConst.MyClass.Count);//输出10
Console.ReadKey();
}
复制
复制
毫无疑问,非常简单的代码,直接输出10。 接下来更新MyClass的Count的值为20,然后重新编译DoTestConst.dll,并更新到应用程序的所在目录中,注意不要编译应用程序。那么这时候的输出结果按预期那么想应该是20才对,但实际上还是10,为什么呢? 这就是Const的特别之处,有多特别还是直接看生成的IL,查看IL代码(假设这时候Count的值为10)
IL_0000: nop IL_0001: ldc.i4.s 10 IL_0003: call void [mscorlib]System.Console::WriteLine(int32)
红色代码很明显的表明了,直接加载10,没有通过任何类型的加载然后得到对应变量的,也就是说在运行时没有去加载DoTestConst.dll,那么是否意味着没有DoTestConst.dll也可以运行呢?答案是肯定的,删除DoTestConst.dll也可以运行,是否很诡异呢?也就解释了之前的实验,为什么更新Const变量的值之后没有调用新的值,因为程序在运行的时候根本不会去加载DoTestConst.dll。那么10这个值是从哪来的呢?实际上CLR对于Const变量做了特殊处理,是将Const的值直接嵌入在生成的IL代码中,在执行的时候不会再去从dll加载。这也带来了一个不容易发觉的Bug,因此在引用其他程序集的Const变量时,需考虑到版本更新问题,要解决这个问题就是把调用的应用程序再编译一次就ok了。但实际程序部署更新时可能只更新个别文件,这时候就必须用Readonly关键字来解决这个问题。
接下来看Readonly的版本:
public static class MyClass
{
public static readonly int Count = 10;
}
复制
复制
调用方代码不变,接着看生成的IL代码:
IL_0000: nop IL_0001: ldsfld int32 [DoTestConst]DoTestConst.MyClass::Count IL_0006: call void [mscorlib]System.Console::WriteLine(int32)
很明显加载代码变了,一个很常见的ldsfld动作,请求了DoTestConst.MyClass的Count变量,是通过强制要求加载DoTestConst来实现的。因此这时候更新Count的值重新编译之后,还是不编译调用程序,然后再执行就会看到新的值。而这时候如果删除DoTestConst.dll那么,会出现找不到dll之类的异常。这也充分说明了对于Readonly定义的变量是在运行时加载的。
ReadOnly 变量是运行时变量,它在运行时第一次赋值后将不可以改变。其中“不可以改变”分为两层意思:
值类型变量,举个例子说明一下:
public class Student
{
public readonly int Age;
public Student(int age)
{
this.Age = age;
}
}
复制
Student的实例Age在构造函数中被赋值以后就不可以改变,下面的代码不会编译通过:
Student student = new Student(20);
student.Age = 21; //错误信息:无法对只读的字段赋值(构造函数或变量初始化器中除外)
复制
复制
引用类型变量,举个例子说明一下:
public class Student
{
public int Age; //注意这里的Age是没有readonly修饰符的
public Student(int age)
{
this.Age = age;
}
}
public class School
{
public readonly Student Student;
public School(Student student)
{
this.Student = student;
}
}
复制
School实例的Student是一个引用类型的变量,赋值后,变量不能再指向其他任何的Student实例,所以,下面的代码将不会编译通过:
School school = new School(new Student(10));
school.Student = new Student(20);//错误信息:无法对只读的字段赋值(构造函数或变量初始化器中除外)
复制
复制
引用本身不可以改变,但是引用说指向的实例的值是可以改变的。所以下面的代码是可以编译通过的:
School school = new School(new Student(10));
school.Student.Age = 20;
复制
在构造方法中,我们可以多次对Readonly修饰的常量赋值。举个例子说明一下:
public class Student
{
public readonly int Age = 20;//注意:初始化器实际上是构造方法的一部分,它其实是一个语法糖
public Student(int age)
{
this.Age = age;
this.Age = 25;
this.Age = 30;
}
}
Const和Readonly的最大区别(除语法外) Const的变量是嵌入在IL代码中,编译时就加载好,不依赖外部dll(这也是为什么不能在构造方法中赋值)。Const在程序集更新时容易产生版本不一致的情况。 Readonly的变量是在运行时加载,需请求加载dll,每次都获取最新的值。Readonly赋值引用类型以后,引用本身不可以改变,但是引用所指向的实例的值是可以改变的。在构造方法中,我们可以多次对Readonly赋值。
]]>to be continued…
最近在工作中使用粒子特效是发现了两个问题
1. 美术提供的粒子特效没有根据屏幕分辨率自适应调整大小2. 粒子特效的层级比较奇怪
在Unity编辑器开发中,使用的是 1080*1920
的分辨率,粒子特效大小看起来完美契合。
在打包运行在手机上时,因为手机的分辨率为 1440*2960
,粒子特效就明显大于了UI界面。
解决办法:
将粒子特效的 Scaling Mode
[缩放模式] ,改为 Hierarchy [跟随父节点缩放]。
注意,改变了缩放模式之后,运行时会发现粒子变得特别小,这是因为Unity重置了该粒子特效的大小,只需要重新调整大小即可。
因为我是在FGUI的基础上穿插使用粒子特效,因为 FGUI 与 Unity 的比例不一致,比例为1:100,所以粒子特效需要将缩放比放大100倍。
Scaling Mode
为Local
时不会有 FGUI 的比例问题,但是会有分辨率不适配问题,两害取其轻,所以选用Hierarchy
模式。
1 | effect.transform.localScale = Vector3.one * 100; |
描述:当你把粒子特效挂到UI上之后,你发现要么粒子全被遮住了或者完全看不到特效。
解决方法:根据需求,修改粒子特效层级。 同样在Particle System 面板里,往下拉找到Renderer组件,若未勾选则将其勾选打开。里面会有两个相关层级的选项:Sorting Layer (排序层级)和 Order in Layer (在层级中的位序)
只要将这两个层级改成与你的挂在UI一个层级,就会完全看到这个特效。如果你需要特效被遮挡,则需要将该层级改成低于UI的层级。
这里有一个坑:当粒子特效和UI处于同一层级的时候,粒子特效不会被UI遮盖,无视节点顺序。
当我们UI和粒子特效都处在同一层级时,此时我们能看到粒子特效没问题。但是假如此时有提示弹窗弹出,需要遮住整个界面的时候。你就会发现UI是遮住了,但是粒子特效却跃然于UI之上。解决这个问题只能是弹窗的层级必须比粒子特效高一个层级,否则无论如何粒子特效都能无视节点顺序,显示在同UI层级的UI之上
]]>to be continued…
《守望先锋》2017 GDC系列的分享前几年给了我很多帮助,尤其是kevinan大神的翻译更让我受益良多,如今我再想温习一下相关技术却发现很多网络上的文章图片都已经坏掉了,故在此收集网络资源发布重置版,当成备份。
重制版内容:新增多级标题,方便分块阅读,部分图片已由本人重置,一些必要的地方我录制了Gif图,方便观看。
全系列链接:《守望先锋》GDC2017技术分享精粹重制版总目录
原视频链接:https://www.youtube.com/watch?v=W3aieHjyNvw&t=2886s&ab_channel=GDC
哈喽,大家好,这次的分享是关于《守望先锋》(译注:下文统一简称为Overwatch)游戏架构设计和网络部分。老规矩,手机调成静音;离开时记得填写调查问卷;换下半藏,赶紧推车!(众笑)
我是Tim Ford,是暴雪公司Overwatch开发团队老大。自从2013年夏季项目启动以来就在这个团队了。在那之前,我在《Titan》项目组,不过这次分享跟Titan没有半毛钱关系。(众笑)
这次分享的一些技术,是用来降低不停增长的代码库的复杂度(译注,代码复杂度的概念需要读者自行查阅)。为了达到这个目的我们遵循了一套严谨的架构。最后会通过讨论网络同步(netcode)这个本质很复杂的问题,来说明具体如何管理复杂性。
Overwatch是一个近未来世界观的在线团队英雄射击游戏,它的主要是特点是英雄的多样性, 每个英雄都有自己的独门绝技。
Overwatch使用了一个叫做“实体组件系统”的架构,接下来我会简称它为ECS。
ECS不同于一些现成引擎中很流行的那种组件模型,而且与90年代后期到21世纪早期的经典Actor模式区别更大。我们团队对这些架构都有多年的经验,所以我们选择用ECS有点是“这山望着那山高”的意味。不过我们事先制作了一个原型,所以这个决定并不是一时冲动。
开发了3年多以后,我们才发现,原来ECS架构可以管理快速增长的代码复杂性。虽然我很乐意分享ECS的优点,但是要知道,我今天所讲的一切其实都是事后诸葛亮 。
ECS架构概述
ECS架构看起来就是这样子的。先有个World,它是系统(译注,这里的系统指的是ECS中的S,不是一般意义上的系统,为了方便阅读,下文统称System)和实体(Entity)的集合。而实体就是一个ID,这个ID对应了组件(Component)的集合。组件用来存储游戏状态并且没有任何的行为(Behavior)。System有行为但是没有状态。
这听起来可能挺让人惊讶的,因为组件没有函数而System没有任何字段。
ECS引擎用到的System和组件
图的左手边是以轮询顺序排列的System列表,右边是不同实体拥有的组件。在左边选择不同的System以后,就像弹钢琴一样,所有对应的组件会在右边高亮显示,我们管这叫组件元组(译注,元组tuple,从后文来看,主要作用就是可以调用Sibling函数来获取同一个元组内的组件,有点虚拟分组的意思)。
System遍历检查所有元组,并在其状态(State)上执行一些操作(也就是行为Behavior)。记住组件不包含任何函数,它的状态都是裸存储的。
绝大多数的重要System都关注了不止一个组件,如你所见,这里的Transform组件就被很多System用到。
来自原型引擎里的一个System轮询(tick)的例子
这个是物理System的轮询函数,非常直截了当,就是一个内部物理引擎的定时更新。物理引擎可能是Box2d或者是Domino(暴雪自有物理引擎)。执行完物理世界的模拟以后,就遍历元组集合。用DynamicPhysicsComponent组件里保存的proxy来取到底层的物理表示,并把它复制给Transform组件和Contact组件(译注:碰撞组件,后文会大量用到)。
System不知道实体到底是什么,它只关心组件集合的小切片(slice,译注:可以理解为特定子集合),然后在这个切片上执行一组行为。有些实体有多达30个组件,而有些只有2、3个,System不关心数量,它只关心执行操作行为的组件的子集。
像这个原型引擎里的例子,(指着上图7中)这个是玩家角色实体,可以做出很多很酷的行为,右边这些是玩家能够发射的子弹实体。
每个System在运行时,不知道也不关心这些实体是什么,它们只是在实体相关组件的子集上执行操作而已。
Overwatch里的(ECS架构的)实现,就是这样子的。
EntityAdmin是个World,存储了一个所有System的集合,和一个所有实体的哈希表。表键是实体的ID。ID是个32位无符号整形数,用来在实体管理器(Entity Array)上唯一标识这个实体。另一方面,每个实体也都存了这个实体ID和资源句柄(resource handle),后者是个可选字段,指向了实体对应的Asset资源(译注:这需要依赖暴雪的另一套专门的Asset管理系统),资源定义了实体。
组件Component是个基类,有几百个子类。每个子类组件都含有在System上执行Behavior时所需的成员变量。在这里多态唯一的用处就是重载Create和析构(Destructor)之类的生命周期管理函数。而其他能被继承组件类实例直接使用的,就只有一些用来方便地访问内部状态的helper函数了。但这些helper函数不是行为(译注:这里强调是为了遵循前面提到的原则:组件没有行为),只是简单的访问器。
EntityAdmin的结尾部分会调用所有System的Update。每个System都会做一些工作。上图9就是我们的使用方式,我们没有在固定的元组组件集合上执行操作,而是选择了一些基础组件来遍历,然后再由相应的行为去调用其他兄弟组件。所以你可以看到这里的操作只针对那些含有Derp和Herp组件的实体的元组执行。
Overwatch客户端的System和组件列表
这里有大概46不同的System和103个组件。这一页的炫酷动画是用来吸引你们看的(众笑)。
然后是服务器
你可以看到有些System执行需要很多组件,而有些System仅仅需要几个。理想情况下,我们尽量确保每个System都依赖很多组件去运行。把他们当成纯函数(译注,pure function,无副作用的函数),而不改变(mutating)它们的状态,就可以做到这一点。我们的确有少量的System需要改变组件状态,这种情况下它们必须自己管理复杂性。
下面是个真实的System代码
这个System是用来管理玩家连接的,它负责我们所有游戏服务器上的强制下线(译注,AFK, Away From Keyboard,表示长时间没操作而被认为离线)功能。
这个System遍历所有的Connection组件(译注:这里不太合适直接翻译成“连接”),Connection组件用来管理服务器上的玩家网络连接,是挂在代表玩家的实体上的。它可以是正在进行比赛的玩家、观战者或者其他玩家控制的角色。System不知道也不关心这些细节,它的职责就是强制下线。
每一个Connection组件的元组包含了输入流(InputStream)和Stats组件(译注:看起来是用来统计战斗信息的)。我们从输入流组件读入你的操作,来确保你必须做点什么事情,例如键盘按键;并从Stats组件读取你在某种程度上对游戏的贡献。
你只要做这些操作就会不停重置AFK定时器,否则的话,我们就会通过存储在Connection组件上的网络连接句柄发消息给你的客户端,踢你下线。
System上运行的实体必须拥有完整的元组才能使得这些行为能够正常工作。像我们游戏里的机器人实体就没有Connection组件和输入流组件,只有一个Stats组件,所以它就不会受到强制下线功能的影响。System的行为依赖于完整集合的“切片”。坦率来说,我们也确实没必要浪费资源去让强制机器人下线。
上面System的更新行为会带来了一个疑问:为什么不能使用传统的面向对象编程(OOP)的组件模型呢?例如在Connection组件里重载Update函数,不停地跟踪检测AFK?
答案是,因为Connection组件会同时被多个行为所使用,包括:AFK检查;能接收网络广播消息的已连接玩家列表;存储包括玩家名称在内的状态;存储玩家已解锁成就之类的状态。所以(如果用传统OOP方式的话)具体哪个行为应该放在组件的Update中调用?其余部分又应该放在哪里?
传统OOP中,一个类既是行为又是数据,但是Connection组件不是行为,它就只是状态。Connection完全不符合OOP中的对象的概念,它在不同的System中、不同的时机下,意味着完全不同的事情。
想象一下你家前院盛开的樱桃树吧,从主观上讲,这些树对于你、你们小区业委会主席、园丁、一只鸟、房产税官员和白蚁而言都是完全不同的。从描述这些树的状态上,不同的观察者会看见不同的行为。树是一个被不同的观察者区别对待的主体(subject)。
类比来说,玩家实体,或者更准确地说,Connection组件,就是一个被不同System区别对待的主体。我们之前讨论过的管理玩家连接的System,把Connection组件视为AFK踢下线的主体;连接实用程序(ConnectUtility)则把Connection组件看作是广播玩家网络消息的主体;在客户端上,用户界面System则把Connection组件当做记分板上带有玩家名字的弹出式UI元素主体。
Behavior为什么要这么搞?结果看来,根据主体视角区分所有Behavior,这样来描述一棵树的全部行为会更容易,这个道理同样也适用于游戏对象(game objects)。
然而随着这个工业级强度的ECS架构的实现,我们遇到了新的问题。
首先我们纠结于之前定下的规矩:组件不能有函数;System不能有状态。显而易见地,System应该可以有一些状态的,对吧?一些从其他非ECS架构导入的遗留System都有成员变量,这有什么问题吗?举个例子,InputSystem, 你可以把玩家输入信息保存在InputSystem里,而其他System如果也需要感知按键是否被按下,只需要一个指向InputSystem的指针就能实现。
在单个组件里存储一个全局变量看起来很很愚蠢,因为你开发一个新的组件类型,不可能只实例化一次(译注:这里的意思是,如果实例化了多次,就会有多份全局变量的拷贝,明显不合理),这一点无需证明。组件通常都是按照我们之前看见过的那种方式(译注:指的是通过ComponentItr<>
函数模板那种方式)来迭代访问,如果某个组件在整个游戏里只有一个实例,那这样访问就会看起来比较怪异了。
无论如何,这种方式撑了一阵子。我们在System里存储了一次性(one-off)的状态数据,然后提供了一个全局访问方式。从图16可以看到整个访问过程(译注:重点是g_game->m_inputSystem这一行)。
如果一个System可以调用另外一个System的话,对于编译时间来说就不太友好了,因为System需要互相包含(include)。假定我现在正在重构InputSystem,想移动一些函数,修改头文件(译注:Client/System/Input/InputSystem.h),那么所有依赖这个头文件去获取输入状态的System都需要被重新编译,这很烦人,还会有大量的耦合,因为System之间互相暴露了内部行为的实现。(译注:转载不注明出处,真的大丈夫吗?还把译者的名字都删除!声明:这篇文章是本人kevinan应GAD要求而翻译!)
从图16最下面可以看见我们有个PostBuildPlayerCommand函数,这个函数是InputSystem在这里的主要价值。如果我想在这个函数里增加一些新功能,那么CommandSystem就需要根据玩家的输入,填充一些额外的结构体信息发给服务器。那么我这个新功能应该加到CommandSystem里还是PostBuildPlayerCommand函数里呢?我正在System之间互相暴露内部实现吗?
随着系统的增长,选择在何处添加新的行为代码变得模棱两可。上面CommandSystem的行为填充了一些结构体,为什么要混在一起?又为什么要放到这里而不是别处?
无论如何,我们就这样凑合了好一阵子,直到死亡回放(Killcam)需求的出现。
为了实现Killcam,我们会有两个不同的、并行的游戏环境,一个用来进行实时游戏过程渲染,一个用来专门做Killcam。我接下来会展示它们是如何实现的。
首先,也很直接,我会添加第二个全新的ECS World,现在就有两个World了,一个是liveGame(正常游戏),一个是replayGame用来实现回放(Replay)。
回放(Replay)的工作方式是这样的,服务器会下发大概8到12秒左右的网络游戏数据,接着客户端翻转World,开始渲染replayAdmin这个World的信息到玩家屏幕上。然后转发网络游戏数据给replayAdmin,假装这些数据真的是来自网络的。此时,所有的System,所有的组件,所有的行为都不知道它们并没有被预测(predict,译注:后面才讲到的同步技术),它们以为客户端就是实时运行在网络上的,像正常游戏过程一样。
听起来很酷吧?如果有人想要了解更多关于回放的技术,我建议你们明天去听一下Phil Orwig的分享,也是在这个房间,上午11点整。
无论如何,到现在我们已经知道的是:首先,所有需要全局访问System的调用点(call sites)会突然出错(译注:Tim思维太跳跃了,突然话锋一转,完全跟不上);另外,不再只有唯一一个全局EntityAdmin了,现在有两个;System A无法直接访问全局System B,不知怎地,只能通过共享的EntityAdmin来访问了,这样很绕。
在Killcam之后,我们花了很长时间来回顾我们的编程模式的缺陷,包括:怪异的访问模式;编译周期太长;最危险的是内部系统的耦合。看起来我们有大麻烦了。
针对这些问题的最终解决方案,依赖于这样一个事实:开发一个只有唯一实例的组件其实没什么不对!根据这个原则,我们实现了一个单例(Singleton)组件。
这些组件属于单一的匿名实体,可以通过EntityAdmin直接访问。我们把System中的大部分状态都移到了单例中。
这里我要提一句,只需要被一个System访问的状态其实是很罕见的。后来在开发一个新System的过程中我们保持了这个习惯,如果发现这个系统需要依赖一些状态。就做一个单例来存储,几乎每一次都会发现其他一些System也同样需要这些状态,所以这里其实已经提前解决了前面架构里的耦合问题。
下面是一个单例输入的例子。
全部按键信息都存在一个单例里面,只是我们把它从InputSystem中移出来了。任何System如果想知道按键是否按下,只需要随便拿一个组件来询问(那个单例)就行了。这样做以后,一些很麻烦的耦合问题消失了,我们也更加遵循ECS的架构哲学了:System没有状态;组件不带行为。
按键并不是行为,掌管本地玩家移动的Movement System里有一个行为,用这个单例来预测本地玩家的移动。而MovementStateSystem里有个行为是把这些按键信息打包发到服务器(译注:按键对于不同的System就不是不同的主体)。
结果发现,单例模式的使用非常普遍,我们整个游戏里的40%组件都是单例的。
一旦我们把某些System状态移到单例中,会把共享的System函数分解成Utility(实用)函数,这些函数需要在那些单例上运行,这又有点耦合了,我们接下来会详细讨论。
改造后如图22,InputSystem依然存在(译注:然而并没有看到InputSystem在哪里),它负责从操作系统读取输入操作,填充SingletonInput的值,然后下游的其他System就可以得到同样的Input去做它们想做的。
像按键映射之类的事情就可以在单例里实现,就与CommandSystem解耦了。
我们把PostBuildPlayerCommand函数也挪到了CommandSysem里,本应如此,现在可以保证所有对玩家输入的命令(PlayerCommand)的修改都能且仅能在此处进行了。这些玩家命令是很重要的数据结构,将来会在网络上同步并用来模拟游戏过程。
在引入单例组件时,我们还不知道,我们其实正在打造的是一个解耦合、降低复杂度的开发模式。在这个例子中,CommandSystem是唯一一处能够产生与玩家输入命令相关副作用的地方(译注:sideeffect,指当调用函数时,除了返回函数值之外,还对主调用函数产生附加影响,例如修改全局变量了)。
每个程序员都能轻易地了解玩家命令的变化,因为在一次System更新的同一时刻,只有这一处代码有可能产生变化。如果想添加针对玩家命令的修改代码,那也很明朗,只能在这个源文件中改,所有的模棱两可都消失了。
现在讨论另外一个问题,与共享行为(sharedbehavior)有关。
共享行为一般出现在同一行为被多个System用到的时候。
有时,同一个主体的两个观察者,会对同一个行为感兴趣。回到前面樱花树的例子,你的小区业委会主席和园丁,可能都想知道这棵树会在春天到来的时候,掉落多少叶子。
根据这个输出可以做不同的处理,至少主席可能会冲你大喊大叫,园丁会老老实实回去干活,但是这里的行为是相同的。
举个例子,大量代码都会关心“敌对关系”,例如,实体A与实体B互相敌对吗?敌对关系是由3个可选组件共同决定的:filter bits,pet master和pet。filter bits存储队伍编号(team index);pet master存储了它所拥有全部pet的唯一键;pet一般用于像托比昂的炮台之类。
如果2个实体都没有filter bits,那么它们就不是敌对的。所以对于两扇门来说,它们就不是敌对的,因为它们的filter bits组件没有队伍编号。
如果它们(译注:2个实体)都在同一个队伍,那自然就不是敌对的,这很容易理解。
如果它们分别属于永远敌对的2个队伍,它们会同时检查自己身上和对方身上的pet master组件,确保每个pet都和对方是敌对关系。这也解决了一个问题:如果你跟每个人都是敌对的,那么当你建造一个炮台时,炮台会立马攻击你(译注:完全没理解为什么会这样)。确实会的,这是个bug,我们修复了。(众笑)
如果你想检查一枚飞行中的炮弹的敌对关系,只需要回溯检查射出这枚炮弹的开火者就行了,很简单。
这个例子的实现,其实就是个函数调用,函数名是CombatUtilityIsHostile,它接受2个实体作为参数,并返回true或者false来代表它们是否敌对。无数System都调用了这个函数。
图25中就是调用了这个函数的System,但是如你所见,只用到了3个组件,少得可怜,而且这3个组件对它们都是只读的。更重要的是,它们是纯数据,而且这些System绝不会修改里面的数据,仅仅是读。
再举一个用到这个函数的例子。
作为一个例子,当用到共享行为的Utility函数时我们采用了不同的规则。
如果你想在多处调用一个Utility函数,那么这个函数就应该依赖很少的组件,而且不应该带副作用或者很少的副作用。如果你的Utility函数依赖很多组件,那就试着限制调用点的数量。
我们这里的例子叫做CharacterMoveUtil,这个函数用来在游戏模拟过程中的每个tick里移动玩家位置。有两处调用点,一处是在服务器上模拟执行玩家的输入命令,另一处是在客户端上预测玩家的输入。
我们继续用Utility函数替换 System间的函数调用,并把状态从System移到单例组件中。
如果你打算用一个共享的Utility函数替换System间的函数调用,是不可能自动地(magically)避免复杂性的,几乎都得做语句级的调整。
正如你可以把副作用都隐藏在那些公开访问的System函数后面一样,你也可以在Utility函数后面做同样的事。
如果你需要从好几处调用那些Utility函数,就会在整个游戏循环中引入很多严重的副作用。虽然是在函数调用后面发生的,看起来没那么明显,但这也是相当可怕的耦合。
如果本次分享只让你学到一点的话,那最好是:如果只有一个调用点,那么行为的复杂性就会很低,因为所有的副作用都限定到函数调用发生的地方了。
下面浏览一下我们用来减少这类耦合的技术。
当你发现有些行为可能产生严重的副作用,又必须执行时,先问问你自己:这些代码,是必须现在就执行吗?
好的单例组件可以通过“推迟”(Deferment)来解决System间耦合的问题。“推迟”存储了行为所需状态,然后把副作用延后到当前帧里更好的时机再执行。
例如,代码里有好多调用点都要生成一个碰撞特效(impact effects)。
包括hitscan(译注:直射,没有飞行时间)子弹;带飞行时间的可爆炸抛射物;查里娅的粒子光束,光束长得就像墙壁裂缝,而且在开火时需要保持接触目标;另外还有喷涂。
创建碰撞特效的副作用很大,因为你需要在屏幕上创建一个新的实体,这个实体可能间接地影响到生命周期、线程、场景管理和资源管理。
碰撞特效的生命周期,需要在屏幕渲染之前就开始,这意味着它们不需要在游戏模拟的中途显现,在不同的调用点都是如此。
下图30是用来创建碰撞特效的一小部分代码。基于Transform(译注:变形,包括位移旋转和缩放)、碰撞类型、材质结构数据来做碰撞计算,而且还调用了LOD、场景管理、优先级管理等,最终生成了所需的特效。
这些代码确保了像弹孔、焦痕持久特效不会很奇怪的叠在一起。例如,你用猎空的枪去射击一面墙,留下了一堆麻点,然后法老之鹰发出一枚火箭弹,在麻点上面造成了一个大面积焦痕。你肯定想删了那些麻点,要不然看起来会很丑,像是那种深度冲突(Z-Fighting)引起的闪烁。我可不想在到处去执行那个删除操作,最好能在一处搞定。
我得修改代码了,但是看上去好多啊,调用点一大堆,改完了以后每一处都需要测试。而且以后英雄越来越多,每个人都需要新的特效。然后我就到处复制粘贴这个函数的调用,没什么大不了的,不就是个函数调用嘛,又不是什么噩梦。(众笑)
其实这样做以后,会在每个调用点都产生副作用的。程序员就得花费更多脑力来记住这段代码是如何运作的,这就是代码复杂度所在,肯定是应该避免的。
于是我们有了Contact单例。
它包含了一个未决的碰撞记录的数组,每个记录都有足够的信息,来在本帧的晚些时候创建那个特效。如果你想要生成一个特效的时候,只需要添加一条新记录并填充数据就可以了。等运行到帧的后期,进行场景更新和准备渲染的时候,ResolveContactSystem会遍历数组,根据LOD规则生成特效并互相叠加。这样的话,即使有严重的副作用,在每一帧也只是发生在一个调用点而已。
除了降低复杂度以外,“推迟”方案还有很多其他优点。数据和指令都缓存在本地,可以带来性能提升;你可以针对特效做性能预算了,例如你有12个D.VA同时在射墙,她们会带来数百个特效,你不用立即创建全部这些特效,你可以仅仅创建自己操纵的D.VA的特效就可以了,其他特效可以在后面的运算过程中分摊开来,平滑性能毛刺。这样做有很多好处,真的,你现在可以实现一些复杂的逻辑了。即使ResolveContactSystem需要执行多线程协作,来确定单个粒子效果的朝向, 现在也很容易做。“推迟”技术真的很酷。
Utility函数,单例,推迟,这些都只是我们过去3年时间建立ECS架构的一小部分模式。除了限制System中不能有状态,组件里不能有行为以外,这些技术也规定了我们在Overwatch中如何解决问题。
遵守这些限制意味着你要用很多奇技淫巧来解决问题。不过,这些技术最终造就了一个可持续维护的、解耦合的、简洁的代码系统。它限制了你,它把你带到坑里,但这是个“成功之坑”。
学习了这些之后呢,咱们来聊聊真正的难题之一,以及ECS是如何简化它的。
作为gameplay(游戏玩法,机制)工程师,我们解决过的最重要的问题就是网络同步(netcode)。
这里先说下目标,是要开发一款快速响应(responsive)的网络对战动作游戏。为了实现快速响应,就必须针对玩家的操作做预测(predict,也可以说是预表现)。如果每个操作都要等服务器回包的话,就不可能有高响应性了。尽管因为一些混蛋玩家作弊所以不能信任客户端,但是已经20年了,这条FPS游戏真理没变过。详见原视频的 22:50 - 23:16 部分
游戏中有快速响应需求的操作包括:移动,技能,就我们而言还有带技能的武器,以及命中判定(hit registration)。
这里所有的操作都有统一的原则:玩家按下按键后必须立即能够看到响应。即使网络延迟很高时也必须是如此。
像我这页PPT中演示的那样,ping值已经250ms了,我所有的操作也都是立即得到反馈的,“看上去”很完美,一点延迟都没有。
然而呢,带预测的客户端,服务器的验证和网络延迟就会带来副作用:预测错误(misprediction,或者说预测失败)了。预测错误的主要症状就一点,会使得你没能成功执行“你认为你已经做出的”操作。
虽然服务器需要纠正你的操作,但代价并不会是操作延迟。我们会用”确定性”(Determinism)来减少预测错误发生的概率,下面是具体的做法。
前提条件不变,PING值还是250毫秒。我认为我跳起来了,但是服务器不这么看,我被猛拉回原地,而且被冻住了(冰冻是英雄Mei的技能之一)。这里(原视频23:30 - 23:50)你甚至可以看到整个预测的工作过程。预测过程开始时,试图把我们移到空中,甚至大猩猩跳跃技能的CD都已经进入冷却了,这是对的,我们不希望预测准确率仅仅是十之八九。所以我们希望尽可能的快速响应,
如果你碰巧在斯里兰卡玩这个游戏,而且又被Mei冻住了,那么就有可能会预测错误。
下面我会首先给出一些准则,然后讨论一下这个崭新的技术是如何利用ECS来减少复杂度的。
这里不会涉及到通用的数据复制技术、远端实体插值(remote entity interpolation)或者是向后缓和(backwardsreconciliation)技术细节。
我们完全是站在巨人的肩膀上,使用了一些其他文献中提过的技术而已。后面的幻灯片会假定大家对那些技术都已经很熟悉了。
确定性模拟技术依赖于时钟的同步,固定的更新周期和量化。服务器和客户端都运行在这个保持同步的时钟和量化值之上。时间被量化成command frame,我们称之为“命令帧”。每个命令帧都是固定的16毫秒,不过在电竞比赛时是7毫秒。
模拟过程的频率是固定的,所以需要把计算机时钟循环转换为固定的命令帧序号。我们使用了一个循环累加器来处理帧号的增长。
在我们的ECS框架内,任何需要进行预表现、或者基于玩家的输入模拟结果的System,都不会使用Update,而是用UpdateFixed。UpdateFixed会在每个固定的命令帧调用。
重制版说明:因为是Gif图,所以会有卡顿,原视频是一直流畅往前运行的,希望不要引起误解
假定输出流是稳定的,那么客户端的始终总是会超前于服务器的,超前了大概半个RTT加上一个缓存帧的时长。这里的RTT就是PING值。上图39的例子中,我们的RTT是160毫秒,一半就是80毫秒,再加上1个缓存帧时长(上图中为1帧),我们每帧是16毫秒,全加起来就是客户端相对于服务器的提前量。
图中的垂直线代表每一个处理中的帧。客户端开始模拟并把第19帧的输入上报给服务器,过一段时间(基本上是半个RTT加上缓冲时间)以后,服务器才开始模拟这一帧。这就是我为什么要说客户端永远是领先于服务器的。
正因为客户端是一股脑的尽快接受玩家输入,尽可能地贴近现在时刻,如果还需要等待服务器回包才能响应的话,那看起来就太慢了,会让游戏变得卡顿。图39中的缓冲区,你肯定希望尽可能的小(译注:缓冲越小,模拟时就越接近当前时刻),顺便说一句,游戏运行的频率是60赫兹,我这里播放动画的速度是正常速度的百分之一(译注:这也是为了让观众看得更清晰、明白)。
客户端的预测System读取当前输入,然后模拟猎空的移动过程。我这里是用游戏摇杆来表示猎空的输入操作并上报的。这里的(第14帧)猎空是我当前时刻模拟出来的运动状态,经过完整的RTT加上缓冲事件,最终猎空会从服务器上回到客户端(译注:这里最好结合演讲视频,静态的文章无法表达到位)。这里回来的是经过服务器验证的运动状态快照。服务器模拟权威带来的副作用就是验证需要额外的半个RTT时间才能回到客户端。
那么这里客户端为什么要用一个环形缓冲(ring buffer)来记录历史运动轨迹呢?这是为了方便与服务器返回的结果进行对比。经过比较,如果与服务器模拟结果相同,那么客户端会开开心心地继续处理下一个输入。如果结果不一致,那就是一个“预测错误”,这时就需要“和解”(reconcile)了。
如果想简单,那就直接用服务器下发的结果覆盖客户端就行了,但是这个结果已经是“旧”(相对于当前时刻的输入来讲)的了,因为服务器的回包一般都是几百毫秒之前的了。
除了上面那个环形缓冲以外,我们还有另一个环形缓冲用来存储玩家的输入操作。因为处理移动的代码是确定性的,一旦玩家开始进入他想要进入到移动状态,想要重现这个过程也是很容易的。所以这里我们的处理方式就是,一旦从服务器回包发现预测失败,我们把你的全部输入都重播一遍直至追上当前时刻。如下图41中的第17帧所示,客户端认为猎空正在跑路,而服务器指出,你已经被晕住了,有可能是受到了麦克雷的闪光弹的攻击。
接下来的流程是,当客户端收到描述角色状态的数据包时,我们基本上就得把移动状态及时恢复到最近一次经过服务器验证过状态上去,而且必须重新计算之后所有的输入操作,直至追上当前时刻(第25帧)。
现在客户端进行到第27帧(上图)了,这时我们收到了服务器上第17帧的回包。一旦重新同步(译注:注意下图41中客户端猎空的状态全都更正为“晕”了)以后,就相当于回退到了“帧同步”(lockstep)算法了。
我们肯定知道我们到底被晕了多久。
到了下图第33帧以后,客户端就知道已经不再是晕住的状态了,而服务器上也正在模拟相同的情况。不再有奇怪的同步追赶问题了。一旦进入这个移动状态,就可以重发玩家当前时刻的操作输入了。
然而,客户端网络并不保证如此稳定,时有丢包发生。我们游戏里的输入都是通过定制化的可靠UDP实现。所以客户端的输入包常常无法到达服务器,也就是丢包。服务器又试图保持了一个小小的、保存未模拟输入的缓冲区,但是让它尽量的小,以保证游戏操作的流畅。
一旦这个缓冲区是空的,服务器只能根据你最后一次输入去“猜测”。等到真正的输入到达时,它会试着“缓和”,确保不会弄丢你的任何操作,但是也会有预测错误。
下面是见证奇迹的时刻。
上图可以看到,已经丢了一些来自客户端的包,服务器意识到以后,就会复制先前的输入操作来就行预测,一边祈祷希望预测正确,一边发包告诉客户端:“嘿哥们,丢包了,不太对劲哦”。接下来发生的就更奇怪的了,客户端会进行时间膨胀,比约定的帧率更快地进行模拟。
这个例子里,约定好的帧速是16毫秒,客户端就会假装现在帧速是15.2毫秒,它想要更加提前。结果就是,这些输入来的越来越快。服务器上缓冲区也会跟着变大,这就是为了在尽量不浪费的情况下,度过(丢包的)难关。
这种技术运转良好,尤其是在经常抖动的互联网环境下,丢包和PING都不稳定。即使你是在国际空间站里玩这个游戏,也是可以的。所以我想这个方案真的很NB。
现在,各位都记个笔记吧,这里收到消息,现在开始放大时间刻度,注意我们是真的加速轮询了,你可以看见图中右边的坡越来越平坦了。它比以前更加快速地上报输入。同时服务器上的缓冲也越来越大了,可以容忍更多地丢包,如果真的发生丢包也有可能在缓冲期间补上。
doc_image_51_w602_h339
一旦服务器发现,你现在的网络恢复健康了,它就会发消息给你说:“嘿哥们,现在没事了”。而客户端会做相反的事情:它会缩小时间刻度,以更慢的速度发包。同时服务器会减小缓冲区的尺寸。
doc_image_52_w601_h338
如果这个过程持续发生,那目标就会是是不要超过承受极限,并通过输入冗余来使得预测错误最小化。
温馨提示:(原视频的 30:50 - 31:56 体现了客户端时间膨胀和服务端缓冲区变化全过程)
早些时候我有提到过,服务器一旦饥饿,就会复制最后一次输入操作,对吧?一旦客户端赶上来了,就不会再复制输入了,这样会有因为丢包而被忽略的风险。解决方法是,客户端维持一个输入操作的滑动窗口。这项技术从《雷神世界》开始就有了。
我们不是仅仅发送当前第19帧的输入,而是把从最后一次被服务器确认的运动状态到现在的全部输入都发送过去。上面的例子可以看出,最后一次从服务器来的确认是第4帧。而我们刚刚模拟到了第19帧。我们会把每一帧的每一个输入都打包成为一个数据包。玩家一般顶多每1/60秒才会有一次操作,所以压缩后数据量其实不大。一般你按住“向前”按钮之前,很可能是已经在“前进”了。
结果就是,即使发生丢包,下一个数据包到达时依然会有全部的输入操作,这会在你真正模拟以前,就填充上所有因为丢包而出现的空洞。所以这个反馈循环的过程和可增长的缓冲区大小,以及滑动窗口,使得你不会因为丢包而损失什么。所以即使丢包也不会出现预测错误。
接下来会再次给你展示动画过程,这一次是双倍速,是正常速度的1/50了。
这里有全部不稳定因素:网络PING值抖动,有丢包,客户端时间刻度放大,输入窗口填充了全部漏洞,有预测失败,有服务器纠正。我们它们都合在一起播放给你看。
接下来的议题,我不想讲太多细节,因为这是Dan Reid的分享的主题(译注,已经翻译就是《守望先锋》中网络脚本化的武器和技能系统一文),因为这是开幕式的一部分,所以强烈推荐各位听一下,真的很棒。还是在这个房间,我讲完了就开始。
doc_image_54_w601_h338
所有的技能都是用暴雪自有指令式脚本语言State开发的。脚本系统的一大优点就是它可以在前后穿越时空。在客户端预测,然后服务器验证,就像之前的例子里面的移动操作,我们可以把你回滚然后重播所有输入。技能也使用了与移动相同的前后滚原则,先回退到最后一次经过验证的快照的状态,然后重播输入直到当前时刻。
大家肯定还记得这个例子,就是猎空被晕导致的服务器纠正过程,技能的处理过程是相同的。客户端和服务器都会模拟技能执行的确定性过程,客户端领先于服务器,所以一般是客户端先模拟,服务器稍后跟进。客户端处理预测错误的方式是,先根据服务器快照回滚,然后再前滚(roll forth),就像这样幻灯演示的动画过程那样。这里演示的是死神的幽灵形态。图45中的这些方块(译注:State中的State)代表了幽灵形态,有了这些方块我就可以很自信的播放很酷的特效和动画了。
幽灵形态结束后就会关闭这些方块。在同一帧中这些小动画会展示出State的关闭过程。紧接着就是幽灵形态的出现,不久以后我们就会得到来自服务器的消息:“嗨,我预测的幽灵形态的过程已经告诉你了,所以你赶紧倒退回去,把这些State都打开,然后咱们再重新模拟全部输入,把这些State都关了”。这基本上就是每次服务器下发更新时回滚和前滚的过程了。
能预测移动很酷,这意味着可以预测每个技能,我们也确实这样做了,同样,对于武器或者其他的模块,我们也可以这么做。
现在讨论一下命中判定的预测和确认。
doc_image_55_w601_h337
ECS处理这个其实很方便,还记得吗,实体如果拥有行为所需的组件元组,它就会是这个行为的主体。如果你的实体是敌对的(还记得我们之前讲的敌对性检查吧)而且你有一个ModifyHealthQueue组件,你就可以被别的玩家击中,这都受制于“命中判定”。
这两个组件,一个是用来检查敌对性的,一个是ModifyHealthQueue。ModifyHealthQueue是服务器记录的你身上的全部伤害和治疗。与单例Contact类似,也是延迟计算的,而且有多个调用点,这就是最大的副作用。延迟计算是因为不想在抛射物模拟途中,立即生成一大堆特效,我们选择延后。
顺便说一句,伤害,也完全不会在客户端预测,因为它们全都是骗子。
然而命中判定却是在客户端处理的。所以,如果你有一个MovementState组件,而且是一个不会被本地玩家操纵的remote对象,那你会被运动 System经过插值(interpolate)运算来重新定位。标准插值是发生在最后一次收到的两个MovementState之间的,这项技术自从《Quake》时代就有了。
System根本不在乎你是一个移动平台、炮台、门还是法老之鹰,你只需要拥有一个MovementState组件就够了,MovementState组件还要负责存储环形缓冲区,还记得环形缓冲嘛?之前用来保存那些猎空小人的位置的。
有了MovementState组件,服务器在计算命中以前,就会把你回滚到攻击者上报时你所在的那一帧,这就是向后缓和(backwards reconcilation)。这个回滚过程与ModifyHealthQueue无关,只是为了判断是否击中目标,当判定击中时ModifyHealthQueue才开始工作,来决定了是否接受伤害。我们还需要倒回门、平台、车的状态,如果子弹被挡住了的话,就无所谓了。一般来说如果你是敌对的,而且有MovementState组件,你就会被倒回,而且可能会受伤。
被倒回(rewind)是被一组Utility函数操纵的行为;而受伤是MovementState组件被延迟处理时发生的另外一个行为。这两种行为独立开来,各自发生在各自的组件切片上。
射击过程有点抽象,我这里会分解一下。
doc_image_56_w601_h338
图47中的框是每一个实体的逻辑边界(bounding volumes),可能有些不太明显,图片中央往左一点有一个茶色透明的框,就是逻辑边界。逻辑边界基本上就是代表了这个源氏的实时快照的并集。所以源氏周围的逻辑边界就代表了过去半秒钟这个角色的全部运动(的最大范围)。如果我现在沿着准星方向射击,在倒回这个角色以前,会首先与这个边界相交,因为基于我的PING值,它有可能在边界内的任意一处位置。
这个例子里,如果我沿着这个方向射击,那只需要单独倒回安娜即可,因为子弹只和她的边界相交了。不需要同时倒回大锤和他的能量盾或者车,以及后面的门。
射击如同移动一样,也可能会有预测失败。
doc_image_57_w602_h336
这里的绿色人偶是死神的客户端视角,黄色是服务器视角。这些绿色的小点点是客户端认为它的子弹击中的位置。可以看见绿色的细线是子弹经过的路径,但服务器在校验的时候,这个蓝紫色的半球才代表实际命中的位置。
这完全是个人为制造的例子,确定型模拟过程是很可靠的,为了重现射击过程中的预测失败,我把我的丢包率设置为60%,然后足足射了这个混蛋20分钟才成功重现(众笑)。
这里我还得提一句,模拟过程如此精确,要归功于我们的QA团队的同事。他们从不接受“NO”作为答案,而且因为市面上其他游戏都不会把命中判定的预测精确度做到这个水平,所以我们的QA小伙伴们根本不相信我,也不在乎我。只是不停地提bug单,而且是越来越多的bug单,而每一次当我们去检查是否真的有bug时,结果是每次都真的有。这里要对他们表示深深的感谢,有了他们的工作才使得我们能做出如此伟大的产品。
如果你的PING值特别高,命中判定就会失效。
一旦PING值超过220毫秒,我们就会延后一些命中效果,也不会再去预测了,直接等服务器回包确认。之所以这么做的原因是,客户端上本来就做了外插值(extrapolate),不想把目标倒回那么远。不想让受害者觉得他们拼命跑到墙后面找掩护,结果还是被回拉、受伤。所以加了一层保护。这倒回外插后一段时间内的行为。下面的视频会演示这个过程(译注:强烈建议看视频39:40 - 40:40)。
PING为0的时候,对弹道碰撞做了预测,而击中点和血条没有预测,要等服务器回包才渲染。
当PING达到300毫秒的时候,碰撞都不预测了,因为射击目标正在做快读的外插,他实际上根本没在这里,这里我们用了DR(Dead Reckoning)导航推测算法,虽然很接近,但是他真没在那里。死神左右来回晃动时就会出现这种情况,外插时完全无法正确预测。这里我们不会照顾你的感受,你的网络太差了。
最后这个视频,PING达到1秒的时候,尤为明显。死神的移动方式不变,还会有外插。顺便提一句,甚至PING已经是1秒钟那么慢了,客户端的所有操作都还是能够立即预测、响应的,只不过大部分都是错的而已。其实我应该放大招的(午时已到),肯定能弄死他。
下面讲下其他预测失败的例子,PING值还是不怎么好,150毫秒。这种条件下,无论何时遇到运动预测失败,都会错误的预测命中。下面用慢动作展现一下。
看,都已经飙血了,但是却没看见血条,也没看见弹坑,所以对于弹道碰撞的预测来讲就是错误的。服务器拒绝了,这不是一次合法的命中。碰撞效果预测失败的原因就是“冰墙”立起来了。你“以为”自己开火时还站在地上,但是服务器模拟时,你已经被冰墙升到了空中,就是这个行为导致预测失败的。
当我们修复这些微小的命中预测错误时,发现大部分情况都能通过与服务器就位置问题达成一致来消除,所以我们花了很多时间来对齐位置。
下面是与运动相关的预测失败的例子,同时也与游戏玩法有关。
PING值还是150毫秒,你想射中这个死神,但是他处于幽灵形态,箭头碰到他时,客户端会预测说应该有血飚出来,但没有弹坑(hit pit),也没有血条,我们根本没击中他,因为它已经先进入幽灵状态了。
这种例子里,虽然大部分时间都会优先满足进攻者,但除非受害者做了什么事情缓和(mitigate)了这次进攻。在这个例子里,死神的幽灵形态会给他3秒钟的无敌时间。无论如何,我们没有真的打到死神。
让我从哲学角度想象一下,你就是那个死神,你进入了幽灵状态,但事实上服务器告诉你所有特效开始播放了,然后你却死掉了,你很快就会跑到论坛上去(抱怨自己已经进入幽灵形态却还是死了)。
ECS简化了网络同步问题。网络同步代码中用到的System,知道自己何时被用于玩家身上,很简单直接,基本上如果一个实体被一个带有Connection组件的东西控制了,它就是一个玩家。
System也知道哪些目标需要被倒回到进攻者时刻的那一帧上,任何包含MovementState组件的实体都会被倒回。
实体与组件之间的内在关联主要行为是MovementState可以在时间线上被取消。
doc_image_62_w602_h339
上图52是System和组件的全景图,其中只有少数几个与网络同步行为有关。而这就是我们已知最复杂的问题了。System中有两个是NetworkEvent和NetworkMessage,是网络同步模块的核心组成部分,参与了接收输入和发送输出这样的典型网络行为。
还有另外几个System,一只手就数得过来:InterpolateMovement,Weapons,State,MovementState,我特别想删了MovementState,因为我不喜欢它。所以呢,实际上网络同步模块中,只有3个System是与gameplay有关的,其中用到的组件就是右边高亮列出的,也只有组件对于网络同步模块是只读的。真正修改了数据的就是像ModifyHealthQueue,因为对敌人造成的伤害是真实的。
现在回头看一下,用了ECS这么多年后,都学到了哪些知识与心得。
我有点希望System和Utility都能回到最早那个ECS操作元祖的权威例程的用法,做法有点特殊,我们只遍历一个组件就够了,再通过它访问所有兄弟组件。对于真正复杂的组件访问元组模型,你必须知道确切的访问对象才行。如果有个行为需要一个含有40个组件的元组,那可能是因为你的系统设计过于复杂了,元组之间有冲突。
doc_image_64_w601_h338
元组另一个很酷的副作用是,你掌握了关于什么System能访问什么状态的先验知识,那么回到我们用到元组的那个原型引擎当中,就可以知道2或3个System可以操作不同的组件集合。因为根据元组的定义就可以知道他们的用途。这里设计的非常容易扩展。就像之前那个弹钢琴的动画一样,不过可以看到多个System同时点亮,只因为它们操纵的组件集合是不同的。
doc_image_65_w602_h339
由于已经知道组件读写的优先级,System的轮询可以做到多线程处理gameplay代码。这里要提一句,Transform组件依然很受欢迎,但只有为数不多的几个System会真正修改它,大部分System都是对它只读。所以当你定义元组时,可以把组件标记上“只读”属性,这就意味着,即使有多个System都操作对该组件,但都是只读,可以并行处理。
实体生命周期管理需要一些技巧,尤其是在一帧的中间创建出来的那些。在早期,我们推迟了创建和销毁行为,当你说“嘿我想要创建一个实体时”,实际上是在那一帧结束时才完成的。事实证明,推迟销毁一点问题都没有,而推迟创建却有一大堆副作用。尤其是当你在System A 中申请创建一个新的实体,然后在System B中使用,这时如果你推迟了创建过程,你就要隔一帧才能使用。
doc_image_66_w601_h337
这有点不爽。这也增加了很多内部复杂性(译注:看到这里,复杂性都是一些潜规则,需要花脑力去记住的hardcode),我们想修改掉这部分代码,使它可以在一帧的中途创建好,这样就可以马上使用了。
我们在游戏发布之后才做了这些改动,实在很恐怖。这个补丁打在了1.2或者1.3版本,上线那天晚上我都是通宵的。
doc_image_67_w601_h339
我们大概花了1年半的时间来制定ECS的使用准则,就像之前那个权威的例子,但是我们需要改造一些现有的代码使之能够适应新的架构。这些准则包括:
仍然有大量代码不符合这个规范,所以它们是复杂度和维护工作的主要来源,就一点也不奇怪了。通过检视代码变更数量或者说bug数量,你就能发现这一点。
所以,如果你有什么遗留代码而且无法融入ECS规范的话,就绝对不应该使用。保持子系统整洁,不用创建任何代理组件去对它们进行封装。
不同的系统设计是用来解决问题的不同方法。
ECS是一个集成大量System的工具,不合适的系统设计原则就不应该被采用。
ECS的设计目的是用来把大量的模块进行集成并解耦,很多 System及其依赖的组件都是冰山形状的。
冰山型组件对其他ECS的System暴露的表面很小,但它们内部其实有大量的状态、代理或者数据结构是ECS层无法访问的。
在线程模型中这些冰山的体型相当明显,大部分ECS的工作,例如更新System,都是发生在主线程(图58顶部)上的。我们也用到了大量的多线程技术,像fork和join。这个例子里,有角色发射了大量的抛射物,然后脚本System说我们需要生成一些抛射物,就创建了几个工作线程来干活。还有这里是ResolvedContactSystem想要创建一些碰撞特效,这里花费了几个工作线程去做这项工作。
抛射物模拟的幕后工作已经被隔离,而且对上层ECS是不可见的,这样很好。
另外一个很酷的例子就是AIPetDataSystem,很好的应用了fork和join模式,在ECS层面,只有一点点耦合,可能是说“嗨,这是一扇可破坏的门,你可能需要在这些区域重建路径”,但是幕后工作其实很多,像获取所有三角形,渲染并裁减,这些都与ECS无关,我们也不应该把ECS置于那些问题领域,应该自己想办法。
doc_image_69_w602_h338
这里的视频演示的是PathValidationSystem,路径(Path)就是全部这些蓝色色块,AI可以行走于其表面上。其实路径并不只用于AI,也用在很多英雄的技能上。所以就需要在服务器和客户端之间对这些路径进行数据同步。
视频里的禅亚塔将会破坏这里的这些物品,你会看见破坏后的物体掉落到表面下方。然后那里的门会打开我们会把那些表面粘在一起。PathValidationSystem只需要说:“嗨,三角形有变化”。然后冰山背后就会用全部数据重建路径。
doc_image_70_w602_h340
现在准备结束今天的分享了。
ECS是Overwatch的粘合剂,它很酷,因为它可以帮你用最小的耦合来集成大量分散的系统。如果你打算用ECS定义你的规范,实际上无论你想用什么架构来快速定义你的规范,应该都是只有少数程序员需要接触物理系统代码、脚本引擎或者音频库。但是每个人都应该能够用到胶水代码,一起集成系统。
实施这些限制,就能够马到成功。
事实证明,网络同步真的很复杂,所以必须尽可能的与引擎其余部分解耦,ECS是解决这个问题的好办法。
最后在接受提问以前,我想感谢我们团队成员,尤其是gameplay工程师,大家花了3年时间创造了如此美妙的艺术品。我们共同努力,创建原则,架构不断进化,结果也是有目共睹的。
参考链接:《守望先锋》架构设计和网络同步
]]>to be continued…
最近开始进行Unity性能优化的工作,主要分为三类:CPU、GPU和内存。
CPU的优化又分为渲染和脚本,本文将着重于脚本优化。
一般来说,优化必须要知道性能热点在哪里,而知道性能热点则需要在目标设备去进行深度的profile。如果不进行profile,而是靠臆测去进行优化,往往会事倍功半,甚至适得其反。
本文所讲述的是已经经过验证的,通用的优化方法和思路,可以为大家节约一些profile时间。以下将从Unity API、C#、Lua、数据结构和算法等方面来详细阐述优化建议。
Unity是基于组合的开发方式,所以GetComponent
是一个高频使用的函数。每次调用GetComponent
时,Unity都要去遍历所有的组件来找到目标组件。每次都去查找是不必要的耗费,我们可以通过缓存的方式来避免这些不必要的开销。
其中Transform
是我们用到最多的组件,GameObject内部提供了一个.transform
来获取此组件。然而经过测试(2017.2.1p1)我们发现缓存的效率依然是最高的。所以若要经常访问一个特定组件,将其缓存。
1 | private Transform m_transform; |
GameObject.Find
会遍历当前所有的GameObject来返回名字相符的对象。所以当游戏内对象很多时,这个函数将很耗时。
可以通过缓存的方法,在Start
或Awake
时缓存一次找到的对象,在后续使用中使用缓存的对象而非继续调用GameObject.Find
。
或者采用GameObject.FindWithTag
来寻找特定标签的对象。如果能在一开始就确定好对象,可以通过Inspector注入的方式,将对象直接拖到Inspector中,从而避免了运行时的查找。
Camera.main
用来返回场景中的主相机,Unity内部是通过GameObject.FindWithTag
来查找tag为MainCamera
的相机。
当需要频繁访问主相机时,我们可以将其缓存以获得性能提升。
1 | private Camera m_mainCamera; |
GameObject.tag
常用来比较对象的tag,但是直接采用.tag ==
来进行对比的话,每一帧会产生GC Alloc。通过GameObject.CompareTag
来进行比较则可以避免掉这些GC,但是前提是比较的tag需在Tag Manager中定义。
1 | // 46Bytes GC Alloc Per Frame |
MonoBehaviour
提供了很多内部的调用方法,诸如Update
、Start
和Awake
等等,它们使用起来很方便,只要在一个继承了MonoBehaviour
的脚本中定义了Update
函数,Unity便会在每一帧去执行这个函数,具体的执行顺序见:Execution Order of Event Functions。
然而当有大量的MonoBehaviour
的Update
需要执行时,在profiler中可以看到它们的耗时很高。因为在MonoBehaviour
内部调用Update
时需要做一系列检查,如下图所示:
我们可以自建一个MonoBehaviour管理器,里面维护一个List,然后将这些需要调用Update
的MonoBehaviour扔进List中,并将它们的Update
函数改成其他名字,比如MonoUpdate
。然后在这个管理器的Update
函数中循环遍历所有的MonoBehaviour调用它们的MonoUpdate
。结果可以获得数量级上的提升,如下所示:
详细原理请阅读:10000 Update calls。
每次调用Transform.SetPosition
或Transform.SetRotation
时,Unity都会通知一遍所有的子节点。
当位置和角度信息都可以预先知道时,我们可以通过Transform.SetPositionAndRotation
一次调用来同时设置位置和角度,从而避免两次调用导致的性能开销。
Animator
提供了一系列类似于SetTrigger
、SetFloat
等方法来控制动画状态机。例如:m_animator.SetTrigger(“Attack”)
是用来触发攻击动画。然而在这个函数内部,“Attack”
字符串会被hash成一个整数。如果我们需要频繁触发攻击动画,我们可以通过Animator.StringToHash
来提前进行hash,来避免每次的hash运算。
1 | // Hash once, use everywhere! |
与Animator
类似,Material
也提供了一系列的设置方法用于改变Shader。例如:m_mat.SetFloat(“Hue”, 0.5f)
是用来设置材质的名为Hue的浮点数。同样的我们可以通过Shader.PropertyToID
来提前进行hash。
1 | // Hash once, use everywhere! |
如果需要比较距离,而非计算距离,用SqrMagnitude
来替代Magnitude
可以避免一次耗时的开方运算。
在进行向量乘法时,有一点需要注意的是乘法的顺序,因为向量乘比较耗时,所以我们应该尽可能的减少向量乘法运算。
1 | // 耗时:73ms |
可以看出上述的向量乘法的结果完全一致,但是却有显著的耗时差异,因为后者比前者少了一次向量乘法。所以,应该尽可能合并数字乘法,最后再进行向量乘。
Coroutine是Unity用来实现异步调用的机制,如果对其不够了解可以参考我之前写的文章:对Unity中Coroutines的理解。
当需要实现一些定时操作时,有些同学可能会在Update
中每帧进行一次判断,假设帧率是60帧,需要定时1秒调用一次,则会导致59次无效的Update调用。
用Coroutine则可以避免掉这些无效的调用,只需要yield return new WaitForSeconds(1f);
即可。当然这里的最佳实践还是用一个变量缓存一下new WaitForSeconds(1f)
,这样省去了每次都new的开销。
SendMessage
用来调用MonoBehaviour的方法,然而其内部采用了反射的实现机制,时间开销异常大,需要尽量避免使用。
可以用事件机制来取代它。
众所周知,输出Log是一件异常耗时,而且玩家感知不到的事情。所以应该在正式发布版本时,将其关闭。
Unity的Log输出并不会在Release模式下被自动禁用掉,所以需要我们手动来禁用。我们可以在运行时用一行代码来禁用Log的输出:Debug.logger.logEnabled = false;
。
不过最好采用条件编译标签Conditional
封装一层自己的Log输出,来直接避免掉Log输出的编译,还可以省去Log函数参数传递和调用的开销。具体可以参见:Unity3D研究院之在发布版本屏蔽Debug.log输出的Log。
反射是一项异常耗时的操作,因为其需要大量的有效性验证而且无法被编译器优化。
而且反射在iOS下还可能存在不能通过AOT的情况,所以我们应该尽量避免使用反射。
我们可以自己建立一个字符串-类型的字典来代替反射,或者采用delegate的方式来避免反射。
在C#中,内存分配有两种策略,一种是分配在栈StackStackStack上,另一种是分配在堆HeapHeapHeap上。
在栈上分配的对象都是拥有固定大小的类型,在栈上分配内存十分高效。
在堆上分配的对象都是不能确定其大小的类型,由于其内存大小不固定,所以经常容易产生内存碎片,导致其内存分配相对于栈来说更为低效。
在C#中,数据可以分为两种类型:值类型ValueTypeValueTypeValue Type和引用类型ReferenceTypeReferenceTypeReference Type。
值类型包括所有数字类型、Bool、Char、Date、所有Struct类型和枚举类型。其类型的大小都是固定,它们都在栈上进行内存分配。
引用类型包括字符串、所有类型的数组、所有Class以及Delegate,它们都在堆上进行内存分配。
装箱BoxingBoxingBoxing指的是将值类型转换为引用类型,而拆箱UnBoxingUnBoxingUnBoxing的是将引用类型转换为值类型。
从上图我们可以发现装箱和拆箱存在着从栈到堆的互指以及堆内存的开辟,所以它们本质是一项非常耗时的操作,我们应该尽量避免之。
Mono之前的foreach导致每帧的GC Alloc,本质也是因为装箱和拆箱导致的,此问题已经在Unity5.6后被修复。
我们在堆上分配的内存,其实是由垃圾回收器(Garbage Collector)来负责回收的。垃圾回收算法异常耗时,因为它需要遍历所有的对象,然后找到没有引用的孤岛,将它们标记为「垃圾」,然后将其内存回收掉。
频繁的垃圾回收不仅很耗时,还会导致内存碎片的产生,使得下一次的内存分配变得更加困难或者干脆无法分配有效内存,此时堆内存上限会往上翻一倍,而且无法回落,造成内存吃紧。
所以我们应该极力避免GC Alloc,即需要控制堆内存的分配。
字符串连接会导致GC Alloc,例如string gcalloc = "GC" + "Alloc"
会导致"GC"
变成垃圾,从而产生GC Alloc。又比如:string c = string.Format("one is {0}", 1)
,也会因为一次装箱操作(数字1
被装箱成字符串"1"
)而产生额外的GC Alloc。
所以如果字符串连接是高频操作,应该尽量避免使用+
来进行字符串连接。C#提供了StringBuilder
类来专门进行字符串的连接。
I2LCPP是Unity提供的将C#的IL码转换为C++代码的服务,由于转成了C++,所以其最后会转换成汇编语言,直接以机器语言的方式执行,而不需要跑在.NET虚拟机上,所以提高了性能。同时由于IL的反编译较为简单,转换成C++后,也会增加一定的反汇编难度。
IL2CPP的C++代码虽然是自动生成的,但是其中间的某些过程也可以被人为操纵,从而达到提升性能的目的。
在C#中,虚函数的调用会比直接调用开销更大,我们可以用sealed
修饰符来修饰掉那些确保不会被继承的类或函数。
具体详情可以参考:IL2CPP Optimizations: Devirtualization。需要注意的是,在最新的Unity版本中,这一项优化已经失效。
在自动转换的C++代码中,IL2CPP默认会对所有Nullable的变量做判空。其实在某些你非常确定参数不为空的场合,这种检测实际上是不必要的。
具体步骤是复制Il2CppSetOptionAttribute.cs
文件到你的Assets
目录下,然后在类或者函数定义上加一个修饰语句[Il2CppSetOption(Option.NullChecks, false)]
即可以禁用整个类或者函数的判空检测。
同理,IL2CPP也会默认对所有数组的读写做越界检测,我们可以通过修饰语句[Il2CppSetOption(Option.ArrayBoundsChecks, false)]
来将其禁用。
具体可以参考:https://docs.unity3d.com/Manual//IL2CPP-CompilerOptions.html
我之前写过一篇有关于纯Lua性能优化的文章:编写高性能的Lua代码,以下是一些摘抄和补充。
Lua的默认变量都是全局变量,必须要加上local
修饰才能变成局部变量。
局部变量相对于全部变量有以下几点好处: 1. 读写更快 2. 可以避免不经意的全局变量名污染 3. 在作用域结束时,会被自动标记为垃圾,避免了内存泄漏
所以,虽然Lua的默认变量声明都是全局变量,我们还是应该将其用local
修饰为局部变量。
Lua中的表内部分为两部分:hash部分和array部分。当创建一个空表时,这两个部分都会默认初始化空间为0。随着内容的不断填充,会不断触发rehash。rehash是一次非常耗时的操作,所以应尽量避免之。
如果同时需要创建较多的小表,我们可以通过预先填充表以避免rehash。
与C#类似,在Lua中的字符串连接的代价也很高昂,但是与C#提供了StringBuilder
不同,Lua没有提供类似的原生解决方案。
不过我们可以用table来作为一个buffer,然后使用table.concat(buffer, '')
来返回最终连接的字符串。
关于与C#的交互,不同的Lua解决方案有不同的策略,但是有些基本的点都是一样的。
首先,关于MonoBehaviour的三大Update的桥接,最佳策略是通过一个管理器继承MonoBehaviour的Update,然后将其派发给Lua端,然后Lua端所有的Update都注册于这个管理器当中。这样可以避免了多次Lua与C#的桥接交互,可以大量节省时间。
其次,需要考虑GC问题,默认的struct比如Vector3
传递到Lua中都需要经历一次装箱操作,会带来额外的GC Alloc,可以采用特殊配置的方式将其避免。XLua的方案可以参考:XLua复杂值类型(struct)gc优化指南。
最后,通用的优化思路可以参考用好Lua+Unity,让性能飞起来——Lua与C#交互篇,作者针对实例做了较为详尽的分析。
容器应该针对不同的使用场合进行选择,主要看使用场合哪种操作的频率较高。例如:
还有一些特殊的数据结构,适用于特殊的使用场合。例如:
对象池(Object Pool)可以避免频繁的对象生成和销毁。游戏对象的生成,首先需要开辟内存,其次还可能会引起GC Alloc,最后还可能会引发磁盘I/O。频繁的销毁对象会引发严重的内存碎片,使得堆内存的分配更加困难。
所以在有大量对象需要重复生成和销毁时,一定要采用对象池来缓存好创建的对象,等到它们无需使用时,不需要将其销毁,而是将其放入对象池中,可以免去下次的生成。
1 | public class ObjectPool<T> where T : new() |
在计算空间碰撞或者寻找最近邻居时,如果空间很庞大,需要参与计算的对象太多的情况下,用两层循环逐个遍历去计算的复杂度为平方级。
我们可以借助于空间划分的数据结构来使复杂度降低到N*Log(N)
。四叉树一般用来划分2D空间,八叉树一般用来划分3D空间,而KD树则是不限空间维度。
我之前写过一篇介绍KD树的原理和优化的文章:KD树的应用与优化,内容比较详尽,大家可以去读一读。
循环的使用非常常见,也非常容易成为性能热点。我们应该尽量避免在循环内进行耗时或无效操作,尤其是这个循环在每帧的Update调用中时。
1 | void Update() { |
以上的循环遍历中,无论condition
为真或者为假,循环都会执行count
次,若condition
为假,则相当于白跑了count
次。
1 | void Update() { |
将判断条件提出循环外,则可以避免白跑了的问题。
另一个需要注意的是小心多重循环的顺序问题,应该尽量把遍历次数较多的循环放在内层。
1 | void Start() |
当内外层循环数有较多数量级上的差别时,将忙的循环放在内层性能更高,因为其避免了更多次内层循环计数器初始化的调用。
开方运算,三角函数这些都是耗时的数学运算,应尽量避免之。
像之前提到的,如果只是单纯比较距离而不是计算距离的话,就可以用距离的平方来表示,可以节约掉一次耗时的开方运算。
三角运算可以通过简单的向量运算来规避之,具体可以参考我之前写的文章:向量运算在游戏开发中的应用和思考。
又比如如果经常需要除一个常数,比如用万分位整数来表示小数需要经常除 大雾,实际验算证明,现代的编译器会对此进行优化,所以没有必要为此牺牲可读性。很多时候还是要先测算再去写代码会比较好。10000
,可以改成乘0.0001f
,可以规避掉较乘法更为耗时的除法运算。
我最喜欢的一种优化思路就是缓存。缓存的本质就是用空间换时间。例如之前在Unity API中提到的很多耗时的函数,都可以用缓存来提升性能。
包括对象池,也是缓存技术的一种。针对于需要依赖复杂运算而且后续要经常用到值,我们便可将其缓存起来,以避免后续的计算,从而获取性能提升。
Posted in Alogrithm, DataStructure, Game Develop, Unity Tagged C#, Lua, Unity, 优化, 性能, 数据结构, 算法
]]>to be continued…
这里是前言介绍。
当执行 git 动作时,.gitattributes 文件允许你指定由 git 使用的文件和路径的属性,例如:git commit
等。
换句话说,每当有文件保存或者创建时,git 会根据指定的属性来自动地保存。
其中的一个属性是 eol(end of line),用于配置文件的结尾。本文将会深入了解如何配置文件的结尾行,这样的话,即使在不同的机器、操作系统 上都可以使得每个开发者都可以使用相同的值。
不是所有的开发者都是一样的,不能因为你是在 Windows 上使用 Visual Studio Code 开发的,就期望别的 Pull Request 也是基于相同的开发 环境完成的(在 MacOS 上可能用的是 Sublime Text2)。
正如上面提到的,开发者使用不同的操作系统,默认的文件结尾行就会不同。在 Windows 上默认的是回车换行(Carriage Return Line Feed, CRLF),然而,在 Linux/MacOS 上则是换行(Line Feed, LF)。
从表面看起来,内容都是一样的,那我们为什么还会困扰呢???
好的,如果你启用了该功能,并设置属性 endOfLine
为 lf
的话。
1 | { |
在 Windows 上,开发者会看到如下的检查警告。
这时候 .gitattributes
就可以派上用场了 !
首先,在仓库的根目录下创建名为 .gitattributes
的文件。
下面,是一份 .gitattributes
文件的样例内容。
1 | *.js eol=lf |
把该文件提交并推送到服务器上。
1 | git add . |
现在,任何人从仓库获取代码后,创建以及修改文件时,git 都会自动地设置好正确的文件结尾。
正如上面提到的,在仓库的根目录下创建名为 .gitattributes
的文件。一旦文件推送到 git 服务器后,请确保你的本地 仓库是干净的、无需提交的。使用命令 git status
可以检查是否你的仓库是干净的。
注意:如果你还有未提及或推送的文件,请确保这些动作已经被执行过了,或者在执行下面的命令前 stash
过。
1 | git rm --cached -r |
上面的命令就会根据文件 .gitattributes
中的定义,更新文件的结尾行。
任何变更都会自动使用指定文件的文件结尾行格式。
下一步,可以通知团队成员或者协作者去执行 Git 属性重置的命令。
现在,prettier 就不会在提示有关 CR 的问题了,所有的开发者都可以安心写代码了! ☮️
]]>to be continued…
这里是前言介绍。
搭建自己的PackageManager服务器
安装nodejs
安装npm
部署verdaccio
将自己写的插件发布到PackageManager服务器上
配置自己的Package
注册 Verdaccio 服务器账号
登陆 Verdaccio 服务器
发布 Package 到 Verdaccio 服务器上
让项目可以使用Verdaccio服务器上的所有Package
修改项目的manifest.json
修改Unity编辑器的项目模板,让每个新建的Unity项目都可安装自己服务器上的插件
修改Unity编辑器项目模板的mainfest.json
使用peazip重新压缩为.tgz文件
https://medium.com/@markushofer/run-your-own-unity-package-server-b4fe9995704e
你可以选择阿里云,腾讯云,谷歌云等产品,本文使用腾讯云的轻量级服务器。
理论上只要是Linux的系统就可以。
系统镜像为Ubuntu
自行购买好服务器之后进入控制台界面,大部分厂商的服务器都是可以直接在网页上远程登陆的。点击登陆
进入控制台之后输入 sudo -i 获取root权限
Verdaccio是一个开源的npm私服,我们后续的package都是发布在这上面的
在安装verdaccio之前需要先安装nodejs和npm
使用以下代码安装
1 | curl -sL https://deb.nodesource.com/setup_11.x | bash - |
成功之后的输出
使用以下代码验证是否安装成功
1 | nodejs -v |
正常输出了版本号则说明安装成功
确保nodejs和npm安装成功之后我们来安装verdaccio
1 | npm install --global verdaccio |
安装成功
然后我们需要运行verdaccio,在普通用户权限下运行即可
按Ctrl D退出root权限
输入verdaccio运行
1 | verdaccio |
成功运行显示下图,记住config file的路径,下一步需要用到
我这里是 /home/lighthouse/verdaccio/config.yaml
按Ctrl Z退出verdaccio
这里使用vim + 上面记住的路径,编辑配置文件
1 | vim /home/lighthouse/verdaccio/config.yaml |
到这个界面之后按 i ,然后在plugins下插入下面两行代码
4873是端口,可以自行定义,之后就使用ip+这个端口访问verdaccio界面
按Esc退出Insert
输入 :wq 并回车保存并退出vim
这时候需要回到控制台,打开防火墙,添加你刚才设置的端口
重新登陆到控制台,运行verdaccio
这时候你就可以打开浏览器,输入ip加端口访问 verdaccio 了
控制台这边也会有log输出
但是这样还有一个弊端,我们需要verdaccio一直运行,但是现在关掉控制台之后,它也会跟着关闭,我们也无法通过端口访问了
我们需要使用一个叫screen的小工具来让verdaccio一直运行在服务器上
登陆服务器输入 screen,
如果没有安装,则输入以下代码进行安装
1 | sudo apt-get install screen |
输入之后会显示该界面,再按一下回车
然后就进入到了screen的控制台,输入verdaccio回车
按 Ctrl A+D退出
大功告成,现在关闭控制台之后,你还可以通过端口访问
你可以参考Package下面这些Unity自带的包格式
这里用我自己写的UI框架举例
有几个要点:
我这个项目是比较简单的结构,就只需要给Editor和Runtime两个文件夹的脚本添加
关于程序集定义文件的详细介绍请看官方手册:
在文件夹下右键创建一个Assembly Definition文件,这个文件会自动将跟它同级以及同级文件夹内的脚本定义为一个程序集
这里我在Runtime文件夹下新建
然后我在Editor文件夹下新建
这里主要注意平台和引用
打开之后,填写包名,版本号,引用的其他package等
全部完成之后,我们的Package就准备好了,剩下就只有上传了
打开PowerShell ,一般都是自带的
首先使用以下指令注册一个verdaccio账户
1 | npm adduser --registry http://ip:端口 |
会让你输入用户名密码和邮箱
注册成功
使用以下代码登陆,注册登陆一般只需一次即可,后续直接发布就行
1 | npm login --registry http://ip:端口 |
接下来cd到你的package路径
然后使用以下指令发布,每次更新都需要提升版本号,否则会失败
1 | npm publish --registry http://ip:端口 |
发布成功
然后可以在网页看到你发布的Package
打开根目录,找到Packages文件夹下的manifest.json
在dependencies上方加入以下代码
1 | "scopedRegistries": [ |
如下图
重新回到项目,打开PackageManager,就可以找到我们发布的package了
新版本的Unity还可以在这筛选是Unity的包还是自定义服务器的包
为了方便找到我自己的包,名字前都加了一个下划线
上面这样依然不是很方便,我每新建一个项目,都要重新修改一下manifest.json文件,对于我这样的程序员来说,一件事情做两遍,是不能忍的。
我们通过修改Unity编辑器的项目模板来解决这个问题,这样每次新建项目的时候,就都会使用我们模板里的json文件了!
首先找到Unity编辑器的项目模板路径
如下
将我们平时常用的那个模板解压,进入解压后得到的文件夹
打开它,增加我们域注册代码
为这个模板重命名,到模板根目录,打开package.json
修改name和displayName
这时候我需要将这个package文件夹重新压缩回tgz,我常用的winrar是不行的
这里我使用PeaZip来进行压缩
右键该文件夹,添加到归档
注意红框内的几个设置
选择GZip,勾选选用TAR归档,点击确定
然后得到一个 .tar.gz 文件
对其进行重命名,将 .tar.gz 改成 .tgz ,否则Unity无法识别该模板
最后删除Package文件夹
然后需要注销或者重启电脑,才会生效
大功告成,新建项目的时候就可以看到我们的模板了
参考来源: [ Unity自定义PackageManager服务器 ]
开发Unity PackageManager 插件包
Unity:使用Package Manager开发和管理自定义插件
]]>to be continued…
这里是前言介绍。
-
启用此设置以在运行应用程序时记录活动模块的分析信息。
-
如果没有启用此按钮,则分析器在运行应用程序时不会收集任何数据。
Back Arrow:向后导航一帧。
Forward Arrow: 向前导航一帧。
Current:
Frame Number:指示当前在分析器中查看的帧号。
Clear:清除Profiler窗口中的所有数据。
Clear On Play:
-
若要记录用于脚本化内存分配的调用堆栈,需要单击此切换。
-
启用此选项时分析器记录的帧在GC中有信息。
-
在完整调用堆栈上的Alloc示例将导致托管脚本分配,即使在Deep Profile没有激活时也是如此。
(1) ColorBindMode:启用此设置可使分析器在其图形中使用更高的对比度颜色。这增强了红绿色盲用户的可见性。
(2) Show Stat for “Current Frame” : 在记录过程中一直显示当前帧的对应的图表上的数据。
(3) Preference:
为了保持较低的开销,Unity只会每隔五帧重绘一次编辑器UI,这将导致一个稍微有些不太平滑的更新。
通常,分析器只分析在profilermarker中显式包装的Code Timings。这包括从引擎的本地代码到脚本代码的第一次调用堆栈深度,例如MonoBehaviour的启动、Update或类似的方法。
如果没有向您自己的代码中添加更显式的ProfilerMarker插装,那么只能看到作为脚本代码子样例的其他样例是那些回调到Unity的API(如果该API已插装)的样例。大多数带有性能开销的API调用都是插装的Instumented。例如:通过Camera.main API获取主相机作为“FindMainCamera”Sample。
当启用Deep Profile设置,Profiler会剖析脚本代码的每一部分并且记录所有的函数调用,包括至少第一次调用堆栈到任何UnityAPI的深度。Unity将分析器工具注入到你所有的脚本方法中来记录所有的函数调用。这对于理解应用程序代码在什么地方花费的时间最多很有用。
深度剖析是资源密集型的,并且会使用大量的内存。因此,在进行概要分析时,应用程序的运行速度会明显变慢。深度剖析更适合使用简单脚本的小型游戏。如果正在使用复杂的脚本代码,应用程序可能根本无法使用深度剖析,而对于许多较大的应用程序,深度剖析可能会使Unity耗尽内存。
要增加Profiler环缓冲区的大小,可以调整分析器。可以调整正在分析的播放器的maxUsedMemory属性。如果Deep Profile导致应用程序的帧率下降到无法运行的程度,那么可以手动地剖析脚本代码块,这比深度剖析的开销要小。
2.GPU Usage:
-
GPU Usage显示与图形处理相关的信息。
-
默认情况下,这个模块没有被激活,因为它有很高的开销。
Video:显示应用程序中与视频相关的信息。
Physics/Physics 2D:显示物理引擎处理过的应用程序中的物理信息。
Network Message,Network Operation两个模块已经被弃用。
UI:
一些分析器模块有很大的数据收集开销,比如GPU、UI和音频分析器模块。为了防止这些模块影响应用程序的性能,可以通过在Profiler模块下拉菜单中取消对它们的选择来禁用它们。这将从窗口中删除模块,停止分析器收集模块的数据,并降低分析器的开销。
这不适用于CPU使用模块,因为其他模块依赖于CPU使用模块,所以CPU使用模块即使在不活动时也会收集数据。
若要添加模块,选择Profiler模块下拉菜单并选择要激活的Profiler。当您从下拉菜单中选择Profiler模块时,它将开始收集数据,但是不会显示它不活动期间的任何数据。
为了避免GPU Profiler模块的开销,它在默认情况下是不活动的。图形分析器模块必须在应用程序启动时激活,以连接到图形驱动程序。如果你稍后添加它,它对大多数平台没有影响,并且剖析器显示消息“图形卡驱动程序不支持GPU剖析(或它被禁用,因为驱动程序bug)”。
如果指示分析器收集数据并通过分析器将数据发送到磁盘。可以通过Profiler. setareaenabled()关闭Profiler模块,而不是通过Profiler窗口。
一些通过外部IDE调试脚本的设置也可能产生开销。为了避免这种开销和获得更准确的测量,禁用编辑器附加设置(Edit->首选项->外部工具)。类似地,在配置构建播放器时,打开构建设置并禁用脚本调试以避免这种开销。
当选择“Build & Run”时,Unity Editor自动为应用程序创建了一个ADB通道。如果想要分析另外的应用,或者重启Adb服务,需要手动配置此通道。
配置方式:打开终端并输入如下内容。
(1) 开启Mono Scripting后端。Edit->ProjectSettings->Player->Android->OtherSetting。
(2) 输入如下命令行:
注:AudioClip是一个声音片段,如:按钮点击,相当于是一首音乐;AudioSource是一个源,包含AudioClip,还有其它一些属性,如:是否静音等,相当于是一个音乐播放器。
TotalAudioSources:场景中AudioSource的数量。
PlayingAudioSources:场景中正在破防的Audio Source的数量。
PausedAudioSources:场景中暂停播放的Audio Source的数量。
Audio Clip Count:场景中Audio Clips的总数量。
Audio Voices:项目中使用的Audio Channels(FMOD Channels)的数量。
Total Audio CPU:Audio使用的CPU的数量(百分比)。
DSP CPU:
Streaming CPU:应用中CPU在项目中使用流送Audio的耗费的百分比。
Other CPU:不被上面CPU包含的一般的CPU耗费(百分比)。
Total Audio Memory:在应用中使用的Audio的内存数量(M)。
Streaming File Memory:
Unity会把音频系统分配的内存集中起来,并且它会一直增长,直到应用程序的运行时间达到饱和。
-
音频系统在内部重用分配的内存,这些内存在运行时无法压缩。
-
音频文件将在加载后立即解压缩。
-
将此选项用于较小的压缩声音,以避免动态解压缩的性能开销。
-
将vorbis编码的声音解压到负载上要比压缩它们多占用大约10倍的内存(对于ADPCM编码,大约是3.5倍),因此不要将此选项用于大型文件。
-
保持声音压缩在内存中,当播放时才解压。
-
这个选项有轻微的性能开销(特别是对于Ogg/Vorbis压缩文件),所以只在较大的文件中使用它,因为在加载时解压缩会占用大量的内存。
-
解压是发生在mixer线程,可以在“DSP CPU”部分的音频窗格的剖析器窗口中监视。
详细视图包含简单视图中的所有信息,另外还包含音频事件的每帧详细日志记录。
Group显示了音频mixer中总线的层次结构。
通道和组视图将显示此信息以及有关播放声音的信息。
Reset Play Count On Play:在下次单击Player窗口中的play或连接到新目标设备时重置plays列中的数字。
Object:包含AudioSource播放Audio的GameObject。
Asset: 对应的GameObject中Audio Source正在播放的音频Asset。
Volume:Audio Source应用到Audio上的音量。这是它整体音量特征和动态音量特征的的结合。动态音量适用于与距离相关的衰减曲线。
Audibility:Audio播放的实际级别。这是Audio Source的音频和另外的mixer通道对其施加的其他衰减的总和。
Players:Unity播放此音频的数量。这个信息对调试逻辑错误可能很有用,因为Unity可能不会使用一些音频文件。
3D: 如果Audio使用动态距离想啊滚衰减和方向平移,则显示Yes。
Paused:如果音频在此帧中是暂停的,则显示YES。
Muted:如果音频在此帧中是静音的,则显示YES。
Virtual:
11.** Distance:AudioSource到AudioListener之间的距离**。
-
在AudioSource曲线编辑器上定义的最小距离。
-
这定义了音频周围的一个球形区域,小于其值时音量保持在一个恒定的水平。
Time:Audio回放的当前相关时间,当音频回放暂停时,时间不会往前走。
Duration:Audio以秒为单位的长度。
类别描述Rendering应用程序花费多少时间来渲染图形。Scripts应用程序在运行脚本上花费了多少时间。Physics应用程序在物理引擎上花费了多少时间。Animation
应用程序花了多少时间来动画SkinnedMeshRenderers,GameObject和其他组件。这还包括花在计算Animation和Animator组件的系统上使用的时间
GarbageCollector应用程序花了多少时间运行垃圾回收器。VSync垂直同步****应用程序每帧花费多少时间等待targetFrameRate或下一个VBlank与之同步。这取决于QualitySetting.vSyncCount值,目标帧速率或VSync设置,该设置是运行应用程序的平台的默认或强制最大值。Global Illunimation应用程序在光照上花费了多少时间。UI应用程序花费多少时间来显示其UI。Other应用程序花在不属于任何其他类别的代码上的时间。这包括整个EditorLoop或在Profile中配置播放模式时的性能分析开销等区域。
当选择“ CPU Usage”模块时,其下方的详细信息窗格将显示应用程序在选定框架中花费时间的细分。可以将时序数据显示为时间轴或层次表;要更改显示,使用详细信息窗格中的左上方下拉菜单(默认设置为时间轴)。可用的三个视图是:
视图功能TimeLine显示特定帧的时间细分,以及该帧长度的时间轴。这是唯一可用于查看除主线程以外的线程上的时序并关联各个线程之间的时序的视图模式。****Hierarchy按时序数据的内部层次结构对其进行分组。此选项以降序列表格显示应用程序调用的元素,默认情况下,这些元素按花费的时间排序。您还可以按分配的脚本内存量(GC Alloc)或调用次数来排序信息。要更改排序表的列,请单击表列的标题。RawHierarchy以类似于发生计时的调用堆栈的分层结构显示计时数据。Unity在这种模式下单独列出每个调用堆栈,而不是像在“ 层次结构”视图中那样合并它们。
时间轴视图是用于CPU Usage模块的默认视图。它概述了应用程序中的时间以及时间之间的关系。
TimeLine视图在其各自的子段中沿同一时间轴显示所有线程的概要分析数据。这与Hierarchy视图不同,后者仅显示来自主线程的概要分析数据。
可以使用时间轴视图来查看不同线程上的活动在并行执行中如何相互关联。可以查看正在使用不同线程的多少,例如JobSystem的工作线程,如何对这些线程进行排队,是否有任何线程处于空闲状态或正在等待另一个线程或一个Job来完成(Wait for X Sample)。
使用鼠标上的滚轮或者Alt+鼠标右键来缩放时间轴上的区域。
使用“A”键来重置缩放,这样整个帧时间都可以看到。
使用鼠标中键或者Alt+鼠标左键来平移时间轴上的区域。
在下面窗口中选中timeline上某个小项,Profiler将会突出显示其对CPU图表的贡献,使得其余部分变暗;若要取消该条目,直接点击视图中其余地方即可。另外,按F键将聚焦选择的当前样本。
Hierarchy视图列出了分析的所有样本,并根据它们的共享调用堆栈和profilermarker的层次结构将它们分组在一起。
原始的Hierarchy视图没有将样本分组在一起,这使得它非常适合在粒度级别上查看样本。
还可以使用Thread下拉菜单选择一个特定的线程,如MainThread或RenderThread,以便在这些视图中进行检查。
这两个视图都在每一行旁边显示了层次结构中每个项目的以下详细信息:
(1) Total:
(2) Self:
(3) Calls:
-
在这一帧中对这个函数的调用次数。
-
在Raw层次结构视图中,此列中的值始终为1,因为分析器不会合并样本的层次结构。
(4) GC Alloc:
(5) Time(ms):
(6) Self(ms):
(7) Warning:
除了脚本代码生成的样例之外,Unity还提供了大量的样例,这些样例可以了解应用程序中哪些部分占用了时间。下表解释了一些更常见的样例的作用。
(1) PlayerLoop:
-
来自应用程序主循环的任何示例的根。
-
当玩家在编辑器中以活动播放模式运行时,目标是编辑器而不是Playmode,此示例将嵌套在EditorLoop下。
(2) EditorLoop:
(3) Profiler.CollectEditorStats
SampleFunctionUpdate.ScriptRunBehaviourUpdateThis sample includes calls to MonoBehaviour.Update
and processing of coroutines.BehaviourUpdateThis sample processes all Update()
methods.CoroutinesDelayedCallsContains coroutine samples after their first yield.PreLateUpdate.ScriptRunBehaviourLateUpdateThis sample processes all LateUpdate()
methods.FixedBehaviourUpdateThis sample processes all FixedUpdate()
methods.
这些示例显示了CPU在哪些地方花费时间为GPU处理数据,或者它可能在哪些地方等待GPU完成。如果GPU分析器不可用,或者它增加了太多的开销,工具栏不会显示这些信息。这些示例可以让您了解应用程序是cpu受限还是GPU受限。
SampleFunctionWaitForTargetFPSThe time your application spends waiting for the targeted FPS that Application.targetFrameRate specifies.
If this sample is a sub-sample of Gfx.WaitForPresent, it represents the amount of time your application spends waiting for the VSync configured in QualitySettings.vSyncCount.
Note: The Editor doesn’t VSync on the GPU and instead uses WaitForTargetFPS to simulate the delay for VSync. Some platforms, in particular Android and iOS, enforce VSync or have a default frame rate cap of 30 or 60.Gfx.ProcessCommandsContains all processing of the rendering commands on the render thread. Some of that time might be spent waiting for VSync or new commands from the main thread, which you can see from it’s child sample Gfx.WaitForPresent.Gfx.WaitForCommandsIndicates that the render thread is ready for new commands and might indicate a bottle neck on the main thread.Gfx.PresentFrameIndicates the time your application spends waiting for the GPU to render and present the frame, which might include waiting for VSync.
A WaitForTargetFPS sample on the main thread shows how much of that time is spent waiting for VSync.Gfx.WaitForPresentIndicates that the main thread is ready to start rendering the next frame, but the render thread has not finished waiting on the GPU to present the frame. This might indicate that your application is GPU-bound. To see what the render thread is simultaneously spending time on, check the Timeline view.
If the render thread spends time in Camera.Render, your application is CPU-bound and might be spending too much time sending draw calls or textures to the GPU.
If the render thread spends time in Gfx.PresentFrame, your game is GPU-bound or it might be waiting for VSync on the GPU. A WaitForTargetFPS sub-sample of GFX.WaitForPresent indicates the portion of the Present phase that your application spends waiting for VSync. The Present phase is the portion of time between Unity instructing the graphics API to swap the buffers, to the time that this operation is completedGfx.WaitForRenderThreadIndicates that the main thread is waiting for the render thread to process all the commands currently in its command stream. This sample only occurs in multithreaded rendering.
These samples do not consume CPU cycles but instead highlight information that relates to threading and the JobSystem. When you see these samples, use the Timeline view to check what’s happening on other threads at the same time.
SampleFunctionIdleAny time that the JobSystem does not utilize a Worker Thread, it emits an Idle sample. Small gaps between Idle samples usually happen when the JobSystem wakes them up, for example to schedule new Jobs. Longer gaps indicate a native Job that has not been instrumented.Semaphore.WaitForSignalThis thread is waiting for something to finish on another thread. To find the thread it is waiting for, check the Timeline view for any samples that ended shortly before this one.WaitForJobGroupIDA Sync Fence on a JobHandle was triggered. This might lead to work stealing, which happens when a worker finishes its work and then looks at other workers’ jobs to complete. These show up as job samples executed under this sample. Jobs that were “stolen” are not necessarily the jobs that were being waited on.
The following table outlines some of the high-level physics Profiler samples. FixedUpdate()
calls all of these samples.
SampleFunctionPhysics.SimulateUpdates the state of the current physics by instructing the physics engine to run its simulation.Physics.ProcessingProcesses all non-cloth physics jobs. Expand this sample to show the low-level detail of the work done internally in the physics engine.Physics.ProcessingClothProcesses all cloth physics jobs. Expand this sample to show the low-level detail of the work done internally in the physics engine.Physics.FetchResultsCollects the results of the physics simulation from the physics engine.Physics.UpdateBodiesUpdates all the physics bodies’ positions and rotations. This sample also contains messages that communicate when these updates are sent.Physics.ProcessReportsRuns once the physics FixedUpdate
ends. Processes the various stages of responding to the results of the simulation. Contacts, joint
breaks and triggers update and message in this sample. There are four distinct sub stages: Physics.TriggerEnterExitsProcesses OnTriggerEnter
and OnTriggerExit
events. Physics.TriggerStaysProcesses OnTriggerStay
events. Physics.ContactsProcesses OnCollisionEnter
, OnCollisionExit
, and OnCollisionStay
events. Physics.JointBreaksProcesses updates and messages relating to broken joints.Physics.UpdateClothContains updates relating to cloth and their Skinned Meshes.Physics.InterpolationManages the interpolation of positions and rotations for all the physics objects.
ShowRelatedObjects视图显示与Profiler示例中相关的UnityEngine.Objects的列表。
如果在编辑器中Profile,Unity将会通过实例ID报告这些对象,并在Profiler窗口将他们解析为一个名称。当配置一个已构建的播放器,或从磁盘加载捕获时,这些名称不会出现,而是显示为“N/A”。
对于GC.Alloc示例中,显示了一个“N/A”项列表,如果在选择GC时,在启用调用堆栈设置的编辑器中配置应用程序,在这个视图中,将显示选择的已分配脚本对象的调用堆栈,即使没有启用深度分析设置。
Profiler可以检测一些在性能关键的上下文中应该避免的特定调用:
默认情况下,在GC.Alloc Samples上分配调用堆栈是禁用的,因为他们会导致多帧延迟扰乱应用程序。
启用方法:导航到Profiler窗口的工具栏,并选择Call stack按钮。
无论是在编辑器中配置文件还是在正在运行的播放器中配置文件,都可以使用此功能。在打开这个选项后的帧中,GC.Alloc Samples包含它们的callstack。在层次结构视图和时间轴视图中,每个脚本堆分配都显示为一个GC.Alloc Samples。在时间轴视图中,它的颜色为明亮的洋红色。
一些Samples只有在编辑器中进行概要分析时才会出现。这包括安全检查,校验一致性,验证对象设置,销毁检查和Prefab-related激活,所有上述这些Samples都不存在于播放器中。
**默认情况下,只编辑的示例在Hierarchy视图中折叠,并命名为EditorOnly [SampleName]**。虽然它们可能会导致垃圾收集分配,但如果他们被折叠,将不会对GC.Alloc造成影响。
要更改默认行为,单击“模块详细信息”窗格右上角的上下文菜单,并禁用“Collapse EditorOnly样例”设置。当您这样做时,您可以扩展Samples并贡献它的GC.Alloc值。
(1) Memory Profiler Module:
(2) Memory Profiler Package:
内存分析器模块可视化表示应用程序中分配的总内存的计数器。可以使用内存模块查看诸如加载对象的数量以及它们在每个类别中占用的总内存等信息。还可以看到每个Profiler帧的GC分配数量。
要获得更精确的数字和应用程序的内存使用情况,请通过“附加到播放器”菜单将分析器连接到正在运行的播放器。这允许您查看目标设备上的实际使用情况。
Simple View显示了在每一帧上Unity实时有多少内存被使用的概述。Unity为分配预留了内存池,以避免过于频繁地向操作系统请求内存。这将显示为一个保留的数量,以及它使用的数量。
Total:下面区域的所有累计值。
Unity:
Audio:音频系统的估计内存使用量
Video:视频系统的估计内存使用量
Profiler:分析器使用的总内存
分析器还列出了一些最常见的Assets和游戏对象类型的内存统计信息。这些统计数据包括计数(在正斜杠之前)和使用的内存。在这个列表中,Total Object Count显示应用程序创建的本地游戏对象的总数。如果这个数字随着时间的推移而增加,你的应用程序将创建游戏对象,而不会销毁它们。
可以使用详细视图获取应用程序当前状态的快照。单击Take Sample按钮以捕获当前目标的详细内存使用情况。分析器需要花费大量的时间来获取这些数据,因此详细视图不会提供实时的详细信息。Profiler获取样本后,Profiler窗口将显示一个树视图,可以在其中更详细地查看应用程序的内存使用情况。
开启“Gather Object references”设置将会手机关于在快照时引用对象的内容的信息,信息显示在窗口的右侧窗格中。
在树视图中,使用内存的对象分为以下几类:
Note: In the Other category, memory reported under System.ExecutableAndDlls is read-only memory. The operating system might discard these pages as needed and later reload them from the file system. This generates lower memory usage, and usually does not directly contribute to the operating system’s decision to close your application if it uses too much memory. Some of these pages might also be shared with other applications that are using the same frameworks.
渲染分析器显示渲染统计数据。
与Game创口的Static数据很相近。
UI和UI Detail模块提供关于Unity花费多少时间和资源在应用程序中布局和呈现用户界面的信息。
可以使用这个模块理解Unity如何处理应用程序中的UI批处理,包括为什么以及如何批处理对象。
还可以使用此模块来查找UI的哪个部分导致了缓慢的性能,或者在清除时间轴时预览UI。
Object:应用程序在分析期间使用的UI画布列表。双击一行以突出显示场景中匹配的对象。
Self Batch Count:Unity为canvas生成了多个个batches。
Cumulative Batch Count:累积的Batch数目。Unity为Canves和所有它嵌套的Canvase生成了多少个Batches。
Self Vertex Count:Canvas渲染了多少个顶点。
Cumulative Batch Count:Canvas乐基渲染了多少个顶点。
Batch Breaking Reason:
GameObjectCount:Batch中有多少个GameObject。
GameObjects:批处理中的GameObject列表。当您从列表中选择一个UI对象时,它的预览将出现在窗格的右侧。在预览上方,工具栏中有以下选项:
(1) Detach:选择此按钮可在单独的窗口中打开UI画布。要重新安装窗口,请关闭它。
(2) Preview Background:使用下拉菜单改变预览背景的颜色。你可以从棋盘格、黑色或白色中选择。如果你的UI有一个特别亮或暗的配色方案,这是非常有用的。
(3) 预览类型:使用下拉菜单从标准、透支或复合透支中进行选择。
-
Unity可能每帧处理具有joints的运动学刚体组件多次,这有助于值的呈现。
-
物理引擎处理的原始约束的数量。约束被用作关节和碰撞响应的构件。
-
例如,限制一个可配置关节的线性或旋转自由度涉及到每个约束的基本约束。
-
场景中所有碰撞器之间的接触对的总数,包括触发重叠对的数量。
-
触点是一对相互接触或重叠的碰撞体。
-
当它们之间的距离低于某个用户可配置的限制时,Unity为每一对碰撞器创建接触对。因此,您可能会看到为刚体组件生成的接触,这些组件还没有接触或重叠。
注意:剖析器中显示的数字可能与场景中包含物理组件的GameObjects的确切数量不一致。这是因为Unity以不同的速率处理一些物理组件,这取决于其他组件对它的影响(例如,一个附加的联合组件)。要计算带有特定物理组件的GameObjects的确切数量,必须使用FindObjectsOfType函数编写一个自定义脚本。物理分析器模块不显示睡眠刚体组件的数量。这些组件不参与物理引擎,因此不被分析器处理。
]]>to be continued…
这里是前言介绍。
Unity Accelerate Solution 团队对 Unity 引擎的源代码了如指掌,可帮助客户们最大限度地利用引擎。团队的日常工作包括深入剖析客户项目,搜寻其在速度、稳定性与效率方面有待优化的部分。本次,我们请到了这支 Unity 最为资深的软件工程师团队来分享一些移动游戏优化方面的专业知识。
他们分享了非常多的锦囊妙计,以至于一篇博文很难涵盖所有内容。因此,我们将推出一个博文系列。作为此系列的首篇文章,我们将着重介绍怎样借助性能分析、内存优化和代码架构来提高游戏的性能。在未来的几周内,我们将再发表两篇文章:一篇讨论 UI Physics,另一篇讨论音频和资源、项目配置和图形。
话不多说,直接开讲!
优化工作的第一个步骤便是通过性能分析来收集性能数据,这也是移动端优化的第一步。
我们要尽早在目标设备上进行性能分析,而且要经常分析。
**Unity Profiler **可提供应用关键的性能信息,因此是优化必不可少的一部分。尽早对项目进行性能分析,不要拖到发售前。对每一个故障或性能尖峰彻查到底。对你自己的项目性能有一个清晰的认知,可帮助你更轻松地发现新问题。
Unity 编辑器内的性能分析可以揭示出游戏不同系统的相对性能,而在运行设备上进行分析可让你获取更为准确的性能洞察。经常性地在目标设备上分析开发版。同时为最高配置与最低配置的设备进行性能分析和优化。
除了 Unity Profiler,你还可以使用 iOS 与 Android 的原生工具来进一步测试引擎在平台上的表现。
-
比如 iOS 的 Xcode 和 Instruments
-
以及 Android 上的 Android Studio 和 Android Profiler
部分硬件更是带有额外的分析工具(例如 Arm Mobile Studio、Intel VTune 以及 Snapdragon Profiler)。
Unity Profiler:
https://docs.unity3d.com/Manual/Profiler.html
Xcode:
https://developer.apple.com/documentation/xcode/
Instruments:
https://help.apple.com/instruments/mac/current/#/dev7b09c84f5
Android Studio:
https://developer.android.com/studio/intro
Android Profiler:
https://developer.android.com/studio/profile/android-profiler
Arm Mobile Studio:
https://developer.arm.com/tools-and-software/graphics-and-gaming/arm-mobile-studio
Intel VTune:
https://software.intel.com/content/www/us/en/develop/documentation/vtune-help/top.html
Snapdragon Profiler:
https://developer.qualcomm.com/software/snapdragon-profiler
针对性优化
如果游戏出现性能问题,切忌自行猜测或揣测成因,一定要使用 Unity Profiler 和平台专属工具来准确找出卡顿的问题来源。
不过,这里所说的优化并不都适用于你的应用。在某个项目中适用的方法不一定适用于你的项目。找出真正的性能瓶颈,将精力集中在有实际效用的地方。
了解 Unity Profiler 工作原理
Unity Profiler 可帮助你在运行时检测出卡顿或死机的原因,更好地了解特定帧或时间点上发生了什么。工具默认启用 CPU 和内存监测轨,你也可以根据需要启用额外的分析模块,包括渲染器、音频和物理(如极度依赖物理模拟的游戏或音游)。
或使用Unity Profiler来测试应用程序的性能和资源分配
勾选** Development Build** 便能为目标设备构建应用,勾选** Autoconnect Profiler **或者手动关联分析器,来加快其启动时间。
选中需要分析的目标平台。按下 Record(录制)按钮可记录应用在几秒钟内的运行(默认为300帧)。打开 **Unity > Preferences > analysis > Profiler > Frame Count **界面可修改录制帧数,最长录制帧数可以增加到 2000帧。当然更长的录制帧数会让 Unity 编辑器占用更多的 CPU 资源和内存,但其在特定情形下的作用非常大。
该分析器采用标记框架,可分析以 ProfileMarkers(如MonoBehaviour的Start或Update方法,或特定API调用)划分出的代码运行时。在使用 Deep Profiling 时,Unity 可以分析出每次函数调用的开始与结尾,准确地呈现出导致应用性能放缓的代码部分。
ProfileMarkers:
https://docs.unity.cn/ScriptReference/Unity.Profiling.ProfilerMarker.html
Deep Profiling:
https://docs.unity.cn/cn/current/Manual/ProfilerWindow.html
你可以借助Timeline视图来明确应用最为依赖的是CPU还是GPU
在分析游戏时,我们建议同时分析性能高峰与帧平均成本。在分析帧率过低的应用时,较为有效的方法是分析并优化每一帧中运行成本较高的代码。在尖峰处首先分析繁重的运算(如物理、AI、动画)和垃圾数据收集。
点击窗口中的某帧,接着使用 **Timeline **或 **Hierarchy **视图进行分析:
-
**Timeline **可显示特定帧耗时的可视化图表,帮助你直观地看到各项活动以及不同线程之间的关系。你可使用该选项来了解项目主要依赖的是 CPU 还是 GPU。
-
Hierarchy 将显示分组的 ProfileMarkers 层级,并以毫秒(Time ms’总耗时’和Self ms‘自执行耗时’)为单位对样本进行排序。你还可以数出帧上函数的 Calls 调用以及内存清理(GC Alloc)的次数。
Hierarchy视图允许按照耗时长短对ProfileMarkers进行排序
注意,在优化任意项目之前,一定要保存 Profiler 的 .data 文件,这样你就能在修改后比较优化前后的不同了。剖析、优化和比较,清空再重复,如此循环往复来提高性能。
Profiler analyzer
该工具可以汇总多帧 Profiler 数据,由用户来挑选出那些问题较大的帧。如果你想了解项目更改后 Profiler 的相应改变,可使用 Compare 视图分别加载和比较两个数据集,从而完成测试与优化。Profile analyzer 可在 Unity Package Manager 中下载。
Profile analyzer:
https://docs.unity3d.com/Packages/com.unity.performance.profile-analyzer@1.0/manual/index.html
Profiler analyzer可以很好地补充Profiler,可以进一步深入分析帧与标记数据
**
**
为每帧设定一个时间预算
你可以设立一个目标帧率,为每帧划定一个时间预算。理想情况下,一个以 30 fps 运行的应用每帧应占有约 33.33 毫秒(1000毫秒/30帧)。同样地,60 fps 每帧约为 16.66 毫秒。
设备可以在短时间内超过预算(如过场动画或加载过程中),但绝不能长时间如此。
设备温度优化
对于移动设备而言,长时间占用最大时间预算可能会导致设备过热,操作系统可能会启动 CPU 与 GPU 降频保护。我们建议每帧仅占用约 65% 的时间预算,保留一定的散热时间。常见的帧预算为:30 fps 为每帧 22 毫秒,60 fps 为每帧 11 毫秒。
大多数移动设备不像桌面设备那样有主动散热功能,因此环境温度可以直接影响性能。
如果设备发热严重,Profiler 可能会察觉并汇报这块性能低下的部分,即使其只是暂时性问题。为了应对分析时设备过热,分析应分成小段进行。这样便能允许设备散热、模拟出真实的运行条件。我们的建议是,在进行性能分析前后,预留 10-15 分钟用于设备散热。
分清 GPU 与 CPU 依赖程度
Profiler 可在 CPU 耗时或 GPU 耗时超出帧预算发出警告,它将弹出下方以 Gfx 为前缀的标记:
-
Gfx.WaitForCommands 标记表示渲染线程正在等待主线程完成,后者可能出现了性能瓶颈。
-
而 **Gfx.WaitForPresent **表示主线程正在等待 GPU 递交渲染帧。
Unity 会采取自动化内存管理来处理由用户生成的代码与脚本。值类型本地变量等小型数据会被分配到内存堆栈中,大型数据和持久性存储数据则会被分配到托管内存中。
垃圾数据收集器会定期识别并删除未被使用的托管内存,这个自动流程在检查堆的对象时可能导致游戏卡顿或运行放缓。
这里,优化内存便是指关注托管内存的分配与删除时机,将内存垃圾回收的影响降到最低。详情请在 Understanding the managed heap 中了解。
Understanding the managed heap:
https://docs.unity.cn/cn/current/Manual/BestPracticeUnderstandingPerformanceInUnity4-1.html
Memory Profiler中的帧数据记录、检视与比较
Memory Profiler
Memory Profiler 属于一个独立的分析模块,可以截取托管数据堆内存的状态,帮助你识别出数据碎片化和内存泄漏等问题。
在 Tree Map 视图中点击一个变量便可跟踪其在内存原生对象上的状态。你可在此处找出由纹理过大或资源重复加载而导致的常见内存消耗问题。
通过以下链接了解如何使用 Unity 的 Memory Profiler 优化内存占用。
Memory Profiler:
https://docs.unity3d.com/Packages/com.unity.memoryprofiler@0.2/manual/index.html
**
**
降低内存垃圾回收(GC)对性能的影响
Unity 使用的是 Boehm-Demers-Weiser 垃圾回收器 ,它会中止主线程代码运行,在垃圾回收工作完成后再让其恢复运行。
请注意,部分多余的托管内存分配会造成 GC 耗能高峰:
-
Strings(字符串):在 C# 中,字符串属于引用类型,而非值类型。我们需要减少不必要的字符串创建或更改操作,尽量避免解析 JSON 和 XML 等由字符串组成的数据文件,将数据存储于 ScriptableObjects,或以 MessagePack 或 Protobuf 等格式保存。如果你需要在运行时构建字符串,可使用 StringBuilder 类。
-
Unity 函数调用:部分函数会涉及托管内存分配。我们需要缓存数组引用,避免在循环进行中进行数组的内存分配,且尽量使用那些不会产生垃圾回收的函数。比如使用 GameObject.CompareTag,而不是使用 GameObject.tag 手动比对字符串(因为返回一个新字符串会产生垃圾数据)。
-
Boxing(打包):避免在引用类型变量处传入值类型变量,因为这样做会导致系统创建一个临时对象,在背地里将值类型转换为对象类型(如int i = 123; object o = i ),从而产生垃圾回收的需求。尽量使用正确的类型覆写来传入想要的值类型。泛型也可用于类型覆写。
-
Coroutines(协同程序):虽然 yield 不会产生垃圾回收,但新建 WaitForSeconds 对象会。我们可以缓存并复用 WaitForSeconds 对象,不必在 yield 中再度创建。
-
LINQ 与 Regular Expressions(正则表达式):这两种方法都会在后台的数据打包期间产生垃圾回收。如果需要追求性能,请尽量避免使用 LINQ 和正则表达式,转而使用 for 循环和列表来创建数组。
Boehm-Demers-Weiser 垃圾回收器:
定时处理垃圾回收
如果你确定垃圾回收带来的卡顿不会影响游戏特定阶段的体验,你可以使用 System.GC.Collect 来启动垃圾数据收集。
请在 Understanding Automatic Memory Management(自动化内存管理)中了解怎样妥善地使用这项功能。
Understanding Automatic Memory Management:
https://docs.unity.cn/cn/current/Manual/UnderstandingAutomaticMemoryManagement.html
使用增量式垃圾回收(Incremental GC)分散垃圾回收
增量式垃圾回收不会在程序运行期间长时间地中断运行,而会将总负荷分散到多帧,形成零碎的收集流程。如果垃圾数据收集对性能产生了较大的影响,可以尝试启用这个选项来降低 GC 的处理高峰。你可以使用 Profile analyzer 来检验此功能的实际作用。
使用增量垃圾回收来降低GC处理高峰
Unity 的 PlayerLoop 包含许多可与引擎核心互动的函数。该结构包含一些负责初始化和每帧更新的系统,所有脚本都将依靠 PlayerLoop 来生成游戏体验。
在分析时,你会在 PlayerLoop 下看到用户使用的代码(Editor代码则位于EditorLoop下)。
Profiler将显示在整个引擎运行过程中的自定义脚本、设置和图形
通过以下链接了解 PlayerLoop 和 脚本生命周期 。
PlayerLoop:
https://docs.unity.cn/ScriptReference/LowLevel.PlayerLoop.html
脚本生命周期:
https://docs.unity.cn/cn/2020.3/Manual/ExecutionOrder.html
你可以使用以下技巧和窍门来优化脚本。
深入理解 Unity PlayerLoop
我们需要掌握 Unity 帧循环的执行顺序 。每个 Unity 脚本都会按照预定的顺序运行事件函数,这要求我们了解 Awake、Start、Update 以及其他运行周期相关函数之间的区别。
请在 Script Lifecycle Flowchart(脚本生命周期流程图)中了解函数的执行顺序。
Script Lifecycle Flowchart:
https://docs.unity.cn/cn/2020.3/Manual/ExecutionOrder.html
降低每帧的代码量
有许多代码并非要在每帧上运行,这些不必要的逻辑完全可以在 Update、LateUpdate 和 FixedUpdate 中删去。这些事件函数可以保存那些必须每帧更新的代码,任何无须每帧更新的逻辑都不必放入其中,只有在相关事物发生变化时,这些逻辑才需被执行。
如果必须要使用 Update,可以考虑让代码每隔 n 帧运行一次。这种划分运行时间的方法也是一种将繁重工作负荷化整为零的常见技术。在下方例子中,**ExampleExpensiveFunction **将每隔三帧运行一次。
private int interval =3;voidUpdate(){if(Time.frameCount % interval ==0){ExampleExpensiveFunction();}}
避免在 Start/Awake 中加入繁重的逻辑
当首个场景加载时,每个对象都会调用如下函数:
-
Awake
-
OnEnable
-
Start
在应用完成第一帧的渲染前,我们须避免在这些函数中运行繁重的逻辑。否则,应用的加载时间会出乎意料地长。
请在 Order of execution for event functions(事件函数的执行顺序)中详细了解首个场景的加载。
Order of execution for event functions:
https://docs.unity.cn/cn/2020.3/Manual/ExecutionOrder.html
避免加入空事件
即使是空的 MonoBehaviours 也会占用资源,因此我们应该删除空的 Update 及 LateUpdate 方法。
如果你想用这些方法进行测试,请使用预处理指令(preprocessor directives):
#ifUNITY_EDITORvoidUpdate(){}#endif
如此一来,在编辑器中的 Update 测试便不会对构建版本造成不良的性能影响。
**
**
删去 Debug Log 语句
Log 声明(尤其是在Update、LateUpdate及FixedUpdate中)会拖慢性能,因此我们需要在构建之前禁用** Log **语句。
你可以用预处理指令编写一条 Conditional 属性来轻松禁用 Debug Log。比如下方这种的自定义类:
Conditional 属性:
https://docs.microsoft.com/en-us/dotnet/api/system.diagnostics.conditionalattribute?view=net-5.0
publicstaticclassLogging{[System.Diagnostics.Conditional("ENABLE_LOG")]staticpublicvoidLog(object message){ UnityEngine.Debug.Log(message);}}
添加自定义预处理指令可以实现脚本的切分
用自定义类生成 Log 信息时,你只需在 Player Settings 中禁用 ENABLE_LOG 预处理指令,所有的 Log 语句便会一下子消失。
使用哈希值、避免字符串
Unity 底层代码不会使用字符串来访问 Animator、Material 和 Shader 属性。出于提高效率的考虑,所有属性名称都会被哈希转换成属性 ID,用作实际的属性名称。
在 Animator、Material 或 Shader 上使用 Set 或 Get 方法时,我们便可以利用整数值而非字符串。后者还需经过一次哈希处理,并没有整数值那么直接。
使用 Animator.StringToHash 来转换 Animator 属性名称,用 Shader.PropertyToID 来转换 Material 和 Shader 属性名称。
Animator.StringToHash:https://docs.unity.cn/ScriptReference/Animator.StringToHash.html
Shader.PropertyToID:https://docs.unity.cn/ScriptReference/Shader.PropertyToID.html
选择正确的数据结构
由于数据结构每帧可能会迭代上千次,因此其结构对性能有着较大的影响。如果你不清楚数据集合该用 List、Array 还是 Dictionary 表示,可以参考 C# 的 MSDN 数据结构指南来选择正确的结构。
MSDN 数据结构指南:
https://docs.microsoft.com/en-us/dotnet/standard/collections/?redirectedfrom=MSDN
避免在运行时添加组件
在运行时调用 AddComponent 会占用一定的运行成本,Unity 必须检查组件是否有重复或依赖项。
当组件已经配置完成,Instantiating a Prefab(实例化预制件)一般来说性能更强。
Instantiating a Prefab:https://docs.unity.cn/cn/current/Manual/Prefabs.html
缓存 GameObjects 和组件
调用 GameObject.Find、GameObject.GetComponent 和 Camera.main(2020.2以下的版本)会产生较大的运行负担,因此这些方法不适合在 Update 中调用,而应在 Start 中调用并缓存。
下方例子展示了一种低效率的 GetComponent 多次调用:
voidUpdate(){ Renderer myRenderer = GetComponent<Renderer>();ExampleFunction(myRenderer);}
其实 GetComponent 的结果会被缓存,因此只需调用一次即可。缓存的结果完全可在 Update 中重复使用,不必再度调用 GetComponent。
private Renderer myRenderer;voidStart(){ myRenderer = GetComponent<Renderer>();}voidUpdate(){ExampleFunction(myRenderer);}
对象池(Object Pool)
Instantiate(实例化)和 Destroy(销毁)方法会产生需要垃圾回收数据、引发垃圾回收(GC)的处理高峰,且其运行较为缓慢。与其经常性地实例化和销毁 GameObjects(如射出的子弹),不如使用对象池将对象预先储存,再重复地使用和回收。
对象池:
https://en.wikipedia.org/wiki/Object_pool_pattern
在这个例子中,ObjectPool创建了20个PlayerLaser实例供重复使用
在游戏特定时间点(如显示菜单画面时)创建可复用的实例,来降低 CPU 处理高峰的影响,再用一个集合来形成“对象池”。在游戏期间,实例可在需要时启用/禁用,用完后可返回到池中,不必再进行销毁。
PlayerLaser对象池目前尚未激活,正等待玩家射击
这一来你就可以减少托管内存分配的次数、防止产生垃圾回收的问题。
使用 ScriptableObjects(可编程对象)
固定不变的值或配置信息可以存储在 ScriptableObject 中,不一定得储存于 MonoBehaviour。ScriptableObject 可由整个项目访问,一次设置便可应用于项目全局,但它并不能直接关联到 GameObject 上。
我们可在 ScriptableObject 中用字段来存储值或设定,然后在 MonoBehaviours 中引用该对象。
用作“Inventory(物品栏)”的ScriptableObject可保存多个游戏对象的设定
下方的 ScriptableObject 字段可有效防止多次 MonoBehaviour 实例化产生的数据重复。
请参考 ScriptableObjects 文档了解如何使用。
ScriptableObjects:https://docs.unity.cn/cn/current/Manual/class-ScriptableObject.html
后续我们将再发表两篇文章:一篇讨论 UI Physics,另一篇讨论音频和资源、项目配置和图形。敬请期待。
]]>to be continued…
这里是前言介绍。
没想到Unity的音频会成为内存杀手,在实际的商业项目中,音频的优化必不可少。
2.所有音频导入时,默认两项设置,LoadType是”Decompress On Load”,压缩格式是“Vorbis”,例如下图原始文件大小计算为35.9 MB,导入的大小计算为10.7 MB。这意味着这个音频剪辑将使您的游戏(存档)大小增加10兆字节,但播放它需要近36兆字节的RAM。
3.Load Type的各个选项
Compressed In Memory – 音频剪辑将存储在RAM中,播放时将解压缩,播放时不需要额外的存储。
Streaming –音频永久存在设备上(硬盘或闪存上) ,播放流媒体方式. 不需要RAM进行存储或播放。
Decompress On Load – 未压缩的音频将存储在RAM中。这个选项需要的内存最多,但是播放它不会像其他选项那样需要太多的CPU电源。
怎么选?长音频播放消耗大量内存,如果播放时不想在内存中进行解压,有两个选择: (1)Load Type选“Streaming”, Compression Format 选”Vorbis",使用最少的内存,但需要更多的CPU电量和硬盘I/O操作; (2)Load Type选“Compressed In Memory”, Compression Format 选”Vorbis",磁盘I/O操作被替换成内存的消耗,请注意,要调整“Quaility”滑块以减小压缩剪辑的大小,以交换音质,一般推荐70%左右。 一般是看到底音乐占据多少内存以及你的目标机型是什么样子的,如果音乐占据的内存本身比较高,你的目标机型的内存又比较小,那么就选择第二种,这种方案会卡一点,否则选择第一种就更好
4.声音特效
(1)对于经常播放的和短的音频剪辑,使用“*Decompress On Load”和“PCM或ADPCM"压缩格式*。当选择PCM时,不需要解压缩,如果音频剪辑很短,它将很快加载。你也可以使用ADPCM。它需要解压,但解压比Vorbis快得多。 (2)对于经常播放,中等大小的音频剪辑使用”*Compressed In Memory“*和”ADPCM“压缩格式,比原始PCM小3.5倍,解压算法的CPU消耗量不会像vorbis消耗那么多CPU。 (3)对于很少播放并且长度比较短的声音剪辑,使用”*Compressed In Memory",* ADPCM 这种压缩格式,原因同(2)。 (4)对于很少播放中等大小的音频,使用”*Compressed In Memory“ *和Vorbis压缩格式。这个音频可能太长,无法使用adpcm存储,播放太少,因此解压缩所需的额外CPU电量不会太多。
一个2MB的音频文件,在内存中却变成了20+MB
音频加载有多种模式: 造成这种现象是因为选择了Decompress On Load这种模式。
为什么会造成这种现象呢?这就要学习一下unity的音频的导入选项了。
动态解码声音。此方法使用==最小量的内存==来缓冲从磁盘逐渐读取并在运行中解码的压缩数据。请注意,解压缩发生在分析器窗口的音频面板的“Streaming CPU”部分中可监视其CPU使用率的单独流式线程上。注意:即使没有加载任何音频数据,流式片段也会有大约200KB的过载。
音频文件一经加载就会被解压缩。对较小的压缩声音使用此选项可避免即时解压缩的性能开销。==请注意,在加载时解压缩Vorbis编码的声音比使用它压缩大约多十倍的内存(对于ADPCM编码大约是3.5倍)==,所以不要将此选项用于大文件。
保持声音在存储器中压缩并在播放时解压缩。这个选项有一个小的性能开销(尤其是对于Ogg / Vorbis压缩文件),所以==只能用于较大的文件==,因为在加载时解压缩会使用大量的内存。解压缩在混音器线程上发生,并可在Profiler窗口的音频面板中的“DSP CPU”部分进行监视。
如果启用,音频剪辑将在场景加载时预先加载。默认情况下,这反映了在场景开始播放时所有音频剪辑已完成加载的标准Unity行为。如果未设置该标志,音频数据将要么被上加载的第一个的AudioSource.Play()/ 的AudioSource .PlayOneShot(),或者它可以通过加载的AudioSource.LoadAudioData(),并通过再次卸载的AudioSource .UnloadAudioData()。
强制音效用单声道
只有少数的手机装置真的有立体声喇叭,而将音效强制设定为单声道能让内存的消耗减半。就算游戏会输出部份的立体声,有些单声道像是 UI 音效还是可以开启这个选项。
详细策略见下面这篇文章,具体的工具实现已上传github
]]>to be continued…
这里是前言介绍。
转载自 :Unity3D内存释放
最近网友通过网站搜索Unity3D在手机及其他平台下占用内存太大.这里写下关于unity3d对于内存的管理与优化.
Unity3D里有两种动态加载机制:一个是Resources.Load,另外一个通过AssetBundle,其实两者区别不大。Resources.Load就是从一个缺省打进程序包里的AssetBundle里加载资源,而一般AssetBundle文件需要你自己创建,运行时 动态加载,可以指定路径和来源的。
其实场景里所有静态的对象也有这么一个加载过程,只是Unity3D后台替你自动完成了。
详细说一下细节概念:
AssetBundle运行时加载:
来自文件就用CreateFromFile(注意这种方法只能用于standalone程序)这是最快的加载方法
也可以来自Memory,用CreateFromMemory(byte[]),这个byte[]可以来自文件读取的缓冲,www的下载或者其他可能的方式。
其实WWW的assetBundle就是内部数据读取完后自动创建了一个assetBundle而已
Create完以后,等于把硬盘或者网络的一个文件读到内存一个区域,这时候只是个AssetBundle内存镜像数据块,还没有Assets的概念。
Assets加载:
用AssetBundle.Load(同Resources.Load)这才会从AssetBundle的内存镜像里读取并创建一个Asset对象,创建Asset对象同时也会分配相应内存用于存放(反序列化)
异步读取用AssetBundle.LoadAsync
也可以一次读取多个用AssetBundle.LoadAll
AssetBundle的释放:
AssetBundle.Unload(flase)是释放AssetBundle文件的内存镜像,不包含Load创建的Asset内存对象。
AssetBundle.Unload(true)是释放那个AssetBundle文件内存镜像和并销毁所有用Load创建的Asset内存对象。
一个Prefab从assetBundle里Load出来 里面可能包括:Gameobject transform mesh texture material shader script和各种其他Assets。
你Instaniate一个Prefab,是一个对Assets进行Clone(复制)+引用结合的过程,GameObject transform是Clone是新生成的。其他mesh / texture / material / shader等,这其中些是纯引用的关系的,包括:Texture和TerrainData,还有引用和复制同时存在的,包括:Mesh/material /PhysicMaterial。引用的Asset对象不会被复制,只是一个简单的指针指向已经Load的Asset对象。这种含糊的引用加克隆的混合, 大概是搞糊涂大多数人的主要原因。
专门要提一下的是一个特殊的东西:Script Asset,看起来很奇怪,Unity里每个Script都是一个封闭的Class定义而已,并没有写调用代码,光Class的定义脚本是不会工作的。其 实Unity引擎就是那个调用代码,Clone一个script asset等于new一个class实例,实例才会完成工作。把他挂到Unity主线程的调用链里去,Class实例里的OnUpdate OnStart等才会被执行。多个物体挂同一个脚本,其实就是在多个物体上挂了那个脚本类的多个实例而已,这样就好理解了。在new class这个过程中,数据区是复制的,代码区是共享的,算是一种特殊的复制+引用关系。
你可以再Instaniate一个同样的Prefab,还是这套mesh/texture/material/shader…,这时候会有新的GameObject等,但是不会创建新的引用对象比如Texture.
所以你Load出来的Assets其实就是个数据源,用于生成新对象或者被引用,生成的过程可能是复制(clone)也可能是引用(指针)
当你Destroy一个实例时,只是释放那些Clone对象,并不会释放引用对象和Clone的数据源对象,Destroy并不知道是否还有别的object在引用那些对象。
等到没有任何 游戏场景物体在用这些Assets以后,这些assets就成了没有引用的游离数据块了,是UnusedAssets了,这时候就可以通过Resources.UnloadUnusedAssets来释放,Destroy不能完成这个任 务,AssetBundle.Unload(false)也不行,AssetBundle.Unload(true)可以但不安全,除非你很清楚没有任何 对象在用这些Assets了。
配个图加深理解:
Unity3D占用内存太大怎么解决呢?
虽然都叫Asset,但复制的和引用的是不一样的,这点被Unity的暗黑技术细节掩盖了,需要自己去理解。
关于内存管理
按照传统的编程思维,最好的方法是:自己维护所有对象,用一个Queue来保存所有object,不用时该Destory的,该Unload的自己处理。
但这样在C# .net框架底下有点没必要,而且很麻烦。
稳妥起见你可以这样管理
创建时:
先建立一个AssetBundle,无论是从www还是文件还是memory
用AssetBundle.load加载需要的asset
加载完后立即AssetBundle.Unload(false),释放AssetBundle文件本身的内存镜像,但不销毁加载的Asset对象。(这样你不用保存AssetBundle的引用并且可以立即释放一部分内存)
释放时:
如果有Instantiate的对象,用Destroy进行销毁
在合适的地方调用Resources.UnloadUnusedAssets,释放已经没有引用的Asset.
如果需要立即释放内存加上GC.Collect(),否则内存未必会立即被释放,有时候可能导致内存占用过多而引发异常。
这样可以保证内存始终被及时释放,占用量最少。也不需要对每个加载的对象进行引用。
当然这并不是唯一的方法,只要遵循加载和释放的原理,任何做法都是可以的。
系统在加载新场景时,所有的内存对象都会被自动销毁,包括你用AssetBundle.Load加载的对象和Instaniate克隆的。但是不包括AssetBundle文件自身的内存镜像,那个必须要用Unload来释放,用.net的术语,这种数据缓存是非托管的。
总结一下各种加载和初始化的用法:
AssetBundle.CreateFrom…..:创建一个AssetBundle内存镜像,注意同一个assetBundle文件在没有Unload之前不能再次被使用
WWW.AssetBundle:同上,当然要先new一个再 yield return 然后才能使用
AssetBundle.Load(name): 从AssetBundle读取一个指定名称的Asset并生成Asset内存对象,如果多次Load同名对象,除第一次外都只会返回已经生成的Asset 对象,也就是说多次Load一个Asset并不会生成多个副本(singleton)。
Resources.Load(path&name):同上,只是从默认的位置加载。
Instantiate(object):Clone 一个object的完整结构,包括其所有Component和子物体(详见官方文档),浅Copy,并不复制所有引用类型。有个特别用法,虽然很少这样 用,其实可以用Instantiate来完整的拷贝一个引用类型的Asset,比如Texture等,要拷贝的Texture必须类型设置为 Read/Write able。
总结一下各种释放
Destroy: 主要用于销毁克隆对象,也可以用于场景内的静态物体,不会自动释放该对象的所有引用。虽然也可以用于Asset,但是概念不一样要小心,如果用于销毁从文 件加载的Asset对象会销毁相应的资源文件!但是如果销毁的Asset是Copy的或者用脚本动态生成的,只会销毁内存对象。
AssetBundle.Unload(false):释放AssetBundle文件内存镜像
AssetBundle.Unload(true):释放AssetBundle文件内存镜像同时销毁所有已经Load的Assets内存对象
Reources.UnloadAsset(Object):显式的释放已加载的Asset对象,只能卸载磁盘文件加载的Asset对象
Resources.UnloadUnusedAssets:用于释放所有没有引用的Asset对象
GC.Collect()强制垃圾收集器立即释放内存 Unity的GC功能不算好,没把握的时候就强制调用一下
在3.5.2之前好像Unity不能显式的释放Asset
举两个例子帮助理解
例子1:
一个常见的错误:你从某个AssetBundle里Load了一个prefab并克隆之:obj = Instaniate(AssetBundle1.Load(‘MyPrefab”);
这个prefab比如是个npc
然后你不需要他的时候你用了:Destroy(obj);你以为就释放干净了
其实这时候只是释放了Clone对象,通过Load加载的所有引用、非引用Assets对象全都静静静的躺在内存里。
这种情况应该在Destroy以后用:AssetBundle1.Unload(true),彻底释放干净。
如果这个AssetBundle1是要反复读取的 不方便Unload,那可以在Destroy以后用:Resources.UnloadUnusedAssets()把所有和这个npc有关的Asset都销毁。
当然如果这个NPC也是要频繁创建 销毁的 那就应该让那些Assets呆在内存里以加速游戏体验。
由此可以解释另一个之前有人提过的话题:为什么第一次Instaniate 一个Prefab的时候都会卡一下,因为在你第一次Instaniate之前,相应的Asset对象还没有被创建,要加载系统内置的 AssetBundle并创建Assets,第一次以后你虽然Destroy了,但Prefab的Assets对象都还在内存里,所以就很快了。
顺便提一下几种加载方式的区别:
其实存在3种加载方式:
一是静态引用,建一个public的变量,在Inspector里把prefab拉上去,用的时候instantiate
二是Resource.Load,Load以后instantiate
三是AssetBundle.Load,Load以后instantiate
三种方式有细 节差异,前两种方式,引用对象texture是在instantiate时加载,而assetBundle.Load会把perfab的全部assets 都加载,instantiate时只是生成Clone。所以前两种方式,除非你提前加载相关引用对象,否则第一次instantiate时会包含加载引用 assets的操作,导致第一次加载的lag。
例子2:
从磁盘读取一个1.unity3d文件到内存并建立一个AssetBundle1对象
AssetBundle AssetBundle1 = AssetBundle.CreateFromFile(“1.unity3d”);
从AssetBundle1里读取并创建一个Texture Asset,把obj1的主贴图指向它
obj1.renderer.material.mainTexture = AssetBundle1.Load(“wall”) as Texture;
把obj2的主贴图也指向同一个Texture Asset
obj2.renderer.material.mainTexture =obj1.renderer.material.mainTexture;
Texture是引用对象,永远不会有自动复制的情况出现(除非你真需要,用代码自己实现copy),只会是创建和添加引用
如果继续:
AssetBundle1.Unload(true) 那obj1和obj2都变成黑的了,因为指向的Texture Asset没了
如果:
AssetBundle1.Unload(false) 那obj1和obj2不变,只是AssetBundle1的内存镜像释放了
继续:
Destroy(obj1),//obj1被释放,但并不会释放刚才Load的Texture
如果这时候:
Resources.UnloadUnusedAssets();
不会有任何内存释放 因为Texture asset还被obj2用着
如果
Destroy(obj2)
obj2被释放,但也不会释放刚才Load的Texture
继续
Resources.UnloadUnusedAssets();
这时候刚才load的Texture Asset释放了,因为没有任何引用了
最后CG.Collect();
强制立即释放内存
由此可以引申出论坛里另一个被提了几次的问题,如何加载一堆大图片轮流显示又不爆掉
不考虑AssetBundle,直接用www读图片文件的话等于是直接创建了一个Texture Asset
假设文件保存在一个List里
TLlist
int n=0;
IEnumerator OnClick()
{
WWW image = newwww(fileList[n++]);
yield return image;
obj.mainTexture = image.texture;
n = (n>=fileList.Length-1)?0:n;
Resources.UnloadUnusedAssets();
}
这样可以保证内存里始终只有一个巨型Texture Asset资源,也不用代码追踪上一个加载的Texture Asset,但是速度比较慢
或者:
IEnumerator OnClick()
{
WWW image = newwww(fileList[n++]);
yield return image;
Texture tex =obj.mainTexture;
obj.mainTexture = image.texture;
n = (n>=fileList.Length-1)?0:n;
Resources.UnloadAsset(tex);
}
这样卸载比较快
Hog的评论引用:
感觉这是Unity内存管理暗黑和混乱的地方,特别是牵扯到Texture
我最近也一直在测试这些用AssetBundle加载的asset一样可以用Resources.UnloadUnusedAssets卸载,但必须先AssetBundle.Unload,才会被识别为无用的asset。比较保险的做法是
创建时:
先建立一个AssetBundle,无论是从www还是文件还是memory
用AssetBundle.load加载需要的asset
用完后立即AssetBundle.Unload(false),关闭AssetBundle但不摧毁创建的对象和引用
销毁时:
对Instantiate的对象进行Destroy
在合适的地方调用Resources.UnloadUnusedAssets,释放已经没有引用的Asset.
如果需要立即释放加上GC.Collect()
这样可以保证内存始终被及时释放
只要你Unload过的AssetBundle,那些创建的对象和引用都会在LoadLevel时被自动释放。
全面理解Unity加载和内存管理机制之二:进一步深入和细节
Unity几种动态加载Prefab方式的差异:
其实存在3种加载prefab的方式:
一是静态引用,建一个public的变量,在Inspector里把prefab拉上去,用的时候instantiate
二是Resource.Load,Load以后instantiate
三是AssetBundle.Load,Load以后instantiate
三种方式有细节差异,前两种方式,引用对象texture是在instantiate时加载,而assetBundle.Load会把perfab的全部 assets都加载,instantiate时只是生成Clone。所以前两种方式,除非你提前加载相关引用对象,否则第一次instantiate时会 包含加载引用类assets的操作,导致第一次加载的lag。官方论坛有人说Resources.Load和静态引用是会把所有资源都预先加载的,反复测试的结果,静态引用和Resources.Load也是OnDemand的,用到时才会加载。
几种AssetBundle创建方式的差异:
CreateFromFile:这种方式不会把整个硬盘AssetBundle文件都加载到 内存来,而是类似建立一个文件操作句柄和缓冲区,需要时才实时Load,所以这种加载方式是最节省资源的,基本上AssetBundle本身不占什么内 存,只需要Asset对象的内存。可惜只能在PC/Mac Standalone程序中使用。
CreateFromMemory和www.assetBundle:这两种方式AssetBundle文件会整个镜像于内存中,理论上文件多大就需要多大的内存,之后Load时还要占用额外内存去生成Asset对象。
什么时候才是UnusedAssets?
看一个例子:
Object obj = Resources.Load(“MyPrefab”);
GameObject instance = Instantiate(obj) as GameObject;
………
Destroy(instance);
创建随后销毁了一个Prefab实例,这时候 MyPrefab已经没有被实际的物体引用了,但如果这时:
Resources.UnloadUnusedAssets();
内存并没有被释放,原因:MyPrefab还被这个变量obj所引用
这时候:
obj = null;
Resources.UnloadUnusedAssets();
这样才能真正释放Assets对象
所以:UnusedAssets不但要没有被实际物体引用,也要没有被生命周期内的变量所引用,才可以理解为 Unused(引用计数为0)
所以所以:如果你用个全局变量保存你Load的Assets,又没有显式的设为null,那 在这个变量失效前你无论如何UnloadUnusedAssets也释放不了那些Assets的。如果你这些Assets又不是从磁盘加载的,那除了 UnloadUnusedAssets或者加载新场景以外没有其他方式可以卸载之。
一个复杂的例子,代码很丑陋实际也不可能这样做,只是为了加深理解
1 | IEnumerator OnClick() |
这是测试结果的内存Profile曲线图
Unity3D占用内存太大怎么解决呢?
图片:p12.jpg
很经典的对称造型,用多少释放多少。
这是各阶段的内存和其他数据变化
说明:
1 初始状态
2 载入AssetBundle文件后,内存多了文件镜像,用量上升,Total Object和Assets增加1(AssetBundle也是object)
3 载入Texture后,内存继续上升,因为多了Texture Asset,Total Objects和Assets增加1
4 载入Prefab后,内存无明显变化,因为最占内存的Texture已经加载,Materials上升是因为多了Prefab的材质,Total Objects和Assets增加6,因为 Perfab 包含很多 Components
5 实例化Prefab以后,显存的Texture Memory、GameObjectTotal、Objects in Scene上升,都是因为实例化了一个可视的对象
6 销毁实例后,上一步的变化还原,很好理解
7 卸载AssetBundle文件后,AssetBundle文件镜像占用的内存被释放,相应的Assets和Total Objects Count也减1
8 直接Resources.UnloadUnusedAssets,没有任何变化,因为所有Assets引用并没有清空
9 把Prefab引用变量设为null以后,整个Prefab除了Texture外都没有任何引用了,所以被UnloadUnusedAssets销毁,Assets和Total Objects Count减6
10 再把Texture的引用变量设为null,之后也被UnloadUnusedAssets销毁,内存被释放,assets和Total Objects Count减1,基本还原到初始状态
从中也可以看出:
Texture加载以后是到内存,显示的时候才进入显存的Texture Memory。
所有的东西基础都是Object
Load的是Asset,Instantiate的是GameObject和Object in Scene
Load的Asset要Unload,new的或者Instantiate的object可以Destroy
Unity3D在内存占用上一直被人诟病,特别是对于面向移动设备的游戏开发,动辄内存占用飙上一两百兆,导致内存资源耗尽,从而被系统强退造成极 差的体验。类似这种情况并不少见,但是绝大部分都是可以避免的。虽然理论上Unity的内存管理系统应当为开发者分忧解难,让大家投身到更有意义的事情中 去,但是对于Unity对内存的管理方式,官方文档中并没有太多的说明,基本需要依靠自己摸索。最近在接手的项目中存在严重的内存问题,在参照文档和 Unity Answer众多猜测和证实之后,稍微总结了下Unity中的内存的分配和管理的基本方式,在此共享。
虽然Unity标榜自己的内存使用全都是“Managed Memory”,但是事实上你必须正确地使用内存,以保证回收机制正确运行。如果没有做应当做的事情,那么场景和代码很有可能造成很多非必要内存的占用, 这也是很多Unity开发者抱怨内存占用太大的原因。接下来我会介绍Unity使用内存的种类,以及相应每个种类的优化和使用的技巧。遵循使用原则,可以 让非必要资源尽快得到释放,从而降低内存占用。
实际上Unity游戏使用的内存一共有三种:程序代码、托管堆(Managed Heap)以及本机堆(Native Heap)。
程序代码包括了所有的Unity引擎,使用的库,以及你所写的所有的游戏代码。在编译后,得到的运行文件将会被加载到设备中执行,并占用一定内存。
这部分内存实际上是没有办法去“管理”的,它们将在内存中从一开始到最后一直存在。一个空的Unity默认场景,什么代码都不放,在iOS设备上占 用内存应该在17MB左右,而加上一些自己的代码很容易就飙到20MB左右。想要减少这部分内存的使用,能做的就是减少使用的库,稍后再说。
托管堆是被Mono使用的一部分内存。Mono项目一个开源的.net框架的一种实现,对于Unity开发,其实充当了基本类库的角色。
托管堆用来存放类的实例(比如用new生成的列表,实例中的各种声明的变量等)。“托管”的意思是Mono“应该”自动地改变堆的大小来适应你所需要的内存,
并且定时地使用垃圾回收(Garbage Collect)来释放已经不需要的内存。关键在于,有时候你会忘记清除对已经不需要再使用的内存的引用,
从而导致Mono认为这块内存一直有用,而无法回收。
最后,本机堆是Unity引擎进行申请和操作的地方,比如贴图,音效,关卡数据等。Unity使用了自己的一套内存管理机制来使这块内存具有和托管堆类似的功能。
基本理念是,如果在这个关卡里需要某个资源,那么在需要时就加载,之后在没有任何引用时进行卸载。听起来很美好也和托管堆一样,
但是由于Unity有一套自动加载和卸载资源的机制,让两者变得差别很大。自动加载资源可以为开发者省不少事儿,
但是同时也意味着开发者失去了手动管理所有加载资源的权力,这非常容易导致大量的内存占用(贴图什么的你懂的),
也是Unity给人留下“吃内存”印象的罪魁祸首。
这部分的优化相对简单,因为能做的事情并不多:主要就是减少打包时的引用库,改一改build设置即可。
对于一个新项目来说不会有太大问题,但是如果是已经存在的项目,可能改变会导致原来所需要的库的缺失(虽说一般来说这种可能性不大),
因此有可能无法做到最优。
当使用Unity开发时,默认的Mono包含库可以说大部分用不上,在Player Setting(Edit->Project Setting->Player或者Shift+Ctrl(Command)+B里的Player Setting按钮)
面板里,将最下方的Optimization栏目中“Api Compatibility Level”选为.NET 2.0 Subset,表示你只会使用到部分的.NET 2.0 Subset,不需要Unity将全部.NET的Api包含进去。接下来的“Stripping Level”表示从build的库中剥离的力度,每一个剥离选项都将从打包好的库中去掉一部分内容。你需要保证你的代码没有用到这部分被剥离的功能,
选为“Use micro mscorlib”的话将使用最小的库(一般来说也没啥问题,不行的话可以试试之前的两个)。库剥离可以极大地降低打包后的程序的尺寸以及程序代码的内存占用,唯一的缺点是这个功能只支持Pro版的Unity。
这部分优化的力度需要根据代码所用到的.NET的功能来进行调整,有可能不能使用Subset或者最大的剥离力度。
如果超出了限度,很可能会在需要该功能时因为找不到相应的库而crash掉(ios的话很可能在Xcode编译时就报错了)。
比较好地解决方案是仍然用最强的剥离,并辅以较小的第三方的类库来完成所需功能。
一个最常见问题是最大剥离时Sysytem.Xml是不被Subset和micro支持的,如果只是为了xml,完全可以导入一个轻量级的xml库来解决依赖(Unity官方推荐这个)。
关于每个设定对应支持的库的详细列表,可以在这里找到。关于每个剥离级别到底做了什么,Unity的文档也有说明。
实际上,在游戏开发中绝大多数被剥离的功能使用不上的,因此不管如何,库剥离的优化方法都值得一试。
Unity有一篇不错的关于托管堆代码如何写比较好的说明,在此基础上我个人有一些补充。
首先需要明确,托管堆中存储的是你在你的代码中申请的内存(不论是用js,C#还是Boo写的)。
一般来说,无非是new或者Instantiate两种生成object的方法(事实上Instantiate中也是调用了new)。
在接收到alloc请求后,托管堆在其上为要新生成的对象实例以及其实例变量分配内存,如果可用空间不足,则向系统申请更多空间。
当你使用完一个实例对象之后,通常来说在脚本中就不会再有对该对象的引用了(这包括将变量设置为null或其他引用,超出了变量的作用域,
或者对Unity对象发送Destory())。在每隔一段时间,Mono的垃圾回收机制将检测内存,将没有再被引用的内存释放回收。总的来说,
你要做的就是在尽可能早的时间将不需要的引用去除掉,这样回收机制才能正确地把不需要的内存清理出来。但是需要注意在内存清理时有可能造成游戏的短时间卡顿,
这将会很影响游戏体验,因此如果有大量的内存回收工作要进行的话,需要尽量选择合适的时间。
如果在你的游戏里,有特别多的类似实例,并需要对它们经常发送Destroy()的话,游戏性能上会相当难看。比如小熊推金币中的金币实例,按理说每枚金币落下台子后
都需要对其Destory(),然后新的金币进入台子时又需要Instantiate,这对性能是极大的浪费。一种通常的做法是在不需要时,不摧毁这个GameObject,而只是隐藏它,
并将其放入一个重用数组中。之后需要时,再从重用数组中找到可用的实例并显示。这将极大地改善游戏的性能,相应的代价是消耗部分内存,一般来说这是可以接受的。
关于对象重用,可以参考Unity关于内存方面的文档中Reusable Object Pools部分,或者Prime31有一个是用Linq来建立重用池的视频教程(Youtube,需要FQ,上,下)。
如果不是必要,应该在游戏进行的过程中尽量减少对GameObject的Instantiate()和Destroy()调用,因为对计算资源会有很大消耗。在便携设备上短时间大量生成和摧毁物体的
话,很容易造成瞬时卡顿。如果内存没有问题的话,尽量选择先将他们收集起来,然后在合适的时候(比如按暂停键或者是关卡切换),将它们批量地销毁并 且回收内存。Mono的内存回收会在后台自动进行,系统会选择合适的时间进行垃圾回收。在合适的时候,也可以手动地调用 System.GC.Collect()来建议系统进行一次垃圾回收。
要注意的是这里的调用真的仅仅只是建议,可能系统会在一段时间后在进行回收,也可能完全不理会这条请求,不过在大部分时间里,这个调用还是靠谱的。
当你加载完成一个Unity的scene的时候,scene中的所有用到的asset(包括Hierarchy中所有GameObject上以及脚本中赋值了的的材质,贴图,动画,声音等素材),
都会被自动加载(这正是Unity的智能之处)。也就是说,当关卡呈现在用户面前的时候,所有Unity编辑器能认识的本关卡的资源都已经被预先加 入内存了,这样在本关卡中,用户将有良好的体验,不论是更换贴图,声音,还是播放动画时,都不会有额外的加载,这样的代价是内存占用将变多。Unity最 初的设计目的还是面向台式机,
几乎无限的内存和虚拟内存使得这样的占用似乎不是问题,但是这样的内存策略在之后移动平台的兴起和大量移动设备游戏的制作中出现了弊端,因为移动设 备能使用的资源始终非常有限。因此在面向移动设备游戏的制作时,尽量减少在Hierarchy对资源的直接引用,而是使用Resource.Load的方 法,在需要的时候从硬盘中读取资源,
在使用后用Resource.UnloadAsset()和Resources.UnloadUnusedAssets()尽快将其卸载掉。总之,这里是一个处理时间和占用内存空间的trade off,
如何达到最好的效果没有标准答案,需要自己权衡。
在关卡结束的时候,这个关卡中所使用的所有资源将会被卸载掉(除非被标记了DontDestroyOnLoad)的资源。注意不仅是DontDestroyOnLoad的资源本身,
其相关的所有资源在关卡切换时都不会被卸载。DontDestroyOnLoad一般被用来在关卡之间保存一些玩家的状态,比如分数,级别等偏向文 本的信息。如果DontDestroyOnLoad了一个包含很多资源(比如大量贴图或者声音等大内存占用的东西)的话,这部分资源在场景切换时无法卸 载,将一直占用内存,
这种情况应该尽量避免。
另外一种需要注意的情况是脚本中对资源的引用。大部分脚本将在场景转换时随之失效并被回收,但是,在场景之间被保持的脚本不在此列(通常情况是被附 着在DontDestroyOnLoad的GameObject上了)。而这些脚本很可能含有对其他物体的Component或者资源的引用,这样相关的 资源就都得不到释放,
这绝对是不想要的情况。另外,static的单例(singleton)在场景切换时也不会被摧毁,同样地,如果这种单例含有大量的对资源的引用,也会成为大问题。
因此,尽量减少代码的耦合和对其他脚本的依赖是十分有必要的。如果确实无法避免这种情况,那应当手动地对这些不再使用的引用对象调用Destroy()
或者将其设置为null。这样在垃圾回收的时候,这些内存将被认为已经无用而被回收。
需要注意的是,Unity在一个场景开始时,根据场景构成和引用关系所自动读取的资源,只有在读取一个新的场景或者reset当前场景时,才会得到清理。
因此这部分内存占用是不可避免的。在小内存环境中,这部分初始内存的占用十分重要,因为它决定了你的关卡是否能够被正常加载。因此在计算资源充足
或是关卡开始之后还有机会进行加载时,尽量减少Hierarchy中的引用,变为手动用Resource.Load,将大大减少内存占用。在 Resource.UnloadAsset()和Resources.UnloadUnusedAssets()时,只有那些真正没有任何引用指向的资源 会被回收,因此请确保在资源不再使用时,将所有对该资源的引用设置为null或者Destroy。
同样需要注意,这两个Unload方法仅仅对Resource.Load拿到的资源有效,而不能回收任何场景开始时自动加载的资源。与此类似的还有 AssetBundle的Load和Unload方法,灵活使用这些手动自愿加载和卸载的方法,是优化Unity内存占用的不二法则。
总之这些就是关于Unity3d优化细节,具体还是查看Unity3D的技术手册,以便实现最大的优化。
]]>to be continued…
这里是前言介绍。
知道吗,如果只是想要实现Xray效果的话,其实并不难。
实现上图的效果,原理就是对角色画两次。第一次是被遮挡住的效果(半透明、单色),第二次是正常的效果(为了简化这里使用unlight只显示贴图)
这两个pass最大的区别,在于使用不同的Ztest(深度测试)。但是这一次我决定不仅仅只写关于Ztest的问题。反正我已经决定对抗懒癌晚期,那就干脆一口气把RenderQueue、Ztest、Zwrite、AlphaTest、AlphaBlend、StencilTest这些烂七八糟的东西都拎出来写一遍,因为这些东西有很多地方都是相通的,一起说明白反而省些力气。
不过说实话,这些东西确实是有点麻烦。我尽自己最大的努力去把这些东西说明白。但是鉴于个人能力实在有限,如果有哪里说得不对或者不清楚,还请见谅。
如上图,现在有三个多边形分别是红色盒子绿色盒子和蓝色盒子,在镜头里红色的盒子在最前面(距离摄像机最近),所以盖住了其他两个颜色的盒子。
按照我们的生活常识,显示最前面的红盒子这样的结果是再正常不过了。可是计算机并不存在所谓的“人类的常识”,它只依靠数学的方法去处理问题。而如何判断谁在前谁在后,这个问题却并非那么简单,并且很容易让人陷入混乱。因为这牵扯到Ztest(深度测试)ZWrite(深度写入或者叫深度缓存)和RenderQueue(渲染序列)。
如果是2D的话,只需要一个Zindex就可以确定Sprite之间的前后(覆盖)关系。RenderQueue(渲染序列)和这个Zindex的概念很像,都是直截了当指定了一个渲染的顺序。 关于RenderQueue可用的标签,有:
Background:1000
Geometry:2000
AlphaTest:2450
Transparent:3000
Overlay:4000
(写起来的样子是这样的:”Queue” = “TransParent”)
数字越大的物体,其渲染顺序就越靠后,就会遮住数字小的物体。从名字里也能看得出来,BackGround自然是那种最先渲染然后被所有东西覆盖掉的东西(比如天空盒)。而像Overlay这样的东西在绝大部分物体之后渲染,适合用来制作UI。
值得注意的是半透明物体(Transparent Objects)的渲染顺序十分靠后。一般情况下是在所有非半透明物体渲染之后,再渲染半透明物体。至于其原因等稍后再说明。
除了使用默认的标签之外,还可以更详细指定渲染序列,写起来大概是这样的: “Queue” = “Geometry+1” 。这样这个物体会在所有Geometry渲染之后再渲染,顺序增加了一个“身位”。如果是”Queue” = “Geometry+5000” ,那可就是比Overlay还靠后,绝对是最最后渲染的东西,理论上覆盖在一切东西之上。
听起来似乎很简单,好像我们已经拿到了一把万能钥匙,可以随意控制那个小小3D世界里的所有一切。然而进度条告诉你事情并没这么简单(雾)。
因为显卡既不允许你用这么简单粗暴的方式控制渲染结果,实际上你也没法用简单的Queue值来确定物体渲染的前后关系。
试想一个大场景里动辄成千上万的物体,你如何去一个一个指定他们的RenderQueue?即便你真的这么做了,一旦镜头转个180°是不是就全错了?更不要提每一帧都在变换位置的角色。就是神仙也不可能预知他们所处的位置到底应该是渲染序列的哪一个位置。
这一点和2D游戏有着本质上的区别。在2D游戏里指定ZIndex的做法在3D游戏里肯定是走不通的。
所以在大多数情况下(除了制作UI和天空盒之外),这个RenderQueue并没有什么卵用。我用几张图来具体说明。
如上图,正常情况下这三个盒子都是”Queue” = “Geometry”。因为是“正常情况”,所以显示的效果肯定是正确的(红色的盒子挡住其他两个,同时绿色盒子挡住蓝色盒子)。但是打开FrameDebugger你会发现,渲染的顺序是很混乱的。也许是因为做测试的时候改动过RenderQueue。现在莫名其妙的是先中间后两边。
关于非透明物体渲染的排序问题,我在这里多说两句。3D实时渲染性能消耗的两个重要部分是CPU和GPU。如果想节省GPU的时间,就要在渲染之前计算一次渲染顺序,这样在Ztest之后就,被遮挡的部分就不会进入fragment shader;反之想要解放CPU的负担,就不要对渲染物体进行排序(排序这个东西大家都懂的)。当然这样会多次渲染被遮挡的像素。
在Unity3d文档里,我找到了关于控制非透明物体渲染顺序的API,其描述如下:
在我的印象当中以前是没有Camera.opaqueSortMode这个东西的,估计是新版本后加入的(我的5.4.0版本已经比较老了)。大家可以根据自己游戏性能的考虑去做优化。
如上图,当我们强行让绿色盒子的RenderQueue发生改变(“Queue”=”Geometry+1”),这样绿色盒子的渲染序列变为最后渲染,然而实际的效果依然没有改变,红色盒子一如既往地盖住了绿色盒子(哪怕红色盒子是在绿色盒子之前就渲染出来的)。
RenderQueue之所以只决定了物体的渲染顺序,却没能决定物体的渲染结果,是因为显卡在渲染的时候,更多的是依靠深度测试(Ztest)来进行判断。
Ztest的工作原理是这样的(假设这3个盒子是屏幕上的3个像素点):
Step1:显卡按照渲染顺序先画出了蓝色盒子的像素(渲染的每一个步骤都可以在FrameDebugger里看到,真是方便)
在画蓝色盒子的像素的时候,除了RGB三个颜色的值以外,显卡还会把这个像素与当前镜头的距离记录下来(这里记录为z1)。与背景相比,蓝盒子显然距离镜头更近,即z1<∞。按照“默认”的做法(注意在这个例子里我一直强调是在“默认”的情况,或者“默认”的做法),画出蓝色的盒子,并且将摄像机在这个像素上的深度值替换为z1。
Step2:接下来按照渲染顺序,开始渲染红色的盒子。
当然红色盒子也有一个深度值(记录为z2)。这个时候显卡会用z2和摄像机在当前像素的深度值z1进行比较,发现z2<z1(因为红色盒子距离镜头比较近)。于是按照“默认”的做法画出红色的盒子,并且将摄像机当前像素值更新为z2。
Step3:接下来按照渲染顺序,开始渲染绿色的盒子。
虽然这张图和上一张很像,但是注意这个时候渲染的是被“神隐”的绿色盒子
当渲染绿色盒子的时候,情况就发生了变化。我们知道绿色盒子之所以最后渲染,是因为我们强行改变了绿色盒子的渲染顺序(“Queue” = “Geometry+1”)。但是绿色盒子距离摄像机的距离是大于红色盒子的。
所以当渲染绿色盒子的时候,其深度值(记录为z3)必然会比当前像素的深度值z2大(z3>z2,和上一步完全相反的情况)。于是显卡按照“默认”的做法,扔掉了绿色盒子的像素,并且保持当前像素值为z2。其结果就是看起来绿色盒子完全被红色盒子遮挡住了(哪怕它是最后渲染出来的物体)。
这一套流程走下来我们不难看出,所谓“默认”的工作原理(注意我再次强调是“默认”),就是当一个物体像素的z值小于当前镜头在该位置像素的深度值时,画出该物体的这个像素,并且将这个较小的z值更新为当前镜头在这个像素上的深度值。
反之,当一个物体的像素的z值大于当前镜头在该位置像素的深度值时,不画出该物体的这个像素,并且保留摄像机在这个像素上的深度值。
说起来实在是拗口,也不知道各位是否能看明白。反正我是尽力了。如果非要打个比方来说,我想和当初学C语言的时候进行数字排序的做法差不多。不知道各位同学是不是看起来很怀念呢?
1 |
|
而这个工作流程,就是所谓的Ztest+Zwrite。
比较新旧z值的大小,就是Ztest;之后更新摄像机每一个像素的z值,就是Zwrite。Ztest影响的是当前物体的显示;Zwrite影响的是之后渲染物体的显示。
可以看出来如果不进行Zwrite更新镜头的z值,那么Ztest的时候就会出现不正常的结果(完全不知道前面渲染出来的物体的深度,只能完全依赖RenderQueue);而Zwrite是否更新摄像机在当前像素上的z值,根据两个条件:
一是要看是否允许进行Zwrite(默认是Zwrite On。当然很多时候我们会手动关掉Zwrite,即Zwrite Off);二是要看Ztest是否通过,只有通过了ZWrite才会更新新的z值。
请务必注意这里:z值是否更新并不在于物体在该像素上的z值比摄像机在该像素上的z值小。而在于是否通过Ztest。只不过在默认的情况下,通过Ztest的条件是小于等于。如果Ztest的条件改变,那么Zwrite写入的新值就未必比原来的值小(关于Ztest的条件马上就会提到)。
Zwrite的概念相对简单,无非就是根据条件,对一个变量进行反复地赋值。比较有意思的Ztest。在三个盒子的例子里,我一直都在强调“默认”两个字。那么默认是什么呢,就是Zwrite On + Ztest On。Zwrite就两种情况(On或者Off)。而对于Ztest来说,条件就要丰富得多得多。Ztest的条件总共有如下几种:
Less (当物体的这个像素的Z值小于当前摄像机在这个像素上的Z值,则通过Ztest)
LEqual(条件变为小于等于)
Greater(条件变为大于)
GEqual(条件变为大于等于)
Equal(条件变为相等)
NotEqual(条件变为不相等)
Always(Ztest永远通过)
Never(Ztest永远不通过)
Off(等同于 ZTest Always)
On(等同于ZTest LEqual)
ZTest LEqual也就是上面一直提到的“Ztest默认工作的原理”。当不写明Ztest的处理方式的时候,ZTest的通过条件LEqual。因此我们就总能看到距离摄像机近的物体(Z值小)盖住了距离摄像机远(Z值大)的物体,这样“理所当然”的效果。
有意思的是当我们相要搞些事情的时候,就可以利用ZTest那些非默认的选项。当物体被遮挡住的时候(即Ztest Greater),原本是看不见的。但是Xray的效果不就正是要看见原本看不见的东西么?
所以Xray效果的第一个pass。我使用以下的“黑科技”:
Blend SrcAlpha OneMinusSrcAlpha说明我们要用alpha blend的方式进行渲染(关于Alpha Blend后面会提到)。Ztest Greater意味着我就是要处理z值大于摄像机z值的情况(只有在别的物体后面z值才会比较大,也就是说只有实际上被别的物体挡住的时候,才会用这种方式渲染)。同时关掉Zwrite。
关闭Zwrite是比较重要的一步,开着Zwrite会把错误的z值(比较大的z值)更新上去。正如前面特别强调的,Zwrite的条件之一是通过Ztest。这一次Ztest的条件是Greater,所以通过Ztest以后z值是比原来大的,更新上去以后会对其他物体的深度判断造成影响,关于这一点我们马上举例说明。
第一个pass效果如下:
我们看到,Z值比较小的像素(即未被遮挡住的像素),反而因为没有处理Ztest Lequal的Pass而无法显示出来。
接下来就是第二个Pass。我们使用新的Ztest条件:
其实这就是刚才我们一直所说的“默认情况”。换句话是其实Zwrite On 和 ZTest LEqual完全可以不用写。效果如下:
那么问题来了,如果我们在第一个pass中打开Zwrite会出现什么结果呢?
第一个Pass打开Zwrite的效果如下:
无论是否被遮挡,人物都会显示成Pass2的效果(而且还有明显得错误)。
我们利用刚才获知的原理来分析一下。在Pass1通过Ztest之后,因为打开了Zwrite,所以将角色在Pass1阶段渲染出来的像素的深度值写入到屏幕当前的深度值。注意这个深度值是大于墙的像素的深度值的,但是依然被写进镜头的深度当中。
当来到Pass2时,Ztest的条件是LEqual(小于等于)。因为当前摄像机中该像素的深度值就是角色身上像素的深度(因为上一步通过Zwrite已经写入)。所以完全符Equal(相等)的条件。于是Pass2的像素成功通过ZTest并被画出来,Pass1画出的像素自然就被Pass2覆盖掉了。
有兴趣的朋友也可以在Pass2中试一试,当Ztest的条件是Less的时候会出现什么效果。这里就不一一举例了。
以上是关于ZTest、Zwrite和RenderQueue三个容易产生混乱的概念。下面又是一个类似的概念:Stencil(模板)。
Stencil和深度一样,是写进buffer里的一个数值(Z buffer和Stencil Buffer这两个词你应该听过很多次了)。
Stencil的工作原理和Ztest+Zwrite很相似,但是灵活性更高一些。关于Stencil的一些具体例子和讲解,网上有很多。我这里的重点就不放在实际例子上,而是关于模板和深度这两个东西在用法和原理上的异同。
关于Ztest+Zwrite我已经提到过很多次了,最简单的理解就是“比较”+“写入”。如果你真的对其原理理解得非常好,那么搞定Stencil就没有任何问题。
在Unity3D里面并不存在“Stencil Test”和“Stencil Write”这两个字眼儿。Stencil就是一个过程,同时包含了“比较”和“写入”两个步骤。
Stencil的完整语法:
stencil{
Ref referenceValue
ReadMask readMask
WriteMask writeMask
Comp comparisonFunction
Pass stencilOperation
Fail stencilOperation
ZFail stencilOperation
}
具体详尽的用法写起来太麻烦(我实在是怕麻烦怕得要死),我就稍微总结一下:总的来说你只要关注Ref\Comp\Pass三个关键词。再稍微复杂一点儿的情况,你可能需要用到Fail\Zfail。最后在需要更复杂的判断的时候,你也许会需要用到那两个Mask。
我们再回顾一下Ztest+Zwrite的原理。获取Z值->测试(比较)Z值->写入新的Z值(如果通过测试)。
我们假定Stencil也有一个值叫Ref值。那么Stencil的用法也实在是看着眼熟:获取Ref值->测试(比较)Ref值->写入新的Ref值(如果通过测试)。
说到底这俩玩意儿的区别,就是在第一步,获取当前物体在这个像素上的这个变量。
Z值是根据像素到摄像机的距离算出来的,不会因为你的个人意愿而改变;S值是你可以随便填的(是的随便填,想写几就写几,范围0-255)。
这样一来Stencil可以帮助你突破Ztest所带来的限制,用更灵(jian)活(dan)便(cu)捷(bao)的方式来控制渲染效果。
Ref就是写入这个像素的Ref值,正如我之前提到的想写几就写几完全看心情(所以我一直都认为叫Stencil Buffer模板缓冲实在是有点唬人的感觉。改成“看哪个数字顺眼就用哪个数字比大小”更贴切一些)。
Com是进行Test的条件,当你看到一大堆Less\LEqual\Greater\GEqual\Equal\NotEqual\Always\Never这样的字眼儿,是不是感到非常的眼熟?这一步比较的过程和Ztest完全一样。
Pass和Zwrite简直就是一个妈生出的俩个孩儿。区别就是这个小哥比他兄弟花样儿多点。Zwrite无非就是写入或者不写入(On or Off)。Pass甚至还可以控制如何写入(虽然大多数情况下可能用不到)。
Pass支持的条件一览,其中Keep类似于Zwrite里的Off;Replace类似于ZWrite里的On(此图来自互联网)。
举个栗子,如下图所示,现在有一面墙和一个茶壶,按照与镜头的位置关系,墙体遮挡住茶壶的下半部分。
如果我们想要做一个如Flash里的Mask Layer效果。就可以使用Stencil来做。
1 | Stencil |
注意墙的深度缓冲要关掉,否则茶壶在做Ztest的时候会因为遮挡关系而被弃掉像素。
接着是茶壶的shader:
1 | Stencil |
茶壶被透明墙遮挡住的部分,因为其Stencil值通过测试,所以被显示了出来。
当然这里存在一个潜在的问题。试想如果这两个非透明物体在渲染的时候,顺序并不是先画墙再画茶壶。其结果就会因为墙的ref值没有提前更新好,而造成了茶壶在比较的ref值的时候出现我们不期望的结果。所以说,虽然我们并没有太多注意过非透明物体的渲染顺序。但是这东西确实会在各种意想不到的地方,造成莫名其妙的显示错误。
最后就是Alpah Test 和 Alpha Blend。看到XXXTest是不是第一反应又是Test + Write这种东西。然后又是一堆Lequal、Gequal这些乱七八糟的条件。
好消息是这个世界上并不存在“Alpha Write”这种东西,并且Alpha Test也远没有之前那两个Test那么复杂;坏消息是你需要多了解一个新的概念——Alpha Blend,一个既麻烦又特别容易出问题的玩意儿。
首先一句话解决Alpha Test。与其他的Test概念相通的是:Alpha Test的运作原理也是当条件成立时,画出该像素,否则抛弃该像素。但是它的特点是无需(也无法)同镜头中同一个位置的其他像素值进行比较(自然更加无法进行写入)。
相对而言,其他的Test还需要跟别的东西比较一下,Alpha Test并不存在这个过程,它只和自己本身存在的变量进行比较,是一个非常自闭的过程。
因为AlpahTest有以上的特性,所以在Unity的shader里并没有Alpha Test On\Off这样的关键字。Alpha Test可用的函数只有两个,一个是clip一个是discard。clip(x)函数的变量x必须小于0才会通过测试。比如说简单粗暴的clip(-1)就把所有像素都干掉了;而用if(){discard;}可以使用任意条件触发。相对而言discard比较灵活,但是要用到if让我很不爽。这两个函数的具体用法大家可自行百度(好吧是我懒得贴)。
一般做渐变消失的时候,会用到clip\discard。比如下图
把不断变化的时间值传入shader,来不断减小clip()函数的变量,就能做出如上的效果。当然这个效果还可以进一步改进,因为和本文无关所以就不展开了。需要注意的是在移动平台上,Alpha Test的消耗较大,属于能不用就不用的东西(就像if、for这些东西能不用尽量别用)。
如果你非要搞明白为什么简单粗暴的alpha test反而消耗大,就自己去查关于PowerVR GPUs、Deferred Tile-Based-Rendering、Early-Z等等这些知识点,对于我一个懒人来说搬运这些东西简直跟要了我的命没什么区别。
Alpha Test是一个非黑即白的过程。通过或者不通过,画出或者抛弃,简单粗暴一目了然。当然我们大多数时候并不喜欢如此粗暴的处理,毕竟人不是机器,凡事还需要温柔一点。所以我们更多的时候用的是Alpha Blend而非Alpha Test。
Alpha Blend即透明混合。我们之前提到的所有Test方式,不是你盖住我就是我盖住你,总之没有任何“和谐共处”的可能性。而Alpha Blend提供了这种可能性。根据Blend的方式不同,该物体在这个像素的rgb值会和其他物体在这个像素上的rgb进行混合。
Alpha Blend的效果,在一般意义上这就是我们理解的“半透明”。
我们之前曾经提到过,半透明的物体(也就是需要用Alpha Blend方式渲染的物体)一般来说渲染序列比较靠后(通常我们用”Queue” = “Transparent”)。道理很简单,你要和别的像素混合,那么必须要有其他像素已经画出来才行。如果透明物体被提前渲染出来,而当时还不存在后面要跟它混合的像素,自然就会出现错误。
所以难怪只有Overlay这种做UI的物体,渲染顺序会排在Transparent之后——毕竟UI是不需要和场景中的半透物体进行混合。
如图所示,当半透明物体(红色方形粒子)没有被指定渲染顺序为Transparent的时候,在混合天空盒的时候发生了明显错误。红圈是渲染粒子的部分,黑圈是渲染天空盒的部分。很明显在渲染粒子的时候,并没有渲染出来天空盒,所以也就没有混合(Blend)操作时可以用来混合的颜色。
当半透明的渲染顺序被正确指定为Transparent的时候,渲染天空盒发生在渲染粒子之前,也就是在画粒子的时候天空盒的像素就已经存在了。这样粒子就有了可以进行混合操作的颜色,因此半透明粒子与天空盒的混合效果正确。(说实话我很奇怪为什么Unity默认的天空盒渲染顺序居然不是BackGround,也许他们有他们自己的考虑吧。)
注意在谈关于Alpha Blend的时候,几乎每一个细节都和RenderQueue息息相关。这和之前的Ztest完全不同。其区别在于Ztest只关心谁盖住了谁,一旦被盖住就不再在意被盖住的像素到底是个什么样;然而Alpha Blend却需要关注任何一个画在当前位置的像素颜色,只有获得这些颜色的全部信息,才可能进行正确的混合。这也是为什么Alpha Blend的消耗很大(因为所有在该像素上的物体都要进入fragment shader进行绘制),而且常常会引发各种非常棘手的问题。
在写Unity Shader的时候,Alpha Blend有两个非常重要的语句:Zwrite Off和Blend的方式。
一般情况下我们渲染半透明物体的时候,都是Zwrite Off。
为什么一定是Zwrite Off?我们最开始说,只有打开Zwrite,才有可能进行“正确有效”的Ztest,否则所有关闭Zwrite的物体,其渲染将完全依赖于RenderQueue。
但是对于透明物体之间来说(注意是透明物体之间,而不是透明和非透明物体之间),我们需要的恰恰是不要进行有效的Ztest——因为我们的初衷就是不能让“正确”的遮挡关系产生作用。试想如果透明物体之间因为Ztest判定了“正确”的遮挡关系,而造成部分像素被显卡丢弃不画,又怎么可能产生之后混合的过程呢?
而一旦放弃Zwrite。透明物体之间的Ztest其实都是统统通过的,换言之任何一个半透明物体的像素在与其他半透明物体的像素进行Ztest的时候,将不会被认为是需要弃掉不画的像素(我再次强调,因为RenderQueue的关系,所有谈到的东西都仅限于半透明物体之间)。
来看这张图,注意粒子后面的角色和墙不一样,这个角色与粒子相同也是个半透明的物体。当Zwrite On的时候,整个渲染过程是先画了方块形的粒子(Draw Dynamic),再画的绿色的角色(那三个Draw Mesh)。当开始绘制角色的时候显卡做了Ztest,其判定这个角色被粒子遮挡住,所以像素并没有画出来。
当Zwrite Off以后,注意这个时候依然是先画出粒子再画出角色,在角色做Ztest的时候,被判定并没有被粒子遮挡(因为粒子的深度信息并没有写入,角色像素的Z值小于等于当前摄像机在当前像素上的Z值),所以角色的像素被绘制出来,并且与粒子的颜色进行了正常的混合。
你可能会问为什么墙不会被挡住,因为墙是”Queue” = “Geometry”,作为一个渲染序列靠前的物体,在画粒子的时候其像素就已经存在了。
根据上面的实例,我总结一下关于显卡的工作机制。显卡只能确定当下的像素是否可以绘制以及如何绘制。其结果可能是1、弃掉这个像素不画。2、这个像素会覆盖掉之前的像素。3、如果是Alpha Blend就和之前的像素进行混合。但是注意无论如何渲染的过程都不可能影响之前的已经被画出来的像素——显卡也许会抛弃当前的像素不画,但是绝不可能让之前画出来的像素消失掉。这个规则非常重要,请务必理解。
所以说对于Alpha Blend来说,RenderQueue非常的重要。已经画出来的像素只能被混合却不能被消除。所以基本上出问题的一定是透明物体和透明物体之间,因为他们的RenderQueue是相同的。先渲染的永远存在,而后渲染的却有可能被抛弃。
当然ZTest Off也许会解决这种因为遮挡而造成的不画像素的问题,但是相信我你绝对不会这么去做,因为会引发更多的麻烦(因为没了Ztest,就是非透明物体也不能正确覆盖住透明物体了)。
因为存在着如此“危险”的规则(之后的渲染不能改变之前的渲染),渲染的先后顺序就绝对不可能是完全随机的。和非透明物体的渲染顺序控制类似,Unity也提供了控制透明物体排序的机制。
因为透明物体之间的排序比较重要,所以我稍微多说两句。按照Unity3D的默认做法,在对透明物体在渲染之前的排序,是根据多边形中心点与摄像机的远近来比较的。比较之后显卡会从后向前对透明物体依次进行渲染。所以绝大多数情况下你看到的粒子特效,其前后遮挡关系还是没什么大问题的。
但是这么做又会引出一个新的问题——当半透明物体交叉在一起的时候,这种判断方式几乎没有任何帮助。所以当一个复杂的多边形(例如有很多部件的角色)在使用Alpha Blend的时候,经常会出现显示效果错误,也是因为这种原因。
所以从优化的角度来讲,我们一直希望尽量少用或者不用Alpha Blend,但是现在的游戏几乎到处都充斥着Alpha Blend的物体。好在现在的处理器性能比之过去实在是强了太多,这些问题似乎也渐渐地不再成为游戏开发的限制。
那么之所以我还要特意写出来,是希望大家能知道关于Alpha Blend消耗的来龙去脉。毕竟无论处理器的性能如何发展,我们做游戏还是要以能省一点儿是一点儿的态度去抠这些细节。
单以上面的例子而言,如果你对之前的讲解理解深入的话,应该知道除了关闭Zwrite这一个办法之外,也可以用指定RenderQueue的方式强行让角色先绘制出来(或者让粒子后绘制)。这种强行改变(指定)RenderQueue也能解决两个半透明物体遮挡的问题。但是正如我之前所说的,强行指定RenderQueue是一种极其不被推荐的做法。还是那句话,如果这个时候镜头转动了180°(即物体和物体之间的前后关系完全反转),强行指定RenderQueue就会造成更严重的渲染错误。
如上图,在没有关闭Zwrite的前提下, 改变粒子的渲染顺序(“Queue” = “Transparent+1” )。这样绿色的半透明角色就在粒子之前被渲染出来,红色的粒子也就有了可以进行混合的对象。
Zwrite Off虽然已经成为Alpha Blend的“标配”,但是不能进行Zwrite其实是很麻烦的。如果你认为上一个效果没毛病就万事大吉,那可就大错特错了。来看下图:
大多数时候我们当然希望第一张图的效果(打开Zwrite,遮挡住原本应该被遮挡的壶把)而非第二张图的效果(关闭Zwirte,这样该物体的任何一个像素都不会改变摄像机在该像素上的深度,就会出现无法遮挡住问题)。
很显然,在不打开Zwrite的前提下,是不可能做出第一张图的效果的。但是正如我们之前所提到的,透明物体如果不是Zwrite Off,又会引发半透明物体之间因为遮挡而无法混合的问题。这真是一个让人头疼的麻烦。
以下是官方一个例子的原理(实在搜不到了只好自己动手,惨),是目前解决半透明问题比较常规的做法。首先做一个pass进行Zwrite,然后在第二个pass里关闭Zwrite,其他不变。可以做出一个完全是剪影的半透明效果。如下图右面的茶壶。
ColorMask是指定输出通道,这里让第一个pass完全不输出任何东西,仅仅只是写入深度。这样一来茶壶就像是个非透明物体一样在屏幕上改变了当前像素的深度值。第二个pass正常绘制,在其Ztest的时候比较的就是刚刚自己留在屏幕上的Z值。这样一个完美的剪影就做出来了。
这里说点题外话。一直以来我都以为把Tag放到Pass里是可行的,直到写本文的时候我才发现只有将Tag放在Pass外面才会真正起作用。那就意味着多pass之间来回切换Tag是不可能的(或许是我理解上有问题,毕竟我刚刚才发现)。
最后要说的是混合方式。如果你用PhotoShop的话,应该对图层混合的模式并不陌生,而Blend方式其实也是一样的概念。所以关于Blend的方式,我就不过多展开了,相关资料网上很多有兴趣可以自行百度。
一般来说正常的Blend方式是:
Blend SrcAlpha OneMinusSrcAlpha
这个语法翻译成中文,大意是这个像素的颜色乘以这个像素的alpha值(SrcAlpha) + 这个像素背后的颜色 * (1 - 这个像素的alpha值)(OneMinusSrcAlpha)。
比如一个红色的像素(1,0,0,0.7),期身后的颜色是蓝色(0,0,1,1)。那么在摄像机里,这个像素最终的颜色就应该是(0.7,0,0.3,0.7)((1,0,0) * 0.7 + (0,0,1)* (1 - 0.7))。如果再出现一个半透明的物体,那就继续用这个步骤计算。
这是“正常”的方式,得到的效果是我们习惯的“默认”的效果。那么非“正常”的效果呢?半透的混合方式还有如下几种。作为比较特殊的混合方式,所有这些方式你都可以在PS的图层混合里找到相同的效果。
]]>to be continued…
发现Application.temporaryCachePath和Application.persistentDataPath返回空字符串。便花时间认真研究了一下Unity3D的路径问题。
我们常用的是以下四个路径:
Application.dataPath
Application.streamingAssetsPath
Application.persistentDataPath
Application.temporaryCachePath
根据测试,详细情况如下:
Application.dataPath /var/containers/Bundle/Application/app sandbox/xxx.app/Data
Application.streamingAssetsPath /var/containers/Bundle/Application/app sandbox/test.app/Data/Raw
Application.temporaryCachePath /var/mobile/Containers/Data/Application/app sandbox/Library/Caches
Application.persistentDataPath /var/mobile/Containers/Data/Application/app sandbox/Documents
iOS和Mac OS X不同于Windows,app都是在一个沙盒空间中运行,每个app也有一个独立的数据存储空间,各app彼此不能互相访问、打扰。
dataPath是app程序包安装路径,app本身就在这里,此目录是只读的。streamingAssetsPath是dataPath下的Raw目录。
app的独立数据存储目录下有三个文件夹:Documents,Library和tmp。
Documents目录,这个目录用于存储需要长期保存的数据,比如我们的热更新内容就写在这里。需要注意的是,iCloud会自动备份此目录,如果此目录下写入的内容较多,审核的可能会被苹果拒掉。
Library目录,这个目录下有两个子目录,Caches和Preferences。
Caches是一个相对临时的目录,适合存放下载缓存的临时文件,空间不足时可能会被系统清除,Application.temporaryCachePath返回的就是此路径。我把热更新的临时文件写在这里,等一个版本的所有内容更新完全后,再把内容转移到Documents目录。
Preferences用于应用存储偏好设置,用NSUserDefaults读取或设置。
tmp目录,临时目录,存放应用运行时临时使用的数据。
需要注意的是,以上无论临时、缓存或者普通目录,如果不需要的数据,都请删除。不要占用用户的存储空间,像微信就是坏榜样。
下面是各路径对应的OC访问方法
app安装路径: [[NSBundle mainBundle] resourcePath]
app数据沙盒存储根目录: NSHomeDirectory()
Documents: NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES)
Library: NSSearchPathForDirectoriesInDomains(NSLibraryDirectory, NSUserDomainMask, YES)
Caches: NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES)
tmp: NSTemporaryDirectory()
Application.dataPath /data/app/package name-1/base.apk
Application.streamingAssetsPath jar:file:///data/app/package name-1/base.apk!/assets
Application.temporaryCachePath /storage/emulated/0/Android/data/package name/cache
Application.persistentDataPath /storage/emulated/0/Android/data/package name/files
看Android上的路径,跟iOS有点类似,简单说一下。Android的几个目录是apk程序包、内存存储(InternalStorage)和外部存储(ExternalStorage)目录。
apk程序包目录: apk的安装路径,/data/app/package name-n/base.apk,dataPath就是返回此目录。
内部存储目录: /data/data/package name-n/,用户自己或其它app都不能访问该目录。打开会发现里面有4个目录(需要root)
cache 缓存目录,类似于iOS的Cache目录
databases 数据库文件目录
files 类似于iOS的Documents目录
shared_prefs 类似于iOS的Preferences目录,用于存放常用设置,比如Unity3D的PlayerPrefs就存放于此
外部存储目录: 在内置或外插的sd上,用户或其它app都可以访问,外部存储目录又分私有和公有目录。
公有目录是像DCIM、Music、Movies、Download这样系统创建的公共目录,当然你也可以像微信那样直接在sd卡根目录创建一个文件夹。好处嘛,就是卸载app数据依旧存在。
私有目录在/storage/emulated/n/Android/data/package name/,打开可以看到里面有两个文件夹cache和files。为什么跟内部存储目录重复了?这是为了更大的存储空间,以防内存存储空间较小。推荐把不需要隐私的、较大的数据存在这里,而需要隐私的或较小的数据存在内部存储空间。
下面是各路径对应的Java访问方法:
apk包内: AssetManager.open(String filename)
内部存储: context.getFilesDir().getPath() or context.getCacheDir().getPath()
外部存储: context.getExternalFilesDir(null).getPath() or context.getExternalCacheDir().getPath()
理解了Android存储的原理,最后来说说开头提到的bug,Application.temporaryCachePath/persistentDataPath返回空字符串。这其实因为权限的原因,app没有声明访问外部存储空间的权限,但是Application.temporaryCachePath/ ApplicationpersistentDataPath却想返回外部存储的路径。这是Unity3D的bug,没有权限本应该抛出一个异常或者错误,让开发者知道原因。
经反复测试发现,有【外置SD卡】的设备上,如果声明读/写外部存储设备的权限,会返回外部存储路径,不声明则会返回内部存储路径,这样不会有问题。而在【无外置SD卡】的设备上,不管是否声明读/写外部存储设备的权限,Application.temporaryCachePath/persistentDataPath都返回外部存储路径,但是又没有权限,就可能会导致返回null了,之所以说可能是因为这个bug不是必现,如果出现了设备重启之后就好了,怀疑是linux设备mount问题。但是出了问题,我们不能跟用户说你重启一下手机就好了。
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
Application.dataPath: 应用的appname_Data/
Application.streamingAssetsPath: 应用的appname_Data/StreamingAssets
Application.temporaryCachePath: C:\Users\username\AppData\Local\Temp\company name\product name
Application.persistentDataPath: C:\Users\username\AppData\LocalLow\company name\product name
Android: /data/data/pkg-name/shared_prefs/pkg-name.v2.playerprefs.xml
iOS:/Library/Preferences/[bundle identifier].plist
Windows:HKEY_CURRENT_USER/Software/CompanyName/ProductName
Mac:~/Library/Preferences/com.CompanyName.ProductName.plist
]]>to be continued…
单例模式是软件工程学中最富盛名的设计模式之一。从本质上看,单例模式只允许被其自身实例化一次,且向外部提供了一个访问该实例的接口。通常来说,单例对象进行实例化时一般不带参数,因为如果不同的实例化请求传递的参数不同的话会导致问题的产生。(若多个请求都是传递的同样的参数的话,工厂模式更应该被考虑)
C#中实现单例有很多种方法,本文将按顺序介绍非线程安全、完全懒汉式、线程安全和低/高性能集中版本。
在所有的实现版本中,都有以下几个共同点:
需要注意的是,本文中所有的例子中都是用一个 public static Instance的变量来访问单例类实例,要将其转换成公共函数是很容易的,但是这样并不会带来效率和线程安全上的提升。
public sealed class Singleton{ private static Singleton instance = null; private Singleton() { } public static Singleton Instance { get { if (instance == null) { instance = new Singleton(); } return instance; } }}
该版本在多线程下是不安全的,会创建多个实例,请不要在生产环境中使用!
因为如果两个线程同时运行到if(instance==null)判断时,就会创建两个实例,这是违背单例模式的初衷的。实际上在后面那个线程进行判断是已经生成了一个实例,但是对于不同的线程来说除非进行了线程间的通信,否则它是不知道的。
public sealed class Singleton2{ private static Singleton2 instance = null; private static readonly object obj = new object(); private Singleton2() { } public Singleton2 Instance { get { lock (obj) { if (instance == null) { instance = new Singleton2(); } return instance; } } }}
该版本是线程安全的。通过对一个过线程共享的对象进行加锁操作,保证了在同一时刻只有一个线程在执行lock{}里的代码。当第一个线程在进行instance判断或创建时,后续线程必须等待直到前一线程执行完毕,因此保证了只有第一个线程能够创建instance实例。
但不幸的是,因为每次对instance的请求都会进行lock操作,其性能是不佳的。
需要注意的是,这里使用了一个private static object变量进行锁定,这是因为当如果对一个外部类可以访问的对象进行锁定时会导致性能低下甚至死锁。因此通常来说为了保证线程安全,进行加锁的对象应该是private的。
public sealed class Singleton3{ private static Singleton3 instance = null; private static object obj = new object(); private Singleton3() { } public static Singleton3 Instance { get { if (instance == null) { lock (obj) { if (instance == null) { instance = new Singleton3(); } } } return instance; } }}
该版本中试图去避免每次访问都进行加锁操作并实现线程安全。然后,这段代码对Java不起作用,因Java的内存模型不能保证在构造函数一定在其他对象引用instance之前完成。还有重要的一点,它不如后面的实现方式。
public sealed class Singleton4{ private static readonly Singleton4 instance = new Singleton4(); /// <summary> /// 显式的静态构造函数用来告诉C#编译器在其内容实例化之前不要标记其类型 /// </summary> static Singleton4() { } private Singleton4() { } public static Singleton4 Instance { get { return instance; } }}
这个版本是的实现非常的简单,但是却又是线程安全的。C#的静态构造函数只有在当其类的实例被创建或者有静态成员被引用时执行,在整个应用程序域中只会被执行一次。使用当前方式明显比前面版本中进行额外的判断要快。
当然这个版本也存在一些瑕疵:
所有版本中,只有这里将instance设置成了readonly,这不仅保证了代码的高校且显得十分短小。
public sealed class Singleton5{ private Singleton5() { } public static Singleton5 Instance { get { return Nested.instance; } } private class Nested { static Nested() { } internal static readonly Singleton5 instance = new Singleton5(); }}
该版本看起来稍微复杂难懂,其实只是在写法上实现了上一版本的瑕疵,通过内嵌类的方式先实现了只有在真正应用Instance时才进行实例化。其性能表现与上一版本无异。
public sealed class Singleton6{ private static readonly Lazy<Singleton6> lazy = new Lazy<Singleton6>(()=> new Singleton6()); public static Singleton6 Instance { get { return lazy.Value; } } private Singleton6() { }}
如果你使用的是.NET 4或其以上版本,可以使用System.Lazy type来实现完全懒汉式。其代码看起来也很简洁且性能表现也很好。
一般情况下,我们并不需要实现完全懒汉式,除非你的构造初始化执行了某些费时的工作。因此一般的,我们使用显式的静态构造函数就能够适用。
本文翻译自Implementing the Singleton Pattern in C#
有时候在进行构造函数初始化时可能 会抛出异常,但这对整个应用程序来说不应该是致命的,所以可能的情况下,你应该自己处理这种异常情况。
上述提供的几种实现方法中,一般情况下提倡使用Version 4
,除非遇到有时早于单列类实例化时就引用了其他静态成员。这种情况下,Version 2
一旦被考虑,虽然它看起来会因加锁耗时,但是其实运行起来并没有你想的那么慢,关键是你很容易写对它。显然Version 1
你永远都不应该考虑,Version 3
在与Version 5
的对比下也是不在考虑范围之内的。
参考来源:[ C#实现单例模式的6种方法 ]
]]>to be continued…
这里是前言介绍。
参考地址请看图片水印:http://www.cnblogs.com/iamzhanglei/archive/2012/06/07/2539751.html
http://blog.sina.com.cn/s/blog_6a1bf1310101g7zy.html
包围体是一个简单的几何空间,里面包含着复杂形状的物体。为物体添加包围体的目的是快速的进行碰撞检测或者进行精确的碰撞检测之前进行过滤(即当包围体碰撞,才进行精确碰撞检测和处理)。包围体类型包括球体、轴对齐包围盒(AABB)、有向包围盒(OBB)、8-DOP以及凸壳。如图1所示。
图1 依次是球体、AABB、OBB
图2 依次是球体、AABB、OBB
包围球碰撞检测方法是用球体包围整个几何体, 无论是几何体还是相交测试都很简单; 但是它的紧密性太差。因为除了在3 个坐标轴上分布得比较均匀的几何体外, 几乎都会留下较大的空隙, 需要花费大量的预处理时间, 以构造一个好的层次结构逼近对象。当物体变形之后,包围球树需要重新计算。因此,它是使用得比较少的一种包围盒。当对象发生旋转运动时, 包围球不需作任何更新, 这是包围球的较优秀特性; 当几何对象进行频繁的旋转运动时, 采用包围球可能得到较好结果。
AABB盒,一个3D的AABB就是一个简单的六面体,每一边都平行于一个坐标平面,矩形边界框不一定都是立方体,它的长、宽、高可以彼此不同。
AABB的重要性质:
Ymin <= Y <= Ymax
Zmin <= Z <= Zmax
特别重要的两个顶点为:Pmin = [Xmin Ymin Zmin],Pmax = [ Xmax Ymax Zmax].
先介绍AABB的表达方法,AABB内的点满足以下条件:
xmin≤x≤xmax
ymin≤y≤ymax
zmin≤z≤zmax
因此只需要知道两个特别重要的顶点(xmin,ymin,zmin)、(xmax,ymax,zmax),记作:
float[] min = new float []{0.0f,0.0f,0.0f};
float[] max = new float []{0.0f,0.0f,0.0f};
中心点是两个顶点的中点,代表了包装盒的质点。
float[] center = new float []{0.0f,0.0f,0.0f};
中心点的计算方法如下:
float [] center(){
center[0] = (min[0] + max[0])*0.5f;
center[1] = (min[1] + max[1])*0.5f;
center[2] = (min[2] + max[2])*0.5f;
return center;
}
通过这两个顶点可以知道以下属性。
float xSize() { return (max[0]-min[0]); }
float ySize() { return (max[1]-min[1]); }
float zSize() { return (max[2]-min[2]); }
float size(){ return (max[0]-min[0])(max[1]-min[1])(max[2]-min[2]);}
当添加一个顶点到包装盒时,需要先与这两个顶点进行比较。
void add(float []p) {
if (p[0] < min[0]) min[0] = p[0];
if (p[0] > max[0]) max[0] = p[0];
if (p[1] < min[1]) min[1] = p[1];
if (p[1] > max[1]) max[1] = p[1];
if (p[2] < min[2]) min[2] = p[2];
if (p[2] > max[2]) max[2] = p[2];
}
检测包装盒是否为空,可以将这两个顶点进行比较。
boolean isEmpty() {
return (min[0] > max[0]) || (min[1] > max[1]) || (min[2] > max[2]);
}
检测某个点是否属于AABB范围之内的代码如下:
boolean contains(float []p){
return
(p[0] >= min[0]) && (p[0] <= max[0]) &&(p[1] >= min[1]) && (p[1] <= max[1]) &&(p[2] >= min[2]) && (p[2] <= max[2]);
}
AABB的静态检测比较简单,检测两个静止包装盒是否相交,它是一种布尔测试,测试结果只有相交或者不相交。这里我们还提供了获取相交范围信息的方法,一般来说,这种测试的目的是为了返回一个布尔值。碰撞的示意如图10-34所示。
图10-34 包装盒的碰撞
检测静态AABB碰撞的方法如下:
boolean intersectAABBs(AABB box2,AABB boxIntersect)
{
float []box2_min = box2.getMin();
float []box2_max = box2.getMax();
if (min[0] > box2_max[0]) return false;
if (max[0] < box2_min[0]) return false;
if (min[1] > box2_max[1]) return false;
if (max[1] < box2_min[1]) return false;
if (min[2] > box2_max[2]) return false;
if (max[2] < box2_min[2]) return false;
if (boxIntersect != null) {
float []box_intersect_min = new float[3]; float []box_intersect_max = new float[3]; box_intersect_min[0] = Math.max(min[0], box2_min[0]); box_intersect_max[0] = Math.min(max[0], box2_max[0]); box_intersect_min[1] = Math.max(min[1], box2_min[1]); box_intersect_max[1] = Math.min(max[1], box2_max[1]); box_intersect_min[2] = Math.max(min[2], box2_min[2]); box_intersect_max[2] = Math.min(max[2], box2_max[2]);
}
return true;
}
可以利用AABB的结构来加快新的AABB的计算速度,而不用变换8个顶点,再从这8个顶点中计算新AABB。下面简单地回顾4×4矩阵变换一个3D点的过程。
通过原边界框(xmin,ymin,zmin,xmax,ymax,zmax)计算新边界框(,,,,,),现在的任务是计算的速度。换句话说,希望找到m11x+m12y+m13z+m14的最小值。其中[x,y,z]是原8个顶点中的任意一个。
变换的目的是找出这些点经过变换后哪一个的x坐标最小。看第一个乘积m11x,为了最小化乘积,必须决定是用xmin还是xmax来替换其中的x。显然,如果m11>0,用xmin能得到最小化的乘积;如果m11<0,则用xmax能得到最小化乘积。
比较方便的是,不管xmin还是xmax中哪一个被用来计算,都可以用另外一个来计算。可以对矩阵中的9个元素中的每一个都应用这个计算过程(其他元素不影响大小)。
根据变换矩阵和原有的AABB包装盒计算新的AABB包装盒的代码如下:
void setToTransformedBox(Transform t)
{
if (isEmpty()) { //判断包装盒是否为空
return;
}
float[] m = new float [16];
t.get(m); //将变换矩阵存入数组
float minx=0,miny=0,minz=0;
float maxx=0,maxy=0,maxz=0;
minx += m[3]; //x方向上平移
maxx += m[3]; //x方向上平移
miny += m[7]; //y方向上平移
maxy += m[7]; //y方向上平移
minz += m[11]; //z方向上平移
maxz += m[11]; //z方向上平移
if (m[0] > 0.0f) {
minx += m[0] * min[0]; maxx += m[0] * max[0];
} else {
minx += m[0] * max[0]; maxx += m[0] * min[0];
}
if (m[1] > 0.0f) {
minx += m[1] * min[1]; maxx += m[1] * max[1];
} else {
minx += m[1] * max[1]; maxx += m[1] * min[1];
}
if (m[2] > 0.0f) {
minx += m[2] * min[2]; maxx += m[2] * max[2];
} else {
minx += m[2] * max[2]; maxx += m[2] * min[2];
}
if (m[4] > 0.0f) {
miny += m[4] * min[0]; maxy += m[4] * max[0];
} else {
miny += m[4] * max[0]; maxy += m[4] * min[0];
}
if (m[5] > 0.0f) {
miny += m[5] * min[1]; maxy += m[5] * max[1];
} else {
miny += m[5] * max[1]; maxy += m[5] * min[1];
}
if (m[6] > 0.0f) {
miny += m[6] * min[2]; maxy += m[6] * max[2];
} else {
miny += m[6] * max[2]; maxy += m[6] * min[2];
}
if (m[8] > 0.0f) {
minz += m[8] * min[0]; maxz += m[8] * max[0];
} else {
minz += m[8] * max[0]; maxz += m[8] * min[0];
}
if (m[9] > 0.0f) {
minz += m[9] * min[1]; maxz += m[9] * max[1];
} else {
minz += m[9] * max[1]; maxz += m[9] * min[1];
}
if (m[10] > 0.0f) {
minz += m[10] * min[2]; maxz += m[10] * max[2];
} else {
minz += m[10] * max[2]; maxz += m[10] * min[2];
}
min[0] = minx; min[1] = miny; min[2] = minz; //用新的AABB坐标替换原有坐标
max[0] = maxx; max[1] = maxy; max[2] = maxz; //用新的AABB坐标替换原有坐标
}
为了使用AABB包装盒进行碰撞检测,将这些方法和属性封装为AABB类,代码如下:
import java.lang.Math;
import javax.microedition.m3g.Transform;
class AABB{
public AABB(){}
float [] getMin(){return min;}
float [] getMax(){return max;}
void setMin(float x,float y,float z){min[0]=x;min[1]=y;min[2]=z;}
void setMax(float x,float y,float z){max[0]=x;max[1]=y;max[2]=z;}
void reset(){
for(int i =0;i<3;i++) { min[i]=0; max[i]=0; }
}
//其他方法同上
}
为了检验碰撞检测的使用构造了两个立方体,并各自绑定了一个包装盒。
mesh1 = createCube(); //创建立方体1
mesh1.setTranslation(1.0f, 0.0f,0.0f) ; //平移
mesh1.setOrientation(90,0.0f,1.0f,0.0f); //旋转
mesh1.setScale(0.5f,0.5f,0.5f); //缩放
box1 = new AABB(); //包装盒
box1.setMin(-1.0f,-1.0f,-1.0f); //设置包装盒1的最小顶点
box1.setMax(1.0f,1.0f,1.0f); //设置包装盒1的最大顶点
mesh1.getCompositeTransform(cubeTransform); //获取立方体1的混合矩阵
box1.setToTransformedBox(cubeTransform); //将变换矩阵应用到包装盒中
world.addChild(mesh1); //将立方体1添加到场景中
mesh2 = createCube(); //创建立方体2
mesh2.setTranslation(-0.5f, 0.0f,0.0f) ; //平移
mesh2.setScale(0.5f,0.5f,0.5f); //缩放
box2 = new AABB(); //包装盒
box2.setMin(-1.0f,-1.0f,-1.0f); //设置包装盒2的最小顶点
box2.setMax(1.0f,1.0f,1.0f); //设置包装盒2的最大顶点
mesh2.getCompositeTransform(cubeTransform); //获取立方体2的混合矩阵
box2.setToTransformedBox(cubeTransform); //将变换矩阵应用到包装盒2中
world.addChild(mesh2); //将立方体2添加到场景中
检测包装盒1和包装盒2是否碰撞的代码如下:
isCollided = box1.intersectAABBs(box2,null); //检测两个AABB包装盒是否碰撞
编译运行程序,设置两个立方体不同的位置和角度,可以比较精确地检测出它们的碰撞情况,如图10-35所示。
检测两个静止AABB的碰撞情况比较简单,只需要在每一维上单独检查它们的重合程度即可。如果在所有维上都没有重合,那么这两个AABB就不会相交。
AABB间的动态检测稍微复杂一些,考虑一个由顶点smin和smax指定的静态包装盒和一个由顶点mmin和mmax指定的动态包装盒(如果两个都是动态的,可以根据相对运动视作如此)。运动的速度由向量s给出,运动时间t假定为0~1。
图10-35 静态物体碰撞检测示意
移动检测的目标是计算运动AABB碰撞到静态AABB的时刻,因此需要计算出两个AABB在所有维上的第一个点。为了简化起见,可以把上述问题先归结到某一维,然后再将三维结合到一起。假设把问题投影到x轴,如图10-36所示。
图10-36 AABB的动态检测
黑色矩形代表沿坐标轴滑动的AABB,t=0时,运动AABB完全位于静止AABB的左边。当t=1时,运动AABB完全位于静止AABB的右边。当t=tenter时,两个AABB刚刚相交,当t=tleave时,两个AABB脱离碰撞。
对照上图,可以推导出两个AABB接触和离开的时间:
,
AABB的动态检测有3个要点。
n 如果速度为0,两个包装盒要么一直相交,要么一直分离。
n 不管物体从哪个方向运动,碰撞过程中,肯定是先入后出,所以有tenter<tleave。
n 如果tenter和tleave超出运动时间范围,那么在此范围内它们是不相交的。
检测出某一维的碰撞还不够,还需要进行其他两维的检测,然后取结果的交集。如果交集为空,那么两AABB包装盒没有相交,如果区间范围在时间段[0,1]之外,那么在此区间也不相交。对AABB进行动态检测的方法定义如下:
float intersectMovingAABB(AABB stationaryBox,AABB movingBox,float []s)
{
float NoIntersection = 1e30f; //没有碰撞则返回大数float tEnter = 0.0f; //初始化碰撞时间float tLeave = 1.0f; //初始化离开时间float Swap = 0.0f; //交换操作中间变量float [] sBoxmin= stationaryBox.getMin(); //静止包装盒的最小值顶点float [] sBoxmax= stationaryBox.getMax(); //静止包装盒的最大值顶点float [] mBoxmin= movingBox.getMin(); //运动包装盒的最小值顶点float [] mBoxmax= movingBox.getMax(); //运动包装盒的最大值顶点if (s[0] == 0.0f) { //如果*x*方向速度为0 if ((sBoxmin[0] >= mBoxmax[0]) ||(sBoxmax[0] <= mBoxmin[0])) { return NoIntersection; //进行静态检测 }} else { float xEnter = (sBoxmin[0]-mBoxmax[0])/s[0]; //计算碰撞时间 float xLeave = (sBoxmax[0]-mBoxmin[0])/ s[0]; //计算离开时间 if (xEnter > xLeave) { //检查顺序 Swap = xEnter; xEnter = xLeave; xLeave = Swap; } if (xEnter > tEnter) tEnter = xEnter; //更新区间 if (xLeave < tLeave) tLeave = xLeave; if (tEnter > tLeave) { //是否导致空重叠区 return NoIntersection; //没有碰撞 }}if (s[1] == 0.0f) { //*y*轴速度为0 if ( (sBoxmin[1] >= mBoxmax[1]) || (sBoxmax[1] <= mBoxmin[1])) { return NoIntersection; //没有相交 }} else { float yEnter = (sBoxmin[1]-mBoxmax[1]) / s[1]; float yLeave = (sBoxmax[1]-mBoxmin[1]) / s[1]; if (yEnter > yLeave) { Swap = yEnter; yEnter = yLeave; yLeave = Swap; } if (yEnter > tEnter) tEnter = yEnter; //更新区间 if (yLeave < tLeave) tLeave = yLeave; if (tEnter > tLeave) { return NoIntersection; }}if (s[2] == 0.0f) { //*z*方向速度为0 if ((sBoxmin[2] >= mBoxmax[2]) ||(sBoxmax[2] <= mBoxmin[2])) { return NoIntersection; }} else { float oneOverD = 1.0f / s[2]; float zEnter = (sBoxmin[2]-mBoxmax[2]) / s[2]; float zLeave = (sBoxmax[2]- mBoxmin[2]) / s[2]; if (zEnter > zLeave) { Swap = zEnter; zEnter = zLeave; zLeave = Swap; } if (zEnter > tEnter) tEnter = zEnter; //更新区间 if (zLeave < tLeave) tLeave = zLeave; if (tEnter > tLeave) { return NoIntersection; }}return tEnter; //返回碰撞时间
}
为了对移动AABB进行检测,创建两个AABB如图10-37所示。两个包装盒距离0.5,速度为3。
图10-37 移动AABB检测
检测代码如下:
float[] speed = new float []{3.0f,0.0f,0.0f};
float tEnter = intersectMovingAABB(box1,box2,speed);
输出结果为0.16667,完全符合预期的猜测。
前面提到了长条物体在旋转时AABB盒的变化,那么是否有能够在任意方向都更为精确的检测方式,答案是肯定的,这是一种基于OBB即定向包容盒子(Oriented Bounding Box,OBB)的技术,它已经广泛用于光线追踪和碰撞检测中。
OBB这种方法是根据物体本身的几何形状来决定盒子的大小和方向,盒子无须和坐标轴垂直。这样就可以选择最合适的最紧凑的包容盒子。OBB盒子的生成比较复杂。一般是考虑物体所有的顶点在空间的分布,通过一定的算法找到最好的方向(OBB盒子的几个轴)。
一个2D示意图如图10-38所示。
这种技术比AABB技术更精确而且更健壮,但OBB实现起来比较困难,执行速度慢,并且不太适合动态的或柔性的物体。特别注意的是,当把一个物体分得越来越小的时候,事实上是在创建一棵有层次的树,如图10-39所示。
图10-39 OBB树的生成(曲折线为物体)
为任意的网格模型创建OBB树可能是算法里最难的一个部分,而且它还要调整以适合特定的引擎或游戏类型。从图中可以看出,不得不找出包围给定模型的最近似的包装盒(或者其他3D体)。
现在得到了所有的包装盒,下一步将构造一棵树。
从最初的AABB包装盒开始从上至下地反复分割它。另外,还可以用从下至上的方式,逐步地合并小包装盒从而得到最大的包装盒。把大的包装盒分割成小的包装盒,应该遵守以下几条原则。
(1)用一个面(这个面垂直于包装盒中的一条坐标轴)来分割包装盒上最长的轴,然后根据多边形处在分割轴的哪一边把多边形分离开来(如图10-38所示)。
(2)如果不能沿着最长的轴进行分割,那就沿第二长的边分割。持续地分割直到包装盒不能再分割为止。
(3)依据需要的精度(比如,是否真的要判断单个三角形的碰撞),可以按选择的方式(是按树的深度或是按包装盒中多边形的数目)以任意的条件停止分割。
正如读者所看到的,创建阶段相当复杂,其中包括了大量的运算,很明显不能实时地创建树,只能是事先创建。事先创建可以免去实时改变多边形的可能。另一个缺点是OBB要求进行大量的矩阵运算,不得不把它们定位在适当的地方,并且每棵子树必须与矩阵相乘。
现在假设已经有了OBB或者AABB树。那么该怎么进行碰撞检测呢?首先检测最大的包装盒是否相交(AABB级别),如果相交了,它们可能(注意,只是可能)发生了碰撞,接下来将进一步地递归处理它们(OBB级别,不断地递归用下一级进行处理)。
如果沿着下一级,发现子树并没有发生相交,这时就可以停止,并得出结论没有发生碰撞。如果发现子树相交,那么要进一步处理它的子树直到到达叶子节点,并最终得出结论。
碰撞检测最直观的想法是把一个OBB盒子的每个边都和另一个盒子的所有面来比较,如果这个边穿过了另一个OBB盒子的一个面,则两个OBB盒子发生了碰撞。显然这种方法的计算量是比较大的,因为要进行12×6×2=144次边和面的比较。
但是,在考察两个没有碰撞的OBB盒子时,人们发现一些规律来简化比较。
(1)如果两个OBB盒子不互相接触,则应该可以找到一个盒子上的一个面,这个面所在的平面可以把3D空间分为两部分,两个OBB盒子各在两边。
(2)如果没有这样的表面存在,则一定可以在两个OBB盒子上各找出一条边,这两条边所在的平面可以把两个OBB盒子分在两边。有了这个平面,就可以找到垂直于它的分割轴(separating axis),如图10-40所示。
(3)进行相交测试时,可以把包装盒投影到分割轴上,并检查它们是否线性相交。两个OBB盒子在这个分割轴上的投影将是分离的。
如上所述,要判断两个OBB盒子是否碰撞,只需要看两个OBB盒子之间是否有这样的平面和分割轴存在。如果存在,则没有碰撞。如果不存在,则碰撞。 对第一种情况,每个盒子有6个表面(其中每两个平行),可以决定3个分割轴。两个OBB盒子一共有6个可能的分割轴需要考虑。对第二种情况,两个OBB盒 子之间的边的组合可以有3×3=9种情况,也就是有9个可能的分割轴。这样对任意两个OBB盒子,只需要考察15个分割轴就可以了。如果在任一分割轴上的 阴影不重合,则OBB盒子之间没有碰撞。
选择AABB还是选择OBB应该根据所需的精确程度而定。对一个需要快速反应的3D射击游戏来说,可能用AABB来进行碰撞检测更好些——可以牺牲一些精度来换取速度和实现的简单化,因此总能在游戏中看到一些小疏漏。当然随着硬件能力的提高,OBB处理会逐渐被重视起来。
在做碰撞检测时应当遵循以下的优化理论,这样可以改善检测速度。
n 分两步检验,距离远时看作质点,距离近时采用包装盒。
n 距离很远的物体不会在短时间内相撞(可以采用BSP树分割空间)。
n 一个物体不能隔着第二个物体和第三个物体相撞。
n 一旦一个物体检测到和另一物体碰撞,另一物体对这个物体不再检测。
n 静止的物体不主动与其他物体碰撞。
以下是另一个博客的OBB解释:
方向包围盒(Oriented bounding box),简称OBB。方向包围盒类似于AABB,但是具有方向性、可以旋转,AABB不能旋转。如图3所示。
图3 矩形和矩形投影检测的四条轴
要计算两个OBB是否碰撞,只需要计算他们在图3上的4个坐标轴上的投影是否有重叠,如果有,则两多边形有接触。这也可以扩展到任意多边形,如图4所示。
图4 矩形和三角形投影检测的五条轴
投影轴来自于多边形自身边的垂线。
判定方式:两个多边形在所有轴上的投影都发生重叠,则判定为碰撞;否则,没有发生碰撞。
OBB存在多种的表达方式,这里使用最常用的一种:一个中心点、2个矩形的边长、两个旋转轴(该轴垂直于多边形自身的边,用于投影计算)。代码如下所示:
(function (window) { var OBB = function (centerPoint, width, height, rotation) { this.centerPoint = centerPoint; this.extents = [width / 2, height / 2]; this.axes = [new Vector2(Math.cos(rotation), Math.sin(rotation)), new Vector2(-1 * Math.sin(rotation), Math.cos(rotation))]; this._width = width; this._height = height; this._rotation = rotation; } window.OBB = OBB;})(window);
其所依赖的Vector2这个类如下所示:
(function (window) { Vector2 = function (x, y) { this.x = x || 0; this.y = y || 0; }; Vector2.prototype = { sub: function (v) { return new Vector2(this.x - v.x, this.y - v.y) }, dot: function (v) { return this.x * v.x + this.y * v.y; } }; window.Vector2 = Vector2;} (window))
然后基于这个数据结构,进行OBB之间的相交测试。为OBB扩展一个方法,即或者在任意轴上的投影半径:
OBB.prototype = { getProjectionRadius: function (axis) { returnthis.extents[0] * Math.abs(axis.dot(this.axes[0])) + this.extents[1] * Math.abs(axis.dot(this.axes[1])); }}
这里你可能需要读者了解Vector2.dot的几何意义:若b为单位矢量,则a与b的点积即为a在方向b的投影。
有了这些,就可以进行相交检测。由上面的判定方式,可以得出,两个矩形之间的碰撞检测需要判断四次(每个投影轴一次)。完整检测代码如下所示:
(function (window) { var CollisionDetector = { detectorOBBvsOBB: function (OBB1, OBB2) { var nv = OBB1.centerPoint.sub(OBB2.centerPoint); var axisA1 = OBB1.axes[0]; if (OBB1.getProjectionRadius(axisA1) + OBB2.getProjectionRadius(axisA1) <= Math.abs(nv.dot(axisA1))) return false; var axisA2 = OBB1.axes[1]; if (OBB1.getProjectionRadius(axisA2) + OBB2.getProjectionRadius(axisA2) <= Math.abs(nv.dot(axisA2))) return false; var axisB1 = OBB2.axes[0]; if (OBB1.getProjectionRadius(axisB1) + OBB2.getProjectionRadius(axisB1) <= Math.abs(nv.dot(axisB1))) return false; var axisB2 = OBB2.axes[1]; if (OBB1.getProjectionRadius(axisB2) + OBB2.getProjectionRadius(axisB2) <= Math.abs(nv.dot(axisB2))) return false; return true; } } window.CollisionDetector = CollisionDetector;})(window)
这里拿两个OBB的中心点连线在坐标轴上的投影长度和两个矩形投影半径之和进行对比,如果半径之后都小于或者等于中心连线之后才判定为碰撞,否则判定为分离状态。
为了更加直观的测试OBB碰撞检测方法,使用Easeljs输出碰撞的状态。当两个矩形没有发生碰撞的时候,两矩形呈现蓝色;当两个矩形发生碰撞的时候,两矩形呈现红色。先引入相关的脚本库以及用于显示的canvas画布:
<script src="Vector2.js" type="text/javascript"></script><script src="OBB.js" type="text/javascript"></script><script src="CollisionDetector.js" type="text/javascript"></script><script src="easel.js" type="text/javascript"></script><canvas id="testCanvas" width="980" height="580">
然后进行OBB初始化以及碰撞检测:
var OBB1, OBB1x = 100, OBB1y = 150, OBB1w = 30, OBB1h = 140, OBB1r = 30;var OBB2, OBB2x = 100, OBB2y = 70, OBB2w = 40, OBB2h = 110, OBB2r = 40;var canvas;var stage;var color;function init() { canvas = document.getElementById("testCanvas"); stage = new Stage(canvas); Ticker.addListener(window);}function tick() { stage.removeAllChildren(); OBB1r += 2; OBB2r += 1; OBB1 = new OBB(new Vector2(OBB1x, OBB1y), OBB1w, OBB1h, OBB1r * Math.PI / 180); OBB2 = new OBB(new Vector2(OBB2x, OBB2y), OBB2w, OBB2h, OBB2r * Math.PI / 180); var r = CollisionDetector.detectorOBBvsOBB(OBB1, OBB2); color=r?"red":"#00F"; OBB1 = new Container(); stage.addChild(OBB1); OBB1.x = OBB1x; OBB1.y = OBB1y; var frame1 = new Shape(); frame1.graphics.beginFill(color).drawRect(0, 0, OBB1w, OBB1h); frame1.rotation = OBB1r; frame1.regX = OBB1w / 2; frame1.regY = OBB1h / 2; OBB1.addChild(frame1); OBB2 = new Container(); stage.addChild(OBB2); OBB2.x = OBB2x; OBB2.y = OBB2y; var frame2 = new Shape(); frame2.graphics.beginFill(color).drawRect(0, 0, OBB2w, OBB2h); frame2.rotation = OBB2r; frame2.regX = OBB2w / 2; frame2.regY = OBB2h / 2; OBB2.addChild(frame2); stage.update();}init();
以上代码定义了两个旋转的OBB包围盒,当他们发生碰撞则改变绘制的颜色,使其成为红色。运行代码,效果图5和6所示。
图5 未发生碰撞
图6 发生碰撞
这里是2D情况下的OBB碰撞检测,对于3D OBB碰撞检测,更为复杂。需要测试15个分离轴以确定OBB的相交状态,两个OBB的坐标轴各3个,以及垂直于每个轴的9个轴。除了坐标轴个数不一样,其相交测试思路和本文一致,本文不再探讨。
]]>to be continued…
总结归纳平常适用Unity的过程中遇到的问题。
检查一下场景中是否存在 EventSystem
打开Unity编辑器-Edit-Project Setting-Player-对应PC平台
或者
按照 -Api Compatibility Level 改为 .Net 4.x
错误提示:
1 | Assertion failed on expression: 'm_PreventLoadingFromFile != nameSpaceI |
原因:
创建一个有摄像头的预制件,在编辑器中创建一个渲染纹理,并将渲染纹理设置到相机上。将预制件构建成assetbundle。当你卸载assetbundle.卸载(true)时,你会得到错误msg:“mpreventloadingfromfile!=nameSpaceID。”
最主要的一点是,在prefab中,摄像头不能有一个渲染纹理,你可以在编辑器中创建它。
所以需要确保你的prefab中相机的Target Texture为空
此处的 Target Texture将用代码进行动态赋值
1 | gameObject.GetComponent<Camera>().targetTexture = renderer1;(renderer1为Render Texture) |
如此便可以解决。
问题可能出自于项目经过多次更新,早期的版本没有包管理器,或者其他原因弄丢了
解决方法是:
关闭unity
在项目根目录下的Packages文件夹里的manifest.json里面加一行
“com.unity.package-manager-ui”: “2.0.3”,
注意最后的逗号不能少。
然后打开unity 应该就可以看到了
]]>to be continued…