buildScenes function
Converts scene assets so an app loads them by source path with loadScene
without hand-editing the asset manifest. Discovers three source kinds under
discoveryRoot: .glb (converted to .fsceneb), authored .fscene
(compiled to .fsceneb, with referenced images embedded and prefab instances
intact for runtime compose), and already-built .fsceneb (an editor's
imported/ assets, copied in as-is).
Call this from a consuming app's hook/build.dart:
import 'package:hooks/hooks.dart';
import 'package:flutter_scene/build_hooks.dart';
void main(List<String> args) {
build(args, (config, output) async {
buildScenes(buildInput: config, buildOutput: output);
});
}
When inputFilePaths is omitted, every .glb/.fscene/.fsceneb under
discoveryRoot (default assets/, relative to the package root) is
discovered, and each source is declared as a build dependency so changing it
retriggers the build (and hot reload). Conversion runs in-process (no
subprocess, no native binary).
Outputs land in the app's flutter_scene_generated/ directory.
SceneAssetMode.dataAssetsRequired registers them as data assets keyed
packages/<package>/flutter_scene/scene/<name>.fsceneb instead.
Set compressTextures to store embedded images as supercompressed block
payloads that transcode to the device's format at load, shrinking the
container and the GPU footprint. Sources must be a multiple of 4 in both
dimensions; anything else is stored uncompressed, with a warning naming it.
Textures are mipmapped either way.
Implementation
void buildScenes({
required BuildInput buildInput,
required BuildOutputBuilder buildOutput,
List<String>? inputFilePaths,
String discoveryRoot = 'assets/',
SceneAssetMode assetMode = SceneAssetMode.generatedTree,
bool compressTextures = false,
bool alignForCompression = false,
}) {
// ignore: deprecated_member_use_from_same_package
if (assetMode == SceneAssetMode.legacyOnly) {
throwRemovedAssetMode(
'SceneAssetMode.legacyOnly',
'SceneAssetMode.generatedTree',
);
}
// ignore: deprecated_member_use_from_same_package
if (assetMode == SceneAssetMode.dataAssetsIfAvailable) {
throwRemovedAssetMode(
'SceneAssetMode.dataAssetsIfAvailable',
'SceneAssetMode.generatedTree',
);
}
final emitDataAssets = assetMode == SceneAssetMode.dataAssetsRequired;
if (emitDataAssets && !buildInput.config.buildDataAssets) {
throw UnsupportedError(_dataAssetsUnavailableMessage);
}
final options = HookOptions.of(buildInput);
final packageRoot = buildInput.packageRoot;
final inputs =
inputFilePaths ??
discoverSceneSources(packageRoot, discoveryRoot: discoveryRoot);
if (inputFilePaths == null) {
// A directory dependency is hashed as the names of its direct children, so
// it costs nothing and catches an added or removed source. Edits are caught
// by each source's own declared dependency below.
// TODO(hook-dep-cost): the hash covers direct children only, so a source
// added in a subdirectory is not seen until something else reruns the hook.
// Declaring each subdirectory found during discovery would close that.
buildOutput.dependencies.add(
packageRoot.resolve(
discoveryRoot.endsWith('/') ? discoveryRoot : '$discoveryRoot/',
),
);
}
if (emitDataAssets) {
// A tree left by an earlier build would ship the same scenes twice, so the
// data assets this run registers replace it.
GeneratedAssetTree.openExisting(packageRoot, buildInput.packageName)
?..dropOwned(GeneratedAssetFamily.scene, owner: buildInput.packageName)
..save();
}
// The outputs go into the app's persistent flutter_scene_generated/ tree,
// whose manifest maps each source path to its generated asset (and stores the
// build stamp).
final tree = emitDataAssets
? null
: GeneratedAssetTree.open(
packageRoot,
buildInput.packageName,
options: options,
);
if (tree != null &&
(inputs.isNotEmpty || tree.hasFamily(GeneratedAssetFamily.scene))) {
tree.requireAssetEntry();
}
if (inputs.isEmpty) {
if (tree != null) {
tree
..pruneMissingSources()
..save();
}
return;
}
final scenesRoot = packageRoot.resolve(_dataAssetStagingDirectory);
for (final inputFilePath in inputs) {
final extension = _sceneSourceExtensions.firstWhere(
inputFilePath.endsWith,
orElse: () => throw Exception(
'Scene source must be a .glb, .fscene, or .fsceneb file. Given: '
'$inputFilePath',
),
);
if (inputFilePath.startsWith('../') || inputFilePath.contains('/../')) {
throw Exception(
'Scene source must be inside the package: $inputFilePath. Place it '
'under the package (for example in assets/), using a symlink if needed.',
);
}
final sourceUri = packageRoot.resolve(inputFilePath);
// The source path without its extension, which `loadScene` resolves by.
final sceneId = inputFilePath.substring(
0,
inputFilePath.length - extension.length,
);
// An already-built `.fsceneb` (an editor's imported asset) needs no
// conversion. A DataAsset registers it from its source location; the
// generated tree has to hold a copy, since only that tree is a listed
// asset directory.
if (extension == '.fsceneb') {
buildOutput.dependencies.add(sourceUri);
if (emitDataAssets) {
buildOutput.assets.data.add(
DataAsset(
package: buildInput.packageName,
name: sceneDataAssetName(inputFilePath),
file: sourceUri,
),
);
continue;
}
final sourceFile = File(sourceUri.toFilePath());
final stamp =
'rev=$buildCacheRevision scene kind=.fsceneb '
'src=${sourceFingerprint(sourceFile, strict: options.strictHashing)}';
final copyUri = tree!.fileUri(
GeneratedAssetFamily.scene,
nameId: sceneId,
extension: '.fsceneb',
);
if (!tree.isFresh(GeneratedAssetFamily.scene, sceneId, stamp, [
copyUri,
])) {
writeGeneratedBytes(copyUri, sourceFile.readAsBytesSync());
stdout.writeln('flutter_scene: copied $inputFilePath');
}
tree.recordFile(
family: GeneratedAssetFamily.scene,
id: sceneId,
uri: copyUri,
stamp: stamp,
source: inputFilePath,
);
continue;
}
// `.glb` and `.fscene` produce a generated `.fsceneb`.
final relativeScenePath = '$sceneId.fsceneb';
final outputSceneUri =
tree?.fileUri(
GeneratedAssetFamily.scene,
nameId: sceneId,
extension: '.fsceneb',
) ??
scenesRoot.resolve(relativeScenePath);
Directory.fromUri(outputSceneUri.resolve('.')).createSync(recursive: true);
// An authored `.fscene` references its imported images by path; read it up
// front so those files can be embedded into the self-contained `.fsceneb`
// and tracked as build dependencies (editing a referenced image then
// retriggers conversion and hot reload). The document is reused for the
// conversion below when the cache is stale.
SceneDocument? fsceneDocument;
List<ExternalImageAsset> imageAssets = const [];
ExternalPayloadAsset? payloadAsset;
if (extension == '.fscene') {
fsceneDocument = readFscene(
File(sourceUri.toFilePath()).readAsStringSync(),
);
imageAssets = resolveExternalImageAssets(fsceneDocument, sourceUri);
payloadAsset = resolveExternalPayloadAsset(fsceneDocument, sourceUri);
}
// Skip the work when the source and settings are unchanged since the
// output was produced, so a hook rerun for an unrelated edit does not
// reconvert every scene. Set FLUTTER_SCENE_DISABLE_BUILD_CACHE to always
// run, or FLUTTER_SCENE_STRICT_HASH to content-hash every source.
final sourceHash = sourceFingerprint(
File(sourceUri.toFilePath()),
strict: options.strictHashing,
);
// Fold each referenced image into the stamp, so editing an embedded image
// (not just the scene text) invalidates the cache and rebuilds the
// dependent `.fsceneb`.
final assetHashes = imageAssets
.map(
(a) =>
'${a.key}='
'${sourceFingerprint(a.file, strict: options.strictHashing)}',
)
.toList();
if (payloadAsset != null) {
assetHashes.add(
'${payloadAsset.key}='
'${sourceFingerprint(payloadAsset.file, strict: options.strictHashing)}',
);
}
final assetStamp = (assetHashes..sort()).join(',');
final stamp =
'rev=$buildCacheRevision scene compress=$compressTextures '
'kind=$extension src=$sourceHash assets=[$assetStamp]';
final stampFile = File('${outputSceneUri.toFilePath()}.inputs');
// The generated tree ships every file in it, so the stamp lives in the
// manifest there rather than in a sidecar next to the output.
final fresh = tree != null
? tree.isFresh(GeneratedAssetFamily.scene, sceneId, stamp, [
outputSceneUri,
])
: isBuildCacheFresh(stampFile, stamp, [
File(outputSceneUri.toFilePath()),
]);
if (!fresh) {
stdout.writeln('flutter_scene: converting $inputFilePath');
if (extension == '.glb') {
importGltfToFsceneb(
inputFilePath,
outputSceneUri.toFilePath(),
workingDirectory: packageRoot.toFilePath(),
compressTextures: compressTextures,
alignForCompression: alignForCompression,
);
} else {
// `.fscene` (authored text) -> `.fsceneb` (binary), embedding referenced
// images so the container is self-contained and keeping prefab instances
// intact for the runtime to compose.
inlineExternalImageAssets(
fsceneDocument!,
imageAssets,
compressTextures: compressTextures,
alignForCompression: alignForCompression,
);
if (payloadAsset != null) {
inlineExternalPayloadAsset(fsceneDocument, payloadAsset);
}
// `.fmat` refs are authored relative to the document; the built app
// resolves them through the DataAssets material registry, which keys
// sources package-relative.
final documentDirEnd = inputFilePath
.replaceAll('\\', '/')
.lastIndexOf('/');
rebaseFmatMaterialRefs(
fsceneDocument,
documentDirEnd < 0
? ''
: inputFilePath
.replaceAll('\\', '/')
.substring(0, documentDirEnd),
exists: (key) => File.fromUri(packageRoot.resolve(key)).existsSync(),
);
writeGeneratedBytes(outputSceneUri, writeFsceneb(fsceneDocument));
}
if (tree == null) stampFile.writeAsStringSync(stamp);
}
tree?.recordFile(
family: GeneratedAssetFamily.scene,
id: sceneId,
uri: outputSceneUri,
stamp: stamp,
source: inputFilePath,
);
buildOutput.dependencies.add(sourceUri);
// Declare each embedded image as a dependency every run (not only when the
// cache is stale), so editing it retriggers the hook and the dependent
// scene's hot reload. The payload sidecar is deliberately not declared: the
// build system hashes every declared dependency in full, the payload is the
// largest file in the project, and the `.fscene` text carries its manifest,
// so any payload change rewrites the text that is declared.
for (final asset in imageAssets) {
buildOutput.dependencies.add(asset.file.uri);
}
if (emitDataAssets) {
buildOutput.assets.data.add(
DataAsset(
package: buildInput.packageName,
name: sceneDataAssetName(relativeScenePath),
file: outputSceneUri,
),
);
}
}
if (tree != null) {
tree
..pruneMissingSources()
..save();
}
}