• Runtimes
  • Swap Texture Atlas Scale at Runtime

We have exported our spine atlases at 3 scales: (small, medium, large). In our game, you can significantly zoom the camera. I am trying to swap out the low res assets for higher res assets at runtime based on the zoom of camera, while also preserving the characters animation state so that everything is smooth.

I have written code to intelligently pick the nearest size based on the zoom of the camera, but when I swap the .atlas file on the SpineSkeletonDataResource (I'm using the Godot runtime), the animation state is lost and the character animation is not smooth.

This animation state is lost because changing the .atlas on the SpineSkeletonDataResource triggers SpineSkeletonDataResource::update_skeleton_data(), which basically deletes and recreates everything.

I have looked at the spine source but cannot a clear way to swap out the atlas while preserving the animation state.

To manually reapply the animation state seems difficult, it appears as though I'd have to save many fields to a temporary place and reapply them.

Related Discussions
...

If you can bear with me, some details:

A mesh or region attachment has a texture region which has a "renderer object". What the renderer object is depends on the AttachmentLoader which sets it based on how the rendering will be done.

Often the texture region is an AtlasRegion whose renderer object is an AtlasPage which has a "texture". Like the attachment's renderer object, what the texture is depends on how rendering will be done. Usually it's a game toolkit specific resource.

When an attachment is loaded by an AttachmentLoader, that is usually when it gets configured with a renderer object (though it doesn't have to be, you could delay it until later, for example if you have thousands of attachments and don't want to load all those images up front). Often the attachment loader is an AtlasAttachmentLoader which finds an AtlasRegion in the texture atlas and sets it as the mesh or region attachment's renderer object.

Later, when you want to change the atlas, you would need to do a similar process: for each region or mesh attachment, find the region in the new atlas, and replace the attachment's renderer object. Look at AtlasAttachmentLoader, since the process is similar (you'd only set the renderer object, not create a new attachment). To iterate all attachments, you can look at the attachments in every skin in the SkeletonData.

At least, that is how you can do it with runtimes that provide access to all of the Spine Runtimes APIs. I'm not sure how much of what is needed is exposed in Godot. For the Godot specific workings, if the above is not enough to get you on track, then we'll have to wait for Mario's assistance. He's been ill recently but hopefully he'll be back in full force early this week.

I appreciate the very thorough response, especially on a weekend. Thank you. I'll look into this on my end.

Finding this task to be quite difficult, potentially not supported without c++ changes.

It is indeed not possible without changes on the C++ side I'm afraid. The reason is that if you change a SkeletonDataResource of a SpineSprite, the underlying Skeleton and AnimationState instances need to be recreated.

The SpineSprite simply can't know that all you did was merely change the texture atlas. Even "just" changing the atlas has a ton of side effects as Nate outlined. At a minimum, all the attachments of the skeleton (and its skins) need to reference the new atlas pages. That in itself is a problem, as you can create Skin instances that contain deep copied attachments the runtime has no idea about, which would also need to reference the new atlas pages. The same is true for all sequences, which reference regions in atlas pages.

Even if all attachments could be rewired to use the new atlas pages (or rather, their textures), they'd then also would need to be update so their UV coordinates are recomputed (see RegionAttachment::updateRegion() and MeshAttachment::updateRegion()).

The only sensible option for SpineSprite is thus to smoke the Skeleton and AnimationState instances and recreate them anew from the new SkeletonDataResource you set.

With the current architecture, what you want to do is not possible, neither through GDScript nor the currently available C++ API. We'd have to significantly change the architecture to enable this use case cleanly, without breaking existing projects, which is not trivial.

One approach could work like this:

  • SpineSkeletonDataResource doesn't reference just one but multiple atlases, e.g. in form of an array of SpineAtlasResource instances. All atlases need to be compatible with the skeleton data!
  • SpineSprite is aware of the multi-atlas nature of SpineSkeletonDataResource and keeps an index into the atlas array which represents the active atlas. The index can be changed by a setter. If the index changes, then the sprite will:
    1. Iterate through all the attachments and update their regions as discussed above, including calls to updateRegion()
    2. Iterate through all sequences and update their regions as well.

There're some caveats of course. The point of SpineSkeleotnDataResource is to be able to share as much data between SpineSprite instances as possible. That includes attachments and atlas regions. What I've outlined above would modify the attachments, rewiring them to point to regions of a different atlas. This would affect all SpineSprite instances that reference the same SpineSkeletonDataResource. This might possibly work for your game, but may not work for other projects.

So, while it's sort of possible to do what you want, I can not provide a good, general solution for everyone. As such, I can't commit to implementing this in the runtime at the moment. You could give it a try yourself by modifying the existing sources as outlined above.

The alternative is to better understand your use case. What problem are you actually trying to solve? To me, this sounds like the perfect use case for mip maps. What you are doing sounds like you're implementing mip map generation and mip level selection manually.

Here's a quick and dirty "solution" you can use. Add this method to SpineSprite:

void SpineSprite::set_atlas(const Ref<SpineAtlasResource> &p_atlas) {
	if (!skeleton_data_res.is_valid() ||
		!skeleton_data_res->is_skeleton_data_loaded() ||
		!skeleton.is_valid() ||
		!skeleton->get_spine_object() ||
		!animation_state.is_valid() ||
		!animation_state->get_spine_object() ||
		!p_atlas.is_valid() ||
		!((Ref<SpineAtlasResource> &)p_atlas)->get_spine_atlas())
		return;
	
	spine::Atlas *atlas = ((Ref<SpineAtlasResource> &)p_atlas)->get_spine_atlas();
	spine::Vector<spine::Skin *> &skins = skeleton_data_res->get_skeleton_data()->getSkins();	
	for (size_t i = 0; i < skins.size(); i++) {
		spine::Skin *skin = skins[i];
		auto attachments = skin->getAttachments();
		while(attachments.hasNext()) {
			auto entry = attachments.next();			
			spine::String name = entry._name;
			spine::AtlasRegion *region = atlas->findRegion(name);
			if (entry._attachment->getRTTI().isExactly(spine::RegionAttachment::rtti)) {
				spine::RegionAttachment *regionAttachment = (spine::RegionAttachment*)entry._attachment;
				regionAttachment->setRegion(region);
				regionAttachment->updateRegion();
			} else if (entry._attachment->getRTTI().isExactly(spine::MeshAttachment::rtti)) {
				spine::MeshAttachment *meshAttachment = (spine::MeshAttachment*)entry._attachment;
				meshAttachment->setRegion(region);
				meshAttachment->updateRegion();
			}
		}
	}
}

Make sure to also expose it to GDScript by adding this to SpineSprite::_bind_methods():

ClassDB::bind_method(D_METHOD("set_atlas", "atlas"), &SpineSprite::set_atlas);

You can then set an atlas on a SpineSprite. Here's a little example:

extends SpineSprite

var highres_atlas:SpineAtlasResource = get_skeleton_data_res().get_atlas_res();
var lowres_atlas:SpineAtlasResource = load("res://assets/spineboy/spineboy-small.atlas");

func _ready():
	get_animation_state().set_animation("walk", true);
	pass
	
func _process(_delta):
	if Input.is_action_just_pressed("ui_left"):
		set_atlas(highres_atlas);
	if Input.is_action_just_pressed("ui_right"):
		set_atlas(lowres_atlas);

You can see it in action here:

Note that this will modify the attachments in the skins in the SpineSkeletonDataResource to point to the atlas you specify as a parameter to set_atlas(). This means that any other SpineSprite that also is based on that same SpineSkeletonDataResource will also use that atlas.

As I said, that's not a general solution, so I'm very hesitant to add this to the official spine-godot runtime. You should be able to build your own editor and export templates with this little patch though. The simplest solution is to use GitHub Actions as described here: http://en.esotericsoftware.com/spine-godot#Building-the-Godot-editor-and-export-templates-via-GitHub-Actions

Yeah we're basically doing our own implementation of mipmaps because I dont like the blurriness of the mipmaps that godot generates when I enable mipmaps on our large textures. I should probably look into what I can do to godot's mipmap generation and selection...