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