브라우저에서 오픈 월드 만들기, 21부: 더 빠르지 않았던 더 빠른 렌더러
글쓴이 Oleg Sidorkin, Cinevva CTO 겸 공동창업자
처음 오셨나요? 시리즈 가이드를 보세요. spike가 무엇인지 설명하고 모든 부분을 링크해 둡니다.
20부에서는 평평한 쿼드 위에 표면 깊이를 가짜로 만들었습니다. 이번 부분은 렌더 아키텍처 결정에 관한 것이고, 교과서적인 정답이 우리 하드웨어에서는 틀린 것으로 드러난 바로 그 spike입니다. 질문은 이렇습니다. 3인칭 카메라 아래 영화 수준 밀도의 alpha-test 풀에 대해, 200명 밀도로 밀어붙이기 전에 visibility buffer가 필요한가? 통상적인 조언은 단호하게 "그렇다"입니다. 우리는 그것을 만들었고, 측정했고, 답은 "아니다"였습니다.
모두가 추천하는 그 기법
visibility buffer는 렌더링을 두 개의 pass로 나눕니다. Pass 1은 지오메트리를 래스터화하고 삼각형 ID와 인스턴스 ID만 깊이와 함께 압축된 정수 타깃에 쓰며, 셰이딩은 전혀 하지 않습니다. Pass 2는 풀스크린 pass로, 덮인 각 픽셀의 ID를 읽고 그 삼각형의 정점을 다시 가져와서 보간된 속성을 재구성하고, 보이는 각 픽셀을 정확히 한 번씩만 셰이딩합니다. 핵심 강점은 완벽한 overdraw 제거입니다. 깊이 테스트는 셰이딩 작업을 하나도 하지 않은 프래그먼트에 대해 실행되므로, 비싼 머티리얼은 당신이 실제로 보는 것에만 돌아갑니다.
이 spike는 한 캔버스, 한 device 위에서 두 경로를 돌려서 유일한 변수가 셰이딩이 어디에서 일어나는지가 되도록 합니다. forward 경로는 three.js를 통한 평범한 MeshStandardNodeMaterial입니다. vis-buffer 경로는 three.js 밖에서 도는 raw WebGPU 2-pass 파이프라인으로, three.js의 풀 텍스처를 백엔드에서 곧장 읽어 pass 1에서 (instanceId, triId)를 RG32Uint 타깃에 쓰고 pass 2에서 라이팅을 해석합니다. 둘은 하나의 ground-truth 라이팅 설정을 공유합니다.
기억해 둘 만한 구현 노트가 두 가지 있습니다. WebGPU는 프래그먼트 셰이더에서 여전히 이식 가능한 primitive_index 빌트인이 없어서, 정점별 삼각형 ID를 비인덱스 지오메트리에 구워 넣고 flat 보간으로 읽는 게 요령입니다. 이건 정점 수를 3배로 만들지만 12정점짜리 풀 카드에서는 무시할 만합니다. 그리고 three.js의 렌더러와 캔버스를 공유하는 일은, 컨텍스트를 다시 구성하지 않고 캔버스 크기를 건드리지 않는 한 거의 아무 일도 아닙니다. 둘 다 three.js가 관리하는 것이거든요. forward 경로의 시간을 재는 게 까다로운 부분이었는데, three.js가 자기 렌더 pass 안에 GPU 타임스탬프 쿼리를 주입할 훅을 노출하지 않기 때문입니다. 우회책은 그 작업 앞뒤로 no-op 타임스탬프 pass를 두 개 제출해 작업을 감싸는 것이고, GPU는 이들을 제출 순서대로 실행합니다.
숫자가 잘못된 방향으로 간다
M 시리즈 Mac에서 약 1080p, 80m 들판 위에 교차 카드 풀잎을 깔고 측정했습니다.
인스턴스 5만 개에서 vis-buffer 경로가 25% 이겼습니다. 4.13ms 대 forward의 5.51ms입니다. 10만 개에서는 비겼습니다. 20만 개에서는 forward가 44% 이겼습니다. 5.44ms 대 vis-buffer의 7.80ms입니다. 밀도가 올라갈수록 vis-buffer 경로는 상대적으로 더 나빠지는데, 이는 overdraw가 심할 때 바로 이긴다는 통설과 정확히 반대입니다.
forward가 버티는 이유
Apple Silicon GPU는 타일 기반 디퍼드 렌더러(TBDR)이고, 이것이 계산 전체를 바꿔 놓습니다. TBDR 위의 forward 셰이딩에는 프래그먼트 셰이더가 돌기 전에 실행되는 은면 제거(HSR) 단계가 있습니다. 래스터라이저가 한 타일에 매핑되는 모든 프래그먼트를 모아 깊이로 정렬하고, 살아남은 것들(alpha-test 통과 이후)만 프래그먼트 셰이더에 도달합니다. 그래서 forward 경로는 이미 하드웨어 안에서, 공짜로, visibility buffer의 "픽셀당 한 번만 셰이딩" 약속의 대부분을 이행하고 있습니다. 풀잎이 화면을 가득 채울수록 더 많은 프래그먼트가 셰이딩이 일어나기 전에 HSR에서 거절되고, forward의 실효 픽셀당 비용은 overdraw와 함께 늘어나는 대신 대체로 평평하게 유지됩니다.
vis-buffer 경로의 pass 1도 똑같은 TBDR 이점을 얻습니다. 문제는 전부 pass 2에 있습니다. Pass 2는 각 픽셀의 인스턴스 행렬을 버퍼에서 읽는데, 이 버퍼는 인스턴스 20만 개에서 12.8MB로, 어떤 GPU 캐시보다도 훨씬 큽니다. 화면에서 인접한 픽셀들은 대개 서로 다른 풀 인스턴스에 속하므로(scatter가 지터링된 그리드라서 이웃 풀잎들은 임의의 인스턴스 ID를 가집니다), 그 버퍼를 치는 모든 wave가 발산적으로 캐시를 놓칩니다. 이 비일관 랜덤 접근 하나만으로도 프레임당 약 4ms가 숨어 있습니다. forward는 이를 완전히 피하는데, 인스턴스 행렬이 정점과 함께 인스턴스별 속성 경로로 도착하기 때문입니다. 그래서 프래그먼트 셰이더가 돌 때쯤이면 변환된 정점 데이터가 이미 타일 로컬 레지스터에 있어서 메가바이트 규모의 랜덤 읽기가 필요 없습니다.
이것이 바로 Nanite의 머티리얼 분류 pass가 분산시키려고 존재하는 그 비용입니다. 픽셀을 인스턴스별로 분류하고 정렬된 compute wave를 디스패치해서 각 wave의 읽기가 일관되게 만드는 것이죠. 우리에게는 그게 없습니다. 대략 계산해 보면 픽셀을 인스턴스별로 정렬하면 그 4ms를 1.5에서 2ms 정도로 떨어뜨리고 교차점을 인스턴스 40만에서 50만 개로 밀어낼 수 있습니다. 하지만 그건 애초에 여기서 이기지 못하는 아키텍처 위에 최적화를 쌓는 일입니다.
솔직한 결론, 그리고 그것을 얻어 낸 감사
Apple Silicon WebGPU에서 alpha-test 교차 카드 식물에 대해서는, three.js TSL 파이프라인의 forward 경로가 이미 vis-buffer 비용에 맞먹거나 그보다 낮습니다. 그리고 vis-buffer 배선은 인스턴스 20만 개를 한참 넘어서기 전까지, 그것도 정렬이나 비닝 pass를 추가로 넣어야만 눈에 보이는 어떤 것도 사 주지 못합니다. 프로덕션 엔진에 대한 현실적인 판단은 앞선 spike들에서 만든 forward 더하기 LOD 더하기 imposter 스택을 유지하고, 두 가지 중 하나가 될 때까지는 vis-buffer 인프라에 투자하지 않는 것입니다. 하나는 우리가 별도의 NVIDIA나 AMD GPU를 주력 배포 대상으로 삼는 경우(거기서는 overdraw 비용이 더 선형적입니다)이고, 다른 하나는 vis-buffer가 어차피 자연스러운 출력이 되는 meshlet 아키텍처로 옮겨 가는 경우입니다.
이 결과가 직관에 반하기 때문에, 결론은 비교가 공정할 때만 가치가 있습니다. 그래서 이 spike는 전체 감사를 한 번 거쳤습니다. 실제 버그 몇 개가 드러나서 고쳐졌습니다. 두 경로를 조용히 어긋나게 하던 풀잎 스케일 슬라이더, 반평행 법선 때문에 forward 풀잎의 절반이 칠흑같이 어둡게 렌더되던 문제(법선을 위로 통일하는 정석적인 식물 기법으로 해결), 그리고 vis-buffer가 약 2배 너무 밝게 읽히던 문제입니다. 이는 에너지 보존 1/π 대신 손으로 고른 Lambert 인자, 하드코딩된 앰비언트 항, 누락된 톤 매핑 때문이었습니다. 수정은 three.js의 정확한 ACES filmic 곡선을 WGSL로 그대로 옮기고, 매 프레임 실제 씬 라이트에서 조명 색과 강도를 읽도록 했습니다. 남은 알려진 격차인 pass 2의 직접 스페큘러 누락은 비교를 vis-buffer 쪽으로 편향시킵니다. 즉 forward는 픽셀당 엄밀히 더 많은 일을 하면서도 고밀도에서 여전히 이기고 있다는 뜻입니다. 그래서 헤드라인 결론은 낙관적인 게 아니라 보수적입니다. 유효한 단서 하나는 이렇습니다. 이 모든 것은 M 시리즈에 특정한 이야기이고, 교차점은 별도 카드에서는 충분히 뒤집힐 수 있으므로, 비 Apple 대상에 스택을 확정하기 전에 다시 돌려 볼 가치가 있습니다.
이 장에서 언급한 기술
visibility buffer 렌더링. Pass 1은 지오메트리를 래스터화하고 삼각형 ID와 인스턴스 ID만 깊이와 함께 쓰며 셰이딩은 하지 않습니다. Pass 2는 풀스크린 해석으로, 덮인 픽셀마다 ID를 읽고 원본 삼각형을 다시 가져와 원근 보정된 무게중심 속성을 재구성하고, 보이는 각 픽셀을 한 번씩 셰이딩합니다. WebGPU에는 이식 가능한 프래그먼트 primitive_index가 없으므로, 삼각형 ID는 비인덱스 지오메트리 위에 flat 보간되는 정점별 속성으로 구워 넣습니다.
TBDR 은면 제거 대 디퍼드 해석. 타일 기반 디퍼드 GPU(Apple Silicon)에서는 forward 셰이딩이 프래그먼트 셰이더가 돌기 전에 이미 가려진 프래그먼트를 거절하므로, visibility buffer의 한 번만 셰이딩하는 이점 대부분을 공짜로 가져가고, 그 픽셀당 비용은 overdraw가 늘어도 대체로 평평하게 유지됩니다. 반면 vis-buffer 해석 pass는 큰 인스턴스별 버퍼(인스턴스 20만 개에서 12.8MB)로의 비일관 랜덤 접근에 비용을 치르는데, 이는 Nanite의 머티리얼 분류처럼 픽셀을 먼저 인스턴스별로 정렬하거나 비닝하지 않는 한 고밀도에서 지배적입니다.
three.js의 WebGPURenderer와 캔버스 공유. raw WebGPU 커맨드 버퍼는 context.configure()를 다시 호출하지 않고 canvas.width/height를 쓰지 않는 한 공유 큐 위에서 three.js 제출과 올바르게 교차됩니다. 둘 다 렌더러가 관리합니다. three.js가 훅을 노출하지 않는 forward 경로 GPU 타이밍은, 그 렌더 호출 앞뒤로 no-op 타임스탬프 렌더 pass를 두 개 제출해서 감쌀 수 있습니다. GPU가 커맨드 버퍼를 제출 순서대로 실행하기 때문입니다.
직관에 반하는 벤치마크 검증하기. 놀라운 성능 결과는 비교의 공정성만큼만 믿을 수 있습니다. 두 경로를 동일한 씬 콘텐츠와 셰이딩으로 감사하는 것(일치하는 ACES 톤 매핑, 에너지 보존 Lambert, 같은 오브젝트에서 읽은 라이트, 동일한 풀잎 스케일)이 바로 "vis-buffer가 더 느리다"를 그럴듯한 측정 잡음에서 방어 가능한 결론으로 바꿔 놓았고, 남은 한 가지 비대칭은 보수적인 방향으로 편향되어 있습니다.
29부 중 21부. 이전: 20부 - 평평한 면에 깊이를 가짜로 만들기 다음: 22부 - 뚫고 날아갈 수 있는 구름, 그리고 값을 하는 컬링 시리즈 가이드: /ko/blog/2026-02-25-open-world-browser-series-guide