shader ทุกตัวที่เขียนในบทที่แล้วเลือกสีแล้วใช้มันตรงๆ ไม่ว่าจะเป็นการ sample texture, สีทึบๆ (flat tint), หรือ gradient ก็ตาม ไม่มีตัวไหนรู้เลยว่าแสงในฉากมาจากทิศไหน บทนี้จะเพิ่มแสงเข้าไป เราจะสร้าง shader ที่ตอบสนองกับแหล่งกำเนิดแสงจริงๆ จะได้เห็นว่าทำไมเทคนิคคลาสสิกที่โปรแกรมเมอร์ใช้กันมาหลายสิบปีถึงเริ่มดูผิดเพี้ยนไปในที่สุด แล้วก็จะได้เรียนแนวคิดเบื้องหลัง PBR (Physically Based Rendering — การเขียนสมการแสงให้ใกล้เคียงฟิสิกส์จริงมากพอที่วัสดุจะดูถูกต้องภายใต้แสงแบบไหนก็ได้ ไม่ใช่แค่แสงดวงเดียวที่เราเผอิญใช้ทดสอบ)
ย้อนกลับไปที่บทเรื่อง rendering pipeline: vertex shader รันหนึ่งครั้งต่อหนึ่ง vertex ส่วน fragment shader รันหนึ่งครั้งต่อหนึ่งพิกเซล โดย GPU จะทำการ interpolate (ผสมค่าแบบไล่เรียบ) ทุกอย่างที่ vertex shader ส่งออกมา กระจายไปทั่วพื้นผิวของแต่ละสามเหลี่ยม สมการแสงทุกตัวในบทนี้สร้างจากลูกศรสามเส้น ซึ่งลากออกจากจุดเดียวกันบนพื้นผิวที่กำลังคำนวณแสง:
การจะได้ N และ V ในระบบ world space นั้น shader ทุกตัวด้านล่างเริ่มจาก building block เล็กๆ อันเดียวกัน คุ้มค่าที่จะพิมพ์ทิ้งไว้สักครั้ง เพราะ shader ทุกตัวในบทนี้จะใช้ซ้ำ:
struct appdata
{
float4 vertex : POSITION;
float3 normal : NORMAL;
};
struct v2f
{
float4 pos : SV_POSITION;
float3 worldNormal : TEXCOORD0;
float3 worldPos : TEXCOORD1;
};
v2f vert(appdata v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
return o;
}
ใน frag() N ได้มาจากการ normalize i.worldNormal (normalize คือการปรับความยาวเวกเตอร์ให้เท่ากับ 1 พอดี — เรื่องนี้สำคัญเพราะการ interpolate normal ที่ยาวหนึ่งหน่วยสองเส้นข้ามสามเหลี่ยม อาจได้ผลลัพธ์ที่สั้นกว่า 1 เล็กน้อย) V ได้จาก normalize(_WorldSpaceCameraPos - i.worldPos) — ตำแหน่ง world ของกล้องลบด้วยตำแหน่ง world ของพิกเซลนี้ ชี้กลับไปทางตา ส่วน L สำหรับ directional light ดวงเดียว ก็แค่ normalize(_WorldSpaceLightPos0.xyz) ซึ่งเป็นตัวแปร built-in ของ Unity ที่เก็บทิศทางไปยัง directional light หลักของฉากไว้อยู่แล้ว
CGPROGRAM, directional light ดวงเดียว ไม่มีเงา) เพื่อให้สมการแสงเป็นจุดสนใจหลัก shader ที่ใช้งานจริงจะต้องเพิ่ม #pragma multi_compile_fwdbase กับ pass ForwardAdd อีกอันเพื่อจัดการเงาและไฟดวงอื่นๆ — เรื่องระบบท่อประปาแบบนั้นเป็นคนละหัวข้อกับตัวสมการเอง ซึ่งเป็นสิ่งที่บทนี้ต้องการสอนลองนึกถึงพื้นผิวด้าน (matte) อย่างชอล์ก กระดาษแผ่นหนึ่ง หรือไม้ที่ยังไม่ได้ขัดเงา แสงที่กระทบมันจะกระจายออกไปในทุกทิศทางค่อนข้างสม่ำเสมอ ดังนั้นไม่ว่าจะมองจากมุมไหน ความสว่างของพื้นผิวขึ้นอยู่กับสิ่งเดียวเท่านั้น คือมันหันหน้าเข้าหาแสงตรงแค่ไหน พื้นผิวชิ้นที่หันตรงเข้าหาแสงจะโดนลำแสงพุ่งเข้ามาเป็นกลุ่มหนาแน่น ส่วนพื้นผิวชิ้นเดียวกันที่เอียงออกไปจะรับแสงชุดเดียวกันแต่กระจายไปทั่วพื้นที่กว้างขึ้น จึงดูมืดกว่า สิ่งนี้เรียกว่า Lambertian reflectance (ตั้งชื่อตาม Johann Lambert) และเป็นเทคนิคหลอกแสงบนพื้นผิวที่เก่าแก่ที่สุด
มุมระหว่าง N กับ L บอกได้เป๊ะๆ ว่าพื้นผิว "หันหน้า" เข้าหาแสงแค่ไหน และมีวิธีหาแบบบรรทัดเดียว คือ dot product สำหรับเวกเตอร์ความยาวหนึ่งหน่วยสองเส้น dot(N, L) จะเท่ากับโคไซน์ของมุมระหว่างพวกมัน — นี่คือ dot product ตัวเดียวกับที่เรียนในบท linear algebra ตอนนี้มันมาทำงานจริงใน shader แล้ว
ลองไล่เลขดู: สมมติ N = (0, 1, 0) (ชี้ขึ้นตรงๆ) และ L = (0, 0.707, 0.707) (แสงอยู่สูงจากขอบฟ้าประมาณ 45 องศา) dot(N, L) = (0)(0) + (1)(0.707) + (0)(0.707) = 0.707 และ cos(45°) ≈ 0.707 — ตรงกันเป๊ะตามที่ควรจะเป็น ทีนี้ลองสมมติว่าแสงอยู่เกือบเสมอระดับพื้นผิว L = (0, 0.1, 0.995) จะได้ dot(N, L) = 0.1 พื้นผิวแทบไม่ได้รับแสงเลย ทั้งที่แสงยังอยู่เหนือขอบฟ้าในทางเทคนิค
fixed4 _Color;
fixed4 frag(v2f i) : SV_Target
{
float3 N = normalize(i.worldNormal);
float3 L = normalize(_WorldSpaceLightPos0.xyz);
float NdotL = max(0, dot(N, L));
fixed3 diffuse = _Color.rgb * _LightColor0.rgb * NdotL;
return fixed4(diffuse, 1);
}
ผลลัพธ์ที่ควรได้: บนทรงกลม ด้านที่หันเข้าหาแสงจะสว่างที่สุด ความสว่างจะค่อยๆ จางลงไปจนถึง "terminator" (เส้นที่พื้นผิวหันข้างเข้าหาแสงพอดี) ส่วนด้านไกลที่ไม่เคยหันเข้าหาแสงเลยจะดำสนิท การหมุนกล้องรอบทรงกลมจะไม่เปลี่ยนอะไรเลย เพราะสมการนี้ไม่ได้ใช้ V เลยสักนิด พื้นผิวแบบ Lambertian จะมีความสว่างเท่าเดิมไม่ว่าจะมองจากมุมไหน
max(0, ...) เมื่อสามเหลี่ยมหันหลังให้แสง dot(N, L) จะติดลบ และถ้าเอาค่าลบที่ไม่ได้ clamp ไปคูณกับสี อาจทำให้เกิด artifact มืดๆ กระพริบแปลกๆ แทนที่จะเป็นสีดำสนิทเรียบร้อย ควร clamp ค่า dot product เสมอก่อนจะเอาไปใช้เป็นปริมาณแสงLambertian diffuse อย่างเดียวทำให้ทุกพื้นผิวดูเหมือนชอล์ก คือแบนและด้าน วัสดุมันวาว (โลหะขัดเงา พื้นผิวเปียก พลาสติก) จะมี specular highlight ด้วย (แสงสะท้อนสว่างเบลอๆ ของแหล่งกำเนิดแสงเอง) ต่างจากแสง diffuse ตรงที่ตำแหน่งของ highlight ขึ้นอยู่กับว่ากล้องอยู่ตรงไหน เพราะเรากำลังเห็นภาพสะท้อนแบบกระจกคร่าวๆ ของแสงนั่นเอง
วิธีคลาสสิก (และยังเร็วมากอยู่) ในการหลอกเรื่องนี้คือ Blinn-Phong shading แทนที่จะคำนวณทิศทางสะท้อนแบบกระจกจริงๆ ของ L ออกจากพื้นผิว (ซึ่งเป็นสิ่งที่โมเดล Phong รุ่นเก่าที่แพงกว่าทำ) เทคนิคของ Blinn ใช้ตัวแทนที่ถูกกว่า คือ half vector H ซึ่งอยู่กึ่งกลางพอดีระหว่าง L กับ V เมื่อ N ชี้ใกล้เคียงกับ H พื้นผิวก็ใกล้ "จุดที่ลงตัว" ที่แสงสะท้อนเกือบตรงจากแหล่งกำเนิดแสงเข้าสู่กล้องพอดี — นั่นคือ highlight
float3 V = normalize(_WorldSpaceCameraPos - i.worldPos);
float3 H = normalize(L + V);
float NdotH = max(0, dot(N, H));
float specular = pow(NdotH, _Shininess);
pow(NdotH, _Shininess) ยกกำลังตัวเลขระหว่าง 0 ถึง 1 — ยิ่งเลขชี้กำลังสูง ค่านี้ก็ยิ่งยุบตัวลงไปหา 0 เร็วขึ้นทันทีที่ N.H ต่ำกว่า 1 แม้แต่นิดเดียว นั่นคือรูปทรงที่ highlight ต้องการพอดี คือจุดเล็กๆ สว่างจ้าที่จางหายไปเร็วรอบๆ ขอบของมัน _Shininess ตรงนี้เป็นแค่ float ธรรมดาที่ artist ปรับเองด้วยมือ (ปกติอยู่ราว 8 ถึง 256) — ค่าต่ำจะทำให้ highlight กระจายเป็นแสงนวลกว้างๆ ส่วนค่าสูงจะบีบมันให้เป็นจุดวิบวับเล็กๆ คมๆ จำไอเดียนี้ไว้ก่อน เดี๋ยวมันจะกลับมาอีกครั้งพร้อมความหมายทางฟิสิกส์จริงๆ ในหัวข้อที่ 7
เอาหัวข้อที่ 2 กับ 3 มารวมกัน บวกกับเทอม ambient แบบทึบๆ (ปริมาณแสงคงที่ที่เพิ่มเข้าไปทุกที่ ใช้แทนแสงกระเด้งทางอ้อมทั้งหมดที่ directional light ดวงเดียวมองข้ามไป) ก็จะได้โมเดลแสงคลาสสิกที่เกมเกือบทุกเกมใช้ก่อนที่ PBR จะกลายเป็นมาตรฐาน:
Shader "Lesson/07_3_BlinnPhong"
{
Properties
{
_Color ("Diffuse Color", Color) = (0.8, 0.8, 0.8, 1)
_SpecColor ("Specular Color", Color) = (1, 1, 1, 1)
_Shininess ("Shininess", Range(8, 256)) = 64
}
SubShader
{
Tags { "RenderType"="Opaque" "LightMode"="ForwardBase" }
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
#include "Lighting.cginc"
fixed4 _Color;
fixed4 _SpecColor;
float _Shininess;
struct appdata { float4 vertex:POSITION; float3 normal:NORMAL; };
struct v2f
{
float4 pos : SV_POSITION;
float3 worldNormal : TEXCOORD0;
float3 worldPos : TEXCOORD1;
};
v2f vert(appdata v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
return o;
}
fixed4 frag(v2f i) : SV_Target
{
float3 N = normalize(i.worldNormal);
float3 L = normalize(_WorldSpaceLightPos0.xyz);
float3 V = normalize(_WorldSpaceCameraPos - i.worldPos);
float3 H = normalize(L + V);
float NdotL = max(0, dot(N, L));
float NdotH = max(0, dot(N, H));
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.rgb * _Color.rgb;
fixed3 diffuse = _Color.rgb * _LightColor0.rgb * NdotL;
fixed3 specular = _SpecColor.rgb * _LightColor0.rgb * pow(NdotH, _Shininess);
return fixed4(ambient + diffuse + specular, 1);
}
ENDCG
}
}
}
ผลลัพธ์ที่ควรได้: ทรงกลมที่มี gradient แบบ Lambert ไล่เรียบๆ ทั่วพื้นผิวส่วนใหญ่ บวกกับ highlight เล็กๆ สว่างออกขาวๆ ตรงบริเวณที่แสงสะท้อนเข้าหากล้อง การหมุนกล้องรอบทรงกลมจะทำให้ highlight เลื่อนไปมาบนพื้นผิว — เพราะมันไล่ตาม V อยู่ — ในขณะที่ diffuse gradient ด้านล่างจะอยู่นิ่งๆ เพราะขึ้นกับ L เพียงอย่างเดียว นี่คือลุคแบบ "พลาสติก" ที่เห็นในเกมยุค 2000 เกือบทุกเกม
Blinn-Phong ถูกใช้ในเกมนับพันเกมก็มีเหตุผลของมัน — มันถูกและดูสมเหตุสมผล แต่มันคือภาพลวงตาที่ปรับมือ ไม่ใช่โมเดลที่จำลองพฤติกรรมแสงจริงๆ และเรื่องนี้จะตามมาทันในหลายจุด:
_SpecColor ด้วยมือ แยกออกจาก _Color ไม่มีอะไรผูกทั้งสองไว้ด้วยกันแบบที่วัสดุจริงผูก specular tint เข้ากับการเป็นโลหะหรือไม่ (หัวข้อที่ 8) — เลยง่ายมากที่จะสร้างวัสดุที่ดูผิดหลักฟิสิกส์โดยที่โค้ดไม่มีจุดผิดชัดเจนเลยปัญหาทุกข้อเหล่านี้มีต้นตอเดียวกันคือ Blinn-Phong เป็นรูปทรงที่บังเอิญดูสมเหตุสมผล ไม่ใช่โมเดลที่ยึดโยงกับฟิสิกส์จริง PBR แทนที่ "รูปทรงที่ดูสมเหตุสมผล" ด้วยสูตรที่ยึดโยงกับแนวคิดทางฟิสิกส์สามอย่าง คือ energy conservation, microfacet model และ Fresnel reflectance บวกกับ workflow ที่ผูกพารามิเตอร์ของวัสดุเข้าด้วยกันแบบเดียวกับที่วัสดุจริงมีพฤติกรรม ส่วนที่เหลือของบทนี้จะสร้างแต่ละชิ้นส่วนเหล่านั้นขึ้นมา
Radiance เป็นชื่อทางเทคนิคของ "ปริมาณพลังงานแสงที่มาถึง หรือออกจากจุดหนึ่ง ในทิศทางที่ระบุเจาะจง" ไม่ต้องรู้นิยามฟิสิกส์เต็มๆ ก็ได้ — สำหรับบทนี้ให้อ่านมันว่า "ความสว่าง แต่เจาะจงว่าวัดในทิศทางไหน" งานทั้งหมดของ PBR shader คือคำนวณ radiance ที่ออกไปทางกล้อง จาก radiance ที่เข้ามาจากแหล่งกำเนิดแสง
ฟังก์ชันที่อธิบายว่าวัสดุหนึ่งเปลี่ยนทิศทางแสงที่เข้ามาอย่างไร เรียกว่า BRDF (Bidirectional Reflectance Distribution Function — สูตรสำหรับ "ถ้าแสงเข้ามาจากทิศทาง L สัดส่วนเท่าไรที่จะออกไปทางทิศทาง V") ทั้ง N.L ของ Lambert และ pow(N.H, shininess) ของ Blinn-Phong ต่างก็เป็น BRDF ที่เรียบง่ายสุดๆ PBR ไม่ได้ทิ้งไอเดียเรื่อง BRDF ไปเลย แต่แทนที่สูตรเฉพาะกิจสองอันนั้นด้วยสูตรที่สร้างมาให้เคารพกฎฟิสิกส์ที่เข้มงวด:
ลองคิดเหมือนเป็นงบประมาณ: ถ้าจุดหนึ่งรับแสงมา 100 หน่วย แล้ว specular response ของวัสดุส่งกลับออกไป 90 หน่วย ก็จะเหลือแค่ 10 หน่วยสำหรับ diffuse bounce เท่านั้น จะไม่มีทางเป็นทั้ง 90 และ สมมติ 80 พร้อมกัน BRDF แบบ PBR ฝังงบประมาณนี้ไว้ในตัวสมการเอง (จะเห็นวิธีการจริงในหัวข้อที่ 8 กับ 10) ทำให้วัสดุไม่มีทางดูสว่างกว่าแสงที่ตกกระทบมันได้โดยบังเอิญ
นี่คือไอเดียที่ PBR specular สร้างขึ้นมาบนฐานนี้: ซูมเข้าไปให้ลึกพอบนพื้นผิวเกือบทุกชนิด — แม้แต่พื้นผิวที่ดูเรียบเนียนสมบูรณ์แบบด้วยตาเปล่า — จะพบว่ามันไม่แบนเลยสักนิด มันเต็มไปด้วยตุ่มระดับจุลภาคนับล้านตุ่ม แต่ละตุ่มเล็กเกินกว่าจะมองเห็นทีละอันได้ และแต่ละอันทำตัวเหมือนกระจกจิ๋วที่สมบูรณ์แบบ นี่คือ microfacet model และกระจกจิ๋วแต่ละบานนั้นเรียกว่า microfacet
microfacet หนึ่งบานสะท้อนแสงไปในทิศทางกระจกที่แน่นอนเพียงทิศทางเดียว เหมือนกระจกจริงๆ สิ่งที่ดูเหมือน highlight นวลๆ กระจายกว้างบนวัสดุ "หยาบ" จริงๆ แล้วคือกระจกจิ๋วสมบูรณ์แบบนับล้านบานนี้ แต่ละบานเอียงต่างกันเล็กน้อย แต่ละบานสะท้อนรังสีของตัวเองไปคนละทิศทาง — แล้วตา (หรือพิกเซลบนจอ) ของเราก็เฉลี่ยการสะท้อนจิ๋วๆ ทั้งหมดนั้นเข้าด้วยกัน Roughness ก็แค่ตัวเลขที่บอกว่าทิศทางของ microfacet เหล่านั้นกระจายตัวออกจาก normal เฉลี่ยของพื้นผิว N มากแค่ไหน
ไอเดียเดียวนี้อธิบายสิ่งที่เลขชี้กำลังของ Blinn-Phong ทำได้แค่หลอกด้วยการลองผิดลองถูก: roughness มีความหมายทางฟิสิกส์ตรงตัว — มันคือ "กระจกจิ๋วของพื้นผิวนี้กระจายตัวมากแค่ไหน" นั่นเอง — และค่า roughness ค่าเดียวก็ทำงานถูกต้องไม่ว่าแสงจะมาจากทิศไหนหรือกล้องอยู่ตรงไหน เพราะมันอธิบายตัวพื้นผิวเอง ไม่ใช่การจัดแสงแบบใดแบบหนึ่ง หัวข้อที่ 10 จะแสดงสูตรที่ PBR ใช้แปลง "กระจายตัวแค่ไหน" ให้เป็นตัวเลขจริงสำหรับความสว่างและรูปทรงของ highlight — เป็นตัวแทนตรงๆ ของ pow(NdotH, _Shininess) ที่ขับเคลื่อนด้วยสิ่งที่วัดได้จริง แทนที่จะเป็นการเดาของ artist
เกมเอนจินสมัยใหม่เกือบทุกตัว (Standard Shader ของ Unity, Lit shader ของ URP, material พื้นฐานของ Unreal) สร้างวัสดุด้วย texture สี่ตัว เรียกว่า metallic/roughness workflow:
_Albedo — สีพื้นฐาน สำหรับวัสดุที่ ไม่ใช่โลหะ (เรียกอีกอย่างว่า dielectric — วัสดุใดๆ ที่ไม่ใช่โลหะเปลือย เช่น ไม้ พลาสติก ผิวหนัง ผ้า หิน) นี่คือสี diffuse reflectance จริงๆ เหมือนกับ _Color ในหัวข้อที่ 2 ส่วนสำหรับ โลหะ มันถูกนำมาใช้เพื่อจุดประสงค์อื่น อธิบายด้านล่าง_Metallic — ปกติจะใกล้ 0 หรือใกล้ 1 สำหรับแต่ละพิกเซล เพราะแทบไม่มีอะไรในโลกจริงที่เป็น "โลหะ 40%" มันจะปรากฏเป็น gradient หลักๆ ตรงรอยต่อของวัสดุ เช่น ขอบสีที่ลอกบนเหล็ก คราบสนิม หรือฝุ่นบนกันชนโครเมียม_Roughness — การกระจายตัวของ microfacet จากหัวข้อที่ 7 ที่วาดไว้เป็นรายพิกเซล เพื่อให้วัตถุชิ้นเดียวมีทั้งจุดกลางที่ขัดเงาและขอบที่ถลอกด้านUnpackNormal) เพิ่มลักษณะรายละเอียดเล็กๆ อย่างรอยขีดข่วน ลายทอผ้า หรือรูขุมขน โดยไม่ต้องเพิ่มสามเหลี่ยมเลยแม้แต่รูปเดียว ทุกสูตรในบทนี้ที่ใช้ N ควรใช้ normal รายพิกเซลนี้ ไม่ใช่ normal เรียบๆ ที่มาจาก mesh ตรงๆทำไมโลหะถึงต้องการการจัดการพิเศษ? อิเล็กตรอนบนพื้นผิวโลหะจะดูดซับแสงที่เข้ามาแล้วปล่อยกลับออกมาทันทีตรงพื้นผิวเลย ไม่มีแสงส่วนไหนเข้าไปในวัสดุแล้วกระจายกลับออกมาแบบที่เกิดขึ้นใน dielectric นั่นหมายความว่า โลหะไม่มีเทอม diffuse เลย แสงที่โลหะสะท้อน 100% เป็น specular และการสะท้อน specular นั้นถูกย้อมด้วยสีของโลหะเอง (ทองดูเหลือง ทองแดงดูส้ม) เนื่องจากโลหะไม่มีสี diffuse ให้เก็บ เอนจินเลยเอา slot ของ albedo มาใช้ซ้ำเพื่อเก็บ specular tint แทน เมื่อ _Metallic เท่ากับ 1 ในทางกลับกัน การสะท้อน specular ของ dielectric แทบจะเป็นสีขาวจางๆ ไร้สีเสมอ ไม่ว่าสีของวัสดุเองจะเป็นสีอะไร — นั่นคือเหตุผลที่ highlight ของลูกบอลพลาสติกสีแดงเป็นสีขาว ไม่ใช่สีแดง
sampler2D _AlbedoMap;
sampler2D _MetallicMap;
sampler2D _RoughnessMap;
sampler2D _BumpMap;
fixed4 frag(v2f i) : SV_Target
{
fixed3 albedo = tex2D(_AlbedoMap, i.uv).rgb;
float metallic = tex2D(_MetallicMap, i.uv).r;
float roughness = tex2D(_RoughnessMap, i.uv).r;
float3 tangentNormal = UnpackNormal(tex2D(_BumpMap, i.uv));
// Combine the per-pixel normal map with the mesh's own
// tangent-space basis (built the same way your earlier
// normal-mapping shader did) to get the final world normal.
float3 N = normalize(mul(tangentNormal, i.tangentToWorld));
// albedo, metallic, roughness, and N now feed every formula
// in the rest of this lesson.
return fixed4(albedo, 1);
}
มองลงไปตรงๆ ในทะเลสาบนิ่งๆ เราจะเห็นทะลุน้ำไปถึงก้นเกือบหมด แต่ถ้ามองข้ามทะเลสาบเดียวกันนั้นในมุมที่เกือบเสมอระดับผิวน้ำ มันจะกลายเป็นกระจกเกือบสมบูรณ์แบบที่สะท้อนท้องฟ้า — ทั้งที่ตัวน้ำเองไม่ได้เปลี่ยนแปลงอะไรเลย นี่คือ Fresnel effect (ตั้งชื่อตาม Augustin-Jean Fresnel): พื้นผิวทุกชนิด ไม่ว่าจะดูด้านแค่ไหนตอนมองตรงๆ จะสะท้อนแสงมากขึ้นอย่างเห็นได้ชัดเมื่อมองในมุมเฉียงต่ำ
ค่าการสะท้อนแสงตอนมองตรงเข้าพื้นผิว (มุม = 0) เรียกว่า F0 (อ่านว่า "เอฟ-ซีโร่" ย่อมาจาก base reflectance) วัสดุ dielectric ทั้งหมดจะรวมกลุ่มกันอยู่แถว F0 ต่ำๆ ราว 0.02–0.05 ไม่ว่าจะสีอะไรก็ตาม — น้ำ กระจก พลาสติก และผิวหนัง ต่างก็อยู่ใกล้ช่วงนี้หมด ในทางกลับกัน โลหะมี F0 สูงเท่ากับสี albedo ของตัวมันเอง ซึ่งตรงกับไอเดียเรื่อง "specular tint" จากหัวข้อที่ 8 พอดี ค่าการสะท้อนจริงของวัสดุทุกชนิดจะไต่ขึ้นจาก F0 ไปเกือบ 1.0 (สะท้อน 100% เหมือนกระจกสมบูรณ์แบบ) เมื่อมุมมองเข้าใกล้มุมเฉียง
วิธีมาตรฐานของวงการในการคำนวณเรื่องนี้ใน shader คือ Schlick's approximation — สูตรราคาถูกที่ใกล้เคียงฟิสิกส์จริงมากโดยไม่ต้องใช้อะไรซับซ้อนเลย:
float3 F_Schlick(float3 F0, float VdotH)
{
return F0 + (1 - F0) * pow(saturate(1 - VdotH), 5);
}
saturate(x) เป็นตัวย่อของ HLSL สำหรับ clamp(x, 0, 1) VdotH จะเท่ากับ 1 เมื่อ V เรียงตรงกับ H พอดี (มุมมองเกือบตรงๆ) และลดลงไปหา 0 เมื่อมุมมองเข้าใกล้มุมเฉียง ดังนั้น 1 - VdotH จะเพิ่มขึ้นไปหา 1 พอดีตอนที่มุมตื้นลง
ลองไล่เลขดู สำหรับ dielectric ที่มี F0 = 0.04: มองตรงๆ VdotH ≈ 1 จะได้ F = 0.04 + 0.96 * (0)^5 = 0.04 — สะท้อนแค่ 4% ตรงกับสัญชาตญาณ (เราเห็นสี diffuse ของวัสดุเองเป็นหลัก) มองในมุมเฉียงต่ำ VdotH ≈ 0.05 จะได้ (1 - 0.05)^5 = 0.95^5 ≈ 0.774 ทำให้ F = 0.04 + 0.96 * 0.774 ≈ 0.783 — สะท้อนถึง 78% ทันที นั่นคือวัสดุเดียวกัน แสงเดียวกัน โค้ดเดียวกัน — เปลี่ยนแค่มุมมอง แล้วพื้นผิวก็เปลี่ยนจาก "แทบไม่สะท้อนเลย" ไปเป็น "เกือบเป็นกระจก"
float3 (สี) ไม่ใช่ตัวเลขเดี่ยวๆ เมื่อมีโลหะเข้ามาเกี่ยวข้อง F0 ของ dielectric ไม่มีสี (สามช่องเท่ากันหมด ราว 0.04) แต่ F0 ของโลหะมีสีย้อม — F0 ของทองคือสีเหลืองส้มอุ่นๆ ไม่ใช่สีเทา การใช้ float ตัวเดียวแทน F0 จะทำให้โลหะทุกชิ้นในฉากดูเป็นสีโครเมียมแบบเงียบๆ แทนที่จะแสดงสีของโลหะจริงๆ ของมันเอนจินที่ใช้งานจริงคำนวณแสง specular แบบ PBR ด้วยสูตรที่เรียกว่าโมเดล Cook-Torrance:
ไม่จำเป็นต้องพิสูจน์ D กับ G ด้วยมือเพื่อจะเข้าใจว่ามันมีไว้ทำอะไร และโปรเจกต์จริงแทบไม่มีใครเขียนมันเองเลย — Standard Shader ของ Unity และ Lit shader ของ URP implement สูตรนี้ไว้ให้แล้วเป๊ะๆ เราแค่ขับเคลื่อนมันผ่านช่อง Albedo, Metallic และ Smoothness (Smoothness ก็คือ 1 - Roughness) ใน Inspector เท่านั้น สิ่งที่ตามมาต่อไปนี้คือตัวแทนแบบย่อที่เขียนเองซึ่งจับรูปทรงเดียวกันไว้ได้ โดยใช้เครื่องมือที่มีอยู่แล้วจากหัวข้อที่ 2–9 — ไม่ใช่สูตรที่ใช้งานจริงเป๊ะๆ แต่มากพอที่จะเห็นและปรับแก้ทุกส่วนได้ด้วยมือของเราเอง:
Shader "Lesson/07_3_SimplifiedPBR"
{
Properties
{
_Albedo ("Albedo", Color) = (0.8, 0.8, 0.8, 1)
_Metallic ("Metallic", Range(0, 1)) = 0
_Roughness ("Roughness", Range(0.02, 1)) = 0.5
}
SubShader
{
Tags { "RenderType"="Opaque" "LightMode"="ForwardBase" }
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
#include "Lighting.cginc"
fixed4 _Albedo;
float _Metallic;
float _Roughness;
struct appdata { float4 vertex:POSITION; float3 normal:NORMAL; };
struct v2f
{
float4 pos : SV_POSITION;
float3 worldNormal : TEXCOORD0;
float3 worldPos : TEXCOORD1;
};
v2f vert(appdata v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
return o;
}
fixed4 frag(v2f i) : SV_Target
{
float3 N = normalize(i.worldNormal);
float3 L = normalize(_WorldSpaceLightPos0.xyz);
float3 V = normalize(_WorldSpaceCameraPos - i.worldPos);
float3 H = normalize(L + V);
float NdotL = saturate(dot(N, L));
float NdotH = saturate(dot(N, H));
float VdotH = saturate(dot(V, H));
// Non-metals start near 4% reflective and colorless;
// metals reflect their own albedo color instead (Section 9).
float3 F0 = lerp(float3(0.04, 0.04, 0.04), _Albedo.rgb, _Metallic);
float3 fresnel = F0 + (1 - F0) * pow(1 - VdotH, 5);
// Stand-in for the D * G terms: rough surfaces spread the
// highlight into a wide, dim hump; smooth surfaces squeeze
// it into a tight, bright spot (Section 7's microfacet idea).
float shininess = lerp(512.0, 2.0, _Roughness);
float specular = pow(NdotH, shininess) * NdotL;
// Metals have (almost) no diffuse bounce (Section 8), so
// scale diffuse down to zero as metallic goes to 1 -- this
// is the energy-conservation budget from Section 6, made real.
float3 diffuse = _Albedo.rgb * (1 - _Metallic) * NdotL;
float3 color = (diffuse + specular * fresnel) * _LightColor0.rgb;
return fixed4(color, 1);
}
ENDCG
}
}
}
ผลลัพธ์ที่ควรได้: ตั้ง _Metallic = 1 และ _Roughness = 0.05 บนทรงกลม จะได้ภาพเหมือนกระจกเกือบสมบูรณ์แบบ แทบไม่เห็นสีพื้นทึบๆ เลย มีแค่ highlight แคบๆ สว่างจ้าย้อมด้วยสี albedo — แบบโครเมียม ตั้ง _Metallic = 0 และ _Roughness = 0.9 บน mesh เดียวกัน จะได้ลูกบอลด้านๆ นวลๆ ที่เป็น diffuse เป็นส่วนใหญ่ มีแค่แสงวับๆ แผ่วๆ กว้างๆ — แบบยาง สังเกตสิ่งที่ไม่เกิดขึ้น: ไม่มี _SpecColor ให้ปรับมือ ไม่มีการเดา shininess รายวัสดุ แค่สไลเดอร์สามตัว แต่ละตัวมีความหมายทางฟิสิกส์จริงๆ ก็ผลิตวัสดุที่แตกต่างกันอย่างน่าเชื่อถือได้สองแบบ
D = a^2 / (PI * ((N.H)^2 * (a^2 - 1) + 1)^2) โดยที่ a = roughness^2 ไม่ต้องท่องจำก็ได้ — มันทำงานแบบเดียวกับตัวแทน pow(NdotH, shininess) ในหัวข้อนี้ (บีบแน่นรอบๆ N.H = 1 สำหรับ roughness ต่ำ กระจายกว้างสำหรับ roughness สูง) แค่มีรูปทรงที่เข้ากับวัสดุที่วัดจากของจริงได้ใกล้เคียงกว่า การยกกำลังสอง roughness ก่อนนำไปใช้ก็เป็นเหตุผลที่ทำให้สไลเดอร์ Roughness รู้สึกเป็นเส้นตรงกับสายตามากขึ้น แทนที่การเปลี่ยนแปลงที่มองเห็นได้จะไปกระจุกอยู่ปลายด้านใดด้านหนึ่งของสไลเดอร์shader ทุกตัวจนถึงตอนนี้ตอบสนองแค่กับ directional light ดวงเดียว ฉากจริงๆ ยังถูกส่องสว่างด้วยท้องฟ้า กำแพงใกล้ๆ และแสงกระเด้งจากทุกอย่างรอบๆ วัตถุด้วย — พื้นผิวมันวาวควรจะสะท้อนสิ่งเหล่านั้นออกมาแผ่วๆ ไม่ใช่แสดงเป็นสีดำทึบตรงจุดที่ไม่มีแสงตรงตกกระทบ นี่คือปัญหาที่ image-based lighting (IBL) แก้ไข
environment map (ปกติจะเป็น cubemap — ภาพสี่เหลี่ยมจัตุรัสหกภาพเย็บเข้าด้วยกันเป็นกล่องรอบจุดหนึ่ง รวมกันครอบคลุมมุมมอง 360 องศาเต็ม) จับภาพว่าสภาพแวดล้อมรอบๆ หน้าตาเป็นยังไงจากจุดที่ประมาณกึ่งกลางฉาก โดย bake ไว้ล่วงหน้าครั้งเดียว ใน Unity สิ่งนี้คือ Reflection Probe — วัตถุที่วางไว้ในฉากซึ่งจับภาพ cubemap ของสภาพแวดล้อมรอบตัวมัน ไม่ว่าจะครั้งเดียว ("baked") หรือต่อเนื่อง ("realtime") แล้วส่งต่อให้ shader ใกล้เคียงตัวไหนก็ตามที่ขอใช้
IBL ให้สองสิ่งแยกกันจริงๆ มาแทนที่ค่าคงที่ UNITY_LIGHTMODEL_AMBIENT แบบทึบๆ ค่าเดียวจากหัวข้อที่ 4 ด้วยสิ่งที่ดูเหมือนห้องจริงๆ:
R = reflect(-V, N) พื้นผิวเรียบ roughness ต่ำจะ sample mip ที่แทบไม่เบลอเลยเพื่อให้ได้ภาพสะท้อนคมชัด ส่วนพื้นผิวหยาบจะ sample mip ที่เบลอหนักๆ ทำให้ภาพสะท้อนกระจายออก — ตรรกะเรียบ-vs-หยาบแบบเดียวกับ microfacet ในหัวข้อที่ 7 เป๊ะ เพียงแค่เอามาใช้กับภาพของห้องแทนที่จะเป็นแสงดวงเดียว(N·V, roughness) เป็น index และเก็บ scale กับ bias สำหรับ F0 ไว้ — มัน pre-integrate เทอม Fresnel กับ geometry (G) ทั่วทั้ง hemisphere ไว้ให้แล้ว shader เลยไม่ต้องทำเอง มันถูกสร้างครั้งเดียว เหมือนกันทุกฉากทุกวัสดุ และเปลี่ยน integral ที่แพงให้เหลือแค่ texture fetch เพิ่มอีกครั้งเดียว: iblSpecular = prefiltered * (F0 * scale + bias)
// Conceptual only -- Unity's built-in macros (UNITY_SAMPLE_TEXCUBE_LOD
// and friends) handle the real version of this for you.
float3 R = reflect(-V, N);
float mip = _Roughness * MAX_REFLECTION_MIP;
// Diffuse IBL: blurry average of the surroundings, tinted by albedo.
float3 iblDiffuse = SampleIrradianceMap(N) * albedo * (1 - metallic);
// Specular IBL via the split-sum: prefiltered color x BRDF LUT.
float3 prefiltered = SampleEnvironmentMap(R, mip);
float2 envBRDF = SampleBRDFLUT(float2(NdotV, _Roughness)).rg; // (scale, bias)
float3 iblSpecular = prefiltered * (F0 * envBRDF.x + envBRDF.y);
// Ambient occlusion dims the AMBIENT term only -- never direct light.
float3 ambient = (iblDiffuse + iblSpecular) * ao;
ผลลัพธ์ที่ควรได้: วางทรงกลมที่มี _Metallic = 1, _Roughness ≈ 0.1 ไว้ในฉากที่มี Reflection Probe โดยไม่มีแสงตรงตกกระทบบางส่วนของมันเลย — แทนที่จะดำสนิทในบริเวณที่ไม่มีแสง ด้านนั้นของทรงกลมจะแสดงภาพสะท้อนเบลอๆ แบบกระจกของท้องฟ้าหรือห้องรอบๆ ตัวมัน เหมือนลูกบอลโครเมียมจริงๆ เป๊ะ
เอาชิ้นส่วนต่างๆ จากบทนี้มารวมกัน จะเห็นแพทเทิร์นหนึ่งโผล่ขึ้นมา: ไม่มีตรงไหนใน _Albedo, _Metallic หรือ _Roughness ที่พูดถึงแสงตัวใดตัวหนึ่ง ฉากใดฉากหนึ่ง หรือเอนจินตัวใดตัวหนึ่งเลย พวกมันอธิบายตัววัสดุเอง — ว่าเวอร์ชันจริงทางฟิสิกส์ของพื้นผิวนี้จะทำอะไรกับแสงที่ตกกระทบมัน เทียบกับ _Shininess และ _SpecColor ของ Blinn-Phong ที่ถูกปรับด้วยสายตาให้เข้ากับการจัดแสงแบบใดแบบหนึ่งโดยเฉพาะ แล้วมักจะดูผิดเพี้ยนทันทีที่ย้ายวัตถุไปด่านอื่นที่มีแสงต่างออกไป
นั่นคือผลตอบแทนที่แท้จริงของทุกอย่างในบทนี้: เพราะ BRDF เคารพ energy conservation (หัวข้อที่ 6) เพราะ roughness ยึดโยงกับโมเดลทางฟิสิกส์จริงของพื้นผิว (หัวข้อที่ 7) แทนที่จะเป็นเลขชี้กำลังที่เดาขึ้นมาลอยๆ เพราะ Fresnel ทำงานอยู่ตลอดเวลา (หัวข้อที่ 9) และเพราะแสง ambient มาจากภาพจริงของสภาพแวดล้อมรอบตัวแทนที่จะเป็นค่าคงที่แบนๆ ค่าเดียว (หัวข้อที่ 11) — วัสดุโครเมียมชิ้นเดียวกันเป๊ะจะดูถูกต้องทั้งใต้แดดเที่ยงจ้า ในถ้ำมืดๆ ที่มีคบไฟ และข้างป้ายนีออนสีสันจัด โดยไม่ต้องปรับใหม่ต่อฉากเลย จัดแสงฉากครั้งเดียว วัสดุ PBR ที่ทำถูกต้องทุกตัวก็แค่ตอบสนองเอง
ยังมีโบนัสในทางปฏิบัติด้วย: เพราะ albedo, metallic และ roughness เป็นปริมาณที่มีมาตรฐาน ยึดโยงกับฟิสิกส์ ไม่ใช่เลขวิเศษเฉพาะตัวของเอนจินใดเอนจินหนึ่ง texture set ชุดเดียวกันที่ export ออกมาจากเครื่องมืออย่าง Substance Painter จะดูถูกต้อง — ไม่ใช่เหมือนกันเป๊ะ แต่ถูกต้อง — ไม่ว่าจะไปลงที่ Unity, Unreal หรือ Blender ค่า shininess "64" แบบ Blinn-Phong ไม่เคยมีความหมายอะไรนอกเหนือไปจาก shader ตัวเดียวที่มันถูกเขียนขึ้นมาเพื่อใช้
ทุกโมเดลในบทนี้ — Lambert, Blinn-Phong, Cook-Torrance, IBL — ล้วนเป็นทางลัดคนละแบบของสูตรพื้นฐานเดียวกัน ในปี 1986 Jim Kajiya เขียนมันออกมาเป็นบรรทัดเดียว และงานเรนเดอร์แสงเรียลไทม์ทั้งหมดก็คือศิลปะของการประมาณสูตรนี้ให้ถูกพอที่จะรันได้ 60 ครั้งต่อวินาที พูดเป็นภาษาคน:
อ่านจากซ้ายไปขวา: แสงที่ออกจากจุดหนึ่งไปยังตาเรา (Lo) คือแสงที่พื้นผิวเปล่งออกมาเอง (Le — เป็นศูนย์สำหรับทุกอย่างยกเว้นโคมไฟ จอภาพ ลาวา และวัสดุ emissive อื่นๆ) บวกกับผลรวมของทุกรังสีที่มาจาก hemisphere ของทิศทางเหนือพื้นผิว โดยแต่ละรังสีถูกคูณด้วยสองสิ่ง: BRDF (สัดส่วนของแสงจากทิศทางที่เข้ามานั้นที่สะท้อนไปยังกล้อง — หัวข้อที่ 6) และ N·L (Lambert cosine ตัวเดียวกับหัวข้อที่ 2 เพราะแสงที่มาในมุมเฉียงจะกระจายบางลง) ส่วน ∫ ("integral") ก็แค่หมายความว่า "รวมทุกทิศทางที่แสงเข้ามา" — เวอร์ชันต่อเนื่องของ loop ที่วนทิศทางแสงจำนวนอนันต์
ไม่มีอะไรในบทนี้หนีสมการนี้ไปได้ แต่ละเทคนิคเป็นแค่วิธีทำให้ส่วน "รวมทุกทิศทาง" มีต้นทุนพอไหว:
BRDF * lightColor * NdotL หนึ่งเทอมต่อไฟหนึ่งดวง นั่นคือสิ่งที่ shader ด้านบนทำใน frag() ตรงๆ เลยเราแทบจะไม่เคยพิมพ์สมการนี้ลงใน shader คุณค่าของมันคือการเป็นแผนที่: เมื่อไรก็ตามที่ผลลัพธ์แสงดูผิด ต้นเหตุคือการประมาณที่แย่ของหนึ่งในสามชิ้นส่วนนี้ — BRDF (D/F/G ผิด หัวข้อที่ 14), แสงที่เข้ามา (ขาด IBL หรือแสงกระเด้ง หัวข้อที่ 11), หรือการบัญชี cosine และพลังงาน (หัวข้อที่ 6)
หัวข้อที่ 10 ใช้ pow(NdotH, shininess) เป็นตัวแทนแล้วชี้ไปที่สูตรจริง ตรงนี้คือ BRDF แบบ Cook-Torrance ตัวจริงที่ Standard shader ของ Unity, URP/HDRP Lit และ Unreal ใช้กันทั้งหมด โดยเขียนทุกเทอมออกมาเป็น HLSL ที่ใช้ได้จริง copy ไปแปะใน shader จากหัวข้อก่อนๆ ได้เลย มีสามฟังก์ชันพอดี — อันละหนึ่งสำหรับ D, F และ G — บวกกับการประกอบร่าง
D — GGX / Trowbridge-Reitz normal distribution นี่คือ "กระจกจิ๋วกระจายตัวแค่ไหน" จากหัวข้อที่ 7 ที่แปลงเป็นตัวเลข: ให้ค่า roughness มา แล้ว microfacet สัดส่วนเท่าไรที่เอียงชี้ตรงไปที่ H พอดี (และสะท้อนแสงตรงเข้าตาเรา)?
// UNITY_PI is defined by UnityCG.cginc; use 3.14159265 elsewhere.
float D_GGX(float NdotH, float roughness)
{
float a = roughness * roughness; // alpha = roughness^2 (Disney remap)
float a2 = a * a;
float d = (NdotH * NdotH) * (a2 - 1.0) + 1.0;
return a2 / (UNITY_PI * d * d);
}
ลองไล่เลขดู (roughness 0.5 ดังนั้น α = 0.25): ตรงจุดกึ่งกลางของ highlight พอดี NdotH = 1 จะได้ D_GGX(1, 0.5) = 5.09 เอียงพื้นผิวให้ NdotH = 0.9 มันยุบเหลือ 0.34 — ลดลง 15 เท่าจากการเปลี่ยนมุมนิดเดียว และยอดแหลมชันนั้นคือสิ่งที่ตาเราอ่านว่าเป็น highlight ทีนี้ทำให้มันเงา (roughness 0.1): D_GGX(1, 0.1) = 3183 แต่ D_GGX(0.99, 0.1) = 0.08 พื้นผิวเกือบเป็นกระจกอัดพลังงานสะท้อนเกือบทั้งหมดไว้ในจุดเดียว — ยอดแหลมสูงลิ่วบางเฉียบ — นั่นคือเหตุผลที่โครเมียมโชว์จุดวับเล็กๆ จ้าจนแสบตา แทนที่จะเป็นแสงนวลๆ
G — Smith geometry (shadowing-masking) term ที่มุมเฉียง microfacet บังกันเอง: บางอันอยู่ในเงาของเพื่อนบ้าน (แสงเข้าไม่ถึง) บางอันถูกบัง (ภาพสะท้อนของมันไปไม่ถึงตา) G คือสัดส่วนที่รอด ตัวเลือกมาตรฐานแบบเรียลไทม์คือ Schlick-GGX ที่ใช้สองครั้งด้วยวิธีของ Smith — ครั้งหนึ่งสำหรับทิศทางแสง อีกครั้งสำหรับมุมมอง:
float G_SchlickGGX(float NdotX, float k)
{
return NdotX / (NdotX * (1.0 - k) + k);
}
float G_Smith(float NdotV, float NdotL, float roughness)
{
float r = roughness + 1.0;
float k = (r * r) / 8.0; // direct-light remap; use k = a*a/2 for IBL
return G_SchlickGGX(NdotV, k) * G_SchlickGGX(NdotL, k);
}
ลองไล่เลขดู (roughness 0.5): มองเกือบตรงๆ NdotV = NdotL = 0.5 จะได้ G = 0.61 — microfacet ส่วนใหญ่มองเห็นได้ ที่มุมเฉียง NdotV = NdotL = 0.1 จะได้ G = 0.08 — เกือบทั้งหมดบังกันเอง G จึงทำให้ specular มืดลงตรงขอบเงาพอดี นี่คือเทอมที่หยุดไม่ให้ Fresnel brightening (หัวข้อที่ 9) ทำให้ขอบสะท้อนแรงเกินจริง: Fresnel ดันการสะท้อนขึ้นที่มุมเฉียง ส่วน G ดึงมันลง และทั้งสองรวมกันก็ตรงกับพื้นผิวจริงที่วัดได้
F — Fresnel คือ F_Schlick(F0, VdotH) จากหัวข้อที่ 9 เป๊ะ ไม่เปลี่ยน ทีนี้ประกอบทั้งสามเข้าเป็นเทอม specular แบบ Cook-Torrance เต็มๆ แล้วแบ่งพลังงานที่เหลือไปเป็น diffuse — นี่คืองบประมาณพลังงานจากหัวข้อที่ 6 ที่ทำให้เห็นจริงๆ:
float3 F0 = lerp(float3(0.04, 0.04, 0.04), albedo, metallic);
float D = D_GGX(NdotH, roughness);
float G = G_Smith(NdotV, NdotL, roughness);
float3 F = F0 + (1.0 - F0) * pow(1.0 - VdotH, 5.0);
// Cook-Torrance specular = D * F * G / (4 (N.V)(N.L)).
// The + 1e-4 stops a divide-by-zero firefly when N.V or N.L hits 0 at the edge.
float3 specular = (D * G * F) / (4.0 * NdotV * NdotL + 1e-4);
// Energy conservation: kS is the specular fraction (= Fresnel), so at most
// (1 - kS) is left for diffuse -- and metals get no diffuse at all.
float3 kS = F;
float3 kD = (1.0 - kS) * (1.0 - metallic);
float3 diffuse = kD * albedo / UNITY_PI;
float3 Lo = (diffuse + specular) * _LightColor0.rgb * NdotL;
มีสองรายละเอียดที่ทำคนสะดุด อย่างแรก / UNITY_PI บนเทอม diffuse ถูกต้องทางฟิสิกส์ — Lambert BRDF คือ albedo / π ไม่ใช่ albedo — แต่ shader เกมจำนวนมากแอบตัดมันทิ้งแล้วยุบ π ที่หายไปเข้าไปในความเข้มของแสงแทน นั่นคือเหตุผลที่ shader แบบย่อในหัวข้อที่ 10 ไม่ใส่มัน อย่างที่สอง kD = (1 - F)(1 - metallic) คือเหตุผลจริงๆ ที่ shader ก่อนหน้าคูณ diffuse ด้วย (1 - metallic): มันคืองบประมาณพลังงาน อะไรก็ตามที่ specular Fresnel สะท้อนไป (kS) จะถูกหักออกจาก diffuse (kD) ดังนั้นทั้งสองจึงไม่มีทางรวมกันเกินแสงที่เข้ามา ไม่ว่า artist จะตั้งสไลเดอร์เป็นเท่าไร
+ 1e-4 (หรือ max ที่เทียบเท่า) ออกจากตัวส่วนของ specular ตรงขอบเงาของวัตถุพอดี N·V เข้าใกล้ 0 ตัวหาร 4 * NdotV * NdotL เข้าใกล้ 0 และค่า specular ระเบิดเป็นพิกเซลขาวจ้าเดี่ยวๆ — fireflies — ที่กระพริบเวลากล้องขยับ การ clamp ตัวส่วนให้ห่างจากศูนย์ (หรือใช้ G ในรูปแบบ "visibility" ที่รวมตัวส่วนไว้ในตัว) จะกำจัดมันได้เกร็ดในหัวข้อที่ 6 บอกว่าเลขคณิตเรื่องพลังงาน "จะถูกต้องก็ต่อเมื่อสีถูกเก็บเป็นค่าแสงแบบ linear" ตรงนี้คือความหมายจริงๆ ของมัน และทำไมการข้ามมันไปถึงทำลายทุกสูตรด้านบนแบบเงียบๆ
จอมอนิเตอร์ไม่ได้แสดงความสว่างแบบเชิงเส้น ป้อนค่า 0.5 ให้มัน มันจะเปล่งแสงราว 22% ของความสว่างเต็ม ไม่ใช่ 50% — จอทำตามเส้นโค้ง sRGB ซึ่งใกล้เคียงกับ gamma 2.2 เพื่อชดเชย ไฟล์ภาพ (PNG, JPEG, texture albedo ที่ artist วาด) จึงถูก เข้ารหัส sRGB ไว้: ตัวเลขที่เก็บถูกดัดล่วงหน้าเพื่อให้ หลังจาก ผ่านเส้นโค้งของจอแล้วดูถูกต้อง การเข้ารหัสนี้ฝังอยู่ในภาพสีแทบทุกภาพที่เราเคยเห็น
ปัญหาคือ: แสงบวกกันแบบเชิงเส้น แต่ค่า sRGB ไม่ ทุกเครื่องหมาย + ในทุก shader ด้านบน — ไฟสองดวงบวกกัน, diffuse บวก specular, ambient บวกแสงตรง — คือพลังงานแสงจริงทางฟิสิกส์ที่กำลังถูกบวก และการบวกนั้นถูกต้องเฉพาะบนตัวเลขแบบ linear เท่านั้น บวกค่า sRGB สองค่าตรงๆ แล้วผลลัพธ์จะไม่มีความหมายทางฟิสิกส์ (มันออกมามืดเกินไป) ดังนั้น pipeline ต้องเป็น:
texture ไหนต้องถอดรหัส และไหนห้ามถอด — นี่คือส่วนที่คนทำผิดกัน texture จะถูกถอดรหัสจาก sRGB ก็ต่อเมื่อมันเก็บสีที่มนุษย์เลือกด้วยสายตา เท่านั้น texture ที่เก็บตัวเลข ต้องปล่อยให้เป็น linear:
เส้นโค้ง sRGB จริงเป็นฟังก์ชันแบบ piecewise ที่มีช่วงเชิงเส้นเล็กๆ ใกล้สีดำ ส่วน pow(x, 2.2) เป็นการประมาณมาตรฐานและเป็นสิ่งที่คำอธิบายส่วนใหญ่ (รวมถึงอันนี้) ใช้ ฮาร์ดแวร์ path แบบ sRGB ใช้เส้นโค้งจริง ดังนั้นควรใช้การติ๊ก checkbox sRGB ของ texture มากกว่าการทำ pow เองด้วยมือ
แสงจริงไม่มีขีดจำกัดบน ดวงอาทิตย์สว่างกว่ากระดาษเป็นพันเท่า หน้าต่างสว่างข้างกำแพงมืดอาจต่างกัน 100 เท่า พอเราบวกไฟหลายดวง บวก specular บวก IBL ผลลัพธ์แบบ linear ก็ทะลุ 1.0 เป็นเรื่องปกติ — จุดวับโครเมียมอาจคำนวณได้ 40.0 แต่ช่องสีของจอตันที่ 1.0 การ clamp ทุกอย่างที่เกิน 1.0 ให้เป็นสีขาวล้วนแบบง่ายๆ จะทิ้งช่วงค่าทั้งหมดนั้นไป และให้ผลเป็นภาพขาวโพลนแบนๆ เหมือนพลาสติก (ลุคโอเวอร์เอ็กซ์โพสแบบเดียวกับที่หัวข้อที่ 5 โทษ Blinn-Phong)
วิธีแก้มีสองส่วน อย่างแรก HDR (High Dynamic Range) rendering: ทำแสงทั้งหมดลงใน render target แบบ floating-point ที่เก็บค่าสูงกว่า 1.0 ได้มากๆ แทนที่จะเป็นแบบ 8-bit ที่ตันที่ 1.0 อย่างที่สอง tonemapping: เส้นโค้งที่ใช้ตอนท้ายสุด บีบช่วง [0, ∞) ที่ไม่มีขอบบนให้ลงมาอยู่ในช่วงที่แสดงได้ [0, 1] โดยยังรักษารายละเอียดทั้งในเงาและในไฮไลต์ไว้ เหมือนฟิล์มจริง เส้นโค้งสองอันที่เจอบ่อยที่สุด:
// Reinhard -- the simplest tonemap. Cheap, but desaturates and flattens highlights.
float3 Reinhard(float3 c)
{
return c / (1.0 + c);
}
// ACES filmic (Narkowicz 2015 fit) -- the modern default in Unity and Unreal.
// Deeper contrast, filmic highlight rolloff, keeps color better than Reinhard.
float3 ACESFilmic(float3 x)
{
const float a = 2.51, b = 0.03, c = 2.43, d = 0.59, e = 0.14;
return saturate((x * (a * x + b)) / (x * (c * x + d) + e));
}
ลองไล่เลขดู ป้อน HDR input ชุดเดียวกันผ่านทั้งสองเส้นโค้ง:
ปกติเราจะใส่ตัวคูณ exposure ก่อน tonemap ด้วย (color *= exposure;) — เวอร์ชันดิจิทัลของปุ่มปรับ exposure ของกล้อง ให้เราเพิ่มความสว่างฉากกลางคืนมืดๆ หรือดึงฉากทะเลทรายที่ขาวโพลนกลับมาได้ โดยไม่ต้องแตะไฟสักดวง ดังนั้นส่วนหางของเฟรมทั้งหมดคือ: linear HDR lighting → คูณ exposure → tonemap → เข้ารหัสเป็น sRGB (หัวข้อที่ 15) → แสดงผล ถ้าสลับลำดับผิด — เช่น tonemap หลัง การเข้ารหัส sRGB — เส้นโค้งจะทำงานบนตัวเลขที่ผิด และทั้งภาพจะเพี้ยน
สมการ PBR ที่ดูถูกต้องแล้วก็ยังสร้างความผิดพลาดที่มีชื่อเรียกและจดจำได้บนฮาร์ดแวร์จริง สี่อย่างที่เราจะเจอ แต่ละอย่างพร้อมสาเหตุและวิธีแก้มาตรฐาน:
Banding gradient มืดๆ เรียบๆ — ท้องฟ้ายามพระอาทิตย์ตก, IBL ที่จางลงนวลๆ, วงแสง spotlight แผ่วๆ — โชว์ลายเส้นขั้นบันไดที่มองเห็นได้ แทนที่จะไล่เรียบ สาเหตุ: ช่องสีของจอแบบ 8-bit มีแค่ 256 ระดับ และตลอด gradient กว้างๆ ที่ค่อยๆ ไล่ ระยะกระโดดระหว่างสองระดับที่ติดกันจะใหญ่พอที่ตาเราจับได้เป็นเส้นขอบคมๆ วิธีแก้: dithering — เพิ่ม noise ระดับต่ำกว่าพิกเซล (ราว ±1/255) เข้าไปก่อนการ quantize เป็น 8-bit เป็นการแลกเส้นขอบคมๆ ที่มองเห็นได้ ให้กลายเป็น noise ละเอียดๆ ที่มองไม่เห็นและตาเฉลี่ยทิ้งไปเอง
// Add just before writing the final 8-bit color. screenPos = pixel coordinates.
float dither = frac(sin(dot(screenPos.xy, float2(12.9898, 78.233))) * 43758.5453);
color.rgb += (dither - 0.5) / 255.0;
Specular aliasing (ระยิบระยับ / เลื้อยไปมา) พื้นผิวที่มีรายละเอียด normal-map ละเอียดๆ หรือวัตถุเงาที่มองจากระยะไกล จะประกายวิบวับและเลื้อยด้วยจุดขาวกระพริบเวลากล้องขยับ สาเหตุ: หนึ่งพิกเซลครอบคลุม microfacet หลายทิศทาง แต่ shader sample normal ตัวเดียวและ roughness (ต่ำ) ตัวเดียว — มัน undersample ยอดแหลม GGX บางเฉียบจากหัวข้อที่ 14 highlight จึงโผล่มาแล้วหายไประหว่างเฟรม วิธีแก้: เพิ่ม roughness ตรงที่ normal แปรผันเร็วกว่าที่พิกเซลจะ resolve ได้ normal map ที่ทำ mipmap จะป้อนขั้นตอน normal-variance-to-roughness (Toksvig หรือปุ่ม "Specular AA" ใน Unity HDRP / Unreal) ที่เพิ่มความหยาบให้พื้นผิวที่ถูกย่อโดยอัตโนมัติ การ clamp roughness ขั้นต่ำและ temporal anti-aliasing (TAA) ก็ช่วยได้ สัญชาตญาณบรรทัดเดียว: ถ้า resolve รายละเอียดไม่ได้ ก็เบลอ highlight แทนที่จะปล่อยให้มันกระพริบ
Gamma ผิด ทั้งฉากดูขุ่นมัวและมืดเกินไป midtone ถูกบีบจนจม — หรือไฟที่ควรจะรวมกันเป็น "สว่างสองเท่า" แทบไม่เปลี่ยนอะไรเลย สาเหตุ: ทำสมการแสงบนค่า sRGB หรือข้ามการเข้ารหัส linear→sRGB ตอนท้าย (หัวข้อที่ 15) ค่า linear 0.25 ที่เขียนลงจอ sRGB ตรงๆ จะแสดงเป็น pow(0.25, 2.2) ≈ 0.047 — มืดเกินไปราวห้าเท่า วิธีแก้: Color Space = Linear, มาร์ก color texture เป็น sRGB, มาร์ก data texture เป็น linear นี่คือสาเหตุที่พบบ่อยที่สุดที่ทำให้ shader ที่เขียนเองตั้งแต่ต้น "ดูแปลกๆ" แบบที่บอกไม่ถูก
Fireflies พิกเซลเดี่ยวๆ สว่างจ้าจนแสบตาที่กระพริบตามขอบและบนพื้นผิวเงา สาเหตุ: ตัวส่วน 4·(N·V)·(N·L) ใน Cook-Torrance specular (หัวข้อที่ 14) เข้าใกล้ศูนย์ที่มุมเฉียง บวกกับค่า HDR สว่างมากๆ ที่ตกลงบน sample เดี่ยวๆ วิธีแก้: การ clamp ตัวส่วนด้วย + 1e-4 จากหัวข้อที่ 14, การ clamp ความสว่างสูงสุดก่อนเข้า bloom/blur pass และ TAA เพื่อเฉลี่ยข้ามเฟรม
N.L, N.H, V.H)pow(N.H, shininess)D = α² / (π ((N·H)²(α²−1)+1)²) โดย α = roughness²pow(c, 2.2) เข้ารหัสกลับด้วย pow(c, 1/2.2)c / (1 + c); ถูกแต่ทำให้สีจางและไฮไลต์แบนfrag() ด้านล่างคำนวณแค่ Lambert diffuse เท่านั้น คัดลอกมาจากหัวข้อที่ 2 ให้ต่อยอดเพิ่มเทอม specular แบบ Blinn-Phong เข้าไปด้วย โดยใช้ property ใหม่ _Shininess (float แบบ Range(8, 256)) และใช้ _Color ซ้ำเป็นสี specular ต้องคำนวณ V กับ H เอง — i.worldPos มีอยู่แล้วใน v2f
fixed4 _Color;
fixed4 frag(v2f i) : SV_Target
{
float3 N = normalize(i.worldNormal);
float3 L = normalize(_WorldSpaceLightPos0.xyz);
float NdotL = max(0, dot(N, L));
fixed3 diffuse = _Color.rgb * _LightColor0.rgb * NdotL;
return fixed4(diffuse, 1);
}
fixed4 _Color;
float _Shininess;
fixed4 frag(v2f i) : SV_Target
{
float3 N = normalize(i.worldNormal);
float3 L = normalize(_WorldSpaceLightPos0.xyz);
float3 V = normalize(_WorldSpaceCameraPos - i.worldPos);
float3 H = normalize(L + V);
float NdotL = max(0, dot(N, L));
float NdotH = max(0, dot(N, H));
fixed3 diffuse = _Color.rgb * _LightColor0.rgb * NdotL;
fixed3 specular = _Color.rgb * _LightColor0.rgb * pow(NdotH, _Shininess);
return fixed4(diffuse + specular, 1);
}
V ได้มาจากตำแหน่ง world ของกล้องลบตำแหน่ง world ของพิกเซลนี้ และ H คือผลรวมที่ normalize แล้วของ L กับ V (หัวข้อที่ 3) การใช้ _Color ซ้ำทั้งสำหรับ diffuse และ specular เป็นการทำให้ง่ายขึ้น — หัวข้อที่ 5 อธิบายว่าทำไม shader จริงๆ ถึงมักแยกสองอย่างนี้ออกจากกัน (หรือใน PBR shader จากหัวข้อที่ 8–10 จะดึง specular tint มาจาก albedo กับ metallic แทนที่จะเป็นสีลอยๆ เดี่ยวๆ)
_Metallic และ _Roughness ที่สมเหตุสมผล (แต่ละค่า 0–1) พร้อมอธิบายเหตุผลการเลือกในหนึ่งประโยค: (a) กันชนรถโครเมียมขัดเงา (b) ยางรถยนต์ (c) จานเซรามิกเคลือบเรียบมัน (d) ราวเหล็กเก่าที่มีคราบสนิมลอกเป็นหย่อมๆ บนเนื้อโลหะเปลือย(a) กันชนโครเมียม: Metallic ≈ 1.0, Roughness ≈ 0.05 มันคือโลหะเปลือยที่ขัดเงา จึงแทบไม่มีเทอม diffuse เลย และ microfacet ของมันเรียงตัวเกือบสมบูรณ์แบบ ทำให้ได้ภาพสะท้อนเกือบเป็นกระจก
(b) ยางรถยนต์: Metallic ≈ 0.0, Roughness ≈ 0.9 ยางเป็น dielectric (Metallic 0) ที่มีพื้นผิว microfacet กระจายตัวมากและด้าน จึงแสดงแค่แสงวับๆ แผ่วๆ กว้างๆ แทนที่จะเป็น highlight คมชัด
(c) จานเซรามิกเคลือบมัน: Metallic ≈ 0.0, Roughness ≈ 0.1–0.2 น้ำเคลือบเป็นวัสดุที่ไม่ใช่โลหะ (Metallic 0) แต่เป็นชั้นเคลือบเรียบเนียนคล้ายแก้ว ทำให้ microfacet ของมันเรียงตัวค่อนข้างดี — roughness ต่ำ ทำให้ได้ highlight ที่ค่อนข้างแคบและสว่าง ทั้งที่ไม่ใช่โลหะเลยสักนิด
(d) ราวเหล็กเป็นสนิม: อันนี้ไม่ใช่ค่าเดียว — ต้องใช้ texture _Metallic ไม่ใช่ตัวเลขทึบๆ ค่าเดียว หย่อมที่เป็นโลหะเปลือยควรอยู่ใกล้ Metallic 1.0 ด้วย roughness ต่ำถึงปานกลาง (มีรอยขีดข่วน ไม่ได้ขัดเงาแบบกระจก) ส่วนหย่อมสนิมควรอยู่ใกล้ Metallic 0.0 ด้วย roughness สูง (สนิมคือออกไซด์ ไม่ใช่โลหะ และดูแห้งด้าน) นี่คือเหตุผลเป๊ะๆ ที่ Metallic กับ Roughness ถูกวาดเป็น texture รายพิกเซลในหัวข้อที่ 8 แทนที่จะตั้งค่าครั้งเดียวต่อวัตถุ — วัสดุจริงๆ แทบไม่เคยสม่ำเสมอทั่วทั้งชิ้นเลย
_Metallic บน shader ด้านล่างถูกตั้งไว้ที่ 1 (ตั้งใจให้เป็นโครเมียมล้วนๆ) แต่แทนที่จะดูเหมือนกระจก ทรงกลมกลับเรนเดอร์ออกมาเป็นแสงวับๆ สีเทาซีดๆ กระจายสม่ำเสมอทั่วทั้งพื้นผิว — ไม่ใช่ highlight คมแคบแบบที่ลูกบอลโครเมียมจริงๆ ควรจะมี หาบรรทัดที่เป็นต้นเหตุ อธิบายว่ามันละเมิดกฎจากหัวข้อที่ 6 ยังไง แล้วแก้ไขมัน
fixed4 frag(v2f i) : SV_Target
{
float3 N = normalize(i.worldNormal);
float3 L = normalize(_WorldSpaceLightPos0.xyz);
float3 V = normalize(_WorldSpaceCameraPos - i.worldPos);
float3 H = normalize(L + V);
float NdotL = saturate(dot(N, L));
float NdotH = saturate(dot(N, H));
float VdotH = saturate(dot(V, H));
float3 F0 = lerp(float3(0.04, 0.04, 0.04), _Albedo.rgb, _Metallic);
float3 fresnel = F0 + (1 - F0) * pow(1 - VdotH, 5);
float shininess = lerp(512.0, 2.0, _Roughness);
float specular = pow(NdotH, shininess) * NdotL;
float3 diffuse = _Albedo.rgb * NdotL;
float3 color = (diffuse + specular * fresnel) * _LightColor0.rgb;
return fixed4(color, 1);
}
บั๊กอยู่ที่บรรทัดนี้:
float3 diffuse = _Albedo.rgb * NdotL;
มันขาดตัวคูณ (1 - _Metallic) จากหัวข้อที่ 10 ไป ตามที่เขียนไว้ตอนนี้ ทุกวัสดุจะได้เทอม diffuse เต็มๆ ไม่มีการปรับสเกล ไม่ว่า _Metallic จะตั้งไว้เท่าไรก็ตาม — ดังนั้นแม้แต่ตอน _Metallic = 1 shader ก็ยังบวกแสงกระเด้ง diffuse ทึบๆ ด้านๆ เต็มๆ เข้าไปทับบนภาพสะท้อน specular อยู่ดี หัวข้อที่ 8 อธิบายไว้แล้วว่าโลหะจริงๆ (แทบ) ไม่มีเทอม diffuse เลย — แสงที่มันสะท้อน 100% เป็น specular การบวกทั้งเทอม diffuse เต็มๆ และเทอม specular เต็มๆ พร้อมกัน ยังละเมิดงบประมาณ energy-conservation จากหัวข้อที่ 6 ด้วย: พื้นผิวตอนนี้สะท้อนแสงรวมมากกว่าที่วัสดุจริงจะทำได้ในทางฟิสิกส์ วิธีแก้คือใส่ตัวคูณที่ขาดไปกลับเข้ามา:
float3 diffuse = _Albedo.rgb * (1 - _Metallic) * NdotL;
เมื่อแก้แล้ว ที่ _Metallic = 1 ค่า (1 - _Metallic) จะกลายเป็น 0 เทอม diffuse หายไปทั้งหมด เหลือแค่ highlight แบบ specular ที่แคบและย้อมด้วย Fresnel — ได้ลุคโครเมียมแบบกระจกที่ถูกต้อง
D_GGX(NdotH, roughness) และ G_Smith(NdotV, NdotL, roughness) ที่ใช้งานได้จากหัวข้อที่ 14 บวกกับ F_Schlick(F0, VdotH) จากหัวข้อที่ 9 ใช้พวกมันเขียนบรรทัด fragment-shader ที่คำนวณ (a) เทอม Cook-Torrance specular, (b) เทอม diffuse ที่รักษาสมดุลพลังงานพร้อมการแบ่ง kD/kS และ (c) Lo รวมสุดท้าย สมมติว่า albedo, metallic และ dot product ทั้งสี่คำนวณไว้แล้ว และ F0 สร้างด้วย lerp(0.04, albedo, metallic) ปริมาณเดียวไหนที่ต้องระวังไม่ให้หารด้วยศูนย์ และแก้ยังไง?
float D = D_GGX(NdotH, roughness);
float G = G_Smith(NdotV, NdotL, roughness);
float3 F = F_Schlick(F0, VdotH);
// (a) specular = D * F * G / (4 (N.V)(N.L)); +1e-4 guards the divide.
float3 specular = (D * G * F) / (4.0 * NdotV * NdotL + 1e-4);
// (b) energy split: kS is the specular fraction, kD is what's left,
// and metals get no diffuse at all.
float3 kS = F;
float3 kD = (1.0 - kS) * (1.0 - metallic);
float3 diffuse = kD * albedo / UNITY_PI;
// (c) combine, apply the light color and the Lambert cosine.
float3 Lo = (diffuse + specular) * _LightColor0.rgb * NdotL;
ปริมาณที่ต้องระวังคือตัวส่วนของ specular 4 * NdotV * NdotL: ตรงขอบเงาของวัตถุ NdotV (และใกล้ terminator คือ NdotL) เข้าใกล้ 0 ตัวหารจึงเข้าใกล้ 0 และ specular ระเบิดเป็น fireflies การเพิ่ม + 1e-4 (หรือครอบตัวหารด้วย max(..., 1e-4)) ทำให้มันมีค่าจำกัด สังเกตว่า diffuse คือ albedo / π ไม่ใช่ albedo — นั่นคือ Lambert BRDF ที่ถูกต้องทางฟิสิกส์ (หัวข้อที่ 14)
ปัญหาที่ 1 — gamma ผิด (หัวข้อที่ 15) โปรเจกต์อยู่ใน color space แบบ Gamma (หรือขาดการเข้ารหัส linear→sRGB ตอนท้าย) ค่าแสง linear จึงถูกเขียนลงจอ sRGB ตรงๆ ค่า linear 0.25 จะแสดงเป็น pow(0.25, 2.2) ≈ 0.047 — มืดเกินไปราวห้าเท่า — ซึ่งก็คือลุคขุ่นมัว midtone จมพอดี วิธีแก้: Player Settings > Color Space = Linear, มาร์ก color texture เป็น sRGB และ data texture (normal/roughness/metallic/AO) เป็น linear
ปัญหาที่ 2 — banding (หัวข้อที่ 17) gradient มืดๆ เรียบๆ มีระดับความสว่างที่ต่างกันมากกว่าที่ช่อง 8-bit 256 ระดับจะแทนได้ ระดับที่ติดกันจึงโชว์เป็นเส้นคมๆ วิธีแก้: dither ก่อน output — color.rgb += (dither - 0.5) / 255.0; — แลกเส้นขอบที่มองเห็นได้เป็น noise ที่มองไม่เห็น
ทั้งสองเป็นอิสระต่อกัน: การแก้ color space กำจัดความมืดแต่ไม่กำจัด banding และการ dither ซ่อน banding แต่ไม่ทำอะไรกับ gamma เลย build ที่ถูกต้องต้องมีทั้งสองอย่าง