본문으로 건너뛰기
피드

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

타입스크립트 7.0, Go 네이티브 포팅으로 빌드가 8~12배 빨라졌다

마이크로소프트가 타입스크립트 7.0을 출시하면서 기존 자바스크립트 기반 구현을 Go로 네이티브 포팅했다. 전체 빌드는 평균 8~12배 빨라졌고, VS코드 빌드는 125.7초에서 10.6초로 줄었으며, 슬랙은 CI 타입 검사 시간을 7.5분에서 1.25분으로 낮췄다.

frontend

타입스크립트 7 공개, 고 포팅으로 빌드가 최대 10배 이상 빨라짐

마이크로소프트가 고(Go)로 네이티브 포팅한 타입스크립트 7을 공개했다. 전체 빌드는 보통 8~12배 빨라지고, VS 코드 코드베이스에서는 125.7초가 10.6초로 줄었으며, 편집기 첫 오류 표시도 17.5초에서 1.3초 미만으로 내려갔다.

frontend

“98% 지원”이 생각보다 별로 안 넉넉한 이유

브라우저 기능 지원률 98%는 얼핏 높아 보이지만, 실제 서비스에서는 2%의 사용자를 깨진 화면 앞에 세우는 숫자일 수 있다는 글이다. 특히 일반 통계와 내 서비스 방문자 분포는 다를 수 있고, 실제 사례로 어떤 사이트는 최신 중첩 CSS 기능 지원 브라우저가 방문자의 약 70%뿐이었다.

frontend

메타, AI가 읽고 쓰는 디자인 시스템 ‘Astryx’ 오픈소스로 공개

메타가 내부 디자인 시스템 Astryx를 오픈소스로 공개했다. React와 StyleX 기반의 150개 이상 접근성 지원 컴포넌트, 디자인 토큰, 테마, 템플릿, CLI를 제공하며, 특히 AI 에이전트가 컴포넌트 구조를 이해하고 UI를 생성·수정할 수 있도록 MCP 서버까지 포함한 점이 핵심이다.

frontend

shadcn/ui, 기본 컴포넌트 라이브러리를 Radix에서 Base UI로 변경

shadcn/ui가 새 프로젝트의 기본 컴포넌트 라이브러리를 Radix가 아니라 Base UI로 바꿨어. Radix 지원은 계속되지만, Base UI가 안정화됐고 주간 다운로드 600만 이상, shadcn/create 신규 프로젝트 선택 비율 2대1이라는 이유로 기본값을 바꾼 거야. 동시에 채팅 UI용 MessageScroller와 관련 컴포넌트, headless 패키지인 @shadcn/react도 공개됐어.