Codrops 원본

Beyond the Luminance Ramp: A Shape-Aware ASCII Renderer in Three.js

텍스트 · MIT

텍스트 더 보기
ORIGINAL PREVIEW
Beyond the Luminance Ramp: A Shape-Aware ASCII Renderer in Three.js 정적 미리보기

갤러리 안에서는 화면을 벗어나거나 움직임을 멈추면 예제도 닫힙니다. 다시 재생할 때는 처음부터 시작해요.

큰 화면으로 보기 새 탭

SOURCE FILES

원본 코드 읽기

19개 파일

수집한 원본 소스와 실행 안내를 함께 제공합니다.

eslint.config.js
파일 저장

// ESLint standing in for Biome's linter, carrying across the rules from the house `biome.jsonc`
// that still mean something here.
//
// Most of that config is about a stack this demo does not have: the React domain, the a11y and
// security rules for JSX, the Tailwind class sorter, the `noRestrictedImports` entries pointing at
// `tailwind-merge`, `sanity:client` and `react`, and the per-folder overrides for `.astro` and
// `sanity/`. None of those have anything to lint against in five files of vanilla JavaScript, so
// they are left out rather than translated into rules that could never fire.
//
// The `../` import ban is left out for a different reason: there it works because `~/` exists as
// the way across folders. This demo has no alias and a two-level tree, so banning `../` would
// forbid something without offering the alternative that makes the rule fair.
//
// What is left is the part that is about JavaScript itself:
//
//   assist.actions.source.organizeImports -> simple-import-sort/imports, /exports
//   correctness.noUnusedImports           -> no-unused-vars
//   correctness.noUnusedVariables         -> no-unused-vars
//   correctness.noUndeclaredVariables     -> no-undef
//   style.useBlockStatements              -> curly
//   style.useSingleVarDeclarator          -> one-var
//   style.useNumberNamespace              -> no-restricted-globals
//   style.noParameterAssign               -> no-param-reassign
//   style.useDefaultParameterLast         -> default-param-last
//   style.noUselessElse                   -> no-else-return

import js from "@eslint/js";
import prettier from "eslint-config-prettier";
import simpleImportSort from "eslint-plugin-simple-import-sort";
import globals from "globals";

export default [
  { ignores: ["dist/**", "node_modules/**"] },

  js.configs.recommended,

  {
    files: ["**/*.js"],
    languageOptions: {
      ecmaVersion: 2024,
      sourceType: "module",
      globals: globals.browser,
    },
    plugins: { "simple-import-sort": simpleImportSort },
    rules: {
      "simple-import-sort/imports": "error",
      "simple-import-sort/exports": "error",

      // Remove all unused variables and imports.
      "no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }],
      "no-undef": "error",

      // Enforce braces for `if`/`else` and other control statements.
      curly: ["error", "all"],
      // Prevent comma-separated variable declarations for clarity.
      "one-var": ["error", "never"],
      // Reassigning parameters is usually not a good idea.
      "no-param-reassign": "error",
      // Default parameters come last for better function signatures.
      "default-param-last": "error",
      // Early returns reduce nesting.
      "no-else-return": ["error", { allowElseIf: false }],

      // Enforce Number namespace methods for clarity, e.g. `Number.parseInt()` over `parseInt()`.
      "no-restricted-globals": [
        "error",
        { name: "parseInt", message: "Use Number.parseInt() instead." },
        { name: "parseFloat", message: "Use Number.parseFloat() instead." },
        { name: "isNaN", message: "Use Number.isNaN() instead." },
        { name: "isFinite", message: "Use Number.isFinite() instead." },
        { name: "NaN", message: "Use Number.NaN instead." },
        { name: "Infinity", message: "Use Number.POSITIVE_INFINITY instead." },
      ],
    },
  },

  // Node-side scripts, which exist only in local checkouts.
  {
    files: ["**/scripts/**/*.mjs"],
    languageOptions: {
      ecmaVersion: 2024,
      sourceType: "module",
      globals: globals.node,
    },
  },

  // Last, so nothing here fights the formatter.
  prettier,
];
index.html
파일 저장

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>ASCII Logo | Codrops</title>
    <meta
      name="description"
      content="The Codrops mark as a draggable 3D solid, printed in ASCII characters on the GPU with three.js."
    />
    <meta name="keywords" content="ascii, webgl, three.js, shaders, custom element, glyph atlas, codrops" />
    <meta name="author" content="Codrops" />
    <link rel="icon" type="image/svg+xml" href="https://tympanus.net/favicon/favicon.svg" />
    <link rel="shortcut icon" href="https://tympanus.net/favicon/favicon.ico" />

    <!-- Regular only, which is the weight the atlas is baked at and the only one the page uses.
         The print is rasterized from this face, so if it fails to arrive the mark is drawn in
         whatever `ui-monospace` resolves to instead. -->
    <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=IBM+Plex+Mono:wght@400&display=swap" rel="stylesheet" />

    <link rel="stylesheet" href="/src/styles.css" />
  </head>
  <body>
    <!-- The demo frame: the links Codrops demos carry, plus the page's one control. It sits in the
         margins the mark never reaches and prints in the same ink, so both themes style it for
         free. -->
    <header class="frame">
      <h1 class="frame__title">ASCII Logo</h1>
      <a class="frame__back" href="https://tympanus.net/codrops/?p=TODO">Article</a>
      <a class="frame__archive" href="https://tympanus.net/codrops/hub/">All demos</a>
      <a class="frame__github" href="https://github.com/edoardolunardi/ascii-logo">GitHub</a>
      <div class="frame__credits">
        <span>By </span>
        <a href="https://www.edoardolunardi.dev/" target="_blank">Edoardo Lunardi</a>
      </div>
      <nav class="frame__tags">
        <a href="https://tympanus.net/codrops/demos/?tag=ascii">#ascii</a>
        <a href="https://tympanus.net/codrops/demos/?tag=shaders">#shaders</a>
        <a href="https://tympanus.net/codrops/demos/?tag=draggable">#draggable</a>
        <a href="https://tympanus.net/codrops/demos/?tag=three-js">#three.js</a>
        <a href="https://tympanus.net/codrops/demos/?tag=webgl">#webgl</a>
      </nav>
      <!-- Both themes rendered, the active one filled: the control reads as a state, not an action. -->
      <div class="theme-switch" role="group" aria-label="Theme">
        <button type="button" data-theme-choice="dark" aria-pressed="true">dark</button>
        <button type="button" data-theme-choice="light" aria-pressed="false">light</button>
      </div>
    </header>

    <main class="stage">
      <!-- The mark is the whole page now, so it is labelled rather than hidden: `aria-hidden` here
           would leave a screen reader with nothing at all to read. -->
      <ascii-logo dark role="img" aria-label="The Codrops mark, drawn as a rotating solid in ASCII characters">
        <!-- Served hidden and faded in once the first frame is on it. Nothing stands in for it
             while it waits, and nothing stands in for it if the print never arrives. -->
        <canvas data-logo-canvas class="is-hidden"></canvas>
      </ascii-logo>
    </main>

    <!-- Vite resolves the bare `three` imports behind this entry and serves the page, so there is
         nothing to configure here and nothing to open off disk. Run `npm run dev`. -->
    <script type="module" src="/src/main.js"></script>
  </body>
</html>
함께 쓰는 파일 17개 보기
prettier.config.js
파일 저장

// Prettier standing in for Biome's formatter. Every option below is the same setting the house
// `biome.jsonc` carries, under whatever name Prettier gives it, so a file formatted here matches a
// file formatted there.
//
//   formatter.lineWidth 130       -> printWidth
//   formatter.indentWidth 2       -> tabWidth
//   formatter.indentStyle space   -> useTabs false
//   javascript.formatter.semicolons always        -> semi
//   javascript.formatter.quoteStyle double        -> singleQuote false
//   javascript.formatter.trailingCommas es5       -> trailingComma
//   javascript.formatter.arrowParentheses always  -> arrowParens
//
// `bracketSpacing` and `endOfLine` are the default on both sides and are spelled out here only so
// the mapping above can be read as complete.

/** @type {import("prettier").Config} */
export default {
  printWidth: 130,
  tabWidth: 2,
  useTabs: false,
  semi: true,
  singleQuote: false,
  trailingComma: "es5",
  arrowParens: "always",
  bracketSpacing: true,
  endOfLine: "lf",
};
src/ascii-logo/element.js
파일 저장

// `<ascii-logo>`: measuring, dragging, the frame loop, and revealing the print.
//
// There is no stand-in. The canvas is served hidden and faded in once it has a frame on it; if it
// never gets one the element stays empty and keeps its box.

import { Color, LinearSRGBColorSpace } from "three";

import { loadGlyphAtlas } from "./glyph-atlas.js";
import { AsciiLogoRenderer } from "./renderer.js";

/** Frame-rate independent exponential lerp. */
const damp = (current, target, tau, dt) => current + (target - current) * (1 - Math.exp(-dt / tau));

/** Slack, so the frame threshold never lands on the display's beat (60 would judder to 30). */
const FRAME_SLACK_MS = 8;

const prefersReducedMotion = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches;

const CONFIG = {
  cellW: 6,
  lineRatio: 1.6,
  maxDpr: 2,
  maxCells: 4600,
  frameHz: 60,
  floatSpeed: 2,
  floatIntensity: 1.4,
  rotationIntensity: 1,
  /** Radians of orbit per CSS pixel dragged, and the clock the camera follows the drag on. */
  dragScale: 0.007,
  orbitTau: 70,
  /** Short of the pole, where the camera's up vector would flip. */
  maxElevation: 0.85,
};

/** A little under the mark's centre, so the disc reads as solid rather than flat on. */
const REST_ELEVATION = -0.22;

const RESIZE_SETTLE_MS = 250;

const CELL_ASPECT = 1 / CONFIG.lineRatio;

/** Read back through a 1x1 canvas rather than parsed: the cascade can hand down `color-mix()` or
 * any other computed form, and only the browser reliably knows what it resolved to. */
function inkOf(element) {
  const probe = document.createElement("canvas");

  probe.width = 1;
  probe.height = 1;

  const ctx = probe.getContext("2d", { willReadFrequently: true });

  if (!ctx) {
    return new Color(1, 1, 1);
  }

  ctx.fillStyle = getComputedStyle(element).color;
  ctx.fillRect(0, 0, 1, 1);

  const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;

  // Tagged as the working space, so colour management passes the bytes through untouched.
  return new Color().setRGB(r / 255, g / 255, b / 255, LinearSRGBColorSpace);
}

export class AsciiLogoElement extends HTMLElement {
  /** Present: light on a dark ground. Absent: ink on paper. Drives ink and tone inversion. */
  static observedAttributes = ["dark"];

  #canvas = null;
  #renderer = null;
  #resizeObserver = null;
  #visibilityObserver = null;

  #width = 0;
  #height = 0;
  #dpr = 1;
  #cellW = 0;
  #cellH = 0;

  #raf = 0;
  #last = 0;
  #settle = 0;
  #elapsed = 0;
  #visible = false;
  #reduced = false;

  #azimuth = 0;
  #elevation = REST_ELEVATION;
  #aimAzimuth = 0;
  #aimElevation = REST_ELEVATION;
  #pointer = -1;
  #pointerX = 0;
  #pointerY = 0;

  connectedCallback() {
    this.#canvas = this.querySelector("[data-logo-canvas]");

    if (!this.#canvas) {
      return;
    }

    this.#reduced = prefersReducedMotion();
    this.#elapsed = Math.PI;

    this.#measure();

    this.#resizeObserver = new ResizeObserver(this.#onResize);
    this.#resizeObserver.observe(this);

    this.#visibilityObserver = new IntersectionObserver(this.#onVisibility);
    this.#visibilityObserver.observe(this);

    this.#canvas.addEventListener("webglcontextlost", this.#onContextLost);

    void this.#build();
  }

  disconnectedCallback() {
    this.#stop();
    clearTimeout(this.#settle);
    this.#resizeObserver?.disconnect();
    this.#visibilityObserver?.disconnect();
    this.#resizeObserver = null;
    this.#visibilityObserver = null;

    this.#canvas?.removeEventListener("webglcontextlost", this.#onContextLost);
    this.removeEventListener("pointerdown", this.#onPointerDown);
    this.#releasePointer();
    this.#renderer?.destroy();
    this.#renderer = null;
  }

  async #build() {
    const canvas = this.#canvas;

    if (!canvas) {
      return;
    }

    // Face and weight come off the canvas, so the stylesheet stays the one place they are chosen.
    const style = getComputedStyle(canvas);
    const atlas = await loadGlyphAtlas(style.fontFamily, style.fontWeight, CELL_ASPECT);

    if (!this.isConnected) {
      return;
    }

    const renderer = AsciiLogoRenderer.create(canvas, atlas);

    if (!renderer) {
      return;
    }

    // A rejected shader surfaces on the first frame, so the whole run-up is guarded and nothing is
    // revealed until it comes back clean.
    try {
      renderer.setInk(inkOf(canvas));
      renderer.setPaper(!this.hasAttribute("dark"));
      renderer.resize(this.#width, this.#height, this.#dpr, this.#cellW, this.#cellH);
      renderer.render(this.#pose());
    } catch (error) {
      console.warn(error);
      renderer.destroy();
      return;
    }

    this.#renderer = renderer;

    canvas.classList.remove("is-hidden");

    // Wired only once there is something to turn, so a page without WebGL never grabs a gesture.
    this.addEventListener("pointerdown", this.#onPointerDown);

    this.#applyMotion();
  }

  attributeChangedCallback() {
    // Fires before `connectedCallback` when the attribute is present at parse, hence the guard.
    if (!this.#renderer) {
      return;
    }

    this.#renderer.setInk(inkOf(this.#canvas));
    this.#renderer.setPaper(!this.hasAttribute("dark"));
    // Drawn here rather than left to the loop: under reduced motion there is no loop.
    this.#draw();
  }

  #onContextLost = (event) => {
    event.preventDefault();
    this.#stop();
    this.#canvas?.classList.add("is-hidden");
  };

  #onVisibility = (entries) => {
    this.#visible = entries.some((entry) => entry.isIntersecting);
    this.#applyMotion();
  };

  #onResize = () => {
    clearTimeout(this.#settle);
    this.#settle = window.setTimeout(this.#remeasure, RESIZE_SETTLE_MS);
  };

  #remeasure = () => {
    const rect = this.getBoundingClientRect();

    if (Math.abs(rect.width - this.#width) < 1 && Math.abs(rect.height - this.#height) < 1) {
      return;
    }

    this.#measure();
    this.#draw();
  };

  #onPointerDown = (event) => {
    if (this.#pointer !== -1 || !event.isPrimary) {
      return;
    }

    this.#pointer = event.pointerId;
    this.#pointerX = event.clientX;
    this.#pointerY = event.clientY;
    this.setPointerCapture(event.pointerId);
    this.classList.add("is-grabbing");
    this.addEventListener("pointermove", this.#onPointerMove);
    this.addEventListener("pointerup", this.#onPointerUp);
    this.addEventListener("pointercancel", this.#onPointerUp);
    this.#applyMotion();
  };

  #onPointerMove = (event) => {
    if (event.pointerId !== this.#pointer) {
      return;
    }

    // The camera moves against the drag, so the mark turns with it.
    this.#aimAzimuth -= (event.clientX - this.#pointerX) * CONFIG.dragScale;
    this.#aimElevation = Math.max(
      -CONFIG.maxElevation,
      Math.min(CONFIG.maxElevation, this.#aimElevation + (event.clientY - this.#pointerY) * CONFIG.dragScale)
    );
    this.#pointerX = event.clientX;
    this.#pointerY = event.clientY;
  };

  #onPointerUp = (event) => {
    if (event.pointerId === this.#pointer) {
      this.#releasePointer();
    }
  };

  #releasePointer() {
    if (this.#pointer !== -1 && this.hasPointerCapture(this.#pointer)) {
      this.releasePointerCapture(this.#pointer);
    }

    this.#pointer = -1;
    this.classList.remove("is-grabbing");
    this.removeEventListener("pointermove", this.#onPointerMove);
    this.removeEventListener("pointerup", this.#onPointerUp);
    this.removeEventListener("pointercancel", this.#onPointerUp);
  }

  #applyMotion = () => {
    if (!this.#renderer || !this.#visible) {
      this.#stop();
      return;
    }

    // Reduced motion holds the frame, but a drag is the visitor's own doing, so it still runs.
    if (this.#reduced && this.#pointer === -1) {
      this.#stop();
      return;
    }

    this.#play();
  };

  #play() {
    if (this.#raf !== 0) {
      return;
    }

    this.#last = 0;
    this.#raf = requestAnimationFrame(this.#onFrame);
  }

  #stop() {
    cancelAnimationFrame(this.#raf);
    this.#raf = 0;
  }

  #onFrame = (now) => {
    this.#raf = requestAnimationFrame(this.#onFrame);

    if (this.#last === 0) {
      this.#last = now;
    }

    const dt = now - this.#last;

    if (dt < 1000 / CONFIG.frameHz - FRAME_SLACK_MS) {
      return;
    }

    this.#last = now;

    // Capped so a backgrounded tab does not jump the float forward on return.
    const step = Math.min(48, dt);

    if (!this.#reduced) {
      this.#elapsed += (step / 1000) * CONFIG.floatSpeed;
    }

    this.#azimuth = damp(this.#azimuth, this.#aimAzimuth, CONFIG.orbitTau, step);
    this.#elevation = damp(this.#elevation, this.#aimElevation, CONFIG.orbitTau, step);

    this.#draw();

    // Nothing left to ease and no float to run: hold the frame rather than repaint forever.
    if (this.#reduced && this.#pointer === -1 && Math.abs(this.#azimuth - this.#aimAzimuth) < 0.001) {
      this.#stop();
    }
  };

  #measure() {
    const rect = this.getBoundingClientRect();

    this.#width = Math.max(rect.width, 1);
    this.#height = Math.max(rect.height, 1);
    this.#dpr = Math.min(CONFIG.maxDpr, window.devicePixelRatio || 1);

    // Always off the base cell, never off the last result, or resizes would compound.
    const raw = (this.#width / CONFIG.cellW) * (this.#height / (CONFIG.cellW * CONFIG.lineRatio));
    const growth = raw > CONFIG.maxCells ? Math.sqrt(raw / CONFIG.maxCells) : 1;

    this.#cellW = Math.round(CONFIG.cellW * growth);
    this.#cellH = Math.round(this.#cellW * CONFIG.lineRatio);

    this.#renderer?.resize(this.#width, this.#height, this.#dpr, this.#cellW, this.#cellH);
  }

  #pose() {
    const wave = this.#elapsed / 4;

    return {
      azimuth: this.#azimuth,
      elevation: this.#elevation,
      rotateX: (Math.cos(wave) / 8) * CONFIG.rotationIntensity,
      rotateY: (Math.sin(wave) / 8) * CONFIG.rotationIntensity,
      rotateZ: (Math.sin(wave) / 20) * CONFIG.rotationIntensity,
      bob: (Math.sin(this.#elapsed / 1.5) / 10) * CONFIG.floatIntensity,
    };
  }

  #draw() {
    this.#renderer?.render(this.#pose());
  }
}
src/ascii-logo/glyph-atlas.js
파일 저장

// The glyph sheet the mark is printed with, and the shape vector that picks between glyphs.

import { CanvasTexture, DataTexture, FloatType, LinearFilter, LinearMipmapLinearFilter, RedFormat, Vector2 } from "three";

import { INNER_SAMPLES } from "./samples.js";

/** Space through tilde. The winner ships in an 8-bit channel, so 255 is the ceiling. */
const GLYPHS = Array.from({ length: 95 }, (_, i) => String.fromCharCode(32 + i));

const CELL_HEIGHT = 64;

/** Bleed around each cell, so a glyph overshooting its box is not clipped into a false edge. */
const PAD = 8;

const atlasCache = new Map();

function shapeVectors(image, cols, cellW, cellH) {
  const count = GLYPHS.length;
  const vectors = new Float32Array(count * INNER_SAMPLES.length);
  const radius = cellH * 0.26;
  const padW = cellW + PAD * 2;
  const padH = cellH + PAD * 2;

  for (let glyph = 0; glyph < count; glyph++) {
    const originX = (glyph % cols) * padW + PAD;
    const originY = Math.floor(glyph / cols) * padH + PAD;

    for (let sample = 0; sample < INNER_SAMPLES.length; sample++) {
      const cx = INNER_SAMPLES[sample][0] * cellW;
      const cy = INNER_SAMPLES[sample][1] * cellH;

      let sum = 0;
      let total = 0;

      for (let y = Math.floor(cy - radius); y <= Math.ceil(cy + radius); y++) {
        for (let x = Math.floor(cx - radius); x <= Math.ceil(cx + radius); x++) {
          const dx = x + 0.5 - cx;
          const dy = y + 0.5 - cy;

          if (dx * dx + dy * dy > radius * radius) {
            continue;
          }

          total++;

          if (x < -PAD || y < -PAD || x >= cellW + PAD || y >= cellH + PAD) {
            continue;
          }

          sum += image.data[((originY + y) * image.width + originX + x) * 4 + 3];
        }
      }

      vectors[glyph * INNER_SAMPLES.length + sample] = total > 0 ? sum / (total * 255) : 0;
    }
  }

  // Normalized per sample point, not globally. The cell's own six values get the same treatment,
  // so a flat tone still spreads across the vocabulary instead of collapsing onto one glyph.
  for (let sample = 0; sample < INNER_SAMPLES.length; sample++) {
    let peak = 0;

    for (let glyph = 0; glyph < count; glyph++) {
      peak = Math.max(peak, vectors[glyph * INNER_SAMPLES.length + sample]);
    }

    if (peak > 0) {
      for (let glyph = 0; glyph < count; glyph++) {
        vectors[glyph * INNER_SAMPLES.length + sample] /= peak;
      }
    }
  }

  return vectors;
}

function rasterize(font, weight, aspect) {
  const cellH = CELL_HEIGHT;
  const cellW = Math.max(Math.round(cellH * aspect), 8);
  const padW = cellW + PAD * 2;
  const padH = cellH + PAD * 2;
  const cols = Math.ceil(Math.sqrt(GLYPHS.length));
  const rows = Math.ceil(GLYPHS.length / cols);
  const source = document.createElement("canvas");

  source.width = cols * padW;
  source.height = rows * padH;

  const ctx = source.getContext("2d", { willReadFrequently: true });

  if (!ctx) {
    throw new Error("2d context unavailable");
  }

  ctx.fillStyle = "#ffffff";
  ctx.textAlign = "center";
  ctx.textBaseline = "middle";
  ctx.font = `${weight} ${Math.floor(Math.min(cellH * 0.92, cellW / 0.58))}px ${font}`;

  for (let glyph = 0; glyph < GLYPHS.length; glyph++) {
    ctx.fillText(GLYPHS[glyph], (glyph % cols) * padW + padW / 2, Math.floor(glyph / cols) * padH + padH / 2);
  }

  const image = ctx.getImageData(0, 0, source.width, source.height);

  // Drawn top down, sampled bottom up, which is the flip `CanvasTexture` does by default.
  const sheet = new CanvasTexture(source);

  sheet.minFilter = LinearMipmapLinearFilter;
  sheet.magFilter = LinearFilter;

  // One row per glyph, six texels wide, read with `texelFetch`: exact texels, nothing filtered.
  const shapes = new DataTexture(
    shapeVectors(image, cols, cellW, cellH),
    INNER_SAMPLES.length,
    GLYPHS.length,
    RedFormat,
    FloatType
  );

  shapes.needsUpdate = true;

  return {
    sheet,
    shapes,
    count: GLYPHS.length,
    grid: new Vector2(cols, rows),
    pad: new Vector2(PAD / padW, PAD / padH),
    inner: new Vector2(cellW / padW, cellH / padH),
  };
}

/** Baked once per page. The load is forced rather than awaited via `fonts.ready`: nothing else on
 * the page uses the face, so the browser never fetches it on its own and `ready` would resolve
 * against the fallback. A failed fetch still bakes, just in whatever the stack falls back to.
 *
 * The textures returned live for the page's lifetime, so no renderer disposes them. */
export function loadGlyphAtlas(font, weight, aspect) {
  const key = `${font}|${weight}|${aspect}`;
  const pending = atlasCache.get(key);

  if (pending) {
    return pending;
  }

  const built = (document.fonts?.load(`${weight} ${CELL_HEIGHT}px ${font}`) ?? Promise.resolve())
    .catch(() => {})
    .then(() => rasterize(font, weight, aspect));

  atlasCache.set(key, built);

  return built;
}
src/ascii-logo/mark.js
파일 저장

// The Codrops mark as a lens: a disc with the droplet cut through it, both faces domed.

import { BufferAttribute, BufferGeometry } from "three";

/** The mark's bounding box, in modules. The disc fills it edge to edge. */
export const LOGO_SIZE = 7;

const OUTER_RADIUS = LOGO_SIZE / 2;

/** The droplet, as fractions of the disc's radius: bulb, bulb centre, apex. */
const BULB_RADIUS = 0.21 * OUTER_RADIUS;
const BULB_Y = -0.17 * OUTER_RADIUS;
const APEX_Y = 0.38 * OUTER_RADIUS;

/** Thickness at the rim, and how much higher each face sits at the middle. The side view. */
const RIM_DEPTH = 0.9;
const DOME_SAG = 0.8;

/** SEGMENTS is a multiple of four, so one step lands on the apex and the corner stays sharp. */
const SEGMENTS = 192;
const RADIAL = 10;

// Where a tangent meets the bulb. Past that angle the outline is the bulb's own arc.
const TANGENT_COS = BULB_RADIUS / (APEX_Y - BULB_Y);
const TANGENT_X = BULB_RADIUS * Math.sqrt(1 - TANGENT_COS * TANGENT_COS);
const TANGENT_Y = BULB_Y + BULB_RADIUS * TANGENT_COS;
const TANGENT_FROM = Math.atan2(TANGENT_Y, TANGENT_X);
const TANGENT_SPAN = Math.PI - 2 * TANGENT_FROM;

// The right-hand tangent as `n . p = c`. The outline is mirror-symmetric, so the left side reuses
// it against `|x|`.
const TANGENT_NX = TANGENT_Y - APEX_Y;
const TANGENT_NY = -TANGENT_X;
const TANGENT_C = TANGENT_NY * APEX_Y;

const TAU = Math.PI * 2;

/** The droplet's outline at `theta`, as a distance from the centre. The droplet is convex and the
 * centre is inside it, so every ray leaves once and this is single valued. */
function dropletRadius(theta) {
  const dy = Math.sin(theta);
  const turn = theta - TANGENT_FROM - Math.floor((theta - TANGENT_FROM) / TAU) * TAU;

  if (turn <= TANGENT_SPAN) {
    return TANGENT_C / (TANGENT_NX * Math.abs(Math.cos(theta)) + TANGENT_NY * dy);
  }

  const along = dy * BULB_Y;

  return along + Math.sqrt(Math.max(along * along - BULB_Y * BULB_Y + BULB_RADIUS * BULB_RADIUS, 0));
}

/** `domeSag` is a debug-only override; the shipped path passes nothing. It cannot be 0, since
 * the dome radius divides by it. Use 0.001 for a near-flat face. */
export function buildLogoGeometry({ domeSag = DOME_SAG } = {}) {
  // The sphere each face is cut from, placed so it passes through the rim and the middle.
  const DOME_RADIUS = (OUTER_RADIUS * OUTER_RADIUS + domeSag * domeSag) / (2 * domeSag);
  const DOME_CENTRE = Math.sqrt(DOME_RADIUS * DOME_RADIUS - OUTER_RADIUS * OUTER_RADIUS) - RIM_DEPTH / 2;

  const domeZ = (r) => Math.sqrt(Math.max(DOME_RADIUS * DOME_RADIUS - r * r, 0)) - DOME_CENTRE;

  const quads = SEGMENTS * (2 + RADIAL * 2);
  const positions = new Float32Array(quads * 6 * 3);
  const normals = new Float32Array(quads * 6 * 3);

  let at = 0;

  const vertex = ([p, n]) => {
    positions.set(p, at);
    normals.set(n, at);
    at += 3;
  };

  /** Corners are `[position, normal]`, wound counter-clockwise seen from outside. */
  const quad = (a, b, c, d) => {
    vertex(a);
    vertex(b);
    vertex(c);
    vertex(a);
    vertex(c);
    vertex(d);
  };

  /** A point on a face, `t` of the way from the droplet out to the rim. The normal is the dome
   * sphere's own radius through it, so it is exact rather than differenced. */
  const onFace = (theta, innerRadius, t, side) => {
    const radius = innerRadius + (OUTER_RADIUS - innerRadius) * t;
    const z = domeZ(radius) * side;
    const x = Math.cos(theta) * radius;
    const y = Math.sin(theta) * radius;
    const nz = (Math.abs(z) + DOME_CENTRE) * side;
    const length = Math.hypot(x, y, nz) || 1;

    return [
      [x, y, z],
      [x / length, y / length, nz / length],
    ];
  };

  const angles = new Float32Array(SEGMENTS);
  const radii = new Float32Array(SEGMENTS);

  for (let i = 0; i < SEGMENTS; i++) {
    angles[i] = (i / SEGMENTS) * TAU;
    radii[i] = dropletRadius(angles[i]);
  }

  for (let i = 0; i < SEGMENTS; i++) {
    const j = (i + 1) % SEGMENTS;

    for (let k = 0; k < RADIAL; k++) {
      const inner = k / RADIAL;
      const outer = (k + 1) / RADIAL;

      for (const side of [1, -1]) {
        const a = onFace(angles[i], radii[i], outer, side);
        const b = onFace(angles[j], radii[j], outer, side);
        const c = onFace(angles[j], radii[j], inner, side);
        const d = onFace(angles[i], radii[i], inner, side);

        if (side === 1) {
          quad(a, b, c, d);
        } else {
          quad(a, d, c, b);
        }
      }
    }

    const mid = ((i + 0.5) / SEGMENTS) * TAU;
    const rim = [Math.cos(mid), Math.sin(mid), 0];
    const ox = Math.cos(angles[i]) * OUTER_RADIUS;
    const oy = Math.sin(angles[i]) * OUTER_RADIUS;
    const px = Math.cos(angles[j]) * OUTER_RADIUS;
    const py = Math.sin(angles[j]) * OUTER_RADIUS;
    const lip = RIM_DEPTH / 2;

    quad([[ox, oy, lip], rim], [[ox, oy, -lip], rim], [[px, py, -lip], rim], [[px, py, lip], rim]);

    // The droplet's wall faces into the void. Flat per step, so the apex stays a hard corner.
    const ix = Math.cos(angles[i]) * radii[i];
    const iy = Math.sin(angles[i]) * radii[i];
    const jx = Math.cos(angles[j]) * radii[j];
    const jy = Math.sin(angles[j]) * radii[j];
    const ez = domeZ(radii[i]);
    const fz = domeZ(radii[j]);
    const ex = jx - ix;
    const ey = jy - iy;
    const length = Math.hypot(ex, ey) || 1;
    const wall = [-ey / length, ex / length, 0];

    quad([[ix, iy, -ez], wall], [[ix, iy, ez], wall], [[jx, jy, fz], wall], [[jx, jy, -fz], wall]);
  }

  const geometry = new BufferGeometry();

  geometry.setAttribute("position", new BufferAttribute(positions, 3));
  geometry.setAttribute("normal", new BufferAttribute(normals, 3));

  return geometry;
}

// The outline math, exported for tooling and diagnostics. Nothing in the shipped path imports
// these.
export { APEX_Y, BULB_RADIUS, BULB_Y, dropletRadius, OUTER_RADIUS, TANGENT_FROM, TANGENT_SPAN, TANGENT_X, TANGENT_Y };
src/ascii-logo/renderer.js
파일 저장

// Three passes: the solid into an offscreen target, one fragment per character cell picking that
// cell's glyph, then the glyph sheet composited over the canvas in the page's own ink.

import {
  Camera,
  Color,
  GLSL3,
  LinearSRGBColorSpace,
  Mesh,
  NearestFilter,
  NoBlending,
  PerspectiveCamera,
  PlaneGeometry,
  Scene,
  ShaderMaterial,
  Spherical,
  Vector2,
  WebGLRenderer,
  WebGLRenderTarget,
} from "three";

import { buildLogoGeometry, LOGO_SIZE } from "./mark.js";
// `?raw` is Vite handing the file over as a string.
import CELL_FRAG from "./shaders/cell.frag.glsl?raw";
import POST_FRAG from "./shaders/post.frag.glsl?raw";
import QUAD_VERT from "./shaders/quad.vert.glsl?raw";
import SCENE_FRAG from "./shaders/scene.frag.glsl?raw";
import SCENE_VERT from "./shaders/scene.vert.glsl?raw";

const FOV = 38;
const NEAR = 0.5;
const FAR = 40;
const CAMERA_DISTANCE = 6.4;

/** Longest side of the mark in scene units, against a frame about 4.3 units tall at that distance. */
const OBJECT_SCALE = 3.05;

/** Scene-target pixels per character cell row: enough for the six samples, far below the canvas. */
const SCENE_CELL_PX = 12;

const SAMPLES = 4;

function fullFrameMaterial(fragmentShader, uniforms) {
  return new ShaderMaterial({
    glslVersion: GLSL3,
    vertexShader: QUAD_VERT,
    fragmentShader,
    uniforms,
    depthTest: false,
    depthWrite: false,
    blending: NoBlending,
  });
}

export class AsciiLogoRenderer {
  #renderer;

  #scene = new Scene();
  #camera = new PerspectiveCamera(FOV, 1, NEAR, FAR);
  #mesh;
  #orbit = new Spherical();

  #frame = new Scene();
  #frameCamera = new Camera();
  #quad;

  #cellMaterial;
  #postMaterial;

  #sceneTarget;
  #cellTarget;

  /** `debug` carries fragment shader overrides and a dome override for diagnostics. The shipped
   * path never passes it. */
  constructor(renderer, atlas, debug = {}) {
    this.#renderer = renderer;

    this.#mesh = new Mesh(
      buildLogoGeometry({ domeSag: debug.domeSag }),
      new ShaderMaterial({
        glslVersion: GLSL3,
        vertexShader: SCENE_VERT,
        fragmentShader: SCENE_FRAG,
        uniforms: { uPaper: { value: 1 } },
      })
    );

    this.#mesh.scale.setScalar(OBJECT_SCALE / LOGO_SIZE);
    // Yaw, then pitch, then roll: the order the idle rock reads most naturally in.
    this.#mesh.rotation.order = "YXZ";
    this.#scene.add(this.#mesh);

    this.#sceneTarget = new WebGLRenderTarget(1, 1, {
      samples: SAMPLES,
      depthBuffer: true,
      stencilBuffer: false,
    });

    // One texel per cell, carrying that cell's glyph index in alpha. Read back exactly as written.
    this.#cellTarget = new WebGLRenderTarget(1, 1, {
      minFilter: NearestFilter,
      magFilter: NearestFilter,
      depthBuffer: false,
      stencilBuffer: false,
      generateMipmaps: false,
    });

    this.#cellMaterial = fullFrameMaterial(debug.cellShader ?? CELL_FRAG, {
      tScene: { value: this.#sceneTarget.texture },
      tShapes: { value: atlas.shapes },
      uResolution: { value: new Vector2(1, 1) },
      uCellPx: { value: new Vector2(1, 1) },
      uGlyphCount: { value: atlas.count },
    });

    this.#postMaterial = fullFrameMaterial(debug.postShader ?? POST_FRAG, {
      tCells: { value: this.#cellTarget.texture },
      tAtlas: { value: atlas.sheet },
      uCellsPerUv: { value: new Vector2(1, 1) },
      uGrid: { value: new Vector2(1, 1) },
      uAtlasGrid: { value: atlas.grid },
      uAtlasPad: { value: atlas.pad },
      uAtlasInner: { value: atlas.inner },
      uColor: { value: new Color(1, 1, 1) },
    });

    this.#quad = new Mesh(new PlaneGeometry(2, 2), this.#cellMaterial);
    // Already in clip space, so three.js must not measure it against the camera.
    this.#quad.frustumCulled = false;
    this.#frame.add(this.#quad);
  }

  static create(canvas, atlas, debug = {}) {
    let renderer;

    try {
      renderer = new WebGLRenderer({
        canvas,
        alpha: true,
        antialias: false,
        powerPreference: "low-power",
      });
    } catch {
      return null;
    }

    // The scene pass already encodes sRGB, so the print must not be converted a second time.
    renderer.outputColorSpace = LinearSRGBColorSpace;
    renderer.setClearColor(0x000000, 0);
    // Thrown rather than logged, so a rejected shader takes the same path as a missing context.
    renderer.debug.onShaderError = () => {
      throw new Error("ascii-logo: shader failed to compile");
    };

    return new AsciiLogoRenderer(renderer, atlas, debug);
  }

  /** Debug-only readback. The scene target is multisampled, and three.js resolves it when the
   * cell pass samples it, so read it back only after a full `render()`. */
  get sceneTarget() {
    return this.#sceneTarget;
  }

  get cellTarget() {
    return this.#cellTarget;
  }

  get renderer() {
    return this.#renderer;
  }

  setInk(color) {
    this.#postMaterial.uniforms.uColor.value.copy(color);
  }

  /** Ink on paper, or light on a dark ground. Decides whether the scene tone is inverted. */
  setPaper(light) {
    this.#mesh.material.uniforms.uPaper.value = light ? 1 : 0;
  }

  /** Cell sizes are CSS pixels. The scene target is sized off the cell grid rather than the canvas,
   * so its cost holds as the device ratio climbs. */
  resize(width, height, dpr, cellWidth, cellHeight) {
    const cols = Math.max(Math.ceil(width / cellWidth), 1);
    const rows = Math.max(Math.ceil(height / cellHeight), 1);
    const scale = SCENE_CELL_PX / cellHeight;
    const sceneWidth = Math.max(Math.round(width * scale), 1);
    const sceneHeight = Math.max(Math.round(height * scale), 1);

    this.#renderer.setPixelRatio(dpr);
    // `false`: the stylesheet owns the canvas box, three.js only sizes the buffer.
    this.#renderer.setSize(width, height, false);

    this.#sceneTarget.setSize(sceneWidth, sceneHeight);
    this.#cellTarget.setSize(cols, rows);

    this.#camera.aspect = sceneWidth / sceneHeight;
    this.#camera.updateProjectionMatrix();

    this.#cellMaterial.uniforms.uResolution.value.set(sceneWidth, sceneHeight);
    this.#cellMaterial.uniforms.uCellPx.value.set(cellWidth * scale, cellHeight * scale);
    this.#postMaterial.uniforms.uCellsPerUv.value.set(width / cellWidth, height / cellHeight);
    this.#postMaterial.uniforms.uGrid.value.set(cols, rows);
  }

  render(pose) {
    const renderer = this.#renderer;

    this.#orbit.set(CAMERA_DISTANCE, Math.PI / 2 - pose.elevation, pose.azimuth);
    this.#camera.position.setFromSpherical(this.#orbit);
    this.#camera.lookAt(0, 0, 0);

    this.#mesh.rotation.set(pose.rotateX, pose.rotateY, pose.rotateZ);
    this.#mesh.position.y = pose.bob;

    renderer.setRenderTarget(this.#sceneTarget);
    renderer.render(this.#scene, this.#camera);

    this.#quad.material = this.#cellMaterial;
    renderer.setRenderTarget(this.#cellTarget);
    renderer.render(this.#frame, this.#frameCamera);

    this.#quad.material = this.#postMaterial;
    renderer.setRenderTarget(null);
    renderer.render(this.#frame, this.#frameCamera);
  }

  /** Everything this renderer made, and nothing it borrowed: the atlas textures outlive it. */
  destroy() {
    this.#sceneTarget.dispose();
    this.#cellTarget.dispose();
    this.#mesh.geometry.dispose();
    this.#mesh.material.dispose();
    this.#quad.geometry.dispose();
    this.#cellMaterial.dispose();
    this.#postMaterial.dispose();
    this.#renderer.dispose();
    this.#renderer.forceContextLoss();
  }
}
src/ascii-logo/samples.js
파일 저장

// The sample points the atlas baker measures with, mirrored by the cell shader.

/** Six sample points per cell, as fractions, y down. Matching on layout rather than on average
 * brightness is what lands an edge on a slash instead of a block. The right column rides higher
 * than the left so a diagonal reads as a diagonal, not as two stacked dots.
 *
 * Hard-coded again as `INNER` in `shaders/cell.frag.glsl`. The two have to agree. */
export const INNER_SAMPLES = [
  [0.28, 0.26],
  [0.72, 0.14],
  [0.28, 0.56],
  [0.72, 0.44],
  [0.28, 0.86],
  [0.72, 0.74],
];
src/ascii-logo/shaders/cell.frag.glsl
파일 저장

// Cell pass: one fragment per character cell.
//
// Each cell reads its own six points plus the ten around it, so a glyph is picked by where the tone
// sits and which way an edge runs through the cell, not by an average. The winner leaves in alpha
// as `index / 255`.
//
// INNER must match INNER_SAMPLES in samples.js, or the search compares against vectors built
// some other way.

precision highp float;
out vec4 outColor;
uniform sampler2D tScene;
uniform sampler2D tShapes;
uniform vec2 uResolution;
uniform vec2 uCellPx;
uniform int uGlyphCount;

const float CONTRAST = 1.5;
const float EDGE_CONTRAST = 3.0;

const vec2 INNER[6] = vec2[6](
  vec2(0.28, 0.26), vec2(0.72, 0.14),
  vec2(0.28, 0.56), vec2(0.72, 0.44),
  vec2(0.28, 0.86), vec2(0.72, 0.74)
);
const vec2 OUTER[10] = vec2[10](
  vec2(0.28, -0.2), vec2(0.72, -0.2),
  vec2(-0.22, 0.25), vec2(1.22, 0.25),
  vec2(-0.22, 0.5), vec2(1.22, 0.5),
  vec2(-0.22, 0.75), vec2(1.22, 0.75),
  vec2(0.28, 1.2), vec2(0.72, 1.2)
);
const vec2 RING[6] = vec2[6](
  vec2(1.0, 0.0), vec2(0.5, 0.8660254), vec2(-0.5, 0.8660254),
  vec2(-1.0, 0.0), vec2(-0.5, -0.8660254), vec2(0.5, -0.8660254)
);

vec2 cellBase;

vec4 fetchTap(vec2 p) {
  vec2 uv = p / uResolution;

  if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) {
    return vec4(0.0);
  }

  return texture(tScene, uv);
}

vec4 sampleCircle(vec2 c) {
  vec2 middle = cellBase + vec2(c.x, 1.0 - c.y) * uCellPx;
  float r = uCellPx.y * 0.161;
  vec4 acc = fetchTap(middle);

  for (int k = 0; k < 6; k++) {
    acc += fetchTap(middle + RING[k] * r);
  }

  return acc / 7.0;
}

float circleLum(vec4 acc) {
  vec3 straight = acc.rgb / max(acc.a, 1e-4);

  return clamp(dot(straight, vec3(0.2126, 0.7152, 0.0722)), 0.0, 1.0) * acc.a;
}

float dirContrast(float value, float ext) {
  float peak = max(value, ext);

  if (peak < 1e-4) {
    return value;
  }

  return pow(value / peak, EDGE_CONTRAST) * peak;
}

void main() {
  cellBase = floor(gl_FragCoord.xy) * uCellPx;

  float v[6];
  vec3 colAcc = vec3(0.0);
  float alphaAcc = 0.0;

  for (int i = 0; i < 6; i++) {
    vec4 acc = sampleCircle(INNER[i]);

    v[i] = circleLum(acc);
    colAcc += acc.rgb;
    alphaAcc += acc.a;
  }

  float e[10];

  for (int i = 0; i < 10; i++) {
    e[i] = circleLum(sampleCircle(OUTER[i]));
  }

  v[0] = dirContrast(v[0], max(max(e[0], e[1]), max(e[2], e[4])));
  v[1] = dirContrast(v[1], max(max(e[0], e[1]), max(e[3], e[5])));
  v[2] = dirContrast(v[2], max(e[2], max(e[4], e[6])));
  v[3] = dirContrast(v[3], max(e[3], max(e[5], e[7])));
  v[4] = dirContrast(v[4], max(max(e[4], e[6]), max(e[8], e[9])));
  v[5] = dirContrast(v[5], max(max(e[5], e[7]), max(e[8], e[9])));

  float peak = max(max(max(v[0], v[1]), max(v[2], v[3])), max(v[4], v[5]));

  if (peak > 1e-4) {
    for (int i = 0; i < 6; i++) {
      v[i] = pow(v[i] / peak, CONTRAST) * peak;
    }
  }

  int best = 0;
  float bestD = 1e9;

  for (int g = 0; g < uGlyphCount; g++) {
    float d = 0.0;

    for (int i = 0; i < 6; i++) {
      float diff = v[i] - texelFetch(tShapes, ivec2(i, g), 0).r;

      d += diff * diff;
    }

    if (d < bestD) {
      bestD = d;
      best = g;
    }
  }

  outColor = vec4(colAcc / max(alphaAcc, 1e-4), float(best) / 255.0);
}
src/ascii-logo/shaders/post.frag.glsl
파일 저장

// Post pass: the glyph sheet composited in the page's own ink.

precision highp float;
in vec2 vUv;
out vec4 outColor;
uniform sampler2D tCells;
uniform sampler2D tAtlas;
uniform vec2 uCellsPerUv;
uniform vec2 uGrid;
uniform vec2 uAtlasGrid;
uniform vec2 uAtlasPad;
uniform vec2 uAtlasInner;
uniform vec3 uColor;

void main() {
  vec2 cellPos = vUv * uCellsPerUv;
  vec2 cell = clamp(floor(cellPos), vec2(0.0), uGrid - 1.0);
  float glyph = floor(texelFetch(tCells, ivec2(cell), 0).a * 255.0 + 0.5);
  vec2 local = clamp(cellPos - cell, 0.0, 1.0);
  float gx = mod(glyph, uAtlasGrid.x);
  float gy = floor(glyph / uAtlasGrid.x);
  vec2 atlasUv = vec2(
    (gx + uAtlasPad.x + local.x * uAtlasInner.x) / uAtlasGrid.x,
    (uAtlasGrid.y - gy - 1.0 + uAtlasPad.y + local.y * uAtlasInner.y) / uAtlasGrid.y
  );
  vec2 atlasStep = uAtlasInner / uAtlasGrid;
  float mask = textureGrad(tAtlas, atlasUv, dFdx(cellPos) * atlasStep, dFdy(cellPos) * atlasStep).a;

  outColor = vec4(uColor * mask, mask);
}
src/ascii-logo/shaders/quad.vert.glsl
파일 저장

// Shared vertex stage for the two full-frame passes. The plane is already in clip space.

out vec2 vUv;

void main() {
  vUv = uv;
  gl_Position = vec4(position.xy, 0.0, 1.0);
}
src/ascii-logo/shaders/scene.frag.glsl
파일 저장

// Scene pass, fragment stage: gradient dome, key lobe, rim lobe, Fresnel, ACES, sRGB.
//
// The tone curve is baked in rather than left linear, so the glyph pass can average taps in an
// 8-bit target without crushing what the shadows hold.

precision highp float;
in vec3 vNormal;
in vec3 vWorld;
out vec4 outColor;
uniform float uPaper;

const vec3 KEY = normalize(vec3(-0.45, 0.85, 0.45));
const vec3 RIM = normalize(vec3(0.62, -0.12, -0.75));
const float ROUGHNESS = 0.32;

vec3 studio(vec3 d) {
  float up = d.y * 0.5 + 0.5;

  // Floor lifted off black: the shaded side is one wide field, and at near-black the cell pass
  // finds nothing to tell apart down there.
  return mix(vec3(0.05), vec3(0.4), up * up) + pow(max(dot(d, KEY), 0.0), 6.0) * 1.85 + pow(max(dot(d, RIM), 0.0), 40.0) * 4.5;
}

vec3 aces(vec3 x) {
  return clamp((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14), 0.0, 1.0);
}

vec3 encodeSrgb(vec3 c) {
  return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(vec3(0.0031308), c));
}

void main() {
  vec3 n = normalize(vNormal);
  vec3 v = normalize(cameraPosition - vWorld);
  vec3 lobe = normalize(mix(reflect(-v, n), n, ROUGHNESS));
  float fresnel = 0.05 + 0.95 * pow(1.0 - clamp(dot(n, v), 0.0, 1.0), 4.0);

  vec3 lit = encodeSrgb(aces(studio(n) * 0.6 + studio(lobe) * fresnel * 1.35));

  // uPaper: 1 on a light ground, 0 on a dark one. The cell pass spends dense glyphs on high tone,
  // and on paper density reads as darkness, so light grounds need the tone inverted or the mark
  // prints as its own negative. The 0.18 floor is the lit side's minimum ink: without it fully lit
  // tone inverts to zero and the silhouette dissolves exactly where the light lands.
  outColor = vec4(mix(lit, mix(vec3(0.18), vec3(1.0), 1.0 - lit), uPaper), 1.0);
}
src/ascii-logo/shaders/scene.vert.glsl
파일 저장

// Scene pass, vertex stage. Attributes and matrices come from three.js's own prefix.

out vec3 vNormal;
out vec3 vWorld;

void main() {
  vec4 world = modelMatrix * vec4(position, 1.0);

  vWorld = world.xyz;
  // Uniform scale, so renormalizing is enough and normalMatrix would be wasted work.
  vNormal = normalize(mat3(modelMatrix) * normal);
  gl_Position = projectionMatrix * viewMatrix * world;
}
src/main.js
파일 저장

// Entry point: register the element, wire the theme switch.

import { AsciiLogoElement } from "./ascii-logo/element.js";

if (!customElements.get("ascii-logo")) {
  customElements.define("ascii-logo", AsciiLogoElement);
}

const choices = document.querySelectorAll("[data-theme-choice]");

function applyTheme(dark) {
  // Page first, element second: the element re-reads its computed ink when its own attribute
  // flips, so the cascade has to already be on the new theme by then.
  if (dark) {
    document.documentElement.removeAttribute("data-theme");
  } else {
    document.documentElement.setAttribute("data-theme", "light");
  }

  for (const logo of document.querySelectorAll("ascii-logo")) {
    logo.toggleAttribute("dark", dark);
  }

  for (const choice of choices) {
    choice.setAttribute("aria-pressed", String((choice.dataset.themeChoice === "dark") === dark));
  }
}

for (const choice of choices) {
  choice.addEventListener("click", () => applyTheme(choice.dataset.themeChoice === "dark"));
}
src/styles.css
파일 저장

/* The object needs three things: a square box, a mono face on the canvas (the atlas bakes from
   it), and a `color` on the element (the print is drawn in whatever ink resolves there). */

/* Dark is the default ground. The blue is lifted a step from the paper value so thin glyphs hold
   up against a near-black ground. */
:root {
  /* The renderer reads whatever `color` resolves to on the element. */
  --mark: #4457ff;
  --ground: #0a0807;
  --font-mono: "IBM Plex Mono", ui-monospace, monospace;
  --page-padding: 1.5rem;
}

:root[data-theme="light"] {
  --mark: #2740de;
  --ground: #ffffff;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  min-height: 100svh;
  background: var(--ground);
  color: var(--mark);
  font-family: var(--font-mono);
  -webkit-font-smoothing: antialiased;
}

.stage {
  display: flex;
  align-items: center;
  justify-content: center;
  /* The frame is in flow above this until the wide breakpoint, where it goes fixed and the stage
     gets the viewport back to itself. */
  min-height: calc(100svh - 12rem);
  padding: 24px;
}

@media screen and (min-width: 53em) {
  .stage {
    min-height: 100svh;
  }
}

/* The demo frame. It prints in the mark's ink rather than blending against the ground, since the
   page already sets its two colours and the frame is just more text in that ink. Fixed and full
   height from the wide breakpoint up, so the links sit in corners the mark's box never reaches. */
.frame {
  position: relative;
  z-index: 1000;
  display: grid;
  padding: 1rem var(--page-padding) 0;
  grid-row-gap: 0.5rem;
  grid-column-gap: 1rem;
  grid-template-columns: min-content min-content min-content 1fr;
  /* The switch takes a row of its own here: columns 1 to 3 are sized by the link row, which
     leaves too little of the fourth for it to sit beside the title without overflowing. */
  grid-template-areas:
    "title title title title"
    "back archive github ..."
    "credits credits credits credits"
    "tags tags tags tags"
    "theme theme theme theme";
  justify-items: start;
  align-items: center;
  /* The header covers the stage on the wide breakpoint, so only its own links take the pointer. */
  pointer-events: none;
  color: var(--mark);
  font-family: var(--font-mono);
  font-size: 0.8125rem;
  letter-spacing: 0.04em;
  text-transform: uppercase;
}

.frame a,
.frame button {
  pointer-events: auto;
}

.frame a {
  color: var(--mark);
  text-decoration: none;
  white-space: nowrap;
}

.frame a:hover {
  text-decoration: underline;
}

.frame a:focus-visible {
  outline: 2px solid var(--mark);
  outline-offset: 2px;
}

.frame__title {
  grid-area: title;
  margin: 0;
  font-size: 0.8125rem;
  font-weight: 400;
  letter-spacing: 0.04em;
}

.frame__back {
  grid-area: back;
}

.frame__archive {
  grid-area: archive;
}

.frame__github {
  grid-area: github;
}

.frame__credits {
  grid-area: credits;
}

.frame__tags {
  grid-area: tags;
  display: flex;
  flex-wrap: wrap;
  gap: 1rem;
}

/* Four corners and a bottom rail: the mark keeps the middle. */
@media screen and (min-width: 53em) {
  .frame {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    padding: var(--page-padding);
    grid-column-gap: 2rem;
    grid-template-columns: auto auto auto auto 1fr auto;
    grid-template-areas:
      "title back archive github ... credits"
      "tags tags tags tags ... theme";
    align-content: space-between;
  }

  .frame__tags {
    gap: 2rem;
  }

  .frame__credits {
    justify-self: end;
  }
}

/* Borrows the mark's ink and the page's face, so both themes style it for free. */
.theme-switch {
  grid-area: theme;
  justify-self: end;
  display: flex;
  gap: 4px;
  padding: 4px;
  border: 1px solid color-mix(in srgb, var(--mark) 35%, transparent);
  border-radius: 999px;
}

.theme-switch button {
  padding: 6px 14px;
  border: 0;
  border-radius: 999px;
  background: none;
  color: var(--mark);
  font-family: var(--font-mono);
  font-size: 13px;
  letter-spacing: 0.04em;
  cursor: pointer;
}

.theme-switch button[aria-pressed="true"] {
  background: var(--mark);
  color: var(--ground);
  cursor: default;
}

.theme-switch button:focus-visible {
  outline: 2px solid var(--mark);
  outline-offset: 2px;
}

/* `aspect-ratio` reserves the box from first paint, so the canvas is never a layout shift. */
ascii-logo {
  position: relative;
  display: block;
  color: var(--mark);
  /* Off the short side, so the square fits whichever way the window is turned. */
  width: min(84vmin, 900px);
  aspect-ratio: 1;
  cursor: grab;
  touch-action: pan-y;
  user-select: none;
}

ascii-logo.is-grabbing {
  cursor: grabbing;
}

ascii-logo [data-logo-canvas] {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  /* Read back by the atlas builder: the sheet is rasterized from this exact face and weight. */
  font-family: var(--font-mono);
  font-weight: 400;
  opacity: 1;
  transition: opacity 500ms ease-out;
}

ascii-logo .is-hidden {
  opacity: 0;
}

@media (prefers-reduced-motion: reduce) {
  ascii-logo [data-logo-canvas] {
    transition: none;
  }
}
vite.config.js
파일 저장

import { defineConfig } from "vite";

export default defineConfig({
  // Relative asset URLs in the build, so `dist/` also works when it is served from a subdirectory
  // rather than from a domain root.
  base: "./",
  server: { open: true },
});
Media credits and license evidence실행 안내·자료
파일 저장

README.md
## Credits

- Type set in [IBM Plex Mono](https://fonts.google.com/specimen/IBM+Plex+Mono)


## License

[MIT](LICENSE)
LICENSE실행 안내·자료
파일 저장

MIT License

Copyright (c) 2009 - 2026 [Codrops](https://codrops.com)

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Bundled dependency licenses실행 안내·자료
파일 저장

three@0.185.1 — LICENSE
The MIT License

Copyright © 2010-2026 three.js authors

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.