Tutorial 70: Procedural Geometry
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:
- Fill a
std::vector<VertexPositionNormalTexture>with computed positions, normals, and UVs. - Fill a
std::vector<uint32_t>with triangle indices. - Create a
VertexBufferandIndexBufferwith the appropriateBufferUsage:BufferUsage::None— static data, optimised for GPU reads.BufferUsage::WriteOnly— hint that data will be uploaded repeatedly (dynamic meshes).
- Call
SetDatato upload. For dynamic meshes callSetDataevery 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.