user 9ea5f2af2b Refactor : Spout/NDI 출력 파이프라인 통합 + 알파 합성 + Normalizer 통합
- RenderStreamOutput 을 URP 17 RenderGraph API 로 마이그레이션
  (옛 Execute() 도 Compatibility Mode 호환용으로 유지)
- 알파 합성 셰이더 신규: Pre/Post 비교(블룸/글로우) + NiloToon Prepass G + 가우시안 블러
- 알파 채널 별도 Spout 송신 추가 ("Streamingle Spout Alpha Output")
  - 그레이스케일 RGB 마스크, A=1
- spout_ndi_normalizer.exe 외부 프로세스 자동 실행/종료 (SpoutNdiLauncher 병합)
  - Display 드롭다운 / Vsync / AlwaysOnTop / HideCursor / Realtime / NoActivate 옵션
  - exe 가 있으면 강제 종료 후 단일 인스턴스 보장
  - 내부 옵션(exe 경로, window size 등)은 [HideInInspector]
- ScreenshotManager 가 RenderStreamOutput 의 합성 결과를 그대로 PNG 저장
  - 자체 카메라 렌더/셰이더 관리 제거 → 알파 품질 라이브 출력과 동일
  - captureWidth/Height 지정 시 한 프레임 임시 고해상도 렌더 후 원복
- spout_ndi_normalizer.exe 위치: Resources → StreamingAssets/SpoutNdiNormalizer
- URP Asset: Allow Post Process Alpha Output 활성화

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 04:35:14 +09:00

651 lines
23 KiB
C#

using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
using UnityEngine.Rendering.RenderGraphModule;
using UnityEngine.Rendering.RenderGraphModule.Util;
using Klak.Spout;
using Klak.Ndi;
using System;
using System.Diagnostics;
using System.IO;
using Debug = UnityEngine.Debug;
// 출력 방식 열거형
public enum OutputMethod
{
None,
Spout,
NDI,
Both
}
// Normalizer 출력 디스플레이 (음수 = windowed, 0~ = borderless fullscreen monitor index)
public enum NormalizerDisplay
{
Windowed = -1,
Display1 = 0,
Display2 = 1,
Display3 = 2,
Display4 = 3,
}
public class RenderStreamOutput : MonoBehaviour
{
[Header("출력 설정")]
[Tooltip("출력 방식 선택 (Spout, NDI, 또는 둘 다)")]
public OutputMethod outputMethod = OutputMethod.Spout;
[Tooltip("알파 채널 유지")]
public bool keepAlpha = true;
[Header("카메라 설정")]
[Tooltip("렌더링 소스로 사용할 카메라")]
public Camera MainCam;
[Header("Spout 설정")]
[Tooltip("Spout 송신 컴포넌트")]
public SpoutSender Sender;
[Tooltip("Spout 송신 이름")]
public string spoutSenderName = "Streamingle Spout Output";
[Header("Spout 알파 별도 송신")]
[Tooltip("알파 전용 Spout 송신 컴포넌트 (자동 생성)")]
public SpoutSender AlphaSender;
[Tooltip("알파 전용 Spout 송신 이름")]
public string alphaSpoutSenderName = "Streamingle Spout Alpha Output";
[Tooltip("Hidden/Streamingle/AlphaOnly 셰이더")]
public Shader alphaOnlyShader;
[Header("NDI 설정")]
[Tooltip("NDI 송신 컴포넌트")]
public NdiSender NdiSender;
[Tooltip("NDI 송신 이름")]
public string ndiSenderName = "Streamingle NDI Output";
[Header("텍스처 설정")]
[Tooltip("텍스처 포맷 설정")]
public RenderTextureFormat TextureFormat = RenderTextureFormat.DefaultHDR;
[Tooltip("안티앨리어싱 레벨 (1, 2, 4, 8)")]
[Range(1, 8)]
public int AntiAliasing = 1;
[Header("프레임 제한")]
[Tooltip("타겟 프레임레이트 (-1: 제한 없음). VSync 는 자동으로 꺼짐")]
public int targetFrameRate = 75;
[Header("Spout/NDI Normalizer")]
[Tooltip("외부 정규화 exe 자동 실행. Unity 출력의 페이싱 jitter 를 외부 프로세스로 정규화해 안정적 cadence 송신")]
public bool normalizerEnabled = true;
[Tooltip("출력 디스플레이 — 풀스크린 모니터 또는 Windowed")]
public NormalizerDisplay normalizerDisplay = NormalizerDisplay.Display2;
[Tooltip("Normalizer 출력 cadence (Hz)")]
public float normalizerFps = 60f;
[Tooltip("Normalizer VSync (런타임에 V 키로 토글 가능)")]
public bool normalizerVsync = true;
[Tooltip("Normalizer 윈도우를 항상 최상위로 유지 (런타임에 T 키로 토글 가능)")]
public bool normalizerAlwaysOnTop = false;
[Tooltip("Normalizer 윈도우 위에 커서가 있을 때 숨김 (런타임에 C 키로 토글 가능)")]
public bool normalizerHideCursor = false;
[Tooltip("⚠ 헤드리스 렌더팜 전용 — TIME_CRITICAL + Pro Audio MMCSS 스케줄링. Unity 가 foreground 일 때 켜면 Unity 자체가 starvation 됨. 평소엔 끄세요")]
public bool normalizerRealtime = false;
[Tooltip("Normalizer 창이 클릭으로 focus 를 가져가지 않게 함. Unity Editor 와 같이 작업할 때 권장 — 클릭이 통과되어 Unity 가 foreground 유지. 단, 런타임 핫키(F11/V/T/C/P/ESC 등) 비활성")]
public bool normalizerNoActivate = true;
// ──────── 내부 옵션 (사용자가 만지지 않도록 숨김) ────────
[HideInInspector] public string normalizerExeRelativePath = "SpoutNdiNormalizer/spout_ndi_normalizer.exe";
[HideInInspector] public string normalizerExeAbsolutePath = "";
[HideInInspector] public int normalizerWindowWidth = 1280;
[HideInInspector] public int normalizerWindowHeight = 720;
[HideInInspector] public bool normalizerShowConsole = false;
[Header("알파 합성")]
[Tooltip("Hidden/Streamingle/AlphaCompose 셰이더")]
public Shader alphaComposeShader;
[Tooltip("cameraColor.a 를 알파로 사용하는 강도. NiloToon 환경에서는 cameraColor.a 가 1 로 차서 보통 0 권장")]
[Range(0f, 4f)]
public float sourceAlphaGain = 0.0f;
[Tooltip("후처리(블룸/글로우 등) 가 추가한 빛을 알파로 변환하는 강도")]
[Range(0f, 20f)]
public float bloomAlphaGain = 5.0f;
[Tooltip("NiloToon Prepass G 채널 알파 강도 (0 = 사용 안 함)")]
[Range(0f, 4f)]
public float prepassAlphaGain = 1.0f;
[Tooltip("Prepass 알파 외곽 가우시안 블러 반경 (텍셀)")]
[Range(0f, 5f)]
public float alphaBlurRadius = 1.0f;
[HideInInspector] public RenderTexture CaptureTexture = null;
[HideInInspector] public RenderTexture AlphaOutputTexture = null;
private RenderTexture m_PreColorTexture = null;
private RTHandle m_CaptureHandle;
private RTHandle m_PreColorHandle;
private RTHandle m_AlphaOutputHandle;
private Material m_AlphaComposeMaterial;
private Material m_AlphaOnlyMaterial;
private PreCapturePass m_PreCapturePass;
private ComposePass m_ComposePass;
private AlphaOutputPass m_AlphaOutputPass;
private Process m_NormalizerProc;
private int screenWidth;
private int screenHeight;
private OutputMethod previousOutputMethod;
private bool previousKeepAlpha;
private int previousTargetFrameRate;
private static readonly int s_SourceAlphaGainID = Shader.PropertyToID("_SourceAlphaGain");
private static readonly int s_BloomAlphaGainID = Shader.PropertyToID("_BloomAlphaGain");
private static readonly int s_PrepassAlphaGainID = Shader.PropertyToID("_PrepassAlphaGain");
private static readonly int s_AlphaBlurRadiusID = Shader.PropertyToID("_AlphaBlurRadius");
private void Awake()
{
MainCam = MainCam == null ? Camera.main : MainCam;
previousOutputMethod = outputMethod;
previousKeepAlpha = keepAlpha;
previousTargetFrameRate = targetFrameRate;
ApplyFrameRate();
EnsureMaterial();
EnsureAlphaOnlyMaterial();
InitializeOutputComponents();
InitializeTextures();
}
private void Start()
{
if (normalizerEnabled)
{
try { LaunchNormalizer(); }
catch (Exception e) { Debug.LogError($"[RenderStreamOutput] normalizer launch failed: {e}"); }
}
}
private void OnApplicationQuit() { KillNormalizer(); }
private void EnsureAlphaOnlyMaterial()
{
if (alphaOnlyShader == null)
alphaOnlyShader = Shader.Find("Hidden/Streamingle/AlphaOnly");
if (alphaOnlyShader == null)
{
Debug.LogError("AlphaOnly 셰이더를 찾을 수 없습니다.");
return;
}
if (m_AlphaOnlyMaterial == null)
m_AlphaOnlyMaterial = new Material(alphaOnlyShader) { hideFlags = HideFlags.HideAndDontSave };
}
private void ApplyFrameRate()
{
if (targetFrameRate > 0)
{
QualitySettings.vSyncCount = 0;
Application.targetFrameRate = targetFrameRate;
}
else
{
Application.targetFrameRate = -1;
}
}
private void EnsureMaterial()
{
if (alphaComposeShader == null)
alphaComposeShader = Shader.Find("Hidden/Streamingle/AlphaCompose");
if (alphaComposeShader == null)
{
Debug.LogError("AlphaCompose 셰이더를 찾을 수 없습니다. Always Included Shaders 에 추가하거나 인스펙터에 할당하세요.");
return;
}
if (m_AlphaComposeMaterial == null)
m_AlphaComposeMaterial = new Material(alphaComposeShader) { hideFlags = HideFlags.HideAndDontSave };
PushMaterialParams();
}
private void PushMaterialParams()
{
if (m_AlphaComposeMaterial == null) return;
m_AlphaComposeMaterial.SetFloat(s_SourceAlphaGainID, sourceAlphaGain);
m_AlphaComposeMaterial.SetFloat(s_BloomAlphaGainID, bloomAlphaGain);
m_AlphaComposeMaterial.SetFloat(s_PrepassAlphaGainID, prepassAlphaGain);
m_AlphaComposeMaterial.SetFloat(s_AlphaBlurRadiusID, alphaBlurRadius);
}
private void InitializeOutputComponents()
{
if (outputMethod == OutputMethod.Spout || outputMethod == OutputMethod.Both)
{
if (Sender == null)
{
Sender = GetComponent<SpoutSender>();
if (Sender == null)
Sender = gameObject.AddComponent<SpoutSender>();
}
}
if (outputMethod == OutputMethod.NDI || outputMethod == OutputMethod.Both)
{
if (NdiSender == null)
{
NdiSender = GetComponent<NdiSender>();
if (NdiSender == null)
NdiSender = gameObject.AddComponent<NdiSender>();
}
}
if (AlphaSender == null)
{
// 같은 GameObject 에 두 번째 SpoutSender 추가
AlphaSender = gameObject.AddComponent<SpoutSender>();
}
}
private void OnEnable()
{
InitializeScriptablePasses();
RenderPipelineManager.beginCameraRendering += OnBeginCameraRendering;
}
private void OnDisable()
{
RenderPipelineManager.beginCameraRendering -= OnBeginCameraRendering;
}
private void OnBeginCameraRendering(ScriptableRenderContext context, Camera camera)
{
if (camera != MainCam)
return;
var data = camera.GetUniversalAdditionalCameraData();
if (m_PreCapturePass != null)
data.scriptableRenderer.EnqueuePass(m_PreCapturePass);
if (m_ComposePass != null)
data.scriptableRenderer.EnqueuePass(m_ComposePass);
if (m_AlphaOutputPass != null)
data.scriptableRenderer.EnqueuePass(m_AlphaOutputPass);
}
private void InitializeTextures()
{
if (MainCam == null)
{
Debug.LogError("MainCam이 설정되지 않았습니다.");
return;
}
screenWidth = MainCam.pixelWidth;
screenHeight = MainCam.pixelHeight;
var desc = new RenderTextureDescriptor(screenWidth, screenHeight, TextureFormat, 0)
{
msaaSamples = Mathf.Max(1, AntiAliasing)
};
ReleaseRT(ref CaptureTexture, ref m_CaptureHandle);
CaptureTexture = new RenderTexture(desc);
CaptureTexture.Create();
m_CaptureHandle = RTHandles.Alloc(CaptureTexture, transferOwnership: false);
ReleaseRT(ref m_PreColorTexture, ref m_PreColorHandle);
m_PreColorTexture = new RenderTexture(desc);
m_PreColorTexture.Create();
m_PreColorHandle = RTHandles.Alloc(m_PreColorTexture, transferOwnership: false);
ReleaseRT(ref AlphaOutputTexture, ref m_AlphaOutputHandle);
AlphaOutputTexture = new RenderTexture(desc);
AlphaOutputTexture.Create();
m_AlphaOutputHandle = RTHandles.Alloc(AlphaOutputTexture, transferOwnership: false);
UpdateOutputSources();
InitializeScriptablePasses();
}
private static void ReleaseRT(ref RenderTexture rt, ref RTHandle handle)
{
if (handle != null)
{
handle.Release();
handle = null;
}
if (rt != null)
{
rt.Release();
DestroyImmediate(rt);
rt = null;
}
}
private void UpdateOutputSources()
{
if (outputMethod == OutputMethod.Spout || outputMethod == OutputMethod.Both)
{
if (Sender != null)
{
Sender.enabled = true;
Sender.sourceTexture = CaptureTexture;
Sender.spoutName = spoutSenderName;
Sender.keepAlpha = keepAlpha;
Sender.captureMethod = Klak.Spout.CaptureMethod.Texture;
}
}
else if (Sender != null)
{
Sender.enabled = false;
}
if (outputMethod == OutputMethod.NDI || outputMethod == OutputMethod.Both)
{
if (NdiSender != null)
{
NdiSender.enabled = true;
NdiSender.sourceTexture = CaptureTexture;
NdiSender.ndiName = ndiSenderName;
NdiSender.keepAlpha = keepAlpha;
NdiSender.captureMethod = Klak.Ndi.CaptureMethod.Texture;
}
else
{
InitializeOutputComponents();
}
}
else if (NdiSender != null)
{
NdiSender.enabled = false;
}
// 알파 전용 Spout 송신 (항상 활성)
if (AlphaSender != null)
{
AlphaSender.enabled = true;
AlphaSender.sourceTexture = AlphaOutputTexture;
AlphaSender.spoutName = alphaSpoutSenderName;
AlphaSender.keepAlpha = false;
AlphaSender.captureMethod = Klak.Spout.CaptureMethod.Texture;
}
}
private void InitializeScriptablePasses()
{
m_PreCapturePass = new PreCapturePass(m_PreColorHandle)
{
renderPassEvent = RenderPassEvent.BeforeRenderingPostProcessing
};
m_ComposePass = new ComposePass(m_CaptureHandle, m_PreColorHandle, m_PreColorTexture, m_AlphaComposeMaterial)
{
renderPassEvent = RenderPassEvent.AfterRenderingPostProcessing
};
m_AlphaOutputPass = new AlphaOutputPass(m_CaptureHandle, m_AlphaOutputHandle, m_AlphaOnlyMaterial)
{
renderPassEvent = RenderPassEvent.AfterRenderingPostProcessing + 1
};
}
private void Update()
{
if (MainCam != null && (screenWidth != MainCam.pixelWidth || screenHeight != MainCam.pixelHeight))
{
InitializeTextures();
}
if (previousOutputMethod != outputMethod || previousKeepAlpha != keepAlpha)
{
previousOutputMethod = outputMethod;
previousKeepAlpha = keepAlpha;
InitializeOutputComponents();
UpdateOutputSources();
}
if (previousTargetFrameRate != targetFrameRate)
{
previousTargetFrameRate = targetFrameRate;
ApplyFrameRate();
}
PushMaterialParams();
}
private void OnDestroy()
{
m_PreCapturePass = null;
m_ComposePass = null;
m_AlphaOutputPass = null;
ReleaseRT(ref CaptureTexture, ref m_CaptureHandle);
ReleaseRT(ref m_PreColorTexture, ref m_PreColorHandle);
ReleaseRT(ref AlphaOutputTexture, ref m_AlphaOutputHandle);
if (m_AlphaComposeMaterial != null)
{
DestroyImmediate(m_AlphaComposeMaterial);
m_AlphaComposeMaterial = null;
}
if (m_AlphaOnlyMaterial != null)
{
DestroyImmediate(m_AlphaOnlyMaterial);
m_AlphaOnlyMaterial = null;
}
KillNormalizer();
}
// ───────────────────────── Spout/NDI Normalizer (외부 exe) ─────────────────────────
private string ResolveNormalizerExePath()
{
if (!string.IsNullOrEmpty(normalizerExeAbsolutePath))
return normalizerExeAbsolutePath;
var rel = normalizerExeRelativePath.Replace('/', Path.DirectorySeparatorChar);
return Path.Combine(Application.streamingAssetsPath, rel);
}
public void LaunchNormalizer()
{
var exe = ResolveNormalizerExePath();
if (!File.Exists(exe))
{
Debug.LogError($"[RenderStreamOutput] normalizer exe not found: {exe}");
return;
}
// 기존 자식 프로세스 + orphan 모두 종료 후 새로 실행 (단일 인스턴스 보장)
KillNormalizer();
KillExternalNormalizers(exe);
var args = BuildNormalizerArgs();
var psi = new ProcessStartInfo(exe, args)
{
UseShellExecute = false,
CreateNoWindow = !normalizerShowConsole,
WindowStyle = normalizerShowConsole ? ProcessWindowStyle.Normal : ProcessWindowStyle.Hidden,
WorkingDirectory = Path.GetDirectoryName(exe),
};
m_NormalizerProc = Process.Start(psi);
if (m_NormalizerProc == null)
{
Debug.LogError("[RenderStreamOutput] normalizer Process.Start returned null");
return;
}
Debug.Log($"[RenderStreamOutput] normalizer launched PID {m_NormalizerProc.Id}\n exe={exe}\n args={args}");
}
public void KillNormalizer()
{
try
{
if (m_NormalizerProc != null && !m_NormalizerProc.HasExited)
{
m_NormalizerProc.Kill();
m_NormalizerProc.WaitForExit(2000);
}
}
catch (Exception e) { Debug.LogWarning($"[RenderStreamOutput] normalizer kill: {e.Message}"); }
m_NormalizerProc = null;
}
private string BuildNormalizerArgs()
{
string Q(string s) => "\"" + s.Replace("\"", "\\\"") + "\"";
var parts = new System.Collections.Generic.List<string>
{
"--sender", Q(spoutSenderName),
"--fps", normalizerFps.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture),
"--width", normalizerWindowWidth.ToString(),
"--height", normalizerWindowHeight.ToString(),
"--parent-pid", Process.GetCurrentProcess().Id.ToString(),
};
int monitorIndex = (int)normalizerDisplay;
if (monitorIndex >= 0) { parts.Add("--monitor"); parts.Add(monitorIndex.ToString()); }
if (!normalizerVsync) parts.Add("--no-vsync");
if (normalizerAlwaysOnTop) parts.Add("--topmost");
if (normalizerHideCursor) parts.Add("--hide-cursor");
if (normalizerRealtime) parts.Add("--realtime");
if (normalizerNoActivate) parts.Add("--no-activate");
return string.Join(" ", parts);
}
private static void KillExternalNormalizers(string exePath)
{
var name = Path.GetFileNameWithoutExtension(exePath);
try
{
foreach (var p in Process.GetProcessesByName(name))
{
try
{
if (!p.HasExited)
{
p.Kill();
p.WaitForExit(2000);
Debug.Log($"[RenderStreamOutput] killed orphan normalizer PID {p.Id}");
}
}
catch (Exception e) { Debug.LogWarning($"[RenderStreamOutput] kill orphan: {e.Message}"); }
finally { p.Dispose(); }
}
}
catch { /* best effort */ }
}
}
// 후처리 전 cameraColor 를 PreColorTexture 로 백업
public class PreCapturePass : ScriptableRenderPass
{
private RTHandle m_PreColorHandle;
public PreCapturePass(RTHandle preColorHandle)
{
m_PreColorHandle = preColorHandle;
}
public override void RecordRenderGraph(RenderGraph renderGraph, ContextContainer frameData)
{
if (m_PreColorHandle == null)
return;
var resourceData = frameData.Get<UniversalResourceData>();
if (resourceData == null)
return;
TextureHandle source = resourceData.activeColorTexture;
if (!source.IsValid())
return;
TextureHandle destination = renderGraph.ImportTexture(m_PreColorHandle);
using (var builder = renderGraph.AddBlitPass(source, destination, Vector2.one, Vector2.zero,
passName: "Streamingle Pre-PostProcess Capture", returnBuilder: true))
{
builder.AllowPassCulling(false);
}
}
}
// CaptureTexture 의 알파를 그레이스케일 RGB 로 변환해서 AlphaOutputTexture 로 출력
public class AlphaOutputPass : ScriptableRenderPass
{
private RTHandle m_SourceHandle;
private RTHandle m_DestHandle;
private Material m_Material;
public AlphaOutputPass(RTHandle sourceHandle, RTHandle destHandle, Material material)
{
m_SourceHandle = sourceHandle;
m_DestHandle = destHandle;
m_Material = material;
}
public override void RecordRenderGraph(RenderGraph renderGraph, ContextContainer frameData)
{
if (m_SourceHandle == null || m_DestHandle == null || m_Material == null)
return;
TextureHandle source = renderGraph.ImportTexture(m_SourceHandle);
TextureHandle destination = renderGraph.ImportTexture(m_DestHandle);
var blitParams = new RenderGraphUtils.BlitMaterialParameters(source, destination, m_Material, 0);
using (var builder = renderGraph.AddBlitPass(blitParams,
passName: "Streamingle Alpha-Only Output", returnBuilder: true))
{
builder.AllowPassCulling(false);
}
}
}
// 후처리 후 cameraColor + PreColorTexture + NiloToonPrepass.g 합성 → CaptureTexture
public class ComposePass : ScriptableRenderPass
{
private RTHandle m_CaptureHandle;
private RTHandle m_PreColorHandle;
private RenderTexture m_PreColorRT;
private Material m_Material;
private static readonly int s_PreColorTexID = Shader.PropertyToID("_PreColorTex");
public ComposePass(RTHandle captureHandle, RTHandle preColorHandle, RenderTexture preColorRT, Material material)
{
m_CaptureHandle = captureHandle;
m_PreColorHandle = preColorHandle;
m_PreColorRT = preColorRT;
m_Material = material;
}
public override void RecordRenderGraph(RenderGraph renderGraph, ContextContainer frameData)
{
if (m_CaptureHandle == null || m_PreColorHandle == null || m_Material == null)
return;
var resourceData = frameData.Get<UniversalResourceData>();
if (resourceData == null)
return;
TextureHandle source = resourceData.activeColorTexture;
if (!source.IsValid())
return;
TextureHandle destination = renderGraph.ImportTexture(m_CaptureHandle);
TextureHandle preColor = renderGraph.ImportTexture(m_PreColorHandle);
// 머티리얼에 PreColor 텍스처 직접 바인딩 (외부 RT 라 lifetime 안전).
// RenderGraph 는 UseTexture 로 PreCapturePass→ComposePass 순서만 보장.
m_Material.SetTexture(s_PreColorTexID, m_PreColorRT);
var blitParams = new RenderGraphUtils.BlitMaterialParameters(source, destination, m_Material, 0);
using (var builder = renderGraph.AddBlitPass(blitParams,
passName: "Streamingle Alpha Compose", returnBuilder: true))
{
builder.UseTexture(preColor);
builder.AllowPassCulling(false);
}
}
}