From 1b2c2ebb978241edfb2bf760665ef2696d0f9125 Mon Sep 17 00:00:00 2001 From: Brandon DeRosier Date: Thu, 28 May 2026 00:21:49 -0700 Subject: [PATCH] Track shader bundle depfiles --- CHANGELOG.md | 5 +++ README.md | 5 +++ lib/build.dart | 92 +++++++++++++++++++++++++++++++++++++++----- test/build_test.dart | 73 +++++++++++++++++++++++++++++++++++ 4 files changed, 166 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5080e1e..79264f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ ## 0.4.4 +* `buildShaderBundleJson` now consumes `impellerc` depfiles when the resolved + compiler advertises `--depfile` support. This lets newer Flutter SDKs track + transitive shader `#include` dependencies while preserving the existing + manifest dependency scan for older `impellerc` builds that do not support the + flag or do not emit a depfile in `--shader-bundle` mode. * `buildShaderBundleJson` accepts an `includeDirectories` parameter: extra directories appended to `impellerc`'s `#include` search path, after the manifest directory and the bundled `shader_lib`. This lets a diff --git a/README.md b/README.md index ef144a0..462b032 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,11 @@ Use native asset build hooks to import Flutter GPU shader bundle assets. }); } ``` + `buildShaderBundleJson` always declares the manifest and directly listed + shader files as build dependencies. With Flutter SDKs whose `impellerc` + supports `--depfile` for shader bundles, it also declares transitive + `#include` dependencies from the generated depfile. Older `impellerc` + builds continue to use the manifest scan fallback. 4. In your project's `pubspec.yaml`, add an asset import rule to package the built shader bundles (this will become unnecessary once the build hook system supports `DataAsset` registration in a future release): ```yaml flutter: diff --git a/lib/build.dart b/lib/build.dart index 986ad2e..ff3d5bd 100644 --- a/lib/build.dart +++ b/lib/build.dart @@ -28,14 +28,11 @@ Future _buildShaderBundleJson({ /// /// The build hook needs to rerun when any input that influences the /// produced shader bundle changes. We track the manifest itself and - /// every shader source file it references. The build hook framework - /// reruns this hook the next time any listed dependency's mtime - /// changes. - /// - /// Transitive `#include`s aren't tracked yet. `impellerc`'s - /// `--depfile` switch (which would let us capture them) is a no-op - /// in `--shader-bundle` mode today; that's filed as an upstream - /// follow-up. + /// every shader source file it references before invoking `impellerc`, + /// so older compilers still get the same dependency tracking as previous + /// package versions. Newer compilers also emit a depfile that captures + /// transitive `#include`s; those dependencies are merged after a + /// successful compile. buildOutput.dependencies.addAll( collectShaderBundleDependencies(inputManifestFilePath, decodedManifest), @@ -47,12 +44,21 @@ Future _buildShaderBundleJson({ final impellercExec = await findImpellerC(); final shaderLibPath = impellercExec.resolve('./shader_lib'); + final depfilePath = Uri.file('${outputBundleFilePath.toFilePath()}.d'); + final depfile = File.fromUri(depfilePath); + final supportsDepfile = impellerCHelpSupportsDepfile( + _impellerCHelpText(impellercExec), + ); + if (supportsDepfile && await depfile.exists()) { + await depfile.delete(); + } final impellercArgs = shaderBundleImpellercArguments( outputBundleFilePath: outputBundleFilePath, manifestJson: reconstitutedManifest, manifestDirectory: inputManifestFilePath.resolve('./'), shaderLibDirectory: shaderLibPath, includeDirectories: includeDirectories, + depfilePath: supportsDepfile ? depfilePath : null, ); final impellerc = Process.runSync( @@ -65,6 +71,25 @@ Future _buildShaderBundleJson({ 'Failed to build shader bundle: ${impellerc.stderr}\n${impellerc.stdout}', ); } + + if (supportsDepfile && await depfile.exists()) { + final depfileContents = await depfile.readAsString(); + buildOutput.dependencies.addAll( + parseImpellerCDepfileDependencies( + depfileContents, + relativeTo: packageRoot, + ), + ); + await depfile.delete(); + } +} + +String _impellerCHelpText(Uri impellercExec) { + final result = Process.runSync(impellercExec.toFilePath(), ['--help']); + if (result.exitCode != 0) { + return ''; + } + return '${result.stdout}\n${result.stderr}'; } /// Collects the build-system dependencies declared by a shader bundle @@ -95,6 +120,50 @@ List collectShaderBundleDependencies( return result; } +/// Returns whether the `impellerc --help` text advertises `--depfile`. +/// +/// Older `impellerc` builds either omit the flag entirely or accept it without +/// producing a depfile in `--shader-bundle` mode. Build hooks use this helper +/// to avoid passing an unsupported flag while preserving the existing manifest +/// dependency fallback. +bool impellerCHelpSupportsDepfile(String helpText) { + return helpText.contains('--depfile='); +} + +/// Parses dependency paths from an `impellerc` depfile. +/// +/// `impellerc` emits a Ninja-style single-line depfile: +/// `: ...`. The target is ignored. Dependency paths are +/// converted to file URIs; relative paths are resolved against [relativeTo] +/// when it is provided. +List parseImpellerCDepfileDependencies( + String depfileContents, { + Uri? relativeTo, +}) { + final separator = RegExp(r':(?:\s|$)').firstMatch(depfileContents); + if (separator == null) { + return const []; + } + final dependencies = depfileContents.substring(separator.end).trim(); + if (dependencies.isEmpty) { + return const []; + } + return dependencies + .split(RegExp(r'\s+')) + .where((dependency) => dependency.isNotEmpty) + .map((dependency) { + if (_isAbsoluteFilePath(dependency)) { + return Uri.file(dependency); + } + return relativeTo?.resolve(dependency) ?? Uri.file(dependency); + }) + .toList(); +} + +bool _isAbsoluteFilePath(String path) { + return path.startsWith('/') || RegExp(r'^[a-zA-Z]:[/\\]').hasMatch(path); +} + /// Builds the `impellerc` argument list for a shader-bundle compile. /// /// The first two `--include` directories are always the manifest's own @@ -111,10 +180,12 @@ List shaderBundleImpellercArguments({ required Uri manifestDirectory, required Uri shaderLibDirectory, List includeDirectories = const [], + Uri? depfilePath, }) { return [ '--sl=${outputBundleFilePath.toFilePath()}', '--shader-bundle=$manifestJson', + if (depfilePath != null) '--depfile=${depfilePath.toFilePath()}', '--include=${manifestDirectory.toFilePath()}', '--include=${shaderLibDirectory.toFilePath()}', for (final directory in includeDirectories) @@ -137,7 +208,10 @@ List shaderBundleImpellercArguments({ /// /// The hook declares the manifest and every shader source file it /// references as build-system dependencies, so the bundle is rebuilt -/// when any input changes. +/// when any input changes. When the resolved `impellerc` supports +/// `--depfile` in `--shader-bundle` mode, the hook also declares transitive +/// `#include` dependencies from the generated depfile. Older `impellerc` +/// builds fall back to the manifest scan. /// /// The optional [includeDirectories] are added to `impellerc`'s `#include` /// search path, after the manifest's directory and `impellerc`'s built-in diff --git a/test/build_test.dart b/test/build_test.dart index 16bf9bc..a5f81d1 100644 --- a/test/build_test.dart +++ b/test/build_test.dart @@ -93,6 +93,20 @@ void main() { ]); }); + test('emits depfile argument when a depfile path is provided', () { + final depfile = Uri.parse( + 'file:///pkg/build/shaderbundles/bundle.shaderbundle.d', + ); + final args = shaderBundleImpellercArguments( + outputBundleFilePath: out, + manifestJson: '{}', + manifestDirectory: manifestDir, + shaderLibDirectory: shaderLib, + depfilePath: depfile, + ); + expect(args, contains('--depfile=${depfile.toFilePath()}')); + }); + test('appends an --include for each extra directory, in order', () { final depA = Uri.parse('file:///dep_a/shaders/'); final depB = Uri.parse('file:///dep_b/glsl/'); @@ -124,4 +138,63 @@ void main() { expect(build(includeDirectories: const []), build()); }); }); + + group('impellerCHelpSupportsDepfile', () { + test('returns true when help advertises the depfile flag', () { + expect( + impellerCHelpSupportsDepfile( + '[optional] --depfile=', + ), + isTrue, + ); + }); + + test('returns false when help omits the depfile flag', () { + expect( + impellerCHelpSupportsDepfile( + '[optional,multiple] --include=', + ), + isFalse, + ); + }); + }); + + group('parseImpellerCDepfileDependencies', () { + test('parses dependency paths from a single-line depfile', () { + final deps = parseImpellerCDepfileDependencies( + '/pkg/build/shaderbundles/bundle.shaderbundle: ' + '/pkg/shaders/main.frag /pkg/shaders/include/common.glsl\n', + ); + expect(deps, [ + Uri.parse('file:///pkg/shaders/main.frag'), + Uri.parse('file:///pkg/shaders/include/common.glsl'), + ]); + }); + + test('resolves relative dependency paths against a base URI', () { + final deps = parseImpellerCDepfileDependencies( + 'build/shaderbundles/bundle.shaderbundle: ' + 'shaders/main.frag shaders/include/common.glsl', + relativeTo: Uri.parse('file:///pkg/'), + ); + expect(deps, [ + Uri.parse('file:///pkg/shaders/main.frag'), + Uri.parse('file:///pkg/shaders/include/common.glsl'), + ]); + }); + + test('handles a drive-letter colon in the target path', () { + final deps = parseImpellerCDepfileDependencies( + r'C:\pkg\build\shaderbundles\bundle.shaderbundle: ' + 'shaders/main.frag', + relativeTo: Uri.parse('file:///pkg/'), + ); + expect(deps, [Uri.parse('file:///pkg/shaders/main.frag')]); + }); + + test('returns no dependencies when the depfile is missing deps', () { + expect(parseImpellerCDepfileDependencies('target:'), isEmpty); + expect(parseImpellerCDepfileDependencies(''), isEmpty); + }); + }); }