Sphere–box smooth blend
A sphere slides through a turning box and melts into it, raymarched and lit in one fragment shader.
- 01GoalShape
- 02TechniqueRaymarched 3D shapes
- 03ExecutionSphere–box smooth blend
Parameters
Copy for your platform
- GLSLCopy
- GodotCopy
Node chain
Built step by step, in GLSL style, with its node names.
Built step by step, in Godot style, with its node names.
No Unreal Engine 5 version of this card yet, so the nodes show the GLSL names.
No Unity version of this card yet, so the nodes show the GLSL names.
No Blender version of this card yet, so the nodes show the GLSL names.
normalizeNormalizenormalizenormalizenormalize UVTime01 One ray per pixel
stepExpressionstepstepstep In02 March the ray
normalizeExpressionnormalizenormalizenormalize In03 Normal from the gradient
dotDotProductdotdotdot In04 Diffuse light
mixMixmixmixmix In05 Blend the colors
mixMixmixmixmix In06 Over the background
How it works
There is no mesh in this card. The quad only gives each pixel a place to run, and the shader builds the scene, finds what the pixel sees and lights it, all from one function that measures distance.
The scene is a distance. Signed distance field (SDF) A function that returns how far a point is from the edge of a shape, negative inside and positive outside. Zero is the outline itself.scene() takes a point in 3D and returns how far it is from the nearest surface, the 3D
form of a signed distance fieldA function that returns how far a point is from the edge of a shape, negative inside and positive outside. Zero is the outline itself.circle(p) = |p| − rabs, then measures to that corner’s faces; subtracting a little afterwards rounds
its edges, the same growing trick as the rounded button. The box turns by rotating the point the other way before
measuring, and the sphere slides along x with a sine of time.
Melting them together. Smooth minimum A version of min that rounds the corner where two values cross. Applied to two distance fields it melts the shapes into one, with the blend width set by k.min(box, sphere) would be both shapes with a sharp crease where they meet. The
smooth minimumA version of min that rounds the corner where two values cross. Applied to two distance fields it melts the shapes into one, with the blend width set by k.h = clamp(0.5 + 0.5 (b − a) / k, 0, 1); smin = mix(b, a, h) − k h (1 − h)h goes from one shape to the other and the extra - smoothness * h * (1 - h) term pulls the surface outward into a
fillet. Far from the joint h is 0 or 1 and each shape keeps its exact distance. The same h says how much of each
shape a point belongs to, so it also blends the two colors.
One ray per pixel. The pixel’s centered, aspect-corrected position becomes a direction from a camera above and in front of the shapes. The camera’s three axes (forward to the origin, right, up) turn that 2D position into a 3D ray; the 1.8 on the forward axis is the lens, and a larger number zooms in.
Marching the ray. Sphere tracingDrawing a scene by sending a ray through each pixel and stepping along it by the distance to the nearest surface, read from a signed distance function, until it hits. Raymarching Drawing a scene by sending a ray through each pixel and stepping along it by the distance to the nearest surface, read from a signed distance function, until it hits.
The normal from the gradient. The distance grows fastest straight away from the surface, so its gradient points
along the normalA unit vector perpendicular to a surface, pointing the way it faces. Lighting and Fresnel effects compare it with other directions. Normal A unit vector perpendicular to a surface, pointing the way it faces. Lighting and Fresnel effects compare it with other directions.scene(), a tiny step either side on each axis, give the
gradient, and normalize makes it a unit normal.
Light and color. The dot productMultiplies two vectors component by component and adds the results. For unit vectors it is the cosine of the angle between them, from 1 (same direction) to -1 (opposite). Dot product Multiplies two vectors component by component and adds the results. For unit vectors it is the cosine of the angle between them, from 1 (same direction) to -1 (opposite).dot(a, b) = |a| |b| cos θhit picks between the lit color and the
background.
What it costs. Each scene() call is about 34 operations. Most pixels, hit or miss, finish the march in about ten
steps: a ray that misses takes longer steps once it is past the shapes and is beyond 20 units quickly. The worst case is
a ray grazing an edge, which runs all 96 steps (about 3,550 operations), and the normal and the color add 7 more calls,
near 3,830 operations per pixel, against a dozen for the Soft dot. Every shape added to scene() makes every step
dearer, since every step evaluates all of them. A bounding test before the loop lets pixels far from the shapes skip the
march entirely, and is the first optimization in a real project.
Make it in GLSL
The copied code is a complete WebGL2 fragment shader. The scene, camera and light all live in it, so the page only has to cover the canvas and keep time.
- Draw two triangles covering the canvas in clip space, with a vertex shader that writes
v_uvfrom 0 to 1 and constants forv_position,v_normalandv_view(the fragment shader declares all four). - Every frame, set
u_timeto the seconds since you started andu_resolutionto the drawing buffer’s size. - Set
p_smoothnessandp_speed, and uploadp_sphereColor,p_boxColorandp_backgrounddecoded from sRGB to linear. - Draw with blending off from a
requestAnimationFrameloop, and stop the loop while the canvas is off screen (anIntersectionObservertells you): unlike most cards, this one is heavy enough to matter.
To add shapes, write their distance functions next to sdSphere and sdBox and combine them inside scene() with
min or the smooth minimum. The march, the normal and the lighting don’t change.
Make it in Godot
The shipped shader assumes HDR 2D is off (the default). With it on, make u_toLinear and u_toSrgb return their input unchanged. A VisualShader can't express the loop except inside one Expression node holding the same code, so the text shader is the way to go here.
The copied code is a complete canvas_item shader; i.time reads Godot’s TIME, so the shapes move in the editor too.
- Add a
ColorRectto a 2D or UI scene and set its anchors preset to Full Rect. - Set its Material to a new
ShaderMaterialwith a newShader, and paste the code. - Adjust the blend size, the speed and the three colors under Shader Parameters.
The helper functions (sdSphere, sdBox, scene, march, normalAt) paste into Godot unchanged; only the frame
around them differs. To build a scene of your own, edit scene() and keep the rest.
No Unreal Engine 5 tutorial for this card yet.
No Unity tutorial for this card yet.
No Blender tutorial for this card yet.
Code
#version 300 es// Raymarched 3D shapes → Sphere–box smooth blend, web GLSL (GLSL ES 3.00). From Creative Center, MIT license.precision highp float;uniform float u_time;uniform vec2 u_resolution; // this pass's output size in pixelsuniform float p_smoothness; // Blend size: 0.4 (0.01 to 1.0)uniform float p_speed; // Speed: 0.8 (0.0 to 3.0)uniform vec3 p_sphereColor; // Sphere color: vec3(1.0, 0.55, 0.3), sRGB; upload it decoded to linearuniform vec3 p_boxColor; // Box color: vec3(0.3, 0.6, 1.0), sRGB; upload it decoded to linearuniform vec3 p_background; // Background: vec3(0.06, 0.07, 0.1), sRGB; upload it decoded to linearin vec2 v_uv;in vec3 v_position;in vec3 v_normal;in vec3 v_view;out vec4 fragColor;struct Inputs { vec2 uv; vec3 position; vec3 normal; vec3 view; float time; vec2 fragCoord; vec2 resolution;};vec3 u_toSrgb(vec3 c) { return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, c));}const float FAR = 20.0;float sdSphere(vec3 p, float r) { return length(p) - r;}float sdBox(vec3 p, vec3 halfSize) { vec3 q = abs(p) - halfSize; return length(max(q, 0.0)) + min(max(q.x, max(q.y, q.z)), 0.0);}vec3 rotateY(vec3 p, float angle) { float c = cos(angle); float s = sin(angle); return vec3(c * p.x + s * p.z, p.y, c * p.z - s * p.x);}// x: the scene's distance; y: how much of it is sphere, 1 on the sphere, 0 on the box, in between where they meltvec2 scene(vec3 p, float time) { // Subtracting a little from the box's distance rounds its edges, so the light catches them float box = sdBox(rotateY(p, 0.5 * time), vec3(0.55)) - 0.06; float sphere = sdSphere(p - vec3(1.3 * sin(time * p_speed), 0.0, 0.0), 0.6); // Smooth minimum: where the two distances are within p_smoothness of each other, they blend instead of creasing float h = clamp(0.5 + 0.5 * (box - sphere) / p_smoothness, 0.0, 1.0); float d = mix(box, sphere, h) - p_smoothness * h * (1.0 - h); return vec2(d, h);}// Sphere tracing: nothing is closer than the scene's distance, so the ray can always step that far safelyfloat march(vec3 origin, vec3 ray, float time) { float t = 0.0; for (int k = 0; k < 96; k++) { float d = scene(origin + ray * t, time).x; if (d < 0.001 || t > FAR) break; t += d; } return t;}// The distance grows fastest straight away from the surface, so its gradient is the normalvec3 normalAt(vec3 p, float time) { vec2 e = vec2(0.001, 0.0); return normalize(vec3( scene(p + e.xyy, time).x - scene(p - e.xyy, time).x, scene(p + e.yxy, time).x - scene(p - e.yxy, time).x, scene(p + e.yyx, time).x - scene(p - e.yyx, time).x ));}vec4 effect(Inputs i) { vec2 p = (i.uv * 2.0 - 1.0) * vec2(i.resolution.x / i.resolution.y, 1.0); vec3 origin = vec3(0.0, 1.2, 3.6); vec3 forward = normalize(-origin); vec3 right = normalize(cross(forward, vec3(0.0, 1.0, 0.0))); vec3 up = cross(right, forward); // The 1.8 is the lens: larger is a narrower field of view vec3 ray = normalize(p.x * right + p.y * up + 1.8 * forward); float t = march(origin, ray, i.time); float hit = step(t, FAR); vec3 point = origin + ray * t; vec3 normal = normalAt(point, i.time); float diffuse = max(dot(normal, normalize(vec3(0.6, 0.8, 0.4))), 0.0); vec3 albedo = mix(p_boxColor, p_sphereColor, scene(point, i.time).y); // A little constant light so the side facing away isn't black vec3 lit = albedo * (0.15 + 0.85 * diffuse); vec3 color = mix(p_background, lit, hit); return vec4(color, 1.0);}void main() { Inputs i; i.uv = v_uv; i.position = v_position; // Interpolation shortens the vectors between vertices i.normal = normalize(v_normal); i.view = normalize(v_view); i.time = u_time; i.fragCoord = gl_FragCoord.xy; i.resolution = u_resolution; vec4 frag_out = effect(i); // The output is sRGB: clamped and encoded once, here, with no tone mapping; opaque, so alpha is 1 fragColor = vec4(u_toSrgb(clamp(frag_out.rgb, 0.0, 1.0)), 1.0);}// Raymarched 3D shapes → Sphere–box smooth blend, Godot 4. From Creative Center, MIT license.shader_type canvas_item;// Unshaded shows the effect's color as is, like the site's preview; scene lights don't touch itrender_mode unshaded;uniform float smoothness : hint_range(0.01, 1.0, 0.01) = 0.4;uniform float speed : hint_range(0.0, 3.0, 0.05) = 0.8;uniform vec3 sphereColor : source_color = vec3(1.0, 0.55, 0.3);uniform vec3 boxColor : source_color = vec3(0.3, 0.6, 1.0);uniform vec3 background : source_color = vec3(0.06, 0.07, 0.1);struct Inputs { vec2 uv; vec3 position; vec3 normal; vec3 view; float time; vec2 fragCoord; vec2 resolution;};// Godot's 2D renderer works in sRGB (HDR 2D off, the default), so the frame decodes color params and sRGB// textures to linear itself and encodes the result. With HDR 2D on, make these return c unchanged.vec3 u_toLinear(vec3 c) { return mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)), step(0.04045, c));}vec3 u_toSrgb(vec3 c) { return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, c));}const float FAR = 20.0;float sdSphere(vec3 p, float r) { return length(p) - r;}float sdBox(vec3 p, vec3 halfSize) { vec3 q = abs(p) - halfSize; return length(max(q, 0.0)) + min(max(q.x, max(q.y, q.z)), 0.0);}vec3 rotateY(vec3 p, float angle) { float c = cos(angle); float s = sin(angle); return vec3(c * p.x + s * p.z, p.y, c * p.z - s * p.x);}// x: the scene's distance; y: how much of it is sphere, 1 on the sphere, 0 on the box, in between where they meltvec2 scene(vec3 p, float time) { // Subtracting a little from the box's distance rounds its edges, so the light catches them float box = sdBox(rotateY(p, 0.5 * time), vec3(0.55)) - 0.06; float sphere = sdSphere(p - vec3(1.3 * sin(time * speed), 0.0, 0.0), 0.6); // Smooth minimum: where the two distances are within smoothness of each other, they blend instead of creasing float h = clamp(0.5 + 0.5 * (box - sphere) / smoothness, 0.0, 1.0); float d = mix(box, sphere, h) - smoothness * h * (1.0 - h); return vec2(d, h);}// Sphere tracing: nothing is closer than the scene's distance, so the ray can always step that far safelyfloat march(vec3 origin, vec3 ray, float time) { float t = 0.0; for (int k = 0; k < 96; k++) { float d = scene(origin + ray * t, time).x; if (d < 0.001 || t > FAR) break; t += d; } return t;}// The distance grows fastest straight away from the surface, so its gradient is the normalvec3 normalAt(vec3 p, float time) { vec2 e = vec2(0.001, 0.0); return normalize(vec3( scene(p + e.xyy, time).x - scene(p - e.xyy, time).x, scene(p + e.yxy, time).x - scene(p - e.yxy, time).x, scene(p + e.yyx, time).x - scene(p - e.yyx, time).x ));}vec4 effect(Inputs i) { vec2 p = (i.uv * 2.0 - 1.0) * vec2(i.resolution.x / i.resolution.y, 1.0); vec3 origin = vec3(0.0, 1.2, 3.6); vec3 forward = normalize(-origin); vec3 right = normalize(cross(forward, vec3(0.0, 1.0, 0.0))); vec3 up = cross(right, forward); // The 1.8 is the lens: larger is a narrower field of view vec3 ray = normalize(p.x * right + p.y * up + 1.8 * forward); float t = march(origin, ray, i.time); float hit = step(t, FAR); vec3 point = origin + ray * t; vec3 normal = normalAt(point, i.time); float diffuse = max(dot(normal, normalize(vec3(0.6, 0.8, 0.4))), 0.0); vec3 albedo = mix(u_toLinear(boxColor), u_toLinear(sphereColor), scene(point, i.time).y); // A little constant light so the side facing away isn't black vec3 lit = albedo * (0.15 + 0.85 * diffuse); vec3 color = mix(u_toLinear(background), lit, hit); return vec4(color, 1.0);}void fragment() { Inputs i; // UV starts at the top-left; the effect expects GLSL's bottom-left i.uv = vec2(UV.x, 1.0 - UV.y); // A flat quad facing the viewer, the same as the site's 2D preview i.position = vec3(i.uv * 2.0 - 1.0, 0.0); i.normal = vec3(0.0, 0.0, 1.0); i.view = vec3(0.0, 0.0, 1.0); i.time = TIME; // FRAGCOORD's origin is the top-left too i.resolution = 1.0 / SCREEN_PIXEL_SIZE; i.fragCoord = vec2(FRAGCOORD.x, i.resolution.y - FRAGCOORD.y); vec4 frag_out = effect(i); COLOR = vec4(u_toSrgb(clamp(frag_out.rgb, 0.0, 1.0)), 1.0);}No Unreal Engine 5 code for this card yet.
No Unity code for this card yet.
No Blender code for this card yet.
Further reading
- Sphere tracing: a geometric method for the antialiased ray tracing of implicit surfaces (opens in a new tab)Paper
- Distance functions (opens in a new tab)Article
- Smooth minimum (opens in a new tab)Article
- Normals for an SDF (opens in a new tab)Article
- Ray marching (opens in a new tab)Reference
- Signed distance function (opens in a new tab)Reference