看到几篇文章,作者利用Compute Shader在Unity里实现光线追踪,这里记一下不好理解的地方和比较重要的知识点
要注意的是这里Unity只是个实现的载体,和Unity原来的渲染管线基本没有关系了,只是使用OnRenderImage来输出光线追踪生成的图像而已。
Chapter 1
基本思路:
利用Compute Shader,屏幕的每个像素点向外释放一条射线来采样颜色,利用光线可逆的原则,每条光线根据碰撞到的物体进行反射,如此反复直到采样到天空盒(无限远)或者达到最大的反射次数。
Unity ComputeShader的使用
C# 调用部分
//我们希望让每个线程处理一个像素,
//我们的computeShader中设置每个线程组为8x8,也就是每一组能处理8x8个像素
//PS:我们可以设置1维、2维、3维,这里我们第三维设为了1,其实是8x8x1
//所以需要的线程组为(高/8)*(宽/8)
int threadGroupsX = Mathf.CeilToInt(Screen.width / 8.0f);
int threadGroupsY = Mathf.CeilToInt(Screen.height / 8.0f);
RayTracingShader.Dispatch(0, threadGroupsX, threadGroupsY, 1);
传递参数:RayTracingShader.SetXXX(...);
Compute Shader 语法
//可以设为多个程序,这里我们只写了一个,c#里的dispatch的序列0就指向了这个程序
#pragma kernel CSMain
//RW RandomWrite 我们要输出的贴图,这个贴图必须开启随机读写c#: _target.enableRandomWrite = true;
RWTexture2D<float4> Result;
[numthreads(8,8,1)]
void CSMain (uint3 id : SV_DispatchThreadID)
{
...
id.xy 当前线程的id,这里可以当做像素点的坐标用 z这里我们是1,直接忽略就好
}
根据像素位置发射射线
因为透视,所以不能是直接垂直屏幕发射。
// 获得纹理(也就是输出屏幕)的宽高
uint width, height;
Result.GetDimensions(width, height);
// 确定当前像素点的uv位置
float2 uv = float2((id.xy + float2(0.5f, 0.5f)) / float2(width, height) * 2.0f - 1.0f);
Ray ray = CreateCameraRay(uv);//从屏幕空间射出的一条射线
Ray CreateCameraRay(float2 uv){
//我们最终需要的是世界空间的射线表示
//起点就是摄像机的位置
float3 origin=mul(_CameraToWorld,float4(0.0f,0.0f,0.0f,1.0f)).xyz;
//方向要从 裁减空间->观察空间 观察空间->世界空间
//float(uv.xy,-1.0f,1.0f)表示的是近裁面对应uv的点(近裁面为-1,远裁面为1,原文用的0.0,也就是中间,其实都一样,反正最后都会归一化),
//因为射线方向就是以摄像机为原点的,所以该点的xyz可以直接看做方向
//tip: 第四分量 1表示点,会考虑位移 0表示方向,不考虑
float3 direction=mul(_CameraInverseProjection,float4(uv.xy,0.0f,1.0f)).xyz;
direction=mul(_CameraToWorld,float4(direction,0.0f)).xyz;
direction=normalize(direction);
return CreateRay(origin,direction);
}
天空盒的采样
这里我们导入天空盒是以texture2D而非texture3D的格式传入,所以我们需要把方向从笛卡尔坐标系转换为球坐标系,接着再除以π和π/2 把值映射至uv坐标的[0,1]范围内。
直角坐标系(x,y,z)与球坐标系(r,θ,φ)的转换关系为:
//因为我们天空盒导入的是2D的,所以要把方向转为球坐标系来采样
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).rgb;
光线追踪流程
//光线进行7次反弹
float3 result=float3(0.0f,0.0f,0.0f);
for(int i=0;i<8;i++){
RayHit hit=Trace(ray);
result+=ray.energy*Shade(ray,hit);
//光线能量耗尽直接退出循环
if(!any(ray.energy)) break;
}
首先是射线检测来寻找和场景中物体的碰撞情况,并将碰撞信息写入rayhit,接着在Shade方法内根据rayhit信息采样颜色,并且更新ray为碰撞点的入射光(原本是相对于碰撞点的出射光,我们的射线和光线是相反的)用于下一次的追踪。
按这个步骤循环进行多次从而形成完整的光线路径,当然不可能模拟无限反弹,我们这里限制最多7次
射线检测:检测是否碰撞到相应物体,注意这只是单条射线寻找最近的碰撞点,不是有多次反弹的一条完整光线
RayHit Trace(Ray ray){
RayHit bestHit=CreateRayHit();
//平面
IntersectGroundPlane(ray,bestHit);
//球体
uint numSpheres, stride;
_Spheres.GetDimensions(numSpheres, stride);
for (uint i = 0; i < numSpheres; i++)
IntersectSphere(ray, bestHit, _Spheres[i]);
return bestHit;
}
碰撞物:也就是场景中的物体,我们先跳过网格,用相交性检测的方式来模拟圆和平面,如果射线和指定物体碰撞且距离最近,就将结果存入rayhit,共着色函数(Shade)使用。
//y=0的平面的射线相交检测
void IntersectGroundPlane(Ray ray,inout RayHit bestHit){
//相似三角形
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,1,0);
bestHit.albedo=float3(1.0f,1.0f,1.0f);
bestHit.specular=float3(0.2f,0.2f,0.2f);
}
}
//球的相交检测
void IntersectSphere(Ray ray, inout RayHit bestHit, Sphere sphere)
{
float3 d = ray.origin - sphere.position;
float p1 = -dot(ray.direction, d);
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);
bestHit.albedo=sphere.albedo;
bestHit.specular=sphere.specular;
}
}
着色:采样颜色并且更新ray信息 这里有一个energy的概念,你可以把它想成光线的损耗度,因为物体是会吸收部分光线的,假设原本的光线强度为1,他碰撞到的第一个物体的specular0.6,物体吸收了0.4后反射的光线强度就变为0.6了(表现出来的就是物体的颜色),第二个Specular为0.8,那么就只剩下0.48,以此类推直到进入我们的眼睛。乘法顺序改变不会有影响,所以我们反着来的射线追踪也直接乘就好。
float3 Shade(inout Ray ray,RayHit hit){
if(hit.distance<1.#INF)
{
//修改射线为反射光线
//加一点偏移,避免浮点精数误差
ray.origin=hit.position+hit.normal*0.001f;
ray.direction=reflect(ray.direction,hit.normal);
ray.energy*=hit.specular;
return float3(0.0f,0.0f,0.0f)
}else{
ray.energy=0;
//没有碰撞的物体就采样天空盒
//因为我们天空盒导入的是2D的,所以要把方向转为极坐标来采样
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).rgb;
}
}
结果应该差不多是这样,因为这里我们假设每个物体都是绝对光滑,完美反射无散射,所以表现为类似金属的感觉。
主光源
前面我们采样的光源颜色全部来自天空盒,也就是环境光,现在我们加入主光源的影响
这里我们添加albedo值,实现方法就是简单的兰伯特光照模型 直接返回。
return saturate(dot(hit.normal,_DirectionLight.xyz)*-1)*_DirectionLight.w*hit.albedo;
要注意的是现在我们有了两个获得光源的地方,所以得到的颜色综合了两者。
如图,黄色为直接光,蓝色为我们的追踪光线,所以我们可以理解为,光线追踪追踪的实际是物体的间接光,而每个物体还会有它的直接光,这解释了之前除了采样到天空盒,shade函数返回值都为0,而现在我们返回了直接光的光照值。
我们可以发现第二个碰撞点实际是在阴影下的,所以我们还需要检测阴影
要注意的是这里我们需要的是直射光反向的反射方向,不是我们的射线方向。
//阴影检测,向直射光的反方向进行射线检测,如果撞到了物体,就说明他在直射光的阴影之下,返回0
Ray shadowRay = CreateRay(hit.position + hit.normal * 0.001f, -1 * _DirectionLight.xyz);
RayHit shadowHit = Trace(shadowRay);
if (shadowHit.distance != 1.#INF)
最后结果如下
抗锯齿
因为我们是每个像素进行采样的,也就是说内容其实是不连续的,所以结果是会有很难看的锯齿
解决办法就是每次采样,都对射线发射坐标进行轻微的偏移,然后获取采样的平均值。
在C#里给computeshader传入一个每帧的随机偏移,shader对起始射线应用这个偏移
//映射至uv的01范围内
float2 uv=float2((id.xy+_PixelOffset.xy)/float2(width,height)*2.0f-1.0f);
然后我们在新增一个pass,对每次采样进行平均。
Shader "Hidden/AddShader"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
}
SubShader
{
Cull Off
ZWrite Off
ZTest Always
Blend SrcAlpha OneMinusSrcAlpha
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
// make fog work
#pragma multi_compile_fog
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct v2f
{
float2 uv : TEXCOORD0;
UNITY_FOG_COORDS(1)
float4 vertex : SV_POSITION;
};
sampler2D _MainTex;
float4 _MainTex_ST;
float _Sample;
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
UNITY_TRANSFER_FOG(o,o.vertex);
return o;
}
float4 frag (v2f i) : SV_Target
{
return float4(tex2D(_MainTex, i.uv).rgb, 1.0f / (_Sample + 1.0f));
}
ENDCG
}
}
}
这样的结果就是每个每一帧占最终结果的混合比例永远是一样的
第一次渲染 1
第二次渲染 1/2 +1/2(前一帧的值)
第三次渲染 1/3+2/3(前两帧的混合 1/3+1/3)
第四次渲染 1/4+3/4(前三帧混合 1/4+1/4+1/4+1/4)