60fps in a Web App
Projector is a design platform built to make creative use of modern media faster, easier, and more expressive. It’s a space where anyone, regardless of design background, can craft compelling visuals for work. All of our tools have been built with modern teams as our focus — they’re cloud-based, they’re fast, and they offer real-time multiplayer collaboration.
Projector is a hybrid WebGL/React application. Our core authoring surface is a single WebGL canvas. That WebGL canvas is hosted within a single React application. We use React for all of our application chrome, navigation, library viewer, template browser, scene thumbnail reel, and editor toolbar components.
Our editor toolbars show the live state of your story’s layout and styles: which font is selected, the position of an element, or the fill color of a shape. The data behind these components can update as fast as the canvas will allow. We aim for 60 frames per second in everything we do. This includes our React components that are representing these style values in the active story.
So there are 2 separate problems for us to solve here:
- How do we get the rendering performance we need in our WebGL canvas?
- How do we get our React components to update at that same framerate?
We’ve done a lot of work to tackle the first problem and look forward to sharing more in a future post. But here, we’ll focus on our React app and how we were able to achieve 60fps with thousands of React components.
Why React?
We decided to use React for our application because it’s a great framework for organizing components, encapsulating behavior, and synchronizing model updates to the DOM only when needed. We love React.
With a large code base and a team of engineers working across the entire app, it is important to us to have a codebase that can scale with a team. Using React has let us do that as we separate the concerns of “what is the data being represented” and “what is the presentation of that data”.
When we began working on Projector, we really tried to use a lot of the standard React patterns. We used redux for shared state, and react state for local ui state. We used React context to avoid prop drilling, and shared hooks for sharing business logic.
What’s the problem?
While this was pretty straightforward to build, the performance and code maintainability did not scale. We ran into issues where dragging a shape across the screen could drop our frame rate to 10fps as all of our React components would re-evaluate their state and decide if they needed to update. Even if they’re not updating, the time it takes for React to determine that the change had no effect contributed significantly to our low frame rate.
We tried to shoe-horn in some calls to shouldComponentUpdate() and state memoizations but this became unwieldy in our codebase as these concepts had to be custom-implemented for each new component we added. This was not going to work for us long term. And even with these shortcuts, we still couldn’t get React to be fast enough.
As convenient as the tight coupling between view and model can be in React, it does not work for large tree (or graph) shaped models. The reason for that is that if any node of the tree changes (no matter how deep) each ancestor node, all the way to the root, becomes “dirty” and signifies a change to any consumers.
The model that backs a Projector story is a tree. A story contains scenes, scenes contain elements, and elements have styles. What this means is if a style on a single element changes, the element, scene, and story all become dirty as well.
When the root is passed through many layers of the react app, each of those components will re-render in order to forward props on to their children.
Look at this example of a mini React tree of our app and a mini story model. The App component, in this case, renders any time the story changes. It passes the story to the Editor component, which in turn, passes the story to the Surface, the ScenePreviews, and then Toolbars component. Each of those components will re-evaluate their result on ANY story change. When our React tree grew to the size it is today, it couldn’t keep up with that many changes.
Regardless of using prop-drilling to forward these models, or using a React Context to implicitly forward them (we tried both), the result is the same: each component that needs access to the story, must be re-evaluated when anything in our model changes.
So what did we do?
Taking a step back to think about the problem conceptually, we wanted to completely separate our model from our view so we could have full control over which views are re-evaluated when the model changes. We also want to be able to subscribe to and access this same model, outside of React — for example in our WebGL canvas.
So what we’d end up with is 3 completely separate pieces:
- A standalone model that can be subscribed to at any node (or combination of nodes)
- Arbitrary views that can represent the live-state of any node (or nodes)
- A connection between these two that would work to read a current value or subscribe to a changing value
We decided we would implement this as:
- A custom model implemented with JavaScript classes representing each node type. There is a global context that manages the tree and the nodes. When changes to the model are made a change event is fired in the context and all subscribers are notified.
- We have two different types of views: React components and a WebGL canvas. Both of these need to have access to arbitrary nodes of our model. Both as a one-time read and as a subscription.
- We created a system called ChangeEmitters that acts as a custom selector type on our model. ChangeEmitters can be used to read a value at a single point in time, or used to subscribe to a value on an ongoing basis.
There are a few keys to this approach that really help us achieve the code scalability and runtime performance we need.
The selection, subscription, memoization, and notification are moved out of React and managed ourselves. This gives us control over when and what needs to update. If we want to throttle an expensive piece of the model, we can. If we want to skip an update because our frame rate is dropping, we can.
We want to limit the number of React evaluations that occur. React should re-render components only when we know they will result in a new on-screen state. This means we want to move any subscription as close to a React leaf component as possible. We also want to subscribe to the minimum information needed for a component. This combination results in the most optimal React execution.
Consider a color picker component that is used for representing the fill color of a piece of text. Our initial implementation would subscribe to the selected element and pass in each attribute to its own toolbar component. But this subscription was too generic and resulted in excessive component renders. If the font size changed, the color picker would re-evaluate to see if anything changed. Instead, the color picker component subscribes specifically to the fill color of the selected element.
As you can see in this snippet, there are no props passed into the ColorPickerContainer and it’s only subscribed to 1 specific value (the color). No matter what changes in our model, this component will not re-render unless the selected fill color changes. We wrap this in a React.memo() on export so that even if the parent does re-render, this component will not.
The SelectedFillChangeEmitter looks something like this (simplified version).
These can be super simple: they implement a selector and a comparator. They can optionally implement other things like throttling and caching.
The useChangeEmitter hook now becomes pretty simple. It initializes a React state with the current value and subscribes to future value changes. When the hook unmounts, the ChangeEmitter is unsubscribed. And the best part is, because of TypeScript, the state values infer their types from the ChangeEmitter and we get type safety in all of our React components.
What was the benefit?
Making these changes across our entire app was a large effort by multiple engineers over the course of a few weeks. But upon making these changes, we immediately saw major improvements.
- The code we wrote became a lot easier to manage. There’s no prop-drilling. No hidden contexts that you hope exist. No components filled with useMemo(). Using our custom useChangeEmitter react hook, we can now subscribe to this change emitter in multiple places without duplicating any logic.
- Our application got a lot faster. We went from 10–20fps during normal story editing back up to 60fps.
- Centralizing the subscription to a single hook implementation allows us to dedupe subscription selection logic. If your component subscribes to a ChangeEmitter that another component is already subscribed to, it actually just piggy-backs on the first subscription instead of adding a duplicate parallel instance.
What’s next?
We’re looking for ways to share more information about the systems we’re building and using at Projector. This is one that we’re really excited about and think it could be useful to other teams as well.
Since developing this, Facebook has launched RecoilJS which looks like it addresses similar problems to what we were seeing. As a lot of our model is optimized to be used with our WebGL canvas, it’s not clear that Recoil would work for us out of the box, but it’s definitely exciting to see more web apps hit and address the same problems that we are.
There’s so much opportunity with the web platform and we believe that the really interesting stuff is just starting to be explored. We’re looking forward to working with other engineers that are passionate about pushing this platform forward.
Interested in working on Projector? Get in touch at careers@projector.com.