Skip to content
Shape · Raymarched 3D shapesAdvanced

Sphere–box smooth blend

A sphere slides through a turning box and melts into it, raymarched and lit in one fragment shader.

UVTimeFull screen
Open in Playground
  1. 01GoalShape
  2. 02TechniqueRaymarched 3D shapes
  3. 03ExecutionSphere–box smooth blend
Full-screen quad

Parameters

0.40
0.80
#ff8c4d
#4d99ff
#0f121a

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.

  1. normalizeNormalizenormalizenormalizenormalize
    UV
    Time

    01 One ray per pixel

  2. stepExpressionstepstepstep

    02 March the ray

  3. normalizeExpressionnormalizenormalizenormalize

    03 Normal from the gradient

  4. dotDotProductdotdotdot

    04 Diffuse light

  5. mixMixmixmixmix

    05 Blend the colors

  6. mixMixmixmixmix

    06 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. scene() takes a point in 3D and returns how far it is from the nearest surface, the 3D form of a signed distance field. A sphere is the distance to its center minus the radius. A box folds the point into one corner with abs, 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. min(box, sphere) would be both shapes with a sharp crease where they meet. The smooth minimum blends instead: where the two distances are within the blend size of each other, 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 tracing walks along the ray in steps equal to the scene’s distance. Nothing is closer than that distance, so the step can never pass through a surface, and it shrinks as the ray nears one. When the distance falls under 0.001 the ray has hit; past 20 units it has missed. 96 steps is enough for this scene; grazing rays along an edge use the most.

The normal from the gradient. The distance grows fastest straight away from the surface, so its gradient points along the normal. Six more calls to scene(), a tiny step either side on each axis, give the gradient, and normalize makes it a unit normal.

Light and color. The dot product of the normal and a fixed light direction is Lambert’s diffuse term, with a little constant light added so the far side isn’t black. 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.

  1. Draw two triangles covering the canvas in clip space, with a vertex shader that writes v_uv from 0 to 1 and constants for v_position, v_normal and v_view (the fragment shader declares all four).
  2. Every frame, set u_time to the seconds since you started and u_resolution to the drawing buffer’s size.
  3. Set p_smoothness and p_speed, and upload p_sphereColor, p_boxColor and p_background decoded from sRGB to linear.
  4. Draw with blending off from a requestAnimationFrame loop, and stop the loop while the canvas is off screen (an IntersectionObserver tells 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.

  1. Add a ColorRect to a 2D or UI scene and set its anchors preset to Full Rect.
  2. Set its Material to a new ShaderMaterial with a new Shader, and paste the code.
  3. 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

sphere-box-blend.fragGLSL
#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);}
sphere-box-blend.gdshaderGodot shader
// 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