Elements
aadmin2026-09-16 发布3D10 阅读

Elements

类型:3D · 分类:Backgrounds · 收录:2026-09-16 20:35:01 · 源码:会员可见

[hidecontent type="vip1"]
# 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]
← 返回首页