Computer Graphics/VulkanPT

pt - 15. rchit shader, rmiss shader

surkim 2025. 8. 14. 12:25

앞에서 메모리 구조와 rgen shader를 설명했었다.

rgen shader에서 레이를 쏘면

맞지 않으면 -> rmiss

맞으면 -> rchit shader로 가고

이 때 정보를 주고 받는 메모리는

raypayload 구조체로 정의된다.

struct RayPayload {
	vec3 L;
	vec3 beta;
	vec3 nextOrigin;
	vec3 nextDir;
	int bounce;
	uint seed;
	int terminated;
	float pdf;
};


layout(location = 0) rayPayloadInEXT RayPayload payload;

내가 정의한 RayPayload 구조체이다.

 

레이에 맞거나 맞지 않거나

rchit 나 rmiss 셰이더로 들어오고 이 구조체의 값을 변경할 수 있어

그 셰이더의 입출력이 되는것이다.

당연하게도 셰이더로 들어오는 구조체인 rayPayloadInEXT는 무조건 하나여야한다는게 중요하다

rchit 셰이더 내부에서도 raypayload구조체를 만들어 자체적으로 레이를 쏠 수 있다. (shadow 판별할 때 썼다.)

 

이건 중요한 부분이라 다시 정리하자면

.rgen shader

	payload.L = vec3(0.0);
	payload.beta = vec3(1.0);
	payload.nextOrigin = origin;
	payload.nextDir = dir;
	payload.bounce = 0;
	payload.seed = initRandom(size, pixel, options.frameCount);
	payload.terminated = 0;


	for (int i = 0; i < 16; ++i) {
		payload.bounce = i;// 이때 payload는 초기화 상태
		traceRayEXT(topLevelAS, gl_RayFlagsOpaqueEXT, 0xFF, 0, 0, 0,
					origin, 0.0001, dir, 1e30, 0);
		// 함수를 거친후 payload는 안의 값이 rchit or rmiss 셰이더로 인해 변경된 상태
		if (payload.terminated != 0) break;

		if (i > 2) {
			float p = clamp(max(payload.beta.r, max(payload.beta.g, payload.beta.b)), 0.05, 1.0);
			if (rand(payload.seed) > p) {
				break;
			}
			payload.beta /= p;
		}

		origin = payload.nextOrigin;
		dir = payload.nextDir;
	}

 

여기서 traceRayEXT 함수로 레이를 쏘면 raypayload구조체가 rchit나 rmiss로 가게 되고

그 구조체 내부의 변수를 셰이더 내부에서 변경하여

다시 rgen 셰이더로 왔을 때 그 값이 변경되는 구조이다.

 

구조 설명했으니 셰이더 코드를 봐보자

 

.rmiss

void main() {
    payload.terminated = 1;
}

miss는 쉬운게 그냥 terminated를 1로 조정하여 끝내버렸다.

 

광원까지 무조건 가야 계산되기 때문에 초기에 miss가 나든 몇번 튕겨 miss나든 똑같다.

 

.rchit

우선 메인 흐름부터

void main() {
    uint instanceID = gl_InstanceCustomIndexEXT;
    InstanceGPU instance = instances[instanceID];

    vec3 N, P;
    computeHitNormal(N, P);
    vec3 wo = -normalize(gl_WorldRayDirectionEXT);
    
    if (instance.lightIndex >= 0) {
        AreaLightGPU light = areaLights[instance.lightIndex];

        vec3 lightNormal = normalize(light.normal);

        if (dot(lightNormal, wo) < 0) {
            payload.terminated = 1;
            return ;
        }

        if (payload.bounce == 0) {
            payload.L = light.color;
            payload.terminated = 1;
            return ;
        }

        if (payload.bounce > 2) {
            payload.L += light.color * light.intensity * payload.beta;
            payload.terminated = 1;
            return ;
        }

        vec3 dir = P - gl_WorldRayOriginEXT;
        float dist = length(dir);
        float dist2 = dist * dist;
        vec3 L_wi = normalize(dir);

        float cosTheta = max(dot(lightNormal, -L_wi), 0.001);
        float areaPdf = 1.0 / light.area;
        float solidAnglePdf = dist2 / (cosTheta + 0.001) * areaPdf;
        float L_pdf = solidAnglePdf / float(options.lightCount);

        float w = L_pdf / (L_pdf + payload.pdf);

        payload.L += light.color * light.intensity * payload.beta * w;
        payload.terminated = 1;
        return;
    }

    // if (dot(N, wo) < 0) {
    //     payload.terminated = 1;
    //     return ;
    // }

    int matIdx = instance.materialIndex;
    MaterialGPU mat = materials[matIdx];


    vec2 uv = getUV();
    mat = copyMaterial(mat, uv);

    payload.beta *= mat.ao;

    if (payload.bounce == 0)
        sampleDirect(N, P, wo, mat);

    sampleIndirect(N, P, wo, mat);
}

 

한줄 한줄 설명 들어간다.

    uint instanceID = gl_InstanceCustomIndexEXT;
    InstanceGPU instance = instances[instanceID];

이건 TLAS 생성할 때 BLAS 인스턴스에 붙인 커스텀 정의값을 불러오는 함수이다. 

다시 말해 rchit 셰이더에 들어왔으면 어디에든 레이가 오브젝트에 맞았다는 뜻이고 

그 오브젝트는 blas로 정의되어있고

그 blas 정의할 때 커스텀 인덱스를 부여해놔서 이 인스턴스가 어떤 인스턴스인지 확인하는 단계

 

    vec3 N, P;
    computeHitNormal(N, P);
    vec3 wo = -normalize(gl_WorldRayDirectionEXT);

맞은 지점과 그 지점의 표면의 법선 벡터를 계산한다.

wo는 표면에 들어온 레이 벡터 계산이다. gl_ 붙여있는거는 내장 변수들이다.

    if (instance.lightIndex >= 0) {
        AreaLightGPU light = areaLights[instance.lightIndex];

        vec3 lightNormal = normalize(light.normal);

        if (dot(lightNormal, wo) < 0) {
            payload.terminated = 1;
            return ;
        }

        if (payload.bounce == 0) {
            payload.L = light.color;
            payload.terminated = 1;
            return ;
        }

        if (payload.bounce > 2) {
            payload.L += light.color * light.intensity * payload.beta;
            payload.terminated = 1;
            return ;
        }

        vec3 dir = P - gl_WorldRayOriginEXT;
        float dist = length(dir);
        float dist2 = dist * dist;
        vec3 L_wi = normalize(dir);

        float cosTheta = max(dot(lightNormal, -L_wi), 0.001);
        float areaPdf = 1.0 / light.area;
        float solidAnglePdf = dist2 / (cosTheta + 0.001) * areaPdf;
        float L_pdf = solidAnglePdf / float(options.lightCount);

        float w = L_pdf / (L_pdf + payload.pdf);

        payload.L += light.color * light.intensity * payload.beta * w;
        payload.terminated = 1;
        return;
    }

인스턴스로 확인해봤을 때 표면에 맞은 부분이 광원일 때 계산이다.

path tracing이 진행될 때마다 payload의 beta부분이 계속 갱신되는데

광원에 맞았을 때 그 광원의 정보를 가져와

지금까지 계산했던 throughput(beta)와 계산하여 최종 샘플 색인 L을 구하는 것이다.

 

딱 bounce가 1일때만 휴리스틱 벨런스(w)를 적용해줘야하는데

처음 바운스된곳에서 NEE를 딱 한번만 적용시켰기 때문이다.

raypayload에 많은 정보를 넣을 수가 없어서 광원에 맞았을 때 레이가 발사된 지점을 역산해서 pdf를 구해줘야했다.

 

    int matIdx = instance.materialIndex;
    MaterialGPU mat = materials[matIdx];


    vec2 uv = getUV();
    mat = copyMaterial(mat, uv);

    payload.beta *= mat.ao;

    if (payload.bounce == 0)
        sampleDirect(N, P, wo, mat);

    sampleIndirect(N, P, wo, mat);

맞은 표면의 material을 찾아서 처음 튕겼을 때는 직접광도 계산하고(NEE)

그 이후부터는 간접광만 계산한다.

 

간접광 계산부터 한번 봐보자

void sampleIndirect(in vec3 N, in vec3 P, in vec3 wo, in MaterialGPU mat)
{
    vec3 baseColor = mat.baseColor.rgb;
    vec3 Kd = baseColor * (1.0 - mat.metallic);
    vec3 F0 = mix(vec3(0.04), baseColor, mat.metallic);
    float probSpec = max(max(F0.r, F0.g), F0.b);

    vec3 H = vec3(0.0);
    vec3 wi = vec3(0.0);
    vec3 f = vec3(0.0);
    float pdf = 1.0;
    float tpdf = 1.0;
    
    int sampledSpecular = rand(payload.seed) < probSpec ? 1 : 0;

    if (sampledSpecular != 0) {
        vec2 Xi = sample2D(payload.seed);
        H = importanceSampleGGX(Xi, N, mat.roughness);
        wi = normalize(reflect(-wo, H));
    } else {
        vec3 localWi = cosineSampleHemisphere(payload.seed);
        wi = normalize(toWorld(localWi, N));
        H = normalize(wo + wi);
    }

    float NdotL = max(dot(N, wi), 0.001);
    float NdotV = max(dot(N, wo), 0.001);
    float NdotH = max(dot(N, H), 0.001);
    float VdotH = max(dot(wo, H), 0.001);

    if (sampledSpecular == 1 && dot(wo, N) * dot(wi, N) < 0.0) {
        payload.terminated = 1;
        return;
    }

    float D = distributionGGX(N, H, mat.roughness);
    float G = geometrySmith(N, wo, wi, mat.roughness);
    vec3 F_refl = fresnelSchlick(VdotH, F0);

    vec3 specularf = (D * G * F_refl) / max(4.0 * NdotV * NdotL, 1e-4);
    vec3 diffusef = Kd / PI;

    float pdf_diff = NdotL / PI * (1.0 - probSpec);
    float pdf_spec = D * NdotH / (4.0 * VdotH + 1e-4) * probSpec;

    float sumPdf = pdf_diff + pdf_spec;
    float misWeight = (sampledSpecular != 0) ? (pdf_spec / sumPdf) : (pdf_diff / sumPdf);

    f = (sampledSpecular != 0) ? specularf * misWeight : diffusef * misWeight;
    pdf = (sampledSpecular != 0) ? pdf_spec : pdf_diff;

    payload.beta *= f * NdotL / max(pdf, 1e-4);
    payload.nextOrigin = P + wi * 0.001;
    payload.nextDir = wi;
    payload.terminated = 0;
    payload.pdf = pdf;
}

 

    vec3 baseColor = mat.baseColor.rgb;
    vec3 Kd = baseColor * (1.0 - mat.metallic);
    vec3 F0 = mix(vec3(0.04), baseColor, mat.metallic);
    float probSpec = max(max(F0.r, F0.g), F0.b);

    vec3 H = vec3(0.0);
    vec3 wi = vec3(0.0);
    vec3 f = vec3(0.0);
    float pdf = 1.0;
    float tpdf = 1.0;

값들 초기화해주는 부분

 

    int sampledSpecular = rand(payload.seed) < probSpec ? 1 : 0;

    if (sampledSpecular != 0) {
        vec2 Xi = sample2D(payload.seed);
        H = importanceSampleGGX(Xi, N, mat.roughness);
        wi = normalize(reflect(-wo, H));
    } else {
        vec3 localWi = cosineSampleHemisphere(payload.seed);
        wi = normalize(toWorld(localWi, N));
        H = normalize(wo + wi);
    }

레이의 출력방향 샘플링해주는 부분이다.

여기서 중요한 점은 metallic성분으로 만든 probSpec 변수를 통해 출력 방향 로브를 랜덤으로 선택하는 부분이다.

이 확률은 나중에 pdf 계산할 때 꼭 들어가야하므로 기억해두고

확률에 따라서 디퓨즈 로브를 선택할지 스페큘러 로브를 선택할지 정한다.

예시로서 딱 좋은 이미지

 

 

    float NdotL = max(dot(N, wi), 0.001);
    float NdotV = max(dot(N, wo), 0.001);
    float NdotH = max(dot(N, H), 0.001);
    float VdotH = max(dot(wo, H), 0.001);

    if (sampledSpecular == 1 && dot(wo, N) * dot(wi, N) < 0.0) {
        payload.terminated = 1;
        return;
    }

계산을 위한 사전 작업과

출력방향이 혹여 표면 반구의 반대방향이 나올 수도 있는 것을 방지하는 코드

 

    float D = distributionGGX(N, H, mat.roughness);
    float G = geometrySmith(N, wo, wi, mat.roughness);
    vec3 F_refl = fresnelSchlick(VdotH, F0);

    vec3 specularf = (D * G * F_refl) / max(4.0 * NdotV * NdotL, 1e-4);
    vec3 diffusef = Kd / PI;

ggx 마이크로패싯 계산 + diffuse 램버트 계산

 

    float pdf_diff = NdotL / PI * (1.0 - probSpec);
    float pdf_spec = D * NdotH / (4.0 * VdotH + 1e-4) * probSpec;

레이를 결정했을 때 이걸 선택한 확률을 구해야한다.

여기서 중요한 점은 디퓨즈 스페큘러 로브를 probSpec 변수로 나눴으니 이것도 꼭 곱해서 적용해준다.

 

    float sumPdf = pdf_diff + pdf_spec;
    float misWeight = (sampledSpecular != 0) ? (pdf_spec / sumPdf) : (pdf_diff / sumPdf);

    f = (sampledSpecular != 0) ? specularf * misWeight : diffusef * misWeight;
    pdf = (sampledSpecular != 0) ? pdf_spec : pdf_diff;

구한 pdf로 밸런스 휴리스틱 계산

f와 pdf 결정

 

    payload.beta *= f * NdotL / max(pdf, 1e-4);
    payload.nextOrigin = P + wi * 0.001;
    payload.nextDir = wi;
    payload.terminated = 0;
    payload.pdf = pdf;

throughput 업데이트와 다음 바운스 세팅을 하면 끝

 

직접광계산은 비슷하지만 다른부분이 있다.

void sampleDirect(in vec3 N, in vec3 P, in vec3 wo, in MaterialGPU mat)
{
    vec3 Kd = mat.baseColor.rgb * (1.0 - mat.metallic);
    vec3 F0 = mix(vec3(0.04), mat.baseColor.rgb, mat.metallic);
    float probSpec = max(max(F0.r, F0.g), F0.b);

    // Light sampling
    int lightIdx = int(mod(rand(payload.seed) * float(options.lightCount), float(options.lightCount)));
    AreaLightGPU light = areaLights[lightIdx];

    // sample point on quad (light.p0~p3)
    float u = rand(payload.seed);
    float v = rand(payload.seed);
    vec3 sampledPos;
    if (u + v <= 1.0) {
        sampledPos = light.p0 * (1.0 - u - v) + light.p1 * u + light.p2 * v;
    } else {
        u = 1.0 - u;
        v = 1.0 - v;
        sampledPos = light.p2 * (1.0 - u - v) + light.p3 * u + light.p0 * v;
    }

    vec3 lightNormal = normalize(light.normal);
    vec3 dir = sampledPos - P;
    float dist = length(dir);
    float dist2 = dist * dist;
    vec3 L_wi = normalize(dir);

    float cosTheta = max(dot(lightNormal, -L_wi), 0.001);
    float areaPdf = 1.0 / light.area;
    float solidAnglePdf = dist2 / (cosTheta + 0.001) * areaPdf;
    float L_pdf = solidAnglePdf / float(options.lightCount);

    // Shadow test
    isShadowed = true;
    traceRayEXT(topLevelAS, 
        gl_RayFlagsTerminateOnFirstHitEXT | gl_RayFlagsOpaqueEXT | gl_RayFlagsSkipClosestHitShaderEXT,
        0xFF, 0, 0, 1, 
        P + N * 0.0001, 0.0001, L_wi, dist * 0.99, 1);

    if (isShadowed || dot(L_wi, N) < 0.0 || L_pdf <= 0.0)
        return;

    // BRDF evaluation
    vec3 H = normalize(wo + L_wi);

    float NdotL = max(dot(N, L_wi), 0.001);
    float NdotV = max(dot(N, wo), 0.001);
    float NdotH = max(dot(N, H), 0.001);
    float VdotH = max(dot(wo, H), 0.001);

    float D = distributionGGX(N, H, mat.roughness);
    float G = geometrySmith(N, wo, L_wi, mat.roughness);
    vec3 F  = fresnelSchlick(VdotH, F0);

    vec3 specularf = (D * G * F) / max(4.0 * NdotV * NdotL, 0.001);
    vec3 diffusef = Kd / PI;

    float pdf_diff = NdotL / PI * (1.0 - probSpec);
    float pdf_spec = D * NdotH / (4.0 * VdotH + 0.001) * probSpec;

    vec3 f = specularf + diffusef;
    float pdfBRDF = max(pdf_diff + pdf_spec, 0.001);

    // Final contribution with MIS weight
    vec3 direct = (f * light.color * light.intensity * NdotL) / L_pdf;
    float w = L_pdf / (L_pdf + pdfBRDF);
    payload.L += payload.beta * direct * w;
}

 

계산은 비슷하니 전체적으로 설명하자면

직접광은 광원을 직접 샘플링해야하기 때문에

광원 인덱스에서 랜덤으로 하나를 뽑았다.

 

원래 가까운 광원이나 더 영향을 많이 줄 것 같은 광원에 확률를 높게주는 기법이 있긴한데

나는 그냥 고른 분포로 뽑게 했다.

 

뽑은 광원의 한 지점을 결정하여 레이의 방향을 결정하고 레이를 한번 쏴서 그림자인지 아닌지 판단한다.

그림자가 아니라고 판단되면 (광원에 레이를 쏴서 그 중간에 아무것도 맞지 않으면)

그 광원에 대한 brdf를 평가한다.

 

이때는 보통의 광원 계산과 같이 diffuse와 specular를 같이 평가하여 더하고

MIS 보정을 위해 광원 선택확률과 brdf평가 확률로 보정계수 w를 계산하고 직접광 계산에 곱해주면 끝!