Skip to content

Commit c260c77

Browse files
authored
Feature/js mode (#6)
* wip * chore: readme 업데이트 * feat: 가이드 추가
1 parent 2090195 commit c260c77

File tree

7 files changed

+919
-281
lines changed

7 files changed

+919
-281
lines changed

Guide/그래픽변환원리.md

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
# 🎨 ASCII Doom: 그래픽 변환 원리
2+
3+
이 문서는 Doom 게임 화면이 어떻게 **ASCII 문자**로 변환되는지 그 과정을 설명합니다.
4+
5+
---
6+
7+
## 1. 변환 개요
8+
9+
```
10+
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
11+
│ 게임 엔진 │ ──▶ │ 변환 파이프라인 │ ──▶ │ 화면 출력 │
12+
│ (Chocolate Doom) │ │ (i_ascii.cpp) │ │ (renderer.js) │
13+
│ 320×200 픽셀 │ │ RGBA → ASCII │ │ 240×80 문자 │
14+
└─────────────────┘ └─────────────────┘ └─────────────────┘
15+
```
16+
17+
* **입력**: 320×200 해상도의 RGBA 픽셀 버퍼 (64,000개 픽셀)
18+
* **출력**: 240×80 크기의 ASCII 문자 그리드 (19,200개 문자)
19+
* **반복**: 이 과정이 **1초에 약 35번** 실행됩니다.
20+
21+
---
22+
23+
## 2. 변환 파이프라인 (5단계)
24+
25+
### ① 다운샘플링 (해상도 축소)
26+
픽셀 하나가 문자 하나보다 훨씬 작으므로, 여러 픽셀을 묶어서 하나의 문자로 대응시켜야 합니다.
27+
* **원본**: 320×200 = 64,000 픽셀
28+
* **타겟**: 240×80 = 19,200 문자
29+
* 각 문자 셀은 대략 **1.33×2.5 픽셀** 영역을 담당합니다.
30+
* 이 영역에 속한 모든 픽셀의 **평균 색상**을 구합니다.
31+
32+
### ② 적분 영상 생성 (Integral Image)
33+
평균 색상을 빠르게 구하기 위한 **최적화 기법**입니다.
34+
35+
**문제**: 19,200개 셀에 대해 매번 픽셀들을 일일이 더하면 느립니다.
36+
**해결**: 미리 **"누적 합 테이블"**을 만들어 둡니다.
37+
38+
```
39+
원본 이미지: 누적 합 테이블 (적분 영상):
40+
┌───┬───┬───┐ ┌───┬───┬───┐
41+
│ 1 │ 2 │ 3 │ │ 1 │ 3 │ 6 │ ← 가로 방향 누적
42+
├───┼───┼───┤ ──▶ ├───┼───┼───┤
43+
│ 4 │ 5 │ 6 │ │ 5 │12 │21 │ ← 가로+세로 누적
44+
└───┴───┴───┘ └───┴───┴───┘
45+
```
46+
47+
이 테이블이 있으면 **어떤 직사각형 영역의 합**이든 **뺄셈 3번**으로 즉시 계산할 수 있습니다.
48+
* `영역 합 = 우하단 - 우상단 - 좌하단 + 좌상단`
49+
50+
### ③ 밝기(휘도) 계산
51+
평균 RGB 값을 사람의 눈이 느끼는 **밝기(Luminance)**로 변환합니다.
52+
* 공식: `Y = R×0.299 + G×0.587 + B×0.114`
53+
* 결과: **0(완전 어두움) ~ 255(완전 밝음)** 사이의 값
54+
55+
### ④ 감마 보정 (Gamma Correction)
56+
문자 사이사이에 검은 여백이 있어서 화면이 원본보다 **어둡게 느껴집니다**.
57+
이를 보정하기 위해 밝기 값을 인위적으로 **밝게 조정**합니다.
58+
* 사용 감마: `0.35` (기본 1.0보다 밝게)
59+
* `gamma_table[i]`에 미리 계산된 보정 값을 저장해두고 즉시 조회합니다.
60+
61+
### ⑤ 문자 매핑 (ASCII Mapping)
62+
최종 밝기 값에 해당하는 **ASCII 문자**를 선택합니다.
63+
64+
```
65+
밝기 스케일:
66+
어두움 ◀━━━━━━━━━━━━━━━━━━━━━━━▶ 밝음
67+
@ % # * + = - : . (공백)
68+
```
69+
70+
* **0 (칠흑)**: `@` (가장 촘촘한 문자)
71+
* **128 (중간)**: `+` 또는 `=`
72+
* **255 (눈부심)**: ` ` (공백, 빈 문자)
73+
74+
밝기→문자 변환도 **`idxLUT[brightness]`** 테이블로 미리 계산해두어 연산을 최소화합니다.
75+
76+
---
77+
78+
## 3. 성능 최적화: SIMD
79+
80+
위 과정에서 가장 무거운 연산인 **"밝기 계산"**을 더 빠르게 하기 위해 **SIMD**를 사용합니다.
81+
82+
### SIMD란?
83+
* **Single Instruction, Multiple Data**의 약자입니다.
84+
* CPU에게 "이 숫자 4개를 **동시에** 계산해"라고 명령하는 기술입니다.
85+
86+
### 적용 방식
87+
```
88+
┌─────────────────────────────────────────────────────┐
89+
│ 보통 루프 (스칼라) │
90+
│ 픽셀1 계산 → 픽셀2 계산 → 픽셀3 계산 → 픽셀4 계산 │
91+
│ ↓ (순차 처리) │
92+
└─────────────────────────────────────────────────────┘
93+
94+
┌─────────────────────────────────────────────────────┐
95+
│ SIMD 루프 (벡터) │
96+
│ [ 픽셀1, 픽셀2, 픽셀3, 픽셀4 ] 동시 계산! │
97+
│ ↓ (병렬 처리, 이론상 4배 빠름) │
98+
└─────────────────────────────────────────────────────┘
99+
```
100+
101+
* **WASM SIMD128**: 128비트(16바이트) 레지스터에 4개의 32비트 정수를 담아 한 번에 계산합니다.
102+
103+
---
104+
105+
## 4. 최종 출력
106+
107+
변환된 결과는 **`AsciiCell`** 구조체 배열로 저장됩니다.
108+
```c
109+
struct AsciiCell {
110+
char character; // 문자 ('@', '#', '+', ...)
111+
uint8_t r, g, b; // 원본 색상 (감마 보정 후)
112+
};
113+
```
114+
115+
자바스크립트(`renderer.js`)는 이 배열을 읽어서:
116+
1. 문자(`character`)를 Canvas에 찍습니다.
117+
2. 색상(`r, g, b`)을 `fillStyle`로 적용하여 **컬러 ASCII 아트**를 완성합니다.

Guide/작동원리.md

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# 📖 ASCII Doom 작동 원리 (상세 가이드)
2+
3+
이 문서는 **ASCII Doom**이 웹 브라우저에서 어떻게 돌아가는지, 그 내부 원리를 비전공자도 이해할 수 있도록 상세한 비유와 함께 설명합니다.
4+
5+
---
6+
7+
## 1. 개요: 둠을 텍스트로?
8+
9+
1993년작 고전 게임 **Doom(둠)**을 웹 브라우저에서 실행하되, 우리가 아는 점(픽셀) 화면 대신 **문자(ASCII 텍스트)**로 변환하여 보여주는 프로젝트입니다.
10+
마치 "매트릭스" 영화 속 세상처럼, 게임 속 모든 장면이 글자들의 흐름으로 표현됩니다.
11+
12+
---
13+
14+
## 2. 핵심 기술: 3가지 마법의 기둥
15+
16+
이 프로젝트가 가능한 이유는 크게 세 가지 기술이 맞물려 돌아가기 때문입니다.
17+
18+
### ① 보이지 않는 게임기 (WASM & Chocolate Doom)
19+
브라우저 뒤편에는 사실 **진짜 게임기**가 숨어 있습니다.
20+
* **Chocolate Doom**: 원래 컴퓨터에 설치해서 해야 하는 C++ 기반의 둠 게임 엔진입니다.
21+
* **Emscripten (번역가)**: C++ 언어를 브라우저가 알아들을 수 있는 언어(**WebAssembly**)로 번역해 주는 도구입니다.
22+
* **결과**: 별도 프로그램 설치 없이, 크롬이나 사파리 같은 브라우저 안에서 고성능 게임 엔진이 쌩쌩 돌아갑니다.
23+
24+
### ② 화면 훔쳐보기 (메모리 공유)
25+
게임기가 화면을 만들어내면, 우리는 그 화면을 모니터에 바로 띄우지 않고 **중간에 가로챕니다.**
26+
* **공유 메모리 (Magic Billboard)**: C++(게임기)과 자바스크립트(웹 화면)가 서로 대화하기 위해 거대한 칠판을 하나 공유합니다.
27+
* 게임기가 매 순간 게임 화면(픽셀 데이터)을 이 칠판에 그리면, 자바스크립트가 잽싸게 그 데이터를 읽어갑니다.
28+
29+
### ③ 텍스트 변환 (ASCII 렌더링)
30+
가져온 그림 데이터를 **문자**로 바꿉니다. 이 과정이 이 프로젝트의 핵심입니다.
31+
32+
---
33+
34+
## 3. 작동 과정 (파이프라인) 상세 분석
35+
36+
매 1초마다 35번씩 일어나는 이 복잡한 과정을 단계별로 뜯어보겠습니다.
37+
38+
### 1단계: 해상도 압축 (Downsampling)
39+
* 원래 둠 게임 화면은 **320x200** 크기의 점들로 이루어져 있습니다.
40+
* 하지만 문자는 점보다 크기 때문에, 이를 가로 **240자**, 세로 **80줄**의 문자판으로 축소해야 합니다.
41+
* 이 과정에서 여러 개의 점을 하나로 뭉개서 평균 색을 구하는데, 이때 **"적분 영상"**이라는 고급 수학 기술을 씁니다. (아래 심화 섹션 참고)
42+
43+
### 2단계: 밝기 측정 및 문자 매칭
44+
각 칸의 밝기를 0부터 255까지의 숫자로 계산한 뒤, 가장 어울리는 문자를 찾습니다.
45+
* **사용하는 문자들**: ` ` (공백) `.` `:` `-` `=` `+` `*` `#` `%` `@`
46+
* **매칭 규칙**:
47+
* **밝기 0 (칠흑 같은 어둠)** -> `@` (가장 꽉 찬 문자)
48+
* **밝기 128 (중간)** -> `+` 또는 `=`
49+
* **밝기 255 (눈부신 빛)** -> ` ` (가장 비어 있는 문자)
50+
* *특이점*: 보통은 어두울수록 빈 문자를 쓰지만, 여기서는 반전된 배경을 고려하여 **어두울수록 꽉 찬 문자**를 써서 더 선명하게 보이게 했습니다.
51+
52+
### 3단계: 색상 복원
53+
단지 흑백 문자만 보여주는 게 아닙니다. 원본 게임의 **색감(Color)**을 그대로 가져옵니다.
54+
* 각 문자에 `style="color: rgb(R, G, B)"`를 입혀서, 둠 특유의 붉은 피나 초록색 독극물 바닥의 느낌을 생생하게 살립니다.
55+
56+
---
57+
58+
## 4. 🔍 심화: 0.1초의 지연도 허용하지 않는 최적화 요령
59+
60+
실시간 게임이기 때문에 조금이라도 계산이 늦어지면 화면이 뚝뚝 끊깁니다. 이를 막기 위해 사용된 "엔지니어의 묘수"들입니다.
61+
62+
### ① 적분 영상 (Integral Image) 기법
63+
* **문제**: 수십만 개의 점을 매번 처음부터 더하고 나누면 컴퓨터가 힘들어합니다.
64+
* **해결**: 미리 **"누적 합계표"**를 만들어 듭니다.
65+
* **비유**: 1월부터 12월까지의 가계부를 쓸 때, '현재까지 총지출'을 미리 다 계산해 두는 것입니다. 그러면 "3월부터 9월까지 쓴 돈은?"이라는 질문에, (9월 누적 - 2월 누적) 뺄셈 딱 한 번으로 답을 구할 수 있습니다.
66+
67+
### ② 감마 보정 (Gamma Correction)
68+
* **문제**: 문자로 그림을 그리면 픽셀 사이사이의 검은 여백 때문에, 원래 게임보다 훨씬 어둡고 칙칙해 보입니다.
69+
* **해결**: 일부러 밝기를 시원하게 올려줍니다 (**감마값 0.35**). 이렇게 해야 우리 눈에는 "원래 둠 화면과 비슷하다"고 느껴집니다.
70+
71+
### ③ 룩업 테이블 (Look-Up Table)
72+
* **문제**: 매번 "이 밝기엔 무슨 문자지?" 하고 수학 공식을 돌리면 느립니다.
73+
* **해결**: **"컨닝 페이퍼(정답표)"**를 0부터 255까지 쫙 만들어 둡니다.
74+
* 컴퓨터는 계산보다 **표에서 찾기**를 훨씬 잘합니다. 밝기 숫자만 딱 보면 0.0000001초 만에 문자가 튀어나옵니다.
75+
76+
### ④ SIMD (Single Instruction, Multiple Data)
77+
* **비유**: **"팔 4개 달린 로봇"**
78+
* 일반적인 처리가 한 손으로 점을 하나하나 옮기는 것이라면, **SIMD** 기술은 팔 4개로 한 번에 점 4개씩 휙휙 처리하는 것입니다. 이 기술 덕분에 웹에서도 끊김 없는 부드러운 화면이 가능합니다.
79+
80+
---
81+
82+
## 5. 키보드는 어떻게 연결되나요? (Input Bridging)
83+
84+
여러분이 브라우저에서 `` `` `` `` 키를 누르면 무슨 일이 일어날까요?
85+
1. **브라우저(JS)**가 키 입력을 감지합니다.
86+
2. 이 신호를 **공유 메모리**를 통해 **C++ 게임기**에 전달합니다. ("야, 사용자가 앞으로 가고 싶대!")
87+
3. 게임기는 이 신호를 받아서 게임 속 주인공을 움직입니다.
88+
4. 주인공이 움직인 새로운 화면이 다시 1~3단계를 거쳐 문자로 변환되어 여러분 눈앞에 나타납니다.
89+
90+
---
91+
92+
## 6. 요약
93+
94+
이 프로젝트는 **고전 게임(Chocolate Doom)****최신 웹 기술(WebAssembly)**로 포장하고, **수학적 기교(적분영상, SIMD)**를 더해 **예술적 형태(ASCII)**로 재탄생시킨 결과물입니다.

README.md

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,22 +37,27 @@ Doom 게임을 웹에서 ASCII 아트로 즐길 수 있습니다.
3737
- 팔레트/감마: 원본 팔레트와 ASCII 밝기 스케일이 달라 어둡게 보임 → 감마 LUT(0.35)와 밝기→문자 LUT(idxLUT) 재조정.
3838
- 해상도 스케일링: 셀 단위 다운샘플 시 계단/깨짐 → 적분영상 + 박스필터 평균으로 셀 컬러 추출.
3939
- 메모리/성능: 프레임마다 동적 할당이 있어 힙 성장 → 프리할로케이션 버퍼(temp_r/g/b 등)와 역수 LUT로 나눗셈 제거.
40-
- SIMD 호환: 일부 환경에서 SIMD 미지원 → JS 버튼으로 SIMD ON/OFF 토글, 벤치마크 패널에서 두 모드 모두 측정.
4140

4241
6. 가산점 항목으로 생각하는 부분
43-
- SIMD 최적화 시도: WASM SIMD로 픽셀 => 밝기 변환 루프를 벡터화, 16바이트 정렬 버퍼로 로드/스토어 정리.
44-
- 병목: 문자 매핑 테이블 접근이 스칼라라서 이득이 제한됨.
45-
- 결과: 7번 Latency 측정 테이블을 참고.
42+
- **SIMD 최적화 시도**: WASM SIMD128 인트린식을 사용하여 픽셀 처리 파이프라인 병렬화.
43+
- 성능 향상: C++ Scalar(0.47ms) 대비 소폭의 성능 향상(0.45ms) 확인, JS(0.56ms) 대비 약 20%의 Latency 절감 효과 입증. (7번 테이블 참고)
44+
- 기술적 구현: 4개의 픽셀(RGBA)을 128비트 벡터로 단일 로드, 밝기 변환 및 클램핑을 병렬 연산하여 처리량 극대화. 또한 16바이트 정렬 메모리를 사용하여 접근 효율 최적화.
45+
- **순수 JS 엔진 구현 및 비교**: WASM/SIMD의 성능 우위를 검증하기 위해 동일한 알고리즘을 JavaScript로 직접 구현하여 비교.
46+
- 성능 비교: C++(SIMD On)이 JS 대비 약 20% 더 빠른 처리 속도를 보임. (7번 테이블 참고)
47+
- WASM이 네이티브에 준하는 성능을 내고 있음을 확인하는 대조군으로 활용.
4648

4749
7. Latency 측정 테이블
4850

49-
> 측정 대상: `I_ConvertRGBAtoASCII()` 함수 (RGBA→ASCII 변환 파이프라인)
50-
> 측정 제외: LUT 초기화, JS Canvas 렌더링, 브라우저 컴포지팅
51+
> **측정 기준**: RGBA 버퍼 입력 시점부터 ASCII 버퍼 출력 완료 시점까지의 **순수 알고리즘 연산 시간**
52+
> - **C++**: `I_ConvertRGBAtoASCII` (적분영상 생성 → 2-Pass 변환(SIMD 최적화 구조))
53+
> - **JavaScript**: `convertRGBAtoASCII_JS` (적분영상 생성 → 1-Pass 변환)
54+
> - **공통 제외 항목**: LUT 초기화, 메모리 할당 체크, Canvas 렌더링, 브라우저 컴포지팅 등 외적 요소를 배제하여 동일 조건 비교.
5155
5256
| 시나리오 | FPS | Latency Avg | Latency Min | Latency Max | 환경 |
5357
|----------|-----|-------------|-------------|-------------|------|
54-
| SIMD ON | 35.00 | 0.45 ms | 0.00 ms | 1.20 ms | MacBook Pro M3 Pro / Chrome |
55-
| SIMD OFF | 34.96 | 0.47 ms | 0.10 ms | 1.40 ms | MacBook Pro M3 Pro / Chrome |
58+
| C++ SIMD ON | 35.00 | 0.45 ms | 0.00 ms | 1.20 ms | MacOS / Apple M3 Pro / 36GB RAM / Chrome |
59+
| C++ SIMD OFF | 34.96 | 0.47 ms | 0.10 ms | 1.40 ms | MacOS / Apple M3 Pro / 36GB RAM / Chrome |
60+
| JavaScript | 27.32 | 0.56 ms | 0.40 ms | 1.10 ms | MacOS / Apple M3 Pro / 36GB RAM / Chrome |
5661

5762

5863

src/i_ascii.cpp

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ static bool use_simd = true;
3232
// 벤치마크 모드
3333
static bool benchmark_mode = false;
3434

35+
// JS 모드 (JS에서 ASCII 변환 수행)
36+
static bool js_mode = false;
37+
static const uint32_t* rgba_buffer_ptr = nullptr;
38+
static int rgba_buffer_width = 0;
39+
static int rgba_buffer_height = 0;
40+
3541
// 벤치마크 통계 구조체
3642
struct BenchmarkStats {
3743
uint32_t frame_count;
@@ -246,6 +252,16 @@ void I_ConvertRGBAtoASCII(const uint32_t *rgba_buffer,
246252
if (!rgba_buffer || src_width<=0 || src_height<=0 ||
247253
ascii_width<=0 || ascii_height<=0) return;
248254

255+
// RGBA 버퍼 포인터 저장 (JS 모드에서 사용)
256+
rgba_buffer_ptr = rgba_buffer;
257+
rgba_buffer_width = src_width;
258+
rgba_buffer_height = src_height;
259+
260+
// JS 모드일 때는 C++ 변환 스킵 (JS에서 처리)
261+
if (js_mode) {
262+
return;
263+
}
264+
249265
// 초기화 작업 (벤치마크에서 제외)
250266
init_luts_once();
251267
ensure_bounds(src_width, src_height, ascii_width, ascii_height);
@@ -527,6 +543,37 @@ double ascii_get_current_fps_simd_on(void) { return current_fps_simd_on; }
527543
EMSCRIPTEN_KEEPALIVE
528544
double ascii_get_current_fps_simd_off(void) { return current_fps_simd_off; }
529545

546+
// JS 모드 관련 exports
547+
EMSCRIPTEN_KEEPALIVE
548+
void ascii_set_js_mode(int enabled) {
549+
js_mode = (enabled != 0);
550+
}
551+
552+
EMSCRIPTEN_KEEPALIVE
553+
int ascii_get_js_mode(void) {
554+
return js_mode ? 1 : 0;
555+
}
556+
557+
EMSCRIPTEN_KEEPALIVE
558+
const uint32_t* ascii_get_rgba_buffer(void) {
559+
return rgba_buffer_ptr;
560+
}
561+
562+
EMSCRIPTEN_KEEPALIVE
563+
int ascii_get_rgba_buffer_size(void) {
564+
return rgba_buffer_width * rgba_buffer_height * sizeof(uint32_t);
565+
}
566+
567+
EMSCRIPTEN_KEEPALIVE
568+
int ascii_get_rgba_width(void) {
569+
return rgba_buffer_width;
570+
}
571+
572+
EMSCRIPTEN_KEEPALIVE
573+
int ascii_get_rgba_height(void) {
574+
return rgba_buffer_height;
575+
}
576+
530577
} // extern "C"
531578

532579
#endif // __EMSCRIPTEN__

0 commit comments

Comments
 (0)