《Unity3D 高级编程:主程手记》知识总结

前言

前段时间,受益匪浅,对于阅读过程中个人认为比较重要的知识(事实上几乎每一节都总结了)进行了总结,少部分内容(主要集中在渲染部分)尚未完全总结。

本书深入介绍了优化、UI、网络同步、资源管理、常见解决方案等,例如换装、切割、资源管理,并基于实际项目开发讲解,深入浅出,推荐阅读!

第一章

好的架构

第二章

2.装箱/拆箱

根据相应的值类型在堆中分配一个值类型内存块,再将数据复制给它,这要按三步进行。

第一步:在堆内存中新分配一个内存块(大小为值类型实例大小加上一个方法表指针和一个SyncBlockIndex类)。

第二步:将值类型的实例字段复制到新分配的内存块中。

第三步:返回内存堆中新分配对象的地址。这个地址就是一个指向对象的引用。

拆箱则更为简单,先检查对象实例,确保它是给定值类型的一个装箱值,再将该值从实例复制到值类型变量的内存块中。

3.排序算法和搜索算法

  • 优先快排:三数取中(保证至少选到倒数第二大和倒数第二小的数)、递归深度到达一定数量改用堆排序,小区间插入排序,分三个区域,小区间优先处理
  • 大量数据可归并

4.Dictionary和List底层

  • list效率低,除了下标,基本都是on
  • dictionary底层是拉链法解决哈希冲突、质数扩容
  • dictionary和list尽量根据数量级提前给一个容量,防止扩容后反而填不满占用内存

5.struct

  • 实现接口 提前装箱
  • 部分数据用struct代替,内存连续,提高缓存一致性,cache命中率
  • 事实上如果struct太大超过cpu一次读取的chunk,那么其实就不会缓存,所以使用int[] byte[]这种可能更好
lass A
{
    public int a;
    public float b;
    public bool c;
}
class B
{
    public int index;
}
class C
{
    private static C _instance;
    public static C instance
    {
        get
        {
            if (null == _instance)
            {
                _instance = new C();
                return _instance;
            }
            return _instance;
        }
    }
    public int[] a = new int { 2, 3, 5, 6 };
    public float[] b = new float { 2.1f, 3.4f, 1.5f, 5.4f };
    public bool[] c = new bool { false, true, false, true };
}
public void main()
{
    A[] arrayA = new A[3] { new A(), new A(), new A() };
    print("A class this is a {0} b {1} c {3}", Aa.a, Aa.ba, Aa.c);
    B b = new B();
    b.index = 2;
    C c = C.instance;
    //采用index配合一个专门的数据类事实上效率会高,值类型数组分配内存连续,缓存命中率高
    print("B class this is a {0} b {1} c {3}", c.a[b.index], c.b[b.index],
    c.c[b.index]);
}
  • 注意的是引用数组,只是引用连续,但实际的对象大概率不连续

6. 尽可能地使用对象池

  • 减少内存分配次数和内存碎片,还要避免内存卸载带来的性能损耗。
using System;
using System.Collections.Generic;
using UnityEngine;

internal class ObjectPool<T> where T : new()
{
    private readonly Stack<T> m_Stack = new Stack<T>();
    private readonly UnityAction<T> m_ActionOnGet;
    private readonly UnityAction<T> m_ActionOnRelease;
  
    public int countAll { get; private set; }
    public int countActive { get { return countAll - countInactive; } }
    public int countInactive { get { return m_Stack.Count; } }
  
    public ObjectPool(UnityAction<T> actionOnGet, UnityAction<T> actionOnRelease)
    {
        m_ActionOnGet = actionOnGet;
        m_ActionOnRelease = actionOnRelease;
    }
  
    public T Get()
    {
        T element;
        if (m_Stack.Count == 0)
        {
            element = new T();
            countAll++;
        }
        else
        {
            element = m_Stack.Pop();
        }
      
        if (m_ActionOnGet != null)
            m_ActionOnGet(element);
          
        return element;
    }
  
    public void Release(T element)
    {
        if (m_Stack.Count > 0 && ReferenceEquals(m_Stack.Peek(), element))
            Debug.LogError("Internal error. Trying to destroy object that is already released to pool.");
          
        if (m_ActionOnRelease != null)
            m_ActionOnRelease(element);
          
        m_Stack.Push(element);
    }
}

internal static class ListPool<T>
{
    // 避免分配对象池
    private static readonly ObjectPool<List<T>> s_ListPool = 
        new ObjectPool<List<T>>(null, l => l.Clear());
  
    public static List<T> Get()
    {
        return s_ListPool.Get();
    }
  
    public static void Release(List<T> toRelease)
    {
        s_ListPool.Release(toRelease);
    }
}

注意字符串

string strA = "test";
for (int i = 0; i < 100; i++)
{
    string strB = strA + i.ToString();
    string[] strC = strB.Split('e');
    strB = strB + strC[0];
    string strD = string.Format("Hello {0}, this is {1} and {2}.", strB, strC[0],
    strC[1]);
}

这段程序每轮分配五次内存,同时还要丢弃原有的strB对象,每次循环还会丢弃之前分配的字符串,最后抛弃所有申请的五百次内存,gc压力很大

使用字典缓存可预测的字符串

int ID = 101;
ResData resData = GetDataById(ID);
string strName = "This is " + resData.Name;
return strName;

一个ID变量对应一个字符串,这种形式下可以建立一个字典容器将它缓存起来,下次用的时候就不需要重新申请内存了,

伪代码如下:

Dictionary<int, string> strCache = new Dictionary<int, string>();
string strName = null;
if (!strCache.TryGetValue(id, out strName))
{
    ResData resData = GetDataById(ID);
    strName = "This is " + resData.Name;
    strCache.Add(id, strName);
}
return strName;

我们用Dictionary字典容器将字符串缓存起来,每次先查询字典中的内容是否存在,若有,则直接使用,若没有,则创建一个并将其植入字典容器中,以便下次使用。

  • 使用native的方法 例如指针直接修改内存内容(感觉操作起来很复杂)
Dictionary<int, string> cacheStr;
public unsafe string Concat(string strA, string strB)
{
    int a_length = a.Length;
    int b_length = b.Length;
    int sum_length = a_Length + b_Length;
    string strResult = null;
    if (!cacheStr.TryGetValue(sum_length, out strResult))
    {
        // 如果不存在sum_length长度的缓存字符串,那么直接连接后存入缓存
        strResult = strA + strB;
        cacheStr.Add(sum_length, strResult);
        return strResult;
    }
    // 将缓存字符串再利用,用指针方式直接改变它的内容
    fixed (char* strA_ptr = strA)
    {
        fixed (char* strB_ptr = strB)
        {
            fixed (char* strResult_ptr = strResult)
            {
                // 将strA中的内容复制到strResult中
                memcopy((byte*)strResult_ptr, (byte*)strA_ptr,
                a_length * sizeof(char));
                // 将strB中的内容复制到strResult的a_Length长度后的内存中
                memcopy((byte*)strResult_ptr + a_Length,
                (byte*)strB_ptr, b_length * sizeof(char));
            }
        }
    }
    return strResult;
}

ToCharArray、Clone、Compare

  • string.ToCharArray返回的char[]数组是一个新创建的字符串数组,与原字符串无关
  • 至于string.Clone、string.ToString()接口,它们并不会重新构建一个string,而是会直接返回当前的string对象
  • Equals源码
public bool Equals(String value)
{
    if (this == null) // 这对于防止反向pinvokes和其他不使用
        throw new NullReferenceException(); // callvirt指令的调用者是必要的
    if (value == null)
        return false;
    if (Object.ReferenceEquals(this, value))
        return true;
    if (this.Length != value.Length)
        return false;
    return EqualsHelper(this, value); // 遍历两者的字符
}
  • ==仅对比引用,equals会逐步直到对比字符串内容,所以如果是对比内容请使用Euqals

程序运行

业务逻辑的优化很大程度上都是围绕着以下环节展开的:如何利用好CPU缓存命中率、如何减少内存分配和卸载次数,以及如何利用好多线程,让多个线程协作顺畅,并且能分担任务。
为了更好地优化我们编写的程序,应该深入底层去了解计算机是如何执行我们编写的程序的。
这次我们得脱离C语言,因为C为我们包装了太多东西,使用起来很方便,同时也蒙蔽了我们的眼睛,让我们看不清底层的原理。

内存布局关键区域

内存区域存储内容特性
指令段所有方法/函数的机器指令只读,程序启动时加载
数据段常量/静态数据程序生命周期内不变
堆内存动态分配的对象非连续,需手动/GC管理
栈内存局部变量/函数调用上下文LIFO结构,自动管理

关键概念解析

虚拟内存机制

  • 操作系统提供虚拟地址空间
  • malloc等操作只分配虚拟内存
  • 实际物理内存通过页表映射
  • 首次读写时触发缺页中断分配物理页

对象内存本质

  • 类实例只是连续的字节块
  • 方法存在于指令段而非对象内
  • 面向对象特性是编译器提供的抽象

|栈与堆对比|||

特性
分配方式自动(LIFO)手动/GC
内存连续性保证连续可能碎片化
访问速度极快(CPU缓存友好)相对较慢
生命周期函数作用域显式控制/GC决定

汇编层视角

  • 代码段:CS(代码段寄存器)+EIP(指令指针)
  • 数据段:DS(数据段寄存器)+偏移量
  • 栈段:SS(栈段寄存器)+ESP(栈指针)
  • 寄存器是CPU最快存储单元(纳秒级访问)

优化启示

  1. 缓存友好设计

    • 提高数据局部性
    • 避免随机内存访问模式
  2. 内存管理原则

    • 优先栈分配
    • 对象池化重用
    • 避免频繁分配大对象
  3. 多线程优化

    • 减少共享数据
    • 合理使用线程局部存储
    • 注意false sharing问题
  4. 指令层优化

    • 减少分支预测失败
    • 利用SIMD指令
    • 避免过度虚函数调用
高级语言的便利性使我们远离底层细节,但性能优化必须理解这些基础原理。在保持代码可维护性的前提下,针对热点代码进行底层优化才能获得最佳效果。

第三章 数据表与程序

数据表

介绍

那么如何理解数据表的存在呢?可以认为数据表是一个本地的数据
库,只不过这个数据库里的数据是不可被修改的只读数据。可以这么
说,在实际项目的开发中,它们大部分是从Excel里生成,再导入游戏
中去的,
  • 代码数据

适合测试阶段或者程序员自测

数据放在代码里的原因只有一个:快,即制作快、使用快、效率
高。不需要建立与其他部门的桥梁,程序员自己动手就能搞定。不需要
像Excel那样,需要先建立Excel表,制定规则,转化为数据,再加载、
再解析,之后才能使用。对于代码里的数据,程序员们直接拿来就能使
用
  • 文本数据

json、xml、excel、

  1. 易读
  2. 方便配合
  • 比特流数据
比如.txt文本中的23345是“23345”这个字符串,占用了5
个字符,每个字符2byte,就用了10byte,而比特流则在存储时直接使用
2byte(short)存储“23345”这个数字,相比文本数据足足小了8byte,因
此比特流形式的数据存储文件更小。一个以.txt格式建立的10MB的数据
文件,转化为二进制格式后,只要几百KB甚至几十KB。
  1. 优化读取速度

例如:Protobuf

数据表制作

使用复制、导出都需要人为,容易出错也麻烦

所以可以借助bash、程序脚本结合jenkins实现自动化流水线

Jenkins是很多公司的自动化流水线必备工具,它拥有的打包、转换
数据表、同步上传、自动化检测、自动运行等功能也被大部分高级程序
员所喜爱。

数据表的另一种作用

连接游戏策划设计师与其他部门

  • 对于数据表的修改,可以自动生成一个专门的变量存储内容为一个int,对应其对应的列,这样策划人员修改后,程序可以快速感知
public Class ExcelDefine
    {
     public const int role_role_ID = 1; // role表的role工作簿的ID列 
    public const int role_role_Name = 2; // role表的role工作簿的Name列 
    public const int role_role_Age = 3; // role表的role工作簿的Age列 
} 
// 获取名字的例子
string role_name_str = RoleTable.GetStr(ExcelDefine.role_role_Name);
int table_role_id = RoleTable.GetInt(ExcelDefine.role_role_ID);
  • 对于多语言我们可以采取同样的方式,例如key value的方式

image

string content = TextMgr.GetTextString(TextKey .YouWin,Language.Chinese)

第四章、用户界面

UI

主流Unity项目中基本上是在NGUI系统和UGUI系统中二选
一。后来又出现了一个FairyGUI系统,它脱离Unity资源导入方式自成一
派,本意是想统一多个引擎之间的UI框架,虽然是好事,但里面涉及很
多性能问题,其优势是便于那些熟悉其他商业引擎的读者转换到Unity
上来。FairyGUI系统有自己的一套编辑器,可以通过编辑器来创建界面
和编辑UI动画,将UI设计与程序脱离开来。Unity官方也做了类似的系
统,并将这套系统命名为UI Elements,在Unity 2020上改名为UI
Toolkit。

UGUI的系统原理和组件

介绍

UGUI是在3D网格下建立起来的UI系统,它的每个可显示的元素都
是通过3D模型网格的形式构建起来的。当UI系统被实例化时,UGUI系
统首先要做的就是构建网格。
也就是说,Unity3D在制作一个图元,或者一个按钮,或者一个背
景时,都会先构建一个方形网格,再将图片放入网格中。可以理解为构
建了一个3D模型,用一个网格绑定一个材质球,材质球里存放要显示
的图片。
这里有一个问题,如果每个元素都生成一个模型且绑定一个材质球
存入一张图片,那么界面上成千上万个元素就会拥有成千上万个材质
球,以及成千上万张图。这样使得引擎在渲染时就需要读取成千上万张
图以及成千上万个材质球,如果GPU对每个材质球和网格都进行渲染,
将会导致GPU的负担重大,我们可以理解为一个材质球拥有一个
drawcall会导致drawcall过高(drawcall的原理将在后面章节介绍)。
UGUI系统对这种情况进行了优化,它将一部分相同类型的图片集
合起来合成一张图,然后将拥有相同图片、相同着色器的材质球指向同
一个材质球,并且把分散开的模型网格合并起来,这样就生成几个大网
格和几个不同图集的材质球,以及少许整张的图集,节省了很多材质
球、图片、网格的渲染,UI系统的效率提升了很多,游戏在进行时也顺
畅了许多。这就是我们常常在UI系统制作中提到的图集概念,它把很多
张图片放置在一张图集上,使得大量的图片和材质球不需要重复绘制,
只要改变模型顶点上的UV和颜色即可。
UGUI系统并不是将所有的网格和材质球都合并成一个,因为这样
模型前后层级就会有问题,它只是把相同层级的元素,以及相同层级上
的拥有相同材质球参数的进行合并处理。合并成一个网格,就相当于是
一个静止的模型,如果移动了任何元素,或者销毁了任何元素,或者改
变了任何元素的参数,原来合并的网格就不符合新的要求了,于是
UGUI系统就会销毁这个网格,并重新构建一个。我们设想一下,如果
每时每刻都在移动一个元素,那么UGUI系统就会不停地拆分合并网
格,也就会不停地消耗CPU来使得画面保持应有的样子。这些合并和拆
分的操作会消耗很多CPU,我们要尽一切可能节省CPU内存,尽量把多
余的CPU让给核心逻辑。UGUI系统在制作完成后,性能优劣差距很多
时候都会出现在这里,我们要想方设法合并更多的元素,减少重构网格
的次数,以达到更少的性能开销目的。

AI总结

核心优化策略
  • 图集系统(Atlas)

    • 合并规则:

      • 同层级元素
      • 相同着色器参数
      • 相同渲染状态(混合模式等)
    • 实现效果:

      • 纹理合并:N张小图 → 1张大图集
      • 材质共享:相同参数材质实例复用
      • 网格合并:相邻元素合并为单一Mesh
  • |动态重建机制|||

    触发条件重建代价优化建议
    元素位移全批次重建避免频繁位置调整
    层级变化部分批次重建保持稳定渲染顺序
    材质参数变更材质实例级重建使用Shader全局参数
性能关键指标
  1. Drawcall数量

    • 每个未合并材质产生1个drawcall
    • 理想情况:同图集元素共享drawcall
  2. 网格重建频率

    • 动态UI导致的网格合并/拆分消耗CPU
    • 峰值计算:每帧可能触发多次完整重建
  3. 内存占用

    • 纹理内存:原始散图 vs 图集打包
    • 显存压力:合并后的大网格数据量
最佳实践指南
  • 静态UI设计

    • 标记不会变动的元素为"Static"
    • 利用Canvas组件的"Static Batch"选项
  • 动态UI优化

    • 控制变化频率(如使用动画曲线替代逐帧修改)
    • 对频繁变化元素使用独立Canvas
  • 图集制作规范

    • 按功能模块分组打包
    • 预留2px间距防止纹理渗色
    • 限制单图集尺寸(建议≤2048x2048)
  • 层级管理技巧
    csharp

    // 通过代码控制合批优先级
    CanvasRenderer.SetMaterial(material, textureID

组件

Canvas

合并的规则为,在同一个Canvas里,将相同层级、相同材质球的元素进行合并,从而减少drawcall。但相同层级并不是指gameobject上的节点层级,而是覆盖层级Canvas里如果两个元素重叠,则可以认为它们是上下层关系,将所有重叠的层级数排列顺序计算完毕后,将从第0层开始的同一层级的元素合并,再将第1、2、3……层的元素同样合并,以此类推其他层。

  • Overlay模式并不与空间上的排序有任何关系,空间上的前后位置不再对元素起作用,它常用在纯UI系统的区域内,这种模式下,Camera排序有别于其他模式,Sort order参数在排序时被重点用到,Sort order参数的值越大,越靠前渲染。
  • Screen Camera模式相对通用一点,它依赖于Camera的平面透视,渲染时的布局依赖于它绑定的Camera。想让更多的非UGUI元素加入UI系统中,Screen Camera模式更有优势。 这种模式是实际项目中制作UI系统最常用的模式,不过UGUI系统底层针对排序有一些规定,如对元素的z轴不为0的元素,会单独提取出来渲染,不参与合并。
  • World Space模式主要用于当UI物体放在3D世界中时,比如,一个大的场景需要将一张标志图放在一块石头上,这时就需要World Space模式。 与Screen Camera的区别是,它常在世界空间中与普通3D物体一同展示,依赖于截锥体透视(Perspective)Camera。 它的原理挺简单,与普通物体一样,当UI物体在这个Camera视野中时,就相当于渲染了一个普通的3D图片,只是除了普通的渲染Canvas外,还会对这些场景里的UI进行合并处理。

CanvasScale

这是一个屏幕适配组件,用来指定画布中元素的比例大小。有简单指定比例大小的Constant Pixel Size模式,也有Scale With Screen Size模式,它具有以屏幕为基准的自动适配比例大小的规则,或者ConstantPhysical Size模式,它具有以物理大小为基准的适配规则。在实际手游项目里,设备的屏幕分辨率变化比较大,通常使用以屏幕为基准的自动适配比例大小的Scale With Screen Size选项。

Graphic Raycaster

它是输入系统的图形碰撞测试组件,它并不会检测Canvas以外的内容,检测的都是Canvas下的元素。 当图元素上存在有效碰撞体时,Graphic Raycaster组件会统一使用射线碰撞测试来检测碰撞的元素。也可以设置完全忽略输入的方式来彻底取消点击响应,还可以指定阻止对某些layers进行响应。

Event Trigger

它是输入事件触发器,与此脚本绑定的UI物体都可以接收输入事件。比如(鼠标、手指)按下、弹起、点击、开始拖曳、拖曳中、结束拖曳、鼠标滚动事件等。它主要起到点击响应作用,配合前面的Graphic Raycaster进行响应。

Image和Rawimage

两者的区别是,Image组件仅能展示图集中的图元,但展示的图元可以参与合并,而RawImage组件能展示单张图片,但无法参与合并。

不使用图集而使用RawImage展示单张图片时,通常都是图片尺寸
太大而导致合并图集的效率太低,或者相同类型的图片数量太多,导致
合并图集后的图集太大,而实际在画面上需要展示的这种类型的图片又
很少,图集方式反而浪费大量内存空间,因此使用RawImage逐一展示
即可。

Mask和RectMask2D

Mask组件使用顶点重构的方式剔除矩形区域外的部分,而RectMask2D组件则采用着色器的剔除方式,每个元素都有自己的材质球实例和实例参数

UGUI事件模块

组成

  • 事件数据模块
  • 输入事件捕获模块
  • 射线碰撞检测模块
  • 事件逻辑处理及回调模块

事件数据模块

主要定义并且存储了事件发生时的位置、与事件对应的物体、事件的位移大小、触发事件的输人类型及事件的设备信息等。

主要包含包含PointerEventData、AxisEventData、BaseEventData三个类

BaseEventData

PointerEventData、AxisEventData的基类,包含常用的接口和对应数据

AxisEventData

包含滚轮数据

image

PointEventData

image

输入事件捕获部分

输人事件捕获模块由BaseInputModule、PointerInputModule、StandaloneInputModule、TouchInputModule四个类组成。

  1. BaseInputModule类是抽象(abstract)基类,提供必需的空接口和基本变量。
  2. PointerInputModule类继承自BaseInputModule类,并且在其基础上扩展了关于点位的
    输人逻辑,增加了输入的类型和状态。
  3. StandaloneInputModule类和TouchInputModule类又继承自PointerInputModule类,它们从父类开始向不同的方向拓展。

    StandaloneInputModule类向标准键盘鼠标输入方向拓展,而TouchInputModule类向触控板输人方向拓展。

ProcessMousePress、ProcessMove、ProcessDrag

分别处理按下(包含clickcount等内容)、移动和拖拽

TouchInputModule

对每一个Touch判断move和press然后分别调用,很像ProcessDrag

句柄处理

image

射线检测

2D射线检测和3D射线检测

2D射线碰撞检测、3D射线碰撞检测相对比较简单,采用射线的形式进行碰撞检测,
区别在于2D射线碰撞检测结果里预留了2D的层级次序,以便在后面的碰撞结果排序时,
以这个层级次序为依据进行排序,而3D射线碰撞检测结果则是以距离大小为依据进行排
序的。

GraphicRaycaster

GraphicRaycaster类为UGUI元素点位检测的类,它被放在Core渲染块里。它主要针对ScreenSpaceOverlay模式下的输人点位进行碰撞检测,

因为这个模式下的检测并不依赖于射线碰撞,而是通过遍历所有可点击的UGUI元素来进行检测比较,从而判断该响应哪个UI元素的。

image

事件逻辑部分

Eventlnterfaces类、EventTrigger类、EventTriggerType类定义了事件回调函数,ExecuteEvents
类编写了所有执行事件的回调接口。
EventSystem类主逻辑里有300行代码,基本上都在处理由射线碰撞检测后引起的各类
事件。比如,判断事件是否成立,若成立,则发起事件回调,若不成立,则继续轮询检查,
等待事件的发生。
**EventSystem类是事件处理模块中唯一继承MonoBehavior类并在Update帧循环中
做轮询的。也就是说,所有UI事件的发生都是通过EventSystem轮询监测并且实施的。
EventSystem类通过调用输人事件检测模块、检测碰撞模块来形成自已的主逻辑部分。因此
可以说EventSystem是主逻辑类,是整个事件模块的入口。**

简单易用的UI框架构建

UIManager

负责创建UI,需要查找现有的某个UI,销毁UI,以及完成UI的统一接口调用和调配工作。UIManager承担了所有UI的管理工作,因此UI实例都将存储在这里。不仅如此,一些UI常用变量也可以存储在里面,比如屏幕的适配标准大小、只有单个个体的实例如Camera等。

image

基类

image

事件

image

自定义组件
  1. UI动画组件

支持程序播放,如Play,包含动画名,播放完成后的回调

此外最好支持自动播放,方便美术人员调试

  1. 按钮播放音效组件
  2. UI元素跟随3D物体组件
  3. 无限滚动列表

UI优化

动静分离

  • “动”指的是元素移动,或者放大/缩小频率比较高的UI。
  • “静则是静止不动的UI,准确来说,是界面上不会移动、旋转、缩放、更换贴图和颜色的UI。

UGUI和NGUI一样都是用网格模型构建UI,构建后都会执行合并网格的操作,减少drawCall

问题在于,合并后的网格,无论哪个元素发生便会,都会重新执行所有的合并

所以可以以Canvas为节点将经常变化的元素和静止不动的部分分开(合并是以Canvas为分组的)

拆分过重UI

比如一个Prafab有好几个界面,或者一个界面有多个子界面等

此时可以将这些界面拆分成独立界面,按需加载,将CPU消耗分散在一个较长的时间线上

预加载

当UI实例化时,需要将Prefab实例化到场景中,**期间还会有网格的合并、组件的初
始化、渲染初始化、图片的加载、界面逻辑的初始化等程序调用,会消耗很多CPU**。这导致我们在打开某个界面时,会频繁出现卡顿现象,其实这就是CPU在那个点消耗过重的表现。

  1. 例如提前将当前场景需要的UI加载到内存(可以结合拆分),需要时实例化,减少加载时间
  2. 但注意预加载不会减少CPU的消耗,只是将CPU的消耗平均的分散在一个较长的时间线,减少卡顿

Alpha分离

UGUI内部支持,比如用RGB888和一张Alpha8的PNG图,

对于NGUI可以修改shader

image

字体拆分

根据场景减少字体内容,因为字体占用的内存很大。

例如某个场景只用了某些字,就只生成这一部分字的字体单独使用

无限滚动列表

ScrollView的更新重建是很消耗性能的

可以只用少量元素,通过交换位置、改变信息减少生成的元素和重建

网格重建优化

修改UI的颜色和Alpha会导致需要修改顶点数据,被迫重建

比如一些UI动画,可能要频繁修改这些值,性能消耗大

我们可以自定义shader,不通过Image去修改颜色

而是直接修改材质的属性来实现修改颜色和透明度(动画也是如此)

减少重建消耗

但要注意,因为启用了自定义的材质球,每个材质球都会单独增加一次drawcall,这会导致drawcall增加。并且,当Alpha不是1的时候,会与原有的UGUI系统产生的半透明材质球形成混淆的渲染排序,原因是当多张透贴放在一起渲染时,Alphablend会导致渲染排序混乱、前后不一致的现象。因此,使用时要小心,用在恰当的地方将发挥大的功效,用在不恰当的地方则事倍功半。

UI展示和关闭

  1. 不销毁UI,而是采用显隐、移出屏幕的方式(移出屏幕可以把UI放到一个不渲染的layer,防止相机裁剪的消耗,同时禁用其更新)

对象池

对象池就是当我们需要一个对象的时候,先去对象池获取(对象池根据自己的size以及是否有可用对象决定是否创建新对象),不使时返回对象池

  1. 典型的空间换时间
  2. 减少内存碎片,内存更容易连续,CPU缓存命中率提高
  3. 对于一些频繁使用的元素,可以减少连续实例化和销毁的性能消耗
  4. 减少gc

运用对象池的经验:

  • 当程序中有重复实例化并不断销毁的对象时需要使用对象池进行优化。重复实例化和销毁操作会消耗大量CPU,在此类对象上使用对象池优化的效果极佳。
  • 每个需要使用对象池的对象都需要继承对象池的基类对象,这样在初始化时可以针对不同的对象重载,区别对待不同类型的对象,让不同对象根据各自的情况分别初始化。
  • 销毁操作时使用对象池接口进行回收,切记不要重复回收对象,也不要错误地放弃回收。在销毁物体时要使用对象池提供的回收接口,以便让对象池集中存储对象。
  • 场景结束时要及时销毁整个对象池,避免无意义的内存驻留。

贴图优化

无论使png、jpg、psd,Unity都是读取他们的内容,生成一套自己格式的图

1)是否需要Alpha通道。如果需要Alpha通道,则要把Alpha通道点开,否则最好
关闭。
2)是否需要进行2次方大小的纠正。对UI贴图来说基本上都是2次方大小的图集,
使用对象大多是头像之类的Icon。
3)去除读、写权限。这里常会默认勾选,导致内存量大增,此选项会使贴图在内存中存储两份,内存会比不勾选时大1倍。
4)去除Mipmap。Mipmap是对3D远近视觉的优化,Mipmap会在摄像头离物体远时因为不需要高清的图片而选择使用Mipmap生成的贴图小的模糊图像,从而减轻GPU压力。但是在2D界面上没有远近之分,所以并不需要Mipmap这个选项,而且Mipmap会导致内存和磁盘空间加大,会使得UI看起来模糊。
5)选择压缩方式。压缩方式的选择,主要是为了降低内存的消耗、加载时的消耗,降低CPU与GPU之间的带宽消耗,以及减少包的大小,在清晰度足够的情况下,我们可以针对性地选择一些压缩方式来优化内存和包体。

最高的色彩度是无压缩的,其次是RGBA16,色彩少了点且有Alpha通道;

再次是RGB24,没有Alpha通道的全彩色;之后是RGB16,色彩少了一半也没了Alpha通道;

最后是算法级别的压缩,RGBAECT28位和RGBAPVRTC4位是带Alpha通道的压缩算法,RGBECT24位和RGBPVRTC4位是不带Alpha通道的压缩算法。

这样逐级下来,压缩的力度会越来越大,画质也会越来越差。前面有介绍过关于UI贴图Alpha分离的方法,这方

注意内存泄漏

  1. 程序上的泄露(Mono的垃圾回收器没有识别成垃圾)
  2. 资源使用后没有释放导致的泄露

程序上的内存泄漏,预防为主,排查为辅,排查较为空难,而资源的泄露排查相对容易,排查资源不再使用时仍然驻留在内存。

垃圾回收机制
  • 引用计数,简单地说,就是当被分配的内存块地址赋值给引用时,增加引用计数1,相反当引用清除内存块地址时,减少引用计数1。引用计数变为0就表明没有人再需要此内存块了,所以可以把内存块归还给系统,此时这个内存块就是垃圾回收机制要找的“垃圾”。
  • 跟踪收集则是遍历引用内存块地址的根变量,以及与之相关联的变量,对内存资源没有引用的内存块进行标记(标记为“垃圾”),在回收时还给系统。
Mono

各个平台的Mono实现解释执行IL

Mono的堆内存池是只增不减的,当程序需要内存就向Mono堆内存池申请,不需要了就返回(不是返回给操作系统),某次申请堆内存池不够,Mono就会向操作系统申请更多的内存池(扩容中mono还有进行一次垃圾回收),池的扩大每次都是一次较大的内存分配,约6-10mb,如果转换为il2cpp,则由il2cpp决定

分配在Mono堆内存上的都是程序需要使用的内存块,例如静态实例以及这些实例中的变量和数组、类定义数据、虚函数表等,函数和临时变量更多是使用栈来存取的。

Unity3D的资源则不同,它是通过Unity3D的C++层读取的,即分配在Native堆内存上的那部分内存,其与Mono堆内存是分开来管理的。

基于各种原因,Unity3D后来不再完全依靠Mono了,而是另寻了一个解决方案,那就是使用IL2CPP算法,Unity3D将C#翻译成IL中间语言后再翻译成Cpp以解决所有问题。那么翻译成C++语言,内存就不托管了吗?不是的。内存依然托管,只是这次由C++编写VM(虚拟机)来接管内存,不过这个VM只是实现内存托管而已,它并不会解析和执行任何代码,它只是个管理器。IL2CPP与Mono的区别在什么地方呢?区别在于Mono只将C#翻译为IL中间语言,
并把中间语言交给VM去解析和执行,VM的工作是既要解析又要执行,这样Mono要针对
不同的平台执行IL程序时,就需要为它们分别定制一个单独的VM。IL2CPP则是把C#代
码翻译为ⅡL中间语言后又继续将其翻译为C++代码,对于不同平台来说,每次翻译的C++
代码必须针对当前平台的API做出些变化,也就是说,IL2CPP在不同平台下需要对不同平
台的接口进行改造。与Mono针对不同平台拥有不同的VM相比,IL2CPP只是在翻译时改
造了不同平台的接口代码,显而易见,对程序员来说,IL2CPP的维护工作量减少了很多。
不仅仅是程序员维护的工作量少了,在IL2CPP翻译完成进行编译时,使用的是平台本身各
自拥有的C++编译器,用平台自己的C++编译器进行编译后就可以直接执行编译内容,而
无须再通过VM处理,因此ⅡL2CPP相对Mono的效率会更高一些。

资源泄露的排查
  • 利用资源命名,如Room_back
  • 利用Memory Profiler的快照
  • 利用Resource.FindObjectsOfTypeAll()获取某个类型的所有对象,然后定时保存为文本,新增的部分就可能是泄露
  • 在游戏切换的时候做一次内存采样,分析哪些对象和资源是不需要的
其他分析工具
UWA的GOT

可以记录每一帧的Mono堆内存,还可以快照间比较

PA_ResourceTracker

Github开源项目,改造了原有的MemoryProfiler可以在Editor下比较

针对高端和低端机型采用不同的优化

如:

  1. UI贴图的质量
  2. 特效质量
  3. 模型精细度
  4. 阴影、光照

有非常多的可选择性

阴影处理
  1. QualitySeetings接口设置
  2. 关闭传统阴影渲染,改用简单面片,空间换时间
  3. 静态阴影烘焙
贴图处理

QualitySettings.masterTextureLimit:

对画质影响大,谨慎使用

  • 0:代表不对贴图渲染做限制
  • 1:渲染1/2的画质
  • 2:1
  • x:以此类推
机型区分

对于Apple,机型较少,可以直接挨个判断,如UnityEngine.iOS.Device.generation ==UnityEngine.iOS.DeviceGeneration.iPhone6

对于Android,机型太多,可以根据处理器、内存、屏幕分辨率、当前帧数、Android系统版本号等分档

UI贴图处理

两套质量的UI

这里NGUI和UGUI中使用的方法不同。

NGUI需要制作两个AtlasPrefab,再通过修改内核将Atlas实时更换掉,也可以复制并制作另一个SDUIPrefab,只改变Atlas中指向标清画质的部分。

UGUI稍微复杂一点,但原理差不多,虽然它不能实时改变图集来
切换高清画质和标清画质,但也可以通过制作一个SDPrefab来达到高清和标清切换的目的。步骤是,首先复制所有图片到SD文件夹,并加上前缀SD,这样好辨认,然后复制一个相同的UIPrefab,将其命名为SDUIPrefab,再把复制过来的SDUIPrefab里的图都换成SD里的图。这样,高清和标清UIPrefab都有了相同的逻辑,只是指向的图不同而已。

图集控制

我们优化图集拼接的最终目的是,减小图集大小,减少图集数量,减少一次性加载的图集数量,让游戏运行得更稳、更快。

  1. 充分利用图集空间:放下更多的图
  2. 减少图集大小,例如4096 2048加载会比较慢
  3. 图集合理分类,例如分为常用图、功能图(大厅、背包)链接类图集、场景图(某个场景特有的)等

第五章 3D模型与动画

美术资源规范

UWA本地资源检测

image

image

此工具中涵盖了前面提到的对静态资源的检测,包括网格数据、纹理贴图、音频格式、
材质设置、Animation(动画)数据、着色器、视频格式、Prefab数据等,检测内容包括资源
属性、资源内容、变体分析等。它还具有粒子特效性能检测功能,特别好用。除此之外,还
有场景检测、代码扫描、行业阈值等功能,可为进行优化和静态扫描工作的人员节省不少时
间和精力。

合并3D模型

动态批处理

将场景中的某些物体自动批处理成一个drawcall

  1. 动态批处理只能应用在少于900个顶点的网格中,如果使用了顶点坐标、发现、单独的UV则只能应用于300个顶点内的网格,在此之外如果使用了UV0、UV1、切线,则下降到180顶点内的网格
  2. 两个物体的缩放比例相同
  3. 相同材质球(这是最基本的)
  4. 多管线着色器会打断动态批处理,如:

    • 支持多个灯光的前置渲染,增加了多个渲染通道
    • 旧系统的延迟渲染路径,因为要绘制物体两次
    • 多个pass的着色器
  5. 动态批出里条件严苛,此外合批还需要CPU将所有的顶点转换到世界空间,合并网格也会带来开销,所以虽然他可能减少drawcall,但不一定就能优化性能

一味的减少drawcall不是万能的,开销取决于很多因素,drawcall的开销主要由图形API调用造成,如果准备工作额外的开销大于节省的开销就得不偿失了,例如一个主机设备或流行的API,drawcall的开销很低,那么动态批处理在优化方面的优势不会很大

静态批处理

引擎在离线的情况下进行模型合并

  1. 无论模型多大,只要使用同一个材质球就可以被静态批处理优化(不需要实时转换顶点消耗CPU)
  2. 标记static Batching,不能动,不能旋转、缩放、有动画
  3. 需要消耗额外内存

静态批处理的具体做法是,将所有有静态标记的物体放人世界空间,以材质球为分类标准将它们分别合并,并构建成一个大的顶点集合和索引缓存,所有可见的同类物体就会被同一批drawcall处理,这会让一系列的drawcall减少,从而实现优化的效果。

从技术上来说,静态批处理并没有节省3DAPIdrawcall的数量,但它能减少因3DAPI之间的状态改变导致的消耗(频繁的切换渲染状态) 。在大多数平台上,批处理被限制在64000个顶点和64000个索引内(OpenGLES上为48000个,MacOS上为32000个),所以,倘若超过这个数量,则需要取消一些静态批处理对象。

手动合批

动态批处理条件严苛,而静态批处理又不能移动等,所以很多时候我们可以手动编写合并3D模型的程序

利用CombinemMeshes、MeshFilter、MeshRenderer类

子网格的含义

网格里拆出来的子模型,子网格需要用到多个额外的材质球,普通网格只需要一个

MeshFilter和MeshRenderer中mesh、shareMesh以及material和shareMaterial的区别

  1. mesh和material都是实例型变量,对他们执行的操作,都会额外复制一份在赋值(即便是get操作)
  2. share变量是共享型的,当你修改share变量时,指向同一个shareMesh和shareMaterial的模型都会发生改变
  3. materials和sharedMaterials也类似,分别是实例型和共享型,和sharedMaterial的区别是,materials和sharedMaterials可以针对不同的子网格,material和sharedMaterial只针对主网格。也就是说,material和sharedMaterial等于materials[O]和sharedMaterials[0]。

网格、MeshFilter、MeshRenderer的关系

  1. 网格是数据资源,储存了顶点、UV、顶点颜色、三角形、切线、法线、骨骼等提供渲染的数据
  2. MeshFilter是一个承载网格数据的类,网格实例化后被储存在此
  3. MeshRenderer提取MeshFilter的数据,结合自身的materials或sharedMaterials进行渲染

CombineInstance

合并时需要为每个将要合并的网格创建一个CombineInstance实例,并往里面放人mesh、subMesh的索引l,lightmap的缩放和偏移,realtimeLightmap的缩放和偏移(如果有),以及世界坐标矩阵。CombineInstance承载了所有需要合并的数据,合并时需要将CombineInstance数组传入合并接口,即通过Mesh.CombineMeshes接口进行合并。

image

状态机

有限状态机可以简单描述为,实例本身有很多种状态,实例从一种状态切换到另一种状态的动作就是状态机转换,然而转换是有条件的,这个转换条件就是状态机之间的连线。

image

状态机主要包含四个要素

  • 现态:现在的状态
  • 条件:当一个条件满足时,将会触发一个动作或执行状态的迁移
  • 动作:条件满足后执行的动作,执行动作后,既可以迁移状态也可以保持状态,动作不是必须的
  • 次态:条件满足后要迁移的新状态

状态机基本要包含Enter、Update、Exit三个部分

  • Enter用于进入状态时的初始化
  • Exit用于退出状态的处理
  • Update是状态持续时要做的事情(非必要)

适合使用的场景

  1. 场景切换
  2. 人物行为状态切换
  3. 宝箱、机关等多动画的元素
  4. 敌人的AI

简要举例:

  1. 状态

image

  1. 状态管理类

image

3D模型的变与换

顶点列表和索引列表

顶点列表储存所有顶点,索引列表储存哪三个顶点组成一个三角形,此外还需要顶点相同长度的数组储存纹理映射坐标UV、法矢量、切矢量、顶点颜色等

索引的顺序

三个索引决定一个三角形,但我们要考虑三角形的正反、从而决定是否渲染他、因此我们对于一个三角形都是用顺时针列出顶点,从而保证顺利计算出面的朝向

网格数据制作到渲染

我们再来完整地叙述一遍网格数据从制作到渲染的过程。首先,

  1. 美术人员制作3D模型并导出成Unity3D能够识别的格式,即.fbx文件,其中已经包含了顶点和索引数据,
  2. 然后在程序中将.fbx实例化成Unity3D的GameObject,它们身上附带的MeshFilter组件存储了网格的顶点数据和索引数据(我们也可以自己创建顶点数组和索引数组,以手动的方式输人顶点数据和索引数据,就如上文描述矩形网格那样)
  3. 。MeshFilter可用于存储顶点和索引数据,MeshRender或SkinMeshRender可用于渲染模型,
  4. 这些顶点数据通常都会与材质球结合,在渲染时一起送入图形卡,其中与我们预想的不一样的是,在送入时并不会由索引数据送入,而是由三个顶点一组组成的三角形顶点送人图形卡。
  5. 接着由图形卡负责处理我们送入的数据,然后渲染帧缓存,并输出到屏幕。除了通过顶点和索引描述模型的轮廓外,还需要其他数据来渲染模型,包括贴图、UV、
    颜色、法线等。下面介绍这些常见的数据是如何作用在模型渲染上的

贴图是如何渲染的呢?

切割模型

我们知道,在Unity3D中,3D模型是由一个渲染实例构成的,也就是说,一个渲染
(Render)组件(MeshRender或SkinnedMeshRender,这里统称为渲染组件)只能渲染一个
模型。那么,如果要把一个渲染的模型切割成两个,就相当于把这个渲染组件变成两个渲染
组件,从而渲染两个不同的模型。

  1. 通过切割线的起点和终点得到一个向量,外加摄像机的forward做叉积得到切割平面的法线
  2. 之后就用切割平面的一点 p-mesh的某个点得到一个向量,二者点击 ,结果符号一样的代表在切割后的一侧。
  3. 对于每个三角形判断是否三个点都在一侧,不在一侧代表要切割
  4. 算交点,可以用线段两侧算垂直距离, 然后通过比例来获取实际的位置

    //线段
    Vector3 AB = B - A;
    //P0代表切割平面上一点,一般就是滑动起点或者终点,N代表法线
    float dotA = Vector3.Dot((A - P0), N);
    float dotB = Vector3.Dot((B - P0), N);
    //计算T 
    float t = dotA / (dotA - dotB);
    //计算交点
    Vector3 intersection = A + t * (B - A);

简单示例代码

using UnityEngine;
using System.Collections.Generic;

public class CubeCutting : MonoBehaviour
{
    public Camera cam;             // 主摄像机
    public GameObject cube;        // 目标立方体
    private Plane cuttingPlane;    // 切割平面

    void Start()
    {
        Vector3 cubeCenter = cube.transform.position;
        Vector3 planeNormal = Vector3.up; // 垂直向上
        cuttingPlane = new Plane(planeNormal, cubeCenter);
    }

    void Update()
    {
        if (Input.GetMouseButtonDown(0))
        {
            touchStartPos = Input.mousePosition;
        }

        if (Input.GetMouseButtonUp(0))
        {
            Vector2 touchEndPos = Input.mousePosition;
            TryCutCube(touchStartPos, touchEndPos);
        }
    }

    private Vector2 touchStartPos;

    void TryCutCube(Vector2 startScreenPos, Vector2 endScreenPos)
    {
        // 1. 从屏幕点发射射线
        Ray startRay = cam.ScreenPointToRay(startScreenPos);
        Ray endRay = cam.ScreenPointToRay(endScreenPos);

        // 2. 计算射线和平面的交点
        if (cuttingPlane.Raycast(startRay, out float startDist) &&
            cuttingPlane.Raycast(endRay, out float endDist))
        {
            Vector3 startPoint3D = startRay.GetPoint(startDist);
            Vector3 endPoint3D = endRay.GetPoint(endDist);

            // 3. 计算切割平面法线
            Vector3 camForward = cam.transform.forward.normalized;
            Vector3 lineDir = (endPoint3D - startPoint3D).normalized;

            Vector3 planeNormal = Vector3.Cross(lineDir, camForward).normalized;

            // 4. 根据起点和法线重新定义切割平面
            cuttingPlane = new Plane(planeNormal, startPoint3D);

            // 5. 获取立方体网格数据
            Mesh cubeMesh = cube.GetComponent<MeshFilter>().mesh;
            Vector3[] vertices = cubeMesh.vertices;
            int[] triangles = cubeMesh.triangles;
            Transform cubeTransform = cube.transform;

            // 6. 将顶点分组到切割平面的两侧
            List<Vector3> positiveSideVertices = new List<Vector3>();
            List<Vector3> negativeSideVertices = new List<Vector3>();
            List<int> positiveSideIndices = new List<int>();
            List<int> negativeSideIndices = new List<int>();

            for (int i = 0; i < vertices.Length; i++)
            {
                Vector3 worldVertex = cubeTransform.TransformPoint(vertices[i]);
                float distance = cuttingPlane.GetDistanceToPoint(worldVertex);
            
                if (distance >= 0)
                {
                    positiveSideVertices.Add(worldVertex);
                    positiveSideIndices.Add(i);
                }
                else
                {
                    negativeSideVertices.Add(worldVertex);
                    negativeSideIndices.Add(i);
                }
            }

            Debug.Log($"正侧顶点数: {positiveSideVertices.Count}, 负侧顶点数: {negativeSideVertices.Count}");

            // 7. 分析三角形是否需要被切割
            List<int> trianglesToCut = new List<int>();
            Dictionary<int, List<Vector3>> intersectionPoints = new Dictionary<int, List<Vector3>>();

            for (int i = 0; i < triangles.Length; i += 3)
            {
                int idx1 = triangles[i];
                int idx2 = triangles[i + 1];
                int idx3 = triangles[i + 2];

                Vector3 v1 = cubeTransform.TransformPoint(vertices[idx1]);
                Vector3 v2 = cubeTransform.TransformPoint(vertices[idx2]);
                Vector3 v3 = cubeTransform.TransformPoint(vertices[idx3]);

                float d1 = cuttingPlane.GetDistanceToPoint(v1);
                float d2 = cuttingPlane.GetDistanceToPoint(v2);
                float d3 = cuttingPlane.GetDistanceToPoint(v3);

                // 检查三角形是否跨越切割平面
                if ((d1 * d2 < 0) || (d1 * d3 < 0) || (d2 * d3 < 0))
                {
                    trianglesToCut.Add(i); // 记录需要切割的三角形起始索引

                    // 计算三角形边与平面的交点
                    List<Vector3> intersections = new List<Vector3>();

                    // 边1-2
                    if (Mathf.Sign(d1) != Mathf.Sign(d2))
                    {
                        Vector3 intersection = GetEdgePlaneIntersection(v1, v2, d1, d2);
                        intersections.Add(intersection);
                    }

                    // 边1-3
                    if (Mathf.Sign(d1) != Mathf.Sign(d3))
                    {
                        Vector3 intersection = GetEdgePlaneIntersection(v1, v3, d1, d3);
                        intersections.Add(intersection);
                    }

                    // 边2-3
                    if (Mathf.Sign(d2) != Mathf.Sign(d3))
                    {
                        Vector3 intersection = GetEdgePlaneIntersection(v2, v3, d2, d3);
                        intersections.Add(intersection);
                    }

                    intersectionPoints.Add(i, intersections);
                }
            }

            Debug.Log($"需要切割的三角形数量: {trianglesToCut.Count}");
        }
        else
        {
            Debug.Log("射线未与切割平面相交,切割失败");
        }
    }

    // 计算边与切割平面的交点
    Vector3 GetEdgePlaneIntersection(Vector3 v1, Vector3 v2, float d1, float d2)
    {
        // 使用线性插值找到交点
        float t = d1 / (d1-d2);
        return v1 + t * (v2 - v1);
    }
}

其他应用

  • 爆炸凹陷、球体拉伸反弹相比切割模型要更加简单
  • 陶艺模拟等

模型简化

可以用二次项误差作为度量代价的边收缩算法,批量简化出一些低质量的LOD模型

相对精度较高的再让美术人员手动处理,平衡效果和时间

蒙皮骨骼动画[重要]

SkinnedMeshRenderer

蒙皮网格通过骨骼层级变换+顶点混合权重实现动态形变,普通网格仅渲染固定形状。

组件MeshRendererSkinnedMeshRenderer
依赖组件MeshFilterMeshFilter + 骨骼系统
核心数据顶点/UV/法线顶点/UV/法线 + 骨骼 + 顶点权重
动画支持静态模型骨骼动画变形
渲染流程直接绘制静态网格动态计算骨骼变换后的顶点位置
性能消耗较低较高(需实时计算蒙皮)

骨骼动画

主要流程

  1. 美术人员通过3D建模软件制作好骨骼点(bones),并计算出每个顶点受哪些骨骼的影响以及权重
  2. 动画人员通过对于骨骼点的移动、旋转等操作制作骨骼动画(关键帧记录)
  3. 以.fbx导入Unity,使用Animation(关键帧动画)播放

其他说明

  1. 我们一般会使用关键帧动画播放,只在关键帧设置数据,关键帧之间可以使用插值的方式计算(如LBS(线混合蒙皮)和BBW(有界双调和权重)),蒙皮网格会根据最终插值后的所有帧数据,计算每个顶点的位置:
  2. 每个骨骼除了坐标外还有变化矩阵,一个骨骼节点的变化通过矩阵的连续右乘实现

image

  1. 简要代码示例

image

3D模型动画换皮换装

由于骨骼点的移动影响网格顶点,更换了模型的网格依然可以根据骨骼点计算出偏移量,为了实现相同效果的动画,必须规定角色的所有动画和部件只使用同一套骨骼。

其次,把骨骼和模型部件拆分开来,骨骼文件里只有骨骼数据,每个部件的模型文件只包含它自已的模型的顶点数据,以及顶点上的骨骼权重数据。

其实就是把一个人物模型拆分成多个fbx文件,其中一个fbx文件只有骨骼数据其他fbx文件是每个部件的模型数据,它们都包含了已经计算好的骨骼权重数据。

只要骨骼点不变,每个部件上模型的顶点数据也会始终映射到同一套骨骼上。每次在更换部件时,只要把原来的部分删除,更换成新的部件即可。

然后只需要把骨骼数据和新的模型部件绑定上即可,具体为

我们将骨骼fbx文件中的模型数据实例化后就得到了一个蒙皮网格实例,基础的骨骼数据就包含在这个实例中,接着把需要展示的各个部件fbx文件中的模型实例化出来,它们就拥有了自己的蒙皮网格实例,最后将骨骼信息从前面骨骼的蒙皮网格实例中取出来赋值给它们,包括所有骨骼节点及变换矩阵。

最后在骨骼fbx的蒙皮网格绑定上Animator动画,当骨骼的蒙皮网格的动画文件播放时,由于其他部件模型绑定的是该骨骼fbx的骨骼点,他们也就会有对应的动画。

每次我们替换部件只需要把原有部分删除,实例化一个新的,骨骼影响权重则是直接存在部件模型中,我们只需要给新部件绑定骨骼点和变换矩阵即

一些弊端

假设我们把一个模型拆分为诸如头、手、身体、腿、脚五个部分,此外还需要一个骨骼fbx,共计六个蒙皮网格,这些部件可能材质、贴图不同,导致增加drawcall,骨骼动画本身就消耗性能,导致性能更加下降

更好的办法是把这5个部件合并成一个模型,它们都使用同一个材质球(保证角色渲染),Unity3D的Mesh.CombineMeshesO方法可用于实现模型的合并。

另一种简单的办法是仍然使用多个材质球进行渲染,在合并网格时使用子网格模式,相当于只减少了蒙皮网格组件的数量,并没有降低其他消耗。

对于贴图,在更换人物的部件模型时,将5张贴图动态地合并成一张,并在合并贴图的同时改变每个模型部件的UV,将它们的UV偏移到这张合并图的某个范围内。

但要注意的是合并贴图、模型本质上还是空间换时间

简要伪代码示例
using UnityEngine;
using System.Collections.Generic;

public class CharacterMeshCombiner : MonoBehaviour
{
    [Header("Settings")]
    public bool mergeTextures = true;
    public Texture2D atlasTexture; // 手动合并好的图集或运行时生成

    void Start()
    {
        CombineCharacterMeshes();
    }

    void CombineCharacterMeshes()
    {
        // 1. 获取所有部件的SkinnedMeshRenderer
        SkinnedMeshRenderer[] partRenderers = GetComponentsInChildren<SkinnedMeshRenderer>(true);
        if (partRenderers.Length == 0) return;

        // 2. 准备合并数据
        List<CombineInstance> combineInstances = new List<CombineInstance>();
        List<Transform> bones = new List<Transform>();
        List<Vector2[]> uvOverrides = new List<Vector2[]>(); // 用于UV重映射
        Texture2D generatedAtlas = null;

        // 3. 合并贴图(
        if (mergeTextures)
        {
            generatedAtlas = GenerateAtlas(partRenderers); // 实现贴图合并逻辑
            atlasTexture = generatedAtlas;
        }

        // 4. 遍历所有部件,收集网格和骨骼数据
        for (int i = 0; i < partRenderers.Length; i++)
        {
            SkinnedMeshRenderer part = partRenderers[i];
        
            // 4.1 添加网格数据(保留骨骼权重)
            for (int subMesh = 0; subMesh < part.sharedMesh.subMeshCount; subMesh++)
            {
                combineInstances.Add(new CombineInstance()
                {
                    mesh = part.sharedMesh,
                    subMeshIndex = subMesh,
                    transform = part.transform.localToWorldMatrix
                });
            }

            // 4.2 收集骨骼(去重)
            foreach (Transform bone in part.bones)
            {
                if (!bones.Contains(bone))
                    bones.Add(bone);
            }

            // 4.3 计算UV偏移(如果合并了贴图)
            if (mergeTextures && atlasTexture != null)
            {
                Vector2[] uvs = part.sharedMesh.uv;
                Vector2[] newUVs = new Vector2[uvs.Length];
                Rect uvRegion = GetAtlasRegionForPart(i); // 获取该部件在图集中的区域(伪代码)
            
                for (int j = 0; j < uvs.Length; j++)
                {
                    // 将原始UV映射到图集的指定区域
                    newUVs[j] = new Vector2(
                        Mathf.Lerp(uvRegion.xMin, uvRegion.xMax, uvs[j].x),
                        Mathf.Lerp(uvRegion.yMin, uvRegion.yMax, uvs[j].y)
                    );
                }
                uvOverrides.Add(newUVs);
            }
        }

        // 5. 创建合并后的Mesh
        Mesh combinedMesh = new Mesh();
        combinedMesh.CombineMeshes(combineInstances.ToArray(), true, true); // 关键参数:合并子网格、保留骨骼

        // 6. 应用UV重映射
        if (mergeTextures && uvOverrides.Count > 0)
        {
            combinedMesh.uv = CombineUVs(uvOverrides); // 合并所有UV数组(伪代码)
        }

        // 7. 设置到主Renderer
        SkinnedMeshRenderer mainRenderer = gameObject.GetComponent<SkinnedMeshRenderer>();
        if (mainRenderer == null)
            mainRenderer = gameObject.AddComponent<SkinnedMeshRenderer>();

        mainRenderer.sharedMesh = combinedMesh;
        mainRenderer.bones = bones.ToArray();
        mainRenderer.sharedMaterial = CreateAtlasMaterial(atlasTexture); // 创建材质球(伪代码)

        // 8. 禁用原始部件
        foreach (var part in partRenderers)
        {
            if (part != mainRenderer)
                part.gameObject.SetActive(false);
        }
    }

    //伪代码
    Texture2D GenerateAtlas(SkinnedMeshRenderer[] parts)
    {
    
    }

    Rect GetAtlasRegionForPart(int partIndex)
    {
  
    }

    Material CreateAtlasMaterial(Texture2D atlas)
    {
   
    }
}

捏脸

其实就是更复杂的换皮换装,部件更多

此外,捏脸系统除了执行更换部件、更换颜色的操作外,还有一个重要的功能,就是用户可以自由随意塑造模型。例如,把鼻子抬高点、把嘴巴拉宽点、把腰压细一点、把腿拉长一点等。

这其实就是前面模型变换的部分,但我们并不是操作顶点,可以直接操作骨骼点。

对应顶点就会因为骨骼的变动而发生变换,但我们不能让用户直接去操作动画关键帧操作的骨骼点(播放动画会强行恢复),我们可以专门添加一些特殊的骨骼点,他不被动画关键帧使用,以满足上述需求

但对于脸部的动画,相对比较复杂,网格变化多,我们不能能添加极多的骨骼点,对于此我们可以制作两个极端的表情网格,然后使用两个模型插值计算的方式得到网格变化的性状,从而形成不同表情的动画

事实上换装和捏脸是非常复杂的,上述仅仅是讲述简要流程,其中涉及的知识点,算法是非常多的。

蒙皮网格动画优化

前面介绍了关于蒙皮动画太消耗CPU的问题,通常所有蒙皮网格的变化都是由CPU计算得到的,这就使得CPU的负担比较重,因为游戏中的动画量通常会比较大。
Unity3D有一个CPUSkinning的选项开启后,引擎会使用多线程+SIMD来对蒙皮网格的计算做加速处理,由于每个顶点的变化都是独立于骨骼点之上的,相邻的顶点并不会互相影响,因此可以使用多线程将一个模型的网格顶点拆分成多个顶点进行计算,多线程的使用将提高蒙皮网格计算的速度。

但GPUSkinning只是加快了计算速度,并没有减少计算量

着色器动画

除了通过CPU利用骨骼计算改变顶点坐标的位置外,还有另外一种途径可用于改变顶点坐标位置,即着色器中的顶点着色器

顶点着色器可以改变网格顶点的位置。于是,我们可以使用顶点着色器,再加上一种合适的顶点变化算法,就可以得到一个随着时间变化的模型动画。

常见的如随风摆动的草、会飘动的旗子、飘动的头发、左右摇摆的树、河流的波浪等。这些算法大部分都会利用时间因子、噪声(noise)算法、数学公式(sin、cos等)来表达顶点的偏移量。

UV动画

不断流淌的水流就属于UV位移动动画

又如火焰效果,可以根据不断更换UV范围达到序列帧动画效果的UV序列帧动画

对于UV序列帧我们可以先定义帧动画的总帧数、图元排列的行数和列数,以及播放速度,顶点着色器的计算过程是通过当前的时间和速度及总帧数,获得当前所在的帧数,再用帧数计算图片所在的行位置和列位置,最后用行位置和列位置计算UV数据,UV数据传人片元后,片元着色器从图片中提取像素颜色,交给后面的步骤进行渲染。

再如不停旋转的面片动画,可以用UV旋转来代替面片旋转,

将CPU的消耗转为GPU的消耗,部分计算更为高效。

但这事实上是将CPU的消耗转移到GPU的方法,具体要根据实际性能瓶颈决定

离线制作加速动画

动画的实质是,每帧显示的内容都不一样
我们可以使用更多的模型,每帧都展示一个已经准备好的不一样的模型,这样每帧都可由不同形状的模型渲染形成动画。
例如**一个时长为5秒的蒙皮网格动画,每秒30帧,总共需要150个画面,我们最多要
准备150个模型依次在每帧中播放。 这样一来,内存和硬盘的代价就会很大,原本一个模型只要一个模型网格就够了,现在要准备150个网格。这就是用内存换CPU的想法,但到底值不值得这么做呢?**
**假设这个场景只有2~3个模型在播放该动画,那么为了这2~3个模型动画,就需
要额外准备150个模型来播放该动画,本来只要一个模型+骨骼就可以办到的事情,却要用150个模型来代替,加载这150个模型也是需要时间的,更何况内存额外加大了150倍,确实不值得。**

那么再假设场景中同时播放该动画的模型数量非常多,如20个以上,这20个模型每帧都需要通过模型+骨骼的方式计算出一个模型的变化形状,而且要重复计算100次,这时如果是用150个模型来代替每帧持续的CPU消耗就值得了。

GPU Instancing加速动画

GPUInstancing可以使用一个网格数据和PerInstancingAttribute的buffer,通过id得到不同变换矩阵,颜色等用一个drawcall绘制一千个相同网格的物体(注意不能使用骨骼动画,且网格、材质、贴图必须一致,事实上就是绘制这一千个物体的时候,渲染状态不能切换)

那么我可以利用贴图,例如我们需要一个使用150个模型的动画,我们可以准备一个

150行的图片,用rgba保存顶点的oxyz

这样,如果一个网格总共有3000个顶点,那么每行就有3000个像素,总共150行,这张贴图就是3000×150像素大小。

然后每次取贴图的数据作为顶点数据实现动画

当然我们还需要额外传入动画状态等数据,保证每个物体的动画不一致

LOD网格和动画

传统骨骼动画的网格变化是由骨骼点与顶点的权重数据计算得到的。也就是说,顶点数量越多,骨骼数量越多,有效权重数据越多,CPU消耗也就越多,CPU消耗与三者中任何一个都成正比。反过来也是一样,顶点越少,骨骼数越少,消耗也就越少

我们可以准备三套模型、骨骼,根据机型去设置不同的模型,同时远处的人物可以使用低模去播放动画。

资源的加载与释放

简要

  1. Rersource.Load

    随包资源(Unity会压缩),放在Assets/Resrouces文件夹下,打包后不可变

  2. FileRead、解密\解压、AssetBundleCreateFromMemory、AssetBundle.Load

    这种方式需要先加载整个ab包,解密后,在创建整个ab包,会占用两份内存,1.3倍的算力,但可以实现加密、解密功能

  3. AssetBundle.CreateFromFile+AssetBundle.Load

    不需要解密或者解压直接加载AB包,这种方式会先加载AB包的数据头,然后在我们调用Load资源的时候,先从数据头获取资源偏移量,再去加载对应资源,实现内存按需分配

除了阻塞式加载,在Unity3D中还有非阻塞式的加载方式,列举如下。
1)AssetBundle.CreateFromFile+AssetBundle.LoadAsync
2)WWW+AssetBundle.Load
3)www+AssetBundle.LoadAsync
4) FileRead all+AssetBundle.CreateFromMemory+AssetBundle.Load
5)FileReadall+AssetBundle.CreateFromMemory+AssetBundle.LoadAsync
6)FileReadasync+AssetBundle.CreateFromMemory+AssetBundle.Load
7) FileReadasync+AssetBundle.CreateFromMemory+AssetBundle.Load
8)FileReadasync+AssetBundle.CreateFromMemory+AssetBundle.LoadAsync

引用计数

我们可以封装Assetbundle,在加载某个AB包并实例化某个资源的时候增加对应的引用计数,当使用该资源的类销毁或者不使用,调用统一的Unload接口,减少引用计数,如果引用计数为0,为了防止后续该包可以使用,我们可以添加一个倒计时,如5s后卸载该包(当然为了防止大量包一起卸载,可以改成随机的范围值)

AseetBundle打包和颗粒度

太大太小都不好,可以根据Prefab、材质、动画分类,以及使用的场景,其中UI可以分的更细一点。太大虽然解决IO,但是内存消耗大,太小,IO、引用计数、解压等等消耗大,管理也困难。

第六章 网络通信

TCP

TCP包头

image

  • 源端口号:本次TCP连接中,发起连接的主机使用的端口号;
  • 目的端口号:本次TCP连接主,接受连接的主机使用的端口号;
  • 序号:通过TCP传输的每一个数据段,都有一个序号,作用是为了确认此数据段的顺序。网络中允许传输的数据长度是有限制的,所以当我们要通过TCP传输一个较大的数据时,TCP会将数据切割成很多小的数据段进行传输。而将这些小的数据段发送到目的主机时(发送方会同时发送多个数据),并不能保证它们是按顺序到达目的地,所以对于每一个数据段,都要有一个序号,来标识它们是属于总数据的哪一部分,以保证在目的主机中能将他们重新拼接。
  • 确认序号:接收方若接收到一个数据段,会发送一个确认报文给发送方,告诉发送方已经接收到这个数据段,而确认序号的作用就是告诉发送方接收到了哪条数据段。若接收方接收到了序号为n的报文段,则确认序号将是n+1,表示它已经接收了n,下一条想要接收n+1
  • 首部长度TCP报文的首部+选项的字节数;
  • ACK:只有1 bit的标志位,若为1,表示这个数据段中的确认序号是有效的,即这个数据报是对之前接收到的某个报文的确认(一个TCP报文可以同时作为确认报文和传递数据报文)。
  • RST:只有1 bit的标志位,若客户端向服务器的一个端口请求建立TCP连接,但是服务器的那个端口并不允许建立连接(比如没开启此端口),则服务器会回送一个TCP报文,将RST位置为1,告诉客户端不要再向这个端口发起连接;
  • SYN:只有1 bit的标志位,若为1,表示这是一条建立连接的TCP报文段;
  • FIN:只有1 bit的标志位,若为1,表示这是一条断开连接的TCP报文段;
  • PSH:推送标志位,表示该数据包被对方接收后应立即交给上层应用,而不在缓冲区排队;
  • URG:紧急标志位,表示数据包的紧急指针域有效,用来保证连接不被阻断,并督促中间设备尽快处理;

三次握手和四次挥手

三次握手

TCP建立连接的过程中需要发送三次报文,所以TCP建立连接也被称为三次握手,接下来我就来讲讲这三次握手的过程,假设客户端向服务器发起TCP连接:

  • 第一步:客户端的TCP程序首先向服务器的TCP程序发送一个TCP报文。这个报文不包含数据,且它的SYN标志位被置为1,表示这是一条建立连接的TCP报文段,因此这个报文段也被称为SYN报文段。客户端的TCP程序随机选择一个序号作为客户端报文的初始序号(假设序号为client_isn),放入这个报文段的序号部分。这个报文段由运输层传递到网络层后,被封装在一个IP数据报中发往服务器;
  • 第二步:包含SYN报文段的IP数据报被服务器接收,服务器的网络层将SYN数据报抽取出来,交给运输层,同时服务器为该TCP连接分配资源(包括发送缓存、接收缓存和变量等),并向客户发送允许连接的TCP报文段。这条允许连接的报文段不包含数据,SYN标志位也被置为1,同时它的ACK标志位也被置为1,表示它是SYN报文段的确认报文,所以这条允许连接的报文段也被称为SYNACK报文段。服务器随机选择一个序号,作为服务器报文段的初始序号(假设称为server_isn),并将其放入SYNACK报文段的序号部分,同时确认号字段被设置为client_isn + 1SYN报文段的序号+1)。这个报文段可以解释为服务器向客户端说:“我收到了你的连接请求,我允许你连接,我的初始序号是server_isn”。
  • 第三步:当客户端接收到SYNACK报文段后,它也将为TCP连接分配资源(缓存和变量),同时生成一条SYNACK报文段的确认报文,并发送给服务器。由于经过上面两个步骤,已经算是建立了连接,所以这次的SYN标志位将被置为0,而不是1ACK标志位是1)。同时,这条报文段的序号被设置为client_isn + 1(第一条客户报文的序号是client_isn,而这是它的下一条,所以+1),而确认序号被设置为server_isn + 1(第一条服务器报文的序号是server_isn,客户端成功接收,所以期望服务器下一次发送server_isn + 1)。和上面两条报文不同,第三条报文可以携带数据,比如HTTP的请求就是在TCP的第三次握手报文中发送到服务器的。

image

为什么是三次握手?

首先我们要明确,两次握手是必要的第一次握手,客户端将SYN报文发送到服务器,服务器接收到报文后,即可确认客户端到服务器是可达的第二次握手,服务器向客户端发送响应的SYNACK报文,客户端接收到后,即可确认服务器到客户端也是可达的。至此,连接已经算是建立那为什么还要有第三次握手呢?

客户端和服务器的握手过程,不仅仅是确认互相可达的过程,更重要的是一个同步的过程,SYN就是同步(Synchronize)的缩写。对于TCP报文段来说,序号是一个至关重要的部分,它保证了TCP传输数据的完整性。而我们上面也说过,TCP报文的初始序号不是从0开始的,而是一个随机的序号,而所谓的同步,就是TCP客户端和服务器互相同步初始序号的过程

  • 第一次握手,客户端发送SYN报文,将自己的初始序号发送到了服务器,
  • 第二次握手,服务器接收到SYN报文后,向客户端发送SYNACK报文段,告诉客户端已经收到了它的初始序号,同时在这个报文段中带上了自己的初始序号。
  • 这个时候,第三次握手的作用就出来了:第三次握手实际上就是客户端在告诉服务器,自己已经收到了它的初始序号,完成了同步,可以开始相互传输数据了

若没有第三次握手,服务器将无法保证客户端接收到了自己的SYNACK报文段若此时SYNACK报文段丢失,客户端不知道服务器的初始序号,将无法处理之后服务发送到达客户端的数据。

此外网上有另一种说法,若仅仅是两次握手,将产生以下问题:

客户端向服务器发送SYN报文段请求建立连接,但是没有在指定时间内收到SYNACK报文段,所以客户端认为SYN报文段在网络中丢失,则再次发送SYN报文段,并成功接收到了SYNACK报文段但是客户端在很短的时间内就断开了TCP连接。然而,最初的SYN报文并没有丢失,只是传输时延太长,过了许久才到达。等它到达服务器时,其实客户端已经与服务器建立过TCP连接,并且已经断开了。此时服务器接收到这条SYN报文段,以为客户端又想建立一条新的连接,于是向客户端回送ACK报文,并为连接分配了资源

由于没有第三次握手,服务器将不知道这其实是上一次连接的报文(没有确认序号) ,于是将它创建一个新的TCP连接并维持,直至因为太久没有接收到数据而释放。这种情况非常浪费资源,所以为了防止这种情况的发生,才需要客户端的再一次确认。

但其实,上述内容第一点的同步才是三次握手的根本原因,第二点只是第一点带来的好处罢了。

四次挥手

TCP在断开连接时,客户端与服务器之间要交换四次报文,所以,TCP的断开连接也叫四次挥手。

  • 第一步:客户端进程发出断开连接指令,这将导致客户端的TCP程序创建一个特殊的TCP报文段,发送到服务器。这个报文段的FIN字段被置为1,表示这是一条断开连接的报文;
  • 第二步服务器接收到客户端发来的断开连接报文,向客户端回送这个报文的确认报文ACK字段为1),告诉客户端服务器已经接收到FIN报文,并允许断开连接,此时客户端会等待服务器发送FIN报文。
  • 第三步:服务器发送完确认报文后,服务器的TCP程序(这个阶段可能服务器还有要发送/处理的数据,需等待处理完毕)创建一条自己的断开连接报文,此报文的FIN字段被置为1,然后发往客户端;
  • 第四步客户端接收到服务器发来的FIN报文段,则产生一条确认报文ACK1),发送给服务器,告知服务器已经接收到了它的断开报文。服务器接收到这条ACK报文段后,释放TCP连接相关的资源(缓存和变量) ,而客户端等待一段时间后(半分钟、一分钟或两分钟),也释放处于客户端的缓存和变量;
为什么是四次挥手?

断开连接为什么是四次挥手,不能是两次呢?其中一方请求断开连接,另一方确认即可,为什么这个过程需要两边各发起一次?原因就是:TCP连接是全双工的。什么是全双工,AB建立连接,则A可以向B发送数据,而B也可以向A发送数据。

C#实现TCP

常用API

  • BeginConnect:开始连接。
  • BeginReceive:开始接收信息。
  • BeginSend:开始发送数据。
  • BeginDisconnect:开始断开。
  • Disconnect(Boolean):立刻断开连接。(同步)

线程锁

实际项目的网络模块中,所有的操作都会以线程级的形式对待,**而Unity3D的API大
都需要在主线程上运作,这里就涉及主线程和子线程对资源抢占冲突导致需要线程锁的问题。**

lock(obj)
{
    mQueue.Push(data);
}

由于两边的线程都需要对队列进行操作,所以每次对线程共享的资源进行操作时,都需要先进行锁确认,没有锁才会去操作数据。

缓冲队列

网络收发时,会源源不断地发送和接收数据,很多时候,程序还没处理好当前的数据包,就有许多数据包已经从服务器传送到了客户端发送数据也是一样,会瞬间积累很多需要被发送出去的数据包,这些数据包如果没有保存好,则无法进行重发甚至还会丢失,所以我们需要用一个队列来进行存储和缓冲,这个队列被称为缓冲队列。

// 全局变量
Queue<NetworkData> networkDataQueue = new Queue<NetworkData>();
Socket socket;
bool isConnected = false;

// 初始化连接
void Connect(string ip, int port)
{
    try {
        socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
        socket.Connect(ip, port);
        isConnected = true;
        StartReceiving();
    }
    catch (Exception e) {
        Log(LogerType.ERROR, "连接失败: " + e.Message);
        DisConnect();
    }
}

// 开始接收数据
void StartReceiving()
{
    if (!isConnected) return;
  
    try {
        byte[] buffer = new byte[1024]; // 接收缓冲区
        socket.BeginReceive(buffer, 0, buffer.Length, SocketFlags.None, Receive_Callback, buffer);
    }
    catch (Exception e) {
        Log(LogerType.ERROR, "开始接收失败: " + e.StackTrace);
        DisConnect();
    }
}

// 接收回调函数
void Receive_Callback(IAsyncResult _result)
{
    if (!isConnected) return;
  
    try {
        int bytesRead = socket.EndReceive(_result);
        if (bytesRead > 0) {
            byte[] buffer = (byte[])_result.AsyncState;
            byte[] receivedData = new byte[bytesRead];
            Array.Copy(buffer, receivedData, bytesRead);
        
            // 将数据推入队列
            PushNetworkData(receivedData);
        
            // 继续接收下一条消息
            StartReceiving();
        }
    }
    catch (Exception e) {
        Log(LogerType.ERROR, "接收回调错误: " + e.StackTrace);
        DisConnect();
    }
}

// 将数据推入队列
void PushNetworkData(byte[] data)
{
    lock (networkDataQueue) {
        NetworkData networkData = new NetworkData {
            RawData = data,
            ReceiveTime = DateTime.Now
        };
        networkDataQueue.Enqueue(networkData);
    }
}

// 从队列取出数据
NetworkData PopNetworkData()
{
    lock (networkDataQueue) {
        if (networkDataQueue.Count > 0) {
            return networkDataQueue.Dequeue();
        }
        return null;
    }
}

// 主线程处理数据
void Update()
{
    while (isConnected) {
        NetworkData data = PopNetworkData();
        if (data != null) {
            DealNetworkData(data);
        }
    }
}

// 处理网络数据
void DealNetworkData(NetworkData data)
{
}

// 断开连接
void DisConnect()
{
    isConnected = false;
  
    try {
        if (socket != null && socket.Connected) {
            socket.Shutdown(SocketShutdown.Both);
            socket.Close();
        }
    }
    catch (Exception e) {
        Log(LogerType.ERROR, "断开连接错误: " + e.Message);
    }
    finally {
        socket = null;
    }
  
    Log(LogerType.INFO, "连接已断开");
}

双队列缓存

前面提到的缓冲队列是多线程编程中常用的手段之一,不过它的效率还不够高,多个线程会因为锁的效率影响而被锁点卡住,导致其他线程无法继续工作。双队列数据结构就能很好地解决这个问题,它能提升多线程中队列的读/写效率。



void Receive_CallBack(Data _result)
{
    Pushdata(_result);
}
void SwitchQueue()
{
    lock(obj)
    {
        Swap(receiveQueue,produceQueue);
    }

}
void Update()
{
    SwitchQueue();
    while((data =PopQueue())!=null)
    Deal_with_network_data(data);
}

当接收到数据包时,与普通接收一样,只需将数据推送到接收队列中即可。不同的是,当主线程需要处理数据时,先切换队列,防止对队列占用过多时间,切换完毕后,再对队列中的全部数据进行处理。

发送数据

建立发送缓冲可以保证发送的有序和高效,而发送队列以及对发送数据的合并就是很好的策略。其具体步骤如下。

  • 每次当你调用发送接口时,先把数据包推人发送队列,发送程序就开始轮询是否有需要发送的信息在队列里,有就发送,没有就继续轮询等待。
  • 发送时合并队列里的一部分数据包,这样可以一次性发送多个数据包,以提高
    效率。
  • 对这种合并操作进行限制,如果因为合并而导致数据包太大,也会导致效率太低。发送过程中,只要丢失一个数据就要全盘重新发送,在数据包很大、发送速度很缓慢的情况下,又要重新整体发送,就会使发送效率大大降低,合并数据的大小限制在窗口大小范围内(2的16次字节内)

协议数据定义

在网络数据传输中,协议是一个比较重要的关键点,它负责指定客户端与服务器端的交流方式。简单来说,协议就是客户端和服务器端商讨后达成一个对数据格式的协定,是客户端与服务器端进行交流的语言。

  • 选择客户端和服务端都能接受的格式
  • 数据包尽量最小化,如采取ProtocolBuff或者其他变种,再或者对数据包进行压缩
  • 要有校验能力:如md5,CRC循环、奇偶校验
  • 为了防止游戏数据被篡改,可以采取加密,如:RSA,公匙私匙、非对称加密等

校验

MD5

这种校验方式比较直接,将数据块整个用MD5散列函数生成一个校验字符串,将校验字符串保存在数据包中。当服务器收到数据包时,会对整个数据块执行同样的MD5操作,如果结果一致则认为校验通过,否则就认为被人为修改过。

奇偶校验

奇偶校验与MD5类似,只是使用的函数方法不同。将每个数据进行“异或”赋值成
一个变量,再将这个变量保存在数据包中。服务器收到数据包时,也对整个数据块执行同样的操作,与数据包中的校验值进行比较,如果数据一致,则认为校验正确,否则认为数据被人篡改过。

CRC

循环冗余校验,是利用除法及余数的原理来进行错误检测的。将接收到的数据组
进行除法运算,如果能除尽,则说明数据校验正确,如果不能除尽,则表明数据被篡改过。循环余校验算法的步骤如下:

  1. 前后端约定一个除数。
  2. 将数据块用除数取余。
  3. 将余数保存在数据包中。
  4. 服务器收到数据后,将余数和数据块相加,并进行取余操作。
  5. 若余数为0,则认为校验正确,否则认为数据被篡改过。

数据数组中,将每4字节的数据合并后除余得到一个1字节以内的余数,**每4字
节得到1字节的余数,最终得到一组余数数组校验是反向操作,先取4字节的数据组成一个正整数,加上对应的余数,再进行除余操作**,如果不为零,则校验失败,如果全部为零,则校验成功。

加密

两次异或

由于两次“异或”处理就能让数据回到原形,所以算法中常使用“异或”操作来做加密。通常的做法是发送时对数据进行“异或”处理一次,收到时再执行一次“异或”处理,这样就能简单快速地加密解密数据。

非对称加密

密钥常常会暴露在外界,导致一些不怀好意的人在客户端上破解并查看,知道网络数据协议的格式,如果前后端密钥Key是同一个,那么就可以轻易破解,进行一些破坏活动,所以前后端使用同一个密钥会比较危险。

非对称加密会比较安全,这样后端两边的密钥不同,且各自保存,即使当前端密钥泄露,也可以随时替换。

此外我们还要采取多种方式在前端隐藏密匙。

断线检测

采用心跳包的方式,在心跳包协议中,每几秒服务器向客户端发送一个心跳包,包内包含服务器时间、服务器状态等少量信息,然后由接收到这个心跳协议的客户端进行反馈,发送给服务器端一个心跳回应包,心跳回应包内包含客户端的少量信息。

当收到心跳信息时认为连接是存在的, 当服务器30秒内没有收到任何反馈心跳包的信息时,则认为客户端已经断线,这时主动断开客户端的连接。客户端也是同样的协定,

当客户端30秒内没有接收到任何数据包时,则认为网络已经断开,客户端最好主动退出游戏,重新登录、重新连接服务器

UDP

UDP包头

image

UDP是一种无状态连接,但我们仍然可以让其具备(类似)连接确认、校验、重发的机制,基本可以模仿TCP三次握手,心跳包、校验重发。

UDP实现校验重发

TCP的校验机制较为复杂,比较消耗性能(尤其是发送累积量检测),因此在实现UDP的检测和重传时,可以做一些改进,具体如下:

  • A端向B端发送数据包,数据包中包含Seq=1(表示数据包的发送序列),发送后将此数据包推人已经发送但还没有确认的队列里。如果B端接收到Seq=1的数据包,就回应客户端一个确认包,包中Ack=1,表示Seq=1的包已经确认收到。

    如果B端没有接收到数据,客户端x秒后发现仍然没有收到Seq为1的确认包,则判定Seq=1的数据包传输失败,从已经发送但未确认的数据包队列中取出Seq=1的数据包,重新发送。

  • 例如,A端向B端发送了10个数据包,分别是Seq=1、2、3、4、5、6、7、8、9、10,服务器收到的序列是1、3、4、5、7、8、9、10,其中2和6没有收到数据包。A端在等待确认数据包超时后,对2和6进行重传。B端接收到数据包后,处理数据包时,如果数据包顺序有跳跃的现象,就表明数据包丢失,等待A端重传,这时就在断开的序列处停止处理数据包,等待重传数据包的到来。
  • B端也可以做加快重传确认时间的处理。A端向B端发送5个数据包,分别是
    Seq=1、2、3、4、5,B端收到的包的序列是1、3、4、5,当收到3时,发现2被跳过1次,当收到4时,发现2被跳过2次,立刻向A端发送确认包要求启动2的数据包的重传,这样就加快了丢包重传的确认速度。

丢包问题

  • 接收端处理时间过长导致丢包:
    当调用异步接收数据方法接收数据时,处理数据会花费一些时间,处理完后若再次调用接收方法,那么在这两次调用的间隙里发过来的包则有丢失的可能。
    如果不确定数据包什么时候发过来,首先保证程序执行后马上开始监听,其次在收到一个数据包后,要在最短的时间内重新回到监听状态,

    在解决的过程中,可以先修改接收端,然后将接收到的包存人一个缓冲区,并迅速返回继续开启接收线程,或者使用前面提到的双队列机制来缩短锁队列的时间,从而消除处理数据包和接收数据包的线程之间的冲突,让两个线程能迅速回到自己的“岗位”上做自己的事。

  • 发送的数据包较大是一个危险的行为,如果超过接收者缓存,大概率会导致丢包,一般当包超过MTU大小的数倍时,就会增大丢包的概率。
    MTU,即MaximumTransmissionUnit的缩写,意思是网络上传送的最大数据包大部分网络设备的MTU都是1500byte如果本机的MTU比网关的MTU大,大的数据包就会被拆开传送,这样会产生很多数据包碎片,增加丢包率,从而增大重发概率,导致网速变慢。
    对于MTU。以太网的MTU通常是1500byte,其他一些诸如拨号连接网络的MTU值为1280byte,如果使用speaking很难得到MTU的网络,那么最好将报文长度控制在1280byte
  • 发送包过快,原因是UDP的发送数据是不会造成线程阻塞的,也就是说,UDP的发送不会像TCP那样,直到数据完全发送才会返回调用函数,UDP并不保证当执行下一条语句时前面的数据已被发送,它的发送接口是异步的。

    如果要发送的数据过多或者过大,那么在缓冲区满的那个瞬间,要发送的报文就很有可能丢失,1秒发送几个数据包不算什么,但是一秒发送成百上千的数据包就不好办了。

    虽然每个包都小于MTU的大小,但是频率太快,例如40多个MTU大小的包连续发送中间不休眠,也有可能导致丢包。这种情况可以通过建立Socket接收缓冲队列或发送缓冲队列来解决,并且在发送频率过快的时候还可以考虑通过线程Sleep休眠,以此作为时间间隔。

HTTP和HTTPS

数据协议

Json、Xml、Yaml、Ini、Excel

MessageBox

Google ProtocolBuff

第七章 游戏中的AI

状态机

行为树

行为树的节点

复合节点

选择节点(SelectorNode)

执行后从头到尾迭代执行子节点

  • 子节点有True,则停止执行,向父节点返回True,
  • 子节点全False,向父节点返回False

选择节点的规则:当执行本类型节点时,它将从头到尾迭代执行自己的子节点如果遇到一个子节点执行后返回True,则停止迭代本节点会向自已的上层父节点也返回True,否则所有子节点都返回False,那么本节点也会向自己的父节点返回False。

顺序节点(SequenceNode)

执行后从头到尾迭代执行子节点

  • 子节点有False停止执行,向父节点返回False;
  • 子节点全为True,向父节点返回True

顺序节点的规则:当执行本类型节点时,它将从头到尾依次迭代执行自已的子节点,如果其中一个子节点执行后返回False,就会立即停止迭代,同时本节点会向自已的父节点也返回False,相反,如果所有子节点都返回True,则本节点会向自己的父节点返回True。

并发节点(ParallelNode)

并发节点的规则:当执行本类型节点时,它将并发执行自己的所有子节点并发节点又分为三种策略,这与它们向父节点返回的值和并行节点所采取的具体策略有关。

并行选择节点(ParallelSelectorNode)

其规则为:执行完所有子节点后,如果有一个子节点返回False,则自己向父节点返回False,只有当所有子节点全返回True时,自己才向父节点返回True。

并行顺序节点(Parallel SequenceNode)

并行执行所有节点,返回规则和顺序节点相同

其规则为:执行完所有子节点后,如果有一个子节点返回True,则自己向父节点返回True,否则,只有当全部子节点返回False时,自己才向父节点返回False。

并行混合节点(ParallelHybirdNode)

其规则为:执行完所有子节点后,按指定数量的节点返回True或False后再决定返回结果。如有三个True返回True/有五个False返回False等

权重

并行节点的子节点执行顺序,可以指定权重,决定优先级,或者对于结果的影响比例

修饰节点(DecoratorNode)

修饰节点的功能为:它的子节点执行后,将对返回的结果值进行额外的修饰处理,然后再返回给它的父节点。
修饰节点都可以通过自定义的方式创造出来,其功能五花八门。其共同点为修饰子节点的结果,或者通过子节点的结果来运行逻辑。 例如,用来调试的日志(log)节点,告知开发者当前节点的位置及相关信息,或者循环修饰节点,循环执行子节点n次等,我们可以根据项目的需求来增加必要的修饰节点逻

下面是一些举例说明:

反向修饰(DecoratorNot)

其功能为将结果反置后返回给上级处理,即当子节点为True时,返回给自已父节点的结果为False,反之,子节点返回False时,它返回给父节点True。

直到失败修饰(DecoratorFailureUntil)、

其功能为子节点在指定的次数到达前一直向上级返回失败信息指定的次数到达后,向上级返回成功信息。

总是失败修饰(DecoratorFail)

其功能为无论子节点返回的结果是否为True,都向上级返回False

计数修饰(DecoratorCounter)

其功能为只运行子节点n次,运行计数超过n次后不再运行。

时间修饰(DecoratorTime)

其功能为在指定时间内运行子节点后都返回True超出这个时间范围,无论子节点返回什么结果,都向上级返回False。

什么也不修饰(DecoratorNothing)

其功能就是什么都不干,只是用来提前占个位。

条件节点(ConditionNode)

条件节点相对比较简单,若条件满足,则返回True,否则返回False。各式各样的条件节点,都继承自基础条件节点并且返回True或False。 比较常用的条件节点有大于、小于、等于、与、或、判断True或False。

这些条件节点可以与变量组合使用,比如判断血量的条件、判断距离的条件、判断状态的条件、判断时间间隔的条件等,条件节点可用于行为树AI中。

行为节点(Action Node)

行为节点(ActionNode)通常都是最后的叶子节点,它在完成具体的一次(或一小步)行为之后根据计算配置返回、返回值。行为节点可以是执行一次得到的结果,也可以是分步执行很多次的行为。例如,向前行走这个行为可以一直执行,直到走出某个范围为止。我们可以通过扩展行为节点让AI行为变得更丰富多彩,行为节点也是自主定义角色行为的关键,其通常会涉及角色的具体行为。

常用的行为节点有:
  • 行走到目标地点的行为节点
  • 追击目标的行为节点
  • 使用物品的行为节点
  • 撤退的行为节点
  • 攻击目标的行为节点
  • 防御动作的行为节点
  • 释放某项技能的行为节点等

这些行为节点都是根据项目的需要从基础的行为节点扩展而来,行为节点是最丰富的节点库,大部分时间程序员都在修改和扩容行为节点,以向AI行为提供更丰富的可编辑行为内容。

总结

行为树中执行任何节点之后,都必须向其上层的父节点报告执行结果:成功或失败或正在运行(还在执行并未执行完毕,例如行走到某个目的地,角色正在行走途中)。这种简单的或成功、或失败、或运行中的汇报原则将被很巧妙地用于控制整棵树的决策方向。
此外整棵行为树中,只有条件节点和行为节点才能称为叶子节点,也只有叶子节点才是需要特别定制的节点,而复合节点和修饰节点均用于控制行为树中的决策走向,所以有些资料中称条件节点和行为节点为表现节点(BehaviorNode),而复合节点和修饰节点为决策节点(Decider Node)。
行为树能够支撑起复杂逻辑的AI的原因就在于我们可以使用这些简单的节点去搭建一个庞大的AI行为树(AI模型)

决策树

决策树的AI解决方案与行为树相似。决策树与行为树一样,都是树形结构,其树叶都由节点构成。在决策树中,每一次决策的构成由树形结构的头部开始,从上往下一路判断下去,无论决策该往哪走,最后一定会执行到叶子节点,进而确定当次行为的动作。

在决策树中,只有叶子节点才能决定如何行动,而且在决定后的行动中无法中途退出,必须等到当次行为执行完毕或被打断,才能开始下一次决策。从理论上讲,决策树就是为了制定决策,而行为树是为了控制行为,它们是两个不同的理念。行为树更加注重变化,而决策树则更加注重选择。因此,行为树可以定制比决策树更复杂的AI逻辑。

非典型AI

机器学习等

第八章 寻路

A Star

JPS

迪杰斯特拉

寻路网格的构建

数组构建网格

  • 可以使用二维数组,如0代表可通行,1代表不可通信。
  • 可以使用1024*1024的RGB8的图片,(255,255,255)代表可同行,(0,0,0)代表不可通行
  • 一维数组的分布比二维数组更紧凑,实际可以用一维数组代替二维数组,提高CPU缓存命中率,如一个4行8列的二维数组,转移到一维数组

    可以使用row [row*8+col],比如[1,1],其实就是一维数组的[9]

路点网格

当场景特别大时,使用的数组也会很大,编辑地图也会麻烦,当场景中部分空间的障碍不规则,并且不密集时,也会浪费很多内存。

image

通过编辑路点和路线,使用图的数据结构,使用A星算法寻路。

  • 路线无法识别障碍,需要通过路点绕过障碍
  • 行走路径的平滑依赖路点和路径的数量
  • 大多数情况需要一个专门的路点编辑器

平面三角形网格

三角形寻路网格的生成,**在计算机图形学里叫平面多边形的三角部分问题,意思是在一张平面图上有很多种颜色,颜色之间的边界形成了很多的点和线,根据这
些点将这幅图分解为由许多三角形组成的多边形**

应用到我们的游戏项目中,可以改为只有两种颜色,即可行走颜色和不可行走颜色。

剖分

  • “最小角最大”:在不出现奇异性的情况下,Delaunay三角剖分最小角之和均大于任何非Delaunay剖分所形成三角形的最小角之和
  • “空外接圆”:三角剖分中任意三角形的外接圆内不包括其他节点

Delaunay三角剖分算法有好几种,但都遵循Delaunay三角剖分特点,即其剖分后的多边形里的三角形必须满足以下三个要求。

  • 除了端点,三角形的边不包含其他任何点。
  • 除了在点上的连接,没有任何一条边是相交的。
  • 所有的面都是三角形,且所有三角形的合集是所有点集合的凸包。、
Bowyer-Watson算法

Delaunay三角剖分只合适凸多边形,因此除非我们把整个不规则区域拆分成多个凸形,就像前面介绍的拆分寻路区域时那样,否则无法使用Delaunay三角剖分。

Bowyer-Watson算法的基本步骤如下。

  1. 构造一个超级三角形或多边形,把所有数据点都包围起来。
  2. 依次逐个加人新的顶点,找到包含新顶点的所有外接圆对应的所有三角形。
  3. 删除包含在所有外接圆中的边,这时新插人的点与删除边上的点相连构成一个凸多边形。
  4. 将新插人的点与这个凸多边形的所有点相连,构成多个新的三角形。
  5. 返回第二步,继续加人新的点,直到所有顶点增加完毕再结束。

切耳算法

名词解释:
  • 简单多边形:指所有顶点都顺时针或者逆时针排列每个顶点只连接两条边,边与边之间没有交叉的多边形
  • 耳点:指多边形中相邻的三个顶点V0、V1、V2形成的三角形里不包含任何其他顶点,并且如果V1点是凸点,即V0-V1的连线与V1-V2的连线之间形成的夹角小于180度,则认为V1是耳点。所以,一个由4个顶点组成的多边形中,至少有2个耳点。
  • 耳朵三角形:三角形顶点中有耳点的就叫耳朵三角形。
算法流程
  1. 找到一个耳点
  2. 记录这个耳朵三角形,然后去掉这个耳朵点基于剩余的顶点继续回到第一步
  3. 直到剩下的最后3个点形成一个三角形并记录下来,把所有记录的三角形拼接起来就形成了三角化网格。

image

多边形含洞的情况

依旧使用上述三步骤来做三角部分,只是部分之前要把“洞”并人外围的简单多边形里,即:

  1. 用外围的简单多边形上的点连接“洞”的简单多边形。为了保持所有点的一致性,
    “洞”必须与外围多边形的点的顺序相反,**即外围如果是逆时针的顺序,“洞”则需要采用顺
    时针的顺序。**
  2. 在连接处,产生两个一模一样的点,即连接点。

使用这种方式可将“洞”并人成为一个单独的简单多边形里,如果有多个洞,则先并入的洞为拥有x轴方向最大的点的“洞”
也就是说,最终计算的还是一个单独的简单多边形,只是在计算之前,将“洞”以凹形形态并人最外围的简单多边形中。

image

如果 “洞”并不是完全包含在外围简单多边形下,比如一半在外面,一半在里面,这时只要做多边形裁剪就可以了。将原来外围的简单多边形根据这个“洞”裁剪成一个凹形就会与“洞”彻底分离开来,形成新的简单多边形。

洞含岛的情况

除了有“洞”,以及“洞”包含在里面和“洞”的一半在里面、一半在外面的情况,还有一种情况是“洞”中有“岛”。这个“岛”就像是湖中的“孤岛”,虽然它也需要三角剖
分,但与外界是无法连接的。因此这个“岛”就相当于另一个独立的简单多边形,可以单独拎出来计算它的三角化部分。

多层级网格(多层级2D网格)

也就是说对于一种立体的情况,可以使用多个2D网格,例如楼房

  • 每一层网格只包含本层的数据,以及切换点、层号
  • 我们每次只需要关注当前层和上下两层的数据
  • 如果在本层寻路,就是传统的2D网格寻路
  • 如果跨层寻路,只需要根据层号,寻找切换点,依次遍历不同层的数据,最终走到对应位置

比如我们在二楼,需要去四楼:

  1. 先走到二楼楼梯入口
  2. 走到三楼
  3. 走到四楼
  4. 走到目的点

平面三角形下的AStar寻路(漏斗算法)(有问题)

我们可以使用三角形中心点,或者寻路三角形的公共边的中点作为路径点,但这样仍然会有很多折线,所以我们可以使用拐点来规划路径

拐点算法有点像射线,所以也常被称为射线优化路径算法。下面看看算法的步骤。

  1. 从起始坐标点出发,连接下一个三角形入口边的两个顶点V1、V2产生两个向量line1、line2,再连接下一个三角形入口边的两个顶点V3、V4产生两个向量line3、line4。
  2. 通过计算这四个向量的叉乘,可以判定一个向量是在另一个向量的左边还是右边。还可以通过计算出V3、V4的值来判断line3和line4是否在line1、line2所形成的
    夹角内。

此后根据line1、line2、line3、line4的关系决定下一次的基准向量

  • line3和line4在line1和line2所组成的夹角内,则line2和line4作为下一次计算的基准向量
  • 若line3或line4超出了line1和line2的夹角范围(这里指的是line3超出linel或line4超出line2,而不是line3反向超出line2或line4反向超出line1),则超出的部分使用原基准向量,如line3超过line1,则这一侧的基准向量使用line1,而line4处于line1和line2夹角内,则这一侧使用line4,最后下一次计算的基准向量为line1和line4
  • line3和line4都在line1的左边时,则line1的坐标点成为拐点。类似地,当line3和line4都在line2的右边时,则line2的坐标点成为拐点
  • line3和line1是同一个坐标(注意是同一个坐标)时它们的坐标点成为拐点。同样,当line4和line2是同一个坐标时,这个坐标点成为拐点
  • 当寻路达到最后一个多边形时,可以直接判断终点是否在line1和line2的中间,如果不在中间,则用line1或line2的坐标点增加一个拐点,依照夹角偏向判定是使用linel1还是是line2,既最靠近终点的向量的坐标作为一个拐点

image

image

关于叉乘

叉乘满足右手准则、且不满足交换律(但满足反交换律)

在二维空间中,a×b

  • 正值:向量b 在 向量a 的逆时针方向(左侧)
  • 负值:向量b 在 向量a 的顺时针方向(右侧)

image

体素寻路

RecastNavigationNavmesh

地图编辑器

对于一张完整的地图来说,我们需要生成一个包含地图中所有元素数据的文件,并且可以通过这个文件还原整个地图。这个数据文件不只可以在视觉上还原地图,还可以还原我们已经设定好的地图中的逻辑参数,这些参数包括障碍碰撞检测范围、触发事件、机关走向、剧情动画、摄像机移动速度和位置等
地图编辑器一般分为三部分:

  • 是可行走区域与障碍区域的构建;
  • 二是地形与物件编辑;
  • 三是游戏逻辑,包括关卡、触发、事件、怪物出生点等业务逻辑的参数配置。
struct map_unit
{
    position,
    rotation,
    scale,
    type,//类型
    table_id,//配置表ID
    size,
    function_type,//功能(往往指向一个工作流或者指令集)
}

数据格式

Json易于读取,但加载慢、占用大,自定义数据格式需要随时维护,在开发过程中可能遇到更新迭代问题,尤其是一些自定义字节流数据,一些更小的其他数据格式,如Protobuf,同样可读性不高,很多情况下我们还需要可以通过数据格式定位、解决问题等

  • 编辑器下使用Json、Xml等易读的数据格式
  • 发布后统一将Json等数据转换成Protobuf

地图加载方式

对于地图,我们如果使用一个prefab加载,会导致内存占用高,加载慢等问题,

  • 所以我们最好是对地图按需加载,
  • 某些情况一个地图我们需要根据玩家的进度、以及模式等等,组合出不同的表现效果甚至地形,此时我们就可以将Prefab拆分成一个个单元,通过地图数据组合出我们需要的地形,这比直接加载整个Prefab把所有的情况都使用Active的方式进行处理(浪费内存),还是制作多个大型Prefab(浪费空间、如果使用AB包等同样会导致内存浪费)
  • 此外我们可以根据地图编辑器的数据进行异步流式的动态加载,让人能有逐步出现的视觉体验,相对画面等待的阻塞方式会更好。异步加载缓解了CPU在某个瞬间的消耗,使得CPU在场景加载和实例化上的消耗更加平滑。

地图九宫格

有点类似于AOI(服务器只会将当前客户端以及周围九宫格内的服务器实体信息,同步给当前客户端),对于地图的加载同样可以如此,尤其是在一些RPG游戏中

此外我们也可以使用更细粒度的分割,例如25宫格。

image

另外我们还可以基于四叉树进行场景管理,基于角色的视野(视锥体),和四叉树分块来决定加载某些部分的地图块,或者我们也可以不隐藏不在视锥体内的地图块,只隐藏NPC、音效、特效一些消耗性能的功能。

当然四叉树更多的情况下适合用于判断实体处于地图的某个区域,用于优化碰撞检测的范围(列表)、寻路、快速查询、细节层次、视锥体剔除等。

八叉树就是把切两刀,变成了切三刀,提供了垂直方向的信息,和四叉树的核心思想没有区别。

地图优化

2D地图

由于2D地图仅仅是一个面片,且往往使用格子地图,每一个格子都是相同大小(类似于TileMap),我们完全可以使用程序,根据地图数据

  • 单独获取每一个格子的数据,自行生成四个顶点、三角形,并处理uv等
  • 动态生成图集,合并mesh,采用一个Material

这样地图就只会有一个drawcall

using System.Collections.Generic;
using UnityEngine;

public class GridTriangulator2D : MonoBehaviour
{
    [System.Serializable]
    public struct TexInfo
    {
        public float uvX, uvY;     // 起始 UV
        public float width, height; // UV 尺寸(0-1)
    }

    [System.Serializable]
    public class MeshInfo
    {
        public Mesh mesh;
        public Vector3 position;
        public Quaternion rotation;
        public Vector3 scale;

        public MeshInfo(Mesh m)
        {
            mesh = m;
            position = Vector3.zero;
            rotation = Quaternion.identity;
            scale = Vector3.one;
        }
    }

    public int widthCount = 10;     // X 方向格子数
    public int lengthCount = 10;    // Y 方向格子数
    public float cellSize = 1f;     // 格子边长
    public TexInfo texInfo;

    private readonly List<MeshInfo> meshInfos = new List<MeshInfo>();

    // 生成整张 2D 地图(XY 平面)
    public void GenerateMap()
    {
        meshInfos.Clear();

        for (int ix = 0; ix < widthCount; ix++)
        {
            for (int iy = 0; iy < lengthCount; iy++)
            {
                var m = GenerateTrianglesByRectangle(ix, iy, texInfo);
                meshInfos.Add(new MeshInfo(m));
            }
        }

        var combined = CombineMeshList(meshInfos);
        ApplyCombinedMesh(combined);
    }

    // 将一个矩形划分为两个三角形
    private Mesh GenerateTrianglesByRectangle(int ix, int iy, TexInfo tex)
    {
        float x = ix * cellSize;
        float y = iy * cellSize;
        float half = cellSize * 0.5f;

        // 顶点位于 XY 平面
        Vector3 p1 = new Vector3(x - half, y + half, 0f); // 左上
        Vector3 p2 = new Vector3(x + half, y + half, 0f); // 右上
        Vector3 p3 = new Vector3(x + half, y - half, 0f); // 右下
        Vector3 p4 = new Vector3(x - half, y - half, 0f); // 左下

        Vector3[] vertices = { p1, p2, p3, p4 };

        // 两个三角形:p1-p2-p3 与 p3-p4-p1(逆时针为正面)
        int[] triangles = {
            0, 1, 2,
            2, 3, 0
        };

        Vector2 uv1 = new Vector2(tex.uvX,             tex.uvY);
        Vector2 uv2 = new Vector2(tex.uvX + tex.width, tex.uvY);
        Vector2 uv3 = new Vector2(tex.uvX + tex.width, tex.uvY - tex.height);
        Vector2 uv4 = new Vector2(tex.uvX,             tex.uvY - tex.height);
        Vector2[] uvs = { uv1, uv2, uv3, uv4 };

        Mesh mesh = new Mesh { name = $"Cell_{ix}_{iy}" };
        mesh.SetVertices(vertices);
        mesh.SetTriangles(triangles, 0);
        mesh.SetUVs(0, uvs);
        mesh.RecalculateNormals();
        mesh.RecalculateBounds();

        return mesh;
    }

    // 合并所有小网格
    private Mesh CombineMeshList(List<MeshInfo> list)
    {
        var combines = new CombineInstance[list.Count];
        for (int i = 0; i < list.Count; i++)
        {
            combines[i] = new CombineInstance
            {
                mesh = list[i].mesh,
                transform = Matrix4x4.TRS(list[i].position, list[i].rotation, list[i].scale)
            };
        }

        Mesh combined = new Mesh { name = "Combined2DMap" };
        combined.indexFormat = UnityEngine.Rendering.IndexFormat.UInt32;
        combined.CombineMeshes(combines, true, true, false);
        combined.RecalculateNormals();
        combined.RecalculateBounds();
        return combined;
    }

    // 将合并后的网格应用到当前物体
    private void ApplyCombinedMesh(Mesh combined)
    {
        var mf = GetComponent<MeshFilter>();
        if (mf == null) mf = gameObject.AddComponent<MeshFilter>();
        mf.sharedMesh = combined;

        var mr = GetComponent<MeshRenderer>();
        if (mr == null) mr = gameObject.AddComponent<MeshRenderer>();
    }
}

3D地图

  • 3D地形,可以使用Maya、3dsMax等软件或者Unity的Terrain,基于高度图来制作地形
  • 可以提前规定地图单元的大小,例如10*10(可以有空白处),之后也可采取类似的思路,但毕竟是三维复杂度要高很多。
  • 一个场景的地图单元最好使用一个图集

此外,3D地形模型的制作需要提前设计和考虑。

  • 因为地图被拆分成了N种类型的地形立方体,所以在制作初期,需要先了解整个地形有哪些类型的需求并进行设计。在读取地图编辑器中的地图数据后,除了拼合地形模型立方体外,实例化场景地图时还需要增加一些逻辑,为的就是更好地适应模型拼合。
  • 就像我们在制作和拆分城墙时,如果城墙处于拐角处,那么需要先判断它的前后左右有没有其他城墙。如果有,则应该选择适当的连接城墙来适应周边的模型,这其中共包括左连接、右连接、前连接、后连接、前后双向连接,以及左右双向连接等9种情况,根据不同的情况选择不同的模型,这样才能完美契合周围的地块模型。
  • 最后一步是合并模型,相对简单,将地形模型放在地图数据文件所描述的指定位置上,并采用前面所说的方式选择适配的模型,调用Unity3D的API方法Mesh.CombineMeshes合并所有模型。合并时,也会有三角面数的限制,合并后的模型的三角形数量不能超过65535(即216-1)个面,如果超过,则应该另创建一个网格实例进行合并。

常规场景优化

性能分逻辑、引擎和渲染三部分。逻辑性能瓶颈主要在业务逻辑上,每个项目的逻辑性能瓶颈都不一样,解决方案也不同,这里主要介绍渲染和引擎上的性能优
化。下面罗列几个主要问题。

  • 同屏渲染面数太多,GPU压力太大。
  • 渲染管线调用次数太多(drawcall太多),GPU的并行处理没有很好地发挥作用。
  • 贴图太大,压缩格式不合适,占内存多,导致显存的带宽负荷大。
  • 动画太多,蒙皮在骨骼上的计算消耗的CPU多。
  • 复杂的着色器开销太大,GPU的计算量开销太大。
  • 实时阴影导致的drawcll多
  • 半透明导致的OverDraw过多
  • 物体渲染需要的shader太多,需要分几次渲染

渲染面数多,GPU压力大

如果只是整个场景的网格大、多,只会导致内存上升,我们主要关注同屏(摄像机内,因为有视锥体剔除)内的渲染面数。

视锥体剔除

在每次提交渲染前,每个物体都能计算或提前计算出包围盒(Bounds),再加上每个物体的变换矩阵,可以得知其在世界中的具体位置,具体视锥体剔除:

  • 就是对于一个物体包围盒的8个顶点,只要有一个在视锥体内,就不能剔除
  • 如果包围盒八个顶点均不在视锥体内,引擎会在提交渲染前舍弃这个物体。
过大模型

所以如果场景有过大、过长的物体(很容易出现在视锥体内,此外还可能出现超大模型错误剔除),不会被视锥体剔除,这样就会浪费很多不必要的GPU计算(因为网格被传人后,是由GPU来负责对每个三角形进行裁剪的,这比用包围盒的方式裁剪要费力得多)。

  • 例如城墙,尽量进行拆分

    但要注意的是,拆分得过细也不行,因为这样会带来更多的drawcall。理解了裁剪原理会对场景物件的颗粒度大小的把控更加清晰

  • 此外还可以减短摄像机远切面,但是以牺牲视距得到的
LOD

空间换时间,根据视锥体距离,越远使用多边形越少的模型,越近使用多边形越多的模型。

我们常提到的Mipmap其实也有点使用LOD方式运作的意思,只是Mipmap的主要意图不是优化性能,而是处理因物体像素比导致的画面瑕疵问题,由于Mipmap会根据远近来选择贴图大小,因此在绘制场景时也节省了不少GPU与内存之间传输数据的带宽.

渲染管线调用次数太多(drawcall太多),GPU的并行处理没有很好地发挥作用。

第九章 图形学与渲染管线

图形渲染接口

Unity3D通过调用图形渲染接口来实现绘制:

  • opengl
  • directX
  • metal
  • vulkon

图形接口本身也是应用层的软件,他提供调用Gpu的能力,也就是引擎和显卡驱动程序之间的中转着,

图形渲染接口会根据我们的硬件设备去寻找对应的驱动程序,从而调用对应的渲染功能(事实上在现在都是显卡驱动去适配不同的图形接口),Unity无需关心我们使用的是什么显卡,因为我们只需要调用图形渲染程序提供的统一渲染接口即可

image

  1. Unity3D调用OpenGL图形接口,旨在告诉OpenGL某个模型数据需要渲染,或者某个渲染状态需要设置,
  2. 再由OpenGL发送指令给显卡驱动程序,
  3. 显卡驱动程序将指令翻译为机器码后,将指令机器码发送到GPU,
  4. 最后GPU根据指令进行相应处理。从这个过程中我们可以看到,

渲染流程

# GPU 渲染流程

---

## 一、应用阶段(Application / CPU 侧)

### 1. 场景与系统更新
- 脚本、动画(Animator / 骨骼)、物理、导航、时间、输入与 UI 事件等。
- 组件状态同步(Transform、Renderer、Light、Camera 等)。

### 2. 可见性裁剪(Culling)
- **视锥裁剪**:基于相机的六个平面剔除不可见对象。
- **遮挡裁剪(可选)**:基于预计算或运行时遮挡数据丢弃被挡住的对象。
- **层与相机掩码**:Layer / Culling Mask 过滤。

### 3. DrawCall 减少(合批 / Instancing / SRP Batcher / UI 合批)
> 核心目标:**减少“渲染状态切换 + DrawCall 次数 + CPU 到 GPU 的提交开销”。**

#### 3.1 静态合批(Static Batching)
- **思路**:把“标记为静态”的多个网格在构建或运行时**合并为更大的网格**,提交时相当于一个或更少的 DrawCall。
- **适用**:不会移动/变形的物体(建筑、路面、道具等)。
- **优点**:减少 DrawCall;**GPU 顶点变换开销不变**(仍逐顶点),但 CPU 提交更少。
- **代价/注意**:
  - 会产生**更大的合并网格**,占用更多内存;物体越分散越容易浪费顶点。
  - 被合并后**无法单独启/停渲染器或变换**(需要重新拆分/重建批次)。
  - 材质、Shader 变体不同会**打断合批**。

#### 3.2 动态合批(Dynamic Batching)
- **思路**:在 CPU 侧把**小型网格**(顶点属性相似、材质一致)“打包”到一个临时缓冲里,一次提交。
- **适用**:**小物体且顶点属性统一**;对大量小碎网格有帮助。
- **优点**:无需预合并资产,运行时自动。
- **代价/注意**:
  - 有**顶点数量/属性数量**等阈值(越复杂越难合批)。
  - 仍有 CPU 打包与复制成本,**大网格不适合**。
  - 材质实例、关键字、渲染队列等差异会打断。

#### 3.3 GPU Instancing(GPU 实例化)
- **思路**:**同一 Mesh + 同一材质**的一组对象,一条 **Instanced Draw** 指令绘制多份实例;每个实例用**实例常量**(如变换矩阵、颜色、UV 偏移)区分。
- **适用**:**大量重复物体**(树、草、路灯、砖块、子弹壳等)。
- **优点**:**一次提交,多份绘制**;CPU 大幅减压;GPU 顶点阶段按实例并行。
- **代价/注意**:
  - 材质必须开启 instancing,Shader 支持 per-instance 属性(`UNITY_INSTANCING_*` 或等价宏)。
  - **材质关键字/变体不同会打断实例化**;同一批实例内的 Pass 必须一致。
  - SkinnedMeshRenderer 通常不走普通 instancing(有独立的 GPU/Compute/Hybrid-DOTS 方案)。

#### 3.4 SRP Batcher(Scriptable Render Pipeline Batcher)
- **思路**:把与 Shader 绑定的常量缓冲区进行**持久化缓存**,**减少 SetPass/绑定开销**;并不减少 DrawCall 数量,但**显著降低 CPU 侧状态切换成本**。
- **适用**:URP/HDRP + SRP Batcher 兼容的 Shader(官方 Lit/Unlit 等)。
- **优点**:**几乎“白送”的 CPU 性能提升**;对含大量材质/对象的场景尤为明显。
- **代价/注意**:自定义 Shader 需按 SRP Batcher 规则书写常量布局;与 Instancing 互补可共用。

#### 3.5 UI 合批(UGUI / TMP)
- **合批原则**:**同 Canvas、同材质、同纹理(或同图集)**、绘制顺序连续 → **一个批次**。
- **打断合批的常见原因**:
  - **不同 Canvas** / 不同 `SortingLayer/Order` / 各种 **Mask/RectMask2D** / **Clipping**。
  - 材质或纹理不同(未做 **图集 Atlas**)。
  - 频繁的 **布局/内容重建**(Layout / ContentSizeFitter / 动态文本排版)导致 **Canvas Rebuild**。
  - 开启了 “Additional Shader Channels” 增加顶点流不统一。
- **优化要点**:
  - **尽量减少 Canvas 的数量**(过多会增加批处理边界);把经常变化的元素单独放入**小 Canvas**,避免大 Canvas 反复重建。
  - 使用 **图集(SpriteAtlas / TMP Atlas)**,减少材质/纹理切换。
  - 合理排序 UI 层级,避免 Mask 级联;能用 `RectMask2D` 就别用复杂 Stencil 遮罩。
  - 文本(TMP)尽量复用材质与字图库;避免每帧修改大段文案造成重建。

#### 3.6 “打断合批/实例化”的常见因素(通用)
- **材质(Material)不一致**(包含 Shader、关键字、RenderQueue、Pass、启用/禁用特性等差异)。
- **不同的渲染状态**:混合、深度写入/测试、模板设置、Cull/Offset、ZClip 等。
- **多 Pass Shader**:每个 Pass 都是一次(或多次)绘制。
- **不同 Lightmap/Probe/ReflectionProbe 设置**(某些管线会分 bucket)。
- **动态改 Shader 关键字 / 材质属性实例化**(会产生新实例,打断批次)。

### 4. 排序与渲染队列(决定“怎么画”)
- **不透明对象**:**前向到后向**排序(Near → Far),利用 Early-Z 减少过绘。
- **透明对象**:**后向到前向**排序(Far → Near),保证正确混合。
- **RenderQueue**:BackGround(1000)/Geometry(2000) / AlphaTest(2450) / Transparent(3000+)/Overlay(4000+);UI 还叠加 SortingLayer/Order。
- **按材质/Pass 分桶**:有利于合批/实例化/SRP Batcher。

### 5. 生成绘制列表(Draw List)
- 为每个渲染通道(阴影、Depth Prepass、GBuffer/Forward、透明等)生成命令序列:
  - 绑定 RenderTarget、设置状态、推送常量、发起 Draw/Dispatch。
- 命令进入渲染线程/命令缓冲,等待 GPU 执行。

---

## 二、几何阶段(GPU 顶点与图元)

1. **顶点着色器(VS)**:模型 → 世界 → 观察 → 裁剪空间;输出需要插值的属性。
2. **(可选)曲面细分(Tessellation)**:细分网格,位移/曲面更平滑。
3. **(可选)几何着色器(GS)**:以图元为粒度增删改;一般实时渲染少用。
4. **图元装配(Primitive Assembly)**:索引→点/线/三角形。
5. **裁剪(Clipping)**:在 **Clip Space** 对视锥体裁剪(发生在除 w 之前)。
6. **透视除法(Perspective Divide)**:`(x,y,z)/w` → **NDC**。
7. **视口变换(Viewport Transform)**:NDC → 屏幕像素坐标。

---

## 三、光栅化阶段(Rasterization)

1. **包围盒确定与边函数**:确定像素范围,用边方程/重心坐标判定覆盖。
2. **插值准备**:构建透视正确插值(属性先乘 `1/w`,插值后再恢复)。
3. **多重采样(MSAA,可选)**:像素内多采样点生成覆盖掩码;每个样本有独立深度/模板值。
4. **生成片元(Fragments)**:为每个被覆盖的像素(及其样本)产出片元及其属性。

---

## 四、片元阶段(Fragment + Per-Fragment Ops)

> 注意:硬件可能做 **Early-Z / Early-Stencil** 来提前丢弃不可见片元,但当着色器里有 `discard/alpha test/修改深度(SV_Depth)` 时会降级为 **Late-Z**。

1. **(可能)Early-Stencil / Early-Z**
   - 条件允许时在 **片元着色器之前** 先做模板/深度测试,可直接丢弃不可见片元。
2. **片元着色器(PS/FS)**
   - 纹理采样、BRDF/PBR 光照、法线/高度/自发光等,输出 `Color`(可选输出深度)。
   - 使用 `clip()/discard` 可做 AlphaTest(但会禁用 Early-Z)。
3. **AlphaTest(可选)**
   - 根据 α 阈值丢弃片元(草叶/栅栏等 Cutout)。
4. **模板测试(Stencil Test)**
   - 与模板缓冲比较(Always / Equal / NotEqual / Less / Greater …)。
   - 失败/通过可分别配置写入操作(Keep/Replace/Incr/Decr/Invert/Saturate/Wrap)。
5. **深度测试(ZTest)**
   - 比较当前片元深度与深度缓冲(Less / LEqual / Greater / GEqual / Equal / NotEqual / Always / Never)。
6. **深度写入(ZWrite)**
   - 若 ZTest 通过且开启写入 → 更新深度缓冲。
   - 透明常见 `ZWrite Off` 以免把背后遮挡死。
7. **颜色混合(Blending)**
   - 直通 Alpha(非预乘):`SrcAlpha, OneMinusSrcAlpha`(Over 合成)。
   - 预乘 Alpha:`One, OneMinusSrcAlpha`。
   - 加法/乘法/最小/最大:`BlendOp Add/Sub/RevSub/Min/Max` 配合因子(One/Zero/SrcAlpha/DstAlpha/...)。
   - MSAA 下可选 **per-sample** 或 **per-pixel** 混合策略。
8. **颜色写掩码(ColorWriteMask)**
   - 控制写入 RGBA 哪些通道。
9. **写入帧缓冲(Framebuffer Output)**
   - 颜色写入 Color Buffer;深度/模板按上一步骤更新。

---

## 五、实用调优清单(以“减少 DrawCall + 减少过绘”为目标)

- **静态物体**:尽可能标记静态,使用 **静态合批**;合并零碎网格。
- **重复物体**:优先使用 **GPU Instancing**;把 per-instance 参数放进 instancing buffer(颜色、UV 偏移、金属度等)。
- **SRP Batcher**:URP/HDRP 项目**务必开启**;自定义 Shader 按规范书写常量。
- **材质/Shader 统一**:减少关键字与变体;同类物体共用材质实例以维持批次。
- **不透明排序**:**近到远**提交,利用 Early-Z;避免复杂透明遮挡关系。
- **透明物体**:`ZWrite Off` + **远到近排序**;尽量用预乘 Alpha 解决边缘黑/白边。
- **UI**:减少 Canvas 数;把频繁变化的元素拆到小 Canvas;**图集化**;避免级联 Mask;减少顶点通道差异。
- **粒子**:Additive/AlphaBlend 统一材质;按距离/屏幕尺寸裁剪;可考虑 `Soft Particle` 与 `ZTest` 策略。
- **阴影/灯光**:限制逐像素灯光数量;合理使用烘焙与混合光照;控制阴影分辨率与级联。

---

### 结语
- **合批/实例化**解决的是 **CPU 提交成本**;  
- **Early-Z/排序/透明策略**减少的是 **GPU 像素填充/过绘**;  
- **SRP Batcher**降低的是 **状态切换成本**。  
三者配合,才能把渲染管线跑得既快又稳。

image

应用阶段

应用阶段是执行Unity引擎和业务代码的阶段(可以理解为场景/渲染数据准备阶段),在这个阶段主要包含:

业务脚本执行逻辑

  • 业务逻辑:Update、LateUpdate、Coroutine 等用户脚本运行。
  • 引擎系统逻辑:动画系统(Animator)、物理系统(Physics)、导航系统(NavMesh)等更新。
  • 事件派发:输入(Input)、UI 事件(EventSystem)、生命周期回调(OnEnable、OnDisable、OnDestroy 等)。

场景和状态更新:

  • GameObject 状态同步:位置(Transform)、层级关系、激活状态。
  • 组件属性更新:比如 MeshRenderer 的材质引用,Light 的强度和范围,Camera 的参数。
  • 剔除前准备:Unity 会先把每个物体的包围盒、层级和可见性标记准备好,方便后续剔除。

渲染相关数据构建:

  • 摄像机排序:确定场景里哪些 Camera 要渲染,以及它们的优先级。
  • 灯光数据准备:收集场景里影响渲染的灯光(平行光、点光源、聚光灯),准备阴影投射数据。
  • 材质和Shader参数更新:MonoBehaviour 里修改的材质属性会在这一阶段同步到 GPU 的命令缓冲区。

剔除

  1. 层级和可见性过滤(粗粒度)

    • Active为false
    • Renderer的Enable为false
    • Camera的CullingMask
    • 默认不渲染的layer
  2. 视锥体剔除:Unity 计算每个 Renderer 的 包围盒(AABB/OBB) 是否和相机的视锥体相交。只要有一个顶点在视锥体内就不会剔除(通常就是六个平面的快速相交性测试)
  3. 遮挡剔除(Occlusion Culling,可选) :一般用于第一人称,提前计算每个位置对应视角下的可见物体列表,过滤掉不应该渲染的对象。
  4. Renderer 级别过滤:此时的对象会进入渲染队列,但仍有一些过滤

    • castShadows = Off → 不参与阴影 pass。
    • ReceiveShadows = Off → 不接收阴影相关计算。
    • ReflectionProbeUsage → 决定是否在反射 probe pass 渲染。
    • MotionVector → 决定是否进入 motion vector pass。

绘制指令生成

在本阶段Unity会进行分组、合批、生成绘制指令。

DrawCall 指令(也叫渲染命令):这些命令描述了「要画哪个网格,用哪个材质,用哪个 Shader Pass,在哪个位置」 ,指令被写入 CommandBuffer 或 Unity 内部的渲染队列,等待渲染线程提交给 GPU。

  1. 收集可见Render
  2. 材质与Pass筛选
  3. 尝试合批

    • 静态合批:离线合并标记为static batching的物体的网格
    • 动态合批:运行时动态合并网格(条件苛刻)
    • GpuInstancing:同一个材质,支持GpuInstancing,会使用一个渲染状态和网格+1000个不同的数据去依次绘制。
    • 手动合批(自己编写脚本)
  4. 渲染排序
  5. 生成绘制指令,提交数据
三重缓存机制

这里要注意的是一般情况下Cpu提交的数据都会被复制到显存一份,但是对于手机这种没有显存的设备会使用相同地址引用如果需要写入数据则会采取三重缓存机制(类似于Texture)。

  • 系统会给资源开三份缓冲区:

    • A:GPU 正在读取/渲染
    • B:CPU 正在写入新的数据
    • C:空闲,随时准备切换
  • 当一帧结束后,三个缓冲区会轮换角色,保证:

    • GPU 总能读到一份稳定的数据(不会被 CPU 半途修改)。
    • CPU 也能不停写(不会卡着等 GPU 读完)。

几何阶段

image

几何阶段的工作目标是将需要绘制的图元(三角形、点、线、面)转化到屏幕空间,因此它将决定哪些图元可以绘制,以及怎么绘制。图元即点、线、面,可以理解为网格的拆分状态,是着色器中的基础数据,在几何阶段所起的作用最大。

顶点着色器

顶点处理函数就是可编程的部分,它可以很简单地只将数据传递到下一个节点,也可以采用变换矩阵的方式来计算顶点在投影空间的位置,或者通过光照公式的计算来得到顶点的颜色,或者计算并准备下一阶段需要的信息

MVP变换

模型空间->世界空间->视口空间->裁剪空间

细分着色器

细分着色器几何着色器是非必要的着色器,作用是把一个粗糙的多边形细分成更多小三角形,很多手机设备上的GPU并没有这几个功能。

细分着色器包括曲面细分着色器和细分计算着色器,其使用面片来描述一个物体的形状,并且增加顶点和面片的数量,使得模型的外观更加平顺。

几何着色器

  • 每个图元(三角形)调用一次。
  • 可以动态生成或丢弃顶点,比如:

    • 生成粒子条带、草叶、轮廓线。
    • 剔除不需要的三角形。
  • Unity 常见的 Shader 里一般不用几何着色器(性能较低)。

图元装配

根据索引把顶点组装成点/线/三角形的数组,注意此时还是在裁剪空间,此时还会计算图元的重心坐标。

裁剪

  • 在 Clip Space 下做视锥裁剪:检查是否满足
    ​$-w_c \le x_c \le w_c$,$-w_c \le y_c \le w_c$,$-w_c \le z_c \le w_c$。
  • 此时还会进行切割,例如一个图元一部分在裁剪空间内,一部分在裁剪空间外

image

image

  • 必须在 除 w 之前裁剪,因为 Clip Space 是线性的。

归一化

  • 裁剪完成之后,才做:

    $$ x_{ndc} = \frac{x_c}{w_c}, \quad y_{ndc} = \frac{y_c}{w_c}, \quad z_{ndc} = \frac{z_c}{w_c} $$

  • 得到 NDC (归一化设备坐标)

此时顶点被归一化到标准立方体:

  • OpenGL:$[-1,1]^3$
  • D3D/Unity:$[-1,1]\times[-1,1]\times[0,1]$

视口转换

把 NDC [−1,1] 映射到屏幕像素 [0,width]×[0,height]

(-z轴方向才是靠近屏幕,可以理解为越小越靠近屏幕)

光栅化阶段

光栅化

三角形设置

对每一个三角形(也就是图元装配后的所有图元),计算出:

  • 三条边的线性方程(保证三角形内部代入后一定大于0或者小于0,这样方便像素判断自己所处的三角形)
  • 重心坐标插值公式
  • 包围盒范围(bounding box)
三角形遍历
  • 在包围盒范围内逐像素判断,哪些像素在三角形内。
  • 对覆盖到的像素,生成片元 → 送到片元着色器 (PS/FS)。

片元是像素候选,即“某个三角形在屏幕上覆盖的一个像素位置的一个实例

片元会具备屏幕坐标、以及根据自身在三角形内的具体位置插值后的颜色、深度、uv、法线、切线等信息等信息

片元着色器

非常重要的可编程着色器阶段。片元着色器的输入是上一个阶段对顶点信息插值得到的结果,输出为每个片元的颜色值。这一阶段可以按需完成很多重要的渲染技术,最重要的技术之一就是纹理采样。

为了在片元着色器中进行纹理采样,先在顶点着色器阶段输出每个顶点对应的纹理坐标,然后经过光栅化阶段对三角形网格的三个顶点对应的纹理坐标进行插值后,就可以得到其覆盖的片元的纹理坐标。

  1. 纹理采样

    • 根据 UV 去采样纹理 (albedo/base color map, normal map, roughness map, …)。
    • GPU 提供硬件级的插值和过滤(最近点/双线性/各向异性)。
  2. 光照计算

    根据光照模型(Lambert, Blinn-Phong, PBR BRDF 等),结合法线、光源方向、材质属性,计算出该片元的最终光照颜色。

  3. 材质/特效计算

    自发光 (emission)、透明度 / 遮罩 (alpha, cutout)法线扰动、视差贴图 (Parallax)特效、火焰、雾、屏幕后处理等。

  4. 输出颜色/深度

虽然每个片元着色器运行时只能看到自己对应的片元数据,但并不代表没有办法影响其他片元,因为GPU 的片元着色器不是独立执行的,而是以 2×2 的片元块 (quad) 为单位并行运行。

  • (x, y)、(x+1, y)、(x, y+1)、(x+1, y+1)

GPU 会 同时运行 4 个片元着色器,所以它可以借助偏导数知道它们之间的差异。

可以应用于选择Mipmap、计算屏幕梯度、以及屏幕细节层次

逐片元操作

逐片元操作其实也属于光栅化阶段的一部分,但涉及内容较多故单独放出。

剪切测试

它主要针对片元是否在矩形范围内进行判断,如果片元不在矩形范围内,则丢弃该片元。这个范围是一个矩形的区域,可以通过为剪切盒。

多重采样

大概流程

  • 光栅化阶段:GPU 判断一个像素是否被三角形覆盖时,不再只判断像素中心点,而是检查多个子采样点。
  • 存储阶段:如果子采样点在三角形内,就会记录对应的颜色、深度等信息(一个像素会有多个更小区域的颜色数据)。
  • 混合阶段:当要写入帧缓冲时,多个子采样点的值会综合,得到最终的像素输出颜色。

详细流程

  • 对于一个像素点增加多个采样点,得到采样掩码(直到哪个采样点被当前图元覆盖)
  • 只执行一次片元着色器(不是每个子采样点都执行)
  • 逐采样测试

    对每个子采样点,GPU 会分别执行:

    • 深度测试 (Z-Test)
    • 模板测试 (Stencil Test)
    • Alpha Test(discard)
  • 颜色写入:对于通过测试的子采样点,写入颜色缓冲。
  • 混合操作可以是:

    • 逐采样点 (Per-Sample Blending) :对每个采样点单独混合。
    • 逐片元 (Per-Pixel Blending) :对整个像素统一混合(更常见,省性能)
  • 最终显示时

    • MSAA 缓冲会做 Resolve → 把多个子采样点的颜色做平均,得到最终的像素颜色。
    • 例:4x MSAA,一个像素最后颜色 \= 4 个子采样颜色的平均值。
阶段普通渲染MSAA 渲染
覆盖测试像素中心判定一次每个子采样点判定一次,得到掩码
着色器每像素执行一次每像素执行一次(结果共享)
深度/模板测试每像素一次每采样点一次
颜色写入一个颜色值每采样点一个颜色值
混合每像素一次每采样点 or 每像素(可配置)
最终显示像素颜色直接用Resolve:采样点颜色平均

模板测试

开启了模板测试,GPU就会使用掩码读取模板缓冲区中该片元的模板值,将该值和读取到的参考值进行比较。-

  • 这个比较函数可以是开发者指定的,例如小于模板值时则舍弃该片元或者大于模板值时舍弃该片元。、
  • 片元无论有没有通过模板测试都可以根据模板测试和下面的深度测试结果来修改模板缓冲区。这个修改操作也是由开发者指定的。

模板测试通常用于限制渲染的区域。

通过条件
比较函数(Unity语法)含义判断方式(Ref vs StencilBuffer)
Always永远通过忽略模板缓冲的值
Never永远不通过忽略模板缓冲的值
Less小于通过Ref \< StencilBuffer
LEqual小于等于通过Ref ≤ StencilBuffer
Greater大于通过Ref \> StencilBuffer
GEqual大于等于通过Ref ≥ StencilBuffer
Equal等于通过Ref \=\= StencilBuffer
NotEqual不等于通过Ref ≠ StencilBuffer
写入方式
操作(Unity语法)含义
Keep保持原值,不改变
Zero写入 0
Replace写入 Ref 值
IncrSat+1,超过 255 保持 255
DecrSat-1,低于 0 保持 0
IncrWrap+1,超过 255 回绕到 0
DecrWrap-1,低于 0 回绕到 255
Invert按位取反 (\~value)
示例

镂空区域(遮罩内显示物体)

// 第一步:画圆形遮罩,往模板缓冲写 1
Stencil {
    Ref 1           // 参考值 = 1
    Comp Always     // 总是通过
    Pass Replace    // 通过时 → 把模板缓冲写成 1
}

// 第二步:画实际内容,只允许模板=1 的地方绘制
Stencil {
    Ref 1           // 参考值 = 1
    Comp Equal      // 只有模板缓冲 == 1 才通过
    Pass Keep       // 保持原值
}

深度测试

GPU会把该片元深度值和已存在与深度缓冲区的深度值进行比较,这个比较函数也是开发者设置的。例如小于缓冲区深度值时舍弃该片元,或者大于缓冲区深度值等于时舍弃该片元。

通常人们更希望显示离摄像机最近的物体,所以一般比较函数设置为当前片元深度值要小于缓冲区深度值,深度值大无法通过测试。如果片元没有通过测试,则会被丢弃掉。

与模板测试不同,只有通过之后开发者才能指定是否用该片元的深度值覆盖原有缓冲区的深度值。这是通过开启/关闭深度写入(比如半透明物体)做到的。

条件(Unity Shader 中的写法)含义判断方式(Z片元 vs Z缓冲)
Less(默认)片元更近才通过Z片元 \< Z缓冲
LEqual更近或相等才通过Z片元 ≤ Z缓冲
Greater更远才通过Z片元 \> Z缓冲
GEqual更远或相等才通过Z片元 ≥ Z缓冲
Equal深度完全相等才通过Z片元 \=\= Z缓冲
NotEqual深度不相等才通过Z片元 ≠ Z缓冲
Always永远通过忽略深度值
Never永远不通过所有片元都丢弃

深度测试不像模板缓存一样,通过测试后,开启ZWrite会将测试片元的深度写入深度缓存

混合(合并)

  • 不透明物体,开发者可以选择关闭混合操作。这样片元着色器计算得到的颜色值就会直接覆盖原来颜色缓冲区中的像素值。
  • 半透明物体,需要使用混合操作。
Blend SrcFactor DstFactor
Blend SrcFactor DstFactor,SrcFactorA DstFactorA
  • 第一种方式为颜色包含Alpha值
  • 第二种方式将颜色和Alpha分开混合。
  • Src(源数据)、Dst(目标数据)、SrcFactor(源数据混合因子)、DstFactor目标数据混合因子),其中源数据就是待测试的片元的颜色数据等,目标数据就是当前缓存中的数据。
BlendOp(混合操作符)

决定源数据 (Src × SrcFactor)目标数据 (Dst × DstFactor)的计算方式

  • Add:加法

    Final = Src × SrcFactor + Dst × DstFactor

    最常用,透明、半透明都靠它。

  • Sub:减法

    Final = Src × SrcFactor - Dst × DstFactor

    可做“反相”、“闪光”类特效。

  • RevSub:反向减法

    Final = Dst × DstFactor - Src × SrcFactor
  • Min:取最小值

    Final = min(Src × SrcFactor, Dst × DstFactor)

    可做“遮罩”、“暗化”效果。

  • Max:取最大值

    Final = max(Src × SrcFactor, Dst × DstFactor)
混合因子
因子名说明例子
One1,完全保留Src × 1 \= Src
Zero0,完全忽略Src × 0 \= 0
SrcColor新片元的颜色如果新片元是红色 (1,0,0),只保留红通道
SrcAlpha新片元的 Alpha 值常用于透明混合
OneMinusSrcAlpha1 - 新片元 Alpha经典“玻璃效果”
DstColor旧像素的颜色按背景色进行调制
DstAlpha旧像素的 Alpha特殊情况下用于半透明累积
OneMinusDstAlpha1 - 旧像素的 Alpha背景控制新颜色覆盖程度

例如半透明

Blend SrcAlpha OneMinusSrcAlpha
//out.rgb = src.rgb * src.a + dst.rgb * (1 - src.a)

输出到帧缓存

逻辑操作

像素混合结束后,片元将被写入缓存中,在写入缓存前还会做一次逻辑操作,这是片元的最后一个操作。它作用于当前刚通过测试的片元和当前帧缓存中的数据,逻辑操作会在它们之间进行一次操作,最后再写人帧缓存。
由于这个过程的实现代价对于硬件来说非常小,因此很多系统都允许采用这种做法。逻辑操作不再有因子,只在两个像素之间进行操作,操作可以选择异或(XOR)操作、与AND)操作、或(OR)操作等。由于它使用得比较少,也可以由其他方式代替,因此Unity3D中并没有自定义设置逻辑操作的功能。

双缓冲机制

片元最后都会以像素的形式写人帧缓存中,帧缓存一边由GPU不断写人,一边由显示器不断输出,这会导致出现画面还没形成就绘制到屏幕的情况,所以GPU通常采用双缓存机制,即前置缓存用于呈现画面,后置缓存继续由GPU不断写人,写人所有像素后再置换两个缓存,置换时只要置换指针地址即可
当整个画面绘制完成时,后置缓存与前置缓存进行调换,于是原来的后置缓存成为新的前置缓存并呈现到屏幕上,原来的前置缓存成为新的后置缓存交由GPU作为帧缓存继续绘制下一帧,这样就可以保证显示与绘制不会互相干扰。

第十章 渲染原理与知识

渲染顺序

基于片元的深度值和深度缓存中的值,测试不通过,抛弃片元;测试通过写入深度缓存(需要ZWrite On),这个过程会涉及Ztest On/Off和ZWrite On/Off操作

  • Ztest:是否开启深度测试
  • ZWrite:是否写入深度缓存

深度测试最大的好处就是抛弃不需要的片元,大部分情况下我们使用ZTest LEuqal来做深度测试,让更近的不透明物体遮挡远的不透明物体(从近到远排序)。

但对于半透明物体,我们则要从远倒近渲染,且Ztest On(被不透明物体遮挡的半透明部分也不会渲染),ZWrite Off(叠加不透明物体和半透明的效果)

  1. Background:背景层,索引l号1000
  2. Geometry:不透明物体层,索引号2000。
  3. AlphaTest:AlphaTest物体层,索引号2450。
  4. Transparent:半透明物体层,索引号3000。
  5. Overlay:覆盖层,索引号4000。

此外Unity规定

  • 2500索引号的排序规则是,根据和摄像机的距离由近到远渲染
  • 2500索引号以上根据摄像机距离由远倒近

Alpha Test

AlphaTest也属于半透明物体的特征,但它不是混合,而是裁切。

AlphaTest使用纹理图片中的Alpha来判定该片元是否需要绘制。当我们尝试展示一些很细节的模型时,如果使用AlphaTest,原本要制作很多细节网格,现在只要用一张图片和两三个面片就能代替巨量的面片制作效,比如草、树叶等

Shader "Example Alpha Test"
{
    Properties
    {
        _MainTex ("Base (RGB)", 2D) = "white" {}
        _Cutoff("Cut off", range(0,1)) = 0.5
    }
    ....
    SubShader
    {
        ...

        // Alpha Test 示例
        Pass
        {
            struct v2f {
                float4 pos : SV_POSITION;
                float4 uv : TEXCOORD0;
            };

            v2f vert(appdata_base v)
            {
                v2f o;
                o.pos = UnityObjectToClipPos(v.vertex);   // 转换顶点空间
                o.uv = v.texcoord;                        // 传递 UV 值
                return o;
            }

            fixed4 frag(v2f i) : SV_Target
            {
                fixed4 _color = tex2D(_MainTex, i.uv.xy); // 根据 UV 获取纹理上的纹素

                // clip 函数非常简单,就是检查它的参数是否小于 0。
                // 如果是,就调用 discard 舍弃 fragment;否则就放过它。
                clip(_color.a - _Cutoff);

                return _color;
            }
        }
    }
}

Early-Z

image

在光栅化之后,片元着色器之前进行一次深度测试,提前舍弃不需要渲染的片元(片元着色器开销较大)。

此外还有Early-Stencil,也是在光栅化后提前模板测试。

Alpah Test的影响

由于AlphaTest的做法让我们在片元着色器中可以自主抛弃片元,因此问题又出现了。片元在片元着色器中被主动抛弃后,Early-Z前置深度测试的结果就会出现问题,因为Early-Z测试通过的可见片元被抛弃后被它遮挡的下一个片元就成为可见片元,导致前置深度测试的结果出现问题。

因此GPU在优化算法中,对片元着色器抛弃片元和修改深度值的操作做了检测,如果检查到片元着色器中存在抛弃片元和改写就不会进行Early-Z

Mipmap的原理

Mipmap:根据物体与摄像机距离的远近,使用不同分辨率的纹理,提升画面效果,提高渲染效率

问题

当一面墙的纹理是1024×1024,当墙与摄像机距离合理时,每一个纹理的纹素,都有对应的像素,显示效果很好,

可是当墙壁与摄像机距离很远时,例如这面墙只占用了64×64的像素,那么纹理采样的范围很大,会导致某个点突然发生变化,特别是运动的物体(因为采样的纹素迅速变化,导致采样结果变化很大),导致闪烁的问题

解决

在OpenGL中,Mipmap是如何决定采用哪层分辨率的贴图的呢?首先有两个概念要介绍。

  1. 屏幕上的颜色点叫像素,纹理贴图上的颜色点叫纹素。
  2. 屏幕坐标系用的是XY坐标系纹理贴图坐标系用的是UV坐标系(归一化0-1)。

如果我们一个实际物体占用的像素很少,会导致一个片元占用的纹素越多(纹素覆盖率高),此时Gpu会对纹素进行采样(多个纹素的颜色才养成一个颜色),当物体发生变化时,由于采样的纹素变化大,导致闪烁

纹素覆盖率
若获取纹理贴图上的纹素大小为64×64,屏幕上的像素区域大小为32×32,那么它们在x轴上的纹素和像素大小比例为2.0(即64/32),y轴上的也为2.0。
纹理贴图上的纹素大小为64×32,屏幕上的像素区域大小8×16,那么它们在x轴上的纹素和像素大小比例为8.0(即64/8),在y轴上的纹素和像素大小比例为2.0(即32/16)。
这个比例就是就是纹素的覆盖率

前面介绍过,Gpu不是一个像素(片元)一个像素的放入片元着色器,而是将其组织成2×2的像素块执行,我们可以利用偏导数计算像素中的变化率

ddx(p(x,y))=p(x+1,y)-p(x,y)
ddy(p(x,y))=p(x,y+1)-p(x,y)

对应shader


float MipmapLevel(float2 uv, float2 texSize)
{
    // 把归一化 UV 转成“以纹素为单位”的坐标
    // 这样 ddx/ddy 的单位就是:每个屏幕像素对应多少“纹素变化”
    float2 dx = ddx(uv * texSize);
    float2 dy = ddy(uv * texSize);

    // ρ = max( |∂(u,v)/∂x| , |∂(u,v)/∂y| ) 的向量范数
    float d = max(dot(dx, dx), dot(dy, dy));   
    d = max(d, 1e-8);                          

    // LOD = log2(ρ) ;因为 d=ρ^2,所以 LOD = 0.5*log2(d)
    return 0.5 * log2(d);
}

得到dx和dy的最大值,返回层级

大部分京情况下,OpenGL已经帮我们做好了MipMap层级的计算,在着色器通过text2D(tex,uv)获取颜色的时候,相当于在Gpu内部

floatlod=CalcLod(ddx(uv),ddy(uv));
uv.w= lod;
return tex2Dlod(tex,uv);

显存的工作原理

PC端 GPU

  • 有独立显存(VRAM) ,存放贴图、网格数据等。
  • 除了显存,还包含:

    • 顶点缓存
    • 深度缓存
    • 模板缓存
    • 帧缓存
  • GPU 内部每个处理单元也有自己的缓存(局部存储)。
  • 显存数据更靠近 GPU,取用速度远快于从系统内存拷贝。
  • 渲染前,应用会通过 OpenGL/DirectX 接口把数据从系统内存复制到显存。

移动端 GPU

  • 没有独立显存,CPU 和 GPU 共享系统内存。
  • 架构:基于 ARM SoC,所有模块(CPU/GPU/音频/视频等)共用一个内存控制器。
  • GPU 内部仍有自己的缓存,但显存拷贝步骤变成了 从系统内存到 GPU 缓存。这个过程比较每帧都在进行,虽然可能有cache中的情况,但仍然避免不了重复复制的情况。
  • 访问速度比 PC 独显稍慢,但省去了显存和系统内存间的复制。

存复用与带宽优化

  • GPU 处理数据仍需缓存,否则访问系统内存开销太大。
  • 移动端采用 Tile-Based Rendering(分块渲染)

    • 屏幕划分为小块(Tile)。
    • 在片上缓存完成渲染,减少大规模系统内存读写。
  • 常见优化:

    • 压缩纹理
    • 小纹理拼接(texture atlas)
    • 网格数据压缩

Filter滤波方式

每张纹理贴图可能都是大小不一的贴图,渲染时它们被映射到网格三角形的表面上,转换到屏幕坐标系之后,纹理上的独立像素(纹素)几乎不可能直接与屏幕上的最终画面像素对应起来。这是因为物体在屏幕上显示的大小会随着摄像机距离的变化而变。

  • 当物体非常靠近摄像机时,屏幕上的一个像素有可能对应纹理贴图上纹素中的一小部分(因为物体覆盖了摄像机视口的大部分面积)
  • 而当物体离摄像机很远时,屏幕上的一个像素包含纹理贴图上的很多个纹素(因为物体只覆盖了相机视口的很小一部分)

因此贴图中的纹素与屏幕上的像素通常无法有一比一的对应关系。

滤波方式

滤波方式采样点数是否用 Mipmap效果典型场景
最近点 (Nearest)1可选马赛克、锐利像素风游戏
双线性 (Bilinear)4单一层平滑,但有跳层常规 2D/3D 贴图
三线性 (Trilinear)8两层 + 插值更平滑,过渡自然大多数 3D 材质
各向异性 (Aniso)16+多层+方向性斜角仍清晰地面、草地、道路

最近采样(Nearest)

当纹素与像素不一致时,找到距离采样点最近的一个 texel,直接返回它的颜色。

双线性(Bilinear)

在当前 mipmap 层,取采样点周围 2×2 个 texel,做加权平均(双线性插值),权值根据四个纹素到采样点的距离决定

三线性(Trilinear)

先确定两个最相邻的 mipmap 层(例如 L2 和 L3),分别做一次 双线性过滤,然后在两层结果之间再做一次 线性插值

各向异性采样

纹理在屏幕上投影成“长条形”时(比如斜着看地面),普通三线性会模糊。AF 会在长轴方向上采样更多的 texel(不仅仅是 2×2,而可能是 8×8、16×8 之类的加权)。

Mipmap层级的计算

Width_n  = max(1, floor(W / 2^n))
Height_n = max(1, floor(H / 2^n))

至于选择层级就是用Mipmap处的伪代码

存储开销

  • 每一层的像素数大约是前一层的 1/4(因为长宽都减半)。
  • 所以 Mipmap 总开销 ≈ 原始纹理大小的 1/3

比如:

层级 (Level)宽度高度说明
L0300500原始纹理
L1150250宽、高各缩小一半 (floor)
L275125再缩小一半
L33762⌊75/2⌋, ⌊125/2⌋
L41831⌊37/2⌋, ⌊62/2⌋
L5915⌊18/2⌋, ⌊31/2⌋
L647⌊9/2⌋, ⌊15/2⌋
L723⌊4/2⌋, ⌊7/2⌋
L811最小只能到 1×1
  • 原始纹理 \= 1024 × 1024 \= 1M texel。
  • Mipmap 所有层加起来 ≈ 1.33M texel。

Gpu在一个像素Quad计算梯度后,广播到四个片元

当我们使用,自动计算MipmapLevel并获取对应采样的结果

float4 color = tex2D(_MainTex, uv);

实时阴影是如何生成的

光源渲染 (ShadowCaster Pass)
     ↓ 生成
 Shadow Map (深度图)
     ↓
摄像机渲染 → 每个片元:
   worldPos → lightSpace
   ↓
   得到 (u,v,z_receiver)
   ↓
   采样 Shadow Map(对应片元在lightspace的uv) → z_shadowMap
   ↓
   比较 z_receiver ? z_shadowMap
   ↓
   阴影(>) or 光照(<)

实时阴影一般通过 Shadow Map(阴影贴图) 生成。

  • 从光源出发,把场景渲染一次,得到一个 深度图(Depth Map),表示光源看到的物体表面深度。
  • 在正常摄像机渲染时,通过比较片元到光源的深度和 Shadow Map 的值,判断是否在阴影中。

ShaadowCaster

Shader "Example ShadowCaster"
{
    SubShader
    {
        Tags { "Queue" = "Geometry" }
        Pass
        {
            Tags { "LightMode" = "ShadowCaster" }
            ZWrite On ZTest LEqual Cull Off

            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma multi_compile_shadowcaster
            #include "UnityCG.cginc"

            struct v2f {
                V2F_SHADOW_CASTER;
                UNITY_VERTEX_OUTPUT_STEREO
            };

            v2f vert(appdata_base v)
            {
                v2f o;
                UNITY_SETUP_INSTANCE_ID(v);
                TRANSFER_SHADOW_CASTER_NORMALOFFSET(o);
                return o;
            }

            float4 frag(v2f i) : SV_Target
            {
                SHADOW_CASTER_FRAGMENT(i);  // 写入深度到 shadowmap
            }
            ENDCG
        }
    }
}
  • V2F_SHADOW_CASTER:输出到阴影贴图需要的顶点数据(位置等)。
  • TRANSFER_SHADOW_CASTER_NORMALOFFSET:把顶点转换到光源空间,生成偏移以避免阴影失真(Peter Panning)。
  • SHADOW_CASTER_FRAGMENT(i) :写入深度值到阴影贴图。

屏幕空间阴影投影

不再从光源角度渲染一张 ShadowMap,而是 直接在屏幕空间(摄像机渲染结果 + 深度图)里估算阴影

  • 从当前片元出发,朝着光源方向做深度检测

    • 在屏幕空间上投射一条采样射线。
    • 沿着这条射线在 深度贴图 中采样。
    • 如果发现有“前方像素”的深度比当前片元更近,说明光被挡住 → 阴影。。
      得到一个遮蔽因子 shadow = 0~1,乘到光照上。
技术Shadow Map (光源空间)Screen-Space Shadows (屏幕空间)
数据来源光源视角渲染一次得到的深度图摄像机的深度图 / GBuffer
能否投射屏幕外阴影✅ 可以❌ 不行(看不见的几何体不会生效)
成本中等(要额外渲染一遍场景)较低(直接用屏幕已有深度)
精度问题Shadow Acne、Peter Panning(需要 bias)走样严重、受屏幕分辨率限制
应用普遍使用(点光、聚光、平行光都能用)多用于接触阴影、平行光的大范围柔和阴影

光照纹理烘焙原理

随着人们对场景画质的要求越来越高,实时光照不足以满足。

局光照(GlobalIllumination,GI)是在真实的大自然中光从太阳照射到物体和地面,再经过无数次的反射和折射,使地面上的任何物体和地面反射出来光都叠加着直接照射的光和许许多多物体反射过来的间接光(反射光),让我们的眼睛看到的画面是光亮又丰富的。也无法做到实时进行全局光照(RealtimeGlobalIllumination) ,实时计算量太大,CPU和GPU都无法承受。

所以可以使用离线全局光照,

我们在渲染3D模型时用到的基本元素有顶点、UV、纹理贴图等。顶点上的UV数据在形成片元后就成了顶点间插值后的UV数据。我们通常使用UV坐标去纹理贴图上采样,取得纹素作为像素,再将像素填充到帧缓存中,最后显示到画面上。

光照纹理也是如此,提前将物体光照的明暗信息保存到纹理上, 用UV坐标来取得光照纹理上的纹素作为像素,将这个像素叠加到片元颜色上输出给缓存。

光照颜色=间接光照颜色+直接光照颜色×阴影系数(0到1), GI一般计算的是间接光照,此外对于不复杂的场景,直接光照可能不会使用光照贴图,最后还会有一张阴影贴图。

光照烘焙一般使用辐射算法。

UV

建模软件中的 UVUnity 接口(C#)Shader 语义常见用途说明
UV0mesh.uvTEXCOORD0主纹理(Albedo、Normal、Mask 等)默认贴图坐标,美术展开时制作
UV1mesh.uv2TEXCOORD1Lightmap 光照贴图Unity 导入时生成的无重叠 UV,用来采样静态烘焙光照
UV2mesh.uv3TEXCOORD2GI / AO 贴图存放环境光遮蔽(AO)或实时 GI 数据
UV3mesh.uv4TEXCOORD3自定义数据程序员自由使用,比如特效参数、流动方向、顶点动画控制等

UVChart

UVChart是静态物件在光照纹理上某块网格对应的UV区块,一个物体在烘焙器预计算后会有很多个UVChart。因此每个物件的UVChart由很多个UVChart组成,每个UVChart为一段连续的UV片段默认情况下,每个Chart至少是4×4的纹素,无论模型的大小,一个Chart需要16个纹素。UVChart之间预留0.5个像素的边缘来防止纹理溢出

当一个场景只有一个立方体,被烘焙后,6个面上的UVChart是如何放在光照贴图的

image

image

image

影响UVChart的因素

烘焙器为了快速计算chart,会对相邻顶点距离小于一定值顶点所处的相邻面的角度在一定范围内的顶点放入一个Chart

可以在ProjectSettings设置MaxDistance和MaxAngle,值越大,烘焙越快,但效果越差。

GPUInstancing的来龙去脉

着色器编译过程

Projector投影原理

最后修改:2025 年 09 月 11 日
如果觉得我的文章对你有用,请随意赞赏