Initial commit: AIGame Unity client with URP, HybridCLR, YooAsset and tooling.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
udx
2026-09-21 14:21:07 +08:00
co-authored by Cursor
commit f3f66ab063
931 changed files with 112366 additions and 0 deletions
@@ -0,0 +1,428 @@
using System;
using System.Collections;
using System.IO;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using Unity.Pipeline.Editor.Authoring;
using Unity.Pipeline.Editor.Commands.Capture;
using UnityEditor;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.TestTools;
namespace Unity.Pipeline.Tests.Editor.Capture
{
/// <summary>
/// Tests for the visual-feedback commands (CLI-199), exercised directly and via PipelineClient.
/// Render tests are GPU-gated: under batchmode/headless the graphics device is
/// <see cref="GraphicsDeviceType.Null"/> and the tests self-ignore rather than fail.
/// </summary>
class CaptureCommandsTests
{
private const string CameraName = "CLI199_Cam";
private const string SaveFolder = "Assets/CLI199_CaptureTests";
// PNG file signature: 0x89 'P' 'N' 'G' 0x0D 0x0A 0x1A 0x0A.
private static readonly byte[] PngSignature = { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A };
private GameObject m_CameraObject;
[SetUp]
public void SetUp()
{
m_CameraObject = new GameObject(CameraName);
m_CameraObject.AddComponent<Camera>();
}
[TearDown]
public void TearDown()
{
if (m_CameraObject != null)
UnityEngine.Object.DestroyImmediate(m_CameraObject);
if (AssetDatabase.IsValidFolder(SaveFolder))
AssetDatabase.DeleteAsset(SaveFolder);
}
private static string AbsolutePath(string projectRelative) =>
Path.Combine(ProjectPaths.ProjectRoot, projectRelative);
private static bool IsHeadless => SystemInfo.graphicsDeviceType == GraphicsDeviceType.Null;
private static void AssertPngSignature(byte[] bytes)
{
Assert.GreaterOrEqual(bytes.Length, PngSignature.Length, "PNG payload too short to contain a signature");
for (var i = 0; i < PngSignature.Length; i++)
Assert.AreEqual(PngSignature[i], bytes[i], $"PNG signature mismatch at byte {i}");
}
#region Direct
[Test]
public void CaptureGameView_NamedCamera_ReturnsPng()
{
if (IsHeadless)
Assert.Ignore("No GPU in batchmode");
var result = CaptureCommands.CaptureGameView(64, 64, CameraName);
Assert.IsNotNull(result, "Capture should return a result");
Assert.IsNotEmpty(result.Base64, "Base64 payload should be non-empty");
Assert.AreEqual(64, result.Width, "Width should match the requested size");
Assert.AreEqual(64, result.Height, "Height should match the requested size");
Assert.AreEqual("png", result.Encoding);
Assert.AreEqual($"camera:{CameraName}", result.Source);
Assert.IsNull(result.SavedPath, "No savePath was requested");
var bytes = Convert.FromBase64String(result.Base64);
AssertPngSignature(bytes);
Assert.AreEqual(bytes.Length, result.Bytes, "Reported byte length should match decoded payload");
}
[Test]
public void CaptureSceneView_ReturnsPng()
{
if (IsHeadless)
Assert.Ignore("No GPU in batchmode");
var sv = SceneView.lastActiveSceneView;
if (sv == null)
Assert.Ignore("No Scene View");
var result = CaptureCommands.CaptureSceneView(64, 64);
Assert.IsNotNull(result, "Capture should return a result");
Assert.IsNotEmpty(result.Base64, "Base64 payload should be non-empty");
Assert.AreEqual("sceneView", result.Source);
var bytes = Convert.FromBase64String(result.Base64);
AssertPngSignature(bytes);
}
[Test]
public void CaptureGameView_SavePath_OmitsBase64_AndWritesPng()
{
if (IsHeadless)
Assert.Ignore("No GPU in batchmode");
var result = CaptureCommands.CaptureGameView(64, 64, CameraName, $"{SaveFolder}/path_only.png");
Assert.IsNull(result.Base64, "save_path without include_inline_image should omit the base64 payload");
Assert.IsNotNull(result.SavedPath, "SavedPath should be set");
var fileBytes = File.ReadAllBytes(AbsolutePath(result.SavedPath));
AssertPngSignature(fileBytes);
Assert.AreEqual(fileBytes.Length, result.Bytes, "Reported byte length should match the file");
}
[Test]
public void CaptureGameView_SavePath_IncludeImage_ReturnsFileAndBase64()
{
if (IsHeadless)
Assert.Ignore("No GPU in batchmode");
var result = CaptureCommands.CaptureGameView(64, 64, CameraName, $"{SaveFolder}/both.png", includeInlineImage: true);
Assert.IsNotEmpty(result.Base64, "include_inline_image=true should return the base64 payload");
Assert.IsNotNull(result.SavedPath, "SavedPath should be set");
AssertPngSignature(Convert.FromBase64String(result.Base64));
AssertPngSignature(File.ReadAllBytes(AbsolutePath(result.SavedPath)));
}
[Test]
public void CaptureGameView_MaxResolution_ClampsInlineRender()
{
if (IsHeadless)
Assert.Ignore("No GPU in batchmode");
// No save_path: the inline image is the only artifact, so the cap applies to the render.
var result = CaptureCommands.CaptureGameView(128, 64, CameraName, maxResolution: 32);
Assert.AreEqual(32, result.Width, "Long edge should be clamped to max_resolution");
Assert.AreEqual(16, result.Height, "Short edge should keep the aspect ratio");
AssertPngSignature(Convert.FromBase64String(result.Base64));
}
[Test]
public void CaptureGameView_SavePath_MaxResolution_DownscalesInlineOnly()
{
if (IsHeadless)
Assert.Ignore("No GPU in batchmode");
var result = CaptureCommands.CaptureGameView(128, 64, CameraName, $"{SaveFolder}/full.png",
includeInlineImage: true, maxResolution: 32);
Assert.AreEqual(128, result.Width, "The saved file keeps the requested resolution");
Assert.AreEqual(32, result.InlineWidth, "The inline copy is downscaled to max_resolution");
Assert.AreEqual(16, result.InlineHeight, "The inline copy keeps the aspect ratio");
AssertPngSignature(Convert.FromBase64String(result.Base64));
AssertPngSignature(File.ReadAllBytes(AbsolutePath(result.SavedPath)));
}
[Test]
public void CaptureSceneView_SavePath_OmitsBase64_AndWritesPng()
{
if (IsHeadless)
Assert.Ignore("No GPU in batchmode");
var sv = SceneView.lastActiveSceneView;
if (sv == null)
Assert.Ignore("No Scene View");
var result = CaptureCommands.CaptureSceneView(64, 64, $"{SaveFolder}/scene_path_only.png");
Assert.IsNull(result.Base64, "save_path without include_inline_image should omit the base64 payload");
Assert.IsNotNull(result.SavedPath, "SavedPath should be set");
AssertPngSignature(File.ReadAllBytes(AbsolutePath(result.SavedPath)));
}
#endregion
#region Source routing (AUTHAPI-10)
[Test]
public void CaptureGameView_ScreenSource_InEditMode_Throws()
{
// Overlay/screen capture is a Play-Mode operation; in Edit Mode it must fail with a clear
// message (this guard runs before the GPU check, so the message is deterministic headless too).
var ex = Assert.Throws<InvalidOperationException>(
() => CaptureCommands.CaptureGameView(64, 64, source: "screen"));
StringAssert.Contains("Play Mode", ex.Message);
}
[Test]
public void CaptureGameView_UnknownSource_Throws()
{
var ex = Assert.Throws<ArgumentException>(
() => CaptureCommands.CaptureGameView(64, 64, source: "bogus"));
StringAssert.Contains("Unknown source", ex.Message);
}
#endregion
#region ViaClient
[Test]
public void CaptureGameView_ViaClient_Succeeds()
{
if (IsHeadless)
Assert.Ignore("No GPU in batchmode");
using (var server = new PipelineTestServer())
{
var response = server.Execute("capture_game_view", new { width = 64, height = 64, camera = CameraName });
Assert.IsTrue(response.IsSuccess, $"capture_game_view should succeed: {response.Error}");
Assert.IsTrue(response.HasValidJson, "Response should have valid JSON");
Assert.IsTrue(response.JsonResponse.ContainsKey("result"), "Should have result field");
}
}
[Test]
public void CaptureGameView_ViaClient_SavePath_SerializedResultOmitsBase64Key()
{
if (IsHeadless)
Assert.Ignore("No GPU in batchmode");
using (var server = new PipelineTestServer())
{
var response = server.Execute("capture_game_view",
new { width = 64, height = 64, camera = CameraName, save_path = $"{SaveFolder}/via_client.png" });
Assert.IsTrue(response.IsSuccess, $"capture_game_view should succeed: {response.Error}");
var result = (JObject)response.JsonResponse["result"];
Assert.IsFalse(result.ContainsKey("base64"),
"A path-only result must not carry a base64 key on the wire (AUTHAPI-8)");
Assert.IsNotNull(result["savedPath"]?.ToString(), "savedPath should be present");
}
}
#endregion
#region Screen capture in Play Mode (AUTHAPI-10)
// Draws a full-screen two-tone quad via IMGUI. Like a Screen Space - Overlay canvas it
// composites straight to the screen and never renders through a camera — the case AUTHAPI-10
// covers — so it lets the test prove overlay content shows up in a screen capture but not a
// camera capture, without a uGUI dependency.
//
// The tones are chosen to catch two capture-path bugs a flat saturated color (the old pure
// red) is blind to:
// * MID-GRAYS, because a primary like (255,0,0) is invariant under gamma conversion — 128
// turns into ~188 when the path double-encodes gamma (the washout Thomas reported), so
// asserting captured luminance pins the tone round-trip.
// * TWO DIFFERENT tones (light top, dark bottom), because a uniform screen is invariant
// under vertical flips — CaptureScreenshotIntoRenderTexture returns GPU-UV-oriented
// content that must be un-flipped on D3D/Metal/Vulkan, so asserting which half is which
// pins the orientation.
private static readonly Color32 OverlayTopGray = new Color32(128, 128, 128, 255);
private static readonly Color32 OverlayBottomGray = new Color32(64, 64, 64, 255);
private class FullScreenOverlay : MonoBehaviour
{
private Texture2D m_TopTex;
private Texture2D m_BottomTex;
private static Texture2D Solid(Color32 c)
{
var tex = new Texture2D(1, 1);
tex.SetPixels32(new[] { c });
tex.Apply();
return tex;
}
private void OnGUI()
{
if (m_TopTex == null)
{
m_TopTex = Solid(OverlayTopGray);
m_BottomTex = Solid(OverlayBottomGray);
}
GUI.depth = -1000; // draw on top of any other IMGUI
var half = Screen.height / 2f;
GUI.DrawTexture(new Rect(0, 0, Screen.width, half), m_TopTex);
GUI.DrawTexture(new Rect(0, half, Screen.width, Screen.height - half), m_BottomTex);
}
private void OnDestroy()
{
if (m_TopTex != null) DestroyImmediate(m_TopTex);
if (m_BottomTex != null) DestroyImmediate(m_BottomTex);
}
}
[UnityTearDown]
public IEnumerator ExitPlayModeIfNeeded()
{
// A failed assertion inside the play-mode test would otherwise leave the editor playing.
if (EditorApplication.isPlaying)
yield return new ExitPlayMode();
}
[UnityTest]
public IEnumerator CaptureGameView_ScreenSource_CapturesOverlayUi()
{
// Overlay/screen capture reads the presented game-view backbuffer, which requires a real,
// rendering game view. In pure headless/batchmode there is none (and WaitForEndOfFrame never
// fires), so self-ignore early. Edit-Mode + unknown-source guards cover CI regardless.
if (IsHeadless || Application.isBatchMode)
{
Assert.Ignore("Screen capture requires an interactive game view (skipped headless/batchmode).");
yield break;
}
yield return new EnterPlayMode();
// Tolerate unrelated error logs from whatever project hosts the run (this test only checks pixels).
LogAssert.ignoreFailingMessages = true;
// A camera that clears to blue and renders nothing, so a camera capture is overlay-free.
var camGo = new GameObject("CLI199_ScreenCam");
var cam = camGo.AddComponent<Camera>();
cam.clearFlags = CameraClearFlags.SolidColor;
cam.backgroundColor = Color.blue;
cam.cullingMask = 0;
var overlayGo = new GameObject("CLI199_Overlay");
overlayGo.AddComponent<FullScreenOverlay>();
// Poll a few frames: the overlay only appears in the capture once the game view has
// repainted it into the backbuffer (a single frame is racy in the automated harness).
CaptureResult screen = null;
var overlayObserved = false;
var stats = default((float fraction, float topLuma, float bottomLuma));
for (var frame = 0; frame < 30 && !overlayObserved; frame++)
{
yield return new WaitForEndOfFrame();
screen = CaptureCommands.CaptureGameView(64, 64, source: "screen");
stats = GrayStats(screen.Base64);
// The full-screen overlay should dominate the frame with neutral gray of ANY
// brightness — brightness-agnostic on purpose, so a tone or orientation bug is a
// failed assert below rather than an "overlay not observed" ignore.
overlayObserved = stats.fraction > 0.8f;
}
// Reliable everywhere the screen path runs: it returns a correctly-sized PNG tagged "screen".
Assert.IsNotNull(screen, "screen capture should have run");
Assert.AreEqual("screen", screen.Source);
Assert.IsNotEmpty(screen.Base64, "screen capture should return an image");
Assert.AreEqual(64, screen.Width);
// Pixel-presence needs the environment to actually present the overlay into the backbuffer.
// Some CI/windowless setups render a blank/stale backbuffer even in Play Mode; treat an
// unobservable overlay as inconclusive rather than a failure (verified live in the PR).
if (!overlayObserved)
{
UnityEngine.Object.Destroy(overlayGo);
UnityEngine.Object.Destroy(camGo);
// ExitPlayModeIfNeeded ([UnityTearDown]) restores edit mode after this ignore.
Assert.Ignore("Overlay not observable in this environment's game-view capture; screen path still verified.");
}
// Tone round-trip: the authored grays must read back near their values. The old AsTexture
// path double-encoded gamma in Linear projects (128 washed out to ~188). Orientation: the
// light half must be on top — CaptureScreenshotIntoRenderTexture returns GPU-UV-oriented
// content that the command must un-flip on D3D/Metal/Vulkan. (AUTHAPI-10 review)
Assert.That(stats.topLuma, Is.EqualTo((float)OverlayTopGray.r).Within(20f),
"top half should be the light gray at its authored tone (flip and/or gamma bug otherwise)");
Assert.That(stats.bottomLuma, Is.EqualTo((float)OverlayBottomGray.r).Within(20f),
"bottom half should be the dark gray at its authored tone (flip and/or gamma bug otherwise)");
// Overlay is observable here: prove it shows in a screen capture but NOT a camera capture
// (the camera clears to solid blue and renders nothing, so it contains no neutral gray).
var cameraShot = CaptureCommands.CaptureGameView(64, 64, camera: "CLI199_ScreenCam", source: "camera");
Assert.Less(GrayStats(cameraShot.Base64).fraction, 0.2f, "overlay UI must be absent from a camera capture");
UnityEngine.Object.Destroy(overlayGo);
UnityEngine.Object.Destroy(camGo);
yield return new ExitPlayMode();
}
/// <summary>
/// Decode the PNG and measure its neutral-gray content: the fraction of pixels that are
/// near-neutral (r≈g≈b, away from black/white clip), plus the mean luminance of the image's
/// top and bottom halves. Brightness-agnostic so gamma distortion shows up in the luminance
/// values (not as a hidden overlay), and per-half so a vertical flip swaps them detectably.
/// </summary>
private static (float fraction, float topLuma, float bottomLuma) GrayStats(string base64)
{
var tex = new Texture2D(2, 2);
try
{
Assert.IsTrue(tex.LoadImage(Convert.FromBase64String(base64)), "capture PNG should decode");
var width = tex.width;
var height = tex.height;
var pixels = tex.GetPixels32();
var gray = 0;
long topSum = 0, bottomSum = 0;
int topCount = 0, bottomCount = 0;
for (var y = 0; y < height; y++)
{
for (var x = 0; x < width; x++)
{
var p = pixels[y * width + x];
if (Mathf.Abs(p.r - p.g) <= 12 && Mathf.Abs(p.g - p.b) <= 12 && p.r >= 40 && p.r <= 240)
gray++;
// GetPixels32 row 0 is the image's BOTTOM row.
var luma = (p.r + p.g + p.b) / 3;
if (y >= height / 2) { topSum += luma; topCount++; }
else { bottomSum += luma; bottomCount++; }
}
}
return (pixels.Length == 0 ? 0f : (float)gray / pixels.Length,
topCount == 0 ? 0f : (float)topSum / topCount,
bottomCount == 0 ? 0f : (float)bottomSum / bottomCount);
}
finally
{
UnityEngine.Object.DestroyImmediate(tex);
}
}
#endregion
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7469ce0ae01ac444280398a19366fdfc
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: