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.
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 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
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:
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.
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.
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
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:
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:
// 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:
// 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:
// 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
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.
/** 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:
<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
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:
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:
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:
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 },
},
})];
Syv hundrede stjerner, ét element.
Praktiske tips
- Sæt
font,fillStyle,strokeStyleoglineWidthuden 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 kunfillStyle, når farven faktisk ændres. shadowBlurer 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ætshadowBlur = 0for 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
widthogheightefter tegningens behov, ikke efter filmen. - Gradienter oprettes pr. billede.
createLinearGradientreturnerer et objekt, der er knyttet til konteksten, så det oprettes inde idrawog 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 om budgettet, hele filmen skal holde sig indenfor, og Opskrifter for resten af eksemplerne.