Skip to content

Instantly share code, notes, and snippets.

@nandordudas
Last active August 24, 2024 13:07
Show Gist options
  • Save nandordudas/97c456b47f9e27793039509d1dcf47d9 to your computer and use it in GitHub Desktop.
Save nandordudas/97c456b47f9e27793039509d1dcf47d9 to your computer and use it in GitHub Desktop.
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)
}
@nandordudas
Copy link
Author

nandordudas commented Aug 24, 2024

TypeScript: TS Playground

class Renderer {
  // ...

  public drawCircle(
    x: number,
    y: number,
    radius: number,
    fillStyle: string,
    strokeStyle: string = 'transparent',
  ): void {
    this._context.beginPath()
    this._context.arc(x, y, radius, 0, Renderer.TAU)
    this._context.fillStyle = fillStyle
    this._context.fill()
    this._context.strokeStyle = strokeStyle
    this._context.stroke()
  }

  // ...
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment