Signed Distance Functions (SDF) - Basics Using 2D Examples
SDF functions (or Signed Distance Functions) are special functions that tell you how far you are from a shape. Imagine drawing a big circle on the ground (or any other shape) - the sdf function tells you how far you are from the surface of that shape!
As you get closer to the shape the distance gets smaller and as you move away the distance gets bigger. If you go inside the circle, it gives a negative number to show you're inside. This can be really useful for graphics and physics simulations (quick and easy way to calculate distances).
A signed distance function takes a point and returns the distance. The point represents the position in the world and the distance returned is the value (positive or negative) amount that you're from the shape - positive if it's outside the shape and negative if it's inside the surface.
@fragment fn main(@location(0) uv : vec2<f32>) -> @location(0) vec4<f32> { let nuv = ( uv * 2.0 - 1.0 ); let p = vec3<f32>( nuv, 0.0 );
if ( sdfSphere( p , vec3(0.0, 0.0, 0.0), 0.5 ) < 0.0 ) { return vec4<f32>(1, 0, 0, 1); // red } return vec4<f32>(0, 0, 1, 1); // white }
SDF Fields
When working with SDF functions - instead of just working with true/false for inside or outside - we can use the distance value for visualizing what is happeing.
These distance values form what is known as a signed distance field - whihc we'll in numerous 2d examples to help explain and visualize what is happening.
Color Gradients
A first approach is to use colors - with blue for outside and red for inside the shape - and of course, green (which means neutral) is for values close to or equal to zero. We can draw the sdf gradient field using a color gradient function - blending between blue and red, as shown below for a simple sphere (or circle in 2d).
fn colorGradient(value: f32) -> vec3<f32> { // scale the values to emphasis colors around the 0.0 var v = pow( abs(value) ,0.2) * sign( value );
// Clamp the input value between -1 and 1 v = clamp(v, -1.0, 1.0);
// Define color stops let red = vec3<f32>(1.0, 0.0, 0.0); // -1.0 let orange = vec3<f32>(1.0, 0.5, 0.0); // -0.5 let white = vec3<f32>(1.0, 1.0, 1.0); // 0.0 let green = vec3<f32>(0.0, 1.0, 0.0); // 0.5 let blue = vec3<f32>(0.0, 0.0, 1.0); // 1.0
// Calculate the color based on value ranges if v < -0.5 { // Blend from red (-1.0) to orange (-0.5) return mix(red, orange, (v + 1.0) / 0.5); } else if v < 0.0 { // Blend from orange (-0.5) to white (0.0) return mix(orange, white, (v + 0.5) / 0.5); } else if v < 0.5 { // Blend from white (0.0) to green (0.5) return mix(white, green, v / 0.5); } else { // Blend from green (0.5) to blue (1.0) return mix(green, blue, (v - 0.5) / 0.5); } }
Now color gradients are good - but they make it difficult to see the wave as they travel outwards from the shape - sort of like ripples on a pond. So instead of a solid color, we'll draw bands using a sine function. This causes the sdf value to oscillate between -1 to 1 - with the amplitude of the field represented by closer or wider color bands.
As we don't usually have negative color values - we take the
abs(..)
of the sine function - also the magitude of the sdf function can be small - so we scale it up a bit (e.g., multiply it by 50.0).
You get sdf gradient rings for the sdf field as follows:
if ( abs(d) < 0.01 ) { color = vec3<f32>(1.0, 0.0, 0.0); }
return vec4<f32>(color.xyz, 1.0); };
Inside or Outside
While the gradient field helps to show the distances from the shape surface - it can be difficult to tell what is inside and what is outside of the shape - especially when we start adding and subtractig sdf functions.
To help make this easier, we update the visualization to color the inside of the shape a yellowish color.
// if inside the shape - mix inside color if ( abs(d) < 0.005 ) { color = vec3<f32>(0.0, 0.8, 0.0); }
// if on the surface - draw solid line if ( d < 0.01 ) { color *= vec3<f32>(1.1, 1.0, 0.0); }
return vec4<f32>(color.xyz, 1.0); };
Shapes
Very easy to create basic shapes with sdf functions, such as the cube and sphere - we'll go through some common ones here. Be aware, as you'll see in a bit, you can mix and match shapes to create complex structures (e.g., cogs and trees) using sdf unions (addition, difference and so on).
When we have two or more sdf functions - we need to add the result together - so we instead of two sdf outputs - they're combined into a single one! Single floating point distance value.
We can't just add the values together - instead we need to use the min(..) function to get the minimum value - this creates a new sdf function that's a combination of both. We can do this repeatedly adding more and more sdf functions together.
To make life easier - we call it the sdf union function:
// add together - called it sdfUnion - but could also be called 'sdfAdd(..)' or 'sdfCombine' fn sdfUnion(a:f32, b:f32) -> f32 { return min(a, b); }
Let's add a few more function definitions for some other shapes and throw them on screen.
fn sdfCapsule(testPoint: vec3<f32>, capsuleA: vec3<f32>, capsuleB: vec3<f32>, capsuleRadius: f32) -> f32 { let pa = testPoint - capsuleA; let ba = capsuleB - capsuleA; let h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0); return length(pa - ba * h) - capsuleRadius; }
fn sdfCone(testPoint: vec3<f32>, coneTip: vec3<f32>, coneDir: vec3<f32>, coneAngle: f32) -> f32 { let q = testPoint - coneTip; let cosTheta = cos(coneAngle); let sinTheta = sin(coneAngle); let d = vec2<f32>( length(q - coneDir * dot(q, coneDir)), abs(dot(q, coneDir)) ); return max(d.x * cosTheta - d.y * sinTheta, -dot(q, coneDir)); }
We'll only draw the cube, sphere and torus (aka donut) - as we're looking at things 2d at the moment - so most of the shapes will look the same (e.g., squares or just circles).
if ( abs(d) < 0.005 ) { color = vec3<f32>(0.0, 0.8, 0.0); } // if inside - use red rings for the gradients if ( d < 0.01 ) { color *= vec3<f32>(1.1, 1.0, 0.0); }
return vec4<f32>(color.xyz, 1.0); };
Union Operations
There is more than one way to combine sdf shapes - you can add them subtract them or even xor the shape overlaps!
The two most common union operations are the union add and the union difference.
// subtract one from the other fn sdfDifference(a:f32, b:f32) -> f32 { return max(a, -b); }
Then we have the
sdfIntersection(..)
which keeps only the overlapping parts.
// Keep only the overlapping intersection parts fn sdfIntersection(a:f32, b:f32) -> f32 { return max(a, b); }
We can take it further with an XOR function - which keeps only the non-overlapping parts - and removes any overlapping pieces.
// keeps the parts that are NOT overlapping fn sdfXORDifference(a: f32, b: f32) -> f32 { return max(min(a, b), min(-a, -b)); }
Smooth Union Operations
We can make each of the union operations smoother - round the edges or make the transitions softer.
fn smoothunion(d1:f32, d2:f32, k:f32/*=1.0*/) -> f32 { let h = max(k - abs(d1 - d2), 0.0) / k; return min(d1, d2) - h * h * k * 0.25; }
We also have a smoothdifference - when we difference two sdf functions.
fn smoothdifference(d1:f32, d2:f32, k:f32/*=1.0*/) -> f32 { let h = max(k - abs(d1 - d2), 0.0) / k; return max(d1, -d2) - h * h * k * 0.25; }
fn smoothintersection(d1:f32, d2:f32, k:f32/*=1.0*/) -> f32 { let h = max(k - abs(d1 - d2), 0.0) / k; return max(d1, d2) + h * h * k * 0.25; }
Adding Noise
We can mix in a bit of smooth noise - for example, for the sphere - instead of it having a fixed radius - we use the smooth noise function.
fn randomsmooth( st:vec2<f32> ) -> f32 { var i = floor( st * 3.0 ); // uv - 0, 1, 2, 3, var f = fract( st * 3.0 ); // uv - 0-1, 0-1, 0-1, 0-1
// Four corners in 2D of a tile var a = random(i); var b = random(i + vec2<f32>(1.0, 0.0)); var c = random(i + vec2<f32>(0.0, 1.0)); var d = random(i + vec2<f32>(1.0, 1.0));
// Ease-in followed by an ease-out (tweening) for f // f = 0.5 * (1.0 - cos( 3.14 * f ) ); // version without cos/sin // f = 3*f*f - 2*f*f*f;
f = 3*f*f - 2*f*f*f;
// bilinear interpolation to combine the values sampled from the // four corners (a,b,c,d), resulting in a smoothly interpolated value.
// Interpolate Along One Axis - interpolate between `a` and `b` using the fractional coordinate `f.x`, // then interpolate between `c` and `d` using the same `f.x`. // This gives two intermediate values, say `e` and `f`.
// Interpolate Along the Other Axis - linearly interpolate between `e` and `f` // using the fractional coordinate `f.y`. // Final interpolation gives a moothly interpolated value across the square
var x1 = mix( a, b, f.x ); var x2 = mix( c, d, f.x );
Add the noise in different ways - using unions, differences and intersections (sphere and cube example).
Splines
fn sdfBezierQuadratic(testPoint: vec3<f32>, p0: vec3<f32>, p1: vec3<f32>, p2: vec3<f32>, radius: f32) -> f32 { let maxSteps = 64; // Number of steps for approximation var minDist = 99999.0;
// Sample the Bézier curve at discrete points for (var i:i32 = 0; i <= maxSteps; i += 1) { let t = f32(i) / f32(maxSteps); let curvePoint = mix(mix(p0, p1, t), mix(p1, p2, t), t); // Quadratic Bézier interpolation let distToCurve = length(testPoint - curvePoint); minDist = min(minDist, distToCurve); }
// Subtract the curve's radius return minDist - radius; }
fn sdfScene( testPoint:vec3<f32> ) -> f32 { let d = sdfBezierQuadratic(testPoint, vec3<f32>(0,0,0), vec3<f32>(-0.9, 0.9, 0), vec3<f32>(0.7, 0.5, 0), 0.1);
return d; }
fn sdfBezierCubic(testPoint: vec3<f32>, p0: vec3<f32>, p1: vec3<f32>, p2: vec3<f32>, p3: vec3<f32>, radius: f32) -> f32 { let maxSteps = 64; // Number of steps for approximation var minDist = 99999.0;
// Sample the Bézier curve at discrete points for (var i:i32 = 0; i <= maxSteps; i += 1) { let t = f32(i) / f32(maxSteps); let a = mix(p0, p1, t); let b = mix(p1, p2, t); let c = mix(p2, p3, t); let d = mix(a, b, t); let curvePoint = mix(d, c, t); // Cubic Bézier interpolation let distToCurve = length(testPoint - curvePoint); minDist = min(minDist, distToCurve); }
// Subtract the curve's radius return minDist - radius; }
As the splines are generated using iterations - go along the bezier spline using small points - we can look show the iterations.
More SDF Noise
We can clamp the influence of the noise to a maximum radius - so it doesn't cause small ripples to propogate throughout the noise field - limiting noise to within a specific region. So for the sphere - we modified the radius - we limit the influence of the radius to a maximum of 1.0.
fn sdfScene( testPoint:vec3<f32> ) -> f32 { var d = mandelboxSDF( testPoint ); return d; }
Repeated folds and distortions (structures of repeating and mirroring sdf patterns)
We can use mathematical transformations to encode complex shapes and patterns into simple equations. Repeated folds and distortions are key techniques in creating intricate structures like fractals. The box fold (`p = abs(p) - vec3(1.0);`) reflects all points into the positive quadrant of space, ensuring symmetry about each axis. For example, a point at `(-2.0, 0.5, -1.5)` becomes `(2.0, 0.5, 1.5)`, effectively mirroring space.
This operation generates sharp edges and a repeating symmetry characteristic of fractal patterns, particularly when applied recursively or in combination with other operations. After folding, subtracting `vec3(1.0)` offsets the space, adding further variation by centering the transformations around a different origin.
The space repetition transformation (`p = (p % scale) - scale / 2.0;`) introduces periodicity by remapping the position into a repeating cell structure. It achieves this by taking the modulus of each coordinate with respect to the `scale`, which ensures the position remains bounded within a range. For example, if `scale = 4.0`, a point at `p = (5.5, -3.2, 8.1)` maps to `(1.5, -3.2, 0.1)` after wrapping around.
Subtracting `scale / 2.0` centers each cell around the origin, creating a uniform tiling effect in space. When combined with box folds, this repetition builds a fractal-like appearance by breaking the space into self-similar patterns, while maintaining distance calculations for rendering or simulation purposes.
e.g.
p = abs(p) - vec3<f32>(1.0); // Fold operation p = (p % scale) - scale / 2.0; // Space repetition
Box Fold Example
This example uses box folding to create a symmetrical structure around the origin.
// Sphere SDF after fold let d = sphereSDF(foldedP, vec3(0.0), 1.7); // Sphere
return d; }
Space Repetition Example
This example shows a grid of spheres created using space repetition.
fn sdfScene( testPoint:vec3<f32> ) -> f32 { var p = testPoint;
let sepdist = 1.0 + abs( sin(mytimer*4.0) ); // animate the spheres moving appart and together
p = vec3<f32>( sign(p.x) * (p.x % sepdist) - sepdist/2, p.y, p.z ); // repeat in the x only
var d = sdfSphere(p, vec3(0.0, 0.0, 0.0), 0.5);
return d; }
Flower (with Petals)
Flower with adjustable petals - simple sdf function for 2D flower shape in the x-y plane with radial symmetry for the petals
fn flowerSDF(p: vec2<f32>, petals: f32, radius: f32, petalSharpness: f32) -> f32 { // Convert position to polar coordinates let r = length(p); let theta = atan2(p.y, p.x);
// Create petal modulation based on angle let petalFactor = cos(theta * petals) * 0.5 + 0.5; // 0 to 1
// Define the flower shape let petalRadius = radius + petalFactor * petalSharpness;
// SDF of the flower return r - petalRadius; }
// ---------------------------------
fn sdfScene( testPoint:vec3<f32> ) -> f32 { var d = flowerSDF( testPoint.xy, 5.0, 1.0, 0.9 );
return d; }
You can make the flowerSDF a 3d version with a couple of extra lines. However, in 2d it looks the same (only if you draw it in 3d).
fn flower3DSDF(p: vec3<f32>, petals: f32, radius: f32, petalSharpness: f32) -> f32 { let flowerDist = flowerSDF(p.xy, petals, radius, petalSharpness); // 2D flower in x-y plane let zDist = abs(p.z) - 0.1; // Thickness along z-axis return max(flowerDist, zDist); // Combine into a 3D shape }
Ray-Marching (Ray-Tracing) with Lines
You can ray-trace an SDF scene - by shooting a ray into the scene and calculating the intersection point. To demonstrate this - let's draw some ray lines and plot their trajectory through the scene.
This includes both reflections and refections as they bounce-off and travel through shapes in the scene.
•
reflect(..)
function to calculate the reflection direction from the intersection point
•
refract(..)
function to calculate the refraction direction through the object (which uses Snell's law for bending due to the material density change)
To make things more interesting - we animate the fov for the rays - so you can see the reflection/refraction vectors change dynamically.
We draw the normals and the internal rays (travel through) the object in gray.
The following gives the raymarching code - which finds the intersection point for a ray with the sdf scene.
• Handle inside rays by using an inside flag to invert the sdf value
• WGSL does not support recursion - so a simple stack is created to emulate recursive nature of rays creating rays when colliding.
let d1 = sdfSphere( testPoint, vec3(1.4, -1.0, 0.0), 0.6 );
let d = sdfUnion( d0, d1 );
return d; }
fn raymarch(ro: vec3<f32>, rd: vec3<f32>, maxSteps: i32, maxDist: f32, inside: f32) -> f32 { var t = 0.0; // Distance traveled along the ray var epsilon: f32 = 0.001; for (var i = 0; i < maxSteps; i = i + 1) { let p = ro + rd * t; // Current position along the ray let d = sdfScene(p) * inside; // Distance to the scene's nearest surface
if (d < epsilon) { // Hit detected return t; // Return the distance to the hit }
if (t > maxDist) { // Exit if the ray goes too far break; }
t += d; // Advance by the SDF value }
return -1.0; // No hit }
// Check if the pixel lies on the line between ray origin and hit point fn drawLine(uv: vec2<f32>, startpoint: vec3<f32>, endpoint: vec3<f32>) -> bool { let thickness:f32 = 0.009; // Convert uv (which is 2D) into a 3D point by using the z component from the startpoint. let pixelPos = vec3<f32>(uv.x, uv.y, startpoint.z);
// Direction of the line from startpoint to endpoint let lineDir = normalize(endpoint - startpoint);
// Vector from the startpoint to the pixel position let toPixel = pixelPos - startpoint;
// Compute the perpendicular distance from the point to the line let perpDist = length(cross(toPixel, lineDir)) / length(lineDir);
// Compute the projection of the point onto the line let projection = dot(toPixel, lineDir) / length(lineDir);
// Compute the distance from the startpoint to the endpoint (line segment length) let lineLength = length(endpoint - startpoint);
// Check if the perpendicular distance is within the thickness // and if the projection is between 0 and the length of the line segment return (perpDist <= thickness) && (projection >= 0.0) && (projection <= lineLength); }
// Function to compute the normal of an SDF at a given point fn sdfNormal(p: vec3<f32>) -> vec3<f32> { let epsilon = 0.001; // Small step for numerical differentiation let dx = vec3<f32>(epsilon, 0.0, 0.0); let dy = vec3<f32>(0.0, epsilon, 0.0); let dz = vec3<f32>(0.0, 0.0, epsilon);
// Approximate gradient using central differences let normal = vec3<f32>( sdfScene(p + dx) - sdfScene(p - dx), sdfScene(p + dy) - sdfScene(p - dy), sdfScene(p + dz) - sdfScene(p - dz) );
return normalize(normal); // Normalize the gradient to get the unit normal }
if ( abs(d) < 0.01 ) { color = vec3<f32>(0.0, 0.8, 0.0); } // if inside - use red rings for the gradients if ( d < 0.01 ) { color *= vec3<f32>(1.1, 1.0, 0.0); }
// --------------------------------------- // Ray-line tracer using 'stack' // // WGSL does not support recursive functions - however, we can emulate this // by adding out own little stack - push/pop information onto it as needed.
var rayStackCount:i32 = 0; var rayStack : array< RayStack, 32 >;
for (var n:i32=-4; n<=4; n++) { let dy = ( 0.1 + sin(mytimer*10.0)*0.05 )*f32(n);
let refractdir = refract( rd, normal, 1.0/1.2 ); // refract in { let ro2 = hitpoint - normal*0.01; let rd2 = refractdir; let hitDist2 = raymarch( ro2, rd2, 32, 4.0, -1.0 ); if ( hitDist2 > 0.0 ) { let hitpoint2 = ro2 + rd2*hitDist2; if (drawLine(nuv, ro2, hitpoint2)) { color = vec3<f32>(0.5, 0.5, 1.0); }
let normal2 = sdfNormal( hitpoint2 ); let rd3 = refract( rd2, -normal2, 1.2/1.0 ); // refract out let ro3 = hitpoint2 + normal2*0.01;
if (drawLine(nuv, ro3, ro3+normal2*0.3 )) { color = vec3<f32>(0.0, 0.0, 1.0); }
rayStack[ rayStackCount ].ro = ro3; rayStack[ rayStackCount ].rd = rd3; rayStackCount++; } } } else // no hit { if (drawLine(nuv, ro, ro+rd*2.0 )) { color = vec3<f32>(0.3, 0.0, 0.0); } } }// end for i
}// end for n
return vec4<f32>(color.xyz, 1.0); };
Next Comes Color!
We have shapes and we have gradients - and we're using 'color' to display information (e.g., inside, outside, edges and the field ripples). But each shape does not have its own color! For example, if we have two spheres or three spheres on screen - how do we assign a color to them? As at the moment, the
sdfScene(..)
function only returns a single parameter - a floating point number which represents the distance. There isn't any other information to help us identify which point belongs to which shape.
Now to fix this problem and add color information for each shape - we need to step back and modify our sdf functions - so they return extra information - a material as well as a distance. I say material and not just a color - as we can add lots of extra stuff inside the material, such as, reflection and refraction factors.
• Each SDF function returns both the distance and an identifier (materialId) for the shape.
• The scene function (sdfScene) determines which shape is closest to the given point and returns the corresponding distance and material ID.
Let's update the example sdf functions we've discussed upto now - but modify them to work with an
SDFResult
structure - which contains the distance and material.
struct Material { color: vec3<f32> // just a basic color };
struct SDFResult { distance: f32, // Signed distance value material: Material // Identifier for the shape };
let sdfResult = sdfScene( point ); let d = sdfResult.distance; let c = sdfResult.material.color;
var color = vec3( abs(sin(d*30.0)) );
if ( abs(d) < 0.02 ) { color = vec3<f32>(0.0, 0.8, 0.0); } // if inside - use red rings for the gradients if ( d < 0.0 ) { color *= c;//color *= vec3<f32>(1.1, 1.0, 0.0); } return vec4<f32>(color, 1.0); }
Back to Ray-Marching (With Colors)
Now that we've updated things so sdf function returns both a distance and a material color - we can integrate them into the simple ray-marching line algorithm we did earlier. So instead of using some constant colors for the rays (e.g., red for the primary ray).
When the line hits a red shape - the line turns red - so we can see a better picture of what's happening.
let point = vec3(nuv, 0.0); let sdfResult = sdfScene(point);
var color = vec3(abs(sin(sdfResult.distance * 30.0)));
if (abs(sdfResult.distance) < 0.01) { color = vec3<f32>(0.0, 0.8, 0.0); } // If inside - use red rings for the gradients if (sdfResult.distance < 0.01) { //color *= vec3<f32>(1.1, 1.0, 0.0); color *= sdfResult.material.color; }
// --------------------------------------- // Ray-line tracer using 'stack' // // WGSL does not support recursive functions - however, we can emulate this // by adding our own little stack - push/pop information onto it as needed.
var rayStackCount: i32 = 0; var rayStack: array<RayStack, 32>;
for (var n: i32 = -12; n <= 12; n++) { let dy = (0.03 + sin(mytimer * 0.0) * 0.05) * f32(n);
for (var i: i32 = 0; i < 3; i++) { if (rayStackCount <= 0) { break; }
// Pop it off the stack rayStackCount--; let ro = rayStack[rayStackCount].ro; let rd = rayStack[rayStackCount].rd;
let hitResult = raymarch(ro, rd, 32, 4.0, 1.0); if (hitResult.distance > 0.0) { let hitpoint = ro + rd * hitResult.distance; if (drawLine(nuv, ro, hitpoint)) { //color = vec3<f32>(1.0, 0.0, 0.0); // Red color = hitResult.material.color; }
let normal = sdfNormal(hitpoint); if (drawLine(nuv, hitpoint, hitpoint + normal * 0.25)) { color = vec3<f32>(0.0, 0.0, 1.0); }
let reflectdir = reflect(rd, normal); { rayStack[rayStackCount].ro = hitpoint + normal * 0.01; rayStack[rayStackCount].rd = reflectdir; rayStackCount++; }
let ior = 1.2;
let refractdir = refract(rd, normal, ior); // Refract in { let ro2 = hitpoint - normal * 0.01; let rd2 = refractdir; let hitResult2 = raymarch(ro2, rd2, 32, 4.0, -1.0); if (hitResult2.distance > 0.0) { let hitpoint2 = ro2 + rd2 * hitResult2.distance;
if (drawLine(nuv, ro2, hitpoint2)) { color = vec3<f32>(0.5, 0.5, 1.0); }
let normal2 = sdfNormal(hitpoint2); let rd3 = refract(rd2, -normal2, 1.0/ior); // Refract out let ro3 = hitpoint2 + normal2 * 0.01;
if (drawLine(nuv, ro3, ro3 + normal2 * 0.3)) { color = vec3<f32>(0.0, 0.0, 1.0); }
rayStack[rayStackCount].ro = ro3; rayStack[rayStackCount].rd = rd3; rayStackCount++; } } } else { // No hit if (drawLine(nuv, ro, ro + rd * 2.0)) { color = vec3<f32>(0.3, 0.0, 0.0); } } } // End for i } // End for n
return vec4<f32>(color.xyz, 1.0); }
Warning about the little function for the ray-marching line implementation - each fragment pixel is doing a lot of work! So be careful not to ramp things up too much - limited the number of lines and recursions - otherwise, it'll either kill your graphics card or crash your browser.
Taste of 3D
The simple scene using ray-machine with line - can be easily modified to use 3d rays instead of 2d lines. For example, the following shows the same scene but rendered in 3d with reflections and refractions (bit of lighting and shadows).
The following gives the body of the code - swapped out the line drawing code for the 2d side view for the 3d rays for each pixel.
So the scene is drawn from the same position - we place the camera on the left side facing right (so it was from the same view as the line we showed earlier).
We also added checkered ground plane to the bottom - to help with reflections - so you can see the reflected ground isntead of just a constant color.
let time = mytimer * 5.0; let cam:Camera = setCamera(nuv, vec3<f32>(-2.5*cos(time), 0, 3.5*sin(time*0.5) ), vec3<f32>( 0,0,0), vec3<f32>(0,1,0), 60.0);
// --------------------------------------- // Ray-line tracer using 'stack' // // WGSL does not support recursive functions - however, we can emulate this // by adding our own little stack - push/pop information onto it as needed.
var rayStackCount: i32 = 0; var rayStack: array<RayStack, 32>;
// 4.0 is to fix a numerical issue - angle is almost parallel var refractdir = refract(rd, normal*3.0, ior); // Refract in // on the edge of the shape (fresnel) if ( length(refractdir) < 0.1 ) { //kr = clamp( 1.0 - fresnelReflectivity(rd, normal, kr), 0.0, 1.0);
} else { // No hit //if (drawLine(nuv, ro, ro + rd * 2.0)) { // color = vec3<f32>(0.3, 0.0, 0.0); //} color += (vec3<f32>(0.2, 0.5, 0.6) + vec3(-rd.y*1.5)) * k; // Dark gray background } } // End for i
return vec4<f32>(color, 1.0); }
There are a few numerical tweaks in the 3d version which wouldn't have ben noticable in the 2d line version - such as, perpendicular and parallel lines for edges of shape surfaces. For example, the dot product of perpendicular vectors is zero, and if you try and normalize a zero - value you'll get an undefined.
We also pass a scaling factor down the stack - as the ray travels through the scene it's scaled by it's energy value (law of energy - sum of the incident, reflection and refraction ray value should sum up to 1.0).
Energy Conservation Law: The sum of the diffuse, reflection, and refraction contributions should not exceed 1.0.
We have the material property values: kd + kr + kt <= 1.0.
kd=diffuse factor, kr=reflection factor and kt is the transmission factor (refraction).
We usually only specify the kr and kt factors - as the kd can be calculated auomatically using: kd = 1.0 − kr − kt.