Fisika untuk penembak PvP seluler dan bagaimana kami berteman dengan ECS

Halo semuanya! Dalam artikel ini, kita akan berbicara tentang pengalaman pribadi bekerja dengan mesin fisik untuk penembak multi-pemain dan terutama berfokus pada interaksi fisika dan ECS : penggaruk seperti apa yang kita lewati selama bekerja, apa yang kita pelajari, mengapa kita memilih solusi spesifik.



Pertama, mari kita cari tahu mengapa mesin fisik dibutuhkan. Tidak ada jawaban universal: di setiap game itu melayani tujuannya. Beberapa game menggunakan mesin fisik untuk mensimulasikan perilaku objek di dunia dengan benar untuk mencapai efek membenamkan pemain. Dalam kasus lain, fisika adalah dasar dari permainan - seperti, misalnya, Angry Birds dan Red Faction. Ada juga "kotak pasir" di mana hukum fisik berbeda dari yang biasa dan dengan demikian membuat gameplay lebih menarik dan tidak biasa (Portal, A Slower Speed ​​of Light).

Dari sudut pandang pemrograman, mesin fisik memungkinkan untuk menyederhanakan proses simulasi perilaku objek dalam game. Bahkan, itu adalah perpustakaan yang menyimpan deskripsi sifat fisik objek. Di hadapan mesin fisik, kita tidak perlu mengembangkan sistem interaksi antara tubuh dan hukum universal yang akan mengatur dunia game. Ini menghemat banyak waktu dan upaya pengembangan.

gambar
Diagram di atas menjelaskan esensi dari Player, komponen-komponennya dan data mereka, dan sistem yang bekerja dengan pemain dan komponen-komponennya. Objek utama dalam diagram adalah pemain: ia dapat bergerak di luar angkasa - Komponen Transform and Movement, MoveSystem; memiliki beberapa kesehatan dan dapat mati - komponen Kesehatan, Kerusakan, KerusakanSistem; setelah kematian muncul di titik respawn - komponen Transform untuk posisi itu, Sistem RespawnS; mungkin kebal - komponen Invincible.

Apa saja fitur penerapan fisika game untuk penembak?


Tidak ada interaksi fisik yang kompleks dalam permainan kami, tetapi ada beberapa hal yang memerlukan mesin fisik. Awalnya, kami berencana menggunakannya untuk memindahkan karakter di dunia sesuai dengan hukum yang telah ditentukan. Biasanya ini dilakukan dengan memberikan tubuh impuls tertentu atau kecepatan konstan, setelah itu, menggunakan metode Simulasikan / Pembaruan perpustakaan, semua tubuh yang terdaftar di dalamnya disimulasikan tepat satu langkah ke depan.

Dalam penembakan, fisika 3D sering digunakan tidak hanya untuk mensimulasikan pergerakan karakter, tetapi juga untuk pemrosesan balistik peluru dan roket yang benar, lompatan, interaksi karakter satu sama lain dan lingkungan. Jika penembak mengaku realistis dan berusaha menyampaikan sensasi sebenarnya dari proses pemotretan, ia hanya membutuhkan mesin fisik. Ketika seorang pemain menembakkan senapan ke sasaran, ia berharap mendapatkan pengalaman dan hasil yang sedekat mungkin dengan yang sudah ia ketahui dari permainan jangka panjang para penembak - sesuatu yang secara radikal baru kemungkinan besar akan mengejutkannya.

Namun dalam kasus permainan kami, ada sejumlah batasan. Karena penembak kami mobile, itu tidak menyiratkan interaksi karakter yang kompleks satu sama lain dan dengan dunia sekitarnya, itu tidak memerlukan balistik yang indah, daya hancur, melompat pada permukaan yang tidak rata. Tetapi pada saat yang sama dan untuk alasan yang sama, ada persyaratan lalu lintas yang sangat ketat. Fisika 3D dalam hal ini akan menjadi berlebihan: ia hanya akan menggunakan sebagian kecil sumber daya komputasi dan menghasilkan data yang tidak perlu, yang dalam jaringan seluler dan sinkronisasi konstan klien dengan server melalui UDP akan memakan terlalu banyak ruang. Di sini perlu diingat bahwa dalam model jaringan kami masih ada hal-hal seperti Prediksi dan Rekonsiliasi , yang juga melibatkan penyelesaian pada klien. Akibatnya, kami mendapatkan bahwa fisika kami harus bekerja secepat mungkin agar berhasil diluncurkan dan bekerja pada perangkat seluler, tanpa mengganggu rendering dan subsistem klien lainnya.

Jadi, fisika 3D tidak cocok untuk kita. Tetapi di sini perlu diingat bahwa meskipun permainan terlihat seperti tiga dimensi, bukan fakta bahwa fisika di dalamnya juga tiga dimensi: semuanya menentukan sifat interaksi objek satu sama lain. Seringkali efek yang tidak dapat dicakup oleh fisika 2D dikustomisasi - yaitu, logika ditulis yang terlihat seperti interaksi tiga dimensi - atau hanya diganti dengan efek visual yang tidak mempengaruhi gameplay. Dalam Heroes of the Storm, Defense of the Ancients, League of Legends, fisika dua dimensi mampu menyediakan semua fitur gameplay dari game tanpa mempengaruhi kualitas gambar atau perasaan kredibilitas yang diciptakan oleh perancang game dan seniman dunia. Jadi, misalnya, dalam permainan ini ada karakter lompat, tetapi tidak ada rasa fisik di ketinggian lompatan mereka, sehingga turun ke simulasi dua dimensi dan mengatur semacam bendera seperti _isInTheAir ketika karakter di udara - itu diperhitungkan saat menghitung logika.

Jadi diputuskan untuk menggunakan fisika 2D. Kami menulis game di Unity, tetapi server menggunakan Unity-less .net, yang tidak dimengerti Unity. Karena bagian terbesar dari kode simulasi digeledah antara klien dan server, kami mulai mencari sesuatu lintas-platform - yaitu, perpustakaan fisik yang ditulis dalam C # murni tanpa menggunakan kode asli untuk menghilangkan bahaya menabrak platform seluler. Selain itu, dengan mempertimbangkan secara spesifik pekerjaan penembak , khususnya, rewinding konstan pada server untuk menentukan di mana pemain menembak, penting bagi kita bahwa perpustakaan dapat bekerja dengan sejarah - yaitu, kita bisa dengan murah melihat posisi tubuh N frame kembali ke masa lalu . Dan, tentu saja, proyek tidak boleh ditinggalkan: penting bahwa penulis mendukungnya dan dapat dengan cepat memperbaiki bug, jika ada yang ditemukan selama operasi.

Ternyata, saat itu sangat sedikit perpustakaan yang dapat memenuhi persyaratan kami. Bahkan, hanya satu yang cocok untuk kita - VolatilePhysics .

Perpustakaan penting karena ia bekerja dengan kedua solusi Unity dan Unity-less, dan juga memungkinkan Anda untuk melakukan rakecast ke keadaan objek di luar kotak, mis. Cocok untuk logika penembak. Selain itu, kenyamanan pustaka terletak pada fakta bahwa mekanisme untuk mengendalikan awal simulasi Simulate () memungkinkan Anda untuk memproduksinya kapan saja ketika klien membutuhkannya. Dan fitur lain - kemampuan untuk menulis data tambahan ke tubuh fisik. Ini bisa berguna ketika menangani objek dari simulasi di hasil reykast - namun, ini sangat mengurangi kinerja.

Setelah melakukan beberapa tes dan memastikan bahwa klien dan server berinteraksi dengan baik dengan VolatilePhysics tanpa crash, kami memilih untuk itu.

Bagaimana kami memasuki perpustakaan dengan cara biasa bekerja dengan ECS dan apa yang terjadi


Langkah pertama ketika bekerja dengan VolatilePhysics adalah menciptakan dunia fisik VoltWorld. Ini adalah kelas proksi, yang dengannya pekerjaan utama berlangsung: penyetelan, mensimulasikan data tentang objek, reykast, dll. Kami membungkusnya dalam fasad khusus sehingga di masa depan kami dapat mengubah implementasi perpustakaan menjadi sesuatu yang lain. Kode fasad tampak seperti ini:

Lihat kode
public sealed class PhysicsWorld { public const int HistoryLength = 32; private readonly VoltWorld _voltWorld; private readonly Dictionary<uint, VoltBody> _cache = new Dictionary<uint, VoltBody>(); public PhysicsWorld(float deltaTime) { _voltWorld = new VoltWorld(HistoryLength) { DeltaTime = deltaTime }; } public bool HasBody(uint tag) { return _cache.ContainsKey(tag); } public VoltBody GetBody(uint tag) { VoltBody body; _cache.TryGetValue(tag, out body); return body; } public VoltRayResult RayCast(Vector2 origin, Vector2 direction, float distance, VoltBodyFilter filter, int ticksBehind) { var ray = new VoltRayCast(origin, direction.normalized, distance); var result = new VoltRayResult(); _voltWorld.RayCast(ref ray, ref result, filter, ticksBehind); return result; } public VoltRayResult CircleCast(Vector2 origin, Vector2 direction, float distance, float radius, VoltBodyFilter filter, int ticksBehind) { var ray = new VoltRayCast(origin, direction.normalized, distance); var result = new VoltRayResult(); _voltWorld.CircleCast(ref ray, radius, ref result, filter, ticksBehind); return result; } public void Update() { _voltWorld.Update(); } public void Update(uint tag) { var body = _cache[tag]; _voltWorld.Update(body, true); } public void UpdateBody(uint tag, Vector2 position, float angle) { var body = _cache[tag]; body.Set(position, angle); } public void CreateStaticCircle(Vector2 origin, float radius, uint tag) { var shape = _voltWorld.CreateCircleWorldSpace(origin, radius, 1f, 0f, 0f); var body = _voltWorld.CreateStaticBody(origin, 0, shape); body.UserData = tag; } public void CreateDynamicCircle(Vector2 origin, float radius, uint tag) { var shape = _voltWorld.CreateCircleWorldSpace(origin, radius, 1f, 0f, 0f); var body = _voltWorld.CreateDynamicBody(origin, 0, shape); body.UserData = tag; body.CollisionFilter = StaticCollisionFilter; _cache.Add(tag, body); } public void CreateStaticSquare(Vector2 origin, float rotationAngle, Vector2 extents, uint tag) { var shape = _voltWorld.CreatePolygonBodySpace(extents.GetRectFromExtents(), 1, 0, 0); var body = _voltWorld.CreateStaticBody(origin, rotationAngle, shape); body.UserData = tag; } public void CreateDynamicSquare(Vector2 origin, float rotationAngle, Vector2 extents, uint tag) { var shape = _voltWorld.CreatePolygonBodySpace(extents.GetRectFromExtents(), 1, 0, 0); var body = _voltWorld.CreateDynamicBody(origin, rotationAngle, shape); body.UserData = tag; body.CollisionFilter = StaticCollisionFilter; _cache.Add(tag, body); } public IEnumerable<VoltBody> GetBodies() { return _voltWorld.Bodies; } private static bool StaticCollisionFilter(VoltBody a, VoltBody b) { return b.IsStatic; } } 


Saat membuat dunia, besarnya sejarah ditunjukkan - jumlah negara di dunia yang akan disimpan oleh perpustakaan. Dalam kasus kami, jumlah mereka adalah 32: 30 frame per detik, kami akan membutuhkannya berdasarkan persyaratan untuk memperbarui logika dan 2 yang tambahan jika kami melampaui batas proses debugging. Kode ini juga memperhitungkan metode pengecoran luar yang menghasilkan tubuh fisik, dan berbagai jenis reykast.

Seperti yang kita ingat dari artikel sebelumnya , dunia ECS pada dasarnya berputar di sekitar panggilan reguler ke metode Jalankan untuk semua sistem yang termasuk di dalamnya. Di tempat yang tepat dari setiap sistem, kami menggunakan panggilan ke fasad kami. Awalnya, kami tidak menulis batching untuk menantang mesin fisik, meskipun ada pemikiran seperti itu. Di dalam fasad, panggilan untuk Pembaruan () dari dunia fisik terjadi, dan perpustakaan mensimulasikan semua interaksi objek yang terjadi per bingkai.

Dengan demikian, bekerja dengan fisika bermuara pada dua komponen: gerakan seragam tubuh dalam ruang dalam satu bingkai dan banyak rakast yang diperlukan untuk pemotretan, operasi efek yang tepat, dan banyak hal lainnya. Reykasts sangat relevan dalam sejarah keadaan tubuh fisik.

Menurut hasil pengujian kami, kami dengan cepat menyadari bahwa perpustakaan bekerja sangat buruk pada kecepatan yang berbeda, dan pada kecepatan tertentu, tubuh dengan mudah mulai melewati dinding. Tidak ada pengaturan yang terkait dengan deteksi tabrakan berkelanjutan untuk menyelesaikan masalah ini di mesin kami. Tetapi tidak ada alternatif untuk solusi kami di pasar pada waktu itu, jadi saya harus membuat versi sendiri dari objek yang bergerak di seluruh dunia dan menyinkronkan data fisika dengan ECS. Jadi, misalnya, kode kami untuk sistem pergerakan adalah sebagai berikut:

Lihat kode
 using System; ... using Volatile; public sealed class MovePhysicsSystem : ExecutableSystem { private readonly PhysicsWorld _physicsWorld; private readonly CollisionFilter _moveFilter; private readonly VoltBodyFilter _collisionFilterDelegate; public MovePhysicsSystem(PhysicsWorld physicsWorld) { _physicsWorld = physicsWorld; _moveFilter = new CollisionFilter(true, CollisionLayer.ExplosiveBarrel); _collisionFilterDelegate = _moveFilter.Filter; } public override void Execute(GameState gs) { _moveFilter.State = gs; foreach (var pair in gs.WorldState.Movement) { ExecuteMovement(gs, pair.Key, pair.Value); } _physicsWorld.Update(); foreach (var pair in gs.WorldState.PhysicsDynamicBody) { if(pair.Value.IsAlive) { ExecutePhysicsDynamicBody(gs, pair.Key); } } } public override void Execute(GameState gs, uint avatarId) { _moveFilter.State = gs; var movement = gs.WorldState.Movement[avatarId]; if (movement != null) { ExecuteMovement(gs, avatarId, movement); _physicsWorld.Update(avatarId); var physicsDynamicBody = gs.WorldState.PhysicsDynamicBody[avatarId]; if (physicsDynamicBody != null && physicsDynamicBody.IsAlive) ExecutePhysicsDynamicBody(gs, avatarId); } } private void ExecutePhysicsDynamicBody(GameState gs, uint entityId) { var body = _physicsWorld.GetBody(entityId); if (body != null) { var transform = gs.WorldState.Transform[entityId]; transform.Position = body.Position; } } private void ExecuteMovement(GameState gs, uint entityId, Movement movement) { var body = _physicsWorld.GetBody(entityId); if (body != null) { float raycastRadius; if (CalculateRadius(gs, entityId, out raycastRadius)) { return; } body.AngularVelocity = 0; body.LinearVelocity = movement.Velocity; var movPhysicInfo = gs.WorldState.MovementPhysicInfo[entityId]; var collisionDirection = CircleRayCastSpeedCorrection(body, GameState.TickDurationSec, raycastRadius); CheckMoveInWall(movement, movPhysicInfo, collisionDirection, gs.WorldState.Transform[entityId]); } } private static bool CalculateRadius(GameState gs, uint id, out float raycastRadius) { raycastRadius = 0; var circleShape = gs.WorldState.DynamicCircleCollider[id]; if (circleShape != null) { raycastRadius = circleShape.Radius; } else { var boxShape = gs.WorldState.DynamicBoxCollider[id]; if (boxShape != null) { raycastRadius = boxShape.RaycastRadius; } else { gs.Log.Error(string.Format("Physics body {0} doesn't contains shape!", id)); return true; } } return false; } private static void CheckMoveInWall(Movement movement, MovementPhysicInfo movPhysicInfo, Vector2 collisionDirection, Transform transform) { // 60 is the max angle when player move in wall and can shoot through the wall from weapon without target. const float maxAngleToWall = 60; if (movement.Velocity.IsEqual(Vector2.zero)) { if (movPhysicInfo.LastCollisionDirection.IsEqual(Vector2.zero)) { var angleToCollision = transform.Angle.GetDirection().CalculateAbsoluteAngleInDegrees(movPhysicInfo.LastCollisionDirection); movPhysicInfo.TurnOnWall = angleToCollision <= maxAngleToWall; } return; } movPhysicInfo.LastCollisionDirection = collisionDirection * -1f; if (movPhysicInfo.LastCollisionDirection.IsEqual(Vector2.zero)) { movPhysicInfo.TurnOnWall = false; movPhysicInfo.LastCollisionDirection = collisionDirection; } else { var angleToCollision = transform.Angle.GetDirection().CalculateAbsoluteAngleInDegrees(movPhysicInfo.LastCollisionDirection); movPhysicInfo.TurnOnWall = angleToCollision <= maxAngleToWall; } } // I can't believe we are using a physics engine and have to write such kludges private Vector2 CircleRayCastSpeedCorrection(VoltBody targetBody, float deltaSeconds, float rayCastRadius) { if (rayCastRadius <= 0) { return Vector2.zero; } var speed = targetBody.LinearVelocity; var position = targetBody.Position; var direction = speed * deltaSeconds; var rayCastResult = _physicsWorld.CircleCast(position + direction.normalized * 0.1f, direction, direction.magnitude, rayCastRadius, _collisionFilterDelegate, 0); if (rayCastResult.Body == null) { return Vector2.zero; } var magSpeed = speed.magnitude; if (rayCastResult.Distance > 0) { var penetratingDistance = magSpeed * deltaSeconds - rayCastResult.Distance; var sinVelocityEdge = Vector2.Dot(-speed.normalized, rayCastResult.Normal); var biasSpeed = penetratingDistance * sinVelocityEdge / deltaSeconds; var biasVector = rayCastResult.Normal * biasSpeed * 1.1f; var resultVelocity = speed + biasVector; if (magSpeed <= 0) { resultVelocity = Vector2.zero; } targetBody.LinearVelocity = resultVelocity; return rayCastResult.Normal; } var destination = rayCastResult.Body.Position; direction = destination - position; var rayCastResultToBody = _physicsWorld.RayCast(position, direction, direction.magnitude, _collisionFilterDelegate, 0); if (rayCastResultToBody.IsValid) targetBody.LinearVelocity = rayCastResultToBody.Normal * magSpeed * deltaSeconds; return rayCastResultToBody.Normal; } } 


Idenya adalah bahwa sebelum setiap karakter bergerak, kita membuat CircleCast ke arah gerakannya untuk menentukan apakah ada penghalang di depannya. CircleCast diperlukan karena proyeksi karakter dalam permainan mewakili lingkaran, dan kami tidak ingin mereka terjebak di sudut-sudut antara geometri yang berbeda. Kemudian kami menganggap peningkatan kecepatan dan menetapkan nilai ini ke objek dunia fisik sebagai kecepatannya dalam satu bingkai. Langkah selanjutnya adalah memanggil metode simulasi Update mesin fisik (), yang memindahkan semua objek yang kita butuhkan, secara bersamaan merekam keadaan lama dalam sejarah. Setelah simulasi di dalam mesin selesai, kami membaca data simulasi ini, menyalinnya ke komponen Transform ECS kami dan kemudian terus bekerja dengannya, khususnya, mengirimkannya melalui jaringan.

Pendekatan ini dalam memperbarui fisika dengan potongan data kecil yang dikontrol pada kecepatan gerakan karakter ternyata sangat efektif dalam menangani perbedaan dalam fisika pada klien dan server. Dan karena fisika kita tidak deterministik - yaitu, dengan data input yang sama, hasil simulasi dapat bervariasi - ada banyak diskusi tentang apakah layak digunakan sama sekali, dan apakah ada orang di industri yang melakukan sesuatu yang serupa, memiliki mesin fisik deterministik di tangan. Untungnya, kami menemukan laporan yang sangat baik dari pengembang NetherRealm Studios di Game Developers Conference tentang komponen jaringan permainan mereka dan menyadari bahwa pendekatan semacam itu benar-benar terjadi. Setelah sepenuhnya merakit sistem dan menjalankannya pada beberapa tes, kami mendapat sekitar 50 prediksi salah untuk 9000 ticks, yaitu selama pertempuran lima menit. Sejumlah kesalahan prediksi mudah diratakan oleh mekanisme Rekonsiliasi dan interpolasi visual dari posisi pemain. Kesalahan yang terjadi selama pembaruan manual fisika yang sering menggunakan data Anda sendiri tidak signifikan, oleh karena itu, interpolasi visual dapat berlangsung agak cepat - diperlukan hanya agar lompatan visual dalam model karakter tidak terjadi.

Untuk memeriksa kebetulan status klien dan server, kami menggunakan kelas tulis-sendiri dari formulir berikut:

Lihat kode
 using PS.Logs.Unity; /// <summary> /// Compares the same avatar in two states. Compares the values potentially /// affected by prediction. /// </summary> public sealed class GameStateComparer : IGameStateComparer { public bool IsSame(GameState s1, GameState s2, uint avatarId) { if (s1 == null && s2 != null || s1 != null && s2 == null) { return false; } if (s1 == null && s2 == null) return false; var entity1 = s1.WorldState[avatarId]; var entity2 = s2.WorldState[avatarId]; if (entity1 == null && entity2 == null) { return false; } if (entity1 == null || entity2 == null) { LogManager.Debug("entity is different"); return false; } if (s1.Time != s2.Time) { LogManager.Warning(string.Format("Trying to compare states with different time! Predicted time: {0} Server time: {1}", s1.Time, s2.Time)); return false; } if (s1.WorldState.Transform[avatarId] != s2.WorldState.Transform[avatarId]) { LogManager.Debug("Transform is different"); return false; } // ... some code ... return true; } } 


Jika perlu, itu bisa otomatis, tetapi kami tidak melakukan ini, meskipun kami memikirkannya di masa depan.

Ubah kode perbandingan:

Lihat kode
 public static bool operator ==(Transform a, Transform b) { if ((object)a == null && (object)b == null) { return true; } if ((object)a == null && (object)b != null) { return false; } if ((object)a != null && (object)b == null) { return false; } if (Math.Abs(a.Angle - b.Angle) > 0.01f) { return false; } if (Math.Abs(a.Position.x - b.Position.x) > 0.01f || Math.Abs(a.Position.y - b.Position.y) > 0.01f) { return false; } return true; } 



Kesulitan pertama


Tidak ada masalah dengan simulasi gerak, sementara itu dapat diproyeksikan ke pesawat 2D - fisika dalam kasus seperti itu bekerja dengan sangat baik, tetapi pada satu titik desainer game datang dan berkata: "Kami ingin granat!" Dan kami berpikir agar tidak ada yang berubah banyak , mengapa tidak mensimulasikan penerbangan 3D dari tubuh fisik dengan hanya data 2D yang ada.

Dan mereka memperkenalkan konsep ketinggian untuk beberapa objek.

Seperti apa perubahan hukum ketinggian dari waktu ke waktu untuk benda yang ditinggalkan, mereka melewati pelajaran fisika di kelas delapan, sehingga keputusan balistik ternyata sepele. Tapi solusi dengan tabrakan tidak sepele lagi. Mari kita bayangkan kasus ini: sebuah granat selama penerbangan harus bertabrakan dengan dinding, atau terbang di atasnya tergantung pada ketinggian saat ini dan ketinggian dinding. Kami akan menyelesaikan masalah hanya di dunia dua dimensi, di mana granat diwakili oleh lingkaran dan dinding dengan persegi panjang.


Tampilan geometri objek untuk menyelesaikan masalah.

Pertama-tama, kami mematikan interaksi tubuh dinamis granat dengan badan statis dan dinamis lainnya. Ini perlu untuk fokus pada tujuan. Dalam tugas kami, sebuah granat harus dapat melewati benda-benda lain dan β€œterbang di atas” dinding ketika proyeksi mereka pada bidang dua dimensi saling berpotongan. Dalam interaksi normal, dua objek tidak dapat saling melewati, namun dalam kasus granat dengan logika gerakan tinggi dan kustom, kami mengizinkannya melakukan ini dalam kondisi tertentu.

Kami memperkenalkan komponen terpisah dari GrenadeMovement untuk granat, di mana kami memperkenalkan konsep ketinggian:

 [Component] public class GrenadeMovement { public float Height; [DontPack] public Vector2 Velocity; [DontPack] public float VerticalVelocity; public GrenadeMovement(float height, Vector2 velocity, float verticalVelocity) { } } 

Sekarang granat memiliki koordinat ketinggian, tetapi informasi ini tidak memberikan apa-apa kepada dunia. Oleh karena itu, kami memutuskan untuk menipu dan menambahkan kondisi berikut: granat dapat terbang di atas dinding, tetapi hanya dengan ketinggian tertentu. Dengan demikian, seluruh definisi tumbukan turun untuk memeriksa tumbukan proyeksi dan membandingkan ketinggian dinding dengan nilai GrenadeMovement. Bidang tinggi. Jika ketinggian terbang granat kurang, itu bertabrakan dengan dinding, jika tidak ia dapat dengan tenang terus bergerak di sepanjang jalurnya, termasuk dalam ruang 2D.

Pada iterasi pertama, granat jatuh begitu saja ketika menemukan persimpangan, tapi kemudian kami menambahkan tabrakan elastis, dan itu mulai berperilaku hampir tidak dapat dibedakan dari hasil yang akan kami dapatkan dalam 3D.

Kode lengkap untuk menghitung lintasan granat dan tumbukan elastis diberikan di bawah ini:

Lihat kode
 using System; // ... some code ... using Volatile; namespace Common.WorldState { public sealed class GrenadeMovementSystem : ExecutableSystem { private struct Projection { public float Min; public float Max; } private float _r; private readonly Vector2[] _vertices = new Vector2[4]; private readonly Vector2[] _verticesV = new Vector2[4]; private Vector2 _Vunit; private Vector2 _VTunit; private Projection _wallProj1; private Projection _wallProj2; private Projection _wallProj1V; private Projection _wallProj2V; private const float CollisionPrecision = 1e-3f; private static readonly float HalfSlope = Mathf.Cos(Mathf.PI / 4.0f); private readonly ContactPointList _contactPoints = new ContactPointList(3); public override void Execute(GameState gs) { var settings = gs.RuleBook.GrenadeConfig[1]; _r = settings.R; var floorDampeningPerTick = (float)Math.Pow(settings.FloorDampening, 1.0 / GameState.Hz); foreach (var grenade in gs.WorldState.GrenadeMovement) { // Gravity must take effect before collision // because contact with walls may and will adjust vertical velocity // and penetration will even move the ball up. grenade.Value.VerticalVelocity -= settings.Gravity * GameState.TickDurationSec; grenade.Value.Height += grenade.Value.VerticalVelocity * GameState.TickDurationSec; // prevent falling through floor if (grenade.Value.Height <= _r) { // slow down horizontal movement by floor friction // actually, friciton is simplified to just dampening coefficient var spdH = grenade.Value.Velocity.sqrMagnitude; var spdV = grenade.Value.VerticalVelocity; var cos = spdH / Mathf.Sqrt(spdH * spdH + spdV * spdV); grenade.Value.Velocity *= floorDampeningPerTick * cos; // slow down vertical movement grenade.Value.VerticalVelocity = settings.FloorRestitution * Math.Abs(grenade.Value.VerticalVelocity); // move up to the floor level grenade.Value.Height = _r; } // A collision will stop the ball and change its velocity. // Otherwise it will be moved by velocity PerformCollisionAndMovement(gs, grenade.Key, grenade.Value); } } private void PerformCollisionAndMovement(GameState gs, uint id, GrenadeMovement grenade) { var settings = gs.RuleBook.GrenadeConfig[1]; var velocity = grenade.Velocity * GameState.TickDurationSec; var trans = gs.WorldState.Transform[id]; var position = trans.Position; _Vunit = velocity.normalized; _VTunit = new Vector2(-_Vunit.y, _Vunit.x); _vertices[0] = position + _VTunit * _r; _vertices[1] = position - _VTunit * _r; _vertices[2] = _vertices[1] + velocity; _vertices[3] = _vertices[0] + velocity; _contactPoints.Reset(); int collisions = 0; var grenProj1V = ProjectCapsule(_Vunit, _vertices, position, velocity); var grenProj2V = ProjectCapsule(_VTunit, _vertices, position, velocity); collisions += CollideWithStaticBoxes(gs, id, position, velocity, grenade, grenProj1V, grenProj2V); collisions += CollideWithCircles(gs, gs.RuleBook.StaticCircleCollider, gs.RuleBook.Transform, id, position, velocity, grenade, grenProj1V, grenProj2V, (CollisionLayer)~0); collisions += CollideWithCircles(gs, gs.WorldState.DynamicCircleCollider, gs.WorldState.Transform, id, position, velocity, grenade, grenProj1V, grenProj2V, ~CollisionLayer.Character); if (collisions == 0) { trans.Position += velocity; } else { var contactSuperposition = CalculateContactSuperposition(); trans.Position += velocity * contactSuperposition.TravelDistance; var reflectedVelocity = grenade.Velocity - 2.0f * Vector2.Dot(grenade.Velocity, contactSuperposition.Normal) * contactSuperposition.Normal; reflectedVelocity *= settings.WallRestitution; #if DEBUG_GRENADES gs.Log.Debug("contact" + "\n\ttravel " + contactSuperposition.TravelDistance + "\n\tcontactNormal " + contactSuperposition.Normal.x + ":" + contactSuperposition.Normal.y + "\n\treflected V " + reflectedVelocity.x + ":" + reflectedVelocity.y); #endif grenade.Velocity = reflectedVelocity; } } private int CollideWithStaticBoxes( GameState gs, uint id, Vector2 position, Vector2 velocity, GrenadeMovement grenade, Projection grenProj1V, Projection grenProj2V) { var settings = gs.RuleBook.GrenadeConfig[1]; var collisions = 0; // TODO spatial query foreach (var collider in gs.RuleBook.StaticBoxCollider) { var wall = collider.Value; var transform = gs.RuleBook.Transform[collider.Key]; var colliderData = gs.RuleBook.PrecomputedColliderData[collider.Key]; // test projection to V _wallProj1V = ProjectPolygon(_Vunit, colliderData.Vertices); if (!Overlap(_wallProj1V, grenProj1V)) continue; // test projection to VT _wallProj2V = ProjectPolygon(_VTunit, colliderData.Vertices); if (!Overlap(_wallProj2V, grenProj2V)) continue; // test projection to wall axis 1 _wallProj1 = ProjectPolygon(colliderData.Axis1, colliderData.Vertices); var grenProj1 = ProjectCapsule(colliderData.Axis1, _vertices, position, velocity); if (!Overlap(_wallProj1, grenProj1)) continue; // test projection to wall axis 2 _wallProj2 = ProjectPolygon(colliderData.Axis2, colliderData.Vertices); var grenProj2 = ProjectCapsule(colliderData.Axis2, _vertices, position, velocity); if (!Overlap(_wallProj2, grenProj2)) continue; var lowWall = wall.Height < settings.TallWallHeight; if (lowWall) { // the wall is too far below, ignore it completely if (grenade.Height > wall.Height + _r) continue; // if grenade if falling down, it can bounce off the top of the wall if (grenade.VerticalVelocity < 0f) { if (grenade.Height > wall.Height - _r) { var localPV = WorldToBoxLocal(transform.Position, colliderData, position + velocity); #if DEBUG_GRENADES gs.Log.Debug("fall on wall" + "\n\tP+V " + (Px + Vx) + ":" + (Py + Vy) + "\n\tlocal " + localPV.x + ":" + localPV.y + "\n\tH w " + wall.Height + " g " + grenade.Height ); #endif if (Math.Abs(localPV.x) < wall.Size.x * 0.5f || Math.Abs(localPV.y) < wall.Size.y * 0.5f) { grenade.Height = wall.Height + _r; grenade.VerticalVelocity = settings.WallRestitution * Math.Abs(grenade.VerticalVelocity); continue; } } } } // collision detected // try to find minimal V before collision var scaleV = CalcTranslationScaleBeforeCollision(CheckBoxCollision, colliderData, 0, position, velocity); var contactPoint = CalcBoxContactPoint(transform.Position, wall, colliderData, position); #if DEBUG_GRENADES gs.Log.Debug("collision grenade #" + id + " with static box #" + collider.Key + "\n\tP=" + Px + ":" + Py + "\n\tV=" + Vx + ":" + Vy + " scale=" + scaleV + "\n\tP+Vs=" + (Px + Vx * scaleV) + ":" + (Py + Vy * scaleV) + "\n\twall pos " + transform.Position.x + ":" + transform.Position.y + " sz " + wall.Size.x + ":" + wall.Size.y + " angle " + transform.Angle + "\n\tproj V w " + _wallProj1V.Min + ":" + _wallProj1V.Max + " g " + grenProj1V.Min + ":" + grenProj1V.Max + " overlap=" + Overlap(_wallProj1V, grenProj1V) + "\n\tproj VT w " + _wallProj2V.Min + ":" + _wallProj2V.Max + " g " + grenProj2V.Min + ":" + grenProj2V.Max + " overlap=" + Overlap(_wallProj2V, grenProj2V) + "\n\taxis1 " + colliderData.Axis1.x + ":" + colliderData.Axis1.y + "\n\tproj 1 w " + _wallProj1.Min + ":" + _wallProj1.Max + " g " + grenProj1.Min + ":" + grenProj1.Max + " overlap=" + Overlap(_wallProj1, grenProj1) + "\n\taxis2 " + colliderData.Axis2.x + ":" + colliderData.Axis2.y + "\n\tproj 2 w " + _wallProj2.Min + ":" + _wallProj2.Max + " g " + grenProj2.Min + ":" + grenProj2.Max + " overlap=" + Overlap(_wallProj2, grenProj2) + "\n\tpoint " + contactPoint.Point.x + ":" + contactPoint.Point.y + " dotV " + Vector2.Dot(P - contactPoint.Point, V) ); #endif // ignore colliders that are behind if (Vector2.Dot(position - contactPoint.Point, velocity) >= 0.0f) continue; contactPoint.TravelDistance = velocity.magnitude * scaleV; _contactPoints.Add(ref contactPoint); collisions++; } return collisions; } private bool CheckBoxCollision(PrecomputedColliderData colliderData, int x, Vector2 position, Vector2 velocity) { _verticesV[0] = _vertices[0]; _verticesV[1] = _vertices[1]; _verticesV[2] = _vertices[1] + velocity; _verticesV[3] = _vertices[0] + velocity; // test projection to V var grenProj1V = ProjectCapsule(_Vunit, _verticesV, position, velocity); if (!Overlap(_wallProj1V, grenProj1V)) return false; // testing projection to VT would be redundant // test projection to wall axis 1 var grenProj1 = ProjectCapsule(colliderData.Axis1, _verticesV, position, velocity); if (!Overlap(_wallProj1, grenProj1)) return false; // test projection to wall axis 2 var grenProj2 = ProjectCapsule(colliderData.Axis2, _verticesV, position, velocity); if (!Overlap(_wallProj2, grenProj2)) return false; return true; } private int CollideWithCircles( GameState gs, Table<CircleCollider> colliderTable, Table<Transform> transformTable, uint id, Vector2 position, Vector2 velocity, GrenadeMovement grenade, Projection grenProj1V, Projection grenProj2V, CollisionLayer collisionLayers) { var settings = gs.RuleBook.GrenadeConfig[1]; var collisions = 0; foreach (var collider in colliderTable) { if ((int)collisionLayers != ~0) { var body = gs.WorldState.PhysicsDynamicBody[collider.Key]; if (body != null && (body.CollisionLayer & collisionLayers) == 0) continue; } var wall = collider.Value; var transform = transformTable[collider.Key]; // test projection to V _wallProj1V = ProjectCircle(_Vunit, transform.Position, wall.Radius); if (!Overlap(_wallProj1V, grenProj1V)) continue; // test projection to VT _wallProj2V = ProjectCircle(_VTunit, transform.Position, wall.Radius); if (!Overlap(_wallProj2V, grenProj2V)) continue; // test distance from the circle wall to semicircles on capsule ends var collisionDistance = (_r + wall.Radius) * (_r + wall.Radius); if ((position - transform.Position).sqrMagnitude > collisionDistance) continue; var distSqr = (position + velocity - transform.Position).sqrMagnitude; if (distSqr > collisionDistance) continue; var lowWall = wall.Height < settings.TallWallHeight; if (lowWall) { // the wall is too far below, ignore it completely if (grenade.Height > wall.Height + _r) continue; // if grenade if falling down, it can bounce off the top of the wall if (grenade.VerticalVelocity < 0f) { if (grenade.Height > wall.Height - _r) { #if DEBUG_GRENADES gs.Log.Debug("grenade #" + id + " falls on wall" + "\n\tP+V " + (Px + Vx) + ":" + (Py + Vy) + "\n\tdist " + Mathf.Sqrt(distSqr) + "\n\tH w " + wall.Height + " g " + grenade.Height ); #endif if (distSqr < wall.Radius * wall.Radius) { grenade.Height = wall.Height + _r; grenade.VerticalVelocity = settings.WallRestitution * Math.Abs(grenade.VerticalVelocity); continue; } } } } // collision detected // try to find minimal V before collision var scaleV = CalcTranslationScaleBeforeCollision(CheckCircleCollision, transform.Position, wall, position, velocity); var contactPoint = CalcCircleContactPoint(transform.Position, wall, position); #if DEBUG_GRENADES gs.Log.Debug("collision grenade #" + id + " with circle #" + collider.Key + "\n\tP=" + Px + ":" + Py + "\n\tV=" + Vx + ":" + Vy + " scale=" + scaleV + "\n\tP+Vs=" + (Px + Vx * scaleV) + ":" + (Py + Vy * scaleV) + "\n\tcircle pos " + transform.Position.x + ":" + transform.Position.y + " r " + wall.Radius + "\n\tdist " + (transform.Position - (P + V * scaleV)).magnitude + "\n\tproj V w " + _wallProj1V.Min + ":" + _wallProj1V.Max + " g " + grenProj1V.Min + ":" + grenProj1V.Max + " overlap=" + Overlap(_wallProj1V, grenProj1V) + "\n\tproj VT w " + _wallProj2V.Min + ":" + _wallProj2V.Max + " g " + grenProj2V.Min + ":" + grenProj2V.Max + " overlap=" + Overlap(_wallProj2V, grenProj2V) + "\n\tpoint " + contactPoint.Point.x + ":" + contactPoint.Point.y + " dotV " + Vector2.Dot(P - contactPoint.Point, V) ); #endif // ignore colliders that are behind if (Vector2.Dot(position - contactPoint.Point, velocity) >= 0.0f) continue; contactPoint.TravelDistance = velocity.magnitude * scaleV; _contactPoints.Add(ref contactPoint); collisions++; } return collisions; } private bool CheckCircleCollision(Vector2 wallCentre, CircleCollider wall, Vector2 position, Vector2 velocity) { _verticesV[0] = _vertices[0]; _verticesV[1] = _vertices[1]; _verticesV[2] = _vertices[1] + velocity; _verticesV[3] = _vertices[0] + velocity; // test projection to V var grenProj1V = ProjectCapsule(_Vunit, _verticesV, position, velocity); if (!Overlap(_wallProj1V, grenProj1V)) return false; // testing projection to VT would be redundant // test distance from the circle wall to the semicircle on the second capsule end var dSqr = (_r + wall.Radius) * (_r + wall.Radius); return (position + velocity - wallCentre).sqrMagnitude < dSqr; } private static float CalcTranslationScaleBeforeCollision<TData1, TData2>( Func<TData1, TData2, Vector2, Vector2, bool> collision, TData1 colliderData1, TData2 colliderData2, Vector2 position, Vector2 vector) { var min = 0.0f; var max = 1.0f; while (true) { var d = (max - min) * 0.5f; if (d < CollisionPrecision) break; var scale = min + d; if (collision(colliderData1, colliderData2, position, vector * scale)) { max = scale; } else { min = scale; } } return min; } private ContactPoint CalculateContactSuperposition() { ContactPoint contactSuperposition; _contactPoints.TryPopClosest(1000f, out contactSuperposition); ContactPoint contact; while (_contactPoints.TryPopClosest(contactSuperposition.TravelDistance, out contact)) { contactSuperposition.Normal += contact.Normal; } contactSuperposition.Normal = contactSuperposition.Normal.normalized; return contactSuperposition; } private static Projection ProjectPolygon(Vector2 axisNormalised, Vector2[] vertices) { Projection proj; var d = Vector2.Dot(axisNormalised, vertices[0]); proj.Min = d; proj.Max = d; for (var i = 1; i < vertices.Length; i++) { d = Vector2.Dot(axisNormalised, vertices[i]); proj.Min = Mathf.Min(proj.Min, d); proj.Max = Mathf.Max(proj.Max, d); } return proj; } private Projection ProjectCapsule(Vector2 axisNormalised, Vector2[] vertices, Vector2 p, Vector2 v) { var proj = ProjectPolygon(axisNormalised, vertices); proj = AddCircleProjection(proj, axisNormalised, p, _r); proj = AddCircleProjection(proj, axisNormalised, p + v, _r); return proj; } private static Projection AddCircleProjection(Projection proj, Vector2 axisNormalised, Vector2 centre, float r) { var c = Vector2.Dot(axisNormalised, centre); proj.Min = Mathf.Min(proj.Min, c - r); proj.Max = Mathf.Max(proj.Max, c + r); return proj; } private static Projection ProjectCircle(Vector2 axisNormalised, Vector2 centre, float r) { Projection proj; var c = Vector2.Dot(axisNormalised, centre); proj.Min = c - r; proj.Max = c + r; return proj; } private static bool Overlap(Projection p1, Projection p2) { return p1.Min < p2.Min ? p1.Max > p2.Min : p2.Max > p1.Min; } private static Vector2 WorldToBoxLocal(Vector2 wallCentre, PrecomputedColliderData colliderData, Vector2 position) { return new Vector2( Vector2.Dot(colliderData.Axis1, position) - Vector2.Dot(colliderData.Axis1, wallCentre), Vector2.Dot(colliderData.Axis2, position) - Vector2.Dot(colliderData.Axis2, wallCentre) ); } private static ContactPoint CalcBoxContactPoint(Vector2 wallCentre, BoxCollider wall, PrecomputedColliderData colliderData, Vector2 position) { var contactPoint = CaclBoxLocalContactPoint(wall.Size * 0.5f, WorldToBoxLocal(wallCentre, colliderData, position)); var worldAxisX = new Vector2(colliderData.Axis1.x, -colliderData.Axis1.y); var worldAxisY = new Vector2(colliderData.Axis1.y, colliderData.Axis1.x); contactPoint.Point = wallCentre + new Vector2(Vector2.Dot(worldAxisX, contactPoint.Point), Vector2.Dot(worldAxisY, contactPoint.Point)); contactPoint.Normal = new Vector2(Vector2.Dot(worldAxisX, contactPoint.Normal), Vector2.Dot(worldAxisY, contactPoint.Normal)); return contactPoint; } private static ContactPoint CaclBoxLocalContactPoint(Vector2 boxHalfSize, Vector2 localPosition) { ContactPoint localContactPoint = default(ContactPoint); // cases are numbered like numpad keys // 1, 2, 3 if (localPosition.y < -boxHalfSize.y) { // 1 if (localPosition.x < -boxHalfSize.x) { localContactPoint.Point = new Vector2(-boxHalfSize.x, -boxHalfSize.y); localContactPoint.Normal = new Vector2(-HalfSlope, -HalfSlope); } // 2, 3 else { // 3 if (localPosition.x > boxHalfSize.x) { localContactPoint.Point = new Vector2(boxHalfSize.x, -boxHalfSize.y); localContactPoint.Normal = new Vector2(HalfSlope, -HalfSlope); } // 2 else { localContactPoint.Point = new Vector2(localPosition.x, -boxHalfSize.y); localContactPoint.Normal = new Vector2(0.0f, -1.0f); } } } // 4, 6, 7, 8, 9 else { // 7, 8, 9 if (localPosition.y > boxHalfSize.y) { // 7 if (localPosition.x < -boxHalfSize.x) { localContactPoint.Point = new Vector2(-boxHalfSize.x, boxHalfSize.y); localContactPoint.Normal = new Vector2(-HalfSlope, HalfSlope); } // 8, 9 else { // 9 if (localPosition.x > boxHalfSize.x) { localContactPoint.Point = new Vector2(boxHalfSize.x, boxHalfSize.y); localContactPoint.Normal = new Vector2(HalfSlope, HalfSlope); } // 8 else { localContactPoint.Point = new Vector2(localPosition.x, boxHalfSize.y); localContactPoint.Normal = new Vector2(0.0f, 1.0f); } } } // 4, 6 else { // 4 if (localPosition.x < -boxHalfSize.x) { localContactPoint.Point = new Vector2(-boxHalfSize.x, localPosition.y); localContactPoint.Normal = new Vector2(-1.0f, 0.0f); } // 6 else { localContactPoint.Point = new Vector2(boxHalfSize.x, localPosition.y); localContactPoint.Normal = new Vector2(1.0f, 0.0f); } } } return localContactPoint; } private static ContactPoint CalcCircleContactPoint(Vector2 wallCentre, CircleCollider wall, Vector2 position) { ContactPoint contactPoint = default(ContactPoint); contactPoint.Normal = (position - wallCentre).normalized; contactPoint.Point = wallCentre + wall.Radius * contactPoint.Normal; return contactPoint; } } } 


. Apa selanjutnya


, , - , . ECS . , , JSON, ECS. :



, «». ECS, , . ― ― , , ECS, ECS . , API, , , . , .

- 2D-: , . , : , opensource , - . ECS, , . , , . - , , . ― - .

- , 3D-, , .

, , , . , , ECS .

Tautan yang bermanfaat


:


:

Source: https://habr.com/ru/post/id481880/


All Articles