UWA问答 | 博客 | 游戏及VR应用性能优化记录分享 | 侑虎科技 - 厚积薄发
UWA旨在诊断与优化游戏和VR项目开发过程中潜在的运行性能问题，帮助项研发团队快速检测项目运行时的性能瓶颈，高效定位问题根源，及时反馈针对瓶颈问题的深度分析和解决方法。
参数GPU Write Total Bandwidth的含义是什么，导致其值过高的因素有哪些
1）参数GPU Write Total Bandwidth的含义是什么，导致其值过高的因素有哪些 2）对Unity项目来说，性能和效果可以兼顾的物理插件有哪些推荐 这是第458篇UWA技术知识分享的推送，精选了UWA社区的热门话题，涵盖了UWA问答、社区帖子等技术知识点，助力大家更全面地掌握和学习。 UWA社区主页：community.uwa4d.com UWA QQ群：793972859 From UWA AI 问答 Q：GPU Write Total Bandwidth这一参数的含义，和其过高时可能的影响因素有哪些？ A：GPU Write Total Bandwidth含义： 该指标表示GPU每帧向内存（显存或系统内存）写入的总数据量，单位通常为MB/s或GB/s。它是GPU带宽压力的关键子项，反映帧渲染过程中所有写操作的带宽消耗总和，包括：帧缓冲（Color/Depth/Stencil）、RenderTexture、计算着色器输出、顶点着色器输出（如Transform Feedback）、以及部分缓存写回等。 Write Total Bandwidth 过高的常见影响因素： ✔ 高分辨率多目标渲染（MRT）或高精度帧缓冲 同时写入多个RenderTarget（如GBuffer中Albedo+Normal+Metallic+Depth），尤其使用高精度格式（RGBA16F、RGB10A2）； 主摄像机启用HDR渲染（R11G11B10或RGBA16F），大幅增加Color Buffer写入带宽； MSAA启用后（如4xMSAA），Color/Depth Buffer写入量成倍增长（需resolve前写入+resolve后写入）。 ✔ 频繁或大尺寸RenderTexture写入 全屏后处理中反复创建/清空/Blit到高分辨率RT（如2048×1536的Bloom临时纹理）； UI渲染中大量使用CanvasRenderTarget或Camera.targetTexture； 动态阴影图（Shadow Map）分辨率过高（如4096×4096）且每帧更新。 ✔ 深度/模板缓冲频繁写入与清除 每帧Clear Depth/Stencil（尤其在多Camera、多Pass场景下重复Clear）； 启用深度预通道（Z-Prepass）但未合批或存在冗余绘制，导致两次深度写入（Prepass+Main Pass）； 不合理的ZTest/ZWrite设置（如ZWrite Off缺失导致无效深度覆盖重试）。 ✔ Compute Shader高频大块写入 使用RWTexture2D/RWBuffer执行大规模粒子更新、物理模拟或图像生成，且线程组尺寸过大或Dispatch次数过多； 未使用AppendBuffer/ConsumeBuffer而采用全量写入方式。 ✔ GPU管线冗余写入行为 多个Camera渲染同一区域且未做裁剪/层级隔离，造成重复Color/Depth写入； URP/HDRP中未关闭不必要的Pass（如ShadowCaster、DepthOnly在非阴影场景仍启用）。 快速定位建议： 查看GOT Online GPU报告中Write Total曲线与Front-end Write/Texture Write/Depth Write等子项占比（若支持）； 对比FPS下降时段是否伴随Write峰值，结合GPU Freq是否因带宽瓶颈触发降频； 检查RenderPipeline Asset中是否启用了非必要高精度RT格式、MSAA或多Pass渲染； 使用UWA资源检测报告筛查高分辨率、未压缩的RenderTexture和ShadowMap资产。 优化优先级：降低RT分辨率 → 合并MRT输出 → 关闭冗余Clear/MSAA → 替换高精度格式（如RGBA8替代RGBA16F）→ 后处理降采样。 UWA GPU分析页面中还展示了很多对性能排查分析有一定参考价值的参数和其子参数，若您对这些参数的具体含义和参考价值仍有疑问，可在社区中进行查阅、或尝试使用AI问答助手解答。如以上问题，除了了解写带宽的影响因素外，您还可以提问在不同GPU架构下多种多样的读带宽子项的含义：Front-end Read、LoadStore Read、Texture Read、Vertex Read、SP Read。 分享链接： https://www.uwa4d.com/main/uwa-shared.html?shareId=8919019e-4d69-43a2-bba1-39138791414a From UWA AI 问答 Q：对Unity项目来说，性能和效果可以兼顾的物理插件有什么推荐？ A：在Unity项目中，兼顾性能与效果的物理插件需满足三大硬性条件： ✔ 跨平台行为一致（尤其iOS/Android）； ✔ CPU占用可控（单帧<1ms，中端机）； ✔ 支持表现/逻辑分层（避免帧同步风险）。 推荐方案： 首选：Unity原生PhysX+工程化封装（非插件，但最可靠） 适用场景：角色布娃娃、关卡交互物体、简单布料、碰撞反馈。 关键优化实践： 使用Rigidbody.interpolation=Interpolate+collisionDetectionMode=ContinuousDynamic控制抖动与穿透； 所有Collider替换为Primitive Collider组合（禁用MeshCollider，见FAQ文档），复杂模型用BoxCollider+CapsuleCollider分区代理； 物理更新频率降为Time.fixedDeltaTime=0.033f（30Hz），配合Physics.autoSimulation=false手动调度。 高效替代：Obi Softbody & Obi Cloth（商业插件） 优势： 纯C#实现，完全绕过PhysX，iOS/Android行为100%一致，支持帧同步逻辑层（需定点数改造）； 布料解算支持GPU加速（Metal/Vulkan），CPU负载下降60%+； 提供ObiSolver多线程调度器，可绑定至Unity Job System。 注意：需关闭Substeps（默认3→设为1），避免低端机卡顿；布料顶点数建议≤128。 表现专用（零物理开销）：Dynamic Bone（免费开源） 定位：纯表现层附属物模拟（头发、衣摆、尾巴）。 优势： 无刚体、无物理引擎依赖，仅靠Transform插值计算； 支持LOD控制（Update When Inactive+Max Distance）； 源码开放，可无缝接入URP/HDRP。 不适用于受力反馈、碰撞交互等逻辑需求。 明确规避 Havok Physics for Unity：仅限高端主机/PC，移动端无授权支持，且无法保证Android/iOS一致性。 自研PhysX封装库：跨平台浮点误差不可控，已有多款上线项目因PhysX版本差异导致iOS/Android同步失败。 最终建议路径 所有方案均需配合UWA GOT Online的Physics模块实时监控Rigidbody Count与FixedUpdate ms，确保长期稳定。 无论是社区里开发者们的互助讨论，还是AI基于知识沉淀的快速反馈，核心都是为了让每一个技术难题都有解、每一次踩坑都有回响。本期分享分别来自UWA AI问答和UWA问答社区，希望这些从真实开发场景中提炼的经验，能直接帮你解决当下的技术卡点，也让你在遇到同类问题时，能更高效地找到破局方向。 封面图来源于网络 今天的分享就到这里。生有涯而知无涯，在漫漫的开发周期中，我们遇到的问题只是冰山一角，UWA社区愿伴你同行，一起探索分享。欢迎更多的开发者加入UWA社区。 UWA官网：www.uwa4d.com UWA社区：community.uwa4d.com UWA学堂：edu.uwa4d.com 官方技术QQ群：793972859
【USparkle专栏】如果你深怀绝技，爱“搞点研究”，乐于分享也博采众长，我们期待你的加入，让智慧的火花碰撞交织，让知识的传递生生不息！ 背景：前段时间在项目内做了关于Mono内存（堆内存）的优化。从结果上将Mono内存从220MB降低到130MB，优化过程中唤起了部分关于GC的消失的回忆，虽然实际的优化工作中也许并用不到，但是更明确底层实现机制总归是一件迭代自我的过程，在这里就来回顾一下。 一、什么是垃圾回收 - GC（Garbage Collector） 在游戏运行的时候，数据主要存储在内存中，当游戏的数据在不需要的时候，存储当前数据的内存就可以被回收以再次使用。内存垃圾是指当前废弃数据所占用的内存，垃圾回收（GC）是指将废弃的内存重新回收再次使用的过程。 1. 什么时候触发垃圾回收 有三个操作会触发垃圾回收： 在堆内存上进行内存分配操作而内存不够的时候都会触发垃圾回收来利用闲置的内存。 GC会自动触发，不同平台运行频率不一样。 GC被代码强制执行。 2. GC操作带来的问题 直白点就两个问题：一个是Stop-the-world导致的“卡”；一个是内存碎片导致的“堆内存太大”。 GC操作会需要大量的时间来运行，如果堆内存上有大量的变量或者引用需要检查，则检查的操作会十分缓慢，这就会使得游戏运行缓慢。 GC可能会在关键时候运行，例如在CPU处于游戏的性能运行关键时刻，此时任何一个额外的操作都可能会带来极大的影响，使得游戏帧率下降。 另外一个GC带来的问题是堆内存的碎片。当一个内存单元从堆内存上分配出来，其大小取决于其存储的变量的大小。当该内存被回收到堆内存上的时候，有可能使得堆内存被分割成碎片化的单元。也就是说堆内存总体可以使用的内存单元较大，但是单独的内存单元较小，在下次内存分配的时候不能找到合适大小的存储单元，这也会触发GC操作或者堆内存扩展操作。 堆内存碎片会造成两个结果：一个是游戏占用的内存会越来越大；一个是GC会更加频繁地被触发。 特别是在堆内存上进行内存分配时内存单元不足够的时候，GC会被频繁触发，这就意味着频繁在堆内存上进行内存分配和回收会触发频繁的GC操作。 二、Unity托管堆 在讲具体的Unity GC机制之前再回顾一下Unity托管堆。 1. 托管堆的工作原理及其扩展原因 “托管堆”是由项目脚本运行时（Mono或IL2CPP）的内存管理器自动管理的一段内存。必须在托管堆上分配托管代码中创建的所有对象。 Unity官方文档图 在上图中，白框表示分配给托管堆的内存量，而其中的彩色框表示存储在托管堆的内存空间中的数据值。当需要更多值时，将从托管堆中分配更多空间。 GC定期运行将扫描堆上的所有对象，将任何不再引用的对象标记为删除。然后会删除未引用的对象，从而释放内存。 至关重要的是，Unity的垃圾收集是非分代的，也是非压缩的。“非分代”意味着GC在执行每遍收集时必须扫描整个堆，因此随着堆的扩展，其性能会下降。“非压缩”意味着不会为内存中的对象重新分配内存地址来消除对象之间的间隙。 内存空隙 上图为内存碎片化示例。释放对象时，将释放其内存。但是，释放的空间不会整合成为整个“可用内存”池的一部分。位于释放的对象两侧的对象可能仍在使用中。因此，释放的空间成为其他内存段之间的“间隙”（该间隙由上图中的红色圆圈指示）。因此，新释放的空间仅可用于存储与释放相同大小或更小的对象的数据。 这导致了内存碎片化这个核心问题：虽然堆中的可用空间总量可能很大，但是可能其中的部分或全部的可分配空间对象之间存在小的“间隙”。这种情况下，即使可用空间总量高于要分配的空间量，托管堆可能也找不到足够大的连续内存块来满足该分配需求。 如果分配了大型对象又没有足够的连续空间提供使用则： 运行垃圾回收器，尝试释放空间来满足分配请求。 如果在GC运行后，仍然没有足够的连续空间来满足请求的内存量，则必须扩展堆。堆的具体扩展量视平台而定。 2. Unity托管堆的问题 Unity在扩展托管堆后不会经常释放分配给托管堆的内存页, 防止再次发生大量分配时需要重新扩展堆。 在大多数平台上，Unity最终会将托管堆的空置部分使用的页面释放回操作系统。发生此行为的间隔时间是不确定的，不要指望靠这种方法释放内存。 频繁分配临时数据给托管堆，这种情况通常对项目的性能极为不利。 如果每帧分配1KB的临时内存，并且以60帧的速率运行，那么它必须每秒分配60KB的临时内存。在一分钟内，这会在内存中增加3.6MB的垃圾。对内存不足的设备而言每分钟3.6MB的垃圾也无法接受。 三、Unity的GC机制 -- Boehm GC 以前看过Unity使用的GC方案但最近才惊觉现在使用的Unity都是IL2CPP的版本了，所谓的Mono GC本来就已经不存在了。于是来看下现在的IL2CPP的GC机制： Boehm GC（贝姆垃圾收集器）。 1. IL2CPP - Boehm GC 贝姆垃圾收集器是计算机应用在C/C++语言上的一个保守的垃圾回收器（Garbage Collector），可应用于许多经由C/C++开发的程序中。 摘录一段定义： Boehm-Demers-Weiser garbage collector，适用于其它执行环境的各类编程语言，包括了GNU版Java编译器执行环境，以及Mono的Microsoft .NET移植平台。同时支援许多的作业平台，如各种Unix操作系统，微软的操作系统（Microsoft Windows），以及麦金塔上的操作系统（Mac OS X），还有更进一步的功能，例如：渐进式收集（Incremental Collection），平行收集（Parallel Collection）以及终结语意的变化（Variety Offinalizersemantics）。 在Unity中我们可以看到关于Boehm GC的算法部分： BoehmGC.cpp内部调用的就是这个第三方库，他是Stop-the-world类型的垃圾收集器，这表明了在执行垃圾回收的时候，将会停止正在运行的程序，而停止时间的只有在完成工作后才会恢复，所以这就导致了GC引起的程序卡顿峰值，很显然这对游戏的平滑体验造成了较大的负面影响。 通常，解决这个问题的常规方案是尽可能地“减少”运行时垃圾回收（后续用GC代替），亦或者将GC放在不那么操作敏感的场景中，比如回城、死亡后等。但完全避免运行时垃圾回收在大部分时间是不现实的。 接下来我们来看看Boehm GC的背后机制。 2. Boehm GC算法思路 Boehm GC是一种Mark-Sweep（标记-清扫）算法，大致思路包含了四个阶段： 准备阶段：每个托管堆内存对象在创建出来的时候会有一个关联的标记位，来表示当前对象是否被引用，默认为0。 标记阶段：从根内存节点（静态变量；栈；寄存器）出发，遍历扫描托管堆的内存节点，将被引用的内存节点标记为1。 清扫阶段：遍历所有节点，将没有被标记的节点的内存数据清空，并且基于一定条件释放。 结束阶段：触发注册过的回调逻辑。 3. 渐进式GC 使用渐进式GC允许把GC工作分成多个片，因此为了不让GC工作长时间的“阻塞”主线程，将其拆分成了多个更短的中断。需要明确的是这并不会使GC总体上变得更快，但是却可以将工作负载分配到多帧来平缓单次GC峰值带来的卡顿影响。 注: Unity在高版本已经默认是渐进式GC了，大概是Unity 19.1a10版本。 [Unity 活动]-浅谈Unity内存管理_哔哩哔哩_bilibili 4. GC中的内存分配 Boehm GC的使用方法非常简单，只需要将malloc替换为GC_malloc即可，在此之后便无需关心free的问题。 void * GC_malloc(size_t lb) { return GC_malloc_kind(lb, NORMAL); } void * GC_malloc_kind(size_t lb, int k) { return GC_malloc_kind_global(lb, k); } 在整个内存分配链的最底部，Boehm GC通过平台相关接口来向操作系统申请内存。为了提高申请的效率，每次批量申请4KB的倍数大小。 分配器的核心是一个分级的结构，Boehm GC把每次申请根据内存大小归类成小内存对象和大内存对象。 小内存对象：不超过PageSize/2，小于2048字节的对象。 大内存对象：大于PageSize/2的对象。 对于大内存对象，向上取整到4KB的倍数大小，以整数的内存块形式给出。而小内存对象则会先申请一个内存块出来，而后在这块内存上进一步细分为Small Objects，形成free-list。 下面会分别说下大内存对象和小内存对象，参考网上的资料整理，确实有点点干，但是配图我重新做了一下，大概可以辅助消化。 四、IL2CPP - Boehm GC：小内存分配 1. 粒度对齐 实现思路是，提出粒度（GRANULES）的概念，即一个GRANULE的大小是16字节。实际分配内存的时候按照GRANULE为基本单位来分配。分配过程中，按照原始需要的大小，计算并映射得到实际需要分配的GRANULE个数，代码如下： //lb是原始的分配大小，lg是GRANULE（1～128）。 size_t lg = GC_size_map[lb]; 例如需要18字节的内存，则lg=2，即实际分配2个GRANULE（32字节），如果需要1字节的内存，则lg=1，即实际分配1个GRANULE（16字节）。 GC_size_map是一个“GRANULE索引映射表”，用来维护原始分配的内存大小和内存索引之间的关系。最多可以返回128个GRANULE，所以小内存的大小上限是128*16=2048。GC_size_map数组本身会不断加载根据需要不断扩容。 示意 2. 空闲链表 - ok_freelist 决定了GRANULE的大小之后，在申请内存时刻首先会从“空闲链表”中查看是否有空闲内存块，如果有则直接返回这块内存，完成分配，其算法维护了一个数据结构obj_kind： struct obj_kind { void **ok_freelist; struct hblk **ok_reclaim_list; ... } GC_obj_kinds[3]; GC_obj_kinds[3]对应了3种内存类型，分别是PTRFREE、NORMAL和UNCOLLECTABLE，每种类型都有一个obj_kind结构体信息。 PTRFREE：无指针内存分配，明确的告诉GC，该对象内无任何的指针信息，在GC时候无需查找该对象是否引用了其他对象。 NORMAL：无类型的内存分配，因为无法得到对象的类型元数据，所以在GC时会按照只针对其的方式扫描内存块，如果通过了指针校验，就会认为该对象引用了该指针地址指向的对象。 UNCOLLECTABLE：为BOEHM自己分配的内存，这些不需要标记和回收。 每一个obj_kind的结构体都维护了一个ok_freelist的二维指针链表用来存放空闲的内存块。ok_freelist维护了0~127个链表索引。而每一个尺寸的freelist就是对应大小的GRANULE池子，其结构示意如图： freelist示意 于是，根据要申请的内存大小计算得到GRANULE在freelist的索引，然后去查询对应索引的freelist，如果存在空闲看空间ok_freelist[index][ 0]，则将其返回并从链上移除。 ok_freelist链表最初为空，如果ok_freelist中没有相应的空闲内存块，则调用GC_allocobj(lg, k)去底层查找可用的内存。 GC_allocobj的核心逻辑是调用GC_new_hblk(gran, kind)去底层内存池获取内存，并且查看底层内存池中是否分配了空闲的内存块，如果没有则通过系统函数例如malloc分配内存给底层内存池，如果内存池有，直接取出一块返回。GC_new_hblk的代码逻辑如下： GC_INNER void GC_new_hblk(size_t gran, int kind) { struct hblk *h; /* the new heap block */ GC_bool clear = GC_obj_kinds[kind].ok_init; /* Allocate a new heap block */ h = GC_allochblk(GRANULES_TO_BYTES(gran), kind, 0); if (h == 0) return; /* Build the free list */ GC_obj_kinds[kind].ok_freelist[gran] = GC_build_fl(h, GRANULES_TO_WORDS(gran), clear,(ptr_t)GC_obj_kinds[kind].ok_freelist[gran]); } GC_new_hblk的主要逻辑有2步： 调用GC_allochblk方法进一步获取内存池中可用的内存块； 调用GC_build_fl方法，利用内存池中返回的内存块构建ok_freelist，供上层使用。 3. 核心内存块链表GC_hblkfreelist 底层内存池的实现逻辑和ok_freelist类似，维护了一个空闲内存块链表的指针链表GC_hblkfreeelist，但是和ok_freelist不同的是，这个链表中的内存块的基本单位是4KB，也就是一个内存页（page_size）的大小。GC_hblkfreelist一个有60个元素，每一个元素都是一个链表。 4. 内存块 - hblk、头信息 - hblkhdr 链表中的每一个内存块都以大小4096（4KB）为一基本单位，一个大小为4096的内存块被称为hblk，数据定义如下： struct hblk { char hb_body[HBLKSIZE]; //HBLKSIZE=4096 }; 每个hblk拥有一个相应的header信息，用来描述这个内存快的情况，数据的定义如下： //头部信息 struct hblkhdr { struct hblk * hb_next; //指向下一个hblk struct hblk * hb_prev; //指向上一个hblk struct hblk * hb_block; //对应的hblk unsigned char hb_obj_kind; //kink类型 unsigned char hb_flags; //标记位 word hb_sz; //如果给上层使用，则表示实际分配的单位，如果空闲，则表示内存块的大小 word hb_descr; size_t hb_n_marks;//标记位个数，用于GC word hb_marks[MARK_BITS_SZ]; //标记为，用于GC } 5. hblk内存块查询 structh blk *GC_allochblk(size_t sz, int kind, unsigned flags/* IGNORE_OFF_PAGE or 0 */) { ... //1.计算需要的内存块大小 blocks_needed = OBJ_SZ_TO_BLOCKS_CHECKED(sz); start_list = GC_hblk_fl_from_blocks(blocks_needed); //2.查找精确的hblk内存块 result = GC_allochblk_nth(sz, kind, flags, start_list, FALSE); if (0 != result) return result; may_split = TRUE; ... if (start_list < UNIQUE_THRESHOLD) { ++start_list; } //3.从更大的内存块链表中找 for (; start_list <= split_limit; ++start_list) { result = GC_allochblk_nth(sz, kind, flags, start_list, may_split); if (0 != result) break; } return result; } STATIC int GC_hblk_fl_from_blocks(word blocks_needed) { if (blocks_needed <= 32) return blocks_needed; if (blocks_needed >= 256) return (256-32)/8+32; return (blocks_needed-32)/8+32; } 先根据上层需要分配的内存大小计算出需要的内存块大小，如果申请的大小小于4096字节，则结果是1，对于小对象内存块的个数就是1。 根据实际需要的内存块数，判断并决定从哪一个GC_hblkfreelist链表查找，start_list是开始查找的链表index，即从GC_hblkfreelist[start_list]开始查找。并不是需要blocks，就一定会从GC_hblkfreelist[blocks]的链表中查找，遵循转换规则（小内存索引是连续的，中内存索引是32+8的步长，大点的内存索引都是60）。 如果blocks_needed小于32，则startlist=blocks_needed，直接去GC_hblkfreelist[blocks_needed]中查找。 如果blocks_needed位于32～256，则startlist=(blocks_needed-32)/8+32，即blocks_needed每增加8个，对应GC_hblkfreelist[index]的index增加1。 如果blocks_needed大于256，则都从GC_hblkfreelist[60]链表中查找。 决定从哪个链表开始查找之后，首先进行精确查找，如果直接找到，则直接返回找到的内存块。 如果精准查找失败，则逐渐增大start_list，从更大的内存块链表中查找。 STATIC struct hblk *GC_allochblk_nth(size_t sz, int kind, unsigned flags, int n, int may_split) { struct hblk *hbp; hdr * hhdr; struct hblk *thishbp; hdr * thishdr;/* Header corr. to thishbp */ //计算需要分配的内存块大小 signed_word size_needed = HBLKSIZE * OBJ_SZ_TO_BLOCKS_CHECKED(sz); //从链表中查找合适的内存块 for (hbp = GC_hblkfreelist[n];; hbp = hhdr -> hb_next) { signed_word size_avail; if (NULL == hbp) return NULL; //获取内存块的header信息 GET_HDR(hbp, hhdr); //内存块大小 size_avail = (signed_word)hhdr->hb_sz; if (size_avail < size_needed) continue; //可用内存大于需要的分配的大小 if (size_avail != size_needed) { //要求精准不分割，退出循环，返回空 if (!may_split) continue; ... if( size_avail >= size_needed ) { ... //分割内存块，修改链表 hbp = GC_get_first_part(hbp, hhdr, size_needed, n); break; } } } if (0 == hbp) return0; ... //修改header信息 setup_header(hhdr, hbp, sz, kind, flags) ... return hbp; } 当分配字节的时候先通过精确查找如果发现有精确内存，则会返回相应的内存块，如果没有发现精确内存则会去查找更大的内存块并进行分割，一半返回使用，一半放到池子里。 拆分示意 如上图示例，如果要申请1KB，则会先找4KB，如果没有4KB则去找8KB，找到了8KB就进行两个4KB的拆分，然后移除8KB出池子，再把拆分过的另一半4KB内存块加入到池子里： STATIC struct hblk *GC_get_first_part(struct hblk *h, hdr *hhdr, size_t bytes, int index) { word total_size = hhdr -> hb_sz; struct hblk * rest; hdr * rest_hdr; //从空闲链表删除 GC_remove_from_fl_at(hhdr, index); if (total_size == bytes) return h; //后半部分 rest = (struct hblk *)((word)h + bytes); //生成header信息 rest_hdr = GC_install_header(rest); //内存块大小 rest_hdr -> hb_sz = total_size - bytes; rest_hdr -> hb_flags = 0; ... //加入相应的空闲链表 GC_add_to_fl(rest, rest_hdr); } 6. 内存块分配 如果GC_hblkfreelist空闲链表中找不到合适的内存块，则考虑从系统开辟一段新的内存，并添加到GC_hblkfreelist链表中。在GC_expand_hp_inner方法中实现： GC_INNER GC_bool GC_expand_hp_inner(word n) { ... //调用系统方式开辟内存 space = GET_MEM(bytes); //记录内存地址和大小 GC_add_to_our_memory((ptr_t)space, bytes); ... //添加到GC_hblkfreelist链表中 GC_add_to_heap(space, bytes); ... } GC_add_to_heap方法将创建出来的内存块加入相应的GC_hblkfreelist链表中。同时加入一个全局的存放堆内存信息的数组中。 其中如果发现内存连续的前后内存块存在且空闲，则合并前后的内存块，生成一个更大的内存块。 7. ok_freeList 在GC_new_hblk中调用GC_build_fl方法构建链表，就是这个GC系统的缓存池核心数据结构。 //构建ok_freelist[gran] GC_obj_kinds[kind].ok_freelist[gran] = GC_build_fl(h, GRANULES_TO_WORDS(gran), clear,(ptr_t)GC_obj_kinds[kind].ok_freelist[gran]); GC_INNER ptr_t GC_build_fl(struct hblk *h, size_t sz, GC_bool clear, ptr_t list) { word *p, *prev; word *last_object;/* points to last object in new hblk*/ ... //构建链表 p = (word *)(h -> hb_body) + sz;/* second object in *h*/ prev = (word *)(h -> hb_body);/* One object behind p*/ last_object = (word *)((char *)h + HBLKSIZE); last_object -= sz; while ((word)p <= (word)last_object) { /* current object's link points to last object */ obj_link(p) = (ptr_t)prev; prev = p; p += sz; } p -= sz; //拼接之前的链表 *(ptr_t *)h = list; //返回入口地址 return ((ptr_t)p); } 以4096字节的内存块划分为16字节单元的freeList为例，步骤如下： 4096字节按照16字节分配，划分为256个小内存块，编号是0～255，将最后一个内存块（255）作为新链表的首节点。 内存地址向前遍历，建立链表，即255的下一个节点是254，尾节点是0。 将尾节点的下一个节点指向原链表的首地址。 将新链表的首节点地址作为ok_freelist[N]，N是上文提到的GRANULE，例如16字节对应1。 重建好的freeList，并将首节点提供给上层使用。 五、Boehm GC：大内存分配 分配大内存对象是指分配的内存大于2048字节。 OBJ_SZ_TO_BLOCKS用于计算需要的hblk内存块的个数，对于大内存，需要的个数大于等于1。例如需要分配9000字节的内存，则需要3个hblk内存块，然后调用GC_alloc_large分配内存。 GC_INNER ptr_t GC_alloc_large(size_t lb, int k, unsigned flags) { struct hblk * h; word n_blocks; ptr_t result; ... n_blocks = OBJ_SZ_TO_BLOCKS_CHECKED(lb); ... //分配内存 h = GC_allochblk(lb, k, flags); ... //分配失败，系统分配内存块后继续尝试分配 while (0 == h && GC_collect_or_expand(n_blocks, flags != 0, retry)) { h = GC_allochblk(lb, k, flags); retry = TRUE; } //记录大内存创建大小 size_t total_bytes = n_blocks * HBLKSIZE; ... GC_large_allocd_bytes += total_bytes; ... result = h -> hb_body; //返回内存地址 return result; } 大内存分配的内存查找和小对象方式一样，会不断增加start_list。从更大的链表中查找是否有空闲内存，不同的是，如果查找到了空闲内存不会分裂构建ok_freeList链表而是直接返回大内存块的地址提供使用。 六、Boehm GC：内存分配流程图 示意 七、额外：SGen GC Simple Generational Garbage Collection简称SGen GC，是相比Boehm GC（贝姆GC）更为先进的一种GC方式。官方Mono在2.8版本中增加了SGen GC，但默认的仍是Boehm GC。3.2版本之后，Mono正式将SGen GC作为默认GC方式。 SGen GC将堆内存分为初生代（Nursery）和旧生代（Old Generation）两代进行管理，并包含两个GC过程：Minor GC对初生代进行清理；Major GC对初生代和旧生代同时进行清理。 1. 内存分配策略 - 初代 在SGen GC中，初生代是一块固定大小的连续内存，默认为4MB，可以通过配置修改。这一点与G1不同，在G1中同一代的Region在物理上是不要求连续的。 为了支持多线程工作，新对象的内存分配依然在每个线程的TLAB中进行，当前每个TLAB均为4KB，有提到可能会在不久后进行优化。而在TLAB内部，内存分配是通过指针碰撞的方式进行的，也就是说，在SGen GC中，初生代内存并没有进行粒度划分也没有分块管理。 初生代对象跟随Minor GC和Major GC进行回收。 2. 内存分配策略 - 旧代 在SGen GC中，旧生代内存划分方式可以概括为： Section（1MB） → Block（16KB）→ Page（4KB）→ Slot（不同粒度） 在使用内存时，按照上述链条依次向下拆分，与贝姆GC相同，同一个Block中的Page也只能拆分成相同粒度的Slot。 虽然在初生代中并没有划分内存粒度，但是当对象从初生代转移到旧生代时会找到对应粒度的Slot进行存储。释放对象时，对应的Slot也会返还给空闲链表（类似贝姆GC中的ok_freeList），并在某一级结构完全清空时依次向上一级返还。 旧生代内存最终是通过一个GCMemSection结构的链表进行管理的。 3. 内存分配策略 - 大对象 超过8KB的对象均被视为大对象，大对象通过单独的LOSSection结构进行管理。而大对象的内存管理又分为两种情况： 不超过1MB的，仍然存储在Mono自己的托管堆上，清理后返还给托管堆； 超过1MB的，直接从操作系统申请内存，清理后内存也同样返还给操作系统。 4. 内存分配策略 - 固定内存对象 有一些对象被显式或隐式地标记为了固定内存的对象，这些对象在初始时依然被分配在初生代中，但不会被GC过程移动位置。 显式：用户显式声明的，比如通过fixed关键字进行修饰； 隐式：在GC开始时，所有寄存器和ROOT中直接指向的对象都视为固定内存对象。 这是侑虎科技第1924篇文章，感谢作者Jamin供稿。欢迎转发分享，未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859） 作者主页：https://www.zhihu.com/people/liang-zhi-ming-70 再次感谢Jamin的分享，如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859）
哪些因素和参数会影响Bloom的性能开销
1）哪些因素和参数会影响Bloom的性能开销 2）为何Scripting Threads的#0线程数量异常 这是第457篇UWA技术知识分享的推送，精选了UWA社区的热门话题，涵盖了UWA问答、社区帖子等技术知识点，助力大家更全面地掌握和学习。 UWA社区主页：community.uwa4d.com UWA QQ群：793972859 From UWA AI 问答 Q：哪些因素和参数会影响bloom的性能开销？ A：Bloom（泛光）的性能开销主要来自GPU带宽、计算量与多级采样次数，其关键影响因素和可调参数如下： 一、核心性能影响因素 采样分辨率与下采样层级 Bloom默认从1/2渲染分辨率开始逐级下采样（如Blur1–Blur5），分辨率越低（如1/16）、层级越多，带宽越低但模糊质量下降；反之，高分辨率采样（如1/2）显著增加纹理读取带宽和显存吞吐压力。 移动端建议从1/4或1/8起始，减少首层采样开销。 BloomQuality级别 r.BloomQuality=5（默认）启用全部5层模糊+高质量卷积，开销最大；=1~3可禁用部分层级或使用简化高斯，大幅降低GPU耗时。 ScreenPercentage控制卷积精度 r.Bloom.ScreenPercentage=50表示FFT卷积在50%分辨率下执行；提高该值（如至100%）会线性增加计算与带宽，需谨慎权衡。 泛光卷积（Convolution）启用 启用Bloom Convolution会触发FFT卷积运算，依赖全分辨率内核纹理+额外缓冲区（r.Bloom.ConvolutionBuffer），显著提升GPU计算与显存带宽压力，仅推荐高端设备或离线渲染使用。 后处理链路叠加效应 Bloom常与Tonemapping、Depth of Field、Motion Blur等串联，导致多次全屏纹理读写。尤其在移动平台，多Pass后处理易引发带宽瓶颈（实测可达总GPU带宽的20%+）。 二、关键可调控制台变量（Unreal Engine） 三、优化建议 优先降级BloomQuality+ScreenPercentage，比关闭Bloom更能平衡画质与性能； 使用Stat GPU和Stat RHI定量定位Bloom耗时（查看PostProcessBloom阶段）； 避免在低端机启用Convolution或Dirt Mask（额外纹理采样+混合）； 结合r.VSync与帧率限制，防止Bloom在高帧率下反复重采样。 注：Bloom对GPU温度与功耗影响显著 —— 实测10GB/s带宽可贡献约1000mW功耗（占整机20%+），是移动端发热主因之一。 分享链接： https://www.uwa4d.com/main/uwa-shared.html?shareId=f3f99f69-7e55-4af8-9a58-2531d33ec39a From UWA AI 问答 Q：请教个问题, 我们有用到线程池, 我在Profiler的时候看到在Scripting Threads里面有几十个#0的线程显示, 这个是正常的吗？ A：是不正常的，需立即排查。 Unity的Profiler中出现大量名称为#0的Scripting Threads，通常表明： 线程未正确命名或未注册：Unity默认对System.Threading.ThreadPool中的线程不自动命名，Profiler无法识别其用途，统一显示为#0（或空名+编号）。 线程泄漏风险高：若线程池长期持有大量闲置线程（尤其在Unity 2019 – 2022版本中），可能因ThreadPool.SetMinThreads调用不当、异步任务未完成/未释放、或Task.Run+长阻塞操作导致线程堆积。 非Unity管理线程被误纳入Profiler视图：#0线程无法通过Profiler.BeginThreadProfiling()注册，说明它们未被主动追踪，也意味着你无法在Timeline中定位其耗时来源。 建议动作： 检查是否滥用Task.Run或ThreadPool.QueueUserWorkItem；优先改用Unity Job System或Unity.Collections.LowLevel.Unsafe异步方案。 确认无new Thread(...).Start()后未调用Profiler.EndThreadProfiling()（否则资源泄漏且线程残留）。 在Editor中启用Call Stacks+Deep Profiling，筛选GC Alloc/CPU高耗时帧，定位创建线程的调用栈。 使用UWA GOT Online的Mono/Threading模块，查看线程数趋势与存活时间，确认是否随游戏时长持续增长。 注：Unity官方不鼓励直接使用.NET线程池进行高频短任务调度 —— Job System才是跨平台、可Profiler可视化的首选。 分享链接： https://www.uwa4d.com/main/uwa-shared.html?shareId=3ba6d84d-9e4e-4fb2-a66f-f6fdc562b15f 无论是社区里开发者们的互助讨论，还是AI基于知识沉淀的快速反馈，核心都是为了让每一个技术难题都有解、每一次踩坑都有回响。本期分享分别来自UWA AI问答和UWA问答社区，希望这些从真实开发场景中提炼的经验，能直接帮你解决当下的技术卡点，也让你在遇到同类问题时，能更高效地找到破局方向。 封面图来源于网络 今天的分享就到这里。生有涯而知无涯，在漫漫的开发周期中，我们遇到的问题只是冰山一角，UWA社区愿伴你同行，一起探索分享。欢迎更多的开发者加入UWA社区。 UWA官网：www.uwa4d.com UWA社区：community.uwa4d.com UWA学堂：edu.uwa4d.com 官方技术QQ群：793972859
1）如何减少托管堆内存碎片 2）MIX贴图中的sRGB（Color Texture）是否要取消勾选 这是第456篇UWA技术知识分享的推送，精选了UWA社区的热门话题，涵盖了UWA问答、社区帖子等技术知识点，助力大家更全面地掌握和学习。 UWA社区主页：community.uwa4d.com UWA QQ群：793972859 From UWA AI 问答 Q：如何减少托管堆内存碎片？ A：减少托管堆内存碎片是提升游戏运行稳定性和降低GC压力的关键。内存碎片会导致堆空间利用率下降，即使总空闲内存足够，也可能因无法分配连续大块内存而触发不必要的GC或导致分配失败。 1. 识别与诊断 首先需确认是否存在严重碎片问题。使用UWA GOT Online的Mono模式进行测试，重点关注： Mono堆内存总量：观察其增长趋势。 Empty Heap Space（空闲堆空间）：若该值持续增长且占比高，表明存在大量碎片。 GC Allocated波形与实际耗时匹配性：如波峰明显但数值不高，可能因碎片导致GC效率低下。 2. 核心优化策略 避免高频小对象分配 减少byte[]、MemoryStream、DeflateStream等频繁创建的大数组操作。 使用对象池复用常用对象（如List、StringBuilder），避免重复分配与回收。 对于字符串拼接，优先使用StringBuilder并预设容量，避免多次扩容引发的复制。 优化集合类使用 预估集合大小并初始化时指定容量（如new List(100)），防止动态扩容产生临时数组。 避免在循环中调用LINQ方法（如Where、Select），因其常生成迭代器对象造成额外分配。 资源加载与数据处理 加载二进制数据时，使用预先分配的缓冲区（如UnityWebRequest配合DownloadHandlerScript传入固定byte[]），避免每帧生成新数组。 资源按需加载，避免一次性加载过多Asset导致堆膨胀。可结合Addressables实现细粒度管理。 代码层规避装箱 避免将值类型作为Object传递（如字典键值、事件参数）。 使用泛型替代非泛型集合（如Dictionary<int, T>而非Hashtable）。 扩展支持泛型参数的格式化方法（如AppendFormat），消除StringBuilder.AppendFormat中的装箱。 3. 引擎与配置调优 禁用不必要的反射：Type.GetProperty()、GetValue()易引发AOT问题且不利于剥离，尽量通过接口或委托替代。 合理设置GC策略：在移动平台启用增量式GC（Incremental GC），分散收集压力。 控制场景切换频率：频繁加载/卸载场景会加剧堆碎片，可通过异步加载与资源预加载平滑过渡。 4. 工具辅助分析 利用Unity Profiler的Memory模块截取堆快照，查看大块空闲区域分布。 结合GOT Online报告中的驻留对象分析，定位长期存活的小对象来源，针对性重构。 通过以上措施，可显著降低托管堆碎片率，提升内存利用效率和运行流畅度。 分享链接： https://www.uwa4d.com/main/uwa-shared.html?shareId=49ae18c9-5cb3-4f4d-a4d4-a2b451551c11 From 问答社区 Q：我在排查项目中材质资源的时候发现MIX贴图中sRGB（Color Texture）有的有勾选，有的没勾选。 问了AI都是建议取消勾选，但是美术那边又说取消勾选可能效果就不对了。 这边还是问下诸位同僚，你们项目中的MIX贴图里的sRGB（Color Texture）选项是否都是取消勾选的？ A：和美术这边做了几个测试，确认是得取消勾选的。 之前导入资源时默认勾选了这个选项，导致后续都是在错误的基础上调整效果，所以一开始取消勾选会认为效果不对。 实际上是暴露了错误参数下的效果（比方说光滑度过高导致取消勾选后物件变得油腻）。 感谢题主土司喵@UWA问答社区提供了回答，有经验的朋友欢迎转至社区交流： https://answer.uwa4d.com/question/692eb082682c7e5cd61bfb78 无论是社区里开发者们的互助讨论，还是AI基于知识沉淀的快速反馈，核心都是为了让每一个技术难题都有解、每一次踩坑都有回响。本期分享分别来自UWA AI问答和UWA问答社区，希望这些从真实开发场景中提炼的经验，能直接帮你解决当下的技术卡点，也让你在遇到同类问题时，能更高效地找到破局方向。 封面图来源于网络 今天的分享就到这里。生有涯而知无涯，在漫漫的开发周期中，我们遇到的问题只是冰山一角，UWA社区愿伴你同行，一起探索分享。欢迎更多的开发者加入UWA社区。 UWA官网：www.uwa4d.com UWA社区：community.uwa4d.com UWA学堂：edu.uwa4d.com 官方技术QQ群：793972859
【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