|
|
|
|
@ -0,0 +1,241 @@
|
|
|
|
|
/**
|
|
|
|
|
* Portal — a backlit sphere with streaks of light sweeping around it.
|
|
|
|
|
*
|
|
|
|
|
* The look: a bright rim (the sphere read contre-jour), a dark interior, and
|
|
|
|
|
* layers of thin curved streaks that wrap the surface and rotate — each layer
|
|
|
|
|
* on its own tilted axis, at its own rate, motion-blurred along the sweep.
|
|
|
|
|
*
|
|
|
|
|
* It is a SPHERE, not a disc of rings: the fragment is mapped onto the unit
|
|
|
|
|
* sphere and the streaks live in that 3D space, which is what makes them bend
|
|
|
|
|
* with the curvature instead of reading as flat stripes. And the strokes come
|
|
|
|
|
* from ANISOTROPIC noise in the sphere's longitude/latitude — slow along the
|
|
|
|
|
* flow, fast across it — not from bands: that ratio is what gives them
|
|
|
|
|
* irregular lengths and stray short dashes. Sine bands were the first attempt
|
|
|
|
|
* and cannot get there; perfectly regular rings read as a wire cage however
|
|
|
|
|
* finely they are tuned.
|
|
|
|
|
*
|
|
|
|
|
* Written for the agent presence surface, so two things are non-negotiable:
|
|
|
|
|
* the interior stays open (the effect owns its alpha — what shows through is
|
|
|
|
|
* the page, never a painted background), and the three colours arrive as
|
|
|
|
|
* concrete hex, resolved by the consumer from its semantic role tokens. A
|
|
|
|
|
* theme re-tints the portal without touching this file.
|
|
|
|
|
*
|
|
|
|
|
* Not a port: the reference for this look is a paid component whose source is
|
|
|
|
|
* not available, and the framework implements its own anyway.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import type { SceneApi, WebglSceneEffect } from '../types';
|
|
|
|
|
import { hexToRgb01 } from './color';
|
|
|
|
|
|
|
|
|
|
/** Streak layers the shader unrolls; `layers` selects how many are lit. */
|
|
|
|
|
const MAX_LAYERS = 7;
|
|
|
|
|
|
|
|
|
|
export interface PortalParams {
|
|
|
|
|
/** The rim — the lit edge of the sphere (hex). */
|
|
|
|
|
colorPrimary: string;
|
|
|
|
|
/** The streaks sweeping across the surface (hex). */
|
|
|
|
|
colorSecondary: string;
|
|
|
|
|
/** The interior the streaks travel over (hex). */
|
|
|
|
|
colorDeep: string;
|
|
|
|
|
/** Sweep rate. `0` freezes the sphere without emptying it. */
|
|
|
|
|
speed: number;
|
|
|
|
|
/** How many streak layers are lit (1–7). Each has its own axis and rate. */
|
|
|
|
|
layers: number;
|
|
|
|
|
/** Streaks per layer — higher packs more, thinner. */
|
|
|
|
|
density: number;
|
|
|
|
|
/** Width of the lit rim, `0` → `1`. The interior stays open at any value. */
|
|
|
|
|
thickness: number;
|
|
|
|
|
/** Length of the motion-blur smear along the sweep. */
|
|
|
|
|
blur: number;
|
|
|
|
|
/** Overall gain. */
|
|
|
|
|
brightness: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface PortalState {
|
|
|
|
|
colorPrimary: WebGLUniformLocation | null;
|
|
|
|
|
colorSecondary: WebGLUniformLocation | null;
|
|
|
|
|
colorDeep: WebGLUniformLocation | null;
|
|
|
|
|
phase: WebGLUniformLocation | null;
|
|
|
|
|
layers: WebGLUniformLocation | null;
|
|
|
|
|
density: WebGLUniformLocation | null;
|
|
|
|
|
thickness: WebGLUniformLocation | null;
|
|
|
|
|
blur: WebGLUniformLocation | null;
|
|
|
|
|
brightness: WebGLUniformLocation | null;
|
|
|
|
|
cp: Float32Array;
|
|
|
|
|
cpKey: string;
|
|
|
|
|
cs: Float32Array;
|
|
|
|
|
csKey: string;
|
|
|
|
|
cd: Float32Array;
|
|
|
|
|
cdKey: string;
|
|
|
|
|
/** Phase integrated from `speed`, so a rate change never jumps the sweep. */
|
|
|
|
|
phaseValue: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const FRAG = `precision highp float;
|
|
|
|
|
uniform float uTime;
|
|
|
|
|
uniform vec3 uResolution;
|
|
|
|
|
uniform float uPhase;
|
|
|
|
|
uniform vec3 colorPrimary;
|
|
|
|
|
uniform vec3 colorSecondary;
|
|
|
|
|
uniform vec3 colorDeep;
|
|
|
|
|
uniform float layers;
|
|
|
|
|
uniform float density;
|
|
|
|
|
uniform float thickness;
|
|
|
|
|
uniform float blur;
|
|
|
|
|
uniform float brightness;
|
|
|
|
|
|
|
|
|
|
vec3 hash33(vec3 p3) { p3 = fract(p3 * vec3(0.1031, 0.11369, 0.13787)); p3 += dot(p3, p3.yxz + 19.19); return -1.0 + 2.0 * fract(vec3(p3.x + p3.y, p3.x + p3.z, p3.y + p3.z) * p3.zyx); }
|
|
|
|
|
float snoise3(vec3 p) { const float K1 = 0.333333333; const float K2 = 0.166666667; vec3 i = floor(p + (p.x + p.y + p.z) * K1); vec3 d0 = p - (i - (i.x + i.y + i.z) * K2); vec3 e = step(vec3(0.0), d0 - d0.yzx); vec3 i1 = e * (1.0 - e.zxy); vec3 i2 = 1.0 - e.zxy * (1.0 - e); vec3 d1 = d0 - (i1 - K2); vec3 d2 = d0 - (i2 - K1); vec3 d3 = d0 - 0.5; vec4 h = max(0.6 - vec4(dot(d0, d0), dot(d1, d1), dot(d2, d2), dot(d3, d3)), 0.0); vec4 n = h * h * h * h * vec4(dot(d0, hash33(i)), dot(d1, hash33(i + i1)), dot(d2, hash33(i + i2)), dot(d3, hash33(i + 1.0))); return dot(vec4(31.316), n); }
|
|
|
|
|
|
|
|
|
|
vec3 rotY(vec3 v, float a) {
|
|
|
|
|
float s = sin(a), c = cos(a);
|
|
|
|
|
return vec3(c * v.x + s * v.z, v.y, -s * v.x + c * v.z);
|
|
|
|
|
}
|
|
|
|
|
vec3 rotX(vec3 v, float a) {
|
|
|
|
|
float s = sin(a), c = cos(a);
|
|
|
|
|
return vec3(v.x, c * v.y - s * v.z, s * v.y + c * v.z);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// One layer of streaks. The shape comes from ANISOTROPIC noise sampled in the
|
|
|
|
|
// sphere's own longitude/latitude: slow along the flow, fast across it. That
|
|
|
|
|
// ratio IS the streak — long, thin, and irregular, with the varying lengths
|
|
|
|
|
// and stray short dashes that a sine band can never produce (a sine gives
|
|
|
|
|
// perfectly regular latitude rings, which read as a wire cage).
|
|
|
|
|
float layerStreaks(vec3 p, float i) {
|
|
|
|
|
float tilt = 0.30 + i * 0.44;
|
|
|
|
|
float rate = 0.55 + i * 0.21;
|
|
|
|
|
float acc = 0.0;
|
|
|
|
|
for (int k = 0; k < 4; k++) {
|
|
|
|
|
// A few taps along the sweep, averaged: that average is the motion blur,
|
|
|
|
|
// and it is what smears each stroke into a trail.
|
|
|
|
|
float t = uPhase * rate + float(k) * blur * 0.03;
|
|
|
|
|
vec3 q = rotY(rotX(p, tilt), t);
|
|
|
|
|
float lon = atan(q.z, q.x);
|
|
|
|
|
float lat = q.y;
|
|
|
|
|
// 1 : 9 anisotropy — the streak runs with the flow and is cut across it.
|
|
|
|
|
float n = snoise3(vec3(lon * 1.1, lat * 9.0 * density, i * 11.0)) * 0.5 + 0.5;
|
|
|
|
|
// High power keeps only the crests: everything between two strokes falls
|
|
|
|
|
// to zero, which is what leaves the sphere open.
|
|
|
|
|
acc += pow(n, 13.0);
|
|
|
|
|
}
|
|
|
|
|
return acc * 0.25;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void main() {
|
|
|
|
|
vec2 center = uResolution.xy * 0.5;
|
|
|
|
|
float size = min(uResolution.x, uResolution.y);
|
|
|
|
|
vec2 uv = (gl_FragCoord.xy - center) / size * 2.0;
|
|
|
|
|
|
|
|
|
|
float r = length(uv);
|
|
|
|
|
// Outside the sphere there is nothing: the disc edge IS the silhouette.
|
|
|
|
|
float disc = 1.0 - smoothstep(0.97, 1.0, r);
|
|
|
|
|
if (disc <= 0.0) {
|
|
|
|
|
gl_FragColor = vec4(0.0);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Map the fragment onto the unit sphere; the streaks live in THIS space, so
|
|
|
|
|
// they bend with the curvature instead of reading as flat stripes.
|
|
|
|
|
float z = sqrt(max(1.0 - r * r, 0.0));
|
|
|
|
|
vec3 p = vec3(uv, z);
|
|
|
|
|
|
|
|
|
|
float streaks = 0.0;
|
|
|
|
|
for (int i = 0; i < 7; i++) {
|
|
|
|
|
float fi = float(i);
|
|
|
|
|
// Fixed bounds (GLSL ES 1.0); the mask lights only the layers asked for,
|
|
|
|
|
// so the count stays animatable without recompiling the program.
|
|
|
|
|
streaks += layerStreaks(p, fi) * step(fi, layers - 0.5);
|
|
|
|
|
}
|
|
|
|
|
streaks = clamp(streaks / max(layers, 1.0) * 9.0, 0.0, 1.0);
|
|
|
|
|
// The limb crowds: near the silhouette the surface turns away, so strokes
|
|
|
|
|
// pile up there exactly as they do on a real sphere.
|
|
|
|
|
streaks *= mix(1.0, 1.5, 1.0 - z);
|
|
|
|
|
|
|
|
|
|
// The rim: a thin lit edge, not a halo — it is the sphere read against the
|
|
|
|
|
// light, and it must not drown the strokes.
|
|
|
|
|
float rimStart = mix(0.985, 0.90, clamp(thickness, 0.0, 1.0));
|
|
|
|
|
float rim = pow(smoothstep(rimStart, 0.998, r), 1.4);
|
|
|
|
|
|
|
|
|
|
// NOTHING is painted into the interior: it is TRANSPARENT, so what shows
|
|
|
|
|
// through the sphere is the page. That is what makes the effect
|
|
|
|
|
// theme-agnostic without a single light/dark branch.
|
|
|
|
|
// All three colours are the state's ROLE slots: the far side recedes into
|
|
|
|
|
// the recessive step, the near side carries the solid, the rim is the edge.
|
|
|
|
|
vec3 col = mix(colorDeep, colorSecondary, z);
|
|
|
|
|
col = mix(col, colorPrimary, rim);
|
|
|
|
|
|
|
|
|
|
// Only light contributes opacity. No stroke and no rim, nothing at all.
|
|
|
|
|
float alpha = clamp(streaks + rim, 0.0, 1.0) * brightness * disc;
|
|
|
|
|
gl_FragColor = vec4(col * alpha, alpha);
|
|
|
|
|
}`;
|
|
|
|
|
|
|
|
|
|
export const portalEffect: WebglSceneEffect<PortalParams, PortalState> = {
|
|
|
|
|
name: 'portal',
|
|
|
|
|
driver: 'webgl',
|
|
|
|
|
// A frozen sweep is still a lit sphere: presence survives the freeze.
|
|
|
|
|
reduce: 'static-frame',
|
|
|
|
|
defaults: {
|
|
|
|
|
colorPrimary: '#c084fc',
|
|
|
|
|
colorSecondary: '#e879f9',
|
|
|
|
|
colorDeep: '#0a0a0f',
|
|
|
|
|
speed: 1,
|
|
|
|
|
layers: 5,
|
|
|
|
|
density: 1,
|
|
|
|
|
thickness: 0.5,
|
|
|
|
|
blur: 1,
|
|
|
|
|
brightness: 1
|
|
|
|
|
},
|
|
|
|
|
fragmentShader: () => FRAG,
|
|
|
|
|
setup(gl, program) {
|
|
|
|
|
return {
|
|
|
|
|
colorPrimary: gl.getUniformLocation(program, 'colorPrimary'),
|
|
|
|
|
colorSecondary: gl.getUniformLocation(program, 'colorSecondary'),
|
|
|
|
|
colorDeep: gl.getUniformLocation(program, 'colorDeep'),
|
|
|
|
|
phase: gl.getUniformLocation(program, 'uPhase'),
|
|
|
|
|
layers: gl.getUniformLocation(program, 'layers'),
|
|
|
|
|
density: gl.getUniformLocation(program, 'density'),
|
|
|
|
|
thickness: gl.getUniformLocation(program, 'thickness'),
|
|
|
|
|
blur: gl.getUniformLocation(program, 'blur'),
|
|
|
|
|
brightness: gl.getUniformLocation(program, 'brightness'),
|
|
|
|
|
cp: new Float32Array(3),
|
|
|
|
|
cpKey: '',
|
|
|
|
|
cs: new Float32Array(3),
|
|
|
|
|
csKey: '',
|
|
|
|
|
cd: new Float32Array(3),
|
|
|
|
|
cdKey: '',
|
|
|
|
|
phaseValue: 0
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
update(gl, state, api: SceneApi<PortalParams>) {
|
|
|
|
|
const p = api.params;
|
|
|
|
|
if (p.colorPrimary !== state.cpKey) {
|
|
|
|
|
state.cpKey = p.colorPrimary;
|
|
|
|
|
state.cp.set(hexToRgb01(p.colorPrimary));
|
|
|
|
|
}
|
|
|
|
|
if (p.colorSecondary !== state.csKey) {
|
|
|
|
|
state.csKey = p.colorSecondary;
|
|
|
|
|
state.cs.set(hexToRgb01(p.colorSecondary));
|
|
|
|
|
}
|
|
|
|
|
if (p.colorDeep !== state.cdKey) {
|
|
|
|
|
state.cdKey = p.colorDeep;
|
|
|
|
|
state.cd.set(hexToRgb01(p.colorDeep));
|
|
|
|
|
}
|
|
|
|
|
// Integrate the rate: a custody transition changes `speed`, and the
|
|
|
|
|
// sweep must not jump when it does.
|
|
|
|
|
state.phaseValue += api.dt * p.speed;
|
|
|
|
|
|
|
|
|
|
if (state.phase) gl.uniform1f(state.phase, state.phaseValue);
|
|
|
|
|
if (state.layers) gl.uniform1f(state.layers, p.layers);
|
|
|
|
|
if (state.density) gl.uniform1f(state.density, p.density);
|
|
|
|
|
if (state.thickness) gl.uniform1f(state.thickness, p.thickness);
|
|
|
|
|
if (state.blur) gl.uniform1f(state.blur, p.blur);
|
|
|
|
|
if (state.brightness) gl.uniform1f(state.brightness, p.brightness);
|
|
|
|
|
if (state.colorPrimary) {
|
|
|
|
|
gl.uniform3f(state.colorPrimary, state.cp[0], state.cp[1], state.cp[2]);
|
|
|
|
|
}
|
|
|
|
|
if (state.colorSecondary) {
|
|
|
|
|
gl.uniform3f(state.colorSecondary, state.cs[0], state.cs[1], state.cs[2]);
|
|
|
|
|
}
|
|
|
|
|
if (state.colorDeep) gl.uniform3f(state.colorDeep, state.cd[0], state.cd[1], state.cd[2]);
|
|
|
|
|
}
|
|
|
|
|
};
|