Easy on the WebGLMay 16 2017
A couple weeks ago I launched my first game that uses WebGL. It worked fine on my machine so I shipped it. The feeling of shipping was amazing. I even took my wife and kids to dinner to celebrate the big launch! That’s when I got the first email from one of my brand new students:
Hey Dave this looks awesome but it really bogs down my laptop, I’d love for it to actually work!
I felt bad that she had such an “old machine”, made a mental note to do some perf testing later, and went back to my sticky finger quesadillas. My phone buzzed again:
Wow I love the artwork! Unfortunately that’s all I get to enjoy because it completely destroys my machine. I can’t even use my mouse it’s so bad.
You know that sinking feeling of dread you haven’t felt since getting busted for starting a chemical fire as a kid? Just me? Ok well, let’s just say I wasn’t in the mood for dessert. Over the course of the next few days I received a lot of emails and direct twitter messages from people who were loving the story course, so at least it worked for some people. But I was heartbroken about the many others. I decided to pause the creation of the remaining levels to figure out what was causing such a big problem in my app.
It just so happened that I had a hack weekend scheduled with some friends, which turned out to be perfect for focusing on this problem. I dug into it more and learned - to my disappointment - that it wasn’t going to be an easy fix at all.
Understanding the Problem
It turned out the problem was with the way I was (ab)using WebGL. I had designed the game so that for each zombie in the level there was a DOM element containing a canvas with its own 3D context (highlighted in blue outline in the following screenshots). Same for the hoodie character.
Now as the user enters code into the LCD screen a few things happen. Targets appear - each painted in their own (2D) canvas:
Once the user gets the answer right, the Hoodie character shoots the zombies and they’re swapped out with a different canvas/context for a death animation:
So far that makes five 3D contexts and eight 2D contexts, oh my. But it gets even worse. Every time you change the
flex-direction, the hoodie character aims in the appropriate direction, getting swapped out for… you guessed it. Another 3D context. Before long your computer realizes that the browser is asking for waaaaay too many resources and requests for additional resources get denied. The browser then has to make do with what it’s got. So it dumps the oldest WebGL contexts in order to free things up for the new ones. When this is about to happen you see this delightful message in the devtools:
WARNING: Too many WebGL contexts. Oldest context will be lost.
Well this is bad when you kinda sorta need the thing on the screen that’s about to get dumped. Fortunately you can react to this event and recreate all your textures etc in that poor unloved WebGL context. But it’s expensive and if you’re recovering contexts over and over it eventually overwhelms the CPU and brings the computer to its knees.
Hacking on a Solution
Once I understood what was happening the solution was obvious: only use a single canvas/context. All I had to do was rewrite my game and implement flexbox in WebGL! It only took the geniuses who build browsers eight years to get it right, how hard could it be?
After a good cry I went on the hack weekend trip and brainstormed some ideas with my brilliant/thoughtful friend @iammerrick. We prototyped a super interesting idea: dual render a React component to both the DOM and to a single shared canvas. We were pleased to discover that this would do the trick quite nicely. Here’s how it works:
A component renders a regular, empty
div which gets controlled by regular CSS flexbox:
When the component is mounted it also calculates its DOMRect (size & position) and draws its WebGL textures (the zombie/hoodie graphics) into the shared canvas at exactly the right spot!
Hide the divs’ borders and BOOM! Canvas-drawn zombies positioned by actual DOM nodes & flexbox.
The app runs way better now. Chrome takes about 11% CPU now as opposed to ~30% before. Memory usage is ~5MB lower going from level to level. The animations are also smoother. There is probably even more optimization I could do later. But hey, at least now flexbox zombies is safe to use on an airplane. Time to finish writing the rest of the story’s chapters.
In hindsight it seems obvious that I was using WebGL/canvas wrong. But I also think that had I tried to something clever like this the first time around it would have just been a distraction from building out my story course idea, and would have taken a lot longer to ship. I’m a believer in only worrying about the problems you have.
Solving the performance problems for actual people who were pumped to use something I had made was incredibly motivating. If I still hadn’t shipped it I would still have been wondering if people even cared about what I was making, which would have made it really hard to sink another week into a rewrite of the core mechanics.
No matter how smart you are, you need good friends. People who aren’t so close to the problems you’re trying to solve and can therefore see them with fresh perspective. People who care enough to tell you when your baby is ugly, and then put in the time and energy to help you make it awesome.
Am I sorry I shipped before it was perfect? Not one bit. A ton of people still loved it, and who knows? Maybe some devs were able to use this to prove to their bosses that they needed a new laptop :) It’s still not perfect, which grates on a perfectionist like me. But I’m training myself to be more OK with the mindset that imperfection shipped is better than perfection unshipped.
Oh and yeah, go easy on the WebGL :)