类型:3D · 分类:Backgrounds · 收录:2026-09-16 20:35:01 · 源码:会员可见
# Elements
Water, lightning, fire, condensation, and a painterly generative tree collected as one elemental family across WebGL2 and Canvas 2D.
> 来源:ThreeUI Community · https://threeui.com · MIT License(Meng To / Design+Code)
> 组件页:https://threeui.com/component/elements · 预览动画:https://threeui.com/previews/elements.webm
> 分类:Backgrounds
> 包含变体(5):elemental-water、elemental-lightning、elemental-flame、condensation、generative-tree
/* ===== src/shaders/elements/ElementsCollection.tsx (tsx) ===== */
import { lazy, Suspense } from "react";
import type { CondensationBackgroundProps } from "../condensation/CondensationBackground";
import type { ElementVariant, ElementsBackgroundProps } from "./ElementsBackground";
import type { GenerativeTreeProps } from "./GenerativeTree";
export type ElementsVariant = ElementVariant | "condensation" | "generative-tree";
type ElementVariantProps = Omit<ElementsBackgroundProps, "variant"> & {
variant?: ElementVariant;
};
type CondensationVariantProps = CondensationBackgroundProps & {
variant: "condensation";
};
type GenerativeTreeVariantProps = GenerativeTreeProps & {
variant: "generative-tree";
};
export type ElementsCollectionProps = ElementVariantProps | CondensationVariantProps | GenerativeTreeVariantProps;
const ElementVariantRenderer = lazy(() =>
import("./ElementsBackground").then((module) => ({ default: module.ElementsBackground })),
);
const CondensationVariant = lazy(() =>
import("../condensation/CondensationBackground").then((module) => ({ default: module.CondensationBackground })),
);
const GenerativeTreeVariant = lazy(() =>
import("./GenerativeTree").then((module) => ({ default: module.GenerativeTree })),
);
const FALLBACK = <div className="threeui-background elements" />;
export function ElementsCollection(props: ElementsCollectionProps) {
if (props.variant === "generative-tree") {
const { variant: _variant, ...variantProps } = props;
return (
<Suspense fallback={FALLBACK}>
<GenerativeTreeVariant {...variantProps} />
</Suspense>
);
}
if (props.variant === "condensation") {
const { variant: _variant, ...variantProps } = props;
return (
<Suspense fallback={FALLBACK}>
<CondensationVariant {...variantProps} />
</Suspense>
);
}
const { variant = "water", ...variantProps } = props;
return (
<Suspense fallback={FALLBACK}>
<ElementVariantRenderer {...variantProps} variant={variant} />
</Suspense>
);
}
/* ===== src/shaders/elements/ElementsBackground.tsx (tsx) ===== */
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import elementalMarksSource from "./sources/elemental-marks.html?raw";
export const ELEMENT_VARIANTS = ["water", "lightning", "fire"] as const;
export type ElementVariant = (typeof ELEMENT_VARIANTS)[number];
export type ElementsBackgroundProps = {
variant?: ElementVariant;
speed?: number;
size?: number;
particleAmount?: number;
opacity?: number;
hue?: number;
saturation?: number;
brightness?: number;
className?: string;
style?: CSSProperties;
};
export const ELEMENTS_DEFAULTS = {
variant: "water" as ElementVariant,
speed: 1,
size: 1,
particleAmount: 1,
opacity: 1,
hue: 0,
saturation: 1,
brightness: 1,
} as const;
const VARIANT_LABELS: Record<ElementVariant, string> = {
water: "Water",
lightning: "Lightning",
fire: "Fire",
};
const BASE_ZOOM: Record<ElementVariant, number> = {
water: 1.56,
lightning: 1.66,
fire: 1.82,
};
const SOURCE_ZOOM: Record<ElementVariant, string> = {
water: "1.06",
lightning: "1.10",
fire: "1.16",
};
const BASE_PARTICLES: Record<ElementVariant, number> = {
water: 160,
lightning: 360,
fire: 560,
};
const SOURCE_PARTICLES: Record<ElementVariant, number> = {
water: 160,
lightning: 240,
fire: 420,
};
const DETAIL_PATCHES = [
[
"const SDF_SIZE = 512;nconst SDF_SPREAD = 128;",
"const SDF_SIZE = 768;nconst SDF_SPREAD = 192;",
],
[
"const DPR = Math.min(window.devicePixelRatio || 1, 1.75);",
"const DPR = Math.min(Math.max(window.devicePixelRatio || 1, 1.5), 2.25);",
],
[
"sim: false, zoom: 1.16, shift: [0, -0.04],",
"sim: false, zoom: 1.16, shift: [0, -0.11],",
],
[
` float inside = smoothstep(0.005, -0.005, d);
vec3 body = vec3(0.055, 0.06, 0.10) + big * vec3(0.55, 0.58, 0.85);
col = mix(col, body, inside);
float rim = exp(-abs(d) / 0.012) * 0.5;`,
` float inside = smoothstep(0.0025, -0.0025, d);
vec3 body = vec3(0.055, 0.06, 0.10) + big * vec3(0.55, 0.58, 0.85);
col = mix(col, body, inside);
float rim = exp(-abs(d) / 0.0075) * 0.56;`,
],
[
` // fBm zigzag arcs crawling the contour
float fade = edgeFade(vUv);
for (int i = 0; i < 3; i++){
float fi = float(i);
float n = fbm(pp * mix(3.0, 8.0, fi / 2.0) + vec2(t * (1.2 + fi * 0.9), fi * 19.3)) - 0.5;
float e = abs(d + n * 0.11);
float fl = hash21(vec2(floor(t * (6.0 + fi * 3.0)), fi));
fl = mix(0.15, 1.0, smoothstep(0.25, 0.95, fl));
float bolt = pow(0.0042 * pb / (e + 0.0022), 1.35) * fl;
bolt = min(bolt, 6.0);
col += bolt * mix(vec3(0.38, 0.42, 1.0), vec3(0.92, 0.9, 1.0), fi / 2.0) * fade;
}
// tight white core arc
float n0 = fbm(pp * 5.0 + vec2(t * 2.4, 7.7)) - 0.5;
float e0 = abs(d + n0 * 0.10);
float corefl = mix(0.25, 1.15, hash21(vec2(floor(t * 9.0), 11.0)));
col += min(pow(0.0026 * pb / (e0 + 0.0016), 1.6), 8.0) * corefl * vec3(1.0) * fade;`,
` // Layered contour arcs: broad current, hairline filaments, and short-lived forks.
float fade = edgeFade(vUv);
for (int i = 0; i < 5; i++){
float fi = float(i);
float layer = fi / 4.0;
float coarse = fbm(pp * mix(4.0, 13.0, layer) + vec2(t * (1.3 + fi * 0.78), fi * 17.7)) - 0.5;
float detail = fbm(pp * mix(17.0, 31.0, layer) + vec2(-t * (2.9 + fi * 0.55), fi * 31.7)) - 0.5;
float n = coarse * mix(0.82, 0.58, layer) + detail * mix(0.18, 0.42, layer);
float e = abs(d + n * mix(0.09, 0.038, layer));
float fl = hash21(vec2(floor(t * (7.0 + fi * 3.4)), fi));
fl = mix(0.12, 1.0, smoothstep(0.22, 0.94, fl));
float width = mix(0.0034, 0.0012, layer);
float bolt = pow(width * pb / (e + mix(0.0018, 0.0008, layer)), mix(1.42, 1.78, layer)) * fl;
bolt = min(bolt, 7.0);
col += bolt * mix(vec3(0.34, 0.39, 1.0), vec3(0.96, 0.94, 1.0), pow(layer, 0.75)) * fade;
}
// A white primary channel with a displaced secondary fork.
float n0 = fbm(pp * 6.5 + vec2(t * 2.8, 7.7)) - 0.5;
n0 += (fbm(pp * 23.0 + vec2(-t * 4.1, 18.2)) - 0.5) * 0.22;
float e0 = abs(d + n0 * 0.075);
float corefl = mix(0.22, 1.2, hash21(vec2(floor(t * 10.0), 11.0)));
col += min(pow(0.00175 * pb / (e0 + 0.0009), 1.78), 9.0) * corefl * vec3(1.0) * fade;
float branchNoise = fbm(pp * 16.0 + vec2(t * 3.7, -13.4)) - 0.5;
float branchEdge = abs(d + n0 * 0.052 + branchNoise * 0.026);
float branchLife = smoothstep(0.35, 0.92, hash21(vec2(floor(t * 13.0), 23.0)));
col += min(pow(0.0011 * pb / (branchEdge + 0.00075), 1.72), 5.0)
* branchLife * vec3(0.64, 0.70, 1.0) * fade;`,
],
[
` // thin tongues: x-squashed scrolling fBm, advected progressively with
// height so flames stay attached at the edge and lick upward
float n1 = fbm(vec2(pp.x * 12.0, pp.y * 3.0 - t * 2.1));
float n2 = fbm(vec2(pp.x * 6.0 + 41.3, pp.y * 1.7 - t * 1.25));
float d0f = d0 + (hash21(vUv * 771.0 + t * 1.3) - 0.5) * 0.004;
float rise = max(0.0, n1 * 1.15 + n2 * 0.65 - 0.5) * 1.3; // many columns ~0, few run hot
float adv = smoothstep(-0.03, 0.22, d0f); // 0 at the edge, grows above
float lick = 0.30 * (1.0 + stoke * 0.8);
vec2 offs = vec2((n2 - 0.5) * 0.05 + uWind * 0.05, -rise * lick * adv);
float df = sdf(vUv + offs);
df += (hash21(vUv * 997.0 + uTime) - 0.5) * 0.004; // dither the 8-bit field
float heat = clamp(1.0 - df / 0.07, 0.0, 1.0);
heat *= mix(0.1, 1.0, upw);
heat *= 1.0 - 0.45 * smoothstep(0.08, 0.26, d0f); // gentle thinning with height
float tongue = smoothstep(0.25, 0.70, heat); // sharp separated licks
float body = pow(heat, 2.2) * 0.45; // faint haze beneath them
float f = max(tongue, body) * (0.95 + 0.25 * stoke);`,
` // Three frequency bands split the fire into attached tongues, fine wisps, and hot veins.
float n1 = fbm(vec2(pp.x * 11.0, pp.y * 3.2 - t * 2.2));
float n2 = fbm(vec2(pp.x * 6.5 + 41.3, pp.y * 1.8 - t * 1.3));
float n3 = fbm(vec2(pp.x * 27.0 - t * 0.7, pp.y * 7.5 - t * 4.0));
float d0f = d0 + (hash21(vUv * 771.0 + t * 1.3) - 0.5) * 0.0025;
float rise = max(0.0, n1 * 0.98 + n2 * 0.54 + n3 * 0.38 - 0.58) * 1.48;
float adv = smoothstep(-0.025, 0.24, d0f);
float lick = 0.31 * (1.0 + stoke * 0.82);
vec2 offs = vec2((n2 - 0.5) * 0.042 + (n3 - 0.5) * 0.018 + uWind * 0.05, -rise * lick * adv);
float df = sdf(vUv + offs);
float micro = fbm(pp * vec2(34.0, 9.0) + vec2(t * 1.1, -t * 5.2));
df += (micro - 0.5) * 0.011 * adv;
df += (hash21(vUv * 997.0 + uTime) - 0.5) * 0.0025;
float heat = clamp(1.0 - df / 0.064, 0.0, 1.0);
heat *= mix(0.08, 1.0, upw);
heat *= 1.0 - 0.52 * smoothstep(0.07, 0.27, d0f);
heat *= 0.84 + n3 * 0.24;
float tongue = smoothstep(0.29, 0.69, heat);
float filament = smoothstep(0.58, 0.91, heat + (n3 - 0.5) * 0.20);
float split = smoothstep(0.38, 0.82, n3) * smoothstep(0.015, 0.23, d0f);
float body = pow(heat, 2.35) * 0.36;
float f = max(max(tongue * (0.72 + 0.28 * n3), filament * split * 0.88), body)
* (0.95 + 0.25 * stoke);`,
],
[
` col += fireRamp(f * (1.05 - 0.4 * smoothstep(0.02, 0.25, d0))); // white base, orange tips
// white-hot mark with a warm halo, like paper mid-combustion
float inside = smoothstep(0.004, -0.004, d0);
float core = smoothstep(0.0, -0.02, d0);`,
` col += fireRamp(f * (1.05 - 0.4 * smoothstep(0.02, 0.25, d0)));
float vein = pow(clamp(1.0 - abs(df) / 0.026, 0.0, 1.0), 3.2)
* smoothstep(0.55, 0.9, n3) * upw * (0.35 + 0.65 * adv);
col += fireRamp(0.82) * vein * 0.34;
// A crisp white-hot mark with a restrained warm halo.
float inside = smoothstep(0.0025, -0.0025, d0);
float core = smoothstep(0.0, -0.016, d0);`,
],
[
` col += exp(-max(d0, 0.0) / 0.035) * vec3(1.0, 0.55, 0.22) * 0.35 * (1.0 - inside * 0.9);
col += exp(-max(d0, 0.0) / 0.12) * vec3(0.55, 0.14, 0.03) * 0.35 * (0.6 + 0.4 * n2);`,
` col += exp(-max(d0, 0.0) / 0.026) * vec3(1.0, 0.55, 0.22) * 0.30 * (1.0 - inside * 0.92);
col += exp(-max(d0, 0.0) / 0.09) * vec3(0.55, 0.14, 0.03) * 0.28 * (0.55 + 0.45 * n2);`,
],
[
`count: 240, travel: 0.055, lifeMin: 0.3, lifeMax: 0.9, alongNormal: 1.0,
wiggle: 0.015, sizeMin: 1.2, sizeMax: 2.6, sparse: 0.8,`,
`count: 240, travel: 0.075, lifeMin: 0.28, lifeMax: 0.82, alongNormal: 0.92,
wiggle: 0.022, sizeMin: 0.8, sizeMax: 2.0, sparse: 0.72,`,
],
[
`count: 420, travel: 0.30, lifeMin: 1.4, lifeMax: 3.0, alongNormal: 0.35,
wiggle: 0.035, sizeMin: 1.6, sizeMax: 4.5, sparse: 0.55,`,
`count: 420, travel: 0.34, lifeMin: 1.1, lifeMax: 2.7, alongNormal: 0.30,
wiggle: 0.046, sizeMin: 0.9, sizeMax: 3.6, sparse: 0.42,`,
],
] as const;
function clamp(value: number, minimum: number, maximum: number) {
return Math.min(maximum, Math.max(minimum, value));
}
function applyDetailPatches(source: string) {
return DETAIL_PATCHES.reduce(
(document, [original, enhanced]) => document.replace(original, enhanced),
source,
);
}
function buildFocusedDocument(variant: ElementVariant, size: number, particleAmount: number) {
const zoom = BASE_ZOOM[variant] / clamp(size, 0.65, 1.5);
const particleCount = Math.max(0, Math.round(BASE_PARTICLES[variant] * clamp(particleAmount, 0, 2)));
const focusStyles = `<style data-elements-focus>
html, body, main { width: 100%; height: 100%; margin: 0; overflow: hidden; background: #060708; }
header, .hint, .info, .kanji { display: none !important; }
main { display: block; }
.panel { display: none; }
.panel[data-fx="${variant}"] {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
border: 0;
opacity: 1;
transform: none;
animation: none;
}
.panel[data-fx="${variant}"] canvas { width: 100%; height: 100%; }
</style>`;
const controls = `<script data-elements-controls>
(function () {
var nativeNow = performance.now.bind(performance);
var last = nativeNow();
var virtual = last;
var state = { speed: 1, paused: false };
window.__ELEMENTS_PAUSED = false;
performance.now = function () {
var real = nativeNow();
if (!state.paused) virtual += (real - last) * state.speed;
last = real;
return virtual;
};
window.addEventListener('message', function (event) {
if (!event.data || event.data.type !== 'elements-controls') return;
var next = event.data.controls || {};
if (Number.isFinite(next.speed)) state.speed = Math.max(0, Math.min(3, next.speed));
state.paused = Boolean(next.paused);
window.__ELEMENTS_PAUSED = state.paused;
});
})();
</script>`;
return applyDetailPatches(elementalMarksSource)
.replace(/<link[^>]+fonts.googleapis.com[^>]*>/gi, "")
.replace(/<link[^>]+fonts.gstatic.com[^>]*>/gi, "")
.replace("</head>", `${focusStyles}${controls}</head>`)
.replace(new RegExp(`count: ${SOURCE_PARTICLES[variant]}`), `count: ${particleCount}`)
.replace(`zoom: ${SOURCE_ZOOM[variant]}`, `zoom: ${zoom.toFixed(4)}`)
.replace(
"for (const p of panels) p.draw(t);",
"if (!window.__ELEMENTS_PAUSED) for (const p of panels) p.draw(t);",
);
}
export function ElementsBackground({
variant = ELEMENTS_DEFAULTS.variant,
speed = ELEMENTS_DEFAULTS.speed,
size = ELEMENTS_DEFAULTS.size,
particleAmount = ELEMENTS_DEFAULTS.particleAmount,
opacity = ELEMENTS_DEFAULTS.opacity,
hue = ELEMENTS_DEFAULTS.hue,
saturation = ELEMENTS_DEFAULTS.saturation,
brightness = ELEMENTS_DEFAULTS.brightness,
className = "",
style,
}: ElementsBackgroundProps) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const [hostVisible, setHostVisible] = useState(true);
const [documentVisible, setDocumentVisible] = useState(() => typeof document === "undefined" || !document.hidden);
const safeVariant = ELEMENT_VARIANTS.includes(variant) ? variant : ELEMENTS_DEFAULTS.variant;
const safeSpeed = clamp(speed, 0, 3);
const paused = !hostVisible || !documentVisible;
const source = useMemo(
() => buildFocusedDocument(safeVariant, size, particleAmount),
[particleAmount, safeVariant, size],
);
const postControls = useCallback(() => {
iframeRef.current?.contentWindow?.postMessage({
type: "elements-controls",
controls: { speed: safeSpeed, paused },
}, "*");
}, [paused, safeSpeed]);
useEffect(() => {
const iframe = iframeRef.current;
if (!iframe || typeof IntersectionObserver === "undefined") return undefined;
const observer = new IntersectionObserver(([entry]) => setHostVisible(entry?.isIntersecting ?? true));
observer.observe(iframe);
return () => observer.disconnect();
}, []);
useEffect(() => {
if (typeof document === "undefined") return undefined;
const update = () => setDocumentVisible(!document.hidden);
document.addEventListener("visibilitychange", update);
return () => document.removeEventListener("visibilitychange", update);
}, []);
useEffect(() => {
postControls();
}, [postControls, source]);
return (
<div
className={`threeui-background elements${className ? ` ${className}` : ""}`}
data-element={safeVariant}
style={{ background: "#060708", pointerEvents: "auto", ...style }}
>
<iframe
ref={iframeRef}
title={`${VARIANT_LABELS[safeVariant]} element background`}
srcDoc={source}
sandbox="allow-scripts"
onLoad={postControls}
aria-hidden="true"
tabIndex={-1}
style={{
position: "absolute",
inset: 0,
display: "block",
width: "100%",
height: "100%",
border: 0,
background: "#060708",
opacity: clamp(opacity, 0.05, 1),
filter: `hue-rotate(${clamp(hue, -180, 180)}deg) saturate(${clamp(saturation, 0, 2)}) brightness(${clamp(brightness, 0.35, 1.8)})`,
}}
/>
</div>
);
}
/* ===== src/shaders/elements/GenerativeTree.tsx (tsx) ===== */
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import generativeTreeSource from "./sources/generative-tree.html?raw";
export type GenerativeTreeProps = {
speed?: number;
size?: number;
particleAmount?: number;
opacity?: number;
hue?: number;
saturation?: number;
brightness?: number;
className?: string;
style?: CSSProperties;
};
export const GENERATIVE_TREE_DEFAULTS = {
speed: 1,
size: 1,
particleAmount: 1,
opacity: 1,
hue: 0,
saturation: 1,
brightness: 1,
} as const;
function clamp(value: number, minimum: number, maximum: number) {
return Math.min(maximum, Math.max(minimum, value));
}
function buildFocusedDocument(size: number, particleAmount: number) {
const particleCount = Math.max(0, Math.round(50 * clamp(particleAmount, 0, 2)));
const treePadding = 1 / clamp(size, 0.65, 1.5);
const focusStyles = `<style data-generative-tree-focus>
html, body, canvas { width: 100%; height: 100%; margin: 0; overflow: hidden; background: #0a0a0a; }
.label { display: none !important; }
</style>`;
const controls = `<script data-generative-tree-controls>
(function () {
var nativeFrame = window.requestAnimationFrame.bind(window);
var clock = { last: null, time: null };
window.__GENERATIVE_TREE_CONTROLS = { speed: 1, paused: false };
window.requestAnimationFrame = function (callback) {
return nativeFrame(function (realTime) {
var state = window.__GENERATIVE_TREE_CONTROLS;
if (clock.last === null) {
clock.last = realTime;
clock.time = realTime;
} else {
if (!state.paused) clock.time += (realTime - clock.last) * state.speed;
clock.last = realTime;
}
callback(clock.time);
});
};
window.addEventListener('message', function (event) {
if (!event.data || event.data.type !== 'generative-tree-controls') return;
var next = event.data.controls || {};
if (Number.isFinite(next.speed)) {
window.__GENERATIVE_TREE_CONTROLS.speed = Math.max(0, Math.min(3, next.speed));
}
window.__GENERATIVE_TREE_CONTROLS.paused = Boolean(next.paused);
});
})();
</script>`;
return generativeTreeSource
.replace(/<script[^>]+cloudflareinsights.com[^>]*></script>/gi, "")
.replace("</head>", `${focusStyles}${controls}</head>`)
.replace("const PARTICLE_COUNT = 50;", `const PARTICLE_COUNT = ${particleCount};`)
.replace(
"const _pad = parseFloat(new URLSearchParams(location.search).get('p')) || 1;",
`const _pad = ${treePadding.toFixed(4)};`,
)
.replace(
"function frame(time) {n // Decay shake",
"function frame(time) {n if (window.__GENERATIVE_TREE_CONTROLS.paused) { requestAnimationFrame(frame); return; }nn // Decay shake",
)
.replace(
"b.growthProgress = Math.min(1, b.growthProgress + b.growthSpeed);",
"b.growthProgress = Math.min(1, b.growthProgress + b.growthSpeed * window.__GENERATIVE_TREE_CONTROLS.speed);",
)
.replace("holdTimer++;", "holdTimer += window.__GENERATIVE_TREE_CONTROLS.speed;")
.replace("fadeTimer++;", "fadeTimer += window.__GENERATIVE_TREE_CONTROLS.speed;")
.replace("waitTimer++;", "waitTimer += window.__GENERATIVE_TREE_CONTROLS.speed;")
// A remounted srcDoc can execute before the iframe receives its layout size.
// Wait one or more frames so the first tree is never generated from a 0x0 viewport.
.replace(
" createTree();n requestAnimationFrame(frame);",
` function startTreeWhenSized() {
resize();
if (W <= 0 || H <= 0) {
requestAnimationFrame(startTreeWhenSized);
return;
}
createTree();
requestAnimationFrame(frame);
}
requestAnimationFrame(startTreeWhenSized);`,
);
}
export function GenerativeTree({
speed = GENERATIVE_TREE_DEFAULTS.speed,
size = GENERATIVE_TREE_DEFAULTS.size,
particleAmount = GENERATIVE_TREE_DEFAULTS.particleAmount,
opacity = GENERATIVE_TREE_DEFAULTS.opacity,
hue = GENERATIVE_TREE_DEFAULTS.hue,
saturation = GENERATIVE_TREE_DEFAULTS.saturation,
brightness = GENERATIVE_TREE_DEFAULTS.brightness,
className = "",
style,
}: GenerativeTreeProps) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const [hostVisible, setHostVisible] = useState(true);
const [documentVisible, setDocumentVisible] = useState(() => typeof document === "undefined" || !document.hidden);
const safeSpeed = clamp(speed, 0, 3);
const paused = !hostVisible || !documentVisible || safeSpeed === 0;
const source = useMemo(() => buildFocusedDocument(size, particleAmount), [particleAmount, size]);
const postControls = useCallback(() => {
iframeRef.current?.contentWindow?.postMessage({
type: "generative-tree-controls",
controls: { speed: safeSpeed, paused },
}, "*");
}, [paused, safeSpeed]);
useEffect(() => {
const iframe = iframeRef.current;
if (!iframe || typeof IntersectionObserver === "undefined") return undefined;
const observer = new IntersectionObserver(([entry]) => setHostVisible(entry?.isIntersecting ?? true));
observer.observe(iframe);
return () => observer.disconnect();
}, []);
useEffect(() => {
if (typeof document === "undefined") return undefined;
const update = () => setDocumentVisible(!document.hidden);
document.addEventListener("visibilitychange", update);
return () => document.removeEventListener("visibilitychange", update);
}, []);
useEffect(() => {
postControls();
}, [postControls, source]);
return (
<div
className={`threeui-background generative-tree${className ? ` ${className}` : ""}`}
style={{ background: "#0a0a0a", pointerEvents: "auto", ...style }}
>
<iframe
ref={iframeRef}
title="Generative Tree background"
srcDoc={source}
sandbox="allow-scripts"
onLoad={postControls}
aria-hidden="true"
tabIndex={-1}
style={{
position: "absolute",
inset: 0,
display: "block",
width: "100%",
height: "100%",
border: 0,
background: "#0a0a0a",
opacity: clamp(opacity, 0.05, 1),
filter: `hue-rotate(${clamp(hue, -180, 180)}deg) saturate(${clamp(saturation, 0, 2)}) brightness(${clamp(brightness, 0.35, 1.8)})`,
}}
/>
</div>
);
}
/* ===== src/shaders/condensation/CondensationBackground.tsx (tsx) ===== */
import { useEffect, useRef } from "react";
import { CONDENSATION_DEFAULTS, createCondensationRenderer, type CondensationOptions } from "./condensationRenderer";
export type CondensationBackgroundProps = Partial<CondensationOptions> & { className?: string };
export function CondensationBackground({ className = "", ...props }: CondensationBackgroundProps) {
const hostRef = useRef<HTMLDivElement>(null), canvasRef = useRef<HTMLCanvasElement>(null), optionsRef = useRef({ ...CONDENSATION_DEFAULTS, ...props }); optionsRef.current = { ...CONDENSATION_DEFAULTS, ...props };
useEffect(() => { const host = hostRef.current, canvas = canvasRef.current; if (!host || !canvas) return undefined; const renderer = createCondensationRenderer(canvas, () => optionsRef.current); let frame = 0, visible = true;
const resize = () => { const bounds = host.getBoundingClientRect(); renderer.resize(bounds.width, bounds.height); renderer.render(performance.now()); }; const tick = (now: number) => { renderer.render(now); frame = visible && !document.hidden ? requestAnimationFrame(tick) : 0; }; const resizeObserver = new ResizeObserver(resize), intersection = new IntersectionObserver(([entry]) => { visible = entry?.isIntersecting ?? true; if (visible && !frame) frame = requestAnimationFrame(tick); if (!visible && frame) cancelAnimationFrame(frame), frame = 0; }); resizeObserver.observe(host); intersection.observe(host); resize(); frame = requestAnimationFrame(tick); return () => { if (frame) cancelAnimationFrame(frame); resizeObserver.disconnect(); intersection.disconnect(); renderer.dispose(); };
}, []);
return <div ref={hostRef} className={`threeui-background condensation${className ? ` ${className}` : ""}`} style={{ opacity: optionsRef.current.opacity }}><canvas ref={canvasRef} /></div>;
}
/* ===== src/shaders/elements/sources/elemental-marks.html (html) ===== */
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Elemental Marks — 水・雷・炎</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg: #060708;
--line: rgba(255,255,255,.07);
--ink: #e8e4dc;
--dim: rgba(232,228,220,.42);
--faint: rgba(232,228,220,.22);
--water: #6cd3ff;
--bolt: #aab4ff;
--fire: #ff9d6b;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; }
body {
background: var(--bg);
color: var(--ink);
font-family: "JetBrains Mono", monospace;
overflow: hidden;
}
header {
position: fixed; inset: 0 0 auto 0; z-index: 10;
display: flex; justify-content: space-between; align-items: baseline;
padding: 18px 22px;
pointer-events: none;
mix-blend-mode: screen;
}
header .brand {
font-family: "Inter", "Hiragino Sans", sans-serif; font-weight: 500;
font-size: 15px; letter-spacing: normal; text-transform: uppercase;
}
header .brand em { font-style: normal; color: var(--dim); margin-left: .8em; letter-spacing: normal; }
main { display: flex; height: 100dvh; }
.panel {
position: relative; flex: 1 1 0; min-width: 0;
transition: flex .7s cubic-bezier(.22,1,.36,1);
border-left: 1px solid var(--line);
opacity: 0; transform: translateY(14px);
animation: reveal 1.1s cubic-bezier(.22,1,.36,1) forwards;
}
.panel:first-child { border-left: 0; }
.panel:nth-child(1) { animation-delay: .05s; }
.panel:nth-child(2) { animation-delay: .18s; }
.panel:nth-child(3) { animation-delay: .31s; }
@keyframes reveal { to { opacity: 1; transform: none; } }
@media (hover:hover) and (min-width: 761px) {
.panel:hover { flex: 1.4 1 0; }
}
.panel canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.info {
position: absolute; left: 22px; bottom: 20px; z-index: 5;
pointer-events: none;
}
.info .romaji {
font-size: 10px; letter-spacing: normal; color: var(--faint);
margin-bottom: 10px; text-transform: uppercase;
}
.info h2 {
font-family: "Inter", "Hiragino Sans", sans-serif; font-weight: 500;
font-size: clamp(20px, 2.2vw, 30px); letter-spacing: normal;
color: var(--ink);
}
.info .tech {
margin-top: 8px; font-size: 10px; letter-spacing: normal;
color: var(--dim); max-width: 30ch; line-height: 1.7;
}
.kanji {
position: absolute; top: 46px; right: 18px; z-index: 5;
font-family: "Inter", "Hiragino Sans", "Yu Gothic", sans-serif; font-weight: 800;
font-size: clamp(64px, 8vw, 120px); line-height: 1;
color: transparent;
-webkit-text-stroke: 1px var(--accent, rgba(255,255,255,.25));
opacity: .55; pointer-events: none;
transition: opacity .5s, color .5s;
}
.panel:hover .kanji { opacity: .95; color: var(--accent); -webkit-text-stroke: 0; text-shadow: 0 0 34px var(--accent); }
.panel[data-fx="water"] { --accent: var(--water); }
.panel[data-fx="lightning"] { --accent: var(--bolt); }
.panel[data-fx="fire"] { --accent: var(--fire); }
.hint {
position: fixed; bottom: 18px; right: 22px;
z-index: 10; font-size: 9px; letter-spacing: normal; color: var(--faint);
text-transform: uppercase; pointer-events: none; white-space: nowrap;
animation: pulse 4s ease-in-out infinite;
}
@keyframes pulse { 50% { opacity: .35; } }
.fail {
position: absolute; inset: 0; display: none; place-items: center;
font-size: 11px; letter-spacing: normal; color: var(--dim); text-align: center;
}
@media (max-width: 760px) {
body { overflow-y: auto; }
main { flex-direction: column; height: auto; }
.panel { height: 72dvh; border-left: 0; border-top: 1px solid var(--line); }
.hint { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.panel { animation-duration: .01s; }
.hint { animation: none; }
}
</style>
</head>
<body>
<header>
<div class="brand">Elemental Marks<em>水 雷 炎</em></div>
</header>
<main>
<section class="panel" data-fx="water">
<canvas></canvas>
<div class="kanji">水</div>
<div class="info">
<div class="romaji">Sui — Water</div>
<h2>OpenAI</h2>
<p class="tech">ping-pong wave equation · gradient refraction of the mark</p>
</div>
<div class="fail">WEBGL2 FLOAT BUFFERS UNAVAILABLE</div>
</section>
<section class="panel" data-fx="lightning">
<canvas></canvas>
<div class="kanji">雷</div>
<div class="info">
<div class="romaji">Rai — Lightning</div>
<h2>Anthropic</h2>
<p class="tech">contour distance field · fBm zigzag arcs crawling the outline</p>
</div>
<div class="fail">WEBGL2 UNAVAILABLE</div>
</section>
<section class="panel" data-fx="fire">
<canvas></canvas>
<div class="kanji">炎</div>
<div class="info">
<div class="romaji">En — Flame</div>
<h2>Claude</h2>
<p class="tech">noise ribbons licking upward from the edge · rising embers</p>
</div>
<div class="fail">WEBGL2 UNAVAILABLE</div>
</section>
</main>
<div class="hint">move — 触れてみて — press</div>
<script>
'use strict';
/* ---------------- logo paths (simple-icons, viewBox 0 0 24 24) ---------------- */
const LOGO_PATHS = {
openai: "M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z",
anthropic: "M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z",
claude: "m4.7144 15.9555 4.7174-2.6471.079-.2307-.079-.1275h-.2307l-.7893-.0486-2.6956-.0729-2.3375-.0971-2.2646-.1214-.5707-.1215-.5343-.7042.0546-.3522.4797-.3218.686.0608 1.5179.1032 2.2767.1578 1.6514.0972 2.4468.255h.3886l.0546-.1579-.1336-.0971-.1032-.0972L6.973 9.8356l-2.55-1.6879-1.3356-.9714-.7225-.4918-.3643-.4614-.1578-1.0078.6557-.7225.8803.0607.2246.0607.8925.686 1.9064 1.4754 2.4893 1.8336.3643.3035.1457-.1032.0182-.0728-.164-.2733-1.3539-2.4467-1.445-2.4893-.6435-1.032-.17-.6194c-.0607-.255-.1032-.4674-.1032-.7285L6.287.1335 6.6997 0l.9957.1336.419.3642.6192 1.4147 1.0018 2.2282 1.5543 3.0296.4553.8985.2429.8318.091.255h.1579v-.1457l.1275-1.706.2368-2.0947.2307-2.6957.0789-.7589.3764-.9107.7468-.4918.5828.2793.4797.686-.0668.4433-.2853 1.8517-.5586 2.9021-.3643 1.9429h.2125l.2429-.2429.9835-1.3053 1.6514-2.0643.7286-.8196.85-.9046.5464-.4311h1.0321l.759 1.1293-.34 1.1657-1.0625 1.3478-.8804 1.1414-1.2628 1.7-.7893 1.36.0729.1093.1882-.0183 2.8535-.607 1.5421-.2794 1.8396-.3157.8318.3886.091.3946-.3278.8075-1.967.4857-2.3072.4614-3.4364.8136-.0425.0304.0486.0607 1.5482.1457.6618.0364h1.621l3.0175.2247.7892.522.4736.6376-.079.4857-1.2142.6193-1.6393-.3886-3.825-.9107-1.3113-.3279h-.1822v.1093l1.0929 1.0686 2.0035 1.8092 2.5075 2.3314.1275.5768-.3218.4554-.34-.0486-2.2039-1.6575-.85-.7468-1.9246-1.621h-.1275v.17l.4432.6496 2.3436 3.5214.1214 1.0807-.17.3521-.6071.2125-.6679-.1214-1.3721-1.9246L14.38 17.959l-1.1414-1.9428-.1397.079-.674 7.2552-.3156.3703-.7286.2793-.6071-.4614-.3218-.7468.3218-1.4753.3886-1.9246.3157-1.53.2853-1.9004.17-.6314-.0121-.0425-.1397.0182-1.4328 1.9672-2.1796 2.9446-1.7243 1.8456-.4128.164-.7164-.3704.0667-.6618.4008-.5889 2.386-3.0357 1.4389-1.882.929-1.0868-.0062-.1579h-.0546l-6.3385 4.1164-1.1293.1457-.4857-.4554.0608-.7467.2307-.2429 1.9064-1.3114Z"
};
/* ---------------- rasterize -> SDF (chamfer) + edge point extraction ---------------- */
const SDF_SIZE = 512;
const SDF_SPREAD = 128; // px each side of the edge
const D_RANGE = SDF_SPREAD * 2 / SDF_SIZE; // decoded sdf span in mask-uv units
function rasterizeLogo(pathStr) {
const c = document.createElement('canvas');
c.width = c.height = SDF_SIZE;
const ctx = c.getContext('2d');
const box = SDF_SIZE * 0.60;
const s = box / 24;
const off = (SDF_SIZE - box) / 2;
ctx.setTransform(s, 0, 0, s, off, off);
ctx.fillStyle = '#fff';
ctx.fill(new Path2D(pathStr));
return ctx.getImageData(0, 0, SDF_SIZE, SDF_SIZE);
}
function chamfer(d, w, h) {
const D1 = 1, D2 = Math.SQRT2;
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
const i = y * w + x; let v = d[i];
if (x > 0) v = Math.min(v, d[i - 1] + D1);
if (y > 0) {
v = Math.min(v, d[i - w] + D1);
if (x > 0) v = Math.min(v, d[i - w - 1] + D2);
if (x < w - 1) v = Math.min(v, d[i - w + 1] + D2);
}
d[i] = v;
}
for (let y = h - 1; y >= 0; y--) for (let x = w - 1; x >= 0; x--) {
const i = y * w + x; let v = d[i];
if (x < w - 1) v = Math.min(v, d[i + 1] + D1);
if (y < h - 1) {
v = Math.min(v, d[i + w] + D1);
if (x < w - 1) v = Math.min(v, d[i + w + 1] + D2);
if (x > 0) v = Math.min(v, d[i + w - 1] + D2);
}
d[i] = v;
}
}
function buildSDF(img) {
const n = SDF_SIZE * SDF_SIZE;
const dOut = new Float32Array(n), dIn = new Float32Array(n);
for (let i = 0; i < n; i++) {
const inside = img.data[i * 4 + 3] > 127;
dOut[i] = inside ? 0 : 1e9;
dIn[i] = inside ? 1e9 : 0;
}
chamfer(dOut, SDF_SIZE, SDF_SIZE);
chamfer(dIn, SDF_SIZE, SDF_SIZE);
const enc = new Uint8Array(n);
for (let i = 0; i < n; i++) {
const d = dOut[i] - dIn[i]; // + outside, - inside
enc[i] = Math.max(0, Math.min(255, Math.round((0.5 + 0.5 * d / SDF_SPREAD) * 255)));
}
return enc;
}
/* contour pixels + outward normals, in y-up mask uv */
function edgePoints(img) {
const S = SDF_SIZE, pts = [];
const a = (x, y) => img.data[(y * S + x) * 4 + 3] > 127;
for (let y = 1; y < S - 1; y++) {
for (let x = 1; x < S - 1; x++) {
if (!a(x, y)) continue;
const l = a(x - 1, y), r = a(x + 1, y), u = a(x, y - 1), dn = a(x, y + 1);
if (l && r && u && dn) continue;
const gx = (r ? 1 : 0) - (l ? 1 : 0);
const gy = (dn ? 1 : 0) - (u ? 1 : 0);
let nx = -gx, ny = gy; // outward, y flipped to y-up
const len = Math.hypot(nx, ny);
if (!len) { nx = 0; ny = 1; } else { nx /= len; ny /= len; }
pts.push((x + 0.5) / S, 1 - (y + 0.5) / S, nx, ny);
}
}
return pts;
}
function makeParticleData(pts, count) {
const data = new Float32Array(count * 5);
const nPts = pts.length / 4;
for (let i = 0; i < count; i++) {
const j = (Math.random() * nPts) | 0;
data[i * 5] = pts[j * 4];
data[i * 5 + 1] = pts[j * 4 + 1];
data[i * 5 + 2] = pts[j * 4 + 2];
data[i * 5 + 3] = pts[j * 4 + 3];
data[i * 5 + 4] = Math.random() * 100 + i * 0.618;
}
return data;
}
/* ---------------- shaders ---------------- */
const VERT = `#version 300 es
out vec2 vUv;
void main(){
vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
vUv = p;
gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
}`;
const GLSL_COMMON = `
precision highp float;
uniform sampler2D uSDF;
uniform float uTime;
uniform float uAspect;
uniform vec2 uScale;
uniform vec2 uShift;
uniform vec3 uPointer; // uv.xy, active
in vec2 vUv;
out vec4 frag;
float hash21(vec2 p){
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
float vnoise(vec2 p){
vec2 i = floor(p), f = fract(p);
f = f * f * (3.0 - 2.0 * f);
float a = hash21(i), b = hash21(i + vec2(1,0));
float c = hash21(i + vec2(0,1)), d = hash21(i + vec2(1,1));
return mix(mix(a,b,f.x), mix(c,d,f.x), f.y);
}
float fbm(vec2 p){
float v = 0.0, a = 0.5;
mat2 r = mat2(0.8, -0.6, 0.6, 0.8);
for (int i = 0; i < 5; i++){ v += a * vnoise(p); p = r * p * 2.03; a *= 0.5; }
return v;
}
/* signed distance in mask-uv units; extended analytically past the texture
border so out-of-range samples keep growing instead of clamping (kills
the rectangular clamp artifacts) */
float sdf(vec2 uv){
vec2 m = 0.5 + (uv - 0.5 - uShift) * uScale;
vec2 mc = clamp(m, 0.0, 1.0);
float d = (texture(uSDF, vec2(mc.x, 1.0 - mc.y)).r - 0.5) * ${D_RANGE.toFixed(4)};
return d + length(m - mc);
}
float edgeFade(vec2 uv){
return smoothstep(0.0, 0.05, uv.x) * smoothstep(1.0, 0.95, uv.x)
* smoothstep(0.0, 0.05, uv.y) * smoothstep(1.0, 0.95, uv.y);
}
`;
/* water simulation step (ping-pong, r = h, g = h_prev) */
const FRAG_SIM = `#version 300 es
precision highp float;
uniform sampler2D uState;
uniform vec2 uTexel;
uniform vec3 uDrop; // sim-uv.xy, strength
in vec2 vUv;
out vec4 frag;
void main(){
vec2 s = texture(uState, vUv).rg;
float l = texture(uState, vUv - vec2(uTexel.x, 0.0)).r;
float r = texture(uState, vUv + vec2(uTexel.x, 0.0)).r;
float u = texture(uState, vUv + vec2(0.0, uTexel.y)).r;
float d = texture(uState, vUv - vec2(0.0, uTexel.y)).r;
float next = (l + r + u + d) * 0.5 - s.g;
next *= 0.984;
if (uDrop.z != 0.0){
float dd = distance(vUv, uDrop.xy);
next += uDrop.z * exp(-dd * dd * 3800.0);
}
frag = vec4(next, s.r, 0.0, 1.0);
}`;
const FRAG_WATER = `#version 300 es
${GLSL_COMMON}
uniform sampler2D uState;
uniform vec2 uSimTexel;
/* cover-map panel uv into the square sim so rings stay circular on screen */
vec2 simUV(vec2 uv){
return 0.5 + (uv - 0.5) * vec2(uAspect, 1.0) / max(uAspect, 1.0);
}
void main(){
vec2 suv = simUV(vUv);
float h = texture(uState, suv).r;
float hx = texture(uState, suv + vec2(uSimTexel.x, 0.0)).r - texture(uState, suv - vec2(uSimTexel.x, 0.0)).r;
float hy = texture(uState, suv + vec2(0.0, uSimTexel.y)).r - texture(uState, suv - vec2(0.0, uSimTexel.y)).r;
vec2 grad = vec2(hx, hy);
vec3 nrm = normalize(vec3(-grad * 30.0, 1.0));
vec2 ruv = vUv + grad * 0.22; // refracted lookup
float d = sdf(ruv);
// deep water base
vec3 col = mix(vec3(0.006, 0.030, 0.055), vec3(0.012, 0.078, 0.125), vUv.y * 0.8 + h * 0.25);
col += vec3(0.02, 0.10, 0.13) * fbm(vUv * vec2(uAspect, 1.0) * 3.0 + uTime * 0.05) * 0.3;
// the mark, seen through the surface
float logo = smoothstep(0.005, -0.005, d);
float glow = exp(-max(d, 0.0) / 0.09) * 0.26;
vec3 markCol = mix(vec3(0.55, 0.92, 1.0), vec3(0.95, 1.0, 1.0), logo * 0.6);
col += markCol * (logo * 0.92 + glow);
// ripple shading: crests bright, troughs barely darker
col += vec3(0.09, 0.30, 0.40) * clamp(h * 1.8, -0.06, 1.0);
col += vec3(0.25, 0.55, 0.65) * pow(clamp(h * 2.6, 0.0, 1.0), 2.0) * 0.5;
// specular glint
vec3 L = normalize(vec3(-0.35, 0.55, 0.75));
vec3 H = normalize(L + vec3(0.0, 0.0, 1.0));
float spec = pow(max(dot(nrm, H), 0.0), 150.0);
col += spec * vec3(0.65, 0.9, 1.0) * 0.9;
col *= 0.35 + 0.65 * edgeFade(vUv);
col += (hash21(vUv * 617.0 + uTime) - 0.5) / 128.0;
frag = vec4(col, 1.0);
}`;
const FRAG_LIGHTNING = `#version 300 es
${GLSL_COMMON}
void main(){
float t = uTime;
float d = sdf(vUv);
vec2 pp = vUv * vec2(uAspect, 1.0);
// storm backdrop
vec3 col = vec3(0.010, 0.011, 0.024);
col += vec3(0.035, 0.04, 0.075) * fbm(pp * 2.2 + vec2(t * 0.04, t * 0.01));
// occasional sheet flash
float slot = floor(t * 0.45);
float ph = fract(t * 0.45);
float big = step(0.68, hash21(vec2(slot, 3.7))) * exp(-ph * 16.0);
// the mark: dark slate body, lit by flashes
float inside = smoothstep(0.005, -0.005, d);
vec3 body = vec3(0.055, 0.06, 0.10) + big * vec3(0.55, 0.58, 0.85);
col = mix(col, body, inside);
float rim = exp(-abs(d) / 0.012) * 0.5;
col += rim * vec3(0.35, 0.4, 0.8);
// pointer draws the storm closer
vec2 dp = (vUv - uPointer.xy) * vec2(uAspect, 1.0);
float pb = 1.0 + 2.4 * uPointer.z * exp(-dot(dp, dp) * 34.0);
// fBm zigzag arcs crawling the contour
float fade = edgeFade(vUv);
for (int i = 0; i < 3; i++){
float fi = float(i);
float n = fbm(pp * mix(3.0, 8.0, fi / 2.0) + vec2(t * (1.2 + fi * 0.9), fi * 19.3)) - 0.5;
float e = abs(d + n * 0.11);
float fl = hash21(vec2(floor(t * (6.0 + fi * 3.0)), fi));
fl = mix(0.15, 1.0, smoothstep(0.25, 0.95, fl));
float bolt = pow(0.0042 * pb / (e + 0.0022), 1.35) * fl;
bolt = min(bolt, 6.0);
col += bolt * mix(vec3(0.38, 0.42, 1.0), vec3(0.92, 0.9, 1.0), fi / 2.0) * fade;
}
// tight white core arc
float n0 = fbm(pp * 5.0 + vec2(t * 2.4, 7.7)) - 0.5;
float e0 = abs(d + n0 * 0.10);
float corefl = mix(0.25, 1.15, hash21(vec2(floor(t * 9.0), 11.0)));
col += min(pow(0.0026 * pb / (e0 + 0.0016), 1.6), 8.0) * corefl * vec3(1.0) * fade;
col += big * vec3(0.30, 0.32, 0.55) * (0.25 + 0.75 * inside);
col = col / (1.0 + col * 0.18);
col *= 0.4 + 0.6 * edgeFade(vUv);
col += (hash21(vUv * 431.0 + uTime) - 0.5) / 128.0;
frag = vec4(col, 1.0);
}`;
const FRAG_FIRE = `#version 300 es
${GLSL_COMMON}
uniform float uWind;
vec3 fireRamp(float x){
x = clamp(x, 0.0, 1.0);
vec3 c = mix(vec3(0.0), vec3(0.45, 0.06, 0.02), smoothstep(0.02, 0.30, x));
c = mix(c, vec3(0.95, 0.34, 0.08), smoothstep(0.28, 0.55, x));
c = mix(c, vec3(1.0, 0.68, 0.22), smoothstep(0.52, 0.78, x));
c = mix(c, vec3(1.0, 0.97, 0.88), smoothstep(0.78, 0.97, x));
return c;
}
void main(){
float t = uTime;
float d0 = sdf(vUv);
vec2 pp = vUv * vec2(uAspect, 1.0);
// which way is "up and away" from the mark (wide epsilon: the gradient
// creases on the medial axis between rays and narrow sampling bands there)
float e = 0.012;
vec2 g = vec2(sdf(vUv + vec2(e, 0.0)) - sdf(vUv - vec2(e, 0.0)),
sdf(vUv + vec2(0.0, e)) - sdf(vUv - vec2(0.0, e)));
float upw = smoothstep(-0.2, 0.9, normalize(g + 1e-5).y);
// pointer stokes the flame
vec2 dp = (vUv - uPointer.xy) * vec2(uAspect, 1.0);
float stoke = uPointer.z * exp(-dot(dp, dp) * 26.0);
// thin tongues: x-squashed scrolling fBm, advected progressively with
// height so flames stay attached at the edge and lick upward
float n1 = fbm(vec2(pp.x * 12.0, pp.y * 3.0 - t * 2.1));
float n2 = fbm(vec2(pp.x * 6.0 + 41.3, pp.y * 1.7 - t * 1.25));
float d0f = d0 + (hash21(vUv * 771.0 + t * 1.3) - 0.5) * 0.004;
float rise = max(0.0, n1 * 1.15 + n2 * 0.65 - 0.5) * 1.3; // many columns ~0, few run hot
float adv = smoothstep(-0.03, 0.22, d0f); // 0 at the edge, grows above
float lick = 0.30 * (1.0 + stoke * 0.8);
vec2 offs = vec2((n2 - 0.5) * 0.05 + uWind * 0.05, -rise * lick * adv);
float df = sdf(vUv + offs);
df += (hash21(vUv * 997.0 + uTime) - 0.5) * 0.004; // dither the 8-bit field
float heat = clamp(1.0 - df / 0.07, 0.0, 1.0);
heat *= mix(0.1, 1.0, upw);
heat *= 1.0 - 0.45 * smoothstep(0.08, 0.26, d0f); // gentle thinning with height
float tongue = smoothstep(0.25, 0.70, heat); // sharp separated licks
float body = pow(heat, 2.2) * 0.45; // faint haze beneath them
float f = max(tongue, body) * (0.95 + 0.25 * stoke);
vec3 col = vec3(0.012, 0.006, 0.004);
col += fireRamp(f * (1.05 - 0.4 * smoothstep(0.02, 0.25, d0))); // white base, orange tips
// white-hot mark with a warm halo, like paper mid-combustion
float inside = smoothstep(0.004, -0.004, d0);
float core = smoothstep(0.0, -0.02, d0);
vec3 markCol = mix(vec3(1.0, 0.92, 0.78), vec3(1.0, 1.0, 0.98), core);
col = mix(col, markCol, inside);
col += exp(-max(d0, 0.0) / 0.035) * vec3(1.0, 0.55, 0.22) * 0.35 * (1.0 - inside * 0.9);
col += exp(-max(d0, 0.0) / 0.12) * vec3(0.55, 0.14, 0.03) * 0.35 * (0.6 + 0.4 * n2);
col = col / (1.0 + col * 0.12);
col *= 0.35 + 0.65 * edgeFade(vUv);
col += (hash21(vUv * 523.0 + uTime) - 0.5) / 128.0;
frag = vec4(col, 1.0);
}`;
/* ---------------- particles: stateless GPU point sprites off the contour ---------------- */
const PART_VERT = `#version 300 es
precision highp float;
layout(location=0) in vec2 aPos; // spawn point, y-up mask uv
layout(location=1) in vec2 aNorm; // outward contour normal
layout(location=2) in float aSeed;
uniform float uTime;
uniform vec2 uScale;
uniform vec2 uShift;
uniform float uDpr;
uniform float uWind;
uniform vec4 uCfgA; // travel, lifeMin, lifeMax, alongNormal
uniform vec4 uCfgB; // wiggle, sizeMin, sizeMax, sparse
out float vFade;
out float vMixC;
float h1(float n){ return fract(sin(n) * 43758.5453); }
void main(){
float hs = h1(aSeed * 1.31);
float life = mix(uCfgA.y, uCfgA.z, hs);
float tt = uTime / life + aSeed * 13.7;
float ph = fract(tt);
float cyc = floor(tt);
float r1 = h1(aSeed + cyc * 0.317);
float r2 = h1(aSeed * 2.13 + cyc * 0.771);
float on = step(uCfgB.w, r2);
vec2 dir = normalize(mix(vec2(0.0, 1.0), aNorm, uCfgA.w) + (vec2(r1, h1(r1 * 7.0)) - 0.5) * 0.8);
float trav = uCfgA.x * (0.45 + 0.9 * r1);
vec2 p = aPos + aNorm * 0.004 + dir * trav * ph;
p.x += sin(ph * 10.0 + r1 * 40.0 + uTime * 0.5) * uCfgB.x * ph;
p.x += uWind * 0.08 * ph;
vec2 uv = 0.5 + uShift + (p - 0.5) / uScale;
vFade = on * smoothstep(0.0, 0.12, ph) * smoothstep(1.0, 0.5, ph) * mix(0.35, 1.0, r2);
vMixC = h1(aSeed * 3.7 + cyc);
gl_Position = vec4(uv * 2.0 - 1.0, 0.0, 1.0);
gl_PointSize = mix(uCfgB.y, uCfgB.z, h1(aSeed * 5.11 + cyc)) * uDpr * (1.0 - 0.45 * ph);
}`;
const PART_FRAG = `#version 300 es
precision highp float;
uniform vec3 uColA;
uniform vec3 uColB;
in float vFade;
in float vMixC;
out vec4 frag;
void main(){
vec2 q = gl_PointCoord * 2.0 - 1.0;
float r2 = dot(q, q);
if (r2 > 1.0) discard;
float a = exp(-r2 * 3.5) * (1.0 - r2);
frag = vec4(mix(uColA, uColB, vMixC) * a * vFade, 1.0);
}`;
/* ---------------- GL helpers ---------------- */
function compile(gl, type, src) {
const sh = gl.createShader(type);
gl.shaderSource(sh, src);
gl.compileShader(sh);
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
console.error(gl.getShaderInfoLog(sh), src);
return null;
}
return sh;
}
function program(gl, vertSrc, fragSrc) {
const p = gl.createProgram();
const vs = compile(gl, gl.VERTEX_SHADER, vertSrc);
const fs = compile(gl, gl.FRAGMENT_SHADER, fragSrc);
if (!vs || !fs) return null;
gl.attachShader(p, vs); gl.attachShader(p, fs);
gl.linkProgram(p);
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) {
console.error(gl.getProgramInfoLog(p));
return null;
}
return p;
}
const DPR = Math.min(window.devicePixelRatio || 1, 1.75);
const REDUCED = matchMedia('(prefers-reduced-motion: reduce)').matches;
const SIM_RES = 512;
class Panel {
constructor(el, fragSrc, logo, opts) {
this.el = el;
this.canvas = el.querySelector('canvas');
this.opts = opts;
this.pointer = { x: 0.5, y: 0.5, active: 0 };
this.wind = 0;
this.windTarget = 0;
this.dropQueue = [];
this.nextAutoDrop = 0.6;
this.needsResize = false;
this.ok = false;
const gl = this.canvas.getContext('webgl2', { alpha: false, antialias: false });
if (!gl) return this.fail();
this.gl = gl;
this.prog = program(gl, VERT, fragSrc);
if (!this.prog) return this.fail();
this.uni = {};
for (const n of ['uSDF','uTime','uAspect','uScale','uShift','uPointer','uState','uSimTexel','uWind'])
this.uni[n] = gl.getUniformLocation(this.prog, n);
// sdf texture
this.sdfTex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, this.sdfTex);
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.R8, SDF_SIZE, SDF_SIZE, 0, gl.RED, gl.UNSIGNED_BYTE, logo.sdf);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
if (opts.sim) {
if (!gl.getExtension('EXT_color_buffer_float')) return this.fail();
this.simProg = program(gl, VERT, FRAG_SIM);
if (!this.simProg) return this.fail();
this.simUni = {
uState: gl.getUniformLocation(this.simProg, 'uState'),
uTexel: gl.getUniformLocation(this.simProg, 'uTexel'),
uDrop: gl.getUniformLocation(this.simProg, 'uDrop'),
};
this.simTex = []; this.simFbo = [];
for (let i = 0; i < 2; i++) {
const t = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, t);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RG16F, SIM_RES, SIM_RES, 0, gl.RG, gl.HALF_FLOAT, null);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
const f = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, f);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, t, 0);
this.simTex.push(t); this.simFbo.push(f);
}
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
this.simSrc = 0;
}
// particle system
const P = opts.particles;
if (P) {
this.partProg = program(gl, PART_VERT, PART_FRAG);
if (!this.partProg) return this.fail();
this.partUni = {};
for (const n of ['uTime','uScale','uShift','uDpr','uWind','uCfgA','uCfgB','uColA','uColB'])
this.partUni[n] = gl.getUniformLocation(this.partProg, n);
const data = makeParticleData(logo.edges, P.count);
this.partVao = gl.createVertexArray();
gl.bindVertexArray(this.partVao);
const buf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 20, 0);
gl.enableVertexAttribArray(1);
gl.vertexAttribPointer(1, 2, gl.FLOAT, false, 20, 8);
gl.enableVertexAttribArray(2);
gl.vertexAttribPointer(2, 1, gl.FLOAT, false, 20, 16);
gl.bindVertexArray(null);
}
this.resize();
// resize inside the rAF (before drawing), never in the RO callback —
// RO fires after rAF, so resizing there clears the canvas post-draw
// and every hover transition frame paints black
new ResizeObserver(() => {
if (REDUCED) this.resize(); else this.needsResize = true;
}).observe(el);
this.bindPointer();
this.ok = true;
}
fail() {
this.el.querySelector('.fail').style.display = 'grid';
this.canvas.style.display = 'none';
}
resize() {
const r = this.el.getBoundingClientRect();
const w = Math.max(2, Math.round(r.width * DPR));
const h = Math.max(2, Math.round(r.height * DPR));
if (this.canvas.width !== w || this.canvas.height !== h) {
this.canvas.width = w; this.canvas.height = h;
}
this.aspect = w / h;
if (REDUCED && this.ok) this.draw(0.001);
}
bindPointer() {
const uv = e => {
const r = this.el.getBoundingClientRect();
return { x: (e.clientX - r.left) / r.width, y: 1 - (e.clientY - r.top) / r.height };
};
let last = null, lastT = 0;
this.el.addEventListener('pointermove', e => {
const p = uv(e), now = performance.now();
this.pointer.x = p.x; this.pointer.y = p.y; this.pointer.active = 1;
if (last) {
const dt = Math.max(8, now - lastT);
const dx = p.x - last.x, dy = p.y - last.y;
const speed = Math.hypot(dx, dy) / (dt / 1000);
if (this.opts.sim && speed > 0.05 && this.dropQueue.length < 6)
this.dropQueue.push({ x: p.x, y: p.y, s: Math.min(speed * 0.14, 0.55) });
this.windTarget = Math.max(-1, Math.min(1, dx / (dt / 1000) * 0.55));
}
last = p; lastT = now;
});
this.el.addEventListener('pointerdown', e => {
const p = uv(e);
if (this.opts.sim) this.dropQueue.push({ x: p.x, y: p.y, s: 0.9 });
this.pointer.x = p.x; this.pointer.y = p.y; this.pointer.active = 1.6;
});
this.el.addEventListener('pointerup', () => { this.pointer.active = 1; });
this.el.addEventListener('pointerleave', () => { this.pointer.active = 0; last = null; this.windTarget = 0; });
}
/* panel uv -> square sim uv (must match simUV() in the water shader) */
toSimUV(x, y) {
const a = this.aspect, m = Math.max(a, 1);
return { x: 0.5 + (x - 0.5) * a / m, y: 0.5 + (y - 0.5) / m };
}
stepSim(t) {
const gl = this.gl;
if (t > this.nextAutoDrop) {
this.dropQueue.push({ x: 0.12 + Math.random() * 0.76, y: 0.12 + Math.random() * 0.76, s: 0.12 + Math.random() * 0.3 });
this.nextAutoDrop = t + 0.5 + Math.random() * 1.4;
}
gl.useProgram(this.simProg);
gl.viewport(0, 0, SIM_RES, SIM_RES);
gl.uniform2f(this.simUni.uTexel, 1 / SIM_RES, 1 / SIM_RES);
for (let i = 0; i < 2; i++) {
const drop = this.dropQueue.shift();
if (drop) {
const s = this.toSimUV(drop.x, drop.y);
gl.uniform3f(this.simUni.uDrop, s.x, s.y, drop.s);
} else {
gl.uniform3f(this.simUni.uDrop, 0, 0, 0);
}
gl.bindFramebuffer(gl.FRAMEBUFFER, this.simFbo[1 - this.simSrc]);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, this.simTex[this.simSrc]);
gl.uniform1i(this.simUni.uState, 1);
gl.drawArrays(gl.TRIANGLES, 0, 3);
this.simSrc = 1 - this.simSrc;
}
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
}
scaleVec() {
const zoom = this.opts.zoom, a = this.aspect;
const fit = Math.min(a, 1);
return [(a / fit) * zoom, (1 / fit) * zoom];
}
draw(t) {
const gl = this.gl;
if (this.needsResize) { this.needsResize = false; this.resize(); }
if (this.opts.sim) this.stepSim(t);
this.wind += (this.windTarget - this.wind) * 0.04;
this.windTarget *= 0.97;
gl.useProgram(this.prog);
gl.viewport(0, 0, this.canvas.width, this.canvas.height);
const sc = this.scaleVec();
gl.uniform1f(this.uni.uTime, t);
gl.uniform1f(this.uni.uAspect, this.aspect);
gl.uniform2f(this.uni.uScale, sc[0], sc[1]);
gl.uniform2f(this.uni.uShift, this.opts.shift[0], this.opts.shift[1]);
gl.uniform3f(this.uni.uPointer, this.pointer.x, this.pointer.y, this.pointer.active);
if (this.uni.uWind) gl.uniform1f(this.uni.uWind, this.wind);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.sdfTex);
gl.uniform1i(this.uni.uSDF, 0);
if (this.opts.sim) {
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, this.simTex[this.simSrc]);
gl.uniform1i(this.uni.uState, 1);
gl.uniform2f(this.uni.uSimTexel, 1 / SIM_RES, 1 / SIM_RES);
}
gl.drawArrays(gl.TRIANGLES, 0, 3);
const P = this.opts.particles;
if (P) {
gl.useProgram(this.partProg);
gl.uniform1f(this.partUni.uTime, t);
gl.uniform2f(this.partUni.uScale, sc[0], sc[1]);
gl.uniform2f(this.partUni.uShift, this.opts.shift[0], this.opts.shift[1]);
gl.uniform1f(this.partUni.uDpr, DPR);
gl.uniform1f(this.partUni.uWind, this.wind);
gl.uniform4f(this.partUni.uCfgA, P.travel, P.lifeMin, P.lifeMax, P.alongNormal);
gl.uniform4f(this.partUni.uCfgB, P.wiggle, P.sizeMin, P.sizeMax, P.sparse);
gl.uniform3f(this.partUni.uColA, P.colA[0], P.colA[1], P.colA[2]);
gl.uniform3f(this.partUni.uColB, P.colB[0], P.colB[1], P.colB[2]);
gl.enable(gl.BLEND);
gl.blendFunc(gl.ONE, gl.ONE);
gl.bindVertexArray(this.partVao);
gl.drawArrays(gl.POINTS, 0, P.count);
gl.bindVertexArray(null);
gl.disable(gl.BLEND);
}
}
}
/* ---------------- boot ---------------- */
function buildLogo(path) {
const img = rasterizeLogo(path);
return { sdf: buildSDF(img), edges: edgePoints(img) };
}
const logos = {
openai: buildLogo(LOGO_PATHS.openai),
anthropic: buildLogo(LOGO_PATHS.anthropic),
claude: buildLogo(LOGO_PATHS.claude),
};
const panels = [
new Panel(document.querySelector('[data-fx="water"]'), FRAG_WATER, logos.openai, {
sim: true, zoom: 1.06, shift: [0, 0],
particles: {
count: 160, travel: 0.10, lifeMin: 4.0, lifeMax: 8.0, alongNormal: 0.15,
wiggle: 0.02, sizeMin: 1.5, sizeMax: 3.5, sparse: 0.5,
colA: [0.10, 0.24, 0.30], colB: [0.22, 0.40, 0.48],
},
}),
new Panel(document.querySelector('[data-fx="lightning"]'), FRAG_LIGHTNING, logos.anthropic, {
sim: false, zoom: 1.10, shift: [0, 0],
particles: {
count: 240, travel: 0.055, lifeMin: 0.3, lifeMax: 0.9, alongNormal: 1.0,
wiggle: 0.015, sizeMin: 1.2, sizeMax: 2.6, sparse: 0.8,
colA: [0.45, 0.50, 1.0], colB: [0.95, 0.95, 1.0],
},
}),
new Panel(document.querySelector('[data-fx="fire"]'), FRAG_FIRE, logos.claude, {
sim: false, zoom: 1.16, shift: [0, -0.04],
particles: {
count: 420, travel: 0.30, lifeMin: 1.4, lifeMax: 3.0, alongNormal: 0.35,
wiggle: 0.035, sizeMin: 1.6, sizeMax: 4.5, sparse: 0.55,
colA: [1.0, 0.62, 0.28], colB: [1.0, 0.30, 0.10],
},
}),
].filter(p => p.ok);
const t0 = performance.now();
function loop() {
const t = (performance.now() - t0) / 1000;
for (const p of panels) p.draw(t);
requestAnimationFrame(loop);
}
if (!REDUCED) requestAnimationFrame(loop);
else for (const p of panels) p.draw(0.001);
</script>
</body>
</html>
/* ===== src/shaders/elements/sources/generative-tree.html (html) ===== */
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Generative Branching Tree</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #0a0a0a; }
canvas { display: block; width: 100vw; height: 100vh; }
.label {
position: fixed;
top: 20px;
left: 24px;
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-size: 11px;
font-weight: 500;
letter-spacing: 0.15em;
text-transform: uppercase;
color: rgba(200, 149, 108, 0.5);
z-index: 10;
pointer-events: none;
user-select: none;
}
</style>
</head>
<body>
<div class="label">04 / Generative Tree</div>
<canvas id="canvas"></canvas>
<script>
(function() {
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
let W, H;
// --- Utilities ---
function lerp(a, b, t) { return a + (b - a) * t; }
function rand(lo, hi) { return Math.random() * (hi - lo) + lo; }
function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
function smoothstep(a, b, t) {
t = Math.max(0, Math.min(1, (t - a) / (b - a)));
return t * t * (3 - 2 * t);
}
// --- Configuration ---
let MAX_DEPTH = 10;
let GROWTH_SPEED_BASE = 0.006;
const HOLD_DURATION = 400;
const FADE_DURATION = 180;
const WAIT_DURATION = 80;
// --- Pre-rendered particle sprite (soft dot) ---
let particleSprite;
function initParticleSprite() {
particleSprite = document.createElement('canvas');
particleSprite.width = 32;
particleSprite.height = 32;
const pctx = particleSprite.getContext('2d');
const g = pctx.createRadialGradient(16, 16, 0, 16, 16, 16);
g.addColorStop(0, 'rgba(230, 200, 155, 1)');
g.addColorStop(0.3, 'rgba(215, 175, 130, 0.5)');
g.addColorStop(1, 'rgba(200, 150, 100, 0)');
pctx.fillStyle = g;
pctx.fillRect(0, 0, 32, 32);
}
function resize() {
W = canvas.width = window.innerWidth;
H = canvas.height = window.innerHeight;
initParticleSprite();
}
window.addEventListener('resize', resize);
resize();
// --- Particle system ---
const PARTICLE_COUNT = 50;
let particles = [];
function createParticle(fullRandom) {
return {
x: rand(W * 0.15, W * 0.85),
y: fullRandom ? rand(H * 0.1, H * 0.9) : rand(H * 0.5, H),
vx: rand(-0.12, 0.12),
vy: rand(-0.35, -0.06),
size: rand(0.6, 2.2),
alpha: rand(0.04, 0.2),
phase: rand(0, Math.PI * 2),
freq: rand(0.0004, 0.0015),
life: fullRandom ? rand(0, 1) : 0,
lifeSpeed: rand(0.0006, 0.0025),
};
}
function initParticles() {
particles = [];
for (let i = 0; i < PARTICLE_COUNT; i++) {
particles.push(createParticle(true));
}
}
function updateParticles(time) {
for (let i = 0; i < particles.length; i++) {
const p = particles[i];
p.x += p.vx + Math.sin(time * p.freq + p.phase) * 0.25;
p.y += p.vy;
p.life += p.lifeSpeed;
if (p.life > 1 || p.y < -10 || p.x < -10 || p.x > W + 10) {
particles[i] = createParticle(false);
}
}
}
function drawParticles(drawCtx, globalAlpha) {
for (const p of particles) {
const lifeFade = p.life < 0.15 ? p.life / 0.15
: p.life > 0.8 ? (1 - p.life) / 0.2
: 1;
const a = p.alpha * lifeFade * globalAlpha;
if (a < 0.004) continue;
const s = p.size * 3;
drawCtx.globalAlpha = a;
drawCtx.drawImage(particleSprite, p.x - s, p.y - s, s * 2, s * 2);
}
drawCtx.globalAlpha = 1;
}
// --- Color palette (warm sienna → amber → golden at tips) ---
const PALETTE = [
{ r: 78, g: 45, b: 14 }, // deep sienna
{ r: 125, g: 76, b: 28 }, // warm bark
{ r: 172, g: 118, b: 50 }, // rich amber
{ r: 205, g: 158, b: 82 }, // warm gold
{ r: 218, g: 182, b: 108 }, // golden
{ r: 215, g: 195, b: 135 }, // warm golden-cream at tips
];
function colorForDepth(depth, hueShift) {
const t = depth / MAX_DEPTH;
const idx = t * (PALETTE.length - 1);
const i0 = Math.floor(idx);
const i1 = Math.min(PALETTE.length - 1, i0 + 1);
const f = idx - i0;
let r = lerp(PALETTE[i0].r, PALETTE[i1].r, f);
let g = lerp(PALETTE[i0].g, PALETTE[i1].g, f);
let b = lerp(PALETTE[i0].b, PALETTE[i1].b, f);
// Per-branch hue variation (stronger at outer depths)
// hueShift -1..1: negative = olive/green shift, positive = warm rose shift
if (hueShift !== undefined) {
const strength = t * t * 22;
r += hueShift * strength * 1.0; // positive = rosier
g += hueShift * strength * -0.5; // positive = less green
b += hueShift * strength * -0.15; // keep blue minimal
}
return { r, g, b };
}
// --- Branch data ---
let allBranches = [];
let treeAlpha = 1;
let treeState = 'growing';
let holdTimer = 0, fadeTimer = 0, waitTimer = 0;
const _pad = parseFloat(new URLSearchParams(location.search).get('p')) || 1;
function createTree() {
allBranches = [];
const trunkLen = H * rand(0.23, 0.28) / _pad;
const trunkThick = Math.max(8, W * 0.015) / _pad;
const trunkAngle = -Math.PI / 2 + rand(-0.05, 0.05);
const approxTreeH = trunkLen * 3.5;
const baseY = _pad > 1 ? (H + approxTreeH) / 2 : H + trunkThick * 0.5;
allBranches.push({
x0: W / 2 + rand(-W * 0.03, W * 0.03),
y0: baseY,
angle: trunkAngle,
length: trunkLen,
thickness: trunkThick,
depth: 0,
growthProgress: 0,
growthSpeed: GROWTH_SPEED_BASE * rand(0.9, 1.1),
children: [],
spawned: false,
swayPhase: rand(0, Math.PI * 2),
swayAmp: 0.0008,
curvature: rand(-0.015, 0.015),
colorShift: rand(-8, 8),
hueShift: 0,
parent: null,
// Pre-computed stroke variation for painterly rendering
strokeSeeds: [rand(-1,1), rand(-1,1), rand(-1,1), rand(-1,1), rand(-1,1)],
tipDots: [],
});
treeState = 'growing';
holdTimer = fadeTimer = waitTimer = 0;
treeAlpha = 1;
initParticles();
}
function spawnChildren(parent) {
if (parent.depth >= MAX_DEPTH) return;
let numChildren;
if (parent.depth < 1) numChildren = 2 + (Math.random() < 0.35 ? 1 : 0);
else if (parent.depth < 3) numChildren = 2 + (Math.random() < 0.4 ? 1 : 0);
else numChildren = Math.random() < 0.25 ? 3 : 2;
// Progressive pruning: more aggressive at outer depths for airy canopy
const pruneChance = parent.depth <= 3 ? 0 : parent.depth <= 5 ? 0.1 : parent.depth <= 7 ? 0.22 : 0.35;
if (Math.random() < pruneChance) numChildren = Math.max(1, numChildren - 1);
const spread = parent.depth < 2 ? rand(0.32, 0.48) : rand(0.38, 0.6);
for (let i = 0; i < numChildren; i++) {
let angleOffset;
if (numChildren === 1) {
angleOffset = rand(-0.25, 0.25);
} else if (numChildren === 2) {
angleOffset = (i === 0 ? -1 : 1) * rand(0.18, spread);
} else {
angleOffset = (i - 1) * spread + rand(-0.1, 0.1);
}
const childAngle = parent.angle + angleOffset;
const lengthFactor = rand(0.58, 0.76);
const thickFactor = rand(0.48, 0.67);
const ep = getBranchEnd(parent, 1, 0);
// Pre-compute tip dots for terminal branches only
const tipDots = [];
const childDepth = parent.depth + 1;
if (childDepth >= MAX_DEPTH) {
const count = Math.random() < 0.4 ? 2 : 1;
for (let d = 0; d < count; d++) {
tipDots.push({
ox: rand(-2, 2),
oy: rand(-2, 2),
size: rand(0.8, 1.6),
alpha: rand(0.08, 0.2),
});
}
}
const child = {
x0: ep.x,
y0: ep.y,
angle: childAngle,
length: parent.length * lengthFactor,
thickness: Math.max(0.4, parent.thickness * thickFactor),
depth: childDepth,
growthProgress: 0,
growthSpeed: GROWTH_SPEED_BASE * rand(1.0, 1.5) * (1 + parent.depth * 0.1),
children: [],
spawned: false,
swayPhase: rand(0, Math.PI * 2),
swayAmp: 0.0018 * (parent.depth + 1) * rand(0.7, 1.3),
curvature: rand(-0.04, 0.04) * (1 + parent.depth * 0.12),
colorShift: rand(-12, 12),
hueShift: Math.max(-1, Math.min(1, parent.hueShift + rand(-0.35, 0.35))),
parent: parent,
strokeSeeds: [rand(-1,1), rand(-1,1), rand(-1,1), rand(-1,1), rand(-1,1)],
tipDots: tipDots,
};
parent.children.push(child);
allBranches.push(child);
}
}
// --- Sway (multi-frequency wind + mouse wind + shake) ---
function getSwayAngle(branch, time) {
let total = 0;
let b = branch;
let depth = 0;
while (b) {
const a = b.swayAmp;
total += Math.sin(time * 0.0005 + b.swayPhase) * a;
total += Math.sin(time * 0.0003 + b.swayPhase * 1.7) * a * 0.6;
total += Math.sin(time * 0.00012 + b.swayPhase * 0.4) * a * 0.35;
depth++;
b = b.parent;
}
// Mouse wind: deeper branches bend more
if (mouseActive) {
total += windForce * 0.04 * depth;
}
// Shake: rapid oscillation that decays
if (shakeAmount > 0.01) {
total += Math.sin(time * 0.015 + branch.swayPhase * 3) * shakeAmount * 0.06 * depth;
}
return total;
}
function getBranchEnd(branch, progress, time) {
const sway = getSwayAngle(branch, time);
const angle = branch.angle + sway;
const len = branch.length * progress;
const perpX = -Math.sin(angle);
const perpY = Math.cos(angle);
const curveOff = branch.curvature * len;
return {
x: branch.x0 + Math.cos(angle) * len + perpX * curveOff,
y: branch.y0 + Math.sin(angle) * len + perpY * curveOff,
};
}
function recalcPositions(time) {
for (const b of allBranches) {
if (b.parent) {
const pe = getBranchEnd(b.parent, 1, time);
b.x0 = pe.x;
b.y0 = pe.y;
}
}
}
function updateBranches(time) {
let allDone = true;
for (const b of allBranches) {
if (b.growthProgress < 1) {
b.growthProgress = Math.min(1, b.growthProgress + b.growthSpeed);
allDone = false;
}
// Overlapping growth: spawn children at 65%
if (b.growthProgress >= 0.65 && !b.spawned) {
b.spawned = true;
spawnChildren(b);
}
}
return allDone;
}
// --- Branch drawing (multi-stroke painterly) ---
function drawBranch(drawCtx, b, time) {
if (b.growthProgress <= 0) return;
const sway = getSwayAngle(b, time);
const angle = b.angle + sway;
const progress = easeOutCubic(b.growthProgress);
const len = b.length * progress;
const x1 = b.x0;
const y1 = b.y0;
// Perpendicular direction
const perpX = -Math.sin(angle);
const perpY = Math.cos(angle);
// Cubic bezier control points with natural curvature
const curveOff = b.curvature * len * 1.4;
const cpx1 = x1 + Math.cos(angle) * len * 0.33 + perpX * curveOff * 0.4;
const cpy1 = y1 + Math.sin(angle) * len * 0.33 + perpY * curveOff * 0.4;
const cpx2 = x1 + Math.cos(angle) * len * 0.66 + perpX * curveOff * 0.85;
const cpy2 = y1 + Math.sin(angle) * len * 0.66 + perpY * curveOff * 0.85;
const x2 = x1 + Math.cos(angle) * len + perpX * curveOff * 0.7;
const y2 = y1 + Math.sin(angle) * len + perpY * curveOff * 0.7;
const col = colorForDepth(b.depth, b.hueShift);
// Alpha falloff at outer depths for graceful canopy edge
const depthT = b.depth / MAX_DEPTH;
const baseAlpha = b.depth <= 1 ? 0.95
: b.depth <= 5 ? lerp(0.92, 0.7, depthT)
: lerp(0.7, 0.35, (depthT - 0.5) * 2);
// Determine stroke count by depth for performance
const strokeCount = b.depth < 3 ? 5 : (b.depth < 6 ? 3 : 2);
const thickBase = b.thickness;
const thickTaper = lerp(thickBase, thickBase * 0.3, progress);
for (let s = 0; s < strokeCount; s++) {
// Deterministic offset from pre-computed seeds
const seed = b.strokeSeeds[s] || 0;
const normalizedS = strokeCount > 1 ? (s / (strokeCount - 1) - 0.5) : 0;
// Perpendicular offset for multi-stroke spread
const offsetAmt = normalizedS * thickBase * 0.35 + seed * thickBase * 0.08;
const ox = perpX * offsetAmt;
const oy = perpY * offsetAmt;
// Color variation per stroke (darker on one side, lighter on other)
const shift = normalizedS * 22 + b.colorShift * 0.3;
const r = Math.max(0, Math.min(255, col.r + shift));
const g = Math.max(0, Math.min(255, col.g + shift * 0.65));
const bb = Math.max(0, Math.min(255, col.b + shift * 0.4));
// Core stroke is full opacity, flanking strokes are softer
const isCore = s === Math.floor(strokeCount / 2);
const alpha = baseAlpha * (isCore ? 1.0 : 0.5);
const thick = thickTaper * (isCore ? 1.0 : lerp(0.65, 0.45, Math.abs(normalizedS)));
drawCtx.beginPath();
drawCtx.moveTo(x1 + ox, y1 + oy);
drawCtx.bezierCurveTo(
cpx1 + ox, cpy1 + oy,
cpx2 + ox, cpy2 + oy,
x2 + ox, y2 + oy
);
drawCtx.strokeStyle = `rgba(${r | 0}, ${g | 0}, ${bb | 0}, ${alpha})`;
drawCtx.lineWidth = thick;
drawCtx.lineCap = 'round';
drawCtx.stroke();
}
// Soft ambient glow on mid-to-deep branches
if (b.depth >= 4 && b.depth < MAX_DEPTH - 1 && b.growthProgress > 0.8) {
const glowAlpha = smoothstep(0.8, 1.0, b.growthProgress) * 0.06 * (b.depth / MAX_DEPTH);
const glowR = Math.max(4, thickBase * 2);
const grd = drawCtx.createRadialGradient(x2, y2, 0, x2, y2, glowR);
grd.addColorStop(0, `rgba(${Math.min(255,col.r+30)|0}, ${Math.min(255,col.g+20)|0}, ${Math.min(255,col.b+15)|0}, ${glowAlpha})`);
grd.addColorStop(0.5, `rgba(${col.r|0}, ${col.g|0}, ${col.b|0}, ${glowAlpha * 0.25})`);
grd.addColorStop(1, `rgba(${col.r|0}, ${col.g|0}, ${col.b|0}, 0)`);
drawCtx.fillStyle = grd;
drawCtx.beginPath();
drawCtx.arc(x2, y2, glowR, 0, Math.PI * 2);
drawCtx.fill();
}
// Delicate luminous tips (inherit branch color)
if (b.tipDots.length > 0 && b.growthProgress > 0.92) {
const tipFade = smoothstep(0.92, 1.0, b.growthProgress);
// Brighten the branch color for the tip glow
const tr = Math.min(255, col.r * 1.3 + 30);
const tg2 = Math.min(255, col.g * 1.3 + 25);
const tb = Math.min(255, col.b * 1.2 + 20);
for (const dot of b.tipDots) {
const dx = x2 + dot.ox;
const dy = y2 + dot.oy;
const da = tipFade * dot.alpha;
const ds = dot.size;
const tg = drawCtx.createRadialGradient(dx, dy, 0, dx, dy, ds * 2);
tg.addColorStop(0, `rgba(${tr|0}, ${tg2|0}, ${tb|0}, ${da * 0.6})`);
tg.addColorStop(0.5, `rgba(${col.r|0}, ${col.g|0}, ${col.b|0}, ${da * 0.15})`);
tg.addColorStop(1, `rgba(${col.r|0}, ${col.g|0}, ${col.b|0}, 0)`);
drawCtx.fillStyle = tg;
drawCtx.beginPath();
drawCtx.arc(dx, dy, ds * 2, 0, Math.PI * 2);
drawCtx.fill();
}
}
}
// --- Scene ---
function drawScene(time) {
ctx.clearRect(0, 0, W, H);
// Background
ctx.fillStyle = '#0a0a0a';
ctx.fillRect(0, 0, W, H);
// Ambient canopy glow (warm halo behind tree crown)
if (treeAlpha > 0.05) {
const cx = W / 2, cy = H * 0.38;
const canopyGlow = ctx.createRadialGradient(cx, cy, 0, cx, cy, H * 0.42);
canopyGlow.addColorStop(0, `rgba(60, 36, 12, ${0.2 * treeAlpha})`);
canopyGlow.addColorStop(0.5, `rgba(35, 20, 8, ${0.08 * treeAlpha})`);
canopyGlow.addColorStop(1, 'rgba(10, 10, 10, 0)');
ctx.fillStyle = canopyGlow;
ctx.fillRect(0, 0, W, H);
}
// Ground glow
if (treeAlpha > 0.05) {
const groundGrad = ctx.createRadialGradient(W / 2, H, 0, W / 2, H, H * 0.3);
groundGrad.addColorStop(0, `rgba(70, 42, 12, ${0.1 * treeAlpha})`);
groundGrad.addColorStop(1, 'rgba(10, 10, 10, 0)');
ctx.fillStyle = groundGrad;
ctx.fillRect(0, H * 0.55, W, H * 0.45);
}
recalcPositions(time);
// Draw branches
ctx.save();
ctx.globalAlpha = treeAlpha;
for (const b of allBranches) {
drawBranch(ctx, b, time);
}
ctx.restore();
// Particles
updateParticles(time);
ctx.save();
drawParticles(ctx, treeAlpha);
ctx.restore();
// Vignette
const vigGrad = ctx.createRadialGradient(W / 2, H / 2, W * 0.3, W / 2, H / 2, W * 0.78);
vigGrad.addColorStop(0, 'rgba(10, 10, 10, 0)');
vigGrad.addColorStop(1, 'rgba(4, 4, 4, 0.3)');
ctx.fillStyle = vigGrad;
ctx.fillRect(0, 0, W, H);
}
// --- State machine ---
function frame(time) {
// Decay shake
if (shakeAmount > 0.01) shakeAmount *= 0.95;
else shakeAmount = 0;
switch (treeState) {
case 'growing': {
const done = updateBranches(time);
drawScene(time);
if (done) {
treeState = 'holding';
holdTimer = 0;
}
break;
}
case 'holding': {
drawScene(time);
holdTimer++;
if (holdTimer >= HOLD_DURATION) {
treeState = 'fading';
fadeTimer = 0;
}
break;
}
case 'fading': {
fadeTimer++;
treeAlpha = Math.max(0, 1 - fadeTimer / FADE_DURATION);
drawScene(time);
if (fadeTimer >= FADE_DURATION) {
treeState = 'waiting';
waitTimer = 0;
}
break;
}
case 'waiting': {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = '#0a0a0a';
ctx.fillRect(0, 0, W, H);
waitTimer++;
if (waitTimer >= WAIT_DURATION) {
createTree();
}
break;
}
}
if (running) requestAnimationFrame(frame);
}
// --- Visibility pause ---
let running = true;
function startLoop() {
if (!running) { running = true; requestAnimationFrame(frame); }
}
document.addEventListener('visibilitychange', function() {
if (document.hidden) { running = false; }
else startLoop();
});
// Mouse: move to push the tree with wind, click to shake it
var mouseX = W / 2, mouseY = H / 2, mouseActive = false;
var windForce = 0; // -1 to 1, based on mouse x relative to tree
var shakeAmount = 0;
canvas.addEventListener('mousemove', function(e) {
mouseX = e.clientX; mouseY = e.clientY; mouseActive = true;
windForce = (mouseX - W / 2) / (W / 2); // -1 left, +1 right
});
canvas.addEventListener('mouseleave', function() { mouseActive = false; windForce = 0; });
canvas.addEventListener('click', function() { shakeAmount = 1.0; });
canvas.addEventListener('touchstart', function(e) {
e.preventDefault(); mouseActive = true;
mouseX = e.touches[0].clientX; mouseY = e.touches[0].clientY;
windForce = (mouseX - W / 2) / (W / 2);
shakeAmount = 1.0;
}, { passive: false });
canvas.addEventListener('touchmove', function(e) {
e.preventDefault(); mouseX = e.touches[0].clientX; mouseY = e.touches[0].clientY;
windForce = (mouseX - W / 2) / (W / 2);
}, { passive: false });
canvas.addEventListener('touchend', function() { mouseActive = false; windForce = 0; });
createTree();
requestAnimationFrame(frame);
window.addEventListener('message', function(e) {
if (e.data && e.data.type === 'param') {
switch (e.data.name) {
case 'GROWTH_SPEED_BASE': GROWTH_SPEED_BASE = e.data.value; break;
case 'MAX_DEPTH': MAX_DEPTH = Math.round(e.data.value); break;
}
}
});
})();
</script>
<script defer src="https://static.cloudflareinsights.com/beacon.min.js/v8c78df7c7c0f484497ecbca7046644da1771523124516" integrity="sha512-8DS7rgIrAmghBFwoOTujcf6D9rXvH8xm8JQ1Ja01h9QX8EzXldiszufYa4IFfKdLUKTTrnSFXLDkUEOTrZQ8Qg==" data-cf-beacon='{"version":"2024.11.0","token":"216c03e5eb1b42998a91f716785010f9","r":1,"server_timing":{"name":{"cfCacheStatus":true,"cfEdge":true,"cfExtPri":true,"cfL4":true,"cfOrigin":true,"cfSpeedBrain":true},"location_startswith":null}}' crossorigin="anonymous"></script>
</body>
</html>
/* ===== src/shaders/condensation/condensationRenderer.ts (typescript) ===== */
export type CondensationOptions = {
speed: number;
dropAmount: number;
opacity: number;
};
export const CONDENSATION_DEFAULTS: CondensationOptions = {
speed: 1,
dropAmount: 1,
opacity: 1,
};
type Drop = {
x: number;
y: number;
r: number;
rMax: number;
grow: number;
running: boolean;
vy: number;
};
type Splash = {
x: number;
y: number;
r0: number;
born: number;
};
export function createCondensationRenderer(
canvas: HTMLCanvasElement,
getOptions: () => CondensationOptions,
) {
const context = canvas.getContext("2d");
if (!context) throw new Error("Condensation requires Canvas 2D");
let width = 1;
let height = 1;
let dpr = 1;
let floorY = 1;
let baseMaxDrops = 120;
let maxDrops = 120;
let lastCascade = 0;
let simulatedNow = 0;
let lastFrame = 0;
let drops: Drop[] = [];
let splashes: Splash[] = [];
const bubbleSprites = new Map<number, HTMLCanvasElement>();
const getBubbleSprite = (radius: number) => {
const spriteRadius = Math.max(0.5, Math.round(radius * 2) / 2);
const cached = bubbleSprites.get(spriteRadius);
if (cached) return cached;
const padding = Math.ceil(2 * dpr);
const size = Math.ceil(spriteRadius * 2 + padding * 2);
const sprite = document.createElement("canvas");
sprite.width = size;
sprite.height = size;
const spriteContext = sprite.getContext("2d");
if (!spriteContext) throw new Error("Condensation bubble sprite unavailable");
const center = size / 2;
const lit = spriteRadius / (4.4 * dpr);
spriteContext.fillStyle = `rgba(96,139,196,${0.18 + lit * 0.22})`;
spriteContext.beginPath();
spriteContext.arc(center, center, spriteRadius, 0, Math.PI * 2);
spriteContext.fill();
spriteContext.strokeStyle = `rgba(70,98,148,${0.35 + lit * 0.35})`;
spriteContext.lineWidth = 0.9 * dpr;
spriteContext.stroke();
spriteContext.fillStyle = `rgba(255,255,255,${0.32 + lit * 0.3})`;
spriteContext.beginPath();
spriteContext.arc(
center - spriteRadius * 0.32,
center - spriteRadius * 0.36,
Math.max(0.45 * dpr, spriteRadius * 0.24),
0,
Math.PI * 2,
);
spriteContext.fill();
bubbleSprites.set(spriteRadius, sprite);
return sprite;
};
const seedDrop = (headStart = 0) => {
const margin = 8 * dpr;
const availableWidth = Math.max(1, width - margin * 2);
const availableHeight = Math.max(1, floorY - margin * 2);
drops.push({
x: margin + Math.random() * availableWidth,
y: margin + Math.random() * availableHeight,
r: (0.5 + headStart * 0.7) * dpr,
rMax: (2.1 + Math.random() * 2.3) * dpr,
grow: (0.0022 + Math.random() * 0.0032) * dpr,
running: false,
vy: 0,
});
};
const resize = (cssWidth: number, cssHeight: number) => {
dpr = Math.min(window.devicePixelRatio || 1, 2);
width = canvas.width = Math.max(1, Math.floor(cssWidth * dpr));
height = canvas.height = Math.max(1, Math.floor(cssHeight * dpr));
canvas.style.width = `${cssWidth}px`;
canvas.style.height = `${cssHeight}px`;
floorY = Math.max(1, height - 2 * dpr);
baseMaxDrops = Math.max(80, Math.min(240, Math.round((width * height) / (dpr * dpr) / 5200)));
maxDrops = Math.round(baseMaxDrops * getOptions().dropAmount);
drops = [];
splashes = [];
bubbleSprites.clear();
const seedCount = Math.round(maxDrops * 0.62);
for (let index = 0; index < seedCount; index += 1) seedDrop(Math.random() * 3);
};
const step = (now: number, motionScale = 1) => {
if (drops.length < maxDrops && Math.random() < 0.34 * motionScale) seedDrop();
if (now - lastCascade > 6400) {
lastCascade = now;
drops
.filter((drop) => !drop.running)
.sort((a, b) => b.r - a.r)
.slice(0, 3)
.forEach((drop) => {
drop.running = true;
drop.vy = 0.35 * dpr;
});
}
for (let index = drops.length - 1; index >= 0; index -= 1) {
const drop = drops[index];
if (!drop.running) {
drop.r += drop.grow * motionScale;
if (drop.r >= drop.rMax) {
drop.running = true;
drop.vy = 0.3 * dpr;
}
continue;
}
drop.vy = Math.min(drop.vy + 0.075 * dpr * motionScale, 5.4 * dpr);
drop.y += drop.vy * motionScale;
for (let otherIndex = drops.length - 1; otherIndex >= 0; otherIndex -= 1) {
const other = drops[otherIndex];
if (other === drop || other.running) continue;
const overlapsX = Math.abs(other.x - drop.x) < drop.r + other.r + 1.5 * dpr;
const overlapsY = other.y > drop.y - drop.r && other.y < drop.y + drop.vy + drop.r;
if (overlapsX && overlapsY) {
drop.r = Math.sqrt(drop.r * drop.r + other.r * other.r);
drops.splice(otherIndex, 1);
if (otherIndex < index) index -= 1;
}
}
if (drop.y >= floorY) {
splashes.push({ x: drop.x, y: floorY, r0: drop.r, born: now });
drops.splice(index, 1);
}
}
splashes = splashes.filter((splash) => now - splash.born < 760);
};
const draw = (now: number) => {
context.clearRect(0, 0, width, height);
drops.forEach((drop) => {
const sprite = getBubbleSprite(drop.r);
context.drawImage(sprite, drop.x - sprite.width / 2, drop.y - sprite.height / 2);
});
splashes.forEach((splash) => {
const progress = (now - splash.born) / 760;
if (progress < 0 || progress > 1) return;
const radius = splash.r0 + progress * 16 * dpr;
context.strokeStyle = `rgba(75,108,158,${(1 - progress) * 0.52})`;
context.lineWidth = 1.1 * dpr;
context.beginPath();
context.ellipse(splash.x, splash.y, radius, radius * 0.34, 0, 0, Math.PI * 2);
context.stroke();
});
};
return {
resize,
render(now: number) {
const options = getOptions();
maxDrops = Math.round(baseMaxDrops * options.dropAmount);
const delta = lastFrame ? Math.min(50, now - lastFrame) : 16.67;
lastFrame = now;
simulatedNow += delta * options.speed;
step(simulatedNow, options.speed);
draw(simulatedNow);
},
dispose() {
drops = [];
splashes = [];
bubbleSprites.clear();
},
};
}
[/hidecontent]