Carrot
본문 바로가기
Unity/멋쟁이사자처럼 부트캠프

[멋쟁이사자처럼부트캠프] 유니티 게임 개발 5기(44일차) - FPS 게임 (1) & Raycast 실습

by 독기품은토끼 2025. 7. 18.
✅ 오늘의 학습 목표
1. 3D FPS 슈터 게임 제작 (1) - 교재 316p
2. Raycast 실습

🥕 예행 작업
1. Scene 생성 (3D FPS Shooter)

 

1. 마우스 움직임에 따른 카메라 회전

1인칭 화면이 마우스 움직임에 따라 회전되는 기능을 구현하려고 한다.

1. Input Manager

 

유니티의 Input 클래스에는 마우스의 좌우(X축), 위아래(Y축) 움직임을 자동으로 감지해 주는 Mouse X, Mouse Y 항목이 미리 설정되어 있어 손쉽게 구현할 수 있다.

 

using UnityEngine;

public class CamRotate : MonoBehaviour
{
    public float rotSpeed = 200f;

    void Update()
    {
        float mouse_X = Input.GetAxis("Mouse X");
        float mouse_Y = Input.GetAxis("Mouse Y");

        Vector3 dir = new Vector3(-mouse_Y, mouse_X, 0f);

        transform.eulerAngles += dir * rotSpeed * Time.deltaTime;

        // 상하 360도 회전 방지
        Vector3 rot = transform.eulerAngles;
        rot.x = Mathf.Clamp(rot.x, -90f, 90f);
        transform.eulerAngles = rot;
    }
}

 

인생 유니티 교과서 [그림 4.1-8]

 

이때 주의할 점은 회전 축이다.

물체를 좌우로 회전시키기 위해 Y축을 기준으로 회전을 구현하여야 하고

마찬가지로 위아래로 회전시키기 위해 X축을 기준으로 회전을 구현하여야 한다.

 

그리고 x축 회전의 경우 양수(+) 방향으로 회전 시 아래쪽으로 회전하고,

음수(-) 방향으로 회전 시 위쪽으로 회전하기 때문에 마우스 상하 입력값을 반대로 적용해야 한다.

 

더보기

짐벌락은 특정 회전 각도에서 회전 축 두 개가 겹치면서 하나의 축이 사라지는 것처럼 보이는 현상이다.
예를 들어 X축을 90도 회전시킨 상태에서 Y축을 돌리면
Y축이 Z축이랑 겹쳐져버려서 둘 중 하나의 축 회전이 제대로 동작하지 않게 된다.
이때는 카메라가 갑자기 튀거나 회전이 막히는 것처럼 보이기도 한다.

그래서 유니티에서는 내부적으로 쿼터니언(Quaternion)이라는 회전 방식을 사용한다.
쿼터니언은 이런 짐벌락 문제없이부드럽고 안정적으로 회전이 가능하다.

 

2. Degree 문제점 보완

상하 회전을 제한하려고 Mathf.Clamp()를 사용해서 각을 -90도~90도 값으로 제한하였다.

그런데 transform.eulerAngles는 내부적으로 0도부터 360도까지의 값을 쓰기 때문에 x 값이 270도 같은 식으로 나올 수 있다.

그래서 Clamp로 -90도~90도를 제한해도 정상적으로 작동되지 않는다. (계속 꺾이는 문제 발생)

using UnityEngine;

public class CamRotate : MonoBehaviour
{
    public float rotSpeed = 200f;

    public float mx = 0;
    public float my = 0;

    void Update()
    {
        float mouse_X = Input.GetAxis("Mouse X");
        float mouse_Y = Input.GetAxis("Mouse Y");

        mx += mouse_X * rotSpeed * Time.deltaTime;
        my += mouse_Y * rotSpeed * Time.deltaTime;

        // 상하 360도 회전 방지
        my = Mathf.Clamp(my, -90f, 90f);

        transform.eulerAngles = new Vector3(-my, mx, 0);
    }
}

 

마우스 입력을 바로 transform.eulerAngles에 더하지 않고,
입력값을 따로 누적해서 각도를 계산한 뒤

최종적으로 transform.eulerAngles = new Vector3(...)로 회전을 적용하는 방식으로 바꿔주었다.

 

3. 카메라 회전에 따라 캐릭터 회전

using UnityEngine;

public class PlayerRotate : MonoBehaviour
{
    public float rotSpeed = 200f;

    public float mx = 0;

    void Update()
    {
        float mouse_X = Input.GetAxis("Mouse X");

        mx += mouse_X * rotSpeed * Time.deltaTime;

        transform.eulerAngles = new Vector3(0, mx, 0);
    }
}

 

캐릭터 회전은 좌우 회전 기능만 있으면 되기 때문에 CamRotate 스크립트에서 x축의 값만 가져와주었다.

 

using UnityEngine;

public class CamFollow : MonoBehaviour
{
    public Transform target;

    void Update()
    {
        transform.position = target.position;
    }
}

 

Player에 빈오브젝트를 만들어주어 카메라가 해당 타겟을 기준으로 따라다닐 수 있도록 스크립트를 작성해 주었다.

 

 

 

2. 캐릭터 조작

1. 이동

1.1. 절대 좌표

using UnityEngine;

public class FPSPlayerMove : MonoBehaviour
{
    public float moveSpeed = 7f;

    void Update()
    {
        float h = Input.GetAxis("Horizontal");
        float v = Input.GetAxis("Vertical");

        Vector3 dir = new Vector3 (h, 0, v);
        dir = dir.normalized;

        transform.position += dir * moveSpeed * Time.deltaTime;
    }
}

 

 

이렇게 작성한 코드는 

이동 방향 벡터(dir)의 기준이 되는 주체가 없어서 캐릭터가 회전해도 이동 방향이 바뀌지 않고 절대 좌표 기준으로 이동하게 된다.

따라서 이 이동 벡터를 상대 좌표로 이동되도록 벡터의 방향을 수정해주어야 한다.

 

1.2. 상대 좌표

using UnityEngine;

public class FPSPlayerMove : MonoBehaviour
{
    public float moveSpeed = 7f;

    void Update()
    {
        float h = Input.GetAxis("Horizontal");
        float v = Input.GetAxis("Vertical");

        Vector3 dir = new Vector3 (h, 0, v);
        dir = dir.normalized;

        // 카메라의 Transform 기준으로 변환
        dir = Camera.main.transform.TransformDirection(dir);

        transform.position += dir * moveSpeed * Time.deltaTime;
    }
}

 

 

▶ TransformDirection()

Transform 컴포넌트가 붙어있는 게임 오브젝트를 기준으로 상대 방향 벡터를 변환해주는 함수

 

Camera 클래스의 main 변수는 메인 카메라 오브젝트를 가리키고,

메인 카메라에도 transform 변수가 있으므로 dir 변수의 이동 벡터를 TransformDirection() 활용해서 변환해 주었다.

 

그런데 이 코드도 중력이 적용되어있지 않기 때문에 하늘 위로 날거나 땅 아래로 박히는 문제점이 있다.

 

1.3. Character Controller를 통해 중력 적용

Character Controller는 Rigidbody랑은 다르게 중력이나 힘을 직접 계산하지 않고도 캐릭터 이동과 충돌 처리를 할 수 있게 도와주는 컴포넌트다.

 

🚨 Character Controller와 Rigidbody의 차이점

기능 Character Controller Rigidbody
충돌 처리 O O
중력 적용 직접 구현해야 함 자동 적용
힘/가속도 기반 이동 X O
계단/경사면 자동 처리 O 직접 처리 필요

 

using UnityEngine;

public class FPSPlayerMove : MonoBehaviour
{
    private CharacterController cc;

    public float moveSpeed = 7f;

    private float gravity = -20f; // 중력
    private float yVelocity = 0f; // 수직 속력

    void Start()
    {
        cc = GetComponent<CharacterController>();
    }

    void Update()
    {
        float h = Input.GetAxis("Horizontal");
        float v = Input.GetAxis("Vertical");

        Vector3 dir = new Vector3 (h, 0, v);
        dir = dir.normalized;

        // 카메라의 Transform 기준으로 변환
        dir = Camera.main.transform.TransformDirection(dir);

        // 중력 적용
        yVelocity += gravity * Time.deltaTime;
        dir.y = yVelocity;

        cc.Move(dir * moveSpeed * Time.deltaTime); // 캐릭터 컨트롤러에 내장된 이동 기능
    }
}

 

 

이렇게 Character Controller 컴포넌트를 활용해서 카메라가 위아래로 향하더라도 바닥에 붙어 이동하도록 구현해 주었다.

 

2. 점프

public class FPSPlayerMove : MonoBehaviour
{
    public float jumpPower = 10f;
    
    void Update()
    {
        if (Input.GetButtonDown("Jump"))
            yVelocity = jumpPower;
    }
}

 

 

Input Manager에 미리 설정된 "Jump" 키 (Spacebar)를 누르면 yVelocity 값에 점프력을 추가하여 캐릭터가 점프될 수 있도록 구현해 주었다.

그런데 높은 곳에서 아래쪽으로 떨어질 때 yVelocity 값이 음의 방향으로 계속 누적되면서 값의 크기는 커졌지만 Character Controller의 충돌 처리 때문에 낙하 속도가 엄청 빨라져 순간이동 느낌이 나타난다.

 

public class FPSPlayerMove : MonoBehaviour
{
    public float jumpPower = 10f;
    public bool isJumping = false;

    void Update()
    {
        // 아래쪽에 무언가 닿은 상태
        if (cc.collisionFlags == CollisionFlags.Below)
        {
            if (isJumping)
                isJumping = false;

            yVelocity = 0f;
        }

        if (Input.GetButtonDown("Jump") && !isJumping)
        {
            isJumping = true;
            yVelocity = jumpPower;
        }
    }
}

 

우선 중복 점프를 막기 위해 bool 타입의 변수를 만들어주었고

캐릭터 컨트롤러 클래스의 CollisionFlags 변수를 사용하여 아래쪽에 무언가 닿은 상태인지를 체크하여 bool 값을 변경해 주었다.

 

그리고 아래쪽에 무언가 닿은 상태라면 yVelocity 값을 0으로 초기화해주어 수직 속도가 순간이동 느낌이 나지 않도록 해주었다.

 

 

 

3. 무기 제작

1. 투척 무기

1.1. 수류탄 폭발

using UnityEngine;

public class BombAction : MonoBehaviour
{
    public GameObject bombEffect;

    private void OnCollisionEnter(Collision collision)
    {
        GameObject eff = Instantiate(bombEffect);
        eff.transform.position = transform.position;

        Destroy(gameObject);
    }
}

 

수류탄 오브젝트를 만들어주고

해당 오브젝트가 무언가와 충돌이 감지되면 폭발 이펙트와 함께 자기 자신(수류탄)이 제거되도록 구현해 주었다.

 

 

이때 플레이어와의 충돌은 감지되지 않도록 레이아웃도 함께 설정해 주었다.

 

1.2. 이펙트 제거

using UnityEngine;

public class DestroyEffect : MonoBehaviour
{
    public float destroyTime = 2f;

    private float currentTime = 0f;

    void Update()
    {
        if (currentTime > destroyTime)
        {
            Destroy(gameObject);
        }

        currentTime += Time.deltaTime;
    }
}

 

 

폭발 효과로 생성된 이펙트도 제거될 수 있도록 스크립트를 만들어 주었고 해당 스크립트는 폭발 이펙트 프리팹에 넣어주었다.

참고로 이펙트의 Playback Time을 참고하여 Destroy Time을 결정해 주었다.

 

1.3. 수류탄 투척

using UnityEngine;

public class FPSPlayerFire : MonoBehaviour
{
    public GameObject firePosition;
    public GameObject bombFactory;

    public float throwPower = 15f;

    void Update()
    {
        if (Input.GetMouseButtonDown(1))
        {
            GameObject bomb = Instantiate(bombFactory);
            bomb.transform.position = firePosition.transform.position;

            Rigidbody rb = bomb.GetComponent<Rigidbody>();
            rb.AddForce(Camera.main.transform.forward * throwPower, ForceMode.Impulse);
        }
    }
}

 

 

 

2. 발사 무기 / 레이캐스트(Raycast)

🥕 예행 작업
1. Asset 다운로드

 

여태 우리가 해왔던 실습에서의 총알 발사는 프리팹을 Instantiate해서 날리는 방식으로 구현했었는데
이번에는 레이캐스트(Raycast)를 사용해서 총알을 구현해 보겠다.

 

🚨 레이캐스트(Raycast)란?
특정 방향으로 가상의 선(Ray)을 쏴서 그 선에 맞는 물체가 있는지 Collider를 체크하는 기능
즉, 눈에 보이는 총알 없이도 마우스 클릭 한 번으로 맞은 물체를 판별할 수 있다.

using UnityEngine;

public class FPSPlayerFire : MonoBehaviour
{
    public GameObject bulletEffect;
    public ParticleSystem ps;

    void Start()
    {
        ps = bulletEffect.GetComponent<ParticleSystem>();
    }

    void Update()
    {
        // 총알 발사
        if (Input.GetMouseButtonDown(0))
        {
            // 레이 생성 후 발사될 위치와 발사 방향 지정
            Ray ray = new Ray(Camera.main.transform.position, Camera.main.transform.forward);

            // 레이가 부딪힌 대상의 정보를 변수로 저장
            RaycastHit hitInfo = new RaycastHit();

            // 레이를 발사한 후 부딪힌 물체가 있는 경우 피격 이펙트 발동
            if (Physics.Raycast(ray, out hitInfo)) // 시작위치, 방향, out hitInfo, 거리
            {
                bulletEffect.transform.position = hitInfo.point; // 피격 이펙트가 부딪힌 대상의 위치로 이동
                ps.Play(); // 피격 이펙트 플레이
            }
        }
    }
}

 

 

이때 총알 이펙트는 Instantiate로 생성시켜 주는 것이 아니기 때문에 이펙트가 씬 상에 존재해야 한다.

그래서 인스펙터 창에서 오브젝트를 연결해 줄 때 씬에 있는 이펙트를 연결해 주었다.

 

 

그리고 총알 발사를 좀 더 생동감 있게 구현해 주기 위해 크로스헤어 UI를 생성해 주었다.

Sprite 편집이 불가능할 경우 패키지 매니저에서 2D를 다운로드 받아주면 된다.

 

 

이제 피격 이펙트가 법선 벡터 방향으로 나타나게 하는 작업을 해줄 것이다.

 

🚨 법선 벡터 

폴리곤의 정면에서 외부로 뻗어나가는 벡터

즉, 면과 수직인 벡터

 

예를 들어 총을 벽에 쐈다면 파티클 이펙트가 벽 안쪽이 아니라, 벽 바깥쪽을 향해 나타나도록 해주는 작업이라는 뜻이다.

// 레이를 발사한 후 부딪힌 물체가 있는 경우 피격 이펙트 발동
if (Physics.Raycast(ray, out hitInfo)) // 시작위치, 방향, out hitInfo, 거리
{
    bulletEffect.transform.position = hitInfo.point; // 피격 이펙트가 부딪힌 대상의 위치로 이동
    bulletEffect.transform.forward = hitInfo.normal;
    ps.Play(); // 피격 이펙트 플레이
}

 

 

만약 Ray가 제대로 작동하지 않는다면 Ray가 제대로 발사되고 있는지 피직스 디버거를 통해 확인한다.

 

 

4. Character Controller 추가 학습

캐릭터 컨트롤러에 대해 조금 더 학습해보자.

아까 캐릭터 컨트롤러는 계단이나 경사 같은 구조물 위에서 별다른 구현 없이 걸을 수 있다고 했었다.

이 부분을 한번 확인해 보겠다.

 

 

계단을 만들어주기 위해 ProBuilder를 다운로드 받아준다.

 

 

캐릭터 컨트롤러의 스텝 값에 따라 경사나 계단을 오를 수 있는 한계가 있다.

 

 

그래서 오브젝트를 배치할 때 계단의 높이나 경사로의 각을 잘 지정해 주어야 캐릭터가 움직일 수 있다.

 

5. Raycast 추가 학습

🥕 예행 작업
1. Asset 다운로드
 

LOWPOLY - Weapon Pack | 3D 무기 | Unity Asset Store

Elevate your workflow with the LOWPOLY - Weapon Pack asset from IV Art. Find this & other 무기 options on the Unity Asset Store.

assetstore.unity.com

 

1. 화살 발사

using System.Collections;
using UnityEngine;

public class CrossBow : MonoBehaviour
{
    public GameObject arrowPrefab;
    public Transform shootPos;
    public bool isShoot;

    void Update()
    {
        Ray ray = new(shootPos.position, shootPos.forward);
        RaycastHit hit;

        bool isTargeting = Physics.Raycast(ray, out hit);

        if (isTargeting && !isShoot)
            StartCoroutine(ShootRoutine());
    }

    IEnumerator ShootRoutine()
    {
        isShoot = true;

        GameObject arrow = Instantiate(arrowPrefab, transform); // 자식
        Quaternion rot = Quaternion.Euler(new Vector3(90, 0, 0));
        arrow.transform.SetPositionAndRotation(shootPos.position, rot);

        yield return new WaitForSeconds(3f);
        isShoot = false;
    }
}

 

  • Raycast를 사용해서 정면에 무언가 감지되면 자동으로 화살을 발사함
  • ShootRoutine() 코루틴을 통해 화살이 연속해서 발사되지 않도록 쿨타임(3초)을 줌
  • Instantiate()로 화살 프리팹을 만들어서 shootPos 위치에 생성하고, 올바른 방향으로 회전값 설정해 줌

 

2. 화살 충돌 처리

using UnityEngine;

public class Arrow : MonoBehaviour
{
    public float moveSpeed = 100f;
    public bool isMove = true;

    void Update()
    {
        if (isMove)
            transform.position += transform.up * moveSpeed * Time.deltaTime;
    }

    // 화살 박히는 거 구현
    private void OnTriggerEnter(Collider other)
    {
        var closetPos = other.ClosestPoint(transform.position);

        transform.position = closetPos;
        transform.SetParent(other.transform);
        isMove = false;
    }
}

 

  • 화살은 생성된 후 transform.up 방향으로 앞으로 계속 날아감
  • OnTriggerEnter()에서 무언가에 부딪히면
    • 그 표면에 가장 가까운 위치에 화살을 고정하고
    • 그 오브젝트를 부모로 설정해서 붙게 만듦
    • isMove = false로 바꿔서 더 이상 움직이지 않도록 멈춤

 

3. Raycast 확인하는 방법

1.1. Physics Debugger

 

1.2. DrawRay

void Update()
{
    Ray ray = new Ray(shootPos.position, shootPos.forward);
    RaycastHit hit; // 레이저 닿은 대상

    bool isTargeting = Physics.Raycast(ray, out hit);

    Debug.DrawRay(shootPos.position, shootPos.forward * 100f, Color.green);
}

 

1.3. DrawGizmo

private void OnDrawGizmosSelected()
{
    Gizmos.color = Color.blue;
    Gizmos.DrawRay(shootPos.position, shootPos.forward * 100f);
}