Univer
Univer Doc
Architecture
Architecture of Rendering Engine

Architecture of Rendering Engine

📊📝📽️ Univer General

The rendering engine of Univer is inspired by KonvaJs / FabricJs and BabylonJs, and is implemented based on Canvas2D.

The purpose of designing the rendering engine is to integrate the rendering of documents, electronic spreadsheets, and presentations, and to reuse rendering capabilities while making their rendering interoperable. Therefore, in electronic spreadsheets, the typesetting and rendering of text within cells is completely reused from that of documents, so that cells support all typesetting capabilities of documents, and can maintain a consistent rendering effect in the cell editor.

SheetEditorRichText

Overall Architecture

The rendering engine adopts an object-oriented approach, abstracting each element that needs to be drawn as an Object, and implementing a nested structure through elements such as Group and SceneViewer.

The overall architecture diagram is as follows:

rendererArchitecture

Engine

Manages the canvas instance (e.g. modifying the width and height of the canvas), provides an API to drive the scene for frame-by-frame drawing, and encapsulates the event mechanism for use by the lower layers.

Scene

The Scene is the space where all rendering objects exist, and its area may exceed the size of the current Viewport (Engine).

rendererArchitecture

The Scene needs to be added to the Engine, and an Engine can have multiple Scenes, which can be switched by Engine.runRenderLoop to render the current Scene.

const engine = new Engine();
const scene = new Scene(SCENE_NAMESPACE, engine, { width, height });
engine.runRenderLoop(() => {
  scene.render();
});

Each Scene has its own event listener, and all rendering objects Object must be added to the Scene in order to be rendered.

const spreadsheet = new Spreadsheet(SHEET_VIEW_KEY.MAIN);
const spreadsheetRowHeader = new SpreadsheetRowHeader(SHEET_VIEW_KEY.ROW);
const spreadsheetColumnHeader = new SpreadsheetColumnHeader(SHEET_VIEW_KEY.COLUMN);
const SpreadsheetLeftTopPlaceholder = new Rect(SHEET_VIEW_KEY.LEFT_TOP, {
  zIndex: 2,
  left: -1,
  top: -1,
  fill: 'rgb(248, 249, 250)',
  stroke: 'rgb(217, 217, 217)',
  strokeWidth: 1,
});
 
scene.addObjects([spreadsheet, spreadsheetRowHeader, spreadsheetColumnHeader, SpreadsheetLeftTopPlaceholder],

It is allowed to directly add events to the Scene, just like adding a global event to the document in DOM.

For example, you can add a MouseMove event to the scene like this:

// Add a new MouseMove event to the scene
scene.onPointerMoveObserver.add((moveEvt: IPointerEvent | IMouseEvent) => {
  const { offsetX: moveOffsetX, offsetY: moveOffsetY } = moveEvt;
  /// ...
});

Viewport

In order to support frozen scenes in electronic spreadsheets, the concept of Camera in BabylonJs is referenced, and the position and width and height of the Viewport can be set to specify which part of the Scene to render.

As shown in the following figure, a spreadsheet in a normal state has 4 Viewports, each corresponding to the top-left corner's selection box, row headers, column headers, and the main content area. Only a ScrollBar is added to the main content area. When row and column freezing is supported, the number of Viewports may increase to 9. If row footer freezing is also supported, the number of Viewports will further increase to 12.

rendererArchitecture

Here's an example of adding a Viewport:

// Create a viewport and add an scrollbar.
const viewMain = new Viewport(VIEWPORT_KEY.VIEW_MAIN, scene, {
  left: rowHeader.width,
  top: columnHeader.height,
  bottom: 0,
  right: 0,
  isWheelPreventDefaultX: true,
});
 
new ScrollBar(viewMain);

Indeed, during rendering, the Viewport also propagates its own viewport information to the Objects that require rendering, allowing for the exclusion of rendering Objects outside of its own viewport. This can optimize rendering performance and improve efficiency.

Layer

The Layer design refers to Konva, but developers do not need to manually create Layers. The rendering engine will automatically create Layers through the Scene method. You can choose whether to enable caching for the Layer layer. This can improve performance when there are many elements, but the browser has a total area limit for canvas. Therefore, Univer requires users to specify which layer's caching to open.

class Scene {
  // When adding an object to the `Scene`, the `Scene` will determine if a new `Layer` is needed
  addObject(o: BaseObject, zIndex: number = 1): this {
    this.getLayer(zIndex)?.addObject(o);
    return this;
  }
 
  getLayer(zIndex: number = 1): Layer | undefined {
    for (const layer of this._layers) {
      if (layer.zIndex === zIndex) {
        return layer;
      }
    }
    return this._createDefaultLayer(zIndex);
  }
 
  // Enable caching for the specified `Layer` by calling the `scene` method
  enableLayerCache(...layerIndexes: number[]): this {
    layerIndexes.forEach((zIndex) => {
      this.getLayer(zIndex)?.enableCache();
    });
    return this;
  }
}

An Object is attached to a Layer, and each Layer has an off-screen canvas as a cache. As long as the layer is not "dirty", the cached content will be directly copied onto the canvas.

Object

It is essential that all objects requiring rendering extend from the BaseObject class:

rendererBaseObject

Shape

Shape implements basic shapes, such as Circle, Rect, Path, and Polygon, with static functions to make them easily usable by other objects.

Here is an example of a Rect shape:

export class Rect<T extends IRectProps = IRectProps> extends Shape<T> {
  private _radius: number = 0;
 
  constructor(key?: string, props?: T) {
    super(key, props);
 
    if (props?.radius) {
      this._radius = props.radius;
    }
  }
 
  static override drawWith(ctx: UniverRenderingContext, props: IRectProps | Rect) {
    let { radius, width, height } = props;
 
    radius = radius ?? 0;
    width = width ?? 30;
    height = height ?? 30;
 
    ctx.beginPath();
 
    if (props.strokeDashArray) {
      ctx.setLineDash(props.strokeDashArray);
    }
 
    ctx.rect(0, 0, width, height);
 
    ctx.closePath();
    this._renderPaintInOrder(ctx, props);
  }
 
  protected override _draw(ctx: UniverRenderingContext) {
    Rect.drawWith(ctx, this);
  }
}

Component

To render more complex objects, such as spreadsheets, documents, and slides, in order to handle complex rendering logic, Univer has designed a ViewModel layer called Skeleton, which is responsible for handling calculated layout data and providing canvas-coordinate-to-Component-internal-coordinate conversion. An Extension is responsible for rendering a specific part of a Component and can be injected with user logic to alter rendering behavior, such as data validation, conditional formatting, or cell images.

rendererComponent

For instance, when drawing a spreadsheet content area, three components must be considered: background color, text, and borders, which results in 3 extensions that accept SpreadsheetSkeleton as input and render each cell based on the provided layout information.

Components support using a canvas as a buffer for caching and offscreen rendering, which significantly improves performance, especially during scrolling. The rendering engine currently only draws incremental views when scrolling the sheet.

rendererTextureCache

Render Unit

render unit architecture

In order to support disposing units and creating new units, we have created a mechanism called RenderUnit. Each RenderUnit maps to a unit and holds an Injector. This injector is responsible for instantiating render controllers. A render controller is a class that is responsible for rendering a specific part of the unit, and it should implements interface IRenderController.


Copyright © 2021-2024 DreamNum Co,Ltd. All Rights Reserved.