- Modifié
Single SkeletonData instance with multiple dynamic atlases
- Modifié
Hi,
We develop the game (c++/cocos2d-x) where the character appearance can be customized.
For example when the user makes some changes in hero's inventory the following parts will be changed in real time: helmet, gloves, boots, armour, weapon, cloak. We have huge amount (hundreds) of assets for each part.
Moreover the player can see several characters of another players at the same time. So we expect that in a certain moment there are multiple fully customized characters at the scene.
How we do that.
- We have character animated in Spine. The default equipment (armour, weapon etc.) is used in project. No skins are used.
- We have a lot of assets of every equipment part. The assets for equipment of the same type have equal frame sizes. For example all hats are stored in a separate .png with the equal sizes.
- We export the project and get atlas file (.atlas) and texture (.png). We DO NOT use this texture because it contains default equipment.
- In runtime we use the atlas from the previous step as layout source to generate personal texture for each character depending on it's current equipment.
- In runtime we create a custom skin that contains attachments pointing to the texture created dynamically in a previous step. And we assign this skin to the skeleton.
The problem is that we can not use single instance of SkeletonData that is shared for all skeletons.
SkeletonData stores DeformTimelines
that keep pointers to the attachments that they must be applied to. These pointers are assigned during the loading of the skeleton data from binary (or json) and there is not way to change them in runtime.
As I know the official solution is to create custom attachment loader. But this will not solve the problem. We will still have to create multiple skeleton data instances - one instance for each character.
Is there any way to solve this problem without runtime to be patched?
- Modifié
We do something similar, but I've found a way to not even need a default atlas file at all.
You still need to create a custom attachment loader, inheriting from Cocos2dAtlasAttachmentLoader, but the trick is the implementation of the following two overrides:
static void deleteAttachmentVertices(void* vertices) {
delete (spine::AttachmentVertices*)vertices;
}
static unsigned short quadTriangles[6] = { 0, 1, 2, 2, 3, 0 };
static void setAttachmentVertices(spine::RegionAttachment* attachment) {
spine::AtlasRegion* region = (spine::AtlasRegion*)attachment->getRendererObject();
auto renderObject = (region == nullptr || region->page == nullptr) ? nullptr : (Texture2D*)region->page->getRendererObject();
spine::AttachmentVertices* attachmentVertices = new spine::AttachmentVertices(renderObject, 4, quadTriangles, 6);
V3F_C4B_T2F* vertices = attachmentVertices->_triangles->verts;
for (int i = 0, ii = 0; i < 4; ++i, ii += 2) {
vertices[i].texCoords.u = attachment->getUVs()[ii];
vertices[i].texCoords.v = attachment->getUVs()[ii + 1];
}
attachment->setRendererObject(attachmentVertices, deleteAttachmentVertices);
}
static void setAttachmentVertices(spine::MeshAttachment* attachment) {
spine::AtlasRegion* region = (spine::AtlasRegion*)attachment->getRendererObject();
auto renderObject = (region == nullptr || region->page == nullptr) ? nullptr : (Texture2D*)region->page->getRendererObject();
spine::AttachmentVertices* attachmentVertices = new spine::AttachmentVertices(renderObject,
attachment->getWorldVerticesLength() >> 1, attachment->getTriangles().buffer(), attachment->getTriangles().size());
V3F_C4B_T2F* vertices = attachmentVertices->_triangles->verts;
for (int i = 0, ii = 0, nn = attachment->getWorldVerticesLength(); ii < nn; ++i, ii += 2) {
vertices[i].texCoords.u = attachment->getUVs()[ii];
vertices[i].texCoords.v = attachment->getUVs()[ii + 1];
}
attachment->setRendererObject(attachmentVertices, deleteAttachmentVertices);
}
void CustomAttachmentLoader::configureAttachment(spine::Attachment* attachment) {
if (attachment->getRTTI().isExactly(spine::RegionAttachment::rtti)) {
setAttachmentVertices((spine::RegionAttachment*)attachment);
}
else if (attachment->getRTTI().isExactly(spine::MeshAttachment::rtti)) {
setAttachmentVertices((spine::MeshAttachment*)attachment);
}
}
spine::MeshAttachment* CustomAttachmentLoader::newMeshAttachment(spine::Skin& skin, const spine::String& name, const spine::String& path)
{
SP_UNUSED(skin);
auto regionP = findRegion(path);
if (!regionP)
{
auto attachmentP = new(__FILE__, __LINE__) spine::MeshAttachment(name);
return attachmentP;
}
return Cocos2dAtlasAttachmentLoader::newMeshAttachment(skin, name, path);
}
spine::RegionAttachment* CustomAttachmentLoader::newRegionAttachment(spine::Skin& skin, const spine::String& name, const spine::String& path)
{
SP_UNUSED(skin);
auto regionP = findRegion(path);
if (!regionP)
{
auto attachmentP = new(__FILE__, __LINE__) spine::RegionAttachment(name);
return attachmentP;
}
return Cocos2dAtlasAttachmentLoader::newRegionAttachment(skin, name, path);
}
What happens here is that it doesn't find a region, since no atlas file is loaded, and since the region is null, it will still create a sort of placeholder attachment that you can later replace yourself.
When you load the skeleton data, just make sure you cache it, and you should be able to re-use it to create new spine::SkeletonAnimation
instances.
Now, I assume you have a list of image IDs and what slot/placeholder they map to on the model, and that you have created sprite sheets from those items. You then simply do something similar to this:
auto textureCache = Director::getInstance()->getTextureCache();
auto texture = textureCache->getTextureForKey(pngFilename);
if (texture == nullptr)
{
auto image = new Image();
Image::setPNGPremultipliedAlphaEnabled(false); // to avoid applying PMA on load since textures saved from Cocos2d RenderTexture are already PMA
image->initWithImageFile(pngFilename);
Image::setPNGPremultipliedAlphaEnabled(true);
texture = textureCache->addImage(image, pngFilename);
CC_SAFE_RELEASE(image);
}
Assuming your dynamically generated sprite sheet is in PLIST format, then do this:
auto spriteCache = SpriteFrameCache::getInstance();
spriteCache->addSpriteFramesWithFile(plistFilename);
auto page = spine::AtlasUtilities::ToSpineAtlasPage(texture);
If it's not in PLIST format, then you'll need to create a loader for that format yourself.
Once this is loaded, you then loop through all the slots and placeholders that belong to the items the player needs to equip, and replace those attachments.
This is a snippet of code from our app:
auto templateSlots = _skeleton->getSlots();
auto frameCache = SpriteFrameCache::getInstance();
const auto imageFilesPrefix = std::string("cache/player/");
spine::Vector<spine::String> slotPlaceholders{};
for (auto&& slotPair : item.Slots)
{
auto slotName = slotPair.first;
const auto itemSlotIndex = _skeleton->findSlotIndex(slotName.c_str());
if (itemSlotIndex == -1)
continue;
slotPlaceholders.clear();
_templateSkin->findNamesForSlot(itemSlotIndex, slotPlaceholders);
for (size_t i = 0; i < slotPlaceholders.size(); i++)
{
auto& placeholderId = slotPlaceholders[i];
// Get the attachment associated with this placeholder, because we need the attachment name
auto templateAtt = _templateSkin->getAttachment(itemSlotIndex, placeholderId);
if (!templateAtt)
{
continue;
}
auto templateAttachmentName = templateAtt->getName();
auto frame = frameCache->getSpriteFrameByName(imageFilesPrefix + std::string(templateAttachmentName.buffer()));
if (!frame)
continue;
auto spr = Sprite::createWithSpriteFrame(frame);
auto atlasRegion = spine::AtlasUtilities::ToAtlasRegion(spr, page);
auto newSkinAtt = spine::AttachmentCloneExtensions::GetRemappedClone(templateAtt, atlasRegion, true, true, true);
if (newSkinAtt != nullptr)
{
// These 2 commented lines are only required for Spine Runtime < v3.8
//auto oldAtt = mySkin->getAttachment(itemSlotIndex, placeholderId);
//delete oldAtt;
mySkin->removeAttachment(itemSlotIndex, placeholderId);
mySkin->setAttachment(itemSlotIndex, placeholderId, newSkinAtt);
}
}
}
spine::AtlasUtilities::ToAtlasRegion
and spine::AttachmentCloneExtensions::GetRemappedClone
are somewhat based on the Unity/C# version of the code, located here: https://github.com/EsotericSoftware/spine-runtimes/tree/3.8/spine-unity/Assets/Spine/Runtime/spine-unity/Utility
We store the attachments in the dynamically generated sprite sheet with the same name as the attachments from the default skin. For example, say the template skin has a slot named HatLarge, with placeholder that has an attachment named skin/HatLarge
. Now if the user selects an item with a name FancyHat.png to equip, then when we generate the sprite sheet, it will store the name as skin/HatLarge
in the sprite atlas. In our case, we also add an extra prefix, like cache/player/
, to avoid name collisions in the cocos2d-x SpriteFrameCache, and you can see in the snippet of code above how we deal with it. All of this makes it so much easier to work with.
This works for our application, so I'm quite certain it will work for what you're trying to do.
- Modifié
Hi Roo101,
Thanks for your great explanation and provided solution.
The key point that let us to move forward was that you called spine::AttachmentCloneExtensions::GetRemappedClone()
with the option cloneMeshAsLinked == true
. This will cause the MeshAttachment be cloned as linked mesh
. And this is exactly what we needed!
Another question related to your implementation.
RegionAttachement
and MeshAttachment store AttachmentVertices
as a renderer object which holds pointer to cocos2d::Texture2D
. But RegionAttachment::copy()
and MeshAttachment::copy()
(that are called from spine::AttachmentCloneExtensions::GetRemappedClone()
) actually copy the pointers to the renderer objects. So copied attachements will share the same texture. This is ok if there is only one character on the scene. But we need to have multiple character at once. Do you have some workaround for this?
And one another question goes to Spine Runtime developers.
Is there any common clear explanation in Spine documentation of solution of this problem? This one is really obscure. I think this is quite common usage case for the games with a customized character appearance.
- Modifié
kxs a écritHi Roo101,
Another question related to your implementation.
RegionAttachement
and MeshAttachment storeAttachmentVertices
as a renderer object which holds pointer tococos2d::Texture2D
. ButRegionAttachment::copy()
andMeshAttachment::copy()
(that are called fromspine::AttachmentCloneExtensions::GetRemappedClone()
) actually copy the pointers to the renderer objects. So copied attachements will share the same texture. This is ok if there is only one character on the scene. But we need to have multiple character at once. Do you have some workaround for this?
It's something I missed in my post above, so I'll explain how to work around this, and I've also updated my response above regarding CustomAttachmentLoader with some extra code you need.
The latest version of AttachmentVertices was updated a little while back to explicitly call retain() and release() on the internal Texture2D object, so that solves one of the issues. The other issue, as you noted, is with the RenderObject itself (in this case being of type AttachmentVertices). To get this working, don't call attachment->copy() directly, but rather go through the AttachmentCloneExtensions static class to do so.
In your AttachmentCloneExtensions class:
static void deleteAttachmentVertices(void* vertices)
{
delete (spine::AttachmentVertices*)vertices;
}
RegionAttachment* AttachmentCloneExtensions::GetCopy(RegionAttachment* o)
{
auto newAttachment = static_cast<RegionAttachment*>(o->copy());
// Adjust the render object
const auto renderObject = static_cast<AttachmentVertices*>(o->getRendererObject());
newAttachment->setRendererObject(renderObject->clone(), deleteAttachmentVertices);
return newAttachment;
}
MeshAttachment* AttachmentCloneExtensions::GetCopy(MeshAttachment* o)
{
auto newAttachment = static_cast<MeshAttachment*>(o->copy());
// Adjust the render object
const auto renderObject = static_cast<AttachmentVertices*>(o->getRendererObject());
newAttachment->setRendererObject(renderObject->clone(), deleteAttachmentVertices);
return newAttachment;
}
This ensures that a new instance of RenderObject is created and owned by the attachment.
You also need to add this to the Spine-cocos2dx AttachmentVertices class:
AttachmentVertices* AttachmentVertices::clone()
{
auto avCopy = new AttachmentVertices(this->_texture, this->_triangles->vertCount, this->_triangles->indices, this->_triangles->indexCount);
cocos2d::V3F_C4B_T2F* dstVertices = avCopy->_triangles->verts;
cocos2d::V3F_C4B_T2F* srcVertices = this->_triangles->verts;
for (int i = 0; i < this->_triangles->vertCount; ++i) {
dstVertices[i].texCoords.u = srcVertices[i].texCoords.u;
dstVertices[i].texCoords.v = srcVertices[i].texCoords.v;
}
return avCopy;
}
I modified my copy of the spine runtime since I needed it, but a github issue has been created to discuss this addition here:
https://github.com/EsotericSoftware/spine-runtimes/issues/1456
Another thing to note. There is some more code that you need to add to the spine runtime, which is required when you port the AttachmentRegionExtensions. A github issue has been created for it:
https://github.com/EsotericSoftware/spine-runtimes/issues/1480
EDIT: Updated incorrect method MeshAttachment* AttachmentCloneExtensions::GetCopy(MeshAttachment* o)
.
Thanks Roo101! Now it's quite clear and we can try to adopt your solution for our needs.