Physically Based Shading

The acron.shading module is the engine’s native physically based renderer for surfaces: a glTF-shaped material (PbrMaterial), the attribute that puts it on nodes (PbrMaterialAttrib), a shader generator that writes one GLSL shader per combination of features (PbrShaderGenerator), the BRDF lookup table it reads (BrdfLuts), the image-based light of an environment map (IblEnvironment), helpers for photometric light units (LightUnits), a physical camera (PhysicalCamera) and a minimal frame of scene buffer and tonemapper (PbrPipeline), and the lighting context through which an environment lights the surfaces (LightingContext). It is C++ with Python bindings, so a C++ game uses it exactly as a Python one does.

Note

This is the first milestone of the module: the standard metallic-roughness lobe, punctual lights with their shadows, image-based light and exposure. Clear coat, sheen, a light buffer for many lights and area lights follow. In Python, acron.pbr.init() sets this module up for a game (the generator, the frame, exposure and image-based light).

Quick start

from acron.core import DirectionalLight, PointLight, Spotlight
from acron.core import Filename
from acron.shading import (PbrPipeline, PbrMaterial, PbrMaterialAttrib,
                             IblEnvironment, LightUnits, PhysicalCamera)

pipeline = PbrPipeline(base.win, base.cam, render)

brass = PbrMaterial("brass")
brass.base_color = (0.91, 0.78, 0.42, 1)
brass.metallic = 1.0
brass.roughness = 0.35
model.set_attrib(PbrMaterialAttrib.make(brass))

sun = DirectionalLight("sun")
LightUnits.set_illuminance(sun, 100000.0, (1.0, 0.96, 0.9))   # lux
render.set_light(render.attach_new_node(sun))

lamp = PointLight("lamp")
LightUnits.set_luminous_power(lamp, 800.0)                    # lumens
lamp.max_distance = 12.0                                      # metres
render.set_light(render.attach_new_node(lamp))

spot = Spotlight("spot")
spot.intensity = 2000.0                                       # candela
spot.get_lens().set_fov(60)                                   # outer: 30°
spot.inner_cone_angle = 20.0                                  # degrees
spot.set_shadow_caster(True, 1024, 1024)
render.set_light(render.attach_new_node(spot))

sky = IblEnvironment(loader.load_texture("sky.exr"))           # a panorama
sky.intensity = 5000.0                                        # nits per unit
sky.apply(render)

PhysicalCamera(16, 1 / 125, 100).apply(render)                # sunny 16

The same in C++:

NodePath camera = window->get_camera_group().find("+Camera");
PT(PbrPipeline) pipeline =
  new PbrPipeline(window->get_graphics_output(), camera, render);

PT(PbrMaterial) brass = new PbrMaterial("brass");
brass->set_metallic(1);
brass->set_roughness(0.35f);
model.set_attrib(PbrMaterialAttrib::make(brass));

PT(IblEnvironment) sky =
  new IblEnvironment(TexturePool::load_texture("sky.exr"));
sky->set_intensity(5000);
sky->apply(render);

PT(PhysicalCamera) camera = new PhysicalCamera(16, 1.0f / 125, 100);
camera->apply(render);

The shader writes linear, pre-exposed radiance (see Exposure); a tonemapper turns it into display colour: PbrPipeline (see The frame) or the frame pipeline’s composite. Without either, render into a floating-point buffer.

Units

Everything is in photometric units, so that the same light and material values hold from a candle to the sun, and the engine can be used for lighting simulation, not only for looks.

Quantity

Unit

Where

Directional light

lux (lm/m²)

illuminance on a surface facing the light

Point light, spotlight

candela (lm/sr)

luminous intensity (on the axis, for a spotlight)

Ambient light

nits (cd/m²)

a uniform sky of that luminance; lights through the diffuse lobe only

Emission

nits

emissive * emissive_strength

Environment (image-based light)

nits

the cube map and its spherical harmonics

Exposure

EV100

from aperture, shutter time and ISO

Typical values: sunlight 100 000 lux, an overcast day 1 000–10 000 lux, an office 500 lux; a 60 W-class bulb 800 lm (64 cd); a phone screen 500 nits; the clear daytime sky 5 000–10 000 nits.

How an Acron light carries its intensity. Light.intensity is in the unit of the light’s kind and its color is a tint (see Lights); the shader receives their product. LightUnits sets both from a photometric value: set_illuminance (lux), set_luminous_intensity (candela), set_luminous_power (lumens) and set_luminance (nits) make the intensity that value and the colour the given tint scaled to a luminance of 1; their get_ counterparts return the luminance of the colour times the intensity.

Falloff

Point lights and spotlights fall off as the inverse square of the distance, windowed so that they reach exactly zero at their range (Karis 2013, Lagarde and de Rousiers 2014):

\[E = \frac{I}{\max(d^2,\ 10^{-4})}\;\mathrm{saturate}\!\left(1 - (d/r)^4\right)^2\]

The window keeps the physical inverse square to within 2 % out to a third of the range (12 % at half of it) and takes it smoothly to zero at the range; beyond it the light is off, which bounds every light for culling. Choose a range several times the distance at which the light matters. The range r is the light’s max_distance; a light whose max_distance is infinite (Acron’s default) gets the distance where it drops to shading-light-cutoff lux (0.01 by default), sqrt(I / cutoff) (LightUnits.get_auto_range()). Acron’s constant/linear/quadratic attenuation is ignored. LightUnits.windowed_falloff() computes the same factor on the CPU.

Lights

Every Acron light has an intensity (Light.set_intensity), in the unit of its kind: lux for a DirectionalLight, candela for a PointLight or a Spotlight, nits for an AmbientLight. Its color (or color_temperature) is a tint, best given with a luminance of 1; a light lights with the colour times the intensity, in every shader and in the fixed-function pipeline, so the default intensity of 1 leaves older scenes as they were. Both are saved in Bam files (files from before the intensity read with 1). Luminous power converts to intensity as in Frostbite and Filament: I = Φ/4π for a point light and I = Φ/π for a spotlight, whatever its cone, so that narrowing the cone shapes the light without focusing it (LightUnits.set_luminous_power, point_lumens_to_candela, spot_lumens_to_candela).

sun = DirectionalLight("sun")
sun.color_temperature = 5800        # the tint
sun.intensity = 100000.0            # lux

Spotlight cones. A spotlight’s outer cone, outside which it gives no light, is its lens’s: half the lens’s horizontal field of view. Its inner_cone_angle (degrees from the axis) is the cone inside which it has its full intensity; between the two it fades as

\[\mathrm{saturate}(\cos\theta \cdot s + o)^2,\quad s = \frac{1}{\max(\cos\theta_\mathrm{inner} - \cos\theta_\mathrm{outer},\ 10^{-4})},\quad o = -\cos\theta_\mathrm{outer}\, s\]

(KHR_lights_punctual’s smooth edge in Filament’s form; Acron sends s and o as the light struct’s spotScaleOffset). The default inner angle, 0, fades the light over the whole cone, as glTF and Unreal Engine do; an inner angle at the outer one gives a hard edge. LightUnits.spot_cone() computes the same factor on the CPU. Acron’s Spotlight.exponent (a fixed-function falloff) is not used by the PBR shader, nor is the constant/linear/quadratic attenuation.

Shadows

A directional light, spotlight or point light casts shadows on PBR-shaded surfaces when it is a shadow caster:

spot.set_shadow_caster(True, 1024, 1024)       # map size in texels
spot.get_lens().set_near_far(0.1, 30)

sun.set_shadow_caster(True, 2048, 2048)
sun.get_lens().set_film_size(40, 40)           # metres covered
sun.get_lens().set_near_far(1, 100)

bulb.set_shadow_caster(True, 512, 512)         # a cube map
for i in range(6):
    bulb.get_lens(i).set_near_far(0.05, 15)

Acron renders the depth map of each shadow-casting light (a cube map for a point light) from the light’s own camera, into a buffer it makes on first use; the lens says what the map covers, so fit a directional light’s film size and every light’s near and far distance to the scene. The generator shades that camera’s view with a depth-only shader: the same vertex stage (so skinned meshes cast where they are drawn), and a fragment stage that only discards the cut-out texels of AM_mask materials (below their cutoff) and of AM_blend materials (below an alpha of one half; blended surfaces cast like masked ones). Acron’s shadow camera culls front faces, so closed meshes cast from their back faces; give thin, open geometry set_two_sided(True). The shadow camera’s camera_mask selects which nodes cast.

The colour pass reads each map with PbrShaderBuilder.shadow_taps hardware-compared bilinear taps on a Vogel disc (a golden-angle spiral) of shading-shadow-filter-radius texels (1.5), rotated per pixel by interleaved gradient noise, which gives soft edges without banding; one tap is the centre, a single 2 x 2 comparison without noise. The count is part of the feature key, so changing it recompiles the states drawn afterwards. It defaults to shading-shadow-taps (4); under acron.pbr’s environment the sun shadows’ quality preset sets it – 1 on low, 4 medium, 8 high, 12 ultra (acron.sky.SunShadows) – unless the configuration sets shading-shadow-taps, which then wins (PbrShaderBuilder.has_configured_shadow_taps()). It reads none where the light adds nothing (out of its range or cone, or behind the surface). The taps are most of a shadow’s cost: on a GTX 660 at 1080p a spotlight over the whole screen costs about 0.4 ms with 4 taps, 0.6 ms with 8 and 0.8 ms with 12 – about 0.15, 0.22 and 0.3 ms on a GTX 1660, whose budget per shadowed light is 0.15 ms: hence the default of 4. Against shadow acne the receiver moves, before the lookup, along its geometric normal by shading-shadow-normal-bias texels (2.0) and towards the light by shading-shadow-slope-bias texels (0.5), the first scaled by sin θ and the second by tan θ (at most 4), θ being the angle between the surface and the light, and the texel measured at the receiver; so the bias follows the map’s resolution and is smallest where the light is steep. With the defaults a plane lit at 60° shows no acne and a box standing on it lets no light under its foot. Raise the biases for low-resolution maps on detailed geometry, lower them for contact shadows of small objects.

Each shadow-casting light costs a sampler and a variant: the feature key counts the shadowed lights with a 2D map (spotlights, directional lights) and those with a cube map (point lights), at most shading-max-shadows (4) per state; further casters light without shadows, with a warning. Shadows need set_shader_auto() (or the auto-shader shadow bit); a light that starts or stops casting re-specialises the states it lights.

Exposure

PhysicalCamera turns camera settings into an exposure (Lagarde and de Rousiers 2014, section 5.1):

\[\mathrm{EV}_{100} = \log_2\frac{N^2}{t} - \log_2\frac{S}{100} - \text{compensation},\qquad \text{exposure} = \frac{1}{1.2 \cdot 2^{\mathrm{EV}_{100}}}\]

with the f-number aperture N, the shutter_speed t in seconds and the sensitivity S in ISO; a positive exposure_compensation brightens. apply(render) sets the shader input pbr_exposure, and the shader multiplies all of its light (lights, environment, emission) by it before writing (“pre-exposure”), so a sunlit frame lands near 1 and stays far from the limits of a half-float target. Without the input the exposure is 1. ev100_from_luminance and luminance_from_ev100 convert to and from a reflected-light meter’s reading, for automatic exposure.

The material

PbrMaterial follows glTF 2.0’s metallic-roughness model with KHR_materials_ior, KHR_materials_specular, KHR_materials_emissive_strength and KHR_materials_unlit:

base_color

linear RGBA; multiplies the base-colour map and the vertex colour

metallic, roughness

0–1; perceptual roughness (GGX α is its square, and the shader uses at least 0.045); multiply the map’s blue and green channels

emissive, emissive_strength

the emission in nits is their product; set_emission_luminance(colour, nits) sets both from a colour and a luminance

ior, specular_factor, specular_color

the dielectric reflectance at normal incidence is ((ior − 1)/(ior + 1))² × specular_color × specular_factor; 1.5 gives the usual 0.04; a specular_factor of 0 leaves a purely diffuse dielectric

normal_scale, occlusion_strength

glTF’s normalTexture.scale and occlusionTexture.strength

alpha_mode, alpha_cutoff

AM_opaque, AM_mask (cut below the cutoff) or AM_blend (the node also needs a TransparencyAttrib)

double_sided

back faces are lit from their own side (culling stays the node’s CullFaceAttrib)

shading_model

SM_standard or SM_unlit (the base colour, unexposed)

A bare PbrMaterial() is a white dielectric of roughness 0.5. A loader sets every factor from the file (glTF’s own defaults are metallic 1 and roughness 1).

The scalar parameters are live: change them and the next frame shows it, with no new shader. alpha_mode, double_sided and shading_model pick a different shader, and the generator re-specialises the affected states automatically.

Materials are saved in Bam files with the nodes that use them, and a material shared by several nodes stays shared after loading. A node with a Acron Material but no PbrMaterialAttrib is shaded with a PbrMaterial derived from it (PbrMaterial.from_material: base colour or diffuse, metallic, roughness, emission, refractive index).

Textures

Maps stay where Acron keeps them: in the node’s TextureAttrib, one TextureStage per map, recognised by the stage’s mode:

Stage mode

Map

M_modulate

base colour (sRGB texture format), with alpha

M_metallic_roughness

metallic in B, roughness in G

M_occlusion_metallic_roughness

the same, plus occlusion in R (an ORM map)

M_occlusion

occlusion in R (ignored when an ORM map is present)

M_normal

tangent-space normal map

M_emission

emission colour

Each stage’s texcoord name picks the UV set (texcoord or texcoord.1), and a TexMatrixAttrib entry for the stage transforms its coordinates (KHR_texture_transform). Keeping the maps in Acron’s own texture state means every model format, set_texture override and texture tool works unchanged, and the shader binds them through Acron’s p3d_Texture* inputs. The material holds the factors that scale them. (The extension maps of later lobes, clear coat and sheen, will be held by the material.)

Without vertex tangents a normal-mapped surface derives its tangent frame from screen-space derivatives. Without vertex normals (Acron then reads them as zero) a surface is shaded flat, with its faces’ normals from the same derivatives.

The shader generator

PbrShaderGenerator is an Acron ShaderGenerator: installed on the GSG, it answers for every state under set_shader_auto(). For each state it computes a PbrFeatureKey (which maps exist and how they are addressed, alpha mode, double-sidedness, unlit, the number of lights in buckets of 4 up to shading-max-lights, the number of shadow-casting lights by map kind, ambient light, image-based light, specular antialiasing and multiscatter, depth-only for states that write no colour, such as a shadow camera’s, and the lighting context with its revision), and compiles one shader per key. Everything else reaches the shader as uniforms, so a scene of many materials shares a handful of shaders. get_builder() returns the PbrShaderBuilder that does the work; it needs no GL, so tools and tests can look at keys and sources (make_key, get_vertex_source, get_fragment_source, make_shader). The GLSL is version 4.50, or 4.10 where the driver has no 4.5 (macOS, for development).

The state’s own shader inputs (set with set_shader_input on any node above) are kept, and the generator adds what is missing: the material’s parameters, the DFG table, an exposure of 1, and the lighting context’s inputs.

Sampler budget. A fragment shader may read 16 textures on every GL 4 driver (and no more on macOS); a shader over the limit fails to link, and the surface is drawn without it. The key counts its samplers (get_num_samplers: the maps, the DFG table, the image-based light’s cube and the lighting context’s), and when a state would need more than builder.max_samplers (shading-max-fragment-samplers, 16) the generator drops maps, least important first, until it fits: the occlusion map, the emission map, the metal-rough map, the normal map, the base colour map. A dropped map leaves its factor (the material’s roughness instead of the map’s, and so on), and the generator warns once per key, naming the maps.

Compiling ahead. A shader is made, compiled and linked the first time a state needs it, which can stall that frame. After loading a level, with its lights and environment in place, call precompile on the level’s root: it makes the shader of every auto-shaded state below it (as it would be drawn, with the root’s net state) and queues them, so the GPU compiles them at the start of the next frame. It returns how many distinct shaders it queued. (NodePath.prepare_scene makes the shaders too, but doesn’t queue them.)

level = loader.load_model("level.bam")
level.reparent_to(render)
gsg.get_shader_generator().precompile(render)

Skinned and morphed meshes need nothing from the generator. With hardware-animated-vertices true the OpenGL back end blends the joints into the shader’s vertex stage itself (positions, normals and tangents, from a uniform buffer of joint matrices); otherwise the vertices are animated on the CPU. A state’s F_hardware_skinning flag, which asks the stock generator to write the blend into its shader, is cleared on the PBR shader so that the back end does it.

What the shader computes, per pixel:

  • Specular: GGX distribution, height-correlated Smith visibility and Schlick’s Fresnel with f0 and f90 from the metallic, ior and specular parameters; a perceptual roughness of at least 0.045.

  • Diffuse: Lambert, attenuated by the dielectric layer’s Fresnel (the layering of glTF’s reference renderer).

  • Multiple scattering: single-scattering GGX loses energy on rough surfaces (up to two thirds of it for a rough white metal); the shader restores it, for lights with Filament’s 1 + f0 (1/E − 1) and for the environment with Fdez-Agüera’s term. builder.multiscatter = False turns it off for comparison.

  • Specular antialiasing: the screen-space variance of the geometric normal widens the lobe (Tokuyoshi and Kaplanyan 2019), which removes most specular shimmer on curved and distant surfaces (shading-spec-aa).

  • Lights: directional, point and spot lights in the units above, with smooth spot cones; ambient lights through the diffuse lobe.

  • Shadows: of the shadow-casting lights, filtered and biased as in Shadows.

  • Image-based light: the split sum with the DFG table, a prefiltered cube map for specular and spherical harmonics for diffuse, with specular occlusion from the material’s occlusion.

  • The lighting context: its key light through the same BRDF, the light around the surface by the split sum like the image-based light, its ambient visibility (multi-bounce and specular occlusion), then its ctx_finish (by default the exposure).

  • Emission, before the context’s finish.

Lighting context

Acron’s lights and the image-based light are what a plain scene needs. A scene with an environment – a sky with its sun, shadows and aerial perspective, a frame pipeline with ambient occlusion – lights its surfaces through a LightingContext: the code behind a fixed set of functions that the PBR fragment shader calls, with the defines, named sources and shader inputs they need.

from acron.shading import LightingContext

ctx = LightingContext("sky")
ctx.set_source("pbr_context.glsl", MY_CONTRACT)   # the functions below
ctx.set_source("sky.glsl", sky_glsl)              # what it includes
ctx.set_define("SKY_SAMPLES", "8")
ctx.set_shader_input(ShaderInput("sky_lut", lut))
ctx.num_samplers = 2                  # what its sources read
ctx.acron_ambient = False             # its light replaces AmbientLights
ctx.image_light = False               # ... and the pbr_env_* light
ctx.key_light = sun_np                # it draws this DirectionalLight
ctx.apply(render)

apply sets the context as the shader input pbr_lighting_context, so it is inherited like any input: a lower node’s own context replaces it (LightingContext.clear(np) removes a node’s own), and an empty LightingContext below gives that subtree the default context back. The generator compiles every state below with it; the context and its revision are part of the feature key, and every change to the context (set_define, set_source, set_shader_input, the switches) bumps the revision and makes the states ask for their shaders again. Make the changes together, at load or when a module comes or goes, not every frame; uniforms that change every frame belong in the context’s inputs or on the scene’s nodes. A context is Bam-writable (its name, defines, sources and switches; the inputs and the key light belong to the running scene). The binding is a shader input, and like every shader input it is not written into a model’s Bam file.

The contract, all in world space (Z up):

void ctx_begin(vec3 world_pos, vec3 n_geom)

first, once per fragment; n_geom is the geometric normal, facing the viewer on a double-sided surface.

bool ctx_key_light(vec3 p, vec3 n, out vec3 l, out vec3 radiance, ...)

the context’s own directional light at p (n the shading normal): l towards it, radiance its illuminance there with its shadows, and its angular radius (the last argument, out float angular_radius); false for none.

vec3 ctx_irradiance(vec3 n)

the light around the surface: the irradiance divided by π around n (a uniform sky of L gives L).

vec3 ctx_radiance(vec3 r, float perceptual_roughness)

that light along r, prefiltered for the roughness.

float ctx_ambient_visibility(out vec3 bent_normal)

how much of that light reaches the surface (0 to 1; the material’s occlusion multiplies it), and the direction its diffuse part comes from (vec3(0): the shading normal). Below 1 the shader applies Jimenez’s multi-bounce fit with the base colour, and Lagarde’s specular occlusion.

vec3 ctx_finish(vec3 radiance, vec3 world_pos)

last: the surface’s radiance to the colour written – the atmosphere, fog, the exposure.

void ctx_write_outputs(vec3 n_world)

the G-buffer outputs (below).

The source named pbr_context.glsl implements it. The library’s own is the default context: no key light, the pbr_env_* image-based light around the surface, and pbr_exposure in ctx_finish. A context that sets its own pbr_context.glsl replaces it for its states; one that does not adds its defines, sources and inputs to the default. A context defines PBR_CTX_IMAGE_LIGHT in its pbr_context.glsl when it has light around the surface (only then are ctx_irradiance, ctx_radiance and ctx_ambient_visibility called). Its sources win over the library’s of the same name, for its own shaders only, and are included in the fragment stage after the module’s own (pbr_common.glsl, pbr_brdf.glsl, pbr_lights.glsl, pbr_ibl.glsl), whose names all begin with pbr_. Its defines follow the key’s, with PBR_CONTEXT, PBR_GBUFFER (gbuffer) and PBR_SKIP_LIGHT (key_light).

The key light. An environment often keeps a DirectionalLight for shaders that read only Acron’s lights. key_light says that the context’s ctx_key_light is that light: the generator gives each state the light’s index among its lights (pbr_skip_light), and the light loop skips it.

Units. The context’s lights and Acron’s are added as they are, with the emission, and ctx_finish exposes the sum: a context’s units are its lights’ units. The default context’s are nits, exposed by pbr_exposure.

Samplers. The generator cannot see which textures a context’s sources read; num_samplers says how many, and the sampler budget counts them.

G-buffer outputs

A context with gbuffer on compiles its states with PBR_GBUFFER, and the fragment shader writes the frame pipeline’s three scene targets:

location = 0

the colour (ctx_finish’s), as always

location = 1 (pbr_gbuffer_normal)

rgb the view-space shading normal (OpenGL’s view: x right, y up, z towards the eye), a = 1

location = 2 (pbr_gbuffer_motion)

rg the motion vector (the screen position now minus a frame ago, unjittered, in uv units), b = 1 where it is known

The shader writes the normal and “no motion” (all zero) itself, then calls ctx_write_outputs: the context knows the cameras of this frame and the last, so it writes the motion (and may write the normal again). A pixel with b = 0 gets its motion from the camera alone, which is right for static geometry. Unlit materials write their geometric normal and no motion. The render target needs the two auxiliary float targets. Without gbuffer the shader writes zeros (“no data”) to locations 1 and 2: OpenGL leaves an output a shader does not write undefined, and NVIDIA’s driver copies the colour into it, which a frame pipeline would take for a normal and a motion.

Image-based light

IblEnvironment turns an environment map into the shader’s image-based light. Give it a cube map or an equirectangular panorama (a 2-D texture) with a RAM image: float or half-float data, or 8- or 16-bit data (sRGB formats are decoded to linear). It makes:

  • a radiance cube map of size × size (256 by default), RGBA16F with a full mipmap chain. Level 0 is the source, area-filtered to the size. Level l below levels (6 by default) holds perceptual roughness l / (levels − 1), prefiltered for GGX by the split sum: samples importance samples per texel (128 by default) with N = V = R, weighted by N·L, each read from the source mip that matches the sample’s solid angle (filtered importance sampling), so that a few samples give a smooth result. The levels below are box-filtered, so the chain is complete;

  • the radiance projected on the nine spherical harmonics up to l = 2, with exact texel solid angles, for the diffuse light.

sky = IblEnvironment(texture, size=256, levels=6, samples=128)
sky.apply(render)                 # the three shader inputs below
sky.write(Filename("sky.ibl"))    # cache: no prefilter on the next run
sky = IblEnvironment.read(Filename("sky.ibl"))

The prefilter runs on the CPU, on a few worker threads, and releases the GIL, so it can run on a task thread: about 50 ms for the default 256² cube on a current desktop CPU. The result depends only on the inputs, and write() and read() cache it. read returns None for a missing, truncated or corrupt file. make_uniform() makes an environment of one radiance in every direction, e.g. a plain overcast ambient or a white furnace test.

Directions are world directions with Z up, the same as the shader’s: a cube map texel is seen along the direction OpenGL’s cube lookup gives it (get_cube_direction()), and a panorama’s column and row follow longitude atan2(x, y) and latitude asin(z), so the panorama’s centre looks along +Y (forward) and its top row is straight up.

Units. The texels are radiance in nits when the source is calibrated. Most HDRIs are not. Set intensity instead (a scale on both the radiance and the irradiance), and call apply again after changing it. Values beyond half-float range (a photometric sun disc is about 1.6 × 10⁹ nits) are kept: the cube is stored divided by a power of two (storage_scale), which apply multiplies back in.

Checking it on the CPU. sample() returns what the shader reads in a direction at a roughness (a trilinear lookup, before the intensity), get_texel() returns one texel of a level, and get_irradiance() returns the irradiance divided by π that the shader computes from the harmonics.

The shader inputs, for an environment made another way (e.g. the sky’s own probe):

pbr_env_radiance

a cube map in nits, prefiltered for GGX: mip level roughness × pbr_env_params.x holds that roughness

pbr_env_sh

9 Vec4 (RGB used): the environment’s radiance projected on the real spherical harmonics, in the order L00, L1−1, L10, L11, L2−2, L2−1, L20, L21, L22, in world space (Z up)

pbr_env_params

x: the mip level of roughness 1; y: a scale applied to both

A uniform environment of L nits has pbr_env_sh[0] = 4π × 0.282095 × L and the other coefficients 0.

The frame

PbrPipeline is the smallest complete frame, for a game that doesn’t use the frame pipeline (and for C++ games until the frame pipeline is native):

  • it installs a PbrShaderGenerator on the window’s GSG (unless it has one) and turns the auto shader on below the scene root;

  • the camera draws into a half-float buffer (buffer, with scene_texture) that follows the window’s size;

  • every display region of the window that showed the camera shows a full-screen pass instead, which tonemaps the scene, encodes it as sRGB (unless the window’s framebuffer does that itself) and dithers it by half an output step against banding (dither).

tonemap picks the display transform, the same set as the frame pipeline’s:

T_shoulder (default)

linear up to 0.76, then Khronos PBR Neutral’s shoulder rolls highlights off to white instead of clipping each channel

T_neutral

the whole Khronos PBR Neutral: the shoulder and its toe

T_aces

Narkowicz’s fit of the ACES filmic curve

T_reinhard

c / (1 + luminance)

T_clamp

none: values above 1 clip

The exposure is the PBR shader’s (Exposure), so the pass has no exposure of its own. cleanup() gives the display regions back to the camera and removes the buffer; the generator stays installed.

The DFG table

BrdfLuts generates the split-sum lookup table on the CPU when it is first needed: for N·V and perceptual roughness on its axes (texel centres), red holds ∫ D G₂/(4 N·L N·V) (1 − V·H)⁵ cos θ and green ∫ D G₂/(4 N·L N·V) cos θ, with the same height-correlated visibility as the direct light, so that the specular albedo is f0 (G − R) + f90 R and green is the single-scattering albedo the multiscatter terms need. It is GGX importance sampling over a Hammersley set in double precision, so the table is the same on every run; 64 × 64 RGBA16F with 1024 samples per texel by default (shading-dfg-lut-size, shading-dfg-lut-samples). integrate_dfg(nov, roughness, samples) evaluates one texel anywhere.

The shader library

ShaderLibrary holds the module’s GLSL, compiled into the library from acron/src/shading/shaders/ at build time, under each file’s name (pbr.frag, pbr_brdf.glsl, …). resolve replaces every #include "name" line by that source, each source once; assemble adds the #version line and defines. A game registers its own sources with set_source or adds directories to search_path; a lighting context’s sources are looked up before the library’s, for that context’s shaders only.

Configuration

shading-spec-aa (true), shading-multiscatter (true), shading-light-cutoff (0.01 lux), shading-max-lights (16), shading-max-shadows (4), shading-shadow-normal-bias (2.0 texels), shading-shadow-slope-bias (0.5 texels), shading-shadow-filter-radius (1.5 texels), shading-shadow-taps (4), shading-max-fragment-samplers (16), shading-dfg-lut-size (64), shading-dfg-lut-samples (1024).

Limitations of this milestone

  • Up to shading-max-lights lights per state, in a loop, and up to four of them with shadows.

  • Shadows are Acron’s per-light maps: a directional light has one map, not cascades, and the biases are global settings, not per light. No light cookies yet.

  • No clear coat, sheen, anisotropy or transmission.

  • The environment is prefiltered on the CPU; a GPU prefilter (compute shaders) follows. The harmonics are not windowed, so a very bright, small source (an unclipped sun) rings in the diffuse light; clip it or light the sun as a directional light.

  • A light’s max_distance needs an engine build whose shaders can read it; otherwise every light gets its automatic range.

  • A lighting context’s ctx_key_light angular radius is not used yet (no sphere-light widening of the specular lobe), and its sources reach the fragment stage only. Hardware-skinned vertices move only with their node in the motion vectors: the back end keeps no previous joint matrices.

References

    1. Karis, “Real Shading in Unreal Engine 4”, SIGGRAPH 2013 course notes.

  • S. Lagarde and C. de Rousiers, “Moving Frostbite to Physically Based Rendering 3.0”, SIGGRAPH 2014 course notes.

  • E. Heitz, “Understanding the Masking-Shadowing Function in Microfacet-Based BRDFs”, JCGT 3(2), 2014.

  • C. Kulla and A. Conty, “Revisiting Physically Based Shading at Imageworks”, SIGGRAPH 2017 course notes.

  • C. J. Fdez-Agüera, “A Multiple-Scattering Microfacet Model for Real-Time Image-based Lighting”, JCGT 8(1), 2019.

  • Y. Tokuyoshi and A. S. Kaplanyan, “Improved Geometric Specular Antialiasing”, I3D 2019.

  • J. Křivánek and M. Colbert, “Real-time Shading with Filtered Importance Sampling”, Computer Graphics Forum 27(4), 2008.

  • R. Ramamoorthi and P. Hanrahan, “An Efficient Representation for Irradiance Environment Maps”, SIGGRAPH 2001.

  • R. Guy and M. Agopian, “Physically Based Rendering in Filament” (the DFG layout and energy compensation).

  • The Khronos glTF 2.0 specification and its KHR_materials_* and KHR_lights_punctual extensions.

  • D. Holbert, “Saying Goodbye to Shadow Acne”, GDC 2011 poster (normal offset).

  • J. Jimenez, “Next Generation Post Processing in Call of Duty: Advanced Warfare”, SIGGRAPH 2014 (interleaved gradient noise).