Computer Graphics/VulkanPT

pt - 14. gltf를 지원하는 path tracing 렌더러

surkim 2025. 8. 12. 18:02

재질 통합을 완료하고 gltf를 지원하는 렌더러로 바꿨다.

 

ui 상 왼쪽 위는 오브젝트, 아래는 area light를 조정할 수 있는 ui이고

다중 광원 역시 지원 가능하다. (물론 area light 중 사각형만..!)

 

계산식 통합이야 계속 해왔던거라 쉬웠는데

오히려 오브젝트 추가 제거나

area light도 오브젝트 취급인데 light 속성을 지니고 있으니 이걸 구분지어주는 것에 애먹었었다

 

생각해보니 내 path tracing 계산식에 대한 코드와 메모리들 설명을 여기다가 안적어서 정리해서 적어 놔야겠다.

오브젝트, arealight, material, camera, options 전부 gpu에 올려 놓았고 메모리 구조는 이렇다.

 

1. 카메라

layout (set = 0, binding = 0) uniform CameraGPU {
    vec3 camPos;
    float pad0;
    vec3 camDir;
    float pad1;
    vec3 camUp;
    float pad2;
    vec3 camRight;
    float fovY;
} camera;

그냥 빡빡하게 메모리 정렬을 맞춰주었다.

내 역량 부족인지 뭔지 작업하는 컴퓨터가 각각 스펙이 다른 3대(rtx2060 super, rtx3060, rtx4070labtop)여서

돌려가며 작업했었는데 왜인지 정렬을 굉장히 빡빡하게 안맞춰주면

어디선가 항상 삑사리가 났고 화딱지 나서 정확하게 16바이트씩 맞춰줬었다.

특별할 거 없는 카메라 정보이고 위치, 방향, up, right, fov를 gpu에 올려둔다.

 

2. 옵션

layout (set = 0, binding = 1) uniform OptionsGPU {
    int frameCount;
    int maxSpp;
    int currentSpp;
    int lightCount;
} options;

옵션은 일단 만들어두고 그때그때 필요할 때마다 추가해주는 형식으로

개발 친화적으로 만든 구조체였고

현재는 frame count, 최대 spp, 현재 spp, light 개수가 들어간다.

 

3. 인스턴스 (오브젝트)

struct InstanceGPU {
    mat4 transform;

    uint64_t vertexAddress;
    uint64_t indexAddress;

    int lightIndex;
    int materialIndex;
    int meshIndex;
    float pad0;
};
layout(set = 3, binding = 0) buffer InstanceBuffer {
    InstanceGPU instances[];
};

모든 transform을 가지고 있는 오브젝트들 묶음이고

이건 오브젝트도 light도 될 수가 있다.

blas에 직접적으로 1대1 대응하는 인스턴스이다.

 

레이에 맞았을 때 이 인스턴스를 찾아서 무엇에 맞는지 알아내는 용도

 

각각 인덱스를 가지고 있고 자발광이 아닐때는 lightIndex 가 -1로 초기화 되어있다.

요 인덱스를 가지고 bindless형태로 각 세부정보를 확인하여 들어간다.

 

맞은 표면의 노말값과 uv값을 정확하게 알기위해 vetexAddress와 indexAddress를 cpu단에서 먼저 보내줘야 했다.

 

4. material

struct MaterialGPU {
    vec4 baseColor;
    vec3 emissiveFactor;
    float roughness;

    float metallic;
    float ao;
    int albedoTexIndex;
    int normalTexIndex;

    int metallicTexIndex;
    int roughnessTexIndex;
    int aoTexIndex;
    int emissiveTexIndex;

    int doubleSided;
    float transmissionFactor;
    float ior;
    float pad0;
};

layout (set = 1, binding = 0) buffer MaterialBuffer {
    MaterialGPU materials[];
};

path tracing 계산에 필요한 material 정보이다.

역시 bindless 형태로 gpu에 보내고 인덱스로 확인하는 구조로 짰고

통합재질이라 + gltf의 특성에 맞춰 메모리를 구성했다.

 

딱히 어려울 건 없고 여기서 중요한 포인트는 texture 인덱스를 또 bindless 형태로 찾아 들어간다는 점?

doubleSided는 이때 알게 되었는데 렌더링중에 특정 오브젝트가 특히 군데군데만 깜해서 찾아봤더니 doubleSided가 켜져있는 gltf파일은 뒷면에 맞아도 렌더링해야한다는 점을 알게되었었다.

 

제일 밑의 transmissionFactor와 ior은 투과의 잔재이다.

 

5. texture

layout(set = 2, binding = 0) uniform sampler2D textures[];

텍스쳐도 bindless형태로 올라간다.

이 프로젝트 부터 bindless를 알게되어서 아주 적극적으로 사용해준 기억이 난다.

이전 프로젝트에선 디스크립터 셋 구조가 난리가 났었다. 

 

6. areaLight

struct AreaLightGPU {
    vec3 color;
    float intensity;

    vec3 p0;
    float area;
    vec3 p1;
    float pad0;
    vec3 p2;
    float pad1;
    vec3 p3;
    float pad2;

    vec3 normal;
    float pad3;
};
layout(set = 3, binding = 1) buffer AreaLightBuffer {
    AreaLightGPU areaLights[];
}

나는 사각형만 했고 직접광을 샘플링하기위해 점 4개가 다 필요했다.

좀 더 좋은 방법이 있긴 할텐데 그냥 모든 정보를 올리는 방법으로 쉽게 했다.

인스턴스에서 타고 들어가 인덱스로 light정보를 확인한다.

 

이정도가 얼추 path tracing에서 필요한 정보들이고 이 외에도 tlas, 그리고 출력용 이미지가 gpu에 올라간다.

 

처음에 렌더링이 시작되면

rgen shader에서 시작된다.

void main() {

	if (options.currentSpp >= options.maxSpp) {
		return;
	}


    uvec2 pixel = gl_LaunchIDEXT.xy;
    uvec2 size = gl_LaunchSizeEXT.xy;
    
	// vec2 uv = (vec2(pixel) + vec2(0.5)) / vec2(size);

    uint seed = initRandom(size, pixel, options.frameCount);
	vec2 jitter = vec2(rand(seed), rand(seed));
	vec2 uv = (vec2(pixel) + jitter) / vec2(size);

    vec2 screen = uv * 2.0 - 1.0;
    screen.y = -screen.y;
    float aspect = float(size.x) / float(size.y);
    float scale = tan(radians(camera.fovY) * 0.5);

    vec3 dir = normalize(
        screen.x * aspect * scale * camera.camRight +
        screen.y * scale * camera.camUp +
        camera.camDir
    );
	vec3 origin = camera.camPos;


	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;
		traceRayEXT(topLevelAS, gl_RayFlagsOpaqueEXT, 0xFF, 0, 0, 0,
					origin, 0.0001, dir, 1e30, 0);
		
		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;
	}

	ivec2 ipixel = ivec2(pixel);

	// 누적 버퍼
	vec4 prevAccum = vec4(0.0);
	if (options.currentSpp > 0) {
		prevAccum = imageLoad(accumPrevImage, ipixel);
	}
	vec4 newAccum = prevAccum + vec4(payload.L, 1.0);
	imageStore(accumCurImage, ipixel, newAccum);

	// 현재 샘플 수로 정규화된 출력
	vec3 finalColor = newAccum.rgb / (float(options.currentSpp) + 1.0f);
	imageStore(outputImage, ipixel, vec4(finalColor, 1.0));

}

ray가 생성되는 곳이고 딱히 어렵다할 부분은 없는데 하나씩 설명하자면

 

1.

if (options.currentSpp >= options.maxSpp) {
		return;
	}

들어오자마자 현재와 맥스 spp를 확인하여 이미 max에 도달했다면 별다른 일 없이 리턴이다.

 

2.

    uvec2 pixel = gl_LaunchIDEXT.xy;
    uvec2 size = gl_LaunchSizeEXT.xy;
    
	// vec2 uv = (vec2(pixel) + vec2(0.5)) / vec2(size);

    uint seed = initRandom(size, pixel, options.frameCount);
	vec2 jitter = vec2(rand(seed), rand(seed));
	vec2 uv = (vec2(pixel) + jitter) / vec2(size);

    vec2 screen = uv * 2.0 - 1.0;
    screen.y = -screen.y;
    float aspect = float(size.x) / float(size.y);
    float scale = tan(radians(camera.fovY) * 0.5);

    vec3 dir = normalize(
        screen.x * aspect * scale * camera.camRight +
        screen.y * scale * camera.camUp +
        camera.camDir
    );
	vec3 origin = camera.camPos;

현재 들어온 곳이 화면의 어느 픽셀인지 계산을 하여 카메라의 위치와 화면을 고려하여 레이의 위치와 방향을 결정하는 단계이다.

여기서 jitter부분은 서브픽셀의 렌덤위치에서 레이를 생성하여 엘리어싱을 방지해주는 기법을 사용했다.

 

3.

	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;
		traceRayEXT(topLevelAS, gl_RayFlagsOpaqueEXT, 0xFF, 0, 0, 0,
					origin, 0.0001, dir, 1e30, 0);
		
		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;
	}

payload는 rgen 셰이더와 rchit, rmiss 셰이더간 정보 교환이 일어나는 구조체다.

rgen에서 payload를 생성하여 초기화해준뒤 레이를 쏘면

blas로 생성된 오브젝트가 맞았을때 rchit 셰이더로 들어가는 구조이다.

쏠 때 거리를 지정해줄 수 있는데 그 거리만큼 레이가 갔는데도 blas구조에 맞지 않으면 rmiss 셰이더로 들어간다.

 

위 코드는 payload 초기화와 직접적으로 path tracing이 일어나는 코드이다.

최대 16번을 튕기면서 레이를 쏘는데 쏘면 rchit나 rmiss 셰이더에 들어가고 payload구조체가 갱신되고 다시 레이를 쏘는 구조이다.

안에 if문은 러시안 룰렛 기법으로 기여도가 너무 없는 레이를 확률적으로 삭제하는 로직이다. 이게 없으면 끝까지 16번 돌것이고 이러면 1spp를 생성하는데 너무 많은 계산이 들어가게 된다. (1번의 샘플을 만들기 위해 해상도 x 16번의 path tracing 계산)

 

4.

	ivec2 ipixel = ivec2(pixel);

	// 누적 버퍼
	vec4 prevAccum = vec4(0.0);
	if (options.currentSpp > 0) {
		prevAccum = imageLoad(accumPrevImage, ipixel);
	}
	vec4 newAccum = prevAccum + vec4(payload.L, 1.0);
	imageStore(accumCurImage, ipixel, newAccum);

	// 현재 샘플 수로 정규화된 출력
	vec3 finalColor = newAccum.rgb / (float(options.currentSpp) + 1.0f);
	imageStore(outputImage, ipixel, vec4(finalColor, 1.0));

다 계산이 되면  payload의 L부분이 그 픽셀의 샘플 색이 되는 거고 이를 누적하여 출력용 이미지에 적재하면 path tracing 종료이다.

 

일단 여기까지 하고

그 다음은 레이를 발사해서 오브젝트 어딘가에 맞았을 때

위의 코드의

traceRayEXT(topLevelAS, gl_RayFlagsOpaqueEXT, 0xFF, 0, 0, 0,
					origin, 0.0001, dir, 1e30, 0);

이부분 이후의 rchit 또는 rmiss 셰이더에서 일어나는 일을 써야겠다.