readFmat function

MaterialDocument readFmat(
  1. Uint8List bytes, {
  2. String name = '',
})

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,
  );
}