DEV Community

Ilan Kim
Ilan Kim

Posted on

Parsing VRM in Flutter without a package: vrm_dart fails on VRM 1.0 (LateInitializationError), so read the GLB chunks yourself

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
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

vrm_dart versus the direct parser across five VRM samples. vrm_dart reads everything only on the 0.x file whose image is named Thumbnail

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.

Console output from reading Seed-san.vrm with the direct parser: VRMC_vrm metadata, thumbnail, 18 expressions and 5 meshes

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

  1. Read the GLB header and chunks. ByteData from dart:typed_data is enough, and the JSON chunk goes through utf8.decode then jsonDecode:
   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'));
Enter fullscreen mode Exit fullscreen mode
  1. Tell the versions apart from extensions. VRMC_vrm present means 1.0, VRM means 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>;
Enter fullscreen mode Exit fullscreen mode
  1. Read the metadata with the version's field names: title, author and licenseName for 0.x; name, authors and licenseUrl for 1.0. If a 1.0 file's creditNotation is required, 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.

  2. Resolve the thumbnail along the two separate paths. The image bytes are a slice of the BIN chunk given by byteOffset and byteLength of bufferViews[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);
Enter fullscreen mode Exit fullscreen mode
  1. Walk expressions and meshes with the right keys. On 0.x it is presetName and name inside blendShapeMaster.blendShapeGroups; on 1.0 it is the keys of expressions.preset and expressions.custom. Meshes are plain glTF: name and primitives.length from meshes[].

  2. Keep dart:io out of the parser when it goes into a Flutter package. Take a single Uint8List, so a file path, rootBundle and file_picker bytes all go through the same function. I put it into a flutter create --template=package project this way; all 4 tests passed and the dependency count added is zero.

  3. Treat image-less files as normal. images and bufferViews may be absent, and the BIN chunk may be absent too. The code above lets all of that fall through as null.

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 through rootBundle or file_picker: I only ran flutter test on 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)