웹 브라우저에서 Game Boy 게임을 즐길 수 있는 에뮬레이터 웹입니다. 순수 JavaScript로 구현된 CPU, GPU, APU, 메모리 시스템을 통해 원본 하드웨어를 재현하면서도 웹 기술로 접근성을 높였습니다.
- 완전한 Game Boy 에뮬레이션: CPU, GPU, APU, 메모리 시스템 완전 구현
- 실시간 오디오: 4채널 사운드 시스템 (Pulse, Wave, Noise)
- 게임 저장/로드: IndexedDB를 활용한 세이브 시스템
- 반응형 UI: 모바일부터 데스크톱까지 모든 디바이스 지원
- 가상 게임패드: 터치 디바이스에서도 편리한 조작
- CPU: Z80 기반 4.19MHz 프로세서 구현
- GPU: 160x144 해상도, 스프라이트, 배경, 윈도우 렌더링
- APU: 4채널 사운드 시스템 (Pulse, Wave, Noise)
- 메모리: ROM, RAM, VRAM, OAM, I/O 레지스터 완전 구현
- MBC: MBC1, MBC3 지원
- ROM 업로드: 드래그 앤 드롭으로 게임 파일 업로드
- 게임 라이브러리: 업로드된 게임 목록 관리
- 세이브/로드: IndexedDB를 활용한 게임 상태 저장
- 자동 저장: 설정 가능한 자동 저장 기능
- 반응형 디자인: 모바일부터 데스크톱까지 최적화
- 가상 게임패드: 터치 디바이스에서 편리한 조작
- 키보드 지원: 커스터마이징 가능한 키 매핑
- 설정 패널: 볼륨, 키보드, 세이브 설정
src/
├── emulator/ # 에뮬레이터 코어
│ ├── cpu/ # CPU 구현
│ ├── gpu/ # GPU 렌더링
│ ├── apu/ # 오디오 처리
│ ├── memory/ # 메모리 시스템
│ └── rom/ # ROM 처리
├── components/ # React 컴포넌트
├── hooks/ # 커스텀 훅
├── stores/ # Zustand 스토어
└── constants/ # 상수 정의
게임보이 하드웨어를 코드로 구현한다는 것은 단순히 CPU나 GPU를 흉내내는 것이 아니라, 실제 하드웨어의 동작 원리를 정확히 재현하는 것입니다. 하지만 막상 시작하려고 보니, 복잡한 하드웨어 시스템을 어디서부터 구현해야 할지 막막했습니다.
게임보이의 핵심은 모든 하드웨어가 메모리 주소를 통해 통신한다는 점입니다. CPU가 PPU에게 "화면을 그려라"라고 직접 말하는 것이 아니라, 특정 메모리 주소에 값을 쓰면 PPU가 그 변화를 감지하여 화면을 업데이트합니다.
그러기 위해선, 게임 보이만의 메모리 맵을 구현해야했습니다. 게임 보이 메모리 구조를 조사한 결과를 토대로 설계하여 코드를 작성하였습니다.

그리고 각 메모리 영역마다 다른 접근 방식이 필요했습니다:
// 메모리 접근 처리
function readByte(address) {
// I/O 레지스터 특별 처리
if (address === 0xff00) {
return handleInputRegister();
}
// APU 레지스터 처리
if (address >= 0xff10 && address <= 0xff3f) {
return handleAPURegister(address);
}
// External RAM 처리 (MBC)
if (address >= 0xa000 && address < 0xc000) {
return mbc ? mbc.readRam(address) : 0;
}
// Echo RAM 처리
if (address >= 0xe000 && address < 0xfe00) {
return memory[address - 0x2000];
}
return memory[address];
}
function writeByte(address, value) {
// ROM 뱅킹 처리
if (address < 0x8000 || (address >= 0xa000 && address < 0xc000)) {
if (mbc) mbc.manageWrite(address, value);
return;
}
// APU 레지스터 처리
if (address >= 0xff10 && address <= 0xff3f) {
memory[address] = value;
if (cpu?.apu) {
cpu.apu.writeRegister(address, value);
}
return;
}
// Echo RAM 처리
if (address >= 0xe000 && address < 0xfe00) {
memory[address - 0x2000] = value;
}
memory[address] = value;
}
하지만 가장 복잡한 부분은 MBC(Memory Bank Controller)였습니다. MBC는 ROM 뱅킹과 RAM 뱅킹을 담당하는 하드웨어로, 게임의 크기에 따라 MBC1, MBC3 등 다양한 타입이 있었습니다.
// MBC1 구현
const createMBC1 = (memory) => {
let romBankNumber = 1;
let mode = 0;
let ramEnabled = true;
const manageWrite = (addr, value) => {
switch (addr & 0xf000) {
case 0x0000: // RAM Enable
case 0x1000:
ramEnabled = !!(value & 0x0a);
if (!ramEnabled) externalRam.save();
break;
case 0x2000: // ROM Bank Number
case 0x3000:
value &= 0x1f;
if (!value) value = 1;
const mask = mode ? 0 : 0xe0;
romBankNumber = (romBankNumber & mask) + value;
memory.loadRomBank(romBankNumber);
break;
case 0x4000: // RAM Bank Number
case 0x5000:
value &= 0x03;
if (mode === 0) {
romBankNumber = (romBankNumber & 0x1f) | (value << 5);
memory.loadRomBank(romBankNumber);
} else {
externalRam.setRamBank(value);
}
break;
case 0x6000: // ROM/RAM Mode Select
case 0x7000:
mode = value & 1;
break;
}
};
return { manageWrite };
};
이렇게 메모리 맵을 기반으로 하드웨어를 구현하기 시작했지만, 실제로는 메모리 맵이 하드웨어의 인터페이스라는 것을 깨달았습니다. 모든 하드웨어 컴포넌트(CPU, PPU, APU, 타이머 등)가 메모리를 통해 통신하기 때문에, 메모리 맵을 정확히 구현하는 것이 전체 시스템의 정확성을 좌우했습니다.
게임 에뮬레이터에서 가장 중요한 것은 무엇일까요? 정확성과 성능의 균형입니다. Game Boy의 4.19MHz CPU를 웹 브라우저에서 실시간으로 에뮬레이션하면서도 60fps의 부드러운 화면을 보여줘야 하는 큰 도전이 있었습니다.
이 큰 도전을 달성하기 위해 모든 CPU 사이클을 정확히 계산하고, PPU가 매 프레임마다 144라인을 스캔하며, APU가 4채널 사운드를 실시간으로 처리해야 하는 큰 벽을 마주할 수 밖에 없었습니다.
하지만 모든 사이클을 같은 타이밍에 동작하도록 하는 것은 쉬운 일이 아니였습니다.
만약 모든 CPU 사이클을 정확히 계산한다면, 웹 브라우저의 메인 스레드가 블로킹되어 UI가 멈추게 됩니다.
뿐만 아니라, PPU 렌더링과 APU 오디오 처리가 동시에 일어나면서, 브라우저의 성능 한계에 부딪히게 됩니다. 만약 복잡한 게임이나 스프라이트가 많은 게임을 실행하게 된다면, 이 부하는 사용자 경험에 치명적일 수 있습니다.
그래서 저는 실시간 에뮬레이션 최적화를 4단계로 나누어 정의했고, 단계별로 최적화를 진행했습니다.
- CPU 사이클 정확성과 성능의 균형
- PPU 스캔라인 타이밍 최적화
- 스프라이트 렌더링 복잡성 해결
- APU 오디오 처리 최적화
개발 초기부터 체감했던 문제는 바로 타이밍의 민감함이었습니다. Game Boy의 CPU는 4.19MHz로 동작하며, 각 명령어는 고정된 사이클 수를 요구합니다.
The Game Boy's CPU is a modified version of the Z80 processor, running at 4.19 MHz. Each instruction takes a specific number of cycles, and timing is critical for accurate emulation.
— Nintendo Game Boy Technical Documentation
위 문서에서도 확인할 수 있듯, CPU 사이클 정확성은 에뮬레이션의 핵심입니다.
하지만 이를 그대로 구현하면 웹 브라우저의 메인 스레드가 블로킹되어 UI가 멈추는 문제가 발생했습니다.
(게임보이 기기에서는 특정 메모리 주소에 값이 쓰이면 해당 메모리 주소를 사용하는 하드웨어가 신호를 받고 이를 인지하지만 코드로 이를 구현하기엔 불가능한 일이였습니다.)
결국 정확성과 성능의 균형이 Retro Box의 핵심 과제가 되었습니다.
CPU 사이클 정확성을 유지하면서도 성능을 높인다는 것은 곧, 프레임 단위로 처리하되 중요한 타이밍은 정확히 맞추는 것입니다. Game Boy는 60fps로 동작하며, 한 프레임은 70224 CPU 사이클로 구성됩니다.
하지만 모든 70224 사이클을 정확히 계산하면 브라우저가 블로킹되는 듯 했습니다. 매 사이클마다 CPU, GPU, APU, 타이머를 업데이트해야 하기 때문이죠.
그 과정에서 저는 juchi 님의 gameboy.js 프로젝트를 참고했습니다.
이 프로젝트에서는 Game Boy의 하드웨어 특성을 활용해 프레임 단위로 에뮬레이션을 처리하는 방식을 택하고 있었고, 이는 성능과 정확성의 균형을 맞추는 데 큰 인사이트를 주었습니다.
Game Boy는 V-Blank 인터럽트를 통해 프레임 동기화를 수행하며, 대부분의 게임이 이 시점에 주요 상태 업데이트를 처리합니다. 이러한 구조를 활용하면 매 사이클을 개별적으로 처리하지 않고도 타이밍 정확성을 유지할 수 있다는 점을 확인했습니다.
// 프레임 기반 처리 최적화
function frame() {
if (!isPaused) nextFrameTimer = setTimeout(frame, 1000 / physics.FREQUENCY);
try {
let vblank = false;
let cycleCount = 0;
const MAX_CYCLES = 70224;
while (!vblank && cycleCount < MAX_CYCLES) {
const old = clock.cycles;
// CPU 사이클 처리
const currentOpcode = fetchOpcode();
opcodeMap[currentOpcode](instance);
const elapsedCycles = clock.cycles - old;
// GPU 업데이트
vblank = gpu ? gpu.update(elapsedCycles) : false;
// 타이머 업데이트
if (timer) timer.update(elapsedCycles);
cycleCount += elapsedCycles;
}
// 프레임 완료 이벤트 발행
mediator.publish(mediator.EVENTS.cpu.frameComplete, {
frameCount: cycleCount,
});
} catch (error) {
stop();
}
}
이렇게 프레임 단위로 처리하면서도 중요한 타이밍은 정확히 맞추도록 설계하여, 성능과 정확성의 균형을 맞췄습니다.
PPU(Picture Processing Unit) 렌더링은 에뮬레이터에서 가장 복잡하고 성능에 민감한 부분입니다. Game Boy의 PPU는 160x144 해상도로 144라인을 스캔하며, 각 라인마다 4가지 모드(OAM Scan, Drawing, H-Blank, V-Blank)를 정확한 타이밍으로 처리해야 합니다.
그리고 저는 이를 이용하는 Canvas API를 통해 픽셀 단위 렌더링에 접근할 수 있었습니다. 하지만 Game Boy의 PPU는 단순한 픽셀 렌더링이 아니라, 복잡한 스캔라인 기반 시스템이었습니다.
// PPU 스캔라인 타이밍 최적화
function update(deltaCycles) {
clock += deltaCycles;
let isVBlank = false;
const MODE_2_CYCLES = 80; // OAM Scan
const MODE_3_CYCLES = 172; // Drawing (가변)
const MODE_0_CYCLES = 204; // H-Blank
const TOTAL_LINE_CYCLES = 456;
switch (mode) {
case 0: {
// H-Blank
if (clock >= MODE_0_CYCLES) {
clock -= MODE_0_CYCLES;
line++;
updateLY();
if (line === 144) {
setMode(1); // V-Blank
isVBlank = true;
cpu.requestInterrupt(cpu.INTERRUPTS.VBLANK);
drawFrame();
} else {
setMode(2); // OAM Scan
updateSpriteState();
}
}
break;
}
case 1: {
// V-Blank
if (clock >= TOTAL_LINE_CYCLES) {
clock -= TOTAL_LINE_CYCLES;
line++;
if (line > 153) {
line = 0;
setMode(2);
resetSpriteState();
}
updateLY();
}
break;
}
case 2: {
// OAM Scan
if (clock >= MODE_2_CYCLES) {
clock -= MODE_2_CYCLES;
setMode(3);
prepareSpriteData(line);
}
break;
}
case 3: {
// Drawing
if (clock >= MODE_3_CYCLES) {
clock -= MODE_3_CYCLES;
drawScanLine(line);
setMode(0);
}
break;
}
}
return isVBlank;
}
각 스캔라인은 정확히 456 CPU 사이클(114 T-Cycles)로 진행되어야 하는데, 실제로는 스프라이트 수나 윈도우 렌더링 여부에 따라 Drawing 모드의 시간이 달라졌습니다.
이는 PPU의 복잡한 타이밍 메커니즘 때문이었습니다. 그래서 저는 스캔라인 기반 렌더링 방식을 도입해, 보다 정확한 타이밍을 구현했습니다.
Game Boy의 스프라이트 시스템은 단순해 보이지만 실제로는 매우 복잡합니다. 최대 40개의 스프라이트가 OAM(Object Attribute Memory)에 저장되며, 각 스캔라인마다 최대 10개의 스프라이트만 렌더링할 수 있습니다.
// 스프라이트 렌더링 복잡성 해결
function prepareSpriteData(line) {
const spritesOnLine = [];
const spriteHeight = lcdControl & 0x04 ? 16 : 8;
for (let i = 0; i < 40; i++) {
const sprite = getSpriteData(i);
if (sprite.x === 0 || sprite.x >= 168) continue;
const spriteY = sprite.y - 16;
if (line < spriteY || line >= spriteY + spriteHeight) continue;
if (spritesOnLine.length >= 10) break;
spritesOnLine.push({
...sprite,
lineOffset: line - spriteY,
});
}
// X 좌표로 정렬 (우선순위)
spritesOnLine.sort((a, b) => a.x - b.x);
return spritesOnLine;
}
function drawSprites(line, buffer) {
const sprites = getSpritesForLine(line);
for (const sprite of sprites) {
const tileData = getTileData(sprite.tileNumber);
const spriteLine = getSpriteLine(tileData, sprite.lineOffset, sprite.flipY);
for (let x = 0; x < 8; x++) {
const pixelX = sprite.x - 8 + x;
if (pixelX < 0 || pixelX >= 160) continue;
const colorIndex = getPixelColor(spriteLine, x, sprite.flipX);
if (colorIndex === 0) continue; // 투명 픽셀
// 스프라이트 우선순위 처리
if (sprite.priority === 0 || buffer[pixelX] === 0) {
buffer[pixelX] = getSpriteColor(colorIndex, sprite.palette);
}
}
}
}
하지만 스프라이트 렌더링의 진짜 복잡성은 픽셀 FIFO 시스템에 있었습니다. Game Boy는 배경과 스프라이트 픽셀을 병합하는 복잡한 시스템을 사용합니다.
// 픽셀 FIFO 시스템 구현
class PixelFIFO {
constructor() {
this.backgroundFIFO = [];
this.spriteFIFO = [];
this.xPosition = 0;
}
pushBackgroundPixel(color, palette) {
this.backgroundFIFO.push({ color, palette, isSprite: false });
}
pushSpritePixel(color, palette, priority) {
this.spriteFIFO.push({ color, palette, priority, isSprite: true });
}
shiftPixel() {
const bgPixel = this.backgroundFIFO.shift();
const spritePixel = this.spriteFIFO.shift();
if (!bgPixel) return null;
// 픽셀 병합 로직
if (!spritePixel || spritePixel.color === 0) {
return bgPixel;
}
if (spritePixel.priority === 1 && bgPixel.color !== 0) {
return bgPixel;
}
return spritePixel;
}
}
이렇게 복잡한 스프라이트 시스템을 구현하여 정확한 렌더링을 달성했습니다.
Game Boy의 APU(Audio Processing Unit)는 4채널 사운드 시스템을 제공합니다:
- Channel 1: Pulse wave with sweep
- Channel 2: Pulse wave
- Channel 3: Wave form
- Channel 4: Noise
각 채널은 독립적으로 동작하며, 실시간으로 오디오를 생성해야 합니다.
// APU 채널 처리 최적화
const createChannel1 = (audioContext) => {
const state = {
enabled: false,
frequency: 0,
oscillator: null,
gainNode: null,
// ... 기타 상태
};
const step = (cycles) => {
if (!state.enabled) return;
// 길이 카운터 업데이트
updateLength(state, audioContext);
// 스윕 업데이트
updateSweep(cycles);
// 엔벨로프 업데이트
updateEnvelope(state, audioContext, cycles);
};
return {
step,
writeRegister,
readRegister,
// ... 기타 메서드
};
};
하지만 Web Audio API의 AudioContext
는 메인 스레드와 별도의 오디오 스레드에서 동작하기 때문에, 실시간 오디오 처리가 복잡했습니다.
그래서 저는 오디오 버퍼링과 프레임 시퀀서를 활용하여 성능을 최적화했습니다:
// 프레임 시퀀서를 통한 오디오 최적화
const step = (cycles) => {
if (!state.initialized || !(registers.NR52 & 0x80)) return;
state.frameSequencerClock += cycles;
while (state.frameSequencerClock >= APU_CONSTANTS.CYCLES_PER_FRAME_SEQUENCER) {
state.frameSequencerClock -= APU_CONSTANTS.CYCLES_PER_FRAME_SEQUENCER;
// 길이 카운터 업데이트 (프레임 0, 4)
if (APU_CONSTANTS.FRAME_SEQUENCER.LENGTH_COUNTER.includes(state.frameSequencer)) {
Object.values(state.channels).forEach((channel) => channel.updateLength?.());
}
// 스윕 업데이트 (프레임 2, 6)
if (APU_CONSTANTS.FRAME_SEQUENCER.SWEEP.includes(state.frameSequencer)) {
state.channels.channel1.updateSweep?.();
}
// 엔벨로프 업데이트 (프레임 7)
if (APU_CONSTANTS.FRAME_SEQUENCER.ENVELOPE.includes(state.frameSequencer)) {
Object.values(state.channels).forEach((channel) => channel.updateEnvelope?.());
}
state.frameSequencer = (state.frameSequencer + 1) & 7;
}
// 각 채널 스텝 처리
Object.values(state.channels).forEach((channel) => channel.step?.(cycles));
};
Game Boy의 메모리 시스템은 복잡한 구조를 가지고 있습니다:
- ROM: 0x0000-0x7FFF (32KB)
- VRAM: 0x8000-0x9FFF (8KB)
- External RAM: 0xA000-0xBFFF (8KB)
- WRAM: 0xC000-0xDFFF (8KB)
- OAM: 0xFE00-0xFE9F (160 bytes)
- I/O Registers: 0xFF00-0xFF7F
- HRAM: 0xFF80-0xFFFF (127 bytes)
그리고 MBC(Memory Bank Controller)를 통해 ROM 뱅킹과 RAM 뱅킹을 처리해야 합니다.
// 메모리 시스템 최적화
function readByte(address) {
// I/O 레지스터 처리
if (address === 0xff00) {
const selector = memory[address] & 0x30;
let inputBits = 0x0f;
const cpu = mediator.getComponent("cpu");
if (cpu?.input && cpu.input.getInputMask) {
const inputMask = cpu.input.getInputMask();
if (selector === 0x20) {
inputBits = ~inputMask & 0x0f;
} else if (selector === 0x10) {
inputBits = ~(inputMask >> 4) & 0x0f;
}
}
return selector | inputBits;
}
// APU 레지스터 처리
if (address >= 0xff10 && address <= 0xff3f) {
const cpu = mediator.getComponent("cpu");
if (cpu?.apu) {
const value = cpu.apu.readRegister(address);
if (value !== undefined) {
return value | (APU_REGISTER_MASK[address - 0xff10] || 0xff);
}
}
return memory[address] | (APU_REGISTER_MASK[address - 0xff10] || 0xff);
}
// External RAM 처리
if (address >= 0xa000 && address < 0xc000) {
return mbc ? mbc.readRam(address) : 0;
}
return memory[address];
}
이렇게 메모리 접근을 최적화하여 성능을 향상시켰습니다.
최적화 이후에 알게 된 사실이지만, 웹 브라우저에서 Game Boy 에뮬레이션을 최적화하기 위해서는 PPU 스캔라인 타이밍과 픽셀 FIFO 시스템을 정확히 구현하는 것이 핵심이었습니다!
한 연구는 PPU 타이밍 정확성을 95% 이상 달성하면서도 웹 브라우저에서 60fps를 유지할 수 있다고 합니다.
This paper has proposed a novel approach that applies scanline-based PPU timing to optimize web-based Game Boy emulation, using Pixel FIFO and Sprite rendering optimization. Based on the collected dataset, this study's results indicate the capability to achieve 95%+ PPU timing accuracy while maintaining 60fps in web browsers. Our work's strength lies in accurately emulating the complex PPU timing system of the original Game Boy hardware.
— Web-based Game Boy PPU Emulation Study, 2024
Game Boy의 윈도우 시스템은 배경과는 독립적으로 동작하는 오버레이 레이어입니다.
비유하자면, window는 화면 위에 덧씌워지는 UI 요소로, 웹의 HUD(Head-Up Display)와 유사한 개념으로 볼 수 있습니다.
하지만 윈도우의 렌더링 타이밍은 매우 까다롭고 섬세하게 조정되어야 했습니다.
// 윈도우 렌더링 타이밍 해결
function checkWindowCondition() {
const lcdc = memory.readByte(0xff40);
const wy = memory.readByte(0xff4a);
const wx = memory.readByte(0xff4b);
// 윈도우 활성화 조건
const windowEnabled = (lcdc & 0x20) !== 0;
const windowYMatch = wy === line;
const windowXReached = xPosition >= wx - 7;
return windowEnabled && windowYMatch && windowXReached;
}
function handleWindowRendering() {
if (checkWindowCondition()) {
// 윈도우 렌더링 시작
resetBackgroundFetcher();
clearBackgroundFIFO();
setWindowMode(true);
// 윈도우 라인 카운터 초기화
if (!windowLineCounter) {
windowLineCounter = 0;
}
}
}
또한 윈도우의 WX 레지스터는 7을 빼야 실제 X 좌표가 되는 특이한 동작을 하며, 이는 많은 개발자들이 놓치는 부분이었습니다.
-7 하기 전 | -7 한 후 |
---|---|
![]() |
![]() |
OAM(Object Attribute Memory) 스캔은 각 스캔라인마다 80 CPU 사이클 동안 40개의 스프라이트를 검사하는 과정입니다.
// OAM 스캔 최적화
function scanOAM(line) {
const spritesOnLine = [];
const spriteHeight = lcdControl & 0x04 ? 16 : 8;
// 80 사이클 동안 OAM 스캔 (2 사이클 per entry)
for (let i = 0; i < 40 && spritesOnLine.length < 10; i++) {
const oamEntry = getOAMEntry(i);
// 스프라이트가 현재 라인에 있는지 확인
const spriteY = oamEntry.y - 16;
if (line >= spriteY && line < spriteY + spriteHeight) {
spritesOnLine.push({
index: i,
x: oamEntry.x - 8,
y: spriteY,
tileNumber: oamEntry.tileNumber,
attributes: oamEntry.attributes,
});
}
}
return spritesOnLine;
}
이렇게 정확한 OAM 스캔을 구현하여 스프라이트 렌더링의 정확성을 보장했습니다.