using System; using System.Collections; using System.Collections.Generic; using System.IO; using UnityEngine; using UnityEngine.Experimental.Rendering; using UnityEngine.Rendering; namespace BugReporter { /// /// Keeps a rolling buffer of the last N seconds of the screen as small JPEG frames (a "flipbook"), so a /// report can carry a short clip of what led to the bug. Capture is throttled to a low fps and uses an /// async GPU readback so it doesn't stall the render thread. Opt-in () — /// it's a continuous cost, so keep it to tester builds. /// internal sealed class ClipRecorder : MonoBehaviour { private byte[][] _ring; private int _next; private bool _wrapped; private readonly object _lock = new object(); private int _maxW, _quality, _inFlight; private float _interval, _lastCapture; private bool _flipY; /// The fps actually used (after clamping) — travels with the report so the dashboard plays /// the clip at real speed instead of guessing. public int Fps { get; private set; } public static ClipRecorder Create() { var cfg = BugReporter.Config; var go = new GameObject("[BugReporter.Clip]"); DontDestroyOnLoad(go); go.hideFlags = HideFlags.HideInHierarchy; var r = go.AddComponent(); int fps = Mathf.Clamp(cfg.ClipFps, 1, 15); int frames = Mathf.Clamp(Mathf.Max(1, cfg.ClipSeconds) * fps, fps, 900); r._ring = new byte[frames][]; r._interval = 1f / fps; r.Fps = fps; r._quality = Mathf.Clamp(cfg.ClipQuality, 1, 100); r._maxW = Mathf.Clamp(cfg.ClipMaxWidth, 120, 1280); // CaptureScreenshotIntoRenderTexture writes in the framebuffer's native orientation: top-origin // APIs (Metal/D3D/Vulkan) land upside-down, OpenGL lands upright. graphicsUVStartsAtTop tells the // two apart, so the clip is the right way up without a per-platform toggle. r._flipY = cfg.ClipFlipY ?? SystemInfo.graphicsUVStartsAtTop; if (SystemInfo.supportsAsyncGPUReadback) { r.StartCoroutine(r.CaptureLoop()); Debug.Log($"[BugReporter] Clip recording on — last {cfg.ClipSeconds}s at {fps}fps ({frames} frames), " + $"flipY={r._flipY} ({(cfg.ClipFlipY.HasValue ? "forced" : $"auto, {SystemInfo.graphicsDeviceType}")})."); if (frames > 300) { Debug.LogWarning($"[BugReporter] {frames} frames is a big ring — only the newest " + $"~{Mathf.Max(256 * 1024, cfg.ClipMaxBytes) / 1048576}MB is ever uploaded (the rest just holds RAM, " + $"roughly {frames * 35 / 1024}MB of it). A shorter ClipSeconds gets you the same clip for less."); } } else { Debug.LogWarning("[BugReporter] Async GPU readback unsupported — clip recording disabled on this device."); } return r; } private IEnumerator CaptureLoop() { var wait = new WaitForEndOfFrame(); while (true) { yield return wait; if (Time.unscaledTime - _lastCapture < _interval) continue; if (_inFlight > 1) continue; // don't let readbacks pile up under load _lastCapture = Time.unscaledTime; try { CaptureOne(); } catch (Exception e) { Debug.LogWarning($"[BugReporter] Clip capture skipped: {e.Message}"); } } } private void CaptureOne() { int sw = Screen.width, sh = Screen.height; if (sw <= 0 || sh <= 0) return; float scale = Mathf.Min(1f, (float)_maxW / sw); int tw = Mathf.Max(1, Mathf.RoundToInt(sw * scale)); int th = Mathf.Max(1, Mathf.RoundToInt(sh * scale)); var full = RenderTexture.GetTemporary(sw, sh, 0, RenderTextureFormat.ARGB32); ScreenCapture.CaptureScreenshotIntoRenderTexture(full); var small = RenderTexture.GetTemporary(tw, th, 0, RenderTextureFormat.ARGB32); if (_flipY) Graphics.Blit(full, small, new Vector2(1, -1), new Vector2(0, 1)); else Graphics.Blit(full, small); RenderTexture.ReleaseTemporary(full); _inFlight++; AsyncGPUReadback.Request(small, 0, TextureFormat.RGBA32, req => { _inFlight = Mathf.Max(0, _inFlight - 1); try { if (!req.hasError) { var data = req.GetData(); var jpg = ImageConversion.EncodeNativeArrayToJPG( data, GraphicsFormat.R8G8B8A8_UNorm, (uint)tw, (uint)th, 0, _quality); byte[] bytes = jpg.ToArray(); jpg.Dispose(); Push(bytes); } } catch (Exception e) { Debug.LogWarning($"[BugReporter] Clip frame encode failed: {e.Message}"); } finally { RenderTexture.ReleaseTemporary(small); } }); } private void Push(byte[] frame) { lock (_lock) { _ring[_next] = frame; _next = (_next + 1) % _ring.Length; if (_next == 0) _wrapped = true; } } /// /// Snapshot the buffered frames, oldest-to-newest, into one blob the backend can split: /// [uint32 count][uint32 len]×count][frame bytes…] (little-endian). Null when empty. /// public byte[] PackLatest() { var all = new List(); lock (_lock) { int count = _wrapped ? _ring.Length : _next; int start = _wrapped ? _next : 0; for (int i = 0; i < count; i++) { byte[] f = _ring[(start + i) % _ring.Length]; if (f != null) all.Add(f); } } if (all.Count == 0) return null; // Budget from the newest frame backwards. An oversized clip gets the whole report rejected by the // server (413) — screenshot and logs with it — so trim here instead, dropping the OLDEST seconds: // what happened right before the tester hit Report is the part worth keeping. int budget = Mathf.Max(256 * 1024, BugReporter.Config.ClipMaxBytes); int total = 4, first = all.Count; for (int i = all.Count - 1; i >= 0; i--) { int cost = all[i].Length + 4; // frame bytes + its length prefix if (total + cost > budget) break; total += cost; first = i; } if (first >= all.Count) return null; // even one frame doesn't fit — send no clip at all var frames = all.GetRange(first, all.Count - first); if (frames.Count < all.Count) { Debug.LogWarning($"[BugReporter] Clip trimmed {all.Count}→{frames.Count} frames " + $"(~{frames.Count / Mathf.Max(1, Fps)}s, {total / 1048576f:F1}MB) to fit ClipMaxBytes. " + "Lower ClipSeconds/ClipMaxWidth if you want the full window."); } using var ms = new MemoryStream(total); using var bw = new BinaryWriter(ms); // BinaryWriter is little-endian on every platform bw.Write((uint)frames.Count); foreach (var f in frames) bw.Write((uint)f.Length); foreach (var f in frames) bw.Write(f); bw.Flush(); return ms.ToArray(); } private void OnDestroy() => StopAllCoroutines(); } }