7.4 Stylized / Toon / NPR (สไตล์อนิเมะ)

เฟส 7 · กราฟิกและ Rendering · เวลาเรียน: 40–80 h

การเรนเดอร์แบบไม่สมจริง — cel shading, เส้นขอบ, การ shade ผิวและผมแบบอนิเมะ, ramp lighting นี่คือลายเซ็นของ HoYoverse; พอร์ตชิ้นนี้แข็ง ๆ มีค่าดั่งทอง

บทนี้พูดถึง "ลุค" ที่ทำให้เกมอย่าง 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 แบบขนานกันเป็นพัน ๆ ครั้งต่อเฟรม

1. NPR คืออะไร และทำไมเกมอนิเมะถึงหน้าตาแบบนั้น

เกม 3D สมัยใหม่ส่วนใหญ่ใช้ PBR (Physically Based Rendering — การ shade ที่พยายามเลียนแบบว่าแสงจริงสะท้อนวัสดุจริงยังไง) PBR ตั้งคำถามเชิงฟิสิกส์: ถ้ามีโลหะหยาบชิ้นนี้ แสงดวงนี้ และกล้องตัวนี้ กล้องจริงจะบันทึกความสว่างเท่าไหร่? คำตอบคือไล่เฉด (gradient) ของแสงที่นุ่มและต่อเนื่อง ผลลัพธ์จึงออกมาเหมือนภาพถ่าย

NPR ย่อมาจาก Non-Photorealistic Rendering — การ shade ที่จงใจไม่สนฟิสิกส์ เพื่อเลียนแบบภาพวาด ภาพเพนต์ การ์ตูน หรือเซลอนิเมะ NPR ตั้งคำถามคนละแบบเลย: ไม่ใช่ "กล้องจะบันทึกอะไร?" แต่เป็น "ศิลปินจะวาดตรงนี้ยังไง?" ศิลปินอนิเมะไม่ได้ไล่เฉดนุ่ม ๆ ทั่วแก้มตัวละคร เขาถมด้วยสีผิวแบน ๆ หนึ่งสี แล้วเติมรูปทรงเงาที่ขอบคมหนึ่งก้อน แล้วเติม highlight สว่างอีกหนึ่งจุด shader แบบ NPR ก็ทำซ้ำการตัดสินใจเชิงศิลป์พวกนี้ในโค้ด

PBR (photo look) NPR (anime look) smooth gradient of light a few flat regions of color many in-between tones hard edges between shadow and light colors come from physics colors chosen by an artist no outlines clean dark outlines realistic soft highlights sharp stylized glints goal: "looks real" goal: "looks drawn"

ทำไมสตูดิโออย่าง HoYoverse ถึงลงแรงกับเรื่องนี้เยอะ? เพราะเสน่ห์ของอนิเมะ 2D นั้นมหาศาล แต่งาน 2D หมุนอิสระในโลก 3D ไม่ได้ NPR คือสะพานเชื่อม: โมเดลและแอนิเมชัน 3D ของจริง สวมทับด้วยชั้นการ shade ที่ยังคงความแบน สะอาด และมีอารมณ์แบบภาพวาดจากทุกมุมกล้อง ที่เหลือของบทนี้คือกล่องเครื่องมือที่สร้างชั้นนั้นขึ้นมา

Tip จำประโยคเดียวไว้ตลอดทั้งบท: ใน NPR ค่าแสงไม่ใช่ "ความสว่าง" แต่มันคือ index ที่ชี้ไปยังการตัดสินใจของศิลปิน เราจะคำนวณค่าอย่าง N·L แต่แทนที่จะเอามาใช้เป็น "สว่างแค่ไหน" ตรง ๆ เราจะเอามันไป เปิดตาราง (look up) ว่าศิลปินตัดสินใจให้บริเวณนั้นเป็นสีอะไร

2. ทบทวน: คณิตแสงชิ้นเดียวที่เราต่อยอด

เกือบทุกอย่างในบทนี้ตั้งอยู่บนค่าเดียวที่คุณเคยเห็นแล้ว: N·L (อ่านว่า "N dot L") ซึ่งก็คือ dot product ของ normal ของพื้นผิว กับ ทิศทางแสง

เพราะแสงติดลบไม่มีความหมาย เราเลย 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 จุดนั้นก็มืด

light L rays | v +-----------------------------------------------+ | surface piece normal N NdotL look | | top points up 1.0 bright| | tilted at 37 deg 0.8 bright| | sideways grazing 0.0 edge | | back away < 0 => 0 dark | +-----------------------------------------------+

การไล่เฉดนุ่ม ๆ จาก 1.0 ลงไป 0.0 นี่แหละคือสิ่งที่ PBR ต้องการเป๊ะ ๆ และเป็นสิ่งที่อนิเมะ ไม่ ต้องการ งานทั้งหมดของเราใน cel shading คือเอาค่านุ่ม ๆ นี้ไปสับให้เป็นขั้นแบน ๆ ไม่กี่ขั้น ที่เหลือคือของประดับที่วางทับข้างบน

3. Cel / Toon Shading: quantize N·L ให้เป็นแถบแข็ง ๆ

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) ไม่มีค่ากลาง ลองไล่ดู:

ndl step(0.5, ndl) lerp(0.35, 1.0, ...) result 0.10 0 0.35 shadow 0.40 0 0.35 shadow 0.55 1 1.00 light 0.90 1 1.00 light

ถ้าอยากได้มากกว่าสองโทน ก็ 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 ไปเรื่อย ๆ ผลลัพธ์คือระดับความสว่างแบน ๆ สามระดับ โดยมีรอยต่อกระโดดชัด ๆ คั่นระหว่างกัน นี่คือหัวใจทั้งหมดในภาพเดียว:

Lambert (smooth gradient) Cel / Toon (3 hard bands) 1.0 |* 1.0 |******* | * | | * |------- hard jump (the terminator) 0.5 | * ===> 0.5 |******* | * | | * |------- hard jump 0.0 |______*_____ 0.0 |*******_____ +-----------> NdotL +-----------> NdotL

เส้นแข็ง ๆ ตรงที่แถบหนึ่งไปเจอแถบถัดไปเรียกว่า terminator (เส้นแบ่งระหว่างพื้นที่สว่างกับพื้นที่เงา) ใน shading แบบอนิเมะ terminator คือพระเอกของงาน — รูปทรงที่สะอาดและตั้งใจของมันคือสิ่งที่ทำให้ตาอ่านว่า "วาดขึ้นมา" การไล่เฉดนุ่ม ๆ ไม่มี terminator เลย นั่นคือเหตุผลว่าทำไม PBR ถึงไม่มีวันดูเหมือนการ์ตูน

Common mistake มือใหม่ชอบดันจำนวน band ขึ้นไป 8 หรือ 10 "เผื่อไว้" นั่นแค่สร้างการไล่เฉดนุ่ม ๆ กลับขึ้นมาใหม่ และทิ้งลุคอนิเมะไปเลย shading อนิเมะจริง ๆ มักใช้ สอง band (สว่าง + เงา) บางทีก็สาม (เพิ่มโทนกลางหรือขอบสว่าง) band น้อย ๆ ที่เลือกมาอย่างตั้งใจ ดูดีกว่า band เยอะ ๆ เสมอ

4. ทำให้ขอบของแถบนุ่มลงนิดหน่อย

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
step(0.5, x) smoothstep(0.485, 0.515, x) 1 | ______ 1 | __--- | | | / | | (a vertical cliff) | / (a short smooth ramp) 0 |_____| 0 |___--/ +-----+------ x +-----+------ x 0.5 0.5

มีเวอร์ชันที่ฉลาดกว่า ซึ่งทำให้ความนุ่มมีความหนาเท่ากันทั่วทั้งจอ ไม่ว่าพื้นผิวจะเอียงยังไง มันใช้ 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 จะเล็ก ช่วงเบลนด์ก็แคบลง ขอบที่ตาเห็นจึงมีความคมเท่ากันทั่วทั้งโมเดล นี่คือเทคนิคเดียวกับที่ตัวเรนเดอร์ฟอนต์ใช้ให้ตัวอักษรเนียนได้ทุกขนาด

Tip ให้ช่วงเล็กไว้ (ไม่กี่เปอร์เซ็นต์ หรือเท่ากับหนึ่ง fwidth) ถ้าถ่างกว้างเกินไปก็จะกลับไปเป็นการไล่เฉดนุ่ม ๆ — เสียลุคแถบแบน ๆ ไป เป้าหมายคือ "คมแต่ไม่หยัก" ไม่ใช่ "นุ่ม"

5. Ramp Texture: สีเงาที่อาร์ตกำหนดเอง

ที่ผ่านมา band มาจากคณิตในโค้ด และสีของมันก็แค่เวอร์ชันมืดกว่า/สว่างกว่าของสีฐาน แต่ shading อนิเมะจริง ๆ มีความคิดเห็นมากกว่านั้น: เงามัก เย็น (อมฟ้าหรือม่วงนิด ๆ) ส่วนที่สว่างมัก อุ่น ฟิสิกส์ไม่มีวันทำแบบนี้จากแสงขาวดวงเดียว — แต่ศิลปินทำ วิธีสะอาด ๆ ที่จะยกการตัดสินใจนี้ให้ศิลปินคือ ramp texture

ramp texture คือภาพที่กว้างและเตี้ย — เช่น กว้าง 256 pixel สูงไม่กี่ pixel ศิลปินลงสีจากซ้ายไปขวา: เงาลึกสุดอยู่ซ้ายสุด, แสงเต็มอยู่ขวาสุด โดยตำแหน่งรอยต่อ band และทุกสีเลือกด้วยมือ ตอนรัน shader ก็ไปหยิบ (sample) จากแถบนี้โดยใช้ N·L เป็นพิกัดแนวนอน

_RampTex (a wide, short strip; painted by an artist) u = 0.0 u = 1.0 +-------+-------+---------+----------+-----------+ | deep | cool | mid | warm | bright | | blue | shadow| tone | light | highlight| +-------+-------+---------+----------+-----------+ shadow colors (cool) | lit colors (warm) sample horizontally at u = NdotL the vertical row v picks the material: skin / cloth / metal

// 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 ตัวเดียว

Tip ตั้ง ramp texture เป็น wrap mode แบบ Clamp (ไม่ใช่ Repeat) แล้วปิด mip maps และ sRGB ถ้า pipeline ของคุณคาดหวัง ramp แบบ linear การ wrap แบบ Repeat จะทำให้สีเงาซ้ายสุดไปปนกับ highlight ขวาสุด และทำให้เกิดการกระพริบตรง terminator

6. Outline วิธีที่ 1: Inverted-Hull Shell

เส้นขอบสีเข้มสะอาด ๆ รอบตัวละครคือครึ่งหนึ่งของลุคอนิเมะ เทคนิคที่ใช้บ่อยที่สุดในเกมคือ inverted hull (เรียกอีกชื่อว่าวิธี "shell" หรือ "backface") ไอเดียคือ วาดตัวละคร สองครั้ง

(side view, camera to the left looking right) STEP 1: normal mesh STEP 2: shell pushed out along drawn with toon shading normals, drawn DARK, only BACK faces kept (Cull Front) .-"""-. .-"""""-. ( body ) (( body )) the dark ring '-...-' '-.....-' peeking out = line On screen: a clean dark outline hugs the 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
}

ปัญหาที่เจอในทางปฏิบัติสองอย่างและวิธีแก้:

Common mistake ลืมใส่ Cull Front ถ้าคุณดันทั้งโมเดลออกแล้ววาดด้านหน้าของมัน shell สีเข้มจะคลุมตัวละครทั้งตัว คุณจะเห็นเป็นก้อนดำ ไม่ใช่เส้นขอบ ต้องเก็บเฉพาะ back faces เพื่อให้โมเดลจริงวาดทับทุกอย่างยกเว้นขอบบาง ๆ

7. Outline วิธีที่ 2: Post-Process Edge Detection

วิธีทำ outline วิธีที่สองไม่แตะโมเดลของตัวละครเลย แต่หลังจากเรนเดอร์ฉากเสร็จ จะมี full-screen pass (shader ที่รันหนึ่งครั้งต่อ pixel บนจอ เหมือนฟิลเตอร์รูป) มองหาขอบ โดยเทียบแต่ละ pixel กับเพื่อนบ้านของมันใน buffer ของ depth และ normal แล้วทาเส้นตรงไหนก็ตามที่เจอการเปลี่ยนแปลงกะทันหัน

Full-screen edge detect: compare a pixel to its neighbors +----+----+----+ | d | d | d | +----+----+----+ | d | C | d | C = current pixel +----+----+----+ d = neighbor depth/normal | d | d | d | +----+----+----+ edge = sum of |C - neighbor| differences big difference (depth cliff or normal flip) => paint a line pixel

// 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 Post-process edge detect where per-object mesh pass one full-screen pass cost draws character twice one pass, whole screen line style hugs silhouette only silhouette + inner creases art control per-material width/color global, harder to tune needs smoothed normals baked depth + normal buffers breaks on hard edges (needs fix) flat details at same depth

เกมอนิเมะส่วนใหญ่ใช้ inverted hull เป็น outline หลัก เพราะมันถูก (ต่อหนึ่งตัวละคร) และให้อาร์ตคุมความหนากับสีต่อ material ได้ง่าย บางทีก็เพิ่ม edge-detect pass เบา ๆ ทับข้างบนเพื่อจับเส้นรายละเอียดข้างในที่ hull เอื้อมไม่ถึง การใช้ทั้งสองอย่างรวมกันเป็นเรื่องปกติ

8. หน้า: SDF Shadow Map เพื่อแก้มอนิเมะที่สะอาด

ลอง shade หน้าอนิเมะด้วย N·L ตรง ๆ ดู มันจะดูผิด: geometry ของหัวสไตไลซ์ที่ต่ำและนุ่มจะให้เงาเป็นด่าง ๆ สั่น ๆ รอบจมูกและเบ้าตา ที่ไต่ยั้วเยี้ยเมื่อแสงขยับ หน้าอนิเมะต้องการรูปทรงเงาที่ สะอาด ตั้งใจ และพลิกคมชัด เมื่อแสงกวาดจากด้านหนึ่งไปอีกด้าน สตูดิโอแก้ด้วย SDF face shadow map

SDF ย่อจาก Signed Distance Field — ในที่นี้คือเท็กซ์เจอร์ grayscale ที่ลงสีบนหน้า โดยค่าที่แต่ละ pixel เข้ารหัสว่า ที่มุมแสงเท่าไหร่ pixel นั้นจะตกเข้าเงา pixel ใกล้จมูกจะมืดเร็ว (แสงเบนนิดเดียวก็เงาแล้ว); pixel ใกล้หูจะสว่างอยู่จนกว่าแสงจะเกือบเฉียงข้าง มันลงสีครั้งเดียวโดยศิลปิน และจับวิธีที่แอนิเมเตอร์จะวาดเงาหน้ากวาดผ่านได้เป๊ะ

Face SDF map (grayscale; darker = falls into shadow sooner) light from LEFT light from FRONT light from RIGHT +------------+ +------------+ +------------+ |#### o o | | o o | | o o ####| |#### __ | | __ | | __ ####| |#### |__| | | |__| | | |__| ####| +------------+ +------------+ +------------+ left half in almost no right half in shadow shadow shadow lit = step(threshold, sdf); threshold comes from the light angle. Flip the map left/right when the light crosses to the other side.

ตอนรัน เราเทียบมุมแนวนอนของแสง (เทียบกับทิศที่หัวหันอยู่) กับค่า 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

Tip โดยปกติหน้ายัง ไม่รับเงา (ignores received shadows) จากผมและสภาพแวดล้อม (หรือรับแบบนุ่ม ๆ ที่อาร์ตกำหนด) เพราะเงาผมที่ฟันผ่านตาอนิเมะดูเหมือนบั๊ก ไม่ใช่ฟีเจอร์ หน้าถูกจัดเป็น material พิเศษที่มีกฎของตัวเอง

9. ผม: Anisotropic Highlight และแถบผม

ผมอนิเมะไม่ได้จุดวาวกลม ๆ เหมือนลูกบิลเลียด มันได้ highlight เป็นแถบ ยาว ๆ ยืด ๆ ที่พาดขวางเส้นผมและเลื่อนไปเมื่อหัวหมุน แถบวาวที่ยืดนั้นคือ anisotropic highlight — "anisotropic" หมายถึงพื้นผิวสะท้อนต่างกันในแต่ละทิศ (ผมเรียบไปตามเส้นแต่ละเส้น แต่ขรุขระเมื่อพาดขวาง)

Normal specular (round) Anime hair highlight (a band) . ============ ..... stretched across the strands, ....... vs follows the tangent T (not N), ..... shifted and masked into a strip . a tight round dot that a long shine that slides moves like a mirror reflection sideways as the head turns

โมเดลมาตรฐานคือ 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 ตัวเดียวกัน

10. Rim Light: ขอบเรืองแสง

Rim light คือเส้นสว่างที่ปรากฏตามขอบตัวละครตรงที่พื้นผิวโค้งหนีกล้อง เหมือนมีแสงนั่งอยู่ข้างหลัง มันทำสองหน้าที่: เพิ่มแสงเรือง ๆ นวล ๆ แบบอนิเมะ และ แยกตัวละครออกจากพื้นหลัง เพื่อไม่ให้ดูเหมือนถูกแปะแบน ๆ ลงบนฉาก

คณิตของมันคือพจน์แบบ Fresnel (Fresnel: พื้นผิวสะท้อนมากขึ้นที่มุมเฉียง — เหตุผลที่ทะเลสาบสะท้อนท้องฟ้าตรงฝั่งไกลแต่ใสตรงเท้าเรา) เราต้องการแค่การประมาณแบบถูก ๆ: rim จะสว่างตรงที่ normal N ตั้งฉากกับทิศมอง V ซึ่งก็คือ silhouette พอดี

Rim light = bright where the normal is sideways to the view. V (direction to camera) ^ | edge | edge N | N center of the shape: \ | / N points toward the camera, N | N dot(N,V) is near 1, N | N rim = 1 - 1 = 0 (no rim) N | N ...... surface ...... silhouette edge: N is nearly sideways to V, dot(N,V) is near 0, rim = 1 - 0 = 1 (bright glow)

// 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 ไปตามทิศแสง เพื่อให้ขอบสว่างอยู่ฝั่งที่หันเข้าหาแสงของฉากเสมอ — รายละเอียดเล็ก ๆ ที่ทำให้ภาพลวงตาสมจริง

Tip rim light ซ้อนกับ ramp และ specular ด้วยการ บวก ไม่ใช่คูณ สีเงาคือการ look up (คูณกับ albedo); rim และ specular คือ แสงที่บวกเข้าไป (พลังงานเพิ่มทับข้างบน) การแยกเรื่องนี้ให้ชัดคือความต่างระหว่าง shader ที่สะอาดกับ shader ที่ขุ่นมัว

11. Specular Highlight แบบสไตไลซ์ (และเกร็ดเรื่อง MatCap)

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;

12. รวมทุกอย่าง: Character Shader ตัวเดียว

ตอนนี้เราประกอบชิ้นส่วนเป็น 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 ใช้ += (พลังงานแสงที่บวกเข้าไป) วินัยข้อเดียวนี้ — คูณสำหรับเงา บวกสำหรับแสง — ทำให้ผลลัพธ์อ่านง่ายไม่ว่าจะซ้อนเอฟเฟกต์กี่ตัว

13. หมายเหตุเชิงปฏิบัติ Unity / URP

ถ้าจะเอาไอเดียข้างบนไปทำเป็นโปรเจกต์ Unity ที่รันได้จริง นี่คือชิ้นส่วนรูปธรรมที่คุณต้องแตะ:

Tip สร้างตามลำดับนี้: (1) ทำ N·L และ Lambert ให้ได้ก่อน, (2) สลับมาใช้ ramp, (3) เพิ่ม inverted-hull outline, (4) เพิ่ม rim, (5) เพิ่ม stylized spec, (6) แล้วค่อยลุยหน้า SDF และผม แต่ละขั้นเห็นผลบนจอ คุณจึงรู้เสมอว่าชิ้นไหนพัง

14. Glossary

15. แบบฝึกหัด

Exercise 1 — จากสอง band เป็นสาม แล้วเป็น ramp เริ่มจากพจน์ toon สองโทนนี้ แล้ว (ก) เขียนใหม่ให้ได้ สาม band แบน ๆ โดยใช้ floor จากนั้น (ข) ทำนายว่า 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 ของคุณ
Show answer

(ก) คูณด้วย 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:

ndl ndl*3 floor /3 band 0.10 0.30 0 0.000 darkest 0.40 1.20 1 0.333 mid 0.70 2.10 2 0.667 bright 0.95 2.85 2 0.667 bright

สังเกตว่า 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 อุ่น ๆ — โดยไม่ต้องแตะโค้ด

Exercise 2 — แก้ outline ที่พัง ศิลปินรายงานว่า inverted-hull outline บนดาบขอบแข็งดูดีตรงพื้นที่เรียบ แต่ แตกเป็นช่องตรงมุม โค้ด vertex ของ outline คือ:

float3 posOS = IN.positionOS.xyz + IN.normalOS * _OutlineWidth;
OUT.positionCS = TransformObjectToHClip(posOS);
อธิบายหนึ่งถึงสองประโยคว่า ทำไม ช่องว่างถึงเกิด และบอกวิธีแก้มาตรฐาน (ไม่ต้องเขียน shader เต็ม — แค่บอกว่าเปลี่ยนข้อมูลอะไรและบรรทัด vertex เปลี่ยนยังไง)
Show answer

ทำไม: ตรงขอบแข็ง โมเดลมี 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 เท่านั้นที่ใช้ตัวเฉลี่ย นี่คือสิ่งที่ตัวละครในเกมอนิเมะที่ปล่อยจริงทำกัน

Exercise 3 — Rim light ฝั่งเงา rim ดีฟอลต์ในหัวข้อ 10 โชว์ที่ฝั่ง สว่าง (rim *= step(0.1, ndl)) ศิลปินแสงต้องการตรงข้าม: rim เย็น ๆ ที่ปรากฏเฉพาะฝั่ง เงา ของตัวละคร เพื่อปลอมแสงที่สะท้อนกลับมาจากสภาพแวดล้อม เขียนพจน์ rim ให้ปรากฏเฉพาะที่พื้นผิวอยู่ในเงา เลือกกำลัง (power) ที่เหมาะสำหรับขอบบาง แล้วไล่ค่าที่ pixel ตรง silhouette ที่อยู่ในเงา (dot(N,V) = 0.05, ndl = 0.0)
Show answer

พลิก 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):

1 - dot(N,V) = 1 - 0.05 = 0.95 pow(0.95, 4) = 0.95*0.95*0.95*0.95 ~ 0.81 (strong rim) step(0.1, ndl=0) = 0 1 - step(...) = 1 (shadow side: keep it) rim final = 0.81 * 1 = 0.81 bright cool edge

ดังนั้น pixel ตรง silhouette นี้ ซึ่งอยู่ในเงา จะได้ rim เย็นแรง ๆ (~0.81 ก่อนคูณสี) ส่วน pixel ตรง silhouette ที่สว่าง (ndl เกิน 0.1) จะคูณด้วย 1 - 1 = 0 และไม่ได้ rim เลย — เป็นเอฟเฟกต์เฉพาะฝั่งเงาอย่างที่ศิลปินขอพอดี

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