Compare commits

..

1 Commits

Author SHA1 Message Date
hans 6eb28ffcac feat: Add bloom shader variant and enhance shader architecture in README
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-04-21 18:02:10 +02:00
5 changed files with 494 additions and 154 deletions
+136 -11
View File
@@ -86,22 +86,147 @@ For full host wiring examples, see:
- `lib/renderer/` — renderer host widgets.
- `lib/managers/` — runtime/session/display/persistence managers.
- `lib/audio/` — platform-aware audio backends.
- `shaders/wolf_world.frag` — fragment shader included in package configuration.
## Development Commands
From this directory:
```bash
flutter analyze
flutter test
```
- `shaders/wolf_world.frag` base fragment shader included in package configuration.
- `shaders/wolf_world_bloom.frag` — bloom-enabled fragment shader variant.
## Integration Notes
- Keep UI/platform concerns in this package or app hosts, not in `wolf_3d_dart`.
- Use exported APIs from `lib/wolf_3d_flutter.dart` rather than importing private internals from `lib/src` in dependencies.
- Shader path is declared in this package `pubspec.yaml` and must stay synchronized with renderer usage.
- Shader paths are declared in this package `pubspec.yaml` and must stay synchronized with renderer usage.
## Shader Architecture And Performance Notes
This package ships two shader variants:
- `shaders/wolf_world.frag` (base pass, no bloom taps)
- `shaders/wolf_world_bloom.frag` (bloom-enabled variant)
The renderer selects one variant in Dart based on runtime settings. This is a
performance decision: when bloom is disabled, we do not run bloom sampling code
at all.
### No-Branch Shader Policy
For package-owned shader sources, do not use `if` statements. Use branchless
selection patterns (`mix`, `step`, `smoothstep`, mask algebra) instead.
Static check:
```bash
rg "\bif\s*\(" packages/wolf_3d_flutter/shaders
```
Expected result: no matches in source shader files.
### Why This Is Different From Dart
If you are primarily a Dart developer, this is the key mindset shift:
- Dart code runs on CPU cores with branch prediction and comparatively cheap
control flow.
- Fragment shaders run across many pixels in parallel on GPU SIMD/SIMT lanes.
- If neighboring pixels take different branches, the GPU can serialize branch
paths (divergence), reducing throughput.
- Texture reads are usually more expensive than scalar ALU math. Removing bloom
work entirely when disabled is often better than trying to gate it inside one
shader.
In short: in Dart, `if` can be good structure. In fragment shaders, branchless
math and pass selection are often better for frame time.
### Dart-Style Thinking vs Shader-Style Thinking
CPU/Dart style:
```dart
if (effectsEnabled) {
uv = warp(uv);
}
if (outsideScreen(uv)) {
return bezelColor;
}
```
Shader style used here:
```glsl
vec2 effectiveUv = mix(uv, warpedUv, effectsMask);
float bezelMask = (1.0 - insideMask) * effectsMask;
vec3 outColor = mix(screenColor, bezelColor, bezelMask);
```
Both produce feature-equivalent behavior, but the second keeps execution paths
uniform across fragments.
### Shader Block Guide (What / Why)
1. UV normalization:
Convert fragment coordinates to 0..1 UV so all sampling math is resolution
agnostic.
2. Barrel warp:
Simulates curved CRT glass by pushing UVs outward as radius increases.
3. Inside/outside mask:
Computes whether warped UV remains on the emissive screen rectangle. This
replaces branch-based bezel routing.
4. Edge-aware AA:
Computes local luma span from N/S/E/W neighbors and blends toward neighborhood
average only where contrast indicates potential aliasing.
5. CRT modulation:
Applies scanlines, moving sweep, center lift, and vignette to mimic phosphor
and lens behavior.
6. Bezel shading:
Uses overflow distance and edge bleed sampling to build depth, inner lip, and
scene-tinted glow on bezel regions.
7. Bloom variant only:
Adds three-ring cross taps, brightness gating, and tone mapping. This code is
in a separate shader so bloom-off mode avoids paying this cost.
### Constant Tuning Reference
- Warp factor: `0.045`
Higher = stronger curvature.
- AA blend ceiling: `0.45`
Higher = softer edges, more blur risk.
- Scanline band: `0.88 + 0.12 * sin(...)`
Lower floor or higher amplitude increases CRT stripe intensity.
- Sweep speed: `uTime * 0.08`
Higher = faster sweep line travel.
- Bloom ring radii: `3`, `7`, `13` texels
Larger radii spread glow farther but increase halo size.
- Bloom gain: `0.42`
Higher = brighter bloom before tone map.
- Tone map: `color / (color + 0.75) * 1.75`
Controls highlight rolloff and midtone lift.
### Glossary
- UV: normalized texture coordinates in [0, 1].
- Luma: perceived brightness estimate from RGB.
- Mask: scalar 0..1 value used to blend between alternatives.
- Vignette: edge darkening effect.
- Tone map: compresses highlights into displayable range.
- Tap: one texture sample read.
- Divergence: parallel shader lanes taking different branches.
### Profiling Expectations
- Bloom disabled: base shader variant runs, no bloom taps.
- Bloom enabled: bloom shader variant runs, additional texture sampling cost.
- Effects disabled: both shaders still remain branchless; effect contribution is
blended out by mask values.
## Troubleshooting
@@ -37,12 +37,16 @@ class WolfGlslRenderer extends BaseWolfRenderer {
class _WolfGlslRendererState extends BaseWolfRendererState<WolfGlslRenderer> {
static const int _renderWidth = 960;
static const int _renderHeight = 600;
static const String _baseShaderAsset =
'packages/wolf_3d_flutter/shaders/wolf_world.frag';
static const String _bloomShaderAsset =
'packages/wolf_3d_flutter/shaders/wolf_world_bloom.frag';
final SoftwareRenderer _renderer = SoftwareRenderer();
ui.Image? _renderedFrame;
ui.FragmentProgram? _shaderProgram;
ui.FragmentShader? _shader;
ui.FragmentShader? _baseShader;
ui.FragmentShader? _bloomShader;
bool _isShaderUnavailable = false;
@override
@@ -99,7 +103,11 @@ class _WolfGlslRendererState extends BaseWolfRendererState<WolfGlslRenderer> {
return const CircularProgressIndicator(color: Colors.white24);
}
if (_isShaderUnavailable || _shader == null) {
final ui.FragmentShader? activeShader = widget.bloomEnabled
? _bloomShader
: _baseShader;
if (_isShaderUnavailable || activeShader == null) {
// Keep frames visible even if GLSL initialization failed.
return Padding(
padding: const EdgeInsets.all(16.0),
@@ -117,9 +125,8 @@ class _WolfGlslRendererState extends BaseWolfRendererState<WolfGlslRenderer> {
child: CustomPaint(
painter: _GlslFramePainter(
frame: _renderedFrame!,
shader: _shader!,
shader: activeShader,
effectsEnabled: widget.effectsEnabled,
bloomEnabled: widget.bloomEnabled,
elapsedSeconds: widget.engine.timeAliveMs / 1000.0,
),
child: const SizedBox.expand(),
@@ -130,15 +137,18 @@ class _WolfGlslRendererState extends BaseWolfRendererState<WolfGlslRenderer> {
Future<void> _loadShader() async {
try {
final ui.FragmentProgram program = await ui.FragmentProgram.fromAsset(
'packages/wolf_3d_flutter/shaders/wolf_world.frag',
final List<ui.FragmentProgram> programs = await Future.wait(
<Future<ui.FragmentProgram>>[
ui.FragmentProgram.fromAsset(_baseShaderAsset),
ui.FragmentProgram.fromAsset(_bloomShaderAsset),
],
);
if (!mounted) {
return;
}
setState(() {
_shaderProgram = program;
_shader = _shaderProgram!.fragmentShader();
_baseShader = programs[0].fragmentShader();
_bloomShader = programs[1].fragmentShader();
_isShaderUnavailable = false;
});
} catch (_) {
@@ -157,14 +167,12 @@ class _GlslFramePainter extends CustomPainter {
final ui.Image frame;
final ui.FragmentShader shader;
final bool effectsEnabled;
final bool bloomEnabled;
final double elapsedSeconds;
_GlslFramePainter({
required this.frame,
required this.shader,
required this.effectsEnabled,
required this.bloomEnabled,
required this.elapsedSeconds,
});
@@ -179,7 +187,6 @@ class _GlslFramePainter extends CustomPainter {
..setFloat(3, texelY)
..setFloat(4, effectsEnabled ? 1.0 : 0.0)
..setFloat(5, elapsedSeconds)
..setFloat(6, bloomEnabled ? 1.0 : 0.0)
..setImageSampler(0, frame);
final Paint paint = Paint()
@@ -194,7 +201,6 @@ class _GlslFramePainter extends CustomPainter {
return oldDelegate.frame != frame ||
oldDelegate.shader != shader ||
oldDelegate.effectsEnabled != effectsEnabled ||
oldDelegate.bloomEnabled != bloomEnabled ||
oldDelegate.elapsedSeconds != elapsedSeconds;
}
}
+1 -1
View File
@@ -28,7 +28,7 @@ dev_dependencies:
flutter:
shaders:
- shaders/wolf_world.frag
- shaders/wolf_world_bloom.frag
# To add assets to your package, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
+124 -127
View File
@@ -1,100 +1,91 @@
#include <flutter/runtime_effect.glsl>
// Output surface size in pixels.
// ----------------------------------------------------------------------------
// Base Variant (Branchless, No Bloom)
// ----------------------------------------------------------------------------
// This shader is the bloom-disabled base variant of the Wolf CRT post-process
// path.
//
// Why keep a separate base file?
// - Dart/CPU side can select this program when bloom is off.
// - That avoids bloom texture taps entirely in the hot path.
// - This is a direct performance optimization, not just stylistic separation.
//
// Why branchless math throughout?
// - Fragment programs run many pixels in lockstep.
// - Divergent control flow can reduce throughput.
// - Mask-based selection using mix/step/smoothstep keeps execution uniform.
//
// For Dart developers:
// - Think dataflow over fields of values, not object-level control logic.
// - Most operations transform scalar/vector fields per pixel.
// - Alternative outcomes are blended by masks instead of branching.
// Output surface size in pixels for the current draw call.
uniform vec2 uResolution;
// One source-texel step in UV space: (1/width, 1/height).
// One source texel step in UV space: (1/width, 1/height).
// This keeps neighborhood kernels stable across resolutions.
uniform vec2 uTexel;
// 1.0 enables CRT post-process effects, 0.0 keeps only base AA.
// 1.0 enables CRT warp/scanline stack, 0.0 keeps only base AA stack.
// Even though this is conceptually boolean, it is expressed as float so the
// shader can blend outcomes natively with mask math.
uniform float uEffectsEnabled;
// Engine time in seconds used to animate scanline travel.
uniform float uTime;
// 1.0 enables CRT phosphor bloom glow, 0.0 disables it.
uniform float uBloomEnabled;
// Source frame produced by the software renderer.
uniform sampler2D uTexture;
out vec4 fragColor;
// Perceptual brightness approximation used for edge detection.
// Perceptual brightness approximation for edge detection.
// This uses Rec.601-style luma weighting.
float luma(vec3 color) {
return dot(color, vec3(0.299, 0.587, 0.114));
}
// Returns 1.0 when uv is inside [0,1] on both axes, else 0.0.
// Implemented branchlessly as a product of step() tests.
float uvInsideMask(vec2 uv) {
vec2 lower = step(vec2(0.0), uv);
vec2 upper = step(uv, vec2(1.0));
return lower.x * lower.y * upper.x * upper.y;
}
void main() {
// Convert fragment coordinates to normalized UV coordinates.
// Normalize destination pixel coordinate into UV space.
vec2 uv = FlutterFragCoord().xy / uResolution;
if (uEffectsEnabled > 0.5) {
// Barrel-like warp to emulate curved CRT glass.
// --------------------------------------------------------------------------
// 1) CRT warp selection (branchless)
// --------------------------------------------------------------------------
// The centered radius term drives a mild barrel distortion.
vec2 centered = uv * 2.0 - 1.0;
float radius2 = dot(centered, centered);
centered *= 1.0 + radius2 * 0.045;
uv = centered * 0.5 + 0.5;
vec2 warpedUv = centered * (1.0 + radius2 * 0.045) * 0.5 + 0.5;
// Fill outside warped bounds with a darker consumer-TV charcoal bezel.
if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) {
vec2 clampedUv = clamp(uv, 0.0, 1.0);
vec2 edgeDelta = uv - clampedUv;
float overflow = max(abs(edgeDelta.x), abs(edgeDelta.y));
// Mix between linear and warped UV with a float mask.
// In Dart, this is conceptually: useWarped ? warpedUv : uv.
vec2 effectiveUv = mix(uv, warpedUv, uEffectsEnabled);
// Sample near-edge scene colors and spread them onto the bezel.
vec2 inwardDir = normalize(-edgeDelta + vec2(1e-6));
vec2 bleedStep = vec2(uTexel.x * 1.6, uTexel.y * 1.6);
vec2 bleedUv1 = clamp(clampedUv + inwardDir * bleedStep, 0.0, 1.0);
vec2 bleedUv2 = clamp(clampedUv + inwardDir * bleedStep * 2.6, 0.0, 1.0);
vec2 bleedUv3 = clamp(clampedUv + inwardDir * bleedStep * 4.2, 0.0, 1.0);
vec3 edgeBleedColor =
texture(uTexture, clampedUv).rgb * 0.52 +
texture(uTexture, bleedUv1).rgb * 0.28 +
texture(uTexture, bleedUv2).rgb * 0.14 +
texture(uTexture, bleedUv3).rgb * 0.06;
float edgeBleedLuma = luma(edgeBleedColor);
// Clamp once to keep all downstream texture fetches in bounds.
vec2 sampleUv = clamp(effectiveUv, 0.0, 1.0);
// Approximate concave bezel depth by measuring how far this fragment is
// from the emissive screen boundary in aspect-corrected UV space.
vec2 aspectScale = vec2(uResolution.x / max(uResolution.y, 1.0), 1.0);
float bezelDistance = length(edgeDelta * aspectScale);
// 1.0 for screen interior, 0.0 outside.
float insideMask = uvInsideMask(effectiveUv);
// Bezel contributes only where warped coordinates leave the source screen.
float bezelMask = (1.0 - insideMask) * uEffectsEnabled;
// Corners receive less direct bleed because the nearest lit area is
// diagonally offset, so attenuate glow toward corner regions.
vec2 clampedCentered = clampedUv * 2.0 - 1.0;
float cornerFactor = smoothstep(0.60, 1.15, length(clampedCentered));
// --------------------------------------------------------------------------
// 2) Lightweight edge-aware AA
// --------------------------------------------------------------------------
// Sample center + cardinal neighbors.
vec4 centerSample = texture(uTexture, sampleUv);
vec3 sampleN = texture(uTexture, clamp(sampleUv + vec2(0.0, -uTexel.y), 0.0, 1.0)).rgb;
vec3 sampleS = texture(uTexture, clamp(sampleUv + vec2(0.0, uTexel.y), 0.0, 1.0)).rgb;
vec3 sampleE = texture(uTexture, clamp(sampleUv + vec2(uTexel.x, 0.0), 0.0, 1.0)).rgb;
vec3 sampleW = texture(uTexture, clamp(sampleUv + vec2(-uTexel.x, 0.0), 0.0, 1.0)).rgb;
float verticalShade = 0.88 + 0.07 * (1.0 - (FlutterFragCoord().y / uResolution.y));
float depthShade = 1.0 - smoothstep(0.0, 0.058, overflow) * 0.34;
float grain = sin(FlutterFragCoord().x * 0.21 + FlutterFragCoord().y * 0.11) * 0.006;
float moldedHighlight = smoothstep(0.072, 0.0, overflow) * 0.028;
// Deeper arcade-style profile: tighter, scene-tinted bleed rolloff.
float bezelGlow = exp(-bezelDistance * 82.0) * mix(1.0, 0.56, cornerFactor);
float innerLip = exp(-bezelDistance * 170.0) * 0.10;
float bleedStrength = smoothstep(0.12, 0.0, overflow) * (0.78 - cornerFactor * 0.26);
float bloomBezelBoost = 1.0 +
uBloomEnabled * smoothstep(0.16, 0.82, edgeBleedLuma) * 0.75;
float bloomLipBoost = 1.0 +
uBloomEnabled * smoothstep(0.10, 0.68, edgeBleedLuma) * 0.45;
vec3 bezelColor =
vec3(0.225, 0.225, 0.215) * verticalShade * depthShade +
edgeBleedColor * bezelGlow * bleedStrength * 1.12 * bloomBezelBoost +
edgeBleedColor * innerLip * 0.36 * bloomLipBoost +
vec3(moldedHighlight) +
vec3(grain);
fragColor = vec4(bezelColor, 1.0);
return;
}
}
// Read the base color from the source frame.
vec4 centerSample = texture(uTexture, uv);
// Sample 4-neighborhood (N/S/E/W) around the current pixel.
vec3 sampleN = texture(uTexture, uv + vec2(0.0, -uTexel.y)).rgb;
vec3 sampleS = texture(uTexture, uv + vec2(0.0, uTexel.y)).rgb;
vec3 sampleE = texture(uTexture, uv + vec2(uTexel.x, 0.0)).rgb;
vec3 sampleW = texture(uTexture, uv + vec2(-uTexel.x, 0.0)).rgb;
// Compute local luma range; wider range means a stronger edge.
// Luma span estimates local contrast; high span implies stronger edge.
float lumaCenter = luma(centerSample.rgb);
float lumaMin = min(
lumaCenter,
@@ -104,72 +95,78 @@ void main() {
lumaCenter,
max(max(luma(sampleN), luma(sampleS)), max(luma(sampleE), luma(sampleW)))
);
float edgeSpan = max(lumaMax - lumaMin, 0.0001);
// Convert raw edge strength into a smooth 0..1 blending amount.
float edgeAmount = smoothstep(0.03, 0.18, edgeSpan);
// Average neighbors and blend toward that average only near edges.
// This acts like a lightweight edge-aware anti-aliasing pass.
float edgeAmount = smoothstep(0.03, 0.18, max(lumaMax - lumaMin, 0.0001));
vec3 neighborhoodAvg = (sampleN + sampleS + sampleE + sampleW) * 0.25;
// Blend toward neighborhood average near likely edges.
vec3 aaColor = mix(centerSample.rgb, neighborhoodAvg, edgeAmount * 0.45);
// Preserve source alpha and output the anti-aliased color.
vec3 outColor = aaColor;
if (uEffectsEnabled > 0.5) {
// Horizontal scanline modulation.
float scanlineBand = 0.88 + 0.12 * sin(uv.y * uResolution.y * 3.14159265);
// Slow bright line crawling down the screen.
// --------------------------------------------------------------------------
// 3) CRT scanline/sweep/vignette stack
// --------------------------------------------------------------------------
float scanlineBand = 0.88 + 0.12 * sin(sampleUv.y * uResolution.y * 3.14159265);
float sweepPos = fract(uTime * 0.08);
float sweepBand = 1.0 + 0.16 * exp(-pow((uv.y - sweepPos) * 120.0, 2.0));
// Slight center brightening and edge falloff (CRT phosphor + lens feel).
vec2 centeredUv = uv * 2.0 - 1.0;
float sweepBand = 1.0 + 0.16 * exp(-pow((sampleUv.y - sweepPos) * 120.0, 2.0));
vec2 centeredUv = sampleUv * 2.0 - 1.0;
float vignette = smoothstep(1.15, 0.25, length(centeredUv));
float centerLift = 1.0 + 0.08 * (1.0 - length(centeredUv));
outColor *= scanlineBand * sweepBand * centerLift;
outColor *= mix(0.62, 1.0, vignette);
}
vec3 crtColor = aaColor;
crtColor *= scanlineBand * sweepBand * centerLift;
crtColor *= mix(0.62, 1.0, vignette);
if (uBloomEnabled > 0.5) {
// CRT phosphor bloom: bright areas spread a soft luminance glow.
// Sample a three-ring cross pattern directly from the source texture so
// the spread is measured in source-texel space and stays resolution-stable.
vec2 s1 = uTexel * 3.0;
vec2 s2 = uTexel * 7.0;
vec2 s3 = uTexel * 13.0;
// Effects mask decides whether CRT modulation is applied.
vec3 screenColor = mix(aaColor, crtColor, uEffectsEnabled);
vec3 glow = vec3(0.0);
// Inner ring — weight 1.0 each
glow += texture(uTexture, uv + vec2( s1.x, 0.0)).rgb;
glow += texture(uTexture, uv + vec2(-s1.x, 0.0)).rgb;
glow += texture(uTexture, uv + vec2( 0.0, s1.y)).rgb;
glow += texture(uTexture, uv + vec2( 0.0, -s1.y)).rgb;
// Mid ring — weight 0.5 each
glow += texture(uTexture, uv + vec2( s2.x, 0.0)).rgb * 0.5;
glow += texture(uTexture, uv + vec2(-s2.x, 0.0)).rgb * 0.5;
glow += texture(uTexture, uv + vec2( 0.0, s2.y)).rgb * 0.5;
glow += texture(uTexture, uv + vec2( 0.0, -s2.y)).rgb * 0.5;
// Outer ring — weight 0.25 each
glow += texture(uTexture, uv + vec2( s3.x, 0.0)).rgb * 0.25;
glow += texture(uTexture, uv + vec2(-s3.x, 0.0)).rgb * 0.25;
glow += texture(uTexture, uv + vec2( 0.0, s3.y)).rgb * 0.25;
glow += texture(uTexture, uv + vec2( 0.0, -s3.y)).rgb * 0.25;
// Normalize: 4*1.0 + 4*0.5 + 4*0.25 = 7.0
glow /= 7.0;
// --------------------------------------------------------------------------
// 4) Bezel shading path (branchless selection)
// --------------------------------------------------------------------------
// This base variant intentionally does not include bloom calculations.
// Only bright pixels contribute — gate the bloom contribution on luma.
float glowLuma = luma(glow);
float bloomStrength = smoothstep(0.18, 0.82, glowLuma);
// edgeDelta is non-zero when warp pushes UV outside source bounds.
vec2 edgeDelta = effectiveUv - sampleUv;
float overflow = max(abs(edgeDelta.x), abs(edgeDelta.y));
// Add bloom additively then apply a gentle Reinhard-style tone-map to
// prevent over-saturation while keeping dark areas clean.
outColor = outColor + glow * bloomStrength * 0.42;
outColor = outColor / (outColor + vec3(0.75)) * 1.75;
}
// Sample inward from clamped border to pull scene tint into bezel.
vec2 inwardDir = normalize(-edgeDelta + vec2(1e-6));
vec2 bleedStep = vec2(uTexel.x * 1.6, uTexel.y * 1.6);
vec2 bleedUv1 = clamp(sampleUv + inwardDir * bleedStep, 0.0, 1.0);
vec2 bleedUv2 = clamp(sampleUv + inwardDir * bleedStep * 2.6, 0.0, 1.0);
vec2 bleedUv3 = clamp(sampleUv + inwardDir * bleedStep * 4.2, 0.0, 1.0);
vec3 edgeBleedColor =
texture(uTexture, sampleUv).rgb * 0.52 +
texture(uTexture, bleedUv1).rgb * 0.28 +
texture(uTexture, bleedUv2).rgb * 0.14 +
texture(uTexture, bleedUv3).rgb * 0.06;
fragColor = vec4(outColor, centerSample.a);
// Aspect-corrected radial metrics for bezel falloff shaping.
vec2 aspectScale = vec2(uResolution.x / max(uResolution.y, 1.0), 1.0);
float bezelDistance = length(edgeDelta * aspectScale);
vec2 clampedCentered = sampleUv * 2.0 - 1.0;
float cornerFactor = smoothstep(0.60, 1.15, length(clampedCentered));
// Shading layers:
// - verticalShade: slight top/bottom tonal variance
// - depthShade: darkens with overflow depth
// - grain: subtle analog texture
// - moldedHighlight: narrow inner edge highlight
float verticalShade = 0.88 + 0.07 * (1.0 - (FlutterFragCoord().y / uResolution.y));
float depthShade = 1.0 - smoothstep(0.0, 0.058, overflow) * 0.34;
float grain = sin(FlutterFragCoord().x * 0.21 + FlutterFragCoord().y * 0.11) * 0.006;
float moldedHighlight = smoothstep(0.072, 0.0, overflow) * 0.028;
float bezelGlow = exp(-bezelDistance * 82.0) * mix(1.0, 0.56, cornerFactor);
float innerLip = exp(-bezelDistance * 170.0) * 0.10;
float bleedStrength = smoothstep(0.12, 0.0, overflow) * (0.78 - cornerFactor * 0.26);
vec3 bezelColor =
vec3(0.225, 0.225, 0.215) * verticalShade * depthShade +
edgeBleedColor * bezelGlow * bleedStrength * 1.12 +
edgeBleedColor * innerLip * 0.36 +
vec3(moldedHighlight) +
vec3(grain);
// Final branchless selection between emissive screen and bezel.
vec3 outColor = mix(screenColor, bezelColor, bezelMask);
float outAlpha = mix(centerSample.a, 1.0, bezelMask);
fragColor = vec4(outColor, outAlpha);
}
@@ -0,0 +1,212 @@
#include <flutter/runtime_effect.glsl>
// ----------------------------------------------------------------------------
// Bloom Variant (Branchless)
// ----------------------------------------------------------------------------
// This shader is the bloom-enabled variant of the Wolf CRT post-process path.
//
// Why a separate file instead of one giant toggle-heavy shader?
// - Dart/CPU side chooses this program only when bloom is enabled.
// - When bloom is disabled, the renderer uses the base shader variant and
// avoids bloom texture taps entirely.
// - This is a deliberate performance decision: skipping work at pipeline
// selection time is usually faster than computing work and masking it out.
//
// Why so much branchless math?
// - GPU fragment execution runs many pixels in lockstep.
// - Divergent control flow reduces throughput.
// - We prefer mask-driven selection using mix/step/smoothstep.
//
// For Dart developers:
// - Think of this as vectorized numeric dataflow, not object-oriented logic.
// - Most values are scalar or vector fields over the whole screen.
// - We combine fields with interpolation/masks instead of choosing one code
// path with direct branching.
// Output surface size in pixels for the current draw call.
uniform vec2 uResolution;
// One source texel step in UV space: (1/width, 1/height).
// This keeps kernel sizes stable across resolutions.
uniform vec2 uTexel;
// 1.0 enables CRT warp/scanline stack, 0.0 keeps only base AA stack.
// Even though this is a boolean concept, it is expressed as float because
// interpolation and mask blending are native operations in GLSL.
uniform float uEffectsEnabled;
// Engine time in seconds used to animate scanline travel.
uniform float uTime;
// Source frame produced by the software renderer.
uniform sampler2D uTexture;
out vec4 fragColor;
// Perceptual brightness approximation for edge and bloom gating.
// This is Rec.601-style luma weighting.
float luma(vec3 color) {
return dot(color, vec3(0.299, 0.587, 0.114));
}
// Returns 1.0 when uv is inside [0,1] on both axes, else 0.0.
// Implemented branchlessly with step() products.
float uvInsideMask(vec2 uv) {
vec2 lower = step(vec2(0.0), uv);
vec2 upper = step(uv, vec2(1.0));
return lower.x * lower.y * upper.x * upper.y;
}
void main() {
// Normalize destination pixel coordinate into UV space.
vec2 uv = FlutterFragCoord().xy / uResolution;
// --------------------------------------------------------------------------
// 1) CRT warp selection (branchless)
// --------------------------------------------------------------------------
// The centered radius term drives a mild barrel distortion.
vec2 centered = uv * 2.0 - 1.0;
float radius2 = dot(centered, centered);
vec2 warpedUv = centered * (1.0 + radius2 * 0.045) * 0.5 + 0.5;
// Mix between linear and warped UV with a float mask.
// In Dart, this is conceptually: useWarped ? warpedUv : uv.
vec2 effectiveUv = mix(uv, warpedUv, uEffectsEnabled);
// Clamp once to keep all downstream texture fetches in bounds.
vec2 sampleUv = clamp(effectiveUv, 0.0, 1.0);
// 1.0 for screen interior, 0.0 outside.
float insideMask = uvInsideMask(effectiveUv);
// Bezel contributes only where warped coordinates leave the source screen.
float bezelMask = (1.0 - insideMask) * uEffectsEnabled;
// --------------------------------------------------------------------------
// 2) Lightweight edge-aware AA
// --------------------------------------------------------------------------
// Sample center + cardinal neighbors.
vec4 centerSample = texture(uTexture, sampleUv);
vec3 sampleN = texture(uTexture, clamp(sampleUv + vec2(0.0, -uTexel.y), 0.0, 1.0)).rgb;
vec3 sampleS = texture(uTexture, clamp(sampleUv + vec2(0.0, uTexel.y), 0.0, 1.0)).rgb;
vec3 sampleE = texture(uTexture, clamp(sampleUv + vec2(uTexel.x, 0.0), 0.0, 1.0)).rgb;
vec3 sampleW = texture(uTexture, clamp(sampleUv + vec2(-uTexel.x, 0.0), 0.0, 1.0)).rgb;
// Luma span estimates local contrast; high span implies stronger edge.
float lumaCenter = luma(centerSample.rgb);
float lumaMin = min(
lumaCenter,
min(min(luma(sampleN), luma(sampleS)), min(luma(sampleE), luma(sampleW)))
);
float lumaMax = max(
lumaCenter,
max(max(luma(sampleN), luma(sampleS)), max(luma(sampleE), luma(sampleW)))
);
float edgeAmount = smoothstep(0.03, 0.18, max(lumaMax - lumaMin, 0.0001));
vec3 neighborhoodAvg = (sampleN + sampleS + sampleE + sampleW) * 0.25;
// Blend toward neighborhood average near likely edges.
vec3 aaColor = mix(centerSample.rgb, neighborhoodAvg, edgeAmount * 0.45);
// --------------------------------------------------------------------------
// 3) CRT scanline/sweep/vignette stack
// --------------------------------------------------------------------------
float scanlineBand = 0.88 + 0.12 * sin(sampleUv.y * uResolution.y * 3.14159265);
float sweepPos = fract(uTime * 0.08);
float sweepBand = 1.0 + 0.16 * exp(-pow((sampleUv.y - sweepPos) * 120.0, 2.0));
vec2 centeredUv = sampleUv * 2.0 - 1.0;
float vignette = smoothstep(1.15, 0.25, length(centeredUv));
float centerLift = 1.0 + 0.08 * (1.0 - length(centeredUv));
vec3 crtColor = aaColor;
crtColor *= scanlineBand * sweepBand * centerLift;
crtColor *= mix(0.62, 1.0, vignette);
// Effects mask decides whether CRT modulation is applied.
vec3 screenColor = mix(aaColor, crtColor, uEffectsEnabled);
// --------------------------------------------------------------------------
// 4) Bloom (enabled by selecting this shader variant)
// --------------------------------------------------------------------------
// Three cross-shaped rings measured in source texels.
vec2 s1 = uTexel * 3.0;
vec2 s2 = uTexel * 7.0;
vec2 s3 = uTexel * 13.0;
// Accumulate weighted glow taps.
vec3 glow = vec3(0.0);
glow += texture(uTexture, clamp(sampleUv + vec2( s1.x, 0.0), 0.0, 1.0)).rgb;
glow += texture(uTexture, clamp(sampleUv + vec2(-s1.x, 0.0), 0.0, 1.0)).rgb;
glow += texture(uTexture, clamp(sampleUv + vec2( 0.0, s1.y), 0.0, 1.0)).rgb;
glow += texture(uTexture, clamp(sampleUv + vec2( 0.0, -s1.y), 0.0, 1.0)).rgb;
glow += texture(uTexture, clamp(sampleUv + vec2( s2.x, 0.0), 0.0, 1.0)).rgb * 0.5;
glow += texture(uTexture, clamp(sampleUv + vec2(-s2.x, 0.0), 0.0, 1.0)).rgb * 0.5;
glow += texture(uTexture, clamp(sampleUv + vec2( 0.0, s2.y), 0.0, 1.0)).rgb * 0.5;
glow += texture(uTexture, clamp(sampleUv + vec2( 0.0, -s2.y), 0.0, 1.0)).rgb * 0.5;
glow += texture(uTexture, clamp(sampleUv + vec2( s3.x, 0.0), 0.0, 1.0)).rgb * 0.25;
glow += texture(uTexture, clamp(sampleUv + vec2(-s3.x, 0.0), 0.0, 1.0)).rgb * 0.25;
glow += texture(uTexture, clamp(sampleUv + vec2( 0.0, s3.y), 0.0, 1.0)).rgb * 0.25;
glow += texture(uTexture, clamp(sampleUv + vec2( 0.0, -s3.y), 0.0, 1.0)).rgb * 0.25;
// Normalize sum (4*1.0 + 4*0.5 + 4*0.25 = 7.0).
glow /= 7.0;
// Gate bloom with luma to keep dark areas cleaner.
float bloomStrength = smoothstep(0.18, 0.82, luma(glow));
screenColor = screenColor + glow * bloomStrength * 0.42;
// Gentle tone map to roll off highlights.
screenColor = screenColor / (screenColor + vec3(0.75)) * 1.75;
// --------------------------------------------------------------------------
// 5) Bezel shading path (branchless selection)
// --------------------------------------------------------------------------
// edgeDelta is non-zero when warp pushes UV outside source bounds.
vec2 edgeDelta = effectiveUv - sampleUv;
float overflow = max(abs(edgeDelta.x), abs(edgeDelta.y));
// Sample inward from clamped border to pull scene tint into bezel.
vec2 inwardDir = normalize(-edgeDelta + vec2(1e-6));
vec2 bleedStep = vec2(uTexel.x * 1.6, uTexel.y * 1.6);
vec2 bleedUv1 = clamp(sampleUv + inwardDir * bleedStep, 0.0, 1.0);
vec2 bleedUv2 = clamp(sampleUv + inwardDir * bleedStep * 2.6, 0.0, 1.0);
vec2 bleedUv3 = clamp(sampleUv + inwardDir * bleedStep * 4.2, 0.0, 1.0);
vec3 edgeBleedColor =
texture(uTexture, sampleUv).rgb * 0.52 +
texture(uTexture, bleedUv1).rgb * 0.28 +
texture(uTexture, bleedUv2).rgb * 0.14 +
texture(uTexture, bleedUv3).rgb * 0.06;
// Bezel luma drives bloom-biased boosts near bright edges.
float edgeBleedLuma = luma(edgeBleedColor);
// Aspect-corrected radial metrics for bezel falloff shaping.
vec2 aspectScale = vec2(uResolution.x / max(uResolution.y, 1.0), 1.0);
float bezelDistance = length(edgeDelta * aspectScale);
vec2 clampedCentered = sampleUv * 2.0 - 1.0;
float cornerFactor = smoothstep(0.60, 1.15, length(clampedCentered));
// Shading layers:
// - verticalShade: slight top/bottom tonal variance
// - depthShade: darkens with overflow depth
// - grain: subtle analog texture
// - moldedHighlight: narrow inner edge highlight
float verticalShade = 0.88 + 0.07 * (1.0 - (FlutterFragCoord().y / uResolution.y));
float depthShade = 1.0 - smoothstep(0.0, 0.058, overflow) * 0.34;
float grain = sin(FlutterFragCoord().x * 0.21 + FlutterFragCoord().y * 0.11) * 0.006;
float moldedHighlight = smoothstep(0.072, 0.0, overflow) * 0.028;
float bezelGlow = exp(-bezelDistance * 82.0) * mix(1.0, 0.56, cornerFactor);
float innerLip = exp(-bezelDistance * 170.0) * 0.10;
float bleedStrength = smoothstep(0.12, 0.0, overflow) * (0.78 - cornerFactor * 0.26);
// Bloom variant intentionally boosts bezel bleed near bright scene edges.
float bloomBezelBoost = 1.0 + smoothstep(0.16, 0.82, edgeBleedLuma) * 0.75;
float bloomLipBoost = 1.0 + smoothstep(0.10, 0.68, edgeBleedLuma) * 0.45;
vec3 bezelColor =
vec3(0.225, 0.225, 0.215) * verticalShade * depthShade +
edgeBleedColor * bezelGlow * bleedStrength * 1.12 * bloomBezelBoost +
edgeBleedColor * innerLip * 0.36 * bloomLipBoost +
vec3(moldedHighlight) +
vec3(grain);
// Final branchless selection between emissive screen and bezel.
vec3 outColor = mix(screenColor, bezelColor, bezelMask);
float outAlpha = mix(centerSample.a, 1.0, bezelMask);
fragColor = vec4(outColor, outAlpha);
}