Fullscreen SDF Raymarching with Smooth CSG and Depth

One self-contained shader that raymarches a full signed-distance-field scene in
a single fullscreen pass: smooth-blended shapes and colors, plastic-look
lighting, depth-buffer occlusion, real depth writes, and anti-aliased
silhouettes. No scripts, no setup beyond one quad. The map() function is the
scene, and it animates out of the box.

The toolbox inside: sphere, box, capsule, and torus, with union, smooth union,
smooth subtract, and smooth intersect. Every operator works on
(color, distance) pairs, so the colors blend along with the surfaces — the
classic metaball melt, except the hues melt too.

Heads-up: the live preview above runs Godot’s Compatibility renderer, and this is a Forward+ shader (it uses the reversed-Z fullscreen convention). That’s why the preview shows an empty floor  the screenshots and the video are what it actually looks like. Drop it on a QuadMesh in a Forward+ project and it renders as shown.

Usage (Godot 4.3+):

  1. Add a MeshInstance3D with a QuadMesh (any size) to your 3D scene.
  2. Set its Extra Cull Margin to a large value (e.g. 16384).
  3. Assign this shader through a ShaderMaterial (material override works too).
  4. Edit map() — that one function is your scene, and TIME animates it.

Because it writes depth, opaque meshes occlude the SDF surfaces and the SDF
surfaces occlude them back. One quad, no second camera, no manual blit chain.

Requirements: Godot 4.3+ (it uses the reversed-Z fullscreen convention),
Forward+. Developed and tested on 4.7.1.

License: CC0 1.0 — public domain, no attribution required. A courtesy credit
to vav-labs is welcome. Distance functions after Inigo Quilez.

Prefer to author shapes as Node3D nodes instead of editing map() — with live
editor preview, physics-driven shapes, and up to 32 primitives? There’s a full
node-based edition (MIT) and the write-up here:
https://vav-labs.com/case-studies/sdf-raymarcher-godot/

Shader code
// SDF Raymarcher (standalone demo) — CC0 1.0 / public domain.
// Fullscreen raymarched CSG scene with smooth-blended shapes AND colors,
// depth-buffer occlusion, depth write and anti-aliased silhouettes.
// Single shader, no scripts needed.
//
// Usage (Godot 4.3+):
//   1. Add a MeshInstance3D with a QuadMesh (any size) anywhere in your 3D scene.
//   2. Set its "Extra Cull Margin" to a large value (e.g. 16384).
//   3. Assign this shader via a ShaderMaterial (material_override works too).
//   4. Edit map() below — that's your scene. TIME animates it out of the box.
//
// Distance functions after Inigo Quilez (iquilezles.org/articles/distfunctions).

shader_type spatial;
render_mode unshaded, fog_disabled, depth_test_disabled, depth_draw_always, cull_disabled;

// ===== Look & quality =====
uniform float epsilon = 0.01;
uniform float max_ray_distance = 256.0;
uniform int max_steps = 128;
uniform float ambient = 0.3;
uniform float highlight_strength = 1.25; // plastic specular + rim
uniform float highlight_width = 0.212;   // 0 = tight, 1 = broad
uniform vec3 light_dir = vec3(0.64, -0.68, -0.36);
uniform vec3 light_color = vec3(1.0);

// ===== Demo scene controls =====
uniform vec3 scene_center = vec3(0.0, 1.6, 0.0);
uniform float animation_speed = 1.0;
uniform float blend_k = 0.35; // smooth-blend radius between shapes

uniform sampler2D depth_texture : hint_depth_texture, filter_nearest;

// ===== SDF primitives =====
float sd_sphere(vec3 p, float r) { return length(p) - r; }

float sd_box(vec3 p, vec3 b) {
	vec3 q = abs(p) - b;
	return length(max(q, 0.0)) + min(max(q.x, max(q.y, q.z)), 0.0);
}

float sd_torus(vec3 p, vec2 t) { // t.x = major radius, t.y = minor radius
	vec2 q = vec2(length(p.xz) - t.x, p.y);
	return length(q) - t.y;
}

float sd_capsule(vec3 p, vec3 a, vec3 b, float r) {
	vec3 pa = p - a, ba = b - a;
	float h = clamp(dot(pa, ba) / max(dot(ba, ba), 1e-12), 0.0, 1.0);
	return length(pa - ba * h) - r;
}

vec3 rotate_euler(vec3 p, vec3 euler_deg) {
	vec3 a = radians(euler_deg);
	float cx = cos(a.x), sx = sin(a.x);
	float cy = cos(a.y), sy = sin(a.y);
	float cz = cos(a.z), sz = sin(a.z);
	p = vec3(p.x, cx * p.y - sx * p.z, sx * p.y + cx * p.z);
	p = vec3(cy * p.x + sy * p.z, p.y, -sy * p.x + cy * p.z);
	p = vec3(cz * p.x - sz * p.y, sz * p.x + cz * p.y, p.z);
	return p;
}

// ===== CSG operators on (color, dist) pairs — dist lives in .w =====
vec4 sdf_union(vec4 a, float d, vec3 col) {
	return (d < a.w) ? vec4(col, d) : a;
}

// IQ smooth min, blending the colors with the same factor.
vec4 sdf_smooth_union(vec4 a, float d, vec3 col, float k) {
	k = max(k, 1e-6);
	float h = clamp(0.5 + 0.5 * (d - a.w) / k, 0.0, 1.0);
	return vec4(mix(col, a.rgb, h), mix(d, a.w, h) - k * h * (1.0 - h));
}

vec4 sdf_smooth_subtract(vec4 a, float d, float k) {
	k = max(k, 1e-6);
	float h = clamp(0.5 - 0.5 * (d + a.w) / k, 0.0, 1.0);
	return vec4(a.rgb, mix(a.w, -d, h) + k * h * (1.0 - h));
}

vec4 sdf_smooth_intersect(vec4 a, float d, vec3 col, float k) {
	k = max(k, 1e-6);
	float h = clamp(0.5 - 0.5 * (d - a.w) / k, 0.0, 1.0);
	return vec4(mix(a.rgb, col, h), mix(a.w, d, h) + k * h * (1.0 - h));
}

// ===== THE SCENE — edit freely, returns (color.rgb, dist.w) =====
vec4 map(vec3 p) {
	float t = TIME * animation_speed * 0.35;
	vec3 center = scene_center;

	// Left study: one controlled smooth blend. Both source shapes remain readable.
	vec3 left_sphere = center + vec3(-1.45, 0.06 + 0.04 * sin(t * 1.6), 0.0);
	vec4 left = vec4(vec3(0.03, 0.78, 1.0), sd_sphere(p - left_sphere, 0.5));
	vec3 left_box = rotate_euler(
		p - (center + vec3(-0.32, -0.02, 0.02)),
		vec3(8.0, -12.0 + 4.0 * sin(t), -10.0));
	left = sdf_smooth_union(
		left,
		sd_box(left_box, vec3(0.43, 0.43, 0.43)),
		vec3(1.0, 0.18, 0.3),
		blend_k * 0.8);

	// Center study: an independent torus, kept separate from the blend.
	vec3 ring = rotate_euler(
		p - (center + vec3(1.02, 0.02, 0.0)),
		vec3(72.0, t * 18.0, 8.0));
	vec4 middle = vec4(vec3(1.0, 0.72, 0.12), sd_torus(ring, vec2(0.55, 0.15)));

	// Right study: a box with a clearly visible spherical subtraction.
	vec3 right_center = center + vec3(2.35, 0.0, 0.0);
	vec3 right_box = rotate_euler(p - right_center, vec3(6.0, -8.0, 8.0));
	vec4 right = vec4(vec3(0.62, 0.28, 1.0), sd_box(right_box, vec3(0.5)));
	vec3 cavity = right_center + vec3(-0.18 + 0.04 * sin(t * 1.2), 0.12, 0.42);
	right = sdf_smooth_subtract(right, sd_sphere(p - cavity, 0.3), blend_k * 0.45);

	// Hard unions keep the three examples visually separate.
	vec4 cd = sdf_union(left, middle.w, middle.rgb);
	return sdf_union(cd, right.w, right.rgb);
}

float map_dist(vec3 p) { return map(p).w; }

// Tetrahedral gradient normal (4 taps).
vec3 estimate_normal(vec3 p) {
	float e = max(epsilon * 0.5, 0.001);
	vec2 k = vec2(1.0, -1.0);
	return normalize(
		k.xyy * map_dist(p + k.xyy * e) +
		k.yyx * map_dist(p + k.yyx * e) +
		k.yxy * map_dist(p + k.yxy * e) +
		k.xxx * map_dist(p + k.xxx * e));
}

void vertex() {
	// Pin the quad to the near plane -> fullscreen (Godot 4.3+, reversed-Z).
	POSITION = vec4(VERTEX.xy, 1.0, 1.0);
}

void fragment() {
	float eps = max(epsilon, 1e-4);
	int steps = clamp(max_steps, 1, 512);

	vec2 ndc_xy = SCREEN_UV * 2.0 - 1.0;

	// Ray through this pixel via the far plane (reversed-Z: far = 0).
	vec4 far_view = INV_PROJECTION_MATRIX * vec4(ndc_xy, 0.0, 1.0);
	far_view.xyz /= far_view.w;
	vec3 cam_pos = INV_VIEW_MATRIX[3].xyz;
	vec3 rd = normalize((INV_VIEW_MATRIX * vec4(normalize(far_view.xyz), 0.0)).xyz);

	// Never march past the camera far plane or the configured limit.
	float max_dist = min(max(max_ray_distance, 0.01), length(far_view.xyz));

	// Depth occlusion: scene geometry stops the ray.
	float depth_raw = texture(depth_texture, SCREEN_UV).r;
	if (depth_raw > 0.0001) {
		vec4 dview = INV_PROJECTION_MATRIX * vec4(ndc_xy, depth_raw, 1.0);
		dview.xyz /= dview.w;
		max_dist = min(max_dist, length(dview.xyz));
	}

	// Sphere tracing + closest-approach tracking against the pixel cone
	// for anti-aliased silhouettes. Pixel angular size: tan(fov/2) = 1/P[1][1].
	float pixel_angle = 2.0 / (VIEWPORT_SIZE.y * PROJECTION_MATRIX[1][1]);
	float t = 0.0;
	bool hit = false;
	float coverage = 1e9;
	float t_edge = 0.0;
	for (int s = 0; s < steps; s++) {
		if (t + eps > max_dist) break; // strict: no hits hiding behind geometry
		float d = map_dist(cam_pos + rd * t);
		if (d < eps) { hit = true; break; }
		float ratio = d / max(t * pixel_angle, 1e-6);
		if (ratio < coverage) { coverage = ratio; t_edge = t; }
		t += max(d, 0.001);
	}

	// Full hit -> opaque. Near-miss inside the pixel footprint -> partial alpha,
	// shaded at the closest-approach point.
	float alpha = 0.0;
	float shade_t = t;
	if (hit) {
		alpha = 1.0;
	} else if (coverage < 1.0) {
		alpha = clamp(1.0 - coverage, 0.0, 1.0);
		shade_t = t_edge;
	}

	if (alpha <= 0.0) {
		ALBEDO = vec3(0.0);
		ALPHA = 0.0;
		DEPTH = depth_raw; // keep the scene's depth on miss
	} else {
		vec3 p = cam_pos + rd * shade_t;
		vec3 albedo = map(p).rgb;
		vec3 n = estimate_normal(p);
		vec3 v = -rd;
		vec3 l = -normalize(light_dir);

		float ndl = clamp(dot(n, l), 0.0, 1.0);
		vec3 col = albedo * (vec3(ambient) + light_color * ndl);

		// Plastic-look highlight: tight specular + rim.
		vec3 h = normalize(l + v);
		float w = clamp(highlight_width, 0.0, 1.0);
		float spec = pow(clamp(dot(n, h), 0.0, 1.0), mix(256.0, 4.0, w)) * highlight_strength;
		float rim = pow(1.0 - clamp(dot(n, v), 0.0, 1.0), mix(6.0, 1.0, w)) * 0.35 * highlight_strength;
		col += (spec + rim) * light_color;

		ALBEDO = col;
		ALPHA = alpha;

		// Write the SDF surface depth so depth-aware effects and transparent
		// materials treat it like real geometry.
		vec4 clip = PROJECTION_MATRIX * (VIEW_MATRIX * vec4(p, 1.0));
		DEPTH = clip.z / clip.w;
	}
}
Live Preview
Tags
3d, csg, depth, fullscreen, metaball, Procedural, raymarching, SDF, Signed Distance Field, smooth union
The shader code and all code snippets in this post are under CC0 license and can be used freely without the author's permission. Images and videos, and assets depicted in those, do not fall under this license. For more info, see our License terms.

Related shaders

guest

0 Comments
Oldest
Newest Most Voted