三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

Unity动态分屏系统:从原理到实现,打造灵活多人同屏体验

Unity动态分屏系统:从原理到实现,打造灵活多人同屏体验

1. 项目概述:为什么我们需要一个动态分屏方案?

在Unity项目中实现分屏,听起来像是个基础功能,不就是调整几个摄像机的Viewport Rect吗?确实,对于固定不变的双人同屏游戏,比如经典的横版过关游戏,直接在编辑器里设置好两个摄像机的视口矩形(一个左半屏,一个右半屏)就完事了。但当我们面对更复杂的需求时,这种静态配置的局限性就暴露无遗。比如,一个支持2-4名玩家随时加入退出的派对游戏;或者一个需要根据玩家位置动态合并、分割屏幕的开放世界合作游戏;又或者是在VR/AR应用中,需要为不同用户显示不同的透视视图。这时,一个简单、灵活且可编程的动态分屏(Dynamic Splitscreen)系统就成了必需品。

Dynamic-Splitscreen这个项目标题,精准地指向了这类需求的核心:动态灵活。它意味着分屏的布局不是预先写死的,而是在运行时根据一系列规则(如玩家数量、玩家间的距离、焦点目标)实时计算和调整的。这不仅能提升游戏的沉浸感和玩法深度,也是应对现代游戏多样化体验挑战的一种优雅技术方案。我曾在多个合作类项目中手动实现过类似系统,踩过不少坑,也积累了一些让代码既强大又易于维护的心得。接下来,我就基于一个通用的Dynamic-Splitscreen管理器设计思路,拆解其核心原理、实现细节以及那些官方手册里不会告诉你的实战技巧。

2. 核心设计思路:从静态分割到动态布局

静态分屏的本质是空间分配,而动态分屏的核心是规则引擎空间仲裁。我们首先要抛弃“一个摄像机对应一块固定屏幕区域”的思维,转而思考:在某一帧,屏幕这个总空间应该如何分配给当前存在的所有“视点”(View),而分配的依据是什么?

2.1 动态分屏的三大核心规则

一个健壮的动态分屏系统通常由以下几类规则驱动,它们共同决定了最终的屏幕布局:

  1. 玩家数量规则:这是最基础的规则。1个玩家全屏,2个玩家水平或垂直平分,3个玩家可能呈“上一下二”或“左一右二”的“品”字形,4个玩家则均匀四等分。这部分逻辑相对固定,可以预定义几种布局模板(Layout Template)。

  2. 玩家距离/相关性规则:这是实现“动态”魅力的关键。当两个玩家控制的角色在游戏世界中距离很远时,他们需要独立的屏幕来关注各自的环境;而当他们靠近甚至同屏时,他们的视野内容大量重叠,继续分割屏幕会导致显示冗余,此时系统可以动态地将他们的视口合并,共享一块更大的屏幕区域,甚至暂时回归单屏。这通常通过计算玩家角色包围盒(Bounds)的距离或相交面积来实现。

  3. 焦点与优先级规则:在某些场景下,比如某个玩家触发了关键事件(如Boss战、解谜),系统可能需要临时放大该玩家的视口,其他玩家视口则缩小或移至角落。这需要为每个视点设计一个优先级权重,并在布局计算时予以考虑。

2.2 系统架构设计

基于以上规则,我们可以设计一个中心化的SplitscreenManager单例类,它负责在每帧或当特定事件(玩家加入/退出、玩家距离变化)发生时,执行以下工作流:

  • 收集状态:获取所有活跃玩家的视点控制器(一个继承了MonoBehaviourPlayerViewController)及其关联的游戏对象(角色)。
  • 评估规则:根据当前玩家集合,应用上述规则,计算出一个理想的“布局描述”(Layout Description)。这个描述不是一个直接的Rect数组,而是一个包含视口分组(哪些玩家共享一个视口)、每组权重等信息的中介数据结构。
  • 计算视口矩形:根据“布局描述”和当前屏幕分辨率,计算出每个PlayerViewController最终应获得的标准化视口矩形(Rect,其值在[0,1]区间)。
  • 应用布局:将计算好的Rect赋值给每个PlayerViewController所控制的摄像机(Camera)的rect属性。
  • 平滑过渡:如果新的布局与旧布局差异较大,直接切换会导致画面跳变。因此,管理器还需要驱动每个视口矩形进行平滑的插值过渡(Lerp)。

这个架构的关键在于将规则判断布局计算渲染执行解耦,使得每部分都可以独立扩展和调试。

3. 关键技术点实现与细节解析

理解了宏观设计,我们来深入几个具体的技术实现环节,这些地方往往是决定系统稳定性和性能的关键。

3.1 视口矩形(Viewport Rect)的精准计算

Unity摄像机的rect属性接受一个Rect结构体,其中xy表示视口左下角在屏幕标准化坐标中的位置,widthheight表示视口的宽和高。所有值应在0到1之间。 计算多个视口在屏幕上的排列,本质上是一个**二维空间装箱(2D Bin Packing)**问题,但为了实时性能,我们通常采用简单的行列分割算法。

示例:实现一个灵活的网格布局算法假设我们有一个玩家ID列表和指定的行数、列数,以下函数可以计算每个玩家的视口矩形:

// 在 SplitscreenManager 类中 Dictionary<int, Rect> CalculateGridLayout(List<int> playerIds, int rows, int cols) { var layout = new Dictionary<int, Rect>(); float cellWidth = 1.0f / cols; float cellHeight = 1.0f / rows; for (int i = 0; i < playerIds.Count; i++) { int row = i / cols; int col = i % cols; // 确保索引不超出网格范围(当玩家数少于网格单元格时) if (row >= rows) break; Rect rect = new Rect(col * cellWidth, 1 - ((row + 1) * cellHeight), cellWidth, cellHeight); layout[playerIds[i]] = rect; } return layout; }

注意:这里Recty坐标计算是1 - ((row + 1) * cellHeight),因为Unity的视口坐标系原点在左下角,而我们的网格计算通常从上到下编号。1 - (row+1)*height得到了该行左下角的y坐标。

更复杂的合并单元格计算:当需要合并相邻玩家的视口时(基于距离规则),问题就变成了动态单元格合并。我们可以在网格布局的基础上,引入一个“合并组”的概念。先为每个玩家分配一个基础的网格单元格,然后遍历所有玩家对,如果满足合并条件(如距离小于阈值),就将他们标记为同一组。最后,计算每个组的包围矩形(该组所有单元格的并集),作为该组共享的视口。

3.2 基于距离的动态合并与分割

这是动态分屏的“灵魂”所在。实现步骤如下:

  1. 距离检测:在PlayerViewController中,每帧或每隔几帧计算其代表角色与其他玩家角色之间的距离。为了优化,可以使用Physics.OverlapSphere配合图层(Layer)过滤,或者在管理器中维护一个位置列表进行两两比较(O(n²)复杂度,玩家少时可用)。
  2. 阈值判断:设定两个阈值——mergeDistance(合并距离)和splitDistance(分割距离)。当距离小于mergeDistance时,触发合并意向;当距离大于splitDistance时,触发分割意向。通常splitDistance>mergeDistance,以避免在阈值附近频繁抖动(Hysteresis)。
  3. 状态管理:为每对玩家维护一个“合并状态”。不要直接根据当前距离切换,而是引入一个简单的状态机(如Separated,Merging,Merged,Splitting),并设置一个短暂的延时或平滑过渡时间,让屏幕的合并与分割有一个柔和的过程,避免生硬切换。
// 简化的状态判断逻辑 float currentDistance = Vector3.Distance(playerA.Position, playerB.Position); if (currentDistance < mergeThreshold && currentState == SplitState.Separated) { // 触发合并流程,可能不是立即合并,而是开始一个过渡动画 RequestMerge(playerA, playerB); } else if (currentDistance > splitThreshold && currentState == SplitState.Merged) { // 触发分割流程 RequestSplit(playerA, playerB); }

3.3 摄像机控制与画面适配

动态调整视口只是第一步,确保每个摄像机渲染的画面对于玩家来说是可玩的同样重要。

  • 摄像机投影矩阵的影响:更改rect只会影响最终渲染到屏幕的哪一部分,不会改变摄像机的视野(FOV)或投影矩阵。这意味着,当一个视口变窄(如从半屏变为三分之一屏)时,如果摄像机是透视投影(Perspective),其水平视野范围不变,但显示在更窄的区域内,会导致水平方向上的“挤压感”或视野损失。一种解决方案是动态调整水平视野(Horizontal FOV),使其与视口宽高比(Aspect Ratio)匹配,但这会改变游戏感知,需谨慎使用。对于正交投影(Orthographic)摄像机,调整orthographicSize即可适配不同高度的视口。
  • UI的适配:世界空间(World Space)的UI通常由特定摄像机渲染,会随视口自动裁剪。但屏幕空间(Screen Space)的UI需要特别注意。如果每个玩家有独立的UI(如血条、弹药),你需要将这些UI元素锚定(Anchor)到对应的视口区域,或者使用多个Canvas,并设置其Render ModeScreen Space - Camera,并指定对应的玩家摄像机。Dynamic-Splitscreen管理器在调整布局后,可能需要通知UI系统更新各UI画布的渲染相机和缩放模式。

4. 实战构建:一个可复用的Dynamic Splitscreen管理器

下面,我将勾勒一个基础但功能完整的SplitscreenManager实现框架,你可以在此基础上进行扩展。

4.1 核心类定义

首先,我们定义几个关键的数据结构和类:

// 描述一个视点(可能对应一个或多个玩家) [System.Serializable] public class ViewGroup { public List<PlayerViewController> members = new List<PlayerViewController>(); public Rect targetViewportRect; // 该组的目标视口 public Rect currentViewportRect; // 当前插值后的视口 } // 布局规则类型 public enum LayoutRule { FixedGrid, // 固定网格 DynamicByDistance, // 基于距离动态合并 PriorityFocus // 优先级焦点 } public class SplitscreenManager : MonoBehaviour { public static SplitscreenManager Instance { get; private set; } [Header("布局设置")] public LayoutRule currentLayoutRule = LayoutRule.DynamicByDistance; public int maxRows = 2; public int maxCols = 2; [Header("动态合并规则")] public float mergeDistance = 15.0f; public float splitDistance = 25.0f; public float layoutChangeDuration = 0.5f; // 布局变化过渡时间 private List<PlayerViewController> allPlayerViews = new List<PlayerViewController>(); private List<ViewGroup> currentViewGroups = new List<ViewGroup>(); private bool isLayoutDirty = false; // 标记布局是否需要重新计算 void Awake() { if (Instance != null && Instance != this) { Destroy(this.gameObject); return; } Instance = this; DontDestroyOnLoad(this.gameObject); // 通常分屏管理器是跨场景的 } void Update() { if (isLayoutDirty) { RecalculateLayout(); isLayoutDirty = false; } UpdateViewportTransitions(); // 每帧更新视口平滑过渡 } }

4.2 玩家注册与事件驱动

管理器需要知道所有活跃的玩家视点。通过一个注册机制来实现:

// 在 SplitscreenManager 中 public void RegisterPlayerView(PlayerViewController pv) { if (!allPlayerViews.Contains(pv)) { allPlayerViews.Add(pv); // 初始化为一个独立的组 var newGroup = new ViewGroup(); newGroup.members.Add(pv); currentViewGroups.Add(newGroup); MarkLayoutDirty(); // 标记布局需要更新 } } public void UnregisterPlayerView(PlayerViewController pv) { if (allPlayerViews.Remove(pv)) { // 从所在组中移除,并清理空组 var groupToClean = currentViewGroups.Find(g => g.members.Contains(pv)); if (groupToClean != null) { groupToClean.members.Remove(pv); if (groupToClean.members.Count == 0) { currentViewGroups.Remove(groupToClean); } } MarkLayoutDirty(); } } private void MarkLayoutDirty() { isLayoutDirty = true; }

PlayerViewControllerOnEnable时调用RegisterPlayerView,在OnDisable时调用UnregisterPlayerView

4.3 核心布局计算逻辑

RecalculateLayout是系统的大脑,它根据当前规则和玩家状态,计算出每个ViewGroup的目标视口。

private void RecalculateLayout() { // 第一步:根据规则,重新分组(例如,基于距离合并) RegroupPlayersBasedOnRule(); // 第二步:为每个组计算目标视口矩形 switch (currentLayoutRule) { case LayoutRule.FixedGrid: ApplyFixedGridLayout(); break; case LayoutRule.DynamicByDistance: // 动态规则下,分组已经由RegroupPlayersBasedOnRule完成 // 现在基于组的数量,应用一个合适的网格布局 ApplyFlexibleGridLayout(); break; case LayoutRule.PriorityFocus: ApplyPriorityFocusLayout(); break; } // 第三步:对于目标视口与当前视口不同的组,启动平滑过渡 foreach (var group in currentViewGroups) { if (group.targetViewportRect != group.currentViewportRect) { // 可以在这里启动一个协程或标记过渡开始 // 我们将在UpdateViewportTransitions中处理插值 } } } private void RegroupPlayersBasedOnRule() { if (currentLayoutRule != LayoutRule.DynamicByDistance) { // 非动态规则,每个玩家独立成组 currentViewGroups.Clear(); foreach (var pv in allPlayerViews) { currentViewGroups.Add(new ViewGroup() { members = new List<PlayerViewController> { pv } }); } return; } // 动态合并逻辑(简化版:两两检查) // 更高效的实现可以使用空间划分数据结构,如四叉树或网格 List<ViewGroup> newGroups = new List<ViewGroup>(); HashSet<PlayerViewController> processed = new HashSet<PlayerViewController>(); foreach (var pv in allPlayerViews) { if (processed.Contains(pv)) continue; ViewGroup group = new ViewGroup(); group.members.Add(pv); processed.Add(pv); // 查找所有应该与此玩家合并的其他玩家 foreach (var otherPv in allPlayerViews) { if (processed.Contains(otherPv)) continue; if (ShouldMerge(pv, otherPv)) { group.members.Add(otherPv); processed.Add(otherPv); } } newGroups.Add(group); } currentViewGroups = newGroups; } private bool ShouldMerge(PlayerViewController a, PlayerViewController b) { // 简单的距离判断,可扩展为更复杂的逻辑(如视线、中间障碍物) float dist = Vector3.Distance(a.TrackedTarget.position, b.TrackedTarget.position); return dist < mergeDistance; } private void ApplyFlexibleGridLayout() { int groupCount = currentViewGroups.Count; if (groupCount == 0) return; // 根据组数决定行数和列数(这是一个简单的启发式方法,可以优化) int rows, cols; if (groupCount <= 1) { rows = 1; cols = 1; } else if (groupCount == 2) { rows = 1; cols = 2; } // 水平分割 else if (groupCount == 3) { rows = 2; cols = 2; } // 品字形,有一个单元格空着 else { rows = 2; cols = 2; } // 4组或更多,先按2x2网格,多的组可能重叠或需要滚动?这里需要更复杂的策略。 // 调用之前提到的CalculateGridLayout,但传入的是组ID和组的数量 // 注意:这里需要将组映射到网格单元格。如果组数超过单元格数,需要处理。 var gridLayout = CalculateGridLayoutForGroups(rows, cols); // 将计算好的Rect赋值给每个组的targetViewportRect // ... 赋值逻辑 ... }

4.4 视口平滑过渡

直接切换Camera.rect会导致画面跳变,体验很差。我们需要平滑的动画。

private void UpdateViewportTransitions() { float deltaTime = Time.deltaTime; foreach (var group in currentViewGroups) { if (group.currentViewportRect != group.targetViewportRect) { // 使用插值向目标矩形过渡 group.currentViewportRect = Rect.Lerp(group.currentViewportRect, group.targetViewportRect, deltaTime / layoutChangeDuration); // 如果非常接近目标,则直接设为目标值 if (RectDistance(group.currentViewportRect, group.targetViewportRect) < 0.001f) { group.currentViewportRect = group.targetViewportRect; } // 将当前矩形应用到该组所有成员的摄像机 ApplyViewportToGroup(group); } } } private void ApplyViewportToGroup(ViewGroup group) { foreach (var playerView in group.members) { if (playerView != null && playerView.ViewCamera != null) { playerView.ViewCamera.rect = group.currentViewportRect; } } } // 计算两个Rect的“距离”(简化版,使用中心点距离) private float RectDistance(Rect a, Rect b) { Vector2 centerA = a.center; Vector2 centerB = b.center; return Vector2.Distance(centerA, centerB); }

5. 性能优化与高级技巧

一个全功能的动态分屏系统在运行时可能会带来性能开销,尤其是在玩家众多、距离判断频繁的情况下。以下是一些优化和增强点:

  • 距离计算优化:不要每帧对所有玩家进行O(n²)的两两距离计算。可以:
    • 使用空间索引:将游戏世界划分为网格(Grid),只检查同一网格或相邻网格内的玩家。
    • 降低检测频率:使用InvokeRepeating或一个计时器,每0.2-0.5秒执行一次分组逻辑,而不是每帧。
    • 使用距离平方:比较距离时使用sqrMagnitude,避免耗时的开方运算。
  • 摄像机裁剪(Culling)优化:当视口变小时,摄像机渲染的内容可能远多于实际显示的部分。可以尝试根据视口大小动态调整摄像机的远裁剪平面(Far Clip Plane)或视野(FOV),减少不可见物体的渲染。更高级的做法是使用自定义投影矩阵,但实现复杂。
  • 渲染纹理(Render Texture)备用方案:对于极其复杂的分屏需求(如画中画、非矩形分割),直接调整Camera.rect可能不够灵活。另一种方案是将每个玩家的视图渲染到一张单独的Render Texture上,然后在UI层用一个全屏的RawImage,通过自定义Shader或多个UI矩形来拼接、显示这些纹理。这给了你完全的像素级控制权,但代价是内存和显存占用更高(每个视图都需要一张RT),且UI事件处理会更复杂。
  • 与Unity新输入系统(Input System)集成:确保每个玩家的输入正确映射到其控制的角色,尤其是在玩家动态加入退出、屏幕布局变化时。Unity的新输入系统通过PlayerInput组件和Input Action Assets可以很好地处理多玩家输入与设备分配,SplitscreenManager需要与它协同工作,在玩家注册时绑定或解绑输入设备。

6. 常见问题与调试心得

在实现动态分屏的过程中,你几乎一定会遇到下面这些问题:

  • 画面撕裂或不同步:如果多个摄像机渲染到同一屏幕的不同部分,确保它们的Render Type设置正确(例如,都是BaseOverlay),并且渲染顺序(Depth)不会互相干扰。有时需要确保所有动态分屏摄像机使用相同的渲染管线设置。
  • UI显示错乱:这是最常见的问题。牢记:Screen Space - Overlay模式的Canvas是渲染在所有东西之上的全屏UI,不适用于分屏。为每个玩家使用Screen Space - Camera模式的Canvas,并将其Render Camera设置为对应的玩家摄像机,Plane Distance设置一个合适的值。管理器在调整视口后,可能需要动态调整Canvas的缩放系数(Scale Factor)或参考分辨率(Reference Resolution)来适应不同大小的视口。
  • 合并/分割时画面剧烈抖动:这通常是因为在过渡期间,玩家的位置更新和视口更新在同一帧内以不可预测的顺序进行。确保布局计算和视口应用在每帧的固定阶段(如LateUpdate)完成,并且所有玩家的位置更新在此之前(如UpdateFixedUpdate)已经完成。
  • 性能热点:使用Unity的Profiler(性能分析器)定位。重点关注UpdateRegroupPlayersBasedOnRule和距离计算函数的耗时。如果成为瓶颈,立即实施上述的空间划分或频率降低优化。
  • 编辑器下的预览问题:在Scene视图里,你只能看到一个摄像机的预览。为了调试分屏,一个有用的技巧是写一个简单的Editor脚本,在Game视图的工具栏上添加一个下拉菜单,让你可以快速切换显示哪个摄像机的视口,或者同时显示所有摄像机的视口轮廓。

一个关键的调试技巧:在SplitscreenManagerOnGUI方法中(或使用新的UI Toolkit),绘制当前所有ViewGroup的视口矩形边框和ID。这能让你在运行时直观地看到布局是如何计算的,对于验证动态合并/分割逻辑是否正确至关重要。

实现一个Dynamic-Splitscreen系统,从表面看是管理一堆矩形区域,但其内核是一个对游戏状态(玩家关系)做出实时反应的空间仲裁器。它要求开发者不仅熟悉Unity的摄像机渲染管线,还要有良好的架构设计能力,以平衡灵活性、性能和代码可维护性。上面的框架提供了一个坚实的起点,你可以根据项目具体需求,融入更复杂的规则(如视野遮挡、动态焦点)、更高效的算法,甚至与网络同步结合,打造出真正令人印象深刻的多人同屏体验。记住,最好的系统往往是那些让玩家几乎感觉不到其存在,但一旦缺失就会明显感到不适的系统,动态分屏正是如此。

← 返回列表