You do not need a package to read VRM metadata in Flutter. A VRM file is a glTF 2.0 GLB: a 12-byte header, then a JSON chunk carrying the model name, author, license, expressions, mesh list and the index of the thumbnail, then a BIN chunk carrying the thumbnail bytes. The one Dart library that exists, gatari/vrm_dart, throws LateInitializationError on every VRM 1.0 file and, on VRM 0.x, returns a null thumbnail unless the image happens to be named "Thumbnail". The parser I ended up with is 101 lines of dart:convert and dart:typed_data and reads all of it on both versions.
This is a full rewrite of a post I wrote in December 2024. It is for anyone who needs to show a VRM model's name, author and thumbnail inside a Flutter app. On 2026-09-22, on macOS Apple Silicon with Flutter 3.47.2 and Dart 3.13.2, I ran vrm_dart and a hand-written parser against five sample files, then dropped the parser into a Flutter package and ran flutter test.
The problem
Feed a VRM 1.0 file to vrm_dart exactly the way the 2024 post did. parse() returns, and the first access to the metadata throws:
== Seed-san.vrm (10917800 B)
parse(): ok
vrmMeta access: LateError: LateInitializationError: Field 'vrmMeta' has not been initialized.
thumbnailByteData: null
The 2024 situation was this. The project had a Unity viewport and a Flutter UI, and the model list screen lived on the Flutter side, so the Flutter side had to read VRM metadata on its own. Unity has UniVRM and Unreal has VRM4U; Flutter had nothing. What I found was gatari/vrm_dart, a repository never published to pub. I copied two files into the project, and these three lines got me the thumbnail and part of the metadata:
final vrmByteData = await File(path).readAsBytes();
final vrmParser = VrmFileParser(ByteData.sublistView(Uint8List.fromList(vrmByteData)));
vrmParser.parse();
The post ended with the regret that BlendShapeGroup and the mesh list were out of reach. Since then the ground has moved. VRoid Studio exports VRM 1.0 from v1.20.0, released 2023-03 (checked 2026-09-22), so 1.0 files are now common. vrm_dart has had no commits since 2021-10-13, and a pub.dev search for vrm still returns zero VRM packages.
VRM 0.x files trip it up in two more ways. A file with no images at all, like the add-on test fixture minimal.vrm, dies inside parse():
== minimal.vrm (4660 B)
parse(): _TypeError: type 'Null' is not a subtype of type 'List<dynamic>' in type cast
And a 0.x file that does have a thumbnail still comes back with thumbnailByteData null when the image is not named "Thumbnail", even though the metadata's texture field points straight at it.
What the measurements show
vrm_dart only ever looks at the extension named VRM. VRM 1.0 stores everything under VRMC_vrm, with a different metadata schema and different names for the main fields: title became name, author became authors, licenseName became licenseUrl, and the thumbnail moved from texture to thumbnailImage. With no branch for that, the late field vrmMeta is never assigned, and the first read throws.
Start with the file structure. A VRM is a GLB, the binary container for glTF 2.0. The first 12 bytes are the header: the magic glTF, version 2 and the total length, each a little-endian uint32. Chunks follow. Each chunk is a length and a type, 4 bytes each, then the payload. The standard chunk types are JSON and BIN; unknown types are skipped. All five sample files had chunk lengths that were multiples of 4. minimal.vrm has a single JSON chunk and no BIN chunk at all.
The VRM data lives under extensions in the JSON chunk, and that is where 0.x and 1.0 diverge:
| Item | VRM 0.x | VRM 1.0 |
|---|---|---|
| Extension name | VRM |
VRMC_vrm |
| Model name | meta.title |
meta.name |
| Author | meta.author |
meta.authors (array) |
| License | meta.licenseName |
meta.licenseUrl |
| Thumbnail |
meta.texture = index into textures[]
|
meta.thumbnailImage = index into images[]
|
| Expressions | blendShapeMaster.blendShapeGroups[] |
expressions.preset / expressions.custom
|
| Meshes |
meshes[] (plain glTF) |
meshes[] (plain glTF) |
The thumbnail path is the sharpest difference. The 0.x schema defines texture as "Thumbnail of VRM model", and because it is a texture index you go through textures[i].source before you reach images[]. The 1.0 schema defines thumbnailImage as "The index to the thumbnail image of the model in gltf.images" (checked 2026-09-22). In 1.0, name, authors and licenseUrl are required.
vrm_dart takes neither path. It searches the images array for an entry named "Thumbnail", so any other name is a miss, and it never reads thumbnailImage at all. The third problem is casting: the generated code casts images and bufferViews to non-null lists, which is where _TypeError comes from on a file that has neither key.
I compared the two parsers on five samples. For 0.x, the CC0 add-on test fixtures minimal.vrm and triangle.vrm. There is no public 0.x sample with a thumbnail, so I built two with Blender 5.2.2 LTS and VRM add-on 4.7.1, attaching a 64×64 PNG to triangle.vrm: one with the image named "Thumbnail", one named "swas_thumb". For 1.0, VirtualCast's Seed-san.vrm.
| Sample | Version | vrm_dart meta | vrm_dart thumbnail | Direct parser |
|---|---|---|---|---|
| minimal.vrm | 0.x | _TypeError |
_TypeError |
meta, 17 expressions |
| triangle.vrm | 0.x | _TypeError |
_TypeError |
meta, 17 expressions, 1 mesh |
| triangle-thumb-Thumbnail.vrm | 0.x | OK | 317 B | meta, thumbnail, 17 expressions, 1 mesh |
| triangle-thumb-swas_thumb.vrm | 0.x | OK | null | meta, thumbnail, 17 expressions, 1 mesh |
| Seed-san.vrm | 1.0 | LateInitializationError |
null | meta, thumbnail, 18 expressions, 5 meshes |
vrm_dart reads exactly one row completely: the 0.x file whose image is named "Thumbnail". That is presumably the kind of file the 2024 post happened to succeed on. The direct parser reads all five. Seed-san's thumbnail came out as a 512×512 PNG of 192,765 bytes, and its meshes are hair, hair_tail, head, robo_arm and wear.
Expression names changed between versions too. The 1.0 spec's expressions document lists the renames (checked 2026-09-22), and the 17 names in the 0.x samples and 18 in the 1.0 sample match this table:
| VRM 0.x | VRM 1.0 | Note |
|---|---|---|
| joy | happy | renamed |
| sorrow | sad | renamed |
| fun | relaxed | renamed |
| a, i, u, e, o | aa, ih, ou, ee, oh | lip sync |
| blink_l, blink_r | blinkLeft, blinkRight | spelling |
| lookup, lookdown, lookleft, lookright | lookUp, lookDown, lookLeft, lookRight | spelling |
| none | surprised | new in 1.0 |
What to do
- Read the GLB header and chunks.
ByteDatafromdart:typed_datais enough, and the JSON chunk goes throughutf8.decodethenjsonDecode:
final d = ByteData.sublistView(bytes);
if (d.getUint32(0, Endian.little) != 0x46546C67) throw const FormatException('not a GLB');
final length = d.getUint32(8, Endian.little);
Map<String, dynamic>? json; Uint8List? bin;
var off = 12;
while (off + 8 <= length) {
final len = d.getUint32(off, Endian.little);
final type = d.getUint32(off + 4, Endian.little);
final data = Uint8List.sublistView(bytes, off + 8, off + 8 + len);
if (type == 0x4E4F534A) json = jsonDecode(utf8.decode(data)); // 'JSON'
if (type == 0x004E4942) bin = data; // 'BIN'
off = (off + 8 + len + 3) & ~3;
}
final root = json ?? (throw const FormatException('no JSON chunk'));
- Tell the versions apart from
extensions.VRMC_vrmpresent means 1.0,VRMmeans 0.x, and neither means it is a plain glTF, not a VRM:
final ext = (root['extensions'] as Map<String, dynamic>?) ?? const <String, dynamic>{};
final is10 = ext.containsKey('VRMC_vrm');
if (!is10 && !ext.containsKey('VRM')) {
throw const FormatException('glTF but neither VRM (0.x) nor VRMC_vrm (1.0) extension');
}
final v = (is10 ? ext['VRMC_vrm'] : ext['VRM']) as Map<String, dynamic>;
final meta = v['meta'] as Map<String, dynamic>;
Read the metadata with the version's field names:
title,authorandlicenseNamefor 0.x;name,authorsandlicenseUrlfor 1.0. If a 1.0 file'screditNotationisrequired, the specification obliges whoever uses the model to display its credit. It does not say where or in what form, so that part is your app's decision.Resolve the thumbnail along the two separate paths. The image bytes are a slice of the BIN chunk given by
byteOffsetandbyteLengthofbufferViews[i]:
Uint8List bufferView(int i) {
final bv = (root['bufferViews'] as List)[i];
final o = (bv['byteOffset'] ?? 0) as int;
return Uint8List.sublistView(bin!, o, o + (bv['byteLength'] as int));
}
int? imageIndex;
if (is10) {
imageIndex = meta['thumbnailImage'] as int?;
} else if (meta['texture'] is int && meta['texture'] >= 0) {
imageIndex = (root['textures'] as List)[meta['texture']]['source'] as int?;
}
final image = imageIndex == null ? null : (root['images'] as List)[imageIndex];
final thumbnail = image?['bufferView'] == null ? null : bufferView(image['bufferView'] as int);
Walk expressions and meshes with the right keys. On 0.x it is
presetNameandnameinsideblendShapeMaster.blendShapeGroups; on 1.0 it is the keys ofexpressions.presetandexpressions.custom. Meshes are plain glTF:nameandprimitives.lengthfrommeshes[].Keep
dart:ioout of the parser when it goes into a Flutter package. Take a singleUint8List, so a file path,rootBundleandfile_pickerbytes all go through the same function. I put it into aflutter create --template=packageproject this way; all 4 tests passed and the dependency count added is zero.Treat image-less files as normal.
imagesandbufferViewsmay be absent, and the BIN chunk may be absent too. The code above lets all of that fall through asnull.
The Blender side of this is written up separately in Blender 5.x and the VRM add-on: 5.2 LTS needs 4.4.0+, 5.3 alpha blocks even 4.7.1. The 0.x thumbnail samples in this post were built with that post's setup.
Images: both are the measurement logs (evidence/parse-glb.log and evidence/vrm-dart-legacy.log) redrawn as a table and a console view. The 1.0 sample is Seed-san by VirtualCast, Inc., under the VRM Public License 1.0.
Takeaways
Skip the package search: the VRM metadata you want is one JSON chunk behind a 12-byte header, and the only real work is branching on VRM versus VRMC_vrm. If you must keep vrm_dart, know that it only works on 0.x files whose thumbnail image is literally named "Thumbnail".
Unverified
- Whether 0.x files exported by VRoid Studio or UniVRM actually name the thumbnail image "Thumbnail": the file the 2024 post read no longer exists, and this post's 0.x thumbnail samples were made with the Blender add-on.
- The
dart:io-free parser on mobile and web Flutter, fed throughrootBundleorfile_picker: I only ranflutter teston the host VM. - Malformed files whose JSON chunk is not padded to 4 bytes: all five samples were multiples of 4. The code rounds the next chunk offset up to a multiple of 4, but I did not test such a file.
Changelog
| Version | Description |
|---|---|
| 1.0 | 2024-12-03 original Korean post (partial VRM 0.x meta and thumbnail via vrm_dart) |
| 2.0 | 2026-09-22 full rewrite (vrm_dart reproduced on Dart 3.13.2, zero-dependency direct parser measured on 5 samples across 0.x and 1.0, 4 flutter tests) |


Top comments (0)