This document presents the definition and serialization of SDK components for the Decentraland
protocol. The schema and serialization to be used are Protocol Buffers and the source of truth
for the components live in the
decentraland/protocol repository. This
standard is valid from SDK7 onwards, the former DecentralandInterface
-based
serialization is deprecated with a sunset date TBD.
As of SDK6 components are serialized using JSON and JSON envelopes (JSON serialized inside JSON strings). That worked well for a small scale with few entities and simple scenes. But the performance limitations of using JSON play against the potential of the platform.
The general approach was to use JSON, now there are some components that are known for sure that need a special serialization to optimize speed: Transform is a clear example. Since transform can be mapped 1-1 with a memory struct of floats, it makes sense that the serialization of the component is only a copy of the memory of the struct.
That may not be the case for components with complex structures, like Material, which has strings on it and other resources (or sub structs) like textures. The memory mapping is non viable.
For this problem, there are some options that need to be considered and evaluated:
This pair, definitions and serialization, can live together from a parent definition, for example, a schema that auto generates both, the serialization and its type. A point to be remembered is each component can have its own serialization, even a JSON one.
The proposal option are:
Using a JSON schema that generate a versionable append only component
Its representation is a standard and the CPU understands it.
Here we can introduce the recursive condition, all primitives types are fixed in their lengths, but if we have a struct with all fixed length fields, the struct is also a fixed length one.
If a component is a fixed-length struct, it can be mapped 1-1 in memory.
The components are placed in a directory components
and each component has its
own directory named in pascal case e.g.BoxShape
. In the component directory,
there will be the version of the component:
components
├── Transform
│ └── v1.json
└── BoxShape
├── v1.json
└── v2.json
The only maintenance rule is each future version has to contain the previous one. We could add a field at the end of the root struct, but we can not remove one. If we wish to support optional fields, we can create a new type that stores the state of existence of the mentioned field. Optional types break the fixed-length and non-fixed-length components cannot be optimally copied to the struct in the language that be able to do it.
We neither can modify a child struct.
What about if a fixed-length struct in
v1.json
mutates to a non-fixed length inv2.json
? Well, the first part ofv2.json
isv1.json
and we append fields at the end. With this rule, the first part ofv2.json
is a fixed-length one.
Example:
isTriggered
that indicates if a BoxShape
was clicked.
components/BoxShape/v1.json
, at the begging of its
life ECS 7.0.0.
isTriggered
from a boolean
to a
struct
that has the flag and the button field. This MUST NOT be allowed.
isTriggeredV2
or a
triggeredButton
. In this case, we'll create isTriggeredV2
as a
struct with two fields: flag: boolean
and button: int32
.
So far if the Renderer sends a BoxShapeV2 to a scene, the scene will be able to read it, no matter if the scene was compiled with ECS 7.0.0 or ECS 7.3.0. The same occurs on the opposite side if the renderer has the implementation of BoxShapeV2 as the last updated version also can get a BoxShapeV1 from the ECS 7.0.0 scene.
Transform component:
{
"schema": {
"type": "struct",
"fields": {
"position": {
"type": "struct",
"fields": {
"x": { "type": "float32" },
"y": { "type": "float32" },
"z": { "type": "float32" }
}
},
"rotation": {
"type": "struct",
"fields": {
"x": { "type": "float32" },
"y": { "type": "float32" },
"z": { "type": "float32" },
"w": { "type": "float32" }
}
},
"scale": {
"type": "struct",
"fields": {
"x": { "type": "float32" },
"y": { "type": "float32" },
"z": { "type": "float32" }
}
}
}
},
"metadata": {
"componentId": 1
}
}
To give a real comparison of these approaches, they were tested two recurrent components: Material and Transform. Material is a full optional field component and Transform is a component that probably never changes and it'll be sent a lot.
The scenario was:
https://docs.google.com/spreadsheets/d/1RGFkudVUMlOkNaQZFYAr0qEvVlrULmWp_F1fGBw0pwM/edit?usp=sharing
Serialization of 12,000 full filled materials | Serialization of 12,000 empty materials | |
---|---|---|
Type of serialization | Time [ms] | Size [KB] |
EcsType | 54.63 | 2784 |
EcsType with optional | 92.17 | 2592 |
FlatBuffer | 88.44 | 12288 |
Protocol Buffer | 55.23 | 2988 |
Deserialization of 12,000 full-filled materials | |
---|---|
Type of serialization | Time [ms] |
EcsType | 150.26 |
EcsType with optional | 134.78 |
FlatBuffer | 185.66 |
Protocol Buffer | 65.14 |
Serialization of 12,000 Transforms | |
---|---|
Type of serialization | Time [ms] |
EcsType (raw) | 5.57 |
FlatBuffer | 8552.9 |
Protocol Buffer | 99.52 |
Deserialization of 12,000 Transform | |
Type of serialization | Time [ms] |
EcsType (raw) | 14.6 |
FlatBuffer | 37.45 |
Protocol Buffer | 16.49 |
Flatbuffer will be discarded in this analysis because it seems to be not compatible with having an external byte builder buffer, its optimization is more oriented to a C++ implementation and an ecosystem with all Flattbuffer. Another inconvenience is its code-generator for typescript, it uses classes and these would introduce extra retyping work, ECS 7.0.0 is a strictly data-oriented paradigm.
Protobuffer demonstrated a powerful performance in complex types, and raw EcsType is in the same level, but for the Transform case, raw is around 20x faster for serialization.
For the maintenance, Protobuffer has a clear advantage. Since all their fields are naturally optional, the messages can be changed completely. EcsType approach doesn't have, and for each version it'd be necessary to send the previous data.
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119 and RFC 8174.