Cocos Creator 3.8 Post-Process Shader Writing (2/2) Advanced

Preface

In the last article, Kylin shared how to add your own post-effect Shader in the custom render pipeline in Cocos Creator 3.8. However, based on the BlitScreen solution, we can only write the simplest post-effect Shader. If we want to support more complex Shaders, such as Blur, Depth of Field, and other effects, we need to work with the code to build it.

Today Kylin will use Gaussian blur to demonstrate how to write a multi-pass post-effect shader.

Prepare the Shader

Gaussian blur is more involved with normal distributions, Gaussian functions, and Gaussian convolution kernels.

If you’re not interested in math, don’t worry about any of the above.

Simply put, Gaussian blur takes every pixel on an image and processes it with the following process.

To put it bluntly, it’s a simple weighted sum: while sampling the target pixel, sample some of the surrounding pixels and give each pixel a weight (the sum of the weights is 1.0). Final pixel value = sum of all (pixels * weights).

If you want good results, iterate a few more times; the more iterations, the better the picture, but the higher the performance overhead.

To create, we create a Cocos Shader file named “gaussian-blur.effect” and write the following.

CCEffect %{
  techniques:
  - passes:
    - vert: blur-hor-vs
      frag: blur-fs
      pass: blur-x
      depthStencilState:
        depthTest: false
        depthWrite: false
    - vert: blur-vert-vs
      frag: blur-fs
      pass: blur-y
      depthStencilState:
        depthTest: false
        depthWrite: false
}%

CCProgram blur-hor-vs %{
  //...
}%
CCProgram blur-hor-vs %{
  //...
}%

CCProgram blur-fs %{
  //...
}%

//...

As you can see, the entire Cocos Shader has only two Passes, one for horizontal blur and one for vertical blur.

The full Shader is placed at the end to avoid interfering with the article.

Note: In Cocos Creator 3.8.0, if there is a new post-effects shader, you need to restart the editor to recognize it. This process will be optimized in later versions.

Writing Component for Properties

In Cocos Creator 3.8, you only need to add the corresponding post-effect component to a node to start the corresponding post-effect.

image

We can inherit the postProcess.PostProcessingSetting component class to enable the visual interface configuration of post-effect parameters.

We can achieve the same experience as the built-in post-processing effects by customizing the post-processing effects on our own.

Create a new TS script file named “GaussianBlur.ts” and enter the following code.

Code Snippet 1:

import { _decorator, gfx, postProcess, Material, EffectAsset, renderer, rendering, Vec4 } from 'cc';
const { Format  } = gfx

const { ccclass, property, menu, executeInEditMode } = _decorator;

@ccclass('GaussianBlur')
@menu('PostProcess/GaussianBlur')
@executeInEditMode
export class GaussianBlur extends postProcess.PostProcessSetting{

    @property(EffectAsset)
    _effectAsset: EffectAsset | undefined

    @property(EffectAsset)
    get effect () {
        return this._effectAsset;
    }
    set effect (v) {
        this._effectAsset = v;
        if(this._effectAsset == null){
            this._material = null;
        }
        else{
            if(this._material == null){
                this._material = new Material();
            }
            this._material.reset({effectAsset:this._effectAsset});
        }
        this.updateMaterial();
    }
    
    @property
    iterations = 3;

    @property
    get blurRadius(){
        return this._blurParams.x;
    }
    set blurRadius(v){
        this._blurParams.x = v;
        this.updateMaterial();
    }


    private _material:Material;
    public get material():Material{
        return this._material;
    }

    @property
    private _blurParams:Vec4 = new Vec4(1.0,0.0,0.0,0.0);
    public get blurParams():Vec4{
        return this._blurParams;
    }

    updateMaterial(){
        if(!this._material){
            return;
        }
        this._material.setProperty('blurParams', this.blurParams);
    }

    protected start(): void {
        if(this._effectAsset){
            this._material = new Material();
            this._material.initialize({effectAsset:this._effectAsset});
            this._material.setProperty('blurParams', this.blurParams);
        }
    }
}

Next, we can add PostProcess/GansussianBlur to the post-processing node using the “Add Component” button.

image

As you can see, in the Inspector panel, it accepts three parameters.

  • effect: used to specify the Shader file to be used for this post-effect.

  • iterations: how many iterations are needed. The more iterations, the more blurring it is

  • blurRadius: the offset of the sampling near the pixels. The bigger the offset, the more blurring it is.

There is no effect because the corresponding rendering code has not yet been implemented.

Writing Render Code

The post-processing pipeline in Cocos Creator 3.8 is based on a new version of the custom pipeline based on the RenderGraph architecture.

You can simply think of a single post-processing effect that we want to implement corresponding to a node on the render flow graph.

The post-processing pipeline provides postProcess.SettingPass for us to write our own post-processing renderings. (It can also be called: custom the post-processing node’s resources and behaviors).

Next, let’s do the actual rendering implementation. We need to inherit the postProcess.SettingPass class from the custom pipeline and implementing the code we want.

  • get setting: get the setting information corresponding to the interface component implemented above.

  • checkEnable: use to judge whether this effect is enabled or not.

  • name: the effect’s name. Just keep it consistent with the class name.

  • outputNames: the final output RT array. (RTs for temporary use don’t need to be put here)

  • render: used to execute the rendering process

The complete code is as follows.

Code snippet 2:

export class GaussianBlurPass extends postProcess.SettingPass {
    get setting () { return this.getSetting(GaussianBlur); }

    checkEnable (camera: renderer.scene.Camera) {
        let enable = super.checkEnable(camera);
        if (postProcess.disablePostProcessForDebugView()) {
            enable = false;
        }
        return enable && this.setting.material != null;
    }

    name = 'GaussianBlurPass';
    outputNames = ['GaussianBlurMap'];

    public render (camera: renderer.scene.Camera, ppl: rendering.Pipeline): void {
        const setting = this.setting;
        if(!setting.material){
            return;
        }

        let passContext = this.context;
        passContext.material = setting.material;

        const cameraID = this.getCameraUniqueID(camera);
        const cameraName = `Camera${cameraID}`;
        const passViewport = passContext.passViewport;

        passContext.clearBlack();
        const format = Format.RGBA8;

        let input = this.lastPass!.slotName(camera, 0);
        for(let i = 0; i < setting.iterations; ++i){
            passContext
                .updatePassViewPort()
                .addRenderPass(`blur-x`, `blur-x${cameraID}`)
                .setPassInput(input, 'outputResultMap')
                .addRasterView('GaussianBlurMap_TMP', format)
                .blitScreen(0)
                .version();

            passContext
                .updatePassViewPort()
                .addRenderPass(`blur-y`, `blur-y${cameraID}`)
                .setPassInput('GaussianBlurMap_TMP', 'outputResultMap')
                .addRasterView(this.slotName(camera), format)
                .blitScreen(1)
                .version();
            input = this.slotName(camera);
        }
    }
}

Next, we’ll look mainly at what the render handles.

Preparation

Each SettingPass is a drawing node. The data for the node is stored in the context.

The render function will be executed every frame, so you need to call context.clearBack() to clean up the background.

Then, we have to set the material to context.

Blurring is handled by using the contents of the frame after the last process. So we use this.lastPass.slotName(camera,0); to get it.

Once everything is ready, it’s time to draw.

Drawing

Here, we use the iterations property to control the total number of iterations. For each iteration, the drawing process will go through.

Let’s look at what each step in the drawing process is used for.

  • updatePassViewPort: This function is used to specify the relative resolution size, which can be specified according to the algorithm requirements. If you want to keep it the same size as the background buffer, pass in 1.0.

  • addRenderPass: this function tells the pipeline that it needs to perform a rendering process.

  • layout: corresponds to the name of the pass in the Cocos Shader.

  • passName: the name of the pass, easy for debugging.

  • setPassInput: if you use RT resources in your custom pipeline (such as the result of the last execution), you need to specify them here so your custom pipeline can manage the resources.

  • inputName: the name of the resource assigned by the custom pipeline.

  • shaderName: the name of the uniform Sampler2D in Cocos Shader.

  • addRasterView: It can be understood as the output result

  • name: the name of the output RT, easy to reuse in subsequent processes.

  • format: the format of the output RT, e.g., RGBA8888, RGBA16F, etc.

  • blitScreen: execute drawing

  • passIdx: the pass index in Cocos Shader (this will be optimized in later versions, by then, the post-processing process can not pass this value).

  • version: meaningless, can be ignored.

Add to pipeline

After finishing the code, don’t forget to add the custom post-process shader to the pipeline, or nothing will happen.

Let’s add the following code at the end of the code file.

Code snippet 3:

let builder = rendering.getCustomPipeline('Custom') as postProcess.PostProcessBuilder;
if (builder) {
    builder.insertPass(new GaussianBlurPass(),postProcess.BlitScreenPass);
}

First, we get the Custom pipeline and add our newly written effect to it.

Return to the editor and adjust the parameters to see our new blur effect.

Post-Processing Pipeline Source Code Analysis

Here is some key source code to make it easier for you to understand the post-processing rendering flow.

Code Sets

Most of the post-processing-related classes are under postProcess. It is recommended to introduce postProcess from “cc” before using it.

RT Management

When the postProcess pipeline is turned on, the camera with the postProcess pipeline effect turned on will automatically generate RT and set its targetTexture.

The postprocessing pipeline is based on the RenderGraph pipeline architecture. The RenderGraph used in the Cocos engine is data-driven, collects the required RT resources per frame, and manages them in a unified way. So you don’t need to create new RTs manually.

Execution Order of Postprocessing Effects

Post-processing effects are not executed in the order they are added to the interface but in the order they are in the array. We can see its internal order through the source code as follows:

// pipeline related
  this.addPass(new HBAOPass());
  this.addPass(new ToneMappingPass());

  // user post-processing
  this.addPass(new TAAPass());
  this.addPass(new FxaaPass());
  this.addPass(new ColorGradingPass());
  this.addPass(new BlitScreenPass());
  this.addPass(new BloomPass());

  // final output
  this.addPass(new FSRPass()); // fsr should be final
  this.addPass(forwardFinal);

Post-processing effects are rendered, and the pipeline traverses this array, executing the available post-processing effects in turn.

for (let i = 0; i < passes.length; i++) {
    const pass = passes[i];
    if (!pass.checkEnable(camera)) {
        continue;
    }
    if (i === (passes.length - 1)) {
        passContext.isFinalPass = true;
    }
    pass.lastPass = lastPass;
    pass.render(camera, ppl);
    lastPass = pass;
}

Source location: engine/cocos/rendering/post-process/post-process-builder.ts

Notes on post-processing

Adding custom post-processes

There are a few things to keep in mind when adding your custom post-effects Shader to the pipeline:

  1. They must be added before ForwardFinal, or they have no effect. Therefore, builder.addPass is not recommended.

  2. When using builder.insterPass to add a new effect, the old one will be removed first if the new effect is renamed to the old one.

  3. builder.insterPass will insert the new effect after the one specified by the second parameter type. Usually, it is recommended to use postProcess.BlitScreenPass.

Effects

  1. TAA+FXAA+FSR should be turned on simultaneously to achieve a good anti-aliasing effect because TAA is mainly responsible for dynamic (inter-frame) anti-aliasing. Still, it will make the screen slightly blurry, FXAA is mainly responsible for edge anti-aliasing, and FSR can make the blurry screen clearer.

  2. ColorGrading is the most cost-effective post-production effect, which can quickly improve the image value of your project.

  3. Don’t rely on HBAO too much because it can’t run on low-end machines. Prioritize the scene to use lightmap to bake AO so that even if HBAO is turned off, the effect is still not bad.

  4. Adjust the threshold of Bloom down and the intensity down to create a full-screen floodlight and soften the image, and adjust the threshold of Bloom up and the intensity up to create a bright pixel exposure effect.

Memory

  1. Although RenderGraph automatically manages and reuses RT, after-effects still consume memory, so test how much memory is used when the desired after-effects are turned on.

  2. Memory overhead is resolution dependent, so please refer to live tests.

  3. The resolution can be lowered by shadingScale to reduce memory and rendering overhead to improve performance. The current actual resolution versus the set maximum after-effects resolution available for the model can determine this value.

Performance

  1. Post-rendering reads and writes to the frame buffer multiple times, requiring high GPU bandwidth, pixel fill rate, and texture fill rate. It will likely cause a sharp drop in performance on low-end and mid-range models.

  2. Do a good job of managing high, medium, and low-end models, and turn on corresponding combinations of after-effects on different grades of models to ensure a balance between performance and effect.

  3. Many single Pass effects can be combined into one. For example, ColorGrading, FXAA, Vignette, and FinalPass can be combined into one. This can reduce the number of BlitScreen times and improve performance.

  4. HBAO can significantly improve the spatial relationship but is also a considerable performance overhead. Use with caution.

Conclusion

The use of post-processing effects can significantly improve the image texture. Still, you must also pay attention to the extra memory overhead and fill rate overhead caused by post-processing effects.

Post-processing effects are very unfriendly to low-end models, so please be prepared to do an excellent job of staging on low-end machines based on performance test results.

In addition, there is a wide variety of post-processing effects, and they can be optimized for specific project requirements. Therefore, the engine can only provide commonly used post-processing effects for everyone to use. For more specialized post-processing effects, developers need to implement them themselves.

I hope that today’s sharing can help you. Thank you!

Complete Shader Code

Create a new Cocos Shader file, and copy the following shader code into the file.

CCEffect %{
  techniques:
  - passes:
    - vert: blur-hor-vs
      frag: blur-fs
      pass: blur-x
      depthStencilState:
        depthTest: false
        depthWrite: false
    - vert: blur-vert-vs
      frag: blur-fs
      pass: blur-y
      depthStencilState:
        depthTest: false
        depthWrite: false
}%

CCProgram blur-hor-vs %{
  precision highp float;
  #include <legacy/input-standard>
  #include <builtin/uniforms/cc-global>
  #include <common/common-define>

  uniform MyConstants {
    vec4 blurParams;
  };

  out vec2 v_uv;
  out vec2 v_uv1;
  out vec2 v_uv2;
  out vec2 v_uv3;
  out vec2 v_uv4;

  void main () {
    StandardVertInput In;
    CCVertInput(In);
    CC_HANDLE_GET_CLIP_FLIP(In.position.xy);
    gl_Position = In.position;
    gl_Position.y = gl_Position.y;
    v_uv = a_texCoord;
    
    vec2 texelSize = cc_nativeSize.zw;
    float blurOffsetX = blurParams.x * texelSize.x;

    v_uv1 = v_uv + vec2(blurOffsetX * 1.0, 0.0);
    v_uv2 = v_uv - vec2(blurOffsetX * 1.0, 0.0);
    v_uv3 = v_uv + vec2(blurOffsetX * 2.0, 0.0);
    v_uv4 = v_uv - vec2(blurOffsetX * 2.0, 0.0);
  }
}%

CCProgram blur-vert-vs %{
  precision highp float;
  #include <legacy/input-standard>
  #include <builtin/uniforms/cc-global>
  #include <common/common-define>

  uniform MyConstants {
    vec4 blurParams;
  };

  out vec2 v_uv;

  out vec2 v_uv1;
  out vec2 v_uv2;
  out vec2 v_uv3;
  out vec2 v_uv4;

  void main () {
    StandardVertInput In;
    CCVertInput(In);
    CC_HANDLE_GET_CLIP_FLIP(In.position.xy);
    gl_Position = In.position;
    gl_Position.y = gl_Position.y;
    v_uv = a_texCoord;
    
    vec2 texelSize = cc_nativeSize.zw;
    float blurOffsetY = blurParams.x * texelSize.y;

    v_uv1 = v_uv + vec2(0.0, blurOffsetY * 1.0);
    v_uv2 = v_uv - vec2(0.0, blurOffsetY * 1.0);
    v_uv3 = v_uv + vec2(0.0, blurOffsetY * 2.0);
    v_uv4 = v_uv - vec2(0.0, blurOffsetY * 2.0);
  }
}%

CCProgram blur-fs %{
  precision highp float;
  #include <builtin/uniforms/cc-global>

  in vec2 v_uv;
  in vec2 v_uv1;
  in vec2 v_uv2;
  in vec2 v_uv3;
  in vec2 v_uv4;

  #pragma rate outputResultMap pass
  uniform sampler2D outputResultMap;

  layout(location = 0) out vec4 fragColor;

  void main () {

    vec3 weights = vec3(0.4026,0.2442,0.0545);
    vec3 sum = texture(outputResultMap, v_uv).rgb * weights.x;

    sum += texture(outputResultMap, v_uv1).rgb * weights.y;
    sum += texture(outputResultMap, v_uv2).rgb * weights.y;
    sum += texture(outputResultMap, v_uv3).rgb * weights.z;
    sum += texture(outputResultMap, v_uv4).rgb * weights.z;

    fragColor = vec4(sum, 1.0);
  }
}%
1 Like