hikari – Terrain Materials

Now that hikari’s material system can express ideas more complicated than “specific set of textures”, it’s time to implement the materials and shading for terrain.

Splat Maps:

The texture selection is going to be based off of splat maps, which means we need the vertex colors: something I conveniently ignored when doing mesh generation earlier. It’s a simple enough fix. I decided to go with layers of noise for testing again, both because it was easy (no need for tooling) and because it produces the worst-case scenario for performance (all pixels use all or most of the splats). If the splat / triplanar mapping is going to fall off the performance cliff I want to find out now, not later.

Splats are normalized (to sum to one) and packed into a 32 bit color per vertex:

inline u32
NormalizeSplat(float w0, float w1, float w2, float w3, float w4) {
    float sum = w0 + w1 + w2 + w3 + w4;
    Assert(sum != 0.0f, "Cannot have total weight of zero on terrain splat.");

    float r = w0 / sum;
    float g = w1 / sum;
    float b = w2 / sum;
    float a = w3 / sum;

    u32 ri = (((u32)(r * 255.0f) & 0xff) <<  0);
    u32 gi = (((u32)(g * 255.0f) & 0xff) <<  8);
    u32 bi = (((u32)(b * 255.0f) & 0xff) << 16);
    u32 ai = (((u32)(a * 255.0f) & 0xff) << 24);

    return ai | bi | gi | ri;
}

I wrote a basic shader to visualize the splat map (although, squeezing 5 values into 3 colors is not particularly effective):
splat_color.png

Textures:

By packing textures tightly, we can reduce the number of texture reads for each splat material to two:
– albedo color
– normal + AO + roughness

This requires encoding the normal map in two channels (which is completely fine for tangent space, just discard the Z component and reconstruct in the shader). It also assumes that none of the splat materials have a metal channel. This is not necessarily true, but the metal mask could be packed into the albedo’s alpha channel. For the textures I’m working with, the metal mask is a constant 0.

So, as a rough calculation:
2 textures per splat material
x 3 splat materials per splat (so that we can have different textures on each planar face)
x 5 splats
= 30 textures.

This immediately rules out the naive approach of binding each texture separately. GL_MAX_TEXTURE_IMAGE_UNITS, the maximum number of bound textures per draw, on my midrange GPU is 32. Since the lighting code uses several textures as well (shadow maps, environment probes, a lookup texture for the BRDF), we run out of texture units.

Fortunately, we don’t have to do things that way.

All of the terrain textures should be the same size, so that the resolution and variation is consistent between them. This in turn means we can pack all of the terrain textures into a set of texture arrays. Instead of binding different textures, each splat material provides an index into the texture arrays.

This does make some things, like streaming textures in and out, more complicated. I’ve ignored this for now, as hikari simply loads everything at startup anyway. (hikari’s handling of assets is not very good, and will need to be addressed sometime soon.)

Shader:

All of the shaders in hikari are plain GLSL, with some minor support for preprocessing (#includes). Material shaders share the same lighting code, via a function called AccumulateLighting() that takes a struct containing all of the surface parameters and returns an RGB color. The benefit of this is writing, debugging, and optimizing the lighting calculation and lookup *once*.

Writing a shader, then, is just a matter of filling out that structure.

For this terrain shader, we need to do two sets of blends: first, blending between splats; second, blending between the three planar projections.

The blending for splats is pretty much exactly what you’d expect:

vec3 SampleAlbedoSplat(vec2 uv, float spw0, float spw1, float spw2, float spw3, float spw4) {
    vec3 splat0 = texture(splat_albedo_array, vec3(uv, splat_id0)).rgb;
    vec3 splat1 = texture(splat_albedo_array, vec3(uv, splat_id1)).rgb;
    vec3 splat2 = texture(splat_albedo_array, vec3(uv, splat_id2)).rgb;
    vec3 splat3 = texture(splat_albedo_array, vec3(uv, splat_id3)).rgb;
    vec3 splat4 = texture(splat_albedo_array, vec3(uv, splat_id4)).rgb;

    vec3 splat_albedo = splat0 * spw0 +
                        splat1 * spw1 +
                        splat2 * spw2 +
                        splat3 * spw3 +
                        splat4 * spw4;

    return splat_albedo;
}

There is another, similar function for sampling and blending the surface properties (normal, roughness, AO).

The triplanar blending is more interesting. See these two blog posts for the fundamentals of what triplanar blending is and how it works:
Ben Golas – Normal Mapping for a Triplanar Shader
Martin Palko – Triplanar Mapping

The first step of triplanar blending is calculating the blend weights for each plane. I take the additional step of clamping weights below a threshold to zero. While this can create some visible artifacts if the threshold is too high, any plane we can avoid looking at is 10 fewer texture samples.

const float blend_sharpness = 4.0;
const float blend_threshold = 0.05;

// fs_in.normal is the interpolated vertex normal.
vec3 blend = pow(abs(fs_in.normal), vec3(blend_sharpness));

blend /= dot(blend, vec3(1.0));

if (blend.x < blend_threshold) blend.x = 0.0;
if (blend.y < blend_threshold) blend.y = 0.0;
if (blend.z < blend_threshold) blend.z = 0.0;

// Need to renormalize the blend
blend /= dot(blend, vec3(1.0));

By checking for a positive blend weight before sampling one of the projections, we can skip those that aren’t going to contribute much. This *does* seem to be a win (about a 0.25ms drop in render time in my test scene), but it’s pretty close to the noise level so I’m not certain.

Thresholding the splat weights may also be worthwhile; in the worst-case test I have set up it definitely isn’t, but actual artist-authored terrain is unlikely to use more than 2 or 3 channels per-pixel.

The actual blending is, mostly, exactly what you’d expect (multiply each planar projection with the corresponding weight and add them together.) The normal blending is slightly different, as described in Ben Golas’ blog post above:

// props_#.ts_normal is the tangent space normal for the plane facing that axis.
vec3 normal_x = vec3(0, props_x.ts_normal.yx);
vec3 normal_y = vec3(props_y.ts_normal.x, 0.0, props_y.ts_normal.y);
vec3 normal_z = vec3(props_z.ts_normal.xy, 0.0);
surf.N = normalize(fs_in.normal +
                   blend.x * normal_x +
                   blend.y * normal_y +
                   blend.z * normal_z);

The normal map for each plane is a linear blend of the splats. This is wrong, but looks okay in practice. I think the correct approach would be to swizzle *every* normal map sample out to world space and *then* blend? Not sure.

The result:
splat_texture.png

Per-plane Textures:

Stepping back a bit, let’s look at this render:
per_plane_color

The textures are placeholders, but each pixel is colorized based on the contribution by each planar mapping. For terrain rendering, this is really nice: it’s identified the slopes for us. In addition, we read a texture for each plane anyway — but there’s no need for it to be the *same* texture!

By exploiting this, we can use a different texture for slopes and level areas, “for free”:
grass_cliff

There’s more we can do here, too, like using different variants of a texture for X and Z planes to increase the amount of variation.

Performance:

I don’t have a very good feel on what makes shaders fast or slow. I was expecting 30+ texture reads to be a problem, but it doesn’t really appear to be. The depth prepass is possibly saving me a lot of pain here, as it means very little of the expensive shading goes to waste. I did notice some issues after first implementing the terrain shader, dropping to 30FPS on occasion, but after adding some GPU profiling code it turns out *shadow rendering* is slow, and just tipped over the threshold to cause problems. (Why does rendering the sun’s shadow cascade take upwards of 5-7ms? I dunno! Something to investigate.)

That said, the method I am using to time GPU operations (glQueryCounter) appears to be less than perfectly accurate (i.e., the UI pass seems to get billed the cost of waiting for vblank.) The GL specification, meanwhile, manages to be either extremely unclear or contradictory on the exact meaning of the timestamp recorded.

For now, I’m going to say this is fine and investigate more later. (ノ`Д´)ノ彡┻━┻

Continuing Work:

At this point, I have most of what I set out to implement, but there are a few things that still need to be done before I’d consider this terrain system complete:

– LOD mesh generation. In addition to simplifying the mesh itself, probably clamp low-weight splats to zero to make shading cheaper.
– Revisit hex tiling. I suspect it really is a more natural fit.
– Fix seams with normals and splats. These are still noticeable with real terrain textures.
– More detailed shading. The terrain materials I’m using all come with height maps; adding occlusion mapping would be interesting and would help sell the look up close.

Until next time.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s