Unity ZED双目相机开发:深入解析ZEDMat.cs数据转换与内存管理
在Unity里接ZED双目相机很多人的第一道坎不是标定也不是SDK安装而是搞懂ZEDMat.cs这个类到底在干嘛。它是ZED Unity插件里所有图像数据的“中转站”不管你是想拿深度图做避障还是把彩色图喂给AI做分割最终几乎都要经过ZEDMat把SDK原生的sl::Mat数据转成Unity能用的Texture2D。这篇文章就把ZEDMat.cs从头到尾拆开讲清楚适合刚开始做AR、机器人、深度视觉项目的Unity开发者也适合那些被内存泄漏、纹理翻转、格式不匹配折磨过的人。1. 为什么ZED的数据非要绕一道ZEDMat1.1 sl::Mat和Unity Texture根本不在一个世界ZED SDK底层用的是它自己的sl::Mat本质是一块由CUDA管理的内存可能是CPU可读的也可能是GPU显存里的取决于你调用RetrieveMeasure时的参数。Unity这边要显示或者做后处理通常需要Texture2D或RenderTexture而Texture2D的像素数据在CPU内存里RenderTexture活在GPU上。ZEDMat就是那个在两种数据形态之间做搬家和换格式的人。生活类比一下sl::Mat是仓库里按自己规则堆的货Unity纹理是超市货架上按另一种规则摆的货。你不可能直接把仓库推给顾客得找搬运工重新上架。ZEDMat就是那个搬运工而且它还知道怎么省力气——如果两边规格一致它就直接指同一个坑位如果不一致它才重新复制一份。这也是为什么ZEDMat会有那么多关于尺寸、格式的判断而不是简单粗暴地直接memcpy。1.2 为什么插件不直接把Mat公开给你你可能想为什么不直接把sl::Mat暴露出来要用一个ZEDMat包一层我猜插件一开始也想过但问题是sl::Mat是SDK native层的对象生命周期跟Unity的托管世界不一样。如果直接把IntPtr丢给上层没人帮你释放每帧拿一个新的Mat内存就炸了。ZEDMat实现了IDisposable内部持有native句柄和Unity纹理的“双户口”在OnDestroy时统一释放才能保证在编辑器里反复Enter Play Mode不把显存吃满。而且ZEDMat里带了类型判断和尺寸判断。ZED相机可以输出720p、1080p、2K深度是32位浮点单通道彩色是8位四通道如果你直接把这两种Mat都塞给同一个Texture2DUnity会直接报错。ZEDMat在更新纹理时会自动判断格式和尺寸不行就重建帮你挡住了很多低级错误。所以不要觉得ZEDMat是“多余的中间层”它在插件架构里更像是一个保险丝盒。2. ZEDMat.cs核心代码结构逐段拆解2.1 类定义与关键字段打开ZEDMat.cs第一眼看到的应该是这样一个类结构public class ZEDMat : IDisposable { private sl.Mat mat_; private Texture2D texture_; private IntPtr textureNativePtr_; private uint width_; private uint height_; private MAT_TYPE matType_; private bool isDisposed_ false; public uint Width { get { return width_; } } public uint Height { get { return height_; } } public MAT_TYPE MatType { get { return matType_; } } // ... }这里有几个关键信息。mat_是真正从SDK retrieve出来的原生矩阵对象texture_是我们在Unity侧“变现”出来的Texture2DtextureNativePtr_是纹理的native指针一般通过Texture2D.GetNativeTexturePtr()拿到用于底层直接写显存不用每帧从CPU拷贝。width_和height_缓存了尺寸matType_记住当前数据格式。我用的版本里构造方法大概是这样的public ZEDMat(sl.Mat mat) { mat_ mat; matType_ mat.GetMatType(); width_ mat.GetWidth(); height_ mat.GetHeight(); } public ZEDMat(uint width, uint height, MAT_TYPE type) { mat_ new sl.Mat(width, height, type); matType_ type; width_ width; height_ height; }注意第二种构造不只是为了包一个已有Mat它还可以自己创建一个sl.Mat出来用于把Unity侧的图像数据回传给SDK。比如你想把做过后处理的彩色图重新塞给ZED的SVO录制就得先建一个ZEDMat再UploadTexture。这个方向我们后面细说。2.2 MAT_TYPE映射关系ZED SDK的sl.MatType对应到Unity侧最常见的就三种MAT_TYPE含义对应Unity纹理格式MAT_8UC48位无符号四通道通常是BGRA彩色图TextureFormat.BGRA32或RGBA32MAT_32F_C132位浮点单通道深度图原始数据TextureFormat.RFloatMAT_8U_C18位无符号单通道灰度图或掩码TextureFormat.Alpha8或R8这里有个容易踩的坑ZED SDK很多版本的彩色图默认是BGRA排列而Unity的Texture2D如果没有特殊设置会当成RGBA。你如果不做通道转换拿到手的彩色图红色和蓝色是反的。ZEDMat内部其实帮你处理了大部分情况但如果你自己写ComputeShader去采样纹理一定要确认颜色空间是Linear还是Gamma以及BGR和RGB的差异。我见过有人把彩色图喂给渲染管线后所有红色物体全变成了蓝色排查了半天最后才发现是通道顺序问题。2.3 构造重载背后的设计意图为什么ZEDMat要提供多个构造重载我的理解是为了适配两条完全不同的数据流从SDK往Unity方向你调用RetrieveMeasure拿到一个sl.Mat然后用ZEDMat包一层再调更新纹理的方法变成Texture2D给UI或者Shader用。从Unity往SDK方向你有一个RenderTexture或者摄像头纹理想把它交给SDK做深度估计或者录制需要先new一个ZEDMat(width, height, type)然后用UploadTexture把Unity纹理数据推给mat_。如果你只做显示可能永远用不到第二个方向。但一旦你做起自定义图像处理管线两个方向都会用到。区分这两个方向是看懂ZEDMat源码的关键。看代码的时候多问一句这个方法是“从SDK来”还是“到SDK去”逻辑就清晰了。3. 核心方法逐行解读数据搬运工的工作日记3.1 GetTexture和UpdateTexture的分工ZEDMat里有两个看着很像的方法GetTexture()和UpdateTexture()。我第一次看的时候以为是重复后来才发现它们分工不同GetTexture()只是返回当前已经生成的texture_如果没有就返回null。它是“取值器”不负责干活。UpdateTexture()才是真正把mat_里的数据同步到texture_的方法。它内部会检查texture_是否为空、尺寸格式是否匹配如果不匹配就重新创建一个Texture2D然后调用LoadRawTextureData或者通过native指针直接拷贝。典型调用是zedMat.UpdateTexture(); Texture2D tex zedMat.GetTexture();在实际工程里你通常不需要每帧都重新new一个ZEDMat而是复用一个实例每帧调UpdateTexture。这样GC压力小很多而且Texture2D的native纹理ID不会变Shader里缓存的纹理引用也不用换。如果你每帧Destroy再创建Shader里的动态纹理引用很可能会短暂丢失表现就是画面偶尔闪一下黑。3.2 数据拷贝的三种姿势根据ZED SDK的不同版本和Unity渲染管线设置UpdateTexture底层实现可能是这三种之一CPU拷贝把mat_的CPU内存拷出来SetPixels32或LoadRawTextureData。慢但兼容性最好。CUDA-Graphics互操作通过CUDA和OpenGL/D3D的互操作把GPU端数据直接写到纹理。快但实现复杂而且一旦切换图形API就容易出问题。Native插件回调插件在Unity的渲染线程上排队一个回调用纹理原生指针直接memcpy。兼顾速度和稳定性。我实际见过的插件版本里最终用的多是第一种和第三种的结合如果Mat是CPU可读的就走LoadRawTextureData如果是GPU端的就先在SDK侧拷贝回CPU再上传。这个细节决定了ZEDMat在1080p深度图下能做到多少FPS也决定了它在某些平台比如没有CUDA的移动端上会退化成软拷贝。因此你在高端PC上跑得很顺换到笔记本或者移动端突然掉帧别抱怨ZEDMat先查一下当前设备是否支持GPU互操作。3.3 UploadTexture反方向的搬运当你想把Unity纹理的数据写到ZEDMat里方法名是UploadTexture。它和UpdateTexture正好相反。逻辑一般分三步确认mat_存在并且尺寸类型正确。从texture_或外部传入的Texture中ReadPixels或者GetRawTextureData得到字节数组。调用mat_.SetFromMemory或memcpy把数据交给SDK。这个功能最常见的场景是SVO视频录制你把RGB相机图像送到SDK的数据入口里SDK再做成带深度或者带追踪数据的SVO文件。另一个场景是自定义标定数据回灌。不过说实话这个方向用得少很多人不会碰但如果你碰了十有八九会卡在Unity纹理的y轴方向和SDK期望的存储方向相反的问题上。Unity纹理的左下角是(0,0)而SDK图像数据通常从左上角开始上传前如果你不把纹理翻转一下录出来的视频画面会上下颠倒。这个坑我踩过一次后来我习惯在UploadTexture之前先做一次垂直翻转或者直接读像素时按行倒序。3.4 Dispose的微妙之处ZEDMat实现了IDisposableDispose方法里一般会做两件事释放texture_如果是它自己创建的并且把mat_.Dispose()。这里有几条经验不要自己手动去释放传入的sl.Mat除非你非常清楚这个Mat的所有权。如果ZEDMat是从RetrieveMeasure拿到的临时Mat构造的Dispose时释放没问题但如果是从一个被其他地方复用的Mat构造的释放了别人就没得用了。Unity的OnDestroy里如果只Dispose Texture2D不Dispose ZEDMat那sl.Mat对应的native内存也可能漏。我在编辑器里被这个坑过表现为越Play内存越高退出后显存还不下来。using语句是好习惯但ZEDMat经常要作为字段存下来跨帧复用所以不能无脑using。先判空再在场景卸载时统一释放更合理。public void Dispose() { if (isDisposed_) return; if (texture_ ! null) { Object.Destroy(texture_); texture_ null; } if (mat_ ! null) { mat_.Dispose(); mat_ null; } isDisposed_ true; }要注意如果你的ZEDMat不是自己new的而是外部传入的sl.Mat构造的那Dispose里可能只释放texture_而不释放mat_具体要看构造参数里有没有所有权标记。这个细节不同版本差异很大建议每次升级SDK后都去源码里确认一下。4. 工程实测这么用ZEDMat才稳4.1 每帧拉取深度图的推荐模板以我常用的方式为例在MonoBehaviour里这样做private ZEDMat depthMat; private Texture2D depthTexture; void OnEnable() { depthMat new ZEDMat((uint)zedCamera.ImageWidth, (uint)zedCamera.ImageHeight, MAT_TYPE.MAT_32F_C1); } void Update() { if (!zedCamera.Ready) return; sl.Mat rawDepth new sl.Mat(); zedCamera.RetrieveMeasure(rawDepth, sl.MEASURE.MEASURE_DEPTH); if (depthMat.UpdateMat(rawDepth)) { depthMat.UpdateTexture(); depthTexture depthMat.GetTexture(); // 把depthTexture传给Shader或者做深度可视化 } rawDepth.Dispose(); } void OnDisable() { if (depthMat ! null) { depthMat.Dispose(); depthMat null; } }注意这里的UpdateMat可能不是真实API名不同版本有细微差别有的版本叫SetMat有的版本直接在内部匹配。核心思路是复用一个ZEDMat避免每帧new和Destroy。RetrieveMeasure出来的临时Mat用完就Dispose防止native层堆积。你可以在OnDisable里统一释放所有ZEDMat不要等到OnDestroy这样在场景切换时更可控。4.2 性能坑纹理格式、分辨率变化与GC格式不对深度图是RFloat你如果硬要用RGBAFloat去接收带宽是四倍帧率肉眼可见下降。ZEDMat会判断类型但你自己不要手动改。做可视化时另外建一个RGBA32的伪彩色纹理不要直接把RFloat挂RawImage。分辨率变化ZED相机切分辨率比如运行时从1080p切到720p后旧的ZEDMat尺寸不匹配。更新纹理时会重新创建纹理但旧纹理的销毁时机要注意最好主动Dispose再重建别让它留在显存里等GC。GC压力如果每帧都从RetrieveMeasure拿Mat再包ZEDMat会产生很多托管对象。用ZEDMat的复用模式可以显著降低IL2CPP下的GC Alloc。我自己测试过复用后GC Alloc基本降为0而每帧new的话大概每帧多出几十KB看起来不多但持续几分钟就会触发频繁GC。4.3 在URP/HDRP里使用ZEDMat的注意事项Unity从Built-in管线换到URP/HDRP之后很多直接改屏幕像素的老写法失效了。ZEDMat生成的Texture2D本身没问题但如果你要把它显示到屏幕上建议通过RawImage或者自己写一个简单的Shader来采样不要指望ZEDMat直接操作RenderTexture。在URP里我遇到过一次深度图拉出来是紫黑花纹排查很久发现是纹理的sRGB标记问题——深度数据是非颜色的不应该做sRGB解码所以创建Texture2D时要设置linear true。这个参数在ZEDMat内部创建纹理时可能默认跟着项目设置走如果你的项目是Gamma空间深度图可能看起来是正常的但线性空间下就会异常。如果遇到这个情况去ZEDMat源码里找创建Texture2D的地方手动加上linear true。4.4 ZEDMat不背锅的常见问题现象可能原因处理办法彩色图红蓝反ZED SDK输出BGRA纹理设置或Shader采样通道顺序不对检查ZEDMat内部转换开关或采样时交换R/B深度图全是零RetrieveMeasure时机太早相机还没出深度等ZEDCamera.Ready为true或检查MEASURE模式纹理闪烁或撕裂UpdateTexture过程中主线程与渲染线程竞争使用申请了GPU互操作的ZEDMat实例或固定更新频率Enter Play Mode后显存泄漏ZEDMat没有在OnDestroy里Dispose补全Dispose链或用using实例化自定义渲染后回写SVO颜色不对UploadTexture前没有做RGBA到BGRA转换SDK对颜色顺序敏感转换后再上传纹理全黑但有数据纹理过滤方式或Shader处理深度值的范围不对深度是0~最大值需要除以距离上限才能用于可视化5. 从ZEDMat延伸出去和周边类怎么配合5.1 和ZEDCamera、ZEDTexture的关系在Unity插件里ZEDCamera是总入口负责初始化相机、Retrieve图像、管理姿态而ZEDMat是它的“快递包裹”。ZEDTexture类更像是一个专用于在Unity UI或Shader上显示的辅助类它内部往往也持有ZEDMat。所以你在写业务代码时最外层的API可能是ZEDTexture.GetTexture()但真正干活的仍然是ZEDMat。如果ZEDTexture给你返回了一个Texture出了问题先查它内部ZEDMat的更新频率和格式比盲目换Shader有用。我遇到过一种情况UI上显示彩色图很流畅但深度图要切换时调用ZEDTexture的SetTextureFromMat()之后画面没反应。最后发现是ZEDTexture内部缓存了纹理的引用而ZEDMat更新时生成了新的TextureIDZEDTexture还拿着旧的。你需要在切换后重新绑定RawImage的texture而不是依赖自动更新。这个经验买一送一凡是封装类持有纹理引用都要注意“引用失效”的问题。5.2 多相机和录制回放场景如果你做多相机同步每个相机都应该有自己独立的ZEDMat实例不要共享。因为ZEDMat和native Mat绑定后内部有帧号、时间戳之类的上下文信息混用会导致画面错位甚至崩溃。录制回放SVO模式下ZEDMat的使用方式和实时流一致只是RetrieveMeasure返回的数据会快很多你反而要注意控制纹理更新的频率避免CPU拷贝成为瓶颈。我之前做双相机测距图省事把两个相机的深度Mat放在同一个ZEDMat里轮询更新结果两个画面的深度完全对不上。后来老老实实给每个相机独立建ZEDMat问题立刻消失。这就是典型的把“数据类”当成“普通类”用导致的错误——ZEDMat不是纯数据容器它绑定了native资源上下文多个实例之间是有状态的。5.3 把ZEDMat的数据喂给AI框架很多避障项目需要把深度图送进TensorFlow或PyTorchUnity侧经常通过NativeArray把Texture像素数据导出去。ZEDMat.GetTexture()之后用GetRawTextureData转成NativeArray时深度图的格式是32F_C1一个像素4字节尺寸对齐是四字节可以直接做float数组。但注意Unity的纹理坐标原点在左下而很多AI框架习惯左上如果不翻转训练和推理会有系统性误差。我习惯在Shader里做翻转或者在取数据时按行倒序读取。5.4 版本变化和兼容建议ZED SDK更新蛮快的ZEDMat.cs的字段和方法在版本间有调整。比较稳定的建议是不要直接依赖私有字段名尽量通过公共方法操作如果你需要针对特定版本写扩展用partial类或者包装类别改原文件。我在升级插件版本时被改过几次内部命名从_MAT改成mat_又把更新纹理的方法从void改成bool。如果你把业务逻辑和ZEDMat公共API耦合得很深升级就会很疼。最后再分享一个小技巧在做深度可视化时不要直接拿ZEDMat的32F深度图挂到UI上而是先通过一个离屏Shader把深度映射成伪彩色再用映射结果去显示。这样既保留了原始精度又能让观察者直观看出障碍物远近。我当时改了两行Shader就解决了“深度图黑乎乎一片”的抱怨。深度图原始值在近距离物体上很大远距离很小直接显示出来大部分像素集中在暗部视觉上就是黑的。用伪彩色把0到最大距离映射到蓝到红渐变问题就解决了。以上是我用ZEDMat做了几个项目之后的实际体会。代码注释这东西光看源码很容易被细节淹没但只要抓住“两个方向、一次复用、三个释放点”这个主线后面读起来就顺了。祝大家接ZED相机的时候少踩几个坑。