Unity Ray Tracing Gem Shader 光線追蹤寶石着色器

最近在學習製作寶石材質時發現了一個 Unity 寶石的插件 R Gem Effect,第一次看這個視頻的時候就覺得很驚豔,可惜這個插件在 Unity 商店裏下架了。看視頻可以發現,原作者使用了光線追蹤,所以就想自己在 Unity 裏實現這樣的效果。

gems
*項目中的模型來自 R Gem Effect Unity Plugin ,HDR 環境圖來自 HDRIHaven

Github: github.com/Sorumi/UnityRayTracingGem
博文原文:sorumi.xyz/posts/unity-toon-shader/

Ray Tracing

光線追蹤是指從攝像機出發的若干條光線,每條光線會和場景裏的物體求交,根據交點位置獲取表面的材質、紋理等信息,並結合光源信息計算光照。相對於傳統的光柵化渲染,光線追蹤可以輕鬆模擬各種光學效果,如反射、折射、散射、色散等。但由於在進行求交計算時需要知道整個場景的信息,其計算成本非常高。

關於如何在 Unity 中進行 Ray Tracing,我參考了 GPU Ray Tracing in Unity這個系列的文章,使用了 Compute Shader 來實現。

實時渲染寶石

實時渲染不同於離線渲染,不可能在整個場景中都使用光線追蹤,又由於寶石材質的特殊性,我對其進行一下幾點約束 (tricks):

  1. 整個場景使用光柵化渲染流程,只有在渲染寶石物體時,使用光線追蹤,在片元着色器中從攝像機放射光線。
  2. 每個寶石物體在光線追蹤時,只和自己的 Mesh 模型進行求交點的計算。
  3. 只使用光線追蹤來計算光線的折射和反射。
  4. 假設寶石表面是完全光滑的,不考慮微表面,且內部無任何雜質。其表面只有 specular 項,無 diffuse 項。
  5. 光線僅在第一次與寶石表面相交時,被分爲反射光線和折射光線,之後折射光線在寶石的內部進行全反射或其折射出寶石表面。
  6. 光線經過反射或折射,射出寶石表面後,對天空球進行採樣。

由於我們在着色器中傳遞整個模型數據,需要使用 ComputeBuffer,這一般是爲 Compute Shader 提供的,要在一般的頂點像素着色器中使用,需要至少支持 shader model 4.5 。在 Shader 中,ComputeBuffer 映射的數據類型爲 StructuredBuffer<T>RWStructuredBuffer<T>

折射

通過入射光線方向、入射點法線方向、入射介質折射率、出射介質折射率來計算出射光線方向:

T=η1η2(I+cos(θ1)N)N1(η1η2)2(1cos2(θ1)) T=\frac{\eta_{1}}{\eta_{2}}\left(I+\cos \left(\theta_{1}\right) N\right)-N \sqrt{1-\left(\frac{\eta_{1}}{\eta_{2}}\right)^{2}\left(1-\cos ^{2}\left(\theta_{1}\right)\right)}

當光由光密介質射到光疏介質的界面時,會發生全反射現象,即光線全部被反射回原介質內。此時:

1(η1η2)2(1cos2(θ1))<0 {1-\left(\frac{\eta_{1}}{\eta_{2}}\right)^{2}\left(1-\cos ^{2}\left(\theta_{1}\right)\right)} < 0

由於要考慮全反射的情況,這裏我自定了 Refract(i, n, eta, o) 函數,返回值表示是否存在折射光線,不存在表示進行了全反射,其實現參考了 Unity 內置的 refract(i, n, eta) 函數。

float Refract(float3 i, float3 n, float eta, inout float3 o)
{
    float cosi = dot(-i, n);
    float cost2 = 1.0f - eta * eta * (1 - cosi * cosi);

    o = eta * i + ((eta * cosi - sqrt(cost2)) * n);
    return 1 - step(cost2, 0);
}

在和模型的三角面求交點時,折射光線會和三角面的背面相交,需要注意不能進行背面剔除。當光線從寶石射出時,法線需要反向

float eta;
float3 normal;
        
// out
if (dot(ray.direction, hit.normal) > 0)
{
	normal = -hit.normal;
	eta = _IOR;
}
// in
else
{
	normal = hit.normal;
	eta = 1.0 / _IOR;
}
        
ray.origin = hit.position - normal * 0.001f;
        
float3 refractRay;
float refracted = Refract(ray.direction, normal, eta, refractRay);

……

// Refraction
if (refracted == 1.0)
	ray.direction = refractRay;
// Total Internal Reflection
else
	ray.direction = reflect(ray.direction, normal);

僅有折射的效果:
gem_refraction

反射

通過入射光線方向、入射點法線方向來計算反射光線方向:

R=I2(NI)N R=I-2(N \cdot I) N

可以直接使用 Unity 的內置函數 reflect(i, n)

float3 reflect(float3 i, float3 n)
{
  return i - 2.0 * n * dot(n, i);
}

僅有反射的效果:
gem_reflection

菲涅爾

透明物體既有反射又有透射即折射。它們反射的光量與透射的光量取決於入射角。當入射角減小時,透射的光量增加。按照能量守恆的原理,反射光的量加上透射光的量必須等於入射光的總量,因此,入射角增加時,反射的光量增加。

反射光與折射光的數量可以使用菲涅爾方程來計算。這裏我使用 Schlick 的簡化版本 來計算 Fresnel 的值,採用了入射光線方向、入射點法線方向、入射介質折射率、出射介質折射率:

R(θ)=R0+(1R0)(1cosθ)5R0=(n1n2n1+n2)2 \begin{aligned} R(\theta) &=R_{0}+\left(1-R_{0}\right)(1-\cos \theta)^{5} \\ R_{0} &=\left(\frac{n_{1}-n_{2}}{n_{1}+n_{2}}\right)^{2} \end{aligned}

float FresnelSchlick(float3 normal, float3 incident, float ref_idx)
{
    float cosine = dot(-incident, normal);
    float r0 = (1 - ref_idx) / (1 + ref_idx); // ref_idx = n2/n1
    r0 = r0 * r0;
    return r0 + (1 - r0) * pow((1 - cosine), 5);
}

在第一次與物體表面相交時,計算 Fresnel 的值,反射量乘以 FRF_{R} , 投射量乘以 1FR1-F_{R}

if (depth == 0)
{
	float3 reflectDir = reflect(ray.direction, hit.normal);
	reflectDir = normalize(reflectDir);
            
	float3 reflectProb = FresnelSchlick(normal, ray.direction, eta) * _Specular;
	specular = SampleCubemap(reflectDir) * reflectProb;
	ray.energy *= 1 - reflectProb;
}

使用菲涅爾融合折射和反射的效果:
gem_fresnel

光線吸收

折射光在透明物體內部進行傳播,根據 Beer-Lambert 定律,光照射入一吸收介質表面,在通過一定厚度後,介質吸收了一部分光能,透射光的強度響應減弱,因此介質會呈現出顏色傾向。光穿過一個體積的透射比 TT 爲:

T=eσad T=e^{-\sigma_{a} d}

這裏 σa\sigma_{a} 爲一個吸收係數,dd 爲光折射傳播的距離。

這是具有 Beer-Lambert 定律的立方體,可吸收遠距離的紅色和綠色光。可以發現光線透過距離越長的部分,顏色越深。

absorption

要應用 Beer-Lambert 定律,您首先要計算射線穿過吸收介質的距離,在 Ray 中增加變量 absorbDistance

struct Ray
{
    float3 origin;
    float3 direction;
    float3 energy;
    float absorbDistance;
};

Shade 函數中對累加計算:

float3 Shade(inout Ray ray, RayHit hit, int depth)
{
	if (hit.distance < 1.#INF && depth < (_TraceCount - 1))
    {
		……
		if (depth != 0)
         	ray.absorbDistance += hit.distance;
		……
	}
}

最後在計算顏色時根據吸收率和穿過距離計算透射比,吸收率一般爲一個顏色值,描述了每個顏色通道在遠處吸收的數量,爲了使材質調整起來更加直觀,這裏對 _Color 值進行取反,與 _AbsorbIntensity 相乘,表示吸收率。

float3 Shade(inout Ray ray, RayHit hit, int depth)
{
	if (hit.distance < 1.#INF && depth < (_TraceCount - 1))
    {
    	……
    }
    else
    {
    	ray.energy = 0.0f;

        float3 cubeColor = SampleCubemap(ray.direction);
        float3 absorbColor = float3(1.0, 1.0, 1.0) - _Color;
        float3 absorb = exp(-absorbColor * ray.absorbDistance * _AbsorbIntensity);

        return cubeColor * absorb * _ColorMultiply + _Color * _ColorAdd;
    }
}

增加光線吸收的效果:
gem_absorption

擴展閱讀

“Cheap” Diamond Rendering

這篇文章中通過烘焙模型內部的法線貼圖爲 Cubemap,來模擬 RayTracing,速度更快。但這種方法仍然有點費,每個像素點最多要進行 7 次 Cubemap 採樣。而且在法線轉折處會有明顯的鋸齒,雖然能通過提高 Cubemap 的精度來改善,但不能完全消除。

參考

GPU Ray Tracing in Unity

Ray Tracing in One Weekend

Triangle Intersection

Reflection, Refraction and Fresnel

Raytracing Reflection, Refraction, Fresnel, Total Internal Reflection, and Beer’s Law

Microfacet models for refraction through rough surfaces.

Extending the Disney BRDF to a BSDF with Integrated Subsurface Scattering

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章