Blog

Dots tunnel : le principe, expliqué en JavaScript

26/10/2026effets de démodots tunnelCPCZ80

Le dots tunnel est un classique des démos : des anneaux de points foncent vers nous, tournent et serpentent, comme si l’on volait dans un tube. Sur CPC, l’effet finit en tables d’octets et en une boucle d’additions, mais ces tables ne se comprennent qu’une fois l’effet construit.

Le chemin passe donc d’abord par JavaScript : un cercle de points, puis une idée par étape, jusqu’aux tables que le Z80 saura lire. Chaque étape a son playground. Le code se modifie sur place, et « Voir le résultat » lance l’aperçu. Le bouton de relance applique vos changements, l’autre bouton rend le code d’origine.

Un cercle

Commençons par un seul anneau, vu de face. On place l’œil à l’origine, et l’anneau devant lui, à la profondeur z. C’est un cercle de rayon R, et on y pose 16 points, régulièrement espacés.

Pour dessiner un point à l’écran, il faut le projeter : trouver où il tombe sur l’écran, vu depuis l’œil. Le schéma montre la scène de côté. L’œil est à gauche, l’axe horizontal est la profondeur, et l’écran est un plan vertical à la distance P de l’œil. Le rayon qui part de l’œil vers le haut de l’objet coupe l’écran à la hauteur y.

écran œil Y Y y y / 2 P z 2z

Les deux triangles qui partent de l’œil ont la même forme. Le petit s’arrête à l’écran, le grand à l’objet. Leurs côtés sont donc proportionnels : y = P × Y / z. Le même objet placé deux fois plus loin coupe l’écran deux fois plus bas : il paraît deux fois plus petit. Toute la perspective tient dans cette division par la profondeur.

P est la distance focale : plus elle est grande, plus l’image est grande. Pour l’anneau, la même règle vaut sur les deux axes. Le point k est à l’angle angle, à R * cos(angle) du centre de l’anneau à l’horizontale, et à R * sin(angle) à la verticale. On multiplie par P, on divise par z, et on ajoute le centre de l’écran, CX et CY : ce sont les deux lignes x = … et y = … du code. Les 16 points sont espacés d’un seizième de tour.

L’écran est celui d’un CPC en mode 1 : 320 × 200 pixels. Le canvas fait 640 × 400, et app.stage.scale.set(2) agrandit tout deux fois. Le code travaille donc en pixels CPC, et un pixel CPC devient un carré de 2 × 2 dans le navigateur. Voici le premier anneau, 16 points blancs à la profondeur 1 :

import { Application, Graphics } from "pixi.js";

const app = new Application();
await app.init({ width: 640, height: 400, background: "#000000" });
document.body.append(app.canvas);

// Un écran CPC en mode 1 : 320 x 200, agrandi deux fois.
app.stage.scale.set(2);

const CX = 160, CY = 100; // centre de l'écran
const P = 90;             // distance focale, en pixels
const R = 1;              // rayon de l'anneau
const z = 1;              // profondeur de l'anneau
const DOTS = 16;          // points sur l'anneau

const g = new Graphics();
app.stage.addChild(g);

for (let k = 0; k < DOTS; k++) {
  const angle = (2 * Math.PI * k) / DOTS;
  const x = CX + (P * R * Math.cos(angle)) / z;
  const y = CY + (P * R * Math.sin(angle)) / z;
  g.rect(x, y, 1, 1).fill(0xffffff);
}

Côté PixiJS, la bibliothèque de dessin des playgrounds, il n’y a presque rien à savoir. g est un objet Graphics, posé sur la scène : on dessine dedans. g.rect(x, y, 1, 1) trace un rectangle de 1 × 1 pixel CPC à la position (x, y), et .fill(0xffffff) remplit en blanc ce rectangle, qui fait notre point.

À essayer : passez z à 2, puis à 3. L’anneau prend la moitié de sa taille, puis le tiers. Doubler R ou P donne le même effet en sens inverse : seul le rapport P * R / z compte.

Plusieurs profondeurs

Un tunnel est une file d’anneaux identiques, placés les uns derrière les autres, et on en met 16. L’anneau i est à la profondeur Z0 + i * GAP : le premier à Z0 = 1, puis un tous les GAP = 0.6. Tous ont le même rayon R, mais la division par z rend les lointains plus petits. Vus de face, ils forment des cercles concentriques : c’est déjà un tube qui s’enfonce dans l’écran.

La couleur aide l’œil à lire la profondeur. Le mode 1 n’a que quatre encres, et le noir du fond en prend une. On coupe donc la longueur du tunnel en quatre quarts : le premier en blanc, le deuxième en bleu ciel, les deux derniers en bleu. Le fond du tunnel s’éteint, comme dans un brouillard. Le playground empile les 16 anneaux, chacun avec sa couleur :

import { Application, Graphics } from "pixi.js";

const app = new Application();
await app.init({ width: 640, height: 400, background: "#000000" });
document.body.append(app.canvas);
app.stage.scale.set(2);

const CX = 160, CY = 100;   // centre de l'écran
const P = 90;               // distance focale, en pixels
const R = 1;                // rayon des anneaux
const Z0 = 1;               // profondeur de l'anneau le plus proche
const GAP = 0.6;            // écart entre deux anneaux
const RINGS = 16;
const DOTS = 16;            // points par anneau
const LENGTH = RINGS * GAP; // longueur du tunnel

// Trois encres, du plus proche au plus lointain.
const COLORS = [0xffffff, 0x0080ff, 0x000080, 0x000080];

const g = new Graphics();
app.stage.addChild(g);

for (let i = 0; i < RINGS; i++) {
  const z = Z0 + i * GAP;
  const color = COLORS[Math.floor((z - Z0) / (LENGTH / 4))];
  for (let k = 0; k < DOTS; k++) {
    const angle = (2 * Math.PI * k) / DOTS;
    const x = CX + (P * R * Math.cos(angle)) / z;
    const y = CY + (P * R * Math.sin(angle)) / z;
    g.rect(x, y, 1, 1).fill(color);
  }
}

À essayer : passez GAP à 0,3. Les anneaux se serrent, et le tunnel paraît plus court. À 1,2, les anneaux du fond se collent au centre.

Tourner

Jusqu’ici, l’image est fixe. Pour l’animer, PixiJS appelle une fonction à chaque image de l’écran : on la donne à app.ticker.add. Elle reçoit ticker.deltaMS, le temps écoulé depuis l’image précédente, en millisecondes. On l’additionne dans time, en secondes. À chaque appel, on efface tout avec g.clear(), puis on redessine la scène à l’instant time.

Pour faire tourner le tunnel, on ajoute à l’angle de chaque point une valeur qui grandit avec le temps. Tous les points tournent ensemble, à SPIN tours par seconde. Le dessin passe dans la fonction du ticker, et l’angle de chaque point gagne SPIN * time tours :

import { Application, Graphics } from "pixi.js";

const app = new Application();
await app.init({ width: 640, height: 400, background: "#000000" });
document.body.append(app.canvas);
app.stage.scale.set(2);

const CX = 160, CY = 100;
const P = 90, R = 1;
const Z0 = 1, GAP = 0.6;
const RINGS = 16, DOTS = 16;
const LENGTH = RINGS * GAP;
const SPIN = 0.2;         // vitesse de rotation, en tours par seconde
const COLORS = [0xffffff, 0x0080ff, 0x000080, 0x000080];

const g = new Graphics();
app.stage.addChild(g);

let time = 0; // en secondes
app.ticker.add((ticker) => {
  time += ticker.deltaMS / 1000;
  g.clear();
  for (let i = 0; i < RINGS; i++) {
    const z = Z0 + i * GAP;
    const color = COLORS[Math.floor((z - Z0) / (LENGTH / 4))];
    for (let k = 0; k < DOTS; k++) {
      const angle = 2 * Math.PI * (k / DOTS + SPIN * time);
      const x = CX + (P * R * Math.cos(angle)) / z;
      const y = CY + (P * R * Math.sin(angle)) / z;
      g.rect(x, y, 1, 1).fill(color);
    }
  }
});

À essayer : passez SPIN à 1. Un tour par seconde, c’est déjà violent. Une valeur négative fait tourner le tunnel dans l’autre sens.

Avancer

Pour voler dans le tunnel, chaque anneau doit venir vers nous : sa profondeur diminue avec le temps, à SPEED unités par seconde. Mais un anneau qui passe devant l’œil doit repartir au fond, sinon le tunnel se vide. Un modulo s’en charge : la profondeur de l’anneau devient Z0 + mod(i * GAP - SPEED * time, LENGTH). Le modulo garde la distance à Z0 entre 0 et LENGTH, la longueur du tunnel. L’anneau qui atteint Z0 réapparaît donc à Z0 + LENGTH, tout au fond. Tous les anneaux sont identiques : l’œil ne voit pas le saut, et le tunnel paraît sans fin.

En JavaScript, % garde le signe du nombre de gauche : -1 % 10 vaut -1, pas 9. La fonction mod corrige ce point.

La couleur dépend toujours de z, et un anneau blanchit donc en approchant. Dans la boucle, seule la ligne de z change :

import { Application, Graphics } from "pixi.js";

const app = new Application();
await app.init({ width: 640, height: 400, background: "#000000" });
document.body.append(app.canvas);
app.stage.scale.set(2);

const CX = 160, CY = 100;
const P = 90, R = 1;
const Z0 = 1, GAP = 0.6;
const RINGS = 16, DOTS = 16;
const LENGTH = RINGS * GAP;
const SPIN = 0.2;
const SPEED = 7.5;        // vitesse d'avance, en unités de profondeur par seconde
const COLORS = [0xffffff, 0x0080ff, 0x000080, 0x000080];

// Modulo toujours positif : mod(-1, 10) vaut 9.
const mod = (a, n) => ((a % n) + n) % n;

const g = new Graphics();
app.stage.addChild(g);

let time = 0;
app.ticker.add((ticker) => {
  time += ticker.deltaMS / 1000;
  g.clear();
  for (let i = 0; i < RINGS; i++) {
    const z = Z0 + mod(i * GAP - SPEED * time, LENGTH);
    const color = COLORS[Math.floor((z - Z0) / (LENGTH / 4))];
    for (let k = 0; k < DOTS; k++) {
      const angle = 2 * Math.PI * (k / DOTS + SPIN * time);
      const x = CX + (P * R * Math.cos(angle)) / z;
      const y = CY + (P * R * Math.sin(angle)) / z;
      g.rect(x, y, 1, 1).fill(color);
    }
  }
});

À essayer : passez Z0 à 0,2. L’anneau le plus proche devient énorme, et la division par un z presque nul envoie ses points hors de l’écran. C’est pour cela que le tunnel commence à Z0 = 1, et pas à 0.

Déplacer les centres

Un tunnel droit devient vite monotone. Pour qu’il serpente, on décale le centre de chaque anneau : de ox à l’horizontale, de oy à la verticale. Chaque décalage suit un sinus. Le sinus vertical va deux fois plus vite que l’horizontal : pendant que ox fait un aller-retour, oy en fait deux. Le centre décrit une courbe de Lissajous. Le + 0.7 décale les deux sinus, pour que la courbe ne passe pas par le centre au même moment sur les deux axes. La phase u dit où en est l’anneau sur la courbe, en tours.

f grandit avec la distance : 0 pour l’anneau le plus proche, 1 au fond. Les anneaux proches restent centrés, les lointains s’écartent. Sans ce facteur, le tunnel entier glisserait sur l’écran, sans se courber.

Il reste à choisir la phase u. L’idée la plus simple la fait dépendre du temps seul : u = SPEED * time / CURVE, où CURVE est la longueur d’une période de la courbe. Tous les anneaux ont alors la même phase, et seul f change d’un anneau à l’autre. Les centres sont donc alignés sur une droite qui part du centre de l’écran. L’axe du tunnel reste droit et pivote autour du centre : le tunnel ne se courbe pas.

La bonne phase ajoute la distance de l’anneau : u = (z - Z0 + SPEED * time) / CURVE. Quand un anneau avance, z diminue exactement autant que SPEED * time grandit : la somme ne change pas. Chaque anneau garde donc sa phase pendant tout son trajet, comme s’il suivait un rail. Les anneaux voisins ont des phases voisines, et l’ensemble dessine un tube qui se tord devant nous. On a l’impression de suivre son axe.

CURVE vaut 64 * GAP : une période de la courbe couvre 64 anneaux. Deux anneaux voisins n’ont donc qu’un 64ᵉ de tour d’écart, et la courbe reste lisse. Les quatre lignes du décalage viennent juste avant la boucle des points :

import { Application, Graphics } from "pixi.js";

const app = new Application();
await app.init({ width: 640, height: 400, background: "#000000" });
document.body.append(app.canvas);
app.stage.scale.set(2);

const CX = 160, CY = 100;
const P = 90, R = 1;
const Z0 = 1, GAP = 0.6;
const RINGS = 16, DOTS = 16;
const LENGTH = RINGS * GAP;
const SPIN = 0.2, SPEED = 7.5;
const AX = 72, AY = 50;   // amplitude de la courbe, en pixels
const CURVE = 64 * GAP;   // longueur d'une période de la courbe
const COLORS = [0xffffff, 0x0080ff, 0x000080, 0x000080];

const mod = (a, n) => ((a % n) + n) % n;

const g = new Graphics();
app.stage.addChild(g);

let time = 0;
app.ticker.add((ticker) => {
  time += ticker.deltaMS / 1000;
  g.clear();
  for (let i = 0; i < RINGS; i++) {
    const z = Z0 + mod(i * GAP - SPEED * time, LENGTH);
    const color = COLORS[Math.floor((z - Z0) / (LENGTH / 4))];

    // Décalage du centre : nul tout près, maximal au fond.
    const f = (z - Z0) / LENGTH;
    const u = (z - Z0 + SPEED * time) / CURVE;
    const ox = AX * f * Math.sin(2 * Math.PI * u);
    const oy = AY * f * Math.sin(4 * Math.PI * u + 0.7);

    for (let k = 0; k < DOTS; k++) {
      const angle = 2 * Math.PI * (k / DOTS + SPIN * time);
      const x = CX + ox + (P * R * Math.cos(angle)) / z;
      const y = CY + oy + (P * R * Math.sin(angle)) / z;
      g.rect(x, y, 1, 1).fill(color);
    }
  }
});

À essayer :

Quantifier

L’effet est complet, mais il calcule des sinus, des cosinus et des divisions en virgule flottante, pour chaque point et à chaque image. Nos ordinosaures 8 bits ne savent pas manipuler des flottants de manière efficace : leurs processeurs ne connaissent que les entiers, et surtout les octets. Un calcul en flottants passe par des routines logicielles, bien trop lentes pour un effet en temps réel. Ils savent en revanche additionner, soustraire et comparer des octets très vite. L’effet doit donc passer en entiers.

La quantification prépare ce passage. Chaque valeur dont dépend un point devient un entier, qui ne prend qu’un petit nombre de valeurs : le temps se compte en images, la profondeur en positions, les angles en pas. Quand toutes les entrées sont des entiers bornés, on peut calculer à l’avance toutes les positions possibles, et les ranger dans des tables indexées par ces entiers. Le Z80 n’aura plus qu’à les lire.

L’image y perd un peu. La rotation avance par crans, la courbe par petits sauts, et chaque point se cale sur la grille des pixels. Le but est de garder ces pertes assez petites pour que l’effet reste fluide. Dans cette étape, le code garde ses sinus et ses flottants, sans tableau : seuls leurs arguments deviennent des entiers.

Le nombre 64 revient trois fois dans la liste ci-dessous : 64 profondeurs, 64 pas par tour, 64 phases. Ce choix a trois raisons :

  1. Le temps se compte en images : t est un entier, qui avance de 1 toutes les 20 ms. C’est le rythme d’un CPC, 50 images par seconde.
  2. La profondeur n’a plus que 64 positions : d va de 0 (tout près) à 63 (au fond), et z = Z0 + d * DZ, avec DZ = GAP / 4 = 0.15. Les anneaux sont à une position sur quatre. À l’image t, l’anneau i est à d = (4 * i - t) & 63 : il avance d’une position par image, et revient au fond après 0. & 63 garde les 6 bits du bas du nombre : c’est un modulo 64, même pour un nombre négatif.
  3. La rotation se compte en 64 pas par tour. Le point k est à l’angle 4 * k + rot, en pas. rot avance d’un pas toutes les 4 images : à 50 images par seconde, cela donne 0,195 tour par seconde, presque le SPIN de tout à l’heure. Une rotation de 4 pas amène chaque point à la place de son voisin, et l’anneau redevient identique. Il n’y a donc que 4 rotations différentes : rot = (t >> 2) & 3.
  4. La phase de la courbe devient phase = ((t + d) >> 2) & 63. C’est la phase u de l’étape précédente, comptée en 64ᵉ de tour. Deux anneaux voisins ont 4 positions d’écart, donc un pas de phase.
  5. Le facteur f passe à 16 niveaux : f = (d >> 2) / 15.
  6. La couleur se lit dans les deux bits du haut de d : COLORS[d >> 4]. Ce sont les quatre quarts du tunnel.
  7. Les positions sont arrondies au pixel, comme sur le CPC.

Il reste quatre entiers : d (64 valeurs), rot (4), k (16) et phase (64). L’étape suivante range tout le calcul dans des tables indexées par ces valeurs. En attendant, le playground calcule encore tout à chaque image, mais à partir de ces entiers :

import { Application, Graphics } from "pixi.js";

const app = new Application();
await app.init({ width: 640, height: 400, background: "#000000" });
document.body.append(app.canvas);
app.stage.scale.set(2);

const CX = 160, CY = 100;
const P = 90, R = 1;
const Z0 = 1, DZ = 0.15;  // z = Z0 + d * DZ
const RINGS = 16, DOTS = 16;
const AX = 72, AY = 50;
const COLORS = [0xffffff, 0x0080ff, 0x000080, 0x000080];

const g = new Graphics();
app.stage.addChild(g);

let time = 0;
app.ticker.add((ticker) => {
  time += ticker.deltaMS / 1000;
  const t = Math.floor(time * 50);       // numéro de l'image, 50 par seconde
  const rot = (t >> 2) & 3;              // rotation : 4 valeurs
  g.clear();
  for (let i = 0; i < RINGS; i++) {
    const d = (4 * i - t) & 63;          // profondeur : 64 valeurs
    const z = Z0 + d * DZ;
    const phase = ((t + d) >> 2) & 63;   // phase de la courbe : 64 valeurs
    const f = (d >> 2) / 15;             // 16 niveaux
    const ox = Math.round(AX * f * Math.sin((2 * Math.PI * phase) / 64));
    const oy = Math.round(AY * f * Math.sin((4 * Math.PI * phase) / 64 + 0.7));
    const color = COLORS[d >> 4];
    for (let k = 0; k < DOTS; k++) {
      const angle = (2 * Math.PI * (4 * k + rot)) / 64;
      const dx = Math.round((P * R * Math.cos(angle)) / z);
      const dy = Math.round((P * R * Math.sin(angle)) / z);
      g.rect(CX + ox + dx, CY + oy + dy, 1, 1).fill(color);
    }
  }
});

À essayer :

Des tables d’octets

Chaque point est maintenant la somme de deux morceaux indépendants. Le centre de l’anneau dépend du niveau d >> 2 et de phase. L’écart du point à ce centre dépend de la profondeur d, de la rotation rot et du numéro k. On calcule les deux tables une seule fois, au démarrage :

Une abscisse de 0 à 319 ne tient pas dans un octet. Mais le tunnel n’occupe pas toute la largeur de l’écran : le plus grand écart au centre est de 90 pixels, pour l’anneau le plus proche, et ses points vont de x = 70 à x = 250. Leurs abscisses tiennent donc dans un octet, et le centre dans un Uint8Array.

Dans la boucle, il ne reste ni sinus ni division, seulement deux lectures dans CENTER par anneau, puis deux lectures dans SHAPE et deux additions par point :

import { Application, Graphics } from "pixi.js";

const app = new Application();
await app.init({ width: 640, height: 400, background: "#000000" });
document.body.append(app.canvas);
app.stage.scale.set(2);

const CX = 160, CY = 100;
const P = 90, R = 1;
const Z0 = 1, DZ = 0.15;
const RINGS = 16, DOTS = 16;
const AX = 72, AY = 50;
const COLORS = [0xffffff, 0x0080ff, 0x000080, 0x000080];

// Écart (dx, dy) au centre, pour chaque profondeur d, rotation rot et point k.
const SHAPE = new Int8Array(64 * 4 * DOTS * 2);
for (let d = 0; d < 64; d++) {
  const s = (P * R) / (Z0 + d * DZ);
  for (let rot = 0; rot < 4; rot++) {
    for (let k = 0; k < DOTS; k++) {
      const angle = (2 * Math.PI * (4 * k + rot)) / 64;
      const n = ((d * 4 + rot) * DOTS + k) * 2;
      SHAPE[n] = Math.round(s * Math.cos(angle));
      SHAPE[n + 1] = Math.round(s * Math.sin(angle));
    }
  }
}

// Centre (x, y) de l'anneau, pour chaque niveau q = d >> 2 et chaque phase.
const CENTER = new Uint8Array(16 * 64 * 2);
for (let q = 0; q < 16; q++) {
  for (let phase = 0; phase < 64; phase++) {
    const n = (q * 64 + phase) * 2;
    CENTER[n] = CX + Math.round(((AX * q) / 15) * Math.sin((2 * Math.PI * phase) / 64));
    CENTER[n + 1] = CY + Math.round(((AY * q) / 15) * Math.sin((4 * Math.PI * phase) / 64 + 0.7));
  }
}

const g = new Graphics();
app.stage.addChild(g);

let time = 0;
app.ticker.add((ticker) => {
  time += ticker.deltaMS / 1000;
  const t = Math.floor(time * 50);
  const rot = (t >> 2) & 3;
  g.clear();
  for (let i = 0; i < RINGS; i++) {
    const d = (4 * i - t) & 63;
    const c = ((d >> 2) * 64 + (((t + d) >> 2) & 63)) * 2;
    const ringX = CENTER[c], ringY = CENTER[c + 1];
    const base = (d * 4 + rot) * DOTS * 2;
    const color = COLORS[d >> 4];
    for (let k = 0; k < DOTS; k++) {
      const x = ringX + SHAPE[base + 2 * k];
      const y = ringY + SHAPE[base + 2 * k + 1];
      g.rect(x, y, 1, 1).fill(color);
    }
  }
});

L’image est identique, pixel pour pixel, à celle de l’étape précédente. Mais la boucle ne fait plus que des additions d’octets et des lectures en mémoire : c’est un programme qu’un Z80 peut exécuter.

Conclusion

L’effet tient maintenant dans deux tables, 8 192 octets pour SHAPE et 2 048 pour CENTER, et dans une boucle qui ne fait que lire et additionner. La quantification a un prix, que les playgrounds laissent voir :

Le prochain article passera au CPC. Un programme en Locomotive BASIC y calculera les deux tables, avec les mêmes noms qu’ici, puis RASM les calculera à son tour, et une première version en Z80 dessinera enfin le tunnel.