Notes:
- This article only covers WebGL1.0
- This article only covers fragment shaders
- The main reference for this article: The Book of Shaders
- Some images are sourced from open.gl
- All examples below can be pasted into this Playground to run directly
Essence#
As can be seen from the pipeline diagram above, the essence of the fragment shader is to process each (pixel) point. It is well known that GPUs excel at parallel simple computations, which is the goal of this task. In contrast, CPUs are good at complex computations but have very few processors.
The Book of Shaders provides a vivid analogy:
Uniform#
The reason uniform
is called uniform is that it is consistent. It is passed from the CPU to the GPU, and this value is consistent when processing each pixel. In contrast, varying
values are computed by the vertex shader and passed to the fragment shader, where the received values are inconsistent for each triangle.
Input and Output#
uniform vec2 u_resolution;
void main() {
// `gl_FragCoord.xy/u_resolution` maps xy values to 0 to 1
vec2 st = gl_FragCoord.xy/u_resolution;
gl_FragColor = vec4(st.x,st.y,0.0,1.0);
}
gl_FragCoord
is a built-in input that represents the position of a point; gl_FragColor
is a fixed output value that indicates what color this point should display.
Mapping#
The key input is position, and the key output is color. Therefore, it is clear that the focus of using fragment shaders for drawing is to find the relationship between position and color. In other words, what the GPU needs to do is calculate the color of each point. Another way to put it is that you need to use your cleverness to generate a beautiful image based on the relationship between x and y positions.
So why is the mapping from 0 to 1 so important? Because the RGB output of WebGL is in the range of 0 to 1, which is the most direct way to establish the relationship between position and color. Additionally, important built-in functions like smoothstep
and fract
return values in the range of 0 to 1.
In the example above, gl_FragCoord
is first mapped to 0
to 1
, and then the x and y values are placed into r and g (with the bottom left being 0,0
), resulting in a black color in the bottom left corner, red in the top left corner, and green in the bottom right corner.
P.S. The output method in WebGL2 is different
Common Functions#
To draw various fancy shapes, we must understand these common functions:
Angle and Trigonometric Functions#
- radians() Convert degrees to radians
- degrees() Convert radians to degrees
- sin()
- cos()
- tan()
- asin()
- acos()
- atan()
Exponential Functions#
Common Mathematical Methods#
- abs() Absolute value
- sign(x) Maps x to 0, 1, -1
- floor() Round down
- ceil() Round up
- fract() Only takes the decimal part
- mod(x, y) Modulus of x
- min(x, y) Returns the smaller of two values
- max(x, y) Returns the larger of two values
- clamp(x, minVal, maxVal) Changes only within the range of max and min values, outputting boundary values when out of range
- mix(x, y, a) Gradually transitions from x to y, where a is the "progress" of the transition
- step(edge, x) A type of abrupt change, mapping x to 0 to 1, returning 0 if less than edge, otherwise returning 1
- smoothstep(edge0, edge1, x) Smoothly maps x to 0 to 1 within the interval of edge0 and edge1 (using Hermite interpolation). Note that changing the order of edge0 and edge1 will yield different effects.
GEOMETRIC FUNCTIONS#
- length() Length of a vector
- distance() Distance between two points, which is essentially similar to length but interpreted differently
- dot() Dot product of vectors
- cross() Cross product of vectors
- normalize() Returns a unit vector
- facefoward() Returns a vector pointing in the same direction as the reference vector
- reflect() Reflection calculation
- refract() Refraction calculation
Can We Start Drawing?#
Yes! After understanding the above common functions, you can already create stunning images! For example, the following short code can create a lava lamp effect (see the effect here):
// Author @patriciogv - 2015
// http://patriciogonzalezvivo.com
#ifdef GL_ES
precision mediump float;
#endif
uniform vec2 u_resolution;
uniform vec2 u_mouse;
uniform float u_time;
vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec2 mod289(vec2 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec3 permute(vec3 x) { return mod289(((x*34.0)+1.0)*x); }
float snoise(vec2 v) {
const vec4 C = vec4(0.211324865405187, // (3.0-sqrt(3.0))/6.0
0.366025403784439, // 0.5*(sqrt(3.0)-1.0)
-0.577350269189626, // -1.0 + 2.0 * C.x
0.024390243902439); // 1.0 / 41.0
vec2 i = floor(v + dot(v, C.yy) );
vec2 x0 = v - i + dot(i, C.xx);
vec2 i1;
i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
vec4 x12 = x0.xyxy + C.xxzz;
x12.xy -= i1;
i = mod289(i); // Avoid truncation effects in permutation
vec3 p = permute( permute( i.y + vec3(0.0, i1.y, 1.0 ))
+ i.x + vec3(0.0, i1.x, 1.0 ));
vec3 m = max(0.5 - vec3(dot(x0,x0), dot(x12.xy,x12.xy), dot(x12.zw,x12.zw)), 0.0);
m = m*m ;
m = m*m ;
vec3 x = 2.0 * fract(p * C.www) - 1.0;
vec3 h = abs(x) - 0.5;
vec3 ox = floor(x + 0.5);
vec3 a0 = x - ox;
m *= 1.79284291400159 - 0.85373472095314 * ( a0*a0 + h*h );
vec3 g;
g.x = a0.x * x0.x + h.x * x0.y;
g.yz = a0.yz * x12.xz + h.yz * x12.yw;
return 130.0 * dot(m, g);
}
void main() {
vec2 st = gl_FragCoord.xy/u_resolution.xy;
st.x *= u_resolution.x/u_resolution.y;
vec3 color = vec3(0.0);
vec2 pos = vec2(st*3.);
float DF = 0.0;
// Add a random position
float a = 0.0;
vec2 vel = vec2(u_time*.1);
DF += snoise(pos+vel)*.25+.25;
// Add a random position
a = snoise(pos*vec2(cos(u_time*0.15),sin(u_time*0.1))*0.1)*3.1415;
vel = vec2(cos(a),sin(a));
DF += snoise(pos+vel)*.25+.25;
color = vec3( smoothstep(.7,.75,fract(DF)) );
gl_FragColor = vec4(1.0-color,1.0);
}
Just kidding, there's still a long way to go before you can independently conceive such images 😂
Different Ways from 0 to 1#
In practical use, there cannot be only linear relationships, so using various functions to adjust the mapping of xy and 0 to 1 is an essential step in learning to write fragments.
In The Book of Shaders, the author uses the plot
function to visualize the mapping relationship while assigning the mapped x value to gl_FragColor
, making it relatively intuitive to see the actual effects of different curves:
// Author: Inigo Quiles
// Title: Pcurve
#ifdef GL_ES
precision mediump float;
#endif
uniform vec2 u_resolution;
uniform vec2 u_mouse;
uniform float u_time;
// Function from Iñigo Quiles
// www.iquilezles.org/www/articles/functions/functions.htm
float pcurve( float x, float a, float b ){
float k = pow(a+b,a+b) / (pow(a,a)*pow(b,b));
return k * pow( x, a ) * pow( 1.0-x, b );
}
float plot(vec2 st, float pct){
return smoothstep( pct-0.02, pct, st.y) -
smoothstep( pct, pct+0.02, st.y);
}
void main() {
vec2 st = gl_FragCoord.xy/u_resolution;
float y = pcurve(st.x,3.0,1.0);
vec3 color = vec3(y);
float pct = plot(st,y);
color = (1.0-pct)*color+pct*vec3(0.0,1.0,0.0);
gl_FragColor = vec4(color,1.0);
}
The rendering result can be directly viewed here
Squares and Circles#
float rect (vec2 st,float width){
// bottom-left
vec2 bl = step(vec2(0.5-width/2.),st);
float pct = bl.x * bl.y;
// top-right
vec2 tr = step(vec2(0.5-width/2.),1.0-st);
pct *= tr.x * tr.y;
return pct;
}
float circle(in vec2 _st, in float _radius){
vec2 dist = _st-vec2(0.5);
return 1.-smoothstep(_radius-(_radius*0.01),
_radius+(_radius*0.01),
dot(dist,dist)*4.0);
}
void main(){
vec2 st = gl_FragCoord.xy/u_resolution.xy;
vec3 rectcolor = vec3(rect(st,.5));
vec3 circlecolor = vec3(1) - vec3(circle(st,.5));
gl_FragColor = vec4(circlecolor + rectcolor,1.0);
}
Squares and circles are the easiest shapes to understand. There are a few points to note in the code above:
- The code for the square is a bit long, but actually, just pay attention to using multiplication to create intersections.
- The example uses the dot product to draw a circle because the dot product is the projection relationship of two vectors, so projecting onto itself is a way to calculate length.
- Of course, you can also use
length
,distance
, or even use the distance formula between two points to draw a circle. - Drawing a circle (or curve) can also be done using
step
, but usingsmoothstep
will give an anti-aliasing effect. - The programmer's good friend abstraction will always help you achieve more efficient and concise code; you can easily adjust a function to draw complex shapes (even if you don't fully understand the drawing principles yet).
Little Planets#
Some mobile photo editing applications have a filter called "Little World," which actually maps images from Cartesian coordinates to polar coordinates. If we want to use fragment shaders to draw shapes like snowflakes or gears, we can map the corresponding "waveforms" to polar coordinates in the same way, as shown in the code below:
void main(){
vec2 st = gl_FragCoord.xy/u_resolution.xy;
vec3 color = vec3(0.0);
vec2 pos = vec2(0.5)-st;
float r = length(pos)*2.0;
float a = atan(pos.y,pos.x);
float f = cos(a*3.); // 3 petals
// f = abs(cos(a*3.)); // 6 petals
// f = abs(cos(a*2.5))*.5+.3; // 5 petals
// f = abs(cos(a*12.)*sin(a*3.))*.8+.1; // snowflake
// f = smoothstep(-.5,1., cos(a*10.))*0.2+0.5; // gear
color = vec3( 1.-smoothstep(f,f+0.02,r) );
gl_FragColor = vec4(color, 1.0);
}
a
is calculated using arctangent to find the angle of the line connecting the point to the center, which corresponds to the original X-axis, returning values from -PI to PI.r
can be understood as the original Y-axis.f
is the value of the Y-axis, and color is determined by usingsmoothstep
to segmentf
, displaying black for values less thanf
and white for values greater thanf
.- So a more visually appealing way to write it is
float y = cos(x*3.);
- By mapping different functions to polar coordinates, various interesting shapes can be produced.
Pattern#
Here, Pattern does not refer to design patterns but to repeated designs. The previous discussion was about single patterns, so how do we generate a large number of neatly arranged patterns?
The answer is fract()
.
void main() {
vec2 st = gl_FragCoord.xy/u_resolution;
vec3 color = vec3(0.0);
st *= 3.0; // Scale up the space by 3
st = fract(st); // Wrap around 1.0
// Now we have 9 spaces that go from 0-1
color = vec3(st,0.0);
// color = vec3(circle(st,0.5));
gl_FragColor = vec4(color,1.0);
}
The essence of humanity is to repeat, and fract()
can be considered GLSL's repeater 😅. By using fract()
, we can make values continuously cycle between 0 and 1.
- First, map the xy values to 0 to 1.
- Then map them to 0 to 3.
- After processing with
fract()
, it will repeat 3 times from 0 to 1, and the effect will appear.
Pseudo-Randomness#
For generating images, pseudo-randomness is sufficient; as for true randomness, involving hardware noise and other unpredictable inputs, we won't discuss that here.
y = fract(sin(x)*1.0);
is one of the foundations of pseudo-randomness. Although it appears very regular from a continuous image perspective, if we take integers for x and output y, a normal human might not realize that this is just a regular sin
. Let's use JavaScript to output integers as parameters to see the "random values":
function fract(num) {
return Math.abs(num - Math.trunc(num))
}
function random(x) {
return fract(Math.sin(x) * 1)
}
const nums = []
for (let i = 1; i < 20; i++) {
nums.push(random(i))
}
// output=> [0.8414709848078965, 0.9092974268256817, 0.1411200080598672, 0.7568024953079282, 0.9589242746631385, 0.27941549819892586, 0.6569865987187891, 0.9893582466233818, 0.4121184852417566, 0.5440211108893698, 0.9999902065507035, 0.5365729180004349, 0.4201670368266409, 0.9906073556948704, 0.6502878401571168, 0.2879033166650653, 0.9613974918795568, 0.7509872467716762, 0.14987720966295234]
On this basis, the larger the multiplier of sin(x)
, the results appear to be more random:
Now we know that we can use y = fract(sin(x)*10000.0);
to map x to pseudo-random values between 0 and 1, so the next step is straightforward: map pixel positions to random numbers and set the random numbers as output colors to see random images.
float random (vec2 st) {
return fract(sin(dot(st.xy,
vec2(12.9898,78.233)))*
10000.0);
}
void main() {
vec2 st = gl_FragCoord.xy/u_resolution.xy;
// st = st * 15.;
// st = floor(st);
float rnd = random( st );
gl_FragColor = vec4(vec3(rnd),1.0);
}
The above code can create an effect similar to a snowflake screen. It is important to note that the "x" in the random function is obtained by taking the dot product (projection length) of the xy values with a vector. If you want a mosaic effect, it's also very simple; just use the previous repeating method to map it (removing the two comments will show the effect)!
Other Functions#
Matrix Related#
Vector Related#
Material Related#
Tools#
Original link: https://ssshooter.com/2022-10-12-fragment-shader/