实现光晕效果_从认识到实现:HDR Bloom in Unity

本文深入探讨了Unity中实现光晕效果(Bloom)的原理,包括Bloom与HDR的关系,以及在Unity中实现Bloom的步骤。通过降采样、模糊处理和颜色累计,实现了接近Unity Post Process Stack的Bloom效果。同时,文章阐述了HDR在提升画面层次和美术意图中的作用。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

b4fa1e4482f00190bc00f29e1d2e6ee7.png

Bloom在游戏中是一种比较常见的效果,是通过后期处理的方式,来达到模拟现实生活中物体在强烈光源照射下,出现“光晕”的效果。本着学习,举一反三的精神,本文将探讨几个问题:

  1. Bloom的基本原理与效果
  2. Bloom与HDR的关系
  3. Bloom的美术意图
  4. Bloom在Unity中的基本实现

一、Bloom的基本原理与效果

d861ee500620a8c87591e290dfdd7d46.png
with bloom

拿我之前做的一个小恐龙作为本文的模特,这个就是最终的效果(Unity 的Post process stack 中的bloom),如果关闭bloom

9c40cec4087f11a1255814cfc076dfe0.png
without bloom

两张图的最明显的地方,则在于上图阳光洒下的地方,会产生强烈的光晕,这个就是bloom效果,大家也常叫这种特效辉光。

接下来,动手在ps里面,拿一张图片,手动的模拟一下这个过程,感性的认识一下原理:

PS:这个过程只是可视化的展示一下原理,结果不准确

之前说了一组重点词,我提取出来:”强烈光源照射下,出现的效果“,”阳光洒下的地方“。我们干的第一件事情就是找出”阳光洒下的地方“也就是受光部分:

先通过色阶,找出图像中的受光(亮度更高的区域):

0b0726e41e0469de5522eed262f4c11f.png

接着,将这部分作为选取,复制出这个选取的原图(不是这个调整过色阶的图)

40da7515de304bec6876f32bd04220be.png
ctrl+鼠标左键点击通道下的RGB通道

4bc93b02ed8b5db380a9b0585d6a82ba.png
选取被定义出来了

72581f8961cc696d2592b520aeb6945b.png
通过选取,复制原图

通过选取复制出来之后,这就是基本上这张图上的受光情况,这就是我们需要bloom的区域,接下来,将这个图层做一次高斯模糊。

4a1fe6e820293439e603bb68d9567a82.png

我这里随便拉一下,得到一张模糊后的图像,最后一步,将这个图像与原图相加即可(调整图层混合模式为 线性减淡(线性减淡就是与下层图像进行相加):

f29271c37ab1bc4fb4edaeedacf8f636.png

为了清晰,我把这里的名字重新做了梳理,从下至上的处理过程,其实Bloom的实现也是这么个流程。

最终我们的到了这样一张:

10342e68ad9b3569c5a72154024fb997.png
blur+source

a44f82864ff1cad2bf5d41cbea4aa3d2.png
source only

为了效果强烈一些,我多复制了一层并且透明度减少了50%,与原图做一个对比。这样,我们也得到了一个看起来略显廉价,但是也保留了主要特征的bloom,我们在与unity post process stack里面的bloom做一下对比:

ab1338a15ca10380bc6c67218989a0bd.png
unity post process stack bloom

导致这样的问题是因为 Unity 进行bloom特效时,所拿到的原始数据精度要远高于QQ截屏下来图像,请记住这个廉价的效果,后期会用到,不过再实际编码过程中处理的流程其实一样的。

梳理一下:bloom其实就是拿到一张图像,对期望区域进行模糊处理,再加回到原图,最后输出。

二、Bloom与HDR

刚做这行不久的时候,其实HDR(High Dynamic Range)这个名词总是会频频出现,尤其是与Bloom也常常绑定着一起出现。所以一直以来心中都有一个疑问,到底该怎样理解HDR,它与Bloom又有着什么样的关系?本章的重点是形象的理解,而非上来就是”高动态范围“这些初次看见就想骂娘的解释。我们挑战一下这个课题。

我们先做这么一个实验,在引擎里始终打开bloom,在HDR和非HDR的环境下观察效果。

b6ae660c4761df0b3db8fc8980cd71ab.png
在相机这里设置HDR的开关

我们看到左边的HDR使用的是Use Graphics Setting 由于我们的目标平台为PC所以在Graphics Setting里面默认是使用HDR的。(这里的版本是Unity 2019.2.4f1)

02536a06df3543d6ffbde4c786abab9d.png
Graphics Setting

先来观察打开HDR的图像,是这样的:

646f6b77148cf7153a3932e2bff4c11a.png

我们再次关闭HDR,观察图像,是这样的:

71fd3cb488dadc4e37c08ba850dc7da4.png

Bloom始终打开,但在HDR与非HDR(LDR)的两者环境下的图像,受光部分出现了比较强的反差,没有HDR支持的小恐龙受光部分的光晕弱了很多,那是什么导致了这样的原因?还记得之前在PS里面处理的廉价版的Bloom图像吗?是不是很像?我拿出来做一下对比

8dd467bdcd36b1a82c79b071cd94b74a.png
(我将ps里面之前多余复制出来的图层关掉)

所以我们可以得到一个初步的判断,非HDR下bloom所处理的图像,跟我们人手QQ截图下来送进ps里的图像,基本是一样的。那为什么HDR下的效果会如此强烈?是什么导致了这样的反差?上面也说过,是两者处理时使用的数据精度不一样所造成的。那要如何形象的理解这里的精度不一呢?我认为如果形象的理解了这个精度,那么对于得到这两个结果的不同就豁然开朗,如下图:

075bc72f5a53423f50746596ef89d26d.png

我们就先用一个亮度的概念来解释:HDR比LDR拥有着更多的亮度。也就是说,HDR跨过了LDR的最大存储容量(每通道8bit,共32bit的容量),这意味HDR能拥有更多的颜色,如果归一化解释,假设这个LDR下亮度最多到1,那么HDR则会突破这个限制超过1。

我们在ps里处理过程中,有一步是抠出受光面,这一步是通过色阶完成的,最终扣出来的那片区域,其实范围也是LDR的,因为我们模糊的图像,是基于QQ截图下来的,Unity内部进行模糊的数据是HDR范围下的,它拥有着比QQ截图更大的亮度,更多的颜色(更高的精度)。

为什么QQ截图是LDR的?因为虽然处理的是HDR数据,但还得是LDR下输出(我们显示器所限),而我们截取的屏幕,也是输出过的,因此截屏是不会得到HDR数据的。

13f5724f12ac7de7685c099b9a94b4f6.png
红色表示两种模式下,模糊受光部分的精度

我们假设红色部分是我们要模糊的那部分像素,LDR下,我们拥有的精度比HDR下少很多的信息,因此得到的结果也就比HDR下弱很多。

当我们拿到更高精度的数据去处理图像的时候,那么得到的也将是更高精度的结果。

总结一下:开启HDR后,ColorBuffer里则会存储更多的数据,当跟一些特殊的后效搭配,可以得到一些更高级的效果,如果把这个”高动态范围“解释为”高精度范围“或者美术更好理解一点的”高明度范围“会不会更好理解一点呢?

三、HDR&Bloom的美术意图

不同的项目或许风格迥异,但都希望在画面上获得更多的宽容度,拉高上限来获得更高自由度去创造虚拟世界,不管是明度对比,还是色彩对比,画面更多的层次、更丰富的变化,永远是美术人追求的目标。这就像是在地面有一个道具,策划往往希望玩家能很明显的观察到这个可交互物,这时就需要一些辅助手段,比如加个特效,换个OutlineShader等,这本质上就是一种拉开画面层次的手段,尽管这个做法并非基于画面美感去考虑的。美感的表达往往是在统一中寻求变化,在这个统一的框架下,变化越是丰富,画面也越经得住眼睛的长时间的考验。我也认为这也就是为什么2D美术即使像油画般精致,却还是被实时渲染的历史潮流无情碾压,实时渲染可以有着更丰富的即时变化,这些技术都是动态的,且还在不断地进步发展,是动态的,那就说明可以给画面提供更丰富的变化。 那么从美术上,肯定也是需要这种拉开美术关系,画面层次的手段。

HDR&bloom就是其中的一种手段,在提升画面层次,气氛的营造有很重要的作用,它可以让静谧的夜晚显得更神秘,正午的阳光更炙热。没图是说明不了问题的:

5eef8af769bd84f56fbf497f12307b1e.png
without bloom

那如果我将这个场景的层次进一步拉开,我们打开bloom

aa8e680820c04a77bec42f51c321d3bb.png
with bloom

意境在bloom加入之后得到了进一步提升。光晕让视觉中心得到了进一步的强化。也让”月光“这个看不见摸不着的东西,在画面中也能得到隐约的感受。

总结:更高的技术宽容度,即更高的表现宽容度

四 、在Unity中实现Bloom

本章将通过Unity给出的API OnImageRender下实现这个特效,探索以上所说的内部原理,并实现它,最终我们会得到一个与unity post process stack 里效果很接近的bloom。

6e27e9cdd8ecc08a26fc6657d16857e2.png
最终效果

首先我们捋一下大致处理的流程,以免跑偏:

  1. 获取当前图像
  2. 抠出期望区域
  3. 对期望区域做模糊
  4. 叠加回源图像
  5. 输出

1、获取当前图像

首先我们简单的搭一个场景:

0fcb2d8a29aca1e276b1428167bc7cc2.png

209d5931fc24efafef2b8dc06ac2a287.png
将其中一个对象材质中的自发光选项打开,以便后续查看颜色强度变化

然后新建一个在摄像机新建一个脚本,Bloom.cs

aa15659b37d1426bb2041d0abad91045.png
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[ExecuteInEditMode]
public class Bloom : MonoBehaviour
{
    private void OnRenderImage(RenderTexture source, RenderTexture destination)
    {
        Graphics.Blit(source, destination);
    }
}

打开脚本,将自带的Start 与Update 方法删掉。为了确保我们的效果在非运行时下可以看到效果我们加一个Header

[ExecuteInEditMode]

为了让我们的Scene视图下也可以观察到效果,后面再跟一个

[ExecuteInEditMode,ImageEffectAllowedInSceneView]

OnRenderImage方法有两个类型为RenderTexture参数,source 指的是当前相机渲染的图像,destnation是目标图像, Graphics.Blit可以将source(当前摄像机图像)传递给 shader,通过一系列操作之后输出到destnation,Unity最后会将destnation写回backbuffer(你的屏幕)。当前这个方法什么都没做。

接下来为了更容易理解,先不着急往下走,继续做一些测试。

我们接着将文件夹做一些归类

ab44565e9037c70d7c8a26e043d5c4f7.png

然后再Shader文件里新建一个最基本ImageEffectShader:

2fbcbec5bc200f176aa4a452694c0f4b.png

6db0dba2007518cb26d1bd85448d5352.png

这里我们就叫bloom,打开shader,看看里面都什么玩意:

Shader "Hidden/Bloom"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
    }
    SubShader
    {
        // No culling or depth
        Cull Off ZWrite Off ZTest Always

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
            };

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
            };

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = v.uv;
                return o;
            }

            sampler2D _MainTex;

            fixed4 frag (v2f i) : SV_Target
            {
                fixed4 col = tex2D(_MainTex, i.uv);
                // just invert the colors
                col.rgb = 1 - col.rgb;
                return col;
            }
            ENDCG
        }
    }
}

shader很简单,不做介绍了,值得注意的是frag 方法里:

                col.rgb = 1 - col.rgb;
                return col;

这儿对颜色做了一个反转,这个有利于我们做测试,看看着色器是不是工作,如果工作,屏幕上应该会出现一个颜色反转(类似底片)的效果。

再次打开Bloom.cs:

...
    [SerializeField] Material mat;
    [SerializeField] Shader bloomShader;

    const string m_bloomShaderID = "Hidden/Bloom";
    private void Awake()
    {
        bloomShader = Shader.Find(m_bloomShaderID);
        mat = new Material(bloomShader);
    }
...

将材质跟shader在Awake里做初始化,然后返回编辑器,看看有没有得到正确结果:

63a5a6a24b78ab9294096ed6737e8800.png
初始化成功

正确,但目前我们并没有看到底底片效果,目前OnRenderImage()里还没有用到我们当前的这个shader做处理,接下来我们将代码稍作修改:

Graphics.Blit(source, destination,mat);

Graphics.Blit有很多重载,我们选择有Material类型的重载,然后将材质传递进去。

4b40c3f631da4bb31a353c51d1f9e733.png

源数据通过材质指定的shader处理过后写到destnation里,最后得到了一张底片的图像,这个最终被写回backbuffer,变成你看到的图像。

还记得之前的公式 最终图像 = 原图像+模糊后的图像。

在模糊图像之前,再探讨一个问题:降采样。我们不会去对原图直接进行模糊,因为原图的尺寸比较大,且blur之后又是一张太清晰的低频图像,因此我们会再得到原图之后,进行一个降采样(更低分辨率)再进行模糊。模糊一般是对图像像素做卷积(术语是这么说的,我一直不太理解为什么叫卷积,一开始以为是春卷的一种衍生食品?),我所理解的卷积其实就是对一个像素上周围每一个像素,都分配一个权重,通过每个像素乘以权重最后加权,得到最终值。常见的方法有高斯过滤,盒式过滤,高斯过滤的好出就是效果比较好好,但是消耗也相对大一些,以至于常常把高斯操作分成两个pass进行单独的横向与纵向模糊最后合并起来。本章我们用效率较高的盒式过滤(Box Filter),直接使用这种方法的效果比较差,因此我们会配合一些技巧来提高图像质量。

首先我们来介绍一个概念,渐进式的降采样与上采样:一般我们为了拿到比屏幕分辨率小的图像时会这样做:

int width = screen.width/6;
int height = screen.height/6;

一个简单的实验:

int width = source.width/6;
int height = source.height/6;

RenderTexture tmp = RenderTexture.GetTemporary(width, height, 0, source.format);

Graphics.Blit(source, tmp, mat);
Graphics.Blit(tmp, destination);

d084f885d32077039c0b63b97eb1bd46.png
直接降低分辨率6倍的结果

我们把原图缩小1/6,然后渲染出来,确实得到了正确的结果,但这个结果效果并不好。理论我们是可以拿着这个图去找GPU让它来帮我们处理,而我们打算弃用高斯滤波,这么粗糙的结果盒式过滤表示拯救不了你,而我们想在预处理阶段就得到一个总体满意的效果。

一步一步来:

所以我们需要渐进式的降低分辨率与提高分辨率:每一次降低一倍的分辨率,然后迭代N次,然后每次再提升一半分辨率,迭代N次。为什么要这样做,迭代两次跟直接除以10有啥区别?这其实是利用了引擎的双线性过滤对图像做一个间接的模糊,如果我们在每一次迭代中,再加入盒式滤波,得到的效果会非常好 我们通过PS来感性的认识一下,先把一张图直接缩小10倍,然后放回原本大小:

e0bd9f5bb5df179e1f9084be74f09cd4.png
原图大小宽度1913,为了方便我们记作1920

接着我们直接降低10倍,

112abdbfc7f6a4ebdb97870a1a3205a7.png

注意:虽然PS的画布的尺寸改变了,但在引擎里,画布的大小是不可变的,我们改变的只是图像的分辨率。然后,我们要再把它放回原来大小:

94d2fd7ffa3f33b78d84f7561722f25f.png

当我们将一张低分辨率的图像填充回屏幕的时候,PS会有一个选项,对图像从新做一次采样,以平滑图像。

bd6bddaa37139a36c7d88c0a5fe4dd33.png

020d7acdebe36c10bdae29465b2ae229.png

直接缩放10倍的前后对比,第一张已经比原先的图像要模糊很多。

bc516026c509c2228e4bbb85d1a49d37.png

当低分辨率图像填充回画布时,两个原始的像素位置(绿点)的最终回出现在两个橙点的位置上,那么绿点中间这些像素就会通过插值,来匹配它们最终的位置(线性过滤),当我们将小分辨率的图像填充回屏幕时,unity也会做同样的操作。

接下来。我们这样做,先将原始图像,每一次分辨率减半,然后重复这样的操作4次,然后每次放大图像一倍,再重复操作4次。

(4次是因为如果我们进行第5次时,分辨率宽度会从120下降为60,而直接除10,分辨率是120,为了保证结果的一致性,这里我们做四次,而不是5次)

f8743bf4597d478abc92b054df1b3ec0.png

最终,我们得到了这样一个结果,在这种情况下,在我们还没有添加其他额外操作,就已经得到了如此平滑的效果。

放大对比一下细节,

24717ab0a2f0f10216270bf738956a85.png

放大之后对比,左边为直接处理,右边为渐进处理,渐进处理的效果要明显好过直接处理。

接下来,我们用同样的原理,放进unity里面,我们会通过最笨,最直接的办法做测试,这样易于理解,关于如何聪明的包装这些代码,相信你比我能。

2.抠出期望区域

3.模糊图像

这两部分我们放在一起做,先处理模糊,再处理抠出期望区域(讲解顺序,非实际处理顺序)。

我们先将屏幕分辨率降低一半然后输出:

int width = source.width;
int height = source.height;
        
RenderTexture tmpSource = RenderTexture.GetTemporary(width, height, 0, source.format);  
RenderTexture tmpDest;

Graphics.Blit(source, tmpSource);
        
tmpDest = RenderTexture.GetTemporary(tmpSource.width/2, tmpSource.height/2, 0, source.format);  
Graphics.Blit(tmpSource, tmpDest);

RenderTexture.ReleaseTemporary(tmpSource);
        
Graphics.Blit(tmpDest, destination);

接着我们每次降低一半,重复四次:

width = source.width;
height = source.height;
        
RenderTexture tmpSource = RenderTexture.GetTemporary(width, height, 0, source.format);  

Graphics.Blit(source, tmpSource);

        //分辨率降一半-------------------------第一次
width/=2;
height/=2;

RenderTexture tmpDest = RenderTexture.GetTemporary(width, height, 0, source.format);  
Graphics.Blit(tmpSource, tmpDest);

RenderTexture.ReleaseTemporary(tmpSource);
tmpSource = tmpDest;


        //分辨率降一半-------------------------第二次
width/=2;
height/=2;

tmpDest = RenderTexture.GetTemporary(width, height, 0, source.format);  
Graphics.Blit(tmpSource, tmpDest);

RenderTexture.ReleaseTemporary(tmpSource);
tmpSource = tmpDest;

        //分辨率降一半-------------------------第三次
width/=2;
height/=2;

tmpDest = RenderTexture.GetTemporary(width, height, 0, source.format);  
Graphics.Blit(tmpSource, tmpDest);

RenderTexture.ReleaseTemporary(tmpSource);
tmpSource = tmpDest;

        //分辨率降一半-------------------------第四次
width/=2;
height/=2;

tmpDest = RenderTexture.GetTemporary(width, height, 0, source.format);  
Graphics.Blit(tmpSource, tmpDest);

RenderTexture.ReleaseTemporary(tmpSource);
tmpSource = tmpDest;

Graphics.Blit(tmpDest, destination);
RenderTexture.ReleaseTemporary(tmpDest);

注意释放Temporary的时机,就像将A盒子的东西放入B盒子里之前,需要先把B盒子里面的杂物拿出来一样。

Graphics.Blit(tmpSource, tmpDest);

RenderTexture.ReleaseTemporary(tmpSource);
tmpSource = tmpDest;

c24823202b0fb5669003c0c3d897c3ec.png

这是我们得到的结果,进frame debug里面瞧瞧:

4e96bdc2891a290c7b43843c74a621de.png

Drawcall 从第12开始到16,分辨率逐次降低,并在第17个Drawcall铺满画布。这么看来,渐进式的下采样就完成了,接下来我们沿用这种思想,逐次进行上采样

        //分辨提升一半-----------------------第一次

width*=2;
height*=2;

tmpDest = RenderTexture.GetTemporary(width, height, 0, source.format);  
Graphics.Blit(tmpSource, tmpDest);

RenderTexture.ReleaseTemporary(tmpSource);
tmpSource = tmpDest;

        //分辨提升一半-----------------------第二次

width*=2;
height*=2;

tmpDest = RenderTexture.GetTemporary(width, height, 0, source.format);  
Graphics.Blit(tmpSource, tmpDest);

RenderTexture.ReleaseTemporary(tmpSource);
tmpSource = tmpDest;

        //分辨提升一半-----------------------第三次

width*=2;
height*=2;

tmpDest = RenderTexture.GetTemporary(width, height, 0, source.format);  
Graphics.Blit(tmpSource, tmpDest);

RenderTexture.ReleaseTemporary(tmpSource);
tmpSource = tmpDest;

        //分辨提升一半-----------------------第四次

width*=2;
height*=2;

tmpDest = RenderTexture.GetTemporary(width, height, 0, source.format);  
Graphics.Blit(tmpSource, tmpDest);

RenderTexture.ReleaseTemporary(tmpSource);
tmpSource = tmpDest;

Graphics.Blit(tmpDest, destination);
RenderTexture.ReleaseTemporary(tmpDest);

076a7b30a3063fbeb1ba9e6ff020175f.png

仅通过渐进式的下采样与下采样的技巧,已经可以得到让人接受的模糊效果。我们用直接处理的方法,做一个对比:

437e291c25d6e7b73578fa20d5b69cb2.png
左侧为直接处理,右侧为间接处理

知道目前,代码中并没有让shader参与任何计算,接下来将在渐进采样的过程中为图像进行盒式过滤:

half3 Sample(float2 uv)
{
      return tex2D(_MainTex,uv).rgb;
}

half3 BoxFilter(float2 uv)
{
      half2 upL,upR,downL,downR;
                
      upL =_MainTex_TexelSize.xy*half2(-1,1);
      upR =_MainTex_TexelSize.xy*half2(1,1);
      downL =_MainTex_TexelSize.xy*half2(-1,-1);
      downR =_MainTex_TexelSize.xy*half2(1,-1);

      half3 col =0;

      col+=Sample(uv+upL)*0.25;
      col+=Sample(uv+upR)*0.25;
      col+=Sample(uv+downL)*0.25;
      col+=Sample(uv+downR)*0.25;

      return col;
}

_MainTex_TexelSize是每一个像素的纹理坐标,通过它我们得以找到每一颗像素的位置,然后以一颗像素为中心,他周围的像素应该会在他的 左上,右上,左下,右下。我们有4颗像素参与计算,因此每个像素的权重是1/4=0.25这样,最后再将他们加起来即可。

到目前为止我们的代码并无明确指定使用这个pass进行处理,所以加上:

Graphics.Blit(tmpSource, tmpDest,mat);

85c5262c603c33dacddd9184871b0f50.gif

效果相当赞,并不比高斯过滤差。

3197e1ab5f95f839f93b9f23ff3f2f8f.png
渐进采样+盒式过滤

抠出期望区域:

bloom只会在亮度很强的地方发生,因此,需要得到图像亮度峰值的区域。只需要再图像处理之前,将图像做一次预处理即可:

float _Theshold;
    
 //亮度分布
half3 PreFilter(half3 c)
{
    half brightness = max(c.r,max(c.g,c.b));
    half contribution = max(0,brightness-_Theshold);
    contribution/=max(brightness,0.00001);
    return c*contribution;
 }

如果直接用减法也可以,但不够精确,这里我们通过比较rgb三个通道最大值来得出亮度值,然后在shader里面公开一个参数,这个参数将通过C#控制图像的亮度阈值。

  SubShader
    {
        // No culling or depth
        Cull Off ZWrite Off ZTest Always

        Pass//0 预处理
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            fixed4 frag (v2f i) : SV_Target
            {
                return fixed4(PreFilter(BoxFilter(i.uv)),1);
            }
            ENDCG
        }

        Pass//1--盒式过滤
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            //...
            ENDCG
        }
    }

然后在C#里面

    [Range(0,10)]
    public float theshold=0f;

...
    private void OnRenderImage(RenderTexture source, RenderTexture destination)
    {
        mat.SetFloat("_Theshold",theshold);
...
     }

d3fd196a8736665c3ce1caba1f15e08f.gif
without HDR

57a9f23ae2e07b6e243c6381062fe92d.gif
with HDR

仔细观察,在非HDR的情况下,当阈值快到1的时候,整个图像无限接近黑色,在HDR环境下,因为物体材质的自发光有更高亮度的情况下,阈值就算超过1,图像也是有信息的。

最后我们创建一个合并的pass:

Pass//--合并
{
    CGPROGRAM
    #pragma vertex vert
    #pragma fragment frag

    fixed4 frag (v2f i) : SV_Target
   {
        fixed3 sourceColor = tex2D(_SourceTex,i.uv).rgb;
        return fixed4(sourceColor+BoxFilter(i.uv,0.5),1);
    }
    ENDCG 
}

_SourceTex是原始没有处理过的图像,我们现在将两者合并:

mat.SetTexture("_SourceTex",source);
Graphics.Blit(tmpDest, destination,mat,3);
RenderTexture.ReleaseTemporary(tmpDest);

然后观察scene view,得到这样的效果:

6b46ee41913e24bf8e495ff0effffd6e.gif

在到里,我们的bloom可以正常工作了,但是效果并不好,可为什么不好呢?当我们去辨识一个有色发光体时,真正决定光色相的不是发光源,而是光晕:

4ac3389541900fe216d8d661f7f877ec.png

假设两个正方形,都做一个相同色相的辉光处理,虽然右侧方形是纯白,但我们还是会认为,这是一个橙黄发光体,并且亮度会比左侧更高,甚至左侧的东西都不像是在发光,因为真正的发光体跟右侧一样,光源中心的亮度,色相,纯度,都跟光晕有差别:

333f4eeb680f511b3af4d1fcbfdf7ded.png

非常高亮的光源一般中心亮度很高高,纯度低,色相不易被察觉,而越往外,色彩倾向越明确,不仅亮度,纯度会发生改变,色相也会发生变化,将最外层的颜色处理为红色,我们依然认为,他是一个橙色发光源,且比上图在感官上有更丰富的变化,自然也更漂亮,甚至因为色相的改变,上图左侧不那么像光源的方形,在下图中更加像一个发光体了。

因此我们的效果与post process stack 中的bloom还有这最后一公里的差距,因为到目前为止,我们的光源与光晕的色相,纯度,都没有太多的变化,那么我们怎么做?

我们需要累计图像亮度与颜色,每一次模糊图像时,都期望与上一个模糊的图像做一个加法混合,而不只是单纯的缩放分辨率与模糊:

Pass//--盒式过滤
{
    blend one one 
    ... ...
}

4b2350892e1c867b74cfb42749c0f816.gif
圣诞快乐!

虽然是个错误的效果,但我们也可以发现事情正在往好的方向发展:

d8666dae9a1a9f5d887b816b10d39ae6.png

从白色,到天蓝色,再到深蓝色,亮度,色相,纯度都有了变化,所以累计亮度是对的。

这个错误是因为每一个drawcall都与上一个的结果进行相加,不断累积了亮度。但第一我们不需要在降采样的时候累加,第二不是从backbuffer 中GetTemporary,而是在上采样的时候,累加之前下采样的结果。

我们的方法是,用一个数组,将之前下采样的结果都存起来,在上采样的时候,数据从数组中取出来,然后累加,我们也不需要再下采样的时候累加,这个时候我们需要再上采样时再用 blend one one的pass做累加:

Pass//2--盒式过滤+亮度累加
{
    blend one one 
    CGPROGRAM
    #pragma vertex vert
    #pragma fragment frag

    fixed4 frag (v2f i) : SV_Target
    {
        return fixed4(BoxFilter(i.uv,1),1);
    }
    ENDCG
}

然后我们再上采样的时候用这个pass 去处理rt:

RenderTexture[] textures =new RenderTexture[16];
        
RenderTexture  tmpDest=textures[0] = RenderTexture.GetTemporary(width, height, 0, source.format);  
Graphics.Blit(source, tmpDest,mat,0);

RenderTexture  tmpSource =tmpDest;
int i =1;

for (; i<7 ; i++)
{
    width/=2;
    height/=2;

    tmpDest =textures[i] = RenderTexture.GetTemporary(width, height, 0, source.format);  
    Graphics.Blit(tmpSource, tmpDest,mat,1);

    tmpSource = tmpDest;
}

for(i-=2;i>=0;i--)
{
    tmpDest = textures[i];

    textures[i] = null;
    Graphics.Blit(tmpSource, tmpDest,mat,2);

    RenderTexture.ReleaseTemporary(tmpSource);
    tmpSource = tmpDest;
}

mat.SetTexture("_SourceTex",source);
Graphics.Blit(tmpDest, destination,mat,3);
RenderTexture.ReleaseTemporary(tmpSource);

现在我们迭代了7次,来对比一下之前跟之后的效果:

39658ccdb50554f7502626fd4c2f1b91.gif
迭代7次,没有亮度累加

d7eb4381cf6603f8d8e395b24645b38a.gif
迭代7次,有亮度累加

跟unity post process stack中效果非常接近。

faf3411048d117722090e30825dfa15f.png

3888bab54017d50310fe4c27b2b46164.png

至此,我们的HDR&bloom就到此讲完了,下一篇文章将讨论一些优化以及如何在LDR下也能得到跟HDR下相似结果的Tirck,虽然现在的机能(不论手机还是桌面)这点算力不算什么,但在移动AR/VR领域下还是很有用处的。

广告时间:本人所在的Ximmerse(广东虚拟现实)是一家AR硬件/软件/服务一体化的公司,近期推出了全息博物馆(HoloMuseum)面向全年龄,目前已经进入到试运营阶段,想想这样一群海龟从头上飘过是一种什么体验?

欢迎大家来抓龟 ^ ^:广东省深圳市南山区中心路宝能太古城北区负2层。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值