Introduction: 4000 Pixel Animated LED Mural [Cheap and Simple*]

About: I'm Ben. I'm currently weaseling my way through undergrad at MIT where I'm majoring in physics and nuclear science and engineering. I made this account back in middle school (hence the cheesy name), and I real…

*...At least as these sorts of things go.

I've been interested in making LED displays since I first discovered LED matrices back in middle school. The problem was that they always seemed to require complex matrixing procedures which required somewhat involved coding and often A LOT of wiring. I was also sort of dissatisfied with how small most commercial LED matrices were. They kind of seemed a bit like a toy, and though I found them interesting, I struggled to think of exciting and worthwhile projects utilizing them (I admit this may just be due to a lack of creativity on my part, but nevertheless...).

When I first heard of LED strips that had individually addressable pixels, I immediately began thinking of ways to make large and [excessively] bright LED matrices. I thought this would be a cool idea for honestly a number of years, but I realized that even though LED strips are generally rather cheap, a display with any sort of decent resolution (i.e. a large number of pixels) would be prohibitively expensive for my strained high school and college student budget. Even a reasonable budget (say between $500 and $1000) would get a display which is rather low resolution as modern standards for LCDs go.

So the challenge became two-fold. A) how could I make a large display cheap enough that I could actually build it, and B) What sorts of images could I find that would still look good even on a low resolution display. I'll get into more detail on my thoughts on both of these points in the next step, but here is a brief summary of the display specs:

-Dimensions-- the display is 72 inches by 30 inches (approximately the size of a door). The area covered by LEDs is large enough to display a life-size person about my size (a skinny 5'4" person lol)

-Resolution-- 40x100 (100 rows of 40 pixel strips)

-Frame Rate-- 20-25 fps when rendering graphics from simulations and up to ~100 fps for playback of simple videos (latter stat is yet to be tested but based on the max observed data transmission speed to the display)

-LED types-- 60 pixel/m WS2812b LED strips (RGB color with an on-chip driver, which is essentially a 24bit shift register mixed with a PWM brightness controller for each color channel. Pretty snazzy!)

-Controller Electronics-- a single PIC32MZ0512EFE064 on a simple custom PCB was sufficient to render graphics, store bitmap animations, and drive all the LEDs on the display. (I'll get into the details of my MCU choice and why I didn't use an arduino in later steps)

-Cost of Parts-- The total cost of this display was ~$650. I received Projx funding for this (, so my goal was to keep it under $500. While I did go over budget, if you chose materials carefully enough, the $500 mark is actually pretty attainable.

Step 1: How to Make Low Resolution Images Compelling Using Animation

Disclaimer: Semi-pretentious rambling ahead, feel free to skip this step if you're not into that sort of thing.

Modern standards for graphics seem to focus a lot high resolution. Making crisp images with as high a pixel density as possible seems to be the goal for most display tech. However, when it comes to LED matrices, where pixels are necessarily large and often power hungry, high resolution can be difficult to attain.

That said, there is a lot you can do even with relatively low resolution so long as you are willing to forfeit the visual information that isn't absolutely essential to what you are trying to display. While limited pixel density doesn't allow you to create life-like images from spatial detail alone, electronic displays open up a whole new way of adding compelling detail and specificity to images: movement.

An object or person's motion often doesn't need very many spatial data points to be accurately conveyed, making it an ideal area of focus for lower resolution displays, like the one built here. There are a few particularly apt examples of what I mean, which I will go into below.

Simulations With Real-Time Inputs -- "Augmented reality murals" seem to becoming more and more common place. There's one particularly interesting one in the Stata building on the MIT campus that films people walking by with a camera, and uses that input to perturb fluid flow simulations (and a whole bunch of other effects) displayed on a mosaic of LCD screens. While simply observing art installations can be interesting, the experience becomes much more personal when it responds to your own inputs. To me, when I see an installation react to the same sensory inputs I'm experiencing, it makes it seem much less removed from my own world. Not to mention it can give me a sort of new frame for experiencing the sounds and images around me.

My first attempt to be "creative" with LED strips came during a fit of procrastination during finals week of my sophomore fall. I ended up wrapping a 150 pixel WS2811 LED strip around a ~65" diameter wooden hexagonal frame that I hung from the ceiling of my dorm room (I'm not much of a painter, and my barren walls were a bit off putting to some of my friends. Thus I hoped to color the walls with LED light mostly out of laziness). I used an arduino uno to directly solve the system of the equations of motion for a string of coupled oscillators using Euler's method. Each mass in the coupled oscillator corresponded to an LED, and the mass's position corresponded to a color in the LED color gradient. 6 evenly spaced masses in the simulation were perturbed based on the volume recorded from a small condenser microphone connected to the arduino's ADC. The result is wave-like perturbations in the colors displayed on the string correlated to ambient sound (it responds especially well to music).

The video above shows this project in action. Maybe one of these days I'll get around to giving it its own instructable, but I think it demonstrates how even with very simple and low resolution images (i.e. just a 1-D string), simulations with inputs from the real world can form interesting and visually appealing patterns. I was encouraged enough from this project to take this concept to the next level...or really the next dimension, but as the 1-D lamp used all the arduino uno's RAM and processing time, I realized I'd need a much more powerful MCU to pull this off.

Of course, the simulations one uses can vary widely for a multitude of interesting effects. My challenge to you is to write a simulation of something you find beautiful and make your own instructable! I'd love to see what y'all come up with!

The Motion of Water -- Ever since I've been living in Boston, I've been enamored with the waves on the Charles River. Some days the water is totally calm and smooth as a mirror; On others, it's rough and breaks reflections into flickering shards. I grew up in a land-locked state, so I never really got to experience the beauty and variety of water waves and reflections and never came to appreciate how the character of a body of water changes with the day.

I find myself living in a rather old, somewhat run-down dorm whose inside walls are completely covered with murals. I felt compelled to contribute to these murals, but my painting and drawing skills are subpar at best. I eventually got to thinking that maybe I could make an electronic mural out of LEDs, which was what really inspired me to start this project, and the motion of water seemed like a rather apt subject to display on a low resolution LED screen.

The features I typically notice when looking at water are often from the light of reflections, making LEDs an excellent display choice, as they have a large enough range in brightness to really capture (I think) the character of reflections. Not to mention, the features of waves can readily be described (to a first order approximation...) by the height of the water surface as a function of a 2D position. This makes it perfect for displaying on a 2-D display, where you are given essentially one degree of freedom (the RGB value) as a function of position on a 2-D plane. Not to mention, it's fairly straightforward to approximate the behavior of water with a mesh of coupled masses, which I will describe in more detail in future steps.

Of course, my methods here are by no means perfect, and could use a lot of improvement, so if you have any ideas, I'd love to hear them in the comments!

Julian Opie's Art work -- Now comes one of my biggest inspirations for this project: Julian Opie. Opie is a minimalist artist, who's known for his unique depictions of humans and animals that have been stripped of as much detail as possible while still maintaining striking elements of specificity. I'm no art aficionado by any stretch, but if you haven't heard of him, I would highly recommend browsing his website ( I find so many of his installations and paintings absolutely stunning!

I think his style is especially compelling with some of his electronic LED installations. The first one I ever saw was called "Suzanne Walking in a Leather Skirt". I have to admit, when I first saw it, my brain short circuited a little bit. It was as if I was looking at some sort of pedestrian crossing or restroom symbol that still somehow had its own personality. I'd just never seen anything quite like it.

Even though the subjects in Opie's installations lack spatial detail, having been reduced to nothing more than the most essential outlines, their specificity nevertheless shines through simply by the way the image moves. I think it's precisely because the display is low resolution that the movements are so emphasized, and I think this is an incredibly interesting and compelling concept.

The piece I ended up displaying on this display was "Suzanne Walking Forwards"(, though I had to adapt it a bit to make it compatible with my display's size and resolution...hopefully Opie won't mind.

Algorithmic approaches approximating Julian Opie's style -- I've read that Opie uses an electronic drawing program to select the only the minimal details he wants in his animations. I can only imagine that he has to repeat this process with each individual frame in his animations (though I can't say for sure).

I wanted to try making some Opie-esque drawings using bit more automation. One of my friends wrote a program to create bitmap videos from regular videos by only illuminating pixels if their greyscale value is above or below a certain threshold. This allows for some interesting effects, but it's really only enough to get silhouettes at best, and tends to be pretty noisy.

I've been working on some methods that use a pixels color velocity as well as contrast to try to pick out the subject from the background, as well as hone in on the most important edge details in the image. This is still very much a work in progress, but hopefully will go an interesting direction in the future.

Step 2: Display Demos

Well by this point you're probably bored out of your mind by my rambling, so how about I just post a few videos of the display so you can see it in action. The display is intended to be placed somewhere and play the same animation/simulation repeatedly, and any of these modes can be selected by jumpers on the MCU board (I'll go into this more in future steps).

The videos in the order they appear above are

-"Suzanne Walking Forwards" by Julian Opie, Adapted by the author to fit on the display -- Pretty straightforward. It's just the video here ( converted to a bitmap video of the appropriate dimensions and resolution for the display presented in this instructable. Unsurprisingly, this design (i.e. the one made by an actual artist) has received the most compliments so far.

-Water reflections? -- I originally meant this to look like the reflection of some of part of the Boston skyline in the Charles, but I don't think my execution was good enough. Nevertheless, you can see some of the wavelike behavior from my water simulation (detailed in later steps).

-Ultimate Chicken Strip -- The water simulation was rendered and used for the illuminated pixels of a bitmap video. The "Chicken Strip" video (see below) in silhouette form is repeated three times on the image, with each image alternating between negative and default colors. This was really just a dumb but kinda fun demo we made for ProjXPo.

-Dog Running -- This was some stock footage of a dog running that we converted to a bitmap video in silhouette. Ideally it would have been just the dog itself, but the ground and some clouds also found their way in. We used the rendered water simulation as the illuminated pixels. I really like this one because it kind of looks like some sort of intergalactic dog running across the milky way...or maybe that's just me. Either way, I think it's another dumb but fun design to display.

Step 3: Overview of Materials

Now that all due introductions and demonstrations are out of the way, we can start discussing how to actually make this beast!

The cost of materials for was ~$650, but I went a bit overboard on the materials for the display chassis. If you are more economical with your chasis design, you could easily pay more like $500. With that said, let's begin the material breakdown

-(14x) 5 meter reels of 60 pixel/m WS2812b LED strips (~$350) -- These come in a lot of varieties from a lot of different sources. Avoid the weather-proof strips, as they have a silicone coating over the LEDs that impedes heat transfer and can more easily lead to some thermal-related failure modes. If you want quality strips and money is no object for you go for those sold by Adafruit ( For the rest of us broke son-of-a-guns, you can find a number of inexpensive WS2812 LED strips on Ebay from a number of sellers (e.g. )These tend to be a bit more sketch in terms of the likelihood of random pixel failures, but as long as you don't drive them too bright, they tend to be okay (I'll go into some precautions to help avoid pixel failures in later steps).

I've seen WS2812 LED panels for sale on Ebay, which may be more apt for this application than the LED strips, but I'll leave that to the future generations.

-(1-2x) 5v 60A power supplies (~$60) -- These can be purchased on ebay rather cheaply (e.g. )The 4000 WS2812 LEDs are not exactly the most power efficient devices, and can require quite a lot of juice. That said, if you intend to look directly at the LEDs (which we do here), you really don't need to drive them that brightly. I put two of these power supplies in parrallel for a total of about 600 watts of potential power, but I found that with all my demos animations, I really could get away with just one power supply. Again, this is because you really do not need to drive LEDs anywhere close to full brightness if you are looking directly at them.

-PIC32MZ Dev Board (~$50) -- I'll go into this in much more detail in an upcoming step, including the BOM for the board and the Eagle files so you can order your own. You could also try one of the higher end arduino boards, but I found even the arduino Due to be too slow. Here I was really after a beefy CPU more than fancy peripherals, but depending on the capabilities you are after, a number of different MCU's and dev boards could probably be used.

-(3x) 30"x24" plywood painting panels (~$90) -- Here is where you could probably save a fair bit of money by simply using a lower cost sheet of plywood or MDF. I used the painting panels because they were sturdy, and small enough I could reasonably carry them home from the store (I don't have a car, and a sheet of stock would be a bit much to carry on the T). I would recommend using at least 3 smaller panels during construction rather than one large panel, as it makes LED strip application much easier, and also allows the display to be disassembled for easy transport.

-(2x) 72"x1.25" Steel strips(~$20) -- These width and thickness of these strips isn't critical, but It should be sturdy. These will hold the plywood panels together on the edges, and also act as the power busses for all the LED strips. I managed to find strips with pre-drilled holes at a local hardware store, but angles like this ( ) would also work.

-24 inch length of 1" OD copper tubing-- I cut this into small sections, hammered them flat, and used them to make power lugs, but you could also use a less jank material for the lugs...

-Lots of wire-- Most of the wiring on the actual LED strips I did with 28AWG ribbon cable, because I managed to scavenge a huge reel of the stuff. I also used 14-18 AWG or wire to connect the power supplies to the power busses, and some recycled mains cords to connect the power supplies to the wall. All the wire I used was recycled, but you could easily buy this all online or at a local hardware store.

-1/4"-20 hardware and zipties-- I used ~18 1/4"-20 bolts+nuts+washers, a few #8 machine screws+nuts+washers, and a number of zipties to assemble the display chassis and affix electronic components

`-Optional: (3x) 30"x24" 1/8" thick plexiglass sheets-- I eventually covered the display with plexiglass once I got around to installing it in the lounge of my dorm, but if you aren't expecting the display to have run ins with drunk people or cats, this is probably unnecessary.

Step 4: Overview of the LED Strip Connections

The display overall display is essentially a composite display made from 13 smaller sub-displays. As I will get into in a future step, this helps us transfer data to the display more quickly and allows for a faster frame rate or greater rendering time.

Each sub-display consists of 8 rows of 40 LEDs with their data lines daisy chained together (meandering up the the display). The top sub-display only consists of 4 rows simply because 100 % 8 = 4, so one of the sub-displays had to be smaller than the rest. Each subdisplay receives its own data stream from its own data line connecting to the MCU.

You may be wondering why I chose to affix the LEDs in 100 lengths of 40 LEDs, when it seems easier to affix only 40 lengths of 100 LEDs. The main reason is that it makes it easier to break the display into smaller panels, but also keeping the number of LEDs per power connection small helps reduce the voltage drop across each strip. Voltage drop can become a problem for strips above a length of ~1 meter or so, but by bussing power to each of the rows of 40 LEDs, the voltage drop across each row is negligible.

The first image above is a block diagram of the entire display, showing how each sub-display connects to the ground and power bus on the either side of the display, and how each sub-display connects to a different GPIO pin on the MCU.

The second image above is a diagram of one of the sub-displays, showing how each strip connects to the ground and power busses on either side of the display, and how the data lines connect successive rows in a meandering pattern. That is, the first row has its DO (data out) connection on the right side, while the row above it runs the opposite direction and has its DI (data in) connection on the right side and so on up the display. This allows a small loop of wire to connect one row to the next in the sub display.

The first DI connection in each sub-display connects to one of the GPIO pins of the MCU, while the last DO connection of each sub-display is left unconnected.

Hopefully these diagrams make sense, but if not, it may be helpful to look at the actual pictures on the steps to follow, which detail the process of affixing the LEDs to each panel, as well as wiring them to the power and ground busses and the MCU.

Step 5: Custom PIC32MZ0512EFE064 Dev Board

An arduino seemed like the obvious choice for this project at first. But, alas, even the arduino DUE running at 80 MHz could only render the water simulation (see later steps) at 8 frames per second. So, I went on digikey and looked up the PIC/AVR MCU with the fastest CPU for under $10, and found the PIC32MZ0512EFE064, which can run up to 252 MHz and has a floating point unit. This seemed like a more appropriate choice for rendering graphics via simulation (i.e. numerically solving differential equations). That said, if all you want to do is playback video from flash or even an SD card, an arduino would probably work fine, though you'd have to write the code yourself.

So because I used a PIC, If you want to use this board, you will need a PICKit3 or equivalent ICSP programmer. You'll also need all the parts in the Bill of Materials below.

Bill of Materials
-(1x) PIC32MZ0512EFE064
-(1x) REG1117-3.3
-(1x) 22 uf cermaic capacitor (0805)
-(1x) 10 uf ceramic capacitor (0805)
-(7x) 0.1 uf ceramic capacitors (0805)
-(1x) 10k resistor (0805)
-(13x) 500 ohm resistors (through holw)
-(depends on how many GPIO you use) Rows of female headers
- 6 pin row of male headers for programming

Now this particular MCU only comes in a 64 pin QFP package, which requires either a breakout board or a custom PCB to prototype with. I opted to just make a custom board so that I could easily include a voltage regulator and place the decoupling capacitors as close to the chip as possible (as per the data sheet's recommendations). Plus this board seemed pretty useful for future projects.

There really isn't too much to this PCB. It just utlizes the minimum connedtion requirements as per the MCU's data sheet with a 3.3v regulator slapped on. I broke all the GPIO out to female header pins around the edge of the board to make it easy to prototype with this board. I did include mounting holes, but they were a bit of an after thought and are thus placed in essentially random locations.

If you want your own board, I attached the eagle files (both the .brd and .sch). This is only a two layer board, and fits within board area allowed by the free version of Eagle (, so anyone should be able to open this.

I ordered my boards from OSH Park ( They accept Eagle files, so if you don't even want to touch Eagle at all, you could just submit the board file here. The schematic for the board can also be viewed in eagle, though it was really a bit too large to convey in a screen shot, so I have neglected to include a photo lieu of the minimum connection schematic from the MCU's data sheet, which is essentially all that's on the board.

Now you may be asking what the 500 ohm through hole resistors are for. Those need to be wired in series with every data line going to each of the SubDisplays. This will help with noise (I'll elaborate on this more in a later step).

Step 6: Affixing LED Strips to the Panels

The first step of the actual display assembly is to affix the LED strips to the wood panels of the display chassis. I HIGHLY recommend breaking the display into several smaller panels rather than one large panel, as you will need to be able to easily reach and see every point of each panel during the LED strip installation. This would be made difficult if the panel is too large and unruly. Here I used a total of three panels, each on 30"x24" plywood painting panels.

I started by cutting my LED strips from the reels into 100 strips each with 40 pixels. You will want the ends of the strips to have exposed solder points so you can wire it up later.

It's not a bad idea to test each 40 pixel strip BEFORE you affix it to the board, So you won't have to rip it up later. In spite of being warned about the cheap Chinese LED strips, I didn't end up finding any faulty pixels right off of the reels. Most pixel failures ended up being my own fault (e.g. connecting power backwards or something).

Most LED strips have an adhesive backing, which I found to be sufficient to affix them to the wood panels. I cleaned the panels first using rubbing alcohol and paper towels just to make sure there was no dust or oil on the surface that could interfere with the adhesive. The adhesive may not hold up well over time, but for the last 9 months or so, it has managed to keep this display in tact.

The LED Strips need to be spaced such that the vertical spacing of each pixel matches the horizontal spacing already present on the strip. These dimensions will vary depending on the exact strip you use. In my case, the pixel spacing was about 1.67 cm. To avoid having to make A TON of measurements during assembly, I made a template in DraftSight that marks where the edge of each strip needs to be to achieve a vertical pixel spacing of 1.67 cm (my template file is attached below in case you want to use it yourself). I used two templates on either side, and used a straight edge to draw a pencil line where the bottom edge of the LED strip would need to be aligned.

I tried to line up the edges of the first pixel in each row as I stuck the strip to the panel. I gradually peeled the protective paper off of the adhesive as I went, keeping the un-stuck strip taught with one hand as I used the other to press the adhesive firmly onto the board. I was careful to stick the edge of each strip on the pencil line I had drawn. If the strip was going off-course, I could pull the un-stuck end in the necessary direction as I continued to stick the rest of the strip down to correct the strip alignment.

The strips from the real are often formed from smaller strips that are soldered together. At the solder joint, the pixel spacing is usually slightly different from the rest of the strip. This will disrupt the vertical pixel alignment slightly, but I found that it was usually pretty hard to see when the display was operating unless you looked really hard. This effect would be larger the longer the rows are, which is one reason I opted to apply the strips as 100 40 pixel rows instead of 40 100 pixel columns.

Successive rows need to have the LED strips going in opposite directions. The WS2812b LEDs essentially contain a serial shift register that will receive data from one end and shift data out the other end. This means the LED strips have a direction, which MUST be taken into account when applying the strips. The data should snake/meander up the display, which means successive rows need to be applied in opposite directions. This is mostly to make wiring the signal lines easier, and make the display programming simpler.

In order to accommodate the power supplies and MCU board at the bottom of the display, roughly 8 inches were left unpopulated with LED strips. This meant only 28 rows were placed on the bottom panel, while 36 were placed on the top two panels.

Step 7: Assembling the Display Chassis

Unfortunately, I forgot to take pictures of this process, but I think It's relatively straightforward. We simply need to connect all the wood panels together by bolting the Steel strips on the outside edges of the panels. (some of the photos in the next step may be illuminating in this process)

It's important to make sure the panels are well aligned before you drill and bolt the steel strips onto the side. I accomplished this by clamping the frames of the panels together such that they were well aligned. I then clamped the steel strip onto the outside edge of the panel frames. I drilled holes in strategic locations and tightened the bolts for each panel before removing the clamps and moving onto the next panel.

Bolts need to be placed in locations that will maximize strength. I placed one bolt about 2 inches away from the top and bottom of each panel, and one in about the center. The bolts on the edges help prevent the panels from rotating around the area where the panels meet. The bolt in the center helps keep the strip flat and prevents it from bowing outwards.

These strips also act as the ground and power buses, and will have additional bolts placed in them when the power lugs are connected.

Step 8: Connecting the LED Strips to the Power Busses

WS2812b's are very power hungry (also very bright) if you drive them close to full power, and busing that much power (in my case up to 120A amps, but that was just the limitations of my two 60A power supplies, which is really only like half max power for the entire display) is non-trivial. You need a rather large chunk of metal to bus that much power.

Fortunately, we already have two large chunks of metal running along each edge of the display in the form of the steel strips that hold the wood panels together. I connected the grounds connections from the power supplies to one of the steel strips using large lugs made from some 3/32" sheet aluminum. I repeated this process on for the +5V connections on the steel strip on the opposite side of the display. Thus the steel strips that hold the 3 wood panels together also act as the power and ground buses for the display.

I made some power lugs by hammering flat ~1.25" lengths of 1" diameter OD copper tubing, and drilling a ~3/8" hole in the center.I soldered strips of ribbon cable to both ends of the lug. I connected them periodically to the power bus using 1/4"-20 hardware. 6 such lugs were used on each of the three panels.

I folded the ribbon cable over onto the display, and secured it using a zip tie. This ensures that if the panels are disassembled later, any strain on the power wire would be on the zip tie rather than the solder joints on the LED strips.

Each row of LED strips is connected to the ground and power buses by two strands of 28 Awg ribbon cable wire. This is a bit thin if you consider the maximum power that each row could consume, but in reality we aren't ever going to run any of the LEDs close to full power (or at least if we do, it has a very small duty cycle), which lets us get away with that sort of delinquent behavior. Mostly, I wanted to use ribbon cable because it makes it very easy to keep wiring organized.

Each LED strip is connected to ground on one end, and +5v on the opposite end (e.g. if the DO end is connected to ground, the DI end is connected to +5V, and vice versa).

In order to keep the wiring organized, the wires were connected to the LED strips using nested right angles (see the photos), alternating between the lower ribbon cable and the upper ribbon cable. as the wire was routed, it was secured using hot glue. This keeps the wires organized and relatively flat on the board. The ends of each pair of 28 AWG wire was soldered to the respective ground or +5v solder point on the end of each LED strip.

This whole process was quite laborious and time consuming. Fortunately for me, I had a friend helping me with most of it. So if you have the opportunity, the could be a great opportunity to hang out and catch up with some of your EE/CS inclined friends!

Step 9: Wiring the LED Strip Signal Lines

I started by marking with pencil all the signal connections that needed to be made. Each sub-display (consisting of groups of 8 rows) was daisy chained together by soldering a small loop of 28 awg ribbon cable (just one wire) from the DO connection on a lower row to the DI row in the row above.

To check my connections, I would follow the signal path with my finger. If my finger made a meandering path (i.e. right to left on one strip, through a wire to the next strip, and then left to right, etc.) up the sub-display it was fine, but if I found I visited a strip twice, or not at all using this method, I had made a mistake somewhere.

The first DI connection of each sub-display was connected to the corresponding GPIO pin listed in the block diagram in the "Overview of LED Strip Connections" step. The last DO connection in each sub-display was left unconnected.

The connections to the MCU required increasingly long wires. Long wires are increasingly susceptible to noise pickup due to inductive and/or capacitive pickup, especially when they are placed next to a bunch of other data lines. The data lines that were the longest (i.e. went to the top panel) required RF shielding to properly transmit data (I'll elaborate on this a bit more in the "Logic Levels and Noise Considerations + How Not to Burn Out Cheap LED Strips" step). This was accomplished by wrapping the bundle of signal wires going to top panel in aluminum foil. This foil was then grounded.

Ideally, every data line would be shielded individually, but I empirically determined that my arrangement was, nonetheless, sufficient to reduce noise to acceptable levels. The signal wires were also held in place using electrical and masking tape. For a more permanent installation, I would recommend using zip ties. It helps reduce noise to tape the data lines as far away from each other as is feasible.

The other ends of the data lines were soldered to male header pins with 500 ohm resistors in series. These headers were then plugged into the respective pins of the MCU board.

Step 10: Test Often!

I'm not going to lie. I didn't know if the things I was going to try were going to work when I started this project. I encountered a lot of problems as I went along and had to address each accordingly. This is probably of the complexity, where you are going to run into some unique problems if you try this yourself that I didn't see. So the best advice I think I could give, is to test everything you do at every possible opportunity you get.

I started by testing my code on a single row, just to make sure my LED driver code was working. Then I made sure I could run multiple data lines a the same time. Then I built the lower panel (first 28 rows) of the display, completely wired it, and tested it to make sure that my construction techniques were sound, and that my display code would work. (see the video of only the 28 row test).

Once I got 28 rows working, I built the rest of the display confident that I had solved all the problems. That's when I ran into noise issues that took me a fair bit of time to figure out how to solve.

I guess my point here is that I probably would not have been able to even get this display to work if I didn't take the time to start trouble shooting at every possible turn. It's much easier to get something working when you only have to solve one problem at a time, but that requires you to consistently stop yourself from building, and test things.

This is especially important because this project has enough individual steps that the probability of making zero mistakes is pretty small. I certainly ran into those odds when I was assembling it!

Step 11: Mounting/Wiring Power Supplies and MCU

I mounted the power supplies on the small 8 inch section of space on the bottom panel of the display using a few medium size zip ties. The zip ties were tightened enough that the supplies couldn't slide around. If you are going to be moving the display around a lot, I'd probably recommend mounting the supplies with machine screws on the threaded inserts on their bottoms, as in my display, they did slide around a bit.

The two 5v 60A power supplies were wired to the +5v and Ground buses (i.e. the steel strips on the two edges of the display) using 14 AWG wire and some crimp connectors. A power lug was made by drilling some holes in a piece of 3/32" sheet aluminum to allow crow's feet crimp connectors to be connected to the power busses with some #6 machine screws. Each power supply has 3 screw terminal connections for power and 3 for ground. This means that my 14 AWG wires each had a max of 20A running through them, which should be fine.

There was a bit of space left between the two power supplies, which is where I mounted the MCU board using a thumb tack (may God forgive me for my sins). I connected the MCU board's power header to +5v and Ground on the display using 18 AWG wire, though 22 or even 24 AWG would probably also be okay.

The ribbon cables with the signal wires were plugged into the corresponding GPIO on the MCU boards. I just used Port B on the PIC, and assigned each pin starting from 0 to each sub-display going up (i.e. B0 controls rows 0-7, B1 controls rows 8-16, ... , B12 controls rows 96-99).

Step 12: How to Drive a LOT of WS2812s at a High Frame Rate

So here comes the fun part. We need to figure out how we can drive our display quickly.

Data is clocked into the WS2812b essentially using a PWM signal, i.e. a square wave. Each period counts as a bit. If the duty cycle of one period is larger than a certain threshold, the bit counts as a one. If it's shorter than a certain threshold, it counts as a zero. There are certain timing characteristics these duty cycles need to adhere to as listed in the data sheet, but they have pretty liberal tolerances.

The key about this protocol, is that each bit takes about 1.25 microseconds to transmit. For each frame of our animation, we need to transmit 24 bits for each of the 4000 pixels. multiplying that out, you can see that if we had all our LEDs on the same data line, we would not be able to run the display at anywhere near 30 fps. Luckily, we can take advantage of the fact that microcontrollers' GPIO pins are lumped together into ports that all update their values at the same time. If we already have each pixel value buffered in the MCU's RAM, we could transmit the data for different sections of the display simultaneously on independent data lines. This reduces the transmission time of the data by a factor of 1/N, where N is the number of data lines used.

The PIC I selected has up to 16 pins on a Port, so I could use up to 16 data lines. I ended up using fewer so that I could get all the data lines to connect to the sub-displays on the same side of the display (i.e. just for convenience sake). I suspect you could probably even use pins that aren't on the same Port, because while they would be out of sync, you're eye may or may not be able to actually pick up on the delay. If someone wants to give that a try, that would be awesome!

In any case, the actual transmission time for this display comes out to 1.25E-6 * 24 *4000 / 13 = 0.0092s or ~10 ms. This could in theory transmit up to 100 fps, but I think the WS2812 update rate might be the limiting factor before really approaching that figure (might be worth trying though).

I've implemented this technique in the code snippet below. To summarize, the code loops through each RGB value in a frame, and corrects its direction for transmission. This must be done for each RGB value to be sent via each data line. the data for each data line can be found using a simple offset value of the index in the frame array. These offset values are stored in the lengths[] array. Each bit of the RGB value is then looped over. Each bit starts by pulling every data line high. A delay is then created using a series of NOP() instructions. The rest of the high time for a zero code as listed in the WS2812b data sheet is occupied by the time it takes to perform the & and bit shift operations to update the LATB register. Those data lines that are transmitting a zero are pulled low during this &= operation, while those transmitting a one are left high. Another series of NOP() operations delays the time difference between a zero and one code. Then every dataline is pulled low. This is repeated for every RGB value in the frame and then all data lines are pulled low for the RET code time to latch the data in the display, and show it to the world. The timing of this process is shown in the "bit timing" diagram above.

void ws2812MultiSend(){
unsigned int tempVal = 0; unsigned long val1, val2, val3, val4, val5, val6, val7, val8, val9, val10,val11, val12, val13, val14, val15, val16; int ind = 0; for(ind = 0; ind < len; ind++){ val1 = (reversed[c[0][ind]] << 16) + (reversed[c[1][ind]] << 8) + (reversed[c[2][ind]]); val2 = (reversed[c[0][ind+len]] << 16) + (reversed[c[1][ind+len]] << 8) + (reversed[c[2][ind+len]]); val3 = (reversed[c[0][ind+lengths[2]]] << 16) + (reversed[c[1][ind+lengths[2]]] << 8) + (reversed[c[2][ind+lengths[2]]]); val4 = (reversed[c[0][ind+lengths[3]]] << 16) + (reversed[c[1][ind+lengths[3]]] << 8) + (reversed[c[2][ind+lengths[3]]]); val5 = (reversed[c[0][ind+lengths[4]]] << 16) + (reversed[c[1][ind+lengths[4]]] << 8) + (reversed[c[2][ind+lengths[4]]]); val6 = (reversed[c[0][ind+lengths[5]]] << 16) + (reversed[c[1][ind+lengths[5]]] << 8) + (reversed[c[2][ind+lengths[5]]]); val7 = (reversed[c[0][ind+lengths[6]]] << 16) + (reversed[c[1][ind+lengths[6]]] << 8) + (reversed[c[2][ind+lengths[6]]]); val8 = (reversed[c[0][ind+lengths[7]]] << 16) + (reversed[c[1][ind+lengths[7]]] << 8) + (reversed[c[2][ind+lengths[7]]]); val9 = (reversed[c[0][ind+lengths[8]]] << 16) + (reversed[c[1][ind+lengths[8]]] << 8) + (reversed[c[2][ind+lengths[8]]]); val10 = (reversed[c[0][ind+lengths[9]]] << 16) + (reversed[c[1][ind+lengths[9]]] << 8) + (reversed[c[2][ind+lengths[9]]]); val11 = (reversed[c[0][ind+lengths[10]]] << 16) + (reversed[c[1][ind+lengths[10]]] << 8) + (reversed[c[2][ind+lengths[10]]]); val12 = (reversed[c[0][ind+lengths[11]]] << 16) + (reversed[c[1][ind+lengths[11]]] << 8) + (reversed[c[2][ind+lengths[11]]]); val13 = (reversed[c[0][ind+lengths[12]]] << 16) + (reversed[c[1][ind+lengths[12]]] << 8) + (reversed[c[2][ind+lengths[12]]]); int j = 0; for(j = 0; j < 24; j++){ LATB |= lineMask; //pull data lines high for the LS high time, should be .35uS ~70 Nops Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); /*Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop();*/ //if the bit for which ever dataline is 0, it must be pulled low now, otherwise leave it alone for a bit longer LATB &= (~lineMask)|((val2 & 0x0001)<<1)| (val1 & 0x0001)|((val3 & 0x0001)<<2)|((val4 & 0x0001)<<3)|((val5 & 0x0001)<<4)|((val6 & 0x0001) <<5)|((val7 & 0x0001)<<6)|((val8 & 0x0001)<<7)|((val9 & 0x0001)<<8)|((val10 & 0x0001)<<9)|((val11 & 0x0001)<<10)|((val12 & 0x0001)<<11)|((val13 & 0x0001)<<12);//|(val14 & 0x0001)|(val15 & 0x0001)|(val16 & 0x0001); //now delay the difference in high time between the 0 and 1 states; should be .55uS ~110Nops Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); //now pull all data lines low LATB &= ~lineMask; //delay the high low time; should be 0.35uS ~70 Nops Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); /*Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop(); Nop();*/ val1 = val1 >> 1; val2 = val2 >> 1; val3 = val3 >> 1; val4 = val4 >> 1; val5 = val5 >> 1; val6 = val6 >> 1; val7 = val7 >> 1; val8 = val8 >> 1; val9 = val9 >> 1; val10 = val10 >> 1; val11 = val11 >> 1; val12 = val12 >> 1; val13 = val13 >> 1; // val14 = val14 >> 1; // val15 = val15 >> 1; // val16 = val16 >> 1; } } }

Looking at the oscilloscope output in the photo's you can see the render time for my water simulation (see future steps), plus the transmission time. The rendered image could be quickly overwritten with a bitmap image, allowing it to act as a delay element for about 20 fps videos. Note that the transmission time is actually ~10 ms, meaning the code was working as expected.

Step 13: Logic Levels and Noise Considerations + How Not to Burn Out Cheap LED Strips

Now that we are actually transmitting data, it's time to mention a few important things.

Those of you who have worked with WS2812b's before are probably screaming at me. The PIC I'm using operates at 3.3v, meaning it's high and low logic levels are 3.3v and 0v respectively. The WS2812b datasheet specifies that its high logic level is 0.7*VDD, which in this case is 0.7 * 5 v = 3.5 v. So the PIC is actually incapable of driving the data line to an appropriate logic level.

This sounds pretty bad, and, admittedly, it's definitely not ideal. If you try this yourself, I would recommend putting a logic level shifter between the PIC's GPIO and the display to boost the logic level to be within spec.

That being said, I found that the strips will usually still work if given a logic level of 3.3v, but they are especially susceptible to noise-related data glitches. You can usually tell a noise-related glitch is occuring if the display starts flickering a bunch of random, uncorrelated colors on each LED.

To solve this, I found it was sufficient for up to 8 data lines to simply place a 500 ohm resistor in series with each data line. This helps reduce noise due to ringing from fast transitions on the data lines. However, with more than 8 data lines near each other, noise from inductive pickup from other, nearby data lines became a problem. I solved this by shielding the data lines going to the top panel with grounded aluminum foil. This acts as a faraday cage, which prevents prevent cross talk between the nearby data lines. If you were to really to a solid job, you would shield each of these wires individually (maybe use some mini-BNC's or something)...or you would just use a level shifter. Either way, it seems like I managed to get away with it (once again, God have mercy on my soul).

And while, we're on the topic of abusing WS2812b's...

How Not to Burn Out Cheap LED Strips

I've been warned repeatedly by people that the cheap LED strips from Ebay are prone to pixel failure after relatively short operating times. This would be pretty catastrophic because as soon as a pixel breaks, it stops transmitting data down the line. Thus one pixel failure would take out everything down stream in the sub-display and lead to a big dark space in the display rather than just one dead pixel.

The only failure mode that I have really seen comes from connecting the power backwards or thermal related failures. The former is usually the fault of the user, and you just need to be careful. The latter comes from running the pixels at too high a power with too little heat dissipation. Basically, if you drive the LEDs at or near full power with a high duty cycle (like >30-50% by my rough estimates) with no forced air and/or no additional heat sinking than just the LED strip itself, the LEDs will begin to heat up quite readily. When they get hot is when the cheap WS2812b's seem to release their inner demons.

My simple recommendation is to simply never run the LED strips at greater than about 25% brightness. If you intend to look directly at the LEDs during operation (like we do here) this should not be a problem. Honestly, much brighter is almost painful to look at. You definitely can run the LED's at full brightness for a few seconds at a time without too many problems, but you probably wouldn't want to do that with a duty cycle of more than a few percent or so...or at least not very often.

I've been running an animation on this display continuously for about 4 months now, and have only had one pixel out of four-thousand lose its blue color channel (still working otherwise). Not too shabby considering how cheap they were!

Step 14: Theory of the Water Simulation

So here comes the water simulation that I kept mentioning in the previous steps.

This is really a simulation of a mesh of masses connected by strings. If you were to take the limit of this as the masses became infinitely small and close together, you'd essentially have a 2-D plate. So I suppose it's a more apt approximation of a solid plate, but I think it still pretty reasonable represents a liquid, where, near the surface, a certain mass of liquid is subject to the forces of surface tension. What I'm getting at here is just that we can use this system as a reasonable approximation of water and thus its motion following certain perturbations probably closely resembles that of water.

In reality, a mesh of masses is much easier to work with, so that's what we'll use. So let's suppose we have a mesh of small masses, one in each position of each LED, and each of these masses is connected by a string to the masses on the left and right of it as well as above and below it (except for the masses on the edges and corners which have only 2-3 nearest neighbors). If we say that the masses are constrained in their x and y positions, we can begin to write down the forces acting on the mass in the z direction (we are only interested in how the masses change in height). if one of the masses is moved from its equilibrium position, it will experience a tension force from the masses around couled to it (its nearest neighbors). The force it experiences due to each mass will be proportional to the difference in z position between it and the other mass. (see the first photo)

Now, lets suppose the system is also immersed in some sort of fluid (e.g. air) that disipates the energy from the motion of each mass. It will add a damping force that is proportional to the mass's velocity, and that acts opposite to it. (see second photo)

These are the only forces acting on the mass, so, given each masses position, we know how to find the forces acting on it, which by Newton's second law (F=ma) means we also the second derivative of the masses z position in time as a function of z position and z velocity. This means we have a 2nd-order ordinary differential equation corresponding to each mass. (see third photo)

We can further break each of these equations into a system of two first order differential equations, which is easy to solve numerically by using a series of small time steps and taylor approximations (see fourth photo). This is essentially the forward Euler method, with a 2nd order correction thrown in at one point.

This allows us to solve for the position of every mass in the mesh at any point in time in the future given we know the initial position and velocity of each mass in the system.

So Now if we perturb the system by, for example, displacing one of the masses suddenly and releasing it, we should see an isotropic wave like propoation of that perterbation. In that particular case, it should look a bit like a water droplet and its resulting ripples.

If you look closely at the demos that make use of this simulation, you can see this droplet and ripple behavior, which is pretty cool!

For a system of 4000 masses, each step of the simulation is a farily lengthy series of arithemetic calculations, which is why I needed such a beefy CPU to pull this off. An arduino Uno can do only do this for a ~150 mass system.

Step 15: Water Simulation Code

The exciting thing about the simulation, is that it allows you to feed inputs from the outside world into your graphics, and see the effects in real time! In the wave lamp I showed in Step #1, I used a microphone to displace select masses in that simulation based on ambient noise. I was originally planning on using that same method on this simulation, but I have not yet successfully implemented the ADC in this code...maybe one of these days I'll get back into it (or maybe one of you will!), but for now a random number generator is used in place of inputs from the real world.

You can see all the algebra from the previous step in code form below. The function advances the simulation one time step. the value of the time step must be small enough that first-order approximations are valid (tstep^2 << tstep). positions[][] and velocities[][] were global variables declared outside the helper function.

After each step, the position of each mass is converted into a color to be displayed on the corresponding LED using a predefined color gradient.

As I said, the simulation in all my graphics thus far are based on randomness, but you could so easily make it based on any sort of input from the real world that you want, which would really take this think to the next level!

void simStep(float tstep){
/*this function calculates the positions and velocities after one time step using forward euler's method*/ //iterate through every element in the simulation int i = 0, j = 0, ind = 0; long color = 0; float positionTerm = 0, force = 0; for(i = 0; i < width; i++){ for(j = 0; j < height; j++){ //calculate the force on this element based on its and its neighbors positions positionTerm = 0; //use this to keep track of the forces contributed by tensions (i.e. the position dependent component) if(i > 0) positionTerm += positions[i][j] - positions[i-1][j]; if(i < (width - 1)) positionTerm += positions[i][j] - positions[i+1][j]; if(j > 0) positionTerm += positions[i][j] - positions[i][j-1]; if(j < (height - 1)) positionTerm += positions[i][j] - positions[i][j+1]; if(positions[i][j] > AMax) positions[i][j] = AMax; if(positions[i][j] < AMin) positions[i][j] = AMin; force = -(T/a)*positionTerm - gamm*velocities[i][j]; //calculate the force as the sum of the z-component of the tensions minus the damping (gamma*v) //update the positions and velocities positions[i][j] = positions[i][j] + velocities[i][j]*tstep + 0.5*(force/m)*tstep*tstep; //get new position via second order taylor expansion about current point velocities[i][j] = velocities[i][j] + (force/m)*tstep; //get new velocity via first order taylor expansion about current point //now we need to assign colors to the pixels according to the position and motion of the LEDs. ind = (int)mapf((float)positions[i][j], AMin, AMax, 0.0, (float)(gradLen-1)); float brightness = pow((mapf(fabs(positions[i][j]),0, AMax, 0.0,1000.0)/1000.0), 1); color = colorScaleBlue[ind]; //color = colorScaleXenonWhite[ind]; ind = getPixelIndex(i, j); c[1][ind] = (unsigned char)(brightness* ((color & 0x00FF0000) >> 16)); c[2][ind] = (unsigned char)(brightness * ((color & 0x0000FF00) >> 8)); c[0][ind] = (unsigned char)(brightness* (color & 0x000000FF)); } } }

Step 16: Bitmap Video Code

I figured it would be worth showing the code for how I read bitmap videos.

I defined my animations an array of frames (or really just a 2D array of ints). Each frame was an array of integers, where each bit in the integer represents a pixel in the frame. This can only store monochrome videos, but it means that the animations are actually small enough to fit in the flash of the MCU.

The function below takes a frame array (i.e. a bitmap image) and clears any pixel that is set as a zero in the frame image. Before running this function, it would be necessary to set the every value in the color buffer (the global c[][] array) to some desired value, or even to set it to the according to simulation data.

void bitMapDisp(const unsigned int frame[250]){
/*Adds bitmap image to color buffer (input parameter is frame) bit map formatting is as follows: -list of all pixels in image with no interruptions -moves from from top to bottom -moves left to right color formatting: -moves from bottom to top -moves from right to left (direction is exact opposite of bit map format...*/ int pos = 0, bitPos = 15, bitMapPos = 0, working = 0, x = 0, y=0, ind = 0; float globalBrightness = 0.2; //the brightness we'll set the pixels to (we'll start out making them all white) for(pos = 0; pos < ledLen; pos++){ working = frame[bitMapPos]; //select the correct variable from the bitmap x = 40-(pos % 40); y = 100-floor(pos/40); ind = getPixelIndex(x, y); if((working & (1 << bitPos)) != 0){ //if the bit for the corresponding positions is set, illuminate that pixel if(img == 0){ c[0][ind] = (unsigned char)(255.0*globalBrightness); c[1][ind] = (unsigned char)(255.0*globalBrightness); c[2][ind] = (unsigned char)(100.0*globalBrightness); } }else{ //otherwise, turn it off c[0][ind] = 0; c[1][ind] = 0; c[2][ind] = 0; } bitPos--; //increment to the next bit if(bitPos < 0){ //if we have already used all the bits in that int... bitPos = 15; //move to the next int in the frame bitMapPos++; } } }

Step 17: Full Code

Attached here are all the source files used for this project. You should be able to include these in a project in MPLabX and upload it to your own MCU board!

I wanted to talk through a few of the more complex functions/ those that are critical to running this display, but for the rest, I'm gonna leave it to you to explore this code yourself.

Of course, It would probably be more interesting for you to include your own animations, which would require a modification of the frames.h and frames.c files. You can find code we used to generate that data for our frames here (, although I think it is pretty much undocumented at this time :(

-gradient.c/.h-- Contains a number of arrays that contain RGB values for various color gradients. This is primarily used to map mass positions from the water simulation to LED colors

-frames.c/.h-- Contains arrays that define all the frames for several bitmap videos/animations

-ws2812b.c/.h-- Contains functions for initializing and transmitting data to display using the PIC's GPIOs

-joWaterMain.c-- Contains the main loop as well as some helper functions to run the simulation, display bitmap videos, etc.

Step 18: Conclusion and Further Work

So that's pretty much all the documentation I currently have on how that display was built. If you want to replicate this, or still have questions, I could maybe help you out if you PM me, but my schedule is a little sporadic at the moment.

There's tons of room for improvements on this project. For example, the wiring on the edges of the LED strips could be replaced by a skinny but long PCB, which would eliminate a lot of the tedious wiring. The logic board could include level shifters to eliminate or at least minimize some of the work arounds required from the strange noise-related glitches. And a microphone or other sensor could be added as an input to the water simulation rather than a random number generator to make an augmented reality mural/ generally cool interactive installation.

I've also attempted some methods to extract outlines of moving subjects algorithmically using pixel color velocity and pixel contrast, but this is still very much a work-in-progress.

In any case, I think this was a very cool project that opened my eyes to a lot of interesting concepts and things I'd like to try in the future. If you liked this project, please vote for it in the Make It Glow Contest. Much appreciated, and happy making!

Make it Glow Contest 2018

First Prize in the
Make it Glow Contest 2018