material shader ทุกตัวที่คุณเคยเขียนมาทำงานแบบ per object คือมันตัดสินสีของ pixel ของ mesh ตัวเดียว ตอนที่ mesh นั้นกำลังถูกวาด แต่ post-processing ทำงานต่างออกไป: มันรันแค่ครั้งเดียวทับภาพทั้งภาพที่เรนเดอร์เสร็จแล้ว หลังจาก object ทุกตัวใน scene ถูกวาดไปหมดแล้ว บทนี้จะพูดถึงว่า post-processing จริง ๆ แล้วคืออะไรข้างใน (คือ texture กับ fragment shader ตัวหนึ่ง ไม่มีอะไรเป็นเวทมนตร์) เอฟเฟกต์หลาย ๆ ตัวถูกต่อกันเป็น stack ยังไง และเอฟเฟกต์ที่คุณเห็นในเกมยุคใหม่แทบทุกเกม — bloom, tone mapping, color grading, vignette, depth of field, และ ambient occlusion — แต่ละตัวทำงานยังไง จบบทด้วยระบบจริงของ Unity ที่ใช้ต่อทุกอย่างเข้าด้วยกัน: Volume framework
material shader ทุกตัวที่คุณเคยใช้มาทำงานแบบ per object (ต่อ object หนึ่งชิ้น): มันตัดสินสีของ pixel ของ mesh ตัวเดียว ตอนที่ mesh นั้นกำลังถูกวาด Post-processing (เรียกอีกอย่างว่า post-processing effects หรือเรียกสั้น ๆ ว่า "post") ต่างออกไป — มันคือชุดเอฟเฟกต์ที่ใส่ลงบน ภาพที่เรนเดอร์เสร็จแล้วทั้งภาพ เป็น pass เพิ่มเติมหนึ่งหรือหลาย pass หลังจาก scene 3D ทั้งหมดถูกวาดเป็น pixel ไปแล้ว ตอนที่ post-processing รัน จะไม่มี geometry เหลืออยู่แล้ว ไม่มี triangle ไม่มี material เหลือแล้ว — มีแต่ภาพ 2D แบนราบ บวกกับ buffer เสริมบางตัว (เช่น depth) ที่ถูกสร้างขึ้นระหว่างการเรนเดอร์ด้วยเช่นกัน
ลองเทียบง่าย ๆ: แอปกล้องมือถือที่ใส่ filter ให้รูปที่คุณถ่ายไปแล้วทำงานแบบเดียวกัน มันไม่รู้และไม่สนใจว่ารูปนั้นมีคน มีหมา มีพระอาทิตย์ตก — มันเห็นแค่ pixel แล้วใช้สูตรคณิตเดียวกันกับทุก pixel (หรือทุก pixel ในบริเวณที่กำหนดไว้ตามกฎ) post-processing ในเกมเอนจินก็ทำแบบเดียวกันเป๊ะ ๆ กับเฟรมที่ renderer 3D เพิ่งสร้างเสร็จ ก่อนที่เฟรมนั้นจะถูกแสดงบนหน้าจอ
ทำไมต้องทำแบบนี้? เพราะเอฟเฟกต์บางตัวจะมีความหมายก็ต่อเมื่อมองเห็น ภาพทั้งภาพ พร้อมกันเท่านั้น bloom ต้องรู้ว่า pixel ไหนในเฟรมทั้งหมดสว่างที่สุด vignette ต้องรู้ว่า pixel ไหนอยู่ใกล้ขอบจอ depth of field ต้องเทียบระยะห่างจากกล้องของทุก pixel คำถามพวกนี้ไม่มีทางตอบได้ด้วย shader ของ object เดียวทำงานลำพัง — มันต้องการภาพที่เสร็จแล้วเป็น input
การจะเปลี่ยน "ทั้ง scene" ให้กลายเป็นสิ่งที่ shader ประมวลผลได้ทีละ pixel เอนจินต้องการ scene ในรูปแบบ texture ที่มันสุ่มตัวอย่าง (sample) ได้ ดังนั้นแทนที่จะวาดมุมมองกล้องลงจอโดยตรง เอนจินจะวาดมันลง render target แบบ offscreen ก่อน (พื้นที่หน่วยความจำ GPU ที่มีรูปร่างเป็นภาพ เรียกว่า RenderTexture ใน Unity) แทนที่จะวาดตรงเข้า buffer ที่ไปโผล่บนจอมอนิเตอร์เลย
เมื่อ scene อยู่ใน texture แล้ว การใส่เอฟเฟกต์กลายเป็นปัญหาที่คุ้นเคย: วาดสี่เหลี่ยมที่คลุมทั้งจอพอดี (full-screen quad — สอง triangle หรือ triangle ใหญ่เกินขนาดหนึ่งชิ้นในเอนจินที่ optimize เรื่องนี้โดยเฉพาะ) ใส่ texture เป็น render target นั้น แล้วรัน fragment shader (shader stage ต่อ pixel ตัวเดียวกับที่คุณใช้กับ material อยู่แล้ว) ที่ sample มัน fragment shader ตัวนี้ไม่ได้กำลัง shade ประตูรถหรือผิวหนังตัวละคร — geometry เดียวที่มันมีคือ "ทั้งจอ" และ input texture เดียวที่มันมีคือ "เฟรมล่าสุดที่เรนเดอร์เสร็จ"
ทุก pixel ที่ fragment shader รันด้วย ตรงกับ pixel เดียวในภาพ output พอดี แทนที่จะอ่าน vertex position และ normal เหมือน material shader มันทำ fragment shader ของ post-processing ส่วนใหญ่ทำอย่างเดียว: เรียก tex2D(inputTexture, uv) (sample input texture ที่พิกัด UV ของ pixel นี้) — บางครั้งก็เรียกหลายครั้ง ที่พิกัด UV ใกล้เคียงหลายจุด ซึ่งเป็นวิธีที่ blur (หัวข้อ 5) และ bloom (หัวข้อ 6) ทำงานพอดี
เอฟเฟกต์ post-processing ที่ง่ายที่สุดที่มีอยู่พิสูจน์แนวคิดจากหัวข้อ 2 ด้วยโค้ดจริง Built-in Render Pipeline ของ Unity ให้ callback OnRenderImage กับทุกกล้อง สร้างมาเพื่อสิ่งนี้โดยเฉพาะ: Unity เรียกมันอัตโนมัติทุกเฟรม หลังจากกล้องเรนเดอร์ scene ลง source texture เสร็จ และก่อนที่ภาพนั้นจะไปถึงจอ
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) คือเวอร์ชัน built-in ของ Unity สำหรับขั้นตอน "วาด full-screen quad" จากหัวข้อ 2: มันวาดสี่เหลี่ยมคลุมทั้งจอลงใน dest ผูก src เข้ากับ property _MainTex ของ shader แล้วรัน fragment shader ของ material ทีละ pixel ตัว shader เองต้องการแค่ vertex stage ที่จัดตำแหน่งสี่เหลี่ยมนั้น กับ fragment stage ที่ทำงานจริง:
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
}
}
}
ไล่ทีละขั้น: ลองสมมติ pixel หนึ่งที่เป็นสีส้มอุ่น ๆ (r=0.8, g=0.3, b=0.2) fragment shader คำนวณ:
ทุก pixel ในเฟรมผ่านสูตรบรรทัดเดียวนี้เหมือนกันหมด ผลลัพธ์ที่คาดหวัง: มุมมองกล้องทั้งหมดกลายเป็นขาวดำ อัปเดตสด ๆ ทุกเฟรม โดยที่ material ของ object ตัวไหนก็ไม่ได้เปลี่ยนเลยสักตัว Cull Off ZWrite Off ZTest Always แค่บอก Unity ให้ข้าม setting การเรนเดอร์ 3D ปกติ (culling, depth writing, depth testing) ที่ไม่มีความหมายอะไรกับสี่เหลี่ยมแบนที่วาดทับจอตรง ๆ
เกมจริงใส่เอฟเฟกต์มากกว่าหนึ่งตัว — bloom แล้วก็ tone mapping แล้วก็ color grading แล้วก็ vignette ทั้งหมดในเฟรมเดียวกัน เพราะหัวข้อ 2 แสดงให้เห็นแล้วว่างานของ post effect คือ "อ่าน texture หนึ่ง เขียน texture หนึ่ง" การต่อเอฟเฟกต์เข้าด้วยกันก็แค่ทำขั้นตอนนั้นซ้ำ: output texture ของ pass หนึ่งกลายเป็น input texture ของ pass ถัดไป chain แบบนี้มักถูกเรียกว่า post-processing stack
มีจุดติดขัดอยู่หนึ่งจุด: ปกติแล้ว texture หนึ่งตัวจะถูกอ่านและเขียนพร้อมกันไม่ได้บน GPU ดังนั้น stack ของ pass ต้องมี render texture อย่างน้อยสองตัว แล้วสลับกันว่าตัวไหนกำลังถูกอ่าน ตัวไหนกำลังถูกเขียน — เทคนิคนี้เรียกว่า 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
ทุก pass มีต้นทุนเวลาบน GPU — มันอ่านทุก pixel ของจอ คำนวณอะไรบางอย่าง แล้วเขียนทุก pixel กลับออกมา stack ที่มีหกเจ็ดเอฟเฟกต์ที่ความละเอียด 4K คือต้นทุนจริงที่วัดได้ ซึ่งเป็นเหตุผลหนึ่งที่ทำให้ stack ระดับ production (หัวข้อ 11) ระมัดระวังเรื่องการรวมเอฟเฟกต์ที่ถูกเข้าด้วยกันเป็น pass เดียวเมื่อทำได้ แทนที่จะแยก pass ต่อเอฟเฟกต์หนึ่งตัว
ทั้ง bloom (หัวข้อ 6) และ depth of field (หัวข้อ 9) ต้อง blur บางส่วนของภาพ ดังนั้นควรเข้าใจ blur เองก่อน blur kernel (เรียกอีกอย่างว่า convolution kernel) คือ grid เล็ก ๆ ของค่า weight การจะ blur pixel หนึ่งตัว คุณไม่ได้มองแค่ pixel นั้น — คุณ sample pixel รอบข้างด้วย คูณสีของ pixel รอบข้างแต่ละตัวด้วย weight ของมันใน kernel แล้วบวกผลลัพธ์ทั้งหมดเข้าด้วยกัน เวอร์ชันที่ง่ายที่สุดคือ box blur ซึ่งให้ weight เท่ากันกับทุก pixel รอบข้างในสี่เหลี่ยม 3x3 (หรือใหญ่กว่า):
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
}
box blur ราคาถูกแต่ดูเหลี่ยม ๆ นิดหน่อย เพราะ pixel รอบข้างทุกตัวถูกนับเท่ากันหมดจนถึงขอบของ kernel แล้วจู่ ๆ ก็ไม่ถูกนับเลย Gaussian blur แก้ปัญหานี้ด้วยการใช้ weight ที่สูงสุดตรงกลางแล้วค่อย ๆ ลดลงเรื่อย ๆ ไปทางขอบ ตามรูปทรงกราฟระฆังคว่ำ (bell curve — ทรงเดียวกับ Gaussian/normal distribution ในวิชาสถิติ) มันดูนุ่มนวลและเป็นธรรมชาติกว่า แลกมาด้วยการคำนวณที่มากขึ้นนิดหน่อยต่อ pixel:
มีการ optimize ที่สำคัญอย่างหนึ่ง: Gaussian blur แบบ 2D แยกส่วนได้ (separable) — การทำ blur เต็ม NxN (N*N ครั้งของการ sample texture ต่อ pixel) ให้ผลลัพธ์เหมือนกับการทำ blur 1D แนวนอนหนึ่ง pass (N ครั้ง) ตามด้วย blur 1D แนวตั้งอีกหนึ่ง pass (อีก N ครั้ง) สำหรับ kernel ขนาด 9x9 นั่นคือ 81 ครั้งเทียบกับ 18 ครั้ง — ประหยัดไปเยอะมาก ซึ่งเป็นเหตุผลที่เอนจินจริง ๆ มักจะ blur ด้วยสอง pass แทนที่จะเป็น pass เดียว
Bloom คือแสงเรืองนุ่ม ๆ ที่ดูเหมือนไหลออกมาจากของที่สว่างมาก ๆ — หลอดไฟ, พระอาทิตย์ที่ลอดผ่านต้นไม้, ป้ายนีออน, ดาบที่เรืองแสง มันเกิดขึ้นจริงในชีวิตจริงเพราะไม่มีเลนส์กล้องหรือดวงตามนุษย์ตัวไหนเป็นระบบทางแสงที่สมบูรณ์แบบ: แสงที่สว่างมาก ๆ กระจายตัวเล็กน้อยตอนผ่านเข้ามา เลอะไปยัง pixel ข้างเคียงบน sensor (หรือ retina) เกมไม่ได้จำลองการกระจายแสงนั้นจริง ๆ — มันปลอมผลลัพธ์ทางภาพด้วยสามขั้นตอนที่รันอยู่ใน post-processing ล้วน ๆ:
ขั้นที่ 1 คือ threshold pass เป็น fragment shader เล็ก ๆ ที่เทียบความสว่างของแต่ละ pixel กับ cutoff แล้วทิ้งอะไรก็ตามที่ต่ำกว่านั้น:
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
}
ขั้นที่ 2 blur ผลลัพธ์ของขั้นที่ 1 ด้วย kernel แบบเดียวกับหัวข้อ 5 (bloom ระดับ production รวมถึงของ Unity เอง มักจะ blur ที่หลายความละเอียดที่ downsample แล้วรวมกัน ทำให้ได้แสงเรืองที่กว้างและนุ่มโดยถูกกว่าการ blur ความละเอียดเดียวขนาดใหญ่มาก) ขั้นที่ 3 บวกภาพที่ blur แล้วซึ่งมีแต่ pixel สว่างนั้นทับลงบนภาพต้นฉบับที่ยังไม่ได้ blur ด้วย additive blending (บวกสีใหม่ทับลงไปแทนที่จะแทนที่มัน ทำให้ pixel สว่างขึ้นได้เท่านั้น ไม่มีทางมืดลง) ดังนั้นมีแค่บริเวณที่สว่างอยู่แล้วเท่านั้นที่จะเรืองแสง — ส่วนที่เหลือของภาพไม่ถูกแตะต้อง
จนถึงตอนนี้ เราสมมติว่าสีของทุก pixel อยู่ระหว่าง 0 ถึง 1 แต่คณิตศาสตร์ของแสงจริงไม่ได้ร่วมมือแบบนั้น: ท้องฟ้าสว่าง, พระอาทิตย์, หรือหลอดไฟที่ควรจะ bloom สามารถให้ค่าสีอย่าง 3.0, 20.0, แม้แต่ 1000.0 ได้จริง ๆ เพราะความเข้มแสงในโลกจริงมีช่วงกว้างมหาศาล การเรนเดอร์ที่เก็บและทำงานกับค่าจริงที่ไม่ถูก clamp เหล่านี้เรียกว่าการเรนเดอร์แบบ HDR (High Dynamic Range) — ค่าเดียวกับที่ threshold ของ bloom ในหัวข้อ 6 ต้องการเพื่อหา pixel ที่สว่างเกินจริง ๆ
แต่จอปกติแสดงความสว่างได้แค่ช่วงคงที่แคบ ๆ ต่อ pixel — 0 ถึง 1 ต่อ channel สี (บางครั้งเรียกว่า LDR, Low หรือ Standard Dynamic Range) ถ้าคุณแค่ clamp ทุกค่า HDR ที่เกิน 1.0 ให้เหลือ 1.0 อะไรที่สว่างก็จะกลายเป็นสีขาวแบนเหมือนกันหมด — พระอาทิตย์กับโคมไฟที่สว่างเกินไปนิดหน่อยจะดูเหมือนกันเป๊ะ และคุณจะเสียรายละเอียดความสว่างที่ละเอียดอ่อนระหว่างสองค่านั้นไปหมด การถูกตัดแบบหยาบ ๆ (clipping) แบบนั้นคือสิ่งที่ tone mapping มีไว้เพื่อหลีกเลี่ยง
Tone mapping คือ curve ที่แม็ปช่วง HDR ที่ไม่มีขอบเขตทั้งหมดลงมาในช่วง 0..1 ที่จอแสดงได้ อย่างนุ่มนวล เพื่อให้สิ่งที่สว่างกว่ายังดูสว่างกว่าสิ่งที่มืดกว่า แทนที่จะถูกตัดจนแบนเป็นสีขาวเดียวกันหมด หนึ่งใน tone mapping curve ที่ง่ายที่สุด คือ Reinhard เป็นสูตรคณิตบรรทัดเดียว:
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);
}
ไล่ทีละขั้น สำหรับ channel สีหนึ่งตัว ที่ความสว่าง input สี่ระดับ:
ค่าเริ่มต้นของ tone mapping ใน URP/HDRP ของ Unity คือ curve ที่ซับซ้อนกว่านี้เรียกว่า ACES (มาจาก Academy Color Encoding System เป็น curve มาตรฐานอุตสาหกรรมภาพยนตร์แต่เดิม) ซึ่งรักษาสีและ contrast ได้ดีกว่า Reinhard แต่เป้าหมายเหมือนกันเป๊ะ: เอาค่าแสง HDR ที่ไม่มีขอบเขตมาบีบให้พอดีกับสิ่งที่จอแสดงผลได้จริง โดยไม่ตัดแบบหยาบ ๆ
Color grading คือการปรับสมดุลสี, contrast, และ saturation ของภาพที่เสร็จแล้วเพื่อสร้างอารมณ์ — อุ่นกว่าและอิ่มสีกว่าสำหรับพื้นที่ที่ร่าเริง เย็นและซีดสีกว่าสำหรับด่านสยองขวัญ เวอร์ชันตรงไปตรงมาแค่คำนวณคณิตต่อ 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 ไม่ค่อยรันคณิตแบบนี้สด ๆ จริง ๆ แล้ว แทนที่จะทำแบบนั้น พวกเขา bake การแปลงทั้งหมดลงไปใน texture ที่เรียกว่า LUT (Look-Up Table): color artist ปรับสีภาพอ้างอิงใน Photoshop หรือเครื่องมือปรับสีตัวหนึ่ง export ผลลัพธ์เป็น texture เล็ก ๆ แล้วเกมก็แค่ sample texture นั้นโดยใช้สีของ pixel เองเป็นพิกัดในการค้นหา — "ถ้าสี input เป็นแบบนี้ นี่คือสี output" คำนวณไว้ล่วงหน้าครั้งเดียวแทนที่จะคำนวณใหม่ทุกเฟรม:
Neutral LUT (LUT ที่ output เท่ากับ input เสมอ) คือจุดเริ่มต้น; artist แก้สำเนาของมันเพื่อสร้าง look แบบกำหนดเองทุกแบบ เพราะมันเป็นแค่ texture ตัวหนึ่ง การสลับ LUT จึงเป็นวิธีถูก ๆ ในการเปลี่ยนอารมณ์ของทั้งเกม — ฉาก flashback, status effect หนึ่งตัว, biome ที่ต่างออกไป — โดยไม่ต้องแก้โค้ดเรนเดอร์ตัวอื่นเลย
Vignette เป็นเอฟเฟกต์ที่เล็กกว่ามาก: ทำให้มุมจอมืดลง (และบางทีก็ลด saturation ด้วย) เพื่อดึงสายตาผู้เล่นเข้าสู่กึ่งกลาง เลียนแบบเอฟเฟกต์เลนส์กล้องจริงที่ชื่อเดียวกัน
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;
}
เลนส์กล้องจริงเก็บระยะห่างได้แค่ระยะเดียวให้คมชัดสมบูรณ์แบบในเวลาเดียวกัน — focus distance อะไรก็ตามที่ใกล้กว่าหรือไกลกว่าระยะนั้นจะเบลอ ยิ่งไกลจากจุดโฟกัสยิ่งเบลอมาก Depth of field (DoF) ปลอม look แบบนี้ในภาพที่เรนเดอร์ ปกติใช้เพื่อดึงสายตาผู้เล่นไปที่ subject หนึ่งตัว หรือเพิ่มความรู้สึกแบบภาพยนตร์ให้ cutscene
การจะ blur "ตามระยะทาง" post shader ต้องรู้ว่า surface ต้นฉบับของแต่ละ pixel อยู่ไกลแค่ไหน — ข้อมูลที่ภาพสีแบนราบเพียงอย่างเดียวไม่มี นี่คือ texture เสริมที่ warning ในหัวข้อ 2 พูดถึงไว้พอดี: นอกจากภาพสีปกติแล้ว renderer ยังเขียน depth buffer ออกมาด้วย (texture ที่แต่ละ pixel เก็บระยะห่างของกล้องจาก surface ที่มองเห็นตรงนั้น แทนที่จะเก็บสี) แล้วส่งให้ DoF shader เป็น input ตัวที่สอง
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 ย่อมาจาก circle of confusion (ศัพท์ทางแสงจริง ๆ สำหรับขนาดความเบลอที่จุดแสงจุดหนึ่งกระจายออกไปกลายเป็น ยิ่งไกลจากระนาบโฟกัสเท่าไหร่) ที่ coc = 0 pixel นั้นคมชัดเต็มที่; ที่ coc = 1 มันเป็นเวอร์ชัน blur เต็มที่ DoF ระดับ production จริง ๆ blur มากกว่าหนึ่งรัศมีและใช้ kernel รูปเลนส์ (bokeh) แทน box หรือ Gaussian ธรรมดา แต่แนวคิดหลัก — อ่าน depth buffer แล้ว blend ระหว่างภาพคมชัดกับภาพ blur ตามระยะห่างจากโฟกัส — คือสิ่งนี้เป๊ะ ๆ
Ambient occlusion (AO) คือความมืดนุ่ม ๆ ที่คุณเห็นตรงบริเวณที่ surface มาชิดหรือสัมผัสกัน — รอยต่อที่กล่องวางอยู่บนพื้น, ด้านในของกรอบประตู, รอยพับที่นิ้วสองนิ้วแตะกัน ในชีวิตจริงสิ่งนี้เกิดขึ้นเพราะ geometry ที่อยู่ใกล้เคียงบัง ambient light บางส่วน (แสงทางอ้อมที่สะท้อนไปมาในฉาก แทนที่จะมาตรงจากโคมไฟหรือพระอาทิตย์) ที่ปกติแล้วจะไปถึงจุดนั้น การคำนวณเรื่องนี้อย่างถูกต้องสำหรับทุกจุดใน scene เทียบกับทุกจุดอื่นที่อาจบังแสงได้ มีต้นทุนแพงมาก — ช้าเกินกว่าจะทำแบบ real-time ได้เต็มรูปแบบ
Screen-space ambient occlusion (SSAO) คือการประมาณแบบถูก ๆ ที่ใช้ข้อมูลที่ renderer มีอยู่แล้วซ้ำ: depth buffer จากหัวข้อ 9 และบ่อยครั้งก็ normal buffer ด้วย (texture ที่เก็บว่า surface ของแต่ละ pixel หันไปทิศทางไหน) เพราะมันมองแค่ภาพจอ 2D กับ buffer พวกนี้ — ไม่ใช่ scene 3D จริง — มันถูกนับเป็นเทคนิคสไตล์ post-processing ถึงแม้ว่าเอนจิน production ปกติจะคำนวณมันทันทีหลังจาก object ทึบแสงหลัก ๆ ถูกวาด (opaque หมายถึงพื้นผิวทึบแสง ไม่โปร่งใส ที่วาดก่อน object โปร่งใส) แทนที่จะทำท้ายสุดของ stack
อัลกอริทึม ในระดับแนวคิด สำหรับแต่ละ pixel P: มองไปที่จุด sample หลายจุดที่กระจายอยู่ในครึ่งวงกลม (hemisphere) เล็ก ๆ รอบ P หันตาม surface normal ของมัน; สำหรับแต่ละ sample เช็ค depth buffer ว่ามี geometry ชิ้นอื่นที่อยู่ใกล้กล้องกว่าจุด sample นั้นจริง ๆ ที่ตำแหน่งจอเดียวกันไหม ถ้าใช่ sample นั้นถูกนับว่า "ถูกบัง" สัดส่วนของ sample ที่ถูกบังกลายเป็นค่า occlusion ซึ่งจะถูกคูณเข้าไปใน (ทำให้มืดลง) ค่า ambient/indirect lighting ของ pixel นั้น — แสงตรงจากโคมไฟและพระอาทิตย์ไม่ถูกแตะต้อง
SSAO มีข้อจำกัดจริง ๆ เพราะมันเห็นแค่จอเท่านั้น: geometry ที่อยู่นอกจอพอดี หรือถูกบังอยู่หลังของอย่างอื่นทั้งหมด จะบังอะไรไม่ได้เลย เพราะ depth buffer ไม่มีบันทึกของมัน ผลลัพธ์ต่อ pixel ยังมี noise ตามธรรมชาติด้วย (แต่ละ pixel เลือกจุด sample ที่ต่างกันเล็กน้อย) ดังนั้น SSAO pass เกือบทุกครั้งจะถูก blur หลังจากนั้น — ใช่ ด้วย blur kernel แบบเดียวกับหัวข้อ 5 — เพื่อทำให้ noise นั้นเรียบขึ้นก่อนที่ผลลัพธ์จะถูกใช้งาน
ทุกอย่างข้างบนถูกสร้างด้วยมือเพื่อให้เห็นกลไกข้างใน ในโปรเจกต์ Unity จริง (URP หรือ HDRP) คุณไม่ต้องเขียน OnRenderImage shader chain เองสำหรับ bloom, tone mapping, และ vignette — Unity มีเอฟเฟกต์พวกนี้ให้ built-in มาแล้วทั้งหมด ควบคุมผ่านระบบ Volume
Volume คือ component ที่คุณเพิ่มลงบน GameObject; มันชี้ไปที่ asset Volume Profile ซึ่งเก็บลิสต์ของ effect override (Bloom, Tone Mapping, Color Adjustments, Vignette, Depth of Field, และอื่น ๆ) แต่ละตัวมี toggle และ setting ของตัวเอง Volume แบบ Global ส่งผลกับทั้ง scene ส่วน Volume แบบ local ใช้ collider กำหนดพื้นที่ในอวกาศแทน และ setting ของมันจะ blend เข้ามาอย่างนุ่มนวลขณะกล้องเข้าใกล้และเข้าไปในพื้นที่นั้น — วิธีมาตรฐานสำหรับทำให้ เช่น ภายในถ้ำรู้สึกเย็นและมืดกว่าพื้นที่ที่มีแดดข้างนอก โดยไม่ต้องเขียนสคริปต์ 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
}
}
}
ไม่ว่าคุณจะเขียน pass เองด้วยมือ หรือใช้ Volume ลำดับที่เอฟเฟกต์รันไม่ได้ตั้งขึ้นมาสุ่ม ๆ — มันตามเหตุผลที่สร้างขึ้นมาตลอดทั้งบทนี้:
ขั้นที่ 5 ต้องมาหลังขั้นที่ 4 ไม่ใช่ก่อน ด้วยเหตุผลที่จับต้องได้: threshold pass ของ bloom (หัวข้อ 6) ต้องเห็นค่า HDR ที่สว่างเกินจริง ๆ เช่น 20.0 เพื่อรู้ว่า pixel นั้นควร bloom ถ้า tone mapping รันก่อน 20.0 นั้นจะถูกบีบลงมาเหลือประมาณ 0.95 ไปแล้ว — ต่ำกว่า threshold ส่วนใหญ่มาก — แล้วแหล่งกำเนิดแสง HDR ที่จ้าจัดก็จะไม่ถูกนับว่า "สว่างพอจะ bloom" อีกต่อไปเลย การทำผิดขั้นตอนนี้เป็นบั๊กจริง ๆ ที่พบบ่อย: bloom เริ่มดูอ่อนหรือหายไปบนแสงที่สว่างที่สุดใน scene ในขณะที่ object ที่แค่มีสีอ่อน (แต่ไม่ได้ HDR-สว่างจริง ๆ) อย่างกระดาษขาวกลับ bloom แทนบางที เพราะหลัง tone mapping แล้วไม่มีความต่างระหว่าง "สว่าง" กับ "สว่างมาก" อีกต่อไป
Ambient occlusion รันตั้งแต่เนิ่น ๆ ระหว่างการเรนเดอร์ scene แบบ opaque แทนที่จะทำท้ายสุด ด้วยเหตุผลคล้ายกัน: มันต้องทำให้ indirect lighting มืดลงก่อนที่ lighting นั้นจะถูกรวมเข้ากับทุกอย่าง และก่อนที่ object โปร่งใส (ซึ่งไม่ได้ปฏิสัมพันธ์กับ depth buffer แบบเดียวกัน) จะถูกวาดทับลงไป
color / (color + 1)appdata/v2f เดิม, vertex stage เดิม) เขียนฟังก์ชัน frag ของเอฟเฟกต์ใหม่ที่ invert ทุก channel สี ให้สีดำกลายเป็นสีขาว และสีอย่าง (0.9, 0.1, 0.4) กลายเป็น (0.1, 0.9, 0.6) จากนั้นเขียนไล่ทีละขั้นบรรทัดเดียวสำหรับ pixel input ที่ (0.2, 0.7, 0.9)
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);
}
แต่ละ channel ถูกลบออกจาก 1.0 แยกกัน — alpha ถูกปล่อยไว้เฉย ๆ เพราะการ invert ความโปร่งใสไม่มีความหมายอะไรกับเอฟเฟกต์สีแบบนี้ ส่วนที่เหลือของ pass (vertex stage, การเชื่อม OnRenderImage) เหมือนกับเอฟเฟกต์ grayscale ของหัวข้อ 3 เป๊ะ ๆ มีแค่บรรทัดเดียวข้างใน frag ที่เปลี่ยน ซึ่งคือประเด็นทั้งหมดของดีไซน์ render-to-texture-plus-shader จากหัวข้อ 2 — เปลี่ยนเอฟเฟกต์คือแค่เปลี่ยน shader ไม่ใช่สร้างระบบเรนเดอร์ใหม่ทั้งหมด
pixel กลางเริ่มต้นที่ 0.1 (มืดพอสมควร) แต่ผลลัพธ์ที่ blur แล้วอยู่ที่ประมาณ 0.41 — สว่างขึ้นมาก เพราะเพื่อนบ้านสามตัว (0.9, 0.8, 0.9) สว่างจัด และ box kernel ให้ weight เท่ากันกับทั้งเก้าค่า นี่คือเหตุผลที่ bloom (หัวข้อ 6) ต้อง threshold ก่อน blur เสมอ: การ blur ก่อนแบบในแบบฝึกหัดนี้ ทำให้ค่าสว่างกระจายเข้าไปในค่ามืด และ blur ความต่างระหว่าง "สว่างพอจะเรือง" กับ "ไม่" ทิ้งไป ซึ่งเป็นสิ่งที่ threshold step มีไว้เพื่อรักษาไว้พอดี
stack นี้รัน Bloom หลัง Tone Mapping tone mapping บีบทุกค่า HDR ลงมาในช่วง 0..1 (หัวข้อ 7) ดังนั้นพอ threshold pass ของ Bloom (หัวข้อ 6) มองภาพ พระอาทิตย์กับคบเพลิง — ซึ่งอาจเริ่มต้นเป็นค่า HDR อย่าง 15.0 หรือ 40.0 — ถูกบีบลงมาเหลือประมาณ 0.99 ไปแล้ว แยกไม่ออกจาก pixel สว่างธรรมดาตัวอื่นเลย ในขณะเดียวกัน สีขาวของ UI ปกติ ซึ่งเป็น 1.0 เป๊ะอยู่แล้วก่อน tone mapping (ไม่เคยเป็น HDR จริง) อาจดูสว่างพอ ๆ กับพระอาทิตย์ที่ถูกบีบแล้ว หรือสว่างกว่าด้วยซ้ำ — เลยข้าม threshold ความสว่างไปแทน
วิธีแก้คือลำดับจากหัวข้อ 11: Bloom ต้องรันบนภาพ HDR จริง ก่อน Tone Mapping เพื่อให้ threshold pass ของมันยังแยก 40.0 ออกจาก 1.0 ได้ Color Grading อยู่หลังทั้งสองตัวถูกต้องแล้ว เพราะมันมีไว้ grade ภาพ LDR สุดท้ายที่ผ่าน tone mapping แล้ว ลำดับที่ถูกต้อง: Bloom -> Tone Mapping -> Color Grading