anasayfa / blog / oynanış
Yayın: 2 Aralık 2025 10 dk okuma oynanış
Karakter kontrolcüsü neden kayıyor gibi hissettiriyor?

Karakter kontrolcüsü neden kayıyor gibi hissettiriyor?

Oyuncular karakter kontrolcüsü için "kayıyor" dediğinde ortada tek bir hata yoktur; yavaşlama, zemin tespiti ve eğim yönetimi ayrı ayrı bozuktur.

"Kayıyor gibi hissettiriyor" ne demek

Geçen yıl bir üçüncü şahıs prototipinde altı kişilik bir test turu yaptık. Altısı da aynı cümleyi kurdu: karakter kayıyor gibi hissettiriyor. Kimse bundan fazlasını söyleyemedi, ben de ilk iki günü yanlış yerde arayarak geçirdim; kamera yumuşatmasını ve animasyon blend sürelerini kurcaladım. Profiler temizdi, kare süresi 6.9 ms'de sabit duruyordu; yani mesele performans değildi.

Ekran kaydını 240 FPS'te kare kare izleyince sorun ortaya çıktı. Oyuncu tuşu bıraktıktan sonra karakter 11 kare daha ilerliyordu, yani 60 Hz'te yaklaşık 180 ms. Rampaya girdiğinde yatay hızı korunuyor ama zemin normali değiştiği için ileri doğru itiliyordu. 20 cm'lik bir basamakta ise capsule takılıp bir anlık duruyordu.

Yani "kayıyor" tek bir hata değil. Yavaşlama eğrisi, zemin tespiti, eğim yönetimi ve fizik-render senkronu ayrı ayrı bozuktu; dördü de aynı şikâyete çıkıyordu. Dördü de fizik kodundaydı, hiçbiri animasyon tarafında değildi. Bu yazı onları tek tek nasıl düzelttiğimi anlatıyor.

Rigidbody mi, kinematik hareket mi?

İlk sürüm klasik Rigidbody ve AddForce ikilisiydi. Kâğıt üzerinde doğru görünüyor: fizik motoru simülasyonu yapar, sen kuvvet verirsin. Pratikte physics material sürtünmesini sıfırladığın anda karakter eğimlerden aşağı kayıyor, sürtünmeyi açtığın anda duvara sürtünerek yürürken hızı düşüyor. Arada iyi bir değer yok, çünkü tek bir katsayı hem zemin hem duvar için kullanılıyor. Yüzeye göre iki farklı physics material'a geçmeyi de denedim; çalışıyor ama sahnedeki her yeni collider için akılda tutulması gereken bir kural doğuruyor.

Kinematik hareketle bitirdim. Rigidbody hâlâ duruyor ama isKinematic açık; hızı ben entegre ediyorum ve her fizik adımında capsule cast ile süpürüp kaydırıyorum. Unity'nin CharacterController bileşenini de denedim: step offset ve slope limit dışında ayar noktası vermiyor, depenetration davranışını göremiyorsun ve rampa inişinde kendi kararını veriyor.

Kural şu oldu: çarpışma sorgusu fizik motorunun işi, hız kararı benim işim. CharacterMotor sınıfı 300 satır civarında ve hareketle ilgili her sayı orada, tek yerde duruyor. Bedeli, itilme ve hareketli platform taşıma gibi şeyleri elle yazmak. Hissi ayarlarken kazandığın zaman bunun çok üstünde.

Kinematik olmanın en görünür eksiği dinamik cisimlerle etkileşim. Karakter artık kutuları kendiliğinden itmiyor, çünkü fizik motoruna kütle olarak görünmüyor. Süpürme sırasında dinamik bir Rigidbody'ye çarptığımda ona hız farkı ve kütle oranıyla ölçeklenmiş bir itme uyguluyorum; bu on beş satır, kaybettiğim davranışın oyunda görülen kısmını geri getirdi.

CharacterMotor.cs
public sealed class CharacterMotor : MonoBehaviour { private const int MaxBounces = 4; private const float Skin = 0.02f; private const float WallFriction = 0.85f; private const float MaxSlopeCos = 0.5736f; // cos(55 degrees) private Vector3 CollideAndSlide(Vector3 motion, Vector3 origin, int depth) { if (depth >= MaxBounces || motion.sqrMagnitude < 1e-6f) return Vector3.zero; Vector3 dir = motion.normalized; GetCapsulePoints(origin, out Vector3 p0, out Vector3 p1); if (!Physics.CapsuleCast(p0, p1, _radius, dir, out RaycastHit hit, motion.magnitude + Skin, _collisionMask)) return motion; // Stop just short of the surface, then slide with whatever is left. Vector3 travelled = dir * Mathf.Max(0f, hit.distance - Skin); Vector3 leftover = Vector3.ProjectOnPlane(motion - travelled, hit.normal); // Walls bleed speed; walkable ground keeps it, so slopes do not slow you down. if (hit.normal.y < MaxSlopeCos) leftover *= WallFriction; return travelled + CollideAndSlide(leftover, origin + travelled, depth + 1); } }
csharpCharacterMotor.cs

Capsule cast ile zemin tespiti ve eğim

Zemin tespitini tek bir aşağı ray ile yapmak kenarlarda çöküyor. Karakterin merkezi platform kenarını 3-4 cm geçtiğinde ray boşa gidiyor, karakter bir kare havada sayılıyor, sonraki karede tekrar zeminde oluyor. Bunun yerine capsule ile aynı yarıçaptaki bir CapsuleCast kullanıyorum: 0.15 m aşağı, 0.02 m skin payı.

Yürünebilirlik kararı çarpma normalinin dikey bileşeninden geliyor: hit.normal.y >= 0.5736f, yani 55 derecenin kosinüsü. Sınırın tam üstünde titremeyi kesmek için histerezis koydum; zemine 55 derecede giriliyor, 58 dereceye kadar zeminde kalınıyor. Bu tek karşılaştırma, dik rampa kenarlarındaki "zemin var / zemin yok" salınımını bitirdi.

Eğimde asıl mesele hızı doğru düzleme yansıtmak. Yatay girdiyi olduğu gibi uygularsan yokuş aşağı hızlanır, yokuş yukarı sürünürsün. Girdi vektörünü zemin normaline göre ProjectOnPlane ile düzleştirince yürüme hızı eğimden bağımsız hâle geliyor. Ayrıca iniş rampalarında karakterin havalanmaması için 0.3 m'ye kadar zemine yapıştırma yapıyorum; bu olmadan her rampa inişi küçük bir zıplamaya dönüşüyor.

Tek bir cast tek bir çarpma döndürüyor ve iki yüzeyin birleştiği yerde hangi normalin geleceği kareden kareye değişiyor. Sekiz sonuçluk bir tampon ile CapsuleCastNonAlloc kullanıp yürünebilir normaller arasından en dik olanını seçince bu kararsızlık kalktı. Sorguları ayrı bir katman maskesine almak da işe yaradı; tetikleyicileri ve hasar hacimlerini dışarıda bırakmak cast maliyetini yaklaşık üçte bir düşürdü.

Basamak çıkma ve duvara sürtünme

Basamak çıkma üç süpürme ile çözülüyor: önce step yüksekliği kadar yukarı (0.35 m), sonra ileri hareket kadar, sonra tekrar aşağı. Üçü de temizse ve inen süpürmenin normali yürünebilirse pozisyonu oraya taşıyorum; değilse hareketi iptal edip yüzeye duvar gibi davranıyorum. Bu üç ek cast, fizik adımı başına ölçtüğüm 0.28 ms'lik motor maliyetinin yaklaşık 0.06 ms'si.

Duvara sürtünme collide-and-slide döngüsünün kendisi. Çarpışma olunca kalan hareketi çarpma düzlemine yansıtıp yeniden süpürüyorum. İç köşelerde tek yansıtma yetmiyor; en fazla dört tekrar yapıyorum ve dördüncüde hâlâ çarpma varsa kalan hareketi tamamen atıyorum. Tekrar sayısını sınırsız bıraktığım sürümde dar köşelerde kare süresi 4 ms'ye fırlamıştı.

Süpürme ne kadar dikkatli olursa olsun karakter zamanla geometrinin içine sızıyor, özellikle hareketli bir platform itince. Her fizik adımının sonunda Physics.ComputePenetration ile örtüşmeyi ölçüp dışarı itiyorum, adım başına en fazla 0.2 m. Bu üst sınır olmadan bir keresinde karakter duvarın öbür tarafına ışınlandı; ölçülen örtüşme 3 m'yi geçmişti, çünkü sahnede negatif ölçekli bir mesh collider vardı.

Basamak süpürmesinin iki koruması var. Birincisi, yalnızca zemindeyken çalışıyor; havadayken açık bıraktığımda karakter duvara koşarken kendini yukarı tırmandırıyordu. İkincisi, yukarı süpürme tavana çarparsa basamak denemesi tamamen iptal ediliyor, yoksa alçak tavanlı koridorlarda karakter bir an için tavanın içine giriyor.

İvmelenme, yavaşlama ve hava kontrolü

Kayma hissinin en doğrudan sebebi yavaşlama. Hızı hedefe Lerp ile çekmek yerine ayrı ivme değerleri kullanıyorum: yerde hızlanma 45 m/s², yavaşlama 60 m/s², havada 12 m/s². Yürüme hızı 6.2 m/s iken bu, tuş bırakıldıktan sonra yaklaşık 100 ms'de tam duruş demek; testçilerin şikâyet ettiği 180 ms'nin neredeyse yarısı.

Yavaşlamanın hızlanmadan yüksek olması bilinçli bir tercih. Simetrik yaptığımda karakter tepkisiz kalıyor, yavaşlamayı çok yükselttiğimde robot gibi hissettiriyor. 1.3 ile 1.5 arasındaki oran benim projelerimde tutarlı biçimde iyi çıktı. Bunu tasarımcı için bir AnimationCurve'e taşımayı denedim; ayarlanacak iki sayıdan daha iyi sonuç vermedi, sadece hata ayıklamayı zorlaştırdı.

Havada kural değişiyor: momentumu koruyorum, yavaşlama uygulamıyorum, girdiyi düşük ivmeyle ekliyor ve yatay hızı yerdeki maksimumla kırpıyorum. Böylece oyuncu havada yön düzeltebiliyor ama zıplayarak hızlanamıyor. Kırpmayı unuttuğum sürümde testçiler zıpla-koş kombinasyonuyla amaçlanan hızın 1.4 katına çıkmış ve seviye sınırlarının dışına taşmıştı.

Girdi tarafında iki ayar kaldı. Analog ölü bölgeyi 0.15'te kesip kalan aralığı sıfırdan bire yeniden eşliyorum, yoksa yavaş yürüme hiç kullanılamıyor. Karakterin baktığı yön ile hareket yönünü de ayırdım; gövde 720 derece/s ile dönüyor ama hız vektörü anında değişiyor, çünkü dönüşü hızın önüne koyduğumda keskin yön değişimleri gecikmeli hissettiriyordu.

Sabit adımlı fizik, interpolation ve sıra

Motor 50 Hz sabit adımda çalışıyor, ekran 144 Hz yeniliyor. Pozisyonu doğrudan fizik adımında yazarsan render karelerinin bir kısmı aynı pozisyonu iki kez gösterir. Ortaya çıkan mikro titreme oyuncuya "takılıyor" diye geri döner ve çoğu kişi bunu kare düşüşü sanıp GPU profilinde arar.

Çözüm, bir önceki ve mevcut fizik pozunu saklayıp Update içinde aradaki oranla interpole etmek. Görsel kök, yani mesh ve kamera hedefi, interpole edilmiş poz üzerinde duruyor; çarpışma capsule'ü fizik pozunda kalıyor. Kamerayı LateUpdate'te aynı interpole poza bağlamak da şart, yoksa bir kare gecikmeli okuma karakteri kameranın önünde salınıyormuş gibi gösteriyor.

Sabit adımın ikinci faydası tekrarlanabilirlik. Kare süresi dalgalandığında bile hareket aynı sayıda adımdan geçtiği için aynı girdi aynı yolu üretiyor; bu olmadan zıplama mesafesi 60 Hz ile 144 Hz arasında yaklaşık 20 cm değişiyordu. Uzun bir kare geldiğinde biriken zamanı adım başına en fazla dört adımla sınırlıyorum, yoksa yükleme takılmalarında simülasyon kendini toparlayamıyor.

Aynı şikâyeti alıyorsanız sırayı şöyle kurun: önce duruş süresini ölçüp düzeltin, sonra zemine yapıştırmayı ekleyin, sonra basamak süpürmesini, en son interpolation'ı. Her adımda capsule cast'leri ve zemin normalini ekrana çizin; bu görselleştirmeyi yazmak yarım günümü aldı ve sonraki üç hafta boyunca her hatayı ilk bakışta gösterdi. "Kayıyor gibi hissettiriyor" belirsiz bir cümle değil, henüz ölçmediğiniz dört sayının toplamı.

← Tüm yazılar