☰
Unity照片墙性能优化实战:从布局到异步加载与对象池
2026/10/4 11:29:54 网站建设 项目流程

简介:这份资源面向Unity开发者与游戏视觉设计学习者,聚焦在引擎中实现照片墙效果这一具体场景,适合具备一定编辑器操作基础、希望提升场景表现力的中级开发者。压缩包共310个文件,约1.54MB,以png图片素材、meta资源索引、asset场景配置、unity场景文件与cs脚本为主,另含dll插件、xml与json配置等,覆盖从素材到工程配置的完整结构。已有525人学习下载,说明该效果在实战项目中具有较高参考价值。资源围绕平面几何体搭建展示面、自定义材质与纹理贴图、C#脚本控制图片切换与淡入淡出过渡、灯光增强立体感以及点击缩放拖动等交互展开,并涉及Asset Store插件与项目目录规范管理,可帮助读者快速理解照片墙从布局、材质到交互的完整实现思路,并直接对照工程文件进行复现与二次开发。

1. 照片墙不只是排个网格:Unity 里做「能跑起来」的相册墙到底难在哪

很多人第一次接到「Unity 实现照片墙效果」这个需求时,脑子里浮现的是 UGUI 里拖几个 Image、挂个 GridLayoutGroup,完事。真上手才发现,几十张照片一铺,帧率掉得比想象中快,滚动一卡一卡,图片加载还时不时白块。照片墙在 Unity 里本质是三个问题叠在一起:布局怎么排、图片怎么异步加载进内存、滚动时怎么复用和裁剪。它适合做数字展厅、相册类 App、大屏互动、VR 相册这类场景,也常被拿来做 unity 数字孪生项目里的素材墙。这篇不聊虚的,从最小可跑版本讲到滚动复用和内存控制,把参数、命令和踩过的坑都摊开,新手能照着搭,熟手能直接拿去改。

2. 照片墙的三种实现路线:UGUI、UI Toolkit 还是自己算坐标

2.1 先想清楚照片墙的规模,再决定用哪套 UI

选型不是看哪个新,是看你的照片数量和交互复杂度。我一般按下面这张表来分:

方案适合照片量优点代价
UGUI + GridLayoutGroup20 张以内拖拽即用,零代码数量一多布局重算开销大
UGUI + 手动坐标 + 对象池50~500 张可控、复用成熟要自己写滚动和回收
UI Toolkit + ListView100 张以上虚拟化内建,性能好生态和调试习惯要重新适应

标题里说的是「照片墙效果」,绝大多数落地场景是几十到几百张,还要支持滚动、点击放大。GridLayoutGroup 在数量上去之后,每次布局都会触发整棵 UI 树的重建,这是卡顿的根源。所以中间规模我推荐 UGUI 手动算坐标加对象池,这也是目前社区里最稳、资料最多的做法。UI Toolkit 的 ListView 自带虚拟化,但和传统 UGUI 混用时层级、输入事件容易打架,团队不熟就别硬上。

2.2 用 GridLayoutGroup 搭一个最小可跑的照片墙

先给一个能立刻看到效果的最小版本,适合验证素材和交互,不适合直接上生产。

using UnityEngine; using UnityEngine.UI; public class SimplePhotoWall : MonoBehaviour { public GameObject photoPrefab; // 带 Image 的预制体 public Sprite[] photos; // 先拖几张测试图 void Start() { var grid = GetComponent<GridLayoutGroup>(); // 单元格尺寸要和图片宽高比一致,否则会拉伸变形 grid.cellSize = new Vector2(200, 200); grid.spacing = new Vector2(10, 10); grid.constraint = GridLayoutGroup.Constraint.FixedColumnCount; grid.constraintCount = 4; // 每行 4 张 foreach (var sp in photos) { var go = Instantiate(photoPrefab, transform); go.GetComponent<Image>().sprite = sp; } } }

逻辑说明:GridLayoutGroup 负责排列,cellSize 决定每格大小,constraintCount 决定列数。参数上最容易翻车的是 cellSize 和图片宽高比不一致,照片会被拉扁。测试阶段这样够用,但一旦照片超过二三十张,每次增删子物体都会触发一次完整布局重建,滚动时尤其明显。所以这个版本只用来确认视觉和点击逻辑,真正上线要换成手动布局加对象池。

2.3 手动算坐标:把布局控制权拿回来

手动布局的核心是把「第 i 张照片放在第几行第几列」算成一个纯函数,不依赖任何 Layout 组件。

// 根据索引算出在内容容器里的 anchoredPosition Vector2 GetPosition(int index, int columns, Vector2 cell, Vector2 spacing) { int row = index / columns; int col = index % columns; float x = col * (cell.x + spacing.x); float y = -row * (cell.y + spacing.y); // UGUI 的 y 轴向下为负 return new Vector2(x, y); }

逻辑说明:UGUI 里 RectTransform 的锚点如果设在左上角,x 向右增大,y 向下减小,所以行号要取负。参数 columns 是每行列数,cell 是单元格尺寸,spacing 是间距。这个函数不产生任何 GC,可以每帧调用。把内容容器的 pivot 设成 (0,1),也就是左上角,坐标计算最直观。这一步做完,布局开销从「整棵树重建」变成「一次乘法」,这是照片墙能不能扛住几百张的分水岭。

3. 图片异步加载与内存:照片墙真正的性能瓶颈

3.1 为什么不能直接 Resources.Load 一把梭

新手最常见的写法是循环里 Resources.Load 所有图片,或者干脆把几十张图全拖进 Sprite 数组。前者会把整个 Resources 文件夹打进包体,后者让所有纹理常驻内存。一张 1024×1024 的 RGBA32 纹理占 4MB,一百张就是 400MB,手机上直接崩。照片墙的正确姿势是:只加载可视区域内的图片,滚出视野的释放或回池。加载方式优先用 Addressables 或 AssetBundle 做异步加载,本地测试可以用 UnityWebRequest 读 StreamingAssets 下的文件。

3.2 用 UnityWebRequest 异步加载本地图片并转成 Sprite

下面这段是本地相册场景里最常用的加载方式,支持 jpg/png,异步不卡主线程。

using System.Collections; using UnityEngine; using UnityEngine.Networking; using UnityEngine.UI; public class PhotoLoader : MonoBehaviour { public RawImage target; // 用 RawImage 省一次纹理拷贝 public IEnumerator Load(string path) { // file:// 前缀在部分平台必须加,否则请求失败 using (var req = UnityWebRequestTexture.GetTexture("file://" + path)) { yield return req.SendWebRequest(); if (req.result != UnityWebRequest.Result.Success) { Debug.LogError($"加载失败 {path}: {req.error}"); yield break; } var tex = DownloadHandlerTexture.GetContent(req); target.texture = tex; } } }

逻辑说明:UnityWebRequestTexture 会把下载和解码放到工作线程,主线程只做赋值。参数上注意两点,一是路径前缀,Windows 和 Android 上不加 file:// 会直接报错;二是这里用 RawImage 而不是 Image,因为 Image 需要 Sprite,从 Texture2D 转 Sprite 会多一次内存拷贝。如果一定要用 Image,记得 Sprite.Create 之后管理好生命周期,否则纹理泄漏。加载失败一定要打日志,照片墙最常见的白块就是路径拼错或权限问题。

3.3 纹理压缩与尺寸:省内存最狠的一刀

加载方式决定峰值,纹理设置决定常驻。照片墙的缩略图根本不需要原图分辨率,一张 200×200 的格子放 1024 的图是浪费。我一般会在导入设置里把缩略图 Max Size 压到 512,格式用 ASTC 6x6(移动端)或 DXT1(桌面端不带透明通道时)。下面这张表是我实测过的内存对比,同一张 1024 图:

设置单张内存100 张常驻
RGBA32 10244 MB400 MB
RGBA32 5121 MB100 MB
ASTC 6x6 512约 0.33 MB33 MB

参数怎么改:选中纹理,Inspector 里 Max Size 设 512,Compression 选对应平台格式,勾上 Generate Mip Maps 只在需要缩小时才开,照片墙通常不需要。注意压缩格式在不同平台不通用,打包前按平台分别设置,别一套设置走天下。

4. 滚动复用与对象池:让几百张照片也能满帧

4.1 可视区域裁剪的判断逻辑

照片墙滚动的本质是:内容容器在动,但只有落在视口矩形内的格子才需要显示。判断一个格子的世界坐标是否在视口内,用 RectTransformUtility 最稳。

// 判断某个格子的 RectTransform 是否和视口相交 bool IsVisible(RectTransform cell, RectTransform viewport, Camera cam) { var cellRect = RectTransformUtility.CalculateRelativeRectTransformBounds(cam, cell); var viewRect = RectTransformUtility.CalculateRelativeRectTransformBounds(cam, viewport); return cellRect.Intersects(viewRect); }

逻辑说明:CalculateRelativeRectTransformBounds 返回的是世界空间包围盒,两个盒子相交就说明可见。参数 cam 在 Overlay 模式的 Canvas 下传 null 也能工作,但 Screen Space Camera 模式必须传对应相机。这个判断每帧对几百个格子跑一遍开销不小,实际项目里我会用索引范围代替:根据滚动位置直接算出当前可见的行号区间,只遍历这个区间,复杂度从 O(n) 降到 O(可见数量)。

4.2 对象池的回收与复用

对象池要解决的是「滚动时不停 Instantiate 和 Destroy」带来的 GC 尖峰。核心就两个队列:空闲池和活跃列表。

using System.Collections.Generic; using UnityEngine; public class PhotoPool { private readonly Stack<GameObject> pool = new Stack<GameObject>(); private readonly GameObject prefab; private readonly Transform parent; public PhotoPool(GameObject prefab, Transform parent) { this.prefab = prefab; this.parent = parent; } public GameObject Get() { var go = pool.Count > 0 ? pool.Pop() : Object.Instantiate(prefab, parent); go.SetActive(true); return go; } public void Release(GameObject go) { go.SetActive(false); pool.Push(go); // 不 Destroy,留着复用 } }

逻辑说明:Get 时优先从池里取,取不到才实例化;Release 时只 SetActive(false) 并压回栈,绝不 Destroy。参数上要注意池的容量上限,如果照片总量固定,池最多开到「一屏可见数量 + 缓冲行数」就够了,开太大反而占内存。滚动时对滚出视野的格子调 Release,对新进入视野的调 Get 并重新赋图。这里有个坑:重新赋图时如果上一张纹理没释放,内存会持续涨,所以 Release 时要顺手把 RawImage.texture 置空并触发一次 Resources.UnloadUnusedAssets,或者用引用计数管理纹理。

4.3 滚动惯性用 ScrollRect 还是自己写

能用 ScrollRect 就用它,惯性、回弹、滚动条都是现成的,你只需要接管它的内容布局和复用。做法是把 ScrollRect 的 content 设成手动布局的容器,监听 onValueChanged,在回调里根据 content.anchoredPosition 算出可见区间,做池的 Get/Release。自己写滚动只在需要特殊手感(比如弧形墙、3D 环绕)时才值得,普通照片墙没必要重复造轮子。注意 ScrollRect 的 movementType 设成 Elastic 时回弹会带来额外布局计算,照片量大时改成 Clamped 更稳。

5. 照片墙避坑清单:这五个坑我基本每个都踩过

5.1 图片显示成紫红色

现象:照片墙里部分格子是紫红色方块。原因:材质或 Shader 丢失,常见于打包后 Shader 没被正确包含,或者用了 URP 却还在用内置管线的默认材质。解决:确认项目渲染管线,UI 用 UI/Default 这类内置 Shader,打包时把用到的 Shader 加进 Always Included Shaders,或者用 ShaderVariantCollection 收集。

5.2 滚动时白块一闪而过

现象:快速滚动时新进入视野的格子先白一下再显示图片。原因:异步加载还没完成就赋给了 RawImage,纹理为空。解决:加载完成前先显示占位图或纯色,加载回调里判断格子是否还在可见区间,不在就直接丢弃结果,避免给已经复用的格子赋错图。

5.3 内存只涨不降

现象:来回滚动几次后内存持续上升,Profiler 里纹理数量只增不减。原因:旧纹理没有释放,或者 Sprite.Create 出来的对象没 Destroy。解决:Release 时清空引用,统一用引用计数管理纹理,确认没有其他格子引用后再 Destroy 并调用 Resources.UnloadUnusedAssets。注意 UnloadUnusedAssets 开销大,别每帧调,滚动停止后延迟调用。

5.4 点击放大后返回,位置错乱

现象:点开大图再返回,照片墙滚动位置跳回顶部或错位。原因:返回时重建了内容容器,anchoredPosition 被重置。解决:进入大图前记录 content.anchoredPosition,返回时恢复,并且复用同一批格子对象,不要重新实例化。

5.5 不同分辨率下格子大小不一致

现象:在手机和大屏上照片墙列数一样但格子被拉伸。原因:cellSize 写死了像素值,没考虑 CanvasScaler 的缩放。解决:CanvasScaler 用 Scale With Screen Size,参考分辨率设一个基准,cellSize 按参考分辨率算,或者干脆按屏幕宽度动态算列数和格子宽度,保证宽高比不变。

6. 进阶:把照片墙做成能扛住真实项目的组件

前面讲的都是单机本地照片。真实项目里照片墙往往还要面对动态增删、网络图、点击预览、甚至 unity 数字孪生场景里的实时贴图更新。我一般会把照片墙抽成一个独立组件,对外只暴露三个接口:SetData(列表)、ScrollTo(index)、OnClick 回调。内部把布局、加载、池、可见性判断全部封装,外部不关心实现。

验证一个照片墙做得好不好,我有个土办法:拿 500 张 512×512 的图,在目标机型上快速来回滚 30 秒,看 Profiler 里 GC Alloc 是不是接近 0,纹理内存是不是稳定在一个平台期。如果 GC 每帧都在涨,说明布局或加载里有临时对象没处理好;如果纹理内存一直爬,说明释放逻辑有漏。这两个指标过了,基本就能上线。

还有个容易被忽略的点是图片的宽高比。真实照片有横有竖,如果强行塞进正方形格子,要么裁要么变形。我的习惯是格子固定,图片用 RawImage 的 uvRect 做等比裁剪填充,或者干脆做瀑布流,每列宽度固定、高度按比例算。瀑布流比网格复杂,但视觉上更像真实相册,值不值得做取决于产品定位。

最后说个我自己的教训:早期做照片墙,我图省事把所有图打进 Resources,测试机上跑得好好的,一到真机就崩。后来改成 Addressables 按需加载,包体小了,内存也稳了。照片墙这种「看起来简单」的需求,性能问题几乎全在资源和内存上,布局反而是最简单的部分。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询