From bb9210d65045249b1b46eef292dc4fcafbbf797e Mon Sep 17 00:00:00 2001 From: Brandon DeRosier Date: Sun, 24 May 2026 15:08:19 -0700 Subject: [PATCH 01/11] Rename TonemapPass to ResolvePass and generalize the scene-color handoff Prepare the render graph for the post-processing chain. - TonemapPass becomes ResolvePass (resolve_pass.dart). It gains grading and overlays in later stages. - kHdrColorBlackboardKey becomes kSceneColorBlackboardKey, the current scene-color handle that post passes read and republish. - Mark the before and after resolve insertion points in Scene.render. Pure rename plus comment seams. Shaders are untouched, so the output is pixel-identical. Toward #47. --- .../render/{tonemap_pass.dart => resolve_pass.dart} | 8 ++++---- .../flutter_scene/lib/src/render/scene_pass.dart | 13 ++++++++----- packages/flutter_scene/lib/src/scene.dart | 8 ++++++-- 3 files changed, 18 insertions(+), 11 deletions(-) rename packages/flutter_scene/lib/src/render/{tonemap_pass.dart => resolve_pass.dart} (96%) diff --git a/packages/flutter_scene/lib/src/render/tonemap_pass.dart b/packages/flutter_scene/lib/src/render/resolve_pass.dart similarity index 96% rename from packages/flutter_scene/lib/src/render/tonemap_pass.dart rename to packages/flutter_scene/lib/src/render/resolve_pass.dart index 7d5344f..a0e6d3b 100644 --- a/packages/flutter_scene/lib/src/render/tonemap_pass.dart +++ b/packages/flutter_scene/lib/src/render/resolve_pass.dart @@ -12,8 +12,8 @@ import 'package:flutter_scene/src/tone_mapping.dart'; /// 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({ +class ResolvePass extends RenderGraphPass { + ResolvePass({ required gpu.RenderTarget target, required double exposure, required ToneMappingMode toneMappingMode, @@ -47,12 +47,12 @@ class TonemapPass extends RenderGraphPass { ); @override - String get name => 'TonemapPass'; + String get name => 'ResolvePass'; @override void execute(RenderGraphContext context) { final hdrColor = context.blackboard.require( - kHdrColorBlackboardKey, + kSceneColorBlackboardKey, ); final commandBuffer = gpu.gpuContext.createCommandBuffer(); 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/scene.dart b/packages/flutter_scene/lib/src/scene.dart index 326d390..dd2521e 100644 --- a/packages/flutter_scene/lib/src/scene.dart +++ b/packages/flutter_scene/lib/src/scene.dart @@ -15,7 +15,7 @@ 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'; @@ -371,13 +371,17 @@ base class Scene implements SceneGraph { cascades: cascades, ), ); + // Post-processing passes that operate on the linear HDR scene color + // run here, before the resolve. None yet. graph.addPass( - TonemapPass( + ResolvePass( target: swapchainTarget, exposure: exposure, toneMappingMode: toneMapping, ), ); + // Post-processing passes that operate on the display image run here, + // after the resolve. None yet. graph.execute( transientsBuffer: transientsBuffer, texturePool: surface.transientTexturePool, From 25b5d4ee1514ba33af6d98dd1d3eedf3d882485c Mon Sep 17 00:00:00 2001 From: Brandon DeRosier Date: Sun, 24 May 2026 15:54:08 -0700 Subject: [PATCH 02/11] Add color grading and turn the tonemap pass into the resolve pass The first built-in post-processing effect. - PostProcessSettings with ColorGradingSettings (brightness, contrast, saturation, white balance, lift/gamma/gain), reached through Scene.postProcess. Effects default off. - The tonemap shader becomes the resolve shader (flutter_scene_resolve.frag): it applies exposure, then color grading, then the tone mapping operator and the display EOTF. Grading is gated by a uniform flag, so a disabled grade renders identically to before. - packResolveInfo packs the ResolveInfo uniform block as a pure, unit-tested function. Toward #47. --- packages/flutter_scene/lib/scene.dart | 1 + .../lib/src/post_process/post_process.dart | 50 ++++++++ .../lib/src/render/resolve_info.dart | 55 +++++++++ .../lib/src/render/resolve_pass.dart | 35 +++--- packages/flutter_scene/lib/src/scene.dart | 6 + .../shaders/base.shaderbundle.json | 4 +- .../shaders/flutter_scene_fullscreen.vert | 3 +- .../shaders/flutter_scene_resolve.frag | 103 ++++++++++++++++ .../shaders/flutter_scene_standard.frag | 2 +- .../shaders/flutter_scene_tonemap.frag | 52 -------- .../flutter_scene/test/post_process_test.dart | 115 ++++++++++++++++++ 11 files changed, 354 insertions(+), 72 deletions(-) create mode 100644 packages/flutter_scene/lib/src/post_process/post_process.dart create mode 100644 packages/flutter_scene/lib/src/render/resolve_info.dart create mode 100644 packages/flutter_scene/shaders/flutter_scene_resolve.frag delete mode 100644 packages/flutter_scene/shaders/flutter_scene_tonemap.frag create mode 100644 packages/flutter_scene/test/post_process_test.dart diff --git a/packages/flutter_scene/lib/scene.dart b/packages/flutter_scene/lib/scene.dart index 495a6e7..244f726 100644 --- a/packages/flutter_scene/lib/scene.dart +++ b/packages/flutter_scene/lib/scene.dart @@ -48,6 +48,7 @@ export 'src/light.dart'; export 'src/math_extensions.dart'; export 'src/mesh.dart'; export 'src/node.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_process.dart b/packages/flutter_scene/lib/src/post_process/post_process.dart new file mode 100644 index 0000000..9a95045 --- /dev/null +++ b/packages/flutter_scene/lib/src/post_process/post_process.dart @@ -0,0 +1,50 @@ +import 'package:vector_math/vector_math.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(); +} + +/// 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); +} 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..f7741e5 --- /dev/null +++ b/packages/flutter_scene/lib/src/render/resolve_info.dart @@ -0,0 +1,55 @@ +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: six std140 rows +/// of four floats each. +const int kResolveInfoFloatCount = 24; + +/// 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. +Float32List packResolveInfo({ + required double exposure, + required ToneMappingMode toneMappingMode, + required bool flipY, + required ColorGradingSettings grading, +}) { + 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; + + return info; +} diff --git a/packages/flutter_scene/lib/src/render/resolve_pass.dart b/packages/flutter_scene/lib/src/render/resolve_pass.dart index a0e6d3b..7fce670 100644 --- a/packages/flutter_scene/lib/src/render/resolve_pass.dart +++ b/packages/flutter_scene/lib/src/render/resolve_pass.dart @@ -2,7 +2,9 @@ import 'dart:typed_data'; 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/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'; @@ -10,25 +12,29 @@ 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. +/// display-referred swapchain image: applies exposure, optional color +/// grading, the tone mapping operator, and the display EOTF as a single +/// full-screen pass. class ResolvePass extends RenderGraphPass { ResolvePass({ required gpu.RenderTarget target, required double exposure, required ToneMappingMode toneMappingMode, + required ColorGradingSettings colorGrading, }) : _target = target, _exposure = exposure, - _toneMappingMode = toneMappingMode; + _toneMappingMode = toneMappingMode, + _colorGrading = colorGrading; final gpu.RenderTarget _target; final double _exposure; final ToneMappingMode _toneMappingMode; + final ColorGradingSettings _colorGrading; static final gpu.Shader _vertexShader = baseShaderLibrary['FullscreenVertex']!; static final gpu.Shader _fragmentShader = - baseShaderLibrary['TonemapFragment']!; + baseShaderLibrary['ResolveFragment']!; // Two triangles of NDC positions covering the screen (6 vec2s). static final gpu.DeviceBuffer _quadBuffer = gpu.gpuContext @@ -73,21 +79,20 @@ class ResolvePass extends RenderGraphPass { 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; + // 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, + grading: _colorGrading, + ); renderPass.bindUniform( - _fragmentShader.getUniformSlot('TonemapInfo'), + _fragmentShader.getUniformSlot('ResolveInfo'), context.transientsBuffer.emplace(ByteData.sublistView(info)), ); renderPass.bindTexture( - _fragmentShader.getUniformSlot('hdr_color'), + _fragmentShader.getUniformSlot('scene_color'), hdrColor, sampler: gpu.SamplerOptions( minFilter: gpu.MinMagFilter.linear, diff --git a/packages/flutter_scene/lib/src/scene.dart b/packages/flutter_scene/lib/src/scene.dart index dd2521e..f26f508 100644 --- a/packages/flutter_scene/lib/src/scene.dart +++ b/packages/flutter_scene/lib/src/scene.dart @@ -11,6 +11,7 @@ import 'material/environment.dart'; import 'material/material.dart'; import 'mesh.dart'; import 'node.dart'; +import 'post_process/post_process.dart'; import 'render/render_graph.dart'; import 'render/render_scene.dart'; import 'render/scene_pass.dart'; @@ -203,6 +204,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); @@ -378,6 +383,7 @@ base class Scene implements SceneGraph { target: swapchainTarget, exposure: exposure, toneMappingMode: toneMapping, + colorGrading: postProcess.colorGrading, ), ); // Post-processing passes that operate on the display image run here, diff --git a/packages/flutter_scene/shaders/base.shaderbundle.json b/packages/flutter_scene/shaders/base.shaderbundle.json index c0aec43..ef642d2 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", 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..7fe44cb --- /dev/null +++ b/packages/flutter_scene/shaders/flutter_scene_resolve.frag @@ -0,0 +1,103 @@ +// Resolve pass: reads the linear HDR scene color (with premultiplied +// alpha), applies exposure, optional color grading, a tone mapping +// operator, and the display EOTF, and writes the display-referred +// swapchain image. +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; +} +resolve_info; + +uniform sampler2D scene_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; +} + +void main() { + vec2 uv = resolve_info.flip_y > 0.5 ? vec2(v_uv.x, 1.0 - v_uv.y) : v_uv; + vec4 hdr = texture(scene_color, uv); + // Un-premultiply so the curves see 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); + + 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); + } + +#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/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_process_test.dart b/packages/flutter_scene/test/post_process_test.dart new file mode 100644 index 0000000..04e2e15 --- /dev/null +++ b/packages/flutter_scene/test/post_process_test.dart @@ -0,0 +1,115 @@ +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('PostProcessSettings', () { + test('color grading is off by default', () { + expect(PostProcessSettings().colorGrading.enabled, isFalse); + }); + }); + + group('packResolveInfo', () { + test('produces the std140 float count', () { + final info = packResolveInfo( + exposure: 1.0, + toneMappingMode: ToneMappingMode.pbrNeutral, + flipY: false, + grading: ColorGradingSettings(), + ); + expect(info.length, kResolveInfoFloatCount); + }); + + test('packs the resolve controls', () { + final info = packResolveInfo( + exposure: 2.5, + toneMappingMode: ToneMappingMode.reinhard, + flipY: true, + grading: ColorGradingSettings(), + ); + 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, + grading: ColorGradingSettings(), + ); + expect(info[2], 0.0); + }); + + test('packs grading fields at their std140 slots', () { + final grading = + ColorGradingSettings() + ..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, + grading: grading, + ); + + 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)); + }); + }); +} From 695f31588e99492cbdaaf5cd601e0c842ab68df1 Mon Sep 17 00:00:00 2001 From: Brandon DeRosier Date: Sun, 24 May 2026 15:55:16 -0700 Subject: [PATCH 03/11] examples: shared post-processing settings sidebar Add a collapsible settings sidebar (top-right), shared by every example and the stress tests, with a collapsible Post-processing section holding color grading controls. - ExampleSettings holds the shared config; each example applies it to its scene before rendering, so one set of controls drives every scene. - Move the stress-tests back button and the nav-route car-controls menu to the top-left, below the example picker, clear of the new sidebar. --- .../flutter_app/lib/example_animation.dart | 3 + examples/flutter_app/lib/example_car.dart | 3 + examples/flutter_app/lib/example_cuboid.dart | 3 + .../flutter_app/lib/example_instancing.dart | 3 + examples/flutter_app/lib/example_logo.dart | 3 + .../flutter_app/lib/example_nav_route.dart | 7 +- .../flutter_app/lib/example_settings.dart | 28 ++++ .../flutter_app/lib/example_stress_tests.dart | 10 +- examples/flutter_app/lib/example_toon.dart | 3 + examples/flutter_app/lib/main.dart | 157 ++++++++++++++++++ 10 files changed, 215 insertions(+), 5 deletions(-) create mode 100644 examples/flutter_app/lib/example_settings.dart 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..495e6c0 --- /dev/null +++ b/examples/flutter_app/lib/example_settings.dart @@ -0,0 +1,28 @@ +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(); + + /// Copies the shared settings onto [scene] so its next frame uses them. + void applyTo(Scene scene) { + final target = scene.postProcess.colorGrading; + target.enabled = colorGrading.enabled; + target.brightness = colorGrading.brightness; + target.contrast = colorGrading.contrast; + target.saturation = colorGrading.saturation; + target.temperature = colorGrading.temperature; + target.tint = colorGrading.tint; + target.lift.setFrom(colorGrading.lift); + target.gamma.setFrom(colorGrading.gamma); + target.gain.setFrom(colorGrading.gain); + } +} + +/// The single shared settings instance used across the example app. +final ExampleSettings exampleSettings = ExampleSettings(); 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..ee00c54 100644 --- a/examples/flutter_app/lib/main.dart +++ b/examples/flutter_app/lib/main.dart @@ -8,6 +8,7 @@ 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'; @@ -94,6 +95,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 +152,156 @@ 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()], + ); + } + + 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 _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, + ), + ), + ], + ); + } +} From 660902d9bf010b766f00f89ead53e65909e69b13 Mon Sep 17 00:00:00 2001 From: Brandon DeRosier Date: Sun, 24 May 2026 16:36:34 -0700 Subject: [PATCH 04/11] Add vignette, chromatic aberration, and film grain to the resolve pass Three more built-in post-processing effects, folded into the single resolve pass. - New VignetteSettings, ChromaticAberrationSettings, and FilmGrainSettings on PostProcessSettings. All off by default. - The resolve shader samples with per-channel offsets for chromatic aberration, then darkens the edges (vignette) and adds animated noise (film grain) after tone mapping. Each is gated by a flag, so the disabled path is unchanged. - ResolveInfo grows to carry the new controls plus a time value for the grain. packResolveInfo and its tests cover the new layout. Toward #47. --- .../lib/src/post_process/post_process.dart | 45 ++++++++ .../lib/src/render/resolve_info.dart | 29 ++++- .../lib/src/render/resolve_pass.dart | 13 ++- packages/flutter_scene/lib/src/scene.dart | 2 +- .../shaders/flutter_scene_resolve.frag | 76 +++++++++++-- .../flutter_scene/test/post_process_test.dart | 100 +++++++++++++++--- 6 files changed, 232 insertions(+), 33 deletions(-) diff --git a/packages/flutter_scene/lib/src/post_process/post_process.dart b/packages/flutter_scene/lib/src/post_process/post_process.dart index 9a95045..516bb00 100644 --- a/packages/flutter_scene/lib/src/post_process/post_process.dart +++ b/packages/flutter_scene/lib/src/post_process/post_process.dart @@ -9,6 +9,16 @@ 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(); } /// Color grading applied to the linear HDR scene color, before exposure @@ -48,3 +58,38 @@ class ColorGradingSettings { /// 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; +} diff --git a/packages/flutter_scene/lib/src/render/resolve_info.dart b/packages/flutter_scene/lib/src/render/resolve_info.dart index f7741e5..077ae2b 100644 --- a/packages/flutter_scene/lib/src/render/resolve_info.dart +++ b/packages/flutter_scene/lib/src/render/resolve_info.dart @@ -3,9 +3,9 @@ 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: six std140 rows +/// Number of floats in the `ResolveInfo` uniform block: nine std140 rows /// of four floats each. -const int kResolveInfoFloatCount = 24; +const int kResolveInfoFloatCount = 36; /// Packs the resolve pass's `ResolveInfo` uniform block. /// @@ -13,12 +13,20 @@ const int kResolveInfoFloatCount = 24; /// `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 ColorGradingSettings grading, + required double time, + required PostProcessSettings settings, }) { + final grading = settings.colorGrading; + final aberration = settings.chromaticAberration; + final vignette = settings.vignette; + final grain = settings.filmGrain; + final info = Float32List(kResolveInfoFloatCount); // Row 0: resolve controls. @@ -51,5 +59,20 @@ Float32List packResolveInfo({ 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; + return info; } diff --git a/packages/flutter_scene/lib/src/render/resolve_pass.dart b/packages/flutter_scene/lib/src/render/resolve_pass.dart index 7fce670..59a5b62 100644 --- a/packages/flutter_scene/lib/src/render/resolve_pass.dart +++ b/packages/flutter_scene/lib/src/render/resolve_pass.dart @@ -20,16 +20,16 @@ class ResolvePass extends RenderGraphPass { required gpu.RenderTarget target, required double exposure, required ToneMappingMode toneMappingMode, - required ColorGradingSettings colorGrading, + required PostProcessSettings postProcess, }) : _target = target, _exposure = exposure, _toneMappingMode = toneMappingMode, - _colorGrading = colorGrading; + _postProcess = postProcess; final gpu.RenderTarget _target; final double _exposure; final ToneMappingMode _toneMappingMode; - final ColorGradingSettings _colorGrading; + final PostProcessSettings _postProcess; static final gpu.Shader _vertexShader = baseShaderLibrary['FullscreenVertex']!; @@ -79,13 +79,18 @@ class ResolvePass extends RenderGraphPass { 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, - grading: _colorGrading, + time: timeSeconds, + settings: _postProcess, ); renderPass.bindUniform( _fragmentShader.getUniformSlot('ResolveInfo'), diff --git a/packages/flutter_scene/lib/src/scene.dart b/packages/flutter_scene/lib/src/scene.dart index f26f508..706372f 100644 --- a/packages/flutter_scene/lib/src/scene.dart +++ b/packages/flutter_scene/lib/src/scene.dart @@ -383,7 +383,7 @@ base class Scene implements SceneGraph { target: swapchainTarget, exposure: exposure, toneMappingMode: toneMapping, - colorGrading: postProcess.colorGrading, + postProcess: postProcess, ), ); // Post-processing passes that operate on the display image run here, diff --git a/packages/flutter_scene/shaders/flutter_scene_resolve.frag b/packages/flutter_scene/shaders/flutter_scene_resolve.frag index 7fe44cb..0607e7e 100644 --- a/packages/flutter_scene/shaders/flutter_scene_resolve.frag +++ b/packages/flutter_scene/shaders/flutter_scene_resolve.frag @@ -1,7 +1,9 @@ // Resolve pass: reads the linear HDR scene color (with premultiplied -// alpha), applies exposure, optional color grading, a tone mapping -// operator, and the display EOTF, and writes the display-referred -// swapchain image. +// 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. @@ -29,6 +31,21 @@ uniform ResolveInfo { 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; } resolve_info; @@ -72,12 +89,38 @@ vec3 ApplyColorGrading(vec3 color) { 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; - vec4 hdr = texture(scene_color, uv); - // Un-premultiply so the curves see 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); + + // 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; + } color *= resolve_info.exposure; if (resolve_info.grading_enabled > 0.5) { @@ -95,9 +138,26 @@ void main() { 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 * hdr.a, hdr.a); + frag_color = vec4(mapped * alpha, alpha); } diff --git a/packages/flutter_scene/test/post_process_test.dart b/packages/flutter_scene/test/post_process_test.dart index 04e2e15..49b31ba 100644 --- a/packages/flutter_scene/test/post_process_test.dart +++ b/packages/flutter_scene/test/post_process_test.dart @@ -26,9 +26,35 @@ void main() { }); }); + 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); + }); + }); + group('PostProcessSettings', () { - test('color grading is off by default', () { - expect(PostProcessSettings().colorGrading.enabled, isFalse); + 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); }); }); @@ -38,9 +64,11 @@ void main() { exposure: 1.0, toneMappingMode: ToneMappingMode.pbrNeutral, flipY: false, - grading: ColorGradingSettings(), + time: 0.0, + settings: PostProcessSettings(), ); expect(info.length, kResolveInfoFloatCount); + expect(info.length, 36); }); test('packs the resolve controls', () { @@ -48,7 +76,8 @@ void main() { exposure: 2.5, toneMappingMode: ToneMappingMode.reinhard, flipY: true, - grading: ColorGradingSettings(), + time: 0.0, + settings: PostProcessSettings(), ); expect(info[0], 2.5); expect(info[1], ToneMappingMode.reinhard.index.toDouble()); @@ -61,28 +90,30 @@ void main() { exposure: 1.0, toneMappingMode: ToneMappingMode.pbrNeutral, flipY: false, - grading: ColorGradingSettings(), + time: 0.0, + settings: PostProcessSettings(), ); expect(info[2], 0.0); }); test('packs grading fields at their std140 slots', () { - final grading = - ColorGradingSettings() - ..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 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, - grading: grading, + time: 0.0, + settings: settings, ); expect(info[3], 1.0); @@ -111,5 +142,40 @@ void main() { 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; + 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); + }); }); } From fbc368032d42d45538e5deb94cde1bd7f743963f Mon Sep 17 00:00:00 2001 From: Brandon DeRosier Date: Sun, 24 May 2026 16:36:39 -0700 Subject: [PATCH 05/11] examples: add vignette, chromatic aberration, and film grain controls Extend the shared post-processing sidebar with collapsible sections for the three new effects, each with an enable switch and sliders. The shared settings are copied onto every example's scene as before. --- .../flutter_app/lib/example_settings.dart | 44 +++++++++--- examples/flutter_app/lib/main.dart | 70 ++++++++++++++++++- 2 files changed, 103 insertions(+), 11 deletions(-) diff --git a/examples/flutter_app/lib/example_settings.dart b/examples/flutter_app/lib/example_settings.dart index 495e6c0..18996d8 100644 --- a/examples/flutter_app/lib/example_settings.dart +++ b/examples/flutter_app/lib/example_settings.dart @@ -9,18 +9,42 @@ 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(); + /// Copies the shared settings onto [scene] so its next frame uses them. void applyTo(Scene scene) { - final target = scene.postProcess.colorGrading; - target.enabled = colorGrading.enabled; - target.brightness = colorGrading.brightness; - target.contrast = colorGrading.contrast; - target.saturation = colorGrading.saturation; - target.temperature = colorGrading.temperature; - target.tint = colorGrading.tint; - target.lift.setFrom(colorGrading.lift); - target.gamma.setFrom(colorGrading.gamma); - target.gain.setFrom(colorGrading.gain); + 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; } } diff --git a/examples/flutter_app/lib/main.dart b/examples/flutter_app/lib/main.dart index ee00c54..7bd600a 100644 --- a/examples/flutter_app/lib/main.dart +++ b/examples/flutter_app/lib/main.dart @@ -238,7 +238,12 @@ class _SettingsSidebarState extends State<_SettingsSidebar> { title: const Text('Post-processing'), initiallyExpanded: true, childrenPadding: EdgeInsets.zero, - children: [_buildColorGrading()], + children: [ + _buildColorGrading(), + _buildChromaticAberration(), + _buildVignette(), + _buildFilmGrain(), + ], ); } @@ -274,6 +279,69 @@ class _SettingsSidebarState extends State<_SettingsSidebar> { ); } + 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 _slider( String label, double value, From c6fdd18b800e32e1d4f1b9afd2c7fc8a9089a94c Mon Sep 17 00:00:00 2001 From: Brandon DeRosier Date: Sun, 24 May 2026 16:57:25 -0700 Subject: [PATCH 06/11] Add bloom A multi-pass HDR bloom built-in: a soft-knee threshold blurred through a downsample/upsample mip chain, composited back into the scene color before tone mapping. - New BloomPass builds the bloom texture from the HDR scene color with three shaders (threshold, 13-tap downsample, tent upsample) and a chain of transient mips. Each step is its own full-screen pass, so no compute or mipmap generation is needed and it runs on WebGL2. - New BloomSettings (threshold, intensity, scatter) on PostProcessSettings, off by default. Scene.render adds BloomPass only when enabled. - The resolve shader samples the bloom texture and adds it in HDR before exposure. ResolveInfo and packResolveInfo carry the bloom controls. Toward #47. --- .../lib/src/post_process/post_process.dart | 20 ++ .../lib/src/render/bloom_pass.dart | 218 ++++++++++++++++++ .../lib/src/render/resolve_info.dart | 9 +- .../lib/src/render/resolve_pass.dart | 17 ++ packages/flutter_scene/lib/src/scene.dart | 9 +- .../shaders/base.shaderbundle.json | 12 + .../flutter_scene_bloom_downsample.frag | 40 ++++ .../flutter_scene_bloom_threshold.frag | 31 +++ .../shaders/flutter_scene_bloom_upsample.frag | 32 +++ .../shaders/flutter_scene_resolve.frag | 11 + .../flutter_scene/test/post_process_test.dart | 18 +- 11 files changed, 412 insertions(+), 5 deletions(-) create mode 100644 packages/flutter_scene/lib/src/render/bloom_pass.dart create mode 100644 packages/flutter_scene/shaders/flutter_scene_bloom_downsample.frag create mode 100644 packages/flutter_scene/shaders/flutter_scene_bloom_threshold.frag create mode 100644 packages/flutter_scene/shaders/flutter_scene_bloom_upsample.frag diff --git a/packages/flutter_scene/lib/src/post_process/post_process.dart b/packages/flutter_scene/lib/src/post_process/post_process.dart index 516bb00..5cf5cf5 100644 --- a/packages/flutter_scene/lib/src/post_process/post_process.dart +++ b/packages/flutter_scene/lib/src/post_process/post_process.dart @@ -19,6 +19,10 @@ class PostProcessSettings { /// 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(); } /// Color grading applied to the linear HDR scene color, before exposure @@ -93,3 +97,19 @@ class FilmGrainSettings { /// 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..63e30bd --- /dev/null +++ b/packages/flutter_scene/lib/src/render/bloom_pass.dart @@ -0,0 +1,218 @@ +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/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); + + 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); + + 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(); + } + + 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/resolve_info.dart b/packages/flutter_scene/lib/src/render/resolve_info.dart index 077ae2b..ab9d885 100644 --- a/packages/flutter_scene/lib/src/render/resolve_info.dart +++ b/packages/flutter_scene/lib/src/render/resolve_info.dart @@ -3,9 +3,9 @@ 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: nine std140 rows +/// Number of floats in the `ResolveInfo` uniform block: ten std140 rows /// of four floats each. -const int kResolveInfoFloatCount = 36; +const int kResolveInfoFloatCount = 40; /// Packs the resolve pass's `ResolveInfo` uniform block. /// @@ -26,6 +26,7 @@ Float32List packResolveInfo({ final aberration = settings.chromaticAberration; final vignette = settings.vignette; final grain = settings.filmGrain; + final bloom = settings.bloom; final info = Float32List(kResolveInfoFloatCount); @@ -74,5 +75,9 @@ Float32List packResolveInfo({ 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 index 59a5b62..3193764 100644 --- a/packages/flutter_scene/lib/src/render/resolve_pass.dart +++ b/packages/flutter_scene/lib/src/render/resolve_pass.dart @@ -2,7 +2,9 @@ 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'; @@ -106,6 +108,21 @@ class ResolvePass extends RenderGraphPass { 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(); } diff --git a/packages/flutter_scene/lib/src/scene.dart b/packages/flutter_scene/lib/src/scene.dart index 706372f..9f290fe 100644 --- a/packages/flutter_scene/lib/src/scene.dart +++ b/packages/flutter_scene/lib/src/scene.dart @@ -12,6 +12,7 @@ import 'material/material.dart'; import 'mesh.dart'; import 'node.dart'; import 'post_process/post_process.dart'; +import 'render/bloom_pass.dart'; import 'render/render_graph.dart'; import 'render/render_scene.dart'; import 'render/scene_pass.dart'; @@ -376,8 +377,12 @@ base class Scene implements SceneGraph { cascades: cascades, ), ); - // Post-processing passes that operate on the linear HDR scene color - // run here, before the resolve. None yet. + // Bloom runs in HDR before the resolve, which composites it back in. + if (postProcess.bloom.enabled) { + graph.addPass( + BloomPass(dimensions: pixelSize, settings: postProcess.bloom), + ); + } graph.addPass( ResolvePass( target: swapchainTarget, diff --git a/packages/flutter_scene/shaders/base.shaderbundle.json b/packages/flutter_scene/shaders/base.shaderbundle.json index ef642d2..145b1dc 100644 --- a/packages/flutter_scene/shaders/base.shaderbundle.json +++ b/packages/flutter_scene/shaders/base.shaderbundle.json @@ -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_resolve.frag b/packages/flutter_scene/shaders/flutter_scene_resolve.frag index 0607e7e..8ab0f4e 100644 --- a/packages/flutter_scene/shaders/flutter_scene_resolve.frag +++ b/packages/flutter_scene/shaders/flutter_scene_resolve.frag @@ -46,10 +46,16 @@ uniform ResolveInfo { 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; @@ -122,6 +128,11 @@ void main() { 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); diff --git a/packages/flutter_scene/test/post_process_test.dart b/packages/flutter_scene/test/post_process_test.dart index 49b31ba..3605f19 100644 --- a/packages/flutter_scene/test/post_process_test.dart +++ b/packages/flutter_scene/test/post_process_test.dart @@ -46,6 +46,14 @@ void main() { 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', () { @@ -55,6 +63,7 @@ void main() { expect(settings.chromaticAberration.enabled, isFalse); expect(settings.vignette.enabled, isFalse); expect(settings.filmGrain.enabled, isFalse); + expect(settings.bloom.enabled, isFalse); }); }); @@ -68,7 +77,7 @@ void main() { settings: PostProcessSettings(), ); expect(info.length, kResolveInfoFloatCount); - expect(info.length, 36); + expect(info.length, 40); }); test('packs the resolve controls', () { @@ -156,6 +165,9 @@ void main() { settings.filmGrain ..enabled = true ..intensity = 0.25; + settings.bloom + ..enabled = true + ..intensity = 0.8; final info = packResolveInfo( exposure: 1.0, toneMappingMode: ToneMappingMode.pbrNeutral, @@ -176,6 +188,10 @@ void main() { 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); }); }); } From 5d86325709397c9fa99fae24d7a1457c4bf37164 Mon Sep 17 00:00:00 2001 From: Brandon DeRosier Date: Sun, 24 May 2026 16:57:30 -0700 Subject: [PATCH 07/11] examples: add bloom controls to the settings sidebar A Bloom section with an enable switch and threshold/intensity/scatter sliders, applied to every example's scene like the other effects. --- .../flutter_app/lib/example_settings.dart | 9 +++++++ examples/flutter_app/lib/main.dart | 26 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/examples/flutter_app/lib/example_settings.dart b/examples/flutter_app/lib/example_settings.dart index 18996d8..eb7fea1 100644 --- a/examples/flutter_app/lib/example_settings.dart +++ b/examples/flutter_app/lib/example_settings.dart @@ -19,6 +19,9 @@ class ExampleSettings { /// Film grain shared across the examples. final FilmGrainSettings filmGrain = FilmGrainSettings(); + /// Bloom shared across the examples. + final BloomSettings bloom = BloomSettings(); + /// Copies the shared settings onto [scene] so its next frame uses them. void applyTo(Scene scene) { final grading = scene.postProcess.colorGrading; @@ -45,6 +48,12 @@ class ExampleSettings { 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; } } diff --git a/examples/flutter_app/lib/main.dart b/examples/flutter_app/lib/main.dart index 7bd600a..9b2b806 100644 --- a/examples/flutter_app/lib/main.dart +++ b/examples/flutter_app/lib/main.dart @@ -240,6 +240,7 @@ class _SettingsSidebarState extends State<_SettingsSidebar> { childrenPadding: EdgeInsets.zero, children: [ _buildColorGrading(), + _buildBloom(), _buildChromaticAberration(), _buildVignette(), _buildFilmGrain(), @@ -342,6 +343,31 @@ class _SettingsSidebarState extends State<_SettingsSidebar> { ); } + 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 _slider( String label, double value, From 31ce850e324bf72e0e8a416b05b808e4c4f3ba64 Mon Sep 17 00:00:00 2001 From: Brandon DeRosier Date: Sun, 24 May 2026 17:25:06 -0700 Subject: [PATCH 08/11] Add the custom PostEffect path Lets users author their own post-processing effects as fragment shaders, the post-processing twin of ShaderMaterial. - New PostEffect (a fragment shader + insertion point + named uniform and texture bindings) and a customEffects list on PostProcessSettings. - PostEffectPass runs one effect as a full-screen pass; the engine binds the current color as input_color and, when useFrameInfo is set, a PostFrameInfo block (resolution, texel size, time). - Scene.render chains the effects: beforeTonemap effects ping-pong on HDR buffers and feed bloom and the resolve; afterTonemap effects run on the resolved image, the last writing the swapchain. The resolve writes to a transient when afterTonemap effects need to chain. - ShaderUniformBindings factors the name-keyed uniform/texture binding so PostEffect packs and binds like ShaderMaterial. Toward #47. --- packages/flutter_scene/lib/scene.dart | 1 + .../lib/src/post_process/post_effect.dart | 121 ++++++++++++++++++ .../lib/src/post_process/post_process.dart | 6 + .../lib/src/render/post_effect_pass.dart | 113 ++++++++++++++++ .../lib/src/render/resolve_pass.dart | 23 +++- packages/flutter_scene/lib/src/scene.dart | 95 +++++++++++++- .../lib/src/shader_uniform_bindings.dart | 74 +++++++++++ .../flutter_scene/test/post_effect_test.dart | 64 +++++++++ 8 files changed, 484 insertions(+), 13 deletions(-) create mode 100644 packages/flutter_scene/lib/src/post_process/post_effect.dart create mode 100644 packages/flutter_scene/lib/src/render/post_effect_pass.dart create mode 100644 packages/flutter_scene/lib/src/shader_uniform_bindings.dart create mode 100644 packages/flutter_scene/test/post_effect_test.dart diff --git a/packages/flutter_scene/lib/scene.dart b/packages/flutter_scene/lib/scene.dart index 244f726..0b4331e 100644 --- a/packages/flutter_scene/lib/scene.dart +++ b/packages/flutter_scene/lib/scene.dart @@ -48,6 +48,7 @@ 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; 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 index 5cf5cf5..fde2c31 100644 --- a/packages/flutter_scene/lib/src/post_process/post_process.dart +++ b/packages/flutter_scene/lib/src/post_process/post_process.dart @@ -1,5 +1,7 @@ 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, @@ -23,6 +25,10 @@ class PostProcessSettings { /// 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 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..c54dad0 --- /dev/null +++ b/packages/flutter_scene/lib/src/render/post_effect_pass.dart @@ -0,0 +1,113 @@ +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/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); + + 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_pass.dart b/packages/flutter_scene/lib/src/render/resolve_pass.dart index 3193764..3a5263a 100644 --- a/packages/flutter_scene/lib/src/render/resolve_pass.dart +++ b/packages/flutter_scene/lib/src/render/resolve_pass.dart @@ -12,23 +12,28 @@ 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 swapchain image: applies exposure, optional color -/// grading, the tone mapping operator, and the display EOTF as a single -/// full-screen pass. +/// 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.RenderTarget target, + required gpu.Texture outputColor, required double exposure, required ToneMappingMode toneMappingMode, required PostProcessSettings postProcess, - }) : _target = target, + }) : _outputColor = outputColor, _exposure = exposure, _toneMappingMode = toneMappingMode, _postProcess = postProcess; - final gpu.RenderTarget _target; + final gpu.Texture _outputColor; final double _exposure; final ToneMappingMode _toneMappingMode; final PostProcessSettings _postProcess; @@ -64,7 +69,9 @@ class ResolvePass extends RenderGraphPass { ); final commandBuffer = gpu.gpuContext.createCommandBuffer(); - final renderPass = commandBuffer.createRenderPass(_target); + final renderPass = commandBuffer.createRenderPass( + gpu.RenderTarget.singleColor(gpu.ColorAttachment(texture: _outputColor)), + ); final pipeline = gpu.gpuContext.createRenderPipeline( _vertexShader, _fragmentShader, @@ -125,5 +132,7 @@ class ResolvePass extends RenderGraphPass { ); renderPass.draw(); commandBuffer.submit(); + + context.blackboard.set(kDisplayColorBlackboardKey, _outputColor); } } diff --git a/packages/flutter_scene/lib/src/scene.dart b/packages/flutter_scene/lib/src/scene.dart index 9f290fe..8547893 100644 --- a/packages/flutter_scene/lib/src/scene.dart +++ b/packages/flutter_scene/lib/src/scene.dart @@ -11,8 +11,10 @@ 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'; @@ -314,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 @@ -377,22 +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( ResolvePass( - target: swapchainTarget, + outputColor: resolveOutput, exposure: exposure, toneMappingMode: toneMapping, postProcess: postProcess, ), ); - // Post-processing passes that operate on the display image run here, - // after the resolve. None yet. + + // 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/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); + }); + }); +} From 82a127b27db5ded13a128cf653f8639c5e62bc54 Mon Sep 17 00:00:00 2001 From: Brandon DeRosier Date: Sun, 24 May 2026 17:25:11 -0700 Subject: [PATCH 09/11] examples: add a custom post-effect demo A user-authored wave-distortion shader (shaders/example_wave.frag) wired through PostEffect and exposed in the settings sidebar with an enable switch, an after-tone-mapping toggle, and an amplitude slider. Loaded from the example shader bundle at startup. --- .../flutter_app/lib/example_settings.dart | 38 ++++++++++++++++ examples/flutter_app/lib/main.dart | 45 ++++++++++++++++++- .../shaders/example.shaderbundle.json | 4 ++ .../flutter_app/shaders/example_wave.frag | 31 +++++++++++++ 4 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 examples/flutter_app/shaders/example_wave.frag diff --git a/examples/flutter_app/lib/example_settings.dart b/examples/flutter_app/lib/example_settings.dart index eb7fea1..1194b89 100644 --- a/examples/flutter_app/lib/example_settings.dart +++ b/examples/flutter_app/lib/example_settings.dart @@ -1,3 +1,4 @@ +import 'package:flutter_scene/gpu.dart' as gpu; import 'package:flutter_scene/scene.dart'; /// Post-processing settings shared by every example. @@ -22,6 +23,13 @@ class ExampleSettings { /// 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; @@ -54,8 +62,38 @@ class ExampleSettings { 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/main.dart b/examples/flutter_app/lib/main.dart index 9b2b806..db527d8 100644 --- a/examples/flutter_app/lib/main.dart +++ b/examples/flutter_app/lib/main.dart @@ -1,7 +1,7 @@ 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'; @@ -28,6 +28,7 @@ class _MyAppState extends State { double elapsedSeconds = 0; String selectedExample = ''; Map examples = {}; + late final Future _ready; @override void initState() { @@ -54,6 +55,11 @@ class _MyAppState extends State { }; selectedExample = examples.keys.first; + _ready = Future.wait([ + Scene.initializeStaticResources(), + loadExampleEffects(), + ]); + super.initState(); } @@ -71,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()); @@ -244,6 +250,7 @@ class _SettingsSidebarState extends State<_SettingsSidebar> { _buildChromaticAberration(), _buildVignette(), _buildFilmGrain(), + _buildCustomEffect(), ], ); } @@ -368,6 +375,40 @@ class _SettingsSidebarState extends State<_SettingsSidebar> { ); } + 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, 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)); +} From 43572d5c91d82ba8b664e5f195260032d8013edc Mon Sep 17 00:00:00 2001 From: Brandon DeRosier Date: Sun, 24 May 2026 18:15:44 -0700 Subject: [PATCH 10/11] Document post-processing and add a 0.15.0 changelog entry POST_PROCESSING.md, a sibling of MATERIALS.md: the built-in suite via Scene.postProcess, the custom PostEffect shader-authoring contract (input_color, the opt-in PostFrameInfo block, named uniforms, the two insertion points and their output contracts), a worked example, and the limitations. The 0.15.0 CHANGELOG entry is prepared release notes; the version bump and consumer-constraint widening are left for the release step. Toward #47. --- POST_PROCESSING.md | 214 ++++++++++++++++++++++++++++ packages/flutter_scene/CHANGELOG.md | 16 +++ 2 files changed, 230 insertions(+) create mode 100644 POST_PROCESSING.md 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/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. From 152cbe76f486934a52fd32c5d62bf376cc4faa00 Mon Sep 17 00:00:00 2001 From: Brandon DeRosier Date: Sun, 24 May 2026 21:39:30 -0700 Subject: [PATCH 11/11] Bind FlipInfo in the bloom and post-effect passes Rebasing onto the OpenGL ES Y-flip work moved the render-to-texture Y-flip into the full-screen vertex shader, which now requires a FlipInfo uniform. The resolve pass picked it up in the merge, but the bloom and custom-effect passes are new files that did not bind it, so their full-screen geometry would collapse. Bind FlipInfo (backendYFlipSign) in BloomPass and PostEffectPass, matching the resolve pass. Toward #47. --- .../flutter_scene/lib/src/render/bloom_pass.dart | 13 +++++++++++++ .../lib/src/render/post_effect_pass.dart | 9 +++++++++ 2 files changed, 22 insertions(+) diff --git a/packages/flutter_scene/lib/src/render/bloom_pass.dart b/packages/flutter_scene/lib/src/render/bloom_pass.dart index 63e30bd..0789311 100644 --- a/packages/flutter_scene/lib/src/render/bloom_pass.dart +++ b/packages/flutter_scene/lib/src/render/bloom_pass.dart @@ -7,6 +7,7 @@ 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. @@ -134,6 +135,7 @@ class BloomPass extends RenderGraphPass { ); renderPass.setColorBlendEnable(false); renderPass.bindVertexBuffer(_quadView, 6); + _bindFlip(context, renderPass); final knee = _settings.threshold * 0.5 + 1e-4; final info = @@ -190,6 +192,7 @@ class BloomPass extends RenderGraphPass { renderPass.setColorBlendEnable(false); } renderPass.bindVertexBuffer(_quadView, 6); + _bindFlip(context, renderPass); final info = Float32List(4) @@ -209,6 +212,16 @@ class BloomPass extends RenderGraphPass { 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, diff --git a/packages/flutter_scene/lib/src/render/post_effect_pass.dart b/packages/flutter_scene/lib/src/render/post_effect_pass.dart index c54dad0..d8332a4 100644 --- a/packages/flutter_scene/lib/src/render/post_effect_pass.dart +++ b/packages/flutter_scene/lib/src/render/post_effect_pass.dart @@ -5,6 +5,7 @@ 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. @@ -79,6 +80,14 @@ class PostEffectPass extends RenderGraphPass { ); 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,