A real multi-FBO HDR post-processing chain: bright pass, separable Gaussian bloom, and three swappable tonemap operators, built on the same FBO/full-screen-triangle machinery as OITransparency. A grid, a teapot and five emissive spheres with HDR colour values (components deliberately > 1.0, e.g. (8, 4, 0.5)) are rendered into a floating-point framebuffer, then a four-pass chain turns that HDR image into something an 8-bit display can show without every bright highlight clipping to flat white.
| Key | Action |
|---|---|
B |
toggle bloom on/off |
T |
cycle tonemap operator: none (clamp) -> Reinhard -> ACES fitted |
E / Shift+E |
increase / decrease exposure |
+ / - |
more / fewer blur passes (1..8) |
H |
toggle split-screen: left half raw clamp, right half the full chain |
| LMB / RMB / wheel | rotate / pan / zoom, Space resets, Esc quits |
- Scene -> full-res
RGBA16FFBO. The grid is drawn with the ordinaryDefaultShader.COLOUR. The teapot and five spheres go through a small custom shader (SceneVertex/SceneFragment.glsl) with anemissiveuniform: the teapot gets plain N·L Lambert shading (never exceeding 1.0), the spheres output theirColouruniform verbatim — and those colours have components as high as 9.0. Nothing here clamps or gamma-corrects; that is the whole point of rendering into a floating-point target. - Bright pass -> half-res
RGBA16FFBO.BrightPassFragment.glslsamples the full-res scene texture (LINEARfiltering, so this doubles as a cheap downsample) and keepsmax(colour - threshold, 0)— everything below the threshold contributes nothing, so ordinary lit geometry never blooms, only the emissive spheres do. - Separable Gaussian blur.
BlurFragment.glslis a fixed 5-tap 1-D kernel;main.pyping-pongs it between two half-res FBOs (blur_a,blur_b) forn_passesiterations (+/-), each iteration doing one horizontal draw (bright/blur_b -> blur_a) followed by one vertical draw (blur_a -> blur_b). Two draws instead of one 2-D kernel is the standard trick that turns anO(n^2)blur intoO(2n). - Tonemap composite -> screen.
TonemapFragment.glsladdsbloomStrength * bloom(the half-res blur result, upsampled byLINEARsampling) back onto the full-res scene, multiplies byexposure, then applies one of three operators, then gamma 2.2:- none —
clamp(colour, 0, 1), the naive behaviour every 8-bit framebuffer would give you for free (and exactly what left-of-screen shows whenHis on) - Reinhard —
c / (1 + c), a cheap curve that compresses the whole range but crushes contrast at the top end - ACES fitted — the Narkowicz 2015 fitted approximation to the ACES filmic curve, noticeably better highlight roll-off for the same input
- none —
The four render targets (scene, bright, blur_a, blur_b) are built and bound with the FrameBufferObject helper class (FrameBufferObject.py, TextureTypes.py as used in the Voxels demo.
Each FBO is created with FrameBufferObject.create(width, height), then, while bound (with fbo:), gets its colour/depth attachments added via add_colour_attachment(...)/add_depth_buffer(...) using the strongly-typed GLTexture*/GLAttachment enums from TextureTypes.py. fbo.bind() / fbo.set_viewport() / fbo.unbind()
This replaces the old manual glBindFramebuffer/glViewport pairs in each pass, and resizing just drops the old FrameBufferObject references (__del__ deletes the underlying GL objects) and rebuilds fresh ones at the new size, using the enums from TextureTypes.py ensures that the correct types are used.
If the emissive spheres were shaded straight into an ordinary 8-bit framebuffer, "bright" and "extremely bright" would both just be (1,1,1) white the instant they were written, there would be nothing left for a bright-pass threshold to find, and no way to later choose how the highlights roll off.
Rendering into RGBA16F first lets values like (9, 9, 2) survive unclipped all the way to the final pass, where the tonemap operator makes an explicit, swappable choice (T) about how to compress that unbounded range back into [0,1] instead of an implicit, irreversible one made the moment a fragment shader returns.
The tonemap fragment shader branches on gl_FragCoord.x against half the screen width: the left half runs clamp(scene, 0, 1) with no bloom, no exposure and no operator, i.e. what you'd see without any of this pipeline, and the right half runs the full chain. It is the fastest way to see that the whole exercise is doing something, not just adding a soft-focus filter.
- N. Narkowicz, "ACES Filmic Tone Mapping Curve", 2016 — blog post — the fitted ACES approximation used by the
ACES fittedoperator. - E. Reinhard, M. Stark, P. Shirley & J. Ferwerda, "Photographic Tone Reproduction for Digital Images", SIGGRAPH 2002 — PDF — the
c/(1+c)operator. - LearnOpenGL — Bloom and LearnOpenGL — HDR — the bright-pass/separable-blur/tonemap structure this demo follows.
- Filmic Worlds — Filmic Tonemapping Operators — a survey of tonemap curves and why "none" clips so badly.
