Skip to content

Touch Input Architecture

This document describes how resistive and capacitive touch integrate with PixelRoot32: data flow, calibration, and platform responsibilities.

For public API details (methods, parameters), see API Reference — Input Module.

1. Design principles

  • Touch is optional. Enable with PIXELROOT32_ENABLE_TOUCH=1 in build flags (default: disabled). Saves ~200 bytes when disabled.
  • Engine optionally owns touch processing. When PIXELROOT32_ENABLE_TOUCH=1 and setTouchManager() is called, Engine automatically processes touch events in Engine::update() and sends them to Scene::processTouchEvents(). When disabled or unset, use the manual integration pattern (section 4).
  • Single coordinate space. After the active adapter runs, coordinates are screen pixels in the same range as PHYSICAL_DISPLAY_WIDTH × PHYSICAL_DISPLAY_HEIGHT (or your TouchManager constructor bounds). Game logic, UI hit tests, and debug overlays should use this space.
  • UI before gameplay. Scene::processTouchEvents runs UIManager::processEvents first (when PIXELROOT32_ENABLE_UI_SYSTEM), marks events consumed, then invokes onUnconsumedTouchEvent for each remaining event.
  • Scene owns touch widgets. Construct UITouchButton / UITouchSlider / UITouchCheckbox (and layouts) in init(), keep them in std::unique_ptr or the scene arena, call UIManager::addElement for hit testing and dispatch, and addEntity on a layout (or entity) so update / draw run with the rest of the scene. UIManager does not allocate or destroy widgets.

2. Pipeline (high level)

With Engine Integration (PIXELROOT32_ENABLE_TOUCH=1 + setTouchManager())

text
Hardware (XPT2046, GT911, …)
    → Adapter readImpl()  [median sample, optional GPIO bit-bang SPI]
    → TouchCalibration / compile-time calibration macros
    → TouchPoint (x, y, pressed)
    → TouchManager::update()
         → clamp to display bounds
    → TouchManager::getTouchPoints()
    → Engine::setTouchManager(&touchManager) [called once in setup()]
    → Engine::update() [automatic each frame]
         → Engine polls getTouchPoints()
         → Detects release (count: >0 → 0)
         → TouchEventDispatcher processes points (gestures: down, drag, up, …)
    → Scene::processTouchEvents()
         → UIManager (optional)
         → onUnconsumedTouchEvent()

Input abstraction (buttons + touch + PC)

Without Engine Integration (PIXELROOT32_ENABLE_TOUCH=0)

text
Hardware (XPT2046, GT911, …)
    → TouchManager::update()
    → TouchManager::getTouchPoints()  [only raw active touches]
    → Manual release detection required (track state externally)
    → Manual touch injection to engine.getTouchDispatcher()
    → Scene::processTouchEvents()  [manual call in user loop]
         → UIManager (optional)
         → onUnconsumedTouchEvent()

Optional gameplay layer: ActorTouchController consumes TouchEvents in onUnconsumedTouchEvent to drag registered Actors (hit test, drag threshold, position update).

3. Key components

ComponentRole
TouchManagerPolls adapter, clamps points, exposes getTouchPoints().
TouchCalibrationforResolution(w,h), transform, rotation; shared with adapter via setCalibration.
XPT2046AdapterESP32 XPT2046: shared TFT SPI or GPIO bit-bang (XPT2046_USE_GPIO_SPI) for boards like ESP32-2432S028R.
TouchEventDispatcher (in Engine)Converts point stream into TouchEvent gestures (TouchDown, DragMove, TouchUp, …).
ActorTouchControllerDrags actors from touch; optional hit slop for resistive alignment.
Scene::processTouchEventsCentral entry for a frame's touch batch; runs UIManager::processEvents then virtual onUnconsumedTouchEvent.
UIManagerNon-owning registry (addElement); processEvents calls UITouchElement::processEvent. Does not draw or own widget memory.

Call once per frame, in order:

  1. touchManager.update(deltaTimeMs);
  2. TouchEvent buf[TOUCH_EVENT_QUEUE_SIZE];
  3. uint8_t n = touchManager.getEvents(buf, …);
  4. If n > 0 and a scene is active: scene->processTouchEvents(buf, n);
  5. engine.run(); (or your update/draw split).

Touch is intentionally processed before engine.run() in this pattern so gameplay reacts in the same frame as the sample.

5. Initialization order (critical on ESP32)

TouchManager::init() forwards the internal TouchCalibration to the adapter. The default TouchCalibration struct uses 320×240 defaults until you call forResolution.

Always set calibration for your real panel before init():

cpp
TouchCalibration cal = TouchCalibration::forResolution(PHYSICAL_DISPLAY_WIDTH, PHYSICAL_DISPLAY_HEIGHT);
touchManager.setCalibration(cal);
touchManager.init();

Wrong dimensions break horizontal mirror span (displayWidth - x), raw-to-screen mapping width/height, and clamping.

6. XPT2046 GPIO path (e.g. Sunton 2432S028R / CYD)

When -D XPT2046_USE_GPIO_SPI is set, the adapter uses a separate bit-banged bus (pins overridable via XPT2046_GPIO_* macros). After reading raw ADC channels and optional axis swap, coordinates go through this order (see XPT2046Adapter.cpp):

  1. Map to screen (either TouchCalibration::transform or linear XPT2046_GPIO_USE_RAW_RANGE using XPT2046_RAW_*_LO / HI).
  2. Optional XPT2046_GPIO_VENDOR_COORDS rotation-style swap.
  3. XPT2046_GPIO_MIRROR_X — horizontal flip in screen space (x = displayWidth - x).
  4. XPT2046_CAL_OFFSET_X / Y — final nudge in post-mirror pixels.
  5. Clamp to calibration.displayWidth / displayHeight.

Typical alignment flags for CYD-class boards (exact values are project-specific):

  • XPT2046_GPIO_SWAP_AXES — finger vertical/horizontal matched wrong screen axes.
  • XPT2046_GPIO_MIRROR_X — left/right inverted after swap.
  • XPT2046_GPIO_USE_RAW_RANGE — map real ADC span to full screen instead of assuming 0–4095.

7. Gameplay hit testing and slop

Resistive stacks often report a cluster of pixels offset from the visible sprite. ActorTouchController::setTouchHitSlop(n) expands the hit rectangle by n pixels per side for picking only (not rendering).

8. Cross-platform vs. board-specific configuration

To port touch features to a new board, it's important to understand which parts of this architecture apply generally and which are specific to particular hardware setups.

Generic engine components (apply to all boards)

The following aspects of the touch system are hardware-agnostic and work exactly the same regardless of your board:

  • The data flow: TouchManager -> TouchEventDispatcher -> Scene::processTouchEvents -> UI/Gameplay.
  • The coordinate space: Your game logic always receives touch events in scaled screen pixels.
  • Gameplay tools: ActorTouchController and its hit-slop mechanics work identically on any touchscreen.
  • Initialization: You must always call TouchCalibration::forResolution and touchManager.setCalibration() before touchManager.init().

Hardware-specific configuration (board-dependent)

The underlying driver and its build flags vary drastically by device. You cannot copy-paste the platformio.ini touch flags from one board to another and expect them to work without adjustment.

Example 1: ESP32-2432S028R (CYD) This board uses an XPT2046 resistive touch controller on a separate bit-banged GPIO bus, not the main TFT SPI bus. It requires specific macro overrides:

  • -D TOUCH_DRIVER_XPT2046
  • -D XPT2046_USE_GPIO_SPI
  • Specific pins (XPT2046_GPIO_MOSI, etc.)
  • Panel-specific tuning (XPT2046_GPIO_SWAP_AXES, XPT2046_GPIO_MIRROR_X, XPT2046_GPIO_USE_RAW_RANGE).

Example 2: A standard ESP32 with shared SPI XPT2046 Many TFT shields share the main SPI bus for both the display and the touch controller.

  • You would not use XPT2046_USE_GPIO_SPI.
  • The TFT_eSPI library typically handles the low-level SPI sharing via the TFT_eSPI_TouchBridge or built-in calibration.
  • You only need -D TOUCH_DRIVER_XPT2046 and -D PIXELROOT32_USE_TFT_ESPI_DRIVER.

Example 3: A capacitive touchscreen (e.g., GT911) Capacitive screens use I²C and usually report perfect screen coordinates out of the box, requiring no axis swapping or offset calibration.

  • You would use -D TOUCH_DRIVER_GT911 instead.
  • Resistive calibration flags (like SWAP_AXES or MIRROR_X) are not used.

10. Engine Integration

When PIXELROOT32_ENABLE_TOUCH=1, Engine automatically handles touch processing, eliminating the need for manual integration in your game loop.

10.1 Automatic Touch (PIXELROOT32_ENABLE_TOUCH=1 + setTouchManager)

With the flag enabled and setTouchManager() called, Engine automatically:

  1. Connects SDL2_Drawer to the touch dispatcher on init (Native only)
  2. Polls touchManager.getTouchPoints() each frame in Engine::update()
  3. Detects releases when count goes from >0 to 0
  4. Processes gesture events through internal TouchEventDispatcher
  5. Dispatches to Scene::processTouchEvents() each frame

Native (PC):

cpp
// No manual setup needed - Engine handles it automatically
Engine engine(displayConfig, inputConfig);
engine.init();
engine.setScene(&myScene);
engine.run();  // Mouse events → touch events automatically

ESP32 - Using setTouchManager (recommended):

cpp
// En setup():
touchManager.init();
engine.setTouchManager(&touchManager);  // 1 línea

// En loop():
touchManager.update(frameDt);
engine.run();  // Engine maneja todo automáticamente

The developer no longer needs to manually inject touch points or track release state - Engine handles it all internally.

10.2 Manual Touch (legacy, without setTouchManager)

When PIXELROOT32_ENABLE_TOUCH=1 but setTouchManager() is not called, you can manually inject touch points:

cpp
void loop() {
    touchManager.update(frameDt);
    
    TouchPoint points[TOUCH_MAX_POINTS];
    uint8_t count = touchManager.getTouchPoints(points);
    
    static bool wasTouching = false;
    static int16_t lastX = 0, lastY = 0;
    
    auto& dispatcher = engine.getTouchDispatcher();
    
    if (count > 0) {
        for (uint8_t i = 0; i < count; i++) {
            dispatcher.processTouch(points[i].id, true, points[i].x, points[i].y, points[i].ts);
        }
        wasTouching = true;
        lastX = points[0].x;
        lastY = points[0].y;
    } else if (wasTouching) {
        dispatcher.processTouch(0, false, lastX, lastY, millis());
        wasTouching = false;
    }
    
    engine.run();
}

Note: Using setTouchManager() (section 10.1) is recommended instead of this manual pattern.

10.3 InputManager Touch API (Native only)

When PIXELROOT32_ENABLE_TOUCH=1, InputManager provides mouse-to-touch conversion for Native platforms:

  • processSDLEvent(const SDL_Event& event): Converts SDL mouse events to touch events (Native only).

10.4 SDL2_Drawer Touch Integration

On Native (PC), SDL2_Drawer automatically converts mouse events to touch events for compatibility with touch UI widgets. This happens automatically when PIXELROOT32_ENABLE_TOUCH=1.

cpp
// Automatic during init when PIXELROOT32_ENABLE_TOUCH=1
// SDL2_Drawer connects to Engine's touchDispatcher

// Mouse → touch coordinate conversion happens internally
// Coordinates are mapped to the logical display resolution

Released under the MIT License.