Skip to content

Where the holes in generated meshes come from ​

By Oleg Sidorkin, CTO and Co-Founder of Cinevva

The wing root of a generated bird before and after the fix: a grey flap with bright green slivers on the left, a clean wing-to-body fold on the right
Same image, same generator, same settings. Left is what shipped until this week. Right is what ships now.

I generated a bird with TRELLIS.2, rigged it, and the moment an animation played there were holes under both wings. Bright slivers where you could see straight through the body. The rigger got the blame first, and it did have its own problem, but the holes were already in the mesh before any bone touched it. This post is the investigation that followed: what the holes actually are, the nine things that didn't fix them, why, and the one thing that did. I'm including the dead ends on purpose. Most of them are the obvious things you'd try, and each one taught us something the eventual fix depended on.

Step one: agree on what a hole is ​

Our mesh checker reported zero boundary edges, and a ray-parity test said the surface was closed. So I told the team the mesh was clean. It wasn't, and the person who'd marked the slivers in red was right to push back. A mesh can be closed and still have tunnels. The one measure that catches them is the Euler characteristic, V minus E plus F, which gives the number of handles per shell. The bird had 69 of them. Every wall in the export was doubled, so a slit in the outer wall connecting to the cavity behind it is a handle, not an opening, and nothing that looks for open edges will ever see it.

Second lesson, more embarrassing: I spent a while probing dark pixels for holes, because holes are dark, until I was told to look at the picture first. The holes were bright. They were slivers of the far wall's green texture showing through a grey fold. An algorithm you haven't pointed at the right thing tells you nothing, and the cheapest way to point it is to look.

Step two: find the stage that makes them ​

TRELLIS.2 builds a mesh in three steps. A decoder emits a raw surface at 1024 cubed resolution. A narrow-band dual contouring pass remeshes it. A decimator brings it down to a hundred thousand faces and the texture is baked. We dumped the geometry after each step and shot the same bundle of rays through the wing root each time.

The raw surface is where it starts. It's a soup of open sheets: 266,242 boundary loops, most of them single missing quads, and 65,371 non-manifold edges. At the wing root a third of the test rays went straight through. The hole-fill that runs next closes almost nothing, 144 faces out of five million. The remesher works on an unsigned distance field, because it has to. It has no inside or outside to work with, so it wraps a thin offset shell around every sheet. That's why every wall is doubled, about four voxels apart, and that's how each porous stripe becomes a fold with a slit in it. Decimation then makes it worse and does so randomly: the same raw mesh decimated twice gave 69 and 107 tunnels.

The bird with a heat map of open-edge density: the neck collar, both wing roots, the hips and the tail feather tips glow, the body and head are clean
Density of open edges in the raw extraction. It lights up only where parts meet: neck collar, wing roots, hips, tail tips. The body and head are clean.

That map was the first real clue. The porosity isn't random. It sits at every junction between parts and nowhere else.

The things that didn't work ​

We tried them roughly in order of how obvious they were.

attemptresult
resolution 1536 instead of 1024worse on three of five test models. More resolution resolves more thin contacts, each one a new junction
the built-in cleanup laddermore non-manifold edges, not fewer
filling every small hole with MeshLib266k loops filled, wing root unchanged. The skin there was never emitted, so there's no loop to fill
a signed remesh from a winding numbershatters the mesh into thousands of shells. The raw output has no interior to sign
a wider contouring band, 2 and 3 voxelsfewer tunnels overall, but the slit gets wider, not narrower. Small tunnels merge into big ones
morphological closing of the voxel structuresolidifies the shell, the decoder then emits surface inside every interior voxel, 4x the faces, 969 tunnels
global voxel remesh of the final meshgenus 0 and the slit sealed. Also every feather edge softened. Rejected on sight, correctly
biasing the decoder's crossing thresholdopen edges 65k to 58k. Noise
completing missing neighbour voxels560 cases in the whole bird. Noise
Four columns: the original bird and three voxel-remeshed versions at increasing radius, wing root, ten times zoom, a cross-section slab, the whole bird and a view from below
The global closing. Column three seals the slit and keeps the toes apart, and it also rounds off every crease on the bird. This is what "blurry" looks like in geometry.

The global closing is worth dwelling on, because it's what a modeler would reach for first: hit voxel remesh at a size just above the gap. It works, topologically. It's also a lie about the model. The generator produced crisp feather edges and we'd be trading them for a sealed wing root. Two of the nine attempts got the tunnel count down and both were rejected the moment anyone looked at the bird instead of the number.

Reading the code ​

At that point the only honest move was to stop treating the decoder as a box and read how its output becomes a mesh. TRELLIS.2 uses a representation called O-Voxel, and the extraction is short. Each active voxel holds one dual vertex and three logits, one per grid edge leaving its corner, each meaning "the surface crosses this edge." A logit above zero emits a quad connecting the four voxels around that edge. If any of the four is missing, the quad is dropped silently. That's the whole thing. There is no sign field, no occupancy, no inside and outside anywhere in the model.

We patched the decoder to dump those logits before the threshold, on the same bird, and counted.

Missing neighbours: 560 out of 2.7 million voxels. Logits sitting near zero: 8,600 out of 8 million. Neither is the problem. The problem shows up when you look at the four edges around each grid face. A closed surface crosses a face's boundary an even number of times. On 126,542 faces, 2.3% of them, the four flags disagree, and every odd face is an open or non-manifold edge by definition. And they disagree confidently. The lone flag on a count-one face sits at logit +10 while its neighbours sit at -4. The median cost to flip one flag and restore parity is 2.3 logits. This isn't a threshold that's slightly off. The model is sure of a surface that can't close.

Three columns on a dark background: the exported bird, the decoder's raw output, and a sign-repaired version. At ten and thirty times zoom the wing junction is a shredded serrated stripe in both raw versions
The decoder's raw output at the wing root, dark means see-through. The junction isn't missing quads. It's a comb.

Then we measured the stripe itself, at the exact surface point our fixed camera had been staring at all week. Within twelve voxels of it there are 2.1 times as many active voxels as a single flat sheet would need. On the chest, for comparison, 1.2. The wing's underside and the body are two skins running inside one voxel layer. The representation has one vertex per voxel, so it cannot hold both. During training, the ground truth for such a voxel is a least-squares average of both skins with "any crossing" flags, which is itself inconsistent, so the model has faithfully learned to emit a comb there. The remesher wraps the comb into a slab, and the slab has tunnels.

The width of the stripe follows from the geometry: roughly one voxel divided by the tangent of the contact angle. Two surfaces meeting at ninety degrees give a one-voxel crease and no trouble. A wing lying flat against a body, feather on feather, toes on the ground, all of those give a stripe ten or fifteen voxels wide. That's the porosity map, explained.

I did try the principled repair, inferring a per-corner inside/outside sign that best explains the flags, then re-deriving them. It can't work. The sign field only exists on the one-voxel shell the decoder emitted, and any extension off that shell runs away. Twelve rounds of it doubled the voxel count with garbage.

What a modeler would do ​

A modeler would never leave two skins a hair apart. They'd union the wing into the body and let there be a crease. The global closing does that everywhere, which is the problem. So do it only where the two skins are.

The detector is one line of thought. Count each voxel's active neighbours within four voxels. On a single sheet that number is the same everywhere, and it's the median. Where two sheets share the layer it's about double. Flag anything above 1.5 times the median, grow the flagged set by one voxel, and drop a unit cube at each. The space between the skins becomes solid, the contouring pass sees one wall, and nothing else on the model is touched.

A crop around the wing root contoured three ways: as is, with the two-sheet zone filled at radius three, and at radius four. Red dots mark the filled voxels along the seam. The serrated seam becomes a smooth crease
Proof of concept on a crop, before touching the pipeline. Red marks the filled voxels. The serrated seam becomes a crease and the feathers around it don't move.

We tested it on a crop first, with MeshLib standing in for the GPU contouring. Fifty-three shells and nineteen handles became two shells and five. Then we put it in the real pipeline, between the decoder and the export, and ran the same bird through the real remesher and the real texture bake.

Four columns, textured, then plain geometry, then a cross-section: original, the same decoder output exported as is, and the two-sheet fill at radius three and four. The grey flap and green slivers vanish in the last two
The real pipeline. Columns one and two are the same decoder output with and without a random decimation. Three and four have the fill.
tunnelsnon-manifold edgeswing-root flaphole on the back
as is79.5318presentpresent
fill, radius 384.597gonegone
fill, radius 47238gonegone

The flap is gone. A second hole on the bird's back, at the same junction seen from behind, one I hadn't even been chasing, is gone too. Feathers stay separate, toes stay separate, the texture bakes normally because the cubes sit inside the shell where the attribute volume already has data. It adds six seconds to a four-minute job. It's on by default now, radius four.

The whole bird from the front, the back, from below with texture, and from below as plain geometry, in four columns: original, as is, fill radius three, fill radius four
The whole bird. The white hole on the back in columns one and two is the same junction from behind. Everything the generator got right is still there in columns three and four.

What this doesn't fix, and what it tells you ​

The tunnel count barely moved, and I want to be honest about why. The remaining tunnels are at feather tips and toe tips, where a thin part ends and the doubled wall pinches shut. They're small, they don't show, and they're a different mechanism. The fill targets contacts between parts, which is where the visible holes were.

The real fix is upstream. A decoder that predicts per-corner signs instead of per-edge flags would be parity-consistent by construction, which is how the previous generation of this model worked and why its meshes were watertight. That's a training change, not something we can patch at inference. Until then, two practical things follow from the stripe formula. If you're generating a character, a pose where the limbs stand off the body will mesh cleaner than one where they lie flat against it. And if you're building on top of a generator like this, measure your meshes with the Euler characteristic and look at the result with your own eyes before you trust a number. The number said clean. It was wrong twice.

The instrumented decoder, the stage dumps, the parity analysis and the fill are all in our pipeline now, so the next model that comes out with holes gets the same treatment in minutes rather than a week.