Last active
August 24, 2024 13:07
-
-
Save nandordudas/97c456b47f9e27793039509d1dcf47d9 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
type Constructor<T = void> = new (...args: any[]) => T | |
type RenderCallback = (deltaTime: number) => void | |
type FPSUpdateCallback = (fps: number) => void | |
declare namespace Rendering { | |
type Context2D = OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D | |
} | |
interface RendererContract<T extends Rendering.Context2D> { | |
context: T | |
render: (callback: FrameRequestCallback) => void | |
stop: () => void | |
} | |
const ONE_TAUSEND = 1_000 | |
const DEFAULT_FPS = 60 | |
/** | |
* This renderer ensures a consistent frame rate by utilizing the | |
* {@link requestAnimationFrame} API. It can render frames onto either an | |
* {@link OffscreenCanvasRenderingContext2D} (for use within {@link Worker}s) or | |
* a {@link CanvasRenderingContext2D} (for the main thread), adapting to the | |
* specific environment where it's deployed. | |
* | |
* @param context The 2D rendering context to render the frames. | |
* @returns A new renderer with the given context. | |
* @throws {TypeError} If the context is not a 2D rendering context. | |
* @example | |
* const canvas = new OffscreenCanvas(800, 600) | |
* const renderer = Renderer.create(canvas.getContext('2d')!) | |
* renderer.setFPS(60) | |
* renderer.setFPSUpdateCallback(console.log) | |
* const renderCallback: RenderCallback = (deltaTime) => { | |
* // Render the frame | |
* } | |
* renderer.render(renderCallback) | |
* renderer.stop() | |
*/ | |
class Renderer<T extends Rendering.Context2D> implements RendererContract<T> { | |
/** | |
* The mathematical constant tau (τ), equivalent to 2π, which represents the | |
* ratio of a circle's circumference to its radius. | |
* @example | |
* Math.PI * 2 // => 6.283185307179586 | |
*/ | |
public static readonly TAU = Math.PI * 2 | |
public static create<T extends Rendering.Context2D>(context: T): Renderer<T> { | |
return new Renderer(context as any) | |
} | |
private _rafId: number | null = null | |
private _lastTimestamp: number | null = null | |
private _targetFPS: number = DEFAULT_FPS | |
private _frameInterval: number = ONE_TAUSEND / this._targetFPS | |
private _frameCount: number = 0 | |
private _lastFpsUpdateTime: number = 0 | |
private _realFPS: number = 0 | |
private _callback: RenderCallback | null = null | |
private _onFPSUpdate: FPSUpdateCallback | null = null | |
public get context(): T { | |
return this._context as T | |
} | |
public get realFPS(): number { | |
return this._realFPS | |
} | |
private constructor(private _context: OffscreenCanvasRenderingContext2D) { | |
assert(_context !== null, 'Context must be a 2D rendering context') | |
} | |
public render(callback: RenderCallback): void { | |
this.stop() | |
this._callback = callback | |
this._resetAnimationState() | |
this._rafId = requestAnimationFrame(this._animate) | |
} | |
public stop(): void { | |
if (this._rafId === null) | |
return | |
cancelAnimationFrame(this._rafId) | |
this._rafId = null | |
} | |
public setFPS(value: number): void { | |
this._targetFPS = value | |
this._frameInterval = ONE_TAUSEND / value | |
} | |
public setFPSUpdateCallback(callback: FPSUpdateCallback): void { | |
this._onFPSUpdate = callback | |
} | |
private _clearCanvas(): void { | |
const { canvas } = this._context | |
this._context.clearRect(0, 0, canvas.width, canvas.height) | |
} | |
private _animate = (timestamp: number): void => { | |
this._lastTimestamp ??= timestamp | |
const elapsedTime = timestamp - this._lastTimestamp | |
if (elapsedTime >= this._frameInterval) { | |
const deltaTime = elapsedTime / ONE_TAUSEND | |
this._renderFrame(deltaTime) | |
this._updateFPSMetrics(timestamp) | |
this._lastTimestamp = timestamp - (elapsedTime % this._frameInterval) | |
} | |
this._rafId = requestAnimationFrame(this._animate) | |
} | |
private _renderFrame(deltaTime: number): void { | |
this._clearCanvas() | |
this._callback?.(deltaTime) | |
} | |
private _updateFPSMetrics(timestamp: number): void { | |
this._frameCount++ | |
if (timestamp - this._lastFpsUpdateTime < ONE_TAUSEND) | |
return | |
const frameRate = this._frameCount * ONE_TAUSEND | |
const timeDifference = timestamp - this._lastFpsUpdateTime | |
this._realFPS = Math.round(frameRate / timeDifference) | |
this._onFPSUpdate?.(this._realFPS) | |
this._frameCount = 0 | |
this._lastFpsUpdateTime = timestamp | |
} | |
private _resetAnimationState(): void { | |
this._lastTimestamp = null | |
this._frameCount = 0 | |
this._lastFpsUpdateTime = performance.now() | |
} | |
} | |
/** | |
* Asserts that a condition is true, throwing an error if it's false. | |
* | |
* @param condition The condition to check. | |
* @param message The error message to throw if the condition is false. | |
* @param ErrorType The constructor function for the error. Defaults to {@link Error}. | |
* @throws An instance of the specified error type if the condition is false. | |
* @example | |
* assert(true, "This won't throw") | |
* assert(false, "This will throw") | |
* class CustomError extends Error { } | |
* assert(false, "This will throw a custom error", CustomError) | |
*/ | |
function assert( | |
condition: boolean, | |
message: string, | |
ErrorType: ErrorConstructor = Error, | |
): asserts condition { | |
if (!condition) | |
raiseError(message, ErrorType) | |
} | |
/** | |
* Raises an error with the specified message and error type. | |
* | |
* @param message The error message. | |
* @param ErrorType The constructor function for the error. Defaults to {@link Error}. | |
* @throws An instance of the specified error type. | |
* @example | |
* raiseError("An error occurred") | |
* class CustomError extends Error { } | |
* raiseError("A custom error occurred", CustomError) | |
*/ | |
function raiseError( | |
message: string, | |
ErrorType: ErrorConstructor = Error, | |
): never { | |
throw new ErrorType(message) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
TypeScript: TS Playground