UE4 후기에서 DX11(SM5)은 Compute Shader로 고정된 파이프라인의 틀을 느슨하게 만들었습니다. 반면 Tessellation은 더 많은 기하 디테일을 가능하게 했지만 성능, 크랙, 편차라는 숙제를 남겼습니다.
UE5는 DX12 시대의 조건에서 "성능 손실 없이 더 많은 디테일을, 그리고 완전한 동적 라이팅을" 이라는 도전을 하게 됩니다. 언제나 핵심은 구조 변화입니다. DX12는 GPU 작업을 드라이버가 자동 최적화해주던 시대에서 엔진이 명시적으로 자원을 배치하고 스케줄링하는 시대로 전환시켰습니다. 그렇게 Nanite와 Lumen은 이 전환을 전제로 설계되었습니다. 그 결과, 렌더링 파이프라인의 고정된 경계가 이전보다 훨씬 약해질 수 있었습니다.
UE4에서 남은 숙제: 디테일과 라이팅
UE4 후기의 DX11(SM5)은 Compute Shader를 통해 고정 파이프라인 외 연산 모델을 제공했고, Tessellation은 기하 디테일을 실시간으로 늘릴 수 있게 했습니다. 하지만 이 두 축은 어디까지나 가능성이었지 기본값은 아니었습니다. Tessellation은 성능 비용이 컸고, 크랙과 균열 문제와 하드웨어 편차도 존재했습니다. 또한 동적 라이팅은 여전히 제작 파이프라인에서 타협이 요구되는 영역이었습니다.
UE5는 이 숙제를 DX12 시대의 운영방식 위에서 다시 설계하였습니다.
많은 최적화가 드라이버 레이어에서 흡수되었던 DX11과는 달리 DX12에서는 자원 상태 전이(배리어), 파이프라인 상태(PSO), 리소스 바인딩 같은 비용을 엔진이 더 직접 관리하는 방향으로 움직였습니다. 이 변화는 기능 하나의 추가라기보다는 렌더러 설계가 CPU와 GPU의 협업(Scheduling, Batch, Caching) 중심으로 이동했다는 의미가 큽니다.
여기에 SM6의 Wave Intrinsics는 같은 웨이브(실행 그룹) 안의 lane들이 데이터를 교환 / 집계하는 연산을 가능하게 해 컬링이나 희소 데이터 패킹 같은 알고리즘에서 유리한 도구가 되었습니다. UE5의 바로 이런 Compute 중심, 데이터 지향 설계가 전제된 시대에 맞춰 Nanite와 Lumen가 탄생하게 되었습니다.
Nanite: 기하 디테일 문제를 바꾸다.
Epic Games, 나나이트 가상화된 지오메트리에서 발췌
에픽게임즈는 Nanite를 가상화된 기하(Virtualized Geometry) 시스템으로 정의했습니다. 내부 메시 포맷과 렌더링 기술을 사용해 픽셀 스케일 디테일과 높은 오브젝트 수를 목표로 하며 화면에 보이는 디테일만 처리하도록 설계되었습니다. 또한 포맷은 고압축일뿐더러 세밀한 단위의 스트리밍과 자동 LOD까지 지원합니다.
이러한 내용이 중요한 이유는 UE4의 Tessellation이 표면을 더 쪼개는 증분적 디테일이었다면 Nanite는 디테일을 가진 원본을 받아들이되 런타임에서 가시성과 스트리밍으로 비용을 제어한다는 운영적 디테일로 축이 이동했기 때문입니다. 즉, UE5는 디테일을 늘리는 기술을 셰이더 단계의 기교가 아니라 데이터 포맷과 스트리밍, 그리고 컬링 파이프라인 전체의 문제로 끌어올리게 됩니다.
Nanite가 해결하려는 문제는 삼각형을 더 그리는 법이 아닙니다. 삼각형이 너무 많아진 시대에 "그것을 어떻게 운영할 것인가?"라는 물음에 대한 해법입니다. 즉, 내부 포맷으로 잘게 쪼개진 기하 데이터를 필요한 순간에만 스트리밍하고, 화면에 기여하지 않는 단위를 대규모로 컬링하며, 그 결과를 GPU 중심으로 빠르게 누적하고 결정해야 합니다.
이런 구조에서는 리소스 상태 전이나 바인딩, 파이프라인 상태 같은 비용이 반복적으로 등장할 수 밖에 없고, 이를 엔진이 명시적으로 통제하지 못하면 운영 자체가 불가능해집니다. 그래서 Nanite는 DX11처럼 드라이버 최적화에 기대기보다는 DX12의 명시적 모델 위에서 스케줄링, 배치, 캐싱을 엔진이 직접 설계하는 방향을 전제로 하게 됩니다. 또한 SM6의 웨이브 단위 협업은 이런 대규모 컬링과 패킹 과정에서 같은 실행 그룹 내부의 데이터 집계를 더 효율적으로 만들 수 있는 기반이 됩니다.
이 지점에서 DX12(SM6)는 선택이 아니라 조건이 됩니다. 왜냐하면 Nanite는 얼마나 많은 삼각형을 그릴 수 있는지를 넘어, 보이는 클러스터만 남기고 나머지는 버리는 GPU 주도 파이프라인을 전제하기 때문입니다.
Lumen: Bake 대신 Realtime을 기본값으로
Epic Games, 루멘 글로벌 일루미네이션 및 리플렉션에서 발췌
에픽 문서를 보게되면 Lumen은 디퓨즈 간접광을 해결하며, 표면에서 반사된 색이 주변에 번지는 컬러 블리딩과 메시가 간접광을 가려 생기는 간접 그림자까지 포함한다고 하였습니다. 또한 반사(Reflections)까지 포함해 "동적 GI + 동적 반사"를 하나의 시스템으로 다룬다는 것을 설명합니다.
Lumen의 핵심은 동적 GI와 동적 반사를 옵션으로 더하는 것이 아니라 장면 변화(시간, 조명, 오브젝트, 카메라)에 따라 라이팅을 지속적으로 갱신하는 것을 기본 전제로 둔다는 점입니다. 이때 엔진은 광원, 표면, 가시성 정보를 반복적으로 업데이트하고, 그 과정에서 생성되는 데이터를 안정적인 GPU 작업으로 스케줄링 해야 합니다.
DX11처럼 많은 비용이 드라이버 레이어에서 흡수되던 모델에서는 이런 지속 갱신이 커질수록 제어가 어렵고, 반대로 DX12에서는 엔진이 배리어와 PSO, 리소스 바인딩을 포함한 실행 흐름을 명시적으로 설계할 수 있기 때문에 시스템 차원에서 동적 라이팅을 기본값으로 유지하는 운영 방식을 만들 수 있었습니다.
즉, Lumen은 단지 새로운 조명 기법이 아니라 DX12 시대의 엔진이 운영하는 방식을 전제로 성립하는 시스템입니다.
그렇기에 UE5 신규 프로젝트에서는 기본적으로 활성화되지만 기존 레거시로 인해 UE4에서 UE5로 변환한 프로젝트에서는 자동으로 활성화되지 않습니다. 이는 기존 프로젝트의 라이팅 패스를 망가뜨리거나 변경하지 않기 위한 조치로 명시되어 있습니다. 즉, Lumen은 단순한 옵션이 아니라, 제작 파이프라인의 기준선을 바꾸는 기능으로 취급됩니다.
UE4에서 동적 광원 수를 늘리는 접근이 CS 기반 컬링 같은 최적화를 요구했다면, UE5는 그 연장선에서 "동적 라이팅 그 자체를 기본값으로 두려면 무엇이 필요한가?"를 끊임없이 고찰하여 시스템차원으로 풀어낸 결과로 Lumen을 선보이게 됩니다.
맺음말
UE4(DX11 / SM5)는 고정 파이프라인을 깨기 시작했지만, 여전히 LOD는 사람이 설계하고, 라이팅은 베이크에 기대어 있었습니다. 하지만 UE5의 Nanite와 Lumen은 이 전제를 흔들었습니다.
기하는 LOD 설계에서 가상화와 가시성 기반 운영으로 이동한다.
라이팅은 베이크된 정답에서 실시간 해답을 기본 목표로 둔다.
따라서 UE5를 이해하는 핵심은 기능 소개가 아닙니다. DX12(SM6) 시대에 엔진이 GPU를 운영하는 방식이 바뀌어버렸고, Nanite와 Lumen은 그 운영 방식에 맞춰 렌더링을 데이터와 시스템 문제로 재정의 했다는 점에서 시사하는 바가 큽니다.
고정된 형식을 넘어 유연함을 추구하며 발전하는 GPU와 언리얼 엔진. 발전에는 끝이 없겠지만, 앞으로가 기대가 되는 GPU의 역사 시리즈를 마치겠습니다.
긴 시리즈를 마무리하며, 이 기술적 흐름에 공감해 주신 분들이 어떤 분들인지 궁금합니다. 아래 중 어디에 해당하시나요? 번호로 댓글만 남겨주시면 큰 힘이 됩니다.
취준생 / 학생: 엔진 내부 원리를 파헤쳐 압도적인 포트폴리오를 만들고 싶다.
현업 개발자: UE5 운영에서 최적화 vs 효율 사이에서 고민 중이다.
기술 결정권자: 엔진과 GPU 흐름을 읽고 팀의 기술 방향을 정해야 한다.
그 외 / 관심 독자: 일단 시리즈가 재미있어서 계속 보고 싶다.
특정 주제로 더 깊게 다이브하거나, 실무 적용 관점의 정리가 필요하다면 방명록(또는 이메일)로 편하게 노크해 주세요.
요즘 레이트레이싱에 관련된 작업을 시작하게 되어 정리차원에서 블로깅하게 되었습니다. 현재는 TinyRaytracer를 마개조 중이었고, 이번 글은 깃헙의 TinyRaytracer를 기반으로 레이트레이싱 기법에 대해 간략하게 설명해보도록 하겠습니다. 최적화 기법은 추후에 다룰 예정입니다. 다른 포스팅에서 확인 부탁드립니다. (fresnel equation이 추가되었습니다.)
ray_intersect함수의 경우, 레이의 original position, direction을 받아서 해당 오브젝트의 충돌검출(구와 직선의 충돌 알고리즘)과 함께 해당 오브젝트까지의 거리를 t0로 넘겨줍니다.
2. 기능 설명
- render
void render(const std::vector<const sdf_model*> &models, const std::vector<Light> &lights) {
const int width = 1024;
const int height = 768;
const float fov = M_PI/3.;
std::vector<Vec3f> framebuffer(width*height);
#pragma omp parallel for
for (size_t j = 0; j<height; j++) { // actual rendering loop
for (size_t i = 0; i<width; i++) {
float dir_x = (i + 0.5) - width/2.;
float dir_y = -(j + 0.5) + height/2.; // this flips the image at the same time
float dir_z = -height/(2.*tan(fov/2.));
framebuffer[i+j*width] = cast_ray(Vec3f(0,0,0), Vec3f(dir_x, dir_y, dir_z).normalize(), models, lights);
}
}
std::ofstream ofs; // save the framebuffer to file
ofs.open("./out.ppm",std::ios::binary);
ofs << "P6\n" << width << " " << height << "\n255\n";
for (size_t i = 0; i < height*width; ++i) {
Vec3f &c = framebuffer[i];
float max = std::max(c[0], std::max(c[1], c[2]));
if (max > 1) c = c * (1. / max);
for (size_t j = 0; j<3; j++) {
ofs << (char)(255 * std::max(0.f, std::min(1.f, framebuffer[i][j])));
}
}
ofs.close();
}
카메라에서 레이를 발사하여 frame buffer에 기록하고, 그 데이터를 ppm 파일에 저장합니다.
- cast_ray
Vec3f cast_ray(const Vec3f &orig, const Vec3f &dir, const std::vector<Sphere> &spheres, const std::vector<Light> &lights, size_t depth=0) {
Vec3f point, N;
Material material;
if (depth>4 || !scene_intersect(orig, dir, spheres, point, N, material)) {
return Vec3f(0.2, 0.7, 0.8); // background color
}
Vec3f refract_color(0.f, 0.f, 0.f);
// compute fresnel
float kr;
fresnel(dir, N, material.refractive_index, kr);
// compute refraction if it is not a case of total internal reflection
if (kr < 1) {
Vec3f refract_dir = refract(dir, N, material.refractive_index).normalize();
Vec3f refract_orig = refract_dir * N < 0 ? point - N * EPSILON : point + N * EPSILON;
refract_color = cast_ray(refract_orig, refract_dir, models, lights, depth + 1);
}
Vec3f reflect_dir = reflect(dir, N).normalize();
Vec3f reflect_orig = reflect_dir * N < 0 ? point - N * EPSILON : point + N * EPSILON; // offset the original point to avoid occlusion by the object itself
Vec3f reflect_color = cast_ray(reflect_orig, reflect_dir, models, lights, depth + 1);
float diffuse_light_intensity = 0, specular_light_intensity = 0;
for (size_t i=0; i<lights.size(); i++) {
Vec3f light_dir = (lights[i].position - point).normalize();
float light_distance = (lights[i].position - point).norm();
Vec3f shadow_orig = light_dir*N < 0 ? point - N*EPSILON : point + N*EPSILON; // checking if the point lies in the shadow of the lights[i]
Vec3f shadow_pt, shadow_N;
Material tmpmaterial;
if (scene_intersect(shadow_orig, light_dir, spheres, shadow_pt, shadow_N, tmpmaterial) && (shadow_pt-shadow_orig).norm() < light_distance)
continue;
diffuse_light_intensity += lights[i].intensity * std::max(0.f, light_dir*N);
specular_light_intensity += powf(std::max(0.f, -reflect(-light_dir, N)*dir), material.specular_exponent)*lights[i].intensity;
}
return material.diffuse_color * diffuse_light_intensity * material.albedo[0]
+ Vec3f(1., 1., 1.)*specular_light_intensity * material.albedo[1]
+ reflect_color * material.albedo[2] * kr
+ refract_color * material.albedo[3] * (1 - kr);
}
레이를 발사하는 함수입니다. 먼저 장면에 충돌시켜 충돌된 위치(point)와 해당 point의 normal값을 구합니다. 그 후, 위 함수는 세 연산으로 나뉩니다.
첫번째는 레이 연산입니다. 레이 연산은 다시 세가지로 나뉘는데 fresnel equation, 반사(reflection), 투과(refraction입니다. 이 부분을 설명하기엔 길어지므로 아래의 각 파트에서 다루도록 하겠습니다.
두번째는 빛 연산입니다. 먼저 변수에 대한 설명을 진행하도록 하겠습니다.
diffuse_light_intensity는 분산광의 정도를 저장하는 변수입니다.
specular_light_intensity는 반사광의 정도를 저장하는 변수입니다.
light_dir는 빛이 diffuse light이므로 빛의 방향이 됩니다.
light_distance는 빛에서부터 point까지의 거리로 shadow casting을 할 때, 앞에 있는 물체를 검출하기 위해 사용할 값입니다. 앞에 물체가 있다면 light값을 적용하지 않습니다.
shadow_origin은 point의 위치를 EPSILON으로 보정한 값입니다. point의 앞에서 발사한 빛인지 뒤에서 발사한 빛인지를 내적으로 검출하여 EPSILON으로 보정하여 light_distance 비교연산을 할 때 오차를 줄여줍니다. light_distance 비교연산은 변수 설명이 끝난 후 설명하겠습니다.
shadow_pt는 빛에서 point로 레이를 발사하는 과정에서 부딪힌 point입니다. light_distance보다 짧은 곳에서 충돌했다면 point 앞에 물체가 있는 것으로 음영에 diffuse_light_intensity와 이 필요합니다.
tmpmaterial은 scene_intersect에 넣어주기 위한 용도로 TinyRaytracer에서는 사용하지 않습니다.
마지막으로 light_distance 비교연산을 진행하고 그 결과에 따라 light_intensity를 적용하게 됩니다. 빛에서부터 해당 point까지 scene_intersect를 진행하여 light_distance보다 짧은 거리에 위치한 물체가 있다면 light_intensity 연산을 하지않게 되는 것이지요.
세번째는 위 연산에 따라 색상을 혼합해주는 연산입니다. 여기서 주목해야할 부분은 reflect_color와 refract_color에 fresnel equation의 결과값인 kr을 적용해주는 부분입니다. 아래 fresnel편에서 자세히 다루도록 하겠습니다.
- scene_intersect
bool scene_intersect(const vec3 &orig, const vec3 &dir, const std::vector<Sphere> &spheres, vec3 &hit, vec3 &N, Material &material) {
float spheres_dist = std::numeric_limits<float>::max();
for (const Sphere &s : spheres) {
float dist_i;
if (s.ray_intersect(orig, dir, s, dist_i) && dist_i < spheres_dist) {
spheres_dist = dist_i;
hit = orig + dir*dist_i;
N = (hit - s.center).normalize();
material = s.material;
}
}
float checkerboard_dist = std::numeric_limits<float>::max();
if (std::abs(dir.y)>1e-3) { // avoid division by zero
float d = -(orig.y+4)/dir.y; // the checkerboard plane has equation y = -4
vec3 pt = orig + dir*d;
if (d>1e-3 && fabs(pt.x)<10 && pt.z<-10 && pt.z>-30 && d<spheres_dist) {
checkerboard_dist = d;
hit = pt;
N = vec3{0,1,0};
material.diffuse_color = (int(.5*hit.x+1000) + int(.5*hit.z)) & 1 ? vec3{.3, .3, .3} : vec3{.3, .2, .1};
}
}
return std::min(spheres_dist, checkerboard_dist)<1000;
}
레이를 원점으로부터 특정 방향으로 진행시킬 때의 hit point와 해당 point의 normal값을 검출하는 함수입니다.
Vec3f refract(const Vec3f &I, const Vec3f &N, const float eta_t, const float eta_i=1.f) { // Snell's law
float cosi = - std::max(-1.f, std::min(1.f, I*N));
if (cosi<0) return refract(I, -N, eta_i, eta_t); // if the ray comes from the inside the object, swap the air and the media
float eta = eta_i / eta_t;
float k = 1 - eta*eta*(1 - cosi*cosi);
return k<0 ? Vec3f(1,0,0) : I*eta + N*(eta*cosi - sqrtf(k)); // k<0 = total reflection, no ray to refract. I refract it anyways, this has no physical meaning
}
refract는 snell's law에 의해 구현되었습니다.
우선 \(cos i\)를 확인하여 광선이 매질 안에서 발사되는 것인지를 확인합니다. 만약 \(cos i\)가 0보다 작으면 매질 안에서 발사되는 것이므로 eta_i와 eta_t를 교환해 다시 refract를 진행해줍니다.
매개변수 중 eta_t는 투과를 진행할 물체의 투과율로 영어로는 ior(index of refraction)라고도 부릅니다. eta_i는 광선이 있는 곳의 투과율입니다.