Tutorial 70: Procedural Geometry

CNA Tutorials  ·  3D Rendering

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<uint32_t> with triangle indices.
  3. Create a VertexBuffer and IndexBuffer with the appropriate BufferUsage:
    • BufferUsage::None — static data, optimised for GPU reads.
    • BufferUsage::WriteOnly — hint that data will be uploaded repeatedly (dynamic meshes).
  4. Call SetData to upload. For dynamic meshes call SetData every frame (or use buffer orphaning).

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:

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

    std::vector<V>        verts;
    std::vector<uint32_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
    indices.reserve(static_cast<size_t>(rings * sectors * 6));
    for (int r = 0; r < rings; ++r) {
        for (int s = 0; s < sectors; ++s) {
            uint32_t cur  = r * (sectors + 1) + s;
            uint32_t next = cur + sectors + 1;
            indices.push_back(cur);
            indices.push_back(next);
            indices.push_back(cur + 1);
            indices.push_back(cur + 1);
            indices.push_back(next);
            indices.push_back(next + 1);
        }
    }

    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::VertexDeclaration,
        static_cast<int>(verts.size()), BufferUsage::None);
    sphereVB_->SetData(verts.data(), static_cast<int>(verts.size()));

    sphereIB_ = std::make_unique<IndexBuffer>(
        gd, IndexElementSize::ThirtyTwoBits,
        static_cast<int>(indices.size()), BufferUsage::None);
    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;

        // Bottom vertex
        verts.push_back({
            Vector3(cx * radius, -height * 0.5f, cz * radius),
            Vector3(cx, 0.0f, cz),   // outward normal
            Vector2(u, 1.0f)
        });
        // Top vertex
        verts.push_back({
            Vector3(cx * radius,  height * 0.5f, cz * radius),
            Vector3(cx, 0.0f, cz),
            Vector2(u, 0.0f)
        });
    }
    return verts;  // render as TriangleStrip
}

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), create the VertexBuffer with BufferUsage::WriteOnly and call SetData with a discard hint each frame. This tells the driver to orphan the old buffer and allocate a fresh one, avoiding a GPU pipeline stall:

// LoadContent — create a dynamic buffer
dynamicVB_ = std::make_unique<VertexBuffer>(
    gd, VertexPositionNormalTexture::VertexDeclaration,
    MAX_VERTS, BufferUsage::WriteOnly);

// Update — called every frame
void UpdateDynamicMesh(GraphicsDevice& gd,
                       std::span<const VertexPositionNormalTexture> verts) {
    // SetData with SetDataOptions::Discard for dynamic updates
    dynamicVB_->SetData(
        0,                     // byte offset
        verts.data(),
        static_cast<int>(verts.size()),
        SetDataOptions::Discard);
}

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 dynamic VertexBuffer using the SetDataOptions::Discard pattern above. For volumes larger than about 64³ cells, partial rebuild (only dirty chunks) is essential for real-time performance.