ESP32-CAM + ILI9341 Live Preview is a runnable microcontroller simulation example on SensBench — open the wired circuit, inspect the code, and compile real firmware in your browser, no install needed. Last updated Aug 14, 2026.

FREE SIMULATOR EXAMPLE · INTERMEDIATE

ESP32-CAM + ILI9341 Live Preview

This example decodes live webcam frames on the ESP32-CAM and paints them on a 320×240 ILI9341 TFT as a 160×120 preview centered at pixel (80, 60). A status bar above the image tracks fps, frame counter, decode failures and null frame buffers. Click Camera in the canvas header, grant permission, and your webcam feed appears on the simulated display.

Board ESP32-CAM (Xtensa LX6, 240 MHz) + OV2640 camera
Difficulty intermediate
Category displays
Libraries Adafruit GFX Library, Adafruit BusIO, Adafruit ILI9341
Runs in Sensbench browser simulator — free, no account
ESP32-CAM with an ILI9341 320x240 TFT wired to VSPI pins 12-15 showing a live webcam preview in the Sensbench simulator

The exact circuit you get when you open this example — already wired, ready to run.

The sketch

// ESP32-CAM live webcam preview on ILI9341 320×240 TFT
// Click "Camera" in the canvas header → grant permission → see your
// face on the TFT.
//
// Wiring (already done by this example's diagram):
//   ILI9341 ↔ ESP32-CAM
//   CS  → 15   RST → 2    D/C → 14
//   MOSI→ 13   SCK → 12   MISO unused

#include "esp_camera.h"
#include "img_converters.h"   // jpg2rgb565 — built into esp32-camera
#include <SPI.h>
#include <Adafruit_GFX.h>
#include <Adafruit_ILI9341.h>

// Camera pins (AI-Thinker)
#define PWDN_GPIO_NUM     32
#define RESET_GPIO_NUM    -1
#define XCLK_GPIO_NUM      0
#define SIOD_GPIO_NUM     26
#define SIOC_GPIO_NUM     27
#define Y9_GPIO_NUM       35
#define Y8_GPIO_NUM       34
#define Y7_GPIO_NUM       39
#define Y6_GPIO_NUM       36
#define Y5_GPIO_NUM       21
#define Y4_GPIO_NUM       19
#define Y3_GPIO_NUM       18
#define Y2_GPIO_NUM        5
#define VSYNC_GPIO_NUM    25
#define HREF_GPIO_NUM     23
#define PCLK_GPIO_NUM     22

// TFT pins
#define TFT_CS    15
#define TFT_DC    14
#define TFT_RST    2
#define TFT_MOSI  13
#define TFT_SCK   12

SPIClass tftSPI(VSPI);
Adafruit_ILI9341 tft = Adafruit_ILI9341(&tftSPI, TFT_DC, TFT_CS, TFT_RST);

// 160×120 preview centered in the 320×240 TFT. After the worker
// added batched spi_batch WS messages, transferring 38 KB/frame is
// no longer the dominant cost in the emulator.
#define PREVIEW_W 160
#define PREVIEW_H 120
#define PREVIEW_X  80
#define PREVIEW_Y  60
static uint8_t rgbBuf[PREVIEW_W * PREVIEW_H * 2];

#define STATUS_REFRESH_EVERY 5

uint32_t frame_count = 0, decode_fails = 0, null_fb = 0;
unsigned long start_ms = 0;

void draw_status_bar(uint32_t bytes_in, bool decoded) {
  tft.fillRect(0, 0, 320, PREVIEW_Y, ILI9341_BLACK);
  tft.setTextSize(1);
  tft.setTextColor(ILI9341_WHITE, ILI9341_BLACK);
  tft.setCursor(8, 6);
  tft.print("SENSBENCH ESP32-CAM live preview");
  tft.setCursor(8, 22);
  tft.printf("frame %4u   %4u B   %s",
             (unsigned)frame_count, (unsigned)bytes_in,
             decoded ? "decode OK   " : "decode FAIL ");
  unsigned long elapsed = millis() - start_ms;
  float fps = elapsed > 0 ? (1000.0f * frame_count) / (float)elapsed : 0.0f;
  tft.setCursor(8, 38);
  tft.printf("fps %4.1f   fails %u   nulls %u",
             fps, (unsigned)decode_fails, (unsigned)null_fb);
  tft.fillCircle(308, 14, 6,
    (frame_count & 1) ? ILI9341_GREEN : ILI9341_DARKGREEN);
}

void setup() {
  Serial.begin(115200);
  delay(500);
  Serial.println("=== ESP32-CAM + ILI9341 live preview ===");

  tftSPI.begin(TFT_SCK, -1, TFT_MOSI, TFT_CS);
  tft.begin();
  tft.setRotation(1);
  tft.fillScreen(ILI9341_NAVY);
  tft.setTextSize(3);
  tft.setTextColor(ILI9341_WHITE);
  tft.setCursor(20, 10); tft.print("SENSBENCH");
  tft.setTextSize(1);
  tft.setCursor(20, 38);
  tft.setTextColor(ILI9341_CYAN);
  tft.print("ESP32-CAM live preview");

  camera_config_t cfg = {};
  cfg.ledc_channel = LEDC_CHANNEL_0;
  cfg.ledc_timer   = LEDC_TIMER_0;
  cfg.pin_d0 = Y2_GPIO_NUM; cfg.pin_d1 = Y3_GPIO_NUM;
  cfg.pin_d2 = Y4_GPIO_NUM; cfg.pin_d3 = Y5_GPIO_NUM;
  cfg.pin_d4 = Y6_GPIO_NUM; cfg.pin_d5 = Y7_GPIO_NUM;
  cfg.pin_d6 = Y8_GPIO_NUM; cfg.pin_d7 = Y9_GPIO_NUM;
  cfg.pin_xclk = XCLK_GPIO_NUM;  cfg.pin_pclk = PCLK_GPIO_NUM;
  cfg.pin_vsync = VSYNC_GPIO_NUM; cfg.pin_href = HREF_GPIO_NUM;
  cfg.pin_sccb_sda = SIOD_GPIO_NUM;
  cfg.pin_sccb_scl = SIOC_GPIO_NUM;
  cfg.pin_pwdn = PWDN_GPIO_NUM;
  cfg.pin_reset = RESET_GPIO_NUM;
  cfg.xclk_freq_hz = 20000000;
  cfg.pixel_format = PIXFORMAT_JPEG;
  cfg.frame_size   = FRAMESIZE_QVGA;
  cfg.jpeg_quality = 12;
  cfg.fb_count     = 1;
  cfg.fb_location  = CAMERA_FB_IN_DRAM;

  esp_err_t err = esp_camera_init(&cfg);
  if (err != ESP_OK) {
    tft.setCursor(20, 100);
    tft.setTextColor(ILI9341_RED);
    tft.setTextSize(2);
    tft.printf("camera_init FAIL 0x%x", err);
    while (1) delay(1000);
  }

  tft.setCursor(20, 100);
  tft.setTextColor(ILI9341_GREEN);
  tft.print("camera_init OK");
  tft.setCursor(20, 130);
  tft.setTextColor(ILI9341_YELLOW);
  tft.print("Waiting for webcam frames...");
  tft.setCursor(20, 150);
  tft.print("Click 'Camera' in the toolbar.");

  start_ms = millis();
  delay(800);
  tft.fillScreen(ILI9341_BLACK);
}

void loop() {
  camera_fb_t* fb = esp_camera_fb_get();
  if (!fb) {
    null_fb++;
    delay(50);
    return;
  }
  frame_count++;
  size_t fb_len = fb->len;
  bool ok = jpg2rgb565(fb->buf, fb->len, rgbBuf, JPG_SCALE_2X);
  esp_camera_fb_return(fb);
  if (ok) {
    tft.drawRGBBitmap(PREVIEW_X, PREVIEW_Y,
                      (uint16_t*)rgbBuf, PREVIEW_W, PREVIEW_H);
  } else {
    decode_fails++;
    tft.fillRect(PREVIEW_X, PREVIEW_Y,
                 PREVIEW_W, PREVIEW_H, ILI9341_DARKGREY);
    tft.drawLine(PREVIEW_X, PREVIEW_Y,
                 PREVIEW_X + PREVIEW_W, PREVIEW_Y + PREVIEW_H, ILI9341_RED);
    tft.drawLine(PREVIEW_X + PREVIEW_W, PREVIEW_Y,
                 PREVIEW_X, PREVIEW_Y + PREVIEW_H, ILI9341_RED);
  }
  // Throttled status-bar redraw — text writes hit SPI too.
  if (frame_count % STATUS_REFRESH_EVERY == 0) {
    draw_status_bar((uint32_t)fb_len, ok);
  }
}

How it works

Frames arrive as JPEG: the camera config sets PIXFORMAT_JPEG at FRAMESIZE_QVGA with quality 12, so each esp_camera_fb_get() returns a compressed buffer of a few kilobytes rather than 150 KB of raw pixels. The sketch decodes it in place with jpg2rgb565() from img_converters.h using JPG_SCALE_2X — the decoder writes a half-resolution RGB565 bitmap straight into a static 38,400-byte buffer, so there is no per-frame heap allocation. Then esp_camera_fb_return() hands the frame back to the driver immediately, before any SPI work happens.

The display side is built for throughput. The TFT hangs off a dedicated SPIClass on VSPI (SCK 12, MOSI 13, CS 15, D/C 14, RST 2), and one drawRGBBitmap() call pushes the whole 38 KB frame. The status bar is deliberately throttled to every fifth frame (STATUS_REFRESH_EVERY) because text writes go over the same SPI bus and would otherwise tax every frame. Failed decodes are not hidden either: the preview area goes grey with a red X and the decode_fails counter ticks up, so you see pipeline problems instead of staring at a frozen image.

Two config choices are worth copying. CAMERA_FB_IN_DRAM with fb_count = 1 keeps the frame buffer in internal RAM — no PSRAM dependency — at the cost of double buffering. And if esp_camera_init() fails, the error code is drawn on the TFT in red rather than only appearing on serial, which matters on a board you often run headless.

Before you build it for real

Real AI-Thinker hardware makes these pin choices expensive. The TFT’s five pins — GPIO 12, 13, 14, 15 and 2 — are exactly the pins the onboard microSD slot uses in SD_MMC mode, so this wiring and SD card logging are mutually exclusive on one board. Worse, GPIO12 is the flash-voltage strapping pin: it must read LOW at reset or the chip configures its flash for 1.8 V and refuses to boot. TFT SCK idles low, so you will usually get away with it — but any module that pulls the line up at power-on will brick your boot in a way no error message explains. Budget for a solid 5 V supply too: the OV2640 plus a TFT backlight browns out weak USB-serial adapters.

Get the parts

Related examples

FAQ

Why is the preview 160×120 when the screen is 320×240?

Because JPG_SCALE_2X decodes at half resolution. A full-size RGB565 frame would be a 150 KB buffer plus twice the SPI traffic; at half scale the buffer is 38,400 bytes and a single drawRGBBitmap() stays fast enough for a fluid preview. It is a deliberate trade — this example is about the capture-decode-render pipeline, not pixel count.

Do I need PSRAM to run this on a real ESP32-CAM?

No — CAMERA_FB_IN_DRAM keeps the QVGA JPEG buffer in internal RAM, which is exactly why it fits. The AI-Thinker module does have PSRAM, so if you want larger frame sizes or fb_count = 2 for smoother capture, switch to CAMERA_FB_IN_PSRAM. At QVGA, DRAM with one buffer is the simpler, safer choice.