# Tegn på canvas

Hvornår du skal tegne et billede på et canvas frem for i DOM’en, Canvas2D-API’et og tre gennemarbejdede eksempler på partikellignende grafik.

Canonical: https://zxn.studio/da/motion/canvas

En vært rasteriserer filmens DOM én gang pr. billede, så en film koster det, dens DOM koster at lægge ud og tegne. Det er ingenting for en titel og ødelæggende for tusind partikler. `<Canvas2D>` er udvejen: ét element, der tegner sig selv ud fra billednummeret i én gennemløbning, til samme pris, uanset om det tegner fyrre ting eller fire hundrede.

## Hvad hører hjemme hvor

Brug DOM og SVG til de snesevis af ting, et menneske ville pege på og navngive: titler, søjler, etiketter, former, kort og tekstbjælker. De klarer layoutet for dig, ændrer stil efter props, forbliver skarpe i enhver størrelse og kan have et `data-zxn-element-id`, så værtens egenskabspanel kan vælge og animere dem. Se [Ydeevne](/da/motion/performance) om formålet med id'er.

Brug et canvas til alt, der minder om partikler, hvor den enkelte ting ikke har nogen identitet: regn, sne, stjerner, tv-støj, støj, et felt af linjer, en plottet kurve eller en sværm. Ingen vil nogensinde vælge prik 914. At tegne dem som elementer betyder, at browseren udfører layout og optegning for hver eneste af dem på hvert billede uden nogen gevinst.

De to kan blandes frit. `examples/recipes/retro-tv.tsx` er et kabinet i SVG med et canvas bag glasset, og det er netop den rigtige arbejdsdeling.

## API'et

```tsx
import { Canvas2D } from "@zxn/motion-core";

<Canvas2D draw={paint} width={960} height={540} className="screen" style={{ opacity: 0.8 }} />;
```

| Prop | Betydning |
| --- | --- |
| `draw` | En `CanvasDraw`. Påkrævet. |
| `width`, `height` | Tegneenheder vandret og lodret. Bruger som standard filmens egen størrelse. |
| `className` | Sendes til `<canvas>`. |
| `style` | Flettes oven på elementets egne stile. |

Elementet er absolut positioneret ved `inset: 0` og strækkes til `width: 100%`, `height: 100%` af sit overordnede element, så tegneenhederne og den viste størrelse er uafhængige. Derfor kan du tegne en lille flade og forstørre den.

`draw` har denne form:

```ts
type CanvasDraw = (context: CanvasRenderingContext2D, frame: CanvasFrame) => void;
```

`CanvasFrame` indeholder alt, en tegning skal vide:

| Felt | Betydning |
| --- | --- |
| `frame` | Billednummeret, lokalt for den omsluttende `<Cue>`. |
| `absoluteFrame` | Filmens eget billede. |
| `seconds` | `frame / frameRate`, til alt, der tænker i sekunder. |
| `film` | `FilmMetadata`: `width`, `height`, `frameRate`, `frames` og resten. |
| `width`, `height` | Tegnefladens størrelse i de enheder, `draw` tegner med. |

Bemærk, at `width` og `height` er *fladens* størrelse, ikke filmens. En tegnefunktion, der bruger dem, fungerer uændret, uanset om den tegner hele billedet eller en skærm på 232 pixels inde i et fjernsyn.

### To regler

**Fladen ryddes, og konteksten nulstilles til identitetstransformationen før hvert kald.** Du arver aldrig en transformation, en klipning eller pixels fra sidste billede, og en tegning, der efterlader en transformation, kan ikke forvrænge det næste billede.

**`draw` skal tegne hele billedet ud fra billednummeret alene.** Billeder efterspørges i vilkårlig rækkefølge — afspilningsmarkøren hopper, en afspiller gentager, og en renderer kan søge. Intet må videreføres fra sidste kald, fordi der måske slet ikke var noget sidste kald. Det er samme disciplin som i resten af biblioteket, og det gør et canvas-billede reproducerbart frem for blot plausibelt. Se [Tænk i billeder](/da/motion/thinking-in-frames).

Tegningen sker i en layout-effekt, efter React har færdiggjort opdateringen, og før værten tager billedet, så pixels tilhører det efterspurgte billede.

### Test af en tegnefunktion

`paintCanvasFrame(context, draw, frame)` er det samme kald, `<Canvas2D>` foretager, eksporteret så en tegnefunktion kan afprøves mod enhver 2D-kontekst, i en test eller uden for React. Det rydder og nulstiller konteksten, kalder `draw` og gendanner den — selv hvis tegningen kaster en fejl, som derefter sendes videre.

```ts
import { paintCanvasFrame } from "@zxn/motion-core";

paintCanvasFrame(context, myDraw, { frame: 15, absoluteFrame: 15, seconds: 0.5, film, width: 320, height: 180 });
```

## Matrix-regn, linje for linje

@video(matrix-rain)

`examples/recipes/matrix-rain.tsx` er skoleeksemplet. Den oplagte løsning — en `<div>` pr. tegn med en lysende `text-shadow` — giver et par tusind slørede elementer og kryber af sted i enhver størrelse. Her er feltet ét element.

Tegnefunktionen indfanger props i en closure, og derfor kan `<Canvas2D>` nøjes med én prop:

```tsx
const rain = ({ color, background, cell, speed }: Props): CanvasDraw => (context, { seconds, width, height }) => {
  context.fillStyle = background;
  context.fillRect(0, 0, width, height);
  context.font = `600 ${Math.round(cell * 0.92)}px ui-monospace, "SF Mono", Menlo, monospace`;
  context.textAlign = "center";
  context.textBaseline = "top";
```

Baggrunden tegnes i stedet for at blive ryddet til ingenting, og skrifttype og tekstjustering sættes én gang uden for begge løkker.

Hver strøm udledes derefter af sit kolonnenummer. Der findes ingen tilstand pr. strøm nogen steder:

```tsx
    // Everything about a stream comes from its column number, so any frame can be drawn on its own.
    const pace = speed * (0.45 + 0.55 * seededRandom(`pace-${column}`));
    const length = 8 + Math.floor(seededRandom(`length-${column}`) * 22);
    // A short gap before a stream comes round again keeps the field full without ever looking like a wall.
    const cycle = rows + length + Math.floor(seededRandom(`gap-${column}`) * rows * 0.4);
    const head = Math.floor(seconds * pace + seededRandom(`start-${column}`) * cycle) % cycle;
```

`seededRandom` returnerer det samme tal for samme startværdi for altid, så `pace-7` er én fast hastighed for kolonne syv i forhåndsvisningen, når du trækker afspilningsmarkøren, og i eksporten. Hovedets position er modulo af forløbne sekunder, ikke en akkumulator: Billede 200 kan tegnes uden først at have tegnet billeder 0 til 199.

`Math.floor` omkring hovedet får det til at ligne *netop den* regn. Strømmen flytter sig en hel celle ad gangen og glider aldrig mellem celler. Glat bevægelse på under én pixel får en naiv version til at virke træg; trinvise bevægelser ser hurtige ud, selv når de ikke er det.

Inde i en strøm vælger hvert tegn sig selv ud fra en flimretæller, der tikker et par gange i sekundet i sin egen rytme, så feltet flimrer i stedet for at ændre sig i kor:

```tsx
      // A glyph changes a few times a second, each on its own beat.
      const flicker = Math.floor(seconds * (3 + 5 * seededRandom(`flicker-${column}-${row}`)));
      const glyph = glyphs[Math.floor(seededRandom(`glyph-${column}-${row}-${flicker}`) * glyphs.length)]!;
      const fade = 1 - step / length;
      // The trail stays readable most of its length and only dies away at the very end.
      context.globalAlpha = step === 0 ? 1 : 0.12 + 0.88 * Math.sqrt(fade);
      context.fillStyle = step === 0 ? "#eaffef" : color;
```

Sporet toner ud med en kvadratrod i stedet for lineært, hvilket holder det meste af længden læselig og først lader det forsvinde helt til sidst. En lineær udtoning får en strøm til at se halvt så lang ud, som den er.

Derefter den ene bevidste udgift:

```tsx
      // One soft glow on the head only: blur is the expensive part of any paint.
      context.shadowColor = color;
      context.shadowBlur = step === 0 ? cell * 0.9 : step < 3 ? cell * 0.25 : 0;
```

Tres lysende hoveder er til at betale. To tusind lysende tegn er ikke, og det ville ikke se bedre ud: Gløden siger »denne er hovedet«, og glød på alt siger ingenting.

## Støj bag glas

@video(retro-tv)

`examples/recipes/retro-tv.tsx` bruger begge teknikker i én film. Kabinettet, antennerne, drejeknapperne og afspilningsknappen består af et par snese SVG-former med tykke, blæklignende konturer — skarpe i enhver størrelse og stylet ud fra fire farveprops. Skærmens støj er et canvas, fordi »et par tusind tilfældige prikker, der skifter på hvert billede« er præcis det, DOM er dårlig til.

```tsx
/** Coarse, chunky static: big specks read as "old TV", fine noise reads as "broken GPU". */
const staticNoise = (strength: number): CanvasDraw => (context, { frame, width, height }) => {
  const speck = 8;
  for (let y = 0; y < height; y += speck) {
    for (let x = 0; x < width; x += speck) {
      const value = seededRandom(`${frame}-${x}-${y}`);
      context.fillStyle = `rgba(255,255,255,${(value * 0.85 * strength).toFixed(3)})`;
      context.fillRect(x, y, speck, speck);
    }
  }
```

Startværdien er billednummeret og cellens position, så støjen varierer i både rum og tid, men er identisk, hver gang billede 41 tegnes.

Selve fladen er lille og skaleres op:

```tsx
          <Canvas2D width={232} height={225} draw={staticNoise(staticStrength)} style={{ imageRendering: "pixelated" }} />
```

232×225 tegneenheder strakt over en åbning på 464×450 med `imageRendering: "pixelated"`, så browseren ikke udglatter forstørrelsen. En fjerdedel af antallet af pixels, og det grove udtryk er stilen frem for et kompromis.

## Støj med fast startværdi

@video(noise-field)

`examples/recipes/noise-field.tsx` styrer et gitter af korte linjer med simplex-støj. Det afgørende er at sætte startværdien rigtigt:

```tsx
let draws = 0;
const noise = createNoise3D(() => seededRandom(`noise-field-${draws++}`));
```

`createNoise3D()` uden argument bruger `Math.random` og bygger et nyt felt hver gang, hvilket betyder en ny film ved hver rendering. En deterministisk kilde fastholder feltet, og ved at oprette det én gang uden for komponenten holdes det fast på tværs af genrenderinger.

Tid er støjens tredje akse, og derfor driver feltet i stedet for at hoppe — nærliggende billeder er nærliggende punkter i samme glatte rumfang:

```tsx
      const angle = noise(x / 520, y / 520, seconds * drift) * Math.PI * 2;
      const reach = 16 + 26 * (noise(x / 300, y / 300, seconds * drift + 40) * 0.5 + 0.5);
```

## Et canvas fra bunden

Et komplet stjernefelt, så du kan se opbygningen:

```tsx
import { Canvas2D, FullFrame, defineFilm, seededRandom, type CanvasDraw } from "@zxn/motion-core";

type Props = { readonly background: string; readonly count: number; readonly drift: number };

const stars = ({ background, count, drift }: Props): CanvasDraw => (context, { seconds, width, height }) => {
  context.fillStyle = background;
  context.fillRect(0, 0, width, height);
  context.fillStyle = "#fdfdff";
  for (let index = 0; index < count; index += 1) {
    // Depth decides brightness, size and speed together, so the field reads as layered.
    const depth = 0.25 + seededRandom(`depth-${index}`) * 0.75;
    const x = (seededRandom(`x-${index}`) * width + seconds * drift * depth) % width;
    const y = seededRandom(`y-${index}`) * height;
    context.globalAlpha = 0.25 + 0.75 * depth;
    context.fillRect(x, y, depth * 3, depth * 3);
  }
};

function Starfield(props: Props) {
  return <FullFrame><Canvas2D draw={stars(props)} /></FullFrame>;
}

export const films = [defineFilm({
  id: "starfield",
  title: "Starfield",
  width: 1920,
  height: 1080,
  frameRate: 30,
  frames: 180,
  component: Starfield,
  defaultProps: { background: "#05060a", count: 700, drift: 60 },
  controls: {
    background: { type: "color", label: "Background" },
    count: { type: "number", label: "Stars", min: 50, max: 2000, step: 50 },
    drift: { type: "number", label: "Drift", min: 0, max: 400, step: 10 },
  },
})];
```

@video(starfield)

Syv hundrede stjerner, ét element.

## Praktiske tips

- **Sæt `font`, `fillStyle`, `strokeStyle` og `lineWidth` uden for de inderste løkker.** Tildeling er ikke gratis, og at tildele samme værdi syv hundrede gange er syv hundrede gange for meget. Regnen sætter skrifttypen én gang og skifter kun `fillStyle`, når farven faktisk ændres.
- **`shadowBlur` er dyrt.** Hver sløret tegneoperation koster mange gange mere end en usløret. Giv en håndfuld tegneoperationer glød, ligesom regnen kun giver hovedet glød, og sæt `shadowBlur = 0` for resten — værdien bliver stående, indtil du ændrer den.
- **Tegn med en lavere intern opløsning, når udtrykket tillader det.** Fjernsynets støj er 232×225 til en åbning på 464×450. Sæt `width` og `height` efter tegningens behov, ikke efter filmen.
- **Gradienter oprettes pr. billede.** `createLinearGradient` returnerer et objekt, der er knyttet til konteksten, så det oprettes inde i `draw` og kan ikke flyttes til modulniveau. Opret det én gang pr. kald uden for løkkerne, som støjfeltet gør.
- **Vælg et canvas ved det første »ét element pr.…«.** Hvis du er ved at skrive en løkke, der returnerer JSX for hver partikel, har du brug for denne side.

Næste: [Ydeevne](/da/motion/performance) om budgettet, hele filmen skal holde sig indenfor, og [Opskrifter](/da/motion/recipes) for resten af eksemplerne.

