视频投影贴花开发指南
功能概述
视频投影贴花是在 投影贴花 基础上的扩展:投影内容不再是一张静态图片,而是一段视频。做法是用 Unity 内置的 VideoPlayer 解码视频,把每一帧的画面动态更新到贴花纹理上,于是这段视频就像幻灯机一样被投射到 3DGS 点云表面。
由于贴花本身是世界空间中固定的透视投影,视频画面会随投影仪的位姿"贴"在点云表面。落在投影仪视锥内的点被视频当前帧染色,视锥外不受影响。
说明:
视频贴花与普通贴花一样,仅在 PC / Mac(URP 管线) 下生效。移动端或 VR 平台调用相关接口会被 SDK 忽略并打印警告。
本文只讲解与"视频"相关的增量部分。场景搭建(Main Camera / LCCManager / LCCRenderer / Projector)、URP Renderer 添加 Decal Depth Feature、遮挡剔除调参、贴花 API 详解等基础内容,请先阅读 投影贴花开发指南。
实现原理
SDK 的贴花接口 SetDecalTexture(Texture2D tex) 只接受 Texture2D,但它支持在运行时动态更新这张纹理。而 Unity 的 VideoPlayer 解码输出的是 RenderTexture(GPU 端)。因此整条数据链路是:
VideoPlayer 解码 → RenderTexture(GPU)→ 拷贝到 Texture2D → SetDecalTexture(帧纹理) → 投影到点云
其中有两个关键点,也是本文重点提醒的两处"对齐":
- 空间对齐:视频画面投到点云的位置、大小、方向由投影仪参数决定(位姿、FOV、Aspect、Near/Far),见 视频与场景的空间对齐。
- 时间对齐:视频帧率往往远低于场景渲染帧率(场景可能跑到几百 FPS),不能每个渲染帧都拷贝并推送一次纹理。通过
VideoPlayer.frame判断是否出现了新解码帧,只在新帧到来时才更新,见 视频帧率与渲染帧率的对齐。

视频投影贴花数据链路:VideoPlayer → RenderTexture → Texture2D → SetDecalTexture
前置条件
视频贴花与普通贴花共享同一套前提,开始前请确认:
- 场景中已搭好 Main Camera、LCCManager、LCCRenderer、Projector(做法见 投影贴花开发指南 · 步骤 1)。
- 当前使用 URP 管线;若需要遮挡剔除,URP Renderer Asset 中已注册 Decal Depth Feature。
- 若 Console 报错且看不到效果,关闭 Render Graph 兼容模式( 参见 投影贴花开发指南 常见问题章节)。
- 准备好一段视频素材:可以是工程内的
VideoClip资源,也可以是磁盘 / 网络上的视频文件 URL(如file:///D:/sample.mp4)。
步骤 1:编写脚本
新建一个 C# 脚本,命名为 VideoStreaming.cs。它在普通 Decal.cs 的骨架上,把"静态贴花纹理"替换为"运行时创建 VideoPlayer 解码视频,并在 Update() 中按解码帧更新贴花纹理"。可直接复制到工程使用。
using LCCCore;
using UnityEngine;
using UnityEngine.Video;
public class VideoStreaming : MonoBehaviour
{
[Header("Required")]
[Tooltip("The LCCManager instance in the scene")]
public LCCManager m_manager;
[Tooltip("A valid LCC scene path")]
public string m_filePath;
[Header("Video source (decoded frames are projected as the decal)")]
[Tooltip("Video clip to project. Takes priority over the URL below")]
public VideoClip m_videoClip;
[Tooltip("Video file path / URL, used when no clip is assigned (e.g. file:///D:/xxx.mp4)")]
public string m_videoUrl;
[Tooltip("Loop playback")]
public bool m_videoLoop = true;
[Header("Projector (projects along +Z)")]
[Tooltip("Projector pose. Leave empty to use this object's transform")]
public Transform m_projector;
[Range(1f, 179f)] public float m_fov = 60f;
[Tooltip("Projection aspect ratio; <=0 uses the video's aspect ratio automatically")]
public float m_aspect = 0f;
public float m_near = 0.1f;
public float m_far = 100f;
[Header("Decal appearance")]
[Range(0f, 1f)] public float m_opacity = 1.0f;
[Range(0f, 0.5f)][Tooltip("Edge fade width (UV ratio). 0 = hard edge")]
public float m_edgeFade = 0.1f;
[Tooltip("Refresh projection every frame, so you can drag the projector / tweak params at runtime")]
public bool m_updateEveryFrame = true;
[Header("Occlusion culling (PC/Mac only)")]
[Tooltip("When on, back-facing/occluded points behind front geometry are no longer colored by the decal (slide-projector effect); when off it is X-ray")]
public bool m_occlusion = false;
[Range(0f, 1f)][Tooltip("Opacity threshold: only splats with opacity >= this value contribute to the depth map, filtering out floaters/foggy points. 0 = all participate")]
public float m_occlusionCutoff = 0f;
[Range(0f, 0.5f)][Tooltip("Relative depth tolerance. Too small -> near-surface self-occlusion; too large -> leaked X-ray coloring. Default 0.05")]
public float m_occlusionBias = 0.05f;
[Range(0, 16)][Tooltip("Square footprint radius cap (px), the main knob for removing X-ray bleed-through. 0 = single texel, default 2")]
public int m_occlusionFootprint = 2;
[Tooltip("Depth map resolution (px, power of two 256~4096). Lower it to fill grazing-angle holes. Default 1024")]
public int m_occlusionResolution = 1024;
// -- 运行时状态 --
private bool m_loaded;
private bool m_lastOcclusion;
private float m_lastCutoff = float.NaN;
private float m_lastBias = float.NaN;
private int m_lastFootprint = -1;
private int m_lastResolution = -1;
// -- 视频状态 --
private VideoPlayer m_videoPlayer;
private RenderTexture m_videoRT; // VideoPlayer 将视频解码到这里(GPU)
private Texture2D m_frameTex; // 当前帧拷贝到这里,喂给贴花
private bool m_videoReady;
private long m_lastVideoFrame = -1; // 上一次推给贴花的帧序号
void Start()
{
if (m_manager == null || string.IsNullOrEmpty(m_filePath))
{
Debug.LogError("[VideoStreaming] m_manager or m_filePath is not assigned.");
enabled = false;
return;
}
// 1) 将渲染管线初始化为 PC 单场景编辑器模式
m_manager.SetPlatformType(PlatformType.PC);
m_manager.SwitchRenderPass(ActiveRenderMode.SingleRender);
m_manager.SetEditorMode(true);
// 2) 与场景加载并行地开始解码视频
SetupVideo();
// 3) 加载点云场景,然后启用贴花(贴花是全局状态,在场景就绪后再设置最为稳妥)
var renderer = m_manager.GetRender(this.transform);
Debug.Log($"[VideoStreaming] Start loading: {m_filePath}");
renderer.Load(m_filePath,
_ => { /* 进度回调,本示例未使用 */ },
OnSceneLoaded);
}
/// <summary>在运行时创建 VideoPlayer 并开始准备 clip/URL。</summary>
private void SetupVideo()
{
m_videoPlayer = gameObject.AddComponent<VideoPlayer>();
m_videoPlayer.playOnAwake = false;
m_videoPlayer.isLooping = m_videoLoop;
m_videoPlayer.renderMode = VideoRenderMode.RenderTexture;
m_videoPlayer.audioOutputMode = VideoAudioOutputMode.None; // 演示:不接音频
if (m_videoClip != null)
{
m_videoPlayer.source = VideoSource.VideoClip;
m_videoPlayer.clip = m_videoClip;
}
else
{
m_videoPlayer.source = VideoSource.Url;
m_videoPlayer.url = m_videoUrl;
}
// 分辨率已知后再分配帧缓冲,然后开始播放。
m_videoPlayer.prepareCompleted += OnVideoPrepared;
m_videoPlayer.Prepare();
}
private void OnVideoPrepared(VideoPlayer vp)
{
int w = (int)vp.width;
int h = (int)vp.height;
m_videoRT = new RenderTexture(w, h, 0, RenderTextureFormat.ARGB32);
m_videoRT.Create();
vp.targetTexture = m_videoRT;
m_frameTex = new Texture2D(w, h, TextureFormat.RGBA32, false);
m_videoReady = true;
vp.Play();
Debug.Log($"[VideoStreaming] Video prepared: {w}x{h}, playing.");
}
private void OnSceneLoaded()
{
Debug.Log("[VideoStreaming] Load complete, enabling decal projection.");
// -- 基础投影:5 个 API --
// 纹理内容在 Update() 中按解码帧驱动;这里先把当前已有的帧推上去。
if (m_frameTex != null) m_manager.SetDecalTexture(m_frameTex);
m_manager.SetDecalOpacity(m_opacity); // 整体不透明度
m_manager.SetDecalEdgeFade(m_edgeFade); // 边缘羽化
ApplyProjection(); // 投影仪位姿(内部调用 SetDecalProjection)
m_manager.SetDecalEnabled(true); // 启用贴花(_DECAL keyword)
// -- 遮挡剔除:2 个 API -- 必须在 ApplyProjection 之后(深度图需要有效的投影仪 VP 矩阵)
m_manager.SetDecalOcclusionSettings(BuildOcclusionSettings());
m_manager.SetDecalOcclusion(m_occlusion);
CacheOcclusionState();
m_loaded = true;
}
void Update()
{
if (!m_loaded) return;
// --- 视频帧更新,与渲染 FPS 解耦 ---
// VideoPlayer.frame 只按视频自身的播放速率递增。即使场景以数百 FPS 渲染,
// 相邻两个视频帧之间该序号也保持不变,因此只有当真正出现新解码帧时,
// 我们才拷贝该帧并重新推送贴花纹理。
if (m_videoReady && m_videoPlayer.frame != m_lastVideoFrame)
{
m_lastVideoFrame = m_videoPlayer.frame;
// 把 GPU 上的 RenderTexture 拷贝到贴花 API 接受的 Texture2D。
// 只在视频帧率下执行,因此回读开销可控。
RenderTexture prev = RenderTexture.active;
RenderTexture.active = m_videoRT;
m_frameTex.ReadPixels(new Rect(0, 0, m_videoRT.width, m_videoRT.height), 0, 0, false);
m_frameTex.Apply(false);
RenderTexture.active = prev;
m_manager.SetDecalTexture(m_frameTex);
}
// 运行时切换遮挡开关
if (m_occlusion != m_lastOcclusion)
{
m_manager.SetDecalOcclusion(m_occlusion);
m_lastOcclusion = m_occlusion;
}
// 若任一遮挡质量参数发生变化 -> 整体推送一次结构体
if (!Mathf.Approximately(m_occlusionCutoff, m_lastCutoff) ||
!Mathf.Approximately(m_occlusionBias, m_lastBias) ||
m_occlusionFootprint != m_lastFootprint ||
m_occlusionResolution != m_lastResolution)
{
m_manager.SetDecalOcclusionSettings(BuildOcclusionSettings());
CacheOcclusionState();
}
// 每帧刷新投影仪 / 外观,便于运行时实时调参
if (!m_updateEveryFrame) return;
m_manager.SetDecalOpacity(m_opacity);
m_manager.SetDecalEdgeFade(m_edgeFade);
ApplyProjection();
}
/// <summary>根据当前投影仪位姿与透视参数构建投影矩阵。</summary>
private void ApplyProjection()
{
Transform p = m_projector != null ? m_projector : this.transform;
float aspect = m_aspect > 0f
? m_aspect
: (m_videoRT != null ? (float)m_videoRT.width / m_videoRT.height : 1920.0f / 1080.0f);
m_manager.SetDecalProjection(p.position, p.rotation, m_fov, aspect, m_near, m_far);
}
/// <summary>将 4 个遮挡参数汇集到一个结构体中。</summary>
private DecalOcclusionSettings BuildOcclusionSettings()
{
return new DecalOcclusionSettings
{
opacityCutoff = m_occlusionCutoff,
depthBias = m_occlusionBias,
footprintMaxPx = m_occlusionFootprint,
resolution = m_occlusionResolution,
};
}
private void CacheOcclusionState()
{
m_lastOcclusion = m_occlusion;
m_lastCutoff = m_occlusionCutoff;
m_lastBias = m_occlusionBias;
m_lastFootprint = m_occlusionFootprint;
m_lastResolution = m_occlusionResolution;
}
private void OnDestroy()
{
if (m_videoPlayer != null) m_videoPlayer.prepareCompleted -= OnVideoPrepared;
if (m_videoRT != null)
{
m_videoRT.Release();
Destroy(m_videoRT);
}
if (m_frameTex != null) Destroy(m_frameTex);
}
// -- 便捷调试入口(Inspector 右键) --
[ContextMenu("Decal On")] private void DecalOn() { m_manager?.SetDecalEnabled(true); }
[ContextMenu("Decal Off")] private void DecalOff() { m_manager?.SetDecalEnabled(false); }
[ContextMenu("Occlusion On")] private void OcclusionOn() { m_occlusion = true; m_manager?.SetDecalOcclusion(true); m_lastOcclusion = true; }
[ContextMenu("Occlusion Off")] private void OcclusionOff() { m_occlusion = false; m_manager?.SetDecalOcclusion(false); m_lastOcclusion = false; }
}
注意:
- 视频与场景加载并行进行:
Start()中先调用SetupVideo()启动VideoPlayer.Prepare(),再发起点云场景Load。二者互不阻塞。- 帧缓冲在视频就绪后才分配:
RenderTexture与Texture2D的尺寸取自视频真实分辨率(vp.width/vp.height),所以放在prepareCompleted回调OnVideoPrepared里创建。- 贴花在场景加载完成后才开启:与普通贴花一致,
SetDecalTexture/SetDecalEnabled等放在OnSceneLoaded回调里。若此时视频尚未就绪,m_frameTex为空则跳过首次纹理设置,等Update()里第一帧到来再补上。
步骤 2:挂载脚本与属性设置
- 将
VideoStreaming.cs脚本挂载到 Hierarchy 窗口中的 LCCRenderer 游戏对象上。 - 在 Inspector 窗口中,将 LCCManager 游戏对象拖到脚本的 Manager 属性上。
- 将 Projector 游戏对象拖到脚本的 Projector 属性上。
- 在 File Path 属性中填写 LCC 场景文件路径。
- 指定视频源,二选一:
- 把工程内的 VideoClip 资源拖到 Video Clip 属性;或
- 在 Video Url 属性填写视频文件路径(如
file:///D:/sample.mp4)。 - 两者都填时以 Video Clip 优先。
- 其他属性可按需调整,含义见下表。

VideoStreaming 脚本的 Inspector 属性设置
| 字段 | 含义 | 默认值 |
|---|---|---|
| Manager | 场景中的 LCCManager 实例 | 必填 |
| File Path | 有效的 LCC 场景路径 | 必填 |
| Video Clip | 要投影的视频资源,优先级高于 Video Url | 空 |
| Video Url | 视频文件路径 / URL,未指定 Clip 时使用 | 空 |
| Video Loop | 是否循环播放 | 勾选 |
| Projector | 投影仪位姿,+Z 为投影方向;留空则用本物体 Transform | 空 |
| Fov | 投影仪透视视角 | 60 |
| Aspect | 投影宽高比;小于等于 0 时按视频宽高比自动计算 | 0(自动) |
| Near / Far | 投影仪视锥近 / 远裁剪面 | 0.1 / 100 |
| Opacity | 贴花整体不透明度 | 1 |
| Edge Fade | 边缘羽化宽度(UV 比例),0 为硬边 | 0.1 |
| Update Every Frame | 每帧刷新投影仪 / 外观,便于运行时调参 | 勾选 |
| Occlusion | 是否开启遮挡剔除 | 关 |
| Occlusion Cutoff / Bias / Footprint / Resolution | 遮挡剔除质量参数,含义见 投影贴花开发指南 | 0 / 0.05 / 2 / 1024 |
步骤 3:运行效果
在 Unity 中点击 Play。视频会在 Prepare 完成后开始播放,场景加载完成后贴花自动开启,视频画面便逐帧投射到 3DGS 表面。勾选 Update Every Frame 时,可在 Scene 视图直接拖动投影仪,实时看到视频画面在点云表面移动。

运行后视频逐帧投影到 3DGS 表面
视频与场景的空间对齐
"空间对齐"指让视频画面准确地投在点云上你想要的位置、朝向和大小。视频画面所处的位置完全由投影仪(SetDecalProjection)决定,与视频内容无关。影响对齐的参数如下:
| 参数 | 来源 | 对对齐的影响 |
|---|---|---|
| 投影仪 位置(Projector.position) | Projector 的 Transform | 决定视锥顶点的世界坐标,即视频从"哪里"投出。整体平移画面在点云上的落点。 |
| 投影仪 朝向(Projector.rotation) | Projector 的 Transform | +Z 为投影方向。旋转投影仪即改变画面投向,画面会随之在表面平移 / 倾斜。 |
| Fov(垂直视角) | 脚本字段 m_fov | 决定视锥张开角度,直接影响画面在点云上的大小:FOV 越大画面铺得越大,越小越聚拢。范围 [1, 179]。 |
| Aspect(宽高比) | 脚本字段 m_aspect | 决定画面横纵比例。填 0 时自动取视频分辨率宽高比(m_videoRT.width / height),这是保证视频不被拉伸变形的推荐做法。手动填错会导致画面拉伸。 |
| Near / Far(近 / 远裁剪面) | 脚本字段 m_near / m_far | 决定视锥沿投影方向的有效深度区间。只有落在 [near, far] 内的点才会被染色。Far 太小会截掉远处点云,Near 太大会漏掉近处。需覆盖目标点云所在的深度范围。 |
对齐的一般步骤:
- 先把 Aspect 保持为 0(自动),避免画面变形,专注调位置和大小。
- 拖动 Projector 的位置和朝向,让画面中心对准目标区域(勾选 Update Every Frame 可实时预览)。
- 调 Fov 控制画面覆盖范围大小。
- 确认 Near / Far 区间完整覆盖目标点云的深度,避免画面被裁剪面截断。
- 如需强制特定比例(例如把 16:9 视频投成正方形区域),再手动设置 Aspect。
提示:
视频的画面比例由 Aspect 控制,视频的分辨率只影响清晰度与回读开销,不影响投在点云上的物理大小——大小由 Fov 和投影距离共同决定。

投影仪位姿 / Fov / Aspect / Near-Far 对视频画面在点云上落点与大小的影响
视频帧率与渲染帧率的对齐
这是视频贴花最关键的一处处理。视频帧率(如 25/30 FPS)通常远低于场景渲染帧率(3DGS 场景可能跑到几百 FPS)。 如果每个渲染帧都做一次"回读 + SetDecalTexture",会有两个问题:
- 重复劳动:同一视频帧被反复拷贝、反复推送,纯属浪费。
- 性能拖累:
ReadPixels是 GPU→CPU 回读,代价不低;在高渲染帧率下每帧都做会明显掉帧。
解耦策略:以 VideoPlayer.frame 为节拍
VideoPlayer.frame 是当前已解码到的帧序号,它只按视频自身的播放速率递增。渲染帧率再高,两个视频帧之间这个序号也不会变。据此只在"序号变化"时才更新纹理:
if (m_videoReady && m_videoPlayer.frame != m_lastVideoFrame)
{
m_lastVideoFrame = m_videoPlayer.frame;
// 只有新解码帧到来时,才回读一次并推送贴花
RenderTexture prev = RenderTexture.active;
RenderTexture.active = m_videoRT;
m_frameTex.ReadPixels(new Rect(0, 0, m_videoRT.width, m_videoRT.height), 0, 0, false);
m_frameTex.Apply(false);
RenderTexture.active = prev;
m_manager.SetDecalTexture(m_frameTex);
}
这样:
- 场景以 300 FPS 渲染、视频是 30 FPS 时,300 个渲染帧里只有约 30 次真正做了回读和推送,其余帧被
if直接跳过。 - 节拍由播放器自己掌握,无需手写计时器去凑视频帧率,也不会因为渲染帧率波动而丢帧或重复帧。
- 视频的快慢、循环、暂停都由
VideoPlayer内部管理,frame序号会如实反映,脚本无需额外同步。
为什么不用每帧计时器?
手动累加
Time.deltaTime去逼近视频帧间隔,既要处理渲染帧率抖动,又要处理视频变速 / 跳帧,容易和播放器内部的实际解码进度错位。直接读VideoPlayer.frame让播放器做唯一事实源,最简单也最稳。
关于 Update Every Frame:
脚本里的
m_updateEveryFrame控制的是投影仪位姿 / 不透明度 / 羽化是否每帧刷新,与视频帧更新是两回事。视频帧更新永远按VideoPlayer.frame节拍走,不受这个开关影响。
性能与格式注意事项
- 回读开销与分辨率成正比。
ReadPixels+Apply是 GPU→CPU→GPU 的往返,分辨率越高越慢。本示例用它是为了简单直观、演示够用。若视频分辨率很高或对性能敏感,可改用纯 GPU 拷贝Graphics.CopyTexture(m_videoRT, m_frameTex),但要求源和目标格式匹配(RenderTextureFormat.ARGB32对应TextureFormat.RGBA32),且部分平台对 CopyTexture 有限制。 SetDecalTexture只接受Texture2D。 这正是必须把RenderTexture回读成Texture2D的原因;不能直接把VideoPlayer的RenderTexture传给贴花接口。- 帧缓冲复用,不要每帧 new。 示例中
m_frameTex只在视频就绪时创建一次,之后每帧复用同一张Texture2D,避免频繁分配 / GC。 - 及时释放资源。
OnDestroy中Release()并Destroy掉RenderTexture与Texture2D,同时反注册prepareCompleted,避免泄漏。 - 音频未接线。 示例把
audioOutputMode设为None,只演示画面。需要声音时可改为AudioSource模式并挂一个AudioSource。
与普通贴花的差异小结
| 方面 | 普通贴花(Decal.cs) | 视频贴花(VideoStreaming.cs) |
|---|---|---|
| 贴花内容 | 静态 Texture2D | 视频逐帧解码得到的 Texture2D |
| 纹理来源 | Inspector 直接拖入图片 | VideoPlayer 解码 → RenderTexture → 回读 |
| 纹理更新时机 | 一次性设置 | 按 VideoPlayer.frame 节拍逐帧更新 |
| 额外组件 | 无 | 运行时 AddComponent<VideoPlayer>() |
| Aspect 自动值 | 图片宽高比 | 视频宽高比 |
| 其余(投影仪 / 遮挡 / API) | 完全一致 | 完全一致 |
涉及的关键 API
视频贴花在 SDK 侧只额外用到了 SetDecalTexture 的动态更新能力,其余贴花 API 与普通贴花完全相同,详见 投影贴花开发指南 · 贴花相关 API。
SetDecalTexture(动态更新)
// 返回值:void
public void SetDecalTexture(Texture2D tex)
设置贴花纹理。RGB 为颜色,A 为贴花自身遮罩。该方法支持运行时反复调用:每次传入新的 Texture2D 都会立即替换当前贴花内容,这正是视频逐帧投影的基础——在 Update() 中每出现一个新解码帧就调用一次。
| 参数 | 类型 | 说明 |
|---|---|---|
tex | Texture2D | 贴花纹理,可在运行时反复替换 |
其余相关 Unity 内置类型(VideoPlayer / RenderTexture / Texture2D)为 Unity 标准 API,用法参见 Unity 官方文档。