refactor: Moved renderer package into Flutter package

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-23 16:12:03 +01:00
parent c4c8e4149a
commit dcfb2e8e02
20 changed files with 14 additions and 165 deletions
@@ -0,0 +1,187 @@
/// Shared Flutter renderer shell for driving the Wolf3D engine from a widget tree.
library;
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
typedef PresentFrameAction = void Function(VoidCallback onComplete);
/// Base widget for renderers that present frames from a [WolfEngine].
abstract class BaseWolfRenderer extends StatefulWidget {
/// Engine instance that owns world state and the shared framebuffer.
final WolfEngine engine;
/// Optional key handler invoked by the focused renderer shell.
final void Function(KeyEvent event)? onKeyEvent;
/// Creates a renderer bound to [engine].
const BaseWolfRenderer({
required this.engine,
this.onKeyEvent,
super.key,
});
}
/// Base [State] implementation that provides a ticker-driven render loop.
abstract class BaseWolfRendererState<T extends BaseWolfRenderer>
extends State<T>
with SingleTickerProviderStateMixin {
/// Per-frame ticker used to advance the engine and request renders.
late final Ticker gameLoop;
/// Focus node used by the enclosing [KeyboardListener].
final FocusNode focusNode = FocusNode();
Duration _lastTick = Duration.zero;
bool _isPresenting = false;
PresentFrameAction? _queuedPresentAction;
int _tickWindowCount = 0;
int _presentWindowCount = 0;
int _coalescedPresentWindowCount = 0;
int _tickWindowMicrosTotal = 0;
int _renderWindowMicrosTotal = 0;
int _presentWindowMicrosTotal = 0;
static const int _perfLogWindowFrames = 180;
@override
void initState() {
super.initState();
gameLoop = createTicker(_tick)..start();
focusNode.requestFocus();
}
void _tick(Duration elapsed) {
if (!widget.engine.isInitialized) return;
if (_lastTick == Duration.zero) {
_lastTick = elapsed;
return;
}
final Duration delta = elapsed - _lastTick;
_lastTick = elapsed;
final Stopwatch tickStopwatch = Stopwatch()..start();
widget.engine.tick(delta);
tickStopwatch.stop();
_tickWindowMicrosTotal += tickStopwatch.elapsedMicroseconds;
_tickWindowCount++;
performRender();
_maybeLogPerfWindow();
}
@override
void dispose() {
gameLoop.dispose();
focusNode.dispose();
super.dispose();
}
/// Renders the latest engine state into the concrete renderer's output type.
void performRender();
/// Schedules presentation work while coalescing to the latest requested frame.
@protected
void scheduleLatestPresent(PresentFrameAction action) {
if (_isPresenting) {
_queuedPresentAction = action;
_coalescedPresentWindowCount++;
return;
}
_runPresentAction(action);
}
/// Records CPU-side rendering stage duration in microseconds.
@protected
void recordRenderStageMicros(int microseconds) {
_renderWindowMicrosTotal += microseconds;
}
void _runPresentAction(PresentFrameAction action) {
_isPresenting = true;
final Stopwatch presentStopwatch = Stopwatch()..start();
bool isCompleted = false;
void onComplete() {
if (isCompleted) {
return;
}
isCompleted = true;
presentStopwatch.stop();
_presentWindowMicrosTotal += presentStopwatch.elapsedMicroseconds;
_presentWindowCount++;
_isPresenting = false;
if (!mounted) {
return;
}
final PresentFrameAction? nextAction = _queuedPresentAction;
_queuedPresentAction = null;
if (nextAction != null) {
scheduleMicrotask(() => _runPresentAction(nextAction));
}
}
action(onComplete);
}
void _maybeLogPerfWindow() {
if (!kDebugMode || _tickWindowCount < _perfLogWindowFrames) {
return;
}
final double avgTickMs = _tickWindowMicrosTotal / _tickWindowCount / 1000.0;
final double avgRenderMs =
_renderWindowMicrosTotal / _tickWindowCount / 1000.0;
final double avgPresentMs = _presentWindowCount > 0
? _presentWindowMicrosTotal / _presentWindowCount / 1000.0
: 0.0;
debugPrint(
'Renderer perf window ($_tickWindowCount frames): '
'avg tick ${avgTickMs.toStringAsFixed(2)}ms, '
'avg render ${avgRenderMs.toStringAsFixed(2)}ms, '
'avg present ${avgPresentMs.toStringAsFixed(2)}ms, '
'coalesced $_coalescedPresentWindowCount',
);
_tickWindowCount = 0;
_presentWindowCount = 0;
_coalescedPresentWindowCount = 0;
_tickWindowMicrosTotal = 0;
_renderWindowMicrosTotal = 0;
_presentWindowMicrosTotal = 0;
}
/// Builds the visible viewport widget for the latest rendered frame.
Widget buildViewport(BuildContext context);
/// Background color used by the surrounding scaffold.
Color get scaffoldColor;
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: scaffoldColor,
body: KeyboardListener(
focusNode: focusNode,
autofocus: true,
onKeyEvent: (event) {
widget.onKeyEvent?.call(event);
},
child: Center(
child: buildViewport(context),
),
),
);
}
}
@@ -0,0 +1,147 @@
/// Flutter widget that renders Wolf3D frames using the ASCII renderer.
library;
import 'package:flutter/material.dart';
import 'package:wolf_3d_dart/wolf_3d_renderer.dart';
import 'package:wolf_3d_flutter/renderer/base_renderer.dart';
/// Displays the game using a text-mode approximation of the original renderer.
class WolfAsciiRenderer extends BaseWolfRenderer {
final AsciiTheme theme;
/// Creates an ASCII renderer bound to [engine].
const WolfAsciiRenderer({
required super.engine,
this.theme = AsciiThemes.blocks,
super.onKeyEvent,
super.key,
});
@override
State<WolfAsciiRenderer> createState() => _WolfAsciiRendererState();
}
class _WolfAsciiRendererState extends BaseWolfRendererState<WolfAsciiRenderer> {
static const int _renderWidth = 160;
static const int _renderHeight = 100;
List<List<ColoredChar>> _asciiFrame = [];
final AsciiRenderer _asciiRenderer = AsciiRenderer(
mode: AsciiRendererMode.terminalGrid,
);
@override
void initState() {
super.initState();
_asciiRenderer.activeTheme = widget.theme;
// ASCII output uses a reduced logical framebuffer because glyph rendering
// expands the final view significantly once laid out in Flutter text.
if (widget.engine.frameBuffer.width != _renderWidth ||
widget.engine.frameBuffer.height != _renderHeight) {
widget.engine.setFrameBuffer(_renderWidth, _renderHeight);
}
}
@override
void didUpdateWidget(covariant WolfAsciiRenderer oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.theme != widget.theme) {
_asciiRenderer.activeTheme = widget.theme;
}
}
@override
Color get scaffoldColor => Colors.black;
@override
void performRender() {
setState(() {
_asciiFrame = _asciiRenderer.render(widget.engine);
});
}
@override
Widget buildViewport(BuildContext context) {
return _asciiFrame.isEmpty
? const SizedBox.shrink()
: AsciiFrameWidget(frameData: _asciiFrame);
}
}
/// Paints a pre-rasterized ASCII frame using grouped text spans per color run.
class AsciiFrameWidget extends StatelessWidget {
/// Two-dimensional text grid generated by [AsciiRenderer.render].
final List<List<ColoredChar>> frameData;
/// Creates a widget that displays [frameData].
const AsciiFrameWidget({super.key, required this.frameData});
@override
Widget build(BuildContext context) {
return AspectRatio(
aspectRatio: 4 / 3,
child: FittedBox(
fit: BoxFit.fill,
child: Container(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: frameData.map((row) {
List<TextSpan> optimizedSpans = [];
if (row.isNotEmpty) {
// Merge adjacent cells with the same color to keep the rich
// text tree smaller and reduce per-frame layout overhead.
Color currentColor = Color(row[0].argb);
Color? currentBackground = row[0].backgroundArgb == null
? null
: Color(row[0].backgroundArgb!);
StringBuffer currentSegment = StringBuffer(row[0].char);
for (int i = 1; i < row.length; i++) {
final Color nextColor = Color(row[i].argb);
final Color? nextBackground = row[i].backgroundArgb == null
? null
: Color(row[i].backgroundArgb!);
if (nextColor == currentColor &&
nextBackground == currentBackground) {
currentSegment.write(row[i].char);
} else {
optimizedSpans.add(
TextSpan(
text: currentSegment.toString(),
style: TextStyle(
color: currentColor,
backgroundColor: currentBackground,
),
),
);
currentColor = nextColor;
currentBackground = nextBackground;
currentSegment = StringBuffer(row[i].char);
}
}
optimizedSpans.add(
TextSpan(
text: currentSegment.toString(),
style: TextStyle(
color: currentColor,
backgroundColor: currentBackground,
),
),
);
}
return RichText(
text: TextSpan(
style: const TextStyle(fontFamily: 'monospace', height: 1.0),
children: optimizedSpans,
),
);
}).toList(),
),
),
),
);
}
}
@@ -0,0 +1,222 @@
import 'dart:async';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
/// A unified widget to display and cache Wolf3D assets.
class WolfAssetPainter extends StatefulWidget {
/// Decoded sprite source, when painting a sprite asset.
final Sprite? sprite;
/// Decoded VGA image source, when painting a VGA asset.
final VgaImage? vgaImage;
/// Pre-rendered game frame, when painting live gameplay output.
final ui.Image? frame;
/// Creates a painter for a palette-indexed [Sprite].
const WolfAssetPainter.sprite(this.sprite, {super.key})
: vgaImage = null,
frame = null;
/// Creates a painter for a planar VGA image.
const WolfAssetPainter.vga(this.vgaImage, {super.key})
: sprite = null,
frame = null;
/// Creates a painter for an already decoded [ui.Image] frame.
const WolfAssetPainter.frame(this.frame, {super.key})
: sprite = null,
vgaImage = null;
@override
State<WolfAssetPainter> createState() => _WolfAssetPainterState();
}
class _WolfAssetPainterState extends State<WolfAssetPainter> {
ui.Image? _cachedImage;
// Only images created inside this widget should be disposed here. Frames
// handed in from elsewhere remain owned by their producer.
bool _ownsImage = false;
@override
void initState() {
super.initState();
_prepareImage();
}
@override
void didUpdateWidget(covariant WolfAssetPainter oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.sprite != oldWidget.sprite ||
widget.vgaImage != oldWidget.vgaImage ||
widget.frame != oldWidget.frame) {
_prepareImage();
}
}
@override
void dispose() {
if (_ownsImage) {
_cachedImage?.dispose();
}
super.dispose();
}
Future<void> _prepareImage() async {
// Dispose previously generated images before creating a replacement so the
// widget does not retain stale native image allocations.
if (_ownsImage && _cachedImage != null) {
_cachedImage!.dispose();
_cachedImage = null;
}
// Pre-decoded frames can be used as-is and stay owned by the caller.
if (widget.frame != null) {
_ownsImage = false;
if (mounted) {
setState(() => _cachedImage = widget.frame);
}
return;
}
ui.Image? newImage;
if (widget.sprite != null) {
newImage = await _buildSpriteImage(widget.sprite!);
} else if (widget.vgaImage != null) {
newImage = await _buildVgaImage(widget.vgaImage!);
}
if (mounted) {
setState(() {
_ownsImage = true;
_cachedImage = newImage;
});
} else {
// If the widget was unmounted while work completed, dispose the image
// immediately to avoid leaking native resources.
newImage?.dispose();
}
}
/// Converts a sprite's indexed palette data into a Flutter [ui.Image].
Future<ui.Image> _buildSpriteImage(Sprite sprite) {
final completer = Completer<ui.Image>();
final pixels = Uint8List(64 * 64 * 4); // 4 bytes per pixel (RGBA)
for (int x = 0; x < 64; x++) {
for (int y = 0; y < 64; y++) {
int colorByte = sprite.pixels[x * 64 + y];
int offset = (y * 64 + x) * 4; // Row-major layout for ui.decodeImage
if (colorByte != 255) {
// 255 is transparency
int abgr = ColorPalette.vga32Bit[colorByte];
pixels[offset] = abgr & 0xFF; // R
pixels[offset + 1] = (abgr >> 8) & 0xFF; // G
pixels[offset + 2] = (abgr >> 16) & 0xFF; // B
pixels[offset + 3] = (abgr >> 24) & 0xFF; // A
} else {
pixels[offset + 3] = 0; // Alpha 0
}
}
}
ui.decodeImageFromPixels(
pixels,
64,
64,
ui.PixelFormat.rgba8888,
(img) => completer.complete(img),
);
return completer.future;
}
/// Converts a planar VGA image into a row-major Flutter [ui.Image].
Future<ui.Image> _buildVgaImage(VgaImage image) {
final completer = Completer<ui.Image>();
final pixels = Uint8List(image.width * image.height * 4);
int planeWidth = image.width ~/ 4;
int planeSize = planeWidth * image.height;
for (int y = 0; y < image.height; y++) {
for (int x = 0; x < image.width; x++) {
int plane = x % 4;
int sx = x ~/ 4;
int colorByte =
image.pixels[(plane * planeSize) + (y * planeWidth) + sx];
int offset = (y * image.width + x) * 4;
if (colorByte != 255) {
int abgr = ColorPalette.vga32Bit[colorByte];
pixels[offset] = abgr & 0xFF; // R
pixels[offset + 1] = (abgr >> 8) & 0xFF; // G
pixels[offset + 2] = (abgr >> 16) & 0xFF; // B
pixels[offset + 3] = (abgr >> 24) & 0xFF; // A
} else {
pixels[offset + 3] = 0; // Alpha 0
}
}
}
ui.decodeImageFromPixels(
pixels,
image.width,
image.height,
ui.PixelFormat.rgba8888,
(img) => completer.complete(img),
);
return completer.future;
}
@override
Widget build(BuildContext context) {
if (_cachedImage == null) {
return const Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
color: Colors.white24,
strokeWidth: 2,
),
),
);
}
return CustomPaint(
painter: _ImagePainter(_cachedImage!),
child: const SizedBox.expand(),
);
}
}
class _ImagePainter extends CustomPainter {
/// Image already decoded into Flutter's native image representation.
final ui.Image image;
_ImagePainter(this.image);
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..filterQuality = FilterQuality.none;
final srcRect = Rect.fromLTWH(
0,
0,
image.width.toDouble(),
image.height.toDouble(),
);
final dstRect = Rect.fromLTWH(0, 0, size.width, size.height);
// Draw the entire cached image in one pass
canvas.drawImageRect(image, srcRect, dstRect, paint);
}
@override
bool shouldRepaint(covariant _ImagePainter oldDelegate) =>
oldDelegate.image != image;
}
@@ -0,0 +1,99 @@
/// Flutter widget that renders Wolf3D frames as native pixel images.
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_flutter/renderer/base_renderer.dart';
import 'package:wolf_3d_flutter/renderer/wolf_3d_asset_painter.dart';
/// Presents the software renderer output by decoding the shared framebuffer.
class WolfFlutterRenderer extends BaseWolfRenderer {
/// Creates a pixel renderer bound to [engine].
const WolfFlutterRenderer({
required super.engine,
super.onKeyEvent,
super.key,
});
@override
State<WolfFlutterRenderer> createState() => _WolfFlutterRendererState();
}
class _WolfFlutterRendererState
extends BaseWolfRendererState<WolfFlutterRenderer> {
static const int _renderWidth = 640;
static const int _renderHeight = 400;
final SoftwareRenderer _renderer = SoftwareRenderer();
ui.Image? _renderedFrame;
@override
void initState() {
super.initState();
// Render at 2x canonical resolution so menu/UI edges stay crisp and avoid
// bleed when the output is scaled to the host viewport.
if (widget.engine.frameBuffer.width != _renderWidth ||
widget.engine.frameBuffer.height != _renderHeight) {
widget.engine.setFrameBuffer(_renderWidth, _renderHeight);
}
}
@override
Color get scaffoldColor => Colors.black;
@override
void dispose() {
_renderedFrame?.dispose();
super.dispose();
}
@override
void performRender() {
scheduleLatestPresent((onComplete) {
final FrameBuffer frameBuffer = widget.engine.frameBuffer;
final Stopwatch renderStopwatch = Stopwatch()..start();
_renderer.render(widget.engine);
renderStopwatch.stop();
recordRenderStageMicros(renderStopwatch.elapsedMicroseconds);
// Convert the engine-owned framebuffer into a GPU-friendly ui.Image on
// the Flutter side while preserving nearest-neighbor pixel fidelity.
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();
}
onComplete();
},
);
});
}
@override
Widget buildViewport(BuildContext context) {
// Delay painting until at least one decoded frame is available.
if (_renderedFrame == null) {
return const CircularProgressIndicator(color: Colors.white24);
}
return Padding(
padding: const EdgeInsets.all(16.0),
child: AspectRatio(
aspectRatio: 4 / 3,
child: WolfAssetPainter.frame(_renderedFrame),
),
);
}
}
@@ -0,0 +1,200 @@
/// 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_flutter/renderer/base_renderer.dart';
import 'package:wolf_3d_flutter/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;
/// Whether CRT phosphor bloom is enabled in the shader pass.
final bool bloomEnabled;
/// 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,
this.bloomEnabled = false,
super.onKeyEvent,
this.onUnavailable,
super.key,
});
@override
State<WolfGlslRenderer> createState() => _WolfGlslRendererState();
}
class _WolfGlslRendererState extends BaseWolfRendererState<WolfGlslRenderer> {
static const int _renderWidth = 960;
static const int _renderHeight = 600;
final SoftwareRenderer _renderer = SoftwareRenderer();
ui.Image? _renderedFrame;
ui.FragmentProgram? _shaderProgram;
ui.FragmentShader? _shader;
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 => Colors.black;
@override
void dispose() {
_renderedFrame?.dispose();
super.dispose();
}
@override
void performRender() {
scheduleLatestPresent((onComplete) {
final FrameBuffer frameBuffer = widget.engine.frameBuffer;
final Stopwatch renderStopwatch = Stopwatch()..start();
_renderer.render(widget.engine);
renderStopwatch.stop();
recordRenderStageMicros(renderStopwatch.elapsedMicroseconds);
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();
}
onComplete();
},
);
});
}
@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!,
effectsEnabled: widget.effectsEnabled,
bloomEnabled: widget.bloomEnabled,
elapsedSeconds: widget.engine.timeAliveMs / 1000.0,
),
child: const SizedBox.expand(),
),
),
);
}
Future<void> _loadShader() async {
try {
final ui.FragmentProgram program = await ui.FragmentProgram.fromAsset(
'packages/wolf_3d_flutter/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();
}
}
}
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,
});
@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)
..setFloat(4, effectsEnabled ? 1.0 : 0.0)
..setFloat(5, elapsedSeconds)
..setFloat(6, bloomEnabled ? 1.0 : 0.0)
..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 ||
oldDelegate.effectsEnabled != effectsEnabled ||
oldDelegate.bloomEnabled != bloomEnabled ||
oldDelegate.elapsedSeconds != elapsedSeconds;
}
}