Rthro Scaling Specification

(UPDATED AS OF 4/23/19)

image

The goal of this tutorial is to provide a technical specification for how Rthro avatars are scaled, so that you can understand and predict it’s behavior when using custom rigs, and so you may create your own Rthro avatars that can be scaled down to classic character proportions.


Avatar Terminology

Rthro is a subset of Roblox’s R15 avatar that uses scales to make the character taller and skinnier. The changes in scale allow R15 to be divided into two sets of body types:

  • Classic
  • Rthro

Additionally, Rthro itself has two sets of body proportions:

  • Normal
  • Slender

All of these sets are blended using two NumberValues inside of the Humanoid, each whose values are constrained between [0 - 1]

BodyTypeScale Blends the avatar's body type between Classic and Rthro.
BodyProportionScale Blends the avatar's Rthro proportions between Normal and Slender.
local function getHumanoidScaleValue(humanoid, bodyValue, default)
	local value = humanoid:FindFirstChild(bodyValue)
	if value and value:IsA("NumberValue") then
		return value.Value
	else
		return default
	end
end

Avatar Part Scale Types

Parts that are in the character’s assembly (limbs, accessories, etc) can have a StringValue inside of them named AvatarPartScaleType to define what proportions are used when the part is using its original scale. There are 2 valid values:

  • "ProportionsNormal"
  • "ProportionsSlender"

If the AvatarPartScaleType value isn’t present, or its value isn’t one of the two listed above, then the part’s scale type is inferred to be for R15. Some accessories use a value of "Classic", although it isn’t required for it to be identified correctly.

local function getAvatarPartScaleType(part)
	local scaleType = part:FindFirstChild("AvatarPartScaleType")
	if scaleType and scaleType:IsA("StringValue") then
		return scaleType.Value
	else
		return "Classic"
	end
end

Avatar Scaling Tables

There are 4 constant scaling tables that are used to scale the body parts of the avatar. These scales are applied depending on what the scale type of each body part is. Before I show these tables as Lua, here is a data-set that describes each of the tables, and what configurations they are used by.

  • The Part Scale Type column describes what AvatarPartScaleType StringValue is in use when this table is used for scaling.
  • The BodyType column describes what the BodyTypeScale value needs to be for the scales in the table to be applied 100%
  • The Proportion column describes what the BodyProportionScale value needs to be for the scales in the table to be applied 100%

Table Name Description Part Scale Type BodyType Proportion
R15_TO_RTHRO_NORMAL Scales the body parts of an R15 rig to an Rthro Normal rig. "Classic" 1 0
R15_TO_RTHRO_SLENDER Scales the body parts of an R15 rig to an Rthro Slender rig. "Classic" 1 1
RTHRO_NORMAL_TO_R15 Scales the body parts of an Rthro Normal rig to an R15 rig. "ProportionsNormal" 0 0
RTHRO_SLENDER_TO_R15 Scales the body parts of an Rthro Slender rig to an R15 rig. "ProportionsSlender" 0 0
-- Scale R15 -> Rthro Normal
local R15_TO_RTHRO_NORMAL =
{
	Head          = Vector3.new(0.942, 0.942, 0.942);
	UpperTorso    = Vector3.new(1.033, 1.310, 1.140);
	LowerTorso    = Vector3.new(1.033, 1.309, 1.140);

	LeftUpperArm  = Vector3.new(1.129, 1.342, 1.132);
	LeftLowerArm  = Vector3.new(1.129, 1.342, 1.132);
	LeftHand      = Vector3.new(1.066, 1.174, 1.231);

	RightUpperArm = Vector3.new(1.129, 1.342, 1.132);
	RightLowerArm = Vector3.new(1.129, 1.342, 1.132);
	RightHand     = Vector3.new(1.066, 1.174, 1.231);
	
	LeftUpperLeg  = Vector3.new(1.023, 1.506, 1.023);
	LeftLowerLeg  = Vector3.new(1.023, 1.506, 1.023);
	LeftFoot      = Vector3.new(1.079, 1.267, 1.129);

	RightUpperLeg = Vector3.new(1.023, 1.506, 1.023);
	RightLowerLeg = Vector3.new(1.023, 1.506, 1.023);
	RightFoot     = Vector3.new(1.079, 1.267, 1.129);
};

-- Scale R15 -> Rthro Slender
local R15_TO_RTHRO_SLENDER =
{
	Head          = Vector3.new(0.896, 0.942, 0.896);
	UpperTorso    = Vector3.new(0.905, 1.204, 1.013);
	LowerTorso    = Vector3.new(0.986, 1.004, 1.013);

	LeftUpperArm  = Vector3.new(1.004, 1.207, 1.006);
	LeftLowerArm  = Vector3.new(1.004, 1.207, 1.006);
	LeftHand      = Vector3.new(0.948, 1.174, 1.094);
	
	RightUpperArm = Vector3.new(1.004, 1.208, 1.006);
	RightLowerArm = Vector3.new(1.004, 1.208, 1.006);
	RightHand     = Vector3.new(0.948, 1.174, 1.094);
	
	LeftUpperLeg  = Vector3.new(0.976, 1.401, 0.909);
	LeftLowerLeg  = Vector3.new(0.976, 1.301, 0.909);
	LeftFoot      = Vector3.new(1.030, 1.133, 1.004);
	
	RightUpperLeg = Vector3.new(0.976, 1.401, 0.909);
	RightLowerLeg = Vector3.new(0.976, 1.301, 0.909);
	RightFoot     = Vector3.new(1.030, 1.133, 1.004);
};

-- Scale Rthro Normal -> R15
local RTHRO_NORMAL_TO_R15 =
{
	Head          = Vector3.new(1.600, 1.600, 1.600);
	UpperTorso    = Vector3.new(1.014, 0.814, 0.924);
	LowerTorso    = Vector3.new(1.014, 0.814, 0.924);

	LeftUpperArm  = Vector3.new(1.121, 0.681, 0.968);
	LeftLowerArm  = Vector3.new(1.121, 0.681, 0.968);
	LeftHand      = Vector3.new(1.390, 0.967, 1.201);

	RightUpperArm = Vector3.new(1.121, 0.681, 0.968);
	RightLowerArm = Vector3.new(1.121, 0.681, 0.968);
	RightHand     = Vector3.new(1.390, 0.967, 1.201);
	
	LeftUpperLeg  = Vector3.new(0.978, 0.814, 1.056);
	LeftLowerLeg  = Vector3.new(0.978, 0.814, 1.056);
	LeftFoot      = Vector3.new(1.404, 0.953, 0.931);

	RightUpperLeg = Vector3.new(0.978, 0.814, 1.056);
	RightLowerLeg = Vector3.new(0.978, 0.814, 1.056);
	RightFoot     = Vector3.new(1.404, 0.953, 0.931);
};

-- Scale Rthro Slender -> R15
local RTHRO_SLENDER_TO_R15 =
{
	Head          = Vector3.new(1.600, 1.600, 1.600);
	UpperTorso    = Vector3.new(1.156, 0.885, 1.039);
	LowerTorso    = Vector3.new(1.063, 1.061, 1.040);

	LeftUpperArm  = Vector3.new(1.261, 0.756, 1.089);
	LeftLowerArm  = Vector3.new(1.261, 0.756, 1.089);
	LeftHand      = Vector3.new(1.564, 0.967, 1.351);

	RightUpperArm = Vector3.new(1.261, 0.756, 1.089);
	RightLowerArm = Vector3.new(1.261, 0.756, 1.089);
	RightHand     = Vector3.new(1.564, 0.967, 1.351);

	LeftUpperLeg  = Vector3.new(1.025, 0.875, 1.188);
	LeftLowerLeg  = Vector3.new(1.025, 0.943, 1.188);
	LeftFoot      = Vector3.new(1.471, 1.065, 1.047);
	
	RightUpperLeg = Vector3.new(1.025, 0.875, 1.188);
	RightLowerLeg = Vector3.new(1.025, 0.943, 1.188);
	RightFoot     = Vector3.new(1.471, 1.065, 1.047);
};

Keeping a Record of Original Values

When a Humanoid determines that a part is attached to the avatar, it first looks for a Vector3Value inside of the part called OriginalSize. If this value isn’t found, it is created using the current size of the part as its value. Its important to make sure this value is accurate and kept around, otherwise you may end up unintentionally adding additional scaling to the avatar that you did not intend.

Additionally, parts that are attached to the avatar will have their children scanned for Attachments. Similarly to parts, Attachments will receive a Vector3Value named OriginalPosition, if it doesn’t have one already. This is used to scale the positional offset of the attachment relative to the scaling that is being done to the part.

local function getOriginalVector3(object, label, default)
	local original
	
	for _,child in pairs(object:GetChildren()) do
		if child:IsA("Vector3Value") and child.Name == label then
			original = child
			break
		end
	end
	
	if not original then
		original = Instance.new("Vector3Value")
		original.Name = label
		original.Value = default
		original.Parent = object
	end
	
	return original
end

local function getOriginalSize(part)
	local size = part.Size
	return getOriginalVector3(part, "OriginalSize", size)
end

local function getOriginalPosition(attachment)
	local position = attachment.CFrame.Position
	return getOriginalVector3(attachment, "OriginalPosition", position)
end

Sampling the Avatar Scaling Tables and Scaling

When a Humanoid determines that it needs to be rescaled, each body part of the avatar samples the 4 scaling tables. From there, it needs to select the scales that it will use for R15, Rthro Normal, and Rthro Slender, based on what its scale type is.

This sampling can be described through the following Lua function:

local function sampleScaleTables(humanoid, bodyPart, scaleType)
	-- Make sure this is a valid R15 body part.
	local partType = humanoid:GetBodyPartR15(bodyPart)
	if partType == Enum.BodyPartR15.Unknown then
		error("Unknown body part received in sampleScaleTables!") 
	end
	
	-- Determine the scale type for this body part.
	if not scaleType then
		scaleType = getAvatarPartScaleType(bodyPart)
	end
	
	-- Sample the scaling tables
	local limbName = bodyPart.Name
	local sampleNoChange = Vector3.new(1, 1, 1)
	
	local sampleR15ToNormal = R15_TO_RTHRO_NORMAL[limbName]
	local sampleNormalToR15 = RTHRO_NORMAL_TO_R15[limbName]

	local sampleR15ToSlender = R15_TO_RTHRO_SLENDER[limbName]
	local sampleSlenderToR15 = RTHRO_SLENDER_TO_R15[limbName]

	local sampleNormalToSlender = (sampleR15ToNormal / sampleR15ToSlender)
	local sampleSlenderToNormal = (sampleR15ToSlender / sampleR15ToNormal)
	
	-- Select the scales that will be interpolated
	local scaleR15, scaleNormal, scaleSlender
	
	if scaleType == "ProportionsNormal" then
		scaleR15 = sampleNormalToR15
		scaleNormal = sampleNoChange
		scaleSlender = sampleNormalToSlender
	elseif scaleType == "ProportionsSlender" then
		scaleR15 = sampleSlenderToR15
		scaleNormal = sampleSlenderToNormal
		scaleSlender = sampleNoChange
	else
		scaleR15 = sampleNoChange
		scaleNormal = sampleR15ToNormal
		scaleSlender = sampleR15ToSlender
	end
	
	return scaleR15, scaleNormal, scaleSlender
end

Using the samples returned from this function, the body part then computes its effective rthro scale using the Body NumberValues inside of the Humanoid.

local function computeLimbScale(humanoid, bodyPart)
	-- If this isn't a valid R15 body part, don't scale it.
	local partType = humanoid:GetBodyPartR15(bodyPart)
	
	if partType == Enum.BodyPartR15.Unknown then
		return Vector3.new(1, 1, 1)
	end
	
	-- Determine the scale type for this body part.
	local scaleType = getAvatarPartScaleType(bodyPart)
	
	-- Select the scales we will interpolate
	local scaleR15, scaleNormal, scaleSlender = sampleScaleTables(humanoid, bodyPart)
	
	-- Compute the Rthro scaling based on the current proportions and body-type.
	local bodyType = getHumanoidScaleValue(humanoid, "BodyTypeScale", 0)
	local proportions = getHumanoidScaleValue(humanoid, "BodyProportionScale", 0)
	
	local scaleProportions = scaleNormal:Lerp(scaleSlender, proportions)
	local scaleBodyType = scaleR15:Lerp(scaleProportions, bodyType)
	
	-- Handle the rest of the scale values.
	if bodyPart.Name == "Head" then
		local headScale = getHumanoidScaleValue(humanoid, "HeadScale", 1)
		return scaleBodyType * headScale
	else
		local bodyWidth  = getHumanoidScaleValue(humanoid, "BodyWidthScale",  1)
		local bodyDepth  = getHumanoidScaleValue(humanoid, "BodyDepthScale",  1)
		local bodyHeight = getHumanoidScaleValue(humanoid, "BodyHeightScale", 1)	
		
		local baseScale = Vector3.new(bodyWidth, bodyHeight, bodyDepth)
		return scaleBodyType * baseScale
	end
end



Accessory Scaling

AccessoryMix

Scaling accessories is a bit of a special case, because its possible to have accessories made for one avatar type being applied to a limb that is using a different avatar type.

For example, you may be wearing an Rthro head while using R15 proportions, while wearing an R15 hat. In this circumstance, the Rthro head would be scaled up by 60%.

image

Mirroring the scale of the head to the hats would be undesirable for hats that were already scaled to work with R15, so the goal in this circumstance is to

  • Get the scale that the accessory will have when its attachment parent is at its original scale.
  • Scale that scale by the current scale of the body part to get the effective scale of the accessory.

This can be done by sampling the scale of the body part for both the body part’s scale type, and the handle’s scale type. Dividing the handle samples by the body part samples will do the trick properly.

local function computeAccessoryScale(humanoid, limb, accessory)
	local handle = accessory:FindFirstChild("Handle")
	if not (handle and handle:IsA("BasePart")) then
		return Vector3.new(1, 1, 1)
	end
	
	local limbScaleType = getAvatarPartScaleType(limb)
	local handleScaleType = getAvatarPartScaleType(handle)
	
	local limbScale = computeLimbScale(humanoid, limb)
	if (limbScaleType == handleScaleType) then
		return limbScale
	end
	
	local limbR15, limbNormal, limbSlender = sampleScaleTables(humanoid, limb, limbScaleType)
	local handleR15, handleNormal, handleSlender = sampleScaleTables(humanoid, limb, handleScaleType)
	
	local originScale
	
	if handleScaleType == "ProportionsNormal" then
		originScale = handleNormal / limbNormal
	elseif handleScaleType == "ProportionsSlender" then
		originScale = handleSlender / limbSlender
	else
		originScale = handleR15 / limbR15
	end

	return originScale * limbScale;	
end


Conclusion

Now you should hopefully have a general understanding of how the Rthro scaling system works. If you have any questions, feel free to post them below. Hope this was informative!


51 Likes

Body parts that are “Rthro Native”, and tagged with ProportionsNormal or ProportionsSlender are scaled down using different scaling factors than are used to scale non-Rthro avatars up to Rthro stature. It’s asymmetric. So there are actually 4 scaling tables in use. Additionally, a few of the values in your tables don’t match what my game is generating on production right now, so just for the sake of completeness, here is what are currently in use (valid 11/16/2018 subject to change). The first two tables are generated by looping over the body parts of a default R15 Block figure scaled with BodyType=1 and BodyProportion 0 and 1 respectively. The second set are generated by iterating over City Life Man and City Life Woman with their BodyType set to 0. In every case, the value in the table is adjusted part size / native size.

R15toRthroNormal = {
	LeftHand = Vector3.new(1.06599998, 1.17400002, 1.23099995),
	LeftLowerArm = Vector3.new(1.12899995, 1.34200001, 1.13199997),
	LeftUpperArm = Vector3.new(1.12899995, 1.34200001, 1.13199997),
	RightHand = Vector3.new(1.06599998, 1.17400002, 1.23099995),
	RightLowerArm = Vector3.new(1.12899995, 1.34200001, 1.13199997),
	RightUpperArm = Vector3.new(1.12899995, 1.34200001, 1.13199997),
	UpperTorso = Vector3.new(1.03299999, 1.30900002, 1.13999999),
	LeftFoot = Vector3.new(1.079, 1.26700008, 1.12899995),
	LeftLowerLeg = Vector3.new(1.023, 1.50600028, 1.023),
	LeftUpperLeg = Vector3.new(1.023, 1.50600028, 1.023),
	RightFoot = Vector3.new(1.079, 1.26699996, 1.12899995),
	RightLowerLeg = Vector3.new(1.023, 1.50600004, 1.023),
	RightUpperLeg = Vector3.new(1.023, 1.50600004, 1.023),
	LowerTorso = Vector3.new(1.03299999, 1.30900002, 1.13999999),
	Head = Vector3.new(0.941999972, 0.941999972, 0.941999972),
}

R15toRthroSlender = {
	LeftHand = Vector3.new(0.947555542, 1.17400002, 1.09422219),
	LeftLowerArm = Vector3.new(1.00355554, 1.20792091, 1.00622225),
	LeftUpperArm = Vector3.new(1.00355554, 1.20792091, 1.00622225),
	RightHand = Vector3.new(0.947555542, 1.17400002, 1.09422219),
	RightLowerArm = Vector3.new(1.00355554, 1.20792091, 1.00622225),
	RightUpperArm = Vector3.new(1.00355554, 1.20792091, 1.00622225),
	UpperTorso = Vector3.new(0.905346155, 1.20423186, 1.01333332),
	LeftFoot = Vector3.new(1.02958012, 1.1332736, 1.00355554),
	LeftLowerLeg = Vector3.new(0.976145089, 1.30051816, 0.909333348),
	LeftUpperLeg = Vector3.new(0.976145089, 1.40093017, 0.909333348),
	RightFoot = Vector3.new(1.02958012, 1.13328028, 1.00355554),
	RightLowerLeg = Vector3.new(0.976145089, 1.30052578, 0.909333348),
	RightUpperLeg = Vector3.new(0.976145089, 1.40093839, 0.909333348),
	LowerTorso = Vector3.new(0.985687017, 1.00460482, 1.01333332),
	Head = Vector3.new(0.896289229, 0.941999972, 0.896289229),
}

RthroManToR15 = {
	LeftHand = Vector3.new(1.38999999, 0.967000008, 1.20099998),
	LeftLowerArm = Vector3.new(1.12100005, 0.680999994, 0.968000114),
	LeftUpperArm = Vector3.new(1.12100005, 0.680999994, 0.968000054),
	RightHand = Vector3.new(1.38999999, 0.967000008, 1.20099998),
	RightLowerArm = Vector3.new(1.12100005, 0.680999994, 0.968000114),
	RightUpperArm = Vector3.new(1.12100005, 0.680999994, 0.968000054),
	UpperTorso = Vector3.new(1.01400006, 0.81400001, 0.924000025),
	LeftFoot = Vector3.new(1.40400004, 0.953000069, 0.930999994),
	LeftLowerLeg = Vector3.new(0.977999926, 0.81400001, 1.05599999),
	LeftUpperLeg = Vector3.new(0.978000045, 0.81400001, 1.05599999),
	RightFoot = Vector3.new(1.40400004, 0.953000188, 0.930999994),
	RightLowerLeg = Vector3.new(0.977999926, 0.814000189, 1.05599999),
	RightUpperLeg = Vector3.new(0.978000045, 0.81400013, 1.05599999),
	LowerTorso = Vector3.new(1.01400006, 0.81400001, 0.924000025),
	Head = Vector3.new(1.60000002, 1.60000002, 1.60000002),
}

RthroWomanToR15 = {
	LeftHand = Vector3.new(1.56375003, 0.967000008, 1.351125),
	LeftLowerArm = Vector3.new(1.26112509, 0.756590962, 1.08899999),
	LeftUpperArm = Vector3.new(1.26112509, 0.756590962, 1.08899999),
	RightHand = Vector3.new(1.56375003, 0.967000067, 1.351125),
	RightLowerArm = Vector3.new(1.26112509, 0.756590962, 1.08899999),
	RightUpperArm = Vector3.new(1.26112509, 0.756590962, 1.08899999),
	UpperTorso = Vector3.new(1.15697408, 0.884817958, 1.0395),
	LeftFoot = Vector3.new(1.47139204, 1.06545448, 1.04737496),
	LeftLowerLeg = Vector3.new(1.02494395, 0.942612529, 1.18799996),
	LeftUpperLeg = Vector3.new(1.02494395, 0.875050426, 1.18799996),
	RightFoot = Vector3.new(1.47139204, 1.06545401, 1.04737496),
	RightLowerLeg = Vector3.new(1.02494395, 0.942612052, 1.18799996),
	RightUpperLeg = Vector3.new(1.02494395, 0.875050068, 1.18799996),
	LowerTorso = Vector3.new(1.06267202, 1.060642, 1.0395),
	Head = Vector3.new(1.68160009, 1.60000002, 1.68160009),
}

Your description of lerping between two tables based on BodyProportionScale is the case with both pairs of tables.

3 Likes

Hmmm, alright.

I used the same technique as you to compute the scale constants, but rounded my values to 3 decimal places, as the data trends suggested that the values below that point were just floating point precision errors.

The values may have been tuned partially since I calibrated my values (which was about a month ago). I implemented it in C# for my Rbx2Source program, which is what I based the tables on.

(Rbx2Source converts Roblox avatars into ragdolls that can be used in the Source Engine, it’s something that I have been developing for a few years now.)

My scaling constants:

https://github.com/CloneTrooper1019/Rbx2Source/blob/master/src/Assembler/R15CharacterAssembler.cs#L41-L79

My function for scaling:

https://github.com/CloneTrooper1019/Rbx2Source/blob/master/src/Assembler/R15CharacterAssembler.cs#L96-L140

1 Like

Yes, the tables were being tuned adjusted as the Rthro models were being created and tested, right up until release. The most significant change was the addition of the tables for scaling Rthro down to classic Robloxian stature, which are not the reciprocals of the Robloxian-to-Rthro scalings.

1 Like

Hey everyone! I just finished rewriting this article to be up to date with the changes made to Rthro’s scaling behavior. There are more code samples provided as well to help contextualize what is going on in a Lua context. Hope you guys enjoy!

9 Likes

Why isn’t this updated?

I clearly wanted to see it updated.