7.6 Post-Processing

Phase 7 · Graphics & Rendering · Study time: 15–30 h

Screen-space effects applied after the scene renders — bloom, tone mapping, color grading, depth of field and ambient occlusion.

Every material shader you have used so far runs once per object, deciding the color of one mesh's own pixels while that mesh is being drawn. Post-processing works differently: it runs once over the whole finished picture, after every object in the scene has already been drawn. This chapter covers what post-processing actually is under the hood (a texture and a fragment shader, not magic), how effects chain together into a stack, and how the effects you see in almost every modern game — bloom, tone mapping, color grading, vignette, depth of field, and ambient occlusion — each work. It ends with Unity's real system for wiring all of this together: the Volume framework.

1. What Post-Processing Is

Every material shader you have used so far runs per object: it decides the color of one mesh's pixels while that mesh is being drawn. Post-processing (also called post-processing effects, or just "post") is different — it is a set of effects applied to the entire rendered image, as one or more extra passes, after the whole 3D scene has already been drawn to pixels. By the time post-processing runs, there is no more geometry, no more triangles, no more materials — just a flat 2D picture, plus a few helper buffers (like depth) that were also produced while rendering.

A useful comparison: a phone camera app that adds a filter to a photo you already took works the same way. It does not know or care that the photo contains a person, a dog, and a sunset — it only sees pixels, and applies the same math to every pixel (or every pixel in some rule-defined region). Post-processing in a game engine does exactly this to the frame the 3D renderer just produced, before that frame is shown on screen.

3D Scene (meshes, materials, lights, cameras) | | normal 3D rendering: vertex/fragment shaders, | run once per object v Rendered Image (a flat grid of colored pixels) | | post-processing: screen-space effects, | run once over the whole image v Final Frame shown on screen

Why bother? Because some effects only make sense when you can see the whole picture at once. Bloom needs to know which pixels in the entire frame are the brightest. Vignette needs to know which pixels are near the screen's edge. Depth of field needs to compare every pixel's distance from the camera. None of these questions can be answered by a single object's shader working alone — they need the finished image as input.

Tip A simple test for "is this a post-processing effect?": does it need information from outside the one object being shaded — the rest of the screen, the whole depth buffer, a brightness threshold across the whole image? If yes, it almost always belongs in a post-processing pass, not a per-object material shader.

2. The Core Idea: Render to a Texture, Then Run a Shader Over It

To turn "the whole scene" into something a shader can process one pixel at a time, the engine needs the scene as a texture it can sample. So instead of drawing the camera's view directly to the screen, the engine first draws it into an offscreen render target (a chunk of GPU memory shaped like an image, called a RenderTexture in Unity), rather than straight into the buffer that reaches the monitor.

Once the scene sits in a texture, applying an effect becomes a familiar problem: draw a rectangle that exactly covers the screen (a full-screen quad — two triangles, or one oversized triangle in engines that optimize for this), texture it with that render target, and run a fragment shader (the per-pixel shader stage you already used for materials) that samples it. This fragment shader is not shading a car door or a character's skin — its only geometry is "the whole screen," and its only input texture is "the last rendered frame."

Camera renders scene | v RenderTexture (color buffer, off-screen, not shown to the player yet) | | a full-screen quad is drawn, textured with that | RenderTexture; a fragment shader samples it, | once per pixel v New RenderTexture (or the screen) holding the processed image

Each pixel the fragment shader runs on corresponds to exactly one pixel of the output image. Instead of reading vertex positions and normals like a material shader does, a post-processing shader mostly does one thing: call tex2D(inputTexture, uv) (sample the input texture at this pixel's UV coordinate) — sometimes several times, at several nearby UV coordinates, which is exactly how blur (Section 5) and bloom (Section 6) work.

Common mistake Thinking a post-processing shader can reach into individual objects, their materials, or their transforms. It cannot — by the time it runs, the scene has already been flattened into pixels. If an effect needs extra per-pixel information beyond color (like distance from the camera, for depth of field), that information has to be rendered into its own texture during the normal scene render, and handed to the post shader as one more texture to sample.

3. A Minimal Post Effect: Grayscale

The simplest possible post-processing effect proves the idea from Section 2 with real code. Unity's Built-in Render Pipeline gives every camera an OnRenderImage callback made exactly for this: Unity calls it automatically after the camera finishes rendering the scene into a source texture, and before that image reaches the screen.


using UnityEngine;

public class GrayscaleEffect : MonoBehaviour
{
    public Material grayscaleMaterial;

    // Unity calls this once per frame, automatically, right after
    // this camera finishes rendering -- src is the rendered scene,
    // dest is where the final image for this camera must end up.
    void OnRenderImage(RenderTexture src, RenderTexture dest)
    {
        Graphics.Blit(src, dest, grayscaleMaterial);
    }
}

Graphics.Blit(src, dest, material) is Unity's built-in version of the "draw a full-screen quad" step from Section 2: it draws a screen-covering rectangle into dest, binds src to the shader's _MainTex property, and runs material's fragment shader once per pixel. The shader itself just needs a vertex stage that positions that rectangle, and a fragment stage that does the actual work:


Shader "Custom/PostGrayscale"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
    }
    SubShader
    {
        Cull Off ZWrite Off ZTest Always

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv     : TEXCOORD0;
            };

            struct v2f
            {
                float2 uv     : TEXCOORD0;
                float4 vertex : SV_POSITION;
            };

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = v.uv;
                return o;
            }

            sampler2D _MainTex;

            fixed4 frag (v2f i) : SV_Target
            {
                fixed4 col = tex2D(_MainTex, i.uv);

                // Standard luminance weights: human eyes are far more
                // sensitive to green than to red or blue.
                float gray = dot(col.rgb, float3(0.299, 0.587, 0.114));

                return fixed4(gray, gray, gray, col.a);
            }
            ENDCG
        }
    }
}

Worked trace: take one pixel that was a warm orange, (r=0.8, g=0.3, b=0.2). The fragment shader computes:

gray = 0.299 * 0.8 + 0.587 * 0.3 + 0.114 * 0.2 = 0.2392 + 0.1761 + 0.0228 = 0.4381 output pixel = (0.4381, 0.4381, 0.4381) -- a mid-gray

Every pixel in the frame goes through this same one-line formula. Expected result: the entire camera view turns black-and-white, updating live every frame, without a single object's own material changing. Cull Off ZWrite Off ZTest Always just tells Unity to skip normal 3D rendering settings (culling, depth writing, depth testing) that make no sense for a flat rectangle drawn directly over the screen.

4. Chaining Passes: The Post-Processing Stack

Real games apply more than one effect — bloom, then tone mapping, then color grading, then vignette, all in the same frame. Since Section 2 showed that a post effect's job is "read one texture, write another," chaining effects is just repeating that step: the output texture of one pass becomes the input texture of the next. This chain is usually called the post-processing stack.

Rendered scene | v [Pass 1: Bloom] | v [Pass 2: Tone Map] | v [Pass 3: Vignette] | v Screen Each bracket is one full-screen shader pass (Graphics.Blit style). Pass 2 never "knows" Pass 1 was called Bloom -- it only sees the pixels Pass 1 wrote out.

There is one wrinkle: a texture normally cannot be read from and written to at the same time on the GPU. So a stack of passes needs at least two render textures, and alternates which one is being read and which is being written — a technique called ping-ponging:


// Pseudocode for a post-processing stack with two ping-pong buffers
RenderTexture rtA = sceneRenderedImage;
RenderTexture rtB = new RenderTexture(...);

Graphics.Blit(rtA, rtB, bloomMaterial);        // read A, write B
Graphics.Blit(rtB, rtA, toneMapMaterial);      // read B, write A
Graphics.Blit(rtA, rtB, vignetteMaterial);     // read A, write B
Graphics.Blit(rtB, null, finalCopyMaterial);   // write B to the screen

Every pass costs GPU time — it reads every pixel of the screen, does some math, and writes every pixel back out. A stack of six or seven effects at 4K resolution is a real, measurable cost, which is one reason production stacks (Section 11) are careful about combining cheap effects into a single pass where possible, instead of one pass per effect.

Common mistake Assuming the order of passes in a stack does not matter, since "it's all just texture in, texture out." It matters enormously — Sections 6, 7, and 11 come back to this with a concrete example of a stack that looks fine on paper but produces the wrong image because two passes ran in the wrong order.

5. Blur Kernels: The Building Block Behind Bloom and Depth of Field

Both bloom (Section 6) and depth of field (Section 9) need to blur part of the image, so it is worth understanding blur itself first. A blur kernel (also called a convolution kernel) is a small grid of weights. To blur one pixel, you do not just look at that pixel — you sample its neighbors too, multiply each neighbor's color by that neighbor's weight in the kernel, and add up the results. The simplest version, a box blur, gives every neighbor in a 3x3 (or larger) square an equal weight:

Box blur kernel (3x3), every weight = 1/9: +-----+-----+-----+ | 1/9 | 1/9 | 1/9 | +-----+-----+-----+ | 1/9 | 1/9 | 1/9 | center pixel gets replaced by the +-----+-----+-----+ AVERAGE of all 9 pixels under the grid | 1/9 | 1/9 | 1/9 | +-----+-----+-----+

fixed4 frag (v2f i) : SV_Target
{
    float2 texel = _MainTex_TexelSize.xy; // size of one pixel, in UV units
    fixed4 sum = fixed4(0, 0, 0, 0);

    for (int y = -1; y <= 1; y++)
    {
        for (int x = -1; x <= 1; x++)
        {
            float2 offset = float2(x, y) * texel;
            sum += tex2D(_MainTex, i.uv + offset);
        }
    }

    return sum / 9.0; // 9 taps, each weighted 1/9
}

A box blur is cheap but looks slightly blocky, because every neighbor counts equally right up to the kernel's edge, then suddenly counts for nothing. A Gaussian blur fixes this by using weights that peak at the center and taper off smoothly toward the edges, following a bell curve (the same Gaussian/normal distribution shape from statistics). It looks softer and more natural, at the cost of a little more math per pixel:

Box blur weights (flat, hard edge): 1/9 1/9 1/9 1/9 1/9 (every neighbor counts equally, then suddenly counts for nothing) Gaussian blur weights (smooth bell curve): 0.02 0.06 0.10 0.06 0.02 (heaviest in the middle, fading out smoothly toward the edges)

One important optimization: a 2D Gaussian blur is separable — doing a full NxN blur (N*N texture samples per pixel) gives the same result as doing a 1D horizontal blur pass (N samples) followed by a 1D vertical blur pass (N more samples). For a 9x9 kernel, that is 81 samples versus 18 — a large saving, which is why real engines almost always blur in two passes instead of one.

6. Bloom: Extract, Blur, Add Back

Bloom is the soft glow that appears to bleed out from very bright things — a lightbulb, the sun through trees, a neon sign, a glowing sword. It happens for real because no camera lens or human eye is a perfect optical system: extremely bright light scatters slightly as it passes through, bleeding into nearby pixels on the sensor (or retina). Games do not simulate that scattering — they fake the visual result with three steps, run entirely in post-processing:

Rendered image | v [1. THRESHOLD] keep only pixels brighter than a cutoff; blacken the rest | v [2. BLUR] blur that bright-only image (Section 5's kernel, often | over several downsampled sizes, for a wide soft glow) v [3. ADD BACK] add the blurred glow on top of the ORIGINAL image original image + blurred bright pixels = bloom result

Step 1, the threshold pass, is a small fragment shader that compares each pixel's brightness to a cutoff and throws away anything below it:


sampler2D _MainTex;
float _Threshold;

fixed4 frag (v2f i) : SV_Target
{
    fixed4 col = tex2D(_MainTex, i.uv);
    float brightness = dot(col.rgb, float3(0.299, 0.587, 0.114));

    if (brightness < _Threshold)
    {
        return fixed4(0, 0, 0, 1); // too dim to bloom -- discard it
    }
    return col; // bright enough -- keep it for blurring
}

Step 2 blurs the result of step 1 using the same kind of kernel from Section 5 (production bloom, including Unity's, typically blurs at several downsampled resolutions and combines them, giving a soft, wide glow much more cheaply than one enormous single-resolution blur). Step 3 adds that blurred, bright-only image back on top of the original, unblurred image using additive blending (adding the new color on top instead of replacing it, so pixels only ever get brighter, never darker), so only the areas that were already bright end up glowing — the rest of the image is untouched.

Tip A common bug: forgetting to threshold before blurring, and blurring the whole image instead. The result is a uniformly blurry, washed-out picture, not a glow — because every pixel contributes to the blur, not just the bright ones. Always extract first.

7. Tone Mapping: Squeezing HDR Into Display Range

Up to this point, every pixel color has been assumed to live between 0 and 1. Real lighting math does not cooperate: a bright sky, the sun, or a bloom-worthy lightbulb can legitimately produce color values of 3.0, 20.0, even 1000.0, because real-world light intensity spans an enormous range. Rendering that keeps and works with these true, unclamped values is called HDR (High Dynamic Range) rendering — exactly the values Section 6's bloom threshold needed to find genuinely overbright pixels.

A normal screen, though, can only display a fixed, small range of brightness per pixel — 0 to 1 per color channel (sometimes called LDR, Low or Standard Dynamic Range). If you simply clamped every HDR value above 1.0 down to 1.0, anything bright would turn into flat, identical white — the sun and a slightly-too-bright lamp would look exactly the same, and you would lose all the subtle brightness detail in between. That harsh clipping is exactly what tone mapping exists to avoid.

Tone mapping is a curve that maps the whole, unbounded HDR range down into the 0..1 range the screen can show, smoothly, so brighter things still look brighter than dimmer things instead of every bright thing clipping to the same flat white. One of the simplest tone mapping curves, Reinhard, is a single line of math:


fixed4 frag (v2f i) : SV_Target
{
    fixed4 hdr = tex2D(_MainTex, i.uv);

    // Reinhard tone mapping: color / (color + 1)
    // As color grows toward infinity, this fraction approaches 1
    // but never reaches or exceeds it.
    float3 mapped = hdr.rgb / (hdr.rgb + 1.0);

    return fixed4(mapped, hdr.a);
}

Worked trace for one color channel at four input brightness levels:

input = 0.5 -> 0.5 / 1.5 = 0.333 input = 2.0 -> 2.0 / 3.0 = 0.667 input = 20.0 -> 20.0 / 21.0 = 0.952 input = 1000.0 -> 1000 / 1001 = 0.999 Notice: every input, no matter how large, lands under 1.0 -- and brighter inputs still map to brighter (but compressed) outputs, instead of all clipping to the same flat 1.0.

Unity's default tone mapping in URP/HDRP is a more elaborate curve called ACES (from the Academy Color Encoding System, an industry-standard curve originally built for film), which preserves color and contrast better than Reinhard, but the goal is identical: take unbounded HDR light values and compress them into something a screen can actually display, without harsh clipping.

8. Color Grading (LUTs) and Vignette

Color grading is adjusting a finished image's color balance, contrast, and saturation to set a mood — warmer and more saturated for a cheerful area, cold and desaturated for a horror level. A direct version just does the math per pixel:


sampler2D _MainTex;
float _Saturation; // 1 = unchanged, 0 = fully grayscale, >1 = more vivid
float _Contrast;   // 1 = unchanged, <1 = flatter, >1 = punchier

fixed4 frag (v2f i) : SV_Target
{
    fixed4 col = tex2D(_MainTex, i.uv);

    float luminance = dot(col.rgb, float3(0.299, 0.587, 0.114));
    float3 gray = float3(luminance, luminance, luminance);

    col.rgb = lerp(gray, col.rgb, _Saturation);                 // toward/away from gray
    col.rgb = lerp(float3(0.5, 0.5, 0.5), col.rgb, _Contrast);  // toward/away from mid-gray

    return col;
}

Production games rarely run math like this live, though. Instead, they bake the entire transformation into a texture called a LUT (Look-Up Table): a color artist grades a reference image in Photoshop or a color tool, exports the result as a small texture, and the game simply samples that texture using the pixel's own color as the lookup coordinate — "if the input color is this, here is the output color," pre-computed once instead of recalculated every frame:

pixel color (r, g, b) | v used AS a texture coordinate into the LUT texture | v LUT texture returns the already-graded replacement color One texture sample replaces potentially many lines of grading math, and an artist can paint the LUT by hand instead of a programmer tuning saturation/contrast sliders by number.

A neutral LUT (one where output always equals input) is the starting point; artists edit a copy of it to build every custom look. Because it is just a texture, swapping LUTs is a cheap way to switch an entire game's mood — a flashback sequence, a status effect, a different biome — without changing any other rendering code.

Vignette is a much smaller effect: darkening (and sometimes desaturating) the corners of the screen to draw the player's eye toward the center, mimicking a real camera lens effect of the same name.


fixed4 frag (v2f i) : SV_Target
{
    fixed4 col = tex2D(_MainTex, i.uv);

    float2 centered = i.uv - 0.5;          // 0 at screen center
    float dist = length(centered) * 2.0;   // ~0 center, ~1.4 at corners

    float vignette = 1.0 - smoothstep(_InnerRadius, _OuterRadius, dist);
    col.rgb *= vignette;

    return col;
}
Screen with vignette applied: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX XX.............................XX X...............................X X....... bright, normal ......X X....... center ......X X...............................X XX.............................XX XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX ( X = darkened corner/edge . = untouched center )

9. Depth of Field: Blurring by Distance

A real camera lens can only keep one distance perfectly sharp at a time — the focus distance. Anything closer or farther than that distance blurs, more so the further it is from the focus point. Depth of field (DoF) fakes this same look in a rendered image, usually to draw the player's eye to a specific subject or to add a cinematic feel to cutscenes.

To blur "by distance," the post shader needs to know how far away each pixel's original surface was — information a flat color image alone does not contain. This is exactly the extra texture the warning in Section 2 mentioned: alongside the normal color image, the renderer also writes out a depth buffer (a texture where each pixel stores how far the camera is from whatever surface is visible there, instead of a color), and hands it to the DoF shader as a second input.

distance from camera: near ------ focus ------ far | | | BLURRY SHARP BLURRY blur amount grows the further a pixel's depth is from the focus distance, in either direction

sampler2D _MainTex;              // sharp, normally-rendered image
sampler2D _BlurredTex;           // the same image, already blurred (Section 5)
sampler2D _CameraDepthTexture;   // depth buffer written during scene render
float _FocusDistance;
float _BlurScale;

fixed4 frag (v2f i) : SV_Target
{
    float rawDepth = tex2D(_CameraDepthTexture, i.uv).r;
    float sceneDepth = LinearEyeDepth(rawDepth, _ZBufferParams);

    // How far this pixel's depth is from the focus distance, 0..1
    float coc = saturate(abs(sceneDepth - _FocusDistance) * _BlurScale);

    fixed4 sharpColor = tex2D(_MainTex, i.uv);
    fixed4 blurColor  = tex2D(_BlurredTex, i.uv);

    return lerp(sharpColor, blurColor, coc); // blend by distance from focus
}

coc stands for circle of confusion (the real optics term for how large and blurry a single point of light spreads into, the further it is from the focus plane). At coc = 0 the pixel is fully sharp; at coc = 1 it is fully the blurred version. Real production DoF blurs at more than one radius and uses a lens-shaped (bokeh) kernel instead of a plain box or Gaussian, but the core idea — read the depth buffer, blend between sharp and blurred based on distance from focus — is exactly this.

Common mistake Confusing depth of field with a simple "the whole background is blurry" fog effect. DoF blurs based on distance from a specific focus plane, so something very close to the camera and something very far away can both be blurry at once, with a sharp band in between — it is not a plain near-to-far gradient.

10. Screen-Space Ambient Occlusion (Concept Level)

Ambient occlusion (AO) is the soft darkening you see wherever surfaces come close together or meet — the seam where a box sits on a floor, the inside of a doorway, the crease where two fingers touch. In real life this happens because nearby geometry blocks some of the ambient light (indirect light, bounced around a scene rather than arriving straight from a lamp or the sun) that would otherwise reach that spot. Correctly calculating this for every point in a scene, against every other point that might block light, is extremely expensive — far too slow to do fully in real time.

Screen-space ambient occlusion (SSAO) is a cheap approximation that reuses information the renderer already has on hand: the depth buffer from Section 9, and often a normal buffer (a texture storing which direction each pixel's surface faces). Because it only looks at the 2D screen image and these buffers — not the real 3D scene — it counts as a post-processing-style technique, even though production engines usually compute it right after the main opaque objects are drawn (opaque meaning solid, non-transparent surfaces, drawn before transparent ones), rather than at the very end of the stack.

surface normal ^ | x x x = sample points checked around P, | x P x scattered in a hemisphere facing | x x the normal -----+------------------ scene surface For each sample point: is something ELSE in the depth buffer closer to the camera than this sample point would be? yes -> that direction is blocked (occluded) no -> that direction is open to light more blocked samples around P => darker occlusion at P

The algorithm, at a concept level, for each pixel P: look at several sample points scattered in a small hemisphere around P, oriented along its surface normal; for each sample, check the depth buffer to see whether some other piece of geometry is actually closer to the camera at that screen position than the sample point would be. If so, that sample counts as "blocked." The fraction of blocked samples becomes an occlusion value, which is then multiplied into (darkens) the ambient/indirect lighting term for that pixel — direct light from lamps and the sun is left alone.

SSAO has real limits, precisely because it only sees the screen: geometry just off-screen, or hidden entirely behind something else, cannot occlude anything, because the depth buffer has no record of it. The per-pixel result is also naturally noisy (each pixel picks slightly different sample points), so SSAO passes are almost always blurred afterward — yes, using the same kind of blur kernel from Section 5 — to smooth that noise out before the result is used.

11. Unity's Volume System and the Full Order of Passes

Everything above was hand-built to show the underlying mechanism. In a real Unity project (URP or HDRP), you do not write your own OnRenderImage shader chain for bloom, tone mapping, and vignette — Unity ships all of these as built-in effects, controlled through the Volume system.

A Volume is a component you add to a GameObject; it points at a Volume Profile asset, which holds a list of effect overrides (Bloom, Tone Mapping, Color Adjustments, Vignette, Depth of Field, and more), each with its own toggle and settings. A Global Volume affects the whole scene. A local Volume instead uses a collider to define a region in space, and its settings blend in smoothly as the camera approaches and enters that region — the standard way to make, say, a cave interior feel colder and darker than the sunny area outside it, without scripting a manual transition.


using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;

public class BloomFlash : MonoBehaviour
{
    public Volume postVolume;
    private Bloom bloom;

    void Start()
    {
        // Pull the Bloom override out of this Volume's profile, if present.
        postVolume.profile.TryGet<Bloom>(out bloom);
    }

    public void FlashBloom(float intensity)
    {
        if (bloom != null)
        {
            bloom.intensity.value = intensity; // e.g. a screen flash on a big hit
        }
    }
}

Whether you write the passes by hand or use Volumes, the order effects run in is not arbitrary — it follows the reasoning built up across this chapter:

1. Scene renders into an HDR color buffer (values can exceed 1.0) 2. Ambient occlusion is applied to lighting while opaque objects draw -- BEFORE the post-processing stack technically begins 3. DEPTH OF FIELD -- blurs by distance, still using true HDR colors 4. BLOOM -- extract/blur/add, needs the real HDR brightness 5. TONE MAPPING -- HDR -> 0..1 LDR range; HDR values are gone after this 6. COLOR GRADING -- LUT applied to the now-LDR image 7. VIGNETTE -- darken the edges of the final LDR image 8. Final image is presented on screen

Step 5 must come after step 4, not before, for a concrete reason: bloom's threshold pass (Section 6) needs to see genuinely overbright HDR values, like 20.0, to know a pixel deserves to glow. If tone mapping ran first, that 20.0 would already be compressed down to roughly 0.95 — well under most thresholds — and a blazing HDR light source would no longer register as "bright enough to bloom" at all. Getting this one step backward is a real, common bug: bloom starts looking weak or missing on the brightest lights in the scene, while merely light-colored (but not HDR-bright) objects like white paper occasionally bloom instead, because after tone mapping there is no more difference between "bright" and "extremely bright."

Ambient occlusion runs early, during the opaque scene render rather than at the end, for a similar reason: it needs to darken indirect lighting before that lighting is combined with everything else, and before transparent objects (which do not interact with the depth buffer the same way) are drawn on top.

12. Glossary

13. Exercises

Exercise 1 — Write an Invert-Color Pass Using the grayscale shader from Section 3 as your template (same appdata/v2f structs, same vertex stage), write the frag function for a new effect that inverts every color channel, so black becomes white and a color like (0.9, 0.1, 0.4) becomes (0.1, 0.9, 0.6). Then write the one-line worked trace for an input pixel of (0.2, 0.7, 0.9).
Show answer

fixed4 frag (v2f i) : SV_Target
{
    fixed4 col = tex2D(_MainTex, i.uv);
    return fixed4(1.0 - col.r, 1.0 - col.g, 1.0 - col.b, col.a);
}
input = (0.2, 0.7, 0.9) output = (1-0.2, 1-0.7, 1-0.9) = (0.8, 0.3, 0.1)

Each channel is subtracted from 1.0 independently — alpha is left alone since inverting transparency does not make sense for a color effect like this. The rest of the pass (vertex stage, OnRenderImage hookup) is identical to Section 3's grayscale effect; only the one line inside frag changes, which is the whole point of the render-to-texture-plus-shader design from Section 2 — swapping an effect means swapping a shader, not rebuilding a rendering system.

Exercise 2 — Blur By Hand The 3x3 neighborhood of brightness values below (0.0 = black, 1.0 = white) is centered on one pixel. Using the box blur kernel from Section 5 (every weight = 1/9), compute the blurred output value for the center pixel by hand.
0.2 0.2 0.9 0.1 0.1 0.8 0.2 0.3 0.9
Show answer
sum = 0.2+0.2+0.9 + 0.1+0.1+0.8 + 0.2+0.3+0.9 = 1.3 + 1.0 + 1.4 = 3.7 blurred value = sum / 9 = 3.7 / 9 = 0.4111...

The center pixel started at 0.1 (quite dark), but the blurred result is about 0.41 — much brighter, because three of its neighbors (0.9, 0.8, 0.9) are strongly bright and the box kernel weights all nine values equally. This is exactly why bloom (Section 6) must threshold BEFORE blurring: blurring first, like this exercise did, spreads bright values into dark ones and blurs away the very distinction between "bright enough to glow" and "not," which is the whole thing the threshold step is supposed to preserve.

Exercise 3 — Diagnose the Order Bug A teammate's post-processing stack runs Tone Mapping, then Bloom, then Color Grading, in that order. In their build, torches and the sun do not glow at all, but the plain white pause-menu background sometimes gets a faint glow around its edges. Using Section 11's reasoning, explain what is wrong and how to fix it.
Show answer

The stack runs Bloom AFTER Tone Mapping. Tone mapping compresses every HDR value into the 0..1 range (Section 7), so by the time Bloom's threshold pass (Section 6) looks at the image, the sun and torches — which might have started as HDR values like 15.0 or 40.0 — have already been squeezed down to roughly 0.99, indistinguishable from any other bright-but-ordinary pixel. Meanwhile, plain UI white, which was already exactly 1.0 before tone mapping (never true HDR), can end up looking just as bright as the now-compressed sun, or brighter — so it crosses the brightness threshold instead.

The fix is the order from Section 11: Bloom must run on the true HDR image, BEFORE Tone Mapping, so its threshold pass can still tell a 40.0 apart from a 1.0. Color Grading correctly stays after both, since it is meant to grade the final, already-tone-mapped LDR picture. Corrected order: Bloom -> Tone Mapping -> Color Grading.

← Back to all chapters