草庐IT

用Unity实现景深效果

异次元的归来 2023-11-05 原文

用Unity实现景深效果

景深也是一种非常常见的后处理手段,它用来模拟相机拍摄画面的效果。今天我们讨论如何在Unity中实现它。

简单来说,景深效果可以拆分为两个部分,一个部分是聚焦,使画面中指定的区域清晰显示,另一个部分是失焦,使画面中其他区域,即没有对焦的地方,变得模糊。

我们先从相机说起。产生模糊的主要原因是模拟的相机并不是理想的针孔相机。对于针孔相机,相机前的物体只有一条光线会被记录下来,因而图像总是清晰的,但是相应的,一条光线不够明亮,需要足够长的曝光时间来提高图像的亮度。而这个时间段内如果移动场景中的物体,就会导致比较严重的运动模糊。

而模拟的相机是通过增大光圈大小来减少曝光时间的,也就是说相机前的物体会有多条光线被记录下来。这样造成的效果就是,原本物体上的一个点,对应到成像平面是一个圆片区域。区域的大小和物体到光圈的距离,光圈到成像平面的距离有关。自然而然,最后的图像是模糊的了。

为了让光线重新聚焦,这里引入一个透镜的概念。它可以使穿过光圈的光重新汇聚到一个点上,但是只有离相机特定距离的物体才可以被重新聚焦起来。其他距离的物体,要么提前汇聚后又分散了,要么还没来得及汇聚就到达了成像平面上了。最终的结果就是我们想要的,一部分物体清晰,其他的物体模糊。

对于模糊的物体,它投影到成像平面的点变成了圆片状的区域。这种失焦的效果被称之为模糊圈(circle of confusion),如图所示:

我们首先来对这个CoC进行建模。显然,CoC的大小与物体到相机的距离有关,也就是深度d有关。我们还需要设定一个当前聚焦的距离f,和聚焦的范围r,然后有:
C o C = d − f r CoC = \dfrac{d - f}{r} CoC=rdf

我们需要把CoC存到一个buffer中,由于CoC是一个值,那么render texture的格式指定为浮点型即可:

		RenderTexture coc = RenderTexture.GetTemporary(
			source.width, source.height, 0,
			RenderTextureFormat.RHalf, RenderTextureReadWrite.Linear
		);

		Graphics.Blit(source, coc, dofMaterial, circleOfConfusionPass);

CoC的值可正可负,不过既然render texture中存储的是浮点数了,就不需要对负值进行处理:

				half FragmentProgram (Interpolators i) : SV_Target {
					float depth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv);
					depth = LinearEyeDepth(depth);

					float coc = (depth - _FocusDistance) / _FocusRange;
					coc = clamp(coc, -1, 1);
					return coc;
				}

由于只有一个通道,这时候buffer看起来是偏红的,越不在CoC范围内的点,颜色越红;完全处于CoC范围内的点,则颜色为黑色;还有一类的点,它们也不在CoC范围内,但是由于此时CoC的值为负数,体现在颜色上,也是黑色:

接下来,我们来考虑如何把这种模糊虚化的效果做出来。首先它应该是一种模糊,就是要采样周围若干像素作为当前像素的结果;其次它的模糊要有圆的轮廓,也就是说它的采样区域应该是一个半径为R的圆,圆形区域内的点采样的像素是相似的,进而模糊后的结果也是相似的,反映到图像上就是形成了一块圆形的模糊区域:

				half4 FragmentProgram (Interpolators i) : SV_Target {
					half3 color = 0;
					float weight = 0;
					for (int u = -radius; u <= radius; u++) {
						for (int v = -radius; v <= radius; v++) {
							float2 o = float2(u, v);
							if (length(o) <= radius) {
								o *= _MainTex_TexelSize.xy * sparse;
								color += tex2D(_MainTex, i.uv + o).rgb;
								weight += 1;
							}
						}
					}
					color *= 1.0 / weight;
					return half4(color, 1);
				}

这里除了模糊的采样半径以外,还增加了一个稀疏系数。这个参数是用来控制模糊的稀疏程度的。简单来说,就是这个值越大,采样的点越稀疏,也就是圆形区域内只有部分的点模糊之后的结果是相似的,反映到图像上就是一个不那么连续光滑,而是比较锐利的圆形区域了。

这样说太抽象了,让我们来看一下实际的效果,首先是固定sparse参数为1,调节radius参数从0变换到10:

再看一下固定radius参数为10,调节sparse参数从1变换到10的效果:

我们还可以调整圆形所覆盖的区域,例如它可以是个圆盘区域,我们只对圆盘区域上的点进行采样:

static const int kernelSampleCount = 16;
static const float2 kernel[kernelSampleCount] = {
    float2(0, 0),
    float2(0.54545456, 0),
    float2(0.16855472, 0.5187581),
    float2(-0.44128203, 0.3206101),
    float2(-0.44128197, -0.3206102),
    float2(0.1685548, -0.5187581),
    float2(1, 0),
    float2(0.809017, 0.58778524),
    float2(0.30901697, 0.95105654),
    float2(-0.30901703, 0.9510565),
    float2(-0.80901706, 0.5877852),
    float2(-1, 0),
    float2(-0.80901694, -0.58778536),
    float2(-0.30901664, -0.9510566),
    float2(0.30901712, -0.9510565),
    float2(0.80901694, -0.5877853),
};
    
half4 FragmentProgram (Interpolators i) : SV_Target {
    half3 color = 0;
    for (int k = 0; k < kernelSampleCount; k++) {
        float2 o = kernel[k];
        o *= _MainTex_TexelSize.xy * radius;
        color += tex2D(_MainTex, i.uv + o).rgb;
    }
    color *= 1.0 / kernelSampleCount;
    return half4(color, 1);
}

这里用到的一堆魔数,可视化如下:

显而易见采样点连成的形状是一个圆盘。使用这套参数,我们还可以将采样半径和稀疏系数统一为一个半径参数,这是因为半径越大,圆盘区域越大,采样点之间的距离也越大,进而采样越稀疏。

来看一下不同半径下的效果:

使用圆盘区域的效果看上去更加梦幻了。不过,在采样半径变大的同时,我们能够很明显地发现采样点之间的间隙,为了消除它,我们准备在这后面再套一层模糊。这次是真的模糊,使用3x3的tent filter:

				half4 FragmentProgram (Interpolators i) : SV_Target {
					float4 o =
						_MainTex_TexelSize.xyxy * float2(-0.5, 0.5).xxyy;
					half4 s =
						tex2D(_MainTex, i.uv + o.xy) +
						tex2D(_MainTex, i.uv + o.zy) +
						tex2D(_MainTex, i.uv + o.xw) +
						tex2D(_MainTex, i.uv + o.zw);
					return s * 0.25;
				}

来看下模糊后的效果,这时候的模糊圈虚虚实实,叠加在了一起:

下一步,我们来考虑如何实现聚焦的效果。首先,不是所有的地方都需要虚化的,聚焦的地方应该是清晰的,这里的模糊圈应该要去掉。我们可以使用之前建模的CoC来辅助判断。CoC越小,说明当前点离聚焦范围越近,它就不可能很模糊,也就是距离它很远的采样点(例如圆盘中外围的点)在模糊过程中要被过滤掉:

				half4 FragmentProgram (Interpolators i) : SV_Target {
					half3 color = 0;
					half weight = 0;
					for (int k = 0; k < kernelSampleCount; k++) {
						float2 o = kernel[k] * _BokehRadius;
						half radius = length(o);
						o *= _MainTex_TexelSize.xy;
						half coc = tex2D(_CoCTex, i.uv + o).r * radius;
						if(abs(coc) >= radius)
						{
							color += tex2D(_MainTex, i.uv + o).rgb;
							weight += 1;
						}
					}
					color *= 1.0 / weight;
					return half4(color, 1);
				}

顺便看看不同聚焦范围下的效果:

过滤的过程可以做得更加平滑,我们引入权重的概念,越处于过滤边缘的采样点所占权重越低:

				half Weigh (half coc, half radius) {
					return saturate((abs(coc) - radius + 2) / 2);
				}

				half4 FragmentProgram (Interpolators i) : SV_Target {
					half3 color = 0;
					half weight = 0;
					for (int k = 0; k < kernelSampleCount; k++) {
						float2 o = kernel[k] * _BokehRadius;
						half radius = length(o);
						o *= _MainTex_TexelSize.xy;
						half coc = tex2D(_CoCTex, i.uv + o).r * _BokehRadius;
						half3 rgb = tex2D(_MainTex, i.uv + o).rgb;
						half sw = Weigh(coc, radius);
						color += rgb * sw;
						weight += sw;
					}
					color *= 1.0 / weight;
					return half4(color, 1);
				}

来对比一下:

由于我们之前在生成模糊圈时,对整个render texture是做了模糊处理的,而聚焦的地方是不需要模糊的,需要借助最原始的render texture,做一个blend,当CoC的值越大,模糊render texture的权重越高;反之,原始render texture的权重越高:

				half4 FragmentProgram (Interpolators i) : SV_Target {
					half4 source = tex2D(_MainTex, i.uv);
					half coc = tex2D(_CoCTex, i.uv).r;
					half4 dof = tex2D(_DoFTex, i.uv);

					half dofStrength = smoothstep(0.1, 1, abs(coc));
					half3 color = lerp(source.rgb, dof.rgb, dofStrength);
					return half4(color, source.a );
				}

看上去效果不错,不过出现了一个问题,右下角的cube,有一个角变清晰了,显得很不自然。原因肯定是出在这个blend上了,我们在把coc代入计算时使用了绝对值,这就可能出现,本来聚焦的地方在后面,但前面有部分像素也算在了聚焦范围内了。因此,我们有必要把前景颜色和背景颜色分开计算,前景的coc是负值,而背景的coc是正值。

我们首先对模糊圈进行处理,分别计算前景和背景之后,以一个权重的方式进行融合:

				half4 FragmentProgram (Interpolators i) : SV_Target {
					half3 bgColor = 0, fgColor = 0;
					half bgWeight = 0, fgWeight = 0;
					for (int k = 0; k < kernelSampleCount; k++) {
						float2 o = kernel[k] * _BokehRadius;
						half radius = length(o);
						o *= _MainTex_TexelSize.xy;
						half coc = tex2D(_CoCTex, i.uv + o).r * _BokehRadius;
						half3 rgb = tex2D(_MainTex, i.uv + o).rgb;

						half bgw = Weigh(max(0, coc), radius);
						bgColor += rgb * bgw;
						bgWeight += bgw;

						half fgw = Weigh(-coc, radius);
						fgColor += rgb * fgw;
						fgWeight += fgw;
					}
					bgColor *= 1.0 / (bgWeight + (bgWeight == 0));
					fgColor *= 1.0 / (fgWeight + (fgWeight == 0));
					half bgfg = min(1, fgWeight * _ForegroundScale);
					half3 color = lerp(bgColor, fgColor, bgfg);
					return half4(color, bgfg);
				}

这里_ForegroundScale参数是用来灵活调节前景模糊程度的,考虑极端情况,如果参数的值为0,说明前景不参与模糊圈的计算,即前景也是清晰的;反正如果值为1,则前景和背景一样模糊。来看一下这个参数从0到1的变化效果:

看起来,和source render texture的融合效果还是不太对。这也难怪,毕竟我们现在还是只用的abs(coc)作为融合的权重。现在,还要把前景和背景的融合参数也考虑进来:

				half4 FragmentProgram (Interpolators i) : SV_Target {
					half4 source = tex2D(_MainTex, i.uv);
					half coc = tex2D(_CoCTex, i.uv).r;
					half4 dof = tex2D(_DoFTex, i.uv);

					half bgfg = dof.a;
					half dofStrength = smoothstep(0.1, 1, abs(coc));
					half3 color = lerp(source.rgb, dof.rgb, 
					dofStrength + bgfg - dofStrength * bgfg);
					return half4(color, source.a );
				}

这里最终的融合参数为dofStrength + bgfg - dofStrength * bgfg,推导起来其实很简单,就是source和dof融合两次,一次使用dofStrength ,另一次使用bgfg作为参数:
c = a + ( b − a ) ⋅ x d = c + ( b − c ) ⋅ y d = a + b x − a x + ( b − a − b x + a x ) y d = a + ( b − a ) ( x + y − x y ) c = a + (b-a) \cdot x \\ d = c + (b-c) \cdot y \\ d = a + bx - ax + (b - a - bx + ax)y \\ d = a + (b - a)(x + y - xy) c=a+(ba)xd=c+(bc)yd=a+bxax+(babx+ax)yd=a+(ba)(x+yxy)
来看一下最终的效果,依旧是调节前景的融合系数_ForegroundScale从0到1:

看上去还不错。自此我们可以自由地调节前景的模糊程度,聚焦的距离与范围,还有模糊的半径了。最后是一张静态效果:

如果你觉得我的文章有帮助,欢迎关注我的微信公众号:我是真的想做游戏啊

Reference

[1] Depth of Field

有关用Unity实现景深效果的更多相关文章

  1. ruby - 如何根据特征实现 FactoryGirl 的条件行为 - 2

    我有一个用户工厂。我希望默认情况下确认用户。但是鉴于unconfirmed特征,我不希望它们被确认。虽然我有一个基于实现细节而不是抽象的工作实现,但我想知道如何正确地做到这一点。factory:userdoafter(:create)do|user,evaluator|#unwantedimplementationdetailshereunlessFactoryGirl.factories[:user].defined_traits.map(&:name).include?(:unconfirmed)user.confirm!endendtrait:unconfirmeddoenden

  2. 华为OD机试用Python实现 -【明明的随机数】 2023Q1A - 2

    华为OD机试题本篇题目:明明的随机数题目输入描述输出描述:示例1输入输出说明代码编写思路最近更新的博客华为od2023|什么是华为od,od薪资待遇,od机试题清单华为OD机试真题大全,用Python解华为机试题|机试宝典【华为OD机试】全流程解析+经验分享,题型分享,防作弊指南华为o

  3. Unity 热更新技术 | (三) Lua语言基本介绍及下载安装 - 2

    ?博客主页:https://xiaoy.blog.csdn.net?本文由呆呆敲代码的小Y原创,首发于CSDN??学习专栏推荐:Unity系统学习专栏?游戏制作专栏推荐:游戏制作?Unity实战100例专栏推荐:Unity实战100例教程?欢迎点赞?收藏⭐留言?如有错误敬请指正!?未来很长,值得我们全力奔赴更美好的生活✨------------------❤️分割线❤️-------------------------

  4. FOHEART H1数据手套驱动Optitrack光学动捕双手运动(Unity3D) - 2

    本教程将在Unity3D中混合Optitrack与数据手套的数据流,在人体运动的基础上,添加双手手指部分的运动。双手手背的角度仍由Optitrack提供,数据手套提供双手手指的角度。 01  客户端软件分别安装MotiveBody与MotionVenus并校准人体与数据手套。MotiveBodyMotionVenus数据手套使用、校准流程参照:https://gitee.com/foheart_1/foheart-h1-data-summary.git02  数据转发打开MotiveBody软件的Streaming,开始向Unity3D广播数据;MotionVenus中设置->选项选择Unit

  5. unity---接入Admob - 2

    目录1.AdmobSDK下载地址2.将下载好的unityPackagesdk导入到unity里​编辑 3.解析依赖到项目中

  6. Unity 3D 制作开关门动画,旋转门制作,推拉门制作,门把手动画制作 - 2

    Unity自动旋转动画1.开门需要门把手先动,门再动2.关门需要门先动,门把手再动3.中途播放过程中不可以再次进行操作觉得太复杂?查看我的文章开关门简易进阶版效果:如果这个门可以直接打开的话,就不需要放置"门把手"如果门把手还有钥匙需要旋转,那就可以把钥匙放在门把手的"门把手",理论上是可以无限套娃的可调整参数有:角度,反向,轴向,速度运行时点击Test进行测试自己写的代码比较垃圾,命名与结构比较拉,高手轻点喷,新手有类似的需求可以拿去做参考上代码usingSystem.Collections;usingSystem.Collections.Generic;usingUnityEngine;u

  7. 基于C#实现简易绘图工具【100010177】 - 2

    C#实现简易绘图工具一.引言实验目的:通过制作窗体应用程序(C#画图软件),熟悉基本的窗体设计过程以及控件设计,事件处理等,熟悉使用C#的winform窗体进行绘图的基本步骤,对于面向对象编程有更加深刻的体会.Tutorial任务设计一个具有基本功能的画图软件**·包括简单的新建文件,保存,重新绘图等功能**·实现一些基本图形的绘制,包括铅笔和基本形状等,学习橡皮工具的创建**·设计一个合理舒适的UI界面**注明:你可能需要先了解一些关于winform窗体应用程序绘图的基本知识,以及关于GDI+类和结构的知识二.实验环境Windows系统下的visualstudio2017C#窗体应用程序三.

  8. MIMO-OFDM无线通信技术及MATLAB实现(1)无线信道:传播和衰落 - 2

     MIMO技术的优缺点优点通过下面三个增益来总体概括:阵列增益。阵列增益是指由于接收机通过对接收信号的相干合并而活得的平均SNR的提高。在发射机不知道信道信息的情况下,MIMO系统可以获得的阵列增益与接收天线数成正比复用增益。在采用空间复用方案的MIMO系统中,可以获得复用增益,即信道容量成倍增加。信道容量的增加与min(Nt,Nr)成正比分集增益。在采用空间分集方案的MIMO系统中,可以获得分集增益,即可靠性性能的改善。分集增益用独立衰落支路数来描述,即分集指数。在使用了空时编码的MIMO系统中,由于接收天线或发射天线之间的间距较远,可认为它们各自的大尺度衰落是相互独立的,因此分布式MIMO

  9. 【Java入门】使用Java实现文件夹的遍历 - 2

    遍历文件夹我们通常是使用递归进行操作,这种方式比较简单,也比较容易理解。本文为大家介绍另一种不使用递归的方式,由于没有使用递归,只用到了循环和集合,所以效率更高一些!一、使用递归遍历文件夹整体思路1、使用File封装初始目录,2、打印这个目录3、获取这个目录下所有的子文件和子目录的数组。4、遍历这个数组,取出每个File对象4-1、如果File是否是一个文件,打印4-2、否则就是一个目录,递归调用代码实现publicclassSearchFile{publicstaticvoidmain(String[]args){//初始目录Filedir=newFile("d:/Dev");Datebeg

  10. ruby - Arrays Sets 和 SortedSets 在 Ruby 中是如何实现的 - 2

    通常,数组被实现为内存块,集合被实现为HashMap,有序集合被实现为跳跃列表。在Ruby中也是如此吗?我正在尝试从性能和内存占用方面评估Ruby中不同容器的使用情况 最佳答案 数组是Ruby核心库的一部分。每个Ruby实现都有自己的数组实现。Ruby语言规范只规定了Ruby数组的行为,并没有规定任何特定的实现策略。它甚至没有指定任何会强制或至少建议特定实现策略的性能约束。然而,大多数Rubyist对数组的性能特征有一些期望,这会迫使不符合它们的实现变得默默无闻,因为实际上没有人会使用它:插入、前置或追加以及删除元素的最坏情况步骤复

随机推荐