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;
}
                    

Back to All Projects