Unity客户端一些面试高频题(自用不定期更新)

1.进程协程线程之间的关系是什么?

如图所示
在这里插入图片描述

  • 一个游戏进程包含多个线程,协程辅助线程工作。

在这里插入图片描述

unity中存在多进程的情况,但比较复杂。

  • 将渲染与游戏逻辑分离到不同进程:在一些对性能要求较高的 Unity 游戏中,尤其是处理大型开放世界场景时,开发者可以将渲染过程与游戏逻辑分离到不同的进程中。这样可以确保渲染不会因为游戏逻辑的复杂计算而受到影响,从而提高帧率和画面流畅度。例如,游戏中的物理计算、AI 逻辑等在一个进程中处理,而渲染相关的任务在另一个进程中进行,两个进程并行工作,提高游戏的整体性能。
  • 通过外部进程与 Unity 进行交互:在大型多人在线游戏(MMO)中,常使用这种方式。例如,将 Unity 作为客户端,与一个独立的服务器进程进行通信。客户端进程负责处理游戏的画面显示、用户输入等,而服务器进程则处理游戏的逻辑,如玩家之间的交互、游戏世界状态的更新等。通过这种方式,可以更好地管理游戏中的大量数据和玩家交互,提高游戏的可扩展性和稳定性。

2.对象池优化

如果对象池物体创立之后经过一段时间的高频使用后使用变少,那么本来创建的多个对象就闲置了,有什么办法优化吗?

  • 动态回收机制
    可以设定一个闲置时间阈值,当对象在对象池中闲置的时间超过这个阈值时,就将其从对象池中移除,释放相关资源。
  • 限制对象池大小
    在对象池创立之初就应该为对象池设置一个最大容量,当对象数量达到最大容量后,不再创建新的对象。并且在回收对象时,如果池已满,则直接销毁该对象。

3.C#和C++的区别

C#没有指针,只有引用类型与值类型。
引用类型在传递给函数时,传递的是对象的引用而不是对象本身,不过多赘述。
那C#是怎么修改传入的值类型的?
在这里插入图片描述

答:C#的关键字Ref和out关键字就是在做这件事。

ref & out
  • ref关键字能够把变量按引用传递给函数,这意味着函数内部对参数的修改会反映到原始变量上。运用ref时,调用函数时变量必须先初始化
  • out关键字同样是按引用传递变量,不过它要求在函数内部对参数进行赋值,而且调用函数时变量无需初始化

4.C++和Csharp分别是怎么使用堆的?有什么区别?

  • C++ 中堆的使用
  • 在 C++ 里,主要借助new和delete操作符(或者new[]和delete[]用于数组)来在堆上分配和释放内存。
    new操作符会在堆上分配指定类型所需的内存空间,并且返回一个指向该内存空间的指针。
    • 手动管理
    • C++ 要求开发者手动管理堆内存,即明确何时分配和释放内存。这就需要开发者有较高的编程技能,以避免内存泄漏(分配了内存却未释放)和悬空指针(指针指向已释放的内存)等问题。
    • 性能
    • 手动管理内存使得 C++ 在堆内存使用上具有更高的性能,因为开发者可以根据具体需求精确控制内存的分配和释放。
  • C# 中堆的使用
  • 在 C# 中,对象的内存分配是自动完成的。当使用new关键字创建一个对象时,CLR(公共语言运行时)会在堆上为该对象分配内存。而内存的释放则由垃圾回收器(GC)自动处理。

5.你刚刚提到了GC,如何减少GC?

  • 对象池的运用
    原理:针对频繁生成与销毁的对象(像子弹、粒子系统、敌人等),借助对象池技术,提前创建一定数量的对象并存储起来。当需要使用时,从对象池中取出;使用完成后,将其放回,而非直接销毁。这样就能避免频繁的内存分配与回收操作。
  • 减少临时对象的生成
    字符串拼接:在 Unity 里,字符串拼接操作会产生大量临时对象。尽量使用StringBuilder来替代直接的字符串拼接。
    例如:String s = new String(“XYZ”); 会有两份字符串。
    一个是由于引用类型,创建在堆上。
    一个是因为后续可能还会引用到,所以unity自身有一个常量池,池子里会放一份。
using System.Text;
using UnityEngine;

public class StringBuilderExample : MonoBehaviour
{
    void Start()
    {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < 10; i++)
        {
            sb.Append(i.ToString());
        }
        string result = sb.ToString();
        Debug.Log(result);
    }
}
  • 资源管理优化
    纹理和音频资源:确保纹理和音频资源的大小与格式合理。过大的资源会占用大量内存,并且在加载和卸载时可能触发 GC。使用合适的压缩格式来减小资源的大小。
    资源卸载:及时卸载不再使用的资源。使用Resources.UnloadUnusedAssets()方法来卸载未使用的资源,但要注意该方法可能会触发 GC,所以要选择合适的时机调用。
  • 代码优化
    委托和事件:避免频繁创建委托和事件,因为它们会产生垃圾。可以在初始化时创建委托和事件,然后重复使用。
    使用值类型:对于一些小的数据结构,优先使用值类型(如struct)而不是引用类型(如class)。值类型在栈上分配内存,不会触发 GC。
  • 监控与分析
    使用 Unity Profiler:利用 Unity Profiler 工具来监控 GC 的情况,了解哪些操作导致了大量的内存分配和 GC。通过分析 Profiler 的数据,找出性能瓶颈并进行优化。

6.UDP和TCP的区别

  • 连接性
    TCP:是面向连接的传输协议。在进行数据传输前,需要通过 “三次握手” 建立连接,传输结束后还要通过 “四次挥手” 断开连接。这确保了数据传输的双方都处于就绪状态,适合对数据准确性要求高的场景,如文件传输、网页浏览等。
    UDP:是无连接的传输协议。在发送数据前不需要建立连接,发送端只需将数据报发送到目标地址,接收端在接收到数据报后也不需要反馈。这种方式简单高效,但不保证数据的可靠传输,常用于实时性要求高的场景,如视频会议、在线游戏等。
  • 可靠性
    TCP:提供可靠的数据传输。它通过序列号、确认应答、重传机制、滑动窗口等技术,确保数据按序、完整、准确地到达接收端。如果数据在传输过程中丢失或损坏,TCP 会自动重传。
    UDP:不保证数据的可靠传输。它只是简单地将数据报发送出去,不关心数据是否能到达目的地,也不保证数据的顺序。如果数据在传输过程中丢失或损坏,UDP 不会进行重传。
  • 传输效率
    TCP:由于需要建立连接、维护连接状态、进行确认应答和重传等操作,会带来一定的开销,因此传输效率相对较低。
    UDP:没有这些额外的开销,数据传输速度快,效率高。
  • 传输形式
    TCP:是面向字节流的传输协议。它将应用层的数据看作是无结构的字节流,在传输过程中会对数据进行分段和重组,接收端需要自己处理数据的边界问题。
    UDP:是面向报文的传输协议。它将应用层的数据封装成一个个独立的报文进行传输,每个报文都有自己的边界,接收端可以直接读取完整的报文。
  • 拥塞控制
    TCP:有拥塞控制机制。当网络出现拥塞时,TCP 会自动调整发送速率,以避免进一步加重网络拥塞。
    UDP:没有拥塞控制机制。它不会根据网络状况调整发送速率,可能会在网络拥塞时导致数据包丢失增加。

7.你提到UDP不可靠,那么要怎么样才能可靠?

详细看这里

8.三次握手四次挥手?

详细看这里
在这里插入图片描述

9.拥塞控制?流量控制?

详细看这里

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

10.UI适配问题

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

  • 锚点和轴心共同影响着 UI 元素的布局和变换。锚点决定了元素在父对象中的位置,轴心则决定了元素自身变换的中心。通过合理设置锚点和轴心,可以实现复杂的 UI 布局和动画效果。
  • Scale With Screen Size 模式主要不是调节分辨率,而是根据不同的屏幕分辨率对 UI 元素进行缩放,以保证 UI 在不同屏幕上的相对大小和布局一致。

11.场景题:现在有一个积分变量,需要经过一些操作,但是有一些地方需要频繁拆装箱,你打算怎么优化?那假设现在积分最多只有1000,你怎么优化?

问1:使用泛型集合来存储积分变量。
问2:假设1 - 1000的int类型积分要频繁装箱成object使用,那么使用object数组进行1次预装箱(把int存入长度为1000的object数组中),后续全部使用Object数组索引调用。即使再调用1亿次也就只有初始预装箱的1000次操作而已。

12.cosnt,string,stringbuilder分别存储在哪里?

const编译之初就处理好了存在元数据中,不占用堆和栈。
string,stringbuilder引用类型堆。
不同之处:

  • string 对象一旦创建,其值就不能被改变。当对 string 进行拼接、替换等操作时,实际上是创建了一个新的 string 对象,对于常量字符串,C# 会使用字符串驻留机制。如果两个常量字符串的值相同,它们会指向堆上的同一个对象,以节省内存。
  • StringBuilder 也是引用类型,其对象存储在堆上,引用存储在栈上(如果是局部变量)。与 string 不同,StringBuilder 是可变的,它内部维护了一个字符数组,当进行拼接等操作时,会直接修改这个数组,而不是创建新的对象,因此在需要频繁修改字符串的场景下,使用 StringBuilder 性能更好。

13.堆和栈的区别

  • 内存分配方式
    栈:内存的分配与释放由系统自动处理。当进入一个函数时,系统会自动为该函数的局部变量在栈上分配内存;函数执行结束后,这些局部变量所占用的内存会被系统自动释放。
    堆:内存分配和释放需要程序员手动操作。在 C 语言里,通过malloc、calloc等函数来申请堆内存,使用free函数释放;在 Java、Python 等语言中,虽然有垃圾回收机制来自动处理堆内存的释放,但仍需手动创建对象来申请堆内存。
  • 内存空间的连续性
    栈:内存空间是连续的,类似于数据结构中的栈,遵循后进先出(LIFO)的原则。新的内存分配总是在栈顶进行,释放也从栈顶开始。
    堆:内存空间是不连续的。操作系统会维护一个空闲内存块列表,当程序申请堆内存时,操作系统会从空闲列表中寻找合适大小的内存块分配给程序。
  • 数据存储的内容
    栈:主要存储函数的局部变量、函数调用的上下文信息(如返回地址、寄存器状态等)。这些数据的生命周期通常与函数的执行周期一致。
    堆:用于存储动态分配的数据,如对象、数组等。这些数据的生命周期可以由程序员控制,不依赖于函数的执行周期。
  • 访问效率
    栈:由于内存空间连续,且操作系统对栈的操作有专门的指令支持,因此栈的访问速度较快。
    堆:由于内存空间不连续,在分配和释放时需要进行复杂的内存管理操作,因此堆的访问速度相对较慢。
  • 内存大小限制
    栈:栈的内存空间通常比较小,其大小由操作系统或编译器预先设定。如果程序在栈上分配的内存超过了这个限制,会导致栈溢出错误。
    堆:堆的内存空间相对较大,其大小受限于系统的物理内存和虚拟内存空间。但如果程序频繁地进行堆内存的分配和释放,可能会导致内存碎片问题,影响系统性能。

14.GC的算法

详细看这个

15.unity的TileMap

存储方式 (二维数组(密集)或者字典(稀疏))

16.什么是稳定排序和不稳定排序,讲一下常用的两种排序(merge,quick)

在这里插入图片描述

稳定排序指的是在排序操作中,相等元素在排序前后的相对顺序不会发生改变。也就是说,若在待排序序列中有两个元素 a 和 b,它们的值相等,且在排序前 a 在 b 前面,那么排序后 a 仍然在 b 前面。
QuickSort
不断以轴心为依据,不断交换左右两边满足条件的数。
最坏情况递归树变为一条链表。此时空间复杂度退化为O(n),时间复杂度退化到O(n^2)
最好情况 空间O(logn)递归深度, 时间(nlogn)每层遍历 * 层数

#include <iostream>
#include <vector>
#include <algorithm>
#include <bits/stdc++.h>
using namespace std;


// 快速排序函数
void quick_sort(std::vector<int>& q, int l, int r)
{
    if(l >= r) return;
    int i = l - 1, j = r + 1, x = q[l + r >> 1];
    while(i < j)
    {
        do i ++; while(q[i] < x);
        do j --; while(q[j] > x);
        if(i < j) swap(q[i], q[j]);
    }
    
    quick_sort(q, l, j);
    quick_sort(q, j + 1, r);
}

int main()
{
    int n;
    cin >> n;

    vector<int> q(n);
    for (int i = 0; i < n; ++i)
    {
        cin >> q[i];
    }

    quick_sort(q, 0, n - 1);

    for (int i = 0; i < n; ++i)
    {
        cout << q[i] << " ";
    }

    return 0;
}
    

MergeSort
每次对半,所以递归深度是logn,每次遍历数组n,所以时间复杂度nlogn。
一个辅助数组,空间复杂度n。
没有好坏情况之分,因为都是对半比大小。

#include <iostream>
#include <vector>

using namespace std;

vector<int> a;

void merge(vector<int> &a, int l, int r)
{
    if(l >= r) return;
    int mid = l + r >> 1;
    merge(a, l, mid);
    merge(a, mid + 1, r);
    int i = l, j  = mid + 1;
    vector<int> temp;
    for(; i <= mid && j <= r;)
    {
        if(a[i] <= a[j]) temp.push_back(a[i ++]);
        else temp.push_back(a[j ++]);
    }
    
    while(i <= mid) temp.push_back(a[i ++]);
    while(j <= r) temp.push_back(a[j ++]);
    for(int k = l; k <= r; k ++)
    {
        a[k] = temp[k - l];
    }
}

int main()
{
    int n;
    cin >> n;
    
    a.resize(n);
    for(int i = 0; i < n; i ++)
    {
        cin >> a[i];
    }
    
    merge(a, 0, n - 1);
    
    for(auto it : a)
    {
        cout << it << ' ';
    }
    
    return 0;
}
    

17.闭包函数是什么

体现在lambda表达式和匿名函数的使用中。
一个函数内嵌套了第二个函数,内部函数可以不需要参数传入也能获取外部函数已经捕获的变量了。
并且被捕获的变量可以一直存在在内部函数中,可以延长它的生命周期。
把函数当成一个可编辑的变量来使用了。

#include <iostream>

auto createClosure(int x) {
    return [x](int y) {
    //捕获参数列表[capture list]
    //y 传入参数
        return x + y;
    };
}

int main() {
    auto closure = createClosure(10);
    std::cout << closure(5) << std::endl;
    return 0;
}
using System;

class Program
{
    static Func<int, int> CreateClosure(int x)
    {
        // 这里的 Lambda 表达式类型根据返回类型 Func<int, int> 自动推断
        return (y) => x + y;
    }

    static void Main()
    {
        var closure = CreateClosure(10);
        int result = closure(5);
        Console.WriteLine(result); 
    }
}

18.Unity渲染管线

  • 应用阶段
    由游戏引擎或应用程序主导,主要负责准备渲染所需的数据,包括场景图的构建、相机设置、光照计算以及材质属性的确定等。例如,在 Unity 中,开发者通过脚本和编辑器设置来定义场景中的物体、光源、相机位置和投影方式等,这些信息都会在应用阶段被整理和传递给下一阶段。

  • 几何处理阶段
    顶点处理:(MVP)将模型的顶点坐标从模型空间转换到世界空间,再到观察空间和裁剪空间。同时,还会进行顶点光照计算、纹理坐标生成等操作(UV)。例如,在渲染一个角色模型时,会根据角色的位置、朝向以及光源的位置来计算每个顶点的光照效果,确定其最终的颜色和位置。
    图元装配:将顶点组装成图元**(链接顶点成为三角形)**,如三角形、线段等。这些图元是构成三维物体的基本单元。
    裁剪:将不在视锥体(相机可见范围)内的图元或顶点进行裁剪,剔除不可见的部分,以减少后续的渲染工作量。
    归一化

  • 光栅化阶段
    视口变换:是将裁剪空间中的归一化设备坐标(NDC)转换为屏幕上的实际像素坐标,确定物体在屏幕上的具体显示位置和大小。。
    三角形遍历:对于每个屏幕上的像素,判断其是否在三角形等图元内部。如果是,则进行后续的片段处理。

  • 片段处理阶段
    纹理采样:(MipMap,各向异性过滤(三线性升级版,效果更好,消耗更大),三线性插值(纹理过大), 双线性插值(纹理过小)) 根据顶点的纹理坐标,从纹理图像中采样颜色值。例如,为一个木质桌子模型采样木纹纹理,使桌子看起来更加逼真。
    片段着色:(EarlyZ可以提前剔除)根据材质属性、光照模型以及纹理采样结果等,计算每个片段的最终颜色 , ( 为房子的砖指定颜色 )。这一阶段可以通过着色器程序进行高度定制,实现各种复杂的渲染效果,如高光、阴影、反射等。
    深度测试与模板测试:深度测试用于确定当前片段是否在其他物体的前面,从而决定是否需要绘制。模板测试则可以根据特定的模板值来控制片段的绘制与否,常用于实现一些特殊效果,如阴影投射、遮罩等。

  • 帧缓冲操作阶段
    混合:将新生成的片段颜色与帧缓冲区(frame buffer)中已有的颜色进行混合,以实现透明效果或其他合成效果。例如,渲染一个透明的玻璃物体时,需要将玻璃的颜色与背景颜色进行混合。
    写入帧缓冲区:将经过处理的片段颜色最终写入帧缓冲区,用于显示在屏幕上。

19.链表和数组的区别

链表在堆上分配。
数组在栈上分配。
数组可以通过下标返回值O(1),因为数组地址连续只需根据公式 address = base_address + i * sizeof(element) 计算出元素的内存地址即可。
增删为O(n),改为O(1)。
链表可以通过指针索引实现O(1)的增删改,而查找是O(n)。

20.虚函数以及抽象类接口

虚函数被继承后不一定要重写。
抽象类中的抽象函数只有签名,在派生类中必须完成对这些抽象函数的实现。抽象类不能实例化。只能继承一个抽象类。
接口中的成员函数必须在子类中全部实现,并且支持多继承接口。

21.运行时多态和编译时多态

运行时多态:运行时多态通过虚方法、抽象方法和接口来实现。在运行阶段,会根据对象的实际类型来确定调用哪个方法版本。(特点运行时才知道)
编译时多态:编译时多态主要借助方法重载和泛型来达成。在编译阶段,编译器会依据方法调用时的参数类型、数量以及顺序等信息,确定要调用的具体方法版本。(特点编译时就确定)

22.析构函数什么时候调用

  • 对象生命周期结束时
  • 使用 delete 操作符时
  • 对象作为容器元素被移除时
  • 程序正常结束时对于全局变量

23.vector扩容机制?

  • 分配新的内存块:vector 会在堆上分配一块更大的连续内存空间,新内存块的大小通常是原内存块大小的两倍(不同编译器可能会有差异,但一般遵循指数增长的策略)。
  • 复制元素:将原内存块中的元素逐个复制到新的内存块中。
  • 释放原内存块:释放原内存块所占用的内存空间。
  • 更新内部指针:更新 vector 内部的指针,使其指向新的内存块。

24.Define 和 Const区别

Define只是文本替换。
const有严格的类型定义。

25.大根堆(小根堆)怎么形成的

先把下标从一开始的数组看成堆。
2i 是左子节点,2i + 1是右子节点。
然后从后往前遍历跳过叶子节点,从第一个叶子结点的根节点开始判断,如果小于优先与比较大的那个数交换。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值