How Do Games Swap Low-Poly Placeholders to Detailed Models Based on Distance?

Hello there,

Roblox has recently launched a platform event called “The Hatch” where there are one hundred eggs you must collect across different biomes from different maps.

However, while exploring the environment, I noticed that the more you get far away from specific areas, some props get swapped by simple Roblox BaseParts. If you get close to them, it fades back to the 3D asset. You can see this example if you’re in “Shifting Shores” in the Sand biome: you’ll see that the cactus changes to its cool-looking design if you get near.

I wonder how the developers made this happen. I know that they already use built resources from their creations and popular creators. The only resource I could think of from the top of my head was ZoneManager, but unfortunately, it’s not closed or behaves the same way.

Does anyone have any idea of how you can achieve something closely? In this way, it’ll help make projects and ensure that their performance runs smoothly.

Thanks!

1 Like

Im confident that they are using this technology.

1 Like

I figured something out, I doubt its optimized but it does work, here is the script (local in SCS)

local fadeModels = game.Workspace:WaitForChild("DistanceDepended")
local char = script.Parent
local fadeDistance = 50
local fadeDuration = 1

local activeFades = {}

local TS = game:GetService('TweenService')

task.wait(1)

function fadeModel(model : Model, direction)

	if activeFades[model] == nil then
		activeFades[model] = {}
	end
	
	if activeFades[model][1] ~= nil then
		return
	end
	
	local fadeInModel
	local fadeOutModel
	
	if direction == "In" then
		fadeInModel = model:FindFirstChild("High")
		fadeOutModel = model:FindFirstChild("Low")
	else
		fadeInModel = model:FindFirstChild("Low")
		fadeOutModel = model:FindFirstChild("High")
	end
	
	for i, v in pairs(fadeInModel:GetDescendants()) do
		if v:IsA("BasePart") then
			
			local fadeTween = TS:Create(v, TweenInfo.new(fadeDuration), {Transparency = 0})
			table.insert(activeFades[model], fadeTween)
			fadeTween:Play()
			
			task.spawn(function()
				fadeTween.Completed:Once(function()
					table.remove(activeFades[model], table.find(activeFades[model], fadeTween))
				end)
			end)
		end
	end
	
	for i, v in pairs(fadeOutModel:GetDescendants()) do
		if v:IsA("BasePart") then

			local fadeTween = TS:Create(v, TweenInfo.new(fadeDuration), {Transparency = 1})
			table.insert(activeFades[model], fadeTween)
			fadeTween:Play()
			task.spawn(function()
				fadeTween.Completed:Once(function()
					table.remove(activeFades[model], table.find(activeFades[model], fadeTween))
				end)
			end)
		end
	end
end

for i, model : Model in pairs(fadeModels:GetChildren()) do
	activeFades[model] = {}
end

task.wait(1)

function checks()
	for i, model: Model in pairs(fadeModels:GetChildren()) do
		local distanceFromPlr = (model.PrimaryPart.Position - char.HumanoidRootPart.Position).Magnitude
		if distanceFromPlr >= fadeDistance then
			fadeModel(model, "Out")
		else
			fadeModel(model, "In")
		end
	end
end

checks()


game["Run Service"]:BindToRenderStep("Fading", 1 ,checks)

Demo video: Download 2025-07-16 21-36-02.mp4 | LimeWire

The folder looks like this:

Each model must have a primary part and the High sub model under it must also have a primary part.

1 Like

It is already automatically done.

If you’re using a bootstrapper, such as Bloxstrap or Fishstrap, you can use these FFlags to change how far each LOD is culled.

  "DFIntCSGLevelOfDetailSwitchingDistance": "40",
  "DFIntCSGLevelOfDetailSwitchingDistanceL12": "60",
  "DFIntCSGLevelOfDetailSwitchingDistanceL23": "90",
  "DFIntCSGLevelOfDetailSwitchingDistanceL34": "130",

Distance is in studs, and I’ll also say that these are NOT the default values.

LODs are automatically generated though, so if roblox deems a model “simple enough”, it seemingly won’t reduce its vert amount much.

Both systems have advantages and disadvantages.

  • Custom written ones allow you to specify a super specific amount of detail for each LOD, even going down to 8 verts if you’d want to, however come with performance impacts for the code running, which I could only imagine would get heavier the more instances you’re updating.

  • The default system has no performance impacts from running and is pretty good all things considered, however its “minimum” allowed LOD still has quite a few verts, use the same full resolution textures, and may not even create LODs for some models if they’re already deemed “basic” enough by some arbitrary variable.


By default, roblox doesn’t fade LODs, they just pop in and out, however their default culling distance is more than enough to barely notice unless you’re looking out for it.

I’d personally just stick to roblox’s system, its the least jank and works well if you know how to optimize your own models well (like using normal maps in place of modeled detail), plus in cases it’s a lot less noticable. The transition states between lower detail models and higher details ones shouldn’t be all that noticeable, and that’s something the default system does well.

There’s nothing less immersive than being able to watch a 4 faced green cube turn into a 40k polygon bush in real time while its only 10 meters away from you.

Heya!

I followed the steps, but it ended up not working because the Positions were given as nil… So, as a test, I tried using AI to enhance it, and it told me to do some things:

AI Response
  1. Initial Issues:
  • Your script threw errors indexing nil values.
  • The fade system was fading the wrong models or parts.
  • You weren’t sure which part to use as the model’s PrimaryPart.
  • The high-detail parts were invisible by default, and low-detail parts never changed.
  1. Key Fixes and Improvements:
  • Added an invisible, anchored “Anchor” part as PrimaryPart for each model so distance calculation is stable and consistent.
  • Set initial transparency: Low parts visible, High parts invisible.
  • Fixed fade logic so High fades in near the player, Low fades out, and vice versa when far.
  • Added debug suggestions to verify model structure and script execution.
  • Adapted the full system to your exact folder structure with High and Low folders inside each model.
  • Explained the difference between server and client (StarterCharacterScripts) context: Anchor creation must be done on the server; fading logic can run locally on each player’s character.
  1. Final Deliverables:
  • A server script to create Anchors and assign PrimaryParts on all relevant models in Workspace.
  • A StarterCharacterScripts client script to run the fading logic per player smoothly and efficiently.

StarterCharacterScripts (as a LocalScript):

local fadeModels = workspace:WaitForChild("DistanceDepended")
local TweenService = game:GetService("TweenService")
local RunService = game:GetService("RunService")

local fadeDistance = 50
local fadeDuration = 1

local activeFades = {}

local char = script.Parent
local hrp = char:WaitForChild("HumanoidRootPart", 5)
if not hrp then
	error("HumanoidRootPart not found")
end

-- Initialize active fades for each model
for _, model in pairs(fadeModels:GetChildren()) do
	if model:IsA("Model") then
		activeFades[model] = {}

		-- Set initial visibility assuming server set PrimaryPart and initial transparency
		local low = model:FindFirstChild("Low")
		local high = model:FindFirstChild("High")

		if low then
			for _, part in pairs(low:GetDescendants()) do
				if part:IsA("BasePart") then
					part.Transparency = 0
				end
			end
		end

		if high then
			for _, part in pairs(high:GetDescendants()) do
				if part:IsA("BasePart") then
					part.Transparency = 1
				end
			end
		end
	end
end

local function fadeModel(model, direction)
	if activeFades[model] == nil then
		activeFades[model] = {}
	end

	if #activeFades[model] > 0 then return end

	local showModel = model:FindFirstChild(direction == "In" and "High" or "Low")
	local hideModel = model:FindFirstChild(direction == "In" and "Low" or "High")

	if not showModel or not hideModel then
		warn("Missing High/Low in model:", model.Name)
		return
	end

	for _, part in pairs(showModel:GetDescendants()) do
		if part:IsA("BasePart") then
			local tween = TweenService:Create(part, TweenInfo.new(fadeDuration), {Transparency = 0})
			table.insert(activeFades[model], tween)
			tween:Play()
			task.spawn(function()
				tween.Completed:Once(function()
					table.remove(activeFades[model], table.find(activeFades[model], tween))
				end)
			end)
		end
	end

	for _, part in pairs(hideModel:GetDescendants()) do
		if part:IsA("BasePart") then
			local tween = TweenService:Create(part, TweenInfo.new(fadeDuration), {Transparency = 1})
			table.insert(activeFades[model], tween)
			tween:Play()
			task.spawn(function()
				tween.Completed:Once(function()
					table.remove(activeFades[model], table.find(activeFades[model], tween))
				end)
			end)
		end
	end
end

local function checks()
	for _, model in pairs(fadeModels:GetChildren()) do
		if not model.PrimaryPart then continue end

		local distance = (model.PrimaryPart.Position - hrp.Position).Magnitude
		if distance >= fadeDistance then
			fadeModel(model, "Out")
		else
			fadeModel(model, "In")
		end
	end
end

task.delay(2, function()
	checks()
	RunService:BindToRenderStep("LOD_Fade", Enum.RenderPriority.Last.Value, checks)
end)

ServerScriptService:

local fadeModels = workspace:WaitForChild("DistanceDepended")

for _, model in pairs(fadeModels:GetChildren()) do
	if model:IsA("Model") then
		-- Remove existing PrimaryPart and Anchor
		model.PrimaryPart = nil
		local oldAnchor = model:FindFirstChild("Anchor")
		if oldAnchor then oldAnchor:Destroy() end

		-- Create invisible Anchor part
		local anchor = Instance.new("Part")
		anchor.Name = "Anchor"
		anchor.Size = Vector3.new(1,1,1)
		anchor.Anchored = true
		anchor.CanCollide = false
		anchor.Transparency = 1
		anchor.Position = model:GetModelCFrame().Position
		anchor.Parent = model

		model.PrimaryPart = anchor
	end
end

(Keeping in mind, I still followed the folder structure and organization from you, @talis783)


It did manage to make what I wanted, but now, I’m starting to wonder from other perspectives if this can be optimized… :wut:

Still, thanks for the help and to everyone else so far in this thread! :tongue:

It’s possible the code ran before everything was loaded, or you forgot to set primary parts. For me I never had to use server scripts. I can send the place file if you want it

Is there a built in way for implementing this into a game? Bloxstrap isn’t for Roblox studio…

There’s bootstrappers for studio, not sure if the FFlags would remain the same though.

As I said, its already implemented in the engine, the values are just high enough to where you don’t really see it happening unless you’re squinting, the FFlags just modify the distance of which each LOD is culled, they don’t toggle whether the LOD culling happens or not unless if you set the values insanely high.

The LOD culling is most obvious on tree models IMO, here’s a tree model I’m using when I’m close to it:


And then the same model from a distance:
image
Its not very noticable, but the shape is certainly less defined.

Edit 3: Just went digging through old images, here’s a screenshot of me using those FFlags which shows the different LOD models:

1 Like