요소
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).
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 />);#The shader
A shader’s source is WGSL, the shading language of WebGPU. It defines one function:
fn shade(uv: vec2f) -> vec4f
uvis 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-in | Type | Value |
|---|---|---|
nsh.size | vec2f | The view’s size in points. |
nsh.time | f32 | Seconds since the shader appeared. A shader that reads it animates: it draws every frame while it is on screen. |
nsh.scale | f32 | Pixels 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 type | Values |
|---|---|
f32, i32, u32 | A number (rounded toward zero for i32 and u32), or an animated value |
vec2f | [x, y]: two numbers or animated values |
vec3f | A color (its red, green and blue), an animated color, or three numbers |
vec4f | A 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))
}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 }} />;
}#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:
| Function | Reads |
|---|---|
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("…"))
}<NavPage title="Details" transitionShader={{ source: RIPPLE, duration: 500 }}>…</NavPage>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:
| Prop | Rust | Type | Description |
|---|---|---|---|
source | .source(program) | string | The 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) | boolean | Stops the clock of nsh.time. Default false. |
ShaderEffect and ScreenEffect take those (source optional) plus:
| Prop | Rust | Type | Description |
|---|---|---|---|
progress | .progress(n), .animated_progress(&value) | number | AnimatedNode | nsh.progress. Default 0. |
maxSampleOffset | .max_sample_offset(px(n)) | number | How 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:
| Prop | Rust | Type | Description |
|---|---|---|---|
value | .value(key) | string | number | boolean | When it changes, the transition runs (not for the first value). |
duration | .duration(Duration) | number | How long it runs, in milliseconds. Default 300. |
fallback | .fallback(TransitionFallback) | "fade" | "none" | What runs without a source. Default "fade". |
NavPage and Dialog take:
| Prop | Rust | Type | Description |
|---|---|---|---|
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
shadehas exactly the signaturefn shade(uv: vec2f) -> vec4f. The source declares its own functions, constants, structs andvar<private>variables; NSH supplies the entry points and the bindings.- The source is at most 64 KiB.
struct Uniformstakes 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.
| Method | Description |
|---|---|
.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.