ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Unity双相机实现表里世界:渲染、物理与逻辑实践

Unity双相机实现表里世界:渲染、物理与逻辑实践 做游戏的人应该都吃过这类设计的亏表面上看只是一个场景实际上要准备两套地图、两套交互物、两套光影甚至物理碰撞都可能分两套然后让玩家在某个瞬间从日常世界跌进“里世界”。经典游戏里这个套路被玩出过很多花样同一个场景换一套规则探索和解谜的深度立刻不一样。这篇文章不聊设计思路专门聊实现——在Unity里把“表里世界”做出来我最终使用的是双相机层可见性后处理混合的方案。这套方案最大的好处是两套世界可以同时存在于一个场景中切换过程完全平滑可控不依赖场景加载也不需要把物体挨个SetActive。适合正在做双世界切换、密室解谜、横版剧情演出或者被跨世界机制困扰的Unity开发者。我会把从搭建架构到踩坑排查的完整过程写清楚代码和参数可以直接抄。1. 思路梳理把“穿越”拆成渲染、物理、逻辑三个独立系统1.1 表里世界机制到底在做什么表里世界这个概念往简单了说就是“同一张地图两套状态”。玩家站在同一个位置看到的景物、可互动的目标、敌人的分布甚至重力规则都可能完全不同。《塞尔达传说众神的三角力量》里光明世界与黑暗世界共用一张地图但解谜线索和通道位置完全不一样近些年的独立游戏也经常拿这个机制做文章比如用灯光切换正反面或者用“现实/灵视”两个维度来解锁隐藏路径。落到Unity里这个玩法的本质是三个系统的切换渲染系统要切换画面内容物理系统要切换碰撞规则逻辑系统要切换场景状态。很多初学者一上来就写“如果当前是世界A就隐藏一堆物体显示另一堆”用一个巨大的分支处理一切结果就是状态越来越多、联动越来越乱。我的经验是把三件事拆开分别设计方案再通过一个统一的“世界切换事件”串联起来。1.2 为什么不用纯SetActive和纯场景加载先说说我一开始排除掉的两个方向。第一个是纯SetActive给表世界和里世界各建一组物体切换时一个全部隐藏一个全部启用。这个方案简单但有几个硬伤。一是场景里同时存在两套地形、两套装饰物编辑器里一不留神就会改错对象二是切换过程只有“瞬转”想做过渡特效得额外写一堆淡入淡出逻辑三是大量物体激活/失活会造成瞬间的CPU峰值画面很容易卡顿。另一个方向是场景加载用多场景并行的方式做切换。这个方案适合“两个世界差距巨大、几乎没有公共物件”的项目但场景加载再快也有延迟而且公共数据、玩家坐标、场景状态的同步都要额外处理复杂度并不低。相比下来双相机方案的优势很明显。两个世界在同一场景里共存各自拥有完整的场景物件不存在加载切换问题过渡通过后处理混合实现可以做得非常细腻物理、音频和逻辑系统也都能围绕同一个“进度值”来串联。当然它也有代价——场景维护成本高需要对层Layer有清晰的规划。这是做这类机制绕不开的功课。1.3 双相机方案的整体架构这套架构的核心角色有三个主相机、里世界相机、后处理混合脚本。主相机正常渲染表世界里世界相机只渲染里世界内容并且把画面输出到一张RenderTexture上。后处理脚本拿到这张纹理后根据一个0到1的过渡参数把主相机画面和里世界画面混合起来输出到屏幕。最后的画面效果就是一开始进度为0屏幕只有表世界进度为1屏幕只有里世界中间过程两套画面叠加配合扭曲、色彩偏移、噪点等特效看起来就像两个世界在互相渗透。物理与逻辑跟这个进度值联动——比如进度超过0.5后玩家才能踩上里世界的平台。这样切换不再是一个简单的“事件”而是一个可以被时间轴、触发器、玩家操作随时驱动的“过程”。2. 渲染层搭建层与相机掩码是关键2.1 定义两个世界层我习惯在项目里新建两个Layer一个叫NormalWorld一个叫InvertedWorld分别挂到对应世界的根物体上。如果物体的子物体需要单独控制也把子物体的Layer一并设置好。这一步看起来很简单但有一个容易忽略的细节Unity的预制体在保存时只会记录当前物体的Layer如果父物体是NormalWorld而子物体忘了设置相机掩码就会出问题。注意Layer的索引必须在Project Settings里的Tags and Layers中提前配好建议把两个世界层固定放一起比如Layer 8和Layer 9。因为后面做物理矩阵时要长期引用固定的索引比名字更稳妥。物体归属的判断可以在根物体上挂一个脚本把世界类型记录下来同时把子物体统一设置Layer。对于动态生成的对象也走同一条逻辑避免层级遗漏。2.2 相机参数怎么配才不会穿帮主相机就是场景里默认的Main Camera保持原先的Culling Mask让它正常渲染表世界和UI。里世界相机单独创建一个挂在场景里的某个固定位置或者是玩家的子物体上。这个相机的Culling Mask只勾选InvertedWorld层Clear Flags建议设为Depth Only这样它输出的RenderTexture背景是透明的不会用纯色把主相机画面盖住。两个相机的Depth关系要注意主相机Depth小里世界相机Depth大。这保证里世界相机会先渲染主相机后渲染后处理再基于主相机画面叠加。里世界相机的Target Texture设为一张RenderTexture我习惯用Screen.width和Screen.height实时创建保证分辨率匹配屏幕。这里有个我在项目中踩过的坑如果RenderTexture的尺寸固定死比如1024x768窗口尺寸一变里世界画面就会变模糊或者边缘溢出。所以脚本里要监听分辨率变化必要时重新创建纹理。推荐在OnPreCull或Update里判断尺寸变了就重建。2.3 世界标记脚本与Inspector辅助为了让同类物体有统一的归属判断我写了一个很简单的WorldAffinity组件using UnityEngine; public enum WorldType { Normal, Inverted, Both } public class WorldAffinity : MonoBehaviour { public WorldType worldType; private void Reset() { worldType WorldType.Normal; } }这个脚本本身不用写太多逻辑核心是让策划或你自己在编辑器里一眼就能看出“这个物体属于哪个世界”。代码里也可以按需查询比如“如果这个物体属于里世界并且当前进度大于某个值才允许交互”。对于批量处理可以再写一个编辑器脚本把选中物体一键设置为对应Layer。比如选中一批里世界的墙体在菜单里点一下自动把根物体和子物体全部设为InvertedWorld层。这个在搭建关卡时能省不少时间。3. 过渡效果一个可以被随时打断的“进度值”3.1 用协程驱动的TransitionFactor表里世界切换最要紧的不是“结果”而是“过程”。我做了一个WorldManager用一个CurrentProgress字段表示世界切换进度0代表完全在表世界1代表完全在里世界。切换时用协程平滑改变这个值并且每帧都广播事件using System; using System.Collections; using UnityEngine; public class WorldManager : MonoBehaviour { public static WorldManager Instance { get; private set; } public float CurrentProgress { get; private set; } public bool IsInverted CurrentProgress 0.5f; public event Actionfloat, bool OnWorldSwitching; public event Actionbool OnWorldSwitched; [SerializeField] private float transitionDuration 1.5f; private Coroutine switchingRoutine; private void Awake() { if (Instance ! null Instance ! this) { Destroy(gameObject); return; } Instance this; } public void RequestSwitch() { if (switchingRoutine ! null) StopCoroutine(switchingRoutine); switchingRoutine StartCoroutine(SwitchRoutine()); } private IEnumerator SwitchRoutine() { float start CurrentProgress; float end start 0.5f ? 1f : 0f; float t 0f; while (t 1f) { t Time.deltaTime / transitionDuration; float eased 1f - Mathf.Pow(1f - t, 3f); // easeOutCubic末尾更平滑 CurrentProgress Mathf.Lerp(start, end, eased); OnWorldSwitching?.Invoke(CurrentProgress, end start); yield return null; } CurrentProgress end; OnWorldSwitched?.Invoke(IsInverted); } }这里有几个细节值得展开。第一用协程而不是Update里的Bool判断是因为协程可以随时StopCoroutine支持玩家在切换过程中反悔、反复触发如果用Update里自己累加状态机容易乱。第二进度值用easeOutCubic曲线而不是线性是为了让切换在最后阶段更平滑视觉上减少“突然定住”的生硬感。第三事件里除了进度值还带了一个“是否正在进入里世界”的布尔值方便其他系统区分“切换的方向”。3.2 后处理Shader实现双路混流有了进度值之后画面的混合就顺理成章了。我写了一个简单的后处理Shader输入主相机画面和里世界相机的RenderTexture根据_Blend参数做混合同时加了一点扭曲和闪烁让切换过程更有“空间撕裂”的感觉。Shader Hidden/WorldBlend { Properties { _MainTex (Screen, 2D) white {} _WorldTex (World, 2D) white {} _Blend (Blend, Range(0,1)) 0 _Twist (Twist, Range(0,0.05)) 0.02 } SubShader { Cull Off ZWrite Off ZTest Always Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; sampler2D _MainTex; sampler2D _WorldTex; float _Blend; float _Twist; v2f vert (appdata v) { v2f o; o.vertex UnityObjectToClipPos(v.vertex); o.uv v.uv; return o; } float4 frag (v2f i) : SV_Target { float2 uv i.uv; // 扭曲以屏幕中心为圆心做径向扰动 float2 center float2(0.5, 0.5); float2 dist uv - center; float radius length(dist); float angle atan2(dist.y, dist.x); float strength _Twist * _Blend * sin(radius * 30 _Time.y * 5); uv center dist * (1.0 strength); float4 screen tex2D(_MainTex, i.uv); float4 world tex2D(_WorldTex, uv); float4 result lerp(screen, world, _Blend); // 切换过程中加一点闪烁感 float flicker 1.0 0.15 * sin(_Time.y * 40) * _Blend * (1.0 - _Blend); return result * flicker; } ENDCG } } }后处理挂在主相机上的脚本也不复杂OnRenderImage里把里世界相机输出的纹理和当前进度传给材质然后Blit到目标屏幕。using UnityEngine; [ExecuteInEditMode] public class WorldBlendController : MonoBehaviour { public RenderTexture worldRT; public Material blendMaterial; public float blend; private void OnRenderImage(RenderTexture source, RenderTexture destination) { if (worldRT ! null blendMaterial ! null) { blendMaterial.SetTexture(_WorldTex, worldRT); blendMaterial.SetFloat(_Blend, blend); Graphics.Blit(source, destination, blendMaterial); } else { Graphics.Blit(source, destination); } } private void Update() { if (WorldManager.Instance ! null) blend WorldManager.Instance.CurrentProgress; } }我在项目里把这个blend值直接绑定到WorldManager的CurrentProgress上所以不管切换怎么触发画面都会跟着走。如果你用的是URP传统OnRenderImage的写法不生效需要在Render Pipeline的Custom Renderer Feature里加一个BlitPass原理一样只是挂载方式不同。这个后面我单独讲。3.3 把音效、摄像机、UI全部接到同一个进度值画面混合是“表里世界”最显眼的表现但不是全部。我在切换时会让镜头做一点点位移和鱼眼变形音效从表世界音轨交叉到里世界音轨UI提示文字根据进度值改变透明度。这些系统并不需要各自维护一套切换状态统一监听WorldManager的OnWorldSwitching事件就够了。例如音频交叉淡入淡出可以用两个AudioSource分别播放两套环境循环音效Update里根据音量做插值。摄像机的FOV和位置偏移也类似切换时把“震颤幅度”按进度值映射到虚线上视觉冲击力会明显提升。这样整个切换体验就变成了一个连贯的、多通道同步的表演而不是“画面变了、声音突然跳了”的割裂感。4. 物理与交互隔离不让两个世界的物件互相穿帮4.1 Layer Collision Matrix设置双相机解决了“看得见”的问题但物体会不会重叠、碰撞、穿帮是另一个大坑。比如表世界有一堵墙里世界同一位置是通道如果两层碰撞体都参与物理计算玩家就会被一堵看不见的墙挡住或者站在一个不存在的障碍上。解决方案是让两个世界的碰撞体互相忽略。在Project Settings - Physics里找到Layer Collision Matrix把NormalWorld和InvertedWorld之间的勾选取消。这样表世界的地形不会和里世界的角色碰撞反之亦然。玩家的Layer我建议单独占一层比如Player层它和两个世界层都保持碰撞这样玩家无论在哪边都能踩到地面。这里的细节在于如果玩家角色也直接被相机区分在世界层里就会出现“里世界相机看不到玩家”的问题。所以玩家物体放到Player层然后在两个相机的Culling Mask里都额外勾上Player层才能保证两套画面都能看到角色。这个配置看起来绕但实际操作一次就能体会到好处物理和渲染各管各的互不干扰。4.2 玩家和NPC的跨世界交互处理两个世界同时存在时交互逻辑要特别小心“存在感”的问题。表世界的一张桌子里世界同一位置可能是一口井玩家在表世界能推动桌子在里世界推的却是井。如果两个物体都挂Collider和Rigidbody物理层又互不碰撞那么角色在表世界推桌子时会推空气因为碰撞检测根本不会触碰到里世界的井。这个问题的处理方式有两种。第一种是给跨世界交互物挂Trigger不实际参与物理碰撞用脚本判断玩家属于哪个世界再决定触发效果。第二种是给每个交互物做“状态代理”比如桌子在表世界是一个可推动物体在里世界被替换为一个静态装饰物理属性也跟着切换。做原型阶段我建议用第一种代码量少、逻辑直观做正式项目再考虑状态代理因为要把物体的物理材质、碰撞体形状都纳入切换管理复杂度会翻倍。4.3 状态保存与对象映射表里世界机制里最容易被低估的是“状态同步”。玩家在表世界推开了一块石头石头在里世界对应位置可能是一个宝箱的开关。如果切换时所有物体都回到初始状态解谜逻辑就会错乱。我给这类跨世界对象设计了一个简单映射一个WorldStateMapDictionary保存对象ID对应的状态键值对。切换时同一ID在两个世界的物体都读取这个状态。比如表世界石头被推开就写入“StoneMovedtrue”里世界的宝箱在初始化时检查这个值如果为true就允许开启。这样两个世界共享一套状态但表现完全独立。这个映射方案一开始可能觉得多余但当你开始给角色加存档、加多章节流程时会发现没有这套状态管理切换导致的Bug会成倍增加。早点把它纳入架构后面省心很多。5. 遇到过的坑与排查实录5.1 里世界画面变成一团黑色这是最常见的坑。里世界相机输出到RenderTexture后如果Clear Flags还是Skybox或者Solid Color或者Depth配置比主相机深结果就是里世界的RT是一张不透明的图后处理混合时把主相机画面整个盖住了。排查方法是分步验证先单独把主相机注释掉把RT直接输出到屏幕看里世界画面是否正常再检查RT的alpha通道确认透明背景是否生效最后检查相机的Depth顺序。我最终把里世界相机的Clear Flags设为Depth Only问题就解决了。5.2 阴影和灯光的串线问题两个世界都需要灯光和阴影时如果共用同一盏方向光画面会因为不同的环境光颜色而变得不协调。更麻烦的是阴影——表世界的墙投下阴影里世界的物体却接住了这块阴影看起来像出现了“幻影”。解决思路是给两个世界分别布置光照环境并在切换时过渡环境光颜色和阴影强度。方向光可以共用一盏但要让它的颜色和强度跟随进度值变化点光源和区域光按WorldAffinity区分归属。还有一点要注意如果两套世界重叠放置阴影投射必须分层不要用默认的Light Layer直接通过Culling Mask或者光照层来隔离。5.3 性能优化别让两套世界同时全量渲染双相机方案允许两个世界同屏但也意味着两套场景的物体可能同时被渲染GPU和DC压力翻倍。我在制作过程中做了几个优化里世界相机的RenderTexture分辨率不用100%可以按屏幕的75%或者50%来渲混合效果下基本看不出差别里世界物体尽量使用低精度LOD、合并Mesh和合并材质距离玩家很远的物体不要设置为可见用距离剔除Culling Distance配合缩放包围盒来削掉一部分Draw Call。物理运算也一样两个世界的Rigidbody都在场景里如果没有休眠策略CPU会被大量闲置物体拖住。解决办法是给非活跃世界的刚体启动睡眠或者在碰撞矩阵允许的情况下把里世界动态物体标为Interpolation None减少物理计算开销。5.4 URP与传统内置渲染管线的差异如果你用的是URP上面的OnRenderImage写法会失效。URP里做后处理要么用Volume组件里的自定义PaniniProjection、Color Curves等标准效果要么用定制Render Feature。我在URP里实现混合的思路是用ScriptableRendererFeature在渲染完透明物体后插入一个CustomRenderPass把主相机画面和里世界RT用同一份混合ShaderBlit到目标纹理。关键代码结构是在Pass的Execute里获取当前相机目标作为_MainTex输入再用cmd.SetGlobalTexture传入里世界RT最后用cmd.Blit做全屏Pass。Shader本身几乎不用改只要确保它能被Shader Graph或URP的Core Package正确识别。如果你本来就在用URP建议直接用URP的Fullscreen Feature扩展省去手动管理材质和RT的麻烦。另外提醒一句URP的项目里RenderTexture的创建和释放要更谨慎编辑器播放模式下反复切换分辨率可能会导致RT泄漏建议统一用一个脚本管理RT的生命周期场景销毁时主动Release。5.5 切换时机的边界问题切换过程是可以被打断的这个我之前在WorldManager里用协程处理过。但打断也会带来边界问题比如进度已经走到0.8玩家突然又按了一次键等于要反向切回去这时候物理状态、音轨、UI都应该顺着反方向走。我在音频系统上第一次踩了坑——切到一半再切回音轨交叉会卡在中间后来统一把所有跟进度相关的系统都改成“监听事件、读取数值”而不是“监听完成回调、一次性切换”问题就解决了。还有一个体会切换过程中最好禁止玩家移动或者限制移动速度。因为两个世界的碰撞体同时有效坐标突然跨过“世界边界”时角色可能会被卡进地形。我的处理方式是切换期间把玩家控制权交给动画只保留极慢的移动和朝向等WorldSwitched事件触发后再恢复控制权。最后分享一点关于这套架构的体会如果你只是做一个演示Demo双相机方案确实要多写一些代码比SetActive方案啰嗦。但当你把表里世界当作一个贯穿整个游戏的核心机制需要反复打磨过渡手感、解谜节奏、跨世界互动时这套架构的价值就会显现出来——所有系统都围绕同一个进度值运转策划调参时只需要改transitionDuration美术调特效时只需改混合Shader的扭曲和闪烁参数程序则专注于事件顺序和物理隔离各司其职。我在实际项目里最后还给切换动作加了一个短暂冷却时间防止玩家在连续点击时出现视觉和物理状态的不同步。另外用Profiler盯一下RenderThread的时间线你会发现里世界RT的分辨率、透明背景和LOD策略往往比Shader本身的混合逻辑更影响帧率。先调整好这些底层参数再去做华丽的过渡特效顺序别反。
返回列表