乌啦呀哈呀哈乌啦!

欢迎光临,这里是喵pass的个人博客,希望有能帮到你的地方

0%

游戏中的“穿透”通常有两种不同的问题:
物体在一次更新中直接越过障碍物,碰撞根本没有被检测到;或者碰撞已经被检测到,但物体仍然陷进了障碍物,甚至被挤到另一侧。

物体高速穿墙

物理引擎通常按固定时间间隔更新,而不是在每一个真实时间点都计算碰撞。

采用离散碰撞检测时,可以将问题理解为:物理引擎检查更新时刻的碰撞形状,却没有充分检查两次更新之间经过的路径。

上一次物理更新 下一次物理更新
● ──────────│墙│────────── ●
检查时没重叠 检查时也没重叠

虽然运动路径经过墙,但两个检查时刻都没有重叠,于是碰撞可能被漏掉。这通常叫 Tunneling,隧穿。

真正需要关注的是相对运动速度。两个物体迎面运动时,即使各自速度不算特别高,相对速度也可能很高。
旋转同样可能造成问题:长杆快速旋转时,杆的末端可以在一个物理步内跨过很远的距离。

一个实用的判断思路是:

当“每个物理步的相对位移”接近或超过“障碍物和运动物体在运动方向上的有效尺寸”时,离散检测漏碰撞的风险会明显增大。
这只是风险判断,不是严格的安全保证。斜向移动、旋转、形状和接触位置都会影响结果。

物体陷进障碍物

这属于另一个问题:碰撞求解不够及时、不够准确,或者运动方式绕过了求解过程。

多数实时物理引擎允许出现少量重叠,然后通过接触约束、速度修正和位置修正,让物体逐渐分离。因此,轻微的嵌入不一定代表检测失效。

严重嵌入通常与这些因素有关:

  • 外力或速度太大,接触约束来不及阻止运动。
  • 堆叠、关节或多处接触很复杂,求解迭代次数不足。
  • 相互接触的动态物体质量差距很大,求解更难稳定。
  • 脚本持续强行设置位置,把物体反复推入墙中。
  • 物体生成时就与场景重叠。
  • 碰撞体尺寸、形状或缩放不合理。

例如,角色已经撞到墙,但脚本每帧仍然执行:

1
transform.position += direction * speed * Time.deltaTime;

这段代码直接改变位置,并没有要求物理系统先判断这一步能否移动。即使检测到了碰撞,下一帧脚本仍可能再次把角色推进墙内。

解决方案

方案 原理 适用场景 局限或代价
连续碰撞检测 CCD 检查运动过程中的碰撞,或预测接触 高速刚体、弹丸 增加计算开销,具体模式对旋转等情况支持不同
缩短物理步长 提高物理更新频率,减小每步位移 整体物理模拟 CPU 开销增加,极高速物体仍可能穿透
增加子步 将一次运动拆成多次,每次都检测碰撞 快速角色、机关、复杂运动 子步越多,开销越大
射线检测 检查当前位置到预计下一位置的线段 小子弹、瞬时射击 射线没有厚度,不适合直接代表大物体
形状扫掠 用球、胶囊或盒子检查运动路径 有体积的弹丸、角色 需要处理初始重叠、多次碰撞和旋转
限制速度或单步位移 避免一次跨过障碍物 普通角色、箱子、慢速机关 可能改变运动效果
加厚、简化碰撞体 增加可检测的接触范围,改善接触稳定性 薄墙、复杂场景模型 可能出现视觉模型与碰撞边界不一致
移动前检测并截断位移 发现障碍物后,只移动到接触位置附近 自定义角色、运动学对象 需要实现滑动、台阶和拐角处理
重叠检测与位置恢复 检测已有嵌入,计算分离方向并修正位置 生成、传送、平台挤压 属于事后修正,处理不好会抖动或弹出
调整碰撞求解参数 提高接触约束的求解精度 堆叠下陷、关节拉伸、持续嵌入 无法解决根本没检测到的碰撞
改变对象实现方式 用命中查询或逻辑轨迹替代高速实体 枪械、飞行弹丸 需要自行实现飞行、命中和表现逻辑

这里有几个容易混淆的地方。

射线和形状扫掠虽然可以替代引擎内置 CCD,但沿运动路径检测碰撞,本身也属于连续检测的思路。
如果你想找原理上不同于 CCD 的办法,主要是缩短步长、增加离散子步、限制速度、调整碰撞体,以及重叠修正。

增加子步必须包含检测。
例如把一次移动拆成十次,每次都查询碰撞才有意义;只在循环里设置十次位置,物理引擎未必会在中间进行检测。

提高求解迭代次数主要解决“碰到了却挡不稳”。
它适合箱子堆叠、复杂接触等情况,不能解决子弹直接跳过薄墙造成的漏检测。

实际选择可以按对象来定:

  • 小子弹:检查前后位置之间的射线;需要飞行时间时,逐步计算轨迹并查询。
  • 火球、炮弹等有体积的弹丸:使用球形扫掠,或者适合的 CCD。
  • 玩家角色:移动前做胶囊查询,截断位移,再处理沿墙滑动和重叠恢复。
  • 动态箱子:检查移动方式和碰撞体,再按需要调整步长、CCD 和求解参数。
  • 快速挥剑、旋转刀片:采样旋转轨迹或检测扫掠体积,单纯检查中心移动路径不够。
  • 传送后卡进墙里:先验证目标位置,再做必要的重叠恢复。

通常会组合使用。例如角色采用 移动前形状查询+沿墙滑动+重叠恢复;高速弹丸采用 轨迹查询+首次命中处理。

Spine 动画通常是在 Spine 编辑器里,把分层图片绑定到骨骼,再给骨骼和图片属性制作关键帧得到的。
Unity 通过 Spine 官方运行库播放它,它和 Unity 的 Animation / Animator 可以配合使用。

动画制作

以一个 2D 人物为例,用Spin制作动画通常是下面这个流程:

  1. 准备分层图片
    把头、身体、上臂、前臂、腿、武器等拆成独立图片。被其他部位遮住的部分通常也需要补画,避免转动时露出空洞。
  2. 在 Spine 编辑器中建立骨骼
    例如:身体 → 上臂 → 前臂 → 手。移动上臂时,前臂和手会跟着移动。
  3. 把图片挂到骨骼上
    简单部位可以直接挂一张图片;需要弯曲的部位,可以把图片划分成网格,并设置骨骼权重,让多个骨骼共同控制图片变形。
  4. 制作动画关键帧
    在时间轴上设置骨骼的位置、旋转、缩放,还可以设置图片切换、透明度、绘制顺序和网格变形,制作 idle、walk、attack 等动画。
  5. 导出供游戏使用的资源
    资源 用途
    .json 或 .skel 骨骼、皮肤、动画等数据
    .atlas 图片在图集中的位置和相关信息
    .png 实际显示的图集图片

所以它通常不是预先渲染的一段视频,也不是每帧一张图片。游戏运行时,Spine 根据动画数据计算骨骼姿态,再生成和绘制网格。

在 Unity 中使用

先安装官方 spine-unity 运行库。运行库版本要与导出资源所用的 Spine 编辑器主次版本匹配,例如 Spine 4.2 导出的资源,应使用对应的 4.2 运行库。

把导出文件放入 Unity 的 Assets 后,运行库会生成相关资源,核心是 SkeletonDataAsset,然后选择适合的组件:

使用场景 组件
场景里的角色、怪物、特效 SkeletonAnimation
Canvas 中的 UI 动画 SkeletonGraphic
想通过 Unity Animator 状态机控制 SkeletonMecanim

最常见的是 SkeletonAnimation。下面是使用方式示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
using UnityEngine;
using Spine.Unity;

public class SpineExample : MonoBehaviour
{
[SerializeField] private SkeletonAnimation skeleton;

private void Start()
{
// 在第 0 条轨道循环播放 idle。
skeleton.AnimationState.SetAnimation(0, "idle", true);
}

public void Attack()
{
// 播放一次 attack,结束后回到 idle。
skeleton.AnimationState.SetAnimation(0, "attack", false);
skeleton.AnimationState.AddAnimation(0, "idle", true, 0f);
}
}

这里的 "idle" 和 "attack",必须是 Spine 资源中已有的动画名称。
Spine 还支持动画混合、多轨道叠加、换皮肤和动画事件。例如下半身继续走路,上半身播放攻击;或者通过皮肤切换衣服、武器。

Unity 原生动画主要通过动画曲线,修改 GameObject、Transform 和组件属性。
Spine 则有自己的骨骼、插槽、附件、皮肤、网格变形和绘制顺序。它的骨骼通常不是 Unity 层级里的一组普通 Transform,所以 Unity 原生 Animation 组件无法直接读取 Spine 导出的骨骼动画数据。

  • SkeletonMecanim 提供一层桥接,让 Animator 状态机选择和混合 Spine 动画。最终的骨骼计算与绘制仍然由 Spine 运行库完成。
  • 或者可以用 Unity 原生动画让整个 Spine 角色移动、缩放,同时让 Spine 播放角色内部的走路动画。若两套动画同时修改同一个属性,则需要明确由哪一套负责。

游戏 UI 中的“框架”通常指两类东西:负责显示界面的 UI 系统,以及负责组织界面代码的架构框架。

以 Unity 为例,uGUI、UI Toolkit 属于第一类;MVC、MVP、MVVM,以及窗口管理、页面导航、数据绑定等机制属于第二类。
它们可以组合使用,例如:

用 uGUI 制作界面,用 MVP 组织代码,再用 UIManager 管理窗口的打开、关闭和层级。

UI系统

UI 系统 核心方式 常见用途 主要特点
uGUI Canvas + GameObject + Component 游戏 HUD、背包、商店、弹窗 与 Unity 场景、Prefab、动画系统结合紧密
UI Toolkit VisualElement + UXML + USS 编辑器工具、菜单、设置、列表型界面 文档结构、样式与逻辑分离
IMGUI 在 OnGUI() 中通过代码绘制 简单调试界面、旧编辑器工具 写起来直接,但不适合作为复杂游戏 UI 的主系统
FairyGUI 等第三方方案 专用编辑器 + 对应运行时 界面量大、依赖 UI 设计协作的项目 强调界面制作、组件复用、过渡动画等完整工作流

这里的选择决定了“界面怎么制作和渲染”,但不会自动解决“业务逻辑怎么组织”。

代码框架

无论用哪种 UI 系统,都会遇到同一个问题:

用户点了按钮,谁处理操作?数据变了,谁更新界面?

MVC、MVP、MVVM 就是在回答这个问题。

架构 主要角色 界面如何更新 适合情况
MVC Model、View、Controller 由 Controller 或模型通知机制推动 流程简单、团队约定清晰
MVP Model、View、Presenter Presenter 显式调用 View uGUI 项目、希望逻辑清晰且容易测试
MVVM Model、View、ViewModel 通过绑定或状态通知更新 表单、列表、状态较多的界面
单向数据流 State、Action、Reducer/处理器、View 操作更新状态,界面读取状态 状态组合复杂、需要追踪变化的界面
这些名字不是自动就能带来良好设计。关键在于:显示、交互协调和业务规则是否有明确边界。

项目设计

MVC、MVP、MVVM 只解决代码组织的一部分。游戏项目还需要处理以下问题。

能力 Unity 中的典型问题
窗口管理 同一个窗口能否重复打开?关闭后销毁还是缓存?
生命周期 什么时候绑定事件、刷新数据、播放动画、释放资源?
分层 HUD、普通页面、弹窗、加载遮罩谁在上面?
导航 返回键关闭弹窗,还是回到上一个页面?
资源加载 Prefab 从哪里加载?异步加载如何避免重复?
数据同步 金币变化后,哪些可见页面需要刷新?
列表复用 几百个背包物品如何避免生成几百套复杂控件?
输入管理 弹窗打开后,下面的页面还能否被点击?
分辨率适配 安全区域、横竖屏、锚点、长文本如何处理?
异步取消 窗口关闭后,请求结果是否还会写入控件?
其中最容易被低估的是生命周期和异步处理。

假设玩家打开排行榜,界面开始请求数据,然后玩家马上关闭它:

打开排行榜
↓
开始网络请求
↓
关闭排行榜
↓
请求返回

此时如果直接执行:rankText.text = result;
可能出现对象已经销毁、数据更新到下一次打开的界面,或旧请求覆盖新请求的情况。
框架需要明确约定:关闭时取消请求,或在结果返回时检查本次打开是否仍然有效。

窗口生命周期

一种常见设计是:

创建
↓
初始化
↓
打开
↓
显示
↓
关闭
↓
隐藏并缓存 / 销毁

假如约定以下方法:

1
2
3
4
5
6
OnCreate()
OnOpen(args)
OnShow()
OnHide()
OnClose()
OnDestroy()

这些名字的具体含义由框架决定,并不是 Unity 内置生命周期。
还要与 Unity 的 Awake、OnEnable、OnDisable、OnDestroy 区分。例如隐藏一个缓存窗口,只会使它停用;下一次打开时,Awake() 不会重新执行。

分层与导航

常见层级可以是:

HUD
Page
Popup
Loading
Toast

但“画在上面”和“阻止下面接收输入”是两个问题。一个透明遮罩即使视觉上位于最上层,也需要正确配置射线或输入处理,才能阻止点击穿透。

导航则适合使用页面栈:

主界面 → 背包 → 物品详情

返回时:

物品详情 → 背包 → 主界面

Toast、加载遮罩和长期显示的 HUD 通常不应该全部放进同一套返回栈。

缓存与列表虚拟化

这两者容易混淆:

  • 窗口缓存:关闭背包后保留整个背包实例,下次直接复用。
  • 对象池:反复使用物品格子,减少创建和销毁。
  • 列表虚拟化:只保留可视区域附近的格子,根据滚动位置展示不同数据。
    有 1000 件物品时,即使通过对象池创建了 1000 个格子,它们仍可能带来大量布局和渲染成本。虚拟化解决的是“同时存在多少个显示单元”的问题。

MipMap(多级纹理映射)是计算机图形学中用于提高渲染效率和图像质量的一种技术。在Unity 3D等游戏开发中,MipMap的作用主要体现在以下几个方面:

  • 减少模糊效果:当纹理在屏幕上缩小时,使用MipMap可以避免出现模糊和失真现象。MipMap的概念是为同一纹理创建多个采样级别,每个级别的分辨率逐渐降低。当物体离摄像机较远时,使用较低分辨率的纹理进行渲染,从而提供更清晰、自然的视觉效果。
  • 提高渲染效率:通过选择合适的MipMap级别,可以减少显存带宽的使用。这是因为较低分辨率的纹理占用的内存较少,传输到显卡时也能够更快速,从而提高渲染速度。
  • 减少锯齿现象:MipMap有助于减少远景物体的锯齿效果。当物体距离摄像机较远时,使用低分辨率的MipMap纹理能够更自然地显示物体的轮廓。
  • 支持纹理过滤:MipMap通常和各种纹理过滤技术(如双线性、三线性过滤)结合使用,进一步改善渲染质量。

在Unity中,开发者可以在材质的设置中开启MipMap,系统会自动为指定的纹理生成适当的MipMap级别,从而优化游戏的表现。总之,MipMap在Unity 3D中的作用是提高画面质量和渲染性能,使游戏体验更加流畅和真实。

纹理采样

我们对纹理采样进行显示的过程,可以理解为将屏幕上的一个像素(下文用像素表示)映射到纹理上的一个像素(下文用纹素表示),然后用纹理上的这个像素的颜色进行显示。

理想情况下,屏幕上的每个像素都可以正好对应到纹理上的一个纹素。

如上图这种情况,将3X3的纹理显示到3X3的屏幕上时,屏幕坐标(1,1)的像素对应的正好也是纹理上(1,1)的纹素。
图中黑点代表的是每个纹素的位置,红点代表的是屏幕像素映射到纹理上的位置(uv),此时只需要取对应纹素的颜色进行显示即可。

  • 这里的 UV 通常指归一化坐标,一般在 [0, 1] 范围内,可以理解为百分比位置,但实际用小数表示。例如 (0.5, 0.5) 表示纹理中心。它不是直接的纹素坐标。
  • 对于宽 W、高 H 的纹理:UV (u, v) 对应纹素空间的位置为 (u × W, v × H)。

但在实际情况中,纹理采样的范围与显示该纹理的屏幕范围并不是一一对应的;
并且在透视相机下,随着纹理所附着的物体与摄像机间的距离改变,其占据的屏幕空间的大小也会发生变化,也就是纹理覆盖的屏幕区域的大小会发生变化。这就会出现纹理过小和纹理过大两种情况。

纹理过小

当物体靠近透视相机,占据的屏幕空间变大,屏幕上更大范围的像素会被用来显示物体身上的纹理,就会出现纹理过小的情况。

比如上图3X3的纹理需要显示在6X6的屏幕范围上时,图中红点和黄点分别代表屏幕空间的前两个像素,此时二者都对应纹理空间的第一个纹素,且其采样位置也不是正好在纹素的位置。
可见,纹理过小时会出现多个像素拥挤到同一个纹理像素上,并且纹理与屏幕范围差距越大,拥挤到同一个纹素上的像素数量越多。此时可以采用:

  • 就近点采样(最近邻采样):根据 UV 选择最近的纹素中心,返回它的颜色。
  • 双线性过滤:混合周围几个纹素的颜色。

就近点采样



如上图所示,红点为屏幕像素映射到的位置,当使用就近点采样进行滤波时,会查找距离目标最近的纹素进行采样,也就是图中的蓝点对应的纹素。

就近点采样速度快,但问题也很明显,当纹理过小且纹理与屏幕范围差距较大时,如果采用就近点采样,就会导致大片的像素都采样到同一个颜色,使渲染出来的图像看起来一块一块的,颗粒感(或像素化)严重。

双线性插值



在双线性插值中,会考虑离采样点最近的四个纹素作为参考点,也就是上图中P1 P2 P3 P4四个蓝点
根据采样点与参考点的水平距离,对P1 P2进行插值得到新参考点P5的颜色、对P3 P4进行插值得到新参考点P6的颜色



之后根据采样点与参考点之间的纵向距离对新参考点P5P6进行插值,从而得到最终的采样颜色。

双线性插值通过两次插值计算,使采样结果在一定程度上反映出采样点附近的综合颜色,且速度也较快,是最常用的滤波方式。

纹理过大

当物体远离透视相机,占据的屏幕空间变小,物体身上的纹理只能被显示在屏幕上很少的几个像素上,就会出现纹理过大的情况。

当出现纹理过大时,如将6X6的纹理显示在4X4的屏幕范围内,一个屏幕像素内就需要塞下多个纹素,上图中红框和黄框分别代表相邻的2个屏幕像素对应的纹素范围。

由于一个像素无法包含如此多的颜色,因而会导致纹理信息丢失,并进一步导致相邻像素间颜色信息不连贯,从而产生摩尔纹或者锯齿化的现象(采样不足产生的混叠现象)。

  • 锯齿:斜线或物体边缘呈阶梯状。屏幕像素数量有限,无法准确表达连续的边界;物体移动时,边缘还可能闪烁
  • 摩尔纹:密集的重复图案出现原本不存在的条纹、波纹。例如远处的砖墙、格子地板,看起来产生大块明暗条带,移动时也可能闪烁

Mipmap

纹理过大问题的本质是一个像素无法表达多个纹素而导致的颜色信息丢失。
最直观的解决方案便是对单个屏幕像素覆盖的多个纹素进行多次采样,然后按照取平均值或者其他方式进行合并,以最终的合并结果作为采样得到的颜色值,也就是超采样技术。

实时超采样的开销无疑是巨大的,为了提升采样速度,我们可以按照一定范围将纹素提前进行合并,生成一张新的纹理,当需要进行超采样时,只要对新生成的纹理直接采样显示即可。
另外一个问题是,当物体与透视相机的距离发生改变,单个屏幕像素要表达的纹素数量也会发生变化,为了使采样结果更加可靠,我们可以以像素覆盖纹素的数量作为依据划分几个等级,计算每个等级需要合并的纹素范围,生成多张新纹理,在渲染时选择对应等级的新纹理进行采样。

以上便是MipMap的原理。

在MipMap中,以原始纹理作为Level 0。
将当前Level D的相邻四个像素进行合并,生成次一级的纹理Level D + 1。所以 Mipmap 每升一级,宽和高都减半。
在渲染时,根据相邻屏幕像素的UV坐标最大距离,算出每个屏幕像素覆盖的纹素范围L,然后对 L 取以 2 为底的对数即可得到对应的MipMap等级D。
最后从已经提前生成的Level D级的纹理中进行采样。


Credits

https://blog.csdn.net/zhiai315/article/details/138750976


Google Protocol Buffers(简称 Protobuf)是一套“数据结构定义 + 代码生成 + 二进制序列化”工具。
先在 .proto 文件里约定字段,再生成 C#、Java、Go 等语言的读写代码,让客户端和服务端遵守同一份协议。

数据定义

以这份玩家数据为例:

1
{"chips":150,"money":12.5,"bonus":0}

不带空格的 UTF-8 JSON 是 34 字节。
JSON 是文本格式,字段名、数值、字符串以及标点,最终都编码成文本字节传输。

比如 {“chips”:150} 的chips字段,除了字段名本身,还需要两个字节传输引号。 连同冒号,”chips”: 共占 8 字节。

这里 150 是 JSON 数值,占 3 字节。
接收方解析后,才会把数值文本 150 转换为内存中的数字;它在 JSON 传输时并不是一个固定 4 字节的整数。
类似地,12.5 传的是四个字符 1、2、.、5,JSON 本身也不区分 float 和 double,由接收程序决定怎么存储。

你的protobuf数据可以定义为:

1
2
3
4
5
6
7
syntax = "proto3";

message PlayerBalance {
int32 chips = 1;
float money = 2;
float bonus = 3;
}

这里的 1、2、3 是固定的字段编号,不是字段值。

按上面的协议,Protobuf 二进制通常只需要 8 字节:

内容 JSON Protobuf
字段标识 每次发送 "chips" 等字段名 发送字段编号和编码类型组成的标签
整数 150 3 个文本字节 2 个变长整数字节
浮点数 12.5 文本表示 float 固定 4 字节
bonus = 0 仍包含字段名和值 此例中,proto3 默认值省略
结构 花括号、引号、冒号、逗号 无须这些文本符号

这个例子里的 8 字节,chips 占 3 字节,money 占 5 字节,bonus 不占空间。(每个字段标签占1字节)
Protobuf的标签不包含字段名,也不一定直接包含字段长度。标签包含这个字段的部分元数据:字段编号和底层编码方式。

整条消息的结束位置则由外层提供,例如 HTTP 响应体的长度或嵌套消息的长度前缀。Protobuf 本身没有一个通用的“消息结束符”,所以在连续的 TCP 字节流中发送多条消息时,需要另外约定每条消息的长度。

数据结构

字段编号与字段名的对应关系需要额外存储。
Protobuf 的关键是:把这份映射提前放在客户端和服务端的协议定义、生成代码里,而不是每次发送数据时重复传输。

所以它用“双方提前约定数据结构”的成本,换取“每条消息更小”的收益。

但缩小比例取决于数据:大量重复字段名、数字、列表通常收益明显;很长的字符串内容仍然要传。
Protobuf 不是压缩算法,也不是加密算法,还可以与 gzip 等压缩一起使用。
若用 Protobuf 的 JSON 输出模式,传输的仍是 JSON,拿不到上述二进制收益。

接入时需要注意这几件事:

  1. 客户端和服务端共同支持协议。 如果服务端仍然只接受表单和 JSON,单独换 Unity 序列化方式无法使用。
  2. 网络层改为处理字节。 通常用 UploadHandlerRaw 发送,读取 downloadHandler.data 接收;Protobuf 也可以继续通过普通 HTTP 使用,不要求 gRPC。
  3. Unity 可以使用 Google.Protobuf,但接入后仍需验证程序集引用和目标平台的 IL2CPP 构建

字段映射

开发时,.proto 文件就是协议定义:

字段编号 字段名 数据类型
1 chips int32
2 money float
3 bonus float

通过 protoc 编译器,可以为客户端生成 C# 类,为服务端生成对应语言的类。

字段名还可能保存在生成的描述信息中,用于反射、JSON 转换等功能。这些内容属于程序代码和元数据,不需要跟着每条网络消息发送。
可以理解为双方事先约定:

“编号 1 表示筹码,编号 2 表示余额,编号 3 表示奖励余额。”

此后每次通信只发送“编号和值”。

JSON 则会在每次通信时重复发送:

“chips 的值是 150,money 的值是 12.5……”

因此,Protobuf 并没有消灭映射,而是把映射从每条消息里移到了双方程序里。

以及二进制里并不是只有字段值,Protobuf 不能直接把 150 和 12.5 连起来,因为接收方需要知道:

  • 这是哪个字段?
  • 这个字段的值占多少字节?
  • 读完后下一个字段在哪里?

所以每个实际发送的字段,基本结构是:

字段标签 tag + 字段值

对于字符串等可变长度的数据,则是:

字段标签 tag + 内容长度 + 内容

Unity热更新可以通过Lua框架(xLua, toLua等)或者HybridCLR实现。

xLua 下载的是 Lua 脚本,由 Lua VM 执行。
HybridCLR 下载的是 C# DLL,由 HybridCLR 在 IL2CPP 中解释执行。

主要流程

如果你项目使用 Addressables,那么 catalog.json、AB 包、New Build / Update Build 的流程,对两者都一样。
Addressables 不关心文件是 Lua 还是 DLL;它只负责把指定地址的资源拿回来。

Player 内置启动器
↓
检查远端 catalog.hash / catalog.json
↓
下载变化的 AssetBundle
↓
从 AB 取出热更新文件
↓
加载并运行热更新逻辑

首次发布Android平台远端资源时,可以这样做:

Addressables → New Build
↓
ServerData/Android/
↓
将其中内容部署到 Cloudflare/CDN 的 Android 资源目录

例如:

本地:
ServerData/Android/
catalog_xxx.json
catalog_xxx.hash
hotcode_a1.bundle
ui_b1.bundle

CDN:
https://assets.example.com/aa/Android/
catalog_xxx.json
catalog_xxx.hash
hotcode_a1.bundle
ui_b1.bundle

需要确保以下两点:

  1. 指定远端资源构建。需要启用 Build Remote Catalog,并将需要热更的 Group 设置为:

Build Path = Remote.BuildPath
Load Path = Remote.LoadPath

仅在当前项目点击 New Build,因远端 Catalog 关闭、默认 Group 是 Local,未必会产生可部署的 ServerData/Android 完整热更新内容。

  1. Remote.LoadPath 必须在构建前指向真实 CDN 地址,例如:

https://assets.example.com/aa/[BuildTarget]

不要先用 localhost 构建,再直接把文件传到 Cloudflare;Catalog 中会保留构建时写入的 LoadPath。

首次发布后,后续的操作不同:

Update Previous Build
↓
只产生新增/变化的 Bundle 与新 Catalog
↓
合并到服务器已有 Android 资源目录

不要清空服务器 Android 目录,也不要只部署本次 Update Build 的少量输出。新 Catalog 文件替换,旧 Bundle 保留。

xLua:Lua 驱动 C#

xLua 的核心是把 Lua VM 放进 Player:

C# AOT Player
↓
LuaEnv
↓
下载 Main.lua、Login.lua、Battle.lua
↓
Lua 脚本解释执行
↓
通过绑定层调用 Unity/C#

游戏启动时,C# 通常创建一个全局 LuaEnv
所以它不是“把 Lua 脚本编译进原生 Player”;而是 Player 预先带着 Lua 解释器,后续下载 Lua 脚本,解释器负责运行这些新脚本。

Lua 与 C# 的交互通常需要配置生成:

  • CSharpCallLua:让 C# 调用 Lua。
  • LuaCallCSharp:让 Lua 高效调用 C#。
  • Delegate、Interface、泛型、重载等都要处理绑定与代码生成。

xLua 的两种“热更新”

  • Lua 业务热更新
    这是最常见、最稳的方式:

C#:框架、Unity 封装、网络、资源管理
Lua:UI 流程、活动、战斗规则、业务逻辑

后续下载新的 Lua 文件即可生效。
优点是包小、更新快;代价是业务层需要长期使用 Lua 开发,并维护 Lua/C# 边界。

  • xLua Hotfix:Lua 覆盖 C# 方法
    xLua 还有 Hotfix 注入机制。你在发包前标记/生成可热修复的 C# 方法,构建时 xLua 向这些方法插入跳板:

原 C# 方法
↓
预先注入 Hotfix 检查点
↓
运行时 Lua 可替换该方法实现

例如 Lua 可以通过 xlua.hotfix(…) 替换一个已预留的 C# 方法。
关键限制是:xLua 不能在 Player 发布后,任意修改一个当初没有注入 Hotfix 跳板的 C# 方法。
也就是说,它是“预埋可替换点”,不是发布后任意加载新 C# 代码。

HybridCLR:运行新的 C# 逻辑

HybridCLR 则更接近“可加载新的 C# 程序集”,但同样受基础 Player 的 API、裁剪、泛型、桥接代码约束。

C# AOT Player
↓
HybridCLR Runtime
↓
下载 Game.HotUpdate.dll.bytes
↓
Assembly.Load
↓
解释执行新的 C# IL

热更新代码仍是 C#:

  • 可以新增普通 C# 类、接口、业务系统。
  • 可以使用熟悉的 C# 语法、async、LINQ、泛型、异常等。
  • 可以直接引用已有 AOT 层 API。
  • 主体逻辑通常无需改写成另一种语言。
    但它不能改变已经编译进 Player 的原生 AOT 代码;它解决的是“新增/替换热更新程序集中的逻辑”。

Catalog 与 AB包

假设热更入口地址为 HotEntry。

  • xLua

catalog.json
“HotEntry”
→ lua_main_abc.bundle

lua_main_abc.bundle
→ Main.lua
→ Login.lua
→ Battle.lua

运行时:

1
luaEnv.DoString(luaTextAsset.text, "Main.lua");
  • HybridCLR

catalog.json
“HotEntry”
→ hotcode_abc.bundle

hotcode_abc.bundle
→ Game.HotUpdate.dll.bytes

运行时:

1
Assembly.Load(hotDllTextAsset.bytes);

HybridCLR 可能还多一组

aotmetadata_abc.bundle
→ Game.Runtime.dll.bytes
→ System.dll.bytes

这些 AOT 元数据要在加载热更新 DLL 前处理。

New Build / Update Build 对比

它们的发布方式一致。第一次发包:

xLua:
New Build → Lua AB + Catalog → 打入/上传

HybridCLR:
生成 HybridCLR 必要代码
→ 构建 Player
→ 打包热更新 DLL / AOT 元数据到 AB
→ New Build → Catalog + AB

后续只改热更逻辑:

xLua:
改 Lua → Update Previous Build → 上传新 Lua AB + Catalog

HybridCLR:
编译新 HotUpdate.dll
→ Update Previous Build
→ 上传新 DLL AB + Catalog

两者都不是二进制增量补丁:

  • 修改一个 Lua 文件,通常重下它所在的整个 Lua Bundle。
  • 修改一个 DLL,通常重下它所在的整个 DLL Bundle。
  • Bundle 划分越细,下载越小;Bundle 越大,依赖和管理越简单。

点到直线距离

方法一:投影 + 勾股定理

利用点到直线上某点的向量长度,以及它在直线方向上的投影长度,用勾股定理求出垂直分量。

1
2
3
4
5
6
// 点到直线距离
public static float DistancePoint2Line(Vector3 point, Vector3 linePoint1, Vector3 linePoint2)
{
float fProj = Vector3.Dot(point - linePoint1, (linePoint1 - linePoint2).normalized);
return Mathf.Sqrt((point - linePoint1).sqrMagnitude - fProj * fProj);
}

原理:|d|² = |斜边|² - |投影|²(勾股定理)。

注意:在浮点误差下 sqrMagnitude - fProj² 可能出现极小的负数,导致 Mathf.Sqrt 返回 NaN,必要时可对被开方数做一次 Mathf.Max(0, ...) 保护。

方法二:叉乘(面积法)

两向量叉乘的模等于它们所构成平行四边形的面积,面积除以底边长度即为高(也就是点到直线的距离)。这种方式在 3D 中最推荐,代码简洁且数值稳定。

1
2
3
4
5
6
public static float DistancePoint2Line_Cross(Vector3 point, Vector3 linePoint1, Vector3 linePoint2)
{
Vector3 dir = linePoint2 - linePoint1;
Vector3 v = point - linePoint1;
return Vector3.Cross(v, dir).magnitude / dir.magnitude;
}

原理:距离 = 平行四边形面积 / 底 = |v × dir| / |dir|。

方法三:先求投影点,再求两点距离

先算出点在直线上的投影点坐标,再直接用 Vector3.Distance。好处是能顺便拿到最近点(投影点)。

1
2
3
4
5
6
public static float DistancePoint2Line_Project(Vector3 point, Vector3 linePoint1, Vector3 linePoint2)
{
Vector3 dir = (linePoint2 - linePoint1).normalized;
Vector3 projPoint = linePoint1 + Vector3.Dot(point - linePoint1, dir) * dir;
return Vector3.Distance(point, projPoint);
}

补充:以上三种求的都是无限长直线的距离。如果要的是**线段(Segment)**距离,需要把投影参数 t 用 Mathf.Clamp01 限制在 [0, 1] 之间,再求点到该最近点的距离。

点到面距离

点到平面的距离公式:d = |Ax0 + By0 + Cz0 + D| / √(A² + B² + C²)。

平面的一般式方程为 Ax + By + Cz + D = 0,平面的法向量为 (A, B, C)。

如果一个非零向量 n 与平面 a 垂直,则称向量 n 为平面 a 的法向量。垂直于平面的直线所表示的向量即为该平面的法向量。每一个平面存在无数个法向量。

方法一:用 Plane 类

Unity 内置的 Plane 类可以由三点构造,并直接求点到平面的(有符号)距离。

1
2
3
4
5
public static float DisPoint2Surface_Plane(Vector3 point, Vector3 surfacePoint1, Vector3 surfacePoint2, Vector3 surfacePoint3)
{
Plane plane = new Plane(surfacePoint1, surfacePoint2, surfacePoint3);
return plane.GetDistanceToPoint(point);
}

方法二:用法线 + 点乘

由平面上两条边叉乘得到法向量,再把「点到平面上一点」的向量投影到法向量方向上,其绝对值即为距离。

1
2
3
4
5
6
public static float DisPoint2Surface_Normal(Vector3 point, Vector3 surfacePoint1, Vector3 surfacePoint2, Vector3 surfacePoint3)
{
Vector3 cross = Vector3.Cross(surfacePoint1 - surfacePoint2, surfacePoint1 - surfacePoint3);
float proj = Vector3.Dot(point - surfacePoint1, cross.normalized);
return Mathf.Abs(proj);
}

核心架构差异

UGUI:每个 UI 元素是一个 GameObject

  • UI 就是场景里的 GameObject,挂 RectTransform + Canvas Renderer + 各种组件(Image、Text、Button…)。
  • 一个复杂界面 = 成百上千个 GameObject,纳入正常的场景层级、Prefab 系统。
  • 优点:概念统一,和场景其它对象一致,容易理解、容易挂脚本。
  • 缺点:GameObject/Component 开销大,元素多了内存和遍历成本高。

UI Toolkit:一棵轻量的 VisualElement 树

  • UI 是一棵 VisualElement 树,不是 GameObject,是纯 C# 轻量对象。
  • 结构(UXML)、样式(USS)、逻辑(C#)三者分离,像 Web 的 HTML/CSS/JS。
  • 优点:元素轻量,海量元素也省内存;结构/样式/逻辑解耦。
  • 缺点:不在场景层级里,不能直接给单个元素挂 MonoBehaviour,思路要转变。

渲染机制

UGUI的渲染单元是Canvas,按 Canvas 合批

  • 合批:依赖 Canvas 内元素的层级/材质,容易被打断,需手动优化(拆 Canvas、图集)
  • 重建:一个元素变化可能触发整个 Canvas rebuild(著名的性能坑)

UI Toolkit的渲染单元是统一的 UIR(UI Renderer)网格系统

  • 合批:通过引擎自动生成网格并合批,通常 drawcall 更少
  • 重建:局部脏标记,只重建变化部分

更新UI样式

UGUI的样式序列化在每个GameObject/组件字段上

  • 复用:改一处只影响一个游戏对象,批量改要靠 Prefab 或脚本
  • 热重载:组件字段改了即时可见(Inspector)
  • 逻辑分离:样式和结构混在场景/预制体里

UI Toolkit的样式存保存于独立的 USS 文本文件中

  • 复用:一个选择器(class)改一处,所有匹配元素全局生效
  • 热重载:存盘热重载,且可运行时动态换 USS
  • 逻辑分离:样式(USS)/结构(UXML)/逻辑(C#)三文件分离

UI Toolkit 让”改样式”变成改一个集中的、可选择器批量作用、可热重载、可运行时切换的文本文件,且和 C# 逻辑彻底分离

样式和结构可以脱离 C# 迭代,但数据源和逻辑仍在 C# 里:

  • 纯视觉(颜色、间距、字体、布局、:hover 效果)→ 改 USS 即可,不碰 C#。
  • 改结构(增删元素、换控件)→ 改 UXML,通常也不用重编译 C#(如果不新增需要 C# 引用的元素)。
  • 改数据绑定关系:如果绑定写在 UXML 里,改绑定路径也不用重编译;但如果绑定写在 C# 里(SetBinding(…)),那就要重编译。
  • 改 ViewModel / [CreateProperty] 属性 / 业务逻辑 → 必须重编译 C#。

数据绑定

UGUI 根本没有内置数据绑定系统。你在 UGUI 里做的所有”数据显示到 UI”都是手写命令式代码。
UI Toolkit(Unity 6 起)则提供了声明式的数据绑定。所以优势不是”绑定做得更好”,而是”从无到有”。

UGUI 的写法(命令式,手动同步)

在Player类中手动引用healthSlider和实现更新
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Player : MonoBehaviour
{
public int health = 100;
public int maxHealth = 100;

public Slider healthSlider; // 拖引用
public Text healthLabel; // 拖引用

public void TakeDamage(int dmg)
{
health -= dmg;
RefreshUI(); // 每次改数据都得记得手动刷新
}

void RefreshUI()
{
healthSlider.value = (float)health / maxHealth;
healthLabel.text = $"{health}/{maxHealth}";
}
}
  • 数据和 UI 强耦合:Player 逻辑类里塞了 Slider/Text 引用。
  • 容易漏刷新:任何地方改了 health 都得记得调 RefreshUI(),漏一处就显示错。
  • UI 引用靠拖拽:换了 UI 结构容易丢引用、报 NullReference。

UI Toolkit 的写法(声明式,自动同步)

数据源(纯数据,不知道 UI 的存在)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class PlayerViewModel : INotifyBindablePropertyChanged
{
int _health = 100;

[CreateProperty]
public int Health
{
get => _health;
set
{
if (_health == value) return;
_health = value;
Notify(); // 只管喊一声"我变了"
}
}

[CreateProperty]
public float HealthRatio => _health / 100f; // 派生属性也能绑

public event EventHandler<BindablePropertyChangedEventArgs> propertyChanged;
private void Notify([CallerMemberName] string name = "") {
propertyChanged?.Invoke(this, new BindablePropertyChangedEventArgs(name));
}
}
绑定(一次性建立,之后自动同步)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var vm = new PlayerViewModel();
root.dataSource = vm;

var bar = root.Q<ProgressBar>("healthBar");
bar.SetBinding("value", new DataBinding
{
dataSourcePath = new PropertyPath(nameof(PlayerViewModel.HealthRatio)),
bindingMode = BindingMode.ToTarget
});

var label = root.Q<Label>("healthLabel");
label.SetBinding("text", new DataBinding
{
dataSourcePath = new PropertyPath(nameof(PlayerViewModel.Health)),
bindingMode = BindingMode.ToTarget
});

之后业务代码只写 vm.Health -= 10;,血条和文字自动更新。逻辑层再也不碰任何 UI 引用。
这些绑定也可以直接写在 UXML 里,做到 UI 结构和绑定关系纯声明式、和 C# 逻辑完全分离。

UI Toolkit的数据绑定优势

  1. 解耦:数据源(ViewModel)完全不知道 UI 存在,可单独测试、复用。
  2. 声明式 + 少样板:绑定关系集中声明(甚至写在 UXML),不用到处写刷新代码,也不会漏刷新。
  3. 双向绑定:BindingMode.TwoWay,输入框改了值自动回写数据源,UGUI InputField得手动写 onValueChanged 回调。
  4. 无反射、低 GC:底层用 Unity.Properties([CreateProperty]),不走反射,运行时开销小。
  5. 派生属性/转换器:可以绑派生属性或加 converter 做格式转换(如 100 → “100/100”),不用在数据里存冗余字段。

UI Toolkit 真正的工作流优势在于三层各自独立迭代:

  • USS (Styles) → 美术/UI 改,热重载,不碰 C#
  • UXML (Structure) → 布局/绑定调整,多数不碰 C#
  • C# (ViewModel) → 程序改数据源,需重编译

而 UGUI 里,样式(组件字段)、结构(Prefab 层级)、刷新逻辑(C# 里的 RefreshUI)纠缠在一起,想修改外观往往还是要回到 C# 手动加刷新代码。

UGUI 用 INotifyPropertyChanged数据绑定

INotifyPropertyChanged 本身只是一个”我变了”的通知接口,它不会自动更新任何 UI。
要在 UGUI 上复现 UI Toolkit 的体验,你需要自己写一个绑定层:

手动写这样的桥接组件,每个属性一个
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class TextBinder : MonoBehaviour
{
public Text target;
INotifyPropertyChanged _source;
string _propName;

public void Bind(INotifyPropertyChanged src, string propName)
{
_source = src;
_propName = propName;
src.PropertyChanged += OnChanged;
UpdateNow();
}

void OnChanged(object s, PropertyChangedEventArgs e)
{
if (e.PropertyName == _propName) UpdateNow();
}

void UpdateNow()
{
// 还得用反射或委托去取值
var val = _source.GetType().GetProperty(_propName).GetValue(_source);
target.text = val.ToString();
}

void OnDestroy() { if (_source != null) _source.PropertyChanged -= OnChanged; }
}

也就是说,用 INotifyPropertyChanged 你需要自己解决:

  • 取值/赋值:要么反射(有 GC 和性能开销),要么每个属性写委托;UI Toolkit 用 Unity.Properties 免了这层。
  • 绑定注册/注销:手动管理事件订阅,注意 OnDestroy 里退订,否则内存泄漏。
  • 双向绑定:还得反向监听 UI 的 onValueChanged 再写回,自己防循环更新。
  • 每种控件/属性都要写对应 binder。

k-NN(k nearest neighbors)问题
我在一个场景中,有100k个小怪,我需要得到1k个与我最近的单位,如何快速查找?


四叉树

四叉树适用于二维场景,将空间递归划分成四个象限,按区域把单位放进去。

树基本结构

  • 根节点:覆盖整个场景范围
  • 每个节点最多存放一定数量的对象(例如 8~16 个)
  • 超过阈值时分裂成 4 个子节点
  • 对象根据坐标放入对应的子节点

查找最近 1k 个单位的步骤

  1. 从根节点开始,把查询点所在的区域优先搜索
  2. 对当前节点里的对象计算距离,维护一个最大堆或固定大小的候选列表
  3. 对子节点递归搜索,但只进入那些有可能包含更近对象的区域
  4. 对每个子节点,先检查该节点包围盒到查询点的最小距离
    • 如果该最小距离已经比当前第 1k 近的距离大,就可以剪枝跳过该子节点
  5. 继续搜索直到所有可能更近的区域都处理完

网格 / 空间哈希(Spatial Hash Grid)

  • 把场景分成固定大小的格子,每个格子维护一个对象列表。查询时只检查当前格子和相邻格子,速度快,非常适合均匀分布。
  • 把格子坐标哈希成桶,适合大范围、动态移动对象。插入、删除、查询都是常数级别,维护简单。也可以看成“网格划分+哈希映射”。
  • 查询时只访问与自身相邻的若干格子
  • 适合均匀分布、实时更新频繁的场景

k-d 树

  • 构建过程的时间复杂度是 O(N log N)。对于100k个小怪,这是一次性的、完全可以接受的开销。
    • 构建完后,这棵树在这一帧内是静态的。
    • 适合静态点集或少量动态更新
  • 递归按 x/y 轴交替划分空间

查找最近 1k 个单位的步骤

最近邻查询类似二叉树剪枝

  • 从根节点开始,比较你的位置和当前节点在分割轴上的坐标。
  • 如果你在节点的左边,就优先进入左子树;如果在右边,就优先进入右子树。像普通二叉树查找一样,一路向下,直到到达一个叶子节点。
  • 当你从递归中返回,开始回溯到父节点时,执行最关键的“剪枝”判断。
  • 核心问题: “另一半子树所代表的空间区域,有没有可能存在比我当前找到的第1000个邻居更近的点?”
  • 判断方法: 计算你的位置到当前节点“分割面”的垂直距离 d_plane。
    • 如果 d_plane ≥ radius: 这意味着另一半子树的整个空间,都比你当前最远的邻居还要远。因此,你完全不需要进入那个子树进行搜索! 这就是“剪枝”。
    • 如果 d_plane < radius: 意味着另一半子树的空间与你的“搜索球”有交集,里面可能存在更近的点。因此,你必须进入这个子树,重复上面的递归搜索过程。

BVH (Bounding Volume Hierarchy) / 包围盒层级

  • 每个节点是一个包围一组物体的盒子(AABB/OBB等)
  • 子节点是把物体集合拆成两组(常见二叉),再分别求包围盒。
  • 划分不要求等分空间,通常按数据分布和代价(如 SAH)自适应构建。

查找最近 1k 个单位的步骤

  • 初始化: 将所有100k个小怪(或它们的精确位置)看作“物体”,放入一个列表中。
  • 递归构建:
    • 终止条件: 如果当前列表中的物体数量小于等于某个阈值(比如8),则创建一个叶子节点。这个叶子节点的AABB是紧紧包裹这几个物体的包围盒,节点内部存储这些物体的引用。然后返回。
    • 选择最长的轴: 计算当前列表中所有物体的AABB,找到这个AABB最长的轴(X, Y, 或 Z)。
    • 排序与划分: 沿着这个最长的轴,根据物体重心的坐标,对列表中的所有物体进行排序。找到中位数,将列表一分为二。
    • 创建子节点:
      • 对第一半的物体,递归调用构建过程,创建一个左子节点。
      • 对第二半的物体,递归调用构建过程,创建一个右子节点。
    • 创建父节点: 创建一个新的中间节点,它的AABB是其左、右两个子节点AABB的并集(即一个能同时包裹住左右两个子包围盒的新包围盒)。这个中间节点的子节点就是刚刚创建的左右子节点。

这个过程从包含所有物体的根节点开始,一直递归下去,直到所有物体都被分配到叶子节点中。构建完成后,你就有了一个完整的BVH树。

  • K-近邻查询 (每帧执行的核心算法)
    一个容量为 K (这里是1000) 的最大优先队列 (Max-Priority Queue),我们称之为“结果集 (resultSet)”。
  • 作用: 用来存储到目前为止找到的 K 个最近邻居。
  • 特性: 队列的“顶部” i.e. queue.peek() 永远是这 K 个中距离你最远的那个。当你尝试加入一个新邻居时,如果它比最远的那个更近,最远的就会被自动“踢”出去。

一个最小优先队列 (Min-Priority Queue),我们称之为“待办列表 (todoList)”。

  • 作用: 用来存储接下来需要检查的 BVH 节点。
  • 特性: 队列的“顶部”永远是距离你最近的那个节点/AABB盒。这保证了我们总是优先探索最有希望找到近邻的区域。

具体算法

假设你的位置是 myPosition,要查找 K=1000 个最近邻。

  • 初始化:
    创建一个空的、容量为1000的 resultSet (最大优先队列)。
    创建一个空的 todoList (最小优先队列)。
    将 BVH 的根节点以及你到根节点AABB的最近距离 d_box (初始时这个距离是0,因为你在场景内)作为一个元组 (rootNode, d_box) 加入 todoList。
    将“最大搜索半径 radius” 初始化为无穷大。
    开始循环搜索: 当 todoList 不为空时,持续循环:

a. 取出最优节点: 从 todoList 中取出距离最近的节点 (currentNode, dist_box)。

b. 核心剪枝:
从 resultSet 中获取当前已找到的最远邻居的距离(如果 resultSet 已满),这个距离就是我们当前的最大搜索半径 radius。
如果 dist_box ≥ radius: 这意味着 todoList 中所有剩下的节点(因为它们比 currentNode 更远)以及它们包含的所有物体,都不可能比我们已经找到的第K个邻居更近。搜索可以提前结束! 跳出循环。

c. 处理节点:
如果 currentNode 是一个叶子节点:
遍历该叶子节点包含的所有实际物体。
对于每个物体,计算你到它的精确距离 d_obj。
尝试更新结果集: 将这个物体和它的距离 d_obj 加入 resultSet。如果 resultSet 已满且 d_obj 更近,优先队列会自动T出最远的那个。
重要: 每当 resultSet 被更新后(特别是当它首次满员时),需要更新 radius 为 resultSet 中新的最大距离。这个 radius 会不断缩小,从而让步骤 b 的剪枝变得越来越强大。

如果 currentNode 是一个中间节点:
获取它的左、右两个子节点。
对于每一个子节点:
计算你到这个子节点AABB的最近距离 d_child_box。
如果 d_child_box < radius: 这意味着这个子节点的区域内可能存在更近的邻居,我们必须探索它。将这个子节点和它的距离 (childNode, d_child_box) 加入 todoList。
如果 d_child_box ≥ radius,则直接忽略这个子节点及其整个分支(又一次剪枝!)。

完成: 当循环结束时,resultSet 中存储的就是最终的1000个最近邻居。


倒排桶 / 桶排序距离

  • 先用距离近似值分桶,再只对近距离桶内对象精确排序
  • 适合距离范围已知、精度需求不高的场景

粗略筛选 + 精确排序

  • 先用简单空间划分得到一小部分候选对象
  • 然后在候选集中排序取前 1k
  • 例如:先找 5x5 个邻近网格,再精确计算

概念 是什么 例子
Shader(着色器) 定义顶点如何处理、颜色如何计算,以及深度、混合等渲染规则 URP/Lit
Material(材质) 引用一个 Shader,并保存贴图、颜色、数值和关键字配置 使用 URP/Lit 的红色金属材质
Shader Variant(Shader 变体) 同一个 Shader 按不同关键字组合编译出的版本 开启法线贴图版本、关闭法线贴图版本

Batching

静态合批: 相同材质并且始终不动的的Mesh合并成为一个大Mesh,然后由CPU合并为一个批次发送给GPU处理,从而减少DrawCall。
动态合批: 相同材质并且会动的物体Mesh,CPU 每帧将符合条件的小网格顶点转换到世界空间,再合并提交
SRP Batcher: 同样的Shader变体,开启一个GPU常量缓冲区,一组可以沿着高效路径连续提交的绘制,但不会将多个网格合并成单个批次进行渲染,不减少DrawCall但能提高性能
GPU Instancing: 同样的Mesh(但允许不同的位置、颜色信息)同样的Shader变体,使用一个DrawCall渲染多个共享同一材质(但可以有实例属性)的Mesh,比如不同颜色可以作为实例属性提供,不需要单独创建材质

Static Batching

针对静态物体(勾选 Static)。在构建时,Unity 把所有使用相同材质的静态 mesh 合并成一个大 mesh,存到内存里。运行时一次 Draw Call 画完。

特性 描述
核心思想 在编辑器时,将标记为 Static 且使用相同材质的游戏对象的网格(Mesh)合并成一个或多个巨大的网格。
工作原理 1. 你在编辑器中将多个物体勾选为 Static。
2. 在构建或进入播放模式时,Unity 找到使用相同材质的静态物体,将它们的顶点和索引数据合并成一个新的、巨大的网格。
3. 运行时,渲染这个大网格只需要一次 Draw Call。
主要优点 ✅ 大幅减少 Draw Call,性能提升非常显著。
主要缺点 ❌ 内存和显存开销大:合并后的网格需要额外存储,且无法进行视锥体剔除(Culling)的细粒度操作(整个大网格要么全被剔除,要么全不被剔除)。
❌ 不能用于非静态物体:物体不能移动、旋转、缩放。
适用场景 场景中静止不动的所有物体,如建筑、地面、大块的岩石、静态的装饰物等。

场景里100棵树(同材质,Static):
构建时合并 → 1个大mesh
运行时:SetPass × 1,Draw Call × 1

代价是内存,合并后的大 mesh 常驻内存,物体也不能移动。

Dynamic Batching

与静态合批不同,动态合批是的物体是可以运动的,针对动态小物体。每帧 CPU 实时把符合条件的 mesh 合并,然后一次提交。
条件很苛刻:顶点数 < 300,不能使用lightmap,不能使用多通道的shader,不能是缩放比不同的物体。
Unity 的 Dynamic Batching(动态批处理)是自动触发的。只要启用了该功能且物体满足特定条件(如使用相同材质、顶点数较少),Unity 引擎就会在运行时自动合并 Draw Call,无需手动干预。

特性 描述
核心思想 在每一帧运行时,由 CPU 自动寻找符合条件的小网格,并将它们合并成一个临时的网格进行绘制。
工作原理 1. CPU 寻找使用相同材质且顶点数极少(通常<300)的移动物体。
2. CPU 将这些物体的顶点从局部空间变换到世界空间。
3. CPU 将变换后的顶点数据拷贝到一个大的临时缓冲区中。
4. 用一次 Draw Call 将这个临时缓冲区提交给 GPU。
主要优点 ✅ 对移动的小物体能减少一些 Draw Call。
主要缺点 ❌ 巨大的 CPU 开销:每一帧都在CPU上做顶点变换和拷贝,往往得不偿失。
❌ 限制非常严格:对顶点数、顶点属性等有诸多要求。
❌ 在 SRP (URP/HDRP) 中已废弃/不推荐:因为它与更高效的 SRP Batcher 工作机制冲突。
适用场景 在旧的内置渲染管线(Built-in RP)中,用于处理大量简单粒子、子弹等小物体。在现代项目中几乎不再使用。

每帧:
CPU 检查哪些动态物体同材质 → 临时合并 mesh → 一次 Draw Call

代价是 CPU 每帧都要做合并,物体多了反而更慢。

SRP Batcher

SRP Batcher 将各个材质的 UnityPerMaterial 数据持久保存在 GPU 内存中。材质数据没有变化时,就不必在每次绘制时重新上传。

创建大型 GPU 缓冲区: 在每一帧开始时,SRP Batcher 会在 GPU 上开辟一个巨大的、持久化的缓冲区 (在现代图形API中通常是 Constant Buffer 或 UBO)。
打包所有数据: 它会遍历所有使用相同 Shader Variant(着色器变体)的物体,把它们的材质属性(颜色、向量、浮点数等)全部打包塞进这个大缓冲区里。

现在,当 CPU 指示 GPU 绘制时,CPU 对 GPU 说:“准备画一批物体,它们都用 Shader A。它们的材质数据已经全部放在那个大缓冲区里了。” (这对应一个非常快速的、几乎可以忽略不计的 SetPass 准备工作)
然后 CPU 快速地连续下达指令:
“画第一个物体,它的数据在缓冲区偏移量0的位置。” (一个 Draw call)
“画第二个物体,它的数据在缓冲区偏移量64的位置。” (一个 Draw call)
“画第三个物体,它的数据在缓冲区偏移量128的位置。” (一个 Draw call)

SetPass call: 只有在切换 Shader Variant 时才会发生一次。对于所有使用相同 Shader Variant 的物体,无论它们有多少个,都属于同一个“SRP Batch”,共享一次 SetPass 设置。因此 SetPass call 的数量大幅降低。
Draw call: 每个物体最终还是需要 GPU 单独绘制一次。CPU 依然需要为每个物体都发送一个 Draw 指令。因此 Draw call 的数量没有改变。

特性 描述
核心思想 大幅降低因材质不同而产生的 SetPass Call 开销,但不减少 Draw Call。
工作原理 1. 找到所有使用相同 Shader Variant(着色器变体)的物体。
2. 将这些物体所有不同的材质属性(颜色、浮点数等)打包到一个巨大的、持久化的 GPU 缓冲区 (Constant Buffer / Uniform Buffer Object) 中。
3. 每次绘制时,CPU 不再上传完整的材质数据,而是只告诉 GPU 去这个大缓冲区的哪个偏移量位置读取数据。
4. 这使得切换材质的成本变得极低。
主要优点 ✅ 极大提升 CPU 性能,因为它解决了最耗时的 SetPass 瓶颈。
✅ 兼容性好,限制较少,适用于大量不同材质但相同Shader的物体。
主要缺点 ❌ 不减少 Draw Call。
❌ 需要 Shader 支持,所有材质属性必须定义在 CBUFFER 块中。URP 和 HDRP 的标准 Shader 已经默认支持。
适用场景 URP/HDRP 项目中的首选批处理方案。适用于场景中几乎所有使用标准 Shader 的物体,极大地提升了异质化物体的渲染性能。

使用条件:

  • 不支持内置渲染管线,支持通用(URP)/高清(HDRP)/自定义(SRP)渲染管线
  • 不使用 MaterialPropertyBlocks
  • Materials must use SRP-compatible shaders

在标准的渲染流程下,CPU需要收集所有场景物体的参数,场景中的材质越多CPU提交给GPU的数据就越多。
而在SRP中流程下GPU拥有数据管理的 “生命权” ,管理大量不同材质但Shader变动较小的的内容。
让数据在GPU中持久存在,从而减少消耗(SetPass Call)。

UnityPerDraw 和 UnityPerMaterial

UnityPerDraw 和 UnityPerMaterial 都是 Shader 使用的常量缓冲区(CBUFFER),区别在于数据更新频率和来源。

缓冲区 数据来源 常见内容
UnityPerDraw 当前绘制的 Renderer/物体 模型矩阵、光照贴图索引、探针数据、物体变换
UnityPerMaterial 当前材质参数

关系可以理解为:

Renderer
├─ Mesh / Transform ──────> UnityPerDraw
└─ Material
└─ Shader Properties ──> UnityPerMaterial

例如两个 Renderer 共用一个 Material:

Renderer A:自己的 UnityPerDraw ─┐
├─ 共用 UnityPerMaterial
Renderer B:自己的 UnityPerDraw ─┘

因此它们颜色、粗糙度等材质属性相同,但模型矩阵不同。

Renderer 是产生绘制任务的组件。Unity准备 Draw Call 时:

  1. 从 Renderer、Transform、Lightmap 等获得每物体数据。
  2. 写入或绑定 UnityPerDraw。
  3. 从 Renderer 使用的 Material 获得材质参数。
  4. 绑定 UnityPerMaterial。
  5. 使用 Shader 绘制 Mesh。

不过“PerDraw”不完全等于“PerRenderer”。一个 Renderer 可能因为多个 SubMesh、Pass 或阴影阶段产生多个 Draw Call。

兼容 SRP Batcher 的 Shader 通常要求:

  • Unity内置的每物体属性集中在一个 UnityPerDraw CBUFFER。
  • 所有材质属性集中在一个 UnityPerMaterial CBUFFER。
  • 不要把材质属性零散放进其他 CBUFFER。

URP 自定义 Shader 常见写法:

1
2
3
4
5
CBUFFER_START(UnityPerMaterial)
float4 _BaseColor;
float4 _BaseMap_ST;
float _Smoothness;
CBUFFER_END

UnityPerDraw 通常已经由 URP/HDRP 的公共 include 文件声明,不应自己重复声明。

SRP Batcher 会缓存 UnityPerMaterial 数据,在连续绘制使用同一 Shader Variant 的 Renderer 时,主要切换 UnityPerDraw。这样减少 CPU 向 GPU 设置状态和上传参数的成本。

需要注意:

  • SRP Batcher通常不会合并 Draw Call,只是降低每个 Draw Call 的 CPU 成本。
  • GPU Instancing 才侧重于把多个相似 Renderer 合并为实例化绘制。
  • MaterialPropertyBlock 可以给不同 Renderer 设置不同属性,但通常会使该 Renderer 无法走标准 SRP Batcher 路径。
  • 多个 Renderer 共用 Material,更有利于缓存和批处理。
  • renderer.material 会创建材质实例;renderer.sharedMaterial 使用共享材质。

一句话总结:Renderer 决定“画什么以及在哪里画”,UnityPerDraw 保存这次绘制的物体数据,UnityPerMaterial 保存材质数据,而 SRP Batcher 利用这种数据分组来降低渲染提交成本。

SRP Batcher vs. Dynamic Batching

Both can be used together in SRP, but SRP Batcher takes precedence when enabled. For optimal performance in modern Unity projects, prefer SRP Batcher with proper shader setup.

Both Dynamic Batching and SRP Batcher are triggered automatically in Unity when their conditions are met:

  • Unity automatically detects and performs Dynamic Batching on compatible objects at runtime
  • SRP batcher automatically batches during render loop when objects use compatible shader variants enabled in the render pipeline settings (Window → Rendering → Render Pipeline → Universal Render Pipeline → General → SRP Batcher)

GPU Instancing

针对大量相同 mesh + 相同材质的物体(位置、旋转、缩放可以不同;颜色等属性也可以不同)。CPU 只提交一次 mesh 数据,附带一个”实例属性数组”,GPU 自己循环绘制 N 次。
在 Unity 中,需要在材质属性中勾选 “Enable GPU Instancing”。

1
2
3
4
// shader 里声明每个实例可以有不同颜色
UNITY_INSTANCING_BUFFER_START(Props)
UNITY_DEFINE_INSTANCED_PROP(float4, _Color)
UNITY_INSTANCING_BUFFER_END(Props)
特性 描述
核心思想 让 GPU 自己去复制绘制。一次 Draw Call 告诉 GPU 将完全相同的网格和材质绘制 N 次。
工作原理 1. CPU 准备一个包含 N 个实例所有不同属性(如位置、旋转、颜色)的额外数据缓冲区。
2. CPU 发起一次 Draw Call,并把网格、材质和这个属性缓冲区一并交给 GPU。
3. GPU 收到指令后,自己循环 N 次,每次绘制时从属性缓冲区中取出一个实例的数据应用,完成绘制。
主要优点 ✅ 极大幅度地减少 Draw Call,能以极高性能绘制成千上万个相同的物体。
✅ 每个实例可以有自己独特的位置、旋转、缩放和颜色(通过 MaterialPropertyBlock)。
主要缺点 ❌ 要求网格和材质完全相同。
适用场景 场景中需要大量重复出现的物体,如草、树木、石子、人群、军队、模块化建筑的窗户等。非常适合程序化生成的内容。

使用条件:

  • 材质需要支持GPU Instancing,例如默认标准材质就有
  • Tranform信息需要有所不同(完全重合了渲染出来也没有意义)
  • 未使用SRP Batcher,如有会优先使用SRP Batcher。(在URP渲染管线中是默认开启的)
  • 粒子对像不能合批

此时的CPU依然只提交1次Drawcall

100棵树(同mesh,同材质,不同位置/颜色):
CPU:提交1次mesh + 100个transform数组
GPU:自己 instance 100次
结果:SetPass × 1,Draw Call × 1