Author – Oasis team – Cheng Kong

preface

In the past, people’s cognition of Oasis has been stuck in the 3D field. In the past, we supported the implementation of many 3D interactive projects. As the number of services we serve is increasing and the complexity is getting higher and higher, only providing 3D capability can no longer fully meet the business needs, so this year we began to expand 2D capability. The most basic ones in 2D are SpriteRenderer and SpriteMask, which we’ve done in version 0.3 of the engineSpriteRendererAnd this article is mainly to shareSpriteMaskThe final results are shown below (left is the inner maskVisibleInsideMask, the right picture is the maskVisibleOutsideMask) :

research

The main purpose of SpriteMask is to work with SpriteRenderer to create Sprite masks. Before going into full development, we looked at two aspects: how some of the industry’s engines use masks at the developer level, and what technical solutions are available to implement masks at the underlying implementation level.

use

At the developer level, there are two main ways to use masks in the industry: based on the node tree hierarchy and based on the render order.

Based on node tree hierarchy

The use of hierarchical structure based on node tree is roughly as follows:



Masks are applied to all the rendering components in the child node. This way of using a mask depends on the hierarchical structure of the node tree. When a Sprite needs multiple masks, multiple masks need to be nested, and if one mask needs to change dynamically, the structure of the tree may need to be adjusted.

Based on the render order

Based on the way the render order is used, the mask is set to the final render range of the two masks [front, back], combined with the render order of the Sprite (screen out)ZIn the positive direction, when two sprites overlap, the larger Z will be rendered on top, i.e. the smaller Z will be overlaid), as follows:



As you can see, masks are strongly related to the render order and are naturally implemented, but not flexible enough. For example, in the image above, we want masks to work with a Sprite mask with Z = 0, but not with the rest unchanged.

Oasis: Based on the mask layer

Whether based on node tree hierarchy or render order, it is not flexible enough. SpriteMask’s mask to SpriteRenderer will be affected by some external factors, such as node tree hierarchy or render order. We want SpriteMask to quickly match SpriteRenderer (Match: A SpriteMask can produce a mask on the SpriteRenderer called a match), and is not affected by external factors, so we design in the way of useThe mask layerWhen the mask that SpriteMask affects intersects with the mask that SpriteRenderer is in, as follows:

Technology selection

The industry realizes the masking ability mainly includes: rectangular mask, rectangular rotating mask, picture mask, geometric polygon mask, inner and outer mask. Oasis is a mobile-first Web graphics engine, so we can use webGL to implement various mask effects, including stencil, Framebuffer, scissor, and Shader. Next, let’s consider both functionality and performance.

Fully functional

From the perspective of complete functions, the analysis and comparison are shown in the following table:

performance

From a functional perspective, we can rule out scissor and Shader solutions, and we need to compare stencil and framebuffer from a performance perspective. We used WebGL to implement the stencil and framebuffer schemes separately, increasing the number of masks and calculating the average frame time (in ms) for 100 frames. The results are as follows:

Test environment device: MacBook Pro processor: 2.4 GHz quad-core Intel Core i5 Browser: Chrome 90.0.4430.212

Stencil: codepen. IO /chengkong/p… Framebuffer: codepen. IO/chengkong/p…

conclusion

By comparing the two dimensions, from the point of view of full functionality, we can rule out other options, leaving only the stencil and framebuffer. From a performance perspective, the framebuffer scheme was approximately 10 orders of magnitude slower than the stencil, so we decided to use the Stencil scheme for the mask.

Key design and implementation

After the survey, the use method and technical solution have been clear, the next is the design of the core class. Here are a few key concepts to understand: mask layers, mask regions, and mask types.

The mask layerIt’s a concept that we abstracted as a link to how SpriteMask and SpriteRenderer match,Mask areasIt means that we’re going to mask a particular area,Mask typeRepresents a mask handling scheme (inner mask, outer mask).

design

As a result, the developer used the following method:

const sprEntity = rootEntity.createChild("Sprite");
// 1.1 Add a SpriteRenderer
const renderer = sprEntity.addComponent(SpriteRenderer);
renderer.sprite = sprite;
// 1.2 Set the mask type
renderer.maskInteraction = SpriteMaskInteraction.VisibleInsideMask;
// 1.3 Set the mask layer of the Sprite
renderer.maskLayer =  SpriteMaskLayer.Layer0;

const maskEntity = rootEntity.createChild("Mask");
// 2.1 Add a SpriteMask
const mask = maskEntity.addComponent(SpriteMask);
// 2.2 Set the mask area
mask.sprite = maskSprite;
// 2.3 Set the influence mask layer to match the Sprite's mask layer
mask.influenceLayers = SpriteMaskLayer.Layer0;
Copy the code

The diagram of related classes is as follows:

The mask layer

The mask layer determines how the SpriteMask and SpriteRenderer match quickly. Let’s define all the mask layers as follows:

/** * Sprite mask layer. */
export enum SpriteMaskLayer {
    /** Mask layer 0. */
  Layer0 = 0x1./** Mask layer 1. */
  Layer1 = 0x2./** Mask layer 31. */
  Layer31 = 0x80000000./** All mask layers. */
  Everything = 0xffffffff
}
Copy the code

There are 32 masks, why?? The Number type is 64-bit, but all the bitwise operations are performed on 32-bit binary numbers, each of which can represent a layer, so we can quickly filter by bitwise operations when matching. And 32 masks per scene should suffice (I’ve never come across a project that uses this many masks at once). SpriteRenderer (SpriteRenderer) and SpriteMask (SpriteMask, SpriteRenderer)

class SpriteRenderer extends Renderer {
  /** * The mask layer the sprite renderer belongs to. */
  get maskLayer() :number;
  set maskLayer(value: number);
}

class SpriteMask extends Renderer {
  /** The mask layers the sprite mask influence to. */
  influenceLayers: number = SpriteMaskLayer.Everything;
}
Copy the code

Mask areas

In the current version we plan to implement the image mask first, where the area of the mask is determined by the image set by the mask, so add a property to the SpriteMask to set the mask image, as follows:

class SpriteMask extends Renderer {
  /** The mask layers the sprite mask influence to. */
  influenceLayers: number = SpriteMaskLayer.Everything;
  
  /** * The Sprite used to define the mask. */
  get sprite() :Sprite;
  set sprite(value: Sprite);
}
Copy the code

Mask type

After the mask layer is designed, it is clear how to match SpriteMask and SpriteRenderer quickly. The next important design is the Sprite to be masked. Does it show the content inside or outside the mask area? First we define an enumeration of mask types, as follows:

/** * Sprite mask interaction. */
export enum SpriteMaskInteraction {
  /** The sprite will not interact with the masking system. */
  None,
  /** The sprite will be visible only in areas where a mask is present. */
  VisibleInsideMask,
  /** The sprite will be visible only in areas where no mask is present. */
  VisibleOutsideMask
}
Copy the code

The selection of the mask type should be determined by SpriteRenderer, so we’ll add an attribute to SpriteRenderer to mark it as follows:

class SpriteRenderer extends Renderer {  
  /** * Interacts with the masks. */
  get maskInteraction() :SpriteMaskInteraction;
  set maskInteraction(value: SpriteMaskInteraction); / * * *The mask layer the sprite renderer belongs to. * /get maskLayer() :number;
  set maskLayer(value: number);
}
Copy the code

implementation

Let’s first look at the flow chart that is finally implemented in the entire rendering pipeline:

Mask layer matching

The basic principle of

Although the SpriteMask inherits from the Renderer, we do not send the SpriteMask directly to the render queue on each call to _render. Instead, we cache the SpriteMask in the render pipeline, as follows:

export class SpriteMask extends Renderer {
  _render(camera: Camera): void {
    // ...
    
    // If it is a SpriteMask render component, cache it directly in the render pipeline
    camera._renderPipeline._allSpriteMasks.add(this);
    
    // ...}}Copy the code

To answer this question, we need to look at how Oasis now feeds the content that needs to be rendered into the final render, as follows: Typically, once the render component has thrown itself into the render queue, it is just a bunch of render elements for the entire render pipeline, and once the render queue is sorted, it is rendered one by one (the green part of the flowchart). So far, we are still not able to explain the above questions. Let’s take a look at how to implement the mask using stencil. We always set the reference value of the template test to 1, as follows:

  1. Send all SpriteMask that affect sprites to the GPU for template testing and update the value of the template buffer
  2. When rendering sprites, select a comparison function based on the mask type (gl.stencilFunc)
  3. The stencil test pixels can be rendered

Did you find a problem? The first step is to send all the SpriteMask that has influence to the GPU. Assuming that there is a SpriteMask that has influence on two different sprites, it must be sent twice. Obviously, it cannot be done according to the existing rendering process. So we need to cache the SpriteMask separately (the blue part of the flow chart), and when we render to a Sprite, we will find all the sprItemasks that match and update the template buffer.

Optimization techniques

Here is a problem to think about. If we render two sprites consecutively, but the sprites match only one SpriteMask apart, then there is no need to update the template buffer one by one. Just diff the mask layer of the two sprites, which can effectively reduce the interaction with the GPU. Based on that, we addSpriteMaskManagerThe main idea is to record the mask layer of the previous Sprite (called preSprite). When rendering the new Sprite (called curSprite), find the difference between the two Sprite masks. There are three cases: CommonLayer, addLayer, and reduceLayer. ReduceLayer: reduceLayer: reduceLayer: reduceLayer: reduceLayer: reduceLayer: reduceLayer: reduceLayer: reduceLayer: reduceLayer: reduceLayer: reduceLayer: reduceLayer: reduceLayer: reduceLayer: reduceLayer: reduceLayer: reduceLayer: reduceLayer: reduceLayer: reduceLayer: reduceLayer: reduceLayer



The core code to find the mask layer difference is as follows:

const commonLayer = preMaskLayer & curMaskLayer;
const addLayer = curMaskLayer & ~preMaskLayer;
const reduceLayer = preMaskLayer & ~curMaskLayer;
Copy the code

Next, we need to find the corresponding SpriteMask by the difference of the mask layer, and then carry out the corresponding operation. The SpriteMask identifies which mask layers it influences by influenceLayers, so we only need to do simple bit operation with the above three layers. The core code is as follows:

// Traverse masks.
for (let i = 0, n = allMasks.length; i < n; i++) {
  const mask = allMaskElements[i];
  const influenceLayers = mask.influenceLayers;

  // Do nothing for commonLayer.
  if (influenceLayers & commonLayer) {
    continue;
  }

  // Stencil value +1 for mask influence to addLayer.
  if (influenceLayers & addLayer) {
    const maskRenderElement = mask._maskElement;
    maskRenderElement.isAdd = true;
    this._batcher.drawElement(maskRenderElement);
    continue;
  }

  // Stencil value +1 for mask influence to reduceLayer.
  if (influenceLayers & reduceLayer) {
    const maskRenderElement = mask._maskElement;
    maskRenderElement.isAdd = false; 
    this._batcher.drawElement(maskRenderElement); }}Copy the code

Mask areas

When a SpriteMask matches, we need to update the stencil buffer. For addLayer, we need to give the corresponding position in the buffer +1, and for reduceLayer, we need to give the corresponding position in the buffer -1. The core code is as follows:

// Set the op that the stencil test passed.
const stencilState = material.renderState.stencilState;
const op = spriteMaskElement.isAdd ? StencilOperation.IncrementSaturate : StencilOperation.DecrementSaturate;
stencilState.passOperationFront = op;
stencilState.passOperationBack = op;
Copy the code

Mask type

After finding all the SpriteMask by matching the mask layer and updating the stencil buffer data, we need to set the template test function based on the set mask type. The core code is as follows:

if (maskInteraction === SpriteMaskInteraction.None) {
  // When the mask is not needed, the stencil test always passed.
  stencilState.enabled = false;
  stencilState.writeMask = 0xff;
  stencilState.referenceValue = 0;
  stencilState.compareFunctionFront = stencilState.compareFunctionBack = CompareFunction.Always;
} else {
  stencilState.enabled = true;
  stencilState.writeMask = 0x00;
  // When a mask is needed, set ref to 1, inside mask ref <= stencil, outside mask ref> stencil.
  stencilState.referenceValue = 1;
  const compare =
        maskInteraction === SpriteMaskInteraction.VisibleInsideMask
  ? CompareFunction.LessEqual
  : CompareFunction.Greater;
  stencilState.compareFunctionFront = compare;
  stencilState.compareFunctionBack = compare;
}
Copy the code

conclusion

Eventually we realized SpriteMask version (support photo mask), see: oasisengine. Cn / 0.4 / docs/sp… . And can use our sample to check the detailed usage, see: oasisengine. Cn / 0.4 / example… .

At present, we only implement image masks in SpriteMask, which can meet most of the needs. We will consider whether to support rectangular masks, oval masks, custom image masks and so on according to the actual needs of developers. And then the mask will support the entire 2D ecology, not just SpriteRenderer.

The last

You are welcome to star our Github repository. You can also pay attention to our subsequent V0.5 planning at any time. You can also ask us your needs and questions in issues. Developers can join us in the Nail group to joke with us and discuss some questions, nail search 31360432

Whether you are rendering, TA, Web front end or game direction, as long as you are like us, eager to achieve the oasis in your heart, welcome to send your resume to [email protected]. Job description: www.yuque.com/oasis-engin… .