From 98492c22cf27419fd4b6883b478ce08ab309083a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Magalh=C3=A3es?= <joamag@gmail.com> Date: Sun, 23 Oct 2022 09:38:44 +0100 Subject: [PATCH] feat: initial support for drawing to display --- examples/web/index.ts | 23 ++-- examples/web/react/app.tsx | 24 +++- .../web/react/components/display/display.tsx | 109 +++++++++++++++++- 3 files changed, 142 insertions(+), 14 deletions(-) diff --git a/examples/web/index.ts b/examples/web/index.ts index 86311492..0ba10610 100644 --- a/examples/web/index.ts +++ b/examples/web/index.ts @@ -1,4 +1,4 @@ -import { Emulator, startApp } from "./react/app"; +import { Emulator, PixelFormat, startApp } from "./react/app"; import { default as _wasm, GameBoy, PadKey, PpuMode } from "./lib/boytacean.js"; import info from "./package.json"; @@ -43,13 +43,6 @@ const KEYS: Record<string, number> = { const ROM_PATH = require("../../res/roms/20y.gb"); -// Enumeration that describes the multiple pixel -// formats and the associated size in bytes. -enum PixelFormat { - RGB = 3, - RGBA = 4 -} - /** * Top level class that controls the emulator behaviour * and "joins" all the elements together to bring input/output @@ -995,6 +988,20 @@ class GameboyEmulator implements Emulator { this.start({ engine: null }); } + /** + * Returns the array buffer that contains the complete set of + * pixel data that is going to be drawn. + * + * @returns The current pixel data for the emulator display. + */ + getImageBuffer(): Uint8Array { + return this.gameBoy!.frame_buffer_eager(); + } + + getPixelFormat(): PixelFormat { + return PixelFormat.RGB; + } + toggleWindow() { this.maximize(); } diff --git a/examples/web/react/app.tsx b/examples/web/react/app.tsx index 7cfc6986..09e6e417 100644 --- a/examples/web/react/app.tsx +++ b/examples/web/react/app.tsx @@ -9,6 +9,7 @@ import { ButtonIncrement, ButtonSwitch, Display, + DrawHandler, Footer, Info, Link, @@ -21,6 +22,11 @@ import { import "./app.css"; +/** + * Top level interface that declares the main abstract + * interface of an emulator structured entity. + * Should allow typical hardware operations to be performed. + */ export interface Emulator { getName(): string; getVersion(): string; @@ -29,6 +35,17 @@ export interface Emulator { pause(): void; resume(): void; reset(): void; + getImageBuffer(): Uint8Array; + getPixelFormat(): PixelFormat; +} + +/** + * Enumeration that describes the multiple pixel + * formats and the associated size in bytes. + */ +export enum PixelFormat { + RGB = 3, + RGBA = 4 } type AppProps = { @@ -53,6 +70,11 @@ export const App: FC<AppProps> = ({ emulator, backgrounds = ["264653"] }) => { const onThemeClick = () => { setBackgroundIndex((backgroundIndex + 1) % backgrounds.length); }; + const onDrawHAndler = (handler: DrawHandler) => { + setTimeout(() => { + handler(emulator.getImageBuffer(), PixelFormat.RGB); + }, 3000); + }; useEffect(() => { document.body.style.backgroundColor = `#${getBackground()}`; }); @@ -67,7 +89,7 @@ export const App: FC<AppProps> = ({ emulator, backgrounds = ["264653"] }) => { <PanelSplit left={ <div> - <Display /> + <Display onDrawHandler={onDrawHAndler} /> </div> } > diff --git a/examples/web/react/components/display/display.tsx b/examples/web/react/components/display/display.tsx index 61c49515..180ec558 100644 --- a/examples/web/react/components/display/display.tsx +++ b/examples/web/react/components/display/display.tsx @@ -1,12 +1,21 @@ -import React, { FC, useState } from "react"; +import React, { FC, useRef, useEffect } from "react"; +import { PixelFormat } from "../../app"; import "./display.css"; declare const require: any; +/** + * Function that handles a draw operation into a + * certain drawing context. + */ +export type DrawHandler = (pixels: Uint8Array, format: PixelFormat) => void; + type DisplayOptions = { width: number; height: number; + logicWidth: number; + logicHeight: number; scale?: number; }; @@ -14,18 +23,53 @@ type DisplayProps = { options?: DisplayOptions; size?: string; style?: string[]; + onDrawHandler?: (caller: DrawHandler) => void; +}; + +type CanvasContents = { + canvasCtx: CanvasRenderingContext2D; + canvasBuffer: HTMLCanvasElement; + canvasBufferCtx: CanvasRenderingContext2D; + imageData: ImageData; + videoBuffer: DataView; }; export const Display: FC<DisplayProps> = ({ - options = {}, + options = { width: 320, height: 288, logicWidth: 160, logicHeight: 144 }, size = "small", - style = [] + style = [], + onDrawHandler }) => { - options = { ...options, ...{ width: 320, height: 288 } }; - const classes = () => ["display", size, ...style].join(" "); + options = { + ...options, + ...{ width: 320, height: 288, logicWidth: 160, logicHeight: 144 } + }; if (!options.scale) { options.scale = window.devicePixelRatio ? window.devicePixelRatio : 1; } + + let canvasContents: CanvasContents | null = null; + const classes = () => ["display", size, ...style].join(" "); + + const canvasRef = useRef<HTMLCanvasElement>(null); + + useEffect(() => { + if (canvasRef.current && !canvasContents) { + canvasContents = initCanvas( + options.logicWidth, + options.logicHeight, + canvasRef.current + ); + } + }); + + if (onDrawHandler) { + onDrawHandler((pixels: Uint8Array, format: PixelFormat) => { + if (!canvasContents) return; + updateCanvas(canvasContents, pixels, format); + }); + } + return ( <div id="display" className={classes()}> <span id="display-close" className="magnify-button canvas-close"> @@ -37,6 +81,7 @@ export const Display: FC<DisplayProps> = ({ </span> <div className="display-frame"> <canvas + ref={canvasRef} id="display-canvas" className="display-canvas" width={options.width * options.scale} @@ -47,4 +92,58 @@ export const Display: FC<DisplayProps> = ({ ); }; +const initCanvas = ( + width: number, + height: number, + canvas: HTMLCanvasElement +): CanvasContents => { + // initializes the off-screen canvas that is going to be + // used in the drawing process, this is used essentially for + // performance reasons as it provides a way to draw pixels + // in the original size instead of the target one + const canvasBuffer = document.createElement("canvas"); + canvasBuffer.width = width; + canvasBuffer.height = height; + const canvasBufferCtx = canvasBuffer.getContext("2d")!; + const imageData = canvasBufferCtx.createImageData( + canvasBuffer.width, + canvasBuffer.height + ); + const videoBuffer = new DataView(imageData.data.buffer); + + const canvasCtx = canvas.getContext("2d")!; + canvasCtx.scale( + canvas.width / canvasBuffer.width, + canvas.height / canvasBuffer.height + ); + canvasCtx.imageSmoothingEnabled = false; + + return { + canvasCtx: canvasCtx, + canvasBuffer: canvasBuffer, + canvasBufferCtx: canvasBufferCtx, + imageData: imageData, + videoBuffer: videoBuffer + }; +}; + +const updateCanvas = ( + canvasContents: CanvasContents, + pixels: Uint8Array, + format: PixelFormat = PixelFormat.RGB +) => { + let offset = 0; + for (let index = 0; index < pixels.length; index += format) { + const color = + (pixels[index] << 24) | + (pixels[index + 1] << 16) | + (pixels[index + 2] << 8) | + (format == PixelFormat.RGBA ? pixels[index + 3] : 0xff); + canvasContents.videoBuffer.setUint32(offset, color); + offset += PixelFormat.RGBA; + } + canvasContents.canvasBufferCtx.putImageData(canvasContents.imageData, 0, 0); + canvasContents.canvasCtx.drawImage(canvasContents.canvasBuffer, 0, 0); +}; + export default Display; -- GitLab