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;
+ }
+ }
+}