Buat Penembak Neon Vektor di XNA: Bloom dan Black Holes
Indonesian (Bahasa Indonesia) translation by Sap (you can also view the original English article)
Dalam seri tutorial ini, saya akan menunjukkan cara membuat tongkat shooter neon, seperti Geometri Wars, di XNA. Tujuan dari tutorial ini bukan untuk meninggalkan Anda dengan replika Perang Geometri yang sama persis, melainkan untuk membahas elemen-elemen penting yang memungkinkan Anda membuat varian berkualitas tinggi sendiri.
Overview
Dalam seri sejauh ini, kita telah mengatur basic gameplay untuk tongkat shooter neon, Shape Blaster. Dalam tutorial ini kita akan membuat tampilan neon tanda tangan dengan menambahkan filter pasca-pemrosesan bloom.
Efek sederhana seperti ini atau efek partikel dapat membuat permainan jauh lebih menarik tanpa membutuhkan perubahan pada gameplay. Penggunaan efek visual yang efektif merupakan pertimbangan penting dalam game manapun. Setelah menambahkan filter bloom, kita juga akan menambahkan black hole kedalam game.
Efek Paska Proses Bloom
Bloom menggambarkan efek yang Anda lihat ketika Anda melihat suatu objek dengan cahaya terang di belakangnya dan cahaya tampak memudar di atas objek. Dalam Shape Blaster, efek bloom akan membuat garis-garis terang dari kapal dan partikel terlihat seperti lampu neon yang terang dan bersinar.



Untuk menerapkan bloom dalam sebuah game, kita harus menjadikan adegan sebagai target render, dan kemudian menerapkan filter bloom ke target render tersebut.
Bloom bekerja dalam tiga langkah:
- Ekstrak bagian terang dari gambar.
- Memburamkan bagian yang terang.
- Kombinasikan gambar buram dengan gambar asli saat melakukan beberapa penyesuaian kecerahan dan saturasi.
Setiap langkah ini membutuhkan shader - pada dasarnya program singkat yang berjalan pada kartu grafis Anda. Shaders di XNA ditulis dalam bahasa khusus yang disebut High-Level Shader Language (HLSL). Gambar contoh di bawah ini menunjukkan hasil dari setiap langkah.












Menambahkan Bloom ke Shape Blaster
Untuk filter bloom, kita akan menggunakan Sampel XNA Bloom Postprocess.
Mengintegrasikan sampel bloom dengan proyek sangatlah mudah. Pertama, temukan dua file kode dari contoh, BloomComponent.cs
dan BloomSettings.cs
, dan tambahkan ke proyek ShapeBlaster. Juga tambahkan BloomCombine.fx
, BloomExtract.fx
, dan GaussianBlur.fx
ke proyek pipeline konten.
Di GameRoot
, tambahkan pernyataan using
untuk namespace BloomPostprocess
dan tambahkan variabel anggota BloomComponent
.
BloomComponent bloom;
Di konstruktor GameRoot
, tambahkan baris berikut.
bloom = new BloomComponent(this); Components.Add(bloom); bloom.Settings = new BloomSettings(null, 0.25f, 4, 2, 1, 1.5f, 1);
Akhirnya, pada awal GameRoot.Draw()
, tambahkan baris berikut.
bloom.BeginDraw();
Itu dia. Jika Anda menjalankan gim ini sekarang, Anda akan melihat bloom yang terjadi.
Saat Anda memakai bloom.BeginDraw()
, ia akan mengalihkan pemakaian gambar berikutnya ke target render yang akan diterapkan. Saat Anda memakai base.Draw()
di akhir metode GameRoot.Draw()
, metode Draw()
BloomComponent
dipakai. Di sinilah bloom diterapkan dan adegan ditarik ke buffer belakang. Oleh karena itu, apa pun yang perlu mekar diterapkan harus ditarik antara panggilan untuk bloom.BeginDraw()
dan base.Draw()
.
Tip: Jika Anda ingin menggambar sesuatu tanpa bloom (misalnya, user interface), gambarlah setelah panggilan ke base.Draw()
.
Anda dapat men-tweak pengaturan mekar sesuai dengan keinginan Anda. Saya telah memilih nilai-nilai berikut:
-
0.25
untuk ambang bloom. Ini berarti setiap bagian dari gambar yang kurang dari seperempat kecerahan penuh tidak akan berkontribusi untuk bloom. -
4
untuk jumlah blur. Untuk yang cenderung matematis, ini adalah standar deviasi dari Gaussian blur. Nilai yang lebih besar akan mengaburkan cahaya yang mekar lebih banyak. Namun, perlu diingat bahwa blur shader diatur untuk menggunakan sejumlah sampel tetap, terlepas dari jumlah blur. Jika Anda menetapkan nilai ini terlalu tinggi, blur akan melampaui radius dari mana sampel shader, dan artefak akan muncul. Idealnya, nilai ini seharusnya tidak lebih dari sepertiga dari radius pengambilan sampel Anda untuk memastikan kesalahannya dapat diabaikan. -
2
untuk intensitas bloom, yang menentukan seberapa kuat bloom mempengaruhi hasil akhir. -
1
untuk intensitas dasar, yang menentukan seberapa kuat gambar asli mempengaruhi hasil akhir. -
1.5
untuk saturation bloom. Ini menyebabkan cahaya di sekitar objek terang memiliki warna yang lebih jenuh daripada objek itu sendiri. Nilai tinggi dipilih untuk mensimulasikan tampilan lampu neon. Jika Anda melihat pusat cahaya neon yang terang, ia terlihat hampir putih, sementara cahaya di sekitarnya lebih berwarna. -
1
untuk saturasi dasar. Nilai ini mempengaruhi saturasi gambar dasar.






Bloom Dibawah Hood
Filter bloom diterapkan di kelas BloomComponent
. Komponen mekar dimulai dengan membuat dan memuat sumber daya yang diperlukan dalam metode LoadContent()
. Di sini, ia memuat tiga shader yang diperlukan dan menciptakan tiga target render.
Target render pertama, sceneRenderTarget
, adalah untuk memegang adegan bahwa mekar akan diterapkan. Dua lainnya, renderTarget1
dan renderTarget2
, digunakan untuk menahan sementara hasil perantara antara setiap render pass. Target-target ini dibuat setengah dari resolusi permainan untuk mengurangi biaya kinerja. Ini tidak mengurangi kualitas akhir dari bloom, karena kita akan mengaburkan gambar bloom.
Bloom membutuhkan empat lintasan perenderan, seperti yang ditunjukkan dalam diagram ini:



Di XNA, kelas Effect
merangkum shader. Anda menulis kode untuk shader dalam file terpisah, yang Anda tambahkan ke pipeline konten. Ini adalah file dengan ekstensi .fx
yang kami tambahkan sebelumnya. Anda memuat shader ke objek Effect
dengan memanggil metode Content.Load<effect>()
di LoadContent()
. Cara termudah untuk menggunakan shader dalam game 2D adalah dengan mengirimkan objek Effect
sebagai parameter ke SpriteBatch.Begin()
.
Ada beberapa jenis shader, tetapi untuk filter bloom kita hanya akan menggunakan pixel shaders (kadang-kadang disebut shader fragment). Shader piksel adalah program kecil yang berjalan sekali untuk setiap piksel yang Anda gambar dan menentukan warna piksel. Kita akan memeriksa masing-masing shader yang digunakan.
BloomExtract
Shader
BloomExtract
shader adalah yang paling sederhana dari tiga shader. Tugasnya adalah mengekstrak area gambar yang lebih terang dari beberapa ambang lalu menskala ulang nilai warna untuk menggunakan rentang warna penuh. Nilai apa pun di bawah ambang akan menjadi hitam.
Kode shader penuh ditunjukkan di bawah ini.
sampler TextureSampler : register(s0); float BloomThreshold; float4 PixelShaderFunction(float2 texCoord : TEXCOORD0) : COLOR0 { // Look up the original image color. float4 c = tex2D(TextureSampler, texCoord); // Adjust it to keep only values brighter than the specified threshold. return saturate((c - BloomThreshold) / (1 - BloomThreshold)); } technique BloomExtract { pass Pass1 { PixelShader = compile ps_2_0 PixelShaderFunction(); } }
Jangan khawatir jika Anda tidak terbiasa dengan HLSL. Mari kita periksa bagaimana ini bekerja.
sampler TextureSampler : register(s0);
Bagian pertama ini mendeklarasikan sampler tekstur yang disebut TextureSampler
. SpriteBatch
akan mengikat tekstur ke sampler ini saat menggambar dengan shader ini. Menentukan yang mendaftar untuk terikat merupakan opsional. Kita menggunakan sampler untuk mencari piksel dari tekstur terikat.
float BloomThreshold;
BloomThreshold
adalah parameter yang bisa kita atur dari kode C# kita.
float4 PixelShaderFunction(float2 texCoord : TEXCOORD0) : COLOR0 {
Ini adalah deklarasi fungsi shader piksel kita yang mengambil koordinat tekstur sebagai input dan mengembalikan warna. Warnanya dikembalikan sebagai float4
. Ini adalah kumpulan dari empat pelampung, mirip dengan Vector4
di XNA. Mereka menyimpan komponen warna merah, hijau, biru, dan alfa dari warna sebagai nilai antara nol dan satu.
TEXCOORD0
dan COLOR0
disebut semantik, dan mereka menunjukkan kepada compiler bagaimana parameter texCoord
dan nilai kembalinya digunakan. Untuk setiap output piksel, texCoord
akan berisi koordinat titik yang sesuai dalam tekstur input, dengan (0, 0)
sebagai sudut kiri atas dan (1, 1)
adalah kanan bawah.
// Look up the original image color. float4 c = tex2D(TextureSampler, texCoord); // Adjust it to keep only values brighter than the specified threshold. return saturate((c - BloomThreshold) / (1 - BloomThreshold));
Di sinilah semua pekerjaan nyata dilakukan. Ini mengambil warna pixel dari tekstur, mengurangi BloomThreshold
dari setiap komponen warna, dan kemudian menskalakannya kembali sehingga nilai maksimumnya adalah satu. Fungsi saturate()
kemudian menjepit komponen warna antara nol dan satu.
Anda mungkin memperhatikan bahwa c
dan BloomThreshold
yang tidak jenis yang sama, karena c
adalah float4
dan BloomThreshold
adalah float
. HLSL memungkinkan Anda untuk melakukan operasi dengan jenis-jenis yang berbeda ini dengan mengubah float
menjadi float4
dengan semua komponen yang sama. (c - BloomThreshold)
secara efektif menjadi:
c -- float4(BloomThreshold, BloomThreshold, BloomThreshold, BloomThreshold)
Sisa shader hanya menciptakan teknik yang menggunakan fungsi pixel shader, dikompilasi untuk shader model 2.0.
Shader GaussianBlur
Gaussian blur mengaburkan gambar menggunakan fungsi Gaussian. Untuk setiap piksel dalam gambar output, kita menjumlahkan piksel dalam gambar input yang ditimbang oleh jaraknya dari piksel target. Piksel di dekatnya berkontribusi besar terhadap warna akhir sementara piksel jauh berkontribusi sangat sedikit.
Karena piksel jauh membuat kontribusi yang dapat diabaikan dan karena pencarian tekstur mahal, kita hanya mengambil sampel piksel dalam radius pendek, bukan sampling seluruh tekstur. Shader ini akan mengambil sampel titik-titik dalam 14 piksel dari piksel saat ini.
Penerapan yang naif dapat mengambil sampel semua titik dalam persegi di sekitar piksel saat ini. Namun, ini bisa jadi mahal. Dalam contoh ini, kita harus mengambil sampel dalam kotak 29x29 (14 poin di kedua sisi piksel tengah, ditambah piksel tengah). Itu total 841 sampel untuk setiap piksel dalam gambar. Untungnya, ada metode yang lebih cepat. Ternyata melakukan 2D Gaussian blur sama dengan memburamkan gambar secara horizontal, dan kemudian mengaburkannya lagi secara vertikal. Masing-masing satu dimensi kabur hanya membutuhkan 29 sampel, mengurangi total menjadi 58 sampel per pixel.
Satu lagi trik digunakan untuk lebih meningkatkan efisiensi blur. Saat Anda memberi tahu GPU untuk mengambil sampel di antara dua piksel, itu akan mengembalikan perpaduan dua piksel tanpa biaya kinerja tambahan. Karena blur kita memadukan piksel bersama-sama, ini memungkinkannya untuk mengambil sampel dua piksel sekaligus. Ini memotong jumlah sampel yang dibutuhkan hampir setengahnya.
Di bawah ini adalah bagian yang relevan dari shader GaussianBlur
.
sampler TextureSampler : register(s0); #define SAMPLE_COUNT 15 float2 SampleOffsets[SAMPLE_COUNT]; float SampleWeights[SAMPLE_COUNT]; float4 PixelShaderFunction(float2 texCoord : TEXCOORD0) : COLOR0 { float4 c = 0; // Combine a number of weighted image filter taps. for (int i = 0; i < SAMPLE_COUNT; i++) { c += tex2D(TextureSampler, texCoord + SampleOffsets[i]) * SampleWeights[i]; } return c; }
Shader sebenarnya cukup sederhana; hanya mengambil array offset dan array yang sesuai bobot dan menghitung penjumlahan seimbang. Semua matematika kompleks sebenarnya dalam kode C# yang mengisi susunan offset dan berat. Ini dilakukan dalam metode SetBlurEffectParameters()
dan ComputeGaussian()
dari kelas BloomComponent
. Ketika melakukan blur lulus horisontal, SampleOffsets
akan diisi hanya dengan offset horizontal (komponen y semuanya nol), dan tentu saja kebalikannya adalah benar untuk pass vertikal.
Shader BloomCombine
Shader BloomCombine
melakukan beberapa hal sekaligus. Ini menggabungkan tekstur mekar dengan tekstur asli sementara juga menyesuaikan intensitas dan saturasi setiap tekstur.
Shader dimulai dengan mendeklarasikan dua samplers tekstur dan empat parameter float.
sampler BloomSampler : register(s0); sampler BaseSampler : register(s1); float BloomIntensity; float BaseIntensity; float BloomSaturation; float BaseSaturation;
Satu hal yang perlu diperhatikan adalah bahwa SpriteBatch
akan secara otomatis mengikat tekstur yang Anda berikan ketika memanggil SpriteBatch.Draw()
ke sampler pertama, tetapi tidak akan secara otomatis mengikatkan apa pun ke sampler kedua. Sampler kedua diatur secara manual di BloomComponent.Draw()
dengan baris berikut.
GraphicsDevice.Textures[1] = sceneRenderTarget;
Selanjutnya kita memiliki fungsi pembantu yang mengatur saturasi warna.
float4 AdjustSaturation(float4 color, float saturation) { // The constants 0.3, 0.59, and 0.11 are chosen because the // human eye is more sensitive to green light, and less to blue. float grey = dot(color, float3(0.3, 0.59, 0.11)); return lerp(grey, color, saturation); }
Fungsi ini mengambil warna dan nilai saturasi dan mengembalikan warna baru. Melewati saturasi 1
daun warna tidak berubah. Melewati 0
akan menghasilkan warna abu-abu, dan nilai kelulusan yang lebih besar dari satu akan mengembalikan warna dengan saturasi yang meningkat. Melewatkan nilai negatif benar-benar di luar penggunaan yang dimaksudkan, tetapi akan membalikkan warna jika Anda melakukannya.
Fungsi ini bekerja dengan terlebih dahulu menemukan luminositas warna dengan mengambil jumlah tertimbang berdasarkan sensitivitas mata kita terhadap cahaya merah, hijau dan biru. Ini kemudian secara linear interpolasi antara abu-abu dan warna asli dengan jumlah kejenuhan yang ditentukan. Fungsi ini disebut dengan fungsi pixel shader.
float4 PixelShaderFunction(float2 texCoord : TEXCOORD0) : COLOR0 { // Look up the bloom and original base image colors. float4 bloom = tex2D(BloomSampler, texCoord); float4 base = tex2D(BaseSampler, texCoord); // Adjust color saturation and intensity. bloom = AdjustSaturation(bloom, BloomSaturation) * BloomIntensity; base = AdjustSaturation(base, BaseSaturation) * BaseIntensity; // Darken down the base image in areas where there is a lot of bloom, // to prevent things looking excessively burned-out. base *= (1 - saturate(bloom)); // Combine the two images. return base + bloom; }
Sekali lagi, shader ini cukup mudah. Jika Anda bertanya-tanya mengapa gambar dasar perlu digelapkan di area dengan mekar yang cerah, ingatlah bahwa menambahkan dua warna bersama-sama meningkatkan kecerahan dan komponen warna apa pun yang menambahkan hingga nilai yang lebih besar dari satu (kecerahan penuh) akan terpotong menjadi satu. Karena gambar bloom mirip dengan gambar dasar, ini akan menyebabkan banyak gambar yang memiliki lebih dari 50% kecerahan menjadi maksimal. Menggelapkan gambar dasar memetakan semua warna kembali ke kisaran warna yang dapat kita tampilkan dengan benar.
Black Holes
Salah satu musuh paling menarik di Geometry Wars adalah black hole. Mari periksa bagaimana kita bisa membuat sesuatu yang mirip dalam Shape Blaster. Kita akan menciptakan fungsi dasar sekarang, dan akan mengunjungi musuh di tutorial berikutnya untuk menambahkan efek partikel dan interaksi partikel.

Fungsi dasar
Lubang hitam akan menarik player ship, musuh di dekatnya, dan (setelah tutorial berikutnya), tetapi akan mengusir peluru.
Ada banyak fungsi yang bisa kita gunakan untuk daya tarik atau tolakan. Yang paling sederhana adalah menggunakan gaya konstan sehingga black hole menarik dengan kekuatan yang sama terlepas dari jarak objek. Pilihan lain adalah memiliki gaya meningkat secara linear dari nol pada jarak maksimum tertentu, untuk kekuatan penuh untuk objek langsung di atas black hole.
Jika kita ingin memodelkan gravitasi secara lebih realistis, kita dapat menggunakan kuadrat terbalik dari jarak, yang berarti gaya gravitasi sebanding dengan \(1 / distance^2\). Kita sebenarnya akan menggunakan masing-masing dari tiga fungsi ini untuk menangani objek yang berbeda. Peluru akan ditolak dengan kekuatan konstan, musuh dan kapal pemain akan tertarik dengan gaya linier, dan partikel akan menggunakan fungsi kuadrat terbalik.
Kita akan membuat kelas baru untuk black hole. Mari mulai dengan fungsi dasar.
class BlackHole : Entity { private static Random rand = new Random(); private int hitpoints = 10; public BlackHole(Vector2 position) { image = Art.BlackHole; Position = position; Radius = image.Width / 2f; } public void WasShot() { hitpoints--; if (hitpoints <= 0) IsExpired = true; } public void Kill() { hitpoints = 0; WasShot(); } public override void Draw(SpriteBatch spriteBatch) { // make the size of the black hole pulsate float scale = 1 + 0.1f * (float)Math.Sin(10 * GameRoot.GameTime.TotalGameTime.TotalSeconds); spriteBatch.Draw(image, Position, null, color, Orientation, Size / 2f, scale, 0, 0); } }
Black hole memerlukan sepuluh tembakan untuk terbunuh. Kita menyesuaikan skala sprite sedikit untuk membuatnya berdenyut. Jika Anda memutuskan bahwa menghancurkan black hole juga harus memberikan poin, Anda harus melakukan penyesuaian serupa dengan kelas BlackHole
seperti yang kita lakukan dengan kelas musuh.
Selanjutnya kita akan membuat black hole benar-benar menerapkan gaya pada entitas lain. Kita membutuhkan metode pembantu kecil dari EntityManager
.
public static IEnumerable GetNearbyEntities(Vector2 position, float radius) { return entities.Where(x => Vector2.DistanceSquared(position, x.Position) < radius * radius); }
Metode ini dapat dibuat lebih efisien dengan menggunakan skema pembagian spasial yang lebih rumit, tetapi untuk jumlah entitas yang akan kita miliki, tidak apa-apa seperti itu. Sekarang kita dapat membuat black hole berlaku secara paksa dalam metode Update()
mereka.
public override void Update() { var entities = EntityManager.GetNearbyEntities(Position, 250); foreach (var entity in entities) { if (entity is Enemy && !(entity as Enemy).IsActive) continue; // bullets are repelled by black holes and everything else is attracted if (entity is Bullet) entity.Velocity += (entity.Position - Position).ScaleTo(0.3f); else { var dPos = Position - entity.Position; var length = dPos.Length(); entity.Velocity += dPos.ScaleTo(MathHelper.Lerp(2, 0, length / 250f)); } } }
Black hole hanya mempengaruhi entitas dalam radius yang dipilih (250 piksel). Peluru dalam radius ini memiliki gaya tolak konstan yang diterapkan, sementara yang lainnya memiliki gaya tarik linear yang diterapkan.
Kita perlu menambahkan penanganan tabrakan untuk lubang hitam ke EntityManager
. Tambahkan List<>
untuk black hole seperti yang kita lakukan untuk jenis entitas lain, dan tambahkan kode berikut di EntityManager.HandleCollisions()
.
// handle collisions with black holes for (int i = 0; i < blackHoles.Count; i++) { for (int j = 0; j < enemies.Count; j++) if (enemies[j].IsActive && IsColliding(blackHoles[i], enemies[j])) enemies[j].WasShot(); for (int j = 0; j < bullets.Count; j++) { if (IsColliding(blackHoles[i], bullets[j])) { bullets[j].IsExpired = true; blackHoles[i].WasShot(); } } if (IsColliding(PlayerShip.Instance, blackHoles[i])) { KillPlayer(); break; } }
Akhirnya, buka kelas EnemySpawner
dan buatlah beberapa black hole. Saya membatasi jumlah maksimum lubang hitam menjadi dua, dan memberi peluang 1 banding 600 lubang hitam untuk menghasilkan setiap bingkai.
if (EntityManager.BlackHoleCount < 2 && rand.Next((int)inverseBlackHoleChance) == 0) EntityManager.Add(new BlackHole(GetSpawnPosition()));
Kesimpulan
Kita telah menambahkan bloom menggunakan berbagai shader, dan black hole menggunakan berbagai rumus kekuatan. Shape Blaster mulai terlihat bagus. Di bagian selanjutnya, kita akan menambahkan beberapa efek partikel atas yang gila.