Unity视频播放性能优化:从VideoPlayer原理到流畅可控的实践方案

Unity视频播放性能优化:从VideoPlayer原理到流畅可控的实践方案

1. 项目概述:为什么Unity视频播放是个“技术活”?

在Unity项目里加个视频播放,听起来就像在PPT里插个视频一样简单,对吧?很多新手开发者一开始都这么想,直到他们遇到了播放卡顿、内存飙升、黑屏无响应,甚至在某些低端设备上直接闪退的“惊喜”。我见过太多项目,前期功能开发飞快,一到集成视频播放,尤其是高清、长视频时,整个应用的性能曲线就变得像过山车一样刺激。这背后,远不止是拖一个VideoPlayer组件到场景里那么简单。

Unity的VideoPlayer组件是一个功能强大但同时也相当“重量级”的系统。它背后连接着不同平台(iOS的AVFoundation、Android的MediaPlayer、Windows的DirectShow/MF等)的原生解码器,涉及到GPU纹理内存的分配、CPU解码线程的管理、音频视频流的同步,以及托管与非托管内存之间的数据交换。一个配置不当的VideoPlayer,可以轻易地吃掉几百MB的内存,并让CPU使用率居高不下,成为你应用性能的“阿喀琉斯之踵”。

因此,优化Unity视频播放,核心目标有两个:流畅可控。流畅意味着无论视频码率多高,播放都要丝滑,不掉帧、不卡顿;可控则意味着内存占用要可预测、可管理,不能成为内存泄漏的黑洞。这篇文章,我将结合自己踩过的无数个坑,从VideoPlayer的工作原理出发,拆解如何通过精细化的配置和代码控制,实现既流畅又内存友好的视频播放方案。无论你是在做游戏过场动画、应用内的教程视频,还是需要动态加载的广告内容,这些技巧都能帮你把视频模块打磨得更专业。

2. VideoPlayer核心工作机制与性能瓶颈拆解

要优化,必须先理解它的工作流程。当你调用videoPlayer.Play()时,背后发生了一系列复杂操作,我们可以将其分为几个关键阶段,每个阶段都可能成为性能瓶颈。

2.1 视频播放的生命周期与资源流

VideoPlayer的工作流可以概括为:准备(Prepare) -> 解码(Decode) -> 上传(Upload) -> 渲染(Render)

  1. 准备阶段:当你设置videoPlayer.urlvideoPlayer.clip并调用Prepare()或直接Play()时,Unity会开始初始化底层播放器。这个过程包括打开文件或网络流、解析容器格式(如MP4、MOV)、读取音视频流信息(编码格式、分辨率、时长、码率)。对于网络视频,这个阶段还包含建立连接和缓冲初始数据。如果视频文件很大或网络慢,Prepare可能会阻塞主线程,导致卡顿。这就是为什么有时点击播放按钮后,界面会“冻住”几秒钟的原因。

  2. 解码阶段:这是最耗CPU的环节。解码器(通常是硬件解码器,但软件解码作为备选)将压缩的视频数据(H.264, H.265/HEVC)解压成一帧帧的YUV或RGB图像数据。解码后的原始帧数据会暂存在一个**环形缓冲区(Ring Buffer)**中。这个缓冲区的大小是关键,它决定了视频播放的“预读”能力,也直接影响了内存占用。缓冲区太小,容易因解码速度跟不上而导致卡顿;缓冲区太大,则会无谓地占用大量内存。

  3. 上传阶段:解码后的帧数据需要从CPU内存(或解码器的专用内存)上传(Upload)到GPU的显存中,变成一张纹理(Texture)。对于VideoPlayer,这张纹理通常是一个RenderTexture。这个上传操作(glTexSubImage2D或类似的DX API调用)是带宽密集型的。如果每一帧都上传一张4K纹理,对移动设备的GPU带宽将是巨大考验。

  4. 渲染阶段:GPU使用上一步得到的纹理,根据你设置的Render Mode(如渲染到摄像机平面、渲染到RenderTexture等)进行最终的画面合成与输出。

2.2 主要性能瓶颈与内存陷阱

理解了流程,我们就能定位常见的“坑点”:

  • CPU瓶颈:高分辨率、高码率视频的解码,特别是使用软件解码时。如果视频编码格式设备不支持硬解(如某些老设备不支持HEVC),CPU占用会瞬间拉满。
  • GPU内存瓶颈:视频纹理是存储在GPU内存中的。一个1080p(1920x1080)的RGBA32纹理,占用内存约为1920 * 1080 * 4 bytes ≈ 7.9 MB。如果是4K视频,这个数字会变成约31.7 MB。如果视频播放完毕,纹理没有被及时释放,或者多个视频纹理同时存在,GPU内存很快就会被撑爆,导致渲染异常甚至崩溃。
  • 托管内存压力:虽然视频帧数据本身主要在非托管内存,但Unity为了管理VideoPlayer对象、事件回调、以及某些中间数据结构,会产生托管堆的分配。频繁地创建、销毁VideoPlayer组件,或者在回调中执行不当操作,可能引发不必要的垃圾回收(GC),导致帧率波动。
  • I/O与网络延迟:对于本地文件,磁盘读取速度是瓶颈;对于网络视频,网络波动和延迟是主要问题。VideoPlayer的缓冲策略如果不够智能,就会导致播放中断,等待加载。

实操心得:在移动设备上,最需要警惕的是GPU内存解码CPU开销。一个常见的误区是只关注播放是否卡顿,而忽略了后台持续增长的内存占用。很多“黑屏无响应”的问题,根源在于内存耗尽后系统杀掉了你的应用进程。

3. 实现流畅播放的核心配置与代码策略

知道了瓶颈在哪,我们就可以有针对性地进行优化。下面这些配置和代码技巧,是我从多个上线项目中总结出来的有效方案。

3.1 精准控制播放流程:Prepare与事件驱动

永远不要依赖默认的playOnAwake = true。你应该显式地控制播放流程,使用Prepare()和相应的事件。

using UnityEngine; using UnityEngine.Video; public class OptimizedVideoPlayer : MonoBehaviour { public VideoPlayer videoPlayer; public string videoUrl; // 或 public VideoClip videoClip; void Start() { if (videoPlayer == null) videoPlayer = gameObject.AddComponent<VideoPlayer>(); // 1. 关键:关闭自动播放 videoPlayer.playOnAwake = false; // 2. 关闭首帧等待(除非你需要严格的同步) videoPlayer.waitForFirstFrame = false; // 3. 设置视频源 videoPlayer.source = VideoSource.Url; videoPlayer.url = videoUrl; // 如果是VideoClip // videoPlayer.source = VideoSource.VideoClip; // videoPlayer.clip = videoClip; // 4. 配置渲染模式(示例:渲染到RenderTexture,便于UI显示和后期控制) videoPlayer.renderMode = VideoRenderMode.RenderTexture; RenderTexture rt = new RenderTexture(1920, 1080, 0, RenderTextureFormat.ARGB32); rt.Create(); videoPlayer.targetTexture = rt; // 将这个rt赋值给你的RawImage.material // 5. 订阅关键事件 videoPlayer.prepareCompleted += OnVideoPrepared; videoPlayer.errorReceived += OnVideoError; videoPlayer.loopPointReached += OnVideoEnd; // 6. 开始准备(异步,不阻塞) videoPlayer.Prepare(); Debug.Log("开始准备视频..."); } void OnVideoPrepared(VideoPlayer vp) { Debug.Log($"视频准备就绪,时长:{vp.length}秒,分辨率:{vp.width}x{vp.height}"); // 此时可以安全地开始播放,或显示一个“播放按钮” // vp.Play(); } void OnVideoError(VideoPlayer vp, string errorMsg) { Debug.LogError($"视频播放出错: {errorMsg}"); // 进行错误处理,如重试、显示错误界面等 } void OnVideoEnd(VideoPlayer vp) { Debug.Log("视频播放结束"); // 执行清理操作,见下文内存管理部分 Cleanup(); } // 外部调用的播放控制 public void PlayVideo() { if (videoPlayer.isPrepared) videoPlayer.Play(); else Debug.LogWarning("视频尚未准备就绪,无法播放"); } }

为什么这么做?

  • Prepare()是异步的,它允许你在后台加载视频资源,而不会卡住主线程。在prepareCompleted事件触发后再播放,能确保点击播放按钮时立即开始,消除等待。
  • 通过事件驱动,你可以精确地在合适的时机处理UI状态(如显示加载圈、播放按钮)、错误和播放结束逻辑。

3.2 渲染模式(Render Mode)的选择与优化

RenderMode决定了视频画面如何被绘制到屏幕上,选择不当会影响性能和效果。

渲染模式适用场景性能与内存考量注意事项
Camera Far/Near Plane作为3D场景中的背景或贴图(如天空盒、屏幕广告牌)。直接由摄像机渲染管线处理,性能通常较好。但视频纹理会占用一个摄像机渲染层。需要调整targetCameraAlpha来实现透明叠加。注意摄像机的裁剪平面设置,确保视频在视野内。
Render Texture最常用、最灵活的模式。用于UI显示(RawImage)、后期处理、或作为其他材质的输入。需要显式创建和管理RenderTexture对象。这是内存管理的核心,你必须手动释放它。创建RT时,尺寸尽量匹配视频分辨率,避免不必要的缩放。使用RenderTextureFormat.DefaultARGB32通常足够。
Material Override将视频作为动态纹理应用到3D物体的特定材质属性上。直接修改材质属性,性能取决于材质复杂度和渲染次数。需要确保材质的Shader支持纹理属性。
API Only不进行自动渲染,仅通过videoPlayer.texture获取纹理,由你完全控制渲染。最灵活,但实现最复杂。你需要自己写代码将纹理应用到需要的地方。适用于高级定制,如自定义播放器UI、复杂的多视频合成等。

对于大多数UI视频播放需求,我强烈推荐使用Render Texture模式。因为它将视频输出隔离到一个独立的纹理对象中,你可以轻松地控制它的生命周期,并且可以方便地将其赋值给UGUI的RawImage组件,实现完美的2D UI集成。

3.3 音频输出模式(Audio Output Mode)的取舍

VideoPlayer也负责音频播放,这里有三个选项:

  1. Audio Source:将音频路由到一个或多个Unity的AudioSource组件。这是推荐的方式,因为它允许你利用Unity完整的音频混合系统,包括空间音效、混音组、音量单独控制等。你可以为视频播放专门创建一个AudioSource,并精细控制其属性。
    videoPlayer.audioOutputMode = VideoAudioOutputMode.AudioSource; videoPlayer.SetTargetAudioSource(0, yourAudioSource); // 0代表第一个音轨 videoPlayer.controlledAudioTrackCount = 1; // 启用一个音轨控制
  2. Direct:音频绕过Unity的音频系统,直接发送到硬件。这可以减少一些延迟,但你会失去Unity音频管理的所有功能(如全局音量控制、暂停)。除非你对音频延迟有极端要求,否则不建议使用。
  3. None/API Only:不输出音频,或仅提供音频样本数据。适用于你只需要视频画面,或者打算用其他系统处理音频的情况。

优化点:如果你使用Audio Source模式,记得将关联的AudioSource组件的Play On Awake也设为false,并由VideoPlayer的事件来控制其播放/暂停,避免音频不同步。

3.4 跳帧与同步策略:Skip On Drop与时钟源

这是保证流畅性的高级技巧。

  • Skip On Drop:这个属性默认为false。当设置为true时,如果VideoPlayer检测到解码或渲染跟不上(即“掉帧”),它会尝试跳过一些帧来追上音频时钟,以保持音画同步。这会导致画面跳跃,但能避免音画不同步越来越严重。对于实时性要求高、且音频重要的场景(如游戏对话过场),建议开启。对于静音播放或背景视频,可以关闭以获得更平滑(即使稍慢)的视觉体验。

    videoPlayer.skipOnDrop = true;
  • Update Mode:这个属性决定了VideoPlayer用什么“时钟”来推进播放。

    • GameTime:使用受Time.timeScale影响的游戏时间。如果你的游戏暂停(Time.timeScale=0),视频也会暂停。这是最常用的模式。
    • UnscaledGameTime:使用不受Time.timeScale影响的游戏时间。即使游戏逻辑暂停,视频也会继续播放。适用于播放与游戏逻辑无关的UI动画视频。
    • DSPTime:使用音频系统的时钟。这能提供最精确的音频同步,但一般用于纯音频或对同步要求极高的专业场景。根据你的游戏状态管理需求来选择合适的模式。

4. 内存管理的深度实践:从分配到释放

内存管理是VideoPlayer优化的重中之重。管理不善,轻则内存泄漏,重则应用崩溃。

4.1 理解VideoPlayer的内存构成

VideoPlayer占用的内存主要分三块:

  1. 解码缓冲区:位于非托管内存,用于存储待解码和已解码的帧数据。大小由视频分辨率、帧率和缓冲策略决定。这部分Unity自动管理,但我们可以通过控制预加载量间接影响。
  2. 目标纹理:即RenderTexture,位于GPU显存。这是我们必须手动管理的大头。
  3. 托管堆对象:VideoPlayer组件本身、事件委托、字符串等C#对象。

4.2 RenderTexture的生命周期管理

这是内存泄漏最常见的源头。一个黄金法则:谁创建,谁销毁

public class ManagedVideoPlayback : MonoBehaviour { private VideoPlayer _videoPlayer; private RenderTexture _videoTexture; public RawImage displayImage; // UGUI的RawImage用于显示 public void LoadAndPlayVideo(string path) { // 1. 清理旧的资源(非常重要!) Cleanup(); // 2. 创建新的VideoPlayer(或复用) _videoPlayer = gameObject.AddComponent<VideoPlayer>(); _videoPlayer.playOnAwake = false; // 3. 创建RenderTexture // 建议根据视频实际分辨率创建,避免缩放损耗。这里假设已知或使用默认值。 _videoTexture = new RenderTexture(1920, 1080, 0); _videoTexture.name = "DynamicVideoRT"; // 命名便于调试 _videoPlayer.renderMode = VideoRenderMode.RenderTexture; _videoPlayer.targetTexture = _videoTexture; // 4. 赋值给UI if (displayImage != null) { displayImage.texture = _videoTexture; } // 5. 设置源并准备 _videoPlayer.url = path; _videoPlayer.Prepare(); _videoPlayer.prepareCompleted += (vp) => vp.Play(); } public void StopAndCleanup() { if (_videoPlayer != null) { _videoPlayer.Stop(); } Cleanup(); } private void Cleanup() { // 关键:销毁RenderTexture if (_videoTexture != null) { // 先从UI上解除引用 if (displayImage != null && displayImage.texture == _videoTexture) { displayImage.texture = null; } // 释放GPU资源 _videoTexture.Release(); // 销毁UnityEngine.Object Destroy(_videoTexture); _videoTexture = null; } // 销毁VideoPlayer组件 if (_videoPlayer != null) { // 取消所有事件订阅,避免内存泄漏 _videoPlayer.prepareCompleted = null; _videoPlayer.loopPointReached = null; _videoPlayer.errorReceived = null; // ... 取消其他所有事件订阅 Destroy(_videoPlayer); _videoPlayer = null; } } void OnDestroy() { // 确保对象销毁时资源被清理 Cleanup(); } }

关键点

  • RenderTexture.Release():立即释放GPU端的纹理资源。这是必须调用的。
  • Destroy(_videoTexture):销毁UnityEngine.Object对象本身。
  • 顺序很重要:先解除UI引用 (displayImage.texture = null),再释放和销毁纹理。否则可能导致UI引用一个已被销毁的纹理,产生错误。
  • 事件取消订阅:如果VideoPlayer组件被销毁,但之前订阅的事件没有取消,委托引用会阻止垃圾回收器回收包含该方法的对象,导致隐式内存泄漏。在Cleanup中将其置为null是良好习惯。

4.3 预加载与缓冲策略优化

VideoPlayer内部有缓冲机制,但我们也可以通过Prepare()来手动控制“预加载”的时机。对于需要秒开的视频,可以在用户可能点击前就提前准备。

// 假设有一个视频列表,预加载下一个可能播放的视频 private VideoPlayer _preloadPlayer; private string _preloadUrl; public void PreloadVideo(string url) { if (_preloadPlayer != null) { // 如果正在预加载另一个,先停止并清理 _preloadPlayer.Stop(); Destroy(_preloadPlayer.gameObject); // 假设预加载播放器在独立GameObject上 } GameObject go = new GameObject("PreloadVideoPlayer"); _preloadPlayer = go.AddComponent<VideoPlayer>(); _preloadPlayer.playOnAwake = false; _preloadPlayer.url = url; _preloadUrl = url; // 预加载但不播放 _preloadPlayer.Prepare(); } public void PlayPreloadedVideo() { if (_preloadPlayer != null && _preloadPlayer.isPrepared && _preloadPlayer.url == _preloadUrl) { // 将预加载的播放器“移交”给主播放系统 // ... (这里需要将_preloadPlayer的targetTexture等配置转移到主播放器) _preloadPlayer.Play(); _preloadPlayer = null; // 移交后清空引用 } else { // 后备方案:正常加载播放 LoadAndPlayVideo(_preloadUrl); } }

注意事项:预加载会占用额外的内存和CPU资源。在内存紧张的移动设备上,不宜同时预加载多个大视频。需要根据设备性能和业务需求做权衡。

4.4 针对不同平台的特别优化

  • iOS:确保视频编码格式是设备支持的(如H.264 Baseline/Main Profile)。HEVC(H.265)虽然压缩率高,但在旧设备上可能不支持硬解。使用Application.RequestAuthorization处理相册访问权限,避免因权限问题导致播放失败。
  • Android:情况更复杂。不同厂商、不同系统版本的解码能力差异巨大。务必在真机上进行广泛的兼容性测试。对于网络视频,注意Android 9.0 (Pie) 及以上版本默认禁用明文HTTP流量,确保使用HTTPS或配置网络安全策略。
  • WebGL:WebGL平台对VideoPlayer的支持有限(如不支持VideoClip,主要用URL)。视频文件需要放在StreamingAssets文件夹下,并通过相对路径访问。浏览器的跨域策略(CORS)也可能导致视频加载失败,服务器需要正确配置CORS头。

5. 高级技巧与疑难问题排查实录

掌握了基础配置和内存管理后,我们来看看一些能进一步提升体验和稳定性的高级技巧,以及如何解决那些令人头疼的常见问题。

5.1 无缝循环与淡入淡出

实现视频的无缝循环,单纯设置isLooping = true有时在循环点会有轻微卡顿。一个更平滑的方案是使用两个VideoPlayer交替播放。

public class SeamlessLoopVideo : MonoBehaviour { public VideoPlayer videoPlayerA; public VideoPlayer videoPlayerB; private VideoPlayer _currentPlayer; private VideoPlayer _nextPlayer; public RawImage display; void Start() { // 初始化两个播放器,指向同一个视频源 SetupPlayer(videoPlayerA); SetupPlayer(videoPlayerB); _currentPlayer = videoPlayerA; _nextPlayer = videoPlayerB; display.texture = videoPlayerA.targetTexture; videoPlayerA.loopPointReached += OnLoopPointReached; videoPlayerA.Play(); } void SetupPlayer(VideoPlayer vp) { vp.playOnAwake = false; vp.isLooping = false; // 禁用单个播放器的循环 vp.renderMode = VideoRenderMode.RenderTexture; vp.targetTexture = new RenderTexture(1920, 1080, 0); vp.Prepare(); } void OnLoopPointReached(VideoPlayer vp) { // 当前播放器播放完毕时,立即切换显示下一个播放器(已预加载到开头) display.texture = _nextPlayer.targetTexture; _nextPlayer.Play(); // 交换角色 VideoPlayer temp = _currentPlayer; _currentPlayer = _nextPlayer; _nextPlayer = temp; // 重置并准备刚才播放完的播放器,作为下一个“备用” _nextPlayer.Stop(); _nextPlayer.frame = 0; _nextPlayer.Prepare(); // 为当前播放器重新订阅结束事件 _currentPlayer.loopPointReached += OnLoopPointReached; } }

对于淡入淡出,可以通过控制显示视频的UI元素(如RawImageCanvasGroup)的Alpha值,结合协程来实现平滑过渡。

5.2 性能监控与自适应

在运行时监控视频播放的性能,并在必要时动态调整。

using UnityEngine.Profiling; public class VideoPerformanceMonitor : MonoBehaviour { public VideoPlayer videoPlayer; private System.Diagnostics.Stopwatch _decodeWatch = new System.Diagnostics.Stopwatch(); private long _lastFrameCount; private float _adjustmentTimer = 0f; public float checkInterval = 2.0f; // 每2秒检查一次 void Update() { if (videoPlayer == null || !videoPlayer.isPlaying) return; _adjustmentTimer += Time.deltaTime; if (_adjustmentTimer >= checkInterval) { _adjustmentTimer = 0f; CheckAndAdjustQuality(); } } void CheckAndAdjustQuality() { // 示例1:检查帧率是否过低 float currentFPS = 1.0f / Time.deltaTime; if (currentFPS < 25f) // 假设目标30fps,低于25则认为卡顿 { Debug.LogWarning($"帧率过低({currentFPS:F1}FPS),尝试降低播放质量"); // 策略:降低播放速度,或切换到更低码率的视频源(如果有) videoPlayer.playbackSpeed = 0.9f; // 轻微降速 // 或者,如果你有备用的低清视频流 // SwitchToLowerQualityStream(); } else if (currentFPS > 55f && videoPlayer.playbackSpeed < 1.0f) { // 性能恢复,尝试恢复正常速度 videoPlayer.playbackSpeed = Mathf.Min(videoPlayer.playbackSpeed + 0.05f, 1.0f); } // 示例2:使用Profiler监控内存(开发阶段) long textureMemory = Profiler.GetAllocatedMemoryForGraphicsDriver(); // 注意:这个值包含所有图形内存,不单是视频纹理。需要建立基线进行比较。 // if (textureMemory > YOUR_SAFE_THRESHOLD) { ... } } }

5.3 常见问题排查速查表

以下是我在项目中遇到并解决过的一些典型问题:

问题现象可能原因排查步骤与解决方案
黑屏,但音频正常1. 渲染模式或目标设置错误。
2. RenderTexture创建失败或未正确赋值。
3. 着色器不支持视频纹理。
1. 检查renderModetargetTexture/targetCamera是否正确设置。
2. 在代码中创建RT后,检查rt.IsCreated()是否为true。
3. 如果是Material Override模式,检查材质Shader是否有对应的纹理属性。尝试使用Standard Shader或UI/Unlit/Transparent等简单Shader测试。
播放卡顿、掉帧1. 视频分辨率/码率超出设备解码能力。
2. 硬盘I/O或网络慢,缓冲不足。
3. 游戏本身CPU占用过高,挤占了解码资源。
4. GPU带宽不足(特别是移动设备)。
1. 使用性能分析器(如Unity Profiler的CPU/GPU模块)查看瓶颈。尝试播放更低分辨率的视频。
2. 对于网络视频,检查缓冲。可适当增加videoPlayer.bufferTime(如果平台支持)。
3. 优化游戏其他模块的CPU开销。尝试开启skipOnDrop
4. 避免在同一帧进行大量其他GPU操作(如大量UI重绘、复杂粒子)。
内存持续增长,最终崩溃1.RenderTexture未释放(最常见)。
2. VideoPlayer组件未销毁,事件未取消订阅。
3. 视频源本身有内存泄漏(某些特殊编码)。
1. 严格遵循“创建-释放”配对,在OnDestroy、播放结束、切换视频时调用清理函数。
2. 使用Unity Profiler的Memory模块,查看RenderTextureVideoPlayer实例数量是否异常。
3. 尝试播放不同的视频文件,排除文件本身问题。
音频与视频不同步1. 解码性能不足导致视频帧落后。
2.Update Mode设置不当。
3. 音频输出模式为Direct时,与游戏音频系统冲突。
1. 开启skipOnDrop
2. 确保UpdateMode设置为GameTimeUnscaledGameTime,并与游戏的时间缩放逻辑匹配。
3. 优先使用Audio Source输出模式,让Unity统一管理音频时钟。
在部分Android设备上无法播放1. 视频编码格式不支持(如HEVC on old devices)。
2. 文件路径错误(StreamingAssets路径问题)。
3. 权限问题(读取存储权限)。
1. 统一转码为兼容性最广的H.264 Baseline/Main Profile, AAC音频。
2. 使用Application.streamingAssetsPath拼接路径,并确保文件在APK中。
3. 动态申请READ_EXTERNAL_STORAGE权限。
Prepare()耗时过长或失败1. 文件路径不存在或网络URL不可达。
2. 文件格式损坏或不支持。
3. 同步调用在主线程造成卡顿。
1. 监听errorReceived事件获取错误信息。
2. 使用Prepare()异步模式,并在UI上显示加载状态。
3. 对于大文件,考虑在后台线程进行初始文件读取(但VideoPlayer的Prepare必须在主线程)。
WebGL平台无法加载视频1. 文件不在StreamingAssets中。
2. 服务器未正确配置CORS。
3. 使用了VideoClip(WebGL不支持)。
1. 将视频文件放在Assets/StreamingAssets文件夹下。
2. 使用videoPlayer.url = System.IO.Path.Combine(Application.streamingAssetsPath, “myVideo.mp4”);
3. 确保服务器对视频文件响应头包含Access-Control-Allow-Origin: *

5.4 一个健壮的VideoPlayer管理器示例

最后,分享一个我常用的VideoPlayer管理器类的简化框架,它集成了加载、播放、内存管理和错误处理。

using System; using UnityEngine; using UnityEngine.Video; using UnityEngine.UI; [RequireComponent(typeof(RawImage))] public class RobustVideoManager : MonoBehaviour { public event Action OnVideoPrepared; public event Action OnVideoStarted; public event Action OnVideoEnded; public event Action<string> OnVideoError; [SerializeField] private VideoPlayer _videoPlayer; [SerializeField] private RawImage _display; [SerializeField] private GameObject _loadingIndicator; [SerializeField] private bool _autoPlay = false; private RenderTexture _currentRT; private string _currentVideoPath; private bool _isPrepared = false; void Awake() { if (_display == null) _display = GetComponent<RawImage>(); if (_videoPlayer == null) _videoPlayer = gameObject.AddComponent<VideoPlayer>(); InitializeVideoPlayer(); } private void InitializeVideoPlayer() { _videoPlayer.playOnAwake = false; _videoPlayer.waitForFirstFrame = false; _videoPlayer.renderMode = VideoRenderMode.RenderTexture; _videoPlayer.audioOutputMode = VideoAudioOutputMode.AudioSource; _videoPlayer.prepareCompleted += HandlePrepareCompleted; _videoPlayer.started += HandleVideoStarted; _videoPlayer.loopPointReached += HandleVideoEnded; _videoPlayer.errorReceived += HandleVideoError; } public void LoadVideo(string videoPath) { if (string.IsNullOrEmpty(videoPath)) { Debug.LogError("视频路径为空!"); return; } // 如果正在播放另一个视频,先停止 if (_videoPlayer.isPlaying) { _videoPlayer.Stop(); } // 清理上一视频资源 CleanupCurrentTexture(); _currentVideoPath = videoPath; _isPrepared = false; // 显示加载状态 if (_loadingIndicator != null) _loadingIndicator.SetActive(true); if (_display != null) _display.enabled = false; // 配置并准备 _videoPlayer.url = videoPath; _videoPlayer.Prepare(); } public void Play() { if (!_isPrepared) { Debug.LogWarning("视频未准备就绪,无法播放。"); // 可以在这里尝试调用 Prepare() 并播放 return; } _videoPlayer.Play(); } public void Pause() => _videoPlayer?.Pause(); public void Stop() => _videoPlayer?.Stop(); private void HandlePrepareCompleted(VideoPlayer source) { _isPrepared = true; Debug.Log($"视频准备完成: {source.url}"); // 创建与视频分辨率匹配的RenderTexture if (_currentRT != null) CleanupCurrentTexture(); _currentRT = new RenderTexture((int)source.width, (int)source.height, 0); _currentRT.Create(); source.targetTexture = _currentRT; // 更新UI显示 if (_display != null) { _display.texture = _currentRT; _display.enabled = true; } if (_loadingIndicator != null) _loadingIndicator.SetActive(false); OnVideoPrepared?.Invoke(); if (_autoPlay) { Play(); } } private void HandleVideoStarted(VideoPlayer source) { Debug.Log("视频开始播放"); OnVideoStarted?.Invoke(); } private void HandleVideoEnded(VideoPlayer source) { Debug.Log("视频播放结束"); OnVideoEnded?.Invoke(); // 如果不循环,可以在这里自动清理 if (!source.isLooping) { // Cleanup(); // 谨慎选择自动清理的时机 } } private void HandleVideoError(VideoPlayer source, string message) { Debug.LogError($"视频播放错误: {message}"); if (_loadingIndicator != null) _loadingIndicator.SetActive(false); OnVideoError?.Invoke(message); } private void CleanupCurrentTexture() { if (_currentRT != null) { if (_display != null && _display.texture == _currentRT) { _display.texture = null; } _currentRT.Release(); Destroy(_currentRT); _currentRT = null; } _isPrepared = false; } public void Cleanup() { if (_videoPlayer != null) { _videoPlayer.Stop(); // 取消事件订阅 _videoPlayer.prepareCompleted -= HandlePrepareCompleted; _videoPlayer.started -= HandleVideoStarted; _videoPlayer.loopPointReached -= HandleVideoEnded; _videoPlayer.errorReceived -= HandleVideoError; } CleanupCurrentTexture(); _currentVideoPath = null; } void OnDestroy() { Cleanup(); } }

这个管理器提供了从加载、状态回调到资源清理的完整生命周期管理,你可以直接将其挂载到UI的RawImage对象上使用,或者作为更复杂视频系统的基础组件。记住,在Unity中处理视频,细节决定成败。每一次Prepare(),每一个RenderTexture,都需要你心中有数,手中有策。