Kirino 的游戏宝藏
← 022호로 돌아가기
게임 관찰

종류 단위로 지불한다, Naio Games가 순수 GDScript Godot 4에서 수백 적과 탄을 어떻게 버텼나

지난 몇 년 서바이벌라이크·불릿헤븐·타워디펜스가 공유해 온 제작 기본값이 적마다·탄마다·픽업마다 스크립트와 라이트와 콜라이더를 달아 두는 일이라면, 2026년 10월 15일 인디 스튜디오 Naio Games가 공개한 약 18분 분량 장문은 그 청구서를 동료 앞에 펼쳐 둔 것과 같다.

저장한 글 ↗

지난 몇 년 서바이벌라이크·불릿헤븐·타워디펜스가 공유해 온 제작 기본값이 적마다·탄마다·픽업마다 스크립트와 라이트와 콜라이더를 달아 두는 일이라면, 2026년 10월 15일 인디 스튜디오 Naio Games가 공개한 약 18분 분량 장문은 그 청구서를 동료 앞에 펼쳐 둔 것과 같다. 소재는 Godot 4로 만드는 스펠 크래프팅 로그라이트 Hexwright의 실측이다. 목표 장면은 적 약 1000, 투사체 778, 노드 약 10000에 이를 수 있다. 적이 약 120을 넘으면 프레임레이트가 48에서 32로 떨어졌다. 이유는 단순하다. 그 지점�� 지나면 매 프레임이 물리 tick을 두 번 돌려, 적 단위 일이 모두 두 배가 된다.

공개 가이드를 통독해 못 박을 수 있는 사실은 단순하다. 기법 일체는 보통 GDScript와 Godot 자체 셰이더 언어에 쓰였고, C#도 C++ 모듈도 GDExtension도 없다. 제약 중 하나는 웹 빌드를 내는 일이었고, 당시 C# 웹 내보내기는 쓸 수 없었다. 로그에서 진짜 느렸던 것은 대부분 GDScript 자체가 아니다. 엔진에 객체마다 작은 일을, 매 프레임 수백 번 부탁하는 일이었다. 숫자를 움직인 것은 그 일의 형태를 바꾼 것이다.

이 글을 관통하는 이미지는 종류 단위로 지불한다, 네 글자다. 회계 용어처럼 들리지만, 프레임 예산에 떨어뜨리면 검증 가능한 장부 고치기다.

Naio Games 성능 가이드 OG 이미지

1. 업계 기본값, 물건마다 따로 낸다

서바이벌라이크와 불릿헤븐의 형태는 거의 고정이다. 종류는 적고, 각 종류가 수백 번 반복된다. 객체 위에 늘어난 라이트 1밀리초, 콜백 한 번, 텍스처 전환 한 번은 무리 규모로 프레임 전체 재앙으로 증폭된다. 작성 기본값도 고정이다. 탄은 `_physics_process`를 갖고, 적은 `Area2D`, 픽업은 `PointLight2D`, 궤적은 `Line2D`. 엔진이 물리·렌더·시그널에 이어 준다. 쓰기 편하다. 청구는 건수 단위로 남는다.

Naio는 아픈 국소 비용을 나열한다. 픽업 라이트 880개가 단독으로 프레임당 약 5 ms를 차지한 적이 있다. 적 대 플레이어 충돌 검사는 적 비용의 약 54%였다. 탄 300이 적 240을 관통할 때 히트 켜짐은 7.9 ms, 꺼짐은 4.9 ms. 탄 궤적 300은 draw call이 316에서 28로 떨어진 뒤에야 13.7 ms에서 5.0 ms로 떨어졌다. 숫자는 자체 플레이테스트 로그와 벤치 장면에서 왔다. 기기는 달라도 방향은 같다. 비싼 것은 종종 똑똑한 AI 조각보다 먼저, 건마다 따로 내는 일 자체다.

기본값과의 분기점은 여기다. 기본값은 각 객체의 행동을 어떻게 다 쓸지를 묻는다. 그들은 같은 종류가 관리비를 한 번만 내고, 인스턴스는 자기 데이터분만 낼 수 없는지를 다시 묻는다.

가이드 페이지 전투 포스터

2. 장부 고치기, 풀, 종류당 한 관리자, 가짜 라이트, 격자 수학

첫 칼은 생성과 파괴다. 전투 중 `instantiate()`는 노드 트리를 짜고 `_ready()`를 돌리며 물리와 렌더에 등록한다. `queue_free()`는 다시 뜯는다. 샷건 한 발이나 한 웨이브가 동시에 쓰러지듯 같은 프레임에 쉰 번 모이면 눈에 보이는 끊김이 된다. 처치는 로딩 때 설계대로 풀을 예열하고, 전투에서는 acquire와 release만 하는 것이다. 씬은 숨김·충돌 꺼짐의 비활성으로 두고, 트리에 붙인 뒤 `activate()`. 재사용마다 시각 설정을 기본값까지 전부 다시 쓴다. 그러지 않으면 재활용 화염구가 전생의 발광을 남긴다. 숨은 노드의 물리 보간은 무시되므로, 보이기 전에 리셋하지 않으면 탄이 지난번 죽은 자리에서 미끄러져 들어온다. 풀 miss는 로그에 남기고, 스킬을 조용히 깎지 않는다.

둘째 칼은 콜백 형태다. 탄 300이 각각 `_physics_process`를 돌리면 엔진 콜백이 300번이다. 탄은 `step(delta)`만 갖고, `bullet_manager`가 목록을 뒤에서부터 진행한다. 해제는 swap-remove로 상수 시간. 일시정지가 `time_scale = 0`이면 매니저는 즉시 return해야 한다. 그들이 밟은 함정은 탄과 적은 멈췄는데 영역 마법과 빔이 속도 0으로 계속 쓸어, 레벨업 화면에서 6~14 ms를 녹인 일이다. 그리기도 같다. 적 200의 상태 아이콘은 단일 `_draw()`를 도는 한 그리기 담당으로 묶을 수 있다.

셋째 칼은 라이트다. 진짜 `PointLight2D`는 닿는 각 스프라이트에 그리기 비용을 더하고, 스프라이트 쪽에도 수신 라이트 상한이 있다. 발광 탄 50이 군중을 날면 군중 비용이 한 번 더 곱해진다. 진 라이트는 플레이어·보스·모닥불처럼 중요한 소수 원천만 남긴다. 탄과 픽업의 바닥 빛은 가산 블렌드 부드러운 원 스프라이트로 바꾸고, 같은 텍스처·같은 머티리얼로 배치되게 한다. 야간 `CanvasModulate`로 어두워질 때는 전역 셰이더 파라미터로 색을 되돌린다. 절반 어두우면 ×2로 올려 가독성을 지킨다. 픽업 라이트 880이 단독 약 5 ms라는 숫자가 이 고치기의 영수증이다.

넷째 칼은 충돌이다. 정말 필요한 게 「원 안에 누가 있나」「선분이 무엇을 쓸었나」뿐이라면, 공간 격자와 거리 판정이 물리 엔진 짝짓기보다 싼 경우가 많다. 매 tick 적을 64 px 칸에 넣고, 조회는 덮인 칸만 본 뒤 소수 후보에 정확한 거리를 잰다. 적은 물리 엔진을 떠나도 되고, 히트 처리는 기존 `on_hit`을 그대로 쓴다. 안정 ID는 instance id를 쓰고 목록 인덱스는 쓰지 않는다. swap-remove가 인덱스를 다른 대상에 재사용하기 때문이다. 보스 반지름은 전용 함정이다. 누락을 막으려면 조회가 최대 반지름만큼 바깥으로 넓어진다. 반지름 약 45 px 보스가 들어오면 전탄 조회가 여분 칸을 쓴다. 반지름 약 32 px 초과 대형만 별도 목록에 두고, 격자 뒤에 소수만 보완한다. 탄 600·적 60·보스 1 장면에서 physics는 17.4~18.3 ms에서 15.5~15.7 ms로, 약 2 ms 줄었다. 글도 이것이 일부일 뿐이고 나머지는 히트 처리 쪽이라고 밝힌다.

네 칼은 같은 원칙을 나눈다. 풀은 전투 중 생성세를 지운다. 종류당 한 관리자는 건수 콜백세를 지운다. 가짜 라이트는 건수 조명세를 지운다. 격자 수학은 건수 물리 짝짓기세를 지운다. 플레이 쪽 히트 규칙은 최대한 남긴다. 바뀌는 것은 누가 내느냐다.

Hexwright 야전 화살비

3. 숫자는 더 내려간다, 아틀라스, MultiMesh, 변한 것만 다시 그리기, GPU 직선 이동

텍스처 배칭은 2D에서 가장 싼 칼 중 하나로, 거의 플레이 코드를 건드리지 않는다. Godot은 이웃 스프라이트의 텍스처와 머티리얼이 같을 때만 자동 배치한다. 걷기 적이 8방향×8스텝을 별도 이미지면 240마리에 draw call 241이 될 수 있다. 여백 있는 atlas로 묶으면 2가 된다. 측정에서 프레임 시간은 여전히 약 2.2 ms로, 빠른 GPU에서는 여유 쪽 숫자이지 당장 보이는 fps 도약이 아닐 수 있다. 윤곽 셰이더가 옆 프레임을 줍지 않도록 여백을 남긴다. 머티리얼도 배치를 가른다. 적 200 중 3마리가 특수 머티리얼이면 여분 3배치. 마리마다면 200배치다.

더 아래로 가면 픽업·불꽃·궤적 세그먼트처럼 다른 물체와 앞뒤 정렬이 필요 없는 동일 소물은 노드를 하나씩 가질 필요가 없다. MultiMesh를 RenderingServer에 직접 걸고, 착지·수거처럼 변할 때만 인스턴스 데이터를 쓴다. 정지 픽업의 상하 흔들림은 GPU에 맡기고 스크립트 비용은 0. 궤적도 같다. 탄마다 `Line2D`는 탄마다 draw call이다. 하나의 MultiMesh에 고리형 세그먼트 칸을 두고, 매 tick 시각 도장이 찍힌 칸을 누르며, 셰이더가 나이로 가늘게 하고 흐리게 한다. 300화살 벤치에서 프레임 전체 13.7→5.0 ms, draw call 316→28.

스킬 트리는 다른 종류의 비싼 화면이다. 육각·실·눈금·아이콘·가격·이름 대부분은 정지이고 액체 애니만 움직인다. 전체에 매 프레임 `queue_redraw()`하면 풀강 후 49.9 ms/frame, 그중 약 36 ms는 같은 그리기 명령을 다시 내는 GDScript이고, 노드 4배면 116 ms. 정지부를 tile로 잘라 각 canvas item 명령 목록을 더러워질 때만 다시 녹음한다. 팬·줌은 tile 변환만 움직인다. 얇은 live layer가 진짜 움직이는 것만 매 프레임 그린다. 오늘 87노드는 49.9→10.7 ms. 4배 노드는 116→15.2 ms. 팬은 매 프레임 전체 재그리기에서 0 재그리기로. 같은 생각을 한 단계 올리면 렌더 해상도다. `canvas_items` 스트레치는 화면 화소로 그리고, 4K는 1280×720 설계의 약 9배 화소로 전체 글로우와 셰이더가 청구된다. 설정으로 두고 `viewport` 모드로 설계 해상도 그린 뒤 확대, 성능 720과 선명 네이티브 두 선택을 준다.

GPU 변위는 과하기 쉬운 칼이다. 직선탄은 시작점·속도·발사 시각만 있으면 셰이더가 매 프레임 올바른 위치에 두고, 스크립트는 변환을 매 프레임 바꾸지 않아도 된다. 발광탄 약 800, 본체+헤일로+바닥빛으로 draw call은 약 1150에서 약 480으로. 프레임 시간은 벤치 노이즈 안이고, 소박한 화살 300에서는 거의 변하지 않는다. 같은 머티리얼·같은 스프라이트는 원래 배치되기 때문이다. 실전이 물어뜯었다. 탄 다수가 유도·선회·부메랑이라 매 tick 구부러지고 앵커도 매 tick 다시 쓰여 4~6 ms가 늘고, 일반 노드보다 나빠졌다. 그래서 직선탄만 GPU 경로를 탄다. 문미도 밝힌다. 직선탄 GPU 그리기, 픽업 MultiMesh, 30 Hz 교차 사고는 당시 아직 완전한 플레이테스트를 거치지 않았고, 나머지는 자사 기기 실측이라 독자 환경에서는 숫자가 다르다.

Hexwright 화염 화살 폭풍 실기 화면

4. 예열, 교차 사고, 플레이를 바꾸지 않는다는 단단한 경계

로딩 커튼이 아직 덮고 있을 때, 이펙트 인스턴스를 시야 안에 몇 프레임 두어 셰이더 파이프라인을 먼저 컴파일시킨다. 화면 밖은 컬링되어 영원히 컴파일되지 않는다. 풀은 설계 피크, 캐스트당 투사체×초당 캐스트×생존 초로 예열한다. 첫 화염구 끊김은 대개 머티리얼 첫 그리기 때의 컴파일이다. Windows 빌드는 기본 Direct3D 12이고, 코드도 렌더도 한가한 100~600 ms 희귀 정체는 GPU 파이프라인 컴파일인 경우가 많다. 실행 시 Vulkan으로 바꿀 수 있고, Steam 실행 옵션으로 두 번째 Play도 만들 수 있다.

사고는 이동보다 드물어도 된다. 이동은 매 tick 해야 하고, 그렇지 않으면 무리가 떨린다. 어디로 갈지 결정은 그렇지 않다. 무리의 절반이 교차 tick으로 생각하고 약 30 Hz, 지난 사고 이후 누적 시간을 넘겨 쿨다운은 실시간을 지킨다. 보스와 준비 동작 중인 적은 매 tick 생각한다. 글의 규모감에서는 사고가 적 1마리 8.7 µs 중 1.85 µs, 1000마리에서 tick당 약 0.9 ms 절약. 교차는 죽음에 견뎌야 한다. 분리가 매 tick 절반만 보고 죽음마다 전체 재조회하면, 매 tick 무언가 죽으므로 이득이 사라진다. 계산 결과를 적에 딸려 보내면 분리는 5.4→2.9 ms. 브라우저는 약 1.4~1.7배 느리고, `.web` 덮어쓰기로 웹에 더 작은 무리 상한을 준다.

글은 같은 단단한 경계로 여러 번 돌아온다. 최적화는 구현을 바꿔도 된다. 손맛을 조용히 바꾸면 안 된다. 격자 히트는 진입 시 한 번. 일시정지 때 픽업은 언다. 시각 쪽 재구축은 옛 히트 처리를 계속 호출한다. 보스 입장은 따로 잰다. 물리 콜백 안에서 물리 객체를 늘리거나 줄이지 않는다. 따라잡기 스텝 상한은 2로 두어 느린 한 프레임이 눈덩이가 되지 않게 한다. 에디터 F5 디버그는 GDScript를 2~3배 느리게 해 31~35 ms 대 11~19 ms. 정식 숫자는 플레이어가 받는 release 내보내기에서 재고, 선택으로 `-- --perflog`. 바쁜 머신은 거짓말을 하며 같은 벤치가 9.4 대 3.0 ms, 3배 차가 날 수 있어 A, B, 다시 A. 헤드리스는 draw call·라이트·배치를 못 잰다. 평균은 끊김을 감추니 최악 프레임도 반드시 적는다.

그래서 종류 단위로 지불한다, 라는 인디 팀용 실행 의미는 구체적이다. 수백 개 있는 것이 무엇인지 먼저 가른다. 라이트, 궤적, 콜백, 물리 짝짓기, 텍스처 전환, 전체 화면 재그리기. 로그에서 가장 큰 항목부터 청구 형태를 바꾼다. 벤치 장면에 스위치를 달아 히트·궤적·시각을 하나씩 끄고 한 질문만 답한다. 처음부터 C++ 모듈이 구해 줄 환상을 품지 않는다. 그들의 로그는, 느린 것은 종종 「객체마다 작은 한 걸음」이 수백 배로 불려진 일이라고 쓴다.

맺음, 독립은 장부 고치기 안에서 다시 값이 매겨진다

이 가이드를 2026년 가을 Godot 제작 현장에 되돌리면, 먼저 맞는 것은 조용한 제작 관성이다. 최적화는 더 빠른 언어라고 기본값 두고, 성능 고통은 나중에 네이티브 플러그인이 지운다고 기본값 두고, 서바이벌의 끊김은 장르세라 수를 줄이는 수밖에 없다고 기본값 둔다. Naio는 다른 길을 연다. 언어는 GDScript로 남아도 된다. 비싼 것은 일의 형태다. 건수 청구를 종류 청구로 바꾸면 프레임 예산이 따라 움직인다.

그들 자신의 체크리스트는 작업 지시서에 가깝다. 탄이나 픽업에 진 라이트가 있으면 가산 글로우와 밤 들어올림 파라미터를 시도한다. 탄마다 `Line2D` 궤적이면 MultiMesh 세그먼트. 전투 중 `instantiate`와 `queue_free`면 예열 풀. 풀화 객체마다 `_process`면 종류당 한 관리자. 적과 탄마다 `Area2D`면 격자와 원/캡슐 수학. 애니 프레임마다 한 장이면 여백 있는 atlas. 동일 소수 스프라이트 수백이면 RenderingServer 위 MultiMesh. 매 프레임 전탄을 스크립트로 움직이면 인스턴스 데이터 앵커, 변할 때만 다시 쓰기. 첫 이펙트 끊김이면 로딩 때 셰이더 예열. 크고 거의 정지인 화면의 매 프레임 `queue_redraw`면 유지 tile과 얇은 live layer. 4K가 720 아트를 그리면 렌더 품질 옵션. 적이 매 tick 의사결정하면 30 Hz 교차, 이동은 매 tick. 웹이 데스크톱 무리 상한 그대로면 `.web` 덮어쓰기. 감각적으로만 무겁다고 시작하면 먼저 실전 대국 성능 로그를 뽑아 가장 큰 수를 고친다.

실행 가능한 지시는 짧다.

  • 건수로 증폭되는 목록을 쓴다. 라이트, 콜백, 물리, draw call, 전체 화면 재그리기. 수백 배로 죽는 세 항목을 먼저 형태부터 바꾼다.
  • 재현 가능한 벤치 전투를 스위치와 함께 만든다. 히트·궤적·시각을 따로 끈다. 한 질문만 답한다. 같은 기기에서 A, B, 다시 A.
  • 충돌이나 그리기를 바꿀 때 진입 시 한 번 히트, 일시정지 동결, 보스 반지름을 회귀한다. 시각과 판정은 같은 제출에서 함께 받는다.
  • 직선탄은 GPU에 실어도 된다. 유도와 부메랑은 먼저 CPU. 풀과 셰이더는 예열하되, miss를 스킬 삭감으로 가리지 않는다. 플레이 규칙은 감상보다 테스트에 쓴다.

10월 15일 장문은 불릿헤븐을 쉽게 만들지 않는다. 어떤 반복에 값을 내고 있는지를 다시 매길 뿐이다. 장부를 고칠 마음이 있는 팀에게는 전후 숫자 달린 공개 숙제와, 이미 문미에 적힌 주의가 있다. 먼저 가장 큰 수를 잰다. 그다음 건수 지불을 그만둔다.

小光

출처와 더 읽기

Naio Games(2026-10-15)Godot 4 Performance in Pure GDScript, Many Things on Screen, No Lag

Hexwright 제품 페이지(spell-crafting roguelite, 개발 중)https://naiogames.com/hexwright

Naio Games 스튜디오 https://naiogames.com/

로그인하고 읽기 기록을 남겨 보세요.

다른 기기에서도 좋아요와 저장한 글을 확인하세요.

로그인