Java Grafik 3D: Render landskap fraktal

Grafik komputer 3D mempunyai banyak kegunaan - dari permainan hingga visualisasi data, realiti maya, dan seterusnya. Lebih kerap daripada itu, kelajuan sangat penting, menjadikan perisian dan perkakasan khusus adalah mustahak untuk menyelesaikan tugas. Perpustakaan grafik tujuan khas menyediakan API peringkat tinggi, tetapi menyembunyikan bagaimana kerja sebenar dilakukan. Sebagai pengaturcara hidung-ke-logam, itu tidak cukup baik untuk kita! Kami akan meletakkan API di dalam almari dan melihat di sebalik tabir bagaimana gambar sebenarnya dihasilkan - dari definisi model maya hingga rendering sebenarnya ke layar.

Kami akan melihat subjek yang cukup spesifik: menghasilkan dan membuat peta medan, seperti permukaan Marikh atau beberapa atom emas. Render peta medan dapat digunakan untuk lebih dari sekadar tujuan estetika - banyak teknik visualisasi data menghasilkan data yang dapat diberikan sebagai peta medan. Maksud saya, tentu saja, sepenuhnya artistik, seperti yang anda lihat pada gambar di bawah! Sekiranya anda inginkan, kod yang akan kami hasilkan cukup umum sehingga hanya dengan tweak kecil, ia juga dapat digunakan untuk membuat struktur 3D selain dari medan.

Klik di sini untuk melihat dan memanipulasi medan medan.

Sebagai persediaan untuk perbincangan kita hari ini, saya mencadangkan agar anda membaca "Lukiskan bidang bertekstur" bulan Jun jika anda belum melakukannya. Artikel ini menunjukkan pendekatan penelusuran sinar untuk membuat gambar (menembakkan sinar ke pemandangan maya untuk menghasilkan gambar). Dalam artikel ini, kami akan menampilkan elemen pemandangan langsung ke paparan. Walaupun kami menggunakan dua teknik yang berbeza, artikel pertama mengandungi beberapa bahan latar pada java.awt.imagepakej yang tidak akan saya ulangi dalam perbincangan ini.

Peta muka bumi

Mari mulakan dengan menentukan a

peta medan

. Peta medan adalah fungsi yang memetakan koordinat 2D

(x, y)

ke ketinggian

a

dan warna

c

. Dengan kata lain, peta medan hanyalah fungsi yang menggambarkan topografi kawasan kecil.

Mari kita menentukan kawasan kita sebagai antara muka:

muka bumi antara muka awam {public double getAltitude (double i, double j); getColor RGB awam (double i, double j); }

Untuk tujuan artikel ini kita akan menganggap bahawa 0.0 <= i, j, ketinggian <= 1.0 . Ini bukan syarat, tetapi akan memberi kita idea yang baik di mana untuk mencari kawasan yang akan kita lihat.

Warna kawasan kita digambarkan sebagai RGB triplet. Untuk menghasilkan gambar yang lebih menarik, kami mungkin mempertimbangkan untuk menambah maklumat lain seperti kilauan permukaan, dan lain-lain. Buat masa ini, kelas berikut akan melakukan:

kelas awam RGB {private double r, g, b; RGB awam (double r, double g, double b) {this.r = r; ini.g = g; ini.b = b; } tambah RGB awam (RGB rgb) {mengembalikan RGB baru (r + rgb.r, g + rgb.g, b + rgb.b); } tolak RGB awam (RGB rgb) {mengembalikan RGB baru (r - rgb.r, g - rgb.g, b - rgb.b); } skala RGB awam (skala dua) {mengembalikan RGB baru (skala r *, skala g *, skala b *); } int toInt (nilai ganda) peribadi {return (nilai 1.0)? 255: (int) (nilai * 255.0); } int awam untuk RGB () hinggaInt (b); }

The RGBkelas mentakrifkan bekas warna yang mudah. Kami menyediakan beberapa kemudahan asas untuk melakukan aritmetik warna dan menukar warna titik apungan ke format bilangan bulat.

Medan transendental

Kita akan mulakan dengan melihat medan transendental - fancyspeak untuk medan yang dihitung dari sinus dan kosinus:

kelas awam TranscendentalTerrain melaksanakan Terrain {private double alpha, beta; TranscendentalTerrain awam (double alpha, double beta) {this.alpha = alpha; ini.beta = beta; } public double getAltitude (double i, double j) {return .5 + .5 * Math.sin (i * alpha) * Math.cos (j * beta); } get Rol umum RGB (double i, double j) {return RGB baru (.5 + .5 * Math.sin (i * alpha), .5 - .5 * Math.cos (j * beta), 0.0); }}

Pembina kami menerima dua nilai yang menentukan frekuensi medan kita. Kami menggunakan ini untuk menghitung ketinggian dan warna menggunakan Math.sin()dan Math.cos(). Ingat, fungsi tersebut mengembalikan nilai -1.0 <= sin (), cos () <= 1.0 , jadi kita mesti menyesuaikan nilai pulangan kita dengan sewajarnya.

Medan fraktal

Medan matematik sederhana tidak menyeronokkan. Apa yang kita mahukan adalah sesuatu yang kelihatan paling nyata. Kita boleh menggunakan fail topografi sebenar sebagai peta medan kita (misalnya Teluk San Francisco atau permukaan Mars). Walaupun ini mudah dan praktikal, ia agak membosankan. Maksud saya, kita sudah

telah

di sana. Apa yang sebenarnya kita mahukan adalah sesuatu yang kelihatan nyata

dan

tidak pernah dilihat sebelum ini. Masuk ke dunia fraktal.

A fractal is something (a function or object) that exhibits self-similarity. For example, the Mandelbrot set is a fractal function: if you magnify the Mandelbrot set greatly you will find tiny internal structures that resemble the main Mandelbrot itself. A mountain range is also fractal, at least in appearance. From close up, small features of an individual mountain resemble large features of the mountain range, even down to the roughness of individual boulders. We will follow this principal of self-similarity to generate our fractal terrains.

Essentially what we'll do is generate a coarse, initial random terrain. Then we'll recursively add additional random details that mimic the structure of the whole, but on increasingly smaller scales. The actual algorithm that we will use, the Diamond-Square algorithm, was originally described by Fournier, Fussell, and Carpenter in 1982 (see Resources for details).

These are the steps we'll work through to build our fractal terrain:

  1. We first assign a random height to the four corner points of a grid.

  2. We then take the average of these four corners, add a random perturbation and assign this to the midpoint of the grid (ii in the following diagram). This is called the diamond step because we are creating a diamond pattern on the grid. (At the first iteration the diamonds don't look like diamonds because they are at the edge of the grid; but if you look at the diagram you'll understand what I'm getting at.)

  3. We then take each of the diamonds that we have produced, average the four corners, add a random perturbation and assign this to the diamond midpoint (iii in the following diagram). This is called the square step because we are creating a square pattern on the grid.

  4. Next, we reapply the diamond step to each square that we created in the square step, then reapply the square step to each diamond that we created in the diamond step, and so on until our grid is sufficiently dense.

An obvious question arises: How much do we perturb the grid? The answer is that we start out with a roughness coefficient 0.0 < roughness < 1.0. At iteration n of our Diamond-Square algorithm we add a random perturbation to the grid: -roughnessn <= perturbation <= roughnessn. Essentially, as we add finer detail to the grid, we reduce the scale of changes that we make. Small changes at a small scale are fractally similar to large changes at a larger scale.

If we choose a small value for roughness, then our terrain will be very smooth -- the changes will very rapidly diminish to zero. If we choose a large value, then the terrain will be very rough, as the changes remain significant at small grid divisions.

Here's the code to implement our fractal terrain map:

public class FractalTerrain implements Terrain { private double[][] terrain; private double roughness, min, max; private int divisions; private Random rng; public FractalTerrain (int lod, double roughness) { this.roughness = roughness; this.divisions = 1 << lod; terrain = new double[divisions + 1][divisions + 1]; rng = new Random (); terrain[0][0] = rnd (); terrain[0][divisions] = rnd (); terrain[divisions][divisions] = rnd (); terrain[divisions][0] = rnd (); double rough = roughness; for (int i = 0; i < lod; ++ i) { int q = 1 << i, r = 1 <> 1; for (int j = 0; j < divisions; j += r) for (int k = 0; k  0) for (int j = 0; j <= divisions; j += s) for (int k = (j + s) % r; k <= divisions; k += r) square (j - s, k - s, r, rough); rough *= roughness; } min = max = terrain[0][0]; for (int i = 0; i <= divisions; ++ i) for (int j = 0; j <= divisions; ++ j) if (terrain[i][j]  max) max = terrain[i][j]; } private void diamond (int x, int y, int side, double scale) { if (side > 1) { int half = side / 2; double avg = (terrain[x][y] + terrain[x + side][y] + terrain[x + side][y + side] + terrain[x][y + side]) * 0.25; terrain[x + half][y + half] = avg + rnd () * scale; } } private void square (int x, int y, int side, double scale) { int half = side / 2; double avg = 0.0, sum = 0.0; if (x >= 0) { avg += terrain[x][y + half]; sum += 1.0; } if (y >= 0) { avg += terrain[x + half][y]; sum += 1.0; } if (x + side <= divisions) { avg += terrain[x + side][y + half]; sum += 1.0; } if (y + side <= divisions) { avg += terrain[x + half][y + side]; sum += 1.0; } terrain[x + half][y + half] = avg / sum + rnd () * scale; } private double rnd () { return 2. * rng.nextDouble () - 1.0; } public double getAltitude (double i, double j) { double alt = terrain[(int) (i * divisions)][(int) (j * divisions)]; return (alt - min) / (max - min); } private RGB blue = new RGB (0.0, 0.0, 1.0); private RGB green = new RGB (0.0, 1.0, 0.0); private RGB white = new RGB (1.0, 1.0, 1.0); public RGB getColor (double i, double j) { double a = getAltitude (i, j); if (a < .5) return blue.add (green.subtract (blue).scale ((a - 0.0) / 0.5)); else return green.add (white.subtract (green).scale ((a - 0.5) / 0.5)); } } 

In the constructor, we specify both the roughness coefficient roughness and the level of detail lod. The level of detail is the number of iterations to perform -- for a level of detail n, we produce a grid of (2n+1 x 2n+1) samples. For each iteration, we apply the diamond step to each square in the grid and then the square step to each diamond. Afterwards, we compute the minimum and maximum sample values, which we'll use to scale our terrain altitudes.

To compute the altitude of a point, we scale and return the closest grid sample to the requested location. Ideally, we would actually interpolate between surrounding sample points, but this method is simpler, and good enough at this point. In our final application this issue will not arise because we will actually match the locations where we sample the terrain to the level of detail that we request. To color our terrain, we simply return a value between blue, green, and white, depending upon the altitude of the sample point.

Tessellating our terrain

We now have a terrain map defined over a square domain. We need to decide how we are going to actually draw this onto the screen. We could fire rays into the world and try to determine which part of the terrain they strike, as we did in the previous article. This approach would, however, be extremely slow. What we'll do instead is approximate the smooth terrain with a bunch of connected triangles -- that is, we'll tessellate our terrain.

Tessellate: to form into or adorn with mosaic (from the Latin tessellatus).

To form the triangle mesh, we will evenly sample our terrain into a regular grid and then cover this grid with triangles -- two for each square of the grid. There are many interesting techniques that we could use to simplify this triangle mesh, but we'd only need those if speed was a concern.

The following code fragment populates the elements of our terrain grid with fractal terrain data. We scale down the vertical axis of our terrain to make the altitudes a bit less exaggerated.

double exaggeration = .7; int lod = 5; int steps = 1 << lod; Triple[] map = new Triple[steps + 1][steps + 1]; Triple[] colors = new RGB[steps + 1][steps + 1]; Terrain terrain = new FractalTerrain (lod, .5); for (int i = 0; i <= steps; ++ i) { for (int j = 0; j <= steps; ++ j) { double x = 1.0 * i / steps, z = 1.0 * j / steps; double altitude = terrain.getAltitude (x, z); map[i][j] = new Triple (x, altitude * exaggeration, z); colors[i][j] = terrain.getColor (x, z); } } 

Anda mungkin bertanya pada diri sendiri: Jadi mengapa segitiga dan bukan kotak? Masalah dengan menggunakan petak grid adalah bahawa mereka tidak rata dalam ruang 3D. Sekiranya anda mempertimbangkan empat titik rawak di ruang angkasa, sangat mustahil ia akan menjadi coplanar. Oleh itu, kita menguraikan kawasan kita menjadi segitiga kerana kita dapat menjamin bahawa tiga titik di ruang akan berbentuk koplanar. Ini bermaksud bahawa tidak akan ada jurang di medan yang akhirnya kita buat.