DEV NOTE · ECHO

수정을 먹으면 멈칫하던 이유: 셰이더 재컴파일과 2.1.0 해상도 시스템

ECHO 2.0.0을 내고 나서 눈에 띈 문제가 있었다. 수정을 주울 때, 손전등을 켜고 끌 때 화면이 한 번씩 멈칫했다. 평소 프레임은 멀쩡한데 특정 순간에만 생기는 끊김이었다. 원인은 셰이더 재컴파일이었고, 2.0.1에서 고쳤다. 이어서 2.1.0에서는 화질 쪽으로 해상도 시스템을 다시 짰다. 두 작업 모두 "렌더러가 실제로 무엇을 하고 있는지"를 다시 확인하는 일이었다.

끊김의 정체: 조명 개수가 바뀌면 셰이더가 새로 만들어진다

Three.js의 조명 재질(MeshStandardMaterial 등)은 장면에 있는 조명의 종류별 개수를 셰이더 코드에 상수로 넣어 컴파일한다. 포인트 라이트가 세 개면 세 개를 도는 반복문이 들어간 셰이더가 만들어지는 식이다. 그래서 보이는 조명의 개수가 바뀌면, 그 조명의 영향을 받는 모든 재질이 새 셰이더 프로그램을 필요로 한다. 셰이더 컴파일은 GPU 드라이버에서 수십 밀리초가 걸릴 수 있고, 그동안 프레임이 멈춘다.

ECHO에는 깜빡이는 조명이 많다. 수정 세 개는 각각 금색 포인트 라이트를 자식으로 품고 있고, HIGH 이상에서는 사냥꾼의 눈에도 포인트 라이트가 있다. 손전등은 스포트라이트다. 2.0.0에서는 이것들을 숨길 때 모두 visible = false를 썼다. 수정을 주우면 수정 메시를 숨겼고, 그 자식인 포인트 라이트도 함께 숨겨졌다. 손전등을 끄면 스포트라이트의 visible을 껐다. 그때마다 장면의 조명 개수가 바뀌었고, 벽·바닥·캐릭터처럼 조명을 받는 재질 전체가 다시 컴파일됐다. 수정을 줍는 순간과 손전등을 토글하는 순간에만 끊긴 이유가 정확히 이것이었다.

해결 1: 조명은 숨기지 말고 어둡게

원칙은 간단하다. 게임 진행 중에는 조명의 visible을 절대 바꾸지 않는다. 대신 밝기를 0으로 만든다. 밝기가 0인 조명도 개수에는 들어가므로 셰이더는 그대로 쓰인다. 셰이더 안에서 조명 하나를 더 계산하는 비용은 재컴파일 한 번에 비하면 무시할 만하다.

// 2.0.0: 숨기면 자식 조명까지 사라져 조명 개수가 바뀐다
if(c.got){ o.m.visible=false; return; }
// 2.0.1: 크기를 0에 가깝게 줄이고 조명은 밝기만 0으로
if(c.got){ o.m.scale.setScalar(0.0001); o.light.intensity=0; return; }

수정과 사냥꾼 그룹은 visible 대신 크기를 0.0001로 줄여 보이지 않게 만든다. 메시는 그대로 장면에 있고, 자식 조명도 살아 있으니 개수가 변하지 않는다. 손전등은 torch.visible을 끄던 코드를 지우고, 꺼질 조건일 때 밝기에 0을 곱하도록 바꿨다. 출구 그룹은 처음부터 계속 보이게 두고 투명도와 조명 밝기로만 켜고 끈다.

예외는 하나 남겨 두었다. 그래픽 품질을 바꿀 때는 수정 조명(MID 이상)과 눈빛 조명(HIGH 이상)의 visible을 실제로 바꾼다. 이건 사용자가 설정 화면에서 직접 누르는 순간에 한 번 일어나는 일이라, 그 자리의 짧은 멈춤은 받아들일 수 있다. 게임 중에 예고 없이 생기는 끊김과는 성격이 다르다.

해결 2: 층을 만들 때 미리 컴파일해 두기

조명 말고도 "처음 화면에 나오는 순간" 컴파일되는 재질이 있었다. 핑 링, 수정을 주울 때 튀는 입자, 사냥꾼 잔상, 컷어웨이로 반투명해진 벽이다. 이것들은 미리 만들어 둔 풀에서 꺼내 쓰는 객체라 평소에는 숨겨져 있고, Three.js는 숨겨진 객체의 셰이더를 컴파일하지 않는다. 그래서 첫 핑, 첫 수정, 첫 반투명 벽이 나오는 순간마다 작은 끊김이 생겼다.

2.0.1에서는 층을 만들 때마다 precompile()을 한 번 부른다. 풀에 있는 링·입자·잔상을 잠깐 모두 보이게 하고, 반투명 벽 인스턴스 개수를 최소 1로 맞춘 뒤 renderer.compile(scene, camera)를 호출한다. 컴파일이 끝나면 원래 상태로 되돌린다. 층 생성 직후는 화면이 바뀌는 순간이라 여기서 생기는 지연은 체감되지 않는다. 이 두 가지 조치 이후로 게임 진행 중에 셰이더가 새로 만들어지는 경우는 품질 변경 말고는 없다.

2.1.0: 안티앨리어싱이 사실 꺼져 있었다

2.1.0은 화질 작업이었는데, 렌더 경로를 다시 따라가 보다가 예상 못 한 사실을 찾았다. 캔버스를 만들 때 안티앨리어싱을 켜 두었지만 실제 화면에는 적용되지 않고 있었다. 이유는 후처리였다. ECHO는 블룸과 톤매핑을 위해 EffectComposer를 쓰는데, 컴포저는 장면을 캔버스가 아니라 자기 렌더 타깃에 먼저 그린다. 캔버스의 안티앨리어싱 옵션은 캔버스에 직접 그릴 때만 적용되므로, 컴포저를 쓰는 순간 사실상 꺼진 것과 같았다.

해결은 컴포저의 렌더 타깃을 직접 만들어 넘기면서 멀티샘플 수를 지정하는 것이다. MID 이상은 4x MSAA, LOW는 0이다. 2.0.x에서는 품질 설정에 따라 캔버스의 antialias 옵션을 켰지만, 효과가 없는 옵션이라 2.1.0부터는 항상 끄고 렌더 타깃 쪽 MSAA만 쓴다. 품질을 바꿀 때는 컴포저 내부의 두 렌더 타깃의 샘플 수를 비교해서 다르면 바꾸고 해제해, 다음 프레임에 새 설정으로 다시 만들어지게 했다. 타깃 형식은 1.0을 넘는 밝기 값을 담을 수 있는 HalfFloat이다.

해상도를 하나의 공식으로

MSAA를 고치면서 렌더 해상도를 정하는 방식도 정리했다. 2.1.0의 픽셀 비율은 다음 한 줄로 결정된다.

// render3d.mjs 발췌
let pr=Math.min(Q.maxPR,dpr)*Q.ss*res.drs;
pr=Math.min(pr,Math.sqrt(RES.maxPixels/(w*h)));

기기 픽셀 비율(dpr)을 품질별 상한(LOW 1, MID 1.5, HIGH·ULTRA 2)으로 자르고, 슈퍼샘플링 배율 ss와 동적 해상도 배율 drs를 곱한다. 마지막으로 전체 픽셀 수가 830만을 넘지 않게 제한한다. 830만은 4K(3840×2160, 약 829만 픽셀)에 맞춘 값이다. 새로 추가한 ULTRA는 ss가 1.5라서 기기 해상도보다 크게 그린 뒤 줄여 보여 주고, 손전등 그림자 맵도 2048로 올린다. 넓은 데스크톱 모니터에서는 1.5배를 다 쓰지 못하고 4K 상한에 걸리는데, 이것도 의도한 동작이다.

동적 해상도(DRS)는 1초마다 평균 프레임 시간을 본다. 평균이 20ms(50fps)를 넘으면 drs를 15% 낮추고, 최저 0.6까지 내려간다. 반대로 17.6ms(약 57fps) 아래로 안정되면 5%씩 올리는데, 마지막으로 낮춘 뒤 3초가 지나기 전에는 올리지 않는다. 내리는 폭은 크고 올리는 폭은 작게 둔 것은 해상도가 위아래로 계속 출렁이는 것을 막기 위해서다. 탭이 숨겨졌다 돌아온 경우처럼 한 프레임이 250ms를 넘으면 측정에서 뺀다.

해상도를 기기 픽셀보다 낮게 그리면 화면이 흐려진다. LOW 품질이나 DRS가 내려간 상황이 그렇다. 이때를 위해 대비 적응형 샤프닝(CAS 방식) 패스를 마지막에 붙였다. 각 픽셀과 상하좌우 네 이웃의 최솟값·최댓값으로 국소 대비를 구하고, 대비가 이미 큰 곳은 덜, 작은 곳은 더 날카롭게 만든다. 강도는 0.18이고, 실제 렌더 비율이 기기 비율의 95%보다 낮을 때만 켜진다. 기기 해상도 이상으로 그릴 때는 샤프닝이 오히려 노이즈를 키우므로 끈다.

ECHO 세로 화면. 랜턴 빛이 닿은 돌 바닥과 벽 가장자리, 시안색 윤곽선이 보인다
윤곽선처럼 가는 선이 많은 화면이라 안티앨리어싱 유무가 크게 드러난다.

정리

2.0.1과 2.1.0의 작업은 둘 다 "켜 두었다고 생각한 것이 실제로는 다르게 동작하던" 문제였다. 숨긴 조명은 셰이더를 다시 만들었고, 켜 둔 안티앨리어싱은 후처리에 가려져 있었다. 확인 수단으로 ECHO.R3.renderInfo()를 노출해 현재 품질, 픽셀 비율, DRS 값, 실제 캔버스 크기, MSAA 샘플 수, 샤프닝 여부를 바로 볼 수 있게 했다. 설계 문서에는 다음 측정 항목으로 ULTRA를 고르는 비율과 DRS 평균값을 적어 두었다. DRS가 평균적으로 크게 내려가 있다면 품질 프리셋의 기본값을 다시 잡아야 한다는 신호가 된다.