DIY Bioluminescent Sea Urchin Lamp: A Custom 3D Printed Project
I recently wanted to create something special for my girlfriend—a gift that felt organic, artistic, and functional. Inspired by the deep ocean, I decided to build a Sea Urchin Lamp. Using a 3D printed shell and a custom Arduino circuit, I brought the bioluminescence of the sea to our bedside table.
In this post, I’ll walk you through how I built it, the hardware I used, and the code that drives the different lighting modes.
The Concept
The goal was to create a lamp that didn't just "turn on," but felt alive. Sea urchins have such intricate, mathematical patterns, and when you put light behind them, the effect is mesmerizing.
I designed the electronics to include six different modes, ranging from a "Warm White" reading light to a "Bioluminescence" mode that mimics glowing deep-sea creatures.
Hardware You’ll Need
To build this lamp, you’ll need some basic electronics and a 3D printer (or a service to print the shell).
Components:
- Arduino Nano: The "brain" of the project. It’s small enough to fit inside the base.
- NeoPixel 12 LED Ring: Provides the vibrant, customizable colors.
- Push Button: To toggle between the lighting modes.
- 3D Printed Urchin Shell: Printed in a translucent or white filament to diffuse the light.
- Wiring & Solder: To connect everything together.
Project Media
The Full Arduino Sketch
#include
#define PIN 4
#define NUMPIXELS 8
#define BUTTON_PIN 9 // Button connected to pin 9
Adafruit_NeoPixel ring(NUMPIXELS, PIN, NEO_GRB + NEO_KHZ800);
// Mode definitions
enum LampMode {
MODE_OFF = 0,
MODE_WARM_WHITE = 1,
MODE_URCHIN = 2,
MODE_OCEAN = 3,
MODE_RANDOM = 4,
MODE_BIOLUMINESCENCE = 5,
MODE_COUNT = 6
};
LampMode currentMode = MODE_OFF;
// Button handling
bool lastButtonState = HIGH;
bool buttonState = HIGH;
unsigned long lastDebounceTime = 0;
unsigned long debounceDelay = 50;
// Ocean color palette (R, G, B)
const uint8_t oceanPalette[][3] = {
{0, 160, 255}, // Aqua blue
{0, 100, 200}, // Deep sea
{0, 180, 150}, // Teal
{20, 255, 150}, // Bioluminescent mint
{0, 120, 255} // Soft ocean blue
};
const int numOceanColors = sizeof(oceanPalette) / sizeof(oceanPalette[0]);
// Urchin color palette (sea foam greens)
const uint8_t urchinPalette[][3] = {
{85, 170, 85}, // Sea foam green
{102, 205, 102}, // Light sea green
{60, 179, 113}, // Medium sea green
{72, 160, 120}, // Sage green
{95, 158, 160} // Cadet blue green
};
const int numUrchainColors = sizeof(urchinPalette) / sizeof(urchinPalette[0]);
// Animation phases
float wavePhase = 0;
float tidePhase = 0;
float waveSpeed = 0.2;
float tideSpeed = 0.005;
// Color transition variables
int currentColorIndex = 0;
int nextColorIndex = 1;
float colorBlendT = 0.0;
float colorBlendSpeed = 0.002; // Slow transition
// Random mode variables
uint8_t currentRandomColor[3];
uint8_t nextRandomColor[3];
float randomColorBlendT = 0.0;
float randomColorBlendSpeed = 0.001; // Very slow transition
// Bioluminescence mode variables
struct BioPoint {
int position;
float intensity;
float fadeRate;
bool active;
unsigned long lastUpdate;
};
BioPoint bioPoints[3]; // Up to 3 simultaneous bio flashes
unsigned long lastBioTrigger = 0;
// Urchin mode variables
float urchinPhase = 0;
float urchinSpeed = 0.05;
void setup() {
ring.begin();
ring.show();
// Setup button with internal pullup
pinMode(BUTTON_PIN, INPUT_PULLUP);
// Initialize random color and bio effects
initializeRandomColor();
initializeBioMode();
Serial.begin(9600);
Serial.println("Sea Urchin Lamp Ready!");
}
void loop() {
handleButton();
switch(currentMode) {
case MODE_OFF:
handleOffMode();
break;
case MODE_WARM_WHITE:
handleWarmWhiteMode();
break;
case MODE_URCHIN:
handleUrchinMode();
break;
case MODE_OCEAN:
handleOceanMode();
break;
case MODE_RANDOM:
handleRandomMode();
break;
case MODE_BIOLUMINESCENCE:
handleBioluminescenceMode();
break;
}
ring.show();
delay(40);
}
void handleButton() {
int reading = digitalRead(BUTTON_PIN);
if (reading != lastButtonState) {
lastDebounceTime = millis();
}
if ((millis() - lastDebounceTime) > debounceDelay) {
if (reading != buttonState) {
buttonState = reading;
if (buttonState == LOW) { // Button pressed (pullup means LOW when pressed)
currentMode = (LampMode)((currentMode + 1) % MODE_COUNT);
Serial.print("Mode changed to: ");
Serial.println(currentMode);
// Reset animation variables when changing modes
wavePhase = 0;
tidePhase = 0;
urchinPhase = 0;
colorBlendT = 0;
currentColorIndex = 0;
nextColorIndex = 1;
initializeRandomColor();
initializeBioMode();
}
}
}
lastButtonState = reading;
}
void handleOffMode() {
// Turn all LEDs off
for (int i = 0; i < NUMPIXELS; i++) {
ring.setPixelColor(i, ring.Color(0, 0, 0));
}
}
void handleWarmWhiteMode() {
// Warm white with slight breathing effect
float breathe = (sin(tidePhase) + 1.0) / 2.0 * 0.3 + 0.7; // 0.7 to 1.0
uint8_t r = 255 * breathe;
uint8_t g = 200 * breathe; // Slightly less green for warmth
uint8_t b = 120 * breathe; // Much less blue for warmth
for (int i = 0; i < NUMPIXELS; i++) {
ring.setPixelColor(i, ring.Color(r, g, b));
}
tidePhase += tideSpeed * 2; // Slower breathing
}
void handleUrchinMode() {
// Get current and next color from urchin palette
uint8_t r1 = urchinPalette[currentColorIndex][0];
uint8_t g1 = urchinPalette[currentColorIndex][1];
uint8_t b1 = urchinPalette[currentColorIndex][2];
uint8_t r2 = urchinPalette[nextColorIndex][0];
uint8_t g2 = urchinPalette[nextColorIndex][1];
uint8_t b2 = urchinPalette[nextColorIndex][2];
// Blend the two colors
uint8_t rBlend = lerp(r1, r2, colorBlendT);
uint8_t gBlend = lerp(g1, g2, colorBlendT);
uint8_t bBlend = lerp(b1, b2, colorBlendT);
// Apply gentle pulsing effect
for (int i = 0; i < NUMPIXELS; i++) {
float angle = urchinPhase + (PI * i / NUMPIXELS);
float pulse = (sin(angle) + 1.0) / 2.0 * 0.4 + 0.6; // 0.6 to 1.0
uint8_t r = rBlend * pulse;
uint8_t g = gBlend * pulse;
uint8_t b = bBlend * pulse;
ring.setPixelColor(i, ring.Color(r, g, b));
}
// Advance phases
urchinPhase += urchinSpeed;
colorBlendT += colorBlendSpeed;
if (colorBlendT >= 1.0) {
colorBlendT = 0.0;
currentColorIndex = nextColorIndex;
nextColorIndex = (nextColorIndex + 1) % numUrchainColors;
}
}
void handleOceanMode() {
// Your original ocean animation code
// Get current and next color from palette
uint8_t r1 = oceanPalette[currentColorIndex][0];
uint8_t g1 = oceanPalette[currentColorIndex][1];
uint8_t b1 = oceanPalette[currentColorIndex][2];
uint8_t r2 = oceanPalette[nextColorIndex][0];
uint8_t g2 = oceanPalette[nextColorIndex][1];
uint8_t b2 = oceanPalette[nextColorIndex][2];
// Blend the two colors
uint8_t rBlend = lerp(r1, r2, colorBlendT);
uint8_t gBlend = lerp(g1, g2, colorBlendT);
uint8_t bBlend = lerp(b1, b2, colorBlendT);
// Apply wave and tide to LEDs
for (int i = 0; i < NUMPIXELS; i++) {
float angle = wavePhase + (2 * PI * i / NUMPIXELS);
float wave = (sin(angle) + 1.0) / 2.0; // 0 to 1
float tide = (sin(tidePhase) + 1.0) / 2.0 * 0.6 + 0.4; // 0.4 to 1.0
float brightness = wave * tide;
uint8_t r = rBlend * brightness;
uint8_t g = gBlend * brightness;
uint8_t b = bBlend * brightness;
ring.setPixelColor(i, ring.Color(r, g, b));
}
// Advance wave and tide phases
wavePhase += waveSpeed;
tidePhase += tideSpeed;
// Advance color blend
colorBlendT += colorBlendSpeed;
if (colorBlendT >= 1.0) {
colorBlendT = 0.0;
currentColorIndex = nextColorIndex;
nextColorIndex = (nextColorIndex + 1) % numOceanColors;
}
}
void handleRandomMode() {
// Blend between current and next random colors
uint8_t rBlend = lerp(currentRandomColor[0], nextRandomColor[0], randomColorBlendT);
uint8_t gBlend = lerp(currentRandomColor[1], nextRandomColor[1], randomColorBlendT);
uint8_t bBlend = lerp(currentRandomColor[2], nextRandomColor[2], randomColorBlendT);
// Apply the same color to all LEDs with gentle breathing effect
float breathe = (sin(tidePhase) + 1.0) / 2.0 * 0.3 + 0.7; // 0.7 to 1.0
uint8_t r = rBlend * breathe;
uint8_t g = gBlend * breathe;
uint8_t b = bBlend * breathe;
for (int i = 0; i < NUMPIXELS; i++) {
ring.setPixelColor(i, ring.Color(r, g, b));
}
// Advance color blend
randomColorBlendT += randomColorBlendSpeed;
if (randomColorBlendT >= 1.0) {
randomColorBlendT = 0.0;
// Move to next color
currentRandomColor[0] = nextRandomColor[0];
currentRandomColor[1] = nextRandomColor[1];
currentRandomColor[2] = nextRandomColor[2];
// Generate new next color
nextRandomColor[0] = random(256);
nextRandomColor[1] = random(256);
nextRandomColor[2] = random(256);
}
tidePhase += tideSpeed * 1.5; // Gentle breathing
}
void initializeRandomColor() {
// Initialize current random color
currentRandomColor[0] = random(256);
currentRandomColor[1] = random(256);
currentRandomColor[2] = random(256);
// Initialize next random color
nextRandomColor[0] = random(256);
nextRandomColor[1] = random(256);
nextRandomColor[2] = random(256);
randomColorBlendT = 0.0;
}
void initializeBioMode() {
// Initialize bioluminescence points
for (int i = 0; i < 3; i++) {
bioPoints[i].active = false;
bioPoints[i].intensity = 0;
bioPoints[i].position = 0;
bioPoints[i].fadeRate = 0;
bioPoints[i].lastUpdate = 0;
}
lastBioTrigger = millis();
}
void handleBioluminescenceMode() {
unsigned long now = millis();
// Clear all LEDs to dark blue base
for (int i = 0; i < NUMPIXELS; i++) {
ring.setPixelColor(i, ring.Color(0, 0, 5)); // Very dark blue
}
// Randomly trigger new bioluminescent flashes
if (now - lastBioTrigger > random(800, 3000)) { // Random interval 0.8-3 seconds
for (int i = 0; i < 3; i++) {
if (!bioPoints[i].active) {
bioPoints[i].active = true;
bioPoints[i].position = random(NUMPIXELS);
bioPoints[i].intensity = random(150, 255);
bioPoints[i].fadeRate = random(3, 8) / 100.0; // 0.03 to 0.08
bioPoints[i].lastUpdate = now;
break;
}
}
lastBioTrigger = now;
}
// Update and render active bio points
for (int i = 0; i < 3; i++) {
if (bioPoints[i].active) {
if (now - bioPoints[i].lastUpdate > 20) { // Update every 20ms
bioPoints[i].intensity -= bioPoints[i].intensity * bioPoints[i].fadeRate;
bioPoints[i].lastUpdate = now;
if (bioPoints[i].intensity < 5) {
bioPoints[i].active = false;
}
}
if (bioPoints[i].active) {
// Create a bright cyan/green flash
uint8_t brightness = bioPoints[i].intensity;
uint8_t r = brightness * 0.1; // Very little red
uint8_t g = brightness * 0.8; // Lots of green
uint8_t b = brightness * 0.9; // Lots of blue for cyan
// Main bright spot
ring.setPixelColor(bioPoints[i].position, ring.Color(r, g, b));
// Dimmer adjacent spots for glow effect
int prev = (bioPoints[i].position - 1 + NUMPIXELS) % NUMPIXELS;
int next = (bioPoints[i].position + 1) % NUMPIXELS;
uint8_t dimR = r * 0.3;
uint8_t dimG = g * 0.3;
uint8_t dimB = b * 0.3;
ring.setPixelColor(prev, ring.Color(dimR, dimG, dimB + 5)); // Add base blue
ring.setPixelColor(next, ring.Color(dimR, dimG, dimB + 5)); // Add base blue
}
}
}
}
// Linear interpolation function
uint8_t lerp(uint8_t a, uint8_t b, float t) {
return a + (b - a) * t;
}