Skip to content

PS5-style UI: backdrop blur, time-based animation, and two new theme elements#1

Draft
danielnuld wants to merge 13 commits into
masterfrom
feature/ps5-ui
Draft

PS5-style UI: backdrop blur, time-based animation, and two new theme elements#1
danielnuld wants to merge 13 commits into
masterfrom
feature/ps5-ui

Conversation

@danielnuld

@danielnuld danielnuld commented Jul 12, 2026

Copy link
Copy Markdown
Owner

What this is

An additive PS5-style visual language for OPL: backdrop blur ("frosted glass"), a real animation engine, three new theme element types, and an included PS5 theme that composes them.

Nothing is rewritten. OPL already had everything needed — gsKit, FreeType, a data-driven theme system, cover art downloading. This grafts onto that.

No existing theme changes. The new element types are appended to the end of elementsType[]; nothing is reordered. A theme that doesn't name them doesn't instantiate them, and — importantly — doesn't even reserve the blur's VRAM: rmBlurInit() allocates nothing, and the chain is claimed lazily on the first rmBlurBackdrop().

The interesting part: blur without shaders

The GS can sample its own framebuffer and chain bilinear taps. A reduction pyramid plus ping-pong passes gives a gaussian-ish blur for a fraction of a frame. No shaders required.

Three things that are easy to get wrong, and cost real debugging:

  1. Render target widths must be exact multiples of 64. gsKit writes a buffer with FRAME.FBW = Width / 64 (truncating) but reads it back with TEX0.TBW = ceil(Width / 64). A 160-wide RT is written at a 128-texel stride and sampled at 192: torn. The chain uses 320 and 128.
  2. RTs must be GSKIT_ALLOC_SYSBUFFER. FRAME.FBP addresses VRAM in 8192-byte units; USERBUFFER only aligns to 256, so the base address would be truncated and the GS would draw somewhere else.
  3. gsKit_setactive() emits FRAME immediately — it does not queue it. The draw queue has to be drained with gsKit_queue_exec() before every target switch, or queued primitives land in the new target.

New API

  • rmBlurBackdrop() / rmDrawFrosted() in renderman (the chain itself is isolated in src/rmblur.c — it repoints FRAME mid-frame, which is the most invasive thing here, so it stays behind the narrowest surface that works)
  • src/uianim.c — time-based easing. The 1 - exp(-rate*dt) form is what makes it framerate-independent: two 8 ms steps land exactly where one 16 ms step does. Frame-indexed easing would run 20% slow in PAL.
  • FrostedPanel, CardShelf, CoverWallpaper theme element types
  • texLoadCover() — box-filters cover art down to a fixed 128×192 CT16 tile on the EE

Why the cover rescaler is a hard prerequisite, not an optimization

OPL uploads covers at the PNG's native resolution, capped only by maxSize = 720*512*4 = 1,440 KiB per texture (textures.c). That is most of the ~1,856 KiB TexManager pool spent on one cover. It works today because the menu only ever shows one at a time and the pool streams.

A shelf of seven wants 3–10 MiB. That does not fit in the console. So shelf covers are box-filtered to a 128×192 CT16 tile (48 KiB) at load time, cached, and the native image is never allocated as a texture at all — it cannot reach VRAM even transiently.

The filter runs over libpng's decoded RGBA rows rather than over a GSTEXTURE, which sidesteps the GS-specific packing entirely (T8 stores a swizzled CLUT, T4 swaps its nibbles) and keeps one code path for CT32, CT24, grayscale and palette.

Cost, measured in PCSX2 on a 1024×512 palettized PNG (worse than any real cover): PNG decode 152 ms, box filter 37 ms. It runs on the IO thread (ioPutRequest), not the render thread, so it drops no frames — and it's a +24% on a decode cost OPL already pays.

Measurements

VRAM — measured in PCSX2. This one is reliable: it's address accounting, not performance.

Framebuffers (NTSC 640×448 CT24) 2,240 KiB
Blur chain 224 KiB
TexManager pool remaining 1,632 KiB

The chain costs 224 KiB, not the 210 first estimated: gsKit_texture_size() aligns to pages, so the RT heights round up. It comes out the same in PAL, because those heights land on the same aligned boundaries.

Fill rate — calculated. Pixels written per frame don't depend on anything an emulator fakes.

The blur chain writes 320·224 + 128·112 + 4·(128·112) = 143,360 px = half a screen.

Assumed fill rate Blur chain Whole PS5 theme
1.18 Gpx/s (textured, theoretical peak) 0.7% of frame 9%
0.50 Gpx/s (pessimistic, eDRAM-bound) 1.7% of frame 21%

⚠️ Testing status — please read

Verified in PCSX2. NOT verified on real hardware. I no longer have a physical PS2, which is why this is a draft.

PCSX2 is fine for VRAM accounting and for confirming that pixels land where they should. It is useless for frame rate: it models neither GS fill cost nor TexManager pressure.

Three questions I cannot answer, and would be grateful for help with:

  1. Frame rate on real hardware, NTSC and PAL. Note the risk is not the blur — the blur is ~1.7% of a frame. It's the theme's composition, which stacks roughly 6.1 screens of fill per frame.
  2. TexManager thrashing. The working set (~1 MiB) fits the shrunken pool (1,632 KiB) on paper, but per-frame DMA re-uploads show up as lost fps, not as a visual glitch.
  3. FAT vs SLIM, and PAL — where the pool is tighter (1,312 KiB).

Build with make DEBUG=1: the debug overlay shows fps, KiB FIXED / KiB TEXMAN, and the cover rescaler's timings — exactly what's needed to answer the above.

To try the theme: copy themes/PS5/ to <device>/THM/PS5/ and select it in Settings → Interface. It is not the default.

Degradation

  • 720p / 1080i — blur disabled, no VRAM reserved, panels fall back to a solid tint
  • No VRAM — same
  • No blur at allCoverWallpaper draws the cover sharp, and the gradient veil still guarantees text contrast

The UI stays usable in every case.

Daniel Noé Núñez López added 13 commits July 12, 2026 00:02
Añade la propuesta OpenSpec para extender OPL con:
- backdrop blur en el GS por render-to-texture, sin shaders
- animación de UI basada en tiempo (no en frames)
- elementos de tema FrostedPanel y CardShelf
- un tema PS5 incluido, no predeterminado

El cambio es aditivo: los temas existentes no se alteran.

Sólo planificación. Ningún cambio de código todavía.
La auditoría del consumo de VRAM invalidó la premisa original.

- OPL usa el TexManager de gsKit: la VRAM tras CurrentPointer es un pool
  de STREAMING, no memoria residente. Reservar los buffers de blur no puede
  fallar; encoge el pool. El riesgo real es thrashing de texturas, no un
  fallo de asignación.
- Los assets integrados suman 6,508 KiB en CT32, más que la eDRAM total.
  OPL los hace caber paletizando (background.png es 1024x512 T4: 256 KiB
  en vez de 2 MiB).
- Hallazgo inesperado: el consumidor grande no es el blur (210 KiB) sino
  el CardShelf, que quiere 7 portadas simultáneas (~784 KiB). Hay que
  dimensionarlas antes de implementarlo.
Añade src/rmblur.c, el módulo que reapunta FRAME a un buffer de VRAM para
reducir y desenfocar el framebuffer. Aislado de renderman.c a propósito: es
la operación más invasiva del proyecto y así el blast radius queda acotado.

renderman.c sólo gana dos líneas: rmBlurInit() tras gsKit_init_screen() (la
reserva de VRAM tiene que ir después de que CurrentPointer se haya movido) y
rmBlurEnd() en rmEnd().

Tres cosas que el diseño tenía mal o no contemplaba, encontradas al escribir
el código contra las fuentes de gsKit:

1. El segundo nivel de la pirámide NO puede ser de 160 de ancho. gsKit
   escribe ese buffer con FRAME.FBW = Width/64, que TRUNCA (160 → 2), y lo
   lee con TEX0.TBW = ceil(Width/64), que REDONDEA (160 → 3). Stride de
   escritura 128, stride de lectura 192: el buffer sale rasgado. Pasa a 128,
   que es múltiplo exacto de 64 y coincide bajo ambos redondeos.

2. Los render targets van en GSKIT_ALLOC_SYSBUFFER, no USERBUFFER. FRAME.FBP
   direcciona la VRAM en unidades de 8192 bytes; USERBUFFER alinea a 256, así
   que la dirección se truncaría y el GS dibujaría en otro sitio.

3. Los offsets de las pasadas alternan signo ({+0.5, −0.5, +1.5, −1.5}). Un
   desplazamiento siempre en el mismo sentido ensancha el kernel pero arrastra
   la imagen. Alternando, la magnitud crece y la suma es cero.

Compila limpio y enlaza. Pero nada llama todavía a rmBlurBackdrop() —el
enlazador se la come por falta de referencias—, así que no hay un solo píxel
verificado. El enganche es rmDrawFrosted(), fase 3.
rmDrawFrosted() dibuja el backdrop desenfocado en una región y le compone el
tinte encima. Vive en renderman.c porque ahí están X_SCALE() y fRenderXOff,
que son los que traducen la región lógica de 640x480 a píxeles reales del
framebuffer -- y por tanto a UVs de la cadena, que es una reducción del
framebuffer entero (anisotropa: 128 de ancho pero H/4 de alto, de ahí que la
escala de U y la de V no sean la misma).

El backdrop va sin blending, por la razón de siempre: componerlo metería el
alpha del framebuffer en la ecuacion, y en CT16S ese canal es de un bit que
no controlamos. El tinte es lo unico que compone. Si no hay cadena,
rmBlurTexture() devuelve NULL y queda solo el tinte: la degradacion
documentada, no un fallo.

Y lo que faltaba de verdad: NADIE llamaba a rmBlurBackdrop(), asi que el
enlazador se comia el modulo entero -- en el commit anterior solo sobrevivian
rmBlurInit y rmBlurEnd. Anadido un test puntual en guiShow() bajo #ifdef
__DEBUG (gEnableBlurTest), que es lo que por fin hace ejecutable la tarea 1.4.
El tema PS5 lo sustituira en la fase 6.

Compila limpio. Ahora si enlazan rmBlurBackdrop, rmBlurTexture y
rmDrawFrosted. Sigue sin verificarse un solo pixel: eso es PCSX2.
El panel de cristal se dibuja con el fondo desenfocado dentro y el resto del
frame queda intacto. Eso confirma las dos cosas que daban miedo: el bind y el
restore del destino de dibujado son correctos, y vaciar la cola con
gsKit_queue_exec() antes de cada gsKit_setactive() preserva el orden de
dibujado. El riesgo #1 del diseño queda despejado.

Lo que PCSX2 no contesta y sigue abierto: los fps reales (el emulador no
modela el coste del GS ni la presion sobre el pool del TexManager), PAL, y
hardware fisico. Eso es la fase 7.
src/uianim.c. Dos primitivas, porque hacen falta las dos: uiApproach() es
suavizado exponencial asintotico, para perseguir un objetivo que se mueve (el
foco); uiAdvance() mas los easings tienen duracion definida, para cuando
"200 ms" tiene que significar 200 ms.

Lo que hace que sea independiente del framerate es la forma 1 - exp(-rate*dt):
el paso depende del tiempo transcurrido, no de que se llame a la funcion. Dos
pasos de 8 ms acaban exactamente donde uno de 16 ms. El ingenuo
cur += (target-cur)*k no tiene esa propiedad y correria un 20% mas lento en
PAL, a 50 Hz.

El tick va en guiStartFrame(), no en menusys.c como decia la tarea: ahi es
donde empieza el frame de verdad, asi que una sola muestra de reloj sirve a
todos los elementos y todos ven el mismo delta. Reutiliza el clock() /
CLOCKS_PER_SEC que OPL ya usa; no mete un segundo reloj.

El delta se recorta a 60 ms. Sin eso, un tiron -escanear un dispositivo,
arrancar el DVD- teletransportaria todas las animaciones a su destino.

Todo en float y verificado con -Wdouble-promotion y -Wfloat-conversion: cero
avisos. El R5900 no tiene doble precision en hardware y cada double se emula
por software via libgcc.

El panel de prueba de gEnableBlurTest ahora se desplaza con uiApproach(), que
es lo que mantiene el modulo enlazado y lo hace visible.
texLoadCover(): filtro de caja de la portada a un tile fijo de 128x192 CT16,
48 KiB. No es una optimizacion, es lo que hace que el shelf quepa en la
consola: OPL sube las portadas a resolucion NATIVA, con un tope por textura de
720*512*4 = 1,440 KiB, o sea casi todo el pool de 1,856 KiB para UNA. Funciona
hoy porque el menu solo ensena una a la vez. Siete a tamano nativo son entre 3
y 10 MiB: no caben.

El filtro corre sobre las filas RGBA de libpng, no sobre el Mem de una
GSTEXTURE ya empaquetada. Empaquetar es especifico del GS -el T8 guarda la
CLUT swizzleada, el T4 intercambia los nibbles- y filtrar ahi obligaria a
deshacer las dos cosas. Por las filas de libpng un solo camino sirve para
CT32, CT24, gris y paleta. Y de paso la imagen nativa nunca llega a existir
como textura, asi que no puede subir a VRAM ni transitoriamente, que es justo
lo que pide la spec.

Filtro de caja y no vecino mas cercano: decimar una portada de 512x720 a
128x192 tirando 15 de cada 16 pixeles aliasea de forma muy visible.

Arnes para la tarea 0.6: el build DEBUG reescala background.png una vez al
arrancar y escribe los ms al log. Ese PNG a proposito, porque es 1024x512
-mas grande que el tope de una portada- y ademas paletizado: peor caso y
camino de paleta de una vez. El tile se dibuja para poder mirar la salida del
filtro, no solo cronometrarla.
El coste del reescalado se escribia con LOG(), que acaba en vprintf: solo
aparece si tienes abierta la consola EE de PCSX2 o ps2link enganchado. En la
practica no se ve nada.

Ahora las cifras salen dibujadas en el overlay de debug, justo debajo del
medidor de FPS y del contador de VRAM, que es donde ya se mira todo lo demas.
Se separan las dos mitades del coste, porque no son la misma cosa y solo una
es culpa nuestra:

  PNG <n>ms  - decodificar el PNG. Coste que OPL ya pagaba antes.
  BOX <n>ms  - el filtro de caja. Esto es lo que anade este cambio.

El LOG() se queda igualmente, para cuando haya ps2link.
Medido sobre background.png (1024x512 paletizado, peor caso -- mas grande que
el tope de 720x512 de una portada):

  decodificar el PNG   152 ms
  filtro de caja        37 ms

Asumible por tres razones, no por una:

1. No bloquea el render. Las portadas se cargan con ioPutRequest en el hilo de
   IO (ioman.c:200), no en el de dibujado. Los 37 ms no tiran ningun frame.
2. Es una fraccion de lo que OPL ya paga: decodificar el PNG cuesta 152 ms hoy,
   sin tocar nada. El filtro anade un +24% a algo que ya era caro y ya era
   asincrono.
3. Ocurre una vez por portada, y el image_cache_t lo cachea.

Numeros de PCSX2, no de consola: el emulador suele correr el codigo escalar mas
rapido que el EE real, asi que esto da el orden de magnitud y confirma que la
idea es viable, pero NO cierra la fase 7. Si en hardware sale caro, la salida
es muestrear el filtro cada 2 pixeles: 4x mas rapido y poca perdida visible a
128x192.
Los dos tipos nuevos se anaden AL FINAL de elementsType[] y no se reordena
nada. Un tema que no los nombra no los instancia, no reserva VRAM de blur y
renderiza igual que antes. Es tambien la condicion para que esto sea
upstreameable: una reescritura de la UI no se mergea nunca; una extension
aditiva del sistema de temas, si.

FrostedPanel envuelve rmDrawFrosted. El blur es una operacion de frame entero,
pero un tema puede poner varios paneles: se desenfoca una sola vez, en el
primero que dibuje, guardado con guiFrameId.

CardShelf: fila de portadas, la enfocada crece y se eleva, las demas se
atenuan. El crecimiento y la elevacion se derivan de la proximidad al foco
ANIMADO (uiApproach), no al indice seleccionado -- por eso suavizan en vez de
saltar cuando cambias de juego. Atenuar modulando el color (0x80 es el neutro
del GS) evita tener que dibujar ningun borde. Culling: solo recorre la pagina
que OPL ya calcula, nunca la lista entera.

Como pide los tiles reescalados: image_cache_t gana un campo psm. CT24 (el
defecto) significa "a tamano nativo", que es lo que quiere todo elemento
existente; CT16 pide el tile de 128x192. Los tres backends ya recibian un psm
que IGNORABAN; ahora lo propagan.

El CardShelf lleva cache propia a proposito. initMutableImage deduplica cachees
por patron de arte, y un ItemCover del mismo tema tambien usa "COV":
compartirla mandaria a uno de los dos por el cargador equivocado -- o el shelf
recibiria portadas a tamano nativo (justo lo que no cabe), o el ItemCover
recibiria tiles de 128x192.

Y lo que arregla la tarea 5.5: rmBlurInit() YA NO RESERVA NADA. El modo de
video se fija antes de cargar el tema (opl.c, applyConfig), asi que ahi es
imposible saber si habra cristal. La cadena se reclama en el primer
rmBlurBackdrop(), de modo que un tema antiguo nunca entra ahi y nunca paga los
224 KiB.

Mas themes/PS5/conf_theme.cfg. El orden de los elementos no es cosmetico:
FrostedPanel desenfoca lo ya dibujado, asi que el fondo va antes y el shelf
despues, para quedar nitido encima del cristal.
Elemento nuevo CoverWallpaper. La gracia es que no hace falta maquinaria nueva
para desenfocarlo: la cadena samplea el FRAMEBUFFER, asi que si el wallpaper
dibuja primero la portada estirada, el framebuffer ya la contiene y un
rmDrawFrosted() a pantalla completa ES el wallpaper desenfocado.

Y la degradacion sale sola: sin blur, rmDrawFrosted dibuja solo el tinte y deja
la portada nitida debajo -- exactamente el fallback que pide la spec, sin
escribir una linea para ello.

El velo va en degradado (rmDrawRectGradient, un solo quad gouraud: el GS
interpola el color entre vertices gratis, asi que cuesta lo mismo que un
rectangulo plano). Mas oscuro abajo, que es donde se apoya el titulo. No es
decoracion: una portada puede ser muy clara y el texto de la UI es blanco.

Se estira un tile de 128x192 a pantalla completa y da igual, porque acaba
desenfocado. Eso mantiene la portada nativa -hasta 1,440 KiB- fuera de VRAM.

Transicion: crossfade de 350 ms. Hizo falta rmDrawPixmapBlend() porque
rmDrawQuad decide el alpha SEGUN EL FORMATO y solo lo activa para CT32: un tile
CT16 era literalmente imposible de fundir.

El crossfade guarda punteros a GSTEXTURE, no a items del menu. Los GSTEXTURE de
una cache viven lo que vive la cache; un item de submenu puede liberarse bajo
tus pies al reconstruirse la lista de dispositivos. En el peor caso se mezcla el
arte equivocado durante unos cientos de ms, pero nunca queda colgado.

Ajuste: validateBackgroundElems() antepone un Background por defecto si el
primer elemento no lo es. Un CoverWallpaper ES el fondo, asi que ahora cuenta
como tal; sin eso OPL dibujaba debajo un fondo a pantalla completa que quedaba
tapado del todo, mas una cache que no leia nadie.
No hay hardware fisico y no lo va a haber, asi que el plan original -"no se
pasa de fase sin correr en consola"- deja de ser cumplible. En vez de dejar la
fase 7 pendiente para siempre, se separa lo que SI se puede cerrar sin consola
de lo que hay que preguntarle a la comunidad.

Lo que se cierra: el RELLENO SE CALCULA. Son pixeles escritos por frame, no
dependen de nada que PCSX2 falsee. La cadena de desenfoque escribe 143,360 px
= media pantalla, entre el 0.7% y el 1.7% del presupuesto de un frame a 60 Hz
segun lo que se asuma que rinde el GS. El requisito de la spec (<20%) se cumple
con margen enorme, y eso no depende de medir nada.

Lo que queda abierto, y NO es el blur: el tema completo apila ~6.1 pantallas de
relleno por frame (9%-21% del frame). Eso es la composicion, no la cadena. Solo
se mide en hardware. Igual que el thrashing del TexManager, que se manifiesta
como perdida de fps y no como fallo visual.

Y de paso, recortado el desperdicio obvio que destaparon las cuentas: en estado
estable el wallpaper dibujaba la capa saliente del crossfade y luego la
entrante encima, opaca del todo. Una pantalla completa de relleno tirada en
cada frame. Ahora solo se dibujan las dos capas mientras el fundido esta en
curso.

La fase 8 pasa a ser una PR en borrador que pide testers: la comunidad de OPL
si tiene hardware, y una PR honesta con una build de debug y tres preguntas
concretas vale mas que un cambio que nadie puede validar.
#1, master <- feature/ps5-ui. En el fork y no contra
ps2homebrew: revisar en casa antes de exponerlo.

El cuerpo va en ingles a proposito, para poder re-apuntar la PR a upstream sin
reescribirla. Los mensajes de commit, en cambio, siguen en espanol: habria que
rehacerlos antes de mandarla a ps2homebrew.
danielnuld pushed a commit that referenced this pull request Jul 12, 2026
#1, master <- feature/ps5-ui. En el fork y no contra
ps2homebrew: revisar en casa antes de exponerlo.

El cuerpo va en ingles a proposito, para poder re-apuntar la PR a upstream sin
reescribirla. Los mensajes de commit, en cambio, siguen en espanol: habria que
rehacerlos antes de mandarla a ps2homebrew.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant