🔬 The velocity's Y component is negated here to match WebGL's flipped Y axis. What happens if you remove that negative sign (pass velocityY instead of -velocityY)? Try it and see whether ripples still respond correctly when you move the mouse vertically.
myShader.setUniform('u_mouse', [mouseX, height - mouseY]);
myShader.setUniform('u_velocity', [velocityX, -velocityY]);
function draw() {
// 1. Calculate mouse velocity
let dx = mouseX - prevMouseX;
let dy = mouseY - prevMouseY;
// Lerp (linear interpolate) velocity for smoothing and decay
// This makes the ripples settle slowly when the mouse stops moving
velocityX = lerp(velocityX, dx, 0.1);
velocityY = lerp(velocityY, dy, 0.1);
// Update previous mouse position for the next frame's calculation
prevMouseX = mouseX;
prevMouseY = mouseY;
// 2. Pass uniforms to the shader
// Uniforms are variables that you pass from p5.js to your shader
myShader.setUniform('u_resolution', [width, height]);
// Invert Y for mouse position and velocity to match WebGL coordinate system
myShader.setUniform('u_mouse', [mouseX, height - mouseY]);
myShader.setUniform('u_velocity', [velocityX, -velocityY]);
// Pass time for animations within the shader (e.g., color shifts, ripple speed)
myShader.setUniform('u_time', frameCount * 0.01); // frameCount is a p5.js built-in, 0.01 controls time speed
// 3. Draw a rectangle that covers the entire canvas
// This triggers the fragment shader for every pixel on the screen
rect(0, 0, width, height);
}
Line-by-line explanation (11 lines)
🔧 Subcomponents:
calculation
Mouse Velocity Calculation
let dx = mouseX - prevMouseX;
Finds how far the mouse moved since last frame, the raw input driving the ripple strength.
calculation
Velocity Smoothing
velocityX = lerp(velocityX, dx, 0.1);
Smooths the jumpy per-frame delta into a gradually changing velocity value so ripples ease in and out.
let dx = mouseX - prevMouseX;
- Calculates how far the mouse moved horizontally since the last frame.
let dy = mouseY - prevMouseY;
- Calculates how far the mouse moved vertically since the last frame.
velocityX = lerp(velocityX, dx, 0.1);
- Blends the old velocity with the new delta by 10%, smoothing sudden jumps and letting velocity decay gradually toward zero when the mouse stops.
velocityY = lerp(velocityY, dy, 0.1);
- Same smoothing applied to the vertical velocity component.
prevMouseX = mouseX;
- Stores the current mouse X so next frame's delta calculation is accurate.
prevMouseY = mouseY;
- Stores the current mouse Y for the same reason.
myShader.setUniform('u_resolution', [width, height]);
- Uploads the canvas size to the shader so it can convert pixel coordinates into a normalized -1 to 1 range.
myShader.setUniform('u_mouse', [mouseX, height - mouseY]);
- Sends the mouse position to the shader, flipping the Y axis because WebGL's coordinate system has Y increasing upward while screen coordinates have Y increasing downward.
myShader.setUniform('u_velocity', [velocityX, -velocityY]);
- Sends the smoothed velocity to the shader (also Y-flipped) so the ripple strength can be driven by how fast the mouse is moving.
myShader.setUniform('u_time', frameCount * 0.01);
- Sends an ever-increasing time value to the shader, used to animate ripples and color shifts independent of mouse movement.
rect(0, 0, width, height);
- Draws a single rectangle covering the entire canvas. Because the shader is active, the GPU runs the fragment shader once for every pixel inside this rectangle - this is how the whole screen gets shaded.
🔬 This caps how strong the ripples can get, no matter how fast you flick the mouse. What happens if you raise the clamp's upper limit from 0.5 to 2.0 - does the surface still look like a smooth sphere, or does it start to break apart?
float velLen = length(u_velocity) * 0.01; // Scale velocity for appropriate ripple strength
velLen = clamp(velLen, 0.0, 0.5); // Cap the ripple strength to a maximum value
float df(vec3 p) {
float sphereRadius = 1.0;
float baseDist = sdSphere(p, sphereRadius); // Base sphere shape
// Mouse velocity determines the strength of the ripples
float velLen = length(u_velocity) * 0.01; // Scale velocity for appropriate ripple strength
velLen = clamp(velLen, 0.0, 0.5); // Cap the ripple strength to a maximum value
// Apply the ripple deformation
float rippleDist = rippleSDF(p, velLen, 2.0); // strength, speed
return baseDist + rippleDist; // Combine sphere and ripple SDFs
}
Line-by-line explanation (6 lines)
🔧 Subcomponents:
calculation
Velocity-to-Ripple-Strength Mapping
float velLen = length(u_velocity) * 0.01;
Converts raw mouse velocity magnitude into a small usable ripple strength value.
calculation
Ripple Strength Clamp
velLen = clamp(velLen, 0.0, 0.5);
Prevents extremely fast mouse movement from creating an unstable or exaggerated ripple.
float sphereRadius = 1.0;
- Sets the base radius of the chrome sphere.
float baseDist = sdSphere(p, sphereRadius);
- Computes the distance to a perfect, undeformed sphere - this is the starting shape.
float velLen = length(u_velocity) * 0.01;
- Takes the magnitude (speed, ignoring direction) of the mouse velocity uniform and scales it down to a usable range for ripple strength.
velLen = clamp(velLen, 0.0, 0.5);
- Restricts the ripple strength to between 0 and 0.5 so extremely fast mouse flicks don't break the surface deformation.
float rippleDist = rippleSDF(p, velLen, 2.0);
- Calls the ripple function using the velocity-based strength and a fixed speed of 2.0 for the wave's animation rate.
return baseDist + rippleDist;
- Adds the ripple deformation on top of the base sphere distance, combining the two into one final surface that the raymarcher will trace.
🔬 These two patterns control how the colors band across the sphere. What happens if you make pattern1 and pattern2 use the same frequency (both 10.0) instead of 10.0 and 8.0?
float pattern1 = sin(u * 10.0 + u_time * 0.1) * 0.5 + 0.5;
float pattern2 = cos(v * 8.0 + u_time * 0.05) * 0.5 + 0.5;
vec3 fakeEnvReflection(vec3 refDir) {
refDir = normalize(refDir); // Ensure reflection direction is a unit vector
// Convert reflection direction to spherical coordinates for pattern generation
float u = atan(refDir.z, refDir.x) / TWO_PI + 0.5;
float v = asin(refDir.y) / PI + 0.5;
// Create various patterns using sin/cos functions
float pattern1 = sin(u * 10.0 + u_time * 0.1) * 0.5 + 0.5;
float pattern2 = cos(v * 8.0 + u_time * 0.05) * 0.5 + 0.5;
// Define base colors for the chrome reflection (blues and purples)
vec3 color1 = vec3(0.1, 0.2, 0.5); // Deep blue
vec3 color2 = vec3(0.4, 0.2, 0.6); // Purple
vec3 color3 = vec3(0.7, 0.8, 0.9); // Light metallic sheen
// Mix colors based on patterns to create the reflective effect
vec3 reflectionColor = mix(color1, color2, pattern1);
reflectionColor = mix(reflectionColor, color3, pattern2 * 0.5);
// Add subtle, time-based color shifts for more dynamism
reflectionColor += sin(u_time * 0.2) * vec3(0.05, 0.0, 0.05); // Subtle purple shift
reflectionColor += cos(u_time * 0.15) * vec3(0.0, 0.05, 0.05); // Subtle cyan shift
return reflectionColor;
}
Line-by-line explanation (13 lines)
🔧 Subcomponents:
calculation
Spherical Coordinate Conversion
float u = atan(refDir.z, refDir.x) / TWO_PI + 0.5;
Converts a 3D reflection direction into 2D (u,v) coordinates so patterns can be generated across it, similar to how a texture wraps around a sphere.
calculation
Layered Color Mix
vec3 reflectionColor = mix(color1, color2, pattern1);
Blends three base colors together using the generated sine/cosine patterns to fake a reflective environment without any actual image texture.
refDir = normalize(refDir);
- Ensures the reflection direction vector has length 1, which is required for correct spherical coordinate math.
float u = atan(refDir.z, refDir.x) / TWO_PI + 0.5;
- Computes a horizontal 'longitude' coordinate from the reflection direction, mapped into the 0-1 range.
float v = asin(refDir.y) / PI + 0.5;
- Computes a vertical 'latitude' coordinate from the reflection direction, also mapped into 0-1.
float pattern1 = sin(u * 10.0 + u_time * 0.1) * 0.5 + 0.5;
- Generates a repeating stripe-like pattern across the u coordinate, animated slowly over time, and rescaled from -1..1 into 0..1.
float pattern2 = cos(v * 8.0 + u_time * 0.05) * 0.5 + 0.5;
- Generates a second, differently-spaced pattern across the v coordinate for extra visual variation.
vec3 color1 = vec3(0.1, 0.2, 0.5);
- Defines a deep blue base color used in the reflection.
vec3 color2 = vec3(0.4, 0.2, 0.6);
- Defines a purple base color.
vec3 color3 = vec3(0.7, 0.8, 0.9);
- Defines a light, near-white metallic highlight color.
vec3 reflectionColor = mix(color1, color2, pattern1);
- Blends between blue and purple using pattern1 as the mix ratio, creating a shifting gradient.
reflectionColor = mix(reflectionColor, color3, pattern2 * 0.5);
- Blends in the light metallic color using pattern2, but only up to 50% strength, to add sheen without washing out the blue/purple tones.
reflectionColor += sin(u_time * 0.2) * vec3(0.05, 0.0, 0.05);
- Adds a small, slowly oscillating purple tint over time for extra life in the color.
reflectionColor += cos(u_time * 0.15) * vec3(0.0, 0.05, 0.05);
- Adds a small, slowly oscillating cyan tint over time as well.
return reflectionColor;
- Returns the final procedurally-generated 'environment map' color for this reflection direction.
🔬 This loop is what actually 'renders' the 3D sphere by marching a ray toward it. What happens visually if MAX_STEPS (defined near the top of the shader) is dropped very low, like 10? Do you start to see banding or gaps in the surface?
for (int i = 0; i < MAX_STEPS; i++) {
float dist = df(currentPos); // Get distance to the object from current ray position
totalDist += dist; // Accumulate total distance
currentPos += rayDir * dist; // Move the ray forward by the distance
void main() {
// Convert vTexCoord (0-1) to fragment coordinates (0-resolution)
vec2 fragCoord = vTexCoord * u_resolution;
vec2 uv = fragCoord / u_resolution;
uv = uv * 2.0 - 1.0; // Map to -1 to 1 range
// Adjust for aspect ratio
uv.x *= u_resolution.x / u_resolution.y;
// Raymarching setup
vec3 camPos = vec3(0.0, 0.0, -2.0); // Camera position slightly back along Z-axis
vec3 rayDir = normalize(vec3(uv, 1.0)); // Direction of the ray from camera through current pixel
vec3 currentPos = camPos; // Start ray at camera position
float totalDist = 0.0; // Total distance traveled by the ray
bool hit = false; // Flag to check if the ray hit the object
// Raymarching loop
for (int i = 0; i < MAX_STEPS; i++) {
float dist = df(currentPos); // Get distance to the object from current ray position
totalDist += dist; // Accumulate total distance
currentPos += rayDir * dist; // Move the ray forward by the distance
if (dist < EPSILON) { // If distance is very small, we've hit the object
hit = true;
break;
}
if (totalDist > MAX_DIST) { // If ray traveled too far, it missed the object
break;
}
}
vec3 finalColor = vec3(0.05, 0.05, 0.1); // Dark background color
if (hit) {
vec3 N = calcNormal(currentPos); // Calculate normal at the hit point
vec3 R = reflect(rayDir, N); // Calculate reflection vector
vec3 reflectionColor = fakeEnvReflection(R); // Get the reflective color
// Fresnel effect: Makes surfaces more reflective at grazing angles
float fresnel = pow(1.0 + dot(rayDir, N), 5.0);
fresnel = clamp(fresnel, 0.0, 1.0); // Clamp fresnel to 0-1 range
// Mix reflection color with a brighter color based on fresnel for edge glow
finalColor = mix(reflectionColor, vec3(1.0), fresnel * 0.5);
}
gl_FragColor = vec4(finalColor, 1.0); // Set the final pixel color
}
Line-by-line explanation (17 lines)
🔧 Subcomponents:
for-loop
Raymarching Loop
for (int i = 0; i < MAX_STEPS; i++) { ... }
Repeatedly steps a ray forward through the scene, checking how close it is to the surface until it either hits the object or travels too far and misses.
conditional
Surface Hit Coloring
if (hit) { ... }
Only runs the lighting/reflection math if the ray actually reached the chrome surface - otherwise the pixel stays the dark background color.
vec2 fragCoord = vTexCoord * u_resolution;
- Converts the 0-1 texture coordinate into actual pixel coordinates (0 to canvas width/height).
uv = uv * 2.0 - 1.0;
- Remaps the 0-1 UV coordinates into a -1 to 1 range, centering the origin in the middle of the screen.
uv.x *= u_resolution.x / u_resolution.y;
- Corrects for non-square canvases so the sphere doesn't look stretched on wide or tall screens.
vec3 camPos = vec3(0.0, 0.0, -2.0);
- Places a virtual camera 2 units back along the Z-axis, looking toward the origin where the sphere sits.
vec3 rayDir = normalize(vec3(uv, 1.0));
- Builds a direction vector from the camera through this specific pixel, forming one ray per pixel.
for (int i = 0; i < MAX_STEPS; i++) {
- Begins the raymarching loop, which will run up to MAX_STEPS times to find where the ray intersects the surface.
float dist = df(currentPos);
- Asks the distance field how far the current ray position is from the sphere's (rippled) surface.
currentPos += rayDir * dist;
- Advances the ray forward by exactly that distance - a safe jump since we know nothing is closer than 'dist' units away.
if (dist < EPSILON) {
- If the ray gets extremely close to the surface, we consider it a hit and stop marching.
if (totalDist > MAX_DIST) {
- If the ray has traveled very far without hitting anything, it's missed the object entirely, so we stop marching.
vec3 finalColor = vec3(0.05, 0.05, 0.1);
- Sets a dark navy default color for pixels where the ray never hits the sphere (the background).
vec3 N = calcNormal(currentPos);
- Calculates the surface normal at the exact point the ray hit, needed for lighting and reflection.
vec3 R = reflect(rayDir, N);
- Computes the reflection direction of the incoming ray off the surface, like light bouncing off a mirror.
vec3 reflectionColor = fakeEnvReflection(R);
- Looks up a procedural 'environment' color based on the reflection direction, giving the chrome its colorful look.
float fresnel = pow(1.0 + dot(rayDir, N), 5.0);
- Calculates the Fresnel term, which is near zero when looking straight at the surface and grows toward 1 at grazing angles - mimicking how real reflective materials get shinier at their edges.
finalColor = mix(reflectionColor, vec3(1.0), fresnel * 0.5);
- Blends the reflection color with pure white based on the Fresnel value, creating a bright rim-light effect around the edges of the sphere.
gl_FragColor = vec4(finalColor, 1.0);
- Outputs the final computed color for this pixel, fully opaque.