motionsites中文站 / Woven Cloth
Woven Cloth
aadmin2026-09-16 发布3D9 阅读

Woven Cloth

类型:3D · 分类:Three.js · 收录:2026-09-16 21:09:01 · 源码:会员可见

[hidecontent type="vip1"]
# Woven Cloth
A Three.js woven-cloth simulation with Woven Cloth typography printed into its procedural textile so every letter deforms with the fabric, and three companion cloths woven around the same Verlet sheet.

> 来源:ThreeUI Community · https://threeui.com · MIT License(Meng To / Design+Code)
> 组件页:https://threeui.com/component/woven-cloth · 预览动画:https://threeui.com/previews/woven-cloth.webm
> 分类:Three.js
> 包含变体(3):iridescent、atelier、washi

/* ===== src/shaders/woven-cloth/WovenCloth.tsx (tsx) ===== */
import { useMemo } from "react";

import {
  NEUFORM_CRAFT_DEFAULTS,
  WovenCloth as WovenClothSource,
  type NeuformCraftEffectProps,
} from "../neuform-isolated/NeuformCraftEffects";
import atelierSource from "./woven-cloth-atelier.html?raw";
import iridescentSource from "./woven-cloth-iridescent.html?raw";
import washiSource from "./woven-cloth-washi.html?raw";

export const WOVEN_CLOTH_VARIANTS = ["woven-cloth", "iridescent", "atelier", "washi"] as const;
export type WovenClothVariant = (typeof WOVEN_CLOTH_VARIANTS)[number];

export type WovenClothProps = NeuformCraftEffectProps & {
  variant?: WovenClothVariant;
};

/* "woven-cloth" is the packaged Neuform export, rendered by its own isolated
   host so the authored document stays exactly as supplied. The other three are
   complete first-party documents: each keeps the parent's Verlet sheet and its
   habit of printing the wordmark into the textile, and rebuilds the material,
   the construction and the light around a different cloth. */
type CompanionDefinition = {
  title: string;
  background: string;
  source: string;
};

const COMPANIONS: Record<Exclude<WovenClothVariant, "woven-cloth">, CompanionDefinition> = {
  iridescent: {
    title: "Woven Cloth iridescent silk",
    background: "#05060d",
    source: iridescentSource,
  },
  atelier: {
    title: "Woven Cloth atelier flag",
    background: "#12100d",
    source: atelierSource,
  },
  washi: {
    title: "Woven Cloth washi noren",
    background: "#0d0a07",
    source: washiSource,
  },
};

function clamp(value: number, minimum: number, maximum: number) {
  return Math.min(maximum, Math.max(minimum, value));
}

function CompanionCloth({
  definition,
  hue = NEUFORM_CRAFT_DEFAULTS.hue,
  saturation = NEUFORM_CRAFT_DEFAULTS.saturation,
  brightness = NEUFORM_CRAFT_DEFAULTS.brightness,
  className,
  style,
}: NeuformCraftEffectProps & { definition: CompanionDefinition }) {
  const safeHue = clamp(hue, -180, 180);
  const safeSaturation = clamp(saturation, 0, 2);
  const safeBrightness = clamp(brightness, 0.35, 1.65);
  const filter = safeHue === 0 && safeSaturation === 1 && safeBrightness === 1
    ? undefined
    : `hue-rotate(${safeHue}deg) saturate(${safeSaturation}) brightness(${safeBrightness})`;

  return (
    <iframe
      className={className}
      title={definition.title}
      srcDoc={definition.source}
      sandbox="allow-scripts"
      loading="eager"
      style={{
        display: "block",
        width: "100%",
        height: "100%",
        border: 0,
        background: definition.background,
        filter,
        ...style,
      }}
    />
  );
}

export function WovenCloth({ variant = "woven-cloth", ...props }: WovenClothProps) {
  const definition = useMemo(
    () => (variant === "woven-cloth" ? undefined : COMPANIONS[variant] ?? undefined),
    [variant],
  );
  if (!definition) return <WovenClothSource {...props} />;
  return <CompanionCloth {...props} key={variant} definition={definition} />;
}

/* ===== src/shaders/neuform-isolated/sources/lumina-weavers-cloth.html (html) ===== */
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
    <title>Lumina Weavers · Kinetic Textiles, Kyoto</title>
    <script src="https://cdn.tailwindcss.com"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/ScrollTrigger.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.min.js"></script>
    <script src="https://code.iconify.design/iconify-icon/1.0.7/iconify-icon.min.js"></script>
</head>
<body class="text-[#e8dcc4] overflow-hidden antialiased selection:bg-[#b02330] selection:text-white font-serif" style="background: radial-gradient(120% 100% at 50% 30%, #2a1113 0%, #1a0a0c 55%, #0f0607 100%); height: 100dvh; width: 100vw;">

    <!-- Aura Asset Background -->
    <div class="fixed inset-0 pointer-events-none z-0 bg-cover bg-center opacity-[0.15] mix-blend-screen" style="background-image: url('https://hoirqrkdgbmvpwutwuwj.supabase.co/storage/v1/object/public/assets/assets/fa51902b-c2a4-4c33-a96e-a8f1ef67edc6_1600w.jpg');"></div>

    <!-- Stage / Background -->
    <div class="fixed inset-0 overflow-hidden z-0">
        <canvas id="cloth" class="absolute inset-0 w-full h-full block pointer-events-none"></canvas>
        <div class="absolute inset-0 pointer-events-none" style="background: radial-gradient(90% 80% at 50% 46%, transparent 55%, rgba(10,5,6,.72) 100%);"></div>
    </div>

    <!-- UI Overlay -->
    <div class="absolute inset-0 z-10 flex flex-col p-6 md:p-12 pointer-events-none font-sans">
        
        <!-- Header -->
        <header class="flex justify-between items-center w-full pointer-events-auto">
            <div class="overflow-hidden">
                <div class="reveal-item opacity-0 translate-y-8 text-xs tracking-[0.28em] uppercase text-[#b99a8f] font-medium">
                    Est. 2024 · Kyoto, Japan
                </div>
            </div>
            <nav class="hidden md:flex gap-8">
                <div class="overflow-hidden">
                    <a href="#library" class="reveal-item block opacity-0 translate-y-8 text-xs tracking-widest uppercase text-[#e8dcc4]/80 hover:text-white transition-colors duration-300">Material Library</a>
                </div>
                <div class="overflow-hidden">
                    <a href="#trade" class="reveal-item block opacity-0 translate-y-8 text-xs tracking-widest uppercase text-[#e8dcc4]/80 hover:text-white transition-colors duration-300">Partnerships</a>
                </div>
                <div class="overflow-hidden">
                    <a href="#mill" class="reveal-item block opacity-0 translate-y-8 text-xs tracking-widest uppercase text-[#e8dcc4]/80 hover:text-white transition-colors duration-300">The Studio</a>
                </div>
            </nav>
        </header>

        <!-- Footer / Content -->
        <div class="mt-auto flex flex-col sm:flex-row justify-between items-start sm:items-end gap-8 pointer-events-auto w-full">
            
            <div class="max-w-md space-y-6">
                <div>
                    <p class="word-reveal text-sm md:text-base leading-relaxed text-[#e8dcc4]/90">
                        <strong class="text-white font-semibold block mb-1 text-base md:text-lg tracking-tight font-serif">Kinetic textiles, rendered to order.</strong>
                        Every meter is simulated on bespoke kinetic engines. Digital threads, authentic physics, designed for infinite virtual environments.
                    </p>
                </div>
                
                <div class="overflow-hidden pt-2">
                    <div class="reveal-item opacity-0 translate-y-8 inline-block rounded-sm p-[1px] bg-gradient-to-b from-white/30 via-[#e03848]/30 to-[#7c1622]/10 shadow-[0_10px_30px_rgba(124,22,34,0.4)] transition-transform duration-300 hover:-translate-y-0.5 group">
                        <a href="#commission" class="flex items-center gap-3 bg-[#b02330] group-hover:bg-[#961c27] text-white px-6 py-3 rounded-[1px] text-sm font-semibold tracking-wide transition-colors relative overflow-hidden">
                            <span class="relative z-10">Commission a textile</span>
                            <iconify-icon icon="solar:arrow-right-linear" width="18" class="relative z-10 transition-transform group-hover:translate-x-1"></iconify-icon>
                            <div class="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-700 ease-in-out"></div>
                        </a>
                    </div>
                </div>
            </div>

            <div class="text-left sm:text-right mt-8 sm:mt-0">
                <div class="word-reveal text-xs tracking-[0.24em] uppercase text-[#b99a8f] leading-[1.9] font-medium">
                    Vector & matrix<br>
                    No. 42 azure thread<br>
                    Node 7 · Grid 2024
                </div>
            </div>

        </div>
    </div>

    <script>
        // --- GSAP Animation ---
        document.addEventListener("DOMContentLoaded", () => {
            gsap.registerPlugin(ScrollTrigger);
            
            // Standard reveals
            gsap.to(".reveal-item", {
                y: 0,
                opacity: 1,
                duration: 1.2,
                stagger: 0.1,
                ease: "power3.out",
                delay: 0.2
            });

            // Masked Word Reveal implementation
            document.querySelectorAll('.word-reveal').forEach(node => {
                function wrapWords(el) {
                    const childNodes = Array.from(el.childNodes);
                    childNodes.forEach(child => {
                        if (child.nodeType === 3) {
                            const words = child.nodeValue.split(/(s+)/);
                            const fragment = document.createDocumentFragment();
                            words.forEach(word => {
                                if (word.trim().length > 0) {
                                    const wrapper = document.createElement('span');
                                    wrapper.className = 'inline-flex overflow-hidden pb-1 -mb-1';
                                    const inner = document.createElement('span');
                                    inner.className = 'word-anim translate-y-[110%] opacity-0 inline-block';
                                    inner.textContent = word;
                                    wrapper.appendChild(inner);
                                    fragment.appendChild(wrapper);
                                } else {
                                    fragment.appendChild(document.createTextNode(word));
                                }
                            });
                            el.replaceChild(fragment, child);
                        } else if (child.nodeType === 1) {
                            wrapWords(child);
                        }
                    });
                }
                wrapWords(node);

                // ScrollTrigger staggered reveal
                gsap.to(node.querySelectorAll('.word-anim'), {
                    y: 0,
                    opacity: 1,
                    duration: 0.9,
                    stagger: 0.02,
                    ease: "power4.out",
                    delay: 0.3,
                    scrollTrigger: {
                        trigger: node,
                        start: "top 95%",
                    }
                });
            });
        });

        // --- Three.js Cloth Simulation ---
        (() => {
            const reduce = matchMedia('(prefers-reduced-motion:reduce)').matches;
            const canvas = document.getElementById('cloth');
            if (!window.THREE) return;

            // Generate Woven Texture
            function makeClothTexture() {
                const W = 1280, H = 800;
                const c = document.createElement('canvas'); 
                c.width = W; 
                c.height = H;
                const x = c.getContext('2d');
                
                // Ivory ground gradient
                const g = x.createLinearGradient(0, 0, 0, H);
                g.addColorStop(0, '#efe6d4'); 
                g.addColorStop(0.5, '#e9dfca'); 
                g.addColorStop(1, '#e3d7bf');
                x.fillStyle = g; 
                x.fillRect(0, 0, W, H);

                // Crimson hem border
                x.strokeStyle = '#a5202c'; 
                x.lineWidth = 10;
                x.strokeRect(46, 46, W-92, H-92);
                x.lineWidth = 3; 
                x.strokeStyle = '#7c1622';
                x.strokeRect(66, 66, W-132, H-132);

                // Typography
                x.fillStyle = '#a5202c';
                x.font = 'bold 78px Georgia, "Times New Roman", serif';
                x.textAlign = 'center'; 
                x.textBaseline = 'middle';
                x.fillText('L W', W/2, 190);
                
                x.font = 'normal 20px "Helvetica Neue", Arial, sans-serif';
                x.fillStyle = '#7c1622';
                x.fillText('· KYOTO ·', W/2, 246);

                x.fillStyle = '#9e1e2a';
                x.font = 'bold 118px Georgia, "Times New Roman", serif';
                x.fillText('LUMINA', W/2, 400);
                x.fillText('WEAVERS', W/2, 520);

                x.fillStyle = '#7c1622';
                x.font = '600 30px "Helvetica Neue", Arial, sans-serif';
                x.fillText('K I N E T I C   T E X T I L E S   ·   2 0 2 4', W/2, 626);

                // Weave overlay (Thread grid)
                x.globalAlpha = 1;
                for(let yy=0; yy<H; yy+=3){
                    x.strokeStyle = 'rgba(60,30,20,0.05)'; 
                    x.lineWidth = 1;
                    x.beginPath(); x.moveTo(0,yy+.5); x.lineTo(W,yy+.5); x.stroke();
                }
                for(let xx=0; xx<W; xx+=3){
                    x.strokeStyle = 'rgba(255,250,235,0.06)'; 
                    x.lineWidth = 1;
                    x.beginPath(); x.moveTo(xx+.5,0); x.lineTo(xx+.5,H); x.stroke();
                }

                // Fabric Slub Noise
                const id = x.getImageData(0, 0, W, H), d = id.data;
                for(let i=0; i<d.length; i+=4){
                    const n = (Math.random()*2-1)*10;
                    d[i]+=n; d[i+1]+=n; d[i+2]+=n;
                }
                x.putImageData(id, 0, 0);

                const tex = new THREE.CanvasTexture(c);
                tex.anisotropy = 4; 
                tex.colorSpace = THREE.SRGBColorSpace;
                return tex;
            }

            // Scene Setup
            const scene = new THREE.Scene();
            const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
            renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
            let camera;

            const BW = 4.4, BH = 2.75; 
            const GX = 40, GY = 26;
            const geo = new THREE.PlaneGeometry(BW, BH, GX, GY);
            const mat = new THREE.MeshPhongMaterial({
                map: makeClothTexture(), 
                side: THREE.DoubleSide,
                shininess: 6, 
                specular: 0x2a1410, 
                color: 0xffffff
            });
            const mesh = new THREE.Mesh(geo, mat); 
            scene.add(mesh);

            // Lighting
            scene.add(new THREE.AmbientLight(0xffe9d0, 0.62));
            const key = new THREE.DirectionalLight(0xfff0dc, 1.15); 
            key.position.set(-3, 3.5, 3.2); 
            scene.add(key);
            const rim = new THREE.DirectionalLight(0xb02330, 0.42); 
            rim.position.set(3, -1.5, 2.0); 
            scene.add(rim);

            // Verlet Physics Data
            const pos = geo.attributes.position;
            const N = (GX + 1) * (GY + 1);
            const cur = new Float32Array(N * 3), prev = new Float32Array(N * 3), rest = new Float32Array(N * 3);
            const pinned = new Uint8Array(N);
            
            for(let i=0; i<N; i++){
                const ax = pos.getX(i), ay = pos.getY(i), az = 0;
                cur[i*3] = prev[i*3] = rest[i*3] = ax;
                cur[i*3+1] = prev[i*3+1] = rest[i*3+1] = ay;
                cur[i*3+2] = prev[i*3+2] = rest[i*3+2] = az;
            }
            
            for(let ix=0; ix<=GX; ix++){ pinned[ix] = 1; }
            const idx = (ix, iy) => ix + iy * (GX + 1);

            const restH = BW / GX, restV = BH / GY;
            const GRAV = -3.1, DAMP = 0.985, DT = 0.016;

            function wind(ix, iy, t) {
                const cx = ix / GX, cy = iy / GY;
                const travel = t * 1.7 - cy * 4.2;
                const gust = 0.6 + 0.42 * Math.sin(t * 0.6) + 0.18 * Math.sin(t * 1.9 + 1.3);
                const amp = 4.3 * cy;
                const fz = (Math.sin(travel + cx * 3.3) + 0.5 * Math.sin(travel * 1.7 + cx * 6.0)) * amp * gust;
                const fx = Math.sin(t * 0.9 + cy * 2.2) * 0.6 * cy;
                const fy = -0.4 * cy;
                return [fx, fy, fz];
            }

            function step(t) {
                for(let iy=0; iy<=GY; iy++){
                    for(let ix=0; ix<=GX; ix++){
                        const i = idx(ix, iy);
                        if(pinned[i]) continue;
                        const [fx, fy, fz] = wind(ix, iy, t);
                        for(let k=0; k<3; k++){
                            const j = i * 3 + k;
                            const a = (k===0 ? fx : k===1 ? (fy+GRAV) : fz);
                            const v = (cur[j] - prev[j]) * DAMP;
                            prev[j] = cur[j];
                            cur[j] = cur[j] + v + a * DT * DT;
                        }
                    }
                }
                
                for(let it=0; it<3; it++){
                    for(let iy=0; iy<=GY; iy++){
                        for(let ix=0; ix<GX; ix++){ solve(idx(ix,iy), idx(ix+1,iy), restH); }
                    }
                    for(let iy=0; iy<GY; iy++){
                        for(let ix=0; ix<=GX; ix++){ solve(idx(ix,iy), idx(ix,iy+1), restV); }
                    }
                }
                
                for(let ix=0; ix<=GX; ix++){
                    const i = ix;
                    cur[i*3] = rest[i*3]; cur[i*3+1] = rest[i*3+1]; cur[i*3+2] = rest[i*3+2];
                    prev[i*3] = rest[i*3]; prev[i*3+1] = rest[i*3+1]; prev[i*3+2] = rest[i*3+2];
                }
            }

            function solve(a, b, rl) {
                const ax = cur[a*3], ay = cur[a*3+1], az = cur[a*3+2];
                const bx = cur[b*3], by = cur[b*3+1], bz = cur[b*3+2];
                let dx = bx - ax, dy = by - ay, dz = bz - az;
                const d = Math.sqrt(dx*dx + dy*dy + dz*dz) || 1e-6;
                const diff = (d - rl) / d * 0.5;
                dx *= diff; dy *= diff; dz *= diff;
                
                const pa = pinned[a], pb = pinned[b];
                if(!pa && !pb){ 
                    cur[a*3]+=dx; cur[a*3+1]+=dy; cur[a*3+2]+=dz; 
                    cur[b*3]-=dx; cur[b*3+1]-=dy; cur[b*3+2]-=dz; 
                }
                else if(pa && !pb){ cur[b*3]-=dx*2; cur[b*3+1]-=dy*2; cur[b*3+2]-=dz*2; }
                else if(!pa && pb){ cur[a*3]+=dx*2; cur[a*3+1]+=dy*2; cur[a*3+2]+=dz*2; }
            }

            function commit() {
                for(let i=0; i<N; i++){ pos.setXYZ(i, cur[i*3], cur[i*3+1], cur[i*3+2]); }
                pos.needsUpdate = true;
                geo.computeVertexNormals();
            }

            function fit() {
                const w = window.innerWidth, h = window.innerHeight;
                renderer.setSize(w, h, false);
                const aspect = w / h;
                camera = new THREE.PerspectiveCamera(42, aspect, 0.1, 100);
                const vFit = (BH/2) / Math.tan(42 * Math.PI / 360);
                const hFit = (BW/2) / Math.tan(42 * Math.PI / 360) / aspect;
                camera.position.set(0, 0.05, Math.max(vFit, hFit) * 1.16 + 0.4);
                camera.lookAt(0, 0, 0);
            }
            
            window.addEventListener('resize', fit); 
            fit();

            let running = false, raf = 0, t = 0;
            function loop() {
                if(!running) return;
                t += DT; 
                step(t); 
                commit();
                renderer.render(scene, camera);
                raf = requestAnimationFrame(loop);
            }
            
            function start() { if(running)return; running=true; raf=requestAnimationFrame(loop); }
            function stop() { running=false; cancelAnimationFrame(raf); }

            if(reduce) {
                for(let s=0; s<220; s++) step(s*DT);
                commit(); 
                renderer.render(scene, camera);
            } else {
                for(let s=0; s<40; s++) step(s*DT);
                t = 40 * DT;
                start();
                document.addEventListener('visibilitychange', () => document.hidden ? stop() : start());
            }
        })();
    </script>
</body>
</html>

/* ===== src/shaders/woven-cloth/woven-cloth-iridescent.html (html) ===== */
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Woven Cloth · Iridescent Silk</title>
<script src="https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.min.js"></script>
<style>
  html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #05060d; }
  #cloth { display: block; width: 100%; height: 100%; }
  #vignette {
    position: fixed; inset: 0; pointer-events: none;
    background: radial-gradient(78% 70% at 50% 44%, transparent 46%, rgba(3,4,10,.78) 100%);
  }
</style>
</head>
<body>
<canvas id="cloth"></canvas>
<div id="vignette"></div>
<script>
(() => {
  const reduce = matchMedia('(prefers-reduced-motion:reduce)').matches;
  const canvas = document.getElementById('cloth');
  if (!window.THREE) return;

  /* ---------------------------------------------------------------- maps --
     Everything the silk needs is drawn once into 2D canvases: a satin weave
     height field, the wordmark woven into it as raised warp floats, and a
     smooth film-thickness field that makes the interference colour travel. */

  const TW = 1600, TH = 1000;

  function surface(w, h) {
    const c = document.createElement('canvas');
    c.width = w; c.height = h;
    return { canvas: c, ctx: c.getContext('2d', { willReadFrequently: true }) };
  }

  // Wordmark drawn once as a white-on-black coverage mask.
  function makeWordmarkMask() {
    const s = surface(TW, TH), x = s.ctx;
    x.fillStyle = '#000'; x.fillRect(0, 0, TW, TH);
    x.fillStyle = '#fff';
    x.textAlign = 'center'; x.textBaseline = 'middle';
    x.font = 'bold 96px Georgia, "Times New Roman", serif';
    x.fillText('W C', TW / 2, 208);
    x.font = '700 30px "Helvetica Neue", Arial, sans-serif';
    x.fillText('· A T E L I E R ·', TW / 2, 278);
    x.font = 'bold 150px Georgia, "Times New Roman", serif';
    x.fillText('WOVEN', TW / 2, 452);
    x.fillText('CLOTH', TW / 2, 600);
    x.font = '700 38px "Helvetica Neue", Arial, sans-serif';
    x.fillText('I R I D E S C E N T   S I L K', TW / 2, 718);
    // Hairline frame, woven as a raised rib rather than printed.
    x.strokeStyle = '#fff'; x.lineWidth = 7;
    x.strokeRect(64, 64, TW - 128, TH - 128);
    x.lineWidth = 3;
    x.strokeRect(92, 92, TW - 184, TH - 184);
    return x.getImageData(0, 0, TW, TH).data;
  }

  // Five-harness satin: long warp floats broken by a scattered binding point.
  function weaveHeight(x, y) {
    const p = 8;
    const cx = Math.floor(x / p), cy = Math.floor(y / p);
    const u = (x % p) / p, v = (y % p) / p;
    const binding = ((cx * 2 + cy) % 5) === 0;
    const profile = (t) => Math.sin(t * Math.PI);
    let h = binding ? profile(v) * 0.9 : profile(u);
    // Slubs: a few threads run thicker than their neighbours.
    h += Math.sin(cx * 12.9898) * Math.sin(cx * 4.1414 + 2.3) * 0.16;
    h += Math.sin(cy * 7.233 + 1.7) * 0.06;
    return h;
  }

  function valueNoise(seed) {
    const G = 64, grid = new Float32Array(G * G);
    let s = seed;
    const rnd = () => (s = (s * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff;
    for (let i = 0; i < G * G; i++) grid[i] = rnd();
    const smooth = (t) => t * t * (3 - 2 * t);
    return (u, v) => {
      const fx = u * G, fy = v * G;
      const ix = Math.floor(fx), iy = Math.floor(fy);
      const tx = smooth(fx - ix), ty = smooth(fy - iy);
      const at = (a, b) => grid[((b % G) + G) % G * G + ((a % G) + G) % G];
      const a = at(ix, iy), b = at(ix + 1, iy), c = at(ix, iy + 1), d = at(ix + 1, iy + 1);
      return (a + (b - a) * tx) + ((c + (d - c) * tx) - (a + (b - a) * tx)) * ty;
    };
  }

  const mask = makeWordmarkMask();

  // One pass builds the height field; albedo, normal and roughness share it.
  const height = new Float32Array(TW * TH);
  for (let y = 0; y < TH; y++) {
    for (let x = 0; x < TW; x++) {
      const i = y * TW + x;
      const m = mask[i * 4] / 255;
      // Inside a letter the warp floats ride higher and longer.
      height[i] = weaveHeight(x, y) * (1 - m * 0.30) + m * 1.15;
    }
  }

  function blurHeight(passes) {
    const tmp = new Float32Array(TW * TH);
    for (let n = 0; n < passes; n++) {
      for (let y = 0; y < TH; y++) {
        const row = y * TW;
        for (let x = 0; x < TW; x++) {
          const a = height[row + (x > 0 ? x - 1 : x)];
          const b = height[row + x];
          const c = height[row + (x < TW - 1 ? x + 1 : x)];
          tmp[row + x] = (a + b + b + c) * 0.25;
        }
      }
      for (let x = 0; x < TW; x++) {
        for (let y = 0; y < TH; y++) {
          const a = tmp[(y > 0 ? y - 1 : y) * TW + x];
          const b = tmp[y * TW + x];
          const c = tmp[(y < TH - 1 ? y + 1 : y) * TW + x];
          height[y * TW + x] = (a + b + b + c) * 0.25;
        }
      }
    }
  }

  function makeNormalTexture(strength) {
    const s = surface(TW, TH);
    const img = s.ctx.createImageData(TW, TH), d = img.data;
    for (let y = 0; y < TH; y++) {
      const yp = y > 0 ? y - 1 : y, yn = y < TH - 1 ? y + 1 : y;
      for (let x = 0; x < TW; x++) {
        const xp = x > 0 ? x - 1 : x, xn = x < TW - 1 ? x + 1 : x;
        const dx = (height[y * TW + xp] - height[y * TW + xn]) * strength;
        const dy = (height[yp * TW + x] - height[yn * TW + x]) * strength;
        const len = Math.sqrt(dx * dx + dy * dy + 1);
        const o = (y * TW + x) * 4;
        d[o] = (dx / len * 0.5 + 0.5) * 255;
        d[o + 1] = (dy / len * 0.5 + 0.5) * 255;
        d[o + 2] = (1 / len * 0.5 + 0.5) * 255;
        d[o + 3] = 255;
      }
    }
    s.ctx.putImageData(img, 0, 0);
    return new THREE.CanvasTexture(s.canvas);
  }

  function makeAlbedoTexture() {
    const s = surface(TW, TH), x = s.ctx;
    const g = x.createLinearGradient(0, 0, TW * 0.35, TH);
    g.addColorStop(0, '#161033');
    g.addColorStop(0.45, '#0d1030');
    g.addColorStop(1, '#100b26');
    x.fillStyle = g; x.fillRect(0, 0, TW, TH);
    const img = x.getImageData(0, 0, TW, TH), d = img.data;
    for (let i = 0; i < TW * TH; i++) {
      const m = mask[i * 4] / 255;
      const shade = 0.88 + height[i] * 0.10;
      const o = i * 4;
      d[o] = Math.min(255, d[o] * shade + m * 96);
      d[o + 1] = Math.min(255, d[o + 1] * shade + m * 78);
      d[o + 2] = Math.min(255, d[o + 2] * shade + m * 118);
    }
    x.putImageData(img, 0, 0);
    const tex = new THREE.CanvasTexture(s.canvas);
    tex.colorSpace = THREE.SRGBColorSpace;
    return tex;
  }

  // Satin floats inside the letters read glossier than the matte ground.
  function makeRoughnessTexture() {
    const s = surface(TW, TH), x = s.ctx;
    const img = x.createImageData(TW, TH), d = img.data;
    const grain = valueNoise(9271);
    for (let y = 0; y < TH; y++) {
      for (let xx = 0; xx < TW; xx++) {
        const i = y * TW + xx;
        const m = mask[i * 4] / 255;
        const n = grain(xx / TW * 5, y / TH * 5);
        let r = 0.30 - height[i] * 0.07 + n * 0.09 - m * 0.16;
        r = Math.max(0.04, Math.min(0.8, r));
        const o = i * 4;
        d[o] = d[o + 1] = d[o + 2] = r * 255; d[o + 3] = 255;
      }
    }
    x.putImageData(img, 0, 0);
    return new THREE.CanvasTexture(s.canvas);
  }

  // Film thickness drives the interference hue; broad and smooth so the
  // colour sweeps across the folds instead of flickering per-thread.
  function makeThicknessTexture() {
    const W = 512, H = 320, s = surface(W, H), x = s.ctx;
    const img = x.createImageData(W, H), d = img.data;
    const a = valueNoise(4471), b = valueNoise(88231);
    for (let y = 0; y < H; y++) {
      for (let xx = 0; xx < W; xx++) {
        const u = xx / W, v = y / H;
        const n = a(u * 3.4, v * 2.4) * 0.58 + b(u * 8.5, v * 5.6) * 0.42;
        const o = (y * W + xx) * 4;
        const sweep = 0.5 + 0.42 * Math.sin(u * Math.PI * 2 - v * 1.15);
        const t = Math.max(0, Math.min(1, sweep + (n - 0.5) * 0.26));
        d[o] = d[o + 1] = d[o + 2] = t * 255; d[o + 3] = 255;
      }
    }
    x.putImageData(img, 0, 0);
    return new THREE.CanvasTexture(s.canvas);
  }

  /* ------------------------------------------------------- environment --
     A small studio painted into an equirectangular canvas: three softboxes
     of different colour temperature, which is what the iridescent film has
     to break apart into colour. */
  function makeStudioEnvironment(renderer) {
    const W = 1024, H = 512, s = surface(W, H), x = s.ctx;
    const g = x.createLinearGradient(0, 0, 0, H);
    g.addColorStop(0, '#0a0c1c');
    g.addColorStop(0.5, '#05060e');
    g.addColorStop(1, '#02030a');
    x.fillStyle = g; x.fillRect(0, 0, W, H);

    const box = (cx, cy, w, h, color, alpha) => {
      const grad = x.createRadialGradient(cx, cy, 0, cx, cy, Math.max(w, h));
      grad.addColorStop(0, color);
      grad.addColorStop(1, 'rgba(0,0,0,0)');
      x.globalAlpha = alpha;
      x.fillStyle = grad;
      x.save(); x.translate(cx, cy); x.scale(w / Math.max(w, h), h / Math.max(w, h));
      x.beginPath(); x.arc(0, 0, Math.max(w, h), 0, Math.PI * 2); x.fill();
      x.restore();
      x.globalAlpha = 1;
    };

    box(W * 0.24, H * 0.20, 300, 120, '#fff3e2', 1);      // warm key overhead
    box(W * 0.74, H * 0.34, 230, 190, '#7fd8ff', 0.95);   // cool side fill
    box(W * 0.52, H * 0.86, 340, 130, '#ff5fa8', 0.55);   // magenta bounce
    box(W * 0.02, H * 0.55, 160, 220, '#9d7bff', 0.5);    // violet rim

    const tex = new THREE.CanvasTexture(s.canvas);
    tex.mapping = THREE.EquirectangularReflectionMapping;
    tex.colorSpace = THREE.SRGBColorSpace;
    const pmrem = new THREE.PMREMGenerator(renderer);
    pmrem.compileEquirectangularShader();
    const env = pmrem.fromEquirectangular(tex).texture;
    pmrem.dispose();
    tex.dispose();
    return env;
  }

  function makeBackdropTexture() {
    const W = 512, H = 320, s = surface(W, H), x = s.ctx;
    x.fillStyle = '#04050c'; x.fillRect(0, 0, W, H);
    const g = x.createRadialGradient(W * 0.5, H * 0.42, 0, W * 0.5, H * 0.42, W * 0.62);
    g.addColorStop(0, 'rgba(58,42,120,0.85)');
    g.addColorStop(0.45, 'rgba(22,20,58,0.55)');
    g.addColorStop(1, 'rgba(3,4,11,0)');
    x.fillStyle = g; x.fillRect(0, 0, W, H);
    const tex = new THREE.CanvasTexture(s.canvas);
    tex.colorSpace = THREE.SRGBColorSpace;
    return tex;
  }

  /* ------------------------------------------------------------- scene -- */
  const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  renderer.toneMapping = THREE.ACESFilmicToneMapping;
  renderer.toneMappingExposure = 1.48;

  const maxAniso = renderer.capabilities.getMaxAnisotropy();
  const scene = new THREE.Scene();
  scene.background = makeBackdropTexture();
  scene.environment = makeStudioEnvironment(renderer);

  const BW = 4.7, BH = 2.8;
  const GX = 56, GY = 40;
  const geo = new THREE.PlaneGeometry(BW, BH, GX, GY);

  const albedo = makeAlbedoTexture();
  blurHeight(2);
  const normalMap = makeNormalTexture(2.4);
  const roughnessMap = makeRoughnessTexture();
  const thicknessMap = makeThicknessTexture();
  thicknessMap.wrapS = THREE.RepeatWrapping;
  [albedo, normalMap, roughnessMap, thicknessMap].forEach((t) => { t.anisotropy = maxAniso; });

  const mat = new THREE.MeshPhysicalMaterial({
    map: albedo,
    normalMap,
    normalScale: new THREE.Vector2(0.24, 0.24),
    roughnessMap,
    roughness: 1.0,
    metalness: 0.45,
    side: THREE.DoubleSide,
    envMapIntensity: 2.6,
    iridescence: 1.0,
    iridescenceIOR: 2.2,
    iridescenceThicknessRange: [300, 820],
    iridescenceThicknessMap: thicknessMap,
    sheen: 0.3,
    sheenColor: new THREE.Color('#a9d9ff'),
    sheenRoughness: 0.32,
  });

  const mesh = new THREE.Mesh(geo, mat);
  scene.add(mesh);

  scene.add(new THREE.AmbientLight(0x3b3676, 0.55));
  const key = new THREE.DirectionalLight(0xffeede, 2.1);
  key.position.set(-3.1, 3.2, 3.4); scene.add(key);
  const rimCool = new THREE.DirectionalLight(0x3fbcff, 2.9);
  rimCool.position.set(3.6, 0.6, 2.2); scene.add(rimCool);
  const rimWarm = new THREE.DirectionalLight(0xff3d8e, 2.4);
  rimWarm.position.set(0.4, -2.6, 1.6); scene.add(rimWarm);
  const fill = new THREE.DirectionalLight(0x9d7bff, 1.6);
  fill.position.set(-3.2, -1.0, 2.6); scene.add(fill);

  /* ----------------------------------------------------------- physics --
     The parent's Verlet sheet, run finer and slower so the specular travels
     over the folds instead of chattering. The pinned top row is driven on a
     shallow standing wave, which is what sets the vertical folds. */
  const pos = geo.attributes.position;
  const N = (GX + 1) * (GY + 1);
  const cur = new Float32Array(N * 3), prev = new Float32Array(N * 3), rest = new Float32Array(N * 3);
  const pinned = new Uint8Array(N);

  for (let i = 0; i < N; i++) {
    const ax = pos.getX(i), ay = pos.getY(i);
    cur[i * 3] = prev[i * 3] = rest[i * 3] = ax;
    cur[i * 3 + 1] = prev[i * 3 + 1] = rest[i * 3 + 1] = ay;
    cur[i * 3 + 2] = prev[i * 3 + 2] = rest[i * 3 + 2] = 0;
  }
  for (let ix = 0; ix <= GX; ix++) pinned[ix] = 1;

  const idx = (ix, iy) => ix + iy * (GX + 1);
  const restH = BW / GX, restV = BH / GY;
  const GRAV = -2.15, DAMP = 0.989, DT = 0.016;

  function wind(ix, iy, t) {
    const cx = ix / GX, cy = iy / GY;
    const travel = t * 1.15 - cy * 3.4;
    const gust = 0.52 + 0.34 * Math.sin(t * 0.44) + 0.16 * Math.sin(t * 1.31 + 1.1);
    const amp = 2.15 * cy;
    const fz = (Math.sin(travel + cx * 2.6) + 0.46 * Math.sin(travel * 1.55 + cx * 5.1)) * amp * gust;
    const fx = Math.sin(t * 0.6 + cy * 1.9) * 0.9 * cy * (cx - 0.5);
    return [fx, -0.3 * cy, fz];
  }

  // Standing folds: the rail the silk hangs from breathes in and out.
  function railZ(ix, t) {
    const cx = ix / GX;
    return Math.sin(cx * Math.PI * 3.0 + t * 0.22) * 0.15
         + Math.sin(cx * Math.PI * 5.0 - t * 0.15) * 0.05;
  }

  function solve(a, b, rl) {
    const ax = cur[a * 3], ay = cur[a * 3 + 1], az = cur[a * 3 + 2];
    let dx = cur[b * 3] - ax, dy = cur[b * 3 + 1] - ay, dz = cur[b * 3 + 2] - az;
    const d = Math.sqrt(dx * dx + dy * dy + dz * dz) || 1e-6;
    const diff = (d - rl) / d * 0.5;
    dx *= diff; dy *= diff; dz *= diff;
    const pa = pinned[a], pb = pinned[b];
    if (!pa && !pb) {
      cur[a * 3] += dx; cur[a * 3 + 1] += dy; cur[a * 3 + 2] += dz;
      cur[b * 3] -= dx; cur[b * 3 + 1] -= dy; cur[b * 3 + 2] -= dz;
    } else if (pa && !pb) { cur[b * 3] -= dx * 2; cur[b * 3 + 1] -= dy * 2; cur[b * 3 + 2] -= dz * 2; }
    else if (!pa && pb) { cur[a * 3] += dx * 2; cur[a * 3 + 1] += dy * 2; cur[a * 3 + 2] += dz * 2; }
  }

  function step(t) {
    for (let iy = 0; iy <= GY; iy++) {
      for (let ix = 0; ix <= GX; ix++) {
        const i = idx(ix, iy);
        if (pinned[i]) continue;
        const f = wind(ix, iy, t);
        for (let k = 0; k < 3; k++) {
          const j = i * 3 + k;
          const a = k === 0 ? f[0] : k === 1 ? f[1] + GRAV : f[2];
          const v = (cur[j] - prev[j]) * DAMP;
          prev[j] = cur[j];
          cur[j] = cur[j] + v + a * DT * DT;
        }
      }
    }
    for (let it = 0; it < 4; it++) {
      for (let iy = 0; iy <= GY; iy++) for (let ix = 0; ix < GX; ix++) solve(idx(ix, iy), idx(ix + 1, iy), restH);
      for (let iy = 0; iy < GY; iy++) for (let ix = 0; ix <= GX; ix++) solve(idx(ix, iy), idx(ix, iy + 1), restV);
    }
    for (let ix = 0; ix <= GX; ix++) {
      const i = ix, z = railZ(ix, t);
      cur[i * 3] = prev[i * 3] = rest[i * 3];
      cur[i * 3 + 1] = prev[i * 3 + 1] = rest[i * 3 + 1];
      cur[i * 3 + 2] = prev[i * 3 + 2] = z;
    }
  }

  function commit() {
    for (let i = 0; i < N; i++) pos.setXYZ(i, cur[i * 3], cur[i * 3 + 1], cur[i * 3 + 2]);
    pos.needsUpdate = true;
    geo.computeVertexNormals();
  }

  /* -------------------------------------------------------------- bloom --
     Scene into a half-float target, a bright pass at quarter resolution,
     two separable blurs, then one composite that tone-maps to the screen. */
  const rtScene = new THREE.WebGLRenderTarget(1, 1, { type: THREE.HalfFloatType, samples: 4 });
  const rtA = new THREE.WebGLRenderTarget(1, 1, { type: THREE.HalfFloatType });
  const rtB = new THREE.WebGLRenderTarget(1, 1, { type: THREE.HalfFloatType });
  [rtScene, rtA, rtB].forEach((rt) => {
    rt.texture.minFilter = THREE.LinearFilter;
    rt.texture.magFilter = THREE.LinearFilter;
    rt.texture.generateMipmaps = false;
  });

  const quadGeo = new THREE.PlaneGeometry(2, 2);
  const quadCam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
  const quadScene = new THREE.Scene();
  const quad = new THREE.Mesh(quadGeo, null);
  quadScene.add(quad);

  const VERT = 'varying vec2 vUv; void main(){ vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }';

  const brightMat = new THREE.ShaderMaterial({
    uniforms: { tDiffuse: { value: null }, threshold: { value: 0.72 } },
    vertexShader: VERT,
    fragmentShader: [
      'uniform sampler2D tDiffuse; uniform float threshold; varying vec2 vUv;',
      'void main(){',
      '  vec3 c = texture2D(tDiffuse, vUv).rgb;',
      '  float l = dot(c, vec3(0.2126, 0.7152, 0.0722));',
      '  float k = max(l - threshold, 0.0) / max(l, 1e-4);',
      '  gl_FragColor = vec4(c * k, 1.0);',
      '}',
    ].join('n'),
  });

  const blurMat = new THREE.ShaderMaterial({
    uniforms: { tDiffuse: { value: null }, direction: { value: new THREE.Vector2(1, 0) } },
    vertexShader: VERT,
    fragmentShader: [
      'uniform sampler2D tDiffuse; uniform vec2 direction; varying vec2 vUv;',
      'void main(){',
      '  vec3 sum = texture2D(tDiffuse, vUv).rgb * 0.2270270270;',
      '  sum += texture2D(tDiffuse, vUv + direction * 1.3846153846).rgb * 0.3162162162;',
      '  sum += texture2D(tDiffuse, vUv - direction * 1.3846153846).rgb * 0.3162162162;',
      '  sum += texture2D(tDiffuse, vUv + direction * 3.2307692308).rgb * 0.0702702703;',
      '  sum += texture2D(tDiffuse, vUv - direction * 3.2307692308).rgb * 0.0702702703;',
      '  gl_FragColor = vec4(sum, 1.0);',
      '}',
    ].join('n'),
  });

  const compositeMat = new THREE.ShaderMaterial({
    uniforms: { tDiffuse: { value: null }, tBloom: { value: null }, strength: { value: 0.62 } },
    vertexShader: VERT,
    fragmentShader: [
      'uniform sampler2D tDiffuse; uniform sampler2D tBloom; uniform float strength; varying vec2 vUv;',
      'void main(){',
      '  vec3 base = texture2D(tDiffuse, vUv).rgb;',
      '  vec3 glow = texture2D(tBloom, vUv).rgb;',
      '  gl_FragColor = vec4(base + glow * strength, 1.0);',
      '  #include <tonemapping_fragment>',
      '  #include <colorspace_fragment>',
      '}',
    ].join('n'),
  });

  function blit(material, target) {
    quad.material = material;
    renderer.setRenderTarget(target);
    renderer.render(quadScene, quadCam);
  }

  /* --------------------------------------------------------------- fit -- */
  let camera, cw = 1, ch = 1;
  function fit() {
    cw = window.innerWidth; ch = window.innerHeight;
    renderer.setSize(cw, ch, false);
    const dpr = renderer.getPixelRatio();
    const pw = Math.max(2, Math.floor(cw * dpr)), ph = Math.max(2, Math.floor(ch * dpr));
    rtScene.setSize(pw, ph);
    rtA.setSize(Math.max(2, pw >> 2), Math.max(2, ph >> 2));
    rtB.setSize(Math.max(2, pw >> 2), Math.max(2, ph >> 2));
    const aspect = cw / ch;
    camera = new THREE.PerspectiveCamera(40, aspect, 0.1, 100);
    const vFit = (BH / 2) / Math.tan(40 * Math.PI / 360);
    const hFit = (BW / 2) / Math.tan(40 * Math.PI / 360) / aspect;
    camera.position.set(0, 0.02, Math.max(vFit, hFit) * 0.98 + 0.24);
    camera.lookAt(0, -0.04, 0);
  }
  window.addEventListener('resize', fit);
  fit();

  function draw(t) {
    thicknessMap.offset.x = (t * 0.028) % 1;
    // Slow parallax so the highlight sweeps rather than sits.
    const base = camera.position.z;
    camera.position.x = Math.sin(t * 0.13) * 0.30;
    camera.position.y = 0.02 + Math.sin(t * 0.17 + 1.4) * 0.12;
    camera.position.z = base;
    camera.lookAt(0, -0.04, 0);

    renderer.setRenderTarget(rtScene);
    renderer.clear();
    renderer.render(scene, camera);

    brightMat.uniforms.tDiffuse.value = rtScene.texture;
    blit(brightMat, rtA);
    blurMat.uniforms.tDiffuse.value = rtA.texture;
    blurMat.uniforms.direction.value.set(1 / rtA.width, 0);
    blit(blurMat, rtB);
    blurMat.uniforms.tDiffuse.value = rtB.texture;
    blurMat.uniforms.direction.value.set(0, 1 / rtA.height);
    blit(blurMat, rtA);

    compositeMat.uniforms.tDiffuse.value = rtScene.texture;
    compositeMat.uniforms.tBloom.value = rtA.texture;
    renderer.setRenderTarget(null);
    blit(compositeMat, null);
  }

  let running = false, raf = 0, t = 0;
  function loop() {
    if (!running) return;
    t += DT;
    step(t); commit(); draw(t);
    raf = requestAnimationFrame(loop);
  }
  function start() { if (running) return; running = true; raf = requestAnimationFrame(loop); }
  function stop() { running = false; cancelAnimationFrame(raf); }

  // Deterministic capture hook — render one frame at an exact time.
  window.__seek = (time) => {
    const target = Math.max(0, time);
    if (target < t) { t = 0; for (let i = 0; i < N * 3; i++) { cur[i] = rest[i]; prev[i] = rest[i]; } }
    while (t < target - DT * 0.5) { t += DT; step(t); }
    commit(); draw(t);
  };

  for (let s = 0; s < 150; s++) step(s * DT);
  t = 150 * DT;
  if (reduce) {
    commit(); draw(t);
  } else {
    start();
    document.addEventListener('visibilitychange', () => (document.hidden ? stop() : start()));
  }
})();
</script>
</body>
</html>

/* ===== src/shaders/woven-cloth/woven-cloth-atelier.html (html) ===== */
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Woven Cloth · Atelier Flag</title>
<script src="https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.min.js"></script>
<style>
  html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #12100d; }
  #cloth { display: block; width: 100%; height: 100%; }
  #vignette {
    position: fixed; inset: 0; pointer-events: none;
    background: radial-gradient(84% 76% at 46% 42%, transparent 50%, rgba(14,12,10,.72) 100%);
  }
</style>
</head>
<body>
<canvas id="cloth"></canvas>
<div id="vignette"></div>
<script>
(() => {
  const reduce = matchMedia('(prefers-reduced-motion:reduce)').matches;
  const canvas = document.getElementById('cloth');
  if (!window.THREE) return;

  const TW = 1600, TH = 1030;

  // Cut-and-sew geometry, in texture pixels. Everything the flag is made of
  // — hoist band, panel seams, hems, topstitching — is laid out from here.
  const HOIST = 122;          // heading tape down the pinned edge
  const FLY_HEM = 48;         // doubled hem at the flying edge
  const EDGE_HEM = 36;        // top and bottom hems
  const SEAM_Y = [TH / 3, (TH * 2) / 3];
  const SEAM_H = 24;

  function surface(w, h) {
    const c = document.createElement('canvas');
    c.width = w; c.height = h;
    return { canvas: c, ctx: c.getContext('2d', { willReadFrequently: true }) };
  }

  function alpha(ctx) { return ctx.getImageData(0, 0, TW, TH).data; }

  // --- masks ---------------------------------------------------------------
  // Construction: the raised cloth of the band, the felled seams, the hems.
  function makeStructureMask() {
    const s = surface(TW, TH), x = s.ctx;
    x.fillStyle = '#000'; x.fillRect(0, 0, TW, TH);
    x.fillStyle = '#fff';
    x.fillRect(0, 0, HOIST, TH);
    x.fillRect(TW - FLY_HEM, 0, FLY_HEM, TH);
    x.fillRect(0, 0, TW, EDGE_HEM);
    x.fillRect(0, TH - EDGE_HEM, TW, EDGE_HEM);
    SEAM_Y.forEach((y) => x.fillRect(HOIST, y - SEAM_H / 2, TW - HOIST - FLY_HEM, SEAM_H));
    return alpha(x);
  }

  // Topstitching: every seam and hem gets its own run of thread.
  function makeStitchMask() {
    const s = surface(TW, TH), x = s.ctx;
    x.fillStyle = '#000'; x.fillRect(0, 0, TW, TH);
    x.strokeStyle = '#fff';
    x.lineWidth = 4;
    x.setLineDash([15, 11]);
    x.lineCap = 'butt';
    const run = (x0, y0, x1, y1) => { x.beginPath(); x.moveTo(x0, y0); x.lineTo(x1, y1); x.stroke(); };

    run(16, 0, 16, TH);                 // hoist tape, inner and outer rows
    run(HOIST - 16, 0, HOIST - 16, TH);
    run(TW - FLY_HEM + 16, 0, TW - FLY_HEM + 16, TH);
    run(HOIST, EDGE_HEM - 13, TW - FLY_HEM, EDGE_HEM - 13);
    run(HOIST, TH - EDGE_HEM + 13, TW - FLY_HEM, TH - EDGE_HEM + 13);
    SEAM_Y.forEach((y) => {
      run(HOIST, y - SEAM_H / 2 + 5, TW - FLY_HEM, y - SEAM_H / 2 + 5);
      run(HOIST, y + SEAM_H / 2 - 5, TW - FLY_HEM, y + SEAM_H / 2 - 5);
    });

    // The sewn-on woven label, tacked down on all four sides.
    x.setLineDash([11, 9]);
    x.lineWidth = 3.5;
    x.strokeRect(TW - 470, TH - 214, 300, 104);
    x.setLineDash([]);
    return alpha(x);
  }

  // Everything printed: the house lockup, the composition line, the label.
  function makeInkMask() {
    const s = surface(TW, TH), x = s.ctx;
    x.fillStyle = '#000'; x.fillRect(0, 0, TW, TH);
    x.fillStyle = '#fff';
    x.textAlign = 'center'; x.textBaseline = 'middle';
    const cx = HOIST + (TW - HOIST - FLY_HEM) / 2;

    x.font = '700 30px "Helvetica Neue", Arial, sans-serif';
    x.fillText('A T E L I E R   ·   N O .   4 2', cx, TH * 0.235);

    x.font = 'bold 152px Georgia, "Times New Roman", serif';
    x.fillText('WOVEN', cx, TH * 0.395);
    x.fillText('CLOTH', cx, TH * 0.555);

    x.fillRect(cx - 250, TH * 0.645, 500, 4);

    x.font = '600 28px "Helvetica Neue", Arial, sans-serif';
    x.fillText('100% LINEN  ·  WARP 40s  ·  WEFT 40s  ·  CUT AND SEWN TO ORDER', cx, TH * 0.705);

    // Woven label content.
    x.font = 'bold 40px Georgia, "Times New Roman", serif';
    x.fillText('W C', TW - 320, TH - 184);
    x.font = '600 19px "Helvetica Neue", Arial, sans-serif';
    x.fillText('THREE PANEL · FLAT FELLED', TW - 320, TH - 144);
    return alpha(x);
  }

  const structure = makeStructureMask();
  const stitch = makeStitchMask();
  const ink = makeInkMask();

  // Plain linen weave — square, matte, slightly irregular in the thread.
  function weaveHeight(x, y) {
    const p = 7;
    const cx = Math.floor(x / p), cy = Math.floor(y / p);
    const u = (x % p) / p, v = (y % p) / p;
    const warpUp = ((cx + cy) % 2) === 0;
    const h = warpUp ? Math.sin(u * Math.PI) : Math.sin(v * Math.PI);
    return h + Math.sin(cx * 12.9898) * Math.sin(cy * 4.1414 + 1.9) * 0.22;
  }

  const height = new Float32Array(TW * TH);
  for (let y = 0; y < TH; y++) {
    for (let x = 0; x < TW; x++) {
      const i = y * TW + x;
      const st = structure[i * 4] / 255;
      const th = stitch[i * 4] / 255;
      height[i] = weaveHeight(x, y) + st * 1.5 + th * 3.4 + (ink[i * 4] / 255) * 0.22;
    }
  }

  function blurHeight(passes) {
    const tmp = new Float32Array(TW * TH);
    for (let n = 0; n < passes; n++) {
      for (let y = 0; y < TH; y++) {
        const row = y * TW;
        for (let x = 0; x < TW; x++) {
          const a = height[row + (x > 0 ? x - 1 : x)], b = height[row + x];
          const c = height[row + (x < TW - 1 ? x + 1 : x)];
          tmp[row + x] = (a + b + b + c) * 0.25;
        }
      }
      for (let x = 0; x < TW; x++) {
        for (let y = 0; y < TH; y++) {
          const a = tmp[(y > 0 ? y - 1 : y) * TW + x], b = tmp[y * TW + x];
          const c = tmp[(y < TH - 1 ? y + 1 : y) * TW + x];
          height[y * TW + x] = (a + b + b + c) * 0.25;
        }
      }
    }
  }

  function makeAlbedoTexture() {
    const s = surface(TW, TH), x = s.ctx;
    // Three panels, each cut from its own dye lot.
    const lots = ['#ded2b8', '#d8ccb1', '#e2d6bd'];
    for (let k = 0; k < 3; k++) {
      x.fillStyle = lots[k];
      x.fillRect(0, (TH / 3) * k, TW, TH / 3 + 1);
    }
    const img = x.getImageData(0, 0, TW, TH), d = img.data;
    const INK = [33, 48, 77], TAPE = [26, 37, 72], THREAD = [242, 234, 218];
    for (let i = 0; i < TW * TH; i++) {
      const px = i % TW;
      const st = structure[i * 4] / 255;
      const th = stitch[i * 4] / 255;
      const ik = ink[i * 4] / 255;
      const o = i * 4;
      const shade = 0.86 + height[i] * 0.14;
      let r = d[o] * shade, g = d[o + 1] * shade, b = d[o + 2] * shade;
      // The heading tape is indigo canvas; the seams and hems only darken.
      if (px < HOIST) {
        r += (TAPE[0] - r) * 0.94; g += (TAPE[1] - g) * 0.94; b += (TAPE[2] - b) * 0.94;
      } else if (st > 0) {
        r *= 1 - st * 0.17; g *= 1 - st * 0.17; b *= 1 - st * 0.15;
      }
      if (ik > 0 && px >= HOIST) {
        r += (INK[0] - r) * ik; g += (INK[1] - g) * ik; b += (INK[2] - b) * ik;
      }
      if (th > 0) {
        r += (THREAD[0] - r) * th * 0.92; g += (THREAD[1] - g) * th * 0.92; b += (THREAD[2] - b) * th * 0.92;
      }
      d[o] = r; d[o + 1] = g; d[o + 2] = b;
    }
    x.putImageData(img, 0, 0);
    const tex = new THREE.CanvasTexture(s.canvas);
    tex.colorSpace = THREE.SRGBColorSpace;
    return tex;
  }

  // Mercerised topstitch thread is the only glossy thing on the flag.
  function makeRoughnessTexture() {
    const s = surface(TW, TH), x = s.ctx;
    const img = x.createImageData(TW, TH), d = img.data;
    for (let i = 0; i < TW * TH; i++) {
      const th = stitch[i * 4] / 255;
      const ik = ink[i * 4] / 255;
      let r = 0.86 - height[i] * 0.05 - th * 0.42 - ik * 0.12;
      r = Math.max(0.18, Math.min(0.98, r));
      const o = i * 4;
      d[o] = d[o + 1] = d[o + 2] = r * 255; d[o + 3] = 255;
    }
    x.putImageData(img, 0, 0);
    return new THREE.CanvasTexture(s.canvas);
  }

  function makeNormalTexture(strength) {
    const s = surface(TW, TH);
    const img = s.ctx.createImageData(TW, TH), d = img.data;
    for (let y = 0; y < TH; y++) {
      const yp = y > 0 ? y - 1 : y, yn = y < TH - 1 ? y + 1 : y;
      for (let x = 0; x < TW; x++) {
        const xp = x > 0 ? x - 1 : x, xn = x < TW - 1 ? x + 1 : x;
        const dx = (height[y * TW + xp] - height[y * TW + xn]) * strength;
        const dy = (height[yp * TW + x] - height[yn * TW + x]) * strength;
        const len = Math.sqrt(dx * dx + dy * dy + 1);
        const o = (y * TW + x) * 4;
        d[o] = (dx / len * 0.5 + 0.5) * 255;
        d[o + 1] = (dy / len * 0.5 + 0.5) * 255;
        d[o + 2] = (1 / len * 0.5 + 0.5) * 255;
        d[o + 3] = 255;
      }
    }
    s.ctx.putImageData(img, 0, 0);
    return new THREE.CanvasTexture(s.canvas);
  }

  // A photographer's seamless: warm sweep behind, softbox above, bounce left.
  function makeStudioEnvironment(renderer) {
    const W = 1024, H = 512, s = surface(W, H), x = s.ctx;
    const g = x.createLinearGradient(0, 0, 0, H);
    g.addColorStop(0, '#2a2620');
    g.addColorStop(0.46, '#1a1713');
    g.addColorStop(1, '#0d0b09');
    x.fillStyle = g; x.fillRect(0, 0, W, H);
    const box = (cx, cy, rw, rh, color, a) => {
      const grad = x.createRadialGradient(cx, cy, 0, cx, cy, Math.max(rw, rh));
      grad.addColorStop(0, color); grad.addColorStop(1, 'rgba(0,0,0,0)');
      x.globalAlpha = a; x.fillStyle = grad;
      x.save(); x.translate(cx, cy); x.scale(rw / Math.max(rw, rh), rh / Math.max(rw, rh));
      x.beginPath(); x.arc(0, 0, Math.max(rw, rh), 0, Math.PI * 2); x.fill(); x.restore();
      x.globalAlpha = 1;
    };
    box(W * 0.30, H * 0.14, 340, 130, '#fffaf0', 1);
    box(W * 0.78, H * 0.40, 220, 200, '#c8d8ea', 0.55);
    box(W * 0.06, H * 0.62, 200, 240, '#e8d3ac', 0.5);
    const tex = new THREE.CanvasTexture(s.canvas);
    tex.mapping = THREE.EquirectangularReflectionMapping;
    tex.colorSpace = THREE.SRGBColorSpace;
    const pmrem = new THREE.PMREMGenerator(renderer);
    pmrem.compileEquirectangularShader();
    const env = pmrem.fromEquirectangular(tex).texture;
    pmrem.dispose(); tex.dispose();
    return env;
  }

  function makeBackdropTexture() {
    const W = 512, H = 320, s = surface(W, H), x = s.ctx;
    x.fillStyle = '#100e0b'; x.fillRect(0, 0, W, H);
    const g = x.createRadialGradient(W * 0.44, H * 0.36, 0, W * 0.44, H * 0.36, W * 0.60);
    g.addColorStop(0, 'rgba(96,84,66,0.95)');
    g.addColorStop(0.5, 'rgba(44,38,30,0.6)');
    g.addColorStop(1, 'rgba(13,11,9,0)');
    x.fillStyle = g; x.fillRect(0, 0, W, H);
    const tex = new THREE.CanvasTexture(s.canvas);
    tex.colorSpace = THREE.SRGBColorSpace;
    return tex;
  }

  /* ------------------------------------------------------------- scene -- */
  const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  renderer.toneMapping = THREE.ACESFilmicToneMapping;
  renderer.toneMappingExposure = 1.16;

  const maxAniso = renderer.capabilities.getMaxAnisotropy();
  const scene = new THREE.Scene();
  scene.background = makeBackdropTexture();
  scene.environment = makeStudioEnvironment(renderer);

  const BW = 5.0, BH = 2.9;
  const GX = 52, GY = 34;
  const geo = new THREE.PlaneGeometry(BW, BH, GX, GY);

  const albedo = makeAlbedoTexture();
  blurHeight(1);
  const normalMap = makeNormalTexture(2.6);
  const roughnessMap = makeRoughnessTexture();
  [albedo, normalMap, roughnessMap].forEach((t) => { t.anisotropy = maxAniso; });

  const mat = new THREE.MeshPhysicalMaterial({
    map: albedo,
    normalMap,
    normalScale: new THREE.Vector2(0.55, 0.55),
    roughnessMap,
    roughness: 1.0,
    metalness: 0.0,
    side: THREE.DoubleSide,
    envMapIntensity: 1.15,
    sheen: 0.6,
    sheenColor: new THREE.Color('#f3e6cd'),
    sheenRoughness: 0.62,
  });

  const flag = new THREE.Mesh(geo, mat);
  const rig = new THREE.Group();
  rig.add(flag);
  scene.add(rig);

  /* ---------------------------------------------------------- hardware -- */
  const brass = new THREE.MeshStandardMaterial({ color: 0xb98f47, metalness: 1.0, roughness: 0.32 });
  const steel = new THREE.MeshStandardMaterial({ color: 0x8d8f95, metalness: 1.0, roughness: 0.28 });

  const HOIST_X = -BW / 2;
  const GROMMET_V = [0.09, 0.5, 0.91];       // down the hoist, in 0..1 of BH
  const grommets = GROMMET_V.map((v) => {
    const g = new THREE.Mesh(new THREE.TorusGeometry(0.055, 0.021, 12, 28), brass);
    g.position.set(HOIST_X + 0.05, BH / 2 - v * BH, 0);
    rig.add(g);
    return g;
  });

  const mast = new THREE.Mesh(
    new THREE.CylinderGeometry(0.035, 0.042, BH * 1.5, 18),
    steel,
  );
  mast.position.set(HOIST_X - 0.16, -BH * 0.12, -0.04);
  rig.add(mast);
  const finial = new THREE.Mesh(new THREE.SphereGeometry(0.072, 20, 14), brass);
  finial.position.set(HOIST_X - 0.16, -BH * 0.12 + BH * 0.75 + 0.05, -0.04);
  rig.add(finial);

  // The clips that carry the hoist grommets on the mast.
  grommets.forEach((g) => {
    const clip = new THREE.Mesh(new THREE.TorusGeometry(0.048, 0.011, 10, 22), steel);
    clip.position.set(HOIST_X - 0.11, g.position.y, -0.02);
    clip.rotation.y = Math.PI / 2.4;
    rig.add(clip);
  });

  /* ------------------------------------------------------------ lights -- */
  scene.add(new THREE.HemisphereLight(0xcadcf2, 0x4a3d2c, 0.75));
  const key = new THREE.DirectionalLight(0xfff4e0, 2.5);
  key.position.set(2.6, 3.4, 3.0); scene.add(key);
  const bounce = new THREE.DirectionalLight(0xe6cfa4, 0.85);
  bounce.position.set(-3.4, -1.6, 2.0); scene.add(bounce);
  const back = new THREE.DirectionalLight(0xfff0d8, 1.5);
  back.position.set(-1.6, 1.4, -3.2); scene.add(back);

  /* ----------------------------------------------------------- physics --
     Pinned down the hoist instead of along the top, so the wave travels out
     to the fly and the free corners snap the way a flown flag does. */
  const pos = geo.attributes.position;
  const N = (GX + 1) * (GY + 1);
  const cur = new Float32Array(N * 3), prev = new Float32Array(N * 3), rest = new Float32Array(N * 3);
  const pinned = new Uint8Array(N);
  const idx = (ix, iy) => ix + iy * (GX + 1);

  for (let i = 0; i < N; i++) {
    const ax = pos.getX(i), ay = pos.getY(i);
    cur[i * 3] = prev[i * 3] = rest[i * 3] = ax;
    cur[i * 3 + 1] = prev[i * 3 + 1] = rest[i * 3 + 1] = ay;
    cur[i * 3 + 2] = prev[i * 3 + 2] = rest[i * 3 + 2] = 0;
  }
  for (let iy = 0; iy <= GY; iy++) pinned[idx(0, iy)] = 1;

  const restH = BW / GX, restV = BH / GY;
  const GRAV = -0.22, DAMP = 0.986, DT = 0.016;

  function wind(ix, iy, t) {
    const cx = ix / GX, cy = iy / GY;
    // Amplitude grows with distance from the hoist: the fly end does the work,
    // while a steady outward pull keeps the flag flown rather than limp.
    const reach = cx * (0.35 + 0.65 * cx);
    const gust = 0.78 + 0.26 * Math.sin(t * 0.53) + 0.14 * Math.sin(t * 1.47 + 0.9);
    const travel = t * 4.2 - cx * 6.6;
    const fz = (Math.sin(travel) + 0.38 * Math.sin(travel * 1.9 + cy * 2.6)) * 3.4 * reach * gust;
    const fy = Math.sin(travel * 0.8 + 1.2) * 0.85 * reach;
    const fx = 2.6 * reach * gust;
    return [fx, fy, fz];
  }

  function solve(a, b, rl) {
    const ax = cur[a * 3], ay = cur[a * 3 + 1], az = cur[a * 3 + 2];
    let dx = cur[b * 3] - ax, dy = cur[b * 3 + 1] - ay, dz = cur[b * 3 + 2] - az;
    const d = Math.sqrt(dx * dx + dy * dy + dz * dz) || 1e-6;
    const diff = (d - rl) / d * 0.5;
    dx *= diff; dy *= diff; dz *= diff;
    const pa = pinned[a], pb = pinned[b];
    if (!pa && !pb) {
      cur[a * 3] += dx; cur[a * 3 + 1] += dy; cur[a * 3 + 2] += dz;
      cur[b * 3] -= dx; cur[b * 3 + 1] -= dy; cur[b * 3 + 2] -= dz;
    } else if (pa && !pb) { cur[b * 3] -= dx * 2; cur[b * 3 + 1] -= dy * 2; cur[b * 3 + 2] -= dz * 2; }
    else if (!pa && pb) { cur[a * 3] += dx * 2; cur[a * 3 + 1] += dy * 2; cur[a * 3 + 2] += dz * 2; }
  }

  function step(t) {
    for (let iy = 0; iy <= GY; iy++) {
      for (let ix = 0; ix <= GX; ix++) {
        const i = idx(ix, iy);
        if (pinned[i]) continue;
        const f = wind(ix, iy, t);
        for (let k = 0; k < 3; k++) {
          const j = i * 3 + k;
          const a = k === 0 ? f[0] : k === 1 ? f[1] + GRAV : f[2];
          const v = (cur[j] - prev[j]) * DAMP;
          prev[j] = cur[j];
          cur[j] = cur[j] + v + a * DT * DT;
        }
      }
    }
    for (let it = 0; it < 4; it++) {
      for (let iy = 0; iy <= GY; iy++) for (let ix = 0; ix < GX; ix++) solve(idx(ix, iy), idx(ix + 1, iy), restH);
      for (let iy = 0; iy < GY; iy++) for (let ix = 0; ix <= GX; ix++) solve(idx(ix, iy), idx(ix, iy + 1), restV);
    }
    // The heading tape is sewn to a rigid edge; hold it exactly.
    for (let iy = 0; iy <= GY; iy++) {
      const i = idx(0, iy);
      for (let k = 0; k < 3; k++) { cur[i * 3 + k] = rest[i * 3 + k]; prev[i * 3 + k] = rest[i * 3 + k]; }
    }
  }

  function commit() {
    for (let i = 0; i < N; i++) pos.setXYZ(i, cur[i * 3], cur[i * 3 + 1], cur[i * 3 + 2]);
    pos.needsUpdate = true;
    geo.computeVertexNormals();
  }

  /* --------------------------------------------------------------- fit -- */
  let camera;
  function fit() {
    const w = window.innerWidth, h = window.innerHeight;
    renderer.setSize(w, h, false);
    const aspect = w / h;
    camera = new THREE.PerspectiveCamera(40, aspect, 0.1, 100);
    const vFit = (BH * 1.12 / 2) / Math.tan(40 * Math.PI / 360);
    const hFit = ((BW + 1.0) / 2) / Math.tan(40 * Math.PI / 360) / aspect;
    camera.position.set(0.10, 0.06, Math.max(vFit, hFit) * 1.13 + 0.30);
    camera.lookAt(0.10, 0.0, 0);
  }
  window.addEventListener('resize', fit);
  fit();

  function draw(t) {
    // The mast leans a few degrees so the flag is never seen dead flat.
    rig.rotation.y = -0.16 + Math.sin(t * 0.11) * 0.045;
    rig.rotation.z = Math.sin(t * 0.08 + 0.7) * 0.012;
    camera.position.y = 0.06 + Math.sin(t * 0.15) * 0.06;
    camera.lookAt(0.10, 0.0, 0);
    renderer.render(scene, camera);
  }

  let running = false, raf = 0, t = 0;
  function loop() {
    if (!running) return;
    t += DT;
    step(t); commit(); draw(t);
    raf = requestAnimationFrame(loop);
  }
  function start() { if (running) return; running = true; raf = requestAnimationFrame(loop); }
  function stop() { running = false; cancelAnimationFrame(raf); }

  window.__seek = (time) => {
    const target = Math.max(0, time);
    if (target < t) { t = 0; for (let i = 0; i < N * 3; i++) { cur[i] = rest[i]; prev[i] = rest[i]; } }
    while (t < target - DT * 0.5) { t += DT; step(t); }
    commit(); draw(t);
  };

  for (let s = 0; s < 160; s++) step(s * DT);
  t = 160 * DT;
  if (reduce) {
    commit(); draw(t);
  } else {
    start();
    document.addEventListener('visibilitychange', () => (document.hidden ? stop() : start()));
  }
})();
</script>
</body>
</html>

/* ===== src/shaders/woven-cloth/woven-cloth-washi.html (html) ===== */
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Woven Cloth · Washi Noren</title>
<script src="https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.min.js"></script>
<style>
  html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #0d0a07; }
  #cloth { display: block; width: 100%; height: 100%; }
  #vignette {
    position: fixed; inset: 0; pointer-events: none;
    background: radial-gradient(80% 74% at 50% 44%, transparent 46%, rgba(10,7,5,.80) 100%);
  }
</style>
</head>
<body>
<canvas id="cloth"></canvas>
<div id="vignette"></div>
<script>
(() => {
  const reduce = matchMedia('(prefers-reduced-motion:reduce)').matches;
  const canvas = document.getElementById('cloth');
  if (!window.THREE) return;

  const TW = 1500, TH = 980;
  const PANELS = 3;
  const BAND = 0.15;            // the uncut sleeve the rod runs through
  const SLIT_U = [1 / 3, 2 / 3];

  // A small deterministic generator, so the paper's fibres and torn edge are
  // the same every load — the deckle has to match the alpha mask exactly.
  function rng(seed) {
    let s = seed >>> 0;
    return () => {
      s ^= s << 13; s >>>= 0;
      s ^= s >> 17;
      s ^= s << 5; s >>>= 0;
      return s / 4294967296;
    };
  }

  function surface(w, h) {
    const c = document.createElement('canvas');
    c.width = w; c.height = h;
    return { canvas: c, ctx: c.getContext('2d', { willReadFrequently: true }) };
  }

  /* ------------------------------------------------------------- deckle --
     One torn profile, sampled by both the alpha mask and the shading, so the
     edge reads as a genuine hand-made sheet rather than a cropped rectangle. */
  const deckleRand = rng(20260826);
  const deckleLow = new Float32Array(TW);
  {
    const control = [];
    for (let i = 0; i <= 24; i++) control.push(deckleRand());
    for (let x = 0; x < TW; x++) {
      const f = (x / TW) * 24;
      const i = Math.floor(f), t = f - i;
      const smooth = t * t * (3 - 2 * t);
      const a = control[i], b = control[Math.min(24, i + 1)];
      const base = a + (b - a) * smooth;
      const fine = Math.sin(x * 0.19) * 0.16 + Math.sin(x * 0.061 + 1.7) * 0.24;
      deckleLow[x] = TH - 14 - (base * 26 + fine * 12);
    }
  }

  function makeAlphaMask() {
    const s = surface(TW, TH), x = s.ctx;
    x.fillStyle = '#fff'; x.fillRect(0, 0, TW, TH);
    x.fillStyle = '#000';

    // Slits between the panels, stopping short of the sleeve.
    const slitW = 22, bandPx = TH * BAND;
    SLIT_U.forEach((u) => x.fillRect(u * TW - slitW / 2, bandPx, slitW, TH - bandPx));

    // Torn lower edge.
    x.beginPath();
    x.moveTo(0, TH);
    for (let px = 0; px < TW; px++) x.lineTo(px, deckleLow[px]);
    x.lineTo(TW, TH);
    x.closePath();
    x.fill();

    // Softly feathered outer edges, as a couched sheet dries.
    const side = rng(771);
    for (let e = 0; e < 2; e++) {
      x.beginPath();
      x.moveTo(e ? TW : 0, 0);
      for (let py = 0; py <= TH; py += 6) {
        const w = 6 + side() * 9;
        x.lineTo(e ? TW - w : w, py);
      }
      x.lineTo(e ? TW : 0, TH);
      x.closePath();
      x.fill();
    }
    return s;
  }

  /* ---------------------------------------------------------- the cloth --
     Indigo-dyed kozo: vat unevenness, long fibres, the laid and chain lines
     the papermaking screen leaves, and a resist-dyed crest and lettering. */
  function drawWeaveCrest(x, cx, cy, R) {
    const cells = 6, step = (R * 1.86) / cells, origin = -R * 0.93;
    x.save();
    x.beginPath(); x.arc(cx, cy, R * 0.86, 0, Math.PI * 2); x.clip();
    x.fillStyle = '#f3ece0';
    for (let a = 0; a < cells; a++) {
      for (let b = 0; b < cells; b++) {
        if ((a + b) % 2) continue;
        x.fillRect(cx + origin + a * step, cy + origin + b * step, step + 0.5, step + 0.5);
      }
    }
    x.restore();
    x.strokeStyle = '#f3ece0';
    x.lineWidth = R * 0.10;
    x.beginPath(); x.arc(cx, cy, R * 0.96, 0, Math.PI * 2); x.stroke();
  }

  function verticalWord(x, word, cx, top, size, step) {
    x.font = 'bold ' + size + 'px Georgia, "Times New Roman", serif';
    x.textAlign = 'center'; x.textBaseline = 'middle';
    for (let i = 0; i < word.length; i++) x.fillText(word[i], cx, top + i * step);
  }

  function makeClothCanvas() {
    const s = surface(TW, TH), x = s.ctx;

    // Indigo vat: deeper where the cloth was dipped longest.
    const g = x.createLinearGradient(0, 0, 0, TH);
    g.addColorStop(0, '#284a6c');
    g.addColorStop(0.42, '#203d5e');
    g.addColorStop(1, '#17304e');
    x.fillStyle = g; x.fillRect(0, 0, TW, TH);

    const cloud = rng(4471);
    for (let i = 0; i < 26; i++) {
      const cx = cloud() * TW, cy = cloud() * TH, r = 120 + cloud() * 320;
      const light = cloud() > 0.5;
      const rg = x.createRadialGradient(cx, cy, 0, cx, cy, r);
      rg.addColorStop(0, light ? 'rgba(96,132,168,0.13)' : 'rgba(9,22,40,0.16)');
      rg.addColorStop(1, 'rgba(0,0,0,0)');
      x.fillStyle = rg; x.fillRect(cx - r, cy - r, r * 2, r * 2);
    }

    // Laid lines from the bamboo screen, then the heavier chain lines.
    x.strokeStyle = 'rgba(180,205,228,0.045)';
    x.lineWidth = 1;
    for (let px = 0; px < TW; px += 4) { x.beginPath(); x.moveTo(px + 0.5, 0); x.lineTo(px + 0.5, TH); x.stroke(); }
    x.strokeStyle = 'rgba(196,218,238,0.10)';
    x.lineWidth = 2;
    for (let py = 26; py < TH; py += 38) { x.beginPath(); x.moveTo(0, py + 0.5); x.lineTo(TW, py + 0.5); x.stroke(); }

    // Kozo fibres: long, mostly aligned, a few standing proud of the sheet.
    const fib = rng(90210);
    for (let i = 0; i < 2200; i++) {
      const fx = fib() * TW, fy = fib() * TH;
      const len = 30 + fib() * 150;
      const ang = (fib() - 0.5) * 0.9 + (fib() > 0.82 ? Math.PI / 2 : 0);
      const bow = (fib() - 0.5) * 26;
      const pale = fib();
      x.strokeStyle = pale > 0.3
        ? 'rgba(214,232,247,' + (0.022 + fib() * 0.048) + ')'
        : 'rgba(10,20,36,' + (0.04 + fib() * 0.07) + ')';
      x.lineWidth = 0.7 + fib() * 1.9;
      x.beginPath();
      x.moveTo(fx, fy);
      x.quadraticCurveTo(
        fx + Math.cos(ang) * len * 0.5 + bow, fy + Math.sin(ang) * len * 0.5 - bow,
        fx + Math.cos(ang) * len, fy + Math.sin(ang) * len,
      );
      x.stroke();
    }

    // Katazome: a resist-dyed frame inside each panel.
    const panelW = TW / PANELS;
    x.strokeStyle = 'rgba(243,236,224,0.72)';
    for (let k = 0; k < PANELS; k++) {
      const x0 = k * panelW + 34, w = panelW - 68;
      x.lineWidth = 5;
      x.strokeRect(x0, TH * BAND + 34, w, TH - TH * BAND - 118);
      x.lineWidth = 2;
      x.strokeRect(x0 + 13, TH * BAND + 47, w - 26, TH - TH * BAND - 144);
    }

    // The sleeve the rod runs through, and its shadow on the cloth below.
    x.fillStyle = 'rgba(8,18,34,0.30)';
    x.fillRect(0, 0, TW, TH * BAND);
    const sh = x.createLinearGradient(0, TH * BAND, 0, TH * BAND + 54);
    sh.addColorStop(0, 'rgba(6,14,28,0.42)');
    sh.addColorStop(1, 'rgba(6,14,28,0)');
    x.fillStyle = sh; x.fillRect(0, TH * BAND, TW, 54);
    x.strokeStyle = 'rgba(243,236,224,0.5)';
    x.lineWidth = 2.5;
    x.beginPath(); x.moveTo(0, TH * BAND); x.lineTo(TW, TH * BAND); x.stroke();

    x.fillStyle = '#f3ece0';
    verticalWord(x, 'WOVEN', panelW * 0.5, TH * 0.34, 96, 116);
    drawWeaveCrest(x, panelW * 1.5, TH * 0.53, 158);
    verticalWord(x, 'CLOTH', panelW * 2.5, TH * 0.34, 96, 116);

    // Vermilion seal, stamped at the foot of the centre panel.
    const S = 96, sx = panelW * 1.5 - S / 2, sy = TH * 0.828;
    x.fillStyle = '#a8342a';
    x.fillRect(sx, sy, S, S);
    x.strokeStyle = '#f3ece0';
    x.lineWidth = 4;
    x.strokeRect(sx + 11, sy + 11, S - 22, S - 22);
    x.fillStyle = '#f3ece0';
    for (let k = 0; k < 3; k++) x.fillRect(sx + 23 + k * 18, sy + 23, 8, S - 46);
    x.fillRect(sx + 23, sy + 38, S - 46, 8);
    x.fillRect(sx + 23, sy + 60, S - 46, 8);

    return s;
  }

  function makeWoodTexture() {
    const W = 512, H = 96, s = surface(W, H), x = s.ctx;
    const g = x.createLinearGradient(0, 0, 0, H);
    g.addColorStop(0, '#a1764a');
    g.addColorStop(0.45, '#7d5533');
    g.addColorStop(1, '#563820');
    x.fillStyle = g; x.fillRect(0, 0, W, H);
    const grain = rng(3312);
    for (let i = 0; i < 160; i++) {
      const y = grain() * H;
      x.strokeStyle = 'rgba(28,17,8,' + (0.05 + grain() * 0.16) + ')';
      x.lineWidth = 0.6 + grain() * 1.8;
      x.beginPath();
      x.moveTo(0, y);
      for (let px = 0; px <= W; px += 32) x.lineTo(px, y + Math.sin(px * 0.02 + i) * 2.4);
      x.stroke();
    }
    const tex = new THREE.CanvasTexture(s.canvas);
    tex.colorSpace = THREE.SRGBColorSpace;
    tex.wrapS = THREE.RepeatWrapping;
    tex.repeat.set(3, 1);
    return tex;
  }

  // A shoji screen behind the noren gives the paper something to glow with.
  function makeShojiTexture() {
    const W = 768, H = 512, s = surface(W, H), x = s.ctx;
    x.fillStyle = '#1a1109'; x.fillRect(0, 0, W, H);
    const glow = x.createRadialGradient(W * 0.5, H * 0.44, 0, W * 0.5, H * 0.44, W * 0.52);
    glow.addColorStop(0, '#ffdda4');
    glow.addColorStop(0.40, '#a97c42');
    glow.addColorStop(0.76, '#341d0c');
    glow.addColorStop(1, '#150d06');
    x.fillStyle = glow; x.fillRect(0, 0, W, H);
    // Kumiko lattice, softened so it stays a suggestion behind the cloth.
    if ('filter' in x) x.filter = 'blur(7px)';
    x.strokeStyle = 'rgba(46,28,13,0.20)';
    x.lineWidth = 3;
    for (let px = 54; px < W; px += 128) { x.beginPath(); x.moveTo(px, 0); x.lineTo(px, H); x.stroke(); }
    for (let py = 46; py < H; py += 118) { x.beginPath(); x.moveTo(0, py); x.lineTo(W, py); x.stroke(); }
    if ('filter' in x) x.filter = 'none';
    const tex = new THREE.CanvasTexture(s.canvas);
    tex.colorSpace = THREE.SRGBColorSpace;
    return tex;
  }

  /* ------------------------------------------------------------- scene -- */
  const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  renderer.toneMapping = THREE.ACESFilmicToneMapping;
  renderer.toneMappingExposure = 1.22;

  const maxAniso = renderer.capabilities.getMaxAnisotropy();
  const scene = new THREE.Scene();
  scene.background = new THREE.Color(0x0c0906);

  const shoji = new THREE.Mesh(
    new THREE.PlaneGeometry(13, 8.4),
    new THREE.MeshBasicMaterial({ map: makeShojiTexture() }),
  );
  shoji.position.set(0, 0, -2.4);
  scene.add(shoji);

  const BW = 4.4, BH = 2.9;
  const GX = 60, GY = 40;
  const geo = new THREE.PlaneGeometry(BW, BH, GX, GY);

  const clothCanvas = makeClothCanvas();
  const albedo = new THREE.CanvasTexture(clothCanvas.canvas);
  albedo.colorSpace = THREE.SRGBColorSpace;
  const alphaMap = new THREE.CanvasTexture(makeAlphaMask().canvas);
  [albedo, alphaMap].forEach((t) => { t.anisotropy = maxAniso; });

  const mat = new THREE.MeshPhysicalMaterial({
    map: albedo,
    alphaMap,
    alphaTest: 0.5,
    transparent: false,
    side: THREE.DoubleSide,
    roughness: 0.92,
    metalness: 0.0,
    transmission: 0.82,
    thickness: 0.10,
    ior: 1.36,
    attenuationColor: new THREE.Color('#9dc0dd'),
    attenuationDistance: 3.0,
    sheen: 0.9,
    sheenColor: new THREE.Color('#d8e6f2'),
    sheenRoughness: 0.85,
  });

  const noren = new THREE.Mesh(geo, mat);
  scene.add(noren);

  /* ------------------------------------------------------------ fittings */
  const wood = new THREE.MeshStandardMaterial({ map: makeWoodTexture(), roughness: 0.66, metalness: 0.0 });
  const rod = new THREE.Mesh(new THREE.CylinderGeometry(0.062, 0.062, BW + 0.9, 20), wood);
  rod.rotation.z = Math.PI / 2;
  rod.position.set(0, BH / 2 + 0.045, 0.02);
  scene.add(rod);
  [-1, 1].forEach((side) => {
    const cap = new THREE.Mesh(new THREE.SphereGeometry(0.082, 18, 12), wood);
    cap.position.set(side * (BW + 0.9) / 2, BH / 2 + 0.045, 0.02);
    scene.add(cap);
    const cord = new THREE.Mesh(new THREE.CylinderGeometry(0.011, 0.011, 3.4, 8),
      new THREE.MeshStandardMaterial({ color: 0x2a2018, roughness: 0.9 }));
    cord.position.set(side * (BW + 0.72) / 2, BH / 2 + 1.75, 0.02);
    scene.add(cord);
  });

  /* ------------------------------------------------------------ lights -- */
  scene.add(new THREE.AmbientLight(0x4a3a28, 0.8));
  const lantern = new THREE.DirectionalLight(0xffd9a0, 3.6);   // through the shoji
  lantern.position.set(-0.4, 0.9, -3.2); scene.add(lantern);
  const key = new THREE.DirectionalLight(0xffe9cc, 2.1);
  key.position.set(-2.6, 2.4, 2.6); scene.add(key);
  const fill = new THREE.DirectionalLight(0x94b6d8, 0.5);
  fill.position.set(3.0, -1.0, 2.0); scene.add(fill);

  /* ----------------------------------------------------------- physics --
     One sheet, but the horizontal links are cut below the sleeve at each
     slit, so the three panels hang and sway as their own pieces of cloth. */
  const pos = geo.attributes.position;
  const N = (GX + 1) * (GY + 1);
  const cur = new Float32Array(N * 3), prev = new Float32Array(N * 3), rest = new Float32Array(N * 3);
  const pinned = new Uint8Array(N);
  const idx = (ix, iy) => ix + iy * (GX + 1);

  for (let i = 0; i < N; i++) {
    const ax = pos.getX(i), ay = pos.getY(i);
    cur[i * 3] = prev[i * 3] = rest[i * 3] = ax;
    cur[i * 3 + 1] = prev[i * 3 + 1] = rest[i * 3 + 1] = ay;
    cur[i * 3 + 2] = prev[i * 3 + 2] = rest[i * 3 + 2] = 0;
  }
  for (let ix = 0; ix <= GX; ix++) pinned[ix] = 1;

  const BAND_ROWS = Math.round(GY * BAND);
  const SLIT_IX = SLIT_U.map((u) => Math.round(u * GX));
  const panelOf = (ix) => (ix < SLIT_IX[0] ? 0 : ix < SLIT_IX[1] ? 1 : 2);
  const linked = (ix, iy) => !(iy > BAND_ROWS && SLIT_IX.indexOf(ix + 1) !== -1);

  const restH = BW / GX, restV = BH / GY;
  const restD = Math.sqrt(restH * restH + restV * restV);
  const GRAV = -1.65, DAMP = 0.986, DT = 0.016;

  function wind(ix, iy, t) {
    const cx = ix / GX, cy = iy / GY;
    // An indoor draft: each panel catches it on its own beat.
    const ph = panelOf(ix) * 2.1;
    const gust = 0.40 + 0.28 * Math.sin(t * 0.37 + ph * 0.6) + 0.15 * Math.sin(t * 0.93 + ph);
    const travel = t * 1.2 - cy * 2.2 + ph;
    const amp = 1.5 * cy;
    const fz = (Math.sin(travel) + 0.35 * Math.sin(travel * 1.8 + cx * 3.4)) * amp * gust;
    const fx = Math.sin(t * 0.44 + ph) * 0.20 * cy;
    return [fx, -0.18 * cy, fz];
  }

  function solve(a, b, rl) {
    const ax = cur[a * 3], ay = cur[a * 3 + 1], az = cur[a * 3 + 2];
    let dx = cur[b * 3] - ax, dy = cur[b * 3 + 1] - ay, dz = cur[b * 3 + 2] - az;
    const d = Math.sqrt(dx * dx + dy * dy + dz * dz) || 1e-6;
    const diff = (d - rl) / d * 0.5;
    dx *= diff; dy *= diff; dz *= diff;
    const pa = pinned[a], pb = pinned[b];
    if (!pa && !pb) {
      cur[a * 3] += dx; cur[a * 3 + 1] += dy; cur[a * 3 + 2] += dz;
      cur[b * 3] -= dx; cur[b * 3 + 1] -= dy; cur[b * 3 + 2] -= dz;
    } else if (pa && !pb) { cur[b * 3] -= dx * 2; cur[b * 3 + 1] -= dy * 2; cur[b * 3 + 2] -= dz * 2; }
    else if (!pa && pb) { cur[a * 3] += dx * 2; cur[a * 3 + 1] += dy * 2; cur[a * 3 + 2] += dz * 2; }
  }

  function step(t) {
    for (let iy = 0; iy <= GY; iy++) {
      for (let ix = 0; ix <= GX; ix++) {
        const i = idx(ix, iy);
        if (pinned[i]) continue;
        const f = wind(ix, iy, t);
        for (let k = 0; k < 3; k++) {
          const j = i * 3 + k;
          const a = k === 0 ? f[0] : k === 1 ? f[1] + GRAV : f[2];
          const v = (cur[j] - prev[j]) * DAMP;
          prev[j] = cur[j];
          cur[j] = cur[j] + v + a * DT * DT;
        }
      }
    }
    for (let it = 0; it < 3; it++) {
      for (let iy = 0; iy <= GY; iy++) {
        for (let ix = 0; ix < GX; ix++) if (linked(ix, iy)) solve(idx(ix, iy), idx(ix + 1, iy), restH);
      }
      for (let iy = 0; iy < GY; iy++) {
        for (let ix = 0; ix <= GX; ix++) solve(idx(ix, iy), idx(ix, iy + 1), restV);
      }
      for (let iy = 0; iy < GY; iy++) {
        for (let ix = 0; ix < GX; ix++) {
          if (!linked(ix, iy) || !linked(ix, iy + 1)) continue;
          solve(idx(ix, iy), idx(ix + 1, iy + 1), restD);
          solve(idx(ix + 1, iy), idx(ix, iy + 1), restD);
        }
      }
    }
    for (let ix = 0; ix <= GX; ix++) {
      const i = ix;
      for (let k = 0; k < 3; k++) { cur[i * 3 + k] = rest[i * 3 + k]; prev[i * 3 + k] = rest[i * 3 + k]; }
    }
  }

  function commit() {
    for (let i = 0; i < N; i++) pos.setXYZ(i, cur[i * 3], cur[i * 3 + 1], cur[i * 3 + 2]);
    pos.needsUpdate = true;
    geo.computeVertexNormals();
  }

  /* --------------------------------------------------------------- fit -- */
  let camera;
  function fit() {
    const w = window.innerWidth, h = window.innerHeight;
    renderer.setSize(w, h, false);
    const aspect = w / h;
    camera = new THREE.PerspectiveCamera(38, aspect, 0.1, 100);
    const vFit = (BH * 1.12 / 2) / Math.tan(38 * Math.PI / 360);
    const hFit = ((BW + 1.2) / 2) / Math.tan(38 * Math.PI / 360) / aspect;
    camera.position.set(0, 0.06, Math.max(vFit, hFit) * 1.02 + 0.3);
    camera.lookAt(0, -0.02, 0);
  }
  window.addEventListener('resize', fit);
  fit();

  function draw(t) {
    camera.position.x = Math.sin(t * 0.1) * 0.20;
    camera.position.y = 0.06 + Math.sin(t * 0.14 + 1.1) * 0.07;
    camera.lookAt(0, -0.02, 0);
    renderer.render(scene, camera);
  }

  let running = false, raf = 0, t = 0;
  function loop() {
    if (!running) return;
    t += DT;
    step(t); commit(); draw(t);
    raf = requestAnimationFrame(loop);
  }
  function start() { if (running) return; running = true; raf = requestAnimationFrame(loop); }
  function stop() { running = false; cancelAnimationFrame(raf); }

  window.__seek = (time) => {
    const target = Math.max(0, time);
    if (target < t) { t = 0; for (let i = 0; i < N * 3; i++) { cur[i] = rest[i]; prev[i] = rest[i]; } }
    while (t < target - DT * 0.5) { t += DT; step(t); }
    commit(); draw(t);
  };

  for (let s = 0; s < 180; s++) step(s * DT);
  t = 180 * DT;
  if (reduce) {
    commit(); draw(t);
  } else {
    start();
    document.addEventListener('visibilitychange', () => (document.hidden ? stop() : start()));
  }
})();
</script>
</body>
</html>
[/hidecontent]
← 返回首页