Two completely separate upper-arm and forearm SVG layers often reveal a crack at the elbow. After rotation, their contours no longer share the same edge, so the transparent background becomes visible as white dots or a thin white line. A more continuous solution builds a mesh around the joint and lets each vertex respond to both bones.
1. The linear blend skinning equation
Let a vertex be attached to n bones with weights w_j. Let M_j be the current bone matrix and B_j the bind-pose matrix. Linear blend skinning (LBS) is:
v' = sum_j w_j M_j B_j^-1 v
subject to: w_j >= 0, sum_j w_j = 1
B_j^-1 moves the vertex from bind-pose world space back into bone-local space, and M_j moves it into the current pose. Omitting the inverse bind matrix causes a second offset on the first animated frame.
2. A two-bone numerical example
Suppose an elbow vertex would be at p=(10,0) under the parent transform and c=(6,8) under the child transform. With w_parent=0.25 and w_child=0.75:
v' = 0.25 p + 0.75 c
= 0.25(10,0) + 0.75(6,8)
= (7,6)
If the weights accidentally sum to 0.25 + 0.60 = 0.85, the vertex shrinks toward the origin. Normalize by the weight sum before applying the blend.
3. How blend width changes the joint
An initial weight field can use each vertex’s horizontal distance from the elbow. A simple linear transition is:
w_child(x) = clamp((x - (pivot - r)) / (2r), 0, 1)
w_parent(x) = 1 - w_child(x)
If r is too small, the joint folds like hard paper. If it is too large, deformation spreads into rigid areas. In the demo, blue means parent weight and red means child weight. Change the joint angle and blend width to see the point grid bend continuously.
Weights · Deformation
Skinning Weights and Seams
Understand linear blend skinning, normalized weights, and why joints collapse or reveal gaps.
4. Where white seam dots come from
- Geometry gaps: two parts do not share boundary vertices and expose a subpixel opening after rotation.
- Transparent-edge contamination: transparent PNG pixels contain white RGB, which bilinear filtering mixes into visible edges.
- SVG antialiasing: adjacent paths are rasterized separately, leaving two displaced translucent edges.
- Discontinuous weights: neighboring vertices receive sharply different transforms and pull the boundary apart.
5. A production repair order
- Prefer one mesh or shared boundary vertices around the joint.
- Add a 1 to 2 pixel inward overlap for separate layers instead of making contours touch exactly.
- Run alpha dilation before exporting transparent PNGs so edge pixels inherit nearby real colors instead of white.
- Use a premultiplied-alpha texture pipeline and keep the export and runtime alpha modes consistent.
- Verify normalized weights and apply limited spatial smoothing without blurring the outer silhouette.
This is why the browser Animation Asset Lab now uses neighboring-color merging, closed contours, and a small overlap when repairing tiny components and seams. A stroke hides the symptom; continuous geometry and correct alpha address the cause.
6. Limits of LBS
LBS linearly averages rigid transforms, so large bend angles lose volume, producing the familiar candy-wrapper or collapsing-joint artifact. 2D rigs often add corrective shapes, helper bones, or manually drawn corrective meshes at important poses instead of adding arbitrary weights.
The candy-wrapper is not folklore; it is a collapse you can compute
The previous section noted that LBS loses volume at large bend angles. This often gets treated as a phenomenon to be avoided by experience, but it can be computed exactly — and computing it once makes clear why more weight painting cannot fix it.
The root cause: LBS averages matrices linearly, and the set of rotation matrices is not closed under addition — the average of two rotation matrices is generally not a rotation matrix.
Take the extreme case: a vertex weighted equally to two bones whose relative rotation is 180 degrees. Let one be the identity rotation R(0) and the other R(π):
R(0) = [ 1 0] R(π) = [-1 0]
[ 0 1] [ 0 -1]
0.5 R(0) + 0.5 R(π) = [0 0]
[0 0]
The average is the zero matrix. Any vertex multiplied by it collapses to the origin. That is the limiting form of the candy-wrapper: fold an arm fully and the ring of equally-weighted vertices at the elbow is crushed onto the bone axis, the cross-section shrinking to a line — exactly like a wrapper being twisted.
180 degrees is the extreme; at general angles the degradation is continuous. With equal weights and a relative rotation of θ, the average matrix has a scale factor of cos(θ/2): at 90 degrees the cross-section shrinks to 71%, at 120 degrees to 50%. That number depends only on the bend angle — not on how well the weights are painted or how wide the falloff is. Falloff width determines how far the collapse spreads, never how deep it goes.
Knowing this, the choice of remedy becomes clear. Genuinely eliminating it means replacing the “linearly average the matrices” premise:
- Helper bones (half-angle bones): insert a bone at the elbow that always holds half the angle between parent and child, and weight vertices primarily to it. No two weighted bones are then more than
θ/2apart, and acos(θ/4)loss is usually invisible. In 2D this is the best return on effort. - Dual quaternion skinning: interpolate on the Lie group of rigid transforms rather than averaging linearly, removing the collapse at the source. The cost is slight bulging on the outside of joints, plus a significant step up in implementation complexity.
- Corrective shapes: hand-author correct cross-sections at a few key angles and interpolate between them. Most controllable, most labour.
Conversely, adding more influencing bones or repeatedly reshaping the weight falloff does nothing for this, because neither touches the linear averaging that causes it. Worth remembering generally: before adjusting parameters, confirm the thing you are trying to fix is something parameters can affect.
Sort before truncating to four bones
A later section mentions renormalising after truncating to the top four weights. There is a prior question: “top four” means the four largest weights, not the first four encountered.
An influence list’s in-memory order usually comes from the order things were written during binding, or from bone index order — neither correlates with weight magnitude. An implementation that takes the first four and renormalises can easily discard a dominant bone at 0.7 and keep four minor ones at 0.05 each. Renormalisation then inflates those four to 0.25 apiece, leaving the vertex entirely controlled by bones that barely mattered.
infl.sort(key=lambda i: i.weight, reverse=True) # sort first
infl = infl[:4] # then truncate
total = sum(i.weight for i in infl)
for i in infl: i.weight /= total # renormalise last
The symptom is deceptive: most vertices behave, and a scattered few fly somewhere strange once things move. Only vertices with more than four influences get truncated at all, and those cluster where several parts meet — armpits, hips, collars.
One companion check: validate export-time and runtime weights under the same truncation rule. The exporter’s weights sum to 1, but if the runtime applies a different truncation, the effective sum is no longer 1 and vertices contract slightly toward the origin. The deviation is small and usually presents as “the model looks slightly thinner in the engine than in the editor,” with no error anywhere.
Euclidean distance leaks weights across gaps
The initial weights above came from lateral distance to the joint — a one-dimensional simplification for a single joint. Generalise it to a whole character by generating weights from “Euclidean distance from vertex to bone” and you hit a characteristic failure.
With the character’s arms hanging naturally at their sides, the upper-arm bone may sit only a dozen pixels from the torso. By Euclidean distance, the vertices on the side of the torso are very close to that bone and receive substantial weight. The result is that raising the arm drags a patch of torso up with it, as though the clothing were stuck to the hand.
The diagnostic signature is unmistakable: deformation influence has crossed a gap that should not connect. It is invisible in the bind pose and only appears in poses that separate the two parts.
The underlying reason is that Euclidean distance does not understand that these are separate pieces. The correct metric travels along the model itself — geodesic distance, the shortest path across the mesh surface from vertex to bone. A torso vertex has to go around the shoulder to reach the upper-arm bone, so its geodesic distance is large and its weight is correspondingly small.
Full geodesic computation is heavy, so practice usually approximates it with heat diffusion: treat the bone as a heat source, solve one diffusion step over the mesh, and use the resulting temperature field directly as weights. It is non-negative, continuous and decays with distance by construction, and because heat can only travel along the mesh, it never crosses a gap.
If the tool chain supports none of this, there is a crude but effective fallback: restrict each bone to a declared set of parts, so only vertices belonging to that part and its neighbours may be influenced. That substitutes manual annotation for a geometric metric — inelegant, but it closes the leak completely.
7. Turn seam quality into measurable checks
Whether a fringe is visible depends on background color, scale, and antialiasing. Save the bind pose and several extreme poses, then inspect geometry, weights, and pixel edges separately.
| Check | Operation | Pass condition | Inspect first on failure |
|---|---|---|---|
| Bind-pose identity | Run the full skinning path with bind matrices | Maximum vertex displacement below 1e-6 | Inverse bind matrix and multiplication order |
| Weight normalization | Compute sum(w_j) per vertex | Error from 1 below 1e-5 | Uninitialized weights, import precision, missing influence |
| Neighborhood continuity | Compare adjacent weights and deformed edge lengths | No isolated discontinuity in the joint region | Automatic-weight radius and hard-edge labels |
| Pixel edge | Render over black, white, and red at 1x, 2x, and 4x | No fixed-color bright or dark fringe | Transparent RGB, premultiplied alpha, texture dilation |
| Extreme pose | Render the maximum allowed bend | No intersection and acceptable volume loss | Corrective shape, helper bone, blend width |
8. The critical loop in a CPU reference
for vertex in mesh:
out = (0, 0, 0)
weight_sum = 0
for influence in vertex.influences:
w = max(influence.weight, 0)
out += w * bone_world[influence.bone]
* inverse_bind[influence.bone]
* vertex.bind_position
weight_sum += w
vertex.position = out / max(weight_sum, epsilon)
A production engine runs the same logic in parallel on the GPU and limits influences per vertex. Renormalize after keeping the top four weights; otherwise an exported sum of 1 becomes a smaller runtime sum and shrinks the vertex. A small CPU implementation is also a useful numerical baseline for checking shader output.
9. Next: make the pose change naturally over time
Bones and skinning solve where geometry is in space. Keyframe curves solve when it arrives. Continue with Keyframes, Interpolation, and Easing. For the pose solver, review Two-Bone Inverse Kinematics.