FDNavigate back to the homepage

Abstract Blob Shader with Particles

Rick
August 8th, 2023 · 4 min read

There are two things going on in this codeSandbox:

  1. Inner sphere blob which has noise applied to it
  2. A points mesh with the outer smokey particles

Inner sphere blob which has noise applied to it

So this article gave me an idea to apply turbulence to the surface of a sphere and this is what gives it the super nova type look - that and noise + 2 mixed colors.

1const SphereShell = () => {
2 const matRef = useRef();
3 const sphereShellRef = useRef();
4
5 const customShader = {
6 uniforms: {
7 time: {
8 value: 0
9 }
10 }
11 };
12
13 const OBC = useCallback(
14 (shader) => {
15 shader.uniforms = {
16 ...shader.uniforms,
17 ...customShader.uniforms
18 };
19
20 shader.vertexShader = shader.vertexShader.replace(
21 "#include <clipping_planes_pars_vertex>",
22 /* glsl */ `
23
24 #include <clipping_planes_pars_vertex>
25 uniform float time;
26
27 varying vec2 vUv;
28 varying vec3 vPosition;
29 varying float vNoise;
30
31
32 ${cnoise}
33
34 float turbulence( vec3 p ) {
35
36 float w = 100.0;
37 float t = -.5;
38
39 for (float f = 1.0 ; f <= 10.0 ; f++ ){
40 float power = pow( 2.0, f );
41 t += abs( cnoise( vec4( power * p, 1.0 ) ) / power );
42 }
43
44 return t;
45
46 }
47
48
49 `
50 );
51 shader.vertexShader = shader.vertexShader.replace(
52 `#include <begin_vertex>`,
53 `
54 #include <begin_vertex>
55
56
57 vUv = uv;
58
59 // get a turbulent 3d noise using the normal, normal to high freq
60 float noise = 10.0 * -.10 * turbulence( .5 * normal + time * 0.25);
61 // get a 3d noise using the position, low frequency
62 float b = .30 * cnoise( vec4(0.05 * position, 1.0) );
63 // compose both noises
64 float displacement = - 1. * noise + b;
65
66 // move the position along the normal and transform it
67 vec3 newPosition = position + normal * displacement;
68 transformed = newPosition;
69 vPosition = newPosition;
70 vNoise = noise;
71 `
72 );
73
74 shader.fragmentShader = shader.fragmentShader.replace(
75 "#include <clipping_planes_pars_fragment>",
76 `
77 #include <clipping_planes_pars_fragment>
78
79 uniform float time;
80 varying float vNoise;
81 varying vec3 vPosition;
82
83 ${cnoise}
84 `
85 );
86 shader.fragmentShader = shader.fragmentShader.replace(
87 `#include <alphamap_fragment>`,
88 `
89 #include <alphamap_fragment>
90
91 float scale = 10.0;
92 float radius = mod(time * 4.0, 20.0);
93
94 float contrast = 20.2;
95 vec3 blue = vec3(0.1,0.3,2.0) * vNoise * 0.1;
96 vec3 lesserBlue = vec3(0.05, 0.15,1.0)* vNoise * 0.03;
97
98 float noise = vNoise + 0.3;
99
100 diffuseColor = vec4(
101 mix(lesserBlue, blue , noise) * contrast + 0.2,
102 mix(
103 0.0,
104 1.0,
105 1.0 - (radius / 20.0)
106 )
107 ) ;
108 `
109 );
110
111 matRef.current.userData.shader = shader;
112 },
113 [customShader.uniforms]
114 );
115
116 useFrame(({ clock, gl }) => {
117 if (matRef.current) {
118 const shader = matRef.current.userData.shader;
119
120 if (shader) {
121 shader.uniforms.time = {
122 value: clock.elapsedTime
123 };
124 }
125 }
126
127 if (sphereShellRef.current) {
128 const scale = (clock.elapsedTime * 4.0) % 20.0;
129 sphereShellRef.current.scale.set(scale, scale, scale);
130 }
131 });
132
133 return (
134 <group>
135 <ambientLight />
136 <mesh ref={sphereShellRef}>
137 <sphereGeometry args={[1.0, 100, 100]} />
138 <meshStandardMaterial
139 ref={matRef}
140 onBeforeCompile={OBC}
141 transparent
142 side={THREE.DoubleSide}
143 />
144 </mesh>
145 </group>
146 );
147};

This is an example of the official way to adapt builtin materials. Why is this a good way to do it?

Well… unless your a physicist or a computer scientist which can understand complicated equations and convert them into code (which is a hard and rare skill to have), then this is definitely the way to go.

It injects code before the shader gets compiled and therefore allows you to inject snippets into positions of the built in shader files or chunks.

The .replace builtin javascript string method, allows us to select a shader chunk name or piece of code we know is in the built in shader. Then replace it with itself and the new code.

Unfortunately this does require some knowledge of what shader terms mean and quite a bit of playing around. For example instead of setting gl_FragColor it could be diffuseColor or transformed in the vertex shader instead of setting gl_Position.

Because we don’t want to set the color or vertex position in the wrong place as this defeats the point of merging our code in seamlessly and taking advantage of all the light calculations. Its all very well creating a abstract shader but if you want nice colors and lighting you will need to implement it yourself or use this onBeforeCompile.

Update uniforms using onBeforeCompile

This took me a while to get right!

In the onBeforeCompile we use the materials userData object and we store a reference to the shader in the onBeforeCompile callback.

We obtain the shader parameter from the first param of onBeforeCompile callback and then set it as a property on the userData object on the material. Important to note… this now gives us a reference to the shader which we can now access the uniforms of the shader and update as you would in the normal way in the useFrame R3F hook.

Sounds more complicated than it actually is and is one of the official ways to access and update uniforms using onBeforeCompile.

1// define the mesh with OBC
2<mesh ref={sphereShellRef}>
3 <sphereGeometry args={[1.0, 100, 100]} />
4 <meshStandardMaterial
5 ref={matRef}
6 onBeforeCompile={OBC}
7 transparent
8 side={THREE.DoubleSide}
9 />
10</mesh>
11
12// set the shader object in OBC
13// callback to material useData object
14
15const OBC = useCallback(
16 (shader) => {
17 shader.uniforms = {
18 ...shader.uniforms,
19 ...customShader.uniforms
20 };
21
22
23 // shader modifications.....
24
25
26 // Set the shader reference
27 matRef.current.userData.shader = shader;
28 },
29 [customShader.uniforms]
30 );

Abstract blob Vertex Shader

This is where we apply turbulence and pass some varying’s to the fragment shader to create a nice colorful effect.

1shader.vertexShader = shader.vertexShader.replace(
2 "#include <clipping_planes_pars_vertex>",
3 /* glsl */ `
4
5 #include <clipping_planes_pars_vertex>
6 uniform float time;
7
8 varying vec2 vUv;
9 varying vec3 vPosition;
10 varying float vNoise;
11
12
13 ${cnoise}
14
15 float turbulence( vec3 p ) {
16
17 float w = 100.0;
18 float t = -.5;
19
20 for (float f = 1.0 ; f <= 10.0 ; f++ ){
21 float power = pow( 2.0, f );
22 t += abs( cnoise( vec4( power * p, 1.0 ) ) / power );
23 }
24
25 return t;
26
27 }
28
29
30 `
31);
32shader.vertexShader = shader.vertexShader.replace(
33 `#include <begin_vertex>`,
34 `
35 #include <begin_vertex>
36
37
38 vUv = uv;
39
40 // get a turbulent 3d noise using the normal, normal to high freq
41 float noise = 10.0 * -.10 * turbulence( .5 * normal + time * 0.25);
42 // get a 3d noise using the position, low frequency
43 float b = .30 * cnoise( vec4(0.05 * position, 1.0) );
44 // compose both noises
45 float displacement = - 1. * noise + b;
46
47 // move the position along the normal and transform it
48 vec3 newPosition = position + normal * displacement;
49 transformed = newPosition;
50 vPosition = newPosition;
51 vNoise = noise;
52 `
53);

So if we have a singular vertex or fragment shader, why do we have to do the replace twice?

The first replace is for any functions, uniforms, varyings, defines or pragmas with glslify. And the second one is for any code going into the body of the main function in the shaders.

#include <clipping_planes_pars_vertex> - is at the end of all the uniforms and is perfect place for putting all the top elements in.

#include <begin_vertex> - is at a point in the main function where we can access the transformed vec3 which is before any matrix multiplications (as far as I know or can tell)

So we are using the turbulence function and apply noise over time we want a quite large noise scale as we don’t want it too wavy like clouds. Then set this to the transformed vec3, which will get used in all the other shader chunks below it - #include <begin_vertex>.

Abstract blob Fragment Shader

The fragment shader is where we add all the color and modify the opacity with noise.

1shader.fragmentShader = shader.fragmentShader.replace(
2"#include <clipping_planes_pars_fragment>",
3`
4 #include <clipping_planes_pars_fragment>
5
6 uniform float time;
7 varying float vNoise;
8 varying vec3 vPosition;
9
10 ${cnoise}
11`
12);
13shader.fragmentShader = shader.fragmentShader.replace(
14`#include <alphamap_fragment>`,
15`
16 #include <alphamap_fragment>
17
18 float radius = mod(time * 4.0, 20.0);
19
20 float contrast = 20.2;
21 vec3 blue = vec3(0.1,0.3,2.0) * vNoise * 0.1;
22 vec3 lesserBlue = vec3(0.05, 0.15,1.0)* vNoise * 0.03;
23
24 float noise = vNoise + 0.3;
25
26 diffuseColor = vec4(
27 mix(lesserBlue, blue , noise) * contrast + 0.2,
28 mix(
29 0.0,
30 1.0,
31 1.0 - (radius / 20.0)
32 )
33 );
34`
35);

The top replace is as it was for the vertex shader, just the fragment shader with a different string to replace #include <clipping_planes_pars_fragment>.

The bottom replace is the interesting part!

So we have an expanding noisy sphere by having an oscillating radius using the builtin mod() glsl math function, noting the 20.0 float.

We want to increase the contrast i.e. make the bright parts brighter and the dark parts darker. Basically increase the difference of the spectrum of colors.

Have a play and change it.

We have two blue colors and use noise as a multiplier which gives us the nice set of colors to mix. Finally mixing these two colors with the builtin mix() glsl function with noise again - meaning lower values of noise will associate with one color and higher values of noise with the other color.

In these circumstances noise is good.

We increase the brightness of the mixed color by adding a 0.2 float to it. This addition will make things lighter all over. Remember 1.0 is white and 0.0 is black.

Below will make it so that the alpha channel gets smaller as you get further away from the origin.

1mix(
2 0.0,
3 1.0,
4 1.0 - (radius / 20.0)
5)

The first param of mix() will be more dominent if the third param is closer to 0 i.e. the radius gets bigger ( noting this mix() function’s third param is in the range of 0-1). We divide the radius by 20.0, as above we mod’ed the time by 20.0. All this means the greater the radius the lower the alpha channel will be.

And because we set the transparent prop on the material we can have transparent parts of the material. Important to also note that not setting this will make changing the alpha channel in the fragment shader useless.

A points mesh with the outer smokey particles

Im not going to spend a lot of time on this as I have covered it in another tutorial. This tutorial did soft particles - granted unlit particles like in this codesandbox, as a builtin points material doesn’t have light affecting it without calculating it manually (I read the light calculations are excluded as it has to do with normal calculations, or calculating normals for points rather than meshes is troublesome).

Particle Vertex Shader

1shader.vertexShader = shader.vertexShader.replace(
2 "#include <clipping_planes_pars_vertex>",
3 /* glsl */ `
4
5 #include <clipping_planes_pars_vertex>
6 uniform float time;
7 uniform vec3 mousePos;
8
9 varying vec2 vUv;
10 varying float noise;
11 varying vec3 noisyPos;
12 varying vec3 vNormal;
13 varying float vDepth;
14
15
16
17 ${cnoise}
18
19 float turbulence( vec3 p ) {
20
21 float w = 100.0;
22 float t = -.5;
23
24 for (float f = 1.0 ; f <= 10.0 ; f++ ){
25 float power = pow( 2.0, f );
26 t += abs( cnoise( vec4( power * p, 1.0 ) ) / power );
27 }
28
29 return t;
30
31 }
32
33 float sdSphere( vec3 pos, float radius ) {
34 return max(length(pos), 0.0) - radius;
35 }
36
37 float quadraticInOut(float t) {
38 float p = 2.0 * t * t;
39 return t < 0.5 ? p : -p + (4.0 * t) - 1.0;
40 }
41
42 `
43);
44shader.vertexShader = shader.vertexShader.replace(
45 `#include <begin_vertex>`,
46 /* glsl */ `
47 #include <begin_vertex>
48
49 float uSpeed = 14.0;
50 float uAmplitude = 9.1;
51
52 vUv = uv;
53
54 vec3 tempPos = position;
55
56 float uCurlFreq = 40.010;
57 vec3 sum = vec3(0.0,0.0,0.0);
58 sum += cnoise(vec4(tempPos, 1.0) * uCurlFreq + time * 0.9);
59 sum += cnoise(vec4(tempPos, 1.0) * uCurlFreq * 0.025) ;
60 sum += cnoise(vec4(tempPos, 1.0) * uCurlFreq * 0.05) ;
61 sum += cnoise(vec4(tempPos, 1.0) * uCurlFreq * .10) ;
62 sum += cnoise(vec4(tempPos, 1.0) * uCurlFreq * .20) ;
63 sum /=5.0;
64 tempPos += sum;
65
66 transformed = tempPos;
67
68 // get a turbulent 3d noise using the normal, normal to high freq
69 float noise = 10.0 * -.10 * turbulence( .5 * normal );
70 // get a 3d noise using the position, low frequency
71 float b = .70 * cnoise( vec4(0.05 * position + vec3( 10.0 ) + time, 1.0) );
72 // compose both noises
73 float displacement = - 1. * noise + b;
74
75 // move the position along the normal and transform it
76 vec3 newPosition = position + normal * displacement * 10.0;
77
78 noisyPos = newPosition;
79 vNormal = normal;
80 vDepth = gl_Position.z / gl_Position.w;
81
82 `
83);
84
85shader.vertexShader = shader.vertexShader.replace(
86 `gl_PointSize = size;`,
87 /* glsl */ `
88 float repeatingRadius = mod(time * 4.0, 20.0);
89 // float eased = quadraticInOut(repeatingRadius / 20.0) * time * 3.0;
90 float defaultPointSize = 0.01;
91 float maxPointSize = 25.1;
92 float minRange = 1.9;
93 float outerRange = 2.2;
94
95 float sdf = sdSphere(noisyPos, repeatingRadius);
96
97 bool inside = sdf < minRange && sdf > -minRange;
98 bool outer = sdf < outerRange && sdf > minRange && sdf < -minRange && sdf > -outerRange;
99
100 if (inside) {
101 gl_PointSize = mix(defaultPointSize, maxPointSize, repeatingRadius / 20.0);
102 }
103
104 else if (outer) {
105 gl_PointSize = mix(maxPointSize, defaultPointSize, repeatingRadius / 20.0);
106
107 } else {
108 gl_PointSize = defaultPointSize;
109 }
110
111
112
113 `
114);

Very quickly the top replace defines the properties like uniforms and functions etc, the middle replace adds the turbulence which we will utilize in the fragment shader and finally a third replace deals with an increasing gl_PointSize as the SDF checks if the points are in a certain distance from the crest of the wave.

Particle Fragment Shader

1shader.fragmentShader = shader.fragmentShader.replace(
2 "#include <clipping_planes_pars_fragment>",
3 /* glsl */ `
4 #include <clipping_planes_pars_fragment>
5
6 uniform float time;
7 uniform sampler2D smoke;
8 varying vec3 noisyPos;
9 varying vec3 vNormal;
10 varying float vDepth;
11
12 float rand(vec2 co, float seed) {
13 float a = 12.9898;
14 float b = 78.233;
15 float c = 43758.5453;
16 float dt= dot(co.xy ,vec2(a,b));
17 float sn= mod(dt, 3.14);
18 return fract(sin(sn + seed) * c);
19 }
20
21 vec2 rotateUV(vec2 uv, float rotation, vec2 mid) {
22 return vec2(
23 cos(rotation) * (uv.x - mid.x) + sin(rotation) * (uv.y - mid.y) + mid.x,
24 cos(rotation) * (uv.y - mid.y) - sin(rotation) * (uv.x - mid.x) + mid.y
25 );
26 }
27
28 ${cnoise}
29`
30);
31shader.fragmentShader = shader.fragmentShader.replace(
32 `#include <premultiplied_alpha_fragment>`,
33 /* glsl */ `
34 #include <premultiplied_alpha_fragment>
35
36 float repeatingRadius = mod(time * 4.0, 20.0);
37
38 // Easing produces 0-1 so have to scale back up
39 // float eased = quadraticInOut(repeatingRadius / 20.0) * time * 2.0;
40 vec3 colorInside = vec3(0.1,0.3,10.0) * 43.0 * cnoise(vec4(noisyPos, 1.0)) * (abs(length(normalize(noisyPos))));
41 vec3 colorOutside = vec3(0.05, 0.15,0.2) * 0.15;
42 float minRange = 1.9;
43 float outerRange = 2.2;
44
45
46 // Soft Particles
47
48 vec2 spriteSheetSize = vec2(1280.0, 768.0); // In px
49 vec2 spriteSize = vec2(256, 256.0); // In px
50 float index = 1.0; // Sprite index in sprite sheet (0-...)
51 float w = spriteSheetSize.x;
52 float h = spriteSheetSize.y;
53
54 // Normalize sprite size (0.0-1.0)
55 float dx = spriteSize.x / w;
56 float dy = spriteSize.y / h;
57
58 // Figure out number of tile cols of sprite sheet
59 float cols = w / spriteSize.x;
60
61 // From linear index to row/col pair
62 float col = mod(index, cols);
63 float row = floor(index / cols);
64
65 // Finally to UV texture coordinates
66 vec2 uv = vec2(dx * gl_PointCoord.x + col * dx, 1.0 - dy - row * dy + dy * gl_PointCoord.y);
67
68 float alpha = texture2D(smoke, uv).a;
69
70 //If transparent, don't draw
71 if (alpha < 0.01) discard;
72
73 vec3 outputColor = vec3(0.2,0.1,3.0);
74 outputColor.b += 2.0;
75
76 gl_FragColor = vec4(outputColor, (1.0 - repeatingRadius / 20.0) * 0.0199 );
77`
78);

Everything up to this code section is covered in the previous article.

1vec3 outputColor = vec3(0.2,0.1,3.0);
2outputColor.b += 2.0;
3
4gl_FragColor = vec4(outputColor , (1.0 - repeatingRadius / 20.0) * 0.0199 );

We want the particles textures to have a blue tinge so we increase the blue channel compared to the other red/green channels and then manually increase its blue channel again to emphasize its blue’ness.

1(1.0 - repeatingRadius / 20.0) * 0.0199

This code just means that as the radius (range 1.0-20.0) gets bigger the alpha gets smaller and more transparent, and we fine tune this with the multiplier * 0.0199.

Subtle is often better than bold floats or multipliers.

Particles Geometry and BufferAttributes

1useEffect(() => {
2 const n = 40,
3 n2 = n / 2; // particles spread in the cube
4
5 for (let i = 0; i < numPoints; i++) {
6 // positions
7
8 const x = Math.random() * n - n2;
9 const y = Math.random() * n - n2;
10 const z = Math.random() * n - n2;
11
12 // positions.push(x, y, z);
13
14 positions[i * 3] = x;
15 positions[i * 3 + 1] = y;
16 positions[i * 3 + 2] = z;
17 }
18
19 pointsRef.current.geometry.setAttribute(
20 "position",
21 new THREE.BufferAttribute(positions, 3)
22 );
23});

This small bit of code produces a range of points across a distance in 3D space, in this case 40 three.js units ( -20.0 - +20.0), into a Float32Array which is required for setting a buffer attribute. And we initialize the buffer attribute like so:

1<bufferGeometry args={[radius, 20, 20]}>
2 <bufferAttribute
3 attachObject={["attributes", "position"]}
4 array={new Float32Array(numPoints * 3)}
5 itemSize={3}
6 onUpdate={(self) => (self.needsUpdate = true)}
7 />
8</bufferGeometry>

Until next time!

More articles from theFrontDev

Realtime Displacement Maps using CanvasTextures in React Three Fiber

A very quick walkthrough for doing realtime displacement of a subdivided plane using displacement maps in Three.js. This has a wide number of applications, for example terrain tools much like in unity, real time growth patterns or some sort of animated displacement. Combined with emission and potential postprocessing you could create some pretty cool projects with this technique.

August 8th, 2023 · 2 min read

Workflow from Shadertoy to React Three Fiber - R3F

This is a workflow from shadertoy to r3f (@react-three/fiber). It involves ping pong textures and using the output texture as the input texture. This pattern is seen a lot in shadertoy as it uses textures and fragment shaders.

August 7th, 2023 · 5 min read
© 2021–2024 theFrontDev
Link to $https://twitter.com/TheFrontDevLink to $https://github.com/Richard-ThompsonLink to $https://www.linkedin.com/in/richard-thompson-248ba3111/