diff --git a/POST_PROCESSING.md b/POST_PROCESSING.md new file mode 100644 index 0000000..d9c20dd --- /dev/null +++ b/POST_PROCESSING.md @@ -0,0 +1,214 @@ +# Post-processing in flutter_scene + +flutter_scene applies post-processing in two ways: a suite of built-in +effects you turn on and tune, and custom effects you author as fragment +shaders. Both are configured per scene through `Scene.postProcess`. + +Everything is off by default, so a fresh scene does no extra work. + +## Built-in effects + +`Scene.postProcess` holds one settings object per effect. Each has an +`enabled` flag (off by default) and typed parameters: + +```dart +final scene = Scene(); + +scene.postProcess.bloom + ..enabled = true + ..threshold = 1.0 // HDR brightness where blooming starts + ..intensity = 0.5 // how strongly the glow is added back + ..scatter = 0.7; // blur spread, 0 to 1 + +scene.postProcess.colorGrading + ..enabled = true + ..brightness = 1.0 + ..contrast = 1.1 + ..saturation = 1.2 + ..temperature = 0.1 // white balance, -1 (cool) to 1 (warm) + ..tint = 0.0 // -1 (magenta) to 1 (green) + ..lift = Vector3.zero() // per-channel shadows + ..gamma = Vector3.all(1.0) // per-channel midtones + ..gain = Vector3.all(1.0); // per-channel highlights + +scene.postProcess.vignette + ..enabled = true + ..intensity = 0.5 // how dark the edges get + ..radius = 0.75 // where darkening begins, from the center + ..smoothness = 0.5; // falloff softness + +scene.postProcess.chromaticAberration + ..enabled = true + ..intensity = 0.5; // channel separation at the edges + +scene.postProcess.filmGrain + ..enabled = true + ..intensity = 0.3; // animated noise strength +``` + +The effects run in a fixed order. Bloom and color grading operate on the +linear HDR scene color before tone mapping; vignette, chromatic +aberration, and film grain are applied around the tone-map step. You do +not reorder the built-ins; you turn them on and tune them. + +## Custom effects + +A `PostEffect` is a fragment shader that reads the current color and +writes a new one. It is the post-processing counterpart of +`ShaderMaterial`, and the authoring workflow is the same: write a fragment +shader, compile it through the `flutter_gpu_shaders` build hook into a +`.shaderbundle`, load it, wrap it, and add it to the scene. + +### Authoring workflow at a glance + +1. Write a fragment shader (see the contract below). +2. Add it to your shader bundle manifest and build it with the + `flutter_gpu_shaders` hook, exactly as in `MATERIALS.md`. +3. Load the bundle, pull out the shader, and wrap it in a `PostEffect`. +4. Add the effect to `scene.postProcess.customEffects`. + +`examples/flutter_app/shaders/example_wave.frag` is a complete worked +case; read along with this doc. + +### The engine contract + +The engine binds the current color to a `sampler2D input_color` that your +shader samples at the `v_uv` varying, and you write to `frag_color`: + +```glsl +uniform sampler2D input_color; + +in vec2 v_uv; + +out vec4 frag_color; + +void main() { + frag_color = texture(input_color, v_uv); +} +``` + +That is a complete (pass-through) effect. The fullscreen vertex shader is +provided by the engine; you only write the fragment shader. + +**Frame info.** Set `PostEffect.useFrameInfo = true` and declare a +`PostFrameInfo` block to receive the target resolution, texel size, and a +seconds time value (useful for animation and for sampling neighbors): + +```glsl +uniform PostFrameInfo { + vec2 resolution; + vec2 texel_size; // 1.0 / resolution + float time; // seconds + float _pad; +} +frame; +``` + +`useFrameInfo` defaults to `false`. The engine only binds `PostFrameInfo` +when you opt in, so an effect that does not use it does not have to declare +it. + +**Your own parameters.** Declare uniform blocks and textures and set them +by name from Dart with `setUniformBlock` / `setTexture`, exactly like +`ShaderMaterial`. The std140 packing rules are identical; see the uniform +block packing section of `MATERIALS.md`. + +### Insertion points and the output contract + +`PostEffect.insertion` selects where the effect runs: + +- `PostInsertion.beforeTonemap` (the default): runs on the linear HDR scene + color, before tone mapping. Output **linear HDR premultiplied by alpha**, + the same contract as a material fragment shader. Values above 1.0 are + fine; the tone curve rolls them off. This is the general-purpose slot. +- `PostInsertion.afterTonemap`: runs on the display-referred image, after + tone mapping. Output a display color. + +A simple resampling effect (like the wave example) works at either point. +Effects that produce or expect high dynamic range belong before tone +mapping. + +### Wiring it up + +```dart +import 'package:flutter_scene/gpu.dart' as gpu; +import 'package:flutter_scene/scene.dart'; + +final library = await gpu.loadShaderLibraryAsync( + 'build/shaderbundles/my_bundle.shaderbundle', +); + +final effect = PostEffect( + fragmentShader: library!['WaveFragment']!, + insertion: PostInsertion.beforeTonemap, + useFrameInfo: true, +)..setUniformBlockFromFloats('WaveInfo', [ + 0.008, // amplitude + 24.0, // frequency + 3.0, // speed + 0.0, // padding + ]); + +scene.postProcess.customEffects.add(effect); +``` + +The matching shader: + +```glsl +uniform sampler2D input_color; + +uniform PostFrameInfo { + vec2 resolution; + vec2 texel_size; + float time; + float _pad0; +} +frame; + +uniform WaveInfo { + float amplitude; + float frequency; + float speed; + float _pad1; +} +wave; + +in vec2 v_uv; + +out vec4 frag_color; + +void main() { + float offset = + sin(v_uv.y * wave.frequency + frame.time * wave.speed) * wave.amplitude; + frag_color = texture(input_color, vec2(v_uv.x + offset, v_uv.y)); +} +``` + +## How effects compose + +Built-in effects run in their fixed order. Custom effects run in +`customEffects` list order, each at its chosen insertion point: every +`beforeTonemap` effect runs (before bloom and tone mapping), then the +built-in resolve, then every `afterTonemap` effect. Each custom effect +reads the previous result and writes the next, so order in the list +matters. + +## Limitations + +- **One pass per custom effect.** Each custom effect is its own + full-screen pass. Stacking many has a per-pass cost; the built-in suite + is folded into a single pass and is cheaper. +- **No depth input yet.** Custom effects receive scene color but not scene + depth. Depth-based effects are a planned addition. +- **Editing a shader's contents needs a clean rebuild.** The shader build + hook only re-runs on a manifest change, not on a content-only edit to an + existing shader. After editing a `.frag`, remove the `.dart_tool` and + `build` directories and run `flutter pub get` before rebuilding. + +## See also + +- `MATERIALS.md`: the custom-material (`ShaderMaterial`) workflow and the + shared shader-bundle build steps and std140 packing rules. +- `examples/flutter_app/shaders/example_wave.frag` and the settings + sidebar in `examples/flutter_app/lib/main.dart`: a custom effect and the + built-in controls, end to end. diff --git a/examples/flutter_app/lib/example_animation.dart b/examples/flutter_app/lib/example_animation.dart index 9f744b5..304b52d 100644 --- a/examples/flutter_app/lib/example_animation.dart +++ b/examples/flutter_app/lib/example_animation.dart @@ -4,6 +4,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_scene/scene.dart'; import 'package:vector_math/vector_math.dart' as vm; +import 'example_settings.dart'; + class ExampleAnimation extends StatefulWidget { const ExampleAnimation({super.key, this.elapsedSeconds = 0}); final double elapsedSeconds; @@ -133,6 +135,7 @@ class _ScenePainter extends CustomPainter { target: vm.Vector3(0, 1.5, 0), ); + exampleSettings.applyTo(scene); scene.render(camera, canvas, viewport: Offset.zero & size); } diff --git a/examples/flutter_app/lib/example_car.dart b/examples/flutter_app/lib/example_car.dart index 936991b..fc3e235 100644 --- a/examples/flutter_app/lib/example_car.dart +++ b/examples/flutter_app/lib/example_car.dart @@ -4,6 +4,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_scene/scene.dart'; import 'package:vector_math/vector_math.dart' as vm; +import 'example_settings.dart'; + class ExampleCar extends StatefulWidget { const ExampleCar({super.key, this.elapsedSeconds = 0}); final double elapsedSeconds; @@ -196,6 +198,7 @@ class _ScenePainter extends CustomPainter { target: vm.Vector3(0, 0, 0), ); + exampleSettings.applyTo(scene); scene.render(camera, canvas, viewport: Offset.zero & size); } diff --git a/examples/flutter_app/lib/example_cuboid.dart b/examples/flutter_app/lib/example_cuboid.dart index 03dded0..5a6c9bb 100644 --- a/examples/flutter_app/lib/example_cuboid.dart +++ b/examples/flutter_app/lib/example_cuboid.dart @@ -4,6 +4,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_scene/scene.dart'; import 'package:vector_math/vector_math.dart' as vm; +import 'example_settings.dart'; + class ExampleCuboid extends StatefulWidget { const ExampleCuboid({super.key, this.elapsedSeconds = 0}); final double elapsedSeconds; @@ -60,6 +62,7 @@ class _ScenePainter extends CustomPainter { target: vm.Vector3(0, 0, 0), ); + exampleSettings.applyTo(scene); scene.render(camera, canvas, viewport: Offset.zero & size); } diff --git a/examples/flutter_app/lib/example_instancing.dart b/examples/flutter_app/lib/example_instancing.dart index 1730154..011b1a5 100644 --- a/examples/flutter_app/lib/example_instancing.dart +++ b/examples/flutter_app/lib/example_instancing.dart @@ -4,6 +4,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_scene/scene.dart'; import 'package:vector_math/vector_math.dart' as vm; +import 'example_settings.dart'; + class ExampleInstancing extends StatefulWidget { const ExampleInstancing({super.key, this.elapsedSeconds = 0}); final double elapsedSeconds; @@ -58,6 +60,7 @@ class _ScenePainter extends CustomPainter { target: vm.Vector3(0, 0, 0), ); + exampleSettings.applyTo(scene); scene.render(camera, canvas, viewport: Offset.zero & size); } diff --git a/examples/flutter_app/lib/example_logo.dart b/examples/flutter_app/lib/example_logo.dart index 62aa941..d075349 100644 --- a/examples/flutter_app/lib/example_logo.dart +++ b/examples/flutter_app/lib/example_logo.dart @@ -4,6 +4,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_scene/scene.dart'; import 'package:vector_math/vector_math.dart' as vm; +import 'example_settings.dart'; + class ExampleLogo extends StatefulWidget { const ExampleLogo({super.key, this.elapsedSeconds = 0}); final double elapsedSeconds; @@ -87,6 +89,7 @@ class _ScenePainter extends CustomPainter { target: vm.Vector3(0, 0, 0), ); + exampleSettings.applyTo(scene); scene.render(camera, canvas, viewport: Offset.zero & size); } diff --git a/examples/flutter_app/lib/example_nav_route.dart b/examples/flutter_app/lib/example_nav_route.dart index b7a7b08..092b70d 100644 --- a/examples/flutter_app/lib/example_nav_route.dart +++ b/examples/flutter_app/lib/example_nav_route.dart @@ -5,6 +5,8 @@ import 'package:flutter/services.dart'; import 'package:flutter_scene/scene.dart'; import 'package:vector_math/vector_math.dart' as vm; +import 'example_settings.dart'; + // Added to the heading derived from the lane tangent, to account for the // car model's forward axis. Flip the sign if the car faces backward. const double _carHeadingOffset = -pi / 2; @@ -344,8 +346,8 @@ class ExampleNavRouteState extends State { ), if (_carPartsReady) Positioned( - top: 8, - right: 8, + top: 56, + left: 8, child: _CarControlsMenu( open: _controlsOpen, onToggle: () => setState(() => _controlsOpen = !_controlsOpen), @@ -576,6 +578,7 @@ class _NavRoutePainter extends CustomPainter { ..scaleByDouble(carScale, carScale, carScale, 1.0); } + exampleSettings.applyTo(scene); scene.render(camera, canvas, viewport: Offset.zero & size); } diff --git a/examples/flutter_app/lib/example_settings.dart b/examples/flutter_app/lib/example_settings.dart new file mode 100644 index 0000000..1194b89 --- /dev/null +++ b/examples/flutter_app/lib/example_settings.dart @@ -0,0 +1,99 @@ +import 'package:flutter_scene/gpu.dart' as gpu; +import 'package:flutter_scene/scene.dart'; + +/// Post-processing settings shared by every example. +/// +/// The settings sidebar edits the single [exampleSettings] instance, and +/// each example copies it onto its own scene with [applyTo] right before +/// rendering, so one set of controls drives every scene. +class ExampleSettings { + /// Color grading shared across the examples. + final ColorGradingSettings colorGrading = ColorGradingSettings(); + + /// Chromatic aberration shared across the examples. + final ChromaticAberrationSettings chromaticAberration = + ChromaticAberrationSettings(); + + /// Vignette shared across the examples. + final VignetteSettings vignette = VignetteSettings(); + + /// Film grain shared across the examples. + final FilmGrainSettings filmGrain = FilmGrainSettings(); + + /// Bloom shared across the examples. + final BloomSettings bloom = BloomSettings(); + + /// A custom, user-authored effect, built by [loadExampleEffects]. Null + /// until the example shader bundle finishes loading. + PostEffect? waveEffect; + + /// Amplitude of the custom wave effect. + double waveAmplitude = 0.008; + + /// Copies the shared settings onto [scene] so its next frame uses them. + void applyTo(Scene scene) { + final grading = scene.postProcess.colorGrading; + grading.enabled = colorGrading.enabled; + grading.brightness = colorGrading.brightness; + grading.contrast = colorGrading.contrast; + grading.saturation = colorGrading.saturation; + grading.temperature = colorGrading.temperature; + grading.tint = colorGrading.tint; + grading.lift.setFrom(colorGrading.lift); + grading.gamma.setFrom(colorGrading.gamma); + grading.gain.setFrom(colorGrading.gain); + + final aberration = scene.postProcess.chromaticAberration; + aberration.enabled = chromaticAberration.enabled; + aberration.intensity = chromaticAberration.intensity; + + final vig = scene.postProcess.vignette; + vig.enabled = vignette.enabled; + vig.intensity = vignette.intensity; + vig.radius = vignette.radius; + vig.smoothness = vignette.smoothness; + + final grain = scene.postProcess.filmGrain; + grain.enabled = filmGrain.enabled; + grain.intensity = filmGrain.intensity; + + final sceneBloom = scene.postProcess.bloom; + sceneBloom.enabled = bloom.enabled; + sceneBloom.threshold = bloom.threshold; + sceneBloom.intensity = bloom.intensity; + sceneBloom.scatter = bloom.scatter; + + final wave = waveEffect; + if (wave != null) { + wave.setUniformBlockFromFloats('WaveInfo', [ + waveAmplitude, + 24.0, + 3.0, + 0.0, + ]); + if (!scene.postProcess.customEffects.contains(wave)) { + scene.postProcess.customEffects.add(wave); + } + } + } +} + +/// The single shared settings instance used across the example app. +final ExampleSettings exampleSettings = ExampleSettings(); + +/// Loads the example shader bundle and builds the custom post-processing +/// effects. Awaited at startup alongside [Scene.initializeStaticResources]. +Future loadExampleEffects() async { + final library = await gpu.loadShaderLibraryAsync( + 'build/shaderbundles/example.shaderbundle', + ); + final waveShader = library?['WaveFragment']; + if (waveShader != null) { + exampleSettings.waveEffect = PostEffect( + fragmentShader: waveShader, + insertion: PostInsertion.beforeTonemap, + enabled: false, + useFrameInfo: true, + ); + } +} diff --git a/examples/flutter_app/lib/example_stress_tests.dart b/examples/flutter_app/lib/example_stress_tests.dart index d3d141f..2e9fe0d 100644 --- a/examples/flutter_app/lib/example_stress_tests.dart +++ b/examples/flutter_app/lib/example_stress_tests.dart @@ -14,6 +14,8 @@ import 'package:flutter_scene/scene.dart' hide Material; import 'package:http/http.dart' as http; import 'package:vector_math/vector_math.dart' as vm; +import 'example_settings.dart'; + import 'hdr_image.dart'; import 'stress_cache.dart'; // The in-memory offline (ahead-of-time) glTF -> .model conversion, used by the @@ -882,10 +884,11 @@ class _StressSceneState extends State<_StressScene> { downloaded: _downloaded, total: _total, ), - // Top-right: the main app's dropdown owns the top-left corner. + // Below the example picker: the settings sidebar owns the + // top-right corner, and the app dropdown owns the top-left. Positioned( - right: 8, - top: 8, + left: 8, + top: 56, child: Material( color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.8), shape: const CircleBorder(), @@ -1280,6 +1283,7 @@ class _ScenePainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { final camera = PerspectiveCamera(position: position, target: target); + exampleSettings.applyTo(scene); scene.render(camera, canvas, viewport: Offset.zero & size); } diff --git a/examples/flutter_app/lib/example_toon.dart b/examples/flutter_app/lib/example_toon.dart index dbe35d0..43c03b4 100644 --- a/examples/flutter_app/lib/example_toon.dart +++ b/examples/flutter_app/lib/example_toon.dart @@ -17,6 +17,8 @@ import 'package:flutter_scene/gpu.dart' as gpu; import 'package:flutter_scene/scene.dart'; import 'package:vector_math/vector_math.dart' as vm; +import 'example_settings.dart'; + class ExampleToon extends StatefulWidget { const ExampleToon({super.key, this.elapsedSeconds = 0}); final double elapsedSeconds; @@ -282,6 +284,7 @@ class _ScenePainter extends CustomPainter { position: vm.Vector3(0, 2, -6), target: vm.Vector3(0, 1.5, 0), ); + exampleSettings.applyTo(scene); scene.render(camera, canvas, viewport: Offset.zero & size); } diff --git a/examples/flutter_app/lib/main.dart b/examples/flutter_app/lib/main.dart index 329f20c..db527d8 100644 --- a/examples/flutter_app/lib/main.dart +++ b/examples/flutter_app/lib/main.dart @@ -1,13 +1,14 @@ import 'package:example_app/example_car.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; -import 'package:flutter_scene/scene.dart' show Scene; +import 'package:flutter_scene/scene.dart' show Scene, PostInsertion; import 'package:example_app/example_animation.dart'; import 'example_cuboid.dart'; import 'example_instancing.dart'; import 'example_logo.dart'; import 'example_nav_route.dart'; +import 'example_settings.dart'; import 'example_stress_tests.dart'; import 'example_toon.dart'; @@ -27,6 +28,7 @@ class _MyAppState extends State { double elapsedSeconds = 0; String selectedExample = ''; Map examples = {}; + late final Future _ready; @override void initState() { @@ -53,6 +55,11 @@ class _MyAppState extends State { }; selectedExample = examples.keys.first; + _ready = Future.wait([ + Scene.initializeStaticResources(), + loadExampleEffects(), + ]); + super.initState(); } @@ -70,7 +77,7 @@ class _MyAppState extends State { // geometry/materials in initState, which touches the shader bundle; // on web that bundle must finish loading first (sync asset reads // aren't possible there). - future: Scene.initializeStaticResources(), + future: _ready, builder: (context, snapshot) { if (snapshot.connectionState != ConnectionState.done) { return const Center(child: CircularProgressIndicator()); @@ -94,6 +101,9 @@ class _MyAppState extends State { }, ), ), + // Settings sidebar (top-right): global post-processing + // controls applied to whichever example is on screen. + const Positioned(top: 8, right: 8, child: _SettingsSidebar()), ], ); }, @@ -148,3 +158,285 @@ class _ExamplePicker extends StatelessWidget { ); } } + +/// Collapsible settings sidebar (top-right). Edits the shared +/// [exampleSettings], which every example applies to its scene before +/// rendering, so one set of controls drives whichever example is shown. +/// +/// Effects are grouped under collapsible sections so more can be added as +/// the post-processing suite grows. +class _SettingsSidebar extends StatefulWidget { + const _SettingsSidebar(); + + @override + State<_SettingsSidebar> createState() => _SettingsSidebarState(); +} + +class _SettingsSidebarState extends State<_SettingsSidebar> { + bool _expanded = false; + + @override + Widget build(BuildContext context) { + final surface = Theme.of( + context, + ).colorScheme.surface.withValues(alpha: 0.95); + + if (!_expanded) { + return Material( + color: surface, + borderRadius: BorderRadius.circular(8), + elevation: 2, + child: IconButton( + icon: const Icon(Icons.tune), + tooltip: 'Settings', + onPressed: () => setState(() => _expanded = true), + ), + ); + } + + return Material( + color: surface, + borderRadius: BorderRadius.circular(8), + elevation: 2, + child: SizedBox( + width: 320, + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height - 16, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 4, 0), + child: Row( + children: [ + Text( + 'Settings', + style: Theme.of(context).textTheme.titleMedium, + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.close), + tooltip: 'Close settings', + onPressed: () => setState(() => _expanded = false), + ), + ], + ), + ), + Flexible( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [_buildPostProcessing()], + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildPostProcessing() { + return ExpansionTile( + title: const Text('Post-processing'), + initiallyExpanded: true, + childrenPadding: EdgeInsets.zero, + children: [ + _buildColorGrading(), + _buildBloom(), + _buildChromaticAberration(), + _buildVignette(), + _buildFilmGrain(), + _buildCustomEffect(), + ], + ); + } + + Widget _buildColorGrading() { + final grading = exampleSettings.colorGrading; + return ExpansionTile( + title: const Text('Color grading'), + initiallyExpanded: true, + childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + children: [ + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Enabled'), + value: grading.enabled, + onChanged: (value) => setState(() => grading.enabled = value), + ), + _slider('Brightness', grading.brightness, 0, 2, (v) { + grading.brightness = v; + }), + _slider('Contrast', grading.contrast, 0, 2, (v) { + grading.contrast = v; + }), + _slider('Saturation', grading.saturation, 0, 2, (v) { + grading.saturation = v; + }), + _slider('Temperature', grading.temperature, -1, 1, (v) { + grading.temperature = v; + }), + _slider('Tint', grading.tint, -1, 1, (v) { + grading.tint = v; + }), + ], + ); + } + + Widget _buildChromaticAberration() { + final settings = exampleSettings.chromaticAberration; + return ExpansionTile( + title: const Text('Chromatic aberration'), + childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + children: [ + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Enabled'), + value: settings.enabled, + onChanged: (value) => setState(() => settings.enabled = value), + ), + _slider('Intensity', settings.intensity, 0, 1, (v) { + settings.intensity = v; + }), + ], + ); + } + + Widget _buildVignette() { + final settings = exampleSettings.vignette; + return ExpansionTile( + title: const Text('Vignette'), + childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + children: [ + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Enabled'), + value: settings.enabled, + onChanged: (value) => setState(() => settings.enabled = value), + ), + _slider('Intensity', settings.intensity, 0, 1, (v) { + settings.intensity = v; + }), + _slider('Radius', settings.radius, 0, 1.5, (v) { + settings.radius = v; + }), + _slider('Smoothness', settings.smoothness, 0, 1, (v) { + settings.smoothness = v; + }), + ], + ); + } + + Widget _buildFilmGrain() { + final settings = exampleSettings.filmGrain; + return ExpansionTile( + title: const Text('Film grain'), + childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + children: [ + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Enabled'), + value: settings.enabled, + onChanged: (value) => setState(() => settings.enabled = value), + ), + _slider('Intensity', settings.intensity, 0, 1, (v) { + settings.intensity = v; + }), + ], + ); + } + + Widget _buildBloom() { + final settings = exampleSettings.bloom; + return ExpansionTile( + title: const Text('Bloom'), + childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + children: [ + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Enabled'), + value: settings.enabled, + onChanged: (value) => setState(() => settings.enabled = value), + ), + _slider('Threshold', settings.threshold, 0, 4, (v) { + settings.threshold = v; + }), + _slider('Intensity', settings.intensity, 0, 2, (v) { + settings.intensity = v; + }), + _slider('Scatter', settings.scatter, 0, 1, (v) { + settings.scatter = v; + }), + ], + ); + } + + Widget _buildCustomEffect() { + final effect = exampleSettings.waveEffect; + if (effect == null) { + return const SizedBox.shrink(); + } + return ExpansionTile( + title: const Text('Custom: wave'), + childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + children: [ + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Enabled'), + value: effect.enabled, + onChanged: (value) => setState(() => effect.enabled = value), + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('After tone mapping'), + value: effect.insertion == PostInsertion.afterTonemap, + onChanged: + (value) => setState(() { + effect.insertion = + value + ? PostInsertion.afterTonemap + : PostInsertion.beforeTonemap; + }), + ), + _slider('Amplitude', exampleSettings.waveAmplitude, 0, 0.03, (v) { + exampleSettings.waveAmplitude = v; + }), + ], + ); + } + + Widget _slider( + String label, + double value, + double min, + double max, + ValueChanged onChanged, + ) { + final textStyle = Theme.of(context).textTheme.bodySmall; + return Row( + children: [ + SizedBox(width: 84, child: Text(label, style: textStyle)), + Expanded( + child: Slider( + value: value.clamp(min, max), + min: min, + max: max, + onChanged: (v) => setState(() => onChanged(v)), + ), + ), + SizedBox( + width: 36, + child: Text( + value.toStringAsFixed(2), + style: textStyle, + textAlign: TextAlign.right, + ), + ), + ], + ); + } +} diff --git a/examples/flutter_app/shaders/example.shaderbundle.json b/examples/flutter_app/shaders/example.shaderbundle.json index 3066202..9e318c2 100644 --- a/examples/flutter_app/shaders/example.shaderbundle.json +++ b/examples/flutter_app/shaders/example.shaderbundle.json @@ -2,5 +2,9 @@ "ToonFragment": { "type": "fragment", "file": "shaders/example_toon.frag" + }, + "WaveFragment": { + "type": "fragment", + "file": "shaders/example_wave.frag" } } diff --git a/examples/flutter_app/shaders/example_wave.frag b/examples/flutter_app/shaders/example_wave.frag new file mode 100644 index 0000000..540fb44 --- /dev/null +++ b/examples/flutter_app/shaders/example_wave.frag @@ -0,0 +1,31 @@ +// Example custom post-processing effect: a horizontal wave that scrolls +// over time. Shows the PostEffect contract: sample input_color at v_uv, +// read time from the engine-provided PostFrameInfo block, and take custom +// parameters from a WaveInfo block set by name from Dart. +uniform sampler2D input_color; + +uniform PostFrameInfo { + vec2 resolution; + vec2 texel_size; + float time; + float _pad0; +} +frame; + +uniform WaveInfo { + float amplitude; + float frequency; + float speed; + float _pad1; +} +wave; + +in vec2 v_uv; + +out vec4 frag_color; + +void main() { + float offset = + sin(v_uv.y * wave.frequency + frame.time * wave.speed) * wave.amplitude; + frag_color = texture(input_color, vec2(v_uv.x + offset, v_uv.y)); +} diff --git a/packages/flutter_scene/CHANGELOG.md b/packages/flutter_scene/CHANGELOG.md index 4651d80..7b4c2ee 100644 --- a/packages/flutter_scene/CHANGELOG.md +++ b/packages/flutter_scene/CHANGELOG.md @@ -1,3 +1,19 @@ +## 0.15.0 + +Post-processing effects chain. + +* Added a post-processing suite configured per scene via + `Scene.postProcess`: bloom, color grading (brightness, contrast, + saturation, white balance, lift/gamma/gain), vignette, chromatic + aberration, and film grain. Each effect is off by default. +* Added `PostEffect`, a custom post-processing effect authored as a + fragment shader, the post-processing counterpart of `ShaderMaterial`. + An effect runs before or after tone mapping and reads the current color + through `input_color`. See `POST_PROCESSING.md`. +* The tone-mapping pass is now the resolve pass: it applies exposure, + color grading, the tone-mapping operator, and the display EOTF, and + composites bloom. + ## 0.14.2 Rendering fixes. diff --git a/packages/flutter_scene/lib/scene.dart b/packages/flutter_scene/lib/scene.dart index 495a6e7..0b4331e 100644 --- a/packages/flutter_scene/lib/scene.dart +++ b/packages/flutter_scene/lib/scene.dart @@ -48,6 +48,8 @@ export 'src/light.dart'; export 'src/math_extensions.dart'; export 'src/mesh.dart'; export 'src/node.dart'; +export 'src/post_process/post_effect.dart'; +export 'src/post_process/post_process.dart'; export 'src/render/env_prefilter.dart'; export 'src/runtime_importer/gltf_resources.dart' show GltfResourceResolver; export 'src/scene_encoder.dart'; diff --git a/packages/flutter_scene/lib/src/post_process/post_effect.dart b/packages/flutter_scene/lib/src/post_process/post_effect.dart new file mode 100644 index 0000000..86f19b8 --- /dev/null +++ b/packages/flutter_scene/lib/src/post_process/post_effect.dart @@ -0,0 +1,121 @@ +import 'dart:typed_data'; + +import 'package:flutter_scene/src/gpu/gpu.dart' as gpu; +import 'package:flutter_scene/src/shader_uniform_bindings.dart'; + +/// Where in the post-processing chain a [PostEffect] runs. +enum PostInsertion { + /// Runs on the linear HDR scene color, before tone mapping. The shader + /// should output linear HDR premultiplied by alpha, the same contract as + /// a material fragment shader. + beforeTonemap, + + /// Runs on the display-referred image, after tone mapping. + afterTonemap, +} + +/// A custom, user-authored post-processing effect: a fragment shader that +/// reads the current color and writes a new one. +/// +/// This is the post-processing counterpart of `ShaderMaterial`. Author a +/// fragment shader, compile it through the `flutter_gpu_shaders` build hook +/// into a `.shaderbundle`, load it, wrap it in a [PostEffect], and add it +/// to `Scene.postProcess.customEffects`. +/// +/// ## Engine-bound resources +/// +/// The engine binds the current color to a `sampler2D input_color`, which +/// the shader samples at the `v_uv` varying: +/// +/// ```glsl +/// uniform sampler2D input_color; +/// in vec2 v_uv; +/// out vec4 frag_color; +/// void main() { frag_color = texture(input_color, v_uv); } +/// ``` +/// +/// Set [useFrameInfo] to also receive a `PostFrameInfo` block with the +/// target resolution, texel size, and a seconds time value: +/// +/// ```glsl +/// uniform PostFrameInfo { +/// vec2 resolution; +/// vec2 texel_size; +/// float time; +/// float _pad; +/// } frame; +/// ``` +/// +/// Declare your own uniform blocks and textures and set them by name with +/// [setUniformBlock] / [setTexture], exactly like `ShaderMaterial`; the +/// std140 packing rules are the same (see `MATERIALS.md`). +/// +/// A [PostInsertion.beforeTonemap] effect should output linear HDR +/// premultiplied by alpha; a [PostInsertion.afterTonemap] effect works on +/// the display-referred image. +class PostEffect { + PostEffect({ + gpu.Shader? fragmentShader, + this.insertion = PostInsertion.beforeTonemap, + this.enabled = true, + this.useFrameInfo = false, + }) : _fragmentShader = fragmentShader; + + gpu.Shader? _fragmentShader; + + /// The fragment shader run for this effect. Set it via the constructor or + /// [setFragmentShader]; reading it throws until one is set. + gpu.Shader get fragmentShader { + final shader = _fragmentShader; + if (shader == null) { + throw StateError('PostEffect has no fragment shader set.'); + } + return shader; + } + + /// Assigns the fragment shader run for this effect. + void setFragmentShader(gpu.Shader shader) => _fragmentShader = shader; + + /// Where in the chain this effect runs. + PostInsertion insertion; + + /// Whether this effect runs. Disabled effects are skipped. + bool enabled; + + /// Whether the engine binds the `PostFrameInfo` block. Set this when the + /// shader declares and uses it. + bool useFrameInfo; + + final ShaderUniformBindings _bindings = ShaderUniformBindings(); + + /// Assigns the byte contents of a uniform block by name (std140 layout). + void setUniformBlock(String name, ByteData? bytes) => + _bindings.setUniformBlock(name, bytes); + + /// Convenience wrapper around [setUniformBlock] that packs floats. + void setUniformBlockFromFloats(String name, List floats) => + _bindings.setUniformBlockFromFloats(name, floats); + + /// Reads back a previously-set uniform block, or `null`. + ByteData? getUniformBlock(String name) => _bindings.getUniformBlock(name); + + /// All currently-bound uniform block names. + Iterable get uniformBlockNames => _bindings.uniformBlockNames; + + /// Assigns a texture to a sampler uniform by name. + void setTexture( + String name, + gpu.Texture? texture, { + gpu.SamplerOptions? sampler, + }) => _bindings.setTexture(name, texture, sampler: sampler); + + /// Reads back a previously-set texture binding, or `null`. + gpu.Texture? getTexture(String name) => _bindings.getTexture(name); + + /// All currently-bound sampler names. + Iterable get textureNames => _bindings.textureNames; + + /// Binds this effect's own uniform blocks and textures. Engine-internal. + void bindUniforms(gpu.RenderPass pass, gpu.HostBuffer transientsBuffer) => + _bindings.bind(pass, fragmentShader, transientsBuffer); +} diff --git a/packages/flutter_scene/lib/src/post_process/post_process.dart b/packages/flutter_scene/lib/src/post_process/post_process.dart new file mode 100644 index 0000000..fde2c31 --- /dev/null +++ b/packages/flutter_scene/lib/src/post_process/post_process.dart @@ -0,0 +1,121 @@ +import 'package:vector_math/vector_math.dart'; + +import 'package:flutter_scene/src/post_process/post_effect.dart'; + +/// Built-in post-processing settings for a [Scene]. +/// +/// Reachable through `Scene.postProcess`. Every effect is off by default, +/// so a fresh scene does no extra post-processing work. Turn an effect on +/// and adjust its fields to change the final image. +class PostProcessSettings { + /// Color grading applied to the linear HDR scene color before tone + /// mapping. + final ColorGradingSettings colorGrading = ColorGradingSettings(); + + /// Channel separation toward the edges, sampled before grading. + final ChromaticAberrationSettings chromaticAberration = + ChromaticAberrationSettings(); + + /// Edge darkening applied after tone mapping. + final VignetteSettings vignette = VignetteSettings(); + + /// Animated noise applied after tone mapping. + final FilmGrainSettings filmGrain = FilmGrainSettings(); + + /// Bright areas blooming into their surroundings, added in HDR before + /// tone mapping. + final BloomSettings bloom = BloomSettings(); + + /// User-authored custom effects, run in list order at their chosen + /// insertion point (see [PostEffect]). + final List customEffects = []; +} + +/// Color grading applied to the linear HDR scene color, before exposure +/// and tone mapping. +/// +/// The defaults are neutral: with [enabled] off, or every field left at +/// its default, the image is unchanged. +class ColorGradingSettings { + /// Whether color grading runs. Off by default. + bool enabled = false; + + /// Overall color multiplier. `1.0` is neutral. + double brightness = 1.0; + + /// Contrast around mid-gray. `1.0` is neutral. Higher values raise + /// contrast, lower values flatten it. + double contrast = 1.0; + + /// Color saturation. `1.0` is neutral, `0.0` is grayscale, higher + /// values are more saturated. + double saturation = 1.0; + + /// White-balance temperature, from `-1` to `1`. Positive is warmer + /// (more red, less blue), negative is cooler. + double temperature = 0.0; + + /// White-balance tint, from `-1` to `1`. Positive adds green, negative + /// adds magenta. + double tint = 0.0; + + /// Per-channel shadow offset (lift). `(0, 0, 0)` is neutral. + Vector3 lift = Vector3.zero(); + + /// Per-channel midtone power (gamma). `(1, 1, 1)` is neutral. + Vector3 gamma = Vector3.all(1.0); + + /// Per-channel highlight scale (gain). `(1, 1, 1)` is neutral. + Vector3 gain = Vector3.all(1.0); +} + +/// Splits the red and blue channels toward the edges, like a simple lens. +/// Sampled from the scene color before grading and tone mapping. +class ChromaticAberrationSettings { + /// Whether the effect runs. Off by default. + bool enabled = false; + + /// How far the channels separate at the edges. `0` is none. + double intensity = 0.5; +} + +/// Darkens the image toward the edges, after tone mapping. +class VignetteSettings { + /// Whether the vignette runs. Off by default. + bool enabled = false; + + /// How dark the edges become. `0` is none, `1` is fully dark. + double intensity = 0.5; + + /// Where the darkening begins, measured from the center. Smaller values + /// darken more of the image. + double radius = 0.75; + + /// Softness of the falloff from clear to dark. + double smoothness = 0.5; +} + +/// Adds animated noise over the final image, after tone mapping. +class FilmGrainSettings { + /// Whether the grain runs. Off by default. + bool enabled = false; + + /// Strength of the noise. `0` is none. + double intensity = 0.3; +} + +/// Makes bright areas bleed light into their surroundings. Computed in a +/// chain of HDR passes and added back to the scene before tone mapping. +class BloomSettings { + /// Whether bloom runs. Off by default. + bool enabled = false; + + /// HDR brightness above which a pixel starts to bloom. + double threshold = 1.0; + + /// How strongly the bloom is added back to the scene. `0` is none. + double intensity = 0.5; + + /// Spread of the blur, from `0` to `1`. Higher values bloom wider. + double scatter = 0.7; +} diff --git a/packages/flutter_scene/lib/src/render/bloom_pass.dart b/packages/flutter_scene/lib/src/render/bloom_pass.dart new file mode 100644 index 0000000..0789311 --- /dev/null +++ b/packages/flutter_scene/lib/src/render/bloom_pass.dart @@ -0,0 +1,231 @@ +import 'dart:math' as math; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter_scene/src/gpu/gpu.dart' as gpu; + +import 'package:flutter_scene/src/post_process/post_process.dart'; +import 'package:flutter_scene/src/render/render_graph.dart'; +import 'package:flutter_scene/src/render/scene_pass.dart'; +import 'package:flutter_scene/src/render/y_flip.dart'; +import 'package:flutter_scene/src/shaders.dart'; + +/// Render-graph blackboard key for the bloom texture [BloomPass] produces. +/// The resolve pass reads it and adds it to the HDR scene color. +const String kBloomTextureBlackboardKey = 'bloom_texture'; + +// Number of mip levels in the bloom chain, starting at half resolution. +const int _kMipCount = 5; + +const gpu.PixelFormat _hdrFormat = gpu.PixelFormat.r16g16b16a16Float; + +/// Builds the bloom texture: a soft-knee threshold of the HDR scene color +/// blurred through a downsample/upsample mip chain. Reads the scene color +/// from the blackboard and publishes the result under +/// [kBloomTextureBlackboardKey] for [ResolvePass] to composite. +/// +/// Each step is its own full-screen pass, so the chain needs no compute +/// shaders or mipmap generation and runs on the WebGL2 backend. +class BloomPass extends RenderGraphPass { + BloomPass({required ui.Size dimensions, required BloomSettings settings}) + : _dimensions = dimensions, + _settings = settings; + + final ui.Size _dimensions; + final BloomSettings _settings; + + static final gpu.Shader _vertexShader = + baseShaderLibrary['FullscreenVertex']!; + static final gpu.Shader _thresholdShader = + baseShaderLibrary['BloomThresholdFragment']!; + static final gpu.Shader _downsampleShader = + baseShaderLibrary['BloomDownsampleFragment']!; + static final gpu.Shader _upsampleShader = + baseShaderLibrary['BloomUpsampleFragment']!; + + // Two triangles of NDC positions covering the screen (6 vec2s). + static final gpu.DeviceBuffer _quadBuffer = gpu.gpuContext + .createDeviceBufferWithCopy( + ByteData.sublistView( + Float32List.fromList([ + -1.0, -1.0, 1.0, -1.0, -1.0, 1.0, // + -1.0, 1.0, 1.0, -1.0, 1.0, 1.0, // + ]), + ), + ); + static final gpu.BufferView _quadView = gpu.BufferView( + _quadBuffer, + offsetInBytes: 0, + lengthInBytes: 6 * 2 * 4, + ); + + @override + String get name => 'BloomPass'; + + @override + void execute(RenderGraphContext context) { + final scene = context.blackboard.require( + kSceneColorBlackboardKey, + ); + + // Allocate the mip chain at successively halved resolutions. + final mips = []; + final sizes = []; + var width = (_dimensions.width / 2).floor(); + var height = (_dimensions.height / 2).floor(); + for (var i = 0; i < _kMipCount; i++) { + width = math.max(1, width); + height = math.max(1, height); + mips.add( + context.texturePool.acquire( + TransientTextureDescriptor.color( + width: width, + height: height, + format: _hdrFormat, + debugName: 'bloom_$i', + ), + ), + ); + sizes.add(ui.Size(width.toDouble(), height.toDouble())); + width = (width / 2).floor(); + height = (height / 2).floor(); + } + + // Threshold the scene into the first mip. + _drawThreshold(context, scene, mips[0]); + + // Downsample down the chain. + for (var i = 1; i < mips.length; i++) { + _drawFilter( + context, + _downsampleShader, + source: mips[i - 1], + sourceSize: sizes[i - 1], + target: mips[i], + additive: false, + ); + } + + // Upsample back up, adding each level into the next larger one. + for (var i = mips.length - 2; i >= 0; i--) { + _drawFilter( + context, + _upsampleShader, + source: mips[i + 1], + sourceSize: sizes[i + 1], + target: mips[i], + additive: true, + ); + } + + context.blackboard.set(kBloomTextureBlackboardKey, mips[0]); + } + + void _drawThreshold( + RenderGraphContext context, + gpu.Texture source, + gpu.Texture target, + ) { + final commandBuffer = gpu.gpuContext.createCommandBuffer(); + final renderPass = commandBuffer.createRenderPass( + gpu.RenderTarget.singleColor(gpu.ColorAttachment(texture: target)), + ); + renderPass.bindPipeline( + gpu.gpuContext.createRenderPipeline(_vertexShader, _thresholdShader), + ); + renderPass.setColorBlendEnable(false); + renderPass.bindVertexBuffer(_quadView, 6); + _bindFlip(context, renderPass); + + final knee = _settings.threshold * 0.5 + 1e-4; + final info = + Float32List(4) + ..[0] = _settings.threshold + ..[1] = knee; + renderPass.bindUniform( + _thresholdShader.getUniformSlot('BloomThresholdInfo'), + context.transientsBuffer.emplace(ByteData.sublistView(info)), + ); + renderPass.bindTexture( + _thresholdShader.getUniformSlot('source'), + source, + sampler: _linearClamp, + ); + renderPass.draw(); + commandBuffer.submit(); + } + + void _drawFilter( + RenderGraphContext context, + gpu.Shader shader, { + required gpu.Texture source, + required ui.Size sourceSize, + required gpu.Texture target, + required bool additive, + }) { + final commandBuffer = gpu.gpuContext.createCommandBuffer(); + final attachment = gpu.ColorAttachment( + texture: target, + // Additive upsample preserves the downsample result already in the + // target; threshold/downsample overwrite it. + loadAction: additive ? gpu.LoadAction.load : gpu.LoadAction.clear, + ); + final renderPass = commandBuffer.createRenderPass( + gpu.RenderTarget.singleColor(attachment), + ); + renderPass.bindPipeline( + gpu.gpuContext.createRenderPipeline(_vertexShader, shader), + ); + if (additive) { + renderPass.setColorBlendEnable(true); + renderPass.setColorBlendEquation( + gpu.ColorBlendEquation( + colorBlendOperation: gpu.BlendOperation.add, + sourceColorBlendFactor: gpu.BlendFactor.one, + destinationColorBlendFactor: gpu.BlendFactor.one, + alphaBlendOperation: gpu.BlendOperation.add, + sourceAlphaBlendFactor: gpu.BlendFactor.one, + destinationAlphaBlendFactor: gpu.BlendFactor.one, + ), + ); + } else { + renderPass.setColorBlendEnable(false); + } + renderPass.bindVertexBuffer(_quadView, 6); + _bindFlip(context, renderPass); + + final info = + Float32List(4) + ..[0] = 1.0 / sourceSize.width + ..[1] = 1.0 / sourceSize.height + ..[2] = _settings.scatter; + renderPass.bindUniform( + shader.getUniformSlot('BloomFilterInfo'), + context.transientsBuffer.emplace(ByteData.sublistView(info)), + ); + renderPass.bindTexture( + shader.getUniformSlot('source'), + source, + sampler: _linearClamp, + ); + renderPass.draw(); + commandBuffer.submit(); + } + + // Binds the full-screen vertex shader's Y-flip sign (see y_flip.dart) so + // each bloom pass stores its output top-down. + void _bindFlip(RenderGraphContext context, gpu.RenderPass renderPass) { + final flipInfo = Float32List(4)..[0] = backendYFlipSign; + renderPass.bindUniform( + _vertexShader.getUniformSlot('FlipInfo'), + context.transientsBuffer.emplace(ByteData.sublistView(flipInfo)), + ); + } + + static final gpu.SamplerOptions _linearClamp = gpu.SamplerOptions( + minFilter: gpu.MinMagFilter.linear, + magFilter: gpu.MinMagFilter.linear, + widthAddressMode: gpu.SamplerAddressMode.clampToEdge, + heightAddressMode: gpu.SamplerAddressMode.clampToEdge, + ); +} diff --git a/packages/flutter_scene/lib/src/render/post_effect_pass.dart b/packages/flutter_scene/lib/src/render/post_effect_pass.dart new file mode 100644 index 0000000..d8332a4 --- /dev/null +++ b/packages/flutter_scene/lib/src/render/post_effect_pass.dart @@ -0,0 +1,122 @@ +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter_scene/src/gpu/gpu.dart' as gpu; + +import 'package:flutter_scene/src/post_process/post_effect.dart'; +import 'package:flutter_scene/src/render/render_graph.dart'; +import 'package:flutter_scene/src/render/y_flip.dart'; +import 'package:flutter_scene/src/shaders.dart'; + +/// Runs one custom [PostEffect] as a full-screen pass. +/// +/// Reads the current color from [inputKey] on the blackboard, renders the +/// effect's shader into [output], and republishes the result under +/// [outputKey] so the next pass picks it up. The engine binds the input as +/// `input_color`; when [PostEffect.useFrameInfo] is set it also binds a +/// `PostFrameInfo` block. +class PostEffectPass extends RenderGraphPass { + PostEffectPass({ + required PostEffect effect, + required String inputKey, + required String outputKey, + required gpu.Texture output, + required ui.Size dimensions, + required double time, + }) : _effect = effect, + _inputKey = inputKey, + _outputKey = outputKey, + _output = output, + _dimensions = dimensions, + _time = time; + + final PostEffect _effect; + final String _inputKey; + final String _outputKey; + final gpu.Texture _output; + final ui.Size _dimensions; + final double _time; + + static final gpu.Shader _vertexShader = + baseShaderLibrary['FullscreenVertex']!; + + // Two triangles of NDC positions covering the screen (6 vec2s). + static final gpu.DeviceBuffer _quadBuffer = gpu.gpuContext + .createDeviceBufferWithCopy( + ByteData.sublistView( + Float32List.fromList([ + -1.0, -1.0, 1.0, -1.0, -1.0, 1.0, // + -1.0, 1.0, 1.0, -1.0, 1.0, 1.0, // + ]), + ), + ); + static final gpu.BufferView _quadView = gpu.BufferView( + _quadBuffer, + offsetInBytes: 0, + lengthInBytes: 6 * 2 * 4, + ); + + static final gpu.SamplerOptions _linearClamp = gpu.SamplerOptions( + minFilter: gpu.MinMagFilter.linear, + magFilter: gpu.MinMagFilter.linear, + widthAddressMode: gpu.SamplerAddressMode.clampToEdge, + heightAddressMode: gpu.SamplerAddressMode.clampToEdge, + ); + + @override + String get name => 'PostEffectPass'; + + @override + void execute(RenderGraphContext context) { + final input = context.blackboard.require(_inputKey); + final shader = _effect.fragmentShader; + + final commandBuffer = gpu.gpuContext.createCommandBuffer(); + final renderPass = commandBuffer.createRenderPass( + gpu.RenderTarget.singleColor(gpu.ColorAttachment(texture: _output)), + ); + renderPass.bindPipeline( + gpu.gpuContext.createRenderPipeline(_vertexShader, shader), + ); + renderPass.bindVertexBuffer(_quadView, 6); + + // Vertex-stage Y-flip so this pass stores its output top-down (see + // y_flip.dart). + final flipInfo = Float32List(4)..[0] = backendYFlipSign; + renderPass.bindUniform( + _vertexShader.getUniformSlot('FlipInfo'), + context.transientsBuffer.emplace(ByteData.sublistView(flipInfo)), + ); + + renderPass.bindTexture( + shader.getUniformSlot('input_color'), + input, + sampler: _linearClamp, + ); + + if (_effect.useFrameInfo) { + // PostFrameInfo std140: { vec2 resolution; vec2 texel_size; + // float time; float pad; }, padded to 32 bytes. + final w = _dimensions.width; + final h = _dimensions.height; + final info = + Float32List(8) + ..[0] = w + ..[1] = h + ..[2] = w == 0 ? 0.0 : 1.0 / w + ..[3] = h == 0 ? 0.0 : 1.0 / h + ..[4] = _time; + renderPass.bindUniform( + shader.getUniformSlot('PostFrameInfo'), + context.transientsBuffer.emplace(ByteData.sublistView(info)), + ); + } + + _effect.bindUniforms(renderPass, context.transientsBuffer); + + renderPass.draw(); + commandBuffer.submit(); + + context.blackboard.set(_outputKey, _output); + } +} diff --git a/packages/flutter_scene/lib/src/render/resolve_info.dart b/packages/flutter_scene/lib/src/render/resolve_info.dart new file mode 100644 index 0000000..ab9d885 --- /dev/null +++ b/packages/flutter_scene/lib/src/render/resolve_info.dart @@ -0,0 +1,83 @@ +import 'dart:typed_data'; + +import 'package:flutter_scene/src/post_process/post_process.dart'; +import 'package:flutter_scene/src/tone_mapping.dart'; + +/// Number of floats in the `ResolveInfo` uniform block: ten std140 rows +/// of four floats each. +const int kResolveInfoFloatCount = 40; + +/// Packs the resolve pass's `ResolveInfo` uniform block. +/// +/// The layout matches the std140 block in `flutter_scene_resolve.frag`. +/// `vec3` grading channels are stored as the first three floats of a +/// 16-byte row so the packing is straightforward. Kept as a pure function +/// so it can be unit tested without a GPU context. +/// +/// [time] is a wall-clock seconds value used to animate film grain. +Float32List packResolveInfo({ + required double exposure, + required ToneMappingMode toneMappingMode, + required bool flipY, + required double time, + required PostProcessSettings settings, +}) { + final grading = settings.colorGrading; + final aberration = settings.chromaticAberration; + final vignette = settings.vignette; + final grain = settings.filmGrain; + final bloom = settings.bloom; + + final info = Float32List(kResolveInfoFloatCount); + + // Row 0: resolve controls. + info[0] = exposure; + info[1] = toneMappingMode.index.toDouble(); + info[2] = flipY ? 1.0 : 0.0; + info[3] = grading.enabled ? 1.0 : 0.0; + + // Row 1: scalar grading controls. + info[4] = grading.brightness; + info[5] = grading.contrast; + info[6] = grading.saturation; + info[7] = grading.temperature; + + // Row 2: tint, then padding. + info[8] = grading.tint; + + // Row 3: lift (xyz), then padding. + info[12] = grading.lift.x; + info[13] = grading.lift.y; + info[14] = grading.lift.z; + + // Row 4: gamma (xyz), then padding. + info[16] = grading.gamma.x; + info[17] = grading.gamma.y; + info[18] = grading.gamma.z; + + // Row 5: gain (xyz), then padding. + info[20] = grading.gain.x; + info[21] = grading.gain.y; + info[22] = grading.gain.z; + + // Row 6: chromatic aberration, then time. + info[24] = aberration.enabled ? 1.0 : 0.0; + info[25] = aberration.intensity; + info[26] = time; + + // Row 7: vignette. + info[28] = vignette.enabled ? 1.0 : 0.0; + info[29] = vignette.intensity; + info[30] = vignette.radius; + info[31] = vignette.smoothness; + + // Row 8: film grain, then padding. + info[32] = grain.enabled ? 1.0 : 0.0; + info[33] = grain.intensity; + + // Row 9: bloom, then padding. + info[36] = bloom.enabled ? 1.0 : 0.0; + info[37] = bloom.intensity; + + return info; +} diff --git a/packages/flutter_scene/lib/src/render/resolve_pass.dart b/packages/flutter_scene/lib/src/render/resolve_pass.dart new file mode 100644 index 0000000..3a5263a --- /dev/null +++ b/packages/flutter_scene/lib/src/render/resolve_pass.dart @@ -0,0 +1,138 @@ +import 'dart:typed_data'; + +import 'package:flutter_scene/src/gpu/gpu.dart' as gpu; + +import 'package:flutter_scene/src/material/material.dart'; +import 'package:flutter_scene/src/post_process/post_process.dart'; +import 'package:flutter_scene/src/render/bloom_pass.dart'; +import 'package:flutter_scene/src/render/render_graph.dart'; +import 'package:flutter_scene/src/render/resolve_info.dart'; +import 'package:flutter_scene/src/render/scene_pass.dart'; +import 'package:flutter_scene/src/render/y_flip.dart'; +import 'package:flutter_scene/src/shaders.dart'; +import 'package:flutter_scene/src/tone_mapping.dart'; + +/// Render-graph blackboard key for the display-referred color the resolve +/// pass produces. After-tone-mapping custom effects read it and republish +/// their own output. +const String kDisplayColorBlackboardKey = 'display_color'; + +/// Resolves the linear HDR scene color (a floating-point render target +/// produced by [ScenePass], read from the blackboard) into the +/// display-referred image: applies exposure, optional color grading, the +/// tone mapping operator, and the display EOTF as a single full-screen +/// pass. Writes into [outputColor] and publishes it on the blackboard. +class ResolvePass extends RenderGraphPass { + ResolvePass({ + required gpu.Texture outputColor, + required double exposure, + required ToneMappingMode toneMappingMode, + required PostProcessSettings postProcess, + }) : _outputColor = outputColor, + _exposure = exposure, + _toneMappingMode = toneMappingMode, + _postProcess = postProcess; + + final gpu.Texture _outputColor; + final double _exposure; + final ToneMappingMode _toneMappingMode; + final PostProcessSettings _postProcess; + + static final gpu.Shader _vertexShader = + baseShaderLibrary['FullscreenVertex']!; + static final gpu.Shader _fragmentShader = + baseShaderLibrary['ResolveFragment']!; + + // Two triangles of NDC positions covering the screen (6 vec2s). + static final gpu.DeviceBuffer _quadBuffer = gpu.gpuContext + .createDeviceBufferWithCopy( + ByteData.sublistView( + Float32List.fromList([ + -1.0, -1.0, 1.0, -1.0, -1.0, 1.0, // + -1.0, 1.0, 1.0, -1.0, 1.0, 1.0, // + ]), + ), + ); + static final gpu.BufferView _quadView = gpu.BufferView( + _quadBuffer, + offsetInBytes: 0, + lengthInBytes: 6 * 2 * 4, + ); + + @override + String get name => 'ResolvePass'; + + @override + void execute(RenderGraphContext context) { + final hdrColor = context.blackboard.require( + kSceneColorBlackboardKey, + ); + + final commandBuffer = gpu.gpuContext.createCommandBuffer(); + final renderPass = commandBuffer.createRenderPass( + gpu.RenderTarget.singleColor(gpu.ColorAttachment(texture: _outputColor)), + ); + final pipeline = gpu.gpuContext.createRenderPipeline( + _vertexShader, + _fragmentShader, + ); + renderPass.bindPipeline(pipeline); + renderPass.bindVertexBuffer(_quadView, 6); + + // Vertex-stage Y-flip: stores this pass's output top-down on backends + // that need it (the OpenGL ES workaround; see y_flip.dart). + final flipInfo = Float32List(4); + flipInfo[0] = backendYFlipSign; + renderPass.bindUniform( + _vertexShader.getUniformSlot('FlipInfo'), + context.transientsBuffer.emplace(ByteData.sublistView(flipInfo)), + ); + + // Wall-clock seconds (wrapped to keep float precision) drive the + // animated film grain. + final timeSeconds = + DateTime.now().millisecondsSinceEpoch.remainder(100000) / 1000.0; + // The vertex stage handles the render-to-texture Y-flip, so the resolve + // samples without a fragment-stage V-flip (flipY is always false). + final info = packResolveInfo( + exposure: _exposure, + toneMappingMode: _toneMappingMode, + flipY: false, + time: timeSeconds, + settings: _postProcess, + ); + renderPass.bindUniform( + _fragmentShader.getUniformSlot('ResolveInfo'), + context.transientsBuffer.emplace(ByteData.sublistView(info)), + ); + renderPass.bindTexture( + _fragmentShader.getUniformSlot('scene_color'), + hdrColor, + sampler: gpu.SamplerOptions( + minFilter: gpu.MinMagFilter.linear, + magFilter: gpu.MinMagFilter.linear, + widthAddressMode: gpu.SamplerAddressMode.clampToEdge, + heightAddressMode: gpu.SamplerAddressMode.clampToEdge, + ), + ); + // Bloom is present only when BloomPass ran this frame; otherwise a + // placeholder fills the slot and the resolve skips it (flag off). + final bloomTexture = + context.blackboard.get(kBloomTextureBlackboardKey) ?? + Material.getWhitePlaceholderTexture(); + renderPass.bindTexture( + _fragmentShader.getUniformSlot('bloom_color'), + bloomTexture, + sampler: gpu.SamplerOptions( + minFilter: gpu.MinMagFilter.linear, + magFilter: gpu.MinMagFilter.linear, + widthAddressMode: gpu.SamplerAddressMode.clampToEdge, + heightAddressMode: gpu.SamplerAddressMode.clampToEdge, + ), + ); + renderPass.draw(); + commandBuffer.submit(); + + context.blackboard.set(kDisplayColorBlackboardKey, _outputColor); + } +} diff --git a/packages/flutter_scene/lib/src/render/scene_pass.dart b/packages/flutter_scene/lib/src/render/scene_pass.dart index 7caaba7..cbc9e2e 100644 --- a/packages/flutter_scene/lib/src/render/scene_pass.dart +++ b/packages/flutter_scene/lib/src/render/scene_pass.dart @@ -11,13 +11,16 @@ import 'package:flutter_scene/src/render/render_scene.dart'; import 'package:flutter_scene/src/render/shadow_pass.dart'; import 'package:flutter_scene/src/scene_encoder.dart'; -/// Render-graph blackboard key for the linear HDR scene-color texture -/// [ScenePass] produces. The downstream tone-mapping pass reads it. -const String kHdrColorBlackboardKey = 'hdr_scene_color'; +/// Render-graph blackboard key for the current scene-color texture. +/// +/// [ScenePass] publishes the linear HDR scene color here. Post-processing +/// passes read it and republish their own output, so the resolve pass +/// reads whatever the last pass produced. +const String kSceneColorBlackboardKey = 'scene_color'; /// Draws the scene's render items (opaque, then depth-sorted /// translucent) into a floating-point HDR color target, publishing it on -/// the render-graph blackboard for the tone-mapping pass to resolve. If a +/// the render-graph blackboard for the resolve pass to read. If a /// [ShadowPass] ran earlier this frame its shadow map is picked up from /// the blackboard and threaded into the per-draw [Lighting]. class ScenePass extends RenderGraphPass { @@ -128,6 +131,6 @@ class ScenePass extends RenderGraphPass { encoder.flush(); commandBuffer.submit(); - context.blackboard.set(kHdrColorBlackboardKey, hdrColor); + context.blackboard.set(kSceneColorBlackboardKey, hdrColor); } } diff --git a/packages/flutter_scene/lib/src/render/tonemap_pass.dart b/packages/flutter_scene/lib/src/render/tonemap_pass.dart deleted file mode 100644 index 7d5344f..0000000 --- a/packages/flutter_scene/lib/src/render/tonemap_pass.dart +++ /dev/null @@ -1,102 +0,0 @@ -import 'dart:typed_data'; - -import 'package:flutter_scene/src/gpu/gpu.dart' as gpu; - -import 'package:flutter_scene/src/render/render_graph.dart'; -import 'package:flutter_scene/src/render/scene_pass.dart'; -import 'package:flutter_scene/src/render/y_flip.dart'; -import 'package:flutter_scene/src/shaders.dart'; -import 'package:flutter_scene/src/tone_mapping.dart'; - -/// Resolves the linear HDR scene color (a floating-point render target -/// produced by [ScenePass], read from the blackboard) into the -/// display-referred swapchain image: applies exposure, the tone mapping -/// operator, and the display EOTF as a single full-screen pass. -class TonemapPass extends RenderGraphPass { - TonemapPass({ - required gpu.RenderTarget target, - required double exposure, - required ToneMappingMode toneMappingMode, - }) : _target = target, - _exposure = exposure, - _toneMappingMode = toneMappingMode; - - final gpu.RenderTarget _target; - final double _exposure; - final ToneMappingMode _toneMappingMode; - - static final gpu.Shader _vertexShader = - baseShaderLibrary['FullscreenVertex']!; - static final gpu.Shader _fragmentShader = - baseShaderLibrary['TonemapFragment']!; - - // Two triangles of NDC positions covering the screen (6 vec2s). - static final gpu.DeviceBuffer _quadBuffer = gpu.gpuContext - .createDeviceBufferWithCopy( - ByteData.sublistView( - Float32List.fromList([ - -1.0, -1.0, 1.0, -1.0, -1.0, 1.0, // - -1.0, 1.0, 1.0, -1.0, 1.0, 1.0, // - ]), - ), - ); - static final gpu.BufferView _quadView = gpu.BufferView( - _quadBuffer, - offsetInBytes: 0, - lengthInBytes: 6 * 2 * 4, - ); - - @override - String get name => 'TonemapPass'; - - @override - void execute(RenderGraphContext context) { - final hdrColor = context.blackboard.require( - kHdrColorBlackboardKey, - ); - - final commandBuffer = gpu.gpuContext.createCommandBuffer(); - final renderPass = commandBuffer.createRenderPass(_target); - final pipeline = gpu.gpuContext.createRenderPipeline( - _vertexShader, - _fragmentShader, - ); - renderPass.bindPipeline(pipeline); - renderPass.bindVertexBuffer(_quadView, 6); - - // Vertex-stage Y-flip: stores this pass's output top-down on backends - // that need it (the OpenGL ES workaround; see y_flip.dart). - final flipInfo = Float32List(4); - flipInfo[0] = backendYFlipSign; - renderPass.bindUniform( - _vertexShader.getUniformSlot('FlipInfo'), - context.transientsBuffer.emplace(ByteData.sublistView(flipInfo)), - ); - - // TonemapInfo std140: { float exposure; float tone_mapping_mode; - // float flip_y; float pad; }. flip_y flips the V coordinate when - // sampling the HDR target. With the vertex-stage flip above, every - // backend's HDR target is now stored top-down, so no sampling flip is - // needed (0.0). Kept as a uniform for the shader contract. - final info = Float32List(4); - info[0] = _exposure; - info[1] = _toneMappingMode.index.toDouble(); - info[2] = 0.0; - renderPass.bindUniform( - _fragmentShader.getUniformSlot('TonemapInfo'), - context.transientsBuffer.emplace(ByteData.sublistView(info)), - ); - renderPass.bindTexture( - _fragmentShader.getUniformSlot('hdr_color'), - hdrColor, - sampler: gpu.SamplerOptions( - minFilter: gpu.MinMagFilter.linear, - magFilter: gpu.MinMagFilter.linear, - widthAddressMode: gpu.SamplerAddressMode.clampToEdge, - heightAddressMode: gpu.SamplerAddressMode.clampToEdge, - ), - ); - renderPass.draw(); - commandBuffer.submit(); - } -} diff --git a/packages/flutter_scene/lib/src/scene.dart b/packages/flutter_scene/lib/src/scene.dart index 326d390..8547893 100644 --- a/packages/flutter_scene/lib/src/scene.dart +++ b/packages/flutter_scene/lib/src/scene.dart @@ -11,11 +11,15 @@ import 'material/environment.dart'; import 'material/material.dart'; import 'mesh.dart'; import 'node.dart'; +import 'post_process/post_effect.dart'; +import 'post_process/post_process.dart'; +import 'render/bloom_pass.dart'; +import 'render/post_effect_pass.dart'; import 'render/render_graph.dart'; import 'render/render_scene.dart'; import 'render/scene_pass.dart'; import 'render/shadow_pass.dart'; -import 'render/tonemap_pass.dart'; +import 'render/resolve_pass.dart'; import 'render/y_flip.dart'; import 'shaders.dart'; import 'surface.dart'; @@ -203,6 +207,10 @@ base class Scene implements SceneGraph { /// display image. Defaults to [ToneMappingMode.pbrNeutral]. ToneMappingMode toneMapping = ToneMappingMode.pbrNeutral; + /// Built-in post-processing settings, such as color grading. Every + /// effect is off by default. + final PostProcessSettings postProcess = PostProcessSettings(); + @override void add(Node child) { root.add(child); @@ -308,9 +316,6 @@ base class Scene implements SceneGraph { final gpu.Texture swapchainColor = surface.getNextSwapchainColorTexture( pixelSize, ); - final swapchainTarget = gpu.RenderTarget.singleColor( - gpu.ColorAttachment(texture: swapchainColor), - ); // Resolve the IBL environment up front (before building the render // graph): the default is built lazily here on first use, which submits @@ -371,13 +376,106 @@ base class Scene implements SceneGraph { cascades: cascades, ), ); + // Split custom effects by where they run in the chain. + final beforeTonemap = []; + final afterTonemap = []; + for (final effect in postProcess.customEffects) { + if (!effect.enabled) { + continue; + } + if (effect.insertion == PostInsertion.beforeTonemap) { + beforeTonemap.add(effect); + } else { + afterTonemap.add(effect); + } + } + + final pool = surface.transientTexturePool; + final width = pixelSize.width.toInt(); + final height = pixelSize.height.toInt(); + final postTime = + DateTime.now().millisecondsSinceEpoch.remainder(100000) / 1000.0; + + // Custom effects on the linear HDR scene color, ping-ponging through + // HDR buffers and republishing the scene-color handle that bloom and + // the resolve read. + for (var i = 0; i < beforeTonemap.length; i++) { + final output = pool.acquire( + TransientTextureDescriptor.color( + width: width, + height: height, + format: gpu.PixelFormat.r16g16b16a16Float, + debugName: i.isEven ? 'post_hdr_a' : 'post_hdr_b', + ), + ); + graph.addPass( + PostEffectPass( + effect: beforeTonemap[i], + inputKey: kSceneColorBlackboardKey, + outputKey: kSceneColorBlackboardKey, + output: output, + dimensions: pixelSize, + time: postTime, + ), + ); + } + + // Bloom runs in HDR before the resolve, which composites it back in. + if (postProcess.bloom.enabled) { + graph.addPass( + BloomPass(dimensions: pixelSize, settings: postProcess.bloom), + ); + } + + // The resolve writes the swapchain directly unless after-tone-mapping + // effects need an intermediate buffer to chain on. + final gpu.Texture resolveOutput = + afterTonemap.isEmpty + ? swapchainColor + : pool.acquire( + TransientTextureDescriptor.color( + width: width, + height: height, + format: swapchainColor.format, + debugName: 'post_ldr_resolve', + ), + ); graph.addPass( - TonemapPass( - target: swapchainTarget, + ResolvePass( + outputColor: resolveOutput, exposure: exposure, toneMappingMode: toneMapping, + postProcess: postProcess, ), ); + + // Custom effects on the display-referred image. The last one writes + // the swapchain that gets composited onto the canvas. + for (var i = 0; i < afterTonemap.length; i++) { + final isLast = i == afterTonemap.length - 1; + final output = + isLast + ? swapchainColor + : pool.acquire( + TransientTextureDescriptor.color( + width: width, + height: height, + format: swapchainColor.format, + debugName: i.isEven ? 'post_ldr_a' : 'post_ldr_b', + ), + ); + graph.addPass( + PostEffectPass( + effect: afterTonemap[i], + inputKey: kDisplayColorBlackboardKey, + outputKey: kDisplayColorBlackboardKey, + output: output, + dimensions: pixelSize, + time: postTime, + ), + ); + } + graph.execute( transientsBuffer: transientsBuffer, texturePool: surface.transientTexturePool, diff --git a/packages/flutter_scene/lib/src/shader_uniform_bindings.dart b/packages/flutter_scene/lib/src/shader_uniform_bindings.dart new file mode 100644 index 0000000..e64a04a --- /dev/null +++ b/packages/flutter_scene/lib/src/shader_uniform_bindings.dart @@ -0,0 +1,74 @@ +import 'dart:typed_data'; + +import 'package:flutter_scene/src/gpu/gpu.dart' as gpu; + +/// Stores caller-supplied uniform blocks and textures keyed by name and +/// binds them to a render pass against a shader's reflection. +/// +/// Used by the custom-shader surfaces (a [PostEffect], and a candidate for +/// `ShaderMaterial`) so they pack and bind uniforms the same way. The +/// block bytes must already follow the shader's std140 layout. +class ShaderUniformBindings { + final Map _uniformBlocks = {}; + final Map _textures = {}; + + void setUniformBlock(String name, ByteData? bytes) { + if (bytes == null) { + _uniformBlocks.remove(name); + } else { + _uniformBlocks[name] = bytes; + } + } + + void setUniformBlockFromFloats(String name, List floats) { + setUniformBlock(name, ByteData.sublistView(Float32List.fromList(floats))); + } + + ByteData? getUniformBlock(String name) => _uniformBlocks[name]; + + Iterable get uniformBlockNames => _uniformBlocks.keys; + + void setTexture( + String name, + gpu.Texture? texture, { + gpu.SamplerOptions? sampler, + }) { + if (texture == null) { + _textures.remove(name); + } else { + _textures[name] = _BoundTexture(texture, sampler); + } + } + + gpu.Texture? getTexture(String name) => _textures[name]?.texture; + + Iterable get textureNames => _textures.keys; + + /// Binds every stored block and texture to [pass], resolving slots + /// against [shader] and emplacing block bytes into [transientsBuffer]. + void bind( + gpu.RenderPass pass, + gpu.Shader shader, + gpu.HostBuffer transientsBuffer, + ) { + for (final entry in _uniformBlocks.entries) { + pass.bindUniform( + shader.getUniformSlot(entry.key), + transientsBuffer.emplace(entry.value), + ); + } + for (final entry in _textures.entries) { + pass.bindTexture( + shader.getUniformSlot(entry.key), + entry.value.texture, + sampler: entry.value.sampler ?? gpu.SamplerOptions(), + ); + } + } +} + +class _BoundTexture { + _BoundTexture(this.texture, this.sampler); + final gpu.Texture texture; + final gpu.SamplerOptions? sampler; +} diff --git a/packages/flutter_scene/shaders/base.shaderbundle.json b/packages/flutter_scene/shaders/base.shaderbundle.json index c0aec43..145b1dc 100644 --- a/packages/flutter_scene/shaders/base.shaderbundle.json +++ b/packages/flutter_scene/shaders/base.shaderbundle.json @@ -23,9 +23,9 @@ "type": "vertex", "file": "shaders/flutter_scene_fullscreen.vert" }, - "TonemapFragment": { + "ResolveFragment": { "type": "fragment", - "file": "shaders/flutter_scene_tonemap.frag" + "file": "shaders/flutter_scene_resolve.frag" }, "PrefilterEnvFragment": { "type": "fragment", @@ -34,5 +34,17 @@ "YFlipProbeFragment": { "type": "fragment", "file": "shaders/flutter_scene_yflip_probe.frag" + }, + "BloomThresholdFragment": { + "type": "fragment", + "file": "shaders/flutter_scene_bloom_threshold.frag" + }, + "BloomDownsampleFragment": { + "type": "fragment", + "file": "shaders/flutter_scene_bloom_downsample.frag" + }, + "BloomUpsampleFragment": { + "type": "fragment", + "file": "shaders/flutter_scene_bloom_upsample.frag" } } diff --git a/packages/flutter_scene/shaders/flutter_scene_bloom_downsample.frag b/packages/flutter_scene/shaders/flutter_scene_bloom_downsample.frag new file mode 100644 index 0000000..b97afe6 --- /dev/null +++ b/packages/flutter_scene/shaders/flutter_scene_bloom_downsample.frag @@ -0,0 +1,40 @@ +// Bloom downsample: a 13-tap filter that halves the resolution while +// blurring, used to build the bloom mip chain. texel_size is the size of +// one texel in the source (larger) mip. +uniform BloomFilterInfo { + vec2 texel_size; + float scatter; + float _pad0; +} +filter_info; + +uniform sampler2D source; + +in vec2 v_uv; + +out vec4 frag_color; + +void main() { + vec2 t = filter_info.texel_size; + + vec3 a = texture(source, v_uv + t * vec2(-2.0, -2.0)).rgb; + vec3 b = texture(source, v_uv + t * vec2(0.0, -2.0)).rgb; + vec3 c = texture(source, v_uv + t * vec2(2.0, -2.0)).rgb; + vec3 d = texture(source, v_uv + t * vec2(-2.0, 0.0)).rgb; + vec3 e = texture(source, v_uv).rgb; + vec3 f = texture(source, v_uv + t * vec2(2.0, 0.0)).rgb; + vec3 g = texture(source, v_uv + t * vec2(-2.0, 2.0)).rgb; + vec3 h = texture(source, v_uv + t * vec2(0.0, 2.0)).rgb; + vec3 i = texture(source, v_uv + t * vec2(2.0, 2.0)).rgb; + vec3 j = texture(source, v_uv + t * vec2(-1.0, -1.0)).rgb; + vec3 k = texture(source, v_uv + t * vec2(1.0, -1.0)).rgb; + vec3 l = texture(source, v_uv + t * vec2(-1.0, 1.0)).rgb; + vec3 m = texture(source, v_uv + t * vec2(1.0, 1.0)).rgb; + + vec3 result = e * 0.125; + result += (a + c + g + i) * 0.03125; + result += (b + d + f + h) * 0.0625; + result += (j + k + l + m) * 0.125; + + frag_color = vec4(result, 1.0); +} diff --git a/packages/flutter_scene/shaders/flutter_scene_bloom_threshold.frag b/packages/flutter_scene/shaders/flutter_scene_bloom_threshold.frag new file mode 100644 index 0000000..42998f3 --- /dev/null +++ b/packages/flutter_scene/shaders/flutter_scene_bloom_threshold.frag @@ -0,0 +1,31 @@ +// Bloom prefilter: extracts the bright part of the scene color with a +// soft-knee threshold, writing it into the first (half-resolution) bloom +// mip. Works on un-premultiplied linear HDR radiance. +uniform BloomThresholdInfo { + float threshold; + float knee; + float _pad0; + float _pad1; +} +threshold_info; + +uniform sampler2D source; + +in vec2 v_uv; + +out vec4 frag_color; + +void main() { + vec4 s = texture(source, v_uv); + vec3 color = s.a > 0.0 ? s.rgb / s.a : vec3(0.0); + float brightness = max(color.r, max(color.g, color.b)); + + // Soft knee around the threshold so the bloom fades in gradually. + float knee = threshold_info.knee; + float soft = clamp(brightness - threshold_info.threshold + knee, 0.0, 2.0 * knee); + soft = soft * soft / (4.0 * knee + 1e-4); + float contribution = + max(soft, brightness - threshold_info.threshold) / max(brightness, 1e-4); + + frag_color = vec4(color * contribution, 1.0); +} diff --git a/packages/flutter_scene/shaders/flutter_scene_bloom_upsample.frag b/packages/flutter_scene/shaders/flutter_scene_bloom_upsample.frag new file mode 100644 index 0000000..0f95a86 --- /dev/null +++ b/packages/flutter_scene/shaders/flutter_scene_bloom_upsample.frag @@ -0,0 +1,32 @@ +// Bloom upsample: a 3x3 tent filter that blurs the smaller mip as it is +// added back up the chain. texel_size is the size of one texel in the +// source (smaller) mip; scatter widens the tent for a softer bloom. +uniform BloomFilterInfo { + vec2 texel_size; + float scatter; + float _pad0; +} +filter_info; + +uniform sampler2D source; + +in vec2 v_uv; + +out vec4 frag_color; + +void main() { + vec2 t = filter_info.texel_size * mix(1.0, 3.0, clamp(filter_info.scatter, 0.0, 1.0)); + + vec3 sum = texture(source, v_uv + t * vec2(-1.0, -1.0)).rgb; + sum += texture(source, v_uv + t * vec2(0.0, -1.0)).rgb * 2.0; + sum += texture(source, v_uv + t * vec2(1.0, -1.0)).rgb; + sum += texture(source, v_uv + t * vec2(-1.0, 0.0)).rgb * 2.0; + sum += texture(source, v_uv).rgb * 4.0; + sum += texture(source, v_uv + t * vec2(1.0, 0.0)).rgb * 2.0; + sum += texture(source, v_uv + t * vec2(-1.0, 1.0)).rgb; + sum += texture(source, v_uv + t * vec2(0.0, 1.0)).rgb * 2.0; + sum += texture(source, v_uv + t * vec2(1.0, 1.0)).rgb; + sum *= 1.0 / 16.0; + + frag_color = vec4(sum, 1.0); +} diff --git a/packages/flutter_scene/shaders/flutter_scene_fullscreen.vert b/packages/flutter_scene/shaders/flutter_scene_fullscreen.vert index e831406..237cc95 100644 --- a/packages/flutter_scene/shaders/flutter_scene_fullscreen.vert +++ b/packages/flutter_scene/shaders/flutter_scene_fullscreen.vert @@ -5,7 +5,7 @@ // UV is derived from the position; V increases downward (origin at the // top), matching the standard texture-sampling convention. Passes that // sample a render-to-texture input account for that target's per-backend -// Y orientation themselves (see flutter_scene_tonemap.frag's flip_y). +// Y orientation themselves (see flutter_scene_resolve.frag's flip_y). // flip_y is -1 on backends where flutter_scene flips render-to-texture in // the vertex stage (the OpenGL ES Y-flip workaround; see y_flip.dart), +1 // otherwise. It negates gl_Position.y so this pass's offscreen target is @@ -14,7 +14,6 @@ uniform FlipInfo { float flip_y; } flip_info; - in vec2 position; out vec2 v_uv; diff --git a/packages/flutter_scene/shaders/flutter_scene_resolve.frag b/packages/flutter_scene/shaders/flutter_scene_resolve.frag new file mode 100644 index 0000000..8ab0f4e --- /dev/null +++ b/packages/flutter_scene/shaders/flutter_scene_resolve.frag @@ -0,0 +1,174 @@ +// Resolve pass: reads the linear HDR scene color (with premultiplied +// alpha) and produces the display-referred swapchain image. In order it +// applies chromatic aberration (at sample time), exposure, color grading, +// a tone mapping operator, the display EOTF, then vignette and film grain. +// Each effect is gated by a flag, so a disabled effect costs only a +// branch and leaves the image unchanged. +uniform ResolveInfo { + float exposure; + // 0 = Khronos PBR Neutral, 1 = ACES filmic, 2 = Reinhard, else linear. + float tone_mapping_mode; + // 1.0 -> flip V when sampling scene_color. The scene color is a + // render-to-texture target, and its sampled Y orientation differs by + // backend (the OpenGL ES backend's FBO is bottom-up); the Dart side + // sets this so the resolved image is upright everywhere. Flutter GPU + // exposes no way to do this in the shader (no backend macro). + float flip_y; + // 1.0 -> apply the color grading controls below. + float grading_enabled; + + float brightness; + float contrast; + float saturation; + float temperature; + + float tint; + float _pad0; + float _pad1; + float _pad2; + + // Only the xyz channels are used; w is padding. + vec4 lift; + vec4 gamma; + vec4 gain; + + float chromatic_aberration_enabled; + float chromatic_aberration_intensity; + float time; + float _pad3; + + float vignette_enabled; + float vignette_intensity; + float vignette_radius; + float vignette_smoothness; + + float grain_enabled; + float grain_intensity; + float _pad4; + float _pad5; + + float bloom_enabled; + float bloom_intensity; + float _pad6; + float _pad7; +} +resolve_info; + +uniform sampler2D scene_color; +uniform sampler2D bloom_color; + +in vec2 v_uv; + +out vec4 frag_color; + +#include + +const float kGamma = 2.2; + +// Color grading on the exposed linear HDR color, before tone mapping. +// Neutral defaults leave the color unchanged. +vec3 ApplyColorGrading(vec3 color) { + // White balance: warm or cool on temperature, green or magenta on tint. + vec3 white_balance = vec3(1.0 + resolve_info.temperature * 0.2, + 1.0 + resolve_info.tint * 0.2, + 1.0 - resolve_info.temperature * 0.2); + color *= white_balance; + + // Brightness. + color *= resolve_info.brightness; + + // Lift, gamma, gain (shadows, midtones, highlights). + color = resolve_info.gain.rgb * + (color + resolve_info.lift.rgb * (1.0 - color)); + color = pow(max(color, vec3(0.0)), + 1.0 / max(resolve_info.gamma.rgb, vec3(1e-4))); + + // Contrast around linear mid-gray. + const float kMidGray = 0.18; + color = (color - kMidGray) * resolve_info.contrast + kMidGray; + color = max(color, vec3(0.0)); + + // Saturation. + float luma = dot(color, vec3(0.2126, 0.7152, 0.0722)); + color = mix(vec3(luma), color, resolve_info.saturation); + + return color; +} + +// Un-premultiplies a sampled premultiplied-alpha color. +vec3 Unpremultiply(vec4 c) { return c.a > 0.0 ? c.rgb / c.a : vec3(0.0); } + +// Value noise for film grain. Mixing time in as a third coordinate +// re-randomizes every pixel each frame, rather than sliding one fixed +// noise field across the screen. +float GrainNoise(vec3 p) { + p = fract(p * 0.1031); + p += dot(p, p.yzx + 33.33); + return fract((p.x + p.y) * p.z); +} + +void main() { + vec2 uv = resolve_info.flip_y > 0.5 ? vec2(v_uv.x, 1.0 - v_uv.y) : v_uv; + + // Sample the scene color. Chromatic aberration pulls the red and blue + // channels from offset positions that grow toward the edges. + vec3 color; + float alpha; + if (resolve_info.chromatic_aberration_enabled > 0.5) { + vec2 offset = + (uv - 0.5) * resolve_info.chromatic_aberration_intensity * 0.04; + vec4 center = texture(scene_color, uv); + color = vec3(Unpremultiply(texture(scene_color, uv + offset)).r, + Unpremultiply(center).g, + Unpremultiply(texture(scene_color, uv - offset)).b); + alpha = center.a; + } else { + vec4 hdr = texture(scene_color, uv); + color = Unpremultiply(hdr); + alpha = hdr.a; + } + + // Bloom is computed in HDR by BloomPass and added back here. + if (resolve_info.bloom_enabled > 0.5) { + color += texture(bloom_color, uv).rgb * resolve_info.bloom_intensity; + } + + color *= resolve_info.exposure; + if (resolve_info.grading_enabled > 0.5) { + color = ApplyColorGrading(color); + } + + vec3 mapped; + if (resolve_info.tone_mapping_mode < 0.5) { + mapped = PBRNeutralToneMapping(color); + } else if (resolve_info.tone_mapping_mode < 1.5) { + mapped = ACESFilmicToneMapping(color, 1.0); + } else if (resolve_info.tone_mapping_mode < 2.5) { + mapped = ReinhardToneMapping(color); + } else { + mapped = clamp(color, 0.0, 1.0); + } + + // Vignette: darken toward the edges of the screen. + if (resolve_info.vignette_enabled > 0.5) { + float dist = length((v_uv - 0.5) * 2.0); + float falloff = smoothstep( + resolve_info.vignette_radius, + resolve_info.vignette_radius + resolve_info.vignette_smoothness, + dist); + mapped *= 1.0 - falloff * resolve_info.vignette_intensity; + } + + // Film grain: animated per-pixel noise. + if (resolve_info.grain_enabled > 0.5) { + float n = + GrainNoise(vec3(gl_FragCoord.xy, resolve_info.time * 60.0)) - 0.5; + mapped = max(mapped + n * resolve_info.grain_intensity, vec3(0.0)); + } + +#ifndef IMPELLER_TARGET_METAL + mapped = pow(mapped, vec3(1.0 / kGamma)); +#endif + + frag_color = vec4(mapped * alpha, alpha); +} diff --git a/packages/flutter_scene/shaders/flutter_scene_standard.frag b/packages/flutter_scene/shaders/flutter_scene_standard.frag index 414ef96..002f596 100644 --- a/packages/flutter_scene/shaders/flutter_scene_standard.frag +++ b/packages/flutter_scene/shaders/flutter_scene_standard.frag @@ -351,7 +351,7 @@ void main() { // Linear HDR, premultiplied by alpha. Exposure, the tone-mapping // operator, and the display EOTF are applied later by the tone-mapping - // resolve pass (see flutter_scene_tonemap.frag), so this writes into a + // resolve pass (see flutter_scene_resolve.frag), so this writes into a // floating-point scene-color target. vec3 out_color = ambient + direct + emissive; frag_color = vec4(out_color, 1.0) * alpha; diff --git a/packages/flutter_scene/shaders/flutter_scene_tonemap.frag b/packages/flutter_scene/shaders/flutter_scene_tonemap.frag deleted file mode 100644 index 44ef1db..0000000 --- a/packages/flutter_scene/shaders/flutter_scene_tonemap.frag +++ /dev/null @@ -1,52 +0,0 @@ -// Tone-mapping resolve pass: reads the linear HDR scene color (with -// premultiplied alpha), applies exposure + a tone mapping operator + the -// display EOTF, and writes the display-referred swapchain image. -uniform TonemapInfo { - float exposure; - // 0 = Khronos PBR Neutral, 1 = ACES filmic, 2 = Reinhard, else linear. - float tone_mapping_mode; - // 1.0 -> flip V when sampling hdr_color. The HDR scene target is a - // render-to-texture target, and its sampled Y orientation differs by - // backend (the OpenGL ES backend's FBO is bottom-up); the Dart side - // sets this so the resolved image is upright everywhere. Flutter GPU - // exposes no way to do this in the shader (no backend macro). - float flip_y; - float _pad0; -} -tonemap_info; - -uniform sampler2D hdr_color; - -in vec2 v_uv; - -out vec4 frag_color; - -#include - -const float kGamma = 2.2; - -void main() { - vec2 uv = - tonemap_info.flip_y > 0.5 ? vec2(v_uv.x, 1.0 - v_uv.y) : v_uv; - vec4 hdr = texture(hdr_color, uv); - // Un-premultiply so the tone curve sees the actual surface color, then - // re-premultiply for compositing onto the Flutter canvas. - vec3 color = hdr.a > 0.0 ? hdr.rgb / hdr.a : vec3(0.0); - - vec3 mapped; - if (tonemap_info.tone_mapping_mode < 0.5) { - mapped = PBRNeutralToneMapping(color * tonemap_info.exposure); - } else if (tonemap_info.tone_mapping_mode < 1.5) { - mapped = ACESFilmicToneMapping(color, tonemap_info.exposure); - } else if (tonemap_info.tone_mapping_mode < 2.5) { - mapped = ReinhardToneMapping(color * tonemap_info.exposure); - } else { - mapped = clamp(color * tonemap_info.exposure, 0.0, 1.0); - } - -#ifndef IMPELLER_TARGET_METAL - mapped = pow(mapped, vec3(1.0 / kGamma)); -#endif - - frag_color = vec4(mapped * hdr.a, hdr.a); -} diff --git a/packages/flutter_scene/test/post_effect_test.dart b/packages/flutter_scene/test/post_effect_test.dart new file mode 100644 index 0000000..10eb630 --- /dev/null +++ b/packages/flutter_scene/test/post_effect_test.dart @@ -0,0 +1,64 @@ +// PostEffect and ShaderUniformBindings accessor/storage tests. The GPU +// bind path requires a real Flutter GPU context and is exercised by the +// example app, not by unit tests. + +import 'dart:typed_data'; + +import 'package:flutter_scene/src/post_process/post_effect.dart'; +import 'package:flutter_scene/src/shader_uniform_bindings.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('PostEffect defaults', () { + test('insertion, enabled, and useFrameInfo', () { + final effect = PostEffect(); + expect(effect.insertion, PostInsertion.beforeTonemap); + expect(effect.enabled, isTrue); + expect(effect.useFrameInfo, isFalse); + }); + + test('fragmentShader throws until set', () { + expect(() => PostEffect().fragmentShader, throwsStateError); + }); + }); + + group('PostEffect uniform storage', () { + test('setUniformBlock round-trips ByteData', () { + final effect = PostEffect(); + final bytes = ByteData.sublistView(Float32List.fromList([1, 2, 3])); + effect.setUniformBlock('Params', bytes); + expect(effect.getUniformBlock('Params'), same(bytes)); + expect(effect.uniformBlockNames, contains('Params')); + }); + + test('setUniformBlockFromFloats packs as Float32List', () { + final effect = PostEffect(); + effect.setUniformBlockFromFloats('Params', [0.5, 1.0]); + final bytes = effect.getUniformBlock('Params'); + expect(bytes, isNotNull); + expect(bytes!.lengthInBytes, 8); + expect(bytes.getFloat32(0, Endian.host), 0.5); + expect(bytes.getFloat32(4, Endian.host), 1.0); + }); + + test('setUniformBlock(null) clears the binding', () { + final effect = PostEffect(); + effect.setUniformBlockFromFloats('Params', [1]); + effect.setUniformBlock('Params', null); + expect(effect.getUniformBlock('Params'), isNull); + expect(effect.uniformBlockNames, isNot(contains('Params'))); + }); + }); + + group('ShaderUniformBindings', () { + test('round-trips and clears uniform blocks', () { + final bindings = ShaderUniformBindings(); + final bytes = ByteData.sublistView(Float32List.fromList([1, 2])); + bindings.setUniformBlock('A', bytes); + expect(bindings.getUniformBlock('A'), same(bytes)); + expect(bindings.uniformBlockNames, contains('A')); + bindings.setUniformBlock('A', null); + expect(bindings.getUniformBlock('A'), isNull); + }); + }); +} diff --git a/packages/flutter_scene/test/post_process_test.dart b/packages/flutter_scene/test/post_process_test.dart new file mode 100644 index 0000000..3605f19 --- /dev/null +++ b/packages/flutter_scene/test/post_process_test.dart @@ -0,0 +1,197 @@ +import 'package:flutter_scene/src/post_process/post_process.dart'; +import 'package:flutter_scene/src/render/resolve_info.dart'; +import 'package:flutter_scene/src/tone_mapping.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:vector_math/vector_math.dart'; + +void main() { + group('ColorGradingSettings', () { + test('defaults are neutral', () { + final grading = ColorGradingSettings(); + expect(grading.enabled, isFalse); + expect(grading.brightness, 1.0); + expect(grading.contrast, 1.0); + expect(grading.saturation, 1.0); + expect(grading.temperature, 0.0); + expect(grading.tint, 0.0); + expect(grading.lift.x, 0.0); + expect(grading.lift.y, 0.0); + expect(grading.lift.z, 0.0); + expect(grading.gamma.x, 1.0); + expect(grading.gamma.y, 1.0); + expect(grading.gamma.z, 1.0); + expect(grading.gain.x, 1.0); + expect(grading.gain.y, 1.0); + expect(grading.gain.z, 1.0); + }); + }); + + group('overlay settings defaults', () { + test('chromatic aberration', () { + final aberration = ChromaticAberrationSettings(); + expect(aberration.enabled, isFalse); + expect(aberration.intensity, 0.5); + }); + + test('vignette', () { + final vignette = VignetteSettings(); + expect(vignette.enabled, isFalse); + expect(vignette.intensity, 0.5); + expect(vignette.radius, 0.75); + expect(vignette.smoothness, 0.5); + }); + + test('film grain', () { + final grain = FilmGrainSettings(); + expect(grain.enabled, isFalse); + expect(grain.intensity, 0.3); + }); + + test('bloom', () { + final bloom = BloomSettings(); + expect(bloom.enabled, isFalse); + expect(bloom.threshold, 1.0); + expect(bloom.intensity, 0.5); + expect(bloom.scatter, 0.7); + }); + }); + + group('PostProcessSettings', () { + test('every effect is off by default', () { + final settings = PostProcessSettings(); + expect(settings.colorGrading.enabled, isFalse); + expect(settings.chromaticAberration.enabled, isFalse); + expect(settings.vignette.enabled, isFalse); + expect(settings.filmGrain.enabled, isFalse); + expect(settings.bloom.enabled, isFalse); + }); + }); + + group('packResolveInfo', () { + test('produces the std140 float count', () { + final info = packResolveInfo( + exposure: 1.0, + toneMappingMode: ToneMappingMode.pbrNeutral, + flipY: false, + time: 0.0, + settings: PostProcessSettings(), + ); + expect(info.length, kResolveInfoFloatCount); + expect(info.length, 40); + }); + + test('packs the resolve controls', () { + final info = packResolveInfo( + exposure: 2.5, + toneMappingMode: ToneMappingMode.reinhard, + flipY: true, + time: 0.0, + settings: PostProcessSettings(), + ); + expect(info[0], 2.5); + expect(info[1], ToneMappingMode.reinhard.index.toDouble()); + expect(info[2], 1.0); + expect(info[3], 0.0); + }); + + test('flipY false packs zero', () { + final info = packResolveInfo( + exposure: 1.0, + toneMappingMode: ToneMappingMode.pbrNeutral, + flipY: false, + time: 0.0, + settings: PostProcessSettings(), + ); + expect(info[2], 0.0); + }); + + test('packs grading fields at their std140 slots', () { + final settings = PostProcessSettings(); + settings.colorGrading + ..enabled = true + ..brightness = 1.1 + ..contrast = 1.2 + ..saturation = 0.9 + ..temperature = 0.3 + ..tint = -0.2 + ..lift = Vector3(0.01, 0.02, 0.03) + ..gamma = Vector3(1.1, 1.2, 1.3) + ..gain = Vector3(0.8, 0.9, 1.0); + final info = packResolveInfo( + exposure: 1.0, + toneMappingMode: ToneMappingMode.pbrNeutral, + flipY: false, + time: 0.0, + settings: settings, + ); + + expect(info[3], 1.0); + expect(info[4], closeTo(1.1, 1e-6)); + expect(info[5], closeTo(1.2, 1e-6)); + expect(info[6], closeTo(0.9, 1e-6)); + expect(info[7], closeTo(0.3, 1e-6)); + expect(info[8], closeTo(-0.2, 1e-6)); + + // Padding floats stay zero. + expect(info[9], 0.0); + expect(info[10], 0.0); + expect(info[11], 0.0); + expect(info[15], 0.0); + expect(info[19], 0.0); + expect(info[23], 0.0); + + // lift / gamma / gain land at the start of their rows. + expect(info[12], closeTo(0.01, 1e-6)); + expect(info[13], closeTo(0.02, 1e-6)); + expect(info[14], closeTo(0.03, 1e-6)); + expect(info[16], closeTo(1.1, 1e-6)); + expect(info[17], closeTo(1.2, 1e-6)); + expect(info[18], closeTo(1.3, 1e-6)); + expect(info[20], closeTo(0.8, 1e-6)); + expect(info[21], closeTo(0.9, 1e-6)); + expect(info[22], closeTo(1.0, 1e-6)); + }); + + test('packs overlay fields and time at their std140 slots', () { + final settings = PostProcessSettings(); + settings.chromaticAberration + ..enabled = true + ..intensity = 0.7; + settings.vignette + ..enabled = true + ..intensity = 0.6 + ..radius = 0.8 + ..smoothness = 0.3; + settings.filmGrain + ..enabled = true + ..intensity = 0.25; + settings.bloom + ..enabled = true + ..intensity = 0.8; + final info = packResolveInfo( + exposure: 1.0, + toneMappingMode: ToneMappingMode.pbrNeutral, + flipY: false, + time: 2.0, + settings: settings, + ); + + expect(info[24], 1.0); + expect(info[25], closeTo(0.7, 1e-6)); + expect(info[26], closeTo(2.0, 1e-6)); + expect(info[27], 0.0); + expect(info[28], 1.0); + expect(info[29], closeTo(0.6, 1e-6)); + expect(info[30], closeTo(0.8, 1e-6)); + expect(info[31], closeTo(0.3, 1e-6)); + expect(info[32], 1.0); + expect(info[33], closeTo(0.25, 1e-6)); + expect(info[34], 0.0); + expect(info[35], 0.0); + expect(info[36], 1.0); + expect(info[37], closeTo(0.8, 1e-6)); + expect(info[38], 0.0); + expect(info[39], 0.0); + }); + }); +}