diff --git a/Assets/Resources/StreamingleDashboard/dashboard_script.txt b/Assets/Resources/StreamingleDashboard/dashboard_script.txt index f66e5549..9e96bda7 100644 --- a/Assets/Resources/StreamingleDashboard/dashboard_script.txt +++ b/Assets/Resources/StreamingleDashboard/dashboard_script.txt @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:550d46af1ac1f6144a3e35ec34f9a19c28b4457945bf04c0e9c83b51684830c9 -size 54959 +oid sha256:697e059bd011a2f9550c44ed20f8fe4a52e2c144b05404db9808d8880229a907 +size 64731 diff --git a/Assets/Resources/StreamingleDashboard/dashboard_style.txt b/Assets/Resources/StreamingleDashboard/dashboard_style.txt index cc24828f..f445cf81 100644 --- a/Assets/Resources/StreamingleDashboard/dashboard_style.txt +++ b/Assets/Resources/StreamingleDashboard/dashboard_style.txt @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b2563d55fd821edfa1d63a40726458bd24d468d00cbcd1201d7ca5104cdff933 -size 20860 +oid sha256:4d1e55f127ed375ad926cd02e1ccd12f8398fbe037708898fa134346d605cdad +size 23863 diff --git a/Assets/Resources/StreamingleDashboard/dashboard_template.txt b/Assets/Resources/StreamingleDashboard/dashboard_template.txt index 8c7b48a4..d19a6438 100644 --- a/Assets/Resources/StreamingleDashboard/dashboard_template.txt +++ b/Assets/Resources/StreamingleDashboard/dashboard_template.txt @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b2dcb365782d643a8343a685fc5efaeb8a536a764deab3659d82e115a6a17437 -size 4093 +oid sha256:c9184c38503fcafc9761e36b345b13e97515e71eae6115a0bd027e5efd390868 +size 6163 diff --git a/Assets/Scripts/Streamdeck/StreamDeckServerManager.cs b/Assets/Scripts/Streamdeck/StreamDeckServerManager.cs index 9a5b3df0..b2f361bc 100644 --- a/Assets/Scripts/Streamdeck/StreamDeckServerManager.cs +++ b/Assets/Scripts/Streamdeck/StreamDeckServerManager.cs @@ -1,4 +1,7 @@ using UnityEngine; +using UnityEngine.Rendering; +using UnityEngine.Rendering.Universal; +using Unity.Collections; using WebSocketSharp.Server; using System.Collections.Generic; using Newtonsoft.Json; @@ -14,6 +17,17 @@ public class StreamDeckServerManager : MonoBehaviour public int dashboardPort = 64210; public bool enableDashboard = true; + [Header("카메라 프리뷰 설정")] + public int previewWidth = 320; + public int previewHeight = 180; + [Range(10, 100)] + public int jpegQuality = 50; + [Range(1, 5)] + public int camerasPerFrame = 1; + [Range(1, 10)] + [Tooltip("N프레임마다 1회 렌더링. 높을수록 GPU 부하 감소, 프리뷰 갱신 느려짐.")] + public int renderInterval = 3; + private WebSocketServer server; private StreamingleDashboardServer dashboardServer; private List connectedClients = new List(); @@ -29,6 +43,16 @@ public class StreamDeckServerManager : MonoBehaviour private readonly Queue mainThreadActions = new Queue(); private readonly object lockObject = new object(); + // 카메라 프리뷰 내부 상태 + private readonly List previewCameraPool = new List(); + private RenderTexture previewRT; + private Texture2D previewReadbackTexture; + private int currentPreviewIndex = 0; + private int previewFrameCounter = 0; + private readonly Dictionary previewPendingCaptures = new Dictionary(); + private readonly HashSet previewSubscribers = new HashSet(); + private readonly object previewSubscriberLock = new object(); + public int ConnectedClientCount => connectedClients.Count; void Awake() @@ -61,6 +85,8 @@ public class StreamDeckServerManager : MonoBehaviour if (systemController == null) Debug.LogWarning("[StreamDeckServerManager] SystemController를 찾을 수 없습니다. 시스템 컨트롤 기능이 비활성화됩니다."); + InitializePreview(); + StartServer(); StartDashboardServer(); } @@ -84,6 +110,16 @@ public class StreamDeckServerManager : MonoBehaviour } } + void LateUpdate() + { + PreviewLateUpdate(); + } + + void OnDestroy() + { + CleanupPreview(); + } + void OnApplicationQuit() { StopServer(); @@ -168,6 +204,7 @@ public class StreamDeckServerManager : MonoBehaviour public void OnClientDisconnected(StreamDeckService service) { connectedClients.Remove(service); + PreviewUnsubscribe(service); Debug.Log($"[StreamDeckServerManager] 클라이언트 연결 해제됨. 총 연결: {connectedClients.Count}"); } @@ -394,6 +431,17 @@ public class StreamDeckServerManager : MonoBehaviour HandleSystemCommand(message); break; + // 카메라 프리뷰 + case "subscribe_preview": + PreviewSubscribe(service); + break; + case "unsubscribe_preview": + PreviewUnsubscribe(service); + break; + case "update_preview_settings": + HandleUpdatePreviewSettings(message); + break; + case "test": SendJson(service, new { @@ -639,6 +687,302 @@ public class StreamDeckServerManager : MonoBehaviour } #endregion + #region Camera Preview + private void InitializePreview() + { + if (cameraManager == null) return; + + CreatePreviewRenderTextures(); + EnsurePreviewCameraPoolSize(); + + RenderPipelineManager.endCameraRendering += OnEndPreviewCameraRendering; + } + + private Camera CreatePreviewCamera(int poolIndex) + { + Camera mainCamera = Camera.main; + if (mainCamera == null) + { + Debug.LogError("[StreamDeckServerManager] 프리뷰: 메인 카메라를 찾을 수 없습니다!"); + return null; + } + + GameObject camObj = new GameObject($"CameraPreview_{poolIndex}"); + camObj.transform.SetParent(transform); + Camera cam = camObj.AddComponent(); + cam.CopyFrom(mainCamera); + cam.targetTexture = previewRT; + cam.depth = mainCamera.depth - 10 - poolIndex; + cam.enabled = false; + + var previewCameraData = cam.GetUniversalAdditionalCameraData(); + if (previewCameraData != null) + { + previewCameraData.renderPostProcessing = false; + previewCameraData.antialiasing = AntialiasingMode.None; + previewCameraData.renderShadows = false; + previewCameraData.dithering = false; + previewCameraData.stopNaN = false; + } + + return cam; + } + + private void EnsurePreviewCameraPoolSize() + { + while (previewCameraPool.Count < camerasPerFrame) + { + Camera cam = CreatePreviewCamera(previewCameraPool.Count); + if (cam != null) + previewCameraPool.Add(cam); + else + break; + } + + for (int i = camerasPerFrame; i < previewCameraPool.Count; i++) + { + previewCameraPool[i].enabled = false; + } + } + + private void CreatePreviewRenderTextures() + { + if (previewRT != null) + { + previewRT.Release(); + Destroy(previewRT); + } + if (previewReadbackTexture != null) + { + Destroy(previewReadbackTexture); + } + + previewRT = new RenderTexture(previewWidth, previewHeight, 24, RenderTextureFormat.ARGB32); + previewRT.name = "CameraPreviewRT"; + previewRT.Create(); + + previewReadbackTexture = new Texture2D(previewWidth, previewHeight, TextureFormat.RGB24, false); + + foreach (var cam in previewCameraPool) + { + if (cam != null) + cam.targetTexture = previewRT; + } + } + + private void PreviewLateUpdate() + { + if (cameraManager == null) return; + + int subscriberCount; + lock (previewSubscriberLock) + { + subscriberCount = previewSubscribers.Count; + } + + if (subscriberCount == 0) + { + for (int i = 0; i < previewCameraPool.Count; i++) + previewCameraPool[i].enabled = false; + return; + } + + previewFrameCounter++; + if (previewFrameCounter % renderInterval != 0) + return; + + var presets = cameraManager.cameraPresets; + if (presets == null || presets.Count == 0) return; + + EnsurePreviewCameraPoolSize(); + previewPendingCaptures.Clear(); + + int toRender = Mathf.Min(camerasPerFrame, Mathf.Min(presets.Count, previewCameraPool.Count)); + + int rendered = 0; + int scanned = 0; + while (rendered < toRender && scanned < presets.Count) + { + currentPreviewIndex = currentPreviewIndex % presets.Count; + var preset = presets[currentPreviewIndex]; + currentPreviewIndex++; + scanned++; + + if (preset?.virtualCamera == null) + continue; + + Camera cam = previewCameraPool[rendered]; + int presetIdx = currentPreviewIndex - 1; + + Camera mainCam = Camera.main; + if (mainCam != null && presetIdx == cameraManager.GetCameraListData().current_index) + { + cam.transform.SetPositionAndRotation( + mainCam.transform.position, + mainCam.transform.rotation); + cam.fieldOfView = mainCam.fieldOfView; + } + else + { + cam.transform.SetPositionAndRotation( + preset.virtualCamera.transform.position, + preset.virtualCamera.transform.rotation); + cam.fieldOfView = preset.virtualCamera.Lens.FieldOfView; + } + cam.enabled = true; + + previewPendingCaptures[cam] = presetIdx; + rendered++; + } + + for (int i = rendered; i < previewCameraPool.Count; i++) + { + previewCameraPool[i].enabled = false; + } + } + + private void OnEndPreviewCameraRendering(ScriptableRenderContext context, Camera camera) + { + if (!previewPendingCaptures.TryGetValue(camera, out int presetIndex)) + return; + + var presets = cameraManager.cameraPresets; + if (presetIndex < 0 || presetIndex >= presets.Count) return; + + camera.enabled = false; + previewPendingCaptures.Remove(camera); + + string presetName = presets[presetIndex].presetName; + int capturedPresetIndex = presetIndex; + + AsyncGPUReadback.Request(camera.targetTexture, 0, TextureFormat.RGB24, (request) => + { + if (request.hasError || this == null) return; + + NativeArray data = request.GetData(); + previewReadbackTexture.LoadRawTextureData(data); + previewReadbackTexture.Apply(false); + + byte[] jpg = previewReadbackTexture.EncodeToJPG(jpegQuality); + BroadcastPreviewBinary(capturedPresetIndex, presetName, jpg); + }); + } + + private void BroadcastPreviewBinary(int index, string name, byte[] jpegData) + { + int currentIndex = cameraManager.GetCameraListData().current_index; + bool isActive = (currentIndex == index); + int totalCameras = cameraManager.cameraPresets.Count; + + byte[] nameBytes = System.Text.Encoding.UTF8.GetBytes(name); + byte nameLen = (byte)Math.Min(nameBytes.Length, 255); + + byte[] packet = new byte[4 + nameLen + jpegData.Length]; + packet[0] = (byte)index; + packet[1] = (byte)totalCameras; + packet[2] = (byte)(isActive ? 1 : 0); + packet[3] = nameLen; + Buffer.BlockCopy(nameBytes, 0, packet, 4, nameLen); + Buffer.BlockCopy(jpegData, 0, packet, 4 + nameLen, jpegData.Length); + + StreamDeckService[] clients; + lock (previewSubscriberLock) + { + clients = previewSubscribers.ToArray(); + } + + foreach (var client in clients) + { + try + { + client.SendBinary(packet); + } + catch (Exception) + { + lock (previewSubscriberLock) + { + previewSubscribers.Remove(client); + } + } + } + } + + private void PreviewSubscribe(StreamDeckService client) + { + lock (previewSubscriberLock) + { + previewSubscribers.Add(client); + } + Debug.Log($"[StreamDeckServerManager] 프리뷰 구독 추가. 총 구독자: {previewSubscribers.Count}"); + } + + private void PreviewUnsubscribe(StreamDeckService client) + { + lock (previewSubscriberLock) + { + previewSubscribers.Remove(client); + } + Debug.Log($"[StreamDeckServerManager] 프리뷰 구독 해제. 총 구독자: {previewSubscribers.Count}"); + } + + private void HandleUpdatePreviewSettings(Dictionary message) + { + var dataObject = message.ContainsKey("data") ? message["data"] : null; + if (dataObject == null) return; + + int width = previewWidth, height = previewHeight, quality = jpegQuality, interval = renderInterval; + + if (dataObject is Newtonsoft.Json.Linq.JObject jObject) + { + if (jObject.ContainsKey("width")) int.TryParse(jObject["width"]?.ToString(), out width); + if (jObject.ContainsKey("height")) int.TryParse(jObject["height"]?.ToString(), out height); + if (jObject.ContainsKey("quality")) int.TryParse(jObject["quality"]?.ToString(), out quality); + if (jObject.ContainsKey("render_interval")) int.TryParse(jObject["render_interval"]?.ToString(), out interval); + } + else if (dataObject is Dictionary data) + { + if (data.ContainsKey("width")) int.TryParse(data["width"]?.ToString(), out width); + if (data.ContainsKey("height")) int.TryParse(data["height"]?.ToString(), out height); + if (data.ContainsKey("quality")) int.TryParse(data["quality"]?.ToString(), out quality); + if (data.ContainsKey("render_interval")) int.TryParse(data["render_interval"]?.ToString(), out interval); + } + + bool resChanged = (width != previewWidth || height != previewHeight); + previewWidth = Mathf.Clamp(width, 160, 960); + previewHeight = Mathf.Clamp(height, 90, 540); + jpegQuality = Mathf.Clamp(quality, 10, 100); + renderInterval = Mathf.Clamp(interval, 1, 10); + + if (resChanged) + { + CreatePreviewRenderTextures(); + } + + Debug.Log($"[StreamDeckServerManager] 프리뷰 설정 변경: {previewWidth}x{previewHeight}, 품질={jpegQuality}, 렌더링간격={renderInterval}"); + } + + private void CleanupPreview() + { + RenderPipelineManager.endCameraRendering -= OnEndPreviewCameraRendering; + + if (previewRT != null) + { + previewRT.Release(); + Destroy(previewRT); + } + if (previewReadbackTexture != null) + { + Destroy(previewReadbackTexture); + } + foreach (var cam in previewCameraPool) + { + if (cam != null) + Destroy(cam.gameObject); + } + previewCameraPool.Clear(); + } + #endregion + #region System Status (Dashboard) private void HandleGetSystemStatus(StreamDeckService service) { @@ -728,6 +1072,18 @@ public class StreamDeckService : WebSocketBehavior } } + public void SendBinary(byte[] data) + { + try + { + Send(data); + } + catch (Exception ex) + { + Debug.LogError($"[StreamDeckService] 바이너리 전송 실패: {ex.Message}"); + } + } + protected override void OnClose(WebSocketSharp.CloseEventArgs e) { if (serverManager != null)