In a nutshell, I create pretty websites for a living. It's always been a delicate balance between nailing the mark with a beautiful design, and dealing with under-performing devices and browsers (I'm looking at you, Safari). I've spent countless hours wrestling with animations, and trying to decide if CSS or JS is the right way to go. While modern CSS is capable of doing a lot of heavy lifting, the GPU is where we can unlock massive performance gains. The hierarchy (at least in my head-canon) is HTML5 canvas 😊 → WebGL 😃 → WebGPU 🥳.
The downside here is that writing code in GLSL (WebGL) or WGSL (WebGPU) is not for the faint of heart. Trying to parse the syntax alone is far beyond my pay-grade — and needing to write two versions of every shader for WebGL and WebGPU devices is a bit of a nightmare. As much as I'd love to say that my work only supports WebGPU, the reality is that we need to gracefully fallback to WebGL.
Here's a bit of psuedo-code in both shader languages:
// GLSL (WebGL)
void main() {
vec2 uv = gl_FragCoord.xy / resolution.xy;
vec3 color = mix(colorA, colorB, uv.x);
gl_FragColor = vec4(color, 1.0);
}
// WGSL (WebGPU)
@fragment
fn main(@location(0) uv : vec2f) -> @location(0) vec4f {
let color = mix(colorA, colorB, uv.x);
return vec4f(color, 1.0);
}
Another challenge besides needing to maintain two similar but different fragment shaders is that I'm often finding myself needing to write these shaders in string literals, so I have no type safety or any other DX magic without using an IDE plugin of some sort.
And then I stumbled my way into TSL (Three Shader Language). While it's still very early on in development, here was a paradigm that made shader programming feel more like writing regular JavaScript code. Not only that, but it's completely renderer-agnostic, so if I write TSL code one time, I can easily compile it out to WebGL and WebGPU from the same codebase.
“TSL is also capable of encoding code into different outputs ... in addition to optimizing the shader graph automatically ... This allows the developer to focus on productivity and leave the graphical management part to [TSL].”
TSL eliminates the need to maintain two versions of every shader by providing a JavaScript-like syntax that compiles to both. Let's see what that looks like:
const fragmentNode = (uniforms) => {
const { mix, uv, vec4 } = TSL
return vec4(
mix(
uniforms.colorA.uniform,
uniforms.colorB.uniform,
uv().x
),
1.0
)
};
How much simpler is that, right? We import what we need from TSL, and then write shader code in plain JS (no more string literals). In fact, most of the TSL functions I've come across are already typed for those who enjoy the type-safety of TypeScript. Typed shader generation is a beautiful thing.
On top of that, we can now also start to organize our shader code in a modular way, using native imports to share logic between multiple shaders. This is how Ombré gets away with reusable effects like "noise/grain" and "distortion" on multiple shaders without repeating too much code.
export const grain = (intensity: number = 0.03) =>
(st: Node) => fract(sin(dot(st, vec2(12, 70))).mul(intensity))
// shader.ts
...
import { grain } from './helpers/grain'
shader.add(grain(_GRAIN_STRENGTH)(uv()))
...
It might not seem like much, but I can now re-use the grain effect in multiple shaders without worrying about needing to update it in multiple locations if I ever want to go back and make changes.
And if you can modularize it, then why can't we also package it up into a component structure for frontend frameworks like Vue, React, and Svelte. Which leads me to an idea...
om·bre: french for "shadow" / "shade"
Let's get to the point! Ombré is a component library for Vue, React, and Svelte that provides a whole catalog of ready-to-use shader components — utilizing TSL under the hood so that they render with WebGPU and fallback gracefully to WebGL if needed.
Instead of writing any crazy shader code, you just import a component and watch as it draws an optimized, beautifully animated effect, perfect for a hero section or page background. You don't have to worry about performance or compatibility, and you can customize the effect by passing in desired props.
<OmbreSpiral1 color-a="limegreen" color-b="yellow" :speed="2" />
Notice that you can pass any color values including hex, rgb, hsl, and even html color codes. The props you pass in are completely reactive, so if you change these, the shader will instantly update. Animating props here can lead to really interesting effects.
Let's face it, I'm a solo developer and this side-project would be challenging enough as it is. Add a newborn baby into the mix (who joined us in January) and you can kiss my free time goodbye. Is that an excuse not to make this awesome? Absolutely not. I'm ambitious, and didn't want to settle for introducing this in a half-baked state. I wanted to put it out there for Vue, React and Svelte all at once. And a handful of shaders wasn't going to cut it. I wanted to produce a ton of customizable shaders, organized into collections, and setup a workflow that allowed me to continue to produce additional collections in the weeks/months to come without feeling like it's a second job.
That's where Turbo comes into the picture. The good thing about using TSL is that Ombré's core code is inherently framework-agnostic - so I could use a layer of automation to build the component libraries for each of the three frameworks supported at launch.
Turbo allowed me to design a workflow where I could work in /core
and as I made changes, they would propagate down to @ombre-ui/vue
, @ombre-ui/react
and @ombre-ui/svelte
where output components would be generated from templates. All that these templates had to do was wire things together - as the /core
package is where all of the shader compilation, render loops, and feature detection was handled.
I'll admit, this did take me a few weeks to lock in. But as Abraham Lincoln once said, "Give me six hours to chop down a tree and I will spend the first four sharpening the axe.". Once my axe was sharp, I knew I could create shaders much more efficiently. I now have a clear path forward, and the tools to make it happen.
So after getting some initial collections in place, it's time to soft-launch this thing and get it out there for you to try. There's nothing better than building in public.
In terms of roadmap, I plan on adding new shader collections on a regular basis moving forward, and would love to have an extensive library of a few hundred shaders. At this point it only takes a couple of hours of dedicated time to develop an entire collection for all three frameworks - making this a manageable challenge over the course of the next few months.
While it's still early, a lifetime license gives you access to all current and future shader collections. You can use Ombré in unlimited personal & commercial projects, and you'd be supporting the continued development of this growing library.
I'm also open to anyone who would like to officially sponsor the project - I'll never turn down free money 😉 That's all for now. Go try it out, and let me know what you think!