后处理 - 模拟相机效果

webgl_lensflares
pseudo-lens-flare

镜头光晕作为一种摄影现象,能够增加某些场景下的真实感:
image.png

来自 Unreal:
https://docs.unrealengine.com/en-us/Engine/Rendering/PostProcessEffects/LensFlare

下面介绍一种并非基于物理的生成算法,可以通过后处理实现。

算法

步骤如下:

  1. Downsample/threshold.
  2. Generate lens flare features.
  3. Blur.
  4. Upscale/blend with original image.

WebGL 实现

这部分在 clay.gl 中归入了 HDR 渲染管线中。

降采样

由于我们需要找到场景中亮度最高的区域,所以进行降采样将显著提升后续后处理的性能:
image.png

来自 clay.gl 的 compositor 中的第一个 pass,在 clay.gl 中创建 compositor 可以传入一个 JSON。
这里需要简单解释几点:

// example/assets/fx/composite.json
{
  "name": "source_half",
  "shader": "#source(clay.compositor.downsample)",
  "inputs": {
    "texture": "source"
  },
  "outputs": {
    "color": {
      "parameters": {
        "width": "expr(width * dpr / 2)",
        "height": "expr(height * dpr / 2)"
      }
    }
  },
  "parameters" : {
    "textureSize": "expr( [width * dpr, height * dpr] )"
  },
  "defines": {
    "RGBM": null
  }
},

虽然并不需要应用 RGBM 编码,但仍需要引入 RGBM 模块,因为为了命名统一,decode/encodeHDR 方法包含在这个模块中,尽管都只是简单返回输入的颜色值。在这一 pass 中得到了每个 fragment 颜色的平均值,采样周围四个邻居:

@export clay.compositor.downsample

uniform sampler2D texture;
uniform vec2 textureSize : [512, 512];

varying vec2 v_Texcoord;

// 引入 rgbm 模块但是并不会实际应用 RGBM 编码
@import clay.util.rgbm
@import clay.util.clamp_sample

void main()
{
		// 上下左右偏移量
    vec4 d = vec4(-1.0, -1.0, 1.0, 1.0) / textureSize.xyxy;
    vec4 color = decodeHDR(clampSample(texture, v_Texcoord + d.xy));
    color += decodeHDR(clampSample(texture, v_Texcoord + d.zy));
    color += decodeHDR(clampSample(texture, v_Texcoord + d.xw));
    color += decodeHDR(clampSample(texture, v_Texcoord + d.zw));
    color *= 0.25;

    gl_FragColor = encodeHDR(color);
}

@end

此时降采样后的效果如图:clay.gl 的 pisa.hdr 场景:
image.pngimage.png

然后进入提取亮度 pass:

{
  "name" : "bright",
  "shader" : "#source(clay.compositor.bright)",
  "inputs" : {
    "texture" : "source_half"
  },
  "defines": {
    "RGBM": null,
    "ANTI_FLICKER": null
  }
},

我们需要找到场景中亮度较高的区域,其余区域清空。这个过程可以重复多次,例如 clay.gl 中后续还会进行一次 bright2 pass:

@export clay.compositor.bright

uniform sampler2D texture;

uniform float threshold : 1; // 最小亮度阈值
uniform float scale : 1.0; // 放大亮度因子

uniform vec2 textureSize: [512, 512];
varying vec2 v_Texcoord;

const vec3 lumWeight = vec3(0.2125, 0.7154, 0.0721);

@import clay.util.rgbm

void main()
{
    vec4 texel = decodeHDR(texture2D(texture, v_Texcoord));
    // 从 rgb 提取亮度
    float lum = dot(texel.rgb , lumWeight);
    vec4 color;
    if (lum > threshold && texel.a > 0.0)
    {
        color = vec4(texel.rgb * scale, texel.a * scale);
    }
    else
    {
        color = vec4(0.0);
    }

    gl_FragColor = encodeHDR(color);
}
@end

此时效果如图,clay.gl 原始例子中 threshold 设置过高导致完全漆黑,这里使用默认值 1:
image.png

接下来可以进行多次降采样 pass:bright_downsample_4、bright_downsample_8、bright_downsample_32。
image.png

生成光晕

这里传入的参数中包括 lensColor 这个纹理:

{
  "name" : "lensflare",
  "shader" : "#source(clay.compositor.lensflare)",
  "inputs" : {
    "texture" : "bright2"
  },
  "parameters" : {
    "textureSize" : "expr([width * dpr / 2, height * dpr / 2])",
    "lensColor" : "#lenscolor"
  },
  "defines": {
    "RGBM": null
  }
},

Ghost

首先是 “Ghost”,生成方式是将上一步中高亮度区域以屏幕中心进行反转

“Ghosts” are the repetitious blobs which mirror bright spots in the input, pivoting around the image centre. The approach I’ve take to generate these is to get a vector from the current pixel to the centre of the screen, then take a number of samples along this vector. 

image.png

按照生成方式:

@export clay.compositor.lensflare

#define SAMPLE_NUMBER 8

uniform sampler2D texture;
uniform sampler2D lenscolor;

uniform vec2 textureSize : [512, 512];

uniform float dispersal : 0.3; // 色散
uniform float distortion : 1.0;

void main()
{
		// 翻转纹理坐标
    vec2 texcoord = -v_Texcoord + vec2(1.0);
    vec2 textureOffset = 1.0 / textureSize;
		// 移动到屏幕中心
    vec2 ghostVec = (vec2(0.5) - texcoord) * dispersal;
    
    vec3 distortion = vec3(-textureOffset.x * distortion, 0.0, textureOffset.x * distortion);

    vec4 result = vec4(0.0);
    for (int i = 0; i < SAMPLE_NUMBER; i++)
    {
        vec2 offset = fract(texcoord + ghostVec * float(i));
				
        // 只有中心的高亮度区域
        float weight = length(vec2(0.5) - offset) / length(vec2(0.5));
        weight = pow(1.0 - weight, 10.0);
				
        // chromatic distortion
        result += textureDistorted(offset, normalize(ghostVec), distortion) * weight;
    }
	
  	// 模拟光晕七彩颜色
    result *= texture2D(lenscolor, vec2(length(vec2(0.5) - texcoord)) / length(vec2(0.5)));
}

此时效果如下:
image.png

Halo

限制效果在一个圆环内:

uniform float haloWidth : 0.4;

// 光环向量
vec2 haloVec = normalize(ghostVec) * haloWidth;

float weight = length(vec2(0.5) - fract(texcoord + haloVec)) / length(vec2(0.5));
weight = pow(1.0 - weight, 10.0);
vec2 offset = fract(texcoord + haloVec);
result += textureDistorted(offset, normalize(ghostVec), distortion) * weight;

效果如下:
image.png

模糊

如果不加模糊,会影响场景本身的效果。简单加入两个水平垂直方向的两趟高斯模糊:

{
  "name" : "lensflare_blur_h",
  "shader" : "#source(clay.compositor.gaussian_blur)",
  "inputs" : {
    "texture" : "lensflare"
  },
  "outputs" : {
    "color" : {
      "parameters" : {
        "width" : "expr(width * dpr / 2)",
        "height" : "expr(height * dpr / 2)"
      }
    }
  },
  "parameters" : {
    "blurSize" : 1,
    "blurDir": 0.0,
    "textureSize" : "expr([width * dpr / 2, height * dpr / 2])"
  },
  "defines": {
    "RGBM": null
  }
},
{
  "name" : "lensflare_blur_v",
  "shader" : "#source(clay.compositor.gaussian_blur)",
  "inputs" : {
    "texture" : "lensflare_blur_h"
  },
  "outputs" : {
    "color" : {
      "parameters" : {
        "width" : "expr(width * dpr / 2)",
        "height" : "expr(height * dpr / 2)"
      }
    }
  },
  "parameters" : {
    "blurSize" : 1,
    "blurDir": 1.0,
    "textureSize" : "expr([width * dpr / 2, height * dpr / 2])"
  },
  "defines": {
    "RGBM": null
  }
},

效果如下:
image.pngimage.png

混合

将上一步模糊后的 lensflare 与场景混合,暂时忽略全局辉光效果:

{
  "name" : "composite",
  "shader" : "#source(clay.compositor.hdr.composite)",
  "inputs" : {
    "texture" : "source",
    "bloom" : "bloom_composite", // 暂时忽略
    "lensflare" : "lensflare_blur_v"
  },
  "outputs" : {
    "color" : {
      "parameters" : {
        "width" : "expr(width * dpr)",
        "height" : "expr(height * dpr)"
      }
    }
  },
  "defines": {
    "RGBM_DECODE": null
  }
},

下面来看两个模拟镜头真实感的附加效果。

镜头灰尘效果

真实的镜头存在落灰,我们可以读取这样的纹理:
image.png

在 hdr 合成时读取纹理值:

uniform sampler2D texture; // 愿场景
uniform sampler2D lensflare; // 模糊后的 lensflare
uniform sampler2D lensdirt; // 镜头灰尘
uniform float lensflareIntensity : 1;

texel += decodeHDR(texture2D(lensflare, v_Texcoord))
	* texture2D(lensdirt, v_Texcoord)
  * lensflareIntensity;

效果如下:
image.png

DIFFRACTION STARBURST

和落灰现象一样,这种光向四周发散的效果也可以通过混合额外的纹理实现:
image.png

这部分 clay.gl 并没有实现,但是修改也很简单:

texel += decodeHDR(texture2D(lensflare, v_Texcoord))
        * (texture2D(lensdirt, v_Texcoord) + texture2D(lensstar, v_Texcoord))
        * lensflareIntensity;

效果如下,注意右下角的向外发散效果是固定不变的:
image.png

但是有一点和镜头灰尘不同,由于相机会发生移动,starburst 纹理的位置也需要跟随。
以下在 clay.gl 的 pp_lensflare 例子中进行修改,在 frame 中实时获取相机 view 矩阵:

var camx = sceneNode.camera.viewMatrix.x;
var camz = sceneNode.camera.viewMatrix.z;
var camrot = camx.z + camz.y;

var scaleBias1 = new clay.Matrix3();
var scaleBias2 = new clay.Matrix3();
var rotateMatrix = new clay.Matrix3();
rotateMatrix.rotate(camrot);

scaleBias1.setArray([
  2.0,   0.0,  -1.0,
  0.0,   2.0,  -1.0,
  0.0,   0.0,   1.0,
]);

scaleBias2.setArray = ([
  0.5,   0.0,   0.5,
  0.0,   0.5,   0.5,
  0.0,   0.0,   1.0,
]);

rotateMatrix.multiplyLeft(scaleBias1).mul(scaleBias2);
finalCompositeNode.setParameter('lensstarMatrix', rotateMatrix.toArray());

旋转纹理坐标:

uniform mat3 lensstarMatrix;

vec2 lensstarTexcoord = (lensstarMatrix * vec3(v_Texcoord, 1.0)).xy;
texel += decodeHDR(texture2D(lensflare, v_Texcoord))
        * (texture2D(lensdirt, v_Texcoord) + texture2D(lensstar, lensstarTexcoord))
        * lensflareIntensity;

效果如下,随着相机的移动,光圈也随之旋转:
image.png