Tutorial 70: Procedural Geometry

CNA Tutorials  ·  3D Rendering

ℹ

What you’ll learn

  • Generating sphere and cylinder meshes at runtime.
  • Driving terrain geometry from a noise function.
  • Updating a DynamicVertexBuffer without reallocating it each frame.
  • The profile limits (index width, buffer size, primitives per draw) that decide how much you can build in one mesh.

Before you start — Tutorial 38: Vertex Buffers and Index Buffers (buffer creation and dynamic updates) and Tutorial 40: Primitive Types (which topology to emit). Requires a 3D-capable renderer such as OPENGLES3 or VULKAN (every 3D renderer draws indexed triangle meshes; only HEADLESS and STUB produce no pixels); the 2D-only renderer (SDL_RENDERER) throws on 3D calls by default.

⚠

Requirements: graphics-profile limits. CNA’s default GraphicsProfile is Reach, and this snapshot enforces its ceilings on every renderer. For procedural meshes that means: 16-bit indices only (IndexElementSize::ThirtyTwoBits throws NotSupportedException on Reach, so a mesh is limited to 65,536 vertices), at most 65,535 primitives per draw (1,048,575 on HiDef), and, on every profile, at most 67,108,863 bytes in one vertex or index buffer. The sphere below has 33 × 33 = 1,089 vertices, so it uses 16-bit indices and runs on the default profile. A mesh with more than 65,536 vertices (a dense noise terrain, a marching-cubes chunk) either needs HiDef, requested in the Game constructor before the device exists, or should be split into several meshes as Tutorial 68 does:

ProceduralGame() : graphics_(this) {
    graphics_.setGraphicsProfileProperty(GraphicsProfile::HiDef);
    // Or for the whole project, before the Game is constructed:
    //   CNA::SetProjectGraphicsProfileEXT(GraphicsProfile::HiDef);   // "CNA/ProjectGraphicsProfile.hpp"
}

The full list of profile ceilings, and the errors you will see when one is exceeded, is in Tutorial 152: Reach vs HiDef.

On HiDef, 32-bit index buffers are implemented by every 3D renderer that draws (OPENGLES3, OPENGL33, WEBGL2, VULKAN, WEBGPU, SDL_GPU, DIRECTX9, DIRECTX11, METAL, FNA3D, SOFTWARE), and by HEADLESS and STUB for validation.

Building meshes at runtime

CNA's VertexBuffer and IndexBuffer accept raw C++ arrays, so you can generate geometry in CPU code and upload it to the GPU in LoadContent (for static meshes) or during Update (for dynamic meshes). The workflow is:

  1. Fill a std::vector<VertexPositionNormalTexture> with computed positions, normals, and UVs.
  2. Fill a std::vector<uint16_t> with triangle indices (or uint32_t on HiDef, see Requirements).
  3. Create a VertexBuffer and IndexBuffer with the appropriate BufferUsage:
    • BufferUsage::WriteOnly — the CPU will only write the buffer, never read it back; GetData on it throws. Use it for meshes you build once and draw many times.
    • BufferUsage::None — the buffer may also be read back with GetData.
    Neither value means “static” or “dynamic”; that distinction is made by the buffer class, below.
  4. Call SetData to upload. For meshes you rewrite every frame use a DynamicVertexBuffer and SetDataOptions::Discard: a plain buffer that is currently bound cannot be rewritten (SetData throws InvalidOperationException because the resource is in use).

Procedural sphere

A UV sphere is parameterised by longitude (phi, 0..2π) and latitude (theta, 0..π). Each ring of vertices steps through theta; each column steps through phi. The normal at any point on the unit sphere is identical to the position vector. The indices are 16-bit, so the function refuses a sphere with more than 65,536 vertices rather than silently wrapping:

// Returns a pair of {vertices, indices} for a UV sphere (16-bit indices: Reach-safe)
std::pair<std::vector<VertexPositionNormalTexture>,
          std::vector<uint16_t>>
ProceduralSphere(float radius, int rings, int sectors) {
    using namespace Microsoft::Xna::Framework;
    using namespace Microsoft::Xna::Framework::Graphics;
    using V = VertexPositionNormalTexture;

    if ((rings + 1) * (sectors + 1) > 65536)
        throw std::invalid_argument("sphere needs 32-bit indices (HiDef) or fewer vertices");

    std::vector<V>        verts;
    std::vector<uint16_t> indices;
    verts.reserve(static_cast<size_t>((rings + 1) * (sectors + 1)));

    const float R    = 1.0f / static_cast<float>(rings);
    const float S    = 1.0f / static_cast<float>(sectors);
    const float PI   = MathHelper::Pi;
    const float TWOPI = 2.0f * PI;

    for (int r = 0; r <= rings; ++r) {
        for (int s = 0; s <= sectors; ++s) {
            float y  = std::sin(-PI / 2.0f + PI * r * R);
            float x  = std::cos(TWOPI * s * S) * std::sin(PI * r * R);
            float z  = std::sin(TWOPI * s * S) * std::sin(PI * r * R);

            V v;
            v.Normal   = Vector3(x, y, z);
            v.Position = v.Normal * radius;
            v.TextureCoordinate = Vector2(s * S, r * R);
            verts.push_back(v);
        }
    }

    // Build index buffer. XNA treats clockwise triangles as front faces and the
    // default state culls counter-clockwise ones, so each triangle is wound
    // clockwise as seen from outside the sphere (worked out by hand for XNA's rule).
    indices.reserve(static_cast<size_t>(rings * sectors * 6));
    for (int r = 0; r < rings; ++r) {
        for (int s = 0; s < sectors; ++s) {
            uint16_t cur  = static_cast<uint16_t>(r * (sectors + 1) + s);
            uint16_t next = static_cast<uint16_t>(cur + sectors + 1);
            indices.push_back(cur);
            indices.push_back(cur + 1);
            indices.push_back(next);
            indices.push_back(cur + 1);
            indices.push_back(next + 1);
            indices.push_back(next);
        }
    }

    return {std::move(verts), std::move(indices)};
}

Upload to the GPU in LoadContent:

void LoadContent() override {
    auto& gd = getGraphicsDeviceProperty();
    auto [verts, indices] = ProceduralSphere(1.0f, 32, 32);

    sphereVB_ = std::make_unique<VertexBuffer>(
        gd, VertexPositionNormalTexture::getVertexDeclarationStatic(),
        static_cast<int>(verts.size()), BufferUsage::WriteOnly);
    sphereVB_->SetData(verts.data(), static_cast<int>(verts.size()));

    sphereIB_ = std::make_unique<IndexBuffer>(
        gd, IndexElementSize::SixteenBits,      // ThirtyTwoBits throws on Reach
        static_cast<int>(indices.size()), BufferUsage::WriteOnly);
    sphereIB_->SetData(indices.data(), static_cast<int>(indices.size()));
    spherePrimCount_ = static_cast<int>(indices.size()) / 3;
}

Procedural cylinder

A cylinder is built from three parts: the side wall (a ring of quads), and two caps (triangle fans). Here is the side wall construction:

std::vector<VertexPositionNormalTexture>
ProceduralCylinderSide(float radius, float height, int segments) {
    using V = VertexPositionNormalTexture;
    std::vector<V> verts;
    const float step = MathHelper::TwoPi / segments;

    for (int i = 0; i <= segments; ++i) {
        float angle = i * step;
        float cx = std::cos(angle);
        float cz = std::sin(angle);
        float u  = static_cast<float>(i) / segments;

        // Top vertex first, then bottom: with this order the strip's triangles are
        // clockwise as seen from outside, the front-face winding of XNA's default
        // (CullCounterClockwise) state.
        verts.push_back({
            Vector3(cx * radius,  height * 0.5f, cz * radius),
            Vector3(cx, 0.0f, cz),   // outward normal
            Vector2(u, 0.0f)
        });
        verts.push_back({
            Vector3(cx * radius, -height * 0.5f, cz * radius),
            Vector3(cx, 0.0f, cz),
            Vector2(u, 1.0f)
        });
    }
    return verts;  // render as TriangleStrip: DrawPrimitives(TriangleStrip, 0, 2 * segments)
}

Procedural terrain from noise

Simple fractal noise (value noise layered at multiple octaves) creates believable terrain without a pre-authored heightmap. A minimal 2D value noise with bilinear interpolation:

// Deterministic hash → pseudo-random float [0,1]
float Hash(int x, int z) {
    int n = x + z * 57;
    n = (n << 13) ^ n;
    return 1.0f - ((n * (n * n * 15731 + 789221)
                   + 1376312589) & 0x7fffffff) / 1073741824.0f;
}

float ValueNoise(float x, float z) {
    int ix = static_cast<int>(std::floor(x));
    int iz = static_cast<int>(std::floor(z));
    float fx = x - ix, fz = z - iz;
    // Smooth step
    fx = fx * fx * (3.0f - 2.0f * fx);
    fz = fz * fz * (3.0f - 2.0f * fz);
    return MathHelper::Lerp(
        MathHelper::Lerp(Hash(ix,     iz    ), Hash(ix + 1, iz    ), fx),
        MathHelper::Lerp(Hash(ix,     iz + 1), Hash(ix + 1, iz + 1), fx),
        fz);
}

float FractalNoise(float x, float z, int octaves = 6) {
    float val = 0.0f, amp = 1.0f, freq = 1.0f, max = 0.0f;
    for (int i = 0; i < octaves; ++i) {
        val  += ValueNoise(x * freq, z * freq) * amp;
        max  += amp;
        amp  *= 0.5f;
        freq *= 2.0f;
    }
    return val / max;
}

Dynamic VertexBuffer update

For geometry that changes every frame (cloth, water mesh, particle ribbons), use a DynamicVertexBuffer, the one buffer class that takes a SetDataOptions argument. A plain VertexBuffer has no such overloads, and rewriting one that a previous draw still has bound throws. Passing SetDataOptions::Discard tells the driver to orphan the old contents and allocate fresh storage, avoiding a GPU pipeline stall; NoOverwrite promises not to touch data that is still in flight, for appending. Most CNA renderers honour the hint as a real GPU mapping hint; a few ignore it and always behave like Discard. The typed overload is SetData(const T* data, int startIndex, int elementCount, SetDataOptions), and the write always lands at the start of the buffer (startIndex only selects where reading from your array begins):

// LoadContent — create a dynamic buffer (capacity MAX_VERTS vertices)
dynamicVB_ = std::make_unique<DynamicVertexBuffer>(
    gd, VertexPositionNormalTexture::getVertexDeclarationStatic(),
    MAX_VERTS, BufferUsage::WriteOnly);

// Update — called every frame
void UpdateDynamicMesh(std::span<const VertexPositionNormalTexture> verts) {
    dynamicVB_->SetData(
        verts.data(),
        0,                                   // first element to read from verts
        static_cast<int>(verts.size()),
        SetDataOptions::Discard);
}

A DynamicVertexBuffer can also lose its contents when the device is reset (getIsContentLostProperty() and the ContentLost event, raised on the renderers whose API can lose a device); if it does, upload the mesh again. The member is declared std::unique_ptr<DynamicVertexBuffer> dynamicVB_;.

Marching cubes overview

Marching cubes is a classic algorithm for extracting a triangle mesh from a scalar field (e.g. a 3D density or signed distance function). It processes each cell of a 3D grid, classifies the 8 corners as inside or outside the isosurface (256 possible configurations), and emits zero to five triangles per cell using a pre-computed lookup table. Applications include:

  • Voxel terrain (Minecraft-style but with smooth surfaces)
  • Metaballs / implicit surfaces
  • Medical imaging volume rendering

In CNA you would run marching cubes on the CPU each frame (or in a background thread — see Tutorial 78) and upload the resulting std::vector<VertexPositionNormalTexture> to a DynamicVertexBuffer using the SetDataOptions::Discard pattern above (a non-indexed draw needs no index buffer, but each draw is still limited to 65,535 primitives on Reach, 1,048,575 on HiDef). For volumes larger than about 64³ cells, partial rebuild (only dirty chunks) is essential for real-time performance.