UWA问答 | 博客 | 游戏及VR应用性能优化记录分享 | 侑虎科技 - 厚积薄发
UWA旨在诊断与优化游戏和VR项目开发过程中潜在的运行性能问题，帮助项研发团队快速检测项目运行时的性能瓶颈，高效定位问题根源，及时反馈针对瓶颈问题的深度分析和解决方法。
为何iPad Pro上设置目标帧率为90时无法生效
1）为何iPad Pro上设置目标帧率为90时无法生效 2）为什么在Dedicated Server发布的程序中内存数值异常 这是第452篇UWA技术知识分享的推送，精选了UWA社区的热门话题，涵盖了UWA问答、社区帖子等技术知识点，助力大家更全面地掌握和学习。 UWA社区主页：community.uwa4d.com UWA QQ群：793972859 From 问答社区 Q1：为什么iPad Pro上设置目标帧率为90时无法生效，仍然以120帧运行？ 在使用Application.targetFrameRate设置目标帧率为90时，在iPad Pro M2芯片上无法生效，目标帧率变为120，在iPhone 14 Pro上测试则能够以80的帧率运行。 在和平精英上用PerfDog采集，可以设置为90的选项，实际以80的帧率在跑，但在Demo中测试无法达到同样的效果。 A：使用UWA AI问答的功能尝试解读这个问题。 分享链接： https://www.uwa4d.com/main/uwa-shared.html?shareId=f3ea7cc1-879f-4f94-aeb5-b29860099ae2 该问题通常由垂直同步（VSync）与帧率控制机制的交互导致，尤其是在高刷新率设备如iPad Pro上。 核心原因分析 在Unity引擎中，Application.targetFrameRate是控制移动平台帧率的主要方式。然而，当垂直同步（VSync）开启时，它会优先于targetFrameRate生效。系统会强制将应用的帧率锁定到屏幕刷新率的整数分之一（如120Hz、60Hz、40Hz等），以避免画面撕裂。因此，即使设置了targetFrameRate=90，系统也无法稳定输出非刷新率整除的帧率（90不是120的整除因子），最终会退回到设备支持的最高帧率（120fps）或下一个可行的分频值。 此外，部分iOS设备（尤其是支持ProMotion技术的iPad Pro）具有动态刷新率调节能力，其底层图形驱动可能进一步影响帧率锁定行为，使得targetFrameRate的控制不够精确。 解决方案 1. 关闭VSync并依赖 targetFrameRate 确保在项目设置或运行时关闭垂直同步： QualitySettings.vSyncCount = 0; // 关闭VSync Application.targetFrameRate = 90; // 设置目标帧率为90 这是实现非标准帧率（如90fps）最直接的方法。关闭VSync后，targetFrameRate将正常生效。 2. 使用引擎级帧平滑（Frame Pacing） 在使用虚幻引擎时，可通过CVar精确控制帧率。对于目标帧率90，在配置文件中设置： r.setframepace 90 此方法直接对接设备刷新周期，能更可靠地在支持的设备上实现90fps。 3. 验证设备与系统支持 确认当前iPad Pro型号及iOS系统版本是否完整支持90Hz的输出模式。某些情况下，操作系统或驱动可能限制了非标准刷新率的使用。 4. 监控实际帧率 使用UWA GOT Online报告中的“FPS均值”和“>40ms帧数占比”指标，结合真机性能测试，验证帧率是否真正稳定在90fps，并评估GPU负载与功耗表现。 综上，欲在iPad Pro上稳定运行90fps，关键在于关闭VSync并通过Application.targetFrameRate明确指定目标帧率，同时确保引擎和设备层面的支持。 Q2：如果已经关闭垂直同步，还可能是什么原因？ A：即使已关闭垂直同步（VSync），在iPad Pro上设置Application.targetFrameRate = 90仍可能无法生效，原因如下： 1. 动态刷新率机制（ProMotion）的底层限制 iPad Pro搭载的ProMotion技术允许屏幕刷新率在120Hz、60Hz、40Hz、30Hz等特定档位间动态切换。系统倾向于选择最接近目标帧率的标准分频值。由于90不是120的整除因子，系统可能无法为90fps创建稳定的刷新周期，最终退回到默认的120Hz模式。 2. Unity引擎的帧时间计算与平台适配问题 根据Unity文档，移动平台上的targetFrameRate实际效果受限于屏幕刷新率的整数分频。即使VSync关闭，Unity的帧调度器仍可能因无法精确匹配90Hz的间隔而出现抖动或回落至120Hz。此外，若未将targetFrameRate设置为屏幕刷新率的约数（如120 → 60, 40, 30），系统可能忽略该设置。 3. 后台进程或系统策略干预 iOS系统可能会因性能管理、温控或后台任务（如应用更新、索引服务）临时提升设备性能至最大刷新率，导致短暂脱离目标帧率。 解决方案 方法一：使用有效的刷新率分频值 避免设置非标准帧率，改用系统支持的分频： 若需高帧率，直接设为120 若目标为平衡性能与功耗，建议设为60或40 // 推荐做法 QualitySettings.vSyncCount = 0; Application.targetFrameRate = 60; // 稳定且兼容性最佳 方法二：通过Metal API或原生插件控制（高级） 利用iOS Metal框架的CADisplayLink精确控制渲染周期，绕过Unity默认调度，实现对90Hz的支持。此方法需编写原生代码，并确保设备支持。 方法三：验证并优化项目配置 检查项目是否启用了“Adaptive Frame Rate”等动态调节功能。 在UWA GOT Online报告中查看“FPS均值”和“>40ms帧数占比”，确认是持续120fps还是帧率波动。 使用UWA PA真机测试，排除模拟器或开发环境干扰。 综上，根本原因在于iOS硬件刷新率策略与Unity帧控制机制的不匹配。最稳妥方案是采用系统原生支持的帧率档位（如60fps），以确保稳定性与兼容性。 A2：iOS设备只能设置30的整数倍。 感谢123-619849@UWA问答社区提供了回答，欢迎大家转至社区交流： https://answer.uwa4d.com/question/6909d30d682c7e5cd61bfb64 From 问答社区 Q：在Dedicated Server发布的程序中，为什么在同一时刻Profiler.GetTotalReservedMemoryLong接口获得的数值会比Profiler.GetMonoHeapSizeLong接口获得的数值还小？ GetTotalReservedMemoryLong不是代表整个程序当前申请的内存大小吗？ 如上图： curValue - maxValue totalMemory:Profiler.GetTotalAllocatedMemoryLong() reservedMemory:Profiler.GetTotalReservedMemoryLong() monoHeap:Profiler.GetMonoHeapSizeLong() monoUse:Profiler.GetMonoUsedSizeLong() 引擎版本是2021.3.25f1c1。 A：使用UWA AI问答的功能尝试解读这个问题。 分享链接： https://www.uwa4d.com/main/uwa-shared.html?shareId=095aef95-daab-422c-ab95-acf6434f7892 在Dedicated Server（专用服务器）构建的程序中，观察到Profiler.GetTotalReservedMemoryLong()的值小于Profiler.GetMonoHeapSizeLong()，这看似矛盾，实则源于Unity内存管理机制和Profiler接口统计范围的本质区别。 核心原因：GetTotalReservedMemoryLong在非开发版中不可靠 最关键的一点是：Profiler.GetTotalReservedMemoryLong()等多数Profiler API在非Development Build（即Release或Server Build）中返回的是不完整或不可靠的数据。虽然文档指出部分内存API在Release版可用，但GetTotalReservedMemoryLong的行为在Dedicated Server环境下可能受限或未被正确维护。 相反，GetMonoHeapSizeLong和GetMonoUsedSizeLong是少数明确说明“即使在Release构建中也可用”的API，其数据相对可靠。 因此，你所观测到的reservedMemory (238MB)小于monoHeap (255-284MB)很可能是因为GetTotalReservedMemoryLong()返回的并非真实的总预留内存，而是一个被截断、未更新或仅包含部分内存池的值。 正确理解各指标含义： 为何monoHeap可能大于reservedMemory？ reservedMemory数据失真：如前所述，在Dedicated Server下，GetTotalReservedMemoryLong可能没有跟踪所有内存域（如图形、音频等子系统），或者其计数器未被激活，导致返回值偏低。 monoHeap是独立增长的：托管堆会根据内存压力动态扩容。当托管内存需求增加时，GC会请求更大的堆空间，GetMonoHeapSizeLong()随之增大。如果此时GetTotalReservedMemoryLong()因为Profiler限制未能同步更新，则会出现monoHeap > reservedMemory的现象。 专用服务器的优化配置：Dedicated Server通常禁用图形驱动、音频等模块，这些模块的内存池可能未被计入GetTotalReservedMemoryLong，但托管堆仍可正常增长。 建议的排查与监控方案 1. 避免依赖GetTotalReservedMemoryLong进行关键判断 在Release或Server Build中，该值不可信。 2. 使用更可靠的替代方案监控总内存 若目标平台支持，通过操作系统API（如Linux的/proc/self/status）读取RSS或PSS。 使用SystemInfo.systemMemorySize结合应用自身逻辑估算。 3. 重点关注托管内存趋势 定期调用GC.Collect()后获取GetMonoUsedSizeLong()，监控是否存在内存泄漏。 观察GetMonoHeapSizeLong()是否持续增长，判断是否需要手动触发GC或优化对象生命周期。 4. 在Development Build中验证 若条件允许，在开启Development Build的服务器上运行测试，对比各项内存指标是否符合预期。 综上，该现象主要由GetTotalReservedMemoryLong在非开发版中的局限性所致，应以GetMonoHeapSizeLong和系统级内存监控作为主要依据。 有经验的朋友欢迎转至社区交流： https://answer.uwa4d.com/question/68afb897244ce21ce9ec090d 无论是社区里开发者们的互助讨论，还是AI基于知识沉淀的快速反馈，核心都是为了让每一个技术难题都有解、每一次踩坑都有回响。本期分享分别来自UWA AI问答和UWA问答社区，希望这些从真实开发场景中提炼的经验，能直接帮你解决当下的技术卡点，也让你在遇到同类问题时，能更高效地找到破局方向。 封面图来源于网络 今天的分享就到这里。生有涯而知无涯，在漫漫的开发周期中，我们遇到的问题只是冰山一角，UWA社区愿伴你同行，一起探索分享。欢迎更多的开发者加入UWA社区。 UWA官网：www.uwa4d.com UWA社区：community.uwa4d.com UWA学堂：edu.uwa4d.com 官方技术QQ群：793972859
游戏AI行为决策——MLP（多层感知机/人工神经网络）
【USparkle专栏】如果你深怀绝技，爱“搞点研究”，乐于分享也博采众长，我们期待你的加入，让智慧的火花碰撞交织，让知识的传递生生不息！ 你一定听说过神经网络的大名，你有想过将它用于游戏AI的行为决策上吗？其实在（2010年发布的）《最高指挥官2》中就有应用了，今天请允许我班门弄斧一番，与大家一同用C# 实现最经典的神经网络 —— 多层感知机（Multilayer Perceptron，简称MLP）。 一、前言 神经网络或者深度学习，总给人一种「量子力学」的感觉，总感觉它神秘无比，又无所不能。我未学习神经网络之前，总以为它是某种能够修改自身代码的代码，否则怎么能做到从「不会」变成「会」的呢？但在亲自学习后才会明白，它并没有做到这种地步，但依旧十分神奇。多层感知机是最基础的神经网络，很多其它类别的神经网络都是在这之上的变形。可以说，学会它是迈入深度学习的第一步。 多层感知机虽说经典，但并不过时。提到神经网络，大多数人脑海里想到的大概也就是类似这样的图片： 这就是一张典型的多层感知机结构图，看着好像很复杂，但实现它所需要用到的数学原理和编程知识都不难，早年间，研究神经网络的学者们还用C语言实现呢！ 二、什么是多层感知机 现在进入正题，我们先来简单讲讲MLP的原理（如果你对此十分熟悉，只是对代码实现感兴趣，那可以跳过这部分）。 既然叫「多层感知机」，那有单个的感知机吗？那是自然，单个感知机的结构十分简单： 它其实就是个算式（为方便理解，我将其分成两部分）： 将传入感知机的多个输入x，与对应的权重w相乘（输入的数量与权重的数量是一样的，且数量是任意的，本例中用了3个），再加上偏置b就可以得出一个计算值sum。再将这个计算值传入一个函数f(x)就可得到感知机的最终输出out。 相信你肯定能理解，只是可能对f(x)函数有些好奇，它具体内容是什么呢？这个函数也被称为「激活函数」，为什么叫这个名字？这就得提感知机的另一个名字 —— 人工神经元。其实感知机正是受神经元结构启发而被提出来的： 神经元会通过树突接受输入信号并汇总，而神经元对于各个输入刺激的响应强度并不相同，所以我们给各个输入设置了相应权重来模拟这个现象。之后，将处理的信息通过轴突传给末梢（终端）。但实际上，只有汇总的信号强度大于一定程度时神经元才会向末梢传递信号，而模拟这个现象的就是「激活函数」。 那偏置b又是模拟什么的？其实它是从数学角度考虑的、方便调整输入加权和的变量值而已。 既然感知机被称为「人工神经元」，那多层感知机岂不就是「人工神经网络」？一点没错，我们现在所说的「神经网络」，基本都是指人工神经网络，而不是真正的、生物的神经网络。而「神经网络」起初就是指多层感知机，只不过现在种类多了，定义也变宽泛了。 结合我们对单个感知机的认识，再看看多层感知机： 但这里的单个感知机（后面用「神经元」来代称）怎么输出了多个值（看标蓝色的那部分）？这种结构图可能会误导某些人，我做个解释，这里的每一条线并不是输出，可以看到它是有箭头的，每条线表示它所指向的那个神经元的一个权重。加上箭头是为了表示数据传递的方向。 不难看出，它就是将多个感知机以层为单位进行了组合，每层都有任意数量（每层的数量可以不同）的感知机，并将一层感知机的输出作为下一层的输入，依次套娃下去。像图中，下一层神经元的权重数量 = 上一层的神经元数量，称为全连接，是神经网络中常见的连接方式，本文也只考虑这种连接方式。 这里的「输入层」其实就是输入的数据（是的，这一层不是神经元），类似之前的x0、x1、x2；「输出层」就是用于输出的神经元所组成的层，有了多个感知机，我们也可以得到多个输出；夹在「输入层」与「输出层」之间的就叫「隐藏层」，因为在实际使用神经网络时，就只是输入一组数值作为「输入层」，再看看「输出层」得到的结果，并不关心中间的运算。 我们常说的「深度学习」里的「深度」指的就是神经网络中「隐藏层」的层数（只不过现在这个词有点被炒作了），当一个神经网络的隐藏层超过3层时，它就是「深度神经网络」。 通过改变神经网络的结构或者调节神经网络的权重和偏置，我们可以用神经网络近似任何的函数、甚至是一些摸不着头脑的规律。 比如影响小明今天玩不玩网游的因素有：今日作业量、心情、本月剩余流量、今天是星期几，但我们并不知道这些因素与小明玩不玩的具体数学关系，只能大概地推断：今天小明作业多，不会玩游戏；又或者今天是星期六，虽然作业还有很多，但他还是会玩游戏……可一旦知道具体数学关系，我们就可以通过计算准确预测小明是否会玩游戏，就像我们知道了牛顿力学公式，就可以根据物体的质量和被射出的力来计算它的运动轨迹一样。 所以我们所关心的、实际所使用的都是这种已经设置好正确权重和偏置的神经网络，像在与GPT聊天时怕「污染数据库」这类事就不用操心了。 要如何为神经网络的各个神经元的各个权重设置正确的值，使它能够输出我们预期的结果呢？手动调肯定不现实，所以我们会运用一些数学知识让程序自行调整权重，这个过程就是「训练/学习」。 我们会给出一些输入以及该输入所对应的正确输出，比如我们可以记录小明上个学期玩网游时的各因素值以及不玩时的各因素值，这些作为「训练集」。然后设计一个「损失函数」评判当前神经网络的输出与正确输出之间的差距。而程序就是不断地调节各个权重，使差距越来越小，这种调节的根据是「导数」，但在这里我就不展开了。总之，如果训练得当，神经网络的损失就会越来越小，直到停在一个值附近，这就是「收敛」。 篇幅所限，我刻意没有讲相关的数学原理，如果你对此感兴趣，又或者对MLP的运作仍有困惑，可以看看以下两个视频。如果准备好了，下面就进入代码实现环节吧。 视频1： https://www.bilibili.com/video/BV1bx411M7Zx/?spm_id_from=333.999.0.0&vd_source=c9a1131d04faacd4a397411965ea21f4 视频2： https://www.bilibili.com/video/BV1o64y1i7yw/?spm_id_from=333.788&vd_source=c9a1131d04faacd4a397411965ea21f4 三、代码实现 1. 相关数学 关于数学部分，我只进行简要说明，不讲它们的数学原理，也不过多注释。如果你只是想将神经网络应用到游戏中，那这部分完全可以不必深究原理，弄清它们应用的场合即可。 a. 初始化权重函数 神经网络权重的初始化十分重要，它会影响你的神经网络最后能否训练成功。这里实现了3种典型的初始化方法： 随机初始化（std = 0.01）：是比较普通的方法，深度学习新手接触的第一个初始化方式。 Xavier初始化：适用于激活函数为Sigmoid和Tanh的场合。 He初始化：适用于激活函数为ReLU及其衍生函数，如Leaky ReLU的场合。 这里我还用了枚举，方便在编辑时切换初始化的方法（后续几类数学函数也会用这种方法）： using System; namespaceJufGame.AI.ANN { publicstaticclassInitWFunc { publicenum Type { Random, Xavier, He, None } public static void InitWeights(Type initWFunc, Neuron neuron) { switch(initWFunc) { case Type.Xavier: XavierInitWeights(neuron.Weights); break; case Type.He: HeInitWeights(neuron.Weights); break; case Type.Random: RandomInitWeights(neuron.Weights); break; default: break; } } private static void RandomInitWeights(float[] weightsList) { var rand = new Random(); for (int i = 0; i < weightsList.Length; ++i) { //使用较小的标准差，适合普通的随机初始化 weightsList[i] = (float)(rand.NextGaussian() * 0.01); } } private static void XavierInitWeights(float[] weightsList) { var rand = new Random(); var scale = 1f / MathF.Sqrt(weightsList.Length); for (int i = 0; i < weightsList.Length; ++i) { weightsList[i] = (float)(rand.NextDouble() * 2 * scale - scale); } } private static void HeInitWeights(float[] weightsList) { var rand = new Random(); var stdDev = MathF.Sqrt(2f / weightsList.Length); //计算标准差 for (int i = 0; i < weightsList.Length; ++i) { //生成服从正态分布的随机数，并乘以标准差 weightsList[i] = (float)(rand.NextGaussian() * stdDev); } } // 用于生成服从标准正态分布的随机数的辅助方法 private static double NextGaussian(this Random rand) { double u1 = 1.0 - rand.NextDouble(); // 生成 [0, 1) 之间的随机数 double u2 = 1.0 - rand.NextDouble(); // 使用 Box-Muller 变换生成正态分布的随机数 return Math.Sqrt(-2.0 * Math.Log(u1)) * Math.Sin(2.0 * Math.PI * u2); } } } b. 激活函数 一般神经网络中所有隐藏层都使用同一种激活函数，输出层根据问题需求可能会使用和隐藏层不一样的激活函数。激活函数都有非线性且可导的特点，我也实现了一些典型的激活函数： 直接输出（Identify）：不做处理直接输出，用于输出层。 Sigmoid：早期的主流，现在一般用于输出层需要将输出值限制在0~1的场合，或者是只有两个输出的二分问题。 Tanh：相当于Sigmoid的改造，将输出限制在了-1~1。 ReLU：当今的主流激活函数，长得十分友好，甚至不用加减运算。一般选它准没错。 Leaky ReLU：ReLU的改造，使得对负数输入也有响应，但并没有说它一定好于ReLU。如果你用ReLU训练出现问题，可以换这个试试。 Softmax：把一系列输出转为总和为1的小数，并且维持彼此的大小关系，相当于把输出结果转为了概率。适用于多分类问题，但一定要搭配交叉熵损失函数使用。 using System; namespaceJufGame.AI.ANN { publicstaticclassActivationFunc { private delegate float FuncCalc(float x); privatestatic FuncCalc curAcFunc; publicenum Type { Identify, Softmax, Tanh, Sigmoid, ReLU, LeakyReLU } //按层使用激活函数计算 public static void Calc(Type funcType, Layer layer) { if(funcType == Type.Softmax) { Softmax_Calc(layer); } else { curAcFunc = funcType switch { Type.Sigmoid => Sigmoid_Calc, Type.Tanh => Tanh_Calc, Type.ReLU => ReLU_Calc, Type.LeakyReLU => LeakyReLU_Calc, _ => Identify_Calc, }; for(int i = 0; i < layer.Neurons.Length; ++i) { layer.Output[i] = curAcFunc(layer.Neurons[i].Sum); } } } //根据传入下标index选取层中神经元，并进行求导 public static float Diff(Type funcType, Layer layer, int index) { return funcType switch { Type.Softmax => Softmax_Diff(layer, index), Type.Sigmoid => Sigmoid_Diff(layer, index), Type.Tanh => Tanh_Diff(layer, index), Type.ReLU => ReLU_Diff(layer, index), Type.LeakyReLU => LeakyReLU_Diff(layer, index), _ => Identify_Diff(), }; } #region 直接输出 private static float Identify_Calc(float x) { return x; } private static float Identify_Diff() { return1; } #endregion #region Softmax private static void Softmax_Calc(Layer layer) { var neurons = layer.Neurons; var expSum = 0.0f; for(int i = 0; i < neurons.Length; ++i) { layer.Output[i] = MathF.Exp(neurons[i].Sum); expSum += layer.Output[i]; } for(int i = 0; i < neurons.Length; ++i) { layer.Output[i] /= expSum; } } private static float Softmax_Diff(Layer outLayer, int index) { return outLayer.Output[index] * (1 - outLayer.Output[index]); } #endregion #region Sigmoid private static float Sigmoid_Calc(float x) { return1.0f / (1.0f + MathF.Exp(-x)); } private static float Sigmoid_Diff(Layer outLayer, int index) { return outLayer.Output[index] * (1 - outLayer.Output[index]); } #endregion #region Tanh private static float Tanh_Calc(float x) { var expVal = MathF.Exp(-x); return (1.0f - expVal) / (1.0f + expVal); } private static float Tanh_Diff(Layer outLayer, int index) { return1.0f - MathF.Pow(outLayer.Output[index], 2.0f); } #endregion #region ReLU public static float ReLU_Calc(float x) { return x > 0 ? x : 0; } public static float ReLU_Diff(Layer outLayer, int index) { return outLayer.Neurons[index].Sum > 0 ? 1 : 0; } #endregion #region LeakyReLU private static float LeakyReLU_Calc(float x) { return x > 0 ? x : 0.01f * x; } private static float LeakyReLU_Diff(Layer outLayer, int index) { return outLayer.Neurons[index].Sum > 0 ? 1 : 0.01f; } #endregion } } c. 更新权重函数 权重的更新涉及一些「超参数」，比如学习率、最大迭代次数等。这些参数是程序不会进行更新的，只能人工提前设置好。在神经网络的学习中，学习率的值很重要，过小会导致训练费时；过大则会导致学习发散而不能正确进行。但好在后面人们想出来更好的权重更新函数，它们对「超参数」的依赖会减小很多。我们所实现的有： SGD：随机梯度下降，最简单的一种更新方法，但有时并不是这么高效，容易陷入局部最优解。 Movement：基于物理上的动量概念，它会在更新权重的过程中考虑先前的更新步骤，需要为每个权重设置额外参数（用m表示）来记录「动量」。 AdaGrad：运用了学习率衰减的技巧，为每个权重适当地调整学习率，相当于给每个权重都设置了独立的学习率，也需要额外参数（用v表示）记录。 Adam：将Movment与AdaGrad结合了起来，通过组合二者的优点，有望实现参数空间的高效搜索。 当然，上述4个方法各有优劣，可以优先考虑SGD和Adam。 顺带一提，权重的更新都是建立在「梯度」之上的，「梯度」可以理解为对神经网络整体权重的变化趋势。你想，有这么多权重要更新，有时训练一个样本A后，会要求权重w0+ = 0.01、w1- = 0.05以减小误差，但训练下一个样本B时，又要求w0- = 0.02、w1+ = 0.04，一个训练集有这么多样本，要以哪个样本训练时产生的权重变化为准呢？ 答案是累加每个样本带来的误差并取平均值。如果觉得还不清楚，可以看看这个视频： https://www.bilibili.com/video/BV16x411V7Qg/?spm_id_from=333.999.0.0&vd_source=c9a1131d04faacd4a397411965ea21f4 还有一点，偏置b也是随着权重更新的，它可以视为一个输入始终为1，权重为b的权重。在后续的实现中，我将偏置放在储存权重的列表的最后一位。（但后来才知道不提倡这种写法。） using System; namespaceJufGame.AI.ANN { publicstaticclassUpdateWFunc { privateconstfloat MinDelta = 1e-7f; privateconstfloat beta1 = 0.9f; privateconstfloat beta2 = 0.999f; private delegate void UpdateLayer(Neuron curNeuron, float learningRate, int curEpochs, int samplesCount); publicenum Type { SGD, Momentum, AdaGrad, Adam } public static void UpdateNetWeights(Type type, NeuralNet net, int samplesCount) { UpdateLayer updateLayerFunc = type switch { Type.Momentum => Momentum_UpdateW, Type.AdaGrad => AdaGrad_UpdateW, Type.Adam => Adam_UpdateW, _ => SGD_UpdateW, }; var curLayer = net.OutLayer; for(int j = 0; j < curLayer.Neurons.Length; ++j) { updateLayerFunc(curLayer.Neurons[j], net.LearningRate, net.CurEpochs ,samplesCount); } for(int i = 0; i < net.HdnLayers.Length; ++i) { curLayer = net.HdnLayers[i]; for(int j = 0; j < curLayer.Neurons.Length; ++j) { updateLayerFunc(curLayer.Neurons[j], net.LearningRate, net.CurEpochs, samplesCount); } } } #region 各参数损失贡献计算 //计算各参数对损失的贡献程度（也是各参数的变化的值） public static void CalcDelta(NeuralNet net, float[] input) { var lastInput = input; for(int i = 0; i < net.HdnLayers.Length; ++i) { var curLayer = net.HdnLayers[i]; CalcLayerDelta(curLayer, lastInput); lastInput = curLayer.Output; } CalcLayerDelta(net.OutLayer, lastInput); } private static void CalcLayerDelta(Layer curLayer, float[] lastInput) { for(int j = 0, k; j < curLayer.Neurons.Length; ++j) { var curNeuron = curLayer.Neurons[j]; for(k = 0; k < lastInput.Length; ++k) { //通过反向传播时神经元的损失，计算每个权重的贡献贡献并累加 curNeuron.WeightParams["Delta"][k] += curNeuron.Params["Error"] * lastInput[k]; } //同理计算偏置的损失贡献 curNeuron.WeightParams["Delta"][k] += curNeuron.Params["Error"]; } } #endregion #region SGD private static void SGD_UpdateW(Neuron curNeuron, float learningRate, int curEpochs, int samplesCount) { for(int k = 0; k < curNeuron.Weights.Length; ++k) { var gradient = curNeuron.WeightParams["Delta"][k] / samplesCount; curNeuron.Weights[k] -= learningRate * gradient; curNeuron.WeightParams["Delta"][k] = 0; } } #endregion #region Momentum private static void Momentum_UpdateW(Neuron curNeuron, float learningRate, int curEpochs, int samplesCount) { for(int k = 0; k < curNeuron.Weights.Length; ++k) { var gradient = curNeuron.WeightParams["Delta"][k] / samplesCount; curNeuron.WeightParams["m"][k] = beta1 * curNeuron.WeightParams["m"][k] - learningRate * gradient; curNeuron.Weights[k] += curNeuron.WeightParams["m"][k]; curNeuron.WeightParams["Delta"][k] = 0; } } #endregion #region AdaGrad private static void AdaGrad_UpdateW(Neuron curNeuron, float learningRate, int curEpochs, int samplesCount) { for(int k = 0; k < curNeuron.Weights.Length; ++k) { var gradient = curNeuron.WeightParams["Delta"][k] / samplesCount; curNeuron.WeightParams["v"][k] += gradient * gradient; curNeuron.Weights[k] -= learningRate * gradient / MathF.Sqrt(curNeuron.WeightParams["v"][k] + MinDelta); curNeuron.WeightParams["Delta"][k] = 0; } } #endregion #region Adam private static void Adam_UpdateW(Neuron curNeuron, float learningRate, int curEpochs, int samplesCount) { for(int k = 0; k < curNeuron.Weights.Length; ++k) { var gradient = curNeuron.WeightParams["Delta"][k] / samplesCount; curNeuron.WeightParams["m"][k] = beta1 * curNeuron.WeightParams["m"][k] + (1 - beta1) * gradient; curNeuron.WeightParams["v"][k] = beta2 * curNeuron.WeightParams["v"][k] + (1 - beta2) * gradient * gradient; var mHat = curNeuron.WeightParams["m"][k] / (1 - MathF.Pow(beta1, curEpochs)); var vHat = curNeuron.WeightParams["v"][k] / (1 - MathF.Pow(beta2, curEpochs)); curNeuron.Weights[k] -= learningRate * mHat / (MathF.Sqrt(vHat) + MinDelta); curNeuron.WeightParams["Delta"][k] = 0; } } #endregion } } d. 损失函数 损失函数用来衡量输出与正确值之间的差距，这里实现的是最常用的两个损失函数： 均方差函数：简单实用，形式如下： 交叉熵函数：主要用在多分类问题上，配合Softmax使用： using System; namespaceJufGame.AI.ANN { publicstaticclassLossFunc { privateconstfloat MinDelta = 1e-7f; publicenum Type { MeanSqurad, CrossEntropy, } public static float Calc(Type type, float[] targetOut, Layer outLayer) { return type switch { Type.MeanSqurad => MeanSquradErr_Calc(targetOut, outLayer), _ => CrossEntropy_Calc(targetOut, outLayer), }; } public static void Diff(Type type, float[] targetOut, Layer outLayer) { switch(type) { case Type.MeanSqurad: MeanSquradErr_Diff(targetOut, outLayer); break; case Type.CrossEntropy: CrossEntropy_Diff(targetOut, outLayer); break; }; } private static float MeanSquradErr_Calc(float[] targetOut, Layer outLayer) { var errSum = 0.0f; for(int i = 0; i < targetOut.Length; ++i) { errSum += MathF.Pow(outLayer.Output[i] - targetOut[i], 2); } return errSum / (2 * targetOut.Length); } private static void MeanSquradErr_Diff(float[] targetOut, Layer outLayer) { for(int i = 0; i < targetOut.Length; ++i) { var curNeuron = outLayer.Neurons[i]; curNeuron.Params["Error"] = outLayer.Output[i] - targetOut[i]; } } private static float CrossEntropy_Calc(float[] targetOut, Layer outLayer) { var errSum = 0.0f; for(int i = 0; i < targetOut.Length; ++i) { //加上一个极小值再取log，放置出现log(0)报错 errSum -= targetOut[i] * MathF.Log(outLayer.Output[i] + MinDelta); } return errSum; } private static void CrossEntropy_Diff(float[] targetOut, Layer outLayer) { for(int i = 0; i < targetOut.Length; ++i) { var curNeuron = outLayer.Neurons[i]; //用Output[i]的前提：神经网络的输出经过了softmax处理 curNeuron.Params["Error"] = outLayer.Output[i] - targetOut[i]; } } } } 2. 感知机（神经元） 简单地对神经元结构进行实现，只是神经元在训练时，需要为自身或者自身的权重记录一些额外信息，所以多了WeightParams和Params备以记录。 为什么不记录激活函数的计算结果out? 因为在训练过程中常常要以层为单位统一处理激活函数的计算结果，故而将out都记录在层中了。（实际上Python中许多深度学习框架库都是以层为最小单位构建神经网络的，这有利于进行矩阵运算，但在我们的实现中，一来没用到矩阵运算，二来是希望能让大家更直接地看到神经网络训练、计算的细节，所以我们以单个神经元作为最小的单位。） 为什么没有激活函数f(x)？ 因为我们之前说过，神经网络的隐藏层都是使用同一种激活函数，顶多输出层用的不太一样。也就是说我们只需要记录两个函数的类型，所以让后续实现的神经网络类记下就行了，没必要每个神经元都记录，浪费空间。 using System; using System.Collections.Generic; using UnityEngine; namespaceJufGame.AI.ANN { [Serializable] // 方便在编辑器页面查看 publicclassNeuron { //神经元权重列表，末位放置偏置b publicfloat[] Weights => weights; //加权和 publicfloat Sum => sum; //为各个权重分配的额外参数 public Dictionary<string, float[]> WeightParams{ get; privateset; } //为神经元本身分配的额外参数 public Dictionary<string, float> Params{ get; privateset; } [SerializeField]privatefloat[] weights; privatefloat sum; public Neuron(int weightCount) { weights = newfloat[weightCount + 1];//末尾放偏置 } /// /// 初始化训练所需参数列表，仅在训练时调用 /// public void InitCache() { Params = new Dictionary<string, float> { ["Error"] = 0,//该值用来记录，每次更新时的累计损失 }; WeightParams = new Dictionary<string, float[]> { //记录权重待变化值 ["Delta"] = newfloat[weights.Length], //Momentum和Adam中，用于记录权重变化的「动量」 ["m"] = newfloat[weights.Length], //AdaGrad和Adam中，用于记录权重独立学习率 ["v"] = newfloat[weights.Length], }; } //计算Sum public float CalcSum(float[] input) { int i; sum = 0; for(i = 0; i < input.Length; ++i) { sum += weights[i] * input[i];//加权和 } sum += weights[i];//加上权重 return Sum; } } } 3. 层 没有太多必要说的，就是嵌套调用了包含的各神经元的函数，比如层的计算就是各个神经元的计算，其它同理。 using System; using UnityEngine; namespaceJufGame.AI.ANN { [Serializable] publicclassLayer { public Neuron[] Neurons => neurons;//存储神经元 publicfloat[] Output => output;//存储各神经元激活函数输出 [SerializeField] private Neuron[] neurons; [SerializeField] privatefloat[] output; public Layer(int neuronCount) { output = newfloat[neuronCount]; neurons = new Neuron[neuronCount]; } //对层中的每个神经元的权重进行初始化 public void InitWeights(int weightCount, InitWFunc.Type initType) { for(int i = 0; i < neurons.Length; ++i) { neurons[i] = new Neuron(weightCount); InitWFunc.InitWeights(initType, neurons[i]); } } //初始化层中每个神经元的额外参数 public void InitCache() { for(int i = 0; i < neurons.Length; ++i) { neurons[i].InitCache(); } } //计算该层，实际上就是计算所有神经元的加权和，并求出激活函数的输出 public float[] CalcLayer(float[] inputData, ActivationFunc.Type acFuc) { for(int i = 0; i < neurons.Length; ++i) { neurons[i].CalcSum(inputData); } ActivationFunc.Calc(acFuc, this); return output; } } } 4. 多层感知机（神经网络） 神经网络也一样，是对层的各个功能的再度包装，只是多了些超参数成员变量。 为什么没有输入层？ 因为输入层其实就只是输入的数值，没必要单独设置一个层（Python中许多深度学习框架也是这样的）。 怎么读取输出？ 直接读取输出层的输出列表即可。 using System; using UnityEngine; namespaceJufGame.AI.ANN { [Serializable] publicclassNeuralNet { publicfloat TargetError = 0.0001f;//预期误差，当损失函数的结果小于它时，就停止训练 publicfloat LearningRate = 0.01f;//学习率 publicint CurEpochs;//记录当前迭代的次数 public ActivationFunc.Type hdnAcFunc;//隐藏层激活函数类型 public ActivationFunc.Type outAcFunc;//输出层激活函数类型 public Layer[] HdnLayers => hdnLayers;//隐藏层 public Layer OutLayer => outLayer;//输出层 [SerializeField] private Layer[] hdnLayers; [SerializeField] private Layer outLayer; public NeuralNet(int hdnLayerCount, int[] neuronsOfLayers, int outCount, ActivationFunc.Type hdnAcFnc, ActivationFunc.Type outAcFnc, float targetError = 0.0001f, float learningRate = 0.01f) { outLayer = new Layer(outCount); hdnLayers = new Layer[hdnLayerCount]; for(int i = 0, j = 0; i < hdnLayerCount; ++i) { hdnLayers[i] = new Layer(neuronsOfLayers[j]); } hdnAcFunc = hdnAcFnc; outAcFunc = outAcFnc; TargetError = targetError; LearningRate = learningRate; } //初始化各神经元权重 public void InitWeights(int inputDataCount, InitWFunc.Type initType) { int neuronNum = inputDataCount; for(int i = 0; i < HdnLayers.Length; ++i) { hdnLayers[i].InitWeights(neuronNum, initType); neuronNum = HdnLayers[i].Neurons.Length; } outLayer.InitWeights(neuronNum, initType); } //初始化各神经元额外参数列表 public void InitCache() { for(int i = 0; i < HdnLayers.Length; ++i) { hdnLayers[i].InitCache(); } outLayer.InitCache(); } //计算神经网络 public float[] CalcNet(float[] inputData) { var curInput = inputData; for(int j = 0; j < hdnLayers.Length; ++j) { curInput = hdnLayers[j].CalcLayer(curInput, hdnAcFunc); } return outLayer.CalcLayer(curInput, outAcFunc); } } } 至此，神经网络就搭建完成了，并没有想象的那么复杂。 5. 训练器 先实现一个训练器的基类……等等，明明就一种神经网络，为什么还要有基类，直接写不好吗？ 其实最初是打算实现多种神经网络的，但后来考试临近，不得不转移重心，最终只实现了最简单的MLP。Unity本身也已经可以导入ONNX模型，如果要在游戏里想实现图像识别这类复杂功能的话，导入模型似乎更方便，所以实现更多神经网络的必要性就值得考虑了。当然，这些文末会再进行讨论。 先来看看这个基类有哪些东西： using UnityEngine; namespaceJufGame.AI.ANN { publicabstractclassTraining { public NeuralNet TrainingNet;//需要训练的神经网络 publicfloat[][] InputSet => inputSet;//训练输入集 /*没有「训练输出集」是因为并非所有类型的神经网络都需要「训练输出」 所以它不是基类必需的，当然，这些就是题外话了*/ protectedfloat[][] inputSet; [SerializeField] protectedint maxEpochs;//最大迭代次数 public Training(NeuralNet initedNet, int maxEpochs) { this.maxEpochs = maxEpochs; TrainingNet = initedNet; } public void SetInput(float[][] inputSet)//设置训练输入集 { this.inputSet = inputSet; } public abstract bool IsTrainEnd();//是否训练完成 public abstract void Train(); //不断训练神经网络 public abstract void Train_OneTime();//训练（迭代）一次神经网络 //打印神经网络输出的结果，调试用的 public static void DebugNetRes(NeuralNet net, float[][] testInput) { for(int i = 0; i < testInput.GetLength(0); ++i) { var res = net.CalcNet(testInput[i]); for(int j = 0; j < res.Length; ++j) { Debug.Log("检验结果 " + i + " = " + res[j]); } } } } } 最后，就是真正用来训练的类了，我们将采用最常见梯度下降法进行训练。其中涉及前向传播和反向传播，我稍作解释： 前向传播（Forward Propagation）：传入训练输入样本计算出当前神经网络模型的输出，并进一步计算损失（损失函数的计算结果其实在反向传播中并没有用，只是给开发者看的，用来判断当前训练情况）。 反向传播（Backward Propagation）：从损失函数开始，用链式求导法则，反向（输出层➡隐藏层➡输入层）计算每个神经元的损失（下图中的δ、代码中的Params["Error"]）。通过神经元的损失，可以计算出神经元的每个参数（权重、偏置）的损失贡献（权重更新函数代码中的WeightParams["Delta"]）并一直累加，直到训练集被读取完。这时，我们就说完成了一次训练迭代。 完成一次迭代后（不是训练完一个样本后），将累加的损失除以训练样本数取得均值，再用权重更新函数对各参数进行更新。 这一迭代过程反复进行，直到损失函数计算的误差达到可容许范围（也就是小于预期损失）或达到最大训练次数，详情可看《解读反向传播算法（图与公式结合）》[1]（但注意！该文章为了方便讲解，训练完一个样本就开始更新权重了），实现如下： using UnityEngine; namespaceJufGame.AI.ANN { [System.Serializable] publicclassBPNN : Training { publicfloat[][] OutputSet => outputSet; [SerializeField] privatefloat meanError = float.MaxValue; [SerializeField] private LossFunc.Type errorFunc; [SerializeField] private UpdateWFunc.Type updateWFunc; privatefloat[][] outputSet; public BPNN(NeuralNet initedNet, LossFunc.Type errorFunc, UpdateWFunc.Type updateWFunc, int maxEpochs): base(initedNet,maxEpochs) { this.errorFunc = errorFunc; this.updateWFunc = updateWFunc; } public void SetOutput(float[][] outputSet) { this.outputSet = outputSet; } public override bool IsTrainEnd()//判断是否训练完成 { return meanError < TrainingNet.TargetError || maxEpochs < TrainingNet.CurEpochs; } public override void Train() { meanError = float.MaxValue; while(!IsTrainEnd()) { Train_OneTime(); } } public override void Train_OneTime() { int samplesCount = inputSet.GetLength(0);//记下样本数量 ++TrainingNet.CurEpochs;//更新迭代次数 meanError = 0; for(int i = 0; i < samplesCount; ++i) { ForWard(i); Backpropagation(); UpdateWFunc.CalcDelta(TrainingNet, inputSet[i]); } UpdateWFunc.UpdateNetWeights( updateWFunc, TrainingNet, samplesCount); meanError /= samplesCount;//取样本误差均值作为本次迭代的误差 #if UNITY_EDITOR Debug.Log($"误差：{meanError}");//调试时用的 #endif } private void ForWard(int trainIndex) { var outLayer = TrainingNet.OutLayer; TrainingNet.CalcNet(inputSet[trainIndex]); meanError = LossFunc.Calc(errorFunc, outputSet[trainIndex], outLayer); /*这里图省事，将反向传播的第一步一并计算了*/ LossFunc.Diff(errorFunc, outputSet[trainIndex], outLayer);//损失函数求导 for(int i = 0; i < outLayer.Neurons.Length; ++i)//输出层激活函数求导 { outLayer.Neurons[i].Params["Error"] *= ActivationFunc.Diff(TrainingNet.outAcFunc, outLayer, i); } } private void Backpropagation() { var lastLayer = TrainingNet.OutLayer; for(int i = TrainingNet.HdnLayers.Length - 1; i > -1; --i) { var curLayer = TrainingNet.HdnLayers[i]; for(int j = 0; j < curLayer.Neurons.Length; ++j) { var curNeuron = curLayer.Neurons[j]; //每次计算损失时要清零，避免上次迭代结果产生的干扰 curNeuron.Params["Error"] = 0; for(int k = 0; k < lastLayer.Neurons.Length; ++k) { var lastNeuron = lastLayer.Neurons[k]; curNeuron.Params["Error"] += lastNeuron.Params["Error"] * lastNeuron.Weights[j]; } curNeuron.Params["Error"] *= ActivationFunc.Diff(TrainingNet.hdnAcFunc, curLayer, j); } lastLayer = curLayer; } } } } 四、使用教程 一切都准备就绪了，那要怎么运转这个神经网络呢？我们创建一个继承了MonoBehavior的脚本，并声明下面三个公开的字段： using UnityEngine; using JufGame.AI.ANN; public class TrainANN : MonoBehaviour { public int inputCount; public BPNN bp; public InitWFunc.Type initW; } 将它挂载在场景的任一物体上，不出意外的话，你可以在编辑器看到神经网络类的许多关键变量都可以显示出来（如果你的没有，就要注意是否遗漏[System.Serializable]或设置成了私有类）： 我们再完善下脚本，设置好训练输入和输出（以「异或」运算为例），使得神经网络能在Unity运行时逐帧训练： public classTrainANN : MonoBehaviour { publicint inputCount; public BPNN bp; public InitWFunc.Type initW; privatefloat[][] inSet = //异或运算的输入 { newfloat[]{1, 0}, newfloat[]{1, 1}, newfloat[]{0, 0}, newfloat[]{0, 1}, }; privatefloat[][] outSet = //异或运算的输出 { newfloat[]{1}, newfloat[]{0}, newfloat[]{0}, newfloat[]{1}, }; private void Awake() { bp.SetInput(inSet);//为训练器设置训练输入集 bp.SetOutput(outSet);//为训练器设置训练输出集 } private void Start() { bp.TrainingNet.InitWeights(inputCount, initW);//初始化权重 bp.TrainingNet.InitCache();//初始化额外参数存储 } private void Update() { if(bp.IsTrainEnd())//如果训练结束，就打印训练完成的神经网络 对训练输入集输出 { Training.DebugNetRes(bp.TrainingNet, inSet); return; } bp.Train_OneTime();//没有训练结束，就每帧训练一次 } } 回到编辑页面，由于关键变量都可以直接修改，所以我们无需调用构造函数进行初始化。我们设置好它的预期误差、最大迭代数等，可以参照下面这个设置： 然后我们开始运行，不出意外的话，训练很快就结束了（大概只花了100多次迭代），你也可以看到Console面板打印了神经网络的输出结果： 可以看到，结果是很接近真实值的（把预期误差设小点还可以更接近），这也说明我们的训练是奏效的！ 可能在尝试其它参数时（尤其是激活函数），会发现有时误差会「停」在某个值下不去了，是怎么回事？ 这是陷入了局部最优解的情况，又或者是神经网络几乎停止训练，在使用较少数据并且激活函数为ReLU的场合可能会出现。可以尝试其它激活函数，或增加训练样本。 可一旦结束Unity运行后，编辑页面这些被训练好的参数就又变回去了，这该怎么办？很简单，一旦训练完后，就暂停运行（是暂停，不是结束），右键神经网络的名字，将它复制下来。 创建一个新的脚本并将它挂在场景中，用以测试被复制的神经网络能否正常工作： using System.Collections; using System.Collections.Generic; using JufGame.AI.ANN; using UnityEngine; publicclassUseMLP : MonoBehaviour { public NeuralNet net; privatefloat[][] inSet = //异或运算的输入 { newfloat[]{1, 0}, newfloat[]{1, 1}, newfloat[]{0, 0}, newfloat[]{0, 1}, }; private void Awake() { Training.DebugNetRes(net, inSet); } } 将之前训练好的神经网络，粘贴给它，然后运行Unity： 不出意外的话，可以看到，这个神经网络的输出是十分正确的（我就不贴图了）！ 五、项目链接 最后一步，神经网络要如何用在AI的行为决策中呢？ 确定好AI决策相关的数据、并将其作为神经网络的输入。 比如生命值、攻击力、与敌人之间的距离等，并将它们进行归一化处理，比如一个角色生命值上限为100，现在生命值为50，那在将生命值作为输入时不是传入5，而是50/100 =0.5。总之，要将输入的数据限制在0~1。这有利于神经网络训练，也能更正确地表达数据的信息，还是以刚才这个角色为例，假设他的魔法值上限为50，当前魔法值为40，能因为当前生命值50 > 当前魔法值40而认为当前角色更需要补充魔法值吗？这显然是不合理的。 根据输入样本拟定对应的输出结果作为训练输出。 选取并想好了数据的归一化处理后，我们需要拟定一部分输入和输出。设计训练输入还是比较简单的，但输出怎么量化呢？因为决策要做的可是一个行为！我们可以这么做，将输出设置为执行某个行为的概率： 这样一来，我们只要遍历训练完的神经网络的所有输出，选出值最大的那个神经元对应的行为来执行就可以了。 我所提供的项目[2]也是这样做的，在项目中我用神经网络控制了小机器人的三个行为：攻击、防御、奔跑。影响因素有自身生命值、敌方生命值、敌我间距。预期的结果就是当我方血少又离得很近（手动拖拽二者距离）时，机器人会防御；当我方与对方旗鼓相当或更优势时，机器人会攻击，其余情况会奔跑。总之，也是很简单的行为。 六、尾声 最后，我们再谈谈之前遗留的问题：是否有必要使用C# 构建神经网络？我个人觉得，除了用来做这种决策行动，其它情况大可不必费力构建。主要原因在于：Unity可以导入由Python深度学习框架训练出来的神经网络模型，省时省力、效果还好。 不过，作为行为决策这种与游戏逻辑紧密联系的部分，用游戏引擎的脚本语言编写是合适的。这方便观察现象时时调整，也能更好地与其它游戏脚本结合，而且行为决策所需的神经网络复杂度并不大，因此带来的性能影响并不大。 以《最高指挥官2》中使用的MLP为例：它的输入层有34个神经元，隐藏层有98个神经元，输出层有15个神经元，但它的计算不超过0.03毫秒。这显然是个可以接受的结果。（该数据来源于2015版《游戏人工智能》——第30章） 但是，神经网络比起传统的决策算法，最大的弊端是不方便调试以及最终效果的不可控。毕竟它的训练是个纯数学的过程，很难像以往那样在程序中打断点跟踪；最终效果的不可控也导致可能要训练多次才能得出较满意的模型。所以在《最高指挥官2》中神经网络也不是全权控制AI决策，而是搭配有限状态机使用。还有一点就是需要准备大量的训练数据，其实这并非是一定的，如果你能用好Excel导入数据或者是像《最高指挥官2》那样使用适应度函数来代替具体的训练样本（篇幅所限，不展开了），感兴趣的同学可以去了解下。 无庸置疑的是，这种AI决策方法的确会使角色更加生动，而且需要额外编写的代码也并不多，只是要多花些时间训练。 参考： [1] 解读反向传播算法（图与公式结合） https://zhuanlan.zhihu.com/p/96046514 [2] 提供的项目 https://www.alipan.com/s/P1Ve6cQYc1f 这是侑虎科技第1902篇文章，感谢作者狐王驾虎供稿。欢迎转发分享，未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859） 作者主页：https://home.cnblogs.com/u/OwlCat 再次感谢狐王驾虎的分享，如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859）
导致Resources文件夹的资源在Android打包后丢失的原因
1）导致Resources文件夹的资源在Android打包后丢失的原因 2）GPU Culled Primitive中的xyz三平面剔除与视锥体剔除有什么区别 3）按照不升级Unity引擎小改动快速适配安卓16KB页面要求操作后还是闪退 这是第451篇UWA技术知识分享的推送，精选了UWA社区的热门话题，涵盖了UWA问答、社区帖子等技术知识点，助力大家更全面地掌握和学习。 UWA社区主页：community.uwa4d.com UWA QQ群：793972859 From UWA AI 问答 Q：请问Resources文件夹的资源在Android打包后丢失是什么导致的呢？有resources.load显式调用的。 A：Resources文件夹中的资源在Android打包后丢失，尽管有Resources.Load显式调用，通常并非资源未被打包进APK，而是由以下几种原因导致加载失败或资源无法访问： 1. 路径与大小写问题 Resources.Load的路径是不区分大小写的，但某些Android设备（尤其是定制ROM）的文件系统对路径敏感。如果代码中指定的路径与实际文件夹/文件名的大小写不完全一致，可能在部分设备上加载失败。确保路径字符串与Resources文件夹内的资源命名（包括嵌套文件夹）保持一致。 此外，路径应使用正斜杠“/”，且不包含“.asset”等扩展名。 2. 构建过程中资源被剥离或冲突 同名资源冲突：若项目中存在多个Resources文件夹，并且包含同名但不同内容的资源，Unity在构建时行为不确定，可能导致错误的资源被打包或覆盖。 Addressables或AssetBundle冲突：如果同一资源同时被标记为Addressable或打包进AssetBundle，又存在于Resources文件夹，可能会因构建流程混乱导致资源引用异常。建议避免资源重复管理。 3. 热更新或补丁机制干扰 使用Application Patching或自定义热更方案时，若处理不当可能导致StreamingAssets目录下原始资源被替换或删除。虽然Resources资源通常打包在主APK内，但在某些增量更新逻辑中，如果误操作了内部文件结构，也可能影响资源读取。 4. 安装路径变化（特定厂商手机） 部分国产手机（如华为、荣耀）将应用安装到SD卡时，路径中可能出现特殊字符（如 -），若项目中有自定义资源加载逻辑依赖路径拼接，未正确转义可能导致文件打开失败。虽然Resources.Load本身不受此影响，但如果涉及从外部加载补充资源并动态注册，则需注意。 5. 资源未实际打入包体 虽然Resources文件夹下的资源默认会被打包，但以下情况可能导致例外： 构建过程中出现异常中断，导致资源未完整写入。 使用了条件编译或脚本化构建流程，意外排除了Resources文件夹。 可通过解压APK文件（位于AndroidStudioProject/app/build/outputs/apk/或Unity导出目录），检查assets/bin/Data/resources.assets是否存在且包含预期资源，以确认资源是否成功打包。 排查建议 使用UWA GOT Online报告中的Resources.Load调用详情页，查看该资源的加载次数、耗时及是否返回null。 在真机运行时添加日志，捕获Resources.Load返回结果，确认是否为空。 检查目标设备的存储路径和安装方式，排除厂商ROM特殊行为影响。 清理构建环境，重新执行完整构建流程，避免增量构建残留问题。 综上，最可能的原因是路径问题或构建过程中的资源冲突，建议优先验证资源是否存在以及加载路径准确性。 分享链接： https://www.uwa4d.com/main/uwa-shared.html?shareId=88f1787d-7dc5-4d8a-91c2-09dc8d9c2258 From UWA AI 问答 Q：报告中的GPU Culled Primitive中的XY平面和Z平面剔除，和视锥体剔除是一样的吗？ A：GPU Culled Primitive中的剔除机制与视锥体剔除密切相关，但“XY平面和Z平面剔除”这一表述并不准确，实际并不存在独立的“XY平面”或“Z平面”剔除方式。通常所说的图元剔除主要指视锥体剔除（Frustum Culling），它是一种基于摄像机视锥空间的裁剪技术，判断物体是否位于可视范围内。 在图形渲染流程中，图元（如三角形）经过顶点着色器处理后，会进行一系列裁剪操作。其中，被裁剪掉的图元数量（culled_primitives）反映的是那些经过顶点计算后，最终因不在摄像机视野内而未被光栅化的图元。这部分图元的产生，主要受视锥体剔除机制影响 —— 即只有处于视锥内的图元才会继续后续渲染流程，其余则被剔除。 此外，还可能存在其他类型的剔除，例如： 遮挡剔除（Occlusion Culling）：用于剔除被其他物体完全遮挡的对象； 背面剔除（Backface Culling）：剔除面向摄像机背面的图元； 但这些通常不计入GPU图元输入统计中的culled_primitives主要部分。 根据本次运行数据分析，项目中图元处理情况良好，input_primitives与culled_primitives的比例处于合理范围，未发现因过度图元提交或严重剔除浪费导致的GPU顶点计算压力问题。因此，当前场景的剔除效率正常，无需针对图元剔除做额外优化。 综上，所谓的“XY平面和Z平面剔除”并非标准术语，实际起作用的是视锥体剔除，它是决定哪些图元被保留或剔除的核心机制之一。 GPU Culled Primitive中的“XY平面剔除”和“Z平面剔除”是视锥体剔除（Frustum Culling）在GPU硬件层面的具体实现阶段，它们共同构成了完整的视锥体剔除过程。 具体来说，视锥体剔除从逻辑上判断一个图元是否在摄像机的可视范围内（即视锥体内）。而在GPU执行时，这一过程被分解为多个连续的裁剪测试阶段： Facing & XY Plane Test：此阶段主要检测图元是否位于视锥的X、Y方向边界内，并进行背面剔除（Facing Culling）。若图元在X或Y轴方向超出视锥范围，或为背向相机的面，则在此阶段被剔除。 Z Plane Test：此阶段检测图元在深度方向（Z轴）是否超出近裁剪面（Near Clip）或远裁剪面（Far Clip）。若图元距离过近或过远，则被剔除。 Sample Test（微图元剔除）：进一步剔除那些屏幕投影面积过小、无法覆盖任何像素采样点的图元。 这些阶段按顺序执行，每一步都会统计被剔除的图元数量。因此，“XY平面”和“Z平面”剔除并非独立于视锥体剔除，而是其内部细分的裁剪步骤。 分享链接： https://www.uwa4d.com/main/uwa-shared.html?shareId=51a667fc-208e-4e0d-9d3d-a36a1bf2de39 From 问答社区 Q1：在UWA问答社区看到帖子： https://answer.uwa4d.com/question/68d4e378244ce21ce9ec0920 最近刚好我的项目也碰到了这个安卓16KB的问题，按照这个贴子实操还是一直闪退，想请教一下大家有碰到闪退问题吗，是如何解决的呢？ A：更新对应的so库，AS自己查看，不是显示16KB就得换，正常应该这么显示： Q2：这些都换了，谷歌的检测工具都能通过了，目前就是闪退。 A：很明显，假现象，工程里面绝对还有其它不符合标准的，可以空包一个一个往里面加就知道了。 Q3：我目前用2020版Unity出的空工程，按照上述操作完成之后，还是闪退，基本就没有其他的so。 A1：Unity得升级，最低是2021，升级到2022.3.系列，起码是2022.3.62，有些同大版本，都是不支持的，我问了一下Unity官方那边的人。 A2：试过不行，最后还是升到2022。 A3：可以分析一下闪退日志是否有报错，一般可以定位到问题库。 感谢小肥狼、tommom和小花轮子@UWA问答社区提供了回答，欢迎大家转至社区交流： https://answer.uwa4d.com/question/68d4e378244ce21ce9ec0920 无论是社区里开发者们的互助讨论，还是AI基于知识沉淀的快速反馈，核心都是为了让每一个技术难题都有解、每一次踩坑都有回响。本期分享分别来自UWA AI问答和UWA问答社区，希望这些从真实开发场景中提炼的经验，能直接帮你解决当下的技术卡点，也让你在遇到同类问题时，能更高效地找到破局方向。 封面图来源于网络 今天的分享就到这里。生有涯而知无涯，在漫漫的开发周期中，我们遇到的问题只是冰山一角，UWA社区愿伴你同行，一起探索分享。欢迎更多的开发者加入UWA社区。 UWA官网：www.uwa4d.com UWA社区：community.uwa4d.com UWA学堂：edu.uwa4d.com 官方技术QQ群：793972859
为什么Android游戏画面在30帧运行时有抖动现象
1）为什么Android游戏画面在30帧运行时有抖动现象 2）团结引擎VS Skinning开启ENABLE_VS_SKINNING_MORE_BONES后，性能反而下降 这是第450篇UWA技术知识分享的推送，精选了UWA社区的热门话题，涵盖了UWA问答、社区帖子等技术知识点，助力大家更全面地掌握和学习。 UWA社区主页：community.uwa4d.com UWA QQ群：793972859 From UWA AI 问答 Q1：Android平台下，游戏以30帧运行时，即便整体性能稳定，仍普遍存在画面抖动现象，想探讨一下这可能是由于什么原因造成的？ A：画面抖动（Jitter）是Android平台30帧游戏中常见但易被忽视的问题，即便整体FPS稳定，仍可能因以下原因导致视觉上的不流畅或抖动感： 1. 帧生成时间不均匀（Frame Pacing问题） 这是最核心的原因。即使平均帧率为30FPS，若帧的生成间隔不均匀（如一帧耗时28ms，下一帧耗时38ms），会导致视觉上出现“微卡顿”或“抖动”。Android设备尤其在高刷新率屏幕（如90Hz、120Hz）下，若未正确配置帧同步策略，GPU与显示子系统之间的帧提交节奏失配，极易引发此问题。 解决方案： 确保关闭Optimized Frame Pacing（部分模拟器和低端设备存在兼容性问题，实测可缓解抖动）。 使用Choreographer回调来对齐VSync信号，确保帧更新时机精准。 在Unity中，避免仅依赖Application.targetFrameRate=30，因其在动态刷新率设备上可能导致帧率倍数下降（如从30→15）。 可结合定时重设帧率策略： IEnumerator SetFrameRatePeriodically() { while (true) { yield return new WaitForSeconds(10); Application.targetFrameRate = 31; yield return new WaitForSeconds(0.1f); Application.targetFrameRate = 30; } } 2. GPU与CPU工作负载波动引起帧时间波动 即使平均性能达标，若每帧CPU或GPU耗时波动大（如某些帧CPU耗时接近33ms，其他帧仅15ms），会造成帧间隔不一致，表现为抖动。 排查建议： 使用UWA GOT Online的CPU每帧耗时曲线、>40ms帧数占比指标，分析是否存在高波动性。 检查是否存在偶发性任务（如GC、资源加载、逻辑计算集中爆发），导致单帧骤增。 3. 渲染管线与垂直同步机制冲突 Android设备厂商常自定义显示策略，部分机型在非原生刷新率（如60Hz设备跑30Hz）时，系统VSync调度可能出现偏差，导致帧呈现时间错位。 优化方向： 避免频繁切换目标帧率，保持帧率恒定。 在支持的设备上尝试使用SurfaceFlinger相关调试工具观察实际显示帧时序。 若使用自定义渲染管线，检查后处理、阴影等异步操作是否引入延迟。 4. 输入与渲染不同步 触摸输入更新频率（通常60Hz或更高）与30Hz渲染不同步，可能导致操作反馈延迟感叠加视觉抖动。 改进措施： 尽量将关键UI更新与渲染帧对齐。 对高频输入做插值或预测处理，提升交互平滑度。 综上，画面抖动多源于帧间时间不一致性而非平均帧率不足。建议优先通过UWA性能报告中的Jank均值、>40ms帧数占比及CPU每帧耗时曲线进行量化分析，并针对性优化帧生成节奏与负载均衡。 Q2：有什么解决方案？ A：针对Android平台30帧下画面抖动问题，可采取以下系统性解决方案： 1. 精准控制帧率同步（核心措施） 关闭Optimized Frame Pacing：该功能在部分设备（如MuMu模拟器）存在兼容性问题，易导致帧提交延迟。在Player Settings中禁用此选项可显著改善抖动。 使用Choreographer对齐VSync：通过Android原生API确保每帧更新与系统刷新信号同步，避免因调度偏差导致的帧间隔不均。 周期性重设目标帧率：应对动态刷新率设备（如120Hz降为60Hz时30帧变15帧），可定时重置targetFrameRate以强制系统重新计算： IEnumerator SetFrameRate() { while (true) { yield return new WaitForSeconds(10); Application.targetFrameRate = 31; yield return new WaitForSeconds(0.1f); Application.targetFrameRate = 30; } } 2. 优化渲染负载与资源管理 降低GPU Skin等高负载特效密度：大量骨骼蒙皮或复杂Shader会加剧帧间波动，建议在低端设备上动态降级。 使用RenderTexture替代Varying插值写回：避免Shader频繁读写主存造成带宽压力，将所需参数绘制到GlobalTexture中复用。 调整Graphics Quality设置：关闭非必要的后处理效果（如动态阴影、抗锯齿），减少单帧渲染开销。 3. 避免运行时性能突变 防止GC集中触发：减少堆内存分配，避免匿名函数、装箱操作；使用对象池管理高频创建/销毁对象。 异步加载资源：通过AssetBundle或Addressables分帧加载，避免单帧卡顿。 限制后台下载并发数：若使用BackgroundDownload，应采用任务队列+线程池模式，控制同时下载数量（如≤5个），防止IO阻塞主线程。 4. 监控与验证工具 使用UWA GOT Online报告分析CPU耗时曲线、>40ms帧占比和Jank指标，定位帧时间波动源头。 启用UWA本地资源检测，排查冗余Shader变体或大纹理占用。 在真机环境中启用Perfetto或Systrace，观察SurfaceFlinger合成节奏是否稳定。 综上，解决抖动需从帧生成节奏控制、负载均衡和系统兼容性适配三方面入手，优先确保帧间隔一致性，再逐步优化资源与逻辑开销。 分享链接： https://www.uwa4d.com/main/uwa-shared.html?shareId=a93edb8c-708a-4dd5-b999-c34331f41bb9 From 问答社区 Q：ENABLE_VS_SKINNING关键字最多支持单个SkinnedMeshRenderer 64根骨骼，所以需要使用ENABLE_VS_SKINNING_MORE_BONES。测试60个单位每个单位骨骼150+，开启后，iPhone XR帧率只有5fps，未开启时帧率20fps，产生了负优化，会是什么原因？ A：使用UWA AI问答的功能尝试解读这个问题。 分享链接： https://www.uwa4d.com/main/uwa-shared.html?shareId=b341fe8e-c27b-4c18-8d09-9abdcf048ba4 问题分析： 1. 顶点着色器计算量激增 开启ENABLE_VS_SKINNING_MORE_BONES后，Unity会切换到更复杂的顶点着色器路径，以处理每顶点更多骨骼影响。 每个顶点的蒙皮计算为：输出位置 = Σ(权重_i × 骨骼变换矩阵_i × 本地位置)。当单个模型骨骼数达150+，即使每个顶点仅受4根骨骼影响，GPU仍需加载并计算大量骨骼矩阵。 这导致： ALU指令数大幅上升，超出移动端GPU执行能力； 显存带宽压力剧增，频繁读取骨骼矩阵纹理或UBO； 着色器编译生成低效代码，尤其在旧版Metal/OpenGL ES驱动下。 2. 硬件性能瓶颈凸显 iPhone XR搭载A12芯片，其GPU性能有限，尤其对高ALU和带宽需求场景敏感。 60个单位×150+骨骼 → 总骨骼数超9000，远超常见角色批次规模。 GPU每帧需完成： 数千个骨骼矩阵上传（若未合批）； 大量顶点着色器并行运行； 渲染管线等待数据就绪，造成Gfx.WaitForPresentOnGfxThread延迟飙升。 3. Unity内置GPU Skinning机制缺陷 Unity原生的GPU Skinning（含ENABLE_VS_SKINNING系列宏）在移动平台优化不足： 未有效利用现代API（如Metal Argument Buffers）减少绑定开销； 缺乏对实例化与多对象批量处理的深度整合； 在复杂角色场景下，反而增加CPU-GPU同步负担。 优化建议： 优先关闭ENABLE_VS_SKINNING_MORE_BONES，采用替代方案。 1. 降低骨骼数量（资源层） 与美术协作，通过减骨工具将非关键区域骨骼合并或移除； 目标控制单模型骨骼数在75以内，确保可使用标准ENABLE_VS_SKINNING路径。 2. 使用自定义GPU Instancing + GPU Skinning 参考UWA开源方案，实现基于Compute Shader的骨骼动画计算； 结合GPU Instancing批量渲染相同怪物类型，显著降低DrawCall与动画更新开销； 可同时规避Animator.Update和MeshSkinning.Update的CPU瓶颈。 3. 启用QualitySettings.skinWeights = 4 Bones并导入时锁定 在Model Import Settings中设置Skin Weights为Standard (4 Bones)，避免运行时冗余计算； 禁止使用Auto模式导致动态降级开销。 4. 控制屏幕内更新范围 设置SkinnedMeshRenderer.updateWhenOffscreen = false，停用屏幕外角色的动画计算； 配合LOD系统，在远处使用简化骨骼版本。 5. 考虑动画共享技术 对同类角色，使用统一动画状态源，其它实例通过CopyPose复用结果； 可结合对象池管理，进一步减少Animator组件数量。 注意：ENABLE_VS_SKINNING_MORE_BONES应仅用于极少数特例角色，且需严格测试目标设备表现。常规项目不建议开启。 有经验的朋友欢迎转至社区交流： https://answer.uwa4d.com/question/68f9a7c9244ce21ce9ec0937 无论是社区里开发者们的互助讨论，还是AI基于知识沉淀的快速反馈，核心都是为了让每一个技术难题都有解、每一次踩坑都有回响。本期分享分别来自UWA AI问答和UWA问答社区，希望这些从真实开发场景中提炼的经验，能直接帮你解决当下的技术卡点，也让你在遇到同类问题时，能更高效地找到破局方向。 封面图来源于网络 今天的分享就到这里。生有涯而知无涯，在漫漫的开发周期中，我们遇到的问题只是冰山一角，UWA社区愿伴你同行，一起探索分享。欢迎更多的开发者加入UWA社区。 UWA官网：www.uwa4d.com UWA社区：community.uwa4d.com UWA学堂：edu.uwa4d.com 官方技术QQ群：793972859
【USparkle专栏】如果你深怀绝技，爱“搞点研究”，乐于分享也博采众长，我们期待你的加入，让智慧的火花碰撞交织，让知识的传递生生不息！ 一、前言 近期笔者一直在研究一个专题：在Android平台下，游戏以30帧运行时，即便整体性能稳定，仍普遍存在画面抖动现象。即使是知名厂商的游戏也不例外。例如笔者测试《星穹轨道》时发现： 角色跑动时留意那盆绿植会比较明显 可能是出于功耗的考虑，一进来就默认推荐了30帧，玩起来也有持续性的画面抖动，不像是偶发的卡顿，我的手机性能是较好的，不至于连30帧都跑不流畅。 通过一段时间的研究，对这个问题的原理和解决方案有了一定的见解。 二、原因分析 1. 显示机制 Android的显示机制是一个典型的生产者-消费者模型。应用端负责生产画面，通过调用SwapBuffer将内容写入BufferQueue，系统端（SurfaceFlinger进程）则从BufferQueue中取出内容，执行后续的合成与显示流程。 SurfaceFlinger取BufferQueue的节奏是根据系统Vsync-sf信号调节的，一般跟屏幕刷新率走，比如60hz那么就是16.6ms为一个周期定期触发，当游戏为30帧时，平均是33.3ms生产一个Buffer，生产的速率远远慢于消耗的速率，当Vsync-sf信号来临但BufferQueue里面为空时，系统会重复显示上一帧的内容。 理想情况下，在60Hz屏幕下运行30帧，SurfaceFlinger应该每两个周期消耗一帧内容。然而通过分析发现，实际的消耗周期并不均匀：有时一个周期消耗，有时甚至三个周期消耗。导致每帧画面在屏幕上的停留时间不一致，从而在视觉上产生抖动感。 2. 量化工具 接下来使用Perfetto来看一下这个游戏，Perfetto是Android上系统级别的Trace工具，可以使用它来观察具体的BufferQueue、Vsync情况。 Perfetto有一些配置，针对画面抖动问题需要确认开启ATrace的gfx、view模块： 下图中标注了每一次Buffer被消耗时，距离上一次的周期数： 这里筛选出了Trace数据中相关的轨道，从BufferQueue里面可以看到大多数情况是2个周期消耗一次，偶尔会有1、3个周期出现。 Perfetto生成的Trace文件可以通过SQL进行统计。笔者统计了每次BufferQueue减少时的时间点，进一步计算了这些时间间隔的标准差。该指标可用于量化画面抖动的严重程度，为后续方案对比与优化提供了数据基础。 完整SQL内容在附录中提供 三、目前的解决方案 1. Swappy方案 Swappy是Google GameSDK的一部分，这里是它的官方介绍： https://developer.android.com/games/sdk/frame-pacing?hl=zh-cn *上述网址需要使用VPN打开 它是大多数游戏引擎内置的方案，以Unity引擎为例直接设置一下生效就行。 此方案核心是通过两个EGL拓展来达成它的目的： 通过setPresentationTime，为Buffer设置一个时间戳，避免此Buffer被过快消耗掉。 回到之前我们的例子，如果这里设置了一个允许被消耗的时间戳，那么这里不会存在1个周期就消耗掉的情况： 利用EGL_KHR_fence_sync，可以追踪上一帧GPU处理完毕再将当前帧Swap进去。由于实际游戏运行过程中不会是平稳地每次33.3ms Swap一次，会有波动，此策略可以避免局部生产过快导致的BufferQueue堆积。 Hook了Swappy中的一些函数，将其标注到了Perfetto中以观察其运行过程： 当然，笔者在实际使用过程中，也是遇到了不少问题： 掉帧，常见于开60帧的情况，因为要等上一帧GPU结束，不同手机追踪到的GPU时间差异大，有的要10几ms，再加上Swappy是以周期为单位进行等待的，很容易延后的时间太多，最终导致帧率下降严重，所以建议60帧时不启用Swappy机制。 延迟明显，一方面是渲染线程进行Swap的平均时间是要延后很多的，另一个方面是主线程是先做逻辑Update，然后通过WaitForPendingPresent等待上一帧渲染线程结束后，再继续，从Update到最终呈现的链路多了一个流程，30帧时预估延迟会增加30~40ms左右，体感明显。 不稳定，不同机型效果差异很大，观察了一下发现有些机型setPresentationTime调用后，实际生效的时间并不精确，还有些机型60hz的屏幕刷新率但程序返回的值是59、58这些奇怪的值，可能会导致功能失效、锁帧之类的问题。 总的来说Swappy对画面抖动是有一定效果的，上述问题有些也好处理，只是此方案虽然开启简单，但决策前仍然要权衡、多机型实测评估。 2. 渲染线程同步方案 这是我在《王者荣耀》上发现的，他们使用了一种不同的方案来解决这个问题。 王者开30帧时的表现 此方案是通过让应用端画面生产节奏保持绝对的均匀，来间接让SurfaceFlinger消耗的节奏均匀，需要改一下引擎实现： 渲染线程每次Swap调用前留一个时间戳，如果下一次Swap前的间隔时间小于33ms，则Sleep补全时间后再Swap。另外主线程WaitForTargetFPS的逻辑要去掉，依赖渲染线程控制输出节奏。 还有个小细节，我通过逆向分析发现王者主线程等待上一个渲染线程结束的Sync点调整到了逻辑Update前，这样虽然降低了主线程、渲染线程的并行程度，但延迟上有优势，因为从逻辑Update到Swap中间没有WaitForPendingPresent。 这个办法简单易行，笔者实测是有效的，当然延迟还是不可避免会增加，30帧时预估延迟会增加16ms左右，仍然比Swappy少很多，算是一个平衡的方案，值得参考。 四、后续探索 很惭愧，目前对于这一问题，笔者尚未找到一个完全理想的解决方案。虽然“渲染线程同步”方案在实际测试中确实能够改善画面抖动，但仍存在不可忽视的局限：一方面，这一方案本质上是通过延长时间来达到输出节奏的均匀，导致整体延迟有所增加，另一方面，对于抖动的缓解效果其实也是有限的。 同时，Swappy库作为Google官方提供的开源方案，内部实现中有许多值得借鉴的点，它能推算出Vsync-sf信号点时间。所以笔者计划在现有“渲染线程同步”方案上进行进一步优化，结合推算出的Vsync-sf信号点时间信息来减少渲染线程Sleep的量，降低延迟，提升精准度。希望通过不断的迭代，能逐步逼近一个更加完美、兼顾效果与延迟的方案。 五、附录：Perfetto中量化统计画面抖动的SQL WITH ValueChange AS ( SELECT c.ts, t.name, c.value, LAG(c.value) OVER (PARTITION BY c.track_id ORDER BY c.ts) AS previous_value, process.name FROM counter AS c JOIN process_counter_track AS t ON c.track_id = t.id JOIN process ON t.upid = process.upid WHERE t.name LIKE "%SurfaceView%" and process.name LIKE "%SurfaceFlinger%" ), ValueChange2 AS ( SELECT ts, name, value FROM ValueChange WHERE value < previous_value ), intervals AS ( SELECT ts - LAG(ts) OVER (ORDER BY ts) AS interval_ns FROM ValueChange2 ), intervals_ms AS ( SELECT interval_ns / 1000000.0 AS interval_ms -- 将纳秒转换为毫秒 FROM intervals WHERE interval_ns IS NOT NULL and interval_ns < 100000000 ), stats AS ( SELECT COUNT(interval_ms) AS count_interval, AVG(interval_ms) AS mean_interval, AVG(interval_ms * interval_ms) AS mean_square_interval FROM intervals_ms ) SELECT mean_interval AS avg_consume_interval_ms, SQRT(mean_square_interval - mean_interval * mean_interval) AS stddev_consume_interval_ms FROM stats; 这是侑虎科技第1894篇文章，感谢作者其乐陶陶供稿。欢迎转发分享，未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859） 作者主页：https://www.zhihu.com/people/jun-yan-76-80 再次感谢其乐陶陶的分享，如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859）
有什么指标可以判断手机是否降频
1）有什么指标可以判断手机是否降频 2）关于降低动画浮点数精度的问 这是第449篇UWA技术知识分享的推送，精选了UWA社区的热门话题，涵盖了UWA问答、社区帖子等技术知识点，助力大家更全面地掌握和学习。 UWA社区主页：community.uwa4d.com UWA QQ群：793972859 From UWA AI 问答 Q：有什么指标可以判断手机是否降频？ A：判断是否发生降频的关键指标 可通过CPU与GPU频率曲线直接观察运行期间主频变化情况。若出现持续低于峰值频率或阶段性降频回弹的现象，说明硬件已触发动态调频机制。结合帧率波动与温度数据，若帧率下降同时伴随CPU/GPU温度上升但频率下降，则可判定为热致降频。当前硬件温度分析未发现明显过热或异常温升，且无显著温度驱动的降频迹象。 判断降频的核心指标 可通过报告中的CPU频率和GPU频率曲线直接观察运行期间是否出现主频下降。若频率低于设备峰值水平，尤其伴随帧率波动或性能下降，则表明已触发动态降频。 关联辅助指标分析 结合温度变化量（CPU/GPU/电池温度）与功率峰值数据：当温度持续升高并达到阈值，同时功耗较高，而频率开始回落，即可确认为热或功耗导致的降频。此外，每万帧耗电均值异常偏高也可能是带宽或GPU压力过大间接引发发热降频的信号。 关于CPU Bound的说明 即使DrawCall和三角面数相近，CPU Bound仍可能由逻辑更新、动画、物理或脚本开销引起。建议查看逻辑代码耗时、动画更新耗时等函数堆栈数据，定位具体模块瓶颈。 注意事项 若测试时设备连接USB或充电，功率与温度数据将失真，严重影响降频判断。务必在非充电状态下进行测试，以获取真实硬件行为。 综上，应重点查看CPU/GPU频率曲线与温度趋势联动情况，在真实供电环境下验证是否存在降频。 您可以前往UWA性能报告页，通过AI问答功能提出您关心的问题，期待您的使用和反馈。详细使用方式详见：《UWA AI问答上线：游戏开发问题秒级解惑》 From 问答社区 Q1：我看到社区中经常提到降低动画浮点数精度这种优化手段。这是针对FBX导入的动画吧？我们自己创建的AnimationClip还需要处理吗？ A：自己创建的一般内存比较小，不需要做这个处理。 Q2：动画曲线浮点精度优化，我理解是FBX导入的动画，有许多关键帧。浮点数精度优化之后的值是一样的，可以变成const。可以这么理解吗 ？ A：是的。 针对以上问题，有经验的朋友欢迎转至社区交流： https://answer.uwa4d.com/question/68f5b96d682c7e5cd61bfb52 无论是社区里开发者们的互助讨论，还是AI基于知识沉淀的快速反馈，核心都是为了让每一个技术难题都有解、每一次踩坑都有回响。本期分享分别来自UWA AI问答和UWA问答社区，希望这些从真实开发场景中提炼的经验，能直接帮你解决当下的技术卡点，也让你在遇到同类问题时，能更高效地找到破局方向。 封面图来源于网络 今天的分享就到这里。生有涯而知无涯，在漫漫的开发周期中，我们遇到的问题只是冰山一角，UWA社区愿伴你同行，一起探索分享。欢迎更多的开发者加入UWA社区。 UWA官网：www.uwa4d.com UWA社区：community.uwa4d.com UWA学堂：edu.uwa4d.com 官方技术QQ群：793972859
iOS框架内存中占用很高的ttc文件是否正常
1）iOS框架内存中占用很高的ttc文件是否正常 2）Constant Buffer在移动端的限制是多少 这是第448篇UWA技术知识分享的推送，精选了UWA社区的热门话题，涵盖了UWA问答、社区帖子等技术知识点，助力大家更全面地掌握和学习。 UWA社区主页：community.uwa4d.com UWA QQ群：793972859 无论是社区里开发者们的互助讨论，还是AI基于知识沉淀的快速反馈，核心都是为了让每一个技术难题都有解、每一次踩坑都有回响。本期分享分别来自UWA AI问答和UWA问答社区，希望这些从真实开发场景中提炼的经验，能直接帮你解决当下的技术卡点，也让你在遇到同类问题时，能更高效地找到破局方向。 From AI 问答 Q：用团结引擎Memory Profiler看iOS包的内存占用，这个可以定位iOS框架内存。发现里面有一个PingFang.ttc占用了74MB，不是在资源里，而是显示在iOS框架内存里面的，这个是正常的么？ A：该现象是正常的，无需过度担忧。 PingFang.ttc是苹果系统自带的苹方字体文件（TrueType Collection），属于iOS系统级资源。当应用首次使用系统默认字体（如在Text、TextMeshPro以外的UI元素中未指定字体）或系统进行文本渲染时，iOS会将该字体文件映射到应用的内存空间，因此在Memory Profiler的“iOS框架内存”或“Untracked Memory”部分中被统计为占用。 此部分内存由操作系统管理，不属于Unity托管堆或应用主动加载的Assets资源，因此不会出现在AssetBundle或资源列表中。其占用行为属于系统正常机制，且通常为共享内存（多个应用可能共用同一份字体数据的物理页），实际对整体PSS内存影响有限。 建议处理方式如下： 确认是否误用系统字体：检查项目中UI文本组件（尤其是Legacy UI Text）是否未指定自定义字体而依赖系统默认字体。若需统一视觉风格或降低对系统字体的依赖，建议使用嵌入式字体（.ttf/.otf）并打包至AssetBundle。 关注可优化内存区域：74MB的系统字体映射属正常范围，应优先关注Tracked Memory中的Managed Heap、Texture、Mesh等可控内存模块的优化。 避免不必要的文本渲染：减少动态生成大量文本对象的行为，合理使用对象池，防止因文本频繁创建导致系统字体缓存持续增长。 使用UWA GOT Online工具辅助分析：通过Resource模式下的内存自动采样，对比不同场景下PSS与Reserved Total的变化趋势，明确业务代码对内存的影响。 综上，PingFang.ttc占用74MB属于iOS系统正常行为，只要项目无异常卡顿或OOM崩溃，可视为合理内存使用。 分享链接： https://www.uwa4d.com/main/uwa-shared.html?shareId=29e9e0df-2c32-4b2e-9317-503e5891cfd8 From 问答社区 Q：CBuffer（Constant Buffer）在移动端一般大小限制是多少？ A：普通Shader的Constant Buffer，会受到Graphics API和硬件影响，可以用SystemInfo.maxConstantBufferSize获取。 通常除了GPU Instancing时，很难达到CBuffer的上限，所以一般不太关心这个，《Rendering 19--GPU Instancing》这篇文章里面有讲GPU Instancing的数量受到CBuffer的限制。 详见：《Rendering 19--GPU Instancing》第1.4 https://catlikecoding.com/unity/tutorials/rendering/part-19/ 原文如下图： 针对以上问题，有经验的朋友欢迎转至社区交流： https://answer.uwa4d.com/question/68d25f50682c7e5cd61bfb30 封面图来源于网络 今天的分享就到这里。生有涯而知无涯，在漫漫的开发周期中，我们遇到的问题只是冰山一角，UWA社区愿伴你同行，一起探索分享。欢迎更多的开发者加入UWA社区。 UWA官网：www.uwa4d.com UWA社区：community.uwa4d.com UWA学堂：edu.uwa4d.com 官方技术QQ群：793972859
游戏AI行为决策——GOAP（目标导向型行为规划）
【USparkle专栏】如果你深怀绝技，爱“搞点研究”，乐于分享也博采众长，我们期待你的加入，让智慧的火花碰撞交织，让知识的传递生生不息！ 一、前言 像先前提到的有限状态机、行为树、HTN，它们实现的AI行为，虽说能针对不同环境作出不同反应，但应对方法是写死了的。有限状态机终究是在几个状态间进行切换、行为树也是根据提前设计好的树来搜索……你会发现，游戏AI角色表现出的智能程度，终究与开发者的设计结构有关，就有限状态机而言，各个状态如何切换很大程度上就影响了AI智能的表现。 那有没有什么决策方法，能够仅需设计好角色需要的动作，而它自己就能合理决定要选择哪些动作完成目标呢？这样的话，角色AI的行为智能程度会更上一层楼，毕竟它不再被写死的决策结构束缚；我们在添加更多AI行为时，也可以简单地直接将它放在角色需要的动作集里就好，减少了工作量，不必像行为树那样，还要考虑节点间的连接。 没错，GOAP（目标导向型行为规划）就可以做到。但请注意，并不是说GOAP就比其它决策方法好，后面也会提到它的缺点。选择何种决策方法还得根据实际项目和自身需求。 PS：本教程需要你具备以下前提知识： 知道数据结构、堆/优先队列、栈、图。 知道A星寻路的流程，如不了解可看此视频[1]。 基本的位运算与位存储（能做到理解Unity中的Layer和LayerMask的程度就行）。 二、运行逻辑 我们来看个简单的寻路问题：你能找到从A到B的最短路线吗？注意，道路是单向的。 聪明如你，这并不难找到： 现在，加大难度，假设每条道路口都有一个门，红色表示门关上了，蓝色表示门开着，你还能找出可达成的最短A到B路线吗？ 同样不难： 这样就足够了，GOAP的规划就是这么一个过程。只是把每个节点都当成一个状态，每条道路都当作一个动作、道路长度作为动作代价、路口的门作为动作执行条件，然后像你这样寻找出一条可以执行的最短「路线」，并记录下途径的道路（注意，不是节点），这样就得到了「动作序列」，再让AI角色逐一执行。GOAP中的图会长成下面这样（只画出了一条路的样子，但相信你们能举一反三的）： GOAP就是在不断执行「从现有状态到目标状态」，上图中的「现有状态」和「目标状态」分别就是「饿」和「饱」。请注意，虽说用了不同形状，但中间的那些椭圆节点，比如「在上网」，也是和「饿」、「饱」同类别的存在。也就是说「在上网」也可以作为现有状态或目标状态。 可想而知，只要状态够多，动作够多，AI就能做出更复杂的动作。虽说这对其它决策方法也成立，但GOAP不需要我们手动设置各动作、状态之间的关系，它能自行规划出要做的一系列动作，更省事且更智能，甚至可以规划出超出原本设想但又合理的动作序列。 希望我讲明白了它的运作（如果还是感觉有点不懂，可以看看这个视频[2]），下面一起来实现一个简单的GOAP进一步了解吧！顺带提一下，在Unity资源商店有免费的GOAP插件，并且做了可视化处理以及多线程优化，各位真的想将GOAP运用于项目的话，更推荐去学习使用成熟的插件。 三、代码实现 本文「世界状态」的实现参考了GitHub上一C语言版本的GOAP[3]。 1. 世界状态 所谓「世界状态」其实就是存储所有的状态放在一块儿的合集。而状态其实还有一个隐藏身份——动作条件。是的，状态也充当了动作的执行条件，比如之前图中的条件「有流量」，它其实也是一个状态。 世界状态会因自然因素变化，比如「饱」会随着时间流逝而变「饿」；也会因角色自身的一些动作导致变化，比如一个角色多运动，也会使「饱」变「饿」。 问题在于： GOAP规划需要时时获取最新的状态，才能保证规划结果的合理性（否则饿晕了还想着运动）； 「世界状态」中有些状态是「共享」的，比如之前说的时间，但还有一些状态是私有的，比如「饱」，是我饱、你饱还是他饱？在一个合集里该如何区分？ 如果你看过上一篇关于HTN的文章的话，你会发现这是如此的眼熟。不过没看过也没关系，我们将采取一种新的实现「世界状态」的方法——原子表示。 PS：在传统人工智能Agent中，对于环境的表示方式有三种： 原子表示（Atomic）：就是单纯描述某个状态有无，通常每个状态都只用布尔值（True/False）表示就可以，比如「有流量」。 要素化表示（Factored）：进一步描述状态的具体数值，这时，状态可以有不同的类型，可以是字符串、整数、布尔值……在HTN中，我们就是用这种方式实现的。 结构化表示（Structured）：再进一步，每个状态不但描述具体数值，还存储于其它数据的连接关系，就像数据结构中「图」的节点那样。 接下来将采用位存储的方式进行原子表示，因为借助位运算可以方便且高效地实现比较，还省空间。缺点就是有些难懂，所以，我希望你了解如int、long的二进制存储方式或者Unity中LayerMask，再来看以下内容。当然，这段代码之后我也会做些举例说明，这个类还继承了三个接口，其用意也会在后面解释： using System; using System.Collections.Generic; /// /// 用位表示的世界状态 /// publicclassGoapWorldState : IAStarNode<GoapWorldState>, IComparable<GoapWorldState>, IEquatable<GoapWorldState> { publicconstint MAXATOMS = 64;//存储的状态数上限，由于用long类型存储，最多就是64（long类型为64位整数） publiclong Values//世界状态值 { get => values; set => values = value; } publiclong DontCare//标记未被使用的位 { get => dontCare; set => dontCare = value; } publiclong Shared => shared;//判断共享状态位 public GoapWorldState Parent { get; set; } publicfloat SelfCost { get; set; } publicfloat GCost { get; set; } publicfloat HCost { get; set; } publicfloat FCost => GCost + HCost; privatereadonly Dictionary<string, int> namesTable;//存储各个状态名字与其在values中的对应位，方便查找状态 privateint curNamsLen;//存储的已用状态的长度 privatelong values; privatelong dontCare; privatelong shared; /// /// 初始化为空白世界状态 /// public GoapWorldState() { //赋值0，可将二进制位全置0；赋值-1，可将二进制位全置1 namesTable = new Dictionary<string, int>(); values = 0L; //全置0，意为世界状态默认为false dontCare = -1L; //全置1，意为世界状态的位全没有被使用 shared = -1L; //将shard的位全置1 curNamsLen = 0; } /// /// 基于某世界状态的进一步创建，相当于复制状态设置但清空值 /// public GoapWorldState(GoapWorldState worldState) { namesTable = new Dictionary<string, int>(worldState.namesTable);//复制状态名称与位的分配 values = 0L; dontCare = -1L; curNamsLen = worldState.curNamsLen;//同样复制已使用的位长度 shared = worldState.shared;//保留状态共享性的信息 } /// /// 根据状态名，修改单个状态的值 /// /// 状态名 /// 状态值 /// 设置状态是否为共享 /// <returns>修改成功与否</returns> public bool SetAtomValue(string atomName, bool value = false, bool isShared = false) { var pos = GetIdxOfAtomName(atomName);//获取状态对应的位 if (pos == -1) returnfalse;//如果不存在该状态，就返回false //将该位 置为指定value var mask = 1L << pos; values = value ? (values | mask) : (values & ~mask); dontCare &= ~mask;//标记该位已被使用 if (!isShared)//如果该状态不共享，则修改共享位信息 { shared &= ~mask; } returntrue;//设置成功，返回true } public void Clear() { values = 0L; namesTable.Clear(); curNamsLen = 0; dontCare = -1L; } /// /// 通过状态名获取单个状态在Values中的位，如果没包含会尝试添加 /// /// 状态名 /// <returns>状态所在位</returns> private int GetIdxOfAtomName(string atomName) { if(namesTable.TryGetValue(atomName, outint idx)) { return idx; } if(curNamsLen < MAXATOMS) { namesTable.Add(atomName, curNamsLen); return curNamsLen++; } return-1; } //——————————三个接口需要实现的函数—————————— public float GetDistance(GoapWorldState otherNode) { } public List<GoapWorldState> GetSuccessors(object nodeMap) { } public int CompareTo(GoapWorldState other) { } public bool Equals(GoapWorldState other) { } public override int GetHashCode() { } } 我们以添加两个状态为例，相信看了这个，你会更容易理解相关函数的内容。虽说总共有64位世界状态，但这里只看4位： 将世界状态分为「私有」和「共享」，我们就可以让角色更新「私有」部分，而全局系统更新「共享」部分。当需要角色规划时，我们就用位运算将该角色的「私有」与世界的「共享」进行整合，得到对于这个角色而言的当前世界状态。这样对于不同角色，它们就能得到对各自的而言的世界状态啦！ 如果去除注释，这个类的内容其实并不多，在使用时几乎只要用到SetAtomValue函数，像这样： worldState = new GoapWorldState(); worldState.SetAtomValue("血量健康", true); worldState.SetAtomValue("大半夜", false, true); 接下来就是那三个接口了，首先是IAStarNode，前文稍提过：「世界状态」是图中的结点，「动作」都是图中的边，这是我用以辅助「泛用A星搜索器」的结点接口，本文就不赘述了，只要知道：继承了这个类，都可以作为A星搜索中的结点，从而参与搜索。完整代码如下： using System.Collections.Generic; publicinterfaceIAStarNode<T> whereT : IAStarNode<T> { public T Parent { get; set; }//父节点，通过泛型使它的类型与具体类一致 publicfloat SelfCost { get; set; }//自身单步花费代价 publicfloat GCost { get; set; }//记录g(n)，距初始状态的代价 publicfloat HCost { get; set; }//记录h(n)，距目标状态的代价 publicfloat FCost { get; }//记录f(n)，总评估代价 /// /// 获取与指定节点的预测代价 /// public float GetDistance(T otherNode); /// /// 获取后继（邻居）节点 /// /// 寻路所在的地图，类型看具体情况转换， /// 故用object类型 /// <returns>后继节点列表</returns> public List<T> GetSuccessors(object nodeMap); /* IComparable实现的CompareTo函数，主要用于优先队列的比较； 一般比较可用以下函数 public int CompareTo(AStarNode other) { var res = (int)(FCost - other.FCost); if(res == 0) res = (int)(HCost - other.HCost); return res; }*/ /* IEquatable实现的Equals函数，可以自定义HashSet和Dictionary的Contains判断依据（但同样要重写GetHashCode） 以及在寻路时用于比对某点是否为终点，可以根据类的特点自行继承 */ } 这段代码的注释也说明了另外两个接口的用意。 2. 动作 我们之前说过，动作包含一个「前提条件」，其实和HTN一样，它还包含一个「行为影响」，相当于之前图中道路指向的椭圆表示的状态。它们也都是世界状态，注意是世界状态，而不是单个状态！ 为什么不设置成单个？首先，「前提条件」和「行为影响」本身就可能是多个状态组合成的，用单个不合适；其次，将它们也设置成世界状态（64位的long类型），方便进行统一处理与位运算。Unity中的Layer也是这样的。 只有当前世界状态与「前提条件」对应位的值相同时，才算满足前提条件，这个动作才有被选择的机会。而动作一旦执行成功，世界状态就会发送变化，对应位上的值会被赋值为「行为影响」所设置的值。 /// /// Goap动作，也是Goap图中的边 /// publicclassGoapAction { publicint Cost{ get; privateset; } //动作代价，作为AI规划的依据 public GoapWorldState Precondition => precondition; public GoapWorldState Effect => effect; privatereadonly GoapWorldState precondition; //动作得以执行的前提条件 privatereadonly GoapWorldState effect; //动作成功执行后带来的影响，体现在对世界状态的改变 /// /// 根据给定世界状态样式创建「前提条件」和「行为影响」， /// 这为了让它们的位与世界状态保持一致，方便进行位运算 /// /// 作为基准的世界状态 /// 动作代价 public GoapAction(GoapWorldState baseState, int cost = 1) { Cost = cost; precondition = new GoapWorldState(baseState); effect = new GoapWorldState(baseState); } /// /// 判断是否满足动作执行的前提条件 /// /// 当前世界状态 /// <returns>是否满足前提</returns> public bool MetCondition(GoapWorldState worldState) { var care = ~precondition.DontCare; return (precondition.Values & care) == (worldState.Values & care); } //--------------------------------------------------------------- /// /// 判断世界状态是否可由执行影响导致 /// /// 当前世界状态 /// <returns>是否能导致</returns> public bool MetEffect(GoapWorldState worldState) { var care = ~effect.DontCare; return (effect.Values & care) == (worldState.Values & care); } //---------------------------------------------------------------- /// /// 动作实际执行成功的影响 /// /// 实际世界状态 public void Effect_OnRun(GoapWorldState worldState) { worldState.Values = ((worldState.Values & effect.DontCare) | (effect.Values & ~effect.DontCare)); } /// /// 设置动作前提条件，利用元组，方便一次性设置多个 /// public GoapAction SetPrecontidion(params (string, bool)[] atomName) { foreach(var atom in atomName) { precondition.SetAtomValue(atom.Item1, atom.Item2); } returnthis; } /// /// 设置动作影响 /// public GoapAction SetEffect(params (string, bool)[] atomName) { foreach (var atom in atomName) { effect.SetAtomValue(atom.Item1, atom.Item2); } returnthis; } public void Clear() { precondition.Clear(); effect.Clear(); } } 你可能发现了这个动作类的奇怪之处——它没有像OnRunning或OnUpdate之类的动作执行函数，这样一来要如何执行动作？是的，这个类主要是用来充当图的边，来连接各个状态，它会作为<string, GoapAction>字典中的值，并于一个动作名字符串绑定。我们会通过动作名，再查找另一个同样以动作名为键、但值为事件的字典，找到对应的事件，这个事件才是真正运行的动作函数。 这样岂不多此一举？其实这是为了提高GOAP图的重用性。如果GOAP中的道路并不是真正的动作函数，而是用了动作名来标记。那么我们可以为多个角色设计同一种动作，但不同的表现。比如「攻击」动作，在弓箭手中就是射击函数，枪手中就是开火函数……这样一来，即便不同角色都可以使用同一张GOAP图，不用重复创建（除非有特殊需求）。 这样是GOAP的一般做法，只用少数GOAP图，而不同角色可以共同使用一张GOAP图来进行互不干扰的规划。这可以省很多代码量，试想在有限状态机中，不做特殊处理你都无法让不同敌人共用「攻击」状态，就得不断写大同小异的代码。GOAP的这种将结构与逻辑分离的做法，就可以很方便地复用结构或进行定制化设计，也是其优势之一。 PS：GOAP图也得用「图」这一数据结果存储，而这种数据结构在C# 中是没有提供的，得自己实现，这里我给个简单的，方便后续其他功能（如果你有自己的一套，也可以用自己的，只是后续文章中相应的函数要进行替换）： public classMyGraph<TNode, TEdge> { publicreadonly HashSet<TNode> NodeSet;//节点列表 publicreadonly Dictionary<TNode, List<TNode>> NeighborList;//邻居列表 publicreadonly Dictionary<(TNode, TNode), List<TEdge>> EdgeList;//边列表 public MyGraph() { NodeSet = new HashSet<TNode>(); NeighborList = new Dictionary<TNode, List<TNode>>(); EdgeList = new Dictionary<(TNode, TNode), List<TEdge>>(); } /// /// 寻找指定节点 /// /// <returns>找到的节点，没找到时返回null</returns> public TNode FindNode(TNode node) { NodeSet.TryGetValue(node, out TNode res); return res; } /// /// 寻找指点起、终点之间直接连接的所有边 /// /// 起点 /// 终点 /// <returns>找到的边，没找到时返回null</returns> public List<TEdge> FindEdge(TNode source, TNode target) { var s = FindNode(source); var t = FindNode(target); if (s != null && t != null) { var nodePairs = (s, t); if (EdgeList.ContainsKey(nodePairs)) { return EdgeList[nodePairs]; } } returnnull; } /// /// 添加节点，用HashSet，包含重复检测 /// public bool AddNode(TNode node) { return NodeSet.Add(node); } /// /// （前提是边两端结点已添加进图）添加指定边，含空节点判断、重复添加判断 /// /// 边起点 /// 边终点 /// 指定边 /// <returns>添加成功与否</returns> public bool AddEdge(TNode source, TNode target, TEdge edge) { var s = FindNode(source); var t = FindNode(target); if (s == null || t == null) returnfalse; var nodePairs = (s, t); if(!EdgeList.ContainsKey(nodePairs)) { EdgeList.Add(nodePairs, new List<TEdge>()); } var allEdges = EdgeList[nodePairs]; if(!allEdges.Contains(edge)) { allEdges.Add(edge); if(!NeighborList.ContainsKey(source)) { NeighborList.Add(source, new List<TNode>()); } NeighborList[source].Add(target); returntrue; } returnfalse; } /// /// 移除指定节点 /// /// <returns>移除成功与否</returns> public bool RemoveNode(TNode node) { return NodeSet.Remove(node); } /// /// 移除指定起、终点的指定边 /// /// 边起点 /// 边终点 /// 指定边 /// <returns>移除成功与否</returns> public bool RemoveEdge(TNode source, TNode target, TEdge edge) { var allEdges = FindEdge(source, target); return allEdges != null && allEdges.Remove(edge); } /// /// 移除指定起、终点的所有边 /// /// 边起点 /// 边终点 /// <returns>移除成功与否</returns> public bool RemoveEdgeList(TNode source, TNode target) { return EdgeList.Remove((source, target)); } /// /// 获取指定节点可抵达的所有邻居节点 /// public List<TNode> GetNeighbor(TNode node) { NeighborList.TryGetValue(node, out List<TNode> res); return res; } /// /// 获取指定节点所延伸出的所有边 /// public List<TEdge> GetConnectedEdge(TNode node) { var resEdge = new List<TEdge>(); var neighbor = GetNeighbor(node); for(int i = 0; i < neighbor.Count; ++i) { var curEdgeList = EdgeList[(node, neighbor[i])]; for(int j = 0; j < curEdgeList.Count; ++j) { resEdge.Add(curEdgeList[j]); } } return resEdge; } } 3. A星节点 接下来要实现的就是那三个接口所需的函数了，这三个接口其实都是为了方便寻找「路径」，GOAP会采用启发式搜索，就像A星寻路所用的那样。所谓「启发式搜索」就是有按照一定「启发值」进行的搜索，它的反面就是「盲目搜索」，如深度优先搜索、广度优先搜索。启发式搜索需要设计「启发函数」来计算「启发值」。 在A星寻路中，我们通过计算「当前位置离起点的距离 + 当前位置离终点的距离」做为启发值来寻找最短路径；类似的，在我们实现的这个GOAP中，我们会通过计算「起点状态至当前状态累计的动作代价 + 当前状态与目标状态的相关度」作为启发值。 累计代价，也相当于与起始状态的「距离」；与目标状态的相关度，在世界状态类中已经说明了，就是比较当前状态与目标状态的有效位的值有多少是相同的，通常相同的越多就越接近。当然，思路不唯一，可以搜索《数据挖掘》相关的文章，了解更多关于数据相关度的计算。 PS：在寻路时，常需要选取已探索过的节点中具有最小启发值的节点。用遍历倒也能做到，但总归效率不高，故可以用「堆」，也就是「优先队列」： //堆属于常用数据结构中的一种，我默认大家都会了，原理就不加以注释说明了 publicclassMyHeap<T> whereT : IComparable<T> { publicint CurLength {get; privateset;} publicreadonlyint capacity; publicbool IsFull => CurLength == capacity; publicbool IsEmpty => CurLength == 0; public T Peak => heapArr[0]; privatereadonlybool isReverse; privatereadonly T[] heapArr; privatereadonly Dictionary<T, int> idxTable; //记录结点在数组中的位置，方便查找 public MyHeap(int size, bool isReverse = false) { CurLength = 0; capacity = size; heapArr = new T[size]; idxTable = new Dictionary<T, int>(); this.isReverse = isReverse; } public void Push(T value) { if(!IsFull) { if (idxTable.ContainsKey(value)) idxTable[value] = CurLength; else idxTable.Add(value, CurLength); heapArr[CurLength] = value; Swim(CurLength++); } } public void Pop() { if(!IsEmpty) { idxTable[heapArr[0]] = -1; heapArr[0] = heapArr[--CurLength]; idxTable[heapArr[0]] = 0; Sink(0); } } public bool Contains(T value) { return idxTable.ContainsKey(value) && idxTable[value] > -1; } public T Find(T value) { return Contains(value) ? heapArr[idxTable[value]] : default; } public void Clear() { idxTable.Clear(); CurLength = 0; } private void Swim(int index) { int father; while(index > 0) { father = (index - 1) / 2; if(IsBetter(heapArr[index], heapArr[father])) { SwapValueByIndex(father, index); index = father; } elsereturn; } } private void Sink(int index) { int best, left = index * 2 + 1, right; while(left < CurLength) { right = left + 1; best = right < CurLength && IsBetter(heapArr[right], heapArr[left]) ? right : left; if(IsBetter(heapArr[best], heapArr[index])) { SwapValueByIndex(best, index); index = best; left = index * 2 + 1; } elsereturn; } } private void SwapValueByIndex(int i, int j) { (heapArr[j], heapArr[i]) = (heapArr[i], heapArr[j]); idxTable[heapArr[i]] = i; idxTable[heapArr[j]] = j; } private bool IsBetter(T v1, T v2) { return isReverse ^ v1.CompareTo(v2) < 0; } } 三个接口所需的函数实现如下： /// /// 用位表示的世界状态 /// publicclassGoapWorldState : IAStarNode<GoapWorldState>, IComparable<GoapWorldState>, IEquatable<GoapWorldState> { …… /// /// 计算该世界状态与指定世界状态的差异度 /// public float GetDistance(GoapWorldState otherNode) { var care = otherNode.dontCare ^ -1L; var diff = (values & care) ^ (otherNode.values & care); int dist = 0; //统计有多少位是不同的，以表示差异度 for (int i = 0; i < MAXATOMS;++i) { /*diff的位不为1，则表示不同*/ if ((diff & (1L << i)) != 0) ++dist; // 差异越多，距离越大 } return dist; } public List<GoapWorldState> GetSuccessors(object nodeMap) { var goapActionSet = nodeMap as GoapActionSet; var actionMap = goapActionSet.actionGraph; var res = actionMap.GetNeighbor(this); //根据找到的动作，对抵达下个结点的代价进行计算 for(int i = 0; i < res.Count; ++i) { res[i].SelfCost = goapActionSet.actionSet[actionMap.FindEdge(this, res[i])[0]].Cost; } return res; } public int CompareTo(GoapWorldState other) { var res = (int)(FCost - other.FCost); if(res == 0) res = (int)(HCost - other.HCost); return res; } public bool Equals(GoapWorldState other) { /*后文提及的所使用的A星搜索器中，总是「动作的条件」对比「当前的世界状态」，即currentNode.Equals(target) 如「动作的条件」：饿-true，而「当前的世界状态」：饿-true，累-true，困-true；显然此时世界状态应当满足条件 这样可以避免当前世界状态过于“包容”却被误判不满足*/ return (values & ~dontCare) == (other.values & ~dontCare); } public override int GetHashCode() { return HashCode.Combine(values & ~dontCare, dontCare); } } 4. 动作集 照理说，动作集不过是动作的合集，单独将它也制成一个类，是为了方便「动作序列」规划，主要体现在GetPossibleTrans函数，根据传入的节点的世界状态，在合集中遍历出「前提条件」满足的动作： using System.Collections.Generic; publicclassGoapActionSet { public MyGraph<GoapWorldState, string> actionGraph; // 动作与状态构成的图 privatereadonly Dictionary<string, GoapAction> actionSet; public GoapActionSet() { actionGraph = new MyGraph<GoapWorldState, string>(); actionSet = new Dictionary<string, GoapAction>(); } public GoapAction this[string idx] { get => actionSet[idx]; } /// /// 添加动作至动作集合中 /// /// 动作名 /// 对应动作 /// <returns>动作集，方便连续添加</returns> public GoapActionSet AddAction(string actionName, GoapAction newAction) { actionSet.Add(actionName, newAction); actionGraph.AddNode(newAction.Effect); actionGraph.AddNode(newAction.Precondition); actionGraph.AddEdge(newAction.Effect, newAction.Precondition, actionName); returnthis; } /// /// 返回两个状态转化的动作名 /// /// 起点状态 /// 状态后的状态 /// <returns>所需执行动作名</returns> public string GetTransAction(GoapWorldState from, GoapWorldState to) { return actionGraph.FindEdge(from, to)[0]; } } 5. A星寻路 一切条件都准备好了，现在实现下用来「寻路」的类。首先，我们会进行反向搜索，意思是说，我们不会「起始状态-->目标状态」，而是「目标状态-->起始状态」，如果成功找到，就将得到的动作序列逆向执行。 为什么这么麻烦？其实恰恰相反，这还是一种简化。如果真的「起始状态-->目标状态」，未必最终会找到目标状态（因为有可能能抵达的动作暂时条件不满足）；但反向搜索，必定会包含目标状态，也一定会找到一条路（因为总会抵达一个当前已经符合的世界状态，否则就是设计的有问题了），只不过可能不是最短的。 我们也能接受这种结果，虽说非最优解，但这种不确定因素，也变相让AI增加了点随机性，更接近真实决策情况。 它的整体搜索过程和A星寻路是一样的，直接用「泛用A星搜索器」即可： using System; using System.Collections.Generic; using JufGame.Collections.Generic; /// /// A星搜索器，T_Node额外实现IComparable用于优先队列的比较，实现IEquatable用于HashSet和Dictionary等同一性的判断 /// /// <typeparam name="T_Map">搜索的图类</typeparam> /// <typeparam name="T_Node">搜索的节点类</typeparam> publicclassAStar_Searcher<T_Map, T_Node> whereT_Node: IAStarNode<T_Node>, IComparable<T_Node>, IEquatable<T_Node> { privatereadonly HashSet<T_Node> closeList;//探索集 privatereadonly MyHeap<T_Node> openList;//边缘集 privatereadonly T_Map nodeMap;//搜索空间（地图） public AStar_Searcher(T_Map map, int maxNodeSize = 200) { nodeMap = map; closeList = new HashSet<T_Node>(); //maxNodeSize用于限制路径节点的上限，避免陷入无止境搜索的情况 openList = new MyHeap<T_Node>(maxNodeSize); } /// /// 搜索（寻路） /// /// 起点 /// 终点 /// 返回生成的路径 public void FindPath(T_Node start, T_Node target, Stack<T_Node> pathRes) { T_Node currentNode; pathRes.Clear();//清空路径以备存储新的路径 closeList.Clear(); openList.Clear(); openList.PushHeap(start); while (!openList.IsEmpty) { currentNode = openList.Top;//取出边缘集中最小代价的节点 openList.PopHeap(); closeList.Add(currentNode);//拟定移动到该节点，将其放入探索集 if (currentNode.Equals(target) || openList.IsFull)//如果找到了或图都搜完了也没找到时 { GenerateFinalPath(start, currentNode, pathRes);//生成路径并保存到pathRes中 return; } UpdateList(currentNode, target);//更新边缘集和探索集 } } private void GenerateFinalPath(T_Node startNode, T_Node endNode, Stack<T_Node> pathStack) { pathStack.Push(endNode);//因为回溯，所以用栈储存生成的路径 var tpNode = endNode.Parent; while (!tpNode.Equals(startNode)) { pathStack.Push(tpNode); tpNode = tpNode.Parent; } pathStack.Push(startNode); } private void UpdateList(T_Node curNode, T_Node endNode) { T_Node sucNode; float tpCost; bool isNotInOpenList; var successors = curNode.GetSuccessors(nodeMap);//找出当前节点的后继节点 if(successors == null) { return; } for (int i = 0; i < successors.Count; ++i) { sucNode = successors[i]; if (closeList.Contains(sucNode))//后继节点已被探索过就忽略 continue; tpCost = curNode.GCost + sucNode.SelfCost; isNotInOpenList = !openList.Contains(sucNode); if (isNotInOpenList || tpCost < sucNode.GCost) { sucNode.GCost = tpCost; sucNode.HCost = sucNode.GetDistance(endNode);//计算启发函数估计值 sucNode.Parent = curNode;//记录父节点，方便回溯 if (isNotInOpenList) { openList.PushHeap(sucNode); } } } } } 6. 代理器 我们最后创建一个「代理器」，它用来整合了上述内容，并统筹运行： public enum EStatus { Failure, Success, Running, Aborted, Invalid } publicclassGoapAgent { privatereadonly GoapActionSet actionSet; //动作集 publicreadonly GoapWorldState curSelfState; //当前自身状态，主要是存储私有状态 privatereadonly AStar_Searcher<GoapActionSet, GoapWorldState> goapAStar; privatereadonly Dictionary<string, Func<EStatus>> actionFuncs; //各动作名字对应的动作函数 private Stack<string> actionPlan;//存储规划出的动作序列 private Stack<GoapWorldState> path; private EStatus curState;//存储当前动作的执行结果 privatebool canContinue;//是否能够继续执行，记录动作序列全部是否执行完了 private GoapAction curAction;//记录当前执行的动作 private Func<EStatus> curActionFunc;//记录当前运行的动作函数 /// /// 初始化代理器 /// /// 世界状态，用来复制成自身状态 /// 动作集 public GoapAgent(GoapWorldState baseWorldState, GoapActionSet actionSet) { curSelfState = new GoapWorldState(baseWorldState) { DontCare = baseWorldState.DontCare }; actionFuncs = new Dictionary<string, Func<EStatus>>(); actionPlan = new Stack<string>(); this.actionSet = actionSet; goapAStar = new AStar_Searcher<GoapActionSet, GoapWorldState>(this.actionSet); path = new Stack<GoapWorldState>(); } /// /// 修改自身状态值 /// public bool SetAtomValue(string stateName, bool value) { return curSelfState.SetAtomValue(stateName, value); } /// /// 为动作名设置对应的动作函数 /// public void SetActionFunc(string actionName, Func<EStatus> func) { actionFuncs.Add(actionName, func); } /// /// 规划GOAP并运行 /// /// /// public void RunPlan(GoapWorldState curWorldState, GoapWorldState goal) { UpdateSelfState(curWorldState);//将自身的私有状态与世界的共享状态融合，得到真正的「当前世界状态」 if (curState == EStatus.Failure) //当前状态为「失败」，就表示动作执行失败 { //那就重新规划，找出新的动作序列 actionPlan.Clear(); goapAStar.FindPath(goal, curSelfState, path); //通过状态序列得到动作序列 path.TryPop(outvar cur); while(path.Count != 0) { actionPlan.Push(actionSet.GetTransAction(cur, path.Peek())); cur = path.Pop(); } } if(curState == EStatus.Success)//执行结果为「成功」，表示动作顺利执行完 { curAction.Effect_OnRun(curWorldState); //动作就会对全局世界状态造成影响 /*这同样要更新自身状态，以防这次改变的是「私有」状态，全局世界状态可是只维护「共享」部分。 所以需要自身状态也记录下这次影响，即便是共享状态也没关系，反正下次会与世界的共享状态融合*/ curAction.Effect_OnRun(curSelfState); } //如果执行结果不是「运行中」，就表示上个动作要么成功了，要么失败了。都该取出动作序列中新的动作来执行 if (curState != EStatus.Running) { canContinue = actionPlan.TryPop(outstring curActionName); if (canContinue)//如果成功取出动作，就根据动作名，选出对应函数和动作 { curActionFunc = actionFuncs[curActionName]; curAction = actionSet[curActionName]; } } curState = canContinue && curAction.MetCondition(curSelfState) ? curActionFunc() : EStatus.Failure; } /// /// 中断当前Goap执行 /// public void AbortedGoapCurState() { curState = EStatus.Aborted; } /// /// 更新自身状态的共享部分与当前世界状态同步 /// private void UpdateSelfState(GoapWorldState curWorldState) { curSelfState.Values = (curWorldState.Values & curWorldState.Shared) | (curSelfState.Values & ~curWorldState.Shared); } } 注意，代码里的这个部分，因为A星搜索得到的是结点——也就是状态，但我们所需要的是链接状态的动作，所以要再「加工」一下： goapAStar.FindPath(goal, curSelfState, path); //通过状态序列得到动作序列 path.TryPop(out var cur); while(path.Count != 0) { actionPlan.Push(actionSet.GetTransAction(cur, path.Peek())); cur = path.Pop(); } 这个类中，RunPlan函数与上一期的HTN中的基本一样。但我想可能有些人还不大明白UpdateSelfState函数是如何融合自身状态与世界状态的，我就简单举个例： 可以看到得到的值，恰好保留了世界状态的共享部分和自身状态的私有部分。其实这也并非「恰好」，这样的位运算理应得到这样的结果才是。你也可以自己动手尝试一些值或者用更多位的数来验证。 四、尾声 GOAP的缺点主要是在设计难度上，它的设计相较FSM、行为树那些不那么直接，你需要把控好动作的条件和影响对应的状态，比其它决策方法更费脑子些。因为GOAP没有显示的结构，如何定义好一个状态，使它能在逻辑层面合理地成为一个动作的前提条件，又能成为另一个动作条件的影响结果（比如「有流量」，想想看，将其做为条件可以设计什么动作？作为影响结果又应该怎么设计呢？）是比较考验开发人员的架构设计的。但毋庸置疑的是，在面对较复杂的AI时，它的代码量一定是小于FSM、行为树和HTN的。而且添加和减少动作也不需要进行过多代码修改，只要将新行动加入到动作集或将欲剔除的动作从动作集中删去就可以，这也是它没有显式结构的好处。 这里也简单用上文所学内容做一个简单的太空射击飞船敌人的AI：gitee项目[4] 在EnemyConfig中为敌人指定了GOAP图并共用，一个非常简单的敌人逻辑（只是用GOAP实现了而已）：当敌人健康时会尝试瞄准玩家后射击，当玩家弱势（无力）时，敌人追击玩家；当敌人自身不安全时会退避并以较低命中率的方式射击： goal = new GoapWorldState(WarSpaceManager.worldState); goal.SetAtomValue("击杀玩家", true); actionSet = new GoapActionSet(); actionSet .AddAction("低命中射击", new GoapAction(WarSpaceManager.worldState, 1) .SetPrecontidion(("安全区内", true)) .SetEffect(("击杀玩家", true))) .AddAction("追击", new GoapAction(WarSpaceManager.worldState, 4) .SetPrecontidion(("弹药充足", true), ("玩家无力", true)) .SetEffect(("击杀玩家", true))) .AddAction("瞄准玩家", new GoapAction(WarSpaceManager.worldState, 3) .SetPrecontidion(("瞄准就绪", false)) .SetEffect(("瞄准就绪", true))) .AddAction("射击", new GoapAction(WarSpaceManager.worldState, 2) .SetPrecontidion(("瞄准就绪", true)) .SetEffect(("击杀玩家", true))) .AddAction("躲避", new GoapAction(WarSpaceManager.worldState, 1) .SetPrecontidion(("安全", false)) .SetEffect(("安全区内", true))); 到这里就结束了。 参考： [1] A星寻路的流程视频 [2] 视频 [3] C语言版本的GOAP [4] gitee项目 这是侑虎科技第1889篇文章，感谢作者狐王驾虎供稿。欢迎转发分享，未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859） 作者主页：https://home.cnblogs.com/u/OwlCat 再次感谢狐王驾虎的分享，如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859）
1）TextureStreaming针对不同分档机型一般怎么设置 2）不升级Unity引擎小改动快速适配安卓16KB页面要求 这是第447篇UWA技术知识分享的推送，精选了UWA社区的热门话题，涵盖了UWA问答、社区帖子等技术知识点，助力大家更全面地掌握和学习。 UWA社区主页：community.uwa4d.com UWA QQ群：793972859 无论是社区里开发者们的互助讨论，还是AI基于知识沉淀的快速反馈，核心都是为了让每一个技术难题都有解、每一次踩坑都有回响。本期分享分别来自UWA AI问答和UWA问答社区，希望这些从真实开发场景中提炼的经验，能直接帮你解决当下的技术卡点，也让你在遇到同类问题时，能更高效地找到破局方向。 From AI 问答 Q：想问问TextureStreaming的选项有没有推荐值，针对高中低机型一般怎么设置呢？ A：关于Texture Streaming的参数设置，需结合设备性能分级（高中低）进行精细化配置，以在画面表现与内存占用间取得最佳平衡。以下是针对不同机型的推荐策略： 核心参数说明 Memory Budget：纹理内存预算，决定Unity开始管理Mipmap加载的阈值。所有纹理（含非流式）均计入此预算。 Max Level Reduction：允许丢弃的最大Mipmap层数级数，优先级高于Memory Budget。例如设为2，则最多只加载Mipmap 2及更小层级。 Add All Cameras：默认开启，对所有摄像机启用串流。若需精细控制，可关闭并手动为特定摄像机添加Streaming Controller组件。 Renderers Per Frame：每帧处理的Renderer数量，影响CPU开销。建议根据帧率动态调整，避免卡顿。 高中低机型设置推荐 1. 高端机型 目标：追求极致画质，充分利用硬件资源。 Memory Budget：512MB~1GB（确保略高于 Texture.desiredTextureMemory）。 Max Level Reduction：0或1（尽可能保留高分辨率Mipmap）。 其他： 可适当提高Async Upload Time Slice和Buffer Size，加快资源上传速度。 启用streamingTextureDiscardUnusedMips = false，利用缓存减少重复IO。 优势：画面细腻，无明显纹理“popping”现象。 注意：监控targetTextureMemory，避免超出物理内存导致OOM。 2. 中端机型 目标：平衡画质与性能，控制内存峰值。 Memory Budget：200MB~300MB。 Max Level Reduction：1~2。 Renderers Per Frame：256~512（根据实际帧率微调）。 Async Upload： asyncUploadTimeSlice：4ms asyncUploadBufferSize：16MB asyncUploadPersistentBuffer = true 推荐做法：通过真机测试获取desiredTextureMemory，将Budget设为其1.2倍左右，留出余量。 3. 低端机型 目标：保障流畅运行，严控内存。 Memory Budget：100MB~150MB。 Max Level Reduction：2~3（强制降级，节省内存）。 Texture Quality：可配合使用“Half Res”模式进一步压缩非关键纹理。 Renderers Per Frame：128~256，降低每帧CPU开销。 强制代码开启： QualitySettings.streamingMipmapsActive = true; 避免编辑器设置在移动端失效。 关键点：UI纹理务必关闭Streaming Mipmap，因其无需远近LOD，开启反而浪费计算和内存。 关键实践建议 1. 必须代码开启Streaming 仅在 Quality Settings 中勾选可能在移动端无效，务必在启动时调用： SystemInfo.supportsMipStreaming // 先检测是否支持 QualitySettings.streamingMipmapsActive = true; 2. 真机测试为准 Editor中纹理内存远高于真机（路径不同），一切优化效果验证必须在目标设备上进行。 3. 合理启用对象 仅对场景模型贴图启用Streaming Mipmap。 UI、图标、不带Mipmap的纹理应关闭。 确保纹理开启Generate Mipmap且未勾选Read/Write Enabled（CPU内存不受Streaming影响）。 4. 动态调整策略参考 使用以下公式估算摄像机切换延迟最小时间： 最短延迟帧数 = (Texture.streamingRendererCount + RenderersPerFrame - 1) / RenderersPerFrame 结合Texture.streamingTexturePendingLoadCount判断是否完成加载，实现平滑镜头切换。 5. Lightmap支持串流 在Player Settings中启用Lightmap Streaming Enabled，同样受Budget与Max Level Reduction控制。 总结 最终配置应基于真实项目资源规模与目标设备Profile数据确定，建议结合UWA GOT Online报告中的纹理内存模块进行量化分析与迭代调优。 分享链接： https://www.uwa4d.com/main/uwa-shared.html?shareId=1d04bf11-027a-4d83-9ebf-f51f7c8a9407 From 问答社区 分享：不升级Unity引擎小改动快速适配安卓16KB页面要求 16KB Google Play兼容性要求 自2025年11月1日起，提交到Google Play且以Android 15及更高版本为目标平台的所有新应用和现有应用更新都必须在64位> 设备上支持16KB页面大小。 https://developer.android.com/guide/practices/page-sizes *上述网址需要使用VPN打开 Unity官方提供了升级引擎的方案： https://discussions.unity.com/t/info-unity-engine-support-for-16-kb-memory-page-sizes-android-15/1589588 如果项目还在使用古老的2018、2019等引擎不好升级怎么办？ 这里介绍一个方法： 一般Unity项目主要涉及以下几个so需要处理： libunity.so libil2cpp.so lib_burst_generated.so libmain.so 根据谷歌的文档，老版本NDK在链接so阶段需要传递以下参数即可完成适配： LOCAL_LDFLAGS += "-Wl,-z,max-page-size=16384" LOCAL_LDFLAGS += "-Wl,-z,common-page-size=16384" 那么我们逐个来看： 1.libunity.so 我们知道，项目勾选了Strip Engine Code的时候Unity是使用.a根据项目情况动态链接一个libunity.so出来的，所以我们很容易插入2这行，链接相关的参数对应的文件位于：PlaybackEngines\AndroidPlayer\Variations\il2cpp\Release\StaticLibs\arm64-v8a\link.rsp -Wl,-z,max-page-size=16384 -Wl,-z,common-page-size=16384 加入这两行即可。 2.libil2cpp.so IL2CPP支持自定义参数，可以通过以下函数实现增加链接参数： PlayerSettings.SetAdditionalIl2CppArgs("--linker-flags=\"-Wl,-z,max-page-size=16384 -Wl,-z,common-page-size=16384\""); 调用一次即可，以上参数会保存在ProjectSettings.asset的additionalIl2CppArgs中。 3.lib_burst_generated.so 如果使用了burst，有条件的话可以直接升级到1.8.21+（参考以上官方链接），也可以自己魔改：使用dnSpy修改burst包.Runtime\Burst.Compiler.IL.dll的AotNativeLinkAndroid类的Link函数，添加那两行参数即可。 4.libmain.so 这个so是Unity已经编译好的，没法自己链接，好在它几乎没怎么改过，可以从已经支持了的高版本拷贝一个过来覆盖使用。 补充：上面解决的是so文件ELF的LOAD段的16KB对齐问题。 APK还存在2种模式： 新模式：安装应用的时候so文件不解压出来，so在APK中是不压缩的，这种情况是通过mmap直接将APK中的so映射到内存使用的。这要求两点：so本身16K对齐 + so在APK中的偏移也要是16K对齐。 旧模式：so在APK中是压缩存储的，安装时会解压so文件，这种情况只要求so本身对齐。可以通过useLegacyPackaging true开启，AndroidManifest.xml中extractNativeLibs会被设置为true。 https://developer.android.com/guide/practices/page-sizes#update-packaging *上述网址需要使用VPN打开 互动提问：不更换Unity版本，这块会不会有问题？ https://developer.android.com/guide/practices/page-sizes?hl=zh-cn#check-code A：目前未发现问题，游戏能正常运行。 互动提问：Unity 2019按上面步骤处理完，so 16KB对齐了，useLegacyPackaging=true，但在Android模拟器16KB镜像上，还是会崩溃，这是为什么？ A：模拟器应该是x86_64的，我是在Pixel9真机上跑的。 互动提问：按照方法处理后，AS分析APK后，lib中的.so库是正确了。但是AS依旧提示不支持16KB，这是为什么？ A：上面补充了一点信息，你的情况应该是上面的新模式了，这个还有个隐性要求：so在APK中的偏移也要是16K对齐，可以开启useLegacyPackaging试试。 感谢题主littlesome@UWA问答社区提供了回答，有经验的朋友欢迎转至社区交流： https://answer.uwa4d.com/question/68d4e378244ce21ce9ec0920 封面图来源于网络 今天的分享就到这里。生有涯而知无涯，在漫漫的开发周期中，我们遇到的问题只是冰山一角，UWA社区愿伴你同行，一起探索分享。欢迎更多的开发者加入UWA社区。 UWA官网：www.uwa4d.com UWA社区：community.uwa4d.com UWA学堂：edu.uwa4d.com 官方技术QQ群：793972859
游戏在高负载场景下，整机功耗控制在多少
1）游戏在高负载场景下，整机功耗控制在多少合理 2）什么是MALLOC_SMALL和MALLOC_NANO，如何优化 3）Spine堆内存占用高怎么办 这是第446篇UWA技术知识分享的推送，精选了UWA社区的热门话题，涵盖了UWA问答、社区帖子等技术知识点，助力大家更全面地掌握和学习。 UWA社区主页：community.uwa4d.com UWA QQ群：793972859 UWA每周技术分享如约来到第446期，自上期我们开启升级时，便希望为大家解决开发中“问题急、需快答”的需求 —— 而本期，这份初心依旧延续：我们从UWA AI问答的真实提问库中筛选案例，既能帮你快速理清解题思路，也能让你切实感受到“不用等、随手查”的便捷。 Q：游戏在高负载场景下，整机功耗控制在4000-5000mW以内是否合理？ A：游戏在高负载场景下整机功耗控制在4000-5000mW是合理且常见的，尤其对于追求高画质、高帧率的移动端3D游戏。 功耗合理性分析 典型范围参考：根据行业经验，在运行复杂渲染与密集逻辑的高端游戏中，设备整机功耗达到4000-5000mW属于正常水平。这表明CPU、GPU等硬件资源被充分调动以维持流畅体验。 对比基准：在轻量应用或待机状态下，设备功耗通常为1000-2000mW。游戏作为高压应用，功耗显著上升是性能需求驱动的结果。 发热关联性：高功耗直接导致设备发热，可能触发系统级降频（如CPU/GPU throttling），进而影响帧率稳定性与用户体验。 高功耗的主要贡献因素 GPU带宽开销 GPU显存带宽是能耗的重要来源之一。数据显示，约1GB/s带宽可带来80-100mW功耗。若项目渲染带宽达10GB/s，则仅此项即可贡献约1000mW功耗，占总功耗的20%左右。因此，优化渲染管线、降低冗余绘制和纹理传输对控电至关重要。 渲染复杂度 同屏三角面数过高（如超过60万面）、DrawCall频繁、多Pass Shader使用等都会显著增加GPU负载与功耗。建议在低端设备上控制同屏面数在25万以内，高端机不超过60万。 CPU模块耗时 动画更新（Animators.Update）、物理模拟（Physics.Simulate）、粒子系统（ParticleSystem.Update）等若占用过高CPU时间，也会推高整体功耗。可通过异步计算、批处理或简化逻辑来缓解。 内存与GC压力 Mono堆内存峰值过高或GC.Collect频繁调用会引发CPU spike，间接增加功耗。应减少运行时内存分配，避免每帧创建对象，并合理管理资源生命周期。 优化建议 监控关键指标：利用UWA GOT Online报告中的“功率峰值”、“每万帧耗电均值”、“温度峰值”等指标，定位高功耗场景。 场景级排查：通过“场景性能列表”功能查看各场景的功率与电流表现，识别异常场景并结合函数堆栈深入分析。 针对性优化： 减少Overdraw，合并材质，压缩纹理格式（如使用ASTC替代RGBA32）。 合理使用LOD与视锥剔除，降低不必要的渲染负担。 控制后台加载频率，避免GC集中触发。 审查Shader复杂度，避免片元着色器过度计算。 综上，4000-5000mW功耗本身合理，但需结合设备散热能力、用户感知（发烫、掉帧）综合评估，并持续优化关键模块以提升能效比。 分享链接： https://www.uwa4d.com/main/uwa-shared.html?shareId=a2de5479-7f4a-4bab-8284-6d5e7715c8ca Q：Unity Profiler中，Untracked中的MALLOC_SMALL和MALLOC_NANO分别是什么意思，以及如何优化？ A：在Unity Profiler中，Untracked内存指的是引擎无法直接追踪和归因的原生内存分配。其中，MALLOC_SMALL和MALLOC_NANO是iOS平台（基于Apple的malloc堆分析）在Xcode或Instruments中对小块内存分配的分类，Unity Profiler在真机采样时也会呈现这些系统层内存信息。 MALLOC_NANO：指通过macOS/iOS的malloc系统中“Nano Zone”分配的小块内存（通常小于256字节）。Nano Zone是为高频、小对象分配优化的快速分配路径，具有低开销和高效率的特点。 MALLOC_SMALL：指大小在256字节至1MB之间的内存块，由malloc的“Small Zone”管理。这类分配适用于中等大小的对象。 这两类内存属于系统原生堆分配，Unity无法直接监控其具体来源，因此归为Untracked Memory。若其占用过高，可能表明存在大量频繁的小对象分配，或第三方库、插件、C++模块的内存使用异常。 优化策略： 定位来源 使用Xcode的Instruments工具（如Allocations、Leaks）对iOS构建进行深度分析，查看MALLOC_NANO和MALLOC_SMALL的具体调用堆栈，识别是引擎模块、第三方SDK还是自定义原生插件导致的高分配。 检查是否有频繁的临时对象创建（如字符串拼接、装箱操作、委托生成等），这些可能间接触发原生层小内存分配。 减少托管层小对象频繁分配 避免在Update等高频函数中进行字符串拼接（使用StringBuilder替代）。 避免装箱（Boxing），如Debug.Log(1)应改为Debug.Log(1.ToString())。 复用对象，使用对象池管理频繁创建/销毁的小对象（如特效、UI元素）。 审查第三方插件与原生代码 若项目集成Wwise、FMOD、广告SDK等，检查其是否在每帧执行高频小内存分配。 使用UWA GOT Online的Lua或Native模式，分析是否存在Lua层或C++插件的内存异常。 优化AssetBundle与资源管理 MALLOC_SMALL异常升高可能与AssetBundle加载/卸载过程中的元数据分配有关。确保AB加载后正确卸载Unload(false)，避免冗余资源驻留。 使用静态字体图集替代动态字体，减少TMP运行时生成字形的原生纹理分配。 启用系统级内存分析 在Android平台，可通过adb shell dumpsys meminfo分析Native Heap；在iOS则结合Perfetto或Instruments，对比不同场景下的MALLOC_*变化，锁定内存增长热点。 综上，MALLOC_NANO和MALLOC_SMALL是系统层小内存分配指标，优化核心在于减少不必要的对象创建、审查第三方库行为，并借助平台级工具精确定位高分配源头。 Q：Spine堆内存占用高怎么办？ A：Spine动画在运行时可能导致较高的堆内存占用，主要源于资源加载策略和运行时数据结构的管理。以下是针对此问题的具体优化方案： 采用按需加载（Lazy Loading）策略 避免一次性加载Spine资源中的全部皮肤、动画或附件数据。可通过解析Spine的二进制文件（.skel），仅在需要特定动画或皮肤时动态加载对应部分，显著降低初始堆内存占用。该方法可在不拆分AssetBundle的前提下实现资源的细粒度控制，减少内存峰值。 使用二进制格式导出资源 务必使用Spine的二进制（.skel）格式而非JSON导出资源。二进制格式不仅解析更快，且在反序列化过程中产生的临时对象更少，堆内存分配更低，整体表现远优于文本格式。 减少冗余数据与美术优化 在Spine编辑器中清理无用的Timeline、骨骼、插槽和裁剪（Clipping）信息。复杂的层级结构会增加运行时解析和更新开销，也会影响堆内存使用。每次修改后使用“Clear”功能压缩数据。 控制同时激活的Spine组件数量 监控场景中处于更新状态的Spine组件数量，尤其是UI上的SkeletonGraphic。建议： 对非必要实时播放的Spine动画，启用Freeze属性使其静止； 合理设置Update When Invisible为Nothing，结合RectMask2D实现视窗外停更，减少无效更新带来的GC压力。 避免重复加载与泄漏 确保Spine Atlas、Texture等资源被正确引用和释放。若通过代码手动加载，请在适当生命周期卸载，并注意AssetBundle引用计数，防止因Bundle提前卸载导致纹理无法流式加载Mipmap（间接影响内存管理）。 利用对象池管理实例 对频繁创建销毁的Spine角色或特效，使用对象池复用实例，避免频繁的资源加载与堆内存分配，降低GC频率。 通过上述措施可有效控制Spine带来的堆内存压力，提升整体运行稳定性。 无论是社区里开发者们的互助讨论，还是AI基于知识沉淀的快速反馈，核心都是为了让每一个技术难题都有解、每一次踩坑都有回响。若你此刻正被某类技术难题绊住脚步，或许这期分享会让你更想试试 UWA AI 问答 —— 毕竟，能即时对接实战经验，少走弯路，才是解决问题的高效方式。 封面图来源于网络 UWA官网：www.uwa4d.com UWA社区：community.uwa4d.com UWA学堂：edu.uwa4d.com 官方技术QQ群：793972859