SSR Screen Space Reflection 반사광, 거울 반사 3D 그래픽 구현 및 처리 과정 - Whitmem
SSR Screen Space Reflection 반사광, 거울 반사 3D 그래픽 구현 및 처리 과정
그래픽 개발
2025-01-11 23:27 게시 eb6307554395a5e5d4e0

0
0
75
이 페이지는 외부 공간에 무단 복제할 수 없으며 오직 있는 그대로 게시되며 부정확한 내용을 포함할 수 있습니다. 법률이 허용하는 한 가이드 라인에 맞춰 게시 내용을 인용하거나 출처로 표기할 수 있습니다.
This page is not to be distributed to external services; it is provided as is and may contain inaccuracies.
스크린 공간 기반 반사
거울 표면의 특정 픽셀에 해당하는 객체의 법선 벡터 방향을 구하고, 카메라 뷰와 노말 벡터의 반사벡터를 구하고, 픽셀에 해당하는 표면 정점에서 반사벡터의 방향으로 Ray Marching 하여 어떤 표면과 도달한 경우 도달한 해당 표면을 픽셀 색상으로 샘플링한다.
필자는 스크린 공간 기반 반사인 SSR (Screen Space Reflection)을 구현해보기로 하였다. 왜 화면 공간이냐면, 이미 렌더링된 색상 영역에서 바탕으로 처리하기 때문이다. 이미 렌더링된 색상에서 역으로 원점을 찾아내고 해당 원점에서 노말로 향하는 법선 벡터와 카메라가 향하는 방향 벡터간의 반사 벡터를 추출하고, 해당 반사 벡터가 향하는 표면을 찾아내는 것이 최종 목표이다. 이를 월드 공간에서 무한히 진행하면 레이-트레이싱이 되는 것 같다만, 게임에서 사용하기 위해서는 약간의 눈 속임과 보정을 통해 시각적인 효과를 보정하는 것으로 보인다.
쉽게 말해서, 상이 그려지는 각각의 모든 지점의 법선 벡터와 입사 벡터의 반사 벡터를 구한다. 그리고 각 반사 벡터가 도달하는 대상 객체의 색상을 가져와 거울 표면에 상을 그려주면 된다. 이를 거울 표면의 한 픽셀 하나 하나 모두 수행해주면 된다.
그런데, GPU 공간안에서 이 반사 벡터가 충돌하는 표면을 바로 찾기 쉽지 않기 때문에, Ray Marching 이라는 기법을 사용한다. 반사 벡터를 단위 벡터로 우선 초기화하고, 일정 step 만큼 나아가면서 특정 지점과 충돌한지 확인하는 방법이다.
반사 벡터를 구한 뒤에 해당 반사 벡터로 여러번 스텝을 움직인 뒤 지점이 표면을 통과했는지 확인할 수 있다.
깊이 맵
기본적으로 깊이 버퍼는 카메라 가까울수록 0에 가깝고 멀수록 1에 가까워지기 때문에, 카메라에 가까운 물체는 어두운 색상으로 보이게 된다. 이러한 버퍼 자료가 필요한 이유는 특정 픽셀에서 표면의 3차원 원점 정보로 역 변환할 때 2D 좌표인 x,y 좌표는 존재하나 z 정보는 존재하지 않기 때문에 깊이 버퍼를 z 자료로 사용하게 된다.
법선 맵
다음으로 법선 맵 정보이다. 기본적으로 법선 맵은 무조건 표면이 z가 +1 방향인 접선 텍스쳐여야 하지만 이미 객체에서 노말 정보가 있기도 하고, 이를 내부 버퍼에 사용할 법선 정보이기 때문에 임의로 월드 공간의 법선 정보로 렌더링하였다. 월드 공간의 법선 역시 노말라이즈(정규화)된 법선이기 때문에 -1~1 데이터를 텍스처에 우겨 담을 수 있다.
그런데, 그런데 메시가 회전되는 경우 노말 정보도 같이 회전되어야 하기 때문에 모델 매트릭스를 연산해 픽셀 쉐이더로 넘겨주었다. 근데 각각의 법선 벡터는 크기와 위치를 가지고 있지 않는 원점을 기준으로 하는 방향 벡터이기 때문에 모델의 월드 행렬을 적용한뒤, 월드 원점에서 행렬을 적용해서 객체가 이동한 크기만큼 다시 노말 벡터에서 빼주어 회전된 노말 벡터만 구할 수 있도록 하였다. 즉 모델의 회전 행렬만 적용된 법선 벡터를 구해 픽셀 텍스처로 렌더링하였다.
+ 2025 01 11일에 알게된 것인데, 벡터 계산시에 4번째 인자인 w 값을 1.0으로 하는 경우 정점으로 보기 때문에 이동 행렬등도 연산되지만, 0.0으로 하는 경우 방향벡터로 연산하기 때문에 이동 행렬은 알아서 적용되지 않은 것을 확인할 수 있었다. 이 부분을 조금 더 자세히 공부는 해야겠지만 이 부분을 몰라서 돌아가서 삽질하였던 것 같다.
스텐실 맵
객체를 제외하고 단순히 거울 영역에만 데이터를 그리기 위해 사용하는 마스크 맵이다. 흰색 영역에만 그림을 그린다.
오브젝트 깊이 맵
깊이 충돌 판정을 할 때 거울 표면 자체와는 충돌 판정을 하지 않고 어떤 오브젝트와 충돌을 확인해야하기 때문에 대상 오브젝트들만 그려진 깊이 맵도 렌더링하였다.
계산 과정
노말 벡터 구하기
vec4 normalColorInProjection = texture2D(normalTexture, uv); vec4 normalDirectionInWorldZeroPos = (normalColorInProjection*2.0)-1.0; /*normalDirectionWorldZeroPos 는 해당 노말의 포지션은 전혀 제외한 월드 공간에서의 법선 방향임 이는 오직 방향만 가지고 있으며 원점 정보를 가지고 있지 않음. */ normalDirectionInWorldZeroPos = vec4(normalize(vec3(normalDirectionInWorldZeroPos)),0.0); vec4 worldNormal = normalDirectionInWorldZeroPos; vec4 normalDirectionInView = viewMatrix * worldNormal; normalDirectionInView = vec4(normalize(vec3(normalDirectionInView)),0.0);
먼저 노말 텍스처를 가져와서 현재 픽셀의 법선 노말을 가져오고 해당 노말을 뷰 공간으로 이동한다. 방향 벡터이기 때문에 w는 0.0으로 계산하였고, 결과적으로 뷰 공간에서 방향만을 나타내는 법선 벡터이다.
표면의 오리지널 정점 구하기
vec4 originPixelDepthColorInProjection = texture2D(depthTexture, uv); float originPixelDepthInProjection = originPixelDepthColorInProjection.r; vec4 originPixelInProjection = vec4(uv.x*2.0-1.0, uv.y*2.0-1.0, originPixelDepthInProjection, 1.0); vec4 originPositionInView = reverseProjection * originPixelInProjection; originPositionInView = originPositionInView / originPositionInView.w;
현재 픽셀의 깊이 정보를 읽어와서 2D로 투영된 정점 정보를 3D 정점 정보로 역변환 하는 과정을 거친다. 이 때 월드 3D 좌표계가아니라, 카메라의 뷰 좌표계 기준으로 옮긴다. 카메라의 좌표가 0,0,0이 되는 것이다.
카메라 - 표면까지의 방향 벡터 구하기
vec4 cameraToOriginPositionInView = originPositionInView; cameraToOriginPositionInView = vec4(normalize(vec3(cameraToOriginPositionInView)),0.0
카메라 좌표계로 옮긴 뒤에 카메라에서 해당 정점을 향하는 방향+크기 벡터를 구하고, 노말라이즈하여 단위 벡터로 변환한다. 즉 여기까지 카메라 - 표면까지의 방향 벡터, 표면의 법선 벡터를 픽셀 쉐이더에서 구하게 된 것이다.
반사 벡터 구하기
vec4 reflectedVectorInView = reflect(cameraToOriginPositionInView, normalDirectionInView); reflectedVectorInView = vec4(normalize(vec3(reflectedVectorInView)),0.0);
카메라 - 표면까지의 방향 벡터와 표면 법선 벡터를 통해 반사 벡터를 구하고, 해당 반사 벡터를 단위화 한다.
Ray Marching 과정을 통해 충돌 확인 후 샘플링 시작
int sampleCount = 100; vec4 addedColor = vec4(0.0,0.0,0.0,0.0); for(int i=1;i<sampleCount;i++){ vec4 originPositionAddVectorInView = originPositionInView + reflectedVectorInView * float(i) * 0.07; originPositionAddVectorInView.w = 1.0;
먼저 샘플 개수만큼 반복을 수행하면서 표면의 정점 위치에서부터 구한 반사 벡터로 일정 길이 (0.07)씩 나아간다. 즉 한번의 반복에서 0.07 길이만큼 반사 벡터로 표면에서 나아간다. 이렇게 나아간 것은 정점이기 때문에 w는 1.0으로 해 주었다.
계산된 반사 스텝 지점에 대한 2D 투영
vec4 rayDestinationPixelPosition = projectionMatrix * originPositionAddVectorInView; rayDestinationPixelPosition = rayDestinationPixelPosition/rayDestinationPixelPosition.w; vec2 rayUVPosition = vec2((rayDestinationPixelPosition+1.0)/2.0);
그리고 위 스텝으로 나아간 지점에 대해서 2D 좌표로 투영하여 UV 텍스처 좌표로 가져온다.
2D 투영된 좌표에 대해서 UV 가 초과 감지
if(rayUVPosition.x<=0.0 || rayUVPosition.y<=0.0) break; if(rayUVPosition.x>=1.0 || rayUVPosition.y>=1.0) break; .... 생략
반사 벡터의 경우 오직 스크린 좌표계 안에서 처리되기 때문에 반사된 벡터를 투영했을 때 UV 좌표를 벗어나는 (-0.1~, 또는 1.1~) 범위가 나올 수 있다. 이런 벡터는 자칫 잘못 계산했다가 화면의 아래나 상단이 렌더링되는 경우가 있기 때문에 (기본 설정에서 UV 좌표는 벗어나면 다시 리피트되기 때문이다.) 이를 제외해주는 것이다.
깊이 버퍼 가져오기
float rayProjectedObjectDepthValue = texture(withoutMirrorTexture,rayUVPosition).z; float rayDepthValue = rayDestinationPixelPosition.z;
반사된 지점에서 오브젝트의 깊이와 반사 벡터의 Z 깊이를 가져온다. 오브젝트 깊이는 이미 그려진 오브젝트의 깊이이고, 반사 벡터의 Z 깊이는 스텝으로 진행된 지점의 Z 버퍼이다. 두 버퍼를 가져온 이유는 정점이 표면을 충돌한지 확인하기 위해서 이다. 안타깝게도 SSR 는 화면 영역에서 반사를 하는 것이기 때문에 반사 벡터가 오브젝트를 실제로 충돌했는지 확인하는 것이 아니라, 깊이가 겹쳐지거나 일정 오차 범위 이하로 줄어들면 충돌했다고 감지하는 것이다. z buffer가 이동한 라인이 z buffer 경계를 충돌하거나, z 가 일정 오차 이하에 있거나, 버퍼 뒤에 있는 경우 충돌로 감지할 수 있다. 하지만 각각의 장단점이 존재하는 것 같고, 오차가 심하기도 해서 나름 다른 방법을 고민 중에 있다. 여기서는 abs 값으로 하여 오차 미만인 경우에 반사되도록 하였다.
충돌 지점 파악
if(rayProjectedObjectDepthValue!=0.0) if(abs(rayProjectedObjectDepthValue-rayDepthValue)<=0.002) { addedColor+= pickedColor; calculatedCount+=1; break; } }
abs(rayProjectedObjectDepthValue-rayDepthValue)는 현재 반사 벡터의 스텝 만큼 이동한 정점 z 깊이와 해당 지점에 그려진 오브젝트의 z 깊이 차가 0.002 이하인 거의 겹치는 경우에 해당 지점의 색상을 가져와 반사 시작 지점의 색상으로 사용하는 것을 의미한다. 충돌하지 않은 경우 sampleCount 만큼 반복하여 스텝을 이동하는 것이다. 끝 지점에 도달하면 break로 나오고 아닌 경우 계속 반복하고 sampleCount 만큼 이동하여도 표면을 발견하지 못한 경우 반사할 사항이 없는 것으로 판단한다.
색상 융합
vec4 renderColor = texture2D(uTexture, uv); vec4 stencilColor = texture2D(stencilTexture, uv); gl_FragColor = renderColor; if(stencilColor.r>=1.0){ if(calculatedCount!=0){ addedColor = addedColor / float(calculatedCount); gl_FragColor = (gl_FragColor*0.1 + addedColor*0.9); }else{ gl_FragColor = gl_FragColor; } }
마지막으로 원본 렌더 컬러를 가져오고 스텐실 버퍼는 거울 표면에만 1.0으로 했기 때문에, 거울 표면에 해당하는 경우 해당 지점에 추가적으로 계산된 반사 벡터가 존재하는 경우 gl_FragColor 에 추가적으로 섞어준다.
문제가 존재하는데 각도에 따라서 반사가 안되는 부분이 존재하는데 내 예측으로 반사 벡터가 충돌 지점을 파악하지 못해서 발생하는 문제로 보인다. 즉 버퍼의 오차가 일정 이하인 경우에만 반사 처리되도록 하였는데, 해당 지점을 벗어나버리는 경우 충돌로 파악하지 못하는 것이다. 그렇다고 오차를 크게 허용해주면, 아래 사진과 같이 관련 없는 부분인 오브젝트 저 뒤까지 반사 벡터가 지나가면서 충돌로 처리하여 반사광을 생성해버린다.
또 다른 문제로, 화면 영역에서 반사 벡터를 계산하고 이미지 픽셀을 가져오는 것이기 때문에, 큐브가 화면 영역안에 다 보이는 경우 반사가 정상적으로 잘 되지만, 화면 영역에 큐브가 다 출력되지 않는 경우 그림자가 같이 잘려버리는 문제가 발생한다. 화면에 그려진 것이 없기 때문에 반사광을 그릴 수 없게 되는 것이다.이를 해결하기 위해 임의로 더 큰 해상도를 렌더링하여 시야각만 줄일 수 있겠으나 이러면 반사광 처리를 위해 실제 출력되지 않는 렌더 영역의 성능 사용량이 증가하고, 이렇게 하더라도 임시 방편에 불과하다는 문제점이 존재한다.
그리고 보정 정도에 따라서 또 천차 만별의 결과가 나오기 때문에 상황에 따라 다양한 기법이 적용되어야 할 것으로 보인다.
그래서 간단한 처리의 경우는 평면의 반사 벡터를 활용해서 월드 공간에서 객체를 평면에 반사 행렬을 통해 반사하고 그린 뒤, 스텐실 버퍼에만 그리는 간단한 방법이 존재하는데 이러면 정점 정보를 가지는 버텍스를 또 다시 그려내야하는 문제가 존재한다.
댓글 0개
댓글을 작성하는 경우 댓글 처리 방침에 동의하는 것으로 간주됩니다. 댓글을 작성하면 일회용 인증키가 발급되며, 해당 키를 분실하는 경우 댓글을 제거할 수 없습니다. 댓글을 작성하면 사용자 IP가 영구적으로 기록 및 부분 공개됩니다.
확인
Whitmemit 개인 일지 블로그는 개인이 운영하는 정보 공유 공간으로 사용자의 민감한 개인 정보를 직접 요구하거나 요청하지 않습니다. 기본적인 사이트 방문시 처리되는 처리 정보에 대해서는 '사이트 처리 방침'을 참고하십시오. 추가적인 기능의 제공을 위하여 쿠키 정보를 사용하고 있습니다. Whitmemit 에서 처리하는 정보는 식별 용도로 사용되며 기타 글꼴 및 폰트 라이브러리에서 쿠키 정보를 사용할 수 있습니다.
이 자료는 모두 필수 자료로 간주되며, 사이트 이용을 하거나, 탐색하는 경우 동의로 간주합니다.