gl-react v3

react gl-react webgl

Happy to release https://gl-react-cookbook.surge.sh containing 43 unique examples and API documentation!

If you don't want to be "spoiled" by this article, go through the cookbook examples. This article will explore some of them.

gl-react has been rewritten from scratch

gl-react v3 is a complete rewrite of the v2 implementation for better performance and compatibility with React paradigm.

This is not yet published on NPM as it's still in development (the Web version is pretty ready, React Native version is not implemented).

Most features provided by gl-react v2 are preserved (API haven't changed, see how similar is the HelloGL example), but v3 fixes most Github issues accumulated for a year.

The biggest mistake of the previous implementation

If there is one lesson learned from previous gl-react implementation: "unfolding" / consuming the children prop by yourself is (probably) wrong, let React solve this job! Using React, you can benefit React reconciliation and diff algorithm. In other words, always prefer to keep users' VDOM tree rather than consuming it with React.Children.* functions.

I feel dumb not having discovered this before, but if you are not actually rendering DOM it's an easy path for a library to just map, traverse, consume the children tree and just render what you needs (like just a <canvas/>). But this is probably a mistake! First, this makes it impossible to use React Devtools and see the original tree, but more importantly, it breaks interoperability with other libraries (e.g. don't forbid someone to use react-motion or React Router in the middle of your components!).

A better idea is to preserve the user children. Keep your logic in each Component and use the React lifecycle to create and destroy things, and use React context to connect children to parent.

You should better keep user children, even if it means rendering it in an empty <span>, current workaround of gl-react, looking forward to hearing from you, idea inspired from the great react-music

What it means for gl-react

The gl-react v3 implementation truly uses React lifecycle: a React Component update triggers a GL redraw. That way, shouldComponentUpdate allows to do partial GL re-rendering. Each Node holds a framebuffer object (created on mount, destroyed on unmount) that only get redrawn when component updates and schedules a Surface reflow.

<Node> receives the gl: WebGLRenderingContext from the ancestor <Surface> thanks to React context. There is also a glParent context (a Surface or another Node) that is used to make GL components discoverable each other so we can build a dependency graph. This dependency graph allows to implement the correct draw pipeline (and it's pretty trivial, see Section "under the hood of Surface and Node redraw").

<Bus>, a better way to share computation

gl-react used to automatically factorize the duplicates elements of the GL tree but it has been decided to remove this feature: This was actually a complex mechanism (a bit too "magic"), hard to implement and a premature optimization that can have slower performance.

The new gl-react embraces the React paradigm: The new way to express a Graph (and share computation) is using a <Bus>...

The `()=>ref` pattern

The problem we want to solve is to express a graph with React, which, at first glance, only allow to represent trees, not graphs!

The way we can solve this is by using refs and a "ref getter function":

  1. a Bus with a ref: <Bus ref="myBus">{content to inject}</Bus>.
  2. pass a function that resolves the ref to pipe Bus into another Node. e.g: ()=>this.refs.myBus.

blurmapdyn example a single ConicalGradient should be used for all blur pass:

There are a few other good examples of ref usages:

The ()=>ref pattern works only if you call the function after component did update (refs are set at this time).

The good ol' children function

There is another pattern for more specific needs: instead of composing by giving an element, you can also compose by giving a Function that returns an element. Why that? Because, it's a way to nicely give you the redraw function: redraw => <Video onFrame={redraw} />:

Checkout video example

We really just want to redraw if there is a new video frame.

We have merged the 2 patterns into one: if you provide a function, it's just called with redraw, and the returned value is used as a texture. We have a few cases to detect what kind of texture it is (and also an extensible mechanism used by implementations to load platform specific objects). (checkout this if you want to see the code)

Node backbuffering & Backbuffer symbol

A new feature allows to inject the previous Node state as a texture. This is called backbuffering. One simple usecase is to implement Motion Blur persistence (like the GIF on top of this article).

We can also accumulate a state, for instance, to implement Game of Life!

Game of life glider example

And the whole idea of gl-react (and React) is about composition. For instance, doing a rotating effect of that Game of Life is basically just <Rotate> <GameOfLife /> </Rotate>.

An interesting part is that you can update the GameOfLife at a rate that is independent from the Rotate rendering: just by making GameOfLife a pure component that receives a tick, or implementing shouldComponentUpdate update (you have as many choices as React have to shortcut the rendering).

golrotscu example

See the counters that indicate the number of redraw. (the capture preview in the Box only get snapshot each 100ms, but in the real canvas, it runs at 60 FPS)

Finally, please checkout ibex example (extracted from another JS13K game! xD).

You can't leave this article before seeing ibex example! I'm serious, this is probably the most accomplished code I ever wrote! xD

under the hood of Surface and Node redraw

In order to make redraw efficient, gl-react have 2 phases: the redraw() phase and the flush() phase (reflecting the respective methods available both on Surface and Node). This is a bit like a rendering engine:

  • redraw() phase sets a dirty flag to a Node and all its "dependents" (other nodes, buses, surface). redraws happen generally bottom-up to the Surface.
  • flush() phase draws all nodes that have the redraw flag. draws happens top-down from the Surface.

redraw() is directly hooked to React update lifecycle (re-rendering a Node will calls redraw() for you). To make this system efficient, the flush() is by default asynchronous, i.e. redraw() means scheduling a new gl draw. Surface have a main loop that runs at 60 fps and call flush(). This is very efficient because if Surface does not have the redraw flag, flush() does nothing.

In gl-react inspector, clicking on the redraw count will call redraw() on the node / bus. We can illustrate that only "dependents" get redrawn using the advanced blurimgtitle example:

only "dependents" get redrawn

This redraw/flush phases allow to prevent and skip rendering multiple times a Node. In some cases, we still want to redraw synchronously: with <Node/> sync prop. For instance, in Game of Life, we don't want to skip an update (the initial update set the initial GoL state, if it was async it might get skipped).


Flow types

Flow types has been used for more robust code and better user experience. BTW, WebGLRenderingContext will soon be released in flow.

Atom highlighting

If you are using Atom Editor, you can have JS inlined GLSL syntax highlighted.

To configure this:

  • add language-babel package.
  • Configure language-babel to add GLSL:source.glsl in settings "JavaScript Tagged Template Literal Grammar Extensions".
  • (Bonus) Add this CSS to your Atom > Stylesheet:
/* language-babel blocks */
atom-text-editor::shadow .line .ttl-grammar {
  /* NB: designed for dark theme. can be customized */
  background-color: rgba(0,0,0,0.3);
atom-text-editor::shadow .line .ttl-grammar:first-child:last-child {
  display: block; /* force background to take full width only if ttl-grammar is alone in the line. */

Tests: almost 100% coverage!

The library is tested directly on the command line, thanks to Jest and headless-gl (Big up to mikolalysenko for headless-gl!). gl-react have 2000 line of tests, involving a lot of gl calls, and readPixels, and it runs... in a few seconds! (to Jest devs: you are wizards!)

 PASS  ./all.test.js
  ✓ renders a red shader (75ms)
  ✓ renders HelloGL (15ms)
  ✓ ndarray texture (27ms)
  ✓ renders a color uniform (18ms)
  ✓ composes color uniform with LinearCopy (21ms)
  ✓ no needs to flush if use of sync (24ms)
  ✓ Node can have a different size and be scaled up (18ms)
  ✓ Surface can be resized (32ms)
  ✓ bus uniform code style (17ms)
  ✓ bus example 1 (17ms)
  ✓ bus example 2 (18ms)
  ✓ bus example 3 (17ms)
  ✓ bus example 4 (22ms)
  ✓ bus example 5 (14ms)
  ✓ bus example 6 (24ms)
  ✓ bus: same texture used in multiple sampler2D is fine (14ms)
  ✓ a surface can be captured and resized (16ms)
  ✓ a node can be captured and resized (17ms)
  ✓ Uniform children redraw=>el function (22ms)
  ✓ Bus redraw=>el function (16ms)
  ✓ many Surface updates don't result of many redraws (18ms)
  ✓ many Surface flush() don't result of extra redraws (10ms)
  ✓ GL Components that implement shouldComponentUpdate shortcut Surface redraws (27ms)
  ✓ nested GL Component update will re-draw the Surface (24ms)
  ✓ Node `clear` and discard; (24ms)
  ✓ Node `backbuffering` (32ms)
  ✓ Node `backbuffering` in `sync` (36ms)
  ✓ texture can be null (12ms)
  ✓ array of textures (22ms)
  ✓ Node uniformsOptions texture interpolation (17ms)
  ✓ can be extended with addTextureLoaderClass (70ms)
  ✓ Surface `preload` prevent to draw anything (59ms)
  ✓ Surface `preload` that fails will trigger onLoadError (59ms)
  ✓ renders a shader inline in the Node (15ms)
  ✓ testing connectSize() feature (17ms)
  ✓ handle context lost nicely (43ms)
  ✓ Bus#uniform and Bus#index (25ms)
  ✓ VisitorLogger + bunch of funky extreme tests (140ms)

File                           |  % Stmts | % Branch |  % Funcs |  % Lines |Uncovered Lines |
All files                      |    97.85 |     88.8 |    95.96 |    99.35 |                |
 src                           |    97.82 |    88.95 |     95.9 |    99.35 |                |
  Backbuffer.js                |      100 |      100 |      100 |      100 |                |
  Bus.js                       |    96.15 |    74.29 |      100 |      100 |                |
  GLSL.js                      |      100 |       50 |      100 |      100 |                |
  LinearCopy.js                |      100 |      100 |      100 |      100 |                |
  NearestCopy.js               |      100 |      100 |      100 |      100 |                |
  Node.js                      |    97.65 |    92.71 |    97.01 |    99.01 |    214,216,358 |
  Shaders.js                   |      100 |    76.92 |      100 |      100 |                |
  Texture2DLoader.js           |      100 |      100 |      100 |      100 |                |
  TextureLoader.js             |      100 |      100 |      100 |      100 |                |
  TextureLoaderNDArray.js      |      100 |      100 |      100 |      100 |                |
  TextureLoaders.js            |      100 |      100 |      100 |      100 |                |
  Visitor.js                   |      100 |      100 |       75 |      100 |                |
  VisitorLogger.js             |      100 |    92.59 |      100 |      100 |                |
  Visitors.js                  |      100 |      100 |      100 |      100 |                |
  connectSize.js               |      100 |    85.71 |      100 |      100 |                |
  copyShader.js                |      100 |      100 |      100 |      100 |                |
  createSurface.js             |    97.09 |    83.61 |    94.55 |    99.32 |            361 |
  genId.js                     |      100 |      100 |      100 |      100 |                |
  index.js                     |      100 |      100 |      100 |      100 |                |
 src/helpers                   |      100 |       75 |      100 |      100 |                |
  disposable.js                |      100 |       50 |      100 |      100 |                |
  invariantNoDependentsLoop.js |      100 |      100 |      100 |      100 |                |
Test Suites: 1 passed, 1 total
Tests:       38 passed, 38 total
Snapshots:   4 passed, 4 total
Time:        2.655s
Ran all test suites.

One limitation is that all tests need to be in a single file. I created an issue here. I think it's either an issue in Jest or in headless-gl.

Tradeoffs and remaining work

The library tradeoffs are written in TRADEOFFS.md. We might cover some unexplored direction in a near future and solve some of them.

v3 is still in development, the main unfinished part is the React Native implementation which is now the main priority of the library. It will probably rely on an awesome initiative: a React Native WebGL implementation started in Exponent by @nikki!

For more information, see v3 alpha: development in progress.