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.
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?