diff --git a/LagFreeScreenshots/EventHandler.cs b/LagFreeScreenshots/EventHandler.cs new file mode 100644 index 0000000..d3af0c0 --- /dev/null +++ b/LagFreeScreenshots/EventHandler.cs @@ -0,0 +1,14 @@ +using System; + +namespace LagFreeScreenshots +{ + public static class EventHandler + { + /// + /// Calls when an screenshot is saved + /// + public static event Action OnScreenshotSaved; + + internal static void InvokeScreenshotSaved(string filePath, int width, int height, Metadata metadata) => OnScreenshotSaved?.Invoke(filePath, width, height, metadata); + } +} diff --git a/LagFreeScreenshots/LagFreeScreenshotsMod.cs b/LagFreeScreenshots/LagFreeScreenshotsMod.cs index 2c6640f..fe18b2b 100644 --- a/LagFreeScreenshots/LagFreeScreenshotsMod.cs +++ b/LagFreeScreenshots/LagFreeScreenshotsMod.cs @@ -24,13 +24,12 @@ using Object = UnityEngine.Object; using CameraTakePhotoEnumerator = VRC.UserCamera.CameraUtil._TakeScreenShot_d__5; using System.Collections.Generic; -using System.Globalization; using Unity.Collections.LowLevel.Unsafe; // using CameraUtil = ObjectPublicCaSiVeUnique; -[assembly:MelonInfo(typeof(LagFreeScreenshotsMod), "Lag Free Screenshots", "1.2.7", "knah, Protected", "https://github.com/knah/VRCMods")] -[assembly:MelonGame("VRChat", "VRChat")] +[assembly: MelonInfo(typeof(LagFreeScreenshotsMod), "Lag Free Screenshots", "1.2.8", "knah, Protected", "https://github.com/knah/VRCMods")] +[assembly: MelonGame("VRChat", "VRChat")] namespace LagFreeScreenshots { @@ -56,29 +55,29 @@ public override void OnApplicationStart() { var category = MelonPreferences.CreateCategory(SettingsCategory, "Lag Free Screenshots"); ourEnabled = category.CreateEntry(SettingEnableMod, true, "Enabled"); - ourFormat = category.CreateEntry( SettingScreenshotFormat, "png", "Screenshot format"); + ourFormat = category.CreateEntry(SettingScreenshotFormat, "png", "Screenshot format"); ourJpegPercent = category.CreateEntry(SettingJpegPercent, 95, "JPEG quality (0-100)"); ourAutorotation = category.CreateEntry(SettingAutorotation, true, "Rotate picture to match camera"); ourMetadata = category.CreateEntry(SettingMetadata, false, "Save metadata in picture"); ourRecommendedMaxFb = category.CreateEntry("RecommendedMaximumFb", 1024, "Try to keep framebuffer below (MB) by reducing MSAA"); - + if (!MelonHandler.Mods.Any(it => it.Info.Name == "UI Expansion Kit" && it.Assembly.GetName().Version >= new Version(0, 2, 6))) { MelonLogger.Error("UI Expansion Kit is not found. Lag Free Screenshots will not work."); return; - } + } HarmonyInstance.Patch( typeof(CameraTakePhotoEnumerator).GetMethod("MoveNext"), new HarmonyMethod(AccessTools.Method(typeof(LagFreeScreenshotsMod), nameof(MoveNextPatchAsyncReadback)))); - + AddEnumSettings(); } [MethodImpl(MethodImplOptions.NoInlining)] private static void AddEnumSettings() { - ExpansionKitApi.RegisterSettingAsStringEnum(SettingsCategory, SettingScreenshotFormat, new []{("png", "PNG"), ("jpeg", "JPEG")}); + ExpansionKitApi.RegisterSettingAsStringEnum(SettingsCategory, SettingScreenshotFormat, new[] { ("png", "PNG"), ("jpeg", "JPEG") }); } private static int GetPictureAutorotation(Camera camera) @@ -93,14 +92,16 @@ private static int GetPictureAutorotation(Camera camera) return 0; } - private static string GetPlayerList(Camera camera) + private static List> GetPlayerList(Camera camera) { var playerManager = PlayerManager.field_Private_Static_PlayerManager_0; - if (playerManager == null) return ""; - - var result = new List(); + if (playerManager == null) return new List>(); var localPlayer = VRCPlayer.field_Internal_Static_VRCPlayer_0; + if (localPlayer == null) return new List>(); + + var result = new List>(); + var localPosition = localPlayer.gameObject.transform.position; foreach (var p in playerManager.field_Private_List_1_Player_0) @@ -109,45 +110,20 @@ private static string GetPlayerList(Camera camera) var playerPositionTransform = avatarRoot?.GetComponent()?.GetBoneTransform(HumanBodyBones.Head) ?? p.transform; var playerPosition = playerPositionTransform.position; Vector3 viewPos = camera.WorldToViewportPoint(playerPosition); - var playerDescriptor = p.prop_APIUser_0.id + "," + - viewPos.x.ToString("0.00", CultureInfo.InvariantCulture) + "," + - viewPos.y.ToString("0.00", CultureInfo.InvariantCulture) + "," + - viewPos.z.ToString("0.00", CultureInfo.InvariantCulture) + "," + - p.prop_APIUser_0.displayName; - + if (viewPos.z < 2 && Vector3.Distance(localPosition, playerPosition) < 2) { //User standing right next to photographer, might be visible (approx.) - result.Add(playerDescriptor); + result.Add(Tuple.Create(p, viewPos)); } else if (viewPos.x > -0.03 && viewPos.x < 1.03 && viewPos.y > -0.03 && viewPos.y < 1.03 && viewPos.z > 2 && viewPos.z < 30) { //User in viewport, might be obstructed but still... - result.Add(playerDescriptor); + result.Add(Tuple.Create(p, viewPos)); } } - return String.Join(";", result); - } - - private static string GetPhotographerMeta() - { - return APIUser.CurrentUser.id + "," + APIUser.CurrentUser.displayName; - } - - private static string GetWorldMeta() - { - var apiWorld = RoomManager.field_Internal_Static_ApiWorld_0; - if (apiWorld == null) return "null,0,Not in any world"; - return apiWorld.id + "," + RoomManager.field_Internal_Static_ApiWorldInstance_0.name + "," + apiWorld.name; - } - - private static string GetPosition() - { - var position = VRCPlayer.field_Internal_Static_VRCPlayer_0.transform.position; - return position.x.ToString(CultureInfo.InvariantCulture) + "," + - position.y.ToString(CultureInfo.InvariantCulture) + "," + - position.z.ToString(CultureInfo.InvariantCulture); + return result; } public static bool MoveNextPatchAsyncReadback(ref bool __result, CameraTakePhotoEnumerator __instance) @@ -156,12 +132,12 @@ public static bool MoveNextPatchAsyncReadback(ref bool __result, CameraTakePhoto var resY = __instance.field_Public_Int32_1; var saveToFile = __instance.field_Public_Boolean_0; var hasAlpha = __instance.field_Public_Boolean_1; - + MelonDebug.Msg($"LFS bools: 0={__instance.field_Public_Boolean_0} 1={__instance.field_Public_Boolean_1}"); - + if (!ourEnabled.Value || !saveToFile) return true; - + ourMainThread = Thread.CurrentThread; __result = false; @@ -179,8 +155,8 @@ private static int MaxMsaaCount(int w, int h) { // MSAA rendertargets store depth (24+8 bits?) and color per sample, plus one extra color sample (output color?) for levels >1 // Unity doesn't like rendertextures over 4 gigs in size, so reduce MSAA if necessary - var maxFbSize = (uint) ourRecommendedMaxFb.Value * 1024 * 1024; - var colorSizePerLevel = w * (long) h * 4 * 2; // ignore no-alpha to be conservative about packing + var maxFbSize = (uint)ourRecommendedMaxFb.Value * 1024 * 1024; + var colorSizePerLevel = w * (long)h * 4 * 2; // ignore no-alpha to be conservative about packing var maxMsaa = (maxFbSize - colorSizePerLevel / 2) / colorSizePerLevel; if (maxMsaa >= 8) maxMsaa = 8; else if (maxMsaa >= 4) maxMsaa = 4; @@ -190,12 +166,12 @@ private static int MaxMsaaCount(int w, int h) if (maxMsaa != ourLastUsedMsaaLevel) { MelonLogger.Msg($"Using MSAA x{maxMsaa} for screenshots (FB size {(colorSizePerLevel * maxMsaa + colorSizePerLevel / 2) / 1024 / 1024}MB)"); - ourLastUsedMsaaLevel = (int) maxMsaa; + ourLastUsedMsaaLevel = (int)maxMsaa; } - return (int) maxMsaa; + return (int)maxMsaa; } - + public static async Task TakeScreenshot(Camera camera, int w, int h, bool hasAlpha) { await TaskUtilities.YieldToFrameEnd(); @@ -213,14 +189,14 @@ public static async Task TakeScreenshot(Camera camera, int w, int h, bool hasAlp camera.targetTexture = renderTexture; camera.allowMSAA = maxMsaa > 1; QualitySettings.antiAliasing = maxMsaa; - + camera.Render(); camera.targetTexture = oldCameraTarget; camera.fieldOfView = oldCameraFov; camera.allowMSAA = oldAllowMsaa; QualitySettings.antiAliasing = oldGraphicsMsaa; - + renderTexture.ResolveAntiAliasedSurface(); (IntPtr, int) data = default; @@ -228,23 +204,23 @@ public static async Task TakeScreenshot(Camera camera, int w, int h, bool hasAlp if (readbackSupported) { MelonDebug.Msg("Supports readback"); - + var stopwatch = Stopwatch.StartNew(); var request = AsyncGPUReadback.Request(renderTexture, 0, hasAlpha ? TextureFormat.ARGB32 : TextureFormat.RGB24, new Action(r => { if (r.hasError) MelonLogger.Warning("Readback request finished with error (w)"); - + data = ToBytes(r.GetDataRaw(0), r.GetLayerDataSize()); MelonDebug.Msg($"Bytes readback took total {stopwatch.ElapsedMilliseconds}"); })); - + while (!request.done && !request.hasError && data.Item1 == IntPtr.Zero) await TaskUtilities.YieldToMainThread(); if (request.hasError) MelonLogger.Warning("Readback request finished with error"); - + if (data.Item1 == IntPtr.Zero) { MelonDebug.Msg("Data was null after request was done, waiting more"); @@ -256,7 +232,7 @@ public static async Task TakeScreenshot(Camera camera, int w, int h, bool hasAlp unsafe { MelonLogger.Msg("Does not support readback, using fallback texture read method"); - + RenderTexture.active = renderTexture; var newTexture = new Texture2D(w, h, hasAlpha ? TextureFormat.ARGB32 : TextureFormat.RGB24, false); newTexture.ReadPixels(new Rect(0, 0, w, h), 0, 0); @@ -265,12 +241,12 @@ public static async Task TakeScreenshot(Camera camera, int w, int h, bool hasAlp var bytes = newTexture.GetRawTextureData(); data = (Marshal.AllocHGlobal(bytes.Length), bytes.Length); - UnsafeUtility.MemCpy((void*) data.Item1, bytes.m_Buffer, bytes.Length); + UnsafeUtility.MemCpy((void*)data.Item1, bytes.m_Buffer, bytes.Length); Object.Destroy(newTexture); } } - + renderTexture.Release(); Object.Destroy(renderTexture); @@ -279,36 +255,28 @@ public static async Task TakeScreenshot(Camera camera, int w, int h, bool hasAlp if (!Directory.Exists(targetDir)) Directory.CreateDirectory(targetDir); - string metadataStr = null; + Metadata metadata = null; int rotationQuarters = 0; - - if (ourAutorotation.Value) + + if (ourAutorotation.Value) rotationQuarters = GetPictureAutorotation(camera); if (ourMetadata.Value) - { - metadataStr = "lfs|2|author:" + GetPhotographerMeta() + "|world:" + GetWorldMeta() + "|pos:" + - GetPosition(); - if (ourAutorotation.Value) - { - metadataStr += "|rq:" + rotationQuarters; - } - metadataStr += "|players:" + GetPlayerList(camera); - } + metadata = new Metadata(ourAutorotation.Value ? rotationQuarters : -1, APIUser.CurrentUser, RoomManager.field_Internal_Static_ApiWorldInstance_0, VRCPlayer.field_Internal_Static_VRCPlayer_0 == null ? new Vector3(0, 0, 0) : VRCPlayer.field_Internal_Static_VRCPlayer_0.transform.position, GetPlayerList(camera)); - await EncodeAndSavePicture(targetFile, data, w, h, hasAlpha, rotationQuarters, metadataStr) + await EncodeAndSavePicture(targetFile, data, w, h, hasAlpha, rotationQuarters, metadata) .ConfigureAwait(false); } - + private static unsafe (IntPtr, int) ToBytes(IntPtr pointer, int length) { var data = Marshal.AllocHGlobal(length); - - Buffer.MemoryCopy((void*) pointer, (void*) data, length, length); + + Buffer.MemoryCopy((void*)pointer, (void*)data, length, length); return (data, length); } - + private static ImageCodecInfo GetEncoder(ImageFormat format) { ImageCodecInfo[] codecs = ImageCodecInfo.GetImageEncoders(); @@ -327,12 +295,12 @@ private static unsafe (IntPtr, int) TransposeAndDestroyOriginal((IntPtr, int Len { (IntPtr, int) newData = (Marshal.AllocHGlobal(data.Length), data.Length); - byte* pixels = (byte*) data.Item1; - byte* newPixels = (byte*) newData.Item1; + byte* pixels = (byte*)data.Item1; + byte* newPixels = (byte*)newData.Item1; for (var x = 0; x < w; x++) - for (var y = 0; y < h; y++) - for (var s = 0; s < step; s++) - newPixels[s + y * step + x * h * step] = pixels[s + x * step + y * w * step]; + for (var y = 0; y < h; y++) + for (var s = 0; s < step; s++) + newPixels[s + y * step + x * h * step] = pixels[s + x * step + y * w * step]; Marshal.FreeHGlobal(data.Item1); return newData; @@ -340,7 +308,7 @@ private static unsafe (IntPtr, int) TransposeAndDestroyOriginal((IntPtr, int Len private static unsafe void FlipVertInPlace((IntPtr, int Length) data, int w, int h, int step) { - byte* pixels = (byte*) data.Item1; + byte* pixels = (byte*)data.Item1; for (var y = 0; y < h / 2; y++) { for (var x = 0; x < w * step; x++) @@ -354,7 +322,7 @@ private static unsafe void FlipVertInPlace((IntPtr, int Length) data, int w, int private static unsafe void FlipHorInPlace((IntPtr, int Length) data, int w, int h, int step) { - byte* pixels = (byte*) data.Item1; + byte* pixels = (byte*)data.Item1; for (var x = 0; x < w / 2; x++) { for (var y = 0; y < h; y++) @@ -369,15 +337,14 @@ private static unsafe void FlipHorInPlace((IntPtr, int Length) data, int w, int } } - private static async Task EncodeAndSavePicture(string filePath, (IntPtr, int Length) pixelsPair, int w, int h, - bool hasAlpha, int rotationQuarters, string description) + bool hasAlpha, int rotationQuarters, Metadata metadata) { if (pixelsPair.Item1 == IntPtr.Zero) return; - + // yield to background thread await Task.Delay(1).ConfigureAwait(false); - + if (Thread.CurrentThread == ourMainThread) MelonLogger.Error("Image encode is executed on main thread - it's a bug!"); @@ -386,7 +353,7 @@ private static async Task EncodeAndSavePicture(string filePath, (IntPtr, int Len unsafe { // swap colors [a]rgb -> bgr[a] - byte* pixels = (byte*) pixelsPair.Item1; + byte* pixels = (byte*)pixelsPair.Item1; for (int i = 0; i < pixelsPair.Length; i += step) { var t = pixels[i]; @@ -423,30 +390,30 @@ private static async Task EncodeAndSavePicture(string filePath, (IntPtr, int Len FlipVertInPlace(pixelsPair, w, h, step); } - var pixelFormat = hasAlpha ? PixelFormat.Format32bppArgb : PixelFormat.Format24bppRgb; using var bitmap = new Bitmap(w, h, pixelFormat); var bitmapData = bitmap.LockBits(new Rectangle(0, 0, w, h), ImageLockMode.WriteOnly, pixelFormat); unsafe { - Buffer.MemoryCopy((void*) pixelsPair.Item1, (void*) bitmapData.Scan0, pixelsPair.Length, pixelsPair.Length); + Buffer.MemoryCopy((void*)pixelsPair.Item1, (void*)bitmapData.Scan0, pixelsPair.Length, pixelsPair.Length); } bitmap.UnlockBits(bitmapData); Marshal.FreeHGlobal(pixelsPair.Item1); // https://docs.microsoft.com/en-us/windows/win32/gdiplus/-gdiplus-constant-property-item-descriptions - if (description != null) + if (metadata != null) { // png description is saved as iTXt chunk manually if (ourFormat.Value == "jpeg") { + var description = metadata.ConvertToString(); var stringBytesCount = Encoding.Unicode.GetByteCount(description); var allBytes = new byte[8 + stringBytesCount]; Encoding.ASCII.GetBytes("UNICODE\0", 0, 8, allBytes, 0); Encoding.Unicode.GetBytes(description, 0, description.Length, allBytes, 8); - var pi = (PropertyItem) FormatterServices.GetUninitializedObject(typeof(PropertyItem)); + var pi = (PropertyItem)FormatterServices.GetUninitializedObject(typeof(PropertyItem)); pi.Type = 7; // PropertyTagTypeUndefined pi.Id = 0x9286; // PropertyTagExifUserComment pi.Value = allBytes; @@ -460,7 +427,7 @@ private static async Task EncodeAndSavePicture(string filePath, (IntPtr, int Len var encoder = GetEncoder(ImageFormat.Jpeg); using var parameters = new EncoderParameters(1) { - Param = {[0] = new EncoderParameter(System.Drawing.Imaging.Encoder.Quality, ourJpegPercent.Value)} + Param = { [0] = new EncoderParameter(System.Drawing.Imaging.Encoder.Quality, ourJpegPercent.Value) } }; filePath = Path.ChangeExtension(filePath, ".jpeg"); bitmap.Save(filePath, encoder, parameters); @@ -468,8 +435,9 @@ private static async Task EncodeAndSavePicture(string filePath, (IntPtr, int Len else { bitmap.Save(filePath, ImageFormat.Png); - if (description != null) + if (metadata != null) { + var description = metadata.ConvertToString(); using var pngStream = new FileStream(filePath, FileMode.Open, FileAccess.ReadWrite); var originalEndChunkBytes = new byte[12]; pngStream.Position = pngStream.Length - 12; @@ -487,16 +455,18 @@ private static async Task EncodeAndSavePicture(string filePath, (IntPtr, int Len // compatibility with log-reading tools UnityEngine.Debug.Log($"Took screenshot to: {filePath}"); - + + EventHandler.InvokeScreenshotSaved(filePath, w, h, metadata); + // yield to background thread for disposes await Task.Delay(1).ConfigureAwait(false); } private static Func ourOurGetPathMethod; - + static string GetPath(int w, int h) { - ourOurGetPathMethod ??= (Func) Delegate.CreateDelegate(typeof(Func), + ourOurGetPathMethod ??= (Func)Delegate.CreateDelegate(typeof(Func), typeof(CameraUtil) .GetMethods(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly).Single(it => it.Name.StartsWith("Method_Private_Static_String_Int32_Int32_") && XrefScanner.XrefScan(it) diff --git a/LagFreeScreenshots/Metadata.cs b/LagFreeScreenshots/Metadata.cs new file mode 100644 index 0000000..06a65e1 --- /dev/null +++ b/LagFreeScreenshots/Metadata.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using UnityEngine; +using VRC; +using VRC.Core; + +namespace LagFreeScreenshots +{ + public class Metadata + { + public int ImageRotation; + public APIUser ApiUser; + public ApiWorldInstance WorldInstance; + public Vector3 Position; + public List> PlayerList; + + public Metadata(int imageRotation, APIUser apiUser, ApiWorldInstance apiWorldInstance, Vector3 position, List> playerList) + { + ImageRotation = imageRotation; + ApiUser = apiUser; + WorldInstance = apiWorldInstance; + Position = position; + PlayerList = playerList; + } + + public string ConvertToString() + { + var worldString = "null,0,Not in any world"; + if (WorldInstance != null && WorldInstance.world != null) + worldString = WorldInstance.world.id + "," + WorldInstance.name + "," + WorldInstance.world.name; + + var positionString = Position.x.ToString(CultureInfo.InvariantCulture) + "," + Position.y.ToString(CultureInfo.InvariantCulture) + "," + Position.z.ToString(CultureInfo.InvariantCulture); + + return "lfs|2|author:" + + ApiUser.id + "," + ApiUser.displayName + + "|world:" + worldString + + "|pos:" + positionString + + (ImageRotation != -1 ? "|rq:" + ImageRotation : "") + + "|players:" + string.Join(";", PlayerList.ConvertAll(new Converter, string>(PlayerListToString))); + } + + private static string PlayerListToString(Tuple playerData) + { + if (playerData.Item1 == null || playerData.Item1.prop_APIUser_0 == null) return "null,0,0,0,null"; + return playerData.Item1.prop_APIUser_0.id + "," + + playerData.Item2.x.ToString("0.00", CultureInfo.InvariantCulture) + "," + + playerData.Item2.y.ToString("0.00", CultureInfo.InvariantCulture) + "," + + playerData.Item2.z.ToString("0.00", CultureInfo.InvariantCulture) + "," + + playerData.Item1.prop_APIUser_0.displayName; + } + } +}