How to 3D Benchmark a PicoGamer

Welcome to this step-by-step tutorial on rendering a 3D rotating sphere using the Raspberry Pi Pico with HDMI output! If you're a hobbyist or a student eager to dive into graphics programming on microcontrollers, you're in the right place. We'll walk you through a program that not only displays a rotating 3D sphere but also performs a benchmark to determine how many polygons it can render at 30 frames per second (FPS).

Prerequisites

  • Arduino IDE Setup: If you haven't set up the Arduino IDE for the Raspberry Pi Pico, follow this guide to get started.
  • PicoDVI Library Installation: Ensure you have the PicoDVI library installed. If not, check out this tutorial for instructions.

Goals of the Program

By the end of this tutorial, you will:

  • Understand how to render 3D graphics on the Raspberry Pi Pico using the PicoDVI library.
  • Learn about basic 3D transformations, camera projection, and rendering techniques.
  • Run a benchmark that increases the complexity of the 3D sphere until the FPS drops below 30, displaying the maximum number of polygons rendered at that frame rate.
  • Gain insights into structuring and organizing code for better readability and understanding.

Quick Start: Copy and Paste into Arduino IDE

To see the program in action immediately, copy the following code and paste it into your Arduino IDE:

// Include necessary headers
#include <PicoDVI.h>
#include <math.h>
#include <vector>
#include <algorithm>  // For sorting polygons

// ------------------------------ Constants and Macros ------------------------------

// Screen dimensions
#define SCREEN_WIDTH 320
#define SCREEN_HEIGHT 240

// Perspective projection parameters
#define FOV 60.0                 // Field of view for perspective projection
#define ASPECT_RATIO (SCREEN_WIDTH / (float)SCREEN_HEIGHT)  // Aspect ratio of the display
#define NEAR_PLANE 0.1f          // Near clipping plane
#define FAR_PLANE 1000.0f        // Far clipping plane

// ------------------------------ Display Initialization ------------------------------

// Create display object with specified resolution and settings
DVIGFX8 display(DVI_RES_320x240p60, true, pico_sock_cfg);

// ------------------------------ Helper Structs and Functions ------------------------------

/**
 * @brief Represents a 3D vector or point
 */
struct Vector3 {
    float x, y, z;
};

/**
 * @brief Represents a 2D point on the screen
 */
struct Point2D {
    float x, y;
};

/**
 * @brief Rotates a 3D point by given rotation angles around the X, Y, and Z axes
 * @param point The 3D point to rotate
 * @param angles The rotation angles (in radians) around the X, Y, and Z axes
 * @return The rotated 3D point
 */
Vector3 rotatePoint(Vector3 point, Vector3 angles) {
    // Rotate around X axis
    float cosX = cos(angles.x);
    float sinX = sin(angles.x);
    float y = point.y * cosX - point.z * sinX;
    float z = point.y * sinX + point.z * cosX;
    point.y = y;
    point.z = z;

    // Rotate around Y axis
    float cosY = cos(angles.y);
    float sinY = sin(angles.y);
    float x = point.z * sinY + point.x * cosY;
    point.z = point.z * cosY - point.x * sinY;
    point.x = x;

    // Rotate around Z axis
    float cosZ = cos(angles.z);
    float sinZ = sin(angles.z);
    x = point.x * cosZ - point.y * sinZ;
    y = point.x * sinZ + point.y * cosZ;
    point.x = x;
    point.y = y;

    return point;
}

// ------------------------------ Classes ------------------------------

/**
 * @brief Camera class to handle camera position and rotation
 */
class Camera {
public:
    Vector3 position;  // Camera position in world space
    Vector3 rotation;  // Camera rotation angles

    /**
     * @brief Constructor for the Camera class
     * @param position Initial position of the camera
     * @param rotation Initial rotation of the camera
     */
    Camera(Vector3 position, Vector3 rotation) : position(position), rotation(rotation) {}

    /**
     * @brief Projects a 3D point onto the 2D screen using perspective projection
     * @param v The 3D point in world space
     * @return The 2D point on the screen
     */
    Point2D project(Vector3 v) {
        // Translate point to camera space (relative to camera position)
        Vector3 translated = {v.x - position.x, v.y - position.y, v.z - position.z};

        // Apply camera rotation
        Vector3 rotated = rotatePoint(translated, rotation);

        // Apply perspective projection
        if (rotated.z <= NEAR_PLANE) rotated.z = NEAR_PLANE;  // Avoid division by zero or negative depth

        float factor = FOV / (FOV + rotated.z);
        return Point2D{
            rotated.x * factor * ASPECT_RATIO + SCREEN_WIDTH / 2.0f,
            rotated.y * factor + SCREEN_HEIGHT / 2.0f
        };
    }
};

/**
 * @brief Polygon class representing a quadrilateral face of the sphere
 */
class Polygon {
private:
    Vector3 vertices[4];     // The four vertices of the polygon (quadrilateral)
    uint8_t outlineColor;    // Color index for the outline
    uint8_t fillColor;       // Color index for the fill

public:
    /**
     * @brief Constructor for the Polygon class
     * @param v0, v1, v2, v3 The four vertices of the quadrilateral
     * @param outlineColor The color index for the outline
     * @param fillColor The color index for the fill
     */
    Polygon(Vector3 v0, Vector3 v1, Vector3 v2, Vector3 v3, uint8_t outlineColor, uint8_t fillColor)
        : outlineColor(outlineColor), fillColor(fillColor) {
        vertices[0] = v0;
        vertices[1] = v1;
        vertices[2] = v2;
        vertices[3] = v3;
    }

    /**
     * @brief Computes the average depth (z-value) of the polygon after rotation
     * @param rotation The rotation angles to apply
     * @param objectCenter The center of the object (sphere)
     * @return The average depth value
     */
    float averageDepth(Vector3 rotation, Vector3 objectCenter) {
        float avgZ = 0;
        for (int i = 0; i < 4; i++) {
            // Compute vertex position relative to object center
            Vector3 relativeVertex = {
                vertices[i].x - objectCenter.x,
                vertices[i].y - objectCenter.y,
                vertices[i].z - objectCenter.z
            };
            // Rotate the vertex
            Vector3 rotatedVertex = rotatePoint(relativeVertex, rotation);
            // Accumulate the z-value
            avgZ += rotatedVertex.z;
        }
        return avgZ / 4.0f;
    }

    /**
     * @brief Draws the polygon onto the display using the given camera and rotation
     * @param camera The camera object for projection
     * @param rotation The rotation angles to apply
     * @param objectCenter The center of the object (sphere)
     */
    void draw(Camera& camera, Vector3 rotation, Vector3 objectCenter) {
        // Rotate all vertices around the object's center
        Vector3 rotatedVertices[4];
        for (int i = 0; i < 4; i++) {
            // Compute vertex position relative to object center
            Vector3 relativeVertex = {
                vertices[i].x - objectCenter.x,
                vertices[i].y - objectCenter.y,
                vertices[i].z - objectCenter.z
            };
            // Rotate the vertex
            rotatedVertices[i] = rotatePoint(relativeVertex, rotation);
            // Translate back to world position
            rotatedVertices[i].x += objectCenter.x;
            rotatedVertices[i].y += objectCenter.y;
            rotatedVertices[i].z += objectCenter.z;
        }

        // Project the rotated vertices into 2D screen space
        Point2D projected[4];
        for (int i = 0; i < 4; i++) {
            projected[i] = camera.project(rotatedVertices[i]);
        }

        // Draw the polygon as two triangles to fill the quadrilateral
        display.fillTriangle(
            projected[0].x, projected[0].y,
            projected[1].x, projected[1].y,
            projected[2].x, projected[2].y,
            fillColor
        );
        display.fillTriangle(
            projected[2].x, projected[2].y,
            projected[3].x, projected[3].y,
            projected[0].x, projected[0].y,
            fillColor
        );

        // Draw the outline of the polygon
        display.drawLine(projected[0].x, projected[0].y, projected[1].x, projected[1].y, outlineColor);
        display.drawLine(projected[1].x, projected[1].y, projected[2].x, projected[2].y, outlineColor);
        display.drawLine(projected[2].x, projected[2].y, projected[3].x, projected[3].y, outlineColor);
        display.drawLine(projected[3].x, projected[3].y, projected[0].x, projected[0].y, outlineColor);
    }
};

/**
 * @brief Sphere class representing a 3D sphere made up of polygons
 */
class Sphere {
private:
    std::vector faces;  // Collection of polygon faces composing the sphere

public:
    /**
     * @brief Constructor for the Sphere class
     * @param center The center position of the sphere
     * @param radius The radius of the sphere
     * @param slices Number of longitudinal slices (divisions around Y-axis)
     * @param stacks Number of latitudinal stacks (divisions along Y-axis)
     * @param faceColors Vector of color indices for face fill colors
     * @param outlineColors Vector of color indices for face outline colors
     */
    Sphere(Vector3 center, float radius, int slices, int stacks, std::vector faceColors, std::vector outlineColors) {
        // Generate sphere faces
        for (int i = 0; i < stacks; ++i) {
            // Calculate latitude angles
            float theta1 = (i * M_PI) / stacks - M_PI / 2;
            float theta2 = ((i + 1) * M_PI) / stacks - M_PI / 2;

            for (int j = 0; j < slices; ++j) {
                // Calculate longitude angles
                float phi1 = (j * 2 * M_PI) / slices;
                float phi2 = ((j + 1) * 2 * M_PI) / slices;

                // Compute vertices of the quadrilateral
                Vector3 v0 = {
                    center.x + radius * cos(theta1) * cos(phi1),
                    center.y + radius * sin(theta1),
                    center.z + radius * cos(theta1) * sin(phi1)
                };
                Vector3 v1 = {
                    center.x + radius * cos(theta1) * cos(phi2),
                    center.y + radius * sin(theta1),
                    center.z + radius * cos(theta1) * sin(phi2)
                };
                Vector3 v2 = {
                    center.x + radius * cos(theta2) * cos(phi2),
                    center.y + radius * sin(theta2),
                    center.z + radius * cos(theta2) * sin(phi2)
                };
                Vector3 v3 = {
                    center.x + radius * cos(theta2) * cos(phi1),
                    center.y + radius * sin(theta2),
                    center.z + radius * cos(theta2) * sin(phi1)
                };

                // Create the polygon for this face
                faces.emplace_back(Polygon(
                    v0, v1, v2, v3,
                    outlineColors[i % outlineColors.size()],
                    faceColors[i % faceColors.size()]
                ));
            }
        }
    }

    /**
     * @brief Draws the sphere onto the display using the given camera and rotation
     * @param camera The camera object for projection
     * @param rotation The rotation angles to apply
     */
    void draw(Camera& camera, Vector3 rotation) {
        Vector3 sphereCenter = {0, 0, 0};  // Sphere's center

        // Sort the faces based on their average depth (farther faces drawn first)
        std::sort(faces.begin(), faces.end(), [&](Polygon& a, Polygon& b) {
            return a.averageDepth(rotation, sphereCenter) > b.averageDepth(rotation, sphereCenter);
        });

        // Draw the sorted faces
        for (auto& face : faces) {
            face.draw(camera, rotation, sphereCenter);
        }
    }
};

// ------------------------------ Global Variables ------------------------------

// Initialize the camera with a position and rotation
Camera camera(
    Vector3{0, 0, -150},  // Camera positioned along the negative Z-axis
    Vector3{0, 0, 0}      // No initial rotation
);

// Define face and outline colors for the sphere
std::vector faceColors = {4, 6, 8, 10, 12, 14};        // Red, Green, Blue, Yellow, Magenta, Cyan
std::vector outlineColors = {16, 17, 18, 19, 20, 21};  // Dark Red, Dark Green, Dark Blue, Dark Yellow, Dark Magenta, Dark Cyan

// Initialize the sphere with initial slices and stacks
Sphere sphere(
    Vector3{0, 0, 0},  // Sphere centered at the origin
    40,                // Radius of the sphere
    4,                 // Initial number of slices
    4,                 // Initial number of stacks
    faceColors,
    outlineColors
);

// Variables for rotation angles
float angleX = 0;
float angleY = 0;
float angleZ = 0;

// Benchmarking variables
unsigned long startTime = millis();
int frameCount = 0;
int slices = 4;
int stacks = 4;
bool benchmarkComplete = false;
float avgFPS = 0;
unsigned long lastFPSTime = millis();
int fpsFrameCount = 0;
float fps = 0;

// ------------------------------ Setup Function ------------------------------

/**
 * @brief Arduino setup function called once at startup
 */
void setup() {
    // Initialize the display
    if (!display.begin()) {
        // If initialization fails, blink the built-in LED as an error indicator
        pinMode(LED_BUILTIN, OUTPUT);
        for (;;) digitalWrite(LED_BUILTIN, (millis() / 500) & 1);
    }

    // Define a 32-color VGA palette
    display.setColor(0, 0, 0, 0);         // Color 0: Black
    display.setColor(1, 255, 255, 255);   // Color 1: White
    display.setColor(2, 128, 128, 128);   // Color 2: Dark Gray
    display.setColor(3, 192, 192, 192);   // Color 3: Light Gray
    display.setColor(4, 255, 0, 0);       // Color 4: Red
    display.setColor(5, 255, 128, 128);   // Color 5: Light Red
    display.setColor(6, 0, 255, 0);       // Color 6: Green
    display.setColor(7, 128, 255, 128);   // Color 7: Light Green
    display.setColor(8, 0, 0, 255);       // Color 8: Blue
    display.setColor(9, 128, 128, 255);   // Color 9: Light Blue
    display.setColor(10, 255, 255, 0);    // Color 10: Yellow
    display.setColor(11, 255, 255, 128);  // Color 11: Light Yellow
    display.setColor(12, 255, 0, 255);    // Color 12: Magenta
    display.setColor(13, 255, 128, 255);  // Color 13: Light Magenta
    display.setColor(14, 0, 255, 255);    // Color 14: Cyan
    display.setColor(15, 128, 255, 255);  // Color 15: Light Cyan
    // Additional 16 colors
    display.setColor(16, 128, 0, 0);      // Color 16: Dark Red
    display.setColor(17, 0, 128, 0);      // Color 17: Dark Green
    display.setColor(18, 0, 0, 128);      // Color 18: Dark Blue
    display.setColor(19, 128, 128, 0);    // Color 19: Dark Yellow
    display.setColor(20, 128, 0, 128);    // Color 20: Dark Magenta
    display.setColor(21, 0, 128, 128);    // Color 21: Dark Cyan
    display.setColor(22, 192, 0, 0);      // Color 22: Darker Red
    display.setColor(23, 0, 192, 0);      // Color 23: Darker Green
    display.setColor(24, 0, 0, 192);      // Color 24: Darker Blue
    display.setColor(25, 192, 192, 0);    // Color 25: Darker Yellow
    display.setColor(26, 192, 0, 192);    // Color 26: Darker Magenta
    display.setColor(27, 0, 192, 192);    // Color 27: Darker Cyan
    display.setColor(28, 64, 0, 0);       // Color 28: Very Dark Red
    display.setColor(29, 0, 64, 0);       // Color 29: Very Dark Green
    display.setColor(30, 0, 0, 64);       // Color 30: Very Dark Blue
    display.setColor(31, 64, 64, 64);     // Color 31: Very Dark Gray

    // Enable double buffering for smooth animations
    display.swap(false, true);
}

// ------------------------------ Main Loop Function ------------------------------

/**
 * @brief Arduino main loop function called repeatedly after setup()
 */
void loop() {
    unsigned long currentTime = millis();

    // Update rotation angles
    angleX += 0.02f;
    angleY += 0.01f;
    angleZ += 0.015f;

    Vector3 rotation = {angleX, angleY, angleZ};

    // Clear the screen to black
    display.fillScreen(0);

    // Render the sphere
    sphere.draw(camera, rotation);

    // Increment frame counts for FPS calculation
    frameCount++;
    fpsFrameCount++;

    // Calculate FPS every second
    if (currentTime - lastFPSTime >= 1000) {
        fps = fpsFrameCount * 1000.0f / (currentTime - lastFPSTime);
        fpsFrameCount = 0;
        lastFPSTime = currentTime;
    }

    // Display the current FPS on the screen
    display.setCursor(240, 5);  // Position at the top-right corner
    display.setTextColor(1);    // White color
    display.setTextSize(1);
    display.printf("FPS: %.2f", fps);

    if (!benchmarkComplete) {
        // Display countdown timer during benchmarking
        int timeLeft = 10 - ((currentTime - startTime) / 1000);
        if (timeLeft < 0) timeLeft = 0;
        display.setCursor(5, 5);  // Position at the top-left corner
        display.setTextColor(1);  // White color
        display.setTextSize(1);
        display.printf("Time Left: %d s", timeLeft);

        // After 10 seconds, calculate average FPS
        if ((currentTime - startTime) >= 10000) {
            avgFPS = (float)frameCount * 1000.0f / (currentTime - startTime);

            if (avgFPS < 30.0f) {
                // Benchmark complete
                benchmarkComplete = true;
                int numPolygons = slices * stacks * 2;  // Each face is two polygons
                // Replace "Time Left" with the final result
                display.setCursor(5, 5);  // Position at the top-left corner
                display.setTextColor(1);  // White color
                display.setTextSize(1);
                display.printf("Max Polygons at 30 FPS: %d", numPolygons);
            } else {
                // Increase slices and stacks to increase complexity
                slices++;
                stacks++;
                // Recreate sphere with increased complexity
                sphere = Sphere(
                    Vector3{0, 0, 0},  // Center of the sphere
                    40,                // Radius of the sphere
                    slices,
                    stacks,
                    faceColors,
                    outlineColors
                );
                // Reset frame counts and start time for the next benchmark
                frameCount = 0;
                startTime = currentTime;
            }
        }
    } else {
        // Benchmark is complete; display the final result
        display.setCursor(5, 5);  // Position at the top-left corner
        display.setTextColor(1);  // White color
        display.setTextSize(1);
        int numPolygons = slices * stacks * 2;  // Each face is two polygons
        display.printf("Max Polygons at 30 FPS: %d", numPolygons);
    }

    // Swap buffers to display the rendered frame
    display.swap();
}

Breaking the Program Down into Understandable Parts

Let's delve into the code to understand how it works. We'll go through each section, explaining the purpose and functionality in simple terms.

Using DVI over HDMI

First, it's essential to understand that the Raspberry Pi Pico doesn't output true HDMI signals. Instead, it uses DVI (Digital Visual Interface) signals, which are compatible with HDMI connectors. This means you can connect your Pico to an HDMI display, but the signal it's sending is actually DVI. The PicoDVI library handles this conversion, allowing us to output graphics to modern displays.

Including Necessary Libraries

#include <PicoDVI.h>
#include <math.h>
#include <vector>
#include <algorithm>  // For sorting polygons
  • <PicoDVI.h>: This is the main library that allows the Pico to output video signals over DVI.
  • <math.h>: Provides mathematical functions like sine and cosine, which are essential for 3D transformations.
  • <vector>: Allows us to use dynamic arrays (vectors) to store our polygons.
  • <algorithm>: Provides functions like std::sort, which we'll use to sort polygons based on depth for proper rendering.

Constants and Macros

#define SCREEN_WIDTH 320
#define SCREEN_HEIGHT 240
#define FOV 60.0
#define ASPECT_RATIO (SCREEN_WIDTH / (float)SCREEN_HEIGHT)
#define NEAR_PLANE 0.1f
#define FAR_PLANE 1000.0f
  • SCREEN_WIDTH and SCREEN_HEIGHT: Define the dimensions of our display.
  • FOV: Field of view for our 3D projection. Affects how "zoomed in" the scene appears.
  • ASPECT_RATIO: Ensures our 3D projection doesn't look stretched or squished.
  • NEAR_PLANE and FAR_PLANE: Define the closest and farthest distances that will be rendered. Objects outside this range won't be displayed.

Display Initialization

DVIGFX8 display(DVI_RES_320x240p60, true, pico_sock_cfg);
  • DVIGFX8 display: Creates a display object using the DVIGFX8 class from the PicoDVI library.
  • DVI_RES_320x240p60: Sets the resolution to 320x240 pixels at 60 Hz.
  • true: Enables double buffering, which helps in reducing flickering by drawing frames off-screen before displaying them.
  • pico_sock_cfg: Configuration settings for the PicoGamer's HDMI output.

Helper Structs and Functions

Vector3 and Point2D Structures
struct Vector3 {
    float x, y, z;
};

struct Point2D {
    float x, y;
};
  • Vector3: Represents a point or vector in 3D space with x, y, and z coordinates.
  • Point2D: Represents a point on the 2D screen with x and y coordinates.
rotatePoint Function
Vector3 rotatePoint(Vector3 point, Vector3 angles) {
    // Rotate around X axis
    float cosX = cos(angles.x);
    float sinX = sin(angles.x);
    float y = point.y * cosX - point.z * sinX;
    float z = point.y * sinX + point.z * cosX;
    point.y = y;
    point.z = z;

    // Rotate around Y axis
    float cosY = cos(angles.y);
    float sinY = sin(angles.y);
    float x = point.z * sinY + point.x * cosY;
    point.z = point.z * cosY - point.x * sinY;
    point.x = x;

    // Rotate around Z axis
    float cosZ = cos(angles.z);
    float sinZ = sin(angles.z);
    x = point.x * cosZ - point.y * sinZ;
    y = point.x * sinZ + point.y * cosZ;
    point.x = x;
    point.y = y;

    return point;
}

Purpose: Rotates a 3D point around the X, Y, and Z axes by the specified angles.

Process:

  • Rotate around X-axis: Adjusts y and z coordinates.
  • Rotate around Y-axis: Adjusts x and z coordinates.
  • Rotate around Z-axis: Adjusts x and y coordinates.

Uses: Applies rotation transformations to points in our 3D space.

Camera Class

class Camera {
public:
    Vector3 position;  // Camera position in world space
    Vector3 rotation;  // Camera rotation angles

    // Constructor for the Camera class
    Camera(Vector3 position, Vector3 rotation) : position(position), rotation(rotation) {}

    // Projects a 3D point onto the 2D screen using perspective projection
    Point2D project(Vector3 v) {
        // Translate point to camera space (relative to camera position)
        Vector3 translated = {v.x - position.x, v.y - position.y, v.z - position.z};

        // Apply camera rotation
        Vector3 rotated = rotatePoint(translated, rotation);

        // Apply perspective projection
        if (rotated.z <= NEAR_PLANE) rotated.z = NEAR_PLANE;  // Avoid division by zero or negative depth

        float factor = FOV / (FOV + rotated.z);
        return Point2D{
            rotated.x * factor * ASPECT_RATIO + SCREEN_WIDTH / 2.0f,
            rotated.y * factor + SCREEN_HEIGHT / 2.0f
        };
    }
};
  • Purpose: Represents the viewer's point of view in the 3D world.
  • Components:
    • position: Where the camera is located in the 3D space.
    • rotation: The direction the camera is facing.
  • Methods:
    • Constructor: Initializes the camera's position and rotation.
    • project Method: Converts a 3D point into a 2D point on the screen.
      • Translation: Adjusts points relative to the camera's position.
      • Rotation: Applies the camera's rotation to the points.
      • Projection: Projects the 3D points onto the 2D screen using perspective projection.

Polygon Class

class Polygon {
private:
    Vector3 vertices[4];     // The four vertices of the polygon (quadrilateral)
    uint8_t outlineColor;    // Color index for the outline
    uint8_t fillColor;       // Color index for the fill

public:
    // Constructor for the Polygon class
    Polygon(Vector3 v0, Vector3 v1, Vector3 v2, Vector3 v3, uint8_t outlineColor, uint8_t fillColor)
        : outlineColor(outlineColor), fillColor(fillColor) {
        vertices[0] = v0;
        vertices[1] = v1;
        vertices[2] = v2;
        vertices[3] = v3;
    }

    // Computes the average depth (z-value) of the polygon after rotation
    float averageDepth(Vector3 rotation, Vector3 objectCenter) {
        float avgZ = 0;
        for (int i = 0; i < 4; i++) {
            // Compute vertex position relative to object center
            Vector3 relativeVertex = {
                vertices[i].x - objectCenter.x,
                vertices[i].y - objectCenter.y,
                vertices[i].z - objectCenter.z
            };
            // Rotate the vertex
            Vector3 rotatedVertex = rotatePoint(relativeVertex, rotation);
            // Accumulate the z-value
            avgZ += rotatedVertex.z;
        }
        return avgZ / 4.0f;
    }

    // Draws the polygon onto the display using the given camera and rotation
    void draw(Camera& camera, Vector3 rotation, Vector3 objectCenter) {
        // Rotate all vertices around the object's center
        Vector3 rotatedVertices[4];
        for (int i = 0; i < 4; i++) {
            // Compute vertex position relative to object center
            Vector3 relativeVertex = {
                vertices[i].x - objectCenter.x,
                vertices[i].y - objectCenter.y,
                vertices[i].z - objectCenter.z
            };
            // Rotate the vertex
            rotatedVertices[i] = rotatePoint(relativeVertex, rotation);
            // Translate back to world position
            rotatedVertices[i].x += objectCenter.x;
            rotatedVertices[i].y += objectCenter.y;
            rotatedVertices[i].z += objectCenter.z;
        }

        // Project the rotated vertices into 2D screen space
        Point2D projected[4];
        for (int i = 0; i < 4; i++) {
            projected[i] = camera.project(rotatedVertices[i]);
        }

        // Draw the polygon as two triangles to fill the quadrilateral
        display.fillTriangle(
            projected[0].x, projected[0].y,
            projected[1].x, projected[1].y,
            projected[2].x, projected[2].y,
            fillColor
        );
        display.fillTriangle(
            projected[2].x, projected[2].y,
            projected[3].x, projected[3].y,
            projected[0].x, projected[0].y,
            fillColor
        );

        // Draw the outline of the polygon
        display.drawLine(projected[0].x, projected[0].y, projected[1].x, projected[1].y, outlineColor);
        display.drawLine(projected[1].x, projected[1].y, projected[2].x, projected[2].y, outlineColor);
        display.drawLine(projected[2].x, projected[2].y, projected[3].x, projected[3].y, outlineColor);
        display.drawLine(projected[3].x, projected[3].y, projected[0].x, projected[0].y, outlineColor);
    }
};
  • Purpose: Represents a quadrilateral face of the sphere.
  • Components:
    • vertices: An array of four Vector3 points defining the corners of the polygon.
    • outlineColor: The color used to draw the edges of the polygon.
    • fillColor: The color used to fill the polygon.
  • Methods:
    • Constructor: Initializes the polygon with its vertices and colors.
    • averageDepth Method: Calculates the average depth (distance from the camera) of the polygon to determine rendering order.
    • draw Method: Renders the polygon onto the display.
      • Rotation: Applies rotation to the vertices.
      • Projection: Converts 3D vertices to 2D screen points.
      • Drawing: Uses fillTriangle and drawLine functions to render the filled polygon and its outline.

Sphere Class

class Sphere {
private:
    std::vector faces;  // Collection of polygon faces composing the sphere

public:
    // Constructor for the Sphere class
    Sphere(Vector3 center, float radius, int slices, int stacks, std::vector faceColors, std::vector outlineColors) {
        // Generate sphere faces
        for (int i = 0; i < stacks; ++i) {
            // Calculate latitude angles
            float theta1 = (i * M_PI) / stacks - M_PI / 2;
            float theta2 = ((i + 1) * M_PI) / stacks - M_PI / 2;

            for (int j = 0; j < slices; ++j) {
                // Calculate longitude angles
                float phi1 = (j * 2 * M_PI) / slices;
                float phi2 = ((j + 1) * 2 * M_PI) / slices;

                // Compute vertices of the quadrilateral
                Vector3 v0 = {
                    center.x + radius * cos(theta1) * cos(phi1),
                    center.y + radius * sin(theta1),
                    center.z + radius * cos(theta1) * sin(phi1)
                };
                Vector3 v1 = {
                    center.x + radius * cos(theta1) * cos(phi2),
                    center.y + radius * sin(theta1),
                    center.z + radius * cos(theta1) * sin(phi2)
                };
                Vector3 v2 = {
                    center.x + radius * cos(theta2) * cos(phi2),
                    center.y + radius * sin(theta2),
                    center.z + radius * cos(theta2) * sin(phi2)
                };
                Vector3 v3 = {
                    center.x + radius * cos(theta2) * cos(phi1),
                    center.y + radius * sin(theta2),
                    center.z + radius * cos(theta2) * sin(phi1)
                };

                // Create the polygon for this face
                faces.emplace_back(Polygon(
                    v0, v1, v2, v3,
                    outlineColors[i % outlineColors.size()],
                    faceColors[i % faceColors.size()]
                ));
            }
        }
    }

    // Draws the sphere onto the display using the given camera and rotation
    void draw(Camera& camera, Vector3 rotation) {
        Vector3 sphereCenter = {0, 0, 0};  // Sphere's center

        // Sort the faces based on their average depth (farther faces drawn first)
        std::sort(faces.begin(), faces.end(), [&](Polygon& a, Polygon& b) {
            return a.averageDepth(rotation, sphereCenter) > b.averageDepth(rotation, sphereCenter);
        });

        // Draw the sorted faces
        for (auto& face : faces) {
            face.draw(camera, rotation, sphereCenter);
        }
    }
};
  • Purpose: Represents the 3D sphere composed of multiple polygons.
  • Components:
    • faces: A vector containing all the polygons (faces) that make up the sphere.
  • Methods:
    • Constructor: Generates the sphere by creating polygons based on the specified number of slices and stacks.
      • Slices: Divisions along the longitude (like the segments of an orange).
      • Stacks: Divisions along the latitude (like layers of an onion).
      • Vertices Calculation: Determines the position of each vertex using spherical coordinates.
    • draw Method: Renders the sphere by drawing each polygon.
      • Depth Sorting: Polygons are sorted based on their depth to ensure proper rendering (farther polygons drawn first).
      • Drawing Polygons: Calls the draw method of each Polygon object.

Global Variables

// Initialize the camera with a position and rotation
Camera camera(
    Vector3{0, 0, -150},  // Camera positioned along the negative Z-axis
    Vector3{0, 0, 0}      // No initial rotation
);

// Define face and outline colors for the sphere
std::vector faceColors = {4, 6, 8, 10, 12, 14};        // Red, Green, Blue, Yellow, Magenta, Cyan
std::vector outlineColors = {16, 17, 18, 19, 20, 21};  // Dark Red, Dark Green, Dark Blue, Dark Yellow, Dark Magenta, Dark Cyan

// Initialize the sphere with initial slices and stacks
Sphere sphere(
    Vector3{0, 0, 0},  // Sphere centered at the origin
    40,                // Radius of the sphere
    4,                 // Initial number of slices
    4,                 // Initial number of stacks
    faceColors,
    outlineColors
);

// Variables for rotation angles
float angleX = 0;
float angleY = 0;
float angleZ = 0;

// Benchmarking variables
unsigned long startTime = millis();
int frameCount = 0;
int slices = 4;
int stacks = 4;
bool benchmarkComplete = false;
float avgFPS = 0;
unsigned long lastFPSTime = millis();
int fpsFrameCount = 0;
float fps = 0;
  • camera: Initialized with a position 150 units along the negative Z-axis, facing the origin.
  • faceColors: A list of color indices used to fill the polygons of the sphere.
  • outlineColors: A list of color indices used for the edges of the polygons.
  • sphere: Creates a sphere centered at the origin with a radius of 40 units, divided into 4 slices and 4 stacks.
  • angleX, angleY, angleZ: Variables that track the rotation angles around the X, Y, and Z axes.
  • Benchmarking Variables:
    • startTime: Records when the benchmark started.
    • frameCount: Counts the number of frames rendered.
    • slices and stacks: Keep track of the current complexity of the sphere.
    • benchmarkComplete: Indicates whether the benchmark is finished.
    • avgFPS: Stores the average FPS calculated during the benchmark.
    • lastFPSTime and fpsFrameCount: Used to calculate the FPS every second.
    • fps: Stores the current FPS.

Setup Function

void setup() {
    // Initialize the display
    if (!display.begin()) {
        // If initialization fails, blink the built-in LED as an error indicator
        pinMode(LED_BUILTIN, OUTPUT);
        for (;;) digitalWrite(LED_BUILTIN, (millis() / 500) & 1);
    }

    // Define a 32-color VGA palette
    display.setColor(0, 0, 0, 0);         // Color 0: Black
    display.setColor(1, 255, 255, 255);   // Color 1: White
    display.setColor(2, 128, 128, 128);   // Color 2: Dark Gray
    display.setColor(3, 192, 192, 192);   // Color 3: Light Gray
    display.setColor(4, 255, 0, 0);       // Color 4: Red
    display.setColor(5, 255, 128, 128);   // Color 5: Light Red
    display.setColor(6, 0, 255, 0);       // Color 6: Green
    display.setColor(7, 128, 255, 128);   // Color 7: Light Green
    display.setColor(8, 0, 0, 255);       // Color 8: Blue
    display.setColor(9, 128, 128, 255);   // Color 9: Light Blue
    display.setColor(10, 255, 255, 0);    // Color 10: Yellow
    display.setColor(11, 255, 255, 128);  // Color 11: Light Yellow
    display.setColor(12, 255, 0, 255);    // Color 12: Magenta
    display.setColor(13, 255, 128, 255);  // Color 13: Light Magenta
    display.setColor(14, 0, 255, 255);    // Color 14: Cyan
    display.setColor(15, 128, 255, 255);  // Color 15: Light Cyan
    // Additional 16 colors
    display.setColor(16, 128, 0, 0);      // Color 16: Dark Red
    display.setColor(17, 0, 128, 0);      // Color 17: Dark Green
    display.setColor(18, 0, 0, 128);      // Color 18: Dark Blue
    display.setColor(19, 128, 128, 0);    // Color 19: Dark Yellow
    display.setColor(20, 128, 0, 128);    // Color 20: Dark Magenta
    display.setColor(21, 0, 128, 128);    // Color 21: Dark Cyan
    display.setColor(22, 192, 0, 0);      // Color 22: Darker Red
    display.setColor(23, 0, 192, 0);      // Color 23: Darker Green
    display.setColor(24, 0, 0, 192);      // Color 24: Darker Blue
    display.setColor(25, 192, 192, 0);    // Color 25: Darker Yellow
    display.setColor(26, 192, 0, 192);    // Color 26: Darker Magenta
    display.setColor(27, 0, 192, 192);    // Color 27: Darker Cyan
    display.setColor(28, 64, 0, 0);       // Color 28: Very Dark Red
    display.setColor(29, 0, 64, 0);       // Color 29: Very Dark Green
    display.setColor(30, 0, 0, 64);       // Color 30: Very Dark Blue
    display.setColor(31, 64, 64, 64);     // Color 31: Very Dark Gray

    // Enable double buffering for smooth animations
    display.swap(false, true);
}

Display Initialization:

  • display.begin(): Initializes the display. If it fails, the built-in LED blinks as an error indicator.
  • Color Palette: Defines a palette of 32 colors that can be used in the program.
  • Double Buffering: Enabled by display.swap(false, true); to prevent flickering by drawing frames off-screen before displaying them.

Main Loop Function

void loop() {
    unsigned long currentTime = millis();

    // Update rotation angles
    angleX += 0.02f;
    angleY += 0.01f;
    angleZ += 0.015f;

    Vector3 rotation = {angleX, angleY, angleZ};

    // Clear the screen to black
    display.fillScreen(0);

    // Render the sphere
    sphere.draw(camera, rotation);

    // Increment frame counts for FPS calculation
    frameCount++;
    fpsFrameCount++;

    // Calculate FPS every second
    if (currentTime - lastFPSTime >= 1000) {
        fps = fpsFrameCount * 1000.0f / (currentTime - lastFPSTime);
        fpsFrameCount = 0;
        lastFPSTime = currentTime;
    }

    // Display the current FPS on the screen
    display.setCursor(240, 5);  // Position at the top-right corner
    display.setTextColor(1);    // White color
    display.setTextSize(1);
    display.printf("FPS: %.2f", fps);

    if (!benchmarkComplete) {
        // Display countdown timer during benchmarking
        int timeLeft = 10 - ((currentTime - startTime) / 1000);
        if (timeLeft < 0) timeLeft = 0;
        display.setCursor(5, 5);  // Position at the top-left corner
        display.setTextColor(1);  // White color
        display.setTextSize(1);
        display.printf("Time Left: %d s", timeLeft);

        // After 10 seconds, calculate average FPS
        if ((currentTime - startTime) >= 10000) {
            avgFPS = (float)frameCount * 1000.0f / (currentTime - startTime);

            if (avgFPS < 30.0f) {
                // Benchmark complete
                benchmarkComplete = true;
                int numPolygons = slices * stacks * 2;  // Each face is two polygons
                // Replace "Time Left" with the final result
                display.setCursor(5, 5);  // Position at the top-left corner
                display.setTextColor(1);  // White color
                display.setTextSize(1);
                display.printf("Max Polygons at 30 FPS: %d", numPolygons);
            } else {
                // Increase slices and stacks to increase complexity
                slices++;
                stacks++;
                // Recreate sphere with increased complexity
                sphere = Sphere(
                    Vector3{0, 0, 0},  // Center of the sphere
                    40,                // Radius of the sphere
                    slices,
                    stacks,
                    faceColors,
                    outlineColors
                );
                // Reset frame counts and start time for the next benchmark
                frameCount = 0;
                startTime = currentTime;
            }
        }
    } else {
        // Benchmark is complete; display the final result
        display.setCursor(5, 5);  // Position at the top-left corner
        display.setTextColor(1);  // White color
        display.setTextSize(1);
        int numPolygons = slices * stacks * 2;  // Each face is two polygons
        display.printf("Max Polygons at 30 FPS: %d", numPolygons);
    }

    // Swap buffers to display the rendered frame
    display.swap();
}

Functionality:

  • Rotation Update: Increments the rotation angles to animate the sphere.
  • Screen Clearing: Clears the screen by filling it with color index 0 (black).
  • Rendering: Draws the sphere with the current rotation and camera settings.
  • FPS Calculation: Updates and displays the frames per second every second.
  • Benchmark Logic: Increases the complexity of the sphere by adding more slices and stacks every 10 seconds until the FPS drops below 30.
  • Buffer Swap: Displays the drawn frame by swapping the buffers.

Conclusion

By understanding each part of the code, you now have a solid grasp of how 3D rendering works on a microcontroller like the Raspberry Pi Pico using the PicoDVI library. Feel free to experiment with different values for slices, stacks, rotation speeds, and camera positions to see how they affect the rendering and performance. Happy coding!


Happy Benchmarking with Your PicoGamer v1.2 DIY Edition!