تُرجمت هذه الصفحة آليًا وقد تحتوي على أخطاء. اقرأ الأصل الإنجليزي

العناصر

Shader

الـ Shader هو View يرسم برنامجُ GPU صندوقَه: تدرجات لونية متدفقة، وضوضاء وحبيبات، وبلازما، وشفق قطبي، ورقائق هولوغرامية، وتموجات تتبع النقر. تكتب دالة WGSL واحدة، shade، تعطي لون كل نقطة من الصندوق؛ وتشغّلها المنصة على GPU بدقة الشاشة، فوق خلفية الـ View وتحت أبنائه. تحرّكها القيم المتحركة أصلياً، إطاراً بإطار، دون أي عمل في شيفرة التطبيق. في TSX تُنشئه بـ <Shader> من nsh، وفي 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))
}

#الـ Shader

مصدر الـ Shader هو WGSL، لغة التظليل الخاصة بـ WebGPU. وهو يعرّف دالة واحدة:

fn shade(uv: vec2f) -> vec4f
  • uv هي نقطة من صندوق الـ View، من (0, 0) عند زاويته العلوية اليسرى إلى (1, 1) عند زاويته السفلية اليمنى، ويزداد y نحو الأسفل كما في CSS.
  • النتيجة هي لون النقطة: الأحمر والأخضر والأزرق وقناة ألفا بقيم من 0 إلى 1، في فضاء sRGB مع ألفا مباشرة (straight alpha)، كما هي ألوان CSS. تُقصّ القيم خارج النطاق من 0 إلى 1.

إلى جانب shade يحتوي المصدر على ما يلزمه من دوال مساعدة وثوابت ومتغيرات var<private>. يوفّر NSH نقاط الدخول، ويقرأ مدخلات الـ Shader من مكانين.

#المتغيرات المدمجة

المتغير المدمجالنوعالقيمة
nsh.sizevec2fحجم الـ View بالنقاط.
nsh.timef32الثواني منذ ظهور الـ Shader. الـ Shader الذي يقرؤه يتحرك: يرسم في كل إطار ما دام على الشاشة.
nsh.scalef32عدد البكسلات لكل نقطة في الإطار الجاري رسمه.

uv * nsh.size هي النقطة بالنقاط؛ وnsh.size.x / nsh.size.y هي نسبة العرض إلى الارتفاع، وتُبقي الدوائر مستديرة في صندوق غير مربع.

#المتغيرات الموحّدة (Uniforms)

تُوضع القيم التي يحددها التطبيق في بنية (struct) باسم Uniforms، يقرؤها الـ Shader بوصفها 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لون مع ألفا، أو لون متحرك، أو أربعة أرقام
array<vec4f, N>قائمة تضم حتى N لوناً (أو أربعة أرقام لكل منها): لوحة ألوان، أو نقاط توقف تدرج لوني

الألوان هي أي لون CSS ("#38bdf8"، "rgb(56 189 248)"، "hsl(199 93% 60%)") وتصل إلى الـ Shader بمكونات sRGB بقيم من 0 إلى 1. الأعضاء التي يتركها التطبيق تكون صفراً. تعيين uniforms مرة أخرى يعيد رسم الـ Shader بالقيم الجديدة.

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

#المتغيرات الموحّدة المتحركة

قد يكون المتغير الموحّد قيمة متحركة أصلية، أو عقدة مشتقة منها (interpolate، عمليات حسابية). تكتب المنصة قيمتها في الـ Shader في كل إطار تتغير فيه، وتعيد الرسم، على خيط واجهة المستخدم: يعمل التحريك بأقصى معدل إطارات مهما كانت شيفرة التطبيق مشغولة. تحرّك الأرقام الأعضاء f32 وi32 وu32 ومكونات vec2f؛ والاستيفاء (interpolation) إلى ألوان يحرّك 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 يوقف ساعة الـ Shader: يحتفظ nsh.time بقيمته ويثبت الـ Shader صورته. وعند إعادته إلى false تبدأ الساعة من حيث توقفت. وتظل تغيّرات المتغيرات الموحّدة والحجم تعيد رسم الـ Shader الموقوف.

const paused = signal(false);

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

الـ Shader الذي لا يقرأ nsh.time يرسم عند تغيّر حجمه أو متغيراته الموحّدة، ويحتفظ بصورته فيما بينها.

#الأبناء والقص

الـ Shader حاوية: يقبل كل خصائص الـ View (الأنماط، والحشو وتخطيط أبنائه، وخصائص الضغط والإيماءات، وonLayout، وإمكانية الوصول، وref)، ويُرسم أبناؤه فوقه. يملأ الـ Shader صندوق الـ View كما تفعل الخلفية، داخل زواياه المستديرة (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>

حدّد حجماً للـ Shader عبر أنماطه (العرض / الارتفاع، flex، نسبة العرض إلى الارتفاع) أو اترك أبناءه يحددون حجمه.

#التأثيرات والانتقالات والشاشة

يمكن للـ Shader أيضاً أن يرسم ما ترسمه Views أخرى. فيقرأ WGSL الخاص به صوراً عبر أربع دوال، تأخذ كل منها نقطة uv من صندوقه وتعيد لوناً sRGB مباشراً مع ألفا، شفافاً خارج الصورة:

الدالةتقرأ
sample_content(uv)أبناء التأثير، أو السطح كله في تأثير الشاشة
sample_backdrop(uv)ما خلف الـ View (يمكن لأي Shader قراءته)
sample_from(uv)الجانب الذي يغادره الانتقال
sample_to(uv)الجانب الذي يصل إليه الانتقال

nsh.progress هي progress الخاصة بالتأثير أو مدى تقدم الانتقال (من 0 إلى 1)، وnsh.overflow هو مدى رسم الـ Shader خارج الصندوق، بالنقاط.

ShaderEffect يرسم أبناءه عبر الـ Shader: فيحتفظون بضغطاتهم وتركيزهم وإمكانية وصولهم، وما يظهر هو نتيجة الـ Shader. progress رقم أو قيمة متحركة، تحرّك التأثير أصلياً. maxSampleOffset تتيح للـ Shader أن يرسم هذا العدد من النقاط خارج الصندوق (توهج، حافة تمويه)؛ فتخرج 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 يغيّر أبناءه عبر الـ Shader عندما تتغير value (نص أو رقم أو قيمة منطقية)، وليس عند قيمتها الأولى: sample_from صورة لما رسموه قبلاً، وsample_to ما يرسمونه الآن، وnsh.progress تنتقل من 0 إلى 1 خلال duration ميلي ثانية (300). وبدون source يعمل fallback: "fade" (تلاشٍ متقاطع، وهو الافتراضي) أو "none".

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

ScreenEffect و**ScreenTransition** يفعلان الشيء نفسه للسطح كله (صفحاته وحواراته وطبقاته العلوية). لا يشغلان مساحة ولا أبناء لهما؛ ضعهما في أي مكان من التطبيق. يُرسم آخر تأثير شاشة جرى تركيبه مع source.

تأتي الصفحات والحوارات وتذهب عبر Shader باستخدام transitionShader: { source, uniforms, duration } على NavPage أو Dialog (في Rust: .transition_shader(TransitionShader::new(program)) على nav_page() أو dialog()). عند دفع الصفحة أو سحبها، أو إظهار الحوار أو إغلاقه، يكون الجانب السفلي (الصفحة التي تحتها، والتطبيق بدون الحوار) هو sample_from والعلوي هو sample_to؛ وتنتقل nsh.progress من 0 إلى 1 عند مجيء الصفحة أو الحوار ومن 1 إلى 0 عند ذهابه، فيرسم Shader واحد الاتجاهين.

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

يحلّ الـ Shader محل animation الخاصة بالصفحة أو الحوار، أياً كانت (بما فيها "none"). ويعمل animation على جهاز بلا GPU، وعندما يطلب المستخدم حركة أقل (Reduce Motion، Remove animations)، ومع مصدر لا يُترجم؛ ومع "none" يظهر التغيير حينئذ فوراً. أي دفع أو سحب أو إظهار أو إغلاق يبدأ أثناء تشغيل آخر يُكمل ذلك الآخر أولاً: تصل صفحته أو حواره أو تغادر، ويُطلق onShow أو onDismiss الخاص به، ثم يبدأ التغيير الجديد من هناك.

تلتقط المنصات الصور عندما يتغير ما تعرضه، مرة واحدة على الأكثر لكل إطار عرض. وتُرسم الصورة بمُصيّر المنصة نفسها، فما لا يستطيع رسمه في صورة (فيديو، صفحة ويب، View تابع لتطبيق آخر) يظهر شفافاً فيها.

#الخصائص

يقبل الـ Shader خصائص الـ View بالإضافة إلى:

الخاصيةRustالنوعالوصف
source.source(program)stringمصدر WGSL الذي يعرّف fn shade(uv: vec2f) -> vec4f. تترجمه 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المدى خارج الصندوق، بالنقاط، الذي يرسمه الـ Shader وتبلغه صوره (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 }الـ Shader الذي تأتي عبره الصفحة أو الحوار وتذهب بدلاً من animation الخاصة بها. duration بالميلي ثانية، و350 افتراضياً.

#الأخطاء

يفحص NSH كل Shader عند تعيين مصدره، قبل أن يصل إلى الـ GPU. الـ Shader الذي لا يُترجم يسجّل تحذيراً فيه السطر والعمود في مصدره وما هو الخطأ، مثلاً:

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

يعرض الـ View حينئذ خلفيته وأبناءه. أما متغير موحّد ينقص البنيةَ، أو قيمة لا يقبلها نوعه، فيسجّل تحذيراً باسم المتغير الموحّد، وتُطبَّق المتغيرات الأخرى.

يُترجم كل مصدر مرة واحدة: الـ Views التي تتشارك المصدر، والمصدر المعيَّن مرة أخرى، تستخدم الـ Shader المترجم.

#القواعد والحدود

  • shade له التوقيع fn shade(uv: vec2f) -> vec4f تماماً. يعلن المصدر دوالّه وثوابته وبنياته ومتغيرات var<private> الخاصة به؛ ويوفّر NSH نقاط الدخول والارتباطات (bindings).
  • أقصى حجم للمصدر 64 KiB.
  • struct Uniforms يأخذ 4096 بايت على الأكثر، بأعضاء من الأنواع الواردة في الجدول أعلاه.
  • أقصى حجم للإطار 4096 بكسل في كل ضلع: الـ View الأكبر يرسم الـ Shader بمقياس أدنى.

للضوضاء، طبّق دالة تجزئة (hash) على الخلية الصحيحة التي توجد فيها النقطة (bitcast<vec2u>(vec2i(floor(p))) مخلوطة بعمليات ضرب وإزاحات xor) بدلاً من fract(sin(…) * 43758.5): فالتجزئة الصحيحة تعطي القيمة نفسها للخلية في كل مكان تُحسب فيه، فتبقى ضوضاء القيم سلسة على كل GPU. تعرض Shaders المعرض (gallery) مثالاً على ذلك.

#المنصات

على iOS وAndroid وmacOS وWindows وLinux، وفي تطبيقات GPUI، يُصيّر NSH الـ Shaders على الـ GPU باستخدام wgpu (Metal على منصات Apple، وVulkan أو Direct3D 12 أو OpenGL في غيرها) بمقياس الشاشة، ويعرض كل إطار تحت أبناء الـ View. يجري التصيير بالتوازي مع واجهة المستخدم: يظهر الإطار بعد رسمه بإطار عرض أو اثنين، وتبقى الواجهة سريعة الاستجابة أثناء عمل الـ GPU. وعلى الويب، يترجم NSH الـ Shader إلى GLSL ويرسمه بـ WebGL 2.

ترسم الـ Shaders مرة واحدة على الأكثر لكل إطار عرض، وفقط عندما يتغير ما تعرضه: الـ Shader ذو المتغيرات الموحّدة الثابتة يرسم مرة واحدة، والذي يقرأ nsh.time يرسم في كل إطار، والذي له متغيرات موحّدة متحركة يرسم ما دامت تتحرك.

#Rust

shader() (من nsh_core::element) تنشئ View من نوع Shader؛ و.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() تنشئ العناصر الأخرى، بالطرق الواردة في الجداول أعلاه.