목차
개념
- 맵과 크기가 동일한 RGBA(0, 0, 0, a)의 텍스쳐를 이용해 지정한 유닛들의 주변시야를 표현한다.
- 지정한 유닛이 현재 위치한 원형 범위 내 영역들은 a = 0,
- 지정한 유닛이 한 번이라도 위치했던 영역들은 a = 0.5~0.8,
- 지정한 유닛이 한 번도 방문하지 않은 영역은 a = 1로 표현한다.
구현 방법
[1] 카메라와 지상 사이에 검정색 Plane 사용
-
시야의 역할을 해줄 Plane을 카메라와 지상 사이에 위치시킨다.
-
맵 전체를 좌표 형태의 2차원 배열로 관리하여, 유닛들이 현재 위치한 영역, 방문했던 영역, 한 번도 방문하지 않은 영역의 정보를 실시간으로 저장한다.
-
카메라와 해당 유닛들 사이에서 Plane 위의 시야 중심 좌표를 구하고, 로컬 좌표로 변환하여 해당 좌표를 기준으로 원형 범위 내의 정점 색상들을 변경시킨다.
-
Plane 위의 시야 중심 좌표를 구하는 방법 : 레이캐스트 또는 비례식 사용
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
* 비례식 이용하여 좌표 구하기
<정의>
- 카메라의 좌표 : (cX, cY, cZ)
- 유닛의 좌표 : (uX, uY, uZ)
- 구해야 할 plane 위의 정점 좌표 : (pX, pY, pZ)
pX - cX : uX - cX = cY - pY : cY 이고,
cY(pX - cX) = (uX - cX)(cY - pY),
pX - cX = (uX - cX)(cY - pY)/cY,
pX = (uX - cX)(cY - pY)/cY + cX 이다.
pX, pZ를 제외한 모든 값을 사전에 알고 있으며
동일한 방법으로 pZ도 구할 수 있다.
[2] 타일맵 기반 구현
타일맵을 이용한 구현
타일맵
-
정사각형 타일 하나의 가로, 세로 너비와 Plane의 가로, 세로 너비를 결정한다.
(예 : 타일 0.5x0.5, Plane 20x20 -> 타일 개수 : (20/0.5 * 20/0.5) = 1600개 )
-
게임 시작 시 각각의 타일마다 지형의 높이(position.y)를 계산해 2차원 배열로 저장한다.
(-Y 방향 레이캐스트 이용, 배열의 크기는 타일의 가로 개수x세로 개수)
유닛
- 시야를 밝힐 대상 유닛들은 리스트를 통해 실시간으로 관리된다.
Visit 배열
-
배열의 크기는 타일의 개수와 같다.
-
Visit.current 배열은 현재 유닛들의 시야가 유지되는 타일들에 대해 true 값을 가지며,
매 주기마다 시야 계산을 하기 전에 전체를 false로 초기화한다.
-
Visit.ever 배열은 한 번이라도 시야가 확보됐던 타일들에 대해 true값을 가지며,
한 번 true가 된 타일은 항상 그 값을 true로 유지한다.
-
주기적인 시야 계산을 통해 Visit 배열을 갱신한다.
시야 계산
-
주기적으로(0.2~0.5초) 각 유닛들이 위치한 타일 기준으로 주변의 시야를 계산한다.
- [1] 유닛의 시야 만큼의 원형 범위 내 모든 타일들을 가져와 검사한다.
- [2] 현재 유닛 타일보다 높은 곳에 위치한 타일들은 배제한다.
- [3] 장애물 검사 알고리즘을 통해, 유닛과 해당 타일 사이에 장애물이 위치하는 경우 해당 타일을 배제한다.
- [4] 결과로 얻은 타일들에 대해 Visit.current 및 Visit.ever 배열의 해당 값들을 true로 초기화한다.
장애물 검사 알고리즘
-
시야 계산을 위해, 목표 지점의 타일과 유닛이 위치한 타일 사이에
유닛보다 더 높은 위치에 있는 타일(장애물)이 존재하는지 검사하는 알고리즘
1. 내적을 이용한 알고리즘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<정의>
- U : 유닛이 위치한 타일
- C : 현재 검사 중인 타일
- O : 장애물 타일(U보다 더 높은 위치에 있는 타일)
- AB : XY평면에서 A에서 B를 향하는 직선
- dist(AB) : XY평면에서 A와 B 사이의 직선거리
- nDot(AB, AC) : XY 평면에서 AB, AC를 각각 정규화한 값을 서로 내적한 값
- T : 시야를 밝힐 수 있는 타일
- F : 시야를 밝힐 수 없는 타일
[1] U 기준으로 원형 범위 내에 있는 모든 타일들을 리스트(InRangeList)로 가져온다.
[2] 그 중, O에 해당하는 타일들은
InRangeList에 넣지 않고 장애물 리스트(ObstacleList)에 넣는다.
[2-1] ObstacleList의 크기가 0인 경우,
InRangeList 내 모든 타일들을 T로 설정하고 [5]로 이동한다.
[3] ObstacleList를 유닛과 장애물 타일 사이의 XY평면상 직선거리로 오름차순 정렬한다.
[4] InRangeList 내 모든 타일들에 대해 각각 순회하며 검사한다.
[4-1] UC가 UO의 최솟값보다 작은 경우 C <- T
[4-2] dist(UC) > dist(UO)이면서 nDot(UC, UO) > 0.9인 O가 존재하지 않는 경우 C <- T
[4-3] [4-1], [4-2]에 모두 해당하지 않는 경우 C <- F
[5] 모든 T에 대해 시야를 밝힌다.
2. 타일 전진 알고리즘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<정의>
- U : 유닛이 위치한 타일
- C : 현재 검사 중인 목표 타일
- O : 장애물 타일(유닛보다 더 높은 위치에 있는 타일)
- G :U와 C를 지나는 직선의 기울기
- aG : G의 절댓값
- T : 시야를 밝힐 수 있는 타일
- F : 시야를 밝힐 수 없는 타일
U와 C의 관계를 4가지로 분류하여 진행한다.
[1] 동일한 X좌표를 가진 경우
- U에서 C까지 Y좌표를 한 칸씩 전진시킨다.
- C에 도달할 때까지 O를 하나라도 발견하면 C <- F
- O를 발견하지 못하면 C <- T
[2] 동일한 Y좌표를 가진 경우
- U에서 C까지 X좌표를 한 칸씩 전진시키며, [1]과 같다.
[3] aG >= 1인 경우
- U로부터 X축으로 전진한 거리의 절댓값을 aX,
Y축으로 전진한 거리의 절댓값을 aY라고 정의한다.
- C에 도달할 때까지 aX / (aY+1) < aG일 때 X축으로 1칸 전진하며,
aX / (aY+1) >= aG일 때는 Y축으로 1칸 전진한다.
- O를 하나라도 발견한 경우 C <- F, 발견하지 못한 경우 C <- T
[4] aG < 1인 경우
- aX, aY의 정의는 [3]과 같다.
- C에 도달할 때까지 aY / (aX+1) < aG일 때 Y축으로 1칸 전진하며,
aY / (aX+1) >= aG일 때는 X축으로 1칸 전진한다.
- O를 하나라도 발견한 경우 C <- F, 발견하지 못한 경우 C <- T
결과로 얻은 모든 T에 대해 시야를 밝힌다.
-
그림으로 표현하면 다음과 같다.
(초록색 : 유닛 위치, 파란색 : 시야 탐색 대상, 갈색 : 장애물)
- 장애물을 발견하지 못한 경우(C <- T) :
- 장애물을 발견한 경우(C <- F) :
-
그림 내에 보이는 모든 타일에 대한 T/F 판정 :
(T : 초록색, F : 빨간색)
Fog 텍스쳐
-
텍스쳐의 색상 저장을 위한 Color 배열이 필요하며, 크기는 타일의 개수와 같다.
-
Fog 텍스쳐의 넓이는 타일의 가로 개수 * 세로 개수와 같다.
-
시야 계산이 끝날 때마다 Visit.current, Visit.ever 값에 따라 Color 배열의 알파값을 지정한다. (current -> 0, ever -> 0.5 ~ 0.8, 미방문 타일 1)
-
Color 배열을 텍스쳐에 적용한다.
Fog 쉐이더
- Fog 쉐이더는 ZTest Off로 설정하여 항상 다른 오브젝트들 위에 보이게 한다.
블러, 보간 효과
-
시야 계산이 끝날 때마다 쉐이더를 통해 가우시안 블러를 적용한다.
-
부자연스러운 픽셀이 나타나는 것을 방지하기 위해 렌더 텍스쳐를 여러 장 거쳐 가우시안 블러를 적용한다.
-
매 프레임마다 이전 프레임의 시야 텍스쳐를 현재 프레임의 텍스쳐에 부드럽게 보간하여 적용한다.
구현 결과
[1] Gizmo를 통해 유닛의 가시 영역 확인
[2] 실제 게임 뷰에서 시야 변화 확인
프로파일링, 최적화
Job + Burst Compiler 적용
- 장애물 검사 알고리즘은 타일 전진 알고리즘을 사용했으며, 프로파일링 결과 주요 병목이라고 생각하여 Job System을 적용해보았다.
테스트 1
- Fog Width : 40x40
- Tile Size : 1
- Update Cycle : 0.2
- Number of Units : 105
- 상단 : 미적용 / 하단 : 적용
테스트 2
- Fog Width : 40x40
- Tile Size : 0.5
- Update Cycle : 0.5
- Number of Units : 105
- 상단 : 미적용 / 하단 : 적용
잡 시스템 적용 결과
- 별 차이가 없다. 그래서 프로파일링을 구체화해서 다시 확인해보았다.
병목 재확인 및 최적화
- 예상 외의 결과였다.
-
ComputeFog_1 부분은 시야 범위 타일 얻어오기, 2는 위에서 잡으로 돌린 장애물 계산, 3이 시야 정보를 업데이트하고 Blit하는 부분이다.
- 그래서 GPU 병목이겠거니 하고 더 세분화 했는데,
- 저부분은..
-
단순히 방문 정보를 이용해 컬러 배열의 알파값을 초기화하는 부분이었다.
- 그래서 방문 정보를 저장하는 방식을 바꿨다.
- 기존에는
1
2
3
4
5
6
7
8
9
10
public struct Visit
{
/// <summary> 현재 위치함 </summary>
public bool current;
/// <summary> 과거에 방문한 적 있음 </summary>
public bool ever;
}
private Visit[] visit;
- 두 개의 bool 변수를 이용해 방문 여부, 과거 방문, 미방문으로 구분했지만
- 변경 이후
1
private float[] visit;
- 이렇게 단순히 float 배열을 통해 알파값을 직접 배열에 넣고,
- 컬러 버퍼에는 그 알파값을 직접 넣도록 변경했다.
- 그 결과,
- 이 부분의 병목을 많이 줄일 수 있었다.
가비지 최적화
-
ComputeFog(), GetVisibleTilesInRange() 메소드 내에서 List를 지역적으로 할당하여 사용하는 부분이 세 군데 있었다.
-
예를 들어,
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
List<FowTile> tilesInSight = new List<FowTile>();
for (int i = -sightRangeInt; i <= sightRangeInt; i++)
{
for (int j = -sightRangeInt; j <= sightRangeInt; j++)
{
if (i * i + j * j <= rangeSquare)
{
var tile = GetTile(pos.x + i, pos.y + j);
if (tile != null)
{
tilesInSight.Add(tile);
}
}
}
}
- 이런 부분들에서 사용하는 List를 모두 필드로 옮겨주고
1
2
tilesInSight.Clear();
// ... Same Codes
- 이렇게 Clear()하여 재사용하는 방식으로 바꾸어 주었더니
- GC 호출을 크게 줄일 수 있었다.
- 프로파일링 결과 :
- Fog Width : 40x40
- Tile Size : 1
- Update Cycle : 0.2
- Number of Units : 105