本頁由機器翻譯,可能有錯誤。 閱讀英文原文

元素

Shader

Shader 是一種視圖,其範圍由 GPU 程式繪製:流動的漸層、雜訊與顆粒、電漿、極光、全像箔紙,以及跟隨點按的漣漪。你只需撰寫一個 WGSL 函式 shade,它會給出視圖範圍內每個點的顏色;平台以螢幕解析度在 GPU 上執行它,繪製於視圖的背景之上、子元件之下。動畫值會以原生方式逐幀驅動它,不需要應用程式碼額外處理。TSX 使用 nsh 中的 <Shader> 建立,Rust 則使用 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))
}

#著色器

著色器的原始碼是 WGSL,也就是 WebGPU 的著色語言。它定義一個函式:

fn shade(uv: vec2f) -> vec4f
  • uv 是視圖範圍內的點,從左上角的 (0, 0) 到右下角的 (1, 1),y 軸向下增長,與 CSS 相同。
  • 回傳值是該點的顏色:紅、綠、藍與 alpha,範圍為 0..1,採用 sRGB 與直通 alpha(straight alpha),與 CSS 顏色相同。超出 0..1 的值會被截斷。

除了 shade,原始碼還可以包含所需的任何輔助函式、常數與 var<private> 變數。NSH 會提供進入點,並從兩個地方讀取著色器的輸入。

#內建值

內建值型別值
nsh.sizevec2f視圖的大小,單位為 point。
nsh.timef32自著色器出現以來經過的秒數。讀取它的著色器會產生動畫:只要在螢幕上,就會逐幀繪製。
nsh.scalef32正在繪製的影格中,每個 point 對應的像素數。

uv * nsh.size 是以 point 為單位的點;nsh.size.x / nsh.size.y 是長寬比,可讓圓形在非正方形的範圍內保持正圓。

#Uniforms

由應用程式設定的值放在名為 Uniforms 的結構中,著色器以 uniforms 讀取:

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

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

uniforms 屬性會依名稱設定其成員。各型別接受的值如下:

WGSL 型別值
f32、i32、u32一個數字(i32 與 u32 會向零捨入),或動畫值
vec2f[x, y]:兩個數字或動畫值
vec3f一個顏色(取其紅、綠、藍)、動畫顏色,或三個數字
vec4f含 alpha 的顏色、動畫顏色,或四個數字
array<vec4f, N>最多 N 個顏色的清單(或每個為四個數字的清單):調色盤、漸層的色標

顏色可以是任何 CSS 顏色("#38bdf8"、"rgb(56 189 248)"、"hsl(199 93% 60%)"),並以 0..1 的 sRGB 分量傳入著色器。應用程式未提供的成員為零。再次設定 uniforms 會以新值重新繪製著色器。

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

uniform 可以是原生動畫值,或由其衍生的節點(interpolate、算術運算)。每當值改變時,平台會在 UI 執行緒上把它寫入著色器並重新繪製:無論應用程式碼多麼繁忙,動畫都以完整影格率執行。數字可驅動 f32、i32 與 u32 成員,以及 vec2f 的各分量;對顏色的插值則可驅動 vec3f 或 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))
}

#暫停

paused 會停止著色器的時鐘:nsh.time 保持原值,著色器維持目前的畫面。將它設回 false 後,時鐘會從停止處繼續。uniforms 與尺寸的變更仍會讓已暫停的著色器重新繪製。

const paused = signal(false);

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

不讀取 nsh.time 的著色器只會在尺寸或 uniforms 改變時繪製,其餘時間保持原畫面。

#子元件與裁切

Shader 是一個容器:它接受視圖的所有屬性(樣式、子元件的內距與版面配置、按壓與手勢屬性、onLayout、無障礙、ref),其子元件繪製在著色器之上。著色器如同背景一般填滿視圖的範圍,位於圓角(borderRadius)之內、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>

請透過樣式為著色器指定大小(寬 / 高、flex、長寬比),或讓其子元件決定其大小。

#效果、轉場與螢幕

著色器也可以繪製其他視圖所繪製的內容。此時它的 WGSL 透過四個函式讀取圖片,每個函式接受其範圍內的點 uv,並回傳直通 sRGB 顏色與 alpha,圖片之外則為透明:

函式讀取內容
sample_content(uv)效果的子元件,或螢幕效果的整個表面
sample_backdrop(uv)視圖後方的內容(任何著色器都可以讀取)
sample_from(uv)轉場中離開的一側
sample_to(uv)轉場中進入的一側

nsh.progress 是效果的 progress,或轉場的進度(0 到 1);nsh.overflow 是著色器在範圍之外繪製的距離,單位為 point。

ShaderEffect 透過著色器繪製其子元件:子元件保留按壓、焦點與無障礙功能,而顯示的是著色器的結果。progress 是數字或動畫值,會以原生方式驅動效果。maxSampleOffset 讓著色器在範圍之外繪製相應的 point 數(光暈、模糊的邊緣);此時它的 uv 會低於 0 並高於 1。沒有 source 時,子元件按原樣繪製。

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 會在 value(字串、數字或布林值)改變時,透過著色器轉換其子元件,第一個值不會觸發:sample_from 是它們先前繪製內容的圖片,sample_to 是它們現在繪製的內容,nsh.progress 在 duration 毫秒(300)內從 0 變為 1。沒有 source 時,它執行 fallback:"fade"(交叉淡化,預設值)或 "none"。

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

ScreenEffect 與 ScreenTransition 對整個表面(其頁面、對話框與浮層)執行相同的操作。它們不佔空間也沒有子元件;可以放在應用程式的任何位置。最後一個掛載且帶有 source 的螢幕效果會被繪製。

頁面與對話框可以透過 transitionShader 以著色器出現與消失:在 NavPage 或 Dialog 上使用 { source, uniforms, duration }(Rust:在 nav_page() 或 dialog() 上使用 .transition_shader(TransitionShader::new(program)))。當頁面被推入或彈出,或對話框顯示或關閉時,下層(其下方的頁面、沒有對話框的應用程式)是 sample_from,上層是 sample_to;頁面或對話框出現時 nsh.progress 從 0 變為 1,消失時從 1 變為 0,因此同一個著色器可以處理兩個方向。

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

著色器會取代頁面或對話框的 animation,不論它是什麼(包括 "none")。在沒有 GPU 的裝置上、使用者要求減少動態效果(Reduce Motion、Remove animations)時,以及原始碼無法編譯時,會使用 animation;若搭配 "none",變更會立即顯示。若在一個轉場執行期間又開始了新的推入、彈出、顯示或關閉,會先完成前一個:其頁面或對話框抵達或離開,其 onShow 或 onDismiss 觸發,然後新的變更從那裡開始。

平台會在所顯示內容改變時擷取圖片,每個顯示影格最多一次。圖片由平台自己的渲染器繪製,因此它無法繪入圖片的內容(影片、網頁、其他應用程式的視圖)在其中會顯示為透明。

#屬性

Shader 接受視圖的屬性,另加:

屬性Rust型別說明
source.source(program)string定義 fn shade(uv: vec2f) -> vec4f 的 WGSL 原始碼。Rust 使用 Shader::compile(source) 編譯。
uniforms.uniform(name, value)Record<string, ShaderUniform>struct Uniforms 各成員的值,依名稱指定:數字、顏色、動畫值,以及它們的清單。
paused.paused(bool)boolean停止 nsh.time 的時鐘。預設為 false。

ShaderEffect 與 ScreenEffect 接受上述屬性(source 為選用),另加:

屬性Rust型別說明
progress.progress(n)、.animated_progress(&value)number | AnimatedNodensh.progress。預設為 0。
maxSampleOffset.max_sample_offset(px(n))number著色器及其圖片在範圍之外延伸的距離,單位為 point(ShaderEffect 與 ShaderTransition)。預設為 0。

ShaderTransition 與 ScreenTransition 接受 source、uniforms 與 paused,另加:

屬性Rust型別說明
value.value(key)string | number | boolean它改變時,轉場便會執行(第一個值不會)。
duration.duration(Duration)number執行時間,單位為毫秒。預設為 300。
fallback.fallback(TransitionFallback)"fade" | "none"沒有 source 時執行的內容。預設為 "fade"。

NavPage 與 Dialog 接受:

屬性Rust型別說明
transitionShader.transition_shader(TransitionShader::new(program))、.uniform(name, value)、.duration(Duration){ source: string; uniforms?: Record<string, ShaderUniform>; duration?: number }頁面或對話框出現與消失時所透過的著色器,取代其 animation。duration 以毫秒為單位,預設為 350。

#錯誤

NSH 在設定每個著色器的原始碼時、在其到達 GPU 之前就會進行檢查。無法編譯的著色器會記錄一則警告,包含其原始碼中的行號與欄號以及錯誤內容,例如:

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

此時視圖會顯示其背景與子元件。若結構中沒有某個 uniform,或某個值不是其型別所接受的,會記錄一則指出該 uniform 的警告,其他 uniforms 仍會套用。

每份原始碼只會編譯一次:共用同一份原始碼的視圖,以及重新設定的原始碼,都會使用已編譯的著色器。

#規則與限制

  • shade 的簽章必須恰為 fn shade(uv: vec2f) -> vec4f。原始碼可自行宣告函式、常數、結構與 var<private> 變數;NSH 提供進入點與繫結。
  • 原始碼最大為 64 KiB。
  • struct Uniforms 最多 4096 位元組,其成員須為上表中的型別。
  • 一個影格每邊最多 4096 像素:更大的視圖會以較低的比例繪製其著色器。

對於雜訊,請對點所在的整數格(bitcast<vec2u>(vec2i(floor(p))),以乘法與 xor-shift 混合)進行雜湊,而不要使用 fract(sin(…) * 43758.5):整數雜湊在任何計算位置都會對同一個格給出相同的值,因此數值雜訊在每種 GPU 上都能保持無縫。圖庫中的著色器有相關示範。

#平台

在 iOS、Android、macOS、Windows 與 Linux,以及 GPUI 應用程式中,NSH 使用 wgpu 在 GPU 上渲染著色器(Apple 平台使用 Metal,其他平台使用 Vulkan、Direct3D 12 或 OpenGL),依螢幕比例繪製,並將每個影格顯示在視圖的子元件之下。渲染與 UI 並行:影格在繪製後一到兩個顯示影格出現,GPU 運作時 UI 仍保持回應。在 Web 上,NSH 會把著色器轉譯為 GLSL,並以 WebGL 2 繪製。

著色器每個顯示影格最多繪製一次,且只在所顯示內容改變時繪製:uniforms 固定的著色器只繪製一次,讀取 nsh.time 的著色器每個影格都繪製,使用動畫 uniforms 的著色器則在它們變動期間繪製。

#Rust

shader()(來自 nsh_core::element)建立一個著色器視圖;.source(program) 為它提供已編譯的 Shader(nsh_core::shader::Shader::compile(source),其回傳的錯誤與上述相同)。請只編譯程式一次並共用它:複製的成本很低。程式可以是繫結的 .source(move || …),以便切換程式。

方法說明
.uniform(name, value)設定 struct Uniforms 的成員:數字、Color 或 Hsla、[f32; 2] / [f32; 3] / [f32; 4],或 Vec<UniformValue>。繫結值(move || …)會在改變時重新設定;動畫值(&wave:數字,或用於 vec3f / vec4f 的顏色)會以原生方式逐幀驅動它。
.paused(bool)停止 nsh.time 的時鐘;可繫結。

子元件以 .child(..) 新增,樣式則使用常見的具型別方法。shader_effect()、shader_transition()、screen_effect() 與 screen_transition() 建立其他元素,其方法見上表。