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 |
|
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):
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
(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):
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_colorlinear RGBA; multiplies the base-colour map and the vertex colour
metallic,roughness0–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_strengththe emission in nits is their product;
set_emission_luminance(colour, nits)sets both from a colour and a luminanceior,specular_factor,specular_colorthe dielectric reflectance at normal incidence is ((ior − 1)/(ior + 1))² ×
specular_color×specular_factor; 1.5 gives the usual 0.04; aspecular_factorof 0 leaves a purely diffuse dielectricnormal_scale,occlusion_strengthglTF’s
normalTexture.scaleandocclusionTexture.strengthalpha_mode,alpha_cutoffAM_opaque,AM_mask(cut below the cutoff) orAM_blend(the node also needs aTransparencyAttrib)double_sidedback faces are lit from their own side (culling stays the node’s
CullFaceAttrib)shading_modelSM_standardorSM_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 |
|---|---|
|
base colour (sRGB texture format), with alpha |
|
metallic in B, roughness in G |
|
the same, plus occlusion in R (an ORM map) |
|
occlusion in R (ignored when an ORM map is present) |
|
tangent-space normal map |
|
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 = Falseturns 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_geomis 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(nthe shading normal):ltowards it,radianceits 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 = 0the colour (
ctx_finish’s), as alwayslocation = 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 belowlevels(6 by default) holds perceptual roughness l / (levels− 1), prefiltered for GGX by the split sum:samplesimportance 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_radiancea cube map in nits, prefiltered for GGX: mip level
roughness × pbr_env_params.xholds that roughnesspbr_env_sh9
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_paramsx: 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
PbrShaderGeneratoron 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, withscene_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_neutralthe whole Khronos PBR Neutral: the shoulder and its toe
T_acesNarkowicz’s fit of the ACES filmic curve
T_reinhardc / (1 + luminance)
T_clampnone: 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-lightslights 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_distanceneeds an engine build whose shaders can read it; otherwise every light gets its automatic range.A lighting context’s
ctx_key_lightangular 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¶
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_*andKHR_lights_punctualextensions.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).