From 6eb28ffcac3e077d4331c568dc75eb9b579d2c8f Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Tue, 21 Apr 2026 18:02:10 +0200 Subject: [PATCH] feat: Add bloom shader variant and enhance shader architecture in README Signed-off-by: Hans Kokx --- packages/wolf_3d_flutter/README.md | 147 +++++++++- .../lib/renderer/wolf_3d_glsl_renderer.dart | 32 ++- packages/wolf_3d_flutter/pubspec.yaml | 2 +- .../wolf_3d_flutter/shaders/wolf_world.frag | 255 +++++++++--------- .../shaders/wolf_world_bloom.frag | 212 +++++++++++++++ 5 files changed, 494 insertions(+), 154 deletions(-) create mode 100644 packages/wolf_3d_flutter/shaders/wolf_world_bloom.frag diff --git a/packages/wolf_3d_flutter/README.md b/packages/wolf_3d_flutter/README.md index 3e3cc59..f97b767 100644 --- a/packages/wolf_3d_flutter/README.md +++ b/packages/wolf_3d_flutter/README.md @@ -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 diff --git a/packages/wolf_3d_flutter/lib/renderer/wolf_3d_glsl_renderer.dart b/packages/wolf_3d_flutter/lib/renderer/wolf_3d_glsl_renderer.dart index 4fcf4b5..8c59587 100644 --- a/packages/wolf_3d_flutter/lib/renderer/wolf_3d_glsl_renderer.dart +++ b/packages/wolf_3d_flutter/lib/renderer/wolf_3d_glsl_renderer.dart @@ -37,12 +37,16 @@ class WolfGlslRenderer extends BaseWolfRenderer { class _WolfGlslRendererState extends BaseWolfRendererState { 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 { 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 { 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 { Future _loadShader() async { try { - final ui.FragmentProgram program = await ui.FragmentProgram.fromAsset( - 'packages/wolf_3d_flutter/shaders/wolf_world.frag', + final List programs = await Future.wait( + >[ + 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; } } diff --git a/packages/wolf_3d_flutter/pubspec.yaml b/packages/wolf_3d_flutter/pubspec.yaml index 9a1da9b..9b9473d 100644 --- a/packages/wolf_3d_flutter/pubspec.yaml +++ b/packages/wolf_3d_flutter/pubspec.yaml @@ -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 diff --git a/packages/wolf_3d_flutter/shaders/wolf_world.frag b/packages/wolf_3d_flutter/shaders/wolf_world.frag index df8c5fd..decec84 100644 --- a/packages/wolf_3d_flutter/shaders/wolf_world.frag +++ b/packages/wolf_3d_flutter/shaders/wolf_world.frag @@ -1,100 +1,91 @@ #include -// 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. - vec2 centered = uv * 2.0 - 1.0; - float radius2 = dot(centered, centered); - centered *= 1.0 + radius2 * 0.045; - uv = centered * 0.5 + 0.5; + // -------------------------------------------------------------------------- + // 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; - // 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; + // -------------------------------------------------------------------------- + // 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)); - if (uEffectsEnabled > 0.5) { - // Horizontal scanline modulation. - float scanlineBand = 0.88 + 0.12 * sin(uv.y * uResolution.y * 3.14159265); + vec3 crtColor = aaColor; + crtColor *= scanlineBand * sweepBand * centerLift; + crtColor *= mix(0.62, 1.0, vignette); - // Slow bright line crawling down the screen. - float sweepPos = fract(uTime * 0.08); - float sweepBand = 1.0 + 0.16 * exp(-pow((uv.y - sweepPos) * 120.0, 2.0)); + // Effects mask decides whether CRT modulation is applied. + vec3 screenColor = mix(aaColor, crtColor, uEffectsEnabled); - // Slight center brightening and edge falloff (CRT phosphor + lens feel). - vec2 centeredUv = uv * 2.0 - 1.0; - float vignette = smoothstep(1.15, 0.25, length(centeredUv)); - float centerLift = 1.0 + 0.08 * (1.0 - length(centeredUv)); + // -------------------------------------------------------------------------- + // 4) Bezel shading path (branchless selection) + // -------------------------------------------------------------------------- + // This base variant intentionally does not include bloom calculations. - outColor *= scanlineBand * sweepBand * centerLift; - outColor *= mix(0.62, 1.0, vignette); - } + // edgeDelta is non-zero when warp pushes UV outside source bounds. + vec2 edgeDelta = effectiveUv - sampleUv; + float overflow = max(abs(edgeDelta.x), abs(edgeDelta.y)); - 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; + // 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; - 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; + // 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)); - // Only bright pixels contribute — gate the bloom contribution on luma. - float glowLuma = luma(glow); - float bloomStrength = smoothstep(0.18, 0.82, glowLuma); + // 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); - // 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; - } + vec3 bezelColor = + vec3(0.225, 0.225, 0.215) * verticalShade * depthShade + + edgeBleedColor * bezelGlow * bleedStrength * 1.12 + + edgeBleedColor * innerLip * 0.36 + + vec3(moldedHighlight) + + vec3(grain); - fragColor = vec4(outColor, centerSample.a); + // 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); } diff --git a/packages/wolf_3d_flutter/shaders/wolf_world_bloom.frag b/packages/wolf_3d_flutter/shaders/wolf_world_bloom.frag new file mode 100644 index 0000000..9724e18 --- /dev/null +++ b/packages/wolf_3d_flutter/shaders/wolf_world_bloom.frag @@ -0,0 +1,212 @@ +#include + +// ---------------------------------------------------------------------------- +// 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); +}