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 ofgl-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":
- a Bus with a ref:
<Bus ref="myBus">{content to inject}</Bus>
. - pass a function that resolves the ref to pipe Bus into another Node. e.g:
()=>this.refs.myBus
.
a single ConicalGradient should be used for all blur pass:
There are a few other good examples of ref usages:
- blurmapmouse
- blurimgtitle (same example that was features in 2016 React conf!)
- behindasteroids, crazy port of a game I made for js13k.
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} />
:
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!
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).
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:
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).
Bonus
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 addGLSL: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.