Where the holes in generated meshes come from
By Oleg Sidorkin, CTO and Co-Founder of Cinevva

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.

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.
| attempt | result |
|---|---|
| resolution 1536 instead of 1024 | worse on three of five test models. More resolution resolves more thin contacts, each one a new junction |
| the built-in cleanup ladder | more non-manifold edges, not fewer |
| filling every small hole with MeshLib | 266k loops filled, wing root unchanged. The skin there was never emitted, so there's no loop to fill |
| a signed remesh from a winding number | shatters the mesh into thousands of shells. The raw output has no interior to sign |
| a wider contouring band, 2 and 3 voxels | fewer tunnels overall, but the slit gets wider, not narrower. Small tunnels merge into big ones |
| morphological closing of the voxel structure | solidifies the shell, the decoder then emits surface inside every interior voxel, 4x the faces, 969 tunnels |
| global voxel remesh of the final mesh | genus 0 and the slit sealed. Also every feather edge softened. Rejected on sight, correctly |
| biasing the decoder's crossing threshold | open edges 65k to 58k. Noise |
| completing missing neighbour voxels | 560 cases in the whole bird. Noise |

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.

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.

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.

| tunnels | non-manifold edges | wing-root flap | hole on the back | |
|---|---|---|---|---|
| as is | 79.5 | 318 | present | present |
| fill, radius 3 | 84.5 | 97 | gone | gone |
| fill, radius 4 | 72 | 38 | gone | gone |
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.

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.