赞
踩
先在右侧目录查找感兴趣的问题➨
IL的全称是Intermediate Language,很多时候我们看到的是CIL(Common Intermediate Language,特指在.NET平台下的IL标准),其实大部分文章中提到的IL和CIL表示的是同一个东西,即中间语言。IL是一种低阶(lowest-level)的人类可读的编程语言。我们可以将通用语言翻译成IL,然后汇编成字节码,最后运行在虚拟机上。也可以把IL看作一个面向对象的汇编语言,只是它必须运行在虚拟机上,而且是完全基于堆栈的语言。
IL有三种转译模式:
Just-in-time(JIT)编译:在编译的时候,把C#编译成CIL,在运行时,逐条读入,逐条解析翻译成机器码交给CPU再执行。
Ahead-of-Time(AOT)编译:在编译成CIL之后,会把CIL再处理一遍,编译成机器码,在运行的时候交给CPU直接执行,Mono下的AOT只会处理部分的CIL,还有一部分CIL采用了JIT的模式。
Full AOT 完全静态编译:在编译成CIL之后,把所有的CIL编译成机器码,在运行的时候直接执行,这个模式适用于iOS操作系统。
一个程序在运行(执行)的过程中所需要的硬件和软件环境
运行时的主要作用是提供程序运行所需要的环境和基础设施,通过为程序提供内存分配、线程管理、类型检查、对象实例化和垃圾回收等操作来支持程序的运行
公共语言运行时(Common Language Runtime,CLR)是整个.NET框架的核心,它为.NET应用程序提供了一个托管的代码执行环境。它实际上是驻留在内存里的一段代理代码,负责应用程序在整个执行期间的代码管理工作,比较典型的有:内存管理、线程管理、安全管理、远程管理、即时编译、代码强制安全类检查等
C#主要运行在.NET平台上,但.NET跨平台支持不好。
Mono是.NET的一个开源,跨平台的实现,它包含一个C#编译器,mono运行时(CLR)和一组类库,Mono使得C#有了很好的跨平台能力。C#这种遵循CLI规范的高级语言,会被编译器编译成中间语言IL(CIL),当需要运行它们时就会被实时地加载到运行时库中,由虚拟机动态地编译成汇编代码(JIT)并执行。
IL2CPP的编译和运行过程:首先还是由Mono将C#语言翻译成IL,IL2CPP在得到中间语言IL后,将它们重新翻译成C++代码,再由各个平台的C++编译器直接编译成能执行的机器码。
为什么要使用IL2CPP:
1)Mono虚拟机维护成本过大。
2)Mono版本授权受限。
3)提高运行效率。换成IL2CPP以后,程序的运行效率有了1.5~2.0倍的提升。
托管代码:托管代码就是执行过程交由运行时(公共语言运行时,CLR)管理的代码。不管使用的是哪种实现(例如
Mono、.NET Framework或.NET Core/.NET 5+)。CLR负责提取托管代码、将其编译成机器代码,然后执行它。除此之外,运行时还提供多个重要服务,例如GC管理、安全边界、类型安全,把托管代码理解成IL中间语言也行
非托管代码:非托管代码会直接编译成目标计算机的机器码,这些代码包含C/C++或C#中以不安全类型写的代码。非托管代码不受CLR管理,需要手动释放内存
一般情况下,我们使用托管代码来编写游戏逻辑,非托管代码通常用于更底层的架构、第三方库或者操作系统相关接口
Mono将Simple Generational GC(SGen-GC)设置为默认的垃圾回收器,当我们向垃圾回收器申请内存时,如果发现内存不足,就会自动触发垃圾回收,或者也可以主动触发垃圾回收,垃圾回收器此时会遍历内存中所有对象的引用关系,如果没有被任何对象引用则会释放内存。SGen-GC的主要思想是将对象分为两个内存池,一个较新,一个较老,那些存活时间长的对象都会被转移到较老的内存池中去。这种设计是基于这样的一个事实:程序经常会申请一些小的临时对象,用完了马上就释放。而如果某个对象一段时间没被释放,往往很长时间都不会释放。
IL2CPP的虚拟机的内存管理仍然采用类似Mono的方式,因此程序员在使用IL2CPP时无须关心Mono与IL2CPP之间的内存差异。
这个问题考察对源码的理解,源码地址
List内部是用数组实现的,如果不指定长度,则使用默认的空数组。
Add接口:添加元素前会先检查容量,容量为0则会增加到4,之后每次扩充都是增加到当前容量的2倍。改变容量会new一个新的数组,通过Array.Copy方法将原数组的元素复制过去。
Remove接口:通过IndexOf方法查找元素位置(线性O(n)),然后在RemoveAt中使用Array.Copy对数组进行覆盖。
Insert接口:与Add接口一样,先检查容量是否足够,不足则扩容一倍。同样使用的是数组覆盖的形式,将数组里指定元素后面的所有元素向后移动一个位置。
Clear接口:调用Array.Clear方法,在调用时并不会删除数组,而只是将数组中的元素设置为0或NULL,并设置_size为0而已。
Contains接口:执行线性O(n)搜索。是否相等是通过调用Equals()来确定的。
ToArray接口:它重新创建了一个指定大小的数组,将本身数组上的内容复制到新数组上再返回
Find接口:同样线性O(n)。
Enumerator接口:每次获取迭代器时,Enumerator都会被创建出来,如果大量使用迭代器,比如foreach,就会产生大量的垃圾对象,这也是为什么我们常常告诫程序员尽量不要使用foreach,因为List的foreach会增加新的Enumerator实例,最后由GC单元将垃圾回收掉。虽然.NET在4.0后已经修复了此问题,但仍然不建议大量使用foreach。
Sort接口:它使用了Array.Sort接口进行排序,而Array.Sort使用快速排序实现,故效率为O(nlgn)。
总结:List的效率并不高,大部分算法使用的是线性复杂度的算法,我们可以在创建List实例时指定容量,这样List就不会因为空间不够而抛弃原有的数组去重新申请数组了。另外也可以从源码中看出,代码是线程不安全的,它并没有对多线程做任何加锁或其他同步操作。由于并发情况下无法判断_size++的执行顺序,因此当我们在多线程间使用List时应加上安全机制。
Stack内部也是数组实现的,与List一样,也是按照2倍的容量去扩容,只是默认容量不一样,Stack默认构建一个容量为10的数组。
Queue内部仍然是数组实现的,通过增长因子_growFactor和最小增长量_MinimumGrow来调整容量,默认的增长因子是200。
可以看到,Queue基本上还是按照两倍的容量去扩容。
源码地址
详细的插入,删除操作参考这篇文章➨ Dictionary原理
public class Dictionary<TKey,TValue>: IDictionary<TKey,TValue>, IDictionary, IReadOnlyDictionary<TKey, TValue>, ISerializable, IDeserializationCallback {
private struct Entry { // 这个结构体时存放数据的最小单位
public int hashCode; // Lower 31 bits of hash code, -1 if unused
public int next; // 下一个元素的索引,没有为-1
public TKey key; // 元素的Key
public TValue value; // 元素的Value
}
private int[] buckets; // hash桶,用于碰撞检测,元素记录entries数组元素的下标,初始化时元素都为-1
private Entry[] entries; // 该数组保存所有元素,当发生冲突时,数组的元素可以形成链表
private int count; // 记录entries数组存在元素的个数
private int version; // 当前版本,防止迭代过程中集合被更改
private int freeList; // 被删除Entry在entries中的下标index,这个位置是空闲的
private int freeCount; // 有多少个被删除的Entry,即有多少个空闲的位置
Dictionary底层仍然是用数组来实现的
处理Hash冲突的方法中,通常有开放定址法、再Hash法、链地址法、建立一个公共溢出区等。Dictionary使用的解决冲突方法是拉链法,又称链地址法
当实例化new Dictionary()后,不指定容量,内部的数组容量是0个的状态。
如果指定了容量,会调用HashHelpers.GetPrime(capacity)获取实际容量,HashHelpers内部有一个质数数组primes,会从数组中找到第一个比capacity大的质数作为实际容量。
当数组容量不足触发扩容时,会先将当前容量乘以2得到newSize,然后从primes数组中找第一个比newSize大的质数作为新的容量,所以Dictionary扩容是按照两倍多的速度增加。
首先根据key计算hashCode并和buckets长度做模运算,确定删除的元素在哪个hash桶中
发生冲突时会形成链表,所以要沿着这个链表找到对应的元素,这里last变量来记录前一个节点的索引,防止链表断开
freeList会记录删除元素的index,它的作用是把所有删除的元素串联起来,形成链表,而freeCount记录删除元素的个数
注意Remove()函数只是将元素置空,并没有对内存进行删减,这是为了减少内存的频繁操作
添加元素调用的是Insert方法,add为true表示添加新元素,为false表示替换原来元素
添加元素的key会计算一个hashCode并和buckets长度做模运算,确定放入哪个hash桶中
中间的for循环用于检查是否有相同的元素
index是要添加的元素在entries数组中的下标,如果有已经删除的元素,就选择删除元素,没有就根据当前entries数组的容量来确定下标
当发生冲突时,buckets[targetBucket]会记录新元素的下标,而新元素的next指向上一个hashCode一样的元素
总结:Dictionary是由数组实现的,其中buckets主要用来进行Hash碰撞,entries用来存储字典的键值元素,并且标识下一个元素的位置,拉链法来解决冲突的。从效率上看,同List一样,最好在新建时,确定大致数量,这样会使得内存分配次数减少,另外,使用数值作为键值比使用类实例的方式更高效,因为类对象实例的Hash值通常都由内存地址再计算得到。从内存操作上看,其大小以3→7→17→37→…的速度(每次增加2倍多)增长,删除时,并不缩减内存。
HashSet与Dictionary类似,也是维护两个数组存放索引和数据,扩容的方式也一样,只是没有key值。
1.Hashtable在多线程读/写中是线程安全的,而Dictionary不是。如果要在多个线程中共享Dictionary的读/写操作,就要自己写lock,以保证线程安全。
2.Dictionary的key和value是泛型存储,数据类型固定,不需要进行类型转换,Hashtable的key和value都是object,在存储或者读取值时会发生装箱和拆箱,所以比较耗时,适合数据类型不唯一的情况。
3.Dictionary保持存储值的插入顺序,HashTable不维护插入键值数据的任何顺序。
1.使用某客户端的计算结果或由服务器决定计算结果,只计算一次,且认定这个值为准确值,把这个值传递给其他设备或模块。
2.改用int或long类型来替代浮点数,把浮点数乘以10的幂次得到更准确的整数再进行计算,由于整数的计算是确定的,因此就不会存在误差,但要注意计算结果可能超出上限。
3.用定点数替代浮点数,定点数把整数部分和小数部分拆分开来,都用整数的形式表示,缺点是由于拆分了整数和小数,两个部分都要占用空间,所以受到存储位数的限制。大部分项目都会自己实现定点数,无论是整数部分还是小数部分,都用整数表示,并封装在类中。因此需要重载所有的基本计算和比较符号,也可以使用开源的定点数库。
4.用字符串代替浮点数,缺点是CPU和内存的消耗特别大,只能做少量高精度的计算。
委托是一种类(class),我们在创建委托时,其实就是创建一个Delegate类实例。委托可以看作是一个函数指针数组,保存一个或多个函数地址,当使用 =(等号)操作时,就会把函数地址保存到这个数组中,多播委托就会保存多个函数地址。当委托被调用时,会把数组中的函数依次调用一遍。
事件(event)是委托(delegate)的封装,用户不能再直接用 =(等号)操作来改变委托变量,用户只能通过 “+=” 和 “-=” 操作来注册或删除委托函数的数量。公开的delegate会直接暴露在外,随时会被 “=” 赋值而清空前面累积起来的委托函数,封装后就保证了 “谁注册就必须谁负责销毁” 的目的,更好地维护了delegate的秩序。委托可以作为方法参数传递,事件不行。
UnityEvent使用Serializable序列化,方便开发者直接在检视面板中编辑事件及事件回调函数,简化开发流程。使用event需要手动编写代码且无法直接编辑。UnityEvent首次触发事件时会产生垃圾,而C# event不会产生任何垃圾,且前者的速度比后者慢两倍之多。
栈空间比较小,栈遵循先进后出的原则。它是一段连续的内存,所以对栈数据的定位比较快速,栈创建和删除的时间复杂度则是O(1);
堆空间比较大,堆是随机分配的空间,处理的数据比较多,无论情况如何,都至少要两次才能定位。堆内存的创建和删除节点的时间复杂度是O(lgn)。
栈是由系统管理的,栈中的生命周期必须确定,销毁时必须按次序销毁,即从最后分配的块部分开始销毁,创建后什么时候销毁必须是一个定量,所以在分配和销毁上不灵活,它基本都用于函数调用和递归调用这些生命周期比较确定的地方。
相反,堆内存可以存放生命周期不确定的内存块,满足当需要删除时再删除的需求,所以堆内存相对于全局类型的内存块更适合,分配和销毁更灵活。
int num = 123;
string name = "Tom";
int[] array1 = new int[]{1,2,3};
string[] array2 = new string[]{"张三", "李四", "王五"};
上诉变量在内存中的分布
CLR对于string进行了特殊的优化,CLR中存在“字符串暂存池”概念。在CLR初始化时创建一个内部的哈希表,这个表相当于一个字典表,键就是字符串,值是指向该字符串对象的引用。详细参考
C#会对闭包进行面向对象的改造,方法不能脱离类独立存在,所以编译器会生成一个类,将匿名函数作为这个类的方法,引用的外部变量作为类的字段。底层原理
1.值类型是直接存储数据,引用类型持有的是数据的引用,其真实数据存储在堆中。
2.值类型的复制操作是直接复制数据,引用类型的复制是复制引用(类似指针)。
3.引用类型可以实现继承关系,值类型不行。
4.很多人把值类型与引用类型归为栈内存和堆内存分配的区别,这是错误的,栈内存主要为确定性生命周期的内存服务,堆内存则更多的是无序的随时可以释放的内存。因此值类型可以在堆内也可以在栈内,引用类型的指针部分也一样,可以在栈内和堆内,区别在于引用类型指向的内存块都在堆内。
以下情况值类型会分配在堆上:数组中的元素,引用类型中的值类型字段,迭代器中的局部变量,闭包情况下匿名函数(lamda)中的局部变量,如果分配到栈上会随着方法调用的结束而清除,分配到托管堆上,以满足在方法调用后还能被访问的要求。
下面代码执行的结果是什么?
public struct Record { public int id; public string name; public int[] children; } public static void DoSomething(Record record) { record.id = 6; record.name = "Bob"; record.children[0] = 7; } public static void Run() { var record = new Record(); record.name = "Alice"; record.children = new int[] { 1, 2, 3 }; DoSomething(record); Debug.Log(string.Format("{0}-{1}-{2}", record.id, record.name, record.children[0])); } /* 上述代码执行结果是 0-Alice-7 Record是结构体属于值类型,方法参数没有标注ref,out都是值传递,所以DoSomething内部是创建了一个新的record, 并复制了原来record内部的值和引用。record.id默认初始化为0,DoSomething方法内部修改不影响原来的值。 record.name虽然是引用类型,但是因为字符串的不可变性,record.name = "Bob" 其实是创建了一个新的字符串"Bob" 并赋值给了record.name,所以DoSomething中的record.name和Run中的record.name是指向两个不同的地址。 DoSomething中的record.children和Run中的record.children指向同一个地址,所以修改会影响到原来的值。 如果将DoSomething里边的 record.chileren[0] = 7, 改成 record.children = new int[]{ 4, 5, 6 }, 那DoSomething中的record.children和Run中的record.children就指向不同地址,修改不会影响原来的值。 如果将Record改为class,那么上述代码执行结果是 6-Bob-7 引用类型作为参数仍然是值传递,DoSomething内部会创建了一个新的record并复制原来record的引用,所以修改会 影响到原来的值。 此时如果修改DoSomething如下,那么DoSomething中的record和Run中的record就指向不同的地址。 public static void DoSomething(Record record) { record = new Record(); record.id = 6; record.name = "Bob"; record.children = new int[] { 4, 5, 6 }; } */
装箱:把值类型实例转换为引用类型实例。拆箱:把引用类型实例转换为值类型实例。
装箱的内部操作:
第一步:在堆内存中新分配一个内存块(大小为值类型实例大小加上一个方法表指针和一个SyncBlockIndex类)。
第二步:将值类型的实例字段复制到新分配的内存块中。
第三步:返回内存堆中新分配对象的地址。这个地址就是一个指向对象的引用。
拆箱的操作:先检查对象实例,确保它是给定值类型的一个装箱值,再将该值从实例复制到值类型变量的内存块中。
由于装箱、拆箱时生成的是全新的对象,不断地分配和销毁内存不但会大量消耗CPU,同时也会增加内存碎片,降低性能。装箱需要消耗的托管堆内存,如果有大量的对象产生,会增加gc的压力。
发生装箱的情况:
1.当程序、逻辑或接口为了更加通用把参数定义为object,一个值类型(如Int32)传入时,就需要装箱。
2.一个非泛型的容器为了保证通用,而将元素类型定义为object,当值类型数据加入容器时,就需要装箱。
3.当结构体实现接口,而接口又持有该结构体时会发生装箱。
interface IAnimal { void Eat(); } struct Animal : IAnimal { public void Eat() { //do some } } public class Test : MonoBehaviour { void Start() { Animal ani = new Animal(); //struct是值类型,接口是引用类型,会装箱 IAnimal iAnimal = ani; } }
4.值类型调用基类Object中的方法可能会装箱。
GetType方法返回System.Type是非虚方法,值类型实例调用GetType方法一定会装箱。
int重写了ToString方法,所以int调用ToString不会装箱。
结构体直接调用ToString,GetHashCode会装箱,如果重写了方法可以避免装箱。
Dictionary、HashSet, 如果Key是结构体,对其进行操作会触发Equals方法和GetHashCode方法,是会发生装箱的,解决方法是实现lEqualityComparer。
1.结构体是值类型,类是引用类型
2.结构体成员不能使用protected访问修饰符,而类可以
3.结构体成员变量申明不能指定初始值,而类可以
4.结构体不能申明无参的构造函数,而类可以
5.结构体不能申明析构函数,而类可以
6.结构体不能被继承,而类可以
7.结构体需要在构造函数中初始化所有成员变量,而类随意
8.结构体不能被静态static修饰(不存在静态结构体),而类可以
9.使用 new 操作符创建一个结构体,会调用构造函数来创建结构体。与类不同,结构可以不使用 new 操作符即可被实例化。
如果不使用 new 操作符,只有在所有的字段都被初始化之后,字段才被赋值,对象才能使用
10.结构体比较特殊,他不能使用比较运算符(==),使用 Equals() 方法进行比较时,当两个结构体对象的所有字段的值都相等时返回 true,否则返回 false
注意对象和结构体的对齐规则,对齐规则是按照其中元素最大的对齐规则决定的。参考视频
数据总线:CPU从内存中读取数据,一次读取多少,一般是8Byte。
一个地址存放1Byte,即CPU一次可以读取8个地址。
//优化前 struct S { int x; long z; int y; } //这个结构体的大小是24Byte,如果没有内存对齐,CPU读取z就要读2次,影响效率 //优化后 struct S { int x; int y; long z; } //这个结构体的大小是16 [StructLayout(LayoutKind.Sequential,Pack = 1)] public struct S { byte b1; int il; byte b2; } //该结构体强制按1字节对齐,所以它的大小就是6。
1.用到继承和多态时用类。
2.结构体复制或者作为参数传递时不会改变原来对象的值,如果需要这种特性可以使用结构体。类对象是引用传递会改变原来对象的值。
3.因为值类型复制的特性,如果结构体定义了很多字段,复制的成本就会很高,所有结构体适用于数据量小的场景。
这三个关键字都是按引用传递,ref表示可读写,传递ref参数必须是最先初始化,in表示只读的,out表示只写的,传递out参数不需要显示初始化,类似返回值。按引用传参,特别是传递较大的结构体参数,可以减少复制带来的开销。在MSDN的优化建议中也提到,推荐所有大于IntPtr.Size的结构体,传参时都按引用传递,但需要注意这样会改变结构体原来的值。
CPU在每次通知GPU进行渲染之前,都需要提前准备好顶点数据(如位置、法线、颜色、纹理坐标等),然后调用一系列API把它们放到GPU可以访问到的指定位置,最后调用一个绘制命令。而调用绘制命令的时候,就会产生一个drawcall。过多的drawcall会造成CPU的性能瓶颈,这是因为每次调用drawcall时,为了把一个对象渲染到屏幕上,CPU需要检查哪些光源影响了该物体,绑定shader并设置它的参数,再把渲染命令发送给GPU。当场景中包含了大量对象时,这些操作就会非常耗时。降低drawcall应该避免使用过多材质,尽量共享材质,尽量合并网格。
服务器把玩家的操作同步给所有的玩家,玩家在本地客户端,根据服务器发过来的操作,来推进游戏。
同样的代码 + 同样输入 ➨ 同样的结果
服务器,每隔一端时间,将采集的玩家的操作,发给所有的客户端,继续采集下一次的操作,等下一次时间到,又把采集到的操作发送給所有客户端。
客户端:收到服务器的操作 ➨ 计算游戏逻辑 ➨ 上报下一帧的操作给服务器。
状态同步:发操作,收状态
帧同步:发操作,收操作
封装:隐藏对象的属性和方法的具体实现,只对外公开接口,增强数据的安全性。
继承:提高代码重用度,增强软件可维护性的重要手段,符合开闭原则。
多态性:主要通过子类对父类方法的覆盖来实现多态,多态性是指同名的方法在不同环境下,自适应的反应出不同得表现,是方法动态展示的重要手段。(这是动态的多态,重载属于静态的多态。)
七大原则
S 单一功能原则:设计的类,接口,对象等应该仅具有一种单一功能。
O 开闭原则:对于扩展开放的,但是对于修改封闭的。
L 里氏替换原则:子类可以扩展父类的功能,但不能修改父类的功能。
I 接口隔离原则:一个接口应该只有一个方法。
D 依赖反转原则:高层不应该依赖于底层,高层和底层都应该依赖于抽象。抽象不应该依赖于细节,细节应该依赖于抽象。
迪米特法则:一个类对于其他类知道的越少越好,即降低耦合。
合成复用原则:组合优于继承,能用组合的地方就不要用继承。ECS使用到这个原则
要进行socket通信,包结构基本原则,固定包头长度+包体内容。
包头:
包体:序列化后的数据。
遮挡剔除会使用一个虚拟的摄像机来遍历场景,从而构建一个潜在可见的对象集合的层级结构。在运行时刻,每个摄像机将会使用这个数据来识别哪些物体是可见的,而哪些被其他物体挡住不可见。使用遮挡剔除技术,不仅可以减少处理的顶点数目,还可以减少overdraw,提高游戏性能。
触发:两者都有Collider组件,其中之一有刚体组件,其中之一有勾选Is Trigger,两者相交时,不管有没有勾选Is Trigger,都会触发OnTrigger相关函数。
碰撞:两者都有Collider组件,两者都不勾选Is Trigger,运动的物体有刚体,Physics设置中layer有碰撞检测。
DestroyImmediate是立即销毁,立即释放资源,做这个操作的时候,会消耗很多时间的,影响主线程运行。Destroy是异步销毁,一般在下一帧就销毁了,不会影响主线程的运行。
获取RectMask2D或者Mask的RectTransform,接着去调用GetWorldCorners获得该UI在世界坐标的信息坐标,然后设置参数给Shader,让其根据Rect坐标进行裁剪。而Shader的实现很简单,将超出部分的透明度设置为0。
1.UGUI通过Mask,RectMask2D来裁剪,而NGUI通过Panel的Clip。
2.NGUI的渲染前后顺序是通过Widget的depth,depth可以手动设置,而UGUI渲染顺序根据Hierarchy的顺序,越下面渲染在顶层,元素depth是动态算出来的。所以在制作功能界面时,DrawCall控制 NGUI > UGUI。
3.NGUI的UIPanel上有DrawCall Tool可以显示drawcall信息,哪些东西合并成了一个drawcall,UGUI没有这种功能。
4.UGUI不需要绑定Colliders,UI可以自动拦截事件,而NGUI需要绑定,UICamera用射线判断点击的物体并通过SendMessage调用OnClick() OnPress()等函数,而SendMessage利用反射机制。
5.UGUI的Navgation在Scene中能可视化。
6.UGUI界面展示是在Canvas下,而NGUI是在UIRoot下。
7.UGUI使用RectTransform控制元素的位置,缩放等信息,NGUI没有用到这个组件。
8.NGUI全部是用C#开发的,UGUI底层代码可以基于C++进行原生的编程。
9.元素的更新方式不同,NGUI的UIPanel会在LateUpdate里遍历所有的widget,如果有widget发生变化,则触发更新,即使没有变化的UI元素,也会有正常的轮询操作的开销。UGUI是通过两个队列m_LayoutRebuildQueue和m_GraphicRebuildQueue分别记录Layout和Graphic发生变化的UI元素,在渲染之前,会在这个回调函数Canvas.SendWillRenderCanvas里去处理这两个队列里的元素,即分别进行Rebuild。
10.NGUI是必须先打出图集然后才能开始做界面。这一点很烦,因为始终都要去考虑你的UI图集。比如图集会不会超1024 ,图集该如何来规划等等。而UGUI的原理则是,让开发者彻底模糊图集的概念,让开发者不要去关心自己的图集。做界面的时候只用小图,而在最终打包的时候unity才会把你的小图和并在一张大的图集里面。然而这一切一切都是自动完成的,开发者不需要去care它。
11.网格更新机制不同,NGUI可以只更新单个DrawCall,UGUI必须重建整个Canvas。 在功能界面的网格更新控制,NGUI >UGUI。在动态HUD界面(如血条,伤害数字,弹出的一些文本等)的网格更新控制,UGUI >> NGUI(因为UGUI网格合并这块的算法是用C++做的,所以会比在C#做的快很多,而且C#难免会触发一些堆内存的问题)
NGUI:UIPanel.LateUpdate两种更新方式
– UIPanel.FillDrawCall 更新单个DrawCall
– UIPanel.FillAllDrawCall 更新所有DrawCall
UGUI:Canvas.BuildBatch 更新所有DrawCall
– WaitingForJob
– PutGeometryJobFence
– BatchRenderer.Flush(开了多线程渲染之后) 所以做优化时建议先关闭多线程渲染
Unity在制作一个图元,或者一个按钮,或者一个背景时,都会先构建一个方形网格,网格的绘制单位是图元(点,线,三角面),再将图片放入网格中。可以理解为构建了一个3D模型,用一个网格绑定一个材质球,材质球里存放要显示的图片。
渲染过程:UI元素都继承自UIWidget,UIPanel遍历自己子物体的UIWidget组件,放入到一个List中,按照depth排序。List中相邻元素如果material,texture,shader相同,就传递它们的material,texture,shader,Geometry缓存都传给同一个UIDrawCall,否则就再创建一个新的UIDrawCall。每次有新的UIDrawCall产生,UIPanel就会调用上一个UIDrawCall的UpdateGeometry()函数,来创建渲染所需的对象。这些对象分别是MeshFilter,MeshRender,和最重要的Mesh(Mesh的顶点,UV,Color,法线,切线,还有三角面)。UIDrawcall是渲染UI元素的载体,UIPanel生成UIDrawcall,UIDrawcall是一个组件,挂载在一个GameObject,这个GameObject上再挂载MeshRender、Mesh、MeshFilter、材质等Unity组件,通过这些组件将UI元素渲染出来。我们在Editor中是看不到这个GameObject的,是因为创建的时候设置了HideFlags.HideAndDontSave。
UGUI的depth是动态算出来的,按照Hierarchy的节点顺序从上向下进行depth分析,最下层的元素depth = 0,元素相交会先判断是否能合批,材质id一样,图片id一样才能合批,比如元素A和元素B相交且B盖住了A,如果A,B可以合批,那么depthB = depthA,否则 depthB = depthA + 1,如果一个元素盖住了多个元素,则选取下面depth最大的元素进行合批判断。从规则中可以看出,depth值与是否相交有关,与是否为子节点无关。相同depth的元素会根据Material ID和Texture ID(字体的Texture ID就是其字体的ID)进行升序排序。
UGUI的渲染过程和NGUI类似,UI组件的基类是Graphic,Graphic保存了当前元素的mesh和material,Graphic实现接口ICanvasElement主要用于重绘,CanvasRenderer用于传递这些数据给Canvas,CanvasRenderer并不是直接渲染,而是交给Canvas,Canvas还要做合批等操作,Canvas会对节点下的Graphic进行合批,所以一个Graphic设置dirty,整个canvas都需要重新计算合批。
Canvas Scale中设置默认的分辨率,按照宽或者高来做适配。
背景图适配做法
(1)如果按照宽度比例缩放,背景图的高度需要上下预留出一些空间。按照高度比例缩放类似。
(2)可以在背景图上加脚本,根据屏幕分辨率来设置这个背景图的不同比例。
UI元素适配方法
(1)调整锚点
(2)根节点上挂脚本,根据屏幕分辨率来设置元素的缩放。如检测到是Ipad屏幕,就可以缩小Scale。
区域适配,如滚动列表,RectTransform中使用stretch锚边,根据父物体的大小变化。
三角面正面是顶点顺时针,背面是顶点逆时针,如
a
b c
a,b,c为逆时针,渲染背面。a,c,b为顺时针,渲染正面。
方法1:修改ParticleSystem的Order in Layer参数,如果特效粒子勾选Render属性,这个特效就会有Order in Layer的概念,就会跟Canvas的order进行混合影响显示层级。
方法2:在Prefab根节点上挂Sorting Group,然后根据情况设置Order in Layer。
方法3:每个特效挂上脚本,脚本中的类继承MaskableGraphic重写OnPopulateMesh函数,该类是模拟Particle,将其转换成UGUI的Graphic,融入到UGUI体系,所以可以将其当做lmage一样控制。
mesh和material都是实例型的变量,对mesh和material执行任何操作,都是额外复制一份后再重新赋值,即使只是get操作,也同样会执行复制操作。也就是说,对mesh和material进行操作后,就会变成另外一个实例,虽然看上去一样,但其实已是不同的实例了。
sharedMesh和sharedMaterial与前面两个变量不同,它们是共享型的。多个3D模型可以共用同一个指定的sharedMesh和sharedMaterial,当你修改sharedMesh或sharedMaterial里面的参数时,指向同一个sharedMesh和sharedMaterial的多个模型就会同时改变效果。也就是说,sharedMesh和sharedMaterial发生改变后,所有使用sharedMesh和sharedMaterial资源的3D模型都会表现出相同的效果。
materials与sharedMaterials类似,只不过变成了数组形式。materials和sharedMaterials可以针对不同的子网格,material和sharedMaterial只针对主网格。也就是说,material和sharedMaterial等于materials[0]和sharedMaterials[0]。
欧拉角
优点:直观,容易理解。3个数据可以节省内存空间
缺点:万向节死锁问题,必须严格按照顺序进行旋转(顺序不同结果就不同)
应用:只涉及到一个方向的简单旋转可以用欧拉角
四元数
优点:没有万向节死锁。存储空间小,计算效率高。平滑插值,
缺点:单个四元数不能表示在任何方向上超过180度的旋转。四元数的数字表示不直观。
应用:物体旋转的过渡
矩阵旋转:
优点:旋转轴可以是任意向量,没有万向节死锁
缺点:元素多,存储空间大
造成内存泄漏的可能原因:
1.你的对象仍被引用但实际上却未被使用。 由于它们被引用,因此GC将不会收集它们,这样它们将永久保存并占用内存。
2.当你以某种方式分配非托管内存(没有垃圾回收)并且不释放它们。
3.过度使用委托会导致内存泄漏,多播委托会引用多个方法,而当这个方法是实例方法(非静态方法)的话,也就是说这个方法隶属于一个对象。一旦我们使用委托引用这个方法的话,那么这个对象就必须存在于内存当中。即便没有其他地方引用这个对象,因为委托的关系,这个对象也不能释放。因为一旦释放,委托就不再能够间接调用到这个方法了,所以没有正确删除委托的方法会导致内存泄漏。
4.静态对象没有及时释放。
如何避免:
1) 在架构上,多添加析构的abstract接口,提醒团队成员,要注意清理自己产生的“垃圾”。
2) 严格控制static的使用,非必要的地方禁止使用static。
3) 强化生命周期的概念,无论是代码对象还是资源,都有它存在的生命周期,在生命周期结束后就要被释放。如果可能,需要在功能设计文档中对生命周期加以描述。
1.使用反射。
Assembly assembly = Assembly.Load(“xxx”);
Type type = assembly.GetType(“yyy”);
return Activator.CreateInstance(type);
2.使用原型模式克隆。
3.反序列化
1.最简单的方式,根据公式计算当前队伍的伤害上限,只要低于此伤害上限就认为战斗数据合法。
2.可以将整个战斗过程上传到服务器进行验算。数值计算涉及到随机结果的情况下,客户端、服务器使用同一随机种子及随机算法,保证数值结果的正确合法性。
IEnumerator Test() { yield return null; //下一帧再执行后续代码,执行时机是下一帧Update后 //如果yield带有参数返回,则会产生不必要的内存垃圾 //返回0,引发了装箱操作,一般还是用null。 yield return 0; //(任意数字)下一帧再执行后续代码 yield break; //直接结束该协程的后续操作 yield return asyncOperation;//等异步操作结束后再执行后续代码 yield return StartCoroution(/*某个协程*/);//等待某个协程执行完毕后再执行后续代码 yield return WWW();//等待www操作完成后再执行后续代码 yield return new WaitForEndOfFrame();//等下一帧LateUpdate后 yield return new WaitForSeconds(0.3f);//受到Time.Scale的影响 yield return new WaitForSecondsRealtime(0.3f);//不受到Time.Scale的影响 yield return new WaitForFixedUpdate();//等待下一次FixedUpdate开始时再执行后续代码 yield return new WaitUntil();//将协同执行直到当输入的参数(或者委托)为true的时候 yield return new WaitWhile();//将协同执行直到当输入的参数(或者委托)为false的时候 //总结 yield return xxxx; //终止本次协程,直到一个条件成立 }
什么时候用协程:
(1)等待下载完成
(2)异步加载资源
协程的原理:
协程分为两部分,协程与协程调度器:协程仅仅是一个能够中间暂停返回的函数,而协程调度是在MonoBehaviour的生命周期中实现的。 准确的说,Unity只实现了协程调度部分,而协程本身其实就是用了C#原生的 “迭代器方法”。MonoBehaviour生命周期的部分,有很多yield阶段,在这些阶段中,Unity会检查MonoBehaviour中是否挂载了可以被唤醒的协程,如果有则唤醒它。参考 Unity协程的原理与应用
调用协程时,会生成一个IEnumerator对象,它是C#的迭代器函数,这个对象可以看作是函数代码的容器,通过yield关键字将协程中的代码分割放入这个容器中。运行时碰到yield return会将函数暂时挂起,下一帧判断yield return后面的条件是否满足,如果满足则继续执行。协程不是多线程,协程还是运行主线程上,它是用同步的方式实现异步的效果。
IEnumerator Test() { Debug.Log("HelloWor1d"); yield return 1; for (int i = 1; i <= 3; i++) { Debug.Log(" i = " + i); yield return null; } Debug.Log("开始 MyWaitForSeconds"); yield return new MyWaitForSeconds(5f); Debug.Log("结束"); } /* IEnumerator可以理解为一个函数对象的容器,[函数代码1,函数代码2,函数代码3,函数代4。。。] yield关键字就是帮你抽出函数代码,生成一个函数,放到IEnumerator容器里面。 调用Test时会创建一个IEnumerator对象 IEnumerator对象 [ { Debug.Log("HelloWor1d"); return 1; }, { int i = 1; Debug.Log(" i = " + i); }, { int i = 2; Debug.Log(" i = " + i); }, { int i = 3; Debug.Log(" i = " + i); }, ] IEnumerator会依次执行容器中的每个函数, */ void Start() { IEnumerator cor = this.Test(); // MoveNext执行完当前的函数,移动到下一个,到最后MoveNext返回一个false // 这样写,cor会在一帧内执行完 while (cor.MoveNext()) { //Current接收当前函数的返回值 Debug.Log(cor.Current); } MyStartCoroutine(Test()); } IEnumerator nowEnum = null; /// <summary> /// 模拟StartCoroutine()的实现 /// 就是将IEnumerator容器里的函数,每隔一帧触发一次 /// </summary> void MyStartCoroutine(IEnumerator e) { this.nowEnum = e; } void LateUpdate() { if (this.nowEnum != null) { if(this.nowEnum.Current is MyWaitForSeconds) { MyWaitForSeconds myWaitFor = this.nowEnum.Current as MyWaitForSeconds; myWaitFor.Update(); if (!myWaitFor.IsOver()) return; } // 时间到了,继续执行 if (!this.nowEnum.MoveNext()) { this.nowEnum = null; } } } /// <summary> /// 模拟WaitForSeconds类 /// 模拟协程时间等待 /// </summary> class MyWaitForSeconds { public float total;//总时间 public float now;//当前时间 public MyWaitForSeconds(float waitTime) { this.total = waitTime; this.now = 0; } public void Update() { this.now += Time.deltaTime; } /// <summary> /// 是否结束 /// </summary> public bool IsOver() { return this.now >= this.total; } }
协程触发的生命周期,不同的 yield 的方法处于生命周期的不同位置
yield WaitForFixedUpdate 处于物理阶段的最后
yield WaitForEndOfFrame 一帧的最后
其他大部分都处在 Update 和 LateUpdate 之间
协程:实现一个任务在不同时间内分段执行,使用它来控制运动,以及对象的行为,或者实现延迟操作
线程:(1) 大量耗时的数据计算
(2) 网络请求
(3) 复杂密集的I/O操作
Unity支持多线程,有main Thread和renderer thread,但是组件和调用mono相关的接口只能运行在主线程上。
1.开放定址法:冲突位置向后移动一个单位,直到不发生冲突。
2.平方探测法:按照+1,-1,+2²,-2²,+3²…顺序查找
3.再哈希法:对于冲突的哈希值再次进行哈希处理,直至没有哈希冲突。
4.拉链法
1.简单工厂模式:把对象的创建封装到类中,根据不同的参数生成不同的对象,如根据建筑的类型生成不同的建筑。
2.观察者模式:C#的event。
3.状态模式:使用有限状态机,将行为抽象成一个个状态,通过状态管理器控制状态之间的转换,同一时间只能处于某一个状态。
4.组合模式:将一些功能抽象成一个个组件,对象创建时根据需求添加不同的组件,增强代码复用性。
5.单例模式:全局为一,游戏中的管理器。
6.外观模式:对多个子系统进行封装,通过外观类来获取这些系统,减少系统的互相依赖,减少和其他系统的耦合。
7.策略模式:定义了一组同类型的算法,在不同的类中封装起来,每种算法可以根据当前场景相互替换,从而使算法的变化独立于使用它们的客户端。
8.命令模式:将一个命令封装为一个对象,从而实现解耦,改变命令对象,撤销功能
9.原型模式:在不需要创建新对象的情况下复制现有对象,并根据需要修改一些属性
1.整包:将完整更新资源放在Application.StreamAssets目录下,首次进入游戏将资源释放到Application.persistentDataPath下。
优点:首次更新少。缺点:下载时间长,首次安装时间久。
2.分包:少部分资源放在包里,其他资源存放在服务器上,进入游戏后将资源下载到Application.persistentDataPath目录下。
优点:安装包小,安装时间短,下载快。缺点:首次更新下载时间久。
View初始化时从Model中获取数据,并监听Model数据变化,用户操作View触发事件,发送给Control,Control处理后更新Model数据,Model再通知View刷新。
Model不依赖于View,但是View是依赖于Model的,Control 是同时持有 View 跟 Model 的。
数据部分分为配置表数据和网络数据,配置表数据相对固定,在界面中通过事件管理器监听网络数据的变化。
界面会监听对应数据的变化,比如背包界面监听背包道具的变化。View和Model的关系只是查询,并不会改变数据,数据的变化只能来自于服务器的协议驱动。
开发模式:编辑器下使用AssetDatabase.LoadAssetAtPath从StreammingAsset下加载资源。
发布模式:从ab包中加载资源,使用AssetBundle.LoadFromFile(Async optional)
加载的资源需要引用计数,当引用计数为0时,如果是GameObject就销毁或者回收到对象池,如果是ab包就unload。
接口是对动作的抽象,抽象类是对同一类事物的抽象。抽象类表示这个对象是什么。接口表示这个对象能做什么。
继承接口的类必须实现它的所有方法,可以用接口来定义一些行为。两者都不能实例化。例如把 “门” 定义为一个抽象类,根据具体情况实例化为铁门,木门等,门有打开的行为,可以把打开这个行为定义为接口,C#的类不能多继承,但接口可以多继承。抽象基类可以定义字段、属性、方法实现。接口只能定义属性、索引器、事件、和方法声明,不能包含字段。一个抽象类可以同时包含抽象方法和非抽象方法。
加载ab ➨ 加载asset ➨ 实例化obj ➨ 销毁obj ➨ 销毁asset ➨ 卸载ab
//先把cube打成ab包 //加载ab var ab = AssetBundle.LoadFromFile(Application.streamingAssetsPath + "/cube"); //加载asset var asset = ab.LoadAsset<GameObject>("cube"); //实例化obj var cube = GameObject.Instantiate(asset); //销毁obj Destroy(cube); cube = null; //销毁asset asset = null; //卸载ab ab.Unload(false); ab.Unload(true); ab = null;
Unload(false)表示只卸载ab包,并破坏了资源和AB之间的链接
Unload(true)表示把ab包和加载的资源都卸载掉
密封类(sealed)是指该类不可以被继承,密封方法,则派生类不能重写该方法的实现。
1.加法Hash,就是把输入元素一个一个的加起来构成最后的结果
2.位运算Hash,这类型Hash函数通过利用各种位运算(常见的是移位和异或)来充分的混合输入元素
3.乘法Hash,这种类型的Hash函数利用了乘法的不相关性
4.混合Hash,混合以上方式
当我们将一个向量经过旋转或其他的变换后,如果想撤销这个变换,就乘以变换矩阵的逆矩阵。
Rigidbody是用来模拟真实物理效果的,模拟物体相互碰撞后的相互作用。
CharacterController可以说是受限的Rigidbody,除了重力效果之外,它是不能受物理力的。本身自带了一个胶囊碰撞体,可以用来产生碰撞。只有调用Move或SimpleMove,对象才能移动。
1.静态类仅包含静态成员,无法实例化。单例类可以同时具有静态和非静态对象,只是一个实例。
2.静态类是在应用程序第一次加载进行初始化,而单例加载可以懒加载也可以由CLR自动加载。
3.静态类是不允许被继承的,单例类可以。
4.静态类不能实现接口,单例可以。
1.用于创建对象和调用构造函数。
2.在用作修饰符时,new 关键字可以显式隐藏从基类继承的成员(变量、属性、函数)。
3.泛型约束条件:无参构造函数(仅无参构造函数,有参不行)。
public class BaseClass { public string name = "BaseClass"; } public class SubClass : BaseClass { //new 关键字显式隐藏从基类继承的成员(变量、属性、函数) new public string name = "SubClass"; } //--------------------------------------------------------------------- public class Employee { private string name; private int id; public Employee() { name = "Temp"; id = 0; } } //泛型约束条件:无参构造函数 class ItemFactory<T> where T : new() { public T GetNewItem() { return new T(); } } public class Test : MonoBehaviour { private void Start() { //此处编译器会检查Employee是否具有公有的无参构造函数。 ItemFactory<Employee> EmployeeFactory = new ItemFactory<Employee>(); } }
最好和平均情况下是nlogn,最坏情况下是n^2,空间复杂度是logn
每一趟比较和交换是O(n)的操作,每一轮折半,都能将一个元素归位,一个长度为n的序列能折半log2n,即logn次。
递归过程中,每分一层都有一些临时变量,总共分了logn层,所有空间复杂度为logn
反射是在运行期间,动态获取类、对象、方法、对象数据等的一种重要手段。
每个类都有对应的Type对象,Type是一种类型描述,描述了这个类型有哪些数据组成,同时描述一些成员函数。
类的实例: new类(),创建了具体的内存对象,这块内存是所有数据成员的集合。
类的成员函数会到哪里去呢?
类的成员函数属于代码指令,编译完成以后,会变成代码指令,全局只有一份,所有类的实例共用一份代码指令,存入到代码段。
编译器–代码 ➨ .exe执行文件 ➨ 运行这个文件的时候,会把里面的所有代码加载到内存的代码段。
编译完成了以后,就可以根据编译信息,来为每个类来生成一个全局的类型描述对象的数据存起来,写入到
.exe,这样就可以使用了Type的方式来获得一个类的描述。
编译器会知道每个数据的相对于对象实例内存块的偏移。
编译器也会知道,每个类的成员函数在代码段偏移位置 ➨ 运行的时候,就可以让指令直接跳转到这里。
//描述信息类似这样 class FiledData { string filedName; //字段名 int type; //类型 int filedSize; //这个字段的内存大小; int offset; //在内存对象中的内存偏移 } class MethodData { string methName; //方法名 int type; //静态的还是,普通的; int offset; //函数代码指令的地址; } class Type { int memSize; //当前类的实例的内存大小; List<FiledData> datas; //当前这个类的成员变量; List <MethodData> funcs; //当前这个类的所有的成员函数; }
反射的作用:
// (1)System.Type.GetType("类型名"), // typeof(T)根据类型或类型名字来获取我们的类型描述对象实例。 Type t = Type.GetType("类型名"); // (2)实例化一个对象 // 利用描述对象实例,构建一个对象出来; var instance = Activator.CreateInstance(t); // (3)Type里面存放了每个数据成员的偏移和大小, // 用这两个数据就能从对象的内存里面读取/设置成员的数据 // 获取所有的成员变量的描述信息 FieldInfo[] fields = t.GetFields(); // 获取单个成员变量的描述信息 FieldInfo ageInfo = t.GetField("age"); // 设置实例成员变量的值 ageInfo.SetValue(instance, 4) ; // (4)每个Type里面都存放了我们成员函数地址,通过这个调用方法 MethodInfo m = t.GetMethod("方法名"); object[] parameters = new object[3]; m.Invoke(t, parameters);
使用心跳包,每隔一段时间,客户端向服务器发送一条指定的心跳协议。
收到的数据包不完整,这种现象称之为黏包。
出现黏包的原因:当发送端缓冲区的长度大于网卡的MTU(网络上传送的最大数据包)时,tcp会将这次发送的数据拆成几个数据包发送出去。
tcp的协议数据不会丢,没有收完包,下次接收,会继续上次继续接收,己端总是在收到ack时才会清除缓冲区内容。数据是可靠的,但是会粘包。
解决办法:
f(寻路消耗) = g(离起点的距离)+ h(离终点的距离)
起点添加到关闭列表,将起点周围的点添加到开放列表中,开放列表中选出一个消耗最小的点放入关闭列表中,如果这个点是终点则路径找完了,否则这个点作为新起点再循环找。每次从新起点找周围的点时,如果周围的点已经在开放列表或者关闭列表中则忽略。除了起点每个格子都会存其父对象,当找到终点后回溯父对象的格子直到起点,连成路径。
优化:预存路径,地图分块,优化开放列表的排序,最小堆数据结构非常适合A星寻路的open排序。
有限状态机将游戏AI行为分为一个一个的状态,状态与状态之间通过状态管理器切换,某一个时刻只能处于其中一种状态
状态机的问题:随着状态的增多,需要考虑任意两个状态之间是否可以切换,逻辑复杂,复用性不好,如果设计一个全新的敌人,又需要重写一套状态节点和切换逻辑
行为树把行为抽象成一棵树,它是一种“轮询式机制”,即每次更新都会遍历树,判定逻辑是否成立,是否该继续往下执行。行为树从上到下,从左到右遍历节点,行为树的每个节点会有返回一个执行状态,一种设置方式是 {Running,Success,Failure } 三种状态,Running代表正在运行,Success,Failure对应执行成功和失败
综合来看,行为树更适合描述角色在复杂环境下的行为。状态机更适用于处理简单的状态转换,并且适合处理基于输入驱动的场景
如果两个ab包A和B中的一些资源都依赖了资源C,那么C就会同时被打进A和B中,造成资源的冗余
资源C又可以分为两种类型,一种是我们自己创建的资源;另一种是Unity内置的资源,例如内置的Shader,Default-Material和UGUI一些组件如Image用的一些纹理资源等等
对于我们自己创建的资源,解决方案就是将这些被多个ab包依赖的资源打包到一个公共ab包中,处理过程如下:
对于内置资源:
将内置资源提取或者下载到本地,打成ab包,检测其他ab包是否引用内置资源,如果引用了内置资源,则修改引用关系
参考
射线检测
扩展方法使你能够向现有类型“添加”方法,无需修改类型
public static void SetText(this TextMeshProUGUI tmpUGUI)
{
tmpUGUI.text = "Text";
}
扩展方法必须满足的
条件1:必须要静态类中的静态方法
条件2:第一个参数的类型是要扩展的类型,并且需要添加this关键字以标识其为扩展方法
int?为可空类型,默认值可以是null
int默认值是0
int?是通过int装箱为引用类型实现
1.引用命名空间,也可using 别名
2.释放资源,实现了IDisposiable的类在using中创建,using结束后会自定调用该对象的Dispose方法,释放资源。
Unity的主要逻辑是单线程的,Update和FixedUpdate都是在主线程上调用的,如果某一帧的Update卡了很长时间,下一帧的FixedUpdate肯定会受影响,那么是怎么保证FixUpdate的更新频率?
FixedUpdate在累计的时间大于一次物理更新时才会调用一次,当经过的时间大于多个物理更新时间就会按更新间隔分成多次调用。比如物理更新间隔设置的是15毫秒,但运行时,实际的帧间隔是30毫秒,30毫秒大于两次物理更新时间,所以fixedupdate会调用两次,update只调用一次。
//重写 public class OverrideTest : MonoBehaviour { void Start() { B b = new B(); A a = b; a.printStr(); //"重写的方法" b.printStr(); //"重写的方法" //只要对象是子类构造的,就会调用子类的重写方法 } } class A { public virtual void printStr() { Debug.Log("父类的方法"); } } class B : A { public override void printStr() { Debug.Log("重写的方法"); } }
//隐藏 public class HideTest : MonoBehaviour { void Start() { //因为是用Father声明的对象,所有是调用Father的方法 //用子类声明时,才会调用隐藏方法 Father father = new Son(); father.Fun(); //"父类的方法" } } abstract class Father { public virtual void Fun() { Debug.Log("父类的方法"); } } class Son : Father { //隐藏方法 public new void Fun() { Debug.Log("隐藏的方法"); } }
特性是用来标记在某个元素上,默认是以Attribute结尾的一个类
可以标记类、类内部的所有元素(字段,方法),默认不能重复标记
特性只能通过反射调用
特性的参数是编译时决定的,定死的,不能给不确定的值
[Serializable]
class Person
{
public string Name { get; set; }
public int Age { get; set; }
public DateTime Birthday { get; set; }
}
Serializable是C#中的一个系统特性,它用于标记一个类是可序列化的,这样就可以将类的实例转换成二进制格式或者JSON格式的数据,对于网络传输或者数据存储是非常有用的
[AttributeUsage(AttributeTargets.Class)]
class MyAttribute : Attribute
{
public string Name { get; set; }
public MyAttribute(string name)
{
Name = name;
}
}
自己定义特性,定义一个类,直接或者间接的继承Attribute父类,约定俗成以Attribute结尾的,标记的时候可以省略Attribute。这里定义了一个名为MyAttribute的特性,它继承自系统特性类Attribute,我们可以使用它来标记其他的元素,例如类、属性、字段等等。然后,在运行时我们可以使用反射来检查自定义的特性是否存在,如果存在则执行自定义的逻辑
纹理内存大小(字节) = 纹理宽度 x 纹理高度 x 像素字节
像素字节 = 像素通道数(R/G/B/A) x 通道大小(1字节/半字节)
举例:比如一个1024 * 1204的RGBA 32bit的纹理占用多大内存?
占用总位数 :allbits = 1024 * 1024 * (4*8bit)
占用总字节数:allbytes = allbits / 8bit
勾选Is Kinematic时将会忽略外部对此刚体的作用(重力以及addforce或者其他物体对此物体的冲撞),但是此刚体仍然主动对外部非Kinematic刚体产生物理作用
PlanceDistance越大越先渲染(canvas距离摄像机的距离)
Sorting layer越小越先渲染
Order in Layer越小越先渲染
默认的UI/Default,渲染队列是Transparent,此队列针对半透明物体的。
去材质调节面板查看,渲染队列(Render Queue)大于3000的都是半透明物体。
Unity提前定义的5个渲染队列如下
名称 | 队列索引号 | 描述 |
---|---|---|
Background | 1000 | 在其他任何渲染队列之前被渲染,正如名称一样,一般用来渲染背景物体 |
Geometry | 2000 | 默认的渲染队列,不透明物体使用此渲染队列 |
AplhaTest | 2450 | 需要进行透明度测试的物体使用此物体,从Geometry抽离出来,原因是在所有不透明物体渲染完成后再渲染它们更高效 |
Transparent | 3000 | 此队列的物体会在前面三个渲染队列的物体渲染后,按照物体从后往前的顺序进行渲染。任何使用了透明度混合的物体都应该使用这个渲染队列 |
Material是模型的材质,包含贴图,shader等。 Shader是Material的一部分,本质是一小段程序,它负责将输入的Mesh(网格)以指定的方式和输入的贴图或者颜色等组合作用,然后输出。
材质 Material 包含贴图 Map,贴图包含纹理 Texture。
纹理是最基本的数据输入单位,游戏领域基本上都用的是位图。此外还有程序化生成的纹理 Procedural Texture。
贴图的英语 Map 其实包含了另一层含义就是“映射”。其功能就是把纹理通过 UV 坐标映射到3D 物体表面。贴图包含了除了纹理以外其他很多信息,比方说 UV 坐标、贴图输入输出控制等等。
材质是一个数据集,主要功能就是给渲染器提供数据和光照算法。贴图就是其中数据的一部分,根据用途不同,贴图也会被分成不同的类型,比方说 Diffuse Map,Specular Map,Normal Map 和 Gloss Map 等等。另外一个重要部分就是光照模型 Shader ,用以实现不同的渲染效果。
Line Renderer组件是一种用于在3D空间中绘制线的工具。它使用一个点的数组来确定线条的形状和位置,然后在每个点之间插值生成顶点和三角形
Android平台无法使用File相关API进行加载,只能使用Unity中WWW、UnityWebRequest、AssetBundle相关API进行加载
垂直同步是一种调整显示器和GPU之间帧率同步的技术。在启用垂直同步时,GPU将帧率锁定为与显示器刷新率相同的数值。例如,如果你的显示器刷新率是60Hz,那么GPU会将帧率锁定为60fps
启用垂直同步有以下影响:
在mesh中添加骨骼,骨骼的两端为关节,骨骼只能以关节为轴心旋转,把mesh上的点绑定到骨骼上,即刷权重,这样mesh就能够随着骨骼的动作而变形。骨骼动画的本质,便是在不同的时间点为某节骨骼定义了特定的位置、缩放、旋转。动画的运作便是根据两个时间点之间的骨骼数据做数值变化,这种行为称之为补间(Tweens),同理骨骼动画也就是一种补间动画
通常包含以下信息:
骨骼名称:每个骨骼都有一个唯一的名称,用于标识该骨骼和在程序中引用它。
骨骼的旋转、位移和缩放:这些变换信息指示了骨骼在动画中的变化,如旋转方向、位置和大小。
骨骼的层次结构:骨骼可以是单个骨骼,也可以是层次结构中的父骨骼或子骨骼。这些关系确定了骨骼在空间中的位置和姿态。
骨骼的绑定信息:这些信息指示了哪些网格顶点与该骨骼相关联,以及它们的权重。这些权重指示了骨骼对网格的影响程度,决定了网格的变形方式。
混合树(Blend Tree)是一种将多个动画片段以位置、速度、角速度为依据经行线性混合的方式,可以将几个动画文件很好的融合在一起
还可以通过动画层(Layer)的方式实现,每一个动画层只对动画主体的部分进行控制,其他部分通过遮罩屏蔽
LZMA压缩的ab包较小,它是流式压缩,只支持顺序读取,获取ab包中的某个资源需要完全解压后再加载,加载时间较慢
LZ4压缩的ab包较大,它是块压缩,支持随机读取,加载时间较快
timeScale改变时,会对以下值产生影响:time、deltaTime、fixedTime以及fixedUnscaledDeltaTime
timeScale会影响 FixedUpdate 的执行速度,当timeScale为0时,FixedUpdate完全停止。但不会影响Update、LateUpdate的执行速度,如果Update、LateUpdate中使用了deltaTime,则也会影响这部分逻辑的执行
timeScale 不会影响 Coroutine本身的执行速度。当timeScale为0时,如果Coroutine中yield了某个WaitForSeconds或者WaitForFixedUpdate,那么该Coroutine会在此处停下。如果想要等待一个不受timeScale影响的时间,请用WaitForSecondsRealtime
如果A,B两个角色使用的Animator Controller结构完全相同,只是用到Animation Clip不一样,这时可以使用Animator Override Controller。实现A的状态机后,B使用Animator Override Controller覆盖掉A中的Animation Clip
内存碎片是指内存中存在的一些不连续的小块空闲内存,由于它们不连续,所以无法被利用。
内存碎片分为外碎片和内碎片:
外碎片:外部碎片指的是还没有被分配出去(不属于任何进程),但由于太小了无法分配给申请内存空间的新进程的内存空闲区域。
内碎片:内部碎片就是已经被分配出去(能明确指出属于哪个进程)却不能被利用的内存空间;
OSI七层
物理层,数据链路层,网络层,传输层,会话层,表示层,应用层
tcp/ip四层
网络接口层,网络层,传输层,应用层
应用层 HTTP、HTTPS、FTP、DNS、SNMP
会话层 Socket
传输层 TCP UDP
网络层 IP
数据链路层:MTU
比较流行,使用简单,可以用工具生成协议代码,对数据进行压缩,在内存占用和网络传输方面具有优势
反射探针可在场景中的关键点对视觉环境进行采样。通常将这些探针放置在反射对象外观发生明显变化的每个点上(例如,隧道、建筑物附近区域和地面颜色变化的地方)。当反射对象靠近探针时探针采样的反射可用于对象的反射贴图。此外,当几个探针位于彼此附近时,Unity可在它们之间进行插值,从而实现反射的逐渐变化。因此,使用反射探针可以产生非常逼真的反射,同时将处理开销控制在可接受的水平。
反射探针捕获的是间接光,参考 IBL和Unity反射探针
public struct A {}
public class B {}
类型A创建的对象占用1 Byte,存储它的地址
类型B创建的对象占用24 Byte,引用类型对象的值由三个部分组成,对象头,方法表指针,空占位符(非空对象就是各个字段的内容),每个部分占用8 Byte,如果是32位平台每个部分占用4 Byte
参考文章
参考视频
间接光照
(1)透明通道,ETC不支持透明通道,ETC2支持透明通道
(2)压缩效率,ETC2相对ETC在相同压缩比下图像质量更好
(3)支持设备,ETC基本支持所有Android设备,ETC2不支持低端设备,目前ETC2在逐渐取代ETC
将纹理的透明通道分离,一张存RGB,一张存A
利用自定义Shader将其组合使用
ETC的内存占用是RGBA32的1/4,效率也会提升,因此即使分成2张图,内存占用也会减少是RGBA的1/2
public class Father<T>{}
public class Son<T> : Father<T>{}
public Father<Father<int>> Test()
{
return new Son<Son<int>>();
}
不正确,Test方法要求返回类型是Father<Father< int>>,其中T是Father< int>,而return返回的是Son<Son< int>>,其中T是Son< int>,T的类型不一致,T并不存在里氏替换的关系
因为游戏中很多物体需要异步加载,等它加载完再运行逻辑就已经不准确了,比如射击子弹,等子弹加载出来再运行逻辑就会慢一点,加载物体越大越明显
我们可以将游戏中的对象大致分成两类
一种图形学技术,将纹理图像映射到三维模型表面,以模拟表面的外观和细节。
书籍 《Unity3D 高级编程》
唐老狮
觉得有用点个赞吧,随缘更新
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。