Black Patches and Blocky Shadow on Your Animated Rig
Your animated character shows black patches on some frames, the shadow lags in blocks and a limb flickers near a PointLight — that's three different bugs wearing the same mask. Four 30-second checks tell you whether it's a flipped normal from Blender, the voxel lighting grid or a shadow-casting invisible part, plus a module that audits the rig for you.
AI-assisted draft, reviewed, tested and edited by a human before publishing. See our Editorial Policy. · Reviewed by Gustavo Cantino, 9/15/26 Editorial Policy

If your animated character shows black patches on some frames, if its shadow lags behind and moves in blocks when it walks, or if an arm lights up and goes dark as it passes near a PointLight, you don't have a "weird lighting bug" — you have three different bugs that look alike. The first is a flipped normal coming out of Blender, and it has absolutely nothing to do with the Lighting service. The second is the voxel lighting grid, which resolves light in 4x4x4 stud cells while your rig moves in fractions of a stud per frame. The third is CastShadow left on an invisible part, or a light with Shadows attached to a bone that animates.
The expensive part isn't the bug: it's the time you burn looking in the wrong place. The default path is to open Lighting, tweak Brightness, add a ColorCorrection, swap the Skybox, test Ambient, and two hours later the mesh still has the same black face — because the black face is in the FBX file, not in the scene.
It's not one bug: it's three
Symptom A — a black patch that changes with the camera angle. One part of the mesh (almost always an arm, a leg or a mirrored piece) goes dark as if it were inside a cave. Rotate the camera and the patch moves or disappears. That's a normal pointing inward: the face is being lit from behind. No Brightness value fixes it, because the surface is asking for light from the wrong side.
Symptom B — a shadow that walks in steps. The character moves smoothly, the shadow on the floor jumps in squares and arrives half a second late. That's Lighting.Technology set to Voxel or Compatibility. Voxel resolves lighting on a 4x4x4 stud grid; a default R15 rig is about 5 studs tall, so every step crosses a cell border and the shadow recalculates in a block. In that mode a skinned mesh doesn't even project its real silhouette — it projects a box approximation.
Symptom C — per-frame flicker. A body part (or the weapon in the hand) blinks between lit and dark during the animation. Two common causes: a light with Shadows = true parented to a moving bone, forcing a shadow recalculation every frame; or a part with Transparency = 1 and CastShadow = true — the invisible hitbox throws a box shadow that doesn't follow the deformed mesh and fights with the real one.
All three show up on the same rig often, and that's why it feels "random". It isn't. Each one has a test that isolates it in under a minute.
Diagnosis in four 30-second checks
Check 1 — is it light or is it mesh? With the game stopped in Studio, select Lighting in the Explorer and toggle Technology between Voxel and Future in Properties. Watch the patch. If it changes shape, changes intensity or disappears, the problem is lighting (symptoms B and C). If it stays identical in every mode, the problem is geometry — skip straight to check 2. One detail that saves time: Technology isn't reliable to change by script at runtime, it's a scene property. Change it in the panel, not in code.
-- Don't diagnose like this: the mode won't reliably change at runtime
game:GetService("Lighting").Technology = Enum.Technology.FutureCheck 2 — the flat light test. Leave Lighting like this for 30 seconds: Ambient at 255,255,255, OutdoorAmbient at 255,255,255, Brightness at 0 and GlobalShadows off. In that setup there's no light direction, so nothing can go dark from shadow. If a black face is still there, the normal is flipped or the mesh came in with broken custom split normals. Confirm it by switching the part's Material to SmoothPlastic and its Color to white — if the face stays black, it's the normal, end of story.
The fix is in Blender, in this order: Object Mode, Ctrl+A and apply All Transforms (that's what resolves the negative mirror scale); Edit Mode, A to select everything, Shift+N to recalculate normals outside; in Object Data Properties, remove Custom Split Normals Data if it exists; on FBX export, leave Smoothing on Face, Apply Modifiers on and Add Leaf Bones off. Reimport and redo check 2 before touching anything else.
One warning here: DoubleSided on the MeshPart makes the patch disappear, and that's why a lot of people stop there. It's not a fix, it's a band-aid — you start rendering both faces of every triangle and pay that cost on every copy of the character on the server. Fix the normal and turn it off.
Check 3 — the stepped shadow. If check 1 pointed at lighting and the symptom is a lagging shadow, the target is Technology. ShadowMap and Future use a real shadow map and resolve the silhouette of a skinned mesh; Voxel and Compatibility don't. Future also gives you ShadowSoftness, which doesn't exist in the other modes. Keep ShadowSoftness low in a scene with an animated rig: high values make the shadow edge "boil" during the animation, because the penumbra is recalculated with the body in motion and the noise reads as flicker.
Still in this check, look at EnvironmentDiffuseScale. If it's at 0 in an indoor scene with no direct light, the rig goes nearly black and it looks like a material bug. It isn't: it's the scene with no indirect ambient light.
Check 4 — the flicker. Select the rig and look for two things: parts with Transparency at 1 and CastShadow on (HumanoidRootPart, hitboxes, collision attachments), and light instances with Shadows on inside the animated hierarchy. Turn CastShadow off on the invisible ones — an invisible part shouldn't project anything, and its box shadow is exactly the odd rectangle you see on the floor. For lights attached to a bone, keep Shadows on for at most one per character and lower the Range: a high-range light that moves widens the update pop and the per-frame cost.
The four checks together take less than five minutes. That's the difference between solving it today and spending the afternoon in ColorCorrection.
The rig audit script
The first artifact below is a ModuleScript called LightingDoctor. It runs all of check 4 automatically and also warns you when the scene is in Voxel or has ShadowSoftness too high for an animated rig.
Put it in ServerScriptService with the exact name LightingDoctor. In the Studio Command Bar, with the rig selected in the Explorer:
local D = require(game.ServerScriptService.LightingDoctor)
D.report(game:GetService("Selection"):Get()[1])The output lists each finding with a short code, the full path of the instance and the reason. The codes that show up most: GHOST_SHADOW (invisible part casting a shadow), LIGHT_WITH_SHADOW (animated light recalculating per frame), DOUBLE_SIDED (band-aid for a flipped normal) and TECH_VOXEL (the whole scene in the wrong mode).
When you want to apply the safe fixes — turn CastShadow off on invisible parts and Shadows off on lights inside the rig — switch to D.fix(...), which returns how many properties it changed. It never touches DoubleSided, Material or Technology: turning DoubleSided off without fixing the normal first exposes holes in the mesh, and the other two are art decisions, not audit decisions.
The module is --!strict and depends on nothing but Lighting, so it behaves the same in Studio and at runtime. Running it in the Command Bar before you export the character into the game costs 5 seconds per rig.
The second artifact is the pair of presets the checks lean on: the FBX export settings on the Blender side, and the Lighting values on the Studio side for both the flat light test and the animated rig scene.
The most expensive mistake: mirroring in Blender without applying the scale
The right arm came out black and the left one didn't. You mirrored the mesh with Ctrl+M or put scale -1 on X to build the opposite side and didn't apply the transform before exporting. Negative scale flips the orientation of every triangle in that object — the mesh still looks right in the Blender viewport, because Blender compensates, and it arrives flipped in Studio, because the FBX carries the scale as part of the object.
What makes this expensive isn't the fix, it's the cycle. You reimport, look at it, decide it got better because the camera is at another angle, publish, and the bug comes back only at certain hours of the day — when the sun changes direction and starts hitting the flipped face. Then you blame the day/night cycle and go mess with ClockTime.
Way out: Object Mode, select everything, Ctrl+A, All Transforms. Confirm in the N panel that the scale is back to 1,1,1 on all three axes. Then Edit Mode, A, Shift+N. Only then export. And always validate with the check 2 test, which removes light direction from the equation — if you validate with the sun on, you'll approve a broken mesh just because the camera angle was in your favor.
The second expensive mistake in the same family: fixing everything by switching Technology to Future and calling it done. Future is the heaviest mode on the platform, and devices that can't handle it drop to a cheaper mode automatically. Meaning: on your PC the shadow looks perfect, and on the phone a good share of your players use it's still the 4-stud voxel, with the shadow stepping exactly like before. If your game is mostly mobile, the real fix is cutting the number of dynamic shadows per character, not raising the lighting mode.
What to measure next
First, test under load: put 8 to 10 copies of the animated rig in the same spot in the scene, open Render Stats (Ctrl+Shift+F2) and look at the frame time. Your target is 16.6 ms per frame for 60 FPS and 33.3 ms for 30 FPS — if the time blows up when the rigs enter the scene and goes back to normal when you delete them, the cost is dynamic shadow, not script. Open the MicroProfiler (Ctrl+F6) and confirm it in the render stage.
Second, test on the real device. Publish to a private test place and open it on a phone, not in Studio's resolution emulator — the automatic lighting downgrade happens by GPU capability, and the emulator doesn't reproduce it. If the stepped shadow comes back on the phone, you have your answer: you need fewer lights with Shadows, not a different Technology.
Third, turn the check into a routine. Run LightingDoctor.report on every new rig before it goes into the game and write down how many findings show up per character. A clean rig comes out with zero. When a new rig comes out with three or four findings, you find out at import time — and not after a player posts the clip of the black arm in your Discord.
Ready-to-use artifact
--!strict
-- LightingDoctor — audits an animated rig for the three lighting bugs
-- that look like a single "weird lighting bug".
-- Place in ServerScriptService with the exact name LightingDoctor.
local Lighting = game:GetService("Lighting")
export type Finding = {
code: string,
path: string,
reason: string,
}
-- Lighting modes that resolve shadows on a 4x4x4 stud voxel grid.
local VOXEL_TECH: { [string]: boolean } = {
Voxel = true,
Compatibility = true,
}
-- Above this value the penumbra boils while the rig animates.
local MAX_SHADOW_SOFTNESS: number = 0.2
-- Range above this on a bone-parented light widens the update pop.
local MAX_BONE_LIGHT_RANGE: number = 20
local LightingDoctor = {}
-- Full Explorer path, so you can find the instance without guessing.
local function fullPath(inst: Instance): string
local ok, path = pcall(function(): string
return inst:GetFullName()
end)
if ok and typeof(path) == "string" then
return path
end
return inst.Name
end
local function add(list: { Finding }, code: string, inst: Instance, reason: string): ()
table.insert(list, {
code = code,
path = fullPath(inst),
reason = reason,
})
end
-- Returns the Range of lights that have one, nil for the ones that don't.
local function lightRange(light: Light): number?
if light:IsA("PointLight") then
return light.Range
elseif light:IsA("SpotLight") then
return light.Range
elseif light:IsA("SurfaceLight") then
return light.Range
end
return nil
end
-- Check 4a: invisible parts still casting a box shadow.
local function scanGhostShadows(root: Instance, list: { Finding }): ()
for _, d in ipairs(root:GetDescendants()) do
if d:IsA("BasePart") and d.Transparency >= 1 and d.CastShadow then
add(list, "GHOST_SHADOW", d,
"invisible part with CastShadow on: throws a box that ignores the deformed mesh")
end
end
end
-- Check 4b: lights with Shadows inside the animated hierarchy.
local function scanShadowLights(root: Instance, list: { Finding }): ()
local shadowCount = 0
for _, d in ipairs(root:GetDescendants()) do
if d:IsA("Light") and d.Enabled and d.Shadows then
shadowCount += 1
if shadowCount > 1 then
add(list, "LIGHT_WITH_SHADOW", d,
"more than one light with Shadows on the same rig: recalculated every frame")
end
local range = lightRange(d)
if range ~= nil and range > MAX_BONE_LIGHT_RANGE then
add(list, "LIGHT_WITH_SHADOW", d,
string.format("moving light with Shadows and Range %.0f: lower the Range", range))
end
end
end
end
-- Band-aid for a flipped normal: renders both faces of every triangle.
local function scanDoubleSided(root: Instance, list: { Finding }): ()
for _, d in ipairs(root:GetDescendants()) do
if d:IsA("MeshPart") and d.DoubleSided then
add(list, "DOUBLE_SIDED", d,
"DoubleSided on: hides a flipped normal instead of fixing it in Blender")
end
end
end
-- Scene-level checks: voxel grid and penumbra size.
local function scanScene(list: { Finding }): ()
local tech = Lighting.Technology.Name
if VOXEL_TECH[tech] then
add(list, "TECH_VOXEL", Lighting,
string.format("Technology = %s: shadows on a 4x4x4 stud grid, stepped under an animated rig", tech))
elseif tech == "Future" and Lighting.ShadowSoftness > MAX_SHADOW_SOFTNESS then
add(list, "SHADOW_SOFTNESS", Lighting,
string.format("ShadowSoftness = %.2f: the shadow edge boils while the body moves", Lighting.ShadowSoftness))
end
end
-- Runs every check and returns the findings, scene first.
function LightingDoctor.audit(root: Instance): { Finding }
local list: { Finding } = {}
scanScene(list)
scanGhostShadows(root, list)
scanShadowLights(root, list)
scanDoubleSided(root, list)
return list
end
-- Prints the audit. Call this from the Command Bar with the rig selected.
function LightingDoctor.report(root: Instance): { Finding }
local list = LightingDoctor.audit(root)
print(string.format("[LightingDoctor] %s — %d finding(s)", root.Name, #list))
for _, f in ipairs(list) do
print(string.format(" %s | %s | %s", f.code, f.path, f.reason))
end
if #list == 0 then
print(" clean rig: nothing to turn off")
end
return list
end
-- Applies only the safe fixes and returns how many properties changed.
-- Never touches DoubleSided, Material or Technology.
function LightingDoctor.fix(root: Instance): number
local changed = 0
for _, d in ipairs(root:GetDescendants()) do
if d:IsA("BasePart") and d.Transparency >= 1 and d.CastShadow then
d.CastShadow = false
changed += 1
elseif d:IsA("Light") and d.Shadows then
d.Shadows = false
changed += 1
end
end
print(string.format("[LightingDoctor] %s — %d property(ies) changed", root.Name, changed))
return changed
end
return LightingDoctor{
"blender_fbx_export": {
"path_mode": "Copy",
"apply_scale_options": "FBX_SCALE_ALL",
"object_types": ["ARMATURE", "MESH"],
"use_mesh_modifiers": true,
"mesh_smooth_type": "FACE",
"use_tspace": false,
"add_leaf_bones": false,
"primary_bone_axis": "Y",
"secondary_bone_axis": "X",
"bake_anim": false
},
"before_export_checklist": [
"Object Mode > Ctrl+A > All Transforms (scale back to 1,1,1)",
"Edit Mode > A > Shift+N (recalculate normals outside)",
"Object Data Properties > remove Custom Split Normals Data",
"N panel: confirm scale 1,1,1 on all three axes"
],
"studio_flat_light_test": {
"Lighting.Ambient": [255, 255, 255],
"Lighting.OutdoorAmbient": [255, 255, 255],
"Lighting.Brightness": 0,
"Lighting.GlobalShadows": false,
"part_Material": "SmoothPlastic",
"part_Color": [255, 255, 255],
"reads_as": "face still black = flipped normal, not lighting"
},
"studio_animated_rig": {
"Lighting.Technology": "ShadowMap",
"Lighting.Technology_high_end": "Future",
"Lighting.ShadowSoftness": 0.2,
"Lighting.EnvironmentDiffuseScale": 0.5,
"MeshPart.DoubleSided": false,
"invisible_parts.CastShadow": false,
"lights_with_Shadows_per_character": 1,
"note": "Future downgrades automatically on weak GPUs"
}
}

