Navigate
Search topics across all sections
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?
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
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
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 faceThe 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
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.
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.// 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// Custom geometry without bounds
const geo = new BufferGeometry();
geo.setAttribute('position', /* ... */);
geo.computeVertexNormals();
// Works... until the camera moves and the
// object suddenly vanishes!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.