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>
This commit is contained in:
user 2026-05-08 04:32:13 +09:00
parent 3f30d5672a
commit 9ea5f2af2b
15 changed files with 737 additions and 290 deletions

Binary file not shown.

View File

@ -1,9 +1,14 @@
using UnityEngine; using UnityEngine;
using UnityEngine.Rendering; using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal; using UnityEngine.Rendering.Universal;
using UnityEngine.Rendering.RenderGraphModule;
using UnityEngine.Rendering.RenderGraphModule.Util;
using Klak.Spout; using Klak.Spout;
using Klak.Ndi; using Klak.Ndi;
using System; using System;
using System.Diagnostics;
using System.IO;
using Debug = UnityEngine.Debug;
// 출력 방식 열거형 // 출력 방식 열거형
public enum OutputMethod public enum OutputMethod
@ -14,6 +19,16 @@ public enum OutputMethod
Both 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 public class RenderStreamOutput : MonoBehaviour
{ {
[Header("출력 설정")] [Header("출력 설정")]
@ -32,99 +47,212 @@ public class RenderStreamOutput : MonoBehaviour
[Tooltip("Spout 송신 이름")] [Tooltip("Spout 송신 이름")]
public string spoutSenderName = "Streamingle Spout Output"; 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 설정")] [Header("NDI 설정")]
[Tooltip("NDI 송신 컴포넌트")] [Tooltip("NDI 송신 컴포넌트")]
public NdiSender NdiSender; public NdiSender NdiSender;
[Tooltip("NDI 송신 이름")] [Tooltip("NDI 송신 이름")]
public string ndiSenderName = "Streamingle NDI Output"; public string ndiSenderName = "Streamingle NDI Output";
[Header("쉐이더 설정")]
[Tooltip("렌더링 결과를 처리할 쉐이더 머티리얼")]
public Material ShaderContral;
[Header("텍스처 설정")] [Header("텍스처 설정")]
[Tooltip("텍스처 포맷 설정")] [Tooltip("텍스처 포맷 설정")]
public RenderTextureFormat TextureFormat = RenderTextureFormat.DefaultHDR; public RenderTextureFormat TextureFormat = RenderTextureFormat.DefaultHDR;
[Tooltip("깊이 버퍼 비트 수")]
public int DepthBuffer = 24;
[Tooltip("안티앨리어싱 레벨 (1, 2, 4, 8)")] [Tooltip("안티앨리어싱 레벨 (1, 2, 4, 8)")]
[Range(1, 8)] [Range(1, 8)]
public int AntiAliasing = 1; public int AntiAliasing = 1;
[HideInInspector] public CustomRenderTexture ShaderTexture = null; [Header("프레임 제한")]
[HideInInspector] public RenderTexture ShaderCameraTexture = null; [Tooltip("타겟 프레임레이트 (-1: 제한 없음). VSync 는 자동으로 꺼짐")]
public int targetFrameRate = 75;
private AlphaRecodingRenderPass m_ScriptablePass; [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 screenWidth;
private int screenHeight; private int screenHeight;
// 이전 출력 방식 저장
private OutputMethod previousOutputMethod; private OutputMethod previousOutputMethod;
// 이전 알파 채널 설정 저장
private bool previousKeepAlpha; 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() private void Awake()
{ {
// 컴포넌트 자동 할당
MainCam = MainCam == null ? Camera.main : MainCam; MainCam = MainCam == null ? Camera.main : MainCam;
// 초기 설정 저장
previousOutputMethod = outputMethod; previousOutputMethod = outputMethod;
previousKeepAlpha = keepAlpha; previousKeepAlpha = keepAlpha;
previousTargetFrameRate = targetFrameRate;
// 출력 컴포넌트 초기화 ApplyFrameRate();
EnsureMaterial();
EnsureAlphaOnlyMaterial();
InitializeOutputComponents(); InitializeOutputComponents();
InitializeTextures(); 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() private void InitializeOutputComponents()
{ {
// Spout 송신 컴포넌트 초기화
if (outputMethod == OutputMethod.Spout || outputMethod == OutputMethod.Both) if (outputMethod == OutputMethod.Spout || outputMethod == OutputMethod.Both)
{ {
if (Sender == null) if (Sender == null)
{ {
Sender = GetComponent<SpoutSender>(); Sender = GetComponent<SpoutSender>();
if (Sender == null) if (Sender == null)
{
Sender = gameObject.AddComponent<SpoutSender>(); Sender = gameObject.AddComponent<SpoutSender>();
Sender.spoutName = spoutSenderName;
Sender.keepAlpha = keepAlpha;
Sender.captureMethod = Klak.Spout.CaptureMethod.Texture;
}
} }
} }
// NDI 송신 컴포넌트 초기화
if (outputMethod == OutputMethod.NDI || outputMethod == OutputMethod.Both) if (outputMethod == OutputMethod.NDI || outputMethod == OutputMethod.Both)
{ {
if (NdiSender == null) if (NdiSender == null)
{ {
NdiSender = GetComponent<NdiSender>(); NdiSender = GetComponent<NdiSender>();
if (NdiSender == null) if (NdiSender == null)
{
Debug.Log("NDI 송신 컴포넌트를 생성합니다.");
NdiSender = gameObject.AddComponent<NdiSender>(); NdiSender = gameObject.AddComponent<NdiSender>();
if (NdiSender != null)
{
NdiSender.ndiName = ndiSenderName;
NdiSender.keepAlpha = keepAlpha;
NdiSender.captureMethod = Klak.Ndi.CaptureMethod.Texture;
}
else
{
Debug.LogError("NDI 송신 컴포넌트 생성 실패.");
}
}
} }
} }
if (AlphaSender == null)
{
// 같은 GameObject 에 두 번째 SpoutSender 추가
AlphaSender = gameObject.AddComponent<SpoutSender>();
}
} }
private void OnEnable() private void OnEnable()
{ {
InitializeScriptablePass(); InitializeScriptablePasses();
RenderPipelineManager.beginCameraRendering += OnBeginCameraRendering; RenderPipelineManager.beginCameraRendering += OnBeginCameraRendering;
} }
@ -135,77 +263,76 @@ public class RenderStreamOutput : MonoBehaviour
private void OnBeginCameraRendering(ScriptableRenderContext context, Camera camera) private void OnBeginCameraRendering(ScriptableRenderContext context, Camera camera)
{ {
if (camera == MainCam) if (camera != MainCam)
{ return;
var data = camera.GetUniversalAdditionalCameraData();
data.scriptableRenderer.EnqueuePass(m_ScriptablePass); 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() private void InitializeTextures()
{ {
// 카메라가 null인 경우 체크
if (MainCam == null) if (MainCam == null)
{ {
Debug.LogError("MainCam이 설정되지 않았습니다."); Debug.LogError("MainCam이 설정되지 않았습니다.");
return; return;
} }
// ShaderContral이 null인 경우 체크
if (ShaderContral == null)
{
Debug.LogError("ShaderContral 머티리얼이 설정되지 않았습니다.");
return;
}
// 카메라의 실제 해상도 사용
screenWidth = MainCam.pixelWidth; screenWidth = MainCam.pixelWidth;
screenHeight = MainCam.pixelHeight; screenHeight = MainCam.pixelHeight;
// ShaderTexture 초기화 - 메모리 누수 방지를 위한 확실한 정리 var desc = new RenderTextureDescriptor(screenWidth, screenHeight, TextureFormat, 0)
if (ShaderTexture != null)
{ {
ShaderTexture.Release(); msaaSamples = Mathf.Max(1, AntiAliasing)
DestroyImmediate(ShaderTexture); // Destroy 대신 DestroyImmediate 사용 };
}
ShaderTexture = new CustomRenderTexture(screenWidth, screenHeight, TextureFormat);
ShaderTexture.material = ShaderContral;
ShaderTexture.updateMode = CustomRenderTextureUpdateMode.Realtime;
ShaderTexture.antiAliasing = AntiAliasing;
// ShaderCameraTexture 초기화 ReleaseRT(ref CaptureTexture, ref m_CaptureHandle);
if (ShaderCameraTexture != null) CaptureTexture = new RenderTexture(desc);
{ CaptureTexture.Create();
ShaderCameraTexture.Release(); m_CaptureHandle = RTHandles.Alloc(CaptureTexture, transferOwnership: false);
DestroyImmediate(ShaderCameraTexture); // Destroy 대신 DestroyImmediate 사용
}
ShaderCameraTexture = new RenderTexture(screenWidth, screenHeight, DepthBuffer, TextureFormat);
ShaderCameraTexture.Create();
ShaderCameraTexture.antiAliasing = AntiAliasing;
// ShaderCameraTexture를 ShaderContral의 _MainTex에 할당 ReleaseRT(ref m_PreColorTexture, ref m_PreColorHandle);
if (ShaderContral != null) m_PreColorTexture = new RenderTexture(desc);
{ m_PreColorTexture.Create();
ShaderContral.SetTexture("_MainTex", ShaderCameraTexture); m_PreColorHandle = RTHandles.Alloc(m_PreColorTexture, transferOwnership: false);
UpdateShaderScreenSize();
} ReleaseRT(ref AlphaOutputTexture, ref m_AlphaOutputHandle);
AlphaOutputTexture = new RenderTexture(desc);
AlphaOutputTexture.Create();
m_AlphaOutputHandle = RTHandles.Alloc(AlphaOutputTexture, transferOwnership: false);
// 출력 방식에 따라 텍스처 할당
UpdateOutputSources(); UpdateOutputSources();
InitializeScriptablePasses();
}
// m_ScriptablePass 재초기화 private static void ReleaseRT(ref RenderTexture rt, ref RTHandle handle)
InitializeScriptablePass(); {
if (handle != null)
{
handle.Release();
handle = null;
}
if (rt != null)
{
rt.Release();
DestroyImmediate(rt);
rt = null;
}
} }
private void UpdateOutputSources() private void UpdateOutputSources()
{ {
// Spout 출력 설정
if (outputMethod == OutputMethod.Spout || outputMethod == OutputMethod.Both) if (outputMethod == OutputMethod.Spout || outputMethod == OutputMethod.Both)
{ {
if (Sender != null) if (Sender != null)
{ {
Sender.enabled = true; Sender.enabled = true;
Sender.sourceTexture = ShaderTexture; Sender.sourceTexture = CaptureTexture;
Sender.spoutName = spoutSenderName; Sender.spoutName = spoutSenderName;
Sender.keepAlpha = keepAlpha; Sender.keepAlpha = keepAlpha;
Sender.captureMethod = Klak.Spout.CaptureMethod.Texture; Sender.captureMethod = Klak.Spout.CaptureMethod.Texture;
@ -216,20 +343,18 @@ public class RenderStreamOutput : MonoBehaviour
Sender.enabled = false; Sender.enabled = false;
} }
// NDI 출력 설정
if (outputMethod == OutputMethod.NDI || outputMethod == OutputMethod.Both) if (outputMethod == OutputMethod.NDI || outputMethod == OutputMethod.Both)
{ {
if (NdiSender != null) if (NdiSender != null)
{ {
NdiSender.enabled = true; NdiSender.enabled = true;
NdiSender.sourceTexture = ShaderTexture; NdiSender.sourceTexture = CaptureTexture;
NdiSender.ndiName = ndiSenderName; NdiSender.ndiName = ndiSenderName;
NdiSender.keepAlpha = keepAlpha; NdiSender.keepAlpha = keepAlpha;
NdiSender.captureMethod = Klak.Ndi.CaptureMethod.Texture; NdiSender.captureMethod = Klak.Ndi.CaptureMethod.Texture;
} }
else else
{ {
// NDI 송신 컴포넌트가 없으면 다시 초기화 시도
InitializeOutputComponents(); InitializeOutputComponents();
} }
} }
@ -237,27 +362,41 @@ public class RenderStreamOutput : MonoBehaviour
{ {
NdiSender.enabled = false; 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 InitializeScriptablePass() private void InitializeScriptablePasses()
{ {
m_ScriptablePass = new AlphaRecodingRenderPass(ShaderCameraTexture, ShaderContral, ShaderTexture); m_PreCapturePass = new PreCapturePass(m_PreColorHandle)
m_ScriptablePass.renderPassEvent = RenderPassEvent.AfterRenderingPostProcessing; {
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() private void Update()
{ {
// 화면 크기 변경 체크 - 매 프레임마다 Screen.width/height 접근은 비용이 있음
// 변경이 자주 일어나지 않으므로 더 효율적인 방법으로 변경
if (MainCam != null && (screenWidth != MainCam.pixelWidth || screenHeight != MainCam.pixelHeight)) if (MainCam != null && (screenWidth != MainCam.pixelWidth || screenHeight != MainCam.pixelHeight))
{ {
screenWidth = MainCam.pixelWidth;
screenHeight = MainCam.pixelHeight;
InitializeTextures(); InitializeTextures();
UpdateShaderScreenSize();
} }
// 설정이 변경되었는지 확인
if (previousOutputMethod != outputMethod || previousKeepAlpha != keepAlpha) if (previousOutputMethod != outputMethod || previousKeepAlpha != keepAlpha)
{ {
previousOutputMethod = outputMethod; previousOutputMethod = outputMethod;
@ -265,87 +404,247 @@ public class RenderStreamOutput : MonoBehaviour
InitializeOutputComponents(); InitializeOutputComponents();
UpdateOutputSources(); UpdateOutputSources();
} }
}
private void UpdateShaderScreenSize() if (previousTargetFrameRate != targetFrameRate)
{
if (ShaderContral != null)
{ {
ShaderContral.SetVector("_Resolution", new Vector4(screenWidth, screenHeight, 0, 0)); previousTargetFrameRate = targetFrameRate;
ApplyFrameRate();
} }
PushMaterialParams();
} }
private void OnDestroy() private void OnDestroy()
{ {
// m_ScriptablePass 정리 (내부 텍스처 참조 해제) m_PreCapturePass = null;
m_ScriptablePass = null; m_ComposePass = null;
m_AlphaOutputPass = null;
if (ShaderCameraTexture != null) ReleaseRT(ref CaptureTexture, ref m_CaptureHandle);
ReleaseRT(ref m_PreColorTexture, ref m_PreColorHandle);
ReleaseRT(ref AlphaOutputTexture, ref m_AlphaOutputHandle);
if (m_AlphaComposeMaterial != null)
{ {
ShaderCameraTexture.Release(); DestroyImmediate(m_AlphaComposeMaterial);
DestroyImmediate(ShaderCameraTexture); m_AlphaComposeMaterial = null;
ShaderCameraTexture = null; // 참조 제거
} }
if (ShaderTexture != null) if (m_AlphaOnlyMaterial != null)
{ {
ShaderTexture.Release(); DestroyImmediate(m_AlphaOnlyMaterial);
DestroyImmediate(ShaderTexture); m_AlphaOnlyMaterial = null;
ShaderTexture = null; // 참조 제거
} }
KillNormalizer();
} }
}
public class AlphaRecodingRenderPass : ScriptableRenderPass // ───────────────────────── Spout/NDI Normalizer (외부 exe) ─────────────────────────
{
private RenderTexture m_ShaderCameraTexture;
private Material m_ShaderContral;
private CustomRenderTexture m_ShaderTexture;
public AlphaRecodingRenderPass(RenderTexture shaderCameraTexture, Material shaderContral, CustomRenderTexture shaderTexture) private string ResolveNormalizerExePath()
{ {
m_ShaderCameraTexture = shaderCameraTexture; if (!string.IsNullOrEmpty(normalizerExeAbsolutePath))
m_ShaderContral = shaderContral; return normalizerExeAbsolutePath;
m_ShaderTexture = shaderTexture; var rel = normalizerExeRelativePath.Replace('/', Path.DirectorySeparatorChar);
return Path.Combine(Application.streamingAssetsPath, rel);
} }
[Obsolete("This method is obsolete. Use the new Render Graph API instead.")] public void LaunchNormalizer()
public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) {
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 try
{ {
if (m_ShaderCameraTexture == null || m_ShaderContral == null || !m_ShaderCameraTexture.IsCreated()) if (m_NormalizerProc != null && !m_NormalizerProc.HasExited)
return;
CommandBuffer cmd = CommandBufferPool.Get("Alpha Recoding Pass");
try
{ {
// Update cameraColorTargetHandle usage m_NormalizerProc.Kill();
var colorTarget = renderingData.cameraData.renderer.cameraColorTargetHandle; m_NormalizerProc.WaitForExit(2000);
// 최종 렌더링 결과를 가져옵니다
RenderTargetIdentifier source = colorTarget;
cmd.Blit(source, m_ShaderCameraTexture);
// ShaderCameraTexture를 ShaderContral의 _MainTex에 할당
m_ShaderContral.SetTexture("_MainTex", m_ShaderCameraTexture);
// ShaderTexture 업데이트 (필요한 경우)
if (m_ShaderTexture != null)
{
m_ShaderTexture.Update();
}
context.ExecuteCommandBuffer(cmd);
}
finally
{
CommandBufferPool.Release(cmd);
} }
} }
catch (System.Exception e) 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>
{ {
Debug.LogError($"Alpha Recoding 렌더 패스 실행 중 오류 발생: {e.Message}"); "--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);
} }
} }
} }

View File

@ -0,0 +1,96 @@
// 후처리 후 RGB + 합성 알파를 출력하는 풀스크린 Blit 셰이더.
//
// 알파 = saturate(prepassAlpha + bloomAlpha)
// prepassAlpha = NiloToon Prepass G 채널 (캐릭터 외곽, AA 포함)
// bloomAlpha = saturate((postLum - preLum) * _BloomAlphaGain) — 후처리가 추가한 빛
//
// _PreColorTex 가 비어있으면 bloomAlpha = 0 (Prepass 만 사용)
// _NiloToonPrepassBufferTex 가 비어있으면 prepassAlpha = 0 (Bloom diff 만 사용)
Shader "Hidden/Streamingle/AlphaCompose"
{
Properties
{
_SourceAlphaGain ("Source Alpha Gain (cameraColor.a)", Range(0, 4)) = 1.0
_BloomAlphaGain ("Bloom Alpha Gain", Range(0, 20)) = 5.0
_PrepassAlphaGain ("Prepass Alpha Gain", Range(0, 4)) = 1.0
_AlphaBlurRadius ("Alpha Blur Radius (texels)", Range(0, 5)) = 1.0
}
SubShader
{
Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" }
Pass
{
ZWrite Off ZTest Always Cull Off
Blend Off
HLSLPROGRAM
#pragma vertex Vert
#pragma fragment Frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Color.hlsl"
#include "Packages/com.unity.render-pipelines.core/Runtime/Utilities/Blit.hlsl"
// _BlitTexture 는 Blit.hlsl 가 이미 선언함 (후처리 후 cameraColor)
TEXTURE2D(_PreColorTex); // 후처리 전 cameraColor
SAMPLER(sampler_PreColorTex);
TEXTURE2D(_NiloToonPrepassBufferTex); // NiloToon 글로벌
SAMPLER(sampler_NiloToonPrepassBufferTex);
float _SourceAlphaGain;
float _BloomAlphaGain;
float _PrepassAlphaGain;
float _AlphaBlurRadius;
// 3x3 가우시안 가중치 (1/4 1/8 1/16) — Prepass G 외곽 부드럽게
half SamplePrepassAlpha(float2 uv)
{
if (_AlphaBlurRadius <= 0.001)
return SAMPLE_TEXTURE2D(_NiloToonPrepassBufferTex,
sampler_NiloToonPrepassBufferTex, uv).g;
float2 t = (1.0 / _ScreenParams.xy) * _AlphaBlurRadius;
half a = 0;
a += SAMPLE_TEXTURE2D(_NiloToonPrepassBufferTex, sampler_NiloToonPrepassBufferTex, uv).g * 0.25;
a += SAMPLE_TEXTURE2D(_NiloToonPrepassBufferTex, sampler_NiloToonPrepassBufferTex, uv + float2( 0, t.y)).g * 0.125;
a += SAMPLE_TEXTURE2D(_NiloToonPrepassBufferTex, sampler_NiloToonPrepassBufferTex, uv + float2( 0, -t.y)).g * 0.125;
a += SAMPLE_TEXTURE2D(_NiloToonPrepassBufferTex, sampler_NiloToonPrepassBufferTex, uv + float2( t.x, 0)).g * 0.125;
a += SAMPLE_TEXTURE2D(_NiloToonPrepassBufferTex, sampler_NiloToonPrepassBufferTex, uv + float2(-t.x, 0)).g * 0.125;
a += SAMPLE_TEXTURE2D(_NiloToonPrepassBufferTex, sampler_NiloToonPrepassBufferTex, uv + float2( t.x, t.y)).g * 0.0625;
a += SAMPLE_TEXTURE2D(_NiloToonPrepassBufferTex, sampler_NiloToonPrepassBufferTex, uv + float2(-t.x, t.y)).g * 0.0625;
a += SAMPLE_TEXTURE2D(_NiloToonPrepassBufferTex, sampler_NiloToonPrepassBufferTex, uv + float2( t.x, -t.y)).g * 0.0625;
a += SAMPLE_TEXTURE2D(_NiloToonPrepassBufferTex, sampler_NiloToonPrepassBufferTex, uv + float2(-t.x, -t.y)).g * 0.0625;
return a;
}
half4 Frag(Varyings input) : SV_Target
{
float2 uv = input.texcoord;
half4 postSample = SAMPLE_TEXTURE2D(_BlitTexture, sampler_LinearClamp, uv);
half3 postRgb = postSample.rgb;
half sourceAlpha = postSample.a * _SourceAlphaGain;
half3 preRgb = SAMPLE_TEXTURE2D(_PreColorTex, sampler_PreColorTex, uv).rgb;
// 후처리가 추가한 luminance (블룸/글로우/플레어 등)
half postLum = Luminance(postRgb);
half preLum = Luminance(preRgb);
half bloomAlpha = saturate((postLum - preLum) * _BloomAlphaGain);
// NiloToon Prepass G 채널 알파 (캐릭터 외곽, 가우시안 블러로 AA)
half prepassAlpha = SamplePrepassAlpha(uv) * _PrepassAlphaGain;
half alpha = saturate(sourceAlpha + prepassAlpha + bloomAlpha);
return half4(postRgb, alpha);
}
ENDHLSL
}
}
Fallback Off
}

View File

@ -0,0 +1,9 @@
fileFormatVersion: 2
guid: 9d778847d83c98846bc8e7c220f88356
ShaderImporter:
externalObjects: {}
defaultTextures: []
nonModifiableTextures: []
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,30 @@
// CaptureTexture 의 알파를 그레이스케일 RGB 로 출력. 알파는 1 고정.
// 검정 = 알파 0, 흰색 = 알파 1
Shader "Hidden/Streamingle/AlphaOnly"
{
SubShader
{
Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" }
Pass
{
ZWrite Off ZTest Always Cull Off
Blend Off
HLSLPROGRAM
#pragma vertex Vert
#pragma fragment Frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
#include "Packages/com.unity.render-pipelines.core/Runtime/Utilities/Blit.hlsl"
half4 Frag(Varyings input) : SV_Target
{
half a = SAMPLE_TEXTURE2D(_BlitTexture, sampler_LinearClamp, input.texcoord).a;
return half4(a, a, a, 1.0);
}
ENDHLSL
}
}
Fallback Off
}

View File

@ -0,0 +1,9 @@
fileFormatVersion: 2
guid: 80cfe82978045b341b4aa611843ae050
ShaderImporter:
externalObjects: {}
defaultTextures: []
nonModifiableTextures: []
userData:
assetBundleName:
assetBundleVariant:

View File

@ -1,67 +1,54 @@
using UnityEngine; using UnityEngine;
using System; using System;
using System.Collections;
using System.IO; using System.IO;
/// <summary> /// <summary>
/// 스크린샷 캡처 관리 (RGB + 알파채널) /// 스크린샷 캡처 관리.
/// RenderStreamOutput 이 매 프레임 갱신하는 CaptureTexture/AlphaOutputTexture 를 PNG 로 저장.
/// captureWidth/Height 가 지정되면 캡처 직전에 카메라 targetTexture 를 임시로 그 해상도로
/// 변경해 한 프레임 고해상도 렌더 후 원복한다.
/// 트레이드오프: 그 한 프레임 동안 Spout/NDI 송신도 같은 해상도가 되어 수신측이 잠깐 깜빡임.
/// </summary> /// </summary>
[Serializable] [Serializable]
public class ScreenshotManager public class ScreenshotManager
{ {
[Tooltip("스크린샷 해상도 (기본: 4K)")] [Tooltip("RenderStreamOutput 컴포넌트 (비어있으면 씬에서 자동 검색)")]
public int screenshotWidth = 3840; public RenderStreamOutput renderStream;
[Tooltip("스크린샷 해상도 (기본: 4K)")] [Tooltip("스크린샷 저장 경로 (비어있으면 프로젝트 루트의 Screenshots/)")]
public int screenshotHeight = 2160;
[Tooltip("스크린샷 저장 경로 (비어있으면 바탕화면)")]
public string screenshotSavePath = ""; public string screenshotSavePath = "";
[Tooltip("파일명 앞에 붙을 접두사")] [Tooltip("파일명 앞에 붙을 접두사")]
public string screenshotFilePrefix = "Screenshot"; public string screenshotFilePrefix = "Screenshot";
[Tooltip("알파 채널 추출용 셰이더")] [Header("고해상도 캡처 (0 이면 카메라 해상도 사용)")]
public Shader alphaShader; [Tooltip("캡처 너비. 0 이면 카메라 해상도 그대로")]
public int captureWidth = 3840;
[Tooltip("캡처 높이. 0 이면 카메라 해상도 그대로")]
public int captureHeight = 2160;
[Tooltip("NiloToon Prepass 버퍼 텍스처 이름")] [Tooltip("(하위 호환) 외부 코드가 .screenshotCamera 로 접근하면 RenderStreamOutput.MainCam 반환")]
public string niloToonPrepassBufferName = "_NiloToonPrepassBufferTex"; public Camera screenshotCamera => renderStream != null ? renderStream.MainCam : null;
[Tooltip("촬영할 카메라 (비어있으면 메인 카메라 사용)")]
public Camera screenshotCamera;
[Tooltip("알파 채널 블러 반경 (0 = 블러 없음, 1.0 = 약한 블러)")]
[Range(0f, 3f)]
public float alphaBlurRadius = 1.0f;
[NonSerialized]
private Material alphaMaterial;
private MonoBehaviour host;
private Action<string> log; private Action<string> log;
private Action<string> logError; private Action<string> logError;
public void Initialize(Action<string> log, Action<string> logError) public void Initialize(MonoBehaviour host, Action<string> log, Action<string> logError)
{ {
this.host = host;
this.log = log; this.log = log;
this.logError = logError; this.logError = logError;
if (screenshotCamera == null) if (renderStream == null)
{ renderStream = UnityEngine.Object.FindFirstObjectByType<RenderStreamOutput>();
screenshotCamera = Camera.main;
}
if (alphaShader == null) if (renderStream == null)
{ logError?.Invoke("RenderStreamOutput 컴포넌트를 찾을 수 없습니다 — 알파 합성 캡처 불가");
alphaShader = Shader.Find("Hidden/AlphaFromNiloToon");
if (alphaShader == null)
{
logError?.Invoke("알파 셰이더를 찾을 수 없습니다: Hidden/AlphaFromNiloToon");
}
}
if (string.IsNullOrEmpty(screenshotSavePath)) if (string.IsNullOrEmpty(screenshotSavePath))
{
screenshotSavePath = Path.Combine(Application.dataPath, "..", "Screenshots"); screenshotSavePath = Path.Combine(Application.dataPath, "..", "Screenshots");
}
if (!Directory.Exists(screenshotSavePath)) if (!Directory.Exists(screenshotSavePath))
{ {
@ -72,113 +59,75 @@ public class ScreenshotManager
public void CaptureScreenshot() public void CaptureScreenshot()
{ {
if (screenshotCamera == null) if (!CanCapture()) return;
{ host.StartCoroutine(CaptureCoroutine(alphaOnly: false));
logError?.Invoke("촬영할 카메라가 설정되지 않았습니다!");
return;
}
string fileName = GenerateFileName("png");
string fullPath = Path.Combine(screenshotSavePath, fileName);
try
{
RenderTexture rt = new RenderTexture(screenshotWidth, screenshotHeight, 24);
RenderTexture currentRT = screenshotCamera.targetTexture;
screenshotCamera.targetTexture = rt;
screenshotCamera.Render();
RenderTexture.active = rt;
Texture2D screenshot = new Texture2D(screenshotWidth, screenshotHeight, TextureFormat.RGB24, false);
screenshot.ReadPixels(new Rect(0, 0, screenshotWidth, screenshotHeight), 0, 0);
screenshot.Apply();
byte[] bytes = screenshot.EncodeToPNG();
File.WriteAllBytes(fullPath, bytes);
screenshotCamera.targetTexture = currentRT;
RenderTexture.active = null;
rt.Release();
UnityEngine.Object.Destroy(rt);
UnityEngine.Object.Destroy(screenshot);
log?.Invoke($"스크린샷 저장 완료: {fullPath}");
}
catch (Exception e)
{
logError?.Invoke($"스크린샷 촬영 실패: {e.Message}");
}
} }
public void CaptureAlphaScreenshot() public void CaptureAlphaScreenshot()
{ {
if (screenshotCamera == null) if (!CanCapture()) return;
host.StartCoroutine(CaptureCoroutine(alphaOnly: true));
}
private bool CanCapture()
{
if (host == null) { logError?.Invoke("ScreenshotManager host(MonoBehaviour) 가 없습니다"); return false; }
if (renderStream == null || renderStream.MainCam == null)
{ {
logError?.Invoke("촬영할 카메라가 설정되지 않았습니다!"); logError?.Invoke("RenderStreamOutput / MainCam 이 준비되지 않았습니다");
return; return false;
} }
return true;
}
if (alphaShader == null) private IEnumerator CaptureCoroutine(bool alphaOnly)
{
var cam = renderStream.MainCam;
bool useTempResolution = captureWidth > 0 && captureHeight > 0;
RenderTexture tempRT = null;
RenderTexture prevTarget = null;
if (useTempResolution)
{ {
logError?.Invoke("알파 셰이더가 설정되지 않았습니다!"); prevTarget = cam.targetTexture;
return; var desc = new RenderTextureDescriptor(captureWidth, captureHeight,
} renderStream.TextureFormat, 24)
string fileName = GenerateFileName("png", "_Alpha");
string fullPath = Path.Combine(screenshotSavePath, fileName);
try
{
RenderTexture rt = new RenderTexture(screenshotWidth, screenshotHeight, 24);
RenderTexture currentRT = screenshotCamera.targetTexture;
screenshotCamera.targetTexture = rt;
screenshotCamera.Render();
Texture niloToonPrepassBuffer = Shader.GetGlobalTexture(niloToonPrepassBufferName);
if (niloToonPrepassBuffer == null)
{ {
logError?.Invoke($"NiloToon Prepass 버퍼를 찾을 수 없습니다: {niloToonPrepassBufferName}"); msaaSamples = Mathf.Max(1, renderStream.AntiAliasing)
screenshotCamera.targetTexture = currentRT; };
UnityEngine.Object.Destroy(rt); tempRT = new RenderTexture(desc);
return; tempRT.Create();
}
if (alphaMaterial == null) cam.targetTexture = tempRT;
{
alphaMaterial = new Material(alphaShader);
}
RenderTexture alphaRT = new RenderTexture(screenshotWidth, screenshotHeight, 0, RenderTextureFormat.ARGB32); // 1프레임 — RenderStreamOutput.Update 가 새 pixelWidth 감지 → InitializeTextures(고해상도)
alphaMaterial.SetTexture("_MainTex", rt); yield return null;
alphaMaterial.SetTexture("_AlphaTex", niloToonPrepassBuffer); // 1프레임 — 고해상도 RT 에 우리 패스 결과 채워짐
alphaMaterial.SetFloat("_BlurRadius", alphaBlurRadius); yield return new WaitForEndOfFrame();
Graphics.Blit(rt, alphaRT, alphaMaterial);
RenderTexture.active = alphaRT;
Texture2D screenshot = new Texture2D(screenshotWidth, screenshotHeight, TextureFormat.RGBA32, false);
screenshot.ReadPixels(new Rect(0, 0, screenshotWidth, screenshotHeight), 0, 0);
screenshot.Apply();
byte[] bytes = screenshot.EncodeToPNG();
File.WriteAllBytes(fullPath, bytes);
screenshotCamera.targetTexture = currentRT;
RenderTexture.active = null;
rt.Release();
UnityEngine.Object.Destroy(rt);
alphaRT.Release();
UnityEngine.Object.Destroy(alphaRT);
UnityEngine.Object.Destroy(screenshot);
log?.Invoke($"알파 스크린샷 저장 완료: {fullPath}");
} }
catch (Exception e)
var srcRT = alphaOnly ? renderStream.AlphaOutputTexture : renderStream.CaptureTexture;
if (srcRT != null)
{ {
logError?.Invoke($"알파 스크린샷 촬영 실패: {e.Message}"); string fileName = GenerateFileName("png", alphaOnly ? "_Alpha" : "");
SaveRtAsPng(srcRT, fileName, alphaOnly ? TextureFormat.RGB24 : TextureFormat.RGBA32);
}
else
{
logError?.Invoke("캡처 RT 가 준비되지 않았습니다");
}
if (useTempResolution)
{
cam.targetTexture = prevTarget;
if (tempRT != null)
{
tempRT.Release();
UnityEngine.Object.Destroy(tempRT);
}
// 한 프레임 더 대기 — RenderStreamOutput.Update 가 원래 해상도로 복귀
yield return null;
} }
} }
@ -195,6 +144,33 @@ public class ScreenshotManager
} }
} }
private void SaveRtAsPng(RenderTexture rt, string fileName, TextureFormat format)
{
string fullPath = Path.Combine(screenshotSavePath, fileName);
RenderTexture prevActive = RenderTexture.active;
Texture2D tex = null;
try
{
RenderTexture.active = rt;
tex = new Texture2D(rt.width, rt.height, format, false);
tex.ReadPixels(new Rect(0, 0, rt.width, rt.height), 0, 0);
tex.Apply();
File.WriteAllBytes(fullPath, tex.EncodeToPNG());
log?.Invoke($"스크린샷 저장 완료: {fullPath} ({rt.width}x{rt.height})");
}
catch (Exception e)
{
logError?.Invoke($"스크린샷 저장 실패 ({fileName}): {e.Message}");
}
finally
{
RenderTexture.active = prevActive;
if (tex != null) UnityEngine.Object.Destroy(tex);
}
}
private string GenerateFileName(string extension, string suffix = "") private string GenerateFileName(string extension, string suffix = "")
{ {
string timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss"); string timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
@ -203,9 +179,6 @@ public class ScreenshotManager
public void Cleanup() public void Cleanup()
{ {
if (alphaMaterial != null) // RenderStreamOutput 이 모든 RT/Material lifecycle 을 관리하므로 별도 정리 없음
{
UnityEngine.Object.Destroy(alphaMaterial);
}
} }
} }

View File

@ -54,7 +54,7 @@ public class SystemController : MonoBehaviour
optiTrack.Initialize(Log, LogError); optiTrack.Initialize(Log, LogError);
facialMotion.Initialize(Log, LogError); facialMotion.Initialize(Log, LogError);
motionRecording.Initialize(optiTrack, Log, LogError); motionRecording.Initialize(optiTrack, Log, LogError);
screenshot.Initialize(Log, LogError); screenshot.Initialize(this, Log, LogError);
clothSimulation.Initialize(Log, LogError); clothSimulation.Initialize(Log, LogError);
avatarHead.Initialize(Log, LogError); avatarHead.Initialize(Log, LogError);
retargetingRemote.Initialize(Log, LogError); retargetingRemote.Initialize(Log, LogError);

View File

@ -29,14 +29,12 @@
<!-- 스크린샷 --> <!-- 스크린샷 -->
<ui:VisualElement class="section"> <ui:VisualElement class="section">
<ui:Foldout text="스크린샷" value="true" class="section-foldout"> <ui:Foldout text="스크린샷" value="true" class="section-foldout">
<uie:PropertyField binding-path="screenshot.screenshotCamera" label="카메라" tooltip="촬영할 카메라 (비어있으면 메인 카메라)"/> <ui:HelpBox message-type="Info" text="알파 합성 결과는 RenderStreamOutput 컴포넌트의 CaptureTexture / AlphaOutputTexture 를 그대로 PNG 로 저장합니다.&#10;해상도/셰이더/블러 설정은 RenderStreamOutput 에서 조절."/>
<uie:PropertyField binding-path="screenshot.screenshotWidth" label="너비"/> <uie:PropertyField binding-path="screenshot.renderStream" label="RenderStreamOutput" tooltip="비어있으면 씬에서 자동 검색"/>
<uie:PropertyField binding-path="screenshot.screenshotHeight" label="높이"/>
<uie:PropertyField binding-path="screenshot.screenshotSavePath" label="저장 경로" tooltip="비어있으면 프로젝트 루트/Screenshots"/> <uie:PropertyField binding-path="screenshot.screenshotSavePath" label="저장 경로" tooltip="비어있으면 프로젝트 루트/Screenshots"/>
<uie:PropertyField binding-path="screenshot.screenshotFilePrefix" label="파일 접두사"/> <uie:PropertyField binding-path="screenshot.screenshotFilePrefix" label="파일 접두사"/>
<uie:PropertyField binding-path="screenshot.alphaShader" label="알파 셰이더"/> <uie:PropertyField binding-path="screenshot.captureWidth" label="캡처 너비" tooltip="0 이면 카메라 해상도 사용. &gt;0 이면 한 프레임 동안 카메라를 그 해상도로 변경 (Spout 도 잠깐 같은 해상도가 됨)"/>
<uie:PropertyField binding-path="screenshot.niloToonPrepassBufferName" label="NiloToon 버퍼 이름"/> <uie:PropertyField binding-path="screenshot.captureHeight" label="캡처 높이" tooltip="0 이면 카메라 해상도 사용"/>
<uie:PropertyField binding-path="screenshot.alphaBlurRadius" label="알파 블러 반경"/>
</ui:Foldout> </ui:Foldout>
</ui:VisualElement> </ui:VisualElement>

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 125f274339c271348b3ac9e905d1b197
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: bd5ef184cfe270f44b04b8d20b5362e5
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

BIN
Assets/StreamingAssets/SpoutNdiNormalizer/README.md (Stored with Git LFS) Normal file

Binary file not shown.

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 556dd080c71356944abc6a238e97d8cc
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: c169e09324be3404dbef88e305616fd4
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant: