There are two things going on in this codeSandbox:
- Inner sphere blob which has noise applied to it
- 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();45 const customShader = {6 uniforms: {7 time: {8 value: 09 }10 }11 };1213 const OBC = useCallback(14 (shader) => {15 shader.uniforms = {16 ...shader.uniforms,17 ...customShader.uniforms18 };1920 shader.vertexShader = shader.vertexShader.replace(21 "#include <clipping_planes_pars_vertex>",22 /* glsl */ `2324 #include <clipping_planes_pars_vertex>25 uniform float time;2627 varying vec2 vUv;28 varying vec3 vPosition;29 varying float vNoise;303132 ${cnoise}3334 float turbulence( vec3 p ) {3536 float w = 100.0;37 float t = -.5;3839 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 }4344 return t;4546 }474849 `50 );51 shader.vertexShader = shader.vertexShader.replace(52 `#include <begin_vertex>`,53 `54 #include <begin_vertex>555657 vUv = uv;5859 // get a turbulent 3d noise using the normal, normal to high freq60 float noise = 10.0 * -.10 * turbulence( .5 * normal + time * 0.25);61 // get a 3d noise using the position, low frequency62 float b = .30 * cnoise( vec4(0.05 * position, 1.0) );63 // compose both noises64 float displacement = - 1. * noise + b;6566 // move the position along the normal and transform it67 vec3 newPosition = position + normal * displacement;68 transformed = newPosition;69 vPosition = newPosition;70 vNoise = noise;71 `72 );7374 shader.fragmentShader = shader.fragmentShader.replace(75 "#include <clipping_planes_pars_fragment>",76 `77 #include <clipping_planes_pars_fragment>7879 uniform float time;80 varying float vNoise;81 varying vec3 vPosition;8283 ${cnoise}84 `85 );86 shader.fragmentShader = shader.fragmentShader.replace(87 `#include <alphamap_fragment>`,88 `89 #include <alphamap_fragment>9091 float scale = 10.0;92 float radius = mod(time * 4.0, 20.0);9394 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;9798 float noise = vNoise + 0.3;99100 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 );110111 matRef.current.userData.shader = shader;112 },113 [customShader.uniforms]114 );115116 useFrame(({ clock, gl }) => {117 if (matRef.current) {118 const shader = matRef.current.userData.shader;119120 if (shader) {121 shader.uniforms.time = {122 value: clock.elapsedTime123 };124 }125 }126127 if (sphereShellRef.current) {128 const scale = (clock.elapsedTime * 4.0) % 20.0;129 sphereShellRef.current.scale.set(scale, scale, scale);130 }131 });132133 return (134 <group>135 <ambientLight />136 <mesh ref={sphereShellRef}>137 <sphereGeometry args={[1.0, 100, 100]} />138 <meshStandardMaterial139 ref={matRef}140 onBeforeCompile={OBC}141 transparent142 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 OBC2<mesh ref={sphereShellRef}>3 <sphereGeometry args={[1.0, 100, 100]} />4 <meshStandardMaterial5 ref={matRef}6 onBeforeCompile={OBC}7 transparent8 side={THREE.DoubleSide}9 />10</mesh>1112// set the shader object in OBC13// callback to material useData object1415const OBC = useCallback(16 (shader) => {17 shader.uniforms = {18 ...shader.uniforms,19 ...customShader.uniforms20 };212223 // shader modifications.....242526 // Set the shader reference27 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 */ `45 #include <clipping_planes_pars_vertex>6 uniform float time;78 varying vec2 vUv;9 varying vec3 vPosition;10 varying float vNoise;111213 ${cnoise}1415 float turbulence( vec3 p ) {1617 float w = 100.0;18 float t = -.5;1920 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 }2425 return t;2627 }282930 `31);32shader.vertexShader = shader.vertexShader.replace(33 `#include <begin_vertex>`,34 `35 #include <begin_vertex>363738 vUv = uv;3940 // get a turbulent 3d noise using the normal, normal to high freq41 float noise = 10.0 * -.10 * turbulence( .5 * normal + time * 0.25);42 // get a 3d noise using the position, low frequency43 float b = .30 * cnoise( vec4(0.05 * position, 1.0) );44 // compose both noises45 float displacement = - 1. * noise + b;4647 // move the position along the normal and transform it48 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>56 uniform float time;7 varying float vNoise;8 varying vec3 vPosition;910 ${cnoise}11`12);13shader.fragmentShader = shader.fragmentShader.replace(14`#include <alphamap_fragment>`,15`16 #include <alphamap_fragment>1718 float radius = mod(time * 4.0, 20.0);1920 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;2324 float noise = vNoise + 0.3;2526 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 */ `45 #include <clipping_planes_pars_vertex>6 uniform float time;7 uniform vec3 mousePos;89 varying vec2 vUv;10 varying float noise;11 varying vec3 noisyPos;12 varying vec3 vNormal;13 varying float vDepth;14151617 ${cnoise}1819 float turbulence( vec3 p ) {2021 float w = 100.0;22 float t = -.5;2324 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 }2829 return t;3031 }3233 float sdSphere( vec3 pos, float radius ) {34 return max(length(pos), 0.0) - radius;35 }3637 float quadraticInOut(float t) {38 float p = 2.0 * t * t;39 return t < 0.5 ? p : -p + (4.0 * t) - 1.0;40 }4142 `43);44shader.vertexShader = shader.vertexShader.replace(45 `#include <begin_vertex>`,46 /* glsl */ `47 #include <begin_vertex>4849 float uSpeed = 14.0;50 float uAmplitude = 9.1;5152 vUv = uv;5354 vec3 tempPos = position;5556 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;6566 transformed = tempPos;6768 // get a turbulent 3d noise using the normal, normal to high freq69 float noise = 10.0 * -.10 * turbulence( .5 * normal );70 // get a 3d noise using the position, low frequency71 float b = .70 * cnoise( vec4(0.05 * position + vec3( 10.0 ) + time, 1.0) );72 // compose both noises73 float displacement = - 1. * noise + b;7475 // move the position along the normal and transform it76 vec3 newPosition = position + normal * displacement * 10.0;7778 noisyPos = newPosition;79 vNormal = normal;80 vDepth = gl_Position.z / gl_Position.w;8182 `83);8485shader.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;9495 float sdf = sdSphere(noisyPos, repeatingRadius);9697 bool inside = sdf < minRange && sdf > -minRange;98 bool outer = sdf < outerRange && sdf > minRange && sdf < -minRange && sdf > -outerRange;99100 if (inside) {101 gl_PointSize = mix(defaultPointSize, maxPointSize, repeatingRadius / 20.0);102 }103104 else if (outer) {105 gl_PointSize = mix(maxPointSize, defaultPointSize, repeatingRadius / 20.0);106107 } else {108 gl_PointSize = defaultPointSize;109 }110111112113 `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>56 uniform float time;7 uniform sampler2D smoke;8 varying vec3 noisyPos;9 varying vec3 vNormal;10 varying float vDepth;1112 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 }2021 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.y25 );26 }2728 ${cnoise}29`30);31shader.fragmentShader = shader.fragmentShader.replace(32 `#include <premultiplied_alpha_fragment>`,33 /* glsl */ `34 #include <premultiplied_alpha_fragment>3536 float repeatingRadius = mod(time * 4.0, 20.0);3738 // Easing produces 0-1 so have to scale back up39 // 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;444546 // Soft Particles4748 vec2 spriteSheetSize = vec2(1280.0, 768.0); // In px49 vec2 spriteSize = vec2(256, 256.0); // In px50 float index = 1.0; // Sprite index in sprite sheet (0-...)51 float w = spriteSheetSize.x;52 float h = spriteSheetSize.y;5354 // Normalize sprite size (0.0-1.0)55 float dx = spriteSize.x / w;56 float dy = spriteSize.y / h;5758 // Figure out number of tile cols of sprite sheet59 float cols = w / spriteSize.x;6061 // From linear index to row/col pair62 float col = mod(index, cols);63 float row = floor(index / cols);6465 // Finally to UV texture coordinates66 vec2 uv = vec2(dx * gl_PointCoord.x + col * dx, 1.0 - dy - row * dy + dy * gl_PointCoord.y);6768 float alpha = texture2D(smoke, uv).a;6970 //If transparent, don't draw71 if (alpha < 0.01) discard;7273 vec3 outputColor = vec3(0.2,0.1,3.0);74 outputColor.b += 2.0;7576 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;34gl_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 cube45 for (let i = 0; i < numPoints; i++) {6 // positions78 const x = Math.random() * n - n2;9 const y = Math.random() * n - n2;10 const z = Math.random() * n - n2;1112 // positions.push(x, y, z);1314 positions[i * 3] = x;15 positions[i * 3 + 1] = y;16 positions[i * 3 + 2] = z;17 }1819 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 <bufferAttribute3 attachObject={["attributes", "position"]}4 array={new Float32Array(numPoints * 3)}5 itemSize={3}6 onUpdate={(self) => (self.needsUpdate = true)}7 />8</bufferGeometry>
Until next time!