How to add caustics on top of .glb model just like convexseascapesurvey.com/virtual-experience/?

I’m new to three.js and 3d programming, I saw the website https://convexseascapesurvey.com/virtual-experience/ and I’m wondering how to add animated caustics on top of 3d models just like them. According to my limited knowledge I thought of adding an animated caustics on top of 3d models by creating two instances of it, in one instance I’ll add the causticShaderMaterial and add that as overlay to the other instance. It’s showing the caustics but for every model I have to load two instances of it which is pretty memory intensive, so, can anyone please help me with this?


2025-02-03 14-38-55.mkv (3.6 MB)

1 Like

To see shader use Spector.js browser extension.
Blend caustic and diffuse by worldNormal.y.
gl_FragColor.rgb = mix(gl_FragColor.rgb, caustics, smoothstep(0.0, max(0.02, uThreshold), worldNormal.y));

Fragment shader:

float sampleCaustics(float scale, vec2 offset, float speed, vec2 currentVelocity) {
    mat3 m = mat3(-2, -1, 2, 3, -2, 1, 1, 2, 2);
    vec2 causticsLoc = vec2((vWorldPosition.x+offset.x+uTime*currentVelocity.x)*scale, (vWorldPosition.z+offset.y+uTime*currentVelocity.y)*scale);
    vec3 t = vec3(causticsLoc, uTime*speed+uSeed);
    vec3 a = vec3(t*m*0.5);
    vec3 b = vec3(a*m*0.4);
    vec3 c = vec3(b*m*0.3);
    return pow(min(min(length(.5-fract(a)), length(.5-fract(b))), length(.5-fract(c))), uCausticsSpreadInverse);
}
vec3 getCausticsColor(float scale, float split, float speed, vec2 currentVelocity) {
    float r = sampleCaustics(scale, vec2(split), speed, currentVelocity);
    float g = sampleCaustics(scale, vec2(split, -split), speed, currentVelocity);
    float b = sampleCaustics(scale, -vec2(split), speed, currentVelocity);
    vec3 color = vec3(1.0, 0., 0.)*r+vec3(.0, 1., 0.)*g+vec3(.0, 0., 1.)*b;
    return max(vec3(0.), color);
}

 vec3 causticsColor1 = getCausticsColor(uCausticsScale, uChromaticAbberration, uCausticsSpeed, uCausticsVelocity);
    vec3 causticsColor2 = getCausticsColor(uCausticsScale*uCausticsDetailScale, uChromaticAbberration, uCausticsSpeed*uCausticsDetailSpeed, uCausticsVelocity);
    float causticsBase1 = sampleCaustics(uCausticsScale, vec2(0.0), uCausticsSpeed, uCausticsVelocity);
    float causticsBase2 = sampleCaustics(uCausticsScale*uCausticsDetailScale, vec2(0.0), uCausticsSpeed*uCausticsDetailSpeed, uCausticsVelocity);
    vec3 causticsColor = (min(causticsColor1, causticsColor2)+vec3(min(causticsBase1, causticsBase2)))*uCausticsStrength;
    vec3 worldNormal = normalize(vWorldNormal);
    vec3 caustics = blendAdd(gl_FragColor.rgb, causticsColor, uCausticsBlendAlpha*vCausticsStrength);
    gl_FragColor.rgb = mix(gl_FragColor.rgb, caustics, smoothstep(0.0, max(0.02, uThreshold), worldNormal.y));

2 Likes

@Chaser_Code
Thanks for your response and my counter question might sound novice but could you please explain if I’ve a custom shader like this to apply it to the .glb?

  const causticUniforms = useMemo(
    () => ({
      uTime: { value: 0 },
      uResolution: {
        value: new THREE.Vector2(window.innerWidth, window.innerHeight),
      },
    }),
    []
  );


  const fShaderCaustics = `
  #define TAU 6.28318530718
#define MAX_ITER 5

uniform float uTime;
uniform vec2 uResolution;
uniform sampler2D uMask;

varying vec2 vUv;

vec3 caustic(vec2 uv) {
    vec2 p = mod(uv * TAU, TAU) - 250.0;
    float time = uTime * 0.5 + 23.0;
    
    vec2 i = vec2(p);
    float c = 1.0;
    float inten = 0.005;
    
    for (int n = 0; n < MAX_ITER; n++) {
        float t = time * (1.0 - (3.5 / float(n+1)));
        i = p + vec2(cos(t - i.x) + sin(t + i.y), sin(t - i.y) + cos(t + i.x));
        c += 1.0 / length(vec2(p.x / (sin(i.x + t) / inten), p.y / (cos(i.y + t) / inten)));
    }
    
    c /= float(MAX_ITER);
    c = 1.17 - pow(c, 1.4);
    vec3 color = vec3(pow(abs(c), 8.0));
    color = clamp(color + vec3(0.0, 0.35, 0.5), 0.0, 1.0); //default vec3(0.0, 0.35, 0.5)
    color = mix(color, vec3(0.0, 0.0,0.0), 0.3); //default vec3(1.0,1.0,1.0)
    
    return color + vec3(0.0, 0.08627, 0.30588);
}

float causticX(float x, float power, float gtime) {
    float p = mod(x * TAU, TAU) - 250.0;
    float time = gtime * 0.5 + 23.0;
    float i = p;
    float c = 1.0;
    float inten = 0.005;
    
    for (int n = 0; n < MAX_ITER / 2; n++) {
        float t = time * (1.0 - (3.5 / float(n+1)));
        i = p + cos(t - i) + sin(t + i);
        c += 1.0 / length(p / (sin(i + t) / inten));
    }
    c /= float(MAX_ITER);
    c = 1.17 - pow(c, power);
    
    return c;
}

float GodRays(vec2 uv) {
    float light = 0.0;
    
    light += pow(causticX((uv.x + 0.08 * uv.y) / 1.7 + 0.5, 1.8, uTime * 0.65), 10.0) * 0.05;
    light -= pow((1.0 - uv.y) * 0.3, 2.0) * 0.2;
    light += pow(causticX(sin(uv.x), 0.3, uTime * 0.7), 9.0) * 0.4;
    light += pow(causticX(cos(uv.x * 2.3), 0.3, uTime * 1.3), 4.0) * 0.1;
    
    light -= pow((1.0 - uv.y) * 0.3, 3.0);
    light = clamp(light, 0.0, 1.0);
    
    return light;
}

void main() {
    vec2 uv = gl_FragCoord.xy / uResolution.xy;
    
    // Calculate caustics
    vec3 causticColor = caustic(uv * 2.0 - 1.0);
    
    // Calculate god rays
    float godRays = GodRays(uv * 2.0 - 1.0);
    
    // Combine caustics and god rays
    vec3 finalColor = causticColor + godRays * vec3(0.0, 0.0, 0.0);
    
    // Calculate alpha: use brightness as alpha
    float alpha = length(finalColor);
    alpha = clamp(alpha, 0.0, 1.0);

    float featherIntensity = 1.0 - vUv.y*3.25;

    //float mask = texture2D(uMask,vUv).r;
    vec4 randomMask = texture2D(uMask,vUv);
    
    // Output the final color
    gl_FragColor = vec4(finalColor, 0.3);
}

  `

  const customMaterial = new THREE.ShaderMaterial({
    uniforms: causticUniforms,
    vertexShader: vShaderCaustics,
    fragmentShader: fShaderCaustics,
    transparent: true,
    blending: THREE.MultiplyBlending,
  });

  {/*GLB*/}
  <primitive
        object={ground}
        scale={0.06}
        position={[1, -2.65, -15]}
        rotation={[-0.12, Math.PI / 2.4, 0.01]}
      />

Hello,
To add animated caustics in Three.js without duplicating instances, consider using a shared caustics shader that overlays on your 3D models. This approach reduces memory usage while achieving the desired effect. You can create a single caustics instance and apply it as a post-processing effect or overlay on your models. Check out resources like the Three.js forum or GitHub repositories for examples and code snippets. yourtexasbenefitsi

Best regards,
Jack Henry

Yes, but how to overlay it?

Finally I solved it by

  1. Accessing the default vertex and fragment shader of the glb model.
  2. Then storing it loacally and modifying it to my need.
  3. After that I passed the uniform time to show the animation.
  4. Also, storing the uniforms in the userData variable of the material for accessing it in the useFrame()
    ground.traverse((child) => {
      if ((child as THREE.Mesh).isMesh) {
        const mesh = child as THREE.Mesh;
        mesh.castShadow = false;
        mesh.receiveShadow = true;

        const currentPhysicalMaterial =
          mesh.material as THREE.MeshPhysicalMaterial;

        currentPhysicalMaterial.onBeforeCompile = (shader) => {
          shader.uniforms.uTime = { value: 0 };
          shader.uniforms.uResolution = {
            value: new THREE.Vector2(window.innerWidth, window.innerHeight),
          };
          shader.uniforms.uMixIntensity = { value: 0.1 };

          shader.vertexShader = vShaderGround;
          shader.fragmentShader = fShaderGround;

          // Store the shader and uniforms in userData
          currentPhysicalMaterial.userData.shader = shader;
          currentPhysicalMaterial.userData.uniforms = shader.uniforms;
        };
      }
    });

useFrame(() => {
    ground.traverse((child) => {
      if ((child as THREE.Mesh).isMesh) {
        const mesh = child as THREE.Mesh;

        const currentPhysicalMaterial =
          mesh.material as THREE.MeshPhysicalMaterial;

        if (currentPhysicalMaterial.userData.uniforms) {
          // Update the uTime uniform
          currentPhysicalMaterial.userData.uniforms.uTime.value += 0.1;
        }
      }
    });

  });

If apply via postprocess, then need convert scenedepthtexture to position and maybe normal


The problem with this approach is that it would apply caustics everywhere but when you need to apply it to the models separately then I think we need to attach the caustics to its default shader.

I tried SpotLight with its map parameter as WebGLRenderTarget :thinking:

Video:

Demo: https://codepen.io/prisoner849/full/dPbLENe

7 Likes

Yes, it’s a good solution too but in my case by modifying the source shader I’m now able to apply the extracted shader to all glb models just by passing their default uniforms.

1 Like

In fact I was about to suggest the same as @prisoner849 (although not as lucidly :sweat_smile:) for this very reason: by using an object-level shader, you are forced to use the same shader with all objects, duplicating the same calculations inefficiently (and leaving little room for objects to have different attributes). So a shader-based approach may be useful when there are only a few objects, but may not be scalable when there are many of them.

Also, from a radiometric point of view, a light-based approach is closer to the physical characteristics of the phenomenon, since caustics should only appear under the area that corresponds to direct light, and not in areas of indirect light, nor umbra or penumbra.

2 Likes

You are right, I should optimize my code further for using light-based approach.