[原创]Unity面试经验分享
下面就是一些我当前年面试 Unity 被问到的以及可能会被问的问题,我觉得可以跟大家分享,希望对找工作的你有些许帮助(以下答案,是我自己的理解,如有错误还请告知,以免误人)内存
Q:Unity内存优化
原生内存: 代码 (引擎,第三方C/C++,lua) 内存, Resource内存, Assets ( AB,场景,音频,纹理,网格,材质,动作 ) 内存等
托管内存:托管堆内存,所有非空引用类型的对象和已装箱值类型对象
分析内存问题:Profiler, Profiler Analyzer , UWA,UPR
代码:1. 泛型以及闭包的滥用导致IL2CPP生成代码量巨大 2.代码脱离级别
Resource文件夹:不要用它,在运行的时候会序列化并生成查找树
AB:1. 建议采用LZ4压缩格式2.如无稳定框架,可采用Addressable Asset System 3.注意资源冗余(UWA查看冗余资源)4.注意释放
场景:静态合批内存消耗,Mesh压缩不会减少内存(只会减少包体大小)
音频:流模式内存消耗小,CPU有消耗
纹理,网格: 1. 纹理,大小格式和冗余 2. 网格面数 3. standard Shader
托管内存:一般20-80M
Q:GC(Garbage Collection)
标记阶段:收集器从根对象开始进行遍历,对从根对象 ( 正在运行的局部变量、静态变量、重写finalizer方法的变量、正在调用的函数传递的参数 ) 可以访问到的对象的同步索引块标标记为可达
清除阶段:收集器会对堆内存从头到尾进行线性的遍历,如果发现某个对象没有标记则将其回收(回收之前,会先执行它的 Finalize() 方法,如果重写了话)
缺点:容易造成内存碎片化
2019.3增加了增量GC优点:避免出现GC时CPU峰值,导致掉帧或者卡顿 ; 缺点:1. 因为是分帧执行遍历,所以得采用写屏障(可能导致bug),2. GC总时常变长
CG触发:1.向托管堆申请连续内存块,但是托管堆中的连续内存块大小不足2.GC 自动触发3.手动调用GC
CG优化: Cache变量,使用对象池,闭包,匿名函数和协程会有托管堆内存分配,配置表拆分,字符串,手动调用GC
Q: 谈一谈你是如何排查静态对象造成的内存泄露
如果是持续的静态内存泄漏,那么Profiler 的GC Alloc里面一定有与之对应的方法可使用Profiler Analyzer做采样对比,内存左边和右边不一致的可重点查看对于Static类和方法,尽量少用
Q: Editor环境和真机环境下,Profiler显示的内存相关数据是否有差异?如果有什么原因造成的?
有差异,一般有以下原因:
一些引擎API在Editor和真机环境下,实现原理不一致。比如加载ab.LoadFromFile, Editor下会加载整个AB,而真机只加载AB头部;GetComponent在Editor会造成GC Alloc, 真机不会我们自己写的,或者第三方插件代码实现不一致编辑器可能缓存了某些资源(比如你选中某个纹理,该纹理会一直在内存中)
Q:谈谈资源卸载
非托管资源,手动卸载;托管资源,清空引用
1. 文件,网络套接字,数据库连接等采用Dispose + Finalize() 释放 ;
2. 资源(纹理,网格, ab中LoadAsset() 等) 用Resources.UnloadAsset(obj) 来卸载
3. AssetBundle内存 (非托管内存 ),如果有指向该资源的变量( 假设名为:ab),则可使用ab.Unload ( false/true ) 来卸载
4. 最后调用Resources.UnloadUnusedAssets()来释放(此方法非常耗时)
帧同步
Q:帧同步一般采用什么网络协议?为什么?
一般来说,都会采用UDP协议,因为帧同步方案,会有高频次的上报和下发逻辑帧信息,所以对网络要求很高
优点:有序,可靠,长连接
缺点:过大的包头(20字节), 拥塞机制(慢启动,拥塞避免)
特性:无序,不可靠,无连接,协议简单,包头小(8字节)
缺点:需要自行实现有序,可靠的信息传递
自定义UDP协议包结构:UDP 包大小最好控制在500字节以内
Q:谈谈粘包,拆包,以及怎么解决?
UDP不会产生粘包,因为它有消息保护边界;TCP会产生因为他的滑动窗口,MSS/MTU限制,Nagl算法
解决粘包办法:自定义数据协议(包含数据长度)
Q:为什么MOBA游戏要使用帧同步?
moba类游戏小兵多玩家少且固定,对实时性,流畅性 , 公平性要求较高
优势:回放制作方便,流量消耗少,制作离线战斗方便(新手战斗)
难度:1. 保证一致性 2. 开发难度比较高,经常会有以主角为主类的想法。也有可能会直接拿玩家的位置等属性作为参数等的情况 ( 一定要注意逻辑代码和表现分离 ) 3. 流畅性 性能,网络
Q:UDP怎么实现自动重传?
UDP本身是不可靠的,所以需要额外信息来保证传输数据的可靠性。因此,我们需要在传输的数据上增加一个包头。用于确保数据的可靠、有序
主要使用两种策略来决定是否需要重传KCP数据包 : 超时重传、快速重传
超时重传:发送数据包在一定的时间内没有收到相应的ACK,等待一定的时间,就会重新发送。这个等待时间被称为RTO,即重传超时时间
快速重传:明确丢了哪个包,那么不用等超时,直接快速重传
Q:三次握手
“第三次握手”是客户端向服务器端发送数据,这个数据就是要告诉服务器,客户端有没有收到服务器“第二次握手”时传过去的数据。若发送的这个数据是“收到了”的信息,接收后服务器就正常建立TCP连接,否则建立TCP连接失败,服务器关闭连接端口
Q:四次挥手
因为当Server端收到Client端的SYN连接请求报文后,可以直接发送SYN+ACK报文。其中ACK报文是用来应答的,SYN报文是用来同步的。但是关闭连接时,当Server端收到FIN报文时,很可能并不会立即关闭SOCKET,所以只能先回复一个ACK报文,告诉Client端,"你发的FIN报文我收到了",只有等到我Server端所有的报文都发送完了,我才能发送FIN报文,因此不能一起发送。故需要四步握手
Q:帧同步怎么做到流畅的战斗?
收到逻辑帧消息之后不立即去播放,用固定的延迟进行播放,网络在我们设置的延迟内波动的话,玩家是感受不出来卡顿的另外一个处理方式就是,逻辑和表现进行分离,表现帧由玩家和逻辑帧进行驱动,逻辑帧卡顿并不影响表现帧,表现帧由玩家进行控制播放,一旦收到了新的逻辑帧,立即去做校验,如果说表现帧的播放轨迹和逻辑帧有出入,立即进行纠正。难度:预测,回滚,追帧
Q:怎么解决丢包问题?
丢包是不可避免的,但是我们可以做一些措施,减少丢包率:
发送缓存被填满,那么降低发送频率;接收方缓存被填满,那么多线程接收控制数据包大小,避免因为分包而导致的丢包冗余发送机制,一个包包含多帧数据
Q:说说帧同步重连怎么处理?
重连有两种情况:
时间较短:那么直接将逻辑帧、状态帧拉回来,服务器一次性将断线之后的消息发给客户端,客户端加速播放时间较长: ECS可以很方便的把服务器的当前数据全部发送给重连的客户端,让客户端直接从重连的那一帧开始游戏,避免了漫长的重连过程;(而且ECS也适合做回滚)
Q:有遇到过不同步的问题吗?怎么解决的?
原则就是:就是保证客户端的输入一致,计算一致
主要的点就是随机数(服务器发随机种子)和浮点数运算(采用定点数)战斗逻辑不要使用一些排序不固定的容器,比如:Dictionary逻辑层不要使用协程(协程是根据Unity的Update去驱动,而不是我们自己的帧逻辑)第三方插件的某些实现导致不同步(比如:Update不受帧控制,导致某些计算两个客户端不是在同一个逻辑帧,就会导致异常)逻辑层和表现层一定不能交叉调用,表现层根据Update去轮询逻辑层的帧
Q:谈谈帧同步的外挂问题
实时验证。验证服实时运行战斗逻辑和客户端不断同步验证关键数据离线验证。这是帧同步的优势,战斗结束后服务器收集整场的操作序列,然后加速播放战斗(几十上百倍),最后校验结果目前无法防全图挂
渲染
Q:简单说说渲染管线
一般分为:应用程序阶段,几何阶段,光栅化阶段
应用阶段:CPU判断哪些物体显示,为需要显示的物品准备数据,发送渲染指令
几何阶段:顶点着色,几何着色,裁剪,屏幕映射
光栅化阶段:三角形设置,三角形遍历,片元着色器,模板测试-深度测试-混合-颜色缓冲区
Q:深度缓冲(z-buffer)与w缓冲(w-buffer)
z-buffer 保存的是经过投影变换后的 z 坐标,投影后物体会产生近大远小的效果,所以距离眼睛比较近的地方,z 坐标的分辨率比较大,而远处的分辨率则比较小。所以投影后的 z 坐标在其值域上,对于离开眼睛的物理距离变化来说,不是线性变化的(即非均匀分布),这样的一个好处是近处的物体得到了较高的深度分辨率,但是远处物体的深度判断可能会出错
w-buffer 保存的是经过投影变换后的 w 坐标,而 w 坐标通常跟世界坐标系中的 z 坐标成正比,所以变换到投影空间中之后,其值依然是线性分布的,这样无论远处还是近处的物体,都有相同的深度分辨率,这是它的优点,当然,缺点就是不能用较高的深度分辨率来表现近处的物体
Q:G-Buffer?
我们知道在前向渲染中,引擎在CPU端将渲染需要的数据(比如顶点、法向量、纹理坐标、相机参数等)通过总线发送到GPU端,为了计算每个片源的最终颜色,在片元着色器中要逐像素计算光照,对于多光源的场景还需要将不同光源的光照颜色混合才能得到最终片源的颜色,因此在多光源的情况下时间复杂度为O(LightsNum * ObjectsNum)。显然,由于遮挡的原因,场景中很多片源实际上是overdraw的,但是由于depth test是在fragment shader之后才进行,所以对被遮挡片源进行光照计算造成了大量的开销,那么为了减小不必要的光照计算开销,延迟渲染的idea就是先搞一个geometry pass,利用管线的depth test,把看不见的片源先剔除掉,并且将片元的Normal、Diffuse color以及Positioin写入到G-Buffer中,然后在Lighting pass中,直接采样G-Buffer中这3个纹理,再进行光照计算
所谓G-Buffer实际上就是Geometry buffer,它存储了片源的几何信息,比如法向量、漫反射颜色、顶点坐标等
Q:什么是法线贴图,缺点是什么?为什么法线贴图一般都是偏蓝色?
法线贴图技术,仅仅是让三角形渲染的时候,多了一个真实的法线值,用于做光照计算,而不能增加顶点值。因为一般来说,顶点值在计算光照的时候都用不到
法线贴图仅仅是简单的视觉欺骗,一旦凹凸太明显的模型,使用了法线贴图,太靠近的时候,就穿帮了。法线贴图主要适用于主要就是凹凸不太明显,细节很多,需要表现实时光照效果,不会太靠近观察的物体
生成法线贴图,一般都是采取纹理的灰度图,根据两个像素间的灰度差,形成U,V两个向量,然后两个向量的叉积就是法线的方向。在切线坐标系里,定义顺序是Tangent、Binormal、Normal,也就是说,Normal处于z这个方向。而对于一个三角形而言,绝大多数时候,法线值都是垂直于这个面的。显而易见,法线贴图的法线值大多数时候是接近于(0,0,1)的,当然是接近于蓝色了
Q:为啥png,jpg不能直接用,而采用etc,astc格式?
答:jpg,png是针对硬盘的压缩格式,最大的问题他们都是基于整张图片的压缩,像素与像素之间在解码过程中存在依赖关系,无法实现单个像素级别的解析,而且,png,jpg解码以后都是RGBA的纹理格式,无法减少显存的占用率。而etc和astc这种是针对图形接口而设计的压缩格式,不需要CPU解压而GPU可以直接采样
设计模式
Q:设计模式原则
单一责任原则、开放封闭原则、里氏替换原则、接口分离原则、依赖倒置原则
Q:AOP , IOC懂吗?
AOP:与传统OOP对比,面向切面,传统的OOP开发中代码逻辑是自上而下的,在自上而下的过程中会产生一些横切性的问题,这些横切性的问题和我们主业务逻辑关系不大,会散落在代码的各个地方,造成难以维护,AOP的思想就是把业务逻辑和横切的问题进行分离,从而达到解耦的目的,是代码的重用性和开发效率提高
既不修改对象,也不替换对象,还能扩展原有对象的功能
IOC :主要是针对接口,抽象编程,而不是具体实现。要做到绝对的依赖注入,就要配置文件,使用反射,针对接口,外部dll实现功能
UGUI
Q:UGUI优化
答:Canvas负责将其子节点的UI元素的网格合并,并生成相应的渲染命令发送到Unity的图形管道,当UI发生了变化,它就要执行一次Batch给GPU进行渲染。Canvas只影响其子节点,但是不会影响子Canvas
重建:
重新计算一个Layout组件子节点的适当位置,或者可能的大小Graphic发生了变化,比如:大小,旋转,文字的变化,图片的修改等,都会引起Rebuild
优化:
动静分离少对Canvas增加、删除、显示、隐藏;少对Canvas进行颜色,材质,纹理等的改变预加载UI模块不直接关闭,设置UI界面为其他的layer不需要进行事件接收的组件,取消勾选Raycaster Target不适用富文本的Text,取消勾选Rich Text,不使用Best Fit用TextMashPro 代替OutLine,shadow组件
Q:讲讲动态图集,你有研究过吗?
答:所谓动态图集,就是在游戏的运行过程中,生成一张或者N张较大的Texture图集,Image加载进来的texture不直接用,而是将Texture信息拷贝到这张大的Texture上,以达到n个Image共用一张Texture,从而可以合并Draw Call
整个流程是:加载—资源管理—拷贝Texture—显示
将一个Texture2D渲染到另外一张Texture2D有四种方法:
(1) Graphics.Blit (Texture source , RenderTexture dest, Material mat)
原理:使用Shader进行复制Texture2D
缺点:大材小用,仅仅是复制Texture就要调用GPU结构走一遍渲染管道流程,还是太重了,Graphics.Blit—般用于后处理那种复杂功能
(2 ) GL编程Low-level graphics library ,作用跟Graphics_Blit差不多,但是实现起来比较麻烦
(3 ) Texture2D的GetPixels( ) 以及 SetPixels( )
优点:相对Graphics.Blit函数消耗少了很多,CPU层面和GPU层面不是很费性能
缺点:调用GetPixels的时候,这个Texture2D图片必须是可读写,也就是要勾选Read/Write Enabled
(4) Graphics.CopyTexture
GPU级别的接口函数,跟CPU基本没什么关联,所以无论是效率还是内存,都是最优解。然后事实上,并不是所有手机的GPU都支持这个接口,通过Systemlnfo.copyTextureSupport接口可以得知GPU是否支持这个接口(PVRTC这种纹理压缩格式,就不支持这个接口)
Q:讲讲UGUI的Draw Call
答: UGUI主要是根据depth来判断Draw Call数量(还有纹理,材质,shader等)
按照Hierarchy节点顺序,从上往下进行深度分析(深度优先)且没有任何其他渲染元素与它相交深度 = 0有其他元素跟它相交,则找到相交渲染元素的最大深度的渲染元素,判断是否能够与它合批,如果可以,则它等于最大深度,否则深度= 最大深度+ 1按照Hierarchy顺序来的对深度进行排序,然后根据材质的Instance ID,纹理的Instance ID 排序
优化点:
一个Canvas组建下的元素才会合批,不同Canvas不会合批有时候会为了合并层级,我们需要给Text垫高层级一个节点的RectTransform的值不会影响合批,Image和rawImage如果引用了相同的texture,可以合批
Q:说说Mask 与 RectMask2D
答:Mask与Image组件配合工作,根据Image/Text等UI组件内容来定位显示范围,所有该组件的子级元素,超出此区域的部分会被隐藏(包括UI的交互事件)
Mask会赋予UI一个特殊的材质,这个材质会给UI的每个像素点进行标记,将标记结果存放在一个缓存内(这个缓存叫做 StencilBuffer);当子级UI进行渲染的时候会去检查这个 Stencil Buffer内的标记,如果当前覆盖的区域存在标记则进行渲染,否则不渲染(CPU)
RectMask2D 不需要UI组件内容作为裁剪区域,所以它的裁剪区域永远是矩形大小,进而CPU计算元素是否在矩形区域之内,如果在,则节点下常规方式合批之后进行顶点裁剪,如果一个元素完全不在矩形区域,则这个元素不会被渲染。(GPU实现)
Mask内的元素和外面的元素不会合批(但可以和其他Mask组件合批)
Mask内的元素可以正常合批
Mask会生成两个 DrawCall
Mask内的元素和外面的元素不会合批
Mask内的元素可以正常合批
RectMask2D不会像Mask一样产生额外的DrawCall
Lua
Q: Lua GC 的基本类型有哪些,哪些类型可以有元表?
答:nil,boolean, number,string,function,userdata,thread,table
每一种数据类型都有元表,但是只有table类型的元表可以重新修改,也可手动给suerdata添加元表
Q:Lua中的原表是什么
在 Lua table 中我们可以访问对应的key来得到value,但是却无法对两个 table 进行操作。因此 Lua 提供了元表,允许我们改变table的行为,每个行为关联了对应的元方法
setmetatable:设置元表,getmetatable: 获取元表
元方法:index,newIndex,call,mode,gc,pairs,tostring,运算符
Q: Lua GC 的垃圾回收机制?
答:lua使用的是经典的标记清扫算法;Lua所有类型的对象都统一为Tvalue;所有动态分配的对象串连成一个链表(或多个);Lua里的注册表,主线程等,这些根集对象再去引用其他对象,由此展开成对象的关系结构
Lua的垃圾回收周期共分为四个阶段:标记、整理、清扫、收尾
标记阶段:Lua会首先将根集合中的对象标记为活跃,然后将可以通过根节点访问到的对象也标记为活跃
整理阶段:Lua会遍历所有的userdata,找出未被标记且有__gc元方法的userdata,将它们标记为活跃,并放入单独的列表中。再根据所有的弱引用table,删除那些未被标记为活跃的key或者value
清扫阶段:Lua遍历所有对象,如果当前对象未被标记,就收集它,否则清除它的标记
收尾阶段:根据上面生成的userdata列表来调用终结函数(类似C#的析构函数)
Q:Lua如何实现面向对象?
封装:通过Table + function 实现,new 函数中创建一个新table,设置原表添加函数
继承:在子类的构建函数中调用基类的构造函数,为子类设置metatable,设置其metatable为父类,并将父类的 __index 设置为其本身的技术实现的
多继承:index设置为一个函数,从多个父类中搜索
Q:. 和 :的区别是什么?
答 :(冒号:)是(点: )的语法糖省略第一个参数,指向self
Q:Lua 如何实现协程,对称和非对称协程?
线程:抢占式多任务机制,是一个相对独立的、可调度的执行单元,是系统独立调度和分配CPU的基本单位。它由操作系统来决定执行哪个任务,在运行过程中需要调度,休眠挂起,上下文切换等系统开销,而且最关键还要使用同步机制保证多线程的运行结果正确
协程:协作式多任务机制,协程之间通过函数调用来完成一个既定的任务。它由程序自己决定执行哪个任务,只涉及到控制权的交换(通过resume-yield),同一时刻只有一个协程在运行,而且无法外部停止。通俗来说,协程就是可以用同步的方式,写出异步的代码
协程(Coroutine)拥有4种状态:
运行(running)如果在协程的函数中调用status,传入协程自身的句柄,那么执行到这里的时候才会返回运行状态挂起(suspended)调用了yeild或还没开始运行,那么就是挂起状态正常(normal)如果协程A重启协程B时,协程A处于的状态为正常状态停止(dead)如果一个协程发生错误结束,或正常终止。那么就处于死亡状态(不可以再重启)
Lua的协程是一种非对称式协程,又或叫半协程,因为它提供了两种传递程序控制权的操作:1. 重启调用协程,通过coroutine.resume实现;2. 挂起协程并将程序控制权返回给协程的调用者,即通过coroutine.yield实现。对称式协程,只有一种传递程序控制权的操作,即将控制权直接传递给指定的协程
协程(Coroutine)具有两个非常重要的特性:1. 私有数据在协程间断式运行期间一直有效;2. 协程每次yield后让出控制权,下次被resume后从停止点开始继续执行
Q:Lua userdata了解吗?
userdata本身只是一个指针(light userdata),或一块受Lua管理的内存块(full userdata),它没有任何预定义行为,在Lua看来它就是一个值,你需要提供配套的C函数去操作它。一般可以用一个对应的元表来判断userdata的类型,以及用一些方法操作userdata的属性
Q: Lua table的底层实现
table支持任意类型的key和任意类型的value;为了能高效的读取和设值,lua采用了数组+哈希链表的组合方式实现table
区分数字类型key和其他类型的key
数字类型的key的值,直接放入数组内(如果key大于数组长度,则当它是其他类型行的key)
其他类型的key,通过哈希获得哈希值,存入对应的哈希链表内
哈希链表处理冲突,采用的是开放地址+拉链法结果的方式,采用lastfree来定位冲突的key所放位置
往table中插入新值,先检测key的主位置(key的哈希值在node中的位置)是否为空
如果主位置为空,就直接插入,主位置不为空,检查占领该位置的key的主位置是不是在这个地方,如果不在,则将该key移动到其他空闲位置,将要插入的key插入到这个位置中。如果在这个地方,则将要插入的key插入到一个空槽中
如果找不到空闲位置放新键值,就rehash函数,扩增hash表的大小,再找出新位置,再调用luaH_set把要插入的key插入到新的哈希表中,直接返回LuaH_set的结果
Q:Lua 中的闭包
闭包主要由以下2个元素组成:
函数原型:一段可执行代码。在Lua中可以是lua_CFunction,也可以是lua自身的虚拟机指令
上下文环境:在Lua里主要是Upvalues和env
Upvalues是在函数闭包生成的时候(运行到function时)绑定的Upvalues在闭包还没关闭前(即函数返回前),是对栈的引用,这样做的目的是可以在函数里修改对应的值从而修改Upvalues的值闭包关闭后(即函数退出后),Upvalues不再是指针,而是值
Q: dofile/require/loadfile的区别,怎么实现热更lua代码?
loadfile 只编译不运行 , dofile 执行 ,require 只执行一次
function reload_module(module_name)
local old_module = _G
package.loaded = nil
require (module_name)
local new_module = _G
//设置upvalue
for i = 1, math.huge do
local name, value = debug.getupvalue(old_module, i)
if not name then break end
debug.setupvalue(new_module, i, value)
end
//然后将所有新值赋值给旧的类,以便维持正确的引用
for k, v in pairs(new_module) do
old_module = v
end
package.loaded = old_module
end
Q:Lua与C#如果实现相互调用
C#与Lua进行交互主要通过虚拟栈实现,栈的索引分为正数与负数,若果索引为正数,则1表示栈底,若果索引为负数,则-1表示栈顶
因为C#不可以热更新,(lua可以当作文本资源,然后运行时才编译);
一些比较特殊的关键逻辑代码可以复用,比如 属性计算,战斗系统等与纯逻辑算法,能最大程序做到客户端服务端同步
1. 到现在为止,可以知道整个c#函数在导出过程中的操作,在启动时候如何通过程序集和反射来实现动态的加载,最后Lua的虚拟机中都会注册前面导出的类文件的相关函数和属性。lua文件在执行的时候,是会编译成字节码在lua的虚拟机中执行的,这样lua的字节码和c#的导出文件,都在同一个环境中执行,调用pcall就可以相互的执行和调用了
2. 如果C#要调用Lua中的函数,则
首先要在Lua虚拟机中加载该函数(LuaState.DoFile)拿到目标函数(LuaState.GetFunction)执行目标函数(LuaFunction.Call)
谈谈你理解的ECS
对于 CPU 来说“内存”已经是一个非常缓慢的硬件了,针对这个问题CPU 内部集成了越来越多的Cache,CPU读取数据,会优先从缓存中读取,如果没有,则会整块的从内存中拷贝到缓存中,如果缓存中依然找不到需要的数据,就会造成 Cache Miss,会重新去内存拷贝数据块。所以将数据以 Cache 友好的方式组织,可以显著提升程序性能
面向对象与面向数据
ECS 架构中的 Component 只包含数据。Entity 也不是面向对象那样把组件、行为封装起来,而是只对应一个 ID。这个设计使得所有同类型组件使用连续的内存得以成为可能(这也是Entitas + C# JobSystem一起工作,效能强大的原因),大大提高 CPU 的 Cache 命中率。而且,系统直接解耦,可以使用多线程并行操作
ECS优势
轻松并行化--------清晰的系统输入输出和小粒度
良好的缓存效率------通过顺序访问组件数组获得空间局部性(动态场景读取十分方便)
耦合度低---------因为系统仅交换数据
易于测试---------因为系统没有状态
Unity DOTS=entities+ mathematics + C# JobSystem + Burst Complier + C#(HPC)
CPU&GPU
引擎模块性能开销和自身代码性能开销。其中,引擎模块中又可细致划分为 渲染模块、动画模块、物理模块、UI模块、粒子系统、加载模块和GC调用等等
渲染模块:Batche,简化资源等
UI模块: 目前采用的fairyGUI,性能问题不大
加载模块:1. 场景切换的时候,Resources.UnloadUnusedAssets 消耗;2. 资源加载,加载量,资源格式等都会影响 3. Instantiate实例化(分帧实例化,其次如果脚本序列化信息很多,实例化时间也会比较长)
自身代码消耗:当数据从托管代码传递回引擎代码时,CPU 可能需要将数据从托管运行时使用的格式转换为引擎代码所需的格式,这种转换称为编组(marshlling)。避免昂贵的Unity API的调用(SendMessage(),Find(),Transform,Vector2 和 Vector3,Camera.main)
解决CPU瓶颈
开启Graphics Jobs(Unity将那些本该由主线程处理的渲染任务分配到辅助线程中)减少Batch次数,减少 SetPass Call 数量减少需要渲染的物体数量(减少场景中可见物数量,距离摄像机多远就不显示,遮挡剔除)减少每个物体渲染次数(实时光,阴影,反射等)合批显示物体开启GPU Skinning,蒙皮网格很消耗性能
解决GPU瓶颈
一般GPU性能最常见的问题是填充率限制,显存带宽,顶点处理
填充率
填充率是指 GPU 在屏幕上每秒可以渲染的像素数量;如果我们游戏是因为填充率导致的GPU性能问题,那么意味着我们游戏每帧尝试绘制的像素数量超过了 GPU 的处理能力,检查是否填充率引起的GPU性能问题其实很简单:
打开Profiler,注意GPU时间重新设置渲染分辨率 Screen.SetResolution(width,height,true)重新打开Profiler,如果GPU性能提升,那么大概率就是填充率的原因了
如果是填充率问题,那么我们有如下几个方法解决这个问题
优化片元着色器应该使用针对移动平台的 the mobile shaders如果使用的是定制的shader,那么应该尽量优化它Overdraw 是指相同位置的像素被绘制了多次。一般发生在某个物体在其他物体之上。最常见引起 Overdraw 的因素是透明材质,未优化的粒子以及重叠的UI元素
显存带宽(Memory bandwidth)
显存带宽是指GPU读写专用内存的速度,如果我们的游戏受限于显存带宽,通常意味着我们使用的纹理太大了,我们可以用如下方法检测是否是显存带宽问题:
打开Profiler,并关注GPU各项数据Project Settings->Quality->Texture Quality,设置纹理质量,降低当前平台的纹理质量重新打开Profiler,重新查看GPU各项数据。如果性能改善,那么就是显存带宽问题
如果是显存带宽问题,那么我们需要降低纹理的内存占用:
纹理压缩技术可以同时极大的降低纹理在内存中的占用Mipmaps,多级渐远纹理是 Unity 对远处物体使用低分辨率纹理。Unity场景视图中的 The Mipmaps Draw Mode 允许我们查看哪些物体适用多级渐远纹理。(Mipmap最主要的目的是为了提高模型质量; 纹理很大,相邻的两个屏幕像素采样的纹素差的很远,此时会大大降低缓存命中率)
顶点处理
顶点处理是指 GPU 处理网格中的每一个顶点,顶点处理的消耗主要受两个因素的影响:顶点数量以及操作每个顶点的复杂度。有一些方法可以优化这个:
降低网格复杂度使用法线贴图模拟更高几何复杂度的网格如果游戏未使用法线贴图,在网格导入的设置中,可以关闭顶点的切线,这可以降低每个顶点的数据量LOD,当物体远离摄像机的时候,降低物体网格的复杂度,可以有效的降低 GPU 需要渲染的顶点数量顶点着色器,是一段shder代码,降低它的复杂度,可以提升性能
C#
Q:什么是.NET?什么是CLI?什么是CLR?IL是什么?它是如何工作的?
.NET是微软的以CLR为基础,支持多种语言(C#、http://VB.NET、C++、Python等)开发的编程平台
CLR (Common Language Runtime通用语言运行时,核心部分是CLI 通用语言结构) 和Java虚拟机一样也是一个运行时环境主要包括:基类库支持,内存管理,线程管理,垃圾回收,安全性,类型检查,异常处理,即时编译
IL是微软.NET平台上衍生出来的一门中间语言,.NET平台上的各种高级语言(如C#,VB,F#)的编译器会将各自的语言转化为 IL
CTS (Common Type System 通用类型系统) , CLR所有功能的实现都是基于类型的。通过类型,为了一种编程语言写的代码能与用另一种编程语言写的代码沟通
Q:类(class)和结构(struct)的区别是什么?它们对性能有影响吗?在自定义类型时,您如何选择是类还是结构?
Class是引用类型,Struct是值类型。Struct不可以被继承,但是可以Override它基类的方法;也可以实现接口。比如:如果想用Struct当作字典的key,但是又想避免有装箱和拆箱等操作,则可以实现接口IEquatable的Equals方法,则可以避免装箱和拆箱操作,以下是一些详细的区别
值类型对象未装箱和已装箱两种形式,而引用类型总是处于已装箱形式值类型从System.ValueType派生,重写了Equals和GetHashCode,由于默认的实现存在性能问题,所以在自定义值类型的时候,如果有用到,应重写这两个方法自定义的值类型不应该有虚方法引用类型的变量的值是堆上的一个对象的地址,值类型包含的是值引用类型赋值是复制内存地址,而值类型赋值则是逐一字段复制未装箱的值类型,不在堆上分配内存。而引用类型在堆上分配内存因为未装箱的值类型,没有同步索引块,所以不支持多线程同步虽然未装箱的值类型,没有类型对象指针,但是仍可以调用由类型继承或重写的虚方法(比如:Equals,GetHashCode或ToString),但是请注意:调用这些方法可能会产生装箱。具体如下:
a. 如果结构体重写了虚方法,并且虚方法内没有内部调用父类的方法,那么结构体实例调用这个重写的虚方法就不会装箱
b. 如果调用父类的非虚方法,那么一定会产生装箱,一定得需要一个类型对象指针,以定位父类类型的方法表才能执行该方法
以下情况适合值类型:
类型具有基元类型的行为,意思就是结构十分简单,属性没有可变类型类型不会派生子类,也不从其他类继承类型的实例较小 ( 因为值作为参数传递或者作为方法返回值的时候,会拷贝整个结构,对性能有损害,所以类型的实例大小是个重要考虑指标 )
关于值类型的装箱问题,发生装箱操作时,在内部发生的事情如下:
在托管堆中分配好内存,分配的内存量是值类型各个字段需要的内存量加上两个额外(类型对象指针和同步索引块)需要的内存量将值类型的字段复制到新分配的内存中返回对象的内存地址,现在值对象变成了引用类型
Q:在.NET程序运行过程中,什么是堆,什么是栈?
栈负责追踪那些在我们代码中执行的内容。而堆则负责追踪我们的对象。栈类似于代码执行过程的一个容器,而堆则类似于保存数据的容器
引用类型总是在堆上创建;值类型和指针类型总是在它声明的地方创建
栈区:存放函数的参数、局部变量、返回数据等值,由编译器自动释放
堆区:存放着引用类型的对象,由CLR释放
结构对象也有可能会分配在堆上,在装箱的时候会发生。应尽量避免这种情况的发生,因为这样做会在堆上产生垃圾
Q:泛型的作用是什么?它有什么优势?它对性能有影响吗?
面向对象的好处是代码重用,对一个类来说,可以继承基类的所有能力,同时可以重写虚方法,或者添加一些新方法,就可以定制该类的行为。而泛型,则是支持另外一种形式的代码重用,即”算法重用”
泛型具有如下优势:1. 源代码保护2. 类型安全(在编译期就能过滤不符合的类型数据)3. 更佳的性能 ( CLR不再需要执行装箱操作;也不需要类型转换,这对于提高代码运行速度很有帮助 )
Q:异常的作用是什么?.NET BCL中有哪些常见的异常?在代码中您是如何捕获/处理异常的?在“catch (ex)”中,“throw”和“throw ex”有什么区别?您会如何设计异常的结构,什么情况下您会抛出异常?
异常的作用是捕获代码错误,BCL常见的异常都是派生自System.Exception
方式1:throw;可追溯到原始异常点,获取所有异常(范围粒度较大) (编译器会警告,定义的ex未有使用)
方法2: throw ex;会将到现在为止的所有信息清空,认为你catch到的异常已经被处理了,只不过处理过程中又抛出新的异常,从而找不到真正的错误源
方法3:throw new Exception("错误信息…",ex);经过对异常重新包装,会保留原始异常点信息推荐采用此种方式抛出异常
Q:List<T>和T[]的区别是什么,平时你如何进行选择?
List是的泛型列表,T[]是泛型数组,在你不确定数据的长度的时候,一般都用List,它使用方便,本身自带的方法也多,而且他是无限长度的,可以根据需要不断地追加。需要注意的是数组本身是继承自Object的所以他总是在堆上分配;其实List<T>,就是居于T[]实现的,只不过包装了很多方法,所以我们使用起来很方便
LinkedList,Stack一般用于深度遍历 , Queue 一般用于广度遍历
Q:抽象类和接口有什么区别?使用时有什么需要注意的吗?如何选择是定义一个抽象类,还是接口?什么是接口的“显式实现”?
接口:描述的是Can-do,是一类行为,而不是针对具体某个类。比如: IDispose,IEnumerable等都是描述一类行为
抽象类:为了继承而存在的,如果你定义了一个抽象类,却不去继承它,那就没有任何意义。抽象类不可用来实例化
拿个具体的事例,比如说动物Animal ,这是一个高度抽象的概念。说到动物,有些人想到的是狗,有些人想到的是猫。所以Animal这个类没办法具体实例化出来,应该设计为抽象类,不继承它就没有意义。那有些动物会飞,有些动物会游,这表示有些动物具有某些行为,应该设计成接口,比如:Ifly, Iswim等
在考虑是使用抽象类还是接口的时候,还要考虑易用性和后期版本控制。因为如果要修改接口(比如,添加新的方法),那么实现了该接口的所有类都必须实现这个新方法。所以改动很大
接口的显示实现,是指继承的接口的方法 与类本身的方法重名而采取的措施,就是将定义方法的那个接口的名称作为方法名的前缀(例如:IDispose.Dispose)。注意,在C#中定义一个显示接口方法时,不可以指定可访问性(比如:public或privtae),也不能标记为virtual,所以它不能被重写。这是因为显示实现的方法并非是该类类型的对象模型的一部分,它是将一个接口连接到一个类型上。
Q:什么是元编程,.NET有哪些元编程的手段和场景?什么是反射?能否举一些反射的常用场景?有人说反射性能较差,您怎么看待这个问题?有什么办法可以提高反射的性能吗?
我理解的元编程就是用代码生成或者操控代码,一般在大型框架(自动生成所需代码),或者在IDE插件中,还有词法语法分析器中经常使用
反射一般是指在运行时发现新的类型信息(比如新的程序集内的类型),创建新类型的实例,以及访问该类型的成员。元数据是用一系列表来存储,生成一个程序集或者模块时,编译器会创建一个类型定义表,一个字段定义表,一个方法定义表以及其他表。利用System.Reflection命名空间的一些类型,可以写代码来解析这些元数据表,甚至可以创建一个对象模型
最常用的场景是:1. 查看一个类的成员(VS的类查看器,ILDasm.exe等) 2.写自定义类库,比如早期的ulua框架,就是使用反射实现的
反射的问题:1. 反射会造成编译时无法保证类型安全2. 反射速度慢
为什么反射性能差?
因为所有的操作都是基于字符串的,反射会扫描程序集的元数据,用字符串去做对比,字符串比较本身就性能比较差(其次,字符串搜索执行不区分大小写,所以就更慢)使用反射调用方法,首先必须将实参pack成一个数组;在内部,反射必须将这些实参unpack到线程栈上,其次,CLR还要检查实参是否类型正确。最后,CLR必须确保调用者有正确的安全权限来访问被调用的成员
避免采用方法的方法:
让类型从一个编译时已知的基类型派生。在运行时,构造派生派性的一个实例,将对它的引用放到基类型的一个变量中(利用转型),再调用基类型定义的虚方法让类型实现一个编译时已知的接口。在运行时,构造类型的一个实例,将对它的引用放到接口类型的一个变量中(利用转型),再调用接口定义的方法
Q:委托是什么?匿名方法是什么?
委托是.Net提供的一种回调函数机制。委托会确保类型安全,而且还允许顺序调用多个方法,并支持调用静态方法和实例方法。每一个委托,实际上都对应一个实际的类类继承自System.MulticastDelegateAction , 无返回泛型委托,Func泛型委托;最多16个参数匿名方法其实就是直接传递方法体,而不需要定义方法,现在基本不会使用匿名方法,都是用lambda表达式
Q:为什么C# 不需要Include头文件?
C# 程序由至少一个文件组成,程序中声明了包含有成员的类型并可命名空间化。类型有如类(Classes)和接口(Interfaces),成员则有如字段(Fields)、方法(Methods)、属性(Properties)和事件(Events)。当 C# 程序被编译,它们将被物理地打包到一个程序集中。程序集的后缀名如 .exe 或 .dll,这取决于它们是实现了应用(Applications)还是类库(Libraries)
由于程序集是一种功能上包含有代码和元数据的自描述单元,故 C# 不需要使用 #include 指令和头文件(header files)。其内包含有公开类型和成员的特殊的程序集在程序被编译时能被轻松引用
程序集所包含的可执行代码由 IL(Intermediate Language)指令和符号信息元数据(metadata)构成。在被执行前,程序集内的 IL 代码由CLR自动即时编译(JIT compiler)转换为针对处理器定制的代码
Q:关于线程同步相关
volatile
volatile多用于多线程的环境,当一个变量定义为volatile时,读取这个变量的值时候每次都是从momery里面读取而不是从cache读。这样做是为了保证读取该变量的内容都是最新的,而无论其他线程如何更新这个变量
volatile 关键字指示一个字段可以由多个同时执行的线程修改。 声明为 volatile 的字段不受编译器优化(假定由单个线程访问)的限制。 这样可以确保该字段在任何时间呈现的都是最新的值
volatile 修饰符通常用于由多个线程访问但不使用 lock 语句对访问进行序列化的字段
volatile 关键字可应用于以下类型的字段:
引用类型
指针类型(在不安全的上下文中)。 请注意,虽然指针本身可以是可变的,但是它指向的对象不能是可变的
基元类型,如 sbyte、byte、short、ushort、int、uint、char、float 和 bool
具有以下基类型之一的枚举类型:byte、sbyte、short、ushort、int 或 uint
已知为引用类型的泛型类型参数
IntPtr 和 UIntPtr
可变关键字仅可应用于类或结构字段,不能将局部变量声明为 volatile
但volatile并不能实现真正的同步,因为它的操作级别只停留在变量级别,而不是原子级别。如果是在单处理器系统中,是没有任何问题的,变量在主存中没有机会被其他人修改,因为只有一个处理器。但在多处理器系统中,可能就会有问题。 每个处理器都有自己的data cache,而且被更新的数据也不一定会立即写回到主存。所以可能会造成不同步,但这种情况很难发生,因为cache的读写速度相当快,flush的频率也相当高,只有在压力测试的时候才有可能发生,而且几率非常非常小
lock
lock是一种比较好用的简单的线程同步方式,它是通过为给定对象获取互斥锁来实现同步的。它可以保证当一个线程在关键代码段的时候,另一个线程不会进来,它只能等待,等到那个线程对象被释放,也就是说线程出了临界区
lock的参数必须是基于引用类型的对象(字符串除外,因为字符串被CLR“暂留”,就是说整个应用程序中给定的字符串都只有一个实例,因此更容易造成死锁现象)。最好避免使用public类型或不受程序控制的对象实例,因为这样很可能导致死锁。建议使用不被“暂留”的私有或受保护成员作为参数。其实某些类已经提供了专门用于被锁的成员,比如Array类型提供SyncRoot,许多其它集合类型也都提供了SyncRoot
推荐参数private static objectojb = new object();如果一个类的实例是public的,最好不要lock(this), 如果MyType是public的,不要lock(typeof(MyType))
System.Threading.Interlocked
对于整数数据类型的简单操作,可以用 Interlocked 类的成员来实现线程同步,存在于System.Threading命名空间。Interlocked类有以下方法:Increment , Decrement , Exchange 和CompareExchange 。使用Increment 和Decrement 可以保证对一个整数的加减为一个原子操作。Exchange 方法自动交换指定变量的值。CompareExchange 方法组合了两个操作:比较两个值以及根据比较的结果将第三个值存储在其中一个变量中。比较和交换操作也是按原子操作执行的
int i = 0 ;
System.Threading.Interlocked.Increment( ref i);
Console.WriteLine(i);
System.Threading.Interlocked.Decrement( ref i);
Console.WriteLine(i);
System.Threading.Interlocked.Exchange( ref i, 100 );
Console.WriteLine(i);
System.Threading.Interlocked.CompareExchange( ref i, 10 , 100 );
输出:1, 0 , 100 , 10
Monitor
Monitor类提供了与lock类似的功能 , 不过与lock不同的是,它能更好的控制同步块 , 当调用了Monitor的Enter(Object o)方法时,会获取o的独占权,直到调用Exit(Object o)方法时,才会释放对o的独占权,可以多次调用Enter(Object o)方法,只需要调用同样次数的Exit(Object o)方法即可,Monitor类同时提供了TryEnter(Object o,)的一个重载方法 , 该方法尝试获取o对象的独占权,当获取独占权失败时,将返回false
但使用 lock 通常比直接使用 Monitor 更可取,一方面是因为 lock 更简洁,另一方面是因为 lock 确保了即使受保护的代码引发异常,也可以释放基础监视器。这是通过 finally 中调用Exit来实现的。事实上,lock 就是用 Monitor 类来实现的
ReaderWriterLock
在考虑资源访问的时候,惯性上我们会对资源实施lock机制,但是在某些情况下,我们仅仅需要读取资源的数据,而不是修改资源的数据,在这种情况下获取资源的独占权无疑会影响运行效率,因此.Net提供了一种机制,使用ReaderWriterLock进行资源访问时,如果在某一时刻资源并没有获取写的独占权,那么可以获得多个读的访问权,单个写入的独占权,如果某一时刻已经获取了写入的独占权,那么其它读取的访问权必须进行等待
a. AcquireReaderLock(): 这个重载方法获取一个读者锁,接受一个整型或者TimeSpan类型的timeout 值。timeout是一个检测死锁的利器
b. AcquireWriterLock():这个重载方法获取一个写者锁,接受一个整型或者TimeSpan类型的timeout 值
c. ReleaseReaderLock(): 释放读者锁
d. ReleaseWriterLock(): 释放写者锁
同步事件和等待句柄
同步事件有两种:AutoResetEvent和 ManualResetEvent。它们之间唯一不同的地方就是在激活线程之后,状态是否自动由终止变为非终止。AutoResetEvent自动变为非终止,就是说一个AutoResetEvent只能激活一个线程。而ManualResetEvent要等到它的Reset方法被调用,状态才变为非终止,在这之前,ManualResetEvent可以激活任意多个线程
可以调用WaitOne、WaitAny或WaitAll来使线程等待事件。它们之间的区别可以查看MSDN。当调用事件的 Set方法时,事件将变为终止状态,等待的线程被唤醒
Q: ref和out?
相同点:ref和out都是按地址传递,使用后都将改变原来参数的数值
不同点:ref将参数的参数值和引用都传入方法中 , 所以ref的参数的初始化必须在方法外部 进行 , 也就是ref的参数必须有初始化值 , 否则程序会报错;out不会将参数的参数值传入方法中 , 只会将参数的引用传入方法中 , 所以参数的初始化工作必须在其对用方法中进行 , 否则程序会报错
Q:谈谈协程,有用过么?
协程其实是分帧执行的类似语法糖。协程方法,在编译以后,会生成一个对应名字的类,继承了IEnumerator<object>, IEnumerator, Idisposable 这三个接口。最主要实现了 MoveNext() 方法,Current 属性。而实现原理则是在MonoBehaviour 内的Update和LateUpdate方法之间,根据Current的值是否调用该类的MoveNext()方法
当MonoBehaviour被摧毁以后,协程也会被终止。但是enabled =false,协程会继续执行
协程的使用规范:需要的时候使用,用完就销毁。不要用对象池保存,因为协程会引用MonoBehaviour类(包括变量,方法等),所以如果不销毁,这些引用会一直存在,从而导致无法被GC。有点类似闭包,以及委托
Q:C# 字符串原理
字符串具有原子性(也就是不可更改性),任何改变字符串的值的行为都不会成功,只会产生一个新的字符串对象。因为在创建的时候,固化了char数组的大小,而且是只读属性作为参数传递,虽然字符串是引用类型,但是因为它的原子性。在方法内对字符串更改,不会影响到原本字符串指向的地址,除非加 ref字符串变量+“string”生成的字符不会检查字符串缓存是否存在该字符,而是直接生成新的字符串对象,并返回结果地址String 类被sealed修饰,表示不能被继承
Addressable Asset System
简单对比
AssetBundle:把资源打包在一起,然后在游戏过程中再加载,可用来减小包体,做资源更新(区分不同平台,不同性能的设备)
缺点:需要开发者自己写资源管理(管理资源依赖,资源卸载等)
Adressable Asset System:目前主推的资源管理工作流,基于AssetBundle,但它囊括了资源管理(包括资源依赖,自动计算引用等)
缺点:它的优点,同时也是它的缺点。无法自定义资源管理。比如:让游戏跳转到指定版本,并加载当前版本资源
打包方式对比
AssetBundle打包方式: 使用BuildPipeline.BuildAssetBundles 对所有标记为ab资产的资源进行打包
打包完以后, ab文件是一个合集,在内部包含多个文件。所有ab都会生成一个关联的 .manifest 清单文件 ( 循环冗余校验,hash数值,和依赖项等信息 )
Addressable打包方式:Addressable跟AB的打包方式有所不同,因为它可以选择3种模式
Fast Mode: 直接加载文件而不打包,快速但Profiler获取的信息较少;在此模式下,我们实际上是用 AssetDatabase.LoadAssetAtPath 加载文件
Virtual Mode:虚拟模式:在不打包的情况下模拟ab的操作;与FastMode不同,您可以查看ab包含的资源资产
Packed Mode :实际上是从AB打包和加载;在这种模式下,实际构建并加载ab
加载资源对比
AssetBundle加载:先从硬盘或者网络加载ab包到非托管内存,再从ab包内加载所需资源
加载ab包:
LoadFromFile:从磁盘加载ab(官方推荐最佳实践)
LoadFromMemory:从字节数组加载ab(会有多份内存占用,不推荐)
UnityWebRequest 从网络上加载ab
从ab包加载资源:
LoadAsset 同步加载资源
LoadAssetAsync 异步加载资源
Addressable加载方式:把资源标记为可寻址,可寻址资源系统会给我们一个它的地址,然后我们可以根据这个地址去异步加载资源
通过可寻址地址或者AssetReference加载资源:
LoadAssetAsync 加载资源
InstantiateAsync 加载资源并直接实例化
我们也可以通过传入好几个地址或者标签来加载一系列资源(Addressables.LoadResourceLocationsAsync 资源查看API,比如可以先查看某个Label内所有的资源,获取想加载的某个资源的地址,然后根据这个地址去加载想要加载的资源)
卸载资源方式对比
AssetBundle卸载方式
首先,通过LoadFromFile加载的ab包,可以通过ab.Unload(false/true)来卸载,通过ab.LoadAsset() 加载的资源,则通过Resources.UnloadAsset(asset)来卸载,通过asset实例化的对象,直接Destroy( obj ) 销毁就行。如果有未主动卸载并且没有被引用的资源,可以通过Resources.UnloadUnusedAssets() 来释放
Addressable卸载方式
Addressable内部已经给我们做了引用计数管理,所有当我们释放对应资源时,只有当引用计数为0才会真正的卸载对应ab对象
不能实例化的资源卸载方式
Release<T>(obj) 直接释放T类型的资源,并减少引用计数
Release ( handle ) 释放由该句柄加载出来的资源,并减少引用计数
能实例化的资源卸载方式
ReleaseInstance ( instance )销毁游戏物体,并且减少引用计数
ReleaseInstance ( handle )释放由该句柄加载出来的资源,并减少引用计数
注意:实例化资源时可以传一个参数trackHandle(默认为true)。如果为true那么销毁实例化的资源用上面2个接口都可以,如果为false那么只能用第二个接口
热更资源方式对比
AssetBundle热更资源方式
去游戏版本服务器获取当前游戏版本号,然后根据版本号去版本服下载对应的资源更新列表,再与本地的资源列表对比(如果不存在或者被删除,最好可以做一个自检,这样可以预防玩家不小心误删了文件), 根据相差的资源去手动下载资源包
Addressable热更资源方式
Addressable也支持资源热更,但是不够灵活。它适合边下边玩,而不是在游戏刚进去时加载资源。因为我们把下载ab的实现交给了addressable,然后它的实现是当你在加载资源时找到这个资源的ab包,然后通过UnityWebRequestAssetBundle判断该ab包是不是已经下载如果下载那么直接从缓存目录加载,不然就下载到缓存目录再加载。所以我们要先加载资源才会去下载ab包,当然,它已经开源,我们可以魔改它
数据结构&算法
Q:C# 字典的实现
字典的实现,主要是两个数组;桶数组以及对象数组。对象是个结构体,包含哈希值,同哈希值的下一个对象的下标索引(默认-1),key,value;如果new 字典的时候,传入了字典大小,那么会初始化,否则会等到添加对象的时候初始化
ADD:设计的很巧妙,有个 freeCount和freeList来标记,当前对象数组,是否有空位置。如果有,优先放入空位置。然后判断哈希值,如果对应的桶内已经有值,那么头插法;桶的索引值变成当前新添加对象所在对象数组的索引,而它的next就是桶原来的索引值
REMOVE:最巧妙的地方,删除以后,该对象的next值等于freeList,然后新的freeList等于这个被删的对象的索引,这样的话,根据freeList就能找到所有被删以后的空位置
扩展:注意,字典没有容量因子的说法,一定要对象数组满了才会扩容。而且它扩容是当前长度*2,然后选中大于这个值的素数(C#给出了一个列表,从3开始),之所以用素数,是减少哈希冲突。当哈希冲突次数超过100以后,才会执行重哈希
Q: A*你具体说说
A*是比较经典的寻路算法,它的重点就是启发函数:总代价=当前点到起点的代价+当前点到终点的代价,代价可以是距离或者权重等。算法的核心是两个列表,开启列表和关闭列表,未走过的节点放入开启列表,已走过的节点放入关闭列表。每个节点的核心元素:当前点的总代价,当前点的父节点
页:
[1]