유니티에서 God Ray 구현 과정 - Whitmem
유니티에서 God Ray 구현 과정
게임 개발 및 엔진
2026-01-12 00:46 게시 02dae1a736d86217022c

0
0
40
이 페이지는 외부 공간에 무단 복제할 수 없으며 오직 있는 그대로 게시되며 부정확한 내용을 포함할 수 있습니다. 법률이 허용하는 한 가이드 라인에 맞춰 게시 내용을 인용하거나 출처로 표기할 수 있습니다.
This page is not to be distributed to external services; it is provided as is and may contain inaccuracies.
저작물 참고 필자도 해당 내용을 공부하고 있는 입장으로 주로 오픈 문서, 포럼, AI 질의 응답을 활용하여 관련 내용을 공부하였습니다. 따라서 틀린 내용이 있을 수 있습니다. 다만, 타인(AI 등)의 저작물을 침해하지 않도록 순서, 과정, 스크린샷, 코드 등 다른 저작물을 활용 또는 포함하지 않았으며, AI 등 인터넷 자료는 오로지 기술 원리 이해의 목표로만 활용하고, 본 게시글에 언급된 과정, 흐름, 본문, 게시한 코드는 AI의 복붙 없이 모두 필자가 사전에 알고 있는 그래픽스 기술과 인터넷 상에 공개된 개념을 통해 모두 직접 시도하며 직접 작성하였음을 밝힙니다. 부득이하게 어떤 자료를 인용하는 경우는 링크로 출처를 남기고 인용합니다.
렌더러 퓨처 구현
이 게시글은 이전에 작성한 God Ray 원리에 대한 다음 내용으로 실제 구현한 과정에 대해서 언급한다. 이 기능을 구현하기 위해서 임의 렌더 퓨처 ScriptableRendererFeature 의 삽입이 필요한데, 하나의 렌더 퓨처를 구현한 다음 내부에서 별도의 패스ScriptableRenderPass를 각각 구현하였다. 첫 패스로는 객체를 일관된 쉐이더로 특정 행렬에서 렌더링하는 패스, 두 번째 패스로는 첫 패스의 텍스처를 읽어들여 스크린에서 광선을 쏘며 빛의 도달 여부를 파악하는 패스와 이를 화면에 렌더하는 내부 패스로 구성된다.
렌더 퓨처 내부에는 각각 2개의 패스를 선언하고 실행 시점을 다르게 설정한다. 일단 빛의 입장에서 깊이 텍스처가 필요한데, 빛의 행렬을 내부적으로 생성하여 뷰로 전달할 수 있는 패스이다. 이 행렬을 사용해서 월드에 존재하는 모든 객체를 그려야 하므로 객체를 모두 가져와서 일괄적으로 렌더링하되, 깊이 쉐이더로 빛의 행렬에서 그려야 한다.
일단 이 렌더 퓨처에서 사용하는 사용자 정의 값들인데, 빛의 투영 행렬 정보, 빛의 위치, 바라보는 방향, 빛의 세기, 이외 Phase 와 감쇄값(카메라로부터 멀어질수록 감쇄하는 값)이다. LightPhaseVelocity 는 어떤 g 값을 사용하는데 그냥 잘 몰라서(이론을 잘 모른다) AI 에게 하나 생성해달라 하여 함수를 복붙하였다. 따라서 이 부분은 언급거나 게시하지 않는다.
먼저 첫 번째 패스, 객체를 일괄적으로 깊이 버퍼로 그리는 패스이다. 여기서 내부 패스 2개를 사용하는데 첫 번째 내부 패스는 실제 빛 행렬로 객체를 모두 일괄적으로 그리고 다시 원본 행렬로 돌리기 위해서 original 메트릭스 정보를 받는다. 이후 두 번째 내부 패스에서는 이 빛 텍스처를 전역 쉐이더로 지정하기 위해 사용한다.
첫 번째 내부 패스에서는 일단 _Near, _Far과 같은 주요 파라메터는 쉐이더에서 사용할 수 있도록 값을 넘겨주었다. 일단 쉐이더에서 필요해 보이는 값들은 다 넘겨준다. 그 다음으로 중요한 것이 바로 RendererListDesc 인데, 렌더할 객체 리스트를 선언하기 위해 정보를 생성하는 단계이다. RendererListDesc 의 첫 번째 인자는 ShaderTagID 인데 해당하는 쉐이더를 가지는 객체를 모두 걸러내되, 이때 렌더링 데이터는 기존 카메라가 걸러낸 cullResults를 사용한다. 그리고 임시로 카메라 정보를 넘긴다. 이것은 실제로 렌더링하는 것이 아니라 어떤 객체를 렌더링해야 하는지 처리하는 단계이다. 일단 성능을 위해 빛의 시점에서 다시 객체를 모두 뽑아내는 것이 아니라, 카메라 시점 기준 프러스텀 컬링된 목록을 모두 가져와 UniversalForward 라는 쉐이더를 가지고 있는 메시들을 렌더러 리스트에 담는다.
이때 불투명한 객체들로 범위를 한정한다. (이 작업을 안해주니 오류가 발생하더라.)
그리고 중요한 부분인데, 해당 렌더러 리스트의 메트리얼이나 쉐이더를 모두 오버라이드할 수 있다. 입력으로 들어온 메트리얼로 모두 오버라이딩하고, 쉐이더 패스는 하나 밖에 없으니 0으로 지정하였다.
이 정보를 바탕으로 렌더러 리스트를 생성한다. builder에게 이 렌더러 리스트를 사용할 것이라고 알려주고 실제 그리기는 execute 에서 실행해야 하므로 passData에 넘겨준다.
그리고 forward, right, up 벡터를 만들어서 임의 카메라 행렬을 만들어준다. 이때 up 좌표는 월드 Vector.up 이 아니라 해당 방향 벡터의 진짜 UP 벡터여야 한다. 그렇기 때문에 방향 벡터를 forward로 보고, 일단 세계의 Vector.up 을 통해서 오른쪽 벡터를 구하고, 이를 다시 Cross 해서 실제 방향의 up 벡터를 구한다. 이 과정이 잘못되면 시점 시야가 이상해질 수 있다.
또한 유니티 엔진에서 이 작업을 수행하면 x가 뒤집어지는 현상이 있어서... 그냥 createWorldToView 라는 함수 안에서 x를 다시 반전시켜주었다.
아무튼 쉐이더에는 하나의 매트릭스만 넘겨야하므로 이를 모두 통합하여 하나의 매트릭스로 계산한다.
그리고 직교 좌표계로 넘기기 위해서 Matrix4x4 Ortho를 사용하였고, 이외 작업후 원래 매트릭스로 돌리기 위해서 카메라의 기본 뷰, 프로젝션 매트릭스(원근)도 다시 넘겨주었다.
현재 작업은 beforeRenderingOpaque 시점에 수행된다. 즉 이 작업이 끝나고 실제 Opaque 객체가 엔진 내부에서 렌더링된다. 이 때 SetViewMatrix 를 한 흔적이 그대로 전달되기 때문에, 반드시 원래 카메라 행렬로 복원해야한다.
이제 실제 텍스처 정보가 내보내질 렌더 타겟과 이에 대한 깊이 버퍼도 내보낸다. 객체 여러개를 그리는 것이기 때문에 깊이 버퍼도 별개로 하나 만들어줘야 할 것 같았다. 메인 카메라의 깊이 버퍼는 일반 장면을 렌더링하기 위한 깊이 버퍼로 여기서는 빛의 행렬 기준 깊이 버퍼를 따로 만들어 지정해야 했다.
내부 패스 2
다음으로 중요한 것이 이 깊이 정보가 담기는 텍스처를 다시 내보내기 위한 내부 패스인데,
AllowGlobalStateModification이 메서드가 제일 중요하다. 이 Pass 는 실제 Execute 안에서 실제 메시를 드로우하지는 않고 단순히 이전 내부 패스 1 깊이 버퍼 텍스처를 쉐이더 전역 파라메터에 넣는 역할만 한다. 이걸 넣는 역할을 Execute 안에서 해야하는데, 메시를 드로우 하지 않으면 유니티 엔진이 최적화 과정을 통해 이 패스를 제거해버린다. 따라서 Global State를 수정할 수 있다는 것을 명시하여 Execute가 실행되게끔 해야 한다.
위 코드는 내부 패스 1에 대한 Execute 부분이다. SetViewProjectionMatrices 를 호출하여 빛 행렬로 모두 변경하고 렌더 타겟은 흰색으로 채운다. 흰색으로 채우는 이유는 내가 흰색이 제일 먼 객체로 판단하게끔 구현했기 때문이다. 이외 Draw하고 다시 원래 행렬로 돌려준다.
그리고 ExecutePass 2 에서는 LightTexture 을 전역 쉐이더에 넘겨준다. 이 과정에서 Builder 를 생성할 때 UseTexture을 반드시 해야한다. 이렇게 별개 패스로 나눈 이유는 내부 패스 1에서 텍스처로 렌더 타겟을 지정했기 때문에 다시 읽기 모드로 읽을 수가 없다. 즉 쉐이더 파라메터에 적용할 수 없다. 별개 패스를 하나 더 만들어 인풋으로 이전 텍스처 아웃을 넣어 비로소 여기서 파라메터에 넣어주는 것이다.
쉐이더는 그냥 depth 로 positionHCS.z 를 넣었다. 직교 좌표계라 비선형이 아니기 때문이다. 이상하게도 뷰로 옮기기 위해 역행렬을 곱하면 값이 너무 이상해지는(비선형이 되어버리는) 증상이 있었는데 원인은 모르겠다. 내부 그래픽 라이브러리에 따라 z 값이 또 반전되는 경우가 있어서 분기를 넣어주긴 했는데... 이 부분은 정확히 모르겠다 추후 좀 조사를 해서 다시 언급하고자 한다.
이렇게 해서 프레임 디버거로 확인해보면 위와 같이 빛의 시점에서 깊이 버퍼가 그려진 것을 볼 수 있다.
PostProcessorRay 구현
이제 객체는 정상적으로 그려졌으니, 실제 엔진 내부에서 Opaque 객체를 카메라 입장에서도 렌더할 것이고, PostProcessing 작업에서 이 기능을 구현하면 된다. 이 작업은 스크린 공간에서 시작하여 처리되기 때문에 이전 텍스처를 받아서 UV를 띄워주기 전에 광선을 쏴 빛을 더하는지 확인 한 후 해당 픽셀에 더하여 내보내면 되는 것이다. 즉 이것도 임의 렌더 타겟에 뿌려주는 작업이 한 번 필요하고 이 렌더 타겟을 읽어들여 비로소 화면에 다시 출력하는 작업이 필요하다.
따라서 내부 패스 2개를 쓰는 것이다.
일단 필요해 보이는 모든 값들은 모두 넘겨준다. 여기서는 이전 패스에서 넘긴 화면 텍스처를 그대로 받아 텍스처 파라메터 입력으로 넣는다. 즉 _BlitTexture가 될 것이다.
그리고 내 보낼 렌더 타겟도 하나 임의로 정의해준다. 여기서는 이미 메시 렌더링이 끝나 새로운 메시 처리를 위한 깊이 버퍼를 처리하지 않기 때문에 깊이 버퍼를 다시 정의할 필요도 없다. 화면 상에 맨위에 다시 그리는 것이기 때문에 쉐이더에서도 깊이 테스트를 끌 것이다.
행렬 정보도 다 넘겨준다.
내부 패스 2 에서는 마찬가지로 텍스처를 읽어들여 화면에 내보내기 위한 작업을 한다. 당연하게 이전 내부 패스의 destination 이 여기서는 입력으로 UseTexture 해야하고, 내보내기로 activeColorTexture 를 해야 한다.
Execute 역시 각각 한 개씩 존재하는데, 일단 화면 포스트 프로세싱 (광선 처리)에서는 필요한 데이터는 모두 쉐이더로 넘겨준다. 그리고 Execute 2 에서는 어차피 하나의 텍스처를 다시 화면에 뿌려주는 것이므로 별도 메시 및 텍스처 구현할 필요 없이, 내부에서 제공하는 BlitTexture 을 사용해서 대상에 복사해주면 된다. (안에서 그려주는 것으로 보인다.)
쉐이더 구현
쉐이더 구현 과정이 제일 복잡했는데... 일단 헷갈리는 y 반전 z 반전 관련된 내용은 여기서는 언급하지 않는다. 나도 그냥 바꿔가면서 시도한 것이기 때문에 언급하기에 적절치가 않다.
일단 화면에 Quad를 꽉 채워 그려주고, Fragment 쉐이더 안에서는 일단 현재 그리는 픽셀의 월드 좌표를 알아야 한다. 월드 좌표를 알아내야 카메라 좌표로부터 해당 월드 좌표까지의 방향 벡터를 만들 수 있다. 다만 유의 할 것은 카메라 픽셀이 각각의 카메라 시작 정점이 되는 것이 아니라, 카메라 정점은 하나이다. 즉 카메라의 위치는 하나이고, 각 픽셀이 최종 그린 저 끝에 있는 월드 좌표와 카메라의 정점 그 자체 하나를 빼는 것이다. 픽셀마다 카메라 시작점이 다르다고 보면 오히려 더 헷갈린다.
이 때 IN.positionHCS.x 와 y 는 스크린 좌표 크기이기 때문에 해상도로 나눠줘야 한다. 이 값은 _ScreenParams 가 가지고 있고, 깊이는 현재 그리는 z깊이가 아니다.지금 이 작업은 포스트 프로세싱이다. 즉 Quad 객체가 그려지는 것이기 때문에 x,y,z 모두 Quad 의 x,y,z 이다. Quad 의 x,y 는 어차피 화면을 꽉 채워놨기 때문에 카메라 픽셀 좌표와 동일하지만, z는 카메라 맨 앞에 바로 그린 것이다. 따라서 이 z를 바로 사용하면 안되고 내부 깊이 버퍼에 그려진 값을 가져와야 한다. 유니티 엔진에서는 SampleSceneDepth 이라는 함수를 제공한다. 이 값을 읽어오기 위해 IN.uv 로 읽어온다. (생각해보니 아래 NDC도 이 IN.uv로 읽어도 되겠다..) 만약 함수가 없다고 뜨면 아래 내장 패키지를 include 해야 한다.
아무튼 이를 역행렬을 곱해서 월드 좌표를 구한다. w를 나누는 것은 동차 좌표 처리를 위한 것이기 때문으로 (즉 원근 처리) 내부 행렬 과정에 포함되어 있는 것이니 그냥 무조건 나눠준다.
이제 카메라로부터 그려진 월드 정점에 대한 방향 벡터를 구해주고, 또한 빛의 방향 벡터도 구해준다. 이는 나중에 세기 감쇄등을 위한 내적 처리에 사용될 수 있다. (이는 선택 사항이다.)
그리고 실제 레이를 그리기 시작하는데 rayDistance 와 power, i 횟수는 필요에 따라 정해준다 pixelLight 가 광선을 쏘면서 세기를 누적하는 값이다. (현재 이 픽셀 쉐이더가 하나의 픽셀을 처리하고 있는 중이다. for은 하나의 픽셀에 대해서 광선이 쭈욱 나가는 것이다.) for 문 안에서 광선 지점을 정의하여 매 i 마다 앞으로 나가게끔 구현하고, 이 광선 위치를 일단 카메라 행렬 기준으로 깊이 버퍼를 구한다. 카메라 행렬 기준으로 깊이 버퍼를 구하는 이유는 카메라 행렬이 보기에 이 객체를 통과하는 경우 통과하는 순간에는 빛을 누적할지 볼 필요도 없기 때문이다. 더 이상 객체에 가려진 뒷 모습은 (카메라 기준) 빛이 받든 말든 알 바가 아니다.
다음으로 빛 행렬 기준 좌표도 구해준다.
빛 행렬 기준 처리를 해 주는 이유는 이렇게 움직이는 광선의 빛 행렬 기준 깊이 버퍼와 아까 맨 앞에 렌더한 빛 깊이 버퍼를 서로 비교하여 빛이 도달하는지 파악하기 위함이다.
이 때 빛 정보는 직교로 선형이기 때문에, Near 값과 far 값을 받아서 직접 0~1 범위로 구해준다.
이제 실제 비교만 하면 된다. 일단 카메라 행렬 기준으로 광선이 카메라 버퍼보다 뒤에 있으면, 즉 해당 광선 지점부터 어떤 객체가 이미 그려져 광선이 가려진 곳을 통과했다면 (넘어갔다면) 더 이상 이제 이 픽셀은 볼 것도 없다 지금까지 누적된 값만 픽셀에 더해주면 된다.
이제 빛 행렬 기준으로 깊이 버퍼를 비교할 차례이다. 여길 보면 SAMPLE_TEXTURE2D * _BaseColor 로 일반 샘플링 코드를 사용했는데... 매우 위험한 접근이다 ㅋㅋ 깊이 버퍼이기 때문이다 눈에 대충 보이는 값이 아니라 데이터를 의미하는 값이기 때문에 이런 작업은 사실상 나중에 오해를 사기 좋다. 그러나 동작만 되면 상관 없으므로.. 우선 난 이렇게 작성하였다. 아무튼 빛 행렬 기준의 깊이 버퍼와, 현재 광선의 깊이 버퍼를 각각 꺼내온다.
내가 그린 빛 깊이 버퍼는 어두울수록 빛에 가까이 있는 것이고. 밝을 수록 빛에 먼 것이다. 즉 현재 빛 행렬 기준 광선의 깊이 버퍼가 이미 어떤 그려진 객체보다 더 어두운(적은) 값이어야 해당 광선의 지점이 그림자지지 않은 빛이 도달할 수 있는 영역이라는 것이다. 따라서 이 경우, pixelLight 를 특정 파워만큼 더해주면 된다. 나의 경우 rayDistance와 power을 곱해서 더해주었는데 distance를 임의로 조절하더라도 일정한 파워로 더해지게 하기 위함이다.. 이외 phase와 decay는 태양과 카메라가 등지고 있으면 어두워진다든지 빛으로부터 멀어질수록 감쇄한다든지 등의 상수, 또는 벡터 내적에 대한 가중치 값이다.
아무튼 이렇게 컬러, LightRaycolor, pixelLight 를 서로 알맞게 처리해서 넣어주면 간단한 레이 처리가 완료된다... 이 구현은 실제 사용하기에는 부적합해보인다. 나중에 그냥 엔진에서 제공하는 기능 사용해야겠다.
댓글 0개
댓글을 작성하는 경우 댓글 처리 방침에 동의하는 것으로 간주됩니다. 댓글을 작성하면 일회용 인증키가 발급되며, 해당 키를 분실하는 경우 댓글을 제거할 수 없습니다. 댓글을 작성하면 사용자 IP가 영구적으로 기록 및 부분 공개됩니다.
확인
Whitmemit 개인 일지 블로그는 개인이 운영하는 정보 공유 공간으로 사용자의 민감한 개인 정보를 직접 요구하거나 요청하지 않습니다. 기본적인 사이트 방문시 처리되는 처리 정보에 대해서는 '사이트 처리 방침'을 참고하십시오. 추가적인 기능의 제공을 위하여 쿠키 정보를 사용하고 있습니다. Whitmemit 에서 처리하는 정보는 식별 용도로 사용되며 기타 글꼴 및 폰트 라이브러리에서 쿠키 정보를 사용할 수 있습니다.
이 자료는 모두 필수 자료로 간주되며, 사이트 이용을 하거나, 탐색하는 경우 동의로 간주합니다.