Unity 已经将UGUI源码发布了很久,作为经常使用UGUI的程序员,以前一直对其一知半解,现在有点空闲,因此决定把源码学习一下。

环境搭建:

  1. 下载源码文件
    Unity Bitbucket上下载对应版本的压缩包。然后解压。
    我的环境是win10, unity 2018.3f2。

  2. 编译代码 解压完,其实按照readme的描述,就可以用vs打开编译,然后把编译出来的dll文件,替换安装目录

    1
    
    Data\UnityExtensions\Unity\GUISystem\{UNITY_VERSION}
    下的文件。 你可以在源码文件里加一句log代码,测试是否成功,比如 UnityEngine.UI\UI\Button.cs 的 press 函数加一句log,在项目里新建一个button,点击就会打印log信息。
    但是这个方法调试起来非常麻烦,虽然可以用pdb转成mdb文件,让mono进行调试,但是每次改动都要重新生成,所以比较好的方法是创建一个可以直接修改编译UI代码的项目。

  3. 创建测试项目

    1. 打开Unity安装目录
      1
      
      Editor\Data\UnityExtensions\Unity
      可以发现里面有很多Unity功能的文件夹,这些对Unity来说其实相当于外部插件,删除也不会影响Unity运行,只是无法使用相关功能。所以我们先把GUISystem备份一下,然后将其删除。
    2. 接着新建一个Unity项目。
    3. 把UGUI源码里的cs代码拷贝到项目里:可以发现只有 UnityEditor.UI 和 UnityEngine.UI 两个文件夹下有cs文件,一个是editor相关代码,一个是源码。把 Properties 和 .csproj 等不相关文件删除,然后把两个文件夹拷贝到项目内,并把 UnityEditor.UI 文件重命名为 Editor,因为这样Unity才能识别。
    4. 这时候编译项目,会发现一大堆报错。不用担心,看一下会发现都是一些 package功能的报错,因为我们只是测试源码的项目,不需要这些功能, 直接打开 Package Manager, 把不相关的插件都remove,关掉项目重新打开,编译会发现可以通过了。(如果项目没有编译通过,应该是打不开 Package Manager 的,这时候可以打开项目目录 Packages\manifest.json 文件,手动删除对应的插件即可)。
    5. 此时就可以随便修改UGUI源码,并很容易进行调试,方便我们阅读源码了~~

关于iOS上反射的限制

iOS上对反射是部分支持,即支持使用反射读取源代码,但不支持使用反射动态生成可执行代码。 下面是限制反射的命名空间: Profiler Reflection.Emit Reflection.Emit.Save functionality COM bindings The JIT engine Metadata verifier (since there is no JIT)

换句话说IOS支持的是Reflection的命名空间的部分方法,但不代表支持Emit下的命名空间的方法,总之,IOS不支持以动态方式创建新的方法和类型。

常用代码热更新方案:

手游热更的基本原理很简单,就是启动时检测下是否有新版本文件,有的话就下载覆盖老文件,然后启动。问题是对于资源这样是可以的,但是对于代码,因为iOS不支持JIT,所以不能将DLL打包为资源然后反射调用。所以手机上代码热更一直都很麻烦,基本上都是想办法运行时解释执行代码。

目前常用的几种热更方案:

  1. 内嵌虚拟机,使用脚本语言(基本都是Lua)
    toLua、sLua等,直接使用lua开发大部分性能不敏感的功能。lua代码都是运行时才编译的,不运行的时候就是文件资源,所以更新逻辑只需要更新脚本,不需要再编译,因而lua能实现“热更新”。
    缺点就是性能有影响,需要熟悉Lua语言,不能用宇宙第一IDE VS。

  2. ILRuntime
    作者实现了IL运行时,可以直接使用C#进行热更新。 性能可能较慢,而且目前使用的成熟项目较少,但个人觉得这并不算太大的问题,目前发展的也比较成熟。 毕竟能使用C#对大部分项目还是很友好的。 还有个ET框架,前后端统一,感觉不错。

  3. 强类型语言翻译至Lua CSharp.lua、Haxe语言。

XLua的Hotfix

这是XLua最特色的功能,开发只用C#,运行时也是C#,不打补丁基本和原有程序一样,只有出现bug时才用Lua修改出问题的地方,下次整包更新时再换回C#。
如果仅希望用热更新来fix bug,这是强烈建议的做法。对于老项目接入也可减少工作量。
(有一个 cshotfix 是基于ILRuntime的类似项目)

对于打了hotfix标签的类,xLua会在il层面注入代码。执行时替换为执行Lua的函数。

你可能会想我怎么知道哪个地方会出bug,然后打标签呢,很简单,把大部分类都打上hotfix标签。。。
另外,如果觉得每个类打标签太累,还可以使用批量配置,这也是官方建议的方式。

总结一些图形学基础数学知识和常用矩阵。

向量点乘(dot product):

笛卡尔坐标系下:

投影(b在a上的投影):

向量叉乘(cross product):

两个向量的叉乘会得到垂直于平面的向量,如图使用右手法则则方向向上:

笛卡尔坐标系下:

坐标系(coordinate frames):

3D空间中任意3个向量,这三个向量都是单位长度,互相之间正交,互相满足叉乘关系。
||u|| = ||v|| = ||w|| u ▪ v = v ▪ w = u ▪ w = 0 w = u x v

坐标系中任意一个向量p 可以表示为:
p = (p ▪ u)u + (p ▪ v)v + (p ▪ w)w

通过任意向量a,b,构造坐标系 w,u,v:

矩阵:

乘法:

满足交换律和结合律:
A(B + C) = AB + AC 、 (A + B)C = AC + BC

转置矩阵:

单位矩阵和逆矩阵:

变换矩阵:

缩放(Scale Nonuniform)矩阵 及 逆矩阵:

错切(Shear) 及 逆矩阵:

2D 旋转(Rotation):

2D下旋转是线性的,并且可交换旋转顺序。

组合旋转、缩放:

x3 = Rx2, x2 = Sx1,
x3 = R(Sx1) = (RS) x1,
x3 != SR x1

组合逆变换:

3D下 绕坐标轴旋转:

任意轴a(x,y,z) 旋转(Rodrigues):

平移:

增加一个w坐标,变为4x4矩阵:

齐次坐标:

一个点的非齐次坐标通过除以w得到:

当 w>0 表示真实的点, w=0时表示无穷远的点(一个长度为0的向量)。

使用齐次坐标,可以将所有变换统一,变换完成最后需要真实点的位置时,除以w即可。

组合 旋转-平移:

组合 平移-旋转:

法向变换:

t是平面的切向量,n是法向量,M是对平面使用的变换矩阵,求对n的变换矩阵Q:

这里 逆矩阵的转置 只针对齐次坐标左上角的 3x3 矩阵。(因为平移坐标对法向没有影响)。

旋转坐标系的每一行,是新坐标系的3个单位基向量(3D同理):

观察:

正交矩阵:

OpenGL中观察的方向是-z,用-n、-f 代替 n、f。

投影矩阵:

d 是视点距屏幕的距离

Frustum:


ø = fov / 2,
d = cotø,
aspect = width / height,

其中aspect用于处理不同的纵横比。
A、B用于把 近裁剪面n 和 远裁剪面f 映射到 -1 和 1。

推导得出A、B:

注意,Z的映射是非线性的,有一个-1/Z的比例项,它的优点是可以处理很广的深度范围,但是深度分辨率不统一,在接近n(近剪裁面)的时候趋于最大值。所以,不要把近剪裁面设为0。

Behavior Tree (BTs),在许多3A游戏中都有广泛应用。之前我们已经简单介绍过它,希望你先看一下。这篇我们来更为详细地介绍一下行为树。

行为树的基本概念

之所以称为树,是因为它是多个节点(Nodes)的分层、分支系统,这些节点共有一个根(Root)。如果我们图像化行为树,它看起来会像这样:

当然,行为树可以由任意数量的节点及子节点构成。在分层最后的节点被称为叶节点,就像真实的树一样。节点可以表示行为或测试。不像状态机有转换规则贯穿其中,一个行为树的流程是由更上层节点的顺序严格定义的。一个行为树是从树的顶点开始评估的,然后继续按顺序遍历每个子节点,直到遇到特定条件或者到达叶节点。行为树总是从根节点开始评估。

不同节点类型

不同类型节点的名称可能会根据情况有所变化,甚至节点自身有时也被称作 任务。然而一个树的复杂度完全取决于AI的需求,关于行为树如何工作的更高级概念,如果我们只关注每个独立的部分就会非常容易理解。接下来的概念对于不管什么类型的节点都是适用的,一个节点总会返回下面其中一个状态:

  • Success:遇到了节点要检查的条件。
  • Failure:没有且不会遇到节点要检查的条件。
  • Running:节点检查的条件的有效性还未确定,可以认为是“请等待”状态。

由于行为树潜在的复杂性,大部分实现都是异步的,即评估一个行为树并不会阻塞其他操作。如果必要的话,一个树中的各种节点的评估过程可能会经过好几帧。如果你要同时检测好几个行为树,可以想象这会影响程序的性能,来等待它们每一个树都返回一个True或False到根节点。这也是“Running”状态存在的重要性。

定义复合节点

之所以称作复合节点是因为它们有一个或更多子节点。它们的状态取决于它们子节点的结果,并且当其子节点正被评估,它们就处于“Running”状态。有许多复合节点类型,一般由它们的子节点如何评估来定义:

  • Sequences(序列):序列的特点是,所有子节点的序列都需要按顺序成功完成,才能被评估为成功。如果序列中在任一步有任一子节点返回flase,那这个序列都被视作失败。需要注意,通常序列顺序都是从左到右,下图显示了成功的序列和失败的序列:

  • Selectors(选择器):相对来说,选择器对于它们的子节点来说是更“宽容”的父节点。任何一个子节点返回True,选择器都马上被认为是True,并不再评估其他子节点。选择器唯一会返回False的情况,就是所有子节点都被评估并且都返回False。

每个复合节点类型都有适用的情况,你可以把它们看作“与”和“或”。

理解修饰节点

修饰节点和复合节点最大的不同是,修饰节点只能有一个子节点。也许你认为这样没有必要,但修饰节点比较特殊,本质上它们接收子节点返回的结果,并根据自身的参数来决定如何回应。一个修饰节点甚至可以指示如何评估以及多久评估一次它们的子节点。这是一些常见的修饰节点类型:

  • Inverter(反转器):可以认为是“非”修饰符。它会反转子节点返回的结果。
  • Repeater(重复器):会重复评估子节点特定的(或无穷)次数,直到子节点返回修饰节点决定的True或False。比如你会无限等待直到遇到某一特定情况。
  • Limiter(限制器):限制一个节点执行的次数,以避免陷入循环。这与Repeater形成对比,可以用于某一角色只会尝试一定次数,然后就会放弃。

还有一些修饰节点是用来调试和测试行为树的,比如:

  • Fake state:根据修饰节点指定总会返回True或False。这对于断言特定行为很有用。你也可以使修饰节点一直保持“Running”状态,以观察周围其他节点的行为。
  • Breakpoint:就像代码中的断点,可以切断逻辑,并指示你节点已到达。

这些类型并不是互相排斥的单一类型。你可以组合这些节点类型以适应你的需求。只是要注意不要组合过多的功能到一个修饰器,也许那样的效率或方便程度还不如使用序列来代替。

描述叶节点

我们之前介绍叶节点是行为树结构的一点,但其实叶节点可以是行为的任何形式。它们可以描述你的角色拥有的任何形式的逻辑。一个叶节点可以描述一个行走函数,射击命令,或攻击行为等。它做什么或如何评估状态并不重要,它只是自身层级的最后一个节点,并且返回三个状态中的一个。

Flocks and Crowds,这是游戏开发中一类非常有用的算法。Flocks主要指模拟非玩家角色的群体行动,如蜂群、鸟群、鱼群等;Crowds指模拟一群人物的行动,如RTS游戏中,选中一群士兵,让他们移动到某一处。

群聚的概念

群聚算法最早由 Craig Reynolds 在八十年代提出。

群聚描述了一组物体,作为一个群体集体移动。Flocking 算法的名字来源于自然界中的鸟群(boids),鸟群中的鸟跟随其他鸟,飞向某一目的地,每一只之间都保持着几乎固定的距离。群聚研究的是,无论路径如何,一个群体中的物体都能和谐且有效率的移动。

有三个基本的概念定义了一个flocks如何运行:

  • 分离(Separation):在群体中和“邻居”保持距离以避免碰撞。

    如图,中间的物体,在不改变自身指向的情况下,正在向远离其他物体的方向移动。

  • 对齐(Alignment):以和群体相同的方向移动,并且速度也保持一致。

    如图,中间的物体,正在改变自己的指向,以匹配周围物体的方向。

  • 凝聚(Cohesion):与群体的中心保持最小的距离。

    如图,右侧的物体,正往箭头指示方向移动,以和它周围的物体保持最小距离。

从这三条可以得知,每个单位都必须有运用转向力行进的能力。此外,每个单位都必须得知其局部周围的情况,邻近单位的位置、它们的方向、与自身的距离等。

Unity中 Flocking 示例

接下来我们在Unity中实现一个简单的flocking示例项目。主要有两个组件:独立的boid物体脚本,和一个控制脚本来维护和领导群体。(Boid是 Craig Reynolds 提出的术语,指像鸟一样的物体,我们用这个术语来代表每个个体。)

UnityFlock 表示独立的物体,这里只用简单的方块来表示,你也可以换成鸟的模型之类的。 UnityFlockController 是它们的头领,它会随机更新移动位置,而其他物体会跟着它移动。

模仿个体行为

下面我们来实现boid的行为,创建一个UnityFlock.cs文件,用来控制每个个体的行为。

首先定义用到的属性:

 
using UnityEngine;
using System.Collections;

public class UnityFlock : MonoBehaviour {    
   
    public float minSpeed = 20.0f;
    public float turnSpeed = 20.0f;
    public float randomFreq = 10.0f;
    public float randomForce = 10.0f;

    // 分离变量
    public float avoidanceRadius = 20.0f;
    public float avoidanceForce = 20.0f;

    // 对齐变量
    public float toLeaderForce = 50.0f;
    public float toLeaderRange = 100.0f;

    // 凝聚变量
    public float cohesionVelocity = 5.0f;
    public float cohesionRadius = 30.0f;

    // 控制boid运动的变量
    private Vector3 velocity;
    private Vector3 randomPush;
    private Vector3 leaderPush;
    private Vector3 avoidPush;
    private Vector3 centerPush;

    // 头领
    private UnityFlockController leader;
    

我们首先定义了最小移动速度、旋转速度,这两个速度最好和leader保持一致。randomFreqrandomForce 是用于随机运动的,以使运动看起来更真实。randomFreq 控制 randomPush 的更新频率,而 randomPush 的值根据 randomForce 决定。

avoidanceRadiusavoidanceForce 用来保持个体间的最小距离,这些属性是用来实现“分离”原则的。

toLeaderRange 指示群体扩散的范围,toLeaderForce 保持boid在范围内并且与头领保持一定距离。用来实现“对齐”原则。

cohesionVelocitycohesionRadius 用来保持和群体中心的最小距离,以实现“凝聚”原则。

leader 物体就是父物体,来控制整个群体的运动。每个boid还需要知道其他boid的运动情况,所以,leader中会有数组 Flocks 保存所有boid的信息。

接下来是start函数,指定父物体为 leader,且开启了更新随即运动的协程:

 
void Start ()
{
    randomFreq = 1.0f / randomFreq;

    if (transform.parent){
        // 指定父物体为leader
        leader = transform.parent.GetComponent<UnityFlockController>();
    }

    if (leader.Flocks != null && leader.FlockCount > 1){
        transform.parent = null;
        // 开始 计算随机移动 的协程
        StartCoroutine(UpdateRandom());
    }
}

随即运动协程函数:

 
IEnumerator UpdateRandom ()
{
    while(true)
    {
        randomPush = Random.insideUnitSphere * randomForce;
        yield return new WaitForSeconds(randomFreq + Random.Range(-randomFreq / 2.0f, randomFreq / 2.0f));
    }
}

接下来是update()函数,分别实现三个原则,计算速度,并应用速度进行位移和旋转。

实现控制器

控制器就是leader,它的作用就是随机移动,并保存所有群体个体数组。

这里就不贴代码了,代码详情请查看 github 项目源码,里面有详细注释。

另一种Flocking算法实现方式

这是一种更简单的实现flocking算法的方法。我们使用Unity中的rigidbody到我们的boid上,通过刚体物理,我们可以简化运动控制。为了避免boid互相重叠,可以添加球形碰撞器。

和上面例子一样,还是需要两部分:个体和控制器。所有个体跟随控制器运动。

在FlockController中,我们会生成所有物体,然后计算群体的中心点和平均速度。并且定义了控制三个原则的变量。 然后再每个Flock中,我们应用FlockController计算出的中心点、平均速度和各个控制变量,连计算运动的速度,并施加于刚体上。

代码详情请查看 github 项目源码,里面有详细注释。

如果需要运动的更具真实性,可以随机改变 群聚度、分离度等属性的值。

使用Crowds(人群)

通常Crowds模拟的是一群人在一个区域移动,并且避免互相碰撞或躲避环境障碍。通常在RTS游戏中使用较多。

实现一个简单的 Crowd模拟

我们主要使用Unity的Navmesh来实现,这非常高效简单。

人群中的每个物体都附加 NavAgent,设置它们的目的地,这样在它们驯鹿导航是,会自然互相避让,并同时向目标行进。

具体详情请查看 github 项目源码