Add: 카메라 프리뷰 시스템 (웹 대시보드 3번째 탭)
CameraManager의 CinemachineCamera 프리셋을 라운드 로빈으로 렌더링하여 웹 대시보드에서 실시간 JPEG 프리뷰를 확인할 수 있는 시스템 추가. - StreamDeckServerManager에 프리뷰 렌더링 로직 통합 (카메라 풀, AsyncGPUReadback, 바이너리 WebSocket) - 대시보드 프리뷰 탭: 해상도/품질/갱신간격/그리드열 커스텀, 클릭→카메라 전환, 더블클릭→풀스크린 - 구독 기반 전송 + Page Visibility API로 불필요한 렌더링 방지 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4a49ecd772
commit
ea3b97b822
BIN
Assets/Resources/StreamingleDashboard/dashboard_script.txt
(Stored with Git LFS)
BIN
Assets/Resources/StreamingleDashboard/dashboard_script.txt
(Stored with Git LFS)
Binary file not shown.
BIN
Assets/Resources/StreamingleDashboard/dashboard_style.txt
(Stored with Git LFS)
BIN
Assets/Resources/StreamingleDashboard/dashboard_style.txt
(Stored with Git LFS)
Binary file not shown.
BIN
Assets/Resources/StreamingleDashboard/dashboard_template.txt
(Stored with Git LFS)
BIN
Assets/Resources/StreamingleDashboard/dashboard_template.txt
(Stored with Git LFS)
Binary file not shown.
@ -1,4 +1,7 @@
|
|||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
|
using UnityEngine.Rendering;
|
||||||
|
using UnityEngine.Rendering.Universal;
|
||||||
|
using Unity.Collections;
|
||||||
using WebSocketSharp.Server;
|
using WebSocketSharp.Server;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
@ -14,6 +17,17 @@ public class StreamDeckServerManager : MonoBehaviour
|
|||||||
public int dashboardPort = 64210;
|
public int dashboardPort = 64210;
|
||||||
public bool enableDashboard = true;
|
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 WebSocketServer server;
|
||||||
private StreamingleDashboardServer dashboardServer;
|
private StreamingleDashboardServer dashboardServer;
|
||||||
private List<StreamDeckService> connectedClients = new List<StreamDeckService>();
|
private List<StreamDeckService> connectedClients = new List<StreamDeckService>();
|
||||||
@ -29,6 +43,16 @@ public class StreamDeckServerManager : MonoBehaviour
|
|||||||
private readonly Queue<System.Action> mainThreadActions = new Queue<System.Action>();
|
private readonly Queue<System.Action> mainThreadActions = new Queue<System.Action>();
|
||||||
private readonly object lockObject = new object();
|
private readonly object lockObject = new object();
|
||||||
|
|
||||||
|
// 카메라 프리뷰 내부 상태
|
||||||
|
private readonly List<Camera> previewCameraPool = new List<Camera>();
|
||||||
|
private RenderTexture previewRT;
|
||||||
|
private Texture2D previewReadbackTexture;
|
||||||
|
private int currentPreviewIndex = 0;
|
||||||
|
private int previewFrameCounter = 0;
|
||||||
|
private readonly Dictionary<Camera, int> previewPendingCaptures = new Dictionary<Camera, int>();
|
||||||
|
private readonly HashSet<StreamDeckService> previewSubscribers = new HashSet<StreamDeckService>();
|
||||||
|
private readonly object previewSubscriberLock = new object();
|
||||||
|
|
||||||
public int ConnectedClientCount => connectedClients.Count;
|
public int ConnectedClientCount => connectedClients.Count;
|
||||||
|
|
||||||
void Awake()
|
void Awake()
|
||||||
@ -61,6 +85,8 @@ public class StreamDeckServerManager : MonoBehaviour
|
|||||||
if (systemController == null)
|
if (systemController == null)
|
||||||
Debug.LogWarning("[StreamDeckServerManager] SystemController를 찾을 수 없습니다. 시스템 컨트롤 기능이 비활성화됩니다.");
|
Debug.LogWarning("[StreamDeckServerManager] SystemController를 찾을 수 없습니다. 시스템 컨트롤 기능이 비활성화됩니다.");
|
||||||
|
|
||||||
|
InitializePreview();
|
||||||
|
|
||||||
StartServer();
|
StartServer();
|
||||||
StartDashboardServer();
|
StartDashboardServer();
|
||||||
}
|
}
|
||||||
@ -84,6 +110,16 @@ public class StreamDeckServerManager : MonoBehaviour
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void LateUpdate()
|
||||||
|
{
|
||||||
|
PreviewLateUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
void OnDestroy()
|
||||||
|
{
|
||||||
|
CleanupPreview();
|
||||||
|
}
|
||||||
|
|
||||||
void OnApplicationQuit()
|
void OnApplicationQuit()
|
||||||
{
|
{
|
||||||
StopServer();
|
StopServer();
|
||||||
@ -168,6 +204,7 @@ public class StreamDeckServerManager : MonoBehaviour
|
|||||||
public void OnClientDisconnected(StreamDeckService service)
|
public void OnClientDisconnected(StreamDeckService service)
|
||||||
{
|
{
|
||||||
connectedClients.Remove(service);
|
connectedClients.Remove(service);
|
||||||
|
PreviewUnsubscribe(service);
|
||||||
Debug.Log($"[StreamDeckServerManager] 클라이언트 연결 해제됨. 총 연결: {connectedClients.Count}");
|
Debug.Log($"[StreamDeckServerManager] 클라이언트 연결 해제됨. 총 연결: {connectedClients.Count}");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -394,6 +431,17 @@ public class StreamDeckServerManager : MonoBehaviour
|
|||||||
HandleSystemCommand(message);
|
HandleSystemCommand(message);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
// 카메라 프리뷰
|
||||||
|
case "subscribe_preview":
|
||||||
|
PreviewSubscribe(service);
|
||||||
|
break;
|
||||||
|
case "unsubscribe_preview":
|
||||||
|
PreviewUnsubscribe(service);
|
||||||
|
break;
|
||||||
|
case "update_preview_settings":
|
||||||
|
HandleUpdatePreviewSettings(message);
|
||||||
|
break;
|
||||||
|
|
||||||
case "test":
|
case "test":
|
||||||
SendJson(service, new
|
SendJson(service, new
|
||||||
{
|
{
|
||||||
@ -639,6 +687,302 @@ public class StreamDeckServerManager : MonoBehaviour
|
|||||||
}
|
}
|
||||||
#endregion
|
#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<Camera>();
|
||||||
|
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<byte> data = request.GetData<byte>();
|
||||||
|
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<string, object> 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<string, object> 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)
|
#region System Status (Dashboard)
|
||||||
private void HandleGetSystemStatus(StreamDeckService service)
|
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)
|
protected override void OnClose(WebSocketSharp.CloseEventArgs e)
|
||||||
{
|
{
|
||||||
if (serverManager != null)
|
if (serverManager != null)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user