Getting started

What you need on the hardware and firmware side before Pip3D even draws its first pixel: board, display, toolchain and a sane main loop.

Target hardware

Pip3D is built for a narrow, well-understood target so the engine can stay small and predictable instead of trying to support every MCU on Earth.

  • MCU: ESP32-S3. Dual core and vector instructions are welcome, but the engine can also run in a conservative mode.
  • Display: 320×240 ST7789 TFT, usually over SPI with DMA access.
  • RAM & flash: leave headroom for your own logic, not only the engine. Budget both frame buffers and geometry.

Firmware workflow

The engine assumes you already have a comfortable workflow for flashing and debugging your board. It does not hide the microcontroller from you.

  • Pick a toolchain you actually like: PlatformIO, ESP-IDF, Arduino core – Pip3D does not care as long as it gets a C/C++ environment on ESP32-S3.
  • Keep serial logging available. Timing and memory usage are easier to understand when you can quickly print numbers from inside the rendering loop.
  • Start from a minimal firmware that clears the screen and draws a very simple test pattern at a stable rate.

Once this baseline is solid, you plug the engine into that main loop instead of trying to debug display issues and 3D math at the same time.

First integration

A typical way to embed Pip3D into a firmware project is to treat it as a self-contained module that is fed with a few simple inputs:

  • Time: current timestamp or delta time per frame.
  • Input: buttons, encoders, touch – mapped to your own control scheme.
  • Display driver: a function that knows how to push a finished framebuffer to the ST7789.

The engine owns its internal state (scene, cameras, jobs), but you stay in control of the outer loop, RTOS tasks and power management.

DisplayConfig and Display

The engine describes the physical panel and SPI wiring through a simple DisplayConfig alias:

using DisplayConfig = Display;

struct Display
{
    uint16_t width, height;
    int8_t   cs, dc, rst, bl;
    uint32_t spi_freq;
};

width, height. Target resolution in pixels. The framebuffer is allocated as width * height RGB565 pixels and all viewports are defined within this space.

cs, dc, rst, bl. SPI control pins for the ST7789-based display: chip-select, data/command, reset and backlight. The backlight pin bl may be -1 when the LED is hard-wired.

spi_freq. SPI clock frequency in Hz (for example 80'000'000 or 100'000'000). This value is forwarded to the low-level driver when the renderer initializes the panel.

Typical usage is to construct a DisplayConfig in your setup() and pass it to the renderer's initialization function.

LCD and ST7789Driver

Under the hood Pip3D uses a dedicated low-level driver for ST7789 TFT panels. The configuration is expressed with an LCD struct and the driver itself is ST7789Driver:

struct LCD
{
    uint16_t w, h;
    int8_t cs, dc, rst, bl;
    uint32_t freq; // Hz

    LCD();
    LCD(int8_t cs_, int8_t dc_, int8_t rst_ = 8);

    static LCD pins(int8_t cs, int8_t dc, int8_t rst = 8);

    LCD &size(uint16_t width, uint16_t height);
    LCD &backlight(int8_t pin);
    LCD &speed(uint32_t mhz);
};
class ST7789Driver
{
public:
    ST7789Driver();
    ~ST7789Driver();

    bool init(const LCD &cfg = LCD());

    void drawPixel(int16_t x, int16_t y, uint16_t color);
    void fillRect(int16_t x, int16_t y, int16_t w, int16_t h, uint16_t color);
    void fillScreen(uint16_t color);

    void pushImage(int16_t x, int16_t y, int16_t w, int16_t h, uint16_t *buffer);

    void drawHLine(int16_t x, int16_t y, int16_t w, uint16_t color);
    void drawVLine(int16_t x, int16_t y, int16_t h, uint16_t color);

    void setRotation(uint8_t r); // 0..3

    uint16_t getWidth() const;
    uint16_t getHeight() const;
};

LCD. A lightweight description of the panel: native resolution, control pins and SPI frequency. Helper functions like LCD::pins, size, backlight and speed make it easy to adapt a config to your specific board.

ST7789Driver. Owns the SPI device handle, configures GPIOs and DMA-capable buffers and provides a set of optimized drawing primitives. The 3D engine normally does not call these methods directly – they are driven through the framebuffer – but you can reuse the driver for custom UIs or debug overlays.

When you initialize the renderer it will internally construct an LCD from your DisplayConfig, call ST7789Driver::init and keep a single driver instance alive for the lifetime of the application.

Screen helper (2D path)

For simple 2D experiments or splash screens you can use the Screen helper instead of going through the full 3D renderer:

class Screen
{
public:
    Screen();

    bool begin();
    bool begin(int8_t cs, int8_t dc, int8_t rst = 8);
    bool begin(const LCD &config);

    void pixel(int16_t x, int16_t y, uint16_t color);
    void rect(int16_t x, int16_t y, int16_t w, int16_t h, uint16_t color);
    void clear(uint16_t color = Color::BLACK);

    void show(uint16_t *buffer);
    void show(int16_t x, int16_t y, int16_t w, int16_t h, uint16_t *buffer);

    uint16_t width() const;
    uint16_t height() const;

    ST7789Driver &raw();
};

begin. Initializes the underlying ST7789Driver using either default ESP32-S3 presets or a fully custom LCD.

pixel, rect, clear. Immediate-mode drawing primitives for quick tests, diagnostics or HUD elements.

show. Uploads a full framebuffer or a rectangular sub-region to the display. This is the same path used by the 3D renderer to present frames produced by its FrameBuffer object.

The 3D engine uses the same low-level building blocks; learning the 2D path first often makes debugging hardware issues easier before you add cameras, meshes and lights.

Minimal initialization example

A very small sketch for an ESP32-S3 board with a 320×240 ST7789 panel could look like this:

// Pseudo-code based on the public API. Adjust pin numbers to your board.
DisplayConfig display{
    320,       240,    // width, height
    10,        9,   8, // cs, dc, rst
    -1,                // bl (backlight disabled or hard-wired)
    80'000'000         // spi_freq
};

Renderer renderer;
void setup()
{
    // Initialize serial for logging if needed.
    Serial.begin(115200);

    // Initialize the 3D renderer and display.
    renderer.init(display);
}
void loop()
{
    renderer.beginFrame();

    // TODO: update scene, camera, submit meshes to render.

    renderer.endFrame();
}

Real projects usually configure more knobs (skybox, job system, resource loading), but the pattern stays the same: build a display description, initialize the renderer once and then drive it from a loop that you control.

See also the rendering pipeline page for a deeper breakdown of what happens between beginFrame() and endFrame().