Xenon 2

How it works · write-up

Plan: Reproduce the "flash tint" draw effect (REAL_DRAWPROC_3) in the GPU sprite-stream window

xenondoc/plan-flash-tint-effect.md · 13 KB · updated 2026-09-17

Context

The GPU-based RENDER_VIEW_SPRITE_STREAM debug window (built and completed in an earlier session) replays g_drawCommandStream against the packed sprite atlas to reconstruct what's on screen without any live CPU rasterization. It currently treats every captured draw the same way regardless of which of the 4 known drawProcs produced it.

One of those drawProcs, REAL_DRAWPROC_3 (0x00000e78, thunking to DrawFlashFrame_RevertToNormalDraw_FUN_00001594), is a one-shot "flash this object solid for a single frame" utility. It's installed as an object's drawProc from several unrelated call sites (the ship when firing, and a couple of enemy-list loops) and immediately reverts the object back to draw_ship_maybe_FUN_000010a2 (the normal blitter) after this one draw, so it always renders for exactly one frame at a time before reverting.

We confirmed via decompilation (comparing DrawFlashFrame_RevertToNormalDraw_FUN_00001594 byte-for-byte against the normal blitter draw_ship_maybe_FUN_000010a2) exactly how it differs: - The normal blitter reads two color longwords per row from the sprite and ORs the sprite's real per-pixel bits into planes 0/1 (after AND-clearing with the mask) — i.e. it draws the sprite's actual stored colors. - The flash blitter reads only one mask word per row. It forces planes 0/1/2 to 1 unconditionally wherever the mask says "opaque" (uVar10 = ~mask; *plane0 |= uVar10; plane1 |= uVar10; plane2 |= uVar10;), and leaves plane 3 untouched (plane3 = mask & plane3, i.e. just preserves whatever was already in the framebuffer there — never ORs a 1 into it).

So the flash effect draws the exact same silhouette/mask shape as the sprite's normal atlas entry (same geometry, same SPRITE_FORMAT_4PLANES_MASKED sprite, same atlas rect — nothing about sprite decoding changes), but replaces every opaque pixel's color with a fixed color instead of the sprite's real per-pixel colors. With planes 0/1/2 forced to 1 and plane 3 generally 0 (objects are almost always drawn over an already-cleared/black background), the resulting color index is consistently palette slot 7 in the vast majority of real cases (plane 3 = 1 giving slot 15 is possible but rare — background is essentially always black under a moving object).

This is currently a known, documented gap: drawCommandStream.c (lines ~230-234) explicitly notes "the solid-color flash tint itself isn't reproduced, only the sprite's normal colors." This plan closes that gap.

Design decisions

Per-vertex flag + a uniform for the color, not a per-vertex color. The sprite-stream renderer's hard constraint (from the earlier plan) is one draw call per frame — every sprite's quad lives in the same CPU-built vertex array, uploaded once, drawn with one SDL_DrawGPUPrimitives call. Within that single batch, whether a given quad should flash varies per-object, so that must be per-vertex data (no other mechanism exists to vary behavior across sprites in one draw call). But the flash color itself is one value shared by every flashing sprite in a given frame (see "Flash color" below), not something that varies per object — so it belongs in a uniform buffer, not per-vertex data. This keeps the vertex-side change to one extra float per vertex (a 0.0/1.0 flag) rather than a full per-vertex color.

A dedicated vertex format for the sprite-stream pipeline only, not a shared one. Vertex (sdlGpuRenderView.h) and CreateGraphicsPipeline (sdlGpuRenderView.c) are currently shared across all four window types (masked sprites, lowres VRAM, memory dump, sprite stream) — same vertex shader (vertex2.spv), same struct. My first instinct was to extend that shared struct/shader with the new field, but that's the wrong call: the sprite-stream window already has its own, separate vertex buffer (spriteStreamData.streamVertexBuffer, distinct from the other 3 types' shared view->vertexBuffer — confirmed via the existing bufferBindings[0].buffer = (type == RENDER_VIEW_SPRITE_STREAM) ? ... : ... branch in sdlGpuRenderView.c), so there's no actual buffer-sharing constraint forcing a shared vertex format. LoadShaders also already branches the fragment shader per RenderViewType (just not the vertex shader yet). So a new, sprite-stream-only SpriteStreamVertex{x,y,u,v,flash} struct + a small dedicated vertex shader is both less invasive (the other 3 window types and vertex2.glsl stay completely untouched — zero regression risk) and more consistent with how LoadShaders already works.

Implementation

1. Capture side — src/drawCommandStream.c / src/includes/drawCommandStream.h

  • DrawMaskedSpriteEntry (drawCommandStream.h): add bool isFlash;.
  • DrawCommandStream_PushMaskedSprite(...): add an isFlash parameter, stored straight into the new field (mirrors how every other field is threaded through today).
  • DrawCommandStream_OnInstructionFetch: it already computes knownDrawProc by comparing drawProc against REAL_DRAWPROC_1..4. Add bool isFlash = (drawProc == REAL_DRAWPROC_3); and pass it into the DrawCommandStream_PushMaskedSprite call at the bottom of the function (the one currently at line ~323).
  • Update the stale comment at lines ~230-234 (the one documenting this exact gap) to reflect that the flash tint is now reproduced.

2. New vertex format — src/includes/sdlGpuRenderView.h, src/sdlGpuRenderView.c

  • Add a new struct in sdlGpuRenderView.h, next to (not replacing) Vertex: c typedef struct SpriteStreamVertex { float x, y; // vec2 position (NDC) float u, v; // vec2 uv (normalized atlas coords) float flash; // 0.0 = normal draw, 1.0 = flash-tint draw (REAL_DRAWPROC_3) } SpriteStreamVertex; SPRITE_STREAM_MAX_VERTICES stays the same constant, just now sized against this new struct on the sprite-stream side.
  • LoadShaders (sdlGpuRenderView.c ~line 807): currently hardcodes vertexShaderFileName = "src/shaders/vertex2.spv" unconditionally. Change to branch like the fragment shader already does: RENDER_VIEW_SPRITE_STREAM → "src/shaders/vertex_sprite_stream.spv", else → "src/shaders/vertex2.spv" (unchanged for the other 3 types).
  • CreateGraphicsPipeline (sdlGpuRenderView.c ~line 933-980): change its signature to take RenderViewType type instead of a raw bool enableBlend (it needs to know the type for the vertex layout branch anyway, and this also centralizes the existing enableBlend = (type == RENDER_VIEW_SPRITE_STREAM) derivation in one place instead of at the call site). Branch the vertex_input_state construction:
  • RENDER_VIEW_SPRITE_STREAM: 3 attributes (position float2 @0, uv float2 @8, flash float @16), pitch = sizeof(SpriteStreamVertex) (20 bytes).
  • else: existing 2-attribute layout, pitch = sizeof(Vertex) (16 bytes) — byte-for-byte unchanged from today.
  • The static full-screen-triangle vertices[] array (sdlGpuRenderView.c ~line 453-457) and the shared Vertex struct are not touched at all — this is the whole point of the split.

3. Shaders

  • New src/shaders/vertex_sprite_stream.glsl: ```glsl #version 450 layout(location = 0) in vec2 inPosition; layout(location = 1) in vec2 inUV; layout(location = 2) in float inFlash;

layout(location = 0) out vec2 outUV; layout(location = 1) out float outFlash;

void main() { gl_Position = vec4(inPosition, 0.0, 1.0); outUV = inUV; outFlash = inFlash; } (`vertex2.glsl` itself stays untouched, still used by the other 3 window types.) - `src/shaders/sprite_atlas_fragment.glsl`: add `layout(location = 1) in float inFlash;` and a uniform buffer for the flash color (see "Flash color" below):glsl layout(std140, set = 3, binding = 0) uniform UniformBlock { vec4 flashColor; }; (matches the existing `set = 3, binding = 0` convention already used by `st_lowres_fragment.glsl` for its own uniform block; the atlas sampler keeps its existing `set = 2, binding = 0`). Fragment logic becomes:glsl vec4 texColor = texture(atlasTex, inUV); outColor = vec4(mix(texColor.rgb, flashColor.rgb, inFlash), texColor.a); `` This keeps the exact same alpha/silhouette shape from the atlas (matching the real blitter's behavior of reusing the mask), only swapping RGB wheninFlashis 1.0. - All changed/new.glslfiles need compiling to.spvvia the existing manualcompilerShaders.bat` process (you'll run this yourself, per your build workflow).

Flash color: live per-frame uniform from STRGBPalette, not a baked-in constant

The palette can change at runtime (level/screen transitions, palette animation), so the flash color must not be hardcoded. sdlGpuRenderView.c already has the exact right precedent for this: RENDER_VIEW_LOWRES_VRAM's per-frame render path (in SdlGpuRenderView_Submit, ~line 1869-1880) already reads the live global STRGBPalette[16] (declared in conv_st.h) fresh every frame and pushes it into a uniform buffer via SDL_PushGPUFragmentUniformData, using the existing ConvertAARRGGBBPalette helper (~line 1626) for the uint32_t → float[4] conversion. xenonRender.c already relies on STRGBPalette the same way for sprite-atlas color decode (with the same caveat noted there: it's "the" palette in use, not guaranteed correct per-sprite for Spec512-style split-screen palette tricks — acceptable here for the same reason it's acceptable there, this is a best-effort debug visualization, not a gameplay-accuracy guarantee).

Rejected alternative: adding the resolved color as a per-vertex attribute (you flagged this yourself as "more per-vertex data" — correct, and unnecessary here since the color is uniform across every flashing sprite in a frame, only the per-object on/off flag needs to vary).

Concretely: - New UniformBufferSpriteStream { float flashColor[4]; } in sdlGpuRenderView.c, next to the existing UniformBufferMaskedSprites/UniformBufferLowRes/UniformBufferMemoryDump structs, and added as a 4th member of the existing anonymous union in SdlGpuRenderView (~line 431-436). - SdlGpuRenderView_Create's RENDER_VIEW_SPRITE_STREAM branch (~line 1148-1151): drop the "no uniform buffer for this type" comment — it no longer applies. - SdlGpuRenderView_Submit's per-type uniform-update block (~line 1861-1889): add a RENDER_VIEW_SPRITE_STREAM branch that converts STRGBPalette[7] (the color index the flash blitter's forced planes 0/1/2=1 + background-preserved plane3 resolves to in the common case — see the Context section) using the same per-channel math as ConvertAARRGGBBPalette, writes it into view->uniformDataSpriteStream.flashColor, and calls SDL_PushGPUFragmentUniformData(commandBuffer, 0, &view->uniformDataSpriteStream, sizeof(UniformBufferSpriteStream)). Since this runs every time the window renders a frame, the flash color always reflects whatever STRGBPalette currently holds — no staleness across palette changes.

4. Vertex building — src/screentrace.c

  • SpriteStreamVertexBuilder and SpriteStreamVertexBuilder_AppendEntry (~line 395-459): switch from Vertex to the new SpriteStreamVertex type. Read entry->isFlash, compute float flash = entry->isFlash ? 1.0f : 0.0f;, and add it as the 3rd component to all 6 SpriteStreamVertex literals for the quad (same value repeated across the quad, exactly like x/y/u/v already are handled).
  • SdlGpuRenderView_Submit's RENDER_VIEW_SPRITE_STREAM branch and StreamVertexBuffer_Update (sdlGpuRenderView.c): change the (const Vertex*)pixelData cast to (const SpriteStreamVertex*)pixelData.
  • sdlGpuRenderView.h's doc comment on SdlGpuRenderView_Submit's pixelData parameter (currently says "this is actually a const Vertex* array" for the sprite-stream case) needs updating to reference the new type.

Verification

  1. Rebuild (your own build step).
  2. Open the "Sprite Stream" debug window, start the ship firing (holding fire is confirmed to install REAL_DRAWPROC_3 on the ship's object every time — see ShipUpdate_ProcessInputMovementCamera_FUN_00006734), and confirm the ship visibly flashes a solid color for exactly one frame per activation, then reverts to its normal textured sprite — matching the self-reverting behavior already confirmed in the decompilation.
  3. Check the other 3 debug window types (Masked Sprites, LowRes VRAM, Memory Dump) still render correctly after the CreateGraphicsPipeline/LoadShaders signature changes — they should be completely unaffected since their vertex format, vertex2.glsl, and pipeline branch are untouched by this plan.