看到几篇文章,作者利用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;
    }
}

结果应该差不多是这样,因为这里我们假设每个物体都是绝对光滑,完美反射无散射,所以表现为类似金属的感觉。 

 

Unity光学瞄准 unity光线追踪_ci

主光源

前面我们采样的光源颜色全部来自天空盒,也就是环境光,现在我们加入主光源的影响

这里我们添加albedo值,实现方法就是简单的兰伯特光照模型 直接返回。

return saturate(dot(hit.normal,_DirectionLight.xyz)*-1)*_DirectionLight.w*hit.albedo;

要注意的是现在我们有了两个获得光源的地方,所以得到的颜色综合了两者。

如图,黄色为直接光,蓝色为我们的追踪光线,所以我们可以理解为,光线追踪追踪的实际是物体的间接光,而每个物体还会有它的直接光,这解释了之前除了采样到天空盒,shade函数返回值都为0,而现在我们返回了直接光的光照值。

Unity光学瞄准 unity光线追踪_ci_02

我们可以发现第二个碰撞点实际是在阴影下的,所以我们还需要检测阴影

要注意的是这里我们需要的是直射光反向的反射方向,不是我们的射线方向。

//阴影检测,向直射光的反方向进行射线检测,如果撞到了物体,就说明他在直射光的阴影之下,返回0
Ray shadowRay = CreateRay(hit.position + hit.normal * 0.001f, -1 * _DirectionLight.xyz);
RayHit shadowHit = Trace(shadowRay);
if (shadowHit.distance != 1.#INF)

最后结果如下

Unity光学瞄准 unity光线追踪_Unity光学瞄准_03

抗锯齿

因为我们是每个像素进行采样的,也就是说内容其实是不连续的,所以结果是会有很难看的锯齿

 

Unity光学瞄准 unity光线追踪_Unity光学瞄准_04

解决办法就是每次采样,都对射线发射坐标进行轻微的偏移,然后获取采样的平均值。

在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)