using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;
namespace InteractiveVideo
{
///
/// Rasterises polygons into a small single-channel RenderTexture on the GPU: white inside, black outside.
/// The polygons are triangulated on the CPU (ear clipping, a few dozen points) and drawn as one mesh with a
/// command buffer, so no per-pixel CPU work and no per-frame allocations after warm-up.
///
/// Input polygons are in VIDEO UV space (0..1, Y up). Callers convert from tracking space with
/// . The mask is sampled with the same UVs as the video texture, so a fixed
/// (video-independent) resolution is fine: it only drives the highlight, not the image.
///
[DisallowMultipleComponent]
[AddComponentMenu("Interactive Video/Polygon Mask Renderer")]
public sealed class PolygonMaskRenderer : MonoBehaviour
{
[Tooltip("Mask width in pixels. 256 / 512 / 1024. Height follows the video aspect when Match Video Aspect is on, otherwise square.")]
[SerializeField] private int maskResolution = 512;
[Tooltip("Size the mask height to the video aspect (e.g. 512x288 for 16:9) so the outline is equally thick in both directions.")]
[SerializeField] private bool matchVideoAspect;
[Tooltip("MSAA samples for the mask (1 = off). Softens the polygon edge.")]
[SerializeField] private int antiAliasing = 4;
[Tooltip("Material using the InteractiveVideo/PolygonMask shader. Falls back to Shader.Find when empty.")]
[SerializeField] private Material maskMaterial;
/// Raised when the RenderTexture is (re)created; the highlight controller re-binds it.
public event Action MaskTextureChanged;
/// The mask. Created lazily; do not cache across resolution changes (listen to MaskTextureChanged).
public RenderTexture MaskTexture
{
get
{
EnsureTexture();
return _texture;
}
}
/// True when the last render drew at least one polygon.
public bool HasContent { get; private set; }
public int MaskResolution
{
get => maskResolution;
set { maskResolution = ClampResolution(value); EnsureTexture(); }
}
private RenderTexture _texture;
private Mesh _mesh;
private Material _material;
private bool _ownsMaterial;
private CommandBuffer _cmd;
private float _videoAspect = 1f;
private readonly List _vertices = new List(256);
private readonly List _indices = new List(768);
private bool _building;
private bool _clearedOnce;
// Which way up the mask lands depends on the graphics API and on how the command buffer is executed
// (measured: Direct3D 12 in the editor draws it upside down with the "render into texture" flip applied).
// Instead of trusting a flag, the first real render draws a calibration polygon into the top half, reads
// one pixel back and flips the projection when that pixel is black. Once per texture; costs one 1x1 readback.
private int _calibration; // 0 = not yet, 1 = upright, 2 = flipped
private Texture2D _probe;
private static readonly Matrix4x4 s_View = Matrix4x4.identity;
private static readonly Matrix4x4 s_Ortho = Matrix4x4.Ortho(0f, 1f, 0f, 1f, -1f, 1f);
private static readonly Matrix4x4 s_OrthoFlipped = Matrix4x4.Ortho(0f, 1f, 1f, 0f, -1f, 1f);
/// True when the platform needed the mask projection flipped (diagnostics).
public bool MaskIsFlipped => _calibration == 2;
// ------------------------------------------------------------------ lifecycle
private void OnValidate()
{
maskResolution = ClampResolution(maskResolution);
antiAliasing = Mathf.Clamp(antiAliasing, 1, 8);
if (antiAliasing == 3 || (antiAliasing > 4 && antiAliasing < 8)) antiAliasing = antiAliasing < 4 ? 2 : 4;
}
private void OnDestroy()
{
if (_texture != null) { _texture.Release(); Destroy(_texture); _texture = null; }
if (_mesh != null) { Destroy(_mesh); _mesh = null; }
if (_ownsMaterial && _material != null) { Destroy(_material); _material = null; }
if (_probe != null) { Destroy(_probe); _probe = null; }
_cmd?.Release();
_cmd = null;
}
// ------------------------------------------------------------------ public API
/// Tell the renderer the video aspect (width/height) so Match Video Aspect can size the mask.
public void SetVideoAspect(float aspect)
{
if (aspect <= 0f || Mathf.Approximately(aspect, _videoAspect)) return;
_videoAspect = aspect;
if (matchVideoAspect) EnsureTexture();
}
/// Clears the mask to black (nothing selected).
public void Clear()
{
EnsureTexture();
if (!HasContent && _clearedOnce) return;
_vertices.Clear();
_indices.Clear();
Execute(drawMesh: false);
HasContent = false;
_clearedOnce = true;
}
/// Renders a single polygon (video UV space).
public void Render(IReadOnlyList uvPolygon)
{
BeginPolygons();
AddPolygon(uvPolygon);
EndPolygons();
}
/// Start collecting polygons for one mask render (multi-selection).
public void BeginPolygons()
{
_vertices.Clear();
_indices.Clear();
_building = true;
}
/// Adds a polygon (video UV space, 3+ points; fewer are ignored). Copies the points, so buffers may be reused.
public void AddPolygon(IReadOnlyList uvPolygon)
{
if (!_building) BeginPolygons();
if (uvPolygon == null || uvPolygon.Count < 3) return;
int offset = _vertices.Count;
for (int i = 0; i < uvPolygon.Count; i++)
_vertices.Add(new Vector3(uvPolygon[i].x, uvPolygon[i].y, 0f));
PolygonUtility.Triangulate(uvPolygon, _indices, offset);
}
/// Draws everything added since BeginPolygons.
public void EndPolygons()
{
_building = false;
EnsureTexture();
if (_indices.Count == 0)
{
Clear();
return;
}
Execute(drawMesh: true);
HasContent = true;
}
// ------------------------------------------------------------------ internals
private void Execute(bool drawMesh)
{
if (drawMesh && _calibration == 0 && EnsureMaterial()) Calibrate();
Draw(_vertices, _indices, drawMesh);
}
private void Draw(List vertices, List indices, bool drawMesh)
{
if (_cmd == null) _cmd = new CommandBuffer { name = "InteractiveVideo Polygon Mask" };
_cmd.Clear();
_cmd.SetRenderTarget(_texture);
_cmd.ClearRenderTarget(false, true, Color.black);
if (drawMesh && EnsureMaterial())
{
if (_mesh == null)
{
_mesh = new Mesh { name = "InteractiveVideo_PolygonMask", indexFormat = IndexFormat.UInt32 };
_mesh.MarkDynamic();
}
_mesh.Clear(false);
_mesh.SetVertices(vertices);
_mesh.SetIndices(indices, MeshTopology.Triangles, 0, false);
_mesh.bounds = new Bounds(new Vector3(0.5f, 0.5f, 0f), new Vector3(4f, 4f, 4f));
var ortho = _calibration == 2 ? s_OrthoFlipped : s_Ortho;
_cmd.SetViewProjectionMatrices(s_View, GL.GetGPUProjectionMatrix(ortho, true));
_cmd.DrawMesh(_mesh, Matrix4x4.identity, _material, 0, 0);
}
Graphics.ExecuteCommandBuffer(_cmd);
}
///
/// Draws a quad over the top half of UV space (v 0.5..1) with the unflipped projection and reads back one
/// pixel from the top quarter. ReadPixels uses the same UV convention the highlight shader samples with,
/// so a black pixel there means the projection must be flipped on this platform.
///
private void Calibrate()
{
_calibration = 1;
try
{
var quad = new List { new Vector3(0f, 0.5f, 0f), new Vector3(1f, 0.5f, 0f), new Vector3(1f, 1f, 0f), new Vector3(0f, 1f, 0f) };
var tris = new List { 0, 1, 2, 0, 2, 3 };
Draw(quad, tris, true);
if (_probe == null) _probe = new Texture2D(1, 1, TextureFormat.RGBA32, false) { name = "InteractiveVideo_MaskProbe", hideFlags = HideFlags.HideAndDontSave };
var previous = RenderTexture.active;
RenderTexture.active = _texture;
_probe.ReadPixels(new Rect(_texture.width / 2, _texture.height * 3 / 4, 1, 1), 0, 0, false);
_probe.Apply(false, false);
RenderTexture.active = previous;
bool topIsWhite = _probe.GetPixel(0, 0).r > 0.5f;
_calibration = topIsWhite ? 1 : 2;
if (!topIsWhite) Debug.Log($"[InteractiveVideo] PolygonMaskRenderer: mask projection flipped for {SystemInfo.graphicsDeviceType}.", this);
}
catch (Exception e)
{
Debug.LogWarning($"[InteractiveVideo] PolygonMaskRenderer: could not calibrate the mask orientation ({e.Message}); assuming upright.", this);
_calibration = 1;
}
}
private bool EnsureMaterial()
{
if (_material != null) return true;
if (maskMaterial != null)
{
_material = maskMaterial;
return true;
}
var shader = Shader.Find("Hidden/InteractiveVideo/PolygonMask");
if (shader == null)
{
Debug.LogError("[InteractiveVideo] PolygonMaskRenderer: assign the InteractiveVideoMask material (shader Hidden/InteractiveVideo/PolygonMask not found).", this);
return false;
}
_material = new Material(shader) { name = "InteractiveVideo_PolygonMask (runtime)" };
_ownsMaterial = true;
return true;
}
private void EnsureTexture()
{
int w = ClampResolution(maskResolution);
int h = matchVideoAspect ? Mathf.Max(8, Mathf.RoundToInt(w / _videoAspect)) : w;
int aa = Mathf.Max(1, antiAliasing);
if (_texture != null && _texture.width == w && _texture.height == h && _texture.antiAliasing == aa) return;
if (_texture != null)
{
_texture.Release();
Destroy(_texture);
}
var format = SystemInfo.SupportsRenderTextureFormat(RenderTextureFormat.R8) ? RenderTextureFormat.R8 : RenderTextureFormat.ARGB32;
_texture = new RenderTexture(w, h, 0, format, RenderTextureReadWrite.Linear)
{
name = "InteractiveVideo_Mask",
antiAliasing = aa,
useMipMap = false,
autoGenerateMips = false,
filterMode = FilterMode.Bilinear,
wrapMode = TextureWrapMode.Clamp,
};
_texture.Create();
_clearedOnce = false;
HasContent = false;
Execute(drawMesh: false);
_clearedOnce = true;
MaskTextureChanged?.Invoke(_texture);
}
private static int ClampResolution(int value)
{
if (value <= 256) return 256;
if (value <= 512) return 512;
return 1024;
}
}
}