自我介绍
面试官,您好,我叫杨小龙,来自成都。我是一位热爱写技术博客的小玩家。面试官你可以在我的简历上查看我的博客地址,到目前位置我已经写了100多篇文章,博客上也有我的项目Demo演示视频。我之前干过前端、也干过后端java,由于对游戏开发的热爱,我毅然决然地选择了游戏开发。虽然在Unity游戏开发当中没有积累太长的时间,但是我的学习能够比较强。我能够通过GPT看懂官方文档,比如TextMeshPro、InputSystem、Cinemachine都是通过官方文档去学习的。也希望面试官给我这次机会,让我能够在游戏开发邻域走得更远。
C#基础
1. 重载和重写的区别
重载:同一个类中,方法名相同,但参数不同列表不同。
public class Example
{
public void Display(int number)
{
Console.WriteLine("Integer: " + number);
}
public void Display(string text)
{
Console.WriteLine("String: " + text);
}
}
重写:子类继承父类,子类重写父类的方法。基类必须用virtual、abstract修饰、子类必须override修饰
public class BaseClass
{
public virtual void Show()
{
Console.WriteLine("Base Class Show Method");
}
}
public class DerivedClass : BaseClass
{
public override void Show()
{
Console.WriteLine("Derived Class Show Method");
}
}
2. 值类型和引用类型的区别?
从存储方式来看:值类型存储在栈中。引用类型的引用在栈中,但引用的数据在堆中。
3. 请简述ArrayList和List<T>的区别?
从类型来看:ArrayList可以存储万物(object),但是每次取出来的时候,你必须告诉编译器这是什么类型,就是强制转换,不然很容易出错。而List<T>只装一种类型,取出直接使用,无需额外操作,比较安全。
从性能来看:ArrayList装值类型时,会将数据打包成对象(装箱),取出时在拆包(拆箱),这种操作会浪费时间和内存。而List<T>直接存储指定类型的数据,直接省去了打包、拆包的过程,速度更快,内存更省。
从代码安全来看:ArrayList不小心放入错误类型,只有到了运行时才会报错。而List会写代码时就会报错。问题早发现找解决。
4. 反射的实现原理?
反射有三个核心内容:元数据、Type类、动态操作。元数据就是在代码编译时,.Net为每个类型生成元数据(嵌入程序集中),这些元数据包含类名、字段、方法等详细信息。Type是反射的入口,通过typeof()、getType()方法可以获得类型的元数据,接下来通过反射的api可以动态实例化和方法调用等。
我之前在学习json持久化数据的时候,经常会用到反射,通过自定义的规则灵活的控制如何将数据写入到对象中,或者如何将对象中的数据读出来。比如进行类型判断时候,如何判断object里面存放的是字典、列表,都会使用反射。
5. .Net和Mono之间的关系
早期的.Net只能支持Windows系统,但一些民间高手觉得.Net好用,但无法支持其他的系统,于是开发了Momo,让.Net程序能够跨平台执行。
微软觉得Mono挺好的,2016 年微软收购了 Xamarin(Mono子公司),把 Mono 技术纳入官方体系。
微软后来自己开发了跨平台的 .NET Core(运行时叫 CoreCLR),2020 年后,微软合并了 .NET Framework 和 .NET Core,推出 .NET 5/6/7+,目标是“一个 .NET 走天下”。
接着我们我们在说一下Unity 和 .NET 的“爱恨情仇”,Unity 最初用 Mono 运行时运行 C# 脚本,但老版本 Mono 性能差、功能旧。于是Unity进行妥协改用 IL2CPP(把 C# 转成 C++ 再编译),提升性能,但调试更麻烦。
现如今:Unity 在慢慢向 .NET 官方靠拢,但历史包袱太重,无法直接换成 CoreCLR。
举一个例子:
Mono 像一辆“改装越野车”,强行把 .NET 程序运到各种平台,但开起来颠簸(性能一般)。
.NET Core (CoreCLR) 是微软造的“官方高铁”,专为跨平台设计,又快又稳,但只能在新轨道(现代系统)上跑。
Unity 像一座“老城”,路窄房子旧(基于旧 Mono),想拆了重建(换 CoreCLR)太难,只能一边修修补补,一边慢慢盖新区(支持新 .NET 特性)
6. 在类的构造函数前加入static会报什么错?为什么?
构造函数分为静态构造函数和实例构造函数。
静态构造函数必须使用static关键字,通过初始化静态成员,不能有访问修饰符和参数。因为静态构造函数由CLR自动调用(在类首次被访问时),程序员无法自己调用它,因此无需也不能控制其可见性。
实例构造函数初始化实例构造函数。
7. string类型比stringBuilder类型的优势是什么?
string的不可变性
string的底层数据结构是一个私有只读的char数组,每次进行拼接、替换、删除的时候,其实会创建一个新的char数组,并将旧的数组复制到新数组中,最终生成一个全新的字符串对象。
从内存来看:每次操作字符串时,都会在堆中生成一个新的空间,旧的对象将会有GC回收。
string的字符串驻留:CLR会对字面量字符串进行缓存,相同值的string共享同一内存地址。
stringBuilder是不会创建一个新的数组,将旧的数组复制到新的数组,而是会动态创建一个固定容量大小的数组,当超过当前的数组大小,则会扩容原来的两倍,比如8、16、32、64、128等大小,当然达到一定大小后,每次会添加8000大小左右。
所以说,如果大量的使用字符串拼接的话,使用stringBuilder的效率会很高。
总体来说:使用string+=内存分配高(耗时),而使用stringBulider内存分配低(比较快)。因为stringBulider是可变的,所以说它线程不安全,而string不变的,线程安全。如果作为Hash的键的话,优先使用字符串拼接的方式。
8. 结构体和类有何区别?
从内存分配机制来看:
结构体是值类型,存储在栈中。而类是引用类型,引用存储在栈中,引用对象存储在堆中。
栈中的分配和释放比较快,适用场景 频繁创建和销毁小型对象就可以使用结构体
而堆中需要垃圾回收进行释放。适合有复杂行为的对象。
从应用场景来看:
结构体适合轻量级,不可变的数据,例如坐标(Point
)、日期时间(DateTime
)、颜色(Color
)等。
类适合复杂行为与继承,而且值可以为null
从赋值来看:
结构体不可为空,默认值为字段默认值
类可以为空
9. 什么是里氏替换原则(LSP)?
父类可以使用多态调用子类的方法。(必须要保证子类不能比父类菜,一定比父类强。子类必须有父类的全部方法)
10. GC相关知识点?
GC的核心工作是清理内存中的垃圾。这个垃圾就是未被引用的对象。
那么GC如何去清理?标记可回收标签、清理标记的标签、整体剩下没有清理的对象。
GC有一个分代回收的机制:它将内存中的对象分为0代、1代、2代。
0代:新创建的小对象,因为90%的新对象很快变垃圾,GC会优先扫描
1代:经过第一轮扫描存活下来的对象
2代:长期存在内存中的对象(GC很少扫描,除非内存不够用了)
那么就要去研究一下什么时候去呼叫GC?
自动触发:可能内存快慢的时候,也可能是系统闲置的时候
手动触发:调用GC.Collect()函数
11. C#中所有类型的基类是什么?
C#中所有类型的基类是object,无论是值类型还是引用类型直接或间接继承System.Object,所以说所有类型都可以调用从object类继承的方法。比如ToString()、Equal()等方法。而值引用类型通过装箱和拆箱操作,也可以调用这些方法。比如。int a = 10. 调用 a.Tostring()。
12. 请描述interface与抽象类之间的不同?
如果你想要定义一套规则,并且运行多个类实现这些行为,你可以使用interface接口。
如果需要通用的行为实现,并希望子类继承这些规则,你可以使用抽象类。
从继承规则来看,一个类可以实现多个接口。而一个类只能继承一个抽象类。
从构造函数来看,接口不能有构造函数,而抽象类可以使用构造函数,用于初始化子类共享的状态。
从访问修饰符来看,接口访问修饰符只能是public,而抽象类有可以有不同的访问修饰符。
举个例子,鸟类会飞,但是有部分鸟不会飞,比如鸵鸟。所以说我们可以定义一个接口,可以实现飞的动作。
各种各样的敌人,都有血量、攻击力、防御力,这种方式可以抽取所有敌人所共有的,做出抽象类,所有的敌人都会继承这个抽象类。
13. C#中有哪些常见的容器,它们各自的特点是什么?
数组:固定大小的容量,一但创建就无法更改大小
List列表:动态数组,有一个扩容机制,根据扩容因子创建一个新的数组,将旧的数组复制到新的数组中。
字典Dictionary:结构是Key-Value键值对的结构,且key不能够重复。通过键快速的查找对应的值(复杂度为O(1)),但是不会排序,可以使用SortedDictionary或者OrderedDictionary。
HashSet:可以存储唯一的元素
SortedSet<T>:容器唯一,且可以进行排序
队列:先进先出
栈:先进后出
14. c#中委托和事件之间的区别是什么?
委托是存储方法的容器,通过关键delegate声明。如果你想传递方法作为参数、或者想要调用多个方法的时候,就可以使用委托。
事件就是对委托的一次封装。提供了一种发布-订阅模型。如果你想要监听某个事情的发生,就可以使用事件。
从封装性来看:委托可以通过外部调用,也可以通过外部替换。灵活性很强,但会导致注册的方法容易被覆盖。而事件只能通过+=或-=注册事件,无法直接调用事件,遵循解耦的设计原则。
15. 堆和栈之间的区别是什么?
从分配方式来看:
栈:连续且固定的内存大小,由编译器自动分配和释放。
堆:动态的且不连续的(灵活性强),通过new去创建,有垃圾回收机制GC自动管理
从生命周期来看:
栈:函数类的局部变量,由函数执行完返回后自动释放(直接弹出栈帧,释放非常快)
堆:对象本身的生命周期或者是垃圾回收机制来释放(内存释放有延迟)
从访问速度来看:
栈:访问速度快(内存连续)
堆:访问速度慢(内存不连续)
从应用场景来看:
栈:值类型,存储大小比较小
堆:引用类型,存储大小比较大
16. 虚函数的实现原理?
虚函数是通过方法表实现多态的。
每个类在内存中都有一个方法表,表中记录该类所有虚方法的地址。对象实例内部隐藏了一个指向方法表的指针。
如果子类重写了父类的虚方法,那么子类的方法表中对应的方法地址会被替换为子类的方法。如果子类没有重写,子类的方法表中保留父类的方法地址。
当通过基类引用调用虚方法时,CLR就会根据对象的实例隐藏的指向方法表的指针,找到方法表中对应的方法地址,执行正确的表现。
Unity基础
1. Unity的生命周期
在Unity中,GameObject的生命周期包含多个重要方法,如Awake()
、Start()
、Update()
等。请简述这些方法的主要用途以及它们被调用的顺序。并且,请给出一个场景示例,在这个场景中你会选择使用OnEnable()
而不是Start()
。
答:
当一个脚本实例被载入时,awake将会被调用。
当一个对象变为可用,或者激活状态时,Onenable将会调用
当Update的第一帧被调用时,会调用Start
接下来就是就是循环
①载入事件阶段(物理阶段)
FixedUpdate(物理帧)> 监听OnStateMachine > OnAnimationMove > OnAnimationIK > OnTrigger > OnClision > yeild FixedUpdate
鼠标输入事件 > OnMouse
②游戏逻辑阶段
Update > yeild return null > yeild return WaitForOneSecond > ... > LateUpdate(处理跟随的相机,防止抖动)
③场景渲染阶段
裁剪、预处理渲染
OnDrawGizmos
④GUI的渲染,帧结束
退出循环之后
OnApplicationQuit、OnDisable、OnDestroy
2. Unity3D中的碰撞器和触发器的区别?
勾选Is Trigger时,就会被物理引擎所忽略,没有碰撞效果,触发OnTrigger相关的函数
碰撞器会产生物理效果,会触发Oncollision相关的函数。
我对碰撞器的理解:两个物体要发生碰撞的条件是两个物体都有碰撞器,其中有一个带有刚体。
它的应用场景拾取地图上的场景中的物体需要触发器,限制摄像机的范围,需要一个触发器的范围。进入攻击范围等。
我们也可以通过层级碰撞矩阵(Unity的设置里)可以去设置层与层之间是否可以碰撞。也可以通过代码去忽略两个碰撞器是否可以进行交互。Physics.IgnoreCollision(collider1, collider2, true);
从性能角度来看,触发器比碰撞器更叫的高效,因为它不会计算物理逻辑。
3. CharacterController和Rigidbody的区别?
rigidBody完全支持物理引擎,CharacterController不完全支持物理引擎。所以说性能上前者消耗大,后者消耗小。其次就是前者会受到重力影响,而后者不会受到重力影响。
4. 详解进程、线程、协程的概念
进程:在操作系统中是资源管理的最小单位。每个进程都有一个独立的内存空间,资源管理、执行状态(PID)。因为是内存是独立的,所以程序的崩溃不会影响其他的进程。
线程:是进程的一个执行单元,是操作系统调度的基本单位。一个进程当中可以有多个线程,所以可以共享进程当中的内存和资源。就是因为可以共享资源的原因,就可能导致多个线程竞争一块资源问题,为了解决这个问题?就有一些同步机制。
而协程:可以协作的程序,所以说它可以由用户调度。可以用代码去控制程序何时暂停、何时恢复。yeild让出,让出CPU资源,你可以通过moveNext让他进行恢复resume。
而在Unity中协程的作用,协程的作用?及底层原理?
既然可以yeild让出资源,那么就可以实现延迟等待。异步加载资源等。
我对协程的理解:Unity内部有一个协程调度器,通过ie.current来接收yeild return的内容,在准备一个类,用来存储当前的IEnumerator和Time.time + 自己定义的规则。在Update中去判断队列中存储的时间与Time.time进行对比,只有当存储的时间小于Time.time的时候,它就会调用MoveNext函数,继续记录下一个yeild return的值,然后存储起来。
5. 当一个细小的高速物体撞向另一个较大的物体时,会出现什么情况?如何避免?
穿透(因为太快了,躲过了步长的检测)。
怎么避免?启用CCD(但是会增加计算开销)限制速度、优化Collider
6. 射线检测碰撞物的原理
7. ScriptableObject
将共享数据抽取为独立资源,实现数据与逻辑分离。通过ScriptableObject可以减少Prefab的重复数据。
UGUI
1. Image和RawImage的区别?
从功能来看:Image有多种显示模式(拉伸、平铺、裁剪),可以实现多种功能,如设置圆角、填充图片等。而RawImage直接显示原始纹理。更加灵活。
从性能来看:RawImage的性能比Image的性能高,因为Image内部可能会做更多的处理。
从应用场景来看:RawImage实时摄像机视图、视频播放器、粒子效果等,而Image做一些图片、图标等
2. MipMap是什么,它的作用?
MipMap叫多重纹理映射,是一种优化纹理渲染的技术。它通过为同一纹理生成不同分辨率的版本(高到低)。
当一个物体距离摄像机较远时,其表面的纹理可能会显得非常小,如果使用高分辨率的原始纹理,就可能导致性能问题和采样失真。而MipMap就是解决这个问题的。它会为原始纹理生成多级:512×512、256×256、128×128、64×64、32×32、.. 1×1,每一级都是前一级采样得到的。
在渲染过程中,GPU 根据物体与摄像机的距离、纹理过滤设置等选择合适的 MipMap 级别。
3. 请简述如何在不同分辨率下保持UI的一致性?
对于多屏幕下的不同分辨率的UI问题:只需要考虑UI的位置和UI的尺寸。
为了解决这个问题,Unity有一个Canvas Scaler组件。不同的模式呈现不同的效果。
设置锚点(与父物体位置的关系)
4. 画布的三种模式
5. UGUI合批问题
Shader
1. 向量的点乘、叉乘的意义?
点乘:a向量 点乘 b向量 = a的模 × b的模 × cosθ,它的意义可以两个向量之间的夹角。点乘常用于计算光线与平面的法线之间的夹角,从而决定光照效果。
叉乘:a向量 叉乘 b向量 = a的模 × b的模 × sinθ。用于计算垂直于两个向量的法向量,通过右手定则判定两个向量的位置关系
2. GPU的工作原理?
CPU通过DrawCall来命令GUP渲染。GPU接收顶点数据作为输入。通过顶点着色器对空间进行变换,然后通过几何着色器来产生更多图元,接着就是裁剪,裁剪掉摄像机看不到的地方,然后是屏幕映射,将图元坐标转化为屏幕坐标系下,接着就是三角形设置,三角形遍历。接着就是片元作色器进行着色,最终输出到屏幕上。
3. 有A和B两组物体,有什么办法能够保证A组物体永远比B组物体先渲染?
如果是2D的话,可以设置Sorting Layer
如果是3D的话,可以设置Shader中的渲染队列。
也可以创建2个摄像机,分别渲染A组物品和B组物品
评论区