UWA问答 | 博客 | 游戏及VR应用性能优化记录分享 | 侑虎科技 - 厚积薄发
UWA旨在诊断与优化游戏和VR项目开发过程中潜在的运行性能问题，帮助项研发团队快速检测项目运行时的性能瓶颈，高效定位问题根源，及时反馈针对瓶颈问题的深度分析和解决方法。
【USparkle专栏】如果你深怀绝技，爱“搞点研究”，乐于分享也博采众长，我们期待你的加入，让智慧的火花碰撞交织，让知识的传递生生不息！ 当有持续时间短，又比较杂的异步任务时，可以使用ThreadPool，用固定数量的工作线程执行任务，不每次都创建新线程。UE4和UE5的线程池有很大区别，UE4线程池会真的创建很多线程，而UE5主要线程池底层复用了TaskGraph的线程，线程池只是逻辑上的概念。 一、创建线程池 线程池在FEngineLoop::PreInitPreStartupScreen函数中创建。 GThreadPool 类型为FQueuedLowLevelThreadPool，是UE5中的新实现，线程数量由FPlatformMisc::NumberOfWorkerThreadsToSpawn()确定。 GIOThreadPool 类型为FQueuedThreadPool，线程数量由FPlatformMisc::NumberOfIOWorkerThreadsToSpawn()确定，Client为4，Server为2。 GBackgroundPriorityThreadPool 类型为FQueuedThreadPool，Client为2，Server为1。 GLargeThreadPool 类型为FQueuedLowLevelThreadPool，数量由FPlatformMisc::NumberOfCoresIncludingHyperthreads()确定。 二、使用线程池 虽然线程池实现比Runnable复杂，但使用方式也比较简单。 1. Async函数 最常见用法，Async函数可设置EAsyncExecution::ThreadPool参数，指定任务在ThreadPool里执行。 函数内部会创建TAsyncQueuedWork封装Function和Promise，然后使用AddQueuedWork接口把任务加到GThreadPool中。 AddQueuedWork是线程池最重要的接口。 2. AsyncPool函数 与Async类似，但可以指定线程池和Work优先级。 3. 手动调用AddQueuedWork AddQueuedWork函数只需要接受IQueuedWork作为参数，TAsyncQueuedWork只是一个子类，我们可以创建子类，做自定义操作，这样也能指定使用哪个线程池。 比如引擎中Encode LightMap的操作，就使用了FAsyncEncode类： 三、线程池实现 1.类型定义 类型定义可分为线程池，线程池线程，任务。 线程池 FQueuedThreadPool：线程池基类，定义了线程池的接口。 Allocate：创建线程池，类型为FQueuedThreadPoolBase。 Create：创建若干工作线程。 AddQueuedWork：向线程池添加任务。 RetractQueuedWork：撤回任务。 AddQueuedWork和RetractQueuedWork是线程池提供给外部调用的主要接口，注意会在多线程中被调用。 FQueuedThreadPool有多种实现： FQueuedThreadPoolBase 最常用，线程池的基础实现，GIOThreadPool和GBackgroundPriorityThreadPool都会使用。 成员： FThreadPoolPriorityQueue QueuedWork：待处理任务的队列。 TArray<FQueuedThread*> QueuedThreads：等待接收任务的空闲线程。 TArray<FQueuedThread*> AllThreads：所有工作线程。 FCriticalSection* SynchQueue：保护任务队列的CriticalSection，因为任务队列会被多线程修改。 FQueuedLowLevelThreadPool 底层线程使用TaskGraph的ThreadPool，UE5中GThreadPool的默认实现。 FQueuedThreadPoolWrapper FQueuedThreadPoolDynamicWrapper FQueuedThreadPoolTaskGraphWrapper 线程池线程 FQueuedThread：继承自FRunnable，表示线程池中的工作线程。可以想象，它大部分时间都处于idle状态，当有任务来时才工作。 成员： DoWorkEvent：通知线程有任务要执行的Event。 QueuedWork：当前线程正在执行的Work。 Thread：Runnable对应的线程。 函数： Run：主函数，可认为是一个等待、执行任务的循环。 DoWork：由ThreadPool调用，传入一个任务并执行。 任务 IQueuedWork：可排队任务的基类接口，供线程池使用。 接口： DoThreadedWork：执行任务。 IQueuedWork有多种实现： TAsyncQueuedWork 最常用，Async和AsyncPool函数中使用。 DoThreadedWork：通过SetPromise执行任务。 FAsyncTaskBase 可操作内容更多。 DoThreadedWork：通过Task执行任务。 类图如下： 常用部分已高亮显示 2. FQueuedThreadPoolBase 线程池创建 FQueuedThreadPoolBase是默认线程池，FQueuedThreadPool::Allocate函数中构造。 线程池通过Create函数初始化，主要工作是创建InNumQueuedThreads数量的工作线程，使用FQueuedThread类封装，并把创建的线程加入QueuedThreads和AllThreads容器中，QueuedThreads中存储了当前线程池中处于空闲状态的线程。还要创建CriticalSection对象SynchQueue，用于保护对QueuedWork和QueuedThreads的访问。 FQueuedThread FQueuedThread继承自FRunnable，是一个可运行任务的抽象，其Create函数如下。首先创建DoWorkEvent，用于做多线程同步，然后创建一个底层的Thread。线程创建好后进入Run方法，初始没有任务，线程在DoWorkEvent上等待，处于休眠状态。 添加任务 观察AddQueuedWork函数，添加任务时分成了两种情况。 如果线程池中尚有空闲线程，即下图中的情况1，QueuedThreads中有元素，那么把任务分配给其中一个线程即可，这里还有一个细节，QueuedThreads采用栈管理，先进后出，这可以更好利用CPU Cache，因为这个Thread可能刚运行过，同时也可以避免数组中的元素移动。得到Thread后，调用DoWork方法添加任务。 另一种情况是所有线程都在忙碌，QueuedThreads中没有元素，这时只能把InQueuedWork暂存到QueuedWork中，等线程执行完之前任务后再做处理。 FQueuedThread::DoWork方法用于通知一个Thread要执行任务了，首先把InQueuedWork设置到其QueuedWork属性上，然后执行DoWorkEvent的Trigger方法，唤醒该Thread。注意这里加了一个MemoryBarrier，是为了避免CPU指令乱序优化导致1071行在1074行之后执行，导致错误。 执行任务 执行任务通过属性的Run函数实现。Thread一开始会在DoWorkEvent上等待，被DoWork函数唤醒后，会获取之前被赋值的QueuedWork，执行DoThreadedWork函数，这里是真正执行任务。执行完成后再调用ThreadPool的ReturnToPoolOrGetNextJob函数，尝试获取暂存的QueuedWork并执行，若没有就把Thread归还到QueuedThreads中，之后在DoWorkEvent上等待，进入休眠状态。 流程图示： 3. TAsyncQueuedWork 线程池中的任务，包装了一个Function对象，DoThreadWork函数中使用给Promise SetValue的形式来执行Function。 以上就是UE线程池常用的FQueuedThreadPoolBase，FQueuedThread，TAsyncQueuedWork组合。 以下内容是UE5的改动。 4. FQueuedLowLevelThreadPool 在UE5中，非Editor模式下GThreadPool实现变成了FQueuedLowLevelThreadPool。底层使用了TaskGraph，相关内容放在后面看，这里只分析与线程池相关的部分。 UE希望把多线程操作尽量放在TaskGraph里，这样好管理。CPU物理核心数量是有限的，如果TaskGraph和ThreadPool都创建了核心数量的线程，其实在各自管理，两边线程都跑满就会产生更多的CPU调度开销。 Create 其实不需要Create了，因为自己不创建线程，初始化在构造函数里完成，主要任务是获取LowLevelTasks::FScheduler单例。 FQueuedThreadPool::Create只是实现一下纯虚函数。 LowLevelTasks::Fscheduler管理了TaskGraph中的Workers线程，包括ForegroundWorkers和BackgroundWorkers，向Worker线程分发任务，细节后面再看。 5. AddQueuedWork 首先创建FQueuedWorkInternalData对象来存储QueuedWork相关数据，然后设置到InQueuedWork.InternalData属性。 FQueuedWorkInternalData类包装了一个LowLevelTasks::FTask，FTask用于把QueuedWork包装成TaskGraph里可执行的东西。Retract函数用于取消任务，但线程池场景下不需要考虑取消。 Task.Init函数调用有点绕，464行先把InQueuedWork包装成一个Lambda函数，然后在Init实现里面再把Lambda包装到另一个TFunction里面。这样就把InQueuedWork存到Task里面了，往后操作只和TaskGraph有关，与线程池无关了。 FScheduler::TryLaunch把Task添加到任务队列中，等待Worker线程来消费。 6. 执行任务 TaskGraph中Worker线程的Run函数会循环获取任务执行，细节放后面TashGraph里看，这里只看一个调用栈。 下图中1的位置是Worker线程取Task，2的位置是执行InQueuedWork->DoThreadedWork()，终于又回到了线程池。 总体来看，FQueuedLowLevelThreadPool其实就是TaskGraph，和Async函数中传EAsyncExecution::TaskGraph是一个效果。 7. FQueuedThreadPoolWrapper 不是真正的线程池，而是另一个线程池的包装，任务都会转发过去。UE5 Editor下GThreadPool就会设置成这个，包装了GLargeThreadPool，目的为共用GLargeThreadPool中的线程，类似FQueuedLowLevelThreadPool共用TaskGraph的线程，因为Editor下后台任务更多，因此单独使用了GLargeThreadPool。这么做的目的还是减少线程创建。 主要成员 FQueuedThreadPool* WrappedQueuedThreadPool; 包装的ThreadPool。 TArray<FScheduledWork*> WorkPool; Work集合。 TMap<IQueuedWork*, FScheduledWork*> ScheduledWork; 当前正在被执行的Work。 std::atomic MaxConcurrency; 最多允许多少Work在后台线程池中运行。 std::atomic CurrentConcurrency; 当前在后台线程池中运行的Work。 FScheduledWork 成员中出现了FScheduledWork类型，它是一个容器，存储了真正的IQueuedWork，同时也是IQueuedWork的子类，有DoThreadedWork接口。 其中128行执行了异步任务，131行通知FQueuedThreadPoolWrapper任务执行完，可调度下个任务，会在下面介绍。 初始化 构造函数如下，主要接受一个线程池作为后台线程池，InMaxConcurrency表示最多同时在后台线程池中执行多少个任务。 AddQueuedWork AddQueuedWork首先把任务加到QueuedWork中，然后执行Schedule函数，默认参数为空。 Schedule函数最重要的是下面几行。首先从QueuedWork中获取要执行的任务，然后递增CurrentConcurrency。接着通过AllocateWork获取一个FScheduledWork对象，并把InnerWork封装在里面，然后把FScheduledWork交给后台线程池运行。 WorkPool容器就缓存了已创建的FScheduledWork对象，AllocateWork会首先从中获取，没有再创建，避免性能上的浪费。 执行 FScheduledWork执行完DoThreadedWork后，会调用Release，继续让线程池执行剩余任务，并把自己重置，加入WorkPool中，等待下次使用。 图示如下： 这是侑虎科技第1917篇文章，感谢作者南京周润发供稿。欢迎转发分享，未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859） 作者主页：https://www.zhihu.com/people/xu-chen-71-65 再次感谢南京周润发的分享，如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859）
1）如何解决ProtoBuf反序列化中的GC高的问题 2）如何在编辑器中集成实时查看Mipmap使用层级工具 这是第455篇UWA技术知识分享的推送，精选了UWA社区的热门话题，涵盖了UWA问答、社区帖子等技术知识点，助力大家更全面地掌握和学习。 UWA社区主页：community.uwa4d.com UWA QQ群：793972859 From UWA AI 问答 Q：有没有比较好的解决ProtoBuf反序列化GC的方案？ A：在Unity项目中，使用ProtoBuf进行反序列化时产生的GC主要来源于两方面：内存分配的临时对象（如字符串、装箱操作）和反射机制的低效调用。以下是针对这些问题的成熟且高效的解决方案。 1. 使用[Preserve]与强制AOT初始化避免反射GC IL2CPP环境下，由于AOT编译限制，ProtoBuf生成代码中的泛型反射方法可能未被提前编译，导致运行时尝试动态调用而崩溃或触发大量GC。为确保所有必要的反射初始化代码被保留： using Google.Protobuf.Reflection; using UnityEngine.Scripting; [Preserve] private void ForceReflectionInitialization() { // 显式调用每个消息类型的ForceReflectionInitialization FileDescriptor.ForceReflectionInitialization<MyMessage1>(); FileDescriptor.ForceReflectionInitialization<MyMessage2>(); // 添加所有用到的消息类型 } 此方法无需调用，仅需存在即可被链接器保留。配合[Preserve]特性防止代码剥离，可有效解决因AOT缺失导致的异常及性能损耗。 2. 避免字符串与值类型装箱，实现0GC解析辅助 参考TMP文本优化思路，可对ProtoBuf解析过程中的输出处理进行定制，减少中间对象分配。例如，在自定义的日志系统或UI更新中直接复用StringBuilder写入，而非通过ToString()创建新字符串。 全局复用StringBuilder用于格式化输出。 对数字字段转字符串时，采用无GC的NumberFormatter.NumberToString(ref char[] buffer, int value)方式，将结果写入预分配字符数组。 自定义IFormatter实现，绕过装箱操作，直接处理基本类型。 3. 使用原生字节访问替代高开销API 对于高频解析场景（如网络同步），考虑绕过完整反序列化流程，直接读取字节流中的关键字段： // 使用CodedInputStream直接读取特定字段 var input = CodedInputStream.CreateInstance(data); while (!input.IsAtEnd) { var tag = input.ReadTag(); switch (tag.FieldNumber) { case 1: int hp = input.ReadInt32(); break; // 只解析必要字段 } } 这种方式跳过对象构建开销，适用于只关心部分数据的轻量级更新。 4. 缓冲池管理Message实例 频繁创建/销毁ProtoBuf对象会加剧GC压力。建议使用对象池缓存常用Message实例： public class MessagePool<T> where T : IMessage, new() { private static readonly Stack<T> _pool = new(); public static T Get() => _pool.Count > 0 ? _pool.Pop() : new T(); public static void Release(T msg) { _pool.Push(msg); } } 结合Clear()重置状态后复用，显著降低内存分配频率。 综上，核心策略是：强制AOT初始化 + 禁止代码剥离 + 复用缓冲区 + 裁剪不必要的反序列化流程。这些方法已在多个上线项目中验证，能有效将ProtoBuf相关GC降至接近零。 分享链接： https://www.uwa4d.com/main/uwa-shared.html?shareId=228136d0-985e-4af9-8c83-f84b04834677 From 问答社区 Q：最近在优化贴图内存，请问有没有开源库或者计算方法，可以检测贴图在屏幕上的占比情况？ 比如，一张1024的贴图，如果渲染时一直使用很高层级的Mipmap，就说明其实不需要1024这么高的分辨率。我记得有种工具还能通过颜色可视化，我主要想集成在编辑器里面实时查看修改。 A：URP里面编写Mipmap工具可以参考以下链接： Scene View Debug Modes in the Unity URP A way to visualize mip levels 欢迎大家转至社区交流： https://answer.uwa4d.com/question/6925280c682c7e5cd61bfb76 无论是社区里开发者们的互助讨论，还是AI基于知识沉淀的快速反馈，核心都是为了让每一个技术难题都有解、每一次踩坑都有回响。本期分享分别来自UWA AI问答和UWA问答社区，希望这些从真实开发场景中提炼的经验，能直接帮你解决当下的技术卡点，也让你在遇到同类问题时，能更高效地找到破局方向。 封面图来源于网络 今天的分享就到这里。生有涯而知无涯，在漫漫的开发周期中，我们遇到的问题只是冰山一角，UWA社区愿伴你同行，一起探索分享。欢迎更多的开发者加入UWA社区。 UWA官网：www.uwa4d.com UWA社区：community.uwa4d.com UWA学堂：edu.uwa4d.com 官方技术QQ群：793972859
1）如何排查优化URP内置Shader冗余 2）运行时Shader内存下降的原因 这是第454篇UWA技术知识分享的推送，精选了UWA社区的热门话题，涵盖了UWA问答、社区帖子等技术知识点，助力大家更全面地掌握和学习。 UWA社区主页：community.uwa4d.com UWA QQ群：793972859 From 问答社区 Q1：请教一下Shader冗余应该怎么查，似乎好几个Shader运行时都有两份？ A：资源冗余最常见的原因是AssetBundle没有依赖打包导致的，可以使用UWA的在线AssetBundle检测进行冗余检测先试试。 Q2：测了AssetBundle，确实有冗余，但AssetBundle冗余的Shader和运行时冗余的Shader好像又不一致。实际运行时的冗余都是Hidden/Universal Render Pipeline/xxx。这又是为什么呢？ A：这些是URP的Shader，通常是URP Asset的引用导致的，因为URP Asset会引用这些Shader。而内存中出现两份Shader，说明内存中出现了两个来源路径不一样的URP Asset，通常一份是在PlayerSetting中引用的URP Asset，另外一份可能来自AssetBundle中动态加载的URP Asset。RendererData里面会引用到PostProcessData，PostProcessData就会引用这些Shader，如果代码里面动态加载的AssetBundle里面也有这种资源，也会引用一份Shader进内存，就会造成冗余。 Q3：请问这个怎么处理，直接删去吗？ A：一般只处理内存占用比较大的即可，其他的内存占用比较小，冗余开销也不大。比如Hidden/Universal Render Pipeline/Uberpost，需要删除其中用不到的Keyword来降低占用；又比如Hidden/Universal Render Pipeline/HBAO，看是否确实要用到，用不到就解除引用。 欢迎大家转至社区交流： https://answer.uwa4d.com/question/69245652244ce21ce9ec095c From 问答社区 Q：请问在游戏运行过程中不同时刻截帧后同一个Shader的内存占用为什么不一样？用UWA的资源列表观察完整生命周期曲线后更明显了，全程都在轻微但持续地下降，这是什么原因呢？ A：这是因为，内存分析工具统计到的是在Unity引擎层Native Object处的Shader内存。引擎会在ShaderCreateGPUProgram操作时，将Shader Code信息转化为运行时GPU实际要用到的信息。因此会表现为Shader本体资源内存下降，但系统层Native Heap和GFX内存都会显著上升。 这个现象在UWA DAY 2025的话题中正好有相应的原理讨论和实验，分别为： Unity移动游戏工业级性能优化指南 3.0 《心动小镇》内存优化经验谈 欢迎大家转至社区交流： https://answer.uwa4d.com/question/69245c90244ce21ce9ec095f 无论是社区里开发者们的互助讨论，还是AI基于知识沉淀的快速反馈，核心都是为了让每一个技术难题都有解、每一次踩坑都有回响。本期分享分别来自UWA AI问答和UWA问答社区，希望这些从真实开发场景中提炼的经验，能直接帮你解决当下的技术卡点，也让你在遇到同类问题时，能更高效地找到破局方向。 封面图来源于网络 今天的分享就到这里。生有涯而知无涯，在漫漫的开发周期中，我们遇到的问题只是冰山一角，UWA社区愿伴你同行，一起探索分享。欢迎更多的开发者加入UWA社区。 UWA官网：www.uwa4d.com UWA社区：community.uwa4d.com UWA学堂：edu.uwa4d.com 官方技术QQ群：793972859
【USparkle专栏】如果你深怀绝技，爱“搞点研究”，乐于分享也博采众长，我们期待你的加入，让智慧的火花碰撞交织，让知识的传递生生不息！ UE游戏包与编辑器中都有众多线程，多线程可以充分利用CPU多核特性，提升游戏表现，而且现代CPU核数越来越多，游戏多线程就更有必要了。 一、线程类型 线程可分为专用线程和线程池中线程。 专用线程为GameThread，RenderThread，StatsThread等，它们各自都干专门的事情，比如GameThread用于驱动游戏逻辑，RenderThread用于渲染，StatsThread用于干性能分析。在事情干完后会进入阻塞状态，不消耗CPU资源。 线程池线程包括PoolThread和TaskGraphThread，每个线程用于干多种异步任务。游戏中许多任务并发量大，但持续时间短，比如求解动画蓝图，每帧都开几个线程求解，求解完再销毁线程无疑很浪费。另外有很多琐碎的多线程任务，单独为它们开线程也很浪费。因此UE使用了线程池，池中线程会循环利用，不断执行不同的异步任务，当没有任务时也处于阻塞状态。 使用多线程时，UE已对底层平台接口进行了封装，开发者无需关注平台差异，直接使用UE提供的统一接口即可。 常用的多线程方式包括：RunnableThread、ThreadPool 和 TaskGraph。这三者在底层线程的实现机制上有所不同，对外提供的接口种类丰富，部分接口还支持通过参数指定所使用的线程实现方式。 二、FRunnable & FRunnableThread 基本的线程使用方式为FRunnable与FRunnableThread的组合，用于创建专用线程，比如AsyncLoadingThread与渲染线程等。FRunnable负责逻辑，FRunnableThread负责具体线程，线程承载了逻辑，两者一一对应，好处是上层逻辑与底层平台接口分离。 FRunnable FRunnable类本身代表一个抽象的“可运行”对象，只有几个接口，不涉及线程细节，是我们写逻辑的地方，理论上可以在任意线程执行。 接口如下： Init Runnable的初始化，初始化可能成功，可能失败，失败会立即返回，线程结束。 Run执行逻辑主体，初始化成功后执行。 Exit Run中任务执行完后的正常退出接口，执行清理操作。 Stop由其他线程调用，用于中途停止该Runnable以及背后的线程，但具体如何停止要我们自己实现。 GetSingleThreadInterface，当UE强制单线程模式时，返回用于Tick执行的实例，用的不多。 上述接口不用我们调用，对应线程创建好后会自动调用。 示例FAsyncloadingThread： 该类用于处理异步资源加载，会另开一个线程加载资源，继承了FRunnable，内部的Thread变量存储线程。 Init函数没做什么，Run函数如下： 主体是一个while循环，StopTaskCounter是循环退出条件，为Atomic计数器，当未被设置时就不断处理加载，被设置后即退出。 Stop函数如下，会设置StopTaskCounter变量： 创建线程，启动Run逻辑。 只需要下面一行代码即可，详细会在下文介绍： FRunnableThread FRunnableThread是平台线程的抽象，也是基类，与平台相关的线程操作由多个子类完成，包括： FRunnableThreadWin FRunnableThreadUnix FRunnableThreadApple FRunnableThreadAndroid 我们不需要继承和修改这些类，使用即可。 成员变量： FString ThreadName：线程名。 FRunnable* Runnable：对应Runnable。 FEvent* ThreadInitSyncEvent：同步的Event。 EThreadPriority ThreadPriority：线程优先级，UE自己抽象了几个枚举。 接口： Kill：结束线程，UE建议不要用操作系统的Kill接口，强杀线程会导致泄露和死锁，应该调用Runnable的Stop方法。 WaitForCompletion：忙等，直到线程执行完。 Suspend：让线程挂起或继续执行。 SetThreadPriority：设置线程优先级。 FRunnableThreadWin 看下常见的Windows平台子类如何实现。 Windows平台的线程为内核对象，通过HANDLE持有索引，这里的Thread就是底层的线程。 Kill方法内调用了Runnable的Stop，该函数由我们自己实现，然后可选忙等，最后调用操作系统的CloseHandle方法释放线程内核对象。 Windows有TerminateThread方法可以直接结束线程，但平常不推荐调用，有以下几个原因： 线程函数中C++对象的析构函数不会被执行； 线程栈不会被清理，除非调用TerminateThread的线程结束。这是Windows有意为之，加入其他在运行的线程要引用被“杀死”线程堆栈上的值，就会引起非法内存访问； DLL通常会在线程终止时收到通知，但TerminateThread会导致DLL收不到通知，从而不执行正常的清理工作。 Suspend方法调用两个操作系统接口，挂起和恢复： SetThreadPriority方法同样调用了操作系统接口，只是要做优先级转换，Windows平台线程优先级为0-31，31最高。 Windows中WaitForSingleObject可以实现WaitForCompletion效果： 三、创建线程 使用静态方法Create可创建FRunnableThread和底层线程： InStack为线程栈大小； InThreadPri为线程优先级； InThreadAffinityMask为线程的CPU运行偏好，一般用默认值； InCreateFlags也一般用默认值。 函数内部首先创建NewThread对象，Windows平台即FRunnableThreadWin。如果当前设置了强制单线程模式，还可选创建FakeThread，通过Tick驱动执行。 之后进入CreateInternal函数，调用操作系统接口创建线程。把Runnable属性设置为传入的InRunnable，然后把线程相关参数转化为适配当前操作系统的参数，调用CreateThread Win32API创建线程，线程执行的函数为_ThreadProc。同时注意到CREATE_SUSPENDED参数，线程创建后默认为挂起状态，执行了后面的ResumeThread，才会让改线程运行。ThreadInitSyncEvent用于等待线程的Init执行完毕，执行完后调用线程才会继续。 _ThreadProc函数先向UE的线程管理类注册改线程，然后进入FRunnableThreadWin::Run函数，真正开始逻辑，注意这个Run函数和FRunnable的Run毫无关系。 首先调用Runnable的Inti函数，之后触发ThreadInitSyncEvent，通知调用线程继续。然后执行最主要的Runnable->Run，等Run自动结束了，再调用Runnable->Exit做清理。最后返回ExitCode，改线程终止。 四、使用方式 Runnable有多种使用方式： 1. 手动创建FRunnable和FRunnableThread 可参考前面的FAsyncLoadingThread，适合一个长期任务，而且工作量大。 2. Async函数 有时我们只想在其他线程中执行一个短期任务，线程生命周期不长，此时专门创建一个Runnable子类，并手动创建一个Thread有些繁琐。引擎提供了Async函数，可以只提供我们想要执行的Lambda函数，引擎为我们创建一个Thread，或者从线程池中选择一个Thread来执行逻辑。 使用例子： 第一个参数用于指定线程模式。 Async函数定义如下： 第一个参数为线程执行方式，第二个为传入的函数对象，第三个为执行完的回调。 线程执行方式Execution有如下几种取值： TaskGraph：在TaskGraph框架下执行，会在线程池选一个线程，适合短任务。 TaskGraphMainThread：与上面类似，但会用主线程。 Thread：创建一个新线程执行，适合长任务。 ThreadIfForkSate：不知。 ThreadPool：在GlobalThreadPool中选一个线程执行。 LargeThreadPool：与上面类似，在LargeThreadPool选线程执行，仅Editor下可用。 这里我们只关注Thread模式，处理分支如下： 创建了一个TAsyncRunnable对象，把Function和Promise传入其中，Promise可理解为上面的CompletionCallback，然后通过FRunnableThread::Create接口创建新线程，执行该Runnable。 TAsyncRunnable是一种特殊类型，它接收一个Function、Promise或Future作为参数。观察其Run方法：在SetPromise时会执行我们指派的任务。这个FRunnable和FRunnableThread对象是匿名的，外部代码仅进行New而不Delete，其生命周期由TAsyncRunnable自身托管。具体的清理做法是将删除操作提交至任务队列（TaskGraph）。之所以不立即Delete，是因为此时Run函数可能尚未执行完毕，立即Delete自身可能导致异常情况。 3. AsyncThread函数 和Async函数类似，内部都用TAsyncRunnable实现，不过它是专门创建匿名线程来执行任务的，因此参数中增加了线程优先级选项，这种用法引擎中不多。 五、线程同步工具 多线程环境下线程同步是个问题，UE提供了多种线程同步工具。 Atomics Atomics可以原子的改变一个变量，相比锁是更轻量的线程同步工具，线程不需要切换状态，提供更好的性能。从底层视角看，原子操作也是有锁的，现代多核CPU会通过电路信号锁Cache的方式来实现原子操作，只是这个过程很快。原子操作是一些多线程安全类型的实现基石。 使用场景 常见使用场景为实现多线程安全的计数器，比如SharedPtr里的引用计数，下面的SharedReferenceCount类型就是std::atomic。 UE引擎提供了几个类型，是对操作系统和C++ Atomic功能的封装。 FPlatformAtomics 可对一个地址进行原子操作，如Add，Exchange。在不同平台上会调用各自原子操作接口，Windows为Win32Api的_InterlockedExchange等接口。 示例 TAtomic 类似std::atomic，底层使用FPlatformAtomics实现，提供相似接口。在UE5中，已被标记为DEPRECATED，推荐直接使用std::atomic。 FThreadSafeCounter 封装的线程安全计数器，提供Increment、Decrement等接口，底层同样使用FPlatformAtomics实现。 六、锁 锁可以创建一个临界区，在临界区内的代码只允许一个线程执行，其他线程等待。根据临界区执行时间，以及平台实现，锁可能使线程从运行态切换到阻塞态，让出CPU，这个状态切换也需要线程从用户模式切换到内核模式，切换时间大概1000个CPU周期。因此锁是较重的线程同步工具。 FCriticalSection UE提供了FCriticalSection作为各平台锁的封装。 Windows平台底层使用CriticalSection实现，称为关键段，Windows平台上也有互斥量Mutex，但CriticalSection相比Mutex速度更快。进入临界区需要调用EnterCriticalSection，其内部会先用原子操作interlocked检查是否能访问资源，如果能访问，就接着运行，这个速度很快。如果不能访问，通常先Spin忙等一小段时间，若还不能访问资源，再使用Event内核对象进入阻塞态。当临界区比较短时，可以避免用户模式到内核模式的切换。因此Windows平台会用CriticalSection。 Linux平台底层使用pthread_mutex_t实现。 Windows实现： 初始化CriticalSection，4000表示未获取到锁忙等的CPU周期。 加锁 解锁 示例 通常使用FScopeLock配合FCriticalSection使用，可以通过构造函数与析构函数机制，使作用域内的代码成为临界区。 Event Event用于多线程的同步，比如上文介绍的Windows线程创建，主线程在调用CreateThread后，调用ThreadInitSyncEvent->Wait()，等待新线程完成初始化工作，新建线程初始化后执行ThreadInitSyncEvent->Trigger()，通知主线程继续执行。 Windows平台底层使用Event内核对象实现Event。Event有自动重置和手动重置概念，自动重置时，一个线程执行Trigger后，只有一个Wait的线程会被唤醒继续执行，手动重置时，所有Wait的线程都会被唤醒，后续要手动调用重置函数，才能使Event变为未触发状态。通常都使用自动重置。 FEvent UE使用FEvent类型表示一个Event，有Wait、Trigger、Reset等接口。而且Event是内核对象，创建比较昂贵，因此UE使用EventPool来管理这些Event，根据ManualReset分成两个Pool。 EventPool接口： WindowsRunnableThread中使用方式： 这是侑虎科技第1908篇文章，感谢作者南京周润发供稿。欢迎转发分享，未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859） 作者主页：https://www.zhihu.com/people/xu-chen-71-65 再次感谢南京周润发的分享，如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859）
移动端反射探针格式用什么比较合理
1）移动端反射探针格式用什么比较合理 2）不同平台打包热更容灾问题的解决方案 这是第453篇UWA技术知识分享的推送，精选了UWA社区的热门话题，涵盖了UWA问答、社区帖子等技术知识点，助力大家更全面地掌握和学习。 UWA社区主页：community.uwa4d.com UWA QQ群：793972859 From UWA AI 问答 Q：移动端反射探针格式用什么比较合理？ A：在移动端使用反射探针时，选择合理的纹理格式是平衡画质与性能的关键。不恰当的格式会显著增加内存占用和带宽消耗，影响帧率和功耗。 一、推荐格式 1. ASTC（Adaptive Scalable Texture Compression） 推荐理由： 这是目前移动平台（尤其是iOS和现代Android设备）上最理想的选择。ASTC具有极高的灵活性，支持从4x4到12x12等多种压缩比率，并且能同时处理RGB、RGBA以及HDR数据。 应用场景： LDR探针：可选用ASTC 6x6或8x8，在保证足够清晰度的同时将体积压缩到最小。 HDR探针：必须使用支持HDR的格式。ASTC 4x4 HDR是最佳选择，它能在较低的内存开销下保留高光等重要细节，非常适合需要真实感反射的场景。 2. ETC2/EAC 推荐理由： 作为OpenGL ES 3.0的标准，ETC2兼容性极佳，几乎所有现代Android设备都支持。对于需要广泛兼容性的项目，它是可靠的备选方案。 应用场景： LDR探针：使用ETC2 RGB。虽然压缩效率略逊于ASTC，但兼容性最好。 注意：ETC2本身不支持原生HDR。如果必须在不支持ASTC HDR的设备上使用HDR探针，通常引擎会回退到未压缩的RGB16F或RGB111110F格式，但这会极大增加内存消耗，应尽量避免。 二、核心优化策略 优先启用ASTC：在Unity的Player Settings中，为iOS和Android分别设置首选的ASTC格式。这能确保在支持的设备上自动使用最优的压缩方案。 谨慎使用HDR：仅对需要捕捉强烈高光（如金属车漆、水面反光）的关键探针开启HDR。大部分环境反射使用LDR的ASTC格式即可满足需求，能大幅降低内存占用。 控制分辨率：根据探针的作用范围和距离玩家的远近来调整分辨率。远离玩家或覆盖大范围区域的探针可使用64x64甚至32x32；而用于特写或关键物体（如角色）附近的探针才使用128x128或256x256。分辨率是影响内存和渲染时间的最主要因素。 利用盒体投影（Box Projection）：对于室内或空间受限的环境，启用盒体投影能让一个探针更准确地模拟出角落和墙壁的反射，从而减少所需探针的数量，间接优化了整体性能。 综上所述，ASTC是移动端反射探针的首选格式，特别是结合其HDR特性，能在性能和视觉质量之间取得最佳平衡。 分享链接： https://www.uwa4d.com/main/uwa-shared.html?shareId=c97db263-6273-40bd-8d91-b9737aa44a16 From UWA AI 问答 Q：目前，不同平台的热更打包工作分散在不同的机器上进行。考虑到容灾需求，如果机器挂了或者硬盘挂了，重新打包可能导致玩家需要再次下载几GB的热更新资源。除了定期备份整个工程或Library文件夹之外，是否有更好的应对方案？另外，有没有什么方法能够确保不同机器、不同工程环境下，可以打出一致的AssetBundle包（Meta文件已经受版本管理控制）？ A：为确保多台机器在不同环境或地理位置下能打出一致的AssetBundle（AB包），并有效应对机器或硬盘故障导致的热更风险，需从构建一致性、缓存管理和容灾备份三个层面进行系统性设计。 1. 确保构建一致性 统一构建环境：所有打包机器必须使用完全相同的Unity版本、操作系统补丁、JDK/Python等依赖环境。建议通过Docker镜像或虚拟机模板固化环境，避免因环境差异导致GUID或序列化数据不一致。 共享与锁定Library：Library文件夹中的ScriptAssemblies、BeeTreeCache等缓存直接影响AB生成。应将关键缓存目录设为只读，并通过网络存储（如NFS）或CI/CD流水线统一分发，确保所有打包机基于相同中间产物构建。 禁用随机因子：关闭Random Seed、时间戳嵌入等可能导致输出差异的功能。若使用Addressables，确保Build Script和Provider配置完全一致。 2. 构建缓存与增量复用 集中式构建缓存服务：采用Unity Cloud Build Cache或自建Artifactory/Nexus服务器，存储每次成功构建的AB包及其元数据（Hash、清单）。当某台打包机故障时，可直接从缓存拉取历史版本，无需重新全量构建。 增量构建策略：结合AssetBundleManifest对比前后版本差异，仅对变更资源重新打包。配合内容分发网络（CDN）的差分更新（如BSDiff），可将热更包体积降至最低，即使重打也能快速恢复。 3. 容灾与高可用方案 分布式备份机制：除定期备份工程外，应将每次成功的AB包及AssetBundleManifest实时同步至至少两个异地存储节点（如云对象存储S3/OSS），并启用版本保留策略。 自动化切换流程：建立打包集群健康监测系统，一旦主打包机异常，自动触发备用机器从缓存或源码重建AB包，并校验输出Hash一致性。可通过脚本集成UWA的AssetBundle检测工具，验证资源完整性。 内容寻址存储（CAS）：以AB包内容哈希作为唯一标识进行存储和分发，确保无论在哪台机器构建，只要输入相同，输出即可互换，从根本上规避路径或时间相关差异。 综上，通过标准化环境、共享中间产物、集中缓存与多重备份，可实现跨机器稳定产出一致AB包，并在单点故障时快速恢复，最大限度减少玩家重复下载风险。 分享链接： https://www.uwa4d.com/main/uwa-shared.html?shareId=517d5cb9-5bce-459e-803c-1d08b07ce469 无论是社区里开发者们的互助讨论，还是AI基于知识沉淀的快速反馈，核心都是为了让每一个技术难题都有解、每一次踩坑都有回响。本期分享分别来自UWA AI问答和UWA问答社区，希望这些从真实开发场景中提炼的经验，能直接帮你解决当下的技术卡点，也让你在遇到同类问题时，能更高效地找到破局方向。 封面图来源于网络 今天的分享就到这里。生有涯而知无涯，在漫漫的开发周期中，我们遇到的问题只是冰山一角，UWA社区愿伴你同行，一起探索分享。欢迎更多的开发者加入UWA社区。 UWA官网：www.uwa4d.com UWA社区：community.uwa4d.com UWA学堂：edu.uwa4d.com 官方技术QQ群：793972859
为何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）