Real-time polygon rendering without canvas or extra buffers in LVGL 9.5 (ESP32)

I’m working with LVGL 9.5 on an ESP32 (ESP-IDF), and I have strong RAM constraints, so I cannot use lv_canvas or any large intermediate buffers.

My goal is to render filled arbitrary polygons dynamically (real-time), without allocating additional frame buffers or off-screen memory.

Current approach

  • Using the SW renderer (no GPU)
  • Drawing primitives manually inside a custom draw routine / task
  • Tried:
    • lv_draw_line → only outlines
    • Splitting shapes into multiple triangles → works partially, but produces visible seams/artifacts between triangles (color blending issues)

Constraints

  • No canvas (RAM limitation)
  • No full-screen buffering
  • Real-time rendering (objects change every frame)
  • Prefer to stay in SW renderer (ThorVG/vector backend seems too heavy and unclear on ESP32)

Questions

  1. Is there any native way in LVGL 9.5 to render filled polygons (convex or concave) without using canvas or vector graphics?
  2. Is there an internal API (e.g. low-level draw context) that supports triangle filling without artifacts between adjacent triangles?
  3. Are the seams between triangles expected due to rasterization/antialiasing? Is there a recommended way to avoid them?
  4. Is using ThorVG the only correct approach for proper polygon filling in LVGL 9.x?
  5. Any recommended pattern for real-time geometry rendering under tight RAM constraints?


You can see the lines inside the square

static void my_ui(void)
{
  lv_obj_t * screen = lv_screen_active();
  lv_obj_set_style_bg_color(screen, lv_color_white(), LV_PART_MAIN);

  lv_obj_add_event_cb(screen, draw_cb_event, LV_EVENT_DRAW_TASK_ADDED, screen);
  lv_obj_set_flag(screen, LV_OBJ_FLAG_SEND_DRAW_TASK_EVENTS, true);

}

static void draw_cb_event(lv_event_t * e)
{
  lv_obj_t * obj = (lv_obj_t *)lv_event_get_target(e);
  lv_draw_task_t * draw_task = lv_event_get_draw_task(e);
  lv_draw_dsc_base_t * base_dsc = (lv_draw_dsc_base_t *)lv_draw_task_get_draw_dsc(draw_task);

  if (base_dsc->part == LV_PART_MAIN){
    // Get Parent Coordinates
    lv_area_t obj_coords;
    lv_obj_get_coords(obj, &obj_coords);
    // Get Parent Center Point
    int32_t cx = (obj_coords.x1 + obj_coords.x2) / 2;
    int32_t cy = (obj_coords.y1 + obj_coords.y2) / 2;
    // LV_LOG_USER("cx:%d - cy:%d", cx, cy);
    // Create triangle with Primitives
    lv_draw_triangle_dsc_t tri_dsc;
    // Init with default config
    lv_draw_triangle_dsc_init(&tri_dsc);
    // Configurate triangle
    tri_dsc.color = lv_palette_main(LV_PALETTE_GREEN);
    // Triangle Size
    int32_t b = 200;
    // Set triangle points (x, y), remember images coordinates
    // (0,0) are start points in top left
    tri_dsc.p[0] = (lv_point_precise_t){cx - b/2, cy};
    tri_dsc.p[1] = (lv_point_precise_t){cx      , cy - b/2};
    tri_dsc.p[2] = (lv_point_precise_t){cx + b/2, cy};
    // Draw Triangle 1
    lv_draw_triangle(base_dsc->layer, &tri_dsc);
    // Set triangle points to make a square base in 2 triangles
    tri_dsc.p[0] = (lv_point_precise_t){cx - b/2, cy};
    tri_dsc.p[1] = (lv_point_precise_t){cx      , cy + b/2};
    tri_dsc.p[2] = (lv_point_precise_t){cx + b/2, cy};
    // Draw Triangle 2
    lv_draw_triangle(base_dsc->layer, &tri_dsc);
  }
}

I ended up using two separate triangles and a thin line between them as a workaround. It was the only way I could avoid the visible seam/artifact that appears when rendering them together as a single shape.
Grabación 2026-04-30 145005

float rotation = 0;

static void timer_cb(lv_timer_t * timer)
{
  lv_obj_t * obj = (lv_obj_t *)lv_timer_get_user_data(timer);
  rotation += 0.5f;
  if(rotation >= 360.0f) rotation = 0.0f;
  lv_obj_invalidate(obj);
}

static void my_ui(void)
{
  lv_obj_t * screen = lv_screen_active();
  lv_obj_set_style_bg_color(screen, lv_color_black(), LV_PART_MAIN);

  lv_obj_add_event_cb(screen, draw_cb_event, LV_EVENT_DRAW_TASK_ADDED, screen);
  lv_obj_set_flag(screen, LV_OBJ_FLAG_SEND_DRAW_TASK_EVENTS, true);

  lv_timer_create(timer_cb, 33, screen);
}

static void square_draw(lv_obj_t * obj, lv_draw_task_t * draw_task,lv_draw_dsc_base_t * base, int32_t size, float rotate)
{
    float angle = rotate * (3.1416f / 180.0f);
    float c = cosf(angle);
    float s = sinf(angle);

    lv_area_t obj_coords;
    lv_obj_get_coords(obj, &obj_coords);
    int cx = (obj_coords.x1 + obj_coords.x2) / 2;
    int cy = (obj_coords.y1 + obj_coords.y2) / 2;

    int32_t h = size / 2;

    lv_point_precise_t corners[4] = {
        {cx - h, cy - h},  // top-left
        {cx + h, cy - h},  // top-right
        {cx + h, cy + h},  // bottom-right
        {cx - h, cy + h},  // bottom-left
    };

    // Rotate around center point
    for(int i = 0; i < 4; i++) {
        float x = corners[i].x - cx;
        float y = corners[i].y - cy;
        corners[i].x = cx + (x * c - y * s);
        corners[i].y = cy + (x * s + y * c);
    }
    // Draw triangle
    lv_draw_triangle_dsc_t tri_dsc;
    lv_draw_triangle_dsc_init(&tri_dsc);
    tri_dsc.opa = LV_OPA_100;
    int32_t offset = 0;
    // Triángulo 1: top-left, top-right, bottom-right
    tri_dsc.color = lv_palette_main(LV_PALETTE_RED);
    tri_dsc.p[0] = (lv_point_precise_t){corners[0].x - offset, corners[0].y};
    tri_dsc.p[1] = corners[1]; // don't touch
    tri_dsc.p[2] = (lv_point_precise_t){corners[2].x - offset, corners[2].y};
    lv_draw_triangle(base->layer, &tri_dsc);
    // Triangle 2: top-left, bottom-right, bottom-left
    tri_dsc.color = lv_palette_main(LV_PALETTE_RED);
    tri_dsc.p[0] = (lv_point_precise_t){corners[0].x + offset, corners[0].y};
    tri_dsc.p[1] = corners[2]; // don't touch
    tri_dsc.p[2] = (lv_point_precise_t){corners[3].x + offset, corners[3].y};
    lv_draw_triangle(base->layer, &tri_dsc);
    // DRAW LINE
    lv_draw_line_dsc_t line_dsc;
    lv_draw_line_dsc_init(&line_dsc);
    line_dsc.color = lv_palette_main(LV_PALETTE_GREY);
    line_dsc.width = 2;
    line_dsc.opa = LV_OPA_100;
    line_dsc.p1 = corners[0];
    line_dsc.p2 = corners[2];
    lv_draw_line(base->layer, &line_dsc);
}