---
title: "CSS로 DOOM을 만들었다 — 벽, 바닥, 임프 전부 다 <div>임"
published: 2026-03-28T20:39:01.000Z
canonical: https://jeff.news/article/1345
---
# CSS로 DOOM을 만들었다 — 벽, 바닥, 임프 전부 다 <div>임

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

- 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 엔진이 삼각함수를 돌려서 벽의 너비와 회전각을 계산함. 관심사 분리가 꽤 깔끔함

```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>`로 등록해야 트랜지션이 먹힘. 이거 없으면 브라우저가 문자열 취급해서 애니메이션 자체가 불가능함

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

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

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

## 투사체는 CSS가 날린다

- 로켓이나 임프 파이어볼은 생성 시 시작/끝 좌표와 duration만 설정하면 CSS animation이 자동으로 A에서 B로 날려줌
- `translate`와 `rotate`를 별도 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()` 함수가 보급되면 깔끔하게 대체 가능

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

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

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

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

- 3인칭 팔로우 모드에서 카메라가 플레이어 뒤에 위치해야 하는데, 플레이어 방향에 따라 `sin()`과 `cos()`로 오프셋을 CSS가 직접 계산함
- `transform`을 `translate`, `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 돌릴 수 있나요?" — 됨

## 핵심 포인트

- DOOM의 모든 렌더링을 CSS로 구현 — JS는 게임 로직만 담당
- CSS hypot(), atan2() 등 삼각함수로 벽 크기/회전 계산
- 카메라 없이 세상 전체를 반대 방향으로 이동시키는 클래식 트릭 사용
- @property 등록으로 커스텀 프로퍼티 애니메이션 가능하게 함
- CSS Anchor Positioning으로 반응형 HUD 구현
- 순수 CSS 컬링을 type grinding 해킹으로 구현
- 다수의 브라우저 버그 발견 (Safari View Transitions, Chrome 컴포지터 불안정 등)

## 인사이트

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