-
Notifications
You must be signed in to change notification settings - Fork 1
Video Rendering
This page describes the video rendering subsystem in detail, covering all six Apple IIe display modes, the per-scanline progressive rendering pipeline, NTSC artifact color generation, character ROM handling, screen address calculation, and the change-log based mid-frame mode-switching system.
- Overview
- Framebuffer Layout
- Video Modes
- Mixed Mode
- Color Palettes
- Character ROM
- Screen Address Calculation
- Per-Scanline Progressive Rendering
- Frame Lifecycle
- Video Soft Switches
- Display Pipeline
- WebGL Shader Pipeline
- Display Settings Integration
- See Also
The Video class (src/core/video/video.cpp) renders the Apple IIe display into a 560x384 RGBA framebuffer. Rendering is progressive: as the CPU executes instructions, completed scanlines are rendered on-the-fly by renderUpToCycle(). At the end of each frame, renderFrame() finishes any remaining scanlines.
The renderer supports all six Apple IIe video modes:
| Mode | Resolution | Colors | Memory Used |
|---|---|---|---|
| TEXT_40 | 40x24 characters | 2 (FG/BG) | $0400-$07FF main |
| TEXT_80 | 80x24 characters | 2 (FG/BG) | $0400-$07FF main + aux |
| LORES | 40x48 color blocks | 16 | $0400-$07FF main |
| HIRES | 280x192 pixels | 6 (artifact) | $2000-$3FFF main |
| DOUBLE_LORES | 80x48 color blocks | 16 | $0400-$07FF main + aux |
| DOUBLE_HIRES | 560x192 pixels | 16 | $2000-$3FFF main + aux |
All rendering is performed in C++ and exposed to JavaScript through the WASM framebuffer pointer. The JavaScript layer then uploads the framebuffer as a WebGL texture and applies CRT shader effects.
The framebuffer is a flat RGBA byte array stored in std::array<uint8_t, FRAMEBUFFER_SIZE>:
| Constant | Value | Description |
|---|---|---|
SCREEN_WIDTH |
560 | Pixels per row (280 x 2) |
SCREEN_HEIGHT |
384 | Pixel rows (192 x 2) |
FRAMEBUFFER_SIZE |
860,160 | Total bytes (560 x 384 x 4) |
Each Apple II pixel is doubled in both dimensions. A single Apple II dot at position (x, y) maps to a 2x2 block in the framebuffer. The setPixel() helper writes individual framebuffer pixels:
offset = (y * SCREEN_WIDTH + x) * 4
framebuffer[offset + 0] = R (bits 16-23 of color)
framebuffer[offset + 1] = G (bits 8-15 of color)
framebuffer[offset + 2] = B (bits 0-7 of color)
framebuffer[offset + 3] = A (bits 24-31 of color)
Colors are stored internally as packed 32-bit 0xAARRGGBB values and unpacked into RGBA component order at write time.
The current video mode is determined by getCurrentMode() examining soft switch state:
if TEXT -> col80 ? TEXT_80 : TEXT_40
if HIRES -> (col80 && !AN3) ? DOUBLE_HIRES : HIRES
else (LORES) -> (col80 && !AN3) ? DOUBLE_LORES : LORES
The key switches involved:
| Switch | Address | Effect |
|---|---|---|
| TEXT | $C050/$C051 | Graphics / Text mode |
| MIXED | $C052/$C053 | Full screen / Mixed (4 text lines at bottom) |
| PAGE2 | $C054/$C055 | Display page 1 / page 2 |
| HIRES | $C056/$C057 | Lo-Res / Hi-Res |
| 80COL | $C00C/$C00D | 40-column / 80-column |
| AN3 | $C05E/$C05F | Annunciator 3 (enables double-width modes when OFF) |
| 80STORE | $C000/$C001 | Redirects PAGE2 to aux memory bank switching |
Double-resolution modes (DOUBLE_LORES, DOUBLE_HIRES) require both col80 on and an3 off.
Renders 40 columns by 24 rows of characters from the 40-column text page. Each character is 7 ROM pixels wide, doubled to 14 framebuffer pixels. Characters are 8 scanlines tall, doubled to 16 framebuffer rows.
Memory layout: Text page 1 at $0400-$07FF, page 2 at $0800-$0BFF. Page selection respects the 80STORE switch -- when 80STORE is on, PAGE2 is ignored for page selection (it is used for auxiliary memory bank switching instead).
Rendering path (renderText40Scanline):
- Compute
textRow(0-23) andcharLine(0-7) from the scanline number (scanline / 8andscanline % 8) - For each column (0-39), read the character byte from the text page address
- Determine inverse/flash status from the top two bits of the character byte
- Look up the character glyph row in the character ROM via
getCharROMInfo()andrenderCharacterLine() - Render 7 dots, each doubled to 2 framebuffer pixels horizontally, and output on 2 framebuffer rows vertically
Renders 80 columns by 24 rows using interleaved main and auxiliary memory. Each character is 7 pixels wide (no horizontal doubling), totaling 560 pixels across.
Memory interleaving: For each memory column position (0-39), the auxiliary byte provides the even (left) display column and the main byte provides the odd (right) display column. Both are read from the same text page address but from different memory banks.
Rendering path (renderText80Scanline):
- For each memory column (0-39), read the aux byte and main byte at the same text page address
- Aux character renders at display column
col * 2(even position, 7 pixels) - Main character renders at display column
col * 2 + 1(odd position, 7 pixels) - Each character is 7 framebuffer pixels wide -- no horizontal doubling is needed since 80 characters x 7 pixels = 560
Renders 40x48 color blocks using text page memory. Each byte encodes two vertically stacked 4-bit color indices.
Color block structure: Each byte in the text page represents two blocks stacked vertically within an 8-scanline text row. The low nibble (bits 0-3) is the top block color (scanlines 0-3 within the row) and the high nibble (bits 4-7) is the bottom block color (scanlines 4-7).
Rendering path (renderLoResScanline):
- Compute
textRow(0-23) andlineInRow(0-7) from the scanline - Select low nibble if
lineInRow < 4, high nibble otherwise - Look up the 16-color palette via
getLoResColor() - Fill a 14x2 framebuffer pixel block (7 Apple II dots doubled to 14 pixels wide, scanline doubled to 2 rows)
Renders 280x192 pixels with NTSC artifact color. Each byte encodes 7 dots plus a high bit that shifts the color phase.
Memory layout: Hi-Res page 1 at $2000-$3FFF, page 2 at $4000-$5FFF. The address mapping is interleaved -- see Screen Address Calculation.
Byte format:
| Bit | Purpose |
|---|---|
| 0-6 | 7 pixel dots (bit 0 = leftmost) |
| 7 | High bit (shifts NTSC color phase by half a color clock) |
Rendering path (renderHiResScanline):
- Build a 280-element
dots[]array and a correspondinghighBits[]array for the visible column range - For monochrome mode, each dot maps directly to on/off color in a 2x2 pixel block
- For color mode, each dot's color is determined by NTSC artifact rules:
NTSC artifact color rules (evaluated left to right for each dot):
| Condition | Color |
|---|---|
| Dot off, flanked by two single on-dots with matching high bits | Artifact color fill (fringing) |
| Dot off otherwise | Black |
| Dot on with adjacent on-dot (left or right) | White |
| Single dot on, even column, high bit 0 | Violet |
| Single dot on, odd column, high bit 0 | Green |
| Single dot on, even column, high bit 1 | Blue |
| Single dot on, odd column, high bit 1 | Orange |
The artifact fringing case handles the situation where a dot between two isolated on-dots takes on the neighboring dot's color, simulating how NTSC chroma bleeds across adjacent pixels on real hardware.
Renders 80x48 color blocks using interleaved main and auxiliary text page memory. Each memory column produces two display columns (aux on the left, main on the right), each 7 framebuffer pixels wide.
Aux nibble rotation: Auxiliary memory nibbles require a 1-bit left rotation within 4 bits to compensate for the half-color-clock phase shift between auxiliary and main video memory timing:
auxColor = ((auxNibble << 1) & 0x0F) | (auxNibble >> 3)
Without this rotation, auxiliary colors would display with incorrect NTSC phase, producing the wrong colors on screen.
Rendering path (renderDoubleLoResScanline):
- Read aux and main bytes at the same text page address
- Extract the appropriate nibble (low nibble if
lineInRow < 4, high nibble otherwise) - Rotate the aux nibble by 1 bit left within 4 bits
- Render aux color in the left 7 framebuffer pixels, main color in the right 7 pixels
- Uses the
LORES_COLORSpalette for both halves
Renders 560x192 pixels using interleaved main and auxiliary Hi-Res memory. Each pair of bytes (aux + main) provides 14 dots that map to four 4-bit color values.
Memory interleaving: For each column address, the aux byte comes first (providing 7 dots), followed by the main byte (7 more dots), giving 14 dots per column pair. This produces 80 bytes per scanline (40 aux + 40 main).
Byte layout:
Byte 0 (aux col 0): 7 dots -> framebuffer dots 0-6
Byte 1 (main col 0): 7 dots -> framebuffer dots 7-13
Byte 2 (aux col 1): 7 dots -> framebuffer dots 14-20
Byte 3 (main col 1): 7 dots -> framebuffer dots 21-27
...
Color encoding: Every group of 4 consecutive dots forms a 4-bit color index into the 16-color Double Hi-Res palette. The bit ordering within each group is:
colorIndex = (dot[base] << 3) | (dot[base+1] << 2) | (dot[base+2] << 1) | dot[base+3]
The 4-dot groups are aligned to absolute dot positions ((i / 4) * 4), meaning all dots within a group share the same color. Since 560 dots / 4 dots-per-group = 140 color cells, Double Hi-Res provides an effective 140x192 pixel color display at 16 colors.
Rendering path (renderDoubleHiResScanline):
- Read all 80 bytes for the scanline into a linear
line[]array (interleaved aux/main) - Extract 560 individual dots from the 7 data bits of each byte
- For each dot, compute its aligned 4-dot group base and derive the 4-bit color index
- Look up the color in the
DHGR_COLORSpalette - Each dot maps to a single framebuffer pixel horizontally, doubled vertically to 2 rows
When the MIXED switch is on ($C053), the bottom 4 text rows (scanlines 160-191) always render as text, regardless of the current graphics mode. The upper portion (scanlines 0-159) renders in the selected graphics mode.
The dispatch logic in renderScanlineSegment() checks:
if (mixed && scanline >= 160 && !text) {
render as text (40-col or 80-col based on col80 switch)
return
}
Mixed mode works with all four graphics modes: LORES, HIRES, DOUBLE_LORES, and DOUBLE_HIRES all show text in the bottom 32 scanlines when mixed mode is active.
The 16-color Lo-Res palette (LORES_COLORS in types.hpp) provides NTSC-accurate colors:
| Index | Name | RGB |
|---|---|---|
| 0 | Black | #000000 |
| 1 | Magenta | #E31E60 |
| 2 | Dark Blue | #604EBD |
| 3 | Purple | #FF44FD |
| 4 | Dark Green | #00A360 |
| 5 | Grey 1 | #9C9C9C |
| 6 | Medium Blue | #14CFFD |
| 7 | Light Blue | #D0C3FF |
| 8 | Brown | #607203 |
| 9 | Orange | #FF6A3C |
| 10 | Grey 2 | #9C9C9C |
| 11 | Pink | #FFA0D0 |
| 12 | Light Green | #14F53C |
| 13 | Yellow | #D0DD8D |
| 14 | Aqua | #72FFD0 |
| 15 | White | #FFFFFF |
Indices 5 and 10 are both grey but appear at different positions in the NTSC chroma cycle. This palette is also used for Double Lo-Res mode.
Hi-Res mode uses a 6-entry color table (HIRES_COLORS in types.hpp) that models NTSC artifact coloring:
| Index | Name | Usage |
|---|---|---|
| 0 | Black | Off dots |
| 1 | Green (#14F53C) |
Odd pixels, high bit = 0 |
| 2 | Violet (#FF44FD) |
Even pixels, high bit = 0 |
| 3 | White (#FFFFFF) |
Adjacent on-dots |
| 4 | Blue (#14CFFD) |
Even pixels, high bit = 1 |
| 5 | Orange (#FF6A3C) |
Odd pixels, high bit = 1 |
The high bit (bit 7) of each byte selects between color group 1 (Violet/Green) and color group 2 (Blue/Orange). Two adjacent on-dots always produce white regardless of group.
Double Hi-Res uses its own 16-color palette (DHGR_COLORS, defined locally in renderDoubleHiResScanline()). The ordering differs from Lo-Res due to the 14 MHz dot rate versus 7 MHz, which changes the NTSC phase relationship:
| Index | Color | Lo-Res Equivalent |
|---|---|---|
| 0 | Black | LORES[0] |
| 1 | Magenta | LORES[1] |
| 2 | Brown | LORES[8] |
| 3 | Orange | LORES[9] |
| 4 | Dark Green | LORES[4] |
| 5 | Grey | LORES[5] |
| 6 | Light Green | LORES[12] |
| 7 | Yellow | LORES[13] |
| 8 | Dark Blue | LORES[2] |
| 9 | Purple | LORES[3] |
| 10 | Grey | LORES[10] |
| 11 | Light Blue | LORES[7] |
| 12 | Medium Blue | LORES[6] |
| 13 | Pink | LORES[11] |
| 14 | Aqua | LORES[14] |
| 15 | White | LORES[15] |
All modes support monochrome rendering via setMonochrome(true). When enabled, getMonochromeColor() returns:
-
On pixels: White (
0xFFFFFFFF) or green phosphor (0xFF33FF33) ifgreenPhosphor_is set -
Off pixels: Black (
0xFF000000)
Lo-Res monochrome treats any non-zero color index as "on." Hi-Res and Double Hi-Res monochrome render individual dots as on/off.
The character ROM is 8 KB total, containing two 4 KB character sets (US and UK). Each character is 8 bytes (one byte per scanline row), with each byte encoding 7 dots. Bit 0 is the leftmost pixel.
Character display attributes are determined by the top two bits of the character byte stored in text page memory:
| Bits 7-6 | Byte Range | Display |
|---|---|---|
00 |
$00-$3F | Inverse (foreground and background swapped) |
01 |
$40-$7F | Flash (toggles between normal and inverse at ~3.75 Hz) |
10-11
|
$80-$FF | Normal |
The getCharROMInfo() helper method decodes the character byte and returns a CharROMInfo struct containing the ROM offset, whether XOR bit-flipping is needed, and the final inverse state.
With the primary character set active (ALTCHARSET off), the character ROM index is derived as follows:
| Byte Range | ROM Index | Display Style |
|---|---|---|
| $00-$1F |
ch (0-31) |
Inverse |
| $20-$3F |
ch (32-63) |
Inverse |
| $40-$5F |
ch & 0x1F (0-31) |
Flash |
| $60-$7F |
(ch & 0x1F) + 32 (32-63) |
Flash |
| $80-$9F |
ch & 0x1F (0-31) |
Normal |
| $A0-$BF |
(ch & 0x1F) + 32 (32-63) |
Normal |
| $C0-$DF |
ch & 0x1F (0-31) |
Normal |
| $E0-$FF |
(ch & 0x1F) + 96 (96-127) |
Normal |
The ROM offset for each character is charIndex * 8.
When the ALTCHARSET switch is on ($C00F), the mapping changes significantly to provide MouseText characters:
| Byte Range | ROM Index | Rendering |
|---|---|---|
| $00-$3F | ch |
Inverse (no XOR) |
| $40-$5F | ch |
XOR rendering, not inverse (MouseText) |
| $60-$7F | ch |
XOR rendering (MouseText) |
| $80-$FF | Mapped via ranges | Normal (no XOR, no inverse) |
In the alternate set, flash behavior is disabled. Characters in the $40-$7F range display MouseText glyphs instead of flashing. The XOR flag means the ROM data bits are inverted (rowData ^= 0xFF) before rendering.
Setting ukCharSet_ to true adds a 0x1000 (4096) byte offset to the character ROM address, selecting the second 4 KB half of the ROM. This models the physical character set switch on UK Apple IIe machines, which replaces certain ASCII characters (notably # with the pound sterling symbol).
Text and Lo-Res modes share the same address calculation. The base address for page 1 is $0400:
address = $0400 + TEXT_ROW_OFFSETS[row] + col
Page 2 adds $0400 to the address (giving base $0800). For 80-column and Double Lo-Res modes, both main and auxiliary memory are read at the same address.
Hi-Res addresses are computed from three components:
block = row / 8 (which 8-line group: 0-23)
line = row % 8 (line within the group: 0-7)
address = $2000 + TEXT_ROW_OFFSETS[block] + line * $400 + col
This interleaved layout means consecutive scanlines are not at consecutive memory addresses. Lines within an 8-line group are spaced $400 (1024 bytes) apart. Page 2 uses $4000 as the base instead of $2000.
The TEXT_ROW_OFFSETS lookup table provides the base offset for each of the 24 text rows (or 8-line blocks in Hi-Res mode):
| Rows 0-7 | Rows 8-15 | Rows 16-23 |
|---|---|---|
$000 |
$028 |
$050 |
$080 |
$0A8 |
$0D0 |
$100 |
$128 |
$150 |
$180 |
$1A8 |
$1D0 |
$200 |
$228 |
$250 |
$280 |
$2A8 |
$2D0 |
$300 |
$328 |
$350 |
$380 |
$3A8 |
$3D0 |
This non-linear layout is a consequence of the original Apple II hardware design, which used a simple counter with specific bit-wiring to generate addresses for both the text and Hi-Res display circuitry.
Each of the 262 scanlines per frame takes exactly 65 CPU cycles:
| Cycles | Purpose |
|---|---|
| 0-24 | Horizontal blanking (25 cycles) |
| 25-64 | Visible display (40 cycles, one byte column per cycle) |
Only the first 192 scanlines contain visible display data. Scanlines 192-261 are vertical blanking. The constants are defined in types.hpp:
| Constant | Value |
|---|---|
CYCLES_PER_SCANLINE |
65 |
SCANLINES_PER_FRAME |
262 |
CYCLES_PER_FRAME |
17,030 |
The Video class maintains a change log of video switch modifications during each frame. When the MMU detects a write to a video-relevant soft switch, it calls onVideoSwitchChanged(), which:
- Captures the full
VideoSwitchStatefrom the MMU's current soft switch state - Compares against the last logged state (or
frameStartState_if the log is empty) to avoid redundant entries - Records the state snapshot along with its cycle offset from the frame start
- Stores up to
MAX_SWITCH_CHANGES(1024) entries per frame; excess changes are silently dropped
The VideoSwitchState struct captures exactly the 8 switches that affect rendering:
| Field | Switch | Purpose |
|---|---|---|
text |
TEXT | Text vs. graphics mode |
mixed |
MIXED | Bottom 4 rows forced to text |
page2 |
PAGE2 | Display page selection |
hires |
HIRES | Hi-Res vs. Lo-Res |
col80 |
80COL | 80-column / double-width enable |
altCharSet |
ALTCHARSET | Alternate character set (MouseText) |
store80 |
80STORE | PAGE2 redirected to bank switch |
an3 |
AN3 | Double-resolution enable |
When logging a switch change, the cycle offset includes a +2 cycle adjustment to model the Apple IIe's two-stage video pipeline:
-
Phi-0/Phi-1 bus phasing: The video hardware fetches memory on Phi-0 (first half of each clock cycle) before the CPU writes on Phi-1 (second half). A soft switch change on cycle N misses that cycle's video fetch.
-
Shift register latching: The byte fetched on Phi-0 of cycle N+1 is loaded into the shift register and does not produce visible dots until approximately cycle N+2.
Combined: a CPU write on cycle N affects display output at cycle N+2. This delay is essential for accurate emulation of demo effects that rely on precise switch timing.
renderUpToCycle(currentCycle) is called after each CPU instruction executes. It renders all scanlines whose 65 cycles are fully complete:
- Calculate
completedScanlines = frameCycle / CYCLES_PER_SCANLINE - Target the last fully-elapsed scanline (
completedScanlines - 1), capping at 191 - Render each unrendered scanline sequentially via
renderScanlineWithChanges() - Track progress with
lastRenderedScanline_(initialized to -1 at frame start)
Rendering one scanline behind the current execution point ensures all CPU writes during a scanline are captured before video memory is read for that scanline. This is critical for raster bar effects and other mid-frame tricks that modify video memory or switches just before the beam reaches them.
renderScanlineWithChanges() processes a single scanline in two phases using the switch change log:
Phase 1 -- HBLANK changes (cycles 0-24): Consume all switch changes occurring during horizontal blanking by advancing changeIdx_ through the log and updating currentRenderState_. No pixels are rendered during hblank.
Phase 2 -- Visible area (cycles 25-64, mapped to columns 0-39): Walk the remaining changes within the visible area. Each change splits the scanline into segments:
col = 0
For each change in the visible area:
changeCol = changeCycle - visibleStartCycle
Render columns [col, changeCol) with currentRenderState_
Update currentRenderState_ from the change log entry
col = changeCol
Render remaining columns [col, 40) with final currentRenderState_
This segment-based approach allows mid-scanline mode switches -- a program can change from text to Hi-Res partway across a scanline, and both modes will render correctly on their respective sides of the switch point.
renderScanlineSegment() dispatches a column range [startCol, endCol) on a single scanline to the correct mode-specific renderer based on the current VideoSwitchState:
- If
scanline >= 192or empty range, return immediately - If
mixedandscanline >= 160and not in text mode, render as text (40-col or 80-col) - If
text, dispatch torenderText40Scanline()orrenderText80Scanline() - If
hires, dispatch torenderDoubleHiResScanline()(ifcol80 && !an3) orrenderHiResScanline() - Otherwise dispatch to
renderDoubleLoResScanline()(ifcol80 && !an3) orrenderLoResScanline()
Frame boundaries are managed by the Emulator class (see Architecture-Overview). At each frame boundary:
-
renderFrame()is called to finalize the current frame:- Increment the flash counter and toggle flash state if threshold reached
- Render any remaining unrendered scanlines (progressive rendering usually handles most by this point)
- Set
frameDirty_ = trueto signal that the framebuffer is ready for upload
-
beginNewFrame(cycleStart)resets state for the next frame:- Save the frame start cycle (
frameStartCycle_) - Capture the initial video switch state (
frameStartState_) - Clear the switch change log (
switchChangeCount_ = 0) - Reset
lastRenderedScanline_to -1 - Reset
changeIdx_to 0 - Set
currentRenderState_to the frame start state
- Save the frame start cycle (
Text characters in the flash range ($40-$7F in the primary character set) toggle between normal and inverse display. The flash state toggles every FLASH_RATE (16) frames, producing approximately 3.75 Hz blinking at 60 fps.
Flash only applies when using the primary character set. The alternate character set disables flash and displays MouseText glyphs in the $40-$7F range instead.
forceRenderFrame() re-renders the entire screen from current memory state in a single pass, ignoring the progressive rendering pipeline and change log. It captures the current video switch state and renders all 192 scanlines with that uniform state. This is used by the debugger for screen refresh after stepping instructions, where the progressive rendering state may be stale.
Video-related soft switches and their addresses:
| Address | Function | Type |
|---|---|---|
| $C050 | Graphics mode | Write |
| $C051 | Text mode | Write |
| $C052 | Full-screen (no mixed) | Write |
| $C053 | Mixed mode (4 text lines at bottom) | Write |
| $C054 | Display page 1 | Write |
| $C055 | Display page 2 | Write |
| $C056 | Lo-Res mode | Write |
| $C057 | Hi-Res mode | Write |
| $C05E | Annunciator 3 ON (disables double-res) | Write |
| $C05F | Annunciator 3 OFF (enables double-res) | Write |
| $C00C | 40-column mode | Write |
| $C00D | 80-column mode | Write |
| $C00E | Primary character set | Write |
| $C00F | Alternate character set (MouseText) | Write |
| $C000 | 80STORE off | Write |
| $C001 | 80STORE on | Write |
| $C019 | VBL status (bit 7: 1 = not in VBL) | Read |
| $C01A | TEXT status | Read |
| $C01B | MIXED status | Read |
| $C01C | PAGE2 status | Read |
| $C01D | HIRES status | Read |
| $C01E | ALTCHARSET status | Read |
| $C01F | 80COL status | Read |
See Memory-System for the full soft switch reference including memory banking switches.
After the C++ Video class produces the framebuffer, the JavaScript layer handles display:
C++ Video (560x384 RGBA framebuffer)
--> WASM _getFramebuffer() returns pointer into WASM heap
--> Worker writes it into a shared framebuffer slot (SharedArrayBuffer)
--> main thread claims it with Atomics.exchange in pollSharedFrame()
--> WebGLRenderer.updateTexture(data)
--> Fragment shader applies CRT effects
--> Canvas displays final output
Frame display is triggered by the audio-driven timing system. When enough audio samples have been generated to represent one frame (~800 samples at 48 kHz / 60 Hz), the AudioDriver fires onFrameReady, which uploads the latest framebuffer to the WebGL texture. See Architecture-Overview for details on the audio-driven timing chain.
The JavaScript renderer (src/js/display/webgl-renderer.js) uploads the completed framebuffer to a WebGL texture each frame, then applies a multi-pass shader pipeline:
The main rendering pass applies CRT and analog display effects in a single draw call:
- Screen curvature (barrel distortion)
- Scanlines and shadow mask
- Phosphor glow (bloom)
- Vignette
- Chromatic aberration (RGB offset)
- Flicker and static noise
- Jitter and horizontal sync distortion
- Glowing scan beam line
- Ambient light reflection
- Color bleed (vertical inter-scanline blending)
- NTSC fringing
- Monochrome phosphor tinting (green, amber, or white)
- Screen border and rounded corners
All parameters are passed as WebGL uniforms and update in real time.
A double-buffered framebuffer pair accumulates bright pixel values and decays them over time. The resulting burn-in texture is sampled by the main CRT shader to blend phosphor persistence into the display.
A final compositing pass draws a subtle highlight along the screen border, simulating light reflecting off CRT glass edges.
- Source texture: 560 x 384 RGBA, updated from WASM memory each frame
- Filtering: Bilinear by default; nearest-neighbor when Sharp Pixels is enabled
- Selection overlay: A separate texture for text selection highlighting, composited in the CRT shader
- Burn-in textures: Two framebuffer-sized textures ping-ponged for temporal accumulation
All shader parameters are exposed through the Display-Settings window. Settings are stored in localStorage as JSON and applied at startup. The WebGLRenderer.setParam(name, value) method maps setting names to uniform locations, allowing real-time adjustment as the user drags sliders.
- Architecture-Overview -- Two-layer design, audio-driven timing, frame synchronization
- CPU-Emulation -- 65C02 processor driving the rendering timing
- Memory-System -- MMU, soft switches, auxiliary memory bank switching
- Display-Settings -- CRT shader effect configuration