이 페이지는 아직 번역되지 않았습니다. 영어 원문 보기

요소

Shader

A shader is a view whose box a GPU program draws: gradients that flow, noise and grain, plasma, aurora, holographic foil, ripples that follow a tap. You write one WGSL function, shade, that gives the color of each point of the box; the platform runs it on the GPU at the screen’s resolution, over the view’s background and under its children. Animated values move it natively, frame by frame, with no work in the app’s code. TSX makes one with <Shader> from nsh, Rust with 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))
}

#The shader

A shader’s source is WGSL, the shading language of WebGPU. It defines one function:

fn shade(uv: vec2f) -> vec4f
  • uv is the point of the view’s box, from (0, 0) at its top left corner to (1, 1) at its bottom right, y growing downwards as in CSS.
  • The result is the point’s color: red, green, blue and alpha in 0..1, in sRGB with straight alpha, as CSS colors are. Values outside 0..1 are clamped.

Next to shade the source holds whatever helper functions, constants and var<private> variables it needs. NSH supplies the entry points and reads the shader’s inputs from two places.

#Built-ins

Built-inTypeValue
nsh.sizevec2fThe view’s size in points.
nsh.timef32Seconds since the shader appeared. A shader that reads it animates: it draws every frame while it is on screen.
nsh.scalef32Pixels per point of the frame being drawn.

uv * nsh.size is the point in points; nsh.size.x / nsh.size.y is the aspect ratio, which keeps circles round in a box that is not square.

#Uniforms

Values the app sets go in a struct named Uniforms, which the shader reads as uniforms:

struct Uniforms {
  colors: array<vec4f, 4>,
  speed: f32,
  center: vec2f,
}

fn shade(uv: vec2f) -> vec4f {
  let t = nsh.time * uniforms.speed;
  // …
}

The uniforms prop sets its members by name. Each type takes these values:

WGSL typeValues
f32, i32, u32A number (rounded toward zero for i32 and u32), or an animated value
vec2f[x, y]: two numbers or animated values
vec3fA color (its red, green and blue), an animated color, or three numbers
vec4fA color with its alpha, an animated color, or four numbers
array<vec4f, N>A list of up to N colors (or of four numbers each): a palette, the stops of a gradient

Colors are any CSS color ("#38bdf8", "rgb(56 189 248)", "hsl(199 93% 60%)") and reach the shader as sRGB components in 0..1. Members the app leaves out are zero. Setting uniforms again draws the shader again with the new values.

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

#Animated uniforms

A uniform may be a native animated value, or a node derived from one (interpolate, arithmetic). The platform writes its value into the shader on every frame it changes and draws again, on the UI thread: the animation runs at full frame rate however busy the app’s code is. Numbers animate f32, i32 and u32 members and the components of a vec2f; an interpolation to colors animates a vec3f or 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))
}

#Pausing

paused stops the shader’s clock: nsh.time keeps its value and the shader holds its picture. Setting it back to false starts the clock from where it stopped. Uniforms and size changes still draw a paused shader.

const paused = signal(false);

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

A shader that does not read nsh.time draws when its size or its uniforms change, and holds its picture in between.

#Children and clipping

A shader is a container: it takes every prop of a view (styles, padding and layout of its children, press and gesture props, onLayout, accessibility, ref), and its children draw over the shader. The shader fills the view’s box the way a background does, inside its rounded corners (borderRadius), over its 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>

Give the shader a size through its styles (width / height, flex, an aspect ratio) or let its children size it.

#Effects, transitions and the screen

A shader can also draw what other views draw. Its WGSL then reads pictures through four functions, each taking a point uv of its box and returning a straight sRGB color with alpha, transparent outside the picture:

FunctionReads
sample_content(uv)an effect’s children, or the whole surface for a screen effect
sample_backdrop(uv)what is behind the view (any shader may read it)
sample_from(uv)a transition’s side it leaves
sample_to(uv)a transition’s side it comes to

nsh.progress is an effect’s progress or how far a transition is (0 to 1), and nsh.overflow how far beyond the box the shader draws, in points.

ShaderEffect draws its children through the shader: they keep their presses, focus and accessibility, and what shows is the shader’s result. progress is a number or an animated value, which moves the effect natively. maxSampleOffset lets the shader draw that many points beyond the box (a glow, a blur’s edge); its uv then goes below 0 and above 1. Without source the children draw as they are.

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 changes its children through the shader when value (a string, number or boolean) changes, not for its first value: sample_from is a picture of what they drew before, sample_to what they draw now, and nsh.progress goes from 0 to 1 over duration milliseconds (300). Without source it runs fallback: "fade" (a cross-fade, the default) or "none".

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

ScreenEffect and ScreenTransition do the same for the whole surface (its pages, dialogs and overlays). They take no space and have no children; put them anywhere in the app. The last screen effect mounted with a source draws.

Pages and dialogs come and go through a shader with transitionShader: { source, uniforms, duration } on a NavPage or a Dialog (Rust: .transition_shader(TransitionShader::new(program)) on nav_page() or dialog()). When the page is pushed or popped, or the dialog shown or closed, the lower side (the page under it, the app without the dialog) is sample_from and the upper sample_to; nsh.progress goes from 0 to 1 as the page or the dialog comes and from 1 to 0 as it goes, so one shader draws both ways.

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

The shader takes the place of the page’s or the dialog’s animation, whatever it is ("none" too). The animation runs on a device without a GPU, when the user asks for less motion (Reduce Motion, Remove animations), and for a source that does not compile; with "none" the change then shows at once. A push, pop, show or close that starts while one runs completes that one first: its page or dialog arrives or leaves, its onShow or onDismiss fires, and the new change starts from there.

The platforms take the pictures when what they show changes, at most once per display frame. A picture is drawn by the platform’s own renderer, so what it cannot draw into a picture (a video, a web page, another app’s view) shows transparent in it.

#Props

A shader takes the props of a view plus:

PropRustTypeDescription
source.source(program)stringThe WGSL source defining fn shade(uv: vec2f) -> vec4f. Rust compiles it with Shader::compile(source).
uniforms.uniform(name, value)Record<string, ShaderUniform>The values of the members of struct Uniforms, by name: numbers, colors, animated values and lists of them.
paused.paused(bool)booleanStops the clock of nsh.time. Default false.

ShaderEffect and ScreenEffect take those (source optional) plus:

PropRustTypeDescription
progress.progress(n), .animated_progress(&value)number | AnimatedNodensh.progress. Default 0.
maxSampleOffset.max_sample_offset(px(n))numberHow far beyond the box, in points, the shader draws and its pictures reach (ShaderEffect and ShaderTransition). Default 0.

ShaderTransition and ScreenTransition take source, uniforms and paused plus:

PropRustTypeDescription
value.value(key)string | number | booleanWhen it changes, the transition runs (not for the first value).
duration.duration(Duration)numberHow long it runs, in milliseconds. Default 300.
fallback.fallback(TransitionFallback)"fade" | "none"What runs without a source. Default "fade".

NavPage and Dialog take:

PropRustTypeDescription
transitionShader.transition_shader(TransitionShader::new(program)), .uniform(name, value), .duration(Duration){ source: string; uniforms?: Record<string, ShaderUniform>; duration?: number }The shader the page or dialog comes and goes through in place of its animation. duration in milliseconds, 350 by default.

#Errors

NSH checks every shader when its source is set, before it reaches the GPU. A shader that does not compile logs a warning with the line and column in its source and what is wrong, for example:

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

The view then shows its background and children. A uniform the struct lacks, or a value its type does not take, logs a warning naming the uniform, and the other uniforms apply.

Each source compiles once: views that share a source, and a source set again, use the compiled shader.

#Rules and limits

  • shade has exactly the signature fn shade(uv: vec2f) -> vec4f. The source declares its own functions, constants, structs and var<private> variables; NSH supplies the entry points and the bindings.
  • The source is at most 64 KiB.
  • struct Uniforms takes at most 4096 bytes, with members of the types in the table above.
  • A frame is at most 4096 pixels a side: a larger view draws its shader at a lower scale.

For noise, hash the integer cell a point is in (bitcast<vec2u>(vec2i(floor(p))) mixed with multiplies and xor-shifts) rather than fract(sin(…) * 43758.5): integer hashes give the same value for a cell everywhere it is computed, so value noise stays seamless on every GPU. The gallery’s shaders show one.

#Platforms

On iOS, Android, macOS, Windows and Linux, and in GPUI apps, NSH renders shaders on the GPU with wgpu (Metal on Apple platforms, Vulkan, Direct3D 12 or OpenGL elsewhere) at the screen’s scale, and shows each frame under the view’s children. Rendering runs alongside the UI: a frame appears one or two display frames after it is drawn, and the UI stays responsive while the GPU works. On the web, NSH translates the shader to GLSL and draws it with WebGL 2.

Shaders draw at most once per display frame, and only when what they show changes: a shader with fixed uniforms draws once, one that reads nsh.time every frame, and one with animated uniforms while they move.

#Rust

shader() (from nsh_core::element) makes a shader view; .source(program) gives it a compiled Shader (nsh_core::shader::Shader::compile(source), which returns the same errors as above). Compile a program once and share it: it is cheap to clone. The program may be bound, .source(move || …), to switch programs.

MethodDescription
.uniform(name, value)Sets a member of struct Uniforms: a number, a Color or Hsla, [f32; 2] / [f32; 3] / [f32; 4], or a Vec<UniformValue>. A bound value (move || …) sets it again when it changes; an animated value (&wave: a number, or colors for a vec3f / vec4f) moves it on every frame, natively.
.paused(bool)Stops the clock of nsh.time; bindable.

Children are added with .child(..), styles with the usual typed methods. shader_effect(), shader_transition(), screen_effect() and screen_transition() make the other elements, with the methods in the tables above.