บทนี้พูดถึง shader: โปรแกรมเล็ก ๆ ที่รันบนการ์ดจอ (graphics card) แทนที่จะรันบน CPU และเป็นตัวตัดสินใจว่าแต่ละพิกเซลที่เราเห็นในเกม Unity จะมีสีอะไร บทก่อนหน้าสอน C#, C++ และ data structure ที่รันบน CPU ทีละคำสั่งเรียงกันไป ส่วน shader เป็นโลกที่ต่างออกไปโดยสิ้นเชิง — โปรแกรมตัวเล็ก ๆ ตัวเดียวกันหลายพันชุดรันพร้อมกัน บนข้อมูลคนละชุด บนชิปที่สร้างมาเพื่องานนี้โดยเฉพาะ เราจะเขียนโค้ด Unity ShaderLab/HLSL จริง ดูว่ามันวาดอะไรออกมาบนจอ แล้วก็ดู Shader Graph ทางเลือกแบบ visual ของ Unity ที่ไม่ต้องพิมพ์ HLSL เอง
Shader คือโปรแกรมเล็ก ๆ ที่รันบน GPU (Graphics Processing Unit — ชิปที่สร้างมาเพื่อวาดพิกเซลให้เร็ว แยกจาก CPU ที่รันสคริปต์ C# ของเรา) GPU สมัยใหม่มี core ประมวลผลเล็ก ๆ อยู่หลายพันตัว แทนที่จะรันลูปเดียวซ้ำหลายรอบบน CPU core เดียวที่เร็วมาก GPU จะรันโปรแกรมเล็ก ๆ ตัวเดียวกันหลายชุด พร้อมกัน โดยแต่ละชุดทำงานกับข้อมูลคนละชุด
โมเดล 3D (เรียกว่า mesh) ถูกสร้างจากสามเหลี่ยม (triangle) แต่ละสามเหลี่ยมมี 3 มุม เรียกว่า vertex (พหูพจน์: vertices) เพื่อวาด mesh หนึ่งชิ้นบนจอ GPU ต้องตอบคำถาม 2 ข้อซ้ำ ๆ สำหรับทุกสามเหลี่ยม
เราเขียนทั้งสองอย่างนี้เป็นฟังก์ชันเล็ก ๆ Unity จะคอมไพล์มันเป็นโค้ด GPU อัปโหลดขึ้นไปบนการ์ดจอ แล้ว GPU ก็รันฟังก์ชันแต่ละตัวหลายพันชุดพร้อมกันแบบขนาน (parallel) mesh ที่มี 500 vertex ไม่ได้รัน vertex shader ครั้งเดียวแล้ววนลูป 500 รอบ — GPU (ในเชิงแนวคิด) รัน 500 ชุดพร้อมกัน แต่ละชุดจัดการ vertex หนึ่งตัว
สอง stage นี้รันคู่กันเสมอ แต่มีหน้าที่และปริมาณงานที่ต่างกันมาก
งานหลักของ vertex shader คือ แปลงตำแหน่ง (transforming a position): เอาตำแหน่งของ vertex ซึ่งตอนแรกอ้างอิงกับตัวโมเดล 3D เอง (object space) แล้วแปลงมันไปเป็น clip space (ระบบพิกัดที่ rasterizer ของ GPU เข้าใจ ใช้ตัดสินว่าอะไรไปตกอยู่ตรงไหนบนจอ) มันยังส่งข้อมูลเพิ่มเติมต่อไปได้ด้วย เช่น UV coordinate, normal vector, หรือสีของ vertex ให้ fragment shader เอาไปใช้ทีหลัง
งานหลักของ fragment shader คือ ตัดสินใจเรื่องสี มันรับข้อมูลที่ vertex shader ส่งต่อมา (ซึ่งถูก interpolate ข้ามสามเหลี่ยมมาแล้ว — รายละเอียดอยู่ใน Section 5) และต้อง output สีสุดท้ายออกมาสำหรับพิกเซลนั้นหนึ่งพิกเซล
เพราะ mesh มักมีจำนวน vertex น้อยกว่าจำนวนพิกเซลที่มันครอบคลุมบนจอมาก fragment shader จึงมักรันมากกว่า vertex shader หลายเท่า นี่คือเหตุผลว่าทำไมโค้ดใน fragment shader มักเป็นครึ่งที่ต้องระวังเรื่อง performance มากกว่า — คำสั่งส่วนเกินไม่กี่คำสั่งตรงนั้นจะถูกคูณด้วยจำนวนพิกเซลทั้งหมดบนจอ ทุกเฟรม
shader ใน Unity ปกติเขียนเป็นไฟล์ .shader โดยใช้ ShaderLab (ภาษา wrapper ของ Unity เองที่จัดระเบียบ setting และ property ของ shader) ส่วนคณิตศาสตร์จริง ๆ ที่ทำงานต่อ vertex/ต่อ pixel เขียนด้วย HLSL (High Level Shading Language — ภาษาคล้าย C ของ Microsoft สำหรับโปรแกรม GPU ใช้กับ DirectX โดยตรงด้วย) ShaderLab ไม่ใช่ HLSL ให้คิดว่า ShaderLab เป็นโครงสร้างโฟลเดอร์และ setting ที่ห่อหุ้มโค้ด HLSL จริงที่อยู่ข้างในไว้
มีสองสามส่วนที่ควรรู้จักชื่อไว้ก่อนดูตัวอย่างเต็ม ๆ
Properties — ประกาศ "ปุ่มปรับ" ที่ Material ซึ่งใช้ shader นี้จะแสดงใน Inspector: สี, texture, ตัวเลข, sliderSubShader / Pass — SubShader รวม setting สำหรับฮาร์ดแวร์แต่ละระดับ; Pass คือการวาด geometry จริงหนึ่งครั้ง shader ง่าย ๆ ส่วนใหญ่มี SubShader เดียวและ Pass เดียวCGPROGRAM / ENDCG — บอกจุดเริ่มและจุดจบของบล็อกโค้ด HLSL จริง ใน Built-in Render Pipeline แบบคลาสสิกของ Unity บทนี้ใช้คู่นี้เพราะไม่ต้อง include package เพิ่ม ทำให้ตัวอย่างสั้นและอ่านง่าย#pragma vertex vert / #pragma fragment frag — บอกตัวคอมไพเลอร์ว่าฟังก์ชันชื่ออะไรคือ vertex shader และฟังก์ชันชื่ออะไรคือ fragment shader#include "UnityCG.cginc" — ดึงไฟล์ helper function และ macro สำเร็จรูปที่ Unity เตรียมไว้ให้ เช่น UnityObjectToClipPosถ้าโปรเจกต์ของเราใช้ URP หรือ HDRP (Scriptable Render Pipeline รุ่นใหม่ของ Unity) คำสั่งที่ใช้ห่อบล็อกจะเปลี่ยนเป็น HLSLPROGRAM / ENDHLSL และ path ของ include จะเปลี่ยนไปเป็นไฟล์ใต้ Packages/com.unity.render-pipelines.universal/... แต่แนวคิด HLSL — vertex function, fragment function, semantic, uniform — เหมือนเดิมทุกอย่าง บทนี้ใช้สไตล์ CGPROGRAM/ENDCG แบบคลาสสิก เพราะมันคอมไพล์ได้ในโปรเจกต์ Built-in Render Pipeline ธรรมดาโดยไม่ต้อง setup อะไรเพิ่ม เป็นทางที่เร็วที่สุดที่จะเห็นแนวคิดพวกนี้ทำงานจริงเป็นครั้งแรก
นี่คือ shader ที่ทำงานได้จริงแบบเต็ม ๆ มันวาดทุกพิกเซลของ object ด้วยสีเรียบสีเดียว ซึ่งเราเปลี่ยนได้จาก Inspector
Shader "Lesson/SolidColor"
{
Properties
{
_Color ("Color", Color) = (1, 0.4, 0, 1)
}
SubShader
{
Tags { "RenderType" = "Opaque" }
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
};
struct v2f
{
float4 pos : SV_POSITION;
};
fixed4 _Color;
v2f vert (appdata v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
return o;
}
fixed4 frag (v2f i) : SV_Target
{
return _Color;
}
ENDCG
}
}
}
สิ่งที่เห็นบนจอ: สร้าง Material ตั้ง shader ของมันเป็น Lesson/SolidColor (พิมพ์ชื่อลงในช่องค้นหาของ dropdown เลือก shader) ลาก Material ไปวางบน Sphere หรือ Cube ใน Scene object ทั้งชิ้นจะกลายเป็นสีส้มเรียบ — ค่า RGBA (1, 0.4, 0, 1) ที่เป็นค่า default ใน Properties เปลี่ยน _Color ใน Inspector แล้วสีของ object จะเปลี่ยนทันที โดยไม่ต้องแก้โค้ดหรือคอมไพล์ใหม่เลย
อธิบายทีละบรรทัด: appdata ("application data") คือ struct ที่บอกว่า vertex หนึ่งตัวที่ เข้ามา หน้าตาเป็นยังไง — ในที่นี้มีแค่ตำแหน่ง ติด tag ด้วย semantic POSITION (label ที่บอก Unity ว่าจะเสียบข้อมูลช่องไหนของ mesh เข้ากับ field นี้) v2f ("vertex to fragment") คือ struct ที่ฟังก์ชัน vertex output ออกมาและฟังก์ชัน fragment รับเข้าไป; v2f ทุกตัวต้องมี field ที่ติด tag SV_POSITION — rasterizer ของ GPU ต้องการตำแหน่ง clip-space นี้เพื่อรู้ว่าจะวาดตรงไหน UnityObjectToClipPos คือ helper function (จาก UnityCG.cginc) ที่ทำคณิตศาสตร์จริง ๆ ในการย้ายตำแหน่งจาก object space ไปเป็น clip space โดยใช้ transform ของ object, กล้อง, และ projection frag ต้อง return ค่าที่ติด tag SV_Target — สีสุดท้ายของพิกเซลนั้นหนึ่งพิกเซล
fixed4 _Color;) ต้องตรงกับชื่อ property ใน Properties block เป๊ะ ๆ รวมขีดล่างด้วย ถ้าชื่อไม่ตรงกัน Unity จะไม่เชื่อมมันเข้าด้วยกัน — shader ยังคอมไพล์ผ่านอยู่ดี แต่ตัวแปรจะเงียบ ๆ ใช้ค่า default ของมันเอง (ปกติคือศูนย์ทั้งหมด ซึ่งอ่านได้เป็นสีดำและโปร่งใสเต็มที่) และไม่ว่าจะแก้อะไรใน Inspector ก็จะไม่เห็นผลอะไรเลยfixed4, half4, และ float4 ทั้งหมดคือ "ตัวเลข 4 ตัวมัดรวมกัน" (ใช้เป็นสีแบบ RGBA หรือตำแหน่งแบบ XYZW) แต่ต่างกันที่ precision: fixed คือ precision ต่ำสุด (ใช้กับสีได้สบาย ประหยัดสุดบน mobile GPU), float คือ precision สูงสุด (จำเป็นสำหรับตำแหน่งและคณิตศาสตร์ที่ต้องแม่นยำ) ส่วน half อยู่ตรงกลางข้อมูลที่ใส่เข้าไปใน field ของ v2f ข้างใน vert() ไม่ได้ถูกส่งตรง ๆ ไปให้ fragment shader แบบไม่เปลี่ยนแปลง vertex shader รันครั้งหนึ่งต่อ มุม ของสามเหลี่ยม (3 ครั้ง) แต่ fragment shader รันครั้งหนึ่งต่อ พิกเซล ข้างในสามเหลี่ยมนั้น (บ่อยครั้งเป็นพัน ๆ ครั้ง) สำหรับทุกพิกเซลเหล่านั้น GPU จะผสมค่าของทั้งสามมุมเข้าด้วยกันโดยอัตโนมัติ ถ่วงน้ำหนักตามว่าพิกเซลนั้นอยู่ใกล้แต่ละมุมแค่ไหน กระบวนการนี้เรียกว่า interpolation และ field ของ v2f ที่ได้รับการปฏิบัตินี้เรียกว่า varying (หรือ interpolator) — ค่าของมัน "แปรผัน" อย่างนุ่มนวลไปทั่วพื้นผิวของสามเหลี่ยม
เราเห็นสิ่งนี้ได้โดยตรงถ้าส่งสีต่อ vertex ผ่านไปให้ fragment shader โดยไม่เปลี่ยนแปลงอะไรเลย
struct appdata
{
float4 vertex : POSITION;
float4 color : COLOR;
};
struct v2f
{
float4 color : COLOR0;
float4 pos : SV_POSITION;
};
v2f vert (appdata v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.color = v.color; // just pass it straight through
return o;
}
fixed4 frag (v2f i) : SV_Target
{
return i.color; // this is already the INTERPOLATED color
}
สิ่งที่เห็นบนจอ: ถ้าสามมุมของสามเหลี่ยมใน mesh ถูกกำหนดสีเป็นแดง เขียว และน้ำเงิน (เช่นผ่านสคริปต์เล็ก ๆ ที่เขียนลงไปใน Mesh.colors) สามเหลี่ยมบนจอจะแสดงเป็นสีผสมแบบรุ้งที่ไล่ระดับนุ่ม ๆ ทั่วพื้นผิว ไม่ใช่สามแผ่นสีเรียบแยกกัน ฟังก์ชัน vert รันแค่ 3 ครั้ง และ "เห็น" ทีละสีเท่านั้น — การผสมสีนุ่ม ๆ ที่เราเห็นเกิดขึ้นข้างใน rasterizer ของ GPU ทั้งหมด ระหว่าง vertex stage กับ fragment stage และไม่มีโค้ด HLSL ที่เราเขียนตรงไหนเป็นคนสร้างมันขึ้นมาโดยตรง
texture คือภาพ 2D แบน ๆ เพื่อห่อภาพแบนนี้ลงบน mesh 3D vertex แต่ละตัวจะเก็บตัวเลขเพิ่มอีกสองตัว เรียกว่า UV coordinate (u กับ v แต่ละตัวปกติมีค่าตั้งแต่ 0 ถึง 1) ที่บอกว่า "จุดไหนบนภาพ texture แบน ๆ นั้นตรงกับมุมนี้ของ mesh" UV พวกนี้ถูกสร้างตอนที่ mesh ถูก UV-unwrap ในโปรแกรมสร้างโมเดล 3D และ Unity ก็แค่พกมันติดไปด้วย
การอ่านสีของ texture ที่ UV หนึ่ง ๆ เรียกว่า sampling ทำใน HLSL ด้วย tex2D(textureVariable, uv) มันไม่ได้แค่หยิบพิกเซลที่ใกล้ที่สุดของภาพมาใช้เฉย ๆ (พิกเซลของภาพที่ใช้แบบนี้เรียกว่า texel ย่อมาจาก "texture element") — โดย default มันจะผสมนุ่ม ๆ ระหว่าง texel ที่อยู่ใกล้กัน เรียกว่า filtering เพื่อไม่ให้ texture ดูเป็นก้อนสี่เหลี่ยมเวลาซูมเข้าไปใกล้ ๆ
Shader "Lesson/TextureSample"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
}
SubShader
{
Tags { "RenderType" = "Opaque" }
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 pos : SV_POSITION;
};
sampler2D _MainTex;
float4 _MainTex_ST;
v2f vert (appdata v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
return o;
}
fixed4 frag (v2f i) : SV_Target
{
fixed4 col = tex2D(_MainTex, i.uv);
return col;
}
ENDCG
}
}
}
สิ่งที่เห็นบนจอ: ตั้ง shader นี้ให้ Material ลาก texture (image asset ไหนก็ได้) ลงไปในช่อง _MainTex แล้วเอา Material ไปวางบน mesh texture จะห่อลงบน mesh ตรงตามที่มัน UV-unwrap ไว้ในโปรแกรม 3D — mesh ตัวละครก็จะแสดงผิวหนังที่วาดไว้ mesh ลังไม้ก็จะแสดงลายไม้บนด้านข้าง
sampler2D _MainTex; คือ handle ที่ shader ใช้เชื่อมไปยัง texture asset จริง ๆ float4 _MainTex_ST; คือ property ที่ Unity เติมค่าให้อัตโนมัติจากช่อง Tiling กับ Offset ของ Material; TRANSFORM_TEX(v.uv, _MainTex) เป็น macro ที่เอา tiling/offset นั้นไปใช้กับ UV ดิบของ mesh ก่อน sample ทำให้การเปลี่ยน Tiling/Offset ใน Inspector ใช้งานได้เลยโดยไม่ต้องเขียนโค้ดเพิ่ม
ค่าอย่าง _Color หรือ _MainTex เรียกว่า uniform ในศัพท์ของ GPU: มันมีค่าเท่ากันเป๊ะ ๆ สำหรับทุก vertex และทุกพิกเซลตลอดการ draw call หนึ่งครั้ง — "uniform" แปลว่า "ไม่เปลี่ยนแปลง" ตรงข้ามกับ varying (Section 5) ที่ต่างกันในแต่ละพิกเซล Properties block เป็นวิธีหนึ่งที่ให้ uniform มีค่า default ที่แก้ได้ใน Inspector แต่เราก็ตั้งค่า uniform ตรง ๆ จากสคริปต์ C# ตอน runtime ได้เหมือนกัน โดยใช้ method SetColor, SetFloat, SetVector, หรือ SetTexture ของ Material
using UnityEngine;
public class SetShaderColor : MonoBehaviour
{
public Color myColor = Color.cyan;
Renderer rend;
void Start()
{
rend = GetComponent<Renderer>();
rend.material.SetColor("_Color", myColor);
}
}
สิ่งที่เห็นบนจอ: แนบสคริปต์นี้เข้ากับ cube ตัวเดิมที่ใช้ shader Lesson/SolidColor จาก Section 4 ตั้ง myColor เป็นสีฟ้าอมเขียว (cyan) ใน Inspector แล้วกด Play cube จะเปลี่ยนจากสีส้ม default เป็นสี cyan ทันทีที่ Start() รัน เพราะ SetColor เขียนทับค่า uniform _Color ของ material ตัวนี้ตอน runtime
"_Color" ที่ส่งให้ SetColor ต้องตรงกับชื่อตัวแปร HLSL เป๊ะ ๆ เหมือนใน Section 4 — พิมพ์ผิดแล้วจะไม่มีอะไรเกิดขึ้น ไม่มี error ด้วย นอกจากนี้ให้ระวัง rend.material (ไม่มีคำว่า "shared"): การอ่าน property นี้จะสร้าง copy ใหม่ของ Material ที่ไม่ซ้ำใครขึ้นมาทันทีตั้งแต่ครั้งแรกที่เข้าถึงบน Renderer ตัวนั้น ถ้าทำแบบนี้ทุกเฟรม หรือทำกับ object หลายร้อยตัวที่จริง ๆ แล้วน่าจะใช้ Material ร่วมกันได้ จะกิน memory เปล่า ๆ และทิ้ง Material instance ส่วนเกินไว้ ซึ่ง Unity จะเตือนเราเรื่องนี้ ใช้ rend.sharedMaterial เวลาที่เราแค่ต้องการอ่านค่า setting ที่ใช้ร่วมกัน และ cache ผลลัพธ์ของ rend.material ไว้ใน field แทนที่จะเรียกซ้ำ ๆUnity ป้อน uniform สำเร็จรูป float4 _Time เข้าไปในทุก shader ให้อัตโนมัติ โดยที่เราไม่ต้องประกาศมันเอง — ไม่ต้องมี entry ใน Properties ด้วยซ้ำ ทั้งสี่ component ของมันเป็นตัวคูณของเวลาเป็นวินาทีนับตั้งแต่ level โหลดเสร็จ ในค่าที่ต่างกัน: _Time.y คือค่าวินาทีดิบ ๆ ส่วน _Time.x, _Time.z, _Time.w คือค่าเดียวกันนั้นหารหรือคูณไว้ให้แล้วเพื่อความสะดวก (ทำให้เราแทบไม่ต้องคูณเองเลย) เพราะค่านี้เปลี่ยนทุกเฟรมโดยไม่ต้องเขียนโค้ด C# เพิ่มแม้แต่บรรทัดเดียว shader จึงทำ animation ได้ทั้งหมดบน GPU
fixed4 _ColorA;
fixed4 _ColorB;
float _PulseSpeed;
fixed4 frag (v2f i) : SV_Target
{
float t = sin(_Time.y * _PulseSpeed) * 0.5 + 0.5; // remap -1..1 to 0..1
return lerp(_ColorA, _ColorB, t);
}
สิ่งที่เห็นบนจอ: เพิ่มโค้ดนี้เข้าไปในฟังก์ชัน fragment ของ shader (พร้อม entry ใน Properties ที่ตรงกันสำหรับ _ColorA, _ColorB, _PulseSpeed) กด Play แล้ว object จะไล่สีไปมาระหว่างสองสีนั้นอย่างนุ่มนวลไม่มีหยุด โดยไม่มี method Update() และไม่มี C# ขับเคลื่อนมันทีละเฟรมเลย sin(_Time.y * _PulseSpeed) แกว่งอยู่ระหว่าง -1 กับ 1; การคูณด้วย 0.5 แล้วบวก 0.5 เป็นการ remap ค่านั้นให้อยู่ในช่วง 0..1 ที่ lerp (linear interpolation คือการผสมค่าสองค่าอย่างนุ่มนวล) ต้องการสำหรับ argument ตัวที่สาม
mat.SetFloat("_PulseSpeed", 2f) เปลี่ยนความเร็วของการ pulse ตัว oscillation เองยังคงไม่กิน CPU time ต่อเฟรมเลย — CPU แค่ต้องตั้งค่า _PulseSpeed ครั้งเดียว แล้ว GPU ก็ทำ animation ต่อไปเองโดยใช้ _Time ที่อัปเดตตัวเองตลอดเวลาพื้นผิวที่หันหน้าเข้าหาแสงโดยตรงจะดูสว่าง ส่วนพื้นผิวที่หันหน้าออกจะดูมืด วิธีคำนวณเรื่องนี้ที่ง่ายที่สุด เรียกว่าแสง diffuse หรือ Lambertian เปรียบเทียบสองทิศทาง: normal vector ของพื้นผิว (N — vector ที่ชี้ออกมาตั้งฉากจากพื้นผิวโดยตรง) กับทิศทางไปยังแสง (L) เครื่องมือสำหรับเปรียบเทียบสองทิศทางคือ dot product เขียนเป็น dot(N, L) ใน HLSL: เมื่อทั้งสอง vector ถูก normalize แล้ว (ปรับความยาวให้เท่ากับ 1) dot product จะเท่ากับ cosine ของมุมระหว่างมันทั้งสอง — เท่ากับ 1 เมื่อชี้ไปทางเดียวกัน (สว่าง), เท่ากับ 0 เมื่อตั้งฉากกัน, และติดลบเมื่อหันหน้าออกจากกัน ซึ่งจะถูก clamp ให้เป็น 0 ด้วย saturate() เพื่อไม่ให้พื้นผิวได้รับแสงติดลบ
Shader "Lesson/SimpleDiffuse"
{
Properties
{
_Color ("Tint", Color) = (1,1,1,1)
}
SubShader
{
Tags { "RenderType" = "Opaque" }
Pass
{
Tags { "LightMode" = "ForwardBase" }
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
float3 normal : NORMAL;
};
struct v2f
{
float3 worldNormal : TEXCOORD0;
float4 pos : SV_POSITION;
};
fixed4 _Color;
v2f vert (appdata v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
return o;
}
fixed4 frag (v2f i) : SV_Target
{
float3 N = normalize(i.worldNormal);
float3 L = normalize(_WorldSpaceLightPos0.xyz);
float NdotL = saturate(dot(N, L));
fixed3 litColor = _Color.rgb * NdotL;
return fixed4(litColor, 1);
}
ENDCG
}
}
}
สิ่งที่เห็นบนจอ: ตั้ง shader นี้บน sphere ที่ถูกส่องด้วย directional light default ของ Unity ด้านที่หันหน้าเข้าหาแสงจะเป็นสีเต็ม ๆ ส่วนด้านที่หันหน้าออกจะค่อย ๆ มืดลงจนเกือบดำ เทียบกับลุคสีเรียบสีเดียวจาก Section 4 แล้ว sphere ตอนนี้ดูเป็นทรงกลม 3D จริง ๆ เพราะความสว่างของมันเปลี่ยนไปทั่วพื้นผิวของตัวมันเอง
UnityObjectToWorldNormal แปลง normal ของ mesh จาก object space ไปเป็น world space ซึ่งสำคัญเพราะ _WorldSpaceLightPos0 (uniform สำเร็จรูปของ Unity ที่ให้ทิศทางไปยังแสง directional light หลักของ scene) ก็อยู่ใน world space เหมือนกัน — ถ้าเปรียบเทียบ vector สองตัวที่อยู่คนละระบบพิกัดด้วย dot() จะได้ผลลัพธ์ที่ไม่มีความหมาย normalize() ปรับสเกล vector ให้มีความยาวเท่ากับ 1 โดยไม่เปลี่ยนทิศทาง จำเป็นต้องทำก่อน dot() เพราะ "ทางลัด cosine ของมุม" ใช้ได้เฉพาะกับ vector ที่ normalize แล้วเท่านั้น
normalize() บน N หรือ L ก่อน dot product การ interpolation (Section 5) ไม่รักษาความยาวของ vector ไว้ — normal ที่มีความยาว 1 ที่ทุก vertex อาจออกมาสั้นกว่า 1 เล็กน้อยหลังจากถูกผสมข้ามสามเหลี่ยม การข้าม normalize() จะทำให้แสงมืดเกินไปแบบไม่สม่ำเสมอในบางจุดอย่างแนบเนียน เป็นบั๊กที่มองข้ามได้ง่ายมากถ้าแค่ดูภาพ screenshot เฉย ๆ_WorldSpaceLightPos0 ให้ทิศทางที่ถูกต้องและง่ายเฉพาะกับแสงแบบ directional (เหมือนดวงอาทิตย์) ซึ่งไม่มีตำแหน่ง มีแต่ทิศทาง ส่วน point light ต้องใช้ตำแหน่งจริงในโลกของแสงนั้น เพื่อคำนวณ normalize(lightPos - worldPos) ต่อพิกเซลแทน — รายละเอียดนี้เก็บไว้สำหรับบทที่เน้นเรื่องแสงโดยเฉพาะ ไม่จำเป็นต้องรู้เพื่อเข้าใจ N dot L เองGPU เร็วในการรัน shader ตัวเดียวกันหลายพันครั้งเพราะวิธีที่มันจัดตารางงาน เรียกว่า SIMT (Single Instruction, Multiple Threads) GPU ไม่ได้ให้แต่ละพิกเซลมีตัวประมวลผลเล็ก ๆ ของตัวเองที่เป็นอิสระเต็มที่ แต่มันจัดกลุ่ม thread เป็น batch ขนาดคงที่ — ปกติ 32 บนฮาร์ดแวร์ NVIDIA เรียกว่า warp หรือ 64 บนฮาร์ดแวร์ AMD เรียกว่า wavefront — และทุก thread ใน batch นั้นจะรัน คำสั่งเดียวกันเป๊ะ ใน จังหวะเดียวกันเป๊ะ เพียงแต่ทำงานกับข้อมูลของตัวเอง (UV ของตัวเอง, ตำแหน่งของตัวเอง และอื่น ๆ) การดึงคำสั่งครั้งเดียวรองรับได้ทั้ง 32 (หรือ 64) thread พร้อมกัน นี่คือสิ่งที่ทำให้ GPU เร็วกว่า CPU มากสำหรับงานแบบนี้
ตรงนี้มีต้นทุนจริงเมื่อโค้ดมีการ branch ถ้า if/else ใน fragment shader ทำให้ thread ต่างกันใน warp เดียวกันต้องการไปคนละทาง GPU ไม่สามารถรันสองคำสั่งพร้อมกันจริง ๆ ได้ แทนที่จะทำแบบนั้น มันจะรัน ทั้งสอง branch เรียงกันไป สำหรับ ทั้ง warp แล้ว mask (ทิ้ง) ผลลัพธ์ของ thread ที่ไม่ต้องการ branch นั้นทิ้งไป สิ่งนี้เรียกว่า warp divergence
fixed4 frag (v2f i) : SV_Target
{
fixed4 col;
if (i.uv.x < 0.5)
{
col = fixed4(1, 0, 0, 1); // red half
}
else
{
col = fixed4(0, 0, 1, 1); // blue half
}
return col;
}
สิ่งที่เห็นบนจอ: ในแง่ภาพที่เห็น shader ตัวนี้ยังวาดเส้นแบ่งครึ่งแดง/น้ำเงินที่คมชัดตรงกลาง object ได้ถูกต้อง — output ถูกต้องสมบูรณ์ ต้นทุนที่เสียไปมองไม่เห็นใน screenshot เลย มันจะโผล่มาเป็น GPU time ที่เพิ่มขึ้นเวลาเรา profile เท่านั้น เพราะงานที่ถูก mask ทิ้งตามที่อธิบายไว้ข้างบน
uv.x < 0.5 ข้างบนตรงขอบของมันพอดีShader Graph คือ editor สร้าง shader แบบ node-based visual ของ Unity (มาพร้อมกับ URP หรือ HDRP) แทนที่จะพิมพ์ข้อความ HLSL เราสร้าง logic ของ vertex/fragment แบบเดียวกันได้ด้วยการลากกล่องที่เรียกว่า node มาวางบน canvas แล้วเชื่อม input/output ของมันด้วยเส้นลวด เบื้องหลังแล้ว Unity ยังคง generate โค้ด HLSL จริง ๆ จาก graph นั้นและคอมไพล์มันในแบบเดียวกันเป๊ะ ๆ กับที่อธิบายไว้ทั้งบทนี้ — Shader Graph เป็น front-end แบบ visual ที่อยู่บน pipeline ของ GPU เดียวกัน ไม่ใช่เทคโนโลยีคนละแบบ
ทุก Shader Graph จบที่ Master Stack ซึ่งแบ่งเป็น block Vertex (offset ของตำแหน่ง, การเปลี่ยน normal — ตรงกับฟังก์ชัน vert() ที่เราเขียนมาตลอด) และ block Fragment (Base Color, Alpha, Smoothness, Emission — ตรงกับฟังก์ชัน frag()) ข้อมูลไหลจากซ้ายไปขวา: node input (Time, property สี, Texture ที่ sample แล้ว, UV) ไหลผ่าน node คณิตศาสตร์เข้าไปยังช่องของ Master Stack
นี่คือ shader ที่ pulse สีจาก Section 8 อธิบายเป็น node flow ของ Shader Graph แทนข้อความ HLSL
อ่าน diagram นี้แบบเดียวกับโค้ดใน Section 8: node Time ป้อนเข้า node Sine (ตรงกับ sin(_Time.y * ...)) แล้ว node Multiply กับ Add remap ค่าจาก -1..1 ให้เป็น 0..1 (ตรงกับ * 0.5 + 0.5) และ node Lerp ผสมระหว่าง property สีสองตัวที่ expose ไว้ โดยใช้ค่า 0..1 นั้นเป็น input T ของมัน (ตรงกับ lerp(_ColorA, _ColorB, t)) ผลลัพธ์เสียบเข้ากับ Base Color บน Master Stack ตรงจุดเดียวกับที่ค่า return ของ frag() จะไปในโค้ด HLSL ที่เขียนเอง
สิ่งที่เห็นบนจอ: animation สีที่ pulse แบบเดียวกันเป๊ะ ๆ กับ Section 8 — graph กับ shader ที่เขียนเองคอมไพล์ออกมาเป็นคำสั่ง GPU ที่เทียบเท่ากันและให้พิกเซลบนจอแบบเดียวกัน Shader Graph ยังให้ live preview บน sphere อยู่ข้างในหน้าต่าง graph เลยขณะที่เราเชื่อม node อัปเดตทันทีโดยไม่ต้องกด Play ด้วย
.shader ทำ diff ใน git ได้สะอาด สองคนแก้คนละบรรทัดก็ merge ง่าย ส่วน Shader Graph เป็น asset ที่ serialize เป็นไฟล์ใหญ่ ถ้าสองคนแก้พร้อมกันมักจะเจอ merge conflict ที่ปวดหัวสตูดิโอที่ทำเกมจริงมักใช้ทั้งสองอย่างร่วมกัน: artist ทำ prototype และปรับลุคใน Shader Graph ส่วน shader ที่สำคัญด้าน performance หรือไม่ธรรมดาก็ให้ engineer เขียน HLSL เอง การเข้าใจแนวคิดดิบ ๆ ของ vertex/fragment/uniform/SIMT ในบทนี้มีประโยชน์ไม่ว่าจะใช้ทางไหน เพราะมันคือสิ่งที่ Shader Graph กำลัง generate อยู่ข้างใต้พอดี — และมันคือสิ่งที่ทำให้เราอ่าน profiler แล้วเข้าใจได้ว่าทำไม node graph บางอันถึงแพง
shader หนึ่งตัวรวมทุกอย่างจากบทนี้เข้าไว้ในไฟล์เดียวได้: การ sample texture, uniform สี tint, แสง N dot L, และการ pulse ตามเวลา
Shader "Lesson/CombinedExample"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
_Tint ("Tint", Color) = (1,1,1,1)
_PulseSpeed ("Pulse Speed", Float) = 2
}
SubShader
{
Tags { "RenderType" = "Opaque" }
Pass
{
Tags { "LightMode" = "ForwardBase" }
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
float3 normal : NORMAL;
float2 uv : TEXCOORD0;
};
struct v2f
{
float2 uv : TEXCOORD0;
float3 worldNormal : TEXCOORD1;
float4 pos : SV_POSITION;
};
sampler2D _MainTex;
float4 _MainTex_ST;
fixed4 _Tint;
float _PulseSpeed;
v2f vert (appdata v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
return o;
}
fixed4 frag (v2f i) : SV_Target
{
fixed4 texColor = tex2D(_MainTex, i.uv);
float3 N = normalize(i.worldNormal);
float3 L = normalize(_WorldSpaceLightPos0.xyz);
float NdotL = saturate(dot(N, L));
float pulse = sin(_Time.y * _PulseSpeed) * 0.5 + 0.5;
fixed4 finalColor = texColor * _Tint * NdotL;
finalColor.rgb += pulse * 0.15; // a gentle glow pulse on top
return finalColor;
}
ENDCG
}
}
}
สิ่งที่เห็นบนจอ: object ที่มี texture มีแสง — สว่างด้านที่หันเข้าหาแสง มืดด้านที่หันออก ย้อมสีด้วย _Tint — พร้อมแสง glow ที่ pulse เบา ๆ หายใจไปทั่วพื้นผิวทั้งหมดตามเวลา ทุกส่วนคือแค่หนึ่งใน section ก่อนหน้านี้ เพียงแต่มาอยู่ในฟังก์ชัน frag() เดียวกัน
รายการสั้น ๆ ของข้อผิดพลาดที่เกือบทุกคนเจอในไม่กี่ครั้งแรก
o.pos = UnityObjectToClipPos(...) ในฟังก์ชัน vertex — ไม่มีอะไรถูกวาดเลย หรือวาดผิดตำแหน่งไปไกลมาก เพราะ rasterizer ไม่เคยได้รับตำแหน่ง clip-space ที่ถูกต้องProperties ที่ชื่อไม่ตรงกับชื่อตัวแปร HLSL เป๊ะ ๆ (Section 4 กับ 7) — shader ยังคอมไพล์ผ่าน แต่ค่าจะเงียบ ๆ อยู่ที่ default ตลอดnormalize() ก่อน dot() ในการคำนวณแสง (Section 9) — แสงดูผิดแบบแนบเนียนแทนที่จะพังแบบเห็นชัดfloat4 ทุกที่จนติดเป็นนิสัย แม้แต่กับสีง่าย ๆ บนเป้าหมายที่เป็น mobile — สิ้นเปลือง GPU bandwidth; fixed4/half4 ปกติเพียงพอสำหรับสีอยู่แล้ว (tip ใน Section 4)lerp()/step()CGPROGRAM/ENDCG (Built-in Render Pipeline) กับ HLSLPROGRAM/ENDHLSL (URP/HDRP) — เอา shader ที่เขียนสำหรับ render pipeline หนึ่งไปวางในโปรเจกต์ที่ใช้อีกแบบหนึ่ง มักจะคอมไพล์ไม่ผ่านPOSITION หรือ SV_Target บน field ของ struct บอก GPU ว่า field นั้นมีความหมายว่าอะไรหรือต้องไปอยู่ตรงไหนtex2D().shader ที่ห่อหุ้มโค้ด HLSL จริงเอาไว้if/else บังคับให้ GPU ต้องรันทั้งสอง branch สำหรับทั้ง warp แล้ว mask ผลลัพธ์ที่ไม่ต้องการทิ้งไปvert() และ frag() ของ shader ที่เขียนเองLesson/Exercise1_Gradient มี property Color สองตัวคือ _ColorBottom กับ _ColorTop ใช้ UV coordinate ของ mesh ทำให้ fragment shader ผสมสีระหว่างสองค่านั้นตาม i.uv.y เพื่อให้ object ดูเหมือนมีการไล่สีแนวตั้งอย่างนุ่มนวลจากล่างขึ้นบน UV ค่าไหนที่ให้ _ColorBottom ล้วน ๆ ค่าไหนที่ให้ _ColorTop ล้วน ๆ
Shader "Lesson/Exercise1_Gradient"
{
Properties
{
_ColorBottom ("Bottom Color", Color) = (0,0,1,1)
_ColorTop ("Top Color", Color) = (1,1,0,1)
}
SubShader
{
Tags { "RenderType" = "Opaque" }
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 pos : SV_POSITION;
};
fixed4 _ColorBottom;
fixed4 _ColorTop;
v2f vert (appdata v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = v.uv;
return o;
}
fixed4 frag (v2f i) : SV_Target
{
return lerp(_ColorBottom, _ColorTop, i.uv.y);
}
ENDCG
}
}
}
i.uv.y == 0 ให้ _ColorBottom ล้วน ๆ และ i.uv.y == 1 ให้ _ColorTop ล้วน ๆ เพราะ lerp(a, b, t) คืนค่าเท่ากับ a เป๊ะ ๆ เมื่อ t เป็น 0 และเท่ากับ b เป๊ะ ๆ เมื่อ t เป็น 1 (diagram ของ Section 6 แสดงให้เห็นว่าทำไม v=0 ถึงเป็นด้านล่างของ texture ตาม convention ของ Unity) ค่า UV ทุกค่าระหว่างกลางจะผสมกันอย่างนุ่มนวล เป็นแนวคิด interpolation แบบเดียวกับ Section 5 เพียงแต่คำนวณด้วย lerp() แทนการผสมจาก vertex ไป fragment แบบอัตโนมัติของ GPU
Float ชื่อ _ScrollSpeed ใน fragment shader บวก _Time.y * _ScrollSpeed เข้าไปกับพิกัด u ของ UV ที่ sample ก่อนจะเรียก tex2D เพื่อให้ texture ดูเหมือนเลื่อนไหลไปด้านข้างตามเวลา เหมือนลาวาไหลหรือสายพานลำเลียง
Shader "Lesson/Exercise2_Scroll"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
_ScrollSpeed ("Scroll Speed", Float) = 1
}
SubShader
{
Tags { "RenderType" = "Opaque" }
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 pos : SV_POSITION;
};
sampler2D _MainTex;
float4 _MainTex_ST;
float _ScrollSpeed;
v2f vert (appdata v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
return o;
}
fixed4 frag (v2f i) : SV_Target
{
float2 scrolledUV = i.uv + float2(_Time.y * _ScrollSpeed, 0);
fixed4 col = tex2D(_MainTex, scrolledUV);
return col;
}
ENDCG
}
}
}
ทำ scroll math ใน fragment shader ง่ายที่สุดในกรณีนี้และได้ผลดี เพราะการ offset UV มีแค่ 2 ตัวเลข คำนวณต่อพิกเซลไม่แพง เราจะทำใน vert() แทนก็ได้ โดย offset o.uv ก่อนส่งต่อไปเลย — ซึ่งรันน้อยครั้งกว่า (ครั้งเดียวต่อ vertex แทนที่จะเป็นครั้งเดียวต่อพิกเซล ตาม Section 2) จึงประหยัดกว่าเล็กน้อย และสำหรับการ scroll แบบสม่ำเสมอแบบนี้ผลลัพธ์ที่เห็นจะเหมือนกันทั้งสองวิธี แนวคิดสำคัญไม่ว่าจะทำวิธีไหนก็เหมือนกับ Section 8: _Time.y ทำให้ GPU ทำ animation ได้เองทีละเฟรม โดยไม่ต้องมีสคริปต์ C# ขับเคลื่อน
if/else: if (i.uv.x < 0.5) col = red; else col = blue; เขียนใหม่โดยไม่ใช้ if/else เลย ใช้ step() กับ lerp() แทน เพื่อให้ได้เส้นแบ่งแดง/น้ำเงินแบบเดียวกันเป๊ะ ๆ โดยไม่มี branch ที่ทำให้เกิด warp divergence (step(edge, x) คืนค่า 0 เมื่อ x < edge และคืนค่า 1 เมื่อ x >= edge)
fixed4 frag (v2f i) : SV_Target
{
float mask = step(0.5, i.uv.x); // 0 when uv.x < 0.5, 1 when uv.x >= 0.5
fixed4 col = lerp(fixed4(1,0,0,1), fixed4(0,0,1,1), mask);
return col;
}
ตอนนี้ทุก thread ใน warp รันลำดับคำสั่งคณิตศาสตร์ธรรมดาชุดเดียวกันเป๊ะ ๆ — คำนวณ step, คำนวณ lerp — ไม่ว่า uv.x ของตัวมันเองจะเป็นเท่าไหร่ มีแค่ตัวเลขที่ป้อนเข้าไปในคณิตศาสตร์นั้นเท่านั้นที่ต่างกันไปตาม thread ไม่มีจุดไหนเลยที่ thread ต่างกันใน warp เดียวกันต้องรันคำสั่งคนละแบบ จึงไม่มีต้นทุน warp-divergence จาก Section 10 เลย แม้ว่าผลลัพธ์ที่เห็นบนจอ — เส้นแบ่งแดง/น้ำเงินที่คมชัด — จะเหมือนกันทุกพิกเซลกับเวอร์ชันที่มี branch ก็ตาม