The port’s wgpu renderer
How the Rust port draws a frame: which PlayStation rasterisation traits are always on, the three that are opt-in, how per-texel semi-transparency becomes two passes, the enhancement passes that leave the faithful render bit-identical when off, and the screen-space overlay that stands in for retail’s ordering table. Retail’s own renderer is on renderer; how a pixel gets its colour is on shading.
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_modegates 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
What is PlayStation about it
| Trait | Gate | What it reproduces |
|---|---|---|
| Affine UV interpolation | unconditional | the texture warp on surfaces with steep depth gradients. It is the faithful behaviour, not an effect |
| Flat texture page + palette per primitive | unconditional | each triangle samples one page and one palette |
| Texture modulation | unconditional | texel * colour / 128 with the baked colour word - the game’s whole lighting on most paths |
| Semi-transparency | set_semi_blend, on by default | the four blend equations, per texel |
| GTE register limits | PSX mode | a 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 snap | PSX mode | the 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 dither | PSX mode | the 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.
| Mode | Equation |
|---|---|
0 | 0.5 * B + 0.5 * F |
1 | B + F (additive) |
2 | B - F (subtractive) |
3 | B + 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.