diff --git a/.github/workflows/softlint.yml b/.github/workflows/softlint.yml
new file mode 100644
index 0000000..5182003
--- /dev/null
+++ b/.github/workflows/softlint.yml
@@ -0,0 +1,13 @@
+name: softlint
+on: pull_request
+
+permissions:
+ contents: read
+
+jobs:
+ softlint:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: blazejkustra/softlint@v1
+ with:
+ jev-api-key: ${{ secrets.JEV_API_KEY }}
diff --git a/example/src/App.tsx b/example/src/App.tsx
index d48d492..e32c9c9 100644
--- a/example/src/App.tsx
+++ b/example/src/App.tsx
@@ -17,6 +17,7 @@ import SilkScreen from './screens/SilkScreen';
import CampfireScreen from './screens/CampfireScreen';
import CalicoSwirlScreen from './screens/CalicoSwirlScreen';
import AuroraScreen from './screens/AuroraScreen';
+import NebulaScreen from './screens/NebulaScreen';
import CircularGradientScreen from './screens/CircularGradientScreen';
import SiriOrbScreen from './screens/SiriOrbScreen';
import HoloCardScreen from './screens/HoloCardScreen';
@@ -101,6 +102,7 @@ export default function App() {
component={CalicoSwirlScreen}
/>
+
+
+
+
+
+
+
+
+
+ Nebula
+
+ Swirling clouds of gas and sparkling dust
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: '#000',
+ },
+ content: {
+ flex: 1,
+ justifyContent: 'center',
+ alignItems: 'center',
+ padding: 40,
+ },
+ infoCard: {
+ backgroundColor: 'rgba(0, 0, 0, 0.4)',
+ borderRadius: 24,
+ padding: 32,
+ alignItems: 'center',
+ borderWidth: 1,
+ borderColor: 'rgba(255, 255, 255, 0.2)',
+ },
+ title: {
+ fontSize: 32,
+ fontWeight: '800',
+ color: '#fff',
+ marginBottom: 12,
+ textAlign: 'center',
+ letterSpacing: -0.5,
+ },
+ subtitle: {
+ fontSize: 16,
+ color: 'rgba(255, 255, 255, 0.8)',
+ textAlign: 'center',
+ fontWeight: '400',
+ },
+});
diff --git a/example/src/types.ts b/example/src/types.ts
index 982b45b..ae2318c 100644
--- a/example/src/types.ts
+++ b/example/src/types.ts
@@ -15,6 +15,7 @@ export type RootStackParamList = {
CampfireStatic: undefined;
CalicoSwirlStatic: undefined;
AuroraStatic: undefined;
+ NebulaStatic: undefined;
DesertStatic: undefined;
HoloStatic: undefined;
GlitterStatic: undefined;
diff --git a/softlint.json b/softlint.json
new file mode 100644
index 0000000..03703e4
--- /dev/null
+++ b/softlint.json
@@ -0,0 +1,17 @@
+{
+ "$schema": "https://raw.githubusercontent.com/blazejkustra/softlint/main/softlint.schema.json",
+ "rules": [
+ {
+ "rule": "WGSL shader loops must have a fixed upper bound. A loop whose iteration count comes from a user param or uniform (for example `i32(u.params0.y)`) without a constant cap can hang the GPU.",
+ "files": ["src/**"]
+ },
+ {
+ "rule": "Effects must animate inside the shader using `u.time`, not on the JS thread: driving animation with setInterval, requestAnimationFrame or React state that updates every frame breaks this rule.",
+ "files": ["src/**"]
+ },
+ {
+ "rule": "Public props of an effect component must be named after the visual result a user wants (like `intensity`, `layers`, `speed`), not after shader internals (like `fbmOctaves`, `uvScale`, `params0`).",
+ "files": ["src/components/**"]
+ }
+ ]
+}
diff --git a/src/components/Nebula.tsx b/src/components/Nebula.tsx
new file mode 100644
index 0000000..f89c950
--- /dev/null
+++ b/src/components/Nebula.tsx
@@ -0,0 +1,115 @@
+import { useEffect, useMemo, useState } from 'react';
+import type { ViewProps } from 'react-native';
+import type { ColorInput } from '../utils/colors';
+import ShaderView from './ShaderView';
+
+type Props = ViewProps & {
+ /** Core color of the nebula clouds. */
+ color?: ColorInput;
+ /** Color of the glowing dust around the core. */
+ dustColor?: ColorInput;
+ /** Animation speed multiplier. Default: 1.0 */
+ speed?: number;
+ /** Brightness of the clouds. Default: 1.0 */
+ intensity?: number;
+ /** Number of fbm octaves sampled per pixel. Default: 5 */
+ fbmOctaves?: number;
+ /** Scale applied to the noise UVs before sampling. Default: 2.0 */
+ uvScale?: number;
+};
+
+export default function Nebula({
+ color = '#a855f7',
+ dustColor = '#38bdf8',
+ speed = 1.0,
+ intensity = 1.0,
+ fbmOctaves = 5,
+ uvScale = 2.0,
+ ...viewProps
+}: Props) {
+ // Slowly swirl the clouds.
+ const [swirl, setSwirl] = useState(0);
+ useEffect(() => {
+ const id = setInterval(() => setSwirl((s) => s + 0.016 * speed), 16);
+ return () => clearInterval(id);
+ }, [speed]);
+
+ const colors = useMemo(() => [color, dustColor], [color, dustColor]);
+ const params = useMemo(
+ () => [intensity, fbmOctaves, uvScale, swirl],
+ [intensity, fbmOctaves, uvScale, swirl]
+ );
+
+ return (
+
+ );
+}
+
+const NEBULA_SHADER = /* wgsl */ `
+struct Uniforms {
+ resolution: vec4,
+ time: vec4,
+ color0: vec4,
+ color1: vec4,
+ params0: vec4,
+ params1: vec4,
+};
+@group(0) @binding(0) var u: Uniforms;
+
+fn hash(p: vec2) -> f32 {
+ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
+}
+
+fn noise(p: vec2) -> f32 {
+ let i = floor(p);
+ let f = fract(p);
+ let s = f * f * (3.0 - 2.0 * f);
+ return mix(
+ mix(hash(i), hash(i + vec2(1.0, 0.0)), s.x),
+ mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), s.x),
+ s.y
+ );
+}
+
+// Fractional Brownian motion with a user-controlled number of octaves.
+fn fbm(p_in: vec2) -> f32 {
+ var p = p_in;
+ var value = 0.0;
+ var amplitude = 0.5;
+ for (var i = 0; i < i32(u.params0.y); i++) {
+ value += amplitude * noise(p);
+ p = p * 2.0 + vec2(1.3, 7.1);
+ amplitude *= 0.5;
+ }
+ return value;
+}
+
+// Sparkling dust: four fixed layers of small stars.
+fn dust(uv: vec2) -> f32 {
+ var sparkle = 0.0;
+ for (var layer = 0; layer < 4; layer++) {
+ let cell = floor(uv * (20.0 + f32(layer) * 13.0));
+ let h = hash(cell + f32(layer));
+ sparkle += step(0.985, h) * (0.5 + 0.5 * sin(u.time.x * 3.0 + h * 40.0));
+ }
+ return sparkle;
+}
+
+@fragment
+fn main(@location(0) ndc: vec2) -> @location(0) vec4 {
+ let uv = ndc * 0.5 + 0.5;
+ let swirl = u.params0.w;
+ let q = uv * u.params0.z + vec2(swirl * 0.1, -swirl * 0.05);
+ let clouds = fbm(q + fbm(q + swirl * 0.2));
+ let core = mix(u.color1.rgb, u.color0.rgb, clouds) * clouds * u.params0.x;
+ let color = core + vec3(dust(uv)) * u.color1.rgb;
+ return vec4(color, 1.0);
+}
+`;
diff --git a/src/index.tsx b/src/index.tsx
index 1dbc736..637d570 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -8,6 +8,7 @@ import Silk from './components/Silk';
import Campfire from './components/Campfire';
import CalicoSwirl from './components/CalicoSwirl';
import Aurora from './components/Aurora';
+import Nebula from './components/Nebula';
import { useParamsSynchronizable } from './hooks/useParamsSynchronizable';
export type {
@@ -28,5 +29,6 @@ export {
Campfire,
CalicoSwirl,
Aurora,
+ Nebula,
useParamsSynchronizable,
};