refactor: Moved renderer package into Flutter package
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
#include <flutter/runtime_effect.glsl>
|
||||
|
||||
// Output surface size in pixels.
|
||||
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;
|
||||
// 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.
|
||||
float luma(vec3 color) {
|
||||
return dot(color, vec3(0.299, 0.587, 0.114));
|
||||
}
|
||||
|
||||
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 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));
|
||||
|
||||
// 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);
|
||||
|
||||
// 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);
|
||||
|
||||
// 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));
|
||||
|
||||
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.
|
||||
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);
|
||||
// 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.
|
||||
vec3 neighborhoodAvg = (sampleN + sampleS + sampleE + sampleW) * 0.25;
|
||||
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.
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
|
||||
// Only bright pixels contribute — gate the bloom contribution on luma.
|
||||
float glowLuma = luma(glow);
|
||||
float bloomStrength = smoothstep(0.18, 0.82, glowLuma);
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
fragColor = vec4(outColor, centerSample.a);
|
||||
}
|
||||
Reference in New Issue
Block a user