A game framework written with osu! in mind.
at master 16 kB view raw
1// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. 2// See the LICENCE file in the repository root for full licence text. 3 4using System; 5using System.Collections.Generic; 6using System.Diagnostics; 7using System.Globalization; 8using System.Threading; 9using JetBrains.Annotations; 10using osu.Framework.Bindables; 11using osu.Framework.Development; 12using osu.Framework.Platform; 13using osu.Framework.Statistics; 14using osu.Framework.Timing; 15 16namespace osu.Framework.Threading 17{ 18 /// <summary> 19 /// A conceptual thread used for running game work. May or may not be backed by a native thread. 20 /// </summary> 21 public class GameThread 22 { 23 internal const double DEFAULT_ACTIVE_HZ = 1000; 24 internal const double DEFAULT_INACTIVE_HZ = 60; 25 26 /// <summary> 27 /// The name of this thread. 28 /// </summary> 29 public readonly string Name; 30 31 /// <summary> 32 /// Whether the game is active (in the foreground). 33 /// </summary> 34 public readonly IBindable<bool> IsActive = new Bindable<bool>(true); 35 36 /// <summary> 37 /// The current state of this thread. 38 /// </summary> 39 public IBindable<GameThreadState> State => state; 40 41 private readonly Bindable<GameThreadState> state = new Bindable<GameThreadState>(); 42 43 /// <summary> 44 /// Whether this thread is currently running. 45 /// </summary> 46 public bool Running => state.Value == GameThreadState.Running; 47 48 /// <summary> 49 /// Whether this thread is exited. 50 /// </summary> 51 public bool Exited => state.Value == GameThreadState.Exited; 52 53 /// <summary> 54 /// Whether currently executing on this thread (from the point of invocation). 55 /// </summary> 56 public virtual bool IsCurrent => true; 57 58 /// <summary> 59 /// The thread's clock. Responsible for timekeeping and throttling. 60 /// </summary> 61 public ThrottledFrameClock Clock { get; } 62 63 /// <summary> 64 /// The current dedicated OS thread for this <see cref="GameThread"/>. 65 /// A value of <see langword="null"/> does not necessarily mean that this thread is not running; 66 /// in <see cref="ExecutionMode.SingleThread"/> execution mode <see cref="ThreadRunner"/> drives its <see cref="GameThread"/>s 67 /// manually and sequentially on the main OS thread of the game process. 68 /// </summary> 69 [CanBeNull] 70 public Thread Thread { get; private set; } 71 72 /// <summary> 73 /// The thread's scheduler. 74 /// </summary> 75 public Scheduler Scheduler { get; } 76 77 /// <summary> 78 /// Attach a handler to delegate responsibility for per-frame exceptions. 79 /// While attached, all exceptions will be caught and forwarded. Thread execution will continue indefinitely. 80 /// </summary> 81 public EventHandler<UnhandledExceptionEventArgs> UnhandledException; 82 83 /// <summary> 84 /// The culture of this thread. 85 /// </summary> 86 public CultureInfo CurrentCulture 87 { 88 get => culture; 89 set 90 { 91 culture = value; 92 93 updateCulture(); 94 } 95 } 96 97 private CultureInfo culture; 98 99 /// <summary> 100 /// The target number of updates per second when the game window is active. 101 /// </summary> 102 /// <remarks> 103 /// A value of 0 is treated the same as "unlimited" or <see cref="double.MaxValue"/>. 104 /// </remarks> 105 public double ActiveHz 106 { 107 get => activeHz; 108 set 109 { 110 activeHz = value; 111 updateMaximumHz(); 112 } 113 } 114 115 private double activeHz = DEFAULT_ACTIVE_HZ; 116 117 /// <summary> 118 /// The target number of updates per second when the game window is inactive. 119 /// </summary> 120 /// <remarks> 121 /// A value of 0 is treated the same as "unlimited" or <see cref="double.MaxValue"/>. 122 /// </remarks> 123 public double InactiveHz 124 { 125 get => inactiveHz; 126 set 127 { 128 inactiveHz = value; 129 updateMaximumHz(); 130 } 131 } 132 133 private double inactiveHz = DEFAULT_INACTIVE_HZ; 134 135 internal PerformanceMonitor Monitor { get; } 136 137 internal virtual IEnumerable<StatisticsCounterType> StatisticsCounters => Array.Empty<StatisticsCounterType>(); 138 139 /// <summary> 140 /// The main work which is fired on each frame. 141 /// </summary> 142 protected event Action OnNewFrame; 143 144 private readonly ManualResetEvent initializedEvent = new ManualResetEvent(false); 145 146 private readonly object startStopLock = new object(); 147 148 /// <summary> 149 /// Whether a pause has been requested. 150 /// </summary> 151 private volatile bool pauseRequested; 152 153 /// <summary> 154 /// Whether an exit has been requested. 155 /// </summary> 156 private volatile bool exitRequested; 157 158 internal GameThread(Action onNewFrame = null, string name = "unknown", bool monitorPerformance = true) 159 { 160 OnNewFrame = onNewFrame; 161 162 Name = name; 163 Clock = new ThrottledFrameClock(); 164 if (monitorPerformance) 165 Monitor = new PerformanceMonitor(this, StatisticsCounters); 166 Scheduler = new GameThreadScheduler(this); 167 168 IsActive.BindValueChanged(_ => updateMaximumHz(), true); 169 } 170 171 /// <summary> 172 /// Block until this thread has entered an initialised state. 173 /// </summary> 174 public void WaitUntilInitialized() 175 { 176 initializedEvent.WaitOne(); 177 } 178 179 /// <summary> 180 /// Returns a string representation that is prefixed with this thread's identifier. 181 /// </summary> 182 /// <param name="name">The content to prefix.</param> 183 /// <returns>A prefixed string.</returns> 184 public static string PrefixedThreadNameFor(string name) => $"{nameof(GameThread)}.{name}"; 185 186 /// <summary> 187 /// Start this thread. 188 /// </summary> 189 /// <remarks> 190 /// This method blocks until in a running state. 191 /// </remarks> 192 public void Start() 193 { 194 lock (startStopLock) 195 { 196 switch (state.Value) 197 { 198 case GameThreadState.Paused: 199 case GameThreadState.NotStarted: 200 break; 201 202 default: 203 throw new InvalidOperationException($"Cannot start when thread is {state.Value}."); 204 } 205 206 state.Value = GameThreadState.Starting; 207 PrepareForWork(); 208 } 209 210 WaitForState(GameThreadState.Running); 211 Debug.Assert(state.Value == GameThreadState.Running); 212 } 213 214 /// <summary> 215 /// Request that this thread is exited. 216 /// </summary> 217 /// <remarks> 218 /// This does not block and will only queue an exit request, which is processed in the main frame loop. 219 /// </remarks> 220 /// <exception cref="InvalidOperationException">Thrown when attempting to exit from an invalid state.</exception> 221 public void Exit() 222 { 223 lock (startStopLock) 224 { 225 switch (state.Value) 226 { 227 // technically we could support this, but we don't use this yet and it will add more complexity. 228 case GameThreadState.Paused: 229 case GameThreadState.NotStarted: 230 case GameThreadState.Starting: 231 throw new InvalidOperationException($"Cannot exit when thread is {state.Value}."); 232 233 case GameThreadState.Exited: 234 return; 235 236 default: 237 // actual exit will be done in ProcessFrame. 238 exitRequested = true; 239 break; 240 } 241 } 242 } 243 244 /// <summary> 245 /// Prepare this thread for performing work. Must be called when entering a running state. 246 /// </summary> 247 /// <param name="withThrottling">Whether this thread's clock should be throttling via thread sleeps.</param> 248 internal void Initialize(bool withThrottling) 249 { 250 lock (startStopLock) 251 { 252 Debug.Assert(state.Value != GameThreadState.Running); 253 Debug.Assert(state.Value != GameThreadState.Exited); 254 255 MakeCurrent(); 256 257 OnInitialize(); 258 259 Clock.Throttling = withThrottling; 260 261 Monitor.MakeCurrent(); 262 263 updateCulture(); 264 265 initializedEvent.Set(); 266 state.Value = GameThreadState.Running; 267 } 268 } 269 270 /// <summary> 271 /// Run when thread transitions into an active/processing state, at the beginning of each frame. 272 /// </summary> 273 internal virtual void MakeCurrent() 274 { 275 ThreadSafety.ResetAllForCurrentThread(); 276 } 277 278 /// <summary> 279 /// Runs a single frame, updating the execution state if required. 280 /// </summary> 281 internal void RunSingleFrame() 282 { 283 var newState = processFrame(); 284 285 if (newState.HasValue) 286 setExitState(newState.Value); 287 } 288 289 /// <summary> 290 /// Pause this thread. Must be run from <see cref="ThreadRunner"/> in a safe manner. 291 /// </summary> 292 /// <remarks> 293 /// This method blocks until in a paused state. 294 /// </remarks> 295 internal void Pause() 296 { 297 lock (startStopLock) 298 { 299 if (state.Value != GameThreadState.Running) 300 return; 301 302 // actual pause will be done in ProcessFrame. 303 pauseRequested = true; 304 } 305 306 WaitForState(GameThreadState.Paused); 307 } 308 309 /// <summary> 310 /// Spin indefinitely until this thread enters a required state. 311 /// For cases where no native thread is present, this will run <see cref="processFrame"/> until the required state is reached. 312 /// </summary> 313 /// <param name="targetState">The state to wait for.</param> 314 internal void WaitForState(GameThreadState targetState) 315 { 316 if (state.Value == targetState) 317 return; 318 319 if (Thread == null) 320 { 321 GameThreadState? newState = null; 322 323 // if the thread is null at this point, we need to assume that this WaitForState call is running on the same native thread as this GameThread has/will be running. 324 // run frames until the required state is reached. 325 while (newState != targetState) 326 newState = processFrame(); 327 328 // note that the only state transition here can be an exiting one. entering a running state can only occur in Initialize(). 329 setExitState(newState.Value); 330 } 331 else 332 { 333 while (state.Value != targetState) 334 Thread.Sleep(1); 335 } 336 } 337 338 /// <summary> 339 /// Prepares this game thread for work. Should block until <see cref="Initialize"/> has been run. 340 /// </summary> 341 protected virtual void PrepareForWork() 342 { 343 Debug.Assert(Thread == null); 344 createThread(); 345 Debug.Assert(Thread != null); 346 347 Thread.Start(); 348 } 349 350 /// <summary> 351 /// Called whenever the thread is initialised. Should prepare the thread for performing work. 352 /// </summary> 353 protected virtual void OnInitialize() 354 { 355 } 356 357 /// <summary> 358 /// Called when a <see cref="Pause"/> or <see cref="Exit"/> is requested on this <see cref="GameThread"/>. 359 /// Use this method to release exclusive resources that the thread could have been holding in its current execution mode, 360 /// like GL contexts or similar. 361 /// </summary> 362 protected virtual void OnSuspended() 363 { 364 } 365 366 /// <summary> 367 /// Called when the thread is exited. Should clean up any thread-specific resources. 368 /// </summary> 369 protected virtual void OnExit() 370 { 371 } 372 373 private void updateMaximumHz() 374 { 375 Scheduler.Add(() => Clock.MaximumUpdateHz = IsActive.Value ? activeHz : inactiveHz); 376 } 377 378 /// <summary> 379 /// Create the native backing thread to run work. 380 /// </summary> 381 /// <remarks> 382 /// This does not start the thread, but guarantees <see cref="Thread"/> is non-null. 383 /// </remarks> 384 private void createThread() 385 { 386 Debug.Assert(Thread == null); 387 Debug.Assert(!Running); 388 389 Thread = new Thread(runWork) 390 { 391 Name = PrefixedThreadNameFor(Name), 392 IsBackground = true, 393 }; 394 395 void runWork() 396 { 397 Initialize(true); 398 399 while (Running) 400 RunSingleFrame(); 401 } 402 } 403 404 /// <summary> 405 /// Process a single frame of this thread's work. 406 /// </summary> 407 /// <returns>A potential execution state change.</returns> 408 private GameThreadState? processFrame() 409 { 410 if (state.Value != GameThreadState.Running) 411 // host could be in a suspended state. the input thread will still make calls to ProcessFrame so we can't throw. 412 return null; 413 414 MakeCurrent(); 415 416 if (exitRequested) 417 { 418 exitRequested = false; 419 return GameThreadState.Exited; 420 } 421 422 if (pauseRequested) 423 { 424 pauseRequested = false; 425 return GameThreadState.Paused; 426 } 427 428 try 429 { 430 Monitor?.NewFrame(); 431 432 using (Monitor?.BeginCollecting(PerformanceCollectionType.Scheduler)) 433 Scheduler.Update(); 434 435 using (Monitor?.BeginCollecting(PerformanceCollectionType.Work)) 436 OnNewFrame?.Invoke(); 437 438 using (Monitor?.BeginCollecting(PerformanceCollectionType.Sleep)) 439 Clock.ProcessFrame(); 440 441 Monitor?.EndFrame(); 442 } 443 catch (Exception e) 444 { 445 if (UnhandledException != null && !ThreadSafety.IsInputThread) 446 // the handler schedules back to the input thread, so don't run it if we are already on the input thread 447 UnhandledException.Invoke(this, new UnhandledExceptionEventArgs(e, false)); 448 else 449 throw; 450 } 451 452 return null; 453 } 454 455 private void updateCulture() 456 { 457 if (Thread == null || culture == null) return; 458 459 Thread.CurrentCulture = culture; 460 Thread.CurrentUICulture = culture; 461 } 462 463 private void setExitState(GameThreadState exitState) 464 { 465 lock (startStopLock) 466 { 467 Debug.Assert(state.Value == GameThreadState.Running); 468 Debug.Assert(exitState == GameThreadState.Exited || exitState == GameThreadState.Paused); 469 470 Thread = null; 471 OnSuspended(); 472 473 switch (exitState) 474 { 475 case GameThreadState.Exited: 476 Monitor?.Dispose(); 477 initializedEvent?.Dispose(); 478 479 OnExit(); 480 break; 481 } 482 483 state.Value = exitState; 484 } 485 } 486 487 private class GameThreadScheduler : Scheduler 488 { 489 public GameThreadScheduler(GameThread thread) 490 : base(() => thread.IsCurrent, thread.Clock) 491 { 492 } 493 } 494 } 495}