├── .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 | 
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
--------------------------------------------------------------------------------