คุณรู้อยู่แล้วว่าเก็บตำแหน่งเป็น vector ยังไง และย้ายของโดยบวก vector เข้าไปทุกเฟรม บทนี้พูดถึงคำถามที่เล็กกว่าแต่โผล่มาทุกที่พอเริ่มทำ gameplay จริง ๆ: ถ้ามีค่าสองค่า จะผสม (blend) ระหว่างมันแบบนุ่มนวลได้ยังไง แล้วจะปรับรูปทรงของการผสมนั้นให้ movement ดูเป็นธรรมชาติแทนที่จะดูแข็ง ๆ เหมือนหุ่นยนต์ได้ยังไง เกือบทุกอย่างข้างล่างนี้ต่อยอดมาจากสูตรเดียวที่คุณจะพิมพ์เป็นร้อย ๆ ครั้งในโปรเจกต์จริง
รูปแบบเดิมเหมือนเดิม: โค้ดสั้น ๆ, output จริง, แล้วอธิบายแบบเข้าใจง่าย interpolation ดูเหมือนคณิตศาสตร์ไม่มีพิษภัย แต่มันซ่อนหนึ่งในบั๊กที่เจอบ่อยที่สุดในเกมที่ปล่อยจริงเอาไว้ด้วย — หัวข้อ 10 จะพาไปดูรายละเอียด เพราะควรรู้ไว้ก่อนที่คุณจะเขียนกล้อง "smooth follow" ตัวแรกของคุณ
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
นี่คือสูตรในรูป 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
std::lerp(a, b, t) ใน <cmath> ซึ่งจัดการ edge case แปลก ๆ ของ floating-point ได้ดีกว่าสูตรบรรทัดเดียวข้างบนเล็กน้อย แต่สูตรบรรทัดเดียวนั้นก็ยังควรจำขึ้นใจ เพราะคุณจะต้องเขียนสูตรเดียวกันเป๊ะ ๆ ใน C#, HLSL, และ GLSL ที่ไม่มีให้ใช้สำเร็จรูปไม่มีอะไรในสูตร lerp ที่ห้ามคุณใส่ t ที่น้อยกว่า 0 หรือมากกว่า 1 คณิตศาสตร์ยังทำงานได้ปกติ — แค่ได้จุดที่อยู่ นอก ช่วงจาก a ถึง b เรียกสิ่งนี้ว่า extrapolation
ส่วนใหญ่คุณไม่อยากได้แบบนั้น — 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 ให้
[0, 1] เพื่อให้ได้ความรู้สึกเด้ง ๆ มันจะเป็นบั๊กก็ต่อเมื่อคุณไม่ได้ตั้งใจให้เกิดแบบนั้น — ดังนั้นควรรู้ว่า lerp call ไหนของคุณ clamp แล้ว และไหนยังไม่ได้ clamplerp ตอบคำถามว่า "ถ้ามี 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 ตัวอื่นต่อ
a กับ b เท่ากัน inverseLerp จะหารด้วยศูนย์ แล้วได้ NaN หรือ infinity ใน C++ เรื่องนี้เกิดบ่อยกว่าที่คิด — เช่น health bar ที่ minHealth กับ maxHealth ดันเท่ากันโดยไม่ตั้งใจ ควรกันเคสนี้ไว้ถ้า a == b มีโอกาสเกิดขึ้นได้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 ที่ถูกใช้บ่อยที่สุดในโค้ดเกมจริง เวลาไหนที่คุณต้องแปลงค่าจาก "หน่วยที่มันมาเป็นธรรมชาติ" ไปเป็น "หน่วยที่อีกที่ต้องการ" นี่คือเครื่องมือที่ใช้
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.25 lerp ธรรมดาไปแล้วหนึ่งในสี่ของทาง แต่ smoothstep ไปแค่ 0.15625 — มันค่อย ๆ เริ่ม (ease in) อย่างนุ่มนวล พอใกล้กลางมันจะเร็วขึ้น แล้วพอใกล้ t=1 มันก็ค่อย ๆ ชะลอ (ease out) อย่างนุ่มนวลอีกครั้ง ในทางคณิตศาสตร์ อัตราการเปลี่ยนแปลง (ความชัน) ของ smoothstep เป็นศูนย์พอดีทั้งที่ t=0 และ t=1 นี่คือเหตุผลที่การเริ่มกับการหยุดรู้สึกนุ่มนวลแทนที่จะกระชาก สูตรเดียวนี้ถูกใช้บ่อยมากสำหรับ fade (จอมืดค่อย ๆ ดับ), การ blend field-of-view ของกล้อง, และค่า UI ไหนก็ตามที่ไม่ควรดู "สะดุด" ตอนเริ่มขยับ
Easing function คือตระกูลใหญ่ที่ smoothstep เป็นสมาชิกอยู่ด้วย: ฟังก์ชันไหนก็ตามที่ปรับรูปทรง t ก่อนส่งเข้า lerp เพื่อให้ movement ไม่เคลื่อนที่ด้วยความเร็วคงที่ มีสามชื่อที่เจอบ่อยมาก:
smoothstep เป็น)เวอร์ชัน quadratic ง่าย ๆ ของแต่ละแบบ:
easeIn(t) = t * t
easeOut(t) = 1 - (1 - t) * (1 - t)
#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 รู้สึกดี เพราะมันเลียนแบบพฤติกรรมของมวลกับแรง
จนถึงตอนนี้ทุกค่าที่เราผสมเป็นแค่ตัวเลขตัวเดียวผสมไปหาอีกตัวเลขหนึ่ง Bezier curve (ตั้งชื่อตาม Pierre Bezier) เอาไอเดียของ lerp แบบเดียวกันมาใช้กับ จุดในพื้นที่ โดยใช้ control point กลุ่มเล็ก ๆ มาช่วยกำหนดรูปทรงของ curve ที่นุ่มนวล แบบที่ง่ายและใช้งานได้จริงที่สุดคือ quadratic Bezier ซึ่งใช้ control point สามจุด: P0 (จุดเริ่ม), P1 (จุด "ดึง"), และ P2 (จุดจบ)
De Casteljau's algorithm สร้างจุดบน curve นั้นโดยใช้แค่การเรียก lerp ซ้ำ ๆ เท่านั้น — ไม่ต้องจำสูตรใหม่เลย ใช้ building block ตัวเดียวกับหัวข้อ 1 แค่เรียกสองรอบ:
#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 ก็จะโค้งเข้าหาตรงนั้น
cubic Bezier ใช้ control point สี่จุด P0 ถึง P3 และสร้างด้วยไอเดีย de Casteljau แบบเดียวกันเป๊ะ ๆ — แค่ lerp เพิ่มอีกรอบเดียว เพราะมี control point เพิ่มมาอีกจุด:
#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 ตอนเริ่มกับตอนจบต้องชี้คนละทาง
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 ที่กำลังรันอยู่ใต้เมาส์ของคุณ
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 ของมัน)
#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 วางไว้
มาถึงส่วนของบทนี้ที่ควรจำให้ขึ้นใจมากกว่าส่วนอื่น โค้ด "smoothing" ที่เจอบ่อยมากหน้าตาแบบนี้ เขียนครั้งเดียวแล้วเรียกทุกเฟรมใน Update() หรือ Tick():
position = lerp(position, target, 0.1f); // "move 10% closer to the target, every frame"
ดูเผิน ๆ เหมือนไม่มีพิษภัย แล้วมันก็ทำให้ movement ดูนุ่มนวลจริง ๆ ด้วย บั๊กอยู่ตรงที่ผลลัพธ์ขึ้นอยู่กับว่าบรรทัดนี้ถูกรันกี่ครั้งต่อวินาที — หมายความว่าโค้ดชุดเดียวกันให้ gameplay ต่างกันบนฮาร์ดแวร์ต่างกัน หรือแม้แต่บนฮาร์ดแวร์เดียวกันตอนโหลดหนักไม่เท่ากัน แต่ละครั้งที่เรียก ช่องว่างที่เหลือจะหดเหลือ 90% ของเดิม ดังนั้นหลังเรียก N ครั้ง ช่องว่างที่เหลือคือ 0.9^N ของช่องว่างตอนแรก
#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 ที่ไม่คงที่
position = lerp(position, target, someConstant) ใน Update() แล้วเรียกมันว่า "smoothing" หรือ "damping" โดยไม่เคยคูณด้วย deltaTime เลย นี่น่าจะเป็นโค้ดที่ขึ้นกับ frame rate ที่ถูก copy-paste กันมากที่สุดในเกมที่ปล่อยจริง — camera follow, ค่า UI ที่นับขึ้น, aim assist, และทิศทางหันหน้าของศัตรู ต่างก็ถูกเขียนแบบนี้โดยไม่ตั้งใจกันทั้งนั้น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 เฟรมก็ตาม
เหตุผลที่วิธีนี้ได้ผลแบบเป๊ะ ๆ ไม่ใช่แค่ใกล้เคียง เป็นเพราะ pow(0.5, x) มีคุณสมบัติแบบ multiplicative (คูณต่อกันได้): การหดด้วย pow(0.5, dt1) แล้วหดต่อด้วย pow(0.5, dt2) ให้ผลรวมเท่ากับหดทีเดียวด้วย pow(0.5, dt1 + dt2) ไม่ว่าจะหั่นหนึ่งวินาทีเป็น 30 เฟรมเท่า ๆ กัน หรือ 60 เฟรมเท่า ๆ กัน แล้วคูณ shrink factor ต่อเฟรมกี่ครั้งก็ตาม ผลรวมสุดท้ายจะเท่ากันเสมอ เพราะชิ้นส่วนทั้งหมดบวกกลับมารวมเป็นหนึ่งวินาทีเท่าเดิมเสมอ ค่าคงที่ 0.9 แบบเก่าไม่มีคุณสมบัตินี้ — มันเป็นการหดคงที่ ต่อการเรียกหนึ่งครั้ง ไม่ใช่ ต่อวินาที ดังนั้นยิ่งเรียกบ่อยในวินาทีเดียวกัน ช่องว่างก็ยิ่งหดมากขึ้น
Update() จริง ๆ dt คือ Time.deltaTime (Unity) หรือ DeltaSeconds ของ tick (Unreal) ซึ่งขยับนิดหน่อยทุกเฟรม — ไม่เป็นไร สูตรนี้ยังทำงานได้ปกติเพราะมันคำนวณ t ใหม่จากเวลาที่ผ่านไปจริงทุกครั้งที่เรียก แทนที่จะสมมติว่าความยาวเฟรมคงที่ ให้เลือก halfLife ที่มีความหมายกับคุณจริง ๆ ("ระยะครึ่งหนึ่งปิดภายใน 0.15 วินาที") แทนที่จะไปจูนค่าคงที่ 0.1 ดิบ ๆ ที่บังเอิญดูโอเคแค่ที่ frame rate ที่คุณทดสอบเท่านั้นlerp(a, b, t) = a + (b - a) * t; ผสม a กับ b ตามค่า t[0, 1]; 0 คือ "เป็น a ล้วน", 1 คือ "เป็น b ล้วน"lerp ให้เมื่อ t อยู่นอก [0, 1]: จุดที่เลย a หรือเลย b ออกไปt ให้อยู่ใน [0, 1])(value - a) / (b - a); หา t กลับจากค่าที่รู้อยู่แล้วระหว่าง a กับ bt * t * (3 - 2 * t); ปรับรูปทรง t ให้อัตราการเปลี่ยนแปลงเป็นศูนย์ที่ทั้งสองปลายt ก่อน lerp เพื่อให้ movement เร่งและ/หรือชะลอ แทนที่จะเคลื่อนที่ด้วยความเร็วคงที่lerp ซ้ำ ๆremap(value, inMin, inMax, outMin, outMax) จากหัวข้อ 4 คำนวณด้วยมือหาความกว้างของการเติมเมื่อ health เท่ากับ 45 แสดง t ตรงกลางที่ได้จาก inverseLerp ด้วยหา t ก่อน: inverseLerp(0, 100, 45) = (45 - 0) / (100 - 0) = 0.45 แล้ว lerp(0, 300, 0.45) = 0 + (300 - 0) * 0.45 = 135 ความกว้างของการเติมคือ 135 pixel
position = lerp(position, target, 0.2) เรียกครั้งเดียวทุกเฟรม เริ่มที่ position = 0 โดยมี target = 50 และเกมรันที่ 10 fps คงที่ ใช้สูตรทบต้น (1 - 0.2)^N คำนวณตำแหน่งหลังผ่านไป 1 วินาทีเป๊ะ (10 เฟรม) จากนั้นโดยไม่ต้องคำนวณใหม่ ให้บอกว่าตำแหน่งหลัง 1 วินาทีจะ สูงกว่า, ต่ำกว่า, หรือ เท่าเดิม ถ้าเกมรันที่ 60fps แทน พร้อมอธิบายเหตุผลหนึ่งประโยคหลัง 10 เฟรม เศษส่วนของช่องว่างที่เหลือคือ 0.8^10 = 0.1073741824 ดังนั้นตำแหน่งคือ 50 * (1 - 0.1073741824) = 44.63 (ปัดแล้ว) ที่ 60fps ตำแหน่งหลัง 1 วินาทีจะ สูงกว่า (ใกล้ 50 มากกว่า) เพราะมีเฟรมรันในวินาทีเดียวกันมากกว่าถึง 6 เท่า และแต่ละเฟรมก็ปิดช่องว่างที่เหลืออยู่อีก 20% — ยิ่งทบต้นหลายรอบในเวลาจริงเท่าเดิม ยิ่งปิดระยะทางรวมได้มากขึ้น นี่คือบั๊กที่ขึ้นกับ frame rate จากหัวข้อ 10 เป๊ะ ๆ เลย
P0 = (0, 0), P1 = (4, 8), P2 = (8, 0) ที่ t = 0.5 แสดงจุดกลาง A กับ B ก่อนจุดสุดท้าย QA = 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 เท่าไหร่