shadimage
Remocn
componentascii-rendersource: ok · hit

ASCII Render

Redraw a live scene as ASCII: the frame divides into glyph cells and each one becomes the character matching the brightness of the picture inside it.

npx shadcn add "https://www.remocn.dev/r/ascii-render.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
remotion

Registry dependencies

@remocn/canvas-presentation

Files1 · source cached 10/14/2026, 6:50:54 PM

registry/remocn/ascii-render/index.tsx
"use client";

import type React from "react";
import { AbsoluteFill, useVideoConfig } from "remotion";
import {
  type CanvasFilterProps,
  makeCanvasFilter,
  makeFilterShader,
} from "@/lib/remocn/canvas-presentation";

export type AsciiRenderProps = {
  glyphSize?: number;
  charset?: string;
  colored?: boolean;
  ink?: string;
  intensity?: number;
};

const DEFAULTS = {
  glyphSize: 26,
  charset: " .:-=+*#%@",
  colored: false,
  ink: "#9dff9d",
  intensity: 1,
};

const GLYPH_ASPECT = 0.545;
const ATLAS_CELL_W = 24;
const ATLAS_CELL_H = 44;
const ATLAS_FONT =
  'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace';

type NormalizedProps = {
  cellX: number;
  cellY: number;
  charset: string;
  colored: boolean;
  ink: string;
  intensity: number;
};

const FRAGMENT_SHADER = `#version 300 es
precision highp float;

uniform sampler2D u_scene;
uniform sampler2D u_atlas;
uniform vec2 u_cell;
uniform float u_glyph_count;
uniform float u_colored;
uniform vec3 u_ink;
uniform float u_intensity;

in vec2 v_uv;
out vec4 outColor;

const int TAPS = 3;

vec4 cellAverage(vec2 center, vec2 cell) {
  vec4 sum = vec4(0.0);
  for (int y = 0; y < TAPS; y++) {
    for (int x = 0; x < TAPS; x++) {
      vec2 offset = (vec2(float(x), float(y)) + 0.5) / float(TAPS) - 0.5;
      sum += texture(u_scene, center + offset * cell);
    }
  }
  return sum / float(TAPS * TAPS);
}

void main() {
  vec2 cell = max(u_cell, vec2(0.001));
  vec2 origin = floor(v_uv / cell) * cell;
  vec4 sampled = cellAverage(origin + cell * 0.5, cell);

  float luma = dot(sampled.rgb, vec3(0.299, 0.587, 0.114));
  float index = min(floor(luma * u_glyph_count), u_glyph_count - 1.0);

  vec2 local = clamp((v_uv - origin) / cell, 0.008, 0.992);
  float mask =
    texture(u_atlas, vec2((index + local.x) / u_glyph_count, local.y)).r;

  vec3 glyph = mix(u_ink, sampled.rgb, u_colored) * mask;
  vec4 source = texture(u_scene, v_uv);
  float amount = clamp(u_intensity, 0.0, 1.0);

  outColor = vec4(
    mix(source.rgb, glyph, amount),
    mix(source.a, max(mask, sampled.a), amount)
  );
}`;

type Atlas = { charset: string; texture: WebGLTexture };

const atlases = new WeakMap<WebGL2RenderingContext, Atlas>();

function drawAtlas(charset: string): OffscreenCanvas {
  const canvas = new OffscreenCanvas(
    ATLAS_CELL_W * charset.length,
    ATLAS_CELL_H,
  );
  const context = canvas.getContext("2d");
  if (!context) {
    throw new Error("ascii-render: failed to acquire a 2D context");
  }
  context.fillStyle = "#000000";
  context.fillRect(0, 0, canvas.width, canvas.height);
  context.fillStyle = "#ffffff";
  context.font = `${Math.round(ATLAS_CELL_H * 0.74)}px ${ATLAS_FONT}`;
  context.textAlign = "center";
  context.textBaseline = "middle";
  for (let i = 0; i < charset.length; i++) {
    context.fillText(
      charset[i],
      i * ATLAS_CELL_W + ATLAS_CELL_W / 2,
      ATLAS_CELL_H / 2,
    );
  }
  return canvas;
}

function bindAtlas(gl: WebGL2RenderingContext, charset: string): void {
  gl.activeTexture(gl.TEXTURE1);

  let atlas = atlases.get(gl);
  if (!atlas) {
    const texture = gl.createTexture();
    if (!texture) {
      throw new Error("ascii-render: failed to create the glyph atlas");
    }
    gl.bindTexture(gl.TEXTURE_2D, texture);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
    atlas = { charset: "", texture };
    atlases.set(gl, atlas);
  } else {
    gl.bindTexture(gl.TEXTURE_2D, atlas.texture);
  }

  if (atlas.charset !== charset) {
    gl.texImage2D(
      gl.TEXTURE_2D,
      0,
      gl.RGBA,
      gl.RGBA,
      gl.UNSIGNED_BYTE,
      drawAtlas(charset),
    );
    atlas.charset = charset;
  }
}

function toRgb(hex: string): [number, number, number] {
  const value = hex.replace("#", "");
  const full =
    value.length === 3
      ? value
          .split("")
          .map((c) => c + c)
          .join("")
      : value;
  const int = Number.parseInt(full, 16);
  if (Number.isNaN(int)) return [1, 1, 1];
  return [
    ((int >> 16) & 255) / 255,
    ((int >> 8) & 255) / 255,
    (int & 255) / 255,
  ];
}

const shader = makeFilterShader<NormalizedProps>(
  FRAGMENT_SHADER,
  ({ gl, uniform, passedProps }) => {
    const { cellX, cellY, charset, colored, ink, intensity } = passedProps;
    bindAtlas(gl, charset);
    gl.uniform1i(uniform("u_atlas"), 1);
    gl.uniform1f(uniform("u_glyph_count"), charset.length);
    gl.uniform2f(uniform("u_cell"), cellX, cellY);
    gl.uniform1f(uniform("u_colored"), colored ? 1 : 0);
    gl.uniform3fv(uniform("u_ink"), toRgb(ink));
    gl.uniform1f(uniform("u_intensity"), intensity);
  },
);

const AsciiRenderFallback: React.FC<NormalizedProps & CanvasFilterProps> = ({
  children,
}) => <AbsoluteFill>{children}</AbsoluteFill>;

const AsciiRenderCanvas = makeCanvasFilter<NormalizedProps>({
  shader,
  fallback: AsciiRenderFallback,
});

export const AsciiRender: React.FC<AsciiRenderProps & CanvasFilterProps> = ({
  glyphSize = DEFAULTS.glyphSize,
  charset = DEFAULTS.charset,
  colored = DEFAULTS.colored,
  ink = DEFAULTS.ink,
  intensity = DEFAULTS.intensity,
  children,
}) => {
  const { width, height } = useVideoConfig();
  const resolved = charset.length > 0 ? charset : DEFAULTS.charset;

  return (
    <AsciiRenderCanvas
      cellX={(glyphSize * GLYPH_ASPECT) / width}
      cellY={glyphSize / height}
      charset={resolved}
      colored={colored}
      ink={ink}
      intensity={intensity}
    >
      {children}
    </AsciiRenderCanvas>
  );
};

More from Remocn

Browse all components in this library →