- Oyun fiziğinde tekrarlanan çarpışma algılama sorununu bir top simülasyonu üzerinden ele alıyor; tüm çiftleri denetlemekten sweep-and-prune’a uzanan optimizasyon akışını açıklıyor
- Basit yöntem,
n nesne için tüm aday çiftlerde intersects() çağırır ve yaklaşık (n*(n-1))/2 denetim yaptığı için hızla O(n²) seviyesine büyür
- AABB kesişim testi birden çok eşitsizlik ve
&& ile kurulur; kısa devre değerlendirme ve eşitsizliklerin geçişliliğinden yararlanarak çarpışma ihtimali olmayan adaylar erken elenebilir
- Nesneler sol sınırları olan minimum x değerine göre sıralandıktan sonra,
ball2.left > ball1.right olduğu anda iç döngü break ile kesilir ve sonraki adaylar topluca dışarıda bırakılır
- Sıralama maliyeti O(n log n) ile x eksenindeki örtüşme sayısı
m kadar döngü maliyeti eklenerek ortalamada O(n log n + m) düzeyine gelir; gereksiz intersects() çağrıları büyük ölçüde azalır
Oyunlarda çarpışma algılamanın çıkış noktası
- Çarpışma algılama, video oyunu programlamasında birçok davranışın önkoşuludur
- Karakterlerin birbirinin içinden geçmesini engeller
- Bir Goomba başka bir nesneye çarptığında yönünü değiştirir
- agar.io’da büyük bir hücre, temas ettiğinde küçük hücreyi yer
- Genel oyun fiziğini işler
- Örnek, çeşitli çarpışma algılama yaklaşımlarını karşılaştırmak için katı cisim top simülasyonu kullanır
- Kapsam, en basit yöntemden sweep-and-prune yaklaşımına uzanan çizgidir; uzamsal bölme veya uzamsal ağaç alt bölümlendirmesi kapsam dışıdır
Tüm çiftleri denetleyen basit yaklaşım
- En doğrudan yöntem, tüm nesne çiftlerini aday olarak görmektir
- Dış döngü her topun üzerinden geçer
- İç döngü
i + 1den başlayarak A-B ve B-A gibi yinelenen çiftleri önler
- Her aday çift için
intersects(ball1, ball2) çağrılır; sonuç doğruysa bounce(ball1, ball2) çalıştırılır
- Bu denetim her zaman adımında tekrarlandığı için toplar çarpıştıkları anda sektirilir
- Nesne sayısı az olduğunda yeterlidir; ancak sayı arttıkça denetim miktarı hızla performans darboğazına dönüşür
O(n²)’nin getirdiği sınır
- Basit algoritma, Big O açısından O(n²) zamanda çalışır
n top için denetlenmesi gereken çift sayısı kabaca (n*(n-1))/2, yani 0.5n² - 0.5n adettir
n = 5 ise 10 çift
n = 10 ise 45 çift
n = 15 ise 105 çift
n = 20 ise 190 çift
- Tüm nesnelerin aynı anda örtüştüğü en kötü durumda, herhangi bir çarpışma algılama algoritmasının O(n²) çarpışma işleminden kaçınması zordur
- Pratik karşılaştırmalarda en kötü durumdan çok ortalama ve en iyi durum daha kullanışlıdır
- Basit yöntem, gerçek çarpışma sayısından bağımsız olarak her zaman Θ(n²) davranır; bu yüzden iyileştirme payı büyüktür
intersects() içindeki tekrarlı iş
- Optimizasyonun başlangıç noktası, tüm aday çiftler için çağrılan
intersects() fonksiyonudur
- Tipik bir AABB kesişim testi, her yöndeki sınırları karşılaştıran birden çok eşitsizlik denetiminden oluşur
function intersects(object1, object2) {
// compare objects' bounds to see if they overlap
return object1.left < object2.right
&& object1.right > object2.left
&& object1.top < object2.bottom
&& object1.bottom > object2.top;
}
- Bu denetim dört koşula ayrılır
object1.left < object2.right
object1.right > object2.left
object1.top < object2.bottom
object1.bottom > object2.top
&& operatörünün kısa devre değerlendirmesi sayesinde koşullardan biri bile yanlışsa tüm kesişim testi hemen yanlış olur
- Birden çok test boyunca “en az bir koşul yanlış” durumunu genelleştirirsek
intersects() çağrısının kendisini azaltabiliriz
- Bu, bir eksende izdüşümler örtüşmüyorsa iki nesnenin çarpışmayacağı fikrini temel alan ayıran eksen teoremi ile aynı yönde bir yaklaşımdır
Eşitsizliklerin geçişliliğiyle adayları eleme
- Yalnızca
object1.right > object2.left koşuluna bakmak bile optimizasyon olanağı yaratır
- A, B, C şeklinde üç nesne yatayda A-B-C sırasındayken aşağıdaki denetimlerin tümü yanlış olabilir
A.right > B.left // returns false
B.right > C.left // returns false
A.right > C.left // returns false
A > B yanlış ve B > C yanlışsa, eşitsizliklerin geçişliliği sayesinde A > Cnin de yanlış olduğunu bilebiliriz
- Bu nedenle
intersects(A, C) çağrılmadan da iki nesnenin çarpışmayacağına karar verilebilir
- Bu atlama yalnızca nesneler belirli bir sıradayken uygulanabilir; ancak nesne etiketleri keyfî olduğundan soldaki nesne A, ortadaki B, sağdaki C olacak şekilde adlandırılabilir
- Nesneleri bu mantıksal sıraya yerleştirme işi de aslında sıralamadır
x ekseninin minimum değerine göre sıralama
- Sıralı liste, eşitsizliklerin geçişliliğini birden çok adaya topluca uygulamayı sağlar
- Tipik hızlı sıralama algoritmaları O(n log n) çalışır ve O(n²)’den düşüktür
- Nesneler nokta değil, x ekseni üzerinde bir aralık kapladığı için x konumuna göre sıralamada sol sınır olan minimum x kullanılır
- Basit O(n²) kodda gereken değişiklikler ikidir
- Döngülerden önce
sortByLeft(balls) ile topları sol sınır x koordinatına göre sıralamak
- İç döngüde
ball2.left > ball1.right ise break yapmak
// sort by min x
sortByLeft(balls);
// for each ball
for (let i = 0; i < balls.length; i++) {
const ball1 = balls[i];
// check each of the other balls
for (let j = i + 1; j < balls.length; j++) {
const ball2 = balls[j];
// stop when too far away
if (ball2.left > ball1.right) break;
// check for collision
if (intersects(ball1, ball2)) {
bounce(ball1, ball2);
}
}
}
- Sıralama fonksiyonu diziyi sol sınır farkına göre sıralar
function sortByLeft(balls) {
balls.sort((a,b) => a.left - b.left);
}
break neden güvenli?
- Liste sıralıysa herhangi bir pozitif tamsayı
c için aşağıdaki ilişki geçerlidir
balls[j + c].left >= balls[j].left
- Mevcut aday aşağıdaki koşulu sağlıyorsa, mevcut çift x ekseninde örtüşmüyordur
balls[j].left > ball1.right
- İki eşitsizliği birleştirince şu ilişki elde edilir
balls[j + c].left >= balls[j].left > ball1.right
- Geçişlilik gereği
balls[j + c].left > ball1.right da doğrudur; dolayısıyla sonraki tüm adaylar da mevcut ball1 ile x ekseninde örtüşmez
- Mevcut
ball2, ball1 ile artık örtüşmediği anda iç döngünün kalan adayları denetlenmeden durdurulabilir
- Bu optimizasyon, gerçek
intersects() çağrılarını x ekseninde örtüşen çiftlerle sınırlar
İyileştirilmiş zaman karmaşıklığı
- Sıralama maliyeti, mergesort veya quicksort gibi hızlı sıralamalar baz alındığında O(n log n) terimini ekler
- Erken durdurmalı çift döngü ortalamada O(n + m) olarak görülebilir
m, toplam x ekseni örtüşme sayısıdır
- En iyi durumda hiç örtüşme yoksa gereksiz işlem neredeyse kalmaz ve O(n)’e yaklaşır
- En kötü durumda hâlâ O(n²)’ye kadar kötüleşebilir
- Ortalama senaryo, nesnelerin genel olarak dengeli dağıldığı ve nesne başına yalnızca birkaç çarpışma olduğu durumu varsayar
- Toplam karmaşıklık, sıralama ve döngü birlikte O(n log n + m) olur
- Basit yöntemden daha iyi olmasının iki nedeni vardır
n log n, n²den küçüktür
- Kısmen örtüşme sayısı
mye bağlı olduğu için gerekenden fazla işlem yapmaz
Uygulama yükü ve sonraki adım
- Bu sıralama tabanlı yöntem, az kod değişikliğiyle çalışma zamanı performansını belirgin biçimde iyileştiren dengeli bir noktadır
- Karşılaştırma demosunda sıralama tabanlı çift denetimi, küresel tüm çift denetimine göre kare başına
intersects() testi sayısını gözle görülür ölçüde azaltır
- Sıralama maliyeti karşılaştırma görselleştirmesinde gösterilmez; ancak kesişim testinin yeterince pahalı olduğu varsayılır
- Daha gelişmiş yöntemler ve nihai kod Part 2 ile devam eder
1 yorum
Hacker News yorumları
Bu yaklaşımda ilginç olan şey, yazarın en yüksek performans için merge sort/quick sort gibi “hızlı” sıralama algoritmalarını kullanmayı önermesi
Ancak pratikte daha “kötü” bir sıralama algoritması olan insertion sort daha hızlı olabilir
Çarpışma algılama sistemlerindeki nesneler genelde kareler arasında yalnızca az miktarda hareket eder, bu yüzden önceki karenin neredeyse sıralı listesini korumak mümkündür
Böyle listelerde insertion sort O(n)'e yaklaşırken, quick sort O(n^2)'ye yaklaşabilir
Bunu şu şekilde açıklıyor: “Sıralama aşaması analizde darboğazdır, ama çoğu zaman sıralama hiçbir şey yapmaz. Liste neredeyse her zaman önceki karede zaten sıralanmıştır. Sıralama bozulsa bile genelde birkaç takasla yeniden düzelir. İşte insertion sort'un buna nasıl davrandığına dair bir örnek”
Örneğin kürenin yarıçapını epsilon kadar büyüterek bu yapılabilir
Küre epsilon kadar hareket etmediği sürece indeksi yeniden hesaplamaya gerek kalmaz
Yeniden hesaplama gerektiğinde gecikme zirvelerini önlemek için her karede %10 sıralayıp geriden gelen bir indeks oluşturabilirsiniz
10 kare sonra, 10 kare önceki konumdan epsilon içinde kaldığı sürece geçerli bir indeks elde etmiş olursunuz
Pivot rastgele seçilirse O(n log n) olur; liste zaten neredeyse sıralıysa listenin ortasındaki öğeyi pivot olarak da seçebilirsiniz
Yine de en iyi pivotla bile quick sort en iyi durumda O(n log n)'dir
Verideki artan/azalan run sayısına k dersek, O(n log k) çalışan basit merge sort varyantları vardır
Haskell standart kütüphanesindeki varsayılan
sortböyle bir algoritma kullanır ve Python da muhtemelen öyledirYazının kurgusu çok iyiydi
90'ların sonlarından beri şu ya da bu şekilde oyun geliştirme yaptım; bugün bunun çoğu motorların içinde soyutlanmış olsa da, karmaşık sistem simülasyonlarının nasıl çalıştığını anlamak için böyle içerikler şart
Yazarın bu kadar erişilebilir bir yazı hazırlamasına teşekkürler
Sürekli çarpışma algılama konusunda şu belgeyi hep çok faydalı bulmuşumdur: https://github.com/bepu/bepuphysics2/blob/master/Documentati...
Kütüphanenin kendisi de performans açısından mükemmel
Yalnız çok fazla optimizasyon içerdiği için entegrasyonu biraz zahmetli
“Bu saf algoritma Big O açısından O(n2) zamanda çalışır” ifadesinin doğru olup olmadığını merak ediyorum
Dış döngü i, n - 1 kez dönüyor; iç döngü j ise i + 1'den başladığı için giderek n - 1'den daha az dönmüyor mu diye düşündüm
Bu alandan değilim; n büyük olduğunda bunun yaklaşık O(n2) ile aynı mı kabul edildiğini, yoksa görünüşte olduğundan daha küçük mü sayıldığını merak ediyorum
i'inci öğe için karşılaştırma sayısı (n - i - 1)'dir; 0 tabanlı indeksle toplam karşılaştırma sayısı (n - 1) * n / 2 olur
Bkz. https://en.wikipedia.org/wiki/Triangular_number
Sonuçta Big O analizinde fark yoktur
Çünkü Big O, n sonsuza giderkenki davranışı açıklar ve burada ikinci dereceden terim baskındır
j = i + 1'den başlatan “optimizasyon”, tüm nesne çiftlerini iki kez kontrol etmemek içindirAyrıca nesnenin kendisiyle karşılaştırılmasını da engeller
Tüm çiftler bir kez kontrol edildiği için algoritma O(n^2)'dir
Genel olarak işlem sayısını giriş boyutunun bir fonksiyonu olarak analitik biçimde ifade edebiliyorsanız, Big O en büyük terimi bırakır ve tüm katsayıları atar
Algoritmanın gerçek performansını mutlaka açıklamaz
20n2^+5nile2n^2 + 9001nikisi de O(n^2)'dirBig O gösteriminde tüm katsayılar ve daha yavaş büyüyen terimler göz ardı edildiğinden ikinci dereceden karmaşıklık olarak sadeleşir
İllüstrasyon kullanımı çok iyiydi ve yerinde görünüyordu
Bazen interaktif illüstrasyonlu yazılar, bir sürü havalı demo koymak için bahaneymiş gibi geliyor; TED konuşmaları gibi, içeriğinden çok süsü olan şeyler olabiliyor
Ama bu yazıda illüstrasyonlar içeriğin önüne geçmemiş
Part 2: https://leanrada.com/notes/sweep-and-prune-2/
Diğer güzel yazılarına da göz atmaya değer: https://leanrada.com/
Uzun zaman önce benzer bir şey yapmıştım; sıralama yerine her yön için indeks listeleri tutuyor ve nesnelerin kendilerini sıralamasını sağlıyordum
Örneğin
objectIndicesSortedByLeftEdge/RightEdge/TopEdge/BottomEdgegibi dört liste vardıNesne yatay hareket ettiğinde leftEdge ve rightEdge dizilerindeki kendi indeksini güncelliyordu
Çünkü hareket etse bile genelde yalnızca 1-2 indeks yer değiştirmek yetiyordu
Dinamik öğe sayısı arttıkça grafiği yeniden kuran yaklaşım daha iyi görünüyor
Benim için yeni bir yaklaşım ama olası çarpışanların sayısını azaltmak için quadtree gibi bir şey kullanmaya benzemiyor mu?
Yalnız gerçek zamanlı render'dan çok offline render tarafında k-d tree gibi yapıları daha sık görürsünüz
“Mekânsal bölme veya mekânsal ağaç alt bölme gibi diğer yaklaşımları ele almayacağım” kısmı ilgimi çekti
Buradaki algoritmanın genel olarak mekânsal bölme/mekânsal ağaç alt bölme yöntemlerinden daha hızlı olup olmadığını bilen var mı?
Uzun zaman önce mekânsal ağaç türü bir yaklaşım kullanmıştım ve ilk bakışta oldukça iyi görünüyordu; ama o zamanlar internet öncesi 80'lerdi, bu yüzden başkalarının kullandığı algoritmaları araştırma ya da karşılaştırma şansım olmadı
Tek bir varlık listesi ya da her biri varlık listesi tutan 256x256 hücreli bir ızgara yönetmek; nesneler her hareket ettiğinde tüm ağaç değişmezlerini korumak zorunda olan karmaşık bölme yapılarından çok daha kolay yazılır, hata ayıklanır ve optimize edilir
DOOM ve Quake döneminde bu tür temel sistemlerin performansı bugüne kıyasla çok daha kritik olduğu için, motor yazarlarının son derece karmaşık bölme sistemleri kurması daha anlamlıydı
Günümüz CPU'ları sıralı dizileri taramakta çok güçlü ve pipelining nedeniyle bağlı listeleri ya da ağaçları takip etmek eskisine göre daha az avantajlı
CPU zamanı artık varlık listelerini yönetmekten çok AI ve render gibi alanlarda harcanıyor