03 - Signed-Distance-Field Raymarcher
The dasVulkan SDF rung raymarches the scene in a compute shader, one invocation per pixel, into a storage image. WebGL2 has no compute, so – exactly as for the Mandelbrot rung – the portable GL idiom is a fullscreen fragment shader: a clip-space quad, with the whole raymarch running once per fragment.
The scene is pure math – a smooth-blended sphere and an orbiting torus floating above a checkered plane – shaded with key light + hemisphere ambient + IQ soft shadow + a rim term + atmospheric fog, then Reinhard tone-mapped. The colour scheme and shading are a faithful port of the Vulkan rung; only the vehicle (compute → fragment) and the pixel→ray mapping change.
var @in @location v_position : float2
var @uniform u_time : float
var @uniform u_aspect : float
var @inout f_uv : float2
var @out f_FragColor : float4
let MAX_STEPS = 96 // raymarch iteration budget per pixel
let MAX_DIST = 60.0 // far plane: distance at which we declare a miss
let HIT_EPS = 0.001 // distance threshold that counts as a surface hit
let SHADOW_K = 32.0 // soft-shadow penumbra sharpness (IQ recipe)
// IQ's polynomial smooth union: blends two SDFs over a soft radius k. Reduces to
// min(a,b) when the surfaces are far apart, but curves the meeting band where
// they overlap -- the visual signature of distance-field rendering.
def private smin(a, b, k : float) : float {
let h = max(k - abs(a - b), 0.0) / k
return min(a, b) - h * h * h * k * (1.0 / 6.0)
}
// SDF primitives. Each returns the signed distance from p to the surface.
def private sd_sphere(p : float3; r : float) : float {
return length(p) - r
}
def private sd_torus(p : float3; major, minor : float) : float {
let q = float2(length(p.xz) - major, p.y)
return length(q) - minor
}
def private sd_plane(p : float3; h : float) : float {
return p.y - h
}
// 2D rotation around the origin; used to spin the torus's local frame about Y.
def private rot2(p : float2; ang : float) : float2 {
let c = cos(ang)
let s = sin(ang)
return float2(c * p.x - s * p.y, s * p.x + c * p.y)
}
// The scene SDF. Returns (distance_to_nearest_surface, material_id) so the shader
// can branch on the material at the hit. material 0 = blob (sphere smin torus),
// material 1 = ground.
def private map(p : float3; t : float) : float2 {
let d_plane = sd_plane(p, -0.5)
let d_sphere = sd_sphere(p - float3(0.0, 0.2 * sin(t * 1.5), 0.0), 0.55)
// torus orbits the sphere by rotating its local xz frame with time
let xz = rot2(float2(p.x, p.z), t * 0.8)
let pt = float3(xz.x, p.y, xz.y)
let d_torus = sd_torus(pt - float3(1.0, 0.0, 0.0), 0.32, 0.12)
let d_blob = smin(d_sphere, d_torus, 0.3)
if (d_plane < d_blob) {
return float2(d_plane, 1.0)
}
return float2(d_blob, 0.0)
}
// Scalar overload for the shadow march (no material needed).
def private map_d(p : float3; t : float) : float {
return map(p, t).x
}
// Forward-difference normal at p: 4 SDF taps (1 baseline + 3 axis-displaced),
// normalized. Cheaper than the 6-tap central difference; the slight +eps bias is
// invisible at this eps and distance threshold.
def private get_normal(p : float3; t : float) : float3 {
let eps = 0.001
let d = map_d(p, t)
let nx = map_d(p + float3(eps, 0.0, 0.0), t) - d
let ny = map_d(p + float3(0.0, eps, 0.0), t) - d
let nz = map_d(p + float3(0.0, 0.0, eps), t) - d
return normalize(float3(nx, ny, nz))
}
// March from ro toward rd. Returns (t_hit, material). t_hit >= MAX_DIST means a
// miss (sky). Three exits: (1) HIT_EPS hit -> set mat + break, (2) far-plane
// escape -> break, (3) step-budget exhausted -> force a miss (else dist could
// land just under MAX_DIST and the caller would shade garbage at material 0).
def private march(ro, rd : float3; t : float) : float2 {
var dist = 0.0
var mat = 0.0
var hit = 0
for (_i in range(MAX_STEPS)) {
let p = ro + rd * dist
let m = map(p, t)
if (m.x < HIT_EPS) {
mat = m.y
hit = 1
break
}
dist += m.x
if (dist > MAX_DIST) {
break
}
}
if (hit == 0) {
dist = MAX_DIST + 1.0
}
return float2(dist, mat)
}
// Soft shadow via IQ's penumbra estimate: track min(h/dist) along the shadow ray;
// small ratios mean the ray grazed close to a surface (deep penumbra).
def private soft_shadow(ro, rd : float3; t : float) : float {
var res = 1.0
var dist = 0.05 // small offset to dodge self-shadowing
for (_i in range(48)) {
let p = ro + rd * dist
let h = map_d(p, t)
if (h < 0.001) {
res = 0.0
break
}
res = min(res, SHADOW_K * h / dist)
dist += clamp(h, 0.02, 1.0)
if (dist > 10.0) {
break
}
}
return clamp(res, 0.0, 1.0)
}
// Vertical sky gradient (warm horizon -> cool zenith) with a tiny solar disc.
def private sky_color(rd, sun_dir : float3) : float3 {
let h = clamp(rd.y * 0.5 + 0.5, 0.0, 1.0)
let horizon = float3(0.94, 0.78, 0.62) // warm peach
let zenith = float3(0.45, 0.62, 0.85) // soft blue
var col = lerp(horizon, zenith, float3(h, h, h))
let sun = max(dot(rd, sun_dir), 0.0)
col += float3(1.0, 0.85, 0.6) * pow(sun, 64.0)
return col
}
// Checkered ground albedo: parity of (floor(x) + floor(z)) picks one of two tones.
def private ground_color(p : float3) : float3 {
let cx = floor(p.x)
let cz = floor(p.z)
let sum = cx + cz
let parity = sum - 2.0 * floor(0.5 * sum) // (cx + cz) mod 2 as a float
let a = float3(0.95, 0.92, 0.85)
let b = float3(0.30, 0.34, 0.38)
return lerp(a, b, float3(parity, parity, parity))
}
// Blob albedo: IQ-style cosine palette pulsing with time, lifted toward the top.
def private blob_color(p : float3; t : float) : float3 {
let phase = t * 0.6
let r = 0.6 + 0.4 * cos(phase + 0.0)
let g = 0.45 + 0.4 * cos(phase + 0.8)
let b = 0.4 + 0.4 * cos(phase + 1.6)
let lift = 0.85 + 0.15 * clamp(p.y + 0.5, 0.0, 1.0)
return float3(r, g, b) * lift
}
// Reinhard tone map. Compresses HDR overshoot (rim glow + sun disc) into [0,1].
def private tonemap(c : float3) : float3 {
let one = float3(1.0, 1.0, 1.0)
return c / (one + c)
}
[vertex_program]
def vs_main {
f_uv = v_position
gl_Position = float4(v_position, 0.0, 1.0)
}
[fragment_program]
def fs_main {
let t = u_time
// screen-normalized coords: x widened by aspect, y in [-1,1] (+y up in clip)
let px = f_uv.x * u_aspect
let py = f_uv.y
// camera orbits the origin at fixed radius with a gentle vertical sway
let cam_ang = t * 0.4
let cam_dist = 3.0
let cam_y = 0.8 + 0.2 * sin(t * 0.3)
let ro = float3(cos(cam_ang) * cam_dist, cam_y, sin(cam_ang) * cam_dist)
let target = float3(0.0, 0.0, 0.0)
let fwd = normalize(target - ro)
let right = normalize(cross(float3(0.0, 1.0, 0.0), fwd))
let up = cross(fwd, right)
let focal = 1.6 // pinhole focal length; larger = narrower FOV
let rd = normalize(fwd * focal + right * px + up * py)
let sun_dir = normalize(float3(-0.4, 0.8, -0.5))
let hit = march(ro, rd, t)
var col = float3(0.0, 0.0, 0.0)
if (hit.x >= MAX_DIST) {
col = sky_color(rd, sun_dir)
} else {
let p = ro + rd * hit.x
let n = get_normal(p, t)
var albedo = float3(0.5, 0.5, 0.5)
if (hit.y > 0.5) {
albedo = ground_color(p)
} else {
albedo = blob_color(p, t)
}
let key = max(dot(n, sun_dir), 0.0)
let sh = soft_shadow(p + n * 0.001, sun_dir, t)
let v = normalize(ro - p)
let rim = pow(1.0 - max(dot(n, v), 0.0), 3.0)
let amb = 0.25 + 0.25 * (n.y * 0.5 + 0.5) // hemisphere ambient
let lit = albedo * (amb + key * sh * 0.85) + float3(1.0, 0.9, 0.75) * rim * 0.2
// atmospheric fog: scene fades into the sky color with distance
let f = clamp(1.0 - exp(-0.02 * hit.x * hit.x), 0.0, 1.0)
col = lerp(lit, sky_color(rd, sun_dir), float3(f, f, f))
}
col = tonemap(col)
f_FragColor = float4(col, 1.0)
}
var program : uint
var vao : uint
var vbo : uint
var ebo : uint
var window : GLFWwindow?
var time : float = 0.0
[vertex_buffer]
struct Vertex {
xy : float2
}
// A fullscreen quad in clip space; the fragment shader does all the work.
let vertices = [Vertex(
xy=float2(-1.0, -1.0)), Vertex(
xy=float2(1.0, -1.0)), Vertex(
xy=float2(1.0, 1.0)), Vertex(
xy=float2(-1.0, 1.0)
)];
let indices = fixed_array<int>(0, 1, 2, 2, 3, 0)
def create_gl_objects {
program = create_shader_program(@@vs_main, @@fs_main)
glGenVertexArrays(1, safe_addr(vao))
glBindVertexArray(vao)
glGenBuffers(1, safe_addr(vbo))
glBindBuffer(GL_ARRAY_BUFFER, vbo)
glBufferData(GL_ARRAY_BUFFER, vertices, GL_STATIC_DRAW)
bind_vertex_buffer(null, type<Vertex>)
glGenBuffers(1, safe_addr(ebo))
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo)
glBufferData(GL_ELEMENT_ARRAY_BUFFER, indices, GL_STATIC_DRAW)
}
[export]
def init {
if (glfwInit() == 0) {
panic("can't init glfw")
}
glfwInitOpenGL(3, 3)
window = glfwCreateWindow(640, 480, "OpenGL - 03 sdf raymarcher", null, null)
if (window == null) {
panic("can't create window")
}
glfwMakeContextCurrent(window)
create_gl_objects()
}
[export]
def update : bool {
time += 1.0 / 60.0
var display_w, display_h : int
glfwGetFramebufferSize(window, display_w, display_h)
let h = max(display_h, 1)
u_time = time
u_aspect = float(display_w) / float(h)
glViewport(0, 0, display_w, display_h)
glClearColor(0.0, 0.0, 0.0, 1.0)
glClear(GL_COLOR_BUFFER_BIT)
glUseProgram(program)
vs_main_bind_uniform(program)
fs_main_bind_uniform(program)
glBindVertexArray(vao)
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo)
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, null)
glfwPollEvents()
glfwSwapBuffers(window)
return glfwWindowShouldClose(window) == 0
}
[export]
def shutdown {
glfwDestroyWindow(window)
glfwTerminate()
}
// Desktop driver. On the web this is never called -- the run path drives the
// three lifecycle functions directly and persists the Context across frames.
[export]
def main {
init()
while (update()) {
}
shutdown()
}
A shader that reads like ordinary code
This rung is the one that really exercises the GLSL emitter. The shader is
authored as a dozen-plus ordinary daslang functions – SDF primitives
(sd_sphere / sd_torus / sd_plane), IQ’s smooth-union smin, the
scene map, a forward-difference get_normal, the march and
soft_shadow loops, and the sky_color / ground_color / blob_color /
tonemap shading – all of which dasGlsl emits as GLSL functions (with forward
declarations) ahead of main(). It leans on a wide intrinsic surface
(length / normalize / cross / dot / floor / pow / exp /
cos / sin / clamp / min / max / mix), 2- and 3-component
swizzles, and for / break control flow – the daslang for (i in
range(N)) lowering to a C-style GLSL loop.
The raymarch
fs_main builds a camera that orbits the origin (the orbit and the blob’s
motion are derived from u_time), shoots one ray per fragment through a pinhole
lens, and marches it through map until it hits a surface or escapes to the far
plane. On a hit it computes the normal, picks the material’s albedo, and shades;
on a miss it samples the sky gradient. Distance fog blends the two, and the result
is tone-mapped to [0,1].
The loop
update() advances u_time, reads the live framebuffer size for the aspect
correction, clears, binds the program and its uniforms, and draws the quad. The
whole animation – camera orbit, torus rotation, sphere bob, palette pulse – is
driven from u_time inside the shader.
Run it
Locally, in a window:
daslang tutorials/opengl/03_sdf/03_sdf.das
In the browser, it runs live in the daslang playground – the same .das,
lowered to WebGL2, the raymarch running once per fragment on your GPU.