一,原理
光线追踪原理(从RR4th_RRT上借个图修一下用):
从无限远处的天空发射的一束光线经过trace1与圆碰撞并获取到hit1的各种属性,通过shader1进行着色计算并产生新的反射光线(能量衰减),光线通过trace2与地面碰撞并获取到hit2的各种属性,同理进行着色并产生新的反射光线,通过trace3射入相机。所以整个过程就是:
Ray? * dot(n, l) →?????1????1?→?????2????2?→ Ray? * dot(v, l)
展开一下里面的值应该是:
hit后射出的光除了利用入射光能量计算的物体本身着色外,还会叠加直射光产生的能量
上图中的另外两条光线traceS1和traceS2是阴影光线,通过碰撞点向光照逆方向发射光线,并检测碰撞,如果有相交,则表示该碰撞点处于阴影中。
然而,能通过该种碰撞进入视野的光线有无数多条,基本无法通过追踪全部入射光线的方法来求解进入视线的光线。因此,一般都是通过反向追踪的方式,来求解着色。即RR4th_RRT中的原图所展示的步骤:
以相机世界坐标为原点,向相机指向屏幕空间像素点的世界坐标的方向发射射线,追踪该光线的传播直到产生碰撞,之后计算反射光线的能量并产生新的光线传播,重复此过程,直到:
1、光线超过最大追踪次数
2、光线能量衰减至0(或某个阈值)
此时获取到的颜色信息就是该屏幕像素上的间接光照颜色信息(应该还需要叠加最近碰撞表面的直接光照信息)。当然为了产生更精确的效果,可能会朝一个像素点发射多条光线,如下图:
逆向光追踪也成立是因为(排除各碰撞的直接光照):开始从环境入射光线进行多次光线衰减得到的结果传递入眼中与假设从眼中射出能量默认为1的光线最终经过多次碰撞光线衰减最后乘上环境反射颜色获得的值其实是一致的(可以参看“Real-Time Rendering 4th_C9 基于物理的着色(上)”中的:亥姆霍兹互易性)。
从光线追踪的角度阐述几个概念:
光线Ray:用发射远点ray.origin,发射方向ray.direction来定义的一条射线。因为每次光线与材质表面发生碰撞时,都会发生折射,吸收,散射,反射等情况,即每次碰撞都会发生光线能量的衰减,因此增加ray.energy来描述这种衰减。
光线与物体的交点RayHit:光线每次与材质表面发生碰撞时,都会产生一条或多条新的光线,因此需要获取hit.position和hit.normal来产生新的光线。获取交点与光线原点的距离hit.distance来判断交点的有效性。为了计算颜色信息,还需要知道交点位置的着色属性(与当前的着色算法相关)。
之后开始进行光追效果的实现。
二,实现
开启Unity2019.3.14f1,新建工程。
创建脚本:RayTracingMaster.cs
创建ComputeShader:RayTracingShader.compute
在网站HDRI Haven下载一张你喜欢的天空球:https://hdrihaven.com/hdri/?c=outdoor&h=cape_hill
中间会用到ComputeShader(后简称CS),我也现学现卖,简单介绍一下。
然后开启脚本:RayTracingMaster.cs 编写最简单的光追结构
//思路,创建一张RT,将需要绘制的内容先绘制到RT上,最后传入帧缓存显示
using UnityEngine;
public class RayTracingMaster : MonoBehaviour
{
//定义使用的CS
public ComputeShader RayTracingShader;
//绘制的RT
private RenderTexture _target;
//用来获取相机矩阵等信息
private Camera _camera;
//用来获取天空纹理
public Texture SkyboxTexture;
private void Awake()
{
_camera = GetComponent<Camera>();
}
private void SetShaderParameters()
{
//向CS中传递V矩阵,进行相机空间和世界空间转换操作
RayTracingShader.SetMatrix("_CameraToWorld", _camera.cameraToWorldMatrix);
//向CS中传递P转置矩阵,进行相机空间和剪裁空间转换操作
RayTracingShader.SetMatrix("_CameraInverseProjection", _camera.projectionMatrix.inverse);
//向CS中传递天空纹理
RayTracingShader.SetTexture(0, "_SkyboxTexture", SkyboxTexture);
}
//完成所有渲染之后调用,通常用于后处理
private void OnRenderImage(RenderTexture source, RenderTexture destination)
{
Render(destination);
}
private void Render(RenderTexture destination)
{
// 创建RT
InitRenderTexture();
// 设置渲染目标并执行ComputeShader
RayTracingShader.SetTexture(0, "Result", _target);
//根据图素和CS中定义的线程数(8,8,1),计算x,y维度上的线程工作组数量
int threadGroupsX = Mathf.CeilToInt(Screen.width / 8.0f);
int threadGroupsY = Mathf.CeilToInt(Screen.height / 8.0f);
//在0号内核上执行,x维度工作组数,y维度工作组数,z维度工作组数
RayTracingShader.Dispatch(0, threadGroupsX, threadGroupsY, 1);
// 将RT绘制入帧缓存显示
Graphics.Blit(_target, destination);
}
private void InitRenderTexture()
{
if (_target == null || _target.width != Screen.width || _target.height != Screen.height)
{
// 保证只存在一张RT
if (_target != null)
_target.Release();
// 创建RT
_target = new RenderTexture(Screen.width, Screen.height, 0,
RenderTextureFormat.ARGBFloat, RenderTextureReadWrite.Linear);
_target.enableRandomWrite = true;
_target.Create();
}
}
}
将创建的CS和下载的天空HDR图拖入脚本对应位置。
需要的CS:
//定义Main函数
#pragma kernel CSMain
//定义一张2D纹理,RW标识可读写
RWTexture2D<float4> Result;
float4x4 _CameraToWorld;
float4x4 _CameraInverseProjection;
Texture2D<float4> _SkyboxTexture;
SamplerState sampler_SkyboxTexture;
static const float PI = 3.14159265f;
//定义光线射线结构体,用原点+方向表示该射线
struct Ray
{
float3 origin;
float3 direction;
};
//创建射线函数
Ray CreateRay(float3 origin, float3 direction)
{
Ray ray;
ray.origin = origin;
ray.direction = direction;
return ray;
}
Ray CreateCameraRay(float2 uv)
{
// 将相机位置(相机空间的[0,0,0])转到世界空间,作为射线原点
float3 origin = mul(_CameraToWorld, float4(0.0f, 0.0f, 0.0f, 1.0f)).xyz;
// 将屏幕上的像素从剪裁空间转到相机空间
float3 direction = mul(_CameraInverseProjection, float4(uv, 0.0f, 1.0f)).xyz;
// 将前面得到的位置再转到世界空间
direction = mul(_CameraToWorld, float4(direction, 0.0f)).xyz;
//单位化并将其作为射线方向
direction = normalize(direction);
return CreateRay(origin, direction);
}
//申请的线程数
[numthreads(8,8,1)]
void CSMain (uint3 id : SV_DispatchThreadID)
{
// 获取传入的RT的宽高尺寸
uint width, height;
// 获取资源尺寸
Result.GetDimensions(width, height);
// 将RT的像素位置id.xy转为屏幕像素空间的uv [-1~1],偏移0.5以便将像素中心作位uv起始
float2 uv = float2((id.xy + float2(0.5f, 0.5f)) / float2(width, height) * 2.0f - 1.0f);
// 在相机上朝屏幕逐像素创建光线
Ray ray = CreateCameraRay(uv);
// 计算光线方向转为uv对天空纹理进行采样
float theta = acos(ray.direction.y) / -PI;
float phi = atan2(ray.direction.x, -ray.direction.z) / -PI * 0.5f;
//采样方式和cg的shader不同,使用Texture.SampleLevel进行采样,最后参数为mipmap的level
Result[id.xy] = _SkyboxTexture.SampleLevel(sampler_SkyboxTexture, float2(phi, theta), 0);
}
解释几个点:
1、ComputeShader:为了充分发挥GPU多线程高并行的计算优势,将大量的可并行的计算分配到GPU进行计算,从而出现了ComputeShader。
2:CS线程与线程组:
线程分为三个维度,显示如下:
上图为[10,8,3]线程数,对应CS中Main函数上面的线程标识
线程组,也分三个维度,显示如下:
对应脚本中CS.Dispatch后面的参数
总上所述,我们该处的意思就是,在CS中申请了[8,8,1]的线程数,假设我们的RT尺寸为1024x512,很显然,[8,8,1]的线程数要分组执行[128,64,1](次),即为线程组。如果我们的线程组深度(Z)定义为2,则线程组就变成了[64,32,2]。如果线程组保持不变[128,64,1],我们CS中申请的线程数深度(Z)变为2,则xy维度则会减半,即为[4,4,2]。
总之就是:
X(线程X维数) * Z(线程深度) * X(线程组X维数) * Z(线程组深度) = RT.width
Y(线程Y维数) * Z(线程深度) * Y(线程组Y维数) * Z(线程组深度) = RT.height
线程数量是有限制的,在shader model 5的平台下X * Y * Z <= 1024,Z <= 64;在shader model 4.5的平台下为768,Z <=1。
3、天空图的采样UV计算:
4、关于GetDimensions:根据前缀不同,获取的资源种类也不同。
RWTexture2D.GetDimensions:获取的为RT的宽高。
StructuredBuffer.GetDimensions:获取的为缓存的元素个数及逐元素大小(字节),后面会用到。
5、id.xy:表示调度线程的id。这里每个线程对应RT中的每个像素,也就是每个像素的uv位置。
运行,就能在Game视图里看到天空球的效果了。
下面开始硬编码场景并进行光线追踪:
在CS中增加如下代码:
//创建光线交点结构体
struct RayHit
{
float3 position;
float distance;
float3 normal;
};
//创建初始交点,用来判断之后交点的有效性
RayHit CreateRayHit()
{
RayHit hit;
hit.position = float3(0.0f, 0.0f, 0.0f);
//初始化其距原点距离为无限远
hit.distance = 1.#INF;
hit.normal = float3(0.0f, 0.0f, 0.0f);
return hit;
}
增加地平面的碰撞检测:
void IntersectGroundPlane(Ray ray, inout RayHit bestHit)
{
// 正常判断应为distance = HitPointPos - Ray.Pos,只要distance < bestHit.distance
//即为有效碰撞。但这里硬编码地面为xz无限延伸y为0的平面,所以只需要判断y方向的距离即可
float t = -ray.origin.y / ray.direction.y;
//判断碰撞距离小于上次碰撞距离即为有效碰撞
if (t > 0 && t < bestHit.distance)
{
bestHit.distance = t;
bestHit.position = ray.origin + t * ray.direction;
bestHit.normal = float3(0.0f, 1.0f, 0.0f);
}
}
然后创建碰撞函数:
RayHit Trace(Ray ray)
{
RayHit bestHit = CreateRayHit();
//目前这里只检测了地面的碰撞
IntersectGroundPlane(ray, bestHit);
return bestHit;
}
创建着色函数,用来区分地面和天空:
//目前仅是从相机发射射线并判断是否与地面相交,还没有多次反弹射线
float3 Shade(inout Ray ray, RayHit hit)
{
//判断是否碰撞到地面
if (hit.distance < 1.#INF)
{
// 返回绿色的地面
return hit.normal * 0.5f + 0.5f;
}
else
{
// 采样天空球
float theta = acos(ray.direction.y) / -PI;
float phi = atan2(ray.direction.x, -ray.direction.z) / -PI * 0.5f;
return _SkyboxTexture.SampleLevel(sampler_SkyboxTexture, float2(phi, theta), 0).xyz;
}
}
然后更改一下CSMain函数,增加碰撞检测并用shade函数替换天空的绘制。
// 相交检测并着色
RayHit hit = Trace(ray);
float3 result = Shade(ray, hit);
Result[id.xy] = float4(result, 1);
运行,这样就能在Game视图中看到显示为法线颜色的硬编码的地面了。
硬编码一个球
在CS中增加如下代码:
//球体碰撞检测,用float4(xyz表示位置,w表示半径)来表示一个球
void IntersectSphere(Ray ray, inout RayHit bestHit, float4 sphere)
{
// 以射线距离圆心的距离来判断是否相交
float3 d = ray.origin - sphere.xyz;
float p1 = -dot(ray.direction, d);
float p2sqr = p1 * p1 - dot(d, d) + sphere.w * sphere.w;
if (p2sqr < 0)
return;
float p2 = sqrt(p2sqr);
float t = p1 - p2 > 0 ? p1 - p2 : p1 + p2;
if (t > 0 && t < bestHit.distance)
{
bestHit.distance = t;
bestHit.position = ray.origin + t * ray.direction;
//设定圆心到碰撞点的方向矢量单位化即为球体的法线
bestHit.normal = normalize(bestHit.position - sphere.xyz);
}
}
然后在CS的Trace函数中增加球体的碰撞:
//设定球体位置在(0,3,0),半径为1
IntersectSphere(ray, bestHit, float4(0, 3.0f, 0, 1.0f));
关于球体的碰撞检测原理:
如上图,
圆心到射线原点的矢量:
float3 d = ray.origin - sphere.xyz;
p1的长度:
float p1 =-dot(ray.direction, d);
解释:射线方向(单位化)与矢量d夹角cos值为
cos = -dot(ray.direction, d/|d|)
然后p1的长度为 |d| * cos
圆心到射线垂距的平方 = |d| * |d| - p1 * p1
p2sqr = sphere.w(圆半径) * sphere.w - 圆心到射线垂距的平方
得出p2sqr = sphere.w * sphere.w - |d| * |d| + p1 * p1
当射线与圆刚好相切时,此时圆半径sphere.w的平方刚好等于圆心到射线垂距的平方,p2sqr = 0。否则,圆心到射线垂距的平方将大于圆半径sphere.w的平方,即p2 < 0即为不相交。
p1 - p2用来判断射线是在圆外还是圆内相交。
之后运行,即可在场景中看到该球体:
加入简单的多重采样抗锯齿功能:
原理:每帧在向屏幕像素上发射射线时,增加一个0~1的像素偏移。然后帧与帧之间进行透明度逐渐降低的半透明混合。
创建后处理shader-AddShader
Shader "Hidden/AddShader"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
}
SubShader
{
// No culling or depth
Cull Off ZWrite Off ZTest Always
Pass
{
// 采用半透明混合模式
Blend SrcAlpha OneMinusSrcAlpha
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;
uniform float _Sample;
fixed4 frag (v2f i) : SV_Target
{
//用传入的_Sample参数,来控制透明度的逐渐降低
return float4(tex2D(_MainTex, i.uv).rgb, 1.0f / (_Sample + 1.0f));
}
ENDCG
}
}
}
之后在脚本中增加如下代码:
//传入半透明混合的权重系数
private uint _currentSample = 0;
//半透明混合的材质
private Material _addMaterial;
在render函数中增加如下代码:
//这部分创建材质可以移除到Awake()中进行
if (_addMaterial == null)
_addMaterial = new Material(Shader.Find("Hidden/AddShader"));
//传入半透明系数并用该材质绘制帧缓存
_addMaterial.SetFloat("_Sample", _currentSample);
Graphics.Blit(_target, destination, _addMaterial);
//增加半透明系数
_currentSample++;
在SetShaderParameters()函数中,增加随机像素偏移:
RayTracingShader.SetVector("_PixelOffset", new Vector2(Random.value, Random.value));
在脚本中增加如下代码:
//每次相机移动时,重置_currentSample,即重新进行抗锯齿
private void Update()
{
if (transform.hasChanged)
{
_currentSample = 0;
transform.hasChanged = false;
}
}
最后在CS中做如下改写:
//Main函数外面定义多重采样变量
float2 _PixelOffset;
// 将Main函数这里的0.5像素偏移改为_PixelOffset
//float2 uv = float2((id.xy + float2(0.5f, 0.5f)) / float2(width, height) * 2.0f - 1.0f);
float2 uv = float2((id.xy + _PixelOffset) / float2(width, height) * 2.0f - 1.0f);
对比左右两侧的效果,右侧边缘要柔和的多。相机运动锯齿会重新出现,静止之后,锯齿会很快消失
硬编码很多很多的球:
我们在脚本中定义如下变量:
//球随机种子,用来产生球体生成时的随机数
public int SphereSeed;
//球体半径,定义一个最小,一个最大值,进行随机
public Vector2 SphereRadius = new Vector2(3.0f, 8.0f);
//场景中的球体数量
public uint SpheresMax = 100;
//场景中的球的随机分布半径
public float SpherePlacementRadius = 100.0f;
//定义一个传入computeshader的缓存,将生成的所有球体传入CS
private ComputeBuffer _sphereBuffer;
//定义球结构体,用来存储球体的所有信息,暂时只需要位置和半径信息
struct Sphere
{
public Vector3 position;
public float radius;
};
这样,我们就可以通过以下参数一定程度上对场景中的球体进行自定义
在脚本中,增加两个函数:
//脚本生效时,对场景进行创建,使用SetUpScene()函数
private void OnEnable()
{
_currentSample = 0;
SetUpScene();
}
//脚本销毁时,释放ComputeBuffer
private void OnDisable()
{
if (_sphereBuffer != null)
_sphereBuffer.Release();
}
然后在SetShaderParameters()函数中,将球体缓存传入CS
RayTracingShader.SetBuffer(0, "_Spheres", _sphereBuffer);
下面开始场景的创建:
//创建场景
private void SetUpScene()
{
//种子,避免每次重新生成场景
Random.InitState(SphereSeed);
//定义球列表,用来存放已生成的球
List<Sphere> spheres = new List<Sphere>();
// 创建一定数量的球
for (int i = 0; i < SpheresMax; i++)
{
Sphere sphere = new Sphere();
// 随机球体的半径
sphere.radius = SphereRadius.x + Random.value * (SphereRadius.y - SphereRadius.x);
//以环形分布产生二维随机数并乘上分布半径
Vector2 randomPos = Random.insideUnitCircle * SpherePlacementRadius;
//将随机数字赋予球体位置的x,z,高度y固定为球体半径(球体均贴地)
sphere.position = new Vector3(randomPos.x, sphere.radius, randomPos.y);
// 判断球体之间的相交性
foreach (Sphere other in spheres)
{
float minDist = sphere.radius + other.radius;
if (Vector3.SqrMagnitude(sphere.position - other.position) < minDist * minDist)
goto SkipSphere;
}
// 将符合的球体填入列表
spheres.Add(sphere);
//未通过相交检测的球跳过
SkipSphere:
continue;
}
// 将球体列表塞入缓存
//这里注意,申请缓存需要至少如下两个参数1、缓存元素个数 2、每元素字节数
//我们只需要4个浮点数,4 * 4即16字节大小
_sphereBuffer = new ComputeBuffer(spheres.Count, 16);
//塞入数据
_sphereBuffer.SetData(spheres);
}
ComputeBuffer:可以从脚本代码创建并填充它们,并在计算着色器或常规着色器中使用它们。在常规图形着色器中,计算缓冲区支持需要最低Shade Model 4.5。
然后回到我们的CS中:
//定义球体结构体
struct Sphere
{
float3 position;
float radius;
};
//定义缓存,用来接收脚本传入的数据
StructuredBuffer<Sphere> _Spheres;
改写球体相交检测函数,将最后参数float4改为sphere:
//光线与球体相交检测
void IntersectSphere(Ray ray, inout RayHit bestHit, Sphere sphere)
{
// Calculate distance along the ray where the sphere is intersected
//距离
float3 d = ray.origin - sphere.position;
//圆心做作垂线交于射线的点到射线原点距离
float p1 = -dot(ray.direction, d);
//半径方减去p1平方为射线交点(如果有)到垂线于射线交点的距离平方
float p2sqr = p1 * p1 - dot(d, d) + sphere.radius * sphere.radius;
if (p2sqr < 0)
return;
float p2 = sqrt(p2sqr);
float t = p1 - p2 > 0 ? p1 - p2 : p1 + p2;
if (t > 0 && t < bestHit.distance)
{
bestHit.distance = t;
bestHit.position = ray.origin + t * ray.direction;
bestHit.normal = normalize(bestHit.position - sphere.position);
}
}
然后改写Trace函数,对所有球体进行相交检测:
//进行光线追踪检测
//IntersectSphere(ray, bestHit, float4(0,2,0,1));
// 追踪所有球体
uint numSpheres, stride;
//在定义的缓存中获取资源维度 资源中结构的数量,每个结构元素的跨度(以字节为单位)
_Spheres.GetDimensions(numSpheres, stride);
for (uint i = 0; i < numSpheres; i++)
IntersectSphere(ray, bestHit, _Spheres[i]);
完成,效果如下图:
下一章增加基本光照、阴影和最激动人心的实时反射。
本文暂时没有评论,来添加一个吧(●'◡'●)