readFmat function
Reads a .fmat material.
JSON, and text on purpose. A material is a few hundred bytes that an artist edits between two runs of the game and that shows up in a diff when the look of something changes; a binary container would buy nothing here and cost both. Models are the other case and have their own container.
Unknown top-level keys are recorded in MaterialDocument.warnings rather
than thrown on: a file written by a newer tool should still load, minus what
this version does not understand, and a hand-edited file with roughnesss
in it should say so rather than quietly take the default. That is not in
tension with the version gate below, which refuses a whole newer fmat
number outright: a bumped version is the writer declaring that the
difference matters, and an extra key in a file that still calls itself
version 1 is the writer saying it does not.
Two namespaces are deliberately open and so are never warned about. A
texture slot the SurfaceMaterial does not name becomes an entry in
MaterialDocument.extraTextures, and a key under parameters becomes a
uniform: both exist so a custom shader can ask for something this engine
has never heard of.
Implementation
MaterialDocument readFmat(Uint8List bytes, {String name = ''}) {
final Object? parsed = json.decode(utf8.decode(bytes));
if (parsed is! Map<String, Object?>) {
throw FormatException('$name is not a JSON object');
}
final version = (parsed['fmat'] as num?)?.toInt();
if (version == null) {
throw FormatException('$name has no "fmat" version key');
}
if (version > kFmatVersion) {
throw FormatException(
'$name is version $version and this engine reads $kFmatVersion. Newer '
'material files are not read as older ones, because the difference '
'between the two versions is precisely what would be silently dropped.',
);
}
// Exactly the keys `writeFmat` emits, so the two halves of the format
// cannot drift: a key added to the writer and not to this set warns on the
// writer's own output, which `fmat_test.dart`'s round trip catches.
const knownKeys = <String>{
'fmat',
'name',
'lighting',
'lightingModel',
'baseColor',
'metallic',
'roughness',
'normalScale',
'occlusionStrength',
'emissive',
'emissiveStrength',
'alphaMode',
'alphaCutoff',
'doubleSided',
'unlit',
'textures',
'parameterBlock',
'parameters',
'hints',
'extensions',
};
final warnings = <String>[
for (final key in parsed.keys)
if (!knownKeys.contains(key))
'"$key" is not a key this reader knows; ignored',
];
final images = <String>[];
final texturePaths = <String, int>{};
/// Interns [path] and returns the index [TextureBinding] addresses it by.
int imageIndex(String path) =>
texturePaths[path] ??= (images..add(path)).length - 1;
TextureBinding? binding(Object? value) {
if (value == null) return null;
if (value is String) return TextureBinding(imageIndex: imageIndex(value));
if (value is! Map<String, Object?>) {
warnings.add('a texture slot is neither a path nor an object; ignored');
return null;
}
final path = value['path'];
if (path is! String) {
warnings.add('a texture slot has no "path"; ignored');
return null;
}
return TextureBinding(
imageIndex: imageIndex(path),
sampling: _readSampling(value),
);
}
final textures =
parsed['textures'] as Map<String, Object?>? ?? const <String, Object?>{};
// The slots [SurfaceMaterial] has a field for; everything else under
// `textures` is an extra, on purpose, and so is never a warning.
const knownSlots = <String>{
'albedo',
'normal',
'metallicRoughness',
'occlusion',
'emissive',
};
final surface = surfaceMaterialFromJson(
parsed,
name: name.isEmpty ? null : name,
warnings: warnings,
baseColorTexture: binding(textures['albedo']),
metallicRoughnessTexture: binding(textures['metallicRoughness']),
normalTexture: binding(textures['normal']),
occlusionTexture: binding(textures['occlusion']),
emissiveTexture: binding(textures['emissive']),
// glTF's own shape, with a `.fmat` slot — a path or an object — wherever
// glTF puts a texture info, so an extension reads the same here as in the
// file it was imported from.
extensions: switch (parsed['extensions']) {
final Map<String, Object?> layers => materialExtensionsFromJson(
layers,
texture: binding,
warnings: warnings,
where: name.isEmpty ? 'this material' : name,
),
_ => null,
},
);
return MaterialDocument(
surface: surface,
images: images,
lighting: _readLighting(parsed['lighting'], warnings),
parameterBlock: parsed['parameterBlock'] as String? ?? 'MaterialParams',
parameters: <String, Float32List>{
for (final entry
in (parsed['parameters'] as Map<String, Object?>? ??
const <String, Object?>{})
.entries)
entry.key: _floats(entry.value),
},
hints: _readHints(parsed['hints'], warnings),
extraTextures: <String, TextureBinding>{
for (final entry in textures.entries)
if (!knownSlots.contains(entry.key))
if (binding(entry.value) case final TextureBinding slot)
entry.key: slot,
},
warnings: warnings,
);
}