From c8cd2cb144e76cc4a133f3df8859fe962b16d31d Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Thu, 19 Mar 2026 19:12:39 +0100 Subject: [PATCH] feat: Add GLSL renderer and implement FPS overlay across rendering backends Signed-off-by: Hans Kokx --- apps/wolf_3d_gui/lib/screens/game_screen.dart | 69 ++++++- .../lib/src/engine/wolf_3d_engine_base.dart | 14 ++ .../lib/src/rendering/ascii_renderer.dart | 7 + .../lib/src/rendering/renderer_backend.dart | 10 + .../lib/src/rendering/sixel_renderer.dart | 12 ++ .../lib/src/rendering/software_renderer.dart | 13 ++ .../lib/wolf_3d_glsl_renderer.dart | 182 ++++++++++++++++++ packages/wolf_3d_renderer/pubspec.yaml | 2 + .../wolf_3d_renderer/shaders/wolf_world.frag | 43 +++++ 9 files changed, 344 insertions(+), 8 deletions(-) create mode 100644 packages/wolf_3d_renderer/lib/wolf_3d_glsl_renderer.dart create mode 100644 packages/wolf_3d_renderer/shaders/wolf_world.frag diff --git a/apps/wolf_3d_gui/lib/screens/game_screen.dart b/apps/wolf_3d_gui/lib/screens/game_screen.dart index bdddef7..f29b807 100644 --- a/apps/wolf_3d_gui/lib/screens/game_screen.dart +++ b/apps/wolf_3d_gui/lib/screens/game_screen.dart @@ -7,6 +7,13 @@ import 'package:wolf_3d_dart/wolf_3d_engine.dart'; import 'package:wolf_3d_flutter/wolf_3d_flutter.dart'; import 'package:wolf_3d_renderer/wolf_3d_ascii_renderer.dart'; import 'package:wolf_3d_renderer/wolf_3d_flutter_renderer.dart'; +import 'package:wolf_3d_renderer/wolf_3d_glsl_renderer.dart'; + +enum _RendererMode { + software, + ascii, + glsl, +} /// Launches a [WolfEngine] via [Wolf3d] and exposes renderer/input integrations. class GameScreen extends StatefulWidget { @@ -25,7 +32,7 @@ class GameScreen extends StatefulWidget { class _GameScreenState extends State { late final WolfEngine _engine; - bool _useAsciiMode = false; + _RendererMode _rendererMode = _RendererMode.software; @override void initState() { @@ -60,11 +67,7 @@ class _GameScreenState extends State { onPointerHover: widget.wolf3d.input.onPointerMove, child: Stack( children: [ - // Keep both renderers behind the same engine so mode switching does - // not reset level state or audio playback. - _useAsciiMode - ? WolfAsciiRenderer(engine: _engine) - : WolfFlutterRenderer(engine: _engine), + _buildRenderer(), if (!_engine.isInitialized) Container( @@ -93,7 +96,7 @@ class _GameScreenState extends State { onKeyEvent: (node, event) { if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.tab) { - setState(() => _useAsciiMode = !_useAsciiMode); + setState(_cycleRendererMode); return KeyEventResult.handled; } return KeyEventResult.ignored; @@ -115,7 +118,7 @@ class _GameScreenState extends State { top: 16, right: 16, child: Text( - 'TAB: Swap Renderer', + 'TAB: ${_modeLabel(_rendererMode)}', style: TextStyle( color: Colors.white.withValues(alpha: 0.5), ), @@ -129,4 +132,54 @@ class _GameScreenState extends State { ), ); } + + Widget _buildRenderer() { + // Keep all renderers behind the same engine so mode switching does not + // reset level state or audio playback. + switch (_rendererMode) { + case _RendererMode.software: + return WolfFlutterRenderer(engine: _engine); + case _RendererMode.ascii: + return WolfAsciiRenderer(engine: _engine); + case _RendererMode.glsl: + return WolfGlslRenderer( + engine: _engine, + onUnavailable: _onGlslUnavailable, + ); + } + } + + void _cycleRendererMode() { + switch (_rendererMode) { + case _RendererMode.software: + _rendererMode = _RendererMode.ascii; + break; + case _RendererMode.ascii: + _rendererMode = _RendererMode.glsl; + break; + case _RendererMode.glsl: + _rendererMode = _RendererMode.software; + break; + } + } + + void _onGlslUnavailable() { + if (!mounted || _rendererMode != _RendererMode.glsl) { + return; + } + setState(() { + _rendererMode = _RendererMode.software; + }); + } + + String _modeLabel(_RendererMode mode) { + switch (mode) { + case _RendererMode.software: + return 'Software'; + case _RendererMode.ascii: + return 'ASCII'; + case _RendererMode.glsl: + return 'GLSL'; + } + } } diff --git a/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart b/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart index 17023b1..90353e5 100644 --- a/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart +++ b/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart @@ -74,6 +74,12 @@ class WolfEngine { /// Elapsed engine lifetime in milliseconds. int get timeAliveMs => _timeAliveMs; + /// Exponential moving average of rendered frames per second. + double _smoothedFps = 0.0; + + /// Current smoothed FPS, suitable for lightweight on-screen diagnostics. + double get fps => _smoothedFps; + /// The episode index where the game session begins. final int? startingEpisode; @@ -203,6 +209,14 @@ class WolfEngine { // Trust the incoming delta time natively _timeAliveMs += delta.inMilliseconds; + if (delta.inMicroseconds > 0) { + final double instantaneousFps = 1000000.0 / delta.inMicroseconds; + if (_smoothedFps <= 0.0) { + _smoothedFps = instantaneousFps; + } else { + _smoothedFps = (_smoothedFps * 0.88) + (instantaneousFps * 0.12); + } + } // 1. Process User Input input.update(); diff --git a/packages/wolf_3d_dart/lib/src/rendering/ascii_renderer.dart b/packages/wolf_3d_dart/lib/src/rendering/ascii_renderer.dart index 6fbc6d5..27fcb8a 100644 --- a/packages/wolf_3d_dart/lib/src/rendering/ascii_renderer.dart +++ b/packages/wolf_3d_dart/lib/src/rendering/ascii_renderer.dart @@ -351,6 +351,13 @@ class AsciiRenderer extends CliRendererBackend { } } + @override + void drawFpsOverlay(WolfEngine engine) { + const int textColor = 0xFFFFFFFF; + const int bgColor = 0xFF000000; + _writeString(1, 0, ' ${fpsLabel(engine)} ', textColor, bgColor); + } + @override void drawMenu(WolfEngine engine) { final int bgColor = _rgbToPaletteColor(engine.menuBackgroundRgb); diff --git a/packages/wolf_3d_dart/lib/src/rendering/renderer_backend.dart b/packages/wolf_3d_dart/lib/src/rendering/renderer_backend.dart index 654fcf8..95a3ae5 100644 --- a/packages/wolf_3d_dart/lib/src/rendering/renderer_backend.dart +++ b/packages/wolf_3d_dart/lib/src/rendering/renderer_backend.dart @@ -79,6 +79,7 @@ abstract class RendererBackend if (engine.difficulty == null) { drawMenu(engine); + drawFpsOverlay(engine); return finalizeFrame(); } @@ -88,6 +89,7 @@ abstract class RendererBackend // 3. Draw 2D overlays. drawWeapon(engine); drawHud(engine); + drawFpsOverlay(engine); // 4. Finalize and return the frame data (Buffer or String/List). return finalizeFrame(); @@ -139,6 +141,14 @@ abstract class RendererBackend /// Default implementation is a no-op for backends that don't support menus. void drawMenu(WolfEngine engine) {} + /// Draws a small FPS diagnostic in the top-left corner. + /// + /// Backends can override this to render text in their native format. + void drawFpsOverlay(WolfEngine engine) {} + + /// Returns a compact FPS label used by renderer-specific overlays. + String fpsLabel(WolfEngine engine) => 'FPS ${engine.fps.round()}'; + /// Plots a VGA image into this backend's HUD coordinate space. /// /// Coordinates are in the original 320x200 HUD space. Backends that support diff --git a/packages/wolf_3d_dart/lib/src/rendering/sixel_renderer.dart b/packages/wolf_3d_dart/lib/src/rendering/sixel_renderer.dart index 213e056..c83cee1 100644 --- a/packages/wolf_3d_dart/lib/src/rendering/sixel_renderer.dart +++ b/packages/wolf_3d_dart/lib/src/rendering/sixel_renderer.dart @@ -333,6 +333,18 @@ class SixelRenderer extends CliRendererBackend { drawStandardVgaHud(engine); } + @override + void drawFpsOverlay(WolfEngine engine) { + const int panelColor = 0; + const int textColor = 15; + final String label = fpsLabel(engine); + final int textWidth = WolfMenuFont.measureTextWidth(label, 1); + final int panelWidth = (textWidth + 8).clamp(16, 96); + + _fillRect320(2, 2, panelWidth, 10, panelColor); + _drawMenuText(label, 4, 3, textColor, scale: 1); + } + @override /// Blits a VGA image into the Sixel index buffer HUD space (320x200). void blitHudVgaImage(VgaImage image, int startX320, int startY200) { diff --git a/packages/wolf_3d_dart/lib/src/rendering/software_renderer.dart b/packages/wolf_3d_dart/lib/src/rendering/software_renderer.dart index d332993..5d601e5 100644 --- a/packages/wolf_3d_dart/lib/src/rendering/software_renderer.dart +++ b/packages/wolf_3d_dart/lib/src/rendering/software_renderer.dart @@ -121,6 +121,19 @@ class SoftwareRenderer extends RendererBackend { drawStandardVgaHud(engine); } + @override + void drawFpsOverlay(WolfEngine engine) { + const int panelX = 2; + const int panelY = 2; + const int panelW = 72; + const int panelH = 10; + final int panelColor = ColorPalette.vga32Bit[0]; + final int textColor = ColorPalette.vga32Bit[15]; + + _fillMenuPanel(panelX, panelY, panelW, panelH, panelColor); + _drawMenuText(fpsLabel(engine), panelX + 3, panelY + 1, textColor); + } + @override /// Blits a VGA image into the software framebuffer HUD space (320x200). void blitHudVgaImage(VgaImage image, int startX320, int startY200) { diff --git a/packages/wolf_3d_renderer/lib/wolf_3d_glsl_renderer.dart b/packages/wolf_3d_renderer/lib/wolf_3d_glsl_renderer.dart new file mode 100644 index 0000000..3ee865a --- /dev/null +++ b/packages/wolf_3d_renderer/lib/wolf_3d_glsl_renderer.dart @@ -0,0 +1,182 @@ +/// Flutter widget that applies a GLSL post-process over Wolf3D frames. +library; + +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; +import 'package:wolf_3d_dart/wolf_3d_renderer.dart'; +import 'package:wolf_3d_renderer/base_renderer.dart'; +import 'package:wolf_3d_renderer/wolf_3d_asset_painter.dart'; + +/// Displays software-rendered frames through a GLSL post-processing pass. +class WolfGlslRenderer extends BaseWolfRenderer { + /// 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.onUnavailable, + super.key, + }); + + @override + State createState() => _WolfGlslRendererState(); +} + +class _WolfGlslRendererState extends BaseWolfRendererState { + static const int _renderWidth = 320; + static const int _renderHeight = 200; + + final SoftwareRenderer _renderer = SoftwareRenderer(); + + ui.Image? _renderedFrame; + ui.FragmentProgram? _shaderProgram; + ui.FragmentShader? _shader; + bool _isRendering = false; + bool _isShaderUnavailable = false; + + @override + void initState() { + super.initState(); + if (widget.engine.frameBuffer.width != _renderWidth || + widget.engine.frameBuffer.height != _renderHeight) { + widget.engine.setFrameBuffer(_renderWidth, _renderHeight); + } + _loadShader(); + } + + @override + Color get scaffoldColor => widget.engine.difficulty == null + ? _colorFromRgb(widget.engine.menuBackgroundRgb) + : const Color.fromARGB(255, 4, 64, 64); + + @override + void dispose() { + _renderedFrame?.dispose(); + super.dispose(); + } + + @override + void performRender() { + if (_isRendering) { + return; + } + _isRendering = true; + + final FrameBuffer frameBuffer = widget.engine.frameBuffer; + _renderer.render(widget.engine); + + ui.decodeImageFromPixels( + frameBuffer.pixels.buffer.asUint8List(), + frameBuffer.width, + frameBuffer.height, + ui.PixelFormat.rgba8888, + (ui.Image image) { + if (mounted) { + setState(() { + _renderedFrame?.dispose(); + _renderedFrame = image; + }); + } else { + image.dispose(); + } + _isRendering = false; + }, + ); + } + + @override + Widget buildViewport(BuildContext context) { + if (_renderedFrame == null) { + return const CircularProgressIndicator(color: Colors.white24); + } + + if (_isShaderUnavailable || _shader == null) { + // Keep frames visible even if GLSL initialization failed. + return Padding( + padding: const EdgeInsets.all(16.0), + child: AspectRatio( + aspectRatio: 4 / 3, + child: WolfAssetPainter.frame(_renderedFrame), + ), + ); + } + + return Padding( + padding: const EdgeInsets.all(16.0), + child: AspectRatio( + aspectRatio: 4 / 3, + child: CustomPaint( + painter: _GlslFramePainter( + frame: _renderedFrame!, + shader: _shader!, + ), + child: const SizedBox.expand(), + ), + ), + ); + } + + Future _loadShader() async { + try { + final ui.FragmentProgram program = await ui.FragmentProgram.fromAsset( + 'packages/wolf_3d_renderer/shaders/wolf_world.frag', + ); + if (!mounted) { + return; + } + setState(() { + _shaderProgram = program; + _shader = _shaderProgram!.fragmentShader(); + _isShaderUnavailable = false; + }); + } catch (_) { + if (!mounted) { + return; + } + setState(() { + _isShaderUnavailable = true; + }); + widget.onUnavailable?.call(); + } + } + + Color _colorFromRgb(int rgb) { + return Color(0xFF000000 | (rgb & 0x00FFFFFF)); + } +} + +class _GlslFramePainter extends CustomPainter { + final ui.Image frame; + final ui.FragmentShader shader; + + _GlslFramePainter({ + required this.frame, + required this.shader, + }); + + @override + void paint(Canvas canvas, Size size) { + final double texelX = frame.width > 0 ? 1.0 / frame.width : 1.0; + final double texelY = frame.height > 0 ? 1.0 / frame.height : 1.0; + shader + ..setFloat(0, size.width) + ..setFloat(1, size.height) + ..setFloat(2, texelX) + ..setFloat(3, texelY) + ..setImageSampler(0, frame); + + final Paint paint = Paint() + ..shader = shader + ..filterQuality = FilterQuality.none; + + canvas.drawRect(Offset.zero & size, paint); + } + + @override + bool shouldRepaint(covariant _GlslFramePainter oldDelegate) { + return oldDelegate.frame != frame || oldDelegate.shader != shader; + } +} diff --git a/packages/wolf_3d_renderer/pubspec.yaml b/packages/wolf_3d_renderer/pubspec.yaml index 0fd2e9e..5a14fb3 100644 --- a/packages/wolf_3d_renderer/pubspec.yaml +++ b/packages/wolf_3d_renderer/pubspec.yaml @@ -25,6 +25,8 @@ dev_dependencies: # The following section is specific to Flutter packages. flutter: + shaders: + - shaders/wolf_world.frag # To add assets to your package, add an assets section, like this: # assets: diff --git a/packages/wolf_3d_renderer/shaders/wolf_world.frag b/packages/wolf_3d_renderer/shaders/wolf_world.frag new file mode 100644 index 0000000..84c1f64 --- /dev/null +++ b/packages/wolf_3d_renderer/shaders/wolf_world.frag @@ -0,0 +1,43 @@ +#include + +uniform vec2 uResolution; +uniform vec2 uTexel; +uniform sampler2D uTexture; + +out vec4 fragColor; + +float luma(vec3 color) { + return dot(color, vec3(0.299, 0.587, 0.114)); +} + +void main() { + vec2 uv = FlutterFragCoord().xy / uResolution; + vec4 centerSample = texture(uTexture, uv); + + 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; + + 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 edgeSpan = max(lumaMax - lumaMin, 0.0001); + float edgeAmount = smoothstep(0.03, 0.18, edgeSpan); + vec3 neighborhoodAvg = (sampleN + sampleS + sampleE + sampleW) * 0.25; + vec3 aaColor = mix(centerSample.rgb, neighborhoodAvg, edgeAmount * 0.45); + + vec2 centered = uv - 0.5; + float vignette = 1.0 - dot(centered, centered) * 0.35; + vignette = clamp(vignette, 0.75, 1.0); + + vec3 color = aaColor * vignette; + fragColor = vec4(color, centerSample.a); +}