TIL
07.01 TIL
2026. 7. 1. 20:49

일정표

시간 할 일 비고
08:30~10:00 코드카타, 오늘의 수학  
10:00~11:00 개인 프로젝트  
11:00~13:00 팀프로젝트 발제 및 개인 공부  
14:00~16:00 Git 특강  
16:00~18:00 팀프로젝트 스크럼, 회의  
19:00~21:00 TIL 작성, 팀프로젝트  
21:00~22:00 운동  
22:00~23:30 개인 프로젝트  

*오늘의 코드카타*

 

문제. 푸드 파이트 대회

더보기

수웅이는 매달 주어진 음식을 빨리 먹는 푸드 파이트 대회를 개최합니다. 이 대회에서 선수들은 1대 1로 대결하며, 매 대결마다 음식의 종류와 양이 바뀝니다. 대결은 준비된 음식들을 일렬로 배치한 뒤, 한 선수는 제일 왼쪽에 있는 음식부터 오른쪽으로, 다른 선수는 제일 오른쪽에 있는 음식부터 왼쪽으로 순서대로 먹는 방식으로 진행됩니다. 중앙에는 물을 배치하고, 물을 먼저 먹는 선수가 승리하게 됩니다

이때, 대회의 공정성을 위해 두 선수가 먹는 음식의 종류와 양이 같아야 하며, 음식을 먹는 순서도 같아야 합니다. 또한, 이번 대회부터는 칼로리가 낮은 음식을 먼저 먹을 수 있게 배치하여 선수들이 음식을 더 잘 먹을 수 있게 하려고 합니다. 이번 대회를 위해 수웅이는 음식을 주문했는데, 대회의 조건을 고려하지 않고 음식을 주문하여 몇 개의 음식은 대회에 사용하지 못하게 되었습니다

예를 들어, 3가지의 음식이 준비되어 있으며, 칼로리가 적은 순서대로 1번 음식을 3개, 2번 음식을 4개, 3번 음식을 6개 준비했으며, 물을 편의상 0번 음식이라고 칭한다면, 두 선수는 1번 음식 1개, 2번 음식 2개, 3번 음식 3개씩을 먹게 되므로 음식의 배치는 "1223330333221"이 됩니다. 따라서 1번 음식 1개는 대회에 사용하지 못합니다

수웅이가 준비한 음식의 양을 칼로리가 적은 순서대로 나타내는 정수 배열 food가 주어졌을 때, 대회를 위한 음식의 배치를 나타내는 문자열을 return 하는 solution 함수를 완성해주세요

#include <string>
#include <vector>
#include <algorithm>

using namespace std;

string solution(vector<int> food) {
    string left_half = "";

    for (int i = 1; i < food.size(); ++i) {
        int count = food[i] / 2;

        left_half.append(count, i + '0'); 
    }

    string right_half = left_half;
    reverse(right_half.begin(), right_half.end());

    return left_half + "0" + right_half;
}

(1) 문제 해결 로직 : 선수 한 명이 먹을 양을 계산한다. 1번 음식부터 순서대로 전체 개수를 2로 나눈 몫 (food[i] / 2) 만큼 좌측 문자열에 배치하고, 물에 해당하는 0을 그 뒤에 끼워 넣고, 우측 문자열에 reverse를 사용해서 뒤집어서 이어 붙인다. 2로 나누는 과정에서 어차피 사용하지 못하는 음식의 경우 int 자료형의 특성상 자동으로 제외되므로 조건이 자연스럽게 충족된다.

 

(2) left_half.append(count, i + '0') : append 함수의 기능을 사용해서, 특정 문자를 원하는 횟수만큼 반복해서 문자열 뒤에 한 번에 붙여줄 수 있다. 여기서는, count 횟수만큼 i + '0' (아스키코드의 특성을 이용해서, 인덱스 번호에 '0' 을 더해주면 char형태의 값이 나온다.

 

(3) for문을 한 번 더 돌리지 말고, 이미 만들어 둔 좌측의 문자열을 'reverse'를 사용하여 돌려서 대칭 구조를 만들었다.


*수학 공부*

1. 계산 기하학 (Computational Geometry)

지금까지는 공간을 쪼개는 '최적화', 이동 방법인 '길찾기'에 대한 수학을 알아보았다면, 계산 기하학은 공간과 지형을 수학적으로 빚어내는 방법이다.

오픈월드 게임의 거대한 대륙을 자동으로 생성하거나, 언리얼 엔진의 파괴 물리를 구현할 때 절대 빠질 수 없는 알고리즘인 '보로노이 다이어그램', '마칭 큐브'를 알아보자.


 

1. 보로노이 다이어그램 (Voronoi Diagram)

 

도화지에 점(Seed)를 여러 개 찍고, 모든 픽셀들이 '어떤 Seed와 제일 가까운지'를 계산해서, 가장 가까운 점의 색깔로 자신의 색을 칠한다. 그 결과 다각형 세포(Cell) 형태가 만들어지게 되는데, 이것이 바로 보로노이 다이어그램이다.

 

얼핏 들으면 무슨 소린가 싶은데, 보로노이 다이어그램은 이 원리로 절차적 월드 생성(Biomes), 언리얼 엔진 파괴 물리(Voronoi Fracture)에 사용된다.

 

1) 오픈월드 맵을 만들 때, 맵 위에 무작위로 Seed를 뿌려 놓고, 1번 점은 사막, 2번 점은 설원... 이렇게 속성을 부여한 뒤에 보로노이 다이어그램을 돌리면 점과 점 사이의 거리를 기준으로 자연스럽고 유기적인 국가/지형의 경계선이 수학적으로 자동 생성된다.

 

2) 유리창을 쏘거나 건물을 폭파시킬 떄, 유리가 반듯하게 깨지면 어색하므로 'Chaos 디스트럭션(언리얼 물리 엔진)'은 물체가 깨지기 직전에 물체 내부에 보로노이 점들을 흩뿌리고, 보로노이 다이어그램 선을 따라 3D 메쉬를 수학적으로 조각낸다. 현실적인 파편처럼 뾰족하고 불규칙한 파괴 효과를 내는 데에 사용한다.

 

수학적 원리 : 기본적인 보로노이 다이어그램은 선이 '직선' 형태여서, 자연스러운 국가나 지형의 경계선을 만들기 위해서는 '도메인 워핑(Domain Warping)이라는 노이즈 수학을 섞게 된다.

 

<도메인 워핑> : 좌표 (X, Y)에서 가장 가까운 씨앗 점(Seed)를 찾을 때, 그냥 (X, Y)를 쓰지 않고 노이즈(난수 곡선)를 더해서 (X + Noise, Y + Noise) 위치에서 거리를 재는 것이다. 이렇게 하면 경계선이 우그러지면서 자연스러운 해안선이나 산맥의 경계선이 탄생하게 된다.

#include "Math/UnrealMathUtility.h"

// 맵의 각 픽셀(타일)이 가질 지형 데이터
struct FBiomeTile {
    int32 BiomeID; // 0: 바다, 1: 사막, 2: 숲 등
    float DistanceToCenter; // 중심점과의 거리 (나중에 고도 계산에 쓰임)
};

TArray<FVector2D> SeedPoints; // 미리 랜덤으로 흩뿌려둔 국가들의 중심점(수도)

FBiomeTile GenerateVoronoiBiome(int32 X, int32 Y)
{
    // 1. 도메인 워핑 (Domain Warping): 직선 경계를 구불구불하게 만듬
    // PerlinNoise 2D 함수가 있다고 가정 (언리얼의 FMath::PerlinNoise2D 활용 가능)
    float WarpX = FMath::PerlinNoise2D(FVector2D(X * 0.05f, Y * 0.05f)) * 20.0f;
    float WarpY = FMath::PerlinNoise2D(FVector2D(X * 0.05f + 100, Y * 0.05f + 100)) * 20.0f;

    // 왜곡된 가짜 좌표 생성
    FVector2D WarpedPos(X + WarpX, Y + WarpY);

    float MinDistance = UE_BIG_NUMBER;
    int32 ClosestSeedIndex = 0;

    // 2. 보로노이 거리 검사 (유클리드 거리)
    for (int32 i = 0; i < SeedPoints.Num(); ++i)
    {
        // 왜곡된 좌표와 각 씨앗 점(수도) 사이의 거리를 측정
        float Dist = FVector2D::Distance(WarpedPos, SeedPoints[i]);
        if (Dist < MinDistance)
        {
            MinDistance = Dist;
            ClosestSeedIndex = i;
        }
    }

    // 3. 가장 가까운 씨앗 점의 ID(지형 종류)를 이 타일에 부여
    FBiomeTile ResultTile;
    ResultTile.BiomeID = ClosestSeedIndex; 
    ResultTile.DistanceToCenter = MinDistance;
    return ResultTile;
}

 

 

2. 마칭 큐브 (Marching Cubes)

 

보로노이 다이어그램이 평면을 쪼개는 방법이라면, 마칭 큐브는 허공에서 부드러운 3D 지형을 뽑아내는 알고리즘이다. 곡선으로 이루어진 점토 같은 지형을 파내고 덧붙일 수 있게 해준다.

 

1) 허공에 무수히 많은 Cube 격자가 놓여져 있고, 격자의 각 꼭짓점(8개)에는 '밀도(Density)'라는 값이 들어있다. 예를 들어, 0은 텅 빈 공기이고, 1은 꽉 찬 흙이다. 이때, 마칭 큐브 알고리즘은 맵의 모든 큐브를 하나씩 순회(Marching)하며 꼭짓점의 밀도 값을 검사한다. 꼭짓점이 8개이므로, 각 꼭짓점이 1이냐 0이냐에 따라 총 2^8 = 256가지의 경우의 수가 나오게 된다. 프로그래머는 이 256가지 모양을 어떻게 그려낼지 미리 룩업 테이블(LUT) 배열에 저장해두면, 큐브가 밀도 데이터에 맞게 256가지 중 하나를 선택해서 그 안에 폴리곤을 그려 넣으면 거대한 3D 산맥이 완성된다. 이때 선형 보간(Lerp) 수학을 적용해서, 미세한 보간 연산을 적용해서 꼭짓점 값을 부드럽게 조정하면 보다 현실적인 매끄러운 곡선 지형이 탄생하게 된다.

 

2) 마칭 큐브는 지형뿐만 아니라 '물(Fluid)'이나 '슬라임 몬스터(Metaball)'를 표현할 때에도 사용한다. 두 개의 물방울이 서로 가까워질 때 공간의 밀도 값이 수학적으로 더해지다가 특정 임계점을 넘는 순간 마칭 큐브가 두 물방울을 부드럽게 하나로 합쳐주는 메쉬를 실시간으로 생성해 낸다. 

 

<언리얼 엔진의 마칭 큐브?>

 

언리얼 엔진의 기본 지형(Landscape) 시스템은 2D 하이트맵(Heightmap) 기반이라서, 동굴을 뚫거나 절벽을 오버행(Overhang)모양으로 깎는 것이 불가능하다. 따라서, 실제로 게임을 개발하는 과정에서 해당 지형이 필요하게 된다면 마칭 큐브 알고리즘을 직접 C++로 구현한 뒤에, UE5의 UDynamicMesh라는 엔진의 그리기 도구에 데이터를 밀어 넣어서 화면에 렌더링하는 방식으로 사용한다.

 

#include "ProceduralMeshComponent.h"

// 마칭 큐브의 256가지 경우의 수가 담긴 전역 룩업 테이블(LUT)이 있다고 가정
extern const int32 edgeTable[256];
extern const int32 triTable[256][16];

void AMyTerrainGenerator::GenerateMarchingCubesMesh()
{
    TArray<FVector> Vertices;
    TArray<int32> Triangles;

    // 1. 3D 공간(청크 단위) 순회
    for (int x = 0; x < ChunkSize; x++) {
        for (int y = 0; y < ChunkSize; y++) {
            for (int z = 0; z < ChunkSize; z++) {
                
                // 2. 현재 큐브의 8개 꼭짓점 밀도 값 추출
                float CubeCorners[8];
                GetCubeDensities(x, y, z, CubeCorners); // 자체 제작 함수

                // 3. 꼭짓점들의 밀도(0과 1의 경계)를 비트마스크(0~255)로 변환
                int32 CubeIndex = 0;
                if (CubeCorners[0] < SurfaceLevel) CubeIndex |= 1;
                if (CubeCorners[1] < SurfaceLevel) CubeIndex |= 2;
                // ... 8번 꼭짓점까지 비트 연산

                // 4. LUT(룩업 테이블)를 참조하여 텅 빈 공기(0)이거나 꽉 막힌 흙(255)이면 그릴 게 없으므로 패스
                if (edgeTable[CubeIndex] == 0) continue;

                // 5. 삼각형 인덱스를 참조하여 실제 버텍스 좌표 계산 및 배열에 추가
                for (int i = 0; triTable[CubeIndex][i] != -1; i += 3)
                {
                    // 선형 보간(Lerp)을 통해 부드러운 정점 위치 계산 로직 (생략)
                    Vertices.Add(CalculatedVertexA);
                    Vertices.Add(CalculatedVertexB);
                    Vertices.Add(CalculatedVertexC);

                    Triangles.Add(Vertices.Num() - 3);
                    Triangles.Add(Vertices.Num() - 2);
                    Triangles.Add(Vertices.Num() - 1);
                }
            }
        }
    }

    // 6. 엔진에 삼각형 배열을 던져서 실제 물리/렌더링 메쉬 생성
    ProceduralMesh->CreateMeshSection_LinearColor(0, Vertices, Triangles, TArray<FVector>(), TArray<FVector2D>(), TArray<FLinearColor>(), TArray<FProcMeshTangent>(), true);
}

 

이렇게 구현해 둔 뒤에, 곡괭이로 지형을 찍었을 때 그 지점의 Density 값을 0으로 바꾸고 메쉬를 업데이트 해주면 파괴 가능 3D 지형이 완성된다.

 

 

3. 보로노이 + 마칭 큐브

 

보로노이를 2D가 아닌 3D 공간으로 확장해보자.

더보기
3D 유클리드 거리

3D에서는 Seed가 허공에 떠 있고, 공간이 '비눗방울들의 집합(Volumetric Cell)'처럼 쪼개지게 된다.

 

보통 3D 샌드박스 게임은 단일 알고리즘만 쓰지 않고 세 가지 수학을 곱하고 더하는 식으로 구현하게 된다.

 

1) 3D 보로노이 : 허공에 보로노이 점들을 배치하고, 보로노의 셀의 중심에 가까울수록 Density 값을 1로, 셀의 외곽에 가까울 수록 Density 값을 0으로 준다. 

 

2) 수직 그라데이션(중력) : Z축(높이)이 낮을 수록 Density에 +1.0을 더하고, 높을수록 -1.0을 뺀다. 이렇게 되면 허공에 섬들이 생성되지 않고 밑으로 가라앉으면서 바닥과 연결되고, 위쪽은 평평하게 깎여나간다.

 

3) 3D 심플렉스 노이즈(디테일) : 어색한 지형의 외곽에 3D 노이즈(난수) 값을 빼고 더해주는 방식으로 자연스러운 모양으로 깎아낸다.

 

이제 이렇게 계산된 최종 Density 값을, 미리 만들어 둔 '마칭 큐브' 시스템에 던져주면 동굴이 뚫려있거나 절벽이 구불구불한 3D 자연 지형이 코드로 자동 생성되게 된다.


*개인 프로젝트 및 개인 공부*

1. 코드 검수 프로토콜 추가

 

2. NPCBase 클래스 구현. 컴포넌트 구조의 완전한 껍데기 형태.

 

3. UE5 문법 확인과 컴파일 에러 해결

 

4. 이름표 위젯 컴포넌트(DGANameplateComponent)를 분리하기로 결정. 탑다운 쿼터뷰 형식으로 구현하고, 차후 필요시 추가 컴포넌트를 만들어 새로운 시점을 가져다 붙이는 방식으로 구성.

 

5. DGANameplateComponent.h, DGANameplateComponent.cpp 코드 작성

 

6. 이름표 컴포넌트를 만들었으므로, DGANPCBase에 들어가 있던 UI 코드를 삭제.

 

7. 에디터에서 BP_DGANPCBase를 열어 이름표 컴포넌트를 추가했음.

 

8. 메인 메뉴에서 다음 레벨로 넘어가면 탑다운 뷰가 적용되지 않고, 의도했던 wasd 이동도 되지 않는 현상 발견.

 

9. 우선 DGAHeroComponent에서, BeginPlay() 되는 순간에 플레이어 컨트롤러가 캐릭터의 몸통에 빙의되지 않았다면 IMC 주입 노드가 통째로 무시되므로 WASD 키가 먹통이 될 수 있음. 따라서, 조건문을 추가하여 캐릭터 빙의와 입력 장치 연결이 100% 보장되도록 코드를 수정함.

 

10. C++ 코드로 작성된 파일들을 블루프린트 형태로 추가하고 정리하는 과정에서 폴더 위치를 옮겼는데, 이로 인해 메인 메뉴 레벨에서 다음 레벨로 넘어가는 Start 버튼이 먹통이 되었음. DataAsset 파일의 위치 변동으로 인해 다음 레벨이 호출되지 않는 것으로 파악, 데이터 에셋의 경우 기존의 Content 폴더로 다시 옮겨주었음. 실제로 DGALevelManagerSubsystem에 해당 데이터 에셋을 호출하는 로직이 포함되어 있어, 파일 경로가 달라지자 다음 레벨을 호출하지 못했던 것이었음. (현재 데이터에셋을 호출하는 방식이 하드코딩 방식임을 확인하여 다음에 여유가 생기면 변경하기로 결정)

 

11. DGAPlayerController를 받아올 때, 메인 메뉴의 UI Only 모드를 없애고 키보드 입력을 게임 월드로 즉시 귀속시킬 수 있도록 Game Only 모드를 강제로 호출하도록 변경. 에디터에서 IA_Move와 IMC_TopDownCharacter를 만들고 HeroComponent에 해당 에셋을 집어넣었음. 이렇게 분리해놓으면 나중에 미니 게임 형식이든, 맵 구조가 바뀌든 필요한 컴포넌트만 골라 넣어서 플레이어의 카메라, 조작 방식, 이름표 출력 여부 등을 유연하게 조정할 수 있게 됨. PlayerController는 InputComponent만 받아서 현재 빙의된 폰이나 액터에게 해당 정보를 패스해주는 역할만 하도록 했음.

 

12. CameraComponent를 일부 수정하고, DGAPlayerCharacter의 블루프린트 파일에서 카메라 컴포넌트, 히어로 컴포넌트를 추가했음.

 

13. 접두사 규칙 DGA가 가독성이 너무 떨어져서, 언리얼 표준인 Dga 로 접두사를 변경하였다. 접두사 변경 결과로 수정해야 하는 것들은 다음과 같다.

1) C++ 코드 및 파일시스템의 경우, Rider 의 기능을 활용하여 (Rename Refactoring, Ctrl + R, R) 안전하게 변경이 가능

2) 에디터 블루프린트의 부모 상속 끊김 문제 : 이름을 바꾼 뒤에 에디터를 켜서 블루프린트를 일일이 열어서 이름이 바뀐 C++ 클래스를 수동으로 다시 지정해주어야 한다. 이 과정에서 기존 작업물이 누락되지 않도록 주의

3) 에디터 내 데이터 에셋 경로 : 콘텐츠 브라우저의 폴더 경로는 C++ 클래스 이름을 바꾼다고 해서 바뀌지 않으므로, 파일 이름만 그대로 유지된다면ㄴ 데이터 에셋 경로 관련 코드는 수정할 필요가 없음.

 

14. DgaInteractionInterface : 인터페이스 선언 시 BlueprintNativeEvent를 사용했기 때문에, C++에서 핵심 규칙을 구현할 때는 함수 뒤에 _Implementation을 붙여서 오버라이딩해야함.

 

15. DgaTriggerComponent : 

 

16. 언리얼 다이나믹 델리게이트가 const 함수를 거부하는 이유 : 

 

17. DgaNPCBase 클래스에 인터페이스 부착 : 

 

18. DgaDialogueWidget 구현, 임시로 테스트를 위해서 해당 위젯을 DgaNPCBase에 직접 붙였음. 차후에 DialogueComponent가 만들어지면 기능을 그쪽으로 옮길 예정. ADgaNPCBase에서 FString 관련 대사 변수들을 삭제하고 NPCIdentityTag 하나만 남길 예정이며, 에디터에서 FString Name, FString Line 컬럼을 가진 구조체 데이터 테이블을 파서, 행(Row)의 이름을 Character.NPC.NachoElder 같은 태그명으로 채워 넣어야 함. 이후에는 NPC 본체가 직접 위젯에게 데이터를 주는 게 아니라, OnInteract가 발동하는 순간 "전역 게임플레이 서브시스템"을 호출하여 자신의 NPCIdentityTag를 던져서, 서브시스템이 데이터 테이블에서 그 태그에 매칭되는 대사 데이터를 긁어와 위젯에 뿌려주게 만들 것임.

 

 

19. 캐릭터들의 대사를 출력하는 위젯 디자인 및 CloseButton 로직 추가. 대사가 출력되는 과정에서 타이핑하는 연출을 추가해보았음. 이에 더해, 마우스 클릭시 타이핑 효과가 멈추고 대화문이 전부 출력되고, 대화문이 전부 출력되고 나서야 CloseButton이 팝업되며 해당 버튼을 눌러 게임을 재개하고 대화 상호작용을 멈출 수 있게 했음.

1) 버튼 가시성 제어 (SetVisibility)

2) 마우스 전역 클릭 가로채기 (NativeOnMouseButtonDown)

 

20. DgaQuestComponent를 만들어 위젯에 가져다 붙일 예정. NPC에게 다가가서 대화문을 읽으면 퀘스트를 수락하고, 해당 퀘스트가 화면 왼쪽에 배치되어 실시간으로 진행상황을 확인할 수 있게 함. FGAQuestRow,  UDgaQuestTrackerWidget 도 이어서 C++ 코드로 구현했음.

 

21. 이렇게 구성할 경우, 레벨 이동시 퀘스트의 진행 상황이 파괴되어버리는 문제가 발생한다. 따라서, Data Architecture 설계에 따라서 추후에 GameInstance를 만들 예정. 현재는 게임 극초반 단계이므로 레벨 이동이 없을 예정이라 우선은 생략.



*오늘의 총평*

팀프로젝트 회의 위주로 진행했고, 그 외에는 Git Flow를 이해하고 Git 사용법을 숙지하는 강의를 들었다. 남은 시간에는 개인 프로젝트를 만들면서 언리얼 에디터 사용을 연습했다.

 

 

'TIL' 카테고리의 다른 글

07.03 TIL  (0) 2026.07.03
07.02 TIL  (0) 2026.07.02
06.30 TIL  (0) 2026.06.30
06.29 TIL  (0) 2026.06.29
06.26 TIL  (0) 2026.06.26