Texture a Studio-built model without breaking the animation
You applied Decals to every face of your Studio-built character and the pattern came out squashed — then it vanished entirely the moment the model became a skinned mesh. Move the model to a MeshPart with a single 1024x1024 atlas and a SurfaceAppearance, so the texture follows the UV instead of a bounding box that no longer exists.
AI-assisted draft, reviewed, tested and edited by a human before publishing. See our Editorial Policy. · Reviewed by Gustavo Cantino, 9/20/26 Editorial Policy

Texture on a model built inside Roblox comes down to exactly three tools — Decal, Texture and SurfaceAppearance — and the choice between them decides whether the texture stays glued on once the model becomes an animated rig. Decal stretches an image until it covers the entire face of the part. Texture repeats the same image every X studs, and SurfaceAppearance is the only one that delivers real PBR (color, normal, roughness, metalness), except that it requires a MeshPart — which means running the model through a UV unwrap first.
If your model is a pile of Parts assembled in Studio and is never going to be animated, Texture solves it in five minutes. If it is a character, a weapon in the character's hand, or anything an Animator is going to move, the road is a different one: export, open the UV, come back as a MeshPart. That road is what the rest of this text walks.
180 Decals on the rig and the texture squashed on the first animation
The typical scenario: a character assembled from about 30 Parts in Studio, and you applied a Decal to the visible faces of each one. Six faces per part, on the full count, gets you 180 Decals. That is 180 extra instances per character, replicated from the server to the client. With 20 characters on a server, that is 3,600 objects existing for no reason other than holding an image.
The visual problem shows up before the performance problem. Decal does not respect aspect ratio: it stretches the image until the entire face is filled. A square 512x512 image on a 1x4x1 part turns into a pattern squashed at 4:1. Since every body piece has a different size, the same fabric shows up at five different scales on the same character, and that gets obvious as soon as the camera comes close.
-- wrong: six instances per part, and none of them respects aspect ratio
for _, face in Enum.NormalId:GetEnumItems() do
local d = Instance.new("Decal")
d.Face = face
d.Texture = "rbxassetid://0"
d.Parent = part
endThe expensive damage comes later. The day you take that character into Blender to do the skinning with bones — which is what gives you smooth deformation at the joints instead of the broken seams of Motor6D — the Parts stop existing. It all becomes a single mesh. Every Decal goes with them and no export will save you: a Decal carries no UV, it is a projection onto the face of a box you just deleted.
And there is the detail that catches anyone who has already worked with MeshPart: a Decal inside a MeshPart does not follow the mesh UV, it projects onto the face of the bounding box. On a sphere or a curved arm, the result is the image running down the sides.
From the Parts model to the MeshPart with SurfaceAppearance
1. Pick the tool before you upload any image. A pattern that repeats across a flat surface (floor, wall, wood, grating) goes in a Texture, with StudsPerTileU and StudsPerTileV equal to the real size of the pattern in studs — that way the scale is the same on every piece, no matter how big each one is. Signs, posters and billboards go in a Decal. Characters, handheld props and anything that animates go through the MeshPart flow.
2. Export the model. Select the Model in the Explorer, File → Export Selection, save as .obj. Studio exports the geometry in the positions it currently sits in, so the fit between the pieces survives into Blender.
3. Open the UV in Blender. Import the .obj, go into Edit Mode, select everything with A and use U → Smart UV Project. Raise the Island Margin to something between 0.02 and 0.03: without that border, the mipmap blends pixels from neighboring islands and you get flickering seams as the camera pulls away. Then run UV → Pack Islands to make use of the space.
4. Paint or bake into a single atlas. Create a new 1024x1024 Image Texture and either paint it in Texture Paint or bake the procedural material (Bake Type: Diffuse, with Direct and Indirect unchecked — you want the color, not Blender's lighting baked into the texture). The goal is one single image covering the whole model.
5. Export at 1024x1024 and stop there. Roblox resamples any image uploaded above 1024x1024 down to 1024x1024. Uploading a 4096 does not give you more detail, it only gives you a slower upload and the false impression that the sharpness you see in Blender is going to reach the game. A 1024x1024 RGBA takes 4,194,304 bytes uncompressed in video memory, plus roughly 33% more for mipmaps — which is exactly why a single atlas beats a pile of small images scattered around.
6. Go back to Studio. Import 3D to bring in the .fbx or .obj (it creates the MeshPart), and Asset Manager → Images to upload the texture and get the assetId. Uploading an image does not cost Robux, but it goes through moderation before it renders for other players.
7. Set up the SurfaceAppearance. Insert a SurfaceAppearance as a child of the MeshPart and fill ColorMap with the image id. NormalMap, RoughnessMap and MetalnessMap are optional, but all of them have to share exactly the same UV as the ColorMap — if you reopen the UV after making the normal, all three fall out of sync. AlphaMode set to Overlay blends the image with the part color (great for team variants without uploading a new texture); Transparency uses the image's alpha channel.
8. Close the loop with the animation. Because the UV lives in the mesh, the texture follows the deformation: whether it is a Motor6D rig with one piece per MeshPart or a skinned mesh with bones. That is the real payoff of leaving Decal behind — the rig can deform all it wants and the artwork stays where it belongs.
For an animated prop there is a bonus that only Texture gives you: scrolling the image instead of moving geometry. Conveyor belts, drive belts, waterfalls and smoke pipes come for free this way.
local texture = script.Parent:FindFirstChildOfClass("Texture")
task.spawn(function()
while texture do
texture.OffsetStudsV -= 0.05
task.wait(1 / 30)
end
end)The ModuleScript that audits the model and swaps the whole skin
The artifact below is a ModuleScript called SkinApplier, meant to live in ServerScriptService. It does two things.
SkinApplier.Audit(model) walks the model and prints how many Texture, how many Decal, how many MeshPart and how many other BaseParts exist inside it, on top of warning you about any SurfaceAppearance hanging off something that is not a MeshPart. That is the number you need to see before and after the migration: going from 180 Decals to 8 MeshParts with SurfaceAppearance is a metric, not a feeling.
SkinApplier.Apply(model, skin) takes a table mapping the Name of each MeshPart to its set of PBR maps, creates the SurfaceAppearance if it does not exist yet, fills ColorMap, NormalMap, RoughnessMap, MetalnessMap and AlphaMode, and returns how many pieces got applied. Since it runs at runtime, it works for team skins, gamepass skins or NPC variants without duplicating the model in ReplicatedStorage.
The thing to watch is the Name: the table key has to match the Name of the MeshPart inside the model. If Import 3D renamed your pieces to Mesh, Mesh1, Mesh2, rename them in the Explorer first — otherwise Apply returns zero and you are going to blame the asset id.
The second artifact is skins.json: keep the list of ids out of your head and under version control, because an asset id swapped by mistake is the most annoying bug to track down (the texture simply does not show up, with no error in the Output).
The expensive mistake: texturing before deciding the final topology
The mistake that costs you the whole afternoon is not picking Decal instead of SurfaceAppearance. It is texturing before deciding whether that model is going to end up as separate pieces or as a single mesh. Every texture you apply to a Part dies in two common operations: Union and skinning. When you union parts, the Decals and Textures of the original pieces do not survive in the UnionOperation. When you export to Blender and merge everything into one mesh with bones, same story.
The practical rule: decide first whether the model is going to animate. If it is, it ends up as a MeshPart, period. Only texture it after the mesh is closed and the UV is done.
If you are already in the hole, the way out is not rebuilding the model — it is running Audit, writing down which images you have already uploaded (they are still valid, they just need to be repositioned in an atlas) and taking the .obj into Blender, laying out the UV with the islands in the same proportions as the old faces. You reuse the art, not the placement.
Two smaller mistakes that show up on the same day: a SurfaceAppearance placed inside a Part or a WedgePart appears in the Explorer and renders nothing at all (that is why Audit warns you); and relief that looks inverted, with the ridges sinking in instead of popping out, is the normal map's green channel in the wrong convention — flip the green on export and compare side by side before uploading again. Always test with the Lighting Technology the game actually uses: what you tuned under Future can flatten out considerably under another technology.
What to measure next
Open the Developer Console in the published game, Memory tab, and look at the texture graphics memory line before and after the migration — that is where the single atlas earns its keep, especially on mobile. Run Audit on your three most-used models and write down the instance count: if a character still goes over 50 texture objects, there is an atlas left to build.
Then time the moderation. Upload a test image, note the clock and see how long it takes until it renders in a published session — that number decides whether you can upload a texture the night before the update or whether you need slack. And keep skins.json in the repository next to the .blend: the day moderation rejects an image, you swap one id in one file instead of hunting for SurfaceAppearance in the Explorer.
Ready-to-use artifact
{
"skinId": "guarda_real_v3",
"atlas": "1024x1024",
"source": "guarda_real.blend / UV: Smart UV Project, island margin 0.03",
"parts": {
"Torso": {
"ColorMap": "rbxassetid://000000001",
"NormalMap": "rbxassetid://000000002",
"RoughnessMap": "rbxassetid://000000003",
"AlphaMode": "Overlay"
},
"Cabeca": {
"ColorMap": "rbxassetid://000000004",
"AlphaMode": "Transparency"
},
"BracoDireito": {
"ColorMap": "rbxassetid://000000005",
"NormalMap": "rbxassetid://000000002",
"AlphaMode": "Overlay"
}
}
}--!strict
-- SkinApplier — ModuleScript in ServerScriptService
--
-- Usage, from a Script in ServerScriptService:
-- local SkinApplier = require(game:GetService("ServerScriptService").SkinApplier)
-- SkinApplier.Audit(workspace.Boneco)
-- print(SkinApplier.Apply(workspace.Boneco, SkinApplier.Skins.GuardaReal), "textured pieces")
local SkinApplier = {}
-- A set of PBR maps. They all need to share the SAME UV as the ColorMap.
export type MapSet = {
ColorMap: string,
NormalMap: string?,
RoughnessMap: string?,
MetalnessMap: string?,
AlphaMode: Enum.AlphaMode?,
}
-- The key is the Name of the MeshPart inside the model.
export type Skin = { [string]: MapSet }
SkinApplier.Skins = {
GuardaReal = {
Torso = {
ColorMap = "rbxassetid://000000001",
NormalMap = "rbxassetid://000000002",
RoughnessMap = "rbxassetid://000000003",
},
Cabeca = {
ColorMap = "rbxassetid://000000004",
AlphaMode = Enum.AlphaMode.Transparency,
},
} :: Skin,
}
-- Creates the SurfaceAppearance if it doesn't exist yet. SurfaceAppearance only renders inside a MeshPart.
local function ensureSurfaceAppearance(mesh: MeshPart): SurfaceAppearance
local existing = mesh:FindFirstChildOfClass("SurfaceAppearance")
if existing then
return existing :: SurfaceAppearance
end
local sa = Instance.new("SurfaceAppearance")
sa.Name = "Skin"
sa.Parent = mesh
return sa
end
-- Counts what textures exist in the model and warns about what is in the wrong place.
function SkinApplier.Audit(model: Model): ()
local textures, decals, meshParts, outrasPartes, orfaos = 0, 0, 0, 0, 0
for _, inst in model:GetDescendants() do
if inst:IsA("Texture") then
-- Texture inherits from Decal, so it comes first in the check.
textures += 1
elseif inst:IsA("Decal") then
decals += 1
elseif inst:IsA("SurfaceAppearance") then
local pai = inst.Parent
if not (pai and pai:IsA("MeshPart")) then
orfaos += 1
warn(string.format("[SkinApplier] SurfaceAppearance outside of a MeshPart at %s — it will not render.", inst:GetFullName()))
end
elseif inst:IsA("MeshPart") then
meshParts += 1
elseif inst:IsA("BasePart") then
outrasPartes += 1
end
end
print(string.format(
"[SkinApplier] %s — MeshParts: %d | other BaseParts: %d | Textures: %d | Decals: %d | orphan SurfaceAppearance: %d",
model.Name, meshParts, outrasPartes, textures, decals, orfaos
))
end
-- Applies the skin. Returns how many MeshParts were affected (zero = no name matched).
function SkinApplier.Apply(model: Model, skin: Skin): number
local aplicados = 0
for _, inst in model:GetDescendants() do
if inst:IsA("MeshPart") then
local set = skin[inst.Name]
if set then
local sa = ensureSurfaceAppearance(inst)
-- SurfaceAppearance overrides the MeshPart's TextureID; there's no need to clear anything.
sa.ColorMap = set.ColorMap
sa.NormalMap = set.NormalMap or ""
sa.RoughnessMap = set.RoughnessMap or ""
sa.MetalnessMap = set.MetalnessMap or ""
sa.AlphaMode = set.AlphaMode or Enum.AlphaMode.Overlay
aplicados += 1
end
end
end
if aplicados == 0 then
warn(string.format("[SkinApplier] No MeshPart of %s matched the skin keys. Check the Names in the Explorer.", model.Name))
end
return aplicados
end
return SkinApplier

