The swing looks sad because everyone went to play in the Shader Park :(
Midnight doubled its weekly posh ramen revenue! I also had a chance to chat with the creators of my favourite SDF plaything who gave me a bunch of tips and code samples to learn from.
I thought I'd celebrate that with a gentle yak shave before I wrap up my experiments with Shader Park for now.
Psst... You might want to read my previous notes on Shader Park before you continue: Shader Park is Kinda Neat and Midnight Shader.
Result (live version):
Implementation
let noiseScale = 2;
let timeScale = 0.15;
let s = enable2D(); // 🐐!
let n = noise(noiseScale * s + time * timeScale);
let n2 = noise(noiseScale * s + time * timeScale * 0.1 + n * 0.8);
let c = pow(1 - n2, 12);
color(vec3(c, c, c));
And here's the generated noise (interactive example here):
Using 2D mode
ShaderPark uses a 3D mode by default. We're only interested in projecting a flat noise texture onto the screen so we can save ourselves from the unnecessary Ray marching computation by enabling the 2D mode (kudos to Torin for mentioning this!).
let s = enable2D(); // 🐐!
Enabling 2D mode also requires tweaking some Three.js settings, mainly passing the canvas size to the shader.
uniforms.resolution = {
name: 'resolution',
value: new Vector2(canvasEl.clientWidth, canvasEl.clientHeight),
type: 'vec2'
};
Jargon: uniforms
are the parameters passed from the JS world into shaders. An example uniform value would be time
which can be used for animation:
let render = () => {
requestAnimationFrame(render);
material.uniforms.time.value += getDeltaTime();
renderer.render(scene, camera);
};
render();
Performance tweaks
Three.js is ca. 70-100kb in increased bundle size. That's noticeable on lower-end devices with poor internet connection. But, I still want my silk-smooth sheets animation to play on your fancy iPhone. We can solve this dilemma with progressive enhancement and dynamic imports. Here's an example in SvelteKit:
<script>
import { onMount } from 'svelte';
let canvasEl;
let isWebGLReady = false;
// called client-side as soon as the component is mounted in DOM
onMount(async () => {
const { /* ... */ WebGLRenderer } = await import('three');
const { default: generatedShader } = await import('./overlay.sp');
// ... (yes, this could be Promise.all, have a cookie 🥠)
render();
isWebGLReady = true;
});
</script>
<canvas class:is-ready={isWebGLReady} bind:this={canvasEl}></canvas>
<style>
canvas {
/* ... */
opacity: 0;
}
canvas.is-ready {
animation: fadeIn 3s 0s ease-in-out both;
}
</style>
Important bits:
Display the animation only when the modules have been imported and the Three.js scene set up:
<canvas class:is-ready={isWebGLReady}>
And:
canvas {
/* ... */
opacity: 0; /* 👈 */
}
Note that we're not using display: none
to hide the <canvas>
element as this would break the animation. That's because we need to pass the canvas dimensions to the shader as the resolution
uniform, but .clientWidth
and .clientHeight
of elements with display: none
is 0!
Alternative — drop Three.js completely:
I'm already transpiling SP code into GLSL during build time and I don't rely on many Three.js features so we could probably replace it with a single script. I'll leave that as an excuse to write another note (the yak is bald already).
Summary
With this approach the page will still load fast on slower internet connection and gently fade in the animation when it's ready.
Loading Three.js via dynamic imports increased its bundle size to 170 kb, but from the user perspective the change is not noticeable.
This is just a reminder that:
- you shouldn't obsess with metrics, and always look at them in context
- practices such as A11y or Progressive Enhancement benefit everyone.
That's all for today. See you tomorrow!