앞에서 메모리 구조와 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를 계산하고 직접광 계산에 곱해주면 끝!
'Computer Graphics > VulkanPT' 카테고리의 다른 글
| pt - 14. gltf를 지원하는 path tracing 렌더러 (3) | 2025.08.12 |
|---|---|
| pt - 13. svgf 폐기 (3) | 2025.08.12 |
| pt - 12. taa 적용 (0) | 2025.05.19 |
| pt - 11. svgf 로직 수정 (샘플 수 적을 때) (0) | 2025.05.16 |
| pt - 10. NEE 계산 수정, svgf 디모듈레이션 수정, 필터에서 거울만 수정 (1) | 2025.05.15 |