Write a simple 2D render engineⅡ

2025-01-24 00:04:09/41 Days ago

In past i wrote a relative blog about simple 2D render engine, but for a tutorial, it’s too simple and in Ⅱ i’ll reorganize some ideas. We already have a simple engine. We implement the Event, Graph, Schedule but i didn’t share any details.

Why we should consider the DIP?

I would like to clarify some concepts about dip.(Because it’s really important) Actually browser provide us a variable window.devicePixelRatio to show the dip of our display. Follwing the Mdn we can know if we want to implement Schedule we should respect the dip. The simple approach:

export function createCanvasElement() {
  return document.createElement('canvas')
}

export function applyCanvasTransform(ctx: CanvasRenderingContext2D, matrix: Matrix2D, dip: number) {
  ctx.setTransform(matrix.a * dip, matrix.b * dip, matrix.c * dip, matrix.d * dip, matrix.e * dip, matrix.f * dip)
}

Ok, let we declare two help function and use them into Schedule

// First cleanup canvas
export function drawGraphIntoCanvas(
  graph: Display,
  opts: DrawGraphIntoCanvasOptions
) {
  const { ctx, dpr } = opts
  ctx.save()
  if (asserts.isBox(graph)) {
    const elements = graph.elements
    const cap = elements.length
    for (let i = 0; i < cap; i++) {
      const element = elements[i]
      drawGraphIntoCanvas(element, opts)
    }
  }
  if (asserts.isGraph(graph)) {
    const matrix = graph.matrix.create({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 })
    matrix.transform(graph.x, graph.y, graph.scaleX, graph.scaleY, graph.rotation, graph.skewX, graph.skewY)
    applyCanvasTransform(ctx, matrix, dpr)
    graph.render(ctx)
  }
  ctx.restore()
}

export class Schedule<D extends DefaultEventDefinition = DefaultEventDefinition> extends Box {
  render: Render
  to: HTMLElement
  event: Event<D>
  constructor(to: ApplyTo, renderOptions: Partial<RenderViewportOptions> = {}) {
    super()
    this.to = typeof to === 'string' ? document.querySelector(to)! : to
    if (!this.to) {
      throw new Error(log.error('The element to bind is not found.'))
    }
    const { width, height } = this.to.getBoundingClientRect()
    Object.assign(renderOptions, { width, height }, { devicePixelRatio: window.devicePixelRatio || 1 })
    this.event = new Event()
    this.render = new Render(this.to, renderOptions as RenderViewportOptions)
  }

  update() {
    this.render.clear(this.render.options.width, this.render.options.height)
    this.execute(this.render, this)
    const matrix = this.matrix.create({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 })
    applyCanvasTransform(this.render.ctx, matrix, this.render.options.devicePixelRatio)
  }

  // execute all graph elements
  execute(render: Render, graph: Display = this) {
    drawGraphIntoCanvas(graph, { c: render.canvas, ctx: render.ctx, dpr: render.options.devicePixelRatio })
  }
}

Look at the function drawGraphIntoCanvas we can know when we draw each graph we called applyCanvasTransform it will auto multiply the dip.

Using cache system to improve your application.

Now we have a simple render engine and we can draw every shape what we want, but if we draw too many graphics, You’ll notice that our canvas start to lag. Let me add a simple FPS monitor.

const badge = document.createElement('div')
badge.style.position = 'fixed'
badge.style.left = '20px'
badge.style.bottom = '20px'
badge.style.padding = '10px'
badge.style.backgroundColor = 'rgba(0, 0, 0, 0.7)'
badge.style.color = 'white'
badge.style.borderRadius = '5px'
badge.style.fontFamily = 'Arial, sans-serif'
badge.style.fontSize = '14px'
badge.textContent = 'FPS: 0'
document.body.appendChild(badge)
let lastFrameTime = 0
let frameCount = 0
let lastSecond = 0
function animate(currentTime: number) {
  if (lastFrameTime !== 0) {
    frameCount++
    if (currentTime - lastSecond >= 1000) {
      const fps = frameCount
      badge.textContent = `FPS: ${fps}`
      frameCount = 0
      lastSecond = currentTime
    }
  }
  lastFrameTime = currentTime
  requestAnimationFrame(animate)
}
requestAnimationFrame(animate)

My monitor refresh rate is 100 Hz. Ok, follow the badge when we drag the graphics quickly, we can see that the fps is dropping rapidly. (There’re 100,000 graph) So how do we fix it? Let’s get back to the topic of using cache.

monitor

export abstract class Cache {
  abstract key: string
  abstract get state(): boolean
  abstract flush(...args: never): void
  abstract destroy(): void
}

export class RenderCache extends Canvas implements Cache {
  key: string
  private $memory: boolean
  constructor(opts: RenderViewportOptions) {
    super(opts)
    this.key = 'render-cache'
    this.$memory = false
  }
  get state() {
    return this.$memory
  }
  flush(treemap: TreemapLayout, matrix = new Matrix2D()) {
    const { devicePixelRatio, width, height } = treemap.render.options
    const { a, d } = matrix
    const { size } = canvasBoundarySize
    // Check outof boundary
    if (width * a >= size || height * d >= size) {
      return
    }
    if (width * a * height * d >= size * size) {
      return
    }
    this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
    this.setOptions({ width: width * a, height: height * d, devicePixelRatio })
    resetLayout(treemap, width * a, height * d)
    drawGraphIntoCanvas(treemap, { c: this.canvas, ctx: this.ctx, dpr: devicePixelRatio })
    this.$memory = true
  }
  destroy() {
    this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
    this.$memory = false
  }
}

We draw all graphics into a offset screen canvas and re draw it when we trigger action.(Like mousedown and etc) Also we should draw it into main canvas with drawImage. Now we drag the cached canvas and found that the monitor frame rate tends to be stable.

Others

Due to limited space, I just briefly described some of the opinion I know. I don’t want to write a lot of content in a simple blog. Wouldn’t be easier for us to understand it into multiple parts?

CC BY-NC-SA 4.0  2024-PRESENT © Kanno