Vertex Animation Textures in Roblox: Bringing Facial and Creature Animation to Horror Games

Have you ever sculpted a perfect snarl in Blender with shape keys, exported the FBX, and watched your creature arrive in Roblox Studio with a frozen, expressionless face? If you build horror experiences, you already know that a jaw that unhinges or a ribcage that breathes is often what sells the scare.
The good news is that baked vertex animation gives you a dependable route around that wall. This guide walks through the bake in Blender, the texture import and the Luau playback script — and it is upfront about where the Roblox version of the technique differs from what you may know from Unreal or Unity.
Why Shape Keys Stop At The Import Dialog
The Roblox mesh importer understands skinned meshes and bones, and it understands one specific family of blend shapes: the FACS poses that drive Dynamic Heads through the FaceControls instance. Arbitrary shape keys on a creature body, a tentacle or a non-humanoid face do not come along as runtime-driveable deformation.
That said, the FACS path is worth a look before you build anything custom. If your monster has a roughly human face, naming your shape keys to the FACS convention may get you most of the way, and our Roblox character animation guide covers how those rigs behave in Studio.
Roblox imports bones and FACS-named head blend shapes, but not arbitrary shape keys. Deformation outside the FACS set — a splitting jaw, a pulsing sac, a tentacle — has to be baked and played back another way.
What A Vertex Animation Texture Actually Is
A vertex animation texture, or VAT, stores the position of every vertex at every frame of an animation as pixel colors. Each column maps to a vertex, each row maps to a frame, and the red, green and blue channels hold the X, Y and Z position normalized against the animation's bounding box.
In Unreal or Unity, a vertex shader samples that texture on the GPU and moves the vertices at almost no cost. Roblox does not expose custom shaders, so the decoding has to happen in Luau on the CPU, and the result is written into an EditableMesh.
Keep in mind that this changes the cost profile considerably. GPU VAT is nearly free per instance, whereas the Roblox version scales with vertex count multiplied by the number of animated creatures on screen — which is why the performance section later in this guide matters as much as the bake itself.
A VAT is an image where each column is a vertex, each row is a frame and RGB holds position. Roblox has no custom shaders, so Luau decodes the pixels and writes them into an EditableMesh.
Before committing to the technique, it helps to see how it compares with the other deformation options Roblox offers:
| Approach | Best For | Runtime Cost | Main Limitation |
|---|---|---|---|
| Bones and skinning | Limbs, torsos, broad body motion | Low | Volume changes and organic squash need many bones |
| Dynamic Heads (FaceControls) | Humanoid faces | Low | Limited to FACS poses on heads |
| VAT via EditableImage and EditableMesh | Creature faces, organic body deformation | CPU, scales with vertices | Vertex and memory budgets, client-only playback |
| Baked buffer in a ModuleScript | Short clips, small meshes | CPU, same as VAT | Larger script assets, no image tooling |
All of these can coexist on one creature. A practical horror setup skins the body with bones and reserves baked vertex animation for the single feature the player is meant to stare at.
Step 1 — Prepare The Mesh In Blender
Start with a mesh you would be comfortable animating on a phone. For CPU playback, aim for roughly 1,000 to 3,000 vertices on the deforming part, and split the head or deforming feature into its own object so the rest of the creature can stay on a normal skinned rig.
Next, apply every modifier except the ones that drive the motion, and apply all transforms. If you are newer to the modifier stack, our beginner-friendly Blender guide walks through applying transforms and modifiers safely.
Finally, triangulate the mesh yourself with a Triangulate modifier and apply it. Roblox triangulates on import anyway, and doing it first means the vertex layout you bake is closer to the one that arrives in Studio.
Step 2 — Bake The Vertex Positions
Tools such as the open-source OpenVAT add-on for Blender, or the SideFX Labs VAT exporter if your team works in Houdini, sample every vertex at every frame and write the result to an image. You can also write the bake yourself with a short Python script that evaluates the dependency graph per frame and records each vertex's position.
Whichever tool you use, record three things alongside the image: the bounding box minimum, the bounding box maximum and the frame count. Your playback script needs all three to turn colors back into studs.
Precision And Layout
An 8-bit channel gives you 256 steps across the bounding box, so a creature head that deforms across 2 studs resolves to roughly 0.008 studs per step. That is usually fine for a breathing sac, but fine facial creases can visibly stair-step.
For 16-bit precision, store the high byte and the low byte of each axis in two adjacent texels. This doubles the width per vertex, but it lifts precision to about 65,000 steps and removes the stepping almost entirely.
Roblox downscales uploaded images larger than 1,024 pixels on a side, so plan the layout within 1,024 by 1,024. If your vertex count exceeds the row width, wrap each frame across multiple rows and store the rows-per-frame value in your metadata.
Use 8-bit channels for broad motion and split each axis into high and low bytes across two texels for faces. Keep the image within 1,024 by 1,024, since Roblox downscales anything larger.
Export Settings
Export the image as a lossless PNG with no color management applied — set the view transform to Standard and make sure nothing converts it to sRGB. A single gamma conversion will bend every position in the clip.
Also export the rest pose, frame zero, as an FBX or OBJ. This becomes the mesh you upload, and its vertex positions are what you will use to match vertices in Step 4.
Step 3 — Import The Texture And Mesh
Upload the PNG through the Asset Manager or Creator Hub as an image, and upload the rest-pose mesh as a model. Note the asset IDs for both.
Before you build anything on top of the import, verify the round trip. Load the image with AssetService:CreateEditableImageAsync, read a handful of known pixels with ReadPixelsBuffer, and compare them against your source file; any processing during upload shows up immediately as a mismatch.
Be aware that the Editable APIs carry permission and memory rules. Loading an asset into an EditableImage or EditableMesh generally requires that the experience owner also owns the asset, and creation can fail when a device exhausts its editable memory budget — so wrap every create call in pcall and check the current Creator Hub documentation for the rules that apply to your experience.
Upload the PNG and the rest-pose mesh separately, then confirm a few pixels read back exactly before writing playback code. Wrap every Editable create call in pcall, since memory budgets can make it fail.
Step 4 — Rebuild The Vertex Map
This is the step that trips up most first attempts. Roblox splits vertices at UV seams and hard edges and can reorder them on import, so EditableMesh vertex IDs will not line up with Blender's vertex indices.
The fix is to match by position. Load the mesh with AssetService:CreateEditableMeshAsync, call GetVertices, read each vertex's rest position with GetPosition, and find the baked column whose frame-zero position is closest.
A spatial hash makes this fast: round each frame-zero position to a small grid, such as 0.001 studs, and use the rounded value as a lookup key. Split vertices at seams share a position, so several Roblox vertices will correctly map to the same baked column.
Do this once per asset, not once per creature. Cache the resulting array of column indices in a module so every instance reuses it — a pattern covered in more depth in our Luau scripting patterns guide.
Roblox reorders and splits vertices on import, so match each EditableMesh vertex to the baked column with the closest rest position. Build that map once per asset and share it across every instance.
Step 5 — Write The Playback Script
Playback runs in a LocalScript, because EditableMesh changes do not replicate. The server's only job is to tell clients which clip to play and when it started.
Here's the sequence the script follows on every update:
- Read the clip clock. Subtract the clip's start time from
workspace:GetServerTimeNow(), multiply by the bake frame rate, and wrap the result by the frame count for looping clips. - Find the two frames. Take the floor of the frame value as frame A, add one for frame B, and keep the fractional remainder as the blend weight.
- Decode both rows. Read each vertex's bytes from the cached pixel buffer with
buffer.readu8, rebuild the normalized value, and scale it between the bounding box minimum and maximum. - Interpolate and write. Lerp between the frame A and frame B positions and pass the result to
SetPositionfor every mapped vertex ID.
Read the full image into a buffer once at load time rather than every frame. Calling ReadPixelsBuffer per frame repeats a copy you already have, and a decode loop over a plain buffer is far cheaper.
To display the result, create a MeshPart from the EditableMesh with AssetService:CreateMeshPartAsync and Content.fromObject, then weld it to the creature's head bone or attachment. Connect the update loop to RunService.PreRender so the deformation lands before the frame draws.
Run playback in a LocalScript on PreRender. Compute the frame from server time, decode two rows from a cached buffer, lerp between them and call SetPosition for each mapped vertex.
Normals
Moving vertices does not automatically correct their shading. For subtle motion, stale normals are rarely noticeable; for a jaw that swings wide open, bake normals into a second image with the same layout and write them with SetNormal, or accept faceted lighting as part of the creature's look.
Keeping It Fast Enough For Mobile
CPU vertex animation scales linearly, so 2,000 vertices on 10 visible creatures means 20,000 position writes per update. That figure is manageable on desktop and can be expensive on a mid-range phone, which is why the budget should be set before the art is final.
Here's how teams typically keep the cost in check:
- Distance throttling. Update nearby creatures every frame, mid-range creatures at 10 to 15 updates per second, and freeze anything farther away on its last pose.
- Visibility culling. Skip creatures outside the camera view entirely.
Camera:WorldToViewportPointgives a cheap on-screen check. - One active VAT per creature. Keep body motion on bones and reserve vertex animation for the face or the single feature the scare depends on.
- Pooled meshes. Reuse EditableMesh instances when creatures despawn rather than creating new ones, since each creation counts against the memory budget.
All of these are easier to tune with real numbers in hand. Our Roblox performance profiling guide shows how to isolate a custom update loop in the MicroProfiler so you can see exactly what the decode costs on each device.
Load timing matters too. Fetching the image and mesh before the creature's reveal, using the preloading approach in our asset streaming guide, keeps the first scare from stalling on a download.
Syncing Scares Across Players
Because each client animates its own copy, two players can see the same creature mid-snarl at slightly different moments unless playback is anchored to shared time. Sending a clip ID and a server timestamp through a RemoteEvent or an attribute keeps everyone within a frame or two of each other.
For jump scares, that sync is essential. Our Roblox replication guide covers what to replicate and what to compute locally, and the same principle applies here: replicate intent, never vertices.
Never replicate vertex data. Send a clip ID and a GetServerTimeNow timestamp, and let every client compute the same frame locally so a scare lands for all players at once.
When VAT Is The Wrong Tool
Baked animation plays back exactly what you baked, which makes it a poor fit for deformation that has to react to gameplay. A creature that bends around a doorway or collapses where it was struck is better served by bones or physics, and our Roblox ragdoll physics guide covers that reactive end of the spectrum.
Likewise, if the image pipeline keeps fighting you, you can store the same bytes in a buffer encoded as a string inside a ModuleScript. The decode loop is identical and the data is lossless by construction, at the cost of a larger script asset and no image tooling.
Practical Questions Before You Scale Up
Below are a few questions worth settling before you take the technique from one creature to a full bestiary.
Can I use the SideFX Labs exporter from Houdini instead of Blender?
Yes, as long as the playback script matches its layout. Labs exports often store offsets from the rest pose rather than absolute positions, so check which mode your export used and add the decoded value to the rest position when needed.
Can one image hold several clips?
Yes. Stack clips vertically in the same image and store each clip's starting row and frame count in your metadata module, so an idle breath, a snarl and a death spasm can share a single upload.
Does this hold up on low-end mobile devices?
It can, provided the vertex budget is small and distant creatures are throttled. Profile on the weakest device you support, and fall back to a static pose when an EditableMesh fails to create.
Bringing It Into Your Next Build
Start with one feature on one creature — a jaw, a throat sac or a twitching eyelid — and get the bake, the vertex map and the playback loop working end to end before expanding. Once that pipeline is proven, every new horror moment becomes a matter of animating in Blender and dropping in a new image.


