https://school.programmers.co.kr/learn/courses/30/lessons/42586

 

풀이

 List<int> answer = new List<int>();

 int n = progresses.Length;
 int[] days = new int[n];

 for (int i = 0; i<n; i++)
 {
     int remain = 100 - progresses[i];
     days[i] = (remain + speeds[i] - 1) / speeds[i];
 }

 int count = 1;
 int curReleaseDay = days[0];
 for (int i = 1; i< n; i++)
 {
     if (curReleaseDay >= days[i])
     {
         count++;
     }
     else
     {
         answer.Add(count);
         curReleaseDay = days[i];
         count = 1;
     }
 }

 answer.Add(count);

 return answer.ToArray();

 

잘못되 접근 방식 : 시뮬레이션 방식으로 하루 하루 더해서 완료가 되는 시점으로 계산하려고 했음

(문제 자체를 시간에 흐름으로 처리하려고 했음)

 

올바른 접근방식 : 

하루가 지남에 따라 완료 시점을 그때마다 확인하는 것이 아니라, 각 기능이 언제 완료되는가를 먼저 구하는 방식이 맞다.

 

이번 문제에서 알아야할 나눗셈 처리

 

        for (int i = 0; i<n; i++)
        {
            int remain = 100 - progresses[i];
            days[i] = (remain + speeds[i] - 1) / speeds[i];
        }

 

1을 빼는 이유는???

왜 단순히 remain / speed를 쓰면 안될까?

C#의 정수 나눗셈은 항상 소수점을 버린다.(내림)

ex) remain = 80, speed = 30일때, remain / speed = 2가 나온다.

 

Day 1 : 0 -> 30

Day 2 : 30 -> 60

Day 3 : 60 -> 90 (완료)

 

하지만 완료되는 시점은 3이다.

그러면 remain에다가 speed만 더하고 나누면 되지 않나? 생각하지만,  그렇다면 -1은 왜필요할까? 

위 예시 remain = 80, speed = 30인 경우는 맞다.

 

하지만, 나눗셈이 딱 떨어지는경우

ex) remain = 60, speed, 30일때는 맞지 않다.

Day 1 : 0 -> 30

Day 2 : 30 -> 60(완료)  2일걸림

 

그래서 나눠 떨어지지 않는 경우와 나눠 떨어지는 경우

모두 고려해서 -1처리가 필요하다.

 

문제를 마무리하면서 느낀점은 과정을 시뮬레이션 하지말고, 결과가 결정되는 시점을 계산하는 방식으로 접근하자..!!!

헷갈리는 키워드를 정리하기 위해 포스팅하게됐다.

1. async / await

- 비동기 프로그래밍 문법

- 실행 흐름을 잠깐 끊었다가, 끝나면 다시 이어서 실행하는 문법

using System.Threading.Tasks;
using UnityEngine;

public class LoginExample : MonoBehaviour
{
    public async Task<string> LoginAsync(string username, string password)
    {
        Debug.Log("로그인 시작");

        // 1초 대기 (메인 스레드를 블로킹하지 않음)
        await Task.Delay(1000);

        Debug.Log("1초 뒤 실행");

        return "Token";
    }
}

- Task.Delay동안 메인스레드는 멈추지 않는다.

- await는 잠시 대기 일뿐이다.

- await가 끝나면 다시 원래 스레드로 돌아간다. 

즉, 이 함수에서는 메인 스레드 외의 다른 워커 스레드를 사용하지 않고 있다.

 

C# Task는 Unity에서 쓰기에는 적합하지 않다.

- GC/할당부담

- Unity 오브젝트 라이프사이클과 매칭

- 유니티 프레임루프와 연결이 느슨함

그래서 선택지는 UniTask다

 

2. UniTask

UniTask는 유니티의 프레임루프 기준으로 동작하도록 만들어진

Unity친화적인 비동기 라이브러리다.

public async UniTask LoginAsync()
{
    await UniTask.Delay(1000);

    // 여기부터 다시 Unity 메인 스레드
    Debug.Log("1초 뒤 실행");
}

가장 큰 특징중에 하나는 await이후에 다시 유니티 메인스레드로 복귀 한다는 점이다.

(await이후 unity api 접근 가능하다는 말)

 

3. 언제 멀티스레드가 되나?

비동기를 사용하면 멀티스레드가 된다고 생각했는데, 아니었다.

명시적으로 워커 스레드를 사용했을때만 멀티스레드가 된다. 

await UniTask.Delay(1000);
//메인스레드
await UniTask.RunOnThreadPool(() =>
{
     //워커스레드
    HeavyWork();
});

//메인 스레드 복귀

 

4. 비동기와 멀티스레드 비교

비동기

- 메인 스레드를 막지 않고 대기 방식이라는것이 핵심이다

- 스레드는 하나 일 수 있다

- 주로 네트워크나 파일I/O, 타이머

=> 기다리는 방식

 

멀티스레드

- 병렬 실행이 핵심

- 스레드가 여러개

- 실행로직을 분리해 무거운 연산에 적합하다.

=> 실행을 나누는 방식

 

5. 코루틴과 비교

코루틴은 비동기가 아니다. 단지 메인스레드에서 비동기적 흐름을 흉내낸것이 맞다. 유니티에서는

유니티 메인스레드에서 실행흐름을 쪼개서 비동기처럼 보이게 만드는 구조이다.

 

 

6. 멀티스레드는 만능인가?

복잡한 연산을 멀티스레드로 구현하면, 플레이에 프리징을 줄일 수 있다.

하지만 만능은 아님

- 스레드 관리비용

- 컨텍스트 스위칭 오버헤드

- 모바일 기기에서는 발열

- CPU 스레드 수 제한등

이런것들을 잘 고려하지 못한다면 오히려 더 나빠질 수 있다.

 

로그인 버튼 클릭 시, 내부 동작 흐름과 결과

이번엔 실제 로그인 버튼을 클릭했을 때

VContainer가 주입한 객체들이 어떻게 연결되어 동작하는지 살펴보자

 

1. LoginPanel(UI진입점)

로그인 시작은 OnLogin()부터

private void OnLoginButtonClick()
{
    onLogin();
}

 

2. Onlogin() 입력 검증과 로그인 시도

private async UniTask onLogin()
{
    if (_isLoggingIn)
    {
        ToastMsg.Show("로그인 처리 중입니다.");
        return;
    }

    string id = usernameInput.text;
    string pw = passwordInput.text;

    if (string.IsNullOrWhiteSpace(id))
    {
        ToastMsg.Show("아이디를 입력해 주세요.");
        return;
    }
    if (string.IsNullOrWhiteSpace(pw))
    {
        ToastMsg.Show("비밀번호를 입력해 주세요.");
        return;
    }

    _isLoggingIn = true;

    var loginParam = new LoginParam { UserID = id, Password = pw };
    var result = await _loginWork.ExecuteWorkAsync(loginParam);

 

샘플프로젝트이기에 간단한 입력공백만 체크하고

LoginParm을 통해 로그인에 필요한 정보를 넘기면서 로그인을 시도한다.

3. LoginWork 주입된 서비스 호출(비동기)

public class LoginWork : IWork<LoginParam, LoginResult>
{
    private readonly IAuthService _authService;
    private readonly IUserService _userService;
    private LoginResult _loginResult;

    public LoginWork(IAuthService authService, IUserService userService)
    {
        _authService = authService;
        _userService = userService;
    }

    public async UniTask<LoginResult> ExecuteWorkAsync(LoginParam param)
    {
        Debug.Log("로그인 시작");

        string token = await _authService.LoginAsync(param.UserID, param.Password);
        if (token == null)
        {
            return new LoginResult(null, LoginErrorCode.InvalidToken, "Invalid Token");
        }

        UserData user = await _userService.LoadUserDataAsync(param.UserID, token);
        if (user == null)
        {
            return new LoginResult(null, LoginErrorCode.InvalidUserData,"Invalid UserData" );
        }
        
        await UniTask.Delay(500);
        
        Debug.Log($"로그인 성공 : {user.NickName}, {user.Level}");
        return _loginResult = new LoginResult(user, LoginErrorCode.None,"Success" );
    }
}

 

- IAuthService.LoginAsync() : 주입받은 Srvice로 로그인을 시도한다.

LoginAsync() 내부에서는 현재 세팅한 서버주소로 접속하여 Token을 확인하고 리턴한다.

- IUserService.LoadUserDataAsync() : 유저정보를 로드한다. 

 

여기서 샘플코드이기에 정해놓은 딜레이 시간을 넣었지만 실제 로그인은 네트워크 통신이 포함되고 여러 작업들이 동반되기에, 스레드를 막지 않도록 비동기(UniTask)사용했다. 이 구조를 사용하면 중복 클릭 방지·로딩 표시 같은 UI 제어가 자연스럽고, 타임아웃, 취소, 재시도 같은 확장도 쉽게 적용할 수 있다.

 

4. loginResult  결과 반환

return _loginResult = new LoginResult(user, LoginErrorCode.None, "Success");

 

UI는 내부 복잡한 로직을 몰라도된다. LoginResult.IsSuccess만 보고 처리하면된다.

로그인에 성공여부를 사용자에게 보여주면된다.

var result = await _loginWork.ExecuteWorkAsync(loginParam);

if (result.IsSuccess)
{
    // 성공 처리 → LogoutScene 로드
}
else
{
    // 실패 Toast 표시
}

 

정리하자면 아래 표와 같다.

UI(LoginPanel) 버튼 클릭, 입력값 수집, Work 실행만 담당
LoginWork 로그인 로직 핵심을 담당하는 “도메인 작업 단위”
IAuthService 로그인 요청을 라우팅
IUserService 유저 데이터 로딩 담당
VContainer LoginWork, AuthServiceRouter, UserService 생성·주입

 

LoginWork는 "하나의 로그인 시나리오(Work)"를 책임지는 객체이고

DI + VContainer구조에서 UI와 서비스 사이를 분리하는 레이어이다.

 

지난 글에서는 DI(Dependency Injection) 개념과 Unity환경에서 왜 필요한지를 정리했다.(꼭 유니티 뿐만 아니죠)

이번에는 VContainer를 실제 프로젝트에 적용해, 로그인 흐름을 깔끔하고 테스트하기 쉬운 구조로 개선해보자


1. VContainer는 무엇인가?

VContainer는 Unity용 DI(의존성 주입) 프레임워크다.

쉽게 말하면, "객체를 직접 생성하지 않고도 필요한 인스턴스를 주입받게 해주는 시스템"이다.

 

기존 방식

// LoginMananger.cs

var auth = new AuthService();
var login = new LoginManager(auth);

 

-> LoginManager는 AuthService에 직접적인 의존관계가 형성된다. 

즉, 나중에 auth를 교체하려고 할때 코드를 직접 수정해야한다.

 

VContainer 방식

public class LoginManager
{
    private readonly IAuthService _auth;

    public LoginManager(IAuthService auth)
    {
        _auth = auth;
    }
}

LoginManager가 이러한 구조일때

protected override void Configure(IContainerBuilder builder)
{
#if UNITY_EDITOR || MOCK
    builder.Register<IAuthService, MockAuthService>(Lifetime.Singleton);
#else
    builder.Register<IAuthService, RealAuthService>(Lifetime.Singleton);
#endif

    builder.Register<LoginManager>(Lifetime.Singleton);
}

이런식으로, 코드 변경 없이 환경(MockAuthService/RealAuthService)을 전환할 수 있다.


의존성 해제 OK, 근데 인터페이스와 뭐가 다른데?

DI를 그냥 인터페이스로 나누는 것과, VContainer로 주입하는 것의 본질적 차이는

아래 예시를 보고 이해해보자

public class LoginManager
{
    private IAuthService _auth;

    public LoginManager()
    {
        _auth = new MockAuthService(); //직접 생성
    }
}

여기서 LoginMananger는 여전히 MockAuthService의 구현체를 알고 있다.

즉, "의존성의 방향"은 인터페이스로 향하지만 "생성"은 여전히 LoginMananger내부에 있다.

결국 누가 어떤 구현체를 쓸지 코드안에서 직접 결정하기 때문에 테스트 전환및 유지보수가 어렵다.

 

VContainer는 생성책임을 외부로 분리한다.

public class LoginManager
{
    private readonly IAuthService _auth;

    public LoginManager(IAuthService auth) // 주입받음
    {
        _auth = auth;
    }
}

이제 LoginMananger는 어떤 구현체가 들어올지 모른다.


VContainer 주입방법

DI컨테이너 "어떤 인터페이스에 어떤 구현체를 주입할지"를 관리해주는 객체 생성 관리자다.

이 과정을 LifetimeScope에서 정의한다.

// LoginLifetimeScope : LifetimeScope
 protected override void Configure(IContainerBuilder builder)
 {
	builder.Register<IAuthService, RealAuthService>(Lifetime.Singleton);
	builder.Register<LoginManager>(Lifetime.Singleton);
}

 

LifetimeScope란?

의존성의 생명주기를 관리하는 단위다. 쉽게 말하면 DI컨테이너의 실행 범위를 정의하는 클래스다.

Container : 객체를 생성하고, 의존성을 주입해주는 관리자
LifeTimeScope :  그 컨테이너 작동하는 "범위"를 정의(씬 단위, 게임 전체)
builder.Register() : 어떤 타입을 어떤 Lifetime으로 관리할지 등록
Lifetime.Singleton : 게임이 실행되는 동안 하나의 인스턴스로 유지
Lifetime.Transient : 매번 새 인스턴스를 생성
Lifetime.Scoped : 현재 스코프 안에서만 동일 인스턴스 유지

객체 라이프사이클에 맞게 선택해서 사용하면 된다.


주입예시

로그인 샘플 프로젝트. 코드 전체

public class LoginLifetimeScope : LifetimeScope
{
    protected override void Configure(IContainerBuilder builder)
    {
        var initialEnv = ServerEnv.DEV; // 기본값

        // EnvironmentService 싱글톤 등록
        var envService = new EnvironmentService(initialEnv);
        builder.RegisterInstance<IEnvironmentService>(envService);
        builder.RegisterInstance(initialEnv); 
        
        builder.Register<AuthService>(Lifetime.Singleton);
        builder.Register<MockService>(Lifetime.Singleton);
        builder.Register<IAuthService, AuthServiceRouter>(Lifetime.Singleton);
        builder.Register<IUserService, UserService>(Lifetime.Scoped);

        
        builder.Register<LoginWork>(Lifetime.Transient);
        builder.Register<LogoutWork>(Lifetime.Transient);

        //mono
        builder.RegisterComponentInHierarchy<LoginPanel>();
        builder.RegisterComponentInHierarchy<UIHUDInfo>();
    }
}

 

 

1) IEnvironmentService (Singleton, Instance 주입)

var initialEnv = ServerEnv.DEV;
var envService = new EnvironmentService(initialEnv);
builder.RegisterInstance<IEnvironmentService>(envService);
builder.RegisterInstance(initialEnv); // 필요 시 enum 자체도 주입

 

- AuthServiceRouter.cs가 현재 환경(DEV/LIVE)을 보고 Real/Mock중 어디로 보낼지 결정한다.

- 런타임에 envService.SetEnv(ServerEnv.REAL) 처럼 환경을 바꿀 수 있도록 되어있다.

 

2) IAuthService → AuthServiceRouter 

builder.Register<AuthService>(Lifetime.Singleton);  // Real
builder.Register<MockService>(Lifetime.Singleton);  // Mock
builder.Register<IAuthService, AuthServiceRouter>(Lifetime.Singleton); // 라우터

- 주입 지점 : LoginWork의 생성자에서 IAuthService로 주입받음

- 동작 방식 : AuthServiceRouter가 IEnvironmentService의 현재 값을 확인하고 내부적으로 AuthService(Real) 혹은 MockService(Mock)로 위임 호출

- 의미 : 등록은 고정, 전환은 환경 값만 변경해서 처리(코드 수정없음)

 

//더보기 AuthServiceRouter.cs

더보기

    public sealed class AuthServiceRouter : IAuthService
    {
        private readonly IEnvironmentService _env;
        private readonly AuthService _real;   // LIVE용
        private readonly MockService _mock;   // DEV/QA용

        public AuthServiceRouter(IEnvironmentService env, AuthService real, MockService mock)
        {
            _env = env;
            _real = real;
            _mock = mock;
        }

3) IAuthService → AuthServiceRouter 

builder.Register<IUserService, UserService>(Lifetime.Scoped);

- scoped : 씬 교체 시 정리(ex 유저 세션)

- LoginWork생성자에서 IUserService주입 -> 로그인 성공 후 유저 데이터 로드에 사용

 

4) LoginWork / LogoutWork (Transient, “작업 단위”)

builder.Register<LoginWork>(Lifetime.Transient);
builder.Register<LogoutWork>(Lifetime.Transient);

- Transient : 로그인/로그아웃은 매번 새 인스턴스로 실행

 

public class LoginWork : IWork<LoginParam, LoginResult>
{
    private readonly IAuthService _authService;  // 라우터로 주입
    private readonly IUserService _userService;  // Scoped로 주입

    public LoginWork(IAuthService authService, IUserService userService) { ... }

    public async UniTask<LoginResult> ExecuteWorkAsync(LoginParam p)
    {
        var token = await _authService.LoginAsync(p.UserID, p.Password);
        var user  = await _userService.LoadUserDataAsync(p.UserID, token);
        ...
    }
}

- UI에서 주입받아 ExecuteWorkAsync  실행용 트리거

 

5) MonoBehaviour 주입 (LoginPanel, UIHUDInfo)

builder.RegisterComponentInHierarchy<LoginPanel>();
builder.RegisterComponentInHierarchy<UIHUDInfo>();

- 씬에 이미 존재하는 MonoBehaviour들을 컨테이너 관리하에 주입한다.

- [Inject]을 꼭 써줘야한다.

 

//더보기 LoginPanel.cs 사용 예시

더보기

       [Inject] //mono
       public void Construct(LoginWork loginWork)
       {
           _loginWork = loginWork;

           Debug.Log("Construct");
       }

 


정리

이번 글에서는 VContainer의 핵심 개념과 실제 프로젝트 적용 예시를 통해 알아봤다.

 

프로젝트 구현 요약

- LoginPanel/UIHUDInfo는 RegisterComponentInHierarchy 덕분에 [Inject]가 꽂힌다.

- LoginWork는 Transient로 주입되어 로그인 버튼 클릭 시 매번 “깨끗한 작업”을 실행한다.

- IAuthService는 AuthServiceRouter를 통해 환경(DEV/REAL) 에 맞춰 Real/Mock으로 자동 라우팅된다.

- IUserService는 Scoped로 씬 교체 시 자연스럽게 수명이 종료된다.


다음 포스팅에는

실제로 로그인버튼 클릭했을때 동작 방식과 결과를 확인해보자

 

유저 분산 시스템  구현, 왜?

온라인 RPG는 오픈 직후 혹은 이벤트 시간대에 특정 위치(NPC, 퀘스트, 포털)에 유저가 한꺼번에 몰린다.
특히 자동 이동이 많은 모바일게임 환경에서는,
모든 유저가 같은 타겟 좌표로 이동하게 되어 캐릭터가 한 지점에 겹쳐지는 현상이 자주 발생한다.

퀘스트 수락 / 완료 시, 던전 입구 대기, 월드 이벤트 참여 ,NPC 대화 상호작용 등등..

 

이런 문제는 지저분하고, 완성도가 낮아져 보일 수 있다. 첫인상에 타격을 준다.

 

실제 내가 서비스했던 게임에서 적용한 방법과 조금 더 기능을 보완해서 샘플 프로젝트를 만들어봤다.


설계 고민

처음에는 단순히,

"같은 목저지로 이동하더라도, 유저가 도착 위치에서부터 살짝 다른 위치에 서면 어떨까?"

 

1차 시도 : 랜덤 오프셋 분산

NPC를 중심으로 여러 개의 도착위치를 원형으로

고르게 미리 만들어두고, 그 위치를 도착하게 하면 어떨까?

겹침

랜덤하게 뭐 대충 +-0.5cm 정도의 값을 더해서 이동하도록 만들었다.

하나로 겹쳐보이지는 않지만, 그래도 문제는 있었다.

 

옹기종기

 

=> 일관성부족, 시각적 불균형

 

2차 시도 : 위치 점유하기

각 유저가 도착하면 그 슬롯을 점유하고 있으면 어떨까?

 

이 접근법은

1. A유저가 먼저 도착하면 슬롯을 점유

2. B유저가 오면 남은 슬롯 중 가장 까운 위치를 선택

3. 슬롯이 다 차면 기존 슬롯과 모두 다른 슬롯으로 재 생성

 

예약 규칙

1. 플레이어가 출발하는 위치에서부터 가장 가까운 빈 슬롯을 찾는다.

2. 빈 슬롯이 배정되면 그 슬롯을 점유 상태로 표시

3. 빈 슬롯이 없으면 처음에 생성한 슬롯 리스트와 다른 슬롯리스트를 생성 후 슬롯 찾기

4. 목적지 도착 후 슬롯 해제

 

고르게 분포하는 플레이어들,

하지만! 그래도 문제점이 있다. 

빈 슬롯만 찾다보니, NPC 반대편에 있는 슬롯을 행해 간다.

(NPC대화하러 간건데, NPC를 지나쳐 뒤로 지나쳐가는 모습은 이상하다.)

 

3차 시도 : OverlapSphereNonAlloc 사용하기

1. 2차시도 마찬가지로 먼저 점유가 안된 빈슬롯으로 향한다.

2. 점유가 안된 슬롯이, NPC 건넌편에 있다, 가장 가까운 슬롯으로 이동한다.

3. 목표 슬롯에 다른 플레이어와 OverlapSphereNonAlloc 검사를 진행하여, 그 플레이어와 겹치지 않게 살짝 이동한다. 

    private bool TryNudgeAround(Vector3 centerPos, out Vector3 nudged)
    {
        nudged = centerPos;

        int hit = Physics.OverlapSphereNonAlloc(centerPos, nudgeProbeRadius, _overlapBuf, pawnMask, QueryTriggerInteraction.Ignore);
        bool isFree = hit == 0;
        if (isFree)
        {
            return true;
        }

        float step = 360f / Mathf.Max(4, nudgeMaxTries);

        for (int i = 0; i < nudgeMaxTries; ++i)
        {
            float angle = step * i;
            Vector3 dir = Quaternion.Euler(0f, angle, 0f) * Vector3.forward;
            Vector3 cand = centerPos + dir * nudgeSearchRadius;

            int h = Physics.OverlapSphereNonAlloc(cand, nudgeProbeRadius, _overlapBuf, pawnMask, QueryTriggerInteraction.Ignore);
            bool isCandFree = h == 0;

            if (isCandFree)
            {
                nudged = cand;
                return true;
            }
        }

        return false;
    }

 

4. 점유하고 있는 슬롯에 플레이어가 점유하고 있는 플레이어가 3명 이상이면 새로운 슬롯 리스트를 생성한다.

 

 

5. 방해 오브젝트에도 레이어를 세팅하면 그 오브젝트를 피한 도착지점으로 보정한다.

중간에 오브젝트가 있을때


마무리

아직은 개선점이 더 필요하다.

- 멀티플레이어에서 플레이어들이 동시에 출발 했을때, 점유슬롯 관련

- NPC 방향에 따른 위치 조정 등등..

 

구현 결과

 

많은 프로젝트에서 싱글톤을 자주 쓰고 있다. 나 역시도 많이 써왔다.
그 이유는 간단하다. 쓰기가 매우 편하다.
언제 어디서든 쉽게 호출할 수 있고, 데이터 캐싱 역할까지 맡기면서 쓰기 좋다.

하지만 이렇게 편리하게 쓰면서도, 그 안에는 여러 가지 문제들이 숨어 있다.
오늘 포스팅에서는 이 문제를 구조적으로 어떻게 해결할 수 있는지, 간단한 로그인 샘플 프로젝트를 만들어서 풀어볼 생각이다.

 

로그인 프로젝트를 예제로 선택한 이유

회사에서 프로젝트를 진행할 때, 가장 초반에 마주하는 게 바로 로그인이다.
특히 빌드 환경(Dev, QA, Live)에 따라 실행 과정, 로그인 플로우, 접속 주소 등이 달라진다.
그만큼 분기 처리가 많아지고, 구조적으로 깔끔하게 정리하지 않으면 관리가 힘들어진다.

게임을 시작하는 첫 번째 관문이 로그인이라 생각했고, 그래서 이번 샘플도 로그인 프로젝트를 기반으로 진행해보기로 했다.

 


그래서 싱글톤에 문제가 뭔데?

아래는 싱글톤으로 로그인 서비스를 구현 내용이다.

// LoginPanel.cs
public class LoginPanel : MonoBehaviour
{
    public void OnClickLogin()
    {
        AuthService.Instance.Login("testId", "1234");
    }
}

// AuthService.cs
public class AuthService
{
    private static AuthService _instance;
    public static AuthService Instance => _instance ??= new AuthService();

    public bool IsLoggedIn { get; private set; }

    public void Login(string id, string pw)
    {
        // Dev, QA, Live 분기를 여기서 직접 처리
#if DEV
        string url = "https://dev-login.server.com";
#elif QA
        string url = "https://qa-login.server.com";
#else
        string url = "https://live-login.server.com";
#endif
        // 실제 로그인 로직 (간단화)
        Debug.Log($"Login Request → {url} : {id}/{pw}");
        IsLoggedIn = true;
    }
}

 

문제점으로 크게 3가지다.

1. AuthService가 싱글톤인 만큼 어디서나 호출 할 수 있다. (뭐 예를 들어 LoginManager.cs이런 매니저류 클래스)

2. 환경 분기 지옥이다. (Dev, QA, Live)가 내부에 하드코딩 되어있다. (지금은 url만 있지만, 환경에 따라 QA는 자동로그인,  DEV는 테스트 로그인만 가능, LIVE는 보안로직 강화 등 분기 지옥이 시작된다)

3. 테스트 환경 변경 어려움 (이건 앞으로 추가적인 예시로 설명 보강하겠다)

 

실무에서 느낀 문제

실제 회사 프로젝트에서 로그인 과정을 싱글톤으로 관리하다 보면, 환경에 따라 다른 주소/설정/로직을 처리하는 코드가 한 군데에 몰린다. 결과적으로:

  • 조건문이 계속 늘어나고
  • 유지보수가 점점 힘들어지며
  • 테스트 환경에서 특정 동작만 분리하기도 어렵다

즉, 편하다고 쓰던 싱글톤이 장기적으로는 프로젝트 속도를 늦추는 원인이 될 수 있다.

 

그~래서 이부분을 해결 할 수 있는 방법이 DI(Dependency Injection)이다.

 


DI(Dependency Injection)란?

DI(의존성 주입)은 말 그대로 객체가 필요한 의존성을 직접 만들지 않고, 외부에서 넣어주는 방식

쉽게 말해서

  • 싱글톤: “내가 필요하면 직접 가져다 쓴다.”
  • DI: “필요한 건 외부에서 준비해주고, 나는 받기만 한다.”

왜 좋은가?

  1. 테스트성
  2. 명시적 의존성
  3. 확장성

DI(의존성 주입)로 해결할 수 있는 포인트

DI는 필요한 객체를 스스로 만들지 않고, 외부에서 주입받는 방식이다.
이를 통해 다음과 같은 개선이 가능하다:

  • 테스트성: 실제 서비스 대신 Mock/Stub 객체를 주입해서 테스트 가능
  • 명시적 의존성: 어떤 클래스가 무엇을 필요로 하는지 코드에 드러남
  • 확장성: Dev, QA, Live 환경별로 다른 객체를 주입하면 깔끔하게 분리 가능

마무리 & 다음 편 예고

이번 글에서는:

  • 싱글톤을 왜 많이 쓰는지
  • 그로 인해 어떤 문제가 발생하는지
  • 로그인 샘플 프로젝트를 예제로 선택한 이유

까지 다뤄보았다.

다음 글에서는 실제로 VContainer를 사용해 DI 환경을 구성하고,
간단한 로그인 플로우를 어떻게 개선할 수 있는지 코드와 함께 살펴볼 예정이다.

 

 

깃허브:

https://github.com/hahahohohun/LoginSample.git

회사에서 sdk 적용하는데, 관련 지식이 부족해서 한참 고생했다. 해당 파일들이 정확하게 무슨 기능을하는지 파악하며 작업 진행을 해야한다고 느꼈다. 

 

 

Assets/Plugins/Android/mainTemplate.gradle

 

  • 앱 모듈 수준 build.gradle에 대응합니다.
  • dependencies { ... } 나 android { ... } 등을 통해 SDK 라이브러리, compileOptions 등을 설정합니다.
  • 여기에서 SDK 의존성 (implementation) 을 선언합니다.

 

Assets/Plugins/Android/baseProjectTemplate.gradle

 

 

  • 프로젝트 루트 수준 build.gradle에 대응합니다.
  • 전체 프로젝트의 공통 설정을 담당하며, 주로 allprojects { repositories { ... } } 등을 설정합니다.
  • 외부 저장소 추가(mavenCentral, google(), gradlePluginPortal()) 시 여기에 설정합니다.

 

Assets/Plugins/Android/settingsTemplate.gradle

 

 

  • Android의 settings.gradle에 대응합니다.
  • 여러 모듈이 있을 경우 프로젝트 구조를 정의합니다. 대부분의 Unity 프로젝트에서는 크게 수정하지 않아도 됩니다.
  • 외부 라이브러리 로컬 등록 시 include 항목 조정 가능.

 

 

Assets/Plugins/Android/gradleTemplate.properties

 

  • 또는 Unity에서 생성 후 Library/... 아래

역할

  • Gradle 빌드 시 사용할 전역 프로퍼티 정의
  • 예: HIVE_SDK_VERSION=23.0.0, org.gradle.jvmargs=-Xmx4096M 등
  • Hive SDK와 같은 BOM 사용 시, ${project.HIVE_SDK_VERSION} 값 주입에 사용됨

 

launcher/build.gradle

 

 

 

  • Unity 빌드 시 자동 생성되는 실제 앱 빌드 대상 Gradle 스크립트입니다.
  • 이곳은 직접 수정하지 않고 mainTemplate.gradle의 설정이 여기로 반영됩니다.
  • 수동 수정 시 빌드 후 덮어써지므로 지양해야 합니다.

 

 

 

https://assetstore.unity.com/packages/tools/integration/unirx-reactive-extensions-for-unity-17276

 

UniRx - Reactive Extensions for Unity | 기능 통합 | Unity Asset Store

Use the UniRx - Reactive Extensions for Unity from neuecc on your next project. Find this integration tool & more on the Unity Asset Store.

assetstore.unity.com

 

 

 

ref

https://skuld2000.tistory.com/31

https://mentum.tistory.com/512

https://www.youtube.com/watch?v=NN1_41TE1N0

제한된 이미지 사이즈 안에서 들어가야 할 글자가 초과될 경우 사용하면 될듯하다.

좌우 방향 설정과 속도조절이 가능하다.

 

 

깃허브 링크

https://github.com/hahahohohun/PublicCode/blob/main/README.md#-cmovetextcs

 

GitHub - hahahohohun/PublicCode

Contribute to hahahohohun/PublicCode development by creating an account on GitHub.

github.com

+) 특정 상태에 따라 흐를 것인가 멈출 것인가를 기능을 넣으면 좋을 듯

선택한 오브젝트를 제외한 오브젝트의 SetActive를 false처리한다. 단축키는 Ctrl + Shift + Q

 

같은 부모 오브젝트 아래에서 작동

 

최상단 오브젝트에서 작동

 

-----------------------------------------------------------------------------------

2021.12.13

단축키를 사용하기전에 애초에 꺼져있는 오브젝트는 다시 켜지지 않도록 처리

1.Ctrl + Shift + Q

2.해당 오브젝트의 포지션값 변경

3.Ctrl + Shift + Q (꺼져있던 오브젝트 다시 활성화)

-----------------------------------------------------------------------------------

 

 

https://github.com/hahahohohun/PublicCode/blob/main/README.md#eccustomtoolscs

 

오브젝트에 인덱스를 추가하여 이름을 변경이 필요할때!

ctrl + D 를 통해 생성한 오브젝트는 (1),(2),(3)~~~ 이름 마지막에 이렇게 숫자가 붙는다. 하나 하나 바꾸기 번거롭기때문에 이것을 한꺼번에 변경이 가능한 간단한 툴을 만들었다.

 

https://github.com/hahahohohun/PublicCode/blob/main/README.md#ecnamingcs

 

유니티 커스텀 에디터로 맵툴 만들기 두 번째 포스팅

오늘 공부할 에디터 뷰


            GUILayout.BeginVertical("box");

            GUILayout.Label("Map Name");
            _strMapName = EditorGUILayout.TextField(_strMapName/*, GUILayout.ExpandWidth(true)*/);
            GUI.enabled = (_strMapName == string.Empty || _strMapName == null) ? false : true;
            if (GUILayout.Button("Create New Map", GUILayout.ExpandWidth(true)))
            {
                Debug.LogError("create " + _strMapName);
                CreateMap();
                _bEditOn = true;
            }

            GUILayout.Space(12);

1. GUILayout.BeginVertical("box"); 박스로 묶는 범위(진한 회색으로)를 지정하게 된다.

2. Create New Map 버튼 : EditorGUILayout.TextField()에 string값이 존재 시 활성화.

3. CreateMap()함수 : 새로운 맵을 생성함

 

 


            GUI.enabled = true;
            GUILayout.Label("Load MapData");
            _txtAssetMapContent = (TextAsset)EditorGUILayout.ObjectField(_txtAssetMapContent, typeof(TextAsset), GUILayout.ExpandWidth(true));
            GUI.enabled = _txtAssetMapContent != null ? true : false;
            if (GUILayout.Button("Edit Map", GUILayout.ExpandWidth(true)))
            {
                LoadMap(_txtAssetMapContent.text);
                _bEditOn = true;
            }
            GUILayout.EndVertical();

1. EditorGUILayout.ObjectField() : 끌어다 붙여도 되고, 동그라미 눌러서 프로젝트 리소시스에 텍스트 파일을 선택해도 됨

2. Edit Map 버튼 : _txtAssetMapContent에 값이 들어와야 활성화.

3. LoadMap() 함수 : 선택한 텍스트 파일을 가지고 하이어라키에 맵 파일을 로드시킨다.

'Unity' 카테고리의 다른 글

unity) Android gradle  (0) 2025.05.16
unity) UniRX  (0) 2024.12.01
유니티)TCP 채팅 #2  (0) 2021.06.10
유니티)TCP 채팅 #1  (0) 2021.06.09
[유니티] 유니티 PlayerPrefs 저장경로/ Application.dataPath/  (0) 2021.05.03

커스텀 에디터를 이용한 맵툴 제작

 

작업 결과물

 

GUILayout.Label(string text );

 GUILayout.Label("Map Name");

=> 윈도 류창에 텍스트 라벨에 원하는 string을 표시할 수 있다.

 

EditorGUILayout.TextField( )

_strMapName = EditorGUILayout.TextField(_strMapName);
 GUI.enabled = (_strMapName == string.Empty || _strMapName == null) ? false : true;

=> 텍스트 입력필드, GUI.enabled을 통해 입력값이 있어야 

 

GUI.enabled

GUI.enabled = (_strMapName == string.Empty || _strMapName == null) ? false : true;

=> 해당 코드 아래에 버튼이나 입력 칸을 활성화 처리 여부를 정한다.

 

GUILayout.Button( )

if (GUILayout.Button("Create New Map", GUILayout.ExpandWidth(true)))
{
    CreateMap();
    _bEditOn = true;
}

=> 버튼 , 클릭하면 if안에 함수들이 실행된다.

 

GUILayout.Space()

GUILayout.Space(12);

=> 여백을 준다.

 

EditorGUILayout.ObjectField()

_txtAssetMapContent = (TextAsset)EditorGUILayout.ObjectField(_txtAssetMapContent, typeof(TextAsset), GUILayout.ExpandWidth(true));

=> 설정한 타입으로 입력받을수있는 링크 필드가 생긴다. 나는 TextAsset으로 텍스트 관련 오브젝트만 입력받을 수 있도록 했지만 해당 타입을 그냥 object라고 하면 어떠한 오브젝트도 받을 수 있는 상태가 된다.

#1에 이어 #2이다. 이번에는 Client코드를 살펴보자.

 

먼저 소켓에 접속하는 함수

 

ConnectToServer()

    public void ConnectToServer()
    {
        // 이미 연결되었다면 함수 무시
        if (_bSoketReady) return;

        // 기본 호스트/ 포트번호
        string ip = ins_inputIPNumber.text == "" ? "127.0.0.1" : ins_inputIPNumber.text;
        int port = ins_inputPort.text == "" ? 9999 : int.Parse(ins_inputPort.text);

        // 소켓 생성
        try
        {
            _tcpSoket = new TcpClient(ip, port);
            _NetworkStream = _tcpSoket.GetStream();
            _writer = new StreamWriter(_NetworkStream);
            _reader = new StreamReader(_NetworkStream);
            _bSoketReady = true;
        }
        catch (Exception e)
        {
            Chat.instance.ShowMessage($"소켓에러 : {e.Message}");
        }
    }

 

 

OnIncomingData

    void OnIncomingData(string data)
    {
        if (data == "%NAME")
        {
            _strClientName = ins_inputNickName.text == "" ? "Guest" + UnityEngine.Random.Range(1000, 10000) : ins_inputNickName.text;
            Send($"&NAME|{_strClientName}");
            return;
        }

        Chat.instance.ShowMessage(data);
    }

if(data = "%NAME") : 으로 들어오는 %,&인 특수문자로 체크하는데 영상 댓글에 보니 들어오기 전 이름, 나갈 이름을 구분하는 용도라 한다. 여기서는 % 들어오기 전에 이름인거같다.

사용자가 입력한 string이 없으면 Guest + 숫자로 임의로 만들어준다.

send : &NAME으로 서버에 보낸다.

 

연결되는 부분이 Server클래스에 OnIncomingData()에서

//Server.cs.

void OnIncomingData(ServerClient c, string data)
    {
        if (data.Contains("&NAME"))
        {
            c.clientName = data.Split('|')[1];
            Broadcast($"{c.clientName}이 연결되었습니다", _listClients);
            return;
        }

        Broadcast($"{c.clientName} : {data}", _listClients);
    }

연결된 클라이언트의 이름을 접속한 클라이언트들에게 보내는 내용이다.

 

OnSendButton

    public void OnSendButton(InputField SendInput)
    {
#if (UNITY_EDITOR || UNITY_STANDALONE)
        if (!Input.GetButtonDown("Submit")) return;
        SendInput.ActivateInputField();
#endif
        if (SendInput.text.Trim() == "") return;

        string message = SendInput.text;
        SendInput.text = "";
        Send(message);
    }

메시지 보내기 버튼으로 입력된 내용을 send 해주는 과정이다.

 

 

OnApplicationQuit

    void OnApplicationQuit()
    {
        CloseSocket();
    }
    
    void CloseSocket()
    {
        if (!_bSoketReady) return;

        _writer.Close();
        _reader.Close();
        _tcpSoket.Close();
        _bSoketReady = false;
    }

앱 종료하면 연결된 소켓들의 정보를 끊어준다.

 

tcp에 대한 간단한 개념잡기에는 괜찮은거 같다.

 

참고:

https://www.youtube.com/watch?v=y3FU6d_BpjI

유튜브에 c# tcp소켓통신 채팅 관련 영상이 있었다 그 영상의 코드를 공부해봤다.

 

먼저 ServerClinet 

public class ServerClient
{
    public TcpClient tcp;
    public string clientName;

    public ServerClient(TcpClient clientSocket)
    {
        clientName = "Guest";
        tcp = clientSocket;
    }
}

TcpClient : TCP 네트워크 서비스에 대한 클라이언트 연결을 제공한다.

 

ServerCreate

    public void ServerCreate()
    {
        _listClients = new List<ServerClient>();
        _listDisconnect = new List<ServerClient>();

        try
        {
            int port = ins_PortInput.text == "" ? 9999 : int.Parse(ins_PortInput.text);
            _server = new TcpListener(IPAddress.Any, port);
            _server.Start(); //바인드 처리.

            StartListening();
            _bserverStarted = true;
            Chat.instance.ShowMessage($"서버가 {port}에서 시작되었습니다.");
        }
        catch (Exception e)
        {
            Chat.instance.ShowMessage($"Socket error: {e.Message}");
        }
    }

port로 포트번호를 만들어준다. (현재 사용하지 않는 포트번호로 지정)

IPAddress.Any : 모든 클라이언트에서 오는 요청을 받겠다는 의미

TcpListener.Start : 들어오는 연결 요청의 수신을 시작. (바인드)

 

StartListening

    private void StartListening()
    {
        _server.BeginAcceptTcpClient(AcceptTcpClient, _server);
    }

BeginAcceptTcpClient(AsyncCallback? callback, object? state) : 들어오는 연결 시도를 받아들이는 비동기 작업을 시작한다.

AsyncCallback은 작업이 완료됐을때 호출한다.

state는 연결을 받아들이는 작업자에 대한 정보가 들어 있는 정의 개체이다. 작업이 완료되면 callback대리자에게 전달한다.

 

AcceptTcpClient

    private void AcceptTcpClient(IAsyncResult ar)
    {
        TcpListener listener = (TcpListener)ar.AsyncState;
        _listClients.Add(new ServerClient(listener.EndAcceptTcpClient(ar)));
        StartListening();

        // 메시지를 연결된 모두에게 보냄
        Broadcast("%NAME", new List<ServerClient>() { _listClients[_listClients.Count - 1] });
    }

매개변수

IAsyncResult : 비동기 작업의 상태를 나타낸다.

IAsyncResult 

TcpListener.EndAcceptTcpClient() : 들어오는 연결 시도를 비동기적으로 받아들이고 원격 호스트 통신을 처리할 새로운 TcpClient을 만든다. (리턴 TcpListener)

 

AcceptTcpClient이 실행 된후에 다시 StartListening()를 실행시켜 새로운 Client를 받을 수 있도록 처리한다.

 

Update()

	priavate void Update()
    {
        if (!_bserverStarted)
        { 
            return;
        }

        foreach (ServerClient c in _listClients)
        {
            // 클라이언트가 여전히 연결되있나?
            if (!IsConnected(c.tcp))
            {
                c.tcp.Close();
                _listDisconnect.Add(c);
                continue;
            }
            // 클라이언트로부터 체크 메시지를 받는다
            else
            {
                NetworkStream s = c.tcp.GetStream();
                if (s.DataAvailable)
                {
                    string data = new StreamReader(s, true).ReadLine();
                    if (data != null)
                        OnIncomingData(c, data);
                }
            }
        }

        for (int i = 0; i < _listDisconnect.Count - 1; i++)
        {
            Broadcast($"{_listDisconnect[i].clientName} 연결이 끊어졌습니다", _listClients);

            _listClients.Remove(_listDisconnect[i]);
            _listDisconnect.RemoveAt(i);
        }
    }

Update에서 클라이언트들의 접속여부와 채팅 내용을 체크하도록 한다.

 

IsConnected

   bool IsConnected(TcpClient c)
    {
        try
        {
            if (c != null && c.Client != null && c.Client.Connected)
            {
                if (c.Client.Poll(0, SelectMode.SelectRead))
                    return !(c.Client.Receive(new byte[1], SocketFlags.Peek) == 0);

                return true;
            }
            else
                return false;
        }
        catch
        {
            return false;
        }
    }

 

if(c.Client.Poll(0, SelectMode.SelectRead))

TcpClient.Poll(int microseconds, SelectMode mode); : 메소드는 간단히 말하자면 하려는 행동이 완료할 수 있는 상태면 true를 리턴한다. 

매개변수

microseconds : 응답을 기다리는 시간

SelectMode : 오류상태모드, 읽기 상태 모드, 쓰기 상태 모드를 선택한다.

=> 데이터를 읽을 수 있다면 true를 반환한다.

 

!(c.Client.Receive(new byte[1], SocketFlags.Peek) == 0);

socket.Receive(Byte [], Int32, Int32, SocketFlags, SocketError)

매개변수

byte[] 수신된 데이터에 대한 스토리지 위치인 Byte형식의 배열

SoketFlags.Peek : 소켓 전송 및 수신 동작을 지정(Peek:들어오는 메시지를 미리 본다)

=> 1바이트를 보내고 실제 수신된 바이트를 확인하여 연결여부를 확인한다.

 

다시 위쪽에 else부분을 살펴보면,

            else
            {
                NetworkStream s = c.tcp.GetStream();
                if (s.DataAvailable)
                {
                    string data = new StreamReader(s, true).ReadLine();
                    if (data != null)
                        OnIncomingData(c, data);
                }
            }

TcpClient.GetStream() : 데이터를 보내고 받는 데 사용되는 NetworkStream을 반환한다.

NetworkStream.DataAvailable() : 데이터를 읽을 수 있는지 여부를 나타내는 값을 가져온다.( 읽을 수 있으면 true, 그렇지 않으면 false)

 

이제 NetworkStream.DataAvailable() 까지 성공했다면 밑에 함수를 통해

    void OnIncomingData(ServerClient c, string data)
    {
        if (data.Contains("&NAME"))
        {
            c.clientName = data.Split('|')[1];
            Broadcast($"{c.clientName}이 연결되었습니다", _listClients);
            return;
        }

        Broadcast($"{c.clientName} : {data}", _listClients);
    }

 

Broadcast()

    void Broadcast(string data, List<ServerClient> cl)
    {
        foreach (var c in cl)
        {
            try
            {
                StreamWriter writer = new StreamWriter(c.tcp.GetStream());
                writer.WriteLine(data);
                writer.Flush();
            }
            catch (Exception e)
            {
                Chat.instance.ShowMessage($"쓰기 에러 : {e.Message}를 클라이언트에게 {c.clientName}");
            }
        }
    }

리스트로 받은 모두 ServerClient에 메세지를 전달하는 과정이다.

StreamWriter.Flush : 현재writer의 모든 버퍼를 지우면 버퍼링된 모든 데이터가 내부 스트림에 쓰여진다.

 

참고

https://www.youtube.com/watch?v=y3FU6d_BpjI 

https://docs.microsoft.com/ko-kr/dotnet/api/system.net.sockets

번들관련 공부를하다가 동적으로 생성되는 파일이나 저장되는 데이터들이 어디로 저장되는지 알아보았다.

 

1. PlayerPrefs 저장위치

단말기에 저장되는 PlayerPrefs 저장경로

 

[레지스트리 편집기] -> [HKEY_CURRENT_USER] -> [SOFTWARE] -> [Unity] -> [UnityEditor] -> [DefaultCompany] ->["ProductName"] 

(DefaultCompany 와 ProductName은 ProjectSettings에서 볼수 있음)

Int형
string형

 


2. 앱 저장 경로 Application클래스

빌드된 패키지에는 에디터에서 폴더에 접근할수 없다. 

 

1) Application.dataPath

[C:\Users\사용자이름\AppData\LocalLow\회사이름]

읽기 전용


2) Application.persistentDataPath

[해당 프로젝트폴더 경로\Assets]

 

3) Application.streamingAssetsPath
[해당 프로젝트 폴더]

읽기 전용이다.

 

 

테스트 결과.

1) 에디터

2) 안드로이드

 


유니티에서 지정한 폴더들의 특징 정리

 

wiki.unity3d.com/index.php/Special_Folder_Names_in_your_Assets_Folder

 

오랜만에 글을 작성한다.

 

사내 스터디 프로젝트에는 어드레서블을 사용한다. 그렇기 때문에 어드레서블에 학습이 필요했다.! 알아야 쓰지

 

리소스 폴더 사용

장점은 사용하기에 편리하다가 있다. 하지만, 리소스폴더에 메모리는 최대한 줄이도록 한다. 시작 시 최소한의 에셋만 남겨두어야겠지만? 리소스 폴더를 경계해야 한다.

단점으로는

1. apk사이즈가 커진다.

2. 앱 시작 시간이 길어진다.

3. 앱이나 폴더 변경 시 재 빌드를 해야 한다. (apk빌드에 묶이기 때문에 에셋 변경 시 무조건 재 빌드해야 한다.)

4. 에셋 이름 변경이 힘들다. (에셋을 로드할 때 경로를 바꿔줘야 하기 때문)

 

이런 문제점들을 보완할 수 있는 에셋 번들이 있다.

에셋 번들

장점은 에셋을 묶음 단위로 관리할 수 있다. 빌드 사이즈 절감과, 앱 시작 시간을 단축시킨다. 있지만

단점으로는

1. 번들의 종속성 문제.

같은 이미지를 Asset A와 Asset B에 묶여있다면 같은 이미지 1개로 처리될 것 같지만 2개의 이미지로 인식된다.

 

이러한 에셋 번들(빌드와 번들의 분리)의 장점과 리소스 폴더의 장점(비교적 편리함)을 가진 어드레 서블 에셋 시스템입니다. 


어드레 서블 에셋

어드레서블 에셋이란 어드레스가 할당된 에셋이다. 어드레스를 이용하여 에셋 위치에 상관없이 참조가 가능하다.

어드레서블 에셋 시스템

어드레스를 이용하여 에셋의 관리, 로딩, 빌드가 통합된 시스템이다.

 

어드레 서블 에셋 시스템. 특징만 봐도 편리해 보인다. 장점을 자세하게 보면

1. 에셋 빌드와 배포의 단순화 : 직접 레퍼런스, 리소스 폴더, 에셋 번들 분리가 단순화되고 편리해진다.

2. 효율적인 에셋 관리 : 종속성 파악 및 메모리 로드/언로드 현황을 볼 수 있다. (중복된 에셋으로 메모리를 낭비가 되는 것을 막는다.)


 

이제 어드레 서블 기능에 대해서 알아보자!

 

1. 해당 프리 팹에 어드레스 부여 하기.

어드레 서블 체크박스에 체크하고 어드레스를 부여하면된다. 그리고 잘 되어있는지 어드레서블 현황 대시보드를 통해 확인한다.

2. 에셋 로드하기.

1) Lables 으로 로드하기.

		Addressables.LoadAssetsAsync(type.ToString(), (Object obj) =>
		{
			if (!m_dicObject.ContainsKey(type))
				m_dicObject.Add(type, new Dictionary<string, Object>());

			m_dicObject[type].Add(obj.name, obj);

		});

위 코드에 type이 어드레서블에 Lables이다. 

 

2) 레퍼런스로 로드하기.

이런 식으로 인스펙터에 레퍼런스를 지정하여 로드하는 방식이다.

 

3) 어드레 서블 이름으로 로드하기.

    public void LoadAsset(string strAddressableName)
    {
       Addressables.InstantiateAsync(strAddressableName);
    }

2),3) 번은 현재 프로젝트에서는 사용하지 않는 방법이지만 어떤 식으로 로드하는지 찾아봤다. 

유니티가 제공하는 인앱결제 IAP에 대한 학습.

간단하게 인앱컨트롤러를 만들어서 게임시작과 동시에 인앱초기화작업이 되도록 한다.

 

1. var builder = ConfigurationBuilder.Instance(StandardPurchasingModule.Instance());

AddProduct ~~~...

builder를 통해 스토어에 등록된 상품의 아이디와 같은 데이터를 입력한다.

 

2. UnityPurchasing.Initialize(this, builder);

등록된 builder와 Store클래스를 초기화 한다.

 

초기화

1. IStoreListener.OnInitialized(IStoreController controller, IExtensionProvider extensions)

초기화가 성공하면 IStoreController 과 IExtensionProvider 을받습니다.

 

2. IStoreController 

Unity IAP를 제어하는데 사용하며 데이터및 구매 영수증을 포함한 제품을 저장하고 있다.

3. IExtensionProvider

상점 별 확장 기능에 대한 엑세스를 제공 (extensionProvider.GetExtension<IAppleExtensions>()) 등 특정 스토어 관련한 

 

초기화 실패

1. OnInitializeFailed

초기화 실패 시InitializationFailureReason를 통해 원인을 볼 수 있음 

 

구매실패

1. Product

구매 시도한 상품의 대한 정보.

2. PurchaseFailureReason

구매 실패한 원인

 

구현결과

인앱 초기화의 성공하면 Purchase함수를 통해 각각 상품 구매 테스트 진행

 

사실 실제 상용게임에서는 여러가지 이유로 구매실패가 많이 발생한다. 게임 구매 시도중에 단말기가 종료되거나 통신이 끊기는 경우가 있기에 거기에 대응이 되는 코드와 조건이 많이 필요하다. 

타이머를 간단하게 만들어봤습니다. 

 

타이머 시작 부분

    public void StartTimer(int nRemain, Text txtTimer = null, UnityAction EndCallBack = null)
    {
        ClearTimer();
        _CorTimer = StartCoroutine(CorStartTime(nRemain, txtTimer, EndCallBack));
    }

 

nRemain : 파라미터로 타이머를 동작시킬 시간

txtTimer : 남은 시간을 표기할 Text UI

EndCallBack : 타이머가 완료 후 실행시킬 함수.

 

코 루틴으로 작동되기 때문에 Clear 하는 부분을 꼭 넣어주도록 합니다. 

    private void ClearTimer()
    {
        if (_CorTimer != null)
        {
            StopCoroutine(_CorTimer);
            _CorTimer = null;
        }
        _bPause = false;
    }

타이머 동작 부분

    private IEnumerator CorStartTime(int nRemain = 5, Text txtTimer = null, UnityAction EndCallBack = null)
    {
        while (nRemain >= 0)
        {
            txtTimer.text = nRemain + " 초 남음";

            while (_bPause)
            {
                yield return null;
            }

            yield return new WaitForSeconds(1f);
            nRemain--;
        }
        EndCallBack?.Invoke();
    }

 

이 타이머스크립트에는 코 루틴으로 작동되며, WaitForSeconds(1f)로 1초마다 남은 시간을 감소하도록 했습니다. 저는 EndCallBack으로 델리게이트로 넘겨받도록 했는데 이유는 다른 곳에서도 쓰일 수 있도록 했습니다. 

 

 

그리고 매프 레임 텍스트만 바꾸지만 매 프레임 단위로 실행시키는 함수가 필요하면 EndCallBack처럼 받고 매프 레임 실행시켜주면 되겠죠. 


일시정지

타이머를 잠깐 멈추는 일시정지를 하는 부분입니다. 버튼 부분에 달아주고 OnPointerDown과 OnPointerUp을 통해 제어하도록 해줬습니다.

public class EventButton : MonoBehaviour, IPointerDownHandler, IPointerUpHandler
{
    private TimerUI _TimerUI = null;
    public void SetData(TimerUI TimerUI)
    {
        _TimerUI = TimerUI;
    }
    //클릭 누름.
    public void OnPointerDown(PointerEventData eventData)
    {
        Debug.LogError("퍼즈");
        _TimerUI.ins_Timer.SetPause(true);
    }
    //클릭 뗌.
    public void OnPointerUp(PointerEventData eventData)
    {
        Debug.LogError("퍼즈 취소");
        _TimerUI.ins_Timer.SetPause(false);
    }

}

 ins_Timer는 타이머 동작부분이 구현되어있는 스크립트입니다. SetPause를 통해 코루틴을 잠시 멈추도록합니다.

 


구현결과

스크롤 셀을 만들 때 셀에 들어가는 데이터가 같은 글자 수면 참 좋겠지만 그렇지 않은 경우가 많다. 예를 들어 퀘스트 스크롤을 제작한다고 하자.

퀘스트의 설명이 어떤 퀘스트는 아주 길고 또 어떤 퀘스트의 내용은 짧고 또는 예상할수 없는 길이의 데이터가 입력될 수 있다.

그렇다고 셀들의 크기를 설명이 큰 내용의 맞추어 크게 만들 수는 없다. 또 유지보수에도 좋지않다.(이미 지정한 크기 이상의 데이터는 추가될 수도 없기 때문이다.)

이쁘지않다.

그렇다면 입력되는 데이터 텍스트의 수의 따라 파란배경 이미지의 사이즈 변경이 필요하다.

방법은 Content Size Fitter와 Layout Group컴포넌트를 이용해하는 것이다. 

사실 위 두개 컴포넌트가 텍스트 사이즈의 맞게 바로바로 변경되면 좋겠지만 그렇지 못하는 경우가 있다. LayoutRebuilder.ForceRebuildLayoutImmediate요 함수를 통해 즉시 정렬하도록 해줬다.

(요 함수는 오브젝트가 켜져있어야 정상 작동한다)

유니티 그래픽스 최적화 스타트업책을 참고해서 작성하였습니다.

 

텍스처를 압축해야 하는 이유?

무한한 메모리를 가지고 있는 기기는 없기 때문이다. 그렇기에 텍스처 압축 기술을 통해 텍스처 역시 디스크나 메모리의 크기를 절약해야 한다. 

 

메모리 이슈

텍스처는 수많은 비트 데이터들도 이루어진 이미지 데이터이다. 그렇기에 GPU가 이 데이터를 이용하려면 대역폭이 필요하고 성능이슈가 발생하게 된다. 우리가 원하는 것은 적은 메모리로 합리적인 품질을 내야한다. 그렇기에 텍스처 데이터 사이즈를 관리해야한다.

하지만 텍스처 사이즈는 대역폭만의 문제만이 아니라 메모리 문제도 있다. 복잡한 씬을 렌더링하거나 데이터 사이즈가 큰 텍스처들을 한꺼번에 많이 올려두는 경우들이 있다. 이런 문제는 PC보다는 적은 메모리에 모바일 디바이스에서 더 큰 문제로 다가 올 수 있다.  

패키지용량 이슈(모바일)

모바일 앱마켓에서 게임을 받을 때 WIFI에서만 다운로드가 가능하거나 권장하는 문구를 본적이 있다. 이렇게 큰 용량의 앱들을 받게 될때 거부감이 들때가 있다. 특히 WIFI가 없는 곳에서는 더더욱! 그렇기 때문에 초기에 모두 받지 않고 설치 이후 패치등으로 데이터드를 다운로드 받게 하는데 이 역시도 너무 큰 용량이라면 거부감이 들 수있다.   

압축방식

이미지 압축은 크게 손실압축과 비손실 압축으로 두 종류가 존재한다. 비손실 압축은 원본 이미지를 그대로 유지하면서 데이터만 압축하는 방식이며, 비손실 압축은 원본 이미지의 퀄리티를 어느정도는 훼손하는 대신에 데이터 압축 비율을 높여서 데이터 크기 절약에 집중하는 방식이다. JPG와PNG는 GPU가 바로 읽어 들일 수 없다. 그렇기 때문에 텍스처를 위한 별도의 압축 방식을 이용해야한다. 유니티는 어떤 이미지를 Import하면 원본 이미지 포맷이 PNG인지 JPEG인지 등은 상관없이 특정 포맷의 이미지로 변환하여 사용한다. 텍스처 압축은 플랫폼별 특성이 다르므로 플랫폼마다 적절한 압축 포맷을 설정해주는게 좋다. 

압축종류의 대한 자세한 설명

https://ozlael.tistory.com/84

1. 그림자

빛이 지나가는 경로에 불투명한 물체가 존재하여 빛이 통과하지 못해 생기는 어두운 부분이다. 게임에서 그림자는 모든 물체를 입체감을 더해주는 요소이다. 그림자가 있으면 사물의 공간산 위치를 인지하기 쉽게 만들어주기 때문이다. 유니티는 그림자 또한 쉽게 표현하도록 기능이 구현되어있다. 

2. 그림자의 원리

유니티는 쉐도우맵기법을 사용하고 있다.  쉐도우 맵은 깊이 텍스처 값을 이용한 기법이다. 이 기법은 모든 굴곡에서 대응하고 셀프-쉐도우가 처리되는 등 높은 퀄리티를 보여준다.

쉐도우 맵의 원리? (포워드 렌더링)

먼저 뎁스 텍스처를 생성한다. 카메라를 통해서 현재의 씬이 렌더링 되는 정보를 렌더링 하는 과정이다. 렌더링 될 픽셀의 컬러 대신 카메라로부터 픽셀의 위치까지의 거리를 렌더링 한다. 카메라의 가까이 있는 픽셀일 수로 록 검은색, 멀 수록 흰색이다(그림자에 영향을 받는 모든 오브젝트들을 렌더링). 그 후 그림자 처리를 위한 별도의 버퍼가 필요하다. 이 버퍼에는 광원에서 바라보는 오브젝트의 픽셀의 깊이를 저장한다. 이 버퍼가 그림자를 위한 정보를 담아서 쉐도우 맵이다. 

그림자의 깊이를 저장하는 버퍼를 만든 후 픽셀 쉐이더에서 깊이를 비교하는 과정을 진행한다. 또한 넓은 영역을 커버하기 위해 여러 구역으로 나누기도 하고 계단 현상을 없애기 위해 여러 번 샘플링하여 필터링 처리를 하는 부가 기능들을 추가한다. 그러다 보니 그림자가 렌더링 비용 중 많은 부분을 차지하게 된다.

뎁스 텍스처와 쉐도우 맵이 완성되면 뎁스 텍스처에 있는 정보와 쉐도우 맵에 있는 정보를 비교하여 그림자 영역을 계산한다. 뎁스 텍처에 있는 픽셀들을 순회하면서 해당 픽셀을 광원으로부터의 거리로 변환하여 비교한다.

2. 어떤 그림자를 이용해야 할까

물론 실시간으로 그림자를 활성화시키고 유니티의 그림자 구현능력을 믿어보면 간단하고 편하다. 하지만 그림자를 렌더링 하는 것은 높은 성능을 요구하기 때문에 이렇게 그냥 맡기는 것은 무책임하다. 그렇기에 만들려는 게임이 무엇인지, 어떤 디바이스를 타깃으로 만들 건지 생각하고 그에 맞는 세팅이 필요하다. 

아래는 유니티가 제공하는 컴포넌트에서 쉽게 설정할 수 있는 셋팅방법이다. 

Mesh Renderer는 메쉬 필터의 지오메트리를 사용하여 오브젝트의 Transform컴포넌트에서 정의된 위치에서 렌더링 한다. 프로퍼티를 간단하게 살펴보면 

Cast Shadows : 그림자 라이트가 비춰질 때 메쉬가 그림자를 만든다. 

Receive Shadows : 오브젝트에 다른 오브젝트의 그림자를 받는 설정이다. 실시간으로 그림자가 적용된다. 

1. 그림자를 필요로 하지 않는 오브젝트인 경우는 CastShadow를 꺼놓아야 드로우콜을 절약할 수 있다.

2. ShadowsOnly로 선택하면 최종 화면에는 렌더링 되지 않지만 쉐도우 맵에는 반영되어 그림자를 만드는 오브젝트가 된다. 즉 화면에는 보이지 않는 특성이 있어 그림자 처리만을 위한 3D 모델에 활용할 수 있다.

씬뷰에는 메시만 보이고 게임 뷰에서는 그림자만 보인다.

3. Light -> Shadow Type

Soft Shadows 가 좋은 만큼 성능도 많이 차지한다. 픽셀 쉐이더의 부담이 된다. 모바일이라면 되도록 HardShadows를 사용한다.

HardShadows

 

SoftShadows

4. Light -> Ressolution

쉐도우맵의 해상도를 나타낸다.

Low Resolution
Very High Resolution

5. Shadow Distance

Edit -> Project Setting -> Quality -> Shadow Distance

Shadow Distance는 카메라로부터 그림자가 그려지는 거리를 의미한다.

이 값이 낮을수록 멀리 있는 그림자는 그리지 않는다. 그러는 대신에 쉐도우 맵에서 오브젝트의 픽셀이 차지하는 비중이 커져 시각적으로 해상도가 올라가는 효과를 낸다.

멀리 있는 슬라임은 그림자를 그리지 않는다.

멀리있는 슬라임의 그림자가까지 그리지만 비교적으로 낮은 품질의 그림자로 보인다.

7. Shadow Cascades 

케스트 케이드 쉐도우 맵은 쉐도우 맵을 범위별로 여러 개를 만들어서 사용하는 기법이다. 원근법이 적용된 뷰 프러스텀 공간에 오브젝트를 렌더링하는 유니티에서는 가까운 오브젝트일수록 차지하는 픽셀의 수가 적다. 

이러한 한계를 해결하기 위해 프러스텀 영역을 몇 단계로 나누어서 서로 다른 쉐도우 맵을 사용 한다.

Two Cascades는 2등분, Four Cascades는 4등분

Two Cascades

많이 쪼갤수록 그만큼 드로우 콜 수도 늘어난다. 

7. 배칭

드로우 콜에 대해서 알았다면, 이제 이것을 줄이는 방법을 알아야 한다. 가장 효율적인 방법으로 배칭이 있다. 효율적인 방법이라고 한 것은 여러의 배치를 하나로 묶어서 하나의 배치로 만드는 것이 바로 배칭인 것이다.  한 가지 예를 들어보면 오브젝트가 3개 있다면 원래는 3개의 드로우 콜 즉, 3개의 배치가 필요하지만 조건에 따라서 1개까지 줄이는 것이다. 메시가 서로 다른 오브젝트이지만 같은 머테리얼을 사용하면 하나의 배치로 만들 수 있다. 

배칭을 위해서는 다른메시를 이용하더라도 머티리얼을 공유해서 사용해야 한다. 파츠가 여러 개인 경우 메시는 각각 다르지만 하나의 머티리얼을 통해 색상을 입히는 것이다. 이렇게 하기 위해서는 여러 장의 텍스처를 하나를 묶어 텍스처 아틀라스 기법으로 하나의 텍스처로 여러 메시들이 사용할 수 있게 하는 것이다.

같은 머테리얼이라는 의미는 동일한 인스턴스를 말하며, 같은 텍스처를 사용하는 머테리얼이지만 그것이 2개라면 같은 인스턴스가 아닌 것이다.

다른 머테리얼

이러한 이유로 코드상에서 머티리얼을 접근할때 유의할 점이있다.  머테리얼 색상을 바꾸는 코드는 GetComponent<Renderer>().material.color = Color.red;을 사용하면 색상을 바꿀 수 있다. 하지만 이 코드를 사용할 때마다 계속해서 새로운 머테리얼을 복사 생성하게 된다. 그러면 다른 머테리얼의 인스턴스를 생성하는 것이다. 하지만 같은 인스턴스를 사용하는 방법도 있다. 머테리얼의 속성을 바꾸는 Renderer.sharedMaterial을 사용하면 된다. 물론 상황에 따라 사용하겠지만 한 가지 예시로,

아래 그림처럼 스켈레톤 몬스터는 모두 같은 머티리얼을 사용하고 있다. 그러면 현재는 한 개의 인스턴스가 있는 것이다. 그런데 플레이어가 스켈레톤을 공격하면 빨간색으로 피격 효과를  받게 되는데 같은 인스턴스를 공유하고 있기 때문에 피격을 당하지 않은 몬스터까지도 피격 효과(빨갛게) 변할 것이다. 

이것은 분명 개발 기획의도는 아닐 것이다. 그렇다면 어쩔 수 없이 몬스터마다 각각 다른 인스턴스를 생성해서 적용해야 하는 걸까?

2019/10/03 - [유니티/레퍼런스] - 유니티)MaterialPropertyBlock

 

유니티)MaterialPropertyBlock

sharedMaterial 머테리얼의 속성을 변경할때 SharedMaterial으로 색상을 바꾼다던지 쉐이더코드의 프로퍼티값들을 변경한다. 그런데 이 런타임중에 변경된 머테리얼의 값들은 종료해도 바뀐 값 그대로 유지된다...

funfunhanblog.tistory.com

MaterialPropertyBlock를 이용하면 객체의 변화를 주는 동안만 배칭이 되지 않고 작업이 끝나면 다시 배칭을 하는 방법이다.

 

8.  스태틱배칭

스태틱 배칭은 말 그대로 정적인 오브젝트 즉 움직이지 않는 오브젝트를 위한 기법이다. 주로 배경 오브젝트들이 해당되는데 이 기법을 이용하려면  Static 플래그를 켜야 한다. 이 플래그를 켜야 유니티가 이 오브젝트는 움직이지 않는 스태틱 오브젝트라는 것을 알 수 있기 때문이다. 

그런 후 다른 작업 없이 게임을 시작하면 자동으로 유니티는 스태틱 플래그가 켜진 오브젝트를 배칭 처리 작업을 진행한다. 오브젝트 버텍스가 많을수록 렌더링 연산 비용이 크다. 당연히 버텍스 수가 많으면 런타임 과정 중에 부담이 크다. 하지만 스태틱 배칭 된 오브젝트는 런타임에 연산이 이루어지지 않는 게 장점이 되는 것이다.

드로우콜수를 줄이기 위해 오브젝트들을 합쳐 내부적으로 하나의 메시로 만들어준다. 만일 3개의 오브젝트가 1개의 메시만 사용하더라도 3개의 메시를 합친 만큼 추가 메모리가 필요하게 되는 것이다. (내 생각에는 SpriteAtlas처럼 생각하면 될듯하다)

물론 이 작업은 유니티가 이러한 정보들을 저장해 야하기 때문에 메모리가 조금 더 필요하게 된다. 렌더링(드로우 콜)이냐 메모리냐 이 논제는 최적화에서 자주 결정해야 할 사항이다.  스태틱 배칭을 간단하게 정의하자면

대상 :움직이지 않는 오브젝트

장점 : 드로우콜 감소

단점 : 메모리 증가

9.  다이내믹 배칭

동적으로 움직이는 오브젝트들끼리 배칭 처리를 하는 기능이다. 동일한 머티리얼을 사용하며 자동으로 배칭이 이루어진다. 배칭 처리는 런타임상에 이루어지며 Static플래그가 체크되어 있지 않는 오브젝트들의 버텍스들을 모아서 합쳐주는 과정을 거친다. 이러한 버텍스들을 모아서 다이내믹 배칭에 쓰이는 버텍스 버퍼와 인덱스 버퍼에 담는다. GPU는 이를 가져가서 렌더링 하는 것이다. 매프 레임 데이터 구축과 갱신이 발생하기 때문에 오버헤드가 발생하게 된다. 다이내믹은 자동으로 이루어지지만 여러 가지 조건들이 있다.

1. 버텍스 900개 이하로 된 메시로만 적용된다.

2. 트랜스폼을 미러링 한 오브젝트들은 배칭 되지 않는다.(예를 들어 Scale값이 1인 오브젝트를 -1로 적용했을 때)

3. 다른 머티리얼 인스턴스를 사용하면 같이 배칭 되지 않는다.

4. 라이트맵이 있는 오브젝트는 추가 렌더러 파라미터를 가지는데, 일반적으로 동적으로 라이트 매핑된 오브젝트가 완전히 동일한 라이트맵의 지점에 있어야 배칭 된다.

5. 멀티 패스 셰이더를 쓰면 배칭이 되지 않는다.

스태틱 배칭은 버텍스 쉐이더에서 월드 스페이스로의 변환이 이루어진다. 즉 GPU에서 연산이 된다. 하지만 다이내믹 배칭은 실시간으로 오브젝트의 위치가 변환되기 때문에 CPU에서 연산이 이루어진다. 

10.  2D 스프라이트 배칭

2D도 배칭 작업이 가능하다. 텍스처 아틀라스 기법처럼 여러 이미지들을 한 이미지에 모아서 사용하는 방법과 SpriteAtlas를 사용하여 배칭 처리한다.

2019/01/09 - [유니티/최적화] - 유니티) 아틀라스 Sprite Atlas이용해 드로우콜을 줄여보자

 

유니티) 아틀라스 Sprite Atlas이용해 드로우콜을 줄여보자

스프라이트 아틀라스 유니티에서 아틀라스는 텍스처를 한곳에 모은 한장의 큰 텍스처라고 할수있다. 텍스처 하나씩 따로따로 사용하고 관리하는것은 효율적이지 못하다. 아틀라스를 사용하면 드로우콜을 줄일 수..

funfunhanblog.tistory.com

11.  GPU 인스턴 싱

적은 수의 드로우 콜을 사용하여 동일한 메시의 여러 복제본을 한 번에 그리거나 렌더링 한다. 특히 씬에서 반복적으로 나타나는 건물, 나무, 풀 등의 오브젝트를 보여줄 때 좋다. 또 같은 메시를 사용하더라도 간단한 변화 컬러, 스케일 등의 변화를 줄 수 있다. 별도의 메시를 메시를 생성하지 않기에(트랜스폼 정보를 별도의 버퍼에 저장) 다이내믹 배칭과 스태틱 배칭보다 오버헤드가 적다.

https://docs.unity3d.com/kr/2017.4/Manual/GPUInstancing.html

 

1. 드로우콜  이란

간단하게 CPU가 GPU에게 렌더링 작업을 수행하도록 명령을 하는것이다.

게임은 실시간 렌더링 어플리케이션이다. 

실시간으로 렌더링을 수행하기에, 한 프레임의 렌더링은 오브젝트를 하나하나 그릴때마다 여러 정보들을 CPU에서 GPU로 전달하여 그리도록 명령한다.

CPU가 중앙처리 장치인 만큼 GPU에게 명령하기 떄문에 CPU의 의해 핸들링 되는 구조이다.

여기서 알아야 할 것은 CPU와 GPU는 각각 자신들이 메모리 공간을 가지고 있다. CPU의 메모리는 RAM이 주로 사용된다. 즉 이 RAM에 각종 데이터들이 담기고 CPU가 이를 이용하여 연산을 수행하게 된다. 그래픽 처리도 GPU와 VRAM을 나누어 관리한다.

보통 모바일기기에서는 CPU와 GPU메모리를 물리적으로 나누지 않는다.(하나의 물리적인 메모리를 논리적으로 나누어 사용)

드로우 콜은 렌더의 상태를 알려주는 명령과 CPU가 GPU에게 그리라는 명령(DP Call)까지이며, 이런 과정은 한번에 보내는 것이 아니라 순차적으로 보내게된다. 이런 과정이 반복되며 화면에 오브젝트를 렌더링하게된다.

2. 데이터와 명령의 흐름

데이터의 흐름 : Storage(HDD, SDD ,SD) -> CPU메모리 -> GPU메모리 (메시정보, 텍스처, 등등)

드로우콜을 이해 하려면 데이터와 명령의 흐름을 알아야한다. 먼저 메시,텍스처 등 렌더링에 필요한 데이터들은 저장소 흔히 알고있는 HDD,SDD,SD에 먼저 위치한다. 그러고 CPU는 이 데이터들을 파싱하여 CPU메모리에 데이터를 옮긴다. 그 그런 후 마지막으로 GPU는 CPU의 메모리의 데이터들을 GPU메모리에 복사를 하게된다.

예를들어 GPU가 메시를 렌더링할 때 지오메트리 데이터를  GPU메모리에서 가져와 렌더링하게 된다. (GPU메모리에 렌더링에 필요한 데이터가 있다고 보면 됨)

메모리를 가져오고 전달하는 과정을 매프레임 때마다 하는것은 성능저하가 올 수 있기 때문에 특정 시점(로딩 시점, 씬전환시점)에 메모리 데이터를 올려 사용하도록 하는게 좋다. 계속해서 사용하는 데이터라면 메모리에 계속 올려두고 사용하고 그럴필요가 없는 메모리라면 데이터를 해제해 메모리 부담이 없도록 한다. 메모리에 영향이 없다면, 특정시점에 한번에 해제하는 것도 방법이다. 

3. 커맨드 버퍼 (Command Buffer)

렌더링은 CPU가 바로 GPU에 명령을 보낸다고 생각하지만 중간에 GPU가 할일을 보관하는 중재자가 있다. CPU와 GPU는 병렬작업을 통해 수행하는데 이러한 과정을 중재하는 커맨드버퍼가 있다. CPU가 GPU에게 명령을 하는 순간 GPU가 다른 일을 하고 있을 수 있기 때문에 그 명령을 쌓아 놓고 순차적으로 GPU가 처리 할 수 있도록 한다.  

4. CPU의 성능에 의존되는 드로우콜

렌더링이기 때문에 드로우콜은 CPU보다는 GPU의 영향이 크다고 생각하지만, GPU가 그리기 위한 신호들을 모두 CPU가 GPU에 맞게 변형하여 보내게된다. 이런 과정들은 곧 CPU바운더리의 오버헤드이며, 텍스쳐의 크기를 줄이거나, 폴리곤 수를 줄인다고 해서 드로우콜의 성능이 좋아지는 것은 아니다. (횟수를 줄여야함) 

5. 배치와 SetPass Call

드로우콜의 발생조건을 이해하기 전에 먼저 배치(Batch)와 SetPass를 알아야한다. 배치는 각각의 드로우콜 사이에서 그래픽스 드라이버 유효성 체크를 진행할때, GPU에 의해 접그되어지는  리소스들을 변경하는 일련의 작업들을 총칭한다. (드로우콜과 혼용하여 사용하지만 드로우콜을 포함하는 상위 개념인것이다.) SetPass는 쉐이더로 인한 렌더링 패스 횟수를 의미하고 쉐이더의 변경 시 SetPass카운트는 증가하게된다. 드로우콜이 일어날때 상태 변경의 발생 여부로 이해하면 된다. (메시 변경은 포함하지 않음) 

유니티 Stats를 보면 간단하게 몇번의 배치가 됐는지 볼수 있다.

6. 드로우콜의 발생 조건

오브젝트 하나를 그릴때 메시1개, 머테리얼도 1개로 이루어져있다면 배치는 1이다.(드로우콜 한번), 하지만 하나의 오브젝트가 여러 파츠로 나눠져있고, 메시가 여러개이면 하나의 머테리얼을 공유했다고 해도 파츠 수 만큼 드로우콜이 발생한다.  이러한 오브젝트가 여러개있다면 파츠수 x 오브젝트수  = 드로우콜 수 가되는것이다. 때로는 당연히 성능에 많은 영향을 미칠수 있다. 메시가 여러개인 경우말고도 머테리얼이 여러개 인 경우, 툰쉐이딩 처럼 외각선을 그리기위해 멀티패스로 이루어져있는 쉐이더를 사용하면 두번의 드로우콜이 발생하게 된다.  

 

2019/01/09 - [유니티/최적화] - 유니티) 아틀라스 Sprite Atlas이용해 드로우콜을 줄여보자

 

 

유니티) 아틀라스 Sprite Atlas이용해 드로우콜을 줄여보자

스프라이트 아틀라스 유니티에서 아틀라스는 텍스처를 한곳에 모은 한장의 큰 텍스처라고 할수있다. 텍스처 하나씩 따로따로 사용하고 관리하는것은 효율적이지 못하다. 아틀라스를 사용하면 드로우콜을 줄일 수..

funfunhanblog.tistory.com

 

 

 

 

sharedMaterial 

머테리얼의 속성을 변경할때 SharedMaterial으로 색상을 바꾼다던지 쉐이더코드의 프로퍼티값들을 변경한다. 그런데 이 런타임중에 변경된 머테리얼의 값들은 종료해도 바뀐 값 그대로 유지된다. 이것을 봤을때 Material에셋과 대응한다고 볼 수 있다.

같은 머테리얼을 가지고 있는 오브젝트

sharedMaterial을 사용하면 같은 머테리얼을 사용하는 모든 오브젝트가 변경된다. 예를들어 플레이어가 공격한 몬스터에게 림라이트효과를 주고 싶어 피격받은 몬스터의 Rim의 굵기를 변경했는데, 주위 모든 몬스터들의 Rim의 굵기가 변경되는것이다. => sharedMaterial을 참조하여 렌더링하기 때문 (배치 렌더링)

하지만 피격한 몬스터의 Rim만 변경을 원하기에 Renderer.material로 특정 오브젝만 변경할 수 있다. 

복사본을 생성(Instance)하는 Renderer.material

값을 변경을 하지 않아도 Renderer.material을 참조하는 순간!, 사본이 생성된다. 당연히 사본을 생성했기에 배치 랜더링이 되지않는다. 

해결법은 Material Property Block

 

 

학습참고

http://thomasmountainborn.com/2016/05/25/materialpropertyblocks/

https://docs.unity3d.com/kr/530/ScriptReference/MaterialPropertyBlock.html

https://cacodemon.tistory.com/entry/material-%EA%B3%BC-sharedMaterial-%EA%B7%B8%EB%A6%AC%EA%B3%A0-Material-Property-Block

1. namespace 추가

 

2. EditorWindow상속

monobehavior는 없애주고, start(),update()도 같이 지워준다

 

3. 에디터 이름및 경로 설정

[MenuItem("TestEditor/EditorWindow")]

유니티 에디터의 등록될 이름과 경로를 적어준 뒤 static함수를 하나 만들어주면 아래와 같이 실제 유니티에서 생성된 것을 볼 수 있다.

유니티 에디터

 

4. 에디터 창 띄우기

현재는 클릭해도 아무것도 변화가 없다. 당연!

EditorWindow클래스에있는 GetWindow라는 함수를 사용해 창을 띄울 수 있다.

에디터 창 이름 변경 

아래 My Editor로 변경된 걸 볼 수 있다.

 

5. 글자 띄우기

OnGUI()함수안에 GUILayout.Label("안녕하세요 반갑습니다."); 넣어준다.

 

문자 입력받기

6. 버튼 만들기

GUILayout.Button("버튼")

 

7. 적용

프로젝트 원소 대전에서 플레이 중의 에디터로 속성을 바꿀 수 있게 했다. 속성마다 테스트할 경우가 많기 때문에 만들어봤다.

물일 때는 불속 성이 도망가지만 땅 속성이면 다시 쫒아온다.

물>불>땅>물

1. 트레이닝 설정값 세팅

config폴더

trainer_config.yaml파일을 복제하고 지금 작업하고 있는 유니티 프로젝트에 넣는다.(#1에서 복사 떴었음)

trainer_config에는 트레이닝에 사용할 여러 가지 설정값들이 존재한다.

2. 트레이닝 설정값 세팅

터미널 창을 열 고을 유니티폴더로 이동 후

mlagents-learn trainer_config.yaml --run-id=firstRun --train --slow 입력 명령어를 입력한다.

유니티 에디터를 실행하라는 명령이 나온다.

 

slow로 명령해 느리지만 자동으로 뭔가 하고 있다. (프로그램이 아무거나 누르는 중이다)

터미널에서 훈련상황 로그를 보여준다.

 

3. 훈련내용 확인

프로젝트 폴더에 가보면 model폴더가 생성된 것을 볼 수 있다. 

 

4. 훈련상황 세팅

파일을 열어주고

 

default선언 부분은 새로 선언한 부분에 없는 속성들은 default에서 속성 값대로 진행된다.

유니티에서 생성한 이름과 같게 Learing_Brain으로 선언한다.

gamma : 낮을수록 먼 미래의 보상을 낮게 평가하기 때문에 즉각적인 보상이 높게 평가됨 / 높으면 미래의 보상을 좀 더 신경 씀

lamda : 낮으면 지금까지의 보상을 기준으로 미래의 보상을 평가함(지금까지의 방향을 유지함) / 높으면 다양성이 커짐 대신 안정성은 떨어짐

buffer_size : 모델이 갱신될 스탭 사이즈

max_steps : 트레이닝을 완전히 종료함

 

훈련을 동시에 실행하기 위해 복사해준다.

Academy TimeScale 변경

TimeScale : 100 -> 15 작을수록 오차가 줄어듦

 

step 5000이 되면 훈련을 종료한다.

 

 

'Unity > 머신러닝' 카테고리의 다른 글

ML-Agents) 브레인 생성  (0) 2019.09.17
ML-Agents) 유니티 프로젝트 생성  (0) 2019.09.17
ML-Agents)개발환경 셋팅 (Window)  (0) 2019.09.17

1. Player 브레인 생성

- Player Brain 파라미터 살펴보기

BrainParameters 

Space Size :  관측한 횟수 (Agent 스크립트에서 6번 저장을 하도록 했으니 6으로 설정)

Stacked Vectors : 다양한 관측이 가능 ( 성능이 떨어짐)

Vector Action

Space Type : Continuous로 변경

Space Size : 입력 통로는 2개로 설정했음 (가로, 세로)

- Axis Continuous Player Action설정

사용자가 입력한 방향을 입력받기 위해

- Brain 등록

만든 Brain등록

아카데미가 브레인을 통제하게 됨

 

Player에게도 브레인 등록

테스트 사용자가 키보드로 움직임

 

2 Learning브레인 생성

ML-Agents -> LearningBrain생성

LearningBrain 파라미터 설정

SpaceSize : 6 설정

SpaceType : Continuous

SpaceType : 2 설정

 

Player는 브레인을 playerBrain에서 LearingBrain으로 변경해준다.

이제는 사용자가 아닌 인고 지능 학습활동으로 움직일 것이기 때문에 

 

Academy설정

생성한 Brains 두 개를 등록해준다.

Control : 외부 파이썬 프로그램(텐서 플로우)이 통제할 수 있도록 하는 것 체크해준다.

 

 

'Unity > 머신러닝' 카테고리의 다른 글

ML-Agents) 강화학습 #4  (0) 2019.09.17
ML-Agents) 유니티 프로젝트 생성  (0) 2019.09.17
ML-Agents)개발환경 셋팅 (Window)  (0) 2019.09.17

1. Unity프로젝트 생성

 

Unity SDK는 템플릿으로 사용할 수 있는 유니티 프로젝트 폴더이다.

여기에는 유니티 ML-Agent에서 쓰이는 유니티 프로젝트 구성이 모두 들어가 있다. 

템플릿 프로젝트를 훼손하지 않도록 복사해서 사용하는 것이 좋다.

 

그런 다음 유니티로 이 프로젝트를 열어주도록 한다. (2017.04버전 이상이어야 사용 가능)

그러면 ML-Agents라는 폴더가 보인다.

여기 안에는 구동할 수 있는 소스와 사용 예제들이 있다.

2. 아카데미 생성

아카데미는 유니티 외부에 있는 파이썬 프로그램과 유니티 내부에 있는 브레인들을 이어주는 역할을 한다.

 

- 스크립트 생성 / 오브젝트 생성

Player_Academy라는 이름의 스크립트와 Academy라는 오브젝트를 생성해준다.

- 네임스페이스 선언

- Academy 상속

이 Academy는 아카데미에서 동작하기 위한 코드들이 작성돼있다. (Academy는 추상 클래스)

- Academy기능 확인

Max Step : 현재 에피소드를 종료하고 다음 에피소드로 강제로 리셋하는 간격

(0으로 되어있으면 아카데미가 강제로 넘기지 않고 파이썬 프로그램에서 설정한 간격을 따른다.)

Training Configuration : 강화 학습을 시작할 때 사용하는 설정(해상도, 프레임 제한)

Inference Configuration : 이미 강화 학습된 결과를 실시간으로 실행할 때 사용하는 옵션

 

3. 트레이닝 환경 구성

아래 그림과 같이 간단한 훈련을 시키기 위해 세팅을 해준다.

Plane : 보여주기 위한 초록색 땅

Player : 훈련을 시킬 에이전트

Target : Player가 먹을 아이템 (Tag를 goal로 바꿔준다)

DeadZone : 플레이어가 닿으면 죽는 곳

(DeadZone의 Tag는 콜라이더 충돌 시 체크하기 위해 변경해준다)

 

3. Agent스크립트 작성

새로운 스크립트를 만든 후 아카데미와 마찬가지로 네임스페이스 추가와 Agent를 상속받도록 한다.

에이전트 기본 세팅

작성한 Agent스크립트를 Player에 붙이고 Pivot과 Target을 넣어준다.

 

 

학습참고

https://www.youtube.com/watch?v=o8cEK6X8pkM

'Unity > 머신러닝' 카테고리의 다른 글

ML-Agents) 강화학습 #4  (0) 2019.09.17
ML-Agents) 브레인 생성  (0) 2019.09.17
ML-Agents)개발환경 셋팅 (Window)  (0) 2019.09.17

 머신러닝

유니티 머신러닝

https://unity3d.com/kr/machine-learning

 

머신러닝은 자율 에이전트에게서 지능형 행동을 끌어낼 수 있는 방식이다.  이 기술로 이용하여 기존의 반복된 작업을 사람이 아닌 컴퓨터가 가능하도록 할 수 있다.

 

유니티 머신러닝의 대한 자세한 설명은 

https://blogs.unity3d.com/kr/2017/09/19/introducing-unity-machine-learning-agents/

 

유니티 머신러닝 에이전트 소개 – Unity Blog

기존에 작성한 두 개의 블로그 게시물에서 게임이 강화 학습 알고리즘 개발을 진전시키는데 수행할 수 있는 역할이 있다고 언급했었습니다. 유니티는 세계에서 가장 널리 사용되는 3D 엔진 개발업체로 머신러닝 및 게임 분야 사이에서 미래를 그려나가고 있습니다. 머신러닝 연구자가 가장 ...

blogs.unity3d.com

 

개발 환경 세팅

 

1. 깃 설치

 

2.  ML-Agents 다운로드 

 

깃에서 주소복사를 해준다

 

3. PowerShell을 이용해서 패키지 Clone

 

복사한 주소를 아래 명령어처럼 입력한다.

설치 중

 

완료되면 설치된 장소로 ml-agents라는 폴더가 생성된다.

4. Ml-agent동작 환경 설정 (Anaconda & Python 설치)

 

- Anaconda 설치 

 

Ml-agent는 Pyhton3.6 버전대에서만 사용이 가능하기에 아나콘다 최신 버전이 아닌 이전 버전을 이용한다.

https://repo.continuum.io/archive/index.html

 

Skip 해준다.

 

- Python개발 환경 설정

 

Anaconda Prompt실행

 

아래 그림처럼 명령어 입력  => 파이썬 3.6 버전을 사용하는 새로운 MI-Agent이름으로 새로운 개발환경을 생성함

 

추가 다운로드할 패키지 명단을 보여줌 (y) 눌러줌

 

ml-agent 개발환경 로드해준다.

 

- ML-Agent 유니티 패키지 개발환경 구성

pip라는 파이썬에 내장되어있는 패키지 매니저를 통해서 유니티 ml-agent가 필요로 하는 외부 의존 라이브러리들을 설치

밑에 그림 명령어를 통해 설치

설치 과정에서 버전에 대한 오류가 났었는데 아래 사이트를 참고하여 해결하였다.

https://github.com/Unity-Technologies/ml-agents/issues/1939

설치확인

mlagents-learn --help  명령어를 통해 설치가 잘되었는지 확인한다.

 

5. 유니티는 Linux Target Support  설치

유니티 허브나 유니티 홈페이지에서 다운로드해준다.

 

 

 

여기까지 개발환경설정이 완료다.

 

 

 

 

 

 

학습 참고 사이트

https://www.youtube.com/watch?v=mJh31T3aGkI

'Unity > 머신러닝' 카테고리의 다른 글

ML-Agents) 강화학습 #4  (0) 2019.09.17
ML-Agents) 브레인 생성  (0) 2019.09.17
ML-Agents) 유니티 프로젝트 생성  (0) 2019.09.17

+ Recent posts