# Painted scenes

Build a full-bleed background that reads as a picture: a place, a time of day and one light, painted in halftone dots.

> For the complete documentation index, see [llms.txt](/llms.txt). Markdown variants are available by appending `.md` to any URL or sending an `Accept: text/markdown` header. The asciicn agent skill, for building any ASCII effect on the engine, is at [/.well-known/agent-skills/asciicn/SKILL.md](/.well-known/agent-skills/asciicn/SKILL.md).

A painted scene is a background that reads as a picture. It paints every cell in colour, including the darkest sky, and the size of each dot follows the colour's value. The landscape and city [backgrounds](/backgrounds) are built this way. The scene below is a complete starter: a moonlit lake between snowy peaks, about 170 lines of GLSL.

```tsx title="examples/engine-painted-scene.tsx"
"use client";

import type { AsciiColor, AsciiSceneProps, ColorMode } from "@/lib/ascii";
import { AsciiShader } from "@/lib/ascii/shader";

// A moonlit lake between hills: the starter scene from the painted scenes
// guide. The scene is painted in colour first; the tone (dot size) is read
// back from that colour, so size and colour always agree.
const SCENE = `
// Set once per sample by lakeColor and read by every layer.
float lakeT;
float lakeHorizon;
float lakeHalfW;
float lakeScale;
float lakeSqueeze;
vec2 lakeMoon;

// A stable random value per cell. hash21 repeats every 50 whole cells.
float lakeHash(vec2 p) {
  return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453);
}

// Deep blue overhead, dusty violet at the horizon, a cool glow around the
// moon, thin cloud lit only near it, and sparse stars high up.
vec3 lakeSky(vec2 p, vec2 cellId) {
  float h = clamp((p.y - lakeHorizon) / (1.0 - lakeHorizon), 0.0, 1.0);
  vec3 col = mix(vec3(0.44, 0.38, 0.58), vec3(0.13, 0.14, 0.3), smoothstep(0.0, 0.35, h));
  col = mix(col, vec3(0.04, 0.05, 0.12), smoothstep(0.3, 1.0, h));
  // Undo the phone squeeze so the moon and its halo stay round.
  float r = length(vec2((p.x - lakeMoon.x) / lakeSqueeze, p.y - lakeMoon.y));
  col += vec3(0.2, 0.21, 0.3) * exp(-r * 3.0);
  col += vec3(0.5, 0.5, 0.56) * exp(-r * r * 260.0);
  float streak = fbm(vec2(p.x * 1.1 - lakeT * 0.006, p.y * 9.0));
  float cloud = smoothstep(0.55, 0.75, streak) * smoothstep(0.04, 0.16, h) * (1.0 - smoothstep(0.45, 0.65, h));
  vec3 cloudCol = mix(vec3(0.1, 0.1, 0.2), vec3(0.62, 0.6, 0.7), exp(-r * 5.0));
  col = mix(col, cloudCol, cloud * 0.7);
  float star = step(0.994, lakeHash(cellId)) * smoothstep(0.35, 0.7, h);
  col += vec3(0.6, 0.62, 0.7) * star * (0.6 + 0.4 * sin(lakeT * 0.7 + lakeHash(cellId + 3.0) * 40.0));
  float disc = 1.0 - smoothstep(0.034, 0.04, r);
  return mix(col, vec3(1.0, 0.96, 0.86), disc);
}

// The top of layer i (0 the far snow range, 1 wooded hills, 2 the near
// shore) above the horizon. Two noise scales, so no edge is straight and
// no two peaks match.
float lakeRidge(float x, float i) {
  float freq = 1.1 + i * 0.6;
  float n = fbm(vec2(x * freq + i * 7.3, i * 3.1));
  float ridged = 1.0 - abs(snoise(vec2(x * freq * 0.8, i + 9.0)));
  float height = mix(0.26, 0.07, i * 0.5) * mix(n, ridged, mix(0.6, 0.2, i * 0.5)) * lakeScale;
  // The near layer dips under the water in the middle and right, so it
  // frames the lake instead of crossing it.
  float near = step(1.5, i) * smoothstep(-0.1, 0.5, x / lakeHalfW);
  return lakeHorizon + mix(0.01, -0.03, i * 0.5) + height - near * 0.12;
}

vec3 lakeAbove(vec2 p, vec2 cellId) {
  vec3 col = lakeSky(p, cellId);
  vec3 haze = lakeSky(vec2(p.x, lakeHorizon + 0.03), cellId) * 0.8;
  // Far to near: each nearer layer paints over the ones behind it.
  for (int k = 0; k < 3; k++) {
    float i = float(k);
    float top = lakeRidge(p.x, i);
    if (p.y < top) {
      // Shade by the silhouette's normal against the moon. Below the crest
      // the slope is read over a wider window at a wandering x, so faces
      // spread into broad planes split by crooked ridges, not vertical
      // lines; ribs down the fall line break them up.
      float below = top - p.y;
      float xs = p.x + below * 0.5 * snoise(vec2(below * 5.0, p.x * 3.0 + i));
      float e = 0.01 + below * 0.8;
      float slope = (lakeRidge(xs + e, i) - lakeRidge(xs - e, i)) / (2.0 * e);
      vec2 normal = normalize(vec2(-slope, 1.0));
      vec2 toMoon = normalize(lakeMoon - vec2(p.x, top));
      float rib = snoise(vec2(p.x * 22.0, below * 3.0 + i));
      float lit = smoothstep(0.1, 0.8, dot(normal, toMoon) + 0.25 * rib);
      // Each nearer layer is darker: moonlit snow far, woods, then rock.
      vec3 shade = mix(vec3(0.12, 0.13, 0.27), vec3(0.03, 0.035, 0.08), i * 0.5);
      vec3 light = mix(vec3(0.78, 0.8, 0.92), vec3(0.2, 0.23, 0.3), i * 0.5);
      // Snow lies near the crests and gives way to dark rock below.
      float snow = 1.0 - smoothstep(0.03, 0.1, below + 0.03 * rib);
      light = mix(light * 0.35, light, mix(snow, 1.0, i * 0.5));
      vec3 face = mix(shade, light, lit);
      // A rim of moonlight along every crest on the lit side.
      face = mix(face, vec3(0.7, 0.72, 0.82), smoothstep(0.01, 0.0, below) * lit * 0.5);
      // Atmospheric perspective: far layers and every layer's foot sink
      // into the haze.
      float far = mix(0.3, 0.0, i * 0.5);
      far = mix(far, 1.0, 0.4 * (1.0 - smoothstep(0.0, 0.05, p.y - lakeHorizon)));
      col = mix(face, haze, far);
    }
  }
  return col;
}

vec3 lakeWater(vec2 p, vec2 cellId) {
  float depth = (lakeHorizon - p.y) / lakeHorizon;
  // Mirror about the horizon, wobbled by ripples that grow toward the viewer.
  float wob = (0.002 + 0.01 * depth) * sin(p.y * 220.0 / (0.25 + depth) + lakeT * 0.5 + 2.0 * snoise(vec2(p.x * 2.0, p.y * 10.0)));
  vec3 col = lakeAbove(vec2(p.x + wob, 2.0 * lakeHorizon - p.y), cellId);
  col = col * vec3(0.6, 0.66, 0.8) * mix(0.95, 0.55, depth) + vec3(0.02, 0.03, 0.07);
  // The moon's path: glints in a column that widens toward the viewer.
  float dx = abs(p.x - lakeMoon.x) / lakeSqueeze;
  float width = 0.012 + 0.07 * depth;
  float column = exp(-dx * dx / (width * width));
  float glint = smoothstep(0.35, 0.85, snoise(vec2(p.x * 26.0, p.y * 130.0 + lakeT * 0.25)));
  col += vec3(0.9, 0.86, 0.72) * column * (0.12 + glint) * mix(1.0, 0.55, depth);
  return col;
}

vec3 lakeColor(vec2 uv, vec2 res, float t) {
  float aspect = res.x / res.y;
  // Narrow boxes squeeze x, so a phone still sees the hills and the moon.
  lakeSqueeze = sqrt(clamp(1.6 / aspect, 1.0, 4.0));
  float portrait = 1.0 - smoothstep(0.7, 1.1, aspect);
  lakeT = t;
  lakeHalfW = 0.5 * aspect * lakeSqueeze;
  // On a phone the headline fills the middle: the horizon and the hills
  // drop below the buttons and the moon rises into the sky above the text.
  lakeHorizon = mix(0.4, 0.2, portrait);
  lakeScale = mix(1.0, 0.7, portrait);
  lakeMoon = vec2(0.55 * lakeHalfW, mix(lakeHorizon + 0.3, 0.8, portrait));
  vec2 p = vec2((uv.x - 0.5) * aspect * lakeSqueeze, uv.y);
  vec2 cellId = floor(uv * res);

  vec3 col = p.y >= lakeHorizon ? lakeAbove(p, cellId) : lakeWater(p, cellId);

  // A dark near shore in the bottom left: the foreground plane.
  float shore = 0.12 + 0.05 * fbm(vec2(p.x * 3.0, 5.0)) - 0.3 * smoothstep(-0.9, 0.2, p.x / lakeHalfW);
  if (p.y < shore) {
    float rocks = 0.5 + 0.5 * snoise(vec2(p.x * 14.0, p.y * 9.0));
    col = mix(vec3(0.03, 0.035, 0.07), vec3(0.12, 0.12, 0.2), rocks * 0.5);
    col = mix(col, vec3(0.4, 0.4, 0.5), smoothstep(0.012, 0.0, shore - p.y) * 0.6);
  }

  // Low mist drifting along the far shore.
  float band = exp(-pow((p.y - lakeHorizon - 0.01) / 0.025, 2.0));
  float mist = smoothstep(0.4, 0.7, fbm(vec2(p.x * 1.5 - t * 0.012, p.y * 12.0))) * band;
  return mix(col, vec3(0.42, 0.4, 0.54), mist * 0.6);
}

// field and colorField run in the same shader pass, so the second call for
// a sample reads the cached result instead of painting the scene again.
vec2 lakeCacheUv = vec2(-1.0);
float lakeCacheT = -1.0;
vec4 lakeCacheValue = vec4(0.0);

vec4 lakeScene(vec2 uv, vec2 res, float t) {
  if (uv == lakeCacheUv && t == lakeCacheT) {
    return lakeCacheValue;
  }
  vec3 col = clamp(lakeColor(uv, res, t), 0.0, 1.0);
  float peak = max(col.r, max(col.g, col.b));
  // Value counts saturated colour as mid-tone, so blue shadow still draws.
  float value = max(dot(col, vec3(0.3, 0.55, 0.15)), 0.7 * peak);
  float tone = 0.1 + 0.9 * pow(value, 1.2);
  // The focal light draws full dots, not a dithered mix of the two largest.
  tone = max(tone, smoothstep(0.82, 0.92, value));
  // Small dots read darker than the light they stand for: lift dark ink.
  vec3 ink = col / max(peak, 1e-3) * pow(peak, 0.6);
  // Quiet darks: the faintest specks, in a colour just above the paper.
  float quiet = 1.0 - smoothstep(0.08, 0.28, value);
  ink = mix(ink, vec3(0.11, 0.12, 0.2), quiet * 0.55);
  tone *= 1.0 - 0.35 * quiet;
  // Below full intensity the middle band, where a headline sits, quiets most.
  float k = lakeIntensity;
  vec2 c = vec2((uv.x - 0.5) * res.x / res.y / 0.55, (uv.y - 0.52) / 0.15);
  float middle = exp(-dot(c, c)) * (1.0 - k);
  tone *= mix(0.72, 1.0, k) * (1.0 - 0.45 * middle);
  ink *= mix(0.5, 1.0, k) * (1.0 - 0.6 * middle);
  lakeCacheUv = uv;
  lakeCacheT = t;
  lakeCacheValue = vec4(ink, tone);
  return lakeCacheValue;
}
`;

const FIELD = `${SCENE}
float field(vec2 uv, vec2 res, float t) {
  return lakeScene(uv, res, t).a;
}
`;

const COLOR_FIELD = `${SCENE}
vec3 colorField(vec2 uv, vec2 res, float t) {
  return lakeScene(uv, res, t).rgb;
}
`;

// The scene paints its own night, so modes that use its colours keep this
// paper in light and dark themes alike.
const NIGHT = "#070a14";
const PAINTED: ReadonlySet<ColorMode> = new Set<ColorMode>([
  "truecolor",
  "ansi16",
  "duotone",
]);

export interface MoonLakeProps extends Omit<AsciiSceneProps, "interactive"> {
  /** How strongly it draws, from 0 (faint, for text on top) to 1. Default 1. */
  intensity?: number;
  /** Square cells, for an even lattice of dots. Default `true`. */
  square?: boolean;
  /** Paper behind the dots. Default: the scene's night in painted modes. */
  paper?: AsciiColor;
}

export const MoonLake = ({
  charset = "dots",
  colorMode = "truecolor",
  cell = 7,
  square = true,
  dither = "bayer4",
  stillTime = 6,
  intensity = 1,
  paper,
  ...props
}: MoonLakeProps) => (
  <AsciiShader
    field={FIELD}
    colorField={COLOR_FIELD}
    uniforms={{ lakeIntensity: Math.min(1, Math.max(0, intensity)) }}
    charset={charset}
    colorMode={colorMode}
    paper={paper ?? (PAINTED.has(colorMode) ? NIGHT : undefined)}
    cell={cell}
    square={square}
    dither={dither}
    stillTime={stillTime}
    fallback={<div className="absolute inset-0 bg-[#070a14]" />}
    {...props}
  />
);

export default function EnginePaintedScene() {
  return <MoonLake className="absolute inset-0" />;
}
```

## The look [#the-look]

| Prop        | Value                   | Why                                                                          |
| ----------- | ----------------------- | ---------------------------------------------------------------------------- |
| `charset`   | `"dots"`                | Five dot sizes. The two smallest are specks, for quiet sky and shadow        |
| `square`    | `true`                  | Square cells lay the dots on an even lattice instead of rows                 |
| `cell`      | `7`                     | About 200 columns across 1440px: big shapes, no aliasing                     |
| `colorMode` | `"truecolor"`           | Each dot takes its colour from the `colorField`                              |
| `dither`    | `"bayer4"`              | Tones between two dot sizes mix them, so gradients don't band                |
| `paper`     | the scene's darkest sky | The scene paints its own ground, so it looks the same in light and dark mode |

## Building one [#building-one]

1. **Write the brief in one sentence.** The place, the time of day, one light, and what sits where. Pick a short palette for it and write the colours as constants.
2. **Set up coordinates for every screen.** Squeeze x on narrow boxes so a phone still sees the scene, place features at a share of the half width, and use a `portrait` factor to reframe for phones. Measure round things (a sun, a moon, a halo) with the squeeze undone, or they turn oval on a phone.
3. **Paint far to near.** Sky, far range, middle layer, near layer, water, foreground, mist. Each nearer layer paints over the last.
4. **Keep a value ladder.** The light is brightest. The subject carries the next brightest values: lit snow, lit facades, lit ice. A scene of dark silhouettes against the sky draws only specks. Far layers are lighter, cooler and closer to the sky colour; near layers are darker. Most of the frame should sit between 0.2 and 0.6 in value, and the sky needs mid-tones even at night, because the water repeats it. Render once with `colorMode="mono"` to see the values alone.
5. **Shape forms with light.** Silhouettes come from noise at two scales, never straight lines. Shade each face by its normal against the light, so it has a lit side and a shadow side, and break the faces with ribs, ledges or a snow line. Size shapes in cells: at `cell` 7 one row is about 0.008 of the height of a 900px box, and a shape needs 8 or more cells across to read.
6. **Add light and air.** A disc, a tight halo and a wide glow for the light. Lamps and windows also light the surfaces near them. Water mirrors the scene about the horizon with ripples that grow toward the viewer, plus a path of glints under the light. A thin band of mist along the horizon.
7. **Turn colour into dots.** See below.
8. **Wrap it in a component** with an `intensity` prop, the look props passed through with the defaults above, its own `paper`, a gradient `fallback` and a `stillTime`.

## Colour into dots [#colour-into-dots]

`field` and `colorField` compile into one shader and run one after the other for each sample. Paint the scene once in a shared function, cache the result, and return the tone from `field` and the colour from `colorField`:

```glsl
vec4 lakeScene(vec2 uv, vec2 res, float t) {
  if (uv == lakeCacheUv && t == lakeCacheT) {
    return lakeCacheValue;
  }
  vec3 col = clamp(lakeColor(uv, res, t), 0.0, 1.0);
  float peak = max(col.r, max(col.g, col.b));
  float value = max(dot(col, vec3(0.3, 0.55, 0.15)), 0.7 * peak);
  float tone = 0.1 + 0.9 * pow(value, 1.2);
  tone = max(tone, smoothstep(0.82, 0.92, value));
  vec3 ink = col / max(peak, 1e-3) * pow(peak, 0.6);
  float quiet = 1.0 - smoothstep(0.08, 0.28, value);
  ink = mix(ink, vec3(0.11, 0.12, 0.2), quiet * 0.55);
  tone *= 1.0 - 0.35 * quiet;
  // ...intensity and the calm middle, then store the cache
}
```

* The tone comes from the colour's value, so dot size and colour agree.
* Dark areas are quiet: their colour moves toward a shade just above the paper and their dots shrink to specks.
* Dark ink colours are lifted, because small dots read darker than the colour they carry.
* The focal light is pinned to the largest dot, so it doesn't dither between the two largest.

On a light paper, take the tone from the colour's distance to the paper instead, so the sky near the paper colour is the faintest dots. The balloon valley background works this way.

## Behind a headline [#behind-a-headline]

A hero puts a headline in the middle of the scene and two buttons below it. Keep the focal light and bright edges out of the middle half of the width. An `intensity` below 1 dims the scene and quiets an ellipse in the middle most:

```tsx
<section className="relative isolate flex min-h-svh flex-col items-center justify-center gap-4 text-center text-white">
  <MoonLake intensity={0.75} className="absolute inset-0 -z-10" />
  <h1>Your headline</h1>
</section>
```

On a 390x844 phone the headline and buttons cover the middle of the screen. Reframe for portrait: move the horizon and the subject below the buttons, or the light into the empty sky above the headline.

## Motion [#motion]

Slow and sparse: drifting cloud and mist, ripples and glints on water, a slow star twinkle, a soft glow pulse. No rain, beams, lightning or pointer response. Never hash anything with raw time in it, or dots flicker. Pick `stillTime` so the frame shown under reduced motion looks finished.

## Checking a scene [#checking-a-scene]

* Screenshot at 1440x900 and 390x844, bare and behind a headline with two buttons. You should be able to name the place in a second and find one clear light.
* Zoom into a crop: round dots in several sizes on an even lattice, not stripes or blur.
* On the phone, the subject sits beside the text and nothing bright is behind the buttons.
* With reduced motion, the renderer reports `data-state="static"` and the still frame looks finished.
* The scene looks the same in light and dark mode, and the console shows no shader errors.

If a scene looks weak, it is usually too dark (the subject has no bright values), half empty (a black sky over black water), too small (features under 8 cells), too busy (dark areas show visible dots), flat (no lit and shadow faces, no haze with distance) or diagrammatic (straight edges, even repeats).

## Going further [#going-further]

The shipped backgrounds paint 2D layers. For a scene that needs real depth, raymarch it in the same `field`: model terrain and buildings as height fields and signed distance functions, cast rays from a camera, light faces from their normals, reflect water by tracing the mirrored ray, and add fog that thickens with distance. Keep it to 64 to 128 steps. The dots, the tone mapping and the checks stay the same.

Coding agents get this guide as the `scenes.md` reference of the asciicn skill.