บทนี้พูดถึง "ลุค" ที่ทำให้เกมอย่าง Genshin Impact, Honkai: Star Rail และ Guilty Gear Xrd ดูออกทันทีว่าเป็นสไตล์อนิเมะ คือตัวละครที่ดูเหมือนภาพวาดมือมากกว่าภาพถ่าย ลุคแบบนี้ไม่ได้เกิดจากงานอาร์ตอย่างเดียว แต่ถูกสร้างขึ้นข้างใน shader (โปรแกรมเล็ก ๆ ที่รันบน GPU และตัดสินใจว่าแต่ละ pixel จะออกมาเป็นสีอะไร) ในบทนี้เราจะสร้างลุคนั้นทีละชิ้น: cel shading แบบแบน ๆ, สีเงาที่อาร์ตกำหนดเอง, เส้นขอบ (outline) ที่คมสะอาด, rim ที่เรืองแสง, highlight แบบสไตไลซ์ และเทคนิคพิเศษที่สตูดิโอใช้กับหน้าและผม ทุกหัวข้อจะเดินตามรูปแบบเดียวกัน: โค้ด shader สั้น ๆ, ภาพว่ามันทำอะไรบนจอ, แล้วตามด้วยคำอธิบายง่าย ๆ ว่าทำไม
ตัวอย่างพวกนี้เราเขียนด้วย HLSL (High-Level Shading Language — ภาษาคล้าย C ที่ Unity ใช้เขียน shader) ข้างใน URP ของ Unity (Universal Render Pipeline — ระบบ rendering สมัยใหม่ที่ปรับแต่งได้ของ Unity) คุณรู้ C อยู่แล้ว HLSL จึงจะรู้สึกคุ้นมาก มันมี float, if, for, ฟังก์ชัน และ struct จุดต่างคือ shader จะรันหนึ่งครั้งต่อ vertex และหนึ่งครั้งต่อ pixel แบบขนานกันเป็นพัน ๆ ครั้งต่อเฟรม
เกม 3D สมัยใหม่ส่วนใหญ่ใช้ PBR (Physically Based Rendering — การ shade ที่พยายามเลียนแบบว่าแสงจริงสะท้อนวัสดุจริงยังไง) PBR ตั้งคำถามเชิงฟิสิกส์: ถ้ามีโลหะหยาบชิ้นนี้ แสงดวงนี้ และกล้องตัวนี้ กล้องจริงจะบันทึกความสว่างเท่าไหร่? คำตอบคือไล่เฉด (gradient) ของแสงที่นุ่มและต่อเนื่อง ผลลัพธ์จึงออกมาเหมือนภาพถ่าย
NPR ย่อมาจาก Non-Photorealistic Rendering — การ shade ที่จงใจไม่สนฟิสิกส์ เพื่อเลียนแบบภาพวาด ภาพเพนต์ การ์ตูน หรือเซลอนิเมะ NPR ตั้งคำถามคนละแบบเลย: ไม่ใช่ "กล้องจะบันทึกอะไร?" แต่เป็น "ศิลปินจะวาดตรงนี้ยังไง?" ศิลปินอนิเมะไม่ได้ไล่เฉดนุ่ม ๆ ทั่วแก้มตัวละคร เขาถมด้วยสีผิวแบน ๆ หนึ่งสี แล้วเติมรูปทรงเงาที่ขอบคมหนึ่งก้อน แล้วเติม highlight สว่างอีกหนึ่งจุด shader แบบ NPR ก็ทำซ้ำการตัดสินใจเชิงศิลป์พวกนี้ในโค้ด
ทำไมสตูดิโออย่าง HoYoverse ถึงลงแรงกับเรื่องนี้เยอะ? เพราะเสน่ห์ของอนิเมะ 2D นั้นมหาศาล แต่งาน 2D หมุนอิสระในโลก 3D ไม่ได้ NPR คือสะพานเชื่อม: โมเดลและแอนิเมชัน 3D ของจริง สวมทับด้วยชั้นการ shade ที่ยังคงความแบน สะอาด และมีอารมณ์แบบภาพวาดจากทุกมุมกล้อง ที่เหลือของบทนี้คือกล่องเครื่องมือที่สร้างชั้นนั้นขึ้นมา
N·L แต่แทนที่จะเอามาใช้เป็น "สว่างแค่ไหน" ตรง ๆ เราจะเอามันไป เปิดตาราง (look up) ว่าศิลปินตัดสินใจให้บริเวณนั้นเป็นสีอะไรเกือบทุกอย่างในบทนี้ตั้งอยู่บนค่าเดียวที่คุณเคยเห็นแล้ว: N·L (อ่านว่า "N dot L") ซึ่งก็คือ dot product ของ normal ของพื้นผิว กับ ทิศทางแสง
1 เมื่อพื้นผิวหันเข้าหาแสงตรง ๆ, เป็น 0 เมื่อแสงเฉียดพื้นผิวไปด้านข้าง, และติดลบเมื่อพื้นผิวหันหนีแสงเพราะแสงติดลบไม่มีความหมาย เราเลย clamp ให้เหลือแค่ศูนย์ด้วย saturate (ฟังก์ชัน shader ที่บีบค่าให้อยู่ในช่วง 0 ถึง 1) การ shade แบบ Lambert ที่นุ่มและเป็นมาตรฐาน (โมเดล diffuse พื้นฐาน) ก็คือแค่นี้:
// N and L are unit vectors. dot() is the dot product.
half ndl = saturate(dot(N, L)); // 0..1, a smooth gradient
half3 lit = albedo * lightColor * ndl; // darker as the surface turns away
ลองตัวอย่างจริง: สมมติ normal ชี้ขึ้นตรง ๆ N = (0, 1, 0) และแสงมาจากทางขวาบน L = (0.6, 0.8, 0) (ยาว 1 อยู่แล้ว) จะได้ dot(N, L) = 0*0.6 + 1*0.8 + 0*0 = 0.8 จุดนี้จึงสว่าง 80% ถ้าหมุนพื้นผิวให้หันหนีแสง ค่า dot จะติดลบ saturate ก็ทำให้เป็น 0 จุดนั้นก็มืด
การไล่เฉดนุ่ม ๆ จาก 1.0 ลงไป 0.0 นี่แหละคือสิ่งที่ PBR ต้องการเป๊ะ ๆ และเป็นสิ่งที่อนิเมะ ไม่ ต้องการ งานทั้งหมดของเราใน cel shading คือเอาค่านุ่ม ๆ นี้ไปสับให้เป็นขั้นแบน ๆ ไม่กี่ขั้น ที่เหลือคือของประดับที่วางทับข้างบน
Cel shading (หรือ toon shading; คำว่า "cel" มาจากแผ่นเซลลูลอยด์ที่แอนิเมเตอร์สมัยก่อนใช้ลงสี) หมายถึงการแทนที่การไล่เฉดแสงนุ่ม ๆ ด้วยบริเวณแบน ๆ ไม่กี่ก้อน ศัพท์เทคนิคเรียกว่า quantize — คือ snap ค่าต่อเนื่องให้ไปอยู่ที่ระดับตายตัวไม่กี่ระดับ
เวอร์ชันง่ายสุดคือแบบสองโทน: เงาหรือสว่าง ไม่มีตรงกลาง เครื่องมือคือ step: step(edge, x) คืนค่า 0 เมื่อ x ต่ำกว่า edge และคืน 1 เมื่อไม่ต่ำกว่า
// Two-tone toon. step(edge, x) = 0 if x < edge, else 1.
half ndl = saturate(dot(N, L));
half toon = step(0.5, ndl); // hard: 0 (shadow) or 1 (light)
// Mix a dark shade and a light shade using that 0/1 switch.
half3 cel = albedo * lerp(0.35, 1.0, toon); // 35% brightness OR 100%
lerp(a, b, t) คือ linear interpolation: คืน a เมื่อ t เป็น 0 และคืน b เมื่อ t เป็น 1 เพราะ toon เป็นได้แค่ 0 หรือ 1 มันจึงเลือกได้แค่โทนมืด (0.35) หรือสีเต็ม (1.0) ไม่มีค่ากลาง ลองไล่ดู:
ถ้าอยากได้มากกว่าสองโทน ก็ quantize ให้เป็น แถบ (bands) ด้วย floor (ปัดลงเป็นจำนวนเต็มที่ใกล้ที่สุด):
// Posterize the gradient into N flat bands.
half bands = 3.0;
half stepped = floor(ndl * bands) / bands; // gives 0, 0.333, 0.667
half3 cel3 = albedo * lightColor * stepped;
คูณ ndl (0..1) ด้วย 3, floor, แล้วหารกลับ: ndl ที่อยู่ใน [0, 0.333) กลายเป็น 0, ใน [0.333, 0.667) กลายเป็น 0.333 ไปเรื่อย ๆ ผลลัพธ์คือระดับความสว่างแบน ๆ สามระดับ โดยมีรอยต่อกระโดดชัด ๆ คั่นระหว่างกัน นี่คือหัวใจทั้งหมดในภาพเดียว:
เส้นแข็ง ๆ ตรงที่แถบหนึ่งไปเจอแถบถัดไปเรียกว่า terminator (เส้นแบ่งระหว่างพื้นที่สว่างกับพื้นที่เงา) ใน shading แบบอนิเมะ terminator คือพระเอกของงาน — รูปทรงที่สะอาดและตั้งใจของมันคือสิ่งที่ทำให้ตาอ่านว่า "วาดขึ้นมา" การไล่เฉดนุ่ม ๆ ไม่มี terminator เลย นั่นคือเหตุผลว่าทำไม PBR ถึงไม่มีวันดูเหมือนการ์ตูน
step ล้วน ๆ ให้ขอบที่แข็งเป๊ะทางคณิตศาสตร์ — แต่บนจอ ขอบนั้นจะโชว์ aliasing (ขอบหยัก ๆ เป็นบันไดตามเส้นเอียง) เราอยากให้ขอบยัง ดู คมแต่ไม่หยัก วิธีแก้คือใช้ smoothstep แบบแคบมาก ๆ แทน step
smoothstep(a, b, x) คืน 0 เมื่อต่ำกว่า a, คืน 1 เมื่อสูงกว่า b, และคืนเส้นโค้งตัว S ที่นุ่มตรงช่วงกลาง ถ้า a กับ b ชิดกันมาก ช่วงเปลี่ยนก็สั้น — สายตาเห็นว่าคม แต่ถูกเบลนด์ข้ามหนึ่งถึงสอง pixel เลยไม่หยัก
// Narrow blend window around the terminator (0.5).
half e = 0.015; // half-width of the blend
half lit = smoothstep(0.5 - e, 0.5 + e, ndl); // crisp but anti-aliased
มีเวอร์ชันที่ฉลาดกว่า ซึ่งทำให้ความนุ่มมีความหนาเท่ากันทั่วทั้งจอ ไม่ว่าพื้นผิวจะเอียงยังไง มันใช้ fwidth (ฟังก์ชัน shader ที่บอกว่าค่าหนึ่งเปลี่ยนเร็วแค่ไหนจาก pixel หนึ่งไปอีก pixel — เป็นตัววัดความชันในพิกัดจอ):
// Softness scaled to on-screen change, so the edge is uniform width.
half d = fwidth(ndl);
half litAA = smoothstep(0.5 - d, 0.5 + d, ndl);
ตรงที่ ndl เปลี่ยนเร็ว (พื้นผิวเอียงชัน ๆ) ค่า d จะใหญ่ ช่วงเบลนด์ก็กว้างขึ้น ตรงที่เปลี่ยนช้า d จะเล็ก ช่วงเบลนด์ก็แคบลง ขอบที่ตาเห็นจึงมีความคมเท่ากันทั่วทั้งโมเดล นี่คือเทคนิคเดียวกับที่ตัวเรนเดอร์ฟอนต์ใช้ให้ตัวอักษรเนียนได้ทุกขนาด
fwidth) ถ้าถ่างกว้างเกินไปก็จะกลับไปเป็นการไล่เฉดนุ่ม ๆ — เสียลุคแถบแบน ๆ ไป เป้าหมายคือ "คมแต่ไม่หยัก" ไม่ใช่ "นุ่ม"ที่ผ่านมา band มาจากคณิตในโค้ด และสีของมันก็แค่เวอร์ชันมืดกว่า/สว่างกว่าของสีฐาน แต่ shading อนิเมะจริง ๆ มีความคิดเห็นมากกว่านั้น: เงามัก เย็น (อมฟ้าหรือม่วงนิด ๆ) ส่วนที่สว่างมัก อุ่น ฟิสิกส์ไม่มีวันทำแบบนี้จากแสงขาวดวงเดียว — แต่ศิลปินทำ วิธีสะอาด ๆ ที่จะยกการตัดสินใจนี้ให้ศิลปินคือ ramp texture
ramp texture คือภาพที่กว้างและเตี้ย — เช่น กว้าง 256 pixel สูงไม่กี่ pixel ศิลปินลงสีจากซ้ายไปขวา: เงาลึกสุดอยู่ซ้ายสุด, แสงเต็มอยู่ขวาสุด โดยตำแหน่งรอยต่อ band และทุกสีเลือกด้วยมือ ตอนรัน shader ก็ไปหยิบ (sample) จากแถบนี้โดยใช้ N·L เป็นพิกัดแนวนอน
// Half-Lambert remap: uses the whole ramp and lets shadow wrap softly.
half hl = ndl * 0.5 + 0.5; // maps -1..1 into 0..1, never fully 0
// u = light amount, v = which material row of the ramp to read.
half2 rampUV = half2(hl, _MaterialRow);
half3 shade = SAMPLE_TEXTURE2D(_RampTex, sampler_RampTex, rampUV).rgb;
half3 color = albedo * lightColor * shade;
มีสองอย่างเกิดขึ้นตรงนี้ อย่างแรก การ remap แบบ half-Lambert (ndl * 0.5 + 0.5 เทคนิคคลาสสิกจาก Half-Life 2 ของ Valve): มันยืดค่า dot ดิบ -1..1 ให้เต็มช่วง 0..1 เพื่อให้ใช้ความกว้างของ ramp ทั้งหมด และทำให้ฝั่งเงาค่อย ๆ พันรอบไปแบบนุ่ม ๆ แทนที่จะกระแทกดำทันที อย่างที่สอง band แข็ง ๆ ถูกอบ (bake) ไว้ในเท็กซ์เจอร์แล้ว: ถ้าศิลปินลงสองบล็อกแบน ๆ ที่มีรอยต่อคม คุณก็ได้ cel shading สองโทนที่ทั้งสีและตำแหน่งรอยต่ออยู่ในการควบคุมของอาร์ต — ไม่ต้องแก้โค้ด
พิกัดแนวตั้ง v คือพระเอกเงียบ ซ้อน ramp หลายอันในเท็กซ์เจอร์เดียว — แถว 0 สำหรับ skin, แถว 1 สำหรับ cloth, แถว 2 สำหรับ metal — แล้วส่ง _MaterialRow ต่อ material ทีนี้ทุก material บนตัวละครก็อ่านสีเงาจาก ramp ที่ลงไว้ของตัวเอง นี่แหละคือวิธีที่ Genshin และ Honkai: Star Rail ให้ skin มีเงาอุ่น ๆ นุ่ม ๆ ขณะที่ metal ได้เงาเย็น ๆ คม ๆ ทั้งหมดจาก shader ตัวเดียว
เส้นขอบสีเข้มสะอาด ๆ รอบตัวละครคือครึ่งหนึ่งของลุคอนิเมะ เทคนิคที่ใช้บ่อยที่สุดในเกมคือ inverted hull (เรียกอีกชื่อว่าวิธี "shell" หรือ "backface") ไอเดียคือ วาดตัวละคร สองครั้ง
Cull Front จะทิ้งสามเหลี่ยมที่หันหน้าเข้าหากล้อง) shell สีเข้มที่ใหญ่ขึ้นเล็กน้อยนี้ซ่อนอยู่หลังโมเดลจริง เหลือแค่ขอบมันที่โผล่พ้นออกมารอบ ๆ silhouetteนี่คือ outline pass ในรูปแบบ ShaderLab + HLSL คำว่า culling หมายถึงการทิ้งสามเหลี่ยมที่หันไปทางที่เลือก; Cull Front เก็บเฉพาะอันที่หันหนีกล้อง — คือด้านหลังของ shell ซึ่งเป็นสิ่งที่ล้อมรอบ silhouette
// --- OUTLINE PASS (put this Pass FIRST in the SubShader) ---
Pass
{
Name "Outline"
Cull Front // keep only back faces of the shell
HLSLPROGRAM
#pragma vertex OutlineVert
#pragma fragment OutlineFrag
float _OutlineWidth;
half4 _OutlineColor;
Varyings OutlineVert(Attributes IN)
{
Varyings OUT;
// push the vertex OUT along its normal, in object space
float3 posOS = IN.positionOS.xyz + IN.normalOS * _OutlineWidth;
OUT.positionCS = TransformObjectToHClip(posOS);
return OUT;
}
half4 OutlineFrag(Varyings IN) : SV_Target
{
return _OutlineColor; // flat dark line, no lighting
}
ENDHLSL
}
ปัญหาที่เจอในทางปฏิบัติสองอย่างและวิธีแก้:
_OutlineWidth วัดเป็นหน่วยโลก (world units) ถ้าอยากให้ความหนาบนจอคงที่ ให้สเกลระยะดันตามระยะห่างจากกล้อง (คูณ offset ด้วยค่า w ใน clip space หรือด้วย depth ใน view space) สตูดิโอมักเปิดให้ปรับ curve เล็ก ๆ เพื่อไม่ให้ตัวละครไกล ๆ เสีย outline ไปเลยCull Front ถ้าคุณดันทั้งโมเดลออกแล้ววาดด้านหน้าของมัน shell สีเข้มจะคลุมตัวละครทั้งตัว คุณจะเห็นเป็นก้อนดำ ไม่ใช่เส้นขอบ ต้องเก็บเฉพาะ back faces เพื่อให้โมเดลจริงวาดทับทุกอย่างยกเว้นขอบบาง ๆวิธีทำ outline วิธีที่สองไม่แตะโมเดลของตัวละครเลย แต่หลังจากเรนเดอร์ฉากเสร็จ จะมี full-screen pass (shader ที่รันหนึ่งครั้งต่อ pixel บนจอ เหมือนฟิลเตอร์รูป) มองหาขอบ โดยเทียบแต่ละ pixel กับเพื่อนบ้านของมันใน buffer ของ depth และ normal แล้วทาเส้นตรงไหนก็ตามที่เจอการเปลี่ยนแปลงกะทันหัน
// Simplified depth-based edge detect (a "Roberts cross" style compare).
half4 EdgeFrag(Varyings IN) : SV_Target
{
float2 uv = IN.uv;
float2 texel = _CameraDepthTexture_TexelSize.xy; // size of one pixel
half d0 = SampleSceneDepth(uv); // this pixel
half d1 = SampleSceneDepth(uv + texel); // diagonal
half d2 = SampleSceneDepth(uv + float2(texel.x, -texel.y));
half edge = abs(d0 - d1) + abs(d0 - d2);
half edgeMask = step(_EdgeThreshold, edge); // 1 on an edge
half3 scene = SampleSceneColor(uv);
return half4(lerp(scene, _LineColor.rgb, edgeMask), 1);
}
เทียบสองวิธีทำ outline เพื่อจะได้เลือกได้อย่างมีเหตุผล:
เกมอนิเมะส่วนใหญ่ใช้ inverted hull เป็น outline หลัก เพราะมันถูก (ต่อหนึ่งตัวละคร) และให้อาร์ตคุมความหนากับสีต่อ material ได้ง่าย บางทีก็เพิ่ม edge-detect pass เบา ๆ ทับข้างบนเพื่อจับเส้นรายละเอียดข้างในที่ hull เอื้อมไม่ถึง การใช้ทั้งสองอย่างรวมกันเป็นเรื่องปกติ
ลอง shade หน้าอนิเมะด้วย N·L ตรง ๆ ดู มันจะดูผิด: geometry ของหัวสไตไลซ์ที่ต่ำและนุ่มจะให้เงาเป็นด่าง ๆ สั่น ๆ รอบจมูกและเบ้าตา ที่ไต่ยั้วเยี้ยเมื่อแสงขยับ หน้าอนิเมะต้องการรูปทรงเงาที่ สะอาด ตั้งใจ และพลิกคมชัด เมื่อแสงกวาดจากด้านหนึ่งไปอีกด้าน สตูดิโอแก้ด้วย SDF face shadow map
SDF ย่อจาก Signed Distance Field — ในที่นี้คือเท็กซ์เจอร์ grayscale ที่ลงสีบนหน้า โดยค่าที่แต่ละ pixel เข้ารหัสว่า ที่มุมแสงเท่าไหร่ pixel นั้นจะตกเข้าเงา pixel ใกล้จมูกจะมืดเร็ว (แสงเบนนิดเดียวก็เงาแล้ว); pixel ใกล้หูจะสว่างอยู่จนกว่าแสงจะเกือบเฉียงข้าง มันลงสีครั้งเดียวโดยศิลปิน และจับวิธีที่แอนิเมเตอร์จะวาดเงาหน้ากวาดผ่านได้เป๊ะ
ตอนรัน เราเทียบมุมแนวนอนของแสง (เทียบกับทิศที่หัวหันอยู่) กับค่า SDF เพราะ map ที่ลงสีไว้ครอบคลุมแค่การหันไปทางเดียว เราจึงมิเรอร์เท็กซ์เจอร์ในแนวนอนเมื่อแสงข้ามไปอีกแก้ม
// faceForward / faceRight come from the head bone (fed in as uniforms).
float3 L = normalize(_MainLightPosition.xyz);
half FdotL = dot(faceForward, L); // light in front(+) or behind(-)
half RdotL = dot(faceRight, L); // light on right(+) or left(-)
// Mirror the SDF map when the light is on the left side of the face.
float2 uv = IN.uv;
uv.x = (RdotL < 0) ? (1.0 - uv.x) : uv.x;
half sdf = SAMPLE_TEXTURE2D(_FaceSDF, sampler_FaceSDF, uv).r; // 0..1
// As the face turns toward the light, the threshold sweeps the shadow.
half threshold = 1.0 - (FdotL * 0.5 + 0.5); // remap front/back to 0..1
half lit = step(threshold, sdf); // clean binary face shadow
ผลลัพธ์คือเส้นแบ่งเงาที่เป็นเส้นโค้งสะอาดเส้นเดียว อยู่ในตำแหน่งที่อาร์ตอนุมัติเสมอ และเลื่อนกวาดผ่านหน้าอย่างนุ่มนวลเมื่อแสงหมุน — ไม่เป็นด่าง ไม่ไต่ยั้วเยี้ย นี่คือเทคนิคเบื้องหลังคำถามที่คนชอบถามว่า "ทำไมหน้าเขาสะอาดจัง" เกี่ยวกับ Genshin, Honkai และ Arknights: Endfield คุณไม่ต้องเขียน SDF เองตั้งแต่แรก สาระสำคัญคือหน้าได้ระบบเงา แยกต่างหากที่ลงสีมา แทนที่จะพึ่งการ shade จาก geometry
ผมอนิเมะไม่ได้จุดวาวกลม ๆ เหมือนลูกบิลเลียด มันได้ highlight เป็นแถบ ยาว ๆ ยืด ๆ ที่พาดขวางเส้นผมและเลื่อนไปเมื่อหัวหมุน แถบวาวที่ยืดนั้นคือ anisotropic highlight — "anisotropic" หมายถึงพื้นผิวสะท้อนต่างกันในแต่ละทิศ (ผมเรียบไปตามเส้นแต่ละเส้น แต่ขรุขระเมื่อพาดขวาง)
โมเดลมาตรฐานคือ Kajiya-Kay ซึ่งอิง highlight กับ tangent ของผม (ทิศตามแนวเส้นผม) แทน normal เราเลื่อน tangent นิดหน่อยด้วย normal เพื่อขยับแถบไปตรงที่ศิลปินต้องการ แล้วยกพจน์ที่ยืดออกไปยกกำลังเพื่อคุมความคม:
// T is the tangent (along the strand); H is the half vector.
float3 H = normalize(L + V);
float3 Tshift = normalize(T + N * _Shift); // slide the band up/down
half dotTH = dot(Tshift, H);
half sinTH = sqrt(1.0 - dotTH * dotTH); // stretched across strands
half spec = pow(sinTH, _HairExp) * _HairStrength;
half3 hairHi = spec * _HairColor.rgb * _HairMask; // mask into a strip
sinTH มีค่ามากเมื่อ half vector เรียงตัว ขวาง เส้นผม และมีค่าน้อยเมื่อเรียงตัวไปตามเส้นผม — นั่นแหละคือสิ่งที่ยืดจุดวาวให้เป็นแถบ _Shift (ป้อนผ่าน texture noise หรือ flow เล็ก ๆ) ทำให้แถบแตกเป็นลุคขรุขระเป็นชั้น ๆ นิด ๆ แบบผมอนิเมะจริง แทนที่จะเป็นเส้นเดียวเป๊ะ ๆ shader ผมสไตไลซ์หลายตัวยังวางแถบสีสว่างกว่าอีกชั้นทับข้างบน — คือ "แถบผม" เงา ๆ ที่คุณเห็นเป็นวงวาว — โดยใช้ mask ตัวเดียวกัน
Rim light คือเส้นสว่างที่ปรากฏตามขอบตัวละครตรงที่พื้นผิวโค้งหนีกล้อง เหมือนมีแสงนั่งอยู่ข้างหลัง มันทำสองหน้าที่: เพิ่มแสงเรือง ๆ นวล ๆ แบบอนิเมะ และ แยกตัวละครออกจากพื้นหลัง เพื่อไม่ให้ดูเหมือนถูกแปะแบน ๆ ลงบนฉาก
คณิตของมันคือพจน์แบบ Fresnel (Fresnel: พื้นผิวสะท้อนมากขึ้นที่มุมเฉียง — เหตุผลที่ทะเลสาบสะท้อนท้องฟ้าตรงฝั่งไกลแต่ใสตรงเท้าเรา) เราต้องการแค่การประมาณแบบถูก ๆ: rim จะสว่างตรงที่ normal N ตั้งฉากกับทิศมอง V ซึ่งก็คือ silhouette พอดี
// V = direction from the surface toward the camera (unit vector).
half rim = 1.0 - saturate(dot(N, V)); // ~1 at the silhouette, ~0 facing us
rim = pow(rim, _RimPower); // higher power = thinner, tighter edge
rim *= step(0.1, ndl); // optional: only on the lit side
half3 rimColor = rim * _RimColor.rgb * _RimStrength;
color += rimColor; // add the glow on top
pow(rim, _RimPower) คุมความหนา: กำลัง 1 ให้ halo กว้าง ๆ นุ่ม ๆ; กำลัง 8 ให้เส้นแสงบางคม การคูณด้วย step(0.1, ndl) เก็บ rim ไว้เฉพาะตรงที่พื้นผิวสว่างอยู่แล้ว เพื่อให้แสงเรืองดูเหมือนมา จาก key light ไม่ใช่ลอยไปทั่ว Honkai: Star Rail ไปไกลกว่านั้นด้วยการชี้ rim ไปตามทิศแสง เพื่อให้ขอบสว่างอยู่ฝั่งที่หันเข้าหาแสงของฉากเสมอ — รายละเอียดเล็ก ๆ ที่ทำให้ภาพลวงตาสมจริง
Specular ของ PBR คือก้อนวาวรูปทรงนุ่ม ๆ ที่ถูกต้องตามฟิสิกส์ Specular อนิเมะมักเป็น จุดวาวขอบแข็ง — รูปทรงคมที่เด้งออกมา โดยเฉพาะบนตา โลหะ และเครื่องประดับเงา ๆ เราสร้างมันเหมือน cel shading เป๊ะ แต่ทำกับพจน์ highlight
highlight ใช้ N·H โดย H คือ half vector — ทิศที่อยู่กึ่งกลางระหว่างแสงกับทิศมอง พอ normal ของพื้นผิวเรียงตรงกับ H ก็เท่ากับคุณมองตรงเข้าไปในการสะท้อน N·H จึงใกล้ 1 ตรงจุดร้อน (hotspot) พอดี
// Hard anime glint: one crisp on/off highlight.
float3 H = normalize(L + V);
half ndh = saturate(dot(N, H));
half spec = step(1.0 - _Gloss, ndh); // hard white blob where ndh is high
spec *= _SpecMask; // paint where shine is allowed
half3 specColor = spec * _SpecColor.rgb;
color += specColor;
_Gloss ที่ใกล้ 1 ทำให้ threshold 1 - _Gloss เล็กจิ๋ว มีเพียงเสี้ยวที่สว่างที่สุดเท่านั้นที่ผ่าน step — เป็นจุดวาวเล็กคม gloss ต่ำลงก็ทำให้ก้อนกว้างขึ้น เท็กซ์เจอร์ _SpecMask สำคัญมาก: มันให้ศิลปินบอกได้ว่า "วาวที่หัวเข็มขัดกับตา ที่อื่นไม่ต้อง" ซึ่งเป็นวิธีที่ตัวละครสไตไลซ์ได้ประกายที่เลือกไว้อย่างตั้งใจ แทนที่จะวาวเลอะไปทั่ว
สำหรับตาและโลหะ สตูดิโอมักข้ามคณิตแสงไปเลย แล้วใช้ MatCap (Material Capture — ภาพทรงกลมที่ถูก light ไว้แล้ว) คุณ sample มันโดยใช้ normal ของพื้นผิวที่แปลงเข้า view space (กรอบอ้างอิงของกล้อง) ก็จะได้ลุคโลหะหรือแก้วที่อาร์ตคุมได้ทันที และหันเข้าหากล้องสวย ๆ เสมอ
// MatCap: sample a pre-lit sphere image by the view-space normal.
float3 nVS = TransformWorldToViewDir(N, true); // normal in camera space
float2 mUV = nVS.xy * 0.5 + 0.5; // map -1..1 to 0..1 UV
half3 matcap = SAMPLE_TEXTURE2D(_MatCap, sampler_MatCap, mUV).rgb;
ตอนนี้เราประกอบชิ้นส่วนเป็น lighting pass เดียว อ่านจากบนลงล่าง — มันคือลำดับที่เราสอนเป๊ะ ๆ: เก็บข้อมูลพื้นผิว, ดึง main light, look up สีที่ quantize แล้วผ่าน ramp, จากนั้น บวก specular สไตไลซ์ และ rim ทับข้างบน
half4 LitFrag(Varyings IN) : SV_Target
{
// 1. surface data
half3 albedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, IN.uv).rgb
* _BaseColor.rgb;
half3 N = normalize(IN.normalWS);
half3 V = normalize(GetWorldSpaceViewDir(IN.positionWS));
// 2. main light (direction, color, and shadow term from URP)
float4 shadowCoord = TransformWorldToShadowCoord(IN.positionWS);
Light mainLight = GetMainLight(shadowCoord);
half3 L = mainLight.direction;
half atten = mainLight.shadowAttenuation;
half ndl = saturate(dot(N, L)) * atten;
// 3. quantized lighting through the ramp (colors come from the texture)
half hl = ndl * 0.5 + 0.5;
half3 ramp = SAMPLE_TEXTURE2D(_RampTex, sampler_RampTex,
half2(hl, _MaterialRow)).rgb;
half3 color = albedo * mainLight.color * ramp;
// 4. stylized hard specular (added on top)
half3 H = normalize(L + V);
half ndh = saturate(dot(N, H));
half spec = step(1.0 - _Gloss, ndh) * _SpecMask;
color += spec * _SpecColor.rgb;
// 5. rim light (added on top, only on the lit side)
half rim = pow(1.0 - saturate(dot(N, V)), _RimPower);
rim *= step(0.1, ndl);
color += rim * _RimColor.rgb * _RimStrength;
return half4(color, 1.0);
}
และนี่คือโครง ShaderLab โดยรวมที่ต่อสอง pass เข้าด้วยกัน — outline ก่อน แล้วตามด้วย toon lighting pass ข้างบน ตัวเนื้อ HLSL เต็ม ๆ อยู่ข้างในแต่ละบล็อก HLSLPROGRAM; ที่สำคัญตรงนี้คือโครงสร้าง
Shader "Toon/Character"
{
Properties
{
_BaseMap ("Base", 2D) = "white" {}
_BaseColor ("Tint", Color) = (1,1,1,1)
_RampTex ("Lighting Ramp", 2D) = "white" {}
_MaterialRow ("Ramp Row", Range(0,1)) = 0.5
_Gloss ("Glossiness", Range(0,1)) = 0.8
_SpecColor ("Spec Color", Color) = (1,1,1,1)
_RimColor ("Rim Color", Color) = (1,1,1,1)
_RimPower ("Rim Power", Range(1,16)) = 4
_RimStrength ("Rim Strength", Range(0,4)) = 1
_OutlineColor ("Outline", Color) = (0,0,0,1)
_OutlineWidth ("Outline Width", Range(0,0.05)) = 0.01
}
SubShader
{
Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" }
// PASS 1: outline shell (inverted hull) -- see Section 6
Pass { Name "Outline" Cull Front /* HLSLPROGRAM ... ENDHLSL */ }
// PASS 2: toon lighting -- LitFrag above
Pass
{
Name "Forward"
Tags { "LightMode"="UniversalForward" }
Cull Back
// HLSLPROGRAM ... ENDHLSL
}
}
}
อ่าน fragment อีกรอบแล้วสังเกตรูปแบบจาก tip ในหัวข้อ 10: albedo * ramp คือการ คูณ (การ look up เงาทำให้สีฐานมืดลงและอมสี) ส่วน specular และ rim ใช้ += (พลังงานแสงที่บวกเข้าไป) วินัยข้อเดียวนี้ — คูณสำหรับเงา บวกสำหรับแสง — ทำให้ผลลัพธ์อ่านง่ายไม่ว่าจะซ้อนเอฟเฟกต์กี่ตัว
ถ้าจะเอาไอเดียข้างบนไปทำเป็นโปรเจกต์ Unity ที่รันได้จริง นี่คือชิ้นส่วนรูปธรรมที่คุณต้องแตะ:
.shader พร้อมบล็อก HLSLPROGRAM และ include header หลักของ URP เพื่อให้ helper อย่าง GetMainLight, TransformObjectToHClip และ SampleSceneDepth มีอยู่: #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"GetMainLight(shadowCoord) คืน struct Light ที่มี .direction, .color และ .shadowAttenuation การเรียกครั้งเดียวนี้แทน L และ lightColor ที่ป้อนมือในตัวอย่างก่อนหน้าGetAdditionalLightsCount() และ GetAdditionalLight(i, positionWS) ใน toon shading คุณมักป้อน N·L ของแต่ละไฟเสริมผ่าน ramp เดียวกัน เพื่อให้มันยังสไตไลซ์ ไม่กลายเป็นนุ่ม ๆPass ที่สอง ใส่ tag LightMode ที่ URP จะวาด (เช่น "SRPDefaultUnlit") หรือเก็บเป็น pass เสริมที่ pipeline คาดหวัง และอย่าลืม Cull FrontScriptableRendererFeature แล้ว blit material แบบเต็มจอหลังการเรนเดอร์ opaque มันต้องใช้ buffer ของ depth (และควรมี normal ด้วย) จึงเปิด Depth Texture บน URP asset และเพิ่ม DepthNormals prepass ถ้าต้องการเส้นรอยพับ ไม่ใช่แค่ silhouetteN·L และ Lambert ให้ได้ก่อน, (2) สลับมาใช้ ramp, (3) เพิ่ม inverted-hull outline, (4) เพิ่ม rim, (5) เพิ่ม stylized spec, (6) แล้วค่อยลุยหน้า SDF และผม แต่ละขั้นเห็นผลบนจอ คุณจึงรู้เสมอว่าชิ้นไหนพังalbedo * saturate(N·L)floor และ step ทำ)step ให้การตัด 0/1 แข็ง ๆ; smoothstep ให้ทางลาดนุ่มสั้น ๆ ระหว่างสองขอบN·LN·L * 0.5 + 0.5 เพื่อใช้ ramp ทั้งอันและให้เงาพันรอบนุ่ม ๆCull Front เก็บ back facesN·H ขับ specular highlightfloor จากนั้น (ข) ทำนายว่า band index จะเป็นเท่าไหร่สำหรับค่า input ข้างล่าง สมมติว่า ndl ถูก saturate มาแล้ว
half ndl = saturate(dot(N, L));
half toon = step(0.5, ndl); // 2 bands: 0 or 1
half3 col = albedo * lerp(0.35, 1.0, toon);
ทำนายผลลัพธ์ของเวอร์ชัน 3 band ของคุณสำหรับ ndl = 0.10, 0.40, 0.70, 0.95
แล้วอธิบายหนึ่งประโยคว่าทำไมศิลปินยังอาจชอบ ramp texture มากกว่าโค้ด floor ของคุณ
(ก) คูณด้วย 3, floor, แล้วหารกลับ:
half bands = 3.0;
half stepped = floor(ndl * bands) / bands; // gives 0, 0.333, 0.667
half3 col = albedo * lightColor * stepped;
(ข) floor(ndl * 3) / 3 สำหรับแต่ละ input:
สังเกตว่า 0.70 กับ 0.95 ตกอยู่ใน band บนสุดทั้งคู่ — ด้วย 3 band คุณจะไม่มีวันถึง 1.0 จาก floor เว้นแต่ ndl จะเท่ากับ 1.0 พอดี (ถ้าอยากให้ band บนสุดสว่างเต็ม ให้ใช้ floor(ndl * (bands-1) + 0.5) / (bands-1) เพื่อปัดแทน)
ทำไม ramp ยังดีกว่า: floor ให้ band ที่ห่างเท่า ๆ กันในเวอร์ชันมืดกว่า/สว่างกว่าของสีเดียวที่ทื่อ ๆ ramp texture ให้ศิลปินวางรอยต่อ band ตรงไหนก็ได้ และ เลือกเฉดสีของแต่ละ band ได้เป๊ะ — เงาอมฟ้าเย็น ๆ, highlight อุ่น ๆ — โดยไม่ต้องแตะโค้ด
float3 posOS = IN.positionOS.xyz + IN.normalOS * _OutlineWidth;
OUT.positionCS = TransformObjectToHClip(posOS);
อธิบายหนึ่งถึงสองประโยคว่า ทำไม ช่องว่างถึงเกิด และบอกวิธีแก้มาตรฐาน (ไม่ต้องเขียน shader เต็ม — แค่บอกว่าเปลี่ยนข้อมูลอะไรและบรรทัด vertex เปลี่ยนยังไง)
ทำไม: ตรงขอบแข็ง โมเดลมี split normals — มุมเดียวกันเก็บ normal ต่างกันสอง (หรือมากกว่า) ตัว ตัวละหน้า การดันสำเนาแต่ละอันไปตาม normal ของตัวเองที่ต่างกัน ทำให้มันแยกออกจากกัน ฉีกเป็นช่องว่างตรงมุมพอดี
วิธีแก้: อบ normal ที่เฉลี่ยแล้ว (smoothed) ลงในโมเดล — คำนวณ normal เฉลี่ยหนึ่งตัวต่อหนึ่งตำแหน่ง แล้วเก็บไว้ใน channel vertex ว่าง ๆ เช่น UV set หรือ vertex color (แพ็กเป็นทิศทาง) ดัน outline ไปตาม smoothed normal นั้นแทน shading normal เพื่อให้สำเนาทั้งสองของมุมขยับไปทางเดียวกันและติดกันไว้:
// _smoothNormalOS was baked into a spare channel (e.g. TEXCOORD3).
float3 posOS = IN.positionOS.xyz + IN.smoothNormalOS * _OutlineWidth;
OUT.positionCS = TransformObjectToHClip(posOS);
pass ที่ shade จริงยังใช้ normal ขอบแข็งตัวเดิมเพื่อให้แสงถูกต้อง; มีแต่การดัน outline เท่านั้นที่ใช้ตัวเฉลี่ย นี่คือสิ่งที่ตัวละครในเกมอนิเมะที่ปล่อยจริงทำกัน
rim *= step(0.1, ndl)) ศิลปินแสงต้องการตรงข้าม: rim เย็น ๆ ที่ปรากฏเฉพาะฝั่ง เงา ของตัวละคร เพื่อปลอมแสงที่สะท้อนกลับมาจากสภาพแวดล้อม เขียนพจน์ rim ให้ปรากฏเฉพาะที่พื้นผิวอยู่ในเงา เลือกกำลัง (power) ที่เหมาะสำหรับขอบบาง แล้วไล่ค่าที่ pixel ตรง silhouette ที่อยู่ในเงา (dot(N,V) = 0.05, ndl = 0.0)
พลิก mask: ใช้ 1 - ndl (หรือ step(ndl, 0.1)) เพื่อให้ rim รอดเฉพาะที่ ndl ต่ำ — ฝั่งเงา กำลังราว ๆ 4 ให้ขอบบาง
half rim = pow(1.0 - saturate(dot(N, V)), 4.0); // thin edge
rim *= (1.0 - step(0.1, ndl)); // ONLY on the shadow side
half3 rimColor = rim * _CoolRimColor.rgb * _RimStrength;
color += rimColor;
ไล่ค่าที่ pixel ที่ให้มา (dot(N,V) = 0.05, ndl = 0.0):
ดังนั้น pixel ตรง silhouette นี้ ซึ่งอยู่ในเงา จะได้ rim เย็นแรง ๆ (~0.81 ก่อนคูณสี) ส่วน pixel ตรง silhouette ที่สว่าง (ndl เกิน 0.1) จะคูณด้วย 1 - 1 = 0 และไม่ได้ rim เลย — เป็นเอฟเฟกต์เฉพาะฝั่งเงาอย่างที่ศิลปินขอพอดี