
1. 项目缘起从单屏到沉浸式空间的跨越做Unity3D开发久了总会遇到一些“天花板”式的需求。比如当你的项目不再满足于在电脑屏幕、手机或者VR头显里呈现而是需要一个由多面投影墙构成的、能让用户真正“走进去”的沉浸式空间时常规的相机和渲染流程就完全不够用了。这就是所谓的Cave洞穴状自动虚拟环境系统。我第一次接触这个需求是帮一个做高端汽车设计展示的团队解决多屏同步渲染的问题。他们需要在三面墙加地面的“盒子”里让一辆车的数字模型以1:1的比例、毫无畸变地呈现在参观者眼前并且参观者走到任何位置看到的透视关系都是正确的。这听起来像是电影里的场景但用Unity3D我们确实可以实现。Cave空间的核心挑战远不止是多放几个相机那么简单。它涉及到投影矩阵的精确计算、多台PC或GPU之间的帧同步、以及如何将虚拟世界的坐标系与真实物理空间的投影面完美对齐。网上关于这方面的完整、可落地的中文资料非常少大多停留在概念层面。所以我打算通过这个系列把我从零搭建一个简易Cave演示系统的完整过程、踩过的坑以及核心原理毫无保留地分享出来。无论你是想用于科研可视化、工业设计评审还是打造独特的沉浸式展览这套思路都能给你一个坚实的起点。2. 理解Cave不只是多个屏幕的拼接在开始敲代码之前我们必须先搞清楚我们要构建的究竟是个什么东西。很多人容易把Cave系统和简单的多显示器扩展如NVIDIA Surround混淆或者认为它就是VR去掉头显。这两种理解都是片面的。2.1 Cave系统的核心特征一个真正的Cave系统至少具备以下三个核心特征投影面是物理实体Cave的“屏幕”是房间的墙壁、地板甚至天花板用户站在这个物理空间内部。这与VR头显创造的、随头部移动的虚拟屏幕有本质区别。透视校正基于用户实际位置渲染到每一面墙上的图像其透视投影的中心即视锥体的尖端必须是用户双眼在真实世界中的实际位置。这意味着当用户在Cave内走动时所有墙面的图像都必须实时、无缝地更新透视以维持正确的立体感和空间感。这是Cave沉浸感的关键也是技术难点。多通道同步渲染每一面墙通常由一个独立的投影机负责投射每个投影机背后可能对应着一台独立的渲染PC节点或一块GPU的一个输出口通道。所有这些通道必须在同一时刻渲染出同一帧画面并且帧率严格同步否则会出现令人眩晕的撕裂或延迟。2.2 与相关技术的区别为了更清晰地定位我们可以做一个快速对比vs. 多显示器游戏多显示器只是将视场角FOV拉宽但所有屏幕共享同一个透视中心位于屏幕平面后方用户移动头部或身体不会改变透视。Cave的透视中心是跟踪器获取的真人眼位置。vs. VR头显VR头显为每只眼睛提供独立的、基于头部位置和朝向的透视图像但其“屏幕”透镜后的显示屏是随头部移动的虚拟平面。Cave的屏幕是固定的物理墙面透视计算需要将眼位反向投影到这些固定平面上。vs. 环幕/弧幕投影环幕通常使用一台或多台投影机拼接一幅超宽画面但透视通常是固定的例如飞行模拟器的窗外视图。Cave更强调用户在空间内的自由移动和与之联动的正确透视。理解了这些我们就知道Unity内置的多相机渲染或多显示器设置只能解决“画多幅图”的问题完全无法解决“为固定物理平面计算正确透视”这个核心问题。我们必须自己动手从最底层的投影矩阵开始改造。3. 基石自定义投影矩阵Projection Matrix的推导这是整个Cave实现中最硬核、也最无法绕过的部分。Unity的Camera.projectionMatrix属性允许我们完全替换掉内置的透视或正交投影矩阵。我们的目标就是为Cave的每一面墙计算出一个独一无二的自定义投影矩阵。3.1 标准透视投影矩阵的局限性Unity中一个普通的透视相机Camera组件其投影矩阵是由视野FOV、近裁剪面Near Clip Plane、远裁剪面Far Clip Plane和屏幕宽高比Aspect Ratio决定的。这个矩阵假设成像平面近裁剪面是垂直于相机视线方向、且中心与视线对齐的一个虚拟矩形。但在Cave中我们的“成像平面”是倾斜的、甚至可能与视线方向成任意角度的物理墙面。3.2 推导适用于倾斜平面的投影矩阵我们需要一种方法为空间中任意位置的矩形平面即我们的投影墙计算投影矩阵。这里我采用业界常用的“离轴投影”Off-Axis Projection方法。其核心思想是根据用户眼睛视点的位置和投影墙四个角点在世界空间中的坐标直接构造出一个不对称的视锥体Frustum然后将这个视锥体变换到齐次裁剪空间。假设我们已经通过空间测量或标定得到了某一面墙在Unity世界坐标系下的四个角点坐标例如BottomLeft,BottomRight,TopLeft,TopRight并且通过头部追踪设备获取了用户左眼和右眼在世界空间中的位置EyePos。以下是计算单眼如左眼针对该面墙的投影矩阵的关键步骤定义投影平面墙的四个点定义了一个平面。我们可以用三个点如BottomLeft,BottomRight,TopLeft来定义这个平面。计算投影平面的法向量和距离利用叉乘计算平面的法向量N并求出平面方程N·P d 0中的dd -N·BottomLeft。计算眼点到平面的符号距离将眼点坐标EyePos代入平面方程得到signedDist N·EyePos d。这个距离必须是正数眼点在平面前方否则看不到这面墙。计算视锥体边界这是最关键的一步。我们需要找出从眼点EyePos出发射向墙面四个角点的四条射线与近裁剪面一个平行于投影平面、距离眼点为near的平面的交点。实际上我们更直接的方法是计算眼点到墙面四个角点的向量在投影平面上的“缩放”投影。 一个更直观且稳定的方法是先将眼点和墙面四个角点都变换到以眼点为原点、以投影平面法线为某个轴如Z轴的临时坐标系中。在这个坐标系下计算每个角点坐标的x/z和y/z比例再乘以近裁剪面距离near就能得到近裁剪面上对应的左、右、下、上l, r, b, t边界。构造投影矩阵得到了非对称的近裁剪面边界(l, r, b, t)以及near和far值后就可以用以下公式构造标准的离轴透视投影矩阵P [ [2*near/(r-l), 0, (rl)/(r-l), 0], [0, 2*near/(t-b), (tb)/(t-b), 0], [0, 0, -(farnear)/(far-near), -2*far*near/(far-near)], [0, 0, -1, 0] ]这个矩阵P就是我们需要赋给Camera.projectionMatrix的自定义投影矩阵。它取代了Unity基于FOV和Aspect Ratio生成的对称投影矩阵。注意上述推导过程涉及大量的空间向量运算。在实际编码中务必注意坐标系的手性Unity是左手坐标系。一个常见的错误是法向量方向算反导致投影矩阵无效画面全黑或扭曲。我强烈建议在实现时先针对一个简单的、正对着的墙面比如正前方墙面进行验证确保基础计算正确再扩展到倾斜墙面。3.3 在Unity中的代码实现框架下面是一个高度简化的代码框架展示如何在Update或LateUpdate中为某个墙面的相机计算并设置这个矩阵。假设我们有一个CaveWall脚本挂在代表墙面的空物体上它存储了四个角点的世界坐标并关联了一个Camera组件。using UnityEngine; public class CaveWall : MonoBehaviour { public Transform eyeTracker; // 头部追踪器提供的眼位 public Vector3 bottomLeft; public Vector3 bottomRight; public Vector3 topLeft; public Vector3 topRight; public float nearClip 0.1f; public float farClip 1000.0f; private Camera wallCamera; void Start() { wallCamera GetComponentCamera(); // 关键必须禁用相机自动的投影矩阵计算 wallCamera.usePhysicalProperties false; // 如果用了物理相机就关掉 } void Update() { if (eyeTracker null || wallCamera null) return; Vector3 eyePos eyeTracker.position; // 假设我们计算左眼如果是立体需要分别计算左眼和右眼矩阵 Matrix4x4 projMatrix CalculateOffAxisProjection(eyePos, bottomLeft, bottomRight, topLeft, topRight, nearClip, farClip); wallCamera.projectionMatrix projMatrix; // 同时相机的transform.position和rotation也需要根据眼位和墙面朝向进行设置。 // 通常相机位置就是眼位相机看向墙面的中心点。 wallCamera.transform.position eyePos; wallCamera.transform.LookAt((bottomLeft topRight) * 0.5f); } Matrix4x4 CalculateOffAxisProjection(Vector3 eyePos, Vector3 bl, Vector3 br, Vector3 tl, Vector3 tr, float near, float far) { // 1. 计算平面法向量 (假设bl, br, tl三点确定平面) Vector3 v1 br - bl; Vector3 v2 tl - bl; Vector3 planeNormal Vector3.Cross(v1, v2).normalized; // 确保法向量指向眼点眼点在平面前方 if (Vector3.Dot(planeNormal, eyePos - bl) 0) { planeNormal -planeNormal; } // 2. 构建一个从世界空间到“墙面平面空间”的变换矩阵 // 这个空间的Z轴是-planeNormal因为Unity相机看向-Z方向原点在blX轴沿(br-bl)方向Y轴沿(tl-bl)方向。 // 这里简化处理更严谨的做法是使用向量投影计算边界。 // 以下是一个简化版的边界计算思路实际实现更复杂 // 将眼点和四个角点转换到以眼点为原点以平面法线为参考的坐标系。 // 此处省略详细的坐标变换和边界计算代码... // 假设我们通过计算得到了 l, r, b, t float l -1.0f; // 示例值需实际计算 float r 1.0f; float b -0.75f; float t 0.75f; // 3. 使用离轴投影矩阵公式 Matrix4x4 p new Matrix4x4(); p[0, 0] 2.0f * near / (r - l); p[0, 2] (r l) / (r - l); p[1, 1] 2.0f * near / (t - b); p[1, 2] (t b) / (t - b); p[2, 2] -(far near) / (far - near); p[2, 3] -2.0f * far * near / (far - near); p[3, 2] -1.0f; // 其他元素为0 return p; } }重要提醒上面的CalculateOffAxisProjection函数中的边界计算l, r, b, t是伪代码。完整的、正确的计算需要一篇独立的文章来阐述。你可以搜索“Off-Axis Projection Matrix Derivation”找到详细的数学推导和代码。在后续文章中我会提供一个经过验证的、完整的计算函数。4. 系统架构与场景搭建知道了核心算法我们还需要一个合理的Unity场景结构来组织这一切。一个清晰的结构能让后续的同步、配置和管理变得简单。4.1 推荐场景层级结构我建议采用以下层级结构它清晰地分离了逻辑、显示和物理参考CaveSystem (空GameObject挂总体控制脚本) ├── TrackingOrigin (空GameObject代表追踪系统的原点) │ └── EyeCamera (空GameObject其位置由追踪数据驱动代表眼位) ├── PhysicalReference (空GameObject所有物理尺寸的参考点) │ ├── Wall_Front (空GameObject位置对应真实前墙中心) │ │ └── Projector_Front (Camera GameObject渲染前墙) │ ├── Wall_Right (空GameObject位置对应真实右墙中心) │ │ └── Projector_Right (Camera GameObject渲染右墙) │ ├── Wall_Left (...) │ └── Wall_Floor (...) └── VirtualContent (所有需要渲染的虚拟物体都放在这里)这样设计的好处追踪与渲染解耦EyeCamera只负责接收追踪数据提供眼位。各个Projector相机根据这个眼位和自己对应的墙面参数计算投影矩阵。物理参数集中管理PhysicalReference及其子物体可以通过编辑器方便地输入真实测量数据如墙的尺寸、位置。这些数据可以被控制脚本读取。内容隔离所有虚拟物体放在VirtualContent下方便统一管理、隐藏或替换。4.2 相机Camera组件的关键配置对于每一面墙对应的Camera组件需要进行如下设置Target Display如果使用多显示器输出在这里选择对应的显示器编号Display 1, 2, 3...。如果所有通道通过网线同步渲染到同一台主机的不同窗口则保持为Display 1后续用渲染纹理Render Texture或插件处理。Viewport Rect如果多个相机渲染到同一个显示器的不同区域不推荐性能差可以在这里设置矩形区域。对于Cave通常每个相机独占一个全屏窗口或显示器。Clear Flags设置为Solid Color或Depth only。在多通道渲染中通常每个相机只负责自己那面墙所以用Solid Color并设置一个纯黑背景即可。千万不要用Skybox除非你希望每面墙都渲染一遍天空盒这会造成巨大的性能浪费和视觉错误。Culling Mask确保所有相机都能看到VirtualContent层。你可以创建一个专用的层如“CaveContent”来管理。Allow MSAA和HDR根据项目需求和图形性能决定是否开启。多通道渲染下抗锯齿MSAA会显著增加每个相机的渲染负担需谨慎评估。Rendering Path根据项目复杂度选择Forward或Deferred。对于Cave这种多视角、可能涉及复杂光照和后期效果的项目Deferred Rendering有时能提供更好的性能但需要硬件支持。4.3 物理尺寸的测量与输入这是保证沉浸感准确性的基础。你需要测量每面墙的尺寸宽度和高度。墙与墙之间的相对位置通常是垂直夹角如90度和相交的棱线位置。投影机安装位置投影机的镜头中心相对于其投射墙面的位置和角度。对于正投投影机在观众一侧这个数据用于后续的几何校正Keystone Correction。在Unity中我们更常模拟背投投影机在墙后这样计算更直接因为相机投影机和墙面是正对的。如果你的真实系统是正投需要在计算投影矩阵时将眼点位置虚拟到墙后对称的位置即“视点”是观众眼睛在墙上的镜像点这是一个进阶话题。在Unity编辑器中你可以用1个Unity单位 1米的比例在PhysicalReference下创建一些Cube作为临时可视化参考确保其尺寸和位置与真实测量值一致。这些Cube的Transform信息就是你的输入数据。5. 立体视觉Stereo的实现对于追求深度沉浸感的Cave立体视觉为左右眼分别提供有视差图像几乎是必须的。这比单目渲染复杂一倍因为你需要为每一面墙渲染两次左眼和右眼并且通常需要配合主动式或被动式立体眼镜。5.1 双相机渲染模式在Unity中为一面墙实现立体渲染常见的有两种架构单相机双Pass渲染使用一个Camera但通过脚本在OnPreCull或OnRenderImage等回调中交替为左右眼计算并设置不同的投影矩阵和视图矩阵camera.worldToCameraMatrix并渲染到不同的渲染目标Render Texture。这种方式对代码控制要求高但资源占用相对少。双相机主从模式为每一面墙创建两个Camera游戏对象一个代表左眼一个代表右眼。将它们设为兄弟节点都作为墙面节点的子物体。通过一个主控脚本根据同一个追踪器获取的左右眼位置分别计算并设置这两个相机的投影矩阵。我强烈推荐第二种“双相机”模式。理由如下逻辑清晰左眼和右眼的渲染管线完全独立符合直觉。便于调试你可以在编辑器中单独禁用其中一个相机来检查单眼画面。兼容性好更容易与Unity的XR插件、多显示器管理插件集成。性能可控可以利用Unity的相机层Camera Layer或渲染顺序进行更精细的控制。5.2 眼间距离IPD与视图矩阵你需要获取或设置用户的瞳距Interpupillary Distance, IPD。通常头部追踪器会提供左右眼的精确位置。如果没有则根据追踪器提供的头部中心位置和朝向在头部局部坐标的X轴正负方向上各偏移IPD/2来估算左右眼位置。对于每一面墙的左眼相机position 左眼世界坐标。rotation 看向墙面中心点的方向。projectionMatrix 使用左眼位置和墙面参数计算出的离轴投影矩阵。对于右眼相机同理。注意两个相机的near/farclip plane 值应保持一致。5.3 输出与同步立体渲染的输出方式取决于你的立体显示技术主动立体如DLP Link NVIDIA 3D Vision显卡以120Hz或更高的频率交替输出左眼和右眼画面用户佩戴同步的快门眼镜。在Unity中你需要启用Player Settings-Resolution and Presentation-Stereo Rendering Method为Single Pass或Multi Pass并配合相应的显卡驱动设置。对于自定义Cave更常见的是将左右眼画面渲染到两张不同的Render Texture然后通过第三方中间件如VSync, MiddleVR, Unity Cluster Package来处理交替输出和同步。被动立体如偏振光使用两台投影机分别投射左眼和右眼画面投影机前加装偏振滤镜用户佩戴偏振眼镜。这时左眼相机和右眼相机需要分别输出到连接着两台不同投影机的显示器或窗口。同步问题同样需要通过集群渲染软件来解决确保两台投影机显示的是同一帧。实操心得在开发初期不要急于上立体。先用单眼例如只用左眼或中心眼把整个Cave的几何校正和透视搞对。单眼模式稳定后再开启立体。调试立体问题时一个非常有效的方法是将左右眼相机的其中一个的渲染输出颜色做一个微小的偏移例如在Shader里给右眼画面整体加一点红色然后在屏幕上观察重叠区域。如果立体对齐正确你看到的应该是清晰的、没有重影的叠加图像。如果出现重影或分离说明投影矩阵或眼位计算有误。6. 性能优化与常见陷阱一个多面墙、立体渲染的Cave场景对图形性能的压榨是极其恐怖的。假设一个5面墙四面墙地板的立体Cave每面墙2个眼睛那就是10个完整的场景渲染。不做优化再强的机器也会卡顿。6.1 核心优化策略渲染管线优化遮挡剔除Occlusion Culling务必精心设置。Cave中很多物体对于某些墙面相机是完全不可见的。良好的遮挡剔除数据能极大减少绘制调用Draw Calls。细节层次LOD为复杂模型配置多级LOD。在Cave中用户可能会走到离墙面很近的地方观察高模是必要的但当他远离时必须切换到低模。静态合批Static Batching将不会移动的静态物体标记为Static让Unity进行合批减少Draw Calls。GPU Instancing对大量重复的物体如树木、人群使用GPU Instancing。针对多相机的优化每相机裁剪层Culling Mask不是所有物体都需要在所有墙上渲染。例如地板上的小物件可能不需要在前墙上渲染。仔细规划图层为每个相机设置最精简的Culling Mask。相机视锥体Frustum优化我们的自定义投影矩阵产生的视锥体可能比标准相机更“歪斜”但Unity的视锥体剔除仍然有效。确保物体的包围盒Bounds计算准确。避免每相机重复计算如果所有相机共享同样的环境光、雾效等全局设置确保这些计算只进行一次。复杂的Shader计算也要注意。集群渲染与同步对于多台PC的Cave系统性能瓶颈往往在网络同步和数据分发。确保主节点Master只同步最必要的变换数据如眼位、控制器位置而不是每帧同步所有物体的状态。使用可靠的UDP或专用的集群中间件如Unity的Unity Cluster Package 但已较旧或第三方如RTT DeltaGen,VRED的集群方案但它们并非Unity原生。在Unity中实现一个轻量级的帧同步和状态插值逻辑是关键。6.2 开发与调试中的常见“坑”矩阵计算错误导致画面扭曲或全黑这是最常见的问题。务必编写可视化调试代码。例如在OnDrawGizmos中绘制出计算得到的近裁剪面四个角点用Gizmos.DrawLine画一个矩形以及从眼点出发到这些角点的射线。在Scene视图里检查它们是否与你预期的墙面几何吻合。一个有用的技巧是先用一个正对墙面的简单案例验证你的投影矩阵计算函数再应用到倾斜墙面。眼位追踪延迟Latency追踪数据更新频率如120Hz与渲染帧率如90Hz不匹配或数据处理管线过长会导致用户移动时画面滞后极易引起晕动症。优化方案包括在渲染线程中尽可能晚地采样追踪数据使用预测算法如基于速度和加速度预测下一帧眼位确保整个软件栈追踪SDK - 你的应用 - 渲染提交的延迟最小化。多通道间的颜色与亮度不一致即使使用同一型号的投影机由于灯泡衰减、色彩设置差异不同墙面也可能出现色差。这需要在投影机硬件端进行初步校准然后在软件端Unity为每个相机设置微调的后处理Post-Processing色彩校正Color Grading参数或使用查找表LUT。“接缝”问题相邻两面墙的投影图像在棱线处无法完美对齐出现错位或重叠。这通常由以下原因导致几何校正不精确你的墙面四个角点坐标测量或输入有误。需要用高精度测量工具如激光测距仪重新测量。投影机未对齐物理投影机的光轴未与墙面完全垂直或镜头存在畸变。这首先需要在硬件上尽量调准剩余的细微误差可以通过在Unity的投影矩阵计算中引入一个额外的“畸变校正网格”或通过像素级别的边缘融合Blending技术来软化接缝。渲染视口重叠或间隙在计算投影矩阵时要确保相邻相机渲染的视锥体在接缝处有微小的重叠例如1-2个像素然后通过边缘融合技术让重叠区域渐变透明从而实现无缝拼接。如果视锥体计算不准确导致存在间隙就会出现黑缝。Unity编辑器与发布后运行不一致在编辑器中所有相机都渲染到Game窗口可能看起来正常。但发布成独立应用后由于全屏、多显示器等设置可能出现相机输出错乱。一定要在发布后的环境中进行最终测试。使用Application.targetDisplayAPI或在构建播放器时设置Player Settings中的显示器参数。7. 从理论到实践搭建一个简易的双墙原型理论说了这么多我们现在动手搭建一个最简单的、只有两面相邻墙比如左墙和前墙的Cave原型。这个原型将串联起上述所有知识点。7.1 第一步场景与基础结构搭建新建一个Unity项目建议使用较新的LTS版本如2022.3。在场景中创建如下结构CaveSystem(空物体)CaveSystem/TrackingOrigin(空物体)CaveSystem/TrackingOrigin/Eye(空物体我们将用键盘模拟其移动)CaveSystem/PhysicalReference(空物体)CaveSystem/PhysicalReference/Wall_Front(空物体位置 (0,0,5) 假设墙宽10米高3米你可以放一个Scale为 (10, 3, 0.1) 的Cube作为视觉参考然后禁用MeshRenderer)CaveSystem/PhysicalReference/Wall_Left(空物体位置 (-5,0,0) 旋转 (0, 90, 0) 同样放一个参考Cube)CaveSystem/VirtualContent(空物体在里面放一些简单的物体如Sphere, Cube 一个3D模型等)7.2 第二步创建墙面相机与控制器在Wall_Front下创建一个子物体Camera_Front 添加Camera组件。清除Background 设置Culling Mask为Everything或你指定的层。同样在Wall_Left下创建Camera_Left。创建一个C#脚本SimpleCaveController 挂到CaveSystem上。using UnityEngine; public class SimpleCaveController : MonoBehaviour { [Header(追踪参考)] public Transform eyeTransform; // 拖拽Eye物体到这里 [Header(墙面配置)] public CaveWall frontWall; public CaveWall leftWall; [Header(模拟移动)] public float moveSpeed 2.0f; public float lookSpeed 100.0f; void Update() { // 简单的键盘控制模拟眼位移动 float h Input.GetAxis(Horizontal); float v Input.GetAxis(Vertical); Vector3 move new Vector3(h, 0, v) * moveSpeed * Time.deltaTime; eyeTransform.Translate(move); // 鼠标控制视角模拟头部转动 float mouseX Input.GetAxis(Mouse X); float mouseY Input.GetAxis(Mouse Y); // 这里简单绕Y轴旋转实际应由追踪器驱动 eyeTransform.Rotate(Vector3.up, mouseX * lookSpeed * Time.deltaTime); } }创建另一个核心脚本CaveWall 挂到Wall_Front和Wall_Left物体上注意不是挂在Camera上。这个脚本将存储墙面参数并控制其子物体下的相机。using UnityEngine; public class CaveWall : MonoBehaviour { [Header(墙面尺寸 (世界单位))] public float width 10.0f; public float height 3.0f; [Header(投影机/相机偏移 (背投假设))] public float projectionOffset 0.5f; // 假设投影机在墙后0.5米 [Header(关联相机)] public Camera wallCamera; [Header(调试)] public bool drawGizmos true; private Vector3 bottomLeft, bottomRight, topLeft, topRight; void Start() { if (wallCamera null) wallCamera GetComponentInChildrenCamera(); if (wallCamera ! null) { wallCamera.usePhysicalProperties false; } CalculateCorners(); } void CalculateCorners() { // 根据当前GameObject的位置、旋转和尺寸计算四个角点的世界坐标 // 假设当前GameObject的Transform代表墙面的中心 Vector3 halfW transform.right * (width * 0.5f); Vector3 halfH transform.up * (height * 0.5f); Vector3 center transform.position; // 注意这里假设墙的局部坐标系X向右Y向上Z向前即法线方向 bottomLeft center - halfW - halfH; bottomRight center halfW - halfH; topLeft center - halfW halfH; topRight center halfW halfH; } public void UpdateProjectionForEye(Vector3 eyePositionWorld) { if (wallCamera null) return; // 对于背投模拟相机位置应该在墙后某个位置但为了计算投影矩阵我们使用“眼位”作为视点。 // 实际上在背投系统中观众的眼位就是视点。 // 所以这里我们直接用传入的眼位作为视点来计算投影矩阵。 // 1. 计算投影矩阵 Matrix4x4 projMat ComputeProjectionMatrix(eyePositionWorld); wallCamera.projectionMatrix projMat; // 2. 设置相机的位置和朝向 // 相机位置对于背投相机投影机实际在墙后。但为了渲染正确我们让相机放在眼位。 // 更准确的模拟是相机放在墙后的固定位置但使用离轴投影矩阵来匹配眼位视角。 // 这里采用一种简化相机位置就是眼位看向墙面中心。 wallCamera.transform.position eyePositionWorld; wallCamera.transform.LookAt(transform.position); // 看向墙面中心 } private Matrix4x4 ComputeProjectionMatrix(Vector3 eyePos) { // 这是一个简化版的离轴投影计算适用于墙面与眼位坐标系大致对齐的情况。 // 完整的通用计算需要更复杂的空间变换。 // 此处旨在提供概念验证。 CalculateCorners(); // 确保角点是最新的 // 将眼点和四个角点转换到以墙面平面为基准的坐标系 // 我们构建一个坐标系原点在墙面中心Z轴指向墙外即背离法线方向因为Unity相机看-Z Vector3 planeNormal -transform.forward; // 墙面法线指向房间内观众侧 Vector3 planeRight transform.right; Vector3 planeUp transform.up; Vector3 planeOrigin transform.position; // 将眼点转换到墙面坐标系 Vector3 eyeLocal WorldToPlaneLocal(eyePos, planeOrigin, planeRight, planeUp, planeNormal); // 将四个角点也转换到墙面坐标系它们的Z坐标应该接近0因为都在墙面上 Vector3 blLocal WorldToPlaneLocal(bottomLeft, planeOrigin, planeRight, planeUp, planeNormal); Vector3 brLocal WorldToPlaneLocal(bottomRight, planeOrigin, planeRight, planeUp, planeNormal); Vector3 tlLocal WorldToPlaneLocal(topLeft, planeOrigin, planeRight, planeUp, planeNormal); Vector3 trLocal WorldToPlaneLocal(topRight, planeOrigin, planeRight, planeUp, planeNormal); // 计算眼点到各角点的向量在墙面上的投影比例近裁剪面处 float near wallCamera.nearClipPlane; float far wallCamera.farClipPlane; // 计算左、右、下、上边界 (在近裁剪面上的坐标) // 公式: boundary (cornerLocal.xy / cornerLocal.z) * near // 注意cornerLocal.z 是角点相对于眼点的深度应为负值因为角点在眼点前方这里需要仔细处理符号 // 简化处理假设眼点在墙面坐标系中位于 (0,0, d) d0 表示眼在墙前方。 // 角点坐标是 (x, y, 0)。 // 那么向量为 (x-0, y-0, 0-d) (x, y, -d)。 // 在近裁剪面处的投影为 (x * near / d, y * near / d, -near) // 所以 left min(x * near/d), right max(x * near/d), bottom min(y * near/d), top max(y * near/d) float d -eyeLocal.z; // 眼点到平面的距离应为正数 if (d near) d near 0.001f; // 防止除零或负数 float l Mathf.Min(blLocal.x, tlLocal.x) * near / d; float r Mathf.Max(brLocal.x, trLocal.x) * near / d; float b Mathf.Min(blLocal.y, brLocal.y) * near / d; float t Mathf.Max(tlLocal.y, trLocal.y) * near / d; // 构造离轴投影矩阵 return Matrix4x4.Frustum(l, r, b, t, near, far); } private Vector3 WorldToPlaneLocal(Vector3 worldPoint, Vector3 origin, Vector3 right, Vector3 up, Vector3 forward) { Vector3 relative worldPoint - origin; float x Vector3.Dot(relative, right); float y Vector3.Dot(relative, up); float z Vector3.Dot(relative, forward); // 注意这里的forward是墙面法线方向 return new Vector3(x, y, z); } void OnDrawGizmos() { if (!drawGizmos) return; CalculateCorners(); Gizmos.color Color.green; Gizmos.DrawLine(bottomLeft, bottomRight); Gizmos.DrawLine(bottomRight, topRight); Gizmos.DrawLine(topRight, topLeft); Gizmos.DrawLine(topLeft, bottomLeft); // 绘制法线 Gizmos.color Color.blue; Gizmos.DrawLine(transform.position, transform.position transform.forward * 2.0f); } }7.3 第三步连接与测试在编辑器里将Eye物体拖到SimpleCaveController脚本的eyeTransform字段。将Wall_Front和Wall_Left物体分别拖到SimpleCaveController的frontWall和leftWall字段。在Wall_Front和Wall_Left的CaveWall组件上将它们的wallCamera字段分别指向其子物体下的相机。修改SimpleCaveController的Update函数在最后调用墙面更新void Update() { // ... 原有的移动代码 ... if (frontWall ! null) frontWall.UpdateProjectionForEye(eyeTransform.position); if (leftWall ! null) leftWall.UpdateProjectionForEye(eyeTransform.position); }运行游戏。用WASD移动Eye物体用鼠标拖动旋转。观察两个相机视图你可以打开两个Game窗口或者使用Camera组件的Target Display属性将它们输出到不同的显示器。当你移动时两个墙面上的图像透视应该会随之正确变化。走到房间角落前墙和左墙交界处观察两个画面在棱线处是否能够衔接。由于我们的计算是简化的可能无法完美处理所有情况但这已经是一个可工作的Cave原型核心。这个原型虽然简陋但它包含了Cave系统最核心的循环获取眼位 - 为每个墙面计算自定义投影矩阵 - 渲染。基于这个框架你可以逐步添加立体视觉、更多墙面、精确的离轴投影计算、网络同步等高级功能。