This thread will explain how the Animation system works in Runescape(317 & OSRS to be more specific afaik it did not change much in higher revisions)
I'm mainly making this thread because when i first wanted to understand how runescape's animation system works, i found no useful posts that described it in detail and the only resource was the client itself(altho a very good resource, it's just that back in early 2020 i didn't even really understand basic 3d rendering concepts so it was difficult to fully understand how the animation system worked)
Rigging
Before you can create animations for a model, it needs to be rigged.
Generally rigging refers to the process of creating the bones(armature) and then every vertex in the model would be influenced by 1 or more bones, how much it is influenced depends on the weight of that vertex.
However in runescape there's no concept of weights, instead each vertex in the model is labeled(just an id p much)
Now, in the client before each animation is applied, the labels are grouped, for example if the model had the following labels:
[5, 5, 2, 2, 7] the following groups would be generated: [[], [], [2, 3], [], [], [0, 1], [], [4]]
In order to understand this better, lets look at the code in the client that creates the groups:
Code:
public void applyGroups() {
if (vertexLabels != null) {
int[] labelCount = new int[256];
int topLabel = 0;
for (int v = 0; v < vertexCount; v++) {
int label = vertexLabels[v];
labelCount[label]++;
if (label > topLabel) {
topLabel = label;
}
}
groupedLabels = new int[topLabel + 1][];
for (int l = 0; l <= topLabel; l++) {
groupedLabels[l] = new int[labelCount[l]];
labelCount[l] = 0;
}
for (int v = 0; v < vertexCount; v++) {
int label = vertexLabels[v];
groupedLabels[label][labelCount[label]++] = v;
}
}
}
As you can see it first finds the largest label and then creates the 2d array where the outer array has it's length set to the value of the largest label + 1
So for the example above [5, 5, 2, 2, 7] this would yield a 2d array where the length of the outer array is 8 as the largest label in the labels array is 7
The inner array simply consists of the indices where that label occurs in the labels array
So if we look at the result [[], [], [2, 3], [], [], [0, 1], [], [4]] again:
The first array is for label 0, however the label array did not contain a value of 0 therefore it's just an empty array, the same for label 1
the 3rd array which is for label 2 has [2, 3] because the label 2 occurred at indices 2 and 3 in the labels array
the next 2 are empty again(as we did not have labels with the value of 3 or 4) however at index 5 we see [0, 1] again which is for label 5, it has an array with the values [0, 1] because the label 5 occurred at the first two indices and i think u can already see why the next one is empty and the last index has an array with the value 4 in it
Okay, in case you still don't understand how the code above works(to be honest i did not fully understand it either when i first looked at it) there's a simpler way to write it
We can have a Map<Integer, int[]> where the key is the label and the value is the indices where that label occurred in the labels array
Code:
Map<Integer, int[]> groupedLabels = new HashMap<>();
for (int label : labels) {
if (groupedLabels.containsKey(label)) {
continue;
}
int[] indices = IntStream.range(0, labels.length)
.filter(i -> labels[i] == label)
.toArray();
groupedLabels.put(label, indices);
}
Animation/Sequence
Alright lets now look at the data that an animation contains(NOTE: this is for 317 & OSRS, but as i mentioned earlier afaik this didn't change much in higher revisions)
An animation consists of n frames where a frame contains the following data:
Code:
public class SeqFrame {
int id;
int[] xModifier;
int[] yModifier;
int[] zModifier;
int[] groups;
int groupCount;
SeqTransform transform;
}
The important parts here are the 3 modifier arrays, the groups and the transform, you'll see how those are used later on
Lets look at SeqTransform
Code:
public class SeqTransform {
int id;
int[] types;
int[][] groupLabels;
int length;
}
Again, the only important parts here are the types array and the group labels array
finally, we have the animation/sequence definition, it contains a bunch of data but the important parts are:
Code:
int[] frameIDs;
int[] delays;
Why the mentioned are important & how they're used will become apparent in just a moment
Now lets look at how animations are actually applied to models
The animation code in the client basically does this:
Code:
for (int i = 0; i < frame.getGroupCount(); i++) {
int group = frame.getGroups()[i];
int type = frame.getTransform().getTypes()[group];
int[] labels = frame.getTransform().getGroupLabels()[group];
int x = frame.getXModifier()[i];
int y = frame.getYModifier()[i];
int z = frame.getZModifier()[i];
animate(type, labels, x, y, z); // alternatively, this could be called transform
}
the type refers to the transform type used for that specific transformation, the types used in the rs client are:
0(ORIGIN)
1(TRANSLATION)
2(ROTATION)
3(SCALING)
5(ALPHA)
the labels as explained above simply store indices to the grouped labels array which contains arrays of vertex indices(or vertex groups)
the x, y, z are the values that the model is going to be affected by(eg it is translated/rotated or scaled by x, y, z)
Lets look at the animate function:
Code:
public void animate(int type, int[] labels, int dx, int dy, int dz) {
switch (type) {
case SET_ORIGIN -> setOrigin(labels, dx, dy, dz);
case APPLY_TRANSLATION -> applyTranslation(labels, dx, dy, dz);
case APPLY_ROTATION -> applyRotation(labels, dx, dy, dz);
case APPLY_SCALING -> applyScaling(labels, dx, dy, dz);
case APPLY_ALPHA -> applyAlpha(labels, dx);
}
}
Now lets look at what each type does(translation, rotation, scaling, alpha are obvious) but i'll also explain how each of those work
SET_ORIGIN:
Code:
private void setOrigin(int[] labels, int dx, int dy, int dz) {
int displacedVertexCount = 0;
resetOffset(); // set originX, originY, originZ to 0;
for (int label : labels) {
if (skeleton.getVertexGroup(label) == null) {
continue;
}
for (int vertex : skeleton.getVertexGroup(label)) {
originX += skeleton.getX(vertex);
originY += skeleton.getY(vertex);
originZ += skeleton.getZ(vertex);
displacedVertexCount++;
}
}
if (displacedVertexCount > 0) {
originX = dx + originX / displacedVertexCount;
originY = dy + originY / displacedVertexCount;
originZ = dz + originZ / displacedVertexCount;
} else {
originX = dx;
originY = dy;
originZ = dz;
}
}
As you can see, it simply gets the midpoint of all the vertices inside the vertex groups which becomes the origin
the origin of what u might wonder, of course the rotation origin because rotations happen around an origin(or about the origin whichever one makes more sense to you) therefore we need to define the rotation origin before each rotation(It is also why before every transform with TYPE_ROTATION a transform with TYPE_ORIGIN occurs) it simply sets the origin for the next transform
Lets now look at what happens when the transform has the type TYPE_ROTATION
Code:
private void applyRotation(int[] labels, int dx, int dy, int dz) {
for (int label : labels) {
if (skeleton.getVertexGroup(label) == null) {
continue;
}
for (int vertex : skeleton.getVertexGroup(label)) {
skeleton.translate(vertex, -originX, -originY, -originZ);
if (dz != 0) skeleton.rotateXY(vertex, SINE[dz], COSINE[dz]); // roll
if (dx != 0) skeleton.rotateZY(vertex, SINE[dx], COSINE[dx]); // pitch
if (dy != 0) skeleton.rotateXZ(vertex, SINE[dy], COSINE[dy]); // yaw
skeleton.translate(vertex, +originX, +originY, +originZ);
}
}
}
First it moves the vertex to the coordinate system origin(0, 0, 0) then it rotates it and moves it back afterwards
Lets look at the rotation methods:
Code:
public void rotateXY(int vertex, int sin, int cos) {
int x = getX(vertex);
int y = getY(vertex);
int newX = y * sin + x * cos >> 16;
int newY = y * cos - x * sin >> 16;
setX(vertex, newX);
setY(vertex, newY);
}
public void rotateZY(int vertex, int sin, int cos) {
int z = getZ(vertex);
int y = getY(vertex);
int newZ = y * sin + z * cos >> 16;
int newY = y * cos - z * sin >> 16;
setZ(vertex, newZ);
setY(vertex, newY);
}
public void rotateXZ(int vertex, int sin, int cos) {
int x = getX(vertex);
int z = getZ(vertex);
int newX = z * sin + x * cos >> 16;
int newZ = z * cos - x * sin >> 16;
setX(vertex, newX);
setZ(vertex, newZ);
}
The only thing im going to explain here is why the >> 16 is required as the rest you can find from just looking up 'rotation matrix' (altho you also have to take the coordinate system into account)
So as you saw above, our rotation method uses a SINE, COSINE table instead of Math.sin and Math.cos
That is because runescape(in 317 & osrs, in higher revs they changed it to 16384 afaik) uses 2048 degree rotations instead of 360, the tables are computed like this:
Code:
public static final double UNIT = Math.PI / 1024d; // How much of the circle each unit of SINE/COSINE is
public static final int[] SINE = new int[2048]; // sine angles for each of the 2048 units, * 65536 and stored as an int
public static final int[] COSINE = new int[2048]; // cosine
static {
for (int i = 0; i < 2048; ++i) {
SINE[i] = (int) (65536.0D * Math.sin((double) i * UNIT));
COSINE[i] = (int) (65536.0D * Math.cos((double) i * UNIT));
}
}
as you can see each value is multiplied by 65536 which is why the >> 16 is necessary, NOTE: that shifting right by 16 is equal to dividing by 65536
APPLY_TRANSLATION, this method is very simple it simply adds dx, dy, dz to the vertex
APPLY_SCALING
Once again a fairly simple method, however before it scales the vertex it moves it to the origin, scales it, moves it back afterwards:
Code:
private void applyScaling(int[] labels, int dx, int dy, int dz) {
for (int label : labels) {
if (skeleton.getVertexGroup(label) == null) {
continue;
}
for (int vertex : skeleton.getVertexGroup(label)) {
skeleton.translate(vertex, -originX, -originY, -originZ);
skeleton.scale(vertex, dx, dy, dz);
skeleton.translate(vertex, +originX, +originY, +originZ);
}
}
}
The last method for the APPLY_ALPHA type is also very simple:
Code:
private void applyAlpha(int[] labels, int dx) {
for (int label : labels) {
if (skeleton.getTriangleGroup(label) == null) {
continue;
}
for (int triangle : skeleton.getTriangleGroup(label)) {
skeleton.setAlpha(triangle, dx);
}
}
}
and all setAlpha does is add dx * 8 to the current alpha value of the triangle, if it's less than 0 afterwards then sets it to 0, if it's over 255 sets it to 255
i also didn't mention triangle groups above, they're simply the triangle 'labels' grouped (the same exact way as vertex labels)
Now, lets look at an example to have a better understanding of how labels are used
Assume that i have a character model, that has the label '3' defined for all vertices of the shoulder and the label '4' for all vertices of the elbow and the label '5' for all vertices of the hand
Now if i wanted to animate(transform) all of the 3(shoulder, elbow, hand) i would need to create a transformation with the labels [3, 4, 5] which would indicate that all the vertices labeled with 3, 4, 5 should be moved with this transformation
Alright i think that's it for explaining the animation system, if you think i missed something or don't understand a certain part, feel free to ask and i can probably clarify
I would also like to briefly explain how an editor for editing or creating new animations could be created
So as i already explained, each vertex has a label which is used for animation and then those labels are grouped
Possibility 1 would be to just create a tool for creating animations using those grouped labels, however that is not optimal for 2 reasons:
1) More complicated models have a lot of different labels(usually 50+, the Vet'ion model for example has 59) therefore creating animations would take a while & it would be rather tedious
2) Because most transformations are rotations, you would also need to define a rotation for each origin making it even more tedious
However there's a much better solution, if you would like to make new animations(or edit existing animations) of a model that has already been animated before, you can simply look at the existing animations to build a hierarchy.
This would be ideal because you would be able to transform labels that never transformed alone in that animation in groups, meaning you would have less labels but would still be able to animate each part of the mesh which would make it much much easier to make or edit animations, on top of that you would also know the origin for each rotation and wouldn't need to create your own.
Building the hierarchy is very easy, one simply has to look at an existing animation and see which vertex groups always transform together, for example if during the whole animation the shoulder never moved alone but it moved together with all the labels of the elbow, then you can infer that the elbow is a children of shoulder, so if you move the shoulder the elbow should also move.
I also thought about covering how Runescape's rigged models could be converted to a different format(like glTF, fbx or collada) so new animations could be created in programs like blender however that would be a very lengthy thread and i don't think it's really related to this thread so if i choose to do it, i'll make a new thread.