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(); if (Sender == null) Sender = gameObject.AddComponent(); } } if (outputMethod == OutputMethod.NDI || outputMethod == OutputMethod.Both) { if (NdiSender == null) { NdiSender = GetComponent(); if (NdiSender == null) NdiSender = gameObject.AddComponent(); } } if (AlphaSender == null) { // 같은 GameObject 에 두 번째 SpoutSender 추가 AlphaSender = gameObject.AddComponent(); } } 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 { "--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(); 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(); 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); } } }