WebGL学习之HDR与Bloom
原文地址:WebGL学习之HDR与Bloom
什么是HDR
HDR (High Dynamic Range,高动态范围),在摄影领域,指的是可以提供更多的动态范围和图像细节的一种技术手段。简单讲就是将不同曝光拍摄出的最佳细节的LDR (低动态范围) 图像合成后,就叫HDR,它能同时反映出场景最暗和最亮部分的细节。为什么需要多张图片?因为目前的单反相机的宽容度还是有限的,一张照片不能反映出高动态场景的所有细节。一张图片拍摄就必须要在暗光和高光之间做出取舍,只能亮部暗部两者取其一。但是通过HDR合成多张图片,却能达到我们想要的效果。
那么在WebGL中,HDR具体指的是什么。它指的是让我们能用超过1.0的数据表示颜色值。到目前为止,我们用的都是LDR(低动态范围),所有的颜色值都被限制在了 [0,1] 范围。在现实当中,太阳,灯光这类光源它们的颜色值肯定是远远超出1.0的范围的。
本节实现的效果请看hdr & bloom
浮点帧缓冲
当帧缓冲使用标准化的定点格式(像gl.RGB)为其颜色缓冲的内部格式,WebGL会在将这些值存入帧缓冲前自动将其约束到0.0到1.0之间。这一操作对大部分帧缓冲格式都是成立的,除了专门用来存放被拓展范围值的浮点格式。
WebGL扩大颜色值范围的方法就是:把颜色的格式设置成16位浮点数或者32位浮点数,即把帧缓冲的颜色缓冲的内部格式设定成 gl.RGB16F, gl.RGBA16F, gl.RGB32F 或者 gl.RGBA32F,这些帧缓冲被叫做浮点帧缓冲(Floating Point Framebuffer),浮点帧缓冲可以存储超过0.0到1.0范围的浮点值,所以非常适合HDR渲染。
创建浮点帧缓冲,我们只需要改变颜色缓冲的内部格式参数就行了(注意 gl.FLOAT参数):
gl.bindTexture(gl.TEXTURE_2D, colorBuffer);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB16F, SCR_WIDTH, SCR_HEIGHT, 0, gl.RGB, gl.FLOAT, NULL);
帧缓冲默认一个颜色分量只占用8位(bits)。当使用一个使用32位每颜色分量时(使用gl.RGB32F 或者 gl.RGBA32F),我们需要四倍的内存来存储这些颜色。所以除非你需要一个非常高的精确度,32位不是必须的,使用 gl.RGB16F就足够了。
色调映射
色调映射(Tone Mapping)是一个损失很小的转换浮点颜色值至我们所需的LDR[0.0, 1.0]范围内的过程,通常会伴有特定的风格的色平衡(Stylistic Color Balance)。
最简单的色调映射算法是Reinhard色调映射,它涉及到分散整个HDR颜色值到LDR颜色值上,所有的值都有对应。Reinhard色调映射算法平均地将所有亮度值分散到LDR上。将Reinhard色调映射应用到之前的片段着色器上,并且加上一个Gamma校正过滤:
void main()
{
const float gamma = 2.2;
vec3 hdrColor = texture(hdrBuffer, TexCoords).rgb;
// Reinhard色调映射
vec3 mapped = hdrColor / (hdrColor + vec3(1.0));
// Gamma校正
mapped = pow(mapped, vec3(1.0 / gamma));
color = vec4(mapped, 1.0);
}
有了Reinhard色调映射的应用,我们不再会在场景明亮的地方损失细节。当然,这个算法是倾向明亮的区域的,暗的区域会不那么精细也不那么有区分度。
另一个色调映射应用是曝光(Exposure)参数的使用。HDR图片包含在不同曝光等级的细节。如果我们有一个场景要展现日夜交替,我们当然会在白天使用低曝光,在夜间使用高曝光,就像人眼调节方式一样。有了这个曝光参数,我们可以去设置可以同时在白天和夜晚不同光照条件工作的光照参数,我们只需要调整曝光参数就行了。
一个简单的曝光色调映射算法会像这样:
uniform float exposure;
void main()
{
const float gamma = 2.2;
vec3 hdrColor = texture(hdrBuffer, TexCoords).rgb;
// 曝光色调映射
vec3 mapped = vec3(1.0) - exp(-hdrColor * exposure);
// Gamma校正
mapped = pow(mapped, vec3(1.0 / gamma));
color = vec4(mapped, 1.0);
}
什么是Bloom
Bloom 泛光 (或者眩光),是用来模拟光源那种发光或发热的技术。区分明亮光源的方式是使它们发出光芒,光源的光芒向四周发散,这样观察者就会产生光源或亮区的确是强光区。Bloom使我们感觉到一个明亮的物体真的有种明亮的感觉。而Bloom和HDR的结合使用能非常完美地展示光源效果。
泛光的品质很大程度上取决于所用的模糊过滤器的质量和类型。下面这几步就是泛光后处理特效的过程,它总结了实现泛光所需的步骤。
提取亮色
首先我们要从渲染出来的场景中提取两张图片。可以渲染场景两次,每次使用一个不同的不同的着色器渲染到不同的帧缓冲中,但可以使用一个叫做MRT(Multiple Render Targets多渲染目标)的小技巧,有了它我们能够在一个单独渲染处理中提取两个图片。在片元着色器的输出前,我们指定一个布局location标识符,这样我们便可控制一个片元着色器写入到哪个颜色缓冲:
layout (location = 0) out vec4 FragColor;
layout (location = 1) out vec4 BrightColor;
使用多个片元着色器输出的必要条件是,有多个颜色缓冲附加到了当前绑定的帧缓冲对象上。直到现在,我们一直使用着 gl.COLOR_ATTACHMENT0,但通过使用 gl.COLOR_ATTACHMENT1,可以得到一个附加了两个颜色缓冲的帧缓冲对象。
但首先我们还是将创建帧缓冲的功能进行封装:
function createFramebuffer(gl,opt,width,height){
const fb = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
const framebufferInfo = {
framebuffer: fb,
textures: []
};
const texs = opt.texs || 1;//颜色缓冲数量
const depth = !!opt.depth;
// SECTION 创建纹理
for(let i=0;i< texs;i++){
const tex = initTexture(gl,opt, width, height);
framebufferInfo.textures.push(tex);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0 + i, gl.TEXTURE_2D, tex, 0);
}
// SECTION 创建用于保存深度的渲染缓冲区
if(depth) {
const depthBuffer = gl.createRenderbuffer();
gl.bindRenderbuffer(gl.RENDERBUFFER, depthBuffer);
gl.renderbufferStorage(gl.RENDERBUFFER, gl.DEPTH_COMPONENT16, width, height);
gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, depthBuffer);
}
// 检查帧缓冲区对象
const e = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
if (gl.FRAMEBUFFER_COMPLETE !== e) {
throw new Error('Frame buffer object is incomplete: ' + e.toString());
}
// 解绑帧缓冲区对象
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.bindTexture(gl.TEXTURE_2D, null);
if(depth) gl.bindRenderbuffer(gl.RENDERBUFFER, null);
return framebufferInfo;
}
接着调用上面的函数创建包含两个颜色附件和一个深度附件的帧缓冲区。
//场景帧缓存(2颜色附件 包含正常颜色 和 hdr高光颜色,1深度附件)
const fbo = createFramebuffer(gl,{informat:gl.RGBA16F, type:gl.FLOAT, texs:2, depth:true});
在渲染的时候还需要显式告知WebGL我们正在通过gl.drawBuffers渲染到多个颜色缓冲,否则WebGL只会渲染到帧缓冲的第一个颜色附件,而忽略所有其他的。
//采样到2个颜色附件
gl.drawBuffers([gl.COLOR_ATTACHMENT0, gl.COLOR_ATTACHMENT1]);
当渲染到这个帧缓冲的时候,一个着色器使用一个布局location修饰符,然后把不同颜色值渲染到相应的颜色缓冲。这样就省去了为提取高光区域的额外渲染步骤。
#version 300 es
precision highp float;
layout (location = 0) out vec4 FragColor;
layout (location = 1) out vec4 BrightColor;
//...
void main() {
vec3 normal = normalize(vNormal);
vec3 viewDirection = normalize(u_viewPosition - vposition);
//...
vec3 result = ambient + lighting;
// 检查结果值是否高于某个门槛,如果高于就渲染到高光颜色缓存中
float brightness = dot(result, vec3(0.2126, 0.7152, 0.0722));
if(brightness > 1.0){
BrightColor = vec4(result, 1.0);
} else {
BrightColor = vec4(0.0, 0.0, 0.0, 1.0);
}
FragColor = vec4(result, 1.0);
}
这里先正常计算光照,将其传递给第一个片元着色器的输出变量FragColor。然后我们使用当前储存在FragColor的东西来决定它的亮度是否超过了一定阈限。我们通过恰当地将其转为灰度的方式计算一个fragment的亮度,如果它超过了一定阈限,我们就把颜色输出到第二个颜色缓冲,那里保存着所有亮部。
这也说明了为什么泛光在HDR基础上能够运行得很好。因为HDR中,我们可以将颜色值指定超过1.0这个默认的范围,我们能够得到对一个图像中的亮度的更好的控制权。没有HDR我们必须将阈限设置为小于1.0的数,虽然可行,但是亮部很容易变得很多,这就导致光晕效果过重。
有了一个提取出的亮区图像,我们现在就要把这个图像进行模糊处理。
高斯模糊
要实现高斯模糊过滤需要一个二维四方形作为权重,从这个二维高斯曲线方程中去获取它。然而这个过程有个问题,就是很快会消耗极大的性能。以一个32×32的模糊kernel为例,我们必须对每个fragment从一个纹理中采样1024次!
幸运的是,高斯方程有个非常巧妙的特性,它允许我们把二维方程分解为两个更小的方程:一个描述水平权重,另一个描述垂直权重。我们首先用水平权重在整个纹理上进行水平模糊,然后在经改变的纹理上进行垂直模糊。利用这个特性,结果是一样的,但是可以节省难以置信的性能,因为我们现在只需做32+32次采样,不再是1024了!这叫做两步高斯模糊。
这意味着我们如果对一个图像进行模糊处理,至少需要两步,最好使用帧缓冲对象做这件事。具体来说,我们将实现像乒乓球一样的帧缓冲来实现高斯模糊。意思是使用一对帧缓冲,我们把另一个帧缓冲的颜色缓冲放进当前的帧缓冲的颜色缓冲中,使用不同的着色效果渲染指定的次数。基本上就是不断地切换帧缓冲和纹理去绘制。这样我们先在场景纹理的第一个缓冲中进行模糊,然后在把第一个帧缓冲的颜色缓冲放进第二个帧缓冲进行模糊,接着将第二个帧缓冲的颜色缓冲放进第一个,循环往复。
在我们研究帧缓冲之前,先来实现高斯模糊的片元着色器:
#version 300 es
precision highp float;
uniform sampler2D image;
uniform bool horizontal;
in vec2 texcoord;
out vec4 FragColor;
const float weight[5] = float[](0.2270270270, 0.1945945946, 0.1216216216, 0.0540540541, 0.0162162162);
void main() {
vec2 tex_offset = vec2(1.0 / float(textureSize(image, 0)));//每个像素的尺寸
vec3 result = texture(image, texcoord).rgb * weight[0];
if (horizontal) {
for (int i = 0; i < 5; ++i) {
result += texture(image, texcoord + vec2(tex_offset.x * float(i), 0.0)).rgb * weight[i];
result += texture(image, texcoord - vec2(tex_offset.x * float(i), 0.0)).rgb * weight[i];
}
} else {
for (int i = 0; i < 5; ++i) {
result += texture(image, texcoord + vec2(0.0, tex_offset.y * float(i))).rgb * weight[i];
result += texture(image, texcoord - vec2(0.0, tex_offset.y * float(i))).rgb * weight[i];
}
}
FragColor = vec4 (result, 1.0);
}
这里使用一个比较小的高斯权重做例子,每次我们用它来指定当前fragment的水平或垂直样本的特定权重。你会发现我们基本上是将模糊过滤器根据我们在uniform变量horizontal设置的值分割为一个水平和一个垂直部分。通过用1.0除以纹理的大小(从textureSize得到一个vec2)得到一个纹理像素的实际大小,以此作为偏移距离的根据。
接着为图像的模糊处理创建两个基本的帧缓冲,每个只有一个颜色缓冲纹理,调用上面封装好的createFramebuffer函数即可。
//2乒乓帧缓存(都只包含1颜色附件)
const hFbo = createFramebuffer(gl,{informat:gl.RGBA16F, type:gl.FLOAT});
const vFbo = createFramebuffer(gl,{informat:gl.RGBA16F, type:gl.FLOAT});
得到一个HDR纹理后,我们用提取出来的亮区纹理填充一个帧缓冲,然后对其模糊处理6次(3次垂直3次水平):
/**
* 乒乓帧缓存
*/
gl.useProgram(pProgram.program);
for(let i=0; i < 6; i++){
bindFramebufferInfo(gl, i%2 ? hFbo:vFbo);
setBuffersAndAttributes(gl, pProgram, pVao);
setUniforms(pProgram,{
horizontal: i%2? true:false,
image: i == 0 ? fbo.textures[1]: i%2 ? vFbo.textures[0]: hFbo.textures[0], //第1次两个乒乓帧缓存都为空,因此第一次要将灯光纹理传入
});
drawBufferInfo(gl, pVao);
}
每次循环根据渲染的是水平还是垂直来绑定两个缓冲其中之一,而将另一个绑定为纹理进行模糊。第一次迭代,因为两个颜色缓冲都是空的所以我们随意绑定一个去进行模糊处理。重复这个步骤6次,亮区图像就进行一个重复3次的高斯模糊了。这样我们可以对任意图像进行任意次模糊处理;高斯模糊循环次数越多,模糊的强度越大。
把两个纹理混合
有了场景的HDR纹理和模糊处理的亮区纹理,只需把它们结合起来就能实现泛光或称光晕效果了。最终的片元着色器要把两个纹理混合:
#version 300 es
precision highp float;
in vec2 texcoord;
uniform sampler2D image;
uniform sampler2D imageBlur;
uniform bool bloom;
out vec4 FragColor;
const float exposure = 1.0;
const float gamma = 2.2;
void main() {
vec3 hdrColor = texture(image, texcoord).rgb;
vec3 bloomColor = texture(imageBlur, texcoord).rgb;
if (bloom)
hdrColor += bloomColor; //添加融合
//色调映射
// vec3 result = hdrColor / (hdrColor + vec3(1.0));
vec3 result = vec3 (1.0) - exp(-hdrColor * exposure);
//进行gamma校正
result = pow(result, vec3 (1.0 / gamma));
FragColor = vec4(result, 1.0);
}
注意要在应用色调映射之前添加泛光效果。这样添加的亮区的泛光,也会柔和转换为LDR,光照效果相对会更好。把两个纹理结合以后,场景亮区便有了合适的光晕特效:
这里只用了一个相对简单的高斯模糊过滤器,它在每个方向上只有5个样本。通过沿着更大的半径或重复更多次数的模糊,进行采样我们就可以提升模糊的效果。因为模糊的质量与泛光效果的质量正相关,提升模糊效果就能够提升泛光效果。
后记
这个HDR + Bloom的是目前为止渲染流程最复杂的一个特效了,使用了3个着色器program和3个帧缓冲区,绘制的时候要不断切换program 和 帧缓冲区。目前有个问题是,从帧缓冲渲染到正常缓冲后场景的锯齿感挺严重的,后续还得深入学习下抗锯齿(anti-aliasing)。
WebGL学习之HDR与Bloom的更多相关文章
- WebGL学习(1) - 三角形
原文地址:WebGL学习(1) - 三角形 还记得第一次看到canvas的粒子特效的时候,真的把我给惊艳到了,原来在浏览器也能做出这么棒的效果.结合<HTML5 Canvas核心技术>和网 ...
- WebGL学习(2) - 3D场景
原文地址:WebGL学习(2) - 3D场景 经过前面WebGL学习(1) - 三角形的学习,我们已经掌握了webGL的基础知识,也已经能够画出最基本的图形,比如点,线,三角形,矩形等.有了2D绘图的 ...
- WebGL学习(3) - 3D模型
原文地址:WebGL学习(3) - 3D模型 相信很多人是以创建逼真酷炫的三维效果为目标而学习webGL的吧,首先我就是
- WebGL学习之纹理贴图
为了使图形能获得接近于真实物体的材质效果,一般会使用贴图,贴图类型主要包括两种:漫反射贴图和镜面高光贴图.其中漫反射贴图可以同时实现漫反射光和环境光的效果. 实际效果请看demo:纹理贴图 2D纹理 ...
- HDR和bloom效果的区别和关系
什么是HDR? 谈论游戏画面时常说的HDR到底是什么呢?HDR,本身是High-Dynamic Range(高动态范围)的缩写,这本来是一个CG概念.HDR的含义,简单说,就是超越普通的 ...
- webgl学习笔记五-纹理
写在前面 建议先阅读下前面我的三篇文章. webgl学习笔记一-绘图单点 webgl学习笔记二-绘图多点 webgl学习笔记三-平移旋转缩放 术语 : 纹理 :图像 图形装配区域 :顶点着色器顶点坐标 ...
- webgl学习笔记四-动画
写在前面 建议先阅读下前面我的三篇文章. webgl学习笔记一-绘图单点 webgl学习笔记二-绘图多点 webgl学习笔记三-平移旋转缩放 下面我们将讲解下如何让一个正方形动起来~不断擦除和重绘 ...
- webgl学习笔记三-平移旋转缩放
写在前面 建议先阅读下前面我的两篇文章. webgl学习笔记一-绘图单点 webgl学习笔记二-绘图多点 平移 1.关键点说明 顶点着色器需要加上 uniform vec4 u_Translation ...
- webgl学习笔记二-绘图多点
写在前面 建议先看下第一篇webgl学习笔记一-绘图单点 第一篇文章,介绍了如何用webgl绘图一个点.接下来本文介绍的是如何绘制多个点.形成一个面. webgl提供了一种很方便的机制,即缓冲区对象, ...
随机推荐
- 关于https的五大误区
如今,https协议正在被广泛重视和使用.随着今年2月初,谷歌旗下Chrome浏览器宣布将所有http标示为不安全网站,许多网站都争相从http升级到了https.当你打开很多网站时,会发现浏览器左上 ...
- javascript权威指南第14章 表单脚本示例代码
HTML部分 <!DOCTYPE html> <html> <head> <title></title> </head> < ...
- kubernetes跨网段pod网络不通问题
kubernetes跨网段问题 k8s的master是10.10.10.0网段,新加了一些node,网段是172.16.100.0网段,造成容器直接网络不能相互访问. 部署k8s的时候也部署了flan ...
- 004_软件安装之_Altium Designer
文件中有软件简单视频教程,安装有pdf教程 链接:https://pan.baidu.com/s/1ow-OHdsPuAyXCevjCVqEsg 提取码:l2rt 复制这段内容后打开百度网盘手机App ...
- linux 中将用户添加到组的 4 个方法
Linux 组是用于管理 Linux 中用户帐户的组织单位.对于 Linux 系统中的每一个用户和组,它都有惟一的数字标识号.它被称为 用户 ID(UID)和组 ID(GID).组的主要目的是为组的成 ...
- 二十一.构建memcached服务、LNMP+memcached、PHP的本地Session信息、PHP实现session共享
proxy client web1 web2 1.构建memcached服务 ]# yum -y install memcached ]# cat /etc/sysconfig/memcached ...
- STL备忘
STL备忘 lower_bound 查找第一个大于或等于的数,返回该数字的地址,地址减去首地址即得到数组下标(首地址下标为0) upper_bound 查找第一个大于的数 unique 去重,常用于离 ...
- 【概率论】4-7:条件期望(Conditional Expectation)
title: [概率论]4-7:条件期望(Conditional Expectation) categories: - Mathematic - Probability keywords: - Exp ...
- Spring Cloud Gateway(六):路由谓词工厂 RoutePredicateFactory
本文基于 spring cloud gateway 2.0.1 1.简介 Spring Cloud Gateway 创建 Route 对象时, 使用 RoutePredicateFactory 创建 ...
- python学习:模块(第一节)
1.什么是模块? 如果你从 Python 解释器退出再进入,那么你定义的所有的方法和变量就都消失了.为此 Python 提供了一个办法,把这些定义存放在文件中,为一些脚本或者交互式的解释器实例使用,这 ...