Pular para o conteúdo
Biblioteca PAR-06

Forma ancorada

Malha de pontos presa a um desenho que você fornece: ela respira, o cursor a empurra e a forma nunca se perde.

usa o PAR-06
abrir sozinho ↗
Código
PAR-06 · alias forma-ancorada
Função
Partículas e motion de fundo
Fonte
OCTA · motion.js — cena forma-ancorada
Recebido
27/08/2026
Status
acervo
Licença
própria

O código

particulas-motor.js + forma-ancorada.js + markup.html 219 linhas · 3 arquivo(s) numa caixa só
/* ===== particulas-motor.js ========================================================= */

/* OCTA · motor de cenas de partículas — a base compartilhada pelos componentes PAR-*.
   Cena de three.js atrás de uma dobra, com física leve e o cursor mandando de verdade.

   Nasceu no site da primeira proposta da OCTA ao Pedro (27/08/2026), onde as seis cenas viviam
   num arquivo só. Aqui o motor está separado da cena: você inclui ESTE arquivo uma vez e, depois
   dele, só o arquivo da cena que for usar.

   Três regras de desempenho que não são opcionais:
     - **pausa fora da tela** (IntersectionObserver): cena invisível não queima bateria;
     - **DPR no teto de 2**: acima disso o ganho é invisível e o custo não;
     - **`prefers-reduced-motion` desenha UM quadro e para** — a cena existe, não se mexe.

   Configuração, no próprio <canvas>:
     data-cena="plexus"           qual cena (obrigatório)
     data-cor="#212121"           a cor dos nós e das linhas
     data-cor-destaque="#e8ad32"  o acento (algumas cenas usam)

   Exige three.js (r12x+) carregado antes. Sem ele, o canvas some em silêncio — a página não quebra.
*/
window.OctaParticulas = (function () {
  'use strict';

  var REDUZIR = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  var CELULAR = window.innerWidth < 700;
  var cenas = {};

  function textura(desenhar) {
    var cv = document.createElement('canvas');
    cv.width = cv.height = 64;
    desenhar(cv.getContext('2d'));
    return new THREE.CanvasTexture(cv);
  }

  var PONTO = null, ANEL = null;
  function texturas() {
    if (PONTO) { return; }
    PONTO = textura(function (g) { g.fillStyle = '#fff'; g.beginPath(); g.arc(32, 32, 30, 0, Math.PI * 2); g.fill(); });
    ANEL = textura(function (g) { g.strokeStyle = '#fff'; g.lineWidth = 7; g.beginPath(); g.arc(32, 32, 24, 0, Math.PI * 2); g.stroke(); });
  }

  function Cena(canvas) {
    texturas();
    this.canvas = canvas;
    this.tipo = canvas.getAttribute('data-cena');
    this.dobra = canvas.parentElement;
    this.COR = new THREE.Color(canvas.getAttribute('data-cor') || '#212121');
    this.DESTAQUE = new THREE.Color(canvas.getAttribute('data-cor-destaque') || '#e8ad32');
    this.PONTO = PONTO;
    this.ANEL = ANEL;
    this.CELULAR = CELULAR;

    this.renderer = new THREE.WebGLRenderer({ canvas: canvas, alpha: true, antialias: true });
    this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
    this.scene = new THREE.Scene();
    /* câmera ortográfica em PIXEL: x e y da cena são as coordenadas da tela, e a conta de
       física fica legível — sem conversão de espaço no meio do passo */
    this.camera = new THREE.OrthographicCamera(0, 1, 1, 0, -500, 500);
    this.mouse = { x: -9999, y: -9999 };
    this.t = 0;
    this.rodando = false;

    var eu = this;
    this.dobra.addEventListener('pointermove', function (e) {
      var r = eu.canvas.getBoundingClientRect();
      eu.mouse.x = e.clientX - r.left;
      eu.mouse.y = e.clientY - r.top;
    });
    this.dobra.addEventListener('pointerleave', function () { eu.mouse.x = -9999; eu.mouse.y = -9999; });
    window.addEventListener('resize', function () { eu.redimensionar(); });

    this.redimensionar();
    var montar = cenas[this.tipo];
    if (!montar) { throw new Error('cena desconhecida: ' + this.tipo); }
    montar.call(this);
    this.montada = true;

    if (REDUZIR) { this.passo(0.016); this.desenhar(); return; }
    if ('IntersectionObserver' in window) {
      new IntersectionObserver(function (en) {
        eu.rodando = en[0].isIntersecting;
        if (eu.rodando) { eu.laco(); }
      }, { rootMargin: '80px' }).observe(canvas);
    } else {
      this.rodando = true;
      this.laco();
    }
  }

  Cena.prototype.redimensionar = function () {
    var w = this.dobra.clientWidth, h = this.dobra.clientHeight;
    this.w = w; this.h = h;
    this.renderer.setSize(w, h, false);
    this.camera.right = w; this.camera.top = 0; this.camera.bottom = h; this.camera.left = 0;
    this.camera.updateProjectionMatrix();
    if (this.montada && this.aoRedimensionar) { this.aoRedimensionar(); }
  };
  Cena.prototype.laco = function () {
    if (!this.rodando || document.hidden) { return; }
    var eu = this;
    this.passo(0.016);
    this.desenhar();
    requestAnimationFrame(function () { eu.laco(); });
  };
  Cena.prototype.desenhar = function () { this.renderer.render(this.scene, this.camera); };
  Cena.prototype.passo = function (dt) { if (this.passoFn) { this.passoFn(dt); } };

  function iniciar() {
    if (!window.THREE) { return; }
    Array.prototype.forEach.call(document.querySelectorAll('canvas[data-cena]'), function (cv) {
      try { new Cena(cv); }
      catch (e) { cv.style.display = 'none'; }   // cena que falha some; a página segue
    });
  }

  if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', iniciar); }
  else { setTimeout(iniciar, 0); }               // dá um tique para os arquivos de cena se registrarem

  return {
    registrar: function (nome, montar) { cenas[nome] = montar; },
    Cena: Cena,
    celular: CELULAR,
    reduzir: REDUZIR
  };
})();


/* ===== forma-ancorada.js =========================================================== */

/* PAR-06 · Forma ancorada — uma malha de pontos presa a um desenho que você fornece: ela respira,
   o cursor a empurra, e a mola a devolve. A FORMA NUNCA SE PERDE (âncora firme, .07).
   Exige particulas-motor.js antes. data-cena="forma-ancorada"

   O desenho vem de window.OCTA_FORMA = { pts: [[x,y],…], edges: [[i,j],…], aspect: 0.6, linkR: 0.12 }
   com x e y normalizados de 0 a 1. Sem `edges`, as arestas saem por proximidade (linkR).
   Sem a forma, o componente não desenha nada — use o PAR-01 ancorado no lugar. */
(function () {
  'use strict';
  function ajustar() {
    var F = window.OCTA_FORMA;
    this.fW = this.w;
    this.fH = Math.min(this.h * .88, this.w * F.aspect);
    this.fY = (this.h - this.fH) / 2;
  }
  function montar() {
    var F = window.OCTA_FORMA;
    if (!F || !F.pts || !F.pts.length) { throw new Error('OCTA_FORMA ausente'); }
    this.ajustarForma = ajustar;
    ajustar.call(this);
    var n = F.pts.length, i, j;
    this.nos = [];
    for (i = 0; i < n; i++) { this.nos.push({ ox: 0, oy: 0, vx: 0, vy: 0 }); }
    if (F.edges) { this.arestas = F.edges; }
    else {
      this.arestas = [];
      for (i = 0; i < n; i++) {
        for (j = i + 1; j < n; j++) {
          var dx = F.pts[i][0] - F.pts[j][0], dy = (F.pts[i][1] - F.pts[j][1]) * F.aspect;
          if (Math.hypot(dx, dy) < (F.linkR || .12)) { this.arestas.push([i, j]); }
        }
      }
    }
    var pg = new THREE.BufferGeometry();
    pg.setAttribute('position', new THREE.BufferAttribute(new Float32Array(n * 3), 3));
    this.pts = new THREE.Points(pg, new THREE.PointsMaterial({ color: this.COR, size: this.CELULAR ? 6 : 9, transparent: true, opacity: .85, map: this.PONTO, alphaTest: .5 }));
    this.scene.add(this.pts);
    var lg = new THREE.BufferGeometry();
    lg.setAttribute('position', new THREE.BufferAttribute(new Float32Array(this.arestas.length * 6), 3));
    this.linhas = new THREE.LineSegments(lg, new THREE.LineBasicMaterial({ color: this.COR, transparent: true, opacity: .55 }));
    this.scene.add(this.linhas);
    var eu = this;
    this.passoFn = function (dt) { passo.call(eu, dt); };
    this.aoRedimensionar = function () { ajustar.call(this); };
  }
  function passo(dt) {
    this.t += dt;
    var F = window.OCTA_FORMA, n = this.nos.length, i;
    var pp = this.pts.geometry.attributes.position.array;
    for (i = 0; i < n; i++) {
      var p = this.nos[i];
      var hx = F.pts[i][0] * this.fW;
      var hy = this.fY + F.pts[i][1] * this.fH;
      var resp = Math.sin(this.t * .7 + i * 1.7) * 2.5;   // respiração sutil, forma preservada
      var x = hx + p.ox, y = hy + p.oy + resp;
      var dx = x - this.mouse.x, dy = y - this.mouse.y, d = Math.hypot(dx, dy);
      if (d < 140 && d > 1) { var f = (1 - d / 140) * 1.4; p.vx += (dx / d) * f; p.vy += (dy / d) * f; }
      p.vx += -p.ox * .07; p.vy += -p.oy * .07;           // âncora firme: a forma não se perde
      p.vx *= .86; p.vy *= .86;
      p.ox += p.vx; p.oy += p.vy;
      pp[i * 3] = hx + p.ox; pp[i * 3 + 1] = hy + p.oy + resp; pp[i * 3 + 2] = 0;
    }
    this.pts.geometry.attributes.position.needsUpdate = true;
    var lp = this.linhas.geometry.attributes.position.array;
    for (i = 0; i < this.arestas.length; i++) {
      var a = this.arestas[i][0], b = this.arestas[i][1];
      lp[i * 6] = pp[a * 3]; lp[i * 6 + 1] = pp[a * 3 + 1]; lp[i * 6 + 2] = 0;
      lp[i * 6 + 3] = pp[b * 3]; lp[i * 6 + 4] = pp[b * 3 + 1]; lp[i * 6 + 5] = 0;
    }
    this.linhas.geometry.attributes.position.needsUpdate = true;
  }
  OctaParticulas.registrar('forma-ancorada', montar);
})();


/* ===== markup.html ================================================================= */

<!-- PAR-06 · Forma ancorada. O <canvas> precisa de um PAI POSICIONADO com altura própria: a cena
     mede o pai, não o canvas. three.js vem antes; o motor antes da cena.
     data-cor e data-cor-destaque são opcionais (padrão: os do IDV da OCTA). -->
<div class="dobra" style="position:relative;min-height:60vh;overflow:hidden">
  <canvas data-cena="forma-ancorada" data-cor="#ebebeb" aria-hidden="true"
          style="position:absolute;inset:0;width:100%;height:100%"></canvas>
  <!-- o conteúdo da dobra vai aqui, com position:relative para ficar por cima -->
</div>

<script src="three.min.js"></script>
<script src="particulas-motor.js"></script>
<script src="forma-ancorada.js"></script>

Já rodou em

Ainda não rodou em peça publicada.

Exige

  • three.js (r12x+), carregado antes do motor

Como se configura

  • data-cena — qual cena montar (obrigatorio)
  • data-cor — a cor dos nos e das linhas (padrao #212121)
  • data-cor-destaque — o acento (padrao #e8ad32)

A ficha

O que o componente entrega

Exige: three.js (r12x+) carregado antes, e particulas-motor.js carregado antes da cena. O <canvas> precisa de um pai posicionado com altura própria — a cena mede o pai, não o canvas.

Variantes. A forma vem de window.OCTA_FORMA = { pts, edges, aspect, linkR }, com coordenadas de 0 a 1. Sem edges, as arestas saem por proximidade.

Três regras de desempenho que vêm do motor, e não são opcionais:

  • pausa fora da tela (IntersectionObserver com 80 px de folga): cena invisível não queima bateria;
  • DPR no teto de 2: acima disso o ganho é invisível e o custo não;
  • prefers-reduced-motion desenha um quadro e para — a cena existe, não se mexe.

A câmera é ortográfica em pixel: x e y da cena são as coordenadas da tela, o que deixa a conta de física legível, sem conversão de espaço no meio do passo.

Detalhes que importam:

  • A âncora é firme (mola .07 contra atrito .86): o cursor deforma, mas a silhueta se lê o tempo todo.
  • É o caminho para pôr qualquer vetor na tela como constelação — logo, ícone, mapa.

O que a adaptação da OCTA mudou

  • O motor saiu do arquivo da cena. No site de origem as seis cenas viviam num motion.js de 444 linhas; aqui você inclui o motor uma vez e, depois dele, só a cena que for usar.
  • A cor deixou de ser constante no código e virou data-cor / data-cor-destaque no <canvas>.
  • A cena que falha some em silêncio e a página segue — o erro não derruba a dobra.

Lacunas

  • Construído e nunca publicado. No site de origem a dobra em que ele entraria acabou usando o plexus-ancorada. O código rodou em desenvolvimento; não há prova dele em produção.
  • Sem window.OCTA_FORMA ele não desenha nada. Na origem havia um fallback silencioso para o plexus ancorado — aqui ele falha explícito, porque fallback silencioso esconde erro de integração.
  • Extrair os pontos de um vetor é trabalho manual: não há ferramenta na casa que faça isso.