테마 선택

📚 지식 노트

AI·하드웨어·주변기기·게임·컴퓨터 시스템·이론 컴퓨터 과학·수학·개발과 운영·보안·응용 분야에 대해 알아 두면 좋은 내용을 정리한 노트다. 위에서 분야를 고르고 아래에서 노트를 고른다.

엔진 구조·최적화

수천 개를 한 번에 돌리는 데이터 배치와 갱신 줄이기, 그리고 게임 기술의 뿌리다.

목차 (9)
  1. 게임 루프와 고정 시간 간격
  2. 오브젝트 풀: 만들고 부수지 말고 돌려 쓴다
  3. 공간 격자와 스태거 업데이트
  4. ECS와 데이터 지향 설계
  5. 팩토리오 벨트: 자리 대신 간격을 적는다
  6. 팩토리오의 다음 최적화: 유체 구간과 병렬화의 조건
  7. 리스트 가상화: 보이는 만큼만 만든다
  8. 게임 기술의 뿌리: 로봇과 시뮬레이터
  9. 엔진 고르기: Unity, Unreal, Godot와 라이선스

게임 루프와 고정 시간 간격

게임은 결국 하나의 반복문이다. 입력을 읽고, 세계를 한 걸음 진행시키고(갱신, update), 화면을 그리고, 다시 처음으로 돌아간다. 문제는 한 바퀴의 길이가 일정하지 않다는 것이다. 폭발이 겹치면 그리기가 늘고, 백그라운드 프로그램이 CPU를 잠깐 빼앗고, 모니터마다 주사율이 다르다. 그래서 “한 바퀴에 얼마만큼의 시간을 진행시키나”를 정해야 한다.

  • 가변 간격: 지난 바퀴가 걸린 시간 dt만큼 진행한다. 속도가 일정한 이동은 이걸로 맞지만, 가속·점프·마찰처럼 dt마다 조금씩 근사하는 계산은 dt가 바뀌면 결과도 바뀐다. 같은 점프가 144 Hz에서와 30 Hz에서 높이가 다르고, 프레임이 크게 튀면 총알이 벽을 건너뛴다. 같은 입력을 넣어도 같은 결과가 안 나오니 리플레이와 락스텝 멀티플레이도 어렵다.
  • 고정 간격: 시뮬레이션은 언제나 정해진 간격(예: 20 ms)으로만 진행한다. 지난 바퀴에 흐른 시간을 누적기(accumulator)에 더하고, 누적기에 간격 하나가 차 있는 동안 갱신을 한 번 돌리고 간격만큼 뺀다. 짧은 프레임에서는 갱신을 건너뛰고, 긴 프레임에서는 두세 번 돌린다.
  • 보간해서 그리기: 갱신을 건너뛴 프레임에 같은 그림을 다시 그리면 움직임이 툭툭 끊긴다. 누적기에 남은 시간 ÷ 간격(α)을 구해 직전 상태와 지금 상태 사이를 α만큼 섞어 그린다. 그 대가로 화면은 시뮬레이션보다 최대 한 간격 늦다.
고정 시간 간격: 위 줄의 화면 프레임은 14에서 33 ms까지 길이가 제각각이고, 아래 줄의 물리 스텝은 20 ms로 일정하다. 짧은 프레임에서는 스텝을 한 번도 돌리지 않고, 긴 프레임에서는 두 번 돌린다. 프레임마다 남은 시간의 비율 α로 보간해 그린다화면 프레임: 길이가 제각각14 ms스텝 0번α 0.7024 ms스텝 1번α 0.909 ms스텝 1번α 0.3533 ms스텝 2번α 0.0017 ms스텝 0번α 0.8521 ms스텝 1번α 0.90물리 스텝: 언제나 20 ms12345α = 쌓였지만 아직 스텝으로 못 쓴 시간 ÷ 20 ms. 그릴 때 직전 두 스텝 사이를 α만큼 섞는다
위는 길이가 제각각인 화면 프레임, 아래는 20 ms로 일정한 물리 스텝이다. 9 ms 프레임은 갱신을 건너뛰고 33 ms 프레임은 두 번 돌린다. α는 누적기를 실제로 굴려 구했다.

고정 간격에는 함정이 하나 있다. 갱신 한 번이 간격보다 오래 걸리면, 밀린 시간을 따라잡으려고 더 많이 돌리고, 그래서 더 밀리는 악순환(spiral of death)에 빠진다. 그래서 한 프레임에 누적기에 넣을 시간에 상한을 둔다. 상한을 넘은 만큼은 버려서, 게임이 잠깐 느려질지언정 멈추지는 않게 한다. Unity가 이 구조로, 물리용 FixedUpdate는 기본 0.02초 간격으로 돌고 한 프레임에 따라잡을 시간은 기본 약 0.33초까지로 묶여 있다.

  • 무엇을 고정할까: 물리, 게임 규칙, 네트워크로 맞춰야 하는 상태는 고정 간격으로, 카메라 흔들림·UI 애니메이션처럼 보이기만 하는 것은 그리는 프레임의 dt로 돌려도 된다. 입력은 놓치지 않게 매 프레임 모아 두고 다음 고정 갱신이 꺼내 쓴다.
  • 더블 버퍼(double buffer): 개체들이 서로의 상태를 읽으며 갱신하면 누가 먼저 갱신됐는지에 따라 결과가 달라진다. 읽는 판(지난 상태)과 쓰는 판(다음 상태)을 따로 두고 갱신이 다 끝나면 둘을 바꾸면 순서와 상관없이 같은 결과가 나온다. 보간에 쓰는 직전 상태도 이 판에서 나온다. 화면을 그리는 프레임 버퍼를 둘 두는 것도 같은 생각이다.

반복문 모양마다 무엇을 얻고 잃는지 차례로 비교한 글로는 Game Programming Patterns의 Game Loop 장이 읽기 좋다. 프레임 시간과 끊김을 재는 방법은 게임 성능 노트에 있다.

오브젝트 풀: 만들고 부수지 말고 돌려 쓴다

기관총은 초당 총알 수십 발을, 폭발은 파편 수백 개를 만든다. 매번 새 객체를 할당하고 몇 초 뒤 해제하면 할당 비용이 쌓이고, C#처럼 가비지 컬렉터(GC)가 있는 언어에서는 쌓인 쓰레기를 치우는 순간 프레임이 튄다. 할당·해제가 잦으면 메모리가 잘게 쪼개져(단편화) 나중에 큰 덩어리를 못 얻기도 한다. 오브젝트 풀(object pool)은 필요한 만큼을 미리 만들어 두고, “부수기”를 “반납”으로, “만들기”를 “빌리기”로 바꾼다.

  • 빈자리 목록(free list): 반납된 객체는 어차피 쓰지 않으니 그 안에 “다음 빈 객체의 번호”를 적어 둔다. 빌리기는 목록 맨 앞을 꺼내고, 반납은 맨 앞에 끼운다. 둘 다 O(1)이고 따로 목록용 메모리도 들지 않는다.
  • 활성은 앞쪽에: 풀을 배열 하나로 두고 쓰는 것을 앞쪽에 모으면, 반납할 때 맨 뒤 활성 객체와 자리를 바꾸는 것만으로 활성 구간이 이어진다. 갱신 반복문이 활성 구간만 훑으니 위의 데이터 지향 설계와도 맞는다.
  • 깨끗이 초기화: 돌려 쓰는 객체에는 지난 생의 속도, 타이머, 붙여 둔 이벤트가 남아 있다. 빌릴 때 모든 필드를 처음 값으로 되돌리는 함수 하나를 반드시 지나게 한다.
  • 묵은 참조: 반납된 총알을 누군가 아직 쥐고 있다가 건드리면, 그 사이 다른 곳에 빌려 간 새 총알이 엉뚱하게 바뀐다. 객체를 번호와 세대(generation)의 짝으로 가리키고 반납할 때 세대를 올리면, 옛 참조는 세대가 안 맞아 바로 들킨다.
  • 꽉 찼을 때: 풀을 늘릴지, 새 요청을 무시할지, 가장 오래된 것을 빼앗아 쓸지 정해 둔다. 벽에 남는 탄흔·핏자국 같은 데칼은 가장 오래된 것을 지워 새로 쓰면 아무도 눈치채지 못한다. 크기는 짐작 말고 실제 전투에서 잰 최댓값으로 정한다.

풀은 공짜가 아니다. 쓰지 않는 객체가 메모리를 계속 차지하고, 로딩 때 미리 만드는 시간이 든다. 할당이 문제라는 것을 프로파일러로 확인한 곳에만 쓴다. Unity는 2021 버전부터 UnityEngine.Pool에 범용 풀을 넣어 두었고, 위의 리스트 가상화와 청크 스트리밍(절차 생성 노트)도 안쪽에서 같은 생각을 쓴다.

공간 격자와 스태거 업데이트

타워디펜스나 뱀서류처럼 몬스터 천 마리가 한 화면에 흐르는 게임이 있다. 무리 규칙(게임 AI·길찾기 노트의 Boids) 자체는 이웃 몇 마리의 벡터를 더하는 싼 계산이다. 비싼 것은 “누가 내 이웃인가”를 찾는 일이다. 모두와 거리를 재면 마리 수의 제곱, N(N−1)번이라 서른 마리면 870번이지만 천 마리면 약 백만 번이다. 최적화는 빠르게 짜기가 아니라 어디가 비싼지 재는 것에서 시작한다. 재 보기 전에는 규칙 계산이 범인처럼 보이기 쉽다.

  1. 공간 격자(uniform grid, spatial hash): 세계를 칸으로 나누고 매 프레임 개체를 칸에 담는다. 칸 한 변을 이웃 반경과 같게 잡으면 반경 안의 이웃은 반드시 내 칸과 둘레 8칸, 모두 9칸 안에 있다. 이웃을 찾을 때 그 칸들의 후보만 거리를 잰다. 개체가 고르게 퍼져 있으면 칸당 수가 거의 일정해 전체 비용이 마리 수에 비례하게 된다. 칸이 무한한 세계라면 칸 좌표를 해시 키로 써서 빈 칸에는 메모리를 쓰지 않는다.
  2. 나누기: 화면은 초당 60번 그려도 조향 판단은 초당 10번쯤이면 품질이 거의 같다. 이때 전원이 같은 프레임에 생각하면 6프레임마다 그 프레임만 터진다. 평균은 같아도 프레임 드랍은 평균이 아니라 최댓값에서 난다. 개체마다 생각하는 박자를 어긋내면(스태거, staggered update) 매 프레임 6분의 1씩만 생각해 부하가 평평해진다.
  3. 묵은 목록: 이웃 목록도 자기 박자에만 새로 찾게 할 수 있다. 같은 방향으로 순항하는 무리는 이웃이 천천히 바뀌어 몇 프레임 묵은 목록으로도 충분하다. 급선회나 정면 교차처럼 이웃이 빨리 바뀌는 장면에서는 다시 재고 써야 한다.
이웃 반경과 같은 크기의 칸으로 나누면 반경 안 이웃은 반드시 주변 9칸 안에 있어 그 칸들만 검사하면 된다. 1000마리가 6프레임에 한 번 생각할 때 전원이 같은 프레임에 생각하면 그 프레임에만 1000이 몰리고, 박자를 어긋내면 프레임마다 167 안팎으로 고르다이웃 찾기: 주변 9칸만전체 70 중 후보 22만 거리를 잰다그중 반경 안 이웃은 8프레임마다 생각하는 수전원이 같은 프레임에최대 1000박자를 어긋내면(스태거)최대 1671000마리 · 6프레임에 한 번 · 평균은 둘 다 같다
왼쪽: 칸 한 변이 이웃 반경과 같으니, 반경(점선 원) 안의 이웃은 칠한 9칸 안에만 있다. 오른쪽: 평균 부하는 같아도 전원이 같은 프레임에 생각하면 그 프레임만 치솟는다.

격자에도 비용이 있다. 매 프레임 칸에 다시 담는 일과 칸을 훑는 반복이 들기 때문에 서른 마리쯤이면 그냥 다 비교하는 편이 낫고, 격자가 값을 하는 것은 수백 마리부터다. 개체가 한곳에 빽빽이 몰리면 칸 하나에 후보가 쏠려 이득이 준다. 그래서 무리 규칙의 분리가 밀도를 묶어 주는 것이 격자 성능에도 도움이 된다. 프레임 예산과 최댓값이 왜 중요한지는 게임 성능 노트에 있다.

ECS와 데이터 지향 설계

개체 오천 개를 만들라고 하면 대개 개체 하나에 객체 하나(언리얼의 액터, Unity의 게임오브젝트)를 만든다. 객체마다 위치를 품고, 매 틱 스스로 판단하고, 주변이 궁금할 때 월드에 질문한다. 사람이 생각하는 방식이고 엔진 API도 그렇게 생겼다. 그런데 하드웨어는 반대로 생겼다.

CPU는 메모리를 한 바이트씩이 아니라 캐시 라인(요즘 x86에서 64바이트) 단위로 가져온다. 메모리에서 값을 기다리는 시간은 계산보다 훨씬 길다(RAM 노트, CPU 노트). 개체 상태가 힙 여기저기의 객체에 흩어져 있으면, 위치 하나를 읽을 때마다 쓸모없는 주변 바이트와 함께 새 캐시 라인을 끌어오고, 다음 개체는 또 다른 곳에 있다. 같은 필드를 배열에 나란히 두면 라인 하나에 다음에 쓸 값이 가득 실려 오고, 다음 라인도 CPU가 미리 읽어 둔다.

64바이트 캐시 라인 두 개를 읽을 때, 개체마다 필드 여덟 개를 묶은 배치에서는 위치 x가 4개만 딸려 오고, x만 모은 배열에서는 32개가 모두 쓸 값이다개체마다 묶음 (AoS): x, y, vx, vy, … 가 개체 단위로 이어진다캐시 라인 1 · 64바이트캐시 라인 2 · 64바이트쓸 값 4/32필드마다 배열 (SoA): x 배열, y 배열, … 이 따로 이어진다캐시 라인 1 · 64바이트캐시 라인 2 · 64바이트쓸 값 32/32칠한 칸: 위치 x(float 4바이트) · 위 줄의 굵은 세로선: 개체 경계(필드 8개 = 32바이트)x만 갱신하는 반복문 기준. CPU는 메모리를 캐시 라인 단위로 통째로 가져온다.
위치 x만 갱신하는 반복문에서 캐시 라인 두 개로 건지는 쓸 값. 개체마다 필드를 묶어 두면 4개, x만 모은 배열이면 32개다.
배치모양잘 맞는 일
흩어진 객체개체마다 힙에 따로, 포인터로 연결개체 하나를 깊이 다룰 때. 디버거로 하나를 집어 보기 쉽다
AoS (array of structs)개체 구조체를 배열로 연속 배치개체 하나의 필드를 한꺼번에 쓸 때
SoA (struct of arrays)필드마다 배열. PosX[], PosY[], VelX[] …수천 개의 같은 필드를 한 번에 훑을 때. SIMD로 묶기도 쉽다

ECS(entity component system)는 이 생각을 구조로 만든 것이다. 엔티티는 번호일 뿐이고, 컴포넌트는 같은 종류끼리 배열에 모인 데이터이며, 시스템은 필요한 컴포넌트를 가진 엔티티들의 배열을 차례로 훑는 함수다. Unity의 DOTS(Entities)와 언리얼 엔진의 Mass가 이런 데이터 지향 프레임워크다. 파티클 시스템이 입자 수만 개를 객체로 만들지 않는 것도 같은 이유다.

  • 알고리즘이 먼저: 공간 격자와 스태거 같은 알고리즘이 비용의 대부분을 깎는다. 데이터 배치는 그 뒤에 남는, 개체 하나당 붙는 고정 비용을 깎는다. 개체가 많을수록 이 단가가 프레임 예산을 차지한다.
  • 병목이 옮겨 간다: 계산을 배열로 눕히고 나면 병목은 그리기로 넘어간다. 개체마다 메시 컴포넌트를 두지 않고 인스턴싱으로 한 번에 그려야 같은 이득이 렌더 쪽에도 생긴다.
  • 옮기는 비용: 실제 게임의 개체에는 스킬, 버프, 인벤토리, 저장, 네트워크 복제가 매달려 있고 객체는 그 모든 시스템이 개체를 부르는 주소다. 이미 객체로 짠 게임을 통째로 옮기는 비용은 크다. 군중, 투사체, 파티클처럼 수천 개가 될 것이 확실한 시스템만 골라 눕히는 것이 현실적이다.

팩토리오 벨트: 자리 대신 간격을 적는다

공장 게임 팩토리오(Factorio)의 벨트 위로는 아이템 수만 개가 흐른다. 아이템마다 위치를 저장하면 갱신할 때마다 수만 개의 위치에 이동 거리를 더하고, 바로 앞 아이템과 너무 붙지 않았는지 검사해야 한다. 실제로 예전 버전은 움직일 수 있는 벨트 위의 아이템을 매 틱 하나하나 옮겼다. 개발사 Wube Software는 개발 블로그 글 Friday Facts #176 - Belts optimization for 0.15에서 이것을 자리 대신 간격을 적는 방식으로 바꾼 과정을 공개했다.

핵심 관찰은 벨트 위 아이템의 배치가 좀처럼 바뀌지 않는다는 것이다. 벨트가 막히지 않으면 아이템들은 서로의 상대 자리를 그대로 둔 채 함께 흐른다. 그래서 이어진 벨트 여러 타일을 한 줄 (트랜스포트 라인)로 묶고, 줄마다 아이템의 절대 위치 대신 다음을 적는다.

  • 앞 빈칸: 맨 앞 아이템에서 출구까지의 거리.
  • 사이 간격: 이웃한 아이템 사이의 거리들.
  • 뒤 빈칸: 입구에서 맨 뒤 아이템까지의 거리.
벨트 한 줄에 아이템 자리 대신 출구까지의 앞 빈칸, 아이템 사이 간격, 입구까지의 뒤 빈칸을 적는다. 벨트가 흐르면 앞 빈칸이 줄고 뒤 빈칸이 늘 뿐, 사이 간격 숫자는 그대로다갱신 전입구출구60504070455580한 번 갱신한 뒤(벨트가 20 흐름)입구출구405040704555100진한 숫자(앞 빈칸·뒤 빈칸)만 바뀌고, 흐린 사이 간격은 아이템과 함께 흘러갈 뿐 값이 그대로다.아이템 자리는 그릴 때 출구에서부터 간격을 더해 구한다.
벨트가 흐르면 앞 빈칸이 줄고 뒤 빈칸이 늘 뿐, 사이 간격은 값이 그대로다. 한 갱신에 고칠 숫자가 줄마다 둘이다.

벨트가 흐를 때 갱신은 앞 빈칸에서 이동 거리를 빼고 뒤 빈칸에 더하는 것뿐이다. 아이템 하나가 끝에서 빠져나가면 그 뒤 간격이 앞 빈칸으로 합쳐지고, 새 아이템이 들어오면 목록 끝에 간격이 하나 늘어난다. 줄 하나의 갱신이 아이템 수에 비례하던 O(n)에서 일정한 O(1)이 되고, 공장 전체로는 아이템 수가 아니라 줄 수에 비례하게 된다. 아이템을 줄에서 다음 줄로 넘기는 것이 새는 비용이라 줄은 되도록 길게 묶는다.

  • 막혔을 때: 출구가 막혀 앞 빈칸이 0이 되면, 붙어 선 아이템들 바로 뒤의 간격 하나를 줄인다. 한 번 붙은 아이템은 계속 붙어 있으니 그 간격의 자리를 기억해 두고 따라가면 매번 줄을 훑지 않아도 된다.
  • 대가: 아이템 하나의 자리를 알려면 출구부터 간격을 더해 가야 한다. 그리기는 화면에 보이는 줄만 하면 되고, 아이템을 집는 팔(인서터)은 그 줄에서 무엇이 움직였는지만 따라간다.

같은 글에서 개발사는 아이템 이동만 놓고 보면 50~100배, 벨트 말고도 할 일이 많은 실제 공장 전체로는 5~10배쯤 빨라질 것으로 내다봤다. 같은 생각은 다른 곳에도 통한다. 모두가 함께 움직이는 데이터라면 각자의 절대값 대신 차이를 적어 두고, 바뀌는 경계만 고친다.

팩토리오의 다음 최적화: 유체 구간과 병렬화의 조건

벨트 다음에도 팩토리오의 개발 블로그는 같은 질문을 계속 던진다. 꼭 하나하나 계산해야 하나, 묶어서 한 번에 셀 수 있나, 그리고 여러 코어에 나눠도 결과가 같은가.

유체: 파이프 대신 구간

예전 유체는 파이프 칸마다 양을 두고 이웃 칸으로 흘려 보내는 시뮬레이션이었다. 그 결과 거리가 멀수록 처리량이 불규칙하게 줄어 플레이어가 위키의 표를 찾아봐야 했고, 갈림길에서는 파이프를 놓은 순서에 따라 한쪽 기계만 물을 많이 받았다. 2.0을 앞두고 낸 Friday Facts #416 - Fluids 2.0은 흐름 계산을 버렸다. 이어진 파이프·지하 파이프·저장 탱크는 하나의 유체 구간(fluid segment)으로 합쳐지고, 구간은 통 하나처럼 다뤄진다.

  • 넣기와 빼기: 기계는 구간에 유체를 제한 없이 밀어 넣고, 빼 갈 수 있는 속도는 구간이 찬 비율에 비례한다. 반쯤 찼으면 최대치의 절반이다. 갈림길이라는 개념이 없어지니 기계들이 훨씬 고르게 나눠 받는다.
  • 버린 사실성: 구간 한쪽에 넣은 유체는 곧바로 구간 어디서든 쓸 수 있다. 현실의 물과는 다르지만, 개발사는 이것을 재미와 단순함을 위해 고른 대가라고 밝혔다. 벨트가 아이템을 묶어 센 것처럼 유체도 칸이 아니라 덩어리로 센다.

병렬화: 결과가 같을 때만

팩토리오는 멀티플레이를 모든 컴퓨터가 같은 입력으로 같은 시뮬레이션을 돌리는 락스텝으로 맞추므로(멀티플레이·안티치트 노트), 여러 스레드에 나눴을 때 갱신 순서가 바뀌어 결과가 달라지면 바로 동기화가 깨진다. 그래서 병렬화의 조건은 빨라지느냐보다 먼저 순서와 상관없이 같은 답이 나오느냐다.

  • 벨트: Friday Facts #364 - 1.1 stable은 서로 절대 만날 수 없는 벨트 줄끼리 그룹으로 묶어 그룹마다 다른 스레드에서 돌렸다. 잠든 인서터를 깨우는 일은 두 그룹이 함께 건드릴 수 있어서, 스레드는 요청만 쌓아 두고 다 끝난 뒤 메인 스레드가 정해진 순서로 깨운다. 벨트 위주 대형 공장에서 벨트 갱신이 4 ms에서 1.6 ms로, 전체 갱신이 20~40% 줄었다.
  • 회로: Friday Facts #421 - Optimizations 2.0에서는 남의 결과에 기대지 않고 읽기만 하는 벨트 판독기와 연산·판정 조합기를 병렬로 돌렸다. 조합기 7만 7천 개짜리 합성 시험에서 131초가 약 8.2초가 됐다.
  • 실패한 병렬화: 같은 글에서 전력망 갱신도 나눠 봤지만, 이 계산은 CPU가 아니라 메모리에서 값을 가져오는 속도에 묶여 있었다. 스레드들이 메모리를 두고 다투기만 해서 전력망 갱신은 0.5 ms에서 0.39 ms로 조금 줄었는데 CPU 사용량은 0.5%에서 15%로 뛰었고, 게임 전체는 빨라지지 않아 넣지 않았다.
  • 생각은 드물게, 그리기는 매번: 같은 글의 일꾼 로봇은 목적지와 도착 시각만 적어 두고 실제 상태는 가끔(움직이는 로봇은 최대 20틱마다) 갱신하며, 그 사이 위치는 그릴 때 보간한다. 로봇이 많은 저장 파일에서 보통 10~25% 빨라졌다. 위의 데드 레커닝과 같은 모양이다.

세 이야기를 겹쳐 보면 순서가 보인다. 먼저 계산할 개수를 줄이고(벨트 간격, 유체 구간), 그다음 드물게 계산하고(로봇), 마지막으로 서로 독립인 것만 골라 나눈다. 메모리에 묶인 일은 코어를 늘려도 빨라지지 않는다는 것도 재 보고서야 알 수 있었다.

리스트 가상화: 보이는 만큼만 만든다

아이템 천 개짜리 인벤토리를 스크롤하면 천 개가 늘어선 긴 목록을 작은 창으로 내려다보는 것 같다. 그렇다고 칸 위젯을 천 개 만들면 만들고 배치하고 그리는 비용이 아이템 수만큼 든다. 리스트 가상화(list virtualization)는 화면에 걸치는 만큼의 칸만 만들고, 스크롤하면 그 칸들을 돌려 쓰며 내용만 바꿔 끼운다.

아이템 1000개짜리 목록에서 화면 창에 걸치는 줄은 일곱 줄뿐이라 칸도 일곱 개만 만든다. 칸 번호는 고정이고, 위로 빠져나간 칸이 맨 아래로 옮겨 다음 아이템을 맡는다데이터: 아이템 1000개아이템 0아이템 1아이템 2아이템 3아이템 4아이템 5아이템 6아이템 7아이템 8아이템 9아이템 10⋮아이템 999화면 창실제로 만든 칸: 7개칸 5아이템 4칸 6아이템 5칸 7아이템 6칸 1아이템 7칸 2아이템 8칸 3아이템 9칸 4아이템 10한 줄 더 내리면 칸 5이 맨 아래로 가서아이템 11의 그림으로 바뀐다칸 번호는 그대로, 맡은 아이템만 바뀐다보이는 것만 칸에
데이터는 천 개, 실제로 만든 칸은 창에 걸치는 일곱 개다. 칸 번호는 그대로이고, 위로 빠진 칸이 맨 아래로 옮겨 다음 아이템을 맡는다.
  • 데이터와 위젯을 나눈다: 목록이 다루는 데이터(아이템)와 화면의 칸(엔트리 위젯)을 따로 둔다. 언리얼 엔진 UMG의 ListView와 Unity UI Toolkit의 ListView가 이 방식으로, 화면에서 빠진 위젯을 풀에 돌려놨다가 다른 아이템에 다시 묶는다.
  • 걸치는 한 줄을 더: 부드럽게 스크롤하면 맨 위와 맨 아래 줄이 반쯤 걸치니 보이는 줄 수보다 한 줄 더 만든다. 칸 줄 전체를 밀어 올리다가 민 거리가 한 줄 높이에 닿으면 맨 위 줄을 맨 아래로 옮기고 민 거리를 0부터 다시 센다.
  • 같은 크기가 쉽다: 칸 높이가 모두 같으면 스크롤 위치 ÷ 줄 높이로 지금 보이는 첫 아이템을 바로 계산한다. 높이가 제각각이면 화면에 들어오는 위젯을 만들어 크기를 재 가며 위치를 정해야 해 더 비싸다. 격자로 늘어놓는 타일 뷰가 모든 타일을 같은 크기로 두는 까닭이다.
  • 칸은 ID만 받는다: 이름·아이콘·등급처럼 종류마다 같은 정보는 아이템 표에 한 번만 두고, 가진 아이템은 위치·ID·개수만 적는다. 칸은 ID로 표 한 줄을 읽어 그린다. 저장할 때도 빈자리는 적지 않고 아이템마다 이 세 값만 남긴다.

웹의 긴 목록, 채팅 기록, 로그 뷰어도 같은 방법을 쓴다. 비용이 데이터 크기가 아니라 화면 크기에 비례하게 만드는 것이 요점이다.

게임 기술의 뿌리: 로봇과 시뮬레이터

게임에서 당연하게 쓰는 기법 상당수는 게임보다 먼저 로봇 연구와 훈련용 시뮬레이터에서 나왔다. 길과 행동과 충돌을 계산하려던 로봇 쪽, 화면과 통신을 감당하려던 시뮬레이터 쪽으로 나눠 보면 이렇다.

기법처음 나온 곳게임에서의 쓰임
A* (1968)스탠퍼드 연구소(SRI)의 이동 로봇 Shakey 연구길찾기. 내비메시 라이브러리 Recast/Detour도 같은 점수(온 비용 + 직선 거리)로 찾는다
STRIPS (1971)같은 연구소, 로봇의 행동 계획F.E.A.R.(2005)의 목표 지향 행동 계획(GOAP)
GJK (1988)로봇 팔의 경로 계획을 위한 볼록 물체 사이 거리 계산물리 엔진의 정밀 충돌 판정(Box2D, Jolt 등)
분리 평면 · BSP 트리 (1969, 1980)조종사 훈련용 시각 시뮬레이터둠(1993)의 렌더링, Source 엔진의 맵 형식
LOD (1976)계층형 모델 논문, 그 전의 시뮬레이터 장비먼 물체를 단순한 모델로 바꿔 그리기
데드 레커닝 (1980년대)미군의 네트워크 전차 시뮬레이터 SIMNET온라인 게임에서 남의 캐릭터 위치 이어 그리기
  • A*: 장애물 모서리 근처에 거쳐 갈 점을 놓고 서로 곧게 닿는 점끼리 이은 그래프에서, 지금까지 온 거리에 목표까지의 직선 거리를 더한 점수가 작은 점부터 꺼낸다. 남은 거리를 실제보다 크게 잡지만 않으면 가장 짧은 길이 나온다(게임 AI·길찾기 노트).
  • STRIPS: 세계를 참인 사실의 목록으로 두고, 행동마다 전제 조건·없어지는 사실·생기는 사실을 적는다. 목표에서 거꾸로 필요한 행동을 찾아 이어 계획을 만든다. F.E.A.R.의 AI는 적에게 목표와 할 수 있는 행동만 주고 순서는 게임 중에 스스로 짜게 했으며, 행동의 순서를 찾는 데에도 A*를 썼다.
  • GJK: 한 모양의 모든 점에서 다른 모양의 모든 점을 뺀 모양(민코프스키 차)을 생각하면, 두 물체 사이 거리는 원점에서 그 모양까지의 거리와 같고 겹치면 원점이 안에 든다. 모양 전체를 만들지 않고 점 몇 개짜리 작은 도형(심플렉스)을 원점 쪽으로 바꿔 가며 몇 번 만에 답을 낸다. 겹친 깊이는 EPA가 맡는다.
  • BSP 트리: 겹치지 않는 두 볼록 물체 사이에는 평면을 둘 수 있고, 관찰자가 평면의 어느 쪽에 있는지만 알면 누가 누구를 가리는지 정해진다. 공간을 평면으로 거듭 나눈 트리를 미리 만들어 두면, 그릴 때 트리를 따라 내려가며 관찰자 반대편부터 칠해 앞의 것이 뒤의 것을 덮게 할 수 있다. 둠은 가까운 쪽부터 내려가며 이미 칠한 화면 열을 건너뛰었다.
  • LOD(level of detail): 화면에서 몇 픽셀만 덮는 사람은 색칠한 상자 하나로 충분하고, 가까워지면 팔·다리·머리로 나누고, 더 가까우면 손가락까지 그린다. 지금 엔진도 화면에서 차지하는 크기에 따라 메시 단계를 바꾼다.
  • 데드 레커닝(dead reckoning): 받는 쪽은 남의 차량을 마지막에 받은 위치와 속도로 계속 굴려 그린다. 보내는 쪽도 남들이 돌리고 있을 그 예측을 같이 돌려 보고, 실제와 차이가 임계값을 넘을 때만 새 상태를 보낸다. 통신량을 줄이려던 것이 지연을 가리는 효과까지 냈다.
실제 궤적과, 마지막에 받은 위치와 속도로 곧게 이어 그린 예측 궤적. 예측이 실제에서 임계값보다 멀어질 때만 새 상태를 보내므로, 고정 주기로 보낼 때보다 패킷이 훨씬 적다이어 그리기와 다시 보내기10초 · 임계값 1 m실제 궤적받는 쪽이 이어 그린 궤적새 상태를 보낸 곳초당 15번 고정으로 보내면 151개, 차이가 1 m를 넘을 때만 보내면 13개(값은 예시 궤적)
받는 쪽이 마지막 위치와 속도로 곧게 이어 그린 궤적은, 실제와 벌어질 때마다 새 상태로 바로잡힌다. 고정 주기로 보낼 때보다 패킷이 훨씬 적다(예시 궤적).

지금 게임은 이어 그리기를 보조로 쓴다. Source 엔진은 남의 캐릭터를 평소에는 조금 과거의 두 스냅샷 사이를 채워 그리고(보간), 패킷이 연달아 빠져 채울 것이 없을 때만 짧은 시간(기본 0.25초까지) 직선으로 이어 그린다. 그보다 길면 오차가 너무 커진다. 보간과 지연 보상은 멀티플레이·안티치트 노트와 네트워크 노트에 이어진다.

엔진 고르기: Unity, Unreal, Godot와 라이선스

이 노트의 구조(게임 루프, 풀, ECS)는 어느 엔진에나 있다. 엔진을 고를 때 실제로 갈리는 것은 쓰는 언어, 팀이 익숙한 도구, 그리고 돈을 내는 방식이다. 셋 모두 무료로 시작할 수 있지만 언제부터 얼마를 내는지가 다르다.

엔진주로 쓰는 언어내는 돈조건
UnityC#좌석(사람)당 연간 구독연 매출과 투자금 합이 20만 달러 이하면 무료인 Personal을 쓸 수 있다. 넘으면 Pro가 필요하고 2025년 1월부터 좌석당 연 2,200달러다
Unreal EngineC++, 블루프린트(노드 그래프)매출에 따른 로열티제품별로 총매출 첫 100만 달러까지는 없고, 그 위로 5%다. 2020년 5월에 이 면제 구간을 넣었다
GodotGDScript, C#없다MIT 라이선스의 오픈 소스다. 게임 크레딧이나 문서에 Godot의 저작권 고지와 라이선스 문구만 넣으면 된다. 만든 게임의 권리는 만든 사람에게 있다

조건은 바뀐다. Unity는 따로 매기려던 런타임 요금(Runtime Fee)을 2024년 9월 취소하면서 위의 구독 조건을 함께 발표했다. Unreal의 로열티 면제 구간은 2020년 5월 발표로 그해 1월 매출부터 소급됐다. Godot의 조건은 라이선스 페이지에 있다. 표의 숫자는 이 글들을 확인한 시점 기준이라, 프로젝트를 시작할 때와 출시 전에 엔진 회사의 최신 약관을 다시 읽는다.