8.1 Shader สำหรับศิลปิน

เฟส 8 · Technical Art · เวลาเรียน: 30–50 h

ทำ material และเอฟเฟกต์ shader ด้วย node graph และ HLSL เบา ๆ เน้นผลลัพธ์ภาพมากกว่า API กราฟิกระดับล่าง

1. งานจริงของ technical artist

Technical artist (เรียกย่อว่า "TA") คือคนที่อยู่ตรงกลางระหว่างทีมอาร์ตกับทีมโปรแกรมเมอร์ ศิลปินสร้างโมเดล ทำ texture และทำแอนิเมชัน ส่วนโปรแกรมเมอร์เขียนโค้ดเกมเพลย์และระบบของ engine หน้าที่ของ technical artist คือสร้าง "ลุค" ของเกม — น้ำ ไฟ เวทมนตร์ ศัตรูที่ค่อย ๆ สลายหายไป โฮโลแกรม ขอบที่เรืองแสง — โดยไม่ต้องเขียน rendering engine ทั้งตัวขึ้นมาเอง

เครื่องมือที่ทำให้ทำแบบนี้ได้คือ node graph: editor แบบเห็นภาพ ที่เราต่อกล่องเล็ก ๆ (เรียกว่า node) เข้าด้วยกันด้วยเส้นลวด แทนที่จะพิมพ์โค้ดทีละบรรทัด ใน Unity เครื่องมือในตัวเรียกว่า Shader Graph ส่วนตัวเลือกจากภายนอกที่นิยมใช้กันคือ Amplify Shader Editor ทั้งสองตัวทำงานแบบเดียวกัน คือเราต่อ node เข้าด้วยกัน แล้วเครื่องมือจะ generate โค้ด GPU จริง ๆ ให้เบื้องหลัง

โค้ดที่ generate ออกมานั้นเขียนด้วย HLSL (High-Level Shading Language) — ภาษาที่ GPU รันจริง ๆ เราไม่จำเป็นต้องเขียน HLSL เองด้วยมือเพื่อทำ effect ส่วนใหญ่ในบทนี้ แต่การรู้คร่าว ๆ ว่า node แต่ละตัว generate HLSL แบบไหน จะทำให้เราเป็น technical artist ที่แข็งแกร่งขึ้นมาก เพราะเราจะอ่าน shader ของคนอื่นออก, debug บั๊กภาพประหลาด ๆ ได้ และดรอปลงไปเขียน Custom Function node เองได้ตอนที่ graph อย่างเดียวทำไม่ได้ นี่คือเหตุผลที่ทุก recipe ในบทนี้จะโชว์ทั้งสองแบบ คือ node graph กับ HLSL ธรรมดา ๆ ที่มันเทียบเท่ากัน

ขั้นตอนการทำงานของ technical artist [ คุณ กำลังลาก node อยู่ใน editor ] | v +----------------------+ | node graph | (Shader Graph / Amplify) | o--o o--o | | \ \ / / | | o--MASTER--o | +----------------------+ | v (เครื่องมือ compile ให้เราเอง) +----------------------+ | HLSL ที่ generate ออกมา | (โค้ด GPU จริง ไวยากรณ์คล้าย C) +----------------------+ | v +----------------------+ | GPU render มันออกมา | --> พิกเซลบนหน้าจอ +----------------------+

ไม่มีอะไรเป็นเวทมนตร์ในนี้เลย node graph คือเครื่องมือวาดรูปสำหรับเขียนโค้ด node แต่ละตัวคือคำนวณเล็ก ๆ ชิ้นหนึ่ง หรือการอ่านค่าจาก texture และเส้นลวดแต่ละเส้นคือค่าที่ไหลจากการคำนวณหนึ่งไปยังอีกการคำนวณหนึ่ง พอเราอ่าน graph ออกว่าเป็น "การคำนวณที่ไหลจากซ้ายไปขวา" shader ก็จะไม่น่ากลัวอีกต่อไป

Tip ถ้าคุณรู้ C หรือ C# มาจากบทก่อน ๆ แล้ว HLSL จะรู้สึกคุ้นเคยมาก มันมี float, float2, float3, float4, function, if, loop และ operator ทางคณิตศาสตร์เหมือนกัน ความต่างหลักคือ shader หนึ่งตัวจะรันครั้งเดียวต่อ vertex หรือครั้งเดียวต่อ pixel เป็นพัน ๆ ครั้งพร้อมกัน แทนที่จะรันครั้งเดียวจาก main() ตัวเดียว

2. Node graph เบื้องต้น — กายวิภาคของ graph

ไม่ว่าจะเป็นเครื่องมือไหน node graph ทุกตัวประกอบขึ้นจากส่วนประกอบสามอย่างเดียวกัน

ทุก graph จะจบที่ node พิเศษตัวหนึ่งคือ Master node (ใน Shader Graph รุ่นใหม่จะแยกเป็น stack ของ Fragment กับ Vertex ส่วน Amplify จะใช้ output node เดียว) นี่คือจุดที่เราตอบคำถามที่ renderer ถามเกี่ยวกับแต่ละ pixel คือ "pixel นี้สีอะไร" "surface normal ชี้ไปทางไหน" "metallic กับ smoothness เท่าไหร่" "เรืองแสงไหม" "โปร่งใสไหม"

+-----------------+ | Sample Tex 2D | | UV --> o | | Tex --> o RGBA o---+ +-----------------+ | v +-------------------+ | MASTER NODE | | Base Color o | <-- เส้นลวดมาต่อตรงนี้ | Normal o | | Metallic o | | Smoothness o | | Emission o | | Alpha o | +-------------------+

graph เล็ก ๆ ด้านบนนี้คือ shader ที่สมบูรณ์และใช้งานได้จริง มันบอกว่า "อ่านสี pixel จาก texture นี้ ด้วย UV เหล่านี้ แล้วใช้เป็น Base Color" ไม่มีอะไรเสียบเข้าไปที่อื่นเลย ดังนั้น engine จะใช้ค่า default ที่สมเหตุสมผลสำหรับ Metallic, Smoothness และค่าอื่น ๆ แนวคิดนี้เหมือนกับ method ใน C# ที่ใส่ค่าเฉพาะ parameter ที่เราสนใจ แล้วปล่อยที่เหลือไว้เป็นค่า default

นี่คือ HLSL ที่ graph แบบนี้ compile ออกมาจริง ๆ (แบบตัดให้ง่ายลง)

// Simplified fragment shader — roughly what the graph above generates
Texture2D _MainTex;
SamplerState sampler_MainTex;

float4 Frag(float2 uv : TEXCOORD0) : SV_Target
{
    float4 baseColor = _MainTex.Sample(sampler_MainTex, uv);
    return baseColor; // becomes the Base Color the renderer lights and shades
}

ทุก node ที่เราลากเข้า Shader Graph จะกลายเป็นโค้ดแบบนี้หนึ่งหรือสองบรรทัด การเรียนรู้ shader ผ่าน node graph กับผ่าน HLSL จริง ๆ แล้วเป็นทักษะเดียวกัน แค่ graph วาด data flow ให้เราดูเท่านั้นเอง

Tip ใน Shader Graph เราดูโค้ดที่ generate ออกมาได้จาก context menu ของ graph ("View Generated Shader") การอ่านมันหลังจากสร้าง graph เสร็จ เป็นวิธีเรียนรู้ที่เร็วที่สุดวิธีหนึ่งว่า node แต่ละตัวทำอะไรจริง ๆ

3. Texture กับ UV space — การ sample texture

Texture คือรูปภาพที่เก็บอยู่ใน memory — grid ของ pixel แต่ละตัวมีสี (และมักมี channel เสริมอย่างความโปร่งใสด้วย) เพื่อเอารูปนี้ไปแปะบน mesh 3D แต่ละ vertex ของ mesh จะเก็บเลขคู่หนึ่งเพิ่มเรียกว่า UV coordinate (ใช้ตัวอักษร U กับ V แทน X กับ Y เพื่อไม่ให้สับสนกับตำแหน่งใน 3D) ค่า UV ปกติจะอยู่ในช่วง 0 ถึง 1 ไม่ว่า texture จริงจะมีขนาดกี่ pixel ก็ตาม

V 1 +-------------------+ | | | รูป texture | | | | | 0 +-------------------+ 0 1 U (0,0) = มุมล่างซ้ายของ texture (1,1) = มุมบนขวาของ texture (0.5,0.5) = จุดกึ่งกลางพอดี

node (หรือ function ใน HLSL) ที่อ่านสีออกจาก texture ที่ UV หนึ่ง ๆ เรียกว่า sampling ใน Shader Graph คือ node Sample Texture 2D เสียบ Texture2D กับ UV (Vector2) เข้าไป มันจะให้สี RGBA ออกมา พร้อม output แยก R, G, B, A สำหรับตอนที่เราต้องการแค่ channel เดียว

// HLSL equivalent of "Sample Texture 2D"
Texture2D _Tex;
SamplerState sampler_Tex; // the sampler decides filtering (blurry vs blocky) and wrap mode

float4 SampleMyTexture(float2 uv)
{
    return _Tex.Sample(sampler_Tex, uv);
}

การตั้งค่า sampler สองอย่างที่สำคัญมากสำหรับงานอาร์ตคือ Filter (Point = แตกเป็น pixel เห็นชัด, Bilinear/Trilinear = เนียน) และ Wrap Mode (Repeat = texture วนซ้ำไปเรื่อย ๆ เมื่อเกิน 0 กับ 1, Clamp = มันจะยืด pixel ขอบไปเรื่อย ๆ ใช้บ่อยกับ UI หรือ decal)

Common mistake ลืมไปว่า UV เป็นของแต่ละ mesh ไม่ใช่ของทั้ง world สอง object ที่ sample UV (0.5, 0.5) เหมือนกัน ต่างก็อ่านจุดกึ่งกลางของ texture space ของตัวเอง — การย้าย object ในโลกไม่ได้เปลี่ยน UV ของมัน ถ้าอยากได้ texture ที่ตอบสนองต่อตำแหน่งใน world แทน (เช่นแอ่งน้ำที่อยู่กับที่ตอนตัวละครเดินผ่าน) ต้องใช้เทคนิคอื่นคือ sample จากตำแหน่ง world แทน UV ซึ่งอยู่นอกเหนือบทนี้

4. การจัดการ UV — tiling, offset, panning

พอ sample texture เป็นแล้ว ทักษะถัดไปคือการดัด UV เองก่อนที่จะ sample ซึ่งมีสามท่าหลักที่ครอบคลุมสิ่งที่ artist ต้องการเกือบทั้งหมด

UV --> [multiply by Tiling] --> [add Offset] --> [Sample Texture] --> color Panning เพิ่มอีกขั้นก่อน sample: Time --> [multiply by Speed] --> [add to UV] --> [Sample Texture] --> color
// HLSL: tiling + offset + panning combined
float2 PanUV(float2 uv, float2 tiling, float2 offset, float2 speed, float time)
{
    float2 tiled  = uv * tiling + offset;   // Shader Graph's "Tiling And Offset" node
    float2 panned = tiled + speed * time;   // scroll over time
    return panned;
}

ลองนึกภาพ texture น้ำที่ sample ด้วย UV ระหว่าง (0,0) ถึง (1,1) ด้วย speed = (0.1, 0) ผ่านไป 10 วินาที จุด sample จะเลื่อนไป 1.0 ในแกน U — เท่ากับความกว้างเต็มของ texture หนึ่งผืน — ดังนั้นลายจึงเลื่อนไปครบหนึ่งรอบแล้ววนกลับมาแบบเนียน ๆ ซึ่งจะดูเนียนได้ก็ต่อเมื่อ Wrap Mode ตั้งเป็น Repeat และ texture ต้นฉบับถูกวาดมาให้ขอบต่อกันสนิท (texture แบบ "tileable")

time = 0s time = 3s time = 6s +----------+ +----------+ +----------+ | ~ ~ ~ | | ~ ~ ~ | |~ ~ ~ | | ลายคลื่น | เลื่อน --> | ลายคลื่น | เลื่อน --> | ลายคลื่น | | ~ ~ ~ | | ~ ~ ~ | |~ ~ ~ | +----------+ +----------+ +----------+ ลายจะเลื่อนไปทางเดียวกันทุกเฟรม แล้ววนกลับมาแบบไร้รอยต่อ
Tip การ pan texture sample สองชั้น (เช่นเมฆสองชั้น) ด้วยความเร็วและทิศทางที่ต่างกันนิดหน่อย แล้ว blend เข้าด้วยกัน จะให้ความรู้สึกเคลื่อนไหวที่เป็นธรรมชาติกว่าการ pan ชั้นเดียวเร็ว ๆ มาก เทคนิคนี้จะกลับมาใน recipe น้ำ/ลาวาข้างล่าง

5. การ blend สองสิ่งเข้าด้วยกัน — Lerp

Lerp ย่อมาจาก linear interpolation มันรับค่าสองค่าคือ A กับ B และตัว blend factor T ที่วิ่งจาก 0 ถึง 1 แล้วให้ค่าที่ไล่เรียบจาก A (ที่ T=0) ไปเป็น B (ที่ T=1)

// Lerp works on floats, colors, and vectors — same formula every time
float Lerp(float a, float b, float t)
{
    return a + (b - a) * t;
}
// t = 0    returns a
// t = 0.5  returns exactly halfway between a and b
// t = 1    returns b
A = แดง B = น้ำเงิน t=0.0 [RRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRR] A ล้วน t=0.25 [RRRRRRRRRRRRRRRRRRRRRRRRRRRRRRBBBBBBBB] เอียงไปทาง A t=0.5 [RRRRRRRRRRRRRRRRRRRRBBBBBBBBBBBBBBBBBB] ครึ่งต่อครึ่ง t=0.75 [RRRRRRRRBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB] เอียงไปทาง B t=1.0 [BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB] B ล้วน

ใน Shader Graph node Lerp รับ A, B, T แล้วให้ค่าที่ blend ออกมา T แทบไม่เคยมาจากอากาศธาตุ — ส่วนใหญ่มันมาจาก texture อีกตัว (mask) มาจาก node Fresnel มาจาก noise หรือมาจาก slider ที่ artist เปิดเป็น material property ทุก recipe ในบทนี้จากนี้ไปใช้ Lerp อย่างน้อยหนึ่งครั้ง เพราะ "blend สองสิ่งด้วย mask" คือปฏิบัติการที่พบบ่อยที่สุดในงาน shader art

6. Mask กับขอบ — Step กับ Smoothstep

Lerp จะ blend เรียบตลอดช่วง 0..1 แต่บางทีเราอยากได้การตัดขาดแบบเฉียบคม — เส้นคมชัดที่ฝั่งหนึ่งเป็นสิ่งหนึ่งเต็ม ๆ อีกฝั่งเป็นอีกสิ่งเต็ม ๆ นั่นคือสิ่งที่ Step ทำ

// Step: a hard on/off switch
float Step(float edge, float x)
{
    return (x < edge) ? 0.0 : 1.0;
}

Step(0.5, x) จะให้ 0 สำหรับทุก x ที่ต่ำกว่า 0.5 และให้ 1 สำหรับทุก x ที่เท่ากับหรือมากกว่า 0.5 เอาผลลัพธ์นี้ไปเสียบเป็น T ของ Lerp หรือใช้เป็น mask ตรง ๆ เราจะได้ขอบคมกริบแทนที่จะเป็น gradient

Smoothstep คือญาติที่นุ่มนวลกว่าของ Step แทนที่จะรับ edge เดียว มันรับสองค่าคือ edge0 กับ edge1 แล้วไล่เรียบระหว่างสองค่านี้แทนการกระโดดทันที

// Smoothstep: an eased ramp between edge0 and edge1
float Smoothstep(float edge0, float edge1, float x)
{
    float t = saturate((x - edge0) / (edge1 - edge0)); // saturate = clamp to 0..1
    return t * t * (3.0 - 2.0 * t); // the "ease" curve — flat at both ends
}
output 1 | ______________ | / | | smoothstep(0.4, 0.6, x) -- ไล่เรียบ | / 0 |______________/ +--------------------------------------- x 0 0.4 0.5 0.6 1 ส่วน step(0.5, x) จะกระโดดทันทีที่ x = 0.5: 1 | _________________ | | 0 |_________________| +--------------------------------------- x 0 0.5 1

ที่ที่สองอันนี้โผล่มาใช้บ่อย ๆ คือขอบ dissolve ที่ต้องการ band บาง ๆ ที่เรืองแสงตรงเส้นตัดพอดี — เราได้ band แบบนั้นมาโดยผสมสอง smoothstep เข้าด้วยกัน หรือใช้ Smoothstep ที่ edge0/edge1 ห่างกันนิดเดียว แถบแสง toon shading (cel-shaded) ก็ใช้ Step หรือ Smoothstep กับค่าแสงแทน gradient เรียบ เพื่อให้ได้แถบสีแบน ๆ ส่วนเส้นฟองคลื่นชายหาดของ shader น้ำ ก็ใช้ Step กับ "ระยะจากชายฝั่ง" เพื่อกำหนดว่าฟองเริ่มตรงไหน

Tip ใน Shader Graph node ทั้งสองตัวชื่อ Step กับ Smoothstep ตรง ๆ เลย ถ้า graph ไหนดู "แบนเหมือนพลาสติก" เกินไป บ่อยครั้งเป็นเพราะใช้ Step ตรงจุดที่ Smoothstep (หรือ Smoothstep ช่วงแคบ ๆ) จะดูนุ่มนวลและไม่มีรอยหยักมากกว่า

7. Remap — ปรับช่วงหนึ่งให้เข้ากับอีกช่วงหนึ่ง

input ของ shader หลายตัวไม่ได้มาในช่วงที่เราต้องการโดยธรรมชาติ texture noise จะ sample ค่าออกมาในช่วง 0 ถึง 1 แต่บางทีเราอยากได้ -1 ถึง 1 เพื่อดัน normal map ได้ทั้งซ้ายและขวา หรือ mask หนึ่งอาจไปถึงแค่ 0.2 ถึง 0.8 ในทางปฏิบัติ แล้วเราอยากยืดมันให้ใช้เต็มช่วง 0..1 เพื่อ contrast ที่มากขึ้น Remap ทำสิ่งนี้ให้พอดี คือรับค่าที่อยู่ในช่วง input แล้วปรับสเกลใหม่ให้อยู่ในช่วง output โดยรักษาตำแหน่งสัมพัทธ์เดิมไว้

// Remap: rescale x from [inMin, inMax] into [outMin, outMax]
float Remap(float x, float inMin, float inMax, float outMin, float outMax)
{
    float t = (x - inMin) / (inMax - inMin); // where x sits inside the input range, as 0..1
    return outMin + t * (outMax - outMin);   // place that same relative position in the output range
}

// Example: Remap(0.75, 0.0, 1.0, -1.0, 1.0)
//   t = (0.75 - 0) / (1 - 0) = 0.75
//   result = -1 + 0.75 * (1 - (-1)) = -1 + 1.5 = 0.5

ใน Shader Graph นี่คือ node Remap มี input คือ In, In Min Max, และ Out Min Max เราจะหยิบมันมาใช้บ่อยมาก ไม่ว่าจะทำให้ mask ที่มืดสว่างขึ้น แปลง noise 0..1 เป็น displacement -1..1 หรือบีบผลลัพธ์ Fresnel ให้แคบลงเพื่อให้ rim glow ดูเข้มขึ้น

8. Time กับ noise — แอนิเมชันและความหลากหลายแบบธรรมชาติ

อีกสองส่วนประกอบที่จะทำให้ toolkit สมบูรณ์ node Time ให้ค่าจำนวนวินาทีที่ผ่านมาตั้งแต่ scene เริ่มทำงาน (Shader Graph ยังมีเวอร์ชันที่สเกลไว้แล้วสำหรับ effect ที่ช้าหรือเร็ว) เอา Time คูณกับความเร็ว แล้วเสียบไปตรงไหนก็ได้ที่ต้องการตัวเลข ไม่ว่าจะเป็น UV offset มุมหมุน หรือความสว่างของสี ค่านั้นก็จะเปลี่ยนทุกเฟรม ซึ่งเป็นสิ่งที่ทำให้ effect ดูมีชีวิตแทนที่จะเป็นภาพนิ่ง

node Noise (Shader Graph มี node Simplex Noise มาให้ ส่วน Perlin Noise คือของดั้งเดิมที่เทียบเท่ากัน) จะสร้างลายสีเทาแบบก้อนเนียน ๆ เป็นธรรมชาติจาก UV input — ไม่ต้องมี texture asset เลย ต่างจาก random แบบสุ่มล้วน ๆ ตรงที่ noise เป็นแบบ ต่อเนื่อง คือตำแหน่ง UV ที่อยู่ใกล้กันจะให้ค่าที่ใกล้เคียงกัน จึงดูเหมือนความหลากหลายตามธรรมชาติ (เมฆ ลาย dissolve รูขุมขนผิว) แทนที่จะเป็นสัญญาณรบกวนแบบทีวีเก่า

noise output (0 = ดำ, 1 = ขาว), sample บน UV grid: 0.1 0.3 0.6 0.8 0.9 0.2 0.4 0.7 0.9 0.7 0.4 0.6 0.8 0.6 0.4 <- ก้อนเนียน ๆ ค่าข้างเคียงใกล้กัน 0.6 0.8 0.6 0.3 0.2 0.8 0.7 0.4 0.2 0.1 เทียบกับ noise แบบสุ่มล้วน ที่ค่าข้างเคียงไม่มีความสัมพันธ์กันเลย: 0.9 0.1 0.7 0.2 0.9 0.2 0.8 0.1 0.9 0.3 <- ดูเหมือนสัญญาณรบกวนทีวี ซึ่งแทบไม่มี artist คนไหนอยากได้

Noise คือส่วนผสมลับเบื้องหลัง effect แบบ "เวทมนตร์" หรือ "ธรรมชาติ" เกือบทุกตัว ไม่ว่าจะเป็นลาย dissolve การกระพริบของไฟ ความผิดพลาดของโฮโลแกรม การบิดเบือนผิวน้ำ รูปทรงเมฆ เมื่อผสมกับ Time (การ scroll UV ที่ป้อนเข้า node noise) เราจะได้ noise ที่ เคลื่อนไหว ได้ ไม่ว่าจะเป็นไฟที่ปั่นป่วน หมอกที่ลอยไป หรือ forcefield ที่เต้นเป็นจังหวะ

Common mistake ใช้ noise ความถี่สูง (สเกลเล็ก) กับสิ่งที่อยากให้ดูเป็นรูปทรงใหญ่ ช้า ๆ อย่างเมฆ จะทำให้มันดูเหมือนสัญญาณรบกวนทีวีแทน ลด tiling ของ noise เอง (ก้อนใหญ่ขึ้น) สำหรับรูปทรงใหญ่ช้า ๆ และเพิ่มขึ้นสำหรับลายละเอียดเล็ก ๆ อย่างผิวหนังหรือสนิม

9. Recipe: วัสดุน้ำ / ลาวาที่เลื่อนไหล

ไอเดีย

น้ำกับลาวาใช้กลเดียวกัน คือเอา texture ที่ tile ได้ (คลื่นหรือหินแตกแบบแมกม่า) กับ normal map ที่เข้าคู่กัน (texture ที่เก็บรอยนูนปลอมของพื้นผิว เพื่อให้แสงตอบสนองเหมือนพื้นผิวเป็นคลื่น ทั้งที่ mesh แบนสนิท) แล้ว pan ทั้งคู่ไปบนพื้นผิวตามเวลา การ pan normal-map sample สองตัวด้วยความเร็วและทิศทางต่างกัน แล้ว blend เข้าด้วยกัน จะทำลายลายที่ซ้ำชัดเจนออกไป และให้ความรู้สึกเหมือนของเหลวที่เปลี่ยนแปลงตลอดเวลา

Node flow

Time --> [x speedA] --> [+UV] --> [Sample Normal A] --+ Time --> [x speedB] --> [+UV] --> [Sample Normal B] --+--> [Blend Normals] --> MASTER.Normal Time --> [x speedA] --> [+UV] --> [Sample Albedo] ----------------------------> MASTER.Base Color

HLSL แบบเบา ๆ

float2 uvA = uv * tiling + _Time.y * speedA; // _Time.y is seconds in Unity's built-in shaders
float2 uvB = uv * tiling + _Time.y * speedB * float2(0.7, -1.0); // different direction and speed

float3 normalA = UnpackNormal(_NormalTex.Sample(sampler_NormalTex, uvA));
float3 normalB = UnpackNormal(_NormalTex.Sample(sampler_NormalTex, uvB));
float3 blendedNormal = normalize(normalA + normalB); // simple, cheap normal blend

float3 albedo = _AlbedoTex.Sample(sampler_AlbedoTex, uvA).rgb;

หน้าตาที่ได้

plane แบนราบดูเหมือนผิวน้ำที่กลิ้งไหวเบา ๆ แสงที่สะท้อนจะเลื่อนและว่ายไปมาบนผิวแทนที่จะนิ่งอยู่กับที่ ทั้งที่ไม่มี vertex ไหนขยับเลย สำหรับลาวา ให้เปลี่ยนคู่ normal/albedo สีฟ้าของน้ำ เป็นคู่หินแตกสีส้ม แล้วเพิ่ม Emission แรง ๆ จาก texture "รอยแตกเรืองแสง" ที่ pan ช้ากว่าอีกชั้นหนึ่ง

คำอธิบาย

mesh อยู่นิ่งสนิทตลอด — มีแค่ UV ที่ป้อนเข้า texture sample เท่านั้นที่เปลี่ยนทุกเฟรม นั่นคือกลทั้งหมด คือสร้างความเคลื่อนไหวจาก "จะอ่าน pixel ไหน" ล้วน ๆ โดยแทบไม่เสีย performance เพิ่มเลย เพราะ GPU ก็ sample texture อยู่แล้วเป็นทุนเดิม

10. Recipe: effect dissolve

ไอเดีย

"Dissolve" ทำให้ object ค่อย ๆ สลายไปตามขอบที่ดูเป็นธรรมชาติ ไม่ใช่แค่ fade หรือหายวับไปทันที ลองนึกถึงการสลายตัวสไตล์ Marvel หรือศัตรูที่ไหม้หายไป กลคือ sample texture noise ทั่วพื้นผิว เทียบกับ slider Dissolve Amount (0 = เห็นเต็มตัว, 1 = หายหมด) โดยใช้ Step แล้วตัด (clip) pixel ไหนก็ตามที่ค่า noise ต่ำกว่าค่านั้นทิ้งไป ส่วน band บาง ๆ จาก Smoothstep ตรงเส้นตัดพอดี จะถูกทาสีสว่างแล้วดันเข้า Emission เพื่อจำลองขอบที่กำลังไหม้

Node flow

UV --> [Simplex Noise] --> noiseValue noiseValue, DissolveAmount --> [Step] --> clipValue --> MASTER.Alpha Clip Threshold noiseValue, DissolveAmount --> [Smoothstep band] --> edgeMask --> [x EdgeColor] --> MASTER.Emission

HLSL แบบเบา ๆ

float noiseValue = SimplexNoise(uv * noiseTiling);

// cut the pixel away entirely once noise falls below the dissolve amount
clip(noiseValue - dissolveAmount); // clip(x): if x < 0, discard this pixel, draw nothing

// thin glowing band exactly at the cutoff line
float edgeWidth = 0.05;
float band = smoothstep(dissolveAmount, dissolveAmount + edgeWidth, noiseValue)
           - smoothstep(dissolveAmount + edgeWidth, dissolveAmount + edgeWidth * 2.0, noiseValue);
float3 emission = edgeColor * band * edgeBrightness;

หน้าตาที่ได้

dissolveAmount = 0.0 dissolveAmount = 0.45 dissolveAmount = 0.9 +------------------+ +------------------+ +------------------+ |##################| |####~~~~~~~~~~~~~~| |~~~~~~~~~~~~~~####| |##################| |###~~~~~~~~~~~~~~~| |~~~~~~~~~~~~~~~###| |##################| |####~~~~~~~~~~~~~~| |~~~~~~~~~~~~~~####| |##################| |#####~~~~~~~~~~~~~| |~~~~~~~~~~~~~#####| +------------------+ +------------------+ +------------------+ เห็นเต็มตัว # = ยังทึบอยู่, ~ = หายไปแล้ว, เกือบหายหมด ขอบเรืองแสงอยู่ตรงเส้น เหลือทึบนิดเดียว noise ที่ขรุขระพอดี

คำอธิบาย

clip() คือคำสั่งใน HLSL ที่ทิ้ง pixel นั้นไปเลย (ไม่ถูกเขียนลงหน้าจอหรือ depth buffer เลย) ถ้าค่าที่ส่งเข้าไปเป็นลบ การเอา noise ไปเทียบกับ slider ที่เราขับเคลื่อนจากโค้ด (เช่น animate dissolveAmount จาก 0 ไป 1 ใน 2 วินาทีตอนศัตรูตาย) ทำให้เส้นตัดค่อย ๆ คืบไปทั่วลาย noise และเพราะ noise ดูเป็นธรรมชาติ ขอบที่ได้จึงดูเหมือนไหม้หรือค่อย ๆ ร่วนสลาย ไม่ใช่เส้นตัดตรง ๆ

Tip Alpha Clip Threshold บน Master node ของ Shader Graph จะเรียก clip() ให้เราเอง เอา noiseValue - dissolveAmount ไปเสียบตรงนั้น (หรือใช้ node Clip โดยตรง) แล้วเปิด Alpha Clipping ใน graph settings

11. Recipe: ขอบเรืองแสงแบบ fresnel rim

ไอเดีย

ลองมองขวดแก้วหรือฟองสบู่ ขอบของมัน ตรงจุดที่เรามองเกือบเฉียดไปตามผิวแทนที่จะมองตรง ๆ จะดูสว่างและสะท้อนแสงมากกว่าตรงกลาง ปรากฏการณ์จริงนี้เรียกว่า Fresnel effect (ออกเสียงว่า "เฟรอ-เนล") และมันคือรากฐานของ effect ขอบเรืองแสงหรือ energy shield แทบทุกตัวในเกม มันเทียบทิศทางที่กล้องมองไปยังจุดหนึ่ง กับ normal ของพื้นผิวตรงจุดนั้น (ทิศที่พื้นผิวหันหน้าไป) มองตรง ๆ = 0, มองเฉียดขอบ = 1

Node flow

Normal (world space) --> [Fresnel Effect] --> fresnelValue ViewDirection --------> [Fresnel Effect] fresnelValue --> [Power: rimPower] --> [x RimColor] --> MASTER.Emission

HLSL แบบเบา ๆ

float3 viewDir = normalize(_WorldSpaceCameraPos - worldPos);
float fresnel = 1.0 - saturate(dot(normalize(worldNormal), viewDir));
fresnel = pow(fresnel, rimPower); // higher power = thinner, tighter rim

float3 rimEmission = rimColor.rgb * fresnel * rimIntensity;

หน้าตาที่ได้

มองทรงกลมตรง ๆ: .::::::. .:: ::. : (กลางมืดกว่า) : : : ':. .:' เรืองแสง ->'::::::::' <- เรืองแสง (ขอบสว่างตรงที่พื้นผิวโค้งหนีจากเรา)

คำอธิบาย

dot(normal, viewDir) (dot product คือวิธีเทียบสองทิศทาง ได้ 1 เมื่อชี้ทางเดียวกัน ได้ 0 เมื่อตั้งฉากกัน) จะมีค่ามากที่สุดตอนเรามองพื้นผิวตรง ๆ และจะเล็กลงเรื่อย ๆ จนเข้าใกล้ 0 ที่ขอบ silhouette การพลิกด้วย 1 - x ทำให้ขอบสว่างแทนที่จะเป็นกลาง และ pow() ช่วยบีบโซนสว่างนั้นให้เป็นเส้นบาง (power สูง) หรือแสงกว้าง (power ต่ำ ใกล้ 1) เพราะมันแตะแค่ Emission เท่านั้น recipe นี้จึงถูกและซ้อนทับบน material อื่นได้เสมอ — เราแทบไม่เคยสร้าง shader ที่มีแค่ fresnel อย่างเดียว แต่จะเพิ่ม fresnel เข้าไปใน material ที่มีอยู่แล้วมากกว่า

12. Recipe: effect โฮโลแกรม

ไอเดีย

ลุคโฮโลแกรมผสมสามสิ่งที่เรารู้จักแล้วเข้าด้วยกัน คือขอบ Fresnel (ให้ขอบเรืองแสงมากกว่าหน้าตรง เหมือนภาพฉาย) scanline แนวนอน (เส้นลายซ้ำบาง ๆ ที่สร้างจาก UV บวก sine wave) และการ flicker (ความสว่างทั้งหมดสั่นไหวตามเวลา ด้วย sine wave หรือ noise) แล้วดันทั้งหมดเข้า Emission ทับบนสีพื้น มักตั้ง material เป็นโปร่งใสด้วย

Node flow

Normal, ViewDir --> [Fresnel] --> [Power] --> fresnelGlow UV.y --> [x scanlineFreq] --> [Sine] --> [Remap -1..1 to 0..1] --> [Smoothstep] --> scanlineMask Time --> [x flickerSpeed] --> [Sine] --> [Remap to 0.6..1.0] --> flickerAmount (fresnelGlow + scanlineMask) --> [x flickerAmount] --> [x HologramColor] --> MASTER.Emission

HLSL แบบเบา ๆ

float fresnel = pow(1.0 - saturate(dot(normalWS, viewDir)), rimPower);

float scanline = sin(uv.y * scanlineFrequency + _Time.y * scrollSpeed);
scanline = scanline * 0.5 + 0.5;           // remap -1..1 to 0..1
scanline = smoothstep(0.4, 0.6, scanline); // sharpen into thin bright lines

float flicker = sin(_Time.y * flickerSpeed) * 0.5 + 0.5;
flicker = lerp(0.6, 1.0, flicker);         // never fully off, just dimmer

float3 emission = hologramColor.rgb * (fresnel + scanline) * flicker;

หน้าตาที่ได้

---------------------- |~~~~~~~~~~~~~~~~~~~~| <- scanline สว่างบาง ๆ |====================| วิ่งผ่านตัวโมเดล |~~~~~~~~~~~~~~~~~~~~| |====================| :\ /: <- ขอบ (fresnel) เรืองแสงกว่า : \ ขอบสว่างกว่า / : พื้นผิวที่หันหน้าตรงเข้ากล้อง : \______________/ : ภาพทั้งหมดค่อย ๆ สว่างขึ้น-หรี่ลงตามเวลา (flicker)

คำอธิบาย

ไม่มีส่วนผสมไหนใหม่เลยสักอย่าง recipe นี้พิสูจน์ว่าส่วนประกอบไม่กี่ตัว (Fresnel, sine wave ที่ขับด้วย UV, sine wave ที่ขับด้วย Time) ผสมกันใหม่กลายเป็น effect ที่หน้าตาต่างไปโดยสิ้นเชิง แค่เปลี่ยนว่าอะไรป้อนเข้าอะไร Scanline คือ sine wave ของ UV.y ที่ทำให้คมขึ้นด้วย Smoothstep จนกลายเป็นเส้นริ้ว ส่วน flicker คือ sine wave ของ Time ที่ remap ให้มันหรี่ลงแต่ไม่มืดสนิท นี่คือวิธีทำงานปกติของ technical artist คือไม่ได้คิดคณิตศาสตร์ใหม่ แต่เอา node แปดเก้าตัวเดิม ๆ มาผสมกันในลำดับใหม่

Tip เปลี่ยนการ flicker แบบ sin() เนี้ยบ ๆ เป็น node Noise ที่ sample ด้วย Time อย่างเดียว (ไม่มี UV) จะได้ flicker ที่ไม่สม่ำเสมอและ "กระตุก" มากขึ้น sine wave ปกติจะดูเหมือนกลไก ส่วน noise จะดูเหมือนไม่เสถียร

13. อีกสอง recipe: color tint / mask blend และลมพัดหญ้า

Recipe: color tint / mask blend

ไอเดีย เกมมักต้องการ mesh เดียวกันในสีทีมต่าง ๆ สีฝ่ายต่าง ๆ หรือ material variant ต่าง ๆ (ผ้าของตัวละครสีหนึ่ง สายหนังอีกสีหนึ่ง) โดยไม่ต้องทำ texture แยกทุก combination กลคือ mask texture สีเทาที่วาดครั้งเดียว (ขาว = "เปลี่ยนสีตรงนี้", ดำ = "อย่ายุ่งตรงนี้") ใช้เป็น T ของ Lerp ระหว่างสี albedo เดิมกับสี tint ที่เปิดเป็น material property

UV --> [Sample Albedo] --> albedoColor UV --> [Sample Mask.R] --> maskValue [Lerp: A=albedoColor, B=TintColor, T=maskValue] --> MASTER.Base Color
float3 albedo = _AlbedoTex.Sample(sampler_AlbedoTex, uv).rgb;
float  mask   = _MaskTex.Sample(sampler_MaskTex, uv).r; // 0..1, painted by an artist
float3 finalColor = lerp(albedo, tintColor.rgb, mask);

หน้าตาที่ได้ ผ้าของตัวละครเปลี่ยนไปเป็นสีทีมที่แมตช์กำหนด ในขณะที่ผิวหนัง หัวเข็มขัดโลหะ และหนัง (ที่วาดเป็นสีดำใน mask ทั้งหมด) ไม่เปลี่ยนเลย คำอธิบาย นี่คือ Lerp จากหัวข้อที่ 5 นั่นเอง เพียงแต่ mask texture ป้อน T ที่ต่างกันในแต่ละ pixel แทนที่จะเป็นค่าเดียว mask จริง ๆ แล้วก็แค่แผนที่ blend factor ที่วาดด้วยมือแล้วเก็บไว้

Recipe: ลมพัดหญ้า (vertex displacement)

ไอเดีย ทุกอย่างที่ผ่านมาเปลี่ยนแค่สีของ pixel (stage fragment/pixel) shader ยังขยับ geometry ได้ด้วย ใน stage vertex ก่อนที่ rasterization จะเกิดขึ้นเสียอีก สำหรับหญ้าที่พัดตามลม เราไม่อยากให้ทั้งใบเลื่อนไปด้านข้างพร้อมกันหมด (จะดูเหมือนลอยอยู่) อยากให้แค่ ปลายใบ ที่โยกไหว ในขณะที่ โคน ยังปักอยู่กับที่ ค่า "จุดนี้ขยับได้แค่ไหน" ต่อ vertex แบบนี้ ถูกวาดเก็บไว้ใน vertex color ของ mesh ตอนที่สร้าง asset หญ้า (ขาวที่ปลาย ดำที่โคน) แล้ว shader ก็อ่านมันกลับมาใช้เป็น mask

Time --> [x windSpeed] --> [Sine] --> [x windStrength] --> swayAmount VertexColor.R (0 ที่โคน, 1 ที่ปลาย) --> [x swayAmount] --> finalSway VertexPosition --> [+ finalSway on X] --> MASTER.Vertex Position
// Vertex shader -- runs once per vertex, not per pixel
float windPhase = _Time.y * windSpeed + worldPos.x * 0.3; // offset by position so blades don't all sway in sync
float sway = sin(windPhase) * windStrength;

float tipAmount = vertexColor.r; // 0 at the base, 1 at the tip -- painted into the mesh
float3 displaced = vertexPosition + float3(sway, 0, 0) * tipAmount;
โคนปักนิ่ง ปลายโยกไหว: ปลาย \ / \ | / \ / \ | / ใบหญ้า | | --> | | | | | | | | โคน _|__|_ _|____|_ _|__|_ ====================================== (พื้นดิน ไม่ขยับเลย)

คำอธิบาย vertexColor.r คือค่าที่วาดลงบน vertex ของ mesh โดยตรง (โปรแกรม 3D ส่วนใหญ่ให้ artist วาด vertex color ได้เหมือน texture หยาบ ๆ ต่อ vertex) การเอา sway ไปคูณกับ mask นั้นทำให้โคน (r=0) ถูกคูณด้วยศูนย์และไม่ขยับเลย ในขณะที่ปลาย (r=1) ได้ sway เต็ม ๆ การเอา worldPos.x ไปบวกเข้า input ของ sine ทำให้ใบหญ้าที่อยู่ข้างกันอยู่คนละจุดในคลื่นเล็กน้อย ทุ่งหญ้าทั้งทุ่งจึงโยกเหมือนคลื่นที่ไหลผ่าน แทนที่ทุกใบจะสะบัดซ้ายขวาพร้อมกันเป๊ะ ๆ

Common mistake ทำ vertex displacement ผิด space แล้วลืมว่ามันต้องจบลงใน space เดียวกับที่ Vertex Position ของ Master node ต้องการ อีกอย่างคือ vertex displacement หนัก ๆ ต้องการ mesh ที่มี vertex พอจะโค้งได้เนียน ๆ quad หญ้า 4 vertex โค้งไม่ได้ เอียงตรง ๆ ได้อย่างเดียว

14. อภิธานศัพท์

15. แบบฝึกหัด

แบบฝึกหัด 1 — คำนวณ panning material น้ำตัวหนึ่ง sample normal map ด้วย Tiling = (2, 2), Offset = (0, 0) และ pan ด้วย Speed = (0.1, 0.05) UV ดิบของ mesh ที่จุดหนึ่งบนพื้นผิวคือ (0.3, 0.6) ที่ Time = 4.0 วินาที UV ที่ถูก sample จริง ๆ คือเท่าไหร่ ใช้สูตร finalUV = uv * tiling + offset + speed * time
Show answer
tiled      = uv * tiling         = (0.3 * 2, 0.6 * 2)   = (0.6, 1.2)
tiled+off  = tiled + offset      = (0.6, 1.2) + (0, 0)   = (0.6, 1.2)
panAmount  = speed * time        = (0.1*4, 0.05*4)       = (0.4, 0.2)
finalUV    = tiled+off + panAmount                       = (1.0, 1.4)

UV ที่ถูก sample คือ (1.0, 1.4) ค่าที่เกิน 1 เองยังไม่มีความหมายพิเศษอะไร — Wrap Mode ต่างหากที่ตัดสินว่าจะเกิดอะไรขึ้นต่อ ด้วย Repeat wrap mode (ตัวเลือกปกติสำหรับ texture น้ำหรือลาวาที่เลื่อนไหล) GPU จะวน 1.0 กลับไปเป็น 0.0 และ 1.4 กลับไปเป็น 0.4 เงียบ ๆ ดังนั้นมันจึง sample pixel เดียวกับ UV (0.0, 0.4) จริง ๆ — texture เลื่อนผ่านไปครบหนึ่งผืนแล้ววนกลับมาแบบไร้รอยต่อ ถ้าใช้ Clamp แทน การ sample จะแช่แข็งอยู่ที่ pixel ขอบของ texture ผิด ๆ ซึ่งเป็นเหตุผลว่าทำไม texture ที่ pan ควรใช้ Repeat เสมอ

แบบฝึกหัด 2 — คำนวณ Step กับ Smoothstep shader dissolve ตัวหนึ่งมี dissolveAmount = 0.4 และความกว้างขอบ 0.05 ที่ pixel หนึ่ง texture noise ให้ค่า 0.42 (ก) clip(noiseValue - dissolveAmount) จะเก็บหรือทิ้ง pixel นี้ (ข) smoothstep(0.4, 0.45, 0.42) มีค่าเท่าไหร่ (ปัดสองตำแหน่งทศนิยม) และมันหมายถึงอะไร
Show answer
(a) clip(noiseValue - dissolveAmount) = clip(0.42 - 0.4) = clip(0.02)
    0.02 is NOT less than 0, so the pixel is KEPT (still visible)

(b) smoothstep(edge0=0.4, edge1=0.45, x=0.42)
    t      = (x - edge0) / (edge1 - edge0) = (0.42 - 0.4) / (0.45 - 0.4) = 0.02 / 0.05 = 0.4
    result = t*t*(3 - 2*t) = 0.4*0.4*(3 - 0.8) = 0.16 * 2.2 = 0.352
    rounded: 0.35

(ก) pixel นี้รอดไป — มันเพิ่งพ้น dissolve threshold มานิดเดียว ยังไม่ถูกกลืนหายไป (ข) 0.35 คือระยะที่ pixel นี้อยู่ในแถบขอบเรืองแสงบาง ๆ นั้น บนสเกลแบบโค้ง (eased) แทนที่จะเป็นเส้นตรง ค่านี้ถูกเอาไปคูณกับสีขอบสำหรับ Emission ของขอบไหม้ ดังนั้น pixel ที่อยู่เพิ่งเริ่มต้นแถบจะเรืองแสงหรี่ ๆ ส่วน pixel ที่อยู่ลึกเข้าไปในแถบมากกว่าจะเรืองแสงใกล้เต็มสว่างกว่า

แบบฝึกหัด 3 — ออกแบบ node graph ออกแบบ (เป็น ASCII node-flow diagram แบบที่ใช้ในบทนี้) material "magic forcefield" มันควรมีขอบเรืองแสง fresnel, texture ลายหกเหลี่ยมที่เลื่อนไหลช้า ๆ ใน Emission และทั้งหมดควรเต้นสว่างขึ้น-หรี่ลงตามเวลา ระบุ node ทุกตัวที่จะใช้ และแต่ละตัวป้อนอะไรเข้าอะไร
Show answer

graph หนึ่งแบบที่สมเหตุสมผล (นี่คือแบบฝึกหัดออกแบบ ดังนั้น layout อื่นก็ถูกต้องได้เหมือนกัน)

Normal, ViewDir --> [Fresnel Effect] --> [Power] --> rimGlow Time --> [x scrollSpeed] --> [add to UV] --> [Sample Hex Texture] --> hexPattern (rimGlow + hexPattern) --> combinedGlow Time --> [x pulseSpeed] --> [Sine] --> [Remap -1..1 to 0.5..1.0] --> pulseAmount combinedGlow --> [x pulseAmount] --> [x ForcefieldColor] --> MASTER.Emission

อ่านจากซ้ายไปขวา Fresnel ให้ความสว่างของขอบ อีกสายหนึ่ง pan UV ตามเวลาแล้ว sample texture ลายหกเหลี่ยม ทำให้ลายหกเหลี่ยมค่อย ๆ เลื่อนไปบนพื้นผิว สองสายนี้ถูกบวกเข้าด้วยกัน (ขอบบวกลายหกเหลี่ยม) — ใช้ Add ตรงนี้แทน Lerp เพราะอยากให้ทั้งสองอย่างโผล่มาพร้อมกัน ไม่ใช่ blend ระหว่างกัน สายที่สามขับ sine wave จาก Time แล้ว remap ให้ความสว่างไม่มีวันต่ำกว่าครึ่งหนึ่ง แล้วคูณเข้ากับผลรวม — นี่คือไอเดีย "flicker" เดียวกับ recipe โฮโลแกรม เพียงแต่ใช้กับ effect ทั้งหมดแทนที่จะใช้แค่ค่าฐานของ Emission สุดท้ายทุกอย่างถูกแต่งสีด้วย property ForcefieldColor ตัวเดียวที่เปิดออกมา แล้วป้อนเข้า Emission ดังนั้น designer จึงใช้ shader ตัวเดียวซ้ำได้ทั้ง shield ศัตรูสีแดงและ shield ผู้เล่นสีน้ำเงิน แค่เปลี่ยน property สีตัวนั้นตัวเดียว

← กลับไปหน้ารวมบท