10 - Deferred Shading + MRT
The capstone rung, and the last one that renders: a faithful port of the dasVulkan deferred tutorial. A concrete cat statue (CC0) sits on a red brick floor, deferred-shaded with a real shadow map, screen-space ambient occlusion, and HDR image-based lighting. Its headline is multiple render targets (MRT): a single geometry pass writes three colour attachments at once – the G-buffer – and later fullscreen passes read them back. Decoupling geometry from lighting is what lets deferred shading afford many lights and many screen-space effects: the expensive per-pixel material work runs once, then every light and every post pass is a cheap read over the screen.
This is also the first OpenGL rung to load external assets – a Wavefront OBJ mesh and PBR texture sets. The loading code is byte-identical to the desktop path; only the web needs one extra step to make those files reachable (covered below).
// ===== shared mesh inputs (cat + floor: pos + normal + uv) =====
var @in @location = 0 a_pos : float3
var @in @location = 1 a_normal : float3
var @in @location = 2 a_uv : float2
// ===== geometry / shadow uniforms + varyings =====
var @uniform u_model : float4x4
var @uniform u_view : float4x4
var @uniform u_proj : float4x4
var @uniform u_light_vp : float4x4
var @uniform u_material : float // 0 = cat, 1 = floor
var @inout gv_world_pos : float3
var @inout gv_world_normal : float3
var @inout gv_uv : float2
// The three G-buffer outputs (@out @location=N -> layout(location=N) out vec4 ...).
var @out @location = 0 g_albedo_out : float4
var @out @location = 1 g_normal_out : float4
var @out @location = 2 g_worldpos_out : float4
// cat + brick PBR maps; all bound together, the fragment branches on u_material.
var @uniform @stage = 0 cat_albedo : sampler2D
var @uniform @stage = 1 cat_normal : sampler2D
var @uniform @stage = 2 cat_arm : sampler2D // AO(r) / Roughness(g) / Metallic(b)
var @uniform @stage = 3 floor_albedo : sampler2D
var @uniform @stage = 4 floor_normal : sampler2D
var @uniform @stage = 5 floor_ao : sampler2D
var @uniform @stage = 6 floor_roughness : sampler2D
// ----- shadow pass: depth-only from the sun's POV -----
[vertex_program]
def shadow_vs {
gl_Position = u_light_vp * u_model * float4(a_pos, 1.0)
}
[fragment_program]
def shadow_fs {
// depth-only framebuffer (colour draw buffer is GL_NONE) -- this is discarded.
g_albedo_out = float4(1.0, 1.0, 1.0, 1.0)
}
// ----- geometry pass -----
[vertex_program]
def gbuffer_vs {
let world = u_model * float4(a_pos, 1.0)
gv_world_pos = world.xyz
gv_world_normal = normalize((u_model * float4(a_normal, 0.0)).xyz)
gv_uv = a_uv
gl_Position = u_proj * u_view * world
}
// Derivative-based TBN (Schueler 2010): a tangent frame from screen-space derivatives of
// world position + uv, no per-vertex tangent buffer. dFdx/dFdy live in the shared module.
def perturb_normal(nrm, p : float3; uv : float2; tn : float3) : float3 {
let dp1 = dFdx(p)
let dp2 = dFdy(p)
let duv1 = dFdx(uv)
let duv2 = dFdy(uv)
let dp2perp = cross(dp2, nrm)
let dp1perp = cross(nrm, dp1)
let T = dp2perp * duv1.x + dp1perp * duv2.x
let B = dp2perp * duv1.y + dp1perp * duv2.y
let invmax = 1.0 / sqrt(max(dot(T, T), dot(B, B)))
return normalize(T * (tn.x * invmax) + B * (tn.y * invmax) + nrm * tn.z)
}
[fragment_program]
def gbuffer_fs {
let n = normalize(gv_world_normal)
var albedo = float3(0.0, 0.0, 0.0)
var spec_i = 0.0
var shininess = 32.0
var perturbed = n
// sRGB albedo textures -> linear so the deferred lighting is done in linear space
// (the lighting pass gamma-encodes the final result, mirroring the Vulkan sRGB target).
let sRGB = float3(2.2, 2.2, 2.2)
if (u_material > 0.5) {
let uv = float2(gv_world_pos.x, gv_world_pos.z) * 0.35
let fb_ao = texture(floor_ao, uv).x
albedo = pow(texture(floor_albedo, uv).rgb, sRGB) * (0.5 + 0.5 * fb_ao)
spec_i = (1.0 - texture(floor_roughness, uv).x) * 0.4
shininess = 24.0
// boost the tangent xy so the mortar reads, then renormalize
let tn_raw = texture(floor_normal, uv).rgb * 2.0 - float3(1.0, 1.0, 1.0)
let tn = normalize(float3(tn_raw.x * 3.0, tn_raw.y * 3.0, tn_raw.z))
perturbed = perturb_normal(n, gv_world_pos, uv, tn)
} else {
let alb = pow(texture(cat_albedo, gv_uv).rgb, sRGB)
let arm = texture(cat_arm, gv_uv).rgb
albedo = alb * (0.55 + 0.45 * arm.x)
spec_i = (1.0 - arm.y) * 0.6
shininess = 48.0
let tn = normalize(texture(cat_normal, gv_uv).rgb * 2.0 - float3(1.0, 1.0, 1.0))
perturbed = perturb_normal(n, gv_world_pos, gv_uv, tn)
}
g_albedo_out = float4(albedo, spec_i)
g_normal_out = float4(perturbed, shininess)
g_worldpos_out = float4(gv_world_pos, u_material)
}
// ===== fullscreen passes (SSAO + lighting share post_vs) =====
var @in @location = 0 q_pos : float2
var @inout l_uv : float2
[vertex_program]
def post_vs {
gl_Position = float4(q_pos, 0.0, 1.0)
l_uv = q_pos * 0.5 + float2(0.5, 0.5)
}
def smoothstep3(a, b, x : float) : float {
let t = clamp((x - a) / (b - a), 0.0, 1.0)
return t * t * (3.0 - 2.0 * t)
}
def hash21(p : float2) : float {
let v = sin(dot(p, float2(127.1, 311.7))) * 43758.5453
return v - floor(v)
}
// ----- SSAO pass -----
var @uniform @stage = 0 ssao_g_normal : sampler2D
var @uniform @stage = 1 ssao_g_worldpos : sampler2D
var @out ssao_out : float4
let SSAO_K = 16
let SSAO_RADIUS = 0.6
let SSAO_BIAS = 0.03
let SSAO_STRENGTH = 1.7
[fragment_program]
def ssao_fs {
let n_raw = texture(ssao_g_normal, l_uv).xyz
var ao = 1.0
if (dot(n_raw, n_raw) >= 0.01) {
let n = normalize(n_raw)
let p = texture(ssao_g_worldpos, l_uv).xyz
let cur_vz = (u_view * float4(p, 1.0)).z
var up = float3(0.0, 1.0, 0.0)
if (abs(n.y) > 0.95) {
up = float3(1.0, 0.0, 0.0)
}
let tangent = normalize(cross(up, n))
let bitangent = cross(n, tangent)
let ang0 = hash21(l_uv * 1024.0) * 6.2831853
var occ = 0.0
for (i in range(SSAO_K)) {
let fi = (float(i) + 0.5) / float(SSAO_K)
let ang = ang0 + float(i) * 2.3998277
let rr = SSAO_RADIUS * sqrt(fi)
let off = (cos(ang) * tangent + sin(ang) * bitangent) * rr + n * (rr * 0.5)
let sp = p + off
let clip = u_proj * u_view * float4(sp, 1.0)
let wv = max(clip.w, 0.0001)
let suv = float2(clip.x / wv * 0.5 + 0.5, clip.y / wv * 0.5 + 0.5)
let stored_p = texture(ssao_g_worldpos, suv).xyz
let stored_n = texture(ssao_g_normal, suv).xyz
let stored_vz = (u_view * float4(stored_p, 1.0)).z
let sample_vz = (u_view * float4(sp, 1.0)).z
let range_check = smoothstep3(0.0, 1.0, SSAO_RADIUS / max(abs(cur_vz - stored_vz), 0.0001))
// view looks down -Z: a stored surface closer to the camera (greater z) occludes
if (clip.w > 0.0 && suv.x >= 0.0 && suv.x <= 1.0 && suv.y >= 0.0 && suv.y <= 1.0
&& dot(stored_n, stored_n) > 0.01 && stored_vz >= sample_vz + SSAO_BIAS) {
occ = occ + range_check
}
}
ao = clamp(1.0 - SSAO_STRENGTH * occ / float(SSAO_K), 0.0, 1.0)
}
ssao_out = float4(ao, ao, ao, 1.0)
}
// ----- lighting pass -----
var @uniform @stage = 0 lit_albedo : sampler2D
var @uniform @stage = 1 lit_normal : sampler2D
var @uniform @stage = 2 lit_worldpos : sampler2D
var @uniform @stage = 3 lit_ssao : sampler2D
var @uniform @stage = 4 shadow_map : sampler2DShadow
var @uniform @stage = 5 env_map : sampler2D
var @uniform u_cam_pos : float3
var @uniform u_light_dir : float3
var @uniform u_time : float
var @out frag_color : float4
let SHADOW_DIM_F = 1024.0
def reflect3(i, nn : float3) : float3 {
return i - nn * (2.0 * dot(nn, i))
}
def equirect_uv(d : float3) : float2 {
let u = atan2(d.z, d.x) * 0.15915494 + 0.5
let v = acos(clamp(d.y, -1.0, 1.0)) * 0.31830989
return float2(u, v)
}
def pcf_shadow(uv : float2; ref : float) : float {
let texel = 1.0 / SHADOW_DIM_F
var sum = 0.0
for (j in range(-2, 3)) {
for (i in range(-2, 3)) {
let off = float2(float(i), float(j)) * texel
sum = sum + textureCompare(shadow_map, uv + off, ref)
}
}
return sum * (1.0 / 25.0)
}
def point_light(lp, lcol : float3; p, nrm, vdir : float3; shininess, spec_i, rng : float) : float3 {
let lvec = lp - p
let d = max(length(lvec), 0.0001)
let ldir = lvec / float3(d, d, d)
let ndotl = max(dot(nrm, ldir), 0.0)
let atten = pow(max(1.0 - d / rng, 0.0), 2.0)
let hv = normalize(ldir + vdir)
let spec = pow(max(dot(nrm, hv), 0.0), shininess) * spec_i
return lcol * (ndotl + spec) * atten
}
[fragment_program]
def lighting_fs {
// ----- bottom filmstrip: raw G-buffer (albedo / normal / world-pos) + SSAO + shadow -----
let TH = 0.18
let GAP = 0.012
let Y0 = 0.02
let in_strip = l_uv.y >= Y0 && l_uv.y <= Y0 + TH
let tv = (l_uv.y - Y0) / TH
let x0 = GAP
let x1 = GAP * 2.0 + TH
let x2 = GAP * 3.0 + TH * 2.0
let x3 = GAP * 4.0 + TH * 3.0
let albedo_spec = texture(lit_albedo, l_uv)
let normal_sh = texture(lit_normal, l_uv)
let worldpos = texture(lit_worldpos, l_uv)
let ssao_factor = texture(lit_ssao, l_uv).x
let albedo = albedo_spec.rgb
let spec_i = albedo_spec.a
let shininess = normal_sh.a
let p = worldpos.xyz
let n_raw = normal_sh.xyz
let is_bg = dot(n_raw, n_raw) < 0.01
var n = float3(0.0, 1.0, 0.0)
if (!is_bg) {
n = normalize(n_raw)
}
var out_col = float3(0.0, 0.0, 0.0)
if (in_strip && l_uv.x >= x0 && l_uv.x <= x0 + TH) {
out_col = texture(lit_albedo, float2((l_uv.x - x0) / TH, tv)).rgb
} elif (in_strip && l_uv.x >= x1 && l_uv.x <= x1 + TH) {
let nn = texture(lit_normal, float2((l_uv.x - x1) / TH, tv)).xyz
out_col = nn * 0.5 + float3(0.5, 0.5, 0.5)
} elif (in_strip && l_uv.x >= x2 && l_uv.x <= x2 + TH) {
let pp = texture(lit_worldpos, float2((l_uv.x - x2) / TH, tv)).xyz
out_col = pp * 0.12 + float3(0.5, 0.5, 0.5)
} elif (in_strip && l_uv.x >= x3 && l_uv.x <= x3 + TH) {
let ao = texture(lit_ssao, float2((l_uv.x - x3) / TH, tv)).x
out_col = float3(ao, ao, ao)
} elif (is_bg) {
let t = clamp(l_uv.y, 0.0, 1.0)
out_col = lerp(float3(0.10, 0.10, 0.16), float3(0.34, 0.24, 0.26), float3(t, t, t))
} else {
let view_dir = normalize(u_cam_pos - p)
// shadowed sun: light-space NDC -> shadow uv + depth ref (GL z in [-1,1] -> [0,1])
let sun_dir = normalize(u_light_dir)
let ndotl_sun = max(dot(n, sun_dir), 0.0)
let lc = u_light_vp * float4(p, 1.0)
let lw = max(lc.w, 0.0001)
let ndc = float3(lc.x / lw, lc.y / lw, lc.z / lw)
let shadow_uv = float2(ndc.x * 0.5 + 0.5, ndc.y * 0.5 + 0.5)
let ref_depth = clamp(ndc.z * 0.5 + 0.5, 0.0, 1.0)
let bias = max(0.0025 * (1.0 - ndotl_sun), 0.0009)
var shadow = pcf_shadow(shadow_uv, ref_depth - bias)
if (ndotl_sun < 0.05) {
shadow = 0.0
}
let hv_sun = normalize(sun_dir + view_dir)
let spec_sun = pow(max(dot(n, hv_sun), 0.0), shininess) * spec_i
let sun = float3(1.25, 1.10, 0.80) * (ndotl_sun + spec_sun) * shadow
// three orbiting coloured point lights, from u_time
var pts = float3(0.0, 0.0, 0.0)
for (i in range(3)) {
let fi = float(i)
let ang = u_time * 0.7 + fi * 2.0944
let lp = float3(cos(ang) * 3.2, 1.6 + 0.6 * sin(u_time + fi), sin(ang) * 3.2)
let lcol = float3(0.5 + 0.5 * cos(fi * 2.5), 0.5 + 0.5 * cos(fi * 2.5 + 2.0), 0.5 + 0.5 * cos(fi * 2.5 + 4.0))
pts = pts + point_light(lp, lcol * 1.4, p, n, view_dir, shininess, spec_i, 5.5)
}
let ao = clamp(ssao_factor, 0.0, 1.0)
let amb_t = n.y * 0.5 + 0.5
let hemi = lerp(float3(0.08, 0.06, 0.05), float3(0.22, 0.24, 0.36), float3(amb_t, amb_t, amb_t))
let env_amb = texture(env_map, equirect_uv(n)).rgb
let ambient = lerp(hemi, env_amb, float3(0.4, 0.4, 0.4))
let inc = float3(-view_dir.x, -view_dir.y, -view_dir.z)
let rdir = reflect3(inc, n)
let env_refl = texture(env_map, equirect_uv(rdir)).rgb
let fres = pow(1.0 - max(dot(n, view_dir), 0.0), 4.0)
let env_spec = env_refl * (spec_i * (0.10 + 0.7 * fres))
let lighting = (ambient * ao) + (sun + pts) * (0.55 + 0.45 * ao) + env_spec * ao
// linear -> sRGB for the display (the default GL framebuffer does no sRGB encode)
let g = float3(0.4545, 0.4545, 0.4545)
out_col = pow(albedo * lighting, g)
}
frag_color = float4(out_col, 1.0)
}
// ===== GL objects =====
var prog_shadow : uint
var prog_gbuffer : uint
var prog_ssao : uint
var prog_light : uint
var cat_vao, cat_vbo, cat_ebo : uint
var cat_index_count : int
var floor_vao, floor_vbo, floor_ebo : uint
var quad_vao, quad_vbo : uint
var g_fbo, g_albedo, g_normal, g_worldpos, g_depth : uint
var ssao_fbo, ssao_tex : uint
var shadow_fbo, shadow_tex : uint
var g_w : int = 0
var g_h : int = 0
var tex_cat_diff, tex_cat_nor, tex_cat_arm : uint
var tex_brick_color, tex_brick_nor, tex_brick_ao, tex_brick_rough : uint
var tex_env : uint
var window : GLFWwindow?
var time : float = 0.0
let SHADOW_DIM = 1024
let CAT_OBJ = "tutorials/_assets/cat/concrete_cat_statue.obj"
let CAT_DIFF = "tutorials/_assets/cat/textures/concrete_cat_statue_diff_2k.jpg"
let CAT_NOR = "tutorials/_assets/cat/textures/concrete_cat_statue_nor_gl_2k.jpg"
let CAT_ARM = "tutorials/_assets/cat/textures/concrete_cat_statue_arm_2k.jpg"
let BRICK_COLOR = "tutorials/_assets/brick/Bricks031_1K-JPG_Color.jpg"
let BRICK_NOR = "tutorials/_assets/brick/Bricks031_1K-JPG_NormalGL.jpg"
let BRICK_AO = "tutorials/_assets/brick/Bricks031_1K-JPG_AmbientOcclusion.jpg"
let BRICK_ROUGH = "tutorials/_assets/brick/Bricks031_1K-JPG_Roughness.jpg"
let HDRI = "tutorials/_assets/hdri/cannon_2k.hdr"
[vertex_buffer]
struct MeshVertex {
pos : float3
normal : float3
uv : float2
}
def identity_m4 : float4x4 {
var m : float4x4
m[0] = float4(1, 0, 0, 0)
m[1] = float4(0, 1, 0, 0)
m[2] = float4(0, 0, 1, 0)
m[3] = float4(0, 0, 0, 1)
return m
}
let floor_verts = [MeshVertex(
pos=float3(-6, 0, 6), normal=float3(0, 1, 0), uv=float2(0, 0)), MeshVertex(
pos=float3(6, 0, 6), normal=float3(0, 1, 0), uv=float2(1, 0)), MeshVertex(
pos=float3(6, 0, -6), normal=float3(0, 1, 0), uv=float2(1, 1)), MeshVertex(
pos=float3(-6, 0, -6), normal=float3(0, 1, 0), uv=float2(0, 1)
)];
let floor_indices = fixed_array<int>(0, 1, 2, 0, 2, 3)
let quad = [MeshVertex(pos=float3(-1, -1, 0), normal=float3(), uv=float2()), MeshVertex(
pos=float3(3, -1, 0), normal=float3(), uv=float2()), MeshVertex(
pos=float3(-1, 3, 0), normal=float3(), uv=float2())];
def load_hdr_texture(fname : string) : uint {
var x, y, comp : int
let data = stbi_loadf(fname, safe_addr(x), safe_addr(y), safe_addr(comp), 3)
if (data == null) {
panic("HDR load failed: " + fname)
}
var tex : uint
glGenTextures(1, safe_addr(tex))
glBindTexture(GL_TEXTURE_2D, tex)
glTexImage2D(GL_TEXTURE_2D, 0, int(GL_RGB16F), x, y, 0, GL_RGB, GL_FLOAT, unsafe(reinterpret<void?>(data)))
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
stbi_image_free(unsafe(reinterpret<void?>(data)))
return tex
}
def upload_cat {
var frag <- load_obj_mesh(CAT_OBJ, false)
if (empty(frag.vertices)) {
panic("cat OBJ failed to load")
}
var bmin = float3(1.0e30, 1.0e30, 1.0e30)
var bmax = float3(-1.0e30, -1.0e30, -1.0e30)
for (gv in frag.vertices) {
bmin = min(bmin, gv.xyz)
bmax = max(bmax, gv.xyz)
}
let center = (bmin + bmax) * 0.5
let s = 2.2 / max(bmax.y - bmin.y, 0.001)
var verts : array<MeshVertex>
verts |> reserve(length(frag.vertices))
for (gv in frag.vertices) {
let pp = float3((gv.xyz.x - center.x) * s, (gv.xyz.y - bmin.y) * s, (gv.xyz.z - center.z) * s)
verts |> push(MeshVertex(pos = pp, normal = gv.normal, uv = gv.uv))
}
cat_index_count = length(frag.indices)
glGenVertexArrays(1, safe_addr(cat_vao))
glBindVertexArray(cat_vao)
glGenBuffers(1, safe_addr(cat_vbo))
glBindBuffer(GL_ARRAY_BUFFER, cat_vbo)
glBufferData(GL_ARRAY_BUFFER, verts, GL_STATIC_DRAW)
bind_vertex_buffer(null, type<MeshVertex>)
glGenBuffers(1, safe_addr(cat_ebo))
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, cat_ebo)
glBufferData(GL_ELEMENT_ARRAY_BUFFER, frag.indices, GL_STATIC_DRAW)
delete verts
delete frag
}
def make_color_attachment(w, h, internal : int; fmt, typ, attach, filt : uint) : uint {
var tex : uint
glGenTextures(1, safe_addr(tex))
glBindTexture(GL_TEXTURE_2D, tex)
glTexImage2D(GL_TEXTURE_2D, 0, internal, w, h, 0, fmt, typ, null)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, filt)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, filt)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE)
glFramebufferTexture2D(GL_FRAMEBUFFER, attach, GL_TEXTURE_2D, tex, 0)
return tex
}
def setup_targets(w, h : int) {
if (g_fbo != 0u) {
glDeleteFramebuffers(1, safe_addr(g_fbo))
glDeleteTextures(1, safe_addr(g_albedo))
glDeleteTextures(1, safe_addr(g_normal))
glDeleteTextures(1, safe_addr(g_worldpos))
glDeleteRenderbuffers(1, safe_addr(g_depth))
glDeleteFramebuffers(1, safe_addr(ssao_fbo))
glDeleteTextures(1, safe_addr(ssao_tex))
}
// G-buffer: 3 MRT + depth
glGenFramebuffers(1, safe_addr(g_fbo))
glBindFramebuffer(GL_FRAMEBUFFER, g_fbo)
g_albedo = make_color_attachment(w, h, int(GL_RGBA8), GL_RGBA, GL_UNSIGNED_BYTE, GL_COLOR_ATTACHMENT0, GL_NEAREST)
g_normal = make_color_attachment(w, h, int(GL_RGBA16F), GL_RGBA, GL_HALF_FLOAT, GL_COLOR_ATTACHMENT1, GL_NEAREST)
g_worldpos = make_color_attachment(w, h, int(GL_RGBA16F), GL_RGBA, GL_HALF_FLOAT, GL_COLOR_ATTACHMENT2, GL_NEAREST)
glGenRenderbuffers(1, safe_addr(g_depth))
glBindRenderbuffer(GL_RENDERBUFFER, g_depth)
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT16, w, h)
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, g_depth)
var draw_bufs = fixed_array(GL_COLOR_ATTACHMENT0, GL_COLOR_ATTACHMENT1, GL_COLOR_ATTACHMENT2)
glDrawBuffers(3, safe_addr(draw_bufs[0]))
if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) {
panic("G-buffer framebuffer incomplete")
}
// SSAO: single colour
glGenFramebuffers(1, safe_addr(ssao_fbo))
glBindFramebuffer(GL_FRAMEBUFFER, ssao_fbo)
ssao_tex = make_color_attachment(w, h, int(GL_RGBA8), GL_RGBA, GL_UNSIGNED_BYTE, GL_COLOR_ATTACHMENT0, GL_LINEAR)
if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) {
panic("SSAO framebuffer incomplete")
}
glBindFramebuffer(GL_FRAMEBUFFER, 0u)
g_w = w
g_h = h
}
def create_shadow_target {
glGenTextures(1, safe_addr(shadow_tex))
glBindTexture(GL_TEXTURE_2D, shadow_tex)
glTexImage2D(GL_TEXTURE_2D, 0, int(GL_DEPTH_COMPONENT24), SHADOW_DIM, SHADOW_DIM, 0,
GL_DEPTH_COMPONENT, GL_UNSIGNED_INT, null)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_COMPARE_MODE, GL_COMPARE_REF_TO_TEXTURE)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_COMPARE_FUNC, GL_LEQUAL)
glGenFramebuffers(1, safe_addr(shadow_fbo))
glBindFramebuffer(GL_FRAMEBUFFER, shadow_fbo)
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, shadow_tex, 0)
var none_buf = uint(GL_NONE)
glDrawBuffers(1, safe_addr(none_buf))
glReadBuffer(uint(GL_NONE))
if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) {
panic("shadow framebuffer incomplete")
}
glBindFramebuffer(GL_FRAMEBUFFER, 0u)
}
[export]
def init {
if (glfwInit() == 0) {
panic("can't init glfw")
}
glfwInitOpenGL(3, 3)
window = glfwCreateWindow(640, 480, "OpenGL - 10 deferred shading (cat on brick)", null, null)
if (window == null) {
panic("can't create window")
}
glfwMakeContextCurrent(window)
prog_shadow = create_shader_program(@@shadow_vs, @@shadow_fs)
prog_gbuffer = create_shader_program(@@gbuffer_vs, @@gbuffer_fs)
prog_ssao = create_shader_program(@@post_vs, @@ssao_fs)
prog_light = create_shader_program(@@post_vs, @@lighting_fs)
upload_cat()
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<MeshVertex>)
glGenBuffers(1, safe_addr(floor_ebo))
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, floor_ebo)
glBufferData(GL_ELEMENT_ARRAY_BUFFER, floor_indices, GL_STATIC_DRAW)
glGenVertexArrays(1, safe_addr(quad_vao))
glBindVertexArray(quad_vao)
glGenBuffers(1, safe_addr(quad_vbo))
glBindBuffer(GL_ARRAY_BUFFER, quad_vbo)
glBufferData(GL_ARRAY_BUFFER, quad, GL_STATIC_DRAW)
bind_vertex_buffer(null, type<MeshVertex>)
tex_cat_diff = load_image_from_file(CAT_DIFF, false)
tex_cat_nor = load_image_from_file(CAT_NOR, false)
tex_cat_arm = load_image_from_file(CAT_ARM, false)
tex_brick_color = load_image_from_file(BRICK_COLOR, false)
tex_brick_nor = load_image_from_file(BRICK_NOR, false)
tex_brick_ao = load_image_from_file(BRICK_AO, false)
tex_brick_rough = load_image_from_file(BRICK_ROUGH, false)
tex_env = load_hdr_texture(HDRI)
create_shadow_target()
setup_targets(640, 480)
print("deferred assets loaded: cat={cat_index_count} idx, env={tex_env}\n")
}
def draw_scene_geometry {
u_model = identity_m4()
u_material = 1.0
gbuffer_vs_bind_uniform(prog_gbuffer)
gbuffer_fs_bind_uniform(prog_gbuffer)
glBindVertexArray(floor_vao)
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, floor_ebo)
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, null)
u_material = 0.0
gbuffer_vs_bind_uniform(prog_gbuffer)
gbuffer_fs_bind_uniform(prog_gbuffer)
glBindVertexArray(cat_vao)
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, cat_ebo)
glDrawElements(GL_TRIANGLES, cat_index_count, GL_UNSIGNED_INT, null)
}
[export]
def update : bool {
time += 1.0 / 60.0
let t = time
var dw, dh : int
glfwGetFramebufferSize(window, dw, dh)
let w = max(dw, 1)
let h = max(dh, 1)
if (w != g_w || h != g_h) {
setup_targets(w, h)
}
let cam_angle = t * 0.3
let cam = float3(cos(cam_angle) * 4.6, 2.3, sin(cam_angle) * 4.6)
u_view = look_at_rh(cam, float3(0.0, 1.0, 0.0), float3(0, 1, 0))
u_proj = perspective_rh_opengl(50.0 * PI / 180.0, float(w) / float(h), 0.1, 60.0)
u_cam_pos = cam
let sun_yaw = t * 0.25
let light_dir = normalize(float3(cos(sun_yaw) * 0.6, 1.05, sin(sun_yaw) * 0.6))
u_light_dir = light_dir
u_light_vp = ortho_rh(-4.0, 4.0, -4.0, 4.0, 0.1, 20.0) * look_at_rh(light_dir * 8.0, float3(0.0, 0.8, 0.0), float3(0, 1, 0))
u_time = t
// ===== Pass 1: shadow map from the sun's POV =====
glBindFramebuffer(GL_FRAMEBUFFER, shadow_fbo)
glViewport(0, 0, SHADOW_DIM, SHADOW_DIM)
glClear(GL_DEPTH_BUFFER_BIT)
glEnable(GL_DEPTH_TEST)
glDepthFunc(GL_LESS)
glDisable(GL_CULL_FACE)
glEnable(GL_POLYGON_OFFSET_FILL)
glPolygonOffset(2.0, 4.0)
glUseProgram(prog_shadow)
u_model = identity_m4()
shadow_vs_bind_uniform(prog_shadow)
glBindVertexArray(floor_vao)
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, floor_ebo)
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, null)
glBindVertexArray(cat_vao)
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, cat_ebo)
glDrawElements(GL_TRIANGLES, cat_index_count, GL_UNSIGNED_INT, null)
glDisable(GL_POLYGON_OFFSET_FILL)
// ===== Pass 2: geometry -> G-buffer (MRT) =====
glBindFramebuffer(GL_FRAMEBUFFER, g_fbo)
glViewport(0, 0, g_w, g_h)
glClearColor(0.0, 0.0, 0.0, 0.0)
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
glEnable(GL_DEPTH_TEST)
glDepthFunc(GL_LESS)
glDisable(GL_CULL_FACE)
glDisable(GL_BLEND)
glUseProgram(prog_gbuffer)
cat_albedo := tex_cat_diff
cat_normal := tex_cat_nor
cat_arm := tex_cat_arm
floor_albedo := tex_brick_color
floor_normal := tex_brick_nor
floor_ao := tex_brick_ao
floor_roughness := tex_brick_rough
draw_scene_geometry()
// ===== Pass 3: SSAO -> single-channel target =====
glBindFramebuffer(GL_FRAMEBUFFER, ssao_fbo)
glViewport(0, 0, g_w, g_h)
glDisable(GL_DEPTH_TEST)
glUseProgram(prog_ssao)
ssao_g_normal := g_normal
ssao_g_worldpos := g_worldpos
post_vs_bind_uniform(prog_ssao)
ssao_fs_bind_uniform(prog_ssao)
glBindVertexArray(quad_vao)
glDrawArrays(GL_TRIANGLES, 0, 3)
// ===== Pass 4: deferred lighting -> screen =====
glBindFramebuffer(GL_FRAMEBUFFER, 0u)
glViewport(0, 0, w, h)
glClearColor(0.0, 0.0, 0.0, 1.0)
glClear(GL_COLOR_BUFFER_BIT)
glDisable(GL_DEPTH_TEST)
glDisable(GL_CULL_FACE)
glUseProgram(prog_light)
lit_albedo := g_albedo
lit_normal := g_normal
lit_worldpos := g_worldpos
lit_ssao := ssao_tex
shadow_map := shadow_tex
env_map := tex_env
post_vs_bind_uniform(prog_light)
lighting_fs_bind_uniform(prog_light)
glBindVertexArray(quad_vao)
glDrawArrays(GL_TRIANGLES, 0, 3)
glfwPollEvents()
glfwSwapBuffers(window)
return glfwWindowShouldClose(window) == 0
}
[export]
def shutdown {
glfwDestroyWindow(window)
glfwTerminate()
}
[export]
def main {
init()
while (update()) {
}
shutdown()
}
Loading external assets
Every prior rung generated its geometry and textures procedurally. This one reads them from disk:
var frag <- load_obj_mesh(CAT_OBJ, false)
...
tex_cat_diff = load_image_from_file(CAT_DIFF, false)
tex_env = load_hdr_texture(HDRI) // stbi_loadf -> GL_RGB16F
load_obj_mesh (from geometry/geom_gen) and load_image_from_file (from
opengl/opengl_boost) are plain fopen underneath – the same calls on desktop and
in the browser. The one web-vs-desktop difference is that the browser’s in-memory
filesystem starts empty, so the asset bytes have to be written into it before the
program’s fopen runs. The playground does that from a sidecar manifest
(gl_10_deferred.das.assets.json, an array of repo-relative paths): it fetches each file
over HTTP and writes it into the virtual filesystem at the exact path the tutorial expects.
The daslang code never changes – the asset rail is entirely on the harness side.
The G-buffer and the MRT write
setup_targets builds the G-buffer framebuffer with three colour attachments plus a
depth renderbuffer:
attachment 0 –
GL_RGBA8: albedo in.rgb, specular intensity in.a;attachment 1 –
GL_RGBA16F: world-space (normal-mapped) normal in.xyz, Blinn shininess in.a;attachment 2 –
GL_RGBA16F: world-space position in.xyz, material tag in.a.
Normals are signed and positions run outside [0, 1], so those two targets need the
float format (GL_RGBA16F is colour-renderable on WebGL2, as tutorial 11 established).
The three attachments are enabled together:
var draw_bufs = fixed_array(GL_COLOR_ATTACHMENT0, GL_COLOR_ATTACHMENT1, GL_COLOR_ATTACHMENT2)
glDrawBuffers(3, safe_addr(draw_bufs[0]))
Without glDrawBuffers only attachment 0 would receive output. With it, the geometry
fragment shader’s three @out @location = N writes land in their matching attachments in
a single draw – one geometry pass, the whole G-buffer.
That @location on a fragment output is load-bearing here. WebGL2 / GLSL ES 3.00
requires an explicit layout(location = N) on every fragment output once there is
more than one; a multi-output fragment shader without them is a compile error in the
browser (desktop #version 330 is lenient and accepts the omission). The GLSL emitter
now carries the @location qualifier through to fragment outputs, not just vertex ones –
so @out @location = 0 g_albedo_out lowers to layout(location = 0) out vec4
g_albedo_out.
Normals come from the textures, not the mesh: perturb_normal reconstructs a tangent
frame from the screen-space derivatives (dFdx / dFdy, from the shared shader module)
of world position and uv, so no per-vertex tangent buffer is needed.
Four passes
The frame is four passes, the same architecture the dasVulkan rung uses – every piece WebGL2-portable:
Shadow – a depth-only render from the sun’s point of view into a depth texture (the tutorial-08 pattern:
GL_DEPTH_COMPONENT24,COMPARE_REF_TO_TEXTURE, colour draw bufferGL_NONE, a polygon-offset to push back the depth and kill acne).Geometry – the cat and floor drawn once into the 3-attachment G-buffer.
SSAO – a fullscreen pass that samples the G-buffer normal and position as textures, so it can read neighbours, and computes a hemisphere-kernel occlusion factor that darkens crevices and the cat-floor contact shadow.
Lighting – a fullscreen pass that samples everything and composites the frame.
Passes 3 and 4 are fullscreen and share post_vs (a single covering triangle from a
small position VBO – not gl_VertexID, kept explicit for portability).
The lighting pass
The lighting fragment shader reads the G-buffer back and reconstructs each surface – albedo, world normal, shininess, world position – then accumulates:
one shadowed directional sun, sampled through a 5x5 PCF kernel (
textureCompareon thesampler2DShadow). OpenGL’s clip-spacezis[-1, 1], so the light-space depth reference isndc.z * 0.5 + 0.5– the one genuine GL-vs-Vulkan shader delta (the Vulkan rung usesndc.zdirectly, since itszis already[0, 1]);three orbiting coloured point lights, computed procedurally from
u_timeand the loop index, so no per-light uniforms are needed – this is the deferred dividend: each light is a cheap add over the screen, independent of scene complexity;hemisphere ambient blended with HDR image-based lighting – the equirectangular environment sampled along the normal for diffuse and along the reflected view vector for a Fresnel-weighted specular term;
the SSAO factor, modulating ambient and IBL.
Lighting is done in linear space: the sRGB albedo textures are decoded with pow(tex,
2.2) on the way in, and the final colour is gamma-encoded with pow(c, 0.4545) on the
way out, because the default GL framebuffer does no sRGB encode of its own.
Seeing the G-buffer
A four-tile filmstrip along the bottom of the lighting pass previews the raw buffers the
earlier passes produced: albedo, the world normal remapped to n * 0.5 + 0.5, world
position scaled into a visible range, and the SSAO factor. It is drawn by remapping
l_uv into each tile and sampling the matching texture – a direct window onto what the
geometry and SSAO passes wrote.
Where the portable floor ends
This is the last rung that renders. The dasVulkan ladder continues into GPU-driven culling and mesh shaders, which depend on compute and indirect draw – capabilities WebGL2 has no answer for at all. Deferred shading, by contrast, ports in full: shadow maps, float targets, and neighbour-sampling SSAO are all things the earlier rungs already proved WebGL2 can do, which is why the whole pipeline made the crossing intact. This is where the portable floor ends.
Run it
Locally, in a window:
daslang tutorials/opengl/10_deferred/10_deferred.das
In the browser, it runs live in the daslang playground – the same .das, lowered to
WebGL2, with the cat mesh and PBR textures fetched into the virtual filesystem first: a
concrete cat on a brick floor, lit by a shadow-casting sun and three drifting coloured
lights, with the raw G-buffer and SSAO channels previewed along the bottom.