From 67dda7f9ccb069547c3428b6432ce178c19ba70c Mon Sep 17 00:00:00 2001 From: adrd <88746311+toxifly@users.noreply.github.com> Date: Sun, 25 Jan 2026 06:07:08 +0800 Subject: [PATCH] fix: improve manage_scene screenshot capture (#600) * fix: improve manage_scene screenshot capture * fix: address PR review feedback for screenshot capture - Gate pre-2022 ScreenCapture fallback warning to log only once - Downgrade warning to Debug.Log to reduce log noise - Refactor path-building into shared PrepareCaptureResult() helper - Add conditional logging to catch blocks in BestEffortPrepareGameViewForScreenshot - Add timeout/failure logging to ScheduleAssetImportWhenFileExists - Fix grammar in README-DEV.md * fix(unity): resolve screenshot import callback type + FindObjectsOfType deprecation * chore: bump version to 9.2.0 * Update ManageScene.cs * Update ScreenshotUtility.cs * Update error logging in ManageScene.cs --------- Co-authored-by: Marcus Sanatan Co-authored-by: GitHub Actions Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> --- .../GameObjects/ManageGameObjectCommon.cs | 7 + MCPForUnity/Editor/Tools/ManageScene.cs | 200 +++++++++++------- .../Runtime/Helpers/ScreenshotUtility.cs | 159 ++++++++------ docs/development/README-DEV-zh.md | 6 + docs/development/README-DEV.md | 8 +- 5 files changed, 243 insertions(+), 137 deletions(-) diff --git a/MCPForUnity/Editor/Tools/GameObjects/ManageGameObjectCommon.cs b/MCPForUnity/Editor/Tools/GameObjects/ManageGameObjectCommon.cs index e686145..8d8a36e 100644 --- a/MCPForUnity/Editor/Tools/GameObjects/ManageGameObjectCommon.cs +++ b/MCPForUnity/Editor/Tools/GameObjects/ManageGameObjectCommon.cs @@ -154,9 +154,16 @@ namespace MCPForUnity.Editor.Tools.GameObjects } else { +#if UNITY_2023_1_OR_NEWER + var inactive = searchInactive ? FindObjectsInactive.Include : FindObjectsInactive.Exclude; + searchPoolComp = UnityEngine.Object.FindObjectsByType(componentType, inactive, FindObjectsSortMode.None) + .Cast() + .Select(c => c.gameObject); +#else searchPoolComp = UnityEngine.Object.FindObjectsOfType(componentType, searchInactive) .Cast() .Select(c => c.gameObject); +#endif } results.AddRange(searchPoolComp.Where(go => go != null)); } diff --git a/MCPForUnity/Editor/Tools/ManageScene.cs b/MCPForUnity/Editor/Tools/ManageScene.cs index e58c094..2f9e397 100644 --- a/MCPForUnity/Editor/Tools/ManageScene.cs +++ b/MCPForUnity/Editor/Tools/ManageScene.cs @@ -384,34 +384,27 @@ namespace MCPForUnity.Editor.Tools try { int resolvedSuperSize = (superSize.HasValue && superSize.Value > 0) ? superSize.Value : 1; - ScreenshotCaptureResult result; - if (Application.isPlaying) + // Best-effort: ensure Game View exists and repaints before capture. + if (!Application.isBatchMode) { - result = ScreenshotUtility.CaptureToAssetsFolder(fileName, resolvedSuperSize, ensureUniqueFileName: true); + EnsureGameView(); + } + + ScreenshotCaptureResult result = ScreenshotUtility.CaptureToAssetsFolder(fileName, resolvedSuperSize, ensureUniqueFileName: true); + + // ScreenCapture.CaptureScreenshot is async. Import after the file actually hits disk. + if (result.IsAsync) + { + ScheduleAssetImportWhenFileExists(result.AssetsRelativePath, result.FullPath, timeoutSeconds: 30.0); } else { - // Edit Mode path: render from the best-guess camera using RenderTexture. - Camera cam = Camera.main; - if (cam == null) - { - // Use FindObjectsOfType for Unity 2021 compatibility - var cams = UnityEngine.Object.FindObjectsOfType(); - cam = cams.FirstOrDefault(); - } - - if (cam == null) - { - return new ErrorResponse("No camera found to capture screenshot in Edit Mode."); - } - - result = ScreenshotUtility.CaptureFromCameraToAssetsFolder(cam, fileName, resolvedSuperSize, ensureUniqueFileName: true); + AssetDatabase.ImportAsset(result.AssetsRelativePath, ImportAssetOptions.ForceSynchronousImport); } - AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); - - string message = $"Screenshot captured to '{result.AssetsRelativePath}' (full: {result.FullPath})."; + string verb = result.IsAsync ? "Screenshot requested" : "Screenshot captured"; + string message = $"{verb} to '{result.AssetsRelativePath}' (full: {result.FullPath})."; return new SuccessResponse( message, @@ -420,6 +413,7 @@ namespace MCPForUnity.Editor.Tools path = result.AssetsRelativePath, fullPath = result.FullPath, superSize = result.SuperSize, + isAsync = result.IsAsync, } ); } @@ -429,6 +423,111 @@ namespace MCPForUnity.Editor.Tools } } + private static void EnsureGameView() + { + try + { + // Ensure a Game View exists and has a chance to repaint before capture. + try + { + if (!EditorApplication.ExecuteMenuItem("Window/General/Game")) + { + // Some Unity versions expose hotkey suffixes in menu paths. + EditorApplication.ExecuteMenuItem("Window/General/Game %2"); + } + } + catch (Exception e) + { + try { McpLog.Debug($"[ManageScene] screenshot: failed to open Game View via menu item: {e.Message}"); } catch { } + } + + try + { + var gameViewType = Type.GetType("UnityEditor.GameView,UnityEditor"); + if (gameViewType != null) + { + var window = EditorWindow.GetWindow(gameViewType); + window?.Repaint(); + } + } + catch (Exception e) + { + try { McpLog.Debug($"[ManageScene] screenshot: failed to repaint Game View: {e.Message}"); } catch { } + } + + try { SceneView.RepaintAll(); } + catch (Exception e) + { + try { McpLog.Debug($"[ManageScene] screenshot: failed to repaint Scene View: {e.Message}"); } catch { } + } + + try { EditorApplication.QueuePlayerLoopUpdate(); } + catch (Exception e) + { + try { McpLog.Debug($"[ManageScene] screenshot: failed to queue player loop update: {e.Message}"); } catch { } + } + } + catch (Exception e) + { + try { McpLog.Debug($"[ManageScene] screenshot: EnsureGameView failed: {e.Message}"); } catch { } + } + } + + private static void ScheduleAssetImportWhenFileExists(string assetsRelativePath, string fullPath, double timeoutSeconds) + { + if (string.IsNullOrWhiteSpace(assetsRelativePath) || string.IsNullOrWhiteSpace(fullPath)) + { + McpLog.Warn("[ManageScene] ScheduleAssetImportWhenFileExists: invalid paths provided, skipping import scheduling."); + return; + } + + double start = EditorApplication.timeSinceStartup; + int failureCount = 0; + bool hasSeenFile = false; + const int maxLoggedFailures = 3; + EditorApplication.CallbackFunction tick = null; + tick = () => + { + try + { + if (File.Exists(fullPath)) + { + hasSeenFile = true; + + AssetDatabase.ImportAsset(assetsRelativePath, ImportAssetOptions.ForceSynchronousImport); + McpLog.Debug($"[ManageScene] Imported asset at '{assetsRelativePath}'."); + EditorApplication.update -= tick; + return; + } + } + catch (Exception e) + { + failureCount++; + + if (failureCount <= maxLoggedFailures) + { + McpLog.Warn($"[ManageScene] Exception while importing asset '{assetsRelativePath}' from '{fullPath}' (attempt {failureCount}): {e}"); + } + } + + if (EditorApplication.timeSinceStartup - start > timeoutSeconds) + { + if (!hasSeenFile) + { + McpLog.Warn($"[ManageScene] Timed out waiting for file '{fullPath}' (asset: '{assetsRelativePath}') after {timeoutSeconds:F1} seconds. The asset was not imported."); + } + else + { + McpLog.Warn($"[ManageScene] Timed out importing asset '{assetsRelativePath}' from '{fullPath}' after {timeoutSeconds:F1} seconds. The file existed but the asset was not imported."); + } + + EditorApplication.update -= tick; + } + }; + + EditorApplication.update += tick; + } + private static object GetActiveSceneInfo() { try @@ -668,7 +767,10 @@ namespace MCPForUnity.Editor.Tools } } } - catch { } + catch (Exception ex) + { + McpLog.Debug($"[ManageScene] Failed to enumerate components for '{go.name}': {ex.Message}"); + } var d = new Dictionary { @@ -684,7 +786,7 @@ namespace MCPForUnity.Editor.Tools { "childrenTruncated", childrenTruncated }, { "childrenCursor", childCount > 0 ? "0" : null }, { "childrenPageSizeDefault", maxChildrenPerNode }, - { "componentTypes", componentTypes }, // NEW: Lightweight component type list + { "componentTypes", componentTypes }, }; if (includeTransform && go.transform != null) @@ -721,57 +823,5 @@ namespace MCPForUnity.Editor.Tools } } - /// - /// Recursively builds a data representation of a GameObject and its children. - /// - private static object GetGameObjectDataRecursive(GameObject go) - { - if (go == null) - return null; - - var childrenData = new List(); - foreach (Transform child in go.transform) - { - childrenData.Add(GetGameObjectDataRecursive(child.gameObject)); - } - - var gameObjectData = new Dictionary - { - { "name", go.name }, - { "activeSelf", go.activeSelf }, - { "activeInHierarchy", go.activeInHierarchy }, - { "tag", go.tag }, - { "layer", go.layer }, - { "isStatic", go.isStatic }, - { "instanceID", go.GetInstanceID() }, // Useful unique identifier - { - "transform", - new - { - position = new - { - x = go.transform.localPosition.x, - y = go.transform.localPosition.y, - z = go.transform.localPosition.z, - }, - rotation = new - { - x = go.transform.localRotation.eulerAngles.x, - y = go.transform.localRotation.eulerAngles.y, - z = go.transform.localRotation.eulerAngles.z, - }, // Euler for simplicity - scale = new - { - x = go.transform.localScale.x, - y = go.transform.localScale.y, - z = go.transform.localScale.z, - }, - } - }, - { "children", childrenData }, - }; - - return gameObjectData; - } } } diff --git a/MCPForUnity/Runtime/Helpers/ScreenshotUtility.cs b/MCPForUnity/Runtime/Helpers/ScreenshotUtility.cs index eb71da6..4a001b0 100644 --- a/MCPForUnity/Runtime/Helpers/ScreenshotUtility.cs +++ b/MCPForUnity/Runtime/Helpers/ScreenshotUtility.cs @@ -9,58 +9,70 @@ namespace MCPForUnity.Runtime.Helpers public readonly struct ScreenshotCaptureResult { public ScreenshotCaptureResult(string fullPath, string assetsRelativePath, int superSize) + : this(fullPath, assetsRelativePath, superSize, isAsync: false) + { + } + + public ScreenshotCaptureResult(string fullPath, string assetsRelativePath, int superSize, bool isAsync) { FullPath = fullPath; AssetsRelativePath = assetsRelativePath; SuperSize = superSize; + IsAsync = isAsync; } public string FullPath { get; } public string AssetsRelativePath { get; } public int SuperSize { get; } + public bool IsAsync { get; } } public static class ScreenshotUtility { private const string ScreenshotsFolderName = "Screenshots"; + private static bool s_loggedLegacyScreenCaptureFallback; + + private static Camera FindAvailableCamera() + { + var main = Camera.main; + if (main != null) + { + return main; + } + + try + { + // Use FindObjectsOfType for Unity 2021 compatibility. + var cams = UnityEngine.Object.FindObjectsOfType(); + return cams.FirstOrDefault(); + } + catch + { + return null; + } + } public static ScreenshotCaptureResult CaptureToAssetsFolder(string fileName = null, int superSize = 1, bool ensureUniqueFileName = true) { - int size = Mathf.Max(1, superSize); - string resolvedName = BuildFileName(fileName); - string folder = Path.Combine(Application.dataPath, ScreenshotsFolderName); - Directory.CreateDirectory(folder); - - string fullPath = Path.Combine(folder, resolvedName); - if (ensureUniqueFileName) - { - fullPath = EnsureUnique(fullPath); - } - - string normalizedFullPath = fullPath.Replace('\\', '/'); - - // Use only the file name to let Unity decide the final location (per CaptureScreenshot docs). - string captureName = Path.GetFileName(normalizedFullPath); - - // Use Asset folder for ScreenCapture.CaptureScreenshot to ensure write to asset rather than project root - string projectRoot = GetProjectRootPath(); - string assetsRelativePath = normalizedFullPath; - if (assetsRelativePath.StartsWith(projectRoot, StringComparison.OrdinalIgnoreCase)) - { - assetsRelativePath = assetsRelativePath.Substring(projectRoot.Length).TrimStart('/'); - } - #if UNITY_2022_1_OR_NEWER - ScreenCapture.CaptureScreenshot(assetsRelativePath, size); + ScreenshotCaptureResult result = PrepareCaptureResult(fileName, superSize, ensureUniqueFileName, isAsync: true); + ScreenCapture.CaptureScreenshot(result.AssetsRelativePath, result.SuperSize); + return result; #else - Debug.LogWarning("ScreenCapture is supported after Unity 2022.1. Using main camera capture as fallback."); - CaptureFromCameraToAssetsFolder(Camera.main, captureName, size, false); -#endif + if (!s_loggedLegacyScreenCaptureFallback) + { + Debug.Log("ScreenCapture is supported after Unity 2022.1. Using camera capture as fallback."); + s_loggedLegacyScreenCaptureFallback = true; + } - return new ScreenshotCaptureResult( - normalizedFullPath, - assetsRelativePath, - size); + var cam = FindAvailableCamera(); + if (cam == null) + { + throw new InvalidOperationException("No camera found to capture screenshot."); + } + + return CaptureFromCameraToAssetsFolder(cam, fileName, superSize, ensureUniqueFileName); +#endif } /// @@ -73,6 +85,54 @@ namespace MCPForUnity.Runtime.Helpers throw new ArgumentNullException(nameof(camera)); } + ScreenshotCaptureResult result = PrepareCaptureResult(fileName, superSize, ensureUniqueFileName, isAsync: false); + int size = result.SuperSize; + + int width = Mathf.Max(1, camera.pixelWidth > 0 ? camera.pixelWidth : Screen.width); + int height = Mathf.Max(1, camera.pixelHeight > 0 ? camera.pixelHeight : Screen.height); + width *= size; + height *= size; + + RenderTexture prevRT = camera.targetTexture; + RenderTexture prevActive = RenderTexture.active; + var rt = RenderTexture.GetTemporary(width, height, 24, RenderTextureFormat.ARGB32); + Texture2D tex = null; + try + { + camera.targetTexture = rt; + camera.Render(); + + RenderTexture.active = rt; + tex = new Texture2D(width, height, TextureFormat.RGBA32, false); + tex.ReadPixels(new Rect(0, 0, width, height), 0, 0); + tex.Apply(); + + byte[] png = tex.EncodeToPNG(); + File.WriteAllBytes(result.FullPath, png); + } + finally + { + camera.targetTexture = prevRT; + RenderTexture.active = prevActive; + RenderTexture.ReleaseTemporary(rt); + if (tex != null) + { + if (Application.isPlaying) + { + UnityEngine.Object.Destroy(tex); + } + else + { + UnityEngine.Object.DestroyImmediate(tex); + } + } + } + + return result; + } + + private static ScreenshotCaptureResult PrepareCaptureResult(string fileName, int superSize, bool ensureUniqueFileName, bool isAsync) + { int size = Mathf.Max(1, superSize); string resolvedName = BuildFileName(fileName); string folder = Path.Combine(Application.dataPath, ScreenshotsFolderName); @@ -85,43 +145,20 @@ namespace MCPForUnity.Runtime.Helpers } string normalizedFullPath = fullPath.Replace('\\', '/'); + string assetsRelativePath = ToAssetsRelativePath(normalizedFullPath); - int width = Mathf.Max(1, camera.pixelWidth > 0 ? camera.pixelWidth : Screen.width); - int height = Mathf.Max(1, camera.pixelHeight > 0 ? camera.pixelHeight : Screen.height); - width *= size; - height *= size; - - RenderTexture prevRT = camera.targetTexture; - RenderTexture prevActive = RenderTexture.active; - var rt = RenderTexture.GetTemporary(width, height, 24, RenderTextureFormat.ARGB32); - try - { - camera.targetTexture = rt; - camera.Render(); - - RenderTexture.active = rt; - var tex = new Texture2D(width, height, TextureFormat.RGBA32, false); - tex.ReadPixels(new Rect(0, 0, width, height), 0, 0); - tex.Apply(); - - byte[] png = tex.EncodeToPNG(); - File.WriteAllBytes(normalizedFullPath, png); - } - finally - { - camera.targetTexture = prevRT; - RenderTexture.active = prevActive; - RenderTexture.ReleaseTemporary(rt); - } + return new ScreenshotCaptureResult(normalizedFullPath, assetsRelativePath, size, isAsync); + } + private static string ToAssetsRelativePath(string normalizedFullPath) + { string projectRoot = GetProjectRootPath(); string assetsRelativePath = normalizedFullPath; if (assetsRelativePath.StartsWith(projectRoot, StringComparison.OrdinalIgnoreCase)) { assetsRelativePath = assetsRelativePath.Substring(projectRoot.Length).TrimStart('/'); } - - return new ScreenshotCaptureResult(normalizedFullPath, assetsRelativePath, size); + return assetsRelativePath; } private static string BuildFileName(string fileName) diff --git a/docs/development/README-DEV-zh.md b/docs/development/README-DEV-zh.md index c79ba55..375c0bd 100644 --- a/docs/development/README-DEV-zh.md +++ b/docs/development/README-DEV-zh.md @@ -208,6 +208,12 @@ X:\UnityProject\Library\PackageCache\com.coplaydev.unity-mcp@272123cfd97e - **`max_nodes`**:默认 **1000**,限制 **1..5000** - **`include_transform`**:默认 **false** +### `manage_scene(action="screenshot")` + +- 将 PNG 保存到 `Assets/Screenshots/`。 +- Unity **2022.1+**:通过 `ScreenCapture.CaptureScreenshot` 捕获 **Game View**,因此包含 `Screen Space - Overlay` UI。注意该写入是 **异步** 的,文件/导入可能会稍后出现。 +- Unity **2021.3**:回退为将可用的 `Camera` 渲染到 `RenderTexture`(仅相机输出;不包含 `Screen Space - Overlay` UI)。 + ### `manage_gameobject(action="get_components")` - **默认行为**:仅返回 **分页的组件元数据**(`typeName`, `instanceID`)。 diff --git a/docs/development/README-DEV.md b/docs/development/README-DEV.md index 0772947..b2e2170 100644 --- a/docs/development/README-DEV.md +++ b/docs/development/README-DEV.md @@ -199,6 +199,12 @@ Some Unity tool calls can return *very large* JSON payloads (deep hierarchies, c - **`max_nodes`**: defaults to **1000**, clamped to **1..5000** - **`include_transform`**: defaults to **false** +### `manage_scene(action="screenshot")` + +- Saves PNGs under `Assets/Screenshots/`. +- Unity **2022.1+**: captures the **Game View** via `ScreenCapture.CaptureScreenshot`, so `Screen Space - Overlay` UI is included. This write is **async**, so the file may appear/import a moment later. +- Unity **2021.3**: falls back to rendering the best available `Camera` into a `RenderTexture` (camera output only; `Screen Space - Overlay` UI is not included). + ### `manage_gameobject(action="get_components")` - **Default behavior**: returns **paged component metadata** only (`typeName`, `instanceID`). @@ -391,4 +397,4 @@ Tests that trigger script compilation mid-run (e.g., `DomainReloadResilienceTest - Run them first in the test suite (before backgrounding Unity) - Use the `[Explicit]` attribute to exclude them from default runs -**Note:** The MCP workflow itself is unaffected—socket messages provide external stimulus that keeps Unity responsive even when backgrounded. This limitation only affects Unity's internal test coroutine waits. \ No newline at end of file +**Note:** The MCP workflow itself is unaffected—socket messages provide external stimulus that keeps Unity responsive even when backgrounded. This limitation only affects Unity's internal test coroutine waits.