Ever17's Studio.

【Unity-URP】快速入门屏幕空间平面反射(SSPR)————(1)

字数统计: 2k阅读时长: 8 min
2021/06/06 Share

前言

紧接上一篇文章,简单在URP实现以下屏幕空间平面反射,同样会和上篇一样着重于原理以及基础实现,不会涉及更加深层次的东西比如多平台优化(如果想学习更加深入的,可以移步Colin的ColinLeung-NiloCat/UnityURP-MobileScreenSpacePlanarReflection: Reusable RendererFeature of MobileScreenSpacePlanarReflection (github.com) 或者Unity URP 移动平台的屏幕空间平面反射(SSPR)趟坑记 - 知乎 (zhihu.com)),当然,后续如果有空了可能会把这部分学完了作为(2)发布。当然为SSPR会涉及到computeshader,是一个不错的入门computeshader的切入点。

关于ScreenSpacePlanarReflection(SSPR)

没怎么仔细考究到底是哪家先提出的概念,但是最有名的估计还属Ubisoft在ghost-recon-wildlands中使用的SSPR。Ubisoft的图形程序remi-genin在自己的博客也发布了他们的做法(Screen Space Planar Reflections in Ghost Recon Wildlands – Rémi Génin (remi-genin.fr))。相比SSR,SSPR免去了Rymarching的高消耗,性能大幅提升,甚至可以适用于移动端,相比平面反射也不存在drawcall翻倍的情况,当然没有免费的午餐,和SSR一样无法映射屏幕外的物体,而且会由于透视的问题导致gaps的出现需要自己处理拉伸

SSPIR_NoDistort

[^]: 图源:Screen Space Planar Reflections in Ghost Recon Wildlands – Rémi Génin (remi-genin.fr)

同时也会存在许多的问题需要补救。

ScreenSpacePlanarReflection原理

SSPIR_WriteUAV-1

类似SSR,我们需要首先通过深度图重建世界坐标,然后通过预定的高度进行坐标翻转,再用翻转后的屏幕坐标会对应翻转前的屏幕颜色。相比SSR会比较好理解。

关于ComputeShader

由于实现SSR会需要用到ComputeShader,也许也有许多和笔者一样没怎么接触过的,这里可以稍微对computeshader做一些前置理解。

首先是为什么SSPR需要computeshader

因为sspr需要把一个坐标的颜色记录到另一个坐标,传统的shader在fragment阶段都是对对应的fragment处理,而computeshader可以做到这种操作(乱序写入)

computeshader的优点

CPU和GPU最大的区别在于GPU具有高并行结构(highly parallel stucture),GPU 拥有更多的 ALU (Arithmetic Logic Unit,逻辑运算单元)用于数据处理,这样的结构适合对密集型数据进行并行处理。

computeshader能充分利用GPU的数据处理能力,把大量重复的计算放在GPU端处理(瓶颈在于需要CPU传输数据到GPU)。

computeshader的名词解释

(以下摘自 URP管线的自学HLSL之路 第二十九篇 ComputeShader释义 - 哔哩哔哩专栏 (bilibili.com),可以去原文进一步了解,这里只放我认为比较重要的)

线程(Thread):最基础工元。

线程组(ThreadGroup):多个线程的组合,一个线程组的线程是按XYZ3个维度进行排布。如[numthreads(4,8,12)]是定义x方向4个线程,y方向8个线程,z方向12个线程的一个线程组。1个线程组的大小(ThreadGroupSize)最大为1024,也就是xyz<=1024。

对于AMD的GPU平台,线程组的大小建议定义成64的倍数(WaveFront构架)。

对于Nvidia的GPU平台,线程组的大小建议定义成32的倍数(SIMD32(Warp)构架)。

调度(Dispatch):多个线程组的组合,一个Dispatch的线程组是按XYZ3个维度进行排布。如Dispatch(KernelID,3,9,12)是定义x方向3个线程组,y方向9个线程组,z方向12个线程组,调用ComputeShader里KernelID的kernel函数的一个调度。

这里对以上做一个总结,线程是员工,线程组是一个团队,而调度是公司能调动的所有团队。因此在SSPR中调度的线程数线程组内线程的数目需要满足申请的RT的大小,同时考虑到平台,这里用64作为线程组的大小,也就是[numthreads(8,8,1)] (Texture是二维的,没有z,所以z用1)

SSPR 开冲

Volume编写(其实直接放在RenderFeature里也行,写在后处理面板方便调试)

同上一篇博客,写法框架很死了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System;
namespace UnityEngine.Rendering.Universal
{
[Serializable,VolumeComponentMenu("Reflection/ScreenSpacePlanarReflection")]
public class ScreenSpacePlanarReflection : VolumeComponent,IPostProcessComponent
{



public BoolParameter isActive = new BoolParameter(false, false);
public FloatParameter RT_Size =new FloatParameter( 512,false);
public FloatParameter Height = new FloatParameter(0, false);
public bool IsActive()
{
return isActive.value;
}

public bool IsTileCompatible()
{
return false;
}


}
}

RenderFeature编写

二话不多说,直接粘贴上一篇SSR博客的renderfeatur(经典废物利用环节),只不过这次不需要material去Blit了,RenderFeature只负责调度ComputeShader,首先自然是需要处理RT申请,这里有一个注意事项

1
2
3
descriptor = new RenderTextureDescriptor(Mathf.CeilToInt(sspr.RT_Size.value* aspect), Mathf.CeilToInt(sspr.RT_Size.value), RenderTextureFormat.ARGB32);
descriptor.enableRandomWrite = true;
cmd.GetTemporaryRT(sspr_handle.id, descriptor, FilterMode.Bilinear);

就是Mathf.CeilToInt,因为Float转到Int会自动截去小数部分,这会导致RT最后的图有黑边(比如512.4会变成512,这个512-512.4的部分就莫得了)

在Execute()方法内传递ComputeShader需要的属性

1
2
3
4
5
6
7
8
9
10
                 //sspr
cmd.SetComputeFloatParam(cs,Shader.PropertyToID("_PlaneHeight"), sspr.Height.value);
cmd.SetComputeVectorParam(cs, Shader.PropertyToID("_RT_Size"),new Vector2( sspr.RT_Size.value*aspect, sspr.RT_Size.value));
cmd.SetComputeTextureParam(cs, 0,Shader.PropertyToID("_SSPRTexture"),sspr_handle.Identifier());
cmd.SetComputeTextureParam(cs, 0, Shader.PropertyToID("_CameraColorTexture"), new RenderTargetIdentifier("_CameraColorTexture"));
cmd.SetComputeTextureParam(cs, 0, Shader.PropertyToID("_CameraDepthTexture"), new RenderTargetIdentifier("_CameraDepthTexture"));
cmd.SetComputeTextureParam(cs, 0, heightID, heightIdentifier);

cmd.DispatchCompute(cs, 0, Mathf.CeilToInt(sspr.RT_Size.value* aspect / 8), Mathf.CeilToInt(sspr.RT_Size.value / 8 ), 1);
cmd.SetGlobalTexture(sspr_handle.id, sspr_handle.Identifier());

重头戏ComputeShader编写

上次写深度重建用的在知乎偷来的方法,前几天VScode瞎搜发现unity在common里有另一种做法,并且封装好了,就是ComputeWorldSpacePosition,在Common.hlsl中可以看到。这里我们用的colin大佬的做法去解决排序问题,通过写入世界坐标的Y值来做比较解决这个问题

QQ图片20210606144901

[^]: 深度排序问题的原因是经过投影可能不同高度对应同一像素,我们使用最小的Y,因为在 “看的到” 的物体里,不存在Y小还被挡住的情况

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"


float2 _RT_Size;
float _PlaneHeight;

// Create a RenderTexture with enableRandomWrite flag and set it
// with cs.SetTexture
RWTexture2D<float4> _SSPRTexture;
RWTexture2D<float> _ReflectHeightBuffer;
Texture2D<float4> _CameraColorTexture;
Texture2D<float4> _CameraDepthTexture;
SAMPLER(sampler_CameraDepthTexture);
SAMPLER(sampler_CameraColorTexture);
// Each #kernel tells which function to compile; you can have many kernels
#pragma kernel CSMain
[numthreads(8,8,1)]
void CSMain (uint3 id : SV_DispatchThreadID)
{
_SSPRTexture[id.xy]=float4(0,0,0,0);
_ReflectHeightBuffer[id.xy]=HALF_MAX;
float2 screenUV=id.xy/_RT_Size;
float depth=_CameraDepthTexture.SampleLevel(sampler_CameraDepthTexture,screenUV,0).r;
//重建世界坐标(NDC空间方法)
//float4 viewRayPS=float4(screenUV*2-1,1,1)*_ProjectionParams.z;
//float4 rayVS=mul(unity_CameraInvProjection,viewRayPS);
//half L01Depth=Linear01Depth(depth,_ZBufferParams);
//float3 PosVS=rayVS.xyz*L01Depth;
//float3 PosWS=mul(UNITY_MATRIX_I_VP,TransformWViewToHClip(PosVS));

//重建世界坐标(另一种Untiy Common.hlsl自带的)

float3 PosWS=ComputeWorldSpacePosition( screenUV,depth, UNITY_MATRIX_I_VP);


//simple planar reflect
float3 reflectPosWS=PosWS;
////////////(y1+y2)/2=h/////////////
reflectPosWS.y=-reflectPosWS.y+2*_PlaneHeight;
float4 reflectPosPS=TransformWorldToHClip(reflectPosWS);
float2 reflectUV=(reflectPosPS.xy/reflectPosPS.w)*0.5+0.5;
#ifdef UNITY_UV_STARTS_AT_TOP
reflectUV.y = 1 - reflectUV.y;
#endif


float2 reflectPixelIndex=reflectUV*_RT_Size;
if(PosWS.y<_PlaneHeight||reflectUV.x>1||reflectUV.x>1||reflectUV.x<0||reflectUV.y<0) return;
if(PosWS.y<_ReflectHeightBuffer[reflectPixelIndex])
{
_ReflectHeightBuffer[reflectPixelIndex]=PosWS.y;
_SSPRTexture[reflectPixelIndex]=_CameraColorTexture.SampleLevel(sampler_CameraColorTexture,screenUV,0);


}

}

QQ图片20210606145432

SSPR额外

经过上述内容,基本的SSPR算法已经阐述过一边,简单在URP上做了一个幼儿园入门版的,有许多地方需要解决:比如由于CS乱序写入的闪面,相近像素对应到同像素导致的黑洞问题(移步colin大神的github,CS里加一个补洞的pass,或者简单粗暴点按照像素灰度确定是否是洞做补洞)等等。

另外如果对SSPR感兴趣可以去看Screen Space Planar Reflections in Ghost Recon Wildlands – Rémi Génin (remi-genin.fr)原文,其中也提到了Ubisoft对于不同水平面的处理,以及拉伸的处理。

2017年的SIGGRAPH上也有一篇平面反射的优化论文,Prezentacja programu PowerPoint (realtimerendering.com)

如果学有余力可以去深挖,笔者写本文更多在于常规做法学习以及computeshader练手,并未涉及进阶部分以及优化(问就是不会)。

以上,本篇完。去通GhostRunner了。

原文作者:luqc

原文链接:https://ever17-luqc.github.io/SSPR/

发表日期:June 6th 2021, 11:34:12 am

更新日期:May 22nd 2022, 9:31:36 pm

版权声明:本文采用知识共享署名-非商业性使用 4.0 国际许可协议进行许可

CATALOG
  1. 1. 前言
  2. 2. 关于ScreenSpacePlanarReflection(SSPR)
  3. 3. ScreenSpacePlanarReflection原理
  4. 4. 关于ComputeShader
    1. 4.1. 首先是为什么SSPR需要computeshader
    2. 4.2. computeshader的优点
    3. 4.3. computeshader的名词解释
  5. 5. SSPR 开冲
    1. 5.1. Volume编写(其实直接放在RenderFeature里也行,写在后处理面板方便调试)
    2. 5.2. RenderFeature编写
    3. 5.3. 重头戏ComputeShader编写
  6. 6. SSPR额外