看到几篇文章,作者利用Compute Shader在Unity里实现光线追踪,这里记一下不好理解的地方和比较重要的知识点
http://blog.three-eyed-games.com/2018/05/03/gpu-ray-tracing-in-unity-part-1/
要注意的是这里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)