At a glance

Crate
legaia-engine-render: winit + wgpu, a software VRAM page sampled in the fragment shader
Shared kernels
legaia-render-kernels - wgpu-free GTE maths, screen primitives, VRAM capture, emitters - linked by the browser host too
Always on
Affine UVs, flat per-primitive texture page and palette, PSX texture modulation, semi-transparency
Opt-in
Renderer::set_psx_mode gates the GTE’s register limits, vertex snap and 15-bit dither only
Enhancements
Enhanced lighting, light shadows, camera-occlusion fade, volumetric fog, bloom - each off is retail
Draw lists
Battle HUD, menus and text come from engine-ui, shared with the browser host

A frame

Software VRAM 1024 x 512 halfwords Opaque scene depth write on Blend pass far to near, 4 modes Screen overlay ordering-table walk HUD, chrome, text engine-ui draw lists enhancements: identity when off Enhanced lighting moods, lamps, halos Occlusion fade screen-door discard Light shadows per-light PCF Volumetric fog not the retail pool VRAM capture drawn frame back into the page PSX mode (opt-in) GTE limits, vertex snap, 15-bit dither
Retail draws 3D primitives and screen-space packets through one ordering table; the port splits them into a scene pass and an overlay pass and joins them in one compositing target. The capture path is the direction the console gets for free: on a PlayStation the framebuffer is VRAM, so a primitive can sample pixels drawn moments earlier.

What is PlayStation about it

TraitGateWhat it reproduces
Affine UV interpolationunconditionalthe texture warp on surfaces with steep depth gradients. It is the faithful behaviour, not an effect
Flat texture page + palette per primitiveunconditionaleach triangle samples one page and one palette
Texture modulationunconditionaltexel * colour / 128 with the baked colour word - the game’s whole lighting on most paths
Semi-transparencyset_semi_blend, on by defaultthe four blend equations, per texel
GTE register limitsPSX modea corner whose eye-space X / Y or depth passes the GTE’s 16-bit IR / SZ saturation lands where RTPS wrote it, not where a plain projection puts it (renderer)
Sub-pixel vertex snapPSX modethe per-vertex shimmer on slow geometry; corners truncate onto the 320×240 grid and each scanline takes its texels at the row itself
15-bit ordered ditherPSX modethe cross-hatched gradients of a 5-bit-per-channel framebuffer

There is no synthetic light. Retail runs no light source on the field path: shading is baked into each primitive’s colour word and the only colour operation is the depth cue. A missing colour stream defaults to white, and white reads as too bright, not unlit - the usual sign that a draw lost its colours.

Semi-transparency is two passes

Whether a texel blends is decided per texel: bit 15 of the palette entry picks blend or opaque inside one semi-transparent primitive. So the opaque pass draws everything and discards those texels, and a blend pass re-draws only the semi-transparent triangles, keeping only them.

ModeEquation
00.5 * B + 0.5 * F
1B + F (additive)
2B - F (subtractive)
3B + 0.25 * F

Order mirrors the ordering table at per-primitive granularity. Every blended primitive is keyed by its centroid depth and the whole list blends far to near regardless of draw boundaries; equal keys draw later-submitted-first, as a bucket of the retail table does. Untextured primitives have no per-texel gate and blend every pixel.

Enhancement passes

  • Enhanced lighting shades the scene under a time-of-day mood, adds lamp point lights, draws emissive-tagged primitives at a gain and composites glow sprites and light shafts. The derivation lives in the shared kernel so both hosts light from the same values; per-light shadows are the part only wgpu has.
  • Camera-occlusion fade dissolves environment fragments that stand between the camera and the player into a 4x4 screen-door pattern, armed only when a five-point ray-cast finds the character completely hidden. It is a fragment discard, not a per-body cull.
  • Volumetric ground fog is separate from the retail fog pool and never touches it. It lies on the ground the scene actually draws: a coverage field rasterised once per scene from the static geometry stops the bank over open air (the Sky Gardens' tower top no longer carries a flat sheet across the sky), and past a rim the bank spills over the edge as slow, broken fog - wisps and tendrils with gaps between them, some stretches of rim pouring and others barely, falling almost straight down the wall face with a third of the bank's weight - above a faint haze far below the rim, so the drop into the void reads as misty rather than ending on nothing. Only what is on screen shapes it: objects a script hid, near-invisible cutouts and blended veils are not ground, and an invisible marker does not part the bank. In battle it drops to a thin, ankle-high haze - a third of the field opacity - so the stage floor stays readable, and it eases in rather than popping at the cut.
  • Bloom (off by default) lets the bright parts of the 3D frame - lamp halos, glowing props, lit windows, sunlit sky - bleed a soft glow. The scene renders offscreen; a quarter-resolution pass keeps only what is already bright (a soft-knee threshold), a two-round Gaussian blurs it, and a composite adds it back under every screen layer, so the HUD never blooms. The threshold and strength follow enhanced lighting’s mood - darker moods bloom lower and stronger - from one shared kernel, and the browser draws the same passes from a GLSL twin held to the WGSL by a parity test.

Each is identity when disabled: the shader helper returns its input when the uniform enable is zero, so the disabled path is pixel-identical to the faithful render.

The screen-space overlay

A screen primitive is a textured quad or a flat quad carrying an ordering-table index. The pass reproduces the retail walk - farthest bucket first, later-submitted-first inside a bucket - and draws coalesced runs by blend class. Primitives are authored in the 320x240 display space every retail emitter clamps against, and the overlay stretches over the surface.

Its consumers are the things retail draws as 2D packets into the 3D table: battle streaks and trails, the field-to-battle transition styles, billboards. The corner projection is the GTE’s own, including its reciprocal and its saturating screen coordinates, so a corner behind the camera smears the way hardware does.

Backdrops draw first. A screen primitive whose four corners sit at the far plane is one retail links into the farthest bucket - the cast colour wash and the overworld sky band. Composited after the 3D pass, a blended primitive writes no depth, so a wash drawn there would erase every additive effect over the sky. Both hosts split those backdrops off the under list and draw them in the scene pass, after the clear and before the first 3D draw.

Landing a frame back in VRAM

The renderer only ever pushed the software page to the GPU. The capture path is the missing direction: it quantises a readback to 15-bit colour and writes it into a rect of the CPU-side page, so the result is visible to every reader of that page. The field-to-battle transition needs it - the tile shatter and the curtain sample the field frame that was on screen when the encounter fired. The frame read back is the one the window presents, screen layers and enhancement passes included, less the transition’s own primitives.

Two retail nuances are stated rather than hidden: the transition’s depth-cue bank has no channel in the overlay, and retail subtracts from the display buffer each frame instead of clearing it, so its strips leave fading trails where the port’s leave black.

Frame profiler

LEGAIA_PROFILE=1 prints a rolling one-second timing breakdown from the native window: frame average and percentiles, draw counts, and the tick / pose / draw-list / encode / submit / present split. It is free when off - every entry point short-circuits on one cached flag.

See also

The blend tails and dither parity, the occlusion tunables, the transition emitter style by style, the battle HUD and menu chrome pipelines and the reference ports of retail’s draw-decision kernels are in docs/subsystems/renderer-port.md.