在WebGL中使用GLSL實現光線追蹤

真昼小天使daisuki發表於2024-05-20

Update:git地址 https://github.com/mahiru23/raytrace

本文的根本目標是在WebGL中使用GLSL實現光線追蹤,無圖(懶得放了),僅供參考。

在一切開始之前,我們預設對GLSL的基本語法有所瞭解,不理解請自行查詢。

一些需要重點關注的東西,請確認自己完全明白這一點再繼續:
MVP變換:模型座標空間 – 世界座標空間 – 相機座標空間 – 投影座標空間。
在工作的時候一定要明白自己當前是在哪個座標系下進行操作。

OpenGL 光線追蹤其一:Phong光照模型

在渲染管線中,模型轉換之後的工作就是Illumination(光照,這個過程我們也叫shading,著色)。簡單來說,本階段的工作就是對於光照的處理。

在計算機圖形學中,對於光源,我們模擬的是光子(photon)。

一些物理概念:

albedo(反照率):衡量物體對光線的反射水平,the average reflectivity of the surface ornaments
flux(通量) : Radiation flux (electromagnetic flux, radiant flux)
Radiance(輻射) – radiant flux per unit solid angle per unit projected area,在圖形學的角度上就是測量物體上某個點的反光量
Irradiance(輻照度) – differential flux falling onto differential area,理解為電磁輻射入射於曲面時每單位面積的功率

此處我們採用的Phong模型是BRDF反射模型,這一階段我們單純考慮反射,不考慮透明材質或次平面反射等。Phong光照模型在片元著色器中依賴於插值進行計算。

光源分為方向光源(平行光源不衰減)、點光源(隨距離衰減)、面光源(蒙特卡洛法)等,本文基本採用單個點光源的方式進行處理,其他型別的光源暫時不考慮。

Phong光照模型

跳過繁雜的理論推導,我們直接從實現層面簡單說明,Phong光照模型由以下三部分組成:

  • Ambient:環境光源,不管什麼遮擋/陰影等效果,所有位置都需要附上。在實際的視覺效果上提供基礎照明,保證你不會得到一大片黑色。
  • Specular Reflectance:鏡面反射,在實際的視覺效果上提供高光,表現為物體表面的亮斑(如果有的話),一般在鏡面材質上較強。
  • Diffuse Reflectance:漫反射,一般是粗糙表面較多,在實際的視覺效果上表現為一大片暈染開的光。

最終得到的光照數值就是把以上三個光照加起來,簡單粗暴,但是有效。

在此處,我們使用vec4型別的變數進行處理,最後一位沒有實際意義,具體看個人實現。

假設n是平面法向量,l是光線方向,d是光源點到光照點的距離,v指向視點位置,r是反射後的光線。其中outNormal和outPosition從頂點著色器中傳過來。Ambient/ diffuse/ specular為三個提前設定的光照引數,按需調整。

程式碼如下:

vec3 ads()
{
	vec3 n = outNormal;
    vec3 l = normalize(lightPositionCam - outPosition);
    float d = length(lightPositionCam - outPosition);
	vec3 v = normalize(-outPosition);
	vec3 r = reflect(-l, n);
    float lightIntensity = light/(4.0*3.1415926*d*d);
	vec4 res =  ambient + 
                lightIntensity * (diffuse * max(dot(l, n), 0.0) + 
                specular * pow(max(dot(r,v), 0.0), shininess));
    return vec3(res);
}

OpenGL 光線追蹤其二:Texture紋理

在接下來的實際光線追蹤中,我們期待能夠讓我們的玻璃球反射/折射出外部的貼圖。

但如果你只關心如何實現光線追蹤,而不考慮貼圖效果,那麼這一步可以跳過。

最簡單的貼圖只需要一張照片,使用六張照片構成一個Box,將我們的玻璃球放在其中。

請直接從這裡:https://github.com/JoeyDeVries/LearnOpenGL 獲取對應的texture資源。其中提供了很多基礎的材質貼圖和場景貼圖。

將貼圖應用到物體:如果是普通的model可以直接直接texture。如果你需要將一張貼圖應用到球體/非實體平面/……等幾何結構,或許需要做一些相應的幾何變換以適應。

此外,一些其他的貼圖(如凹凸貼圖等)也進行了實現,此處暫不說明。

這是一個對於球體的典型貼圖,textureSampler2自行匯入:

uniform sampler2D textureSampler2;
intersec.colour = mix(thisSphere.colour, vec3(texture(textureSampler2,vec2(0.5+asin(intersec.normal.x)/(3.14),0.5-asin(intersec.normal.y)/(3.14)))), 0.5);

這個是對於平面的,這裡直接使用了黑白格子:

if((modx<modbase/2.0 && modz<modbase/2.0) || (modx>=modbase/2.0 && modz>=modbase/2.0))
        intersec.colour = 1.0 - thisPlane.colour;
    else
        intersec.colour = thisPlane.colour;

OpenGL 光線追蹤其三:反射(reflect)、折射(refract)和陰影(shadow)

光線追蹤就是從視點出發,逆向查詢投射進視點的光路。

這個過程是遞迴的,有對應的生成樹,每一層都對應著反射(reflect)、折射(refract)和陰影(shadow)三個操作。

實際實現中大部分邏輯在片元著色器中實現。

第一步:求交點

這裡我們只使用了球體和平面,

對於球體:顯然,解線和球體相交的方程會有兩個解,這裡我們暫且稱它們為u1和u2。這裡我們只取u2,原因是我們這裡不考慮一束光線在球體內部反覆反射/折射,只有與u1相交的那個點才是合法的。

首先判斷是否有交點(方程有解),已經交點是否合法(u1>0),在光線路徑上而不是反向延長線上。之後取對應位置的貼圖顏色。

Intersection sphereIntersection(vec3 p0, vec3 d, Sphere thisSphere) {
    
    Intersection intersec;
    vec3 ps = thisSphere.centre;
    float r = thisSphere.radius;
    vec3 delta_p = p0 - ps;
    float temp = pow(dot(d, delta_p), 2.0) - pow(length(delta_p), 2.0) + r*r;
    float u1 = -dot(d, delta_p) - sqrt(temp);
    float u2 = -dot(d, delta_p) + sqrt(temp);


    if((temp <= 0.001 || u1 <= 0.001)) {
        intersec.flag = false;
        return intersec;
    }

    // calculate the Intersection
    intersec.flag = true;
    intersec.u = u1;
    intersec.position = p0 + u1 * d;
    intersec.normal = normalize(intersec.position - ps);
    intersec.colour = thisSphere.colour;
    return intersec;
}

對於平面:與球體同理,但是隻可能有一個交點,所以只需要取唯一的交點即可。

Intersection planeIntersection(vec3 p0, vec3 d, Plane thisPlane) {
    Intersection intersec;
    vec3 p1 = thisPlane.point;
    vec3 n = thisPlane.normal;
    float u = -dot(p0-p1, n)/dot(d, n);
    if(u <= 0.001) { // add offset to avoid self-shadowing
        intersec.flag = false;
        return intersec;
    }

    // calculate the Intersection
    intersec.flag = true;
    intersec.u = u;
    intersec.position = p0 + u * d;
    intersec.normal = n;
    float modbase = 1.0;
    float modx = mod(intersec.position.x, modbase);
    float modz = mod(intersec.position.z, modbase);

    // checkerboard pattern results even-odd cross grid
    if((modx<modbase/2.0 && modz<modbase/2.0) || (modx>=modbase/2.0 && modz>=modbase/2.0))
        intersec.colour = vec3(0.7, 0.7, 0.7);
    else
        intersec.colour = vec3(0.3, 0.3, 0.3);

    return intersec;
}

這裡沒有使用貼圖,球體和平面均返回預設顏色。

第二步:找最近的交點

從第一步中返回的交點列表中選取最近的那個,若無交點則直接從該迴圈中返回。

            for(int i=0; i<sphere_num+1; i++) {
                if(intersectionList[i].flag == true) {
                    if(valid_flag == -1) {
                        closeIntersection = intersectionList[i];
                        min_u = intersectionList[i].u;
                        valid_flag = i;
                    }
                    else if(intersectionList[i].u < min_u) {
                        closeIntersection = intersectionList[i];
                        min_u = intersectionList[i].u;
                        valid_flag = i;
                    }
                    else {
                        ;
                    }
                }
            }

            if(valid_flag == -1) { // no valid intersection
                    Result[res_pos] = vec3(0.0);
                    res_pos++;
                    if(depth != maxRayTraceDepth-1) {
                        new_ray_list[now_list_size] = defalult_ray();
                        new_ray_list[now_list_size+1] = defalult_ray();
                        now_list_size += 2;
                    }
                    continue;
            }

第三步:檢測陰影

陰影的檢測方法是做該點與光源點的連線,檢查中間是否有其他的物體,如果有則設為交點。

陰影僅考慮在單個點光源下的表現形式,不考慮多點光源帶來的軟陰影(soft shadow)問題。

// Shadow
bool shadow_check(Intersection intersection) {
    // slightly move the ray origin outwards of the object along the surface normal
    vec3 p0 = vec3(lightPosition);
    vec3 d = normalize(intersection.position - vec3(lightPosition));

    float u_this = length(intersection.position - vec3(lightPosition));

    float min_u = 100000.0;
    bool flag_sphere = false;
    for(int i=0; i<sphere_num; i++) {
        Intersection intersectionSphere = sphereIntersection(p0, d, sphere[i]);
        if(intersectionSphere.flag == true) {
            flag_sphere = true;
            min_u = min(min_u, intersectionSphere.u);
            //break;
        }
    }
    Intersection intersectionPlane = planeIntersection(p0, d, plane);
    if(intersectionPlane.flag == true) {
        min_u = min(min_u, intersectionPlane.u);
    }

    if(intersectionPlane.flag == false && flag_sphere == false) {
        return false;
    }

    if(min_u < u_this-0.001) {
        return true;
    }
    return false;
}

第四步:反射/折射

反射和折射可以用glsl的內建函式實現。需要注意的是,對於反射和折射的光線佔比,可以根據物體折射率和光線角度,使用菲涅爾定律進行求解。

增加球體內部的反射和折射沒什麼必要,效能消耗過大了。

Ray Refract(Ray ray, Intersection intersection) {
    Ray new_ray;
    new_ray.p0 = intersection.position - intersection.normal*0.002; // another side
    new_ray.d = normalize(refract(ray.d, intersection.normal, 0.6));
    new_ray.intensity_k = ray.intensity_k*(1.0-k_fresnel);
    return new_ray;
}

Ray Reflect(Ray ray, Intersection intersection) {
    Ray new_ray;
    new_ray.p0 = intersection.position + intersection.normal*0.001;
    new_ray.d = normalize(reflect(ray.d, intersection.normal));
    new_ray.intensity_k = ray.intensity_k * k_fresnel;
    return new_ray;
}

影像效果

需要注意的問題:

在取交點的時候,記得沿法線外表面稍微延伸一小段距離(如0.001),否則由於浮點數精度丟失問題會出現粗糙表面。

在glsl中,由於該語言不支援遞迴,只能使用迴圈替代遞迴操作。因此需要使用額外的棧空間來進行過程中的中間值儲存,並在完成後統一累加結果。

個人實際測試,遞迴到三層就會有很好的視覺效果,且光線追蹤操作對於效能的消耗是隨著生成樹指數級遞增的,因此儘量減少額外空間的使用(否則會爆視訊記憶體),追蹤3-4次即可,實測RTX2070的最大追蹤深度在6-7層,謹慎使用過深。

final

毫無疑問這個實現效果是比較拉的,可以考慮增加菲涅爾效果/模糊陰影/抗鋸齒超取樣等等......理論確實是學了不少,但是限於工期,也不可能每一項都實現,畢竟也不打算從事圖形學,只能說是豐富一下眼界罷了。

感謝看到這裡,歡迎批評指正。

相關文章