Roblox recently officially announced the new RGB based Avatar Color Picker, and I started to realize issues with the Default Clothing Preview in the Universal App. This made me think of that Roblox most likely uses a formula to enforce Default Clothing on an Avatar or not, so I started
Hello, Avatar Editor Experience game developers, or people that just need the formula, or are curious!
I’ve been exploring so much more Avatar related things about Roblox.
Once, I made a guide about the DepthScale Formula:
I hope these guides, will encourage Avatar Editor Experience developers, to actually implement it in their game. Like Catalog Avatar Creator @ItsMuneeeb. I don’t think they have implemented it yet, unfortunately.
Today!, I will create a guide for the formula related to the enforcement of the Default Clothing on your Avatar.
Just like the other Formula Guide. I will only explain the step by step concept. See it as some sort of RFC Guide.
If you’re in doubt on the formulas, I don’t know what to tell you (I do mention though if a formula could be outdated, this one might has some components different). But you can battle-field test the formulas, such as the DepthScale one. If something is different, then it’s because it’s outdated and or Roblox changed something without saying what. (In this context, currently something has changed apparently, long ago)
What is this Formula useful for?
This formula is useful to validate, whether the BodyColors of your Roblox Avatar, will enforce Default Clothing or not. This is useful for Avatar Previews in Avatar Editors.
Pre-explanation before the Formula
Apparently, there’s a few more conditions, but these aren’t needed, they’re not related to us.
The Step by Step rough process
As you might know, same BodyColor, enforces Default Clothing, like so:
1. The check that determines everything.
The System compares your RightLeg BrickColor ID with your Torso BrickColor ID and the same for the LeftLeg BrickColor ID.
The RightLeg and the LeftLeg, are NOT allowed to be similar to the Torso’s body color. If any of it are, then your Lower Body colors are too similar to the Torso’s color and you’ll get enforced to wear Default Clothing.
As example, purely gonna name this function uhhh…
BodyColorsTooSimilar(RightLegColor, TorsoColor)
and same for LeftLeg
2. The actual BodyColor similarity formula.
See below.
Formula for “BodyColor Too Similar”
Determines whether 2 body colors are similar enough to pass for “pure skin tone”. The Delta-E difference between two body colors is calculated using the CIE2000 algorithm, and the minimum threshold value is determined by a setting.
pure skin tone, was not part of the description, but doesn’t matter
Note, results could vary, based on what Roblox changes
This below is NOT code, only demonstration of the formula and its inputs.
BodyColorsTooSimilar(firstBrickColorId, secondBrickColorId)
As the quote says, a setting is defined. I will call it MinimumDeltaEColorDifference
, which is 11.4
. I hope they’ll say what they change it to, if they do change it. (Preferably through API)
Oh… it’s there already
- You’ll need a Degrees to Radians Function.
-
degrees * 0.01745329251994329576923690768489
, which is (pi/180)
-
-
RGB_to_Lab
Function. - When I mention
CIE2000Difference(color1, color2)
I am talking about this Color difference - Wikipedia (Same page for Delta-E on Wikipedia btw.)- Yes… that’s the formula you’d need and the equavilient of the function.
- Returns A number representing the CIE2000 Delta-E between color1 and color2
This is how the formula would be.
MinimumDeltaEColorDifference = 11.4
BodyColor_DeltaE(color1, color2) {
ColorLab color1_lab = RGB_to_Lab(color1)
ColorLab color2_lab = RGB_to_Lab(color2)
return CIE2000Difference(color1_lab, color2_lab)
}
// Boolean is returned
BodyColorsTooSimilar(firstBrickColorId, secondBrickColorId) {
Color3 firstColor = Color3FromBrickColorId(firstBrickColorId)
Color3 secondColor = Color3FromBrickColorId(secondBrickColorId)
return ( BodyColor_DeltaE(firstColor, secondColor) < MinimumDeltaEColorDifference )
}
Utility
To get the closest BrickColor ID through RGB, you can use:
BrickColor.new( Color3.fromRGB(r, g, b) )
Rough Code Example
Example Code
local MinimumDeltaEColorDifference = 11.4
--[[
These are defined by
International Commission on Illumination (CIE)
https://en.wikipedia.org/wiki/Standard_illuminant
]]
local D65_WhiteReference = Vector3.new(
95.047, -- X
100.0, -- Y
108.883 -- Z
)
--[[
https://github.com/icrawler/luacolors
]]
-- Convert CIEXYZ to CIELAB, with PivotXyz
-- Numbers are from CIE 1931 standard
local function XYZ_To_Lab(clrX, clrY, clrZ)
-- https://colour.readthedocs.io/en/v0.3.9/colour.constants.cie.html
local function PivotXyz(n)
local CIE_Epsilon = 0.008856451679035631
local CIE_Kappa = 903.2962962962963
if (n > CIE_Epsilon) then
return math.pow(n, (1/3))
end
return (CIE_Kappa * n + 16) / 116
end
local x = PivotXyz(clrX / D65_WhiteReference.X)
local y = PivotXyz(clrY / D65_WhiteReference.Y)
local z = PivotXyz(clrZ / D65_WhiteReference.Z)
local L = math.max(0.0, 116 * y - 16)
local A = 500.0 * (x - y)
local B = 200.0 * (y - z)
return L, A, B
end
local function Color3_To_XYZ(clr: Color3)
local function PivotRgb(n)
-- Numbers apparently are from sRGB specification
if (n > 0.04045) then
return math.pow((n + 0.055) / 1.055, 2.4) * 100.0
end
return n / 12.92 * 100.0
end
-- Surprise surprise, we don't have to turn it into RGB
-- It is already in Normalized RGB
-- This would require the normalized RGB input
local r = PivotRgb(clr.R)
local g = PivotRgb(clr.G)
local b = PivotRgb(clr.B)
-- Contribution values or something defined by CIE
local x = (r * 0.4124 + g * 0.3576 + b * 0.1805)
local y = (r * 0.2126 + g * 0.7152 + b * 0.0722)
local z = (r * 0.0193 + g * 0.1192 + b * 0.9505)
return x, y, z
end
-- Convert Color3 to Lab
local function Color3_To_Lab(color3: Color3)
return XYZ_To_Lab( Color3_To_XYZ(color3) )
end
local function DegreesToRadians(n)
return n * (math.pi/180)
end
local DegToRad = DegreesToRadians
type ColorLab = {
L: number;
A: number;
B: number;
}
function CIE2000Difference(clr1_Lab: ColorLab, clr2_Lab: ColorLab)
local degrees360InRadians = DegreesToRadians(360.0)
local degrees180InRadians = DegreesToRadians(180.0)
local pow25To7 = math.pow(25, 7)
local num = math.sqrt(clr1_Lab.A * clr1_Lab.A + clr1_Lab.B * clr1_Lab.B)
local c2 = math.sqrt(clr2_Lab.A * clr2_Lab.A + clr2_Lab.B * clr2_Lab.B)
local barC = (num + c2) / 2
local g = 0.5 * (1.0 - math.sqrt(math.pow(barC, 7) / (math.pow(barC, 7) + pow25To7)) )
local a1Prime = (1.0 + g) * clr1_Lab.A
local a2Prime = (1.0 + g) * clr2_Lab.A
local cPrime = math.sqrt(a1Prime * a1Prime + clr1_Lab.B * clr1_Lab.B)
local cPrime2 = math.sqrt(a2Prime * a2Prime + clr2_Lab.B * clr2_Lab.B)
local hPrime
if (clr1_Lab.B == 0) and (a1Prime == 0) then
hPrime = 0.0
else
hPrime = math.atan2(clr1_Lab.B, a1Prime)
if (hPrime < 0.0) then
hPrime += degrees360InRadians
end
end
local hPrime2
if (clr2_Lab == 0) and (a2Prime == 0) then
hPrime2 = 0
else
hPrime2 = math.atan2(clr2_Lab.B, a2Prime)
if (hPrime2 < 0.0) then
hPrime2 += degrees360InRadians
end
end
local num2 = clr2_Lab.L - clr1_Lab.L
local deltaCPrime = cPrime2 - cPrime
local cPrimeProduct = cPrime * cPrime2
local deltaHPrime
if (cPrimeProduct == 0.0) then
deltaHPrime = 0.0
else
deltaHPrime = hPrime2 - hPrime
if (deltaHPrime < -degrees180InRadians) then
deltaHPrime += degrees360InRadians
elseif (deltaHPrime > degrees180InRadians) then
deltaHPrime -= degrees360InRadians
end
end
deltaHPrime = 2.0 * math.sqrt(cPrimeProduct) * math.sin(deltaHPrime / 2.0)
local barLPrime = (clr1_Lab.L + clr2_Lab.L) / 2.0
local barCPrime = (cPrime + cPrime2) / 2.0
local hPrimeSum = hPrime + hPrime2
local barHPrime
if ((cPrime * cPrime) == 0) then
barHPrime = hPrimeSum
elseif (math.abs(hPrime - hPrime2) <= degrees180InRadians) then
barHPrime = hPrimeSum / 2.0
elseif (hPrimeSum < degrees360InRadians) then
barHPrime = (hPrimeSum + degrees360InRadians) / 2.0
else
barHPrime = (hPrimeSum - degrees360InRadians) / 2.0
end
local t = 1.0 - 0.17 * math.cos( barHPrime - DegToRad(30.0) )
+ 0.24 * math.cos(2.0 * barHPrime)
+ 0.32 * math.cos( 3.0 * barHPrime + DegToRad(6.0) )
- 0.2 * math.cos( 4.0 * barHPrime - DegToRad(63.0) )
local deltaTheta = DegToRad(30.0) * math.exp( -math.pow((barHPrime - DegToRad(275.0) )
/ DegToRad(25.0), 2.0))
local rC = 2.0 * math.sqrt(math.pow(barCPrime, 7.0)
/ (math.pow(barCPrime, 7.0) + pow25To7))
local sL = 1.0 + 0.015 * math.pow(barLPrime - 50.0, 2.0)
/ math.sqrt(20.0 + math.pow(barLPrime - 50.0, 2.0))
local sC = 1.0 + 0.045 * barCPrime
local sH = 1.0 + 0.015 * barCPrime * t
local rT = -math.sin(2.0 * deltaTheta) * rC
return math.sqrt(
math.pow(num2 / (1.0 * sL), 2.0)
+ math.pow(deltaCPrime / (1.0 * sC), 2.0)
+ math.pow(deltaHPrime / (1.0 * sH), 2.0)
+ rT * (deltaCPrime / (1.0 * sC)) * (deltaHPrime / (1.0 * sH))
)
end
-- Calculates the Delta-E distance between two colors given in RGB.
function BodyColor_DeltaE(color1: Color3, color2: Color3)
local color1_Lab = {} :: ColorLab
color1_Lab.L, color1_Lab.A, color1_Lab.B = Color3_To_Lab(color1)
local color2_Lab = {} :: ColorLab
color2_Lab.L, color2_Lab.A, color2_Lab.B = Color3_To_Lab(color2)
return CIE2000Difference(color1_Lab, color2_Lab)
end
function BodyColorsTooSimilar(first_Color: Color3, second_Color)
return BodyColor_DeltaE(first_Color, second_Color) < MinimumDeltaEColorDifference
end
-- We will compare two colors
local PseudoLowerBodyColor3 = Color3.fromRGB(50, 50, 50)
local PseudoTorsoColor3 = Color3.fromRGB(17, 17, 17)
print(BodyColorsTooSimilar(
BrickColor.new(PseudoLowerBodyColor3).Color, BrickColor.new(PseudoTorsoColor3).Color
))
print(BodyColorsTooSimilar(
PseudoLowerBodyColor3, PseudoTorsoColor3
))
That code should be good enough to demonstrate.
Don’t forget, you’d have to do this for RightLeg and LeftLeg with Torso
To understand what you actually need to input into the similarity check function, I made a test.
I wanted to figure out if it’s specifically Color3
and BrickColor
.
It seems to be purely based on the RGB inputs, as the BrickColor type did not influence it at all. However, there seems to be something wrong with the formula, so either something changed or idk.
Don’t use the Thumbnail from the Avatar V1 Editor, as it is not accurate with the Default Clothing for some reason
local PseudoLowerBodyColor3 = Color3.fromHex("000000")
local PseudoTorsoColor3 = Color3.fromHex("050505")
As example, this one will still give Default Clothing, but not on the Thumbnail that you see in the Avatar V1 Editor. Simply because it’s old.
Meaning that in-game it will still look different. And that’s all that counts.
Same for this.
local PseudoLowerBodyColor3 = Color3.fromHex("a3fd8f")
local PseudoTorsoColor3 = Color3.fromHex("a2fd8e")
The formula from my example used wrong color conversions before, but I fixed them now!
Here is Roblox’s old avatar code, it’s pretty much the same thing.
When using Formulas compare them to this calculator, make sure all the Color Converting Formulas are correct!
https://colormine.org/delta-e-calculator/cie2000
Aslong you’re using the right CIE Formula, you should be fine!
You can eventually find some around GitHub, like fast/packages/utilities/fast-colors/src/color-converters.ts at master · microsoft/fast · GitHub
And there you go!
But eitherways, I hope this Guide was useful.