Shaders in the Environment

A game’s own materials – terrain, buildings, water, characters – should be lit by the same sun, shadowed by the same cascades and clouds, and fade into the same haze as the sky and the trees. acron.shaderlib is the GLSL that makes that possible: a set of include files with the functions the sky, the clouds and the forest use themselves, and a small assembler that compiles a shader with the right version line and #define s. This page is for writing such a shader; Sky, Weather and Forests describes the environment it lights against.

The reference is lit_basic in acron/sky/shaders/, a complete lit material in some thirty lines. A game copies its calls into its own shaders.

Compiling a shader

Shader sources include library files by name and carry no #version line:

#include "atmosphere.glsl"
#include "shadows.glsl"

Environment.make_shader() compiles a vertex and fragment file pair against the environment. It adds #version 450 core (or 410 core on a macOS development machine), every frame slot’s number (FRAME_SUN_DIR, …), the sky’s table sizes, the defines the clouds added, and the caller’s own; then it pastes each #include in place, each file once. Files are looked up in the folders given as search first, then in the library:

shader = env.make_shader("terrain.vert", "terrain.frag",
                         {"ENV_PROBE": True, "DETAIL_LAYERS": 4},
                         search=("assets/shaders",))
terrain.set_shader(shader)

A define of True becomes a bare #define; False and None leave it out; floats keep their decimal point and tuples become vecN(...). The engine caches shaders by their text, so switching an option back and forth does not recompile.

Without an environment (a tool, a test), acron.shaderlib does the same work: source(text_or_name, defines) returns one complete source, assemble_sources(vertex, fragment, defines) the pair without touching the GPU (so a test can check that every variant resolves), make() compiles, and glsl_version(gsg) says which version a context gets.

What a lit shader can call

Include

Functions

frame.glsl

frame_value(slot), frame_matrix(first, p), frame_camera(), frame_time(), frame_exposure(), frame_night(), frame_metres_per_unit(), frame_display(color)

atmosphere.glsl

key_light_at(p), sky_ambient_irradiance(n), sky_radiance(dir), aerial_perspective(p), height_fog(p), apply_atmosphere(color, p) (the froxel fog with acron.fog: fog.glsl)

local_lights.glsl

local_light_irradiance(p, n) (acron.fog)

shadows.glsl

sun_visibility(p, n), sun_shadow(p, n), cloud_shadow(p)

probe.glsl

env_specular(dir, roughness), env_irradiance(n), env_brdf_approx(n_dot_v, roughness)

light_shafts.glsl

apply_light_shafts(color, p), light_shafts(p, jitter)

cloud_fog.glsl

apply_clouds(color, p)

wind.glsl

wind_offset(...) and its three parts

foliage.glsl

foliage_wrap, foliage_translucency, foliage_season, foliage_coverage

common.glsl

PI, saturate, remap, phase functions, hashes, ign (interleaved gradient noise), luminance, purkinje

octahedral.glsl

oct_encode(n), oct_decode(uv)

gbuffer_vertex.glsl

gbuffer_vertex(p), gbuffer_vertex(p, prev) (The Frame Pipeline: motion vectors)

gbuffer.glsl

gbuffer_write(n), gbuffer_prev_uv(), gbuffer_prev_depth()

ao.glsl

ambient_occlusion(uv), gbuffer_ambient_occlusion(), ao_multibounce(v, albedo), ao_bent_normal(uv, n)

stbn.glsl

screen_noise(pixel), screen_noise2(pixel), stbn(pixel, frame)

p is always a world-space position and n a world-space unit normal; the frame values hold the camera and every matrix in world space, so the vertex shader passes acron_ModelMatrix * acron_Vertex down. Including atmosphere.glsl brings frame.glsl and common.glsl with it.

The key light. frame_value(FRAME_KEY_DIR).xyz points towards the key light – the sun, or the moon at night. key_light_at(p) is the light’s illuminance arriving at p after the air above it: reddened at sunset, gone when the light is under the horizon. sun_visibility(p, n) is how much of it reaches the surface, 0 to 1: the shadow cascades (with a normal offset growing with the cascade’s texel size), the clouds’ shadow when acron.clouds runs, and the look’s shadow strength.

The sky’s light. sky_ambient_irradiance(n) is the irradiance from the sky on a surface facing n – up, down, towards and away from the sun, blended – dimmed and greyed under clouds. With an environment probe, the ENV_PROBE define brings in probe.glsl: env_irradiance(n) is the real sky’s diffuse light, clouds and all, and env_specular(r, roughness) its GGX-prefiltered reflection, combined with Karis’s split-sum approximation:

vec3 r = reflect(-v, n);
vec2 ab = env_brdf_approx(max(dot(n, v), 0.0), roughness);
vec3 specular = env_specular(r, roughness) * (f0 * ab.x + ab.y);

The air. apply_atmosphere(color, p) puts the aerial perspective (the light scattered into the view ray, and what the ray lets through) and the height fog between the camera and a lit surface – with acron.fog, the froxel volume’s shadowed, lamp-lit fog (Volumetric Fog). apply_light_shafts takes back the in-scatter where clouds and the sun’s cascades shadow the air, and apply_clouds fades geometry inside or behind the cloud layer into it. They go in this order, last.

A lit shader

A complete fragment shader, Lambert plus the probe’s reflections, with every optional part behind its define:

#include "atmosphere.glsl"
#include "shadows.glsl"
#ifdef ENV_PROBE
#include "probe.glsl"
#endif
#ifdef LIGHT_SHAFTS
#include "light_shafts.glsl"
#endif
#ifdef CLOUDS_FOG
#include "cloud_fog.glsl"
#endif

uniform sampler2D acron_Texture0;
uniform float roughness;
in vec3 v_world;
in vec3 v_normal;
in vec2 v_uv;
layout(location = 0) out vec4 o_color;

void main() {
    vec3 albedo = texture(acron_Texture0, v_uv).rgb;
    vec3 n = normalize(v_normal);
    vec3 v = normalize(frame_camera() - v_world);
    vec3 l = normalize(frame_value(FRAME_KEY_DIR).xyz);

    vec3 key = key_light_at(v_world) * sun_visibility(v_world, n);
    vec3 color = albedo / PI * key * max(dot(n, l), 0.0);
#ifdef ENV_PROBE
    color += albedo * env_irradiance(n) / PI;
    vec2 ab = env_brdf_approx(max(dot(n, v), 0.0), roughness);
    color += env_specular(reflect(-v, n), roughness) * (0.04 * ab.x + ab.y);
#else
    color += albedo * sky_ambient_irradiance(n) / PI;
#endif

    color = apply_atmosphere(color, v_world);
#ifdef LIGHT_SHAFTS
    color = apply_light_shafts(color, v_world);
#endif
#ifdef CLOUDS_FOG
    color = apply_clouds(color, v_world);
#endif
    o_color = vec4(frame_display(color), 1.0);
}

The defines are set by whoever owns the feature: ENV_PROBE by the game when it made an EnvironmentProbe; CLOUD_SHADOWS, CLOUDS_FOG and LIGHT_SHAFTS (with CLOUD_FOG_STEPS, SHAFT_SAMPLES, SHAFT_RANGE_M and the density constants) by CloudRenderer, into Environment.defines() – which is why the clouds are made before lit shaders are compiled. A shader compiled before them works, without the clouds.

The exposure contract

Every function returns light unexposed, in the scene’s light units (the sun outside the air has Look.sun_illuminance). Do all the lighting and the air in those units, and finish with frame_display(color) once, at the very end: the frame’s exposure and, without the frame pipeline, the night’s rod shift (purkinje(); under the pipeline its composite does it for the whole picture). Write linear colour: the window converts it to sRGB (framebuffer-srgb true). Do not apply the exposure twice – the environment’s DirectionalLight and AmbientLight already carry it, and a shader that reads them (acron_LightSource) must not multiply again. Tone mapping happens after, on the exposed colour: in the frame pipeline’s composite (The Frame Pipeline), or in a game’s own post pass.

sky_ambient_irradiance(n) includes the night sky’s own light (starlight, airglow, light pollution: the NIGHT_AMBIENT frame slot), so a shader lit by it is never black on a moonless night.

The inputs

The environment binds everything these functions read once, on the scene root (render): the frame values (frame_values), the sky’s tables (sky_transmittance; sky_view, the sun’s and the moon’s sky-view tables side by side; sky_aerial, the aerial perspective with the sky’s irradiance in its top row), the cascades (sun_shadow_atlas: a 1 x 1 one that reads as lit without SunShadows), the probe’s maps (probe_atlas and probe_layout) and the clouds’ weather map and noise. When a module is destroyed it leaves a 1 x 1 stand-in for each of its inputs (a shader compiled while it ran still declares them), and Environment.destroy() does the same for its own. A node under the root inherits them, so a shader needs no set_shader_input calls of its own for the environment.

A node drawn outside the scene root – an offscreen pass, a second scene – gets them with the owners’ bind methods:

env.frame.bind(card)          # the frame values
env.passes.bind(card)         # the sky's tables
probe.bind(card)              # the probe's maps

Per-frame values a game adds go into the frame values, not into shader inputs on the root (see Frame values in The Sky, Day and Night).

Wind and foliage

wind.glsl and foliage.glsl are the forest’s vegetation shading, usable for any plant: grass cards, bushes, a game’s own tree models. The wind reads two vertex columns that acron.proctree’s mesher writes – wind0 (bend weight, phase, flutter, level) and wind1 (the pivot: the stem’s centreline point or the leaf’s anchor; stiffness) – and works in the plant’s own frame, in metres, z up:

#include "frame.glsl"
#include "wind.glsl"

in vec4 wind0;
in vec4 wind1;

// in main(), with p and n in the plant's frame:
vec4 w = frame_value(FRAME_WIND);                // world m/s, gust
// (turn it into the plant's frame if the plant is rotated)
vec3 dir = normalize(vec3(w.xy, 0.0) + vec3(1e-4, 0.0, 0.0));
p += wind_offset(p, n, wind0, plant_height, dir, length(w.xy), w.w,
                 frame_time(), plant_phase, leaf_random,
                 vec4(1.0));                      // strength, bend, sway, flutter

A mesh from another tool can paint the same columns (a weight that grows from the root, and a pivot) to sway the same way. foliage_wrap and foliage_translucency light a crown as a volume and let the sun glow through the leaves; foliage_season turns a colour towards autumn or spring; foliage_coverage sharpens alpha for alpha-to-coverage.

Platform notes

The library targets GLSL 4.50 core on Windows and Linux and compiles as GLSL 4.10 core on macOS for development (ACRON_GLSL holds the version, for a shader that wants to branch on it). It does not compile for OpenGL ES or WebGL, and the Vulkan renderer is not supported yet.