
Render shader
First we render a simple shader — a flat green surface.
This requires:
- ▪ a canvas element in the DOM
- ▪ vertex and fragment shader files
- ▪ a program to compile these files
- ▪ a render loop
/index.html
<canvas id="canvas"></canvas>
/index.js
import vertSrc from "./shader.vert?raw";import fragSrc from "./shader.frag?raw";const canvas = document.querySelector("#canvas");const gl = canvas.getContext("webgl2");function compileShader(type, src) {const shader = gl.createShader(type);gl.shaderSource(shader, src);gl.compileShader(shader);if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {throw new Error(gl.getShaderInfoLog(shader));}return shader;}// A program links a vertex shader and fragment shader into one GPU pipeline.const program = gl.createProgram();gl.attachShader(program, compileShader(gl.VERTEX_SHADER, vertSrc));gl.attachShader(program, compileShader(gl.FRAGMENT_SHADER, fragSrc));gl.linkProgram(program);// WebGL2 requires a bound VAO to draw.gl.bindVertexArray(gl.createVertexArray());function render() {gl.clearColor(0, 0, 0, 1);gl.clear(gl.COLOR_BUFFER_BIT);gl.useProgram(program);// Process 3 vertices (positions hardcoded in vertex shader)gl.drawArrays(gl.TRIANGLES, 0, 3);requestAnimationFrame(render);}requestAnimationFrame(render);
Normally, you'd pass a geometry — like a flat plane — from JavaScript to the vertex shader, then manipulate its vertex positions to create a desired shape. However, we just need a flat surface. So, we use a shortcut. Instead of passing in a geometry, I define three points inside the vertex shader. They create a triangle big enough to cover the viewport. The GPU clips the triangle, leaving a viewport sized rectangle.
/shader.vert
#version 300 esvoid main() {// The vertex shader requires coordinates to be in clip space for its output variable `gl_Position`.// Clip space: [-1, -1] (bottom-left) -> [1, 1] (top-right)vec2 pos[3] = vec2[](vec2(-1.0, -1.0), // bottom-leftvec2( 3.0, -1.0), // bottom-rightvec2(-1.0, 3.0) // top);// gl_VertexID is 0, 1, or 2 — coming from gl.drawArrays(TRIANGLES, 0, 3)gl_Position = vec4(pos[gl_VertexID], 0.0, 1.0);}
/shader.frag
#version 300 esprecision highp float;out vec4 fragColor;void main() {// greenfragColor = vec4(0.0, 1.0, 0.0, 1.0);}
smoothstep() + mix()
Two built-in functions we'll use are smoothstep and mix.
They allow us to create a linear gradient.
I wired some variables from the JS file to a GUI, then passed them into the fragment shader (see sending data for more details). In the shader, I first get the UV — the normalized coordinates of the fragment.
The shader provides us with gl_FragCoord, which contains the fragment's coords relative to screen resolution (window-space position).
In this coord system, the bottom-left is [0,0] and the top-right might be [1920,1080] (depending on screen size).
We normalise these to make things easier to work with.
I'm normalizing to [-1, -1] -> [1, 1] because having [0, 0] at the center is ideal for our requirements.
Next, I use smoothstep.
It takes three numbers — a start, an end and a value in between.
It returns a fraction of where that value lies within start to end.
For example, it returns:
- ▪
0if the value is at the start - ▪
1if at the end - ▪ and
0.5if in the middle
I set the return value as t then pass it to mix with two colors. If:
- ▪
t == 0, it returns the first color. - ▪
t == 1, it returns the second color. - ▪ anything in between will be a mix of the two.
/shader.frag
#version 300 esprecision highp float;uniform vec2 u_resolution;uniform float u_edge0;uniform float u_edge1;uniform vec3 u_colorA;uniform vec3 u_colorB;out vec4 fragColor;vec2 getUV(vec4 fragCoord, vec2 resolution) {vec2 uv = (fragCoord.xy / resolution) * 2.0 - 1.0;// prevent warping because viewport isn't squareuv.x *= resolution.x / resolution.y;return uv;}void main() {vec2 uv = getUV(gl_FragCoord, u_resolution);float t = smoothstep(u_edge0, u_edge1, uv.y);vec3 color = mix(u_colorA, u_colorB, t);fragColor = vec4(color, 1.0);}
Sine wave
To create an organic animation, we base the gradient on a sine wave instead of a straight line.
To render the wave:
- ▪ get the UV
- ▪ pass the fragment's x-coord to a sine wave function to get the wave's y-coord
- ▪ call
renderWaveLine— a function that usessmoothstepandmixto return:- ▪ black if this fragment is far from the line,
- ▪ a tiny black to white gradient if close or
- ▪ white if on the line
/shader.frag
...out vec4 fragColor;vec2 getUV(vec4 fragCoord, vec2 resolution) {vec2 uv = (fragCoord.xy / resolution) * 2.0 - 1.0;uv.x *= resolution.x / resolution.y;return uv;}float sinWave(float freq, float amp, float phase, float x) {return amp * sin(freq * x + phase);}vec3 renderWaveLine(float resolutionY, vec3 backgroundColor, vec3 lineColor, float uvY, float waveY) {// antialiasing: make the line thicker than 1 pixel and fade it out with smoothstepfloat aa = 2.0 / resolutionY;float distanceFromWave = abs(uvY - waveY);float waveLine = smoothstep(aa, 0.0, distanceFromWave);return mix(backgroundColor, lineColor, waveLine);}void main() {vec2 uv = getUV(gl_FragCoord, u_resolution);float waveY = sinWave(u_frequency, u_amplitude, u_phase, uv.x);vec3 color = renderWaveLine(u_resolution.y, u_backgroundColor, u_waveColor, uv.y, waveY);fragColor = vec4(color, 1.0);}
Organic wave
A sine wave is more organic than a straight line — but it's uniform — which isn't organic. We can fix this by combining sine waves with different properties:
/shader.frag
...float wave =sinWave(u_frequency[0], u_amplitude[0], u_phase[0], uv.x) +sinWave(u_frequency[1], u_amplitude[1], u_phase[1], uv.x) +sinWave(u_frequency[2], u_amplitude[2], u_phase[2], uv.x);...
Organic gradient
Now we create a wave-based gradient by rendering one colour on the wave and transitioning to another as we move away from it.
This is done by:
- ▪ calculating each fragment's distance to the wave
- ▪ mapping that value to a number between
0and1(to get thetvalue) - ▪ passing
ttomixwith two colors
/shader.frag
...void main() {vec2 uv = getUV(gl_FragCoord, u_resolution);float wave = sinWave(u_frequency[0], u_amplitude[0], u_phase[0], uv.x)+ sinWave(u_frequency[1], u_amplitude[1], u_phase[1], uv.x)+ sinWave(u_frequency[2], u_amplitude[2], u_phase[2], uv.x);// signed distance from the wave: positive above, negative belowfloat dist = uv.y - wave;// map dist from [-1,1] to [0,1]float t = clamp(dist * 0.5 + 0.5, 0.0, 1.0);vec3 color = mix(u_gradColor[0], u_gradColor[1], t);fragColor = vec4(color, 1.0);}
Color banding
The above approach results in color banding — visible lines where the color changes abruptly.
This is most noticeable at gradient endpoints (when we render 100% of one color).
We can fix this by applying an easing function to t before passing it to mix.
I'm using cosine easing, which eases in and out at both ends of the gradient:
/shader.frag
...t = 0.5 - 0.5 * cos(t * 3.14159265);vec3 color = mix(u_gradColor[0], u_gradColor[1], t);...
Animation
The last step is to add animation.
This requires adding time to each wave's phase value:
/index.js
...const clock = new THREE.Clock();function render() {const elapsed = clock.getElapsedTime(); // seconds since startgl.uniform1f(u.time, elapsed);...}...
/shader.frag
...float wave =sinWave(u_frequency[0], u_amplitude[0], u_wavePhase[0] + u_time * u_waveSpeed[0], uv.x) +sinWave(u_frequency[1], u_amplitude[1], u_wavePhase[1] + u_time * u_waveSpeed[1], uv.x) +sinWave(u_frequency[2], u_amplitude[2], u_wavePhase[2] + u_time * u_waveSpeed[2], uv.x);...
Feedback
Have any feedback about this note or just want to comment on the state of the economy?




