buildMaterials function

Future<void> buildMaterials({
  1. required BuildInput buildInput,
  2. required BuildOutputBuilder buildOutput,
  3. List<String>? materials,
  4. String bundleName = 'materials',
  5. String discoveryRoot = 'assets/',
  6. MaterialAssetMode assetMode = MaterialAssetMode.legacyOnly,
})

Compiles .fmat custom-material files into a Flutter GPU shader bundle plus a parameter-metadata sidecar, for use with ShaderMaterial / PreprocessedMaterial at runtime.

Call this from a consuming app's hook/build.dart, alongside buildScenes and buildShaderBundleJson:

import 'package:hooks/hooks.dart';
import 'package:flutter_scene/build_hooks.dart';

void main(List<String> args) {
  build(args, (config, output) async {
    await buildMaterials(
      buildInput: config,
      buildOutput: output,
      materials: ['assets/toon.fmat'],
    );
  });
}

Each path in materials is resolved relative to the package root. If materials is omitted, .fmat files under discoveryRoot (default assets/, the same root buildScenes discovers .glb sources under) are discovered automatically; set discoveryRoot to search a different directory. The produced bundle is written to build/shaderbundles/[bundleName].shaderbundle (one fragment entry per material, named by the material's name), and the combined parameter sidecar to build/shaderbundles/[bundleName].fmat.json. In MaterialAssetMode.legacyOnly, list both as assets in the app's pubspec. In DataAssets modes, the generated files are registered as DataAssets when the toolchain supports them.

The generated shaders #include flutter_scene's framework GLSL; this hook puts flutter_scene's shaders/ directory on impellerc's include path (via buildShaderBundleJson's includeDirectories), so no framework files are copied into the consumer's project.

Implementation

Future<void> buildMaterials({
  required BuildInput buildInput,
  required BuildOutputBuilder buildOutput,
  List<String>? materials,
  String bundleName = 'materials',
  String discoveryRoot = 'assets/',
  MaterialAssetMode assetMode = MaterialAssetMode.legacyOnly,
}) async {
  final dataAssetsAvailable = buildInput.config.buildDataAssets;
  if (assetMode == MaterialAssetMode.dataAssetsRequired &&
      !dataAssetsAvailable) {
    throw UnsupportedError(_dataAssetsUnavailableMessage);
  }

  final packageRoot = buildInput.packageRoot;
  final materialPaths =
      materials ??
      discoverFmatMaterials(packageRoot, discoveryRoot: discoveryRoot);
  if (materialPaths.isEmpty) {
    return;
  }

  // Locate flutter_scene's framework shader directory. flutter_scene has no
  // top-level `flutter_scene.dart` library, so resolve through this package's
  // `build_hooks.dart` (which always exists) and hop to the sibling `shaders/`.
  final frameworkLib = await Isolate.resolvePackageUri(
    Uri.parse('package:flutter_scene/build_hooks.dart'),
  );
  if (frameworkLib == null) {
    throw Exception(
      'buildMaterials could not resolve the flutter_scene package location.',
    );
  }
  final frameworkShaders = frameworkLib.resolve('../shaders/');

  // Generated GLSL and the synthesized manifest live under the package's build
  // directory; they are regenerated each run.
  final generatedDir = Directory.fromUri(
    packageRoot.resolve('build/fmat/$bundleName/'),
  );
  generatedDir.createSync(recursive: true);

  final bundleFile = File(
    packageRoot
        .resolve('build/shaderbundles/$bundleName.shaderbundle')
        .toFilePath(),
  );
  final sidecarFile = File(
    packageRoot
        .resolve('build/shaderbundles/$bundleName.fmat.json')
        .toFilePath(),
  );
  final indexFile = File(
    packageRoot
        .resolve('build/shaderbundles/$bundleName.index.json')
        .toFilePath(),
  );

  final sidecars = <String, Object?>{};
  final materialSources = <String, String>{};

  // Skip the whole compile when every source (.fmat files and the framework
  // GLSL they include) is unchanged since the outputs were produced, so a
  // hook rerun for an unrelated edit costs nothing here. A recorded compile
  // error always forces a rebuild (the marker must clear once the source is
  // fixed). Set FLUTTER_SCENE_DISABLE_BUILD_CACHE to always compile.
  final stampBuffer = StringBuffer(
    'rev=$buildCacheRevision fmat package=${buildInput.packageName} '
    'bundle=$bundleName',
  );
  for (final materialPath in materialPaths) {
    final hash = contentHash(
      File(packageRoot.resolve(materialPath).toFilePath()).readAsBytesSync(),
    );
    stampBuffer.write(' $materialPath=$hash');
  }
  for (final name in _frameworkShaderFiles) {
    final hash = contentHash(
      File(frameworkShaders.resolve(name).toFilePath()).readAsBytesSync(),
    );
    stampBuffer.write(' $name=$hash');
  }
  final stamp = stampBuffer.toString();
  final stampFile = File(
    packageRoot.resolve('build/shaderbundles/$bundleName.inputs').toFilePath(),
  );
  final wantIndex =
      dataAssetsAvailable && assetMode != MaterialAssetMode.legacyOnly;
  var fresh = isBuildCacheFresh(stampFile, stamp, [
    bundleFile,
    sidecarFile,
    if (wantIndex) indexFile,
  ]);
  if (fresh) {
    try {
      fresh = !sidecarFile.readAsStringSync().contains('#compile_error');
    } catch (_) {
      fresh = false;
    }
  }
  if (fresh) {
    _registerOutputs(
      buildInput: buildInput,
      buildOutput: buildOutput,
      assetMode: assetMode,
      dataAssetsAvailable: dataAssetsAvailable,
      bundleName: bundleName,
      bundleFile: bundleFile,
      sidecarFile: sidecarFile,
      indexFile: indexFile,
      writeIndex: false,
      sidecars: const {},
      materialSources: const {},
      packageRoot: packageRoot,
      materialPaths: materialPaths,
      frameworkShaders: frameworkShaders,
    );
    return;
  }

  // Compile and bundle, but tolerate a broken material when the previous
  // outputs exist: a `.fmat` edit with a shader error during hot reload then
  // keeps the last good shaders on screen (with the error reported) instead
  // of failing the whole build and taking the session down. A first build
  // with no previous output still fails with the real error.
  try {
    final manifest = <String, Object?>{};
    for (final materialPath in materialPaths) {
      if (!materialPath.endsWith('.fmat')) {
        throw Exception('Material files must end with ".fmat": $materialPath');
      }
      final materialUri = packageRoot.resolve(materialPath);
      final source = File(materialUri.toFilePath()).readAsStringSync();
      final compiled = compileFmat(source, fileName: materialPath);
      final entryName = compiled.material.name;

      if (manifest.containsKey(entryName)) {
        throw Exception(
          'Two materials in bundle "$bundleName" share the name "$entryName"; '
          'material names must be unique within a bundle.',
        );
      }

      final fragFileName = '$entryName.frag';
      File(
        generatedDir.uri.resolve(fragFileName).toFilePath(),
      ).writeAsStringSync(compiled.glsl);

      manifest[entryName] = <String, Object?>{
        'type': 'fragment',
        // impellerc resolves a bundle entry's `file` relative to the package
        // root (its working directory), so reference the generated shader from
        // there, not relative to the manifest.
        'file': 'build/fmat/$bundleName/$fragFileName',
      };
      sidecars[entryName] = compiled.sidecar;
      materialSources[entryName] = materialPath;
    }

    // Write the synthesized shader-bundle manifest next to the generated
    // shaders, so its `file` entries resolve relative to it.
    final manifestRelativePath =
        'build/fmat/$bundleName/$bundleName.shaderbundle.json';
    File(
      packageRoot.resolve(manifestRelativePath).toFilePath(),
    ).writeAsStringSync(const JsonEncoder.withIndent('  ').convert(manifest));

    // Snapshot the previous bundle so a failed compile that left a partial
    // file behind can be rolled back to the last good bundle.
    final previousBundleBytes = bundleFile.existsSync()
        ? bundleFile.readAsBytesSync()
        : null;
    try {
      // Compile, with flutter_scene's shaders/ on the include path so the
      // generated shaders' framework `#include`s resolve directly (no copies).
      await buildShaderBundleJson(
        buildInput: buildInput,
        buildOutput: buildOutput,
        manifestFileName: manifestRelativePath,
        includeDirectories: [frameworkShaders],
        // Match the engine bundle's GLES dialect (GLSL ES 3.00); the
        // framework radiance sampling these materials can `#include` uses
        // textureLod, which is not available in 1.00 without an extension.
        glesLanguageVersion: 300,
      );
    } catch (_) {
      // Roll back only when the failed compile actually disturbed the bundle;
      // an unnecessary rewrite changes the file's timestamp mid-build, which
      // the tool flags as a modified file and reruns the build for.
      if (previousBundleBytes != null &&
          (!bundleFile.existsSync() ||
              !_sameBytes(bundleFile.readAsBytesSync(), previousBundleBytes))) {
        bundleFile.writeAsBytesSync(previousBundleBytes);
      }
      rethrow;
    }

    // Write the combined parameter sidecar next to the produced bundle.
    sidecarFile.writeAsStringSync(
      const JsonEncoder.withIndent('  ').convert(sidecars),
    );
    stampFile.writeAsStringSync(stamp);
  } catch (error) {
    final shouldRegisterIndex =
        dataAssetsAvailable && assetMode != MaterialAssetMode.legacyOnly;
    final haveLastGood =
        bundleFile.existsSync() &&
        sidecarFile.existsSync() &&
        (!shouldRegisterIndex || indexFile.existsSync());
    if (!haveLastGood) {
      rethrow;
    }
    stderr.writeln(
      'flutter_scene: building .fmat materials failed; keeping the previous '
      'shaders.\n$error',
    );
    // Surface the error in the running app too: the sidecar's content hash
    // changes, so the hot-reload coordinator re-reads it and prints the
    // marker. The per-material entries are unchanged, so the live materials
    // keep their last good state. The success path below rewrites the sidecar
    // without the marker once the material compiles again.
    try {
      final lastGood = (jsonDecode(sidecarFile.readAsStringSync()) as Map)
          .cast<String, Object?>();
      // Skip the rewrite when the same error is already recorded, so repeated
      // failed reloads do not churn the file's timestamp.
      if (lastGood['#compile_error'] != '$error') {
        lastGood['#compile_error'] = '$error';
        sidecarFile.writeAsStringSync(
          const JsonEncoder.withIndent('  ').convert(lastGood),
        );
      }
    } catch (_) {
      // The previous sidecar was unreadable; the stderr report stands alone.
    }
    _registerOutputs(
      buildInput: buildInput,
      buildOutput: buildOutput,
      assetMode: assetMode,
      dataAssetsAvailable: dataAssetsAvailable,
      bundleName: bundleName,
      bundleFile: bundleFile,
      sidecarFile: sidecarFile,
      indexFile: indexFile,
      writeIndex: false,
      sidecars: const {},
      materialSources: const {},
      packageRoot: packageRoot,
      materialPaths: materialPaths,
      frameworkShaders: frameworkShaders,
    );
    return;
  }

  _registerOutputs(
    buildInput: buildInput,
    buildOutput: buildOutput,
    assetMode: assetMode,
    dataAssetsAvailable: dataAssetsAvailable,
    bundleName: bundleName,
    bundleFile: bundleFile,
    sidecarFile: sidecarFile,
    indexFile: indexFile,
    writeIndex: true,
    sidecars: sidecars,
    materialSources: materialSources,
    packageRoot: packageRoot,
    materialPaths: materialPaths,
    frameworkShaders: frameworkShaders,
  );
}