Procedural Environment Creation in Houdini, Chapter 1 — Low-Poly Terrain, Road, and Splat Maps for Unity

pavelzosim:~/atlas_●SYS.ONLINE / UTC+3

01 Overview

This chapter covers a procedural environment creation workflow in Houdini for real-time production pipelines. It walks through building a procedural environment and optimizing its assets for game development, with the focus on modeling, texturing, and preparing assets efficiently while keeping performance suitable for a game engine such as Unity.

Implementation context: Houdini for terrain, road, remeshing, UVs, and baking; Unity for the splat map and wrapped tile atlas shader.

02 Project scope

The goal is to model and texture the provided assets: a modular building, and a terrain with a road. Each asset has a specific low texel density, polygon count, and material usage. The final deliverables are:

This chapter covers the terrain and the road. The modular building is planned for the next chapter.

Reference image of the terrain with a road that is replicated with a low-poly 3D mesh
FIG 01 — The terrain reference that is replicated with a low-poly 3D mesh.

03 Heightfield and road alignment

Heightfield generation

Generate the terrain and draw the road using curves and the Labs Road Generator, Expand2D, Sweep, or a custom solution. At this stage it is crucial to store masks of the zones and terrain types, because they help with remeshing and with the wrapped tile atlas later.

Houdini viewport of a 3D terrain with labeled zones: Pavement, Grass, Rock, and Road
FIG 02 — Generating masks: the terrain zones (pavement, grass, rock, road) are stored while the heightfield is built.

Road alignment

The road passes through mountainous terrain, and the generated mountain surface (the heightfield) is a high-density volume with unevenness. Projecting a low-poly road mesh onto this volume can distort the geometry and add unnecessary slopes to the road surface. Aligning the road takes several steps.

Step 1 — project the points onto the volume

Houdini viewport: a curving blue line of road points being projected onto the terrain volume
FIG 03 — Projection of the road geometry onto the volume.

Step 2 — define and group edges by the maximum Y position in the row

These VEX helpers group edge loops, find the next edge from the current primitive, and collect the primitives connected by an edge. They are shown as they were written for this setup.

// Main function for grouping edge loops
void GroupEdgeLoops(int input, string edgegroup, groupname) {
    if (edgegroup == "") {
        setedgegroup(geoself(), groupname, 0, 0, 0);  // If the edge group is empty, clear the group
        return;
    }

    int edges[] = expandedgegroup(input, edgegroup);  // Expand the edge group into an array of edges
    if (len(edges) == 0) {
        setedgegroup(geoself(), groupname, 0, 0, 0);  // If no edges are found, clear the group
        return;
    }

    int count = int(0.5 * len(edges));  // Calculate the number of edges
    for (int i = 0; i < count; ++i) {
        int edgepts[] = sort(array(edges[2*i], edges[1+2*i]));  // Sort the edge points
        int index0 = find(loopedgepts, edgepts[0]);  // Find the index of the first edge point in the loop
        int index1 = find(loopedgepts, edgepts[1]);  // Find the index of the second edge point in the loop
        if (!edgefound) {
            // The logic for processing edge loops continues here
        }
    }
}
// Function to get the next edge based on the current primitive and edge points
function int[] GetNextEdge(int input, int edgept0, int edgept1, int currentprim) {
    int primpts[];
    int count = primvertexcount(input, currentprim);  // Get the number of vertices in the primitive
    for (int f = 0; f < count; ++f) {
        int vertIndex = vertexindex(input, currentprim, f);  // Get the vertex index
        int pointIndex = vertexpoint(input, vertIndex);  // Get the point index
        if (pointIndex != edgept0 && pointIndex != edgept1)
            append(primpts, pointIndex);  // Add the point index if it's not part of the edge
    }
    int pt0 = min(primpts[0], primpts[1]);  // Get the minimum point index
    int pt1 = max(primpts[0], primpts[1]);  // Get the maximum point index
    return array(pt0, pt1);  // Return the array of next edge points
}
// Function to get all primitives connected by a given edge
function int[] GetPrimsFromEdge(int input, int pt0, int pt1) {
    int prims[];
    int hedge = pointedge(input, pt0, pt1);  // Get half-edge from the given points
    if (hedge != -1) {
        int count = hedge_equivcount(input, hedge);  // Get the equivalent count of the half-edge
        for (int i = 0; i < count; ++i) {
            int pr = hedge_prim(input, hedge);  // Get the primitive from the half-edge
            if (pr != -1) {
                append(prims, pr);  // Add the primitive to the array
                hedge = hedge_nextequiv(input, hedge);  // Move to the next equivalent half-edge
            }
        }
    }
    return prims;  // Return the array of primitives
}

Step 3 — copy the highest point's Y position to the lowest points in the row

Houdini viewport: a winding checkered road over smooth hilly terrain, its points being aligned row by row
FIG 04 — Aligning the points by the maximum Y position in the row.

Step 4 — adjust the terrain heightfield to the road

The heightfield is adjusted to the newly repositioned road. Since the road is already aligned by the previous steps, the transitions and slopes along the road path have to be smoothed to avoid harsh geometry changes.

Houdini viewport: terrain with red and white road paths winding over hills, the heightfield being adjusted to the road
FIG 05 — Adjusting the heightfield to the road's new point positions.

04 Dense mesh and terrain zones

Convert the heightfield and store the layers

Once the terrain layers are stored in a primitive attribute and the road alignment and smoothing are complete, the heightfield volume can be converted into a dense mesh. This step matters most when texture baking is needed for normal maps, ambient occlusion (AO), or curvature maps.

Houdini viewport: a terrain with red and green zones, curved gray road lines, and a grid background, converted from a heightfield to a dense mesh
FIG 06 — Converting the heightfield to a dense mesh.

Assign the splat map zones

Different colored zones are assigned to the terrain for the splat map mask baking. They mark areas with different terrain types (for example roads, grass, dirt) that are later used for texture blending in a game engine or rendering software.

Houdini viewport: a terrain divided by paths into three colored regions (teal, red, orange) over a checkerboard pattern
FIG 07 — Terrain zones added as colors in the color channels (RGB/RGBA).

05 Remeshing and welding to the road

Remeshing is divided into several substeps to reduce the polycount without losing essential geometry, especially along mesh boundaries.

Reduce the polycount per zone

The first substep reduces the polycount individually for each terrain zone. Each zone can have a different surface area, so uniform remesh settings across the entire mesh can be interpreted badly and lose crucial geometry, particularly at the edges.

Houdini viewport: a remeshed terrain with teal, maroon, and orange zones outlined in blue
FIG 08 — Remeshing, first iteration: the mesh looks acceptable.

Weld the terrain to the road

This was the most interesting and challenging part of the process. The goal was to weld all terrain points to the road seamlessly while the road mesh itself remained unchanged.

First, all borders of each terrain zone are grouped to prepare for the welding and division process. This is what lets different terrain areas connect seamlessly while keeping their distinct characteristics.

Houdini viewport: a mesh with curved blue border lines, orange points, and numeric annotations showing grouped zone points, edges, and primitives
FIG 09 — Grouping zone points, edges, and primitives.

Combining meshes of different polycounts is designed to be fully procedural, so terrain zones and the road blend automatically without changing the road geometry.

How it works:

Houdini is well suited to problems like this: even the iteration count can be derived by checking the mesh for non-connected polygons.

Houdini viewport and parameter panel: the terrain and road welded into one gray mesh with blue outlines on a grid
FIG 10 — The welded result: the geometry is ready for the game, 3111 triangles in total.

Result of this stage: the terrain and road form a single game-ready mesh at 3111 triangles in total, and the road mesh is unchanged.

06 UVs, splat map, and atlas textures

Auto UVs

The mesh gets two UV channels. The first UV is for the Wrapped Tile Atlas: depending on the number of terrain types, the UV can be divided into sections. Here there are three sections, so each tile is equal to x0.5. The second UV channel is used for splat map masking and light baking.

The second UV channel (UV2): three terrain-zone islands laid out over a lettered checker grid
FIG 11 — The second UV channel (UV2): the three terrain zones laid out for splat map masking and light baking.

Preparing the atlases and other textures

The color information of the splat map is baked into a texture using the second UV. An atlas is also created with two different grass textures and two different rocky textures. This kind of texture atlas reduces draw calls and breaks the repetitiveness of the terrain texture.

FIG 12 — The textures: the road texture, the 2×2 atlas (two grass, two rocky), the baked splat map on UV2, and the noise mask used to break repetition.

07 Shader: splat map and wrapped tile atlas

The splat map setup is straightforward: splitting the color channels makes it easy to mask the terrain zones.

Shader graph for the splat map: Sample Texture 2D, Colorspace Conversion, Normalize, and Split nodes on a dark background
FIG 13 — Splat map sampling: sample the texture, convert the color space, normalize, and split the channels.
Unity editor showing the terrain colored by red, green, and blue splat map channels, with project panels around it
FIG 14 — Each color channel represents a terrain zone and is multiplied by the wrapped tiled subgraph described below.

The wrapped tile subgraph

The terrain texture atlas is remapped, which helps scale it on the geometry, and it is fed into the tiling node using the first UV. The next step is the Fraction function, which returns the fractional part of its input. For example, if the input is 3.75, Fraction returns 0.75, which means it works within the borders of one UV tile, here x0.5. This is useful for repeating or cyclical effects, and here it keeps the sampling inside the tile UV that is defined in the Flipbook node. The value is a Vector2 Tiling, which is then connected to the input of the texture atlas.

Shader subgraph: a UV Scale Global group (Remap, Tiling And Offset, Fraction) and a Tiling group (Split, Flipbook) feeding Sample Texture 2D and Output
FIG 15 — The wrapped tile subgraph: UV Scale Global (Remap, Tiling And Offset, Fraction) and Tiling (Split, Flipbook) feed Sample Texture 2D.

With this setup a single texture can be scaled and offset, and all of it stays within the bounds of the tile UV and that single texture. It can also be mixed with the neighboring texture using a noise mask multiplier, which helps break the repetitiveness of the terrain.

Aerial view of the terrain with a winding road through green textured ground and a large brown area, before the wrapped tile atlas
FIG 16 — Before.
Aerial view of the same terrain after the wrapped tile atlas: the road divides a brown rocky area and green vegetation with less visible repetition
FIG 17 — After, with the same tile size: 1 texture = 4 textures.

08 Result and next chapter

The terrain and road are a single low-poly mesh with two UV channels, a baked splat map, and a texture atlas that breaks repetition with a single shader setup. In the next chapter, I'll demonstrate how to create a procedural, optimized modular building.


Like this post? ( ´◔ ω◔`) ノシ

Support: Buy Me a Coffee | Patreon | GitHub | Gumroad | YouTube

// END OF ARTICLE // PROCEDURAL_ENVIRONMENT_CHAPTER_1 // EOF