Project 4: Paper Airplane Flight Simulator

Difficulty: Advanced    Version: V15

V15 means 15 iterations! This project evolved through many versions: basic wireframe, rotation, leaves, fog/horizon, autopilot. Build complex things piece by piece!

What's New

This project combines everything so far and adds 3D projection - making flat screens show depth.

Concept What It Means
3D Model Points defined in X, Y, Z space that form a paper airplane shape.
Perspective Projection Far things look smaller. The formula: screenX = worldX / distance.
Pitch & Roll Aircraft rotation axes - nose up/down (pitch) and wing tilt (roll).
Depth Sorting Drawing far objects first so near objects appear in front.
Autopilot When idle, the plane flies itself with gentle movements.
Dithering Checkerboard patterns that simulate gray on a 1-bit display.

Controls

Touch Action
LEFT (D0)Roll left
RIGHT (D3)Roll right
UP (D1)Pitch up (climb)
DOWN (D2)Pitch down (dive)
No touch for 2sAutopilot takes over

The Code

This is a longer sketch. Take time to read through the sections!

/*
 * WireFramePaperAirplane.ino
 * V15: Horizon & Fog
 *
 * Features:
 * - Wireframe 3D paper airplane
 * - 40 falling leaves with different shapes
 * - Scenic autopilot when idle
 * - Manual control overrides autopilot
 */

#include <Adafruit_GFX.h>
#include <Adafruit_SH110X.h>
#include <Wire.h>

Adafruit_SH1106G display = Adafruit_SH1106G(128, 64, &Wire, -1);

// Touch Pins
const int PIN_LEFT = 1;
const int PIN_UP = 2;
const int PIN_DOWN = 3;
const int PIN_RIGHT = 4;

// 3D Point structures
struct Point3D { float x, y, z; };
struct Point2D { int x, y; };

// Paper Airplane Model - 7 vertices
const int NUM_VERTS = 7;
Point3D model[NUM_VERTS] = {
    {0, 0, 0},      // 0: Nose
    {0, 9, -18},    // 1: Tail
    {-9, 9, -18},   // 2: Left Wing
    {9, 9, -18},    // 3: Right Wing
    {0, 12, -18},   // 4: Keel
    {0, 5, -6.5},   // 5: Canopy Front
    {0, 6, -11.5// 6: Canopy Rear
};

// Physics state
float pitch = 0.6;
float roll = 0;
float altitude = 0;

// Autopilot
unsigned long lastTouchTime = 0;
const int IDLE_TIMEOUT = 2000;
const float CRUISE_PITCH = 0.6;
const float PLANE_Z_CENTER = -9.0;

// Leaves (40 of them!)
const int NUM_LEAVES = 40;

struct Leaf {
  float x, y, z;
  float size;
  float angleOffset;
  float speed_mult;
  int type;
  int ditherType;
};
Leaf leaves[NUM_LEAVES];
int leafOrder[NUM_LEAVES];

// Leaf shape definitions (polygon vertices)
const int MAPLE_COUNT = 16;
const float SHAPE_MAPLE[] = {0,-1.0, 0.2,-0.6, 0.5,-0.8, 0.7,-0.5,
                             0.4,-0.2, 0.8,0.2, 0.6,0.6, 0.2,0.5,
                             0.0,0.8, -0.2,0.5, -0.6,0.6, -0.8,0.2,
                             -0.4,-0.2, -0.7,-0.5, -0.5,-0.8, -0.2,-0.6};

const int OAK_COUNT = 14;
const float SHAPE_OAK[] = {0,-1.0, 0.3,-0.8, 0.5,-0.6, 0.3,-0.4, 0.6,0.0,
                           0.3,0.4, 0.4,0.8, 0.0,1.0, -0.4,0.8, -0.3,0.4,
                           -0.6,0.0, -0.3,-0.4, -0.5,-0.6, -0.3,-0.8};

const int BIRCH_COUNT = 8;
const float SHAPE_BIRCH[] = {0,-1.0, 0.4,-0.3, 0.5,0.2, 0.3,0.6,
                             0.0,0.8, -0.3,0.6, -0.5,0.2, -0.4,-0.3};

void setup() {
  Serial.begin(115200);
  Wire.begin(5, 6);
  Wire.setClock(400000);
  display.begin(0x3C, true);

  // Initialize leaves with random positions
  for (int i = 0; i < NUM_LEAVES; i++) {
    leaves[i].x = random(-250, 250);
    leaves[i].y = random(-250, 250);
    leaves[i].z = random(-120, 400);
    leaves[i].size = random(25, 55);
    leaves[i].angleOffset = random(0, 628) / 100.0;
    leaves[i].speed_mult = random(8, 14) / 10.0;
    leafOrder[i] = i;

    int r = random(0, 100);
    if (r < 40) { leaves[i].type = 0; leaves[i].ditherType = 1; }      // Maple
    else if (r < 70) { leaves[i].type = 1; leaves[i].ditherType = 2; } // Oak
    else { leaves[i].type = 2; leaves[i].ditherType = 0; }             // Birch
  }
}

// Project 3D point to 2D screen
Point2D project(float x, float y, float z) {
  // Apply pitch rotation
  float py2 = y * cos(-pitch) - z * sin(-pitch);
  float pz2 = y * sin(-pitch) + z * cos(-pitch);

  // Apply roll rotation
  float px3 = x * cos(-roll) - py2 * sin(-roll);
  float py3 = x * sin(-roll) + py2 * cos(-roll);

  // Perspective projection (camera at Z = -150)
  float pz3 = pz2 + 150.0;
  if (pz3 < 1) pz3 = 1;
  float scale = 300.0 / pz3;

  return {(int)(64 + px3 * scale), (int)(28 + py3 * scale)};
}

// Dithered triangle fill (simulates shading on 1-bit display)
void fillTriangleDither(int x0, int y0, int x1, int y1, int x2, int y2, int ditherType) {
  int minX = max(0, min(127, min(x0, min(x1, x2))));
  int maxX = min(127, max(0, max(x0, max(x1, x2))));
  int minY = max(0, min(63, min(y0, min(y1, y2))));
  int maxY = min(63, max(0, max(y0, max(y1, y2))));

  for (int y = minY; y <= maxY; y++) {
    for (int x = minX; x <= maxX; x++) {
      bool draw = false;
      if (ditherType == 0) draw = (x % 2 == 0) && (y % 2 == 0);
      else if (ditherType == 1) draw = ((x + y) % 2 == 0);
      else draw = !((x % 2 == 0) && (y % 2 == 0));

      if (draw) {
        // Point-in-triangle test
        int w0 = (x1-x0)*(y-y0) - (y1-y0)*(x-x0);
        int w1 = (x2-x1)*(y-y1) - (y2-y1)*(x-x1);
        int w2 = (x0-x2)*(y-y2) - (y0-y2)*(x-x2);
        if ((w0>=0 && w1>=0 && w2>=0) || (w0<=0 && w1<=0 && w2<=0)) {
          display.drawPixel(x, y, SH110X_WHITE);
        }
      }
    }
  }
}

void drawLeaf2D(int i) {
  float relY = leaves[i].y - altitude;
  Point2D p = project(leaves[i].x, relY, leaves[i].z);

  if (p.x < -64 || p.x > 192 || p.y < -64 || p.y > 128) return;

  float angle = sin(millis() * 0.0005 + leaves[i].angleOffset) * 0.5;
  float ca = cos(angle);
  float sa = sin(angle);

  const float* poly;
  int count;
  if (leaves[i].type == 0) { poly = SHAPE_MAPLE; count = MAPLE_COUNT; }
  else if (leaves[i].type == 1) { poly = SHAPE_OAK; count = OAK_COUNT; }
  else { poly = SHAPE_BIRCH; count = BIRCH_COUNT; }

  // Draw leaf as triangle fan
  float lx0 = poly[0] * leaves[i].size;
  float ly0 = poly[1] * leaves[i].size;
  int startX = p.x + (int)(lx0*ca - ly0*sa);
  int startY = p.y + (int)(lx0*sa + ly0*ca);
  int prevX = startX, prevY = startY;

  for (int j = 1; j < count; j++) {
    float lx = poly[j*2] * leaves[i].size;
    float ly = poly[j*2+1] * leaves[i].size;
    int px = p.x + (int)(lx*ca - ly*sa);
    int py = p.y + (int)(lx*sa + ly*ca);
    fillTriangleDither(p.x, p.y, prevX, prevY, px, py, leaves[i].ditherType);
    prevX = px; prevY = py;
  }
  fillTriangleDither(p.x, p.y, prevX, prevY, startX, startY, leaves[i].ditherType);
}

void drawPlane() {
  Point2D pv[NUM_VERTS];
  float ca = cos(roll), sa = sin(roll);
  float cp = cos(pitch), sp = sin(pitch);

  for (int i = 0; i < NUM_VERTS; i++) {
    float py = model[i].y * cp - model[i].z * sp;
    float pz = model[i].y * sp + model[i].z * cp;
    float px = model[i].x * ca - py * sa;
    float py2 = model[i].x * sa + py * ca;

    float pz3 = pz + 150.0;
    if (pz3 < 1) pz3 = 1;
    float sc = 300.0 / pz3;
    pv[i] = {(int)(64 + px*sc), (int)(28 + py2*sc)};
  }

  // Fill triangles (black background)
  display.fillTriangle(pv[0].x, pv[0].y, pv[2].x, pv[2].y, pv[1].x, pv[1].y, SH110X_BLACK);
  display.fillTriangle(pv[0].x, pv[0].y, pv[3].x, pv[3].y, pv[1].x, pv[1].y, SH110X_BLACK);
  display.fillTriangle(pv[0].x, pv[0].y, pv[1].x, pv[1].y, pv[4].x, pv[4].y, SH110X_BLACK);

  // Draw wireframe edges
  display.drawLine(pv[0].x, pv[0].y, pv[2].x, pv[2].y, SH110X_WHITE);
  display.drawLine(pv[2].x, pv[2].y, pv[1].x, pv[1].y, SH110X_WHITE);
  display.drawLine(pv[0].x, pv[0].y, pv[3].x, pv[3].y, SH110X_WHITE);
  display.drawLine(pv[3].x, pv[3].y, pv[1].x, pv[1].y, SH110X_WHITE);
  display.drawLine(pv[0].x, pv[0].y, pv[1].x, pv[1].y, SH110X_WHITE);
  display.drawLine(pv[0].x, pv[0].y, pv[4].x, pv[4].y, SH110X_WHITE);
  display.drawLine(pv[4].x, pv[4].y, pv[1].x, pv[1].y, SH110X_WHITE);

  // Canopy
  display.fillTriangle(pv[5].x, pv[5].y, pv[6].x, pv[6].y, pv[1].x, pv[1].y, SH110X_BLACK);
  display.drawLine(pv[5].x, pv[5].y, pv[6].x, pv[6].y, SH110X_WHITE);
}

void loop() {
  bool tL = touchRead(PIN_LEFT) > 40000;
  bool tU = touchRead(PIN_UP) > 40000;
  bool tD = touchRead(PIN_DOWN) > 40000;
  bool tR = touchRead(PIN_RIGHT) > 40000;
  bool interacting = (tL || tR || tU || tD);

  if (interacting) {
    lastTouchTime = millis();
    if (tL) roll -= 0.06;
    if (tR) roll += 0.06;
    if (tU) pitch += 0.05;
    if (tD) pitch -= 0.05;
    roll *= 0.96;
    float climbRate = (pitch - CRUISE_PITCH) * 2.0;
    altitude -= climbRate;
  } else {
    if (millis() - lastTouchTime > IDLE_TIMEOUT) {
      // AUTOPILOT
      float t = millis() * 0.001;
      float targetRoll = sin(t * 0.5) * 0.5;
      float targetPitch = CRUISE_PITCH + sin(t * 0.3) * 0.3;
      float k = 0.015;
      pitch = pitch * (1.0 - k) + targetPitch * k;
      roll = roll * (1.0 - k) + targetRoll * k;
      float altTarget = sin(t * 0.2) * 40.0;
      altitude = altitude * (1.0 - k) + altTarget * k;
    } else {
      roll *= 0.98;
    }
  }

  // Clamp values
  if (pitch > 1.5) pitch = 1.5;
  if (pitch < -1.5) pitch = -1.5;
  if (altitude > 200) altitude = 200;
  if (altitude < -200) altitude = -200;

  display.clearDisplay();

  // Update & sort leaves by depth
  for (int i = 0; i < NUM_LEAVES; i++) {
    leaves[i].z -= (0.3 * leaves[i].speed_mult);
    if (leaves[i].z < -120) {
      leaves[i].z = 400;
      leaves[i].x = random(-250, 250);
      leaves[i].y = altitude + random(-250, 250);
    }
  }

  // Bubble sort by Z (far to near)
  for (int i = 0; i < NUM_LEAVES - 1; i++) {
    for (int j = 0; j < NUM_LEAVES - i - 1; j++) {
      if (leaves[leafOrder[j]].z < leaves[leafOrder[j+1]].z) {
        int temp = leafOrder[j];
        leafOrder[j] = leafOrder[j+1];
        leafOrder[j+1] = temp;
      }
    }
  }

  // Draw leaves and plane in depth order
  bool planeDrawn = false;
  for (int i = 0; i < NUM_LEAVES; i++) {
    int idx = leafOrder[i];
    if (!planeDrawn && leaves[idx].z < PLANE_Z_CENTER) {
      drawPlane();
      planeDrawn = true;
    }
    drawLeaf2D(idx);
  }
  if (!planeDrawn) drawPlane();

  // Overlay
  display.setTextSize(1);
  display.setTextColor(SH110X_WHITE);
  display.setCursor(0, 0);
  display.print("Airplane v15");

  display.display();
}

The Math: 3D Perspective Projection

screenX = worldX * focalLength / (worldZ + cameraDistance)
screenY = worldY * focalLength / (worldZ + cameraDistance)

Key insight: Divide by Z - that's what makes far things smaller!


How I Built This (The Iteration Process)

Version What I Added
V1-3Basic wireframe airplane that rotates
V4-6Pitch and roll controls
V7-9Falling leaves (simple circles first)
V10-12Leaf shapes, dithering patterns
V13Depth sorting (leaves in front AND behind plane)
V14Autopilot when idle
V15Horizon spawn, polish
Lesson: Complex projects are built in layers. Get one thing working, then add the next.

Want to Build This Yourself?

Step Gemini Prompt
1. Wireframe "Define a simple 3D paper airplane as an array of Point3D vertices"
2. Project it "Explain perspective projection and write a function to convert 3D to 2D"
3. Rotate it "Apply pitch and roll rotation to 3D points before projection"
4. Add particles "Create an array of leaf particles that move toward the camera"
5. Depth sort "Sort objects by Z depth so far objects draw before near objects"

Key Concepts

Next Up: In Project 5, you'll create an autonomous AI simulation - a Koi fish that hunts schooling minnows!