บทที่แล้ว Multiplayer Architecture วางรากฐาน client-server model ไว้แล้ว: server เก็บสำเนาที่ถูกต้องที่สุดของโลกเกมไว้ชุดเดียว (เรียกว่า authoritative server) ฝั่ง client ส่ง input ของตัวเองไปให้ แล้ว server เป็นคนตัดสินว่าจริง ๆ แล้วเกิดอะไรขึ้น โมเดลนี้ถูกต้องตามหลักการ แต่ถ้าใช้ตรง ๆ แบบไม่มีอะไรเสริม จะเล่นแล้วรู้สึกแย่ ระหว่างจังหวะที่เรากดปุ่มกับจังหวะที่คำตอบจาก server มาถึงจอเรา มีดีเลย์จริง ๆ คั่นอยู่ วัดเป็นเวลาบนเครือข่าย ไม่ใช่จำนวนเฟรม บทนี้พูดถึงเทคนิคที่ใช้ซ่อนดีเลย์นั้น: การทำนายการเคลื่อนไหวของตัวเองไว้ล่วงหน้าก่อน server จะยืนยัน, การแก้ไขความผิดพลาดแบบเงียบ ๆ เมื่อ server ตัดสินไม่ตรงกับที่ทำนายไว้, การทำให้ผู้เล่นคนอื่นเคลื่อนไหวลื่นไม่กระตุกทั้งที่ข้อมูลมาไม่สม่ำเสมอ และสำหรับทุกอย่างที่เกี่ยวกับการยิงโดนผู้เล่นคนอื่น — การย้อนเวลาบน server เพื่อให้การยิงที่ดูแม่นบนจอคนยิงถูกตัดสินอย่างยุติธรรมด้วย
สมมติ client กับ server ห่างกัน 50 มิลลิวินาทีในแต่ละทิศทาง — one-way latency (ดีเลย์ทางเดียว) 50ms ดังนั้น round trip (RTT, Round-Trip Time) จะประมาณ 100ms ถ้า client ไม่ทำอะไรนอกจากส่ง input แล้วรอคำตอบจาก server timeline ของการกดปุ่มครั้งเดียวจะเป็นแบบนี้:
ผู้เล่นต้องรอ 100ms หลังจากกดปุ่ม กว่าอะไรจะขยับบนจอ ต่อให้เน็ตบ้านดี ๆ ก็สังเกตได้แล้ว ถ้าเน็ตแย่กว่านี้ (RTT 200-300ms พบได้ทั่วไปบนเน็ตมือถือ) จะรู้สึกหน่วงมาก เหมือนบังคับเรือแทนที่จะบังคับตัวละคร และนี่คือกรณีที่ดีที่สุดแล้วด้วยซ้ำ — packet จริง ๆ ไม่ได้มาถึงห่างกันเท่า ๆ กัน (jitter อธิบายในหัวข้อ 5) ดังนั้นดีเลย์ไม่ได้คงที่ 100ms เป๊ะ ๆ แต่กระโดดขึ้นลง
server ต้องคง authority ไว้ — ถ้าปล่อยให้ client ตัดสินตำแหน่งของตัวเองได้จริง ๆ ทันที เกมนี้จะโกงง่ายมาก ดังนั้นทางแก้ไม่ใช่การ "สละ authority" แต่คือการทำให้จอของ client เองอัปเดตทันทีด้วยการเดา ในขณะที่ server ยังคำนวณคำตอบจริงอยู่เบื้องหลัง
Client-side prediction คือการที่ client apply input ของตัวเองทันทีที่มันเกิดขึ้น โดยไม่รอ server ใช้กฎการเคลื่อนไหวเดียวกันเป๊ะกับที่ server จะใช้ในที่สุด ผู้เล่นเห็นตัวเองขยับทันที client กำลังเดาว่า server จะตัดสินอะไร — และเพราะทั้งสองฝั่งรันกฎเดียวกันบน input เดียวกัน การเดานี้จึงมักจะถูกเป๊ะแทบทุกครั้ง
นี่คือ player controller แบบทำนายอย่างง่ายที่สุดใน Unity C# มัน apply การเคลื่อนไหวทันทีที่มี input เกิดขึ้น แล้วก็ส่ง input เดียวกันนั้นไปให้ server เพื่อให้ server รันกฎเดียวกันแบบ authoritative:
using UnityEngine;
using System.Collections.Generic;
public class PredictedInput
{
public int Sequence; // increasing counter, one per input this client has sent
public Vector3 MoveDir; // input direction for this tick
public float DeltaTime; // how much time this input covers
}
public class PredictedPlayer : NetworkBehaviour
{
public float speed = 5f;
int nextSequence = 0;
// every unacknowledged input we have applied locally, oldest first
Queue<PredictedInput> history = new Queue<PredictedInput>();
void Update()
{
if (!IsOwner) return; // only the owning client predicts its own player
Vector3 moveDir = new Vector3(
Input.GetAxisRaw("Horizontal"), 0f, Input.GetAxisRaw("Vertical"));
PredictedInput input = new PredictedInput
{
Sequence = nextSequence++,
MoveDir = moveDir,
DeltaTime = Time.deltaTime
};
ApplyMove(input); // 1. move on screen right now, no waiting
history.Enqueue(input); // 2. remember it, in case the server disagrees
SubmitInputServerRpc(input.Sequence, moveDir, input.DeltaTime); // 3. tell the server
}
void ApplyMove(PredictedInput input)
{
transform.position += input.MoveDir.normalized * speed * input.DeltaTime;
}
[ServerRpc]
void SubmitInputServerRpc(int sequence, Vector3 moveDir, float dt)
{
// the server applies the SAME movement rule, authoritatively.
// covered in full in section 3.
}
}
สองอย่างนี้ทำให้มันเป็นการทำนายจริง ๆ ไม่ใช่แค่การเดาลอย ๆ ที่ไม่เกี่ยวข้องกับอะไร:
ApplyMove คือฟังก์ชันเดียวกันเป๊ะกับที่ server จะรันสำหรับ input นี้ กฎเดียวกัน input เดียวกัน จุดเริ่มต้นเดียวกัน -> ผลลัพธ์เดียวกัน แทบทุกครั้งhistory พร้อมแท็กเลข Sequence ที่เพิ่มขึ้นเรื่อย ๆ แทนที่จะทิ้งไปหลังส่ง หัวข้อ 3 ต้องใช้ history นี้เพื่อแก้กรณีที่เดาผิดserver รับ SubmitInputServerRpc แต่ละครั้ง apply กฎการเคลื่อนไหวเดียวกัน แล้วจำ เลข sequence ของ input ล่าสุดที่ประมวลผลไปแล้ว ของผู้เล่นคนนั้นไว้ ตามตารางเวลาคงที่ (ครั้งละหนึ่ง tick — ขั้นตอน simulation แบบคงที่ ตามที่บท Multiplayer Architecture สอนไว้) มันจะส่งผลลัพธ์ authoritative กลับไปให้ client เจ้าของ: ตำแหน่งที่ได้ พร้อมเลข sequence นั้น
[ServerRpc]
void SubmitInputServerRpc(int sequence, Vector3 moveDir, float dt)
{
transform.position += moveDir.normalized * speed * dt; // authoritative move
lastProcessedSequence = sequence;
// tell the owning client: "this is your real position, I have processed
// everything up to and including this sequence number"
StateUpdateClientRpc(transform.position, lastProcessedSequence);
}
ฝั่ง client StateUpdateClientRpc คือจุดที่ reconciliation เกิดขึ้น (การทำให้ state ที่ทำนายไว้กลับมาตรงกับ state ที่ authoritative อีกครั้ง) มีสามขั้นตอนทุกครั้งที่ state update มาถึง:
history ที่มีเลข sequence น้อยกว่าหรือเท่ากับ sequence ที่ server ยืนยันแล้ว — server คิดตัวเหล่านั้นไปแล้ว ถ้า replay ซ้ำอีกจะเท่ากับ apply สองครั้งhistory ตามลำดับ บนตำแหน่งที่ snap ไปแล้ว — นั่นคือ input ที่ server ยังไม่เห็นตอนที่ส่ง update นี้มา[ClientRpc]
void StateUpdateClientRpc(Vector3 serverPosition, int acknowledgedSequence)
{
if (!IsOwner) return;
// 1. snap to the authoritative position
transform.position = serverPosition;
// 2. drop every input the server has already processed
while (history.Count > 0 && history.Peek().Sequence <= acknowledgedSequence)
history.Dequeue();
// 3. replay everything the server had not seen yet
foreach (PredictedInput input in history)
ApplyMove(input);
}
ถ้าการเดาของ client ตรงกับ server เป๊ะ ขั้นตอนที่ 3 จะ replay input บนตำแหน่งที่เหมือนกับที่ client เคยอยู่อยู่แล้ว — ผู้เล่นจะไม่ขยับให้เห็นตอนโค้ดนี้รัน เพราะ serverPosition บวก input ที่ replay กลับมาลงจุดเดิมเป๊ะที่จอเคยแสดงอยู่แล้ว ถ้าการเดาผิดไปนิดหน่อย (เช่นการชนที่ client ทำนายไม่ตรง) การ replay จะแก้ให้ มักจะเป็นการขยับเล็กน้อยระดับ sub-pixel ที่ไม่มีใครสังเกตเห็น
List<PredictedInput> แล้วเรียก RemoveAt(0) ในลูป ก็ทำงานได้แต่ช้าโดยไม่จำเป็น และการลบ item ระหว่าง iterate ด้วย foreach จะ throw exception ด้วย Queue<T> คือรูปแบบที่ถูกต้องตรงนี้: ลบจากหน้าคิว (Dequeue) เพิ่มที่ท้ายคิว (Enqueue) เท่านั้นใส่ตัวเลขจริงจะเห็นภาพชัดขึ้น สมมติผู้เล่นขยับแกนเดียว หนึ่งหน่วยต่อ tick เริ่มต้นช่วงนี้ที่ x = 10.0 client ทำนาย input ไปแล้วห้าตัว (sequence 41 ถึง 45) ก่อนได้ยินคำตอบจาก server:
StateUpdateClientRpc ของ client รันตอนนี้:
ทีนี้มาดูกรณีที่น่าสนใจ: สมมติ tick 43 ข้ามสิ่งกีดขวางที่ client ไม่รู้จัก server จึงหยุดผู้เล่นจริง ๆ ที่ x = 12.6 แทนที่จะไปต่อถึง 13.0:
Vector3.Lerp จากตำแหน่งที่แสดงอยู่ ไปยังตำแหน่งที่ reconcile แล้ว ในช่วง 100-150ms — แทนที่จะกำหนดค่าในขั้นตอนเดียวprediction แก้ปัญหาการเคลื่อนไหวของตัวละครเราเอง เพราะเรารู้ input ของตัวเองก่อน server จะรู้ แต่มันช่วยตัวละครของผู้เล่นคนอื่นไม่ได้เลย — เราไม่มีทางรู้ว่าผู้เล่นคนอื่นกำลังจะกดอะไร สิ่งเดียวที่เราได้จากพวกเขาคือสตรีมของ snapshot (การอัปเดตตำแหน่ง) ที่ส่งมาผ่านเครือข่าย และสตรีมนั้นไม่เคยลื่นไหลสมบูรณ์แบบ packet ไม่ได้มาถึงในเวลาเท่ากันทุกครั้ง ความแปรปรวนของเวลาที่มาถึงเรียกว่า jitter
ถ้า client แค่ snap ผู้เล่นคนอื่นไปยังตำแหน่งที่ snapshot ล่าสุดบอก ทันทีที่มันมาถึง ตัวละครจะขยับเป็นกระตุก ๆ ไม่สม่ำเสมอ — ลื่นบ้าง หยุดบ้าง แล้วก็มีสองอัปเดตมาซ้อนกันเกือบพร้อมกัน Entity interpolation แก้ปัญหานี้ด้วยการจงใจ render ผู้เล่นคนอื่นให้ช้ากว่าความเป็นจริงเล็กน้อย (ในอดีตเล็กน้อย) แล้ว blend ระหว่างสองตำแหน่งที่รู้แล้วอย่างลื่นไหล แทนที่จะกระโดดไปยังแต่ละตำแหน่งใหม่
client เก็บ buffer สั้น ๆ ของ snapshot ล่าสุดไม่กี่อันที่ได้รับ แต่ละอันแท็กด้วย timestamp ของ server มันเลือก render time ที่ช้ากว่า snapshot ล่าสุดนิดหน่อย — ปกติ 100-200ms มากพอที่จะมั่นใจได้ว่ามี snapshot อย่างน้อยสองอันคร่อมมันอยู่ แม้จะมี jitter — แล้วหาสอง snapshot ใน buffer ที่คร่อม render time นั้น แล้ว interpolate (blend) ระหว่างสองอันนั้น
public struct Snapshot
{
public float ServerTime;
public Vector3 Position;
}
public class InterpolatedRemotePlayer : MonoBehaviour
{
List<Snapshot> buffer = new List<Snapshot>();
public float interpolationDelay = 0.12f; // seconds behind the newest snapshot
public void OnSnapshotReceived(float serverTime, Vector3 pos)
{
buffer.Add(new Snapshot { ServerTime = serverTime, Position = pos });
if (buffer.Count > 20) buffer.RemoveAt(0); // keep the buffer from growing forever
}
void Update()
{
if (buffer.Count < 2) return;
float renderTime = buffer[buffer.Count - 1].ServerTime - interpolationDelay;
// find the two snapshots that straddle renderTime
for (int i = 0; i < buffer.Count - 1; i++)
{
Snapshot a = buffer[i];
Snapshot b = buffer[i + 1];
if (a.ServerTime <= renderTime && renderTime <= b.ServerTime)
{
float t = (renderTime - a.ServerTime) / (b.ServerTime - a.ServerTime);
transform.position = Vector3.Lerp(a.Position, b.Position, t);
return;
}
}
}
}
ต้นทุนของมันคือดีเลย์ที่คงที่และจงใจให้เกิด — เราเห็นผู้เล่นคนอื่นช้ากว่าความเป็นจริงเสมอ 100-200ms นั่นเป็นการแลกเปลี่ยนที่คุ้มค่า: ดีเลย์เล็ก ๆ คงที่ที่มองไม่เห็น ดีกว่าการกระตุกที่เห็นชัด นี่ก็เป็นเหตุผลที่ lag compensation (หัวข้อ 7) ต้องมีอยู่ด้วย: ภาพจริงของ server ว่าทุกคนอยู่ตรงไหนตอนนี้ ไม่เหมือนกับสิ่งที่ client แต่ละตัวกำลังเห็นอยู่บนจอ
interpolationDelay เป็นค่าที่ปรับได้ ไม่ใช่ค่าคงที่ตายตัว ถ้าเล็กเกินไป buffer จะหมดบ่อย ๆ บังคับให้ต้อง extrapolate (หัวข้อ 6) บ่อยกว่าที่ตั้งใจ ถ้าใหญ่เกินไป ผู้เล่นคนอื่นจะดูล้าหลังตำแหน่งจริงมากขึ้น ซึ่งสำคัญกว่าในเกมยิงจังหวะเร็ว มากกว่าในเกมทำฟาร์มจังหวะช้าInterpolation ต้องการ snapshot ที่อยู่หลังระยะเวลาที่จะ render เพื่อใช้ blend เข้าหา ถ้าเครือข่ายสะดุด — packet หาย หรือ latency พุ่งขึ้นมา — snapshot ล่าสุดที่ client มีอาจเก่ากว่า render time ไปแล้ว โดยไม่มีอะไรใหม่กว่าให้ interpolate เข้าหา buffer หมด
Extrapolation (บางทีเรียก dead reckoning) คือทางเลือกสำรอง: แทนที่จะ blend ระหว่างสองจุดที่รู้แล้ว ก็ให้ตัวละครขยับต่อไปโดยใช้ velocity ล่าสุดที่รู้ เดาว่าตอนนี้มันน่าจะอยู่ตรงไหน
Vector3 lastPos = buffer[buffer.Count - 1].Position;
Vector3 velocity = EstimateVelocityFromLastTwoSnapshots(buffer);
float overrun = renderTime - buffer[buffer.Count - 1].ServerTime;
// guess: keep moving in the same direction at the same speed
Vector3 extrapolatedPos = lastPos + velocity * overrun;
Extrapolation ปลอดภัยแค่ในช่วงสั้น ๆ เท่านั้น — ไม่กี่สิบมิลลิวินาที ยิ่งปล่อยให้รันนานเท่าไรโดยไม่ตรวจสอบ ยิ่งเบี่ยงเบนจากความเป็นจริงมากขึ้นเท่านั้น: ผู้เล่นจริงเลี้ยว หยุด หรือกลับทิศตลอดเวลา และการ "ขยับทางเดิมต่อไป" จะยิ่งแย่ลงเรื่อย ๆ ยิ่งต้องเดาแบบมองไม่เห็นนานเท่าไร พอ snapshot จริงมาถึงในที่สุด การเดาแบบ extrapolate กับตำแหน่งจริงมักไม่ตรงกันเป๊ะ ตัวละครเลยต้องแก้ไขให้เห็นชัด ๆ — สแนปแบบเดียวกับที่ tip ในหัวข้อ 4 เตือนไว้ เพียงแต่คราวนี้เกิดกับตัวละครของคนอื่นแทนที่จะเป็นของเราเอง
เอา prediction, reconciliation และ interpolation มารวมกัน แล้วดูว่าจากมุมมองของเรา ผู้เล่นแต่ละคนอยู่ตรงไหนในเวลาจริง:
ทีนี้ลองคิดถึงการเล็งยิง เราเล็งไปที่จุดที่เราเห็นศัตรู ซึ่งตามที่เพิ่งอธิบายไป ไม่ใช่จุดที่ศัตรูอยู่จริง ๆ บน server ตอนนี้ — แต่เป็นจุดที่ศัตรูเคยอยู่ เมื่อประมาณ 100 ถึง 300ms ที่แล้ว ขึ้นอยู่กับการเชื่อมต่อของเรา ถ้า server แค่เช็คว่า "มีใครยืนอยู่ตรงจุดที่ยิงเล็งไปไหม โดยใช้ตำแหน่ง authoritative ปัจจุบัน" การยิงที่ดูเล็งแม่นเป๊ะบนจอเรา มักจะพลาด เพราะ server ขยับศัตรูตัวนั้นไปข้างหน้าแล้วตั้งแต่ snapshot ที่เรากำลังดูอยู่
Lag compensation (เรียกอีกอย่างว่า server-side rewind) แก้ปัญหานี้โดยให้ server เก็บประวัติสั้น ๆ ของ hitbox (รูปทรงชนที่ใช้ตรวจจับการโดนยิง) ของทุกคนไว้หลาย tick — เช่นหนึ่งวินาทีล่าสุด เมื่อการยิงมาถึง server มันจะไม่เช็คกับตำแหน่งปัจจุบัน แต่จะคำนวณ latency ของคนยิง ย้อน hitbox ของผู้เล่นคนอื่นกลับไปยังจุดที่มันอยู่ ณ เวลานั้นในอดีต แล้วเช็คการยิงกับจุดนั้นแทน
bool ServerValidateHit(Player shooter, Ray shotRay, float shooterLatencySeconds)
{
// 1. work out which point in the recent past the shooter was aiming at
float rewindTime = ServerTime.Now - shooterLatencySeconds;
foreach (Player target in AllPlayersExcept(shooter))
{
// 2. get that target's hitbox as it was back then, from the
// server's own rolling history buffer (same idea as
// the interpolation buffer in section 5, kept server-side)
Hitbox rewoundHitbox = target.HitboxHistory.SampleAt(rewindTime);
// 3. check the shot against the REWOUND hitbox, not the current one
if (rewoundHitbox.IntersectsRay(shotRay))
{
ApplyDamage(target, shooter);
return true;
}
}
return false;
}
นี่คือเหตุผลที่ lag compensation ถูกอธิบายว่า "server ย้อนเวลากลับไปเพื่อตัดสินการยิงอย่างยุติธรรมให้คนยิง" จากมุมมองคนยิง การเล็งอย่างระมัดระวังแล้วลั่นไก จะโดนเป้าตามที่เห็นบนจอได้อย่างแม่นยำ ไม่ว่า ping จะเท่าไร ถ้าไม่มีระบบนี้ ผู้เล่นที่มี latency จริง ๆ จะต้อง lead การยิง (เล็งนำหน้า) ตลอดเวลาเพื่อชดเชยดีเลย์ที่มองไม่เห็นด้วยซ้ำ แล้วก็ยังจะพลาดการยิงที่ดูถูกต้องอยู่ดี
Lag compensation ทำให้ประสบการณ์ของคนยิงยุติธรรม โดยแลกกับประสบการณ์ของเหยื่อโดยตรง ลองไล่ดูการย้อนเวลาเดียวกันจากมุมมองเหยื่อบ้าง:
นี่คือคำบ่นที่โด่งดังว่า "ตายทั้งที่หลบหลังกำแพงแล้ว" ในเกมยิงแข่งขันทุกเกมที่ใช้ lag compensation มันไม่ใช่บั๊ก — มันคือผลที่หลีกเลี่ยงไม่ได้โดยตรงจากการตัดสินการยิงให้ยุติธรรมกับคนยิง ต้องมีใครสักคนแพ้ในความไม่ตรงกันระหว่าง "จอของเหยื่อเองแสดงว่าอยู่ตรงไหน" กับ "จอของคนยิงแสดงว่าอยู่ตรงไหน" และ lag compensation จะตัดสินให้ฝั่งคนยิงชนะเสมอ เพราะทางเลือกตรงข้าม — เข้าข้างเหยื่อเสมอ — จะทำให้การยิงแทบเป็นไปไม่ได้เลยสำหรับใครก็ตามที่มี ping จริง
มีสองตัวแปรที่ควบคุมว่าจะรู้สึกแย่แค่ไหน และทั้งสองปรับได้โดยทีมที่ทำเกม:
ไม่มีการตั้งค่าไหนที่ทำให้ผู้เล่นทั้งสองฝ่ายรู้สึกว่าตัวเองเป็นผู้ตัดสินสิ่งที่เกิดขึ้นแต่เพียงผู้เดียวได้ — ดีเลย์เครือข่ายมีอยู่จริง และต้องมีใครสักคนรับผลนั้นไป lag compensation คือการเลือกอย่างจงใจว่าจะให้ใครรับ
Prediction ไม่ได้ฟรี — ทุก action ที่จะทำนายได้ ต้องให้ client รันกฎเดียวกับที่ server รัน และต้องมี path สำหรับ reconciliation เวลาที่มันผิด มันคุ้มค่าสำหรับ action ที่เกิดถี่และคำนวณใหม่ได้ถูก ๆ อย่างการเคลื่อนไหว แต่มันมักจะไม่คุ้มค่า หรือถึงขั้นผิดเลย สำหรับ action แบบอื่น:
นี่คือตัวอย่างเดียวที่รวมหัวข้อ 2-4 เข้าด้วยกัน: local prediction, input history และ reconciliation เขียนเป็น player controller ตัวเดียว ใกล้เคียงกับที่โปรเจกต์ Unity NGO (Netcode for GameObjects) จริง ๆ จะเป็น เพียงแต่ทำให้ network call เรียบง่ายลงเพื่อให้อ่านง่าย:
using UnityEngine;
using System.Collections.Generic;
public class PredictedInput
{
public int Sequence;
public Vector3 MoveDir;
public float DeltaTime;
}
public class FullPredictedPlayer : NetworkBehaviour
{
public float speed = 5f;
int nextSequence = 0;
Queue<PredictedInput> history = new Queue<PredictedInput>();
// ----- CLIENT SIDE -----
void Update()
{
if (!IsOwner) return;
Vector3 moveDir = new Vector3(
Input.GetAxisRaw("Horizontal"), 0f, Input.GetAxisRaw("Vertical"));
var input = new PredictedInput
{
Sequence = nextSequence++,
MoveDir = moveDir,
DeltaTime = Time.deltaTime
};
ApplyMove(input);
history.Enqueue(input);
SubmitInputServerRpc(input.Sequence, moveDir, input.DeltaTime);
}
[ClientRpc]
void StateUpdateClientRpc(Vector3 serverPosition, int acknowledgedSequence)
{
if (!IsOwner) return;
transform.position = serverPosition;
while (history.Count > 0 && history.Peek().Sequence <= acknowledgedSequence)
history.Dequeue();
foreach (PredictedInput queued in history)
ApplyMove(queued);
}
// ----- SHARED -----
void ApplyMove(PredictedInput input)
{
transform.position += input.MoveDir.normalized * speed * input.DeltaTime;
}
// ----- SERVER SIDE -----
int lastProcessedSequence = -1;
[ServerRpc]
void SubmitInputServerRpc(int sequence, Vector3 moveDir, float dt)
{
var input = new PredictedInput { Sequence = sequence, MoveDir = moveDir, DeltaTime = dt };
ApplyMove(input); // same rule, authoritative result
lastProcessedSequence = sequence;
StateUpdateClientRpc(transform.position, lastProcessedSequence);
}
}
ไล่ดูตั้งแต่ต้นจนจบสำหรับ lag spike หนึ่งครั้ง ใช้ speed = 1 unit/tick เพื่อให้ตัวเลขเข้าใจง่าย เริ่มที่ x = 0:
นี่คือใจความสำคัญของครึ่งแรกของบทนี้ทั้งหมด ในหนึ่ง trace: ผู้เล่นไม่รู้สึกหน่วงเลยแม้แต่น้อย และ server ก็ไม่เคยเสีย authority ไป — มันแค่ยืนยันช้าไปนิดหน่อย สิ่งที่ client เดาไว้ถูกต้องอยู่แล้ว
บั๊กของ prediction และ reconciliation มักมีอาการเดียวกัน: ตัวละครสั่น กระตุก หรือค่อย ๆ เบี่ยงเบน แม้บนการเชื่อมต่อที่ดี สาเหตุที่เจอบ่อย ๆ มีดังนี้:
ApplyMove ฝั่ง client กับโค้ดเทียบเท่าฝั่ง server ไม่ใช่กฎเดียวกันเป๊ะ (ค่า gravity ต่างกัน, รัศมีชนต่างกัน, floating point คำนวณคนละลำดับ) ทุกครั้งที่ replay จะได้คำตอบต่างจากที่ผู้เล่นเห็นอยู่แล้วนิดหน่อย ผู้เล่นจึงเห็นการสั่นแบบถาวรตลอดเวลา แทนที่จะเป็นการแก้ไขที่มองไม่เห็นเป็นครั้งคราวStateUpdateClientRpc มาถึงไม่เรียงลำดับ (เกิดยาก แต่เป็นไปได้) และ client reconcile กับ ack เก่าหลังจาก ack ใหม่รันไปแล้ว มันอาจดึง input ที่ถูกลบไปถูกต้องแล้วกลับมาใหม่ได้interpolationDelay ของหัวข้อ 5 เล็กกว่า jitter ปกติ buffer จะหมดบ่อย ๆ และผู้เล่นคนอื่นทุกคนจะ extrapolate (หัวข้อ 6) บ่อยกว่าที่ตั้งใจไว้มาก แสดงออกมาเป็นอาการกระตุกเล็ก ๆ พอดีตอนเครือข่ายมีโหลดสูงhistory ทุกเฟรม บนการเชื่อมต่อที่เสถียร ค่านี้ควรอยู่แถว ๆ RTT / tickInterval และค่อนข้างคงที่ คิวที่โตขึ้นเรื่อย ๆ ไม่มีขีดจำกัด แปลว่า acknowledgment ไม่มาถึง หรือจับคู่กับเลข sequence ผิด — client กำลังทำนายล่วงหน้าไปเรื่อย ๆ โดยไม่มีอะไรมายืนยันเลยPredictedInput กี่รายการที่อยู่ใน history พร้อมกันได้ ในกรณีที่แย่ที่สุด ก่อนที่ acknowledgment จะมาถึงได้เลย ทำไม client ถึงต้องเก็บอย่างน้อยจำนวนนั้นไว้ แทนที่จะเก็บแค่ตัวล่าสุดหนึ่งหรือสองตัว?หนึ่ง tick ประมาณ 33ms round trip เต็ม ๆ คือ 200ms ดังนั้น client จะส่ง input ที่ทำนายใหม่ไปเรื่อย ๆ ตลอด 200ms นั้น ก่อนที่ acknowledgment ตัวแรกสุดของ input ตัวไหนจะกลับมาได้เลย: 200ms / 33ms ต่อ tick ≈ 6 tick ในกรณีที่แย่ที่สุด client อาจมี input ที่ยังไม่ถูกยืนยันอยู่ใน history มากขนาดนั้นพร้อมกัน — บวกส่วนเผื่อเล็กน้อยสำหรับ jitter ดังนั้นปัดขึ้นเป็น 8-10 ช่อง ถือว่าปลอดภัย
ถ้า client เก็บแค่ input ล่าสุดหนึ่งหรือสองตัว reconciliation (หัวข้อ 3) จะไม่มีอะไรเหลือให้ replay หลัง snap ไปยังตำแหน่งของ server เลย — ทุก acknowledgment จะลบ input ที่ server ยังไม่ได้ประมวลผลจริง ๆ ทิ้งไป ผู้เล่นจะสแนปถอยหลังให้เห็นชัด ๆ ทุกครั้งที่มี update แทนที่จะอยู่นิ่ง ๆ history ต้องครอบคลุม round trip ทั้งหมด ไม่ใช่แค่ "สิ่งล่าสุดที่สุด"
x = 50.0 client ส่งและ apply input ในเครื่องไปแล้วห้าตัว sequence 200 ถึง 204 แต่ละตัวขยับ +2 ตามแกน x server เนื่องจากมีการชนที่ client ไม่รู้จัก จริง ๆ แล้วขยับผู้เล่นแค่ +2 สำหรับ sequence 200 กับ 201 แล้ว +0 (ถูกกีดขวาง) สำหรับ sequence 202 จากนั้นรายงานกลับมาว่า serverPosition = 54.0, acknowledgedSequence = 202 ให้หาว่า: (a) จอของ client แสดงอะไรอยู่ก่อน update นี้จะมาถึง (b) เหลือ entry อะไรบ้างใน history หลัง reconciliation และ (c) จอแสดงตำแหน่งอะไรทันทีหลัง reconciliation(a) ก่อน update มาถึง client apply การขยับ +2 ทั้งห้าตัวไปแบบไม่รู้อิโหน่อิเหน่ บนตำแหน่ง 50.0: 50 + 2+2+2+2+2 = 60.0 นั่นคือสิ่งที่ผู้เล่นกำลังเห็นอยู่
(b) Reconciliation ทิ้งทุก entry ที่มี Sequence <= 202 ซึ่งลบ 200, 201 และ 202 ออกไป สิ่งที่เหลือใน history คือ [203, 204]
(c) Snap ไปยังคำตอบของ server ก่อน: x = 54.0 จากนั้น replay สอง input ที่เหลือ แต่ละตัว +2: 54 + 2 = 56 แล้ว 56 + 2 = 58 จอแสดง x = 58.0 หลัง reconciliation — เป็นการแก้ไขที่เห็นชัด 2 หน่วยถอยหลังจาก 60.0 ที่แสดงอยู่เมื่อครู่ เพราะการเดาของ client ไม่รู้จักการชนที่กีดขวาง sequence 202
public class BuggyRemotePlayer : MonoBehaviour
{
Vector3 latestPosition;
public void OnSnapshotReceived(float serverTime, Vector3 pos)
{
latestPosition = pos;
}
void Update()
{
transform.position = latestPosition;
}
}
บั๊ก: โค้ดนี้ไม่เก็บ history เลย เก็บแค่ snapshot ล่าสุดตัวเดียว แล้ว snap ตรงไปยังมันทุกครั้งที่มีตัวใหม่มาถึง นี่คือพฤติกรรม "apply แค่ snapshot ล่าสุด" ที่หัวข้อ 5 อธิบายไว้ว่าทำให้เกิดการกระตุกไม่สม่ำเสมอเป๊ะ ๆ — ไม่มีอะไรให้ interpolate ระหว่างกลางเลย ดังนั้นระหว่างที่ยังไม่มี snapshot ใหม่มา ตัวละครจะหยุดนิ่ง แล้วก็เทเลพอร์ตทันทีที่ตัวถัดไปมาถึง
public class FixedRemotePlayer : MonoBehaviour
{
struct Snapshot { public float ServerTime; public Vector3 Position; }
List<Snapshot> buffer = new List<Snapshot>();
public float interpolationDelay = 0.12f;
public void OnSnapshotReceived(float serverTime, Vector3 pos)
{
buffer.Add(new Snapshot { ServerTime = serverTime, Position = pos });
if (buffer.Count > 20) buffer.RemoveAt(0);
}
void Update()
{
if (buffer.Count < 2) return;
float renderTime = buffer[buffer.Count - 1].ServerTime - interpolationDelay;
for (int i = 0; i < buffer.Count - 1; i++)
{
if (buffer[i].ServerTime <= renderTime && renderTime <= buffer[i + 1].ServerTime)
{
float t = (renderTime - buffer[i].ServerTime) /
(buffer[i + 1].ServerTime - buffer[i].ServerTime);
transform.position = Vector3.Lerp(buffer[i].Position, buffer[i + 1].Position, t);
return;
}
}
}
}
ทางแก้คือเก็บ buffer สั้น ๆ ของ snapshot แทนที่จะเก็บแค่ตัวเดียว เลือก render time ที่ช้ากว่าตัวล่าสุดที่มาถึงนิดหน่อย แล้ว blend (Lerp) ระหว่างสอง snapshot ที่คร่อมมันอยู่ทุกเฟรม — เป็นแพทเทิร์นเดียวกับหัวข้อ 5 เป๊ะ ซึ่งควรกลับไปอ่านซ้ำอีกทีตอนที่แบบฝึกหัดนี้เข้าใจแล้ว