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

마흔여덟 바이트 병표와 육십만 엔티티의 무덤: Matt Busel은 어떻게 커스텀 인스턴싱으로 《LAWN WAR》의 병목을 Draw Call에서 리스트 구축으로 되돌렸나

Bevy 같은 ECS 엔진에서 인디의 기본 작법이 흔히 「움직이는 것은 먼저 Entity로 만들고, Mesh3d·Transform·Visibility를 걸고, 엔진 자동 배칭에 맡긴다」라면, 2026년 10월 16일 Matt Busel이 Tensorust에 공개한 그 해체는 그 기본 청구서를 테이블에 펼치고 가장 비싼…

저장한 글 ↗

Bevy 같은 ECS 엔진에서 인디의 기본 작법이 흔히 「움직이는 것은 먼저 Entity로 만들고, Mesh3d·Transform·Visibility를 걸고, 엔진 자동 배칭에 맡긴다」라면, 2026년 10월 16일 Matt Busel이 Tensorust에 공개한 그 해체는 그 기본 청구서를 테이블에 펼치고 가장 비싼 줄에 빨간 동그라미를 친 것과 같다. 게임은 《LAWN WAR》, Rust와 Bevy 0.19.1. 끝없는 식물 대 좀비 전쟁. 설계 문서는 「한쪽당 수십만 유닛」을 요구한다. 본고는 완성본을 미플레이했으며, 아래 장면과 숫자는 저자의 공개 글, DEV 동기 원고, 문중 측정 조건에 따른다.

못 박을 수 있는 사실을 맞춰 보았다. 10월 2–3일, 작품은 대체로 Matthew의 설계 메모를 따르는 코딩 에이전트가 조립했고, 소스는 GitLab에 공개. 첫 플레이 가능 빌드는 정규 ECS로, 좀비 슬롯마다 엔티티. 남긴 부하 스크린샷은 좀비 974개에 36fps, 916개에 8fps. 약 2시간 10분 뒤 파이프라인 전체가 커스텀 인스턴싱으로 바뀌고, 유닛은 더 이상 엔티티가 아니었다. 10월 16일 저녁 9:11–9:17(미 동부), RTX 4070, i7-13700KF, 1600×900, `LW_LOG=1`, wave28 stage30 다섯 번의 상한 대조에서 638,734유닛도 약 25fps, 프레임당 562,727인스턴스에 약 27MB.

본고를 관통하는 이미지는 마흔여덟 바이트의 병표다. 자료구조 이름처럼 들리지만, 제작 윤리로는 「엔진답게 보이는 사용」에 돈을 내지 않겠다는 약속이다. 먼저 유닛을 데이터로 되돌리고, 무엇이 렌더 월드로 들어갈지 정한다. 아낀 것에는 증거가 있어야 하고, 비싼 줄에는 이름이 있어야 한다. 「Draw Call이 너무 많다」는 습관적 범인 지목이 아니다.

《LAWN WAR》 60만 유닛 장면, 약 26fps

일. 업계 기본: 유닛=엔티티, 최적화=Draw Call 추측

Bevy의 일상 가르침에서는 자동 인스턴싱이면 충분하다. 많은 엔티티가 같은 메시 핸들과 같은 머티리얼을 공유하면 엔진이 배칭한다. 수천 개의 움직이는 물체라면 저자 스스로도 「자동을 쓰고 읽기를 멈춰라」고 한다. 이 기본은 깨끗하고, 그래서 게으르다. 「유닛」을 「씬 그래프의 시민」으로 번역한다. 시민은 매 프레임 변환 전파·가시성·변경 감지·렌더 월드로의 추출을 낸다. 플레이어와 개발자는 같은 어휘로 끊김을 읽는다. 「엔티티가 너무 많다」「Draw Call이 터졌다」「배칭이 안 됐다」. 그 합의가 「추측」을 용서받는 제작 문법으로 만든다.

《LAWN WAR》는 그 추측 비용을 높인다. 설계가 원하는 것은 바다다. 양군이 늘어 전장이 유닛의 바다가 될 때까지. 걷고, 치고, 타고, 죽고, 폭발에 공중으로 구른다. 첫 spawn은 10월 2일 오후 2:58(ET) 커밋: 용량 안 각 슬롯에 `Mesh3d`, 머티리얼, 땅속 `Transform`, `Visibility::Hidden`. 부하가 걸리면 수백 좀비만으로도 프레임이 한 자리로 떨어진다. 저자는 특정 Bevy 시스템에 죄명을 못 박지 않는다——그 빌드는 제대로 프로파일하지 않았다. 중요한 것은 그것이 강요한 결정이다. 2시간 10분 뒤, 유닛은 시민권을 떠났다.

분기점은 여기다. 기본은 「어떻게 더 많은 엔티티를 더 적은 Draw Call로 접을까」를 묻는다. 그들은 「수천을 넘으면 엔티티 자체가 매 프레임 세금을 걷고 있지 않은가. GPU에 도착해야 할 것은 충분히 마른 병표뿐 아닌가」로 물음을 바꿨다.

첫 ECS 빌드: 좀비 974, 36fps

이. 대본을 부수다: Vec, 마흔여덟 바이트, 한 번의 Draw, 복사 없는 인계

재작성 후 각 군대는 만용량으로 한 번 할당한 평평한 `Vec`, 원소는 `Copy` 가능한 평범한 구조체, 여유 리스트와 고수위 `hw`. 기본 상한은 군당 60만이지만 초반 웨이브는 수백 슬롯만 쓴다. 모든 루프는 `units[..hw]`만. 소규모 교전은 비어 있는 599,000에 내지 않는다. 시뮬레이션은 rayon으로 그 슬라이스를 병렬 순회. 이웃은 매 프레임 재구축하는 2m 그리드, 계수 정렬, 해시 없음, 셀마다 `Vec` 없음, 양군 동시 재구축.

보이는 유닛은 48바이트 `Inst`: 세 개의 `vec4`. 위치+요, 스케일·위상·애니 종류, 틴트와 글로우. 완전한 4×4 행렬도 본도 없다. 유닛은 수직축만 돌고, 요는 float 하나, 회전은 셰이더가 짠다. 562,727인스턴스에서 리스트는 약 27MB. 25fps면 초당 대략 680MB가 GPU로 간다.

리스트를 어떻게 빠르게 만드느냐가 뒤 차트가 진짜로 말하는 함수다. 군대를 8192 청크로 전 코어 병렬, 카메라 뒤를 건너뛰고, 거리로 LOD, 위상으로 베이크 메시 프레임을 고르고, 메시별 사유 리스트에 넣은 뒤 reduce로 붙이며 빈 쪽은 swap. 컬링은 점곱 하나, 완전한 프러스텀이 아니다. 대략 20m 밖이고 카메라 뒤인 것을 버리고, 나머지는 GPU가 클립. LOD는 40m와 120m. 좀비의 걷기·공격·죽음은 Kenney CC0 묘지 키트. GPU 스키닝 대신 클립을 별도 메시로 굽는다——걷기 8, 공격 4, 죽음 1——각 프레임은 자기 인스턴스 리스트를 가진 또 다른 메시일 뿐이다.

인계는 더 이상 27MB를 복사하지 않는다. 리스트를 `Arc`로 감싸��, 추출은 포인터만 클론한다. 엔티티는 여전히 있지만 메시당 하나, `NoFrustumCulling` 포함. 그렇지 않으면 Bevy가 원점의 메시 바운드로 배치 전체를 잘라 낸다. 639,000유닛에서 이 단계는 0.08ms. GPU 버퍼도 매 프레임 `create_buffer`하지 않는다. `write_buffer`로 제자리 기록, 커질 때만 재할당, 50% 여유. 파이프라인은 Bevy 일반 메시에 `VertexStepMode::Instance` 정점 버퍼를 하나 붙인 것. 드로는 리스트 전체에 대한 한 번의 `draw_indexed`. 애니메는 WGSL 정점 셰이더. CPU는 위상과 종류만 쓰고, 좀비의 비틀거림·식물의 흔들림·시체의 누움·파편의 구름은 GPU에서 자란다. 연기는 더 교묘하다——알파 블렌딩 없음. 음의 「emissive」가 투시를 뜻하고, 프래그먼트가 4×4 Bayer로 일부 픽셀을 버린다. 오십만 투명물을 정렬할 필요는 영원히 없다.

여기까지 방법이 갖춰진다. 데이터가 살린다. 병표가 보이게 한다. Arc가 두 번째 복사를 거절한다. 한 번의 Draw가 각 메시를 소유한다. Bayer가 연기를 줄 세우지 않는다.

삼. 죄명표: 25fps의 정직함, 그리고 리스트가 전쟁보다 비싸다

10월 16일 밤 저자는 QA 모드로 다섯 판. 봇이 스스로 플레이, 2초마다 `LW_LOG`, GPU 시간은 Bevy `RenderDiagnosticsPlugin`. 같은 웨이브, 같은 스테이지, 군 상한만 바꿈. 각 숫자는 처음 12초 이후 16–21샘플 평균.

5,568유닛: 50fps. 186,000인스턴스의 대부분은 폭발 파티클. 27,934: 61fps, CPU 9.17ms, GPU 10.56. 115,217: 49fps, 11.31 / 15.60. 277,469: 40fps, 19.17 / 20.19. 638,734: 25fps(23–28), CPU 34.82, GPU 35.08, 프레임당 562,727인스턴스. 115k 조는 10월 2일 설계 문서의 「약 110k, CPU 9–13, GPU 10–14」와 맞고, 그래픽 백엔드만 다르다——깨끗한 sanity check.

놀람은 Draw Call이 아니다. 2.8만에서 63.9만(약 23배)으로 `think()`——양군 시뮬레이션 전체——는 1.13ms에서 10.09ms, 9배 미만. 계수 정렬 그리드가 버텼다. `render()`——인스턴스 리스트만 만드는 루프——는 0.69에서 18.85, 약 27배. 만편에서는 리스트 만들기가 전쟁 시뮬레이션보다 CPU를 더 먹는다. GPU는 전쟁과 함께 착실히 느려져 마지막 35.08ms로 CPU와 나란히. 타이밍의 어떤 줄도 「Draw Call이 너무 많다」를 가리키지 않았다.

도중에 깨진 것도 있다. 정점색 없는 메시는 파이프라인을 무너뜨린다. 셰이더는 `@location(5) color`를 읽고, Bevy 구·직육면체에는 그 속성이 없다. 세 줄 `inst::white()`가 흰 정점색을 더한다. 슬롯 회수에 마법 HP는 쓸 수 없다. 후반 데미지가 그 초병값을 뚫고 HUD에 45.8조 HP가 떴다. 전투 창을 떠나는 유닛은 `gone = true`를 세우고 사망으로 치지 않고 여유 리스트로 돌아간다. 10월 2일 이 PC에 Vulkan 어댑터가 없어 Bevy는 DX12, FXC가 실행마다 약 40초. 빠른 DXC는 파티클 crate `bevy_hanabi`를 깨뜨려 대기는 로딩 화면 뒤에 숨겼다. 그날 밤 새 드라이버(617.42)는 Vulkan을 골랐다. QA를 두 개 병렬로 띄우면 양쪽 셰이더 컴파일이 느려지고 스크린샷 타이밍이 왜곡된다. 다섯 대조는 하나씩 측정했다.

다음 내기는 정직하게 적혀 있다. 미검증. 차트는 Step 3을 가리키며, 더 많은 렌더 기교가 아니다. 리스트를 프레임 간에 살리고 움직인 유닛만 다시 쓰거나, CPU 리스트를 건너뛰고 컬링·LOD를 compute에 맡겨 인스턴스 버퍼에 직기입. Bevy 자체 렌더러도 0.16부터 GPU-driven으로 가고 있다. 어느 쪽이든 프레임에서 10ms 이상을 깎을 수 있다.

시스템별 CPU: 리스트 구축이 시뮬레이션을 앞지른다

수렴: 독립이란 청구서의 올바른 줄에 죄명을 못 박는 것

이 10월 해체를 2026년 가을 Rust/Bevy 인디 현장에 되돌리면, 먼저 치는 것은 숨은 제작 관성이다. 유닛은 엔티티가 기본, 끊김은 Draw Call 탓이 기본, 「배칭되면 출시 가능」이 기본. 수천을 넘으면 시민세가 전쟁 자체보다 비싸지 않은지, 25fps까지 떨어지면 비싼 것이 GPU인지 표만 채우는 CPU 루프인지 묻는 사람은 적다.

소규모 팀에게 이 재료는 특히 유용하다. 60만 식물도 RTX 4070도 필요 없다. 자신의 「기본이면 충분」 목록은 있다. 대량 유닛을 아직 Entity로 거는 일. Draw Call로 시작하는 최적화 회의. 매 프레임 `create_buffer`하는 데모. 오십만 급 반투명을 아직 정렬하려는 일. per-system span 없이 병목을 주보에 쓰는 일. Matt Busel의 공개 작업은 비용을 투박하게 적는다. 엔티티 수가 아플 때까지 자동 인스턴싱. 병표를 48바이트로 줄이기. 사유 병렬 리스트, Arc 인계, 버퍼 재사용, 정점 애니, Bayer 연기. 비싼 것은 첫날부터 로그를 남기는 일, 그리고 만편에서는 리스트가 전쟁보다 비싸다고 청구서에 인정하는 일이다.

실행 가능한 단서는 짧다.

  • 먼저 Bevy 자동 인스턴싱. 같은 메시, 같은 머티리얼. 삼각형이 아니라 엔티티 수가 아플 때만 커스텀으로.
  • 대량 객체는 데이터에 둔다. `Copy` 구조의 `Vec`, 여유 리스트, 고수위. 엔티티는 정말 필요한 소수에게.
  • 인스턴스를 말린다. 위치+요, 4×4가 아니다. 48바이트가 오십만과 마주하면 스스로 말한다.
  • 병렬 리스트는 사유 버퍼로. `par_chunks`, 태스크·메시마다 `Vec`, 끝에서 병합. 락 없음.
  • `Arc`로 인계하고, 메시당 하나의 엔티티에 `NoFrustumCulling`.
  • GPU 버퍼 재사용. `write_buffer`와 여유, 성장할 때만 재할당.
  • 애니는 정점 셰이더로. 투명이 늘어나면 디더, 알파 정렬이 아니다.
  • 첫날부터 per-system 계측. span이 없으면 거의 확실히 잘못된 줄을 최적화한다.

구백칠십사 시민 엔티티, 두 시간 십 분의 재작성, 마흔여덟 바이트 병표, 25fps 위의 육십삼만팔천 유닛, 그리고 `think()`보다 비싼 `render()`. 스토어 문구가 입을 열기 전에, 제작자는 감사 기준을 공개 해체에 써 넣었다. 먼저 유닛을 데이터로 되돌리고, 죄명을 올바른 줄에 못 박아라.

小光

출처와 더 읽기

Matt Busel / Tensorust(2026-10-16)Bevy instancing: drawing 639,000 units with one draw call per mesh

Matt Busel / DEV(동기)동일 제목

《LAWN WAR》 소스 GitLab

  • Bevy custom_shader_instancing 예(문중의 자동/수동)

Bevy 0.16 렌더 방향: Bevy 0.16

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

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

로그인