====== Arduino_GFX helper functions ====== ===== Good References ===== * [[https://blog.dzl.dk/2020/04/04/crazy-fast-tft-action-with-adafruit-gfx-and-esp32/|SPI_WRITE16() optimization for Adafruit GFX]] * [[https://github.com/moononournation/Arduino_GFX|moononournation/Arduino_GFX: Rewrite of Adafruit GFX for speed]] * [[https://www.codeproject.com/Articles/5302085/GFX-Forever-The-Complete-Guide-to-GFX-for-IoT|GFX-Forever: Another optimized library]] ===== Embrient Logo ===== - X(𝑡) = cos(𝑡) − 0.5cos(5𝑡) - Y(𝑡) = sin(𝑡) + 0.5sin(5𝑡) - Z(𝑡) = 1.32204cos(3𝑡) Where 𝑡 is in degrees, i.e., 𝑡 ∈ [0,360) To normalize the diameter to '1' Use the following equation - X(𝑡) = 0.378122(cos(t) − 0.5cos(5t)) - Y(𝑡) = 0.378122(sin(t) + 0.5sin(5t)) - Z(𝑡) = 0.5cos(3t) ​ Xt = 0.378122*(cos(t)−0.5*cos(5*t)); Yt = 0.378122*(sin(t)+0.5*sin(5*t)); Zt = 0.5*cos(3t); ===== Thick Lines ===== Arduino_GFX doesn't let you draw lines with thickness, here I have two functions, one thats "simple" and one thats a bit more optimized. I like to chain lines together to make a polyline so rounded edges make it look better. This is a "naive" function which computes two triangles representing the thickened line and then plops two circles on the ends. It gets the job done, but it isn't efficient. // Draw a thick line with rounded ends using Adafruit_GFX primitives void DrawNaiveThickLineRounded(int x1, int y1, int x2, int y2, int thickness, int color) { // Calculate the differences between endpoints int deltaX = x1 - x2; int deltaY = y1 - y2; // Compute the length once float len = sqrt(sq(deltaX) + sq(deltaY)); // If the endpoints are nearly identical, draw a single circle if (len < 0.001) { tft.fillCircle(x1, y1, thickness / 2, color); return; } // Compute factor for the perpendicular offset float factor = (thickness / 2.0) / len; // Calculate the perpendicular offsets for rounded thickness float offsetX = factor * (y1 - y2); float offsetY = factor * (x1 - x2); // Draw the thick line as two triangles forming a rectangle tft.fillTriangle(x1 + offsetX, y1 - offsetY, x1 - offsetX, y1 + offsetY, x2 + offsetX, y2 - offsetY, color); tft.fillTriangle(x1 - offsetX, y1 + offsetY, x2 - offsetX, y2 + offsetY, x2 + offsetX, y2 - offsetY, color); // Add rounded caps at both endpoints tft.fillCircle(x1, y1, thickness / 2, color); tft.fillCircle(x2, y2, thickness / 2, color); } This is more optimized code focusing on eliminating redundant calculations. // Draw a thick line with rounded ends using a unified scanline fill approach. // Focuses more on integer optimization, further optimization can be made by eliminating division. void DrawThickLineRoundedHLines(int x1, int y1, int x2, int y2, int thickness, uint16_t color) { // Half thickness (using integer division is fine for even thicknesses) int r = thickness >> 1; int dxLine = x2 - x1; int dyLine = y2 - y1; int d2 = dxLine * dxLine + dyLine * dyLine; // If endpoints coincide, draw a full circle. if (d2 == 0) { tft.fillCircle(x1, y1, r, color); return; } // Compute the length and normalized line direction. float len = sqrt((float)d2); float lx = dxLine / (float)len; float ly = dyLine / (float)len; // Compute perpendicular offset (using (dy, -dx)) scaled to half thickness. float factor = (thickness / 2.0f) / len; float offX = factor * dyLine; float offY = factor * -dxLine; // Compute the four vertices (of the quadr) in clockwise order. // These define the main body of the line. float Ax = x1 + offX, Ay = y1 + offY; float Bx = x1 - offX, By = y1 - offY; float Cx = x2 - offX, Cy = y2 - offY; float Dx = x2 + offX, Dy = y2 + offY; // Determine overall bounding box (including the circular caps). float bboxMinX = min(min(min(Ax, Bx), min(Cx, Dx)), (float)min(x1 - r, x2 - r)); float bboxMaxX = max(max(max(Ax, Bx), max(Cx, Dx)), (float)max(x1 + r, x2 + r)); float bboxMinY = min(min(min(Ay, By), min(Cy, Dy)), (float)min(y1 - r, y2 - r)); float bboxMaxY = max(max(max(Ay, By), max(Cy, Dy)), (float)max(y1 + r, y2 + r)); int iMinY = (int)floor(bboxMinY); int iMaxY = (int)ceil(bboxMaxY); // Lambda to process an edge of the quadr. // Returns the x coordinate where the horizontal scanline at 'y' intersects the edge, // or NAN if there is no intersection. auto processEdge = [=](float x0, float y0, float x1, float y1, int y) -> float { if ((y0 <= y && y1 > y) || (y1 <= y && y0 > y)) { float t = (y - y0) / (y1 - y0); return x0 + t * (x1 - x0); } return NAN; }; // For each scanline, we compute up to three segments: one from the quadr body, // one from the start cap, and one from the end cap. // We then merge overlapping segments and draw them. struct Segment { float x0, x1; }; for (int y = iMinY; y <= iMaxY; y++) { Segment segments[3]; int segCount = 0; // 1. Quadr segment: process all four edges of the quadr. float inters[4]; int count = 0; float ttemp; ttemp = processEdge(Ax, Ay, Bx, By, y); if (!isnan(ttemp)) inters[count++] = ttemp; ttemp = processEdge(Bx, By, Cx, Cy, y); if (!isnan(ttemp)) inters[count++] = ttemp; ttemp = processEdge(Cx, Cy, Dx, Dy, y); if (!isnan(ttemp)) inters[count++] = ttemp; ttemp = processEdge(Dx, Dy, Ax, Ay, y); if (!isnan(ttemp)) inters[count++] = ttemp; if (count >= 2) { float qx0 = inters[0], qx1 = inters[0]; for (int i = 1; i < count; i++) { if (inters[i] < qx0) qx0 = inters[i]; if (inters[i] > qx1) qx1 = inters[i]; } segments[segCount++] = { qx0, qx1 }; } // 2. Start cap (centered at (x1,y1)): if y is within the circle's vertical span. if (y >= y1 - r && y <= y1 + r) { float dyCap = y - y1; float dxCap = sqrt((float)(r*r - dyCap*dyCap)); float capStart = x1 - dxCap; float capEnd = x1 + dxCap; // Clip to the half circle: include only points for which dot((x-x1,y-y1),(lx,ly)) < 0. // Solve: (x - x1)*lx + (y - y1)*ly < 0 => if lx != 0, x < x1 - ((y-y1)*ly)/lx when lx > 0, // or x > x1 - ((y-y1)*ly)/lx when lx < 0. if (fabs(lx) > 1e-6) { float xBoundary = x1 - ((y - y1) * ly) / lx; if (lx > 0) { capEnd = min(capEnd, xBoundary); } else { capStart = max(capStart, xBoundary); } } else { // For near-vertical lines, if the condition isn't met, skip drawing the cap on this scanline. if ((ly > 0 && y >= y1) || (ly < 0 && y <= y1)) { capStart = 1, capEnd = 0; // empty } } if (capEnd >= capStart) segments[segCount++] = { capStart, capEnd }; } // 3. End cap (centered at (x2,y2)): if y is within the circle's vertical span. if (y >= y2 - r && y <= y2 + r) { float dyCap = y - y2; float dxCap = sqrt((float)(r*r - dyCap*dyCap)); float capStart = x2 - dxCap; float capEnd = x2 + dxCap; // Clip to the half circle: include only points for which dot((x-x2,y-y2),(lx,ly)) > 0. if (fabs(lx) > 1e-6) { float xBoundary = x2 - ((y - y2) * ly) / lx; if (lx > 0) { capStart = max(capStart, xBoundary); } else { capEnd = min(capEnd, xBoundary); } } else { if ((ly > 0 && y <= y2) || (ly < 0 && y >= y2)) { capStart = 1, capEnd = 0; } } if (capEnd >= capStart) segments[segCount++] = { capStart, capEnd }; } // If no segments were found on this scanline, continue. if (segCount == 0) continue; // Merge segments: sort by starting x and then combine overlapping ones. // (Since there are few segments, a simple bubble sort is sufficient.) for (int i = 0; i < segCount - 1; i++) { for (int j = i + 1; j < segCount; j++) { if (segments[j].x0 < segments[i].x0) { Segment tmp = segments[i]; segments[i] = segments[j]; segments[j] = tmp; } } } // Merge overlapping segments. float mergedStart = segments[0].x0; float mergedEnd = segments[0].x1; for (int i = 1; i < segCount; i++) { if (segments[i].x0 <= mergedEnd + 1) { // overlapping or contiguous if (segments[i].x1 > mergedEnd) mergedEnd = segments[i].x1; } else { // Draw the current merged segment. int ix0 = (int)ceil(mergedStart); int ix1 = (int)floor(mergedEnd); if (ix1 >= ix0) tft.drawFastHLine(ix0, y, ix1 - ix0 + 1, color); mergedStart = segments[i].x0; mergedEnd = segments[i].x1; } } // Draw the final merged segment. int ix0 = (int)ceil(mergedStart); int ix1 = (int)floor(mergedEnd); if (ix1 >= ix0) tft.drawFastHLine(ix0, y, ix1 - ix0 + 1, color); } } ===== Put in practice as ===== #include #include #include #include using std::vector; #define TFT_CS 2 #define TFT_RST -1 #define TFT_DC 4 Adafruit_ST7796S tft = Adafruit_ST7796S(TFT_CS, TFT_DC, TFT_RST); #define SCREEN_WIDTH 360 #define SCREEN_HEIGHT 480 GFXcanvas16 canvas(SCREEN_WIDTH, SCREEN_HEIGHT); // Off-screen framebuffer const float PI_F = 3.14159265; const float SCALE = 0.378122; const float Z_SCALE = 0.5; const float Z_OFFSET = 2.0; const int NUM_SEGMENTS = 60; float baseThickness = 24.0; const uint16_t blackish = 0x0000; float rotation = 0.0; struct Vec3 { float x, y, z; }; struct Segment3D { int x1, y1, x2, y2; float avgZ; int thickness; }; // Rotate a 3D point around the X-axis. Vec3 rotateX(const Vec3& v, float angleDeg) { float a = angleDeg * PI_F / 180.0; float cosA = cos(a); float sinA = sin(a); return { v.x, cosA * v.y - sinA * v.z, sinA * v.y + cosA * v.z }; } // Rotate a 3D point around the Y-axis. Vec3 rotateY(const Vec3& v, float angleDeg) { float a = angleDeg * PI_F / 180.0; float cosA = cos(a); float sinA = sin(a); return { cosA * v.x + sinA * v.z, v.y, -sinA * v.x + cosA * v.z }; } // Rotate a 3D point around the Z-axis. Vec3 rotateZ(const Vec3& v, float angleDeg) { float a = angleDeg * PI_F / 180.0; float cosA = cos(a); float sinA = sin(a); return { cosA * v.x - sinA * v.y, sinA * v.x + cosA * v.y, v.z }; } // Project a 3D point into 2D canvas coordinates. void project3D(const Vec3& pt, int centerX, int centerY, float size, int &px, int &py) { baseThickness = size / 5; float target_size = size - baseThickness; float f = 1.0 / (1.15 - pt.z); float x_proj = pt.x * f * target_size; float y_proj = pt.y * f * target_size; px = static_cast(centerX + x_proj); py = static_cast(centerY - y_proj); } inline uint16_t rgb888_to_565(uint8_t r, uint8_t g, uint8_t b) { uint16_t r5 = (r * 249 + 1014) >> 11; uint16_t g6 = (g * 253 + 505) >> 10; uint16_t b5 = (b * 249 + 1014) >> 11; return (r5 << 11) | (g6 << 5) | b5; } // Draw a thick line with rounded ends using a unified scanline fill approach. void DrawThickLineRoundedHLines(GFXcanvas16& canvas, int x1, int y1, int x2, int y2, int thickness, uint16_t color) { int r = thickness >> 1; int dxLine = x2 - x1; int dyLine = y2 - y1; int d2 = dxLine * dxLine + dyLine * dyLine; if (d2 == 0) { canvas.fillCircle(x1, y1, r, color); return; } float len = sqrt((float)d2); float lx = dxLine / (float)len; float ly = dyLine / (float)len; float factor = (thickness / 2.0f) / len; float offX = factor * dyLine; float offY = factor * -dxLine; float Ax = x1 + offX, Ay = y1 + offY; float Bx = x1 - offX, By = y1 - offY; float Cx = x2 - offX, Cy = y2 - offY; float Dx = x2 + offX, Dy = y2 + offY; float bboxMinX = min(min(min(Ax, Bx), min(Cx, Dx)), (float)min(x1 - r, x2 - r)); float bboxMaxX = max(max(max(Ax, Bx), max(Cx, Dx)), (float)max(x1 + r, x2 + r)); float bboxMinY = min(min(min(Ay, By), min(Cy, Dy)), (float)min(y1 - r, y2 - r)); float bboxMaxY = max(max(max(Ay, By), max(Cy, Dy)), (float)max(y1 + r, y2 + r)); int iMinY = (int)floor(bboxMinY); int iMaxY = (int)ceil(bboxMaxY); auto processEdge = [=](float x0, float y0, float x1, float y1, int y) -> float { if ((y0 <= y && y1 > y) || (y1 <= y && y0 > y)) { float t = (y - y0) / (y1 - y0); return x0 + t * (x1 - x0); } return NAN; }; struct Segment { float x0, x1; }; for (int y = iMinY; y <= iMaxY; y++) { Segment segments[3]; int segCount = 0; float inters[4]; int count = 0; float ttemp; ttemp = processEdge(Ax, Ay, Bx, By, y); if (!isnan(ttemp)) inters[count++] = ttemp; ttemp = processEdge(Bx, By, Cx, Cy, y); if (!isnan(ttemp)) inters[count++] = ttemp; ttemp = processEdge(Cx, Cy, Dx, Dy, y); if (!isnan(ttemp)) inters[count++] = ttemp; ttemp = processEdge(Dx, Dy, Ax, Ay, y); if (!isnan(ttemp)) inters[count++] = ttemp; if (count >= 2) { float qx0 = inters[0], qx1 = inters[0]; for (int i = 1; i < count; i++) { if (inters[i] < qx0) qx0 = inters[i]; if (inters[i] > qx1) qx1 = inters[i]; } segments[segCount++] = { qx0, qx1 }; } if (y >= y1 - r && y <= y1 + r) { float dyCap = y - y1; float dxCap = sqrt((float)(r * r - dyCap * dyCap)); float capStart = x1 - dxCap; float capEnd = x1 + dxCap; if (fabs(lx) > 1e-6) { float xBoundary = x1 - ((y - y1) * ly) / lx; if (lx > 0) { capEnd = min(capEnd, xBoundary); } else { capStart = max(capStart, xBoundary); } } else { if ((ly > 0 && y >= y1) || (ly < 0 && y <= y1)) { capStart = 1, capEnd = 0; } } if (capEnd >= capStart) segments[segCount++] = { capStart, capEnd }; } if (y >= y2 - r && y <= y2 + r) { float dyCap = y - y2; float dxCap = sqrt((float)(r * r - dyCap * dyCap)); float capStart = x2 - dxCap; float capEnd = x2 + dxCap; if (fabs(lx) > 1e-6) { float xBoundary = x2 - ((y - y2) * ly) / lx; if (lx > 0) { capStart = max(capStart, xBoundary); } else { capEnd = min(capEnd, xBoundary); } } else { if ((ly > 0 && y <= y2) || (ly < 0 && y >= y2)) { capStart = 1, capEnd = 0; } } if (capEnd >= capStart) segments[segCount++] = { capStart, capEnd }; } if (segCount == 0) continue; for (int i = 0; i < segCount - 1; i++) { for (int j = i + 1; j < segCount; j++) { if (segments[j].x0 < segments[i].x0) { Segment tmp = segments[i]; segments[i] = segments[j]; segments[j] = tmp; } } } float mergedStart = segments[0].x0; float mergedEnd = segments[0].x1; for (int i = 1; i < segCount; i++) { if (segments[i].x0 <= mergedEnd + 1) { if (segments[i].x1 > mergedEnd) mergedEnd = segments[i].x1; } else { int ix0 = (int)ceil(mergedStart); int ix1 = (int)floor(mergedEnd); if (ix1 >= ix0) canvas.drawFastHLine(ix0, y, ix1 - ix0 + 1, color); mergedStart = segments[i].x0; mergedEnd = segments[i].x1; } } int ix0 = (int)ceil(mergedStart); int ix1 = (int)floor(mergedEnd); if (ix1 >= ix0) canvas.drawFastHLine(ix0, y, ix1 - ix0 + 1, color); } } //======================================================================== // 1. Create the shape (a list of 3D points) defined by sin/cos functions. vector createShape() { vector shape; for (int i = 0; i <= NUM_SEGMENTS; ++i) { float t = (360.0 / NUM_SEGMENTS) * i; float rad = t * PI_F / 180.0; float y = -SCALE * (cos(rad) - 0.5 * cos(5 * rad)); float x = SCALE * (sin(rad) + 0.5 * sin(5 * rad)); float z = Z_SCALE * cos(3 * rad); shape.push_back({ x, y, z }); } return shape; } //======================================================================== // 2. Rotate the shape vector rotateShape(const vector& shape, float angleX, float angleY, float angleZ) { vector rotated; for (size_t i = 0; i < shape.size(); ++i) { // Apply rotations in order: X, then Y, then Z. Vec3 point = rotateX(shape[i], angleX); point = rotateY(point, angleY); point = rotateZ(point, angleZ); rotated.push_back(point); } return rotated; } //======================================================================== // 3. Render the shape by projecting the rotated 3D points into 2D, building segments, // sorting them by depth, and drawing them with thick lines. void renderShape(const vector& shape, int centerX, int centerY, float size) { int segmentIndex = 0; int prev_px_i = 0, prev_py_i = 0; float prev_z = 0; bool firstPoint = true; Segment3D segmentsLocal[NUM_SEGMENTS]; for (size_t i = 0; i < shape.size(); i++) { int px, py; project3D(shape[i], centerX, centerY, size, px, py); if (!firstPoint && px >= 0 && px < SCREEN_WIDTH && py >= 0 && py < SCREEN_HEIGHT) { float avgZ = (shape[i].z + prev_z) / 2.0; float perspectiveScale = 1.0f / (Z_OFFSET - avgZ); int thickness = baseThickness * perspectiveScale; if (thickness < 1) thickness = 1; if (thickness > 20) thickness = 20; segmentsLocal[segmentIndex++] = { prev_px_i, prev_py_i, px, py, avgZ, thickness }; } firstPoint = false; prev_px_i = px; prev_py_i = py; prev_z = shape[i].z; } // Depth sort (back to front) for (int i = 0; i < segmentIndex - 1; ++i) { for (int j = i + 1; j < segmentIndex; ++j) { if (segmentsLocal[j].avgZ < segmentsLocal[i].avgZ) { Segment3D tmp = segmentsLocal[i]; segmentsLocal[i] = segmentsLocal[j]; segmentsLocal[j] = tmp; } } } // Draw each segment with color based on depth. for (int i = 0; i < segmentIndex; ++i) { float depthFactor = (Z_OFFSET - segmentsLocal[i].avgZ - 1.5f); depthFactor = constrain(depthFactor, 0.0f, 1.0f); uint8_t brightnessg = (uint8_t)(99.0 - (depthFactor * 99.0 * 0.5)); uint8_t brightnessb = (uint8_t)(168.0 - (depthFactor * 168.0 * 0.5)); uint16_t color = rgb888_to_565(0, brightnessg, brightnessb); DrawThickLineRoundedHLines( canvas, segmentsLocal[i].x1, segmentsLocal[i].y1, segmentsLocal[i].x2, segmentsLocal[i].y2, segmentsLocal[i].thickness, color ); } } void setup() { tft.init(320, 480, 0, 0, ST7796S_BGR); tft.setSPISpeed(40000000); tft.invertDisplay(true); tft.setRotation(0); } void loop() { canvas.fillScreen(blackish); // Create the base shape only once (static initialization). static vector baseShape = createShape(); vector rotatedShape = rotateShape(baseShape, rotation, rotation, rotation); renderShape(rotatedShape, 30, 30, 50); canvas.setCursor(5, 60); canvas.setTextColor(rgb888_to_565(0, 99, 168)); canvas.print("EMBRIENT"); // Draw the off-screen canvas onto the display. tft.drawRGBBitmap(0, 0, canvas.getBuffer(), SCREEN_WIDTH, SCREEN_HEIGHT); // Increment rotation for next frame. rotation += 4.0; if (rotation >= 360.0) rotation -= 360.0; }