☰
Unity照片墙实战:UGUI瀑布流布局、对象池与异步加载优化
2026/10/5 7:51:51 网站建设 项目流程

简介:这份资源面向Unity开发者与游戏视觉设计学习者,聚焦在引擎中实现照片墙效果这一具体场景,适合具备一定编辑器操作基础、希望提升场景表现力的初中级人员。包内共310个文件,以png图片素材、meta资源索引、asset场景与配置资产、cs脚本、dll插件库及xml、json等配置说明为主,另有少量unity场景与sample示例,压缩包约1.54MB,整体结构便于按素材、脚本、配置分类查阅。已有525人学习下载,说明该效果在实战项目中具备一定参考价值。资源围绕平面几何体搭建展示面、自定义材质与纹理贴图、C#脚本控制图片切换与淡入淡出过渡、灯光营造立体感以及点击缩放拖动等交互展开,并涉及Asset Store插件与项目文件组织思路,可帮助读者快速理解照片墙从素材准备到动态交互的完整实现路径,并迁移到展厅、相册或游戏UI等场景中。

1. 照片墙不是把图铺满:Unity 里真正要解决的是布局、性能与交互

很多人第一次在 Unity 里做照片墙,直觉就是把一堆贴图拖进 Canvas,用 Grid Layout Group 一铺,觉得完事。真跑起来才发现:几十张图就开始掉帧,滚动时图片乱序,点击某张照片放大后返回,位置全错。照片墙效果在 Unity 里本质是三个问题的叠加——不规则布局怎么排、大量图片怎么省内存、点击缩放怎么不破坏原布局。它适合做相册展示、展厅大屏、数字孪生里的资产面板,也适合作为 UGUI 进阶练手项目。这篇笔记按「先能跑通最小版本,再谈性能与交互」的顺序拆,代码可以直接抄,参数我会标清楚为什么这么设。照片墙这个词听着简单,但它是 UGUI 布局、对象池、异步加载、动画状态管理的一次综合演练,值得认真做一遍。

2. 用 UGUI 搭出可滚动的照片墙骨架

2.1 为什么选 ScrollRect + 自定义布局而不是 Grid Layout Group

Grid Layout Group 的问题是它强制等宽等高,照片墙往往需要错落感,而且它每帧重算所有子物体位置,图一多就是性能黑洞。常见做法是用 ScrollRect 做滚动容器,内容节点挂一个自己写的布局脚本,按列高或瀑布流算法算位置。这样你能控制每张图的宽高比,也能只对可视区域内的图片做加载。

结构上分三层:最外层 ScrollRect,中间 Content(挂布局脚本和 Content Size Fitter),最里面是每个照片项 PhotoItem 预制体。PhotoItem 里放一个 Image 做缩略图、一个 Button 做点击、一个 Outline 做选中态。Content 的锚点要设成顶部拉伸,pivot 设 (0.5, 1),否则滚动方向会反。

2.2 瀑布流布局脚本与关键参数

下面这个脚本按固定列数、每列累加高度的方式排布,适合宽度固定、高度随图片比例变化的场景。

using UnityEngine; using UnityEngine.UI; [RequireComponent(typeof(RectTransform))] public class WaterfallLayout : MonoBehaviour { public int columnCount = 3; // 列数,手机竖屏一般 2-3 public float spacing = 12f; // 项间距,和 UI 缩放一起调 public float sidePadding = 16f; // 左右留白 private RectTransform _rect; private float[] _columnHeights; void Awake() => _rect = GetComponent<RectTransform>(); // 每次增删照片后调用一次,不要放 Update public void Rebuild() { int childCount = transform.childCount; if (childCount == 0) return; float totalWidth = _rect.rect.width - sidePadding * 2f; float itemWidth = (totalWidth - spacing * (columnCount - 1)) / columnCount; _columnHeights = new float[columnCount]; for (int i = 0; i < childCount; i++) { var item = transform.GetChild(i) as RectTransform; // 找到当前最矮的列,把这张图放进去 int col = FindShortestColumn(); float x = sidePadding + col * (itemWidth + spacing); float y = -_columnHeights[col]; item.anchorMin = new Vector2(0, 1); item.anchorMax = new Vector2(0, 1); item.pivot = new Vector2(0, 1); item.sizeDelta = new Vector2(itemWidth, itemWidth); // 高度按比例改 item.anchoredPosition = new Vector2(x, y); _columnHeights[col] += item.rect.height + spacing; } float maxHeight = 0f; foreach (var h in _columnHeights) maxHeight = Mathf.Max(maxHeight, h); _rect.sizeDelta = new Vector2(_rect.sizeDelta.x, maxHeight + sidePadding); } int FindShortestColumn() { int idx = 0; for (int i = 1; i < _columnHeights.Length; i++) if (_columnHeights[i] < _columnHeights[idx]) idx = i; return idx; } }

逻辑说明:Rebuild只在照片数量变化时调用,避免每帧计算。FindShortestColumn是瀑布流的核心,每次把新图塞进当前最矮的列,保证整体高度均衡。参数上,columnCount建议按屏幕宽度动态算,比如Mathf.Max(2, Mathf.FloorToInt(Screen.width / 400f)),这样平板和手机都能用。spacing和sidePadding要跟 Canvas Scaler 的参考分辨率匹配,否则不同分辨率下间距会失真。

提示:Content 上不要同时挂 Content Size Fitter 的 Vertical Fit 为 Preferred Size,会和手动设置的 sizeDelta 打架,二选一。

2.3 图片按比例撑满格子且不变形

照片宽高比五花八门,直接塞进正方形格子会拉伸。做法是给 Image 外面套一层 Mask 或 RectMask2D,Image 用preserveAspect保持比例,超出部分裁掉。如果想让格子高度跟着图片比例走,就在加载完 Sprite 后把item.sizeDelta.y改成itemWidth * (sprite.rect.height / sprite.rect.width),再调一次 Rebuild。这一步是照片墙好不好看的分水岭,别偷懒。

3. 大量照片的加载与内存控制

3.1 异步加载与缩略图策略

照片墙最容易翻车的地方是内存。原图动辄几 MB,一百张就是几百 MB,移动端直接崩。正确做法是加载时生成缩略图,只保留可视区域附近的原图。Unity 里可以用UnityWebRequestTexture异步加载本地或远程图片,加载完用Texture2D缩放成缩略图。

using UnityEngine; using UnityEngine.Networking; using System.Collections; public class ThumbnailLoader : MonoBehaviour { public int thumbSize = 256; // 缩略图边长,列表用 256 足够 public IEnumerator LoadThumb(string path, System.Action<Texture2D> onDone) { using (var req = UnityWebRequestTexture.GetTexture("file://" + path)) { yield return req.SendWebRequest(); if (req.result != UnityWebRequest.Result.Success) { Debug.LogWarning($"加载失败 {path}: {req.error}"); onDone?.Invoke(null); yield break; } var full = DownloadHandlerTexture.GetContent(req); var thumb = ScaleTexture(full, thumbSize, thumbSize); Destroy(full); // 原图立刻释放,别留着 onDone?.Invoke(thumb); } } Texture2D ScaleTexture(Texture2D src, int w, int h) { var rt = RenderTexture.GetTemporary(w, h); Graphics.Blit(src, rt); var prev = RenderTexture.active; RenderTexture.active = rt; var dst = new Texture2D(w, h, TextureFormat.RGB24, false); dst.ReadPixels(new Rect(0, 0, w, h), 0, 0); dst.Apply(); RenderTexture.active = prev; RenderTexture.ReleaseTemporary(rt); return dst; } }

逻辑说明:UnityWebRequestTexture在协程里跑,不阻塞主线程。加载完立刻把原图Destroy,只留缩略图,这是内存能压住的关键。thumbSize设 256 是因为列表里单张图显示尺寸通常不超过 300 像素,再大就是浪费显存。如果图片在 StreamingAssets 或 persistentDataPath,路径前缀要对应改。

3.2 对象池复用 PhotoItem

滚动时不断 Instantiate 和 Destroy 会产生 GC 峰值,表现就是滚动卡顿。用对象池:预先创建一屏能显示的数量的 PhotoItem,滚动时把移出屏幕的项回收,重新绑定数据。判断是否移出屏幕用RectTransformUtility.RectangleContainsScreenPoint或直接比较 anchoredPosition 和 ScrollRect 的 viewport 范围。

public class PhotoPool : MonoBehaviour { public GameObject itemPrefab; public RectTransform content; private readonly System.Collections.Generic.Queue<GameObject> _pool = new(); public GameObject Get() { if (_pool.Count > 0) { var go = _pool.Dequeue(); go.SetActive(true); return go; } return Instantiate(itemPrefab, content); } public void Release(GameObject go) { go.SetActive(false); _pool.Enqueue(go); } }

参数上,池的初始容量按「一屏可见数量 + 缓冲 4 个」来定,缓冲太少会频繁创建,太多浪费内存。回收时记得把 Image 的 sprite 置空并Resources.UnloadUnusedAssets的时机控制好,别每帧调。

3.3 用 Addressables 还是 Resources

Resources 文件夹简单但会把所有资源打进包体,且加载是同步的,图一多启动就卡。常见做法是用 Addressables 做按需加载和远程更新,缩略图打成一个 group,原图打另一个 group,点开大图时才加载原图。如果项目小、图不多,Resources 也能用,但要有心理准备后期迁移成本。选型上,只要照片墙的图会超过 50 张,我一般直接上 Addressables。

4. 点击放大、返回与动画衔接

4.1 从缩略图到大图的过渡动画

点击某张照片后放大,最忌讳的是瞬间切换,用户会失去空间感。做法是记录被点击 PhotoItem 的世界坐标和尺寸,在大图层用一个 Image 从该位置和尺寸插值到全屏,用Mathf.SmoothStep做缓动。

IEnumerator ZoomIn(RectTransform from, RectTransform to, float duration = 0.25f) { float t = 0f; Vector2 startPos = from.position; Vector2 endPos = to.position; Vector2 startSize = from.rect.size; Vector2 endSize = to.rect.size; while (t < 1f) { t += Time.deltaTime / duration; float e = Mathf.SmoothStep(0f, 1f, t); to.position = Vector2.Lerp(startPos, endPos, e); to.sizeDelta = Vector2.Lerp(startSize, endSize, e); yield return null; } }

逻辑说明:SmoothStep比线性 Lerp 更有「加速再减速」的手感,duration 设 0.2 到 0.3 秒最舒服,太慢显得拖沓。返回时反向跑一遍同样的插值,用户就能感知到「回到原来那张图」。注意动画期间要禁用 ScrollRect 的滚动,否则会跟手势冲突。

4.2 返回后布局错位的修复

血泪经验:放大再返回后,照片墙位置全乱,多半是因为动画改了 PhotoItem 的 anchoredPosition 却没还原。解决方法是动画只操作大图层,绝不碰列表里的原始项;或者动画前缓存原始 anchoredPosition,结束后强制还原并调一次 Rebuild。另外 ScrollRect 的velocity在动画期间要清零,不然松手惯性会把内容带偏。

4.3 手势与点击的冲突处理

ScrollRect 本身会拦截拖拽,导致 Button 的点击偶尔失灵。常见做法是给 PhotoItem 挂一个实现IBeginDragHandler的脚本,拖拽距离超过阈值就标记为滑动,OnPointerClick里判断这个标记,是滑动就不触发放大。阈值一般设 10 到 20 像素,太小会误判,太大点击迟钝。

5. 照片墙避坑与排查清单

5.1 图片显示成紫红色

现象:所有照片变成紫红色方块。原因:Shader 丢失或材质引用的 shader 在当前渲染管线里不存在,比如 URP 项目里用了 Built-in 的 UI/Default。解决:把 Canvas 的材质换成 URP 对应的 UI shader,或检查 Addressables 打包时 shader 有没有被剥离,在 Graphics Settings 里把用到的 shader 加进 Always Included Shaders。

5.2 滚动时图片闪烁或错位

现象:快速滚动时缩略图跳到别的格子。原因:异步加载回调回来时,PhotoItem 已经被对象池回收并绑定了新数据,旧回调把图贴到了错误的项上。解决:给每个加载请求带一个版本号或 token,回调时比对当前项的 token,不一致就丢弃结果。这是异步列表的经典坑,别省这个判断。

5.3 移动端内存暴涨后闪退

现象:浏览几十张图后 App 崩溃。原因:原图 Texture 没释放,或者缩略图用了TextureFormat.RGBA32且没压缩。解决:加载完立刻 Destroy 原图;缩略图用TextureFormat.RGB24;在 Profiler 里看Texture Memory曲线,正常应该稳定在一个平台期而不是持续爬升。另外注意Resources.UnloadUnusedAssets不要频繁调,它本身很重。

5.4 高分辨率屏上照片模糊

现象:同样的图在 2K 屏上糊。原因:缩略图尺寸按逻辑像素算,没乘 Canvas Scaler 的 scaleFactor。解决:thumbSize要乘以canvas.scaleFactor,或者干脆按屏幕物理分辨率的一屏尺寸来定缩略图边长。这个参数设错,用户第一眼就觉得「不清晰」。

5.5 点击放大后返回,滚动位置跳回顶部

现象:返回列表时内容滚到了最上面。原因:动画或重建布局时改了 Content 的 anchoredPosition。解决:进入大图前缓存content.anchoredPosition,返回后还原,再调 Rebuild。顺序不能反,先 Rebuild 再还原位置会被布局覆盖。

6. 进阶:让照片墙在低端机上也能跑满帧

前面跑通之后,真正拉开差距的是性能。我一般会做三件事。第一,把缩略图按可视区域分页加载,屏幕外两屏的项直接回收贴图,只留占位图。第二,用Canvas分层,把滚动内容和固定 UI 拆到不同 Canvas,减少重建范围,因为 UGUI 任何一个元素变脏都会触发整个 Canvas 的 batch 重建。第三,如果图量真的很大,考虑用UIElements或者干脆用Mesh自己拼,UGUI 在几千个元素时顶点数会爆。

验证方法很直接:打开 Profiler,看Canvas.BuildBatch和Canvas.SendWillRenderCanvases的耗时,正常应该低于 2ms;再看Texture Memory是否稳定。如果 BuildBatch 超过 5ms,就是 Canvas 拆分没做好。下面这个表是我常用的参数起点,按项目实际情况微调。

参数手机竖屏平板/大屏说明
列数2-34-6按屏宽除以 400 取整
缩略图边长256384乘 scaleFactor 后取整
对象池容量可见数+4可见数+6缓冲太少会频繁创建
动画时长0.25s0.3s大屏可稍慢
拖拽阈值15px20px防误触

还有一个容易被忽略的点:照片墙的滚动惯性。ScrollRect 的decelerationRate默认 0.135,手机上感觉偏「滑」,改成 0.1 到 0.2 之间按手感调。movementType设 Elastic 还是 Clamped 看需求,相册一般用 Clamped,避免拉出空白。

我自己踩过最深的坑是早期用同步Resources.Load加载缩略图,图一多启动直接卡死三秒,用户以为崩了。后来全部改成异步加对象池,启动时间降到 200ms 以内。做照片墙,先把加载和回收这两件事做扎实,再谈动画和视觉,顺序反了就是给自己挖坑。希望帮到你。

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

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

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

立即咨询