Penciptaan Minecraft dalam Satu Minggu dalam C ++ dan Vulkan

Saya mengatur sendiri tugas membuat ulang Minecraft dari awal dalam satu minggu menggunakan mesin saya sendiri di C ++ dan Vulkan. Saya terinspirasi oleh Hopson , yang melakukan hal yang sama dengan C ++ dan OpenGL. Pada gilirannya, dia terinspirasi oleh Shane Beck , yang terinspirasi oleh Minecraft, sumber inspirasi yang untuknya adalah Infiniminer, yang ciptaannya, mungkin, diilhami oleh penambangan nyata.


Repositori GitHub untuk proyek ini ada di sini . Setiap hari memiliki tag git sendiri.

Tentu saja, saya tidak berencana untuk membuat ulang Minecraft secara harfiah. Proyek ini seharusnya menjadi proyek pendidikan. Saya ingin belajar tentang menggunakan Vulkan dalam sesuatu yang lebih rumit daripada vulkan-tutorial.com atau demo Sasha Willem. Oleh karena itu, penekanan utama adalah pada desain mesin Vulkan, dan bukan pada desain game.

Tugas


Pengembangan pada Vulkan jauh lebih lambat daripada di OpenGL, jadi saya tidak bisa memasukkan banyak fitur Minecraft ini ke dalam gim. Tidak ada massa, tidak ada kerajinan, tidak ada batu merah, tidak ada fisika blok, dll. Sejak awal, tujuan proyek adalah sebagai berikut:

  • Membuat sistem render medan
    • Menumbuk
    • Petir
  • Membuat sistem generator medan
    • Bantuan
    • Pohon
    • Bioma
  • Menambahkan kemampuan untuk mengubah medan dan memindahkan blok

Saya perlu menemukan cara untuk mengimplementasikan semua ini tanpa menambahkan GUI ke dalam game, karena saya tidak dapat menemukan perpustakaan GUI yang bekerja dengan Vulkan dan mudah diintegrasikan.

Perpustakaan


Tentu saja, saya tidak akan menulis aplikasi Vulkan dari awal. Untuk mempercepat proses pengembangan, saya akan menggunakan perpustakaan yang sudah jadi jika memungkinkan. Yaitu:

  • VulkanWrapper - pembungkus C ++ saya sendiri untuk Vulkan API
  • GLFW - untuk windows dan input pengguna
  • VulkanMemoryAllocator - untuk mengalokasikan memori Vulkan
  • GLM - untuk vektor dan matriks matematika
  • entt - untuk sinyal / slot dan ECS
  • stb - untuk utilitas pemuatan gambar
  • FastNoise - untuk menghasilkan suara 3D

Hari 1


Pada hari pertama, saya menyiapkan pelat Vulkan dan kerangka mesin. Sebagian besar kode adalah boilerplate dan saya bisa menyalinnya dari vulkan-tutorial.com . Ini juga termasuk trik dengan menyimpan data vertex sebagai bagian dari shader vertex. Ini berarti bahwa saya bahkan tidak perlu menyetel alokasi memori. Hanya konveyor sederhana yang hanya bisa melakukan satu hal: menggambar segitiga.

Mesinnya cukup sederhana untuk mendukung renderer segitiga. Ini memiliki satu jendela dan loop game yang sistemnya dapat dihubungkan. GUI dibatasi oleh frame rate yang ditampilkan dalam judul jendela.

Proyek ini dibagi menjadi dua bagian: VoxelEnginedan VoxelGame.


Hari ke-2


Saya mengintegrasikan perpustakaan Vulkan Memory Allocator. Perpustakaan ini menangani sebagian besar pelat alokasi alokasi memori Vulkan: tipe memori, tumpukan memori perangkat, dan alokasi sekunder.

Sekarang saya memiliki alokasi memori, saya membuat kelas untuk jerat dan buffer verteks. Saya mengubah renderer segitiga sehingga menggunakan kelas jerat, dan bukan array yang dibangun ke dalam shader. Saat ini, data mesh ditransfer ke GPU dengan memberikan segitiga secara manual.


Sedikit yang berubah

Hari ke-3


Saya menambahkan sistem rendering grafik. Posting ini diambil sebagai dasar untuk membuat kelas ini , tetapi kelasnya sangat disederhanakan. Grafik rendering saya hanya berisi hal-hal penting untuk menangani sinkronisasi dengan Vulkan.

Grafik rendering memungkinkan saya untuk mengatur node dan edge. Node adalah pekerjaan yang dilakukan oleh GPU. Rusuk adalah ketergantungan data antara node. Setiap node menerima buffer instruksi sendiri, di mana ia menulis. Grafik terlibat dalam buffer perintah buffering ganda dan menyinkronkannya dengan frame sebelumnya. Tepi digunakan untuk secara otomatis memasukkan penghalang konveyor sebelum dan sesudah sebuah node menulis ke setiap buffer instruksi. Hambatan pipa menyinkronkan penggunaan semua sumber daya dan mentransfer kepemilikan antar antrian. Selain itu, tepi menyisipkan semaphores antara node.

Node dan edge membentuk grafik asiklik terarah . Kemudian grafik rendering melakukan penyortiran topologis.node, yang mengarah ke pembuatan daftar datar node yang diurutkan sehingga setiap node berjalan setelah semua node yang bergantung padanya.

Mesin memiliki tiga jenis node. AcquireNodemenerima gambar dari rantai buffer (swapchain), TransferNodementransfer data dari CPU ke GPU, dan PresentNodemenyediakan gambar rantai buffer untuk ditampilkan.

Setiap node dapat diimplementasikan preRender, renderdan postRender, yang dieksekusi di setiap frame. AcquireNodemendapat gambar rantai buffer selama preRender. PresentNodemenyediakan gambar ini tepat waktu postRender.

Saya refactored renderer segitiga sehingga menggunakan sistem grafik rendering, daripada memproses semuanya sendiri. Ada keunggulan antara AcquireNodedanTriangleRendererserta antara TriangleRendererdan PresentNode. Ini memastikan bahwa gambar rantai penyangga disinkronkan dengan benar selama penggunaannya selama bingkai.


Saya bersumpah di dalam mesin telah berubah

Hari ke 4


Saya membuat kamera dan sistem render 3D. Sejauh ini, kamera menerima buffer dan deskriptor kumpulan yang persisten.

Saya melambat hari itu karena saya mencoba mencari konfigurasi yang tepat untuk rendering 3D dengan Vulkan. Sebagian besar materi daring berfokus pada rendering menggunakan OpenGL, yang menggunakan sistem koordinat yang sedikit berbeda dari Vulkan. Di OpenGL, sumbu Z dari ruang klip ditentukan sebagai [-1, 1], dan tepi atas layar berada di Y = 1. Di Vulkan, sumbu Z ditentukan sebagai [0, 1], dan tepi atas layar berada di Y = -1. Karena perbedaan kecil ini, matriks proyeksi GLM standar tidak berfungsi dengan benar karena dirancang untuk OpenGL.

GLM memiliki opsiGLM_FORCE_DEPTH_ZERO_TO_ONE, menghilangkan masalah dengan sumbu Z. Setelah itu, masalah dengan sumbu Y dapat dihilangkan dengan hanya mengubah tanda elemen (1, 1)matriks proyeksi (GLM menggunakan pengindeksan dari 0).

Jika kita membalik sumbu Y, maka kita perlu membalik data verteks, karena sebelum itu, arah negatif dari sumbu Y mengarah ke atas.


Sekarang dalam 3D!

Hari ke 5


Saya menambahkan input pengguna dan kemampuan untuk memindahkan kamera dengan mouse. Sistem input terlalu canggih, tetapi menghilangkan keanehan input GLFW. Secara khusus, saya punya masalah mengubah posisi mouse sambil memblokirnya.

Input keyboard dan mouse pada dasarnya adalah pembungkus tipis di atas GLFW, dibuka melalui penangan sinyal entt.

Sebagai perbandingan - tentang hal yang sama yang dilakukan Hopson pada hari pertama proyeknya.


Hari 6


Saya mulai menambahkan kode untuk menghasilkan dan merender blok voxel. Menulis kode meshing itu mudah karena saya melakukannya sebelumnya dan tahu abstraksi yang memungkinkan saya untuk membuat lebih sedikit kesalahan.

Salah satu abstraksi adalah kelas templat ChunkData<T, chunkSize>yang mendefinisikan kubus dengan tipe Tukuran chunkSizemasing-masing sisi. Kelas ini menyimpan data dalam larik 1D dan memproses pengindeksan data dengan koordinat 3D. Ukuran setiap blok adalah 16 x 16 x 16, sehingga data internal adalah array sederhana dengan panjang 4.096

. Abstraksi lain adalah membuat iterator posisi yang menghasilkan koordinat dari (0, 0, 0)hingga(15, 15, 15). Kedua kelas ini memastikan bahwa iterasi dengan blok data dilakukan dalam urutan linear untuk meningkatkan lokalitas cache. Koordinat 3D masih tersedia untuk operasi lain yang membutuhkannya. Contohnya:

for (glm::ivec3 pos : Chunk::Positions()) {
    auto& data = chunkData[pos];
    glm::ivec3 offset = ...;
    auto& neighborData = chunkData[pos + offset];
}

Saya memiliki beberapa array statis yang menentukan offset yang biasa digunakan dalam game. Sebagai contoh, ia Neighbors6mendefinisikan 6 tetangga dengan kubus memiliki wajah yang sama.

static constexpr std::array<glm::ivec3, 6> Neighbors6 = {
        glm::ivec3(1, 0, 0),    //right
        glm::ivec3(-1, 0, 0),   //left
        glm::ivec3(0, 1, 0),    //top
        glm::ivec3(0, -1, 0),   //bottom
        glm::ivec3(0, 0, 1),    //front
        glm::ivec3(0, 0, -1)    //back
    };

Neighbors26- semua ini adalah tetangga yang memiliki kubus, wajah, tepi atau simpul yang sama. Yaitu, itu adalah kotak 3x3x3 tanpa kubus pusat. Ada juga array serupa untuk set tetangga lain dan untuk set 2D tetangga.

Ada array yang mendefinisikan data yang dibutuhkan untuk membuat satu wajah kubus. Arah setiap wajah dalam array ini sesuai dengan arah dalam array Neighbors6.

static constexpr std::array<FaceArray, 6> NeighborFaces = {
    //right face
    FaceArray {
        glm::ivec3(1, 1, 1),
        glm::ivec3(1, 1, 0),
        glm::ivec3(1, 0, 1),
        glm::ivec3(1, 0, 0),
    },
    ...
};

Berkat ini, kode kreasi mesh sangat sederhana. Itu hanya mem-bypass data blok dan menambahkan wajah ketika blok solid, tetapi tetangganya tidak. Kode hanya memeriksa setiap wajah dari setiap kubus di blok. Ini mirip dengan metode "naif" yang dijelaskan di sini .

for (glm::ivec3 pos : Chunk::Positions()) {
    Block block = chunk.blocks()[pos];
    if (block.type == 0) continue;

    for (size_t i = 0; i < Chunk::Neighbors6.size(); i++) {
        glm::ivec3 offset = Chunk::Neighbors6[i];
        glm::ivec3 neighborPos = pos + offset;

        //NOTE: bounds checking omitted

        if (chunk.blocks()[neighborPos].type == 0) {
            Chunk::FaceArray& faceArray = Chunk::NeighborFaces[i];
            for (size_t j = 0; j < faceArray.size(); j++) {
                m_vertexData.push_back(pos + faceArray[j]);
                m_colorData.push_back(glm::i8vec4(pos.x * 16, pos.y * 16, pos.z * 16, 0));
            }
        }
    }
}

Saya diganti TriangleRendererdengan ChunkRenderer. Saya juga menambahkan buffer kedalaman sehingga blok jala dapat dirender dengan benar. Itu perlu untuk menambahkan satu sisi lagi ke grafik rendering antara TransferNodedan ChunkRenderer. Tepi ini mentransfer kepemilikan sumber daya keluarga antrian antara antrian transfer dan antrian grafis.

Lalu saya mengganti mesinnya sehingga bisa menangani acara perubahan jendela dengan benar. Dalam OpenGL, ini dilakukan secara sederhana, tetapi agak membingungkan di Vulkan. Karena rantai buffer harus dibuat secara eksplisit dan memiliki ukuran konstan, ketika Anda mengubah ukuran jendela, Anda perlu membuatnya kembali. Anda harus membuat ulang semua sumber daya yang bergantung pada rantai penyangga.

Semua perintah yang bergantung pada rantai penyangga (dan sekarang ini semua perintah menggambar) harus menyelesaikan eksekusi sebelum menghancurkan rantai penyangga yang lama. Ini berarti bahwa seluruh GPU akan menganggur.

Anda perlu mengubah jalur grafik untuk memberikan viewport dinamis dan mengubah ukuran.

Rantai penyangga tidak dapat dibuat jika ukuran jendela adalah 0 pada sumbu X atau Y. Termasuk ketika jendela diminimalkan. Yaitu, ketika ini terjadi, seluruh permainan dihentikan sementara dan berlanjut hanya ketika jendela terbuka.

Sekarang mesh adalah papan catur tiga dimensi sederhana. Warna RGB dari mesh diatur sesuai dengan posisi XYZ-nya dikalikan 16.



Hari ke 7


Saya membuat proses permainan bukan hanya satu, tetapi beberapa blok sekaligus. Beberapa blok dan jeratnya dikelola oleh perpustakaan ECS entt. Kemudian saya refactored renderer blok sehingga memberikan semua blok yang ada di ECS. Saya hanya memiliki satu blok, tetapi saya bisa menambahkan yang baru jika perlu.

Saya refactored mesh sehingga datanya dapat diperbarui setelah itu dibuat. Ini akan memungkinkan saya untuk memperbarui blokir blok di masa depan ketika saya menambahkan kemampuan untuk menambah dan menghapus kubus.

Saat Anda menambah atau menghapus kubus, jumlah simpul di jala berpotensi meningkat atau berkurang. Buffer vertex yang dipilih sebelumnya hanya dapat digunakan jika mesh baru memiliki ukuran yang sama atau lebih kecil. Tetapi jika mesh lebih besar, maka buffer vertex baru harus dibuat.

Buffer vertex sebelumnya tidak dapat segera dihapus. Mungkin ada buffer instruksi yang dieksekusi dari frame sebelumnya yang khusus untuk objek tertentu VkBuffer. Mesin harus menyimpan buffer sampai buffer perintah ini selesai. Artinya, jika kita menggambar mesh dalam sebuah frame i, GPU dapat menggunakan buffer ini sebelum frame dimulai i + 2. Buffer tidak dapat dihapus dari CPU sampai GPU selesai menggunakannya. Jadi saya mengubah grafik rendering sehingga melacak masa pakai sumber daya.

Jika simpul grafik render ingin menggunakan sumber daya (buffer atau gambar), maka simpul itu harus memanggil metode syncdi dalam metode tersebut preRender. Metode ini mendapatkan pointer shared_ptrke sumber daya. Inishared_ptrmemastikan bahwa sumber daya tidak akan dihapus saat buffer perintah dieksekusi. (Dalam hal kinerja, solusi ini tidak terlalu baik. Lebih lanjut tentang ini nanti.)

Sekarang blok jala dibuat ulang di setiap frame.


Kesimpulan


Itu semua yang saya lakukan dalam seminggu - menyiapkan dasar-dasar rendering dunia dengan beberapa blok voxel dan akan terus bekerja di minggu kedua.

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


All Articles