Unity 气泡留言墙:无限滚动照片流的实现
一个展厅双屏一体机的视觉中心 —— 从数据驱动、无限循环滚动、自适应布局、到克隆补位的完整设计思路。
项目背景
在一台双屏留言签名一体机中,主屏(操作屏)和副屏(面向观众的大屏)的首页都需要一个动态的视觉中心。所有用户拍摄、留言、签名后合成的纪念照,不应该静静地躺在文件夹里——它们应该像气泡一样飘过屏幕,让整个空间充满鲜活的社区氛围。
这就是 BubbleVoiceWall,一个能无限循环滚动展示用户生成内容的"气泡墙"。
核心设计
产品形态
┌─────────────────────────────────────────────────┐
│ Row 0: [PHOTO] [PHOTO] [PHOTO] [PHOTO] →→→ │
│ Row 1: [PHOTO] [PHOTO] →→→ │
│ Row 2: [PHOTO] [PHOTO] [PHOTO] →→→ │
│ │
│ (所有气泡持续向左滚动,超出左边界后从右侧补回) │
└─────────────────────────────────────────────────┘
每个"气泡"是一个包含照片、寄语文字、签名的卡片(SouvenirPhoto 组件)。气泡按行排列,各行独立滚动,营造错落有致的视觉层次。
数据结构
组件内部维护了三套并行的数据结构:
// 原始 GameObject 引用,统一清理用
private List<GameObject> _bubbles;// 每个气泡的元数据:位置、所属行、宽度、数据源
private List<BubbleInfo> _bubbleInfos;// 按行分组的同一批气泡,方便逐行操作
private List<List<BubbleInfo>> _rowBubbles; // [rowIndex] → 该行的气泡列表
BubbleInfo 是一个内部类,记录了气泡的核心属性:
private class BubbleInfo
{public RectTransform transform; // 位置和尺寸public int assignedRow; // 所属行 (0..rows-1)public float width; // 实际渲染宽度public SouvenirPhotoData sourceData; // 数据源
}
无缝无限滚动
每帧滚动
在 Update() 中,所有气泡每帧向左移动:
info.transform.anchoredPosition += Vector2.left * floatSpeed * Time.deltaTime;
边界回绕
当一个气泡完全移出左边界时,将它重置到该行最右侧:
if (x < -(canvasWidth / 2 + 300)) // 超出左边界
{ResetBubblePosition(info); // 回绕到该行最右侧
}
floatSpeed = 80 像素/秒(由 config.json 配置),意味着在 1920px 宽的屏幕上,一个气泡从出现到完全穿过大约需要 24 秒——不疾不徐,观赏性刚好。
ResetBubblePosition 的多行逻辑
private void ResetBubblePosition(BubbleInfo info)
{var rowList = _rowBubbles[info.assignedRow];float rightmostX = rowList.Where(b => b != info).Max(b => b.transform.anchoredPosition.x + b.width);float startX = rightmostX + _bubbleSpacing + info.width;info.transform.anchoredPosition = new Vector2(startX, currentY);
}
不是简单地放到最右,而是放到该行现有最右气泡的右侧,加上间距。这样即使某行气泡数量不均(有的被克隆补位),回绕后仍保持连续无缝的视觉效果。
自适应布局
Pivot 感知定位
同一套代码同时服务操作屏和面向观众的大屏,两块屏幕的 Canvas 锚点可能不同。组件通过检查父级 RectTransform 的 pivot 自动适配:
private float CalculateStartXForRow()
{float canvasWidth = _parentRectTransform.rect.width;if (_parentRectTransform.pivot.x < 0.1f) // 左对齐(大屏常见)return canvasWidth + _spacing; // 气泡从右侧进入else if (_parentRectTransform.pivot.x > 0.9f) // 右对齐return _spacing; // 气泡从左侧进入else // 居中(操作屏典型)return canvasWidth / 2 + _offset;
}
竖直方向同理——pivot 在顶部时 Y 偏移为负,在中间时使用半高计算。一套代码适配所有锚点布局。
最小气泡数保证(克隆补位)
某些行的气泡可能偏少(比如数据源数量不够分配)。为了不让画面上出现空洞,ArrangeAllBubbles 会在帧末布局后检查每行数量:
// 计算填满屏幕一行所需的气泡数
float bubbleWidth = prefabWidth * _scale + _bubbleSpacing;
int minPerRow = Mathf.CeilToInt(_parentRectTransform.rect.width / bubbleWidth);// 不够就克隆该行已有气泡
while (rowList.Count < minPerRow)
{int randomIdx = Random.Range(0, rowList.Count);var newBubble = CloneBubble(rowList[randomIdx]);// 放置到该行末尾
}
CloneBubble 从该行已有的气泡中随机挑选一个复制——创建新 GameObject,拷贝 SouvenirPhoto 数据(同一张照片/寄语/签名),继承相同的 scale 和位置。这不是标准对象池(克隆体不会被回收复用),而是更简单的"按需填充"策略:
- 数据源充足(几十张照片)时,克隆几乎不触发
- 数据源稀少(刚启动,只有几张)时,重复出现的内容让画面看起来更充实
- 内容逐渐丰富后,克隆体会被
PopulateBubbleWall全量刷新清除
数据来源
两种路径
1. 磁盘加载(历史照片)
PopulateBubbleWall() 在页面展示时调用,从 config/HistoryImag/ 加载所有保存的合成 PNG:
var photos = SharedState.LoadPhotosFromFolder(folder); // 扫描 PNG 文件
SharedState.Shuffle(photos); // Fisher-Yates 洗牌
foreach (var data in photos)_bubbleVoiceWall.AddPhotoBubble(data); // 逐个放入气泡墙
磁盘加载的照片是"复合型"(message 和 signature 已烘焙在 PNG 里),SouvenirPhoto.Populate() 检测到空寄语+空签名时,照片会自动撑满气泡全框。
2. 事件触发(新创建的照片)
用户在签名流程中完成合图保存后,SignatureColorSelectionPage 发布 SouvenirGeneratedEvent。首页监听此事件,立即将新照片追加进气泡墙:
// HomePage / HomeLargeScreenPage
private void OnSouvenirGenerated(SouvenirGeneratedEvent e)
{if (DisplayConfig.Load().SourceMode == 0) // 普通模式_bubbleVoiceWall.AddPhotoBubble(e.Data);
}
新创建的照片是"独立型"——持有独立的 photo、message、signature 三个引用,气泡卡会显示照片、文字、签名三区域。
双模式切换
通过 DisplayConfig.SourceMode 支持两种运行模式:
| 模式 | 数据来源 | 适用场景 |
|---|---|---|
| Mode 0(普通) | HistoryImag/ + 当前会话新建 |
日常运行 |
| Mode 1(特展) | 指定文件夹 | 展览/活动定制 |
按 Tab 键或程序调用 ToggleMode() 即时切换。
配置驱动
气泡墙的所有视觉参数由 config.json 控制:
{"floatSpeed": 80,"bubbleSize": 1.0,"fontSize": 1.0,"startXOffset": 0.0,"rowOffset": 75.0
}
| 参数 | 类型 | 说明 |
|---|---|---|
floatSpeed |
float | 滚动速度(像素/秒),默认 80 |
bubbleSize |
float | 气泡整体缩放系数 |
fontSize |
float | 寄语文字缩放系数 |
startXOffset |
float | 初始水平偏移(微调位置) |
rowOffset |
float | 行间水平错位值(制造错落感) |
Awake() 时从 JSON 反序列化,覆盖 Inspector 默认值。改配置不需要重新打包,重启应用即生效。
语音气泡预留
代码中保留了 AddVoiceBubble(float time, string voiceName) 方法:
public void AddVoiceBubble(float time, string voiceName)
{GameObject bubble = Instantiate(_bubblePrefab, _parentRectTransform);SetupBubbleAppearance(bubble, time); // 将时长显示为 "45''" 格式// ... 布局逻辑同照片气泡
}
- 输入音频时长(秒),气泡上以
"N''"格式显示 - 颜色硬编码橙色(
#FF6C2A) - 如果预制体缺少
Text组件,会程序化创建一个(用 Unity 内置 Arial 字体、全框居中)
这个方法在当前版本中未被任何页面调用——它是一个预留接口,为未来的语音留言功能做好了准备。
性能设计
不做的事情
| 没做的事 | 原因 |
|---|---|
| 标准对象池 | 气泡墙全量刷新(PopulateBubbleWall)不频繁,Instantiate/Destroy 开销可接受 |
| 每帧排序/查找 | _rowBubbles 按行预分组,ResetBubblePosition 只需查所在行 |
| Layout Group 自动布局 | 所有位置手动计算,避免 Layout 重建导致每帧 GC |
| 每帧读 Config | 只在 Awake 时加载一次 |
选择的手动管理
- 所有气泡位置通过
anchoredPosition直接赋值,不经过ContentSizeFitter/HorizontalLayoutGroup等自动布局组件 - 连续滚动时只有一个
Vector2.left * speed * dt的加法运算,没有重建、没有重算
与项目其他系统的关系
┌─────────────────┐│ EventBus ││ (静态 Pub/Sub) │└───────┬─────────┘│ SouvenirGeneratedEvent┌───────────────────┼───────────────────┐▼ ▼ ▼HomePage HomeLargeScreenPage HistoryPage│ │▼ ▼BubbleVoiceWall BubbleVoiceWall(操作屏实例) (大屏实例)│ │└─────────┬─────────┘│ 共用同一套数据▼SharedState.SouvenirPhotos+ 磁盘 HistoryImag/
操作屏和大屏各有一个独立的 BubbleVoiceWall 实例(分别挂在自己的 Canvas 下),但它们读取的是同一份数据源(SharedState.SouvenirPhotos + 磁盘文件)。两者独立滚动、独立布局,互不干扰。
写在最后
BubbleVoiceWall 的设计哲学是用简单的机制产生丰富的视觉感受。没有粒子系统、没有 Shader 特效、没有 Unity Timeline——就靠一行 Vector2.left * speed * dt 的匀速滚动、Fischer-Yates 的随机洗牌、以及几行 Pivot 感知的定位逻辑,做出了一个能撑起展厅大屏的视觉中心。
技术上的亮点不在于炫技,而在于恰到好处的工程设计:
- 克隆补位机制让内容稀疏时也不显空洞
- Pivot 感知布局让同一套代码适配多种屏幕锚点
- JSON 驱动配置让非技术人员也能调整展示效果
- 语音气泡预留接口让功能扩展变得简单
如果再加一个迭代,我会考虑:
- LOD 气泡:远离视口中心的气泡逐渐缩小,制造深度感
- 触控交互:点击操作屏上的气泡,大屏高亮放大对应的照片
- Viewport 剔除:完全不可见的气泡暂时禁用 Canvas 渲染(不过在当前规模下还不需要)
