Navigate

Search topics across all sections

GitHub
Geometries

Custom Geometry

When the built-in shapes aren't enough, you can build any shape from scratch. Define your own vertices, tell the computer how to connect them, and create geometry that doesn't exist in any toybox.

You carefully define the vertices for a custom triangle. You add lights, a nice material, and position the camera perfectly. The scene loads and... the triangle is completely black. You rotate the view and suddenly it appears from behind — but the front is invisible. What's going on?

terminal
Custom geometry is invisible from the front but visible from behind. Material appears black when visible.

Real-world

Think of custom geometry like origami — folding paper into 3D shapes.

Every fold creates a flat surface (a triangle). Every point where folds meet is a vertex. The direction a surface faces determines whether you see the "front" or "back" of the paper.

When you fold origami, the order matters. Fold the paper the wrong way and the design is backwards. In 3D, the order you list your vertices determines which side is the "front" — and by default, the back side is invisible.

Custom geometry is the ultimate creative tool: you place every vertex, define every triangle, and shape any form you can imagine. But with great power comes great responsibility — you need to handle the details that built-in shapes do automatically.

Place Vertices

Define dot positions

Connect Triangles

Define which dots form faces

Compute Normals

Tell the GPU which way faces point

Compute Bounds

Tell the camera what's visible

Render!

Your custom shape appears

Hands-On: Building a Custom Quad

Let's build a square (quad) from scratch. A quad is just two triangles that share an edge — the simplest shape beyond a single triangle.

Step 1: Place four vertices

CustomQuad.tsxTSX
const positions = new Float32Array([
  -1, -1, 0,   // vertex 0: bottom-left
   1, -1, 0,   // vertex 1: bottom-right
   1,  1, 0,   // vertex 2: top-right
  -1,  1, 0,   // vertex 3: top-left
]);

Four dots arranged in a square. But the GPU only draws triangles — so how do we make a square from triangles? We need to tell it which dots to connect.

Step 2: Connect them with an index

CustomQuad.tsxTSX
const indices = new Uint16Array([
  0, 1, 2,   // Triangle 1: bottom-left, bottom-right, top-right
  0, 2, 3,   // Triangle 2: bottom-left, top-right, top-left
]);
// Counter-clockwise order = front face

The index says: "Make a triangle from vertices 0, 1, 2, then another from 0, 2, 3." Notice the counter-clockwise order — this is crucial! It tells the GPU which side is the "front." Two triangles sharing vertex 0 and 2 form a seamless square.

Step 3: Assemble and finalize

CustomQuad.tsxTSX
const geo = useMemo(() => {
  const g = new BufferGeometry();
  g.setAttribute('position',
    new BufferAttribute(positions, 3));
  g.setIndex(new BufferAttribute(indices, 1));
  g.computeVertexNormals();
  g.computeBoundingSphere();
  return g;
}, []);

<mesh geometry={geo}>
  <meshStandardMaterial color="teal" />
</mesh>

We set the position attribute, add the index, compute normals (for lighting), and compute the bounding sphere (so the camera knows when it's on screen). Now we have a fully lit, properly culled custom quad! Try adding vertex colors by creating a "color" attribute with 3 values (r, g, b) per vertex.

What you just learned

Custom geometry starts with placing vertices (dots in 3D space) and connecting them into triangles with an index buffer.

Winding order matters: counter-clockwise = front face, clockwise = back face (invisible by default).

You MUST compute normals for lit materials to work — otherwise surfaces render as pure black.

Always compute bounding box and sphere so frustum culling works correctly and objects don't randomly disappear.

Question

If you create a custom cube, each face needs to be flat-shaded (distinct from its neighbors). A cube has 8 corners, but you might need more than 8 vertices. Why? What would happen if you shared vertices between faces?

Think about it...

You build a custom triangle with vertices in this order: (0,1,0), (1,-1,0), (-1,-1,0). Looking at it from the front (positive Z direction), it's invisible. What's the most likely fix?

Hint: Think about the difference between clockwise and counter-clockwise winding...

Try These Challenges

Put what you learned into practice. Try each challenge in the demo above using the Leva controls, then check the solution.

Try This!

Beginner

Toggle vertexColors off — what color is it?

Try This!

Intermediate

Hide normals — can you guess their direction?

Try This!

Advanced

Scale to 3 — do normals scale too?

These are the patterns that trip up developers most often. Switch between Wrong and Fixed to compare the code side by side.

1
Forgetting to compute normals
Custom geometry renders as completely black even with lights
Don't do this
CustomShape.tsxTSX
const positions = new Float32Array([
  -1, -1, 0,  1, -1, 0,  0, 1, 0,
]);
geo.setAttribute('position',
  new BufferAttribute(positions, 3)
);
// No normals! Lit materials will be pure black.
Lit materials (MeshStandardMaterial, MeshPhongMaterial, etc.) need normal vectors to calculate how light bounces off the surface. Without normals, the lighting math produces zero — pure black. Always call computeVertexNormals() after setting positions.
2
Wrong winding order makes faces invisible
The triangle exists but you can only see it from behind
Don't do this
CustomShape.tsxTSX
// Clockwise order — this is a BACK face!
const positions = new Float32Array([
   0,  1, 0,  // top
   1, -1, 0,  // bottom-right
  -1, -1, 0,  // bottom-left
]);
// With default culling, invisible from the front
Three.js determines front vs. back faces by the order of vertices. Counter-clockwise (CCW) = front face. Clockwise (CW) = back face, which gets hidden by default. Fix the vertex order, or use DoubleSide to render both sides.
3
Not computing bounding box/sphere
Objects randomly disappear when you rotate the camera
Don't do this
CustomShape.tsxTSX
// Custom geometry without bounds
const geo = new BufferGeometry();
geo.setAttribute('position', /* ... */);
geo.computeVertexNormals();
// Works... until the camera moves and the
// object suddenly vanishes!
Three.js uses bounding spheres to decide if an object is visible to the camera (frustum culling). Without bounds, the engine can't make this check correctly, causing objects to vanish when they should be visible. Always compute bounds after building custom geometry.

Best Practices

Always Compute Normals

Call computeVertexNormals() after setting all positions and indices. Without normals, lit materials render black. Only skip if you provide manual normals for special effects.

Always Compute Bounds

Call computeBoundingBox() and computeBoundingSphere() after building geometry. Without bounds, frustum culling won't work and objects may randomly vanish.

CCW Winding = Front Face

List vertices in counter-clockwise order when viewed from the front. Clockwise faces are hidden by default. Use DoubleSide only when both sides need to be seen.

Use Indices to Save Memory

When triangles share vertices (which they usually do), use an index buffer. Define each unique vertex once and reference it by number. This can cut memory usage by 30-50% on complex shapes.