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 <msanatan@gmail.com>
Co-authored-by: GitHub Actions <actions@github.com>
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>
main
adrd 2026-01-25 06:07:08 +08:00 committed by GitHub
parent c1d89c7680
commit 67dda7f9cc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 243 additions and 137 deletions

View File

@ -154,9 +154,16 @@ namespace MCPForUnity.Editor.Tools.GameObjects
} }
else else
{ {
#if UNITY_2023_1_OR_NEWER
var inactive = searchInactive ? FindObjectsInactive.Include : FindObjectsInactive.Exclude;
searchPoolComp = UnityEngine.Object.FindObjectsByType(componentType, inactive, FindObjectsSortMode.None)
.Cast<Component>()
.Select(c => c.gameObject);
#else
searchPoolComp = UnityEngine.Object.FindObjectsOfType(componentType, searchInactive) searchPoolComp = UnityEngine.Object.FindObjectsOfType(componentType, searchInactive)
.Cast<Component>() .Cast<Component>()
.Select(c => c.gameObject); .Select(c => c.gameObject);
#endif
} }
results.AddRange(searchPoolComp.Where(go => go != null)); results.AddRange(searchPoolComp.Where(go => go != null));
} }

View File

@ -384,34 +384,27 @@ namespace MCPForUnity.Editor.Tools
try try
{ {
int resolvedSuperSize = (superSize.HasValue && superSize.Value > 0) ? superSize.Value : 1; 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 else
{ {
// Edit Mode path: render from the best-guess camera using RenderTexture. AssetDatabase.ImportAsset(result.AssetsRelativePath, ImportAssetOptions.ForceSynchronousImport);
Camera cam = Camera.main;
if (cam == null)
{
// Use FindObjectsOfType for Unity 2021 compatibility
var cams = UnityEngine.Object.FindObjectsOfType<Camera>();
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.Refresh(ImportAssetOptions.ForceSynchronousImport); string verb = result.IsAsync ? "Screenshot requested" : "Screenshot captured";
string message = $"{verb} to '{result.AssetsRelativePath}' (full: {result.FullPath}).";
string message = $"Screenshot captured to '{result.AssetsRelativePath}' (full: {result.FullPath}).";
return new SuccessResponse( return new SuccessResponse(
message, message,
@ -420,6 +413,7 @@ namespace MCPForUnity.Editor.Tools
path = result.AssetsRelativePath, path = result.AssetsRelativePath,
fullPath = result.FullPath, fullPath = result.FullPath,
superSize = result.SuperSize, 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() private static object GetActiveSceneInfo()
{ {
try 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<string, object> var d = new Dictionary<string, object>
{ {
@ -684,7 +786,7 @@ namespace MCPForUnity.Editor.Tools
{ "childrenTruncated", childrenTruncated }, { "childrenTruncated", childrenTruncated },
{ "childrenCursor", childCount > 0 ? "0" : null }, { "childrenCursor", childCount > 0 ? "0" : null },
{ "childrenPageSizeDefault", maxChildrenPerNode }, { "childrenPageSizeDefault", maxChildrenPerNode },
{ "componentTypes", componentTypes }, // NEW: Lightweight component type list { "componentTypes", componentTypes },
}; };
if (includeTransform && go.transform != null) if (includeTransform && go.transform != null)
@ -721,57 +823,5 @@ namespace MCPForUnity.Editor.Tools
} }
} }
/// <summary>
/// Recursively builds a data representation of a GameObject and its children.
/// </summary>
private static object GetGameObjectDataRecursive(GameObject go)
{
if (go == null)
return null;
var childrenData = new List<object>();
foreach (Transform child in go.transform)
{
childrenData.Add(GetGameObjectDataRecursive(child.gameObject));
}
var gameObjectData = new Dictionary<string, object>
{
{ "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;
}
} }
} }

View File

@ -9,58 +9,70 @@ namespace MCPForUnity.Runtime.Helpers
public readonly struct ScreenshotCaptureResult public readonly struct ScreenshotCaptureResult
{ {
public ScreenshotCaptureResult(string fullPath, string assetsRelativePath, int superSize) 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; FullPath = fullPath;
AssetsRelativePath = assetsRelativePath; AssetsRelativePath = assetsRelativePath;
SuperSize = superSize; SuperSize = superSize;
IsAsync = isAsync;
} }
public string FullPath { get; } public string FullPath { get; }
public string AssetsRelativePath { get; } public string AssetsRelativePath { get; }
public int SuperSize { get; } public int SuperSize { get; }
public bool IsAsync { get; }
} }
public static class ScreenshotUtility public static class ScreenshotUtility
{ {
private const string ScreenshotsFolderName = "Screenshots"; 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<Camera>();
return cams.FirstOrDefault();
}
catch
{
return null;
}
}
public static ScreenshotCaptureResult CaptureToAssetsFolder(string fileName = null, int superSize = 1, bool ensureUniqueFileName = true) 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 #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 #else
Debug.LogWarning("ScreenCapture is supported after Unity 2022.1. Using main camera capture as fallback."); if (!s_loggedLegacyScreenCaptureFallback)
CaptureFromCameraToAssetsFolder(Camera.main, captureName, size, false); {
#endif Debug.Log("ScreenCapture is supported after Unity 2022.1. Using camera capture as fallback.");
s_loggedLegacyScreenCaptureFallback = true;
}
return new ScreenshotCaptureResult( var cam = FindAvailableCamera();
normalizedFullPath, if (cam == null)
assetsRelativePath, {
size); throw new InvalidOperationException("No camera found to capture screenshot.");
}
return CaptureFromCameraToAssetsFolder(cam, fileName, superSize, ensureUniqueFileName);
#endif
} }
/// <summary> /// <summary>
@ -73,6 +85,54 @@ namespace MCPForUnity.Runtime.Helpers
throw new ArgumentNullException(nameof(camera)); 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); int size = Mathf.Max(1, superSize);
string resolvedName = BuildFileName(fileName); string resolvedName = BuildFileName(fileName);
string folder = Path.Combine(Application.dataPath, ScreenshotsFolderName); string folder = Path.Combine(Application.dataPath, ScreenshotsFolderName);
@ -85,43 +145,20 @@ namespace MCPForUnity.Runtime.Helpers
} }
string normalizedFullPath = fullPath.Replace('\\', '/'); string normalizedFullPath = fullPath.Replace('\\', '/');
string assetsRelativePath = ToAssetsRelativePath(normalizedFullPath);
int width = Mathf.Max(1, camera.pixelWidth > 0 ? camera.pixelWidth : Screen.width); return new ScreenshotCaptureResult(normalizedFullPath, assetsRelativePath, size, isAsync);
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);
}
private static string ToAssetsRelativePath(string normalizedFullPath)
{
string projectRoot = GetProjectRootPath(); string projectRoot = GetProjectRootPath();
string assetsRelativePath = normalizedFullPath; string assetsRelativePath = normalizedFullPath;
if (assetsRelativePath.StartsWith(projectRoot, StringComparison.OrdinalIgnoreCase)) if (assetsRelativePath.StartsWith(projectRoot, StringComparison.OrdinalIgnoreCase))
{ {
assetsRelativePath = assetsRelativePath.Substring(projectRoot.Length).TrimStart('/'); assetsRelativePath = assetsRelativePath.Substring(projectRoot.Length).TrimStart('/');
} }
return assetsRelativePath;
return new ScreenshotCaptureResult(normalizedFullPath, assetsRelativePath, size);
} }
private static string BuildFileName(string fileName) private static string BuildFileName(string fileName)

View File

@ -208,6 +208,12 @@ X:\UnityProject\Library\PackageCache\com.coplaydev.unity-mcp@272123cfd97e
- **`max_nodes`**:默认 **1000**,限制 **1..5000** - **`max_nodes`**:默认 **1000**,限制 **1..5000**
- **`include_transform`**:默认 **false** - **`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")` ### `manage_gameobject(action="get_components")`
- **默认行为**:仅返回 **分页的组件元数据**`typeName`, `instanceID`)。 - **默认行为**:仅返回 **分页的组件元数据**`typeName`, `instanceID`)。

View File

@ -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** - **`max_nodes`**: defaults to **1000**, clamped to **1..5000**
- **`include_transform`**: defaults to **false** - **`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")` ### `manage_gameobject(action="get_components")`
- **Default behavior**: returns **paged component metadata** only (`typeName`, `instanceID`). - **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) - Run them first in the test suite (before backgrounding Unity)
- Use the `[Explicit]` attribute to exclude them from default runs - 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. **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.