Complex Refractions in WebGL (WIP)

A technique for good-enough realtime refraction through forms with hollows.

This demo and article are a work in progress. Have a look if you like!

Input

Left mouse to rotate camera. Right mouse to pan. Scroll wheel to dolly zoom. Cursor keys or left stick of a gamepad to rotate the object.

Placeholder Summary

This is rendered in a single pass as a simple rounded cuboid mesh. Defined as uniforms are axis-aligned bounding boxes (AABBs) for the exterior and interior volumes, a plane for the water fill, and various constants for refractive indices, and absorbance values.

All the details are provided by cubemapped normals. That allows rays to be traced through nice computationally simple AABBs but sample the cubemap for where they hit, and apply those normals for lots more detail.

The more that the forms we raycast against deviate from the originally modelled form, the less accurate the result. The shader used here also ignores a lot of cases where the ray is split at an interface, and instead only traces one of those rays.

Vertex Shader

uniform vec3 waterPosition;
uniform vec3 waterNormal;

varying vec3 vObjectPosition;
varying vec3 vObjectCameraPosition;
varying mat4 vModelMatrix;
varying mat4 vModelMatrixInverse;
varying vec3 vWaterPosition;
varying vec3 vWaterNormal;

void main() {
    vObjectPosition = position;

    gl_Position = projectionMatrix * modelViewMatrix * vec4(vObjectPosition.xyz, 1.0);

    vObjectCameraPosition = (vec4(cameraPosition.xyz, 1.0) * modelMatrix).xyz;

    mat4 modelMatrixInverse = inverse(modelMatrix);
    vWaterPosition = (vec4(waterPosition.xyz, 1.0) * modelMatrix).xyz;
    vWaterNormal = (vec4(waterNormal.xyz, 0.0) * modelMatrix).xyz;

    vModelMatrix = modelMatrix;
}

Fragment Shader

uniform samplerCube environmentSampler;
uniform samplerCube interiorSampler;
uniform samplerCube exteriorSampler;

uniform vec3 aabbExterior;
uniform vec3 aabbInterior;

uniform float refractiveIndexAir;
uniform float refractiveIndexGlass;
uniform float refractiveIndexWater;

uniform float glassAbsorbanceCoefficient;
uniform vec3 glassAbsorbanceColour;
uniform float waterAbsorbanceCoefficient;
uniform vec3 waterAbsorbanceColour;

varying vec3 vObjectPosition;
varying vec3 vObjectCameraPosition;
varying mat4 vModelMatrix;
varying mat4 vModelMatrixInverse;
varying vec3 vWaterPosition;
varying vec3 vWaterNormal;

// approximation of the Fresnel equation for reflectance traveling from medium A to B.
// based on https://graphics.stanford.edu/courses/cs148-10-summer/docs/2006--degreve--reflection_refraction.pdf
float schlickApproximation(vec3 incoming, vec3 surfaceNormal, float indexA, float indexB) {
    float cosX = abs(dot(surfaceNormal, incoming));
    if (abs(cosX)  indexB) {
        float n = indexA / indexB;
        float sinT2 = n * n * (1.0 - cosX * cosX);

        if (sinT2 > 1.0)
        {
            // Total internal reflection.
            return 1.0;
        }

        cosX = sqrt(1.0 - sinT2);
    }

    float ratio = (indexA - indexB) / (indexA + indexB);
    float ratioSquared = ratio * ratio;
    float x = 1.0 - cosX;
    return ratioSquared + (1.0 - ratioSquared) * x*x*x*x*x;
}

void castThroughInterface(in vec3 incomingDirection, in vec3 surfaceNormal,
    in float indexA, in float indexB,
    out vec3 reflectDirection, out vec3 refractDirection, out float reflectance
) {
    reflectance = schlickApproximation(incomingDirection, surfaceNormal, indexA, indexB);
    reflectDirection = reflect(incomingDirection, surfaceNormal);
    refractDirection = refract(incomingDirection, surfaceNormal, indexA / indexB);
}

float max4(in float a, in float b, in float c, in float d) {
    return max(max(a, b), max(c, d));
}

float min4(in float a, in float b, in float c, in float d) {
    return min(min(a, b), min(c, d));
}

// Solve quadratic equation ax^2 + bx + c = 0.
// x1 and x2 are the two solutions.
// hasRealSolution is true if there is a real solution.
void solveQuadratic(in float a, in float b, in float c,
    out bool hasRealSolution, out float x1, out float x2
) {
    float s = sqrt(b * b - 4.0 * a * c);
    hasRealSolution = s > 0.0 && a != 0.0;
    x1 = (-b + s) / (2.0 * a);
    x2 = (-b - s) / (2.0 * a);
}

void planeIntersection(in vec3 rayDirection, in vec3 rayPosition,
    in vec3 planeNormal, in vec3 planePosition,
    out float tIntersect
) {
    float denom = dot(planeNormal, rayDirection);
    if (abs(denom) > 0.0001) {
        tIntersect = dot(planePosition - rayPosition, planeNormal) / denom;
    } else {
        tIntersect = -1.0;
    }
}

void isBelowPlane(in vec3 position, in vec3 planeNormal, in vec3 planePosition,
    out bool isBelow
) {
    isBelow = dot(planeNormal, position - planePosition) = yMin && y1 = yMin && y2  tMin) {
        // Ray starts inside the AABB.
        tIntersect = tMax;
    } else {
        // Ray starts outside the AABB.
        tIntersect = ((tMin  tMax))
            ? -1.0
            : tMin;
    }
}

vec3 sampleEnvFromObjectDirection(in vec3 objectDirection, in mat4 modelMatrix, in samplerCube environmentSampler) {
    vec3 worldDirection = (modelMatrix * vec4(objectDirection.xyz, 0.0)).xyz;
    return textureCube(environmentSampler, worldDirection).rgb;
}

vec3 sampleNormal(in vec3 objectPosition, in samplerCube normalSampler) {
    vec3 raw = vec3(1.0) - textureCube(normalSampler, normalize(objectPosition)).rgb;
    vec3 normal = normalize(vec3(
        (raw.x - 0.5) * 2.0,
        (raw.y - 0.5) * 2.0,
        (raw.z - 0.5) * 2.0
    ));

    return normal;
}

/*
Reference for positions:
- A. Where the ray enters the mesh.
- B. Ray enters the interior volume (may not exist.)
- W. Ray passes through the water surface within interior volume (may not exist.)
- C. Ray exits the interior volume (may not exist.)
- D. Ray leaves the mesh.

A is the only position where we sample both the reflection and refraction ray.
Everywhere else we just pick one to follow.

*/

void handleWaterSurfaceBtoW(
    in bool bIsBelowWater, in vec3 bPosition, in vec3 bExitDirection,
    in float tWaterIntercept, in float tLeaveInterior,
    in vec3 aabbInternalMin, in vec3 aabbInternalMax,
    inout float distanceInWater,
    out vec3 cIncomingDirection, out vec3 cPosition
) {
    if (tWaterIntercept  tLeaveInterior) {
        // Ray leaves interior without hitting water surface.
        if (bIsBelowWater) {
            distanceInWater += max(0.0, tLeaveInterior);
        }

        cIncomingDirection = bExitDirection;
        cPosition = bPosition + bExitDirection * tLeaveInterior;

        return;
    }

    vec3 wNormal = vWaterNormal;
    if (bIsBelowWater) {
        // b to w is under water.
        distanceInWater += max(0.0, tWaterIntercept);
        wNormal = -1.0 * wNormal;
    }

    vec3 wPosition = bPosition + bExitDirection * tWaterIntercept;

    vec3 wRefractDirection = vec3(0.0, 0.0, 0.0);
    vec3 wReflectDirection = vec3(0.0, 0.0, 0.0);
    float wReflectance = 0.0;
    castThroughInterface(bExitDirection, wNormal,
        bIsBelowWater ? refractiveIndexWater : refractiveIndexAir,
        bIsBelowWater ? refractiveIndexAir : refractiveIndexWater,
        wReflectDirection, wRefractDirection, wReflectance
    );

    // Decide if we'll follow the reflection or refraction ray.
    vec3 wExitDirection = (wReflectance >= 0.999) ? wReflectDirection : wRefractDirection;
    bool wToCInWater = (wReflectance >= 0.999) ? bIsBelowWater : !bIsBelowWater;

    float cFromW = 0.0;
    aabbIntersection(wExitDirection, wPosition,
        aabbInternalMin, aabbInternalMax,
        cFromW
    );

    if (wToCInWater) {
        distanceInWater += max(0.0, cFromW);
    }

    cPosition = wPosition + wExitDirection * cFromW;
    cIncomingDirection = wExitDirection;
}

void handleInternalVolumeBtoC(in vec3 bPosition, in vec3 bIncomingDirection,
    in vec3 aabbInternalMin, in vec3 aabbInternalMax,
    inout float distanceInWater,
    out vec3 beforeDPosition, out vec3 dIncomingDirection
) {
    // Find the normal of the interior at the point where the ray enters interior.
    vec3 bNormal = sampleNormal(bPosition, interiorSampler).xyz;

    // Find if the ray is entering water or air.
    bool bIsBelowWater;
    isBelowPlane(bPosition, vWaterNormal, vWaterPosition, bIsBelowWater);

    // Find how the ray gets split when entering interior space.
    vec3 bRefractDirection = vec3(0.0, 0.0, 0.0);
    vec3 bReflectDirection = vec3(0.0, 0.0, 0.0);
    float bReflectance = 0.0;
    castThroughInterface(bIncomingDirection, bNormal,
        refractiveIndexGlass, bIsBelowWater ? refractiveIndexWater : refractiveIndexAir,
        bReflectDirection, bRefractDirection, bReflectance
    );

    if (bReflectance >= 0.999) {

        // Total internal reflection, so follow reflection ray without entering interior.
        beforeDPosition = bPosition;
        dIncomingDirection = bReflectDirection;

        return;
    }

    // Ray passes through interior volume.

    // Find when ray will go through water surface.
    float tWaterIntercept;
    planeIntersection(bRefractDirection, bPosition,
        vWaterNormal, vWaterPosition,
        tWaterIntercept
    );

    // Find where the uninterrupted ray would leave the interior AABB.
    float cUninterruptedLeave = 0.0;
    aabbIntersection(bRefractDirection, bPosition,
        aabbInternalMin, aabbInternalMax,
        cUninterruptedLeave
    );

    vec3 cIncomingDirection;
    vec3 cPosition;

    handleWaterSurfaceBtoW(
        bIsBelowWater, bPosition, bRefractDirection,
        tWaterIntercept, cUninterruptedLeave,
        aabbInternalMin, aabbInternalMax,
        distanceInWater,
        cIncomingDirection, cPosition
    );

    bool cIsBelowWater;
    isBelowPlane(cPosition, vWaterNormal, vWaterPosition, cIsBelowWater);

    // Find the normal of the interior at point c.
    vec3 cNormal = sampleNormal(cPosition, interiorSampler).xyz;

    // Find how the ray gets split when leaving the interior space.
    vec3 cRefractDirection = vec3(0.0, 0.0, 0.0);
    vec3 cReflectDirection = vec3(0.0, 0.0, 0.0);
    float cReflectance = 0.0;
    castThroughInterface(bRefractDirection, cNormal * -1.0,
        cIsBelowWater ? refractiveIndexWater : refractiveIndexAir, refractiveIndexGlass,
        cReflectDirection, cRefractDirection, cReflectance
    );

    // Just ignoring reflection here. Going from water/air to glass so this'll be minor.

    dIncomingDirection = cRefractDirection;
    beforeDPosition = cPosition;
}

void main() {
    vec3 aabbInternalMin = aabbInterior * -0.5;
    vec3 aabbInternalMax = aabbInterior * 0.5;

    vec3 aabbExternalMin = aabbExterior * -0.5;
    vec3 aabbExternalMax = aabbExterior * 0.5;

    // Sum of distance within the medium this ray travels.
    float distanceInGlass = 0.0;
    float distanceInWater = 0.0;

    // Ray enters the mesh at A.
    vec3 aPosition = vObjectPosition.xyz;
    vec3 aDirection = normalize(aPosition - vObjectCameraPosition).xyz;
    vec3 aNormal = sampleNormal(aPosition, exteriorSampler).xyz;

    // Handle interface at A.
    vec3 aRefractDirection = vec3(0.0, 0.0, 0.0);
    vec3 aReflectDirection = vec3(0.0, 0.0, 0.0);
    float aReflectance = 0.0;
    castThroughInterface(aDirection, aNormal,
        refractiveIndexAir, refractiveIndexGlass,
        aReflectDirection, aRefractDirection, aReflectance
    );

    // Some is immediately reflected back out of the mesh.
    vec3 aColourReflect = sampleEnvFromObjectDirection(aReflectDirection, vModelMatrix, environmentSampler);

    // aRefracted may enter interior volume, if so it's at B.
    float bBoxIntersect = 0.0;
    aabbIntersection(aRefractDirection, aPosition,
        aabbInternalMin, aabbInternalMax,
        bBoxIntersect
    );
    bool bHitBox = bBoxIntersect > -1.0;

    vec3 beforeDPosition = vec3(0.0, 0.0, 0.0);
    vec3 dIncomingDirection = vec3(0.0, 0.0, 0.0);

    if (!bHitBox) {

        // Ray doesn't enter interior volume, so skip to it leaving.
        beforeDPosition = aPosition;
        dIncomingDirection = aRefractDirection;

    } else { // Ray enters interior volume.

        vec3 bPosition = aPosition + aRefractDirection * bBoxIntersect;
        distanceInGlass += max(0.0, bBoxIntersect);

        handleInternalVolumeBtoC(bPosition, aRefractDirection,
            aabbInternalMin, aabbInternalMax,
            distanceInWater,
            beforeDPosition, dIncomingDirection
        );
    }

    // Find where the ray exits the mesh.
    float dDistance = 0.0;
    aabbIntersection(dIncomingDirection, beforeDPosition,
        aabbExternalMin, aabbExternalMax,
        dDistance
    );

    distanceInGlass += max(0.0, dDistance);
    
    vec3 dPosition = (dDistance >= 0.0)
        ? (beforeDPosition + dIncomingDirection * dDistance)
        : beforeDPosition;

    vec3 dNormal = sampleNormal(dPosition, exteriorSampler).xyz;

    // Find how the refracted ray gets split when leaving the mesh.
    vec3 dRefractDirection = vec3(0.0, 0.0, 0.0);
    vec3 dReflectDirection = vec3(0.0, 0.0, 0.0);
    float dReflectance = 0.0;

    castThroughInterface(dIncomingDirection, dNormal * -1.0,
        refractiveIndexGlass, refractiveIndexAir,
        dReflectDirection, dRefractDirection, dReflectance
    );

    // Some is refracted out into the environment.
    vec3 exitRefraction = sampleEnvFromObjectDirection(dRefractDirection, vModelMatrix, environmentSampler);

    // Some is reflected back into the AABB.
    // We *should* follow this through the AABB again, but we'll have to stop tracking at some point.
    vec3 exitReflection = sampleEnvFromObjectDirection(dReflectDirection, vModelMatrix, environmentSampler);

    vec3 interiorColour = mix(exitRefraction, exitReflection, dReflectance);

    interiorColour = max(
        vec3(0.0, 0.0, 0.0),
        interiorColour * exp(
            -1.0 * distanceInGlass * glassAbsorbanceCoefficient * glassAbsorbanceColour
        )
    );

    interiorColour = max(
        vec3(0.0, 0.0, 0.0),
        interiorColour * exp(
            -1.0 * distanceInWater * waterAbsorbanceCoefficient * waterAbsorbanceColour
        )
    );

    gl_FragColor.rgba = vec4(mix(interiorColour, aColourReflect, aReflectance), 1.0);
}
    

CubeMap

The baked normals of the internal volume, as a cubemap.

Reference for ray vs AABB: Ray vs AABB