🤖

본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.

⚠️

본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.

이미지 로딩 중...

프로시저럴 생성으로 무한 게임 세계 만들기 - 슬라이드 1/7
A

AI Generated

2025. 12. 30. · 0 Views

프로시저럴 생성으로 무한 게임 세계 만들기

게임 개발에서 매번 손으로 맵을 그리는 대신, 알고리즘으로 자동 생성하는 프로시저럴 생성 기법을 배웁니다. 펄린 노이즈부터 던전 생성, 무한 맵 시스템까지 실전 예제로 익혀봅시다.


목차

  1. 펄린_노이즈_활용
  2. 던전_자동_생성
  3. 지형_생성_알고리즘
  4. 무한_맵_시스템
  5. 시드_기반_생성
  6. 절차적_애니메이션

1. 펄린 노이즈 활용

어느 날 김게임 씨가 인디 게임을 개발하고 있었습니다. 지형을 일일이 그리다 보니 손이 아프고 시간도 너무 오래 걸렸습니다.

선배 박시니어 씨가 코드를 보더니 말했습니다. "왜 펄린 노이즈를 안 쓰세요?"

펄린 노이즈는 자연스러운 무작위 값을 생성하는 알고리즘입니다. 마치 구름이나 산맥처럼 부드럽게 변화하는 패턴을 만들어냅니다.

게임에서 지형, 텍스처, 구름 등을 자동으로 생성할 때 필수적으로 사용되는 기법입니다.

다음 코드를 살펴봅시다.

import 'dart:math';

class PerlinNoise {
  final Random random = Random();

  // 2D 펄린 노이즈 생성
  double noise2D(double x, double y) {
    int xi = x.floor();
    int yi = y.floor();
    double xf = x - xi;
    double yf = y - yi;

    // 각 코너의 그래디언트 값 계산
    double n00 = dotGridGradient(xi, yi, x, y);
    double n10 = dotGridGradient(xi + 1, yi, x, y);
    double n01 = dotGridGradient(xi, yi + 1, x, y);
    double n11 = dotGridGradient(xi + 1, yi + 1, x, y);

    // 부드러운 보간
    double sx = fade(xf);
    double sy = fade(yf);

    double nx0 = lerp(n00, n10, sx);
    double nx1 = lerp(n01, n11, sx);

    return lerp(nx0, nx1, sy);
  }

  double fade(double t) => t * t * t * (t * (t * 6 - 15) + 10);
  double lerp(double a, double b, double t) => a + t * (b - a);

  double dotGridGradient(int ix, int iy, double x, double y) {
    // 그래디언트 벡터 생성 및 내적 계산
    double dx = x - ix;
    double dy = y - iy;
    double angle = random.nextDouble() * 2 * pi;
    return dx * cos(angle) + dy * sin(angle);
  }
}

김게임 씨는 2D 플랫포머 게임을 개발 중이었습니다. 첫 번째 스테이지의 지형을 손으로 그리는 데만 이틀이 걸렸습니다.

이런 식이라면 열 개의 스테이지를 만드는 데 한 달은 족히 걸릴 것 같았습니다. 선배 박시니어 씨가 김게임 씨의 화면을 보며 웃었습니다.

"아직도 손으로 그리고 있어요? 펄린 노이즈 써보세요." 펄린 노이즈란 정확히 무엇일까요?

쉽게 비유하자면, 펄린 노이즈는 마치 자연의 붓질과 같습니다. 완전히 랜덤한 것도 아니고, 완전히 규칙적인 것도 아닌, 그 중간의 자연스러운 패턴을 만들어냅니다.

산맥의 능선이나 구름의 모양처럼 부드럽게 변화하는 곡선을 그려내는 것이죠. 펄린 노이즈가 없던 시절에는 어땠을까요?

게임 개발자들은 지형을 일일이 손으로 그려야 했습니다. 스테이지가 많아지면 작업량이 기하급수적으로 늘어났죠.

더 큰 문제는 랜덤 함수를 쓰면 너무 울퉁불퉁해서 자연스럽지 않다는 것이었습니다. 1과 0이 갑자기 번갈아 나타나는 식이었죠.

바로 이런 문제를 해결하기 위해 1983년 켄 펄린이 펄린 노이즈를 개발했습니다. 펄린 노이즈를 사용하면 자연스러운 지형 생성이 가능해집니다.

또한 같은 시드 값을 쓰면 똑같은 패턴을 재현할 수도 있습니다. 무엇보다 계산이 빠르고 효율적이라는 큰 이점이 있습니다.

위의 코드를 한 줄씩 살펴보겠습니다. 먼저 noise2D 메서드를 보면 x, y 좌표를 받아서 노이즈 값을 반환하는 것을 알 수 있습니다.

이 부분이 핵심입니다. 다음으로 각 격자점의 그래디언트를 계산하고, fade 함수로 부드럽게 보간합니다.

마지막으로 0과 1 사이의 자연스러운 값이 반환됩니다. 실제 현업에서는 어떻게 활용할까요?

예를 들어 마인크래프트 같은 샌드박스 게임을 개발한다고 가정해봅시다. 펄린 노이즈로 높이맵을 생성하면 산, 평야, 계곡이 자연스럽게 배치됩니다.

같은 시드로 생성하면 친구와 똑같은 월드를 공유할 수도 있죠. 많은 게임 엔진에서 이런 패턴을 적극적으로 사용하고 있습니다.

하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 펄린 노이즈를 너무 작은 범위에서 샘플링하는 것입니다.

이렇게 하면 패턴이 너무 촘촘해서 오히려 노이즈처럼 보일 수 있습니다. 따라서 적절한 스케일 값을 곱해서 사용해야 합니다.

다시 김게임 씨의 이야기로 돌아가 봅시다. 박시니어 씨의 설명을 들은 김게임 씨는 눈이 반짝였습니다.

"이걸로 지형을 자동으로 만들 수 있겠네요!" 펄린 노이즈를 제대로 이해하면 무한히 변화하는 자연스러운 콘텐츠를 자동으로 생성할 수 있습니다. 여러분도 오늘 배운 내용을 실제 게임 프로젝트에 적용해 보세요.

실전 팁

💡 - 여러 옥타브의 노이즈를 합쳐서 더 복잡한 패턴을 만들 수 있습니다

  • 스케일 값을 조절해서 큰 산맥부터 작은 언덕까지 표현 가능합니다
  • 시드 값을 저장하면 같은 맵을 다시 생성할 수 있습니다

2. 던전 자동 생성

김게임 씨가 로그라이크 게임을 만들기로 했습니다. 매번 다른 던전이 나와야 재미있을 텐데, 던전을 어떻게 자동으로 생성할지 막막했습니다.

"BSP 알고리즘 한번 써보세요." 박시니어 씨가 말했습니다.

던전 자동 생성은 알고리즘으로 방과 복도를 무작위로 배치하는 기법입니다. BSP(Binary Space Partitioning)는 공간을 재귀적으로 분할해서 던전을 만드는 대표적인 방법입니다.

매번 플레이할 때마다 새로운 던전이 생성되어 무한한 재미를 제공합니다.

다음 코드를 살펴봅시다.

import 'dart:math';

class DungeonGenerator {
  final Random random = Random();

  // BSP로 던전 생성
  List<Room> generateDungeon(int width, int height, int minRoomSize) {
    List<Room> rooms = [];
    List<Partition> partitions = [Partition(0, 0, width, height)];

    // 공간을 재귀적으로 분할
    while (partitions.isNotEmpty) {
      Partition p = partitions.removeLast();

      if (p.width > minRoomSize * 2 && p.height > minRoomSize * 2) {
        // 가로 또는 세로로 분할
        if (random.nextBool()) {
          int splitX = p.x + minRoomSize + random.nextInt(p.width - minRoomSize * 2);
          partitions.add(Partition(p.x, p.y, splitX - p.x, p.height));
          partitions.add(Partition(splitX, p.y, p.width - (splitX - p.x), p.height));
        } else {
          int splitY = p.y + minRoomSize + random.nextInt(p.height - minRoomSize * 2);
          partitions.add(Partition(p.x, p.y, p.width, splitY - p.y));
          partitions.add(Partition(p.x, splitY, p.width, p.height - (splitY - p.y)));
        }
      } else {
        // 분할할 수 없으면 방 생성
        rooms.add(createRoom(p));
      }
    }

    return rooms;
  }

  Room createRoom(Partition p) {
    int roomWidth = minRoomSize + random.nextInt(p.width - minRoomSize);
    int roomHeight = minRoomSize + random.nextInt(p.height - minRoomSize);
    int roomX = p.x + random.nextInt(p.width - roomWidth);
    int roomY = p.y + random.nextInt(p.height - roomHeight);
    return Room(roomX, roomY, roomWidth, roomHeight);
  }
}

class Partition { int x, y, width, height; Partition(this.x, this.y, this.width, this.height); }
class Room { int x, y, width, height; Room(this.x, this.y, this.width, this.height); }

김게임 씨는 로그라이크 게임의 매력에 푹 빠져 있었습니다. 죽으면 다시 시작하지만, 매번 던전이 달라서 질리지 않는다는 점이 좋았죠.

"나도 이런 게임을 만들고 싶어." 하지만 어떻게 던전을 자동으로 만들까요? 박시니어 씨가 화이트보드에 사각형을 그렸습니다.

"간단해요. 공간을 계속 쪼개면 돼요." 던전 자동 생성이란 정확히 무엇일까요?

쉽게 비유하자면, 던전 생성은 마치 케이크를 자르는 것과 같습니다. 큰 사각형 케이크를 반으로 자르고, 그 조각을 또 반으로 자르고, 계속 반복하다 보면 작은 조각들이 생깁니다.

각 조각 안에 방을 하나씩 만들고, 방들을 복도로 연결하면 던전이 완성되는 것이죠. 던전 자동 생성이 없던 시절에는 어땠을까요?

게임 개발자들은 던전 맵을 직접 디자인해야 했습니다. 레벨 디자이너가 타일 에디터로 하나하나 배치했죠.

던전이 열 개만 되어도 작업량이 어마어마했습니다. 더 큰 문제는 플레이어가 모든 던전을 다 외워버리면 재미가 떨어진다는 것이었습니다.

바로 이런 문제를 해결하기 위해 프로시저럴 던전 생성이 등장했습니다. BSP 알고리즘을 사용하면 무한히 다양한 던전 구조를 만들 수 있습니다.

또한 알고리즘으로 생성하므로 개발 시간을 대폭 줄일 수 있습니다. 무엇보다 매번 새로운 경험을 제공해서 리플레이 가치가 높아진다는 큰 이점이 있습니다.

위의 코드를 한 줄씩 살펴보겠습니다. 먼저 generateDungeon 메서드를 보면 전체 공간을 시작 파티션으로 설정하는 것을 알 수 있습니다.

이 부분이 핵심입니다. 다음으로 while 루프에서 파티션을 계속 분할하거나 방을 생성합니다.

분할 방향은 랜덤으로 결정되어 매번 다른 구조가 만들어집니다. 마지막으로 생성된 방들의 리스트가 반환됩니다.

실제 현업에서는 어떻게 활용할까요? 예를 들어 로그라이크 RPG를 개발한다고 가정해봅시다.

BSP로 던전 구조를 만들고, A* 알고리즘으로 방들을 복도로 연결합니다. 각 방에 몬스터와 아이템을 랜덤 배치하면 완성입니다.

실제로 넷핵, 로그 같은 클래식 게임들이 이런 방식을 사용했습니다. 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수 중 하나는 최소 방 크기를 너무 작게 설정하는 것입니다. 이렇게 하면 플레이어가 움직일 공간도 없는 좁은 방들이 생깁니다.

따라서 캐릭터 크기와 게임 플레이를 고려해서 적절한 최소 크기를 정해야 합니다. 다시 김게임 씨의 이야기로 돌아가 봅시다.

박시니어 씨의 설명을 들은 김게임 씨는 흥분했습니다. "와, 이걸로 무한 던전 크롤러를 만들 수 있겠어요!" 던전 자동 생성을 제대로 이해하면 적은 노력으로 무한한 콘텐츠를 제공할 수 있습니다.

여러분도 오늘 배운 내용을 실제 게임에 적용해 보세요.

실전 팁

💡 - 방들을 연결할 때는 A* 경로 찾기 알고리즘을 활용하세요

  • 최소/최대 방 크기를 조절해서 던전의 느낌을 바꿀 수 있습니다
  • 룸 타입(보물방, 보스방 등)을 미리 정의하면 더 재미있습니다

3. 지형 생성 알고리즘

김게임 씨가 오픈월드 게임 프로젝트를 시작했습니다. 광활한 대륙을 만들어야 하는데, 손으로 그릴 엄두가 나지 않았습니다.

"높이맵 기반으로 지형을 생성해보세요." 박시니어 씨가 조언했습니다.

지형 생성 알고리즘은 높이 값을 기반으로 산, 평야, 바다 등을 자동으로 배치하는 기법입니다. 펄린 노이즈로 높이맵을 만들고, 임계값에 따라 타일 타입을 결정합니다.

현실적이고 자연스러운 대륙을 코드 몇 줄로 생성할 수 있습니다.

다음 코드를 살펴봅시다.

import 'dart:math';

enum TileType { water, sand, grass, forest, mountain, snow }

class TerrainGenerator {
  final PerlinNoise noise = PerlinNoise();

  // 높이맵 기반 지형 생성
  List<List<TileType>> generateTerrain(int width, int height, double scale) {
    List<List<TileType>> terrain = List.generate(
      height, (_) => List.filled(width, TileType.water)
    );

    for (int y = 0; y < height; y++) {
      for (int x = 0; x < width; x++) {
        // 여러 옥타브의 노이즈를 합성
        double elevation = 0;
        double frequency = 1;
        double amplitude = 1;

        for (int octave = 0; octave < 4; octave++) {
          elevation += noise.noise2D(x * scale * frequency, y * scale * frequency) * amplitude;
          frequency *= 2;
          amplitude *= 0.5;
        }

        // 높이 값에 따라 타일 타입 결정
        terrain[y][x] = getTileType(elevation);
      }
    }

    return terrain;
  }

  TileType getTileType(double elevation) {
    if (elevation < -0.2) return TileType.water;
    if (elevation < 0.0) return TileType.sand;
    if (elevation < 0.3) return TileType.grass;
    if (elevation < 0.6) return TileType.forest;
    if (elevation < 0.8) return TileType.mountain;
    return TileType.snow;
  }
}

김게임 씨는 RPG의 세계관을 구상하고 있었습니다. 넓은 대륙, 거대한 산맥, 푸른 바다가 펼쳐진 판타지 세계를 상상했죠.

하지만 이걸 어떻게 구현할까요? 타일 하나하나 배치하기에는 100x100 크기만 해도 만 개의 타일입니다.

박시니어 씨가 모니터를 가리켰습니다. "높이맵 개념을 알아요?

그걸로 자동 생성하면 돼요." 지형 생성 알고리즘이란 정확히 무엇일까요? 쉽게 비유하자면, 지형 생성은 마치 등고선 지도를 그리는 것과 같습니다.

각 위치의 높이를 숫자로 표현하고, 그 숫자에 따라 색을 칠하면 지형이 됩니다. 낮은 곳은 파란색 물, 중간은 초록색 풀, 높은 곳은 갈색 산처럼 말이죠.

지형 생성 알고리즘이 없던 시절에는 어땠을까요? 레벨 디자이너들은 타일 에디터로 며칠 동안 맵을 그렸습니다.

큰 맵일수록 작업량이 기하급수적으로 늘어났죠. 더 큰 문제는 자연스럽게 보이도록 만드는 것이 정말 어렵다는 것이었습니다.

산맥의 흐름이나 강의 배치를 손으로 하나하나 신경 써야 했습니다. 바로 이런 문제를 해결하기 위해 프로시저럴 지형 생성이 등장했습니다.

높이맵 기반 생성을 사용하면 광활한 대륙을 몇 초 만에 만들 수 있습니다. 또한 옥타브를 조절해서 섬세한 디테일까지 표현할 수 있습니다.

무엇보다 자연스러운 지형 분포를 알고리즘이 자동으로 만들어준다는 큰 이점이 있습니다. 위의 코드를 한 줄씩 살펴보겠습니다.

먼저 generateTerrain 메서드를 보면 2차원 배열을 생성하는 것을 알 수 있습니다. 이 부분이 핵심입니다.

다음으로 각 타일 위치에서 여러 옥타브의 노이즈를 합성해서 최종 높이 값을 계산합니다. 옥타브를 거듭할수록 주파수는 두 배로, 진폭은 절반으로 줄어듭니다.

마지막으로 높이 값에 따라 물, 모래, 풀, 숲, 산, 눈 중 하나의 타일이 배치됩니다. 실제 현업에서는 어떻게 활용할까요?

예를 들어 문명 같은 전략 시뮬레이션을 개발한다고 가정해봅시다. 지형 생성 알고리즘으로 기본 대륙을 만들고, 침식 시뮬레이션으로 강을 배치하고, 기후 시스템으로 식생을 결정합니다.

매번 새로운 맵에서 게임을 즐길 수 있죠. 많은 샌드박스 게임들이 이런 방식을 사용합니다.

하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 옥타브를 너무 많이 쌓는 것입니다.

이렇게 하면 계산 비용이 늘어나고, 오히려 노이즈처럼 지저분해 보일 수 있습니다. 따라서 3~5개 옥타브 정도가 적당합니다.

다시 김게임 씨의 이야기로 돌아가 봅시다. 박시니어 씨의 설명을 들은 김게임 씨는 감탄했습니다.

"와, 이렇게 간단하게 대륙을 만들 수 있다니!" 지형 생성 알고리즘을 제대로 이해하면 거대한 오픈월드도 손쉽게 구현할 수 있습니다. 여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.

실전 팁

💡 - 옥타브별로 주파수와 진폭을 조절해서 다양한 지형 느낌을 만들 수 있습니다

  • 임계값을 바이옴별로 다르게 설정하면 사막, 설원 등을 표현 가능합니다
  • 강은 침식 알고리즘으로 자연스럽게 배치할 수 있습니다

4. 무한 맵 시스템

김게임 씨가 샌드박스 게임을 만들고 있었습니다. 플레이어가 끝없이 탐험할 수 있는 무한 세계를 구현하고 싶었는데, 메모리가 부족했습니다.

"청크 시스템을 써야죠." 박시니어 씨가 말했습니다.

무한 맵 시스템은 맵을 작은 청크로 나누어 필요할 때만 생성하고 로드하는 기법입니다. 플레이어 주변의 청크만 메모리에 유지하고, 멀어지면 언로드합니다.

이를 통해 사실상 무한한 크기의 게임 월드를 구현할 수 있습니다.

다음 코드를 살펴봅시다.

import 'dart:collection';

class InfiniteMapSystem {
  final Map<String, Chunk> loadedChunks = {};
  final int chunkSize = 16;
  final int renderDistance = 3;

  // 플레이어 위치 기준으로 청크 로드/언로드
  void updateChunks(double playerX, double playerY) {
    int centerChunkX = (playerX / chunkSize).floor();
    int centerChunkY = (playerY / chunkSize).floor();

    Set<String> chunksToKeep = {};

    // 렌더 거리 내의 청크만 로드
    for (int dx = -renderDistance; dx <= renderDistance; dx++) {
      for (int dy = -renderDistance; dy <= renderDistance; dy++) {
        int chunkX = centerChunkX + dx;
        int chunkY = centerChunkY + dy;
        String chunkKey = '$chunkX,$chunkY';

        chunksToKeep.add(chunkKey);

        // 아직 로드되지 않은 청크는 생성
        if (!loadedChunks.containsKey(chunkKey)) {
          loadedChunks[chunkKey] = generateChunk(chunkX, chunkY);
        }
      }
    }

    // 렌더 거리 밖의 청크는 언로드
    loadedChunks.removeWhere((key, chunk) => !chunksToKeep.contains(key));
  }

  Chunk generateChunk(int chunkX, int chunkY) {
    // 청크의 월드 좌표를 시드로 사용
    return Chunk(chunkX, chunkY, chunkSize);
  }
}

class Chunk {
  final int x, y, size;
  late List<List<int>> tiles;

  Chunk(this.x, this.y, this.size) {
    tiles = List.generate(size, (i) => List.filled(size, 0));
    // 여기서 펄린 노이즈 등으로 타일 생성
  }
}

김게임 씨는 마인크래프트 같은 샌드박스 게임을 동경했습니다. 끝없이 펼쳐진 세계를 자유롭게 탐험하는 재미가 정말 매력적이었죠.

하지만 문제가 있었습니다. 10000x10000 크기의 맵을 메모리에 올리려니 게임이 뻗어버렸습니다.

박시니어 씨가 웃으며 말했습니다. "전체 맵을 한 번에 로드하면 안 돼요.

청크 시스템을 써야죠." 무한 맵 시스템이란 정확히 무엇일까요? 쉽게 비유하자면, 무한 맵 시스템은 마치 도서관과 같습니다.

도서관에는 수만 권의 책이 있지만, 사서가 모든 책을 동시에 들고 있지는 않습니다. 필요한 책만 꺼내서 보여주고, 다 읽으면 다시 책장에 꽂습니다.

게임도 마찬가지로 플레이어가 보는 부분만 메모리에 로드하면 되는 것이죠. 무한 맵 시스템이 없던 시절에는 어땠을까요?

게임 맵의 크기가 정해져 있었습니다. 메모리 한계 때문에 맵 가장자리에 보이지 않는 벽을 세워야 했죠.

더 큰 문제는 전체 맵을 미리 생성해서 저장해야 한다는 것이었습니다. 용량도 많이 차지하고, 로딩 시간도 오래 걸렸습니다.

바로 이런 문제를 해결하기 위해 청크 기반 무한 맵 시스템이 등장했습니다. 청크 시스템을 사용하면 이론상 무한한 크기의 월드를 만들 수 있습니다.

또한 플레이어 주변만 로드하므로 메모리 사용량이 일정하게 유지됩니다. 무엇보다 필요할 때 생성하므로 초기 로딩 시간이 거의 없다는 큰 이점이 있습니다.

위의 코드를 한 줄씩 살펴보겠습니다. 먼저 updateChunks 메서드를 보면 플레이어 위치를 청크 좌표로 변환하는 것을 알 수 있습니다.

이 부분이 핵심입니다. 다음으로 렌더 거리 내의 모든 청크를 순회하면서 로드되지 않은 청크는 새로 생성합니다.

마지막으로 렌더 거리 밖의 청크는 맵에서 제거되어 메모리에서 해제됩니다. 실제 현업에서는 어떻게 활용할까요?

예를 들어 생존 게임을 개발한다고 가정해봅시다. 플레이어가 이동할 때마다 updateChunks를 호출해서 주변 청크를 관리합니다.

멀리 떨어진 청크의 데이터는 디스크에 저장했다가 다시 방문하면 불러옵니다. 테라리아, 스타듀밸리 같은 게임들도 비슷한 방식을 사용합니다.

하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 렌더 거리를 너무 크게 설정하는 것입니다.

이렇게 하면 로드할 청크가 많아져서 오히려 성능이 떨어집니다. 따라서 게임의 시야 거리에 맞춰서 적절한 렌더 거리를 설정해야 합니다.

다시 김게임 씨의 이야기로 돌아가 봅시다. 박시니어 씨의 설명을 들은 김게임 씨는 무릎을 쳤습니다.

"아, 그래서 마인크래프트가 끝이 없었던 거군요!" 무한 맵 시스템을 제대로 이해하면 메모리 걱정 없이 거대한 오픈월드를 만들 수 있습니다. 여러분도 오늘 배운 내용을 실제 게임에 적용해 보세요.

실전 팁

💡 - 청크 크기는 16x16 또는 32x32가 일반적입니다

  • 비동기로 청크를 생성하면 프레임 드롭을 방지할 수 있습니다
  • 플레이어가 수정한 청크는 파일로 저장해서 영구 보존하세요

5. 시드 기반 생성

김게임 씨가 멀티플레이 게임을 개발하고 있었습니다. 친구들이 같은 맵에서 플레이해야 하는데, 프로시저럴 생성은 매번 다르지 않나요?

"시드 값을 공유하면 돼요." 박시니어 씨가 답했습니다.

시드 기반 생성은 난수 생성기에 고정된 시드 값을 주어 동일한 결과를 재현하는 기법입니다. 같은 시드를 사용하면 언제 어디서든 똑같은 맵이 생성됩니다.

이를 통해 맵 데이터를 저장하지 않고도 월드를 공유할 수 있습니다.

다음 코드를 살펴봅시다.

import 'dart:math';

class SeededGenerator {
  late Random random;
  final int seed;

  SeededGenerator(this.seed) {
    random = Random(seed);
  }

  // 시드 기반으로 월드 생성
  WorldData generateWorld(int width, int height) {
    // 시드를 리셋해서 항상 같은 결과 보장
    random = Random(seed);

    List<List<int>> heightMap = [];

    for (int y = 0; y < height; y++) {
      List<int> row = [];
      for (int x = 0; x < width; x++) {
        // 시드에 따라 결정론적으로 생성
        int elevation = random.nextInt(100);
        row.add(elevation);
      }
      heightMap.add(row);
    }

    return WorldData(seed, heightMap);
  }

  // 특정 위치의 값을 재현
  int getValueAt(int x, int y) {
    // 위치 기반 시드 생성 (해시 함수 활용)
    int localSeed = seed ^ (x * 374761393 + y * 668265263);
    Random localRandom = Random(localSeed);
    return localRandom.nextInt(100);
  }
}

class WorldData {
  final int seed;
  final List<List<int>> heightMap;

  WorldData(this.seed, this.heightMap);

  // 월드를 공유할 때는 시드만 전송
  String getShareCode() => 'WORLD-$seed';
}

김게임 씨는 협동 서바이벌 게임을 만들고 있었습니다. 친구와 같은 섬에서 함께 플레이하려면 똑같은 맵이 필요했죠.

하지만 프로시저럴 생성은 매번 다른 결과를 내는 게 특징 아닌가요? 맵 파일을 주고받아야 할까요?

박시니어 씨가 고개를 저었습니다. "아니에요.

시드 숫자 하나만 공유하면 돼요." 시드 기반 생성이란 정확히 무엇일까요? 쉽게 비유하자면, 시드는 마치 요리 레시피와 같습니다.

같은 레시피를 따라 하면 누가 만들든 같은 요리가 나옵니다. 마찬가지로 같은 시드 값을 사용하면 누구의 컴퓨터에서든 똑같은 월드가 생성되는 것이죠.

레시피 한 장만 공유하면 되니 편리합니다. 시드 기반 생성이 없던 시절에는 어땠을까요?

프로시저럴 생성은 매번 다른 결과를 냈습니다. 친구와 같은 맵으로 플레이하려면 맵 파일을 직접 주고받아야 했죠.

더 큰 문제는 파일 크기가 커서 전송하기 어렵다는 것이었습니다. 맵이 클수록 수백 메가바이트가 넘어갔습니다.

바로 이런 문제를 해결하기 위해 시드 기반 생성이 등장했습니다. 시드를 사용하면 숫자 하나만으로 전체 월드를 재현할 수 있습니다.

또한 맵 데이터를 저장할 필요가 없어서 저장 공간이 절약됩니다. 무엇보다 "이 시드로 플레이해봐!"라고 쉽게 공유할 수 있다는 큰 이점이 있습니다.

위의 코드를 한 줄씩 살펴보겠습니다. 먼저 SeededGenerator 생성자를 보면 시드 값으로 Random 객체를 초기화하는 것을 알 수 있습니다.

이 부분이 핵심입니다. 다음으로 generateWorld 메서드는 매번 Random을 리셋해서 동일한 순서로 난수를 생성합니다.

getValueAt 메서드는 위치 기반 해시를 사용해서 특정 좌표의 값을 독립적으로 재현합니다. 마지막으로 월드 공유 코드는 시드 값만 포함합니다.

실제 현업에서는 어떻게 활용할까요? 예를 들어 마인크래프트를 생각해봅시다.

플레이어가 월드를 만들 때 시드를 입력하거나 랜덤으로 생성합니다. 같은 시드로 만든 월드는 산맥, 동굴, 마을 위치가 모두 똑같습니다.

유명한 시드는 커뮤니티에서 공유되어 많은 사람들이 같은 월드를 탐험합니다. 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수 중 하나는 시드를 제대로 리셋하지 않는 것입니다. 이렇게 하면 같은 시드인데도 호출 순서에 따라 다른 결과가 나올 수 있습니다.

따라서 월드 생성 전에 반드시 Random을 리셋해야 합니다. 다시 김게임 씨의 이야기로 돌아가 봅시다.

박시니어 씨의 설명을 들은 김게임 씨는 환호했습니다. "와, 숫자 하나로 이렇게 많은 걸 공유할 수 있다니!" 시드 기반 생성을 제대로 이해하면 효율적으로 콘텐츠를 공유하고 재현할 수 있습니다.

여러분도 오늘 배운 내용을 실제 게임에 적용해 보세요.

실전 팁

💡 - 플레이어가 시드를 직접 입력할 수 있게 하면 재미있습니다

  • 시드 값은 문자열을 해시 변환해서 사용할 수도 있습니다
  • 디버깅할 때는 고정 시드를 쓰면 버그 재현이 쉽습니다

6. 절차적 애니메이션

김게임 씨가 캐릭터 애니메이션을 작업하고 있었습니다. 다리가 여덟 개인 거미 몬스터인데, 일일이 키프레임을 찍기에는 너무 복잡했습니다.

"절차적 애니메이션을 써보세요." 박시니어 씨가 제안했습니다.

절차적 애니메이션은 키프레임 없이 수학 공식과 물리 법칙으로 동작을 생성하는 기법입니다. IK(Inverse Kinematics), 물리 시뮬레이션, 노이즈 함수 등을 활용해서 자연스러운 움직임을 만듭니다.

다리가 많거나 복잡한 생물체의 애니메이션을 효율적으로 구현할 수 있습니다.

다음 코드를 살펴봅시다.

import 'dart:math';

class ProceduralAnimation {
  // IK로 다리 위치 계산
  LegPose calculateLegIK(Vector2 target, double upperLen, double lowerLen) {
    double distance = min(target.length(), upperLen + lowerLen);

    // 코사인 법칙으로 관절 각도 계산
    double a = upperLen;
    double b = lowerLen;
    double c = distance;

    double angleKnee = acos((a * a + b * b - c * c) / (2 * a * b));
    double angleHip = atan2(target.y, target.x) -
                      acos((a * a + c * c - b * b) / (2 * a * c));

    return LegPose(angleHip, angleKnee);
  }

  // 걸음 사이클 생성 (sin 파형 활용)
  Vector2 getFootPosition(double time, double speed, int legIndex) {
    double phase = (time * speed + legIndex * 0.5) % 1.0;

    double x = 0;
    double y = 0;

    if (phase < 0.5) {
      // 공중에 떠 있는 단계
      x = lerp(-0.5, 0.5, phase * 2);
      y = sin(phase * 2 * pi) * 0.3; // 포물선 궤적
    } else {
      // 땅에 닿아 있는 단계
      x = lerp(0.5, -0.5, (phase - 0.5) * 2);
      y = 0;
    }

    return Vector2(x, y);
  }

  double lerp(double a, double b, double t) => a + (b - a) * t;
}

class LegPose { double hipAngle, kneeAngle; LegPose(this.hipAngle, this.kneeAngle); }
class Vector2 { double x, y; Vector2(this.x, this.y); double length() => sqrt(x * x + y * y); }

김게임 씨는 크리처 디자인에 도전하고 있었습니다. 거대한 거미 보스를 만들었는데, 다리가 여덟 개나 되었죠.

각 다리마다 키프레임을 찍으려니 머리가 지끈거렸습니다. 게다가 경사면에서는 다리 길이를 조절해야 하는데, 이걸 다 애니메이션으로 만들 수는 없었습니다.

박시니어 씨가 화면을 보며 말했습니다. "그냥 절차적으로 계산하면 어때요?

훨씬 자연스러울 걸요." 절차적 애니메이션이란 정확히 무엇일까요? 쉽게 비유하자면, 절차적 애니메이션은 마치 인형극과 같습니다.

인형극 연출가가 매 순간마다 손의 위치를 일일이 메모하지 않습니다. 대신 "손이 이 위치에 있어야 한다"고 목표를 정하면, 팔과 어깨가 자동으로 적절한 각도로 구부러집니다.

절차적 애니메이션도 목표 지점만 주면 관절 각도를 자동 계산하는 것이죠. 절차적 애니메이션이 없던 시절에는 어땠을까요?

애니메이터들은 모든 프레임을 손으로 그렸습니다. 캐릭터가 계단을 오르거나 경사를 걷는 장면은 각각 별도의 애니메이션이 필요했죠.

더 큰 문제는 지형에 따라 발이 땅을 뚫고 들어가거나 공중에 뜨는 현상이었습니다. 모든 상황을 미리 만들 수는 없었으니까요.

바로 이런 문제를 해결하기 위해 절차적 애니메이션이 등장했습니다. IK를 사용하면 발이 항상 지면에 정확히 닿도록 만들 수 있습니다.

또한 수학 공식으로 생성하므로 어떤 지형에도 적응합니다. 무엇보다 다리가 수십 개인 지네도 같은 로직으로 처리할 수 있다는 큰 이점이 있습니다.

위의 코드를 한 줄씩 살펴보겠습니다. 먼저 calculateLegIK 메서드를 보면 목표 지점과 다리 길이를 받는 것을 알 수 있습니다.

이 부분이 핵심입니다. 다음으로 코사인 법칙으로 무릎 관절 각도를 계산하고, atan2로 고관절 각도를 구합니다.

getFootPosition 메서드는 시간에 따라 발의 궤적을 사인 함수로 생성합니다. 마지막으로 각 다리의 위상을 다르게 해서 자연스러운 걸음걸이를 만듭니다.

실제 현업에서는 어떻게 활용할까요? 예를 들어 오픈월드 게임의 말 캐릭터를 생각해봅시다.

평지, 언덕, 바위 위를 걸을 때마다 다리 각도가 달라져야 합니다. 절차적 애니메이션으로 각 발의 목표 지점을 지면에 레이캐스트해서 정하면, IK가 자동으로 관절을 구부립니다.

데스 스트랜딩, 호라이즌 같은 AAA 게임들이 이런 기법을 사용합니다. 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수 중 하나는 IK 계산을 매 프레임마다 하는 것입니다. 이렇게 하면 성능이 떨어질 수 있습니다.

따라서 목표 지점이 크게 바뀔 때만 재계산하거나, 간단한 보간으로 부드럽게 처리해야 합니다. 다시 김게임 씨의 이야기로 돌아가 봅시다.

박시니어 씨의 설명을 들은 김게임 씨는 감탄했습니다. "이제 다리가 백 개여도 문제없겠어요!" 절차적 애니메이션을 제대로 이해하면 복잡한 생물체도 효율적으로 움직일 수 있습니다.

여러분도 오늘 배운 내용을 실제 게임에 적용해 보세요.

실전 팁

💡 - IK는 2조인트(팔, 다리)가 가장 쉽고, 3조인트부터는 복잡해집니다

  • 걸음 사이클은 사인/코사인 함수로 간단히 구현 가능합니다
  • 레이캐스트로 지면 높이를 감지해서 발 위치를 결정하세요

이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!

#Flutter#ProceduralGeneration#PerlinNoise#GameDevelopment#Flame#Flutter,Flame,Game

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.

함께 보면 좋은 카드 뉴스

Flame 게임 물리 엔진 완벽 가이드

Flutter 게임 엔진 Flame에서 Forge2D를 활용한 물리 시뮬레이션을 초급자도 이해할 수 있도록 실무 스토리로 풀어낸 완벽 가이드입니다. 중력, 충돌, 조인트 등 게임 물리의 핵심을 배워보세요.

Flame 게임 엔진 스프라이트와 이미지 완벽 가이드

Flutter 게임 엔진 Flame에서 이미지와 스프라이트를 다루는 방법을 실무 중심으로 배웁니다. 이미지 로딩부터 스프라이트 컴포넌트 생성, 크기 조정까지 게임 개발의 기초를 탄탄하게 익힙니다.

Flame 고급 물리 시뮬레이션 완벽 가이드

Flutter Flame 엔진의 고급 물리 시뮬레이션 기법을 다룹니다. 유체, 소프트 바디, 래그돌부터 파괴 효과와 물리 최적화까지, 실전에서 바로 사용할 수 있는 고급 물리 엔진 활용법을 초급자도 이해할 수 있도록 쉽게 풀어냈습니다.

타일맵 시스템 완벽 가이드

Flame 게임 엔진에서 타일맵을 활용하여 효율적으로 게임 월드를 구성하는 방법을 배웁니다. Tiled 에디터부터 충돌 처리, 동적 타일 변경까지 실무에서 바로 활용할 수 있는 내용을 다룹니다.

게임 루프와 컴포넌트 완벽 가이드

Flame 게임 엔진의 핵심인 게임 루프와 컴포넌트 시스템을 이해하고, 실전에서 활용하는 방법을 배웁니다. 초급 개발자도 쉽게 따라할 수 있도록 실무 예제와 함께 설명합니다.