20250502
.pbrt 파일내 blackbody L로 지정된 광원에서
파서에서 파싱된 결과가 조단위 이상으로 매우 크게 출력 되었었습니다.
[bathroom.pbrt]
AttributeBegin
AreaLightSource "diffuse" "blackbody L" [6500 100 ] #"color L" [2500 2500 2500]
Shape "plymesh" "string filename" "geometry/mesh_00062.ply"
AttributeEnd
[파싱 결과]
4400002063400960.0 4162397761699840.0 4331947501289472.0
초기에는 해당 값을 파서의 잘못된 계산, 보정치가 잘못 들어간 줄 알고 수치 보정을 적용했었습니다.
float Y_norm = xyz[1];
if (Y_norm > 0.0f) {
xyz[0] /= Y_norm;
xyz[1] = 1.0f;
xyz[2] /= Y_norm;
}
그러나 이후 이 값이 단위 문제일 수 있다는 피드백을 받고
계산 과정과 물리 단위의 의미를 검토한 결과
이 수치는 Planck 복사 법칙 기반의 정확한 물리량임을 확인하였습니다.
초기 제가 보정식을 만들 때는 Radiance의 Y 성분으로 정규화한 뒤 L을 곱하는 방식이었는데
이 과정에서는 Radiance [W / (sr · m²)] 단위 내 포함되어야 할 광원 면적 요소가 누락되었기 때문에
색상은 유지되더라도, 광원의 실제 면적에 따라 달라져야 할 상대 에너지 비율이 깨지는 문제가 발생했습니다.
보정치를 면적을 포함한 복사휘도의 절대량을 고려한 방식으로 다시 계산하거나
값 자체를 그대로 쓰고 후처리로 tone mapping 및 자동 exposure 보정을 통해 바꾸는 방식 중에
후자를 선택하여 보정하려고 합니다.
.pbrt 파일에서는 plastic, substrate, uber 등의 재질이 디퓨즈 + 스펙큘러 구성의 BRDF 혼합 모델을 따르고 있습니다.
초기 구현에서는 이들 재질에 대해
- 샘플링 방향은 임의의 확률 (5:5) 로 diffuse/specular를 선택했고,
- BRDF f와 pdf 또한 5대 5 가중치로 단순 평균 했었습니다.
이제는 Fresnel 기반으로 샘플 선택으로 조정했습니다.
샘플 분기 시 rand < F_spec 조건을 사용해,
Fresnel 계수 F = Schlick(V⋅H, F₀)에 따라 디퓨즈/스펙큘러 중 하나를 선택했습니다.
Throughput 계산 시 pdf 보정
선택된 경로의 pdf 뿐만 아니라
전체 혼합 분포의 pdf = pdf_diff + pdf_spec 를 통해
f / pdf_total 구조를 유지하였습니다.
Ks를 그대로 Fresnel F₀로 사용했던 초기 구조를 수정하고,
굴절률 η 기반 F₀ = ((η−1)/(η+1))²으로 계산한 뒤 Ks를 곱해 최종 F₀를 구성합니다.
이러한 구조 개선을 통해:
- 반사 세기, 샘플링 비율, throughput 계산 간의 일관성 확보
- 특히 우버 재질의 바닥 반사량이 비정상적으로 커지던 문제 해결했습니다.
- 샘플 누적 시 어두워지는 문제는 BRDF 문제가 아니라 이미지 포맷 설정 문제였습니다.
→ VK_FORMAT_R16G16B16A16_SFLOAT 포맷에서 float 누적 시 오버플로우 발생 → 밝기가 비정상적으로 감소
→ 포맷을 R32G32B32A32_SFLOAT로 교체 후 해결 - NaN 값 문제도 동일 원인으로 확인됩니다.
→ 감쇠된 throughput 또는 누적된 조도 값이 float 범위/정밀도를 벗어나면서 INF/NaN 발생했습니다.
밑은 blackbody 관련 내용 정리입니다.
Radiance
단위: W / (sr · m²)
단위 면적당 단위 입체각 방향으로 방출되는 방사 에너지량
Planck 복사 법칙은 특정 온도 T를 갖는 흑체(blackbody)가
파장 λ에서 얼마만큼의 에너지를 방출하는지를 계산하는 물리 법칙입니다.
여기서 λ(람다)는 파장이며,
보통 400nm ~ 700nm의 가시광선 범위를 일정 간격으로 샘플링하여 사용합니다.
이 샘플된 각 λ에 대해 Planck 수식을 적용해 스펙트럼 Radiance를 계산하고
해당 값에 CIE XYZ 민감도 곡선을 곱하여 적분함으로써 색 정보를 포함한 XYZ Radiance를 구합니다.
샘플 수 및 Y 정규화 계수에 따른 보정 계수를 적용합니다.
여기서 CIE Y함수 전체적분값을 미리 계산해 놓은 것으로 사용한다 합니다.
그 후 XYZ를 RGB로 선형 변환하고, 최종적으로 사용자 정의 밝기 스케일을 곱하여 최종 RGB Radiance가 완성됩니다.
정리하면
- Planck 복사 법칙으로 λ별 Spectral Radiance 계산
- 각 λ에 대해 CIE x̄(λ), ȳ(λ), z̄(λ) 민감도 곡선을 곱하고 수치 적분
- 샘플 수 및 Y 정규화 계수에 따른 보정 계수 적용
- XYZ → RGB 선형 변환
- 마지막으로 밝기 스케일 L을 곱함
밑은 파서의 blackbody_to_rgb 함수입니다.
책에 나온 공식 그대로를 사용하고 틀린 건 저였습니다.
// 입력 예시) blackbody[0] = 2700, blackbody[1] = 10 [2700, 10]
// 출력 xyz[3]
static void blackbody_to_xyz(const float blackbody[2], float xyz[3])
{
const float c = 299792458.0f;
const float h = 6.62606957e-34f;
const float kb = 1.3806488e-23f;
float t = blackbody[0]; // temperature in Kelvin
xyz[0] = 0.0f;
xyz[1] = 0.0f;
xyz[2] = 0.0f;
for (int i = 0; i < nSpectralSamples; i++) {
float wl = lerp(float(i) / float(nSpectralSamples), sampledLambdaStart, sampledLambdaEnd);
// Calculate `Le`, the amount of light emitted at wavelength = `wl`.
// `wl` is deliberately chose to match the wavelengths at which the X, Y
// and Z curves are sampled.
float l = wl * 1e-9f;
float lambda5 = (l * l) * (l * l) * l;
float Le = (2.0f * h * c * c) / (lambda5 * (std::exp((h * c) / (l * kb * t)) - 1));
xyz[0] += X[i] * Le;
xyz[1] += Y[i] * Le;
xyz[2] += Z[i] * Le;
}
float scale = float(sampledLambdaEnd - sampledLambdaStart) / float(CIE_Y_integral * nSpectralSamples);
xyz[0] *= scale;
xyz[1] *= scale;
xyz[2] *= scale;
// 여기서 자체적으로 보정을 적용했었습니다.
// float Y_norm = xyz[1];
// if (Y_norm > 0.0f) {
// xyz[0] /= Y_norm;
// xyz[1] = 1.0f;
// xyz[2] /= Y_norm;
// }
// else {
// xyz[0] = 0.0f;
// xyz[1] = 0.0f;
// xyz[2] = 0.0f;
// }
float L = blackbody[1];
xyz[0] *= L;
xyz[1] *= L;
xyz[2] *= L;
}
static void blackbody_to_rgb(const float blackbody[2], float rgb[3])
{
float xyz[3];
blackbody_to_xyz(blackbody, xyz);
xyz_to_rgb(xyz, rgb);
}