CSS로 둠(DOOM)을 렌더링하다: 웹 표준의 진화인가, 기형적 추상화인가?
“Can it run Doom?”
엔지니어라면 누구나 한 번쯤 들어봤을 밈이다. 임신 테스트기부터 영수증 프린터까지 온갖 기기에서 둠을 돌리는 프로젝트들을 봐왔지만, 이번 프로젝트는 내 시선을 완전히 사로잡았다. Niels Leenheer가 순수 CSS만으로 둠의 3D 렌더링 엔진을 구현했기 때문이다. WebGL이나 Canvas API를 쓴 것이 아니다. 오직 HTML <div> 태그와 CSS Transform만을 사용했다.
15년 넘게 웹 생태계를 굴러먹은 엔지니어로서 솔직히 말하자면, 처음엔 그저 흔한 잉여력 낭비 프로젝트인 줄 알았다. 하지만 코드를 뜯어볼수록 현대 브라우저의 렌더링 파이프라인이 얼마나 기형적으로, 그리고 경이롭게 강력해졌는지 깨닫게 되었다.
CSS가 삼각함수를 계산하는 시대
과거 우리가 float: left와 Clearfix 핵으로 레이아웃을 잡으며 고통받던 시절을 생각해보자. 지금의 CSS는 완전히 다른 괴물이 되었다. 이 프로젝트의 핵심은 WAD 파일에서 추출한 둠의 원시 좌표 데이터를 JavaScript가 CSS 변수(Custom Properties)로 넘겨주면, CSS가 브라우저 단에서 직접 삼각함수를 돌려 기하학적 렌더링을 수행한다는 점이다.
.wall {
--delta-x: calc(var(--end-x) - var(--start-x));
--delta-y: calc(var(--end-y) - var(--start-y));
width: calc(hypot(var(--delta-x), var(--delta-y)) * 1px);
height: calc((var(--ceiling-z) - var(--floor-z)) * 1px);
transform:
translate3d(
calc(var(--start-x) * 1px),
calc(var(--ceiling-z) * -1px),
calc(var(--start-y) * -1px)
)
rotateY(atan2(var(--delta-y), var(--delta-x)));
}
벽의 너비는 피타고라스 정리를 이용해 hypot()으로 구하고, 회전 각도는 atan2()를 사용해 처리한다. 흥미로운 점은 둠의 Y좌표가 CSS의 음수 Z좌표와 완벽하게 맞아떨어져서 별도의 복잡한 변환 없이 수학적 계산이 그대로 적용된다는 것이다.
카메라 대신 세상을 움직이다
CSS에는 3D 카메라라는 개념이 없다. 따라서 저자는 플레이어의 시점을 움직이는 대신, 플레이어의 움직임에 역산하여 3D 월드 전체를 반대 방향으로 이동시키는 고전적인 트릭을 사용했다.
#scene {
translate: 0 0 var(--perspective);
transform:
rotateY(calc(var(--player-angle) * -1rad))
translate3d(
calc(var(--player-x) * -1px),
calc(var(--player-z) * 1px),
calc(var(--player-y) * 1px)
);
}
이 방식은 훌륭하지만 치명적인 약점을 동반한다. 바로 브라우저의 Compositor가 이런 극단적인 3D 씬을 위해 설계되지 않았다는 것이다.
Type Grinding: CSS의 한계를 부수는 기괴한 해킹
수천 개의 3D 변환된 폴리곤(DOM 요소)을 렌더링하면 모바일 기기는 문자 그대로 불타오른다. 당연히 시야 밖의 요소를 숨기는 Frustum Culling이 필수적이다. 하지만 브라우저 엔진은 3D 공간에 배치된 DOM 요소에 대해 이를 자동으로 최적화해주지 않는다.
여기서 이 프로젝트의 가장 미친듯한 해킹이 등장한다. CSS만으로 Culling을 구현하기 위해 저자는 Type Grinding이라는 기법을 사용했다.
animation: cull-toggle 1s step-end paused;
animation-delay: calc(var(--cull-outside) * -0.5s);
@keyframes cull-toggle {
0%, 49.9% { visibility: visible; }
50%, 100% { visibility: hidden; }
}
CSS의 if() 함수가 아직 모든 브라우저에 배포되지 않았기 때문에, 계산된 값을 음수 animation-delay로 주입하여 일시 정지된 애니메이션의 특정 프레임(보임/숨김)으로 강제 이동시키는 꼼수다. 엔지니어링 관점에서 보면 정말 소름 돋을 정도로 똑똑하지만, 동시에 프로덕션 환경에서는 절대 마주치고 싶지 않은 형태의 코드다.
Hacker News의 반응과 나의 시선
이 글이 Hacker News에 올라왔을 때 커뮤니티의 반응은 뜨거웠다. 흥미로운 논의들을 몇 가지 짚어보자.
- Turing Completeness: 한 유저는 CSS로 x86 CPU 에뮬레이터를 만든 프로젝트(Lyra의 x86 in CSS)를 언급하며, CSS가 이미 튜링 완전한 언어가 된 것에 대한 두려움을 표했다.
- Abstraction Inversion: CSS는 원래 문서의 스타일을 선언적으로 정의하기 위해 만들어졌다. 하지만 이제는 제어 흐름(Control flow)과 상태(State)를 다루는 영역까지 침범하고 있다. 이는 명백한 추상화 역전 현상이다.
- Performance: 크롬에서는 버벅거림이 심했지만, Firefox의 WebRender 엔진은 GPU 가속을 적극 활용하여 훨씬 부드러운 모습을 보여주었다는 피드백이 많았다.
개인적으로 나는 CSS의 이러한 진화가 양날의 검이라고 생각한다. anchor-name이나 @property 같은 최신 스펙들은 확실히 과거 JS로 범벅이 되어야 했던 UI 로직을 우아하게 만들어준다. 하지만 이 프로젝트에서 볼 수 있듯, CSS 변수를 남용하여 매 프레임마다 background-image를 재평가하게 만들면 브라우저의 렌더링 파이프라인에 심각한 병목(Re-rasterization)을 유발한다.
결론: 그래서 우리는 무엇을 배워야 하는가?
이 프로젝트는 “CSS로 둠을 돌릴 수 있는가?”에 대한 완벽한 대답이자, 브라우저 렌더링 엔진의 한계선(Edge case)을 테스트하는 훌륭한 스트레스 테스트다. Safari의 View Transition 버그나 Chrome의 텍스처 소실 현상 같은 브라우저 레벨의 버그들을 찾아낸 것만으로도 가치가 있다.
하지만 동시에 우리가 웹 개발을 하며 지켜야 할 관심사의 분리(Separation of Concerns)에 대해 다시 생각하게 만든다. 프레젠테이션(CSS)과 로직(JS)의 경계가 무너지고 있는 지금, 우리는 단순히 “할 수 있기 때문에” 하는 것이 아니라 “이 도구가 이 작업에 적합한가”를 끊임없이 질문해야 한다.
물론, 퇴근 후 맥주 한 캔을 마시며 CSS로 구현된 둠을 플레이하는 것은 꽤나 즐거운 경험이었다. 적어도 내 아이폰이 핫팩으로 변하기 전까지는 말이다.
References
- Original Article: https://nielsleenheer.com/articles/2026/css-is-doomed-rendering-doom-in-3d-with-css/
- Hacker News Thread: https://news.ycombinator.com/item?id=47557960