okinawaengine GitHub

Two materials, one mesh

This tutorial builds a single OkItem — a crate with wooden sides and a metal lid — that wears two textures. It stays one object: one transform, one bounding sphere, one frustum test. The index buffer is carved into two ranges, each with its own material. See Items for the API.

The alternative, one item per texture, is worth avoiding: it doubles the objects for what is one mesh, and it leaves you keeping two halves of a surface aligned by hand.

Lay the faces out by material

The only real constraint is that each material's faces must be contiguous in the index buffer, so a range can name them. Build the mesh material by material and record where each run starts and how long it is.

A cube's corners already need one vertex per face — three faces meet there, each wanting its own texture coordinates — so the geometry below is six independent quads, and nothing extra has to be split:

// One quad from four corners, mapped over the whole texture.
static void addQuad(std::vector<float> &verts, std::vector<unsigned int> &idx,
                    const float *a, const float *b,
                    const float *c, const float *d) {
  const float  uv[4][2] = {{0, 0}, {1, 0}, {1, 1}, {0, 1}};
  const float *p[4]     = {a, b, c, d};
  unsigned int base     = (unsigned int)(verts.size() / 5);
  for (int i = 0; i < 4; i++) {
    verts.push_back(p[i][0]);
    verts.push_back(p[i][1]);
    verts.push_back(p[i][2]);
    verts.push_back(uv[i][0]);
    verts.push_back(uv[i][1]);
  }
  idx.insert(idx.end(), {base, base + 1, base + 2});
  idx.insert(idx.end(), {base, base + 2, base + 3});
}

// The eight corners of a unit crate.
const float A[3] = {-0.5f, -0.5f,  0.5f}, B[3] = { 0.5f, -0.5f,  0.5f};
const float C[3] = { 0.5f,  0.5f,  0.5f}, D[3] = {-0.5f,  0.5f,  0.5f};
const float E[3] = {-0.5f, -0.5f, -0.5f}, F[3] = { 0.5f, -0.5f, -0.5f};
const float G[3] = { 0.5f,  0.5f, -0.5f}, H[3] = {-0.5f,  0.5f, -0.5f};

std::vector<float>        verts;
std::vector<unsigned int> idx;

// The five wooden sides first...
long sidesFirst = (long)idx.size();
addQuad(verts, idx, A, B, C, D);   // front
addQuad(verts, idx, F, E, H, G);   // back
addQuad(verts, idx, B, F, G, C);   // right
addQuad(verts, idx, E, A, D, H);   // left
addQuad(verts, idx, E, F, B, A);   // bottom
long sidesCount = (long)idx.size() - sidesFirst;

// ...then the metal lid, so the two runs stay contiguous.
long lidFirst = (long)idx.size();
addQuad(verts, idx, D, C, G, H);   // top
long lidCount = (long)idx.size() - lidFirst;

Every face brings its own four vertices, so the crate has 24 rather than 8. That is not a concession to the material split — a cube needs it anyway, since three faces meet at each corner and each wants its own texture coordinates. With stride 5 the engine derives normals from the triangle list, and unshared vertices give the flat, hard-edged shading a crate should have.

Create the item and name its slots

OkItem *crate = new OkItem("crate", verts.data(), (long)verts.size(),
                           idx.data(), (long)idx.size());
crate->addMaterialFromFile(sidesFirst, sidesCount, "assets/wood.png");
crate->addMaterialFromFile(lidFirst,   lidCount,   "assets/metal.png");
scene->addObject(crate);

That is the whole difference. Everything else about the item behaves as it always did — setPosition, setVisible, setFade, parenting — and applies to the mesh as a whole.

What happens at draw time

Per frame the item is culled once, its transform is uploaded once, and its lighting and fade uniforms are set once. Then, for each slot, the texture is bound and one glDrawElements is issued over that slot's range. Two slots cost one extra bind and one extra draw call — not a second object.

An item with no slots is unchanged: it draws its whole index buffer with the texture set by loadTextureFromFile or setTexture. Slots are opt-in, and single-material meshes pay nothing for the feature.

Choosing the split

Ranges are drawn in the order added and should cover the index buffer without overlapping. Nothing enforces that: a gap never draws and an overlap draws twice, both silently. Building the mesh one material at a time, recording first and count as above, makes it hard to get wrong.

Where two materials meet within a surface rather than at a corner — a stripe across a floor, a worn patch on a wall — the vertices along the join usually have to be duplicated: same position, one copy per material, each with its own texture coordinates. A vertex carries one set, so it can belong to one mapping only. That costs vertices, never triangles, and it is the same reason a cube corner is three vertices rather than one.

And if the join falls somewhere the mesh has no edge at all, no slot can help: the geometry has to be cut along that line first, and only then can the ranges name the two sides. Painting part of a face is a job for a shader mask or a decal, not for a material slot.