diff --git a/.vscode/settings.json b/.vscode/settings.json index 7df4a12..47e81c5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,6 @@ { - "cmake.ignoreCMakeListsMissing": true + "cmake.ignoreCMakeListsMissing": true, + "chat.tools.terminal.autoApprove": { + "flutter": true + } } \ No newline at end of file diff --git a/apps/wolf_3d_gui/lib/screens/game_screen.dart b/apps/wolf_3d_gui/lib/screens/game_screen.dart index 783745b..14adf53 100644 --- a/apps/wolf_3d_gui/lib/screens/game_screen.dart +++ b/apps/wolf_3d_gui/lib/screens/game_screen.dart @@ -35,6 +35,7 @@ class _GameScreenState extends State { late final WolfEngine _engine; RendererMode _rendererMode = RendererMode.hardware; AsciiTheme _asciiTheme = AsciiThemes.blocks; + bool _glslEffectsEnabled = false; @override void initState() { @@ -109,7 +110,7 @@ class _GameScreenState extends State { top: 16, right: 16, child: Text( - '<${widget.wolf3d.input.rendererToggleKeyLabel}> ${_rendererMode.name}${_rendererMode == RendererMode.ascii ? ' <${widget.wolf3d.input.asciiThemeCycleKeyLabel}> ${_asciiTheme.name}' : ''} <${widget.wolf3d.input.fpsToggleKeyLabel}> FPS ${_engine.showFpsCounter ? 'On' : 'Off'}', + '<${widget.wolf3d.input.rendererToggleKeyLabel}> ${_rendererMode.name}${_activeModeOverlayHint()} <${widget.wolf3d.input.fpsToggleKeyLabel}> FPS ${_engine.showFpsCounter ? 'On' : 'Off'}', style: TextStyle( color: Colors.white.withValues(alpha: 0.5), ), @@ -142,6 +143,7 @@ class _GameScreenState extends State { case RendererMode.hardware: return WolfGlslRenderer( engine: _engine, + effectsEnabled: _glslEffectsEnabled, onKeyEvent: _handleRendererKeyEvent, onUnavailable: _onGlslUnavailable, ); @@ -164,10 +166,24 @@ class _GameScreenState extends State { } if (event.logicalKey == widget.wolf3d.input.asciiThemeCycleKey) { - setState(_cycleAsciiTheme); + if (_rendererMode == RendererMode.ascii) { + setState(_cycleAsciiTheme); + } else if (_rendererMode == RendererMode.hardware) { + setState(_toggleGlslEffects); + } } } + String _activeModeOverlayHint() { + if (_rendererMode == RendererMode.ascii) { + return ' <${widget.wolf3d.input.asciiThemeCycleKeyLabel}> ${_asciiTheme.name}'; + } + if (_rendererMode == RendererMode.hardware) { + return ' <${widget.wolf3d.input.asciiThemeCycleKeyLabel}> Effects ${_glslEffectsEnabled ? 'on' : 'off'}'; + } + return ''; + } + void _cycleRendererMode() { switch (_rendererMode) { case RendererMode.hardware: @@ -198,4 +214,8 @@ class _GameScreenState extends State { void _cycleAsciiTheme() { _asciiTheme = AsciiThemes.nextOf(_asciiTheme); } + + void _toggleGlslEffects() { + _glslEffectsEnabled = !_glslEffectsEnabled; + } } diff --git a/packages/wolf_3d_renderer/lib/wolf_3d_glsl_renderer.dart b/packages/wolf_3d_renderer/lib/wolf_3d_glsl_renderer.dart index 0f9b418..572ca6a 100644 --- a/packages/wolf_3d_renderer/lib/wolf_3d_glsl_renderer.dart +++ b/packages/wolf_3d_renderer/lib/wolf_3d_glsl_renderer.dart @@ -11,12 +11,16 @@ import 'package:wolf_3d_renderer/wolf_3d_asset_painter.dart'; /// Displays software-rendered frames through a GLSL post-processing pass. class WolfGlslRenderer extends BaseWolfRenderer { + /// Whether CRT-like post effects are enabled in the shader pass. + final bool effectsEnabled; + /// Callback when shader loading fails and software fallback should be used. final VoidCallback? onUnavailable; /// Creates a GLSL renderer bound to [engine]. const WolfGlslRenderer({ required super.engine, + this.effectsEnabled = false, super.onKeyEvent, this.onUnavailable, super.key, @@ -113,6 +117,8 @@ class _WolfGlslRendererState extends BaseWolfRendererState { painter: _GlslFramePainter( frame: _renderedFrame!, shader: _shader!, + effectsEnabled: widget.effectsEnabled, + elapsedSeconds: widget.engine.timeAliveMs / 1000.0, ), child: const SizedBox.expand(), ), @@ -152,10 +158,14 @@ class _WolfGlslRendererState extends BaseWolfRendererState { class _GlslFramePainter extends CustomPainter { final ui.Image frame; final ui.FragmentShader shader; + final bool effectsEnabled; + final double elapsedSeconds; _GlslFramePainter({ required this.frame, required this.shader, + required this.effectsEnabled, + required this.elapsedSeconds, }); @override @@ -167,6 +177,8 @@ class _GlslFramePainter extends CustomPainter { ..setFloat(1, size.height) ..setFloat(2, texelX) ..setFloat(3, texelY) + ..setFloat(4, effectsEnabled ? 1.0 : 0.0) + ..setFloat(5, elapsedSeconds) ..setImageSampler(0, frame); final Paint paint = Paint() @@ -178,6 +190,9 @@ class _GlslFramePainter extends CustomPainter { @override bool shouldRepaint(covariant _GlslFramePainter oldDelegate) { - return oldDelegate.frame != frame || oldDelegate.shader != shader; + return oldDelegate.frame != frame || + oldDelegate.shader != shader || + oldDelegate.effectsEnabled != effectsEnabled || + oldDelegate.elapsedSeconds != elapsedSeconds; } } diff --git a/packages/wolf_3d_renderer/shaders/wolf_world.frag b/packages/wolf_3d_renderer/shaders/wolf_world.frag index 8a23eab..faa0f2f 100644 --- a/packages/wolf_3d_renderer/shaders/wolf_world.frag +++ b/packages/wolf_3d_renderer/shaders/wolf_world.frag @@ -4,6 +4,10 @@ uniform vec2 uResolution; // One source-texel step in UV space: (1/width, 1/height). uniform vec2 uTexel; +// 1.0 enables CRT post-process effects, 0.0 keeps only base AA. +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; @@ -17,6 +21,30 @@ float luma(vec3 color) { void main() { // Convert fragment coordinates to normalized UV coordinates. 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; + + // Fill outside warped bounds with a subtle gray plastic TV 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 = abs(uv - clampedUv); + float overflow = max(edgeDelta.x, edgeDelta.y); + + float verticalShade = 0.96 + 0.06 * (1.0 - (FlutterFragCoord().y / uResolution.y)); + float depthShade = 1.0 - smoothstep(0.0, 0.06, overflow) * 0.18; + float grain = sin(FlutterFragCoord().x * 0.21 + FlutterFragCoord().y * 0.11) * 0.01; + + vec3 bezelColor = vec3(0.46, 0.46, 0.44) * verticalShade * depthShade + vec3(grain); + fragColor = vec4(bezelColor, 1.0); + return; + } + } + // Read the base color from the source frame. vec4 centerSample = texture(uTexture, uv); @@ -47,5 +75,24 @@ void main() { vec3 aaColor = mix(centerSample.rgb, neighborhoodAvg, edgeAmount * 0.45); // Preserve source alpha and output the anti-aliased color. - fragColor = vec4(aaColor, centerSample.a); + 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. + 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 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); + } + + fragColor = vec4(outColor, centerSample.a); }