본문으로 건너뛰기
피드

CSS로 DOOM을 만들었다 — 벽, 바닥, 임프 전부 다 <div>임

frontend 약 10분
vote
0
댓글
북마크

CSS transforms로 DOOM의 3D 렌더링을 완전히 구현한 프로젝트. 벽, 바닥, 스프라이트가 전부 <div>이고, CSS의 hypot(), atan2(), sin(), cos() 함수로 삼각함수 계산까지 브라우저가 처리함. 게임 로직만 JavaScript이고 렌더링은 순수 CSS.

  • 1

    DOOM의 모든 렌더링을 CSS로 구현 — JS는 게임 로직만 담당

  • 2

    CSS hypot(), atan2() 등 삼각함수로 벽 크기/회전 계산

  • 3

    카메라 없이 세상 전체를 반대 방향으로 이동시키는 클래식 트릭 사용

  • 4

    @property 등록으로 커스텀 프로퍼티 애니메이션 가능하게 함

  • 5

    CSS Anchor Positioning으로 반응형 HUD 구현

  • 6

    순수 CSS 컬링을 type grinding 해킹으로 구현

  • 7

    다수의 브라우저 버그 발견 (Safari View Transitions, Chrome 컴포지터 불안정 등)

  • DOOM의 모든 벽, 바닥, 배럴, 몬스터가 <div>로 되어있고, CSS transforms로 3D 공간에 배치됨. 게임 로직만 JavaScript고 렌더링은 100% CSS. 실제로 플레이 가능함
  • 원래 1980년대 오실로스코프에서 DOOM을 돌리는 프로젝트를 했던 사람이라, WAD 파일 파싱이나 수학 관련 문제는 이미 해결된 상태에서 시작한 거임

CSS가 삼각함수를 한다고?

  • 각 벽은 DOOM 좌표를 CSS 커스텀 프로퍼티(--start-x, --end-x 등)로 받고, CSS가 직접 피타고라스와 역탄젠트를 계산함. hypot()atan2() — 이게 CSS에 있다는 게 핵심임
  • JavaScript는 원본 WAD 데이터만 넘기고, CSS 엔진이 삼각함수를 돌려서 벽의 너비와 회전각을 계산함. 관심사 분리가 꽤 깔끔함
.wall {
  width: calc(hypot(var(--delta-x), var(--delta-y)) * 1px);
  transform: translate3d(...)
    rotateY(atan2(var(--delta-y), var(--delta-x)));
}

카메라가 없으니까 세상을 움직인다

  • CSS에는 카메라 개념이 없음. 그래서 고전적인 트릭을 씀 — 플레이어가 앞으로 가면 세상 전체를 뒤로 밀어버리는 거임
  • JavaScript가 설정하는 건 딱 4개 커스텀 프로퍼티뿐: --player-x, --player-y, --player-z, --player-angle. 이걸로 이동과 시선 회전이 전부 처리됨
  • DOOM 좌표계랑 CSS 3D 좌표계가 달라서 translate3d(x, -z, -y) 같은 변환이 필요한데, 벽과 월드의 변환이 정확히 역수라서 수학적으로 딱 맞아떨어짐

바닥은 <div>를 옆으로 눕힌 거임

  • DOM 요소는 기본이 수직이라 바닥은 rotateX(90deg)로 수평으로 눕힘. 처음에 -90도로 했다가 바닥이 반대로 향해서 안 보였다고 함
  • DOOM 섹터는 L자, 불규칙 다각형 등 복잡한 형태가 많은데 clip-path: polygon()path() + evenodd 규칙으로 구멍 뚫린 바닥까지 표현함
  • 새로운 CSS shape() 함수 덕분에 퍼센트 단위 + evenodd를 동시에 쓸 수 있게 되어서, JavaScript에서 좌표 변환하던 부분을 CSS로 옮길 수 있었음

텍스처 타일링의 비밀

  • 인접한 섹터의 바닥 텍스처가 끊김 없이 이어지려면, 모든 섹터가 같은 기준점에서 패턴을 시작해야 함
  • 해법은 의외로 간단함 — background-position에 월드 좌표의 역수를 넣으면 끝. 요소가 어디에 위치하든 텍스처 그리드가 일치함

문, 리프트, 그리고 @property

  • 문 열기는 data-state 속성 하나만 토글하면 CSS transition이 알아서 애니메이션함. JavaScript 애니메이션 루프 필요 없음
  • 근데 리프트는 문제가 있음 — 플레이어가 리프트와 함께 움직여야 하는데, --player-z는 게임 루프가 관리하니까 CSS 애니메이션과 동기화가 안 됨. 현재는 JavaScript에서 cubic ease-in-out으로 대충 맞추는 중인데, 본인도 "kind of works, not really"라고 인정함
  • @property로 커스텀 프로퍼티를 <number>로 등록해야 트랜지션이 먹힘. 이거 없으면 브라우저가 문자열 취급해서 애니메이션 자체가 불가능함

중요

> @property 등록 없이는 CSS 커스텀 프로퍼티를 애니메이션할 수 없음 — 브라우저가 문자열로 취급하기 때문. 이 프로젝트의 핵심 기반 기술임

스프라이트 — 항상 카메라를 바라보는 빌보딩

  • DOOM의 8방향 스프라이트 중 실제로 저장되는 건 5세트뿐이고, 나머지 3개는 좌우반전(scaleX(-1))으로 처리함. CSS에서도 동일하게 구현
  • 모든 적이 완벽히 동기화되어 발을 맞춰 걷는 게 소름 끼칠 정도로 이상했다고 함. 해결책은 animation-delay에 랜덤 값 넣기. CSS random()이 브라우저에 탑재되면 이것도 CSS로 옮길 수 있음

투사체는 CSS가 날린다

  • 로켓이나 임프 파이어볼은 생성 시 시작/끝 좌표와 duration만 설정하면 CSS animation이 자동으로 A에서 B로 날려줌
  • translaterotate를 별도 CSS 프로퍼티로 분리한 게 핵심 — 위치 애니메이션 중에도 rotate--player-angle에 반응해서 파이어볼이 항상 카메라를 향함
  • 폭발 이펙트는 3프레임 스프라이트시트를 steps()로 재생하고, animationend 이벤트에서 remove() 호출. 타이머도 수동 정리도 필요 없음

조명과 투명 몬스터

  • DOOM의 섹터별 조명 레벨을 filter: brightness()로 처리. CSS 캐스케이드 덕분에 어두운 섹터 안의 벽, 바닥, 스프라이트가 전부 자동으로 어두워짐
  • 깜빡이는 조명은 @property + keyframe 애니메이션으로 구현
  • 반투명 몬스터 Spectre는 SVG 필터(feTurbulence + feDisplacementMap)로 원본의 일렁이는 실루엣 효과를 재현함

반응형 DOOM과 Anchor Positioning

  • 브라우저 창 크기를 줄여도 다 작동함. 폰에서도 됨 — 크래시 나기 전 몇 분 정도는
  • HUD 상태바가 flex-wrap으로 줄바꿈되면 높이가 달라지는데, 무기 스프라이트가 항상 상태바 위에 붙어야 함. CSS Anchor Positioning으로 해결 — bottom: anchor(top)으로 상태바 높이에 자동 추적

컬링 — 브라우저는 3D 최적화를 안 해줌

  • 수천 개의 3D 변환 요소를 다루니 성능이 문제임. Safari on iOS는 그냥 크래시남
  • 브라우저가 시야 밖 요소를 자동으로 안 그려줄 것 같지만 실제로는 안 함. 브라우저 컴포지터는 레이어 UI용이지 3D 씬용이 아님
  • JavaScript 기반 컬링(거리/방향 체크)도 있고, 실험적 순수 CSS 컬링도 구현함
  • CSS에서 값을 계산해서 0/1로 만들 수 있지만 visibility를 직접 토글할 수 없어서, type grinding이라는 해킹 기법을 씀 — paused animation의 animation-delay를 조작해서 visible/hidden 키프레임을 선택하는 방식. CSS if() 함수가 보급되면 깔끔하게 대체 가능

💡

> Type grinding 패턴: paused animation + 음수 animation-delay로 계산된 값에 따라 키프레임을 선택하는 기법. CSS if()가 아직 Chrome에만 있어서 현재로서는 유용한 우회법임

하늘 렌더링 — DOOM은 치팅한다

  • 원본 DOOM은 3D 벽에 2D 하늘 텍스처를 그냥 그려넣는 치팅을 함. 하지만 CSS 버전은 진짜 3D 씬이라 이게 안 됨
  • 해결책: 하늘을 3D 씬 뒤에 2D로 깔고, 하늘 "벽" 뒤에 있는 맵 지오메트리는 컬링 알고리즘에서 숨김 처리

스펙테이터 모드 — 카메라 위치도 CSS가 계산

  • 3인칭 팔로우 모드에서 카메라가 플레이어 뒤에 위치해야 하는데, 플레이어 방향에 따라 sin()cos()로 오프셋을 CSS가 직접 계산함
  • transformtranslate, rotate 등 개별 프로퍼티로 분리하니까, 1인칭→3인칭 전환 시 카메라가 이상한 호를 그리던 문제가 사라지고 매끄러운 트랜지션이 됨

브라우저 버그 수확

  • Safari의 View Transitions가 preserve-3d를 완전히 무시하고 2D 스냅샷으로 캡처해버림
  • CSS 커스텀 프로퍼티로 background-image를 설정하면 Safari와 Chrome 모두에서 매 프레임 수천 개 텍스처를 재래스터화하는 심각한 성능 이슈 발생 → 인라인 스타일로 우회
  • Chrome 컴포지터는 이 정도 규모의 3D 변환 표면에서 텍스처가 간헐적으로 사라지는 불안정성 있음
  • @starting-style + display: none 조합이 Safari에서 트랜지션을 무한 반복 트리거하는 버그도 발견

결론

  • WebGL/WebGPU를 대체하려는 게 아님. CSS의 한계를 밀어붙이는 실험임
  • hypot(), atan2(), sin(), cos(), @property, clip-path, shape(), anchor positioning, SVG 필터 — 전부 프로덕션 레디 CSS 기능인데, 스펙 작성자들도 상상 못한 방식으로 쓰인 거임
  • 그리고 이 프로젝트는 아무도 안 물어본 질문에 답한 셈임: "CSS로 DOOM 돌릴 수 있나요?" — 됨

CSS가 얼마나 발전했는지 보여주는 극단적 데모. 프로덕션용은 아니지만, hypot/atan2/@property/anchor positioning 같은 모던 CSS 기능들의 실전 활용 가능성을 확인할 수 있는 프로젝트임.

댓글

댓글

댓글을 불러오는 중...

frontend

요즘 픽셀 폰트가 그냥 복고 감성이 아닌 이유

1990년대 기기 화면 느낌을 현대 폰트 시스템으로 재해석한 픽셀 폰트 몇 가지를 소개한 글이다. 핵심은 예쁜 복고풍 글자 모양만이 아니라, 실제 제품에서 쓸 수 있게 기준선, 자간, 메타데이터, 세로 메트릭까지 챙기는지가 중요하다는 점이다.

frontend

HTML의 `<dl>`이 생각보다 쓸모 많은 이유

이 글은 HTML의 description list, 즉 `<dl>`, `<dt>`, `<dd>`가 단순 용어 사전용 태그가 아니라 이름-값 쌍 UI를 표현하는 꽤 강력한 시맨틱 도구라고 설명한다. 숙소 편의시설, 요금 내역, 기술 용어 설명, 게임 능력치표처럼 흔한 패턴을 중첩 `<div>` 대신 의미 있는 HTML로 만들 수 있다는 얘기다.

frontend

HTML을 캔버스 안에 넣는 데모 모음이 등장함

구글 크롬 랩스 저장소에 HTML-in-Canvas 관련 데모와 프레임워크 지원 목록이 정리됐다. Duck Hunt 스타일 폼, 흔들리는 버튼, 셰이더 기반 페이지 전환, 천처럼 매달린 폼 같은 실험적 예제가 포함돼 있고 Three.js와 PlayCanvas 쪽 샘플도 연결돼 있다.

frontend

싱글 페이지 앱이 웹을 너무 비싸게 만들었다는 불평

이 글은 싱글 페이지 앱(SPA)이 사용자 경험을 좋게 만든다는 명분 아래 웹의 초기 로딩 비용, 도구 복잡도, 개발 진입 장벽을 키웠다고 비판한다. 페이스북 로그인 페이지의 CSS 3.8MB, 레딧 몇 개 클릭 후 33MB 다운로드 같은 숫자를 들며, 지금의 프론트엔드 생태계가 사람보다 대기업의 요구에 맞춰져 있다고 주장한다.

frontend

네이티브로 끝까지 가려다 텍스트에서 막힌 macOS 개발자의 고백

20년 가까이 macOS와 iOS 네이티브 개발을 해온 작성자가 SwiftUI, AppKit, TextKit 2로 마크다운 채팅 UI를 만들다 결국 WebKit과 Electron 쪽이 훨씬 낫다는 결론에 도달한 글이다. 문제는 성능 하나가 아니라 선택, 스트리밍, 스크롤, 접근성, 텍스트 상호작용 같은 ‘사용자가 당연히 기대하는 기본기’가 네이티브 조합에서 계속 깨진다는 점이다.