Geometry Processing

B-Spline Volumes

A friend shared a paper called CUBE. It's about encoding faces as B-spline volumes. Sounded elegant until I realised I had no idea what a B-spline volume was.

Math lady meme

1 1D B-splines in detail

If you are reading this blog, you must be familiar with B-splines, mostly in context of curve fitting. This section is a quick refresher and a buildup for extending it to 3D.

The goal is to draw a smooth curve through space that you can control locally.

You have a set of handles (control points). Move one handle and a nearby section of the curve follows. The rest of the curve stays put. The curve is always smooth, always well-behaved, no matter how many handles you have.

Interactive โ€” drag the red control points to reshape the curve
4 control points degree 2

The dashed polygon connects control points. The blue curve is pulled toward them without touching them. Drag any red dot to see the curve reshape instantly.

Control points

Let's start simple. Place 4 points anywhere in 2D space and call them Pโ‚, Pโ‚‚, Pโ‚ƒ, Pโ‚„. Remember, these are not points on the curve. They don't sit on it. They pull it, like magnets. The curve bends toward them but never has to touch them.

๐Ÿ’ก The puppet analogy
Imagine control points as the strings of a puppet. Pull string #2 upward and the nearby part of the body lifts but the feet stay put. The influence is local, smooth, and it fades with distance. That's exactly how control points work.
Puppet meme

Now we need a way to talk about position along the curve. We introduce a parameter u โˆˆ [0, 1] and think of it as a dial. At u = 0 you're at the start of the curve. At u = 1 you're at the end. Anywhere in between, you're somewhere along it.

So what is the curve point at a given u? It's just a weighted average of the control points:

curve(u) = Nโ‚(u)ยทPโ‚ + Nโ‚‚(u)ยทPโ‚‚ + Nโ‚ƒ(u)ยทPโ‚ƒ + Nโ‚„(u)ยทPโ‚„ The basis functions Nแตข(u) decide how much each control point contributes at position u

Basis functions

Here is the first thing to internalize. For a degree-2 spline, at most degree + 1 = 3 control points have non-zero weight at any given u. Always. This is local support stated precisely. If you have 4 control points, exactly one of them has zero weight at any u. If you have 10 control points, at least 7 of them have zero weight. The curve only ever talks to 3 neighbours at a time, no matter how many control points you have in total.

๐Ÿ’ก Why this matters
Move control point Pโ‚„ and only the nearby region of the curve responds. The 3 active neighbours shift, everyone else stays exactly put. This is what makes B-splines practical for complex shapes. You get precise local control, not a global ripple through the entire curve.

So what are these weight functions Nแตข(u) concretely? Each one is a smooth bump. It rises from zero, peaks near its control point's region of parameter space, then fades back to zero. Outside its local territory it is exactly zero, which is what enforces the degree+1 rule above.

For degree 2 with 3 control points, we can write them out explicitly. They turn out to be the binomial expansion of ((1โˆ’u) + u)ยฒ:

Nโ‚(u) = (1โˆ’u)ยฒ    Nโ‚‚(u) = 2u(1โˆ’u)    Nโ‚ƒ(u) = uยฒ Bernstein polynomials of degree 2. They always sum to 1, guaranteeing a true weighted average.

With more control points this closed form breaks down and you need the Cox-de Boor recursion, covered in the next section. But the shape of the weights stays the same: smooth bumps, summing to 1, exactly degree+1 of them active at any u.

Interactive โ€” drag the u slider and watch the weights change
3 control points degree 2
0.50
Nโ‚(u)
0.25
Nโ‚‚(u)
0.50
Nโ‚ƒ(u)
0.25
sum
1.00

At u = 0, Nโ‚ = 1 and everything else is zero โ€” the curve starts at Pโ‚. At u = 1, Nโ‚ƒ = 1 โ€” it ends at Pโ‚ƒ. At u = 0.5, Nโ‚‚ peaks and the curve is pulled hardest toward Pโ‚‚. Notice that the sum bar is always 1. That is the weighted average guarantee holding everywhere.

This is the core mechanic. The basis functions are a smooth baton pass of influence from one control point to the next as u sweeps from 0 to 1. At any moment only 3 runners are holding the baton.

The knot vector

Here is the problem. Three control points gave us one smooth arc. But what if you want a long, complex curve with many control points? You could fit one giant high-degree polynomial through all of them, but that leads to Runge's phenomenon: wild oscillations and numerical instability.

The B-spline solution is much smarter. Instead of one big polynomial, stitch together many small quadratic pieces, each controlled by just 3 nearby points. The knot vector is simply the schedule that tells you where each piece starts and ends along the u axis.

For 4 control points, degree 2, a uniform knot vector looks like:

knots = [0, 0, 0, 0.5, 1, 1, 1] Length = n_control_points + degree + 1 = 4 + 2 + 1 = 7. Always.

Let's read this out loud. The three 0s at the start and three 1s at the end are repeated on purpose. They pin the curve so it starts exactly at Pโ‚ and ends exactly at Pโ‚„. Without them the curve would float away from its endpoints.

The interior value 0.5 is just a mile marker on the u axis. It is not a control point. It has no position in space. It simply says: segment 1 runs from u = 0 to u = 0.5, segment 2 from 0.5 to 1. That is all a knot vector does.

[ 0 0 0 0.5 โ†’ seg boundary 1 1 1 ]

Here is a concrete example. Say you are evaluating the curve at u = 0.6. You look at the knot vector and ask: which span does 0.6 fall in? It falls between 0.5 and 1, so you are in segment 2. Now, which control points govern segment 2? The rule is simple: for degree 2, each segment uses exactly 3 consecutive control points starting at the span index minus the degree. Segment 2 is the second active span, which sits at index 3 in the knot vector (counting from 0). So the starting control point is index 3 minus 2 = 1, giving us Pโ‚‚, Pโ‚ƒ, Pโ‚„ (1-indexed). That is the only information the knot vector gives you - which three control points to use.

Why 0.5 and not some other value? It's just 1/2, equally dividing the [0,1] range into 2 segments. You could use any value. Non-uniform knot spacing lets you put more detail in certain regions.

Where do the weights come from?

So far we have used the basis functions Nโ‚(u), Nโ‚‚(u), Nโ‚ƒ(u) as if they just exist. For 3 control points and degree 2, we could write them out as closed-form polynomials. But what happens when you have 6, 10, or 100 control points? You need a general algorithm that computes the right weights for any u. That algorithm is the Cox-de Boor recursion

The idea is to build the smooth bump functions from scratch, starting from something extremely simple and refining it step by step. Here is how it works.

Degree 0: box functions
Ask one question for each span in the knot vector: does u fall in this span? If yes, the function is 1. If no, it is 0. At any given u, exactly one box fires. This is the seed of the whole recursion.
N[i,0] = 1 if knots[i] โ‰ค u < knots[i+1], else 0
Degree 1: blend neighboring boxes
Take each pair of neighboring degree-0 boxes and blend them together using linear interpolation. The blend weight ฮฑ is how far u has travelled into the current span. The result is a tent function. It ramps up and then ramps back down. Two control points now have non-zero influence instead of one.
N[i,1] = ฮฑยทN[i,0] + (1โˆ’ฮฑ)ยทN[i+1,0]
Degree 2: blend the tent functions
Do the same blending again, this time on the degree-1 tent functions. The result is a smooth quadratic bump. Each iteration spreads the influence one more control point wider and makes the function one degree smoother. For degree 2, three control points now have non-zero influence at any u.
N[i,2] = ฮฒยทN[i,1] + (1โˆ’ฮฒ)ยทN[i+1,1]
Read off the final weights
After d rounds of blending you have your degree-d basis function weights, one per control point. They always sum to 1, so the curve point is always a true weighted average. No control point can ever pull the curve to infinity.
weights = N[:n_ctrl, degree]

The key thing to notice is that each extra round of blending does two things simultaneously: it spreads influence to one more neighbouring control point, and it smooths the function by one degree. That is why degree and local support are directly linked. A degree-2 spline always has at most 3 active control points at any u. A degree-3 spline always has at most 4. You cannot get more smoothness without also getting slightly wider influence.

Putting it all together: computing the curve

Putting it all together meme

Given a query u, the full algorithm is four lines of logic:

Python
# Given: u โˆˆ [0,1], control_points list of (x,y) positions

# 1. Compute basis weights via Cox-de Boor
weights = bspline_basis(u, n_ctrl=4, degree=2)  # shape (4,)

# 2. Weighted average of control points
curve_point = sum(w * P for w, P in zip(weights, control_points))

# 3. Repeat for many u values โ†’ the full curve
curve = [compute(u) for u in np.linspace(0, 1, 200)]
Interactive โ€” drag the red control points to reshape the curve
4 control points degree 2

The dashed polygon connects control points. The blue curve is pulled toward them without touching them. Drag any red dot to see the curve reshape instantly.

โœ… What you now understand about 1D B-splines
Control points are handles in space. Knot vector schedules the segment boundaries. Basis functions (computed via Cox-de Boor) distribute local influence. Curve = weighted average of control points. Weights always sum to 1. Moving one point changes only a nearby region.
ยท ยท ยท

2 Extending to 3D

Going from 1D to 3D sounds intimidating. It isn't. The jump is applying the exact same 1D operation three times independently, once per axis.

Wait. What does a 3D spline even mean?

Wait meme

Before touching the math, let's make sure the goal is clear. Each dimension of spline buys you one extra dimension of output. The pattern is consistent:

Spline typeInput parametersWhat you getAnalogy
1D splineuA curve through spaceA wire bent by handles
2D splineu, vA curved surface (shell)A rubber sheet stretched over a frame
3D spline (volume)u, v, wA warp of solid spaceA rubber cube you can squeeze and stretch
๐Ÿง  The key shift at 3D

In 1D and 2D, the spline is the shape you care about. You're fitting a curve or a surface to data.

In 3D, the spline wraps around a shape you already have. You're not fitting a 3D solid. You're defining a smooth warp of the entire space inside the cage, and your mesh rides along inside it.

Think of it like this: a 1D spline tells you where a point on a wire ends up. A 3D spline tells you where every point inside a cube ends up after deformation.

๐Ÿงต 2D spline is fitting a surface

You have scattered 3D points on a bumpy surface (say, a hillside). A 2D spline fits a smooth curved shell through or near those points. The output is a 2D manifold embedded in 3D space. You care about the shell itself.

๐Ÿ“ฆ 3D spline is warping a volume

You have a mesh sitting inside a cage. A 3D spline defines how the entire interior of that cage deforms. The output is a new position for every point inside the cage. You care about what happens to the mesh inside it.

A concrete example. Think of those squishy water-filled toy balls with a rubber figurine sealed inside. The outer rubber shell is your cage, a coarse surface surrounding the figurine. When you squeeze one side of the ball, the water pressure shifts and the figurine inside deforms. It stretches where you push, compresses where you squeeze, all without you touching the figurine directly. Your mesh works the same way: move a cage point and the geometry inside follows smoothly, as if suspended in fluid.

First, let's go to 2D: a surface

Relax meme

Before jumping to 3D volumes, let's do 2D first. The goal here is different from 1D. In 1D we drew a curve through space. In 2D we want to fit a curved surface to a set of 3D points. Think of a bumpy hillside, or a car hood, or a face. You have a cloud of 3D points and you want a smooth sheet that approximates them.

The way B-splines handle this is elegant. Instead of one row of control points, you now have a grid of control points indexed by (i, j). And instead of one parameter u, you now have two: u and v. Together (u, v) is your address on the surface, just like latitude and longitude on a globe.

So how do you evaluate the surface at a given (u, v)? You just apply the 1D B-spline twice, once per axis:

Step 1: fix v and apply 1D B-spline along each row
Treat v as fixed. For each row i of the control point grid, run the 1D B-spline along that row using the v weights Nโฑผ(v). Each row of 3D control points collapses into a single 3D intermediate point Rแตข. You now have one intermediate point per row.
Rแตข = ฮฃโฑผ Nโฑผ(v) ยท P[i,j]
Step 2: apply 1D B-spline along the resulting column
Now you have a column of intermediate points Rโ‚, Rโ‚‚, Rโ‚ƒ, Rโ‚„ โ€” each one a 3D position. Run the 1D B-spline along that column using the u weights Nแตข(u). One final 3D point comes out. That is your surface point at (u, v).
surface(u,v) = ฮฃแตข Nแตข(u) ยท Rแตข
Step 3: substitute and simplify
Plug the expression for Rแตข into the second equation. The weights from both axes multiply together naturally. This is what is called the tensor product, just the consequence of applying 1D B-spline twice. Each control point P[i,j] contributes with weight Nแตข(u)ยทNโฑผ(v), which is only non-zero if (u,v) is near (i,j) in both directions simultaneously. Local support now works in 2D.
surface(u,v) = ฮฃแตข ฮฃโฑผ Nแตข(u)ยทNโฑผ(v) ยท P[i,j]

Notice what just happened. We did not invent anything new. We just applied the exact same 1D operation twice, once along v to collapse rows, once along u to collapse the resulting column. The 2D formula fell out automatically. That pattern is exactly what we will repeat one more time to get to 3D.

Now 3D a volume

Add a third parameter w and a third layer of control points. Apply 1D B-spline three times:

volume(u,v,w) = ฮฃแตข ฮฃโฑผ ฮฃโ‚– Nแตข(u) ยท Nโฑผ(v) ยท Nโ‚–(w) ยท P[i,j,k] Triple sum over all control point combinations. Weight of P[i,j,k] = Nแตข(u)ยทNโฑผ(v)ยทNโ‚–(w)

The key property: control point P[i,j,k] only has non-zero weight if all three axis distances are simultaneously small. If the query is far from index i along just the u-axis, the whole weight collapses to zero. This is local support in 3D.

๐Ÿ’ก The three independent questions
Nแตข(u): "How close is u to control point i along the x-axis?"
Nโฑผ(v): "How close is v to control point j along the y-axis?"
Nโ‚–(w): "How close is w to control point k along the z-axis?"

Multiply the three answers. Miss on any one โ†’ zero influence. Hit all three โ†’ strong influence.
๐ŸŽ›๏ธ 1D: row of handles

4 control points. One parameter u. At any u, at most 3 control points contribute (for degree 2).

๐Ÿ“ฆ 3D: cube of handles

4ร—4ร—4 = 64 control points. Three parameters u,v,w. At any (u,v,w), at most 3ร—3ร—3 = 27 control points contribute.

๐Ÿ“ The 3D formula is not harder than 1D. It's literally the same formula applied three times and multiplied together. The complexity is in the bookkeeping, not the math.
ยท ยท ยท

3 The sphere example

Repeat after me meme
๐Ÿ”ฅ The B-spline volume is a warp function, not a shape
It answers one question: "If a point starts at parameter coordinate (u,v,w), where does it end up after the warp?" It's a mapping from one 3D space to another. Your mesh vertices ride along inside it.

The rubber cube analogy

Imagine your sphere mesh sitting inside a rubber cube. The 4ร—4ร—4 lattice of control points is a cage of 64 handles on the surface of that rubber cube. When you move handles, the rubber cube stretches and everything inside it (including the sphere vertices) moves along with it.

The B-spline volume is the mathematical description of how the rubber deforms. For each sphere vertex:

Assign (u,v,w) to each vertex once, forever
Normalize each vertex's xyz position into [0,1]ยณ using the bounding box. This is the vertex's permanent address inside the cage.
u = (x โˆ’ x_min) / (x_max โˆ’ x_min)   (same for v,w)
Move control point handles to deform the cage
Push the top layer up โ†’ top of sphere stretches. Push left layer inward โ†’ sphere squishes. The (u,v,w) addresses never change only the control point positions do.
P[i,j,k] += displacement
Evaluate volume to get new vertex positions
For each vertex, query the B-spline volume at its fixed (u,v,w). The weighted sum of nearby control points gives the new xyz position.
new_xyz = ฮฃแตขโฑผโ‚– Nแตข(u)ยทNโฑผ(v)ยทNโ‚–(w) ยท P[i,j,k]
โœ… What changes vs what stays fixed
Fixed forever: the (u,v,w) parameter coordinates of each vertex, the 4ร—4ร—4 grid structure, the basis function math.

Variable: the (x,y,z) position of each control point in 3D space this is what the encoder predicts per-person in CUBE.
Interactive โ€” slide to deform ยท drag canvas to rotate
4ร—4ร—4 cage blue = original ยท orange = deformed
X planes
Y planes
Z planes

Each slider shifts an entire plane of control points along its axis. Moving X-plane 0 and X-plane 3 apart stretches the sphere. Mixing axes creates squash-and-stretch effects.

Why cage-based, not direct vertex editing?

A sphere mesh has 400 vertices. Moving all 400 by hand is tedious, error-prone, and produces no smoothness guarantees. The B-spline cage has only 64 control points and moving any one of them smoothly and automatically propagates to the right subset of nearby vertices, with mathematically guaranteed continuity.

Direct vertex editingB-spline cage
Handles to move400 (one per vertex)64 (4ร—4ร—4 cage)
SmoothnessNot guaranteedGuaranteed by math
Local controlYes but tediousYes and automatic
Different shapesCompletely different vertex listsJust different control point positions
๐Ÿง  This is exactly what CUBE builds on

Standard B-spline volumes store a 3D position (x,y,z) at each control point. When you query the volume at (u,v,w), you get back a 3D position: the reconstructed vertex location.

Simple, but limited. A coarse 4ร—4ร—4 cage does not have enough degrees of freedom to capture fine wrinkles, pores, or person-specific surface detail.

You are asking 64 control points to describe an entire human face. They can get the rough shape right, but the detail is gone.

๐Ÿ”ฅ The one conceptual swap CUBE makes

Instead of storing (x, y, z) at each control point, store a 1024-dimensional feature vector. That is the entire idea.

Standard B-spline volume
cp[i,j,k] = (x, y, z)
3 numbers per control point
CUBE
cp[i,j,k] = f โˆˆ โ„ยนโฐยฒโด
1024 numbers per control point

The B-spline math is completely unchanged. You still compute Wu[i]ยทWv[j]ยทWw[k] exactly as before. The only difference: instead of interpolating 3 numbers you are interpolating 1024. The output is no longer a 3D point โ€” it is a feature vector that a residual MLP decodes into fine geometric detail.

That's all folks meme

References

  1. CUBE: Encoding Faces as B-Spline Volumes: the paper that started this blog post.
  2. B-Splines, Ken Koon Wong: an accessible intro to B-splines in context of curve fitting.
  3. Cox-de Boor Algorithm, Wolfram Demonstrations: interactive visualisation of the recursion.
  4. Runge's Phenomenon, Wikipedia: why high-degree global polynomials oscillate wildly near the edges.