Unions have awful performance // Pure DATA to demonstrate the not-so-greatness of CSG

This post is being made because I keep seeing developers make the assumption that just because Unions decrease instance count, that their performance is improved.

Unions do not improve performance, and in fact, hurt performance. Only rare edge cases have been acceptable:

  • If you need to have destruction that is extremely accurate, CSG may be required. If it does not need to be accurate, use Blender destruction simulation for best results.
  • If you absolutely require perfectly sharp edges without texture compression, a union may be solution for this (thanks to PoptartNoahh for making me aware of this).

Otherwise, it’s universally agreed among most developers that unions are awful.

Some problems with unions:

  • Active geometry calculation (based on CSG binary trees)
  • Inaccurate triangulation
  • Unoptimized calculations – multiple vertices will exist in one location, creating shading errors and extremely high triangle count at times.

If you want to see the issues with triangulation off the bat, then take a look at this video I made long ago. If you don’t want to watch it, then you can just see this screenshot that shows a 6-sided cube that has an awful wireframe along the edges between the spheres.

That aside, let’s talk about the geometry calculation. I’ll cut to the chase here.

This is some data showing geometry calculation for a Part versus a Union. You can see the total, 10th percentile, 50th percentile, 90th percentile, min, max, and average frame times are all lower for the part.

Here is the .bench file.
local function GeometryCalculationTesting(AffectedInstance, OriginCFrame)
    AffectedInstance.CFrame = OriginCFrame * CFrame.new(math.random(), math.random(), math.random()) * CFrame.fromEulerAnglesXYZ(math.random(), math.random(), math.random())
    
    AffectedInstance.CFrame = OriginCFrame -- Reset CFrame
end

return {
    ParameterGenerator = function()
        local Instances = {
            Union = workspace.Union,
            Part = workspace.Part
        }
        
        local OriginCFrames = {
            Union = Instances.Union.CFrame,
            Part = Instances.Part.CFrame
        }
        
        return {
            Instances,
            OriginCFrames,
        }
    end;

    Functions = {
        ["Part CFrame"] = function(Profiler, Data) -- You can change "Sample A" to a descriptive name for your function
            GeometryCalculationTesting(Data[1].Part, Data[2].Part)
        end;

        ["Union CFrame"] = function(Profiler, Data)
            GeometryCalculationTesting(Data[1].Union, Data[2].Union)
        end;
    };

}

Out of this data, the 50th percentile is the most important, as it shows the most accurate test results. At the 50th percentile, the frame time to CFrame a Union is 34.8 us, while for a part is 24.5 us.

On top of this, the union that was used is the exact same part, with the only difference being that I clicked “union” on it. They have the same size and rotation, as well as the same exact 12-triangle wireframe (red = part, blue = union).

image

Hopefully this simple but powerful bit of data helps settle any future disagreements.

22 Likes