Learning Goals 5 min
- Combine the smooth-fade pattern (L02-04) with the RGB colour mixing (L02-05) to build a small "lamp" sketch that breathes through three calming colours forever.
- Apply a simple gamma curve so the breath looks linear to the eye, not linear to the duty cycle — fixing the "fast at the start, slow at the end" problem from L02-03.
- Polish a small Arduino project from start to finish — wire, code, test, tune, then leave it running.
Warm-Up 10 min
A "mood lamp" is a small piece of furniture. It sits on a desk, it does one thing, and it does that one thing beautifully — no buttons, no menu, no surprises. Today you build one.
Predict-the-output
Mia's sketch breathes the RGB LED, but holds each colour for the full breath instead of crossfading. What does it look like?
breath(R); // red in, red out
breath(G); // green in, green out
breath(B); // blue in, blue outReveal
Three distinct "coloured breaths" in sequence — a slow red swell, then a slow green swell, then a slow blue swell. Beautiful but a bit choppy at the transitions (red disappears, then green starts from zero). Today's project blends the transitions so the LED appears to morph through colours while breathing.
New Concept · The gamma curve in three lines of code 20 min
The problem we're solving
From L02-03: equal duty steps don't look equal. A linear ramp 0 → 255 spends too long looking bright, not enough time looking dim. The breath feels "snappy" at the bottom and "parked" at the top.
The fix — pre-computed brightness table
We'll pre-compute a 64-entry lookup table where each value is the duty cycle that matches a perceived brightness step. For γ = 2.2 the formula is:
duty[i] = round( pow(i / 63.0, 2.2) × 255 )The first few values come out very low (0, 0, 0, 1, 1, 2, 3, ...) and the last few come out high (240, 247, 255). Most of the entries sit in the middle — exactly where the eye has the most resolution. The result: a fade through this 64-entry table looks linear.
Instead of typing pow at runtime (which is slow on the AVR), we paste a hard-coded table into the sketch:
const byte GAMMA[64] = {
0, 0, 0, 1, 1, 2, 2, 3,
4, 5, 6, 7, 9, 10, 12, 14,
16, 18, 20, 23, 26, 28, 31, 35,
38, 42, 45, 49, 53, 58, 62, 67,
72, 77, 82, 87, 93, 99, 105, 111,
117, 124, 130, 137, 144, 152, 159, 167,
175, 183, 191, 199, 208, 217, 225, 235,
244, 253, 255, 255, 255, 255, 255, 255
};The breath loop now indexes the table instead of using v directly:
for (int i = 0; i < 64; i++) {
analogWrite(LED, GAMMA[i]);
delay(STEP_DELAY);
}64 steps instead of 256 — and because the duty values are spaced according to perception, the eye sees a beautifully smooth ramp.
Why const byte and not int?
Each entry fits in 0–255, which is exactly one byte. const byte GAMMA[64] uses 64 bytes of flash memory; using int would waste twice that. On a UNO with only 2 KB of RAM, the byte savings add up — but more importantly, const data lives in flash, not RAM, so this table costs zero RAM. Free!
Blending colours during the breath
For the lamp's "morph between colours" effect, we run two crossfades at once: the brightness fades up + down (the breath), while the colour also slowly shifts (the morph). The cleanest pattern is to step through a list of three target colours and crossfade between them, with the breath layered on top:
const int COLOURS[3][3] = {
{255, 80, 120}, // warm pink
{ 80, 180, 255}, // sky blue
{180, 255, 120} // soft lime
};Crossfade colour A → colour B over one full breath; while doing so, modulate the overall brightness with the gamma table. The two effects layer naturally because both feed into the same three analogWrite calls.
Worked Example · The full mood lamp 20 min
Step 1 — wiring
Reuse the L02-05 RGB wiring exactly. ~9 R, ~10 G, ~11 B, each through 220 Ω to its anode; cathode to GND. Nothing else changes.
Step 2 — the sketch
Save as mood-lamp.ino:
// L02-06: Breathing mood lamp — RGB + gamma-corrected breath
const int R = 9;
const int G = 10;
const int B = 11;
// 64-step gamma table (γ ≈ 2.2)
const byte GAMMA[64] = {
0, 0, 0, 1, 1, 2, 2, 3,
4, 5, 6, 7, 9, 10, 12, 14,
16, 18, 20, 23, 26, 28, 31, 35,
38, 42, 45, 49, 53, 58, 62, 67,
72, 77, 82, 87, 93, 99, 105, 111,
117, 124, 130, 137, 144, 152, 159, 167,
175, 183, 191, 199, 208, 217, 225, 235,
244, 253, 255, 255, 255, 255, 255, 255
};
// Three calming target colours
const int COLOURS[3][3] = {
{255, 80, 120}, // warm pink
{ 80, 180, 255}, // sky blue
{180, 255, 120} // soft lime
};
const int STEP_DELAY = 40; // 64 × 40 ms ≈ 2.5 s per ramp
void setColour(int r, int g, int b) {
analogWrite(R, r);
analogWrite(G, g);
analogWrite(B, b);
}
// One full breath in colour 'from', ending in colour 'to'.
void breathFromTo(int fromIdx, int toIdx) {
// inhale — brightness 0 → max, colour shifts halfway from → to
for (int i = 0; i < 64; i++) {
int bright = GAMMA[i];
// 0..63 of the way through the colour blend
int r = ((64 - i) * COLOURS[fromIdx][0] + i * COLOURS[toIdx][0]) / 64;
int g = ((64 - i) * COLOURS[fromIdx][1] + i * COLOURS[toIdx][1]) / 64;
int b = ((64 - i) * COLOURS[fromIdx][2] + i * COLOURS[toIdx][2]) / 64;
setColour(r * bright / 255, g * bright / 255, b * bright / 255);
delay(STEP_DELAY);
}
// exhale — brightness max → 0, colour finishes the blend to 'to'
for (int i = 63; i >= 0; i--) {
int bright = GAMMA[i];
setColour(COLOURS[toIdx][0] * bright / 255,
COLOURS[toIdx][1] * bright / 255,
COLOURS[toIdx][2] * bright / 255);
delay(STEP_DELAY);
}
}
void setup() {
pinMode(R, OUTPUT);
pinMode(G, OUTPUT);
pinMode(B, OUTPUT);
setColour(0, 0, 0);
}
void loop() {
breathFromTo(0, 1); // pink → blue
breathFromTo(1, 2); // blue → lime
breathFromTo(2, 0); // lime → pink
}Step 3 — upload and let it run
One full cycle = 3 breaths × ~5 s each ≈ 15 seconds. The LED appears to morph slowly through pink, blue and lime while gently breathing. Let it run for a minute. Notice how the breath feels continuous, not stepped — the gamma table is doing its work.
Step 4 — tune it
Three sliders you can tweak:
- STEP_DELAY — controls breath speed. 20 = active. 40 = relaxed. 80 = sleep-time.
- COLOURS — pick any three you like. Warm tones (red/orange/yellow) feel energising; cool tones (blue/cyan/teal) feel calming.
- Number of colours — drop to 2 (just pink → blue → pink) or expand to 5 for a longer cycle. Adjust the
loop()calls accordingly.
Basic 7 min
Goal: Run the worked example unchanged. While it's breathing, time one full breath cycle with your phone's stopwatch. Does it match the formula (64 × 40 × 2 ≈ 5.12 s per breath)?
Challenge 1 7 min
Goal: Add a fourth colour, warm amber {255, 140, 0}, to the worked example. The cycle becomes pink → blue → lime → amber → pink.
You need to change three things: the size of the COLOURS array, its rows, and the calls in loop().
It works if the lamp breathes through all four colours and one full cycle takes about 20 seconds (4 × 64 × 40 × 2 ms).
Challenge 2 6 min
Goal: Replace the hand-pasted gamma table with one your sketch computes at boot time using pow(). Compare the result to the hard-coded one — does the lamp look the same?
Challenge 3 · Fix the broken mood lamp 15 min
A classmate wrote a simpler lamp. It should breathe pink, then blue, then lime, one smooth breath each. It uploads, but it misbehaves:
- Each breath stays dark on the way up, then jumps to full brightness.
- Pink never appears.
- An odd colour nobody chose shows up in the cycle.
There are three mistakes.
const int R = 9;
const int G = 10;
const int B = 11;
const byte GAMMA[64] = {
0, 0, 0, 1, 1, 2, 2, 3,
4, 5, 6, 7, 9, 10, 12, 14,
16, 18, 20, 23, 26, 28, 31, 35,
38, 42, 45, 49, 53, 58, 62, 67,
72, 77, 82, 87, 93, 99, 105, 111,
117, 124, 130, 137, 144, 152, 159, 167,
175, 183, 191, 199, 208, 217, 225, 235,
244, 253, 255, 255, 255, 255, 255, 255
};
const int COLOURS[3][3] = {
{255, 80, 120}, // warm pink
{ 80, 180, 255}, // sky blue
{180, 255, 120} // soft lime
};
const int STEP_DELAY = 40;
void setColour(int r, int g, int b) {
analogWrite(R, r);
analogWrite(G, g);
analogWrite(B, b);
}
void breathIn(int c) {
for (int i = 0; i <= 64; i++) {
int bright = GAMMA[i];
setColour(COLOURS[c][0] * (bright / 255),
COLOURS[c][1] * (bright / 255),
COLOURS[c][2] * (bright / 255));
delay(STEP_DELAY);
}
}
void breathOut(int c) {
for (int i = 63; i >= 0; i--) {
int bright = GAMMA[i];
setColour(COLOURS[c][0] * bright / 255,
COLOURS[c][1] * bright / 255,
COLOURS[c][2] * bright / 255);
delay(STEP_DELAY);
}
}
void setup() {
pinMode(R, OUTPUT);
pinMode(G, OUTPUT);
pinMode(B, OUTPUT);
setColour(0, 0, 0);
}
void loop() {
for (int c = 1; c <= 3; c++) {
breathIn(c);
breathOut(c);
}
}For each mistake, write down:
- The line it is on.
- Which symptom above it causes.
- The corrected line.
Then fix the sketch and upload it.
It works if the lamp breathes pink, blue and lime in turn, and every breath rises and falls smoothly.
Recap 5 min
You built your first Level 2 project end-to-end: an LED that breathes, morphs through colours, and looks good doing it. The gamma table is the one piece of new craft — 64 pre-computed values that make a linear loop look linear to the eye, not to the duty cycle. The rest is L02-04's fade pattern × L02-05's RGB layered together. Tomorrow we switch to input — meeting the ADC for the first time.
- Gamma correction
- Pre-distorting brightness values to match the eye's non-linear sensitivity. γ ≈ 2.2 is the standard for screens and LEDs.
- Lookup table
- A pre-computed array of values you index into instead of recalculating on the fly. Trades a tiny bit of memory for big speed.
const byte ARRAY[64]- An immutable, fixed-size table that lives in flash memory (not RAM) on AVR chips like the UNO. Zero RAM cost.
- Crossfade between palettes
- Smooth transition between two colours using a counter:
r = ((N - i) × from_r + i × to_r) / Nfor each channel. - Project polish
- The difference between "sketch that works" and "thing you'd put on your desk". Comes from tuning constants, naming functions clearly, and timing the breath until it feels right.
Extra Mission 5 min
Part 1 — Design a lamp for one moment
Pick one moment: a study lamp, a bedtime lamp, a birthday lamp or an aquarium lamp. On paper, design the palette and the breath that fit it.
Your design must include:
- The moment, and the feeling the lamp should give.
- Three or four colours, each with a name and its R, G, B values.
- The breath length in seconds, and the
STEP_DELAYthat gives it (64 steps up and 64 down). - One sentence on why each colour fits the moment.
Part 2 — Make it
Change only COLOURS, STEP_DELAY and the calls in loop() of the worked example. Add a comment block at the top that gives your moment, colours and timing.
Bring back next class: the uploaded sketch and a short video of the lamp. Next class, a classmate tries to guess your moment.