buildMaterials function
- required BuildInput buildInput,
- required BuildOutputBuilder buildOutput,
- List<
String> ? materials, - String bundleName = 'materials',
- String discoveryRoot = 'assets/',
- 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,
);
}