팀프로젝트를 마무리 단계는 코드를 분석하여서 정리하기로 하였습니다. 전 플레이어 연출인 카메라 부분 및 낙하 판정을 하는 부분을 분석해보려고 합니다.
협업한 친구가 코드를 잘 정리하여 보내주었습니다.
굿
카메라가 벽에 닿을 시 처리하는 부분
카메라 값
/*초기 값 설정*/
private float defaultPosition;
private float targetPosition;
public Transform cameraTransform;
defaultPosition = cameraTransform.localPosition.z;
cameraTransform.localRotation = Quaternion.identity;
cameraTransform.parent = pivotTransform;
// TPS 카메라 보정
// 카메라와 플레이어 사이에 물체가 있을 때 레이로 충돌판정, z 거리조절
private void CameraCollision()
{
// 카메라 기본위치
// 충돌이 없는경우 이 값으로 이동 될 수 있게 따로 변수 만듦
targetPosition = defaultPosition;
RaycastHit hit;
// 카메라에서 카메라 pivot 으로 향하는 방향벡터
Vector3 direction = cameraTransform.position - pivotTransform.position;
// 정규화(방향만 필요함)
direction.Normalize();
// 스피어캐스트 사용, pivotTransform.position위치에서 direction 방향으로 광선을 쏘고 그 광선을 따라서
// 반지름 0.2크기의 구를 날려서 부딪히는지 체크, 마지막 매개변수는 광선의 사거리로 카메라와 플레이어 사이 기본거리
if (Physics.SphereCast(pivotTransform.position, 0.2f, direction, out hit, Mathf.Abs(targetPosition)))
{
// 플레이어와 카메라 사이 물체가 있는경우
// distance = pivot과 충돌물체 사이의 거리값 저장
float distance = Vector3.Distance(pivotTransform.position, hit.point);
// 카메라 위치값 보정
// 카메라z 위치 = distance - 보정값(카메라 얼마나 플레이어 쪽으로 땡길지 값)
// - 붙은 이유 : 카메라 로컬 좌표, 플레이어 뒤에서 찍기 때문에
targetPosition = -(distance - tps_CameraCollisonDistance);
}
// 위에서 보정 후에
// 카메라와 pivot 사이거리가 보정값보다 짧은경우, 즉 너무 벽에 붙어있어서 벽과 플레이어 사이거리가 보정값 보다 짧은 경우
if (Mathf.Abs(targetPosition) < tps_CameraCollisonDistance)
{
// 최소 플레이어와 카메라 사이 거리를 지정해줌
// 최소값 따로 안만들고 보정값이 적당해서 그대로 사용함
targetPosition = -tps_CameraCollisonDistance;
}
// 보정한 값
// Lerp로 부드럽게 이동시킴
cameraPosition.z = Mathf.Lerp(cameraTransform.localPosition.z, targetPosition, Time.deltaTime / tps_cameraOffsetSpeed);
// 카메라 z위치 최종 보정값 실제 반영
cameraTransform.localPosition = cameraPosition;
// 카메라가 바라보는 기준을 pivot으로 해놓은 이유 : 플레이어에 변동사항이 생겨도 수정이 용이하게 하기위함
// 스피어캐스트 사용이유 : 더 정밀하게 보정할 수 있기 때문에, 그냥 레이캐스트 사용시 체크하지 못하는 부분까지 체크해줌
}
플레이어 낙하 판정 부분
레이캐스트를 이용한 판정 (빨간선)값 설정
// 플레이어 낙하 판정
// 바닥 거리판정이기 때문에 여기서 플레이어 y위치 처리도 함
private void PlayerFalling()
{
RaycastHit hit;
// 캡슐콜라이더의 높이의 절반 길이
float length = capsuleCollider.height * 0.5f;
// 콜라이더 박스의 중심 점
Vector3 origin = capsuleCollider.bounds.center;
// 레이캐스트, 플레이어의 콜라이더 중심으로 부터 아래 방향으로 콜라이더 절반 길이 + 최소 낙하판정 거리
// 사거리 저렇게 두고 충돌하면 바닥에 닿고 있거나 떨어져도 낙하 동작 나올만큼의 높이가 아닌 경우
if (Physics.Raycast(origin, Vector3.down, out hit, length + minimumDistanceFalling))
{
// 확인용
Debug.DrawRay(origin, Vector3.down * (length + minimumDistanceFalling), Color.red);
// 이 함수 안에 들어왔으면 바닥에 닿고 잇는 상태
inputCtrl.isGround = true;
// hit.distance = 부딪힌 지점까지의 거리
// 이 거리가 콜라이더 절반 길이보다 길면(= 바닥에 서 있는 모든 경우)
if (hit.distance > length)
{
// 플레이어의 위치 저장, 현재 위치에서 y값만 보정해주기 위해서
Vector3 newPos = transform.position;
// 현재 위치의 y값을 충돌지점의 y값으로 바꾸어준다. 즉 바닥의 y좌표랑 플레이어 y좌표랑 똑같이해서 바닥아래 발이 안빠지게 함
newPos.y = hit.point.y;
// 변경한 위치를 실제 플레이어 위치에 반영
transform.position = newPos;
}
주말동안 알고리즘 책을 읽고 나서 문득 생각해보니 코드테스트 전 합격 여부인 포폴이 중요한데
포폴을 좀 더 정리해야겠다고 생각해서 문서를 보강하기로 했습니다.
먼저 애너미 구조 설계를 상속 형태로 리팩토링, AI를 Nav Mesh Agent로 구현, 애너미 애니메이션을 살펴보겠습니다.
리팩토링전 스크립트입니다. 보기와 같이 fleldOfView와 같이 외부 스크립트 의존성이 높고 전체적 가독성이 떨어지며 확장성, 다양성이 좋지 못합니다.
리팩토링 후 스크립트입니다. 가독성은 말할 것도 없이 좋아졌고 MeleeEnemy라는 파생 스크립트로 근거리 애너미를 만들었습니다. 다양성이 좋아졌습니다. 확장 또한 가능하게 세분화한 상태입니다.
상속을 사용 시 협업 시 유리합니다. EnemyAttack 이라는 함수를 만들지는 않았지만 상위 스크립트에 추상적으로 만들어놓기만 하여도 다른 사람이 EnemyAttack를 가져가 사용할거나 재정의가 가능합니다. 아무런 기능을 하지 않아도 말입니다. 이런면에서 전체적인 틀만 만들어놓으면 재정의 후 수정해서 기능을 다시 만들수도 있습니다.
추적, 정찰, 정지 Enemy AI입니다. Nav Mesh Agent로 구현하였고 아래와 같은 형식으로 구현됩니다.
사용할 맵을 Navigation를 이용해 Bake한 상태입니다. 건물 오브젝트는 올라가지 못하게 제외하였습니다.
애너미 정찰등 구현 방법은 아래 포인트를 따라가서 가까울 시 멈추고 다음 포인트를 탐색하는 형식입니다. 결국 이동도 이와 같은 형식입니다.
이동 관련 애니메이션은 한번에 처리하였습니다.
if문으로 상태를 검사하여 SetFloat으로 값을 반환하여 블렌더 트리를 이용해 자연스러운 변경을 이끌어냈습니다.
0일땐 Idle 상태 정지 상태, 1일땐 wark 상태 정찰 상태 ,2일땐 run 상태 추적 상태입니다.
0 Idle 정지 상태1 Walk 정찰 상태2 Run 추적 상태
다음 애니메이션 매니저입니다. PlayerTargetAnimation 함수를 가지고 있습니다. 호출해주는 형식입니다. Off 기능이 없는 이유는 아래 애니메이션 구성을 보면 알 수 있습니다.
보시는거와 같이 끝나야하는 행동은 다시 돌아가기만 해논 상태로 호출하고 실행 후 돌아가는 형태입니다.
Die 같은 경우는 죽고 나서 다시 돌아갈 이유가 없으므로 끊어논 상태입니다.
그리고 각 오브젝트 당 애니메이션의 행동이 다르기 때문에 상속을 받아 애니메이션이 실행 시 OnAnimatorMove가 실행되어 각종 오브젝트 별로 값을 재설정해서 애니메이션을 자연스럽게 만들었습니다.
다음 어택 애니메이션의 구현은 두가지 애니메이션을 랜덤 돌립니다. 그리고 리소스를 아끼기 위해 어택 1,2에 대한 값은 스크립터블 오브젝트에 작성해논 값을 참조만 해서 사용합니다. 스크립터블 오브젝트 파일은 에셋에만 존재함으로서 미리 배열에 넣어둔 상태입니다.
어택 1,2 애니메이션을 랜덤 구현Attack 1, Attack 2 스크립터블 오브젝트를 넣어둔 상태
위 파일을 만드는 방법은 아래 코드에 CreateAssetMenu를 이용하여 사용 가능합니다.
YAGNI - You aren't gonna need it : 필요하다고 간주할 때까지 기능을 추가히자 않는것이 좋다.
리팩토링에 필요한 자료를 정리하며 공부했습니다.
GRASP - General, Responsibility, Assignment, Software, Pattern (디자인패턴)
원칙 같은걸 배우고 사용하는건 자신을 위한것도 있지만 협업을 할때 좋기때문입니다. 상속을 사용하는 이유에 대해 의문이 참 많았습니다. 왜 이걸 상속받아서 재정의 해서 사용해야하나, 그걸 질문하니 협업을 위해서 라고 하니 먼가 띵하고 꽂혔습니다. 이부분을 머리에 가지고 아래를 공부하면 좋을꺼 같습니다.
=== 일기장이라서 일기 형식으로 느낀점 적어놨습니다 스킵해도됩니다. ===
자료 조사하던중 의문이 생기는 부분인 리스코프 치환과 상속을 사용할때 예시가 만족스럽지 못해서 예시를 만들어서 가져갔습니다. 적을 만들꺼니 적을 기준으로 예를 들었습니다. 부모 클래스 Enemy와 자식 클래스 Melee Enemy ,Ranger Enemy가 있다고 과정하고 보통 예시는 Move()라는 함수를 만들어서 Enemy 부모 클래스에 만들고 Melee Enemy 클래스에 보통 상속해줘서 재정의 후 사용합니다. 요기서 의문점이 들었습니다. 어짜피 Move라는 이동 함수는 Melee든 Ranger든 같은데 Enemy 부모 클래스에서 Move를 다 만들어서 주면 좋지 않나요? 라고 선생님에게 물어봤습니다. 거기서 위와 같은 답을 얻었습니다. 전에 수업을 하면서 들은 말이 생각나네요 이해 안가도 대략적인 느낌을 알고 넘어가도된다. 이번 질문을 하며 수업때도 들었지만 까먹었던 말이 처음에 배운거라도 다시 보면 다르다라고 했던것도 생각나구요. 이래서 질문을 많이 해야하는것 같습니다. 경력이 있는 분들에게서 배운걸 흡수할려면 자신이 궁금하고 어느정도 지식을 습득한 후에 물어보면 새로운게 보이는거 같습니다.
툰 셰이더 튜토리얼을 해보고 적용 시킬 생각입니다. (기본적인 글의 그림은 위 링크가 출처입니다.)
일단 기본적인 유니티 셰이더로 조명과 상호작용하는 Surface Shaders(서피스 셰이더)를 일반적으로 사용합니다.
방향성 조명 (Directional lighting)
조명 데이터를 받기 위해 코드를 추가합니다.
Tags
{
// 포워드 렌더링에 사용, 앰비언트 라이트, 주 방향 광원, 버텍스/SH 라이트 및 라이트맵이 적용
"LightMode" = "ForwardBase"
// 포워드 렌더링, 위 값의 주방향 광원, 앰비언트/라이트프로브 데이터만 셰이더에 전달됩니다.
"PassFlage" = "OnlyDirectional"
}
Tag는 기본적으로 SubShader 안 Pass 밖에서 사용 "키 태그 이름(TagName)" = "값 명령어(Value)" 이며 , 를 사용하지 않고 갯수 제한이 없음.
이제 프래그먼트 셰이더(Fragment Shader)에서 월드 노말 값을 사용해 내적 연산을 통하여 빛의 방향을 비교 할 수 있습니다.
내적(Dot Product)
길이에 관계없이 두 벡터를 받고 단일 숫자를 반환합니다. 벡터가 평행한 상태이고 방향 벡터(길이가 1 인 벡터)인 경우값은 1입니다. 또는 벡터가 수직이면 0을 반환합니다. 벡터를 평행에서 수직으로 이동하면 내적 결과가 비선형 형태로 1에서 0으로 이동합니다. 벡터 사이의 각도가 90보다 크면 내적은 음수가됩니다.
프래그먼트 셰이더(Fragment Shader)에 다음을 추가합니다. 아래 NdotL 은 수정한 코드입니다.
// 프래그먼트 셰이더 맨 위
float3 normal = normalize(i.worldNormal);
float NdotL = dot(_WorldSpaceLightPos0, normal);
…
// 기존 return 문 수정
return _Color * sample * NdotL;
결과물
사실적인 조명을 구현한 Blinn-Phong 셰이더를 렌더링한 모습입니다.
이제 툰 셰이딩으로 수정하기 위해 밝고 어두운 두 영역으로 나누는 작업입니다.
// NdotL이 계산 후 들어가야함으로 NdotL 아래에 적어줍니다.
float lightIntensity = NdotL > 0 ? 1 : 0;
// return 문 수정
return _Color * sample * lightIntensity;
결과물
엠비언트 라이트 (Ambient light)
어두운 면이 너무 어둡습니다. 주변 광을 추가하겠습니다.
// property에 추가
[HDR]
_AmbientColor("Ambient Color", Color) = (0.4,0.4,0.4,1)
…
// 이름 일치해야함, 프래그먼트 셰이더 위에 기입
float4 _AmbientColor;
// 수정
return _Color * sample * (_AmbientColor + lightIntensity);
결과물
씬에서 Directional Light(조명) 강도나 색상을 수정해도 셰이더에 영향을 주지 않습니다, 이를 조명 계산에 포함하기 코드를 더 추가합니다.
// #include "UnityCG.cginc" 아래
#include "Lighting.cginc"
…
// lightIntensity 아래에 추가
float4 light = lightIntensity * _LightColor0;
// 수정
return _Color * sample * (_AmbientColor + light);
기존 lightIntensity 값을 곱하여 float4에 저장하여 계산에 조명의 색상을 포함합니다. _LightColor0은 주 방향 조명의 색상입니다. Lighting.cginc 파일에 선언 된 fixed4이므로 값을 활용하기 위해 위의 파일을 포함합니다.
입력 하고 조명 색을 바꿀 시 구의 색도 같이 변합니다. 어둡게 할 시 어두워지고요.
더 진행하기 전에 밝음과 어두움 사이의 가장자리를 부드럽게 만듭니다. 빛에서 어둠으로의 전환은 즉각적이며 단일 픽셀에서 발생합니다. 대신 smoothstep 함수를 사용하여 값을 1에서 0으로 부드럽게 블렌딩합니다.
smoothstep (왼쪽)과 선형 함수 (오른쪽)의 비교. 값은 회색조 배경과 빨간색 곡선에 매핑됩니다.
smoothstep은 선형이 아닙니다. 값이 0 -> 0.5로 가속되고 0.5 -> 1로 감속됩니다. 이것은 매끄럽게 블렌딩하는데 이상적이며, 라이트 강도 값을 블렌딩하는 데 사용할 방법입니다.
// 수정
float lightIntensity = smoothstep(0, 0.01, NdotL);
좌 <변경 전> 우 <변경 후>
하한값과 상한값 인 0과 0.01은 가깝습니다. 이는 상대적으로 왜곡된 가장자리를 유지하는 데 도움이됩니다. 잘 모르겠으면 0.01 값을 바꿔볼 시 단번에 이해가 가능합니다.
정반사 (Specular reflection)
광원에 의해 만들어진 개별적이고 뚜렷한 반사를 모델링합니다. 이 반사는 표면이 보이는 각도의 영향을 받는다는 점에서 시점에 따라 움직입니다. 정점 셰이더에서 월드 뷰 방향을 계산하고 이를 프래그먼트 셰이더로 전달합니다. 현재 정점에서 카메라를 향하는 방향입니다.
// struct v2f 추가
float3 viewDir : TEXCOORD1;
// vertex shader 추가
o.viewDir = WorldSpaceViewDir(v.vertex);
이제 Blinn-Phong의 specular 구성 요소를 구현하겠습니다. 표면에서 두 가지 속성인 반사를 착색하는 반사광 색상과 반사 크기를 제어하는 광택을 가져옵니다.
// properties에 추가
[HDR]
_SpecularColor("Specular Color", Color) = (0.9,0.9,0.9,1)
_Glossiness("Glossiness", Float) = 32
// 변수명 주의
float _Glossiness;
float4 _SpecularColor;
정반사의 강도는 Blinn-Phong에서 표면의 법선과half vector 사이의 내적으로 정의됩니다. half vector는 보는 방향(카메라)과 광원 사이의 벡터입니다. 이 두 벡터를 합하고 결과를 정규화하여 얻을 수 있습니다.
pow 함수를 사용하여 정반사 크기를 제어합니다. NdotH에 lightIntensity(빛의 세기)를 곱하여 표면의 빛 들어올때만 반사가 되도록합니다. _Glossiness(광택)는 그 자체로 곱해 지므로 머티리얼 에디터에서 더 작은 값이 더 큰 효과를 내고 셰이더 작업을 더 쉽게 할 수 있습니다.
다시 smoothstep을 사용하여 반사를 툰화하고 최종 출력에 _SpecularColor를 곱합니다.
림 라이트 (Rim lighting)
반사광이나 역광을 시뮬레이션하기 위해 물체의 가장자리에 조명을 추가하는 것입니다.툰 셰이더가 평평한 음영 표면에서 오브젝트의 실루엣을 돋보이게하는 데 특히 유용합니다.
개체의 "Rim"은 카메라에서 멀리 향하는 표면으로 정의됩니다. 그러므로 우리는 법선과 뷰 방향의 내적을 취하고 그것을 반전시켜 림을 계산할 것입니다.
// fragment shader에 specular 밑에 작성
float4 rimDot = 1 - dot(viewDir, normal);
// 수정
return _Color * sample * (_AmbientColor + light + specular + rimDot);
// include 기존 항목 아래에 추가
#include "AutoLight.cginc"
// v2f 구조체에 추가
SHADOW_COORDS(2)
// vertex shader에 추가
TRANSFER_SHADOW(o)
그림자를 샘플링하는 데 사용할 여러 매크로가 포함 된 파일 인 Autolight.cginc를 포함합니다.
SHADOW_COORDS (2)는 다양한 정밀도(대상 플랫폼에 따라 다름)로 4차원 값을 생성하고 제공된 인덱스(이 경우 2)에서 TEXCOORD 의미 체계에 할당합니다.
TRANSFER_SHADOW는 입력 정점의 공간을 그림자 맵의 공간으로 변환 한 다음 선언 한 SHADOW_COORD에 저장합니다.
그러나 그림자 맵을 샘플링하기 전에 셰이더가 두 가지 다른 조명 케이스를 처리하도록 설정되어 있는지 확인해야합니다. 주 방향 조명이 그림자를 투사하거나 투사하지 않는 경우입니다. Unity는 각 사용 사례에 대해이 셰이더의 여러 변형을 컴파일하여이 두 가지 구성을 처리하는 데 도움이됩니다.
#pragma multi_compile_fwdbase
Unity가 포워드베이스 렌더링에 필요한 모든 변형을 컴파일하도록 지시합니다. 이제 그림자 맵에서 값을 샘플링하여 조명 계산에 적용 할 수 있습니다.
// 프래그먼트 셰이더에서 lightIntensity 위에 추가
float shadow = SHADOW_ATTENUATION(i);
// 수정
float lightIntensity = smoothstep(0, 0.01, NdotL * shadow);
SHADOW_ATTENUATION은 0과 1 사이의 값을 반환하는 매크로입니다. 여기서 0은 그림자가 없음을 나타내고 1은 완전히 그림자가 있음을 나타냅니다. 주 방향 광에서받은 빛의 양을 저장하는 변수이기 때문에 NdotL에이 값을 곱합니다.
완료.
툰 셰이더는 다양한 그래픽 스타일로 제공되지만 효과를 얻으려면 일반적으로 표준 조명 설정 (Blin-Phong에서했던 것처럼)을 취하고 단계적으로 셰이더 함수를 적용하는 것이 중심입니다.
나중에 공부해볼만한 것 (실제로 노멀과 라이팅 데이터를 사용할 수있는 경우 포스트 프로세스 효과로 수행 할 수 있습니다. 이에 대한 예는 Unreal Engine 4 튜토리얼에서 찾을 수 있습니다.)