From 895e6d0f818109fbc8df132a165fef73aad2e310 Mon Sep 17 00:00:00 2001 From: Piotr Korzuszek Date: Fri, 23 May 2025 10:11:46 +0200 Subject: [PATCH 1/7] Optimization: Do not recreate array --- Runtime/Scripts/GameRecorder.cs | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/Runtime/Scripts/GameRecorder.cs b/Runtime/Scripts/GameRecorder.cs index 238d29c..6b2e62b 100644 --- a/Runtime/Scripts/GameRecorder.cs +++ b/Runtime/Scripts/GameRecorder.cs @@ -2,6 +2,7 @@ using System.Collections; using System.IO; using UnityEngine.Serialization; +using Unity.Collections; namespace BetaHub { @@ -173,6 +174,8 @@ private IEnumerator CaptureFrames() RenderTexture scaledRT = null; Texture2D scaledTexture = null; + byte[] frameData = null; + if (_outputWidth != _gameWidth || _outputHeight != _gameHeight) { scaledRT = new RenderTexture(_outputWidth, _outputHeight, 0, RenderTextureFormat.ARGB32); @@ -191,8 +194,6 @@ private IEnumerator CaptureFrames() _screenShot.ReadPixels(new Rect(0, 0, _gameWidth, _gameHeight), 0, 0); _screenShot.Apply(); - byte[] frameData; - if (scaledRT != null) { // 2a. Blit (scale) to RT @@ -208,13 +209,13 @@ private IEnumerator CaptureFrames() var painter = new TexturePainter(scaledTexture); painter.DrawNumber(5, 5, (int)_fps, Color.white, 2); - frameData = scaledTexture.GetRawTextureData(); + SmartCopyRawDataTextureToByteArray(scaledTexture, ref frameData); } else { // 2b. Draw overlays on the original screenshot _texturePainter.DrawNumber(5, 5, (int)_fps, Color.white, 2); - frameData = _screenShot.GetRawTextureData(); + SmartCopyRawDataTextureToByteArray(_screenShot, ref frameData); } _videoEncoder.AddFrame(frameData); @@ -225,5 +226,26 @@ private IEnumerator CaptureFrames() if (scaledTexture != null) Destroy(scaledTexture); if (scaledRT != null) scaledRT.Release(); } + + // tries to copy the raw data texture to byte array and resize the byte array + // if it is of a different size + private void SmartCopyRawDataTextureToByteArray(Texture2D texture, ref byte[] data) + { + NativeArray rawData = texture.GetRawTextureData(); + if (data == null || rawData.Length != data.Length) + { + #if BETAHUB_DEBUG + UnityEngine.Debug.Log($"Recording: Resizing buffer byte array from {data?.Length ?? 0} to {rawData.Length}"); + #endif + + var buffer = new byte[rawData.Length]; + rawData.CopyTo(buffer); + data = buffer; + } + else + { + rawData.CopyTo(data); + } + } } } \ No newline at end of file From 899fd0659062f10fbd48625777a2f7594d487194 Mon Sep 17 00:00:00 2001 From: Piotr Korzuszek Date: Mon, 26 May 2025 19:56:07 +0200 Subject: [PATCH 2/7] Remove redundant Texture2D.Apply() calls --- Runtime/Scripts/GameRecorder.cs | 5 ++--- Runtime/Scripts/TexturePainter.cs | 7 +++++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/Runtime/Scripts/GameRecorder.cs b/Runtime/Scripts/GameRecorder.cs index 6b2e62b..5fb30ff 100644 --- a/Runtime/Scripts/GameRecorder.cs +++ b/Runtime/Scripts/GameRecorder.cs @@ -202,19 +202,18 @@ private IEnumerator CaptureFrames() // 2b. Read pixels from scaled RT RenderTexture.active = scaledRT; scaledTexture.ReadPixels(new Rect(0, 0, _outputWidth, _outputHeight), 0, 0); - scaledTexture.Apply(); RenderTexture.active = null; // 3a. Draw overlays on the scaled texture var painter = new TexturePainter(scaledTexture); - painter.DrawNumber(5, 5, (int)_fps, Color.white, 2); + painter.DrawNumber(5, 5, (int)_fps, Color.white, 2, false); SmartCopyRawDataTextureToByteArray(scaledTexture, ref frameData); } else { // 2b. Draw overlays on the original screenshot - _texturePainter.DrawNumber(5, 5, (int)_fps, Color.white, 2); + _texturePainter.DrawNumber(5, 5, (int)_fps, Color.white, 2, false); SmartCopyRawDataTextureToByteArray(_screenShot, ref frameData); } diff --git a/Runtime/Scripts/TexturePainter.cs b/Runtime/Scripts/TexturePainter.cs index 1633dc1..f1e34c0 100644 --- a/Runtime/Scripts/TexturePainter.cs +++ b/Runtime/Scripts/TexturePainter.cs @@ -132,7 +132,7 @@ private void DrawRectangle(int x, int y, int width, int height, Color color) } } - public void DrawNumber(int x, int y, int number, Color color, int scale = 1) + public void DrawNumber(int x, int y, int number, Color color, int scale = 1, bool apply = true) { string numberString = number.ToString(); int digitWidth = 3; @@ -163,7 +163,10 @@ public void DrawNumber(int x, int y, int number, Color color, int scale = 1) } // Apply the changes to the texture - texture.Apply(); + if (apply) + { + texture.Apply(); + } } } } \ No newline at end of file From c9d78ad57b258b3297efe79190d7cbdaf1d694d0 Mon Sep 17 00:00:00 2001 From: Piotr Korzuszek Date: Fri, 30 May 2025 15:51:38 +0200 Subject: [PATCH 3/7] New capture method --- Runtime/Prefabs/BugReportingFormCanvas.prefab | 89 ++------ Runtime/Scripts/GameRecorder.cs | 197 ++++++++++++------ Runtime/Scripts/TexturePainter.cs | 109 ++++++++-- Runtime/Scripts/VideoEncoder.cs | 1 - 4 files changed, 247 insertions(+), 149 deletions(-) diff --git a/Runtime/Prefabs/BugReportingFormCanvas.prefab b/Runtime/Prefabs/BugReportingFormCanvas.prefab index 64e925b..2e82cd8 100644 --- a/Runtime/Prefabs/BugReportingFormCanvas.prefab +++ b/Runtime/Prefabs/BugReportingFormCanvas.prefab @@ -32,7 +32,6 @@ RectTransform: - {fileID: 977477087176976246} - {fileID: 4000851359225435227} m_Father: {fileID: 3630024249061363983} - m_RootOrder: 9 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 1} m_AnchorMax: {x: 1, y: 1} @@ -118,7 +117,6 @@ RectTransform: m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 7066460814585062359} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0.5, y: 0.5} m_AnchorMax: {x: 0.5, y: 0.5} @@ -195,7 +193,6 @@ RectTransform: m_Children: - {fileID: 1687794342509650617} m_Father: {fileID: 1503495149724826169} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 1} m_AnchorMax: {x: 0, y: 1} @@ -272,7 +269,6 @@ RectTransform: m_Children: - {fileID: 5559154134761285907} m_Father: {fileID: 6900125880984329700} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 1} m_AnchorMax: {x: 0, y: 1} @@ -349,7 +345,6 @@ RectTransform: m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 6900125880984329700} - m_RootOrder: 1 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0.5, y: 0.5} m_AnchorMax: {x: 0.5, y: 0.5} @@ -497,7 +492,6 @@ RectTransform: m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 8830360361489452988} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0.5, y: 0.5} m_AnchorMax: {x: 0.5, y: 0.5} @@ -573,7 +567,6 @@ RectTransform: m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 1503495149724826169} - m_RootOrder: 1 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 0} m_AnchorMax: {x: 1, y: 1} @@ -653,7 +646,6 @@ RectTransform: m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 3571735674788685447} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0.5, y: 0.5} m_AnchorMax: {x: 0.5, y: 0.5} @@ -729,7 +721,6 @@ RectTransform: m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 2032552138566355961} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 1} m_AnchorMax: {x: 1, y: 1} @@ -865,7 +856,6 @@ RectTransform: - {fileID: 7066460814585062359} - {fileID: 7996142808180909739} m_Father: {fileID: 2032552138566355961} - m_RootOrder: 5 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0.5, y: 0.5} m_AnchorMax: {x: 0.5, y: 0.5} @@ -951,7 +941,6 @@ RectTransform: m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 485512187738098592} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 0} m_AnchorMax: {x: 1, y: 1} @@ -1086,7 +1075,6 @@ RectTransform: m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 4993008937618779998} - m_RootOrder: 1 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 0} m_AnchorMax: {x: 1, y: 1} @@ -1221,7 +1209,6 @@ RectTransform: m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 1741720219296545283} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 0} m_AnchorMax: {x: 1, y: 1} @@ -1356,7 +1343,6 @@ RectTransform: m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 1627904781333639061} - m_RootOrder: 1 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 0} m_AnchorMax: {x: 1, y: 1} @@ -1436,7 +1422,6 @@ RectTransform: m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 2032552138566355961} - m_RootOrder: 1 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 1} m_AnchorMax: {x: 1, y: 1} @@ -1576,7 +1561,6 @@ RectTransform: m_Children: - {fileID: 4993008937618779998} m_Father: {fileID: 2032552138566355961} - m_RootOrder: 2 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 1} m_AnchorMax: {x: 1, y: 1} @@ -1752,7 +1736,6 @@ RectTransform: m_Children: - {fileID: 3630024247477140643} m_Father: {fileID: 3630024248915368463} - m_RootOrder: 2 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 1, y: 0} m_AnchorMax: {x: 1, y: 0} @@ -1872,7 +1855,6 @@ RectTransform: m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 3630024247434959543} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 0} m_AnchorMax: {x: 1, y: 1} @@ -2010,7 +1992,6 @@ RectTransform: m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 3630024247696638879} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 0} m_AnchorMax: {x: 1, y: 1} @@ -2174,7 +2155,6 @@ RectTransform: - {fileID: 3630024248915368463} - {fileID: 2032552138566355961} m_Father: {fileID: 0} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 0} m_AnchorMax: {x: 0, y: 0} @@ -2200,6 +2180,7 @@ Canvas: m_SortingBucketNormalizedSize: 0 m_VertexColorAlwaysGammaSpace: 0 m_AdditionalShaderChannelsFlag: 25 + m_UpdateRectTransformForStandalone: 0 m_SortingLayerID: 0 m_SortingOrder: 32767 m_TargetDisplay: 0 @@ -2255,23 +2236,25 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: 7901aaefc5f754eecb87f4d46d21fcf7, type: 3} m_Name: m_EditorClassIdentifier: - bugReportPanel: {fileID: 3630024249061363980} - descriptionField: {fileID: 3630024249523897025} - stepsField: {fileID: 3630024249440770398} + BugReportPanel: {fileID: 3630024249061363980} + DescriptionField: {fileID: 3630024249523897025} + StepsField: {fileID: 3630024249440770398} IncludeVideoToggle: {fileID: 827000258257465722} IncludeScreenshotToggle: {fileID: 4530607169642399183} IncludePlayerLogToggle: {fileID: 522816198355007551} - submitButton: {fileID: 3630024248266512409} - closeButton: {fileID: 6839878271182632936} - messagePanel: {fileID: 3630024248915368460} - messagePanelUI: {fileID: 3630024248915368456} - reportSubmittedUI: {fileID: 8165929657888516934} - submitEndpoint: https://app.betahub.io - projectID: pr-5287510306 - authToken: tkn-15e6fbc1470613d5cfd2199edbde52157379b8c6dcd365441eabf8fea62d76a7 - shortcutKey: 293 - includePlayerLog: 1 - includeVideo: 1 + SubmitButton: {fileID: 3630024248266512409} + CloseButton: {fileID: 6839878271182632936} + MessagePanel: {fileID: 3630024248915368460} + MessagePanelUI: {fileID: 3630024248915368456} + MediaUploadType: 0 + ReportSubmittedUI: {fileID: 8165929657888516934} + SubmitEndpoint: https://app.betahub.io + ProjectID: pr-5287510306 + AuthToken: tkn-15e6fbc1470613d5cfd2199edbde52157379b8c6dcd365441eabf8fea62d76a7 + DefaultEmailAddress: + ShortcutKey: 293 + IncludePlayerLog: 1 + IncludeVideo: 1 OnBugReportWindowShown: m_PersistentCalls: m_Calls: [] @@ -2304,8 +2287,11 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: d140507fc9ceb4e6ea7621a4a80a0d24, type: 3} m_Name: m_EditorClassIdentifier: - frameRate: 30 + FrameRate: 30 RecordingDuration: 60 + DownscaleVideo: 1 + MaxVideoHeight: 768 + MaxVideoWidth: 1280 DebugMode: 0 --- !u!114 &7122726013767127902 MonoBehaviour: @@ -2374,7 +2360,6 @@ RectTransform: - {fileID: 3630024247595246889} - {fileID: 3630024249088894872} m_Father: {fileID: 3630024249440770397} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 0} m_AnchorMax: {x: 1, y: 1} @@ -2426,7 +2411,6 @@ RectTransform: m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 3630024248915368463} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 0} m_AnchorMax: {x: 1, y: 1} @@ -2562,7 +2546,6 @@ RectTransform: - {fileID: 3630024248099868723} - {fileID: 3630024248145179427} m_Father: {fileID: 3630024249523897028} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 0} m_AnchorMax: {x: 1, y: 1} @@ -2614,7 +2597,6 @@ RectTransform: m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 3630024248266512410} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 0} m_AnchorMax: {x: 1, y: 1} @@ -2750,7 +2732,6 @@ RectTransform: m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 3630024247824868509} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 0} m_AnchorMax: {x: 1, y: 1} @@ -2905,7 +2886,6 @@ RectTransform: m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 3630024247824868509} - m_RootOrder: 1 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 0} m_AnchorMax: {x: 1, y: 1} @@ -3040,7 +3020,6 @@ RectTransform: m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 3630024249061363983} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 1} m_AnchorMax: {x: 0, y: 1} @@ -3177,7 +3156,6 @@ RectTransform: m_Children: - {fileID: 3630024248052450034} m_Father: {fileID: 3630024249061363983} - m_RootOrder: 4 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 1, y: 0} m_AnchorMax: {x: 1, y: 0} @@ -3297,7 +3275,6 @@ RectTransform: m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 3630024249061363983} - m_RootOrder: 5 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 1} m_AnchorMax: {x: 0, y: 1} @@ -3436,7 +3413,6 @@ RectTransform: - {fileID: 3630024249432119131} - {fileID: 3630024247434959543} m_Father: {fileID: 3630024247677609247} - m_RootOrder: 1 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0.5, y: 0.5} m_AnchorMax: {x: 0.5, y: 0.5} @@ -3537,7 +3513,6 @@ RectTransform: - {fileID: 4184646509822323759} - {fileID: 1503495149724826169} m_Father: {fileID: 3630024247677609247} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0.5, y: 0.5} m_AnchorMax: {x: 0.5, y: 0.5} @@ -3613,7 +3588,6 @@ RectTransform: m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 3630024247696638879} - m_RootOrder: 1 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 0} m_AnchorMax: {x: 1, y: 1} @@ -3748,7 +3722,6 @@ RectTransform: m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 3630024249061363983} - m_RootOrder: 2 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0.5, y: 1} m_AnchorMax: {x: 0.5, y: 1} @@ -3883,7 +3856,6 @@ RectTransform: m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 3630024248915368463} - m_RootOrder: 1 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 0} m_AnchorMax: {x: 1, y: 1} @@ -4020,7 +3992,6 @@ RectTransform: m_Children: - {fileID: 3630024247696638879} m_Father: {fileID: 3630024249061363983} - m_RootOrder: 3 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 1} m_AnchorMax: {x: 1, y: 1} @@ -4196,7 +4167,6 @@ RectTransform: m_Children: - {fileID: 3630024247824868509} m_Father: {fileID: 3630024249061363983} - m_RootOrder: 1 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 1} m_AnchorMax: {x: 1, y: 1} @@ -4378,7 +4348,6 @@ RectTransform: - {fileID: 6689157203348440904} - {fileID: 6900125880984329700} m_Father: {fileID: 3630024247677609247} - m_RootOrder: 2 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0.5, y: 0.5} m_AnchorMax: {x: 0.5, y: 0.5} @@ -4470,7 +4439,6 @@ RectTransform: m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 852619800817139833} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0.5, y: 0.5} m_AnchorMax: {x: 0.5, y: 0.5} @@ -4546,7 +4514,6 @@ RectTransform: m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 3713366576188347011} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 0} m_AnchorMax: {x: 1, y: 1} @@ -4684,7 +4651,6 @@ RectTransform: - {fileID: 3571735674788685447} - {fileID: 7344009242182697266} m_Father: {fileID: 2032552138566355961} - m_RootOrder: 6 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0.5, y: 0.5} m_AnchorMax: {x: 0.5, y: 0.5} @@ -4772,7 +4738,6 @@ RectTransform: m_Children: - {fileID: 7386148524240149116} m_Father: {fileID: 3630024249061363983} - m_RootOrder: 6 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 1, y: 1} m_AnchorMax: {x: 1, y: 1} @@ -4893,7 +4858,6 @@ RectTransform: m_Children: - {fileID: 3988553454410020858} m_Father: {fileID: 1627904781333639061} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 1} m_AnchorMax: {x: 0, y: 1} @@ -4969,7 +4933,6 @@ RectTransform: m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 977477087176976246} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0.5, y: 0.5} m_AnchorMax: {x: 0.5, y: 0.5} @@ -5046,7 +5009,6 @@ RectTransform: - {fileID: 852619800817139833} - {fileID: 2793722129197531301} m_Father: {fileID: 3630024249061363983} - m_RootOrder: 8 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 1} m_AnchorMax: {x: 1, y: 1} @@ -5133,7 +5095,6 @@ RectTransform: m_Children: - {fileID: 7647189051541875379} m_Father: {fileID: 4184646509822323759} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 1} m_AnchorMax: {x: 0, y: 1} @@ -5211,7 +5172,6 @@ RectTransform: m_Children: - {fileID: 5341077937392854438} m_Father: {fileID: 2032552138566355961} - m_RootOrder: 3 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 1, y: 0} m_AnchorMax: {x: 1, y: 0} @@ -5332,7 +5292,6 @@ RectTransform: - {fileID: 8830360361489452988} - {fileID: 3220442243219187897} m_Father: {fileID: 3630024249061363983} - m_RootOrder: 7 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 1} m_AnchorMax: {x: 1, y: 1} @@ -5419,7 +5378,6 @@ RectTransform: m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 6689157203348440904} - m_RootOrder: 1 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0.5, y: 0.5} m_AnchorMax: {x: 0.5, y: 0.5} @@ -5568,7 +5526,6 @@ RectTransform: m_Children: - {fileID: 1908213462890504975} m_Father: {fileID: 6689157203348440904} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 1} m_AnchorMax: {x: 0, y: 1} @@ -5645,7 +5602,6 @@ RectTransform: m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 4993008937618779998} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 0} m_AnchorMax: {x: 1, y: 1} @@ -5800,7 +5756,6 @@ RectTransform: m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 4184646509822323759} - m_RootOrder: 1 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 0} m_AnchorMax: {x: 1, y: 1} @@ -5882,7 +5837,6 @@ RectTransform: m_Children: - {fileID: 1140490455101815007} m_Father: {fileID: 2032552138566355961} - m_RootOrder: 4 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 1, y: 0} m_AnchorMax: {x: 1, y: 0} @@ -6003,7 +5957,6 @@ RectTransform: - {fileID: 8246874345483132745} - {fileID: 5924505674562940352} m_Father: {fileID: 300062612667332752} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 0} m_AnchorMax: {x: 1, y: 1} diff --git a/Runtime/Scripts/GameRecorder.cs b/Runtime/Scripts/GameRecorder.cs index 5fb30ff..38bf113 100644 --- a/Runtime/Scripts/GameRecorder.cs +++ b/Runtime/Scripts/GameRecorder.cs @@ -3,6 +3,7 @@ using System.IO; using UnityEngine.Serialization; using Unity.Collections; +using UnityEngine.Rendering; namespace BetaHub { @@ -22,7 +23,14 @@ public class GameRecorder : MonoBehaviour [Tooltip("The maximum width of the video. The video will be downscaled if the screen resolution is higher.")] public int MaxVideoWidth = 1920; - private Texture2D _screenShot; + // Optimized: Use RenderTextures for efficient GPU-based capture + private RenderTexture _captureRT; + private RenderTexture _fullScreenRT; // For capturing full screen when downscaling + private byte[] _rgbConversionBuffer; // Reusable buffer for RGBA to RGB conversion + + // GC Optimization: Reusable buffers to avoid allocations in capture loop + private byte[] _frameDataBuffer; // Reusable buffer for frame data from GPU + private byte[] _rgbaDataBuffer; // Reusable buffer for RGBA texture data public bool IsRecording { get; private set; } #if ENABLE_IL2CPP && !ENABLE_BETAHUB_FFMPEG @@ -32,7 +40,6 @@ public class GameRecorder : MonoBehaviour #endif private VideoEncoder _videoEncoder; - private TexturePainter _texturePainter; private int _gameWidth; private int _gameHeight; @@ -56,9 +63,9 @@ void Start() return; #endif - // Adjust the game resolution to be divisible by 2 - _gameWidth = Screen.width % 2 == 0 ? Screen.width : Screen.width - 1; - _gameHeight = Screen.height % 2 == 0 ? Screen.height : Screen.height - 1; + // Adjust the game resolution to be divisible by 4 + _gameWidth = Screen.width - (Screen.width % 4); + _gameHeight = Screen.height - (Screen.height % 4); // Determine target (output) resolution _outputWidth = _gameWidth; @@ -73,16 +80,32 @@ void Start() _outputWidth = MaxVideoWidth; _outputHeight = Mathf.RoundToInt(_outputWidth / aspect); } - // Ensure both dimensions are even - _outputWidth -= _outputWidth % 2; - _outputHeight -= _outputHeight % 2; + // Ensure both dimensions are multiples of 4 (for AsyncGPUReadback) + _outputWidth -= _outputWidth % 4; + _outputHeight -= _outputHeight % 4; UnityEngine.Debug.Log($"Video is to be downscaled to {_outputWidth}x{_outputHeight}"); } - // Create a Texture2D with the adjusted resolution - _screenShot = new Texture2D(_gameWidth, _gameHeight, TextureFormat.RGB24, false); - _texturePainter = new TexturePainter(_screenShot); + // Optimized: Create RenderTexture once at output resolution (handles downscaling automatically) + _captureRT = new RenderTexture(_outputWidth, _outputHeight, 0, RenderTextureFormat.ARGB32); + _captureRT.Create(); + + // If we're downscaling, we need a full-screen texture to capture from first + if (DownscaleVideo && (_gameWidth != _outputWidth || _gameHeight != _outputHeight)) + { + _fullScreenRT = new RenderTexture(_gameWidth, _gameHeight, 0, RenderTextureFormat.ARGB32); + _fullScreenRT.Create(); + } + + // Initialize RGB conversion buffer + _rgbConversionBuffer = new byte[_outputWidth * _outputHeight * 3]; // RGB24 format + + // GC Optimization: Initialize reusable buffers and textures + int expectedFrameSize = _outputWidth * _outputHeight * 4; // RGBA32 format + _frameDataBuffer = new byte[expectedFrameSize]; + _rgbaDataBuffer = new byte[expectedFrameSize]; + IsRecording = false; string outputDirectory = Path.Combine(Application.persistentDataPath, "BH_Recording"); @@ -100,6 +123,20 @@ void Start() void OnDestroy() { + // Optimized: Proper cleanup of RenderTextures + if (_captureRT != null) + { + _captureRT.Release(); + _captureRT = null; + } + if (_fullScreenRT != null) + { + _fullScreenRT.Release(); + _fullScreenRT = null; + } + _rgbConversionBuffer = null; + _frameDataBuffer = null; + _rgbaDataBuffer = null; if (_videoEncoder != null) // can be null since Start() may not have been called { _videoEncoder.Dispose(); @@ -166,22 +203,10 @@ public string StopRecordingAndSaveLastMinute() private IEnumerator CaptureFrames() { #if BETAHUB_DEBUG - UnityEngine.Debug.Log($"Game resolution: {_gameWidth}x{_gameHeight}"); UnityEngine.Debug.Log($"Output resolution: {_outputWidth}x{_outputHeight}"); - #endif - RenderTexture scaledRT = null; - Texture2D scaledTexture = null; - byte[] frameData = null; - - if (_outputWidth != _gameWidth || _outputHeight != _gameHeight) - { - scaledRT = new RenderTexture(_outputWidth, _outputHeight, 0, RenderTextureFormat.ARGB32); - scaledTexture = new Texture2D(_outputWidth, _outputHeight, TextureFormat.RGB24, false); - } - while (IsRecording) { yield return new WaitForEndOfFrame(); @@ -190,61 +215,111 @@ private IEnumerator CaptureFrames() { _nextCaptureTime += _captureInterval; - // 1. Capture the full-res frame - _screenShot.ReadPixels(new Rect(0, 0, _gameWidth, _gameHeight), 0, 0); - _screenShot.Apply(); - - if (scaledRT != null) + // Optimized: Capture screen with proper scaling support + if (_fullScreenRT != null) { - // 2a. Blit (scale) to RT - Graphics.Blit(_screenShot, scaledRT); - - // 2b. Read pixels from scaled RT - RenderTexture.active = scaledRT; - scaledTexture.ReadPixels(new Rect(0, 0, _outputWidth, _outputHeight), 0, 0); - RenderTexture.active = null; - - // 3a. Draw overlays on the scaled texture - var painter = new TexturePainter(scaledTexture); - painter.DrawNumber(5, 5, (int)_fps, Color.white, 2, false); - - SmartCopyRawDataTextureToByteArray(scaledTexture, ref frameData); + // First capture full screen + Graphics.Blit(null, _fullScreenRT); + // Then scale down to output resolution + Graphics.Blit(_fullScreenRT, _captureRT); } else { - // 2b. Draw overlays on the original screenshot - _texturePainter.DrawNumber(5, 5, (int)_fps, Color.white, 2, false); - SmartCopyRawDataTextureToByteArray(_screenShot, ref frameData); + // Direct capture when no scaling needed + Graphics.Blit(null, _captureRT); } - _videoEncoder.AddFrame(frameData); + // Optimized: Use AsyncGPUReadback for non-blocking frame capture + AsyncGPUReadback.Request(_captureRT, 0, OnCompleteReadback); } } - - // Clean up if needed - if (scaledTexture != null) Destroy(scaledTexture); - if (scaledRT != null) scaledRT.Release(); } - // tries to copy the raw data texture to byte array and resize the byte array - // if it is of a different size - private void SmartCopyRawDataTextureToByteArray(Texture2D texture, ref byte[] data) + // Optimized: Async callback for GPU readback completion + private void OnCompleteReadback(AsyncGPUReadbackRequest request) { - NativeArray rawData = texture.GetRawTextureData(); - if (data == null || rawData.Length != data.Length) + if (request.hasError) { #if BETAHUB_DEBUG - UnityEngine.Debug.Log($"Recording: Resizing buffer byte array from {data?.Length ?? 0} to {rawData.Length}"); + UnityEngine.Debug.LogError("AsyncGPUReadback request failed"); #endif - - var buffer = new byte[rawData.Length]; - rawData.CopyTo(buffer); - data = buffer; + return; } - else + + if (!IsRecording) return; // Recording might have stopped while waiting for readback + + // Get the raw data from GPU using safe copy to avoid allocation + var rawData = request.GetData(); + SafeCopyNativeArrayToByteArray(rawData, ref _frameDataBuffer); + + // Apply FPS overlay and get the processed RGB data + var processedFrameData = ApplyFPSOverlay(_frameDataBuffer, _outputWidth, _outputHeight); + + // Send frame to encoder + _videoEncoder.AddFrame(processedFrameData); + } + + // Helper method to apply FPS overlay to raw frame data + private byte[] ApplyFPSOverlay(byte[] frameData, int width, int height) + { + if (_rgbConversionBuffer == null) + { + // no buffer could mean that we're in the middle of shutting down + return frameData; + } + + // Copy frameData to _rgbaDataBuffer to avoid modifying the original + if (_rgbaDataBuffer == null || _rgbaDataBuffer.Length != frameData.Length) + { + _rgbaDataBuffer = new byte[frameData.Length]; + } + System.Array.Copy(frameData, _rgbaDataBuffer, frameData.Length); + + // Create a byte buffer wrapper to work with the frame data directly + var bufferWrapper = new ByteBufferWrapper(_rgbaDataBuffer, width, height, 4); // RGBA format + + // Draw FPS overlay directly on the buffer + // flipY=true means Y=0 is at the bottom, so coordinates work like mathematical coordinates + var painter = new TexturePainter(bufferWrapper, flipY: true); + painter.DrawNumber(5, 5, (int)_fps, Color.white, 2); // This will draw near bottom-left corner + + // Convert RGBA32 to RGB24 by removing alpha channel + for (int i = 0, j = 0; i < _rgbaDataBuffer.Length; i += 4, j += 3) { - rawData.CopyTo(data); + _rgbConversionBuffer[j] = _rgbaDataBuffer[i]; // R + _rgbConversionBuffer[j + 1] = _rgbaDataBuffer[i + 1]; // G + _rgbConversionBuffer[j + 2] = _rgbaDataBuffer[i + 2]; // B + // Skip alpha channel (_rgbaDataBuffer[i + 3]) } + + return _rgbConversionBuffer; + } + + /// + /// Safely copies data from a NativeArray to a byte array, resizing the target array if necessary. + /// This avoids GC allocations by reusing existing arrays when possible. + /// + /// The source NativeArray to copy from + /// The target byte array to copy to (will be resized if needed) + private void SafeCopyNativeArrayToByteArray(NativeArray source, ref byte[] target) + { + int requiredSize = source.Length; + + // Resize target array if it's null or too small + if (target == null || target.Length != requiredSize) + { + int oldSize = target?.Length ?? 0; + + target = new byte[requiredSize]; + #if BETAHUB_DEBUG + UnityEngine.Debug.Log($"Resized buffer from {oldSize} to {requiredSize} bytes"); + #endif + } + + // Use CopyTo for efficient copying without allocation + + source.CopyTo(target); + } } } \ No newline at end of file diff --git a/Runtime/Scripts/TexturePainter.cs b/Runtime/Scripts/TexturePainter.cs index f1e34c0..a6d49cf 100644 --- a/Runtime/Scripts/TexturePainter.cs +++ b/Runtime/Scripts/TexturePainter.cs @@ -2,9 +2,71 @@ namespace BetaHub { + /// + /// Wrapper for byte buffer that provides pixel access without needing to know color component offsets + /// + public class ByteBufferWrapper + { + private byte[] buffer; + private int width; + private int height; + private int bytesPerPixel; + + public ByteBufferWrapper(byte[] buffer, int width, int height, int bytesPerPixel = 4) + { + this.buffer = buffer; + this.width = width; + this.height = height; + this.bytesPerPixel = bytesPerPixel; + } + + public void SetPixel(int x, int y, Color color) + { + if (x < 0 || x >= width || y < 0 || y >= height) return; + + int index = (y * width + x) * bytesPerPixel; + if (index + bytesPerPixel - 1 >= buffer.Length) return; + + // RGBA format + buffer[index] = (byte)(color.r * 255); // R + buffer[index + 1] = (byte)(color.g * 255); // G + buffer[index + 2] = (byte)(color.b * 255); // B + if (bytesPerPixel == 4) + { + buffer[index + 3] = (byte)(color.a * 255); // A + } + } + + public Color GetPixel(int x, int y) + { + if (x < 0 || x >= width || y < 0 || y >= height) return Color.clear; + + int index = (y * width + x) * bytesPerPixel; + if (index + bytesPerPixel - 1 >= buffer.Length) return Color.clear; + + float r = buffer[index] / 255f; + float g = buffer[index + 1] / 255f; + float b = buffer[index + 2] / 255f; + float a = bytesPerPixel == 4 ? buffer[index + 3] / 255f : 1f; + + return new Color(r, g, b, a); + } + + public int Width => width; + public int Height => height; + } + + /// + /// TexturePainter provides drawing operations on byte buffers. + /// + /// Y Coordinate System: + /// - When flipY is false: Y=0 is at the top of the image, Y increases downward (standard screen coordinates) + /// - When flipY is true: Y=0 is at the bottom of the image, Y increases upward (mathematical/OpenGL coordinates) + /// public class TexturePainter { - private Texture2D texture; + private ByteBufferWrapper bufferWrapper; + private bool flipY; private static readonly byte[][] digits = new byte[][] { new byte[] // 0 @@ -89,9 +151,27 @@ public class TexturePainter } }; - public TexturePainter(Texture2D texture) + public TexturePainter(ByteBufferWrapper bufferWrapper, bool flipY = false) { - this.texture = texture; + this.bufferWrapper = bufferWrapper; + this.flipY = flipY; + } + + /// + /// Transforms Y coordinate based on flipY setting. + /// If flipY is true, flips Y coordinate so that Y=0 is at the bottom. + /// + private int TransformY(int y) + { + return flipY ? (bufferWrapper.Height - 1 - y) : y; + } + + /// + /// Sets a pixel with coordinate transformation applied. + /// + private void SetPixel(int x, int y, Color color) + { + bufferWrapper.SetPixel(x, TransformY(y), color); } public void DrawVerticalProgressBar(int x, int y, int width, int height, float progress, Color barColor, Color borderColor) @@ -107,12 +187,9 @@ public void DrawVerticalProgressBar(int x, int y, int width, int height, float p { for (int j = 1; j < progressHeight - 1; j++) { - texture.SetPixel(x + i, y + j, barColor); + SetPixel(x + i, y + j, barColor); } } - - // Apply the changes to the texture - texture.Apply(); } private void DrawRectangle(int x, int y, int width, int height, Color color) @@ -120,19 +197,19 @@ private void DrawRectangle(int x, int y, int width, int height, Color color) // Draw top and bottom borders for (int i = 0; i < width; i++) { - texture.SetPixel(x + i, y, color); - texture.SetPixel(x + i, y + height - 1, color); + SetPixel(x + i, y, color); + SetPixel(x + i, y + height - 1, color); } // Draw left and right borders for (int i = 0; i < height; i++) { - texture.SetPixel(x, y + i, color); - texture.SetPixel(x + width - 1, y + i, color); + SetPixel(x, y + i, color); + SetPixel(x + width - 1, y + i, color); } } - public void DrawNumber(int x, int y, int number, Color color, int scale = 1, bool apply = true) + public void DrawNumber(int x, int y, int number, Color color, int scale = 1) { string numberString = number.ToString(); int digitWidth = 3; @@ -154,19 +231,13 @@ public void DrawNumber(int x, int y, int number, Color color, int scale = 1, boo { for (int sx = 0; sx < scale; sx++) { - texture.SetPixel(x + (dx * scale) + sx + i * (digitWidth * scale + spacing), y + ((digitHeight - 1 - dy) * scale) + sy, color); + SetPixel(x + (dx * scale) + sx + i * (digitWidth * scale + spacing), y + ((digitHeight - 1 - dy) * scale) + sy, color); } } } } } } - - // Apply the changes to the texture - if (apply) - { - texture.Apply(); - } } } } \ No newline at end of file diff --git a/Runtime/Scripts/VideoEncoder.cs b/Runtime/Scripts/VideoEncoder.cs index dcaf262..49d753e 100644 --- a/Runtime/Scripts/VideoEncoder.cs +++ b/Runtime/Scripts/VideoEncoder.cs @@ -100,7 +100,6 @@ public void StartEncoding() "-s", $"{width}x{height}", "-r", frameRate.ToString(), "-i", "-", - "-vf", "vflip", "-c:v", encoder, "-pix_fmt", "yuv420p" }; From 979dd0469132d71d8f5acbf31b418b9d8a4b838d Mon Sep 17 00:00:00 2001 From: Piotr Korzuszek Date: Fri, 30 May 2025 16:36:53 +0200 Subject: [PATCH 4/7] VIdeoEncoder: Use unique instance directories --- Runtime/Scripts/VideoEncoder.cs | 151 ++++++++++++++++++++++++++++++-- 1 file changed, 146 insertions(+), 5 deletions(-) diff --git a/Runtime/Scripts/VideoEncoder.cs b/Runtime/Scripts/VideoEncoder.cs index dcaf262..007ab3e 100644 --- a/Runtime/Scripts/VideoEncoder.cs +++ b/Runtime/Scripts/VideoEncoder.cs @@ -4,9 +4,22 @@ using System.Collections.Generic; using UnityEngine; using System.Threading.Tasks; +using System; // Added for Guid namespace BetaHub { + /// + /// VideoEncoder handles video recording and encoding using FFmpeg. + /// + /// Bug Fix (File Access Conflicts): + /// - Uses unique instance directories to prevent conflicts between multiple VideoEncoder instances + /// - Implements retry logic with exponential backoff for file deletion operations + /// - Gracefully handles IOException when files are still in use by FFmpeg processes + /// - Properly cleans up instance directories when empty + /// + /// This fixes the issue where scene reloads would cause IOException spam due to + /// multiple VideoEncoder instances trying to access the same segment files. + /// public class VideoEncoder { private IProcessWrapper ffmpegProcess; @@ -25,6 +38,9 @@ public class VideoEncoder private float frameInterval; private bool debugMode; + + // Unique instance identifier to prevent conflicts between multiple instances + private readonly string instanceId; // if set to true, the encoding thread will pause adding new frames public bool IsPaused { get; set; } @@ -36,16 +52,19 @@ public class VideoEncoder private volatile bool _stopRequest = false; private volatile bool _stopRequestHandled = false; - public VideoEncoder(int width, int height, int frameRate, int recordingDurationSeconds, string outputDir = "Recording") + public VideoEncoder(int width, int height, int frameRate, int recordingDurationSeconds, string baseOutputDir = "Recording") { this.width = width; this.height = height; this.frameRate = frameRate; - this.outputDir = outputDir; + + // Create unique instance identifier and output directory + this.instanceId = Guid.NewGuid().ToString("N").Substring(0, 8); // Use first 8 characters of GUID + this.outputDir = Path.Combine(baseOutputDir, instanceId); this.outputPathPattern = Path.Combine(outputDir, "segment_%03d.mp4"); #if BETAHUB_DEBUG - UnityEngine.Debug.Log("Video output directory: " + outputDir); + UnityEngine.Debug.Log($"Video output directory: {outputDir} (instance: {instanceId})"); #endif Directory.CreateDirectory(outputDir); @@ -66,9 +85,16 @@ public void Dispose() if (ffmpegProcess != null && ffmpegProcess.IsRunning()) { SendStopRequestAndWait(); + + // Give the process a moment to fully release file handles + System.Threading.Thread.Sleep(100); } - RemoveAllSegments(); + // Clean up segments with retry logic for better file access handling + RemoveAllSegmentsWithRetry(); + + // Clean up the unique instance directory if it's empty + CleanupInstanceDirectory(); } public void StartEncoding() @@ -318,6 +344,9 @@ private void RemoveAllSegments() // cleanups only the old segments, keeping the ones with the latest segment numbers private void CleanupSegments() { + if (!Directory.Exists(outputDir)) + return; + var directoryInfo = new DirectoryInfo(outputDir); var filesToDelete = directoryInfo.GetFiles("segment_*.mp4") @@ -327,7 +356,40 @@ private void CleanupSegments() foreach (var file in filesToDelete) { - file.Delete(); + // Retry logic for file deletion to handle file access conflicts + int retryCount = 0; + const int maxRetries = 3; + const int retryDelayMs = 25; + + while (retryCount < maxRetries) + { + try + { + file.Delete(); + break; // Success, exit retry loop + } + catch (IOException ex) when (ex.Message.Contains("being used by another process")) + { + retryCount++; + if (retryCount >= maxRetries) + { + #if BETAHUB_DEBUG + UnityEngine.Debug.LogWarning($"Could not delete old segment file {file.Name} after {maxRetries} attempts. File may still be in use."); + #endif + } + else + { + System.Threading.Thread.Sleep(retryDelayMs * retryCount); + } + } + catch (System.Exception ex) + { + #if BETAHUB_DEBUG + UnityEngine.Debug.LogError($"Unexpected error deleting old segment file {file.Name}: {ex.Message}"); + #endif + break; // Don't retry for unexpected errors + } + } } } @@ -526,6 +588,85 @@ private static string GetFfmpegPath() return path; } + + private void RemoveAllSegmentsWithRetry() + { + if (!Directory.Exists(outputDir)) + return; + + var directoryInfo = new DirectoryInfo(outputDir); + var files = directoryInfo.GetFiles("segment_*.mp4"); + + foreach (var file in files) + { + // Retry logic for file deletion to handle file access conflicts + int retryCount = 0; + const int maxRetries = 5; + const int retryDelayMs = 50; + + while (retryCount < maxRetries) + { + try + { + file.Delete(); + break; // Success, exit retry loop + } + catch (IOException ex) when (ex.Message.Contains("being used by another process")) + { + retryCount++; + if (retryCount >= maxRetries) + { + UnityEngine.Debug.LogWarning($"Could not delete segment file {file.Name} after {maxRetries} attempts. File may still be in use. Error: {ex.Message}"); + } + else + { + #if BETAHUB_DEBUG + UnityEngine.Debug.Log($"Retrying deletion of {file.Name} (attempt {retryCount}/{maxRetries})"); + #endif + System.Threading.Thread.Sleep(retryDelayMs * retryCount); // Exponential backoff + } + } + catch (System.Exception ex) + { + UnityEngine.Debug.LogError($"Unexpected error deleting segment file {file.Name}: {ex.Message}"); + break; // Don't retry for unexpected errors + } + } + } + } + + private void CleanupInstanceDirectory() + { + try + { + if (Directory.Exists(outputDir)) + { + // Check if directory is empty (no files left) + var remainingFiles = Directory.GetFiles(outputDir); + var remainingDirs = Directory.GetDirectories(outputDir); + + if (remainingFiles.Length == 0 && remainingDirs.Length == 0) + { + Directory.Delete(outputDir); + #if BETAHUB_DEBUG + UnityEngine.Debug.Log($"Cleaned up empty instance directory: {outputDir}"); + #endif + } + else + { + #if BETAHUB_DEBUG + UnityEngine.Debug.Log($"Instance directory not empty, keeping: {outputDir} (files: {remainingFiles.Length}, dirs: {remainingDirs.Length})"); + #endif + } + } + } + catch (System.Exception ex) + { + #if BETAHUB_DEBUG + UnityEngine.Debug.LogWarning($"Could not clean up instance directory {outputDir}: {ex.Message}"); + #endif + } + } } public class CircularBuffer From 0feed3e57ce7ad99d4ffa0c12d3a92cd1f26f0e7 Mon Sep 17 00:00:00 2001 From: Piotr Korzuszek Date: Fri, 30 May 2025 18:23:00 +0200 Subject: [PATCH 5/7] GameRecorder: Can set custom source render texture --- Documentation.html | 1 + Runtime/Scripts/GameRecorder.cs | 52 ++++++++++++++++++++++++++------- 2 files changed, 42 insertions(+), 11 deletions(-) diff --git a/Documentation.html b/Documentation.html index a01b6f3..43b8cf7 100644 --- a/Documentation.html +++ b/Documentation.html @@ -130,6 +130,7 @@

Configuration

  • Frame Rate: Default is 30. Higher rates may increase file size and resource usage.
  • Recording Duration: Default is 60 seconds.
  • Downscale Video: Off by default. If enabled, the video will be downscaled to a maximum resolution (default: 1920x1080) if your game runs at a higher resolution. This helps reduce CPU usage and file size when recording on high-resolution displays. Enable this option in the inspector if you notice high CPU usage or stuttering during recording at large resolutions.
  • +
  • Capture Render Texture: Optional. When set, the recorder will capture from this specific render texture instead of the main screen. This is useful for recording from specific cameras, UI canvases, or other render targets. The recorder will automatically handle scaling if the render texture dimensions don't match the output resolution. Leave empty to record the main screen.
  • diff --git a/Runtime/Scripts/GameRecorder.cs b/Runtime/Scripts/GameRecorder.cs index 38bf113..d0a0a75 100644 --- a/Runtime/Scripts/GameRecorder.cs +++ b/Runtime/Scripts/GameRecorder.cs @@ -23,6 +23,10 @@ public class GameRecorder : MonoBehaviour [Tooltip("The maximum width of the video. The video will be downscaled if the screen resolution is higher.")] public int MaxVideoWidth = 1920; + // set this to a render texture to capture a specific render texture instead of the screen + [HideInInspector] + public RenderTexture CaptureRenderTexture; + // Optimized: Use RenderTextures for efficient GPU-based capture private RenderTexture _captureRT; private RenderTexture _fullScreenRT; // For capturing full screen when downscaling @@ -67,6 +71,13 @@ void Start() _gameWidth = Screen.width - (Screen.width % 4); _gameHeight = Screen.height - (Screen.height % 4); + // if custom render texture is not set and the screen w and h is not divisible by 4, print a warning + if (CaptureRenderTexture == null && (_gameWidth % 4 != 0 || _gameHeight % 4 != 0)) + { + UnityEngine.Debug.LogWarning("Current screen width and height are not divisible by 4. " + + "This may cause severe performance issues."); + } + // Determine target (output) resolution _outputWidth = _gameWidth; _outputHeight = _gameHeight; @@ -215,22 +226,41 @@ private IEnumerator CaptureFrames() { _nextCaptureTime += _captureInterval; - // Optimized: Capture screen with proper scaling support - if (_fullScreenRT != null) + // Check if we should use a custom render texture or capture from screen + if (CaptureRenderTexture != null) { - // First capture full screen - Graphics.Blit(null, _fullScreenRT); - // Then scale down to output resolution - Graphics.Blit(_fullScreenRT, _captureRT); + // Use the specified render texture instead of screen capture + if (CaptureRenderTexture.width == _outputWidth && CaptureRenderTexture.height == _outputHeight) + { + // Direct readback if dimensions match output resolution + AsyncGPUReadback.Request(CaptureRenderTexture, 0, OnCompleteReadback); + } + else + { + // Scale to output resolution if dimensions don't match + Graphics.Blit(CaptureRenderTexture, _captureRT); + AsyncGPUReadback.Request(_captureRT, 0, OnCompleteReadback); + } } else { - // Direct capture when no scaling needed - Graphics.Blit(null, _captureRT); + // Original screen capture logic + if (_fullScreenRT != null) + { + // First capture full screen + Graphics.Blit(null, _fullScreenRT); + // Then scale down to output resolution + Graphics.Blit(_fullScreenRT, _captureRT); + } + else + { + // Direct capture when no scaling needed + Graphics.Blit(null, _captureRT); + } + + // Use AsyncGPUReadback for non-blocking frame capture + AsyncGPUReadback.Request(_captureRT, 0, OnCompleteReadback); } - - // Optimized: Use AsyncGPUReadback for non-blocking frame capture - AsyncGPUReadback.Request(_captureRT, 0, OnCompleteReadback); } } } From b91285b262d8c52e48cf348d3754df609fc2e1ff Mon Sep 17 00:00:00 2001 From: Piotr Korzuszek Date: Fri, 30 May 2025 18:38:25 +0200 Subject: [PATCH 6/7] Add optional cursor rendering - needs to be enabled on GameRecorder --- Documentation.html | 1 + Runtime/Scripts/GameRecorder.cs | 32 ++++++++++++++++ Runtime/Scripts/TexturePainter.cs | 62 +++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+) diff --git a/Documentation.html b/Documentation.html index 43b8cf7..6f8d6b2 100644 --- a/Documentation.html +++ b/Documentation.html @@ -130,6 +130,7 @@

    Configuration

  • Frame Rate: Default is 30. Higher rates may increase file size and resource usage.
  • Recording Duration: Default is 60 seconds.
  • Downscale Video: Off by default. If enabled, the video will be downscaled to a maximum resolution (default: 1920x1080) if your game runs at a higher resolution. This helps reduce CPU usage and file size when recording on high-resolution displays. Enable this option in the inspector if you notice high CPU usage or stuttering during recording at large resolutions.
  • +
  • Render Cursor: Off by default. When enabled, the mouse cursor position will be rendered in the recorded video. This is useful for showing user interactions and click locations during bug reports. The cursor is drawn as a white arrow with a black outline for visibility against different backgrounds.
  • Capture Render Texture: Optional. When set, the recorder will capture from this specific render texture instead of the main screen. This is useful for recording from specific cameras, UI canvases, or other render targets. The recorder will automatically handle scaling if the render texture dimensions don't match the output resolution. Leave empty to record the main screen.
  • diff --git a/Runtime/Scripts/GameRecorder.cs b/Runtime/Scripts/GameRecorder.cs index d0a0a75..2aaa426 100644 --- a/Runtime/Scripts/GameRecorder.cs +++ b/Runtime/Scripts/GameRecorder.cs @@ -23,6 +23,9 @@ public class GameRecorder : MonoBehaviour [Tooltip("The maximum width of the video. The video will be downscaled if the screen resolution is higher.")] public int MaxVideoWidth = 1920; + [Tooltip("If enabled, renders the mouse cursor position in the recorded video.")] + public bool RenderCursor = false; + // set this to a render texture to capture a specific render texture instead of the screen [HideInInspector] public RenderTexture CaptureRenderTexture; @@ -313,6 +316,35 @@ private byte[] ApplyFPSOverlay(byte[] frameData, int width, int height) var painter = new TexturePainter(bufferWrapper, flipY: true); painter.DrawNumber(5, 5, (int)_fps, Color.white, 2); // This will draw near bottom-left corner + // Draw cursor if enabled + if (RenderCursor) + { + // Get mouse position in screen coordinates + Vector3 mousePos = Input.mousePosition; + + // Convert screen coordinates to texture coordinates + // Screen coordinates: (0,0) at bottom-left, (Screen.width, Screen.height) at top-right + // Texture coordinates depend on scaling and capture source + int cursorX, cursorY; + + if (CaptureRenderTexture != null) + { + // When using a custom render texture, we need to map mouse position to texture space + // This assumes the render texture represents the full screen view + cursorX = Mathf.RoundToInt((mousePos.x / Screen.width) * width); + cursorY = Mathf.RoundToInt((mousePos.y / Screen.height) * height); + } + else + { + // Direct screen capture - account for potential downscaling + cursorX = Mathf.RoundToInt((mousePos.x / _gameWidth) * width); + cursorY = Mathf.RoundToInt((mousePos.y / _gameHeight) * height); + } + + // Draw cursor with white fill and black outline for visibility + painter.DrawCursor(cursorX, cursorY, Color.white, Color.black, 1); + } + // Convert RGBA32 to RGB24 by removing alpha channel for (int i = 0, j = 0; i < _rgbaDataBuffer.Length; i += 4, j += 3) { diff --git a/Runtime/Scripts/TexturePainter.cs b/Runtime/Scripts/TexturePainter.cs index a6d49cf..ac25194 100644 --- a/Runtime/Scripts/TexturePainter.cs +++ b/Runtime/Scripts/TexturePainter.cs @@ -239,5 +239,67 @@ public void DrawNumber(int x, int y, int number, Color color, int scale = 1) } } } + + /// + /// Draws a simple mouse cursor at the specified coordinates. + /// The cursor is drawn as an arrow shape with an outline for visibility. + /// + /// X coordinate of the cursor tip + /// Y coordinate of the cursor tip + /// Main color of the cursor + /// Outline color for better visibility + /// Scale factor for the cursor size + public void DrawCursor(int x, int y, Color color, Color outlineColor, int scale = 1) + { + // Define cursor shape as a simple arrow + // This represents a 11x16 cursor bitmap + byte[] cursorBitmap = new byte[] + { + 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // row 0 + 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, // row 1 + 1, 2, 1, 0, 0, 0, 0, 0, 0, 0, 0, // row 2 + 1, 2, 2, 1, 0, 0, 0, 0, 0, 0, 0, // row 3 + 1, 2, 2, 2, 1, 0, 0, 0, 0, 0, 0, // row 4 + 1, 2, 2, 2, 2, 1, 0, 0, 0, 0, 0, // row 5 + 1, 2, 2, 2, 2, 2, 1, 0, 0, 0, 0, // row 6 + 1, 2, 2, 2, 2, 2, 2, 1, 0, 0, 0, // row 7 + 1, 2, 2, 2, 2, 2, 2, 2, 1, 0, 0, // row 8 + 1, 2, 2, 2, 2, 2, 2, 2, 2, 1, 0, // row 9 + 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1, // row 10 + 1, 2, 2, 2, 2, 2, 1, 1, 1, 1, 1, // row 11 + 1, 2, 2, 1, 2, 2, 1, 0, 0, 0, 0, // row 12 + 1, 2, 1, 0, 1, 2, 2, 1, 0, 0, 0, // row 13 + 1, 1, 0, 0, 1, 2, 2, 1, 0, 0, 0, // row 14 + 1, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0 // row 15 + }; + + int cursorWidth = 11; + int cursorHeight = 16; + + // Draw the cursor with scaling + for (int dy = 0; dy < cursorHeight; dy++) + { + for (int dx = 0; dx < cursorWidth; dx++) + { + // Flip the cursor vertically if flipY is true to account for coordinate system + int bitmapRow = flipY ? (cursorHeight - 1 - dy) : dy; + byte pixelValue = cursorBitmap[bitmapRow * cursorWidth + dx]; + + if (pixelValue > 0) + { + Color pixelColor = pixelValue == 1 ? outlineColor : color; + + // Apply scaling + for (int sy = 0; sy < scale; sy++) + { + for (int sx = 0; sx < scale; sx++) + { + SetPixel(x + (dx * scale) + sx, y + (dy * scale) + sy, pixelColor); + } + } + } + } + } + } } } \ No newline at end of file From 8eae09f1f92a7c37a965fad3699910926251c8ee Mon Sep 17 00:00:00 2001 From: Piotr Korzuszek Date: Fri, 30 May 2025 20:40:36 +0200 Subject: [PATCH 7/7] Add video flip workaround --- Runtime/Scripts/GameRecorder.cs | 5 ++++- Runtime/Scripts/VideoEncoder.cs | 13 ++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/Runtime/Scripts/GameRecorder.cs b/Runtime/Scripts/GameRecorder.cs index 2aaa426..96f2d59 100644 --- a/Runtime/Scripts/GameRecorder.cs +++ b/Runtime/Scripts/GameRecorder.cs @@ -26,6 +26,9 @@ public class GameRecorder : MonoBehaviour [Tooltip("If enabled, renders the mouse cursor position in the recorded video.")] public bool RenderCursor = false; + [Tooltip("If enabled, mirrors the video vertically (flips it upside down).")] + public bool MirrorVertically = false; + // set this to a render texture to capture a specific render texture instead of the screen [HideInInspector] public RenderTexture CaptureRenderTexture; @@ -129,7 +132,7 @@ void Start() } // Initialize the video encoder with the output resolution - _videoEncoder = new VideoEncoder(_outputWidth, _outputHeight, FrameRate, RecordingDuration, outputDirectory); + _videoEncoder = new VideoEncoder(_outputWidth, _outputHeight, FrameRate, RecordingDuration, outputDirectory, MirrorVertically); _captureInterval = 1.0f / FrameRate; _nextCaptureTime = Time.time; diff --git a/Runtime/Scripts/VideoEncoder.cs b/Runtime/Scripts/VideoEncoder.cs index dc8ec97..27a841f 100644 --- a/Runtime/Scripts/VideoEncoder.cs +++ b/Runtime/Scripts/VideoEncoder.cs @@ -41,6 +41,9 @@ public class VideoEncoder // Unique instance identifier to prevent conflicts between multiple instances private readonly string instanceId; + + // Control vertical mirroring of the video + private readonly bool mirrorVertically; // if set to true, the encoding thread will pause adding new frames public bool IsPaused { get; set; } @@ -52,11 +55,12 @@ public class VideoEncoder private volatile bool _stopRequest = false; private volatile bool _stopRequestHandled = false; - public VideoEncoder(int width, int height, int frameRate, int recordingDurationSeconds, string baseOutputDir = "Recording") + public VideoEncoder(int width, int height, int frameRate, int recordingDurationSeconds, string baseOutputDir = "Recording", bool mirrorVertically = false) { this.width = width; this.height = height; this.frameRate = frameRate; + this.mirrorVertically = mirrorVertically; // Create unique instance identifier and output directory this.instanceId = Guid.NewGuid().ToString("N").Substring(0, 8); // Use first 8 characters of GUID @@ -135,6 +139,13 @@ public void StartEncoding() arguments.Add("-preset"); arguments.Add(presetName); } + + // Add vertical mirroring filter if enabled + if (mirrorVertically) + { + arguments.Add("-vf"); + arguments.Add("vflip"); + } arguments.AddRange(new[] {