Elementos
Shader
Un shader es una vista cuya caja dibuja un programa de GPU: degradados que fluyen, ruido y grano, plasma, aurora, lámina holográfica, ondas que siguen un toque. Escribes una sola función WGSL, shade, que da el color de cada punto de la caja; la plataforma la ejecuta en la GPU a la resolución de la pantalla, sobre el fondo de la vista y bajo sus hijos. Los valores animados la mueven de forma nativa, fotograma a fotograma, sin trabajo en el código de la app. TSX crea uno con <Shader> de nsh, Rust con shader().source(program).
import { cleanup, createAnimatedValue, Easing, mount, Shader, signal, Text, View } from "nsh";
import { Animated, type CompositeAnimation } from "nsh";
import { Button, Switch } from "nsh/base";
import { button, buttonText, desktop, focusRing, hoverFill, Log, Panel, Segmented, size, Split, ui } from "./palette";
// A shader's colors come from uniforms; a tap starts a ripple, an animated
// uniform that runs natively; `paused` stops the shader's clock.
const SOURCE = /* wgsl */ `
struct Uniforms { a: vec4f, b: vec4f, c: vec4f, center: vec2f, wave: f32 }
fn shade(uv: vec2f) -> vec4f {
let t = nsh.time * 0.4;
let warp = vec2f(sin(uv.y * 4.0 + t * 1.3), cos(uv.x * 3.0 - t)) * 0.08;
let q = uv + warp;
let ab = mix(uniforms.a.rgb, uniforms.b.rgb, smoothstep(0.0, 1.0, q.x + 0.2 * sin(t)));
var color = mix(ab, uniforms.c.rgb, smoothstep(0.2, 1.0, q.y + 0.15 * cos(t * 0.7)));
let aspect = nsh.size / max(nsh.size.x, nsh.size.y);
let d = distance(uv * aspect, uniforms.center * aspect);
let ring = exp(-pow((d - uniforms.wave * 0.9) * 24.0, 2.0)) * (1.0 - uniforms.wave);
color += vec3f(ring * 0.8);
return vec4f(color, 1.0);
}`;
const PALETTES = {
sunset: ["#ff6b6b", "#ffb86b", "#7b5cff"],
lagoon: ["#00c2a8", "#2f80ed", "#0b3d91"],
citrus: ["#f9f871", "#9be15d", "#ff9671"],
} as const;
type Palette = keyof typeof PALETTES;
function App() {
const palette = signal<Palette>("sunset");
const paused = signal(false);
const events = signal<string[]>([]);
const log = (line: string) => events.set([line, ...events()].slice(0, 5));
const wave = createAnimatedValue(1);
const cx = createAnimatedValue(0.5);
const cy = createAnimatedValue(0.5);
let box = { width: 1, height: 1 };
let ripple: CompositeAnimation | undefined;
cleanup(() => ripple?.stop());
const tap = (x: number, y: number) => {
ripple?.stop();
cx.set(x);
cy.set(y);
wave.set(0);
ripple = Animated.timing(wave, { toValue: 1, duration: 1600, easing: Easing.out(Easing.quad) });
ripple.start();
log(`Ripple at ${Math.round(x * 100)}%, ${Math.round(y * 100)}%`);
};
const colors = () => PALETTES[palette()];
return (
<View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
<Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>shader</Text>
<Text style={{ color: ui().muted }}>{desktop ? "Click the shader to start a ripple" : "Tap the shader to start a ripple"}</Text>
<Split
gap={14}
width={760}
inspector={
<>
<Segmented
label="colors"
options={["sunset", "lagoon", "citrus"] as const}
value={palette()}
onChange={(next) => {
palette.set(next);
log(`Colors: ${next}`);
}}
/>
<Panel title="paused · ripple">
<Switch
checked={paused()}
onChange={({ checked }) => {
paused.set(checked);
log(checked ? "Paused: the clock stands still" : "Running");
}}
class={`-mx-2 flex-row-reverse items-center justify-between gap-3 rounded-lg px-2 ${desktop ? "py-1" : "py-2"} active:opacity-70`}
hoverStyle={hoverFill()}
focusStyle={focusRing()}
trackClass={`${desktop ? "w-9" : "w-12"} rounded-full p-0.5 transition-colors motion-reduce:transition-none`}
trackStyle={paused() ? ui().accentFill : ui().inset}
thumbClass={desktop ? "size-4 rounded-full transition-transform motion-reduce:transition-none checked:translate-x-4" : "size-5.5 rounded-full transition-transform motion-reduce:transition-none checked:translate-x-5"}
thumbStyle={paused() ? { backgroundColor: ui().onAccent } : ui().raised}
>
<Text style={{ flex: 1, fontSize: size.label, color: ui().text }}>Paused</Text>
</Switch>
<Button onPress={() => tap(0.5, 0.5)} {...button("secondary", { style: desktop ? { alignSelf: "flex-start" } : {} })}>
<Text style={buttonText()}>Ripple</Text>
</Button>
</Panel>
<Log title="events" lines={events()} minHeight={92} />
</>
}
>
<Shader
testID="shader"
source={SOURCE}
paused={paused()}
uniforms={{ a: colors()[0], b: colors()[1], c: colors()[2], center: [cx, cy], wave }}
style={{ height: desktop ? 420 : 280, borderRadius: 16, padding: 18, justifyContent: "flex-end" }}
onLayout={({ bounds }) => (box = bounds.size)}
onStartShouldSetResponder={() => true}
onResponderGrant={(e) => tap(e.locationX / box.width, e.locationY / box.height)}
>
<Text style={{ color: "#ffffff", fontSize: 15, fontWeight: "600" }}>Drawn by a WGSL shader, under its children</Text>
</Shader>
</Split>
</View>
);
}
mount(App);use nsh_core::{prelude::*, shader::Shader};
const GLOW: &str = r#"
struct Uniforms { tint: vec4f }
fn shade(uv: vec2f) -> vec4f {
let d = distance(uv, vec2f(0.5));
let pulse = 0.5 + 0.5 * sin(nsh.time * 2.0);
let glow = smoothstep(0.5, 0.0, d) * (0.6 + 0.4 * pulse);
return vec4f(uniforms.tint.rgb * glow, 1.0);
}
"#;
fn glow() -> impl IntoElement {
let program = Shader::compile(GLOW).expect("a valid shader");
shader()
.source(program)
.uniform("tint", Hsla::from(rgb(0x38bdf8)))
.h(px(200.0))
.rounded(px(14.0))
}import { mount, Shader } from "nsh";
const GLOW = /* wgsl */ `
struct Uniforms { tint: vec4f }
fn shade(uv: vec2f) -> vec4f {
let d = distance(uv, vec2f(0.5));
let pulse = 0.5 + 0.5 * sin(nsh.time * 2.0);
let glow = smoothstep(0.5, 0.0, d) * (0.6 + 0.4 * pulse);
return vec4f(uniforms.tint.rgb * glow, 1.0);
}`;
function Glow() {
return <Shader source={GLOW} uniforms={{ tint: "#38bdf8" }} style={{ height: 200, borderRadius: 14 }} />;
}
mount(() => <Glow />);#El shader
El código fuente de un shader es WGSL, el lenguaje de shaders de WebGPU. Define una función:
fn shade(uv: vec2f) -> vec4f
uves el punto de la caja de la vista, desde(0, 0)en su esquina superior izquierda hasta(1, 1)en su esquina inferior derecha, con y creciendo hacia abajo como en CSS.- El resultado es el color del punto: rojo, verde, azul y alfa en 0..1, en sRGB con alfa directo, como los colores CSS. Los valores fuera de 0..1 se recortan.
Junto a shade, el código contiene las funciones auxiliares, constantes y variables var<private> que necesite. NSH aporta los puntos de entrada y lee las entradas del shader desde dos lugares.
#Built-ins
| Built-in | Tipo | Valor |
|---|---|---|
nsh.size |
vec2f |
El tamaño de la vista en puntos. |
nsh.time |
f32 |
Segundos desde que apareció el shader. Un shader que lo lee se anima: dibuja en cada fotograma mientras está en pantalla. |
nsh.scale |
f32 |
Píxeles por punto del fotograma que se dibuja. |
uv * nsh.size es el punto en puntos; nsh.size.x / nsh.size.y es la relación de aspecto, que mantiene redondos los círculos en una caja que no es cuadrada.
#Uniforms
Los valores que fija la app van en un struct llamado Uniforms, que el shader lee como uniforms:
struct Uniforms {
colors: array<vec4f, 4>,
speed: f32,
center: vec2f,
}
fn shade(uv: vec2f) -> vec4f {
let t = nsh.time * uniforms.speed;
// …
}
La prop uniforms fija sus miembros por nombre. Cada tipo acepta estos valores:
| Tipo WGSL | Valores |
|---|---|
f32, i32, u32 |
Un número (redondeado hacia cero para i32 y u32), o un valor animado |
vec2f |
[x, y]: dos números o valores animados |
vec3f |
Un color (su rojo, verde y azul), un color animado, o tres números |
vec4f |
Un color con su alfa, un color animado, o cuatro números |
array<vec4f, N> |
Una lista de hasta N colores (o de cuatro números cada uno): una paleta, los puntos de parada de un degradado |
Los colores pueden ser cualquier color CSS ("#38bdf8", "rgb(56 189 248)", "hsl(199 93% 60%)") y llegan al shader como componentes sRGB en 0..1. Los miembros que la app omite valen cero. Al fijar uniforms de nuevo, el shader se dibuja otra vez con los nuevos valores.
const palettes = { Sunset: ["#ff6b6b", "#ffb86b", "#7b5cff", "#ff4fa3"], Lagoon: ["#00c2a8", "#2f80ed", "#a0f0ff", "#0b3d91"] };
const palette = signal<keyof typeof palettes>("Sunset");
<Shader source={MESH} uniforms={{ colors: palettes[palette()], speed: 0.5 }} style={{ height: 200 }} />;
#Uniforms animados
Un uniform puede ser un valor animado nativo, o un nodo derivado de uno (interpolate, aritmética). La plataforma escribe su valor en el shader en cada fotograma en que cambia y vuelve a dibujar, en el hilo de la UI: la animación se ejecuta a la tasa de fotogramas completa por muy ocupado que esté el código de la app. Los números animan los miembros f32, i32 y u32 y los componentes de un vec2f; una interpolación a colores anima un vec3f o vec4f.
use nsh_core::{prelude::*, shader::Shader};
fn ripple(program: Shader) -> impl IntoElement {
let wave = animated_value(0.0);
wave.timing(1.0, 2200.0, Easing::Linear);
shader()
.source(program)
.uniform("wave", &wave)
.uniform("center", [0.5_f32, 0.5])
.h(px(200.0))
}import { Animated, cleanup, createAnimatedValue, Easing, Shader } from "nsh";
const RIPPLE = /* wgsl */ `
struct Uniforms { center: vec2f, wave: f32, tint: vec4f }
fn shade(uv: vec2f) -> vec4f {
let d = distance(uv * nsh.size, uniforms.center * nsh.size) / max(nsh.size.x, nsh.size.y);
let ring = exp(-pow((d - uniforms.wave * 0.75) * 28.0, 2.0)) * (1.0 - uniforms.wave);
return vec4f(vec3f(0.03, 0.03, 0.06) + uniforms.tint.rgb * ring * 1.6, 1.0);
}`;
function Ripple() {
const wave = createAnimatedValue(0);
const tint = wave.interpolate({ inputRange: [0, 1], outputRange: ["#38bdf8", "#c084fc"] });
const loop = Animated.loop(Animated.timing(wave, { toValue: 1, duration: 2200, easing: Easing.out(Easing.quad) }));
loop.start();
cleanup(() => loop.stop());
return <Shader source={RIPPLE} uniforms={{ center: [0.5, 0.5], wave, tint }} style={{ height: 200, borderRadius: 14 }} />;
}#Pausa
paused detiene el reloj del shader: nsh.time conserva su valor y el shader mantiene su imagen. Al volver a ponerlo en false, el reloj continúa desde donde se detuvo. Los uniforms y los cambios de tamaño siguen dibujando un shader en pausa.
const paused = signal(false);
<Shader source={AURORA} paused={paused()} style={{ height: 200 }} onPress={() => paused.set(!paused())} />;
Un shader que no lee nsh.time se dibuja cuando cambian su tamaño o sus uniforms, y mantiene su imagen entre un cambio y otro.
#Hijos y recorte
Un shader es un contenedor: acepta todas las props de una vista (estilos, relleno y diseño de sus hijos, props de pulsación y de gestos, onLayout, accesibilidad, ref), y sus hijos se dibujan sobre el shader. El shader llena la caja de la vista como lo hace un fondo, dentro de sus esquinas redondeadas (borderRadius), sobre su backgroundColor.
<Shader source={HOLO} uniforms={{ tilt }} style={{ height: 200, borderRadius: 18, padding: 20, justifyContent: "space-between" }}>
<Text style={{ color: "#ffffff", fontSize: 13, fontWeight: "600" }}>NATIVE SHELL</Text>
<Text style={{ color: "#ffffff", fontSize: 26, fontWeight: "700" }}>4242 0000 1717</Text>
</Shader>
Dale un tamaño al shader mediante sus estilos (width / height, flex, una relación de aspecto) o deja que sus hijos lo dimensionen.
#Efectos, transiciones y la pantalla
Un shader también puede dibujar lo que dibujan otras vistas. Su WGSL lee entonces imágenes mediante cuatro funciones, cada una de las cuales recibe un punto uv de su caja y devuelve un color sRGB con alfa directo, transparente fuera de la imagen:
| Función | Lee |
|---|---|
sample_content(uv) |
los hijos de un efecto, o toda la superficie en un efecto de pantalla |
sample_backdrop(uv) |
lo que hay detrás de la vista (cualquier shader puede leerlo) |
sample_from(uv) |
el lado de una transición del que se sale |
sample_to(uv) |
el lado de una transición al que se llega |
nsh.progress es el progress de un efecto o el avance de una transición (de 0 a 1), y nsh.overflow es cuánto más allá de la caja dibuja el shader, en puntos.
ShaderEffect dibuja sus hijos a través del shader: conservan sus pulsaciones, foco y accesibilidad, y lo que se muestra es el resultado del shader. progress es un número o un valor animado, que mueve el efecto de forma nativa. maxSampleOffset permite que el shader dibuje esa cantidad de puntos más allá de la caja (un resplandor, el borde de un desenfoque); su uv pasa entonces a valores por debajo de 0 y por encima de 1. Sin source, los hijos se dibujan tal cual.
import { createAnimatedValue, ShaderEffect, Text } from "nsh";
const WIPE_OUT = `
fn shade(uv: vec2f) -> vec4f {
let c = sample_content(uv);
let edge = smoothstep(nsh.progress - 0.05, nsh.progress + 0.05, uv.x);
return vec4f(c.rgb, c.a * edge);
}`;
const gone = createAnimatedValue(0);
<ShaderEffect source={WIPE_OUT} progress={gone} style={{ padding: 16 }}>
<Text>Wiped away as `gone` goes to 1</Text>
</ShaderEffect>
ShaderTransition cambia sus hijos a través del shader cuando cambia value (una cadena, un número o un booleano), no con su primer valor: sample_from es una imagen de lo que dibujaban antes, sample_to lo que dibujan ahora, y nsh.progress va de 0 a 1 durante duration milisegundos (300). Sin source ejecuta fallback: "fade" (un fundido cruzado, el valor por defecto) o "none".
<ShaderTransition source={WIPE} value={card()} duration={450}>
<Card name={card()} />
</ShaderTransition>
ScreenEffect y ScreenTransition hacen lo mismo con toda la superficie (sus páginas, diálogos y superposiciones). No ocupan espacio y no tienen hijos; colócalos en cualquier lugar de la app. Se dibuja el último efecto de pantalla montado con un source.
Las páginas y los diálogos entran y salen a través de un shader con transitionShader: { source, uniforms, duration } en un NavPage o un Dialog (Rust: .transition_shader(TransitionShader::new(program)) en nav_page() o dialog()). Cuando se apila o se desapila la página, o se muestra o se cierra el diálogo, el lado inferior (la página que queda debajo, la app sin el diálogo) es sample_from y el superior sample_to; nsh.progress va de 0 a 1 cuando la página o el diálogo llega y de 1 a 0 cuando se va, de modo que un solo shader dibuja ambos sentidos.
use std::time::Duration;
use nsh_base::nav_page;
use nsh_core::{prelude::*, shader::{Shader, TransitionShader}};
fn details() -> impl IntoElement {
let ripple = Shader::compile(RIPPLE).expect("a valid shader");
nav_page()
.title("Details")
.transition_shader(TransitionShader::new(ripple).duration(Duration::from_millis(500)))
.child(text("…"))
}<NavPage title="Details" transitionShader={{ source: RIPPLE, duration: 500 }}>…</NavPage>El shader ocupa el lugar de animation de la página o del diálogo, sea cual sea (también "none"). El animation se ejecuta en un dispositivo sin GPU, cuando el usuario pide menos movimiento (Reduce Motion, Remove animations) y con un código fuente que no compila; con "none" el cambio se muestra entonces de inmediato. Un push, pop, show o close que empieza mientras otro se ejecuta completa primero ese: su página o diálogo llega o se va, se dispara su onShow o onDismiss, y el nuevo cambio empieza desde ahí.
Las plataformas toman las imágenes cuando cambia lo que muestran, como máximo una vez por fotograma de pantalla. Una imagen la dibuja el propio renderizador de la plataforma, así que lo que este no puede dibujar en una imagen (un vídeo, una página web, la vista de otra app) aparece transparente en ella.
#Props
Un shader acepta las props de una vista más:
| Prop | Rust | Tipo | Descripción |
|---|---|---|---|
source |
.source(program) |
string |
El código WGSL que define fn shade(uv: vec2f) -> vec4f. Rust lo compila con Shader::compile(source). |
uniforms |
.uniform(name, value) |
Record<string, ShaderUniform> |
Los valores de los miembros de struct Uniforms, por nombre: números, colores, valores animados y listas de ellos. |
paused |
.paused(bool) |
boolean |
Detiene el reloj de nsh.time. Por defecto false. |
ShaderEffect y ScreenEffect aceptan esas (source opcional) más:
| Prop | Rust | Tipo | Descripción |
|---|---|---|---|
progress |
.progress(n), .animated_progress(&value) |
number | AnimatedNode |
nsh.progress. Por defecto 0. |
maxSampleOffset |
.max_sample_offset(px(n)) |
number |
Cuánto más allá de la caja, en puntos, dibuja el shader y llegan sus imágenes (ShaderEffect y ShaderTransition). Por defecto 0. |
ShaderTransition y ScreenTransition aceptan source, uniforms y paused más:
| Prop | Rust | Tipo | Descripción |
|---|---|---|---|
value |
.value(key) |
string | number | boolean |
Cuando cambia, se ejecuta la transición (no con el primer valor). |
duration |
.duration(Duration) |
number |
Cuánto dura, en milisegundos. Por defecto 300. |
fallback |
.fallback(TransitionFallback) |
"fade" | "none" |
Lo que se ejecuta sin un source. Por defecto "fade". |
NavPage y Dialog aceptan:
| Prop | Rust | Tipo | Descripción |
|---|---|---|---|
transitionShader |
.transition_shader(TransitionShader::new(program)), .uniform(name, value), .duration(Duration) |
{ source: string; uniforms?: Record<string, ShaderUniform>; duration?: number } |
El shader a través del cual la página o el diálogo entra y sale, en lugar de su animation. duration en milisegundos, 350 por defecto. |
#Errores
NSH comprueba cada shader cuando se fija su código fuente, antes de que llegue a la GPU. Un shader que no compila registra una advertencia con la línea y la columna en su código y qué está mal, por ejemplo:
the shader of node 12: 2:1: `shade` takes the point's `uv: vec2f` and returns its color, a `vec4f`
La vista muestra entonces su fondo y sus hijos. Un uniform que el struct no tiene, o un valor que su tipo no acepta, registra una advertencia con el nombre del uniform, y los demás uniforms se aplican.
Cada código fuente se compila una sola vez: las vistas que comparten un código fuente, y un código fuente fijado de nuevo, usan el shader ya compilado.
#Reglas y límites
shadetiene exactamente la firmafn shade(uv: vec2f) -> vec4f. El código declara sus propias funciones, constantes, structs y variablesvar<private>; NSH aporta los puntos de entrada y los bindings.- El código fuente ocupa como máximo 64 KiB.
struct Uniformsocupa como máximo 4096 bytes, con miembros de los tipos de la tabla anterior.- Un fotograma mide como máximo 4096 píxeles por lado: una vista mayor dibuja su shader a una escala menor.
Para el ruido, calcula el hash de la celda entera en la que está un punto (bitcast<vec2u>(vec2i(floor(p))) mezclado con multiplicaciones y xor-shifts) en lugar de fract(sin(…) * 43758.5): los hashes enteros dan el mismo valor para una celda en cualquier lugar donde se calculen, así que el ruido de valor no tiene costuras en ninguna GPU. Los shaders de la galería muestran un ejemplo.
#Plataformas
En iOS, Android, macOS, Windows y Linux, y en apps GPUI, NSH renderiza los shaders en la GPU con wgpu (Metal en las plataformas de Apple; Vulkan, Direct3D 12 u OpenGL en el resto) a la escala de la pantalla, y muestra cada fotograma bajo los hijos de la vista. El renderizado se ejecuta en paralelo a la UI: un fotograma aparece uno o dos fotogramas de pantalla después de dibujarse, y la UI sigue respondiendo mientras trabaja la GPU. En la web, NSH traduce el shader a GLSL y lo dibuja con WebGL 2.
Los shaders dibujan como máximo una vez por fotograma de pantalla, y solo cuando cambia lo que muestran: un shader con uniforms fijos se dibuja una vez, uno que lee nsh.time se dibuja en cada fotograma, y uno con uniforms animados se dibuja mientras estos se mueven.
#Rust
shader() (de nsh_core::element) crea una vista de shader; .source(program) le da un Shader compilado (nsh_core::shader::Shader::compile(source), que devuelve los mismos errores que se describieron antes). Compila un programa una vez y compártelo: clonarlo es barato. El programa puede ser enlazable, .source(move || …), para cambiar de programa.
| Método | Descripción |
|---|---|
.uniform(name, value) |
Fija un miembro de struct Uniforms: un número, un Color o Hsla, [f32; 2] / [f32; 3] / [f32; 4], o un Vec<UniformValue>. Un valor enlazado (move || …) lo fija de nuevo cuando cambia; un valor animado (&wave: un número, o colores para un vec3f / vec4f) lo mueve en cada fotograma, de forma nativa. |
.paused(bool) |
Detiene el reloj de nsh.time; enlazable. |
Los hijos se añaden con .child(..), los estilos con los métodos tipados habituales. shader_effect(), shader_transition(), screen_effect() y screen_transition() crean los demás elementos, con los métodos de las tablas anteriores.