2.4 Interpolation, เส้นโค้ง และ Easing

เฟส 2 · คณิตศาสตร์สำหรับเกม · เวลาเรียน: 15–25 h

lerp, smoothstep, เส้นโค้ง Bezier และ spline และฟังก์ชัน easing — วิธีทำให้การเคลื่อนไหว กล้อง และ UI รู้สึกดีแทนที่จะแข็งทื่อ

คุณรู้อยู่แล้วว่าเก็บตำแหน่งเป็น vector ยังไง และย้ายของโดยบวก vector เข้าไปทุกเฟรม บทนี้พูดถึงคำถามที่เล็กกว่าแต่โผล่มาทุกที่พอเริ่มทำ gameplay จริง ๆ: ถ้ามีค่าสองค่า จะผสม (blend) ระหว่างมันแบบนุ่มนวลได้ยังไง แล้วจะปรับรูปทรงของการผสมนั้นให้ movement ดูเป็นธรรมชาติแทนที่จะดูแข็ง ๆ เหมือนหุ่นยนต์ได้ยังไง เกือบทุกอย่างข้างล่างนี้ต่อยอดมาจากสูตรเดียวที่คุณจะพิมพ์เป็นร้อย ๆ ครั้งในโปรเจกต์จริง

รูปแบบเดิมเหมือนเดิม: โค้ดสั้น ๆ, output จริง, แล้วอธิบายแบบเข้าใจง่าย interpolation ดูเหมือนคณิตศาสตร์ไม่มีพิษภัย แต่มันซ่อนหนึ่งในบั๊กที่เจอบ่อยที่สุดในเกมที่ปล่อยจริงเอาไว้ด้วย — หัวข้อ 10 จะพาไปดูรายละเอียด เพราะควรรู้ไว้ก่อนที่คุณจะเขียนกล้อง "smooth follow" ตัวแรกของคุณ

1. Lerp: ผสมค่าสองค่าเข้าด้วยกันด้วย lerp(a, b, t)

Interpolation (การประมาณค่าระหว่างจุด) หมายถึงการคำนวณค่าที่อยู่ระหว่างค่าที่รู้อยู่แล้วสองค่า รูปแบบที่พบบ่อยที่สุดคือ linear interpolation ซึ่งเกือบทุกคนเรียกสั้น ๆ ว่า lerp มันรับค่าเริ่มต้น a, ค่าปลายทาง b, และตัวเลข t (เรียกว่า interpolation parameter) ที่บอกว่าอยากอยู่ตรงไหนของการผสมนั้น สูตรมีบรรทัดเดียว:

lerp(a, b, t) = a + (b - a) * t

อ่าน t เป็นสัดส่วนของระยะทางจาก a ไป b เมื่อ t = 0 สูตรจะให้ค่า a กลับมาเป๊ะ ๆ เมื่อ t = 1 จะได้ b กลับมาเป๊ะ ๆ เมื่อ t = 0.5 จะได้จุดกึ่งกลางพอดี ส่วน t ค่าไหนก็ตามระหว่าง 0 กับ 1 จะให้จุดที่อยู่บนเส้นตรงระหว่าง a กับ b

a = 0 b = 10 |------|------|------|------|------|------|------|------|------|------| 0 1 2 3 4 5 6 7 8 9 10 lerp(a, b, 0.00) = 0 (t=0 -> exactly at a) lerp(a, b, 0.25) = 2.5 (a quarter of the way from a to b) lerp(a, b, 0.50) = 5 (exactly halfway) lerp(a, b, 0.75) = 7.5 (three quarters of the way) lerp(a, b, 1.00) = 10 (t=1 -> exactly at b)

นี่คือสูตรในรูป C++ พร้อม loop ที่สุ่มตัวอย่าง t ห้าค่า:

#include <iostream>

float lerp(float a, float b, float t) {
    return a + (b - a) * t;   // move (b-a) distance, scaled by t
}

int main() {
    float a = 0.0f, b = 10.0f;
    for (int i = 0; i <= 4; i++) {
        float t = i / 4.0f;                  // 0, 0.25, 0.5, 0.75, 1.0
        std::cout << "t=" << t << " lerp=" << lerp(a, b, t) << "\n";
    }
}

ผลลัพธ์:

t=0 lerp=0
t=0.25 lerp=2.5
t=0.5 lerp=5
t=0.75 lerp=7.5
t=1 lerp=10

นี่คือไอเดียทั้งหมด: lerp คือค่าเฉลี่ยถ่วงน้ำหนัก (weighted average) ของ a กับ b โดย t เป็นตัวตัดสินว่าแต่ละฝั่งได้น้ำหนักเท่าไหร่ มันใช้กับ float ได้ และใช้กับ vector ได้ด้วย (คุณ lerp x, y, z แบบเดียวกัน แยกกันทีละแกน) — นี่แหละคือสิ่งที่ฟังก์ชัน "เลื่อนเข้าหาตำแหน่งเป้าหมาย" หรือ "fade สี" ทำอยู่เบื้องหลังใน Vector3.Lerp ของ Unity หรือ FMath::Lerp ของ Unreal

Tip C++20 เพิ่ม std::lerp(a, b, t) ใน <cmath> ซึ่งจัดการ edge case แปลก ๆ ของ floating-point ได้ดีกว่าสูตรบรรทัดเดียวข้างบนเล็กน้อย แต่สูตรบรรทัดเดียวนั้นก็ยังควรจำขึ้นใจ เพราะคุณจะต้องเขียนสูตรเดียวกันเป๊ะ ๆ ใน C#, HLSL, และ GLSL ที่ไม่มีให้ใช้สำเร็จรูป

2. เมื่อ t เกิน 0 กับ 1: clamping และ extrapolation

ไม่มีอะไรในสูตร lerp ที่ห้ามคุณใส่ t ที่น้อยกว่า 0 หรือมากกว่า 1 คณิตศาสตร์ยังทำงานได้ปกติ — แค่ได้จุดที่อยู่ นอก ช่วงจาก a ถึง b เรียกสิ่งนี้ว่า extrapolation

t < 0 ...... a ................ b ...... t > 1 (extrapolated, (extrapolated, lands before a) lands past b) lerp with t=-0.5 lands to the LEFT of a. lerp with t=1.5 lands to the RIGHT of b. clamp01(t) forces t into [0, 1] first, so the result can never leave [a, b].

ส่วนใหญ่คุณไม่อยากได้แบบนั้น — health bar ที่เติมเกิน 100% หรือต่ำกว่า 0% ไม่มีความหมายอะไร วิธีแก้คือ clamp t ให้อยู่ในช่วง [0, 1] ก่อนเอาไปใช้:

#include <iostream>

float lerp(float a, float b, float t) {
    return a + (b - a) * t;
}

float clamp01(float t) {
    if (t < 0.0f) return 0.0f;
    if (t > 1.0f) return 1.0f;
    return t;
}

int main() {
    float a = 0.0f, b = 10.0f;

    std::cout << "no clamp:\n";
    std::cout << "t=-0.5 lerp=" << lerp(a, b, -0.5f) << "\n";
    std::cout << "t=1.5  lerp=" << lerp(a, b, 1.5f)  << "\n";

    std::cout << "with clamp01:\n";
    std::cout << "t=-0.5 lerp=" << lerp(a, b, clamp01(-0.5f)) << "\n";
    std::cout << "t=1.5  lerp=" << lerp(a, b, clamp01(1.5f))  << "\n";
}

ผลลัพธ์:

no clamp:
t=-0.5 lerp=-5
t=1.5  lerp=15
with clamp01:
t=-0.5 lerp=0
t=1.5  lerp=10

ถ้าไม่ clamp t=-0.5 จะได้ -5 (ต่ำกว่า a) และ t=1.5 จะได้ 15 (สูงกว่า b) พอใส่ clamp01 ก่อน ทั้งสองค่าจะถูกดึงกลับมาที่ขอบใกล้สุด ฟังก์ชัน lerp ของ engine ส่วนใหญ่ (เช่น Mathf.Lerp ของ Unity) จะ clamp t ให้อัตโนมัติ ส่วนฟังก์ชันที่ชื่อลงท้ายด้วย LerpUnclamped จะไม่ clamp ให้

Tip extrapolation ไม่ได้เป็นบั๊กเสมอไป easing curve แบบ "overshoot" หรือ "back" (เช่น panel UI ที่เลื่อนเลยตำแหน่งพักไปนิดหน่อยก่อนจะนิ่ง) จงใจใช้ค่านอกช่วง [0, 1] เพื่อให้ได้ความรู้สึกเด้ง ๆ มันจะเป็นบั๊กก็ต่อเมื่อคุณไม่ได้ตั้งใจให้เกิดแบบนั้น — ดังนั้นควรรู้ว่า lerp call ไหนของคุณ clamp แล้ว และไหนยังไม่ได้ clamp

3. Inverse lerp: ย้อนกลับจากค่าไปหา t

lerp ตอบคำถามว่า "ถ้ามี a, b, และ t แล้วค่าคืออะไร?" ส่วน inverse lerp ตอบคำถามกลับด้าน: "ถ้ามี a, b, และค่าที่อยู่ระหว่างมันสองค่า t ที่ทำให้ได้ค่านั้นคือเท่าไหร่?" คุณแก้สมการ lerp หา t:

inverseLerp(a, b, value) = (value - a) / (b - a)
#include <iostream>

float inverseLerp(float a, float b, float value) {
    return (value - a) / (b - a);   // what fraction of the way is value?
}

int main() {
    std::cout << "t=" << inverseLerp(0.0f, 10.0f, 2.5f) << "\n";
    std::cout << "t=" << inverseLerp(0.0f, 10.0f, 10.0f) << "\n";
    std::cout << "t=" << inverseLerp(100.0f, 200.0f, 150.0f) << "\n";
}

ผลลัพธ์:

t=0.25
t=1
t=0.5

2.5 คือหนึ่งในสี่ของทางจาก 0 ไป 10 เลย t=0.25 10 อยู่ที่ b พอดี เลย t=1 150 อยู่กึ่งกลางพอดีระหว่าง 100 กับ 200 เลย t=0.5 นี่คือวิธีแปลงค่าดิบในเกม (health ปัจจุบัน, volume ปัจจุบัน, ตำแหน่ง x ของเมาส์) ให้กลายเป็นเศษส่วน 0..1 ที่สะอาด เอาไปใช้ได้กับทุกอย่าง — ปริมาณเติมของ bar, ค่า alpha, หรือ parameter ของ lerp ตัวอื่นต่อ

Common mistake ถ้า a กับ b เท่ากัน inverseLerp จะหารด้วยศูนย์ แล้วได้ NaN หรือ infinity ใน C++ เรื่องนี้เกิดบ่อยกว่าที่คิด — เช่น health bar ที่ minHealth กับ maxHealth ดันเท่ากันโดยไม่ตั้งใจ ควรกันเคสนี้ไว้ถ้า a == b มีโอกาสเกิดขึ้นได้

4. Remap: กระโดดจาก range หนึ่งไปอีก range หนึ่ง

Remap รวมสองไอเดียก่อนหน้าเข้าด้วยกัน: หาว่าค่านั้นอยู่ตรงไหนของ range ต้นทาง ด้วย inverseLerp แล้วเอาสัดส่วนนั้นไป lerp เข้า range ปลายทาง ที่ต่างออกไป

#include <iostream>

float lerp(float a, float b, float t) {
    return a + (b - a) * t;
}

float inverseLerp(float a, float b, float value) {
    return (value - a) / (b - a);
}

float remap(float value, float inMin, float inMax, float outMin, float outMax) {
    float t = inverseLerp(inMin, inMax, value);
    return lerp(outMin, outMax, t);
}

int main() {
    // joystick tilt -1..1 mapped to a turn speed of -90..90 degrees per second
    std::cout << "turnSpeed=" << remap(0.5f, -1.0f, 1.0f, -90.0f, 90.0f) << "\n";
    // health 0..100 mapped to a health-bar fill width of 0..200 pixels
    std::cout << "barWidth=" << remap(30.0f, 0.0f, 100.0f, 0.0f, 200.0f) << "\n";
}

ผลลัพธ์:

turnSpeed=45
barWidth=60

joystick เอียง 0.5 คือ 75% ของทางจาก -1 ไป 1 และ 75% ของทางจาก -90 ไป 90 คือ 45 health 30 จาก 100 คือ 30% ของทาง และ 30% ของ 0..200 pixel คือ 60 remap เป็นหนึ่งใน utility function ที่ถูกใช้บ่อยที่สุดในโค้ดเกมจริง เวลาไหนที่คุณต้องแปลงค่าจาก "หน่วยที่มันมาเป็นธรรมชาติ" ไปเป็น "หน่วยที่อีกที่ต้องการ" นี่คือเครื่องมือที่ใช้

5. Smoothstep: เริ่มนุ่ม ๆ หยุดนุ่ม ๆ

lerp ธรรมดาเคลื่อนที่ด้วยอัตราคงที่ — t ขยับเท่ากันทุกครั้ง ผลลัพธ์ก็ขยับเท่ากันทุกครั้ง สำหรับข้อมูลทั่วไปแบบนี้ไม่มีปัญหา แต่สำหรับอะไรก็ตามที่ผู้เล่น มองเห็น อัตราคงที่จะดูแข็ง ๆ เหมือนเครื่องจักร เพราะแทบไม่มีอะไรในโลกจริงที่เริ่มหรือหยุดเคลื่อนที่แบบทันทีทันใด Smoothstep คือการปรับเล็ก ๆ ที่แก้ปัญหานี้: แทนที่จะป้อน t ตรง ๆ เข้า lerp คุณปรับรูปทรงมันก่อนด้วยสูตรนี้ แล้วค่อย lerp ด้วยค่าที่ปรับแล้ว:

smoothstep(t) = t * t * (3 - 2 * t)
#include <iostream>

float smoothstep(float t) {
    return t * t * (3.0f - 2.0f * t);
}

int main() {
    for (int i = 0; i <= 4; i++) {
        float t = i / 4.0f;
        std::cout << "t=" << t << " smoothstep=" << smoothstep(t) << "\n";
    }
}

ผลลัพธ์:

t=0 smoothstep=0
t=0.25 smoothstep=0.15625
t=0.5 smoothstep=0.5
t=0.75 smoothstep=0.84375
t=1 smoothstep=1
t 0.00 0.25 0.50 0.75 1.00 lerp(t) 0.00 0.25 0.50 0.75 1.00 <-- straight ramp, constant speed smoothstep(t) 0.00 0.15625 0.50 0.84375 1.00 <-- flat at both ends, steep in the middle near t=0 and t=1, smoothstep barely changes (a gentle start and a gentle stop). near t=0.5, smoothstep moves faster than plain lerp to make up the difference.

เทียบสองแถวนี้: ที่ t=0.25 lerp ธรรมดาไปแล้วหนึ่งในสี่ของทาง แต่ smoothstep ไปแค่ 0.15625 — มันค่อย ๆ เริ่ม (ease in) อย่างนุ่มนวล พอใกล้กลางมันจะเร็วขึ้น แล้วพอใกล้ t=1 มันก็ค่อย ๆ ชะลอ (ease out) อย่างนุ่มนวลอีกครั้ง ในทางคณิตศาสตร์ อัตราการเปลี่ยนแปลง (ความชัน) ของ smoothstep เป็นศูนย์พอดีทั้งที่ t=0 และ t=1 นี่คือเหตุผลที่การเริ่มกับการหยุดรู้สึกนุ่มนวลแทนที่จะกระชาก สูตรเดียวนี้ถูกใช้บ่อยมากสำหรับ fade (จอมืดค่อย ๆ ดับ), การ blend field-of-view ของกล้อง, และค่า UI ไหนก็ตามที่ไม่ควรดู "สะดุด" ตอนเริ่มขยับ

6. Easing: ease-in, ease-out, และ ease-in-out

Easing function คือตระกูลใหญ่ที่ smoothstep เป็นสมาชิกอยู่ด้วย: ฟังก์ชันไหนก็ตามที่ปรับรูปทรง t ก่อนส่งเข้า lerp เพื่อให้ movement ไม่เคลื่อนที่ด้วยความเร็วคงที่ มีสามชื่อที่เจอบ่อยมาก:

เวอร์ชัน quadratic ง่าย ๆ ของแต่ละแบบ:

easeIn(t)  = t * t
easeOut(t) = 1 - (1 - t) * (1 - t)
t 0.0 0.2 0.4 0.6 0.8 1.0 linear 0.0 0.2 0.4 0.6 0.8 1.0 -- constant speed, feels robotic ease-in 0.0 0.04 0.16 0.36 0.64 1.0 -- starts slow, speeds up near the end ease-out 0.0 0.36 0.64 0.84 0.96 1.0 -- starts fast, slows down near the end look at t=0.2 (20% of the time has passed): linear has covered 20% of the distance ease-in has covered only 4% (still winding up) ease-out has covered 36% (already moving quickly)
#include <iostream>

float easeIn(float t)  { return t * t; }
float easeOut(float t) { return 1.0f - (1.0f - t) * (1.0f - t); }
float easeInOut(float t) {
    if (t < 0.5f) return 2.0f * t * t;
    float u = -2.0f * t + 2.0f;
    return 1.0f - (u * u) / 2.0f;
}

int main() {
    for (int i = 0; i <= 5; i++) {
        float t = i / 5.0f;
        std::cout << "t=" << t
                   << " in="    << easeIn(t)
                   << " out="   << easeOut(t)
                   << " inout=" << easeInOut(t) << "\n";
    }
}

ผลลัพธ์:

t=0 in=0 out=0 inout=0
t=0.2 in=0.04 out=0.36 inout=0.08
t=0.4 in=0.16 out=0.64 inout=0.32
t=0.6 in=0.36 out=0.84 inout=0.68
t=0.8 in=0.64 out=0.96 inout=0.92
t=1 in=1 out=1 inout=1

ทำไมต้องสนใจเรื่องนี้? เพราะของจริงในโลกฟิสิกส์แทบไม่เคยเคลื่อนที่ด้วยความเร็วคงที่เป๊ะ ๆ — มันเร่งและชะลอเพราะ inertia (ความเฉื่อย) กับแรงเสียดทาน panel UI ที่เลื่อนเข้ามาด้วยความเร็วคงที่แล้วหยุดกึกทันทีจะดู "เหมือนคอมพิวเตอร์" panel เดียวกันที่ทำ ease-out (เริ่มเร็ว ค่อย ๆ นิ่ง) จะดูตอบสนองไวและมีชีวิตชีวา นี่ไม่ใช่แค่ของตกแต่ง — นักทำ animation และ UI designer มองว่า easing เป็นหนึ่งในตัวแปรใหญ่ที่สุดที่ทำให้ movement รู้สึกดี เพราะมันเลียนแบบพฤติกรรมของมวลกับแรง

Tip ease-out เป็นค่าเริ่มต้นที่ใช้บ่อยที่สุดสำหรับ UI motion (เมนูเปิด, ปุ่มถูกกด) เพราะการเริ่มเร็วให้ความรู้สึกตอบสนองต่ออินพุตทันที และการค่อย ๆ นิ่งตอนท้ายให้ความรู้สึกควบคุมได้แทนที่จะกระชาก ส่วน ease-in เดี่ยว ๆ (เริ่มช้า จบเร็ว) ใช้เดี่ยว ๆ น้อยกว่า เพราะการเริ่มช้าอาจรู้สึกหน่วง — มักถูกเอาไปรวมเป็น ease-in-out แทน

7. Bezier curve ภาค 1: quadratic curve กับ de Casteljau's algorithm

จนถึงตอนนี้ทุกค่าที่เราผสมเป็นแค่ตัวเลขตัวเดียวผสมไปหาอีกตัวเลขหนึ่ง Bezier curve (ตั้งชื่อตาม Pierre Bezier) เอาไอเดียของ lerp แบบเดียวกันมาใช้กับ จุดในพื้นที่ โดยใช้ control point กลุ่มเล็ก ๆ มาช่วยกำหนดรูปทรงของ curve ที่นุ่มนวล แบบที่ง่ายและใช้งานได้จริงที่สุดคือ quadratic Bezier ซึ่งใช้ control point สามจุด: P0 (จุดเริ่ม), P1 (จุด "ดึง"), และ P2 (จุดจบ)

P1 * . . . . . . . . P0 * . . . . * P2 P0, P1, P2 are the CONTROL POINTS (the dots trace the control polygon, not the curve itself -- the actual curve is a smooth arc that starts at P0, ends at P2, and bulges toward P1 without ever touching it).

De Casteljau's algorithm สร้างจุดบน curve นั้นโดยใช้แค่การเรียก lerp ซ้ำ ๆ เท่านั้น — ไม่ต้องจำสูตรใหม่เลย ใช้ building block ตัวเดียวกับหัวข้อ 1 แค่เรียกสองรอบ:

step 1 (lerp along each edge of the control polygon): A = lerp(P0, P1, t) t of the way from P0 to P1 B = lerp(P1, P2, t) t of the way from P1 to P2 step 2 (lerp between the two new points): Q = lerp(A, B, t) t of the way from A to B <-- Q sits ON the curve
#include <iostream>

struct Vec2 { float x, y; };

Vec2 lerpVec2(Vec2 a, Vec2 b, float t) {
    return { a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t };
}

Vec2 quadraticBezier(Vec2 p0, Vec2 p1, Vec2 p2, float t) {
    Vec2 a = lerpVec2(p0, p1, t);   // t of the way along edge p0-p1
    Vec2 b = lerpVec2(p1, p2, t);   // t of the way along edge p1-p2
    return lerpVec2(a, b, t);       // t of the way along a-b, this is the curve point
}

int main() {
    Vec2 p0 = {0.0f, 0.0f};
    Vec2 p1 = {5.0f, 10.0f};
    Vec2 p2 = {10.0f, 0.0f};

    for (int i = 0; i <= 4; i++) {
        float t = i / 4.0f;
        Vec2 p = quadraticBezier(p0, p1, p2, t);
        std::cout << "t=" << t << " x=" << p.x << " y=" << p.y << "\n";
    }
}

ผลลัพธ์:

t=0 x=0 y=0
t=0.25 x=2.5 y=3.75
t=0.5 x=5 y=5
t=0.75 x=7.5 y=3.75
t=1 x=10 y=0

ไล่ดูรูปทรง: มันเริ่มที่ (0, 0) ซึ่งคือ p0 จบที่ (10, 0) ซึ่งคือ p2 และจุดสูงสุดอยู่ตรงกลาง (5, 5) — ถูกดึงไปครึ่งทางหา p1 = (5, 10) แต่ไม่เคยไปถึงจริง ๆ พฤติกรรม "ถูกดึงไปหาแต่ไม่เคยแตะ" แบบนี้แหละที่ทำให้ control point จับวางด้วยมือได้ง่าย ๆ: ลาก p1 ไปตรงไหน curve ก็จะโค้งเข้าหาตรงนั้น

8. Bezier curve ภาค 2: cubic curve และที่ใช้งานจริง

cubic Bezier ใช้ control point สี่จุด P0 ถึง P3 และสร้างด้วยไอเดีย de Casteljau แบบเดียวกันเป๊ะ ๆ — แค่ lerp เพิ่มอีกรอบเดียว เพราะมี control point เพิ่มมาอีกจุด:

4 control points shape a cubic curve: P0, P1, P2, P3 level 1 (3 points): A = lerp(P0,P1,t) B = lerp(P1,P2,t) C = lerp(P2,P3,t) level 2 (2 points): D = lerp(A,B,t) E = lerp(B,C,t) level 3 (1 point): Q = lerp(D,E,t) <-- point on the cubic curve
#include <iostream>

struct Vec2 { float x, y; };

Vec2 lerpVec2(Vec2 a, Vec2 b, float t) {
    return { a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t };
}

Vec2 cubicBezier(Vec2 p0, Vec2 p1, Vec2 p2, Vec2 p3, float t) {
    Vec2 a = lerpVec2(p0, p1, t);
    Vec2 b = lerpVec2(p1, p2, t);
    Vec2 c = lerpVec2(p2, p3, t);
    Vec2 d = lerpVec2(a, b, t);
    Vec2 e = lerpVec2(b, c, t);
    return lerpVec2(d, e, t);       // point on the cubic curve
}

int main() {
    Vec2 p0 = {0.0f, 0.0f};
    Vec2 p1 = {0.0f, 10.0f};
    Vec2 p2 = {10.0f, 10.0f};
    Vec2 p3 = {10.0f, 0.0f};

    Vec2 mid = cubicBezier(p0, p1, p2, p3, 0.5f);
    std::cout << "t=0.5 x=" << mid.x << " y=" << mid.y << "\n";
}

ผลลัพธ์:

t=0.5 x=5 y=7.5

เมื่อ p1 กับ p2 ดึงขึ้นด้านบนทั้งคู่ จุดกึ่งกลางของ curve จะอยู่ที่ y=7.5 สูงกว่าเส้นตรงระหว่าง p0 กับ p3 (ซึ่งจะได้ y=0) การมี control point สองจุดแทนที่จะเป็นจุดเดียวทำให้ได้ curve รูปตัว S หรือรูปทรงไหนก็ตามที่ทิศทาง tangent ตอนเริ่มกับตอนจบต้องชี้คนละทาง

Bezier curve โผล่มาที่ไหนบ้างใน engine จริง

cubic Bezier อยู่ทุกที่พอคุณรู้จะมองหา: path ของ SVG กับโปรแกรมวาด vector (pen tool ของ Illustrator คือการวาง control point ของ Bezier), outline ของ font (font แบบ TrueType กับ PostScript เก็บรูปทรงตัวอักษรเป็น Bezier curve), และ path ของกล้อง dolly ในเครื่องมือทำ cutscene ที่สำคัญที่สุดสำหรับคนเขียนเกม: animation curve editor ที่คุณเห็นใน Unity (AnimationCurve) และ Unreal (curve editor ของ timeline) เบื้องหลังคือลำดับของ cubic Bezier segment — "tangent handle" เล็ก ๆ ที่ลากได้บนแต่ละ keyframe ก็คือ control point P1 กับ P2 ของ segment นั้นนั่นเอง ทุก custom easing curve ที่คุณเคยลากในเครื่องมือ animation คือ de Casteljau's algorithm ที่กำลังรันอยู่ใต้เมาส์ของคุณ

9. Catmull-Rom spline: curve ที่ลากผ่านทุกจุด

Bezier curve มีข้อเสียอย่างหนึ่งเวลาใช้สร้าง path: curve จะแตะแค่ control point ตัวแรกกับตัวสุดท้ายเท่านั้น — ตัวกลาง ๆ แค่ดึงมันเฉย ๆ ถ้าคุณอยากได้ path ที่นุ่มนวลและลากผ่าน waypoint ทุกจุดในลิสต์แบบเป๊ะ ๆ (เส้นแข่งผ่าน checkpoint, path กล้องผ่านตำแหน่งที่มาร์กไว้) Bezier เพียว ๆ ไม่ใช่เครื่องมือที่ใช่ Catmull-Rom spline ถูกสร้างมาเพื่อสิ่งนี้โดยเฉพาะ: กำหนดจุดสี่จุด P0, P1, P2, P3 มันจะวาด curve segment ระหว่าง P1 กับ P2 ที่ลากผ่านทั้งสองจุดนั้น โดยใช้ P0 กับ P3 แค่เพื่อตัดสินว่า curve กำลังมุ่งหน้าไปทิศไหนตอนเข้าและตอนออก (คือ tangent ของมัน)

waypoints: P0 P1 P2 P3 * *. . . . . . . . . .* * the dotted segment between P1 and P2 is the part of the curve that gets drawn. It passes exactly through P1 and through P2. P0 and P3 are only used to work out the tangent (the direction the curve is heading) -- the curve never touches P0 or P3 for this segment.
#include <iostream>

struct Vec2 { float x, y; };

Vec2 catmullRom(Vec2 p0, Vec2 p1, Vec2 p2, Vec2 p3, float t) {
    float t2 = t * t;
    float t3 = t2 * t;
    float x = 0.5f * (2.0f * p1.x + (-p0.x + p2.x) * t +
              (2.0f * p0.x - 5.0f * p1.x + 4.0f * p2.x - p3.x) * t2 +
              (-p0.x + 3.0f * p1.x - 3.0f * p2.x + p3.x) * t3);
    float y = 0.5f * (2.0f * p1.y + (-p0.y + p2.y) * t +
              (2.0f * p0.y - 5.0f * p1.y + 4.0f * p2.y - p3.y) * t2 +
              (-p0.y + 3.0f * p1.y - 3.0f * p2.y + p3.y) * t3);
    return { x, y };
}

int main() {
    Vec2 p0 = {0.0f, 0.0f};
    Vec2 p1 = {2.0f, 0.0f};
    Vec2 p2 = {4.0f, 2.0f};
    Vec2 p3 = {6.0f, 2.0f};

    Vec2 atStart = catmullRom(p0, p1, p2, p3, 0.0f);
    Vec2 atEnd   = catmullRom(p0, p1, p2, p3, 1.0f);
    std::cout << "t=0 x=" << atStart.x << " y=" << atStart.y << "\n";
    std::cout << "t=1 x=" << atEnd.x   << " y=" << atEnd.y   << "\n";
}

ผลลัพธ์:

t=0 x=2 y=0
t=1 x=4 y=2

ที่ t=0 curve อยู่ที่ (2, 0) พอดี ซึ่งคือ p1 ที่ t=1 อยู่ที่ (4, 2) พอดี ซึ่งคือ p2 การการันตีว่า "ลากผ่านเสมอ" แบบนี้คือประเด็นทั้งหมด: ต่อ waypoint มากพอ แล้วเรียกฟังก์ชันนี้ทีละกลุ่มสี่จุดที่ติดกัน คุณจะได้ curve นุ่มนวลต่อเนื่องเส้นเดียวที่ร้อยผ่านทุก waypoint โดยไม่ต้องมานั่งวาง tangent handle เอง นี่คือเครื่องมือมาตรฐานสำหรับเส้นกึ่งกลางของสนามแข่ง และ path ลาดตระเวน/กล้องที่สร้างจากลิสต์จุดที่ designer วางไว้

10. บั๊กคลาสสิก: smoothing ที่ขึ้นกับ frame rate

มาถึงส่วนของบทนี้ที่ควรจำให้ขึ้นใจมากกว่าส่วนอื่น โค้ด "smoothing" ที่เจอบ่อยมากหน้าตาแบบนี้ เขียนครั้งเดียวแล้วเรียกทุกเฟรมใน Update() หรือ Tick():

position = lerp(position, target, 0.1f);   // "move 10% closer to the target, every frame"

ดูเผิน ๆ เหมือนไม่มีพิษภัย แล้วมันก็ทำให้ movement ดูนุ่มนวลจริง ๆ ด้วย บั๊กอยู่ตรงที่ผลลัพธ์ขึ้นอยู่กับว่าบรรทัดนี้ถูกรันกี่ครั้งต่อวินาที — หมายความว่าโค้ดชุดเดียวกันให้ gameplay ต่างกันบนฮาร์ดแวร์ต่างกัน หรือแม้แต่บนฮาร์ดแวร์เดียวกันตอนโหลดหนักไม่เท่ากัน แต่ละครั้งที่เรียก ช่องว่างที่เหลือจะหดเหลือ 90% ของเดิม ดังนั้นหลังเรียก N ครั้ง ช่องว่างที่เหลือคือ 0.9^N ของช่องว่างตอนแรก

position = lerp(position, target, 0.1) called ONCE PER FRAME, starting gap = 100 remaining gap after exactly 1 second of real time: at 30 fps (30 calls that second): 100 * 0.9^30 ~= 4.24 (96% closed) at 60 fps (60 calls that second): 100 * 0.9^60 ~= 0.18 (99.8% closed) at 120 fps (120 calls that second): 100 * 0.9^120 ~= 0.0003 (basically all closed) same code, same "0.1", same real second -- three different results. on a faster machine the object visibly snaps to the target quicker. that difference in FEEL and in GAMEPLAY is the bug.
#include <iostream>
#include <iomanip>

double naiveSmooth(double dt, int frames) {
    double position = 0.0, target = 100.0;
    for (int i = 0; i < frames; i++)
        position = position + (target - position) * 0.1;   // move 10% closer, every frame
    return position;
}

int main() {
    std::cout << std::fixed << std::setprecision(4);
    std::cout << "30fps position after 1s = " << naiveSmooth(1.0 / 30.0, 30) << "\n";
    std::cout << "60fps position after 1s = " << naiveSmooth(1.0 / 60.0, 60) << "\n";
}

ผลลัพธ์:

30fps position after 1s = 95.7609
60fps position after 1s = 99.8203

ทั้งสองรันจำลองเวลาจริงหนึ่งวินาทีเป๊ะ ๆ เริ่มจากระยะห่างเท่ากันจากเป้าหมาย ใช้ factor 0.1 ตัวเดียวกันเป๊ะ ต่างกันแค่ว่ามีกี่เฟรมยัดอยู่ในวินาทีนั้น ที่ 60fps ตัว object ไปถึง 99.82% ของทาง ที่ 30fps ไปถึงแค่ 95.76% ของทาง บนเครื่องช้า "smoothing" นี้จะดูหน่วงเห็นชัด บนเครื่องเร็วจะดูกระชากไปหาเป้าหมายเห็นชัด แถมยังแย่กว่านั้น ถ้า frame rate ของคุณแกว่งไปมาทุกขณะ (เฟรมตกตอนฉากยุ่ง ๆ) ความเร็วของ object ที่วิ่งเข้าหาเป้าหมายก็จะเปลี่ยนไปด้วย — โดยไม่มีการแก้โค้ด ไม่มีเหตุผลใน bug report มีแค่ frame timing ที่ไม่คงที่

Common mistake เขียน position = lerp(position, target, someConstant) ใน Update() แล้วเรียกมันว่า "smoothing" หรือ "damping" โดยไม่เคยคูณด้วย deltaTime เลย นี่น่าจะเป็นโค้ดที่ขึ้นกับ frame rate ที่ถูก copy-paste กันมากที่สุดในเกมที่ปล่อยจริง — camera follow, ค่า UI ที่นับขึ้น, aim assist, และทิศทางหันหน้าของศัตรู ต่างก็ถูกเขียนแบบนี้โดยไม่ตั้งใจกันทั้งนั้น

11. วิธีแก้: smoothing ด้วย deltaTime

Delta time (deltaTime หรือ dt) คือเวลาจริงเป็นวินาทีที่ผ่านไปตั้งแต่เฟรมที่แล้ว — มันคือสิ่งที่ทำให้ movement ไม่ขึ้นกับ frame rate ในที่อื่น ๆ ทุกที่ (position += velocity * deltaTime) และ smoothing ก็ต้องการการดูแลแบบเดียวกัน ทริกคือกำหนด half-life: กี่วินาทีที่ควรใช้ให้ช่องว่างที่เหลือหดลงครึ่งหนึ่ง ไม่ว่า frame rate จะเท่าไหร่ก็ตาม แล้วคำนวณ blend factor ต่อเฟรมจาก deltaTime แทนที่จะใช้ค่าคงที่ตายตัว:

t = 1 - pow(0.5, deltaTime / halfLife)
position = lerp(position, target, t)
#include <iostream>
#include <iomanip>
#include <cmath>

double fixedSmooth(double dt, int frames, double halfLife) {
    double position = 0.0, target = 100.0;
    for (int i = 0; i < frames; i++) {
        double t = 1.0 - std::pow(0.5, dt / halfLife);   // frame-rate independent factor
        position = position + (target - position) * t;
    }
    return position;
}

int main() {
    std::cout << std::fixed << std::setprecision(4);
    std::cout << "30fps position after 1s = " << fixedSmooth(1.0 / 30.0, 30, 0.1) << "\n";
    std::cout << "60fps position after 1s = " << fixedSmooth(1.0 / 60.0, 60, 0.1) << "\n";
}

ผลลัพธ์:

30fps position after 1s = 99.9023
60fps position after 1s = 99.9023

เวลาจริงหนึ่งวินาทีเท่าเดิม ระยะเริ่มต้นเท่าเดิม แต่ตอนนี้ frame rate ทั้งสองแบบได้ผลลัพธ์เดียวกันเป๊ะ ด้วย halfLife = 0.1 ช่องว่างจะหดลงครึ่งหนึ่งทุก ๆ 0.1 วินาที — ในหนึ่งวินาทีเต็มก็คือหดลงครึ่งหนึ่ง 10 รอบ ดังนั้น 0.5^10 = 0.0009765625 ของช่องว่างที่ยังเหลืออยู่ หรือปิดไปแล้ว 99.9023% ไม่ว่าวินาทีนั้นจะถูกหั่นเป็น 30 เฟรมหรือ 60 เฟรมก็ตาม

ทำไมทริก half-life ถึงไม่ขึ้นกับ frame rate

เหตุผลที่วิธีนี้ได้ผลแบบเป๊ะ ๆ ไม่ใช่แค่ใกล้เคียง เป็นเพราะ pow(0.5, x) มีคุณสมบัติแบบ multiplicative (คูณต่อกันได้): การหดด้วย pow(0.5, dt1) แล้วหดต่อด้วย pow(0.5, dt2) ให้ผลรวมเท่ากับหดทีเดียวด้วย pow(0.5, dt1 + dt2) ไม่ว่าจะหั่นหนึ่งวินาทีเป็น 30 เฟรมเท่า ๆ กัน หรือ 60 เฟรมเท่า ๆ กัน แล้วคูณ shrink factor ต่อเฟรมกี่ครั้งก็ตาม ผลรวมสุดท้ายจะเท่ากันเสมอ เพราะชิ้นส่วนทั้งหมดบวกกลับมารวมเป็นหนึ่งวินาทีเท่าเดิมเสมอ ค่าคงที่ 0.9 แบบเก่าไม่มีคุณสมบัตินี้ — มันเป็นการหดคงที่ ต่อการเรียกหนึ่งครั้ง ไม่ใช่ ต่อวินาที ดังนั้นยิ่งเรียกบ่อยในวินาทีเดียวกัน ช่องว่างก็ยิ่งหดมากขึ้น

Tip ใน Update() จริง ๆ dt คือ Time.deltaTime (Unity) หรือ DeltaSeconds ของ tick (Unreal) ซึ่งขยับนิดหน่อยทุกเฟรม — ไม่เป็นไร สูตรนี้ยังทำงานได้ปกติเพราะมันคำนวณ t ใหม่จากเวลาที่ผ่านไปจริงทุกครั้งที่เรียก แทนที่จะสมมติว่าความยาวเฟรมคงที่ ให้เลือก halfLife ที่มีความหมายกับคุณจริง ๆ ("ระยะครึ่งหนึ่งปิดภายใน 0.15 วินาที") แทนที่จะไปจูนค่าคงที่ 0.1 ดิบ ๆ ที่บังเอิญดูโอเคแค่ที่ frame rate ที่คุณทดสอบเท่านั้น

12. Glossary

13. แบบฝึกหัด

Exercise 1 health bar ควรแสดงความกว้างของการเติม 0 pixel ที่ health 0 และ 300 pixel ที่ health 100 โดยใช้ remap(value, inMin, inMax, outMin, outMax) จากหัวข้อ 4 คำนวณด้วยมือหาความกว้างของการเติมเมื่อ health เท่ากับ 45 แสดง t ตรงกลางที่ได้จาก inverseLerp ด้วย
Show answer

หา t ก่อน: inverseLerp(0, 100, 45) = (45 - 0) / (100 - 0) = 0.45 แล้ว lerp(0, 300, 0.45) = 0 + (300 - 0) * 0.45 = 135 ความกว้างของการเติมคือ 135 pixel

Exercise 2 เกมหนึ่งใช้ naive smoothing จากหัวข้อ 10: position = lerp(position, target, 0.2) เรียกครั้งเดียวทุกเฟรม เริ่มที่ position = 0 โดยมี target = 50 และเกมรันที่ 10 fps คงที่ ใช้สูตรทบต้น (1 - 0.2)^N คำนวณตำแหน่งหลังผ่านไป 1 วินาทีเป๊ะ (10 เฟรม) จากนั้นโดยไม่ต้องคำนวณใหม่ ให้บอกว่าตำแหน่งหลัง 1 วินาทีจะ สูงกว่า, ต่ำกว่า, หรือ เท่าเดิม ถ้าเกมรันที่ 60fps แทน พร้อมอธิบายเหตุผลหนึ่งประโยค
Show answer

หลัง 10 เฟรม เศษส่วนของช่องว่างที่เหลือคือ 0.8^10 = 0.1073741824 ดังนั้นตำแหน่งคือ 50 * (1 - 0.1073741824) = 44.63 (ปัดแล้ว) ที่ 60fps ตำแหน่งหลัง 1 วินาทีจะ สูงกว่า (ใกล้ 50 มากกว่า) เพราะมีเฟรมรันในวินาทีเดียวกันมากกว่าถึง 6 เท่า และแต่ละเฟรมก็ปิดช่องว่างที่เหลืออยู่อีก 20% — ยิ่งทบต้นหลายรอบในเวลาจริงเท่าเดิม ยิ่งปิดระยะทางรวมได้มากขึ้น นี่คือบั๊กที่ขึ้นกับ frame rate จากหัวข้อ 10 เป๊ะ ๆ เลย

Exercise 3 ใช้ de Casteljau's algorithm จากหัวข้อ 7 คำนวณจุดบน quadratic Bezier curve ที่มี control point P0 = (0, 0), P1 = (4, 8), P2 = (8, 0) ที่ t = 0.5 แสดงจุดกลาง A กับ B ก่อนจุดสุดท้าย Q
Show answer

A = lerp(P0, P1, 0.5) = (0 + (4-0)*0.5, 0 + (8-0)*0.5) = (2, 4) B = lerp(P1, P2, 0.5) = (4 + (8-4)*0.5, 8 + (0-8)*0.5) = (6, 4) Q = lerp(A, B, 0.5) = (2 + (6-2)*0.5, 4 + (4-4)*0.5) = (4, 4) จุดบน curve ที่ t=0.5 คือ (4, 4) — ถูกดึงขึ้นเหนือเส้นตรงระหว่าง P0 กับ P2 (ซึ่งจะได้ (4, 0)) ไปทาง P1 แต่ไม่ได้ใกล้จะไปถึงเลย

นี่คือกล่องเครื่องมือของ interpolation ทั้งหมด: lerp เป็นสูตรตัวเดียวที่ทุกอย่างอื่นต่อยอดมา inverseLerp กับ remap สำหรับแปลงค่าข้าม range smoothstep กับ easing สำหรับ movement ที่รู้สึกมีชีวิตแทนที่จะแข็งเหมือนเครื่องจักร Bezier กับ Catmull-Rom curve สำหรับสร้างรูปทรง path จากจุดไม่กี่จุด และ — ตัวที่ควรจำไว้ใช้งานจริงที่สุด — ควรคำนวณ smoothing factor ต่อเฟรมจาก deltaTime เสมอ อย่าใช้ค่าคงที่ดิบ ๆ ครั้งหน้าที่คุณเขียน lerp(position, target, someNumber) ในลูป update บทนี้แหละคือเหตุผลที่ควรหยุดคิดสักนิดแล้วถามตัวเองว่าตัวเลขนั้นถูกจูนไว้ที่ frame rate เท่าไหร่

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