Tectonic planet surfaces

Published on
Last changed on

Recently, I have been been spending some time on earth-like planetary surfaces, although not as much as I would've liked. Heh. And even though some progress is documented here on the Babylonjs forums, I am now thinking of providing more details on the various steps, problems and improvements that I encountered (and am still encountering) while dealing with this very interesting topic and its implementation.

The ultimate goal

Put very simply, AdLumens needs something that can apply the following rule for any tectonic planet:

for any point on a sphere, determine its elevation, its vertex colour, various physical characteristics such as pressure, etc. and data related to resources that can be found there: amount/concentration, extraction difficulty, etc. All in 16ms or less.

Now, at first glance this sounds simple and straightforward. I confess I even hoped at the beginning of the coding story of it all that I would come up with a magical "one formula to rule them all", a beautiful linear function that would simply run once and voilà! A few weeks later and I was wrong -- obviously; and things turned out to be far less… err … linear.

The original idea was not and is not a tantrum, though: it was for me the best way to implement code in rust that would be used both server- and client-side. More on this below if you are interested.

The spherical split

So how to implement the above? Well, with the childish "one formula" idiocy out of the way, the project is very roughly articulated around a split into two separate layers:

  • A purely mathematical "virtual layer": this simply exists and lies inactive until it is queried for points data; this layer can be used server-side and client-side (on a need basis)
  • A spherical representation of a planet surface is created and its points are used as "query values" to the virtual sphere; most of the time this will be limited to client-side operations

The virtual sphere

Is defined using various attributes, either physical or more "abstract". Some of these attributes are then used to initialise properties for the sphere, which will remain stable and unchangeable during its whole lifetime.

  • Obvious stuff: identifier used as a base for seeding, radius, number of tectonic plates, etc.
  • Several noise fields spread more or less evenly across the sphere surface, with configurables octaves, frequencies, etc.

The "physical" sphere

The first truth about the physical sphere is that it can be the outcome of any sphere approximation:

  • Poisson
  • Quadsphere
  • Icosphere
  • … or any other that I'm probably not aware of

This is true because whatever the chosen approximation model, the truth lies in the virtual sphere.
In the case of AdLumens, the icosphere was selected; the reason for this is mainly its slightly easier handling of distortion at the poles and its easy split management (at least for a conceptual perspective: it is extremely easy to visualise the split mechanism in one's mind and believe me… that helped me a lot!).

Whichever the representation type is selected though, the resulting mesh is just a simplified rendering proxy. Its discreetness is vastly different from the continuous nature of the virtual sphere. The points that result from the mesh construction are used as any other point would be used to query the virtual sphere.
Put another way: the physical sphere is a simplified and visual representation of the virtual sphere, tesselated to a certain resolution (based on performance requirements, etc).

Virtual sphere attributes

As of writing this, the virtual sphere can generate the following data (attributes, properties) for any given point.

AttributeBrief explanationPhysical sphere manifestation
Plate assignment Uses a series of noise fields to assign a plate to the point. This is a good opportunity to say that plates are randomly generated using various attributes such as origin, surface_fraction, weight, etc.
This is the starting point from which all other point attributes will be determined.
None of the code is scientifically correct; hell, I even have smaller plates sitting in the middle of larger ones - the goal was to have and end result that looked ok.
Crust assigned tectonic plates
Base elevation This is directly derived from the assigned plate. At the moment, this is fairly "static" but is due to be influenced by various server-side parameters such as compound/mineral composition, overall crust level of activity, etc. A view of point base elevations
Direction Also derived from the assigned plate and its geodesic movement along the sphere, expressed as Vector2<f64> for [lon/lat].
The direction angle is slightly randomised on top of the one inherited from the plate.
The end result is a direction field spread over the sphere, with potential strong variance across different plates, and weak ones within plates.
Direction field
SpeedAlso derived from the assigned plate. Works in a similar fashion as Direction above. Speed field
Compression Now entering the realm of more interesting stuff. This is computed by comparing direction, speed, boundary score and up/subduction effects (if any).
The result is a f64 expressing the compression exerted at the point. It's all very abstract, so the number is dimensionless and can be negative. No Pascal or such units here.
Compression field
Elevation This is the first "visible outcome" and is how displaced vertices are going to be compared to their approximated base sphere position.
Each point value can of course be negative or positive.
Elevation field
Other fields Fields for resources discovery and extraction. Will very likely be represented as dynamic heatmaps, with peaks depending on matching mineral types (igneous, metamorphic, sedimentary) versus boundary scores versus volcanic-crust activity.
Although far from finished, this can lead to very interesting visualisation modes, such as the result of an ingame scan of a planetary surface, looking for compound X or mineral Y. Depending on the roll outcome, the resulting heatmap could be more or less accurate, be wrong (for critical failures, etc), and could be visualised by assigning the relevant colours to the mesh vertices. Food for thought…
-
Textures / actual colours Well this one is complex. I think. I have several ideas on the subject, but none of them worth mentioning because again -- I need to start implementing them against performance constraints. -

As one can see, there is nothing scienfitic in there. Just what I hope is basic (if not intuitive) common sense, whose primary goal is to result in landscapes that look reasonably nice and realistic. Ho and that follows the "begin with the end" approach. The main reason is of course the time budget of 16 milliseconds, even when working inside a worker. A bit more on this below.

The images above illustrate visual outcomes at a low subdivision level (5), and the code behind them is being constantly modified. So take them with a grain of salt! :-)

Last, some new fields are very likely to become born, such as shear or rotation. They are in draft mode at the moment and their impact evaluated in terms of what they bring versus what they cost.

A brief history of versions

Full JS prototype

The very first quick & dirty prototype was this BabylonJS playground. By quick and dirty is meant: 1) it is slow 2) it is ugly 3) the randomness is really bad. But at least it got me into understanding what " basic steps" were going to be required for something hopefully more serious.

First rust/wasm version

The next step was to port the JS code to Rust, to be compiled as wasm. I initially tried wasm-bindgen but I thought that the generated Javascript was not that great, so I decided to do my own. The end result is something that takes up 10 times less space and -to me at least- is far easier to understand and maintain.

For example, being able to log messages from within rust/wasm to the Javascript console:

Javascript:

const load_wasm = async function(url) {
    let instance;
    const import_object = {
        env: {
            console_log: function(severity, ptr, len) {
                if(!instance || !instance.exports){
                    return;
                }
                const m = new Uint8Array(instance.exports.memory.buffer, ptr, len);
                const message = new TextDecoder('utf-8').decode(m);
                if(severity == 3){
                    console.error(message);
                }
                else if(severity == 2){
                    console.warn(message);
                }
                else{
                    console.log(message);
                }
            }
        }
    };
    await WebAssembly.instantiateStreaming(fetch(url), import_object).then(function(obj){
        instance = obj.instance;
    });
    return instance.exports;
};

Rust:

#[cfg(feature = "client")]
#[link(wasm_import_module = "env")]
extern "C" {
    fn console_log(severity:u8, ptr: *const u8, len: usize);
}

#[cfg(feature = "client")]
pub fn log_message(
    severity: u8,
    message: &str,
) {
    let bytes = message.as_bytes();
    unsafe {
        console_log(severity, bytes.as_ptr(), bytes.len());
    }
}

// usage
// equivalent to console.warn(`Hello from WASM: ${some_variable}`);
log_message(2, format!("Hello from WASM: {:?}", some_variable).as_str());

Rust feature

For a while I continued working on an isolated version of the rust code; meaning: the files were completely isolated from the main rust code base, as I did not want to potentially break the server-side builds.
Soon though, I had to tackle the ability of the server to make use of that code. Again, based on the authoritative server model, whatever the client can do, the server can -- and more. In the current context this meant that the server, just as well as the client, should be able to determine the exact characteristics of a point at the surface of a sphere; "why" does not matter - it must be able to do it as the client is the enemy. :-)

Initially I thought about writing a python script that would switch lib.rs files around. But then, and even though it meant a heavy rewrite of the rust code base, I opted for a more standard way of doing this: rust's feature err… feature?

Compilation is still invoked via a python script (hell, it's even a django management command!), with the following arguments:

ArgumentExplanationExample
os Selects the targeted operating system: at the moment it's just win or linux.
There are slight differences to how subprocess.run accepts parameters depending on where this whole thing is compiled from. Not a big deal, but enough to introduce that difference in the arguments.
On linux:
generate_rust_file_all()
subprocess.run([
    'maturin',
    'build', '-rq',
    '--target', 'x86_64-unknown-linux-gnu',
    '--features', 'server',
    '-i', 'python3.11',
    '--strip'
])
wheel = './target/wheels/adlumens-0.1.0-cp311-cp311-manylinux_2_34_x86_64.whl'
subprocess.run(['touch', wheel])
subprocess.run(['pip', 'uninstall', 'adlumens', '-y'])
subprocess.run(['pip', 'install', wheel, '--force-reinstall'])
feature Selects whether we want to output:
  • The python wheel
  • or the wasm file
subprocess.run(f'cargo build --target wasm32-unknown-unknown --release --features client')
subprocess.run('wasm-opt -O4 target/wasm32-unknown-unknown/release/adlumens.wasm -o target/wasm32-unknown-unknown/release/adlumens.wasm')
shutil.copyfile(
    f'{settings.BASE_DIR}/galaxy/rust/target/wasm32-unknown-unknown/release/adlumens.wasm',
    f'{settings.BASE_DIR}/portal/web/static/wasm/adlumens.wasm'
)
… which makes the wasm essentially ready for a collecstatic

WASM versus JS performance

Let's face it. JS engines such as V8 are monsters of optimisation, worked on by f**ing geniuses. I mean it. So I initially had serious doubts about wasm's ability to go fastah on loops, vector maths, and the like.

Initially… and even though wasm was about twice faster, I frankly was underwhelmed by the ratio extra workextra performance this meant.

But then two things happened that completely changed this (apart from manual code optimisations, of course):

The first was enriching config.toml with the following:

[target.wasm32-unknown-unknown]
rustflags = ["-C", "target-feature=+simd128"]

… which is fairly safe to use nowadays unless you target Internet Explorer 10. :-P

The second happened by chance and it's a little bit funny. You see, the JS/WASM interface is all Float32Array. The "C/nomangle" rust functions all take f32s as input. So I had naturally assumed that, internally, f32s should continue to be used.
That changed when I had to integrate the previously isolated client-side code into the main rust code base, and had to migrate the internal floats to f64s. Ignoring the obvious precision gains, the performance impact was MASSIVE. Around 200% performance increase. I am not sure why, but I suspect that 1) rust more aggresively optimises/vectorises 64-bit operations 2) wasm, when using f64s, is closer to the metal on 64-bit CPUs? I frankly do not know, but the effect was quite positive! :-)

Internals

This section gives a brief overview of the internal processing that results in a mesh's visual outcome. It's fairly simple.

StepPerformance costSome explanation
Initialisation Negligible A run-once per instance, this does several quick things:
  • Initialises the necessary noise field generators (think open simplex, worley, billow, etc)
  • Initialises the tectonic plates
Of note are the four (at the moment, this may change) Vec<f32> or Vec<u32>, where the data exposed to JS is crammed each tick: vertex_positions, vertex_indices, vertex_uvs and vertex_colors
Camera values Negligible Each tick, the Javascript tells the WASM "Hey these are my camera coordinates". It is not as simple as it seems as the camera data is made up of:
  • the actual camera position (three floats)
  • the frustum details (24 floats)
This is used for culling and the like, but also tested at each tick: if none of those numbers change, there is no work to be done!
Triangles processing Medium This is an important step that considerably helps reduce the load as it is essentially as dynamic LOD engine (a full icosphere at subdivision level 16 would be too heavy). It is not perfect at the moment, but works well enough. But the principle is simple: based on distance from camera, frustum intersection and backfacing ratio, decide whether a triangle should be split or merged. For each triangle:
  • if outside of frustum, merge
  • if above a certain "backfacing ratio" merge
  • compute a "subdivision level at depth", from camera distance and altitude
  • if further out than this distance, merge
  • if within that distance and not already split, split
Triangle merging/splitting Low This step is the child of the previous one and involves several sub-steps:
  • edge creation (edges are buffered)
  • vertex creation (vertices are buffered too)
The outcome is of course individual edges and vertices, but also an update of correspondence maps, used for neighbour tracking and the like.
Vertex creation Medium Another very important step, touched on above with the pretty pictures. A few more details:
  • plate assignment is made over several coarse fields, to maximise large scale chaos, but minimise it at smaller scales. Not perfect but hey at least it's a non-costly try against self-similarity
  • speed and direction are inherited from the assigned plate and slightly randomised
  • boudary score is generated by comparing the assigned plate score to the closest neighbour score. It is important to note that the boundary location is not only completely unknown, it is also completely irrelevant. It exists only when the boundary score is 1.0!
  • Compression is calculated by moving 2d vectors at the tangent of the vertex and computing from there. I am not satisfied with the way it currently runs, so I am working on this step at the moment
  • Elevation is a mix of assigned plate elevation, compression and of course noise! :-)
Each vertex is buffered and does not need to be recomputed once it has been. The memory cost is amply justified by the performance gains.
Vertex data gathering Negligible The final stage, where the internal f64cough data is converted into f32 for Javascript.

I must mention that the data structures used throughout the code are built around two main principles: O(1) for individual access and contiguous memory for loops. Hence, FXHashMaps and Arenas are used.

A couple videos…

Both in black & white: the colour gradient at each pixel is assigned from elevation.

This one is from a little while back:

This one is more recent:

As you can see, each "tick" takes about a few milliseconds, on a reasonable CPU (i9 12900HK)

In closing: next steps

Naturally, a new tools and experiments page will be added at some stage.

Then comes the question of how to integrate this little thing into the 3D explorer. Should it run inside the rendering worker? Or be invoked as another worker or set or workers from that rendering worker? The idea of splitting the work across several workers is seductive and would allow for LODs to be chunked and so for the overall visual quality to increase.
Food for thought again…

And last but not least, ground textures and atmosphere. Which as mentioned aboveI have started to investigate, but haven't gotten very far yet.

Well that was fairly long post. Hum.

Please signin to add your comment.