├── .gitattributes ├── .github └── FUNDING.yml ├── .gitignore ├── LICENSE.txt ├── PerformanceLogger.cs ├── README.md └── changelog.txt /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | 4 | # Custom for Visual Studio 5 | *.cs diff=csharp 6 | 7 | # Standard to msysgit 8 | *.doc diff=astextplain 9 | *.DOC diff=astextplain 10 | *.docx diff=astextplain 11 | *.DOCX diff=astextplain 12 | *.dot diff=astextplain 13 | *.DOT diff=astextplain 14 | *.pdf diff=astextplain 15 | *.PDF diff=astextplain 16 | *.rtf diff=astextplain 17 | *.RTF diff=astextplain 18 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: QFSW 2 | patreon: QFSW 3 | custom: paypal.me/QFSW 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Windows image file caches 2 | Thumbs.db 3 | ehthumbs.db 4 | 5 | # Folder config file 6 | Desktop.ini 7 | 8 | # Recycle Bin used on file shares 9 | $RECYCLE.BIN/ 10 | 11 | # Windows Installer files 12 | *.cab 13 | *.msi 14 | *.msm 15 | *.msp 16 | 17 | # Windows shortcuts 18 | *.lnk 19 | 20 | # ========================= 21 | # Operating System Files 22 | # ========================= 23 | 24 | # OSX 25 | # ========================= 26 | 27 | .DS_Store 28 | .AppleDouble 29 | .LSOverride 30 | 31 | # Thumbnails 32 | ._* 33 | 34 | # Files that might appear in the root of a volume 35 | .DocumentRevisions-V100 36 | .fseventsd 37 | .Spotlight-V100 38 | .TemporaryItems 39 | .Trashes 40 | .VolumeIcon.icns 41 | 42 | # Directories potentially created on remote AFP share 43 | .AppleDB 44 | .AppleDesktop 45 | Network Trash Folder 46 | Temporary Items 47 | .apdisk 48 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 QFSW 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /PerformanceLogger.cs: -------------------------------------------------------------------------------- 1 | using System.Collections; 2 | using System.Collections.Generic; 3 | using UnityEngine; 4 | using System; 5 | using System.Linq; 6 | using System.IO; 7 | using System.Threading; 8 | using QFSW.PL.Internal; 9 | 10 | namespace QFSW.PL 11 | { 12 | /// Logs performance on a frame by frame basis, analyses, and then dumps to a logfile. 13 | public class PerformanceLogger : MonoBehaviour 14 | { 15 | /// The possible different states of the PerformanceLogger. 16 | public enum LoggerState 17 | { 18 | None = 0, 19 | Logging = 1, 20 | Dumping = 2, 21 | } 22 | 23 | //If the folder containing the log should be opened at the end 24 | public static bool openLogFolder = true; 25 | 26 | /// Current active logger. 27 | private static PerformanceLogger currentLogger; 28 | 29 | /// If the PerformanceLogger is currently logging. 30 | public static bool IsLogging { get { return currentLogger != null; } } 31 | 32 | /// The current state of the PerformanceLogger. 33 | public static LoggerState CurrentState { get; private set; } 34 | 35 | private readonly Dictionary frameTimes = new Dictionary(); 36 | private readonly List loggedCustomEvents = new List(); 37 | private readonly List loggedCustomEventsTimestamps = new List(); 38 | 39 | private float startTime; 40 | private Action completionCallback; 41 | private string systemSpecs; 42 | 43 | /// Begins logging. 44 | public static void StartLogger() 45 | { 46 | if (currentLogger != null) { Destroy(currentLogger); } 47 | GameObject loggerObject = new GameObject("Performance Logger"); 48 | currentLogger = loggerObject.AddComponent(); 49 | CurrentState = LoggerState.Logging; 50 | } 51 | 52 | /// Adds a custom event to the performance logger. 53 | /// Event data. 54 | public static void LogCustomEvent(string eventData) 55 | { 56 | if (currentLogger == null) { Debug.LogError("ERROR: No logger was running"); } 57 | else 58 | { 59 | currentLogger.loggedCustomEventsTimestamps.Add(Time.unscaledTime); 60 | currentLogger.loggedCustomEvents.Add(eventData); 61 | } 62 | } 63 | 64 | /// Ends the logger, dumping to a logfile. 65 | /// Full name and path of the logfile to dump to. 66 | /// Any extra information to prepend to the logfile. 67 | /// If the dump process should run in asynchronous mode. 68 | /// An optional callback to execute upon completing the log dump. 69 | public static void EndLogger(string path, string extraInfo = "", bool async = true, Action completionCallback = null) 70 | { 71 | if (currentLogger == null) { Debug.LogError("ERROR: No logger was running"); } 72 | else 73 | { 74 | CurrentState = LoggerState.Dumping; 75 | if (async) 76 | { 77 | //Ends logger, begins asynchronous dump, completing all main thread only tasks before entering async mode 78 | currentLogger.completionCallback = completionCallback; 79 | if (openLogFolder) 80 | { 81 | if (completionCallback == null) { currentLogger.completionCallback = () => ShowLogFolder(path); } 82 | else { currentLogger.completionCallback += () => ShowLogFolder(path); } 83 | } 84 | currentLogger.GetSystemSpecs(); 85 | Thread dumpThread = new Thread(new ThreadStart(() => currentLogger.DumpLog(path, extraInfo))); 86 | dumpThread.IsBackground = true; 87 | dumpThread.Start(); 88 | } 89 | else 90 | { 91 | //Dumps logfile and ends logger 92 | currentLogger.DumpLog(path, extraInfo); 93 | completionCallback(); 94 | if (openLogFolder) { ShowLogFolder(path); } 95 | Destroy(currentLogger.gameObject); 96 | currentLogger = null; 97 | } 98 | } 99 | } 100 | 101 | /// Opens the log folder for the user. 102 | /// Full name and path of the logfile to dump to. 103 | private static void ShowLogFolder(string path) 104 | { 105 | System.Diagnostics.Process showProcess = new System.Diagnostics.Process(); 106 | #if UNITY_STANDALONE_WIN 107 | string command = "/select, \"" + path.Replace(@"/", @"\") + "\""; 108 | showProcess.StartInfo = new System.Diagnostics.ProcessStartInfo("explorer.exe", command); 109 | #elif UNITY_STANDALONE_OSX || UNITY_STANDALONE_LINUX 110 | string command = path.Replace(@"\", @"/"); 111 | command = command.Trim(); 112 | command = "open -R " + command.Replace(" ", @"\ "); 113 | command = command.Replace("'", @"\'"); 114 | showProcess.StartInfo.FileName = "/bin/bash"; 115 | showProcess.StartInfo.Arguments = "-c \" " + command + " \""; 116 | showProcess.StartInfo.UseShellExecute = false; 117 | showProcess.StartInfo.RedirectStandardOutput = true; 118 | #endif 119 | showProcess.Start(); 120 | } 121 | 122 | private void Start() { startTime = Time.realtimeSinceStartup; } 123 | 124 | private void Update() 125 | { 126 | if (currentLogger == null) { Destroy(this); } 127 | if (CurrentState == LoggerState.Logging) { LogFrameTime(); } 128 | else if (CurrentState == LoggerState.None) 129 | { 130 | //Terminates logger once dump is complete 131 | if (completionCallback != null) { completionCallback(); } 132 | currentLogger = null; 133 | Destroy(this.gameObject); 134 | } 135 | } 136 | 137 | private void LogFrameTime() 138 | { 139 | frameTimes.Add(Time.realtimeSinceStartup - startTime, Time.unscaledDeltaTime * 1000f); 140 | } 141 | 142 | private string AnalyseFramesUnderFPS(float FPSCutoff) 143 | { 144 | //Gets frames under threshold 145 | int frameCount = frameTimes.Count((KeyValuePair x) => 1000f / x.Value < FPSCutoff); 146 | float totalFrameTime = frameTimes.Sum((KeyValuePair x) => 1000f / x.Value < FPSCutoff ? x.Value : 0f); 147 | 148 | //Creates analysis string 149 | #if NET_4_6 150 | string analysisString = $"FPS < {FPSCutoff}: {frameCount} frame{(frameCount == 1 ? "" : "s")} ({(100 * frameCount / (float)frameTimes.Count).RoundToSigFigs(3)}%)"; 151 | analysisString += $", {(totalFrameTime / 1000).RoundToSigFigs(4)}s ({(0.1f * totalFrameTime / frameTimes.Keys.Max()).RoundToSigFigs(3)}%)"; 152 | #else 153 | string analysisString = "FPS < " + FPSCutoff.ToString() + ": " + frameCount.ToString() + " frame" + (frameCount == 1 ? "" : "s") + "(" + (100 * frameCount / (float)frameTimes.Count).RoundToSigFigs(3).ToString() + "%)"; 154 | analysisString += ", " + (totalFrameTime / 1000).RoundToSigFigs(4).ToString() + "s (" + (0.1f * totalFrameTime / frameTimes.Keys.Max()).RoundToSigFigs(3) + "%)"; 155 | #endif 156 | return analysisString; 157 | } 158 | 159 | private void DumpLog(string path, string extraInfo = "") 160 | { 161 | //Analyses collected data 162 | float logDuration = frameTimes.Keys.Max(); 163 | float averageFrameTime = frameTimes.Values.Average(); 164 | float RMSFrameTime = Mathf.Sqrt(frameTimes.Sum((KeyValuePair x) => Mathf.Pow(x.Value, 2) / frameTimes.Count)); 165 | float minFrameTime = frameTimes.Values.Min(); 166 | float maxFrameTime = frameTimes.Values.Max(); 167 | float averageFPS = 1000 / averageFrameTime; 168 | float RMSFPS = 1000 / RMSFrameTime; 169 | float maxFPS = 1000 / minFrameTime; 170 | float minFPS = 1000 / maxFrameTime; 171 | float[] orderedFrameTimes = frameTimes.Values.OrderBy((float x) => x).ToArray(); 172 | float p10FrameTime = orderedFrameTimes[Mathf.Max(0, Mathf.RoundToInt(frameTimes.Count * 0.1f) - 1)]; 173 | float p90FrameTime = orderedFrameTimes[Mathf.RoundToInt(frameTimes.Count * 0.9f) - 1]; 174 | float p10FPS = 1000 / p10FrameTime; 175 | float p90FPS = 1000 / p90FrameTime; 176 | 177 | //Adds analysis to logfile 178 | string dataString = extraInfo; 179 | #if NET_4_6 180 | dataString += $"\n\n\nLog duration: {logDuration}s"; 181 | dataString += $"\nTotal frames: {frameTimes.Count}"; 182 | dataString += $"\n\nAverage frametime: {averageFrameTime.RoundToSigFigs(4)}ms, {averageFPS.RoundToSigFigs(4)} FPS"; 183 | dataString += $"\nRMS frametime: {RMSFrameTime.RoundToSigFigs(4)}ms, {RMSFPS.RoundToSigFigs(4)} FPS"; 184 | dataString += $"\nMinimum frametime: {minFrameTime.RoundToSigFigs(4)}ms, {maxFPS.RoundToSigFigs(4)} FPS"; 185 | dataString += $"\nMaximum frametime: {maxFrameTime.RoundToSigFigs(4)}ms, {minFPS.RoundToSigFigs(4)} FPS"; 186 | dataString += $"\np10%: {p10FrameTime.RoundToSigFigs(4)}ms, {p10FPS.RoundToSigFigs(4)} FPS"; 187 | dataString += $"\np90%: {p90FrameTime.RoundToSigFigs(4)}ms, {p90FPS.RoundToSigFigs(4)} FPS"; 188 | dataString += $"\n\n{AnalyseFramesUnderFPS(120)}"; 189 | dataString += $"\n{AnalyseFramesUnderFPS(60)}"; 190 | dataString += $"\n{AnalyseFramesUnderFPS(30)}"; 191 | dataString += $"\n{AnalyseFramesUnderFPS(15)}"; 192 | dataString += $"\n{AnalyseFramesUnderFPS(5)}"; 193 | dataString += $"\n{AnalyseFramesUnderFPS(1)}"; 194 | #else 195 | dataString += "\n\n\nLog duration: " + logDuration.ToString() + "s"; 196 | dataString += "\nTotal frames: " + frameTimes.Count.ToString(); 197 | dataString += "\n\nAverage frametime: " + averageFrameTime.RoundToSigFigs(4).ToString() + "ms, " + averageFPS.RoundToSigFigs(4).ToString() + " FPS"; 198 | dataString += "\nRMS frametime: " + RMSFrameTime.RoundToSigFigs(4).ToString() + "ms, " + RMSFPS.RoundToSigFigs(4).ToString() + " FPS"; 199 | dataString += "\nMinimum frametime: " + minFrameTime.RoundToSigFigs(4).ToString() + "ms, " + maxFPS.RoundToSigFigs(4).ToString() + " FPS"; 200 | dataString += "\nMaximum frametime: " + maxFrameTime.RoundToSigFigs(4).ToString() + "ms, " + minFPS.RoundToSigFigs(4).ToString() + " FPS"; 201 | dataString += "\np10%: " + p10FrameTime.RoundToSigFigs(4).ToString() + "ms, " + p10FPS.RoundToSigFigs(4).ToString() + " FPS"; 202 | dataString += "\np90%: " + p90FrameTime.RoundToSigFigs(4).ToString() + "ms, " + p90FPS.RoundToSigFigs(4).ToString() + " FPS"; 203 | dataString += "\n\n" + AnalyseFramesUnderFPS(120); 204 | dataString += "\n" + AnalyseFramesUnderFPS(60); 205 | dataString += "\n" + AnalyseFramesUnderFPS(30); 206 | dataString += "\n" + AnalyseFramesUnderFPS(15); 207 | dataString += "\n" + AnalyseFramesUnderFPS(5); 208 | dataString += "\n" + AnalyseFramesUnderFPS(1); 209 | #endif 210 | 211 | //Adds system specs to logfile 212 | if (string.IsNullOrEmpty(systemSpecs)) { GetSystemSpecs(); } 213 | dataString += "\n\n\n" + systemSpecs; 214 | 215 | //Writes data to file 216 | string directoryPath = System.IO.Path.GetDirectoryName(path); 217 | if (!Directory.Exists(directoryPath)) { Directory.CreateDirectory(directoryPath); } 218 | StreamWriter logFile = new StreamWriter(path); 219 | logFile.Write(dataString); 220 | 221 | //Writes custom events 222 | if (loggedCustomEvents.Count > 0) 223 | { 224 | logFile.Write("\n\n\n\nCustom events:"); 225 | for (int i = 0; i < loggedCustomEvents.Count; i++) 226 | { 227 | #if NET_4_6 228 | logFile.Write($"\n{loggedCustomEventsTimestamps[i]}, {loggedCustomEvents[i]}"); 229 | #else 230 | logFile.Write("\n" + loggedCustomEventsTimestamps[i].ToString() + ", " + loggedCustomEvents[i].ToString()); 231 | #endif 232 | } 233 | } 234 | 235 | //Writes raw data to the log file 236 | logFile.Write("\n\n\nFrametimes:"); 237 | foreach (KeyValuePair frame in frameTimes) 238 | { 239 | #if NET_4_6 240 | logFile.Write($"\n{frame.Key}, {frame.Value}"); 241 | #else 242 | logFile.Write("\n" + frame.Key.ToString() + ", " + frame.Value.ToString()); 243 | #endif 244 | } 245 | 246 | //Closes file and ends 247 | logFile.Flush(); 248 | logFile.Close(); 249 | logFile.Dispose(); 250 | CurrentState = LoggerState.None; 251 | } 252 | 253 | private string GetSystemSpecs() 254 | { 255 | systemSpecs = "System Specifications:"; 256 | #if NET_4_6 257 | systemSpecs += $"\n\n{SystemInfo.deviceName}"; 258 | systemSpecs += $"\n{SystemInfo.deviceModel}"; 259 | systemSpecs += $"\n{SystemInfo.deviceType}"; 260 | systemSpecs += $"\n{Screen.width} x {Screen.height} @{Screen.currentResolution.refreshRate}Hz - {(Screen.fullScreen ? "Fullscreen" : "Windowed")}"; 261 | systemSpecs += $"\n\nCPU: {SystemInfo.processorType}, {SystemInfo.processorCount}C, {SystemInfo.processorFrequency}MHz"; 262 | systemSpecs += $"\nRAM: {SystemInfo.systemMemorySize}MB"; 263 | systemSpecs += $"\nGPU: {SystemInfo.graphicsDeviceName}, {(SystemInfo.graphicsMultiThreaded ? "Multithreaded" : "Singlethreaded")}"; 264 | systemSpecs += $"\nGraphics API: {SystemInfo.graphicsDeviceType}, {SystemInfo.graphicsDeviceVersion}"; 265 | systemSpecs += $"\nVRAM: {SystemInfo.graphicsMemorySize}MB"; 266 | #else 267 | systemSpecs += "\n\n" + SystemInfo.deviceName; 268 | systemSpecs += "\n" + SystemInfo.deviceModel; 269 | systemSpecs += "\n" + SystemInfo.deviceType; 270 | systemSpecs += "\n" + Screen.width.ToString() + " x " + Screen.height.ToString() +" @" + Screen.currentResolution.refreshRate.ToString() + "Hz - " + (Screen.fullScreen ? "Fullscreen" : "Windowed"); 271 | systemSpecs += "\n\nCPU: " + SystemInfo.processorType + ", " + SystemInfo.processorCount.ToString() + "C, " + SystemInfo.processorFrequency.ToString() + "MHz"; 272 | systemSpecs += "\nRAM: " + SystemInfo.systemMemorySize.ToString() + "MB"; 273 | systemSpecs += "\nGPU: " + SystemInfo.graphicsDeviceName + ", " + (SystemInfo.graphicsMultiThreaded ? "Multithreaded" : "Singlethreaded"); 274 | systemSpecs += "\nGraphics API: " + SystemInfo.graphicsDeviceType + ", " + SystemInfo.graphicsDeviceVersion; 275 | systemSpecs += "\nVRAM: " + SystemInfo.graphicsMemorySize + "MB"; 276 | #endif 277 | return systemSpecs; 278 | } 279 | } 280 | 281 | namespace Internal 282 | { 283 | /// Extends the float class. 284 | public static class FloatExtension 285 | { 286 | /// Rounds the floating point value to n significant figures. 287 | /// The number of signiciant figures. 288 | /// Rounded float. 289 | public static float RoundToSigFigs(this float num, int n) 290 | { 291 | if (num == 0) { return num; } 292 | int currentSigFigs = (int)Math.Log10(Math.Abs(num)) + 1; 293 | if (n < 1) { return num; } 294 | 295 | //Reduces sig figs 296 | num /= (float)Math.Pow(10, currentSigFigs - n); 297 | num = (int)Math.Round(num); 298 | num *= (float)Math.Pow(10, currentSigFigs - n); 299 | 300 | return num; 301 | } 302 | } 303 | } 304 | } 305 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Performance Logger - A Simple and Powerful Logging Tool for Unity Games 2 | 3 | This logger only requires you to start and end the logger, the rest is handled for you. 4 | Once activated, the logger will record the frametime for every frame; on dump, a summary will be generated, a long with pulled system specs, and the full log. 5 | 6 | Include the namespace QFSW.PL to gain access to the performance logger 7 | 8 | To begin, call `PerformanceLogger.StartLogger();` 9 | 10 | To end and dump the logger, call `EndLogger(string Path, string ExtraInfo = "", bool Async = true, Action CompletionCallback = null)` 11 | 12 | `Path` is the full file path (name included) of the dumped log file 13 | 14 | `ExtraInfo` is a string that will be prepended to the log file, this could be useful to use as a version number 15 | 16 | `Async` will cause the dump to run in async mode, which is highly recommended for large dumps. If this is used, you should use the `CompletionCallback`, which will execute (on the main thread) as soon as the dump process is done. This is useful for disabling a message. 17 | 18 | If you want to log a custom event, such as spawning a boss, use `PerformanceLogger.LogCustomEvent(string EventData)`, and the event will be added to the log file. 19 | 20 | An example of the log file can be seen here: 21 | ![alt text](https://pbs.twimg.com/media/DeMO4raXUAAlMHE.jpg:large) 22 | 23 | # Donate 24 | If you enjoyed this product and would like to see more, please consider donating or purchasing some of our other products. 25 | - [Unity Asset Store Products](https://assetstore.unity.com/publishers/18921) 26 | - [Steam Games](https://store.steampowered.com/developer/QFSW) 27 | - [Patreon](https://www.patreon.com/QFSW) 28 | - [PayPal](https://www.paypal.me/qfsw) 29 | -------------------------------------------------------------------------------- /changelog.txt: -------------------------------------------------------------------------------- 1 | V1.0.1 2 | Improvement 0001: Internal rewrite to follow proper naming conventions 3 | 4 | V1.0.0 5 | Initial release --------------------------------------------------------------------------------