diff --git a/README.md b/README.md index 146e8ba..81fd490 100644 --- a/README.md +++ b/README.md @@ -101,9 +101,9 @@ The full engine legitimately uses classic `for`, `while`, and `do...while` loops. Their compatibility flags are scoped to `examples/doom-gpl/goccia.json`; the bindings and smaller MIT examples do not enable them. The initial GocciaScript 0.10.0 implementation rendered at roughly 3 FPS on an Apple M1 -Max. Caching the framebuffer length and using the packed-palette display path -raised the profiled steady-state estimate to 4.71 FPS, so it is genuine but not -yet real-time. +Max. The packed-palette fallback reaches 4.97 FPS, and presenting the indexed +framebuffer through a palette-texture shader reaches 6.32 FPS in controlled +steady-state measurements. It is genuine but not yet real-time. ## Bunnymark performance diff --git a/benchmarks/application-performance.md b/benchmarks/application-performance.md index 0466833..779367d 100644 --- a/benchmarks/application-performance.md +++ b/benchmarks/application-performance.md @@ -45,7 +45,20 @@ workload, so the reproduced candidate reduces end-to-end execution time by about 32%. This includes startup and is separate from the profiler's 4.71 FPS steady-state estimate. -The deterministic frame-8 smoke screenshot has zero changed pixels below the -FPS overlay compared with the retained byte-wise conversion baseline. The -focused palette test also checks all 256 palette entries, with 1,024 byte-level -assertions in each of interpreter and bytecode modes. +The indexed-texture shader was measured in five paired runs against that packed +RGBA fallback, alternating path order. Each run discarded 12 warm-up frames and +timed the next 24 frames with raylib's monotonic clock, for 120 measured frames +per path. The normal FPS overlay and periodic garbage collections remained +enabled. + +| Display path | Mean frame time | Mean FPS | FPS range | Sample SD | +|---|---:|---:|---:|---:| +| packed RGBA fallback | 201.22 ms | 4.97 | 4.96-5.00 | 0.016 | +| indexed-texture shader | 158.33 ms | 6.32 | 6.28-6.35 | 0.028 | + +The shader reduces whole-frame time by 21.31% and raises steady-state +performance by 1.27x for this stationary early-E1M1 scene. The deterministic +frame-8 shader and fallback screenshots are byte-identical when only the live +FPS overlay is disabled. The focused palette test checks all 256 entries in all +14 palette slots with 28,672 assertions in each of interpreter and bytecode +modes. diff --git a/examples/doom-gpl/Makefile b/examples/doom-gpl/Makefile index 0e12a68..a5ac672 100644 --- a/examples/doom-gpl/Makefile +++ b/examples/doom-gpl/Makefile @@ -50,7 +50,7 @@ verify-source: fetch @test ! -e "$(UPSTREAM_DIR)/DOOM-SHAREWARE.WAD" @echo "Pure-JavaScript LinuxDoom source pin and GPL boundary verified." -$(GENERATED_STAMP): verify-source prepare-port.mjs src/i_input.ts src/i_sound.ts src/i_video.ts +$(GENERATED_STAMP): verify-source prepare-port.mjs src/i_input.ts src/i_sound.ts src/i_video.ts src/palette.ts node prepare-port.mjs "$(UPSTREAM_DIR)" "$(GENERATED_DIR)" touch "$@" diff --git a/examples/doom-gpl/README.md b/examples/doom-gpl/README.md index 113b5b4..8997791 100644 --- a/examples/doom-gpl/README.md +++ b/examples/doom-gpl/README.md @@ -88,10 +88,12 @@ make test-palette make smoke ``` -`make test-palette` checks the packed palette conversion against the original -byte-wise RGBA result in both interpreter and bytecode modes. The palette and -framebuffer use paired byte and 32-bit views, so the copied bytes remain RGBA -on either little- or big-endian hosts. +`make test-palette` checks all 256 palette indices across all 14 DOOM +`PLAYPAL` slots against the packed RGBA fallback in both interpreter and +bytecode modes. The palette and fallback framebuffer use paired byte and +32-bit views, so the copied bytes remain RGBA on either little- or big-endian +hosts. `make smoke` uses the indexed-texture shader when available and falls +back to the packed RGBA display path if shader setup fails. `make smoke` runs deterministic engine frames against cached Freedoom data and writes `.cache/doom-goccia-smoke.png`. The current upstream port claims vanilla diff --git a/examples/doom-gpl/src/i_video.ts b/examples/doom-gpl/src/i_video.ts index 5444622..da1f3e4 100644 --- a/examples/doom-gpl/src/i_video.ts +++ b/examples/doom-gpl/src/i_video.ts @@ -4,6 +4,7 @@ import { BLACK, BeginDrawing, BeginMode2D, + BeginShaderMode, Camera2D, ClearBackground, CloseWindow, @@ -11,19 +12,29 @@ import { DrawTexture, EndDrawing, EndMode2D, + EndShaderMode, GenImageColor, + GetShaderLocation, + ImageFormat, InitWindow, + IsShaderValid, + IsTextureValid, + LoadShaderFromMemory, LoadTextureFromImage, - RAYWHITE, + PIXELFORMAT_UNCOMPRESSED_GRAYSCALE, + PIXELFORMAT_UNCOMPRESSED_R8G8B8A8, + SetShaderValueTexture, SetExitKey, SetTargetFPS, SetTextureFilter, SetWindowTitle, TEXTURE_FILTER_POINT, UnloadImage, + UnloadShader, UnloadTexture, UpdateTexture, Vector2, + WHITE, closeRaylib, } from "../../../bindings/raylib.ts"; import { @@ -38,6 +49,33 @@ const rgba = new Uint8Array(sourceWidth * sourceHeight * 4); const rgba32 = new Uint32Array(rgba.buffer); const packedPaletteBytes = new Uint8Array(256 * 4); const packedPalette = new Uint32Array(packedPaletteBytes.buffer); +const forceRgbaFallback = + globalThis.DOOM_EXAMPLE_DISPLAY_PATH === "rgba"; +const showFps = globalThis.DOOM_EXAMPLE_SHOW_FPS !== false; +const vertexShaderSource = `#version 330 +in vec3 vertexPosition; +in vec2 vertexTexCoord; +in vec4 vertexColor; +out vec2 fragTexCoord; +out vec4 fragColor; +uniform mat4 mvp; +void main() { + fragTexCoord = vertexTexCoord; + fragColor = vertexColor; + gl_Position = mvp*vec4(vertexPosition, 1.0); +}`; +const fragmentShaderSource = `#version 330 +in vec2 fragTexCoord; +in vec4 fragColor; +out vec4 finalColor; +uniform sampler2D texture0; +uniform sampler2D paletteTexture; +uniform vec4 colDiffuse; +void main() { + float paletteIndex = floor(texture(texture0, fragTexCoord).r*255.0 + 0.5); + vec2 paletteCoord = vec2((paletteIndex + 0.5)/256.0, 0.5); + finalColor = texture(paletteTexture, paletteCoord)*colDiffuse*fragColor; +}`; const camera = Camera2D.create({ offset: Vector2.create({ x: 0, y: 0 }), target: Vector2.create({ x: 0, y: 0 }), @@ -49,8 +87,13 @@ let video = null; let playpal = null; let paletteIndex = 0; let packedPaletteIndex = -1; -let image = null; -let texture = null; +let uploadedPaletteIndex = -1; +let indexedTexture = null; +let paletteTexture = null; +let rgbaTexture = null; +let shader = null; +let paletteLocation = -1; +let indexedShaderActive = false; let initialized = false; const refreshPackedPalette = (): void => { @@ -59,6 +102,70 @@ const refreshPackedPalette = (): void => { packedPaletteIndex = paletteIndex; }; +const createTexture = ( + width: number, + height: number, + format: number, +) => { + const image = GenImageColor(width, height, BLACK); + if (format !== PIXELFORMAT_UNCOMPRESSED_R8G8B8A8) { + ImageFormat(image, format); + } + const created = LoadTextureFromImage(image); + UnloadImage(image); + SetTextureFilter(created, TEXTURE_FILTER_POINT); + return created; +}; + +const unloadTexture = (texture): void => { + if (texture !== null && IsTextureValid(texture)) UnloadTexture(texture); +}; + +const initializeRgbaFallback = (): void => { + rgbaTexture = createTexture( + sourceWidth, + sourceHeight, + PIXELFORMAT_UNCOMPRESSED_R8G8B8A8, + ); + indexedShaderActive = false; +}; + +const initializeIndexedShader = (): boolean => { + if (forceRgbaFallback) return false; + + shader = LoadShaderFromMemory(vertexShaderSource, fragmentShaderSource); + if (!IsShaderValid(shader)) return false; + + paletteLocation = GetShaderLocation(shader, "paletteTexture"); + if (paletteLocation < 0) return false; + + indexedTexture = createTexture( + sourceWidth, + sourceHeight, + PIXELFORMAT_UNCOMPRESSED_GRAYSCALE, + ); + paletteTexture = createTexture( + 256, + 1, + PIXELFORMAT_UNCOMPRESSED_R8G8B8A8, + ); + + if ( + !IsTextureValid(indexedTexture) || + !IsTextureValid(paletteTexture) + ) { + unloadTexture(indexedTexture); + unloadTexture(paletteTexture); + indexedTexture = null; + paletteTexture = null; + return false; + } + + uploadedPaletteIndex = -1; + indexedShaderActive = true; + return true; +}; + const I_InitGraphics = (): void => { if (initialized) return; InitWindow( @@ -68,17 +175,19 @@ const I_InitGraphics = (): void => { ); SetExitKey(0); SetTargetFPS(35); - image = GenImageColor(sourceWidth, sourceHeight, BLACK); - texture = LoadTextureFromImage(image); - UnloadImage(image); - image = null; - SetTextureFilter(texture, TEXTURE_FILTER_POINT); + if (!initializeIndexedShader()) initializeRgbaFallback(); + console.log( + `DOOM display path: ${ + indexedShaderActive ? "indexed texture shader" : "RGBA fallback" + }`, + ); initialized = true; }; const I_RegisterPlaypal = (paletteBytes: Uint8Array): void => { playpal = paletteBytes; packedPaletteIndex = -1; + uploadedPaletteIndex = -1; refreshPackedPalette(); }; @@ -89,22 +198,44 @@ const I_SetPalette = (nextPalette: number): void => { const I_FinishUpdate = (): void => { const indexed = video.screens[0]; - expandIndexedFrame(indexed, packedPalette, rgba32); - UpdateTexture(texture, rgba); + if (indexedShaderActive) { + UpdateTexture(indexedTexture, indexed); + if (uploadedPaletteIndex !== packedPaletteIndex) { + UpdateTexture(paletteTexture, packedPaletteBytes); + uploadedPaletteIndex = packedPaletteIndex; + } + } else { + expandIndexedFrame(indexed, packedPalette, rgba32); + UpdateTexture(rgbaTexture, rgba); + } + BeginDrawing(); ClearBackground(BLACK); BeginMode2D(camera); - DrawTexture(texture, 0, 0, RAYWHITE); + if (indexedShaderActive) { + BeginShaderMode(shader); + SetShaderValueTexture(shader, paletteLocation, paletteTexture); + DrawTexture(indexedTexture, 0, 0, WHITE); + EndShaderMode(); + } else { + DrawTexture(rgbaTexture, 0, 0, WHITE); + } EndMode2D(); - DrawFPS(8, 8); + if (showFps) DrawFPS(8, 8); EndDrawing(); }; const I_ShutdownGraphics = (): void => { if (!initialized) return; - UnloadTexture(texture); - texture = null; + unloadTexture(indexedTexture); + unloadTexture(paletteTexture); + unloadTexture(rgbaTexture); + if (shader !== null && IsShaderValid(shader)) UnloadShader(shader); + indexedTexture = null; + paletteTexture = null; + rgbaTexture = null; + shader = null; CloseWindow(); closeRaylib(); initialized = false; diff --git a/examples/doom-gpl/tests/palette.test.ts b/examples/doom-gpl/tests/palette.test.ts index dbf63e9..7e2849b 100644 --- a/examples/doom-gpl/tests/palette.test.ts +++ b/examples/doom-gpl/tests/palette.test.ts @@ -5,35 +5,59 @@ import { packPaletteBytes, } from "../src/palette.ts"; +const paletteCount = 14; +const colourCount = 256; + describe("DOOM palette conversion", () => { test("matches the byte-wise RGBA reference for every palette entry", () => { - const playpal = new Uint8Array(2 * 768); + const playpal = new Uint8Array(paletteCount * colourCount * 3); for (let index = 0; index < playpal.length; index += 1) { playpal[index] = (index * 37 + 11) % 256; } - const indexed = new Uint8Array(256); + const indexed = new Uint8Array(colourCount); for (let index = 0; index < indexed.length; index += 1) { indexed[index] = index; } - const packedPaletteBytes = new Uint8Array(256 * 4); + const packedPaletteBytes = new Uint8Array(colourCount * 4); const packedPalette = new Uint32Array(packedPaletteBytes.buffer); const rgba = new Uint8Array(indexed.length * 4); const rgba32 = new Uint32Array(rgba.buffer); - packPaletteBytes(playpal, 1, packedPaletteBytes); - expandIndexedFrame(indexed, packedPalette, rgba32); + for ( + let paletteIndex = 0; + paletteIndex < paletteCount; + paletteIndex += 1 + ) { + packPaletteBytes(playpal, paletteIndex, packedPaletteBytes); + expandIndexedFrame(indexed, packedPalette, rgba32); - const paletteOffset = 768; - const pixelCount = indexed.length; - for (let index = 0; index < pixelCount; index += 1) { - const colourOffset = paletteOffset + indexed[index] * 3; - const pixelOffset = index * 4; - expect(rgba[pixelOffset]).toBe(playpal[colourOffset]); - expect(rgba[pixelOffset + 1]).toBe(playpal[colourOffset + 1]); - expect(rgba[pixelOffset + 2]).toBe(playpal[colourOffset + 2]); - expect(rgba[pixelOffset + 3]).toBe(255); + const paletteOffset = paletteIndex * colourCount * 3; + for (let index = 0; index < colourCount; index += 1) { + const colourOffset = paletteOffset + indexed[index] * 3; + const pixelOffset = index * 4; + expect(packedPaletteBytes[pixelOffset]).toBe( + playpal[colourOffset], + ); + expect(packedPaletteBytes[pixelOffset + 1]).toBe( + playpal[colourOffset + 1], + ); + expect(packedPaletteBytes[pixelOffset + 2]).toBe( + playpal[colourOffset + 2], + ); + expect(packedPaletteBytes[pixelOffset + 3]).toBe(255); + expect(rgba[pixelOffset]).toBe(packedPaletteBytes[pixelOffset]); + expect(rgba[pixelOffset + 1]).toBe( + packedPaletteBytes[pixelOffset + 1], + ); + expect(rgba[pixelOffset + 2]).toBe( + packedPaletteBytes[pixelOffset + 2], + ); + expect(rgba[pixelOffset + 3]).toBe( + packedPaletteBytes[pixelOffset + 3], + ); + } } }); });