编写一个简单的2D渲染引擎

2024-11-08 24:05:02/62天之前

市面上成熟的2d render engine数不胜数例如konva.js,pixi.js,zrender 等其他。他们都十分的优秀 在学习他们之前 我们可以编写个简单的render engine去感受这些上层的抽象,我在写这篇blog的时候是因为我刚刚完成了一个新的作品squarified

在我们使用比如konva.js这样的渲染引擎的时候其实不难发现框架会把画布进行一个拆分stage,group,graphics,event,animation…。这里我不会详细展开他们的细节。 但是我们需要关注的是如何为我们自己的引擎定义一个简单的抽象。在一个极小的需求场景下我们的设计可以随意一点比如schedule,graphics,render。我的渲染引擎只做了这3部分 剩下的内容都是采用动态拓展的方式去扩展整个的引擎而不是内聚这样的好处是他是可以拆卸的。

Schedule

相比其他的渲染引擎schedule的职责非常简单只需要管理图形的更新就行比如变换平移以及一些原生canvas指令的抽象转译。举个例子比如我们创建了一个graphics如下:

import { Rect, Schedule } from 'lib'


const rectangle =const new Rect({
  style: {
    fill: 'red',
    lineWidth: 2
  }
})

const schedule = new Schedule()

schedule.add(rectangle)

schedule.update()

这时候呢Schedule里就会存在一个图形然后在调用update的时候把这个图形的比如style属性转换成对应的canvas指令 在这个示例中我们会把style对象转换成如下指令

// ctx 指的是canvas的 context

ctx.fillStyle = 'red'
ctx.lineWidth = 2

// update
// render 指的是 canvas

function update() {
  ctx.save()
  // ... traverse 图形 做matrix矩阵变换和上面的指令
  ctx.restore()
}

这些其实就是Schedule的核心伪代码了。

Box

既然我们有了Schedule 那么我们的图形怎么组织管理是一个问题,因此我们需要抽象出一个容器的概念去存放我们的图形 让相关联的图形作为一个组这样我们在更新或者后续的优化都是可以更加方便的。

class Box extends Display {
  constructor() {
    this.elements = []
    this.parent = null
  }
  add() {}
  destory() {}
  remove() {}
  removeAt() {}
}

有了Box我们就可以把我们刚才的rectangle放到这个容器里(注意容器是可以嵌套的) 为了简单我们不会给graphics设计parentchild的概念,所有具备父子关系的图形都应该被装到Box里。

到这里我们就实现了渲染引擎所需要的基本功能了,关于cache,hit testing,aabb,nesting check 这些功能作为一个简单的教程不会涉及(或许哪天我有兴趣了会单独开一篇)。

为了模块化的设计我们需要实现混入的功能相比extends他更加强大。

export interface InheritedCollections<T = {}> {
  name: string
  fn: (instance: T) => void
}

export function mixin<T>(applyTo: T, methods: InheritedCollections<T>[]) {
  methods.forEach(({ name, fn }) => {
    Object.defineProperty(app, name, {
      value: fn(app),
      writable: false
    })
  })
}

有了mixin我们就可以设计和event模块了

type EventCallback<P = any[]> = P extends any[] ? (...args: P) => any : never

export type DefaultEventDefinition = Record<string, EventCallback>

export type BindThisParameter<T, C = unknown> = T extends (...args: infer P) => infer R ? (this: C, ...args: P) => R
  : never

export interface EventCollectionData<EvtDefinition extends DefaultEventDefinition, C = unknown> {
  name: string
  handler: BindThisParameter<EvtDefinition[keyof EvtDefinition], C>
  ctx: C
}

export type EventCollections<EvtDefinition extends DefaultEventDefinition> = Record<
  keyof EvtDefinition,
  EventCollectionData<EvtDefinition>[]
>

export class Event<EvtDefinition extends DefaultEventDefinition = DefaultEventDefinition> {
  eventCollections: EventCollections<EvtDefinition>

  constructor() {
    this.eventCollections = Object.create(null)
  }

  on<C, Evt extends keyof EvtDefinition>(evt: Evt, handler: BindThisParameter<EvtDefinition[Evt], unknown extends C ? this : C>, c?: C) {
    if (!(evt in this.eventCollections)) {
      this.eventCollections[evt] = []
    }

    const data = <EventCollectionData<EvtDefinition>> {
      name: evt,
      handler,
      ctx: c || this
    }
    this.eventCollections[evt].push(data)
  }

  off(evt: keyof EvtDefinition, handler?: BindThisParameter<EvtDefinition[keyof EvtDefinition], unknown>) {
    if (evt in this.eventCollections) {
      if (!handler) {
        this.eventCollections[evt] = []
        return
      }
      this.eventCollections[evt] = this.eventCollections[evt].filter((d) => d.handler !== handler)
    }
  }

  emit(evt: keyof EvtDefinition, ...args: Parameters<EvtDefinition[keyof EvtDefinition]>) {
    if (!this.eventCollections[evt]) { return }
    const handlers = this.eventCollections[evt]
    if (handlers.length) {
      handlers.forEach((d) => {
        d.handler.call(d.ctx, ...args)
      })
    }
  }

  bindWithContext<C>(
    c: C
  ) {
    return (evt: keyof EvtDefinition, handler: BindThisParameter<EvtDefinition[keyof EvtDefinition], unknown extends C ? this : C>) =>
      this.on(evt, handler, c)
  }
}

// Schedule.ts

export type PrimitiveEvent = typeof primitiveEvents[number]

export interface PrimitiveEventMetadata<T extends keyof HTMLElementEventMap> {
  native: HTMLElementEventMap[T]
  module: LayoutModule
}

export type PrimitiveEventCallback<T extends PrimitiveEvent> = (metadata: PrimitiveEventMetadata<T>) => void

type SelfEventCallback<T extends PrimitiveEvent | 'wheel'> = (metadata: PrimitiveEventMetadata<T>) => void

export type PrimitiveEventDefinition = {
  [key in PrimitiveEvent]: BindThisParameter<PrimitiveEventCallback<key>, TreemapInstanceAPI>
}

const schedule = new Schedule()

const event = new Event()

const methods: InheritedCollections[] = [
  {
    name: 'on',
    fn: () => event.bindWithContext(treemap.api).bind(event)
  },
  {
    name: 'off',
    fn: () => event.off.bind(event)
  },
  {
    name: 'emit',
    fn: () => event.emit.bind(event)
  }
]

function bindPrimitiveEvent(
  c: HTMLCanvasElement,
  evt: PrimitiveEvent,
  bus: Event<SelfEventDefinition>
) {
  const handler = (e: unknown) => {
    const { x, y } = captureBoxXY(
      c,
      e,
      self.scaleRatio,
      self.scaleRatio,
      self.translateX,
      self.translateY
    )

    const event = <PrimitiveEventMetadata<PrimitiveEvent>> {
      native: e,
      module: findRelativeNode(c, { x, y }, treemap.layoutNodes)
    }
    bus.emit(evt, event)
  }
  c.addEventListener(evt, handler)
  return handler
}

const events = ['click', 'mousemove']

for (const evt of events) {
  bindPrimitiveEvent(c, evt, event)
}

mixin(schedule, methods)

// ... 自己实现一些dom的绑定

到这为止我们就实现了一个简单的render engine的骨架了。

CC BY-NC-SA 4.02024-PRESENT © Kanno