In some respects this is a adaptation of another article about hdri’s and applying textures to an inner mesh, the article named Mastering Skybox Realism - Loading and Applying HDRI with Three and R3F.
This article allowed you to apply a hdri to a sphere and apply the light emitting aspects of the hdri to the objects in the scene.
The extension to this is using: cube cameras, reflective materials from drei, a queue which stores center positions and radi for a circular SDF and a really cool customShaderMaterial from Farazz - which allows a developer to easily extend material classes from three or derivatives (meaning a class which extends of the base class materials from three).
So I wanted a base to work off to create or mimic kelvin wakes. I here you say surely you can find something on shaderToy and port over? I found this to be quite complex and really hard, alittle beyond my reach as of now.
However instead of a feedback loop for ever expanding elipsises to mimic kelvin wakes, can I not just use a queue of centers and radi of an SDF? I have done something similar in another article to do with vertex displacement. You have to be aware of hardware limitations ie max sizes of arrays, but we are way under with 300 and this is more than enough to implement this basic effect.
The key take home message was that I can dynamically set a array in glsl with javascript template literals but its slow af. The reason being I couldnt get it to update with out causing rerenders which drastically reduces fps.
Sometimes I think anything which has complex orchestration is better of doing programatically with threejs core api, rather than declaritvely.
Personal opinion from 3-4 years of playing around with these kind of things and shader effects, Im not talking about basic shaderMaterials or posprocessing but things which have more than one render or off screen render targets.
CustomShaderMaterial
This is something really cool from Farazz created which allows easy extension of core materials or already exnted materials in R3F or Three.js.
In my case I have extended MeshReflectiveMaterial from @react-three/drei (notice the import from the materials sub directory) this is because we cannot directly use a react component which this npm packaged exports from the main folder.
CustomShaderMaterial can be defined like this:
1<CubeCamera>2 {(texture) => {3 return (4 <mesh5 rotation={[-Math.PI / 2, 0, 0]}6 scale={[200, 200, 200]}7 position={[0, 1, 0]}8 onPointerMove={(e) => {9 handleClick(e);10 }}11 ref={meshRef}12 >13 <planeGeometry />14 <CustomShaderMaterial15 ref={setMatRef}16 baseMaterial={MeshReflectorMaterial}17 vertexShader={patchShaders(customShader.vertex)}18 fragmentShader={patchShaders(customShader.fragment)}19 uniforms={uniforms}20 envMap={texture}21 metalness={1}22 roughness={0}23 />24 </mesh>25 );26 }}27</CubeCamera>
CustomShaderMaterial expects as a minimum:
1baseMaterial={MeshReflectorMaterial}2vertexShader={patchShaders(customShader.vertex)}3fragmentShader={patchShaders(customShader.fragment)}
CubeCamera
The cubeCamera essentially takes a snap shot of the surroundings onto a 2d texture and used in the way stated above, this is a gross oversimplification, but im not trying to give a 101 on cubeCameras, just a general workflow or idea how to practicvally use them. This cube map will give us a texture we can use onto the surface of a mesh.
So these 3 things are enabling the reflectivity:
1envMap={texture}2metalness={1}3roughness={0}
We are using Pysically based material (PBR - which meshStandardMaterial and meshPhysicalMaterial) which means we can increase the metalness to give a good shine and decrease the roughness to give the appearance of less rough material and more smoothness.
Wake and dynamic arrays GLSL
I firstly attempted to recreate this shadertoy in r3f with a feedback loop for the different buffers, unfortunately I couldnt get this to work, each shadertoy seems to have different requirements with r3f setup.
One day I should be able to come up with a solution to porting which is easy and effective, until then I will have to think of different avenues!
The way I thought of, is to extend this article.
Essentially I tried to make a dyanmic array by using javascript template literals in the shaders.
1uniform vec2 positions[int(`${positions.current.length}`)]
Suffice to say this didnt work, I got to the stage where unless i had a state setting in onPointerMove and had the uniforms update based on this (which was slow), then I had to take another avenue.
The over avenue was to have a fixed length to my array, and have a queue buffer ie, one in one out, this way the shader wouldnt complain and would compile.
1let positions = new Array(200).fill(new THREE.Vector2());2let radius = new Array(200).fill(60);34// more code56useFrame(() => {7 radius = radius.map((item, index) =>8 Math.min(item + 100 * easeCubic(index / 200), 60.0)9 );1011 if (matRef) {12 matRef.uniforms.radius.value = radius;13 matRef.uniforms.positions.value = positions;14 }15});
This shows a increase in radius, but the default radius have 60 so are at the maximum and dont animate, and positions have a default vec2.
And the CustomShaderMaterial from Farazz allows easily updating of uniforms as it has the vertexShader and fragmentShader + uniforms on the material instance which normal materials dont have, which makes using onBeforeCompile really complicated.
A neat solution which by all means is helping develop the shader ecosystem.
The main shader
The shader is the summation of colors having looped through the positions array and using a sdf, creating a hollow circle with two ranges.
1vec3 colorSum = vec3(csm_DiffuseColor.rgb);2for (int i = 0; i< MAX_SIZE; i++) {3 vec2 position = vUv; // Calculate the fragment's position in 2D4 vec2 center = positions[i]; // Set the center of the circle5 float radius = 1.0 / max(1.0, 60.0 - radius[i]);67 float distance = circleSDF(position, center, radius);89 if (distance < 0.0 && distance > -0.005) {10 // Fragment is inside the circle11 colorSum += colorSum * 0.06;12 } else {13 // Fragment is outside the circle14 colorSum += vec3(0.0, 0.0, 0.0); // Transparent color15 }1617 if (length(center) > 0.0) {18 }19 csm_DiffuseColor = vec4(colorSum, 1.0);20}
An important note to make is that using this library for a customShader you need to replace standard things like, gl_Position, gl_FragColor and three DiffuseColor with cms_FragColor or csm_DiffuseColor. And there are loads of ways to configure this so replacing specific bits of the shader etc etc.
The future part 2
This is a great base to work from and develop a kelvin wake algorithm along with reflective water, with a few coming tweaks this should be a cool water example. For now, have fun and watch out for the part 2 of this.