2024.12.30 - [TA] - [Unity] Custom Shadow System 제작하기 (1) - 커스텀 그림자 제작의 필요성
[Unity] Custom Shadow System 제작하기 (1) - 커스텀 그림자 제작의 필요성
캐릭터는 젠레스 존 제로의 니콜이다. MMD 공유하는 중국 사이트에서 받음. 이 포스팅은 산으로 가다못해 우주로 가버린 그림자 시스템 제작기에 대한 내용을 다룬다. 주요 기능캐릭터의 Self Sha
gohen.tistory.com
재미없는 배경 설명은 열심히 했다. 물론 처음보는 사람은 저게 대체 뭐가 중요하냐고 따지겠지만, 이걸 직접 만들어보면 저런 정보가 얼마나 중요한지 절실히 깨닫게 된다. 그래서 시간을 좀 많이 투자해서 정리해보았다.
이 두번째 포스팅에서는 직접 기능을 제작하고, 어떤 것들이 고려되어야 하는지 등을 다루고자 한다.
Per Object Shadow 구현하기
일단 캐릭터의 그림자를 캐스팅하고, 리시브 할 수 있도록 System(계)를 구축하는 것이 우선이다. 유니티 내장 시스템과 완전히 독립적으로 동작하는 시스템을 스크립트를 통해 작성했다.
그림자를 그릴 공간 | Bounds 계산하기
우선 제일 핵심적인 부분인 Bounds(AABB) 데이터를 어떻게 설정할 것인가?
Bounds 정보는 Skinned Mesh Renderer와 Mesh Renderer 등 Renderer의 기본 API로 제공되고 있다. 그리고 Renderer에서 리턴하는 Bounds는 World 좌표 상의 Bounds로 메쉬의 Transform 정보가 모두 포함된 정보이다.
using UnityEditor;
using UnityEngine;
using UnityEngine.Rendering;
public class Character : MonoBehaviour
{
public Renderer[] renderers;
public void CollectRenderers()
{
renderers = GetComponentsInChildren<Renderer>();
foreach (var r in renderers)
{
switch (r)
{
case SkinnedMeshRenderer skr:
skr.updateWhenOffscreen = true;
break;
}
r.shadowCastingMode = ShadowCastingMode.Off;
}
}
public Bounds GetBounds()
{
Bounds b = new Bounds(Vector3.negativeInfinity, Vector3.zero);
if (renderers.Length > 0)
{
b = renderers[0].bounds;
}
for (int i = 1; i < renderers.Length; ++i)
{
b.Encapsulate(renderers[i].bounds);
}
return b;
}
public void OnDrawGizmos()
{
var bounds = GetBounds();
Gizmos.DrawCube(bounds.center, bounds.size);
}
}
[CustomEditor(typeof(Character))]
public class CharacterEditor : Editor
{
public override void OnInspectorGUI()
{
if (GUILayout.Button("수집"))
{
(target as Character).CollectRenderers();
}
}
}
심플한 구조이다. Renderer를 미리 수집하고, Renderer의 Bounds를 Encapsulate하고 출력할 수 있는 함수를 추가한다.
렌더러를 수집하는 버튼을 누르면 기즈모 뷰 모드가 활성화된 상태에서 위 이미지와 같이 나온다. 캐릭터를 완전히 감싸는 큐브를 얻을 수 있다.
UpdateWhenOffScreen 옵션
이 스크립트가 붙은 채로 캐릭터의 애니메이션을 붙여보면, 실제 애니메이션에 따른 Bounds 업데이트가 제대로 되지 않음을 확인할 수 있다.
댄스 애니메이션을 보면 손이 Bounds 범위 바깥에 나가는 것을 확인할 수 있다.
이 상황에서 Renderer에서 bounds를 가져올 경우 당연하게도 캐릭터의 일부분이 그림자맵에 찍히지 않는 상황이 발생한다. 따라서 선택할 수 있는 건 캐릭터의 전 범위가 포함될 수 있도록 Bounds를 충분하게 늘려주거나, Update When Offscreen 옵션을 켜서 Bounds가 Skinning 적용에 맞춰서 갱신이 계속 될 수 있도록 하는 방법 중에 선택할 수 있다.
하지만 Bounds를 직접 수정하게 될 경우 캐릭터 하나에 대해서는 별로 번거로운 작업이 아닐 수 있으나, 이걸 게임에 들어가는 모든 캐릭터에 대해서 수행한다고 하면 그때부터 곤란한 상황이 발생한다. 아트 작업자들에게 이걸 일일이 다 설명하고 맞춰달라고 하는 게 너무나 어렵다. 어디서부터 설명해야 할지도 막막하고 결국 당신들 일을 더 늘리겠다고 선언하는 꼴이 되는 터라 답답한 상황이 되어버릴 것 같아서. 내 능력 부족이겠지만.
이외에 추가로 냈던 아이디어는 이 캐릭터가 사용하는 모든 Animation Clip을 에디터 환경에서 재생시키면서 Bounds를 계산하는 스크립트를 짜는 것이었다. 하지만 또 막히는 게 캐릭터가 공중에서 내려찍는 등 다이나믹한 애니메이션이 들어오는데, 이런 클립을 넣으면 Bounds가 오히려 오염되는 문제가 발생했다. 공중에 올라간 것까지 다 고려하게 되다보니 Bounds가 세로로 엄청 커지는 등의 문제가 발생한다.
그래서 그냥 Update When Offscreen을 켜기로 했다.
정확한 알고리즘은 잘 모르고 있지만(Skinning하면서 Vertex 별 min max 계산하고 ReadBack하는 방식이라고 추측만 하고 있다. 하지만 GPU Readback은 위험하지 않나 싶기도 하고...) 캐릭터를 수십 개 띄워놓고 봐도 큰 이슈없이 동작하는 걸 확인하고 Per Object Shadow는 주요 캐릭터를 더 멋지게 보여지기 위해서 쓰는 기법이니 캐릭터가 많은 상황에서 쓸만한 기술도 아니라고 생각한다. 그러니 써도 될 것이라고 판단했다.
World Space Bounds를 모두 포함하는 View, Projection Matrix 계산하기
Bounds를 구했지만 이번엔 이 Bounds를 포함하는 Bounds를 한번더 계산해야 한다. 무슨 소린가 하면, World 좌표계 상에서 Bounds가 어떻게 되어있든 그림자맵에 찍히는 건 임의로 지정한 조명 위치에서의 Frustum이기 때문에 조명 공간을 기준으로 좌표계 변환이 필요하다는 뜻이다.
그러면 우선 Light를 이용해서 View Matrix를 구성해보자.
view matrix는 항상 카메라를 생각하게 되니까 어렵게 생각하는데, 그냥 어떤 오브젝트의 위치를 원점으로 했을 때의 좌표계로 이동하는 거라고 생각하면 된다. 즉, GameObject의 Transform의 worldToLocalMatrix는 이 GameObject에 대한 View Matrix라고 판단할 수 있다.
View Matrix 구성시 Scale은 딱히 고려하지 않아도되기 때문에 Translation과 Rotation 정보만 얻을 수 있다면 이를 이용해 View Matrix를 localToWorld Matrix의 역행렬처럼 구할 수 있다.
public static Matrix4x4 GetViewMatrix(Vector3 origin, Vector3 direction)
{
var rot = Quaternion.LookRotation(direction);
return Matrix4x4.Rotate(Quaternion.Inverse(rot)) * Matrix4x4.Translate(-origin);
}
View Space로 이동하기 위해서 먼저 원점으로 이동시키기 위한 Translate,
그리고 적용된 회전을 다시 0으로 돌려놓기 위한 역 회전 행렬을 곱하면 바로 View Matrix가 된다. (물론 실제로 이 ViewMatrix를 셰이더로 가져가 쓰게 되면 애로 사항이 꽃필 것이다..)
Directional Light 정보를 이용해 Light Space(View Space) Bounds 계산하기
제일 간단한 Directional Light를 기준으로 하자. direction은 그냥 forward 벡터를 가져오면 그만인데, 원점을 어디로 할 것인가가 문제가 된다. 어려울 것 없이 Bounds에 찰싹 붙여서 만들면 파파라치 밀착취재가 되지 않을까?
문제는 이 Bounds는 Light 공간의 Bounds라는 것이다. 한마디로 Light Frustum인 셈이다. 그러면 이 Frustum Cube의 Near Center Point를 어떻게 얻을 것인가 하면, 우선 임시로 World Space Bounds를 계산하고 Bounds의 Center Point를 Origin이라는 가정하에 ViewMatrix를 계산한다. 그러면 원점은 안맞지만 일단 회전 정보는 정확한 Light View Matrix가 만들어질 것이다. 이 상태에서 World Space Bounds를 계산한 임시 View Matrix를 이용해 좌표 변환을 하여 새로운 View Space Bounds를 구성한다. 그다음 이 Bounds의 Center Point에서 extent.z 값을 빼주면 이 좌표가 바로 임시 Light Space의 원점이 된다.
슬슬 코드가 늘어나기 시작한다.. 나중엔 다 쓰지도 못하겠네.
public static Matrix4x4 GetViewMatrix(Vector3 origin, Vector3 direction)
{
var rot = Quaternion.LookRotation(direction);
return Matrix4x4.Rotate(Quaternion.Inverse(rot)) * Matrix4x4.Translate(-origin);
}
private static void MakeMin(ref Vector3 src, in Vector3 vec)
{
src.x = Mathf.Min(src.x, vec.x);
src.y = Mathf.Min(src.y, vec.y);
src.z = Mathf.Min(src.z, vec.z);
}
private static void MakeMax(ref Vector3 src, in Vector3 vec)
{
src.x = Mathf.Max(src.x, vec.x);
src.y = Mathf.Max(src.y, vec.y);
src.z = Mathf.Max(src.z, vec.z);
}
public static Vector3[] corners = new Vector3[8];
public static Bounds TransformBounds(Bounds boundsWS, Matrix4x4 transformMatrix)
{
Vector3 max = boundsWS.max, min = boundsWS.min;
corners[0] = transformMatrix.MultiplyPoint3x4(max);
corners[1] = transformMatrix.MultiplyPoint3x4(min);
corners[2] = transformMatrix.MultiplyPoint3x4(new Vector3(min.x, max.y, min.z));
corners[3] = transformMatrix.MultiplyPoint3x4(new Vector3(min.x, max.y, max.z));
corners[4] = transformMatrix.MultiplyPoint3x4(new Vector3(max.x, max.y, min.z));
corners[5] = transformMatrix.MultiplyPoint3x4(new Vector3(max.x, min.y, max.z));
corners[6] = transformMatrix.MultiplyPoint3x4(new Vector3(min.x, min.y, max.z));
corners[7] = transformMatrix.MultiplyPoint3x4(new Vector3(max.x, min.y, min.z));
min = corners[0];
max = corners[1];
for (int i = 0; i < 8; ++i)
{
MakeMin(ref min, corners[i]);
MakeMax(ref max, corners[i]);
}
return new Bounds((min + max) * 0.5f, max - min);
}
public static Matrix4x4 CalculateViewMatrix(Bounds boundsWS, Vector3 direction)
{
var tmpView = GetViewMatrix(boundsWS.center, direction);
Bounds boundsLS = TransformBounds(boundsWS, tmpView);
Vector3 nearCenterLS = boundsLS.center + Vector3.back * boundsLS.extents.z;
Vector3 nearCenterWS = tmpView.inverse.MultiplyPoint3x4(nearCenterLS);
return GetViewMatrix(nearCenterWS, direction);
}
위에 설명은 장황했는데, 오히려 코드를 보면 이해가 쉬울 것이다.
Bounds의 공간 변환은 정말 무식하다. Bounds의 각 꼭지점 8개를 구한 후에, 각각에 대한 Min, Max 연산을 통해 min, max position을 구한 후, 이걸 Bounds로 쓰는 것이다.
회전 정보가 없는 AABB를 이런 방법을 통해 공간 변환을 해서 쓸 수 있다. 이 아이디어는 정말 활용도가 무궁무진하기 때문에 알아두면 좋다.
Projection Matrix 계산하기
이제 View Matrix를 구했으니 Projection Matrix를 구할 차례다. Projection Matrix는 API를 제공하기 때문에 매우 심플하게 얻을 수 있다. Light 공간 상의 Bounds의 size를 알고 있기 때문에 Projection Matrix의 너비와 높이를 어떻게 지정할 지 계산이 끝난 셈이다.
public static void CalculateDirectionalLightMatrices(Bounds boundsWS, Vector3 direction, out Matrix4x4 view, out Matrix4x4 proj)
{
var tmpView = GetViewMatrix(boundsWS.center, direction);
Bounds boundsLS = TransformBounds(boundsWS, tmpView);
Vector3 nearCenterLS = boundsLS.center + Vector3.back * boundsLS.extents.z;
Vector3 nearCenterWS = tmpView.inverse.MultiplyPoint3x4(nearCenterLS);
view = GetViewMatrix(nearCenterWS, direction);
proj = Matrix4x4.Ortho(-boundsLS.extents.x, boundsLS.extents.x, -boundsLS.extents.y, boundsLS.extents.y, 0f, boundsLS.extents.z * 2f);
}
Directional Light는 조명과 물체의 거리에 따라서 그림자 크기가 변하는 기능이 없기 때문에 Orthographic Matrix를 Projection Matrix로 사용한다. Cascade Shadow Map도 Orthographic Matrix를 사용한다.
Shadow Map 렌더링
이제 view, projection matrix를 모두 계산했기 때문에 이 데이터를 이용해서 캐릭터를 RenderTexture에 그리도록 코드를 짜면 이걸 shadow map으로 활용할 수 있게 된다.
대충 Update 함수에서 다음과 같이 캐릭터를 그리도록 코드를 짰다.
void Update()
{
var cmd = CommandBufferPool.Get();
var boundsWS = GetBounds();
var lightDirection = light.transform.forward;
CalculateDirectionalLightMatrices(boundsWS, lightDirection, out var view, out var proj);
if (SystemInfo.usesReversedZBuffer)
{
view.m20 = -view.m20;
view.m21 = -view.m21;
view.m22 = -view.m22;
view.m23 = -view.m23;
}
cmd.SetViewProjectionMatrices(view, proj);
if (!rt)
{
rt = new RenderTexture(1024, 1024, 16, DefaultFormat.Shadow);
}
cmd.SetRenderTarget(rt);
cmd.ClearRenderTarget(true, false, Color.black, 1.0f);
for (int i = 0; i < renderers.Length; ++i)
{
materialCache.Clear();
renderers[i].GetSharedMaterials(materialCache);
for (int j = 0; j < materialCache.Count; ++j)
{
int index = FindPassIndex(materialCache[j]);
if (index == -1) continue;
cmd.DrawRenderer(renderers[i], materialCache[j], j, index);
}
}
Graphics.ExecuteCommandBuffer(cmd);
cmd.Clear();
CommandBufferPool.Release(cmd);
}
대충 이런 식으로 Shadow Map을 그린다.
View, Projection Matrix만 구할 수 있으면 Shadow Map을 렌더링하는 건 그다지 어려운 일이 아니다.
Gizmo로 확인하면 다음과 같이 나온다. 녹색이 월드 좌표계의 Bounds이고, 빨간색이 Light Space, 즉 view space Bounds이다.
'TA' 카테고리의 다른 글
[Unity] Skeletal Animation 동작에 대하여 (0) | 2025.02.28 |
---|---|
(작성중)[Unity] Custom Shadow System 제작하기 (3) - Shadow Map 샘플링 (0) | 2025.02.25 |
(작성중) [Unity] URP환경에서 GPU Instancing Tool 만들기 (0) | 2025.01.20 |
[Unity] Custom Shadow System 제작하기 (1) - 커스텀 그림자 제작의 필요성 (0) | 2024.12.30 |
(작성중) [Unity] Irradiance Volume 만들기 (짭 Adaptive Probe Volume) (0) | 2024.12.29 |