視頻投影貼花開發指南
功能概述
視頻投影貼花是在 投影貼花 基礎上的擴展:投影內容不再是一張靜態圖片,而是一段視頻。做法是用 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 官方文檔。