06 - The Skybox
The first cubemap rung. A procedural sky surrounds the camera, and a mirror
sphere on a checkered floor reflects it – so the same samplerCube is sampled
two ways: as a background (the skybox) and as an environment map (the reflection).
It is a faithful port of the dasVulkan skybox rung – same procedural sky, same
reflective sphere and floor.
Two new ideas after tutorial 04’s single 2D texture: the cubemap itself, and the depth=1.0 trick that lets the skybox fill the background in one draw without overdrawing the foreground.
// ===== shared shader globals =====
// skybox draws with the rotation-only view (so the cube stays glued to the camera);
// the sphere + floor draw with the full view (they live in the world).
var @uniform u_view_rot : float4x4
var @uniform u_view_full : float4x4
var @uniform u_proj : float4x4
var @uniform u_cam_pos : float3
var @uniform u_material : float4 // rgb = metal tint, a = reflectivity
var @uniform u_sky : samplerCube
// vertex inputs: pos is shared (location 0); the scene shaders also read a normal
var @in @location = 0 a_pos : float3
var @in @location = 1 a_normal : float3
// varyings: the skybox passes the cube position as a direction; the scene passes
// world position + normal for the reflection
var @inout v_dir : float3
var @inout v_wpos : float3
var @inout v_wnormal : float3
var @out f_FragColor : float4
// ===== skybox shaders =====
[vertex_program]
def skybox_vs {
// the cube position is the sampling direction; the rotation-only view keeps the
// cube centred on the camera, so the position is the world direction to the vertex
v_dir = a_pos
let clip = u_proj * u_view_rot * float4(a_pos, 1.0)
// snap z to w so post-divide NDC z = 1.0 -- every skybox fragment is at the far plane
gl_Position = float4(clip.x, clip.y, clip.w, clip.w)
}
[fragment_program]
def skybox_fs {
let col = texture(u_sky, normalize(v_dir)).xyz
f_FragColor = float4(col, 1.0)
}
// ===== scene (sphere + floor) shaders =====
// one shared vertex shader; the fragment shaders diverge (mirror metal vs checker)
[vertex_program]
def scene_vs {
v_wpos = a_pos
v_wnormal = a_normal
gl_Position = u_proj * u_view_full * float4(a_pos, 1.0)
}
// GLSL-style reflect(I, N) = I - 2*dot(N,I)*N, I the incident (eye->surface) direction.
def reflect_dir(i, n : float3) : float3 {
return i - n * (2.0 * dot(n, i))
}
[fragment_program]
def sphere_fs {
let n = normalize(v_wnormal)
let v = normalize(u_cam_pos - v_wpos) // surface -> eye
let ndotv = max(dot(n, v), 0.0)
let env = texture(u_sky, reflect_dir(-v, n)).xyz // reflect the eye ray, sample the sky
let tint = u_material.xyz
let f0 = tint * u_material.w
// Schlick metal Fresnel: tinted at normal incidence, ramping to white at grazing
let fres = pow(1.0 - ndotv, 5.0)
let reflectance = lerp(f0, float3(1.0, 1.0, 1.0), float3(fres, fres, fres))
f_FragColor = float4(env * reflectance, 1.0)
}
[fragment_program]
def floor_fs {
let n = normalize(v_wnormal)
let gx = floor(v_wpos.x * 0.5 + 100.0)
let gz = floor(v_wpos.z * 0.5 + 100.0)
// checkerboard parity (gx+gz) mod 2 via floor -- no mod builtin needed
let cell = gx + gz
let parity = cell - floor(cell * 0.5) * 2.0
let checker = parity < 0.5 ? 0.85 : 0.28
let base = float3(checker, checker, checker) * float3(0.72, 0.74, 0.80)
let v = normalize(u_cam_pos - v_wpos)
let env = texture(u_sky, reflect_dir(-v, n)).xyz
let fres = pow(1.0 - max(dot(n, v), 0.0), 4.0) * 0.4 // faint sky reflection on the polish
let col = lerp(base, env, float3(fres, fres, fres))
f_FragColor = float4(col, 1.0)
}
// ===== GL objects =====
var prog_sky : uint
var prog_sphere : uint
var prog_floor : uint
var sky_vao, sky_vbo, sky_ebo : uint
var sphere_vao, sphere_vbo, sphere_ebo : uint
var floor_vao, floor_vbo, floor_ebo : uint
var sky_tex : uint
var sphere_index_count : int
var window : GLFWwindow?
var time : float = 0.0
let FACE_DIM = 256
let N_FACES = 6
let SPHERE_LAT = 48
let SPHERE_LON = 64
let SPHERE_CY = 1.05 // sphere centre height (just above the floor)
let FLOOR_HALF = 9.0
[vertex_buffer]
struct SkyVertex {
pos : float3
}
[vertex_buffer]
struct SceneVertex {
pos : float3
normal : float3
}
// 8 corners of a unit cube; the position is the sampling direction. Rendered with
// culling off, so winding does not matter -- the camera sits at the cube centre.
let cube_corners = [SkyVertex(
pos=float3(-1, -1, -1)), SkyVertex(
pos=float3(1, -1, -1)), SkyVertex(
pos=float3(1, 1, -1)), SkyVertex(
pos=float3(-1, 1, -1)), SkyVertex(
pos=float3(-1, -1, 1)), SkyVertex(
pos=float3(1, -1, 1)), SkyVertex(
pos=float3(1, 1, 1)), SkyVertex(
pos=float3(-1, 1, 1)
)];
let cube_indices = fixed_array<int>(
1, 5, 6, 1, 6, 2, // +X
0, 3, 7, 0, 7, 4, // -X
2, 6, 7, 2, 7, 3, // +Y
0, 4, 5, 0, 5, 1, // -Y
4, 7, 6, 4, 6, 5, // +Z
0, 1, 2, 0, 2, 3) // -Z
// ===== procedural sky =====
// A continuous function of direction -- a two-tone vertical gradient (warm horizon to
// zenith blue) with a ground falloff, plus a sun disc + glow. Each cube face just
// rasterises this same function over its own outgoing-direction patch, so the seams
// between faces are invisible.
let SUN_DIR : float3 = normalize(float3(0.45, 0.55, 0.85))
def mix3(a, b : float3; t : float) : float3 {
return a + (b - a) * t
}
def sample_sky(dir : float3) : float3 {
let altitude = clamp(dir.y, -1.0, 1.0)
let elevation = clamp(altitude * 0.5 + 0.5, 0.0, 1.0)
let zenith = float3(0.18, 0.32, 0.70)
let horizon = float3(0.88, 0.62, 0.45)
var col = mix3(horizon, zenith, elevation)
if (altitude < 0.0) {
let ground = float3(0.10, 0.07, 0.05)
let g = clamp(-altitude * 1.6, 0.0, 1.0)
col = mix3(col, ground, g)
}
let sun_dot = clamp(dot(dir, SUN_DIR), 0.0, 1.0)
let glow = pow(sun_dot, 32.0) * 0.9
col += float3(1.0, 0.85, 0.65) * glow
if (sun_dot > 0.998) {
col = float3(1.0, 0.95, 0.85) // the sun disc itself
}
return clamp(col, float3(0.0, 0.0, 0.0), float3(1.0, 1.0, 1.0))
}
// Map a face index + (x,y) pixel to a unit direction. This matches the GL cubemap
// sampling convention (the major-axis table), so the sky the shader samples is the
// sky we baked. Faces are ordered +X, -X, +Y, -Y, +Z, -Z to match the
// GL_TEXTURE_CUBE_MAP_POSITIVE_X .. NEGATIVE_Z target order.
def face_direction(face, x, y, dim : int) : float3 {
let s = (float(x) + 0.5) / float(dim) * 2.0 - 1.0
let t = (float(y) + 0.5) / float(dim) * 2.0 - 1.0
var d : float3
if (face == 0) { // +X
d = float3(1.0, -t, -s)
} elif (face == 1) { // -X
d = float3(-1.0, -t, s)
} elif (face == 2) { // +Y
d = float3(s, 1.0, t)
} elif (face == 3) { // -Y
d = float3(s, -1.0, -t)
} elif (face == 4) { // +Z
d = float3(s, -t, 1.0)
} else { // -Z
d = float3(-s, -t, -1.0)
}
return normalize(d)
}
// Build one face (dim*dim RGBA8) of the procedural sky.
def gen_sky_face(face, dim : int) : array<uint8> {
var pixels : array<uint8>
pixels |> resize(dim * dim * 4)
for (y in range(dim)) {
for (x in range(dim)) {
let col = sample_sky(face_direction(face, x, y, dim))
let p = (y * dim + x) * 4
pixels[p + 0] = uint8(clamp(col.x * 255.0, 0.0, 255.0))
pixels[p + 1] = uint8(clamp(col.y * 255.0, 0.0, 255.0))
pixels[p + 2] = uint8(clamp(col.z * 255.0, 0.0, 255.0))
pixels[p + 3] = 255u8
}
}
return <- pixels
}
// Generate the six faces and upload them into one cubemap. Each face goes to its own
// GL_TEXTURE_CUBE_MAP_POSITIVE_X + face target; CLAMP_TO_EDGE on all three axes keeps
// the face edges from bleeding across the seams.
def create_skybox_cubemap(dim : int) : uint {
var tex : uint
glGenTextures(1, safe_addr(tex))
glBindTexture(GL_TEXTURE_CUBE_MAP, tex)
for (face in range(N_FACES)) {
var pixels <- gen_sky_face(face, dim)
glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + uint(face), 0, int(GL_RGBA), dim, dim, 0,
GL_RGBA, GL_UNSIGNED_BYTE, unsafe(addr(pixels[0])))
delete pixels
}
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE)
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE)
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE)
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
return tex
}
// ===== sphere + floor geometry =====
// UV sphere centred at (0, SPHERE_CY, 0); each vertex is position + outward unit normal.
def gen_sphere(var verts : array<SceneVertex>; var indices : array<int>) {
verts |> reserve((SPHERE_LAT + 1) * (SPHERE_LON + 1))
indices |> reserve(SPHERE_LAT * SPHERE_LON * 6)
for (i in range(SPHERE_LAT + 1)) {
let theta = float(i) / float(SPHERE_LAT) * PI
let st = sin(theta)
let ct = cos(theta)
for (j in range(SPHERE_LON + 1)) {
let phi = float(j) / float(SPHERE_LON) * 2.0 * PI
let nrm = float3(st * cos(phi), ct, st * sin(phi))
verts |> push(SceneVertex(pos = nrm + float3(0.0, SPHERE_CY, 0.0), normal = nrm))
}
}
let row = SPHERE_LON + 1
for (i in range(SPHERE_LAT)) {
for (j in range(SPHERE_LON)) {
let a = i * row + j
let b = a + row
indices |> push(a); indices |> push(b); indices |> push(a + 1)
indices |> push(a + 1); indices |> push(b); indices |> push(b + 1)
}
}
}
// Large floor quad in the XZ plane at y=0, normal up.
let floor_verts = [SceneVertex(
pos=float3(-9.0, 0.0, -9.0), normal=float3(0, 1, 0)), SceneVertex(
pos=float3(9.0, 0.0, -9.0), normal=float3(0, 1, 0)), SceneVertex(
pos=float3(9.0, 0.0, 9.0), normal=float3(0, 1, 0)), SceneVertex(
pos=float3(-9.0, 0.0, 9.0), normal=float3(0, 1, 0)
)];
let floor_indices = fixed_array<int>(0, 1, 2, 0, 2, 3)
def create_gl_objects {
prog_sky = create_shader_program(@@skybox_vs, @@skybox_fs)
prog_sphere = create_shader_program(@@scene_vs, @@sphere_fs)
prog_floor = create_shader_program(@@scene_vs, @@floor_fs)
// skybox cube (positions only)
glGenVertexArrays(1, safe_addr(sky_vao))
glBindVertexArray(sky_vao)
glGenBuffers(1, safe_addr(sky_vbo))
glBindBuffer(GL_ARRAY_BUFFER, sky_vbo)
glBufferData(GL_ARRAY_BUFFER, cube_corners, GL_STATIC_DRAW)
bind_vertex_buffer(null, type<SkyVertex>)
glGenBuffers(1, safe_addr(sky_ebo))
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, sky_ebo)
glBufferData(GL_ELEMENT_ARRAY_BUFFER, cube_indices, GL_STATIC_DRAW)
// sphere (pos + normal)
var sverts : array<SceneVertex>
var sidx : array<int>
gen_sphere(sverts, sidx)
sphere_index_count = length(sidx)
glGenVertexArrays(1, safe_addr(sphere_vao))
glBindVertexArray(sphere_vao)
glGenBuffers(1, safe_addr(sphere_vbo))
glBindBuffer(GL_ARRAY_BUFFER, sphere_vbo)
glBufferData(GL_ARRAY_BUFFER, sverts, GL_STATIC_DRAW)
bind_vertex_buffer(null, type<SceneVertex>)
glGenBuffers(1, safe_addr(sphere_ebo))
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, sphere_ebo)
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sidx, GL_STATIC_DRAW)
delete sverts
delete sidx
// floor quad (pos + normal)
glGenVertexArrays(1, safe_addr(floor_vao))
glBindVertexArray(floor_vao)
glGenBuffers(1, safe_addr(floor_vbo))
glBindBuffer(GL_ARRAY_BUFFER, floor_vbo)
glBufferData(GL_ARRAY_BUFFER, floor_verts, GL_STATIC_DRAW)
bind_vertex_buffer(null, type<SceneVertex>)
glGenBuffers(1, safe_addr(floor_ebo))
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, floor_ebo)
glBufferData(GL_ELEMENT_ARRAY_BUFFER, floor_indices, GL_STATIC_DRAW)
sky_tex = create_skybox_cubemap(FACE_DIM)
u_sky := sky_tex
}
// Metals the sphere morphs through: float4(tint.rgb, reflectivity).
let METAL_PRESETS = [
float4(0.95, 0.96, 0.98, 1.00), // chrome
float4(1.00, 0.78, 0.34, 0.95), // gold
float4(0.95, 0.55, 0.38, 0.92), // copper
float4(0.70, 0.72, 0.76, 0.78)]; // brushed steel
// Lerp through the metal presets, ~3 s each, looping.
def cycle_metal(t : float) : float4 {
let n = length(METAL_PRESETS)
let u = t / 3.0
let idx = int(floor(u)) % n
let nxt = (idx + 1) % n
let f = u - floor(u)
return METAL_PRESETS[idx] + (METAL_PRESETS[nxt] - METAL_PRESETS[idx]) * f
}
[export]
def init {
if (glfwInit() == 0) {
panic("can't init glfw")
}
glfwInitOpenGL(3, 3)
window = glfwCreateWindow(640, 480, "OpenGL - 06 skybox", null, null)
if (window == null) {
panic("can't create window")
}
glfwMakeContextCurrent(window)
create_gl_objects()
}
[export]
def update : bool {
time += 1.0 / 60.0
let t = time
var display_w, display_h : int
glfwGetFramebufferSize(window, display_w, display_h)
let h = max(display_h, 1)
let aspect = float(display_w) / float(h)
// camera orbits the sphere, slightly elevated, gently swaying
let camera_angle = t * 0.4
let cam_pos = float3(cos(camera_angle) * 4.5, 2.2 + sin(camera_angle * 2.0) * 0.6, sin(camera_angle) * 4.5)
let target = float3(0.0, 0.9, 0.0)
u_view_full = look_at_rh(cam_pos, target, float3(0, 1, 0))
// rotation-only view: eye at the origin so the skybox cube stays centred on the camera
u_view_rot = look_at_rh(float3(0, 0, 0), normalize(target - cam_pos), float3(0, 1, 0))
u_proj = perspective_rh_opengl(70.0 * PI / 180.0, aspect, 0.1, 50.0)
u_cam_pos = cam_pos
u_material = cycle_metal(t)
glViewport(0, 0, display_w, display_h)
glDepthMask(true) // glClear honours the depth mask -- restore before clearing
glClearColor(0.0, 0.0, 0.0, 1.0)
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
glEnable(GL_DEPTH_TEST)
glDisable(GL_CULL_FACE)
// foreground first, writing depth: mirror sphere, then checker floor
glDepthFunc(GL_LESS)
glUseProgram(prog_sphere)
scene_vs_bind_uniform(prog_sphere)
sphere_fs_bind_uniform(prog_sphere)
glBindVertexArray(sphere_vao)
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, sphere_ebo)
glDrawElements(GL_TRIANGLES, sphere_index_count, GL_UNSIGNED_INT, null)
glUseProgram(prog_floor)
scene_vs_bind_uniform(prog_floor)
floor_fs_bind_uniform(prog_floor)
glBindVertexArray(floor_vao)
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, floor_ebo)
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, null)
// skybox last: z=1.0 + LEQUAL + no depth write fills only the uncovered background
glDepthFunc(GL_LEQUAL)
glDepthMask(false)
glUseProgram(prog_sky)
skybox_vs_bind_uniform(prog_sky)
skybox_fs_bind_uniform(prog_sky)
glBindVertexArray(sky_vao)
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, sky_ebo)
glDrawElements(GL_TRIANGLES, 36, 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()
}
The cubemap
A samplerCube is sampled by a 3-vector direction instead of a 2D UV. The
skybox is a unit cube centred on the camera; each vertex position doubles as the
world-space sampling direction, so skybox_fs samples the sky with
texture(u_sky, normalize(v_dir)) and never needs a UV. The mirror sphere
samples the same cubemap along the reflected view vector – that is what makes
the reflection show the surrounding sky.
The six faces are generated on the CPU. sample_sky is a continuous function of
direction (a horizon-to-zenith gradient, a ground falloff, a sun disc and glow);
face_direction maps each face’s pixels to outgoing directions following the GL
cubemap convention, so the sky the shader samples is exactly the sky we baked.
create_skybox_cubemap uploads each face into its own
GL_TEXTURE_CUBE_MAP_POSITIVE_X + face target of one GL_TEXTURE_CUBE_MAP
texture, with CLAMP_TO_EDGE on all three axes so the face edges do not bleed
across the seams.
On the host side, assigning a texture handle into a sampler global – u_sky :=
sky_tex – uses the samplerCube-to-handle := operator that this rung added
to glsl_common (the sampler-binding surface that the wider sampler types had
left incomplete). The generated *_bind_uniform then binds it through
bind_sampler_cube.
The depth=1.0 trick
After the projection multiply, skybox_vs snaps clip.z = clip.w by rebuilding
the clip-space vector as float4(clip.x, clip.y, clip.w, clip.w). The
perspective divide then gives an NDC z of exactly 1.0: every skybox fragment
lives at the far plane.
The draw order does the rest. The foreground (sphere, then floor) draws first with
ordinary LESS depth testing and depth writes on. The skybox draws last with
the depth test set to LEQUAL and depth writes off, so its far-plane fragments
pass only where the depth buffer is still at its cleared 1.0 – the pixels no
foreground covered. One background draw, no overdraw of what is in front of it.
(glClear honours the depth write mask, so update re-enables depth writes with
glDepthMask(true) before each clear, having turned them off for the skybox draw.)
Reflections
sphere_fs reflects the eye ray about the surface normal with a small
reflect_dir helper (emitted as a GLSL function, same as tutorial 03’s SDF
helpers), samples the cubemap along it, and tints the result with a Schlick metal
Fresnel – tinted reflectance at normal incidence ramping to white at grazing
angles. The host cycles the sphere’s u_material through chrome, gold, copper and
steel over time. floor_fs is a checkerboard with a faint Fresnel sky reflection
so it reads as a polished surface grounding the sphere rather than a flat plane.
Run it
Locally, in a window:
daslang tutorials/opengl/06_skybox/06_skybox.das
In the browser, it runs live in the daslang playground – the same .das, lowered
to WebGL2: a procedural cubemap sky with a mirror sphere reflecting it on your GPU.