Difficulty: Advanced Version: V15
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. |
| Touch | Action |
|---|---|
| LEFT (D0) | Roll left |
| RIGHT (D3) | Roll right |
| UP (D1) | Pitch up (climb) |
| DOWN (D2) | Pitch down (dive) |
| No touch for 2s | Autopilot takes over |
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(); }
Key insight: Divide by Z - that's what makes far things smaller!
| Version | What I Added |
|---|---|
| V1-3 | Basic wireframe airplane that rotates |
| V4-6 | Pitch and roll controls |
| V7-9 | Falling leaves (simple circles first) |
| V10-12 | Leaf shapes, dithering patterns |
| V13 | Depth sorting (leaves in front AND behind plane) |
| V14 | Autopilot when idle |
| V15 | Horizon spawn, polish |
| 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" |
value = value * 0.98 + target * 0.02 creates smooth movement.