Cocos Creator 实现屏幕空间的环境光遮蔽(SSAO)

Cocos Creator 实现屏幕空间的环境光遮蔽(SSAO)

  关于本文是如何诞生的 ?2020 年朋友推荐了 [ 闫令琪老师的 GAMES101-现代计算机图形学入门 ] 这个课程, 而且之前 Cocos 官方论坛里也看到乐府的大佬提到过, 所以就怀揣 (wang xiang) 着看完了这个就能干 TA 的想法, 开始了毕业多年后的第一堂课, 不得不说, 闫令琪老师的这个课程真的是 YYDS, 看得时候竟然不困, 而且 21 年新课程 [ GAMES202-高质量实时渲染 ] 现在也已经更完了, 恰好 21 年 GAMES101 课程的作业开放了重新提交, 于是索性就跟着做起了作业, 也算给自己一个坚持的动力, 大作业选题的时候看到了 SSAO (我是不会告诉你因为感觉这个比较简单才选的) 这个课题可以选择使用游戏引擎来实现, 然后当然就选择了最熟悉的 Cocos Creator 来完成大作业, 不过现阶段自定义渲染管线文档不完善, 研究自定义渲染管线也着实耗费了些时间。不过终于还是赶在 deadline 前提交了作业, Demo 工程放在了文末


已知问题

  • 当前使用的引擎版本为 Creator 3.3.1。
  • 编辑器摄像机预览会渲染不正确。
  • 资源管理器里面点击自定义管线资源文件, 编辑器控制台会报错, 可能会导致编辑器无响应 (目前建议没事别碰, 碰过重启编辑器可恢复正常)。
  • 手机浏览器 (小米 10 Pro) 下使用最大采样核心 (64) 时, 帧数只有个位数, 可以确定当前版本基本不能应用到实际项目中, 还需优化。
  • Native 下自定义渲染管线同时还需要自定义 engine-native 引擎, 所以 Native 暂时还未支持, 可参考 PR 3934 添加对 Native 的支持, 这里要感谢 大表姐Kristine 提供的信息。

关键词

  • AO (Ambient Occlusion)。
  • SSAO (Screen-Space Ambient Occlusion)。
  • 屏幕空间的环境光遮蔽。
  • Cocos Creator 扩展延迟渲染管线。
  • 后处理。

相关教程


什么是 AO & SSAO?

  环境光照(Ambient Lighting)。环境光照是我们加入场景总体光照中的一个固定光照常量,它被用来模拟光的散射(Scattering)。在现实中,光线会以任意方向散射,它的强度是会一直改变的,所以间接被照到的那部分场景也应该有变化的强度,而不是一成不变的环境光。

  其中一种间接光照的模拟叫做环境光遮蔽(Ambient Occlusion),它的原理是通过将褶皱、孔洞和非常靠近的墙面变暗的方法近似模拟出间接光照。这些区域很大程度上是被周围的几何体遮挡的,所以这些地方看起来会更暗一些。


  下面这幅图展示了在使用和不使用SSAO时场景的不同。特别注意对比电话亭后面和墙角部分,你会发现(环境)光被遮蔽了许多:

ssao_example


  虽然这个效果不是非常明显,但是启用AO的确实给我们更真实的感觉,这些小的遮蔽细节能让整个场景看起来更有层次感。

  环境光遮蔽这一技术会带来很大的性能开销,因为它还需要考虑周围的几何体。我们可以对空间中每一点发射大量光线来确定其遮蔽量,但是这在实时运算中会很快变成大问题。

  在2007年,Crytek公司发布了一款叫做屏幕空间环境光遮蔽(Screen-Space Ambient Occlusion, SSAO)的技术,并用在了他们的看家作孤岛危机上。这一技术使用了屏幕空间场景的深度而不是真实的几何体数据来确定遮蔽量。这一做法相对于真正的环境光遮蔽不但速度快,而且还能获得很好的效果,使得它成为近似实时环境光遮蔽的标准。


SSAO 原理

  SSAO背后的原理很简单:对于铺屏四边形(Screen-filled Quad)上的每一个片段,我们都会根据周边深度值计算一个遮蔽因子(Occlusion Factor)。这个遮蔽因子之后会被用来减少或者抵消片段的环境光照分量。遮蔽因子是通过采集片段周围球型核心(Kernel)的多个深度样本,并和当前片段深度值对比而得到的。高于片段深度值样本的个数就是我们想要的遮蔽因子。

ssao_crysis_circle

上图中在几何体内灰色的深度样本都是高于片段深度值的,他们会增加遮蔽因子;几何体内样本个数越多,片段获得的环境光照也就越少。

  很明显,渲染效果的质量和精度与我们采样的样本数量有直接关系。如果样本数量太低,渲染的精度会急剧减少,我们会得到一种叫做波纹(Banding)的效果;如果它太高了,反而会影响性能。我们可以通过引入随机性到采样核心(Sample Kernel)的采样中从而减少样本的数目。通过随机旋转采样核心,我们能在有限样本数量中得到高质量的结果。然而这仍然会有一些问题,因为随机性引入了一个很明显的噪声图案,我们将需要通过模糊降噪来修复这一问题。下面这幅图片展示了波纹效果还有随机性造成的效果:

ssao_banding_noise

你可以看到,尽管我们在低样本数的情况下得到了很明显的波纹效果,引入随机性之后这些波纹效果就完全消失了。

  Crytek公司开发的SSAO技术会产生一种特殊的视觉风格。因为使用的采样核心是一个球体,它导致平整的墙面也会显得灰蒙蒙的,因为核心中一半的样本都会在墙这个几何体上。下面这幅图展示了孤岛危机的SSAO,它清晰地展示了这种灰蒙蒙的感觉:

ssao_crysis

由于这个原因,我们将不会使用球体的采样核心,而使用一个沿着表面法向量的半球体采样核心。

ssao_hemisphere

  通过在法向半球体(Normal-oriented Hemisphere)周围采样,我们将不会考虑到片段底部的几何体,它消除了环境光遮蔽灰蒙蒙的感觉,从而产生更真实的结果。


SSAO 特点

  • 独立于场景复杂性,仅和投影后最终的像素有关,和场景中的顶点数三角数无关。
  • 跟传统的AO处理方法相比,不需要预处理,无需加载时间,也无需系统内存中的内存分配,所以更加适用于动态场景。
  • 对屏幕上的每个像素以相同的一致方式工作。
  • 没有 CPU 使用 - 它可以在 GPU 上完全执行。
  • 可以轻松集成到任何现代图形管线中。

基于 Cocos Creator 实现 SSAO

样本缓冲

SSAO 需要几何体的信息来确定一个片段的遮蔽因子, 对于每个片段(像素), 我们需要如下数据:

  • 逐片段位置向量
  • 逐片段法线向量
  • 逐片段反射颜色
  • 采样核心
  • 用来旋转采样核心的随机旋转向量

  通过使用一个逐片段观察空间位置,我们可以将一个采样半球核心对准片段的观察空间表面法线。对于每一个核心样本我们会采样线性深度纹理来比较结果。采样核心会根据旋转矢量稍微偏转一点;我们所获得的遮蔽因子将会之后用来限制最终的环境光照分量。

通过以上我们发现 SSAO 所需的数据不正是延迟管线的 G-buffer, 关于 G-buffer 是什么可通过此文章 - 延迟着色法 做一个简单的了解 , 通过阅读引擎代码 editor/assets/chunks/standard-surface-entry-entry.chunk 和 cocos/core/pipeline/define.ts。

// editor/assets/chunks/standard-surface-entry-entry.chunk 33 行附近
#elif CC_PIPELINE_TYPE == CC_PIPELINE_TYPE_DEFERRED

    layout(location = 0) out vec4 fragColor0;
    layout(location = 1) out vec4 fragColor1;
    layout(location = 2) out vec4 fragColor2;
    layout(location = 3) out vec4 fragColor3;

    void main () {
        StandardSurface s; surf(s);
        fragColor0 = s.albedo;                         // 漫反射颜色 -> 反照率纹理
        fragColor1 = vec4(s.position, s.roughness);    // 位置 -> 世界空间位置
        fragColor2 = vec4(s.normal, s.metallic);       // 法线 -> 世界空间法线
        fragColor3 = vec4(s.emissive, s.occlusion);    // 和本文无关, 不做介绍
    }
#endif

// cocos/core/pipeline/define.ts  117 行 附近
export enum PipelineGlobalBindings {
    UBO_GLOBAL,
    UBO_CAMERA,
    UBO_SHADOW,

    SAMPLER_SHADOWMAP,
    SAMPLER_ENVIRONMENT,
    SAMPLER_SPOT_LIGHTING_MAP,
    SAMPLER_GBUFFER_ALBEDOMAP,   // 6
    SAMPLER_GBUFFER_POSITIONMAP, // 7
    SAMPLER_GBUFFER_NORMALMAP,   // 8
    SAMPLER_GBUFFER_EMISSIVEMAP,
    SAMPLER_LIGHTING_RESULTMAP,

    COUNT,
}

// cocos/core/pipeline/define.ts  283 行 附近
const UNIFORM_GBUFFER_ALBEDOMAP_NAME = 'cc_gbuffer_albedoMap';
export const UNIFORM_GBUFFER_ALBEDOMAP_BINDING = PipelineGlobalBindings.SAMPLER_GBUFFER_ALBEDOMAP; // 6
// ...

const UNIFORM_GBUFFER_POSITIONMAP_NAME = 'cc_gbuffer_positionMap';
export const UNIFORM_GBUFFER_POSITIONMAP_BINDING = PipelineGlobalBindings.SAMPLER_GBUFFER_POSITIONMAP; // 7
// ...

const UNIFORM_GBUFFER_NORMALMAP_NAME = 'cc_gbuffer_normalMap';
export const UNIFORM_GBUFFER_NORMALMAP_BINDING = PipelineGlobalBindings.SAMPLER_GBUFFER_NORMALMAP; // 8
// ...

通过以上代码可以分析出引擎 G-buffer 的数据布局, 和具体 G-buffer 数据内容, 深度值后面将会使用 G-buffer 计算得出。


自定义渲染管线

  通过扩展延迟渲染管线的方式, 在内置渲染管线的 LightFlow 上增加 一个 SsaoStage 用来生成 AO 纹理。首先创建一个渲染管线资源,资源管理器右键->创建->Render Pipeine->Render Pipeline Asset, 命名为 ssao-deferrd-pipeline,创建 ssao-material | ssao-effect 着色器用来计算 AO 纹理, 完整文件如下。

.
├── ssao-constant.chunk            // UBO 描述
├── ssao-deferred-pipeline.rpp     // 管线资源文件
├── ssao-effect.effect             // ssao shader
├── ssao-lighting.effect           // 光照 shader, 直接拷贝内置 internal/effects/pipeline/defferrd-lighting
├── ssao-lighting.mtl
├── ssao-material.mtl
├── ssao-render-pipeline.ts        // 定制管线脚本
├── ssao-stage.ts                  // stage 脚本
└── uboDefine.ts                   // Uniform Buffer Object 定义脚本

  对应管线配置如下, 在 LightingFlow 下 Stages 最前面加入 SsaoStage, 并指定对应的材质, 可以看到, 引擎现在其实已经支持后处理 (PostProcess) 了, 只要指定材质就可以了,可能当前版本还不完善,所以引擎组还没公开,其实 SSAO 也可以算是一种后处理效果。


// uboDefine.ts
import { gfx, pipeline } from "cc";
const { DescriptorSetLayoutBinding, UniformSamplerTexture, DescriptorType, ShaderStageFlagBit, Type } = gfx;
const { SetIndex, PipelineGlobalBindings, globalDescriptorSetLayout } = pipeline;

let GlobalBindingStart = PipelineGlobalBindings.COUNT; // 11
let GlobalBindingIndex = 0;
/**
* 定义 SSAO Frame Buffer, 布局描述
*/
const UNIFORM_SSAOMAP_NAME = 'cc_ssaoMap';
export const UNIFORM_SSAOMAP_BINDING = GlobalBindingStart + GlobalBindingIndex++; // 11
const UNIFORM_SSAOMAP_DESCRIPTOR = new DescriptorSetLayoutBinding(UNIFORM_SSAOMAP_BINDING, DescriptorType.SAMPLER_TEXTURE, 1, ShaderStageFlagBit.FRAGMENT);
const UNIFORM_SSAOMAP_LAYOUT = new UniformSamplerTexture(SetIndex.GLOBAL, UNIFORM_SSAOMAP_BINDING, UNIFORM_SSAOMAP_NAME, Type.SAMPLER2D, 1);
globalDescriptorSetLayout.layouts[UNIFORM_SSAOMAP_NAME] = UNIFORM_SSAOMAP_LAYOUT;
globalDescriptorSetLayout.bindings[UNIFORM_SSAOMAP_BINDING] = UNIFORM_SSAOMAP_DESCRIPTOR;

/**
 * 采样核心、相机远近裁剪面 near & far 等 UniformBlock 布局描述
 */
export class UBOSsao {
    public static readonly SAMPLES_SIZE = 64; // 最大采样核心

    public static readonly CAMERA_NEAR_FAR_LINEAR_INFO_OFFSET = 0;
    public static readonly SSAO_SAMPLES_OFFSET = UBOSsao.CAMERA_NEAR_FAR_LINEAR_INFO_OFFSET + 4;

    public static readonly COUNT = (UBOSsao.SAMPLES_SIZE + 1) * 4;
    public static readonly SIZE = UBOSsao.COUNT * 4;

    public static readonly NAME = 'CCSsao';
    public static readonly BINDING = GlobalBindingStart + GlobalBindingIndex++; // 12
    public static readonly DESCRIPTOR = new gfx.DescriptorSetLayoutBinding(UBOSsao.BINDING, gfx.DescriptorType.UNIFORM_BUFFER, 1, gfx.ShaderStageFlagBit.ALL);
    public static readonly LAYOUT = new gfx.UniformBlock(SetIndex.GLOBAL, UBOSsao.BINDING, UBOSsao.NAME, [
        new gfx.Uniform('cc_cameraNFLSInfo', gfx.Type.FLOAT4, 1), // vec4
        new gfx.Uniform('ssao_samples', gfx.Type.FLOAT4, UBOSsao.SAMPLES_SIZE), // vec4[64]
    ], 1);
}
globalDescriptorSetLayout.layouts[UBOSsao.NAME] = UBOSsao.LAYOUT;
globalDescriptorSetLayout.bindings[UBOSsao.BINDING] = UBOSsao.DESCRIPTOR;


/**
 *  ssao-render-pipeline.ts
 *  扩展延迟渲染管线
 */
import { _decorator, DeferredPipeline, gfx, renderer } from "cc";
import { UNIFORM_SSAOMAP_BINDING } from "./uboDefine";
const { ccclass } = _decorator;

const _samplerInfo = [
    gfx.Filter.POINT,
    gfx.Filter.POINT,
    gfx.Filter.NONE,
    gfx.Address.CLAMP,
    gfx.Address.CLAMP,
    gfx.Address.CLAMP,
];

const samplerHash = renderer.genSamplerHash(_samplerInfo);

export class SsaoRenderData {
    frameBuffer?: gfx.Framebuffer | null;
    renderTargets?: gfx.Texture[] | null;
    depthTex?: gfx.Texture | null;
}

@ccclass("SsaoRenderPipeline")
export class SsaoRenderPipeline extends DeferredPipeline {
    private _width = 0;
    private _height = 0;

    private _ssaoRenderData: SsaoRenderData | null = null!;
    private _ssaoRenderPass: gfx.RenderPass | null = null;

    public activate(): boolean {
        const result = super.activate();
        this._width = this.device.width;
        this._height = this.device.height;
        this._generateSsaoRenderData();
        return result;
    }

    public resize(width: number, height: number) {
        if (this._width === width && this._height === height) {
            return;
        }
        super.resize(width, height);
        this._width = width;
        this._height = height;
        this._destroyRenderData();
        this._generateSsaoRenderData();
    }

    public getSsaoRenderData(camera: renderer.scene.Camera): SsaoRenderData {
        if (!this._ssaoRenderData) {
            this._generateSsaoRenderData();
        }
        return this._ssaoRenderData!;
    }

    /**
     * 核心代码, 创建一个 FrameBuffer 存储 SSAO 纹理
     */
    private _generateSsaoRenderData() {
        if (!this._ssaoRenderPass) {
            const colorAttachment = new gfx.ColorAttachment();
            colorAttachment.format = gfx.Format.RGBA8;
            colorAttachment.loadOp = gfx.LoadOp.CLEAR;
            colorAttachment.storeOp = gfx.StoreOp.STORE;
            colorAttachment.endAccesses = [gfx.AccessType.COLOR_ATTACHMENT_WRITE];

            const depthStencilAttachment = new gfx.DepthStencilAttachment();
            depthStencilAttachment.format = this.device.depthStencilFormat;
            depthStencilAttachment.depthLoadOp = gfx.LoadOp.CLEAR;
            depthStencilAttachment.depthStoreOp = gfx.StoreOp.STORE;
            depthStencilAttachment.stencilLoadOp = gfx.LoadOp.CLEAR;
            depthStencilAttachment.stencilStoreOp = gfx.StoreOp.STORE;

            const renderPassInfo = new gfx.RenderPassInfo([colorAttachment], depthStencilAttachment);
            this._ssaoRenderPass = this.device.createRenderPass(renderPassInfo);
        }

        this._ssaoRenderData = new SsaoRenderData();
        this._ssaoRenderData.renderTargets = [];
        // 因为 SSAO 纹理最终是一张灰度图, 所以使用 Format.R8 单通道纹理, 减少内存占用, 使用时只需要读取 R 通道即可
        this._ssaoRenderData.renderTargets.push(this.device.createTexture(new gfx.TextureInfo(
            gfx.TextureType.TEX2D,
            gfx.TextureUsageBit.COLOR_ATTACHMENT | gfx.TextureUsageBit.SAMPLED,
            gfx.Format.R8,
            this._width,
            this._height,
        )));

        this._ssaoRenderData.depthTex = this.device.createTexture(new gfx.TextureInfo(
            gfx.TextureType.TEX2D,
            gfx.TextureUsageBit.DEPTH_STENCIL_ATTACHMENT,
            this.device.depthStencilFormat,
            this._width,
            this._height,
        ));
        this._ssaoRenderData.frameBuffer = this.device.createFramebuffer(new gfx.FramebufferInfo(
            this._ssaoRenderPass!,
            this._ssaoRenderData.renderTargets,
            this._ssaoRenderData.depthTex,
        ));

        this.descriptorSet.bindTexture(UNIFORM_SSAOMAP_BINDING, this._ssaoRenderData.frameBuffer.colorTextures[0]!);
        const sampler = renderer.samplerLib.getSampler(this.device, samplerHash);
        this.descriptorSet.bindSampler(UNIFORM_SSAOMAP_BINDING, sampler);
    }


    public destroy(): boolean {
        this._destroyRenderData();
        return super.destroy();
    }

    private _destroyRenderData() {
        if (!this._ssaoRenderData) {
            return;
        }
        if (this._ssaoRenderData.depthTex) {
            this._ssaoRenderData.depthTex.destroy();
        }
        if (this._ssaoRenderData.renderTargets) {
            this._ssaoRenderData.renderTargets.forEach((o) => {
                o.destroy();
            })
        }
        if (this._ssaoRenderData.frameBuffer) {
            this._ssaoRenderData.frameBuffer.destroy();
        }
        this._ssaoRenderData = null;
    }
}

通过项目设置修改渲染管线为自定义的 SSAO 管线:


采样核心

  我们需要沿着表面法线方向生成大量的样本。就像前面介绍的那样,我们想要生成形成半球形的样本。由于对每个表面法线方向生成采样核心非常困难,也不合实际,我们将在切线空间(Tangent Space)内生成采样核心,法向量将指向正 z 方向。

ssao_hemisphere

假设我们有一个单位半球,我们可以生成一个拥有最大64样本值的采样核心:

// ssao-stage.ts
activate(pipeline: DeferredPipeline, flow: RenderFlow) {
    super.activate(pipeline, flow);

    const device = pipeline.device;


    this._sampleBuffer = device.createBuffer(new gfx.BufferInfo(
        gfx.BufferUsageBit.UNIFORM | gfx.BufferUsageBit.TRANSFER_DST,
        gfx.MemoryUsageBit.HOST | gfx.MemoryUsageBit.DEVICE,
        UBOSsao.SIZE,
        UBOSsao.SIZE,
    ));

    this._sampleBufferData = new Float32Array(UBOSsao.COUNT);

    const sampleOffset = UBOSsao.SSAO_SAMPLES_OFFSET / 4;
    // 64 样本值采样核心, 这里写的不太详细, 可结合 LearnOpenGL CN 的教程, 加深理解
    for (let i = 0; i < UBOSsao.SAMPLES_SIZE; i++) {
        let sample = new Vec3(
            Math.random() * 2.0 - 1.0,
            Math.random() * 2.0 - 1.0,
            Math.random() + 0.01, // 这里和原教程有点区别, Z 稍微增加一个很小的值, 可改善平面波纹(Banding)的效果, 可能会对精度造成影响
        );
        sample = sample.normalize();
        let scale = i / UBOSsao.SAMPLES_SIZE;
        // 通过插值, 将核心样本靠近原点分布
        scale = lerp(0.1, 1.0, scale * scale);
        sample.multiplyScalar(scale);
        const index = 4 * (i + sampleOffset);
        this._sampleBufferData[index + 0] = sample.x;
        this._sampleBufferData[index + 1] = sample.y;
        this._sampleBufferData[index + 2] = sample.z;
    }
    this._pipeline.descriptorSet.bindBuffer(UBOSsao.BINDING, this._sampleBuffer);
}

  我们在切线空间中以-1.0到1.0为范围变换 x 和 y 方向,并以 0.0 和 1.0 为范围变换样本的 z 方向 (如果以-1.0到1.0为范围,取样核心就变成球型了)。由于采样核心将会沿着表面法线对齐,所得的样本矢量将会在半球里。通过权重插值,这就给了我们一个大部分样本靠近原点的核心分布。


获取深度数据

通过 G-buffer 中的 PostionMap 获取线性深度值:

float getDepth(vec3 worldPos) {
// 转到观察空间
vec3 viewPos = (cc_matView * vec4(worldPos.xyz, 1.0)).xyz;
// cc_cameraNFLSInfo.y -> 相机 Far, 通过 ssao-stage.ts 脚本更新
float depth = -viewPos.z / cc_cameraNFLSInfo.y;
return depth;
}

深度图如下:


SSAO 着色器

/**
* ssao-effect.effect
*/
CCProgram ssao-fs %{
  precision highp float;
  #include <cc-global>
  #include <cc-shadow-map-base>
  #include <ssao-constant>

  // 最大 64
  #define SSAO_SAMPLES_SIZE 64

  in vec2 v_uv;

  #pragma builtin(global)
  layout (set = 0, binding = 7) uniform sampler2D cc_gbuffer_positionMap;
  #pragma builtin(global)
  layout (set = 0, binding = 8) uniform sampler2D cc_gbuffer_normalMap;

  layout(location = 0) out vec4 fragColor;

  // 随机数 0.0 - 1.0
  float rand(vec2 uv, float dx, float dy)
  {
    uv += vec2(dx, dy);
    return fract(sin(dot(uv,  vec2(12.9898, 78.233))) * 43758.5453);
  }

  // 随机旋转采样核心向量
  vec3 getRandomVec(vec2 uv){
    return vec3(
      rand(uv, 0.0, 1.0) * 2.0 - 1.0,
      rand(uv, 1.0, 0.0) * 2.0 - 1.0,
      0.0
    );
  }

  // 获取线性深度
  float getDepth(vec3 worldPos) {
    vec3 viewPos = (cc_matView * vec4(worldPos.xyz, 1.0)).xyz;
    float depth = -viewPos.z / cc_cameraNFLSInfo.y;
    return depth;
  }

  // 深度图
  // void main () {
  //   vec3 worldPos = texture(cc_gbuffer_positionMap, v_uv).xyz;
  //   fragColor = vec4(getDepth(worldPos));
  // }

  void main () {
    vec3 worldPos = texture(cc_gbuffer_positionMap, v_uv).xyz;
    vec3 normal = texture(cc_gbuffer_normalMap, v_uv).xyz;
    vec3 randomVec = getRandomVec(v_uv);

    float fragDepth = -getDepth(worldPos);

    // 创建一个TBN矩阵,将向量从切线空间变换到观察空间
    vec3 tangent = normalize(randomVec - normal * dot(randomVec, normal));
    vec3 bitangent = cross(normal, tangent);
    mat3 TBN = mat3(tangent, bitangent, normal);

    // 取样半径
    float radius = 1.0;
    float occlusion = 0.0;
    for(int i = 0; i < SSAO_SAMPLES_SIZE; ++i)
    {
      vec3 ssaoSample = TBN * ssao_samples[i].xyz;
      ssaoSample = worldPos + ssaoSample * radius;
      float aoDepth = -getDepth(ssaoSample);

      vec4 offset = vec4(ssaoSample, 1.0);
      offset      = (cc_matProj * cc_matView) * offset;   // 转换到裁剪空间
      offset.xyz /= offset.w;                             // 透视除法
      offset.xyz  = offset.xyz * 0.5 + 0.5;               // 从 NDC (标准化设备坐标, -1.0 - 1.0) 变换到 0.0 - 1.0

      vec3 samplePos = texture(cc_gbuffer_positionMap, offset.xy).xyz;
      float sampleDepth = -getDepth(samplePos);
      // 范围检查
      float rangeCheck = smoothstep(0.0, 1.0, radius / abs(fragDepth - sampleDepth));
      // 检查样本的当前深度值是否大于存储的深度值,如果是,添加到最终的贡献因子上
      occlusion += (sampleDepth >= aoDepth ? 1.0 : 0.0) * rangeCheck;
    }
    // 将遮蔽贡献根据核心的大小标准化,并输出结果
    occlusion = 1.0 - (occlusion / float(SSAO_SAMPLES_SIZE));
    fragColor = vec4(occlusion, 1.0, 1.0, 1.0);
  }
}%

下图展示了环境遮蔽着色器产生的纹理:

可见,环境遮蔽产生了非常强烈的深度感。仅仅通过环境遮蔽纹理我们就已经能清晰地看见模型一定躺在地板上而不是浮在空中。

  现在的效果仍然看起来不是很完美,由于重复的噪声纹理在图中清晰可见。为了创建一个光滑的环境遮蔽结果,我们需要模糊环境遮蔽纹理。( 这里和原文有所不同, 原文旋转采样核心是通过采样一个 4 x 4 纹理得到的, 所以会有重复噪点的感觉, 本文是直接在着色器代码中使用随机算法生成旋转向量, 所以噪点和原文看起来不太一样,不过降噪过程都是一样的 )。


应用 SSAO 纹理

  最后将 SSAO 纹理进行模糊降噪, 并逐片段将环境遮蔽因子乘到环境光照分量上, 拷贝内置光照着色器 (internal/effects/pipeline/deferred-lighting.effect) 命名为 ssao-lighting.effect。

/**
* 本文改动部分添加了中文注释
*/
CCProgram lighting-fs %{
  precision highp float;
  #include <cc-global>
  #include <shading-standard-base>
  #include <shading-standard-additive>
  #include <output-standard>
  #include <cc-fog-base>


  in vec2 v_uv;

  #pragma builtin(global)
  layout (set = 0, binding = 6) uniform sampler2D cc_gbuffer_albedoMap;
  #pragma builtin(global)
  layout (set = 0, binding = 7) uniform sampler2D cc_gbuffer_positionMap;
  #pragma builtin(global)
  layout (set = 0, binding = 8) uniform sampler2D cc_gbuffer_normalMap;
  #pragma builtin(global)
  layout (set = 0, binding = 9) uniform sampler2D cc_gbuffer_emissiveMap;
  #pragma builtin(global)
  layout (set = 0, binding = 11) uniform sampler2D cc_ssaoMap;

  layout(location = 0) out vec4 fragColor;

  vec4 gaussianBlur(sampler2D Tex, vec2 UV, float Intensity)
  {
    // 省略, 详见 demo 工程
    return texture(Tex, UV);
  }

  // 屏幕展示 SSAO 纹理
  // void main() {
  //   // 降噪
  //   vec4 color = gaussianBlur(cc_ssaoMap, v_uv, 3.0);
  //   // 不降噪
  //   vec4 color = texture(cc_ssaoMap, v_uv);
  //   fragColor = vec4(vec3(color.r), 1.0);
  // }

  void main () {
    StandardSurface s;

    vec4 albedoMap = texture(cc_gbuffer_albedoMap,v_uv);
    vec4 positionMap = texture(cc_gbuffer_positionMap,v_uv);
    vec4 normalMap = texture(cc_gbuffer_normalMap,v_uv);
    vec4 emissiveMap = texture(cc_gbuffer_emissiveMap,v_uv);
    // ssao 环境遮蔽因子, 单通道纹理, 所以只取 R 通道
    vec4 ssaoMap = vec4(vec3(gaussianBlur(cc_ssaoMap, v_uv, 3.0).r), 1.0);

    s.albedo = albedoMap * ssaoMap; // 乘到辐照率贴图上, 应用遮蔽纹理
    s.position = positionMap.xyz;
    s.roughness = positionMap.w;
    s.normal = normalMap.xyz;
    s.metallic = normalMap.w;
    s.emissive = emissiveMap.xyz;
    s.occlusion = emissiveMap.w;
    // fixme: default value is 0, and give black result
    float fogFactor;
    CC_TRANSFER_FOG_BASE(vec4(s.position, 1), fogFactor);

    vec4 shadowPos;
    CC_TRANSFER_SHADOW_BASE(vec4(s.position, 1), shadowPos);

    vec4 color = CCStandardShadingBase(s, shadowPos) +
                 CCStandardShadingAdditive(s, shadowPos);

    CC_APPLY_FOG_BASE(color, fogFactor);
    fragColor = CCFragOutput(color);
  }
}%

最后我们来看下最终的渲染结果对比, 首先是 SSAO 开启的效果:

SSAO 关闭的效果:

  屏幕空间环境遮蔽是一个可高度自定义的效果,它的效果很大程度上依赖于我们根据场景类型调整它的参数。对所有类型的场景并不存在什么完美的参数组合方式。一些场景只在小半径情况下工作,又有些场景会需要更大的半径和更大的样本数量才能看起来更真实。当前这个演示用了64个样本,属于比较多的了,你可以调整核心大小和半径从而获得合适的效果。


写在最后

  本文中的 Demo 开源, 转载本文请注明出处, 欢迎感兴趣的同学可以三连支持。

24赞

mark打卡

膜拜大佬~

越来越多管线大佬了 :+1:

:+1: :+1: :+1:

:+1: :+1: :+1:

给大佬点赞 mark!!!

不明觉厉!

:thinking: :thinking: :thinking:

  #pragma builtin(global)
  layout (set = 0, binding = 7) uniform sampler2D cc_gbuffer_positionMap;

求问,这样就能拿到 GBuffer 的数据吗?是什么原理呀?
照着写了一下,应用在 PostProcess Stage,报错如下,不知道是什么原因呢?

[Scene] builtin samplerTexture ‘cc_gbuffer_positionMap’ not available!

可能是版本不一样,新版本没有 cc_gbuffer_positionMap 了, 参考你使用的引擎版本内置 deferred-lighiting.effect 写法

论坛为数不多的实践3.x自定义渲染管线的帖子
官方文档看完两眼一抹黑,还是看大佬的demo靠谱 :+1:

2赞

哈哈,赞同,完全不从小白去看的方式去写文档,而是从认为都看得懂专业术语,都知道这是啥的情况下去写文档,浪费看的时间

:grin: 是一个后续需要研究的课题,Get,感谢

这个后面要好好研究一下

看完帖子,看完demo, 延迟渲染管线还是不知道怎么搞 :tired_face: 我太菜了

多看几遍就好了,当时我也摸索了很久