Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Scripts to bake and play hundreds of thousands of GPU vertex animations #10866

Open
KontosTwo opened this issue Sep 30, 2024 · 2 comments
Open

Comments

@KontosTwo
Copy link

KontosTwo commented Sep 30, 2024

Describe the project you are working on

I am developing a real time tactics game, currently capable of supporting up to 30,000 animated 3D soldiers fighting in 1,200 units. However, this was not possible using out-of-the box Godot functionality which leads to the next section

Describe the problem or limitation you are having in your project

The out-of-the-box Godot functionality to tackle this task of animating tens of thousands of 3D soldiers would initially be a MeshInstance3D and AnimationPlayer pair for every single soldier. However, this would result in massive CPU and GPU time spent both submitting draw calls and computing skeletal mesh animation. The next attempt would be to use MultiMeshInstance3D, but it lacks direct AnimationPlayer integration and can only render one mesh anyway. Thus, it's not possible to easily animate a large number of 3D meshes unless significant work is done.

Describe the feature / enhancement and how it helps to overcome the problem or limitation

The feature is based off of the GPU vertex animation tooling I've already implemented. It consists of three modules:

  1. Defining: To define an animated 3D mesh, a VertexAnimation Resource stores three other Resources: a mesh (slime), an AnimationPlayer (slime crawl, slime squish, slime dies) compatible with that mesh, and all possible texture variations (blue slime, red slime, evil slime) for that mesh.
  2. Baking: To convert these human-readable Resources into a GPU-friendly format, a baker script will run through every single VertexAnimation Resource and convert the three child Resources into vertex and normal textures, which will be stored in a specified folder, and their paths stored in their originating VertexAnimation resource. In addition, all possible texture variations will be combined into one Texture2DArray
  3. Playing: A script will accept a VertexAnimation Resource and upload its vertex and normals textures, as well as the texture variations array, into a shader that is implemented to replace the vertex and normals with those stored in the array

This suite of tools and scripts solve the following problems:

  • It makes animating tens of thousands or even more 3D meshes possible. Vertex animations are a common optimization technique and adding this feature to Godot would enable its developers to have this many animations as an option.
  • It reduces time needed to implement their own GPU vertex animation solution, which is already a common pattern. I can testify that developing these tools took a stressful 3 weeks which others won't have to experience if this proposal were accepted

Here is a demonstration of the results of using these modules

https://www.youtube.com/watch?v=OTbQH3k0q6Q

Screenshot 2024-09-30 143203 Screenshot 2024-09-30 143232

For 5,000 critters, CPU Time is 2ms, and GPU Time is 10ms for a crowded screen

Describe how your proposal will work, with code, pseudo-code, mock-ups, and/or diagrams

The above section gives an introduction to how the three modules work. Here are some code snippets or images to illustrate them in action

  1. Defining:
    Here's an example of a resource definition
Screenshot 2024-09-30 145902 3. Baking Here's the baker script
using Godot;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using WaywardSoul.Battle;
using WaywardSoul.Helpers;

namespace WaywardSoul.Graphics
{
    public partial class CritterBaker : Node
    {
        [ExportGroup("Actions")]

        [Export]
        private bool Meshes { get; set; } = true;

        [Export]
        private bool Textures { get; set; } = true;

        [ExportGroup("Baking")]

        [Export]
        private float LODBias { get; set; } = 10f;

        [Export(PropertyHint.Dir)]
        private string VertexDirectory { get; set; }

        [Export(PropertyHint.Dir)]
        private string NormalDirectory { get; set; }

        [Export(PropertyHint.Dir)]
        private string TextureArrayDirectory { get; set; }

        [ExportGroup("Dependencies")]

        [Export]
        private AnimationPlayer AnimationPlayer { get; set; }

        [Export]
        private Skeleton3D Skeleton { get; set; }

        public int NumFrames => AnimationPlayer.GetTotalFrameCount(FPS);

        private HashSet<string> BakedMeshes { get; set; }

        private static readonly string NONE_CRITTER = "none.tres";
        public static readonly float FPS = 8;
        public static float SPF => 1 / FPS;

        public override void _Ready()
        {
            BakedMeshes = [];

            if (string.IsNullOrEmpty(VertexDirectory) 
                || string.IsNullOrEmpty(NormalDirectory) 
                || string.IsNullOrEmpty(TextureArrayDirectory))
            {
                GD.PrintErr("Vertex or Normals directory not set");
                return;
            }
            BakeCritterEquipmentProcess();

            GetTree().Quit();
        }

        private void BakeCritterEquipmentProcess()
        {
            var equipmentAndNames = new List<(CritterEquipment, string)>();

            var files = GetCritterEquipmentFiles();
            foreach(var file in files)
            {
                var filePath = file.Item1;
                var resource = ResourceLoader.Load(filePath);
                if (resource is CritterEquipment)
                {
                    equipmentAndNames.Add((resource as CritterEquipment, file.Item2));
                }
            }

            var meshToEquipments = new Dictionary<Mesh, List<(string, CritterEquipment, Texture2D)>>();
            foreach (var equipmentAndName in equipmentAndNames)
            {
                var equipment = equipmentAndName.Item1;
                var name = equipmentAndName.Item2;

                var mesh = equipment.Mesh;
                var texture = equipment.Texture;

                if (!meshToEquipments.ContainsKey(mesh))
                {
                    meshToEquipments.Add(mesh, []);
                }
                meshToEquipments[mesh].Add((name, equipment, texture));
            }

            foreach(var meshPathToEquipment in meshToEquipments)
            {
                var textureArray = new Texture2DArray();
                var equipments = meshPathToEquipment.Value;
                var textures = new Godot.Collections.Array<Image>(equipments.Select(e =>
                {
                    var texture = e.Item3;
                    var image = texture.GetImage();
                    return image;
                }));

                var mesh = meshPathToEquipment.Key;
                var meshPath = mesh.ResourcePath;
                var meshFileName = Path.GetFileNameWithoutExtension(meshPath);

                var vertexImagePath = VertexDirectory + "/" + meshFileName + ".png";
                var normalImagePath = NormalDirectory + "/" + meshFileName + ".png";

                if (Meshes)
                {
                    BakeCritterMesh(mesh, vertexImagePath, normalImagePath);
                }

                if (Textures)
                {
                    var textureArrayErrorCode = textureArray.CreateFromImages(textures);
                    if (textureArrayErrorCode != Error.Ok)
                    {
                        GD.PrintErr("Texture2DArray " + meshFileName + " could not be created: " + textureArrayErrorCode);
                    }

                    var textureArrayPath = TextureArrayDirectory + "/" + meshFileName + ".res";
                    var saveErrorCode = ResourceSaver.Save(textureArray, textureArrayPath);
                    if (saveErrorCode != Error.Ok)
                    {
                        GD.PrintErr("Texture2DArray " + meshFileName + " could not be saved: " + saveErrorCode);
                    }

                    var textureArrayResource = ResourceLoader.Load<Texture2DArray>(textureArrayPath);

                    var numMeshPathToEquipment = equipments.Count;
                    for (var i = 0; i < numMeshPathToEquipment; i++)
                    {
                        var equipment = equipments[i];
                        AssignCritterEquipmentMesh(
                            equipment.Item2,
                            vertexImagePath,
                            normalImagePath,
                            textureArrayResource,
                            i
                        );
                    }
                }
            }
        }

        private void BakeCritterMesh(
            Mesh mesh,
            string vertexImagePath,
            string normalImagePath
        )
        {
            var instance = new MeshInstance3D
            {
                Mesh = mesh,
                LodBias = LODBias,
                Skeleton = Skeleton.GetPath()
            };
            Skeleton.AddChild(instance);

            var meshDataTool = mesh.MeshDataTool();
            var numVertices = meshDataTool.GetVertexCount();

            var numTotalFrames = NumFrames;

            var vertexImage = Image.Create(numTotalFrames, numVertices, false, Image.Format.Rgbaf);
            var normalImage = Image.Create(numTotalFrames, numVertices, false, Image.Format.Rgbaf);

            var frameCounter = 0;
            foreach (var animation in AnimationPlayer.GetAnimationList())
            {
                AnimationPlayer.Play(animation);

                var animationDuration = AnimationPlayer.GetAnimation(animation).Length;
                var numAnimationFrames = Mathf.CeilToInt(animationDuration * FPS);
                for (var i = 0; i < numAnimationFrames; i++)
                {
                    AnimationPlayer.Advance(SPF);

                    var bakedFrame = BakeCritterEquipmentMeshForAnimation(meshDataTool);
                    var bakedFrameEnumerator = bakedFrame.GetEnumerator();

                    for (var j = 0; j < numVertices; j++)
                    {
                        bakedFrameEnumerator.MoveNext();
                        var (vertex, normal) = bakedFrameEnumerator.Current;

                        vertexImage.SetPixel(frameCounter, j, vertex);
                        normalImage.SetPixel(frameCounter, j, normal);
                    }

                    frameCounter++;
                }
            }

            vertexImage.SavePng(vertexImagePath);

            normalImage.SavePng(normalImagePath);

            Skeleton.RemoveChild(instance);
            instance.QueueFree();
        }

        private void AssignCritterEquipmentMesh(
            CritterEquipment equipment, 
            string vertexImagePath,
            string normalImagePath,
            Texture2DArray textureArray, 
            int textureIndex
        )
        {
            equipment.Vertex = ResourceLoader.Load<CompressedTexture2D>(vertexImagePath, cacheMode: ResourceLoader.CacheMode.Ignore);

            equipment.Normal = ResourceLoader.Load<CompressedTexture2D>(normalImagePath, cacheMode: ResourceLoader.CacheMode.Ignore);

            equipment.TextureArray = textureArray;
            equipment.TextureIndex = textureIndex;

            ResourceSaver.Save(equipment);
        }

        private IEnumerable<(Color, Color)> BakeCritterEquipmentMeshForAnimation(MeshDataTool meshDataTool)
        {
            var boneTransformRests = new Dictionary<int,Transform3D>();
            var boneTransformCurrents = new Dictionary<int, Transform3D>();
            for (var i = 0; i < Skeleton.GetBoneCount(); i++)
            {
                boneTransformRests.Add(i, Skeleton.GetBoneGlobalRest(i));
                boneTransformCurrents.Add(i, Skeleton.GetBoneGlobalPose(i));
            }

            for (var i = 0; i < meshDataTool.GetVertexCount(); i++)
            {
                var bones = meshDataTool.GetVertexBones(i);
                var weights = meshDataTool.GetVertexWeights(i);
                var vertex = meshDataTool.GetVertex(i);
                var normal = meshDataTool.GetVertexNormal(i);

                var globalVertex = Vector3.Zero;
                var globalNormal = Vector3.Zero;
                var numBones = bones.Length;
                for (var j = 0; j < numBones; j++)
                {
                    var bone = bones[j];
                    var boneTransformRest = boneTransformRests[bone];
                    var boneTransformCurrent = boneTransformCurrents[bone];
                    var boneWeight = weights[j];

                    var localVertex = boneTransformRest.Inverse() * vertex;
                    var localVertexTransformed = boneTransformCurrent * localVertex;
                    globalVertex += localVertexTransformed * boneWeight;

                    var localNormal = boneTransformRest.Inverse() * normal;
                    var localNormalTransformed = boneTransformCurrent * localNormal;
                    globalNormal += localNormalTransformed * boneWeight;
                }

                globalVertex += Vector3.One;
                globalVertex /= Vector3.One * 2;

                globalNormal = globalNormal.Normalized();
                globalNormal += Vector3.One;
                globalNormal /= Vector3.One * 2;


                var globalVertexColor = new Color(globalVertex.X, globalVertex.Y, globalVertex.Z);
                var globalNormalColor = new Color(globalNormal.X, globalNormal.Y, globalNormal.Z);

                yield return (globalVertexColor, globalNormalColor);
            }
        }

        private List<(string, string)> GetCritterEquipmentFiles()
        {
            return GetCritterEquipmentFiles("res://wayward_soul");
        }

        private List<(string, string)> GetCritterEquipmentFiles(string path)
        {
            var files = new List<(string, string)>();
            var dir = DirAccess.Open(path);
            dir.ListDirBegin();
            var fileName = dir.GetNext();
            while(fileName != "")
            {
                var filePath = path + "/" + fileName;
                if (dir.CurrentIsDir())
                {
                    files.AddRange(GetCritterEquipmentFiles(filePath));
                }
                else
                {
                    if (fileName.EndsWith(".tres") && !fileName.Equals(NONE_CRITTER))
                    {
                        files.Add((filePath, fileName.Replace(".tres","")));
                    }
                }
                fileName = dir.GetNext();
            }
            return files;
        }
    }
}
  1. Playing
    Arranging the transform and frame number for the CUSTOM_DATA
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void UpdateGPUData()
{
    CritterGPUPayloadInstanceData.Transform = Transform;

    var globalAnimationFrame = Access.CritterAnimationCumulativeFrameIndex(CurrentAnimation) + AnimationFrame;

    CritterGPUPayloadInstanceData.FrameIndex = Mathf.FloorToInt(globalAnimationFrame);
}

Uploading transform and custom data to multimesh, and limiting the number of visible instances

for (var j = 0; j < NumThreads; j++)
{
    var model = CritterGPUPayloadCollection.AcquireForThread(j).Models[meshIndex];
    var data = model.Data;
    var start = modelInstanceCount;
    var numInstancesForModel = model.InstancesCount;
    for (var k = 0; k < numInstancesForModel; k++)
    {
        RenderingServer.MultimeshInstanceSetTransform(critterMeshRid, start + k, data[k].Transform);
        RenderingServer.MultimeshInstanceSetCustomData(critterMeshRid, start + k, data[k].CustomData);
    }

    modelInstanceCount += numInstancesForModel;
}
RenderingServer.MultimeshSetVisibleInstances(critterMeshRid, modelInstanceCount);

Shader for MultiMeshInstance3D

shader_type spatial;

#define USE_ALPHA 0
#define USE_ALPHA_CUTOFF 0
#define USE_EMISSION 0
#define USE_REFLECTIONS 0
#define USE_NORMAL_MAP 0
#define USE_OCCLUSION 0
#define USE_ANISOTROPY 0
#define USE_BACKLIGHT 0
#define USE_REFRACTION 0

#if USE_ALPHA
render_mode depth_draw_always;
#endif

//#include "includes/base-cel-shader.gdshaderinc"

#if USE_EMISSION
#include "includes/emission.gdshaderinc"
#endif

#if USE_REFLECTIONS
#include "includes/reflections.gdshaderinc"
#endif

#if USE_NORMAL_MAP
#include "includes/normal-map.gdshaderinc"
#endif

#if USE_OCCLUSION
#include "includes/occlusion.gdshaderinc"
#endif

#if USE_ANISOTROPY
#include "includes/anisotropy.gdshaderinc"
#endif

#if USE_BACKLIGHT
#include "includes/backlight.gdshaderinc"
#endif

#if USE_REFRACTION
#include "includes/refraction.gdshaderinc"
#elif !USE_REFRACTION && USE_ALPHA
#include "includes/transparency.gdshaderinc"
#endif

group_uniforms BaseProperties;
#if USE_ALPHA_CUTOFF
uniform float alpha_cutoff: hint_range(0.0, 1.0) = 0.5;
#endif

uniform float vertex_count;
uniform float frame_count;
uniform sampler2D gpu_animation_vertex: hint_default_white;
uniform sampler2D gpu_animation_normal: hint_default_white;
uniform sampler2DArray gpu_animation_texture;

void vertex() {
	
	float pixel = 1.0 / vertex_count;
	float half_pixel = pixel * 0.5;
	float frame = 1.0 / frame_count;
	float half_frame = frame * 0.5;

	int span_r = floatBitsToInt(INSTANCE_CUSTOM.r);
	float frame_number = float(span_r & 0xFFFF);

	float x = frame_number / frame_count;
	float y = float(VERTEX_ID) / vertex_count;
	vec2 offset = vec2(half_frame, half_pixel);
	vec2 coord = (vec2(x,y) + offset);

	vec4 vertex_color = texture(gpu_animation_vertex, coord);
	VERTEX = (vertex_color.xyz - 0.5) * 2.0;

	vec4 normal_color = texture(gpu_animation_normal, coord);
	NORMAL = (normal_color.xyz - 0.5) * 2.0;

	float tintR = float((span_r >> 24) & 0xFF);

	int span_g = floatBitsToInt(INSTANCE_CUSTOM.g);
	float tintG = float(span_g & 0xFF);
	float tintB = float((span_g >> 8) & 0xFF);

	vec3 tint = vec3(tintR / float(255), tintG / float(255), tintB / float(255));

	float texture_index = float((span_r >> 16) & 0xFF);

	COLOR.rgba = vec4(tint, texture_index);
}
void fragment() {
	float texture_index = COLOR.a;

	ALBEDO = COLOR.rgb * texture(gpu_animation_texture, vec3(UV, texture_index)).rgb;
#if USE_ALPHA
	float alpha = color.a * texture(base_texture, UV).a;
	ALBEDO *= alpha;
#elif USE_ALPHA_CUTOFF
	ALPHA = color.a * texture(base_texture, UV).a;
	ALPHA_SCISSOR_THRESHOLD = color.a * texture(base_texture, UV).a;
#endif
}
}

If this enhancement will not be used often, can it be worked around with a few lines of script?

This feature requires some trial and error to implement the first time, costing developers many weeks for a feature that is common in game requiring many animated 3D meshes

Is there a reason why this should be core and not an add-on in the asset library?

This provides a high requested feature as seen from these posts:

https://forum.godotengine.org/t/how-to-instance-animations/46857
https://godotforums.org/d/19323-anyone-have-luck-with-implementing-gpu-instancing
https://www.reddit.com/r/godot/comments/8d54yy/anyone_have_luck_with_implementing_gpu_instancing/
https://www.reddit.com/r/godot/comments/11d0iot/15000_zombies_rendered_in_godot_on_my_macbook/

The author of the last one actually managed to implement it, but hasn't shared implementation details yet, leaving curious developers in the dark. So this commonly requested feature, which is tooling to easily manage GPU vertex animations to animate tens of thousands of 3D meshes, doesn't exist at the moment. However, if it does, then developers can eliminate weeks of development time to leverage the freedom that comes with being able to animate huge numbers of 3D meshes.

This feature could also be a core feature because it relies entirely on existing Godot public API (basically, no internal calls to the engine's C++ code). This makes maintenance quite easy since nothing fancy is being done.

@KontosTwo
Copy link
Author

Here's a better illustration of how the public API could work once the vertex and normal arrays, and the texture variations have been baked

gpuanimation drawio

@KontosTwo
Copy link
Author

I've modified this design in my game project due to the severe overhead of MultiMeshInstance3D.SetInstanceTransform and its Color and CustomData variants. While the design in the initial proposal works reasonably well, it shows its limits once around 145,000 actors as seen in this demo. The CPU time spikes above 33ms, with around 16ms being spent in this chunk of code

RenderingServer.MultimeshInstanceSetTransform(critterMeshRid, start + k, data[k].Transform);
RenderingServer.MultimeshInstanceSetCustomData(critterMeshRid, start + k, data[k].CustomData);

This is admittedly expected given that setting any data in the MultiMesh API modifies GPU state, which not only entails the overhead of transferring bytes, but also that of interrupting the GPU's work. A single one of these calls is trivial, but hundreds of thousands of them quickly reaches a limit. Thus, the solution is to figure out a way to upload all MultiMesh data in a single GPU state change, and that is using ImageTextures and Images along with MultiMeshes.

The key is that ImageTexture's update method counts as a single GPU state change. That means that changing the GPU state of a MultiMesh and its hundreds of thousands of instances has the effective overhead of just the CPU to GPU transfer rate. An average machine with 16GB per second would mean that 16MB of GPU state change costs just 1ms. Which means updating 1 million custom data per frame (remember that custom data is used to store animation frame data) costs 1ms. And updating 1 million transforms per frame costs 4ms.

To illustrate one such application of this, see this diagram

Image

This diagram also shoehorns in an "equipment variation" scheme, but if that's considered out of scope, then focus only on a single model. For a single animated model per actor, there's two ImageTextures. One holding transforms, and one holding a single float as the index to the particular transform. Use the image format that consumes the least amount of bytes. Then, every time an actor wants to render itself and all its "equipment variations", set the corresponding transform data on the next Image pixel, and for each Image representing a model like Body, Sword, Shield, etc, add a pixel with the index to that transform data.

This setup not only minimizes the amount of bytes transferred to the GPU (all the animated models share the actor's transform), but the overhead of GPU state changes is reduced from O(n) to just O(1) thanks to the ImageTexture.Update(Image) method. GPU transfer time for 288,000 actors in my latest build takes 3ms, compared to 16ms for 144,000. Once the data is in these textures, accessing it is trivial in whatever shader you implement. Here's my implementation that relies on a super minimal transform consisting of a Vector3 position and a single float representing 2D rotation.

uniform int x_size;
uniform float x_size_float;
uniform float y_size_transforms;
uniform float y_size_colors;
uniform sampler2D transforms: repeat_disable;
uniform vec2 transforms_offset;
uniform sampler2D colors: repeat_disable;
uniform vec2 colors_offset;

mat4 transform(int instance_id){
	float x = float(instance_id % x_size) / x_size_float;
	float y = float(instance_id / x_size) / y_size_transforms;

	vec2 coord = vec2(x, y) + transforms_offset;
	vec4 color  = texture(transforms, coord);
	float angle = (2.0 * PI) - color.a;
	float angle_sin = sin(angle);
	float angle_cos = cos(angle);

	mat4 local_to_world = mat4(
		vec4(angle_cos, 0.0, angle_sin, 0.0),
		vec4(0.0, 1.0, 0.0, 0.0),
		vec4(-angle_sin, 0.0, angle_cos, 0.0),
		vec4(color.xyz, 1.0)
	);

	return local_to_world;
}


mat4 transform_scale_2d(int instance_id, float width, float height){
	float x = float(instance_id % x_size) / x_size_float;
	float y = float(instance_id / x_size) / y_size_transforms;

	vec2 coord = vec2(x, y) + transforms_offset;
	
	vec4 transform  = texture(transforms, coord);
	float angle = (2.0 * PI) - transform.a;
	float angle_sin = sin(angle);
	float angle_cos = cos(angle);

	mat4 scale = mat4(
		vec4(width, 0.0, 0.0, 0.0),
		vec4(0.0, 1.0, 0.0, 0.0),
		vec4(0.0, 0.0, height, 0.0),
		vec4(0.0, 0.0, 0.0, 1.0)
	);

	mat4 rotation = mat4(
		vec4(angle_cos, 0.0, angle_sin, 0.0),
		vec4(0.0, 1.0, 0.0, 0.0),
		vec4(-angle_sin, 0.0, angle_cos, 0.0),
		vec4(0.0, 0.0, 0.0, 1.0)
	);
	
	mat4 translation = mat4(
		vec4(1.0, 0.0, 0.0, 0.0),
		vec4(0.0, 1.0, 0.0, 0.0),
		vec4(0.0, 0.0, 1.0, 0.0),
		vec4(transform.xyz, 1.0)
	);
	
	mat4 local_to_world = translation * rotation * scale;
	
	return local_to_world;
}

mat4 transform_billboard(int instance_id, mat3 camera_basis){
	float x = float(instance_id % x_size) / x_size_float;
	float y = float(instance_id / x_size) / y_size_transforms;

	vec2 coord = vec2(x, y) + transforms_offset;
	vec4 color  = texture(transforms, coord);

	mat4 local_to_world = mat4(camera_basis);
	local_to_world[3] = vec4(color.xyz, 1.0);

	return local_to_world;
}

vec4 color(int instance_id){
	float x = float(instance_id % x_size) / x_size_float;
	float y = float(instance_id / x_size) / y_size_colors;
	vec2 coord = vec2(x, y) + colors_offset;
	return texture(colors, coord);
}

It's a small shader library that reads from various ImageTexture input and converts it to Transforms, frame data, anything. Note that this means that users aren't just limited to Transforms, Color, and CustomData anymore like in vanilla MultiMeshes - you can have any reasonable number of additional data to supply to a shader.

Now, the resources for this feature are:
AnimatedMeshConfig - holds a mesh, all texture variations, and an animationplayer
AnimatedMeshBaker - converts the config into vertex and normal textures, and also has a helper method to assemble all relevant textures into a texturearray. In my latest build, only the bare minimum number of texture variations, instead of all possible ones per model, are used, to avoid wasting GPU space
AnimatedMeshInstance - has MultiMeshInstance3D as a child, and has the usual PlayAnimation(int animationIndex) and AddInstance(Transform3D) methods, but also a Submit() method that makes the sole GPU state change.
AnimatedActorMeshInstance - if you want a an equipment variation scheme like in this earlier demo, this node will separate the AddInstance(Transform3D) method into an AddActor(Transform3D) and an AddModel(int modelIndex) that operates on the current index of the actor

@KontosTwo KontosTwo changed the title Scripts to bake and play tens of thousands of GPU vertex animations Scripts to bake and play hundreds of thousands of GPU vertex animations Feb 8, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

2 participants