本系列文章是對 metalkit.org 上面MetalKit內容的全面翻譯和學習.
在本系列的第二部分中,我們將學習soft shadows軟陰影.我們將使用在Raymarching in Metal
中的playground,因為它已經建立了3D
物體.讓我們建立一個基本場景,包含一個球體,一個平面,一個燈光和一個射線:
struct Ray {
float3 origin;
float3 direction;
Ray(float3 o, float3 d) {
origin = o;
direction = d;
}
};
struct Sphere {
float3 center;
float radius;
Sphere(float3 c, float r) {
center = c;
radius = r;
}
};
struct Plane {
float yCoord;
Plane(float y) {
yCoord = y;
}
};
struct Light {
float3 position;
Light(float3 pos) {
position = pos;
}
};
複製程式碼
下一步,我們建立幾個distance operation距離運算
函式來幫助我們確定元素到場景之間的距離:
float unionOp(float d0, float d1) {
return min(d0, d1);
}
float differenceOp(float d0, float d1) {//差集
return max(d0, -d1);
}
float distToSphere(Ray ray, Sphere s) {
return length(ray.origin - s.center) - s.radius;
}
float distToPlane(Ray ray, Plane plane) {
return ray.origin.y - plane.yCoord;
}
複製程式碼
下一步,我們建立一個distanceToScene() 函式,它給出場景中到任意物體的最近距離.我們用這些函式來生成一個形狀,它看起來像是一個帶有幾個洞的空心球體:
float distToScene(Ray r) {
Plane p = Plane(0.0);
float d2p = distToPlane(r, p);
Sphere s1 = Sphere(float3(2.0), 1.9);
Sphere s2 = Sphere(float3(0.0, 4.0, 0.0), 4.0);
Sphere s3 = Sphere(float3(0.0, 4.0, 0.0), 3.9);
Ray repeatRay = r;
repeatRay.origin = fract(r.origin / 4.0) * 4.0;
float d2s1 = distToSphere(repeatRay, s1);
float d2s2 = distToSphere(r, s2);
float d2s3 = distToSphere(r, s3);
float dist = differenceOp(d2s2, d2s3);
dist = differenceOp(dist, d2s1);
dist = unionOp(d2p, dist);
return dist;
}
複製程式碼
目前我們寫的都是舊程式碼,只是對Raymarching文章中的重構.讓我們談談normals法線及為什麼需要法線.如果我們有一個平板-比如我們的平面-它的法線總是(0,1,0)
也就是指向上方.本例中卻很繁瑣.法線在3D
空間是一個float3
而且我們需要知道它在射線上的位置.假設射線剛好接觸到球體的左側.法線應是(-1,0,0)
,就是指向左邊並遠離球體.如果射線稍稍移動到該點的右邊,在球體內部(如-0.001
).如果射線稍稍移動到該點的左邊,在球體外部(如0.001
).如果我們從左邊減去左邊得到-0.001 - 0.001 = -0.002
它指向左邊,所以這就是我們法線的x
座標.然後對y
和z
重複同樣操作.我們使用一個名為eps的2D
向量,來讓向量調和vector swizzling更容易操作,每次都使用選定的值0.001
作為各個座標值:
float3 getNormal(Ray ray) {
float2 eps = float2(0.001, 0.0);
float3 n = float3(distToScene(Ray(ray.origin + eps.xyy, ray.direction)) -
distToScene(Ray(ray.origin - eps.xyy, ray.direction)),
distToScene(Ray(ray.origin + eps.yxy, ray.direction)) -
distToScene(Ray(ray.origin - eps.yxy, ray.direction)),
distToScene(Ray(ray.origin + eps.yyx, ray.direction)) -
distToScene(Ray(ray.origin - eps.yyx, ray.direction)));
return normalize(n);
}
複製程式碼
最後,我們已經準備好看到圖形了.我們再次使用Raymarching
程式碼,放在已經新增了法線的核心函式的末尾,這樣我們就可以給每個畫素插值出顏色:
kernel void compute(texture2d<float, access::write> output [[texture(0)]],
constant float &time [[buffer(0)]],
uint2 gid [[thread_position_in_grid]]) {
int width = output.get_width();
int height = output.get_height();
float2 uv = float2(gid) / float2(width, height);
uv = uv * 2.0 - 1.0;
uv.y = -uv.y;
Ray ray = Ray(float3(0., 4., -12), normalize(float3(uv, 1.)));
float3 col = float3(0.0);
for (int i=0; i<100; i++) {
float dist = distToScene(ray);
if (dist < 0.001) {
col = float3(1.0);
break;
}
ray.origin += ray.direction * dist;
}
float3 n = getNormal(ray);
output.write(float4(col * n, 1.0), gid);
}
複製程式碼
如果你現在執行playground你將看到類似的影像:
現在我們有了法線,我們可以用lighting() 函式來計算場景中每個畫素的光照.首先我們需要知道燈光的方向(lightRay光線
),我們用規範化的燈光位置和當前射線來取得燈光方向.對diffuse漫反射光照我們需要知道法線和光線間的角度,也就是兩者的點積.對specular高光光照我們需要在表面進行反射,它們依賴於我們尋找的角度.不同之處在於,本例中,我們首先發射一個射線到場景中,從表面反射回來,再測量反射線和lightRay光線
間的角度.然後對這個值進行一個高次乘方運算來讓它更銳利.最後我們返回混合光線:
float lighting(Ray ray, float3 normal, Light light) {
float3 lightRay = normalize(light.position - ray.origin);
float diffuse = max(0.0, dot(normal, lightRay));
float3 reflectedRay = reflect(ray.direction, normal);
float specular = max(0.0, dot(reflectedRay, lightRay));
specular = pow(specular, 200.0);
return diffuse + specular;
}
複製程式碼
在核心函式中用下面幾行替換最後一行:
Light light = Light(float3(sin(time) * 10.0, 5.0, cos(time) * 10.0));
float l = lighting(ray, n, light);
output.write(float4(col * l, 1.0), gid);
複製程式碼
如果你現在執行playground你將看到類似的影像:
下一步,陰影!我們幾乎從本系列的第一部分就開始使用shadow() 函式到現在,只做過少許修改.我們規範化燈光方向(lightDir
),並在步進射線時不斷更新disAlongRay
:
float shadow(Ray ray, Light light) {
float3 lightDir = light.position - ray.origin;
float lightDist = length(lightDir);
lightDir = normalize(lightDir);
float distAlongRay = 0.01;
for (int i=0; i<100; i++) {
Ray lightRay = Ray(ray.origin + lightDir * distAlongRay, lightDir);
float dist = distToScene(lightRay);
if (dist < 0.001) {
return 0.0;
break;
}
distAlongRay += dist;
if (distAlongRay > lightDist) { break; }
}
return 1.0;
}
複製程式碼
用下面幾行替換核心函式中的最後一行:
float s = shadow(ray, light);
output.write(float4(col * l * s, 1.0), gid);
複製程式碼
如果你現在執行playground你將看到類似的影像:
讓我們給場景新增點soft shadows軟陰影
.在現實生活中,離物體越遠陰影散佈越大.例如,如果地板上有個立方體,在立方體的頂點我們得到清晰的陰影,但離立方體遠的地方看起來像一個模糊的陰影.換句話說,我們從地板上的某點出發,向著燈光前進,要麼撞到要麼錯過.硬陰影很簡單:我們撞到了什麼東西,這個點主在陰影中.軟陰影則處於兩者之間.用下面幾行更新shadow() 函式:
float shadow(Ray ray, float k, Light l) {
float3 lightDir = l.position - ray.origin;
float lightDist = length(lightDir);
lightDir = normalize(lightDir);
float eps = 0.1;
float distAlongRay = eps * 2.0;
float light = 1.0;
for (int i=0; i<100; i++) {
Ray lightRay = Ray(ray.origin + lightDir * distAlongRay, lightDir);
float dist = distToScene(lightRay);
light = min(light, 1.0 - (eps - dist) / eps);
distAlongRay += dist * 0.5;
eps += dist * k;
if (distAlongRay > lightDist) { break; }
}
return max(light, 0.0);
}
複製程式碼
你會注意到,我們這次從白色(1.0
)燈光開始,通過使用一個衰減器(k)來得到不同的(中間的)燈光值.eps變數告訴我們當光線進入場景中時beam波束有多寬.窄波束意味著銳利的陰影,而寬波束意味著軟陰影.我們從小distAlongRay
到大開始,不然的話該點所在的曲面會投射陰影到自己身上.然後我們像硬陰影中那樣沿射線前進,並得到離場景的距離,之後我們從eps
(beam width波束寬度)中減掉dist
併除以eps
.這樣給出了波束覆蓋的百分比.如果我們顛倒它(1 - beam width
)就得到了處於燈光中的百分比.當我們沿著射線前進時,我們取這個新的值和light
值中的最小值,來讓陰影保持最黑.然後再沿射線前進,並根據行進距離均勻地增加beam width波束寬度,並縮放k
倍.如果超過了燈光,就跳出迴圈.最後,我們想要避免給燈光一個負值,所以我們返回0.0和燈光值之間的最大值.現在讓我們用新的shadow()
函式來改寫核心函式:
float3 col = float3(1.0);
bool hit = false;
for (int i=0; i<200; i++) {
float dist = distToScene(ray);
if (dist < 0.001) {
hit = true;
break;
}
ray.origin += ray.direction * dist;
}
if (!hit) {
col = float3(0.5);
} else {
float3 n = getNormal(ray);
Light light = Light(float3(sin(time) * 10.0, 5.0, cos(time) * 10.0));
float l = lighting(ray, n, light);
float s = shadow(ray, 0.3, light);
col = col * l * s;
}
Light light2 = Light(float3(0.0, 5.0, -15.0));
float3 lightRay = normalize(light2.position - ray.origin);
float fl = max(0.0, dot(getNormal(ray), lightRay) / 2.0);
col = col + fl;
output.write(float4(col, 1.0), gid);
複製程式碼
注意我們切換到了預設的白色.然後我們新增一個布林值叫hit,它來告訴我們碰撞到物體沒有.我們限定當我們到場景的距離在0.001之內就是碰撞了,如果我們沒有碰到任何東西,則用灰色著色,否則確定陰影的數值.在最後我們只需要在場景前面新增另一個(固定的)光源,就能看到陰影的更多細節.如果你現在執行playground你將看到類似的影像:
要看這份程式碼的動畫效果,我在下面使用一個Shadertoy
嵌入式播放器.只要把滑鼠懸浮在上面,並單擊播放按鈕就能看到動畫:<譯者注:這裡不支援嵌入播放器,我用gif代替https://www.shadertoy.com/embed/XltSWf>
下次見!