Unity光线追踪实践

Unity光线追踪实践

NVIDIA前段时间推出了NVIDIA RTX,使得光线追踪这一古老而又年轻的技术再次进入人们的视野,相比传统的光栅化算法,光线追踪更加符合数学家对物理世界的描述,得益于这个技术我们能在游戏或者电影里面看到更加炫酷,更加真实,更激动人心的画面。本文立足于Unity渲染API,以及Real Time Rending等参考资料,意在说明实现一个简单的光线追踪渲染器需要注意的点和主要的模块,主要用于技术探索和交流。(文章所用原理图片来自网络)

1.光线追踪原理

1.1渲染方程

渲染方程可以简单理解为:在一个光照环境中,一个物体w在不同观察方向上的光照强度分布情况。本文后面的光照模型,phone模型等,都是对这个方程的简单模拟。

渲染方程中,x为当前的物体,L(x,w) 表示,在x处,沿着w方向射出的光照强度,Le为自发光,后半部分是反射光。方程里面的坐标表示都是球坐标。,球坐标的解释如下,具体的计算和推导细节请读者参考其他资料。

物理学中通常使用的球坐标(r, θ, φ)(ISO 约定):径向距离 r,极角θ (theta) 与方位角φ (phi)。在数学里,球坐标系(spherical coordinate system)是一种利用球坐标表示一个点P在三维空间的位置的三维正交坐标系。右图显示了球坐标的几何意义:原点与点P之间的径向距离(radial distance),原点到点P的连线与正z-轴之间的极角(polar angle),以及原点到点P的连线在xy-平面的投影线,与正x-轴之间的方位角(azimuth angle)。它可以被视为极坐标系的三维版本。


1.2光线追踪过程

严格来讲,光线追踪应该叫做视线追踪,其本质是逆物理过程,这里的物理过程指的是:光线从光源发出,照射到物体上面通过屏幕进入相机,从而能够成像。光线追踪就是假设从摄像机发射一束视线穿过屏幕到物体上,观察这个地方的光照结果,并且把这些结果叠加从而作为屏幕上当前点的颜色信息。这里光照结果大体分为两个部分,第一个部分指的是光照直接投射到物体上产生的结果,第二个部分指的是其他物体发出的光线对该物体的影响,这时候就需要对其他物体递归使用视线追踪,最终把结果叠加。在本文中,光线的产生和相交运算采用Unity Api,主要是Physica.Raycast模块,为了更好的性能这一部分操作理应在GPU中进行,这也是笔者后面的优化方向。

2.Unity常见API和概念

  • onRenderImage

当图像已经渲染完成,相当于后期处理,这里能够拿到原始的图片,然后通过3d数据进行图像的修正。这个函数可以访问当前正在渲染的图像。这个函数只出现在Camera上的脚本

  • Graphics.Blit

后期处理的过程:在onRenderImage里面拿到当前的渲染图像,然后Blit传递给shader进行渲染,渲染完成之后会再次通过onRenderImage进行回调,这时候可以通过Blit函数渲染到屏幕上。sourceTexture会成为material的mainTexture。Blit(sourceTarget,des,material),渲染的时候会使用material里面的shader。

  • Mesh

Mesh,网格 。 MeshFilter 用于获取网格数据的组件。MeshRender,用于渲染网格的组件。因为mesh可能被多个模型使用,mesh是每个模型单独的,sharedMesh则指的是共享的mesh,修改之后所有使用的mesh都会进行改变。Mesh实际上是三角形的点和边的集合。

  • ComputeShader

需要判断ComputeShader是否支持,ComputeShader不能挂在mesh上面,只能在脚本里面调用。SetTexture 传递数据,Dispatch 函数名字,线程组个数,每个线程组的线程数目,每次处理的像素个数,StructBuffer可以双向传递数据albedo

  • Awake

创建之后立即调用

  • Start

update之前调用

  • 部分Shader内置函数

UnityObjectToClipPos

本地坐标转换成相机空间的坐标,光线追踪里面都是以相机空间作为基准。在顶点着色器拿到的都是本地坐标,计算的时候需要转换。

unity_CameraInvProjection,摄像机投影矩阵的逆矩阵

  • 冯氏光照模型材质

l Albedo Color 反照颜色

l Metalness 金属性

l Fresnel Color 菲涅尔颜色,反射颜色

l Roughness 粗糙程度 光滑程度

冯氏光照模型决定了光照的颜色

Albedo定义了物体的整体颜色


3.Unity光照系统参数

Unity的光照模型至关重要。Unity里面有四种光源类型

  • Directional Light 方向光,类似太阳光的效果,消耗的系统资源最少
  • Point Light 点光源,类似蜡烛
  • Spotlight 聚光灯,蕾丝手电筒
  • Area Light 区域光,一般用于烘焙贴图,在光线追踪里面可以用来产生软阴影,这里会涉及对光源进行采样,但是不规则的光源计算起来太复杂,所以后面都会把光源模拟成球体来简化计算。

4.参考资料

CPU计算,只计算了反射,但是对从模型获取三角形,光源的处理有很强的借鉴意义。

实现了GPU求交,采样等等,优点是能够借鉴GPU处理光线追踪的框架。缺点是求交运算太简单,材质处理也太简单

  • 《Ray Trace In One Week》

对光线追踪和图像渲染有一个高屋建瓴的认识,能够快速建立起一个大概的印象,但是只能渲染球体,其他进阶的处理还需要额外看资料

  • 《Real Time Rending》

既有基本框架,也有高深的数据描述,但是没有多少实践部分

5.光线追踪模型框架

参照前文所述的光线追踪原理,我们可以得出一个光线追踪模型的基本框架。

//参考 real time rending 
rayTrace(){     
    for( p in pixels){        
        color of p = trace(eye ray through p);    
    } 
}

 trace(){     
    pt = find last intersection;     
    return shade(pt); 
} 

shade(point){    
     color = 0;     
    for(L in light sources){         
         trace(shadow ray to L);        
         color += evaluate BRDF;    
     }     
    color += trace(reflection ray);     
    color += trace(refraction ray);     
    return color; 
}

6.法线插值计算

当渲染一个三角形的时候整个面的法线方向都是一致的,因此会出现棱角分明的效果,需要对法线进行插值。Mesh里面包含了每个顶点的法线信息,顶点法线其实也是通过对面的计算然后加权平均计算的,raycast会返回重心坐标,这个坐标反应了重心分割的时候三块面积的大小。以此对三角形面的法线进行插值。需要注意的是如何获取顶点index,根据三角形的index,去mesh.triangles里面查找。mesh里面的信息都是本地坐标系统需要用transform进行转换。

public void GetFixedNormal(Mesh mesh,ref Vector3 normal,int hIndex,Transform transform,Vector3 barycentricCoordinate)
    {
        Vector3[] normals = mesh.normals;
        int[] triangles = mesh.triangles;
        int trianglesLength = triangles.Length;
        int normalLength = normals.Length;
        int tIndex0 = hIndex * 3 + 0;
        int tIndex1 = hIndex * 3 + 1;
        int tIndex2 = hIndex * 3 + 2;
        if (tIndex0 >= 0 && tIndex0 < trianglesLength && tIndex1 < trianglesLength && tIndex2 < trianglesLength)
        {
            int vIndex0 = triangles[tIndex0];
            int vIndex1 = triangles[tIndex1];
            int vIndex2 = triangles[tIndex2];
            // Extract local space normals of the triangle we hit
            if (vIndex0 < normalLength && vIndex1 < normalLength && vIndex2 < normalLength)
            {
                Vector3 n0 = normals[vIndex0];
                Vector3 n1 = normals[vIndex1];
                Vector3 n2 = normals[vIndex2];
                // interpolate using the barycentric coordinate of the hitpoint
                Vector3 baryCenter = barycentricCoordinate;
                // Use barycentric coordinate to interpolate normal
                Vector3 interpolatedNormal = n0 * baryCenter.x + n1 * baryCenter.y + n2 * baryCenter.z;
                // normalize the interpolated normal
                interpolatedNormal = interpolatedNormal.normalized;

                // Transform local space normals to world space
                Transform hitTransform = transform;
                interpolatedNormal = hitTransform.TransformDirection(interpolatedNormal);

                normal = interpolatedNormal;
            }
        } 
    }


7.局部光照模型简单实现

using UnityEngine;
using System.Collections;
//光线处理工具
public class RayUtil
{

    /*--------------------------------------局部光照模型------------------------------------
     * 单一光源,特定BRDF下的推导。需要光线方向,光照强度,视线方向
     */

    /*
     * 反射光线在视线上的投影
     * Phone模型   ks*ls*(v dot r)^n 高光系数*光照强度*(反射光线 点乘 视线 )^高光指数
     */
    public static Vector3 PhoneLight(Vector3 lightDirection, Vector3 lightColor, float specular, float lightStrength, Vector3 normal, Vector3 viewDirection, float alpha)
    {


        return lightColor * specular *  lightStrength * 
        Mathf.Pow(
            (Vector3.Dot(Vector3.Normalize(Vector3.Reflect(lightDirection, normal)), Vector3.Normalize(viewDirection))), 
            alpha);
    }

    /*
     * 漫反射,光照射到粗糙的表面的时候,均匀向四周反射,漫反射的光强与入射方向与法线的夹角余弦成正比,因此此模型不涉及视线
     * 
     * Lambert模型 kd*ld*(n dot l) 漫反射属性*入射光强度*(入射单位法向量 dot 入射点指向光源的单位向量)
     */
    public static Vector3 LambertLight(Vector3 lightDirection, Vector3 lightColor, float albedo, Vector3 normal,float ligthStrength)
    {

        return lightColor * albedo  *  Mathf.Max((Vector3.Dot(Vector3.Normalize(normal), Vector3.Normalize(lightDirection)))*0.5f+0.5f,0.0f);

    }

    public static Vector3 BlinnPhong(Vector3 lightDirection, Vector3 lightColor, float specular, float lightStrength, Vector3 normal, Vector3 viewDirection, float alpha)
    {

        return lightColor * specular * lightStrength * Mathf.Pow(Mathf.Max(Vector3.Dot(Vector3.Normalize(lightDirection + viewDirection ), Vector3.Normalize(normal)),0.0f), alpha);

    }

    public static float SmoothnessToAlpha(float s)
    {
        return Mathf.Pow(1000.0f, s * s);
    }
}

8.软阴影

在光线追踪里面,阴影的产生和判断都是通过发射shadow ray判断是否有障碍物来计算的,这样的话其实阴影会有一个很明显的边界,在现实世界,光源不可能是一个点,方向光也不可能绝对平行,因此阴影会有一个平滑的过渡过程,这样的阴影就是软阴影。在光线追踪里面,软阴影的实现采用发射shadow ray的时候在光源表面随机采样来实现,按理说应该用光源上的每个点进行计算,但是这样计算量太大了。这里光源就不是一个点(其他部分为了方便计算都抽象成一个点)。

Vector3 shadowRayDirection = ligthDirection + Random.onUnitSphere * 0.009f;  
if (Physics.Raycast(hit.point, shadowRayDirection, 500))
  {
          return new Vector3(0.0f, 0.0f, 0.0f);
 }

9.体积光渲染

体积光使用RayMarch的方式渲染,测试每一条光和光源之间的距离,采用合适的衰减和采样函数来确定当前方向上的光线表现。实现如下:

目前光线的衰减函数是和距离平方成反比,这样的函数衰减特别厉害,会导致光线到不了物体,后面需要优化。下图是函数衰减曲线。

当物体在光线的阴影里面的时候需要进行相交判断,具体做法是从当前位置向光源发射一束光线,如果没有碰撞说明没有遮挡,这里又个需要注意的地方,有可能会导致光源前后都形成遮挡区域这时候要根据碰撞距离来保留合适的那一个。

using System;
using System.Collections.Generic;
using UnityEngine;
/*
 * 处理发光物的丁达尔效应,默认为球体光源
 */
public class LightObject
{
    public BoundingSphere boundSphere;
    public static float MAX_LIGHT_DISTANCE = 2.0f;
    //物体最靠近的颜色
    public static float MIN_DISTANCE = 1.0E-2f;

    public Vector3 mLightColor;

    public static int MAX_SAMPLE_COUNT =180;

    public float mStep = 0.0f;

    public float mInnerDistance = MIN_DISTANCE;
    public Vector3 mPosition;


    public void Init(Vector3 color,Vector3 position,float length)
    {
        mPosition = position;
        mLightColor = color;
        mStep = 0.1f;
        mInnerDistance = length;
    }
    /*
     * 光线步进的方式获取光照颜色   
     */
    public Vector3 RayMarch(Ray ray,float minDistance,float maxDistance)
    {
        float t = minDistance;
        Vector3 result = new Vector3(0.0f,0.0f,0.0f);
        int realCount = 0;
        for(int i = 0; i < MAX_SAMPLE_COUNT && t >= minDistance && t <= maxDistance; i++)
        {
            Vector3 p = ray.GetPoint(t);
            if (!InShadow(p))
            {
                result += GetSampleColor(Vector3.Distance(p, GetPosition()));
                realCount++;
            }
            t += GetStep();
        }
        result /= (realCount + 0.0f);
        return result;
    }
    public Vector3 GetSampleColor(float distance)
    {
        return mLightColor * (mInnerDistance / (distance  + 0.0f));
    }
    public Vector3 GetPosition()
    {
        return mPosition;
    }
    public float GetStep()
    {
        return mStep;
    }
    public Vector3 GetLightColor()
    {
        return mLightColor;
    }
    public bool InShadow(Vector3 point)
    {

        Vector3 direction = (mPosition - point).normalized;
        RaycastHit hit;
        if (Physics.Raycast(point ,direction ,out hit,100f))
        {
            return hit.distance >= 0.0f;
        }
        else
        {
            return false;
        }
    }
}

11.初步结果

  • 采用图像二次采样,即关闭所有光照,环境光调整为1,然后在需要图像颜色的时候对原始图片进行采样,这样其实不正确,但是也能得到一个近似结果


  • 上面那种方法,对阴影采样进行收敛之后的结果


  • 上面的方法,材质全光滑的结果


  • 下面都是正常处理,采用Unity的材质系统处理颜色。单一方向光


  • alpha = 3.2f,specular = 1.1f,可以看到出现了光斑


  • 修改成B-Phong模型


  • 根据Mesh读取颜色


  • 法线插值效果


  • 一些模型


  • 软阴影,50条光线


  • 软阴影 400条光线


  • 无限制体积光

12.后期的构想

一方面需要提升性能,因此我打算在GPU里面实现相交,采样等运算,Unity仍然负责提供材质的解析,一些初始化的工作。另一方面需要完善自发光物体的效果,因此引入了体积光渲染,但是要渲染一个不规则的自发光物体,还需要对体积光渲染进行修正,比如可以对发光物体先进行一次发光物体的求交运算。细节问题欢迎私信交流讨论。


代码已经开源,欢迎学习交流:github.com/zfymoon/RayT

编辑于 2019-09-16

文章被以下专栏收录