Skip to content

Commit ff7972f

Browse files
committed
Updates on Camera Capture Feature
* Enable Camera Capture through both play and editor mode Notes: Because the standard ScreenCapture.CaptureScreenshot does not work in editor mode, so we use ScreenCapture.CaptureScreenshotIntoRenderTexture to enable it during play mode. * user can access the camera access through the tool menu, or through direct LLM calling. Both tested on Windows with Claude Desktop.
1 parent 7b33aaa commit ff7972f

File tree

5 files changed

+344
-30
lines changed

5 files changed

+344
-30
lines changed

MCPForUnity/Editor/Tools/ManageScene.cs

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using System.IO;
44
using System.Linq;
55
using MCPForUnity.Editor.Helpers; // For Response class
6+
using MCPForUnity.Runtime.Helpers; // For ScreenshotUtility
67
using Newtonsoft.Json.Linq;
78
using UnityEditor;
89
using UnityEditor.SceneManagement;
@@ -23,6 +24,8 @@ private sealed class SceneCommand
2324
public string name { get; set; } = string.Empty;
2425
public string path { get; set; } = string.Empty;
2526
public int? buildIndex { get; set; }
27+
public string fileName { get; set; } = string.Empty;
28+
public int? superSize { get; set; }
2629
}
2730

2831
private static SceneCommand ToSceneCommand(JObject p)
@@ -42,7 +45,9 @@ private static SceneCommand ToSceneCommand(JObject p)
4245
action = (p["action"]?.ToString() ?? string.Empty).Trim().ToLowerInvariant(),
4346
name = p["name"]?.ToString() ?? string.Empty,
4447
path = p["path"]?.ToString() ?? string.Empty,
45-
buildIndex = BI(p["buildIndex"] ?? p["build_index"])
48+
buildIndex = BI(p["buildIndex"] ?? p["build_index"]),
49+
fileName = (p["fileName"] ?? p["filename"])?.ToString() ?? string.Empty,
50+
superSize = BI(p["superSize"] ?? p["super_size"] ?? p["supersize"])
4651
};
4752
}
4853

@@ -142,14 +147,26 @@ public static object HandleCommand(JObject @params)
142147
return ga;
143148
case "get_build_settings":
144149
return GetBuildSettingsScenes();
150+
case "screenshot":
151+
return CaptureScreenshot(cmd.fileName, cmd.superSize);
145152
// Add cases for modifying build settings, additive loading, unloading etc.
146153
default:
147154
return new ErrorResponse(
148-
$"Unknown action: '{action}'. Valid actions: create, load, save, get_hierarchy, get_active, get_build_settings."
155+
$"Unknown action: '{action}'. Valid actions: create, load, save, get_hierarchy, get_active, get_build_settings, screenshot."
149156
);
150157
}
151158
}
152159

160+
/// <summary>
161+
/// Captures a screenshot to Assets/Screenshots and returns a response payload.
162+
/// Public so the tools UI can reuse the same logic without duplicating parameters.
163+
/// Available in both Edit Mode and Play Mode.
164+
/// </summary>
165+
public static object ExecuteScreenshot(string fileName = null, int? superSize = null)
166+
{
167+
return CaptureScreenshot(fileName, superSize);
168+
}
169+
153170
private static object CreateScene(string fullPath, string relativePath)
154171
{
155172
if (File.Exists(fullPath))
@@ -329,6 +346,55 @@ private static object SaveScene(string fullPath, string relativePath)
329346
}
330347
}
331348

349+
private static object CaptureScreenshot(string fileName, int? superSize)
350+
{
351+
try
352+
{
353+
int resolvedSuperSize = (superSize.HasValue && superSize.Value > 0) ? superSize.Value : 1;
354+
ScreenshotCaptureResult result;
355+
356+
if (Application.isPlaying)
357+
{
358+
result = ScreenshotUtility.CaptureToAssetsFolder(fileName, resolvedSuperSize, ensureUniqueFileName: true);
359+
}
360+
else
361+
{
362+
// Edit Mode path: render from the best-guess camera using RenderTexture.
363+
Camera cam = Camera.main;
364+
if (cam == null)
365+
{
366+
var cams = UnityEngine.Object.FindObjectsOfType<Camera>();
367+
cam = cams.FirstOrDefault();
368+
}
369+
370+
if (cam == null)
371+
{
372+
return new ErrorResponse("No camera found to capture screenshot in Edit Mode.");
373+
}
374+
375+
result = ScreenshotUtility.CaptureFromCameraToAssetsFolder(cam, fileName, resolvedSuperSize, ensureUniqueFileName: true);
376+
}
377+
378+
AssetDatabase.Refresh();
379+
380+
string message = $"Screenshot captured to '{result.AssetsRelativePath}' (full: {result.FullPath}).";
381+
382+
return new SuccessResponse(
383+
message,
384+
new
385+
{
386+
path = result.AssetsRelativePath,
387+
fullPath = result.FullPath,
388+
superSize = result.SuperSize,
389+
}
390+
);
391+
}
392+
catch (Exception e)
393+
{
394+
return new ErrorResponse($"Error capturing screenshot: {e.Message}");
395+
}
396+
}
397+
332398
private static object GetActiveSceneInfo()
333399
{
334400
try

MCPForUnity/Editor/Windows/Components/Tools/McpToolsSection.cs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using MCPForUnity.Editor.Constants;
55
using MCPForUnity.Editor.Helpers;
66
using MCPForUnity.Editor.Services;
7+
using MCPForUnity.Editor.Tools;
78
using UnityEditor;
89
using UnityEngine.UIElements;
910

@@ -199,6 +200,11 @@ private VisualElement CreateToolRow(ToolMetadata tool)
199200
row.Add(parametersLabel);
200201
}
201202

203+
if (IsManageSceneTool(tool))
204+
{
205+
row.Add(CreateManageSceneActions());
206+
}
207+
202208
return row;
203209
}
204210

@@ -258,13 +264,56 @@ private void AddInfoLabel(string message)
258264
categoryContainer?.Add(label);
259265
}
260266

267+
private VisualElement CreateManageSceneActions()
268+
{
269+
var actions = new VisualElement();
270+
actions.AddToClassList("tool-item-actions");
271+
272+
var screenshotButton = new Button(OnManageSceneScreenshotClicked)
273+
{
274+
text = "Capture Screenshot"
275+
};
276+
screenshotButton.AddToClassList("tool-action-button");
277+
screenshotButton.style.marginTop = 4;
278+
screenshotButton.tooltip = "Capture a screenshot to Assets/Screenshots via manage_scene.";
279+
280+
actions.Add(screenshotButton);
281+
return actions;
282+
}
283+
284+
private void OnManageSceneScreenshotClicked()
285+
{
286+
try
287+
{
288+
var response = ManageScene.ExecuteScreenshot();
289+
if (response is SuccessResponse success && !string.IsNullOrWhiteSpace(success.Message))
290+
{
291+
McpLog.Info(success.Message);
292+
}
293+
else if (response is ErrorResponse error && !string.IsNullOrWhiteSpace(error.Error))
294+
{
295+
McpLog.Error(error.Error);
296+
}
297+
else
298+
{
299+
McpLog.Info("Screenshot capture requested.");
300+
}
301+
}
302+
catch (Exception ex)
303+
{
304+
McpLog.Error($"Failed to capture screenshot: {ex.Message}");
305+
}
306+
}
307+
261308
private static Label CreateTag(string text)
262309
{
263310
var tag = new Label(text);
264311
tag.AddToClassList("tool-tag");
265312
return tag;
266313
}
267314

315+
private static bool IsManageSceneTool(ToolMetadata tool) => string.Equals(tool?.Name, "manage_scene", StringComparison.OrdinalIgnoreCase);
316+
268317
private static bool IsBuiltIn(ToolMetadata tool) => tool?.IsBuiltIn ?? false;
269318
}
270319
}
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
using System;
2+
using System.IO;
3+
using System.Linq;
4+
using UnityEngine;
5+
6+
namespace MCPForUnity.Runtime.Helpers
7+
//The reason for having another Runtime Utilities in additional to Editor Utilities is to avoid Editor-only dependencies in this runtime code.
8+
{
9+
public readonly struct ScreenshotCaptureResult
10+
{
11+
public ScreenshotCaptureResult(string fullPath, string assetsRelativePath, int superSize)
12+
{
13+
FullPath = fullPath;
14+
AssetsRelativePath = assetsRelativePath;
15+
SuperSize = superSize;
16+
}
17+
18+
public string FullPath { get; }
19+
public string AssetsRelativePath { get; }
20+
public int SuperSize { get; }
21+
}
22+
23+
public static class ScreenshotUtility
24+
{
25+
private const string ScreenshotsFolderName = "Screenshots";
26+
27+
public static ScreenshotCaptureResult CaptureToAssetsFolder(string fileName = null, int superSize = 1, bool ensureUniqueFileName = true)
28+
{
29+
int size = Mathf.Max(1, superSize);
30+
string resolvedName = BuildFileName(fileName);
31+
string folder = Path.Combine(Application.dataPath, ScreenshotsFolderName);
32+
Directory.CreateDirectory(folder);
33+
34+
string fullPath = Path.Combine(folder, resolvedName);
35+
if (ensureUniqueFileName)
36+
{
37+
fullPath = EnsureUnique(fullPath);
38+
}
39+
40+
string normalizedFullPath = fullPath.Replace('\\', '/');
41+
42+
// Use only the file name to let Unity decide the final location (per CaptureScreenshot docs).
43+
string captureName = Path.GetFileName(normalizedFullPath);
44+
ScreenCapture.CaptureScreenshot(captureName, size);
45+
46+
Debug.Log($"Screenshot requested: file='{captureName}' intendedFullPath='{normalizedFullPath}' persistentDataPath='{Application.persistentDataPath}'");
47+
48+
string projectRoot = GetProjectRootPath();
49+
string assetsRelativePath = normalizedFullPath;
50+
if (assetsRelativePath.StartsWith(projectRoot, StringComparison.OrdinalIgnoreCase))
51+
{
52+
assetsRelativePath = assetsRelativePath.Substring(projectRoot.Length).TrimStart('/');
53+
}
54+
55+
return new ScreenshotCaptureResult(
56+
normalizedFullPath,
57+
assetsRelativePath,
58+
size);
59+
}
60+
61+
/// <summary>
62+
/// Captures a screenshot from a specific camera by rendering into a temporary RenderTexture (works in Edit Mode).
63+
/// </summary>
64+
public static ScreenshotCaptureResult CaptureFromCameraToAssetsFolder(Camera camera, string fileName = null, int superSize = 1, bool ensureUniqueFileName = true)
65+
{
66+
if (camera == null)
67+
{
68+
throw new ArgumentNullException(nameof(camera));
69+
}
70+
71+
int size = Mathf.Max(1, superSize);
72+
string resolvedName = BuildFileName(fileName);
73+
string folder = Path.Combine(Application.dataPath, ScreenshotsFolderName);
74+
Directory.CreateDirectory(folder);
75+
76+
string fullPath = Path.Combine(folder, resolvedName);
77+
if (ensureUniqueFileName)
78+
{
79+
fullPath = EnsureUnique(fullPath);
80+
}
81+
82+
string normalizedFullPath = fullPath.Replace('\\', '/');
83+
84+
int width = Mathf.Max(1, camera.pixelWidth > 0 ? camera.pixelWidth : Screen.width);
85+
int height = Mathf.Max(1, camera.pixelHeight > 0 ? camera.pixelHeight : Screen.height);
86+
width *= size;
87+
height *= size;
88+
89+
RenderTexture prevRT = camera.targetTexture;
90+
RenderTexture prevActive = RenderTexture.active;
91+
var rt = RenderTexture.GetTemporary(width, height, 24, RenderTextureFormat.ARGB32);
92+
try
93+
{
94+
camera.targetTexture = rt;
95+
camera.Render();
96+
97+
RenderTexture.active = rt;
98+
var tex = new Texture2D(width, height, TextureFormat.RGBA32, false);
99+
tex.ReadPixels(new Rect(0, 0, width, height), 0, 0);
100+
tex.Apply();
101+
102+
byte[] png = tex.EncodeToPNG();
103+
File.WriteAllBytes(normalizedFullPath, png);
104+
}
105+
finally
106+
{
107+
camera.targetTexture = prevRT;
108+
RenderTexture.active = prevActive;
109+
RenderTexture.ReleaseTemporary(rt);
110+
}
111+
112+
string projectRoot = GetProjectRootPath();
113+
string assetsRelativePath = normalizedFullPath;
114+
if (assetsRelativePath.StartsWith(projectRoot, StringComparison.OrdinalIgnoreCase))
115+
{
116+
assetsRelativePath = assetsRelativePath.Substring(projectRoot.Length).TrimStart('/');
117+
}
118+
119+
return new ScreenshotCaptureResult(normalizedFullPath, assetsRelativePath, size);
120+
}
121+
122+
private static string BuildFileName(string fileName)
123+
{
124+
string name = string.IsNullOrWhiteSpace(fileName)
125+
? $"screenshot-{DateTime.Now:yyyyMMdd-HHmmss}"
126+
: fileName.Trim();
127+
128+
name = SanitizeFileName(name);
129+
130+
if (!name.EndsWith(".png", StringComparison.OrdinalIgnoreCase) &&
131+
!name.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase) &&
132+
!name.EndsWith(".jpeg", StringComparison.OrdinalIgnoreCase))
133+
{
134+
name += ".png";
135+
}
136+
137+
return name;
138+
}
139+
140+
private static string SanitizeFileName(string fileName)
141+
{
142+
var invalidChars = Path.GetInvalidFileNameChars();
143+
string cleaned = new string(fileName.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray());
144+
145+
return string.IsNullOrWhiteSpace(cleaned) ? "screenshot" : cleaned;
146+
}
147+
148+
private static string EnsureUnique(string path)
149+
{
150+
if (!File.Exists(path))
151+
{
152+
return path;
153+
}
154+
155+
string directory = Path.GetDirectoryName(path) ?? string.Empty;
156+
string baseName = Path.GetFileNameWithoutExtension(path);
157+
string extension = Path.GetExtension(path);
158+
int counter = 1;
159+
160+
string candidate;
161+
do
162+
{
163+
candidate = Path.Combine(directory, $"{baseName}-{counter}{extension}");
164+
counter++;
165+
} while (File.Exists(candidate));
166+
167+
return candidate;
168+
}
169+
170+
private static string GetProjectRootPath()
171+
{
172+
string root = Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
173+
root = root.Replace('\\', '/');
174+
if (!root.EndsWith("/", StringComparison.Ordinal))
175+
{
176+
root += "/";
177+
}
178+
return root;
179+
}
180+
}
181+
}

0 commit comments

Comments
 (0)