Questa pagina è stata tradotta automaticamente e può contenere errori. Leggi l'originale in inglese

Elementi

Shader

Uno shader è una view il cui riquadro viene disegnato da un programma GPU: gradienti che scorrono, rumore e grana, plasma, aurora, lamina olografica, onde che seguono un tap. Si scrive una sola funzione WGSL, shade, che restituisce il colore di ogni punto del riquadro; la piattaforma la esegue sulla GPU alla risoluzione dello schermo, sopra lo sfondo della view e sotto i suoi figli. I valori animati lo muovono in modo nativo, frame per frame, senza alcun lavoro nel codice dell’app. In TSX se ne crea uno con <Shader> da nsh, in Rust con shader().source(program).

el-shader.tsx
el-shader.tsxLive
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))
}

#Lo shader

Il sorgente di uno shader è WGSL, il linguaggio di shading di WebGPU. Definisce una funzione:

fn shade(uv: vec2f) -> vec4f
  • uv è il punto del riquadro della view, da (0, 0) nell’angolo in alto a sinistra a (1, 1) in basso a destra, con la y che cresce verso il basso come in CSS.
  • Il risultato è il colore del punto: rosso, verde, blu e alpha in 0..1, in sRGB con alpha straight, come i colori CSS. I valori fuori da 0..1 vengono limitati (clamped).

Accanto a shade il sorgente contiene le funzioni di supporto, le costanti e le variabili var<private> che gli servono. NSH fornisce gli entry point e legge gli input dello shader da due posti.

#Built-in

Built-inTipoValore
nsh.sizevec2fLa dimensione della view in punti.
nsh.timef32Secondi trascorsi da quando lo shader è apparso. Uno shader che lo legge si anima: disegna a ogni frame finché è sullo schermo.
nsh.scalef32Pixel per punto del frame che viene disegnato.

uv * nsh.size è il punto in punti; nsh.size.x / nsh.size.y è il rapporto d’aspetto, che mantiene i cerchi tondi in un riquadro non quadrato.

#Uniform

I valori impostati dall’app vanno in una struct chiamata Uniforms, che lo shader legge come 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 ne imposta i membri per nome. Ogni tipo accetta questi valori:

Tipo WGSLValori
f32, i32, u32Un numero (arrotondato verso lo zero per i32 e u32), o un valore animato
vec2f[x, y]: due numeri o valori animati
vec3fUn colore (il suo rosso, verde e blu), un colore animato, o tre numeri
vec4fUn colore con il suo alpha, un colore animato, o quattro numeri
array<vec4f, N>Un elenco di al massimo N colori (o di quattro numeri ciascuno): una palette, i punti di un gradiente

I colori possono essere qualsiasi colore CSS ("#38bdf8", "rgb(56 189 248)", "hsl(199 93% 60%)") e arrivano allo shader come componenti sRGB in 0..1. I membri che l’app omette valgono zero. Impostare di nuovo uniforms ridisegna lo shader con i nuovi valori.

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 }} />;

#Uniform animate

Una uniform può essere un valore animato nativo, o un nodo derivato da uno (interpolate, aritmetica). La piattaforma scrive il suo valore nello shader a ogni frame in cui cambia e ridisegna, sul thread UI: l’animazione gira a frame rate pieno per quanto sia impegnato il codice dell’app. I numeri animano i membri f32, i32 e u32 e le componenti di un vec2f; un’interpolazione verso colori anima un vec3f o un 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))
}

#Pausa

paused ferma l’orologio dello shader: nsh.time mantiene il suo valore e lo shader mantiene la sua immagine. Reimpostarlo a false fa ripartire l’orologio da dove si era fermato. Le uniform e i cambi di dimensione ridisegnano comunque uno shader in pausa.

const paused = signal(false);

<Shader source={AURORA} paused={paused()} style={{ height: 200 }} onPress={() => paused.set(!paused())} />;

Uno shader che non legge nsh.time si ridisegna quando cambiano la sua dimensione o le sue uniform, e nel frattempo mantiene la sua immagine.

#Figli e ritaglio

Uno shader è un contenitore: accetta ogni prop di una view (stili, padding e layout dei figli, prop di pressione e di gesti, onLayout, accessibilità, ref), e i suoi figli si disegnano sopra lo shader. Lo shader riempie il riquadro della view come fa uno sfondo, dentro gli angoli arrotondati (borderRadius), sopra il suo 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>

Dai allo shader una dimensione tramite gli stili (width / height, flex, un aspect ratio) oppure lascia che lo dimensionino i figli.

#Effetti, transizioni e schermo

Uno shader può anche disegnare ciò che disegnano altre view. Il suo WGSL legge allora le immagini tramite quattro funzioni, ognuna delle quali riceve un punto uv del suo riquadro e restituisce un colore sRGB straight con alpha, trasparente fuori dall’immagine:

FunzioneLegge
sample_content(uv)i figli di un effetto, o l’intera superficie per un effetto schermo
sample_backdrop(uv)ciò che sta dietro la view (qualsiasi shader può leggerlo)
sample_from(uv)il lato di una transizione che sta lasciando
sample_to(uv)il lato di una transizione a cui sta arrivando

nsh.progress è il progress di un effetto o a che punto si trova una transizione (da 0 a 1), e nsh.overflow è quanto oltre il riquadro disegna lo shader, in punti.

ShaderEffect disegna i suoi figli attraverso lo shader: i figli mantengono pressioni, focus e accessibilità, e ciò che appare è il risultato dello shader. progress è un numero o un valore animato, che muove l’effetto in modo nativo. maxSampleOffset permette allo shader di disegnare altrettanti punti oltre il riquadro (un bagliore, il bordo di una sfocatura); il suo uv scende allora sotto 0 e sale sopra 1. Senza source i figli si disegnano così come sono.

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 modifica i suoi figli attraverso lo shader quando value (una stringa, un numero o un booleano) cambia, non per il suo primo valore: sample_from è un’immagine di ciò che i figli disegnavano prima, sample_to ciò che disegnano ora, e nsh.progress va da 0 a 1 in duration millisecondi (300). Senza source esegue fallback: "fade" (una dissolvenza incrociata, il default) o "none".

<ShaderTransition source={WIPE} value={card()} duration={450}>
  <Card name={card()} />
</ShaderTransition>

ScreenEffect e ScreenTransition fanno lo stesso per l’intera superficie (le sue pagine, i dialog e gli overlay). Non occupano spazio e non hanno figli; mettili ovunque nell’app. Disegna l’ultimo effetto schermo montato con un source.

Pagine e dialog entrano ed escono attraverso uno shader con transitionShader: { source, uniforms, duration } su un NavPage o un Dialog (Rust: .transition_shader(TransitionShader::new(program)) su nav_page() o dialog()). Quando la pagina viene aggiunta o rimossa, o il dialog viene mostrato o chiuso, il lato inferiore (la pagina sotto, l’app senza il dialog) è sample_from e quello superiore sample_to; nsh.progress va da 0 a 1 quando la pagina o il dialog arriva e da 1 a 0 quando se ne va, così un solo shader disegna in entrambe le direzioni.

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("…"))
}

Lo shader prende il posto del animation della pagina o del dialog, qualunque esso sia (anche "none"). Il animation viene eseguito su un dispositivo senza GPU, quando l’utente chiede meno movimento (Reduce Motion, Remove animations), e per un sorgente che non compila; con "none" il cambiamento appare allora subito. Un push, pop, show o close che inizia mentre un altro è in corso completa prima quello: la sua pagina o il suo dialog arriva o se ne va, scatta il suo onShow o onDismiss, e il nuovo cambiamento parte da lì.

Le piattaforme acquisiscono le immagini quando cambia ciò che mostrano, al massimo una volta per frame di display. Un’immagine è disegnata dal renderer della piattaforma stessa, quindi ciò che questo non può disegnare in un’immagine (un video, una pagina web, la view di un’altra app) appare trasparente al suo interno.

#Prop

Uno shader accetta le prop di una view più:

PropRustTipoDescrizione
source.source(program)stringIl sorgente WGSL che definisce fn shade(uv: vec2f) -> vec4f. Rust lo compila con Shader::compile(source).
uniforms.uniform(name, value)Record<string, ShaderUniform>I valori dei membri di struct Uniforms, per nome: numeri, colori, valori animati ed elenchi di questi.
paused.paused(bool)booleanFerma l’orologio di nsh.time. Default false.

ShaderEffect e ScreenEffect accettano queste (source opzionale) più:

PropRustTipoDescrizione
progress.progress(n), .animated_progress(&value)number | AnimatedNodensh.progress. Default 0.
maxSampleOffset.max_sample_offset(px(n))numberQuanto oltre il riquadro, in punti, disegna lo shader e arrivano le sue immagini (ShaderEffect e ShaderTransition). Default 0.

ShaderTransition e ScreenTransition accettano source, uniforms e paused più:

PropRustTipoDescrizione
value.value(key)string | number | booleanQuando cambia, la transizione parte (non per il primo valore).
duration.duration(Duration)numberQuanto dura, in millisecondi. Default 300.
fallback.fallback(TransitionFallback)"fade" | "none"Cosa viene eseguito senza source. Default "fade".

NavPage e Dialog accettano:

PropRustTipoDescrizione
transitionShader.transition_shader(TransitionShader::new(program)), .uniform(name, value), .duration(Duration){ source: string; uniforms?: Record<string, ShaderUniform>; duration?: number }Lo shader attraverso cui la pagina o il dialog entra ed esce al posto del suo animation. duration in millisecondi, 350 per default.

#Errori

NSH controlla ogni shader quando ne viene impostato il sorgente, prima che raggiunga la GPU. Uno shader che non compila registra un avviso con la riga e la colonna nel sorgente e ciò che non va, ad esempio:

the shader of node 12: 2:1: `shade` takes the point's `uv: vec2f` and returns its color, a `vec4f`

La view mostra allora il suo sfondo e i suoi figli. Una uniform che manca nella struct, o un valore che il suo tipo non accetta, registra un avviso con il nome della uniform, e le altre uniform vengono applicate.

Ogni sorgente viene compilato una sola volta: le view che condividono un sorgente, e un sorgente impostato di nuovo, usano lo shader compilato.

#Regole e limiti

  • shade ha esattamente la firma fn shade(uv: vec2f) -> vec4f. Il sorgente dichiara le proprie funzioni, costanti, struct e variabili var<private>; NSH fornisce gli entry point e i binding.
  • Il sorgente è di al massimo 64 KiB.
  • struct Uniforms occupa al massimo 4096 byte, con membri dei tipi indicati nella tabella sopra.
  • Un frame è di al massimo 4096 pixel per lato: una view più grande disegna il proprio shader a una scala inferiore.

Per il rumore, fai l’hash della cella intera in cui si trova un punto (bitcast<vec2u>(vec2i(floor(p))) mescolato con moltiplicazioni e xor-shift) invece di fract(sin(…) * 43758.5): gli hash interi danno lo stesso valore per una cella ovunque vengano calcolati, così il value noise resta senza giunture su ogni GPU. Gli shader della galleria ne mostrano uno.

#Piattaforme

Su iOS, Android, macOS, Windows e Linux, e nelle app GPUI, NSH renderizza gli shader sulla GPU con wgpu (Metal sulle piattaforme Apple, Vulkan, Direct3D 12 o OpenGL altrove) alla scala dello schermo, e mostra ogni frame sotto i figli della view. Il rendering procede in parallelo alla UI: un frame appare uno o due frame di display dopo essere stato disegnato, e la UI resta reattiva mentre la GPU lavora. Sul web, NSH traduce lo shader in GLSL e lo disegna con WebGL 2.

Gli shader disegnano al massimo una volta per frame di display, e solo quando cambia ciò che mostrano: uno shader con uniform fisse disegna una volta, uno che legge nsh.time disegna a ogni frame, e uno con uniform animate disegna finché queste si muovono.

#Rust

shader() (da nsh_core::element) crea una view shader; .source(program) le assegna un Shader compilato (nsh_core::shader::Shader::compile(source), che restituisce gli stessi errori visti sopra). Compila un programma una sola volta e condividilo: è economico da clonare. Il programma può essere associato (bound), .source(move || …), per cambiare programma.

MetodoDescrizione
.uniform(name, value)Imposta un membro di struct Uniforms: un numero, un Color o Hsla, [f32; 2] / [f32; 3] / [f32; 4], o un Vec<UniformValue>. Un valore associato (move || …) lo reimposta quando cambia; un valore animato (&wave: un numero, o colori per un vec3f / vec4f) lo muove a ogni frame, in modo nativo.
.paused(bool)Ferma l’orologio di nsh.time; associabile.

I figli si aggiungono con .child(..), gli stili con i consueti metodi tipizzati. shader_effect(), shader_transition(), screen_effect() e screen_transition() creano gli altri elementi, con i metodi delle tabelle sopra.